State Management in ASP.NET 2.0
- Cross-Page Posting
- Wizard and MultiView Controls
- Profile
- Summary
WHERE DO YOU STORE per-client state in a Web application? This question is at the root of many heated debates over how to best design Web applications. The disconnected nature of HTTP means that there is no "natural" way to keep state on behalf of individual clients, but that certainly hasn't stopped developers from finding ways of doing it. Today there are many choices for keeping client-specific state in an ASP.NET Web application, including Session state, View state, cookies, the HttpContext.Items collection, and any number of custom solutions. The best choice depends on many things, including the scope (Do you need the state to last for an entire user session or just between two pages?), the size (Are you worried about passing too much data in the response and would prefer to keep it on the server?), and the deployment environment (Is this application deployed on a Web farm so that server state must be somehow shared?), just to name a few.
ASP.NET 2.0 does not offer a penultimate solution for storing client state, but it does introduce three new features that should be considered any time you are looking for a place to store state on behalf of individual users. The first feature, cross-page posting, is actually the resurrection of a common technique used in classic ASP and other Web development environments for propagating state between two pages. This technique was not available in ASP.NET 1.1 because of the way POST requests were parsed and processed by individual pages, but has now been reincorporated into ASP.NET in such a way that it works in conjunction with server-side controls and other ASP.NET features. The second feature is a trio of new server-side controls that implement the common technique of showing and hiding portions of a page as the user interacts with it. The Wizard control gives developers a simple way to construct a multistep user interface on a single page, and the MultiView and View controls provide a slightly lower-level (and more flexible) way of hiding and displaying panes.
The last feature, Profile, is by far the most intriguing. Profile provides a prebuilt implementation that will store per-client state across requests and even sessions of your application in a persistent back-end data store. It ties into the Membership provider of ASP.NET 2.0 for identifying authenticated clients, and generates its own identifier for working with anonymous users as well, storing each client's data in a preconfigured database table. This feature provides a flexible and extensible way of storing client data and should prove quite useful in almost any ASP.NET application.
Cross-Page Posting
This version of ASP.NET reintroduces the ability to perform cross-page posts. Once a common practice in classic ASP applications, ASP.NET 1.x made it nearly impossible to use this technique for state propagation because of server-side forms and view state. This section covers the fundamentals of cross-page posting in general, and then looks at the support added in ASP.NET 2.0.
Fundamentals
One common mechanism for sending state from one page to another in Web applications is to use a form with input elements whose action attribute is set to the URL or the target page. The values of the source page's input elements are passed as name-value pairs to the target page in the body of the POST request (or in the query string if the form's method attribute is set to GET), at which point the target page has access to the values. Listings 4-1 and 4-2 show a pair of sample pages that request a user's name, age, and marital status, and display a customized message on the target page.
Listing 4-1. sourceform.aspx—sample form using a cross-page post
<!-- sourceform.aspx --> <%@ Page language="C#" %> <html xmlns="http://www.w3.org/1999/xhtml"> <head> <title>Source Form</title> </head> <body> <form action="target.aspx" method="post"> Enter your name: <input name="_nameTextBox" type="text" id="_nameTextBox" /> <br /> Enter your age: <input name="_ageTextBox" type="text" id="_ageTextBox" /><br /> <input id="_marriedCheckBox" type="checkbox" name="_marriedCheckBox" /> <label for="_marriedCheckBox">Married?</label><br /> <input type="submit" name="_nextPageButton" value="Next page" /> </form> </body> </html>
Listing 4-2. target.aspx—sample target page for a cross-page post
<!-- target.aspx --> <%@ Page language="C#" %> <html xmlns="http://www.w3.org/1999/xhtml" > <head> <title>Target Page</title> </head> <body> <h3> Hello there <%= Request.Form["_nameTextBox"] %>, you are <%= Request.Form["_ageTextBox"] %> years old and are <%= (Request.Form["_marriedCheckBox"] == "on") ? "" : "not " %> married! </h3> </body> </html>
This example works fine in both ASP.NET 1.1 and 2.0, and with a few simple modifications would even work in classic ASP. This technique is rarely used in ASP.NET, however, because the form on the source page cannot be marked with runat="server"; thus, many of the advantages of ASP.NET, including server-side controls, cannot be used. ASP.NET builds much of its server-side control infrastructure on the assumption that pages with forms will generate POST requests back to the same page. In fact, if you try and change the action attribute of a form that is also marked with runat="server", it will have no effect, as ASP.NET will replace the attribute when it renders the page with the page's URL itself. As a result, most ASP.NET sites resort to alternative techniques for propagating state between pages (like Session state or using Server.Transfer while caching data in the Context.Items collection).
In the 2.0 release of ASP.NET, cross-page posting is now supported again, even if you are using server-side controls and all of the other ASP.NET features. The usage model is a bit different from the one shown in Listings 4-1 and 4-2, but in the end it achieves the desired goal of issuing a POST request from one page to another, and allowing the secondary page to harvest the contents from the POST body and process them as it desires. To initiate a cross-page post, you use the new PostBackUrl attribute defined by the IButtonControl interface, which is implemented by the Button, LinkButton, and ImageButton controls. When the PostBackUrl property is set to a different page, the OnClick handler of the button is set to call a JavaScript function that changes the default action of the form to the target page's URL. Listing 4-3 shows a sample form that uses cross-page posting to pass name, age, and marital status data entered by the user to a target page.
Listing 4-3. SourcePage1.aspx—using cross-page posting support in ASP.NET 2.0
<!-- SourcePage1.aspx --> <%@ Page Language="C#" CodeFile="SourcePage1.aspx.cs" Inherits="SourcePage1" %> <html xmlns="http://www.w3.org/1999/xhtml"> <head runat="server"> <title>Source page 1</title> </head> <body> <form id="form1" runat="server"> <div> Enter your name: <asp:TextBox ID="_nameTextBox" runat="server" /><br /> Enter your age: <asp:TextBox ID="_ageTextBox" runat="server" /><br /> <asp:CheckBox ID="_marriedCheckBox" runat="server" Text="Married?" /><br /> <asp:Button ID="_nextPageButton" runat="server" Text="Next page" PostBackUrl="~/TargetPage.aspx" /> </div> </form> </body> </html>
Once you have set up the source page to post to the target page, the next step is to build the target page to use the values passed by the source page. Because ASP.NET uses POST data to manage the state of its server-side controls, it would not have been sufficient to expect the target page to pull name/value pairs from the POST body, since many of those values (like __VIEWSTATE) need to be parsed by the server-side controls that wrote the values there in the first place. Therefore, ASP.NET will actually create a fresh instance of the source page class and ask it to parse the POST body on behalf of the target page. This page instance is then made available to the target page via the PreviousPage property, which is now defined in the Page class. Listings 4-4 and 4-5 show one example of how you could use this property in a target page to retrieve the values of the controls from the previous page: by calling FindControl on the Form control, you can retrieve individual controls whose state has been initialized with values from the post's body.
Listing 4-4. TargetPage.aspx—target page of a cross-page post
<!-- TargetPage.aspx --> <%@ Page Language="C#" AutoEventWireup="true" CodeFile="TargetPage.aspx.cs" Inherits="TargetPage" %> <html xmlns="http://www.w3.org/1999/xhtml"> <head runat="server"> <title>Target Page</title> </head> <body> <form id="form1" runat="server"> <div> <asp:Label runat="server" ID="_messageLabel" /> </div> </form> </body> </html>
Listing 4-5. TargetPage.aspx.cs—target page of a cross-page post codebehind
// TargetPage.aspx.cs public partial class TargetPage : System.Web.UI.Page { protected void Page_Load(object sender, EventArgs e) { if (PreviousPage != null) { TextBox nameTextBox = (TextBox)PreviousPage.Form.FindControl("_nameTextBox"); TextBox ageTextBox = (TextBox)PreviousPage.Form.FindControl("_ageTextBox"); CheckBox marriedCheckBox = (CheckBox)PreviousPage.Form.FindControl("_marriedCheckBox"); _messageLabel.Text = string.Format( "<h3>Hello there {0}, you are {1} years old and {2} married!</h3>", nameTextBox.Text, ageTextBox.Text, marriedCheckBox.Checked ? "" : "not"); } } }
The technique shown in Listing 4-5 for retrieving values from the previous page is somewhat fragile, as it relies on the identifiers of controls on the previous page as well as their hierarchical placement, which could easily be changed. A better approach is to expose any data from the previous page to the target page by writing public property accessors in the codebehind, as shown in Listing 4-6.
Listing 4-6. SourcePage1.aspx.cs—exposing public properties to the target page
// File: SourcePage1.aspx.cs public partial class SourcePage1 : Page { public string Name { get { return _nameTextBox.Text; } } public int Age { get { return int.Parse(_ageTextBox.Text); } } public bool Married { get { return _marriedCheckBox.Checked; } } }
Once the public properties are defined, the target page can cast the PreviousPage property to the specific type of the previous page and retrieve the values using the exposed properties, as shown in Listing 4-7.
Listing 4-7. TargetPage.aspx.cs—target page using properties to retrieve source page values
// TargetPage.aspx.cs public partial class TargetPage : System.Web.UI.Page { protected void Page_Load(object sender, EventArgs e) { SourcePage1 sp = PreviousPage as SourcePage1; if (sp != null) { _messageLabel.Text = string.Format( "<h3>Hello there {0}, you are {1} years old and {2} married!</h3>", sp.Name, sp.Age, sp.Married ? "" : "not"); } } }
Because this last scenario is likely to be the most common use of cross-page posting—that is, a specific source page exposes properties to be consumed by a specific target page—there is a directive called PreviousPageType that will automatically cast the previous page to the correct type for you. When you specify a page in the VirtualPath property of this directive, the PreviousPage property that is generated for that page will be strongly typed to the previous page type, meaning that you no longer have to perform the cast yourself, as shown in Listings 4-8 and 4-9.
Listing 4-8. TargetPage.aspx with strongly typed previous page
<!-- TargetPage.aspx --> <%@ Page Language="C#" AutoEventWireup="true" CodeFile="TargetPage.aspx.cs" Inherits="TargetPage" %> <%@ PreviousPageType VirtualPath="~/SourcePage1.aspx" %> ...
Listing 4-9. TargetPage.aspx.cs—using strongly typed PreviousPage accessor
// TargetPage.aspx.cs public partial class TargetPage : System.Web.UI.Page { protected void Page_Load(object sender, EventArgs e) { if (PreviousPage != null) { _messageLabel.Text = string.Format( "<h3>Hello there {0}, you are {1} years old and {2} married!</h3>", PreviousPage.Name, PreviousPage.Age, PreviousPage.Married ? "" : "not"); } } }
Implementation
When you set the PostBackUrl property of a button to a different page, it does two things. First, it sets the client-side OnClick handler for that button to point to a JavaScript method called WebForm_DoPostBackWithOptions, which will programmatically set the form's action to the target page. Second, it causes the page to render an additional hidden field, __PREVIOUSPAGE, which contains the path of the source page in an encrypted string along with an accompanying message authentication code for validating the string. Setting the action dynamically like this enables you to have multiple buttons on a page that all potentially post to different pages and keeps the architecture flexible. Storing the path of the previous page in a hidden field means that no matter where you send the POST request, the target page will be able to determine where the request came from, and will know which class to instantiate to parse the body of the message.
Once the POST request is issued to the target page, the path of the previous page is read and decrypted from the __PREVIOUSPAGE hidden field and cached. As you have seen, the PreviousPage property on the target page gives access to the previous page and its data, but for efficiency, this property allocates the previous page class on demand. If you never actually access the PreviousPage property, it will never create the class and ask it to parse the body of the request.
The first time you do access the PreviousPage property in the target page, ASP.NET allocates a new instance of the previous page type, as determined by the cached path to the previous page extracted from the __PREVIOUSPAGE hidden field. Once it is created, it then executes the page much like it would if the request had been issued to it. The page is not executed in its entirety, however, since it only needs to restore the state from the POST body, so it runs through its life cycle up to and including the LoadComplete event. The Response and Trace objects of the previous page instance are also set to null during this execution since there should be no output associated with the process.
It is important to keep in mind that the preceding page will be created and asked to run through LoadComplete. If you have any code that generates side effects, you should make an effort to exclude that code from running when the page is executed during a cross-page postback. You can check to see whether you are being executed for real or for the purpose of evaluating the POST body of a cross-page post by checking the IsCrossPagePostBack property. For example, suppose that the source page wrote to a database in its Load event handler for logging purposes. You would not want this code to execute during a cross-page postback evaluation since the request was not really made to that page. Listing 4-10 shows how you might exclude your logging code from being evaluated during a cross-page postback.
Listing 4-10. Checking for IsCrossPagePostBack before running code with side effects
public partial class SourcePage1 : Page { protected void Page_Load(object sender, EventArgs e) { if (!IsCrossPagePostBack) { WriteDataToLogFile(); } } }
Caveats
While this new support for cross-page posting is a welcome addition to ASP.NET, it does have some potential drawbacks you should be aware of before you elect to use it. The first thing to keep in mind is that the entire contents of the source page is going to be posted to the target page. This includes the entire view state field and all input elements on the page. If you are using cross-page posting to send the value of a pair of TextBox controls to a target page, but you have a GridView with view state enabled on the source page, you're going to incur the cost of posting the entire contents of the GridView in addition to the TextBox controls just to send over a pair of strings. If you can't reduce the size of the request on the source page to an acceptable amount, you may want to consider using an alternative technique (like query strings) to propagate the values.
Validation is another potential trouble area with cross-page posting. If you are using validation controls in the client page to validate user input prior to the cross-page post, you should be aware that server-side validation will not take place until you access the PreviousPage property on the target page. Client-side validation will still happen as usual before the page issues the POST, but if you are relying on server-side validation at all, you must take care to check the IsValid property of the previous page before accessing the data exposed by the PreviousPage property.
A common scenario where this may occur is with custom validation controls. If you have set up a custom validation control with a server-side handler for the ServerValidate event, that method will not be called until you access the PreviousPage after the cross-page posting has occurred. Then there is the question of what to do if the previous page contains invalid data, since you can no longer just let the page render back to the client with error messages in place (because the client has already navigated away from the source page). The best option is probably just to place an indicator message that the data is invalid and provide a link back to the previous page to enter the data again. Listings 4-11 and 4-12 show a sample of a source page with a custom validation control and a button set up to use cross-page posting, along with a target page. Note that the code in the target page explicitly checks the validity of the previous page's data before using it and the error handling added if something is wrong.
Listing 4-11. Source page with custom validator
<!-- SourcePageWithValidation.aspx --> <%@ Page Language="C#" %> <script runat="server"> public int Prime { get { return int.Parse(_primeNumberTextBox.Text); } } private bool IsPrime(int num) { // implementation omitted } protected void _primeValidator_ServerValidate(object source, ServerValidateEventArgs args) { args.IsValid = IsPrime(Prime); } </script> <html xmlns="http://www.w3.org/1999/xhtml"> <head runat="server"> <title>Source page with validation</title> </head> <body> <form id="form1" runat="server"> <div> Enter your favorite prime number: <asp:TextBox ID="_primeNumberTextBox" runat="server" /> <asp:CustomValidator ID="_primeValidator" runat="server" ErrorMessage="Please enter a prime number" OnServerValidate="_primeValidator_ServerValidate"> **</asp:CustomValidator><br /> <asp:Button ID="_nextPageButton" runat="server" Text="Next page" PostBackUrl="~/TargetPageWithValidation.aspx" /><br /> <br /> <asp:ValidationSummary ID="_validationSummary" runat="server" /> </div> </form> </body> </html>
Listing 4-12. Target page checking for validation
<!-- TargetPageWithValidation.aspx --> <%@ Page Language="C#" %> <%@ PreviousPageType VirtualPath="~/SourcePageWithValidation.aspx" %> <script runat="server"> protected void Page_Load(object sender, EventArgs e) { if (PreviousPage != null && PreviousPage.IsValid) { _messageLabel.Text = "Thanks for choosing the prime number " + PreviousPage.Prime.ToString(); } else { _messageLabel.Text = "Error in entering data"; _messageLabel.ForeColor = Color.Red; _previousPageLink.Visible = true; } } </script> <html xmlns="http://www.w3.org/1999/xhtml"> <head runat="server"> <title>Target Page With validation</title> </head> <body> <form id="form1" runat="server"> <div> <asp:Label runat="server" ID="_messageLabel" /><br /> <asp:HyperLink runat="server" ID="_previousPageLink" NavigateUrl="~/SourcePageWithValidation.aspx" visible="false"> Return to data entry page...</asp:HyperLink> </div> </form> </body> </html>
Finally, it is important to be aware that the entire cross-page posting mechanism relies on JavaScript to work properly, so if the client either doesn't support or has disabled JavaScript, your source pages will simply post back to themselves as the action on the form will not be changed on the client in response to the button press.
Multi-Source Cross-Page Posting
Cross-page posting can also be used to create a single target page that can be posted to by multiple source pages. Such a scenario may be useful if you have a site that provides several different ways of collecting information from the user but one centralized page for processing it.
If we try and extend our earlier example by introducing a second source page, also with the ability to collect the name, age, and marital status of the client, we run into a problem because each page is a distinct type with its own VirtualPath, and the target page will somehow have to distinguish between a post from source page 1 and one from source page 2. One way to solve this problem is to implement a common interface in each source page's base class; this way, the target page assumes only that the posting page implements a particular interface and is not necessarily of one specific type or another. For example, we could write the IPersonInfo interface to model our cross-page POST data, as shown in Listing 4-13.
Listing 4-13. IPersonInfo interface definition
public interface IPersonInfo { string Name { get; } int Age { get; } bool Married { get; } }
In each of the source pages, we then implement the IPersonInfo on the codebehind base class, and our target page can now safely cast the PreviousPage to the IPersonInfo type and extract the data regardless of which page was the source page, as shown in Listing 4-14.
Listing 4-14. Generic target page using interface for previous page
IPersonInfo pi = PreviousPage as IPersonInfo; if (pi != null) { _messageLabel.Text = string.Format("<h3>Hello there {0}, you are {1} years old and {2} married!</h3>", pi.Name, pi.Age, pi.Married ? "" : "not"); }
It would be even better if we could use the PreviousPageType directive to strongly type the PreviousPage property to the IPersonInfo interface. In fact, there is a way to associate a type with a previous page instead of using the virtual path, which is to specify the TypeName attribute instead of the VirtualPath attribute in the PreviousPageType directive. Unfortunately, the TypeName attribute of the PreviousPageType directive requires that the specified type inherit from System.Web.UI.Page. You can introduce a workaround to get the strong typing by defining an abstract base class that implements the interface (or just defines abstract methods directly) and inherits from Page, as shown in Listing 4-15.
Listing 4-15. Abstract base class inheriting from Page for strong typing with PreviousPageType
public abstract class PersonInfoPage : Page, IPersonInfo { public abstract string Name { get; } public abstract int Age { get; } public abstract bool Married { get; } }
This technique then requires that each of the source pages you author change their base class from Page to this new PersonInfoPage base, and then implement the abstract properties to return the appropriate data. Listing 4-16 shows an example of a codebehind class for a source page using this new base class.
Listing 4-16. Codebehind class for a sample source page inheriting from PersonInfoPage
public partial class SourcePage1 : PersonInfoPage { public override string Name { get { return _nameTextBox.Text; } } public override int Age { get { return int.Parse(_ageTextBox.Text); } } public override bool Married { get { return _marriedCheckBox.Checked; } } }
Once all source pages are derived from our PersonInfoPage and the three abstract properties are implemented, our target page can be rewritten with a strongly typed PreviousPageType directive, which saves the trouble of casting, as shown in Listing 4-17.
Listing 4-17. Strongly typed target page using TypeName
<%@ PreviousPageType TypeName="PersonInfoPage" %> <script runat="server"> protected void Page_Load(object sender, EventArgs e) { if (PreviousPage != null) { _messageLabel.Text = string.Format( "<h3>Hello there {0}, you are {1} years old and {2} married!</h3>", PreviousPage.Name, PreviousPage.Age, PreviousPage.Married ? "" : "not"); } } </script> <!-- ... -->
The effort required to get the strong typing to work for multiple source pages hardly seems worth it in the end. You already have to check to see whether the PreviousPage property is null or not, and casting it to the interface using the as operator in C# is about the same amount of work as checking for null. However, both ways are valid approaches, and it is up to you to decide how much effort you want to put into making your previous pages strongly typed.