Thursday, 16 May 2013

Implementing Waiting Pages in ASP.NET

Implementing Waiting Pages in ASP.NET
https://www.simple-talk.com/dotnet/asp.net/implementing-waiting-pages-in-asp.net/


One thing users do not like about Web-applications is waiting for some long-running process to finish without knowing if it is really working or not. There are few greater frustrations than looking at the screen and think “What’s going on?”, while the application performs some undercover operation. That is why application should provide user with feedback, showing that something is happening and, telling the user that he will have to wait just a little bit longer.
If the process is running synchronously you have no options – the user will have to wait while page is reloading anyway. But then an asynchronous process comes to play, you may like to work out various solutions for a better user experience. In this article we will discover a few ways to create a waiting page.

Architecture of the waiting page solution

Before we start to build a waiting pages, we have to decide on architecture of the solution. The easiest to implement solution is shown at Fig. 1.
Fig. 1.Waiting page architecture
The main page starts a new thread and assigns a unique ID to it, then redirects user to the waiting page, passing ID to the waiting page to enable waiting page to track progress of the process running in a thread which was started by the main page. The process submits results to a special controller object that contains collection of key-value pairs to identify results submitted by threads with different IDs. The waiting page queries the controller to check if the process is still running or has already finished.

Solutions

Now we are set with the architectural decision and are ready to start building waiting page. Let’s go from the easiest solution to the more complex ones.

The simplest solution

The simplest solution to implement is which does not require tracking real progress of asynchronous process, thus showing only two states of the process – still running or already finished.
At first, we should create a controller object that can simply provide the waiting page with the state of the request.
The controller:
using System;
using System.Collections;


public static class SimpleProcessCollection
{
    private static Hashtable _results = new Hashtable();

    public static string GetResult(Guid id)
    {
        if (_results.Contains(id))
        {
            return Convert.ToString(_results[id]);
        }
        else
        {
            return String.Empty;
        }
    }

    public static void Add(Guid id, string value)
    {
        _results[id] = value;
    }

    public static void Remove(Guid id)
    {
        _results.Remove(id);
    }
}
The main page should assign the asynchronous process with an unique ID and pass this ID to the waiting page, then waiting page will query SimpleProcessCollection for the result with the given ID. The process, in turn, should add the result to the SimpleProcessCollection to notify waiting page that the process ended.
The main page
<%@ Page Language="C#" AutoEventWireup="true" CodeFile="Start.aspx.cs"
Inherits="Simple_Start" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<html xmlns="http://www.w3.org/1999/xhtml" >
<head runat="server">
    <title>Simple Waiting Page</title>
</head>
<body>
    <form id="form1" runat="server">
    <div>
        <asp:Button ID="btnStart" runat="server" Text="Start Long-Running Process"
OnClick="btnStart_Click" />
    </div>
    </form>
</body>
</html>
using System;
using System.Data;
using System.Configuration;
using System.Collections;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;
using System.Threading;

public partial class Simple_Start : System.Web.UI.Page
{
    protected Guid id;

    protected void btnStart_Click(object sender, EventArgs e)
    {
        // assign an unique ID
        id = Guid.NewGuid();
        // start a new thread
        ThreadStart ts = new ThreadStart(LongRunningProcess);
        Thread th = new Thread(ts);
        th.Start();
        // redirect to waiting page
        Response.Redirect("Status.aspx?ID=" + id.ToString());
    }

    // this is a stub for a asynchronous process
    protected void LongRunningProcess()
    {
        // do nothing actually, but there should be real code
        // for instance, there could be a call for a remote web service
        Thread.Sleep(9000);
        // add result to the controller
        SimpleProcessCollection.Add(id, "Some result.");
    }
}
The waiting page
<%@ Page Language="C#" AutoEventWireup="true" CodeFile="Status.aspx.cs"
Inherits="Simple_Status" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title>Simple Waiting Page</title>
</head>
<body>
    <form id="form1" runat="server">
        <div>
        <p align="center">
            <asp:Image ID="ImageStatus" ImageUrl="~/Images/Wait.gif"
runat="server" /></p>
            <h1>
                <asp:Label ID="lblStatus" runat="server"
Text="Working... Please wait..."></asp:Label>
            </h1>
        </div>
    </form>
</body>
</html>

using System;
using System.Data;
using System.Configuration;
using System.Collections;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;

public partial class Simple_Status : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        // check if the page was called properly
        if (Request.Params["ID"] != null)
        {
            Guid id = new Guid(Request.Params["ID"]);
            // check if there is a result in the controller
            if (SimpleProcessCollection.GetResult(id) == String.Empty)
            {
                // no result - let's refresh again
                Response.AddHeader("Refresh""3");
            }
            else
            {
                // everything's fine, we have the result
                lblStatus.Text = "Job is done.";
                ImageStatus.Visible = false;
            }
        }
        else
        {
            Response.Redirect("Start.apsx");
        }
    }
}
You can see that the solution is very simple and required just a dozen lines of code. As we are free to use an animated gif on the waiting page, the user will have fun while waiting for the process to complete. The waiting page discussed is presented at Fig. 2.
 
