实战项目一:搭建一对一视频通话的信令服务器(含完整代码)

好,咱们直接进入正题。这一章,我们要亲手搭建一个能跑起来的一对一视频通话信令服务器。说实话,信令服务器是 WebRTC 里最容易被低估的环节。很多人觉得「不就是传个消息嘛」,结果一跑起来,要么连不上,要么连上了但媒体流死活不通。

我当年第一次做 WebRTC 项目时,就踩过这个坑。信令逻辑写得太随意,结果排查了整整两天,最后发现是信令消息的顺序没处理好。嗯,从那以后,我对信令服务器的设计就格外上心。

项目需求分析

先搞清楚我们要做什么。一对一视频通话的信令服务器,核心职责就两个:

  • 转发信令消息:把 A 的 Offer、ICE Candidate 等消息传给 B,反过来也一样
  • 管理房间状态:谁在房间里?谁加入了?谁离开了?

说白了,它就是个消息中转站 + 状态管理器。不需要处理媒体流,也不需要做复杂的业务逻辑。

核心原则:信令服务器只负责「传话」,不负责「说话」。媒体协商、连接建立这些事,交给浏览器端的 RTCPeerConnection 去处理。

技术选型

我个人习惯用 Node.js + WebSocket 来做信令服务器。原因很简单:

  • Node.js 的异步 I/O 模型非常适合处理大量并发连接
  • WebSocket 是全双工通信,天然适合信令这种双向实时交互场景
  • 生态成熟,ws 库稳定可靠

你可能会问:用 HTTP 轮询行不行?技术上可以,但效率差太多。WebRTC 的信令交换需要低延迟,HTTP 的请求-响应模型会引入不必要的延迟。你想想看,ICE Candidate 可能几十个,每个都走 HTTP 请求,那画面太美我不敢看。

方案 延迟 复杂度 推荐度
WebSocket ⭐⭐⭐⭐⭐
HTTP 轮询 ⭐⭐
SSE ⭐⭐⭐

整体架构设计

先画一张图,让你对整体流程有个直观认识。

一对一视频通话信令架构 浏览器 A RTCPeerConnection WebSocket 客户端 浏览器 B RTCPeerConnection WebSocket 客户端 信令服务器 Node.js + WebSocket 房间管理 / 消息转发 状态维护 媒体流(P2P 直连) 信令(Offer/Answer/ICE) 媒体流(P2P)

看到这张图你应该就明白了。信令服务器处在中间位置,负责转发消息。而真正的媒体流,是浏览器之间直连的,不经过服务器。这就是 WebRTC 的精髓——信令走服务器,媒体走 P2P。

完整代码实现

好,直接上代码。我会把完整的信令服务器代码贴出来,然后逐段讲解。

1. 项目初始化

// package.json
{
  "name": "webrtc-signaling-server",
  "version": "1.0.0",
  "description": "一对一视频通话信令服务器",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "ws": "^8.16.0"
  }
}

安装依赖:

npm install

2. 信令服务器核心代码

// server.js
const WebSocket = require('ws');

const PORT = 8080;
const server = new WebSocket.Server({ port: PORT });

// 房间管理
// 结构:{ roomId: [ws1, ws2] }
const rooms = new Map();

console.log(`信令服务器已启动,端口: ${PORT}`);

