C#實(shí)現(xiàn)HTTP服務(wù)器:處理文件上傳---解析MultipartFormDataContent
完整項(xiàng)目托管地址:https://github.com/sometiny/http
HTTP還有重要的一塊:文件上傳。
這篇文章將詳細(xì)講解下,前面實(shí)現(xiàn)了同一個(gè)鏈接處理多個(gè)請求,為了方便,我們獨(dú)立寫了一個(gè)HTTP基類,專門處理HTTP請求。
https://github.com/sometiny/http/blob/main/src/Http/HttpServerBase.cs
本類實(shí)現(xiàn)了簡單的路由功能,路由功能后續(xù)可以使用正則或path2regexp去處理,以處理更復(fù)雜的路由請求。
增加了對靜態(tài)文件的處理,沒匹配到的路由都會(huì)進(jìn)入OnResource邏輯。
增加了WebRoot和UploadTempDir的設(shè)置,WebRoot目錄下的靜態(tài)文件在HTTP請求時(shí)都會(huì)自動(dòng)加載,不需要單獨(dú)寫路由。
UploadTempDir用來臨時(shí)保存上傳的文件。
1、上傳簡介
上傳文件時(shí),使用Content-Type: multipart/form-data; boundary=[BOUNDARY]標(biāo)頭來告訴服務(wù)器,請求實(shí)體為multipart/form-data編碼。
服務(wù)器根據(jù)編碼協(xié)議解析multipart/form-data的內(nèi)容即可,其中[BOUNDARY]為一個(gè)請求實(shí)體“塊”的結(jié)束或開始標(biāo)識,用于解析實(shí)體內(nèi)容。
在瀏覽器中,為form標(biāo)簽增加enctype="multipart/form-data"屬性時(shí),瀏覽器會(huì)自動(dòng)生成對應(yīng)的上傳標(biāo)頭。
例如下面的標(biāo)頭,為Chrome瀏覽器生成的:
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryuHXBXxnxXp0aCz08
2、上傳時(shí)到底傳送了什么格式的數(shù)據(jù)
話不多說,直接上代碼,直觀點(diǎn)。
先從這里https://github.com/sometiny/http/tree/main/bin/Release把web目錄及其內(nèi)容放到你自己的Debug或Release編譯結(jié)果目錄下。
實(shí)現(xiàn)一個(gè)測試服務(wù)器
注意,繼承的是HttpServerBase基類,在OnReceivedPost方法顯示下瀏覽器發(fā)送的內(nèi)容。
public class HttpServer : HttpServerBase { public HttpServer() : base() { //設(shè)置Web根目錄 //方便輸出靜態(tài)文件 WebRoot = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "web")); UplaodTempDir = AppDomain.CurrentDomain.BaseDirectory + "uploads"; //注冊一些路由 RegisterRoute("/", OnIndex); RegisterRoute("/post", OnReceivedPost); } /// <summary> /// 首頁路由處理程序,跳轉(zhuǎn)到index.html /// </summary> /// <param name="request"></param> /// <param name="stream"></param> private bool OnIndex(HttpRequest request, Stream stream) { //跳轉(zhuǎn)到頁面 HttpResponser responser = new ChunkedResponser(301); responser.ContentType = "text/html; charset=utf-8"; responser["Location"] = "/index.html"; responser.Write(stream, "Redirect To '/index.html'"); responser.End(stream); return true; } /// <summary> /// 處理POST數(shù)據(jù) /// </summary> /// <param name="request"></param> /// <param name="stream"></param> /// <returns></returns> private bool OnReceivedPost(HttpRequest request, Stream stream) { HttpResponser responser = new ChunkedResponser(); responser.ContentType = "text/html; charset=utf-8"; responser.Write(stream, "<style type=\"text/css\">body{font-size:12px;}</style>"); responser.Write(stream, "<h4>上傳表單演示</h4>"); responser.Write(stream, $"<a href=\"/index.html\">返回</a><br />"); responser.Write(stream, $"ContentType:{request.ContentType}<br />"); responser.Write(stream, $"Boundary:{request.Boundary}<br />"); ///這里輸出下瀏覽器發(fā)送來的請求 responser.Write(stream, $"<pre>{Encoding.UTF8.GetString( request.RequestBody)}</pre>"); responser.End(stream); return true; } }
運(yùn)行服務(wù)器,瀏覽器訪問:http://127.0.0.1:4189/index.html,展示如下一個(gè)表單。

我們什么都不上傳,直接點(diǎn)提交,看看服務(wù)器收到些什么內(nèi)容。

綠色部分就是我們收到的請求實(shí)體內(nèi)容。
編碼分析
實(shí)例中的[BOUNDARY]為:----WebKitFormBoundarydyAItGK9UU5xVhZq
3、首行:--[BOUNDARY]\r\n 在boundary前面補(bǔ)兩個(gè)中橫線(-)。
首行讀取完畢后,即開始表單項(xiàng)的讀取。
4、非文件表單項(xiàng):
每行一個(gè)標(biāo)頭,直到空行,空行后表單內(nèi)容開始。這里跟Http請求
Content-Disposition: form-data; name="name" 測試hello world! ------WebKitFormBoundarydyAItGK9UU5xVhZq
說明:
Content-Disposition: form-data; name="[表單項(xiàng)名稱]"\r\n\r\n[內(nèi)容]\r\n--[BOUNDARY]
5、文件表單項(xiàng)
標(biāo)頭的讀取和結(jié)束標(biāo)志跟上面的一致。
只是Content-Disposition會(huì)多一個(gè)filename屬性,因?yàn)槲覀儧]選擇文件,filename值為空。
同時(shí),會(huì)提供一個(gè)Content-Type標(biāo)頭來標(biāo)識文件類型。
不排除序應(yīng)用程序會(huì)提供更多的標(biāo)頭,我們只要讀取到空行,關(guān)心我們需要的標(biāo)頭即可。
Content-Disposition: form-data; name="image"; filename="" Content-Type: application/octet-stream ------WebKitFormBoundarydyAItGK9UU5xVhZq
說明:
Content-Disposition: form-data; name="[表單項(xiàng)名稱]"; filename=""\r\nContent-Type: application/octet-stream\r\n\r\n[文件內(nèi)容]\r\n--[BOUNDARY]
6、如何確定結(jié)束標(biāo)識之后是下一個(gè)表單項(xiàng),還是請求實(shí)體的結(jié)尾。
讀取到表單內(nèi)容結(jié)束標(biāo)識后,再往前讀取兩個(gè)字節(jié)。
如果兩個(gè)字節(jié)為\r\n,代表后面還有其他的表單項(xiàng)。
如果兩個(gè)字節(jié)為--,代表所有表單項(xiàng)已讀取完畢,請求實(shí)體也讀完了。
7、實(shí)現(xiàn)上傳請求實(shí)體的解析。
解析使用了兩個(gè)類。
將請求實(shí)體解析為Form和Files:https://github.com/sometiny/http/blob/main/src/Http/Utils/HttpMultipartFormDataParser.cs
Multipart數(shù)據(jù)讀取的輔助流:https://github.com/sometiny/http/blob/main/src/Http/Streams/MultipartReadStream.cs
輔助流里面實(shí)現(xiàn)了核心的數(shù)據(jù)解析,內(nèi)部用到了BoyerMoore字符串查找算法。
我們主要是講解協(xié)議原理,協(xié)議解析這部分可以不用關(guān)心,我寫的數(shù)據(jù)解析也可能不是很嚴(yán)格(我不會(huì)告訴你,我寫完后就看不懂了)。
修改我們上面實(shí)現(xiàn)的服務(wù)器中OnReceivePost方法,我們這次把上傳的表單和文件列出來。
private bool OnReceivedPost(HttpRequest request, Stream stream) { HttpResponser responser = new ChunkedResponser(); responser.ContentType = "text/html; charset=utf-8"; responser.Write(stream, "<style type=\"text/css\">body{font-size:12px;}</style>"); responser.Write(stream, "<h4>上傳表單演示</h4>"); responser.Write(stream, $"<a href=\"/index.html\">返回</a><br />"); responser.Write(stream, $"ContentType:{request.ContentType}<br />"); responser.Write(stream, $"Boundary:{request.Boundary}<br />"); #region 輸出解析后的上傳內(nèi)容 responser.Write(stream, $"<h5>上傳表單數(shù)據(jù):</h5>"); foreach (string formName in request.Form.Keys) { responser.Write(stream, $"{formName}: {request.Form[formName]}<br />"); } responser.Write(stream, $"<h5>上傳文件列表:</h5>"); foreach (FileItem file in request.Files) { responser.Write(stream, $"{file.Name}: {file.FileName}, {file.TempFile}<br />"); } #endregion #region 輸出解析前的上傳內(nèi)容,不能同時(shí)與上面代碼塊運(yùn)行 //responser.Write(stream, $"<pre style=\"font-family:'microsoft yahei',arial; color: green\">{Encoding.UTF8.GetString( request.RequestBody)}</pre>"); #endregion responser.End(stream); return true; }
運(yùn)行服務(wù)器,瀏覽器訪問:http://127.0.0.1:4189/index.html,現(xiàn)在我們選擇幾個(gè)文件,為了方便演示,建議選擇有少量文本的文本文件。
頭像我選擇了兩個(gè)文件,微信選擇了一個(gè)。

提交表單。下面可以看到,服務(wù)器正確處理了表單數(shù)據(jù)和三個(gè)文件數(shù)據(jù)。FileItem對象保存了表單名,文件名,文件類型和文件的臨時(shí)保存路徑,可以將文件移動(dòng)到應(yīng)用實(shí)際的目錄。

移除對OnReceivedPost中對輸出解析前的上傳內(nèi)容代碼塊的注釋,并且把輸出解析后的上傳內(nèi)容代碼塊注釋掉,刷新頁面,可以查看原始未解析的數(shù)據(jù)。可以對照我們前面對上傳數(shù)據(jù)的編碼分析查看下。

8、總結(jié)
1、文件上傳主要是增加了Content-Type的設(shè)置,使服務(wù)器能正確處理上傳的內(nèi)容。
2、請求實(shí)體的解析部分,因?yàn)椴幌馠ttpRequest一樣有Content-Length來標(biāo)識具體的長度,只能用boundary去分析什么時(shí)候開始解析,什么時(shí)候結(jié)束解析。
3、對于上傳的請求,請求實(shí)體解析后,ResponseBody就取不到內(nèi)容了,所以要想看到請求的具體內(nèi)容,不能調(diào)用Form或Files方法,因?yàn)檫@兩個(gè)方法一旦調(diào)用,上傳請求就會(huì)被自動(dòng)解析了。
如果使用第三方去解析HTTP multipart/form-data content的話---HttpMultipartParser,無論.net framwork 版本還是net core版本都可以使用,參考地址: https://github.com/Http-Multipart-Data-Parser/Http-Multipart-Data-Parser,reader/parser of the HTTP multipart/form-data content sent from Windows Runtime via MultipartFormDataContent class. nuget 包管理器直接搜索安裝即可使用。
MultipartReader---http://dul.codeplex.com/ ,ASP.NET reader/parser of the HTTP multipart/form-data content sent from Windows Runtime via MultipartFormDataContent class. nuget 包管理器直接搜索安裝即可使用。

浙公網(wǎng)安備 33010602011771號