Fig. 2. The simplest waiting page.

Waiting for more than one process

If there is more than one process running in the background waiting page should wait for, then it is necessary to implement some kind of progress bar control and extend the sample shown above to handle more than one process. To do that, we can implement a simple counter of processes that are still running as shown in the following code snippet.
The controller
public static class MultiProcessCollection
{
    private static Dictionary<Guidint> _results =
                        new Dictionary<Guidint>();

    public static int GetProgress(Guid id)
    {
        if (_results.ContainsKey(id))
        {
            return _results[id];
        }
        else
        {
            return 0;
        }
    }

    public static bool IsCompleted(Guid id)
    {
        return (GetProgress(id) == 0);
    }

    public static void Add(Guid id)
    {
        if (!_results.ContainsKey(id))
        {
            _results.Add(id, 0);
        }
        _results[id] = _results[id] + 1;
    }

    public static void Remove(Guid id)
    {
        if (_results.ContainsKey(id) && _results[id] > 0)
        {
            _results[id] = _results[id] - 1;
        }
    }
}
This time controller increments the counter then a process is registered and decrements the counter as a process notifies the controller that it is finished. Thus, code for the main page and for the waiting page will be a little bit more complex.
The main page
<%@ Page Language="C#" AutoEventWireup="true" CodeFile="Start.aspx.cs"
Inherits="Simple_Start" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<html xmlns="http://www.w3.org/1999/xhtml" >
<head runat="server">
    <title>Progress Waiting Page</title>
</head>
<body>
    <form id="form1" runat="server">
    <div>
        <asp:CheckBox ID="cbProgress" runat="server" Text="Show Progress" /> &nbsp;
        <asp:Button ID="btnStart" runat="server"
Text="Start Three Long-Running Processes" OnClick="btnStart_Click" />
    </div>
    </form>
</body>
</html>

using System;
using System.Data;
using System.Configuration;
using System.Collections;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;
using System.Threading;

public partial class Simple_Start : System.Web.UI.Page
{
    protected Guid id;

    protected void btnStart_Click(object sender, EventArgs e)
    {
        id = Guid.NewGuid();

        MultiProcessCollection.Add(id);
        ThreadStart ts = new ThreadStart(LongRunningProcess1);
        Thread th = new Thread(ts);
        th.Start();

        MultiProcessCollection.Add(id);
        ts = new ThreadStart(LongRunningProcess2);
        th = new Thread(ts);
        th.Start();

        MultiProcessCollection.Add(id);
        ts = new ThreadStart(LongRunningProcess3);
        th = new Thread(ts);
        th.Start();

        if (cbProgress.Checked)
        {
            Response.Redirect("Progress.aspx?ID=" + id.ToString());
        }
        else
        {
            Response.Redirect("Status.aspx?ID=" + id.ToString());
        }
    }

    protected void LongRunningProcess1()
    {
        Thread.Sleep(3000);
        MultiProcessCollection.Remove(id);
    }
    protected void LongRunningProcess2()
    {
        Thread.Sleep(7000);
        MultiProcessCollection.Remove(id);
    }
    protected void LongRunningProcess3()
    {
        Thread.Sleep(5000);
        MultiProcessCollection.Remove(id);
    }
}
The waiting page
<%@ Page Language="C#" AutoEventWireup="true" CodeFile="Status.aspx.cs"
Inherits="Simple_Status" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title>Progress Waiting Page</title>
</head>
<body>
    <form id="form1" runat="server">
        <div>
            <h1>
                <asp:Label ID="lblStatus" runat="server"
Text="Working... Please wait..."></asp:Label>
            </h1>
        </div>
    </form>
</body>
</html>

using System;
using System.Data;
using System.Configuration;
using System.Collections;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;

public partial class Simple_Status : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        if (Request.Params["ID"] != null)
        {
            // check for result
            Guid id = new Guid(Request.Params["ID"]);
            if (!MultiProcessCollection.IsCompleted(id))
            {
                Response.AddHeader("Refresh""1");
            }
            else
            {
                lblStatus.Text = "Job is done.";
            }
        }
        else
        {
            Response.Redirect("Start.aspx");
        }
    }
}
This time we can make the user experience a little bit better by showing the progress bar indicating the real progress. To do this we implement a simple progress bar control and use it on a modified waiting page.
The progress bar control
using System;
using System.Data;
using System.Configuration;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;
using System.Drawing;

namespace MyControls
{

