今天@julyclyde 在微博上問我websocket的細節。但是這個用70個字是無法說清楚的,所以就整理在這里吧。恰好我最近要重構年前寫的websocket的代碼。
眾所周知,HTTP是一種基于消息(message)的請求(request )/應答(response)協議。當我們在網頁中點擊一條鏈接(或者提交一個表單)的時候,瀏覽器給服務器發一個request message,然后服務器算啊算,答復一條response message。主動發起TCP連接的是client,接受TCP連接的是server。HTTP消息只有兩種:request和response。client只能發送request message,server只能發送response message。一問一答,因此按HTTP協議本身的設計,服務器不能主動的把消息推給客戶端。而像微博、網頁聊天、網頁游戲等都需要服務器主動給客戶端推東西,現在只能用long polling等方式模擬,很不方便。
 
OK,來看看internet的另一邊,網絡游戲是怎么工作的?
我之前在一個游戲公司工作。我們做游戲的時候,普遍采用的模式是雙向、異步消息模式。
首先通信的最基本單元是message。(這點和HTTP一樣)
其次,是雙向的。client和server都可以給對方發消息(這點和HTTP不一樣)
最后,消息是異步的。我給服務器發一條消息出去,然后可能有一條答復,也可能有多條答復,也可能根本沒有答復。無論如何,調用完send方法我就不管了,我不會傻乎乎的在這里等答復。服務器和客戶端都會有一個線程專門負責read,以及一個大大的switch… case,根據message id做相應的action。
while( msg=myconnection.readMessage()){
switch(msg.id()){
case LOGIN: do_login(); break;
case TALK: do_talk(); break;
}
}
Websocket就是把這樣一種模式,搬入到HTTP/WEB的框架內。它主要解決兩個問題:
從服務器給客戶端主動推東西。
HTTP協議傳輸效率低下的問題。這一點在web service中尤為突出。每個請求和應答都得帶上很長的http header!
websocket協議在RFC 6455中定義,這個RFC在上個月(2011年12月)才終于定稿、提交。所以目前沒有任何一個瀏覽器是能完全符合這個RFC的最終版的。Google是websocket協議的主力支持者,目前主流的瀏覽器中,對websocket支持最好的就是chrome。chrome目前的最新版本是16,支持的是RFC 6455的draft 13,http://tools.ietf.org/html/draft-ietf-hybi-thewebsocketprotocol-13 。IE9則是完全不支持websocket。而server端,只有jetty和Node.js對websocket的支持比較好。
 
Websocket協議可以分為兩個階段,一個是握手階段,一個是數據傳輸階段。
在建立TCP連接之后,首先是websocket層的握手。這階段很簡單,client給server發一個http request,server給client一個http response。這個階段,所有數據傳輸都是基于文本的,和現有的HTTP/1.1協議保持兼容。
這是一個請求的例子:
Connection:Upgrade
Host:echo.websocket.org
Origin:http://websocket.org
Sec-WebSocket-Key:ov0xgaSDKDbFH7uZ1o+nSw==
Sec-WebSocket-Version:13
Upgrade:websocket
 
(其中Host和Origin不是必須的)
Connection是HTTP/1.1中常用的一個header,以前經常填的是keepalive或close。這里填的是Upgrade。在設計HTTP/1.1的時候,委員們就想到一個問題,假如以后出HTTP 2.0了,那么現有的這套東西怎么辦呢?所以HTTP協議中就預定義了一個header叫Upgrade。如果客戶端發來的請求中有這個,那么意思就是說,我支持某某協議,并且我更偏向于用這個協議,你看你是不是也支持?你要是支持,咱們就換新協議吧!
然后就是websocket協議中所定義的兩個特殊的header,Sec-WebSocket-Key和Sec-WebSocket-Version。
其中Sec-WebSocket-Key是客戶端生的一串隨機數,然后base64之后填進去的。Sec-WebSocket-Version是指協議的版本號。這里的13,代表draft 13。下面給出,我年前寫的發送握手請求的JAVA代碼:
// 生一個隨機字符串,作為Sec-WebSocket-Key
?View Code JAVA
        byte[] nonce = new byte[16];        rand.nextBytes(nonce);        BASE64Encoder encode = new BASE64Encoder();        String chan = encode.encode(nonce);         HttpRequest request = new BasicHttpRequest("GET", "/someurl");        request.addHeader("Host", host);        request.addHeader("Upgrade", "websocket");        request.addHeader("Connection", "Upgrade");        request.addHeader("Sec-WebSocket-Key", chan); // 生的隨機串         request.addHeader("Sec-WebSocket-Version", "13");        HttpResponse response;        try {            conn.sendRequestHeader(request);            conn.flush();            request.toString();            response = conn.receiveResponseHeader();        } catch (HttpException ex) {            throw new RuntimeException("handshake fail", ex);        }
 
