简单的 socket 实现

前言

在金融项目中一般需要用到 socket 推送,比如,行情,订单,k线 等。

如果项目需要与多个不同的 Socket 服务(不同的 URL/域名/端口)通信,通常必须创建多个独立的 WebSocket 实例/连接。

因为一个标准的 HTTP/WebSocket 协议连接是基于特定的 TCP 域名和端口 建立的,无法在一个原始的 WebSocket 管道里直接穿透去连另一个服务。

这种"一个项目搞多个 WebSocket 连接"的架构在业务发展到一定阶段非常常见,但如果缺乏统一管理,会引发一系列性能与架构问题。

在实际工程中,面对"多 URL / 多服务"需求,通常采用以下优化解法:

采用网关层聚合(BFF / API Gateway)------ 最推荐的架构解法

如果这些 Socket 服务都是你们公司内部的服务(比如:消息服务、行情服务、推送服务):

  • 做法 :在服务端前面挂一层 API 网关(Gateway)BFF (Backend For Frontend)
  • 效果 :前端只保持 1 个 WebSocket 连接 连到网关。前端发送的消息带着 channeltopic 字段,由网关在内部通过高性能 RPC/微服务转发给不同的后端服务。
  • 优势:前端极简,只有 1 个心跳和重连机制;消灭了前端多连接开销。

金融行业通常有以下业务场景:要在同一个 WebSocket 连接中兼顾"无 Symbol 的普通业务(如 chatorder)"和"有 Symbol 的标的业务(如 tickerkline)" ,最核心的思路是:设计一套统一的层级化通信信封(Envelope)与路由映射机制

在通用网关设计中,symbol 本质上只是 topic(主题)或 subChannel(子通道)的一个特例。只要把协议抽象为 "一级通道 (channel) + 二级主题 (topic / symbol,可选)" ,就能用同一套代码完美兼容所有场景。

统一通信协议(Standard Envelope Protocol)

无论什么业务,前端与网关之间收发的消息格式统一规整为如下格式:

ts 复制代码
// 1. 统一前端发给网关的控制/请求指令格式
interface WSRequest {
  action: 'SUBSCRIBE' | 'UNSUBSCRIBE' | 'SEND';
  channel: string;          // 一级通道,必填(例如: 'chat', 'order', 'ticker')
  topics?: string[];        // 二级主题/标的,可选(例如: ['BTC-USDT', 'ETH-USDT'])
  event?: string;           // 具体的行为/事件名,可选(例如: 'send_message')
  data?: any;               // 携带的数据
}

// 2. 统一网关推给前端的数据包格式
interface WSResponse<T = any> {
  channel: string;          // 一级通道,必填(例如: 'chat', 'order', 'ticker')
  topic?: string;           // 二级主题/标的,可选(例如: 'BTC-USDT',有就带,没有就不带)
  event?: string;           // 业务事件名,可选(例如: 'order_status_change')
  data: T;                  // 真正的业务数据
  timestamp: number;        // 时间戳
}

实现

这里将实现一版包含心跳保活、指数退避、断线重连、发送队列暂存、连接状态感知 等强健机制,网关聚合、多业务场景(无 Topic 业务如 chat / order + 有 Topic 业务如 ticker:BTC-USDT)、按需订阅与引用计数 ,构成一个企业级、全功能的 UnifiedWSClient

ts 复制代码
// UnifiedWSClient.ts

// 1. 连接状态枚举(供外部感知)
export enum WSStatus {
  CONNECTING = 'CONNECTING',
  CONNECTED = 'CONNECTED',
  DISCONNECTED = 'DISCONNECTED',
  RECONNECTING = 'RECONNECTING',
  CLOSED = 'CLOSED', // 主动调用 close() 后的终态,不再自动重连
}

// 2. 网关标准通信协议(信封结构 Envelope)
export interface WSRequest {
  action: 'SUBSCRIBE' | 'UNSUBSCRIBE' | 'SEND' | 'PING';
  channel: string;          // 一级通道 (如 'chat', 'order', 'ticker')
  topics?: string[];        // 二级主题/标的,可选 (如 ['BTC-USDT', 'ETH-USDT'])
  event?: string;           // 业务行为/事件,可选 (如 'send_message')
  data?: any;               // 真正的业务数据
}

