从零构建一个迷你 Zoom:Lazy Rabbit Meeting 架构全解析

Posted on Sun 08 March 2026 in Tech • 12 min read

引言

视频会议系统是现代远程协作的基石。Zoom、Google Meet、Microsoft Teams 这些产品背后,是一套复杂的实时通信技术栈。但如果我们剥离商业产品的复杂性,一个视频会议系统的核心到底需要什么?

本文将以 Lazy Rabbit Meeting(懒兔会议)为例,详细讲解如何从零构建一个具备完整功能的迷你视频会议系统。这个项目用 Go + Vue.js + WebRTC 实现,包含用户认证、房间管理、实时聊天、音视频通话、屏幕共享、服务端录制和 Docker 部署——麻雀虽小,五脏俱全。

整个项目通过 8 个迭代(Iteration 0-7)逐步构建,采用严格的 TDD(测试驱动开发)方法,最终交付 231 个测试全部通过的完整系统。

┌─────────────────────────────────────────────────────────────┐
│                    Lazy Rabbit Meeting                       │
│                                                             │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌──────────────┐  │
│  │  认证    │  │  房间    │  │  聊天    │  │  音视频通话   │  │
│  │  Auth    │  │  Room    │  │  Chat    │  │  WebRTC+SFU  │  │
│  └─────────┘  └─────────┘  └─────────┘  └──────────────┘  │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌──────────────┐  │
│  │ 屏幕共享 │  │ 媒体控制 │  │ 服务端   │  │  Docker      │  │
│  │ Screen   │  │ Mute    │  │ 录制     │  │  部署        │  │
│  └─────────┘  └─────────┘  └─────────┘  └──────────────┘  │
└─────────────────────────────────────────────────────────────┘

一、整体架构

1.1 技术选型

层次 技术 选型理由
后端 Go + Gin 高并发、低延迟、原生协程支持
前端 Vue 3 + TypeScript Composition API 适合实时状态管理
实时通信 WebSocket + WebRTC 信令走 WS,媒体走 WebRTC
WebRTC 库 Pion WebRTC v4 纯 Go 实现,无 CGO 依赖(除 SQLite)
ORM GORM 支持 SQLite/MySQL 双数据库
认证 JWT 无状态、适合 WebSocket 鉴权
UI 组件 Element Plus 成熟的 Vue 3 组件库
部署 Docker + Nginx 容器化 + HTTPS/WSS 反向代理

1.2 分层架构

系统采用经典的分层架构,每一层职责清晰:

┌──────────────────────────────────────────────────────────────┐
│                     Frontend (Vue 3 SPA)                     │
│  ┌────────────┐  ┌────────────┐  ┌────────────┐             │
│  │ LoginView  │  │ LobbyView  │  │MeetingView │             │
│  └────────────┘  └────────────┘  └────────────┘             │
│  ┌──────────────────────────────────────────────┐            │
│  │  Composables: useWebSocket / useWebRTC /     │            │
│  │               useMediaDevices                │            │
│  └──────────────────────────────────────────────┘            │
└──────────────────────┬───────────────────────────────────────┘
                       │  HTTP REST + WebSocket (JMPP)
┌──────────────────────┴───────────────────────────────────────┐
│                     Handler Layer (Gin)                       │
│  AuthHandler │ RoomHandler │ WSHandler │ RecordingHandler     │
├──────────────────────────────────────────────────────────────┤
│                     Service Layer                             │
│  RoomService │ ChatService │ SignalingService │ RecordingService│
├──────────────────────────────────────────────────────────────┤
│                     WebRTC Layer (Pion)                       │
│  SFU │ RoomSession │ Peer │ TrackForwarder │ Recorder        │
├──────────────────────────────────────────────────────────────┤
│                     Repository Layer (GORM)                   │
│  UserRepo │ RoomRepo │ MessageRepo │ RecordingRepo           │
├──────────────────────────────────────────────────────────────┤
│                     Domain Layer                              │
│  User │ Room │ ChatMessage │ Recording                       │
└──────────────────────────────────────────────────────────────┘

关键设计原则:

  • Domain 层:纯业务逻辑,不依赖任何框架。Room 有状态机(active → closed),Recording 有生命周期(recording → completed/failed)
  • Repository 层:接口定义在 interfaces.go,GORM 实现在 gorm_*_repo.go,方便替换存储引擎
  • Service 层:编排业务流程,如 RoomService.JoinRoom() 需要检查房间状态、容量、广播通知
  • Handler 层:HTTP/WebSocket 入口,负责参数解析、鉴权、调用 Service
  • WebRTC 层:SFU 引擎,管理 PeerConnection、Track 转发、录制

二、信令协议:JMPP

2.1 为什么需要自定义协议?

