【Jeffrey Zhao 】讓UpdatePanel支援上傳檔案

iDotNetSpace發表於2008-06-11

客戶端通訊替換機制

  UpdatePanel從一開始就無法支援AJAX的檔案上傳方式。Eilon Lipton寫了一篇文章解釋了這個問題的原因。文章中提供了兩個繞開此問題的方法:

  1. 將“上傳”按鈕設為一個傳統的PostBack控制元件而不是非同步PostBack。您可以使用多種方法來這麼做:例如將一個按鈕放置在UpdatePanel外,將按鈕設為某個UpdatePanel的PostBackTrigger,或者呼叫ScriptManager.RegisterPostBackControl來註冊它。
  2. 建立一個不使用ASP.NET AJAX的上傳頁面,很多站點已經這麼做了。

  不過,我們為什麼不使UpdatePanel相容FileUpload控制元件()呢?如果可以這樣,一定能夠受需要使用UpdatePanel上傳檔案的使用者歡迎。

  我們首先要解決的問題是,找到一種能夠將資訊傳送到伺服器端的方法。我們都知道XMLHttpRequest只能傳送字串。在這裡,我們使用和其他的非同步上傳檔案的解決方案一樣,使用iframe來上傳檔案。iframe元素是一個非常有用的東西,即使在AJAX這個概念出現之前,它已經被用於製作一些非同步更新的效果了。

  其次,我們該如何改變UpdatePanel傳輸資料的行為?幸虧Microsoft AJAX Library有個靈活的非同步通訊層,我們可以方便建立一個UpdatePanelIFrameExecutor來繼承Sys.Net.WebRequestExecutor,並且將它交給一個上傳檔案的WebRequest物件。因此,下面的程式碼可以作為我們開發元件的第一步:

Type.registerNamespace("AspNetAjaxExtensions");
 
AspNetAjaxExtensions.UpdatePanelIFrameExecutor = function(sourceElement)
{
    AspNetAjaxExtensions.UpdatePanelIFrameExecutor.initializeBase(this);
 
    // ...
}
 
AspNetAjaxExtensions.UpdatePanelIFrameExecutor.prototype =
{
    // ...
}
AspNetAjaxExtensions.UpdatePanelIFrameExecutor.registerClass(
    "AspNetAjaxExtensions.UpdatePanelIFrameExecutor",
     Sys.Net.WebRequestExecutor);
 
AspNetAjaxExtensions.UpdatePanelIFrameExecutor._beginRequestHandler = function(sender, e)
{
    var inputList = document.getElementsByTagName("input");
    for (var i = 0; i < inputList.length; i++)
    {
        var type = inputList[i].type;
        if (type && type.toUpperCase() == "FILE")
        {
            e.get_request().set_executor(
                new AspNetAjaxExtensions.UpdatePanelIFrameExecutor(e.get_postBackElement()));

            return;
        }
    }
}
 
Sys.Application.add_init(function()
{
    Sys.WebForms.PageRequestManager.getInstance().add_beginRequest(
        AspNetAjaxExtensions.UpdatePanelIFrameExecutor._beginRequestHandler);
});

  在上面的程式碼中,我們在頁面初始化時監聽了PageRequestManager物件的beginRequest事件。當PageRequestManager觸發了一個非同步請求時,我們會檢查頁面上是否有控制元件。如果存在的話,則建立一個UpdatePanelIFrameExecutor例項,並分配給即將執行的WebRequest物件。

  根據非同步通訊層的實現,WebRequest的作用只是一個儲存請求資訊的容器,至於如何向伺服器端傳送資訊則完全是Executor的事情了。事實上Executor完全可以不理會WebRequest攜帶的資訊自行處理,而我們的UpdatePanelIFrameExecutor就是這樣的玩意兒。它會改變頁面上的內容,將資訊Post到額外的IFrame中,並且處理從伺服器端獲得的資料。

