用Rust+Vue3构建WebRTC视频会议系统:信令交换与前端实战
简介:一份面向Python课程期末大作业的完整实战工程,基于WebRTC实现了具备房间管理、多人音视频通话能力的视频会议系统。项目采用前后端分离架构,后端为Rust编写的信令服务器(含Cargo配置与rs源码),前端使用Vue3与TypeScript构建,整体覆盖媒体协商、ICE候选交换、连接生命周期处理等关键环节。压缩包共61个文件,大小约162KB,文件类型以Rust源码、Vue单文件组件、TypeScript声明、Markdown文档、JSON配置及sample示例为主,目录划分清晰,便于按模块检索。配套思路文档详细记录了从环境搭建、信令调试到联调踩坑的完整过程,能帮助读者避开常见问题;项目体量轻、可改造性强,适合作为课程设计或期末答辩的基础框架。已有212人学习/下载,适合高校学生作为期末大作业参考,也适合对WebRTC和前端音视频开发感兴趣的初学者快速上手。
1. 一个“Python期末作业”外壳下的WebRTC视频会议系统
解压这份基于WebRTC的视频会议系统.zip,第一眼看到的是 思路.md ,第二眼是 backend 和 frontend 两个目录,唯独没有 main.py 。与其先翻 vscode python环境配置 ,不如把 readme.md 读一遍:名义上是 Python 期末大作业,实际把最吃并发的信令服务放在了 Rust 侧,前端用 Vue3 + TypeScript 走 WebRTC 点对点音视频。这种结构的好处是信令中转不参与媒体流,服务器压力小,双人视频会议即使在一台内存不高的云主机上也能顶住。适合想拿课程设计去答辩、又不想只做“能打开页面”的演示版的人。如果只把它当免费 python 源码大全里的一个小项目来改,反而会错过最值得讲的信号设计。
2. 信令交换设计与Rust后端实现
2.1 为什么视频会议需要信令服务器
WebRTC 的媒体流是浏览器之间直连的,但建立连接之前,双方必须交换两类关键元数据:SDP offer/answer 和 ICE candidate。SDP 里包含音视频编码、网络地址、DTLS 指纹等信息;ICE candidate 是浏览器探测到的可用地址候选。如果没有一个“中间人”把这两类消息从一端送到另一端,两端永远无法得知对方的能力和地址。
这个中间人就是信令服务器。你不需要让它转发 RTP 包,也不需要做媒体转发,它只负责文本消息接力和房间成员管理。这也是为什么这个项目选择 Rust 而不是把信令也塞进 Python 教程里常写的 Flask-SocketIO:信令服务逻辑简单,但连接数一多,并发能力和内存占用就会拉开差距。用 Rust 写一个 WebSocket 网关,再配合 Vue3 前端,整条链路在本地和校园网上都能跑得很稳。
2.2 从Cargo.toml到main.rs的通信骨架
这份源码包里的后端是 Cargo 项目,入口在 backend/src/main.rs ,房间相关的逻辑单独抽到了 room.rs 和 id.rs 。常见做法是用 axum 提供 WebSocket 接口, tokio 管理异步连接。下面是从项目目录结构推出的典型主入口写法,也是这类作业最常用的骨架:
// backend/src/main.rs
use axum::{
extract::{
ws::{Message, WebSocket},
WebSocketUpgrade,
},
response::IntoResponse,
routing::get,
Router,
};
#[tokio::main]
async fn main() {
// 把 WebSocket 路由挂到 /ws/:room, 每个房间一个连接入口
let app = Router::new().route("/ws/:room", get(ws_handler));
// 绑定 127.0.0.1:8010, 供前端 Vite proxy 转发
axum::Server::bind(&"127.0.0.1:8010".parse().unwrap())
.serve(app.into_make_service())
.await
.unwrap();
}
async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_socket(socket))
}
async fn handle_socket(mut socket: WebSocket) {
// 每人进入房间后先等待 join 消息
while let Some(Ok(msg)) = socket.recv().await {
if let Message::Text(text) = msg {
// 将 text 解析为信令 JSON, 广播给同一个房间的其他成员
}
}
}
这里的逻辑是:客户端连接 /ws/:room 后, ws_handler 把 WebSocket 升级并交给 handle_socket 循环。每次收到文本消息,后端解析 JSON,提取 room 字段或直接用 URL 中的参数,再把消息转发给房间里除自己之外的连接。 axum::Server::bind 监听 127.0.0.1:8010 ,前端在 vite.config.ts 里通过 proxy 把 /ws 请求转发到这里,避免浏览器跨域问题。
id.rs 的作用一般是生成唯一 room_id 和 peer_id ,比如用 UUID 或简单的自增随机串。 room.rs 则维护 HashMap<RoomId, Room> ,每个 Room 里放一组 WebSocket Sender。如果看到源码里把连接直接存进 HashMap,不用觉得奇怪,小型课程设计不需要引入 redis 之类的中间件。
2.3 消息类型约定与room状态流转
信令协议是前后端能接上的关键。这个包里大概率在 readme.md 里约定了一份 JSON 消息格式,通常不超过 5 种动作。下面是我在同类项目里最常用的一套约定:
| 动作 | 发送方 | payload | 含义 |
|---|---|---|---|
join | 新加入者 | {roomId, peerId} | 进入房间 |
joined | 后端 | {roomId, peers[]} | 返回房间成员列表 |
offer | 呼叫方 | {targetPeerId, sdp} | 发起 SDP offer |
answer | 应答方 | {targetPeerId, sdp} | 回复 SDP answer |
ice | 任意端 | {targetPeerId, candidate} | 交换 ICE 候选 |
leave | 离开者 | {roomId, peerId} | 退出房间 |
后端收到 join 后,要先把 peerId 加到房间成员列表,再把已有成员列表通过 joined 回给新来的,否则新客户端不知道要跟谁建连。收到 offer 、 answer 、 ice 时,则根据 targetPeerId 找到对应的 WebSocket 连接直接转发,不做 SDP 内容判断。这里一个容易踩的坑是:如果接收循环里没有区分消息类型,把 join 也广播出去,那么所有成员都会收到一条不知道是谁的加入通知,前端逻辑一旦依赖 type === 'join' 更新 UI,页面就会出现两个“自己”。
实时通信中,很常见的错误是在 join 之后立刻发送 offer ,但后端还没把 peer 列表广播回去,导致目标 peerId 找不到。解决顺序通常是:先 join ,收到 joined 回执后再向目标 peerId 发 offer 。信令服务本身是无状态的,有状态的是 Room 成员表,所以调试时优先看 room.rs 里 join 分支是否把 Sender 正确插入。
3. Vue3前端如何接住WebRTC媒体流
3.1 从getUserMedia到RTCPeerConnection的状态机
前端的关键不在 UI,而在 RTCPeerConnection 状态机。打开摄像头用 navigator.mediaDevices.getUserMedia ,拿到 MediaStream 后放到 video.srcObject ,再把流里的 track 通过 addTrack 加进 peer connection。SDP 协商和 ICE 候选收集是并行的,分别通过 onicecandidate 和 createOffer/createAnswer 两个方向触发。
一个常见误区是等 getUserMedia 完成后再 createOffer ,其实顺序无所谓,因为 SDP 中只包含媒体能力和 ICE 候选,真正传输前才绑定。另一个常见误区是把 localStream 先 stop() 了才 createOffer ,这样 SDP 里可能只有音轨没有视频轨道。如果你是照着网上的 webrtc 实例改的,先确认 addTrack 和 createOffer 是否在同一段同步代码里。
3.2 App.vue里的连接流程与信令封装
在 Vue3 的 App.vue 中,比较稳妥的做法是把 WebRTC 逻辑抽到 utils/webrtc.ts ,组件只留生命周期钩子。下面这段是典型实现,核心是维护一个全局的 peerConnection 和 socket :
// frontend/src/utils/webrtc.ts
export class WebRTCClient {
private pc: RTCPeerConnection
private socket: WebSocket
private localStream: MediaStream
constructor(roomId: string) {
// 这里填 STUN 服务器地址, 没有可先用 stun:stun.l.google.com:19302
this.pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
})
this.socket = new WebSocket(`ws://${location.host}/ws/${roomId}`)
this.pc.onicecandidate = (ev) => {
if (ev.candidate) {
this.socket.send(JSON.stringify({
type: 'ice',
candidate: ev.candidate
}))
}
}
// 收到远端流后绑定到远端 video 标签
this.pc.ontrack = (ev) => {
const video = document.getElementById('remote') as HTMLVideoElement
video.srcObject = ev.streams[0]
}
}
async startLocalStream() {
this.localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
})
const localVideo = document.getElementById('local') as HTMLVideoElement
localVideo.srcObject = this.localStream
// 把媒体轨道绑定到 RTCPeerConnection
this.localStream.getTracks().forEach(track => {
this.pc.addTrack(track, this.localStream)
})
}
async call(peerId: string) {
const offer = await this.pc.createOffer()
await this.pc.setLocalDescription(offer)
this.socket.send(JSON.stringify({
type: 'offer',
targetPeerId: peerId,
sdp: this.pc.localDescription
}))
}
}
逻辑上, startLocalStream 和 call 是无序的,但必须保证 call 执行前 localStream 已经 addTrack 完成。 onicecandidate 里发送的 candidate 是已经序列化好的 RTCIceCandidateInit ,信令服务器原样转发即可。 ontrack 里使用 ev.streams[0] 而不是 ev.track ,因为远端音视频 track 属于同一个 MediaStream,绑定到 video 标签时这样处理才正确。
实际项目里 location.host 是 Vite dev server 的地址,WebSocket 会被 proxy 转发到 Rust 后端。如果发现连接总是失败,先在浏览器 Network 面板里看 WS 请求是否返回 101,再看 vite.config.ts 是否配置了 /ws 前缀转发,这是整个前端调试最耗时间的环节。
3.3 关键参数:iceServers与sdpConstraints
RTCPeerConnection 的配置参数决定了你能不能在不理想的网络环境里建立直连。常用参数如下:
| 参数 | 作用 | 常见取值 |
|---|---|---|
iceServers.urls | STUN/TURN 服务器地址 | stun:stun.l.google.com:19302 |
iceCandidatePoolSize | 预收集候选数量 | 10 或省略 |
sdpSemantics | SDP 语法标准 | plan-b 或 unified-plan |
bundlePolicy | 是否合并传输通道 | max-bundle |
rtcpMuxPolicy | 是否复用 RTCP 端口 | require |
推荐 sdpSemantics: 'unified-plan' ,因为 plan-b 在某些浏览器已经过时。 iceCandidatePoolSize 设太大会让创建连接变慢,太小又可能在弱网下候选不够。如果只是课程设计,不设 TURN 也没关系,但若演示环境在校园网内,通常 NAT 比较简单,STUN 就够用了。
4. 房间生命周期与ICE候选的调试切入点
4.1 room.rs里的状态对象与错误处理
Rust 后端的 room.rs 承担的是房间状态管理,最简单的实现是:
// backend/src/room.rs 简化
use std::collections::HashMap;
use tokio::sync::mpsc::Sender;
type Peer = Sender<String>;
pub struct Room {
pub peers: HashMap<String, Peer>,
}
impl Room {
pub fn new() -> Self {
Room { peers: HashMap::new() }
}
pub fn insert(&mut self, peer_id: String, tx: Peer) {
self.peers.insert(peer_id, tx);
}
pub fn remove(&mut self, peer_id: &str) {
self.peers.remove(peer_id);
}
pub fn broadcast_except(&self, from: &str, msg: &str) {
for (id, tx) in &self.peers {
if id != from {
let _ = tx.send(msg.to_string());
}
}
}
}
这里 Sender<String> 是 tokio 的 mpsc 发送端,每个 WebSocket 连接持有自己的 Sender,往这个 sender 里发消息就对应给那个客户端发文本。 broadcast_except 在 offer / answer / ice 转发时非常关键,如果写成 broadcast 就会把消息也回给发送者,前端会收到自己的 offer ,然后错误地创建一个回环连接。
实际项目中, room.rs 还可能要处理“房间满员”“peer 不存在”等错误。一般错误处理就是返回一个带 error 字段的 JSON,前端收到后弹提示。注意 Room::remove 在 leave 时一定要调用,否则 WebSocket 断开后 peers 里残留死连接,下一次广播时 send 失败但不报错,会导致消息静默丢失。
| 错误场景 | 返回消息 | 前端表现 |
|---|---|---|
| 房间不存在 | {type:"error", code:404} | 提示“房间号错误” |
| 房间满员 | {type:"error", code:403} | 按钮变灰 |
| peer_id 不在线 | {type:"error", code:410} | 显示“对方已离开” |
4.2 ICE候选丢失的典型症状
上课环境里最常见的故障不是 SDP 协商失败,而是 ICE 候选没有收集完就开始发送。这种问题的表现是:两端都显示“已连接”,但画面一直黑屏,几秒后浏览器控制台报 ICE failed 或 Failed to set remote answer 。
一个很隐蔽的坑是 onicecandidate 里发送 candidate 对象时序列化丢失了 sdpMid 。浏览器生成的 candidate 里有 sdpMid 、 sdpMLineIndex 、 candidate 三个字段,如果你只取了 ev.candidate.candidate 字符串,接收方 addIceCandidate 时可能因为缺少 sdpMLineIndex 抛异常。正确做法是发送整个 ev.candidate 对象,或者在后端原样透传。
我在调试时习惯先把 ICE 候选打点,看两边是否都收集到 host 和 srflx 类型。如果只有 host,说明两端都在同一局域网,单走 STUN 也能通;如果出现 srflx 但收不到对端的,多半是信令服务器丢消息,或者是把 targetPeerId 写错了,ICE 消息发给了自己而不是对方。
4.3 在Vite dev server里配proxy转发信令
前后端联调时,跨域是最早出现的阻碍。 vite.config.ts 里配置 proxy 可以减少很多麻烦:
// frontend/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/ws': {
target: 'ws://127.0.0.1:8010',
ws: true,
},
},
},
})
这里的 /ws 代理规则会把浏览器里的 ws://localhost:5173/ws/123 转发给 ws://127.0.0.1:8010/ws/123 。 ws: true 是 WebSocket 代理的开关,必须显式打开。如果你用 http:// 而不是 ws:// 作为 target,Vite 会报 Invalid URL 。另外需要确认 Rust 后端监听的是 127.0.0.1:8010 ,如果它监听 0.0.0.0:8010 ,proxy 里 target 仍写 127.0.0.1 即可。
一个容易被忽略的点是:Vite proxy 监听在 localhost 时,前端如果通过手机访问开发服务器,需要把 server.host 改成 0.0.0.0 ,否则手机上连不上 WS。但就算手机连上了,WebRTC 使用 getUserMedia 时必须在 HTTPS 或 localhost 环境下,否则浏览器会拒绝摄像头权限。这属于 WebRTC 技术详解里老生常谈但答辩现场一定会踩的坑。
5. 用本地TURN服务器验证多端互通
如果你想让两个不同校园网的同学在演示时也能稳定通话,只靠 STUN 是不够的。STUN 只能帮你拿到公网地址,但 NAT 类型是 symmetric 时,点对点直连大概率失败。这时需要一台 TURN 服务器做中继。本地搭建可以用 coturn ,这也是课程设计里的加分项。
先安装 coturn(Ubuntu/Debian 系):
sudo apt install coturn
编辑 /etc/turnserver.conf ,最简配置:
listening-port=3478
fingerprint
realm=localhost
user=webrtc:2024
total-quota=100
stale-nonce
no-multicast-peers
use-auth-secret
static-auth-secret=change_this_secret
启动 coturn 后,在前端 RTCPeerConnection 的 iceServers 里加一行:
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'turn:你的服务器IP:3478', username: 'webrtc', credential: '2024' }
]
验证 TURN 是否生效,打开 chrome://webrtc-internals ,查看 ICE Candidate Pair 里的 local-candidate 和 remote-candidate 。如果其中一个是 relay 类型,说明走了 TURN;如果仍然只有 host 和 srflx ,说明两端 NAT 比较友好,直连已经通了。需要观察的是 googCandidatePair 里的 transportAddress ,如果是经过中继的地址,延迟会明显增加,但通话不会断。
这套验证方法同样可以用于检查 WebRTC 链路容量估计的问题:中继模式带宽翻倍,因为媒体流要先上行到 TURN 再下行到对端,所以演示时如果画面卡顿,优先看 CPU 占用和本地码率,而不是单纯调高分辨率。多端互通验证通过后,课程设计的核心演示点基本就稳了,剩下的只是 UI 美化。
更多推荐

所有评论(0)