    public class SimpleProgressControl : WebControl
    {
        private int _Max;

        public int Max
        {
            get { return _Max; }
            set { _Max = value; }
        }

        private int _Value;

        public int Value
        {
            get { return _Value; }
            set { _Value = value; }
        }

        protected override void Render(HtmlTextWriter writer)
        {
            writer.AddAttribute(HtmlTextWriterAttribute.Width,
                this.Width.Value.ToString());
            writer.AddAttribute(HtmlTextWriterAttribute.Cellpadding, "0");
            writer.AddAttribute(HtmlTextWriterAttribute.Cellspacing, "0");
            writer.RenderBeginTag(HtmlTextWriterTag.Table);

            writer.AddAttribute(HtmlTextWriterAttribute.Height,
                                  this.Height.Value.ToString());
            writer.RenderBeginTag(HtmlTextWriterTag.Tr);

            for (int i = 0; i < _Max; i++)
            {
                // background color
                if (i < _Value)
                    writer.AddAttribute(HtmlTextWriterAttribute.Bgcolor,
                         ColorTranslator.ToHtml(this.ForeColor));
                else
                    writer.AddAttribute(HtmlTextWriterAttribute.Bgcolor,
                              ColorTranslator.ToHtml(this.BackColor));

                writer.RenderBeginTag(HtmlTextWriterTag.Td);
                writer.RenderEndTag(); // td
            }

            writer.RenderEndTag(); // tr
            writer.RenderEndTag(); // table
        }


    }
}
The more advanced waiting page
<%@ Page Language="C#" AutoEventWireup="true" CodeFile="Progress.aspx.cs"
Inherits="Multi_Progress" %>
<%@ Register TagPrefix="my" Namespace="MyControls" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title>Progress Waiting Page</title>
</head>
<body>
    <form id="form1" runat="server">
        <div>
        <my:SimpleProgressControl ID="ctlProgress" runat="server"
BackColor="blue" ForeCOlor="red" Max="3" Value="0" Width="300" Height="30" />
        <h1>
            <asp:Label ID="lblComplete" runat="server" Text="Process complete."
Visible="false"></asp:Label></h1>
        </div>
    </form>
</body>
</html>

using System;
using System.Data;
using System.Configuration;
using System.Collections;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;

public partial class Multi_Progress : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        if (Request.Params["ID"] != null)
        {
            Guid id = new Guid(Request.Params["ID"]);
            int p = MultiProcessCollection.GetProgress(id);
            ctlProgress.Value = 3 - p;
            if (p != 0)
            {
                Response.AddHeader("Refresh""1");
            }
            else
            {
                lblComplete.Visible = true;
            }
        }
        else
        {
            Response.Redirect("Start.aspx");
        }
    }
}
This page looks like the shown one on fig. 3.
Fig. 3. More advanced waiting page.

Returning custom data objects from the asynchronous processes

The next step on the way to building more advanced waiting page is to modify the controller object to work with custom data objects thus enable asynchronous process to return these custom data objects and provide the waiting page with more data about the state of the process.
For instance, if the process can be split into a few different steps it may notify the waiting page about the percentage of its completeness – this value can be stored in a field of custom data object used.
The custom data object
using System;
using System.Data;
using System.Configuration;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;


public class FeedbackObject
{

    private string _result1 = String.Empty;

    public string Result1
    {
        get { return _result1; }
        set { _result1 = value; }
    }
    private string _result2 = String.Empty;

    public string Result2
    {
        get { return _result2; }
        set { _result2 = value; }
    }

    private int _progress = 0;

    public int Progress
    {
        get { return _progress; }
        set { _progress = value; }
    }


    public bool Complete
    {
        get { return (_progress == 100); }
    }
}
 
The controller
using System;
using System.Collections;


public static class FeedbackProcessCollection
{
    private static Hashtable _results = new Hashtable();

    public static FeedbackObject GetResult(Guid id)
    {
        if (_results.Contains(id))
        {
            return (FeedbackObject)_results[id];
        }
        else
        {
            return null;
        }
    }

    public static void Add(Guid id, FeedbackObject stat)
    {
        _results[id] = stat;
    }

    public static void Remove(Guid id)
    {
        _results.Remove(id);
    }
}
To use this features we have to modify the waiting page and the main page.
The main page
<%@ Page Language="C#" AutoEventWireup="true" CodeFile="Start.aspx.cs"
Inherits="Simple_Start" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<html xmlns="http://www.w3.org/1999/xhtml" >
<head runat="server">
    <title>Feedback Waiting Page</title>
</head>
<body>
    <form id="form1" runat="server">
    <div>
        <asp:Button ID="btnStart" runat="server" Text="Start Long-Running Process"
OnClick="btnStart_Click" />
    </div>
    </form>