伺服器端元件

  再來關注伺服器端的元件。目前的主要問題是,我們如何讓頁面(事實上是ScriptManager控制元件)認為它接收到的是一個非同步的回送?ScriptManager控制元件會在HTTP請求的Header中查詢特定的項,但是我們在向IFrame中POST資料時無法修改Header。所以我們必須使用一個方法來“欺騙”ScriptManager。

  目前使用的解決方案是,我們在POST資料之前在頁面中隱藏的輸入元素()中放入一個特定的標記,然後我們開發的伺服器端元件(我把它叫做UpdatePanelFileUplaod)會在它的Init階段(OnInit方法)中在Request Body中檢查這個標記,然後使用反射來告訴ScriptManager目前的請求為一個非同步請求。

  但是事情並不像我們想象的那麼簡單,讓我們在寫程式碼之前來看一個方法:

internal sealed class PageRequestManager
{
    // ...
 
    internal void OnInit()
    {
        if (_owner.EnablePartialRendering && !_owner._supportsPartialRenderingSetByUser)
        {
            IHttpBrowserCapabilities browser = _owner.IPage.Request.Browser;
            bool supportsPartialRendering =
                (browser.W3CDomVersion >= MinimumW3CDomVersion) &&
                (browser.EcmaScriptVersion >= MinimumEcmaScriptVersion) &&
                browser.SupportsCallback;
 
            if (supportsPartialRendering)
            {
                supportsPartialRendering = !EnableLegacyRendering;
            }
            _owner.SupportsPartialRendering = supportsPartialRendering;
        }
 
        if (_owner.IsInAsyncPostBack)
        {
            _owner.IPage.Error += OnPageError;
        }

    }

    ...
}

  上面這段程式碼會在ScriptManager的OnInit方法中被呼叫。請注意最後加粗部分的程式碼,“_owner”變數是當前頁面上的ScriptManager。在頁面收到一個真正的非同步回送之後,PageRequestManager會響應頁面的Error事件,並且將錯誤資訊用它定義的格式輸出。如果我們只是修改了ScriptManager的私有field,那麼如果在非同步回送時出現了一個未捕獲的異常,那麼頁面就會輸出客戶端未知的內容,導致在客戶端解析失敗。所以我們必須保證這種情況下的輸出和真正的非同步回送是相同的,於是我們就可以使用以下的做法來解決錯誤處理的問題。

internal static class FileUploadUtility
{
    public static bool IsInUploadAsyncPostBack(HttpContext context)
    {
        string[] values = context.Request.Params.GetValues("__UpdatePanelUploading__");
 
        if (values == null) return false;
 
        foreach (string value in values)
        {
            if (value == "true")
            {
                return true;
            }
        }
 
        return false;
    }
}
 
 
[PersistChildren(false)]
[ParseChildren(true)]
[NonVisualControl]
public class UpdatePanelFileUpload : Control
{
    // ScriptManager members;
    private readonly static FieldInfo s_isInAsyncPostBackFieldInfo;
    private readonly static PropertyInfo s_pageRequestManagerPropertyInfo;
 
    // PageRequestManager members;
    private readonly static MethodInfo s_onPageErrorMethodInfo;
 
    static UpdatePanelFileUpload()
    {
        // Omitted: Initializing of the static members for reflection;
        ...
    }
 
    private bool m_pageInitialized = false;
 
    protected override void OnInit(EventArgs e)
    {
        base.OnInit(e);
 
        // Omitted: Initializing UpdatePanelFileUpload control on the page;
        ...

        this.IsInUploadAsyncPostBack = FileUploadUtility.IsInUploadAsyncPostBack(this.Context);
        if (this.IsInUploadAsyncPostBack)
        {
            s_isInAsyncPostBackFieldInfo.SetValue(ScriptManager.GetCurrent(this.Page), true);
            this.Page.Error += (sender, ea) =>
            {
                s_onPageErrorMethodInfo.Invoke(
                    this.PageRequestManager, new object[] { sender, ea });

            };
        }

    }
 
    public bool IsInUploadAsyncPostBack { get; private set; }
 
    private object m_pageRequestManager;
    private object PageRequestManager
    {
        get
        {
            if (this.m_pageRequestManager == null)
            {
                this.m_pageRequestManager = s_pageRequestManagerPropertyInfo.GetValue(
                    ScriptManager.GetCurrent(this.Page), null);
            }
 
            return this.m_pageRequestManager;
        }
    }
 
