WebSocket 简介及应用实例
HTML5 的出现,标志着后 Flash 时代各种现代浏览器的集体爆发,也是谨防 Adobe 一家独大的各家厂商们,历经多年各自为战,想换个活法儿并终于达成一定共识后,所积kao累bei的技术的一次集中释放 — 正所谓 “H5 是个筐,什么都可以往里装”。
其中引人瞩目并被广泛支持的一项,就是此次要谈论的 WebSocket 了。本文将尝试说明它被用来解决什么问题,以及与久经沙场的“传统” Socket 又有什么异同等基础问题。
I. 定义及由来
望文而生义,面对 WebSocket 这个名称,web
无需做太多解释,傻傻分不清楚的 socket
看着也是相当的面熟;甭管有没有联系,先来了解一下也无妨:
(1.1) 传统的 Socket API
Socket 往往指的是 TCP/IP 网络环境中的两个连接端,以及为方便此类开发所设计的一组编程 API
如图,英文单词 “socket” 的字面原义是 “孔” 或 “插座”。
作为一个技术用语时,socket
通常取后一种意思,像一个多孔插座。用于描述一个通信链路两端的 IP 地址和端口等,可以用来实现不同设备之间的通信。Socket
、TCP Socket
都是通用的叫法,中文一般习惯性的译作**“套接字”、“TCP套接字”** 等。
…至于为嘛把“插座儿”翻译成“套接字”,好奇的程序猿并不在少数,科考文章在文章底部参考链接中可以找到。
可以将服务端主机想象成一个布满各种插座的房间,每个插座有一个编号,有的插座提供 220 伏交流电,有的提供固定电话信号,有的则提供有线电视节目。客户端软件将插头接入不同编号的插座,就可以得到不同的服务
Socket API 所处的楼层
OSI 模型作为一种概念模型,由国际标准化组织(ISO)提出,一个试图使各种计算机在世界范围内互连为网络的标准框架。我们熟悉的 HTTP、FTP 等协议都工作在最顶端的应用层(Application Layer)。
而 **TCP/IP 协议族(Protocol Suite)**将软件通信过程抽象化为四个抽象层,常被视为是简化的七层OSI模型。当多个层次的协议共同工作时,类似数据结构中的堆栈,因此又被称为 TCP/IP 协议栈(Protocol Stack)。
单说 TCP 的话,指的是面向连接的一种传输控制协议。TCP 连接之后,客户端和服务器可以互相发送和接收消息,在客户端或者服务器没有主动断开之前,连接一直存在,故称为长连接。
Socket 其实并不是一个标准的协议,而是应用层与 TCP/IP 协议族通信的中间软件抽象层,它是一组接口,工作位置基本在 OSI 模型会话层(第5层),是为了方便大家直接使用更底层协议(一般是 TCP 或 UDP )而存在的一个抽象层。
在设计模式中,Socket其实就是一个门面(facade)模式,它把复杂的 TCP/IP 协议族隐藏在 Socket API 后面,对用户来说,一组简单的接口就是全部,让 Socket 去组织数据,以符合指定的协议
最早的一套 Socket API 是采用 C 语言实现的,也就成为了 Socket 的事实标准。
常见的 Socket API 实现
一些语言的实现
传统的后端编程语言基本都有 Socket API 的封装;而在 HTML5 出现之前,要想用纯前端技术实现 TCP Socket 的客户端,也基本只有 Java Applet (java.net.Socket
或 java.net.DatagramSocket
或 java.net.MulticastSocket
) 、Flash (flash.net.Socket
或 flash.net.XMLSocket
) 或 Silverlight(System.Net.Sockets
) 等可以选择。
下面以 PHP 的 服务器/客户端 实现为例,演示一个最基础的例子:
<?php //server.php set_time_limit(0); $ip = '127.0.0.1'; $port = 1999; // 创建一个Socket $sock = socket_create(AF_INET,SOCK_STREAM,SOL_TCP); // 绑定Socket地址和端口 $ret = socket_bind($sock,$ip,$port); // 开始监听链接 $ret = socket_listen($sock,4); $count = 0; //最多接受几次请求后就退出 do { // 另一个Socket来处理通信 if (($msgsock = socket_accept($sock)) >= 0) { // 发到客户端 $msg ="server: HELLO CLIENTS!\n"; if (socket_write($msgsock, $msg, strlen($msg))) { echo "发送成功!\n"; } // 获得客户端的输入 $buf = socket_read($msgsock,8192); $talkback = "接受成功!内容为:$buf\n"; echo $talkback; if(++$count >= 5){ break; }; } // 关闭sockets socket_close($msgsock); } while (true); socket_close($sock); echo "TCP 连接关闭OK\n"; ?>
<?php //client.php error_reporting(E_ALL); set_time_limit(0); $port = 1999; $ip = "127.0.0.1"; // 创建Socket $socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); // 绑定Socket地址和端口 $result = socket_connect($socket, $ip, $port); if ($result >= 0) echo "TCP 连接OK\n"; $in = "client: HELLO SERVER!\r\n"; if(socket_write($socket, $in, strlen($in))) { echo "发送成功!\n"; } $out = ''; while($out = socket_read($socket, 8192)) { echo "接受成功!内容为:",$out; } socket_close($socket); echo "TCP 连接关闭OK\n"; ?>
(1.2) HTML5 带来的 WebSocket 协议
WebSockets
为 C/S 两端提供了实时交互通信的能力,允许服务器主动发送信息给客户端,是一种区别于 HTTP 的全新双向数据流协议
简单的说,传统的 TCP Socket 是一套相对标准化的 API,而出现时间不久的 WebSocket 是一种网络协议 — 两码事。
WebSocket 底层是基于 TCP 协议的,所以早期草案中叫做 TCPConnection,最后之所以改名,其实是借用了传统 Socket 沟通 TCP 网络两端的意思而已。
要解决的问题
WebSocket 的用武之地
大部分传统的方式既浪费带宽(HTTP HEAD 是比较大的),又消耗服务器 CPU 占用(没有信息也要接受请求);而 WebSocket 则会大幅降低上述的消耗,更适用于以下场景:
- 实时性要求高的应用
- 聊天室
- IoT (物联网 – internet of things)
- 在线多人游戏
兼容性也令人满意,非要说何时不适用的话,大概就是少数必须兼容老旧浏览器,或者对实时要求明显不高的情况下了。
HTTP 的扩展
WebSocket 连接的 URL 使用 ws://
或 wss://
等开头,其加密、cookie 等策略和对应的 HTTP/HTTPS 基本相同。
HTTP、WebSocket 等应用层协议,都是基于 TCP 协议来传输数据的,可以把这些高级协议理解成对 TCP 的封装。
Websocket协议是为了解决web即时应用中服务器与客户端浏览器全双工通信的问题而设计的,是完全意义上的Web应用端的双向通信技术,可以取代之前使用半双工HTTP协议而模拟全双工通信,同时克服了带宽和访问速度等的诸多问题。协议定义为ws和wss协议,分别为普通请求和基于SSL的安全传输,占用端口与http协议系统,ws为80端口,wss为443端口,这样可以支持HTTP代理。
协议包含两个部分,第一个是“握手”,第二个是数据传输。
一、Websocket URI
定义的两个协议框架ws和wss与http类似,而且各自部分的要求也是在HTTP协议中使用的一样,各自的URI如下:
ws-URI = “ws:” “//” host [ “:” port ] path [ “?” query ]
wss-URI = “wss:” “//” host [ “:” port ] path [ “?” query ]
其中port是可选项,query前接“?”。
二、握手(Opening & Closing Handshake)打开连接
当建立一个Websocket连接时,为了保持基于HTTP协议的服务器软件和中间件进行兼容工作,客户端打开一个连接时使用与HTTP连接的同一个端口到服务器进行连接,这样被设计为一个升级的HTTP请求。
1、发送握手请求
此时的连接状态是CONNECTING,客户端需要提供host、port、resource-name和一个是否是安全连接的标记,也就是一个WebSocket URI。
客户端发送的一个到服务器端握手请求如下:
GET /chat HTTP/1.1 Host: server.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Origin: http://example.com Sec-WebSocket-Protocol: chat, superchat Sec-WebSocket-Version: 13
这个升级的HTTP请求头中的字段顺序是可以随便的。与普通HTTP请求相比多了一些字段。
-
- Connection必须设置Upgrade,表示客户端希望连接升级。
- Upgrade字段必须设置Websocket,表示希望升级到Websocket协议。
- Sec-WebSocket-Key是随机的字符串,服务器端会用这些数据来构造出一个SHA-1的信息摘要。把“Sec-WebSocket-Key”加上一个特殊字符串“258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然后计算SHA-1摘要,之后进行BASE-64编码,将结果做为“Sec-WebSocket-Accept”头的值,返回给客户端。如此操作,可以尽量避免普通HTTP请求被误认为Websocket协议。
- Sec-WebSocket-Version 表示支持的Websocket版本。RFC6455要求使用的版本是13,之前草案的版本均应当弃用。
- Origin字段是可选的,通常用来表示在浏览器中发起此Websocket连接所在的页面,类似于Referer。但是,与Referer不同的是,Origin只包含了协议和主机名称。
- 其他一些定义在HTTP协议中的字段,如Cookie等,也可以在Websocket中使用。
- Sec-WebSocket-Protocol:字段表示客户端可以接受的子协议类型,也就是在Websocket协议上的应用层协议类型。上面可以看到客户端支持chat和superchat两个应用层协议,当服务器接受到这个字段后要从中选出一个协议返回给客户端。
- 请求的WebSocket URI必须要是定义的有效的URI。
- 如果客户端已经有一个WebSocket连接到远程服务器端,不论是否是同一个服务器,客户端必须要等待上一个连接关闭后才能发送新的连接请求,也就是同一客户端一次只能存在一个WebSocket连接。如果想同一个服务器有多个连接,客户端必须要串行化进行。如果客户端检测到多个到不同服务器的连接,应该限制一个最大连接数,在web浏览器中应该设定最多可以打开的标签页的数目。这样可以防止到远程服务器的DDOS攻击,但这是对到多个服务器的连接,如果是到同一个服务器连接,并没有数目限制。
- 如果使用了代理服务器,那么客户端建立连接的时候需要告知代理服务器向目标服务器打开TCP连接。
- 如果连接没有打开,一定是某一方出现错误,此时客户端必须要关闭再次连接的尝试。
- 连接建立后,握手必须要是一个有效的HTTP请求
- 请求的方式必须是GET,HTTP协议的版本至少是1.1
- Upgrade字段必须包含而且必须是”websocket”,Connection字段必须内容必须是“Upgrade”
- Sec-Websocket-Version必须,而且必须是13 (固定版本号)
2、返回握手应答
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= Sec-WebSocket-Protocol: chat
响应头握手过程中是服务器返回的是否同意握手的依据。
- 首行返回的是HTTP/1.1协议版本和状态码101,表示变换协议(Switching Protocol)
- Upgrade 和 Connection:这两个字段是服务器返回的告知客户端同意使用升级并使用websocket协议,用来完善HTTP升级响应
- Sec-WebSocket-Accept:服务器端将加密处理后的握手Key通过这个字段返回给客户端表示服务器同意握手建立连接。
- Sec-Websocket-Procotol:服务器选择的一个应用层协议。
上述响应头字段被客户端浏览器解析,如果验证到Sec-WebSocket-Accept字段的信息符合要求就会建立连接,同时就可以发送WebSocket的数据帧了。如果该字段不符合要求或者为空或者HTTP状态码不为101,就不会建立连接。
- 解析握手请求头:获取握手依据Key并进行处理,检测HTTP的GET请求和版本是否准确,Host字段是否有权限,Upgrade字段中websocket是一个与大小写无关的ASCII字符串,Connection字段是一个大小写无关的”Upgrade”ASCII字符串,Websocket协议版本必须为13,其他的关于Origin、Protocol和Extensions可选。
- 发送握手响应头:检测是否是wss协议连接,如果是就是用TLS握手连接,否则就是普通连接。服务器可以添加额外的验证信息到客户端进行验证。当进行一系列验证之后,服务器必须返回一个有效的HTTP响应头。响应头中每一行一个字段,结束必须为“\r\n”,使用的ABNF语法。
浏览器中的实现
在浏览器中可以直接调用 WebSocket
对象,其定义如下:
enum BinaryType { "blob", "arraybuffer" }; [Constructor(USVString url, optional (DOMString or sequence<DOMString>) protocols = []), Exposed=(Window,Worker)] interface WebSocket : EventTarget { readonly attribute USVString url; // ready state const unsigned short CONNECTING = 0; const unsigned short OPEN = 1; const unsigned short CLOSING = 2; const unsigned short CLOSED = 3; readonly attribute unsigned short readyState; readonly attribute unsigned long long bufferedAmount; // networking attribute EventHandler onopen; attribute EventHandler onerror; attribute EventHandler onclose; readonly attribute DOMString extensions; readonly attribute DOMString protocol; void close([Clamp] optional unsigned short code, optional USVString reason); // messaging attribute EventHandler onmessage; attribute BinaryType binaryType; void send(USVString data); void send(Blob data); void send(ArrayBuffer data); void send(ArrayBufferView data); };
使用起来大概是这样的:
var ws = new WebSocket('ws://www.xxx.com/some.php'); ws.send('xxx'); //每次只能发送字符串 ws.onmessage = function(event) { var data = event.data; }; ws.onerror = function() { ws.close(); };
II. 一个多用户交互的 WebSocket 实例
这里随便设想一个用户场景,比如我们要做一个在线纸牌游戏,肯定就是一个多人进入同一个房间的形式,并且每个人的动作能广播给其他人。
下面用 WebSocket 做一个最基础的验证原型,让每个玩家知道其他人的进入、离开、出牌、悔牌,甚至是耍赖换牌等:
(2.1) 服务器端的实现
我们用 nodejs+expressjs 搭建基础服务器,并用 https://github.com/websockets/ws 封装的库实现 WebSocket 协议的服务器端逻辑:
// server.js var express = require('express') var ws = require('./ws') var app = express() app.get('/', function (req, res) { res.sendFile(__dirname + '/ws.html'); }) app.listen(3000, function () { console.log('Example app listening on port 3000!') })
// ws.js const { Server, OPEN } = require('ws'); const clients = []; //array of websocket clients const cardsArr = []; //array of {cardId, count, title, ...} let _lock = false; const wss = new Server({port: 40510}) wss.on('connection', function (ws) { const _cid = clients.push(ws) - 1; ws.on('message', function (json) { const { act, cid, data } = JSON.parse(json); switch (act) { case 'client:join': _onCustomerJoin(ws, _cid); break; case 'client:leave': _onCustomerLeave(cid); break; case 'client:add': //增加牌 _onAddCard(cid, data); break; case 'client:update': //修改牌 _onUpdateCard(cid, data); break; case 'client:remove': //删除牌 _onRemoveCard(cid, data); break; case 'client:win': //下单 _onWin(cid); break; default: console.log('received: %s', act, cid) break; } }); }); function _ensureLock(func) { return function() { if (_lock) return; _lock = true; const rtn = func.apply(null, arguments); _lock = false; return rtn; }; } function _findCard(cardId) { const cidx = cardsArr.map(Card=>Card.cardId).indexOf(cardId); return cidx; } const _broadcast = (excludeId, msg, data=null)=>{ clients.forEach( (client, cidx)=>{ if (cidx === excludeId) return; if (client && client.readyState === OPEN) { client.send(JSON.stringify({ act: 'server:broadcast', msg: msg, data: data })); } } ); }; const _onCustomerJoin = (ws, cid)=>{ ws.send(JSON.stringify({ act: 'server:regist', data: { cid: cid } })); _broadcast(cid, '玩家加入:', {cid: cid}); }; const _onCustomerLeave = (cid)=>{ clients[cid].terminate(); clients.splice(cid, 1); _broadcast(cid, '玩家退出:', {cid: cid}); }; const _onAddCard = _ensureLock( (cid, data)=>{ const d = _findCard(data.cardId); if (d !== -1) { cardsArr.splice(d, 1); } cardsArr.push(data); _broadcast(-1, '玩家添加了牌', { cid: cid, Card: data, cardsArr: cardsArr }); } ); const _onUpdateCard = _ensureLock( (cid, data)=>{ const d = _findCard(data.cardId); if (d === -1) return; cardsArr[d] = data; _broadcast(-1, '玩家更改了牌', { cid: cid, Card: data, cardsArr: cardsArr }); } ); const _onRemoveCard = _ensureLock( (cid, data)=>{ const d = _findCard(data.cardId); if (d === -1) return; cardsArr.splice(d, 1); _broadcast(-1, '玩家删除了牌', { cid: cid, Card: data, cardsArr: cardsArr }); } ); const _onWin = _ensureLock( (cid)=>{ //do sth. here _broadcast(cid, '玩家胡牌了'); } );
(2.2) 客户端的实现
<h1></h1> <div></div> <button onclick="_add()">出牌</button> <button onclick="_update()">换牌</button> <button onclick="_remove()">悔牌</button> <button onclick="_win()">胡牌</button> <button onclick="_leave()">离开</button> <script> let cid = null; const ws = new WebSocket('ws://localhost:40510'); ws.onopen = function () { console.log('websocket is connected ...'); _send({ act: 'client:join' }); }; ws.onmessage = function (ev) { const { act, msg, data } = JSON.parse(ev.data); switch(act) { case 'server:regist': cid = data.cid; console.log(`regist: cid is ${cid}`); document.querySelector('h1').innerHTML = 'I AM: ' + cid; break; case 'server:broadcast': console.log('从服务器端接收的广播:', msg, data); if (data && data.cardsArr) { document.querySelector('div').innerHTML = JSON.stringify( data.cardsArr, null, 4 ); } break; default: console.log(ev); break; } } function _send(json) { ws.send(JSON.stringify(json)); } function _add() { _send({ act: 'client:add', cid: cid, data: { cardId: 111, count: 1, title: '红桃A' } }) } function _update() { _send({ act: 'client:update', cid: cid, data: { cardId: 111, count: 2, title: '黑桃9' } }) } function _remove() { _send({ act: 'client:remove', cid: cid, data: { cardId: 111 } }) } function _win() { _send({ act: 'client:win', cid: cid }) } function _leave() { _send({ act: 'client:leave', cid: cid }) } </script>
(2.3) 运行效果
玩家 1 加入:
玩家 1 出牌:
玩家 1 胡牌并退出:
与 WebSocket 类似的技术
实际上,每当谈到实时双向通信的问题时,我们自然会想起历年来一些基于 HTTP 技术的尝试;也正是基于这些之前工作中的实践和困扰,WebSocket 才应运而生。让我们大概回顾一下相关的方案及其缺陷:
轮询 (Polling)
借助于 setInterval() 等方式,客户端不断的发送请求并得到响应。这种做法比较简单,可以在一定程度上解决问题。不过对于轮询的时间间隔需要进行仔细考虑。轮询的间隔过长,会导致用户不能及时接收到更新的数据;轮询的间隔过短,会导致查询请求过多,增加服务器端的负担。
长轮询 (Long Polling)
这是对轮询的一种改进。客户端发出请求后,服务器端用 while(true) 等方式阻塞住请求,直到有可用数据才发送响应数据,而客户端收到响应后再发送下一个请求。
这种方式又被成为 “Hanging GET”、“反向 Ajax” 或 “Comet” 等,虽然看上去很像服务器推送,但仍然是基于 HTTP 的一种慢响应;且在数据更新频繁的情况下,其效率并不优于一般的轮询。
HTTP 流 (Streaming)
使用 HTTP 1.1 且响应头中包含 Transfer-Encoding: chunked
的情况下,服务器发送给客户端的数据可以分成多个部分,保持打开(while-true, sleep等),并周期性 flush() 分块传输。
客户端只发送一个HTTP连接,在 xhr.readyState==3 状态下,用 xhr.responseText.substring 获取每次的数据。
但是数据响应可能会因 代理服务器 或 防火墙 等中间人造成延迟,所以可能还要额外探测这种情况以切换到长轮询方式。
SSE (Server-Sent Events)
SSE 规范也是 HTML 5 规范的一个组成部分。服务器端响应的内容类型是text/event-stream
,在浏览器端使用 EventSource
对象处理返回的数据。
比之于 WebSocket,SSE 的缺点在于:
- 不支持 CORS
- 单向通道,只能服务器向浏览器端发送
- 浏览器兼容性稍差
III. 总结
- 传统的 TCP Socket 往往指的是 TCP/IP 网络环境中的两个连接端,以及为方便此类开发所设计的一组编程 API
- WebSockets 为 C/S 两端提供了实时交互通信的能力,允许服务器主动发送信息给客户端
- WebSockets 是 HTML 5 规范的一个组成部分,是一种区别于 HTTP 的全新双向数据流协议
- 全双工通信的 WebSockets 有效改善了之前 长轮询 等方式的弊端
- WebSockets 适用于实时性要求高的应用、聊天室、多人游戏等
参考:
https://juejin.im/post/5ae3eb9b51882567382f5767
https://www.cnblogs.com/oshyn/p/3574497.html
http://www.cnblogs.com/hustskyking/p/websocket-with-node.html
http://www.cnblogs.com/hustskyking/p/websocket-with-php.html
https://www.cnblogs.com/zxtceq/p/6963964.html