</body>
</html>

using System;
using System.Data;
using System.Configuration;
using System.Collections;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;
using System.Threading;

public partial class Simple_Start : System.Web.UI.Page
{
    protected Guid id;

    protected void btnStart_Click(object sender, EventArgs e)
    {
        id = Guid.NewGuid();
        ThreadStart ts = new ThreadStart(LongRunningProcess);
        Thread th = new Thread(ts);
        th.Start();

        Response.Redirect("Status.aspx?ID=" + id.ToString());
    }

    protected void LongRunningProcess()
    {
        FeedbackObject fo = new FeedbackObject();
        FeedbackProcessCollection.Add(id, fo);
        for (int i = 0; i <= 100; i++)
        {
            Thread.Sleep(50);
            if (i == 100)
            {
                fo.Result1 = "First result.";
                fo.Result2 = "Second result.";
            }
            fo.Progress = i;
        }
    }
}
The waiting page
<%@ Page Language="C#" AutoEventWireup="true" CodeFile="Status.aspx.cs"
Inherits="Simple_Status" %>
<%@ Register TagPrefix="my" Namespace="MyControls" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title>Feedback Waiting Page</title>
</head>
<body>
    <form id="form1" runat="server">
        <div>
            <my:simpleprogresscontrol id="ctlProgress" runat="server"
backcolor="blue" forecolor="red" max="100"
value="0" width="300" height="30" />
            <h1>
                <asp:Label ID="lblComplete" runat="server"
Text="Process complete." Visible="false"></asp:Label></h1>
        </div>
    </form>
</body>
</html>

using System;
using System.Data;
using System.Configuration;
using System.Collections;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;

public partial class Simple_Status : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        if (Request.Params["ID"] != null)
        {
            // check for result
            Guid id = new Guid(Request.Params["ID"]);
           
            ctlProgress.Value = FeedbackProcessCollection.GetResult(id).Progress;

            if (!FeedbackProcessCollection.GetResult(id).Complete)
            {
                Response.AddHeader("Refresh""1");
            }
            else
            {
                FeedbackObject fo = FeedbackProcessCollection.GetResult(id);
                lblComplete.Text = fo.Result1 + " " + fo.Result2;
                lblComplete.Visible = true;
            }
        }
        else
        {
            Response.Redirect("Start.aspx");
        }
    }
}
Now we are able to get any data as a result from an asynchronous process as well as percentage of the process completeness.

Adding Ajax features

Eventually, to make user experience even greater we can use Ajax features to our waiting page. Thanks to Microsoft Ajax Extensions (http://ajax.asp.net/) we do not need to do much work. We will add ScriptManager control and UpdatePanel controls and modify only the waiting page. (Please note, to use Microsoft Ajax Extensoins for ASP.NET you should add a reference to System.Web.Extenstions assembly and configure web.config file for your application. You may look through the sample application configuration file to get familiar with the configuration.)
The waiting page
<%@ Page Language="C#" AutoEventWireup="true" CodeFile="Status.aspx.cs"
Inherits="Simple_Status" %>

<%@ Register TagPrefix="my" Namespace="MyControls" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title>Feedback Waiting Page</title>
</head>
<body>
    <form id="form1" runat="server">
        <asp:ScriptManager ID="ScriptManager1" runat="server"
EnablePartialRendering="true">
        </asp:ScriptManager>
        <div>
            <asp:UpdatePanel ID="UpdatePanel1" runat="server"
ChildrenAsTriggers="true">
                <ContentTemplate>
                    <asp:Timer ID="Timer1" runat="server" Interval="1000"
OnTick="Timer1_Tick">
                    </asp:Timer>
                    <my:SimpleProgressControl ID="ctlProgress" runat="server"
BackColor="blue" ForeColor="red"
                        Max="100" Value="0" Width="300" Height="30" />
                    <h1>
                        <asp:Label ID="lblComplete" runat="server"
Text="Process complete." Visible="false"></asp:Label></h1>
                </ContentTemplate>
            </asp:UpdatePanel>
        </div>
    </form>
</body>
</html>

using System;
using System.Data;
using System.Configuration;
using System.Collections;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;

public partial class Simple_Status : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        if (Request.Params["ID"] == null)
        {
            Response.Redirect("Start.aspx");
        }
    }
    protected void Timer1_Tick(object sender, EventArgs e)
    {
        // check for result
        Guid id = new Guid(Request.Params["ID"]);

        ctlProgress.Value = FeedbackProcessCollection.GetResult(id).Progress;

        if (FeedbackProcessCollection.GetResult(id).Complete)
            lblComplete.Visible = true;
    }
}

As this is all we have to change to use Ajax features at our waiting page. Now only the small part of the page will be updated during the progress control refreshes.

No comments:

Post a Comment