4、信令服务器入门:信令的作用、WebSocket基础、搭建简易信令服务器(Node.js)、客户端连接信令服务器

好,咱们正式开始动手了。

前面几章我们把 WebRTC 的整体架构、通话流程、以及 Android 端的核心 API 都过了一遍。但有个关键问题一直悬着:两个浏览器(或 App)之间,到底怎么互相找到对方?

你想想看,WebRTC 的媒体数据走的是 P2P 通道,但建立这个通道之前,双方得先交换一些“元信息”——比如你的 IP 地址、端口、支持的编解码器、加密参数等等。这些信息不能凭空传递,必须有个中间人帮忙转发。

这个中间人,就是信令服务器

4.1 信令到底在干什么?

说白了,信令就是“握手前的握手”。WebRTC 本身不定义信令协议,它只负责媒体传输。谁来帮双方交换 SDP(会话描述协议)和 ICE Candidate(网络候选地址)?就是信令服务器。

我在项目中遇到过不少新手,上来就写 PeerConnection,结果发现两端根本连不上。为什么?因为没走信令交换。你想想看,A 不知道 B 在哪,B 不知道 A 用什么编码,这怎么连?

信令的核心任务就三个:

  • 交换 SDP:告诉对方“我能收什么格式的视频、音频,我的加密参数是什么”。
  • 交换 ICE Candidate:告诉对方“我有哪些可能的网络路径可以连过来”。
  • 管理房间/用户:谁加入了会议,谁离开了,谁在发言。

重要提醒:信令服务器不传输任何音视频数据。它只传控制信令。别把媒体流往信令服务器上塞,那会压垮它的。

嗯,这里要注意:信令协议你可以自己定义。WebRTC 没有规定你必须用 JSON、XML 还是二进制。我个人习惯用 JSON,简单直观,调试方便。

4.2 WebSocket 基础——为什么选它?

信令传输需要双向、实时、低延迟的通道。HTTP 是请求-响应模式,服务器不能主动推消息给客户端。轮询?太浪费资源了。

WebSocket 就完美契合这个场景。它建立在 TCP 之上,一旦连接建立,双方可以随时互发消息。而且它和 HTTP 共用 80/443 端口,不会被防火墙拦死。

我曾经在一个项目中用过 Socket.IO,后来发现它太重了。对于信令这种轻量级场景,原生 WebSocket 完全够用,而且 Node.js 对 WebSocket 的支持非常成熟。

WebSocket 的几个关键点:

  • 握手阶段:客户端发 HTTP Upgrade 请求,服务器返回 101 Switching Protocols。
  • 数据传输:之后就是全双工通信,消息可以是文本或二进制。
  • 心跳机制:WebSocket 本身没有内置心跳,需要自己实现 ping/pong 来检测连接是否存活。

小技巧:调试 WebSocket 时,可以用 Chrome 的开发者工具 -> Network -> WS 标签,直接看到收发消息的内容。非常方便。

4.3 搭建简易信令服务器(Node.js)

好,咱们直接动手。我会用 Node.js + ws 库搭建一个最简的信令服务器。这个服务器只做一件事:转发消息

先初始化项目:

mkdir simple-signaling
cd simple-signaling
npm init -y
npm install ws

然后创建 server.js

const WebSocket = require('ws');

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

// 存储所有连接的客户端
const clients = new Map();

wss.on('connection', (ws, req) => {
    const clientId = Date.now() + Math.random().toString(36).substr(2, 5);
    console.log(`[+] 客户端 ${clientId} 已连接`);

    // 发送自己的 ID 给客户端
    ws.send(JSON.stringify({ type: 'welcome', clientId }));

    // 接收消息
    ws.on('message', (message) => {
        const data = JSON.parse(message);
        console.log(`[<-] 来自 ${clientId}:`, data);

        switch (data.type) {
            case 'join':
                // 加入房间
                clients.set(clientId, { ws, room: data.room });
                // 通知房间内其他用户
                broadcastToRoom(data.room, {
                    type: 'user-joined',
                    clientId
                }, clientId);
                break;

            case 'offer':
            case 'answer':
            case 'ice-candidate':
                // 转发给目标客户端
                const target = clients.get(data.target);
                if (target) {
                    target.ws.send(JSON.stringify({
                        ...data,
                        sender: clientId
                    }));
                }
                break;

            case 'leave':
                clients.delete(clientId);
                broadcastToRoom(data.room, {
                    type: 'user-left',
                    clientId
                }, clientId);
                break;
        }
    });

    ws.on('close', () => {
        console.log(`[-] 客户端 ${clientId} 断开`);
        // 清理
        const client = clients.get(clientId);
        if (client) {
            broadcastToRoom(client.room, {
                type: 'user-left',
                clientId
            }, clientId);
            clients.delete(clientId);
        }
    });
});

