express和socket.io搭配,用户多点登陆,一处更新时通知其它客户端

在实际项目中有这样一种需求:app允许同一个用户多点登陆,登陆后用户可能进行某些操作,这些操作可能影响数据库,同时理论上讲会影响界面,而如果是多点登陆,按传统的架构,如果用户A进行来某些操作,改变来数据库数据,用户B的用户界面是不会有任何变化,用户B会继续操作并提交数据,这时可能提交上来的数据是A改过之前对,导致A的修改反而被B提交的老数据覆盖了。

友好的操作是,当A对数据库进行提交,并影响到B时,应该通知B,让B选择是否采用新数据。这种友好的提示被广泛使用。那么在我们的express应用中如何实现呢?这篇文章就利用express和socket.io搭配,实现一套这个需求的逻辑。

在express应用中加入socket.io

大多数前端er都知道socket.io,它是一个基于websocket的库,你可以用它来方便的实现一些socket需求。对于不支持websocket的浏览器,它也支持降级到ajax轮询来解决。

对于一个应用而言,socket的实现有服务端和客户端两个部分,因此socket.io有两个package,socket.io这个包是用在node端做server使用的,而socket.io-client这个包是在前端做client使用。所以在你的应用中需要同时安装这两个包,一个用在server代码里,一个用在client代码里。

server端的代码

我们server端的应用是express搭建起来的,但是socket.io需要使用http模块创建的纯server,因此我们将express创建的app传给http.Server来获得一个纯server:

import express from "express";
import http from "http";
import socketIoServer from "socket.io";
const app = express();
const server = http.Server(app);
const io = new socketIoServer(server);
io.on("connection", (socket) => { ... });
server.listen(3000);

这是一个大致的框架,中间省略来app的各种路由,以及io的connection里面的内容。这段代码告诉我们如何让express的应用启动时,同时启动socket.io创建的服务。

client端的代码

客户端的socket.io在api上和服务端的非常像,只不过由于宿主环境不同,所以要在内部实现上采用不同的方式,因此分来两个包来做。而且如果了解网络握手的相关知识,大致可以知道建立socket连接都要走哪些网络连接的步骤。我们来看下client端代码:

import socketIoClient from 'socket.io-client';
let io = new socketIoClient();
io.on("connection", (socket) => { ... });

可以看到,基本没啥差别。在我们没有深入之前,可以把一个客户端于服务端的一个连接当作点对点(P2P),由客户端率先发起socket连接,连接成功之后客户端和服务端的connection事件被触发,两端的所有操作都在这个事件的回调函数中执行,双方都可以通过发起.disconnect()来断开连接。

cookie方式确定用户身份

我们大部分应用通过cookie来确定某个用户的身份,对于多点登陆用户而言,虽然是同一个用户,但cookie是不同的,因此,我们要通过cookie找出user id来确定身份。在服务端,要通过user id来决定socket的一条消息要发送给哪个客户端。那么怎么确定身份呢?

socket.io新版本已经官方支持cookie获取了,cookie被保存在socket.handshake.headers.cookie中,然而这里得到的cookie是一个完整的原始cookie,我们希望通过一个parser工具把它解析成对象的形式便于我们获取。cookie-parser这个包已经做了这件事,express应用直接用它来解析,而一个叫socket.io-cookie-parser的包对它进行来封装,让socket.io也支持同样的方式:

import express from "express";
import cookieParser from "cookie-parser";
import socketIoServer from "socket.io";
import socketIoCookieParser from "socket.io-cookie-parser";
import http from "http";
const app = express();
const server = http.Server(app);
const io = new socketIoServer(server);
app.use(cookieParser());
io.use(socketIoCookieParser());
io.on("connection", (socket) => {
    let cookies = socket.request.cookies;
    let autcookie = cookies.auth;
    // 接下来通过你系统内部的机制,通过这个cookie去获取用户的user id
    ...
});
server.listen(3000);

上面这段代码演示了怎么在socket.io中获取cookie信息,而你的express中也可以获取一模一样的cookie,所以就有了对应关系。

http保存,socket推送

现在进入最关键的一个环节。前面的两节帮我们解决了express和socket.io的部署问题,但是怎么融合还没有讲。让我们回到一个现实的案例中,我们创建了一个聊天室,一个用户发送消息到服务端,我们采用ajax请求发送(虽然实际上可以采用socket.io的客户端程序发送),他自己本地可以马上显示这条消息,问题是,其它用户的屏幕上怎么马上显示这条消息呢?我们需要通过服务端发出一个消息推送,把更新的内容推送给所有客户端,这样所有客户端就可以得到新的内容并显示在界面上。

逻辑看上去很简单。而且如果你对socket.io有了解的话,通过服务端一个socket.broadcast.emit和客户端的socket.on,可以很容易实现。问题在于怎么把“http保存消息完再socket推送”这个逻辑实现。

我们的大部分api都是restful的,因此保存这个消息常常是一个post请求,得到这个请求之后,我们在express的某个路由中实现写入数据库的过程。然而我们去研究socket.io的使用方式,发现socket.io的响应是独立的作用域,和express创建的app是分离的。
因此,我们要提供一个全局的变量来保存每一个socket连接,并且把每一个socket连接和建立它的用户对应起来。

import express from "express";
import cookieParser from "cookie-parser";
import socketIoServer from "socket.io";
import socketIoCookieParser from "socket.io-cookie-parser";
import http from "http";
const app = express();
const server = http.Server(app);
const io = new socketIoServer(server);
const sockets = {};

app.use(cookieParser());
io.use(socketIoCookieParser());
app.use((req, res, next) => {
    app.sockets = req.sockets = res.sockets = sockets;
    next();
});
io.on("connection", (socket) => {
    let cookies = socket.request.cookies;
    let autcookie = cookies.auth;
    if (!autcookie) {
        console.log("a user connected by socket, but without sso cookie, so disconnected.");
        socket.disconnect();
        return;
    }
    getUserId(autcookie).then((uid) => {
        sockets[uid] = sockets[uid] || [];
        sockets[uid].push(socket);
        io.on("disconnection", () => {
            sockets[uid] = sockets[uid].filter((item) => item !== socket);
        });
    });
});
server.listen(3000);

这其实是一个比较hack的手法,但是可以解决我们的问题。我们创建来一个全局的sockets变量,并把这个变量赋值给express创建的app,这样我们就可以在路由里面使用sockets这个属性了。

这个sockets保存了所有的socket连接,并且第一层按照userid进行保存,也就是说在路由里面只要知道userid,就可以知道这个用户都建立了哪些socket。第二层是一个数组,因为相同userid的登陆用户可能在多个地方登陆了,因此可能建立多个socket连接,我们还要用这个数组实现推送,即当其中一个客户端更新了,我们先找出userid,然后得到这个数组,并且遍历这个数组,对每一个socket抛出一个推送消息,这样,虽然是同一个userid但不同client就可以接收到更新提示。

getUserId是我自己的内部函数,不需要公开。不过,在disconnect的时候,我们不忘把这个socket从sockets中删除,这是一个好习惯,也保证程序不会出错。

小结

上面的阐述主要还是从server端去阐述,没有考虑客户端提示应该怎么做。其实客户端也是在某个on中进行提示处理,或者你可以更自动一些,得到这个推送之后,自己默认发一个新的ajax去获取最新数据。通过本文,主要让你可以了解如何实现http和socket的结合。

2018-03-05 689

为价值买单

本文价值6.89RMB