如何用 Socket.IO 构建实时聊天应用?
本篇文章将深入探讨 WebsSockets,着眼于 HTTP 和 web 的发展历程以及在现代 Web 的基础上使用 WebSockets。我会列出构建实时聊天应用的 16 个步骤,帮助大家积累实战经验,如果你在 web 开发方面没有丰富的经验,我强烈建议你花 20 分钟仔细阅读一下本文。本项目会用到 Node.js、一些 vanilla JavaScript 编程语言和一个预先构建的前端。

我们已经习惯了 app 在用户不知情的情况下自动发送/接收海量数据。

回想一下,你上次刷新喜欢的 app 或网站是什么时候呢?

无论是打游戏、等邮件、刷新 twitter 简讯还是使用即时通讯 app,我们都期待数据能根据我们的需要,从服务器中发出,通过深层过滤和优化后再显示在我们的设备屏幕上。

但这对于不断提高需求的用户来说仍旧不够,我们希望能不刷新页面,就能把数据发送回服务器并显示在屏幕上。

如果你觉得这不算啥,那是因为实时体验早已与技术融为一体,非常普遍,但这并不意味着它简单。

即时响应 app 为用户提供了非常流畅的体验,他们将最新的消息即时且持续地反馈给客户,无论是加密货币的实时价格、与朋友的聊天消息还是在线游戏。

我们现在对 app 的即时性习以为常,但要知道之前的 Web 用户只有刷新浏览器才能访问新内容,非常繁琐,而且浪费时间。

假设 45 亿 web 用户都要刷​​新页面来查看新内容,每人每天光刷新页面就花费十秒,这必然会降低效率,如果把所有人浪费的时间加在一起,那每天将浪费 450 亿秒,也就是 1,427 年!

这样算就有概念了吧?

到底是哪种伟大的技术让 web 实现奇迹般的、即时的、双向的通信行为呢?我们该感谢谁呢?要说清这一点,我们得先说点题外话。


HTTP 1.0 和早期的 Web

Hypertext Transfer Protocol(简称 HTTP)是将数据传输到网络的规则集和信息系统,简言之, HTTP 1.0 包含了客户端和服务器之间的请求-响应循环。关于 HTTP 1.0 和请求-响应循环有几个要点需要说明一下。

第一、请求都是由客户端发起的。

第二、一个来自客户端的(成功的)请求总是会得到一个来自服务器的响应,操作完成后,客户端和服务器之间的连接就会关闭。

假如一个用户想在聊天 app 上给同一个人发送 100 条消息,如果他们都在使用 HTTP 1.0,那就意味着分别要有 100 条请求-响应循环。至于那个要接收一百条叨逼叨的幸运儿,他们只能不断刷新页面来接收消息。

问题就出在这里。因为服务器和客户端之间的连接会在每次请求-响应循环结束时失效,通常服务器不会保留用户之前请求和所见状态的信息,因此 HTTP 又被称为无状态协议

当我们想看页面上的静态内容时,比如浏览维基百科的文章或查看新闻,这种无状态行为还是很有用的。在这些案例中,HTTP 的无状态性能最大限度地减少需要传输的数据,并能一次性渲染所有重要内容,而且在接收到客户端的单独请求前什么都不用做。

当然了,也有其他方法存储用户和他们的浏览会话状态,有人知道 Cookies 吗?

正如我们之前讲到的,有许多情况是需要服务器保存应用程序状态信息的,所以我们希望服务器和客户端能够相互独立地提出数据请求和接收数据,而不需要任何轮换或先后顺序。

引入 AJAX 后,用户无需重新加载页面就能向服务器提交请求并接收响应,这有利于提升用户的无缝体验,但这并不能让服务器在不被请求的情况下发送数据,也没有解决多个客户端和服务器之间长期连接的问题。

这时候就轮到 WebSockets 出场了:

WebSocket 可以让客户端和服务器之间保持连接状态,而传统上的服务器会在响应客户端请求后会关闭连接。


实时 web:HTTP 1.1 和 WebSockets

为解决以上问题,2008 年开发人员 Michael CarterIan 和 Hickson 开始开发 WebSockets。