function broadcastToRoom(room, message, excludeId) {
    clients.forEach((client, id) => {
        if (id !== excludeId && client.room === room) {
            client.ws.send(JSON.stringify(message));
        }
    });
}

console.log('信令服务器已启动,端口: 8080');

这个服务器逻辑很简单:

  • 每个客户端连接后,分配一个唯一 ID。
  • 客户端可以加入某个房间(room)。
  • offer、answer、ice-candidate 这三种消息会被直接转发给目标客户端。
  • 用户离开时,通知同房间其他人。

注意:这个服务器没有做任何安全校验。生产环境一定要加 Token 验证、消息校验、以及连接数限制。我曾经见过一个没做限制的信令服务器,被恶意客户端刷了几万个连接,直接 OOM 了。

4.4 客户端连接信令服务器

Android 端怎么连?用 OkHttp 的 WebSocket 支持就行。我一般封装一个 SignalingClient 类:

class SignalingClient {
    private var webSocket: WebSocket? = null
    private var clientId: String? = null

    fun connect(serverUrl: String) {
        val client = OkHttpClient.Builder()
            .readTimeout(0, TimeUnit.MILLISECONDS)  // WebSocket 需要长连接
            .build()

        val request = Request.Builder()
            .url(serverUrl)
            .build()

        webSocket = client.newWebSocket(request, object : WebSocketListener() {
            override fun onOpen(webSocket: WebSocket, response: Response) {
                Log.d("Signaling", "连接成功")
            }

            override fun onMessage(webSocket: WebSocket, text: String) {
                val json = JSONObject(text)
                when (json.getString("type")) {
                    "welcome" -> {
                        clientId = json.getString("clientId")
                        Log.d("Signaling", "我的ID: $clientId")
                    }
                    "user-joined" -> {
                        // 有人加入房间,开始创建 PeerConnection
                    }
                    "offer" -> {
                        // 收到对方的 SDP offer
                    }
                    "answer" -> {
                        // 收到对方的 SDP answer
                    }
                    "ice-candidate" -> {
                        // 收到对方的 ICE 候选地址
                    }
                }
            }

            override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
                Log.e("Signaling", "连接失败: ${t.message}")
                // 重连逻辑
            }
        })
    }

    fun sendMessage(json: JSONObject) {
        webSocket?.send(json.toString())
    }

    fun disconnect() {
        webSocket?.close(1000, "正常关闭")
    }
}

使用时,在 Activity 或 Fragment 中:

val signaling = SignalingClient()
signaling.connect("ws://192.168.1.100:8080")

// 加入房间
val joinMsg = JSONObject().apply {
    put("type", "join")
    put("room", "room-123")
}
signaling.sendMessage(joinMsg)

嗯,这里有个坑:Android 9 以上默认禁止明文 WebSocket(ws://),必须用 wss:// 或者在 network_security_config.xml 里放行。我建议开发阶段直接用 10.0.2.2 连接本机模拟器,或者用真机连同一局域网。

4.5 本章知识体系

下面这张图把信令的整个流程串起来了:

信令服务器核心流程 客户端 A 信令服务器 客户端 B WebSocket 连接 WebSocket 连接 ① 加入房间 ② 通知 B:A 已加入 ③ 发送 Offer (SDP) ④ 转发 Offer 给 B ⑤ 返回 Answer ⑥ ICE Candidate ⑦ 转发 ICE ⑧ ICE Candidate ⑨ P2P 媒体通道建立

整个流程其实就三步:

  1. 连接信令服务器:双方各自建立 WebSocket 连接。
  2. 交换元数据:通过信令服务器转发 SDP 和 ICE Candidate。
  3. 建立 P2P 通道:元数据交换完成后,媒体数据直接走 P2P,信令服务器功成身退。

避坑指南:我曾经在信令服务器上犯过一个低级错误——把 SDP 和 ICE Candidate 当成普通文本直接转发,结果遇到超大 SDP(包含多个视频轨道)时,消息被截断了。后来我改用分片发送,或者用 JSON 数组打包。记住:信令消息不要超过 WebSocket 的单帧限制(通常是 64KB)

好了,信令服务器这块咱们就讲到这里。你现在应该能自己搭一个简单的信令服务器,并且让 Android 客户端连上它了。下一章我们会把信令和 PeerConnection 结合起来,真正跑通一个视频通话。


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