WebRTC 本身只定义了媒体传输,不定义信令。Zoom 用的是私有协议,Google Meet 用的是基于 XMPP 的变体。我们设计了一个轻量级的 JSON 协议——JMPP(JSON Meeting Presence Protocol)

所有 WebSocket 消息都遵循统一的信封格式:

{
  "type": "offer",
  "room_id": "abc-123",
  "from": "user-1",
  "to": "user-2",
  "payload": { ... }
}

2.2 消息类型全景

JMPP 定义了 20+ 种消息类型,覆盖会议的完整生命周期:

┌─────────────────────────────────────────────────────────────┐
│                    JMPP 消息类型                              │
├─────────────────────────────────────────────────────────────┤
│  房间管理        │  join, leave                              │
│  文本聊天        │  chat                                     │
│  WebRTC 信令     │  offer, answer, candidate                 │
│  通话控制        │  call, call_accept, call_reject, hangup   │
│  媒体控制        │  mute_audio, unmute_audio,                │
│                  │  mute_video, unmute_video                 │
│  屏幕共享        │  screen_share_start, screen_share_stop,   │
│                  │  screen_share_started, screen_share_stopped│
│  录制控制        │  start_record, stop_record                │
│  服务端通知      │  user_joined, user_left, room_state,      │
│                  │  record_status, error                     │
└─────────────────────────────────────────────────────────────┘

2.3 消息流转示例

以"Alice 加入房间"为例,完整的消息流如下:

Alice                    Server                    Bob (已在房间)
  │                        │                         │
  │── join ──────────────>│                         │
  │                        │── user_joined ────────>│
  │<── room_state ────────│                         │
  │                        │                         │
  │── offer (SDP) ───────>│                         │
  │<── answer (SDP) ──────│                         │
  │                        │                         │
  │<── candidate ─────────│                         │
  │── candidate ──────────>│                         │
  │                        │                         │
  │ ═══════ WebRTC 媒体通道建立 ═══════════════════ │
  │<═══════ 音频/视频 RTP 包 ═══════════════════════>│

2.4 代码实现

// internal/message/jmpp.go

// SignalMessage 是 JMPP 的核心消息结构
type SignalMessage struct {
    Type    string          `json:"type"`
    RoomID  string          `json:"room_id,omitempty"`
    From    string          `json:"from,omitempty"`
    To      string          `json:"to,omitempty"`
    Payload json.RawMessage `json:"payload,omitempty"`
}

// Payload 使用 json.RawMessage 实现多态
// 不同 type 对应不同的 Payload 结构:
// - chat     → ChatPayload{Content}
// - offer    → SDPPayload{Type, SDP}
// - candidate → CandidatePayload{Candidate, SDPMid, SDPMLineIndex}
// - room_state → RoomStatePayload{Participants, IsRecording, ScreenSharer}
// - error    → ErrorPayload{Code, Message}

这种设计的好处是: 1. 统一信封:所有消息共享 type/room_id/from/to 字段,路由逻辑统一 2. Payload 多态:用 json.RawMessage 延迟解析,按 type 分发后再反序列化 3. 双向复用:同一个 WebSocket 连接承载所有信令,无需多连接


三、WebSocket 层:Hub + Client

3.1 Hub 模式

WebSocket 管理采用经典的 Hub 模式(类似 Gorilla WebSocket 的 chat example,但做了增强):

                    ┌─────────────┐
                    │     Hub     │
                    │             │
                    │ clients map │
                    │ userID→Client│
                    └──────┬──────┘
                           │
              ┌────────────┼────────────┐
              │            │            │
        ┌─────┴─────┐ ┌───┴───┐ ┌─────┴─────┐
        │  Client   │ │Client │ │  Client   │
        │  Alice    │ │ Bob   │ │ Charlie   │
        │           │ │       │ │           │
        │ Send chan │ │Send ch│ │ Send chan │
        │ rooms set │ │rooms  │ │ rooms set │
        └───────────┘ └───────┘ └───────────┘
// internal/ws/hub.go

type Hub struct {
    mu      sync.RWMutex
    clients map[string]*Client // userID → Client
}

// 关键方法:
// - Register(client)           注册新连接
// - Unregister(client)         断开连接
// - SendToUser(userID, msg)    点对点发送
// - BroadcastToRoom(roomID, msg, excludeUserID)  房间广播

3.2 Client 生命周期

每个 WebSocket 连接对应一个 Client,包含两个 goroutine:

// internal/ws/client.go

type Client struct {
    Hub      *Hub
    Conn     *websocket.Conn
    UserID   string
    Username string
    Send     chan []byte      // 发送缓冲区(256 条消息)
    rooms    map[string]bool  // 已加入的房间集合
    OnMessage func(client *Client, data []byte)
    OnDisconnect func()
}