WebSockets 包含两部分:一是一套关于客户端和服务器的规则集,该规则集是关于如何建立通信和互相传输数据的;另一个是用于数据交换的传输层。WebSockets 支持交换不同数据格式,其中包括 JSON XML

一个正常的 HTTP 1.0 请求是客户端向服务器发起请求。客户端通知服务器它要用特定的资源进行某种操作,还发送了能找到该资源的 url 的信息,这个信息包含在请求标头中,附带的还有将要使用的 HTTP 协议的显式语句(本例为 1.0)。

然后,服务器会发送一个带有状态码的响应,该状态码除了本身内容之外,还提供有关请求成功和所发送内容的信息。

所有操作都是通过 TCP/IP socket 上进行的。WebSockets 建立在 TCP layer 层之上(一种数据传输协议,在发送任何数据之前都依赖于两个连接的主机),并对 TCP/IP socket 进行更改,从而使客户端和服务器同意 socket 保持开放。

使 socket 保持开放是启动双向通信的重要步骤,如果没有这一步,就不会有 WebSockets 了。使用 web 仍会围绕着请求-响应循环,用户也始终需要向服务器发起请求来处理数据。

在持续连接的条件下,接下来要做的是双方就如何中断交换的数据一事达成一致,这就是 WebSocket 握手。

WebSocket 握手类似于简单的 HTTP GET 请求,但该请求包含一个“升级标头”,该标头要求服务器切换到使用 WebSocket 的二进制协议,并提供一些关于 WebSocket 连接的信息。服务器用一个 101 标头响应,以确认它正在交换协议以及 WebSocket 是开着的,然后现在就可以实现多个客户端和服务器之间的实时数据交换了。

以下图片显示了一个日常用的 HTTP GET 请求标头。我们可以看到该连接使用了 HTTP 1.1,最后“连接”被设置成“keep alive”。大家应该还记得此功能是在 HTTP 1.1 中引用的,它可以让客户端和服务器之间一直连接来保持开放,这构成了 WebSockets 的基础。

1_tiIwSe9kZpjm6rUQxFwUKw

一个普通的 HTTP 请求标头

接下来,我们看到的是“升级标头”的示例。首先,请求 URL 的前缀是“ws://”而不是“ HTTP://”,这表示我们正在请求使用的是 WebSockets 协议而不是 HTTP。我们还能看见一个“连接”标头和一个“升级”标头,前者尝试将连接升级成二进制协议,而后者则允许客户端指定他们想要升级的协议(本例中为 WebSocket 协议)。
1_QrWxG-eAfoAiUx0i5XbPyQ

如果请求通过,WebSocket 连接就建好了。服务器可以通过 WebSocket 对足球比分进行实时更新、接收客户端朋友的消息等等。不仅如此,它还能在客户端未发起请求的情况下实现这一点,并且可以反复执行。

同样,在 WebSocket 握手和升级过程中,只要一直保持连接并且所发送的请求与双方达成的协议一致,客户端就可以发送任何内容。

我们不能忽视这项技术创新的意义,因为它改变了我们使用 web 的方式,并在技术娱乐和服务层面为我们提供了大量的创新。

这些成果先要归功于 WebSocket 握手程序,而从客户端到服务器的包含一个升级标头的请求让成果更加完美,另外,双方都同意保持 TCIP/IP socket 的打开状态,从而保持持续的连接。

那么要想实施这项技术会涉及到哪些内容呢?我看了一个流行的 WebSocket 库,领教到了实施 WebSocket 的难度。


使用 Socket.IO 的现代 Web 开发中的 WebSockets

Socket.IO 是支持用户在项目中使用 WebSockets 的最受欢迎的库之一 。但 Socket.IO 到底是什么?以下是其网站的介绍:

“ Socket.IO 是一个能在浏览器和服务器之间进行实时、双向以及基于事件通信的库,它包括:
-Node.js 服务器:Source
-浏览器的 Javascript 客户端库(也可以从 Node.js 运行)”

最简单的方法是 Socket.IO 在 node 中找到的标准 WebSockets API 周围添加了一个语法“包装器”,使阅读和操作更容易。

我很喜欢阅读 Socket.IO 的文档,在“Socket.IO 不是什么?”一节中写道,Socket.IO 不是 WebSocket 的实现。

那它到底是个啥?

