视频面试系统的技术架构:WebRTC 信令、媒体流与录制
一、深度引言与场景痛点:"你能听到我说话吗?"——视频面试的第一关
技术面试的前 30 秒,有超过一半的概率会用来确认"是否能听到"、"画面是否清晰"。虽然 Zoom、腾讯会议等工具已经很成熟,但在自建的面试系统中集成视频通话功能时,这些"成熟"的基础能力仍然需要从零实现。
视频面试的技术实现涉及三个核心模块:信令服务(建立连接)、媒体流传输(音视频通信)和录制存储(面试复盘)。本文将从架构层面拆解这三个模块的实现方案。
二、底层机制与原理深度剖析
WebRTC 的连接建立过程
WebRTC 的连接建立不是简单的"A 直连 B"。在 NAT 网络环境下,两台设备通常无法直接通信,需要借助 STUN/TURN 服务器进行 NAT 穿透。
三、生产级代码实现与最佳实践
信令服务器实现
// WebSocket 信令服务器 —— 负责 WebRTC 连接前的信息交换
const WebSocket = require('ws');
const { v4: uuidv4 } = require('uuid');
class SignalingServer {
constructor(port = 8080) {
this.wss = new WebSocket.Server({ port });
// rooms: { roomId: { participants: [{ws, userId, role}], metadata: {} } }
this.rooms = new Map();
this.wss.on('connection', (ws) => {
const userId = uuidv4();
ws.userId = userId;
console.log(`[信令] 新连接: ${userId}`);
ws.on('message', (data) => {
try {
const msg = JSON.parse(data);
this.handleMessage(ws, msg);
} catch (e) {
ws.send(JSON.stringify({
type: 'error',
message: '消息格式错误,需要合法的 JSON'
}));
}
});
ws.on('close', () => {
this.handleDisconnect(ws);
});
});
console.log(`信令服务器启动在 ws://localhost:${port}`);
}
handleMessage(ws, msg) {
switch (msg.type) {
case 'join_room':
this.handleJoinRoom(ws, msg);
break;
case 'offer':
case 'answer':
case 'ice_candidate':
// 信令转发 —— 原封不动转给房间内的其他用户
this.forwardToRoom(ws, msg);
break;
case 'leave_room':
this.handleLeaveRoom(ws, msg);
break;
default:
ws.send(JSON.stringify({
type: 'error',
message: `未知消息类型: ${msg.type}`
}));
}
}
handleJoinRoom(ws, msg) {
const { roomId, role } = msg; // role: 'candidate' | 'interviewer'
if (!roomId || !role) {
ws.send(JSON.stringify({
type: 'error',
message: 'join_room 消息必须包含 roomId 和 role'
}));
return;
}
// 创建或加入房间
if (!this.rooms.has(roomId)) {
this.rooms.set(roomId, {
participants: [],
metadata: { createdAt: Date.now() }
});
}
const room = this.rooms.get(roomId);
// 房间人数限制 —— 面试场景只需要 2 人
if (room.participants.length >= 2) {
ws.send(JSON.stringify({
type: 'error',
message: '房间已满(最多 2 人)'
}));
return;
}
room.participants.push({ ws, userId: ws.userId, role });
ws.roomId = roomId;
ws.role = role;
// 通知房间内其他人有新用户加入
this.broadcastToRoom(roomId, {
type: 'user_joined',
userId: ws.userId,
role: role
}, ws);
ws.send(JSON.stringify({
type: 'joined',
roomId: roomId,
userId: ws.userId,
participantCount: room.participants.length
}));
}
forwardToRoom(sender, msg) {
const room = this.rooms.get(sender.roomId);
if (!room) return;
// 不做任何修改,原样转发给房间内其他人
room.participants.forEach(p => {
if (p.ws !== sender && p.ws.readyState === WebSocket.OPEN) {
p.ws.send(JSON.stringify(msg));
}
});
}
broadcastToRoom(roomId, msg, excludeWs = null) {
const room = this.rooms.get(roomId);
if (!room) return;
room.participants.forEach(p => {
if (p.ws !== excludeWs && p.ws.readyState === WebSocket.OPEN) {
p.ws.send(JSON.stringify(msg));
}
});
}
handleLeaveRoom(ws, msg) {
this.cleanupParticipant(ws);
}
handleDisconnect(ws) {
this.cleanupParticipant(ws);
}
cleanupParticipant(ws) {
if (!ws.roomId) return;
const room = this.rooms.get(ws.roomId);
if (!room) return;
// 从房间移除用户
room.participants = room.participants.filter(p => p.ws !== ws);
// 通知房间内其他人
this.broadcastToRoom(ws.roomId, {
type: 'user_left',
userId: ws.userId,
role: ws.role
});
// 如果房间为空,清理房间
if (room.participants.length === 0) {
this.rooms.delete(ws.roomId);
console.log(`[信令] 房间 ${ws.roomId} 已清理`);
}
}
}
录制与存储
# 面试录制管理器 —— 将音视频流保存到对象存储
import boto3
import tempfile
from datetime import datetime
class RecordingManager:
"""管理面试录像的录制、存储和检索
由于 WebRTC 是 P2P 的,录制有两种方案:
1. 服务端录制:将媒体流转发到服务器再录制(质量好,带宽消耗大)
2. 客户端录制:浏览器端录制后上传(延迟低,受客户端性能影响)
面试场景推荐客户端录制——带宽消耗小,且面试通常在稳定的网络环境下进行。
"""
def __init__(self, s3_bucket: str, region: str = "ap-southeast-1"):
self.s3 = boto3.client("s3", region_name=region)
self.bucket = s3_bucket
def generate_upload_url(self, interview_id: str,
participant: str, expiry: int = 3600) -> str:
"""生成预签名上传 URL
客户端拿到这个 URL 后可以直接上传文件到对象存储,
不需要经过应用服务器中转,节省带宽。
Args:
interview_id: 面试 ID
participant: 参与者标识(candidate/interviewer)
expiry: URL 有效期(秒),默认 1 小时
"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
key = (
f"interviews/{interview_id}/"
f"recording_{participant}_{timestamp}.webm"
)
# 生成预签名 URL,允许客户端直接上传
url = self.s3.generate_presigned_url(
'put_object',
Params={
'Bucket': self.bucket,
'Key': key,
'ContentType': 'video/webm',
},
ExpiresIn=expiry
)
return url
def list_recordings(self, interview_id: str) -> list[dict]:
"""列出某次面试的所有录制文件"""
prefix = f"interviews/{interview_id}/"
response = self.s3.list_objects_v2(
Bucket=self.bucket,
Prefix=prefix
)
recordings = []
for obj in response.get('Contents', []):
recordings.append({
'key': obj['Key'],
'size_bytes': obj['Size'],
'last_modified': obj['LastModified'].isoformat(),
'download_url': self.generate_download_url(obj['Key'])
})
return recordings
def generate_download_url(self, key: str, expiry: int = 86400) -> str:
"""生成预签名下载 URL"""
return self.s3.generate_presigned_url(
'get_object',
Params={'Bucket': self.bucket, 'Key': key},
ExpiresIn=expiry
)
def delete_recording(self, interview_id: str) -> int:
"""删除某次面试的所有录制文件"""
recordings = self.list_recordings(interview_id)
if not recordings:
return 0
objects = [{'Key': r['key']} for r in recordings]
response = self.s3.delete_objects(
Bucket=self.bucket,
Delete={'Objects': objects}
)
return len(response.get('Deleted', []))
四、边界分析与架构权衡
STUN vs TURN
| 场景 | 方案 | 效果 |
|---|---|---|
| 双方都有公网 IP | 直接 P2P | 最佳 |
| 一方在 NAT 后 | STUN | 大部分情况能穿透 |
| 双方都在对称 NAT 后 | TURN(中继) | 强制中继,质量取决于 TURN 服务器 |
建议:部署免费的 STUN 服务器(如 Google 的 stun.l.google.com:19302),再自建一个 TURN 服务器作为兜底。
录制方案的选择
- 服务端录制:质量稳定,但有中转延迟,且需要更多的服务器带宽
- 客户端录制:无中转延迟,但可能受客户端性能影响(如 CPU 占用高时帧率下降)
推荐方案:使用客户端录制为主,服务端同时保存信令数据和关键事件日志。这样即使客户端录制质量不佳,也能通过日志还原面试过程。
五、总结
视频面试的技术实现本质上是 WebRTC 的三板斧:信令、媒体流、录制。这三个模块每个单独拆分都不复杂,复杂的是让它们可靠地协同工作。
核心经验:
- 信令服务器要简单——它只负责转发,不负责处理
- STUN/TURN 是必须的——不要假设用户总在公网环境下
- 录制提供兜底——即使面试顺利,录制也能用于后续复盘
对于面试场景,WebRTC 的一个天然优势是:它只涉及 2 个参与者,比多人会议简单得多。不需要 MCU 混流,不需要复杂的选路算法。这也使得自建视频面试系统的技术难度显著低于自建视频会议系统。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/cannonjinx/article/details/163119656



