当前位置: 首页 > news >正文

web端即时通信技术

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发送一个脚本;

  • 通过发送的脚本,主动执行父页面中的方法,达到推送的效果。

iframeflow

简单实现

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的长轮询底层还是长轮询技术,只是实现方式不同,而且在浏览器上会显示请求未加载完成,图标会不停旋转,简直是强迫症杀手,个人不是很推荐。

iframe长轮询

Server-Sent Events(SSE)

  • HTML5规范中提供了服务端事件EventSource,浏览器在实现了该规范的前提下创建一个EventSource连接后,便可收到服务端的发送的消息,这些消息需要遵循一定的格式,对于前端开发人员而言,只需在浏览器中侦听对应的事件皆可

  • SSE的简单模型是:一个客户端去从服务器端订阅一条,之后服务端可以发送消息给客户端直到服务端或者客户端关闭该“流”,所以eventsource也叫作server-sent-event

  • EventSource流的实现方式对客户端开发人员而言非常简单,兼容性良好

  • EventSourceSSE客户端接口

  • 对于服务端,它可以兼容老的浏览器,无需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的协议

img

img

客户端:申请协议升级
  • 首先客户端发起协议升级请求
  • 请求采用的是标准的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-AcceptAccept字符串
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)

img

位运算符
按位与(&)
  • 两个输入数的同一位都为1才为1

img

按位或(|)
  • 两个输入数的同一位只要有一个为1就是1

img

按位异或(^)
  • 两个输入数的同一位不同就是1,如果相同就设为0

img

数据帧格式
  • 单位是比特 比如FIN、RSV1各占据1比特,opcode占据4比特

img

  0                   1                   2                   30 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 ...                |+---------------------------------------------------------------+
字段含义
FIN1个比特 如果是1,表示这是消息(message)的最后一个分片(fragment),如果是0,表示不是是消息(message)的最后一个分片(fragment)
RSV1, RSV2, RSV3各占1个比特。一般情况下全为0
Opcode4个比特,操作代码
Mask1个比特。表示是否要对数据载荷进行掩码操作,从客户端向服务端发送数据时,需要对数据进行掩码操作;从服务端向客户端发送数据时,不需要对数据进行掩码操作,如果Mask是1,那么在Masking-key中会定义一个掩码键(masking key),并用这个掩码键来对数据载荷进行反掩码。所有客户端发送到服务端的数据帧,Mask都是1
Payload length数据载荷的长度
Masking-key0或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得到结果并对原来的索引进行异或操作

img

img

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是不是1let opcode = chunk[0] & 0b00001111;//取一个字节的后四位,得到的一个是十进制数let masked = (chunk[1] & 0b10000000) === 0b10000000;//第一位是否是1let 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是用户名 值是socketsockets[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;//用户名,默认为undefinedlet username;//放置着此客户端所在的房间let rooms = [];// 私聊的语法 @用户名 内容socket.on('message', function (message) {if (username) {//首先要判断是私聊还是公聊let result = message.match(/@([^ ]+) (.+)/);if (result) {//有值表示匹配上了let toUser = result[1];//toUser是一个用户名 socketlet 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 delegatefunction 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

相关文章:

  • Python for循环中的引用传递和值传递
  • Redis 面试热点(二)
  • 每日一练:攻防世界:Ditf
  • Golang并发控制的三种方案
  • 一文理清GO语言日志库实现开发项目中的日志功能(rotatelogs/zap分析)
  • 基于多头注意力机制卷积神经网络结合双向门控单元CNN-BIGRU-Mutilhead-Attention实现柴油机故障诊断附matlab代码
  • MongoDB~高可用集群介绍:复制集群(副本集)、分片集群
  • SQL MAX() 函数深入解析
  • PyQt5设计登录跳转界面
  • 使用net.sf.mpxj读取project的.mpp文件
  • 文件操作(2)(C语言版)
  • Tuple 元组
  • MAX30102驱动
  • 【ajax基础03】常用ajax请求方法和数据提交以及axios错误处理
  • Java23种设计模式(二)
  • [deviceone开发]-do_Webview的基本示例
  • docker python 配置
  • Laravel5.4 Queues队列学习
  • markdown编辑器简评
  • Netty 框架总结「ChannelHandler 及 EventLoop」
  • nodejs:开发并发布一个nodejs包
  • Otto开发初探——微服务依赖管理新利器
  • quasar-framework cnodejs社区
  • react-native 安卓真机环境搭建
  • vue-loader 源码解析系列之 selector
  • 强力优化Rancher k8s中国区的使用体验
  • 如何用vue打造一个移动端音乐播放器
  • 软件开发学习的5大技巧,你知道吗?
  • 少走弯路,给Java 1~5 年程序员的建议
  • 使用Maven插件构建SpringBoot项目,生成Docker镜像push到DockerHub上
  • 微服务入门【系列视频课程】
  • 写代码的正确姿势
  • 学习HTTP相关知识笔记
  • 一起来学SpringBoot | 第三篇:SpringBoot日志配置
  • 一些css基础学习笔记
  • 云栖大讲堂Java基础入门(三)- 阿里巴巴Java开发手册介绍
  • 3月27日云栖精选夜读 | 从 “城市大脑”实践,瞭望未来城市源起 ...
  • 整理一些计算机基础知识!
  • #微信小程序(布局、渲染层基础知识)
  • (3)选择元素——(14)接触DOM元素(Accessing DOM elements)
  • (delphi11最新学习资料) Object Pascal 学习笔记---第13章第1节 (全局数据、栈和堆)
  • (Mac上)使用Python进行matplotlib 画图时,中文显示不出来
  • (附源码)springboot学生选课系统 毕业设计 612555
  • (亲测成功)在centos7.5上安装kvm,通过VNC远程连接并创建多台ubuntu虚拟机(ubuntu server版本)...
  • (图)IntelliTrace Tools 跟踪云端程序
  • (幽默漫画)有个程序员老公,是怎样的体验?
  • (轉貼) VS2005 快捷键 (初級) (.NET) (Visual Studio)
  • ... 是什么 ?... 有什么用处?
  • .bat批处理(一):@echo off
  • .net core 6 使用注解自动注入实例,无需构造注入 autowrite4net
  • .net6Api后台+uniapp导出Excel
  • ?php echo $logosrc[0];?,如何在一行中显示logo和标题?
  • @JoinTable会自动删除关联表的数据
  • @private @protected @public
  • [ C++ ] STL---仿函数与priority_queue