简介:一份面向有一定Java基础的全栈开发者的视频通话系统实现资料,围绕Spring Boot、Vue.js、WebSocket与WebRTC四种技术整合,解决远程协作与在线沟通场景下的实时音视频通信需求。资源共2010个文件,以1685个Markdown说明文档为主,另含291个JS前端源码、10个Java后端代码、12个JSON数据配置以及properties、xml等配置文件,压缩包约72.15MB。从环境搭建、WebSocket端点配置、Vue界面组件通信入手,内容涉及视频预览、呼叫与挂断等交互,深入解析基于WebSocket的信令机制、SDP与ICE候选交换、WebRTC连接建立、媒体流捕获编码传输,并涵盖STUN/TURN穿透、回声消除、噪声抑制、连接状态监控及异常恢复等实战难题。大量配置示例和分模块笔记可帮助读者理解视频通话全流程,快速搭建可用Demo,并为课程设计、毕设或企业二次开发提供直接参考。已有3886人学习。 做WebRTC视频通话,选型这一步很多人上来就懵了,尤其当后端锁定了Java系时,网上资料东一块西一块,看着都眼熟,串起来却总差那么一口气。这套"SpringBoot + Vue + WebSocket + WebRTC"组合,我前后做了三个项目才完全跑顺,今天把完整链路和踩过的坑一次性说透。

先说结论:WebRTC本身负责音视频的P2P传输,但它不管"你怎么找到对方"这件事,这个"找人"的过程需要信令服务器来协调,SpringBoot + WebSocket就是干这个的。Vue负责渲染页面和调用浏览器API。整个项目拆开看并不复杂,难点在信令状态管理和网络穿透,这两块我会重点讲。

1. 技术选型的底层逻辑:WebRTC为什么必须配信令服务器

很多人一开始有个误区,觉得WebRTC既然叫"实时通信",那是不是建好连接就不需要服务器了?对,也不对。媒体数据确实是点对点直传的,服务器不碰你的视频帧、音频包,这是WebRTC省流量的核心原因。但在连接建立之前,双方需要先交换一大堆"元信息"——你支持哪些编码格式、你的网络出口是什么、你在哪个房间、怎么找到你——这个过程在WebRTC标准里叫 信令(Signaling) ,而信令走什么通道,标准里刻意没规定,留给了开发者自己选。

为什么WebRTC不把信令也做成协议?因为不同业务场景的信令差异太大了。WebRTC官方文档里讲得很清楚,信令可以用WebSocket,也可以用HTTP轮询,甚至用短信或者信鸽都行。但在实时通话场景里, WebSocket是最自然的选择 :双向通信、低延迟、服务器可以主动推送消息,正好匹配信令交换这种"你来我往"的实时交互模型。

那SpringBoot在这个架构里承担什么角色?它不只是搭个WebSocket端点就完事了,真正的价值在于 会话管理 。用户上线、下线、加入房间、离开房间,这些业务状态需要维护;同一个小队里谁在线、谁不在线,需要实时广播;如果要做通话记录、邀请通知,后端还得落库。SpringBoot作为成熟的后端框架,这些活儿干起来轻车熟路。我第一版图省事用Node.js写信令,后来发现要做鉴权、要接Redis存在线状态、要对接业务系统时,还是得乖乖回到SpringBoot体系里来。

这套组合里Vue的角色反而最单纯:采集本地音视频流、创建RTCPeerConnection、把信令消息通过WebSocket发给后端、再把远端音视频流渲染到页面上。不算简单,但所有逻辑都在浏览器里,调试起来直观得多。

2. 后端信令服务的搭建与核心逻辑

2.1 创建项目并引入WebSocket依赖

先不说代码,先想清楚后端最小需要哪几块能力:接收前端连接、记录连接对应的用户身份、转发信令消息、处理断开连接。基于这个需求,SpringBoot里现成的方案是 spring-boot-starter-websocket ,它底层封装了Java标准的WebSocket实现,也支持Spring的SockJS协议。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
    <version>2.7.18</version>
</dependency>

为什么我推荐2.7.x而不是3.x?如果你是新项目,3.x完全可以,但要注意自己的JDK版本,SpringBoot 3要求JDK 17及以上。我的生产环境是JDK 8跑老项目,2.7.x更稳。另外,如果你用了Spring Security做鉴权,WebSocket握手阶段的Token传参要额外处理,这个后面说。

