最近在做的一個專案中需要使用到HTML5中引入的WebSocket技術,本來以為應該很容易就能搞定,誰知道在真正上手開發了以後才發現有很多麻煩的地方,雖然我們是一個以前端開發和設計見長的團隊,而且作為一個二手程式猿又長期不被待見,但是為了讓有同樣需求的朋友少走些彎路,我還是決定把實現方法貼在這個地方。
關於WebSocket的基本概念,維基百科上解釋的很清楚,而且網上也能搜出來一大把,這裡就略過不表,直接進入正題。
這次的問題首先有一個前提,就是得用Python來實現這個伺服器,如果對具體語言沒有限制的話,推薦大家首選Node.js的一個第三方庫:Socket.IO,非常好用,10分鐘不打針不吃藥搞定WebSocket Server,而且用JS來寫後端,相信也能對上很多文藝開發者的胃口。
但是如果選擇用Python,google搜尋的結果幾乎都不能用,最要命的問題是,WebSocket協議本身還是一個草案,所以不同瀏覽器支援的協議版本有所不同,Safari 5.1支援的是老版本協議Hybi-02,Chrome 15以及Firefox 8.0支援的是新版本協議Hybi-10,老版本協議和新版本協議在建立通訊的握手方法還有資料傳輸的格式要求上都有所不同,導致網上大多數實現方式只能適用於Safari瀏覽器,並且Safari和C&F瀏覽器之間無法互相通訊。
首先第一步需要解釋的是新、舊版本WebSocket協議的握手方式。我們先來看看三個不同瀏覽器傳送的握手資料的結構:
Chrome:
1 2 3 4 5 6 7 8 |
GET / HTTP/1.1 Upgrade: websocket Connection: Upgrade Host: 127.0.0.1:1337 Sec-WebSocket-Origin: http://127.0.0.1:8000 Sec-WebSocket-Key: erWJbDVAlYnHvHNulgrW8Q== Sec-WebSocket-Version: 8 Cookie: csrftoken=xxxxxx; sessionid=xxxxx |
Firefox:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
GET / HTTP/1.1 Host: 127.0.0.1:1337 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.7; rv:8.0) Gecko/20100101 Firefox/8.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Accept-Language: en-us,en;q=0.5 Accept-Encoding: gzip, deflate Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7 Connection: keep-alive, Upgrade Sec-WebSocket-Version: 8 Sec-WebSocket-Origin: http://127.0.0.1:8000 Sec-WebSocket-Key: 1t3F81iAxNIZE2TxqWv+8A== Cookie: xxx Pragma: no-cache Cache-Control: no-cache Upgrade: websocket |
Safari:
1 2 3 4 5 6 7 8 9 |
GET / HTTP/1.1 Upgrade: WebSocket Connection: Upgrade Host: 127.0.0.1:1337 Origin: http://127.0.0.1:8000 Cookie: sessionid=xxxx; calView=day; dayCurrentDate=1314288000000 Sec-WebSocket-Key1: cV`p1* 42#7 ^9}_ 647 08{ Sec-WebSocket-Key2: O8 415 8x37R A8 4 ;"###### |
取出Sec-WebSocket-Key1中的所有數字字元形成一個數值,這裡是1427964708,然後除以Key1中的空格數目,這裡好像是6個空格,得到一個數值,保留該數值整數位,得到數值N1;對Sec-WebSocket-Key2如法炮製,得到第二個整數N2;把N1和N2按照Big-Endian字元序列連線起來,然後再與另外一個Key3連線,得到一個原始序列ser_key。那麼Key3是什麼呢?大家可以看到在Safari傳送過來的握手請求最後,有一個8位元組的奇怪的字串“;”######”,這個就是Key3。回到ser_key,對這個原始序列做md5算出一個16位元組長的digest,這就是老版本協議需要的token,然後將這個token附在握手訊息的最後傳送回Client,即可完成握手。
新版協議生成Token的方法比較簡單:首先把Sec-WebSocket-Key和一串固定的UUID “258EAFA5-E914-47DA-95CA-C5AB0DC85B11”做拼接,然後對這個拼接後的字串做SHA1加密,得到digest以後,做一次base64編碼,即可獲得Token。
另外需要注意的是,新版本和老版本握手協議回傳給Client的資料結構有所不同,在附件中的server原始碼中寫得很清楚了,看看就能明白。
完成握手只是WebSocket Server的一半功能,現在只能保證這個Server能夠和兩個版本的瀏覽器建立連結了,但是如果試著把Chrome中的訊息傳送給Safari,會發現Safari無法接收。導致這個結果的原因,是因為兩個版本的協議的Data Framing結構不同,也即是在握手建立連線後,Client傳送和接收的資料結構都不一樣。
首先第一步需要獲取不同版本協議下Client傳送過來的原始資料。老版本協議比較簡單,實際上就是在原始資料前加了個’x00′,在最後面加上了一個’xFF’,所以如果Safari的Client傳送一個字串’test’,實際上WebSocket Server收到的資料是:’x00testxFF’,所以只需要剝離掉首尾那兩個字元就可以了。
比較麻煩的是新版本協議的資料,按照新版draft的解釋,Chrome和Firefox發過來的資料包文由以下幾個部分組成:首先是一個固定的位元組(1000 0001或是1000 0002),這個位元組可以不用理會。麻煩的是第二個位元組,這裡假設第二個位元組是1011 1100,首先這個位元組的第一位肯定是1,表示這是一個”masked”位,剩下的7個0/1位能夠計算出一個數值,比如這裡剩下的是 011 1100,計算出來就是60,這個值需要做如下判斷:
如果這個值介於0000 0000 和 0111 1101 (0 ~ 125) 之間,那麼這個值就代表了實際資料的長度;如果這個數值剛好等於0111 1110 (126),那麼接下來的2個位元組才代表真實資料長度;如果這個數值剛好等於0111 1111 (127),那麼接下來的8個位元組代表資料長度。
有了這個判斷之後,能夠知道代表資料長度的位元組在第幾位結束,比如我們舉得例子60,這個值介於0~125之間,所以第二個位元組本身就代表了原始資料的長度了(60個位元組),所以從第三個位元組開始,我們能抓出4個位元組來,這一串位元組叫做 “masks” (掩碼?),掩碼之後的資料,就是實際的data…的兄弟了。說是兄弟,是因為這個資料是實際data根據掩碼做過一次位運算後得到的,獲得原始data的方法是,將兄弟資料的每一位x,和掩碼的第i%4位做xor運算,其中i是x在兄弟資料中的索引。看得眼花是吧,看看下面這個程式碼片段也許就能明白了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
def send_data(raw_str): back_str = [] back_str.append('\x81') data_length = len(raw_str) if data_length < 125: back_str.append(chr(data_length)) else: back_str.append(chr(126)) back_str.append(chr(data_length >> 8)) back_str.append(chr(data_length & 0xFF)) back_str = "".join(back_str) + raw_str |
這樣生成的back_str,就能夠傳送給使用新版協議的Chrome或是Firefox了。
至此,這個簡單的WebSocket Server就完成了,能夠同時相容老版協議和新版協議的Socket連線,以及不同版本之間的資料傳輸。該Server的原始碼請點選這裡下載,需要注意的是裡面用到了twisted框架來跑TCP服務,程式碼寫得不怎麼樣,僅供大家參考。