松林AI说头像
关注

视频面试系统的技术架构:WebRTC 信令、媒体流与录制

视频面试系统的技术架构: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 的三板斧:信令、媒体流、录制。这三个模块每个单独拆分都不复杂,复杂的是让它们可靠地协同工作。

核心经验:

  1. 信令服务器要简单——它只负责转发,不负责处理
  2. STUN/TURN 是必须的——不要假设用户总在公网环境下
  3. 录制提供兜底——即使面试顺利,录制也能用于后续复盘

对于面试场景,WebRTC 的一个天然优势是:它只涉及 2 个参与者,比多人会议简单得多。不需要 MCU 混流,不需要复杂的选路算法。这也使得自建视频面试系统的技术难度显著低于自建视频会议系统。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/cannonjinx/article/details/163119656

文章来源crawl

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--