// ReadPump: 从 WebSocket 读取消息 → 调用 OnMessage 回调
// WritePump: 从 Send channel 读取消息 → 写入 WebSocket

为什么用 channel 而不是直接写?

WebSocket 连接不是线程安全的。多个 goroutine 同时写会导致数据混乱。通过 Send channel + 单一 WritePump goroutine,保证了写入的串行化。

3.3 消息分发

WSHandler 是 WebSocket 消息的总调度器:

// internal/handler/ws_handler.go

func (h *WSHandler) onMessage(client *ws.Client, data []byte) {
    var msg message.SignalMessage
    json.Unmarshal(data, &msg)
    msg.From = client.UserID  // 服务端强制设置 from,防止伪造

    switch msg.Type {
    case "join":
        h.handleJoin(client, &msg)
    case "leave":
        h.handleLeave(client, &msg)
    case "chat":
        h.chatSvc.HandleRoomMessage(client, &msg)
    case "offer", "answer", "candidate":
        h.signalingSvc.RelaySignal(client, &msg)
    case "mute_audio", "unmute_audio", "mute_video", "unmute_video":
        h.signalingSvc.HandleMuteToggle(client, &msg)
    case "screen_share_start", "screen_share_stop":
        h.signalingSvc.HandleScreenShare(client, &msg)
    case "start_record", "stop_record":
        h.handleRecording(client, &msg)
    }
}

四、SFU 引擎:音视频的核心

4.1 为什么选择 SFU?

视频会议有三种架构:

┌─────────────────────────────────────────────────────────────┐
│  Mesh (P2P)          SFU                    MCU             │
│                                                             │
│  A ←→ B              A → SFU → B           A → MCU → A'    │
│  A ←→ C              B → SFU → A           B → MCU → B'    │
│  B ←→ C              C → SFU → A,B         C → MCU → C'    │
│                                                             │
│  上行: N-1            上行: 1               上行: 1          │
│  下行: N-1            下行: N-1             下行: 1          │
│  服务器: 无            服务器: 转发           服务器: 转码      │
│                                                             │
│  适合: 2-3人          适合: 3-50人           适合: 大型会议    │
│  延迟: 最低           延迟: 低               延迟: 较高        │
│  成本: 零             成本: 中               成本: 高          │
└─────────────────────────────────────────────────────────────┘

SFU(Selective Forwarding Unit) 是最佳平衡点: - 每个参与者只需上传一份媒体流(节省上行带宽) - 服务器只做转发不做转码(CPU 开销低) - 支持 3-50 人的中等规模会议 - Zoom 的核心架构就是 SFU(加上一些 MCU 优化)

4.2 SFU 三层结构

┌─────────────────────────────────────────────────────────────┐
│                        SFU Engine                            │
│                                                             │
│  sessions: map[roomID] → RoomSession                        │
│                                                             │
│  ┌───────────────────────────────────────────────────────┐  │
│  │                    RoomSession                         │  │
│  │                                                       │  │
│  │  peers: map[userID] → Peer                            │  │
│  │  forwarders: map["userID:kind"] → TrackForwarder      │  │
│  │  recorder: *Recorder (optional)                       │  │
│  │  screenSharer: string                                 │  │
│  │                                                       │  │
│  │  ┌─────────┐  ┌─────────┐  ┌─────────┐              │  │
│  │  │  Peer   │  │  Peer   │  │  Peer   │              │  │
│  │  │  Alice  │  │  Bob    │  │ Charlie │              │  │
│  │  │         │  │         │  │         │              │  │
│  │  │ PC      │  │ PC      │  │ PC      │              │  │
│  │  │ inbound │  │ inbound │  │ inbound │              │  │
│  │  │ outbound│  │ outbound│  │ outbound│              │  │
│  │  └─────────┘  └─────────┘  └─────────┘              │  │
│  └───────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘
  • SFU:全局单例,管理所有房间的 Session
  • RoomSession:一个房间的所有 Peer 和 Track 转发逻辑
  • Peer:一个参与者的 PeerConnection,包含入站和出站 Track

4.3 Track 转发机制

这是 SFU 最核心的部分。当 Alice 发布音频时,需要转发给 Bob 和 Charlie:

Alice 的浏览器                    SFU                     Bob 的浏览器
     │                            │                           │
     │── Audio RTP packets ──────>│                           │
     │                            │                           │
     │                     ┌──────┴──────┐                    │
     │                     │ TrackRemote │ (Alice 的入站 Track)│
     │                     └──────┬──────┘                    │
     │                            │                           │
     │                     ┌──────┴──────┐                    │
     │                     │TrackForwarder│                   │
     │                     │ Read RTP    │                    │
     │                     │ Write to    │                    │
     │                     │ LocalTrack  │                    │
     │                     └──────┬──────┘                    │
     │                            │                           │
     │                     ┌──────┴──────┐                    │
     │                     │TrackLocal   │ (添加到 Bob 的 PC)  │
     │                     └──────┬──────┘                    │
     │                            │── Audio RTP packets ─────>│
