本文将基于腾讯云Lighthouse(轻量应用服务器)实例,对WebSocket进行简单的示例演示,以及初步的协议原理探索。帮助大家直观地感受双向通讯的特点以及常见的基于JavaScript的实现方法。
0x00 背景概念
WebSocket是近些年适用广泛且流行的网络传输协议,它使得在Web等HTTP协议的应用场景中,可以非常方便地实现类似TCP的全双工通信。这种建立在一个持久连接之上的基于消息的双向通信机制,相比HTTP的Request-Response的事务请求模式,可以让客户端与服务器端的通讯变得丰富和灵活。
WebSocket协议在2011年由IETF组织标准化为RFC6455,浏览器的WebSocket API由W3C标准化被各大主流浏览器全面支持。目前WebSocket已是服务器端向客户端推送数据等功能的标准协议,在站内信、聊天室、新闻推送、视频弹幕发送等多种场景下应用广泛。
0x01 示例目标
首先,那么让我们直观感受一下什么是全双工通信的协议。简单说就是在通讯双方可以在任意时刻,根据自身状态向对端发送数据。而不是受限于发送请求/超时前返回等类似HTTP的精简固定模式,就像一直在握着手交谈。
如下图所知,描述了客户端和服务器的一次连接及通信过程。可以看出客户端通过不断探索试探以及根据服务器端的返回,完成了和服务器端友好的“相互沟通”,而非直接地一问一答。
接下来,我们将用最精简的WebSocketAPI以及服务端代码实现类似的功能。
0x02 环境准备
工欲善其事,必先利其器。选择一台计算/网络性能强劲且操作简易的云服务器是上路体验的第一步。
目前腾讯云最流行的IaaS级产品莫过于Lighthouse(轻量应用服务器)了。该产品以套餐形式提供了便捷的云主机选购,网络流量包、应用镜像以及免密登录等特性也更加注重了人性化的体验。Lighthouse作为目前最炙手可热的面向个人开发者及中小企业的新一代云服务器产品,特别适合搭建个人博客、网站、论坛、小型应用等多种场景。另外,其良心的价格和促销力度也是前所未有,其持续运营的策略是相对面向未来的。总之,如果需要一台CVM(云服务器),可以考虑先使用Lighthouse。
详细的产品介绍可以参考这里:Lighthouse产品介绍,点击前往 购买传送门,如下图所示:
可以看到,Lighthouse整个购买页只有这一页,步骤也很清晰明确,仅仅需要关注选择镜像和套餐就足够了。
当然也有另一种选择,用Ubuntu/CenteOS等系统镜像,那就需要安装Node.js,其实过程也不复杂。官网下载Node.js最新的x64二进制预编译包,解压到/usr/local目录即可:
sudo tar -C /usr/local --strip-components=1 -Jxvf node-v12.18.3-linux-x64.tar.xz
Lighthouse的“套餐”其实是对CPU/内存/网络的流量带宽等基础资源“打包”的概念,通常根据需要选择即可。对于本文的实验选择最简单的套餐也足够。
然后打开咱们最顺手的JavaScript语言编辑器,准备开始下一步吧。
0x03 代码演示
本节我们正式讲解浏览器端WebSocketAPI,以及服务器端Node.js实现的基本方法。
客户端实现
浏览器端两个简单的html/js文件即可:
➜ websocket-example git:(master) ✗ tree client
client
├── index.html
└── index.js
index.js内容如下:
const uri = 'ws://' + location.host + ':3000/'
const socket = new WebSocket(uri)
socket.addEventListener('message', (event) => {
try {
let data = JSON.parse(event.data)
console.log(`TA说: “${data.content}”`)
if (data.detail) console.log('详情: ', data.detail)
} catch(e) {
console.log(e)
}
})
socket.addEventListener('open', (event) => {
socket.send(JSON.stringify({ 'content': '我来了' }))
})
socket.addEventListener('close', (event) => {
console.log('被拒绝了。')
})
function say(content, detail) {
socket.send(JSON.stringify({ 'content': content, 'detail': detail }))
}
我们通过WebSocket()函数初始化和服务器的连接,实例化到WebSocket对象(socket)。然后只需实现并注册监听器事件函数即可,就如同一般的DOM对象事件处理类似。这里统一用了addEventListener函数,也可以用更简洁的onopen()
、onclose()
、onmessage()
方法来实现连接建立、连接断开以及有数据消息到达的处理函数。相关API的详细介绍可以参考MDN的文档。
注意:此处仅做和示例演示,忽略了错误处理onerror(),真实场景中应该妥善处理错误(如连接被服务器断开等),或者用封装更全面的WebSocket客户端库。
另外,注意到我们在连接建立时主动向服务器端发送了第一条消息。而且我们还实现了一个say()
方法向服务器端发送消息,后续将用其在console中直接调用。
页面结构我们这里用最简单的元素,加载上述脚本即可(关于页面设计超出非本文范围,后续会有篇幅专门介绍)。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>websocket example</title>
</head>
<body>
<p>websocket example</p>
<script src="index.js"></script>
</body>
</html>
运行(serve)客户端静态代码有很多方式,比如常见的Nginx即可,这里我们用npm下的http-server工具,更加方便。
npm install -g http-server
http-server -p 80 client
运行结果
打开console,我们来试试验证下和服务器“通话”。
愉快的对话:
尬聊的对话:
服务端实现
const WebSocket = require('ws')
const wsServer = new WebSocket.Server({ port: 3000 })
// client session state
const wsClients = {}
wsServer.on('connection', (ws, req) => {
let clientId = req.headers['sec-websocket-key']
console.log(`client ${clientId} connected.`)
wsClients[clientId] = { '状态': '无聊', '兴奋度': 0 } // 状态枚举:'无聊', '起兴', '热情'
ws.on('message', (message) => {
message = JSON.parse(message)
handle(ws, message, wsClients[clientId])
})
ws.on('close', (message) => {
console.log(`client ${clientId} closed.`)
delete wsClients[clientId]
})
})
// handle message of the specific client and emit
function handle(ws, message, state) {
console.log(message.content)
let shouldClose = false
let content = ''
let detail = null
switch (state['状态']) {
case '无聊':
switch (message.content) {
case '我来了': content = '才来啊'; break
case '路上堵': state['状态'] = '起兴'; content = '喝点啥'; detail = Beverage; break
default: content = '呵呵'; shouldClose = true; break
}; break
case '起兴':
switch (message.content) {
case '我看看': content = '一般般'; break
case '再来不': content = '不错哦'; state['兴奋度'] = state['兴奋度'] + 1; break
case '听我的':
if (state['兴奋度'] >= 2) { state['状态'] = '热情'; content = '随你哦'; detail = '<已解锁>' }
else { content = '呵呵'; shouldClose = true }
break
default: content = '呵呵'; shouldClose = true; break
}
break
case '热情':
try {
content = eval(message.content)
} catch (e) {
content = '呵呵'; shouldClose = true
}
break
default: content = '呵呵'; shouldClose = true; break
}
ws.send(JSON.stringify({ 'content': content, 'detail': detail }))
if (shouldClose) ws.close() // 无话可说, 主动告辞
}
const Beverage = [
'Cosmopolitan', 'Daiquiri', 'Negroni', 'Mojito', 'Old fashion', 'Screwdriver', 'Tequila Sunrise', 'Whiskey Sour'
]
WebSocket的服务端逻辑代码理论上可以用任何编程语言实现,这里我们采用最方便的Node.js(ws库)来实现。
服务器端代码看起来很客户端有不少相似出,比如也是通过实现WebSocket连接对象的事件监听器函数来实现功能。
不同的是:
- 后台需要记录和每一个客户端连接的信息状态,这里通过
sec-websocket-key
来作为ID标识一个客户端并记录在全局的字典中; - 服务器端通常有更多状态记录,并根据和每个客户端的不同阶段动态计算返回,这也是最用的思路。(其实上一节的客户端本质也有这个环节,只不过通过人脑来手工完成了。)
这种类似“解析+检索+计算+返回”模式很直接,通常Switch语句实现即可,看其来很类似一些解析协议的C代码,是不是很熟悉?当然更现代的语言有一些更方便的语法如pattern-matching等,但逻辑一样,因为本质就是实现一个有限状态机(见下节)。
相信机智的你已经读懂服务器端的逻辑了,应该不会出现“聊天止于呵呵”的场景了。
0x04 分析小结
那么现在我们分析示例代码后,相信你对单个连接建立后的双向全双工通信实现有了基本的认识,即通信双方均只需要实现一个针对业务的有限状态机——具体说是确定性有限自动机(DFA):
有限状态机计算模型图示:
或者比较流行的基于MPSC的ActorModel:
而我们的主角——WebSocket(以及相关API和库)只是担任通信的底层协议和部分实现媒介。
至此我们示例讲解结束,下面开始我们从握手过程开始,逐渐探索WebSocket协议原理。
0x05 协议原理
握手过程
首先我们来分析下WebSocket建立连接即握手的过程。受益于通过HTTP的Upgrade请求(升级协商),只需要经过一次RTT即可客户端和服务器端的连接。可以用Linux下的nc
命令来具体查看握手细节如图:
如红色框标识所示,如果向WebSocket服务器发送普通的HTTP请求,会收到服务器端返回的426错误,表示需要客户端升级协议。
如绿色框所示是成功建立连接的情况:
客户端需要请求时至少带上5个特定的请求头(Header):Connection
、Host
、Upgrade
、Sec-WebSocket-Version
以及Sec-WebSocket-Key
。前四个是固定值,最后一个Key由客户端提供,用来向服务器确认客户端有权升级请求到WebSocket协议。不安全的客户端希望升级时,用此Header可以某种程度上的保护以防止滥用或无意间地请求升级,Key值是规范中定义的方法计算的因而不能提供任何安全性。 总之这个Key是用来“明确确认”客户端的确希望升到WebSocket协议。另外,该Key每个连接特定的值,16字节的随机数的Base64编码,可以看成是每个WebSocket连接的“ID”。另外,浏览器客户端通常还会发送Origin请求头,配合服务器端实现相关的CORS策略。
服务器端则需要返回101状态码,明确告知客户端连接协议已经切换为WebSocket协议,并返回Upgrade
、Connection
和Sec-WebSocket-Accept
Headher,注意后者是根据客户端的Key通过固定方法hash出来的。
连接建立后,两端的双方即可发送消息了,接下来我们一起来认识下帧结构。
协议帧结构
从我们刚才的示例代码可以看出,WebSocket的API是面向消息(message-oriented)的,即一端发送任意的UTF-8编码或二进制的文本,另一端只有在整个消息全部发送完成时,才会触发上层的事件通知。而整个分帧的逻辑细节,对与协议使用者是无感知的。WebSocket采用独特的二进制分帧格式,将每一条应用消息分拆为多个数据帧(frames),并传输至对端、重组消息以及通知。
帧是指最小的通信单元,每帧由变长的帧头和一个净荷数据(payload)组成,承载整条或部分应用消息;而消息本质是可以映射到一条应用逻辑消息的完整的帧序列。
帧格式如下图:
包括FIN、Opcode、Mask、Length、Masking Key以及Payload。FIN标记是否为最后一帧,Opcode包含帧类型的说明(数据帧中的Text/Binary标志)或者控制帧的类型(close/ping/pong等),Payload是数据部分,Mask Key是用来说明payload的掩码。注意Length(Payload长度)本身也是变长的字段。
0x06 协议对比
功能特性
WebSocket提供一种低延迟双向发送二进制/文本应用消息的机制。另外它的设计使得其带宽消耗少,因为只有建立连接时带少量头部信息。相比基于HTTP的XHR polling以及EventSource的Streaming都有很大的优势。因而更适合服务器主动向客户端推送的场景。
与HTTP/TCP关系
WebSocket的设计哲学中提到希望最小化分帧,仅有分帧只是让协议是基于帧(而非基于流)的,并支持区分Unicode文本和二进制帧。从而实现面向消息的协议。元数据的处理应该被设计在WebSocket之上的应用协议中来完成,就如同TCP上的应用协议(如HTTP)处理元数据的方式。
- vs. TCP: 概念上,WebSocket只是TCP之上的一个协议,多做了如下的工作: 针对浏览器作了一个基于origin Header的安全模型 一个用于寻址和协议的名字空间机制,使得一个IP上可以有多个主机名,以及一个端口上可有多个WebSocket服务 在TCP上加了一个分帧的机制,类似IP网络包的分帧,而且不限长度。 增加了断开连接(挥手)机制,可以更好地适配已有的网络中间层服务如代理服务。 另外,它被设计成可以和HTTP服务器共享端口,并通过HTTP Upgrade请求来建立握手连接,这使得它也可以很简单方便地与已有的HTTP基础服务共存,如已有的缓存服务、安全策略等等。
- vs. HTTP: WebSocket协议是一个独立的基于TCP的协议。它和HTTP唯一的关系就是它的握手(建立连接)是由HTTP服务器作为升级请求进而处理完成的。 默认情况下,WebSocket协议用80端口作为普通连接,用443端口作为TLS连接。
比HTTP强的地方主要在并发性能,HTTP是Request-Responde事务型的,而WebSocket一个连接可以发很多请求。即便HTTP/2以后也可以,但由于HTTP被设计成短期爆式的连接会断/以及客户端连接数的限制,而WebSocket是长时间的连接,没有这个限制。相比HTTP1.1的keep-alive连接,虽然一次TCP连接中可以发多个请求,但是仍受限于Request-Response模式,并且每个Request仍然要带上请求头。
0x07 最佳实践
任何协议的应用都需要考虑性能和安全两大因素,WebSocket也不例外,这里简单介绍几个最佳实践Tips。
首先,因为WebSocket是面向消息的,在一个连接内,第一个消息没有发送完成时,是无法发送第二个消息的,中间仅能插入控制帧。因此我们的消息设计要尽可能小一些,(对于大消息尽量拆分来避免head-of-line阻塞)或者高效利用压缩机制。另外,WebSocket天生可以利用HTTP已有的技术设施,比如浏览器和中介(Intermediary)如代理、CDN服务器的缓存,利用好这些也能明显提速。最后,安全上除了origin请求头以外,尽量采用WSS协议(WebSocket over TLS)来增加安全性、完整性和身份认证。
0x08 总结展望
看到这里,相信你已经对WebSocket协议/API有了直观的了解,熟悉其原理和部分最佳实践,明确了典型的应用场景。最重要的,体验了双向协议的常规编程实现方法。下一步或许可以尝试着自己写一些有趣的应用了。
不过,对于部署真实的WebSocket应用来说,还有很多实操性较强的问题需要解决,比如配置反向代理、多节点连接保持、高可用地发送消息等等。我们后续将陆续为大家介绍相关知识点和实践案例,敬请期待~