export interface WSResponse<T = any> {
  channel: string;          // 一级通道
  topic?: string;           // 二级主题/标的 (如果有)
  event?: string;           // 业务事件
  data: T;                  // 响应数据
  timestamp?: number;
}

// 3. 配置参数接口
export interface WSClientOptions {
  url: string | (() => string); // 支持动态 URL (解决 Token 刷新的问题)
  protocols?: string | string[];
  pingInterval?: number;        // 心跳发送间隔 (默认 15000ms)
  pongTimeout?: number;         // 心跳超时判定时间 (默认 5000ms)
  maxReconnectAttempts?: number;// 最大重连次数 (默认 10 次,-1 为无限重连)
  reconnectBaseDelay?: number;  // 重连基础延迟 (默认 1000ms)
  maxReconnectDelay?: number;   // 重连最大延迟 (默认 30000ms)
  autoConnect?: boolean;        // 是否自动连接 (默认 true)
}

export type MessageCallback = (data: any, rawMessage: WSResponse) => void;
export type EventListener = (...args: any[]) => void;

export class UnifiedWSClient {
  private urlGetter: () => string;
  private protocols?: string | string[];
  private options: Required<Omit<WSClientOptions, 'url' | 'protocols'>>;

  private ws: WebSocket | null = null;
  private status: WSStatus = WSStatus.DISCONNECTED;

  // 定时器句柄
  private pingTimer: any = null;
  private pongTimer: any = null;
  private reconnectTimer: any = null;

  // 重连控制
  private reconnectAttempts = 0;
  private isExplicitlyClosed = false;

  // 消息发送队列(连接断开或未准备好时暂存)
  private messageQueue: Array<string | ArrayBufferLike | Blob | ArrayBufferView> = [];

  // 通用事件发布订阅 (内部状态变化通知: open, close, error, statusChange)
  private eventListeners: Map<string, Set<EventListener>> = new Map();

  // 业务订阅树: RouteKey ("channel" 或 "channel:topic") -> Set<MessageCallback>
  private subscribers: Map<string, Set<MessageCallback>> = new Map();

  constructor(options: WSClientOptions) {
    this.urlGetter = typeof options.url === 'function' ? options.url : () => options.url;
    this.protocols = options.protocols;

    this.options = {
      pingInterval: options.pingInterval ?? 15000,
      pongTimeout: options.pongTimeout ?? 5000,
      maxReconnectAttempts: options.maxReconnectAttempts ?? 10,
      reconnectBaseDelay: options.reconnectBaseDelay ?? 1000,
      maxReconnectDelay: options.maxReconnectDelay ?? 30000,
      autoConnect: options.autoConnect ?? true,
    };

    if (this.options.autoConnect) {
      this.connect();
    }
  }

  // ==================== 1. 核心连接控制与状态感知 ====================

  /**
   * 建立底层物理连接
   */
  public connect() {
    if (this.ws && (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN)) {
      return;
    }

    this.isExplicitlyClosed = false;
    this.setStatus(this.reconnectAttempts > 0 ? WSStatus.RECONNECTING : WSStatus.CONNECTING);

    try {
      const url = this.urlGetter();
      this.ws = new WebSocket(url, this.protocols);
      this.initEvents();
    } catch (error) {
      this.emit('error', error);
      this.handleReconnect();
    }
  }

  /**
   * 外部判断连接是否正常
   */
  public isNormal(): boolean {
    return this.status === WSStatus.CONNECTED && this.ws?.readyState === WebSocket.OPEN;
  }

  /**
   * 获取当前详细状态
   */
  public getStatus(): WSStatus {
    return this.status;
  }

  /**
   * 主动关闭连接(不触发自动重连)
   */
  public close(code: number = 1000, reason?: string) {
    this.isExplicitlyClosed = true;
    this.clearAllTimers();
    this.messageQueue = [];

    if (this.ws) {
      this.ws.close(code, reason);
      this.ws = null;
    }
    this.setStatus(WSStatus.CLOSED);
  }

  /**
   * 监听底层状态变更事件
   */
  public on(event: 'open' | 'close' | 'error' | 'statusChange', listener: EventListener) {
    if (!this.eventListeners.has(event)) {
      this.eventListeners.set(event, new Set());
    }
    this.eventListeners.get(event)!.add(listener);
  }

  public off(event: string, listener: EventListener) {
    this.eventListeners.get(event)?.delete(listener);
  }

