过年期间,做了个 WebRTC 视频会议系统(开源了)
一个程序员的自述:从「这玩意儿怎么这么难」到「原来也没那么复杂」的心路历程
项目地址:NeoView - 基于 mediasoup 的 WebRTC 视频会议系统
一、故事的开始
过年期间,有个想法在我脑子里转悠:能不能自己做一个视频会议系统?
不是那种调用别人 SDK 的「假」视频会议,而是真正的、从底层到上层都自己把控的那种。
然后我打开了 Google,输入了「WebRTC」,接着陷入了长达两周的懵逼状态。
如果你也对 WebRTC 感兴趣,或者正在做类似的技术选型,这篇文章会帮你省掉很多「走弯路」的时间。
二、先聊聊「视频会议」到底难在哪
很多人觉得:不就是两个人视频通话吗?微信、钉钉不都能做到?
但当你真正去研究,就会发现事情没那么简单。
2.1 两个人的通话:简单
A 和 B 打视频电话,只需要建立一条连接:
A ←———→ B
浏览器提供了 WebRTC API,媒体流直接在两人之间传输,不需要经过服务器。完美!(是不是像小学时学的两个人那个易拉罐连根绳就能说话)
2.2 三个人的通话:开始头秃
A、B、C 三个人开会,需要建立几条连接?
A ←——→ B
A ←——→ C
B ←——→ C
3 条连接。(其实也还好,为啥,三个易拉罐呗,哈哈哈)
2.3 十个人的会议:放弃治疗
如果是 10 个人开会呢?
连接数 = n × (n - 1) / 2 = 45 条连接!
100 个人开会?4950 条连接。每增加一个人,所有人的浏览器都要创建新的连接,CPU 直接原地爆炸。(易拉罐和绳子不够用啦!)
这就是为什么我们需要「媒体服务器」。
三、媒体服务器:从「互相直连」到「有个中间人」
既然每个人直接连其他人太复杂,那就找个「中间人」——所有人只跟「中间人」连,「中间人」负责把音视频流转发给其他人。
这个「中间人」,就叫 媒体服务器。
graph LR
subgraph 没有媒体服务器 - 全员互连
A1[A] <--> B1[B]
A1 <--> C1[C]
B1 <--> C1[C]
end
subgraph 有媒体服务器 - 星形连接
A2[A] --> S[媒体服务器]
B2[B] --> S
C2[C] --> S
S --> A2
S --> B2
S --> C2
end
这个模式有个专业名字:SFU(Selective Forwarding Unit,选择性转发单元)。
听起来很高端,其实就是个「转发器」——你把视频发给我,我帮你转发给别人。不做任何处理,只管转发,所以延迟极低。
四、技术选型:为什么我选了 mediasoup?
市面上做 SFU 的媒体服务器有好几个,我调研了一圈:
| 名字 | 什么写的 | 我的感受 |
|---|---|---|
| Jitsi | Java | 功能很全,但太重了。就像想买个自行车,结果给你推来一辆坦克 |
| Janus | C | 性能强悍,但 C 语言......我不配 |
| Pion | Go | 很灵活,但需要自己实现的东西太多了 |
| mediasoup | Node.js/C++ | 刚刚好!轻量、专注、文档清晰 |
mediasoup 就像那种「小而美」的工具——只做一件事,但把这件事做到极致。
它的特点是:
- 延迟极低:毫秒级转发,开会就像面对面
- 资源占用少:一个普通服务器就能跑几百人的会议
- Node.js 原生支持:前端开发者友好
就它了!
五、架构设计:一个「翻译官」的故事
选好了 mediasoup,我以为可以开干了。
然后我发现了一个问题:mediasoup 用的是一种叫 protoo 的协议,而这个协议只有 Node.js 的客户端库。
我的后端是 Spring Boot(Java)啊!
怎么办?有两个选择:
- 用 Node.js 重写后端 —— 我疯了?
- 找个办法让 Java 也能和 mediasoup 通信 —— 这个靠谱
于是,我想到了一个方案:找一个「翻译官」。
sequenceDiagram
participant Java as Spring Boot<br/>(只会说中文)
participant 翻译官 as Node.js 桥接<br/>(中英文都懂)
participant Mediasoup as mediasoup<br/>(只会说英文)
Java->>翻译官: "我想创建一个传输通道"
翻译官->>翻译官: 翻译成 protoo 协议
翻译官->>Mediasoup: createWebRtcTransport
Mediasoup-->>翻译官: 好的,通道已创建
翻译官->>翻译官: 翻译成 JSON
翻译官-->>Java: "传输通道创建成功"
这个「翻译官」就是 neoview-signal-bridge 模块——一个用 Node.js 写的小服务,专门负责把 Java 的 JSON 消息翻译成 protoo 协议,再发给 mediasoup。
桥接层的核心代码
// neoview-signal-bridge/server.js
// 每一个来自 Spring Boot 的连接,都对应一个翻译会话
class BridgeSession {
constructor(ws) {
this.ws = ws; // 跟 Spring Boot 的连线
this.protoo = null; // 跟 mediasoup 的连线
}
// 建立「翻译通道」
connect({ roomId, peerId }) {
// 连接到 mediasoup
const url = `wss://mediasoup-server/?roomId=${roomId}&peerId=${peerId}`;
const transport = new protooClient.WebSocketTransport(url);
this.protoo = new protooClient.Peer(transport);
// mediasoup 发消息过来,翻译后发给 Spring Boot
this.protoo.on('request', (request, accept, reject) => {
this.ws.send(JSON.stringify({
type: 'protooServerRequest',
method: request.method,
data: request.data
}));
});
}
}
很简单对吧?就是个「消息中转站」,把一种语言翻译成另一种语言。
六、整体架构:长什么样?
有了「翻译官」,整个系统的架构就清晰了:
graph TB
subgraph 用户端
Browser["浏览器<br/>(Vue 3 前端)"]
end
subgraph 服务器端
Backend["Spring Boot<br/>(大管家:用户、会议、业务逻辑)"]
Bridge["Node.js 桥接<br/>(翻译官:协议转换)"]
Mediasoup["mediasoup<br/>(媒体服务器:音视频转发)"]
end
subgraph 存储层
DB[("PostgreSQL<br/>(数据库)")]
Cache[("Redis<br/>(缓存)")]
end
Browser -->|"WebSocket 信令"| Backend
Backend <-->|"JSON 消息"| Bridge
Bridge <-->|"protoo 协议"| Mediasoup
Browser -->|"WebRTC 音视频"| Mediasoup
Backend --> DB
Backend --> Cache
角色分工:
| 角色 | 职责 | 比喻 |
|---|---|---|
| Spring Boot | 用户认证、会议管理、业务逻辑 | 公司的「行政部」 |
| Node.js 桥接 | 协议翻译、消息转发 | 公司的「翻译」 |
| mediasoup | 音视频流的接收和转发 | 公司的「物流部」 |
| Vue 前端 | 用户界面、媒体采集和播放 | 公司的「前台」 |
七、用户加入会议:完整流程
说了这么多概念,来看一个真实的场景:小明想加入一个视频会议。
sequenceDiagram
participant 小明 as 小明(浏览器)
participant 后端 as Spring Boot
participant 翻译官 as Node.js 桥接
participant 媒体 as mediasoup
小明->>后端: 1. 我要加入会议 room123
后端->>翻译官: 2. 帮我连上 mediasoup
翻译官->>媒体: 3. 建立 protoo 连接
媒体-->>翻译官: 4. 连上了
翻译官-->>后端: 5. 准备就绪
小明->>后端: 6. 给我服务器支持的编码格式
后端->>翻译官: 7. getRouterRtpCapabilities
翻译官->>媒体: 8. 你支持什么编码?
媒体-->>翻译官: 9. H264、VP8、Opus...
翻译官-->>后端: 10. 返回编码列表
后端-->>小明: 11. 这些编码你可以用
小明->>后端: 12. 帮我创建传输通道
Note over 小明,媒体: 创建「发送通道」和「接收通道」
小明->>媒体: 13. 开始发送视频流
媒体->>媒体: 14. 转发给房间里的其他人
媒体-->>小明: 15. 接收其他人的视频流
用大白话解释:
- 小明点击「加入会议」
- 后端收到请求,通知翻译官去连接 mediasoup
- 翻译官连接成功后,告诉后端「可以开始了」
- 小明的浏览器问服务器:「你支持什么视频编码?」
- 服务器通过翻译官问 mediasoup,得到答案后告诉小明
- 小明的浏览器创建「传输通道」(可以理解为建立一条管道)
- 小明开始发送视频流,mediasoup 收到后转发给房间里的其他人
- 同时,小明也开始接收其他人的视频流
整个过程在 1 秒内完成,用户几乎感觉不到延迟。(在没有cdn加速域名的情况下,上海和山东以及北京用户没有感受到有什么延迟)
八、前端是怎么接入的?
前端使用 mediasoup-client 这个库,它帮我们封装了 WebRTC 的复杂操作。
核心代码(简化版)
import * as mediasoupClient from 'mediasoup-client';
class VideoMeeting {
async join(roomId) {
// 1. 连接后端
await this.signaling.connect();
// 2. 获取服务器的编码能力
const { routerRtpCapabilities } =
await this.signaling.request('getRouterRtpCapabilities');
// 3. 创建 Device(可以理解为「浏览器端的代理」)
this.device = new mediasoupClient.Device();
await this.device.load({ routerRtpCapabilities });
// 4. 创建传输通道
await this.createTransports();
// 5. 正式加入房间
await this.signaling.request('join', {
displayName: '小明',
rtpCapabilities: this.device.rtpCapabilities
});
// 6. 开始发送摄像头画面
await this.publishCamera();
}
async publishCamera() {
// 获取摄像头
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
// 通过发送通道发送视频
const producer = await this.sendTransport.produce({
track: stream.getVideoTracks()[0]
});
}
}
几个关键概念:
| 概念 | 是什么 | 比喻 |
|---|---|---|
| Device | 浏览器端的「代理」 | 前台接待员 |
| Transport | 传输通道 | 数据管道 |
| Producer | 发送音视频流的「生产者」 | 发货员 |
| Consumer | 接收音视频流的「消费者」 | 收货员 |
九、最后想说的话
之前就一直对WebRTC 感兴趣,对音视频会议充满了interest,但确实对 WebRTC 一无所知。
经过此次,我做出了一个能用的视频会议系统,还把它开源了。
这个过程教会我的:
- 技术没那么可怕:很多概念听起来高大上,拆解开来其实就那么回事
- 架构设计很重要:好的架构让每个模块职责清晰,出了问题也好排查
- 开源的力量:mediasoup、protoo-client 这些开源库,帮我省了大量的时间
如果你也想做一个 WebRTC 项目,希望这篇文章能给你一些启发。
十、项目信息
GitHub / Gitee:NeoView
技术栈:Vue 3 + Spring Boot 3.2 + Node.js + mediasoup + PostgreSQL + Redis
功能:
- 多人视频会议
- 屏幕共享
- 实时语音识别
- AI 降噪
- 3D虚拟头像(规划中)
如果觉得有帮助,欢迎 Star ⭐ 支持一下!
作者:椰子皮 本文首发于稀土掘金,转载请注明出处