模擬HTML表單上傳文件(RFC 1867)
如今使用HTTP協議定制API已經是十分常見的事情,在普通的GET和POST請求中傳遞些參數估計人人都會,但是如果我們需要上傳文件呢?如果只是傳遞單個文件,那么將數據流POST給服務器端即可。但如果需要上傳多個文件,或是在文件之外需要附帶一些信息,那么又該怎么做呢?之前我遇到過一些朋友是這么打算的,他們說,不如就把文件流轉化為文本,然后把它當作一個普通的字段傳遞。這么做自然可以“實現功能”,但缺點也很多。首先,將二進制流轉化為文本會增大體積(例如最常見的BASE64編碼會增大1/3的數據量);其次,既然互聯網上存在相關的協議,又為何要自定義一套規則呢?其實這便是《RFC 1867 - Form-based File Upload in HTML》,它是我們用HTML表單上傳文件時使用的傳輸協議,雖然十分常用,但似乎了解它的人并不多。
普通POST操作
說起HTML表單,大家絕對不會陌生。例如下面這樣的HTML表單:
<input type="text" name="myText1" /><br />
<input type="text" name="myText2" /><br />
<input type="submit" />
</form>
提交時會向服務器端發出這樣的數據(已經去除部分不相關的頭信息):
Host: www.baidu.com
Content-Length: 74
Content-Type: application/x-www-form-urlencoded
myText1=hello+world&myText2=%E4%BD%A0%E5%A5%BD%E4%B8%96%E7%95%8C
對于普通的HTML POST表單,它會在頭信息里使用Content-Length注明內容長度。頭信息每行一條,空行之后便是Body,即“內容”。此外,我們可以發現它的Content-Type是application/x-www-form-urlencoded,這意味著消息內容會經過URL編碼,就像在GET請求時URL里的Query String那樣。在上面的例子中,myText1里的空格被編碼為加號,而myText2,您看得出這是“你好世界”這四個漢字嗎?
使用POST上傳文件
不過之前的HTML表單是無法上傳文件的,因此RFC 1867應運而生,它的目的便是讓HTML表單可以提交文件。它對HTML表單的擴展主要是:
- 為input標記的type屬性增加一個file選項。
- 在POST情況下,為form標記的enctype屬性定義默認值為application/x-www-form-urlencoded。
- 為form標記的enctype屬性增加multipart/form-data選項。
于是,如果我們要使用HTML表單提交文件,則可以使用如下定義:
<input type="text" name="myText" /><br />
<input type="file" name="upload1" /><br />
<input type="file" name="upload2" /><br />
<input type="submit" />
</form>
為了實驗所需,我們創建兩個文件file1.txt和file2.txt,內容分別為“This is file1.”及“This is file2, it's bigger.”。在文本框里寫上“hello world”,并選擇這兩個文件,提交,則會看到瀏覽器傳遞了如下數據:
Host: www.baidu.com
Content-Length: 495
Content-Type: multipart/form-data; boundary=---------------------------7db2d1bcc50e6e
-----------------------------7db2d1bcc50e6e
Content-Disposition: form-data; name="myText"
hello world
-----------------------------7db2d1bcc50e6e
Content-Disposition: form-data; name="upload1"; filename="C:\file1.txt"
Content-Type: text/plain
This is file1.
-----------------------------7db2d1bcc50e6e
Content-Disposition: form-data; name="upload2"; filename="C:\file2.txt"
Content-Type: text/plain
This is file2, it's longer.
-----------------------------7db2d1bcc50e6e--
這段內容比較有趣,值得細細觀察。首先,第一個空行之前自然還是HTTP頭,之后則是Body,而此時的Body也比之前要復雜一些。根據RFC 1867定義,我們需要選擇一段數據作為“分割邊界”,這個“邊界數據”不能在內容其他地方出現,一般來說使用一段從概率上說“幾乎不可能”的數據即可。例如,上面這段數據使用的是IE 9,而我在Chrome下則是這樣的:
Host: www.baidu.com
Content-Length: 473
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryW49oa00LU29E4c5U
------WebKitFormBoundaryW49oa00LU29E4c5U
Content-Disposition: form-data; name="myText"
hello world
------WebKitFormBoundaryW49oa00LU29E4c5U
Content-Disposition: form-data; name="upload1"; filename="file1.txt"
Content-Type: text/plain
This is file1.
------WebKitFormBoundaryW49oa00LU29E4c5U
Content-Disposition: form-data; name="upload2"; filename="file2.txt"
Content-Type: text/plain
This is file2, it's bigger.
------WebKitFormBoundaryW49oa00LU29E4c5U--
很顯然它們兩個選擇了不同的數據“模式”作為邊界——事實上,瀏覽器提交兩次數據時,使用的邊界也可能不會相同,這都沒有問題。
選擇了邊界之后,便會將它放在頭部的Content-Type里傳遞給服務器端,實際需要傳遞的數據便可以分割為“段”,每段便是“一項”數據。從上面的內容中大家應該都能看出數據傳輸的規范,因此便不做細談了。只強調幾點:
- 數據均無需額外編碼,直接傳遞即可,例如您可以看出上面的示例中的“空格”均沒有變成加號。至于這里您可以看到清晰地文字內容,是因為我們上傳了僅僅包含可視ASCII碼的文本文件,如果您上傳一個普通的文件,例如圖片,捕獲到的數據則幾乎完全不可讀了。
- IE和Chrome在filename的選擇策略上有所不同,前者是文件的完整路徑,而后者則僅僅是文件名。
- 數據內容以兩條橫線結尾,并同樣以一個換行結束。在網絡協議中一般都以連續的CR、LF(即\r、\n,或0x0D、Ox0A)字符作為換行,這與Windows的標準一致。如果您使用其他操作系統,則需要考慮它們的換行符。
實現
了解上述策略之后,使用編程來實現文件上傳也是順理成章的事情,例如我這里便編寫了一段簡單的代碼實現這一功能。
首先,我們定義一個Part類,表示每“段”,它的Write方法會寫入整段數據。每段數據分為Header和Body兩部分,使用WriteHeader和WriteBody兩個抽象方法寫入:
{
protected abstract void WriteHeader(StreamWriter writer);
protected abstract void WriteBody(StreamWriter writer);
public void Write(StreamWriter writer)
{
this.WriteHeader(writer);
writer.WriteLine();
this.WriteBody(writer);
}
}
接著便是表示普通字段的NormalPart和文件上傳得FilePart:
{
public string Name { get; set; }
public string FilePath { get; set; }
protected override void WriteHeader(StreamWriter writer)
{
writer.WriteLine(
"Content-Disposition: form-data; name=\"{0}\"; filename=\"{1}\"",
this.Name,
Path.GetFileName(this.FilePath));
writer.WriteLine("Content-Type: application/octet-stream");
}
protected override void WriteBody(StreamWriter writer)
{
var data = File.ReadAllBytes(this.FilePath);
writer.Flush();
writer.BaseStream.Write(data, 0, data.Length);
writer.WriteLine();
}
}
最后便是統一寫入各段的Write方法,我在這里使用新建的GUID作為“邊界”:
{
var guidBytes = Guid.NewGuid().ToByteArray();
var boundary = "----------------" + Convert.ToBase64String(guidBytes);
foreach (var p in parts)
{
writer.WriteLine(boundary);
p.Write(writer);
}
writer.WriteLine(boundary + "--");
}
其實就是這么簡單。不過在實際情況中可能會復雜一些。例如,由于HTTP協議需要先發送頭信息,因此我們需要提前計算出Content-Length再傳輸所有內容,不過我相信這對您來說也不會是件難事。
其他
世界上已經有了足夠多的協議,在我看來在絕大部分情況下都無所謂使用自定義的協議。協議在制定時,往往也會考慮到安全、性能等諸多方面,有時候我們自己所謂的“顧慮”其理由也并不充分。更重要的是,使用現成的協議,我們往往都有現成的實現,對于開發和測試都會有很大幫助。
RFC 1867是一個很簡單的協議,當然再簡單也不是我這短短一篇文章可以完整描述的,其中很多細節(例如在同一個“段”中上傳多個文件)就要靠您自己去挖掘了。