// internal/webrtc/track.go

type TrackForwarder struct {
    sourceUserID string
    localTrack   *webrtc.TrackLocalStaticRTP
    stopCh       chan struct{}
}

// Start 是一个阻塞的 goroutine,持续读取远端 RTP 包并写入本地 Track
func (f *TrackForwarder) Start(remoteTrack *webrtc.TrackRemote) {
    buf := make([]byte, 1500) // MTU 大小的缓冲区
    for {
        select {
        case <-f.stopCh:
            return
        default:
        }

        n, _, err := remoteTrack.Read(buf)
        if err != nil {
            return // Track 结束或出错
        }

        packet := &rtp.Packet{}
        packet.Unmarshal(buf[:n])
        f.localTrack.WriteRTP(packet)
    }
}

4.4 新参与者加入时的 Track 同步

当 Charlie 加入一个已有 Alice 和 Bob 的房间时:

// internal/webrtc/room_session.go - AddPeer

func (rs *RoomSession) AddPeer(userID, username string) (*Peer, error) {
    peer, _ := NewPeer(userID, username, rs.api, rs.config, rs.sendSignal)

    // 设置回调:当 Charlie 的 Track 就绪时,转发给其他人
    peer.SetOnTrackReady(func(p *Peer, track *webrtc.TrackRemote) {
        rs.onPeerTrackReady(p, track)
    })

    rs.peers[userID] = peer

    // 关键:把已有的 forwarder 的 LocalTrack 添加到 Charlie 的 PC
    // 这样 Charlie 就能收到 Alice 和 Bob 的音视频
    for key, fwd := range rs.forwarders {
        if fwd.SourceUserID() == userID {
            continue // 不发自己的 Track 给自己
        }
        peer.AddOutboundTrack(key, fwd.LocalTrack())
    }

    return peer, nil
}

4.5 Renegotiation(重新协商)

当房间中的 Track 发生变化(有人加入/离开/开始屏幕共享),需要触发 SDP 重新协商:

func (rs *RoomSession) renegotiate(peer *Peer) {
    // 创建新的 Offer
    offerSDP, _ := peer.CreateOffer()

    // 通过 WebSocket 发送给客户端
    payload, _ := json.Marshal(message.SDPPayload{Type: "offer", SDP: offerSDP})
    rs.sendSignal(peer.UserID, &message.SignalMessage{
        Type:    "offer",
        RoomID:  rs.roomID,
        From:    "sfu",
        Payload: payload,
    })
}

客户端收到新的 Offer 后,会创建 Answer 并发回,完成重新协商。


五、房间管理与状态同步

5.1 Room 领域模型

// internal/domain/room.go

type Room struct {
    ID          string     `json:"id"`
    Name        string     `json:"name"`
    Type        string     `json:"type"`        // "private" 或 "group"
    CreatorID   string     `json:"creator_id"`
    MaxUsers    int        `json:"max_users"`   // 2-50
    Status      string     `json:"status"`      // "active" 或 "closed"
    IsRecording bool       `json:"is_recording"`
    CreatedAt   time.Time  `json:"created_at"`
    ClosedAt    *time.Time `json:"closed_at,omitempty"`
}

Room 有严格的验证规则: - 名称不能为空,不超过 100 字符 - 类型只能是 private(1:1)或 group(多人) - 容量 2-50 人 - 状态机:activeclosed(不可逆)

5.2 RoomService:内存 + 数据库双层状态

// internal/service/room_service.go

type RoomService struct {
    mu       sync.RWMutex
    sessions map[string]*RoomSessionState  // 内存中的实时状态
    roomRepo repository.RoomRepository     // 数据库持久化
    hub      *ws.Hub                       // WebSocket 广播
    sfu      *rtc.SFU                      // SFU 引擎
}

type RoomSessionState struct {
    Participants map[string]string  // userID → username
}

为什么需要双层状态?

  • 数据库:持久化房间元数据(名称、创建者、状态),重启后恢复
  • 内存:实时参与者列表,高频读写(每次 join/leave 都要更新)

5.3 加入房间的完整流程