  // ==================== 2. 统一业务订阅与发送 API ====================

  /**
   * 融合订阅 API(支持单/多 Topic,支持分批订阅、引用计数)
   * 示例 1: ws.subscribe('chat', callback)
   * 示例 2: ws.subscribe('ticker', 'BTC-USDT', callback)
   * 示例 3: ws.subscribe('ticker', ['BTC-USDT', 'ETH-USDT'], callback)
   */
  public subscribe(channel: string, callback: MessageCallback): () => void;
  public subscribe(channel: string, topics: string | string[], callback: MessageCallback): () => void;
  public subscribe(channel: string, arg2: any, arg3?: any): () => void {
    let topics: string[] = [];
    let callback: MessageCallback;

    // 参数多态重载解析
    if (typeof arg2 === 'function') {
      callback = arg2;
    } else {
      topics = Array.isArray(arg2) ? arg2 : [arg2];
      callback = arg3;
    }

    // 1. 无 Topic 的场景 (如 chat, order)
    if (topics.length === 0) {
      const isFirst = this.addSubscription(channel, callback);
      if (isFirst) {
        this.sendGatewayAction('SUBSCRIBE', channel);
      }
    } 
    // 2. 有 Topic 的场景 (如 ticker: BTC-USDT, ETH-USDT)
    else {
      const newTopicsToSubscribe: string[] = [];
      topics.forEach((topic) => {
        const routeKey = `${channel}:${topic}`;
        const isFirst = this.addSubscription(routeKey, callback);
        if (isFirst) {
          newTopicsToSubscribe.push(topic);
        }
      });

      if (newTopicsToSubscribe.length > 0) {
        this.sendGatewayAction('SUBSCRIBE', channel, newTopicsToSubscribe);
      }
    }

    // 返回取消订阅的函数,便于组件卸载时直接调用
    return () => this.unsubscribe(channel, arg2, arg3);
  }

  /**
   * 取消订阅 API
   */
  public unsubscribe(channel: string, arg2: any, arg3?: any) {
    let topics: string[] = [];
    let callback: MessageCallback;

    if (typeof arg2 === 'function') {
      callback = arg2;
    } else {
      topics = Array.isArray(arg2) ? arg2 : [arg2];
      callback = arg3;
    }

    if (topics.length === 0) {
      const hasRemaining = this.removeSubscription(channel, callback);
      if (!hasRemaining) {
        this.sendGatewayAction('UNSUBSCRIBE', channel);
      }
    } else {
      const topicsToUnsubscribe: string[] = [];
      topics.forEach((topic) => {
        const routeKey = `${channel}:${topic}`;
        const hasRemaining = this.removeSubscription(routeKey, callback);
        if (!hasRemaining) {
          topicsToUnsubscribe.push(topic);
        }
      });

      if (topicsToUnsubscribe.length > 0) {
        this.sendGatewayAction('UNSUBSCRIBE', channel, topicsToUnsubscribe);
      }
    }
  }

  /**
   * 业务消息发送(若连接未准备好,会自动缓存进入发送队列)
   */
  public send(channel: string, data: any, event?: string, topic?: string) {
    const payload: WSRequest = {
      action: 'SEND',
      channel,
      event,
      data,
      topics: topic ? [topic] : undefined,
    };

    this.sendRaw(JSON.stringify(payload));
  }

  // ==================== 3. 内部事件绑定与分发 ====================

  private initEvents() {
    if (!this.ws) return;

    this.ws.onopen = (event) => {
      this.reconnectAttempts = 0;
      this.setStatus(WSStatus.CONNECTED);
      
      this.startHeartbeat();     // 启动心跳
      this.flushMessageQueue();  // 补发缓存消息
      this.resubscribeAll();     // 重新挂载全量业务订阅

      this.emit('open', event);
    };

    this.ws.onmessage = (event) => {
      // 收到任何服务端数据,重置心跳倒计时
      this.resetHeartbeatTimeout();

      try {
        const message: WSResponse = JSON.parse(event.data);

        // A. 心跳 Pong 回应检查
        if (message.channel === 'system' && message.event === 'pong') {
          return;
        }

        // B. 业务消息路由分发
        const { channel, topic } = message;
        const routeKey = topic ? `${channel}:${topic}` : channel;

        const callbacks = this.subscribers.get(routeKey);
        if (callbacks && callbacks.size > 0) {
          callbacks.forEach((cb) => cb(message.data, message));
        }
      } catch (err) {
        console.error('[UnifiedWS] 消息解析或路由异常:', err);
      }
    };

    this.ws.onerror = (event) => {
      this.emit('error', event);
    };

    this.ws.onclose = (event) => {
      this.clearAllTimers();
      this.ws = null;
      this.emit('close', event);

      // 非主动关闭则启动自动重连机制
      if (!this.isExplicitlyClosed) {
        this.setStatus(WSStatus.DISCONNECTED);
        this.handleReconnect();
      }
    };
  }