使用 Socket.IO 的目的是实现“浏览器和服务器之间的实时、双向和基于事件的通信”。大多数情况下,这意味着它会为我们提供比 Node 随附的 WebSocket API 更实用的接口。

但是,有时很难建立 WebSocket,例如存在代理服务和负载均衡器的情况下。在这些情况下,Socket.IO 会用 Engine.IO 在服务器和客户端之间建立一个长轮询连接,并同时尝试升级成像 WebSocket 这样更好的传输方式。

简言之,长轮询是保持客户端与服务器之间紧密连接的有效方法。在长轮询中,客户端向服务器发送一个请求,如果服务器没有响应,两者之间的连接是不会断开的。在响应并断开连接后,浏览器会迅速发送请求并重新打开连接。尽管这确实为客户端和服务器之间建立了持续连接的印象,却没有真正实现双向通信,实用性远不如 WebSockets。

快速浏览 Socket.IO 的“发送备忘单”,可以了解使用 Socket.IO 的 WebSockets 的简单性和多功能性。
1_hTIVNoxFUXiUrzsRe1cpLg

“发送备忘单”显示了 Socket.IO 的某些功能


使用 Socket.IO 的一些反思

我对 Socket.IO 的第一反应是使用起来十分简单,仅需几个步骤就能创造出无限可能。

在服务器端:

1.需要 Web 服务器,并传入你要监听的端口;

2.监听连接;

3.在连接主体内发送事件、监听特定事件、向除发送方以外的所有客户端进行传播等等。

而客户端只需要:

  1. 创建一个 WebSocket 并监听托管 app 的端口;
  2. 为个别事件设置事件监听器;
  3. 当客户端希望将数据发送回服务器时,创建事件发射器。

的确,使用 WebSockets 似乎根本没什么好处。简言之,我们简单导入 Socket.IO 库,并设置我们要监听的端口,就可以创建一个服务端的 Socket 对象。把 Socket 对象记录到控制台,我们就可以很好地了解它。
1_lf-EL85eohPu36yBCnWtcQ

1_niXa5RWGd2hHJQke8TjuTg

WebSockets 依靠 ‘ws’(websockets API)来实现 WebSockets,并通过 Engine.IO 建立一个长轮询连接。

1_14LW3Jys22kDGe9S2dwsNA

Socket 对象揭示了很多客户端和服务器之间的信息

从以上图中我们可以看出,Socket 对象包含大量客户端和服务之间的信息,以及事件处理、错误处理等方法。我们可以看见连接到服务器的客户端的数量,还能看见每个 socket 都有唯一的 ID。


实践:用 Socket.IO 构建实时聊天应用程序

为了了解更多有关 Socket.IO 的信息,我参考了 Web Dev Simplified 的优质教程该教程用 Socket.IO 来构建实时聊天 app。我将此过程压缩成 16 个步骤来进行讲解。

因为这项活动专注于 WebSockets 的实施,所以我们不会太注重前端的逻辑,但值得注意的是为此类 app 构建前端有很多理想的方案。此处我们使用的是预制 index.html 前端。

为了运行前端代码,对于 Visual Studio 代码我使用了十分给力的 Live Server 扩展程序。

1.首先,你需要创建一个项目文件夹,在上面链接中抓取 index.html 并放入项目文件夹中。用 Live Server 启动前端服务器然后在浏览器中查看 html。

2.要想使项目运行正常,我们需要一些依赖项。通过运行命令行中的 npm init 将你的项目设置为节点项目;然后运行 npm i --save-dev nodemon 后的 npm i socket.io 来安装 Socket.IO 和 Nodemon,并作为开发依赖项;每当我们在后端做出调整时,Nodemon 都会重新启动服务器。

3.在 package.json 文件中,用以下代码块替换整个 "scripts" 部分,这会允许我们通过运行命令行中的 npm run devStart 启动服务器。

"scripts": {
  "devStart": "nodemon server.js"
},

4.新建一个包含服务器代码的 server.js 文件。

5.现在到有趣的部分了。打开 server.js 文件,用 require 语法导入 Socket.IO。 Socket.IO 可以让我们把想要服务器运行的端口作为 require 语句的一部分,这很简要明了。

const io = require('socket.io')(3000);

