从零搭建SpringBoot+Vue+Netty+WebSocket+WebRTC视频聊天系统

在实时通信场景中,音视频聊天是最核心的需求之一,比如在线会议、远程面试、社交视频等。本文将手把手教你搭建一套基于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),版本可根据自身需求调整,本文提供的版本经过实测,无兼容性问题。

<!-- 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候选)。

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,不广播)。

/** * 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服务。

/** * 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服务。

@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输入、连接服务器、发起呼叫、视频展示、挂断”五大核心模块。

<template> <div> <h2>WebRTC 视频聊天系统(SpringBoot+Netty+Vue)&lt;/h2&gt; <!-- 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"&gt;挂断通话&lt;/button&gt; &lt;/div&gt; <!-- 4. 视频展示区域(本地+远程) --> <div> <div> <p>本地视频(自己)&lt;/p&gt; <!-- 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> // 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 测试准备

  1. 启动后端SpringBoot项目,控制台无报错,打印“Netty WebSocket服务启动成功,监听端口:8081”。
  2. 启动Vue前端项目(npm run dev),打开前端页面(默认地址:http://localhost:5173)。
  3. 打开两个浏览器标签页(均访问前端页面),分别作为“客户端A”和“客户端B”。

4.2 测试步骤

  1. 客户端A:输入用户ID(如user1),点击“连接服务器”,控制台打印“WebSocket连接成功”“用户user1注册成功”。
  2. 客户端B:输入用户ID(如user2),点击“连接服务器”,控制台打印“WebSocket连接成功”“用户user2注册成功”。
  3. 客户端A:输入对方用户ID(user2),点击“发起视频呼叫”,浏览器弹出“请求摄像头/麦克风权限”,点击“允许”。
  4. 客户端B:自动应答,浏览器弹出权限请求,点击“允许”,此时两个标签页均显示本地视频和对方视频,可听到声音,说明视频聊天成功。
  5. 测试挂断:任意一方点击“挂断通话”,双方视频画面清空,摄像头/麦克风释放,测试完成。

五、常见问题排查

搭建过程中,可能遇到以下问题,本文整理了高频报错及解决方案,无需百度,直接对照排查:

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;若仍失败,可更换网络(如使用手机热点),排除内网限制。

六、项目优化与扩展建议

本文实现的是基础版视频聊天系统,可根据实际需求进行优化扩展,推荐以下方向:

  1. 添加“是否接听”弹窗:当前版本为自动应答,可在handleMessage的call分支中,添加弹窗组件,让用户选择“接听”或“拒绝”。
  2. 添加异常提示:如“对方不在线”“呼叫超时”“连接断开”等提示,提升用户体验。
  3. 集成TURN服务器:STUN仅支持简单NAT穿透,复杂网络(如双层NAT)无法穿透,可集成TURN服务器(如coturn),实现所有网络环境下的连接。
  4. 添加音视频控制:如静音、关闭摄像头、调节音量等功能,丰富交互体验。
  5. 部署上线:后端部署到云服务器(如阿里云、腾讯云),前端打包后部署到Nginx,修改WebSocket连接地址为服务器IP,即可实现公网访问(需开放对应端口)。

七、总结

本文从零搭建了一套基于SpringBoot+Vue+Netty+WebSocket+WebRTC的全栈视频聊天系统,保留了全部完整可运行代码,拆解了核心技术原理和关键流程,解决了NAT穿透、信令转发、音视频采集与传输等核心问题。

整套系统无需第三方音视频SDK,完全基于原生技术栈开发,兼顾实用性与可扩展性,适合作为实时通信项目的基础框架,也可用于学习WebRTC、Netty、WebSocket等核心技术。

如果在搭建过程中遇到其他问题,可在评论区留言,或查看浏览器控制台报错信息,对照本文“常见问题排查”部分,基本都能解决。

Read more

HarmonyOS NEXT开发进阶(二十三):多端原生App中通过WebView嵌套Web应用实现机制

HarmonyOS NEXT开发进阶(二十三):多端原生App中通过WebView嵌套Web应用实现机制

文章目录 * 一、前言 * 二、 Android * 三、 iOS & iPadOS * 四、鸿蒙 * 4.1 核心机制:自研框架 + 开源内核 * 4.2 与 AOSP/Android WebView 的本质区别 * 4.3 技术特点与优势 * 4.4 开发视角 * 五、总结 一、前言 简单来说,在原生App中通过WebView嵌套的Web应用,所使用的浏览器并不是我们通常理解的独立App(如Chrome、Safari),而是由操作系统提供的、专门嵌入应用内部的“浏览器渲染引擎”。 这个引擎通常与系统自带的完整浏览器共享核心,但运行环境、权限和能力有所不同。 以下是各个平台的详细情况: 二、 Android * 名称/内核: Android

AI助力3D开发:用THREE.JS中文文档快速构建Web3D应用

快速体验 1. 打开 InsCode(快马)平台 https://www.inscode.net 2. 输入框内输入如下内容: 创建一个基于THREE.JS的3D场景展示应用,包含以下功能:1. 自动解析THREE.JS中文文档关键概念 2. 根据用户选择的3D元素(如立方体、球体等)生成对应代码 3. 提供实时3D预览功能 4. 支持参数化调整(大小、颜色、材质) 5. 生成可复用的代码片段。使用Kimi-K2模型实现智能代码生成,界面简洁直观,适合开发者快速学习和应用THREE.JS。 1. 点击'项目生成'按钮,等待项目生成完整后预览效果 最近在做一个Web3D项目时,发现THREE.JS这个库功能强大但学习曲线有点陡峭。官方文档虽然全面,但直接啃英文文档效率太低。后来尝试用AI辅助开发,发现结合InsCode(

山东大学《Web数据管理》期末复习宝典【万字解析!】

山东大学《Web数据管理》期末复习宝典【万字解析!】

🌈 个人主页:十二月的猫-ZEEKLOG博客 🔥 系列专栏:🏀山东大学期末速通专用_十二月的猫的博客-ZEEKLOG博客 💪🏻 十二月的寒冬阻挡不了春天的脚步,十二点的黑夜遮蔽不住黎明的曙光  目录 1. 第二章 网络爬虫 1.1 爬虫基础知识 1.2 爬虫分类 1.3 开源工具 Nutch 2. 第三章 网页分析 2.1 正则表达式 2.2 DOM模型 2.3 Beautiful Soup工具 2.4 Scrapy框架 2.5 不同爬虫工具比较 2.6 元搜索引擎 3. 第四章 爬虫与网站的博弈 3.1 Robot协议 3.

【前端实战】海明码编码与纠错工具:原理+完整代码+逐行精讲|一篇彻底吃透

【前端实战】海明码编码与纠错工具:原理+完整代码+逐行精讲|一篇彻底吃透

🔥大家好!今天给大家带来一篇超详细、超易懂、逐行精讲 的海明码编码与纠错工具教学博客。 这篇文章会完整讲解海明码的底层原理 ,再逐行解读前端实现代码 ,从理论到实战全覆盖,无论你是学生、前端新手、通信/计算机专业学习者、做毕设还是开发实用工具,都能彻底学会! 你将收获: ✅ 海明码算法底层原理 (通俗讲解,不堆砌复杂公式) ✅ 前端实战开发全流程 (HTML结构+CSS美化+JS逻辑) ✅ 现代前端UI设计 (渐变、响应式、交互动效) ✅ 原生JS实现编码、纠错、复制等核心功能 ✅ 完整可直接使用的工具源码 (复制即用,无需额外配置) ✅ 逐行代码注释 + 详细文字解释 (新手也能看懂每一行) 全程干货,建议直接收藏! 工具效果图如下 (二维码为博主专属,不擦除会误伤图片,影响体验很抱歉) 🚀一、先搞懂:什么是海明码(Hamming Code)? 1. 海明码核心作用 海明码是一种线性分组纠错码,核心功能是「