Úvodem musím uvést jedno důležité upozornění: Pokud dokonale rozumíte jak funguje ViewState a jaký je životní cyklus stránky ASP.NET WebForms aplikace, tak v tomto článku asi nenajdete žádnou pro vás novou informaci.
V tomto článku předpokládejme následující příklad. Máme ASP.NET WebForms aplikaci se stránkou pro výpis rezervací filtrovaných podle vybrané lokality a týdne. Stránka bude konkrétně obsahovat DropDownList s výběrem lokality, textbox pro výběr data začátku týdne, tlačítka pro změnu na předchozí a následující týden a tlačítko Obnovit pro potvrzení změny hodnoty textboxu. Výber filtrů bude vypadat takto:
V reálné aplikaci budou pod tímto výběrem zobrazovaná filtrovaná data, v našem příkladu bude pro jednoduchost pouze prvek Literal, do kterého aktuální nastavení filtrů vypíšeme.
Naivní implementace této stránky Rezervace.aspx by mohla vypadat nějak takto:
<%@ Page Title="Rezervace" Language="C#" MasterPageFile="~/Pages/Site.Master" CodeBehind="Rezervace.aspx.cs" Inherits="RezervaceWeb.Rezervace" ViewStateMode="Disabled" %>
<asp:Content runat="server" ContentPlaceHolderID="MainContent">
<div class="rezervace-controls">
<asp:Label runat="server" Text="Lokalita:" AssociatedControlID="cboLokalita" />
<asp:DropDownList ID="cboLokalita" runat="server" CssClass="form-control" AutoPostBack="True" SelectMethod="GetLokality" DataValueField="IDLokality" DataTextField="Nazev" OnSelectedIndexChanged="cboLokalita_SelectedIndexChanged" />
</div>
<div class="clearfix"></div>
<div class="rezervace-controls">
<asp:Label runat="server" Text="Týden od:" />
<asp:Button ID="PreviousWeekButton" runat="server" Text="<" CssClass="btn btn-default" CausesValidation="false" OnClick="PreviousWeekButton_Click" />
<asp:TextBox ID="txtDatumOd" runat="server" CssClass="form-control datepicker" />
<asp:Button ID="NextWeekButton" runat="server" Text=">" CssClass="btn btn-default" CausesValidation="false" OnClick="NextWeekButton_Click" />
<asp:Button ID="btnRefresh" runat="server" Text="Obnovit" CssClass="btn btn-default" CausesValidation="false" OnClick="btnRefresh_Click" />
</div>
<asp:Literal ID="RezervaceFilter" runat="server" />
</asp:Content>
Stránka se odkazuje na master page umístěnou v souboru Pages/Site.Master (její obsah může být libovolný, pouze musí obsahovat kontejner “MainContent”).
Dále potřebujeme pomocnou třídu po lokalitu:
namespace RezervaceWeb.Data.Models
{
[System.Diagnostics.DebuggerDisplay("\\{ IDLokality = {IDLokality}, Nazev = {Nazev} \\}")]
public sealed class Lokalita
{
#region member varible and default property initialization
public int IDLokality { get; set; }
public string Nazev { get; set; }
#endregion
}
}
A konečně codebehind stránky Rezervace.aspx.cs je:
using System;
using System.Collections.Generic;
using RezervaceWeb.Data.Models;
namespace RezervaceWeb
{
public partial class Rezervace : System.Web.UI.Page
{
#region action methods
public IEnumerable<Lokalita> GetLokality()
{
return new[] { new Lokalita() { IDLokality = 1, Nazev = "Lokalita 1" }, new Lokalita() { IDLokality = 2, Nazev = "Lokalita 2" }, new Lokalita() { IDLokality = 3, Nazev = "Lokalita 3" } };
}
#endregion
#region property getters/setters
private int? IDLokality
{
get
{
return (int?)this.Session["Rezervace_IDLokality"];
}
set
{
this.Session["Rezervace_IDLokality"] = value;
}
}
private DateTime DatumOd
{
get
{
DateTime? datumOd = (DateTime?)this.Session["Rezervace_DatumOd"];
if (datumOd == null)
{
//Výchozí datum od
datumOd = GetFirstDayOfWeek(DateTime.Today);
}
return datumOd.Value;
}
set
{
this.Session["Rezervace_DatumOd"] = value;
}
}
#endregion
#region private member functions
protected void Page_Load()
{
if (!IsPostBack)
{
cboLokalita.DataBind();
if (this.IDLokality != null)
{
cboLokalita.SelectedValue = this.IDLokality.ToString();
}
InitRezervace();
return;
}
this.IDLokality = Int32.Parse(cboLokalita.SelectedValue);
DateTime datumOd;
if (DateTime.TryParse(txtDatumOd.Text, System.Globalization.CultureInfo.CurrentCulture, System.Globalization.DateTimeStyles.None, out datumOd))
{
this.DatumOd = GetFirstDayOfWeek(datumOd);
}
InitRezervace();
}
protected void cboLokalita_SelectedIndexChanged(object sender, EventArgs e)
{
Response.Redirect(Request.RawUrl);
}
protected void PreviousWeekButton_Click(object sender, EventArgs e)
{
this.DatumOd = this.DatumOd.AddDays(-7);
Response.Redirect(Request.RawUrl);
}
protected void NextWeekButton_Click(object sender, EventArgs e)
{
this.DatumOd = this.DatumOd.AddDays(7);
Response.Redirect(Request.RawUrl);
}
protected void btnRefresh_Click(object sender, EventArgs e)
{
Response.Redirect(Request.RawUrl);
}
private void InitRezervace()
{
txtDatumOd.Text = this.DatumOd.ToShortDateString();
RezervaceFilter.Text = string.Format("IDLokality: {0}, DatumOd: {1}", Int32.Parse(cboLokalita.SelectedValue), this.DatumOd.ToShortDateString());
}
private static DateTime GetFirstDayOfWeek(DateTime value)
{
int offset = (value.DayOfWeek - System.Globalization.DateTimeFormatInfo.CurrentInfo.FirstDayOfWeek + 7) % 7;
return value.AddDays(-offset);
}
#endregion
}
}
Na první pohled to možná i vypadá, že by stránka takto mohla fungovat. V kódu si všimněte několik věcí.
- Stránka si udržuje hodnoty aktuálně zvolených filtrů tj. aktuální hodnoty IDLokality a DatumOd. Zde si je udržuje konkrétně pouze v (in-process) session což může i nemusí být v pořádku (aplikace bude nasazena pouze v jedné instanci a nejedná se o žádný kritický stav tj. nevadí, že o toto nastavení uživatel při “náhodném” restartu aplikace přijde). V reálné aplikaci ale nic nebrání tomu, aby se tento stav ukládal podle ID přihlášeného uživatele do databáze.
- Aplikace využívá tzv. Post-Redirect-Get pattern tj. způsob, kdy po zpracování postbacku je proveden redirect na tu samou stránku. Výsledkem je, že stránka je opět vyrenderovaná GET requestem, takže nejsou problémy s prováděním refresh (F5) v prohlížeči.
- Pro detekci změny filtrů se používají událostí Click tlačítek pro změnu týdne a obnovení stránky a událost SelectedIndexChanged u prvku DropDownList lokality.
Takto implementovaná stránka má ale jeden zásadní problém: Stránka nefunguje, protože má vypnutý ViewState.
(Viewstate vypínáme na prvním řádku souboru Rezervace.aspx pomoci ViewStateMode="Disabled".)
Pokud by měla stránka viewstate zapnutý, vše bude fungovat přesně tak jak má, my se zde ale snažíme implementovat požadovanou funkcionalitu této stránky bez využití viewstate (protože jak asi tušíte nebo za chvilku uvidíte, viewstate by pro tuto funkcionalitu neměl být potřeba a jeho vypnutím snížíme obsah přenášených dat při stahování stránky).
A v čem konkrétně je problém?
Problém je v tom, že stránka používá událost SelectedIndexChanged u serverového prvku DropDownList. Tato událost má být vyvolána při provedení změny vybraného prvku v DropDownListu a následném provedení postbacku (postback se zde provede hned, díky nastavení AutoPostBack="True"). “Provedení změny” ale znamená, že se při postbacku musí znát původní a nová hodnota, ty se navzájem porovnají a pokud jsou různé, událost se vyvolá (a jinak ne). Nová hodnota je vcelku jasná, ta přichází jako součást formulářových dat v POST requestu. Co ale původní hodnota? Jako původní hodnotu WebForms chápou hodnotu, kterou si uložili do viewstate při předchozím renderování stránky, a která je při postbacku na server vrácena. A to je ten problém, tato hodnota se při vypnutém viewstate do stránky neukládá a proto se nezná.
WebForms v tomto případě jednoduše použijí hodnotu SelectedIndex 0, čemuž přesně odpovídá chování chybné implementace. Pokud měníme filtr na jinou než první položku, změna filtru zafunguje, pokud ale měníme filtr z libovolné položky na položku první, není událost SelectedIndexChanged vyvolána, změna filtru se sice provede, ale neprovede se redirect a stránka bude vyrenderovaná přímo tímto POST requestem (tj. při F5 bude prohlížeč zobrazovat dotaz na opakované odeslání POST dat).
S vypnutým viewstate událost SelectedIndexChanged tedy nikdy fungovat nebude a nelze jí proto použít.
Co my zde ale konkrétně potřebujeme? Naše původní hodnota nastavení filtru je hodnota, kterou server zná jako hodnotu, kterou lze získat pomoci vlastnosti IDLokality (nastavení ze session případně z databáze). Protože ale o této hodnotě WebForms obecně nevědí, museli by jsme si porovnání původní a nové hodnoty provádět sami (výrazem this.IDLokality != Int32.Parse(cboLokalita.SelectedValue)). Zde je ale další problém v tom, že tato hodnota nemusí být ještě správně zinicializovaná, takže by jsme v takovém případě museli ještě zjišťovat zda není index vybrané položky filtru větší než 0 (protože výchozí filtr odpovídá první položce) nebo něco podobného.
My na to půjdeme ale úplně jinak. Protože naše stránka používá Post-Redirect-Get pattern, budeme redirect při postbacku provádět jednoduše vždy a změnu filtrů nebudeme vůbec zjišťovat. Aby se ale předtím správně zpracovali například události tlačítek na změnu týdne, budeme tak provádět až ve fázi PreRender stránky. Zpracování POST requestu tedy proběhne takto:
- V Page_Load nastavíme filtr na aktuální “nové” hodnoty (které přišli jako formulářová data).
- Necháme zpracovat události serverových prvků.
- Nakonec v Page_PreRender provedeme redirect.
Pojďme tedy nyní implementaci stránky spravit.
Jednak v souboru Rezervace.aspx nebudeme u prvku DropDownList a tlačítka btnRefresh žádnou událost registrovat:
<div class="rezervace-controls">
<asp:Label runat="server" Text="Lokalita:" AssociatedControlID="cboLokalita" />
<asp:DropDownList ID="cboLokalita" runat="server" CssClass="form-control" AutoPostBack="True" SelectMethod="GetLokality" DataValueField="IDLokality" DataTextField="Nazev" />
</div>
<div class="clearfix"></div>
<div class="rezervace-controls">
<asp:Label runat="server" Text="Týden od:" />
<asp:Button ID="PreviousWeekButton" runat="server" Text="<" CssClass="btn btn-default" CausesValidation="false" OnClick="PreviousWeekButton_Click" />
<asp:TextBox ID="txtDatumOd" runat="server" CssClass="form-control datepicker" />
<asp:Button ID="NextWeekButton" runat="server" Text=">" CssClass="btn btn-default" CausesValidation="false" OnClick="NextWeekButton_Click" />
<asp:Button ID="btnRefresh" runat="server" Text="Obnovit" CssClass="btn btn-default" CausesValidation="false" />
</div>
A jednak musíme upravíme codebehind této stránky. Upravený soubor Rezervace.aspx.cs zde raději pro přehlednost uvedu celý:
using System;
using System.Collections.Generic;
using RezervaceWeb.Data.Models;
namespace RezervaceWeb
{
public partial class Default : System.Web.UI.Page
{
#region action methods
public IEnumerable<Lokalita> GetLokality()
{
return new[] { new Lokalita() { IDLokality = 1, Nazev = "Lokalita 1" }, new Lokalita() { IDLokality = 2, Nazev = "Lokalita 2" }, new Lokalita() { IDLokality = 3, Nazev = "Lokalita 3" } };
}
#endregion
#region property getters/setters
private int? IDLokality
{
get
{
return (int?)this.Session["Rezervace_IDLokality"];
}
set
{
this.Session["Rezervace_IDLokality"] = value;
}
}
private DateTime DatumOd
{
get
{
DateTime? datumOd = (DateTime?)this.Session["Rezervace_DatumOd"];
if (datumOd == null)
{
//Výchozí datum od
datumOd = GetFirstDayOfWeek(DateTime.Today);
}
return datumOd.Value;
}
set
{
this.Session["Rezervace_DatumOd"] = value;
}
}
#endregion
#region private member functions
protected void Page_Load()
{
if (!IsPostBack)
{
cboLokalita.DataBind();
if (this.IDLokality != null)
{
cboLokalita.SelectedValue = this.IDLokality.ToString();
}
InitRezervace();
return;
}
this.IDLokality = Int32.Parse(cboLokalita.SelectedValue);
DateTime datumOd;
if (DateTime.TryParse(txtDatumOd.Text, System.Globalization.CultureInfo.CurrentCulture, System.Globalization.DateTimeStyles.None, out datumOd))
{
this.DatumOd = GetFirstDayOfWeek(datumOd);
}
}
protected void Page_PreRender()
{
if (IsPostBack)
{
Response.Redirect(Request.RawUrl);
}
}
protected void PreviousWeekButton_Click(object sender, EventArgs e)
{
this.DatumOd = this.DatumOd.AddDays(-7);
}
protected void NextWeekButton_Click(object sender, EventArgs e)
{
this.DatumOd = this.DatumOd.AddDays(7);
}
protected void InitRezervace()
{
txtDatumOd.Text = this.DatumOd.ToShortDateString();
RezervaceFilter.Text = string.Format("IDLokality: {0}, DatumOd: {1}", Int32.Parse(cboLokalita.SelectedValue), this.DatumOd.ToShortDateString());
}
private static DateTime GetFirstDayOfWeek(DateTime value)
{
int offset = (value.DayOfWeek - System.Globalization.DateTimeFormatInfo.CurrentInfo.FirstDayOfWeek + 7) % 7;
return value.AddDays(-offset);
}
#endregion
}
}
Tato verze stránky, která událost SelectedIndexChanged nepoužívá, již bude fungovat korektně a přitom viewstate ke své činnosti nepotřebuje.