.

Saturday, September 13, 2008

Display Waiting Image at Process Time in ASP.NET

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 Pagetitle>

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 Pagetitle>

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)

{

// chech 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 Ffig. 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<Guid, int> _results =
new Dictionary<Guid, int>();

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 Pagetitle>

head>

<body>

<form id="form1" runat="server">

<div>

<asp:CheckBox ID="cbProgress" runat="server" Text="Show Progress" />

<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 Pagetitle>

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 <>

{

// background color

if (i <>

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 Pagetitle>

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 Pagetitle>

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 Pagetitle>

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 Pagetitle>

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.

Conclusion

In this paper we saw how to quickly create a waiting page to use in our applications. If you need only to show to user that a process is still running or already finished, the simplest solution will do for you (note that you also can add Ajax features for better user experience!). If your process should return complex data or may be split in discrete parts you may like to use the waiting page with feedback, or if you have to run a few processes you may use the multi-process version of the waiting page.

No comments:

.