func (s *RoomService) JoinRoom(roomID, userID, username string) (*domain.Room, error) {
    // 1. 从数据库查询房间
    room, err := s.roomRepo.FindByID(roomID)
    if err != nil { return nil, ErrRoomNotFound }

    // 2. 检查房间状态
    if room.Status == domain.RoomStatusClosed { return nil, ErrRoomClosed }

    // 3. 检查容量
    s.mu.Lock()
    session := s.getOrCreateSession(roomID)
    if len(session.Participants) >= room.MaxUsers {
        s.mu.Unlock()
        return nil, ErrRoomFull
    }

    // 4. 添加参与者
    session.Participants[userID] = username
    s.mu.Unlock()

    // 5. 广播 user_joined 给房间其他人
    joinMsg, _ := message.NewSignalMessage("user_joined", roomID, "system",
        message.UserPayload{UserID: userID, Username: username})
    s.hub.BroadcastToRoom(roomID, joinMsg, userID)

    return room, nil
}

5.4 Room State 同步

当用户加入房间时,服务端会发送完整的房间状态:

{
  "type": "room_state",
  "room_id": "abc-123",
  "payload": {
    "room_id": "abc-123",
    "room_name": "Team Standup",
    "participants": [
      {"user_id": "u1", "username": "Alice", "audio_muted": false, "video_muted": false},
      {"user_id": "u2", "username": "Bob", "audio_muted": true, "video_muted": false}
    ],
    "is_recording": false,
    "screen_sharer": ""
  }
}

这确保了新加入的用户能立即看到正确的 UI 状态(谁在静音、谁在共享屏幕、是否在录制)。


六、屏幕共享

6.1 互斥机制

Zoom 的屏幕共享是互斥的——同一时间只有一个人可以共享。我们在 RoomSession 中实现了这个逻辑:

func (rs *RoomSession) StartScreenShare(userID string) error {
    rs.mu.Lock()
    defer rs.mu.Unlock()

    if rs.screenSharer != "" && rs.screenSharer != userID {
        return fmt.Errorf("user %s is already sharing screen", rs.screenSharer)
    }

    rs.screenSharer = userID
    return nil
}

6.2 前端实现

// frontend/src/composables/useWebRTC.ts

async function startScreenShare(displayStream: MediaStream) {
    const videoTrack = displayStream.getVideoTracks()[0]

    // 替换 PeerConnection 中的视频 Track(从摄像头切换到屏幕)
    const sender = pc.getSenders().find(s => s.track?.kind === 'video')
    if (sender) {
        await sender.replaceTrack(videoTrack)
    }

    // 监听浏览器的"停止共享"按钮
    videoTrack.onended = () => {
        stopScreenShare()
    }

    // 通知服务器
    send({ type: 'screen_share_start', room_id: currentRoomId.value })
}

关键技术点: 使用 RTCRTPSender.replaceTrack() 替换 Track,而不是重新创建 PeerConnection。这样可以无缝切换,不需要重新协商 SDP。


七、服务端录制

7.1 录制架构

┌─────────────────────────────────────────────────────────────┐
│                    RoomSession                               │
│                                                             │
│  Peer(Alice) ──── TrackRemote(audio) ──┐                    │
│                                        ├──→ Recorder        │
│  Peer(Bob)   ──── TrackRemote(audio) ──┤    │               │
│              ──── TrackRemote(video) ──┤    ├→ alice_audio.ogg│
│                                        │    ├→ bob_audio.ogg  │
│  Peer(Charlie)── TrackRemote(audio) ──┘    ├→ bob_video.ivf  │
│                                             └→ charlie_audio.ogg│
└─────────────────────────────────────────────────────────────┘

7.2 Recorder 实现

// internal/webrtc/recorder.go

type Recorder struct {
    mu          sync.Mutex
    roomID      string
    outputDir   string
    maxDuration time.Duration
    writers     map[string]*trackWriter  // "userID:kind" → writer
    startedAt   time.Time
    recording   bool
    timer       *time.Timer
    onTimeout   func()
}

type trackWriter struct {
    file   *os.File
    writer media.Writer  // OGG (Opus) 或 IVF (VP8)
}

