如何通过Socket.IO构建实时聊天应用程序

在本篇文章中,我将走进WebsSockets世界、着眼于HTTP和web的发展历程以及在现代Web的基础上 如何 使用WebSockets。我将会列举要构建实时聊天应用程序的16个步骤,帮助你积累 实战 经验,我十分推荐那些在这方面经验并不丰富的人花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支持交换不同数据格式,其中包括JSONXML

一个正常的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 头响应,以确认它正在交换协议以及WebSocke是开着的,然后现在就可以实现多个客户端和服务器之间的实时数据交换了。

以下图片显示了一个日常用的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

吸引用户在项目中使用WebSockets并且最受欢迎的库之一的是Socket.IO。但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’ 事件,并传入一个回调函数 ,该函数将我们从服务器端传来的数据 (字符串)作为参数,将这段代码添加到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

推荐阅读
作者信息
AgoraTechnicalTeam
TA 暂未填写个人简介
文章
140
相关专栏
本专栏仅用于分享音视频相关的技术文章,与其他开发者和 Agora 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。