在实时通信场景中,音视频聊天是最核心的需求之一,比如在线会议、远程面试、社交视频等。本文将手把手教你搭建一套基于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),版本可根据自身需求调整,本文提供的版本经过实测,无兼容性问题。
XML
<!-- SpringBoot 核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- Netty WebSocket 依赖(核心,处理网络通信) -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.94.Final</version>
</dependency>
<!-- JSON 解析(处理前后端信令消息,FastJSON性能更优) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.25</version>
</dependency>
2.2 核心实体类:Message(信令消息封装)
前后端传输的信令消息,需要统一格式,包含"消息类型、发送方ID、接收方ID、消息内容",对应四种消息类型:register(注册)、call(呼叫)、answer(应答)、ice(ICE候选)。
java
public class Message {
// 消息类型:register(注册)、call(呼叫)、answer(应答)、ice(ICE候选)
private String type;
// 发送方ID
private String from;
// 接收方ID
private String to;
// 消息内容(存储SDP提议/应答、ICE候选数据,以JSON字符串形式传输)
private String data;
// 自动生成getter、setter方法(此处省略,实际开发需添加)
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,不广播)。
java
/**
* Netty WebSocket 核心处理器:处理连接、消息转发、异常处理
*/
@Configuration
@ChannelHandler.Sharable // 允许处理器被多个Channel共享(关键,避免多客户端连接报错)
public class WebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
// 存储用户ID与Channel的映射(线程安全,应对多客户端并发)
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 {
// 1. 解析客户端发送的JSON格式信令
String text = msg.text();
Message message = JSON.parseObject(text, Message.class);
System.out.println("收到消息:" + text);
// 2. 根据消息类型,处理不同逻辑
switch (message.getType()) {
case "register":
// 注册:将用户ID与当前Channel绑定,告诉服务端"该用户已上线"
USER_CHANNEL_MAP.put(message.getFrom(), ctx.channel());
System.out.println("用户 " + message.getFrom() + " 注册成功");
break;
case "call":
case "answer":
case "ice":
// 呼叫、应答、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服务。
java
/**
* Netty WebSocket 服务端配置:启动Netty服务,监听指定端口
*/
@Configuration
public class NettyWebSocketServer {
@Autowired
private WebSocketHandler coordinationSocketHandler; // 注入自定义WebSocket处理器
public void start() throws Exception {
// 1. 创建两个EventLoopGroup线程组(Netty高性能核心)
EventLoopGroup bossGroup = new NioEventLoopGroup(); // 负责接收客户端连接
EventLoopGroup workerGroup = new NioEventLoopGroup(); // 负责处理客户端读写请求
try {
// 2. 配置ServerBootstrap
ServerBootstrap sb = new ServerBootstrap();
sb.option(ChannelOption.SO_BACKLOG, 1024) // 队列大小,处理并发连接
.group(workerGroup, bossGroup) // 绑定线程组
.channel(NioServerSocketChannel.class) // 指定使用NIO通道
.localAddress(8081) // 监听端口(关键,前端连接时需对应)
.childHandler(new ChannelInitializer<SocketChannel>() { // 客户端连接时触发的初始化操作
@Override
protected void initChannel(SocketChannel ch) throws Exception {
// 3. 添加通道处理器链(顺序不可乱)
// HTTP解编码器:WebSocket基于HTTP握手,需先处理HTTP请求
ch.pipeline().addLast(new HttpServerCodec());
// 块写入处理器:处理大文件/流数据
ch.pipeline().addLast(new ChunkedWriteHandler());
// HTTP聚合器:将HTTP消息聚合为FullHttpRequest/FullHttpResponse
ch.pipeline().addLast(new HttpObjectAggregator(8192));
// WebSocket协议处理器:指定WebSocket路径为/ws,支持心跳检测
ch.pipeline().addLast(new WebSocketServerProtocolHandler("/ws", "WebSocket", true, 65536 * 10));
// 自定义处理器:处理信令消息转发
ch.pipeline().addLast(coordinationSocketHandler);
}
});
// 4. 绑定端口,启动服务(异步绑定,同步等待关闭)
ChannelFuture cf = sb.bind().sync();
System.out.println("Netty WebSocket服务启动成功,监听端口:8081");
cf.channel().closeFuture().sync(); // 阻塞等待服务关闭
} finally {
// 5. 服务关闭时,优雅释放线程池资源
workerGroup.shutdownGracefully().sync();
bossGroup.shutdownGracefully().sync();
}
}
}
2.5 SpringBoot 启动类
实现CommandLineRunner接口,在SpringBoot项目启动后,自动调用Netty服务的start()方法,无需手动启动Netty服务。
java
@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;
/**
* SpringBoot启动后,自动执行该方法,启动Netty服务
*/
@Override
public void run(String... args) throws Exception {
nettyServer.start(); // 启动Netty WebSocket服务
}
}
后端测试要点
启动SpringBoot项目,控制台打印"Netty WebSocket服务启动成功,监听端口:8081",说明后端服务正常启动,无报错即可进入前端开发。
三、前端开发:Vue+WebRTC(客户端)
前端核心目标:搭建用户交互页面,实现WebSocket连接、WebRTC音视频采集与传输,绑定后端服务,完成整个视频聊天的交互流程。本文使用Vue3+Script Setup语法,简洁高效,适配现代前端开发规范。
3.1 项目搭建与页面布局
创建Vue项目(可使用Vue CLI),无需额外安装依赖(WebRTC和WebSocket均为浏览器原生API),直接编写页面组件(本文以HomeView.vue为例),布局包含"用户ID输入、连接服务器、发起呼叫、视频展示、挂断"五大核心模块。
html
<template>
<div class="container">
<h2>WebRTC 视频聊天系统(SpringBoot+Netty+Vue)</h2>
<!-- 1. 用户ID输入与连接服务器区域 -->
<div class="input-group">
<input
v-model="userId"
placeholder="输入你的用户ID(如user1)"
type="text"
/>
<button @click="connect">连接服务器</button>
</div>
<!-- 2. 呼叫功能区域(仅连接成功后显示) -->
<div class="input-group" v-if="socketConnected">
<input
v-model="targetUserId"
placeholder="输入对方用户ID(如user2)"
type="text"
/>
<button @click="call">发起视频呼叫</button>
</div>
<!-- 3. 挂断按钮(仅连接成功且有远程流时显示) -->
<div class="input-group" v-if="socketConnected && remoteVideo.value?.srcObject">
<button @click="hangUp" style="background: #e53935;">挂断通话</button>
</div>
<!-- 4. 视频展示区域(本地+远程) -->
<div class="video-container">
<div class="video-item">
<p>本地视频(自己)</p>
<!-- muted:本地视频静音,避免回声;autoplay:自动播放采集到的音视频流 -->
<video ref="localVideo" autoplay muted></video>
</div>
<div class="video-item">
<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核心函数、挂断功能、资源清理,全程注释详细,可直接复制使用,关键逻辑单独标注。
javascript
<script setup>
// 1. 导入Vue内置依赖(响应式变量、生命周期钩子)
import { ref, onUnmounted } from 'vue';
// 2. 定义响应式变量(页面动态绑定的数据,值变化时页面自动更新)
const userId = ref(''); // 本地用户ID(用户输入)
const targetUserId = ref(''); // 对方用户ID(发起呼叫时输入)
const socketConnected = ref(false); // WebSocket连接状态(控制呼叫、挂断区域显示)
// 3. 定义视频DOM引用(用于绑定音视频流,操作视频标签)
const localVideo = ref(null); // 本地视频DOM
const remoteVideo = ref(null); // 远程视频DOM
// 4. 定义非响应式全局变量(仅脚本内使用,无需页面响应,避免不必要的页面渲染)
let socket = null; // WebSocket实例(连接服务端)
let peerConnection = null; // WebRTC核心实例(处理音视频连接、流传输)
let localStream = null; // 本地音视频流(用于后续停止摄像头/麦克风,避免资源占用)
// 5. 配置STUN服务器(WebRTC必需,用于NAT穿透,获取公网ICE候选)
// 推荐使用国内STUN服务器(腾讯、阿里云),谷歌STUN需外网环境,备用即可
const iceServers = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }, // 谷歌免费STUN(备用)
{ urls: 'stun:stun.qq.com:3478' }, // 腾讯免费STUN(国内稳定,优先使用)
{ urls: 'stun:stun.aliyun.com:3478' } // 阿里云免费STUN(备用)
]
};
// 6. WebSocket连接函数(点击"连接服务器"按钮触发)
const connect = () => {
// 校验:用户ID不能为空(去除前后空格,避免无效输入)
if (!userId.value.trim()) {
alert('请输入你的用户ID!(不能为空/仅输入空格)');
return;
}
// 创建WebSocket连接(核心:对应后端Netty服务的地址,端口与后端一致)
// 注意:本地测试用ws://localhost:8081/ws;若后端部署在其他机器,替换为对应IP(如ws://192.168.1.100:8081/ws)
socket = new WebSocket(`ws://localhost:8081/ws`);
// 6.1 连接成功回调(WebSocket状态变为OPEN)
socket.onopen = () => {
console.log('✅ WebSocket连接成功,已连接到后端服务');
socketConnected.value = true; // 更新连接状态,显示呼叫区域
// 发送注册信令:告诉服务端"当前用户已上线",完成用户ID与Channel的绑定
sendMessage({
type: 'register',
from: userId.value,
to: '', // 注册无需指定接收方,留空即可
data: ''
});
};
// 6.2 接收服务端消息回调(核心:处理服务端转发的信令,如呼叫、应答、ICE候选)
socket.onmessage = (e) => {
try {
// 解析服务端发送的JSON格式信令(try-catch避免非JSON消息导致脚本崩溃)
const message = JSON.parse(e.data);
console.log('📥 收到服务端转发的消息:', message);
handleMessage(message); // 调用专门的消息处理函数
} catch (err) {
console.error('❌ 消息解析失败,请检查信令格式:', err);
}
};
// 6.3 连接关闭回调(WebSocket断开连接时触发)
socket.onclose = () => {
console.log('❌ WebSocket连接关闭');
socketConnected.value = false; // 更新状态,隐藏呼叫、挂断区域
};
// 6.4 连接错误回调(连接失败时触发,如后端服务未启动、端口错误)
socket.onerror = (err) => {
console.error('❌ WebSocket连接错误:', err);
socketConnected.value = false;
alert('连接服务器失败!请检查服务端是否启动,端口是否与后端一致。');
};
};
// 7. 通用消息发送函数(复用函数,避免重复代码,发送各种类型的信令)
const sendMessage = (message) => {
// 校验:WebSocket必须处于打开状态(状态码1=OPEN),否则无法发送消息
if (socket && socket.readyState === WebSocket.OPEN) {
// 将消息转为JSON字符串发送(前后端统一格式)
socket.send(JSON.stringify(message));
console.log('📤 发送消息:', message);
} else {
console.error('❌ WebSocket未连接,无法发送消息');
alert('未连接服务器,请先点击"连接服务器"!');
}
};
// 8. 信令消息处理函数(根据消息类型,处理不同逻辑)
const handleMessage = async (message) => {
switch (message.type) {
case 'call':
// 收到呼叫请求:自动应答(实际项目可添加"是否接听"弹窗,本文简化为自动应答)
await answerCall(message);
break;
case 'answer':
// 收到应答消息:设置远程SDP(音视频参数协商)
await setRemoteSDP(message.data);
break;
case 'ice':
// 收到ICE候选:添加到PeerConnection,完成网络地址协商
await addIceCandidate(message.data);
break;
default:
console.log('📌 未知消息类型,忽略:', message.type);
}
};
// 9. WebRTC核心函数:初始化PeerConnection(复用,避免重复创建)
const initPeerConnection = async () => {
// 如果已有PeerConnection实例,先关闭(避免重复创建,导致资源泄漏)
if (peerConnection) {
peerConnection.close();
}
// 创建PeerConnection实例,传入STUN服务器配置(关键:实现NAT穿透)
peerConnection = new RTCPeerConnection(iceServers);
// 9.1 监听ICE候选生成事件(本地生成网络地址时触发,发送给对方)
peerConnection.onicecandidate = (e) => {
if (e.candidate) {
// 发送ICE候选信令给对方,让对方获取当前设备的公网地址
sendMessage({
type: 'ice',
from: userId.value,
to: targetUserId.value,
data: JSON.stringify(e.candidate)
});
}
};
// 9.2 监听远程音视频流到达事件(关键:显示对方视频)
peerConnection.ontrack = (e) => {
// 将远程音视频流绑定到远程视频DOM,页面自动显示对方画面和声音
remoteVideo.value.srcObject = e.streams[0];
console.log('🎥 收到远程音视频流,已显示对方视频');
};
};
// 10. 发起视频呼叫函数(点击"发起视频呼叫"按钮触发)
const call = async () => {
// 校验:对方用户ID不能为空
if (!targetUserId.value.trim()) {
alert('请输入对方用户ID!');
return;
}
try {
// 初始化PeerConnection,准备建立WebRTC连接
await initPeerConnection();
// 获取本地音视频流(请求摄像头/麦克风权限,浏览器会弹出授权提示)
localStream = await navigator.mediaDevices.getUserMedia({
video: true, // 开启视频采集
audio: true // 开启音频采集(可设为false,只实现视频聊天)
});
// 将本地音视频流绑定到本地视频DOM,显示自己的画面
localVideo.value.srcObject = localStream;
// 将本地音视频轨道添加到PeerConnection,用于传输给对方
localStream.getTracks().forEach(track => {
peerConnection.addTrack(track, localStream);
});
// 创建SDP提议(offer):包含本地音视频编码、网络配置等参数,用于与对方协商
const offer = await peerConnection.createOffer();
// 设置本地SDP(保存本地音视频参数)
await peerConnection.setLocalDescription(offer);
// 发送呼叫信令给对方,包含SDP提议,告知对方"我要发起呼叫"
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. 摄像头/麦克风权限已授予');
}
};
// 11. 应答呼叫请求函数(收到call信令时触发)
const answerCall = async (message) => {
// 记录呼叫方ID(后续发送应答、ICE消息时,需要指定接收方)
targetUserId.value = message.from;
try {
// 初始化PeerConnection
await initPeerConnection();
// 获取本地音视频流,请求摄像头/麦克风权限
localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
// 绑定本地视频流
localVideo.value.srcObject = localStream;
// 添加本地音视频轨道到PeerConnection
localStream.getTracks().forEach(track => {
peerConnection.addTrack(track, localStream);
});
// 设置远程SDP(呼叫方的offer,获取对方的音视频参数)
await peerConnection.setRemoteDescription(JSON.parse(message.data));
// 创建SDP应答(answer):告知呼叫方"我同意连接",并发送自己的音视频参数
const answer = await peerConnection.createAnswer();
// 设置本地SDP(保存自己的音视频参数)
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('应答呼叫失败!请检查摄像头/麦克风权限。');
}
};
// 12. 设置远程SDP函数(收到answer信令时触发,完成音视频参数协商)
const setRemoteSDP = async (sdpStr) => {
try {
const sdp = JSON.parse(sdpStr);
// 设置远程SDP,保存对方的音视频参数,完成协商
await peerConnection.setRemoteDescription(new RTCSessionDescription(sdp));
console.log('✅ 设置远程SDP成功,音视频参数协商完成');
} catch (err) {
console.error('❌ 设置远程SDP失败:', err);
}
};
// 13. 添加ICE候选函数(收到ice信令时触发,完成网络地址协商)
const addIceCandidate = async (iceStr) => {
try {
const ice = JSON.parse(iceStr);
// 添加对方的ICE候选,获取对方的公网地址,建立P2P连接
await peerConnection.addIceCandidate(new RTCIceCandidate(ice));
console.log('✅ 添加ICE候选成功,网络地址协商完成');
} catch (err) {
console.error('❌ 添加ICE候选失败:', err);
}
};
// 14. 挂断通话函数(点击"挂断通话"按钮触发)
const hangUp = () => {
// 停止本地音视频流,释放摄像头/麦克风(关键,避免页面关闭后仍占用设备)
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
localVideo.value.srcObject = null; // 清空本地视频画面
}
// 清空远程视频画面
if (remoteVideo.value) {
remoteVideo.value.srcObject = null;
}
// 关闭PeerConnection,断开WebRTC连接
if (peerConnection) {
peerConnection.close();
peerConnection = null;
}
// 重置目标用户ID,方便下次发起呼叫
targetUserId.value = '';
console.log('📞 已挂断通话');
alert('已挂断通话!');
};
// 15. 页面销毁时清理资源(Vue生命周期钩子,避免内存泄漏)
onUnmounted(() => {
// 关闭WebSocket连接
if (socket) {
socket.close();
console.log('🔌 关闭WebSocket连接');
}
// 关闭PeerConnection
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等核心技术。
如果在搭建过程中遇到其他问题,可在评论区留言,或查看浏览器控制台报错信息,对照本文"常见问题排查"部分,基本都能解决。