每个 Track 独立写入一个文件: - 音频:Opus 编码 → OGG 容器(.ogg) - 视频:VP8 编码 → IVF 容器(.ivf

7.3 自动添加新 Track

录制过程中如果有新参与者加入,他们的 Track 会自动添加到 Recorder:

// RoomSession.onPeerTrackReady 中:
if rs.recorder != nil && rs.recorder.IsRecording() {
    rs.recorder.AddTrack(track, peer.UserID, peer.Username)
}

7.4 超时保护

为防止忘记停止录制导致磁盘写满,Recorder 有最大时长限制(默认 2 小时):

func (r *Recorder) Start() error {
    r.recording = true
    r.startedAt = time.Now()

    if r.maxDuration > 0 {
        r.timer = time.AfterFunc(r.maxDuration, func() {
            r.Stop()
            if r.onTimeout != nil {
                r.onTimeout()  // 通知 RecordingService 广播 record_status
            }
        })
    }
    return nil
}

7.5 权限控制

只有房间创建者可以开始/停止录制:

func (s *RecordingService) StartRecording(roomID, userID string) error {
    room, _ := s.roomRepo.FindByID(roomID)

    if room.CreatorID != userID {
        return fmt.Errorf("only room creator can start recording")
    }

    // ... 开始录制
}

八、前端架构

8.1 Composable 模式

Vue 3 的 Composition API 非常适合管理实时通信状态。我们用三个 Composable 封装了核心逻辑:

┌─────────────────────────────────────────────────────────────┐
│                    MeetingView.vue                            │
│                                                             │
│  ┌─────────────────┐  ┌──────────────┐  ┌───────────────┐  │
│  │  useWebSocket   │  │  useWebRTC   │  │useMediaDevices│  │
│  │                 │  │              │  │               │  │
│  │ connect()       │  │ joinRoom()   │  │ getDevices()  │  │
│  │ disconnect()    │  │ leaveRoom()  │  │ getStream()   │  │
│  │ send(msg)       │  │ toggleAudio()│  │ audioDevices  │  │
│  │ on(type, fn)    │  │ toggleVideo()│  │ videoDevices  │  │
│  │ off(type, fn)   │  │ startScreen()│  │               │  │
│  │ connected       │  │ remoteStreams│  │               │  │
│  │ reconnecting    │  │ participants │  │               │  │
│  └─────────────────┘  └──────────────┘  └───────────────┘  │
└─────────────────────────────────────────────────────────────┘

8.2 useWebSocket:自动重连

// frontend/src/composables/useWebSocket.ts

function scheduleReconnect() {
    // 指数退避:1s, 2s, 4s, 8s, ... 最大 30s
    const delay = Math.min(baseDelay * Math.pow(2, reconnectAttempts), 30000)
    reconnectAttempts++

    reconnectTimer = setTimeout(() => {
        connect(currentUrl, currentToken)
    }, delay)
}

8.3 useWebRTC:事件驱动

// 注册信令处理器
on(MessageTypes.OFFER, handleOffer)
on(MessageTypes.ANSWER, handleAnswer)
on(MessageTypes.CANDIDATE, handleCandidate)
on(MessageTypes.ROOM_STATE, handleRoomState)
on(MessageTypes.USER_JOINED, handleUserJoined)
on(MessageTypes.USER_LEFT, handleUserLeft)
// ... 更多处理器

8.4 视频网格布局

MeetingView 使用自适应网格显示所有参与者的视频:

<!-- frontend/src/views/MeetingView.vue -->
<div class="video-grid" :class="gridClass">
  <!-- 本地视频 -->
  <VideoTile :stream="localStream" :username="username" :muted="true" :is-local="true" />

  <!-- 远程视频 -->
  <VideoTile
    v-for="[id, remote] in remoteStreams"
    :key="id"
    :stream="remote.stream"
    :username="remote.username"
    :audio-muted="remote.audioMuted"
    :video-muted="remote.videoMuted"
  />
</div>

网格布局根据参与者数量自动调整: - 1 人:全屏 - 2 人:左右分屏 - 3-4 人:2×2 网格 - 5-9 人:3×3 网格 - 10+ 人:4×N 网格


九、认证与安全

9.1 JWT 认证流程

┌──────────┐                    ┌──────────┐
│  Client  │                    │  Server  │
│          │                    │          │
│  POST /api/v1/auth/register   │          │
│  {username, email, password}──>│          │
│          │<── 201 Created ────│          │
│          │                    │          │
│  POST /api/v1/auth/login      │          │
│  {email, password} ──────────>│          │
│          │<── {token, user} ──│          │
│          │                    │          │
│  GET /ws?token=xxx ──────────>│          │
│          │<── WebSocket ──────│          │
│          │                    │          │
│  GET /api/v1/rooms            │          │
│  Authorization: Bearer xxx ──>│          │
│          │<── [rooms] ────────│          │
└──────────┘                    └──────────┘

9.2 WebSocket 鉴权

WebSocket 不支持自定义 Header,所以 JWT token 通过 query parameter 传递:

func (h *WSHandler) HandleWebSocket(c *gin.Context) {
    tokenStr := c.Query("token")
    claims, err := h.jwtManager.ValidateToken(tokenStr)
    if err != nil {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
        return
    }

    conn, _ := upgrader.Upgrade(c.Writer, c.Request, nil)
    client := ws.NewClient(h.hub, conn, claims.UserID, claims.Username, h.onMessage)
    // ...
}

9.3 消息防伪造

服务端强制设置 from 字段,防止客户端伪造发送者:

func (h *WSHandler) onMessage(client *ws.Client, data []byte) {
    var msg message.SignalMessage
    json.Unmarshal(data, &msg)
    msg.From = client.UserID  // 强制覆盖,不信任客户端
    // ...
}

十、部署架构

10.1 Docker 多阶段构建

# Stage 1: 构建 Vue 前端
FROM node:20-alpine AS frontend-builder
COPY frontend/ ./
RUN npm ci && npm run build

# Stage 2: 构建 Go 后端
FROM golang:1.23-alpine AS backend-builder
COPY . .
RUN CGO_ENABLED=1 go build -ldflags="-s -w" -o server ./cmd/server

# Stage 3: 最小运行镜像
FROM alpine:3.19
COPY --from=backend-builder /app/server .
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
EXPOSE 8080
ENTRYPOINT ["./server"]

最终镜像只有 ~30MB(Alpine + Go 二进制 + 前端静态文件)。

10.2 Nginx 反向代理

┌──────────┐     HTTPS/WSS      ┌──────────┐     HTTP/WS      ┌──────────┐
│  Client  │ ──────────────────> │  Nginx   │ ──────────────── │ Meeting  │
│          │                     │          │                   │  Server  │
│          │                     │ SSL终止   │                   │          │
│          │                     │ Rate Limit│                   │          │
│          │                     │ Gzip     │                   │          │
└──────────┘                     └──────────┘                   └──────────┘

Nginx 配置的关键点:

# WebSocket 需要特殊的 proxy 配置
location /ws {
    proxy_pass http://meeting:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 86400s;  # 24小时,保持长连接
}

10.3 Docker Compose 编排

services:
  meeting:
    build: .
    ports: ["8080:8080"]
    volumes:
      - ./recordings:/app/recordings  # 录制文件持久化
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]

  nginx:
    image: nginx:1.25-alpine
    ports: ["80:80", "443:443"]
    depends_on:
      meeting:
        condition: service_healthy

  mysql:  # 可选,默认用 SQLite
    image: mysql:8.0
    profiles: ["mysql"]

