从零搭建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

多组学因果推断实操指南:孟德尔随机化 + 中介效应建模(含 R/Python 因果验证代码)

多组学因果推断实操指南:孟德尔随机化 + 中介效应建模(含 R/Python 因果验证代码)

一、引言:从关联到因果 —— 多组学研究的核心挑战 在精准医学时代,多组学技术(基因组学、转录组学、蛋白质组学、代谢组学等)已成为解析复杂疾病机制的核心工具。通过整合不同生物分子层面的数据,研究者能构建从基因到表型的分子调控网络,但传统分析往往止步于变量间的相关性描述,难以区分因果关系与混杂干扰。例如,在肥胖与 2 型糖尿病的研究中,炎症因子水平升高与两者均相关,但无法确定是炎症导致糖尿病,还是肥胖同时驱动了两者变化。 孟德尔随机化(Mendelian Randomization, MR)借助遗传变异的 "自然随机分配" 特性,为解决因果推断难题提供了新思路。其核心逻辑是:遗传变异在受孕时随机分配,不受后天环境和疾病状态影响,可作为暴露因素的工具变量(Instrumental Variable, IV),有效规避混杂偏倚与反向因果问题。而中介效应建模能进一步拆解因果通路,识别多组学分子在暴露 - 结局关系中的中间传导角色,例如揭示某蛋白质如何介导基因变异对疾病的影响。 本指南将系统梳理多组学背景下 MR 与中介效应建模的整合分析框架,结合真实案例与可复现代码,助力研究者实现从

By Ne0inhk
[全网首发] Sora2Pro API 逆向接入指南:如何用 Python 实现 0.88元/次 的 4K 视频生成?(附源码)

[全网首发] Sora2Pro API 逆向接入指南:如何用 Python 实现 0.88元/次 的 4K 视频生成?(附源码)

2025年10月,OpenAI 终于开放了 Sora2 API,但 $5/次的昂贵成本和严苛的企业资质认证劝退了 90% 的开发者。本文将分享一种“曲线救国”的方案——通过 小镜AI开放平台 接入 Sora2Pro。实测单次成本降低 98%(低至 0.88元),且独创“成功才计费”模式。本文包含完整的 Python 接入流程、错误处理机制及并发优化策略。 1. 行业痛点:为什么官方 API 是“开发者的噩梦”? 根据 Statista 2025 Q3 报告,78% 的开发者在对接官方 Sora2 API 时遭遇滑铁卢。核心原因在于 OpenAI 的企业级定位对个人和中小团队极不友好: * 成本黑洞: 官方单次调用

By Ne0inhk
从零开始:Python与Jupyter Notebook中的数据可视化之旅

从零开始:Python与Jupyter Notebook中的数据可视化之旅

目录 * **理解数据与数据可视化的基本流程** * **了解Python与其他可视化工具** * **掌握Anaconda、Jupyter Notebook的常用操作方法** * **原理** * 环境配置 * 1. **安装Anaconda软件,创建实验环境** * 2. **安装Jupyter Notebook** * 3. **创建第一个Jupyter Notebook文本** * (1)**更改保存路径、重命名文件** * (2)**创建代码单元和Markdown单元** * 实验1-1:鸢尾花数据集可视化练习 * 1. **安装scikit-learn库** * 2. **导入鸢尾花数据集并绘制表格** * 代码步骤: * 绘制特征之间的散点图 * 绘制饼图 * 绘制散点图 * 条形图:展示每种鸢尾花品种的平均特征值,例如平均花萼长度。 * 通过鸢尾花的目标(种类)创建类别列 * 计算每个品种的

By Ne0inhk
Python快速入门专业版(十四):变量赋值的“陷阱”:浅拷贝与深拷贝(用代码看懂内存地址)

Python快速入门专业版(十四):变量赋值的“陷阱”:浅拷贝与深拷贝(用代码看懂内存地址)

目录 引言:为什么改了b,a也跟着变? 1.赋值的本质:不是值传递,而是引用传递 1.1 用id()函数看穿内存地址 场景1:不可变对象的赋值(无副作用) 场景2:可变对象的赋值(有副作用) 1.2 不可变对象的“特殊情况”:小整数池与字符串驻留 2.浅拷贝(Shallow Copy):只复制“外层壳子” 2.1 浅拷贝的4种实现方式 代码示例:列表的浅拷贝 2.2 浅拷贝的“隐形陷阱”:内层对象仍共享 代码演示:浅拷贝的内层共享问题 2.3 浅拷贝的适用场景 3.深拷贝(Deep Copy):复制“所有层级”

By Ne0inhk