跳到主要内容SpringBoot+Vue+Netty+WebSocket+WebRTC 视频聊天系统搭建指南 | 极客日志Java大前端java
SpringBoot+Vue+Netty+WebSocket+WebRTC 视频聊天系统搭建指南
基于 SpringBoot、Vue、Netty、WebSocket 和 WebRTC 技术栈的全栈视频聊天系统搭建过程。涵盖后端信令服务实现、前端音视频采集与传输、NAT 穿透原理(ICE/STUN)及全流程测试步骤。提供完整可运行代码,解决端口占用、权限拒绝、信令转发失败等常见问题,并给出集成 TURN 服务器及部署上线的扩展建议。
性能调优5 浏览 在实时通信场景中,音视频聊天是核心需求之一,比如在线会议、远程面试、社交视频等。本文详解一套基于 SpringBoot+Vue+Netty+WebSocket+WebRTC 的全栈视频聊天系统搭建过程,提供完整可运行代码,可直接部署测试,同时拆解核心技术原理,帮助你理解底层逻辑。
本文适合有一定 Java 和 Vue 基础的开发者,核心目标是实现两端内网设备实时视频通话,无需第三方音视频 SDK,完全基于原生技术栈开发,兼顾实用性与可扩展性。
一、核心技术栈原理铺垫
在动手开发前,我们先理清核心技术的作用,尤其是 WebRTC 相关的关键概念——很多开发者踩坑,本质是没搞懂 NAT 穿透和信令交互的逻辑。
1.1 WebRTC:浏览器原生的实时通信利器
WebRTC(Web Real-Time Communication)是浏览器内置的实时通信技术标准,无需安装任何插件,就能让网页直接实现音视频采集、编码、传输和渲染。简单说,它帮我们搞定了音视频流怎么从本地设备传到对方设备的核心问题,是整个视频聊天的核心引擎。
1.2 ICE 与 STUN:解决内网设备找不到彼此的难题
我们日常使用的电脑、手机,连接的都是内网(家里宽带、公司 WiFi),拿到的都是内网 IP(比如 192.168.1.100),而非公网 IP。两个内网设备要直接通信,就像两个人在不同小区,只知道自己的门牌号,却不知道小区的对外地址——这就是 NAT 穿透问题。
而 ICE 和 STUN,就是解决这个问题的关键工具:
- ICE:全称 Interactive Connectivity Establishment,是 WebRTC 中用于 NAT 穿透的框架,核心作用是寻找最优的网络连接路径,让两个内网设备能成功建立连接。
- STUN 服务器:全称 Session Traversal Utilities for NAT,是 ICE 框架的辅助工具,轻量级网络服务器。核心作用是帮内网设备获取自己的公网 IP+端口以及 NAT 设备类型,相当于帮两个小区里的人查到彼此小区的对外地址和出入口。
1.3 其他技术栈的核心作用
除了 WebRTC,整套系统的其他技术栈各司其职,缺一不可:
- SpringBoot:快速搭建后端项目框架,管理 Netty 服务,简化配置与依赖管理。
- Netty:高性能网络通信框架,实现 WebSocket 服务端,处理多客户端并发连接,保证信令传输的高效性。
- WebSocket:保持客户端与服务端的长连接,负责传输信令消息(比如注册、呼叫、应答、ICE 候选等)——注意:WebSocket 不传输音视频数据,只传输协商信息。
- Vue:搭建前端页面,实现用户交互(输入用户 ID、发起呼叫)和音视频画面展示,绑定 WebRTC 相关 API。
1.4 核心流程梳理
客户端 A 发起呼叫 → 通过 WebSocket 将呼叫信令传给 Netty 服务端 → 服务端转发信令给客户端 B → 双方通过 STUN 服务器获取自身公网地址(ICE 候选) → 交换 ICE 候选和音视频参数(SDP) → 建立 WebRTC P2P 连接 → 直接传输音视频数据,实现实时聊天。
二、后端开发:SpringBoot+Netty+WebSocket(服务端)
后端核心目标:搭建 WebSocket 服务端,实现客户端连接管理、信令消息转发,同时集成 Netty 保证高性能,无需处理音视频数据,只负责信令中转。
2.1 项目搭建与依赖配置
创建 SpringBoot 项目,添加以下核心依赖(pom.xml),版本可根据自身需求调整。
<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>
2.2 核心实体类:Message(信令消息封装)
前后端传输的信令消息,需要统一格式,包含消息类型、发送方 ID、接收方 ID、消息内容,对应四种消息类型:register(注册)、call(呼叫)、answer(应答)、ice(ICE 候选)。
public class Message {
private String type;
private String from;
private String to;
private String data;
public String getType() { return type; }
public void setType(String type) { this.type = type; }
public String getFrom() { return from; }
public void setFrom(String from) { this.from = from; }
public String getTo() { return to; }
public void setTo(String to) { this.to = to; }
public String getData() { return data; }
public void setData(String data) { this.data = data; }
}
2.3 Netty WebSocket 处理器(核心逻辑)
自定义 WebSocket 处理器,处理客户端连接的建立、断开、消息接收与转发,核心是用 ConcurrentHashMap 存储用户 ID 与 Channel 的映射,实现精准的信令转发(比如 A 呼叫 B,只转发给 B,不广播)。
@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();
}
}
2.4 Netty WebSocket 服务启动类
配置 Netty 服务,指定监听端口(本文用 8081,可自行修改),添加通道处理器链(WebSocket 基于 HTTP 协议,需先添加 HTTP 解编码器),实现 SpringBoot 启动时,自动启动 Netty 服务。
@Configuration
public class NettyWebSocketServer {
@Autowired
private WebSocketHandler coordinationSocketHandler;
public void start() throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap sb = new ServerBootstrap();
sb.option(ChannelOption.SO_BACKLOG, 1024)
.group(workerGroup, bossGroup)
.channel(NioServerSocketChannel.class)
.localAddress(8081)
.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();
System.out.println("Netty WebSocket 服务启动成功,监听端口:8081");
cf.channel().closeFuture().sync();
} finally {
workerGroup.shutdownGracefully().sync();
bossGroup.shutdownGracefully().sync();
}
}
}
2.5 SpringBoot 启动类
实现 CommandLineRunner 接口,在 SpringBoot 项目启动后,自动调用 Netty 服务的 start() 方法,无需手动启动 Netty 服务。
@SpringBootApplication
@MapperScan("com.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();
}
}
后端测试要点
启动 SpringBoot 项目,控制台打印'Netty WebSocket 服务启动成功,监听端口:8081',说明后端服务正常启动,无报错即可进入前端开发。
三、前端开发:Vue+WebRTC(客户端)
前端核心目标:搭建用户交互页面,实现 WebSocket 连接、WebRTC 音视频采集与传输,绑定后端服务,完成整个视频聊天的交互流程。本文使用 Vue3+Script Setup 语法,简洁高效,适配现代前端开发规范。
3.1 项目搭建与页面布局
创建 Vue 项目(可使用 Vue CLI),无需额外安装依赖(WebRTC 和 WebSocket 均为浏览器原生 API),直接编写页面组件(本文以 HomeView.vue 为例),布局包含用户 ID 输入、连接服务器、发起呼叫、视频展示、挂断五大核心模块。
<template>
<div>
<h2>WebRTC 视频聊天系统(SpringBoot+Netty+Vue)</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 v-if="socketConnected && remoteVideo.value?.srcObject">
<button @click="hangUp">挂断通话</button>
</div>
<!-- 4. 视频展示区域(本地 + 远程) -->
<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>
3.2 核心脚本编写(WebRTC+WebSocket 逻辑)
脚本分为 7 个核心模块:变量定义、STUN 服务器配置、WebSocket 连接、消息发送与处理、WebRTC 核心函数、挂断功能、资源清理,全程注释详细,可直接复制使用,关键逻辑单独标注。
<script setup>
import { ref, onUnmounted } from 'vue';
const userId = ref('');
const targetUserId = ref('');
const socketConnected = ref(false);
const localVideo = ref(null);
const remoteVideo = ref(null);
let socket = null;
let peerConnection = null;
let localStream = null;
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:8081/ws`);
socket.onopen = () => {
console.log('✅ WebSocket 连接成功,已连接到后端服务');
socketConnected.value = true;
sendMessage({ type: 'register', from: userId.value, to: '',
};
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;
};
socket.onerror = (err) => {
console.error('❌ WebSocket 连接错误:', err);
socketConnected.value = false;
alert('连接服务器失败!请检查服务端是否启动,端口是否与后端一致。');
};
};
const sendMessage = (message) => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
console.log('📤 发送消息:', message);
} else {
console.error('❌ WebSocket 未连接,无法发送消息');
alert('未连接服务器,请先点击'连接服务器'!');
}
};
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);
}
};
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) });
console.log('📞 已发起视频呼叫,对方用户 ID:', targetUserId.value);
} catch (err) {
console.error('❌ 发起呼叫失败:', err);
alert('发起呼叫失败!请检查:1. 已连接服务器 2. 摄像头/麦克风权限已授予');
}
};
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) });
console.log('📞 已应答视频呼叫,呼叫方用户 ID:', 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);
}
};
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 = '';
console.log('📞 已挂断通话');
alert('已挂断通话!');
};
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('🔇 停止本地音视频流,释放设备资源');
});
}
});
</script>
四、全流程测试(关键步骤)
测试环境:本地测试(前后端均部署在本地),需打开两个浏览器标签页(模拟两个客户端),步骤如下,确保每一步无报错:
4.1 测试准备
- 启动后端 SpringBoot 项目,控制台无报错,打印'Netty WebSocket 服务启动成功,监听端口:8081'。
- 启动 Vue 前端项目(npm run dev),打开前端页面(默认地址:http://localhost:5173)。
- 打开两个浏览器标签页(均访问前端页面),分别作为'客户端 A'和'客户端 B'。
4.2 测试步骤
- 客户端 A:输入用户 ID(如 user1),点击'连接服务器',控制台打印'WebSocket 连接成功''用户 user1 注册成功'。
- 客户端 B:输入用户 ID(如 user2),点击'连接服务器',控制台打印'WebSocket 连接成功''用户 user2 注册成功'。
- 客户端 A:输入对方用户 ID(user2),点击'发起视频呼叫',浏览器弹出'请求摄像头/麦克风权限',点击'允许'。
- 客户端 B:自动应答,浏览器弹出权限请求,点击'允许',此时两个标签页均显示本地视频和对方视频,可听到声音,说明视频聊天成功。
- 测试挂断:任意一方点击'挂断通话',双方视频画面清空,摄像头/麦克风释放,测试完成。
五、常见问题排查
搭建过程中,可能遇到以下问题,本文整理了高频报错及解决方案,直接对照排查:
5.1 后端报错:Netty 服务启动失败,端口被占用
解决方案:修改 NettyWebSocketServer 类中的 localAddress(本文用 8081),改为未被占用的端口(如 8082),同时修改前端 WebSocket 连接地址中的端口,保持一致。
5.2 前端报错:WebSocket 连接失败,无法连接到 ws://localhost:8081/ws
- 检查后端服务是否正常启动,控制台是否打印'Netty WebSocket 服务启动成功'。
- 检查前端 WebSocket 连接地址的端口,是否与后端 Netty 监听端口一致。
- 若使用 Chrome 浏览器,本地测试可忽略'跨域'问题;若部署在不同机器,需在后端添加跨域配置。
5.3 前端无视频画面,控制台报错:getUserMedia 权限被拒绝
解决方案:浏览器地址栏点击'摄像头/麦克风'图标,允许当前页面使用摄像头和麦克风;若浏览器禁用了权限,需在浏览器设置中开启。
5.4 能连接服务器,但无法发起呼叫/接收呼叫
- 检查双方用户 ID 是否输入正确(如 user1 和 user2,不可输错)。
- 检查后端控制台,是否打印'转发消息到用户 XXX',若未打印,说明信令转发失败,检查 WebSocketHandler 中的 USER_CHANNEL_MAP 是否正确存储用户映射。
5.5 有本地视频,但无对方视频,控制台报错:ICE 候选添加失败
解决方案:检查 STUN 服务器配置,若本地无外网,谷歌 STUN 服务器无法使用,可删除谷歌 STUN,仅保留腾讯和阿里云 STUN;若仍失败,可更换网络(如使用手机热点),排除内网限制。
六、项目优化与扩展建议
本文实现的是基础版视频聊天系统,可根据实际需求进行优化扩展,推荐以下方向:
- 添加'是否接听'弹窗:当前版本为自动应答,可在 handleMessage 的 call 分支中,添加弹窗组件,让用户选择'接听'或'拒绝'。
- 添加异常提示:如'对方不在线''呼叫超时''连接断开'等提示,提升用户体验。
- 集成 TURN 服务器:STUN 仅支持简单 NAT 穿透,复杂网络(如双层 NAT)无法穿透,可集成 TURN 服务器(如 coturn),实现所有网络环境下的连接。
- 添加音视频控制:如静音、关闭摄像头、调节音量等功能,丰富交互体验。
- 部署上线:后端部署到云服务器(如阿里云、腾讯云),前端打包后部署到 Nginx,修改 WebSocket 连接地址为服务器 IP,即可实现公网访问(需开放对应端口)。
七、总结
本文搭建了一套基于 SpringBoot+Vue+Netty+WebSocket+WebRTC 的全栈视频聊天系统,保留了全部完整可运行代码,拆解了核心技术原理和关键流程,解决了 NAT 穿透、信令转发、音视频采集与传输等核心问题。
整套系统无需第三方音视频 SDK,完全基于原生技术栈开发,兼顾实用性与可扩展性,适合作为实时通信项目的基础框架,也可用于学习 WebRTC、Netty、WebSocket 等核心技术。
如果在搭建过程中遇到其他问题,可查看浏览器控制台报错信息,对照本文'常见问题排查'部分,基本都能解决。
微信扫一扫,关注极客日志
微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具
- 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