本文来源公众号:程序员成功本文从网络协议、技术背景、安全性和安全性等角度详细介绍了WebSocket在Node.js中的实现生产应用。大纲预览本文介绍的内容包括以下几个方面:网络协议演进Socket.IO?ws模块实现Express集成WebSocket实例消息广播安全和鉴权BFF应用网络协议演进HTTP协议是前端最熟悉的网络通信协议。我们平时打开网页和请求接口,都是HTTP请求。HTTP请求的特点是:请求->响应。客户端发起请求,服务端收到请求后响应,一次请求完成。也就是说,HTTP请求必须由客户端发起,服务器才能被动响应。另外,在发起HTTP请求之前,需要通过三次握手建立TCP连接。HTTP/1.0的特点是每次通信都要经过“三步走”的过程——TCP连接->HTTP通信->断开TCP连接。每个这样的请求都是独立的,一旦请求完成就会断开连接。HTTP1.1优化了请求流程。TCP连接建立后,我们可以进行多次HTTP通信,直到一段时间内没有发起HTTP请求,连接才会断开。这就是HTTP/1.1带来的长连接技术。但即便如此,通信方式还是由客户端发起,服务端响应。这个基本逻辑不会改变。随着应用交互的复杂性,我们发现有一些场景是需要实时获取服务端消息的。比如即时聊天,比如消息推送,用户不会发起请求,但是当服务端有新消息的时候,客户端需要第一时间知道,并反馈给用户。HTTP不支持服务端主动推送,但是这些场景急需解决,所以轮询(polling)就在早期出现了。轮询是指客户端每隔一段时间向服务器端发起请求,检测服务器端是否有更新,如果有则返回新的数据。这种轮询方式虽然简单粗暴,但显然有两个缺点:请求消耗太大。客户端不断请求,浪费流量和服务器资源,给服务器造成压力。无法保证及时性。客户端需要平衡时效性和性能,请求间隔不能太小,所以会出现延迟。随着HTML5中WebSocket的引入,即时通信场景终于迎来了根本性的解决。WebSocket是一种全双工通信协议。当客户端和服务器端建立连接后,双方就可以互相发送数据了。这样客户端就不需要通过轮询这种低效的方式来获取数据,服务端直接推送新消息。只是把它交给客户。传统的HTTP连接方式如下:##普通连接http://localhost:80/test##安全连接https://localhost:80/testWebSocket是另一种协议,连接方式如下:##普通连接ws://localhost:80/test##安全连接到wss://localhost:80/test,但是WebSocket并没有完全脱离HTTP。要建立WebSocket连接,客户端必须发起HTTP请求建立连接。连接成功后客户端和服务器之间可以进行双向通信。套接字.IO?说到使用Node.js实现WebSocket,大家肯定会想到一个库:Socket.IO是的,Socket.IO是目前Node.js在生产环境开发WebSocket应用的最佳选择。功能强大,高性能,低延迟,可一步集成到express框架中。但也许你不知道,Socket.IO并不是一个纯粹的WebSocket框架。它将Websocket、轮询机制等实时通信方式封装到一个通用的接口中,实现更高效的双向通信。严格来说,Websocket只是Socket.IO的一部分。你可能会问:既然Socket.IO在WebSocket的基础上做了这么多的优化,非常成熟,为什么还要使用原生的WebSocket服务呢?首先,Socket.IO无法通过原生的ws协议进行连接。例如,如果您尝试在浏览器中通过ws://localhost:8080/test-socket连接到Socket.IO服务,您将无法连接。因为Socket.IOserver必须通过Socket.IOclient连接,所以不支持默认的WebSocket连接。其次,Socket.IO的封装程度非常高,使用它可能无法帮助你理解WebSocket连接建立的原理。因此,在本文中,我们使用Node.js中的基础ws模块,从头实现一个原生的WebSocket服务,在前端使用ws协议直接连接,体验双向通信的感觉!ws模块实现ws是一个简单、快速、高度定制化的Node.js下的WebSocket实现,包括服务端和客户端。用ws搭建的服务器,浏览器可以通过原生的WebSocket构造函数直接连接,非常方便。ws客户端模拟浏览器的WebSocket构造函数,用于连接其他WebSocket服务器进行通信。注意:ws只能在Node.js环境下使用,在浏览器中是不可用的。请直接在浏览器中使用本机WebSocket构造函数。下面开始访问,第一步是安装ws:$npminstallws安装完成后,我们先搭建一个ws服务器。服务端需要使用WebSocketServer构造函数来搭建一个websocket服务端。const{WebSocketServer}=require('ws')constwss=newWebSocketServer({port:8080})wss.on('connection',(ws,req)=>{console.log('Clientconnected:',req.socket.remoteAddress)ws.on('message',data=>{console.log('收到客户端发送的消息:',data)})ws.send('我是服务端')//发送amessagetothecurrentclient})将这段代码写入ws-server.js并运行:$nodews-server.js这样一个监听8080端口的WebSocket服务器已经开始运行了。客户端在上一步搭建了WebSocket服务端,现在我们在前端连接并监听消息:varws=newWebSocket('ws://localhost:8080')ws.onopen=function(mevt){console.log('CustomerClientisconnected')}ws.onmessage=function(mevt){console.log('Clientreceivedmessage:'+evt.data)ws.close()}ws.onclose=function(mevt){console.log('connectionclosed')}将代码写入wsc.html,用浏览器打开,打印结果如下:可以看到,浏览器连接成功后,收到了客户端主动推送的消息服务器,然后浏览器可以主动关闭连接。在Node.js环境下,我们看看ws模块是如何发起连接的:constWebSocket=require('ws')varws=newWebSocket('ws://localhost:8080')ws.on('open',()=>{console.log('Clientisconnected')})ws.on('message',data=>{console.log('Clientreceivedmessage:'+data)ws.close()})ws.on('close',()=>{console.log('connectionclosed')})代码和浏览器的逻辑完全一样,只是写法略有不同,注意区别。有一点需要特别说明的是,浏览器监听的是消息事件的回调函数。该参数是MessageEvent的实例对象。服务器实际发送的数据需要通过mevt.data获取。在ws客户端中,这个参数是服务端的实际数据,可以直接获取。Express集成的ws模块一般不会单独使用,更好的方案是将其集成到已有的框架中。在本节中,我们将ws模块集成到Express框架中。集成到Express框架的好处是我们不需要单独监听一个端口,直接使用框架启动的端口即可,而且我们还可以在发起WebSocket连接之前指定一个路由访问。幸运的是,这一切都不需要手动实现,express-ws模块已经为我们完成了大部分的集成工作。先安装,然后在入口文件中引入:varexpressWs=require('express-ws')(app)和Express的Router一样,express-ws也支持注册全局路由和本地路由。先看全局路由,通过[host]/test-ws连接:app.ws('/test-ws',(ws,req)=>{ws.on('message',msg=>{send(msg)})})本地路由是注册在路由组下的子路由。配置一个名为websocket的路由组,指向websocket.js文件,代码如下://websocket.jsvarrouter=express.Router()router.ws('/test-ws',(ws,req)=>{ws.on('message',msg=>{ws.send(msg)})})module.exports=路由器连接到[host]/websocket/test-ws以访问此子路由器。路由组的作用是定义一个websocket连接组,不同的需求在这个组下连接不同的子路由。例如,您可以将SingleChat和GroupChat设置为两个子路由来处理各自的连接和通信逻辑。完整代码如下:varexpress=require('express')varapp=express()varwsServer=require('express-ws')(app)varwebSocket=require('./websocket.js')app.ws('/test-ws',(ws,req)=>{ws.on('message',msg=>{ws.send(msg)})})app.use('/websocket',webSocket)应用程序。Listen(3000)实际开发中获取常用信息的小方法://客户端IP地址req.socket.remoteAddress//连接参数req.queryWebSocket实例WebSocket实例指的是客户端连接对象,服务端连接的第一个范围。varws=newWebSocket('ws://localhost:8080')app.ws('/test-ws',(ws,req)=>{}代码中的ws为WebSocket实例,表示已建立连接.browse浏览器的ws对象包含的信息如下:{binaryType:'blob'bufferedAmount:0extensions:''onclose:nullonerror:nullonmessage:nullonopen:nullprotocol:''readyState:3url:'ws://localhost:8080/'}首先有四个非常关键的监听属性,用来定义函数:onopen:连接建立后的函数onmessage:接收推送消息的函数serveronclose:关闭连接的函数onerror:连接异常最常用的函数是onmessage属性,赋值一个监听服务器消息的函数:ws.onmessage=mevt=>{console.log('message:',mevt.data)}另外一个关键属性是readyState,表示连接状态,值为一个数字。而每个值都可以用一个常量表示,对应关系和含义如下:0:常量WebSocket.CONNECTING,表示正在连接1:常量WebSocket.OPEN,表示已连接2:常量WebSocket.CLOSING,表示正在关闭3:常量WebSocket.CLOSED,表示已经关闭。当然最重要的是发送信息和发送数据给服务端的send方法:ws.send('待发送信息')服务端server的ws对象表示当前正在发起连接的客户端,以及它的基本属性与浏览器的属性大致相同。比如上面client的四个监听属性,readyState属性,send方法都是一样的。不过由于服务端是Node.js实现的,所以会有更丰富的支持。例如下面两个监听事件效果相同://Node.js环境ws.onmessage=str=>{console.log('message:',str)}ws.on('message',str=>{console.log('message:',mevt.data)})详细的属性和介绍请参考官方文档。消息广播WebSocket服务器不会只有一个客户端连接。消息广播是指向所有连接的客户端发送信息,就像一个扬声器,每个人都能听到。经典场景就是热推。那么在广播之前,必须解决一个问题,如何获取当前连接(在线)的客户端?其实ws模块提供了一个快速访问的方法:varwss=newWebSocketServer({port:8080})//获取所有连接的客户端wss.clients很方便。看看如何获??取express-ws:varwsServer=expressWebSocket(app)varwss=wsServer.getWss()//获取所有连接的clientswss.clients获取wss.clients后,我们看看它长什么样。打印出来后发现它的数据结构比想象的要简单,就是一个所有在线客户端的WebSocket实例组成的Set集合。然后,获取当前在线客户端数:wss.clients.size广播的简单粗暴实现:wss.clients.forEach(client=>{if(client.readyState===1){client.send('广播数据')}})这是一个非常简单的基本实现。试想一下,如果此时有10000个在线客户,那么这个循环很可能就会卡死。这就是为什么会有像socket.io这样的库对基础功能做大量的优化和封装来提高并发性能的原因。上面的广播属于全局广播,就是把消息发给大家。不过还有一种场景,比如5个人的群聊,此时的广播只给5个人的小群发消息,所以这也叫本地广播。本地广播的实现比较复杂,一般会结合具体的业务场景。这就需要我们在客户端连接时持久化客户端数据。例如,Redis用于存储在线客户端的状态和数据,以便更快、更高效地进行检索和分类。实现本地广播,一对一私聊更轻松。只要找到两个客户端对应的WebSocket实例,互相发送消息即可。安全与认证前面搭建的WebSocket服务器默认是可以被任何客户端连接的,这在生产环境中肯定是做不到的。我们要保证WebSocket服务器的安全,主要从两个方面:Token连接认证wss支持下面说说我的实现思路。Token连接认证HTTP请求接口我们通常做JWT认证,在请求头中指定一个Header,传递一个token字符串给后端,后端会使用这个token进行校验。如果验证失败,将返回401错误以阻止请求。上面我们说了,建立WebSocket连接的第一步是客户端发起一个HTTP连接请求,然后我们对这个HTTP请求进行验证,如果验证不通过,就创建中间的WebSocket连接,这不就可以了吗?按照这个思路,我们来修改服务器代码。因为需要在HTTP层进行校验,所以使用http模块创建服务器,关闭WebSocket服务的端口。varserver=http.createServer()varwss=newWebSocketServer({noServer:true})server.listen(8080)当client通过ws://连接到server时,server会升级协议,即http协议升级为websocket协议,此时会触发升级事件:server.on('upgrade',(request,socket)=>{//使用request获取参数进行验证//1.验证失败判断if('验证失败'){socket.write('HTTP/1.1401Unauthorized\r\n\r\n')socket.destroy()return}//2.验证通过后,继续建立连接wss.handleUpgrade(request,socket,_,ws=>{wss.emit('connection',ws,request)})})//3.监听连接wss.on('connection',(ws,request)=>{console.log('客户端有连接')ws.send('服务端信息')})这样就增加了服务端认证,具体认证方式结合确定客户的段落传米法。WebSocket客户端连接不支持自定义headers,所以不能使用JWT方案。有两种可用方案:BasicAuthQuary。BasicAuth认证就是简单的账号+密码认证,账号密码携带在URL中。假设我的账号是ruims,密码是123456,那么客户端连接是这样的:varws=newWebSocket('ws://ruims:123456@loc??alhost:8080'),那么服务端就会收到这样的请求header:wss.on('connection',(ws,req)=>{if(req.headers['authorization']){letauth=req.headers['authorization']console.log(auth)//打印value:BasiccnVpbXM6MTIzNDU2}}其中cnVpbXM6MTIzNDU2是ruims:123456的base64编码,可以由服务端获取进行鉴权,Quary参数传递比较简单,就是普通的URL参数传递,更短的加密字符串即可服务端获取字符串然后进行鉴权:varws=newWebSocket('ws://localhost:8080?token=cnVpbXM6MTIzNDU2')服务器获取参数:wss.on('connection',(ws,req)=>{console.log(req.query.token)}wss支持WebSocket客户端使用ws://协议连接,那么wss是什么意思呢其实很简单,原理和https完全一样。https表示安全的http协议,组合是HTTP+SSLwss表示安全的ws协议,组合是WS+SSL,为什么一定要用wss呢?除了安全之外,还有一个关键原因:如果你的web应用是https协议,那么你当前的应用在使用WebSocket时,必须是wss协议,否则浏览器拒绝连接。配置wss直接在https配置中添加location即可,nginx配置中直接:location/websocket{proxy_passhttp://127.0.0.1:8080;proxy_redirect关闭;proxy_http_version1.1;proxy_set_header升级$http_upgrade;proxy_set_headerConnectionupgrade;}然后clientconnection变成这样:varws=newWebSocket('wss://[host]/websocket')BFFapplicationBFF可能你听说过全称BackendForFrontend,意思是后端服务于前端,在实际应用架构中属于前端和后端之间的一个中间层。这个中间层一般都是Node.js实现的,那么它是做什么的呢?众所周知,现在后端的主流架构是微服务。在微服务的情况下,API将被划分为非常精细的细节。商品服务是商品服务,通知服务是通知服务。当你想在产品上架时向用户发送通知,你可能至少需要调整两个接口。这样的话对前端就不友好了,所以后来出现了BFF中间层,相当于后端请求的一个中间代理站。组合起来一次一个返回前端。那么我们如何在BFF层应用上面讲到的大量WebSocket知识呢?我想到的应用场景至少有4种:查看当前在线用户数、使用在线用户信息登录新设备、注销其他设备检测站内消息网络连接/断开、小点表示这些功能之前是在后台实现的,并且会和其他业务功能耦合。现在有了BFF,可以在这一层实现WebSocket,让后端专注于核心数据逻辑。由此可见,掌握WebSocket在Node.js中的实际应用后,作为前端,我们可以打破内卷,继续在另一个领域发挥价值。是不是很美?源码+问答本文所有代码均由本人亲身实践。为了方便小伙伴们的参考和实验,我搭建了一个GitHub仓库来存放本文的完整源码以及后续文章的完整源码。仓库地址在这里:杨成功的博客源码欢迎大家查看和实验。如果遇到任何疑问,欢迎加我微信ruidoc进行咨询,WebSocket实践过程中的所有想法和想法都欢迎与我交流~