    ...
}

  這段實現並不複雜。如果Request Body中的“__UpdatePanelUploading__”的值為“true”,我們就會使用反射修改ScirptManager控制元件中的私有變數“_isInAsyncPostBack”。此後,我們使用了自己定義的匿名方法來監聽頁面的Error事件,當頁面的Error事件被觸發時,我們定義的新方法就會將能夠正確解析的內容傳送給客戶端。

  自然,UpdatePanelFileUpload也需要將程式集中內嵌的指令碼檔案註冊到頁面中。我為元件新增了一個開關,可以讓使用者開發人員使用程式設計的方式來開啟/關閉對於AJAX檔案上傳的支援。這部分實現更為簡單:

public bool Enabled
{
    get { ... }
    set { ... }
}
 
public string ExecuteMethod
{
    get { ... }
    set { ... }
}
 
protected override void OnPreRender(EventArgs e)
{
    base.OnPreRender(e);
 
    ScriptManager sm = ScriptManager.GetCurrent(this.Page);
    if (sm.IsInAsyncPostBack || !sm.EnablePartialRendering ||
        this.IsInUploadAsyncPostBack || !this.Enabled)
    {
        return;
    }
 
    if (String.IsNullOrEmpty(this.ExecuteMethod))
    {
        throw new ArgumentException("Please provide the ExecuteMethod.");
    }
 
    ScriptReference script. = new ScriptReference(
        "AspNetAjaxExtensions.UpdatePanelFileUpload.js",
        this.GetType().Assembly.FullName);
    ScriptManager.GetCurrent(this.Page).Scripts.Add(script);
 
    if (!String.IsNullOrEmpty(this.ExecuteMethod))
    {
        this.Page.ClientScript.RegisterStartupScript(
            this.GetType(),
            "ExecuteMethod",
            "AspNetAjaxExtensions.UpdatePanelIFrameExecutor._executeForm. = " + this.ExecuteMethod + ";",
            true);
    }
}

  從上面的程式碼中還可以看到一個ExecuteMethod屬性,而這個屬性最終會被拼接為一段JavaScript並註冊到頁面中去。這是新版UpdatePanelFileUpload控制元件的新特點。這個控制元件最關鍵的特性是使用iframe來傳遞和接受資料,而我將實現這個功能完全交由使用者來實現。原因如下:

  • 使用iframe進行通訊非常複雜也很難寫出真正完美的程式碼,因此將這部分功能轉移到控制元件外部,這樣使用者就可以自行修改了。
  • 一些AJAX元件提供了使用iframe進行通訊的功能(例如jQuery的Form外掛),但是控制元件無法知道使用者的應用中是否已經用了其他客戶端框架,因此UpdatePanelFileUpload不會與任何特定的客戶端框架進行繫結。

  當然,為了方便大家使用,也為了提供一個完整的解決方案,我會提供一個基於jQuery的Form外掛的ExecuteMethod。在使用中也可以將其替換為適合您專案的做法,例如swfupload

 

客戶端元件

  UpdatePanelIFrameExecutor繼承了WebRequestExecutor,因此需要實現許多方法和屬性。但是我們事實上不用完整地實現所有的成員,因為客戶端的非同步刷信機制只會訪問其中的一部分。以下是非同步刷信過程中會使用的成員列表,我們必須正確地實現它們:

  • get_started: 表示一個Executor是否已經開始 了。
  • get_responseAvailable: 表示一個請求是否成功。
  • get_timedOut: 表示一個請求是否超時。
  • get_aborted: 表示一個請求是否被取消了。
  • get_responseData: 獲得文字形式的Response Body。 
  • get_statusCode: 獲得Response的狀態程式碼
  • executeRequest: 執行一個請求。
  • abort: 停止正在執行的請求。

  UploadPanelIFrameExecutor非常簡單,只是定義了一些私有變數:

AspNetAjaxExtensions.UpdatePanelIFrameExecutor = function(sourceElement)
{
    AspNetAjaxExtensions.UpdatePanelIFrameExecutor.initializeBase(this);
 
    // for properties
    this._started = false;
    this._responseAvailable = false;
    this._timedOut = false;
    this._aborted = false;
    this._responseData = null;
    this._statusCode = null;
   
    // the element initiated the async postback
    this._sourceElement = sourceElement;
    // the form. in the page.
    this._form. = Sys.WebForms.PageRequestManager.getInstance()._form;
}

  對於大部分屬性來說,它們的實現只不過是對上面這些私有變數進行讀取或寫入而已,在此不提。而一個Executor最重要的莫過於它的executeRequest方法,一些回撥函式,還有過期定時器之類的邏輯:

