本项目代码已开源,具体见:
后端工程:express-blog-backend
数据库初始化脚本:关注公众号bin不懂二进制,回复关键字"博客数据库脚本",即可获取。
前端走向全栈,从这个项目开始准没错!
前言
作为前端工程师,我们几乎每天都在使用 ajax / fetch 请求与后端进行数据交互,这种基于请求-响应的通讯模式,我们再熟练不过了,无论是C端产品或者是B端产品,都离不开这种通讯模式。但是像即时通讯IM类场景,通常不会选择这种"你来我回"的通信模式,而是会选择 WebSocket 这类的全双工通信模式。
本文会带您全方位去了解一下 WebSocket 的本质,方便您搞清楚"Connection: Upgrade 是什么意思,为什么是它?"、"Upgrade: WebSocket 又是什么意思?这就可以双向通信了?"、"WebSocket 和 HTTP/TCP 到底有什么关联?八股文背了还是不理解"之类的问题,帮助您无论面试或工作时被问到 WebSocket 都能有更多细节可以聊,妥妥的一个加分项!
最后通过一个在线聊天室实战案例带大家熟悉下 WebSocket 的全栈使用,可点击在线聊天室进行体验。
认识 WebSocket
WebSocket 是一种网络通信协议,它建立在 HTTP 之上,提供了在单个 TCP 连接上进行全双工通信的能力。这意味着服务器和客户端可以互相发送和接收消息,而不需要每次都重新建立连接。WebSocket 最初由 HTML5 规范定义,具体可以参考WebSockets Living Standard。而现在,WebSocket 已被广泛支持并应用于各种应用,包括实时聊天、多人在线游戏、股票交易系统等需要实时数据更新的场景。
WebSocket 的兼容性如何?
作为前端开发,对 API 的兼容性还是非常敏感的,我们先来看看 WebSocket 的兼容性怎么样。
可以看到,主流浏览器都支持了 WebSocket,IE10 及以上版本也对 WebSocket 提供了完备的支持,所以我们可以大胆地使用起来!
WebSocket 的前端用法
浏览器 JS 运行时提供了 WebSocket 这个 API,可以用来创建和管理 WebSocket 连接。
使用也非常简单,构造实例后只有几个简单的方法调用,一看就会。
javascript
// 创建一个新的 WebSocket 连接
const socket = new WebSocket('ws://your-websocket-server-url')
// 监听连接打开
socket.onopen = (e) => {
console.log('WebSocket is connected.')
// 连接打开后,你可以发送消息
socket.send('Hello Server!')
}
// 监听消息
socket.onmessage = (e) => { console.log('Message from server: ', e.data) }
// 监听关闭
socket.onclose = (e) => { console.log('Connection closed.') }
// 监听错误
socket.onerror = (err) => { console.error('WebSocket Error: ', err) }
// 如果你想主动关闭连接,可以调用 close 方法
// socket.close()
wss:// 是 ws:// 的 TLS 加持版,可以类比于 https:// 和 http://
Nodejs 原生支持 WebSocket 吗?
WebSocket 前端的使用非常简单,我自然会联想到:如果我做全栈开发,用 Nodejs 实现 WebSocket 服务端,有原生的模块可以支持吗?
经过查询了解到,Node 原生模块中并未直接支持 WebSocket 服务端的开箱使用,一个比较流行的库是 ws。
那么 ws 这个库是怎么实现 WebSocket 服务端的呢?怎么才能和浏览器的 WebSocket 实现对接上?
直接读源码肯定是看不懂的,即便看懂了一些过程调用,也是懵逼的,我们往下看。
WebSocket 协议概览
我们知道,通讯是基于协议的,WebSocket 也有它的专属协议。ws 的实现它也是要遵循这个协议,才能和客户端实现匹配上,完成通讯。
这个协议我们去哪里看呢?根据 wikipedia 的介绍,我们知道,WebSocket 的标准化是基于IETF 的 RFC 6455 WebSocket Protocol。大致浏览后,我圈出了协议里一些值得关注的内容。阅读这类协议时,我们可以先挑重点看,对协议有一个基本的认识即可。
我们了解到一些关键词:
- 连接握手
- 怎么建立连接
- 数据发送和接收,数据帧
- 关闭握手
- 插件,相关 HTTP 头部字段
虽然有了一些关键词在脑海中,但我们对整个通讯过程肯定还有一连串疑问。带着疑问,我们继续往下看协议的具体内容。
- 我们会了解到 WebSocket 出现的背景,它是为了解决什么,很显然,普通的 HTTP 请求不适合一些双向通信场景,比如聊天、股票、游戏等。
- 即便普通 HTTP 请求能通过一些业务设计满足双向通信需求,性能问题也很大,TCP 连接的开销等问题都要考虑在内。
- WebSocket 就是希望在一个 TCP 连接上,开辟双工通道,实现全双工实时通信。
- 之所以选择在 HTTP 协议的基础上去实现 WebSocket,也是一种权衡和取舍,可能会牺牲一些性能,但是也极大地复用了已有的网络基础设施,包括协议、安全、代理、认证等。
WebSocket 协议 - 连接握手
再往下翻一翻协议,我们能翻到最关键的部分,这也是面试里能和面试官吹的内容,请仔细看!
很多人面试被问到 WebSocket,就说 WebSocket 可以双向通信,这是和 HTTP 最大的不同。讲道理,这种回复面试官已经听腻了。
如果你能告诉面试官,WebSocket 的协议涉及到以下几个 HTTP 头部字段,并简述一下各个字段的简单含义,我相信你的面试绝对加分!
我们先看请求头:
- WebSocket 请求一定是 GET 类型的。
- Origin: 浏览器客户端会带上,包含 Origin 是为了安全考虑,充分利用浏览器的同源策略。
- Connection: Upgrade 和 Upgrade: websocket。这是客户端告知服务端需要升级协议,并且升级的协议为 websocket。
- Sec-WebSocket-Key:由客户端(比如浏览器)随机生成,16位随机数经过 base64 编码后得到。响应头 Sec-WebSocket-Accept 是与它搭配使用的,用来确保请求的有效性和安全性。
- Sec-WebSocket-Protocol:不是必选的。用来约定应用层面的子协议,使得客户端和服务器能够灵活地协商并选择一个双方都能理解的协议来进行通信。如果服务端选择使用某个子协议通信,则会在响应头中返回。
再看响应头:
- 服务端返回 101 Switching Protocols,代表握手成功,协议切换到 WebSocket。
- Connection: Upgrade 和 Upgrade: websocket,用于告知客户端可以升级为 websocket 协议。
- Sec-WebSocket-Accept:基于 Sec-WebSocket-Key 处理得到,处理公式如下,其中
"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
是一个固定的字符串:
less
base64-encode(sha1(Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
引用 wikipedia 的一张图,可以看得更清楚!
WebSocket 协议 - 数据帧
建立了连接握手后,WebSocket 就可以发送和接受消息数据了,消息是由一个或多个帧组成的。我们先看看这两张图了解一下数据帧的结构。
其中 Opcode 是操作码,具体见下表。
由于存在数据比较大的可能,这时需要切片传输,WebSocket 消息数据支持分片传输。
- 当 FIN = 1 且 OpCode ≠ 0 时,代表这一帧数据不是分片处理的。
- 当 FIN = 0 时,如果 OpCode = 0,代表这一帧数据是某个分片的中间数据帧;如果 OpCode ≠ 0,代表这一帧数据是一个分片数据中的起始帧。
WebSocket 能发送文本数据或二进制数据,这个是体现在 OpCode 上。如果起始帧的 OpCode 是 1,则代表是文本数据;如果起始帧的 OpCode 是 2,则代表是二进制数据。
WebSocket 协议 - 关闭连接
我们看看规范中,关闭握手这部分是怎么说的。
WebSocket 任何一端都可以发起关闭连接。
当一方准备关闭连接时,应该发送 Close Frame 开始关闭握手,之后不应该再发送任何数据;
另一方收到 Close Frame 时,需要回复 Close Frame,并且准备释放资源,同时也应该丢弃后续从这个连接上可能接收到的数据。
发起方收到 Close Frame 控制帧后,关闭连接释放资源,不再接收数据。
这种 WebSocket 关闭握手机制也是在 TCP 握手机制上的一种补充,更好地保证端到端通信的可靠性!
有的朋友可能会考虑到这个问题:当客户端发送 Close Frame 后,服务端正常接收到,并且回复 Close Frame,但是由于网络问题客户端没有服务端响应的 Close Frame,这种情况是怎么关闭 WebSocket 连接的?
实际上,TCP 连接也有它的超时和重试机制,当一段时间内没有数据传输时,也会断开连接。所以我们无需担心这一点。
当这种没有成功关闭握手但是关闭了 TCP 连接的情况发生时,onclose
事件回调中收到的错误码应该是 1006,这一点可以在上面的表格中找到。
正常关闭是 1000。
在实际业务实现上,还会通过 ping-pong 之类的心跳检测机制来保证可靠性。
回顾 ws 关键源码
有了这些知识储备后,再来看 ws 的实现源码,可能就会有头绪一点。
当你看到这部分,你会知道它在校验头部字段是否符合协议要求,准备升级协议...
当你看到这个 101 状态码,你会恍然大悟:"哦,原来是在这里完成了协议的升级!"虽然还有些细节看不懂,但是无伤大雅!
当你看到这里时,你会知道,如果客户端尝试通过普通的 HTTP 请求来连接 WebSocket 服务,服务端应该返回 426 Upgrade Required 告诉客户端,"你该升级协议再跟我对话!"
本文不是源码解读,点到为止,我们往下看。
为什么选择 Socket.IO
实际将 WebSocket 运用到生产环境时,我们一般不会直接使用 ws 这种协议实现库,而是会选择在应用层面进行了一些封装的库,比如 Socket.IO。
这是因为在 WebSocket 实际使用过程中,还有很多问题要考虑,比如心跳检测、优雅降级、房间隔离、命名空间隔离、API 的易用性等。而这些,Socket.IO 已经开箱支持。
准确说,Socket.IO 并非是一个 WebSocket 实现,而是一个事件驱动的低延迟双向通讯方案。
它的底层通讯不一定是基于 WebSocket 的,可能会根据情况选择 HTTP 长轮询、WebTransport。
WebTransport 是一个基于 HTTP/3 的通讯技术,可实现可靠通信和不可靠通信。HTTP/3 底层基于 Google 的 QUIC 协议,而 QUIC 协议是基于 UDP 的。
Socket.IO 有它的约定和规则,或者叫协议,只要遵循这个协议,就能完成客户端和服务端的实现,所以你会看到,它也有多语言的实现,甚至在客户端还有小程序的实现。
这个协议其实也就对应着Socket.IO 的底层引擎 Engine.IO。
虽然现在大部分浏览器都支持了 WebSocket,但是也不排除某些远古项目的存在,它必须运行在"古董"浏览器版本之上。Socket.IO 考虑到了这一点,它的自动优雅降级完美解决了这一问题。
Socket.IO 的心跳检测机制和自动重连也是实际业务中必不可少的!
更多的特性还有:
- 对话回调
- 广播
- 房间
- 命名空间多路复用
- ...
Socket.IO 的通讯过程
当我们打开一个 Socket.IO 的客户端页面时,会发现 Network 里发出了多个请求,在 101 websocket 连接建立之前,有 4 个 xhr 请求,其中还有一个是 POST 请求。
Socket.IO 在升级机制中解释了这一点,直接建立可靠可用的 WebSocket 连接并非一件很轻松的事情,通常从 HTTP 开始平滑升级到 WebSocket,对连接的可靠性和用户体验来说是更好的。
升级协议会经历这么一些步骤,对应着我们在上面看到的几个 Network 请求。
我这个项目开始得很早,所以EIO=3,代表协议版本号是3,目前 Engine.IO 已经升级到版本4了。
在 Socket.IO 的 HTTP 长轮询模式中,使用长时间运行的 GET 请求接收数据,使用短期运行的 POST 请求发送数据。
了解了这些机制,并且查看 API 用法后,就可以开始运用了,一些高级用法可以在使用过程中再去探索!
聊天室的全栈实现
基于以上理解,我们开始搭建博客项目中的聊天室功能,我们会实现这些主要能力:
- 成功创建 Socket.IO 连接
- 展示聊天室的系统通知信息(涉及到单播和广播)
- 聊天对话功能(广播)
我们首先把依赖安装好,客户端使用socket.io-client,服务端使用socket.io即可。
服务端开启 WebSocket 服务
第一步是把 WebSocket 服务启动。由于本项目开始较早,socket.io 版本是 2.5,大家对照文档的时候按 2.x 文档看就好。
socket.io 可以利用已经存在的 HTTP 服务,由于项目是用 Express 搭建的,我们直接与 Express 共享一个 HTTP 服务即可。
io 实例化后,监听到 connection 事件就代表有客户端过来了,可以开始干活了。我这里是把聊天室相关的逻辑都放在了 chatroom 这个命名空间下。
of 的作用就是初始化并使用命名空间。
客户端服务端建立连接
第二步是建立连接。首先引入依赖。
javascript
import io from "socket.io-client";
再进行实例化,得到一个 socket 实例。
javascript
this.socket = io(process.env.VUE_APP_SOCKET_SERVER + "/chatroom");
有了这个 socket,我们就能监听各种事件了。
聊天室的系统通知信息
当一个客户端连接上服务器时,服务器会发送一条消息,"hello,欢迎您加入在线聊天室!"这是通过单播实现的,只要拿着socket
对象,调用其emit
方法就行。
除了对这个客户端打招呼外,还需要告诉其他用户,有新人加入了。这是通过socket.broadcast
广播实现的。
arduino
socket.broadcast.emit('broadcast', param);
当有人退出聊天室,会触发 disconnect 事件,此时我们可以广播通知其他人。
这就是我们在前端页面看到的效果:
聊天对话功能
由于我们做的是聊天室,相当于一个群聊,就不涉及到单播聊天了,直接用广播就行。
用户在客户端发聊天消息时,是用到socket
对象的emit
进行发送。
这个chat
事件是在客户端连接上服务端时开始监听的,在这个回调里,我们需要把内容广播给除发送者的其他用户,子事件名是new_chat_content
。
而其他用户则会通过客户端监听广播事件中的new_chat_content
子事件拿到聊天数据,最终呈现到界面上。
以上都是通讯上的设计,了解了这个机制,UI 的展示就非常简单了,毕竟 UI = Render(data),不做更多介绍!
小结
本文中,我首先分享了我对 WebSocket 协议的一些理解,希望对还不太理解这块的朋友起到一点帮助作用。面试里,WebSocket 是一个常问的考点,如果你回答的仅仅是"全双工通信",可能并不能起到一个很好的效果,把文中小知识甩面试官脸上吧,哈哈哈!
最终通过一个实际案例,带大家理解一个聊天室功能的设计思路,在实际落地的过程中夯实对 WebSocket 协议的理解。
码字分享不易,多多点赞关注,项目给个 star,多谢啦,宝子!
- 开源地址:vue3-ts-blog-frontend
- 专栏导航:Vue3+TS+Node打造个人博客(总览篇)