众所周知,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
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解码。
示例代码如下:
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 Profit》,有空我继续看。貌似是中国人写的。
还有,为什么要把message分成frame呢? 因为HTTP协议有chunk功能,可以让服务器一边生数据,一边发。而websocket协议也考虑到了这点。如果没有framing功能,那么我必须知道整个message的长度之后,才能开始发送message的data。