executeRequest : function()
{
    this._addAdditionalHiddenElements();
 
    var nSuccess = Function.createDelegate(this, this._onSuccess);
    var nFailure = Function.createDelegate(this, this._onFailure);
 
    this._started = true;
 
    var timeout = this._webRequest.get_timeout();
    if (timeout > 0)
    {
        this._timer = window.setTimeout(
            Function.createDelegate(this, this._onTimeout), timeout);

    }
   
    AspNetAjaxExtensions.UpdatePanelIFrameExecutor._executeForm(
        this._form, onSuccess, onFailure);

},
 
_addAdditionalHiddenElements : function() { ... },
 
_removeAdditionalHiddenElements : function() { ... },
 
_onSuccess : function(responseData)
{
    this._clearTimer();
    if (this._aborted || this._timedOut) return;
   
    this._statusCode = 200;
    this._responseAvailable = true;
    this._responseData = responseData;
   
    this._removeAdditionalHiddenElements();
    this.get_webRequest().completed(Sys.EventArgs.Empty);
},
 
_onFailure : function()
{
    this._clearTimer();
    if (this._aborted || this._timedOut) return;
   
    this._statusCode = 500;
    this._responseAvailable = false;
   
    this._removeAdditionalHiddenElements();
    this.get_webRequest().completed(Sys.EventArgs.Empty);
},
 
abort : function()
{
    this._aborted = true;
    this._clearTimer();
   
    this._removeAdditionalHiddenElements();
},
 
_onTimeout : function()
{
    this._timedOut = true;

    this._statusCode = 500;
    this._responseAvailable = false;

    this._removeAdditionalHiddenElements();
    this.get_webRequest().completed(Sys.EventArgs.Empty);

},
 
_clearTimer : function()
{
    if (this._timer != null)
    {
        window.clearTimeout(this._timer);
        delete this._timer;
    }
}

  如果您瞭解Executor的功能,那麼應該很容易看懂上面的程式碼:executeRequest方法用於發出請求,在executeRequest方法中還會開啟一個監聽是否超時的定時器,當得到回覆或超時後就會呼叫WebRequest的completed方法(在_onSuccess和_onFailure方法內)進行通知。不過上面這段程式碼中還有兩個特別的方法“_addAddtionalHiddenElements”和“removeAdditionalHiddenElements,從名稱上就能看出,這是為這次“非同步提交”而準備的額外元素。

  那麼我們該建立哪些附加的隱藏輸入元素呢?自然我們表示“非同步回送”的自定義標記是其中之一,那麼剩下的還需要哪些呢?似乎我們只能通過閱讀PageRequestManager的程式碼來找到問題的答案。還好,似乎閱讀下面的程式碼並不困難:

function Sys$WebForms$PageRequestManager$_onFormSubmit(evt)
{
    // ...
   
    // Construct the form. body
    var formBody = new Sys.StringBuilder();
    formBody.append(this._scriptManagerID + '=' + this._postBackSettings.panelID + '&');
 
    var count = form.elements.length;
    for (var i = 0; i < count; i++)
    {
        // ...
        // Traverse the input elements to construct the form. body
        // ...
    }
 
    if (this._additionalInput)
    {
        formBody.append(this._additionalInput);
        this._additionalInput = null;
    }

 
    var request = new Sys.Net.WebRequest();
    // ...
    // prepare the web request object
    // ...
 
    var handler = this._get_eventHandlerList().getHandler("initializeRequest");
    if (handler) {
        var eventArgs = new Sys.WebForms.InitializeRequestEventArgs(
            request, this._postBackSettings.sourceElement);
        handler(this, eventArgs);
        continueSubmit = !eventArgs.get_cancel();
    }
 
    // ...
 
    this._request = request;
    request.invoke();
 
    if (evt) {
        evt.preventDefault();
    }
}

  請注意加粗部分的程式碼。可以發現有兩種資料需要被新增為隱藏的輸入元素。其一是ScriptManager相關的資訊(第一部分的紅色程式碼),其二則是變數“_additionalInput”的內容。我們很容易得到前者的值,但是後者的內容究竟是什麼呢?我們繼續閱讀程式碼:

