web端即时通信技术
-
对于IM/消息推送这类即时通讯系统而言,系统的关键就是“实时通信”能力。所谓实时通信有以下两层含义
-
客户端可以主动向服务端发送信息。
-
当服务端内容发生变化时,服务端可以实时通知客户端。
-
HTTP局限
- Http是客户端/服务器模式中请求-响应所用的协议,在这种模式中,客户端(一般是web浏览器)向服务器提交HTTP请求,服务器响应请求的资源
- HTTP是半双工协议,也就是说,在同一时刻数据只能单向流动,客户端向服务器发送请求(单向的),然后服务器响应请求(单向的)。
- 服务器不能主动推送数据给浏览器。
常用技术
-
客户端轮询:传统意义上的短轮询(Short Polling)
-
服务器端轮询:长轮询(Long Polling)
-
单向服务器推送:Server-Sent Events(SSE)
-
全双工通信:WebSocket
短轮询
实现原理
-
客户端向服务器端发送一个请求,服务器返回数据,然后客户端根据服务器端返回的数据进行处理。
-
客户端继续向服务器端发送请求,继续重复以上的步骤。(为了减小服务端压力一般会采用定时轮询的方式)
优点
- 实现简单,不需要额外开发,仅需要定时发起请求,解析响应即可。
缺点
- 客户端和服务器之间会一直进行连接,每隔一段时间就询问一次,不断的发起请求和关闭请求,一个个接受,一个发送。而且每次发送请求都会有Http的Header,会很耗流量,也会消耗CPU的利用率,性能损耗以及对服务端的压力较大,且HTTP请求本身本身比较耗费资源。
- 轮询间隔不好控制。如果实时性要求较高,短轮询是明显的短板,但如果设置太长,会导致消息延迟。
简单实现
server.js
let express = require('express');
let app = express();
app.use(express.static(__dirname));
app.use(function(req,res,next){
res.header('Access-Control-Allow-Origin', 'http://localhost:8000');
res.end(new Date().toLocaleTimeString());
});
app.listen(8080);
<body>
<div id="clock"></div>
<script>
setInterval(function () {
let xhr = new XMLHttpRequest();
xhr.open('GET', 'http://localhost:8080', true);
xhr.onreadystatechange = function () {
if (xhr.readyState == 4 && xhr.status == 200) {
document.querySelector('#clock').innerHTML = xhr.responseText;
}
}
xhr.send();
}, 1000);
</script>
</body>
长轮询
实现原理
- 客户端发送发送一个请求给HTTP给服务器之后,看有没有新消息,如果没有新消息,就一直等待,服务器会hold住这个请求。
- 直到监听的内容有改变,才会返回数据给客户端,断开连接(或者在一定的时间内,请求还得不到返回,就会因为超时自动断开连接);
- 客户端继续发送请求,重复以上步骤
优点
- 减少了客户端发起Http连接的开销,改成在服务器端主动地去判断关注的内容是否变化,在某种程度上减小了网络带宽和CPU利用率等问题。
缺点
- 由于http数据包的头部数据量往往很大(通常有400多个字节),但是真正被服务器需要的数据却很少(有时只有10个字节左右),
- 这样的数据包在网络上周期性的传输,难免对网络带宽是一种浪费,需要有很高的并发能力
简单实现
<div id="clock"></div>
<script>
(function poll() {
let xhr = new XMLHttpRequest();
xhr.open('GET', 'http://localhost:8080', true);
xhr.onreadystatechange = function () {
if (xhr.readyState == 4 && xhr.status == 200) {
document.querySelector('#clock').innerHTML = xhr.responseText;
poll();
}
}
xhr.send();
})();
</script>
基于iframe的长轮询
基于iframe的长轮询是长轮询的另一种实现方案。通过在HTML页面里嵌入一个隐藏的iframe,然后将这个iframe的src属性设为对一个长连接的请求,服务器端就能源源不断地往客户推送数据
实现原理
-
在页面中嵌入一个隐藏的iframe,地址指向轮询的服务器地址,然后在父页面中放置一个执行函数,比如execute(data);
-
当服务器有内容改变时,会向iframe发送一个脚本;
-
通过发送的脚本,主动执行父页面中的方法,达到推送的效果。
简单实现
server.js
const express = require('express');
const app = express();
app.use(express.static(__dirname));
app.get('/clock', function (req, res) {
setInterval(function () {
res.write(`
<script type="text/javascript">
parent.document.getElementById('clock').innerHTML = "${new Date().toLocaleTimeString()}";
</script>
`);
}, 1000);
});
app.listen(8080);
client.html
<div id="clock"></div>
<iframe src="/clock" style=" display:none" />
缺点
- 基于iframe的长轮询底层还是长轮询技术,只是实现方式不同,而且在浏览器上会显示请求未加载完成,图标会不停旋转,简直是强迫症杀手,个人不是很推荐。
Server-Sent Events(SSE)
-
HTML5规范中提供了服务端事件EventSource,浏览器在实现了该规范的前提下创建一个EventSource连接后,便可收到服务端的发送的消息,这些消息需要遵循一定的格式,对于前端开发人员而言,只需在浏览器中侦听对应的事件皆可
-
SSE的简单模型是:一个客户端去从服务器端订阅一条
流
,之后服务端可以发送消息给客户端直到服务端或者客户端关闭该“流”,所以eventsource也叫作server-sent-event -
EventSource流的实现方式对客户端开发人员而言非常简单,兼容性良好
-
EventSource
是SSE
客户端接口 -
对于服务端,它可以兼容老的浏览器,无需upgrade为其他协议,在简单的服务端推送的场景下可以满足需求
-
短轮询和长轮询技术,服务器端是无法主动给客户端推送消息的,都是客户端主动去请求服务器端获取最新的数据。
-
而SSE是一种可以主动从服务端推送消息的技术。
-
SSE的本质其实就是一个HTTP的长连接,只不过它给客户端发送的不是一次性的数据包,而是一个stream流,格式为text/event-stream。所以客户端不会关闭连接,会一直等着服务器发过来的新的数据流。
实现原理
- 客户端向服务端发起HTTP长连接,服务端返回stream响应流。客户端收到stream响应流并不会关闭连接而是一直等待服务端发送新的数据流。
- 客户端向服务器发送一个GET请求,带有指定的header,表示可以接收事件流类型,并禁用任何的事件缓存。
- 服务器返回一个响应,带有指定的header,表示事件的媒体类型和编码,以及使用分块传输编码(chunked)来流式传输动态生成的内容。
- 服务器在有数据更新时,向客户端发送一个或多个名称:值字段组成的事件,由单个换行符分隔。事件之间由两个换行符分隔。服务器可以发送事件数据、事件类型、事件ID和重试时间等字段。
- 客户端使用EventSource接口来创建一个对象,打开连接,并订阅onopen、onmessage和onerror等事件处理程序来处理连接状态和接收消息。
- 客户端可以使用GET查询参数来传递数据给服务器,也可以使用close方法来关闭连接。
优点
- SSE 使用 HTTP 协议,现有的服务器软件都支持
- SSE提供了从服务器到客户端的单向通信,对于那些只需要服务器推送数据到客户端的应用(如股票行情、新闻更新等),SSE 属于轻量级,使用简单
- SSE 默认支持断线重连,简化了客户端的重连逻辑
- SSE复用现有的HTTP端口(通常为80或443),因此在部署时不需要额外的网络配置
- SSE 一般只用来传送文本,二进制数据需要编码后传送
- SSE 支持自定义发送的消息类型
缺点
-
「单向通信限制」:SSE只支持服务器到客户端的单向通信,如果需要客户端向服务器发送消息,则需要使用其他的HTTP请求方式。
-
「流量消耗」:对于需要频繁更新的应用,SSE可能会因为持续的HTTP连接而消耗更多的流量和服务器资源。
-
「缺乏协议支持」:由于SSE是基于HTTP的,它不支持二进制数据传输,这在传输大量数据时可能不如WebSocket高效。
-
「带宽占用」:尽管SSE通常传输的数据量不大,但持续的连接和频繁的数据推送仍然会占用一定的带宽。对于高流量应用,这可能会成为限制因素。
-
「状态管理」:服务器需要维护每个SSE连接的状态,包括发送的数据、重连尝试等。状态管理的复杂性随着连接数的增加而增加。 可以使用数据库或缓存来存储和管理SSE连接状态。
-
「内存泄漏」:长时间运行的SSE连接可能会导致内存泄漏,特别是如果不正确地管理事件监听器和相关资源。
适用场景
chatGPT 返回的数据 就是使用的SSE 技术
-
SSE适用场景的特点:
-
数据更新频繁:服务器需要不断地将最新的数据推送给客户端,保持数据的实时性和准确性。
-
低延迟:服务器需要尽快地将数据推送给客户端,避免数据的延迟和过期。
-
单向通信:服务器只需要向客户端推送数据,而不需要接收客户端的数据。
-
-
SSE适用场景是指服务器向客户端实时推送数据的场景,例如:
- 股票价格更新:服务器可以根据股市的变化,实时地将股票价格推送给客户端,让客户端能够及时了解股票的走势和行情。
- 新闻实时推送:服务器可以根据新闻的更新,实时地将新闻内容或标题推送给客户端,让客户端能够及时了解最新的新闻动态和信息。
- 在线聊天:服务器可以根据用户的发送,实时地将聊天消息推送给客户端,让客户端能够及时收到和回复消息。
- 实时监控:服务器可以根据设备的状态,实时地将监控数据或报警信息推送给客户端,让客户端能够及时了解设备的运行情况和异常情况。
简单实现
浏览器端
- 浏览器端,需要创建一个
EventSource
对象,并且传入一个服务端的接口URI作为参 - 默认EventSource对象通过侦听
message
事件获取服务端传来的消息 open
事件则在http连接建立后触发error
事件会在通信错误(连接中断、服务端返回数据失败)的情况下触发- 同时
EventSource
规范允许服务端指定自定义事件,客户端侦听该事件即可
<script>
var eventSource = new EventSource('/eventSource');
eventSource.onmessage = function(e){
console.log(e.data);
}
eventSource.onerror = function(err){
console.log(err);
}
</script>
服务端
-
事件流的对应MIME格式为
text/event-stream
,而且其基于HTTP长连接。针对HTTP1.1规范默认采用长连接,针对HTTP1.0的服务器需要特殊设置。 -
event-source必须编码成
utf-8
的格式,消息的每个字段使用"\n"来做分割,并且需要下面4个规范定义好的字段:
- Event: 事件类型
- Data: 发送的数据
- ID: 每一条事件流的ID
- Retry: 告知浏览器在所有的连接丢失之后重新开启新的连接等待的时间,在自动重新连接的过程中,之前收到的最后一个事件流ID会被发送到服务端
let express = require('express');
let app = express();
app.use(express.static(__dirname));
let sendCount = 1;
app.get('/eventSource',function(req,res){
res.header('Content-Type','text/event-stream',);
setInterval(() => {
res.write(`event:message\nid:${sendCount++}\ndata:${Date.now()}\n\n`);
}, 1000)
});
app.listen(8888);
let express = require('express');
let app = express();
app.use(express.static(__dirname));
const SseStream = require('ssestream');
let sendCount = 1;
app.get('/eventSource',function(req,res){
const sseStream = new SseStream(req);
sseStream.pipe(res);
const pusher = setInterval(() => {
sseStream.write({
id: sendCount++,
event: 'message',
retry: 20000, // 告诉客户端,如果断开连接后,20秒后再重试连接
data: {ts: new Date().toTimeString()}
})
}, 1000)
res.on('close', () => {
clearInterval(pusher);
sseStream.unpipe(res);
})
});
app.listen(8888);
安全考虑
使用HTTPS加密数据传输
SSE基于HTTP协议,因此容易受到中间人攻击或数据泄露的风险。为了保护数据的安全性,应该使用HTTPS来加密客户端和服务器之间的数据传输。
// 在Servlet中设置HTTPS
response.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
response.setContentType("text/event-stream");
response.setCharacterEncoding("UTF-8");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Connection", "keep-alive");
response.setHeader("Content-Security-Policy", "default-src 'self'");
防止CSRF攻击
- SSE连接本身不会触发CSRF(跨站请求伪造)攻击,因为SSE是服务器向客户端的单向通信。然而,如果SSE用于触发客户端的某些操作,那么应该确保这些操作的安全性,比如通过验证请求来源或使用CSRF令牌。
防止XSS攻击
- 由于SSE允许服务器动态地向客户端页面发送数据,如果不正确处理,可能会成为XSS攻击的载体。确保对所有接收到的数据进行适当的清理和编码,避免直接插入到DOM中。
eventSource.onmessage = function(event) {
const safeData = encodeURI(event.data); // 对数据进行URL编码
const messageElement = document.createElement('div');
messageElement.textContent = safeData; // 安全地将数据添加到页面
document.getElementById('messages').appendChild(messageElement);
};
安全方案
验证连接请求
- 验证所有SSE连接请求,确保它们来自可信的源。可以通过检查
Referer
头或使用身份验证令牌来实现。
// 检查请求来源
String refererHost = request.getHeader("Referer");
if (refererHost == null || !refererHost.contains("trusted-domain.com")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
限制连接数量
- 为了防止资源耗尽攻击,服务器应该限制每个客户端可以建立的SSE连接数量。这可以通过在服务器端设置最大连接数来实现。
监控和日志记录
- 启用详细的日志记录和监控机制,以便在发生安全事件时快速响应。记录所有SSE连接的元数据,如IP地址、连接时间等。
实施访问控制
- 使用适当的访问控制策略,确保只有授权用户才能接收敏感数据。这可能涉及到用户认证和授权机制。
定期安全审计
- 定期对SSE实现进行安全审计,检查潜在的安全漏洞,并及时应用安全补丁。
优化策略
连接优化
- 「连接复用」:尽可能复用现有的连接,减少连接建立和关闭的开销。
- 「批量发送」:如果可能,批量发送数据而不是单个事件,减少数据包的数量。
- 「使用高效的序列化」:选择高效的数据序列化方法,减少数据传输的大小。
- 「超时和自动重连」:合理设置超时时间和自动重连策略,避免不必要的资源浪费。
流量消耗优化
- 优化数据传输:优化SSE的流量消耗通常涉及减少传输数据的大小和频率。使用GZIP压缩可以显著减少传输的数据量。
- 减少不必要的数据传输:仅在数据实际发生变化时才发送更新,避免发送重复或无关紧要的信息。
- 批量更新:如果可能,考虑将多个更新合并为一个数据包发送,减少消息的频率。
- 条件更新:只在客户端需要更新时才发送数据,例如通过客户端的请求参数来确定发送哪些数据。
- 连接超时和重连策略:设置合理的连接超时时间,并提供明确的重连策略,避免不必要的连接保持和频繁的重连尝试。
- 使用缓存:在客户端使用缓存来存储重复的数据,减少对服务器的请求。
- 监控流量使用:实施监控机制来跟踪SSE的流量使用情况,以便及时发现和解决流量消耗问题。
- 断开空闲连接:对于长时间空闲的连接,服务器可以主动断开,避免无谓的资源占用。
- 客户端流量控制:允许客户端控制接收数据的频率和量,例如提供暂停和恢复数据流的能力。
WebSocket
WebSocket 是一种网络传输协议,可在单个 TCP 连接上进行全双工通信,位于 OSI 模型的应用层。
- WebSockets_API 规范定义了一个 API 用以在网页浏览器和服务器建立一个 socket 连接。通俗地讲:在客户端和服务器保有一个持久的连接,两者之间就可以创建持久性的连接,两边可以在任意时间开始发送数据。
- HTML5开始提供的一种浏览器与服务器进行全双工通讯的网络技术
- 属于应用层协议,它基于TCP传输协议,并复用HTTP的握手通道。
实现原理
- 客户端发送一个 HTTP GET 请求到服务器,请求的路径是 WebSocket 的路径(类似 ws://example.com/socket)。请求中包含一些特殊的头字段,如 Upgrade: websocket 和 Connection: Upgrade,以表明客户端希望升级连接为 WebSocket。
- 服务器收到这个请求后,会返回一个 HTTP 101 状态码(协议切换协议)。同样在响应头中包含 Upgrade: websocket 和 Connection: Upgrade,以及一些其他的 WebSocket 特定的头字段,例如 Sec-WebSocket-Accept,用于验证握手的合法性。
- 客户端和服务器之间的连接从普通的 HTTP 连接升级为 WebSocket 连接。之后,客户端和服务器之间的通信就变成了 WebSocket 帧的传输,而不再是普通的 HTTP 请求和响应,客户端和服务端相互进行通信。
解决的问题
- WebSocket协议解决了HTTP协议的几个关键问题:
- 「单向通信」:HTTP是单向的,只能由客户端发起请求。WebSocket允许服务器主动推送信息。
- 「请求/响应模式」:HTTP的每次通信都需要建立新的连接,而WebSocket在建立连接后可以持续通信,无需重复握手。
- 「无状态」:HTTP连接是无状态的,而WebSocket可以保持会话状态。
- 「半双工协议」:HTTP是半双工的,即每次只能有一方发送数据。WebSocket是全双工的,双方可以同时发送和接收数据。
优点
- 实时性:WebSocket 提供了双向通信,服务器可以主动向客户端推送数据,实现实时性非常高,适用于实时聊天、在线协作等应用。
- 减少网络延迟:与轮询和长轮询相比,WebSocket 可以显著减少网络延迟,一旦WebSocket连接建立,就不需要像HTTP那样频繁地建立和关闭连接。
- 较小的数据传输开销:WebSocket 的数据帧相比于 HTTP 请求报文较小,减少了在每个请求中传输的开销,特别适用于需要频繁通信的应用。
- 较低的服务器资源占用:由于 WebSocket 的长连接特性,服务器可以处理更多的并发连接,相较于短连接有更低的资源占用。
- 跨域通信:与一些其他跨域通信方法相比,WebSocket 更容易实现跨域通信
- 服务器推送:服务器可以主动向客户端推送信息,而不需要客户端的请求
- 更好的二进制支持
缺点
- 连接状态保持:长时间保持连接可能会导致服务器和客户端都需要维护连接状态,可能增加一些负担
- 不适用于所有场景:对于一些请求-响应模式较为简单的场景,WebSocket 的实时特性可能并不是必要的,使用 HTTP 请求可能更为合适。
- 复杂性:与传统的 HTTP 请求相比,WebSocket 的实现和管理可能稍显复杂,尤其是在处理连接状态、异常等方面
WebSocket实现
服务端
let express = require('express');
const path = require('path');
let app = express();
let server = require('http').createServer(app);
app.get('/', function (req, res) {
res.sendFile(path.resolve(__dirname, 'index.html'));
});
app.listen(3000);
//-----------------------------------------------
let WebSocketServer = require('ws').Server;
let wsServer = new WebSocketServer({ port: 8888 });
wsServer.on('connection', function (socket) {
console.log('连接成功');
socket.on('message', function (message) {
console.log('接收到客户端消息:' + message);
socket.send('服务器回应:' + message);
});
});
客户端
<script>
let ws = new WebSocket('ws://localhost:8888');
ws.onopen = function () {
console.log('客户端连接成功');
ws.send('hello');
}
ws.onmessage = function (event) {
console.log('收到服务器的响应 ' + event.data);
}
</script>
如何建立连接
- WebSocket复用了HTTP的握手通道
- 具体指的是,客户端通过HTTP请求与WebSocket服务端协商升级协议
- 协议升级完成后,后续的数据交换则遵照WebSocket的协议
客户端:申请协议升级
- 首先客户端发起协议升级请求
- 请求采用的是标准的HTTP报文格式,且只支持GET方法
GET ws://localhost:8888/ HTTP/1.1
Host: localhost:8888
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: IHfMdf8a0aQXbwQO1pkGdA==
字段 | 含义 |
---|---|
Connection: Upgrade | 表示要升级协议 |
Upgrade: websocket | 表示要升级到websocket协议 |
Sec-WebSocket-Version: 13 | 表示websocket的版本 |
Sec-WebSocket-Key | 与后面服务端响应首部的Sec-WebSocket-Accept是配套的,提供基本的防护,比如恶意的连接,或者无意义的连接 |
服务端:响应协议升级
- 服务端返回内容如下
- 状态代码101表示协议切换
- 到此完成协议升级,后续的数据交互都按照新的协议来
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: aWAY+V/uyz5ILZEoWuWdxjnlb7E=
字段 | 含义 |
---|---|
Connection: Upgrade | 升级协议 |
Upgrade: websocket | 升级到websocket协议 |
Sec-WebSocket-Accept | Accept字符串 |
Sec-WebSocket-Accept的计算
- Sec-WebSocket-Accept根据客户端请求首部的Sec-WebSocket-Key计算出来
- 计算公式为:
- 将Sec-WebSocket-Key跟258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接
- 通过SHA1计算出摘要,并转成base64字符串
const crypto = require('crypto');
const CODE = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
function toAcceptKey(wsKey) {
return crypto.createHash('sha1').update(wsKey + CODE).digest('base64');;
}
const webSocketKey = 'IHfMdf8a0aQXbwQO1pkGdA==';
console.log(toAcceptKey(webSocketKey));//aWAY+V/uyz5ILZEoWuWdxjnlb7E=
Sec-WebSocket-Key/Accept的作用
- 避免服务端收到非法的websocket连接
- 确保服务端理解websocket连接
- 用浏览器里发起ajax请求,设置header时,Sec-WebSocket-Key以及其他相关的header是被禁止的
- Sec-WebSocket-Key主要目的并不是确保数据的安全性,因为Sec-WebSocket-Key、Sec-WebSocket-Accept的转换计算公式是公开的,而且非常简单,最主要的作用是预防一些常见的意外情况(非故意的)
数据帧格式
- WebSocket客户端、服务端通信的最小单位是帧,由1个或多个帧组成一条完整的消息(message)
- 发送端 将消息切割成多个帧,并发送给服务端
- 接收端 接收消息帧,并将关联的帧重新组装成完整的消息
bit和byte
- 比特就是bit 二进制数系统中,每个0或1就是一个位(bit),位是数据存储的最小单位
- 其中8个bit就称为一个字节(Byte)
位运算符
按位与(&)
- 两个输入数的同一位都为1才为1
按位或(|)
- 两个输入数的同一位只要有一个为1就是1
按位异或(^)
- 两个输入数的同一位不同就是1,如果相同就设为0
数据帧格式
- 单位是比特 比如FIN、RSV1各占据1比特,opcode占据4比特
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个比特 如果是1,表示这是消息(message)的最后一个分片(fragment),如果是0,表示不是是消息(message)的最后一个分片(fragment) |
RSV1, RSV2, RSV3 | 各占1个比特。一般情况下全为0 |
Opcode | 4个比特,操作代码 |
Mask | 1个比特。表示是否要对数据载荷进行掩码操作,从客户端向服务端发送数据时,需要对数据进行掩码操作;从服务端向客户端发送数据时,不需要对数据进行掩码操作,如果Mask是1,那么在Masking-key中会定义一个掩码键(masking key),并用这个掩码键来对数据载荷进行反掩码。所有客户端发送到服务端的数据帧,Mask都是1 |
Payload length | 数据载荷的长度 |
Masking-key | 0或4字节(32位) 所有从客户端传送到服务端的数据帧,数据载荷都进行了掩码操作,Mask为1,且携带了4字节的Masking-key。如果Mask为0,则没有Masking-key。载荷数据的长度,不包括mask key的长度 |
Payload data | 载荷数据 |
Opcode
字段 | 含义 |
---|---|
%x0 | 表示一个延续帧。当Opcode为0时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片 |
%x1 | 表示这是一个文本帧 |
%x2 | 表示这是一个二进制帧 |
%x3-7 | 保留的操作代码 |
%x8 | 表示连接断开 |
%x9 | 表示这是一个ping操作 |
%xA | 表示这是一个pong操作 |
%xB-F | 保留的操作代码 |
掩码算法
- 掩码键(Masking-key)是由客户端挑选出来的
32bit
的随机数,掩码操作不会影响数据载荷的长度 - 掩码和反掩码操作都采用如下算法
- 对索引
i
模以4得到结果并对原来的索引进行异或操作
function unmask(buffer, mask) {
const length = buffer.length;
for (let i = 0; i < length; i++) {
buffer[i] ^= mask[i % 4];
}
}
let mask = Buffer.from([1, 0, 1, 0]);
let buffer = Buffer.from([0, 1, 0, 1, 0, 1, 0, 1]);
unmask(buffer, mask);
console.log(buffer);
WebSocket服务器实现
const net = require('net');
const { EventEmitter } = require('events');
const crypto = require('crypto');
const CODE = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const OP_CODES = {
TEXT: 1,
BINARY: 2
};
class Server extends EventEmitter {
constructor(options) {
super(options);
this.options = options;
this.server = net.createServer(this.listener);
this.server.listen(options.port);
}
listener = (socket) => {
socket.setKeepAlive(true);
socket.send = function (payload) {
let _opcode;
if (Buffer.isBuffer(payload)) {
_opcode = OP_CODES.BINARY;
} else {
_opcode = OP_CODES.TEXT;
payload = Buffer.from(payload);
}
let length = payload.length;
let buffer = Buffer.alloc(2 + length);
buffer[0] = 0b10000000 | _opcode;
buffer[1] = length;
payload.copy(buffer, 2);
socket.write(buffer);
}
socket.on('data', (chunk) => {
if (chunk.toString().match(/Upgrade: websocket/)) {
this.upgrade(socket, chunk.toString());
} else {
this.onmessage(socket, chunk);
}
});
this.emit('connection', socket);
}
onmessage = (socket, chunk) => {
let FIN = (chunk[0] & 0b10000000) === 0b10000000;//判断是否是结束位,第一个bit是不是1
let opcode = chunk[0] & 0b00001111;//取一个字节的后四位,得到的一个是十进制数
let masked = (chunk[1] & 0b10000000) === 0b10000000;//第一位是否是1
let payloadLength = chunk[1] & 0b01111111;//取得负载数据的长度
let payload;
if (masked) {
let masteringKey = chunk.slice(2, 6);//掩码
payload = chunk.slice(6);//负载数据
unmask(payload, masteringKey);//对数据进行解码处理
}
if (FIN) {
switch (opcode) {
case OP_CODES.TEXT:
socket.emit('message', payload.toString());
break;
case OP_CODES.BINARY:
socket.emit('message', payload);
break;
default:
break;
}
}
}
upgrade = (socket, chunk) => {
let rows = chunk.split('\r\n');//按分割符分开
let headers = toHeaders(rows.slice(1, -2));//去掉请求行和尾部的二个分隔符
let wsKey = headers['Sec-WebSocket-Key'];
let acceptKey = toAcceptKey(wsKey);
let response = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
`Sec-WebSocket-Accept: ${acceptKey}`,
'Connection: Upgrade',
'\r\n'
].join('\r\n');
socket.write(response);
}
}
function toAcceptKey(wsKey) {
return crypto.createHash('sha1').update(wsKey + CODE).digest('base64');;
}
function toHeaders(rows) {
const headers = {};
rows.forEach(row => {
let [key, value] = row.split(': ');
headers[key] = value;
});
return headers;
}
function unmask(buffer, mask) {
const length = buffer.length;
for (let i = 0; i < length; i++) {
buffer[i] ^= mask[i & 3];
}
}
exports.Server = Server;
socket.io
- Socket.IO是一个WebSocket库,包括了客户端的js和服务器端的nodejs,它的目标是构建可以在不同浏览器和移动设备上使用的实时应用。
socket.io的特点
- 易用性:socket.io封装了服务端和客户端,使用起来非常简单方便。
- 跨平台:socket.io支持跨平台,这就意味着你有了更多的选择,可以在自己喜欢的平台下开发实时应用。
- 自适应:它会自动根据浏览器从WebSocket、AJAX长轮询、Iframe流等等各种方式中选择最佳的方式来实现网络实时应用,非常方便和人性化,而且支持的浏览器最低达IE5.5。
初步使用
安装部署
使用npm安装socket.io
$ npm install socket.io
启动服务
创建 app.js
文件
var express = require('express');
var path = require('path');
var app = express();
app.get('/', function (req, res) {
res.sendFile(path.resolve('index.html'));
});
var server = require('http').createServer(app);
var io = require('socket.io')(server);
io.on('connection', function (socket) {
console.log('客户端已经连接');
socket.on('message', function (msg) {
console.log(msg);
socket.send('sever:' + msg);
});
});
server.listen(80);
客户端引用
服务端运行后会在根目录动态生成socket.io的客户端js文件 客户端可以通过固定路径/socket.io/socket.io.js
添加引用
客户端加载socket.io文件后会得到一个全局的对象io
connect
函数可以接受一个url
参数,url可以socket服务的http完整地址,也可以是相对路径,如果省略则表示默认连接当前路径
创建index.html文件
<script src="/socket.io/socket.io.js"></script>
<script>
window.onload = function(){
const socket = io.connect('/');
//监听与服务器端的连接成功事件
socket.on('connect',function(){
console.log('连接成功');
});
//监听与服务器端断开连接事件
socket.on('disconnect',function(){
console.log('断开连接');
});
};
</script>
发送消息
成功建立连接后,我们可以通过socket
对象的send
函数来互相发送消息 修改index.html
var socket = io.connect('/');
socket.on('connect',function(){
//客户端连接成功后发送消息'welcome'
socket.send('welcome');
});
//客户端收到服务器发过来的消息后触发
socket.on('message',function(message){
console.log(message);
});
修改app.js
var io = require('scoket.io')(server);
io.on('connection',function(socket){
//向客户端发送消息
socket.send('欢迎光临');
//接收到客户端发过来的消息时触发
socket.on('message',function(data){
console.log(data);
});
});
深入分析
send方法
send
函数只是emit
的封装node_modules\socket.io\lib\socket.js
源码
function send(){
var args = toArray(arguments);
args.unshift('message');
this.emit.apply(this, args);
return this;
}
emit
函数有两个参数
- 第一个参数是自定义的事件名称,发送方发送什么类型的事件名称,接收方就可以通过对应的事件名称来监听接收
- 第二个参数是要发送的数据
服务端事件
事件名称 | 含义 |
---|---|
connection | 客户端成功连接到服务器 |
message | 接收到客户端发送的消息 |
disconnect | 客户端断开连接 |
error | 监听错误 |
客户端事件
事件名称 | 含义 |
---|---|
connect | 成功连接到服务器 |
message | 接收到服务器发送的消息 |
disconnect | 客户端断开连接 |
error | 监听错误 |
划分命名空间
服务器端划分命名空间
- 可以把服务分成多个命名空间,默认/,不同空间内不能通信 ```js
io.on(‘connection’, function (socket) { //向客户端发送消息 socket.send(‘/ 欢迎光临’); //接收到客户端发过来的消息时触发 socket.on(‘message’,function(data){ console.log(‘/’+data); }); }); io.of(‘/news’).on(‘connection’, function (socket) { //向客户端发送消息 socket.send(‘/news 欢迎光临’); //接收到客户端发过来的消息时触发 socket.on(‘message’,function(data){ console.log('/news '+data); }); });
### 5.2 客户端连接命名空间
```js
window.onload = function(){
var socket = io.connect('/');
//监听与服务器端的连接成功事件
socket.on('connect',function(){
console.log('连接成功');
socket.send('welcome');
});
socket.on('message',function(message){
console.log(message);
});
//监听与服务器端断开连接事件
socket.on('disconnect',function(){
console.log('断开连接');
});
var news_socket = io.connect('/news');
//监听与服务器端的连接成功事件
news_socket.on('connect',function(){
console.log('连接成功');
socket.send('welcome');
});
news_socket.on('message',function(message){
console.log(message);
});
//监听与服务器端断开连接事件
news_socket.on('disconnect',function(){
console.log('断开连接');
});
};
房间
- 可以把一个命名空间分成多个房间,一个客户端可以同时进入多个房间。
- 如果在大厅里广播 ,那么所有在大厅里的客户端和任何房间内的客户端都能收到消息。
- 所有在房间里的广播和通信都不会影响到房间以外的客户端
进入房间
socket.join('chat');//进入chat房间
离开房间
socket.leave('chat');//离开chat房间
全局广播
广播就是向多个客户端都发送消息
向大厅和所有人房间内的人广播
io.emit('message','全局广播');
向除了自己外的所有人广播
socket.broadcast.emit('message', msg);
socket.broadcast.emit('message', msg);
房间内广播
向房间内广播
从服务器的角度来提交事件,提交者会包含在内
//2. 向myroom广播一个事件,在此房间内包括自己在内的所有客户端都会收到消息
io.in('myroom').emit('message', msg);
io.of('/news').in('myRoom').emit('message',msg);
向房间内广播
从客户端的角度来提交事件,提交者会排除在外
//2. 向myroom广播一个事件,在此房间内除了自己外的所有客户端都会收到消息
socket.broadcast.to('myroom').emit('message', msg);
socket.broadcast.to('myroom').emit('message', msg);
获取房间列表
io.sockets.adapter.rooms
获取房间内的客户id值
取得进入房间内所对应的所有sockets的hash值,它便是拿到的socket.id
let roomSockets = io.sockets.adapter.rooms[room].sockets;
聊天室
- 创建客户端与服务端的websocket通信连接
- 客户端与服务端相互发送消息
- 添加用户名
- 添加私聊
- 进入/离开房间聊天
- 历史消息
app.js
//express+socket联合使用
//express负责 返回页面和样式等静态资源,socket.io负责 消息通信
let express = require('express');
const path = require('path');
let app = express();
app.get('/news', function (req, res) {
res.sendFile(path.resolve(__dirname, 'public/news.html'));
});
app.get('/goods', function (req, res) {
res.sendFile(path.resolve(__dirname, 'public/goods.html'));
});
let server = require('http').createServer(app);
let io = require('socket.io')(server);
//监听客户端发过来的连接
//命名是用来实现隔离的
let sockets = {};
io.on('connection', function (socket) {
//当前用户所有的房间
let rooms = [];
let username;//用户名刚开始的时候是undefined
//监听客户端发过来的消息
socket.on('message', function (message) {
if (username) {
//如果说在某个房间内的话那么他说的话只会说给房间内的人听
if (rooms.length > 0) {
for (let i = 0; i < rooms.length; i++) {
//在此处要判断是私聊还是公聊
let result = message.match(/@([^ ]+) (.+)/);
if (result) {
let toUser = result[1];
let content = result[2];
sockets[toUser].send({
username,
content,
createAt: new Date()
});
} else {
io.in(rooms[i]).emit('message', {
username,
content: message,
createAt: new Date()
});
}
}
} else {
//如果此用户不在任何一个房间内的话需要全局广播
let result = message.match(/@([^ ]+) (.+)/);
if (result) {
let toUser = result[1];
let content = result[2];
sockets[toUser].send({
username,
content,
createAt: new Date()
});
} else {
io.emit('message', {
username,
content: message,
createAt: new Date()
});
}
}
} else {
//如果用户名还没有设置过,那说明这是这个用户的第一次发言
username = message;
//在对象中缓存 key是用户名 值是socket
sockets[username] = socket;
socket.broadcast.emit('message', {
username: '系统',
content: `<a>${username}</a> 加入了聊天`,
createAt: new Date()
});
}
});
//监听客户端发过来的join类型的消息,参数是要加入的房间名
socket.on('join', function (roomName) {
let oldIndex = rooms.indexOf(roomName);
if (oldIndex == -1) {
socket.join(roomName);//相当于这个socket在服务器端进入了某个房间
rooms.push(roomName);
}
})
//当客户端告诉服务器说要离开的时候,则如果这个客户端就在房间内,则可以离开这个房间
socket.on('leave', function (roomName) {
let oldIndex = rooms.indexOf(roomName);
if (oldIndex != -1) {
socket.leave(roomName);
rooms.splice(oldIndex, 1);
}
});
socket.on('getRoomInfo', function () {
console.log(io);
//let rooms = io.manager.rooms;
console.log(io);
});
});
// io.of('/goods').on('connection', function (socket) {
// //监听客户端发过来的消息
// socket.on('message', function (message) {
// socket.send('goods:' + message);
// });
// });
server.listen(8080);
/**
* 1. 可以把服务分成多个命名空间,默认/,不同空间内不能通信
* 2. 可以把一个命名空间分成多个房间,一个客户端可以同时进入多个房间。
* 3. 如果在大厅里广播 ,那么所有在大厅里的客户端和任何房间内的客户端都能收到消息。
*/
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="stylesheet" href="https://cdn.bootcss.com/bootstrap/3.3.1/css/bootstrap.css">
<style>
.user {
color: green;
cursor: pointer;
}
</style>
<title>聊天室</title>
</head>
<body>
<div class="container">
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-default">
<div class="panel-heading text-center">
<div>
<button class="btn btn-danger" onclick="join('red')">进入红房间</button>
<button class="btn btn-danger" onclick="leave('red')">离开红房间</button>
</div>
<div>
<button class="btn btn-success" onclick="join('green')">进入绿房间</button>
<button class="btn btn-success" onclick="leave('green')">进入绿房间</button>
</div>
<div>
<button class="btn btn-primary" onclick="getRoomInfo()">
获取房间信息
</button>
</div>
</div>
<div class="panel-body">
<ul class="list-group" id="messages" onclick="clickUser(event)">
</ul>
</div>
<div class="panel-footer">
<div class="row">
<div class="col-md-10">
<input id="textMsg" type="text" class="form-control">
</div>
<div class="col-md-2">
<button type="button" onclick="send()" class="btn btn-primary">发言</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script>
let socket = io('/');
let textMsg = document.querySelector('#textMsg');
let messagesEle = document.querySelector('#messages');
socket.on('connect', function () {
console.log('客户端连接成功');
});
socket.on('message', function (messageObj) {
let li = document.createElement('li');
li.innerHTML = `<span class="user">${messageObj.username}</span>:${messageObj.content} <span class="text-right">${messageObj.createAt.toLocaleString()}</span>`;
li.className = 'list-group-item';
messagesEle.appendChild(li);
});
function send() {
let content = textMsg.value;
if (!content)
return alert('请输入聊天内容');
socket.send(content);
}
function join(name) {
//向后台服务器发送一个消息,join name是房间名
socket.emit('join2', name);
}
function leave(name) {
//向后台服务器发送一个消息,离开某个房间
socket.emit('leave3', name);
}
function getRoomInfo() {
socket.emit('getRoomInfo');
}
function clickUser(event) {
console.log('clickUser', event.target.className);
if (event.target.className == 'user') {
let username = event.target.innerHTML;
textMsg.value = `@${username} `;
}
}
</script>
</body>
</html>
聊天室
app.js
let express = require('express');
let http = require('http');
let path = require('path')
let app = express();
let mysql = require('mysql');
var connection = mysql.createConnection({
host: 'localhost',
user: 'root',
password: 'root',
database: 'chat'
});
connection.connect();
app.use(express.static(__dirname));
app.get('/', function (req, res) {
res.header('Content-Type', "text/html;charset=utf8");
res.sendFile(path.resolve('index.html'));
});
let server = http.createServer(app);
//因为websocket协议是要依赖http协议实现握手的,所以需要把httpserver的实例的传给socket.io
let io = require('socket.io')(server);
const SYSTEM = '系统';
//保存着所有的用户名和它的socket对象的对应关系
let sockets = {};
let mysockets = {};
let messages = [];//从旧往新旧的 slice
//在服务器监听客户端的连接
io.on('connection', function (socket) {
console.log('socket', socket.id)
mysockets[socket.id] = socket;
//用户名,默认为undefined
let username;
//放置着此客户端所在的房间
let rooms = [];
// 私聊的语法 @用户名 内容
socket.on('message', function (message) {
if (username) {
//首先要判断是私聊还是公聊
let result = message.match(/@([^ ]+) (.+)/);
if (result) {//有值表示匹配上了
let toUser = result[1];//toUser是一个用户名 socket
let content = result[2];
let toSocket = sockets[toUser];
if (toSocket) {
toSocket.send({
user: username,
content,
createAt: new Date()
});
} else {
socket.send({
user: SYSTEM,
content: `你私聊的用户不在线`,
createAt: new Date()
});
}
} else {//无值表示未匹配上
//对于客户端的发言,如果客户端不在任何一个房间内则认为是公共广播,大厅和所有的房间内的人都听的到。
//如果在某个房间内,则认为是向房间内广播 ,则只有它所在的房间的人才能看到,包括自己
let messageObj = {
user: username,
content: message,
createAt: new Date()
};
//相当于持久化消息对象
//messages.push(messageObj);
connection.query(`INSERT INTO message(user,content,createAt) VALUES(?,?,?)`, [messageObj.user, messageObj.content, messageObj.createAt], function (err, results) {
console.log(results);
});
if (rooms.length > 0) {
/**
socket.emit('message', {
user: username,
content: message,
createAt: new Date()
});
rooms.forEach(room => {
//向房间内的所有的人广播 ,包括自己
io.in(room).emit('message', {
user: username,
content: message,
createAt: new Date()
});
//如何向房间内除了自己之外的其它人广播
socket.broadcast.to(room).emit('message', {
user: username,
content: message,
createAt: new Date()
});
});
*/
let targetSockets = {};
rooms.forEach(room => {
let roomSockets = io.sockets.adapter.rooms[room].sockets;
console.log('roomSockets', roomSockets);//{id1:true,id2:true}
Object.keys(roomSockets).forEach(socketId => {
if (!targetSockets[socketId]) {
targetSockets[socketId] = true;
}
});
});
Object.keys(targetSockets).forEach(socketId => {
mysockets[socketId].emit('message', messageObj);
});
} else {
io.emit('message', messageObj);
}
}
} else {
//把此用户的第一次发言当成用户名
username = message;
//当得到用户名之后,把socket赋给sockets[username]
sockets[username] = socket;
//socket.broadcast表示向除自己以外的所有的人广播
socket.broadcast.emit('message', { user: SYSTEM, content: `${username}加入了聊天室`, createAt: new Date() });
}
});
socket.on('join', function (roomName) {
if (rooms.indexOf(roomName) == -1) {
//socket.join表示进入某个房间
socket.join(roomName);
rooms.push(roomName);
socket.send({
user: SYSTEM,
content: `你成功进入了${roomName}房间!`,
createAt: new Date()
});
//告诉客户端你已经成功进入了某个房间
socket.emit('joined', roomName);
} else {
socket.send({
user: SYSTEM,
content: `你已经在${roomName}房间了!请不要重复进入!`,
createAt: new Date()
});
}
});
socket.on('leave', function (roomName) {
let index = rooms.indexOf(roomName);
if (index == -1) {
socket.send({
user: SYSTEM,
content: `你并不在${roomName}房间,离开个毛!`,
createAt: new Date()
});
} else {
socket.leave(roomName);
rooms.splice(index, 1);
socket.send({
user: SYSTEM,
content: `你已经离开了${roomName}房间!`,
createAt: new Date()
});
socket.emit('leaved', roomName);
}
});
socket.on('getAllMessages', function () {
//let latestMessages = messages.slice(messages.length - 20);
connection.query(`SELECT * FROM message ORDER BY id DESC limit 20`, function (err, results) {
// 21 20 ........2
socket.emit('allMessages', results.reverse());// 2 .... 21
});
});
});
server.listen(8080);
/**
* socket.send 向某个人说话
* io.emit('message'); 向所有的客户端说话
*
*/
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u"
crossorigin="anonymous">
<style>
.user {
color: red;
cursor: pointer;
}
</style>
<title>socket.io</title>
</head>
<body>
<div class="container" style="margin-top:30px;">
<div class="row">
<div class="col-xs-12">
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="text-center">欢迎来到珠峰聊天室</h4>
<div class="row">
<div class="col-xs-6 text-center">
<button id="join-red" onclick="join('red')" class="btn btn-danger">进入红房间</button>
<button id="leave-red" style="display: none" onclick="leave('red')" class="btn btn-danger">离开红房间</button>
</div>
<div class="col-xs-6 text-center">
<button id="join-green" onclick="join('green')" class="btn btn-success">进入绿房间</button>
<button id="leave-green" style="display: none" onclick="leave('green')" class="btn btn-success">离开绿房间</button>
</div>
</div>
</div>
<div class="panel-body">
<ul id="messages" class="list-group" onclick="talkTo(event)" style="height:500px;overflow-y:scroll">
</ul>
</div>
<div class="panel-footer">
<div class="row">
<div class="col-xs-11">
<input onkeyup="onKey(event)" type="text" class="form-control" id="content">
</div>
<div class="col-xs-1">
<button class="btn btn-primary" onclick="send(event)">发言</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa"
crossorigin="anonymous"></script>
<script>
let contentInput = document.getElementById('content');//输入框
let messagesUl = document.getElementById('messages');//列表
let socket = io('/');//io new Websocket();
socket.on('connect', function () {
console.log('客户端连接成功');
//告诉服务器,我是一个新的客户,请给我最近的20条消息
socket.emit('getAllMessages');
});
socket.on('allMessages', function (messages) {
let html = messages.map(messageObj => `
<li class="list-group-item"><span class="user">${messageObj.user}</span>:${messageObj.content} <span class="pull-right">${new Date(messageObj.createAt).toLocaleString()}</span></li>
`).join('');
messagesUl.innerHTML = html;
messagesUl.scrollTop = messagesUl.scrollHeight;
});
socket.on('message', function (messageObj) {
let li = document.createElement('li');
li.className = "list-group-item";
li.innerHTML = `<span class="user">${messageObj.user}</span>:${messageObj.content} <span class="pull-right">${new Date(messageObj.createAt).toLocaleString()}</span>`;
messagesUl.appendChild(li);
messagesUl.scrollTop = messagesUl.scrollHeight;
});
// click delegate
function talkTo(event) {
if (event.target.className == 'user') {
let username = event.target.innerText;
contentInput.value = `@${username} `;
}
}
//进入某个房间
function join(roomName) {
//告诉服务器,我这个客户端将要在服务器进入某个房间
socket.emit('join', roomName);
}
socket.on('joined', function (roomName) {
document.querySelector(`#leave-${roomName}`).style.display = 'inline-block';
document.querySelector(`#join-${roomName}`).style.display = 'none';
});
socket.on('leaved', function (roomName) {
document.querySelector(`#join-${roomName}`).style.display = 'inline-block';
document.querySelector(`#leave-${roomName}`).style.display = 'none';
});
//离开某个房间
function leave(roomName) {
socket.emit('leave', roomName);
}
function send() {
let content = contentInput.value;
if (content) {
socket.send(content);
contentInput.value = '';
} else {
alert('聊天信息不能为空!');
}
}
function onKey(event) {
let code = event.keyCode;
if (code == 13) {
send();
}
}
</script>
</body>
</html>
参考
- socket.io