  // ==================== 4. 心跳保活机制 ====================

  private startHeartbeat() {
    this.stopHeartbeat();

    this.pingTimer = setInterval(() => {
      if (this.isNormal()) {
        // 向网关发送系统级心跳
        this.sendRaw(JSON.stringify({ action: 'PING', channel: 'system', event: 'ping' }));

        // 启动 Pong 超时限制
        this.pongTimer = setTimeout(() => {
          console.warn('[UnifiedWS] 心跳响应 Timeout,判定连接断开,触发重新连接');
          this.ws?.close(); // 触发 onclose 进入重连流程
        }, this.options.pongTimeout);
      }
    }, this.options.pingInterval);
  }

  private resetHeartbeatTimeout() {
    if (this.pongTimer) {
      clearTimeout(this.pongTimer);
      this.pongTimer = null;
    }
  }

  private stopHeartbeat() {
    if (this.pingTimer) clearInterval(this.pingTimer);
    if (this.pongTimer) clearTimeout(this.pongTimer);
    this.pingTimer = null;
    this.pongTimer = null;
  }

  // ==================== 5. 断线重连(指数退避算法) ====================

  private handleReconnect() {
    if (this.isExplicitlyClosed) return;

    const { maxReconnectAttempts, reconnectBaseDelay, maxReconnectDelay } = this.options;

    if (maxReconnectAttempts !== -1 && this.reconnectAttempts >= maxReconnectAttempts) {
      console.error(`[UnifiedWS] 已达最大重连次数 (${maxReconnectAttempts}),停止自动重连`);
      this.setStatus(WSStatus.DISCONNECTED);
      return;
    }

    this.reconnectAttempts++;

    // 指数退避算法: 1s, 2s, 4s, 8s ... Max 30s
    const delay = Math.min(
      reconnectBaseDelay * Math.pow(2, this.reconnectAttempts - 1),
      maxReconnectDelay
    );

    console.log(`[UnifiedWS] 将在 ${delay}ms 后进行第 ${this.reconnectAttempts} 次重连...`);

    this.reconnectTimer = setTimeout(() => {
      this.connect();
    }, delay);
  }

  // 连上后,重新向服务端恢复原有的全量订阅通道
  private resubscribeAll() {
    const channelTopicMap = new Map<string, string[]>();

    this.subscribers.forEach((_, routeKey) => {
      const [channel, topic] = routeKey.split(':');
      if (!channelTopicMap.has(channel)) {
        channelTopicMap.set(channel, []);
      }
      if (topic) {
        channelTopicMap.get(channel)!.push(topic);
      }
    });

    channelTopicMap.forEach((topics, channel) => {
      this.sendGatewayAction('SUBSCRIBE', channel, topics.length > 0 ? topics : undefined);
    });
  }

  // ==================== 6. 基础设施与辅助方法 ====================

  private addSubscription(routeKey: string, cb: MessageCallback): boolean {
    if (!this.subscribers.has(routeKey)) {
      this.subscribers.set(routeKey, new Set());
    }
    const set = this.subscribers.get(routeKey)!;
    const isFirst = set.size === 0;
    set.add(cb);
    return isFirst;
  }

  private removeSubscription(routeKey: string, cb: MessageCallback): boolean {
    const set = this.subscribers.get(routeKey);
    if (set) {
      set.delete(cb);
      if (set.size === 0) {
        this.subscribers.delete(routeKey);
        return false;
      }
      return true;
    }
    return false;
  }

  private sendGatewayAction(action: 'SUBSCRIBE' | 'UNSUBSCRIBE', channel: string, topics?: string[]) {
    const payload: WSRequest = {
      action,
      channel,
      topics: topics && topics.length > 0 ? topics : undefined,
    };
    this.sendRaw(JSON.stringify(payload));
  }

