ASP.NET 檔案上傳的兩三事

近日寫點網頁提供手機程式上傳檔案,順手做點筆記。主要包含兩個部分:一是檔案上傳背後所使用的 multipart 類型的 HTTP POST 訊息格式,二是如何在 ASP.NET 程式中處理上傳的檔案。

ASP.NET 的 FileUpload 控制項

如果上傳檔案的用戶端程式和伺服器端接收的程式都是你自己的 ASP.NET 網頁,用 FileUpload 控制項就很簡單。這方面,網路上可以找到許多教學文件,就不再贅述。

但有個小地方可以注意一下:當你在 Visual Studio 中把 FileUpload 控制項拖到 .aspx 網頁上,然後在瀏覽器中檢視該網頁時,看一下網頁原始碼,你會看到像這樣的標籤:
<form action="UploadDemo.aspx" enctype="multipart/form-data" id="form1" method="post">
...

其中的 enctype="multipart/form-data" 屬性是由於網頁加入了 FileUpload 控制項才增加的,這是因為上傳檔案時,form 的內容必須依照 multipart/form-data 的標準格式。此標準規範在許多 RFC 文件中都有提到,例如 RFC 2388RFC 1867

呃....一定要 K 這些 RFC 嗎?我沒仔細 K 它,倒也不會覺得良心不安。需要進一步了解細節的時候知道去哪裡找就夠了。(某種程度上是為懶惰找理由)

另一方面,我也發現,當我在跟其他寫手機和平板程式的同事合作時,了解這些 HTTP 標準規範還挺有用的。所以,接著就來整理一點有關 HTTP multipart 的基礎知識。

一點點基礎:HTTP Multipart

有的網頁在 submit 的時候,除了一些單純的文字欄位之外,還需要上傳一或多個檔案,像這種需要在一個 request 中包含多種不同類型資料的情況,就得採用 HTTP POST 的方式,將這些資料以 multipart 的格式包成一個 request。

底下是一個 multipart 類型的 HTTP POST 訊息範例:

POST http://192.168.1.10/AddMsg.aspx HTTP/1.1
Accept: text/html, application/xhtml+xml, */*
Accept-Language: en-US
Content-Type: multipart/form-data; boundary=-----------------------7dc210152a156e
Accept-Encoding: gzip, deflate
Host: 192.168.1.10
Content-Length: 29923
Connection: Keep-Alive
Pragma: no-cache

-------------------------7dc210152a156e
Content-Disposition: form-data; name="msgID"

647684176835
-------------------------7dc210152a156e
Content-Disposition: form-data; name="phoneNumber"

02-87920182
-------------------------7dc210152a156e
Content-Disposition: form-data; name="msgText"

Hello!
-------------------------7dc210152a156e
Content-Disposition: form-data; name="file1"; filename="photo.png"
Content-Type: image/png

�PNG....(後面的二進位影像資料省略)
_


注意其中的第四行:

Content-Type: multipart/form-data; boundary=-----------------------7dc210152a156e

一般以瀏覽器送出 HTTP Form POST 請求時,我們並不需要擔心如何兜出 Content-Type 這行字串。但如果是在不同平台之間需要以程式送出 POST 請求的時候,例如從 Android 手機程式發出 Form POST 請求給伺服器端的 ASP.NET 應用程式,此時就得了解這些標準參數的格式,才有辦法兜出服務端能夠解析的訊息。

在指定 HTTP request 的 Content-Type 時,multi-part/form-data 代表這個 request 包含了多個區塊的資料,而各區塊則是由後面接著的 boundary 屬性作為分隔的識別字串。所以你會看到上面的範例當中,實際的 request 內容一共出現了四次分隔字串,而最後一個區塊是上傳檔案的部分,其中使用了 Content-Type  來描述檔案內容的 MIME 類型

另一個要注意的地方是換行符號,還是用圖來說明比較清楚吧:


圖中的 [CRLF] 就是換行符號(\r\n),為了避免整張圖太花,我只在第一個區塊當中標示換行符號。

檔案上傳的部分是在範例中的最後一個區塊。你可以看到此區塊的參數宣告還有 filename 屬性,以及使用 Content-Type 來指定該區塊的內容類型。

以上是發送請求的用戶端程式比較需要知道的部分,接著來看伺服器端的 ASP.NET 網頁如何接收檔案。

使用 ASP.NET 提供的 Request.Files 物件

如果傳送檔案的用戶端不是你自己的網頁,或者採用 ASP.NET MVC 框架,當然就不可能有 FileUpload 控制項可以直接處理接收到的檔案了。不過別擔心,ASP.NET 已經幫我們處理好了,只要用 Request 物件的 Files 屬性就可以取得所有用戶端丟過來的檔案。參考底下的程式範例:

    protected void Page_Load(object sender, EventArgs e)
    {
        string relativePath = @"Uploads\";
        string absolutePath = Server.MapPath("~/" + relativePath);
 
        for (int i = 0; i < Request.Files.Count; i++)
        {
            HttpPostedFile aFile = Request.Files[i];
 
            if (aFile.ContentLength == 0 || String.IsNullOrEmpty(aFile.FileName))
            {
                continue;
            }
 
            string displayFileName = Path.GetFileName(aFile.FileName);
            string realFileName = NewFileName(absolutePath, displayFileName);
 
            aFile.SaveAs(realFileName);
  
            // Now we can save the file information to database...
            myDao.SaveFileInformation(....);         }     }

此範例是將前端丟過來的檔案存放至網站的 Uploads 子目錄下,程式碼都很簡單,其中只有 NewFileName 方法沒有列出程式碼。其實它的作用只是在伺服器端的某個指定路徑下產生一個唯一的檔案名稱而已。

自行剖析 Multipart 資料

如果你碰到某些特殊情況,需要自己寫 code 來剖析 HTTP 請求的內容,網路上也可以找到一些範例,例如 Lorenzo Polidori 的 HttpFormParser。我實際用了這個類別,發現它只能處理一個檔案,於是參考它的程式碼,自己改寫了一個可以 parse 多個上傳檔案的工具類別,程式碼列在底下。我想,實務上應該不太會碰到需要自己剖析 HTTP Form POST 內容的情況吧,萬一碰到的時候再回頭來看就行了。

/// /// HttpMultipartHelper
/// Reads a multipart http data stream and parse the content.
/// namespace Huanlin.Web
{
    public delegate void HttpMultipartFileHandler(string filename, string contentType, byte[] content);
 
    public class HttpMultipartHelper
    {
        public static void ParseFile(Stream stream, Encoding encoding,
            HttpMultipartFileHandler multiPartHandler)
        {
            stream.Position = 0;
 
            // Read the stream into a byte array
            byte[] data = ArrayHelper.ToByteArray(stream);
 
            // Copy to a string for header parsing
            string content = encoding.GetString(data);
 
            // The first line should contain the delimiter
            int delimiterEndIndex = content.IndexOf("\r\n");
 
            if (delimiterEndIndex < 0)
            {
                return// just ignore error format.
            }
 
            string boundary = content.Substring(0, content.IndexOf("\r\n"));
 
            string[] sections = content.Split(new string[] { boundary }, StringSplitOptions.RemoveEmptyEntries);
 
            foreach (string currSection in sections)
            {
                if (!currSection.Contains("Content-Disposition"))
                {
                    continue;
                }
 
                // If we find "Content-Disposition", this is a valid multi-part section
                // Now, look for the "name" parameter
                Match nameMatch = new Regex(@"(?<=name\=\"")(.*?)(?=\"")").Match(currSection);
                string name = nameMatch.Value.Trim();
 
                // Look for Content-Type
                Regex re = new Regex(@"(?<=Content\-Type:)(.*?)(?=\r\n\r\n)");
                Match contentTypeMatch = re.Match(currSection);
 
                // Look for filename
                re = new Regex(@"(?<=filename\=\"")(.*?)(?=\"")");
                Match filenameMatch = re.Match(currSection);
 
                // Did we find the required values?
                if (contentTypeMatch.Success && filenameMatch.Success)
                {
                    // Set properties
                    string contentType = contentTypeMatch.Value.Trim();
                    string fileName = filenameMatch.Value.Trim();
 
                    if (multiPartHandler != null)
                    {
                        // Get the start & end indexes of the file contents
                        int startIndex = contentTypeMatch.Index + contentTypeMatch.Length + "\r\n\r\n".Length;
 
                        byte[] delimiterBytes = encoding.GetBytes("\r\n" + boundary);
                        int endIndex = ArrayHelper.IndexOf(data, delimiterBytes, startIndex);
 
                        int contentLength = endIndex - startIndex;
 
                        // Extract the file contents from the byte array
                        byte[] fileData = new byte[contentLength];
 
                        Buffer.BlockCopy(data, startIndex, fileData, 0, contentLength);
 
                        // Callback the event handler provided by the client.
                        multiPartHandler(fileName, contentType, fileData);
                    }
                }
                else if (!string.IsNullOrWhiteSpace(name))
                {
                    // It's just a string parameter, not a file.
                    int startIndex = nameMatch.Index + nameMatch.Length + "\r\n\r\n".Length;
                    string value = currSection.Substring(startIndex).TrimEnd(new char[] { '\r''\n' }).Trim();
 
                    System.Diagnostics.Debug.WriteLine("{0} = {1}", name, value);
                }
            }
        }
    }
}

此類別只提供一個靜態方法:ParseFile。其基本運作方式是這樣的:當你在網頁程式中呼叫 HttpMultipartHelper.ParseFile 方法來取得 end user 上傳的檔案時,必須傳入三個參數:

  • stream - 代表 HTTP 請求的串流物件。ASP.NET 程式的話,傳入 Request.InputStream 即可。
  • encoding - 字元編碼。一般傳入 System.Text.Encoding.UTF8 即可。
  • multipartHandler - 這是一個 HttpMultipartFileHandle 型別的委派物件。每當此方法找到一個檔案,就會 callback 外界提供的的委派方法。

所以,用戶端的網頁程式大概是這麼寫:

    protected void Page_Load(object sender, EventArgs e)
    {
        string relativePath = @"Uploads\";
        string absolutePath = Server.MapPath("~/" + relativePath);
 
        HttpMultipartHelper.ParseFiles(Request.InputStream, Encoding.UTF8,
            (filename, contentType, content) =>
            {
                string displayFileName = Path.GetFileName(filename);
                string realFileName = NewFileName(absolutePath, displayFileName);
 
                File.WriteAllBytes(realFileName, content);
 
                // Now we can save the file information to database...
            });
    }

這裡使用了 lambda 語法來撰寫匿名委派方法,若不熟悉此語法,可參考另一篇 C# 筆記:重訪委派-從 C# 1.0 到 2.0 到 3.0

保存 Request 內容

現在你已經知道 ASP.NET 的 Request 物件有提供 InputStream 屬性了,萬一將來碰到需要 dump 整個 HTTP request 內容的情況時,例如當伺服器端沒有接收到正確資料,或用戶端懷疑伺服器端的程式邏輯有問題,便可以用這個簡易的方式抓蟲。只要簡單幾行程式碼:

    protected void Page_Load(object sender, EventArgs e)
    {
        // log request
        long pos = Request.InputStream.Position;
        string fname = @"C:\" + DateTime.Now.ToString("yyyyMMddHHmmss") + ".txt";
        byte[] content = new byte[Request.InputStream.Length];
        Request.InputStream.Read(content, 0, (int)Request.InputStream.Length);
        File.WriteAllBytes(fname, content);
    }

沒有什麼高深技巧。It just works :)

2 則留言:

  1. 看你的文章真的是會提升功力, 非常感恩!!

    回覆刪除
  2. 解說非常詳細~~
    忍不住說"讚"

    回覆刪除

技術提供:Blogger.
回頂端⬆️