蛙蛙推薦:自己寫個IIS玩-協議解析篇
[2] 蛙蛙推薦:自己寫個IIS玩-協議解析篇
這里不是說用System.Web.Hosting.ApplicationHost和System.Net.HttpListener做的那種web server,而是直接用socket api做一個簡單的能收發HTTP包的網絡服務器,當然也不會完全實現RFC 2616,主要學習探索用。
我們先來看HTTP協議解析部分,做一個HTTP協議棧-HttpStatck,大概看一下HTTP協議基礎,
1、消息頭和消息體中間用兩個\r\n(0x0d0x0a)來分割,
2、消息頭之間用\r\n分割,
3、消息頭的個數不定,但有最大數,
4、消息體的大小根據Content-Length頭來確定,
5、消息頭的名字和值用英文半角冒號分割
6、消息頭的第一行用來標識協議是request還是response,及協議的版本,請求的方法,應答碼,應答描述
協議了解了,協議棧就好寫了,如果我們能一次讀取一個完整的包,那我們把整個包讀出來,解析成字符串,然后用IndexOf,Split等函數很快的就能解析出一個個都HttpRequest和HttpResponse,但是真是的網絡中,你可能只能解析到半個半個多包,沒準連消息頭的第一行都分兩次才能接受到,甚至像一個中文字符也有可能會收兩次才能包才能解析成字符串。我們要想提高效率,盡量避免把bytes解析成字符串,另外我們只解析出header給上層應用就行了,body的話暴露成一個Stream就行了,因為你不知道Body的格式,由應用去做處理吧,asp.net也是這樣的,有對應的InputStream和OutStream。
下面是具體的性能方面的分析。
1、在Stack收到異步讀取的網絡包后,首先繼續調用BeginReceive方法,然后再解析收到的包,這是為了防止在解析包的時候出錯,或者線程掛起而造成無法接受剩下的包,當然每次盡量多讀取一些字節,讀取次數多也會降低性能,buffer可以設置的稍微大一些,這個可能要經過具體平臺的測試才能確定最合適的值。這點有不同意見,說不要在剛收到異步讀取回調后就先BeginReceive,應該把包收完再BeginReceive,否則如果本次沒收完包,剩下的包只能在其它的IOCP線程里接收,影響性能,這個我不確認,但是一次接受完緩沖區的所有數據是可以做到的,用Socket.IOControl(FIONREAD, null, outValue)或者socket.Available可以獲取接受緩沖區有多少數據,然后把這些數據收完;但是微軟反對使用這些方法去探察socket的接受數據大小,因為執行這個方法系統需要內部使用鎖鎖定數據計算這個值,降低socket效率。關于接受包這里的最佳實踐,歡迎大家討論。
2、按理說收到包后先放隊列里,再調用解析包方法,解析包的方法順序從隊列里取包解析,但解析包和接受包可以都在一個線程里,沒有必要引入單獨的解析包線程,最后還是考慮不使用隊列,每次直接把收到的字節數組進行解析。原則是我們盡量讓一個線程只適用本線程的私有數據,而不去用全局共享的數據,如果要使用別的線程的數據,就給那個線程發個消息,讓那個線程自己去處理自己線程的數據,而不要直接操作不屬于自己的數據,那樣的話那個數據就得用加鎖之類的線程同步了。線程模型的確定很重要。
3、按理說解析網絡包推薦用Encoding.UTF8.GetDecoder().GetChars()方法,該方法會維持utf8解析狀態,在收到不能解析成一個完整的unicode字符的包的字節數組的時候它可以保存剩下的半截兒包,和下次收到的包一起解析,而不會造成包丟失。但是該方法的參數只能傳入一個char數組,然后我們有可能把多個char數組進行內存拷貝,這就浪費了性能,所以不考慮了。如果該方法能把解析出來的char數組自動填充到一個字節環形鏈表里,我們就可以考慮用它。我們盡量使用.NET自己提供的功能,但是如果不滿足我們的需求的時候,我們就得自己實現去,當然可以反射.NET程序集,借鑒他的做法。
4、我們應該盡量避免把收到的字節數組解析成字符串,然后再按包的規則進行解析,因為把字節數組轉換成字符串也是個耗時的過程,像一些解析包的標志位如分割消息頭和消息體的\r\n\r\n,分割多個消息頭的\r\n,其對應的字節表示值是固定的,如0d0a0d0a,0d0a,我們直接對字節數組進行解析就能區拆出來消息頭字節數組和消息體字節數組。
5、對字符串的操作我們可以用正則表達式,用string類的方法等,但對字節數組就沒這么多的API了,但是我們可以去了解一下正則表達式的原理,先寫出正則正則表達式,再推導出對應的NFA算法,再推導出對應的DFA算法,就可以寫出針對字節數組的算法了。典型的場景是我們需要讀取到字節數組里的0d0a0d0a的token,或者我們知道了表示消息頭的字節數組,我們要把這些字節數組按照0d0a分割成多個子數組,然后再對每個子數組進行utf-8.getstring,這應該比把整個header字節數組轉換成字符串再split性能好一些,因為split會臨時生成多個小字符串,引起很多對象分配操作。其實我們并不應該把大字節數組分割成小字節數組,我們就找到0d0a的位置,然后用utf-8.getstring(bytes,index,length)來按段兒來提取每一行的消息頭。
6、為了防止對接受到的字節數組進行內存拷貝,我們應該把接受到的字節數組放到一個鏈表里,因為我們是順序插入字節,解析的時候也是順序訪問字節數組,所以我認為這里應該用鏈表,而且鏈表的API完全滿足消息解析的要求,如果構建一個環形的字節數組,操作起來比鏈表復雜,而且性能應該也不會比字節鏈表好。
7、在字節鏈表上,我們只要找到對應的包的開頭、結尾節點,然后我們就可以把這段兒鏈表賦值給包對象,然后包對象自己去把這段兒鏈表換算成一個字節數組,進行相應的處理,比如轉換成字符串,進一步解析每行的header,但有的服務只解析出header就可以處理這個包,比如轉發給另一個服務,那么body就不需要轉換成字節數組,更不用轉換成字符串,直接把屬于Body的那段兒字節鏈表(可以進一步封裝成Stream)傳出去就行了。
8、剛開始我在收到字節數組后要先把字節數組fill到字節鏈表里,這個過程會無謂的消耗一些性能,所以我又優化了一下,把字節鏈表改成了字節數組鏈表,但改成字節數組鏈表后,遍歷起來很麻煩,有的鏈表節點上的字節數組有半截兒已經解析給上個包了,下次解析要接著上次解析的地方去解析,所以每個字節數組節點還要保存一個有效數組段兒的開始位置和結束位置,比第一次的代碼更復雜了一些,但是性能要好于前者,
9、還有就是在收到一個半截header或者半截body的情況下,下一次收到包解析的時候盡量避免回溯,比較好的算法是盡量遍歷一次就匹配出所有規則,DFA就是這樣,但得加更多的標志位來保存解析狀態。
10、在解析header的時候也避免先把字節數組鏈表轉換成字節數組,會造成字節數組拷貝,應該一次字節數組鏈表的遍歷就直接解析出所有header,當然可能會跨越多個字節數組節點,但比把多個字節數組節點合并成一個大的字節數組再解析header性能要好不少。
下面來具體看下代碼
BytesLine,表示header中的一行,因為消息頭不會出現中文,所以直接用ASCII編碼,除了header的第一行,消息頭都分為name,value部分,這里用String1和String2表示
Code
BytesNode,該類表示字節數組鏈表中的一個節點,其中Next屬性指向鏈表中的下一個節點,其余的都是一個幫助性的方法和屬性,比如該節點已經解析到什么位置了,有效字節的結束為止,及如何把自己切成兩個,獲取有效字節數組,把有效字節數組解析成字符串等方法。該類盡量做成不變類,成員能用readonly就用readonly,這樣可以在多線程的時候防止加鎖。
Code