【译】 WebSocket 协议第八章——错误处理(Error Handling)

概述本文为 WebSocket 协议的第八章,本文翻译的主要内容为 WebSocket 错误处理相关内容。错误处理(协议正文)8.1 处理 UTF-8 数据错误当终端按照 UTF-8 的格式来解析一个字节流,但是发现这个字节流不是 UTF-8 编码,或者说不是一个有效的 UTF-8 字节流时,终端必须让 WebSocket 连接关闭。这个规则在建立连接开始握手和后续的数据交换时都生效。

February 19, 2019 · 1 min · jiezi

【译】 WebSocket 协议第十章——安全性考虑(Security Considerations)

概述本文为 WebSocket 协议的第九章,本文翻译的主要内容为 WebSocket 安全性相关内容。10 安全性考虑(协议正文)这一章描述了一些 WebSocket 协议的可用的安全性考虑。这一章的小节描述了这些特定的安全性考虑。10.1 非浏览器客户端WebSocket 协议防止在受信任的应用例如 Web 浏览器中执行的恶意 JavaScript 代码,例如通过检查Origin头字段(见下面)。见第 1.6 节去了解更多详情。这种假设在更有能力的客户端的情况下不成立。这个协议可以被网页中的脚本使用,也可以通过宿主直接使用。这些宿主是代表自己的利益的,因此可以发送假的Origin头字段来欺骗服务端。因此服务端对于他们正在和已知的源的脚本直接通信的假设需要消息,并且必须认为他们可能通过没有预期的方式访问。特别地,服务端不应该相信任何输入都是有效的。示例:如果服务端使用输入的内容作为一部分的 SQL 查询语句,所有的输入文本都必须在传递给 SQL 服务器时进行编码,以免服务端受到 SQL 注入攻击。10.2 源考虑只处理特定站点,不打算处理任何 Web 页面的数据服务器应该验证Origin字段是否是他们预期的。如果服务端收到的源字段是不接受的,那么他应该通过包含 HTTP 禁止状态码为 403 的请求响应作为 WebSocket 握手的响应。当不信任的一方是 JavaScript 应用作者并存在受信任的客户端中运行时,Origin字段可以避免出现这种攻击的情况。客户端可以连接到服务端,通过协议中的Origin字段,确定是否开放连接的权限给 JavaScript 应用。这么做的目的不是组织非浏览器应用建立连接,而是保证在受信任的浏览器中可能运行的恶意 JavaScript 代码并不会构建一个假的 WebSocket 握手。10.3 基础设施攻击(添加掩码)除了终端可能会成为通过 WebSocket 被攻击的目标之外,网络基础设施的另外一部分,例如代理,也有可能是攻击的对象。这个协议发展后,通过一个实验验证了部署在外部的缓存服务器由于一系列在代理上面的攻击导致投毒。一般形式的攻击就是在攻击者控制下建立一个与服务端的连接,实现一个与 WebSocket 协议建立连接相似的 HTTP UPGRADE 连接,然后通过升级以后的连接发送数据,看起来就像是针对已知的特定资源(在攻击中,这可能类似于广泛部署的脚本,用于跟踪广告服务网络上的点击或资源)进行 GET 请求。远端服务器可能会通过一些看上去像响应数据的来响应假的 GET 请求,然后这个响应就会按照非零百分比的已部署中介缓存,因此导致缓存投毒。这个攻击带来的影响就是,如果一个用户可以正常的访问一个攻击者控制的网网站,那么攻击者可以针对这个用户进行缓存投毒,在相同缓存的后面其他用户会运行其他源的恶意脚本,破坏 Web 安全模型。为了避免对中介服务的此类攻击,使用不符合 HTTP 的数据帧中为应用程序的数据添加前缀是不够的,我们不可能详细的检查和测试每一个不合标准的中介服务有没有跳过这种非 HTTP 帧,或者对帧载荷处理不正确的情况。因此,采用的防御措施是对客户端发送给服务端的所有数据添加掩码,这样的话远端的脚本(攻击者)就不能够控制发送的数据如何出现在线路上,因此就不能够构造一条被中介误解的 HTPT请求。客户端必须为每一帧选择一个新的掩码值,使用一个不能够被应用预测到的算法来进行传递数据。例如,每一个掩码值可以通过一个加密强随机数生成器来生成。如果相同的值已经被使用过或者已经存在一种方式能够判断出下一个值如何选择时,攻击这个可以发送一个添加了掩码的消息,来模拟一个 HTTP 请求(通过在线路上接收攻击者希望看到的消息,使用下一个被使用的掩码值来对数据进行添加掩码,当客户端使用它时,这个掩码值可以有效地反掩码数据)。当从客户端开始传递第一帧时,这个帧的有效载荷(应用程序提供的数据)就不能够被客户端应用程序修改,这个策略是很重要的。否则,攻击者可以发送一个都是已知值(例如全部为 0)的初始值的很长的帧,计算收到第一部分数据时使用过的掩码,然后修改帧中尚未发送的数据,以便在添加掩码时显示为 HTTP 请求。(这与我们在之前的段落中描述的使用已知的值和可预测的值作为掩码值,实际上是相同的问题。)如果另外的数据已经发送了,或者要发送的数据有所改变,那么新的数据或者修改的数据必须使用一个新的数据帧进行发送,因此也需要选择一个新的掩码值。简短来说,一旦一个帧的传输开始后,内容不能够被远端的脚本(应用)修改。受保护的威胁模型是客户端发送看似HTTP请求的数据的模型。因此,从客户端发送给服务端的频道数据需要添加掩码值。从服务端到客户端的数据看上去像是一个请求的响应,但是,为了完成一次请求,客户端也需要可以伪造请求。因此,我们不认为需要在双向传输上添加掩码。(服务端发送给客户端的数据不需要添加掩码)尽管通过添加掩码提供了保护,但是不兼容的 HTTP 代理仍然由于客户端和服务端之间不支持添加掩码而受到这种类型的攻击。10.4 指定实现的限制在从多个帧重新组装后,对于帧大小或总消息大小具有实现和必须避免自己超过相关的多平台特定限制带来的影响。(例如:一个恶意的终端可能会尝试耗尽对端的内存或者通过发送一个大的帧(例如:大小为 2 ** 60)或发送一个长的由许多分片帧构成的流来进行拒绝服务攻击)。这些实现应该对帧的大小和组装过后的包的总大小有一定的限制。10.5 WebSocket 客户端认证这个协议在 WebSocket 握手时,没有规定服务端可以使用哪种方式进行认证。WebSocket 服务器可以使用任意 HTTP 服务器通用的认证机制,例如: Cookie、HTTP 认证或者 TLS 认证。10.6 连接保密性和完整性连接保密性是基于运行 TLS 的 WebSocket 协议(wss 的 URLs)。WebSocket 协议实现必须支持 TLS,并且应该在与对端进行数据传输时使用它。如果在连接中使用 TLS,TLS带来的连接的收益非常依赖于 TLS 握手时的算法的强度。例如,一些 TLS 的加密算法不提供连接保密性。为了实现合理登记的保护措施,客户端应该只使用强 TLS 算法。“Web 安全:用户接口指南”(W3C.REC-wsc-ui-20100812)讨论了什么是强 TLS 算法。RFC5246 的附录 A.5和附录 D.3提供了另外的指导。10.7 处理无用数据传入的数据必须经过客户端和服务端的认证。如果,在某个时候,一个终端面对它无法理解的数据或者违反了这个终端定义的输入安全规范和标准,或者这个终端在开始握手时没有收到对应的预期值时(在客户端请求中不正确的路径或者源),终端应该关闭 TCP 连接。如果在成功的握手后收到了无效的数据,终端应该在进入关闭 WebSocket流程前,发送一个带有合适的状态码(第 7.4 节)的关闭帧。使用一个合适的状态码的关闭帧有助于诊断这个问题。如果这个无效的数据是在 WebSocket 握手时收到的,服务端应该响应一个合适的 HTTP 状态码(RFC2616)。使用错误的编码来发送数据是一类通用的安全问题。这个协议指定文本类型数据(而不是二进制或者其他类型)的消息使用 UTF-8 编码。虽然仍然可以得到长度值,但实现此协议的应用程序应使用这个长度来确定帧实际结束的位置,发送不合理的编码数据仍然会导致基于此协议构建的应用程序可能会导致从数据的错误解释到数据丢失或潜在的安全漏洞出现。10.8 在 WebSocket 握手中使用 SHA-1在这个文档中描述的 WebSocket 握手协议是不依赖任意 SHA-1 的安全属性,流入抗冲击性和对第二次前映像攻击的抵抗力(就像 RFC4270 描述的一样)。 ...

February 19, 2019 · 1 min · jiezi

九种跨域方式实现原理

前言前后端数据交互经常会碰到请求跨域,什么是跨域,以及有哪几种跨域方式,这是本文要探讨的内容。一、什么是跨域?1.什么是同源策略及其限制内容?同源策略是一种约定,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,浏览器很容易受到XSS、CSFR等攻击。所谓同源是指”协议+域名+端口”三者相同,即便两个不同的域名指向同一个ip地址,也非同源。 同源策略限制内容有:Cookie、LocalStorage、IndexedDB 等存储性内容DOM 节点AJAX 请求发送后,结果被浏览器拦截了但是有三个标签是允许跨域加载资源<img src=XXX><link href=XXX><script src=XXX>2.常见跨域场景当协议、子域名、主域名、端口号中任意一个不相同时,都算作不同域。不同域之间相互请求资源,就算作“跨域”。常见跨域场景如下图所示: 特别说明两点:第一:如果是协议和端口造成的跨域问题“前台”是无能为力的。第二:在跨域问题上,仅仅是通过“URL的首部”来识别而不会根据域名对应的IP地址是否相同来判断。“URL的首部”可以理解为“协议, 域名和端口必须匹配”。这里你或许有个疑问:请求跨域了,那么请求到底发出去没有?跨域并不是请求发不出去,请求能发出去,服务端能收到请求并正常返回结果,只是结果被浏览器拦截了。你可能会疑问明明通过表单的方式可以发起跨域请求,为什么 Ajax 就不会?因为归根结底,跨域是为了阻止用户读取到另一个域名下的内容,Ajax 可以获取响应,浏览器认为这不安全,所以拦截了响应。但是表单并不会获取新的内容,所以可以发起跨域请求。同时也说明了跨域并不能完全阻止 CSRF,因为请求毕竟是发出去了。二、跨域解决方案1.jsonp1) JSONP原理利用<script> 标签没有跨域限制的漏洞,网页可以得到从其他来源动态产生的 JSON 数据。JSONP请求一定需要对方的服务器做支持才可以。2) JSONP和AJAX对比JSONP和AJAX相同,都是客户端向服务器端发送请求,从服务器端获取数据的方式。但AJAX属于同源策略,JSONP属于非同源策略(跨域请求)3) JSONP优缺点JSONP优点是简单兼容性好,可用于解决主流浏览器的跨域数据访问的问题。缺点是仅支持get方法具有局限性,不安全可能会遭受XSS攻击。4) JSONP的实现流程声明一个回调函数,其函数名(如show)当做参数值,要传递给跨域请求数据的服务器,函数形参为要获取目标数据(服务器返回的data)。创建一个<script>标签,把那个跨域的API数据接口地址,赋值给script的src,还要在这个地址中向服务器传递该函数名(可以通过问号传参:?callback=show)。服务器接收到请求后,需要进行特殊的处理:把传递进来的函数名和它需要给你的数据拼接成一个字符串,例如:传递进去的函数名是show,它准备好的数据是show(‘我不爱你’)。最后服务器把准备的数据通过HTTP协议返回给客户端,客户端再调用执行之前声明的回调函数(show),对返回的数据进行操作。在开发中可能会遇到多个 JSONP 请求的回调函数名是相同的,这时候就需要自己封装一个 JSONP函数。// index.htmlfunction jsonp({ url, params, callback }) { return new Promise((resolve, reject) => { let script = document.createElement(‘script’) window[callback] = function(data) { resolve(data) document.body.removeChild(script) } params = { …params, callback } // wd=b&callback=show let arrs = [] for (let key in params) { arrs.push(${key}=${params[key]}) } script.src = ${url}?${arrs.join('&amp;')} document.body.appendChild(script) })}jsonp({ url: ‘http://localhost:3000/say’, params: { wd: ‘Iloveyou’ }, callback: ‘show’}).then(data => { console.log(data)})上面这段代码相当于向http://localhost:3000/say?wd=Iloveyou&callback=show这个地址请求数据,然后后台返回show(‘我不爱你’),最后会运行show()这个函数,打印出’我不爱你’// server.jslet express = require(’express’)let app = express()app.get(’/say’, function(req, res) { let { wd, callback } = req.query console.log(wd) // Iloveyou console.log(callback) // show res.end(${callback}('我不爱你'))})app.listen(3000)5) jQuery的jsonp形式JSONP都是GET和异步请求的,不存在其他的请求方式和同步请求,且jQuery默认就会给JSONP的请求清除缓存。$.ajax({url:“http://crossdomain.com/jsonServerResponse",dataType:"jsonp",type:"get",//可以省略jsonpCallback:“show”,//->自定义传递给服务器的函数名,而不是使用jQuery自动生成的,可省略jsonp:“callback”,//->把传递函数名的那个形参callback,可省略success:function (data){console.log(data);}});2.corsCORS 需要浏览器和后端同时支持。IE 8 和 9 需要通过 XDomainRequest 来实现。浏览器会自动进行 CORS 通信,实现 CORS 通信的关键是后端。只要后端实现了 CORS,就实现了跨域。服务端设置 Access-Control-Allow-Origin 就可以开启 CORS。 该属性表示哪些域名可以访问资源,如果设置通配符则表示所有网站都可以访问资源。虽然设置 CORS 和前端没什么关系,但是通过这种方式解决跨域问题的话,会在发送请求时出现两种情况,分别为简单请求和复杂请求。1) 简单请求只要同时满足以下两大条件,就属于简单请求条件1:使用下列方法之一:GETHEADPOST条件2:Content-Type 的值仅限于下列三者之一:text/plainmultipart/form-dataapplication/x-www-form-urlencoded请求中的任意 XMLHttpRequestUpload 对象均没有注册任何事件监听器; XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问。2) 复杂请求不符合以上条件的请求就肯定是复杂请求了。 复杂请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为”预检”请求,该请求是 option 方法的,通过该请求来知道服务端是否允许跨域请求。我们用PUT向后台请求时,属于复杂请求,后台需做如下配置:// 允许哪个方法访问我res.setHeader(‘Access-Control-Allow-Methods’, ‘PUT’)// 预检的存活时间res.setHeader(‘Access-Control-Max-Age’, 6)// OPTIONS请求不做任何处理if (req.method === ‘OPTIONS’) { res.end() }// 定义后台返回的内容app.put(’/getData’, function(req, res) { console.log(req.headers) res.end(‘我不爱你’)})接下来我们看下一个完整复杂请求的例子,并且介绍下CORS请求相关的字段// index.htmllet xhr = new XMLHttpRequest()document.cookie = ’name=xiamen’ // cookie不能跨域xhr.withCredentials = true // 前端设置是否带cookiexhr.open(‘PUT’, ‘http://localhost:4000/getData’, true)xhr.setRequestHeader(’name’, ‘xiamen’)xhr.onreadystatechange = function() { if (xhr.readyState === 4) { if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) { console.log(xhr.response) //得到响应头,后台需设置Access-Control-Expose-Headers console.log(xhr.getResponseHeader(’name’)) } }}xhr.send()//server1.jslet express = require(’express’);let app = express();app.use(express.static(__dirname));app.listen(3000);//server2.jslet express = require(’express’)let app = express()let whitList = [‘http://localhost:3000’] //设置白名单app.use(function(req, res, next) { let origin = req.headers.origin if (whitList.includes(origin)) { // 设置哪个源可以访问我 res.setHeader(‘Access-Control-Allow-Origin’, origin) // 允许携带哪个头访问我 res.setHeader(‘Access-Control-Allow-Headers’, ’name’) // 允许哪个方法访问我 res.setHeader(‘Access-Control-Allow-Methods’, ‘PUT’) // 允许携带cookie res.setHeader(‘Access-Control-Allow-Credentials’, true) // 预检的存活时间 res.setHeader(‘Access-Control-Max-Age’, 6) // 允许返回的头 res.setHeader(‘Access-Control-Expose-Headers’, ’name’) if (req.method === ‘OPTIONS’) { res.end() // OPTIONS请求不做任何处理 } } next()})app.put(’/getData’, function(req, res) { console.log(req.headers) res.setHeader(’name’, ‘jw’) //返回一个响应头,后台需设置 res.end(‘我不爱你’)})app.get(’/getData’, function(req, res) { console.log(req.headers) res.end(‘我不爱你’)})app.use(express.static(__dirname))app.listen(4000)上述代码由http://localhost:3000/index.html向http://localhost:4000/跨域请求,正如我们上面所说的,后端是实现 CORS 通信的关键。3.postMessagepostMessage是HTML5 XMLHttpRequest Level 2中的API,且是为数不多可以跨域操作的window属性之一,它可用于解决以下方面的问题:页面和其打开的新窗口的数据传递多窗口之间消息传递页面与嵌套的iframe消息传递上面三个场景的跨域数据传递postMessage()方法允许来自不同源的脚本采用异步方式进行有限的通信,可以实现跨文本档、多窗口、跨域消息传递。otherWindow.postMessage(message, targetOrigin, [transfer]);message: 将要发送到其他 window的数据。targetOrigin:通过窗口的origin属性来指定哪些窗口能接收到消息事件,其值可以是字符串””(表示无限制)或者一个URI。在发送消息的时候,如果目标窗口的协议、主机地址或端口这三者的任意一项不匹配targetOrigin提供的值,那么消息就不会被发送;只有三者完全匹配,消息才会被发送。transfer(可选):是一串和message 同时传递的 Transferable 对象. 这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。接下来我们看个例子: http://localhost:3000/a.html页面向http://localhost:4000/b.html传递“我爱你”,然后后者传回”我不爱你”。// a.html <iframe src=“http://localhost:4000/b.html” frameborder=“0” id=“frame” onload=“load()"></iframe> //等它加载完触发一个事件 //内嵌在http://localhost:3000/a.html <script> function load() { let frame = document.getElementById(‘frame’) frame.contentWindow.postMessage(‘我爱你’, ‘http://localhost:4000’) //发送数据 window.onmessage = function(e) { //接受返回数据 console.log(e.data) //我不爱你 } } </script>// b.html window.onmessage = function(e) { console.log(e.data) //我爱你 e.source.postMessage(‘我不爱你’, e.origin) }4.websocketWebsocket是HTML5的一个持久化的协议,它实现了浏览器与服务器的全双工通信,同时也是跨域的一种解决方案。WebSocket和HTTP都是应用层协议,都基于 TCP 协议。但是 WebSocket 是一种双向通信协议,在建立连接之后,WebSocket 的 server 与 client 都能主动向对方发送或接收数据。同时,WebSocket 在建立连接时需要借助 HTTP 协议,连接建立好了之后 client 与 server 之间的双向通信就与 HTTP 无关了。原生WebSocket API使用起来不太方便,我们使用Socket.io,它很好地封装了webSocket接口,提供了更简单、灵活的接口,也对不支持webSocket的浏览器提供了向下兼容。我们先来看个例子:本地文件socket.html向localhost:3000发生数据和接受数据// socket.html<script> let socket = new WebSocket(‘ws://localhost:3000’); socket.onopen = function () { socket.send(‘我爱你’);//向服务器发送数据 } socket.onmessage = function (e) { console.log(e.data);//接收服务器返回的数据 }</script>// server.jslet express = require(’express’);let app = express();let WebSocket = require(‘ws’);//记得安装wslet wss = new WebSocket.Server({port:3000});wss.on(‘connection’,function(ws) { ws.on(‘message’, function (data) { console.log(data); ws.send(‘我不爱你’) });})5. Node中间件代理(两次跨域)实现原理:同源策略是浏览器需要遵循的标准,而如果是服务器向服务器请求就无需遵循同源策略。 代理服务器,需要做以下几个步骤:接受客户端请求 。将请求 转发给服务器。拿到服务器 响应 数据。将 响应 转发给客户端。 我们先来看个例子:本地文件index.html文件,通过代理服务器http://localhost:3000向目标服务器http://localhost:4000请求数据。// index.html(http://127.0.0.1:5500) <script src=“https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script> <script> $.ajax({ url: ‘http://localhost:3000’, type: ‘post’, data: { name: ‘xiamen’, password: ‘123456’ }, contentType: ‘application/json;charset=utf-8’, success: function(result) { console.log(result) // {“title”:“fontend”,“password”:“123456”} }, error: function(msg) { console.log(msg) } }) </script> // server1.js 代理服务器(http://localhost:3000)const http = require(‘http’)// 第一步:接受客户端请求const server = http.createServer((request, response) => { // 代理服务器,直接和浏览器直接交互,需要设置CORS 的首部字段 response.writeHead(200, { ‘Access-Control-Allow-Origin’: ‘’, ‘Access-Control-Allow-Methods’: ‘’, ‘Access-Control-Allow-Headers’: ‘Content-Type’ }) // 第二步:将请求转发给服务器 const proxyRequest = http .request( { host: ‘127.0.0.1’, port: 4000, url: ‘/’, method: request.method, headers: request.headers }, serverResponse => { // 第三步:收到服务器的响应 var body = ’’ serverResponse.on(‘data’, chunk => { body += chunk }) serverResponse.on(’end’, () => { console.log(‘The data is ’ + body) // 第四步:将响应结果转发给浏览器 response.end(body) }) } ) .end()})server.listen(3000, () => { console.log(‘The proxyServer is running at http://localhost:3000’)})// server2.js(http://localhost:4000)const http = require(‘http’)const data = { title: ‘fontend’, password: ‘123456’ }const server = http.createServer((request, response) => { if (request.url === ‘/’) { response.end(JSON.stringify(data)) }})server.listen(4000, () => { console.log(‘The server is running at http://localhost:4000’)})上述代码经过两次跨域,值得注意的是浏览器向代理服务器发送请求,也遵循同源策略,最后在index.html文件打印出{“title”:“fontend”,“password”:“123456”}6.nginx反向代理实现原理类似于Node中间件代理,需要你搭建一个中转nginx服务器,用于转发请求。使用nginx反向代理实现跨域,是最简单的跨域方式。只需要修改nginx的配置即可解决跨域问题,支持所有浏览器,支持session,不需要修改任何代码,并且不会影响服务器性能。实现思路:通过nginx配置一个代理服务器(域名与domain1相同,端口不同)做跳板机,反向代理访问domain2接口,并且可以顺便修改cookie中domain信息,方便当前域cookie写入,实现跨域登录。先下载nginx,然后将nginx目录下的nginx.conf修改如下:// proxy服务器server { listen 80; server_name www.domain1.com; location / { proxy_pass http://www.domain2.com:8080; #反向代理 proxy_cookie_domain www.domain2.com www.domain1.com; #修改cookie里域名 index index.html index.htm; # 当用webpack-dev-server等中间件代理接口访问nignx时,此时无浏览器参与,故没有同源限制,下面的跨域配置可不启用 add_header Access-Control-Allow-Origin http://www.domain1.com; #当前端只跨域不带cookie时,可为 add_header Access-Control-Allow-Credentials true; }}最后通过命令行nginx -s reload启动nginx// index.htmlvar xhr = new XMLHttpRequest();// 前端开关:浏览器是否读写cookiexhr.withCredentials = true;// 访问nginx中的代理服务器xhr.open(‘get’, ‘http://www.domain1.com:81/?user=admin', true);xhr.send();// server.jsvar http = require(‘http’);var server = http.createServer();var qs = require(‘querystring’);server.on(‘request’, function(req, res) { var params = qs.parse(req.url.substring(2)); // 向前台写cookie res.writeHead(200, { ‘Set-Cookie’: ’l=a123456;Path=/;Domain=www.domain2.com;HttpOnly’ // HttpOnly:脚本无法读取 }); res.write(JSON.stringify(params)); res.end();});server.listen(‘8080’);console.log(‘Server is running at port 8080…’);7.window.name + iframewindow.name属性的独特之处:name值在不同的页面(甚至不同域名)加载后依旧存在,并且可以支持非常长的 name 值(2MB)。其中a.html和b.html是同域的,都是http://localhost:3000;而c.html是http://localhost:4000 // a.html(http://localhost:3000/b.html) <iframe src=“http://localhost:4000/c.html” frameborder=“0” onload=“load()” id=“iframe”></iframe> <script> let first = true // onload事件会触发2次,第1次加载跨域页,并留存数据于window.name function load() { if(first){ // 第1次onload(跨域页)成功后,切换到同域代理页面 let iframe = document.getElementById(‘iframe’); iframe.src = ‘http://localhost:3000/b.html’; first = false; }else{ // 第2次onload(同域b.html页)成功后,读取同域window.name中数据 console.log(iframe.contentWindow.name); } } </script>b.html为中间代理页,与a.html同域,内容为空。 // c.html(http://localhost:4000/c.html) <script> window.name = ‘我不爱你’ </script>总结:通过iframe的src属性由外域转向本地域,跨域数据即由iframe的window.name从外域传递到本地域。这个就巧妙地绕过了浏览器的跨域访问限制,但同时它又是安全操作。8.location.hash + iframe实现原理: a.html欲与c.html跨域相互通信,通过中间页b.html来实现。 三个页面,不同域之间利用iframe的location.hash传值,相同域之间直接js访问来通信。具体实现步骤:一开始a.html给c.html传一个hash值,然后c.html收到hash值后,再把hash值传递给b.html,最后b.html将结果放到a.html的hash值中。 同样的,a.html和b.html是同域的,都是http://localhost:3000;而c.html是http://localhost:4000 // a.html <iframe src=“http://localhost:4000/c.html#iloveyou”></iframe> <script> window.onhashchange = function () { //检测hash的变化 console.log(location.hash); } </script> // b.html <script> window.parent.parent.location.hash = location.hash //b.html将结果放到a.html的hash值中,b.html可通过parent.parent访问a.html页面 </script> // c.html console.log(location.hash); let iframe = document.createElement(‘iframe’); iframe.src = ‘http://localhost:3000/b.html#idontloveyou’; document.body.appendChild(iframe);9.document.domain + iframe该方式只能用于二级域名相同的情况下,比如 a.test.com 和 b.test.com 适用于该方式。 只需要给页面添加 document.domain =‘test.com’ 表示二级域名都相同就可以实现跨域。实现原理:两个页面都通过js强制设置document.domain为基础主域,就实现了同域。我们看个例子:页面a.zf1.cn:3000/a.html获取页面b.zf1.cn:3000/b.html中a的值// a.html<body> helloa <iframe src=“http://b.zf1.cn:3000/b.html" frameborder=“0” onload=“load()” id=“frame”></iframe> <script> document.domain = ‘zf1.cn’ function load() { console.log(frame.contentWindow.a); } </script></body> // b.html<body> hellob <script> document.domain = ‘zf1.cn’ var a = 100; </script></body>三、总结CORS支持所有类型的HTTP请求,是跨域HTTP请求的根本解决方案JSONP只支持GET请求,JSONP的优势在于支持老式浏览器,以及可以向不支持CORS的网站请求数据。不管是Node中间件代理还是nginx反向代理,主要是通过同源策略对服务器不加限制。日常工作中,用得比较多的跨域方案是cors和nginx反向代理 ...