服務器在收到握手請求之后需要做相應的答復。消息的例子如下:
HTTP/1.1 101 Switching Protocols
Connection:Upgrade
Date:Sun, 29 Jan 2012 18:05:49 GMT
Sec-WebSocket-Accept:7vI97qQ5QRxq6lD6E5RRX36mOBc=
Server:jetty
Upgrade:websocket
(其中Date、Server都不是必須的)
第一行是HTTP的Status-Line。注意,這里的Status Code是101。很少見吧!Sec-WebSocket-Accept字段是一個根據client發來的Sec-WebSocket-Key得到的計算結果。
算法為:
把客戶端發來的key作為字符串,與” 258EAFA5-E914-47DA-95CA-C5AB0DC85B11″這個字符串連接起來,然后做sha1 Hash,將計算結果base64編碼。注意,用來和” 258EAFA5-E914-47DA-95CA-C5AB0DC85B11″做連接操作的字符串,是base64編碼后的。也就是說,客戶端發來什么樣就是什么樣,不要試圖去做base64解碼。
示例代碼如下:
?View Code CPP
    std::string value=request.get("Sec-WebSocket-Key");    value+="258EAFA5-E914-47DA-95CA-C5AB0DC85B11";    unsigned char hash[20];    sha1::calc(value.c_str(),value.length(),hash);    std::string res=base64_encode(hash,sizeof(hash));    std::ostringstream oss;    oss<<"HTTP/1.1 101 Switching Protocols\r\n"        "Upgrade: websocket\r\n"        "Connection: Upgrade\r\n"        "Sec-WebSocket-Accept: "<<res<<"\r\n";                   connection.send(oss.str());
握手成功后,進入數據流階段。這個階段就和http協議沒什么關系了。是在TCP流的基礎上,把數據分成frame而已。首先,websocket的一個message,可以被分成多個frame。從邏輯上來看,frame的格式如下
isFinal
opcode
isMasked
Data length
Mask key
Data(可以是文本,也可以是二進制)
 
isFinal:
每個frame的第一個字節的最高位,代表這個frame是不是該message的最后一個frame。1代表是最后一個,0代表后面還有。
opcode:
指明這個frame的類型。目前定義了這么幾類continuation、text 、binary 、connection close、ping、pong。對于應用而言,最關心的就是,這個message是binary的呢,還是text的?因為html5中,對于這兩種message的接口有細微不一樣。
isMasked:
客戶端發給服務器的消息,要求加擾之后發送。加擾的方式是:客戶端生一個32位整數(mask key),然后把data和這32位整數做異或。
mask key:
前面已經說過了,就是用來做異或的隨機數。
Data:
這才是我們真正要傳輸的數據啊!!
發送frame時加擾的代碼如下:
        java.util.Random rand ;
        ByteBuffer buffer;
        byte[] dataToSend;
        …
        
        byte[] mask = new byte[4];
        rand.nextBytes(mask);
        buffer.put(mask);
        int oldpos = buffer.position();        
        buffer.put(data);
        int newpos = buffer.position();
        // 按位異或
        for (int i = oldpos; i != newpos; ++i) {
            int maskIndex = (i – oldpos) % mask.length;
            buffer.put(i, (byte) (buffer.get(i) ^ (byte) mask[maskIndex]));
        }
下面討論一下這個協議的某些設計:
為什么要做這個異或操作呢?
說來話長。首先從Connection:Upgrade這個header講起。本來它是留給TLS用的。就是,假如我從80端口去連接一個服務器,然后通過發送Connection:Upgrade,仿照上面所說的流程,把http協議”升級”成https協議。但是實際上根本沒人這么用。你要用https,你就去連接443。我80端口根本不處理https。由于這個header只是出現在rfc中,并未實際使用,于是大多數cache server看不懂這個header。這樣的結果是,cache server可能以為后面的傳輸數據依然是普通的http協議,然后按照原來的規則做cache。那么,如果這個client和server都已經被黑客很好的操控,他就可以往這個cache server上投毒。比如,從client發送一個websocket frame,但是偽裝成普通的http GET請求,指向一個JS文件。但是這個GET請求的目的地未必是之前那個websocket server,可能是另外一臺web server。然后他再去操控這個web server,做好配合,給一個看起來像http response的答復(實際是websocket frame),里面放的是被修改過的js文件。然后cache server就會把這個js文件錯誤的緩存下來,以后發給其他人。
首先,client是誰?是瀏覽器。它在一個不很安全的環境中,很容易受到XSS或者流氓插件的攻擊。假如我們的頁面遭到了xss,使得攻擊者可以利用JS從受害者的頁面上發送任意字符串給服務器,如果沒有這個異或操作,那么他就可以控制什么樣的二進制數據出現在信道上,從而實現上述攻擊。但是我還是覺得有點問題。proxy server一般都會對目的地做嚴格的限制,比如,sina的squid肯定不會幫new.163.com做cache。那么既然你已經控制了一個web server,為什么不讓js直接這么做呢?那篇paper的名字叫《Talking to Yourself for Fun and Pro?t》,有空我繼續看。貌似是中國人寫的。
還有,為什么要把message分成frame呢? 因為HTTP協議有chunk功能,可以讓服務器一邊生數據,一邊發。而websocket協議也考慮到了這點。如果沒有framing功能,那么我必須知道整個message的長度之后,才能開始發送message的data。