2.2 实现WebSocket配置与连接管理


@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(new SignalHandler(), "/signal")
                .setAllowedOrigins("*");
    }
}

这个配置类很短,但 setAllowedOrigins("*") 这个配置在生产环境要注意。开发阶段允许跨域没问题,上线后一定要改成你前端实际部署的域名,否则任何网站都可以连接你的信令服务器,等于把通话通道门敞开让人白嫖。我在内网演示时不算在意,上公网第一天就被人扫码连进来吓了一跳,从此学乖了。

接下来是核心的 SignalHandler ,继承 TextWebSocketHandler 。为什么继承这个而不是concrete WebSocket接口?因为 TextWebSocketHandler 已经帮你封装好了文本消息的处理逻辑,我们只需覆写方法即可,代码更简洁。


@Component
public class SignalHandler extends TextWebSocketHandler {

    // 维护房间号 -> 该房间所有用户的WebSocket会话
    private static final ConcurrentHashMap<String, ConcurrentHashMap<String, WebSocketSession>> ROOMS = new ConcurrentHashMap<>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 连接建立时,从URL参数中解析用户ID和房间ID
        String userId = session.getAttributes().get("userId").toString();
        String roomId = session.getAttributes().get("roomId").toString();
        ROOMS.computeIfAbsent(roomId, k -> new ConcurrentHashMap<>())
             .put(userId, session);
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        JSONObject json = JSONObject.parseObject(message.getPayload());
        String type = json.getString("type");
        String roomId = session.getAttributes().get("roomId").toString();
        String userId = session.getAttributes().get("userId").toString();

        switch (type) {
            case "offer":
                // 把offer转发给房间内的其他用户
                sendToOtherUsers(roomId, userId, json);
                break;
            case "answer":
                sendToOtherUsers(roomId, userId, json);
                break;
            case "ice":
                sendToOtherUsers(roomId, userId, json);
                break;
            default:
                break;
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        // 连接关闭时,从房间内移除该用户
        // 并通知其他用户"对方已离线"
    }

    private void sendToOtherUsers(String roomId, String exceptUserId, JSONObject msg) throws IOException {
        ConcurrentHashMap<String, WebSocketSession> room = ROOMS.get(roomId);
        if (room == null) return;
        for (Map.Entry<String, WebSocketSession> entry : room.entrySet()) {
            if (!entry.getKey().equals(exceptUserId)) {
                entry.getValue().sendMessage(new TextMessage(msg.toJSONString()));
            }
        }
    }
}

这段代码有几点要说清楚。

第一, ROOMS 用 ConcurrentHashMap 嵌套的原因是多线程安全。一个信令服务器通常会同时承载几百上千个WebSocket连接,如果只用普通的HashMap,并发状态下轻则丢消息,重则直接抛ConcurrentModificationException。我看过不少教程为了演示省事不写线程安全,上线不出问题才怪。

第二,消息转发用的是"广播给其他人"的策略。这意味着如果房间里有三个人A、B、C,A发送offer时,B和C都能收到,至于谁响应就由前端业务逻辑决定了。一对一通话当然没问题,多对多会议时这种方式算最简模型,够用。

第三,握手阶段的鉴权信息怎么拿?我上面的代码里直接从 session.getAttributes() 里取,实际上 UserId 这个东西需要在握手拦截器里从URL参数或者Header解析出来,再塞进attributes里。核心思路是:因为 handshakeInterceptor 在HTTP握手时执行,可以拿到HttpServletRequest,这时做参数校验,校验通过就放行WebSocket连接建立。


public class AuthHandshakeInterceptor implements HandshakeInterceptor {

    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
                                   WebSocketHandler wsHandler, Map<String, Object> attributes) {
        if (request instanceof ServletServerHttpRequest) {
            ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
            String userId = servletRequest.getServletRequest().getParameter("userId");
            String token = servletRequest.getServletRequest().getParameter("token");
            // 校验token有效性...
            attributes.put("userId", userId);
            attributes.put("roomId", servletRequest.getServletRequest().getParameter("roomId"));
            return true;
        }
        return false;
    }

    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
                               WebSocketHandler wsHandler, Exception exception) {
    }
}

token校验里我再强调一次,上线后这个interceptor必须配上严谨的登录态校验逻辑。开发时图省事直接用前端传来的userId当身份标志,之后遇到同名用户顶号或者乱入房间的问题,排查起来会绕一个大圈子。