function Sys$WebForms$PageRequestManager$_onFormElementClick(evt)
{
    var element = evt.target;
    if (element.disabled) {
        return;
    }
 
    // Check if the element that was clicked on should cause an async postback
    this._postBackSettings = this._getPostBackSettings(element, element.name);
 
    if (element.name)
    {
        if (element.tagName === 'INPUT')
        {
            var type = element.type;
            if (type === 'submit')
            {
                this._additionalInput =
                    element.name + '=' + encodeURIComponent(element.value);
            }
            else if (type === 'image')
            {
                var x = evt.offsetX;
                var y = evt.offsetY;
                this._additionalInput =
                    element.name + '.x=' + x + '&' + element.name + '.y=' + y;
            }
        }
        else if ((element.tagName === 'BUTTON') &&
            (element.name.length !== 0) && (element.type === 'submit'))
        {
            this._additionalInput = element.name + '=' + encodeURIComponent(element.value);
        }
    }
}

  _onFormElmentClick方法會在使用者點選form中特定元素時執行。方法會提供變數“_additionalInput”的內容,然後緊接著,我們之前分析過的_onFormSubmit方法會被呼叫。於是只要我們對WebRequest的body屬性進行分析,就能夠輕鬆地得知需要像form中額外新增哪些隱藏輸入元素:

_addHiddenElement : function(name, value)
{
    var hidden = document.createElement("input");
    hidden.name = name;
    hidden.value = value;
    hidden.type = "hidden";
    this._form.appendChild(hidden);
    Array.add(this._hiddens, hidden);
},
 
_addAdditionalHiddenElements : function()
{
    var prm = Sys.WebForms.PageRequestManager.getInstance();
   
    this._hiddens = [];
   
    this._addHiddenElement(prm._scriptManagerID, prm._postBackSettings.panelID);
    this._addHiddenElement("__UpdatePanelUploading__", "true");
   
    var additionalInput = null;
    var element = this._sourceElement;
   
    if (element.name)
    {
        var requestBody = this.get_webRequest().get_body();
        var index = -1;
       
        if (element.tagName === 'INPUT')
        {
            var type = element.type;
            if (type === 'submit')
            {
                index = requestBody.lastIndexOf("&" + element.name + "=");
            }
            else if (type === 'image')
            {
                index = requestBody.lastIndexOf("&" + element.name + ".x=");
            }
        }
        else if ((element.tagName === 'BUTTON') && (element.name.length !== 0) &&
            (element.type === 'submit'))

        {
            index = requestBody.lastIndexOf("&" + element.name + "=");
        }
       
        if (index > 0)
        {
            additionalInput = requestBody.substring(index + 1);
        }
    }
   
    if (additionalInput)
    {
        var inputArray = additionalInput.split("&");
        for (var i = 0; i < inputArray.length; i++)
        {
            var nameValue = inputArray[i].split("=");
            this._addHiddenElement(nameValue[0], decodeURIComponent(nameValue[1]));
        }
    }
}, 

  至於請求結束(超時或得到結果)後用於清除那些額外元素的方法也就順理成章了:

_removeAdditionalHiddenElements : function()
{
    var hiddens = this._hiddens;
    delete this._hiddens;
   
    for (var i = 0; i < hiddens.length; i++)
    {
        hiddens[i].parentNode.removeChild(hiddens[i]);
    }
   
    hiddens.length = 0;
},

  至此,我們的客戶端元件已經編寫完畢了。不過您應該產生疑問:通過IFrame傳遞資料的程式碼在哪裡啊?我們接下來就來解釋這個問題。

 

