跳到主要内容基于 SpringBoot、Vue、Netty 与 WebRTC 的视频聊天实现 | 极客日志Java大前端java
基于 SpringBoot、Vue、Netty 与 WebRTC 的视频聊天实现
综述由AI生成介绍基于 SpringBoot、Vue、Netty 和 WebRTC 实现实时视频聊天的完整方案。核心架构利用 SpringBoot 作为信令服务器,Netty 处理高并发 WebSocket 连接,负责交换 SDP 和 ICE 候选信息;客户端通过 WebRTC 建立点对点(P2P)音视频传输,借助 STUN 服务器解决 NAT 穿透问题。文章详细阐述了 WebRTC 原理、系统设计方案,并提供了后端 Netty 消息处理器代码及前端 Vue 交互逻辑,涵盖连接建立、呼叫信令、音视频流采集与渲染等关键步骤,适合需要开发即时通讯或视频会议功能的开发者参考。
芝士奶盖36 浏览 一、关于 WebRTC(Web Real-Time Communication)
**WebRTC 是什么:**是浏览器内置的实时通信技术,能让网页直接实现音视频通话、数据传输,无需安装插件。
**ICE 是什么:**ICE(Interactive Connectivity Establishment)是 WebRTC 中用于解决 NAT 穿透(简单说就是让不同网络下的设备能找到彼此)的框架,而 iceServers 就是给 ICE 提供'辅助服务器'的配置。
**STUN 服务器:**STUN(Session Traversal Utilities for NAT),直译是'NAT 会话穿透工具',它是一种轻量级的网络服务器,核心作用是:帮助处于 NAT(网络地址转换)后的设备(比如你的电脑/手机),获取自己的公网 IP + 端口,以及 NAT 设备的类型,从而让不同 NAT 后的设备能找到彼此,建立点对点(P2P)连接。
先补个前提:为什么需要 STUN?
我们日常用的网络(比如家里的宽带、公司的内网),设备拿到的都是内网 IP(如 192.168.1.100),不是公网 IP。当两个内网设备要直接通信(比如 WebRTC 音视频通话),它们不知道对方的公网地址,就像两个人在不同的小区里,只知道自己的门牌号,却不知道小区的地址和对外的出入口——STUN 服务器就是帮它们查'小区地址 + 出入口'的工具。用一个'打电话'的例子解释:
- 设备 A(内网) 向 STUN 服务器发送一个请求:'请告诉我,你看到的我的地址和端口是什么?'
- STUN 服务器 收到请求后,会记录下请求来源的公网 IP + 端口(这是 NAT 设备给设备 A 分配的对外端口),然后把这个信息返回给设备 A。
- 设备 A 拿到自己的公网地址后,通过信令服务器(比如 WebSocket)把这个地址告诉设备 B;同理,设备 B 也通过 STUN 服务器拿到自己的公网地址并告诉设备 A。
- 最后,设备 A 和设备 B 就可以用彼此的公网地址,直接建立 P2P 连接。
二、设计思路

