WebRTC(十三):信令服务器

发布于:2025-06-30 ⋅ 阅读:(16) ⋅ 点赞:(0)

作用

WebRTC 本身只处理媒体流的 P2P 传输、编解码与传输优化但不包含信令协议。WebRTC 的 PeerConnection 建立流程,需要两端完成连接协商和网络打洞信息的交换。这些内容包括:

功能模块 说明
SDP 协商 中转 offer/answer 信息(媒体能力)
ICE 候选交换 中转 NAT 穿透相关的候选地址
用户身份验证 确保用户合法(如 token 登录)
房间管理 支持多人房间、用户列表维护
心跳检测 检测用户连接状态
广播通知 通知用户上线、下线、离开房间
拓展支持 可扩展为 SFU 适配、统计分析等

工作流程

                ┌──────────────┐
                │   Peer A     │
                └──────┬───────┘
                       │ Login
                       ▼
                ┌──────────────┐
                │ Signaling    │
                │   Server     │
                └──────┬───────┘
                       │ Notify online
                       ▼
                ┌──────────────┐
                │   Peer B     │
                └──────────────┘

当 A 和 B 都上线后,建立连接时:

Peer A                       Signaling Server                    Peer B
  |                                 |                              |
  |────── Login (userA) ────────►   |                              |
  |                                 |                              |
  |◄──── Login ack  ─────────────── |                              |
  |                                 |                              |
  |───── Signal: Offer ───────────► | ───────► Offer ───────────► |
  |                                 |                              |
  |◄──── Signal: Answer ◄────────── | ◄────── Answer ◄─────────── |
  |                                 |                              |
  |───── ICE Candidate ───────────► | ───────► Candidate ───────► |
  |◄──── ICE Candidate ◄────────── | ◄────── Candidate ◄──────── |

信令消息

类型 说明
login 用户登录
join 加入房间
leave 离开房间
signal 转发 SDP / ICE 消息
ping 心跳保活
room-users 查询当前房间用户列表
user-joined 广播:新用户加入房间
user-left 广播:用户离开房间或掉线

核心职责

步骤 描述
用户登录 记录客户端 ID,与连接对象关联(如 WebSocket)
信令转发 将一个客户端发来的信令(SDP / ICE)转发给目标客户端
用户管理 管理在线用户、断线清理、广播状态等
会话控制(可选) 支持 room、会议、group call、用户状态通知等

信令服务器部署要求

要求