6.现在我们已经建完一个服务器并且可以监听连接了。用 Socket.IO 简单的 .on 方法,我们可以监听连接事件并为每个建立连接的个体用户打开 WebSocket。服务器能使用 socket.emit 方法通过 socket 向每个用户 emit 响应,第一个参数是事件的名称,第二个则是数据。在 Socket.IO 中,有很多不能用来命名事件的保留的事件名称。除了这些名称之外,你可以随意命名任何事件,这能帮助我们写出易于理解的服务器代码。在以下示例中,从客户端到 WebSocket 的每个连接,我们都返回一个名为 ‘chat-message’ 且带有 ‘Hello world’ 文本的字符串。

io.on('connection', socket => {
  socket.emit('chat-message', 'Hello World')
});

7.我们已经能在服务器端的监听连接了,现在我们需要的是处理客户端的连接。我们先新建一个能存储客户端 javascript 的 script.js 文件。在这个文件中,通过传输 url 简单创建一个 socket 连接,url 把我们的 app 托管到 Socket.IO 特定的 io() 函数,并且该函数在全局范围内可使用。

const socket = io('http://localhost:3000')

8.现在客户端 socket 连接已经设置完了,接下来我们可以用上文提到的 .on 方法监听之前命名的 ‘chat-message’ 事件,并传入一个回调函数 ,该函数将我们从服务器端传来的数据 (字符串‘hello world’)作为参数,将这段代码添加到 scripts.js 文件中。

socket.on('chat-message', data => {
  console.log(data)
});

9.为了是用户可以彼此发送消息,我们需要在用户按下发送或提交时捕获输入字段中的数据。我们通过获取输入字段、提交按钮并添加事件监听器对客户端进行处理。防止默认的提交行为(页面刷新)阻止页面重新加载和消息被擦除这件事十分重要。我们在事件监听器回调中执行此操作。

const messageForm = document.getElementById('send-container')
messageForm.addEventListener('submit', e => {
  e.preventDefault()
})

10.同样,在事件侦听器回调中,在重复步骤 6 中的 socket.emit 行为之前,我们从输入字段中获取值,这会将消息数据从客户端发送回服务器端,和之前事件的操作一样。我们将该事件命名为 ‘send-chat-message’ 。最后,我们将输入字段的值重置为空字符串,并将其清除。

const messageForm = document.getElementById('send-container')
const messageInput = document.getElementById('message-input')
messageForm.addEventListener('submit', e => {
   e.preventDefault();
   const message = messageInput.value
   socket.emit('send-chat-message', message)
   messageInput.value = '';  
})

11.我们已经从客户端向包含消息字符串的 server.js 发出了一个事件。现在,我们需要使用熟悉的 .on 在服务器端监听事件。然后,我们可以对此事件进行处理,并使用 .broadcast.emit 将事件发送给除发送者以外的所有客户端。

io.on('connection', socket => {
  socket.on('send-chat-message', message => {
    socket.broadcast.emit('chat-message', message)
  })
});

12.返回到 script.js 中的客户端,添加一个函数,该函数在每次发送消息时都能将消息追加到 DOM。我们还需要提示用户提供其姓名,以便能够识别每条消息的来源。然后,我们从客户端 emit 此事件,表明新用户已加入聊天。我们将此事件称为 ‘new-user’ ,并连同从 JavaScript 提示中捕获的用户名一起 emit