一、整体视频通话思路
这是一个基于 WebRTC + Spring Boot + STUN 的完整视频通话方案。它的核心是:
- 信令协商:通过 Spring Boot 服务端作为中介,交换双方的网络信息和通话指令。
- 网络穿透:借助 STUN 服务器获取设备的公网 IP 和端口,解决 NAT 穿越问题。
- P2P 直连:在协商完成后,WebRTC 建立端到端的直接连接,负责音视频的实时传输。
- 长连接保活:通过 WebSocket 维持客户端与服务端的通信通道,确保信令能实时送达。
二、各功能模块的作用
1. Spring Boot 项目
- 核心作用:作为信令服务器,是整个通话的'协调中心'。
- 内部组件:
- Netty:高性能网络框架,保证 WebSocket 连接的高并发和稳定 IO 传输。
- WebSocket:在客户端与服务端之间建立长连接,实时传递通话请求、应答、网络信息等信令。
- 关键功能:转发 A、B 双方的信令,让彼此知道对方的网络信息,为后续 P2P 连接铺路。
2. WebRTC(客户端 A/B)
- 核心作用:实现音视频的采集、编码、传输、解码和渲染,是通话的'数据通道'。
- 关键功能:
- 从摄像头、麦克风采集音视频数据。
- 对音视频进行编码压缩,减少传输带宽占用。
- 通过协商建立的 P2P 通道,直接向对方发送音视频流。
- 接收对方的音视频流,解码后在本地播放。
- 自动处理网络抖动、丢包等问题,保证通话流畅。
3. STUN 服务器
- 核心作用:帮助处于 NAT 后的设备(如手机、电脑)获取自己的公网 IP 和端口,是'网络地址翻译的探测器'。
- 关键功能:
- 客户端向 STUN 服务器发送请求,服务器会返回该客户端在公网中的可见 IP 和端口。
- 这个公网地址会通过信令服务器转发给对方,让双方知道如何找到彼此。
- 解决了大多数家庭/企业网络下,设备无法直接被外部访问的问题。
4. 信令(A 信令 / B 信令)
- 核心作用:携带通话所需的控制信息和网络信息,是双方沟通的'语言'。
- 包含内容:
- 通话发起、应答、挂断等控制指令。
- 从 STUN 服务器获取的公网 IP + 端口。
- WebRTC 协商所需的 SDP(会话描述协议)和 ICE(交互式连接建立)候选地址。
- 传递方式:通过 WebSocket 连接,由 Spring Boot 服务端转发给对方。
三、完整通话流程
- 初始化连接:客户端 A 和 B 分别通过 WebSocket 与 Spring Boot 服务端建立长连接。
- 获取公网地址:A 和 B 各自向 STUN 服务器请求,得到自己的公网 IP + 端口。
- 信令交换:A 发起通话请求,携带自己的公网信息和 SDP,通过 WebSocket 发送给服务端;服务端转发给 B;B 应答并返回自己的信息,再由服务端转发给 A。
- P2P 连接建立:A 和 B 根据收到的对方信息,通过 WebRTC 建立直接的 P2P 连接。
- 音视频传输:连接建立后,WebRTC 接管音视频传输,双方开始实时通话。
- 通话结束:任意一方发起挂断信令,服务端转发后,双方关闭 P2P 连接和 WebSocket 连接。
三、前后端设计
步骤 1:核心原理铺垫
- 技术作用:SpringBoot 快速搭建后端项目框架,管理 Netty 服务;Netty 高性能网络通信框架,实现 WebSocket 服务端,处理多客户端连接;WebSocket 保持客户端与服务端的长连接,传输信令消息(注册、呼叫、应答等);WebRTC 浏览器原生支持的实时音视频通信技术,实现音视频采集、编码、传输;Vue 搭建前端页面,实现用户交互(输入用户 ID、发起呼叫)和视频画面展示。
- 核心流程图示:
- 客户端 A → WebSocket 连接 → Netty 服务端(注册)
- 客户端 B → WebSocket 连接 → Netty 服务端(注册)
- 客户端 A 发起呼叫 → 信令(含 SDP 提议)→ 服务端转发 → 客户端 B
- 客户端 B 应答 → 信令(含 SDP 应答)→ 服务端转发 → 客户端 A
- 双方交换 ICE 候选(网络地址)→ 建立 WebRTC P2P 连接 → 实时音视频传输
- 信令交换:相当于'敲门 + 协商'——告诉对方'我要和你视频''我这边的音视频参数是什么''我的网络地址是什么',由 WebSocket+Netty 负责
- 端到端音视频传输:协商完成后,直接在两个客户端之间传输音视频数据,不经过服务端中转(P2P),由 WebRTC 负责
步骤 2:服务端开发(一)—— 项目搭建与依赖配置
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.94.Final</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.25</version>
</dependency>
Netty 是核心,负责网络通信;FastJSON 用于处理前后端传输的 JSON 格式信令
步骤 3:服务端开发(二)—— 核心实体类与消息处理器
信令消息需要包含'消息类型''发送方 ID''接收方 ID''消息内容',所以定义对应的属性
public class Message {
private String type;
private String from;
private String to;
private String data;
}
处理 WebSocket 连接的建立、消息接收与转发、连接断开等事件,是服务端的核心逻辑
package com.qcby.schoolai.commun;
import com.alibaba.fastjson.JSON;
import com.qcby.schoolai.entity.Message;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import org.springframework.context.annotation.Configuration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Configuration
@ChannelHandler.Sharable
public class WebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
public static final ConcurrentHashMap<String, Channel> USER_CHANNEL_MAP = new ConcurrentHashMap<>();
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
System.out.println("与客户端建立连接,通道开启!");
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
String text = msg.text();
Message message = JSON.parseObject(text, Message.class);
System.out.println("收到消息:" + text);
switch (message.getType()) {
case "register":
USER_CHANNEL_MAP.put(message.getFrom(), ctx.channel());
System.out.println("用户 " + message.getFrom() + " 注册成功");
break;
case "call":
case "answer":
case "ice":
Channel targetChannel = USER_CHANNEL_MAP.get(message.getTo());
if (targetChannel != null && targetChannel.isActive()) {
targetChannel.writeAndFlush(new TextWebSocketFrame(text));
System.out.println("转发消息到用户 " + message.getTo());
} else {
System.out.println("用户 " + message.getTo() + " 不在线");
}
break;
default:
System.out.println("未知消息类型:" + message.getType());
}
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
System.out.println("与客户端断开连接,通道关闭!");
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
System.out.println("连接异常:" + cause.getMessage());
USER_CHANNEL_MAP.entrySet().removeIf(entry -> entry.getValue() == ctx.channel());
ctx.close();
}
}
步骤 4:Netty 服务启动类与 SpringBoot 启动类
需要在 SpringBoot 项目启动时,自动启动 Netty 的 WebSocket 服务,监听指定端口(8004)
package com.qcby.schoolai.commun;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.stream.ChunkedWriteHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
@Configuration
public class NettyWebSocketServer {
@Autowired
private WebSocketHandler coordinationSocketHandler;
public void start() throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup group = new NioEventLoopGroup();
try {
ServerBootstrap sb = new ServerBootstrap();
sb.option(ChannelOption.SO_BACKLOG, 1024);
sb.group(group, bossGroup)
.channel(NioServerSocketChannel.class)
.localAddress(8004)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new HttpServerCodec());
ch.pipeline().addLast(new ChunkedWriteHandler());
ch.pipeline().addLast(new HttpObjectAggregator(8192));
ch.pipeline().addLast(new WebSocketServerProtocolHandler("/ws", "WebSocket", true, 65536 * 10));
ch.pipeline().addLast(coordinationSocketHandler);
}
});
ChannelFuture cf = sb.bind().sync();
cf.channel().closeFuture().sync();
} finally {
group.shutdownGracefully().sync();
bossGroup.shutdownGracefully().sync();
}
}
}
import com.qcby.springboot.commun.NettyWebSocketServer;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan("com.qcby.springboot.dao")
public class Application implements CommandLineRunner {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
@Autowired
private NettyWebSocketServer nettyServer;
@Override
public void run(String... args) throws Exception {
nettyServer.start();
}
}
步骤 5:Vue 前端开发
1. 编辑模版 template
<template>
<div>
<h2>WebRTC 视频聊天</h2>
<!-- 1. 用户 ID 输入与连接服务器区域 -->
<div>
<input v-model="userId" placeholder="输入你的用户 ID(如 user1)" type="text" />
<button @click="connect">连接服务器</button>
</div>
<!-- 2. 呼叫功能区域(仅连接成功后显示) -->
<div v-if="socketConnected">
<input v-model="targetUserId" placeholder="输入对方用户 ID(如 user2)" type="text" />
<button @click="call">发起视频呼叫</button>
</div>
<!-- 3. 视频展示区域 -->
<div>
<div>
<p>本地视频</p>
<!-- muted:本地视频静音,避免回声;autoplay:自动播放 -->
<video ref="localVideo" autoplay muted></video>
</div>
<div>
<p>远程视频</p>
<video ref="remoteVideo" autoplay></video>
</div>
</div>
</div>
</template>
<style scoped>
/* 全局容器:居中 + 固定宽度,避免页面太宽/太窄 */
.container {
width: 900px;
margin: 20px auto;
text-align: center;
font-family: "Microsoft YaHei", sans-serif;
}
/* 输入框 + 按钮组样式:统一间距和样式 */
.input-group {
margin: 25px 0;
}
input {
padding: 10px 15px;
width: 220px;
margin-right: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
button {
padding: 10px 20px;
background: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #359469; /* 鼠标悬浮变色,提升交互体验 */
}
/* 视频容器:并排展示两个视频 */
.video-container {
display: flex;
justify-content: center;
gap: 30px;
margin-top: 30px;
}
.video-item {
text-align: center;
}
.video-item p {
margin-bottom: 8px;
font-size: 16px;
color: #333;
}
/* 视频标签样式:固定尺寸,加边框,提升美观度 */
video {
width: 400px;
height: 300px;
border: 1px solid #ccc;
border-radius: 8px;
background-color: #f5f5f5; /* 未加载流时显示浅灰色背景 */
}
</style>
2. 编写核心脚本(script setup),实现交互逻辑
<script setup>
import { ref, onUnmounted } from 'vue';
const userId = ref('');
const targetUserId = ref('');
const socketConnected = ref(false);
const inCall = ref(false);
const localVideo = ref(null);
const remoteVideo = ref(null);
let socket = null;
let peerConnection = null;
let localStream = null;
</script>
ref:创建响应式变量,变量值变化时,页面会自动更新(比如 socketConnected 变为 true,呼叫区域会显示);
onUnmounted:页面销毁时执行的钩子,用于清理资源(避免内存泄漏);
localStream:额外定义本地流变量,方便后续'挂断'时停止摄像头/麦克风。
步骤 2.2:配置 STUN 服务器 + 编写 WebSocket 连接函数
<script setup>
const iceServers = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.qq.com:3478' },
{ urls: 'stun:stun.aliyun.com:3478' }
]
};
const connect = () => {
if (!userId.value.trim()) {
alert('请输入你的用户 ID!(不能为空/仅空格)');
return;
}
socket = new WebSocket(`ws://localhost:8004/ws`);
socket.onopen = () => {
console.log('✅ WebSocket 连接成功');
socketConnected.value = true;
sendMessage({ type: 'register', from: userId.value, to: '', data: '' });
};
socket.onmessage = (e) => {
try {
const message = JSON.parse(e.data);
console.log('📥 收到服务端消息:', message);
handleMessage(message);
} catch (err) {
console.error('❌ 消息解析失败:', err);
}
};
socket.onclose = () => {
console.log('❌ WebSocket 连接关闭');
socketConnected.value = false;
inCall.value = false;
};
socket.onerror = (err) => {
console.error('❌ WebSocket 连接错误:', err);
socketConnected.value = false;
inCall.value = false;
alert('连接服务器失败!请检查服务端是否启动,端口是否正确。');
};
};
</script>
- STUN 服务器:必须配置,否则内网设备无法找到彼此的网络地址,导致视频无法连接;
trim():去除用户 ID 前后空格,避免用户输入空字符/仅空格;
try-catch:防止服务端返回非 JSON 格式消息,导致脚本崩溃;
- WebSocket 地址:
ws:// 对应 HTTP,wss:// 对应 HTTPS,本地测试用 ws:// 即可。
<script setup>
const sendMessage = (message) => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
console.log('📤 发送消息:', message);
} else {
console.error('❌ WebSocket 未连接,无法发送消息');
alert('未连接服务器,请先点击'连接服务器'!');
}
};
</script>
WebSocket.OPEN:状态码为 1,表示连接已打开;其他状态(0 = 连接中,2 = 关闭中,3 = 已关闭)都无法发送消息;
- 封装成通用函数:后续发送 call/answer/ice 消息时,直接调用即可,减少重复代码。
<script setup>
const handleMessage = async (message) => {
switch (message.type) {
case 'call':
await answerCall(message);
break;
case 'answer':
await setRemoteSDP(message.data);
break;
case 'ice':
await addIceCandidate(message.data);
break;
default:
console.log('📌 未知消息类型:', message.type);
}
};
</script>
- 按消息类型分支处理:对应服务端的 register/call/answer/ice 四种类型;
async/await:后续操作(如设置 SDP)是异步的,需等待完成,避免报错。
<script setup>
const initPeerConnection = async () => {
if (peerConnection) {
peerConnection.close();
}
peerConnection = new RTCPeerConnection(iceServers);
peerConnection.onicecandidate = (e) => {
if (e.candidate) {
sendMessage({ type: 'ice', from: userId.value, to: targetUserId.value, data: JSON.stringify(e.candidate) });
}
};
peerConnection.ontrack = (e) => {
remoteVideo.value.srcObject = e.streams[0];
console.log('🎥 收到远程音视频流');
};
};
const call = async () => {
if (!targetUserId.value.trim()) {
alert('请输入对方用户 ID!');
return;
}
try {
await initPeerConnection();
localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
localVideo.value.srcObject = localStream;
localStream.getTracks().forEach(track => {
peerConnection.addTrack(track, localStream);
});
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
sendMessage({ type: 'call', from: userId.value, to: targetUserId.value, data: JSON.stringify(offer) });
inCall.value = true;
console.log('📞 发起视频呼叫:', targetUserId.value);
} catch (err) {
console.error('❌ 发起呼叫失败:', err);
alert('发起呼叫失败!请检查摄像头/麦克风权限,或是否已连接服务器。');
}
};
const answerCall = async (message) => {
targetUserId.value = message.from;
try {
await initPeerConnection();
localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
localVideo.value.srcObject = localStream;
localStream.getTracks().forEach(track => {
peerConnection.addTrack(track, localStream);
});
await peerConnection.setRemoteDescription(JSON.parse(message.data));
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
sendMessage({ type: 'answer', from: userId.value, to: targetUserId.value, data: JSON.stringify(answer) });
inCall.value = true;
console.log('📞 应答视频呼叫:', targetUserId.value);
} catch (err) {
console.error('❌ 应答呼叫失败:', err);
alert('应答呼叫失败!');
}
};
const setRemoteSDP = async (sdpStr) => {
try {
const sdp = JSON.parse(sdpStr);
await peerConnection.setRemoteDescription(new RTCSessionDescription(sdp));
console.log('✅ 设置远程 SDP 成功');
} catch (err) {
console.error('❌ 设置远程 SDP 失败:', err);
}
};
const addIceCandidate = async (iceStr) => {
try {
const ice = JSON.parse(iceStr);
await peerConnection.addIceCandidate(new RTCIceCandidate(ice));
console.log('✅ 添加 ICE 候选成功');
} catch (err) {
console.error('❌ 添加 ICE 候选失败:', err);
}
};
</script>
getUserMedia:浏览器原生 API,请求音视频权限,返回本地流;如果用户拒绝权限,会抛出错误,所以用 try-catch 包裹;
PeerConnection:WebRTC 的核心对象,负责协商连接、传输音视频流;
ontrack 事件:对方的音视频流到达时触发,将流绑定到 remoteVideo 即可显示对方画面;
onicecandidate 事件:本地生成网络地址(ICE 候选)时触发,发送给对方,双方才能建立 P2P 连接。
<script setup>
onUnmounted(() => {
if (socket) {
socket.close();
console.log('🔌 关闭 WebSocket 连接');
}
if (peerConnection) {
peerConnection.close();
console.log('🔌 关闭 PeerConnection');
}
if (localStream) {
localStream.getTracks().forEach(track => {
track.stop();
console.log('🔇 停止本地音视频流');
});
}
inCall.value = false;
});
</script>
- 必须停止本地流:否则页面关闭后,摄像头/麦克风仍会被占用(浏览器标签栏会显示摄像头图标);
onUnmounted:Vue3 的生命周期钩子,页面销毁时自动执行,确保资源全部清理。
步骤 6:补充'挂断'功能
<!-- 挂断按钮(仅连接成功且有远程流时显示) -->
<div v-if="socketConnected && remoteVideo.value?.srcObject">
<button @click="hangUp">挂断通话</button>
</div>
<script setup>
const hangUp = () => {
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
localVideo.value.srcObject = null;
}
if (remoteVideo.value) {
remoteVideo.value.srcObject = null;
}
if (peerConnection) {
peerConnection.close();
peerConnection = null;
}
targetUserId.value = '';
inCall.value = false;
console.log('📞 挂断通话');
alert('已挂断通话!');
};
</script>
相关免费在线工具
- Keycode 信息
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
- Escape 与 Native 编解码
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
- JavaScript / HTML 格式化
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
- JavaScript 压缩与混淆
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online