耗時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改寫為delegate
ReportProgress updateProgress =
new ReportProgress(
delegate(string m) {
OutputScript(string.Format(
"parent.window.afales_update('{0}', '{1}');",
callerId, JSStringEscape(m, false)));
});
//ASP.NET WebForm: Lambda改寫為delegate
protected 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,被叫前輩實愧不敢當,充其量只算是學長。非常謝謝你的回饋分享!!!