在我们的通话 io(‘http://localhost:3000')下面以下内容:

const name = prompt('What is your name?');
appendMessage('You joined');
socket.emit('new-user', name)

13.要在服务器端处理 ‘new-user’ 事件,我们需要再一次使用 .on 方法监听事件。因为每个 WebSocket 都有自己的唯一 ID,所以我们可以把它当做每个用户的唯一引用,并将其存储在 users 对象上。现在,我们可以通过查看 users 对象来浏览 socket ID 旁边存储的名称,从而获得用户名。然后,我们使用 socket 上的另一个 .broadcast.emit 用户将此用户的连接广播给所有其他用户,并发出一个名为 ‘user-connected’ 和名称的事件。我们还将更新 ‘chat-message’ 事件,以传递包含消息和用户名的对象。

const io = require('socket.io')(3000);
const users = {}
io.on('connection', socket => {
  socket.on('new-user', name => {
    users[socket.id] = name;
    socket.broadcast.emit('user-connected', name)
  })
  socket.on('send-chat-message', message => {
    socket.broadcast.emit('chat-message', 
    {message, name:  users[socket.id]})
  })
});

14.现在,我们可以在客户端使用这些更改。修改 socket.on 事件以在接收 ‘chat-message’ 聊天时附加在消息和用户名。我们还希望在消息表单上升级事件侦听器,以便 DOM 也能向用户显示他们自己的消息。

socket.on('chat-message', data => {
  appendMessage(`${data.name}: ${data.message}`)
})
messageForm.addEventListener('submit', e => {
  e.preventDefault();
  const message = messageInput.value
  appendMessage(`You: ${message}`)
  socket.emit('send-chat-message', message)
  messageInput.value = '';
})

15.最后,我们要学会如何处理用户掉线的情况。在 server.js 内部,我们使用可靠的 .on 方法,但这次我们监听的是一个名为 ‘disconnect’ 的内置事件。传递一个匿名函数,在该函数中我们发出一个事件将其命名为 ‘user-disconnected’ 、并在传递用户名字的同时引用 users 对象上相应的 socket.id 属性。然后,我们使用 delete 关键字将用户从 users 对象中删除。

server.js 的最终版本应如下所示:

const io = require('socket.io')(3000);
const users = {}
io.on('connection', socket => {
  socket.on('new-user', name => {
    users[socket.id] = name;
    socket.broadcast.emit('user-connected', name)
  })
  socket.on('send-chat-message', message => {
    socket.broadcast.emit('chat-message', 
    {message, name:     users[socket.id] } )
  })
  socket.on('disconnect', () => {
    socket.broadcast.emit('user-disconnected', users[socket.id])
    delete users[socket.id]
  })
});

16.至此,我们应该能提前预测到相应客户端的变化了。我们用 .on 来监听 ‘user-disconnected’ 事件,并在 DOM 上附加一条宣布用户离开的消息。如果现在你的 script.js 看起来像下面的代码块,那么恭喜你,仅用 16 个步骤就构建了一个实时聊天应用程序。

现在,你的 script.js 文件应如下所示:

script.js
const messageForm = document.getElementById('send-container')
const messageInput = document.getElementById('message-input')
const messageContainer = document.getElementById('message-container')
const socket = io('http://localhost:3000')
const name = prompt('What is your name?');
appendMessage('You joined');
socket.emit('new-user', name)
socket.on('chat-message', data => {
  appendMessage(`${data.name}: ${data.message}`)
})
socket.on('user-connected', name => {
  appendMessage(`${name} connected`)
})
socket.on('user-disconnected', name => {
  appendMessage(`${name} disconnected`)
})
messageForm.addEventListener('submit', e => {
  e.preventDefault();
  const message = messageInput.value
  appendMessage(`You: ${message}`)
  socket.emit('send-chat-message', message)
  messageInput.value = '';
})
function appendMessage(message) {
  const messageElement = document.createElement('div');
  messageElement.innerText = message;
  messageContainer.append(messageElement);
}


使用 Socket.IO 的几点思考

作为 Web 开发的新手,学习一种新技术并真正实施起来似乎是一项艰巨的任务。

WebSocket API(例如 WebSocket 或执行类似功能的库,例如 Socket.IO)的魅力在于,它们简化了所有在客户端与服务器之间建立持久连接的复杂性。

以我们的聊天 app 为例。尽管它还有缺陷,但事实上我们仅用 16 个步骤以及.on.emit 、和 .broadcast 这三个 WebSocket 方法就能让很多用户加入聊天了。

显然,WebSockets 不仅在支持现代 Web 体验方面非常有用,而且还易于操作。诸如 Socket.IO 之类的库提升了用户体验,其便捷的文档使很多问题都迎刃而解。

我很高兴接触到了 WebSockets,并且也很高兴使用 Socket.IO 直观又好用的库进行了此操作。我期待将来可以用 Socket.IO 库进一步探索其他项目。

我的实时聊天应用



原文作者:James Brown
原文链接:https://medium.com/@jamesbrown5292/what-i-learned-about-websockets-by-building-a-real-time-chat-application-using-socket-io-3d9e163e504


推荐阅读
相关专栏
开发者实践
182 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和声网 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。