自定義Execute方法

  之前我已經描述過這個元件的一個特點:由於使用IFrame傳遞資料的邏輯非常複雜,因此我將其與控制元件的邏輯進行分離,這樣使用者就可以在需要時對這部分邏輯進行修改。此外這種做法還可以避免UpdatePanelFileUpload與某個特定的JavaScript框架繫結,使用者可以選擇一個符合自己應用程式的做法來實現這部分邏輯。因此UpdatePanelFileUpload釋放出一個屬性ExecuteMethod,它會在頁面上寫上“AspNetAjaxExtensions.UpdatePanelIFrameExecutor._executeForm. = ...”這樣的程式碼。而ExecuteMethod會在UpdatePanelIFrameExecutor的executeRequest方法內被呼叫。ExecuteMethod方法接受三個引數:“form”,“onSuccess”和“onFailure”。第一個引數為需要Post的Form,而後兩個引數都為回撥函式,供ExecuteMethod在合適的時候呼叫。

  jQuery的Form外掛提供了一個將內容Post到一個IFrame的功能,因此我在這裡提供一個基於jQuery的方法作為示例:

function htmlDecode(s)
{
    ...
}
 
function executeForm(form, onSuccess, onFailure)
{
    $("#"+ form.id).ajaxSubmit({
        url : form.action,
        type : "POST",
        error : onFailure,
        success: getOnSuccessHandler(onSuccess)});
}
 
function getOnSuccessHandler(onSuccess)
{
    return function(content)
    {
        if (content.startsWith("

"
) || content.startsWith("
"
))
        {
            content = content.substring(5);
        }
       
        if (content.endsWith("") || content.endsWith(""))
        {
            content = content.substring(0, content.length - 6);
        }
       
        content = htmlDecode(content);
       
        if (content.indexOf("\n") >= 0 && content.indexOf("\r\n") < 0)
        {
            content = content.replace(/\n/g, "\r\n");
        }
       
        onSuccess(content);
    }
}

  原本以為jQuery的Form外掛提供了一個成熟的解決方案,可惜最後發現依舊不夠完美。例如會在傳輸的結果兩邊加上“

”和“
”標籤,還會將其中的字元進行編碼,這迫使我們在得到結果後還需要進行Html Decod——這在JavaScript中可不是一件容易實現的工作。最後我從網上找了一個JavaScript版本的HTML Decode函式才算解決這個問題。此外還有一個問題就和瀏覽器密切相關了:IE中的換行字元為“\r\n”,而FireFox中的換行字元為“\n”,因此同樣的字串經過IFrame的傳遞之後實際就改變了。在普通情況下這不會造成太大問題,不過UpdatePanel客戶端的解析邏輯與字串長度密切相關,因此我們需要將結果中的\n替換成\r\n才能讓功能正常執行。同樣地,我們在伺服器端如果手動輸出HTML時,就必須輸出\r\n而不是\n。

  經過我的簡單測試,這個方法能夠支援IE6+以及FireFox 1.5+的瀏覽器,不過沒有測試過Safari或Opera瀏覽器。理論上,您可以使用更好的辦法來替換這個基於jQuery的實現,甚至您可以避免使用IFrame傳遞的方式,而改用其他的解決方案,例如swfupload。如果您發現示例中的方法有什麼問題,或者有更好的做法請聯絡我。

 

控制元件的使用

  由於UpdatePanelFileUpload控制元件的工作原理是欺騙ScriptManager,將其修改為普通非同步呼叫的狀態,因此我們要儘可能早地做到這一點。所以在使用這個控制元件時必須將其緊跟著ScirptManager擺放,頁面中的ScriptManager和UpdatePanelFileUpload控制元件之間存在任何其他ASP.NET AJAX控制元件,就可能會產生一些不可預知的問題。以下是附件中的使用示例:

<script type="text/C#" runat="server">
    protected void btnUpload_Click(object sender, EventArgs e)
    {
        this.lblFileSize.Text = this.fileUpload.PostedFile.ContentLength.ToString();
    }
script>
 
<form id="form1" runat="server">
    <asp:ScriptManager runat="server" ID="sm">
        <Scripts>
            <asp:ScriptReference Path="Scripts/jquery-1.2.3.js" />
            <asp:ScriptReference Path="Scripts/jquery.form.js" />
        Scripts>
    asp:ScriptManager>
    <ajaxExt:UpdatePanelFileUpload ID="UpdatePanelFileUpload1" runat="server"
        ExecuteMethod="executeForm" />
 
    <asp:UpdatePanel runat="server" ID="up1">
        <ContentTemplate>
            = DateTime.Now %><br />

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/12639172/viewspace-343340/,如需轉載,請註明出處,否則將追究法律責任。

相關文章