NestJS 和 Vue3 中搭建 WebSocket 即时通信中心
WebSocket 是一个长连接的通道, 经常用在一些物联网设备中, 也用在即时通信中. 以下介绍 NestJS 作为服务端 中 websocket 的搭建和使用, 用 Vue3 作为客户端, 可以类推到物联网设备中去
NestJS 服务端搭建
如果可以, 直接按照官方文档来就行了. 不过官方文档有点抽象, 我这里有个简单的方式
适配器
Nestjs 可以看作是一个通用框架, 里面写了通用的调用方法与属性等. 底层, 就是调用 socket.io 或原生的 websocket, 也就是 ws, 官方推荐是用 socket.io, 因为这个库基本封装的很好了, 可以少写很多代码, 这里各位按需求来选适配器吧. 也就是 socket.io 或原生的 websocket.
socket.io 库
安装
ts
$ npm i --save @nestjs/websockets
$ npm i --save @nestjs/platform-socket.io
ws 库
安装
ts
$ npm i --save @nestjs/websockets
$ npm i --save @nestjs/platform-ws
Main.js 中设置为适配器使用 ws
ts
const app = await NestFactory.create(ApplicationModule);
app.useWebSocketAdapter(new WsAdapter(app));
配置流程
安装完后, 就可以配置了, 这里以 socket.io 来讲.
ws 模块
创建三个文件, ws.module.ts, ws.gateway.ts, ws.service.ts, 记得在 app.module.ts 的 imports 里 添加上 ws.module.ts
ws.module.ts 模块文件
ts
import { Global, Module } from '@nestjs/common';
import { WsGateway } from './ws.gateway';
import { WsService } from './ws.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Message } from '@/entities/message.entity';
import { User } from '@/entities/user.entity';
import { WsController } from './ws.controller';
@Global()
@Module({
imports: [
TypeOrmModule.forFeature([Message, User])
],
providers: [WsGateway, WsService],
controllers:[WsController], // 这个是 HTTP 服务, 可有可无
exports: [WsService],
})
export class WsModule { }
ws.gateway.ts ws 网关文件
ts
import { WebSocketGateway, SubscribeMessage, OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, WebSocketServer } from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import { WsService } from './ws.service';
@WebSocketGateway({ core: true })
export class WsGateway implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit {
constructor(private readonly wsService: WsService) {}
// server 实例
@WebSocketServer()
server: Server;
/**
* 用户连接上
* @param client client
* @param args
*/
handleConnection(client: Socket, ...args: any[]) {
// 注册用户
const token = client.handshake?.auth?.token ?? client.handshake?.headers?.authorization
return this.wsService.login(client, token)
}
/**
* 用户断开
* @param client client
*/
handleDisconnect(client: Socket) {
// 移除数据 socketID
this.wsService.logout(client)
}
/**
* 初始化
* @param server
*/
afterInit(server: Server) {
Logger.log('websocket init... port: ' + process.env.PORT)
this.wsService.server = server;
// 重置 socketIds
this.wsService.resetClients()
}
}
重点来了, WebSocketGateway 这个方法, 进去看方法
ts
export declare function WebSocketGateway(port?: number): ClassDecorator;
export declare function WebSocketGateway<T extends Record<string, any> = GatewayMetadata>(options?: T): ClassDecorator;
export declare function WebSocketGateway<T extends Record<string, any> = GatewayMetadata>(port?: number, options?: T): ClassDecorator;
然后看 GatewayMetadata
ts
export interface GatewayMetadata {
/**
* The name of a namespace
*/
namespace?: string | RegExp;
/**
* Name of the path to capture
* @default "/socket.io"
*/
path?: string;
/**
* Whether to serve the client files
* @default true
*/
serveClient?: boolean;
/**
* The adapter to use
* @default the in-memory adapter (https://github.com/socketio/socket.io-adapter)
*/
adapter?: any;
/**
* The parser to use
* @default the default parser (https://github.com/socketio/socket.io-parser)
*/
parser?: any;
/**
* How many ms before a client without namespace is closed
* @default 45_000
*/
connectTimeout?: number;
/**
* How many ms without a pong packet to consider the connection closed
* @default 20_000
*/
pingTimeout?: number;
/**
* How many ms before sending a new ping packet
* @default 25_000
*/
pingInterval?: number;
/**
* How many ms before an uncompleted transport upgrade is cancelled
* @default 10_000
*/
upgradeTimeout?: number;
/**
* How many bytes or characters a message can be, before closing the session (to avoid DoS).
* @default 1e6 (1 MB)
*/
maxHttpBufferSize?: number;
/**
* A function that receives a given handshake or upgrade request as its first parameter,
* and can decide whether to continue or not. The second argument is a function that needs
* to be called with the decided information: fn(err, success), where success is a boolean
* value where false means that the request is rejected, and err is an error code.
*/
allowRequest?: (req: any, fn: (err: string | null | undefined, success: boolean) => void) => void;
/**
* The low-level transports that are enabled
* @default ["polling", "websocket"]
*/
transports?: Array<'polling' | 'websocket'>;
/**
* Whether to allow transport upgrades
* @default true
*/
allowUpgrades?: boolean;
/**
* Parameters of the WebSocket permessage-deflate extension (see ws module api docs). Set to false to disable.
* @default false
*/
perMessageDeflate?: boolean | object;
/**
* Parameters of the http compression for the polling transports (see zlib api docs). Set to false to disable.
* @default true
*/
httpCompression?: boolean | object;
/**
* What WebSocket server implementation to use. Specified module must
* conform to the ws interface (see ws module api docs). Default value is ws.
* An alternative c++ addon is also available by installing uws module.
*/
wsEngine?: string;
/**
* An optional packet which will be concatenated to the handshake packet emitted by Engine.IO.
*/
initialPacket?: any;
/**
* Configuration of the cookie that contains the client sid to send as part of handshake response headers. This cookie
* might be used for sticky-session. Defaults to not sending any cookie.
* @default false
*/
cookie?: any | boolean;
/**
* The options that will be forwarded to the cors module
*/
cors?: CorsOptions;
/**
* Whether to enable compatibility with Socket.IO v2 clients
* @default false
*/
allowEIO3?: boolean;
/**
* Destroy unhandled upgrade requests
* @default true
*/
destroyUpgrade?: boolean;
/**
* Milliseconds after which unhandled requests are ended
* @default 1_000
*/
destroyUpgradeTimeout?: number;
}
上面东西很多, 上面都有解释了, 就拿这上 port 来说吧. 代码里面可以这样写. Port 可以自定义 3001, 里面我 http 是 3000 的, 如果这样设置, 那么就使用二个不同的端口了. { core: true }
是 GatewayMetadata
的参数
ts
@WebSocketGateway( 3001, { core: true })
但是, 因为 ws 和 http 走的是二种不同的连接协议, 所以就算用同一个 port 也可以的. 所以我就没有另外设置 port了.
ts
@WebSocketGateway({ core: true })
上面这个方法, 就是启动了, 一个 ws 服务, 端着就是你自己设定那个了, 或者同 http 一样, 然后这个网关里, 有几个方法是系统方法.
- 比如:
handleConnection
这个方法, 是每次 ws 客户端连接上都会调用, 这个方法用来做什么呢, 可以用来注册登陆用户, 识别用户. ws 的机制是谁都可以连接上来, 这里就可以对非用户做踢下线的东西了. 如我上面方法里, 就会带上用户 token 去做识别了. handleDisconnect
这个方法, 是用户主动断线时调用的.afterInit
这个始初化动作了, 这里可以把 ws.gateway.ts ws 的 server 实例传到 ws.service.ts 去处理消息
ws.service.ts 服务类
ts
import { Injectable } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import { Logger } from '@nestjs/common';
import { WSResponse } from '../message/model/ws-response.model';
import { validateToken } from '@/utils/helper';
import { Message } from '@/entities/message.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '@/entities/user.entity';
/**
* 消息类型,0 用户消息, 1 系统消息,2 事务消息
*/
enum MessageType {
person = 0,
system = 1,
transactional = 2
}
/**
* WebSocket 订阅地址
*/
enum MessagePath {
/**
* 用户消息, 系统消息, 事务消息
*/
message = 'message',
/**
* 错误通知
*/
error = 'error'
}
@Injectable()
export class WsService {
constructor(
@InjectRepository(Message) private messageRepo: Repository<Message>,
@InjectRepository(User) private userRepo: Repository<User>,
) { }
// ws 服务器, gateway 传进来
server: Server;
// 存储连接的客户端
connectedClients: Map<string, Socket> = new Map();
/**
* 登录
* @param client socket 客户端
* @param token token
* @returns
*/
async login(client: Socket, token: string): Promise<void> {
if (!token) {
Logger.error('token error: ', token)
client.send('token error')
client.disconnect() // 题下线
return
}
// 认证用户
const res: JwtInterface = validateToken(token.replace('Bearer ', ''))
if (!res) {
Logger.error('token 验证不通过')
client.send('token 验证不通过')
client.disconnect()
return
}
const employeeId = res?.employeeId
if (!employeeId) {
Logger.log('token error')
client.send('token error')
client.disconnect()
return
}
// 处理同一工号在多处登录
if (this.connectedClients.get(employeeId)) {
this.connectedClients.get(employeeId).send(`${employeeId} 已在别的客户端上线登录, 此客户端下线处理`)
this.connectedClients.get(employeeId).disconnect()
}
// 保存工号
this.connectedClients.set(employeeId, client)
Logger.log(`${employeeId} connected, onLine: ${this.connectedClients.size}`)
client.send(`${employeeId} connected, onLine: ${this.connectedClients.size}`)
return
}
/**
* 登出
* @param client client
*/
async logout(client: Socket) {
// 移除在线 client
this.connectedClients.forEach((value, key) => {
if (value === client) {
this.connectedClients.delete(key);
Logger.log(`${key} disconnected, onLine: ${this.connectedClients.size}`)
}
});
}
/**
* 重置 connectedClients
*/
resetClients() {
this.connectedClients.clear()
}
/**
* 发送公共消息(系统消息)
* @param messagePath 发布地址
* @param response 响应数据
*/
async sendPublicMessage(response: WSResponse) {
try {
// const message = await this.messageRepo.save(response.data)
// if (!message) {
// throw new Error('消息保存错误')
// }
const res = this.server?.emit(response.path, response)
if (!res) {
Logger.log('websocket send error', response)
}
} catch (error) {
throw new Error(error?.toString())
}
}
/**
* 发送私人消息(事务消息、个人消息)
* @param messagePath 发布地址
* @param response 响应数据
* @param employeeId 接收者工号
*/
async sendPrivateMessage(response: WSResponse, employeeId: string) {
try {
// const message = await this.messageRepo.save(response.data)
// if (!message) {
// throw new Error('消息保存错误')
// }
const res = this.connectedClients.get(employeeId)?.emit(response.path, response)
if (!res) {
Logger.log('websocket send error', response)
}
} catch (error) {
throw new Error(error?.toString())
}
}
/**
* 发送事务消息通知
* @param message 消息
*/
sendTransactionWs(message: Message) {
try {
const wsRes = new WSResponse(MessagePath.message, message.title, message)
this.sendPrivateMessage(wsRes, message.receiver.employeeId)
} catch (error) {
Logger.debug('发送事务消息通知', error)
}
}
}
ts
// 存储连接的客户端
connectedClients: Map<string, Socket> = new Map();
connectedClients
存储连接的客户端, 这里用到 Map 格式, 可以保存一个用户只有一处登陆, 如果不想这样做, 就不要用 Map 格式.
然后, 我们在登陆的时候, 做了一个保存, 保存了 ws 连接的 client, 还有用 employeeId 工号和做识别查找. 也可以用别的
ts
// 保存工号
this.connectedClients.set(employeeId, client)
ts
// 取得用户 client
const client = this.connectedClients.get(employeeId)
这个 client 主要是用来对用户发送消息
群发消息
调用 server 发送 emit 就行了.
ts
this.server?.emit(response.path, response)
- 第一个参数是发送的地址 (客户端要先订阅这个地址, 默认订阅的地址是 message, 这个可以不填写)
- 第二个参数是发送的数据(格式定好规定就好了, 客户端按这个去解释, 我就喜欢用 json)
个人消息
和上面一样, 只是调用 client 去发送消息.
ts
this.connectedClients.get(employeeId)?.emit(response.path, response)
后面的操作, 消息的处理, 都是 socket.io, 功能很多. 详情可以去 socket.io 的官网看文档, 这里就不细说了, 上面只说了二点. 到了这里, 服务端部分的配置就完成了. 当然, 还有一个 https 的坑点, 后面 vue3 之后会一起说到.
Vue3 客户端连接
安装库
ts
npm install socket.io-client --save
用法, 可以看文档, 以下是对 socket.io client 的一个封装, 可以初始化后, 直接调用.
WebSocketClient 封装
ts
import { Socket, io } from 'socket.io-client'
import { useMessageStore } from '@store/message'
import { notification, Button } from 'ant-design-vue'
export class WebSocketClient {
private socket: Socket;
private onMessageCallback?: (data: any) => void;
private onOpenCallback?: () => void;
private onCloseCallback?: (event: CloseEvent) => void;
private onErrorCallback?: (error: Event) => void;
constructor(url: string, token: string) {
this.socket = io(url, {
transports: ['websocket'],
auth: { token }
});
this.socket.on('message', (event: any) => this.handleMessage(event));
this.socket.on('connect', () => this.handleOpen());
this.socket.on('disconnect', (event: any) => this.handleClose(event));
this.socket.on('error', (error) => this.handleError(error));
this.socket.connect();
}
private handleMessage(event: any) {
console.log('message', event);
}
private handleOpen() {
if (this.onOpenCallback) {
this.onOpenCallback();
}
}
private handleClose(event: any) {
if (this.onCloseCallback) {
this.onCloseCallback(event);
}
}
private handleError(error: Event) {
if (this.onErrorCallback) {
this.onErrorCallback(error);
}
}
public onMessage(callback: (data: any) => void) {
this.onMessageCallback = callback;
}
public onOpen(callback: () => void) {
this.onOpenCallback = callback;
}
public onClose(callback: (event: CloseEvent) => void) {
this.onCloseCallback = callback;
}
public onError(callback: (error: Event) => void) {
this.onErrorCallback = callback;
}
public send(data: any) {
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(data));
} else {
console.error('WebSocket connection is not open.');
}
}
public close() {
this.socket.close();
}
}
连接配置
这个文件可以放在 App.vue 根目录, 或者需要打开的页面都行.
ts
//websocket 消息通知
const token = `Bearer ${useUserStore().token}`
const url = import.meta.env.VITE_WS_URL
// 创建 websocket 消息通知
if (token) { new WebSocketClient(url, token) }
然后, 这个 url 地址, 有个坑.
nginx 代理
ts
server {
listen 80;
# 服务器端口使用443,开启ssl, 这里ssl就是上面安装的ssl模块
listen 443 ssl;
# 域名,多个以空格分开
server_name www.test.com;
###
其它配置
###
# 配置跨域请求
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
try_files $uri $uri/ /index.html;
}
# 配置后端API代理
location /api/v1/ {
proxy_pass http://127.0.0.1:3000/api/v1/; # 替换成你的后端服务器 IP 和端口
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# websokcet 服务代理
location /socket.io/ {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
上面配置了 ssl, 也就是使用了 https 服务, 里面有一个 websokcet 服务代理, 这一步后, wss, 注意, 是多了个 s , 实际的地址就是
ts
# socket.io 地址
VITE_WS_URL=https://www.test.com
注意, 上面地址并不是 ws 开头, 也不是 wss 开头. 这里面其实做了一个内部的代理跳转了. www.test.com/socket.io/ 它的实际地址是这个.
如果没有加 wss, 那么, 可以直接用 ws 访问.
ts
# socket.io 地址
VITE_WS_URL=ws://localhost:3000
Ps: 当初这个坑把我搞了一天, 上线时, 不停的在用 wss 去连接, 去调试. 结果发现, 实现的地址并不是 wss 通道, 而是 https 通道进去跳转.