十一、测试策略

11.1 测试金字塔

                    ┌───────────┐
                    │ Acceptance│  7 个文件,35+ 测试
                    │   Tests   │  端到端:HTTP + WebSocket + SFU
                    ├───────────┤
                    │  Service  │  4 个文件,40+ 测试
                    │   Tests   │  业务逻辑 + Mock 依赖
                    ├───────────┤
                    │ Handler   │  2 个文件,30+ 测试
                    │  Tests    │  HTTP 请求/响应
                    ├───────────┤
                    │Repository │  3 个文件,30+ 测试
                    │  Tests    │  SQLite 内存数据库
                    ├───────────┤
                    │  Domain   │  3 个文件,40+ 测试
                    │  Tests    │  纯逻辑,无依赖
                    ├───────────┤
                    │  WebRTC   │  3 个文件,30+ 测试
                    │  Tests    │  SFU + Peer + Recorder
                    └───────────┘

                    总计:231 个测试,11 个包

11.2 验收测试示例

每个迭代都有对应的验收测试文件,测试完整的用户场景:

// test/acceptance_iter7_test.go

func TestAC_7_1_CreatorCanRecord(t *testing.T) {
    app := setupTestAppWithSFU(t)  // 启动完整的测试服务器
    defer app.close()

    alice := app.registerUser(t, "alice", "alice@example.com", "secret123")
    bob := app.registerUser(t, "bob", "bob@example.com", "secret456")

    aliceConn := app.connectWS(t, alice.Token)  // WebSocket 连接
    bobConn := app.connectWS(t, bob.Token)

    room := app.createRoom(t, alice.Token, "record-room", "group", 10)

    // 两人加入房间
    aliceConn.WriteJSON(map[string]interface{}{"type": "join", "room_id": room.ID})
    bobConn.WriteJSON(map[string]interface{}{"type": "join", "room_id": room.ID})

    // Alice(创建者)开始录制
    aliceConn.WriteJSON(map[string]interface{}{
        "type": "start_record", "room_id": room.ID,
    })

    // Bob 应该收到 record_status 通知
    statusMsg := drainUntilType(t, bobConn, "record_status", 3*time.Second)
    var status message.RecordStatusPayload
    statusMsg.ParsePayload(&status)
    assert.True(t, status.Recording)
}

11.3 测试基础设施

测试使用 SQLite 内存数据库 + 真实的 SFU 引擎 + httptest 服务器:

func setupTestAppWithSFU(t *testing.T) *testApp {
    db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
    db.AutoMigrate(&domain.User{}, &domain.Room{}, &domain.ChatMessage{}, &domain.Recording{})

    sfu, _ := rtc.NewSFU(rtc.SFUConfig{
        ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}},
    }, sendSignalFunc)

    // ... 组装所有 Handler 和 Service
    server := httptest.NewServer(router)
    return &testApp{server: server, sfu: sfu, db: db}
}

