手邊的ASP.NET WebForm專案,有幾個耗時頗久的資料庫作業被寫在Button伺服器端Click事件裡。下場是當使用者按下按鈕,只見瀏覽器一直顯示執行中,等到天荒地老卻無法得知程式是已經當掉還是沒跑完,嚴格來說,這是蠻糟的介面設計。理想的做法,至少要讓使用者在漫長的等待過程持續獲得處理進度資訊,看著處理百分比不斷增加或是待處理件數逐漸減少,肯定能有效降低等待的焦慮感,明顯改善操作體驗。

要實現進度回報,改用AJAX呼叫是不錯的解法,但依過去的經驗,要在數分鐘的執行過程中持續傳回目前進度,機制還蠻複雜的! 例如: 在Server另開Thread執行耗時作業 + 允許進度資訊被其他Request Thread讀取 + Client端定期發出AJAX Request讀取進度...

為了讓WebForm開發同事只用最小幅的改寫,就讓原本按下去石沈大海的Postback變成可以持續回報進度,我決定寫一個符合以下目標的模組:

  1. 部署需求愈少愈好,最好一個檔案就能解決
  2. WebForm的改寫愈少愈好
  3. 耗時作業邏輯希望還是依原先石沈大海式的做法,全都寫在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("\"", "&quot;").Replace("'", "\\'") :
               raw = raw.Replace("'", "\\'").Replace("\"", "\\\"");
    }
}

由於LongExecutionSidekick要一人分飾兩角,負責串通前端後端,程式碼還蠻複雜的。不過大家不用擔心,反正它就像個黑盒子,或是說像台傻瓜相機,絕大部分的人只需要知道怎麼按下快門就可以了!

底下是一個應用範例。首先,要為ASP.NET Page宣告一個LongExecutionSidekick變數,並記得在Page_Load時再建構它。範例裡我放了四個Button,目的如下:

  1. btnLongExec1、btnLongExec2並存,測試是否能讓多個Button都支援傻瓜進度回報 (但不能同時,我有試過每一個按鈕配一個IFrame,但測試發現瀏覽器一次只會執行一個PostBack,完成後才會執行下一個)
  2. btnLongExec1示範將進度資訊直接呈現在<span>,並可客製開始及完成的顯示文字
  3. btnLongExec2展示透過afales.init、afales.update、afales.end事件來觸發自訂的Client事件
  4. 由於我們會更動<form>的target,btnPostBack用來測試傻瓜進度回報是否會干擾一般的PostBack
  5. 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,被叫前輩實愧不敢當,充其量只算是學長。非常謝謝你的回饋分享!!!

Post a comment