February 12, 2019 · 4 min · jiezi

简单聊聊前端开发中的热更新原理

背景前端项目开发过程中热更新的机制大家都知道,不知道你在开发的时候是否做了这方面的配置。相信接触最多的就是 webpack 的热更新,文件保存后页面自动刷新,或者 css 自动更新,页面的样式在不刷新页面的情况下就会更新。还有就是模块热替换。热更新机制很好玩,能提升不少开发效率,但是只是处于会用的阶段不是我们的目的,我们应该适当的深入学习下,看看他背后的原理,一个是否思考过,一个是否能自己实现。热更新原理咱们这里主要说下怎样自己实现一个热更新,也就是文件更改了会自动刷新页面,可以同步 pc 和 移动端,css 更改了可以不刷新页面就应用最新的 css。其实热更新的原理并不复杂,或者说很简单。咱们一步一步的分析下。本文不是要告诉你一些 api如何使用,而是利用架构的思维去分析和解决问题。【分析】文件内容变更了,浏览器是怎么知道的呢?css 文件内容变更了,没有刷新页面 怎么加载最新的内容呢?只要解决了上面两个问题,我们就算是完成了。因为剩下得就是编码了,这都好说。【结果】文件变更了,我怎样通知浏览器?浏览器和服务器保持着连接。 服务器有什么事儿直接通过当前的链接告诉浏览器就可以了。连接肯定是长连接,不然怎么实时通信。保持长连接有哪些方法呢? 轮询?eventSorce? 都不够好。 有么有更好的方案呢?那就是 - websocket浏览器和服务器先建立好链接,服务器就可以直接通知到客户端了。这个时候无论是 pc 上还是手机上都可以随时根据需要刷新或者加载资源。css 更新,css 本身是可以通过 dom 去操作的。浏览器只要知道是 css更新了,直接重新加载当前的 css 文件就可以了。架构思维咱们在重新捋捋这个架构。服务器和浏览器通过 websocket 建立链接。服务器和浏览器规定好消息的规则,是刷新页面还是更新 css。基本架构有了,其他的就是编码实现了。服务端使用 node 创建一个 ws 服务。浏览器使用 websocket 创建一个链接和服务器进行链接。双方通过对应的 api 进行数据的操作。代码实现本文只是讲解下思路,并没有实现文件的监听,文件监听后面会介绍。咱暂时先确定好两个消息规则:浏览器收到 命令为:htmlFileChange ,此时浏览器刷新;浏览器收到命令为:cssFileChange,此时不刷新页面,自动加载 css 文件;具体代码如下:服务端://web-socket.js 创建 ws 服务var ws = require(“nodejs-websocket”);//需要安装这个包module.exports = function(){ return function () { console.log(“重度前端提醒,开始建立连接…”) var sessions = [];//存放每一个链接对象 var server = ws.createServer(function (conn) { sessions.push(conn);//将新的链接对象存放在数组中 conn.on(“text”, function (str) { console.log(“收到的信息为:” + str) sessions.forEach(item=>{ item.sendText(str) //所有客户端都发送消息 }); }); conn.on(“close”, function (code, reason) { console.log(“关闭连接”) }); conn.on(“error”, function (code, reason) { console.log(“异常关闭”) }); }).listen(6152) console.log(“WebSocket建立完毕”) }}//server.js http 服务代码let http = require(‘http’);let fs = require(‘fs’);let webSocket = require(’./node/web-socket’);const BASEROOT = process.cwd();//获得当前的执行路径//读取 index.html内容let getPageHtml = function () { let data = fs.readFileSync(BASEROOT+’/html/index.html’); return data.toString();}//读取 index.css内容let getPageCss = function () { let data = fs.readFileSync(BASEROOT + ‘/html/index.css’); return data.toString();}//node 端 开启 ws 服务webSocket()();http.createServer(function (req, res) {//创建 http 服务 let body = ‘’,url = req.url; req.on(‘data’, function (chunk) { body += chunk; }); req.on(’end’, function () { //路由简单处理 根据不同路径输出不同内容给浏览器 if(url.indexOf(’/index.css’)>-1){ res.write(getPageCss()); }else{ res.write(getPageHtml()); } res.end(); });}).listen(6151);console.log(‘重度前端提醒…… server start’);页面截图客户端//index.html 布局代码省略 const nick = [‘a’, ‘b’, ‘c’, ’d’, ’e’, ‘f’, ‘g’, ‘aa’, ‘cc’]; let index = 0; // Create WebSocket connection. const socket = new WebSocket(‘ws://10.70.69.191:6152’); // Connection opened socket.addEventListener(‘open’, function (event) { socket.send(navigator.userAgent); }); // 监听服务器推送的消息 socket.addEventListener(‘message’, function (event) { if (index > nick.length) { index = 0;//只是为了每次输出不同的昵称,没实际意义 } var ele = document.createElement(‘div’); ele.innerHTML = nick[index] + ‘:’ + event.data; if (event.data === ‘htmlFileChange’) { //html 文件更新了 刷新当前页面 location.reload(); } if (event.data === ‘cssFileChange’) { //css 文件更新了 刷新当前页面 reloadCss(); } document.getElementById(‘content’).append(ele); index += 1; }); //重新加载 css function reloadCss() { var cssUrl = [], links = document.getElementsByTagName(’link’), len = links.length; for (var i = 0; i < len; i++) { var url = links[i].href; document.getElementsByTagName(‘head’)[0].appendChild(getLinkNode(url)); //创建新的 css 标签 document.getElementsByTagName(‘head’)[0].removeChild(links[i]); //移除原有 css } console.log(document.getElementsByTagName(‘head’)[0]) function getLinkNode(cssUrl) { var node = document.createElement(’link’); node.href = cssUrl; node.rel = ‘stylesheet’; return node; } } document.getElementById(‘btn1’).onclick = function () { socket.send(document.getElementById(‘message’).value); document.getElementById(‘message’).value = ‘’; }index.css 内容 input { outline: none; } #content { height: 400px; width: 400px; border: solid 1px #ccc; color: red; }代码倒是次要的。解决问题的思路才重要。有了解决问题的架构思维,代码实现都好说。写到这里咱们还能顺便实现一个群聊。本质就是服务器和浏览器怎样实时通信,解决了这个问题,其他的都是小事儿。这个技术实现还是比较简单的。另外对模块热更新和 websocket 原理有兴趣的可以研究下,后面可能也会介绍。总结本文主要介绍简易版热更新的原理;热更新实现思路和代码实现;架构思维:简单的带出架构思维的作用;希望本文对你有用。原创不易、请多鼓励自家观点、欢迎打脸代码示例下载https://github.com/bigerfe/ho…作者:微信公众号 - 重度前端 主笔:八门欢迎关注 重度前端-每周5原创全栈干货+每周三深度技术文章 ...

January 31, 2019 · 2 min · jiezi

WebSocket 与 Polling , Long-Polling , Streaming 的比较!

Web Sockets定义了一种在通过一个单一的 socket 在网络上进行全双工通讯的通道。它不仅仅是传统的 HTTP 通讯的一个增量的提高,尤其对于实时、事件驱动的应用来说是一个飞跃。HTML5 Web Sockets 相对于老的技术(在浏览器中模拟全双工连接的复杂技术)有了如此巨大的提升,以致于谷歌的 Ian Hickson(HTML5 说明书的总编)说:“将数据的千字节减少到2字节……并将延迟从150ms减少到50ms,这远远超过了边际效应。”事实上,仅这两个因素就足以让谷歌对 Web Sockets 字产生浓厚的兴趣。让我们来看看 HTML5 Web Sockets 是如何通过与传统的解决方案进行比较,从而极大地减少不必要的网络流量和延迟的Polling (轮询), Long-Polling (长轮询), and Streaming (串流)通常,当一个浏览器访问一个网页时,会向拥有这个页面的服务器发送一个HTTP请求。Web 服务器接受这个请求并返回一个响应。在许多情况下——例如,股票价格、新闻报道、机票销售、交通模式、医疗设备读数等等——浏览器渲染页面时,响应可能已经过时,如果你想获得最新的“实时”信息,你可以不断手动刷新该页面,但这显然不是一个很好的解决方案。当前尝试提供实时 Web 应用程序其主要围绕轮询和其他服务器端推送技术,其中最引人注目的是 Comet,它会延迟完成 HTTP 响应以将消息传递到客户端。基于 Comet 的推送一般采用 JavaScript 实现并使用长连接或流等连接策略。comet: 基于 HTTP 长连接的“服务器推”技术。基于这种架构开发的应用中,服务器端会主动以异步的方式向客户端程序推送数据,而不需要客户端显式的发出请求。Comet 架构非常适合事件驱动的 Web 应用,以及对交互性和实时性要求很强的应用,如股票交易行情分析、聊天室和 Web 版在线游戏等。Polling (轮询)通过轮询,浏览器定期发送 HTTP 请求并立即接收响应,这项技术是浏览器首次尝试传递实时信息。显然,如果消息传递的确切时间间隔已知,这是一个很好的解决方案,因为可以在服务器上先把需要发送的信息准备好之后在发送给客户端。然而,实时数据通常是不可预测的,这必然造成许多不必要的请求,因此,在低频率消息的情况下,许多连接被不必要地打开和关闭的。Long-Polling (长轮询)长轮询是让服务器在接收到浏览器所送出 HTTP 请求后,服务器会等待一段时间,若在这段时间里面服务器有新的消息,它就会把最新的消息传回给浏览器,如果等待的时间到了之后也没有新的消息的话,就会送一个回应给浏览器,告知浏览器消息没有更新。虽然轮询可以减少产生原本轮询造成网络带宽浪费的情况,但是,如果在资料更新频繁的状况下,长时间轮询不传统比传统的轮询有效率,而且有时候资料量很大时,会造成连续的轮询不断产生,反而会更糟糕。串流(Streaming)串流 (streaming) 是让服务器在接收到浏览器所送出的 HTTP 请求后,立即产生一个回应浏览器的连接,并且让这个连接持续一段时间不要中断,而服务器在这段时间内如果有新的消息,就可以透过这个连接将消息马上传送给浏览器。然而,由于流仍然封装在 HTTP 中,介入的防火墙和代理服务器可能会选择缓冲响应,从而增加消息传递的延迟。因此,如果检测到缓冲代理服务器,流式 Comet 解决方案将退回到长轮询。或者,可以使用TLS (SSL)连接来防止响应被缓冲,但是这种情况下创建和销毁每一个连接将消耗更多的可用的服务器资源。TLS:安全传输层协议(TLS)用于在两个通信应用程序之间提供保密性和数据完整性。 该协议由两层组成: TLS 记录协议(TLS Record)和 TLS 握手协议(TLS Handshake)。SSL:SSL(Secure Sockets Layer 安全套接层),及其继任者传输层安全(Transport LayerSecurity,TLS)是为网络通信提供安全及数据完整性的一种安全协议。TLS与SSL在传输层对网络连接进行加密。最后,所有这些提供实时数据的方法都会引入 HTTP 请求和响应报头,这些报头包含大量额外的、不必要的报头数据,并会带来延迟。最重要的是,全双工连接需要的不仅仅是从服务器到客户端的下行连接。为了在半双工HTTP上模拟全双工通信,当今的许多解决方案使用两个连接:一个用于下行,一个用于上行,这两个连接的维护和协调在资源消耗方面引入了大量开销,并增加了许多复杂性。简单地说,HTTP 不是为实时、全双工通信而设计的,可以在下面的图中看到,该图展示了构建 Comet Web 应用(在半双工的 HTTP 上使用订阅模式实时获取后端数据)的复杂性。当试图将 Comet 的解决方案扩充系统的规模时会变得更糟。在 HTTP 模拟全双工的浏览器通讯易出错、复杂而且复杂度无法降低。尽管最终用户可能正在体验类似于实时 Web应用程序的服务,但这种 “实时” 体验的代价高得惊人。这个代价是,付出额外的延迟,不必要的网络流量和 CPU性能的影响上。HTML5 WebSocket 通訊协议在 HTML5 规范的通信部分中定义,HTML5 Web Sockets 代表了全双工的网络交互的下一个演变 —— 一个全双工、双向的通信通道,通过 Web 上的单个套接字进行操作。HTML5 Web Sockets 提供了一个真正的标准,可以使用它来构建可扩展的实时 Web 应用程序。此外,由于它提供了浏览器本地的套接字,因此避免了 Comet 解决方案容易出现的许多问题。 Web Socket s移除了开销大幅度减轻了复杂度。为了建立WebSocket连接,客户端和服务器在首次握手时从 HTTP 协议升级到 WebSocket 协议,如下图所示:示例1 - WebSocket握手(浏览器请求和服务器响应)熟悉 HTTP 的可能会发现,这段类似 HTTP 协议的握手请求中,多了几个东西:Upgrade: websocketConnection: Upgrade这个就是 Websocket 的核心了,告诉 Apache 、 Nginx 等服务器:发起的是 Websocket协议,使用对应的Websocket协议处理,而不是使用 HTTP 协议。一旦建立,WebSocket 数据帧可以在客户端和服务器之间以全双工模式来回发送。文本和二进制帧都可以发送全双工,在同一时间向任意方向发送,数据的最小帧只有两个字节。在文本帧的情况下,每个帧以 0x00 元组开头,以 0xFF 元组结束,中间包含 UTF-8 数据,WebSocket 文本帧使用终止符,而二进制帧使用长度前缀。注意:尽管 Web Sockets 协议已经准备好支持各种客户端集,但是它不能将原始二进制数据交付给 JavaScript,因为 JavaScript 不支持字节类型。因此,如果客户端是 Javascript 的,二进制数据将被忽略 —— 但是可以把它发送给其它支持二进制的客户端。Comet vs. HTML5 WebSocket那么在非必要的网络传输和延迟性上究竟减少了多少?让比较一下长连接应用和 WebSocket 应用。对于轮询示例,我创建了一个简单的 Web 应用程序,其中 Web 页面使用传统的发布/订阅模型从RabbitMQ 消息队列中获取实时股票信息。它通过轮询驻留在 web 服务器上的 Java Servlet 来实现这一点。RabbitMQ 消息队列从虚构的持续改变股票价格的股票价格服务接收数据。Web 页面连接并订阅特定的股票通道(message broker上的主题),并使用 XMLHttpReques t每秒轮询更新一次。当接收到更新时,执行一些计算,股票数据显示在一个表中,如下图所示。注意:后台股票服务实际上每秒会产生大量股票价格更新,因此每秒轮询一次实际上比使用Comet 长轮询解决方案更为谨慎,后者会导致一系列持续轮询,这里轮询有效的节制了数据更新。这一切看起来都很好,但从内部看,这个应用程序有一些严重的问题。在 Mozilla Firefox 中使用 Firebug(一个火狐插件——可以对网页进行deb、跟踪加载页面和执行脚本的时间),可以看到 GET 请求每隔一秒就会连接服务器。打开Live HTTP Headers(另外一个火狐插件——可以显示活跃 HTTP 头传输)暴露了每一个连接上巨大数量的头开销(header overhead)。下面的例子展示了一个请求和响应的头信息。事例二:HTTP request header事例三:HTTP response header只是为了好玩,我数了数所有的字符。总的 HTT P请求和响应头信息开销包含 871 字节,甚至不包括任何数据 !当然,这只是一个示例,可以拥有少于 871 字节的头数据,但是我也看到过头数据超过 2000 字节的情况。在这个示例应用程序中,典型股票标题信息仅仅20个字符长。正如所看到的,它实际上被过多的头信息淹没了,而头信息甚至在一开始就不是必需的!那么当你把这个应用部署到大用户量的场景下会怎么样? 让我们在三个不同的场景中对比与此轮询应用程序关联的 HTTP 请求和响应头数据的网络吞吐量。场景一:每秒 1000 个客户端轮询,每秒的网络流量是 6.6 M。场景二:每秒 10000 个客户端轮询,每秒的网络流量是 66 M。场景三:每秒 100000 个客户端轮询,每秒的网络流量是 660 M。这是大量不必要的网络吞吐量,要是我们能通过网络得到必要的数据就好了,此时就可以使用 HTML5 Web Sockets!我重新构建了应用程序以使用 HTML5 Web Sockets,在 Web 页面中添加了一个事件处理程序来异步侦听来来自于代理的股票更新信息。。每一个信息都是一个WebSocket帧,只有两个字节的开销(而不是871字节)! 看看这如何影响我们的三个用例中的网络吞吐量开销。场景一:每秒 1000 个客户端轮询,每秒的网络流量是 0.015 M。场景二:每秒 10000 个客户端轮询,每秒的网络流量是 0.153 M。场景三:每秒 100000 个客户端轮询,每秒的网络流量是 .1526 M。如下图所示,与轮询解决方案相比,HTML5 Web Sockets大大减少了不必要的网络流量。那么延迟的减多少呢? 请看下图:在上半部分,可以看到半双工轮询解决方案的延迟。在本例中,假设消息从服务器传输到浏览器需要50毫秒,那么轮询应用程序将引入大量额外的延迟,因为在响应完成时必须将新请求发送到服务器。这个新请求需要另一个50ms,在此期间服务器不能向浏览器发送任何消息,从而导致额外的服务器内存消耗。在图的下半部分,可以看到 WebSocket 解决方案降低了延迟。一旦连接升级到 WebSocket,消息就可以在到达时从服务器流到浏览器。消息从服务器传输到浏览器仍然需要 50 毫秒,但是WebSocket 连接仍然打开,因此不需要向服务器发送另一个请求。WebSocke 浏览器支持情况总结HTML5 Web Sockets 在实时网络的扩展性上向前迈出了一大步。正如在本文中看到的, HTML5 Web Sockets可以提供 500:1 甚至 1000:1 的非必要HTTP头信息传输的变少,以及 3:1 延迟性的降低。这不仅仅是个进步,它是巨大的一个飞跃!原文:http://www.websocket.org/quan…你的点赞是我持续分享好东西的动力,欢迎点赞!一个笨笨的码农,我的世界只能终身学习!更多内容请关注公众号《大迁世界》! ...

January 30, 2019 · 2 min · jiezi

netty搭建web聊天室(3)单聊

