从零构建一个迷你 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 人
- 状态机:active → closed(不可逆)
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 |
可以进一步改进的方向
- Simulcast:让发送端同时发送多个分辨率,SFU 根据接收端带宽选择转发哪个
- SVC(可伸缩视频编码):VP9/AV1 的 SVC 模式,更灵活的带宽适配
- TURN 服务器:处理 NAT 穿透失败的情况(当前只有 STUN)
- 端到端加密:使用 Insertable Streams API 实现 E2EE
- 带宽估计:实现 Transport-Wide Congestion Control (TWcc)
- 会议录制合成:将多个 OGG/IVF 文件合成为单个 MP4
十四、关键经验总结
14.1 WebRTC 的坑
- ICE Candidate 时序:Candidate 可能在 RemoteDescription 设置之前到达,需要缓存
- Renegotiation 竞争:多个 Track 变化可能同时触发 renegotiation,需要排队
- Track 生命周期:
ontrack事件可能触发多次(audio + video),需要正确关联 Stream - 浏览器差异:Safari 对
replaceTrack的支持不如 Chrome
14.2 Go 并发的坑
- WebSocket 写入不是线程安全的:必须用 channel + 单一 writer goroutine
- Map 并发访问:Hub 的 clients map 必须用 sync.RWMutex 保护
- Goroutine 泄漏:TrackForwarder 的 goroutine 必须有 stop channel
- 测试中的竞争:用
go test -race检测,用time.Sleep等待异步操作(不优雅但有效)
14.3 架构决策
- SFU vs MCU:SFU 是正确的选择,CPU 开销低,延迟低,实现简单
- 单进程 vs 微服务:对于 <50 人的会议,单进程足够,避免分布式复杂性
- SQLite vs MySQL:开发用 SQLite(零配置),生产用 MySQL(可选)
- 自定义协议 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 个测试,从零到完整视频会议系统。