3. 前端Vue视频通话页面的实现

3.1 初始化Vue项目与页面布局

前端我用的是Vue 3 + Vite,组件库看喜好选了Element Plus,其实这个项目对UI库不挑剔,原生写也没问题。页面布局很直接:大视频区显示远端画面,左下角小窗放本端画面,底部一排控制按钮(接听、挂断、静音、关摄像头)。

npm create vite@latest video-chat -- --template vue
cd video-chat
npm install element-plus

视频区域用 <video> 标签渲染,注意autoplay和playsinline必须加上,否则移动端浏览器会强制全屏或自动暂停。很多人栽在这两个属性上。

3.2 采集本地媒体流

采集音视频流用的是 getUserMedia ,这一步是整个WebRTC链路里用户感知最直观的部分——浏览器弹权限窗,同意之后摄像头画面就会显示在页面上。


async function startLocalStream() {
    localStream = await navigator.mediaDevices.getUserMedia({
        video: { width: { ideal: 1280 }, height: { ideal: 720 } },
        audio: { echoCancellation: true, noiseSuppression: true }
    });
    localVideoRef.value.srcObject = localStream;
    return localStream;
}

echoCancellation 和 noiseSuppression 这两个参数建议打开,实测中能明显减少回声和底噪。另外, video 里设置理想分辨率不算强制约束,如果摄像头不支持1280x720,浏览器会自动降级到它支持的最大分辨率,不会因此报错。

3.3 创建RTCPeerConnection并处理SDP交换

RTCPeerConnection是整个WebRTC前端逻辑的心脏。需要先把本地媒体流的轨道(track)加进去,然后创建offer,设置本地描述,通过WebSocket发给对端。


const pc = new RTCPeerConnection({
    iceServers: [
        { urls: "stun:stun.l.google.com:19302" },
        // 生产环境必须有TURN服务器,否则NAT对称型网络无法穿透
        {
            urls: "turn:your-turn-server.com:3478",
            username: "your-username",
            credential: "your-password"
        }
    ]
});

// 把本地track挂到peerConnection上
localStream.getTracks().forEach(track => pc.addTrack(track, localStream));

// 监听远端track
pc.ontrack = (event) => {
    remoteVideoRef.value.srcObject = event.streams[0];
};

// 监听ICE候选,实时发送给对端
pc.onicecandidate = (event) => {
    if (event.candidate) {
        ws.send(JSON.stringify({
            type: "ice",
            candidate: event.candidate
        }));
    }
};

// 发起呼叫:创建offer并交换
async function makeOffer() {
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    ws.send(JSON.stringify({ type: "offer", data: offer }));
}

这套代码里最容易出问题的点是ICE候选的时序。 ICE候选是异步产生的,可能在你创建offer之后的一段时间里连续冒出来好几个。 如果对端在收到offer之后、设置好remoteDescription之前,先收到了ICE候选,它调用 addIceCandidate 会直接抛异常。稳妥做法是前端维护一个待处理的候选队列,等remoteDescription设置完成后,再把这个队列里攒着的候选逐个添加进去。


let pendingCandidates = [];
let remoteDescSet = false;

async function handleIceCandidate(candidate) {
    if (!remoteDescSet) {
        pendingCandidates.push(candidate);
        return;
    }
    await pc.addIceCandidate(new RTCIceCandidate(candidate));
}

// 在setRemoteDescription成功之后,先把pending队列清空
async function handleAnswer(answer) {
    await pc.setRemoteDescription(new RTCSessionDescription(answer));
    remoteDescSet = true;
    for (const cand of pendingCandidates) {
        await pc.addIceCandidate(new RTCIceCandidate(cand));
    }
    pendingCandidates = [];
}

这个时序坑我在第二个项目里调了一整个下午,症状是:偶尔能接通,偶尔一直黑屏,反复刷新代码也没用。后来抓了WebSocket的消息日志才看明白,ICE候选总比answer慢半拍。如果你接通的概率不稳定,第一个怀疑对象就应该是这个队列处理逻辑。

3.4 WebSocket在Vue端的封装

前端WebSocket不能裸用,要做简单封装,重点处理断线重连和消息路由。断线重连很重要,因为WebSocket在弱网环境下经常被中间链路悄悄断开,你不重连,对方的挂断动作就永远传不过来。


