WebRTC 项目技术说明(学习版)
本目录用于记录 WebRTC 示例项目的学习向文档,帮助你从整体把握项目架构、信令流程和前端实现。
项目定位:本仓库是一个基于 Go 的最小可用 WebRTC Demo,用于学习一对一通话、信令、媒体控制、DataChannel、录制和简单房间成员管理。
1. 项目整体架构
1.1 模块结构
WebRTC/
├── cmd/
│ └── server/ # HTTP + WebSocket 服务入口
├── internal/
│ └── signal/ # 信令逻辑(房间管理、消息转发)
├── web/ # 浏览器前端 Demo(纯 HTML + JS)
│ ├── index.html
│ └── app.js
├── docs/ # 文档(当前目录)
├── go.mod
└── README.md
- 后端(Go)
- 使用
net/http提供静态文件服务(web/)。 - 使用 Gorilla WebSocket 提供
/ws信令通道。 internal/signal中实现房间管理、消息转发、房间成员列表广播等。
- 使用
- 前端(浏览器)
index.html:简单 UI(房间、ID、按钮、本地/远端视频、聊天、录制等)。app.js:- WebSocket 信令处理;
- WebRTC 媒体流与
RTCPeerConnection; - 静音/摄像头/屏幕共享控制;
- DataChannel 文本聊天;
- 本地录制与下载;
- 房间成员列表展示。
1.2 高层交互示意
graph TD
subgraph BrowserA[浏览器 A]
AUI[HTML UI]
AJS[app.js]
end
subgraph BrowserB[浏览器 B]
BUI[HTML UI]
BJS[app.js]
end
subgraph Server[Go 后端]
HTTP[HTTP 静态文件]
WS["/ws WebSocket"]
HUB[Signal Hub]
end
AUI --> AJS
BUI --> BJS
AJS -- HTTP GET / --> HTTP
BJS -- HTTP GET / --> HTTP
AJS -- WebSocket /ws --> WS
BJS -- WebSocket /ws --> WS
WS --> HUB
AJS -- WebRTC 媒体/数据通道 --> BJS
2. 信令服务器与房间管理
2.1 Message 结构
文件:internal/signal/message.go
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"`
}
join/leave:房间加入、离开事件。offer/answer:SDP 信息,建立 WebRTC 连接用。candidate:ICE 候选,帮助打洞。room_members:服务器广播的“当前房间成员列表”。
可选的辅助消息:
ping/pong:简单心跳(前端定时发送ping,服务端可选回pong),用于学习“保活”概念。
2.2 Hub 与 Client
文件:internal/signal/hub.go
Hub:rooms map[string]map[string]*Client:房间名 -> (客户端 ID -> Client)。upg websocket.Upgrader:升级 HTTP 为 WebSocket。allowedOrigins/allowAllOrigins:WebSocketOrigin校验配置(由服务入口解析WS_ALLOWED_ORIGINS并通过signal.Options注入)。
Client:id:客户端 ID(由前端生成,例如随机字符串)。room:所在房间名。conn:WebSocket 连接。send chan Message:要发送给该客户端的消息队列。
2.3 一对一通话信令时序
sequenceDiagram
participant A as 浏览器 A
participant B as 浏览器 B
participant S as Signal Hub
A->>S: WebSocket /ws
A->>S: {type: "join", room, from: A}
S-->>A: room_members (仅 A)
B->>S: WebSocket /ws
B->>S: {type: "join", room, from: B}
S-->>A: room_members (A, B)
S-->>B: room_members (A, B)
A->>S: offer (to: B)
S-->>B: offer
B->>S: answer (to: A)
S-->>A: answer
A->>S: candidate (to: B)
S-->>B: candidate
B->>S: candidate (to: A)
S-->>A: candidate
2.4 房间成员列表广播逻辑
- 加入房间
addClient- 将
Client放入rooms[room][id]; - 枚举该房间所有
id生成members []string; - 构造
Message{Type: "room_members", Room: room, Members: members}; - 向房间内所有
Client.send非阻塞写入该消息。
- 将
- 离开房间
removeClient- 从
rooms[room]删除该id; - 若房间成员变为 0:删除整个房间;
- 若房间还有其他成员:再次生成
members []string并广播room_members,让前端更新列表。
- 从
3. 前端整体流程与状态机
文件:web/app.js
3.1 核心状态变量
state:'idle' | 'joined' | 'calling' | 'ended'。roomId:当前加入的房间名。myId:本端随机生成 ID(在页面显示Your ID)。peers:peerId -> { pc, dc, remoteStream }的映射表,用于 Mesh 多人通话管理多条连接。localStream:本地媒体流(摄像头 + 麦克风)。screenStream/usingScreen:屏幕共享状态(共享时会对所有 peer 执行replaceTrack)。
3.2 状态机
stateDiagram-v2
[*] --> Idle
Idle: 未连接房间
Joined: 已加入房间
Calling: 通话中
Ended: 通话结束
Idle --> Joined: 点击 Join 且成功连接 & getUserMedia
Joined --> Calling: 点击 Call 并成功发起呼叫
Calling --> Joined: 点击 Hangup 结束通话
Joined --> Idle: WebSocket 关闭 / 刷新页面
- 不同状态下按钮可用性:
Idle:只允许Join;Joined:允许Call,Hangup禁用;Calling:允许继续Call(可对多个成员重复),允许Hangup(结束所有连接但仍留在房间);Ended:可视为回到Joined或Idle,本项目中挂断后回到Joined(仍在房间)。
4. 媒体获取与控制
4.1 获取本地媒体 getMedia()
localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true })
document.getElementById('local').srcObject = localStream
- 成功时将
localStream绑定到本地<video>标签; - 失败时在页面错误区域显示提示(例如权限拒绝)。
4.2 将媒体添加到 RTCPeerConnection
在 ensurePC 中:
await getMedia()
localStream.getTracks().forEach(t => pc.addTrack(t, localStream))
- 多人场景下,每个
peerId都会有独立的pc;当收到远端媒体时,动态创建/找到对应的<video>并绑定:
pc.ontrack = (e) => {
const video = ensureRemoteTile(peerId)
if (video) video.srcObject = e.streams[0]
}
4.3 静音 / 恢复麦克风
const tracks = localStream.getAudioTracks()
muted = !muted
tracks.forEach(t => { t.enabled = !muted })
- 通过修改
MediaStreamTrack.enabled控制是否发送音频; - 按钮文字在
Mute与Unmute间切换。
4.4 摄像头开关
const tracks = localStream.getVideoTracks()
cameraOff = !cameraOff
tracks.forEach(t => { t.enabled = !cameraOff })
- 同样使用
track.enabled,控制是否发送视频画面。
4.5 屏幕共享与 replaceTrack
-
调用
getDisplayMedia获取屏幕流:const stream = await navigator.mediaDevices.getDisplayMedia({ video: true }) const track = stream.getVideoTracks()[0] -
将本地
<video>源切换为屏幕:localVideo.srcObject = screenStream -
若已建立
pc,找到现有发送视频的RTCRtpSender,替换其中的track:for (const peer of peers.values()) { const sender = peer.pc.getSenders().find(s => s.track && s.track.kind === 'video') if (sender) await sender.replaceTrack(track) } -
当用户停止共享(浏览器自带“停止共享”按钮):
- 停掉屏幕共享所有 track;
- 恢复
localStream到本地<video>; - 再次用
replaceTrack把发送的track换回摄像头。
5. DataChannel 文本聊天
5.1 创建与接收
- 呼叫方在
Call时创建 DataChannel:
const dc = peer.pc.createDataChannel('chat')
setupDataChannel(peerId, dc)
- 被叫方在
pc.ondatachannel中接收:
pc.ondatachannel = (e) => {
setupDataChannel(peerId, e.channel)
}
5.2 事件处理与 UI
setupDataChannel 中:
function setupDataChannel(peerId, dc) {
const peer = peers.get(peerId)
if (peer) peer.dc = dc
dc.onopen = () => appendChat('[system] chat channel opened: ' + peerId)
dc.onmessage = (e) => appendChat(peerId + ': ' + e.data)
dc.onclose = () => appendChat('[system] chat channel closed: ' + peerId)
}
appendChat会将文本追加到chatLog区域,并滚动到底部。
发送消息:
chatSend.onclick = () => {
const text = chatInput.value.trim()
if (!text) return
const channels = []
for (const peer of peers.values()) {
if (peer.dc && peer.dc.readyState === 'open') channels.push(peer.dc)
}
if (!channels.length) {
setError('聊天通道未建立(请先 Call)')
return
}
channels.forEach(dc => dc.send(text))
appendChat('me: ' + text)
chatInput.value = ''
}
6. 本地录制与下载
6.1 选择录制对象
函数 getRecordStream():
- 若存在任意 peer 的
remoteStream,优先录制远端流; - 否则若正在屏幕共享,录制
screenStream; - 否则,如果有
localStream,录制本地流; - 否则提示“没有可录制的媒体流”。
6.2 使用 MediaRecorder
- 开始录制:
- 创建
recorder = new MediaRecorder(stream); - 注册
ondataavailable收集recordedChunks; recorder.start()开始;- 按钮状态:
Start Rec禁用,Stop Rec启用。
- 创建
- 停止录制:
- 调用
recorder.stop(); - 在
onstop中:- 根据
recordedChunks创建Blob(video/webm); - 使用
URL.createObjectURL生成下载链接,自动点击隐藏<a>下载; - 最后释放 URL,清空状态。
- 根据
- 调用
7. 房间成员列表
7.1 服务端广播 room_members
见第 2.4 节:每次加入/离开房间都会广播一条成员列表消息给该房间所有客户端。
7.2 前端渲染与点击选人
renderMembers(list)将成员数组渲染为一组按钮:- 自己的 ID 标记为
xxx (you); - 点击其他人的按钮会把该 ID 自动填入
Remote ID输入框,方便快捷发起Call。
- 自己的 ID 标记为
- WebSocket 关闭时,调用
renderMembers([])清空列表。
8. 建议的阅读与实践顺序
如果你想系统学习这个 Demo,可以按下面顺序:
- 通读本文件的第 1~3 节:
- 熟悉整体架构、信令流程、前端状态机。
- 结合代码查看:
internal/signal/hub.go:对照第 2 节理解房间管理与消息转发;web/app.js:对照第 3~7 节理解状态管理、媒体控制、DataChannel 和录制。
- 按 ROADMAP 实践扩展:
- 尝试小规模 Mesh 多人通话;
- 尝试简单 TURN/HTTPS 或 Docker 打包。
你可以在 docs/ 目录继续添加更细的文档,例如:
- signaling.md:只讲信令与房间;
webrtc-api-notes.md:记录 WebRTC API 的实验笔记;troubleshooting.md:踩坑记录和解决方案。