  private sendRaw(data: string) {
    if (this.isNormal()) {
      this.ws?.send(data);
    } else {
      // 未连上时存入队列
      this.messageQueue.push(data);
    }
  }

  private flushMessageQueue() {
    while (this.messageQueue.length > 0 && this.isNormal()) {
      const msg = this.messageQueue.shift();
      if (msg) this.ws?.send(msg);
    }
  }

  private setStatus(nextStatus: WSStatus) {
    if (this.status !== nextStatus) {
      const prevStatus = this.status;
      this.status = nextStatus;
      this.emit('statusChange', { status: nextStatus, prevStatus });
    }
  }

  private clearAllTimers() {
    this.stopHeartbeat();
    if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
    this.reconnectTimer = null;
  }

  private emit(event: string, ...args: any[]) {
    this.eventListeners.get(event)?.forEach((listener) => {
      try {
        listener(...args);
      } catch (err) {
        console.error(`[UnifiedWS] Event ${event} listener error:`, err);
      }
    });
  }
}

Demo

ts 复制代码
import { UnifiedWSClient, WSStatus } from './UnifiedWSClient';

// 1. 初始化客户端单例(全局维护 1 条 WebSocket 连接)
export const wsClient = new UnifiedWSClient({
  // 支持函数动态返回 Token
  url: () => `wss://gateway.yourdomain.com/ws?token=${localStorage.getItem('token')}`,
  pingInterval: 10000,     // 10秒发一次心跳
  pongTimeout: 3000,       // 3秒无响应认定断线
  maxReconnectAttempts: 10 // 重连最多10次
});

// 2. 全局状态感知(例如显示在 Navbar 的网络状态点)
wsClient.on('statusChange', ({ status }) => {
  if (status === WSStatus.CONNECTED) {
    console.log('💚 WebSocket 已就绪,通信正常');
  } else if (status === WSStatus.RECONNECTING) {
    console.warn('💛 正在尝试自动重连网络...');
  } else if (status === WSStatus.DISCONNECTED) {
    console.error('🔴 连接断开');
  }
});

// 随时查询连接是否正常
if (wsClient.isNormal()) {
  console.log('网络正常');
}

// ----------------------------------------------------
// 3. 业务组件 A:普通聊天功能 (无 Topic)
const unsubChat = wsClient.subscribe('chat', (data, rawMsg) => {
  console.log('收到新聊天消息:', data, '事件:', rawMsg.event);
});

// 发送聊天消息(即使在重连阶段调用,也会排队待连上后补发)
wsClient.send('chat', { text: 'Hello Gateway!' }, 'send_message');


// ----------------------------------------------------
// 4. 业务组件 B:行情数据看板 (有 Topic)
// 分批订阅 BTC 和 ETH
const unsubTicker = wsClient.subscribe('ticker', ['BTC-USDT', 'ETH-USDT'], (data, rawMsg) => {
  console.log(`收到 ${rawMsg.topic} 最新价格:`, data.price);
});

// 动态追加订阅 SOL
const unsubSol = wsClient.subscribe('ticker', 'SOL-USDT', (data) => {
  console.log('收到 SOL 最新价格:', data.price);
});

// 订阅 K线:监听 SOL 的 1分钟 K线 
const unsubKline = wsClient.subscribe('kline', 'SOL-USDT:1m', (data, rawMsg) => {
  console.log(`收到 ${rawMsg.topic} K线:`, data); 
});


// ----------------------------------------------------
// 5. 页面销毁 / 组件卸载
// 调用各自返回的 unsub 函数,引用计数降为 0 时 SDK 才会向网关发退订请求
unsubChat();
unsubTicker();
unsubSol();
unsubKline();

// 用户登出或关闭应用时:主动关闭,释放所有定时器且不触发重连
// wsClient.close();

总结

以上就是一个简版的 socket 实现,可以根据公司具体的业务需求场景进行改造

相关推荐
mldong23 分钟前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排1 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60612 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角3 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!3 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰3 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
脉动数据行情14 小时前
C# 实现德指 DAX 期货 WebSocket 实时行情监听
开发语言·websocket·c#
mayaairi4 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen5 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马6 小时前
AI源码分析:拆解模型底层实现逻辑
前端