要点 说明
公网可访问 信令服务器必须有一个公网 IP 或域名
使用 TLS/WSS 推荐使用 wss://(加密 WebSocket),提升浏览器兼容性和安全性
防火墙设置 打开 WebSocket 监听端口(默认如 443, 8443, 9001
使用 CDN/反代(可选) Nginx、Caddy 等反向代理支持 WSS 路由
跨网测试 客户端部署在不同网络(如:4G/家宽/云主机)进行真实互通测试

部署结构图

       +--------------------------+
       |     信令服务器 (WSS)     |
       |   wss://signal.example.com  |
       +--------------------------+
               ▲           ▲
               │           │
         WebRTC A     WebRTC B
         (家宽/4G)     (云主机/4G)

A 和 B 都通过 WebSocket 连接到信令服务器。服务器转发 offer/answer/ICE 信息后,A 和 B 就可以尝试建立直连 P2P 链接。

部署流程

  1. 在云主机或公网服务器部署信令服务器:
./webrtc-signal-server --port 9001
  1. 配置 Nginx 反向代理 + TLS(WSS):
server {
    listen 443 ssl;
    server_name signal.example.com;

    ssl_certificate     /etc/ssl/cert.pem;
    ssl_certificate_key /etc/ssl/key.pem;

    location / {
        proxy_pass http://localhost:9001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
    }
}
  1. 客户端连接信令服务器:
const socket = new WebSocket("wss://signal.example.com");

WebRTC整体部署流程图

                           ┌────────────────────┐
                           │   信令服务器       │
                           │  (wss://signal)    │
                           └───────┬────────────┘
                                   │
                ┌──────────────────┼──────────────────┐
                │                                      │
      ┌────────▼─────────┐                ┌──────────▼──────────┐
      │   Client A        │                │     Client B        │
      │  (WebRTC App)     │                │    (WebRTC App)     │
      └────────┬──────────┘                └──────────┬──────────┘
               │                                       │
       ┌───────▼────────┐                    ┌────────▼───────┐
       │   STUN/TURN    │◀──────────────────▶│   STUN/TURN    │
       └────────────────┘                    └────────────────┘

示例

// WebRTC 信令服务器(支持房间机制 + 用户状态广播 + 心跳 + 可扩展协议 + WSS + 查询 + 限制房间人数)
// 编译依赖: uWebSockets (v20+) + OpenSSL + pthread + nlohmann::json

#include <uwebsockets/App.h>
#include <unordered_map>
#include <unordered_set>
#include <nlohmann/json.hpp>
#include <iostream>
#include <chrono>
#include <thread>
#include <optional>

using json = nlohmann::json;
using namespace std::chrono;

constexpr int MAX_USERS_PER_ROOM = 5;

struct UserData {
    std::string userId;
    std::string roomId;
    std::string protocol;
    time_point<steady_clock> lastPing;
};

using WS = uWS::WebSocket<false, true, UserData>;

std::unordered_map<std::string, WS*> userMap;                // userId -> ws
std::unordered_map<std::string, std::unordered_set<std::string>> roomMap; // roomId -> userId set

void broadcastToRoom(const std::string& roomId, const std::string& senderId, const std::string& message) {
    if (!roomMap.count(roomId)) return;
    for (const auto& userId : roomMap[roomId]) {
        if (userMap.count(userId)) {
            userMap[userId]->send(message, uWS::OpCode::TEXT);
        }
    }
}

void removeUser(WS* ws) {
    auto userId = ws->getUserData()->userId;
    auto roomId = ws->getUserData()->roomId;
    if (!userId.empty()) {
        userMap.erase(userId);
        if (!roomId.empty()) {
            roomMap[roomId].erase(userId);
            json offline = {
                {"type", "user-left"},
                {"userId", userId},
                {"roomId", roomId}
            };
            broadcastToRoom(roomId, userId, offline.dump());
        }
        std::cout << "[Disconnected] " << userId << "\n";
    }
}

int main() {
    std::thread([] {
        while (true) {
            std::this_thread::sleep_for(seconds(30));
            auto now = steady_clock::now();
            for (auto it = userMap.begin(); it != userMap.end();) {
                auto ws = it->second;
                if (duration_cast<seconds>(now - ws->getUserData()->lastPing).count() > 60) {
                    std::cout << "[Timeout] " << it->first << "\n";
                    removeUser(ws);
                    it = userMap.erase(it);
                } else {
                    ++it;
                }
            }
        }
    }).detach();

    uWS::SSLApp({
        .key_file_name = "./certs/key.pem",
        .cert_file_name = "./certs/cert.pem"
    }).ws<UserData>("/*", {
        .open = [](WS* ws) {
            ws->getUserData()->lastPing = steady_clock::now();
        },
        .message = [](WS* ws, std::string_view msg, uWS::OpCode) {
            try {
                json j = json::parse(msg);
                std::string type = j["type"];
                auto& userData = *ws->getUserData();

                if (type == "ping") {
                    userData.lastPing = steady_clock::now();
                } else if (type == "login") {
                    std::string userId = j["userId"];
                    userData.userId = userId;
                    userMap[userId] = ws;
                    if (j.contains("protocol")) {
                        userData.protocol = j["protocol"];
                    }
                    json ack = {
                        {"type", "login"},
                        {"success", true},
                        {"protocol", userData.protocol}
                    };
                    ws->send(ack.dump(), uWS::OpCode::TEXT);
                } else if (type == "join") {
                    std::string roomId = j["roomId"];
                    if (roomMap[roomId].size() >= MAX_USERS_PER_ROOM) {
                        json err = {
                            {"type", "join"},
                            {"success", false},
                            {"error", "room-full"}
                        };
                        ws->send(err.dump(), uWS::OpCode::TEXT);
                        return;
                    }
                    userData.roomId = roomId;
                    roomMap[roomId].insert(userData.userId);
                    json joined = {
                        {"type", "user-joined"},
                        {"userId", userData.userId},
                        {"roomId", roomId},
                        {"protocol", userData.protocol}
                    };
                    broadcastToRoom(roomId, "", joined.dump());
                } else if (type == "leave") {
                    std::string roomId = userData.roomId;
                    roomMap[roomId].erase(userData.userId);
                    userData.roomId.clear();
                    json left = {
                        {"type", "user-left"},
                        {"userId", userData.userId},
                        {"roomId", roomId}
                    };
                    broadcastToRoom(roomId, userData.userId, left.dump());
                } else if (type == "signal") {
                    std::string roomId = userData.roomId;
                    broadcastToRoom(roomId, userData.userId, msg);
                } else if (type == "room-users") {
                    std::string roomId = j["roomId"];
                    json resp = {
                        {"type", "room-users"},
                        {"roomId", roomId},
                        {"users", json::array()}
                    };
                    if (roomMap.count(roomId)) {
                        for (const auto& uid : roomMap[roomId]) {
                            resp["users"].push_back(uid);
                        }
                    }
                    ws->send(resp.dump(), uWS::OpCode::TEXT);
                }
            } catch (...) {
                ws->send("{\"type\":\"error\",\"msg\":\"invalid json\"}", uWS::OpCode::TEXT);
            }
        },
        .close = [](WS* ws, int, std::string_view) {
            removeUser(ws);
        }
    })
    .listen(9003, [](auto* token) {
        if (token) std::cout << "[✔] WSS signaling server running at wss://localhost:9003\n";
        else std::cerr << "[✘] Failed to start WSS server\n";
    }).run();
}