耗時ASP.NET Postback的傻瓜進度回報
| | | 15 | |
手邊的ASP.NET WebForm專案,有幾個耗時頗久的資料庫作業被寫在Button伺服器端Click事件裡。下場是當使用者按下按鈕,只見瀏覽器一直顯示執行中,等到天荒地老卻無法得知程式是已經當掉還是沒跑完,嚴格來說,這是蠻糟的介面設計。理想的做法,至少要讓使用者在漫長的等待過程持續獲得處理進度資訊,看著處理百分比不斷增加或是待處理件數逐漸減少,肯定能有效降低等待的焦慮感,明顯改善操作體驗。
要實現進度回報,改用AJAX呼叫是不錯的解法,但依過去的經驗,要在數分鐘的執行過程中持續傳回目前進度,機制還蠻複雜的! 例如: 在Server另開Thread執行耗時作業 + 允許進度資訊被其他Request Thread讀取 + Client端定期發出AJAX Request讀取進度...
為了讓WebForm開發同事只用最小幅的改寫,就讓原本按下去石沈大海的Postback變成可以持續回報進度,我決定寫一個符合以下目標的模組:
- 部署需求愈少愈好,最好一個檔案就能解決
- WebForm的改寫愈少愈好
- 耗時作業邏輯希望還是依原先石沈大海式的做法,全都寫在Button_Click事件中,力求程式單純
思索了一下,我把腦筋動到以前研究過的Response.Flush上! 關鍵是在網頁中新增一個<iframe name=”afa_target” style=”display:none” />,然後將<form method=”POST” target=”afa_target” />指向這個IFrame(有人主張form.target在XHTML中已被標為淘汰,但頗有爭議,既然各大瀏覽器都可支援,就安心服用吧!),則Postback結果會被顯現在IFrame中,透過陸續傳回<script>區塊+Response.Flush()實現進度資訊的持續更新。如此,只要Button_Click事件中的耗時作業能定期丟出Script去更新parent.window的特定元素顯示進度資訊,就可以達成"程式小改、體驗大變"(謎: 這Slogan怪怪的)的神奇目標了!
最後我生出了以下的輔助類別,LongExecutionSidekick.cs,要放在App_Code中(或另外Build成DLL也可)。
using System; using System.Web;using System.Web.UI;using System.Web.UI.HtmlControls;using System.Web.UI.WebControls;public class LongExecutionSidekick
{private Page _page = null;
public LongExecutionSidekick() { _page = HttpContext.Current.CurrentHandler as Page;if (_page == null)
throw new ApplicationException(
"Only ASP.NET Page HttpHandler is supported!");_page.ClientScript.RegisterClientScriptBlock(_page.GetType(),
"afalesk_script", @"
$(function() { function show(evt, data) { $('.afa-status-' + data.callerId).html(data.msg);}
$('body').append('<iframe name=""afa_target"" style=""display:none"" />'); var $form = $('form:first'); $form.bind('afales.update afales.init afales.end', show); function pack(callerId, msg) { return { callerId:callerId, msg:msg };}
$('input[data-caller-id]').click(function() { if ($form.attr('target') != 'afa_target') { $form.data('orig-target', $form.attr('target') || '_self'); $form.attr('target', 'afa_target');}
var $btn = $(this);
$form.trigger('afales.init', pack($btn.data('callerId'), $btn.data('initMsg') || 'Start')); setTimeout(function() { $('input[data-caller-id]').attr('disabled', true);}, 500);
});
window.afales_init = function(callerId, msg) { $form.trigger('afales.init', pack(callerId, msg));}
window.afales_restarget = function() { $form.attr('target', $form.data('orig-target'));}
window.afales_end = function(callerId, msg) { $form.trigger('afales.end', pack(callerId, msg)); $('input[data-caller-id]').removeAttr('disabled');}
window.afales_update = function(callerId, msg) { $form.trigger('afales.update', pack(callerId, msg));}
});", true);}
public delegate void ReportProgress(string msg);
private void OutputScript(string script)
{_page.Response.Write(
"<script type='text/javascript'>" + script + "</script>");
_page.Response.Flush();
}
private string GetMsgAttr(Button btn, string catg)
{string key = "data-" + catg + "-msg";
if (btn.Attributes[key] == null)
return catg == "init" ? "Start" : "Done";
else return JSStringEscape(btn.Attributes[key], false);
}
public void Execute(Button btn, Action<ReportProgress> cb)
{if (btn.Attributes["data-caller-id"] == null)
throw new ArgumentException("No data-caller-id attribute!");
string callerId = btn.Attributes["data-caller-id"];
_page.Response.Clear();
_page.Response.BufferOutput = false; _page.Response.Write("<html><head><meta http-equiv=\"Content-Type\" " + "content=\"text/html; charset=UTF-8\" /></head><body>"); OutputScript("parent.window.afales_restarget();");ReportProgress updateProgress =
new ReportProgress( (m) => OutputScript(string.Format( "parent.window.afales_update('{0}', '{1}');", callerId, JSStringEscape(m, false))));
cb(updateProgress);
OutputScript(string.Format( "parent.window.afales_end('{0}', '{1}');", callerId, GetMsgAttr(btn, "end"))); _page.Response.Write("</body></html>");_page.Response.End();
}
private string JSStringEscape(string raw, bool inHtmlAttribute)
{raw = raw.Replace("\r\n", "\\n").Replace("\r", "").Replace("\n", "\\n");
return inHtmlAttribute ? raw.Replace("\"", """).Replace("'", "\\'") :
raw = raw.Replace("'", "\\'").Replace("\"", "\\\"");
}
}
由於LongExecutionSidekick要一人分飾兩角,負責串通前端後端,程式碼還蠻複雜的。不過大家不用擔心,反正它就像個黑盒子,或是說像台傻瓜相機,絕大部分的人只需要知道怎麼按下快門就可以了!
底下是一個應用範例。首先,要為ASP.NET Page宣告一個LongExecutionSidekick變數,並記得在Page_Load時再建構它。範例裡我放了四個Button,目的如下:
- btnLongExec1、btnLongExec2並存,測試是否能讓多個Button都支援傻瓜進度回報 (但不能同時,我有試過每一個按鈕配一個IFrame,但測試發現瀏覽器一次只會執行一個PostBack,完成後才會執行下一個)
- btnLongExec1示範將進度資訊直接呈現在<span>,並可客製開始及完成的顯示文字
- btnLongExec2展示透過afales.init、afales.update、afales.end事件來觸發自訂的Client事件
- 由於我們會更動<form>的target,btnPostBack用來測試傻瓜進度回報是否會干擾一般的PostBack
- btnPostWait用來讓大家體驗石沈大海的感覺,也對照與傻瓜進度回報Button_Click事件的寫法差異
要為特定Button啟用傻瓜進度回報,只需要為Button加上”data-caller-id” Attribute(注意Attribute要由Server-Side設定,不能由JavaScript指定,否則LongExecutionSidekick讀取不到),然後在Button_Click事件中,使用LongExecutionSidekick.Execute功能,傳入Button鈕本身及要執行作業的委派程式 – Action<ReportProgress>,在委派程式中就如往常跑迴圈執行耗時任務,只需定期呼叫ReportProgress(“….”)傳送進度資訊回Client端。
<%@ Page Language="C#" %> <%@ Import Namespace="System.Threading" %> <!DOCTYPE html>
<script runat="server">
private LongExecutionSidekick les; protected void Page_Load(object sender, EventArgs e)
{ les = new LongExecutionSidekick(); btnLongExec1.Attributes.Add("data-caller-id", "JOB1");
btnLongExec1.Attributes.Add("data-init-msg", "開始");
btnLongExec1.Attributes.Add("data-end-msg", "完成");
btnLongExec2.Attributes.Add("data-caller-id", "JOB2");
}
protected void btnLongExec_Click(object sender, EventArgs e)
{ les.Execute(
sender as Button, (rp) =>
{ //模擬執行很耗時的作業 for (int i = 0; i < 5; i++)
{ Thread.Sleep(1000);
//處理到一個段落,呼叫Delegate傳回訊息 rp("Process - " + i.ToString()); }
});
}
protected void btnPostBack_Click(object sender, EventArgs e)
{ lblDisp.Text = DateTime.Now.ToString("HH:mm:ss.fff"); }
protected void btnPostWait_Click(object sender, EventArgs e)
{ for (int i = 0; i < 5; i++)
{ Thread.Sleep(1000);
}
lblDisp.Text = "Thanks for waiting!"; }
</script>
<html xmlns="http://www.w3.org/1999/xhtml"> <head id="Head1" runat="server">
<title>Long Execution</title>
<script src="http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.6.1.js" type="text/javascript"></script> <script type="text/javascript"> $(function () { $("#form1") .bind("afales.init", function (e, d) {
if (d.callerId == "JOB2")
alert("開始執行"); })
.bind("afales.end", function (e, d) {
if (d.callerId == "JOB2")
alert("作業完成!"); })
.bind("afales.update", function (e, d) {
if (d.callerId == "JOB2")
$("#btnLongExec2").val("Run - " + d.msg.split('-')[1]);
});
});
</script>
<style type="text/css">
body { font-size: 9pt; } input { margin: 5px; } span { padding: 4px; } </style>
</head>
<body>
<form id="form1" runat="server">
<div>
<asp:Button runat="server" ID="btnLongExec1" Text="Run"
onclick="btnLongExec_Click"/><span class="afa-status-JOB1"></span>
</div>
<div>
<asp:Button runat="server" ID="btnLongExec2" Text="Run" onclick="btnLongExec_Click"/>
</div>
<div>
<asp:Button runat="server" ID="btnPostBack" Text="PostBack"
onclick="btnPostBack_Click" />
<asp:Label ID="lblDisp" runat="server" EnableViewState="false"></asp:Label>
</div>
<div>
<asp:Button runat="server" ID="btnPostWait" Text="Hold On"
runat="server" onclick="btnPostWait_Click" />
</div>
</form>
</body>
</html>
執行結果如下: (經測試在IE, Firefox, Safari, Chrome, Opera下也能順利運作)

等一下!! 講了大半天"體驗大變"卻只用張圖例草草帶過豈不跟"看3D電影卻只有字幕是立體的"一樣無趣,所以我準備了Live Demo,大家可以實地感受一下,並歡迎提供意見回饋給我。
[2011-07-11補充] 如要套用在ASP.NET 2.0環境,請分別修改LongExecutionSidekick.cs及WebForm程式如下,只需將Lambda語法改寫為delegate即可。
//LongExecutionSidekick.cs Lambda改寫為delegateReportProgress updateProgress =
new ReportProgress(delegate(string m) {
OutputScript(string.Format( "parent.window.afales_update('{0}', '{1}');", callerId, JSStringEscape(m, false)));});
//ASP.NET WebForm: Lambda改寫為delegateprotected void btnLongExec_Click(object sender, EventArgs e)
{les.Execute(
sender as Button, delegate(LongExecutionSidekick.ReportProgress rp) { //模擬執行很耗時的作業for (int i = 0; i < 5; i++)
{Thread.Sleep(1000);
//處理到一個段落,呼叫Delegate傳回訊息 rp("Process - " + i.ToString());}
});
}
Comments
# by 小黑
黑大這程式是否可用在 asp.net 2.0 與 VS2005 開發上
# by Tim
new ReportProgress( (m) => OutputScript(string.Format( "parent.window.afales_update('{0}', '{1}');", callerId, JSStringEscape(m, false))) 請問上述程式碼是不是有問題. compile 不會過!
# by Jeffrey
to Tim, Compile錯誤是否為無法識別>符號? 這裡用了Lambda語法,要放在ASP.NET 3.5以上的專案才能直接執行。 to 小黑,這段程式是以ASP.NET 3.5為平台,但同樣的概念可以移植為ASP.NET 2.0實做,主要是Lambda部分要做小幅改寫。你可以先試改看看,晚點我再找時間整理出改套2.0時的寫法差異。
# by Tim
ok 了, 謝 jeffrey!! ps: 1. 我對 Lambda 不熟~~ 原諒我的無知!! ^_^ 2. jeffrey, 如果 Build 成 dll. 然後給 .net 2.0 專案用可以嗎!? (放在 app_code 下引用)
# by Jeffrey
to 小黑,Tim,已在文末補上要在ASP.NET 2.0執行時的修改法,請參考。
# by 小黑
感謝黑大不吝嗇出手,補充藥包一帖,受用無窮,謹記在心。
# by ㄚ傻
我使用您的程式碼 用IE執行之後 點前面兩個按鈕 會挑出錯誤視窗 行: 1 錯誤: 物件不支援此屬性或方法 是什麼原因呀
# by Jeffrey
to ㄚ傻, 對該錯誤沒什麼概念,在此詢問幾個問題: 1) 直接執行http://www.darkthread.net/miniajaxlab/afales.aspx 就會產生前述錯誤? 2) IE版本? 若使用Firefox/Chrome等非IE瀏覽器測試的話是否也會出錯?
# by Eric
黑大,請問執行到一半要怎麼停止? 我功力太淺,改不出來 Orz..
# by Jeffrey
to Eric, 要做到執行一半還可以取消或中止執行,相對複雜很多。 到btnLongExec_Click()的階段,就只剩Server->Browser的傳輸管道還開啟,Browser無法透過同一管道傳達中止執行的指令,因此就涉及跨Thread溝通的Issue,是個頗具挑戰性的議題,理論上可行,但工程不會太小,未來再找機會探討。
# by Eric
非常感謝黑大的回覆 :)
# by Nick
黑大,請問若我要在原本的頁面(lblResult)顯示處理後的最終結果,要怎麼做呢? 測試後若跑了 les.Execute(... 這段委派後,就算我指定 lblResult.Text = "xxxx"; 都沒辦法顯示出來了...希望可以麻煩解答一下,謝謝!!
# by Jeffrey
to Nick, 受限Web運作原理,lblResult.Text="..."的寫法只有在ASP.NET網頁HTML被傳回瀏覽器前有作用,之後要持續更新網頁上文字,只能透過JavaScript處理。 你可以參考文中寫法,用類似 $("#form1").bind("afales.end", function (e, d) { $("#lblResult").text("完成訊息"); }); 去掛載作業完成事件,該段JavaScript會在整個作業完成後觸發,在兰中修改lblResult的文字內容。
# by Loter
to Jeffrey前輩 發現如果在執行按鈕上加上confirm,按下後選擇取消,雖然沒有執行運算,卻會造成form的submit對象改變,自此網頁上所有postback功能全數失效,只剩javascript效果還在。 嘗試進行了修改,將$('input[data-caller-id]').click()改成先取出按鈕目前事件,執行完後再執行$('input[data-caller-id]').click()原本在做的事,就可以解決使用者按下取消後,網頁上其他功能會失效的問題。 參考 http://stackoverflow.com/questions/7008892/how-to-call-an-event-handler-dynamically-and-get-its-return-value-in-javascript 這篇的作法
# by Jeffrey
to Loter,被叫前輩實愧不敢當,充其量只算是學長。非常謝謝你的回饋分享!!!