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

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 通道进去跳转.

相关推荐
alicelovesu9 小时前
Mac开发者噩梦终结者?实测三大工具,告别环境配置地狱!
python·node.js
等一个晴天丶12 小时前
node爬虫实战:爬取世纪佳缘交友信息
node.js
有仙则茗14 小时前
process.cwd()和__dirname有什么区别
前端·javascript·node.js
盛夏绽放17 小时前
Node.js 路由请求方式大全解:深度剖析与工程实践
node.js·有问必答
濮水大叔17 小时前
快来玩玩便捷、高效的Demo练习场
typescript·nodejs·nestjs
水冗水孚18 小时前
面试官:你是前端你了解oss吗?我反手写了一个react+express+minio实现oss文件存储功能
react.js·node.js·express
木西19 小时前
Nest.js实战:构建聊天室的群聊与私聊模块
前端·后端·nestjs
华洛19 小时前
《从0到1打造企业级AI售前机器人——实战指南五:处理用户意图的细节实现!》
javascript·vue.js·node.js
树獭叔叔2 天前
从零开始Node之旅——装饰器
后端·node.js