class SignalClient {
    constructor(url) {
        this.url = url;
        this.ws = null;
        this.listeners = {};
        this.heartbeatTimer = null;
        this.connect();
    }

    connect() {
        this.ws = new WebSocket(this.url);

        this.ws.onmessage = (event) => {
            const msg = JSON.parse(event.data);
            this.dispatch(msg.type, msg.data);
        };

        // 心跳保活:每20秒发一次ping
        this.heartbeatTimer = setInterval(() => {
            if (this.ws.readyState === WebSocket.OPEN) {
                this.ws.send(JSON.stringify({ type: "ping" }));
            }
        }, 20000);
    }

    dispatch(type, data) {
        if (this.listeners[type]) {
            this.listeners[type].forEach(cb => cb(data));
        }
    }

    on(type, callback) {
        this.listeners[type] = this.listeners[type] || [];
        this.listeners[type].push(callback);
    }

    send(msg) {
        if (this.ws.readyState === WebSocket.OPEN) {
            this.ws.send(JSON.stringify(msg));
        } else {
            console.error("WebSocket 未连接");
        }
    }
}

心跳保活不是可有可无的锦上添花。国内云服务器的网络环境下,防火墙和负载均衡器普遍对空闲连接有超时回收策略,一般60秒到90秒没动静就给你静默掐断。20秒一次的ping能让连接保持活跃,同时还能在断线时通过 onclose 事件尽早感知。

4. 联调测试中最容易踩的坑

4.1 同一个浏览器?WebRTC其实可以单页面联调

先给个好消息:联调不一定要两台电脑。Chrome和Edge支持同一个浏览器开两个标签页,访问同一个页面,分别扮演主叫和被叫,WebRTC在局域网内依然能建立连接。这是因为两个标签页属于不同的进程或至少不同的源,WebRTC的逻辑认为它们是两个独立的通信端点。这个技巧对快速验证信令流程非常有用,我绝大多数bug都是在两个标签页之间来回切换调出来的。

4.2 摄像头权限与安全上下文问题

getUserMedia 要求页面是安全上下文(HTTPS或者localhost),否则 navigator.mediaDevices 是undefined。开发时用localhost没问题,但如果你用IP地址访问开发服务器,比如 http://192.168.1.100:5173 ,Chrome会认为这不是安全上下文,摄像头直接不可用。

解决办法有几个:一是开发时坚持用localhost访问;二是把服务器加到浏览器的不安全来源白名单, chrome://flags/#unsafely-treat-insecure-origin-as-secure ,填入你的IP加端口,切到Enabled后重启浏览器;三是在Chrome启动参数里加 --unsafely-treat-insecure-origin-as-secure=http://192.168.1.100:5173 。哪个方便用哪个,本质上都是让浏览器把这个地址临时当成安全上下文看待。

4.3 STUN/TURN配置不完整,跨网络无法连接

局域网内测试一切正常,一上公网就黑屏,这是WebRTC项目最经典的翻车场景。原因是你的STUN服务器帮双方知道了自己的公网映射地址,但如果双方所处的网络有对称型NAT——很多企业网和移动4G/5G网络都是这种——STUN就无能为力了,必须靠TURN服务器中转媒体数据。

TURN服务器可以理解为音视频数据的"中继站",它帮你把媒体流转发过去,绕开无法穿透的NAT。代价是要消耗服务器的带宽和流量,所以生产环境一般用coturn自建,或者购买云厂商的TURN服务。配置TURN时注意,用户名密码要动态生成,不然bot爬到了你的TURN凭据就能白嫖流量。coturn支持基于时间的临时凭证,建议用上。

判断ICE连接状态的技巧:在浏览器控制台执行 pc.iceConnectionState ,如果一直停在"checking"说明双方还没打通;变成"connected"或"completed"就说明媒体通路已经建立。黑屏问题九成以上卡在这里。

4.4 服务器日志里出现"Cross-Site"相关报错

部署时WebSocket握手报403 Cross-Site,十有八九是 setAllowedOrigins 配置的问题。开发环境我可以直接放行 * ,生产环境要精确到域名。另外如果你用Nginx反代WebSocket,注意配置 Upgrade 头相关字段,否则请求到了Nginx这一层就被挡掉了,根本到不了SpringBoot。


location /signal {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_read_timeout 3600s;
}