上节课讲了群聊,这次来说说单聊,单聊要比群聊复杂点,但是代码也不是很多,主要是前端显示比较麻烦点。效果:登陆首先一个新的用户,需要先登陆,输入自己的昵称,然后点击登陆。后端服务会把你的用户名和当前的线程进行邦定,这样就可以通过你的用户名找到你的线程。登陆成功,后端返回定义好的消息 success,前端判断记录CHAT.me,这样给别人发消息时就可以携带自己的信息。查找用户在输入框输入用户名,就可以返回对应的用户的线程,这样你就可以把消息发送给你要聊天的对象。如果不存在,后端回返回消息给前端,该用户不存在。如果存在,就记录此用户名到CHAT.to中,这样你发送消息的时候就可以发送给对应用户了。开始聊天发送聊天信息时me:to:消息,这样后端就知道是谁要发给谁,根据用户名去找到具体的线程去单独推送消息,实现单聊。前端待完善左侧聊天列表没有实现,每搜索一个在线用户,应该动态显示在左侧,点击该用户,动态显示右侧聊天窗口进行消息发送。现在是你和所有人的单聊消息都会显示在右侧,没有完成拆分,因为这是一个页面,处理起来比较麻烦,我一个后端就不花时间搞了,感兴趣的可以自己去实现。前端代码因为注视比较详细,就直接复制整个代码到这里,大家自己看。<!DOCTYPE html><html> <head> <meta charset=“utf-8”> <title>单人聊天</title> <link rel=“stylesheet” href=“https://cdnjs.cloudflare.com/ajax/libs/zui/1.8.1/css/zui.min.css"> <link rel=“stylesheet” href=“zui-theme.css”> </head> <body> <div class=“container”> <div class=“row”> <h1>mike单人聊天室,等你来聊</h1></div> <div class=“row”> <div class=“input-control has-icon-left has-icon-right” style=“width:50%;"> <input id=“userName” type=“text” class=“form-control” placeholder=“聊天昵称”> <label for=“inputEmailExample1” class=“input-control-icon-left”><i class=“icon icon-user”></i></label> <label for=“inputEmailExample1” class=“input-control-icon-right”><a onclick=“login()">登陆</a></label> </div> </div> <br> <div class=“row”> <div class=“input-control search-box search-box-circle has-icon-left has-icon-right” id=“searchUser”> <input id=“inputSearch” type=“search” class=“form-control search-input” placeholder=“输入在线好友昵称聊天…enter开始查找”> <label for=“inputSearchExample1” class=“input-control-icon-left search-icon”><i class=“icon icon-search”></i></label> <a href=”#” class=“input-control-icon-right search-clear-btn”><i class=“icon icon-remove”></i></a> </div> </div> <hr> <div class=“row”> <div class=“col-lg-3”> <p class=“with-padding bg-success”>聊天列表</p> <div class=“list-group”><a href=”#" class=“list-group-item”><h4 class=“list-group-item-heading”><i class=“icon-user icon-2x”></i>&nbsp;&nbsp;may</h4></a><a href="#" class=“list-group-item active”><h4 class=“list-group-item-heading”><i class=“icon-user icon-2x”></i>&nbsp;&nbsp;steve</h4></a> </div> </div> <div class=“col-lg-1”></div> <div class=“col-lg-8”> <div class=“comments”> <section class=“comments-list” id=“chatlist”> </section> <footer> <div class=“reply-form” id=“commentReplyForm1”> <a href="###" class=“avatar”><i class=“icon-user icon-2x”></i></a> <form class=“form”> <div class=“form-group”> <textarea id=“inputMsg” class=“form-control new-comment-text” rows=“2” value="" placeholder=“开始聊天… 输入enter 发送消息”></textarea> </div> </form> </div> </footer> </div> </div> </div> </div> <!– ZUI Javascript 依赖 jQuery –> <script src=“https://cdnjs.cloudflare.com/ajax/libs/zui/1.8.1/lib/jquery/jquery.js"></script> <!– ZUI 标准版压缩后的 JavaScript 文件 –> <script src=“https://cdnjs.cloudflare.com/ajax/libs/zui/1.8.1/js/zui.min.js"></script> <script type=“text/javascript”> window.CHAT = { isLogin: false, to: “”, me: “”, WS:{}, init: function () { if (window.WebSocket) { this.WS = new WebSocket(“ws://A156B7L58CCNY4B:8090/ws”); this.WS.onmessage = function(event) { var data = event.data; console.log(“收到数据:” + data); //返回搜索消息 if(data.indexOf(“search”) != -1){ new $.zui.Messager(‘提示消息:’+data, { type: ‘info’ // 定义颜色主题 }).show(); if(data.indexOf(“已找到”)){ //可以进行会话 CHAT.to = data.split(”:”)[1]; } } //返回登陆消息 if(data == “success”){ CHAT.isLogin = true; new $.zui.Messager(‘提示消息:登陆成功’, { type: ‘success’ // 定义颜色主题 }).show(); //连接成功不再修改昵称 $("#userName").attr(“disabled”,“disabled”); CHAT.me = $("#userName").val(); } //返回聊天信息 if (data.split(":").length==3 && CHAT.me == data.split(":")[1]) { CHAT.to = data.split(":")[0]; //设置对话 appendOtherchat(data); } }, this.WS.onclose = function(event) { console.log(“连接关闭”); CHAT.isLogin = false; $("#userName").removeAttr(“disabled”); new $.zui.Messager(‘提示消息:聊天中断’, { type: ‘danger’ // 定义颜色主题 }).show(); }, this.WS.onopen = function(evt) { console.log(“Connection open …”); }, this.WS.onerror = function(event) { console.log(“连接失败….”); CHAT.isLogin = false; $("#userName").removeAttr(“disabled”); new $.zui.Messager(‘提示消息:聊天中断’, { type: ‘danger’ // 定义颜色主题 }).show(); } } else { alert(“您的浏览器不支持聊天,请更换浏览器”); } }, chat:function (msg) { this.WS.send(msg); } } CHAT.init(); function login() { var userName = $("#userName").val(); if (userName != null && userName !=’’) { //初始化聊天 CHAT.chat(“init:"+userName); } else { alert(“请输入用户名登录”); } } function Trim(str) { return str.replace(/(^\s*)|(\s*$)/g, “”); } function appendMy (msg) { //拼接自己的聊天内容 document.getElementById(‘chatlist’).innerHTML+="<div class=‘comment’><a class=‘avatar pull-right’><i class=‘icon-user icon-2x’></i></a><div class=‘content pull-right’><div><strong>我</strong></div><div class=‘text’>"+msg+"</div></div></div>”; } function appendOtherchat(msg) { //拼接别人的聊天信息到聊天室 var msgs = msg.split(":"); document.getElementById(‘chatlist’).innerHTML+="<div class=‘comment’><a class=‘avatar’><i class=‘icon-user icon-2x’></i></a><div class=‘content’><div><strong>"+msgs[0]+"</strong></div><div class=‘text’>"+msgs[2]+"</div></div></div>"; } //搜索在线人员发送消息 document.getElementById(“inputSearch”).addEventListener(‘keyup’, function(event) { if (event.keyCode == “13”) { //回车执行查询 CHAT.chat(“search:"+$(’#inputSearch’).val()); } }); //发送聊天消息 document.getElementById(‘inputMsg’).addEventListener(‘keyup’, function(event) { if (event.keyCode == “13”) { //回车执行查询 var inputMsg = $(’#inputMsg’).val(); if (inputMsg == null || Trim(inputMsg) == "” ) { alert(“请输入聊天消息”); } else { var userName = $(’#userName’).val(); if (userName == null || userName == ‘’) { alert(“请输入聊天昵称”); } else { //发送消息 定义消息格式 me:to:[消息] CHAT.chat(userName+":"+CHAT.to+":"+inputMsg); appendMy(inputMsg); //发送完清空输入 document.getElementById(‘inputMsg’).focus(); document.getElementById(‘inputMsg’).value=""; } } } }); </script> </body></html>后端改造加入一个UserMap,邦定user和Channelpackage netty;import java.util.HashMap;import java.util.Map;import io.netty.channel.Channel;/** * The class UserMap /public class UserMap { private HashMap<String, Channel> users = new HashMap(); private static UserMap instance; public static UserMap getInstance () { if (instance == null) { instance = new UserMap(); } return instance; } private UserMap () { } public void addUser(String userId, Channel ch) { this.users.put(userId, ch); } public Channel getUser (String userId) { return this.users.get(userId); } public void deleteUser (Channel ch) { for (Map.Entry<String, Channel> map: users.entrySet()) { if (map.getValue() == ch) { users.remove(map.getKey()); break; } } }}ChatHandler改造package netty;import java.time.LocalDateTime;import io.netty.buffer.Unpooled;import io.netty.channel.Channel;import io.netty.channel.ChannelHandlerContext;import io.netty.channel.SimpleChannelInboundHandler;import io.netty.channel.group.ChannelGroup;import io.netty.channel.group.DefaultChannelGroup;import io.netty.handler.codec.http.DefaultFullHttpResponse;import io.netty.handler.codec.http.FullHttpRequest;import io.netty.handler.codec.http.FullHttpResponse;import io.netty.handler.codec.http.HttpHeaderValues;import io.netty.handler.codec.http.HttpResponseStatus;import io.netty.handler.codec.http.HttpVersion;import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;import io.netty.handler.codec.http.websocketx.WebSocketFrame;import io.netty.util.concurrent.GlobalEventExecutor;/* * /public class ChatHandler extends SimpleChannelInboundHandler{ public static ChannelGroup channels = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); public static UserMap usermap = UserMap.getInstance(); /* * 每当从服务端收到新的客户端连接时,客户端的 Channel 存入ChannelGroup列表中,并通知列表中的其他客户端 Channel / @Override public void handlerAdded(ChannelHandlerContext ctx) throws Exception { Channel incoming = ctx.channel(); for (Channel channel : channels) { channel.writeAndFlush("[SERVER] - " + incoming.remoteAddress() + " 加入\n"); } channels.add(ctx.channel()); } /* * 每当从服务端收到客户端断开时,客户端的 Channel 移除 ChannelGroup 列表中,并通知列表中的其他客户端 Channel / @Override public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { Channel incoming = ctx.channel(); for (Channel channel : channels) { channel.writeAndFlush("[SERVER] - " + incoming.remoteAddress() + " 离开\n"); } channels.remove(ctx.channel()); } /* * 会话建立时 / @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { // (5) Channel incoming = ctx.channel(); System.out.println(“ChatClient:"+incoming.remoteAddress()+“在线”); } /* * 会话结束时 / @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { // (6) Channel incoming = ctx.channel(); System.out.println(“ChatClient:"+incoming.remoteAddress()+“掉线”); //清除离线用户 this.usermap.deleteUser(incoming); } /* * 出现异常 / @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { // (7) Channel incoming = ctx.channel(); System.out.println(“ChatClient:"+incoming.remoteAddress()+“异常”); // 当出现异常就关闭连接 cause.printStackTrace(); ctx.close(); } /* * 读取客户端发送的消息,并将信息转发给其他客户端的 Channel。 */ @Override protected void channelRead0(ChannelHandlerContext ctx, Object request) throws Exception { if (request instanceof FullHttpRequest) { //是http请求 FullHttpResponse response = new DefaultFullHttpResponse( HttpVersion.HTTP_1_1,HttpResponseStatus.OK , Unpooled.wrappedBuffer(“Hello netty” .getBytes())); response.headers().set(“Content-Type”, “text/plain”); response.headers().set(“Content-Length”, response.content().readableBytes()); response.headers().set(“connection”, HttpHeaderValues.KEEP_ALIVE); ctx.channel().writeAndFlush(response); } else if (request instanceof TextWebSocketFrame) { // websocket请求 //此处id为neety自动分配给每个对话线程的id,有两种,一个长id一个短id,长id唯一,短id可能会重复 String userId = ctx.channel().id().asLongText(); //客户端发送过来的消息 String msg = ((TextWebSocketFrame)request).text(); System.out.println(“收到客户端”+userId+”:"+msg); //发送消息给所有客户端 群聊 //channels.writeAndFlush(new TextWebSocketFrame(msg)); // 邦定user和channel // 定义每个上线用户主动发送初始化信息过来,携带自己的name,然后完成绑定 模型 init:[usrname] // 实际场景中应该使用user唯一id if (msg.indexOf(“init”) != -1) { String userNames[] = msg.split(”:”); if (“init”.equals(userNames[0])) { // 记录新的用户 this.usermap.addUser(userNames[1].trim(), ctx.channel()); ctx.channel().writeAndFlush(new TextWebSocketFrame(“success”)); } } //搜索在线用户 消息模型 search:[username] if (msg.indexOf(“search”) != -1) { Channel ch = this.usermap.getUser(msg.split(":")[1].trim()); if (ch != null) { //此用户存在 ctx.channel().writeAndFlush(new TextWebSocketFrame(“search:"+msg.split(”:")[1].trim()+":已找到")); } else { // 此用户不存在 ctx.channel().writeAndFlush(new TextWebSocketFrame(“search:"+msg.split(”:")[1].trim()+":未找到")); } } //发送消息给指定的用户 消息模型 me:to:[msg] if (msg.split(":").length == 3) { //判断是单聊消息 this.usermap.getUser(msg.split(":")[1].trim()).writeAndFlush(new TextWebSocketFrame(msg)); } //ctx.channel().writeAndFlush(new TextWebSocketFrame(((TextWebSocketFrame)request).text())); } }}注释很详细,自己看总结消息模型应该定义一个单独的类来管理,我目前是用的String字符串来判断,提前规定了一些模型,通过判断来响应前端的请求,比较简单。还有就是没有使用数据库,前端不能显示聊天记录,不能实现消息的已读未读。实际场景中应该对消息进行加密存储,且不能窥探用户隐私。前端可以使用localstorage来存储聊天记录,自己可以扩展。前端的显示可能有点问题,自己可以调。其实主要是学习netty后端的搭建别忘了关注我 mike啥都想搞求关注啊。 ...

January 23, 2019 · 4 min · jiezi

netty搭建web聊天室(2)群聊

上节课完成了netty的后端搭建,搞定了简单的http请求响应,今天来结合前端websocket来完成群聊功能。话不多说先上图:前端构建不使用复杂构建工具直接静态页面走起使用了zui样式库 http://zui.sexy/?#/,非常不错,有好多模板。我使用的是聊天模板改造 <link rel=“stylesheet” href=“https://cdnjs.cloudflare.com/ajax/libs/zui/1.8.1/css/zui.min.css"> <link rel=“stylesheet” href=“zui-theme.css”>主体部分<div class=“container”> <h1>mike多人聊天室,等你来聊</h1> <div class=“comments”> <section class=“comments-list” id=“chatlist”> <div class=“comment”> <a href=”###" class=“avatar”> <i class=“icon-user icon-2x”></i> </a> <div class=“content”> <div><strong>其他人</strong></div> <div class=“text”>其他人的聊天内容</div> </div> </div> <div class=“comment”> <a href="###" class=“avatar pull-right”> <i class=“icon-user icon-2x”></i> </a> <div class=“content pull-right”> <div><strong>我</strong></div> <div class=“text”>我说话的内容</div> </div> </div> </section> <footer> <div class=“reply-form” id=“commentReplyForm1”> <form class=“form”> <div class=“form-group”> <div class=“input-control has-label-left”> <input id=“userName” type=“text” class=“form-control” placeholder=""> <label for=“inputAccountExample2” class=“input-control-label-left”>昵称:</label> </div> </div> </form> <a href="###" class=“avatar”><i class=“icon-user icon-2x”></i></a> <form class=“form”> <div class=“form-group”> <textarea id=“inputMsg” class=“form-control new-comment-text” rows=“2” value="" placeholder=“开始聊天… 输入enter 发送消息”></textarea> </div> </form> </div> </footer></div></div>引入依赖js <!– ZUI Javascript 依赖 jQuery –> <script src=“https://cdnjs.cloudflare.com/ajax/libs/zui/1.8.1/lib/jquery/jquery.js"></script> <!– ZUI 标准版压缩后的 JavaScript 文件 –> <script src=“https://cdnjs.cloudflare.com/ajax/libs/zui/1.8.1/js/zui.min.js"></script>websocket的js代码以及业务代码 <script type=“text/javascript”> window.CHAT = { me: “”, WS:{}, init: function () { if (window.WebSocket) { this.WS = new WebSocket(“ws://A156B7L58CCNY4B:8090/ws”); this.WS.onmessage = function(event) { var data = event.data; console.log(“收到数据:” + data); //显示其他人的聊天信息 console.log(CHAT.me); console.log(data.split(”:”)[0]); if(CHAT.me != data.split(":")[0]) { appendOtherchat(data); } }, this.WS.onclose = function(event) { console.log(“连接关闭”); }, this.WS.onopen = function(evt) { console.log(“Connection open …”); }, this.WS.onerror = function(event) { console.log(“连接失败….”); } } else { alert(“您的浏览器不支持聊天,请更换浏览器”); } }, chat:function (msg) { this.WS.send(msg); } } CHAT.init(); function Trim(str) { return str.replace(/(^\s*)|(\s*$)/g, “”); } function appendMy (msg) { //拼接自己的聊天内容 document.getElementById(‘chatlist’).innerHTML+="<div class=‘comment’><a class=‘avatar pull-right’><i class=‘icon-user icon-2x’></i></a><div class=‘content pull-right’><div><strong>我</strong></div><div class=‘text’>"+msg+"</div></div></div>"; } function appendOtherchat(msg) { //拼接别人的聊天信息到聊天室 var msgs = msg.split(":"); document.getElementById(‘chatlist’).innerHTML+="<div class=‘comment’><a class=‘avatar’><i class=‘icon-user icon-2x’></i></a><div class=‘content’><div><strong>"+msgs[0]+"</strong></div><div class=‘text’>"+msgs[1]+"</div></div></div>"; } document.getElementById(‘inputMsg’).addEventListener(‘keyup’, function(event) { if (event.keyCode == “13”) { //回车执行查询 var inputMsg = document.getElementById(‘inputMsg’).value; if (inputMsg == null || Trim(inputMsg) == "" ) { alert(“请输入聊天消息”); } else { var userName = document.getElementById(‘userName’).value; if (userName == null || userName == ‘’) { alert(“请输入聊天昵称”); } else { //发送消息 定义消息格式 用户名:[消息] CHAT.chat(userName+":"+inputMsg); //记录我的昵称 CHAT.me = userName; appendMy(inputMsg); //发送完清空输入 document.getElementById(‘inputMsg’).focus(); document.getElementById(‘inputMsg’).value=""; } } } }); </script>都有注释就不解释了自己看后端服务改造ChatHandler改造,判断websocket响应 /** * 读取客户端发送的消息,并将信息转发给其他客户端的 Channel。 / @Override protected void channelRead0(ChannelHandlerContext ctx, Object request) throws Exception { if (request instanceof FullHttpRequest) { //是http请求 FullHttpResponse response = new DefaultFullHttpResponse( HttpVersion.HTTP_1_1,HttpResponseStatus.OK , Unpooled.wrappedBuffer(“Hello netty” .getBytes())); response.headers().set(“Content-Type”, “text/plain”); response.headers().set(“Content-Length”, response.content().readableBytes()); response.headers().set(“connection”, HttpHeaderValues.KEEP_ALIVE); ctx.channel().writeAndFlush(response); } else if (request instanceof TextWebSocketFrame) { // websocket请求 String userId = ctx.channel().id().asLongText(); System.out.println(“收到客户端”+userId+":"+((TextWebSocketFrame)request).text()); //发送消息给所有客户端 channels.writeAndFlush(new TextWebSocketFrame(((TextWebSocketFrame)request).text())); //发送给单个客户端 //ctx.channel().writeAndFlush(new TextWebSocketFrame(((TextWebSocketFrame)request).text())); } } ChatServerInitializer改造,加入WebSocket @Override protected void initChannel(SocketChannel ch) throws Exception { ChannelPipeline pipeline = ch.pipeline(); //websocket协议本身是基于http协议的,所以这边也要使用http解编码器 pipeline.addLast(new HttpServerCodec()); //以块的方式来写的处理器 pipeline.addLast(new ChunkedWriteHandler()); //netty是基于分段请求的,HttpObjectAggregator的作用是将请求分段再聚合,参数是聚合字节的最大长度 pipeline.addLast(new HttpObjectAggregator(102410241024)); //ws://server:port/context_path //ws://localhost:9999/ws //参数指的是contex_path pipeline.addLast(new WebSocketServerProtocolHandler("/ws",null,true,65535)); //自定义handler pipeline.addLast(new ChatHandler()); System.out.println(“ChatClient:"+ch.remoteAddress() +“连接上”); }改造完成启动后端服务,访问你的前端静态页面就可以和小伙伴聊天了。其实后端群聊很简单,就是把一个用户的输入消息,返回给所有在线客户端,前端去负责筛选显示。自己动手照着搞10分钟就能完成。实现功能输入聊天昵称开始聊天聊天消息不为空才能发送发送完自动清空输入,且聚焦输入框自己的消息显示在左侧,其他人的消息在右侧别忘了关注我 mike啥都想搞求关注啊。 ...

January 22, 2019 · 2 min · jiezi

用gorilla websocket 搞一个聊天室

这个demo实现了:消息广播心跳检测通过命令行来进行聊天具体逻辑都在 websocket.go 这个文件里这里的核心就是 aliveList 这个全局变量, 负责把消息分发给各客户端, 事件用channel来传递, 减少阻塞单个链接会在 aliveList 中注册, ConnList 就是所有活跃的链接// AliveList 当前在线列表type AliveList struct { ConnList map[string]*Client register chan *Client destroy chan *Client broadcast chan Message cancel chan int Len int}// Client socket客户端type Client struct { ID string conn *websocket.Conn cancel chan int}服务启动后会执行事件监听循环// 启动监听func (al *AliveList) run() { log.Println(“开始监听注册事件”) for { select { case client := <-al.register: log.Println(“注册事件:”, client.ID) al.ConnList[client.ID] = client al.Len++ al.SysBroadcast(ConnectedMessage, Message{ ID: client.ID, Content: “connected”, SentAt: time.Now().Unix(), }) case client := <-al.destroy: log.Println(“销毁事件:”, client.ID) err := client.conn.Close() if err != nil { log.Printf(“destroy Error: %v \n”, err) } delete(al.ConnList, client.ID) al.Len– case message := <-al.broadcast: log.Printf(“广播事件: %s %s %d \n”, message.ID, message.Content, message.Type) for id := range al.ConnList { if id != message.ID { err := al.sendMessage(id, message) if err != nil { log.Println(“broadcastError: “, err) } } } case sign := <-al.cancel: log.Println(“终止事件: “, sign) os.Exit(0) } }}因为消息的类型比较多, 单纯字符串无法满足需求, 就选用了比较常用的json格式去传递, 消息目前分:const ( // SystemMessage 系统消息 SystemMessage = iota // BroadcastMessage 广播消息(正常的消息) BroadcastMessage // HeartBeatMessage 心跳消息 HeartBeatMessage // ConnectedMessage 上线通知 ConnectedMessage // DisconnectedMessage 下线通知 DisconnectedMessage)// Message 消息体结构type Message struct { ID string Content string SentAt int64 Type int // <- SystemMessage 等类型就是这里了}如果有空闲时间就再搞搞多聊天室的实现, 以及优化一下目前的事件循环逻辑如果还有更多的余力, 就搞一个好看点的客户端?demo地址我的博客 ...

January 22, 2019 · 1 min · jiezi

【译】WebSocket协议第五章——数据帧(Data Framing)