十二、迭代开发过程

整个项目通过 8 个迭代逐步构建,每个迭代都有明确的目标和验收标准:

迭代 主题 核心交付 测试数
Iter 0 TDD 脚手架 Domain 模型 + 验证规则 + 测试框架 ~40
Iter 1 JWT 认证 注册/登录 + JWT 中间件 ~60
Iter 2 WebSocket + JMPP Hub/Client + 聊天 + 信令分发 ~90
Iter 3 房间管理 CRUD + Join/Leave + P2P 信令 ~120
Iter 4 SFU 引擎 Peer + TrackForwarder + 音频转发 ~150
Iter 5 屏幕共享 + 媒体控制 互斥共享 + Mute/Unmute ~170
Iter 6 视频聊天 + 前端 Vue 3 SPA + 视频网格 ~200
Iter 7 录制 + 部署 Recorder + Docker + Nginx 231

每个迭代遵循 TDD 循环: 1. 写验收测试(红灯) 2. 实现最小代码(绿灯) 3. 重构(保持绿灯)


十三、与 Zoom 的对比

特性 Zoom Lazy Rabbit Meeting
架构 SFU + MCU 混合 纯 SFU
信令协议 私有二进制协议 JMPP (JSON over WebSocket)
媒体传输 私有 RTP 扩展 标准 WebRTC
编码 H.264/H.265 + Opus VP8 + Opus
录制 云端 + 本地 服务端文件
端到端加密 有 (E2EE) 无(SFU 可见明文)
Simulcast 有(多分辨率)
SVC 有(可伸缩编码)
带宽估计 REMB + TWcc 基础 REMB
最大参会者 1000+ ~50
部署 全球数据中心 单机 Docker

可以进一步改进的方向

  1. Simulcast:让发送端同时发送多个分辨率,SFU 根据接收端带宽选择转发哪个
  2. SVC(可伸缩视频编码):VP9/AV1 的 SVC 模式,更灵活的带宽适配
  3. TURN 服务器:处理 NAT 穿透失败的情况(当前只有 STUN)
  4. 端到端加密:使用 Insertable Streams API 实现 E2EE
  5. 带宽估计:实现 Transport-Wide Congestion Control (TWcc)
  6. 会议录制合成:将多个 OGG/IVF 文件合成为单个 MP4

十四、关键经验总结

14.1 WebRTC 的坑

  1. ICE Candidate 时序:Candidate 可能在 RemoteDescription 设置之前到达,需要缓存
  2. Renegotiation 竞争:多个 Track 变化可能同时触发 renegotiation,需要排队
  3. Track 生命周期ontrack 事件可能触发多次(audio + video),需要正确关联 Stream
  4. 浏览器差异:Safari 对 replaceTrack 的支持不如 Chrome

14.2 Go 并发的坑

  1. WebSocket 写入不是线程安全的:必须用 channel + 单一 writer goroutine
  2. Map 并发访问:Hub 的 clients map 必须用 sync.RWMutex 保护
  3. Goroutine 泄漏:TrackForwarder 的 goroutine 必须有 stop channel
  4. 测试中的竞争:用 go test -race 检测,用 time.Sleep 等待异步操作(不优雅但有效)

14.3 架构决策

  1. SFU vs MCU:SFU 是正确的选择,CPU 开销低,延迟低,实现简单
  2. 单进程 vs 微服务:对于 <50 人的会议,单进程足够,避免分布式复杂性
  3. SQLite vs MySQL:开发用 SQLite(零配置),生产用 MySQL(可选)
  4. 自定义协议 vs 标准协议:JMPP 足够简单,不需要引入 SIP/XMPP 的复杂性

结语

构建一个视频会议系统,核心挑战不在于某个单一技术,而在于多个实时系统的协调

  • WebSocket 负责信令的可靠传递
  • WebRTC 负责媒体的低延迟传输
  • SFU 负责多方通话的 Track 路由
  • 状态同步确保所有参与者看到一致的 UI

Lazy Rabbit Meeting 用 ~13,000 行代码(Go 10,754 + Vue/TS 2,509)实现了一个功能完整的视频会议系统。它不是 Zoom 的替代品,但它展示了视频会议系统的核心架构,可以作为学习 WebRTC 和实时通信的参考实现。

完整代码:github.com/walterfan/lazy-rabbit-meeting


本文基于 Lazy Rabbit Meeting 项目的实际开发过程撰写。项目采用 TDD 方法,8 个迭代,231 个测试,从零到完整视频会议系统。