Skip to the content.

← 返回首页

信令与房间管理详解

本篇文档专门深入讲解本 WebRTC Demo 中的信令与房间管理实现,帮助你从源码层面理解:

对照阅读建议:


1. 信令在 WebRTC 中的作用

WebRTC 负责媒体和数据通道的端到端传输,但它本身并不定义“如何把两个浏览器配对起来”。

在浏览器建立 WebRTC 连接之前,需要交换一些“信令”信息:

本项目选择:


2. 消息结构:Message

文件:internal/signal/message.go

package signal

import "encoding/json"

type Message struct {
    Type      string          `json:"type"`
    Room      string          `json:"room"`
    From      string          `json:"from"`
    To        string          `json:"to,omitempty"`
    SDP       json.RawMessage `json:"sdp,omitempty"`
    Candidate json.RawMessage `json:"candidate,omitempty"`
    Members   []string        `json:"members,omitempty"`
}

字段含义:

这种设计的特点:


3. Hub 与 Client:房间数据结构

文件:internal/signal/hub.go

type Hub struct {
    mu    sync.RWMutex
    rooms map[string]map[string]*Client
    upg   websocket.Upgrader

	allowedOrigins  []string
	allowAllOrigins bool
}

type Client struct {
    id   string
    room string
    conn *websocket.Conn
    send chan Message
}

3.1 Hub 的职责

3.2 Client 的职责

这种“读写分离 + 通道”的做法:


4. WebSocket 连接生命周期:HandleWS

核心函数:

func (h *Hub) HandleWS(w http.ResponseWriter, r *http.Request) {
    c, err := h.upg.Upgrade(w, r, nil)
    if err != nil {
        log.Printf("signal: ws upgrade failed from %s path=%s: %v", r.RemoteAddr, r.URL.Path, err)
        return
    }
    log.Printf("signal: ws connected from %s", r.RemoteAddr)
    client := &Client{conn: c, send: make(chan Message, 32)}
    go client.writePump()

    // readPump: blocks until read error (disconnect / protocol error)
    for {
        var msg Message
        if err := c.ReadJSON(&msg); err != nil {
            log.Printf("signal: read message error room=%s id=%s: %v", client.room, client.id, err)
            break
        }
        switch msg.Type {
        case "join":
            client.id = msg.From
            client.room = msg.Room
            h.addClient(client)
        case "leave":
            h.removeClient(client)
        case "ping":
            select {
            case client.send <- Message{Type: "pong"}:
            default:
            }
        case "offer", "answer", "candidate":
            h.forward(msg)
        default:
            log.Printf("signal: unknown msg type=%s room=%s from=%s", msg.Type, msg.Room, msg.From)
        }
    }

    // Cleanup: explicit sequential order to avoid goroutine leak and data race
    // 1. Remove from hub (prevents new messages being sent to client.send)
    h.removeClient(client)
    // 2. Close send channel (terminates writePump's range loop)
    close(client.send)
    // 3. Close WebSocket (read goroutine owns conn lifecycle)
    c.Close()
}

4.1 升级与注册

  1. 使用 Upgrader 将 HTTP 请求升级为 WebSocket:
    • 失败时记录日志并返回;
    • 成功时打印“ws connected from …”。
  2. 为该连接创建一个 Client,带一个缓冲大小为 32 的 send 通道。
  3. 启动 writePump 协程,负责异步写消息。
  4. 当读循环退出后,按顺序执行显式清理:
    • 调用 removeClient 移除客户端(阻止新消息进入 send 通道);
    • 关闭 send 通道(终止 writePump 的 range 循环);
    • 关闭 WebSocket 连接(只有读循环持有连接生命周期)。

4.2 消息读取与分发

ReadJSON 返回错误(连接关闭/协议错误等):


5. 房间管理:addClientremoveClient

5.1 加入房间:addClient

func (h *Hub) addClient(c *Client) {
    h.mu.Lock()
    defer h.mu.Unlock()
    if c.room == "" || c.id == "" {
        return
    }
    m, ok := h.rooms[c.room]
    if !ok {
        m = make(map[string]*Client)
        h.rooms[c.room] = m
    }
    m[c.id] = c
    log.Printf("signal: join room=%s id=%s", c.room, c.id)

    members := make([]string, 0, len(m))
    for id := range m {
        members = append(members, id)
    }
    msg := Message{
        Type:    "room_members",
        Room:    c.room,
        Members: members,
    }
    for _, cli := range m {
        if cli != nil && cli.conn != nil {
            select {
            case cli.send <- msg:
            default:
            }
        }
    }
}

关键点:

5.2 离开房间:removeClient

func (h *Hub) removeClient(c *Client) {
    h.mu.Lock()
    defer h.mu.Unlock()
    if c.room == "" || c.id == "" {
        return
    }
    room := c.room
    if m, ok := h.rooms[room]; ok {
        if _, ok2 := m[c.id]; !ok2 {
            c.room = ""
            return
        }
        delete(m, c.id)
        c.room = ""
        if len(m) == 0 {
            delete(h.rooms, room)
            log.Printf("signal: room %s closed", room)
            return
        }
        members := make([]string, 0, len(m))
        for id := range m {
            members = append(members, id)
        }
        msg := Message{
            Type:    "room_members",
            Room:    room,
            Members: members,
        }
        for _, cli := range m {
            if cli != nil && cli.conn != nil {
                select {
                case cli.send <- msg:
                default:
                }
            }
        }
    }
}

关键点:

注意:


6. 消息转发:forward

func (h *Hub) forward(msg Message) {
    h.mu.RLock()
    defer h.mu.RUnlock()
    if m, ok := h.rooms[msg.Room]; ok {
        if dst, ok := m[msg.To]; ok && dst != nil && dst.conn != nil {
            select {
            case dst.send <- msg:
            default:
                // drop if buffer full to avoid blocking the hub
            }
        }
    }
}

这种设计使得:


7. 前端如何使用这些信令

文件:web/app.js 中,与信令相关的主要逻辑:

7.1 建立 WebSocket 并发送 join

function connectWS() {
  if (ws && ws.readyState === WebSocket.OPEN) return
  const proto = location.protocol === 'https:' ? 'wss://' : 'ws://'
  ws = new WebSocket(proto + location.host + '/ws')
  ws.onopen = () => {
    setError('')
    ws.send(JSON.stringify({ type: 'join', room: roomId, from: myId }))
    setState('joined')
  }
  ws.onmessage = async (ev) => {
    const msg = JSON.parse(ev.data)
    // 这里根据 msg.type 处理 offer/answer/candidate/room_members
  }
}

7.2 处理房间成员列表 room_members

else if (msg.type === 'room_members') {
  const list = msg.members || []
  renderMembers(list)
}

8. 小结与建议

接下来你若想在这个基础上继续进阶,可以尝试:

  1. 设计一个简单的“房间权限/昵称”机制:
    • join 消息中增加昵称字段;
    • 在房间成员列表中显示昵称而不是纯 ID。
  2. 实现小规模 Mesh 多人通话:
    • pc 变为 remoteId -> pc 的映射;
    • 为每个远端创建独立的 RTCPeerConnection<video>
    • 使用同样的信令结构转发不同 pair 的 offer/answer/candidate