proxy_read_timeout 3600s 这一行很重要,默认的60秒超时会让WebSocket连接在长空闲后被Nginx强杀,表现就是通话到一半突然断线,而且前端不一定第一时间感知到。加上这行能保证长连接不被无谓中断。

5. 通话接通后的优化思路与调参经验

5.1 实时监控与日志:通没通,看数据说话

接通之后不要急着欢呼,先在浏览器里跑一遍WebRTC的统计接口看数据质量。Chrome的 chrome://webrtc-internals 页面会记录所有连接的详细统计,包括RTT、丢包率、码率、分辨率等。我每次调完参数都会截一张这个页面的图保存下来,对比调整前后有没有真正改善。

页面里主要看三个指标: 丢包率(packetsLost) 、 RTT(往返时延) 和 码率(bitrate) 。丢包率超过2%画面就开始有可感知的卡顿,RTT超过300ms通话体验就很差了。这两个值在浏览器过程内可以直接在 chrome://webrtc-internals 里看到,不用额外写代码。

5.2 带宽自适应与弱网卡顿优化

WebRTC自带了拥塞控制和带宽估计机制,但它默认的带宽上限并不是针对你的实际场景调好的。举个例子,一个720P视频通话默认可能跑到2Mbps左右,如果对方是手机流量且信号不佳,这个码率会导致卡顿。实际场景里,我会手动给RTCPeerConnection设置一个带宽上限:


const sender = pc.getSenders().find(s => s.track && s.track.kind === "video");
if (sender) {
    const params = sender.getParameters();
    if (!params.encodings) {
        params.encodings = [{}];
    }
    params.encodings[0].maxBitrate = 800_000; // 800kbps
    await sender.setParameters(params);
}

把视频码率限制在800kbps,实测在3G网络或者拥堵Wi-Fi下依然能保持基本流畅的画面,代价是清晰度有所下降。这个trade-off对大多数业务场景是值得的,毕竟"能看到人脸"比"超清但卡成幻灯片"实用得多。

音频方面也有一个非常有效的优化:启用 audio 的 googCpuOveruseDetection 等参数没有直观的浏览器API控制,但你可以通过 AudioContext 处理,或者至少设置 echoCancellation 和 noiseSuppression 为 true ,这在采集时已经做过了。还有一个实用技巧是,通话过程中动态切换音频输出设备时,要在 <video> 元素上调用 setSinkId ,否则改系统声音设备后视频通话的音频会继续从默认设备输出。

5.3 断线重连与状态机设计:用户"消失"了怎么办

真实场景里用户不会乖乖点挂断按钮。电梯里没信号、手机锁屏后台被杀、浏览器标签页被系统释放,这些情况都会导致WebSocket连接不经onclose通知直接消失。后端能感知到的是TCP层超时,但Spring的WebSocket handler默认不会立刻触发 afterConnectionClosed ——它基于容器的心跳检查,可能需要几十秒甚至更久才能确认连接已死。

这里有两个层面的应对。前端做心跳超时检测,我上面的 SignalClient 已经发了ping,如果连续3次ping没有收到pong响应,就主动关闭当前连接并重连。后端方面,结合业务需求可以定时轮询房间内连接的空闲状态,做兜底清理。真正严谨的做法是引入"最后活跃时间"字段,每次收到消息都更新时间戳,后台扫描线程发现超过阈值就手动把用户踢下线。

这三个层面的状态管理设计好后,整个系统才算具备基本的"抗骚扰能力",否则线上用户互相看到"对方明明在线却打不通、挂了电话对方还一直呼叫中"的灵异事件,你会被测试和客服追着跑。

6. 最后的经验总结

整套项目从零搭建到基本可用,按照我的实际经验,熟练了之后大概两到三天能搞定。最容易拖慢进度的不是WebRTC本身,而是ECMAScript的异步时序和WebSocket连接状态的交叉管理。再分享一个实用习惯: 写一个简单的日志面板 ,把信令消息、连接状态、ICE状态全部实时显示在页面上。这个日志面板在联调阶段省下的时间,远远超过你写它花的时间。

如果再往下扩展,可以考虑的方向有:多人视频会议(把信令从"转发给其他人"改成"广播给房间内所有人")、录制通话(用MediaRecorder接口采集流)、屏幕共享(替换video track为getDisplayMedia)。每一块都有不少细节值得单独写。先把信令和穿透这两块吃透,后面的路就顺了。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

Logo

邀请您加入社区

更多推荐