概述本文为WebSocket协议的第五章,本文翻译的主要内容为WebSocket传输的数据相关内容。有兴趣了解该文档之前几张内容的同学可以见:【译】WebSocket协议第一章——介绍(Introduction)【译】WebSocket协议第二章——一致性要求(Conformance Requirements)【译】WebSocket协议第三章——WebSocket网址(WebSocket URIs)【译】WebSocket协议第四章——连接握手(Opening Handshake)数据帧(协议正文)5.1 概览在WebSocket协议中,数据是通过一系列数据帧来进行传输的。为了避免由于网络中介(例如一些拦截代理)或者一些在第10.3节讨论的安全原因,客户端必须在它发送到服务器的所有帧中添加掩码(Mask)(具体细节见5.3节)。(注意:无论WebSocket协议是否使用了TLS,帧都需要添加掩码)。服务端收到没有添加掩码的数据帧以后,必须立即关闭连接。在这种情况下,服务端可以发送一个在7.4.1节定义的状态码为1002(协议错误)的关闭帧。服务端禁止在发送数据帧给客户端时添加掩码。客户端如果收到了一个添加了掩码的帧,必须立即关闭连接。在这种情况下,它可以使用第7.4.1节定义的1002(协议错误)状态码。(这些规则可能会在将来的规范中放开)。基础的数据帧协议使用操作码、有效负载长度和在“有效负载数据”中定义的放置“扩展数据”与“引用数据”的指定位置来定义帧类型。特定的bit位和操作码为将来的协议扩展做了保留。一个数据帧可以在开始握手完成之后和终端发送了一个关闭帧之前的任意一个时间通过客户端或者服务端进行传输(第5.5.1节)。5.2 基础帧协议在这节中的这种数据传输部分的有线格式是通过ABNFRFC5234来进行详细说明的。(注意:不像这篇文档中的其他章节内容,在这节中的ABNF是对bit组进行操作。每一个bit组的长度是在评论中展示的。在线上编码时,最高位的bit是在ABNF最左边的)。对于数据帧的高级的预览可以见下图。如果下图指定的内容和这一节中后面的ABNF指定的内容有冲突的话,以下图为准。 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+——-+-+————-+——————————-+ |F|R|R|R| opcode|M| Payload len | Extended payload length | |I|S|S|S| (4) |A| (7) | (16/64) | |N|V|V|V| |S| | (if payload len==126/127) | | |1|2|3| |K| | | +-+-+-+-+——-+-+————-+ - - - - - - - - - - - - - - - + | Extended payload length continued, if payload len == 127 | + - - - - - - - - - - - - - - - +——————————-+ | |Masking-key, if MASK set to 1 | +——————————-+——————————-+ | Masking-key (continued) | Payload Data | +——————————– - - - - - - - - - - - - - - - + : Payload Data continued … : + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + | Payload Data continued … | +—————————————————————+FIN: 1 bit 表示这是消息的最后一个片段。第一个片段也有可能是最后一个片段。RSV1,RSV2,RSV3: 每个1 bit 必须设置为0,除非扩展了非0值含义的扩展。如果收到了一个非0值但是没有扩展任何非0值的含义,接收终端必须断开WebSocket连接。Opcode: 4 bit 定义“有效负载数据”的解释。如果收到一个未知的操作码,接收终端必须断开WebSocket连接。下面的值是被定义过的。 %x0 表示一个持续帧 %x1 表示一个文本帧 %x2 表示一个二进制帧 %x3-7 预留给以后的非控制帧 %x8 表示一个连接关闭包 %x9 表示一个ping包 %xA 表示一个pong包 %xB-F 预留给以后的控制帧Mask: 1 bit mask标志位,定义“有效负载数据”是否添加掩码。如果设置为1,那么掩码的键值存在于Masking-Key中,根据5.3节描述,这个一般用于解码“有效负载数据”。所有的从客户端发送到服务端的帧都需要设置这个bit位为1。Payload length: 7 bits, 7+16 bits, or 7+64 bits 以字节为单位的“有效负载数据”长度,如果值为0-125,那么就表示负载数据的长度。如果是126,那么接下来的2个bytes解释为16bit的无符号整形作为负载数据的长度。如果是127,那么接下来的8个bytes解释为一个64bit的无符号整形(最高位的bit必须为0)作为负载数据的长度。多字节长度量以网络字节顺序表示(译注:应该是指大端序和小端序)。在所有的示例中,长度值必须使用最小字节数来进行编码,例如:长度为124字节的字符串不可用使用序列126,0,124进行编码。有效负载长度是指“扩展数据”+“应用数据”的长度。“扩展数据”的长度可能为0,那么有效负载长度就是“应用数据”的长度。Masking-Key: 0 or 4 bytes 所有从客户端发往服务端的数据帧都已经与一个包含在这一帧中的32 bit的掩码进行过了运算。如果mask标志位(1 bit)为1,那么这个字段存在,如果标志位为0,那么这个字段不存在。在5.3节中会介绍更多关于客户端到服务端增加掩码的信息。Payload data: (x+y) bytes “有效负载数据”是指“扩展数据”和“应用数据”。Extension data: x bytes 除非协商过扩展,否则“扩展数据”长度为0 bytes。在握手协议中,任何扩展都必须指定“扩展数据”的长度,这个长度如何进行计算,以及这个扩展如何使用。如果存在扩展,那么这个“扩展数据”包含在总的有效负载长度中。Application data: y bytes 任意的“应用数据”,占用“扩展数据”后面的剩余所有字段。“应用数据”的长度等于有效负载长度减去“扩展应用”长度。基础数据帧协议通过ABNF进行了正式的定义。需要重点知道的是,这些数据都是二进制的,而不是ASCII字符。例如,长度为1 bit的字段的值为%x0 / %x1代表的是一个值为0/1的单独的bit,而不是一整个字节(8 bit)来代表ASCII编码的字符“0”和“1”。一个长度为4 bit的范围是%x0-F的字段值代表的是4个bit,而不是字节(8 bit)对应的ASCII码的值。不要指定字符编码:“规则解析为一组最终的值,有时候是字符。在ABNF中,字符仅仅是一个非负的数字。在特定的上下文中,会根据特定的值的映射(编码)编码集(例如ASCII)”。在这里,指定的编码类型是将每个字段编码为特定的bits数组的二进制编码的最终数据。ws-frame =frame-fin; 长度为1 bitframe-rsv1; 长度为1 bitframe-rsv2; 长度为1 bitframe-rsv3; 长度为1 bitframe-opcode; 长度为4 bitframe-masked; 长度为1 bitframe-payload-length; 长度为7或者7+16或者7+64 bit[frame-masking-key]; 长度为32 bitframe-payload-data; 长度为大于0的n8 bit(其中n>0)frame-fin =%x0,除了以下为1的情况%x1,最后一个消息帧长度为1 bitframe-rsv1 =%x0 / %x1,长度为1 bit,如果没有协商则必须为0frame-rsv2 =%x0 / %x1,长度为1 bit,如果没有协商则必须为0frame-rsv3 =%x0 / %x1,长度为1 bit,如果没有协商则必须为0frame-opcode =frame-opcode-non-controlframe-opcode-controlframe-opcode-contframe-opcode-non-control%x1,文本帧%x2,二进制帧%x3-7,保留给将来的非控制帧长度为4 bitframe-opcode-control%x8,连接关闭%x9,ping帧%xA,pong帧%xB-F,保留给将来的控制帧长度为4 bitframe-masked%x0,不添加掩码,没有frame-masking-key%x1,添加掩码,存在frame-masking-key长度为1 bitframe-payload-length%x00-7D,长度为7 bit%x7E frame-payload-length-16,长度为7+16 bit%x7F frame-payload-length-63,长度为7+64 bitframe-payload-length-16%x0000-FFFF,长度为16 bitframe-payload-length-63%x0000000000000000-7FFFFFFFFFFFFFFF,长度为64 bitframe-masking-key4(%x00-FF),当frame-mask为1时存在,长度为32 bitframe-payload-dataframe-masked-extension-data frame-masked-application-data,当frame-masked为1时frame-unmasked-extension-data frame-unmasked-application-data,当frame-masked为0时frame-masked-extension-data(%x00-FF),保留给将来的扩展,长度为n8,其中n>0frame-masked-application-data(%x00-FF),长度为n8,其中n>0frame-unmasked-extension-data(%x00-FF),保留给将来的扩展,长度为n8,其中n>0frame-unmasked-application-data(%x00-FF),长度为n*8,其中n>05.3 客户端到服务端添加掩码添加掩码的数据帧必须像5.2节定义的一样,设置frame-masked字段为1。掩码值像第5.2节说到的完全包含在帧中的frame-masking-key上。它是用于对定义在同一节中定义的帧负载数据Payload data字段中的包含Extension data和Application data的数据进行添加掩码。掩码字段是一个由客户端随机选择的32bit的值。当准备掩码帧时,客户端必须从允许的32bit值中须知你咋一个新的掩码值。掩码值必须是不可被预测的;因此,掩码必须来自强大的熵源(entropy),并且给定的掩码不能让服务器或者代理能够很容易的预测到后续帧。掩码的不可预测性对于预防恶意应用作者在网上暴露相关的字节数据至关重要。RFC 4086讨论了安全敏感的应用需要一个什么样的合适的强大的熵源。掩码不影响Payload data的长度。进行掩码的数据转换为非掩码数据,或者反过来,根据下面的算法即可。这个同样的算法适用于任意操作方向的转换,例如:对数据进行掩码操作和对数据进行反掩码操作所涉及的步骤是相同的。表示转换后数据的八位字节的i(transformed-octet-i )是表示的原始数据的i(original-octet-i)与索引i模4得到的掩码值(masking-key-octet-j)经过异或操作(XOR)得到的:j = i MOD 4transfromed-octed-i = original-octet-i XOR masking-key-octet-j在规范中定义的位于frame-payload-length字段的有效负载的长度,不包括掩码值的长度。它只是Payload data的长度。如跟在掩码值后面的字节数组的数。5.4 消息分片消息分片的主要目的是允许发送一个未知长度且消息开始发送后不需要缓存的消息。如果消息不能被分片,那么一端必须在缓存整个消息,因此这个消息的长度必须在第一个字节发送前就需要计算出来。如果有消息分片,服务端或者代理可以选择一个合理的缓存长度,当缓存区满了以后,就想网络发送一个片段。第二个消息分片使用的场景是不适合在一个逻辑通道内传输一个大的消息占满整个输出频道的多路复用场景。多路复用需要能够将消息进行自由的切割成更小的片段来共享输出频道。(注意:多路复用的扩展不在这个文档中讨论)。除非在扩展中另有规定,否则帧没有语义的含义。如果客户端和服务的没有协商扩展字段,或者服务端和客户端协商了一些扩展字段,并且代理能够完全识别所有的协商扩展字段,在这些扩展字段存在的情况下知道如何进行帧的合并和拆分,代理就可能会合并或者拆分帧。这个的一个含义是指在缺少扩展字段的情况下,发送者和接收者都不能依赖特定的帧边界的存在。消息分片相关的规则如下:一个未分片的消息包含一个设置了FIN字段(标记为1)的单独的帧和一个除0以外的操作码。一个分片的消息包含一个未设置的FIN字段(标记为0)的单独的帧和一个除0以外的操作码,然后跟着0个或者多个未设置FIN字段的帧和操作码为0的帧,然后以一个设置了FIN字段以及操作码为0的帧结束。一个分片的消息内容按帧顺序组合后的payload字段,是等价于一个单独的更大的消息payload字段中包含的值;然而,如果扩展字段存在,因为扩展字段定义了Extension data的解析方式,因此前面的结论可能不成立。例如:Extension data可能只出现在第一个片段的开头,并适用于接下来的片段,或者可能每一个片段都有Extension data,但是只适用于特定的片段。在Extension data不存在时,下面的示例演示了消息分片是如何运作的。示例:一个文本需要分成三个片段进行发送,第一个片段包含的操作码为0x1并且未设置FIN字段,第二个片段的操作码为0x0并且未设置FIN字段,第三个片段的操作码为0x0并且设置了FIN字段。控制帧(见5.5节)可能被插入到分片消息的中间。控制帧不能被分片。消息片段必须在发送端按照顺序发送给接收端。除非在扩展中定义了这种嵌套的逻辑,否则一条消息分的片不能与另一条消息分的片嵌套传输。终端必须有能力来处理在分片的消息中的控制帧。发送端可能会创建任意大小的非控制消息片段。客户端和服务端必须同时支持分片和不分片消息。控制帧不能被分片,并且代理不允许改变控制帧的片段。如果有保留字段被使用并且代理不能理解这些字段的值时,那么代理不能改变消息的片段。在扩展字段已经被协商过,但是代理不知道协商扩展字段的具体语义时,代理不能改变任意消息的片段。同样的,扩展不能看到WebSocket握手(并且得不到通知内容)导致WebSocket的连接禁止改变连接过程中任意的消息片段。作为这些规则的结论,所有的消息片段都是同类型的,并且设置了第一个片段的操作码(opccode)字段。控制帧不能被分片,所有的消息分片类型必须是文本或者二进制,或者是保留的任意一个操作码。注:如果控制帧没有被打断,心跳(ping)的等待时间可能会变很长,例如在一个很大的消息之后。因此,在分片的消息传输中插入控制帧是有必要的。实践说明:如果扩展字段不存在,接收者不需要使用缓存来存储下整个消息片段来进行处理。例如:如果使用一个流式API,再收到部分帧的时候就可以将数据交给上层应用。然而,这个假设对以后所有的WebSocket扩展可能不一定成立。5.5 控制帧控制帧是通过操作码最高位的值为1来进行区分的。当前已经定义的控制帧操作码包括0x8(关闭),0x9(心跳Ping)和0xA(心跳Pong)。操作码0xB-0xF没有被定义,当前被保留下来做为以后的控制帧。控制帧是用于WebSocket的通信状态的。控制帧可以被插入到消息片段中进行传输。所有的控制帧必须有一个126字节或者更小的负载长度,并且不能被分片。5.5.1 关闭(Close)控制帧的操作码值是0x8。关闭帧可能包含内容(body)(帧的“应用数据”部分)来表明连接关闭的原因,例如终端的断开,或者是终端收到了一个太大的帧,或者是终端收到了一个不符合预期的格式的内容。如果这个内容存在,内容的前两个字节必须是一个无符号整型(按照网络字节序)来代表在7.4节中定义的状态码。跟在这两个整型字节之后的可以是UTF-8编码的的数据值(原因),数据值的定义不在此文档中。数据值不一定是要人可以读懂的,但是必须对于调试有帮助,或者能传递有关于当前打开的这条连接有关联的信息。数据值不保证人一定可以读懂,所以不能把这些展示给终端用户。从客户端发送给服务端的控制帧必须添加掩码,具体见5.3节。应用禁止在发送了关闭的控制帧后再发送任何的数据帧。如果终端收到了一个关闭的控制帧并且没有在以前发送一个关闭帧,那么终端必须发送一个关闭帧作为回应。(当发送一个关闭帧作为回应时,终端通常会输出它收到的状态码)响应的关闭帧应该尽快发送。终端可能会推迟发送关闭帧直到当前的消息都已经发送完成(例如:如果大多数分片的消息已经发送了,终端可能会在发送关闭帧之前将剩余的消息片段发送出去)。然而,已经发送关闭帧的终端不能保证会继续处理收到的消息。在已经发送和收到了关闭帧后,终端认为WebSocket连接以及关闭了,并且必须关闭底层的TCP连接。服务端必须马上关闭底层的TCP连接,客户端应该等待服务端关闭连接,但是也可以在收到关闭帧以后任意时间关闭连接。例如:如果在合理的时间段内没有收到TCP关闭指令。如果客户端和服务端咋同一个时间发送了关闭帧,两个终端都会发送和接收到一条关闭的消息,并且应该认为WebSocket连接已经关闭,同时关闭底层的TCP连接。5.5.2 心跳Ping心跳Ping帧包含的操作码是0x9。关闭帧可能包含“应用数据”。如果收到了一个心跳Ping帧,那么终端必须发送一个心跳Pong 帧作为回应,除非已经收到了一个关闭帧。终端应该尽快恢复Pong帧。Pong帧将会在5.5.3节讨论。终端可能会在建立连接后与连接关闭前中间的任意时间发送Ping帧。注意:Ping帧可能是用于保活或者用来验证远端是否仍然有应答。5.5.3 心跳Pong心跳Ping帧包含的操作码是0xA。5.5.2节详细说明了Ping帧和Pong帧的要求。作为回应发送的Pong帧必须完整携带Ping帧中传递过来的“应用数据”字段。如果终端收到一个Ping帧但是没有发送Pong帧来回应之前的pong帧,那么终端可能选择用Pong帧来回复最近处理的那个Ping帧。Pong帧可以被主动发送。这会作为一个单项的心跳。预期外的Pong包的响应没有规定。数据帧数据帧(例如非控制帧)的定义是操作码的最高位值为0。当前定义的数据帧操作吗包含0x1(文本)、0x2(二进制)。操作码0x3-0x7是被保留作为非控制帧的操作码。数据帧会携带应用层/扩展层数据。操作码决定了携带的数据解析方式:文本“负载字段”是用UTF-8编码的文本数据。注意特殊的文本帧可能包含部分UTF-8序列;然而,整个消息必须是有效的UTF-8编码数据。重新组合消息后无效的UTF-8编码数据处理见8.1节。二进制“负载字段”是任意的二进制数据,二进制数据的解析仅仅依靠应用层。5.7 示例一个单帧未添加掩码的文本消息0x81 0x05 0x48 0x65 0x6c 0x6c 0x6f (内容为"Hello")一个单帧添加掩码的文本消息0x81 0x85 0x37 0xfa 0x21 0x3d 0x7f 0x9f 0x4d 0x51 0x58 (内容为Hello")一个分片的未添加掩码的文本消息0x01 0x03 0x48 0x65 0x6c (内容为"Hel")0x80 0x02 0x6c 0x6f (内容为”lo")未添加掩码的Ping请求和添加掩码的Ping响应(译者注:即Pong)0x89 0x05 0x48 0x65 0x6c 0x6c 0x6f (包含内容为”Hello", 但是文本内容是任意的)0x8a 0x85 0x37 0xfa 0x21 0x3d 0x7f 0x9f 0x4d 0x51 0x58 (包含内容为”Hello", 匹配ping的内容)256字节的二进制数据放入一个未添加掩码数据帧0x82 0x7E 0x0100 [256 bytes of binary data]64KB二进制数据在一个非掩码帧中0x82 0x7F 0x0000000000010000 [65536 bytes of binary data]扩展性这个协议的设计初衷是允许扩展的,可以在基础协议上增加能力。终端的连接必须在握手的过程中协商使用的所有扩展。在规范中提供了从0x3-0x7和0xB-0xF的操作码,在数据帧Header中的“扩展数据”字段、frame-rsv1、frame-rsv2、frame-rsv3字段都可以用于扩展。扩展的协商讨论将在以后的9.1节中详细讨论。下面是一些符合预期的扩展用法。下面的列表不完整,也不是规范中内容。“扩展数据”可以放置在“负载数据“中的应用数据”之前的位置。保留的字段可以在每一帧需要时被使用。保留的操作码的值可以被定义。如果需要更多的操作码,那么保留的操作码字段可以被定义。保留的字段或者“扩展”操作码可以在“负载数据”之中的分配额外的位置来定义,这样可以定义更大的操作码或者更多的每一帧的字段。 ...

January 7, 2019 · 2 min · jiezi

用Java构建一个简单的WebSocket聊天项目之新增HTTP接口调度