server.on('connection', (ws) => {
  console.log('新客户端连接');
  let currentRoom = null;
  let currentUserId = null;

  // 处理收到的消息
  ws.on('message', (message) => {
    try {
      const data = JSON.parse(message);
      handleMessage(ws, data);
    } catch (e) {
      console.error('消息解析失败:', e);
      ws.send(JSON.stringify({
        type: 'error',
        message: '消息格式错误'
      }));
    }
  });

  // 处理断开连接
  ws.on('close', () => {
    console.log('客户端断开连接');
    if (currentRoom) {
      leaveRoom(ws, currentRoom, currentUserId);
    }
  });

  // 消息路由
  function handleMessage(ws, data) {
    switch (data.type) {
      case 'join':
        handleJoin(ws, data);
        break;
      case 'offer':
      case 'answer':
      case 'ice-candidate':
        forwardMessage(ws, data);
        break;
      case 'leave':
        if (currentRoom) {
          leaveRoom(ws, currentRoom, currentUserId);
        }
        break;
      default:
        ws.send(JSON.stringify({
          type: 'error',
          message: '未知消息类型'
        }));
    }
  }

  // 处理加入房间
  function handleJoin(ws, data) {
    const { roomId, userId } = data;
    
    if (!roomId || !userId) {
      ws.send(JSON.stringify({
        type: 'error',
        message: '缺少 roomId 或 userId'
      }));
      return;
    }

    // 检查房间是否已满
    if (rooms.has(roomId) && rooms.get(roomId).length >= 2) {
      ws.send(JSON.stringify({
        type: 'error',
        message: '房间已满,最多支持两人'
      }));
      return;
    }

    // 加入房间
    if (!rooms.has(roomId)) {
      rooms.set(roomId, []);
    }
    rooms.get(roomId).push(ws);
    currentRoom = roomId;
    currentUserId = userId;

    console.log(`${userId} 加入房间 ${roomId}`);

    // 通知加入者
    ws.send(JSON.stringify({
      type: 'joined',
      roomId: roomId,
      userId: userId
    }));

    // 如果房间已有两人,通知双方可以开始
    if (rooms.get(roomId).length === 2) {
      broadcastToRoom(roomId, {
        type: 'ready',
        roomId: roomId
      });
    }
  }

  // 转发消息给房间内的其他客户端
  function forwardMessage(ws, data) {
    if (!currentRoom) {
      ws.send(JSON.stringify({
        type: 'error',
        message: '请先加入房间'
      }));
      return;
    }

    const clients = rooms.get(currentRoom);
    if (!clients) return;

    // 找到对方客户端并转发
    clients.forEach(client => {
      if (client !== ws && client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(data));
      }
    });
  }

  // 离开房间
  function leaveRoom(ws, roomId, userId) {
    const clients = rooms.get(roomId);
    if (!clients) return;

    // 从房间移除
    const index = clients.indexOf(ws);
    if (index !== -1) {
      clients.splice(index, 1);
    }

    // 通知对方
    broadcastToRoom(roomId, {
      type: 'peer-left',
      userId: userId
    });

    // 如果房间空了,删除房间
    if (clients.length === 0) {
      rooms.delete(roomId);
      console.log(`房间 ${roomId} 已删除`);
    }

    currentRoom = null;
    currentUserId = null;
  }

  // 向房间内所有客户端广播
  function broadcastToRoom(roomId, data) {
    const clients = rooms.get(roomId);
    if (!clients) return;

    clients.forEach(client => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(data));
      }
    });
  }
});

我的经验:这段代码里,我最想强调的就是 forwardMessage 函数。我在项目中遇到过一个问题:客户端发来的 ICE Candidate 消息没有正确转发,导致双方始终无法建立连接。排查后发现是消息类型判断写错了。所以这里我用了白名单方式,只转发 offer、answer、ice-candidate 这三种消息,其他的一律拦截。

消息协议设计

信令服务器和客户端之间需要约定一套消息格式。我设计的是 JSON 格式,每个消息都包含 type 字段,用来区分消息类型。

消息类型 方向 说明
join 客户端 → 服务器 加入房间,需携带 roomId 和 userId
joined 服务器 → 客户端 确认加入成功
ready 服务器 → 客户端 双方已就绪,可以开始协商
offer 客户端 ↔ 客户端 SDP Offer,经服务器转发
answer 客户端 ↔ 客户端 SDP Answer,经服务器转发
ice-candidate 客户端 ↔ 客户端 ICE Candidate,经服务器转发
peer-left 服务器 → 客户端 对方已离开房间
leave 客户端 → 服务器 主动离开房间
error 服务器 → 客户端 错误信息

注意:消息类型的设计要尽量精简。我曾经见过有人把信令消息设计成几十种类型,结果维护起来非常痛苦。记住,信令服务器只做两件事:加入/离开房间,转发协商消息。其他的一概不管。

启动与测试

代码写完了,怎么跑起来?很简单:

node server.js

你会看到控制台输出:

信令服务器已启动,端口: 8080

这时候服务器就在 8080 端口上等着了。你可以用 WebSocket 客户端工具(比如 wscat)来测试:

# 终端1:模拟用户A
wscat -c ws://localhost:8080
> {"type":"join","roomId":"room1","userId":"Alice"}

# 终端2:模拟用户B
wscat -c ws://localhost:8080
> {"type":"join","roomId":"room1","userId":"Bob"}

当两个用户都加入后,服务器会广播 ready 消息,表示可以开始媒体协商了。

常见问题与避坑指南

我在实际项目中遇到过不少问题,挑几个典型的说说:

  • 连接断开后房间状态没清理:客户端可能直接关闭页面,不会发 leave 消息。所以一定要在 close 事件里做清理,否则房间会一直占用。
  • 消息顺序问题:Offer 必须在 Answer 之前到达,ICE Candidate 可以在任何时候发送。如果顺序乱了,连接可能失败。我的做法是在客户端做状态机,确保消息按正确顺序发送。
  • 端口被占用:如果 8080 端口被其他程序占用了,服务器会启动失败。可以换个端口,或者用 lsof -i :8080 查一下谁在用。

核心要点回顾

  • 信令服务器用 Node.js + WebSocket 实现
  • 核心功能:房间管理 + 消息转发
  • 消息协议用 JSON 格式,type 字段区分类型
  • 一定要处理客户端断开连接的清理工作
  • 信令服务器不处理媒体流,只传信令消息

好了,这一章的内容就到这里。代码可以直接拿去用,但建议你亲手敲一遍。只有自己写过,才能真正理解信令服务器的工作原理。下一章我们会把前端代码也补上,让整个视频通话流程跑通。


公众号:蓝海资料掘金营,微信deep3321