NestJS 和 Vue3 中搭建 WebSocket 即时通信中心

2,438 阅读6分钟

NestJS 和 Vue3 中搭建 WebSocket 即时通信中心

WebSocket 是一个长连接的通道, 经常用在一些物联网设备中, 也用在即时通信中. 以下介绍 NestJS 作为服务端 中 websocket 的搭建和使用, 用 Vue3 作为客户端, 可以类推到物联网设备中去

NestJS 服务端搭建

如果可以, 直接按照官方文档来就行了. 不过官方文档有点抽象, 我这里有个简单的方式

适配器

Nestjs 可以看作是一个通用框架, 里面写了通用的调用方法与属性等. 底层, 就是调用 socket.io 或原生的 websocket, 也就是 ws, 官方推荐是用 socket.io, 因为这个库基本封装的很好了, 可以少写很多代码, 这里各位按需求来选适配器吧. 也就是 socket.io 或原生的 websocket.

socket.io 库

安装

$ npm i --save @nestjs/websockets 
$ npm i --save @nestjs/platform-socket.io

ws 库

安装

$ npm i --save @nestjs/websockets 
$ npm i --save @nestjs/platform-ws

Main.js 中设置为适配器使用 ws

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 模块文件

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 网关文件

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 这个方法, 进去看方法

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

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 的参数

@WebSocketGateway( 3001, { core: true })

但是, 因为 ws 和 http 走的是二种不同的连接协议, 所以就算用同一个 port 也可以的. 所以我就没有另外设置 port了.

@WebSocketGateway({ core: true })

上面这个方法, 就是启动了, 一个 ws 服务, 端口就是你自己设定那个了, 或者同 http 一样, 然后这个网关里, 有几个方法是系统方法.

  • 比如: handleConnection 这个方法, 是每次 ws 客户端连接上都会调用, 这个方法用来做什么呢, 可以用来注册登陆用户, 识别用户. ws 的机制是谁都可以连接上来, 这里就可以对非用户做踢下线的东西了. 如我上面方法里, 就会带上用户 token 去做识别了.
  • handleDisconnect 这个方法, 是用户主动断线时调用的.
  • afterInit 这个始初化动作了, 这里可以把 ws.gateway.ts ws 的 server 实例传到 ws.service.ts 去处理消息

ws.service.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)
    }
  }
}
// 存储连接的客户端
connectedClients: Map<string, Socket> = new Map();

connectedClients 存储连接的客户端, 这里用到 Map 格式, 可以保存一个用户只有一处登陆, 如果不想这样做, 就不要用 Map 格式.

然后, 我们在登陆的时候, 做了一个保存, 保存了 ws 连接的 client, 还有用 employeeId 工号和做识别查找. 也可以用别的

// 保存工号
this.connectedClients.set(employeeId, client)
// 取得用户 client
const client = this.connectedClients.get(employeeId)

这个 client 主要是用来对用户发送消息

群发消息

调用 server 发送 emit 就行了.

this.server?.emit(response.path, response)
  • 第一个参数是发送的地址 (客户端要先订阅这个地址, 默认订阅的地址是 message, 这个可以不填写)
  • 第二个参数是发送的数据(格式定好规定就好了, 客户端按这个去解释, 我就喜欢用 json)
个人消息

和上面一样, 只是调用 client 去发送消息.

this.connectedClients.get(employeeId)?.emit(response.path, response)

后面的操作, 消息的处理, 都是 socket.io, 功能很多. 详情可以去 socket.io 的官网看文档, 这里就不细说了, 上面只说了二点. 到了这里, 服务端部分的配置就完成了. 当然, 还有一个 https 的坑点, 后面 vue3 之后会一起说到.

Vue3 客户端连接

安装库

npm install socket.io-client --save

用法, 可以看文档, 以下是对 socket.io client 的一个封装, 可以初始化后, 直接调用.

WebSocketClient 封装

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 根目录, 或者需要打开的页面都行.

//websocket 消息通知
const token = `Bearer ${useUserStore().token}`
const url = import.meta.env.VITE_WS_URL

// 创建 websocket 消息通知
if (token) { new WebSocketClient(url, token) }

然后, 这个 url 地址, 有个坑.

nginx 代理

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 , 实际的地址就是

# socket.io 地址
VITE_WS_URL=https://www.test.com

注意, 上面地址并不是 ws 开头, 也不是 wss 开头. 这里面其实做了一个内部的代理跳转了. www.test.com/socket.io/ 它的实际地址是这个.

如果没有加 wss, 那么, 可以直接用 ws 访问.

# socket.io 地址
VITE_WS_URL=ws://localhost:3000

Ps: 当初这个坑把我搞了一天, 上线时, 不停的在用 wss 去连接, 去调试. 结果发现, 实现的地址并不是 wss 通道, 而是 https 通道进去跳转.