本文首发公众号与个人博客:Java猫说 & 猫叔的博客 | MySelf,转载请申明出处。前言大家可以看看上一篇:用Java构建一个简单的WebSocket聊天室在上一篇文章中我们已经实现了:自我对话、好友交流、群聊、离线消息等的功能。而本篇,我们的框架升级了,并且开通了几个新的HTTP接口功能,同时也把原先框架的一些异常做了处理。我们将使用更少的代码完成功能更加完善的聊天项目!采用框架我们整个Demo基本不需要大家花费太多时间,就可以实现以下的功能。用户token登录校验自我聊天点对点聊天群聊获取在线用户数与用户标签列表发送系统通知首先,我们需要介绍一下我们今天打算采用的框架,InChat : 一个轻量级、高效率的支持多端(应用与硬件Iot)的异步网络应用通讯框架,采用这个框架,我们基本上只需要两三个类就可以实现我们今天需要的功能了。先看看效果需要了解SSM & SpringBoot 吗?InChat ,本身不依赖于任何的底层框架,所以大家只要会基本的Java语言就可以实现一套自己的WebSocket聊天室。框架使用手册(新版V1.1.2刚刚发布)关于详细的手册说明,大家可以看看官网的介绍:V1.1.2版本使用说明V1.1.2版本视频教学<dependency> <groupId>com.github.UncleCatMySelf</groupId> <artifactId>InChat</artifactId> <version>1.1.2</version></dependency>开始Demo搭建构建一个空的Maven项目我们不需要依赖其他的Maven包,只要本文提及的框架即可。<dependency> <groupId>com.github.UncleCatMySelf</groupId> <artifactId>InChat</artifactId> <version>1.1.2</version></dependency>InChat启动参数可以自配置你只需要继承InChat的默认配置类InitNetty即可,如下public class MyInit extends InitNetty { /** 自定义启动监听端口 */ @Override public int getWebport() { return 8090; }}获取聊天消息数据此接口与原先一样,仅修改了方法名public class DataBaseServiceImpl implements InChatToDataBaseService { @Override public Boolean writeMessage(InChatMessage message) { System.out.println(message.toString()); return true; }}登录校验与群聊消息此接口没有做过多的修改public class VerifyServiceImpl implements InChatVerifyService { @Override public boolean verifyToken(String token) { return true; } @Override public JSONArray getArrayByGroupId(String groupId) { JSONArray jsonArray = JSONArray.parseArray("["1111","2222","3333"]"); return jsonArray; }}服务端发送通知消息枚举类此接口具有Demo模板,用户需要继承InChat框架的FromServerService接口,同时该接口注释也有实例demo,我们需要实现一个自定义的枚举,你可以这样写:public enum FromServerServiceImpl implements FromServerService { //你可以自定义自己的系统消息,请以Integer-String的形式 TYPE1(1,"【系统通知】您的账号存在异常,请注意安全保密信息。"), TYPE2(2,"【系统通知】恭喜您连续登录超过5天,奖励5积分。"); private Integer code; private String message; FromServerServiceImpl(Integer code, String message){ this.code = code; this.message = message; } public Integer getCode() { return code; } //实现接口的方法,遍历本枚举的code,获取对应的消息,作为系统消息发送 public String findByCode(Object code) { Integer codes = (Integer)code; for (FromServerServiceImpl item: FromServerServiceImpl.values()) { if (item.code == codes){ return item.message; } } return null; } public void setCode(Integer code) { this.code = code; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; }}启动项目1.1.2版本的启动项目变得异常的简单,你只需要配置启动的配置工厂即可。public class application { public static void main(String[] args) { //配置你的自定义配置 ConfigFactory.initNetty = new MyInit(); //配置校验类 ConfigFactory.inChatVerifyService = new VerifyServiceImpl(); //配置消息接收处理类 ConfigFactory.inChatToDataBaseService = new DataBaseServiceImpl(); //配置服务端系统消息枚举,这里的值无所谓 TYPE1或者TYPE2或者TYPEN均可以 ConfigFactory.fromServerService = FromServerServiceImpl.TYPE1; //启动InChat InitServer.open(); }}项目效果启动成功DEBUG - -Dio.netty.threadLocalDirectBufferSize: 0DEBUG - -Dio.netty.maxThreadLocalCharBufferSize: 16384 INFO - 服务端启动成功【192.168.56.1:8090】当聊天连接未注册情况下,客户端自动断开后,服务会自动包对应的异常 INFO - [Handler:channelInactive]/192.168.56.1:8090关闭成功ERROR - [捕获异常:NotFindLoginChannlException]-[Handler:channelInactive] 关闭未正常注册链接!原先的自我发送,点对点发送,群聊均与原来一样原先的接口说明可以看上一版本: v1.1.0-alpha版本使用说明新功能添加 HTTP新增HTTP接口三个,在你启动Inchat的时候,默认启动,对于你的其他web API并无任何影响,它是一个IM的辅助作用。本版本不支持用户自定义相关的InChat HTTP接口获取在线用户数地址:[ip:端口]/get_size GET返回值{ “code”: 200, “data”: { “online”: 1,//当前在线数 “time”: “Jan 3, 2019 10:06:45 PM”//查询时间 }}获取在线用户标识地址:[ip:端口]/get_list GET返回值{ “code”: 200, “data”: { //返回在线用户列表 “tokens”: [ “1111” ] }}根据用户标签,发送系统指定消息地址:[ip:端口]/send_from_server POST参数:token(你可以从get_list中得到在线用户标签)、value(你在系统中添加枚举的code值,这里不接受字符串)返回值{ “code”: 400, “data”: { “message”: “通知发送成功” }}(有个小BUG,返回值code应该是200)关于前端InChat : 一个轻量级、高效率的支持多端(应用与硬件Iot)的异步网络应用通讯框架,大家可以直接来这个项目下获取前端页面,或者直接访问这个地址:https://github.com/UncleCatMy…对于这个前端页面,我们需要更改一下IP地址。运行调试项目接下来直接启动后端项目,当我们看到以下的信息,则项目启动成功。 INFO - 服务端启动成功【192.168.1.121:8090】这里的IP需要更换以下读者启动后的IP地址。接着直接用浏览器打开chat.html的页面即可,关于js的方法,大家可以看看InChatV1.1.0版本使用说明。运行效果已经提前展示啦!公众号:Java猫说现架构设计(码农)兼创业技术顾问,不羁平庸,热爱开源,杂谈程序人生与不定期干货。 ...

January 4, 2019 · 2 min · jiezi

用Java构建一个简单的WebSocket聊天室

前言首先对于一个简单的聊天室,大家应该都有一定的概念了,这里我们省略用户模块的讲解,而是单纯的先说说聊天室的几个功能:自我对话、好友交流、群聊、离线消息等。今天我们要做的demo就能帮我们做到这一点啦!!!采用框架我们整个Demo基本不需要大家花费太多时间,就可以实现以上的几个功能。首先,我们需要介绍一下我们今天打算采用的框架,InChat : 一个轻量级、高效率的支持多端(应用与硬件Iot)的异步网络应用通讯框架,采用这个框架,我们基本上只需要两三个类就可以实现我们今天需要的功能了。需要了解SSM & SpringBoot 吗?InChat ,本身不依赖于任何的底层框架,所以大家只要会基本的Java语言就可以实现一套自己的WebSocket聊天室。框架使用手册关于详细的手册说明,大家可以看看官网的介绍:InChatV1.1.0版本使用说明开始Demo搭建构建一个空的Maven项目我们不需要依赖其他的Maven包,只要本文提及的框架即可。com.github.UncleCatMySelfInChat1.1.0-alpha对接两个接口与实现一个是框架提供给我们用户进行数据保存与读取的,通过这个接口的实现,我们可以异步拿到每个聊天的通信数据。这里的InChatMessage是一个框架自定义的通信对象。public class ToDataBaseServiceImpl implements InChatToDataBaseService{ @Override public Boolean writeMapToDB(InChatMessage message) { System.out.println(message.toString()); return true; }}还有一个接口是对登录的校验(这里我们审理用户登录与校验模块,所以直接返回true即可),还有一个是返回群聊的数组信息。public class verifyServiceImpl implements InChatVerifyService { @Override public boolean verifyToken(String token) { //登录校验 return true; } @Override public JSONArray getArrayByGroupId(String groupId) { //根据群聊id获取对应的群聊人员ID JSONArray jsonArray = JSONArray.parseArray("["1111","2222","3333"]"); return jsonArray; }}我们可以再详细的说下,获取群聊信息,是通过一个groupId来获取对应的用户Id数组,我们可以自己做一个数据查询。核心的框架启动代码直接上代码,然后我们再讲解一下。public class DemoApplication { public static void main(String[] args) { //配置InChat配置工厂 ConfigFactory.inChatToDataBaseService = new ToDataBaseServiceImpl(); ConfigFactory.inChatVerifyService = new verifyServiceImpl(); //默认启动InChat InitServer initServer = new InitServer(new InitNetty()); initServer.open(); //获取用户值 WebSocketChannelService webSocketChannelService = new WebSocketChannelService(); //启动新线程 new Thread(new Runnable() { @Override public void run() { //设定默认服务器发送值 Map map = new HashMap<>(); map.put(“server”,“服务器”); //获取控制台用户想发送的用户Token Scanner scanner = new Scanner(System.in); String token = scanner.nextLine(); //获取用户连接 Channel channel = (Channel) webSocketChannelService.getChannel(token); //调用接口发送 webSocketChannelService.sendFromServer(channel,map); } }).start(); }}好了,以上已经基本完成了我们的聊天室Demo了,是不是很简单!?首先,我们将实现的两个类,配置到框架的配置工厂中,然后启动框架即可,相关的类,都是框架提供的。下面的线程是一个框架的接口,以服务器第一人称发送给针对用户通知信息,输入“1111”,Demo演示的用户token值。关于前端InChat : 一个轻量级、高效率的支持多端(应用与硬件Iot)的异步网络应用通讯框架,大家可以直接来这个项目下获取前端页面,或者直接访问这个地址:https://github.com/UncleCatMy…对于这个前端页面,我们需要更改一下IP地址。运行调试项目接下来直接启动后端项目,当我们看到以下的信息,则项目启动成功。 INFO - 服务端启动成功【192.168.1.121:8090】这里的IP需要更换以下读者启动后的IP地址。接着直接用浏览器打开chat.html的页面即可,关于js的方法,大家可以看看InChatV1.1.0版本使用说明。运行效果如下: INFO - 服务端启动成功【192.168.1.121:8090】DEBUG - -Dio.netty.buffer.bytebuf.checkAccessible: trueDEBUG - Loaded default ResourceLeakDetector: io.netty.util.ResourceLeakDetector@68ad4247 INFO - [DefaultWebSocketHandler.channelActive]/192.168.1.121:17330链接成功DEBUG - -Dio.netty.recycler.maxCapacityPerThread: 4096DEBUG - -Dio.netty.recycler.maxSharedCapacityFactor: 2DEBUG - -Dio.netty.recycler.linkCapacity: 16DEBUG - -Dio.netty.recycler.ratio: 8DEBUG - [id: 0xabb0dbad, L:/192.168.1.121:8090 - R:/192.168.1.121:17330] WebSocket version V13 server handshakeDEBUG - WebSocket version 13 server handshake key: JYErdeATDgbPmgK0mZ+IlQ==, response: YK9ZiJehNP+IwtlkpoVkPt94yWY=DEBUG - Decoding WebSocket Frame opCode=1DEBUG - Decoding WebSocket Frame length=31 INFO - [DefaultWebSocketHandler.textdoMessage.LOGIN]DEBUG - Encoding WebSocket Frame opCode=1 length=33DEBUG - Decoding WebSocket Frame opCode=1DEBUG - Decoding WebSocket Frame length=43 INFO - [DefaultWebSocketHandler.textdoMessage.SENDME]1111DEBUG - Encoding WebSocket Frame opCode=1 length=28 INFO - 【异步写入数据】InChatMessage{time=Mon Dec 24 10:03:00 CST 2018, type=‘sendMe’, value=’’, token=‘1111’, groudId=‘null’, online=‘null’, onlineGroup=null, one=‘null’}DEBUG - Decoding WebSocket Frame opCode=1DEBUG - Decoding WebSocket Frame length=56 INFO - [DefaultWebSocketHandler.textdoMessage.SENDTO]1111DEBUG - Encoding WebSocket Frame opCode=1 length=41 INFO - 【异步写入数据】InChatMessage{time=Mon Dec 24 10:03:01 CST 2018, type=‘sendTo’, value=’’, token=‘1111’, groudId=‘null’, online=‘2222’, onlineGroup=null, one=‘2222’}DEBUG - Decoding WebSocket Frame opCode=1DEBUG - Decoding WebSocket Frame length=60 INFO - [DefaultWebSocketHandler.textdoMessage.SENDGROUP]1111DEBUG - Encoding WebSocket Frame opCode=1 length=59 INFO - 【异步写入数据】InChatMessage{time=Mon Dec 24 10:03:02 CST 2018, type=‘sendGroup’, value=’’, token=‘1111’, groudId=‘2’, online=‘null’, onlineGroup=[2222, 3333], one=‘null’}1111DEBUG - Encoding WebSocket Frame opCode=1 length=22 ...

December 24, 2018 · 2 min · jiezi

JavaScript是如何工作: 深入探索 websocket 和HTTP/2与SSE +如何选择正确的路径!

文章底部分享给大家一套 react + socket 实战教程这是专门探索 JavaScript 及其所构建的组件的系列文章的第5篇。如果你错过了前面的章节,可以在这里找到它们:JavaScript是如何工作的:引擎,运行时和调用堆栈的概述!JavaScript是如何工作的:深入V8引擎&编写优化代码的5个技巧JavaScript如何工作:内存管理+如何处理4个常见的内存泄漏JavaScript是如何工作的:事件循环和异步编程的崛起+ 5种使用 async/await 更好地编码方式!这一次,我们将深入到通信协议的领域,映射和探讨它们的属性,并在此过程中构建部分组件。快速比较WebSockets和 HTTP/2。最后,我们分享一些关于如何选择网络协议的方法。简介如今,功能丰富、动态 ui 的复杂 web 应用程序被认为是理所当然。这并不奇怪——互联网自诞生以来已经走过了漫长的道路。最初,互联网并不是为了支持这种动态和复杂的 web 应用程序而构建的。它被认为是HTML页面的集合,相互链接形成一个包含信息的 “web” 概念。一切都是围绕 HTTP 的所谓 请求/响应 范式构建的。客户端加载一个页面,然后在用户单击并导航到下一个页面之前什么都不会发生。大约在2005年,AJAX被引入,很多人开始探索在客户端和服务器之间建立双向连接的可能性。尽管如此,所有HTTP 通信都由客户端引导,客户端需要用户交互或定期轮询以从服务器加载新数据。让 HTTP 变成“双向”交互让服务器能够“主动”向客户机发送数据的技术已经出现了相当长的时间。例如“Push”和“Comet”。最常见的一种黑客攻击方法是让服务器产生一种需要向客户端发送数据的错觉,这称为长轮询。通过长轮询,客户端打开与服务器的 HTTP 连接,使其保持打开状态,直到发送响应为止。 每当服务器有新数据时需要发送时,就会作为响应发送。看看一个非常简单的长轮询代码片段是什么样的:(function poll(){ setTimeout(function(){ $.ajax({ url: ‘https://api.example.com/endpoint', success: function(data) { // Do something with data // … //Setup the next poll recursively poll(); }, dataType: ‘json’ }); }, 10000);})();这基本上是一个自执行函数,第一次立即运行时,它设置了 10 秒间隔,在对服务器的每个异步Ajax调用之后,回调将再次调用Ajax。其他技术涉及 Flash 或 XHR multipart request 和所谓的 htmlfiles 。但是,所有这些工作区都有一个相同的问题:它们都带有 HTTP 的开销,这使得它们不适合于低延迟应用程序。想想浏览器中的多人第一人称射击游戏或任何其他带有实时组件的在线游戏。WebSockets 的引入WebSocket 规范定义了在 web 浏览器和服务器之间建立“套接字”连接的 API。简单地说:客户机和服务器之间存在长久连接,双方可以随时开始发送数据。客户端通过 WebSocket 握手 过程建立 WebSocket 连接。这个过程从客户机向服务器发送一个常规 HTTP 请求开始,这个请求中包含一个升级头,它通知服务器客户机希望建立一个 WebSocket 连接。客户端建立 WebSocket 连接方式如下:// Create a new WebSocket with an encrypted connection.var socket = new WebSocket(‘ws://websocket.example.com’)WebSocket url使用 ws 方案。还有 wss 用于安全的 WebSocket 连接,相当于HTTPS。这个方案只是打开 websocket.example.com 的 WebSocket 连接的开始。下面是初始请求头的一个简化示例:如果服务器支持 WebSocke t协议,它将同意升级,并通过响应中的升级头进行通信。Node.js 的实现方式:建立连接后,服务器通过升级头部中内容时行响应:一旦建立连接,open 事件将在客户端 WebSocket 实例上被触发:var socket = new WebSocket(‘ws://websocket.example.com’);// Show a connected message when the WebSocket is opened.socket.onopen = function(event) { console.log(‘WebSocket is connected.’);};现在握手已经完成,初始 HTTP 连接被使用相同底层 TCP/IP 连接的 WebSocket 连接替换。此时,双方都可以开始发送数据。使用 WebSockets,可以传输任意数量的数据,而不会产生与传统 HTTP 请求相关的开销。数据作为消息通过 WebSocket 传输,每个消息由一个或多个帧组成,其中包含正在发送的数据(有效负载)。为了确保消息在到达客户端时能够正确地进行重构,每一帧都以负载的4-12字节数据为前缀, 使用这种基于帧的消息传递系统有助于减少传输的非有效负载数据量,从而大大的减少延迟。注意:值得注意的是,只有在接收到所有帧并重构了原始消息负载之后,客户机才会收到关于新消息的通知。WebSocket URLs之前简要提到过 WebSockets 引入了一个新的URL方案。实际上,他们引入了两个新的方案:ws:// 和wss://。url 具有特定方案的语法。WebSocket url 的特殊之处在于它们不支持锚点(#sample_anchor)。同样的规则适用于 WebSocket 风格的url和 HTTP 风格的 url。ws 是未加密的,默认端口为80,而 wss 需要TLS加密,默认端口为 443。帧协议更深入地了解帧协议,这是 RFC 为我们提供的:在RFC 指定的 WebSocket 版本中,每个包前面只有一个报头。然而,这是一个相当复杂的报头。以下是它的构建模块:FIN :1bit ,表示是消息的最后一帧,如果消息只有一帧那么第一帧也就是最后一帧,Firefox 在 32K 之后创建了第二个帧。RSV1,RSV2,RSV3:每个1bit,必须是0,除非扩展定义为非零。如果接受到的是非零值但是扩展没有定义,则需要关闭连接。Opcode:4bit,解释 Payload 数据,规定有以下不同的状态,如果是未知的,接收方必须马上关闭连接。状态如下:0x00: 附加数据帧0x01:文本数据帧 0x02:二进制数据帧 0x3-7:保留为之后非控制帧使用0x8:关闭连接帧0x9:ping0xA:pong0xB-F(保留为后面的控制帧使用) Mask:1bit,掩码,定义payload数据是否进行了掩码处理,如果是1表示进行了掩码处理。Masking-key:域的数据即是掩码密钥,用于解码PayloadData。客户端发出的数据帧需要进行掩码处理,所以此位是1。Payload_len:7位,7 + 16位,7+64位,payload数据的长度,如果是0-125,就是真实的payload长度,如果是126,那么接着后面的2个字节对应的16位无符号整数就是payload数据长度;如果是127,那么接着后面的8个字节对应的64位无符号整数就是payload数据的长度。Masking-key:0到4字节,如果MASK位设为1则有4个字节的掩码解密密钥,否则就没有。Payload data:任意长度数据。包含有扩展定义数据和应用数据,如果没有定义扩展则没有此项,仅含有应用数据。为什么 WebSocket 是基于帧而不是基于流?我不知道,就像你一样,我很想了解更多,所以如果你有想法,请随时在下面的回复中添加评论和资源。另外,关于这个主题的讨论可以在 HackerNews 上找到。帧数据如上所述,数据可以被分割成多个帧。 传输数据的第一帧有一个操作码,表示正在传输什么类型的数据。 这是必要的,因为 JavaScript 在开始规范时几乎不存在对二进制数据的支持。 0x01 表示 utf-8 编码的文本数据,0x02 是二进制数据。大多数人会发送 JSON ,在这种情况下,你可能要选择文本操作码。 当你发送二进制数据时,它将在浏览器特定的 Blob 中表示。通过 WebSocket 发送数据的API非常简单:var socket = new WebSocket(‘ws://websocket.example.com’);socket.onopen = function(event) { socket.send(‘Some message’); // Sends data to server.};当 WebSocket 接收数据时(在客户端),会触发一个消息事件。此事件包括一个名为data的属性,可用于访问消息的内容。// Handle messages sent by the server.socket.onmessage = function(event) { var message = event.data; console.log(message);};在Chrome开发工具:可以很容易地观察 WebSocket 连接中每个帧中的数据:消息分片有效载荷数据可以分成多个单独的帧。接收端应该对它们进行缓冲,直到设置好 fin 位。因此,可以将字符串“Hello World”发送到11个包中,每个包的长度为6(报头长度)+ 1字节。控件包不允许分片。但是,规范希望能够处理交错的控制帧。这是TCP包以任意顺序到达的情况。连接帧的逻辑大致如下:接收第一帧记住操作码将帧有效负载连接在一起,直到 fin 位被设置断言每个包的操作码是零分片目的是发送长度未知的消息。如果不分片发送,即一帧,就需要缓存整个消息,计算其长度,构建frame并发送;使用分片的话,可使用一个大小合适的buffer,用消息内容填充buffer,填满即发送出去。么是跳动检测?主要目的是保障客户端 websocket 与服务端连接状态,该程序有心跳检测及自动重连机制,当网络断开或者后端服务问题造成客户端websocket断开,程序会自动尝试重新连接直到再次连接成功。在使用原生websocket的时候,如果设备网络断开,不会触发任何函数,前端程序无法得知当前连接已经断开。这个时候如果调用 websocket.send 方法,浏览器就会发现消息发不出去,便会立刻或者一定短时间后(不同浏览器或者浏览器版本可能表现不同)触发 onclose 函数。后端 websocket 服务也可能出现异常,连接断开后前端也并没有收到通知,因此需要前端定时发送心跳消息 ping,后端收到 ping 类型的消息,立马返回 pong 消息,告知前端连接正常。如果一定时间没收到pong消息,就说明连接不正常,前端便会执行重连。为了解决以上两个问题,以前端作为主动方,定时发送 ping 消息,用于检测网络和前后端连接问题。一旦发现异常,前端持续执行重连逻辑,直到重连成功。错误处理以通过监听 error 事件来处理所有错误:var socket = new WebSocket(‘ws://websocket.example.com’);// Handle any error that occurs.socket.onerror = function(error) { console.log(‘WebSocket Error: ’ + error);};关闭连接要关闭连接,客户机或服务器都应该发送包含操作码0x8的数据的控制帧。当接收到这样一个帧时,另一个对等点发送一个关闭帧作为响应,然后第一个对等点关闭连接,关闭连接后接收到的任何其他数据都将被丢弃:// Close if the connection is open.if (socket.readyState === WebSocket.OPEN) { socket.close();}另外,为了在完成关闭之后执行其他清理,可以将事件侦听器附加到关闭事件:// Do necessary clean up.socket.onclose = function(event) { console.log(‘Disconnected from WebSocket.’);};服务器必须监听关闭事件以便在需要时处理它:connection.on(‘close’, function(reasonCode, description) { // The connection is getting closed.});WebSockets和HTTP/2 比较虽然HTTP/2提供了很多功能,但它并没有完全满足对现有推送/流技术的需求。关于 HTTP/2 的第一个重要的事情是它并不能替代所有的 HTTP 。verb、状态码和大部分头信息将保持与目前版本一致。HTTP/2 是意在提升数据在线路上传输的效率。比较HTTP/2和WebSocket,可以看到很多相似之处:正如我们在上面看到的,HTTP/2引入了 Server Push,它使服务器能够主动地将资源发送到客户机缓存。但是,它不允许将数据下推到客户机应用程序本身,服务器推送只由浏览器处理,不会在应用程序代码中弹出,这意味着应用程序没有API来获取这些事件的通知。这就是服务器发送事件(SSE)变得非常有用的地方。SSE 是一种机制,它允许服务器在建立客户机-服务器连接之后异步地将数据推送到客户机。然后,只要有新的“数据块”可用,服务器就可以决定发送数据。它可以看作是单向发布-订阅模式。它还提供了一个名为 EventSource API 的标准JavaScript,作为W3C HTML5标准的一部分,在大多数现代浏览器中实现。不支持 EventSource API 的浏览器可以轻松地使用 polyfilled 方案来解决。由于 SSE 基于 HTTP ,因此它与 HTTP/2 非常合适,可以结合使用以实现最佳效果:HTTP/2 处理基于多路复用流的高效传输层,SSE 将 API 提供给应用以启用数据推送。为了理解 Streams 和 Multiplexing 是什么,首先看一下`IETF定义:“stream”是在HTTP/2 连接中客户机和服务器之间交换的独立的、双向的帧序列。它的一个主要特征是,一个HTTP/2 连接可以包含多个并发打开的流,任何一个端点都可以从多个流中交错帧。SSE 是基于 HTTP 的,这说明在 HTTP/2 中,不仅可以将多个 SSE 流交织到单个 TCP 连接上,而且还可以通过多个 SSE 流(服务器到客户端的推送)和多个客户端请求(客户端到服务器)。因为有 HTTP/2 和 SSE 的存在,现在有一个纯粹的 HTTP 双向连接和一个简单的 API 就可以让应用程序代码注册到服务器推送服务上。在比较 SSE 和 WebSocket 时,缺乏双向能力往往被认为是一个主要的缺陷。有了 HTTP/2,不再有这种情况。这样就可以跳过 WebSocket ,而坚持使用基于 HTTP 的信号机制。如何选择WebSocket和HTTP/2?WebSockets 会在 HTTP/2 + SSE 的领域中生存下来,主要是因为它是一种已经被很好地应用的技术,并且在非常具体的使用情况下,它比 HTTP/2 更具优势,因为它已经被构建用于具有较少开销(如报头)的双向功能。假设建立一个大型多人在线游戏,需要来自连接两端的大量消息。在这种情况下,WebSockets 的性能会好很多。一般情况下,只要需要客户端和服务器之间的真正低延迟,接近实时的连接,就使用 WebSocket ,这可能需要重新考虑如何构建服务器端应用程序,以及将焦点转移到队列事件等技术上。使用的方案需要显示实时的市场消息,市场数据,聊天应用程序等,依靠 HTTP/2 + SSE 将为你提供高效的双向通信渠道,同时获得留在 HTTP 领域的各种好处:当考虑到与现有 Web 基础设施的兼容性时,WebSocket 通常会变成一个痛苦的源头,因为它将 HTTP 连接升级到完全不同于 HTTP 的协议。规模和安全性:Web 组件(防火墙,入侵检测,负载均衡)是以 HTTP 为基础构建,维护和配置的,这是大型/关键应用程序在弹性,安全性和可伸缩性方面更偏向的环境。原文:https://blog.sessionstack.com…编辑中可能存在的bug没法实时知道,事后为了解决这些bug,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具Fundebug。老铁福利:Redux+React+Express+Socket.io构建实时聊天应用教程你的点赞是我持续分享好东西的动力,欢迎点赞!一个笨笨的码农,我的世界只能终身学习!更多内容请关注公众号《大迁世界》! ...

December 19, 2018 · 2 min · jiezi

B站直播数据包分析连载(2018-12-11更新)

TODO: 这篇文章是我分析B站直播的数据包的过程,可能会有一些待补充的内容,如果有什么建议可以私信我或者跟评。感谢一下下面的各位做出的卓越贡献CREDITS: 冰块TiO2 - 提供样本数据(十个辣条呢!)炒鸡嗨客协管徐 - 参考文章:获取bilibili直播弹幕的WebSocket协议(这篇文章写的很全了www)我这次分析是通过移动端的h5网页进行的,比如我的直播间是4568796,然后打开的网页是http://live.bilibili.com/h5/4568796,如果被跳转到了一般网页,可以试一下改成iPhone的UA。主流程(点击前往)1.获取房间ID / 2.封包结构 / 3.初始化连接 / 4.心跳包 / 5.数据包获取房间ID大部分跟直播间ID是一样的,也就是URL路由后面跟着的那一串数字,比如我的就是4568796,通过API请求房间ID是一样的。{ “code”: 0, “msg”: “ok”, “message”: “ok”, “data”: { “uid”: 8759339, “room_id”: 4568796, “short_id”: 0, “attention”: 65, “online”: 15, “is_portrait”: false, “description”: “<p>主要直播FFXIV,渣渣水平,不要吐槽啊~</p>\n<p> 欢迎大家一起来讨论 一起来玩FF呀 </p>\n<p>偶尔也会直播一些PS4游玩过程</p>”, “live_status”: 0, “area_id”: 102, “parent_area_id”: 2, “parent_area_name”: “游戏”, “old_area_id”: 3, “background”: “https://static.hdslb.com/live-static/images/bg/6.jpg", “title”: “光之赤石 国际服咸鱼+日常”, “user_cover”: “https://i0.hdslb.com/bfs/live/ddc99aeab675f33b0f84afcd41ced570bd9c2d9c.jpg", “keyframe”: “https://i0.hdslb.com/bfs/live/4568796.jpg?12050340", “is_strict_room”: false, “live_time”: “0000-00-00 00:00:00”, “tags”: “stormblood,ff14,最终幻想,最终幻想14”, “is_anchor”: 1, “room_silent_type”: “”, “room_silent_level”: 0, “room_silent_second”: 0, “area_name”: “最终幻想14”, “pendants”: “”, “area_pendants”: “”, “hot_words”: [“2333333”, “喂,妖妖零吗”, “红红火火恍恍惚惚”, “FFFFFFFFFF”, “Yooooooo”, “啪啪啪啪啪”, “666666666”, “老司机带带我”, “你为什么这么熟练啊”, “gg”, “prprpr”, “向大佬低头”, “请大家注意弹幕礼仪哦!”, “还有这种操作!”, “囍”, “打call”, “你气不气?”, “队友呢?”], “hot_words_status”: 0, “verify”: “”, “new_pendants”: { “frame”: null, “badge”: null, “mobile_frame”: null, “mobile_badge”: null }, “up_session”: “”, “pk_status”: 0, “pk_id”: 0, “allow_change_area_time”: 0, “allow_upload_cover_time”: 0 }}当然也会有不一样的,比如URL后面的数字是419,但是通过API访问拿到的房间ID是3151254。貌似前者在B站的定义是short_id,后者是真实的房间ID,我们请求数据要用的是后面的那个ID。{ “code”: 0, “msg”: “ok”, “message”: “ok”, “data”: { “uid”: 37164813, “room_id”: 3151254, “short_id”: 419 /…/ }}JSON 部分感觉有用的字段FIELDEXAMPLEDESCRIPTIONcode0应该是正常返回代码0,如果出现错误,可能不是这个数值。data.uid8759339UP主用户ID,可以通过https://space.bilibili.com/8759339访问B站空间。data.room_id4568796这是我们要用来连接websocket的ID。data.short_id0如果不是0的话,可以拼接为进入直播间的URL。类似于靓号的存在么?data.attention65粉丝数data.online15旧版的在线人数,现在的人气值。data.descriptionHTML直播间下方的描述,是一段HTML。data.live_status01表示正在直播,0表示不在直播。data.descriptionHTML直播间下方的描述,是一段HTML。data.parent_area_namedata.area_name游戏最终幻想14直播的一级分类跟二级分类。data.live_time0000-00-00 00:00:00直播开始的时间,如果全零就是表示不在直播。data.backgroundURL一个URL指向直播间(桌面版)的背景。data.user_coverURL一个URL指向直播间的封面。data.keyframeURL一个URL指向直播间的直播截图。data.title光之赤石 国际服咸鱼+日常直播的标题。data.tagsstormblood,ff14,最终幻想,最终幻想14用半角空格分隔的标签列表。data.verifyUP主认证类型,如果是签约的会有bilibili直播签约主播。封包结构引用自:获取bilibili直播弹幕的WebSocket协议封包由头部和数据组成,字节序均为大端模式头部格式:偏移量长度含义04封包总大小42头部长度62协议版本,目前是184操作码(封包类型)124sequence,可以取常数1已知的操作码:操作码含义2客户端发送的心跳包3人气值,数据不是JSON,是4字节整数5命令,数据中[‘cmd’]表示具体命令7认证并加入房间8服务器发送的心跳包示意图:初始化连接H5播放器使用的弹幕连接是wss://broadcastlv.chat.bilibili.com/sub,桌面版的会使用CDN的连接,每次都不一样,没有测试过是否每次连接都可用。我使用桌面版的时候就出现过:wss://tx-live-dmcmt-sel-01.chat.bilibili.com/subwss://tx-tokyo-live-comet-01.chat.bilibili.com/subplayer.js中定义的默认服务器则是ws://broadcastlv.chat.bilibili.com:2244/sub连接上ws以后,第一件事情就是发认证包,截获的数据大致如下:# Client.1 | Binary x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xA xB xC xD xE xF===========================================================0000000x |00 00 00 65 00 10 00 01 00 00 00 07 00 00 00 010000001x |7B 22 75 69 64 22 3A 38 37 35 39 33 33 39 2C 220000002x |72 6F 6F 6D 69 64 22 3A 34 35 36 38 37 39 36 2C0000003x |22 70 72 6F 74 6F 76 65 72 22 3A 31 2C 22 70 6C0000004x |61 74 66 6F 72 6D 22 3A 22 77 65 62 22 2C 22 630000005x |6C 69 65 6E 74 76 65 72 22 3A 22 31 2E 35 2E 310000006x |30 2E 31 22 7D———————————————————–{“uid”:8759339,“roomid”:4568796,“protover”:1,“platform”:“web”,“clientver”:“1.5.10.1”}===========================================================MASK: D6-CD-12-0E这里的uid为登录用户的id,roomid就是上一步中我们得到的真实房间ID。服务器会返回一个数据包,如下:# Server.2 | Binary x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xA xB xC xD xE xF===========================================================0000000x |00 00 00 10 00 10 00 01 00 00 00 08 00 00 00 01———————————————————–操作码为08,服务器发来的心跳包,表示服务器在线。心跳包每隔30s需要向服务器发送心跳包保持在线状态。从浏览器中截获的心跳包如下:# Client.3 | Binary x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xA xB xC xD xE xF===========================================================0000000x |00 00 00 1F 00 10 00 01 00 00 00 02 00 00 00 010000001x |5B 6F 62 6A 65 63 74 20 4F 62 6A 65 63 74 5D———————————————————–[object Object]===========================================================MASK: 26-E1-EC-F2NOTES: 很奇怪为什么心跳包的主体是[object Object]文本,感觉似乎是调用了什么的toString,准备尝试一下使用无主体的心跳包试试,日后更新这个部分。UPDATE1: player.js中有一行var t = this.convertToArrayBuffer({}, r.a.WS_OP_HEARTBEAT);用于生成心跳包,貌似传入的是一个空的对象。服务器通常会返回一个带有人气值的数据包# Server.4 | Binary x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xA xB xC xD xE xF===========================================================0000000x |00 00 00 14 00 10 00 01 00 00 00 03 00 00 00 010000001x |00 00 00 01———————————————————操作码为3,人气值数据,主体部分是一个四字节的整数。数据包这个部分可能涉及到的内容比较多,也是比较核心的部分。大概包括以下部分:开始直播 / 结束直播 / 收到弹幕 / 收到礼物 / 欢迎进入 / 广播消息这些数据包的操作码都是恒定为5。很奇怪的是,这些数据包的版本位定义是0,sequence常数也是0。开始直播# Server.7 | Binary x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xA xB xC xD xE xF===========================================================0000000x |00 00 00 2F 00 10 00 00 00 00 00 05 00 00 00 000000001x |7B 22 63 6D 64 22 3A 22 4C 49 56 45 22 2C 22 720000002x |6F 6F 6D 69 64 22 3A 34 35 36 38 37 39 36 7D———————————————————{“cmd”:“LIVE”,“roomid”:4568796}=========================================================操作码为5,主体的cmd定义为LIVE,roomid表示对应直播间的id。结束直播# Server.23 | Binary x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xA xB xC xD xE xF===========================================================0000000x |00 00 00 36 00 10 00 00 00 00 00 05 00 00 00 000000001x |7B 22 63 6D 64 22 3A 22 50 52 45 50 41 52 49 4E0000002x |47 22 2C 22 72 6F 6F 6D 69 64 22 3A 22 34 35 360000003x |38 37 39 36 22 7D———————————————————{“cmd”:“PREPARING”,“roomid”:“4568796”}=========================================================操作码为5,主体的cmd定义为PREPARING,roomid表示对应直播间的id。收到弹幕# Server.19 | Binary x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xA xB xC xD xE xF===========================================================0000000x |00 00 00 C7 00 10 00 00 00 00 00 05 00 00 00 000000001x |7B 22 69 6E 66 6F 22 3A 5B 5B 30 2C 31 2C 32 350000002x |2C 31 36 37 37 37 32 31 35 2C 31 35 34 34 30 380000003x |31 37 37 31 2C 39 33 36 35 37 35 39 32 39 2C 300000004x |2C 22 38 32 63 61 61 34 31 39 22 2C 30 2C 30 5D0000005x |2C 22 E5 96 B5 22 2C 5B 32 37 33 32 32 34 35 360000006x |2C 22 E5 86 B0 E5 9D 97 54 69 4F 32 22 2C 30 2C0000007x |30 2C 30 2C 31 30 30 30 30 2C 31 2C 22 22 5D 2C0000008x |5B 5D 2C 5B 31 2C 30 2C 39 38 36 38 39 35 30 2C0000009x |22 3E 35 30 30 30 30 22 5D 2C 5B 5D 2C 30 2C 30000000Ax |2C 7B 22 75 6E 61 6D 65 5F 63 6F 6C 6F 72 22 3A000000Bx |22 22 7D 5D 2C 22 63 6D 64 22 3A 22 44 41 4E 4D000000Cx |55 5F 4D 53 47 22 7D———————————————————{“info”:[[0,1,25,16777215,1544081771,936575929,0,“82caa419”,0,0],“喵”,[27322456,“冰块TiO2”,0,0,0,10000,1,””],[],[1,0,9868950,">50000”],[],0,0,{“uname_color”:""}],“cmd”:“DANMU_MSG”}=========================================================cmd定义为DANMU_MSG,另一个字段为info,是一个很杂的数组,我们来分析一下他:info: [0]: [0,1,25,16777215,1544081771,936575929,0,“82caa419”,0,0] [14]: “喵” [2]: [27322456,“冰块TiO2”,0,0,0,10000,1,""] [15]: [] [16]: [1,0,9868950,">50000"] [17]: [] [18]: 0 [7]: 0 [8]: {“uname_color”:""}不难看出[1]是弹幕文本内容,[2]定义了一些用户基本信息。仔细观察可以看出[0]中的1544081771是一个Linux时间戳,转换成北京时间是December 6, 2018 3:36:11 PM GMT+08:00,这与弹幕送出来的时间是吻合的,所以[0]应该是一些弹幕元信息。UPDATE1: [0][19]应该是一个代表颜色的数值,[0][20]为rnd,似乎是播放器用于校验使用的数值??。再看看另两个数据包 [0]: [0, 1, 25, 16772431, 1544172160, 950512928, 0, “4b1a8da4”, 0, 0], [1]: “这头猪这辈子值了”, [2]: [17, “永幡”, 0, 1, 0, 10000, 1, “”], [3]: [12, “杆菌”, “杆菌无敌”, 246, 10512625, “”], [4]: [49, 0, 16746162, 3071], [5]: [“title-174-1”, “title-174-1”], [6]: 0, [7]: 0, [8]: {“uname_color”: “”} [0]: [0, 1, 25, 16777215, 1544172161, 522412774, 0, “b8415757”, 0, 0], [1]: “送猪肉的猪肉工”, [2]: [19, “七公”, 0, 0, 0, 10000, 1, “”], [3]: [12, “杆菌”, “杆菌无敌”, 246, 10512625, “”], [4]: [23, 0, 5805790, “>50000”], [5]: [“ice-dust”, “title-48-1”], [6]: 0, [7]: 0, [8]: {“uname_color”: “”}[3]的数值是粉丝勋章相关的讯息,[3][0]是粉丝勋章等级,[3][21]是粉丝勋章名称。[4]是用户等级相关讯息,[4][0]是用户等级,[4][22]是排名。[5]是活动头衔相关。[2]中[2][23],[2][24],[2][25]之中,三个标志位,应该分别是舰长、老爷、房管的标志吧?(猜测)IndexDescription0Array 弹幕元信息。[3]为颜色,[4]为弹幕发送时间(Unix时间戳)1String 弹幕内容2Array 发言人信息。[0]为用户ID,[1]为用户名称,[2]是舰长标志位,[3]是老爷标志位,[4]是房管标志位。(这三个标志位是猜测)3Array 粉丝勋章相关的讯息。[0]是粉丝勋章等级,[1]是粉丝勋章名称。4Array 用户等级相关讯息。[0]是用户等级,[3]是排名。5Array 活动头衔相关。6Number 未知。7Number 未知。8Object 未知。收到礼物# Server.5 | Binary x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xA xB xC xD xE xF===========================================================0000000x |00 00 02 DE 00 10 00 00 00 00 00 05 00 00 00 000000001x |7B 22 63 6D 64 22 3A 22 53 45 4E 44 5F 47 49 460000002x |54 22 2C 22 64 61 74 61 22 3A 7B 22 67 69 66 740000003x |4E 61 6D 65 22 3A 22 5C 75 38 66 61 33 5C 75 360000004x |37 36 31 22 2C 22 6E 75 6D 22 3A 31 2C 22 75 6E0000005x |61 6D 65 22 3A 22 5C 75 35 36 64 62 5C 75 37 630000006x |66 38 5C 75 34 65 34 33 5C 75 37 30 36 63 5C 750000007x |35 33 36 31 5C 75 35 34 63 37 5C 75 34 66 30 610000008x |22 2C 22 66 61 63 65 22 3A 22 68 74 74 70 3A 5C0000009x |2F 5C 2F 69 30 2E 68 64 73 6C 62 2E 63 6F 6D 5C000000Ax |2F 62 66 73 5C 2F 66 61 63 65 5C 2F 62 30 36 39000000Bx |34 31 34 63 34 34 33 38 65 32 66 61 36 66 64 34000000Cx |34 30 36 66 65 35 33 61 30 30 32 32 62 37 65 30000000Dx |63 38 61 62 2E 6A 70 67 22 2C 22 67 75 61 72 64000000Ex |5F 6C 65 76 65 6C 22 3A 30 2C 22 72 63 6F 73 74000000Fx |22 3A 31 36 39 30 32 33 34 32 36 2C 22 75 69 640000010x |22 3A 31 39 32 32 30 33 36 31 34 2C 22 74 6F 700000011x |5F 6C 69 73 74 22 3A 5B 5D 2C 22 74 69 6D 65 730000012x |74 61 6D 70 22 3A 31 35 34 34 31 37 32 31 34 330000013x |2C 22 67 69 66 74 49 64 22 3A 31 2C 22 67 69 660000014x |74 54 79 70 65 22 3A 30 2C 22 61 63 74 69 6F 6E0000015x |22 3A 22 5C 75 35 35 38 32 5C 75 39 38 64 66 220000016x |2C 22 73 75 70 65 72 22 3A 30 2C 22 73 75 70 650000017x |72 5F 67 69 66 74 5F 6E 75 6D 22 3A 30 2C 22 700000018x |72 69 63 65 22 3A 31 30 30 2C 22 72 6E 64 22 3A0000019x |22 31 35 34 34 31 37 32 31 32 36 22 2C 22 6E 65000001Ax |77 4D 65 64 61 6C 22 3A 30 2C 22 6E 65 77 54 69000001Bx |74 6C 65 22 3A 30 2C 22 6D 65 64 61 6C 22 3A 5B000001Cx |5D 2C 22 74 69 74 6C 65 22 3A 22 22 2C 22 62 65000001Dx |61 74 49 64 22 3A 22 30 22 2C 22 62 69 7A 5F 73000001Ex |6F 75 72 63 65 22 3A 22 6C 69 76 65 22 2C 22 6D000001Fx |65 74 61 64 61 74 61 22 3A 22 22 2C 22 72 65 6D0000020x |61 69 6E 22 3A 30 2C 22 67 6F 6C 64 22 3A 30 2C0000021x |22 73 69 6C 76 65 72 22 3A 30 2C 22 65 76 65 6E0000022x |74 53 63 6F 72 65 22 3A 30 2C 22 65 76 65 6E 740000023x |4E 75 6D 22 3A 30 2C 22 73 6D 61 6C 6C 74 76 5F0000024x |6D 73 67 22 3A 5B 5D 2C 22 73 70 65 63 69 61 6C0000025x |47 69 66 74 22 3A 6E 75 6C 6C 2C 22 6E 6F 74 690000026x |63 65 5F 6D 73 67 22 3A 5B 5D 2C 22 63 61 70 730000027x |75 6C 65 22 3A 6E 75 6C 6C 2C 22 61 64 64 46 6F0000028x |6C 6C 6F 77 22 3A 30 2C 22 65 66 66 65 63 74 5F0000029x |62 6C 6F 63 6B 22 3A 31 2C 22 63 6F 69 6E 5F 74000002Ax |79 70 65 22 3A 22 73 69 6C 76 65 72 22 2C 22 74000002Bx |6F 74 61 6C 5F 63 6F 69 6E 22 3A 31 30 30 2C 22000002Cx |74 61 67 5F 69 6D 61 67 65 22 3A 22 22 2C 22 75000002Dx |73 65 72 5F 63 6F 75 6E 74 22 3A 30 7D 7D———————————————————{“cmd”:“SEND_GIFT”,“data”:{“giftName”:"\u8fa3\u6761",“num”:1,“uname”:"\u56db\u7cf8\u4e43\u706c\u5361\u54c7\u4f0a",“face”:“http://i0.hdslb.com/bfs/face/b069414c4438e2fa6fd4406fe53a0022b7e0c8ab.jpg”,“guard_level”:0,“rcost”:169023426,“uid”:192203614,“top_list”:[],“timestamp”:1544172143,“giftId”:1,“giftType”:0,“action”:"\u5582\u98df",“super”:0,“super_gift_num”:0,“price”:100,“rnd”:“1544172126”,“newMedal”:0,“newTitle”:0,“medal”:[],“title”:"",“beatId”:“0”,“biz_source”:“live”,“metadata”:"",“remain”:0,“gold”:0,“silver”:0,“eventScore”:0,“eventNum”:0,“smalltv_msg”:[],“specialGift”:null,“notice_msg”:[],“capsule”:null,“addFollow”:0,“effect_block”:1,“coin_type”:“silver”,“total_coin”:100,“tag_image”:"",“user_count”:0}}=========================================================这次的json格式要清晰很多了,我们格式化一下来看。{ “cmd”: “SEND_GIFT”, “data”: { “giftName”: “\u8fa3\u6761”, “num”: 10, “uname”: “\u51b0\u5757TiO2”, “face”: “http://i0.hdslb.com/bfs/face/880b7078006c262009674a77e3ca9a23c10cfd21.jpg”, “guard_level”: 0, “rcost”: 29423, “uid”: 27322456, “top_list”: [], “timestamp”: 1544081779, “giftId”: 1, “giftType”: 0, “action”: “\u5582\u98df”, “super”: 0, “super_gift_num”: 0, “price”: 100, “rnd”: “1799741030”, “newMedal”: 0, “newTitle”: 0, “medal”: [], “title”: “”, “beatId”: “”, “biz_source”: “live”, “metadata”: “”, “remain”: 0, “gold”: 0, “silver”: 10910, “eventScore”: 0, “eventNum”: 0, “smalltv_msg”: [], “specialGift”: null, “notice_msg”: [], “capsule”: null, “addFollow”: 0, “effect_block”: 1, “coin_type”: “silver”, “total_coin”: 1000, “tag_image”: “”, “user_count”: 0 }}JSON 部分感觉有用的字段FIELDEXAMPLEDESCRIPTIONdata.giftName\u8fa3\u6761将数据unescape一下,就是汉字辣条,明显是礼物名称。data.num10数量。data.faceURL用户的头像。data.timestampe1544081779送礼时间,Unix时间戳。data.price100价值,好像是单价。data.golddata.silver010910好像是用户持有的金瓜子和银瓜子数量,不像是礼物价值。data.coin_typedata.total_coinsilver1000礼物总价值。欢迎进入# Server.19 | Binary x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xA xB xC xD xE xF===========================================================0000000x |00 00 00 65 00 10 00 00 00 00 00 05 00 00 00 000000001x |7B 22 63 6D 64 22 3A 22 57 45 4C 43 4F 4D 45 5F0000002x |47 55 41 52 44 22 2C 22 64 61 74 61 22 3A 7B 220000003x |75 69 64 22 3A 32 30 35 39 38 32 33 38 2C 22 750000004x |73 65 72 6E 61 6D 65 22 3A 22 E9 99 8C 2D 2D E80000005x |90 BD 22 2C 22 67 75 61 72 64 5F 6C 65 76 65 6C0000006x |22 3A 33 7D 7D 00 00 01 AA 00 10 00 00 00 00 000000007x |05 00 00 00 00 7B 22 63 6D 64 22 3A 22 45 4E 540000008x |52 59 5F 45 46 46 45 43 54 22 2C 22 64 61 74 610000009x |22 3A 7B 22 69 64 22 3A 34 2C 22 75 69 64 22 3A000000Ax |32 30 35 39 38 32 33 38 2C 22 74 61 72 67 65 74000000Bx |5F 69 64 22 3A 31 38 33 34 33 30 2C 22 73 68 6F000000Cx |77 5F 61 76 61 74 61 72 22 3A 31 2C 22 63 6F 70000000Dx |79 5F 77 72 69 74 69 6E 67 22 3A 22 E6 AC A2 E8000000Ex |BF 8E E8 88 B0 E9 95 BF 20 3C 25 E9 99 8C 2D 2D000000Fx |E8 90 BD 25 3E 20 E8 BF 9B E5 85 A5 E7 9B B4 E60000010x |92 AD E9 97 B4 22 2C 22 68 69 67 68 6C 69 67 680000011x |74 5F 63 6F 6C 6F 72 22 3A 22 23 45 36 46 46 300000012x |30 22 2C 22 62 61 73 65 6D 61 70 5F 75 72 6C 220000013x |3A 22 68 74 74 70 3A 5C 2F 5C 2F 69 30 2E 68 640000014x |73 6C 62 2E 63 6F 6D 5C 2F 62 66 73 5C 2F 6C 690000015x |76 65 5C 2F 31 66 61 33 63 63 30 36 32 35 38 650000016x |31 36 63 30 61 63 34 63 32 30 39 65 32 36 34 350000017x |66 64 61 33 63 32 37 39 31 38 39 34 2E 70 6E 670000018x |22 2C 22 65 66 66 65 63 74 69 76 65 5F 74 69 6D0000019x |65 22 3A 32 2C 22 70 72 69 6F 72 69 74 79 22 3A000001Ax |37 30 2C 22 70 72 69 76 69 6C 65 67 65 5F 74 79000001Bx |70 65 22 3A 33 2C 22 66 61 63 65 22 3A 22 68 74000001Cx |74 70 3A 5C 2F 5C 2F 69 31 2E 68 64 73 6C 62 2E000001Dx |63 6F 6D 5C 2F 62 66 73 5C 2F 66 61 63 65 5C 2F000001Ex |37 38 39 36 32 32 38 64 31 31 65 35 63 31 37 36000001Fx |34 63 61 36 37 34 62 66 64 39 36 33 30 61 37 630000020x |30 31 35 62 37 66 66 39 2E 6A 70 67 22 7D 7D 000000021x |00 00 66 00 10 00 00 00 00 00 05 00 00 00 00 7B0000022x |22 63 6D 64 22 3A 22 57 45 4C 43 4F 4D 45 22 2C0000023x |22 64 61 74 61 22 3A 7B 22 75 69 64 22 3A 33 390000024x |32 31 36 32 34 35 2C 22 75 6E 61 6D 65 22 3A 220000025x |E6 BA 90 E7 A8 9A E7 82 8E 22 2C 22 69 73 5F 610000026x |64 6D 69 6E 22 3A 66 61 6C 73 65 2C 22 76 69 700000027x |22 3A 31 7D 7D 00 00 00 66 00 10 00 00 00 00 000000028x |05 00 00 00 00 7B 22 63 6D 64 22 3A 22 57 45 4C0000029x |43 4F 4D 45 22 2C 22 64 61 74 61 22 3A 7B 22 75000002Ax |69 64 22 3A 32 30 35 39 38 32 33 38 2C 22 75 6E000002Bx |61 6D 65 22 3A 22 E9 99 8C 2D 2D E8 90 BD 22 2C000002Cx |22 69 73 5F 61 64 6D 69 6E 22 3A 66 61 6C 73 65000002Dx |2C 22 73 76 69 70 22 3A 31 7D 7D———————————————————{“cmd”:“WELCOME_GUARD”,“data”:{“uid”:28,“username”:“陌落”,“guard_level”:3}}{“cmd”:“ENTRY_EFFECT”,“data”:{“id”:4,“uid”:28,“target_id”:183430,“show_avatar”:1,“copy_writing”:“欢迎舰长 <%陌落%> 进入直播间”,“highlight_color”:"#E6FF00",“basemap_url”:“http://i0.hdslb.com/bfs/live/1fa3cc06258e16c0ac4c209e2645fda3c2791894.png”,“effective_time”:2,“priority”:70,“privilege_type”:3,“face”:“http://i1.hdslb.com/bfs/face/7896228d11e5c1764ca674bfd9630a7c015b7ff9.jpg”}}{“cmd”:“WELCOME”,“data”:{“uid”:35,“uname”:“源炎”,“is_admin”:false,“vip”:1}}{“cmd”:“WELCOME”,“data”:{“uid”:28,“uname”:“陌落”,“is_admin”:false,“svip”:1}}四个包黏在一起 ( —_— |||。可以看到,舰长的消息是WELCOME_GUARD而且会有一个ENTRY_EFFECT消息,老爷进入只有WELCOME消息。WELCOME_GUARD消息中,data.uid为用户ID,data.username为用户名称,data.guard_level表示舰长等级。welcome消息中,data.uid为用户ID,data.uname为用户名称,(乃们命名不能统一一点吗……)is_admin表示是否是房管,vip为1的时候表示是老爷,svip为1的时候表示是年费老爷。(待续。。。 ...

December 11, 2018 · 12 min · jiezi

深入探索WebSockets

WebSockets简介在2008年中期,开发人员Michael Carter和Ian Hickson特别敏锐地感受到Comet在实施任何真正强大的东西时所带来的痛苦和局限。 通过在IRC和W3C邮件列表上的合作,他们制定了一项计划,在网络上引入现代实时双向通信的新标准,因此创造了“WebSocket”这个名称。这个想法进入了W3C HTML草案标准,不久之后,Michael Carter写了一篇文章,将Comet社区介绍给WebSockets。 2010年,谷歌Chrome 4是第一个提供对WebSockets全面支持的浏览器,其他浏览器供应商也在接下来的几年中采用了这种方式。 2011年,RFC 6455 - WebSocket协议 - 发布到IETF网站。今天,所有主流浏览器都完全支持WebSockets,甚至包括Internet Explorer 10和11.此外,自2013年以来,iOS和Android上的浏览器都支持WebSockets,这意味着总而言之,WebSocket支持的现代环境非常健康。 大多数“物联网”或IoT也在某些版本的Android上运行,因此从2018年开始,其他类型设备上的WebSocket支持也相当普遍。那么究竟什么是WebSockets呢?简而言之,WebSockets是一个构建在设备TCP / IP堆栈之上的传输层。 目的是为Web应用程序开发人员提供本质上尽可能接近原始的TCP通信层,同时添加一些抽象来消除某些差异。 它们还满足了这样一个事实,即网络具有额外的安全考虑因素,必须将其考虑在内以保护消费者和服务提供者。您可能听说WebSockets同时被称为“传输”和“协议”。前者更准确,因为虽然它们是一种协议,因为必须遵守一套严格的规则来建立通信并包含所传输的数据,但该标准并没有对如何构建实际数据有效载荷采取任何规定。事实上,规范的一部分包括客户端和服务器就一个协议达成一致的规范,传输的数据将通过该协议进行格式化和解释。该标准将这些称为“子协议”,以避免术语中含糊不清的问题。子协议的示例是JSON,XML,MQTT,WAMP等。这些不仅可以确保数据的结构方式,还可以确保通信必须开始,继续并最终终止的方式。只要双方都了解协议所包含的内容,任何事情都会发生。 WebSocket仅提供传输层,通过该传输层可以实现该消息传递过程,这就是为什么大多数常见的子协议不是基于WebSocket的通信所独有的。关于身份验证和授权的快速说明把WebSockets看作是一个建立在TCP / IP之上的薄层,超出基本握手和消息框架规范的任何东西都需要在每个应用程序或每个库的基础上处理。 引用RFC:此协议未规定服务器在WebSocket握手期间可以对客户端进行身份验证的任何特定方式。 WebSocket服务器可以使用通用HTTP服务器可用的任何客户端身份验证机制,例如cookie,HTTP身份验证或TLS身份验证。简而言之,您仍然可以使用的基于HTTP的身份验证方法,或使用MQTT或WAMP等子协议,这两种子协议都提供身份验证和授权方法。用HTTP做连接定义WebSocket标准时的一个早期考虑因素是确保它“与网络”很好地协同工作。 这意味着认识到Web通常使用URL而不是IP地址和端口号进行寻址,并且WebSocket连接应该能够使用Web请求相同的基于HTTP的任何其他类型进行初始握手。这是一个简单的HTTP GET请求中发生的事情。假设在http://www.example.com/index….。 如果不深入到HTTP协议本身,就足以知道请求必须从所谓的Request-Line开始,然后是一系列键值对标题行,每一行都告诉服务器一些关于什么的信息。 期望在随后的请求有效负载中跟随头数据,以及它可以从客户端得到的关于它能够理解的响应类型的内容。请求中的第一个令牌是HTTP方法,它告诉服务器客户端针对引用的URL尝试的操作类型。 当客户端仅请求服务器向其提供由指定URL引用的资源的副本时,使用GET方法。根据HTTP RFC格式化的请求标头的系统示例如下所示:GET /index.html HTTP/1.1Host: www.example.com收到请求标头后,服务器然后格式化一个以状态行开头的响应标头,然后是一组键值标头对,为客户端提供来自服务器的补充信息,关于服务器的请求。 响应。 “状态行”告诉客户端HTTP状态代码(如果没有问题,通常为200),并提供解释状态代码的简短“原因”文本描述。 接下来出现键值标题对,然后是请求的实际数据(除非状态代码表明由于某种原因无法满足请求)。HTTP/1.1 200 OKDate: Wed, 1 Aug 2018 16:03:29 GMTContent-Length: 291Content-Type: text/html(additional headers…) (response payload continues here…)那么你可能会问,这与WebSockets有什么关系呢?抛弃HTTP以获得更合适的东西在发出HTTP请求并接收响应时,涉及的实际双向网络通信通过活动的TCP / IP套接字进行。浏览器中请求的Web URL通过全局DNS系统映射到IP地址,HTTP请求的默认端口为80.这意味着虽然Web URL已输入浏览器,但实际通信是通过TCP进行的/ IP,使用类似于123.11.85.9:80的IP地址和端口组合。我们现在知道,WebSockets也建立在TCP堆栈之上,这意味着我们所需要的只是客户端和服务器共同同意保持套接字连接打开并重新利用它以进行持续通信的方式。如果他们这样做,就可以发送和接收的二进制数据。要开始重新调整TCP套接字以进行WebSocket通信,客户端可以包含专门为此类用例发明的标准请求标头:GET /index.html HTTP/1.1Host: www.example.comConnection: UpgradeUpgrade: websocketConnection标头告诉服务器客户端希望协商套接字使用方式的更改。 随附的值Upgrade表示当前通过TCP使用的传输协议应该更改。 现在服务器知道客户端想要通过活动TCP套接字升级当前正在使用的协议,服务器知道要查找相应的升级头,这将告诉它客户端想要使用哪个传输协议的剩余生命周期 连接。 一旦服务器将websocket视为Upgrade标头的值,它就知道WebSocket握手过程已经开始。请注意,如果您想了解本文中介绍的更多详细信息,请参阅RFC 6455中概述了握手过程(以及其他所有内容)。避免有趣的麻烦除了上面描述的内容之外,WebSocket握手的第一部分涉及证明这实际上是一个正确的WebSocket升级握手,并且该过程不是通过客户端或可能通过某种中间欺骗来规避或模拟的。 位于中间的代理服务器。启动升级到WebSocket连接时,客户端必须包含Sec-WebSocket-Key标头,该标头具有该客户端唯一的值。 这是一个例子:Sec-WebSocket-Key: BOq0IliaPZlnbMHEBYtdjmKIL38=如果使用现代浏览器中提供的WebSocket类,上面的内容将自动处理。 您只需在服务器端查找它并生成响应。响应时,服务器必须将特殊GUID值258EAFA5-E914-47DA-95CA-C5AB0DC85B11附加到密钥,生成结果字符串的SHA-1哈希值,然后将其包含为Sec的base-64编码值。 它包含在响应中的WebSocket-Accept标头:Sec-WebSocket-Accept: 5fXT1W3UfPusBQv/h6c4hnwTJzk=在Node.js WebSocket服务器中,我们可以编写一个函数来生成这个值,如下所示:const crypto = require(‘crypto’); function generateAcceptValue (acceptKey) { return crypto .createHash(‘sha1’) .update(acceptKey + ‘258EAFA5-E914-47DA-95CA-C5AB0DC85B11’, ‘binary’) .digest(‘base64’);}然后我们只需要调用这个函数,传递Sec-WebSocket-Key头的值作为参数,并在发送响应时将函数返回值设置为Sec-WebSocket-Accept头的值。要完成握手,请将适当的HTTP响应头写入客户端套接字。 一个简单的响应看起来像这样:HTTP/1.1 101 Web Socket Protocol HandshakeUpgrade: WebSocketConnection: UpgradeSec-WebSocket-Accept: m9raz0Lr21hfqAitCxWigVwhppA=到目前为止,我们还没有完成握手 - 还有很多事情要考虑。子协议 - 统一语言客户端和服务器通常需要在给定消息内以及从一个消息到下一个消息的一段时间内,就它们如何格式化,解释和组织数据本身的兼容策略达成一致。 这就是子协议(前面提到过)的用武之地。如果客户端知道它可以处理一个或多个特定的应用程序级协议(例如WAMP,MQTT等),它可以包含它理解的协议列表。 发出初始HTTP请求。 如果它这样做,则服务器需要选择其中一个协议并将其包含在响应头中,否则将使握手失败并终止连接。子协议请求标头示例:Sec-WebSocket-Protocol: mqtt, wamp服务器在响应中发出的示例倒数标题:Sec-WebSocket-Protocol: wamp请注意,服务器必须从客户端提供的列表中精确选择一种协议。选择多个将意味着服务器无法可靠或一致地解释后续WebSocket消息中的数据。例如,如果服务器选择了json-ld和json-schema。两者都是基于JSON标准构建的数据格式,并且会有许多边缘情况,其中一个可能被解释为另一个,从而在处理数据时导致意外错误。虽然不可否认本身不是消息传递协议,但该示例仍然适用。当客户端和服务器都实现为从一开始就使用通用消息传递协议时,可以在初始请求中省略Sec-WebSocket-Protocol标头,在这种情况下服务器可以忽略此步骤。在实现通用服务,基础结构和工具时,子协议协商是最有用的,在这些服务,基础结构和工具中,一旦建立了WebSocket连接,就无法保证客户端和服务器都能相互理解。通用协议的标准化名称应在IANA注册中心注册,用于WebSocket子协议名称,在本文撰写时,已经注册了36个名称,包括soap,xmpp,wamp,mqtt等。尽管注册表是将子协议名称映射到其解释的规范来源,但唯一严格的要求是客户端和服务器就其相互选择的子协议实际意味着什么达成一致,无论它是否出现在IANA注册表中。请注意,如果客户端请求使用子协议但未提供服务器可以支持的任何内容,则服务器必须发送失败响应并关闭连接。WebSocket扩展还有一个标题用于定义数据有效负载编码和成帧方式的扩展,但在本文时,只存在一种标准化扩展类型,它提供了一种WebSocket - 等同于消息中的gzip压缩。 扩展可能发挥作用的另一个例子是多路复用 - 使用单个套接字来交错多个并发通信流。WebSocket扩展是一个有点高级的主题,并且超出了本文的范围。 现在,它足以知道它们是什么,以及它们如何适应图片。客户端 - 在浏览器中使用WebSocketsWebSocket API在WHATWG HTML Living Standard中定义,实际上非常简单易用。 构造WebSocket需要一行代码:const ws = new WebSocket(‘ws://example.org’);注意使用ws,你通常有http方案。 您也可以选择使用wss,通常使用https。 这些协议与WebSocket规范一起引入,旨在表示HTTP连接,其中包括升级连接以使用WebSockets的请求。创建WebSocket对象本身并没有做很多事情。 连接是异步建立的,因此您需要在发送任何消息之前侦听握手的完成,并且还包括从服务器接收的消息的侦听器:ws.addEventListener(‘open’, () => { // Send a message to the WebSocket server ws.send(‘Hello!’);}); ws.addEventListener(‘message’, event => { // The event object is a typical DOM event object, and the message data sent // by the server is stored in the data property console.log(‘Received:’, event.data);});还有错误和关闭事件。 连接终止时WebSockets不会自动恢复 - 这是您需要自己实现的,并且是存在许多客户端库的原因之一。 虽然WebSocket类简单易用,但它实际上只是一个基本的构建块。 必须单独实现对不同子协议或消息传递通道等附加功能的支持。生成和解析WebSocket消息帧一旦将握手响应发送到客户端,客户端和服务器就可以使用他们选择的子协议(如果有的话)开始通信。 WebSocket消息在名为“frames”的包中传递,这些包以消息头开头,并以“payload”结尾 - 此帧的消息数据。 大型消息可能会将数据分成几帧,在这种情况下,您需要跟踪到目前为止收到的内容,并在数据全部到达后将数据分组。翻译的很乱,但愿对你有点帮助 ...

December 6, 2018 · 1 min · jiezi

纯静态HTML 与 C# Server 进行WebSocket 连接

TODO: 这篇文章只是写了一个DEMO,告诉你如何使用C#构建一个WebSocket服务器,以便HTML网页可以通过WebSocket与之进行交互。将会使用到的 Package: websocket-sharp Newtonsoft.JSON这个DEMO主要完成的工作是:HTML 连接 WebSocket 并传送一个Json,Json包含两个数字a和b。服务器监听 WebSocket 并解析Json里面的两个数字,将两个数字加起来的和作为结果以Json的形式传送给HTML。HTML 得到返回以后更新显示。10秒之后,服务器主动向浏览器再发送一次消息。准备姿势新建工程首先需要准备两个工程:一个是Web项目,可以是任何Web项目,因为我们只用到HTML。HTML单文件也是没有问题的。这里我用的是vscode live server。另一个是C#命令行项目,当然也可以不是命令行,只是觉得命令行比较方便,DEMO也不需要窗体,如果你需要窗体可以使用WPF或者WinForms。必要依赖在C#项目中,我们需要安装Nuget包:WebSocketSharp (由于这个Nuget包在写文的时候还是rc,所以需要勾选包括抢鲜版才会搜索出来哦)和 Newtonsoft.JSON服务器代码首先我们需要新建一个类,作为一个app,去处理传送来的消息。using Newtonsoft.Json;using Newtonsoft.Json.Linq;using System;using System.Collections.Generic;using System.Linq;using System.Text;using System.Threading.Tasks;using WebSocketSharp;using WebSocketSharp.Server;namespace WebSocketDemo{ class Add : WebSocketBehavior { protected override void OnOpen() { Console.WriteLine(“Connection Open”); base.OnOpen(); } protected override void OnMessage(MessageEventArgs e) { var data = e.Data; if (TestJson(data)) { var param = JToken.Parse(data); if (param[“a”] != null && param[“b”] != null) { var a = param[“a”].ToObject<int>(); var b = param[“b”].ToObject<int>(); Send(JsonConvert.SerializeObject(new { code = 200, msg = “result is " + (a + b) })); Task.Factory.StartNew(() => { Task.Delay(10000).Wait(); Send(JsonConvert.SerializeObject(new { code = 200, msg = “I just to tell you, the connection is different from http, i still alive and could send message to you.” })); }); } } else { Send(JsonConvert.SerializeObject(new { code = 400, msg = “request is not a json string.” })); } } protected override void OnClose(CloseEventArgs e) { Console.WriteLine(“Connection Closed”); base.OnClose(e); } protected override void OnError(ErrorEventArgs e) { Console.WriteLine(“Error: " + e.Message); base.OnError(e); } private static bool TestJson(string json) { try { JToken.Parse(json); return true; } catch (JsonReaderException ex) { Console.WriteLine(ex); return false; } } }}上面这一段代码中,重点在于OnMessage方法,这个方法就是处理消息的主要流程。在Main函数中,我们加入下面的代码。6690是这次Demo使用的端口号,第二行AddWebSocketService添加了一行路由,使得连接到ws://localhost:6690/add可以导向我们预定义好的App类中的处理逻辑。using System;using WebSocketSharp.Server;namespace WebSocketDemo{ class Program { static void Main(string[] args) { var wssv = new WebSocketServer(6690); wssv.AddWebSocketService<Add>("/add”); wssv.Start(); Console.WriteLine(“Server starting, press any key to terminate the server.”); Console.ReadKey(true); wssv.Stop(); } }}客户端代码<!DOCTYPE html><html> <head> <meta charset=“utf-8” /> <meta http-equiv=“X-UA-Compatible” content=“IE=edge” /> <title>WebSocket DEMO</title> <meta name=“viewport” content=“width=device-width, initial-scale=1” /> <style> ul, li { padding: 0; margin: 0; list-style: none; } </style> </head> <body> <div> a:<input type=“text” id=“inpA” /> b:<input type=“text” id=“inpB” /> <button type=“button” id=“btnSub”>submit</button> </div> <ul id=“outCnt”></ul> <script> let wsc; var echo = function(text) { var echoone = function(text) { var dom = document.createElement(“li”); var t = document.createTextNode(text); dom.appendChild(t); var cnt = document.getElementById(“outCnt”); cnt.appendChild(dom); }; if (Array.isArray(text)) { text.map(function(t) { echoone(t); }); } else { echoone(text); } }; (function() { if (“WebSocket” in window) { // init the websocket client wsc = new WebSocket(“ws://localhost:6690/add”); wsc.onopen = function() { echo(“connected”); }; wsc.onclose = function() { echo(“closed”); }; wsc.onmessage = function(e) { var data = JSON.parse(e.data); echo(data.msg || e.data); console.log(data.msg || e.data); }; // define click event for submit button document.getElementById(“btnSub”).addEventListener(‘click’, function() { var a = parseInt(document.getElementById(“inpA”).value); var b = parseInt(document.getElementById(“inpB”).value); if (wsc.readyState == 1) { wsc.send(JSON.stringify({ a: a, b: b })); } else { echo(“service is not available”); } }); } })(); </script> </body></html>当创建WebSocket对象的时候,会自动进行连接,这个对象可以用onopen,onclose,onmessage分别处理事件。主要通讯的流程也是在onmessage中进行处理。 ...

November 28, 2018 · 2 min · jiezi

socket踩坑实录

socket简述socket(双工协议)网络中的两个程序,通过一个双向的连接来实现数据的交换,我们把连接的一端称为socketsocket特性自带连接保持可以实现双向通信socket分类基于TCP的socket基于UDP的socket基于RawIP的socket基于链路层的socket文章持续更新中~~~~

November 25, 2018 · 1 min · jiezi

基于websocket的简单广播系统

在年初的时候,我们有点儿小迷茫,于是也跟风去做了一些轻娱乐类的小游戏。那时为了实战对战,想到需要一个实时性很强的技术实现,于是我去实现了一个websocket server,没想到后来这些小程序没有成,但是我们的这个web socket server 演化得无处不在。下面介绍一下这个技术实现。看理论肯定会有点拗口是不是,我们直接上代码就得了。我们现在假设有这么一个用户付款的逻辑,在写用户付款事件时,我们事先并不知道以后还需要加什么逻辑,于是我们先把这个行为广播出去。以下是伪代码: req := httplib.Post(“https://ws.app.12zan.net/eventcast/user/5905e89db43fec42e3055df05ff72afe") text, er := zanjson.Encode(order) if er != nil { log.Println(ev) return } req.Param(“data”, string(text)) resp,_ = req.Response()好了,现在,每当有用户付款时,这个用户系统都会往/eventcast/user/5905e89db43fec42e3055df05ff72afe这个频道广播一条消息。但是很遗憾,目前没有客户端订阅这类消息,所有的消息都被丢弃了。有一天,我们英明神武的老板决定要加一个通知,每当有一个新的用户付款时,都给公司的同胞们发一个邮件通知一下,我们获得了新的付费用户,好让大家小开心一把,尤其是第一个试用客户付费的时候,我们肯定都要开心地跳起来。这时我们如果去改线上运行好的付款系统,还是有点儿风险的,一旦有修改,我们就得走一下测试流程,不然万一有问题不是影响公司发财了吗。没关系,我们之前不是已经把付款事件广播出来了吗,我们现在用起来。写这么一段js,线上运行起来,就好了。const webSocket = require(‘ws’);let ws = new webSocket(“wss://ws.app.12zan.net/eventcast/user/5905e89db43fec42e3055df05ff72afe”);ws.on(‘open’, function open() { console.log(“connected”);});ws.on(‘message’, function incoming(data) { let user = JSON.parse(data); Mail.send(“一个叫”+user.name+“的好心人支付了”+user.amount+“元,让主赞美他!”);});好了,现在一旦有人付款,我们全公司都能收到一个邮件,及时得到这一好消息了。让我们小小地庆祝一下吧。接下来又过了几天,我们想改进一下体验,用户一旦付款成功,就发送一条短信,告知用户他的有效期和我们的24小时客服电话;只需要这么一段代码部署起来运行就好了, 之前的任何代码都不用动:const webSocket = require(‘ws’);let ws = new webSocket(“wss://ws.app.12zan.net/eventcast/user/5905e89db43fec42e3055df05ff72afe”);ws.on(‘open’, function open() { console.log(“connected”);});ws.on(‘message’, function incoming(data) { let user = JSON.parse(data); let expiresAt = (zan.Date.now().add("+365 day”).format(“YYYY-mm-dd”)); SMS.send(user.Mobile,“尊敬的”+user.name+",您成功购买了十二赞旗舰版,有效期至"+expiresAt+",请登陆:https://www.12zan.cn 查看,如有任何疑问,欢迎致电4006681102");});发送通知邮件和发送告知短信,都基于用户付款动作,但是发邮件和发短信的代码完全隔离,相互之间出完全不知道对方的存在。是不是很赞?那我们接下来梳理一下逻辑。概念及主要逻辑也许我们来不及去翻看websocket的定义,但是我们可以简单地理解,Websocket是对HTTP协议的一个扩展升级,在发起连接时,HTTP部分都是有效的,只是连接成功以后,服务端和客户端的连接不断,双方可以双向数据传输,且服务端可以主动向客户端推送数据。我们看一次Websocket发起连接的过程(来自维基百科):客户端向服务端发起连接:GET / HTTP/1.1Upgrade: websocketConnection: UpgradeHost: example.comOrigin: http://example.comSec-WebSocket-Key: sN9cRrP/n9NdMgdcy2VJFQ==Sec-WebSocket-Version: 13服务端的返回:HTTP/1.1 101 Switching ProtocolsUpgrade: websocketConnection: UpgradeSec-WebSocket-Accept: fFBooB7FAkLlXgRSz0BT3v4hq5s=Sec-WebSocket-Location: ws://example.com/在HTTP协议中常见的字段,如Cookies,Host等,依然有效。但是具体到我们的应用上,十二赞的这个websocket server实现了两个小目标【多遗憾了,并没有赚到两个亿】:我们实现的是一个广播系统,一个广播系统意味着一个地方去发送数据,n多个接受端来接受数据。要支持非常多的客户端同时连上数据来实时接受数据。我们最终的server端的实现,全内存实现,没有用redis或是MySQL类似的数据库,就是为了实现超多客户端的支持。我们希望采用最简单、最通用的文案,并且,非常高效,支持非常多的客户端同时连接,我们认为http协议更简单,所以在发送的时候,我们是走http协议来发送数据的。并且,没有任何安全上的设计,如果数据很重要,请自行加密之后发送。当然我们也有一些遗憾:允许数据丢失。有得必有失,我们允许一个比例的信息丢失。产生数据丢失时,不影响主逻辑。就像刚才的例子,发送邮件通知我们有新付款的这个事件没有触发并没有关系,我们到下午才发现有新用户付款,这时再去开香槟也不迟:(。容忍时序错乱。像刚才的例子,有新用户付款时,是先告诉我们全体同事有新付款,还是先给用户发送一条短信,并不那么重要。好了,回到我们的系统,我们给一点点总结。我们定义,每个websocket的入口,都是一个URL;去掉协议和HOST部分,剩下的PATH部分代表了不同的频道。比如,发起websocket时连接到ws://ws.app.12zan.net/channel/hello,那么这个频道地址就是/channel/hello;所有连接到ws.app.12zan.net/channel/hello的websocket客户端,他们会收到一模一样的消息,我们称之为订阅。同时,为了简化发起数据的过程,我们还在websocket server中定义:当一个http 的客户端,以POST方式请求某一个地址时,我们截取URL中的PATH部分,得到频道名,并取POST的数据中的data域,作为要广播的数据,将之广播到相应的频道。在十二赞的应用:这个广播系统,在十二赞的整个技术架构中,后来应用的特别广。比如,我们的部署系统zeus,在网页端实现了一个客户端,当服务端有应用重启、关闭、启动时,都会弹出消息通知。任何在打开了这个系统的网页的人都能看到。比如我和同事小王都正在zeus的网页上,我新建了一个search系统的一个节点,启动完毕的时候,我和小王会收到通知,在第三号服务器上新启了一个search系统的节点。我在操作,很关心这个,所心这时我可以放心去继续我的工作。小王正要在三号机器上新部署一个系统,他收到这个通知后,觉得这个机器可能会很忙,于是把自己的新实例部署在了四号机器上。再比如,我们的日志服务器,担负着收集所有服务器上日志的使命。但是如果它挂掉了呢?于是我们在这个日志服务器上跑了一个定时器,每5秒钟向某个频道广播一条心跳消息,告诉世界自己还活着。然后另行跑了一个进程,收听这个频道的广播,如果连续30秒没有收到这个心跳包,证明这个日志服务器挂掉了,就发一条报警短信,通知同学去看看这个服务。再比如,我们在日志服务上的应用,参见这里:十二赞日志系统简介 ...

November 14, 2018 · 1 min · jiezi

以中间件,路由,跨进程事件的姿势使用WebSocket

通过参考koa中间件,socket.io远程事件调用,以一种新的姿势来使用WebSocket。浏览器端浏览器端使用WebSocket很简单// Create WebSocket connection.const socket = new WebSocket(‘ws://localhost:8080’);// Connection openedsocket.addEventListener(‘open’, function (event) { socket.send(‘Hello Server!’);});// Listen for messagessocket.addEventListener(‘message’, function (event) { console.log(‘Message from server ‘, event.data);});MDN关于WebSocket的介绍能注册的事件有onclose,onerror,onmessage,onopen。用的比较多的是onmessage,从服务器接受到数据后,会触发message事件。通过注册相应的事件处理函数,可以根据后端推送的数据做相应的操作。如果只是写个demo,单单输出后端推送的信息,如下使用即可:socket.addEventListener(‘message’, function (event) { console.log(‘Message from server ‘, event.data);});实际使用过程中,我们需要判断后端推送的数据然后执行相应的操作。比如聊天室应用中,需要判断消息是广播的还是私聊的或者群聊的,以及是纯文字信息还是图片等多媒体信息。这时message处理函数里可能就是一堆的if else。那么有没有什么别的优雅的姿势呢?答案就是中间件与事件,跨进程的事件的发布与订阅。在说远程事件发布订阅之前,需要先从中间件开始,因为后面实现的远程事件发布订阅是基于中间件的。中间件前面说了,在WebSocket实例上可以注册事件有onclose,onerror,onmessage,onopen。每一个事件的处理函数里可能需要做各种判断,特别是message事件。参考koa,可以将事件处理函数以中间件方式来进行使用,将不同的操作逻辑分发到不同的中间件中,比如聊天室应用中,聊天信息与系统信息(比如用户登录属于系统信息)是可以放到不同的中间件中处理的。koa提供use接口来注册中间件。我们针对不同的事件提供相应的中间件注册接口,并且对原生的WebSocket做封装。export default class EasySocket{ constructor(config) { this.url = config.url; this.openMiddleware = []; this.closeMiddleware = []; this.messageMiddleware = []; this.errorMiddleware = []; this.openFn = Promise.resolve(); this.closeFn = Promise.resolve(); this.messageFn = Promise.resolve(); this.errorFn = Promise.resolve(); } openUse(fn) { this.openMiddleware.push(fn); return this; } closeUse(fn) { this.closeMiddleware.push(fn); return this; } messageUse(fn) { this.messageMiddleware.push(fn); return this; } errorUse(fn) { this.errorMiddleware.push(fn); return this; }}通过xxxUse注册相应的中间件。 xxxMiddleware中就是相应的中间件。xxxFn 中间件通过compose处理后的结构再添加一个connect方法,处理相应的中间件并且实例化原生WebSocketconnect(url) { this.url = url || this.url; if (!this.url) { throw new Error(‘url is required!’); } try { this.socket = new WebSocket(this.url, ’echo-protocol’); } catch (e) { throw e; } this.openFn = compose(this.openMiddleware); this.socket.addEventListener(‘open’, (event) => { let context = { client: this, event }; this.openFn(context).catch(error => { console.log(error) }); }); this.closeFn = compose(this.closeMiddleware); this.socket.addEventListener(‘close’, (event) => { let context = { client: this, event }; this.closeFn(context).then(() => { }).catch(error => { console.log(error) }); }); this.messageFn = compose(this.messageMiddleware); this.socket.addEventListener(‘message’, (event) => { let res; try { res = JSON.parse(event.data); } catch (error) { res = event.data; } let context = { client: this, event, res }; this.messageFn(context).then(() => { }).catch(error => { console.log(error) }); }); this.errorFn = compose(this.errorMiddleware); this.socket.addEventListener(’error’, (event) => { let context = { client: this, event }; this.errorFn(context).then(() => { }).catch(error => { console.log(error) }); }); return this; }使用koa-compose模块处理中间件。注意context传入了哪些东西,后续定义中间件的时候都已使用。compose的作用可看这篇文章 傻瓜式解读koa中间件处理模块koa-compose然后就可以使用了:new EasySocket() .openUse((context, next) => { console.log(“open”); next(); }) .closeUse((context, next) => { console.log(“close”); next(); }) .errorUse((context, next) => { console.log(“error”, context.event); next(); }) .messageUse((context, next) => { //用户登录处理中间件 if (context.res.action === ‘userEnter’) { console.log(context.res.user.name+’ 进入聊天室’); } next(); }) .messageUse((context, next) => { //创建房间处理中间件 if (context.res.action === ‘createRoom’) { console.log(‘创建房间 ‘+context.res.room.anme); } next(); }) .connect(‘ws://localhost:8080’)可以看到,用户登录与创建房间的逻辑放到两个中间件中分开处理。不足之处就是每个中间件都要判断context.res.action,而这个context.res就是后端返回的数据。怎么消除这个频繁的if判断呢? 我们实现一个简单的消息处理路由。路由定义消息路由中间件messageRouteMiddleware.jsexport default (routes) => { return async (context, next) => { if (routes[context.req.action]) { await routescontext.req.action; } else { console.log(context.req) next(); } }}定义路由router.jsexport default { userEnter:function(context,next){ console.log(context.res.user.name+’ 进入聊天室’); next(); }, createRoom:function(context,next){ console.log(‘创建房间 ‘+context.res.room.anme); next(); }}使用:new EasySocket() .openUse((context, next) => { console.log(“open”); next(); }) .closeUse((context, next) => { console.log(“close”); next(); }) .errorUse((context, next) => { console.log(“error”, context.event); next(); }) .messageUse(messageRouteMiddleware(router))//使用消息路由中间件,并传入定义好的路由 .connect(‘ws://localhost:8080’)一切都变得美好了,感觉就像在使用koa。想一个问题,当接收到后端推送的消息时,我们需要做相应的DOM操作。比如路由里面定义的userEnter,我们可能需要在对应的函数里操作用户列表的DOM,追加新用户。这使用原生JS或JQ都是没有问题的,但是如果使用vue,react这些,因为是组件化的,用户列表可能就是一个组件,怎么访问到这个组件实例呢?(当然也可以访问vuex,redux的store,但是并不是所有组件的数据都是用store管理的)。我们需要一个运行时注册中间件的功能,然后在组件的相应的生命周期钩子里注册中间件并且传入组件实例运行时注册中间件,修改如下代码:messageUse(fn, runtime) { this.messageMiddleware.push(fn); if (runtime) { this.messageFn = compose(this.messageMiddleware); } return this; }修改 messageRouteMiddleware.jsexport default (routes,component) => { return async (context, next) => { if (routes[context.req.action]) { context.component=component;//将组件实例挂到context下 await routescontext.req.action; } else { console.log(context.req) next(); } }}类似vue mounted中使用mounted(){ let client = this.$wsClients.get(“im”);//获取指定EasySocket实例 client.messageUse(messageRouteMiddleware(router,this),true)//运行时注册中间件,并传入定义好的路由以及当前组件中的this}路由中通过 context.component 即可访问到当前组件。完美了吗?每次组件mounted 都注册一次中间件,问题很大。所以需要一个判断中间件是否已经注册的功能。也就是一个支持具名注册中间件的功能。这里就暂时不实现了,走另外一条路,也就是之前说到的远程事件的发布与订阅,我们也可以称之为跨进程事件。跨进程事件看一段socket.io的代码:Server (app.js)var app = require(‘http’).createServer(handler)var io = require(‘socket.io’)(app);var fs = require(‘fs’);app.listen(80);function handler (req, res) { fs.readFile(__dirname + ‘/index.html’, function (err, data) { if (err) { res.writeHead(500); return res.end(‘Error loading index.html’); } res.writeHead(200); res.end(data); });}io.on(‘connection’, function (socket) { socket.emit(’news’, { hello: ‘world’ }); socket.on(‘my other event’, function (data) { console.log(data); });});Client (index.html)<script src="/socket.io/socket.io.js"></script><script> var socket = io(‘http://localhost’); socket.on(’news’, function (data) { console.log(data); socket.emit(‘my other event’, { my: ‘data’ }); });</script>注意力转到这两部分:服务端 socket.emit(’news’, { hello: ‘world’ }); socket.on(‘my other event’, function (data) { console.log(data); });客户端 var socket = io(‘http://localhost’); socket.on(’news’, function (data) { console.log(data); socket.emit(‘my other event’, { my: ‘data’ }); });使用事件,客户端通过on订阅’news’事件,并且当触发‘new’事件的时候通过emit发布’my other event’事件。服务端在用户连接的时候发布’news’事件,并且订阅’my other event’事件。一般我们使用事件的时候,都是在同一个页面中on和emit。而socket.io的神奇之处就是同一事件的on和emit是分别在客户端和服务端,这就是跨进程的事件。那么,在某一端emit某个事件的时候,另一端如果on监听了此事件,是如何知道这个事件emit(发布)了呢?没有看socket.io源码之前,我设想应该是emit方法里做了某些事情。就像java或c#,实现rpc的时候,可以依据接口定义动态生成实现(也称为代理),动态实现的(代理)方法中,就会将当前方法名称以及参数通过相应协议进行序列化,然后通过http或者tcp等网络协议传输到RPC服务端,服务端进行反序列化,通过反射等技术调用本地实现,并返回执行结果给客户端。客户端拿到结果后,整个调用完成,就像调用本地方法一样实现了远程方法的调用。看了socket.io emit的代码实现后,思路也是大同小异,通过将当前emit的事件名和参数按一定规则组合成数据,然后将数据通过WebSocket的send方法发送出去。接收端按规则取到事件名和参数,然后本地触发emit。(注意远程emit和本地emit,socket.io中直接调用的是远程emit)。下面是实现代码,事件直接用的emitter模块,并且为了能自定义emit事件名和参数组合规则,以中间件的方式提供处理方法:export default class EasySocket extends Emitter{//继承Emitter constructor(config) { this.url = config.url; this.openMiddleware = []; this.closeMiddleware = []; this.messageMiddleware = []; this.errorMiddleware = []; this.remoteEmitMiddleware = [];//新增的部分 this.openFn = Promise.resolve(); this.closeFn = Promise.resolve(); this.messageFn = Promise.resolve(); this.errorFn = Promise.resolve(); this.remoteEmitFn = Promise.resolve();//新增的部分 } openUse(fn) { this.openMiddleware.push(fn); return this; } closeUse(fn) { this.closeMiddleware.push(fn); return this; } messageUse(fn) { this.messageMiddleware.push(fn); return this; } errorUse(fn) { this.errorMiddleware.push(fn); return this; } //新增的部分 remoteEmitUse(fn, runtime) { this.remoteEmitMiddleware.push(fn); if (runtime) { this.remoteEmitFn = compose(this.remoteEmitMiddleware); } return this; } connect(url) { … //新增部分 this.remoteEmitFn = compose(this.remoteEmitMiddleware); } //重写emit方法,支持本地调用以远程调用 emit(event, args, isLocal = false) { let arr = [event, args]; if (isLocal) { super.emit.apply(this, arr); return this; } let evt = { event: event, args: args } let remoteEmitContext = { client: this, event: evt }; this.remoteEmitFn(remoteEmitContext).catch(error => { console.log(error) }) return this; }}下面是一个简单的处理中间件:client.remoteEmitUse((context, next) => { let client = context.client; let event = context.event; if (client.socket.readyState !== 1) { alert(“连接已断开!”); } else { client.socket.send(JSON.stringify({ type: ’event’, event: event.event, args: event.args })); next(); } })意味着调用client.emit(‘chatMessage’,{ from:‘admin’, masg:“Hello WebSocket”});就会组合成数据{ type: ’event’, event: ‘chatMessage’, args: { from:‘admin’, masg:“Hello WebSocket” }}发送出去。服务端接受到这样的数据,可以做相应的数据处理(后面会使用nodejs实现类似的编程模式),也可以直接发送给别的客户端。客户受到类似的数据,可以写专门的中间件进行处理,比如:client.messageUse((context, next) => { if (context.res.type === ’event’) { context.client.emit(context.res.event, context.res.args, true);//注意这里的emit是本地emit。 } next();})如果本地订阅的chatMessage事件,回到函数就会被触发。在vue或react中使用,也会比之前使用路由的方式简单mounted() { let client = this.$wsClients.get(“im”); client.on(“chatMessage”, data => { let isSelf = data.from.id == this.user.id; let msg = { name: data.from.name, msg: data.msg, createdDate: data.createdDate, isSelf }; this.broadcastMessageList.push(msg); });}组件销毁的时候移除相应的事件订阅即可,或者清空所有事件订阅destroyed() { let client = this.$wsClients.get(“im”); client.removeAllListeners();}心跳重连核心代码直接从websocket-heartbeat-js copy过来的(用npm包,还得在它的基础上再包一层),相关文章 初探和实现websocket心跳重连。核心代码: heartCheck() { this.heartReset(); this.heartStart(); } heartStart() { this.pingTimeoutId = setTimeout(() => { //这里发送一个心跳,后端收到后,返回一个心跳消息 this.socket.send(this.pingMsg); //接收到心跳信息说明连接正常,会执行heartCheck(),重置心跳(清除下面定时器) this.pongTimeoutId = setTimeout(() => { //此定时器有运行的机会,说明发送ping后,设置的超时时间内未收到返回信息 this.socket.close();//不直接调用reconnect,避免旧WebSocket实例没有真正关闭,导致不可预料的问题 }, this.pongTimeout); }, this.pingTimeout); } heartReset() { clearTimeout(this.pingTimeoutId); clearTimeout(this.pongTimeoutId); }最后源码地址:easy-socket-browsernodejs实现的类似的编程模式(有空再细说):easy-socket-node实现的聊天室例子:online chat demo 聊天室前端源码:lazy-mock-im聊天室服务端源码:lazy-mock ...

November 5, 2018 · 4 min · jiezi

Node.js+webSocket

// 引入WebSocket模块var ws = require(’nodejs-websocket’)var PORT = 3030var server = ws.createServer(function(conn){ console.log(‘新连接’) conn.on(“text”,function(str){ console.log(“接受数据”+str) conn.sendText(“返回数据:"+str) }) conn.on(“close”,function(code,reason){ console.log(“关闭连接”) }) conn.on(“error”,function(err){ console.log(err) })}).listen(PORT)console.log(‘websocket 监听端口: ’ + PORT)github地址:https://github.com/Rossy11/node

October 30, 2018 · 1 min · jiezi

手摸手教你使用WebSocket[其实WebSocket也不难]

在本篇文章之前,WebSocket很多人听说过,没见过,没用过,以为是个很高大上的技术,实际上这个技术并不神秘,可以说是个很容易就能掌握的技术,希望在看完本文之后,马上把文中的栗子拿出来自己试一试,实践出真知。游泳、健身了解一下:博客、前端积累文档、公众号、GitHubWebSocket解决了什么问题:客户端(浏览器)和服务器端进行通信,只能由客户端发起ajax请求,才能进行通信,服务器端无法主动向客户端推送信息。当出现类似体育赛事、聊天室、实时位置之类的场景时,客户端要获取服务器端的变化,就只能通过轮询(定时请求)来了解服务器端有没有新的信息变化。轮询效率低,非常浪费资源(需要不断发送请求,不停链接服务器)WebSocket的出现,让服务器端可以主动向客户端发送信息,使得浏览器具备了实时双向通信的能力,这就是WebSocket解决的问题一个超简单的栗子:新建一个html文件,将本栗子找个地方跑一下试试,即可轻松入门WebSocket:function socketConnect(url) { // 客户端与服务器进行连接 let ws = new WebSocket(url); // 返回WebSocket对象,赋值给变量ws // 连接成功回调 ws.onopen = e => { console.log(‘连接成功’, e) ws.send(‘我发送消息给服务端’); // 客户端与服务器端通信 } // 监听服务器端返回的信息 ws.onmessage = e => { console.log(‘服务器端返回:’, e.data) // do something } return ws; // 返回websocket对象}let wsValue = socketConnect(‘ws://121.40.165.18:8800’); // websocket对象上述栗子中WebSocket的接口地址出自:WebSocket 在线测试,在开发的时候也可以用于测试后端给的地址是否可用。webSocket的class类:当项目中很多地方使用WebSocket,把它封成一个class类,是更好的选择。下面的栗子,做了非常详细的注释,建个html文件也可直接使用,websocket的常用API都放进去了。下方注释的代码,先不用管,涉及到心跳机制,用于保持WebSocket连接的class WebSocketClass { /** * @description: 初始化实例属性,保存参数 * @param {String} url ws的接口 * @param {Function} msgCallback 服务器信息的回调传数据给函数 * @param {String} name 可选值 用于区分ws,用于debugger / constructor(url, msgCallback, name = ‘default’) { this.url = url; this.msgCallback = msgCallback; this.name = name; this.ws = null; // websocket对象 this.status = null; // websocket是否关闭 } /* * @description: 初始化 连接websocket或重连webSocket时调用 * @param {*} 可选值 要传的数据 */ connect(data) { // 新建 WebSocket 实例 this.ws = new WebSocket(this.url); this.ws.onopen = e => { // 连接ws成功回调 this.status = ‘open’; console.log(${this.name}连接成功, e) // this.heartCheck(); if (data !== undefined) { // 有要传的数据,就发给后端 return this.ws.send(data); } } // 监听服务器端返回的信息 this.ws.onmessage = e => { // 把数据传给回调函数,并执行回调 // if (e.data === ‘pong’) { // this.pingPong = ‘pong’; // 服务器端返回pong,修改pingPong的状态 // } return this.msgCallback(e.data); } // ws关闭回调 this.ws.onclose = e => { this.closeHandle(e); // 判断是否关闭 } // ws出错回调 this.onerror = e => { this.closeHandle(e); // 判断是否关闭 } } // heartCheck() { // // 心跳机制的时间可以自己与后端约定 // this.pingPong = ‘ping’; // ws的心跳机制状态值 // this.pingInterval = setInterval(() => { // if (this.ws.readyState === 1) { // // 检查ws为链接状态 才可发送 // this.ws.send(‘ping’); // 客户端发送ping // } // }, 10000) // this.pongInterval = setInterval(() => { // this.pingPong = false; // if (this.pingPong === ‘ping’) { // this.closeHandle(‘pingPong没有改变为pong’); // 没有返回pong 重启webSocket // } // // 重置为ping 若下一次 ping 发送失败 或者pong返回失败(pingPong不会改成pong),将重启 // console.log(‘返回pong’) // this.pingPong = ‘ping’ // }, 20000) // } // 发送信息给服务器 sendHandle(data) { console.log(${this.name}发送消息给服务器:, data) return this.ws.send(data); } closeHandle(e = ’err’) { // 因为webSocket并不稳定,规定只能手动关闭(调closeMyself方法),否则就重连 if (this.status !== ‘close’) { console.log(${this.name}断开,重连websocket, e) // if (this.pingInterval !== undefined && this.pongInterval !== undefined) { // // 清除定时器 // clearInterval(this.pingInterval); // clearInterval(this.pongInterval); // } this.connect(); // 重连 } else { console.log(${this.name}websocket手动关闭) } } // 手动关闭WebSocket closeMyself() { console.log(关闭${this.name}) this.status = ‘close’; return this.ws.close(); }}function someFn(data) { console.log(‘接收服务器消息的回调:’, data);}// const wsValue = new WebSocketClass(‘ws://121.40.165.18:8800’, someFn, ‘wsName’); // 这个链接一天只能发送消息50次const wsValue = new WebSocketClass(‘wss://echo.websocket.org’, someFn, ‘wsName’); // 阮一峰老师教程链接wsValue.connect(‘立即与服务器通信’); // 连接服务器// setTimeout(() => {// wsValue.sendHandle(‘传消息给服务器’)// }, 1000);// setTimeout(() => {// wsValue.closeMyself(); // 关闭ws// }, 10000)栗子里面我直接写在了一起,可以把class放在一个js文件里面,export出去,然后在需要用的地方再import进来,把参数传进去就可以用了。WebSocket不稳定WebSocket并不稳定,在使用一段时间后,可能会断开连接,貌似至今没有一个为何会断开连接的公论,所以我们需要让WebSocket保持连接状态,这里推荐两种方法。WebSocket设置变量,判断是否手动关闭连接:class类中就是用的这种方式:设置一个变量,在webSocket关闭/报错的回调中,判断是不是手动关闭的,如果不是的话,就重新连接,这样做的优缺点如下:优点:请求较少(相对于心跳连接),易设置。缺点:可能会导致丢失数据,在断开重连的这段时间中,恰好双方正在通信。WebSocket心跳机制:因为第一种方案的缺点,并且可能会有其他一些未知情况导致断开连接而没有触发Error或Close事件。这样就导致实际连接已经断开了,而客户端和服务端却不知道,还在傻傻的等着消息来。然后聪明的程序猿们想出了一种叫做心跳机制的解决方法:客户端就像心跳一样每隔固定的时间发送一次ping,来告诉服务器,我还活着,而服务器也会返回pong,来告诉客户端,服务器还活着。具体的实现方法,在上面class的注释中,将其打开,即可看到效果。关于WebSocket怕一开始就堆太多文字性的内容,把各位吓跑了,现在大家已经会用了,我们再回头来看看WebSocket的其他知识点。WebSocket的当前状态:WebSocket.readyState下面是WebSocket.readyState的四个值(四种状态):0: 表示正在连接1: 表示连接成功,可以通信了2: 表示连接正在关闭3: 表示连接已经关闭,或者打开连接失败我们可以利用当前状态来做一些事情,比如上面栗子中当WebSocket链接成功后,才允许客户端发送ping。if (this.ws.readyState === 1) { // 检查ws为链接状态 才可发送 this.ws.send(‘ping’); // 客户端发送ping}WebSocket还可以发送/接收 二进制数据这里我也没有试过,我是看阮一峰老师的WebSocket教程才知道有这么个东西,有兴趣的可以再去谷歌,大家知道一下就可以。二进制数据包括:blob对象和Arraybuffer对象,所以我们需要分开来处理。 // 接收数据ws.onmessage = function(event){ if(event.data instanceof ArrayBuffer){ // 判断 ArrayBuffer 对象 } if(event.data instanceof Blob){ // 判断 Blob 对象 }}// 发送 Blob 对象的例子let file = document.querySelector(‘input[type=“file”]’).files[0];ws.send(file);// 发送 ArrayBuffer 对象的例子var img = canvas_context.getImageData(0, 0, 400, 320);var binary = new Uint8Array(img.data.length);for (var i = 0; i < img.data.length; i++) { binary[i] = img.data[i];}ws.send(binary.buffer);如果你要发送的二进制数据很大的话,如何判断发送完毕:webSocket.bufferedAmount属性,表示还有多少字节的二进制数据没有发送出去:var data = new ArrayBuffer(10000000);socket.send(data);if (socket.bufferedAmount === 0) { // 发送完毕} else { // 发送还没结束}上述栗子出自阮一峰老师的WebSocket教程WebSocket的优点:最后再吹一波WebSocket:双向通信(一开始说的,也是最重要的一点)。数据格式比较轻量,性能开销小,通信高效协议控制的数据包头部较小,而HTTP协议每次通信都需要携带完整的头部更好的二进制支持没有同源限制,客户端可以与任意服务器通信与 HTTP 协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器结语看了本文之后,如果还是有点迷糊的话,一定要把文中的两个栗子,新建个html文件跑起来,自己鼓捣鼓捣一下。不然读多少博客/教程都没有用,实践才出真知,切勿纸上谈兵。希望看完的朋友可以点个喜欢/关注,您的支持是对我最大的鼓励。博客、前端积累文档、公众号、GitHub以上2018.10.22参考资料:WebSocket 教程理解WebSocket心跳及重连机制WebSocket协议:5分钟从入门到精通 ...

October 25, 2018 · 3 min · jiezi

浅淡 RxJS WebSocket

引言中后台仪表盘是一个非常复杂,特别是当需要全面屏运用时,数据的实时性需求非常高。WebSocket 不管在什么环境中使用其实都是非常简单,各现代浏览器实现标准都很统一,而且接口也足够简单。即便是在 Angular 也是如此,只需要简单几行代码就能使用 WebSocket。const ws = new WebSocket(‘wss://echo.websocket.org’);ws.onmessage = (e) => { console.log(‘message’, e);}若需要向服务端发送消息,则:ws.send(content);在 Angular 里绝大多数的人都会根据上述代码进一步拓展,比如统一消息解析、错误处理、多路复用等,并最终将其封装成一个服务类。事实上,RxJS 也包裹了一个 WebSocket Subject,位于 rxjs/websocket。如何使用假如将上面的示例使用 RxJS 来写,则:import { webSocket, WebSocketSubject } from ‘rxjs/webSocket’;const ws = webSocket(‘wss://echo.websocket.org’);ws.subscribe(res => { console.log(‘message’, res);});ws.next(content);webSocket 是一个工厂函数,所生产出来的 WebSocketSubject 对象可被多次订阅,若未订阅或取消最后一个订阅时都会导致 WebSocket 连接中断,当再一次订阅时会重新自动连接。WebSocketSubjectConfigwebSocket 除了接收字符串(WebSocket服务远程地址)外,还允许指定更复杂的配置项。默认情况下,消息是使用 JSON.parse 和 JSON.stringify 对消息格式序列化和反序列化操作,所以不管消息发送或接收都以 JSON 为准,可通过 serializer、deserializer 属性来改变。若需要关心 WebSocket 什么时候开始或结束(closeObserver),则:const open$ = new Subject();const ws = webSocket({ url: ‘wss://echo.websocket.org’, openObserver: open$});// 订阅打开事件open$.subscribe(() => {});消息WebSocketSubject 也是 Subject 的变体之一,因此订阅它表示接收消息,反之则利用 next、complete、error 来维护消息的推送。使用 next 来发送消息使用 complete 会尝试检测是否最后一个订阅,若是将会关闭连接使用 error 相当于原始 close 方法且必须提供 { code: number, reason?: string} 参数,注意 code 务必遵守取值范围可被重放调用 next 发送消息时若 WebSocket 连接中断(例如:没人订阅时),消息会被缓存当下一次重新连接以后会按顺序发送。这对于异步世界里非常方便,我们只需要确保 Angular 启动前初始化好 WebSocket 不管什么时候订阅接收消息,都可以随时发送也无须等待。事实上这一点是 RxJS WebSocket 默认情况下是通过 webSocket 所生产的 WebSocketSubject 其本质上是 ReplaySubject 的“重放”能力。当然你可以通过 webSocket 的第二个参数改变这种行为。多路复用一般来说我们不太可能只会一个 Web Socket 服务完成所有的事,然而也不太可能针对每一个业务实例创建一个 webSocket。往往我们会增加一层网关并将这些业务 WebSocket 进行汇总,对于前端始终只需要一个连接,这就是多路复用存在的意义。而核心是必须要让后端知道,什么时候发送什么消息给什么样的服务。首先必须先使用 multiplex 方法来创建 Observable 以便订阅某一路消息,它有三个参数来帮助我们区分消息:subMsg 告知正在订阅哪一路消息unsubMsg 告知取消订阅哪一路消息messageFilter 过滤消息,使订阅者只接收哪一路消息const ws = webSocket(‘wss://echo.websocket.org’);const user$ = this.ws.multiplex( () => ({ type: ‘subscribe’, tag: ‘user’ }), () => ({ type: ‘unsubscribe’, tag: ‘user’ }), message => message.type === ‘user’);user$.subscribe(message => console.log(message));const todo$ = this.ws.multiplex( () => ({ type: ‘subscribe’, tag: ’todo’ }), () => ({ type: ‘unsubscribe’, tag: ’todo’ }), message => message.type === ’todo’);todo$.subscribe(message => console.log(message));user$ 流和 todo$ 流他们共用一个 WebSocket 连接,这便是多路复用。虽然订阅是通过 multiplex 创建的,然后消息的推送依然还是需要使用 ws.next()。总结这原本是对内部一个简单培训,然而我发现竟然极少人会讨论 RxJS 里面 Web Socket 的实现。其实一直有想着要给 ng-alain 内置 WebSocket,只是就封装角度来讲完全没有价值,因为已经足够优雅。 ...

September 24, 2018 · 1 min · jiezi