跳到主要内容
基于 SpringBoot + Vue + Netty 构建实时视频聊天系统 | 极客日志
Java 大前端 java
基于 SpringBoot + Vue + Netty 构建实时视频聊天系统 基于 SpringBoot、Netty 和 Vue 搭建实时视频聊天系统。后端利用 Netty 处理 WebSocket 信令转发,前端通过 WebRTC 实现点对点音视频传输。核心涉及 STUN 服务器配置用于 NAT 穿透,以及 ICE 候选交换机制。代码涵盖服务端连接管理、消息路由及前端流媒体采集与播放逻辑,适合学习全栈即时通讯开发。
基于 SpringBoot + Vue + Netty 构建实时视频聊天系统
实时音视频聊天是当下社交、在线协作类应用的核心功能之一。WebRTC(Web Real-Time Communication)作为浏览器原生支持的实时通信技术,能让前端无需插件即可实现点对点音视频传输;而 Netty 作为高性能的 Java NIO 框架,可提供稳定的 WebSocket 通信通道,配合 SpringBoot 的快速开发能力和 Vue 的前端工程化能力,能快速搭建一套完整的视频聊天系统。
技术架构概览
整个视频聊天系统分为三层,核心在于信令转发 和点对点音视频传输 :
技术栈 核心作用 SpringBoot 后端快速开发框架,整合 Netty、配置 WebSocket,提供接口支撑 Vue 前端工程化框架,负责音视频界面渲染、WebRTC API 调用 Netty 高性能网络通信框架,实现 WebSocket 服务端,处理客户端连接和信令转发 WebSocket 全双工通信协议,用于前端和后端之间的信令(如呼叫、应答、ICE 候选)传输 WebRTC 实时通信标准,提供音视频采集、编码、点对点传输能力
前端层(Vue) :采集音视频流、创建 WebRTC 连接、通过 WebSocket 发送 / 接收信令;
通信层(Netty+WebSocket) :维护客户端连接、转发信令(如呼叫请求、ICE 候选信息);
后端层(SpringBoot) :整合 Netty、管理用户连接状态、提供基础配置支撑。
关于 STUN 与 ICE 的补充说明
在开始编码前,有必要理解一下为什么需要 STUN。我们日常用的网络(比如家里的宽带、公司的内网),设备拿到的都是内网 IP(如 192.168.1.100),不是公网 IP。当两个内网设备要直接通信(比如 WebRTC 音视频通话),它们不知道对方的公网地址,就像两个人在不同的小区里,只知道自己的门牌号,却不知道小区的地址和对外的出入口。
STUN 服务器就是帮它们查'小区地址 + 出入口'的工具。简单来说,设备 A 向 STUN 服务器发送一个请求:'请告诉我,你看到的我的地址和端口是什么?'STUN 服务器返回设备 A 的公网 IP + 端口。拿到这个信息后,设备 A 通过信令服务器告诉设备 B,双方就能用彼此的公网地址建立 P2P 连接了。
服务端开发
1. 项目依赖配置
首先需要在 pom.xml 中引入必要的依赖。Netty 是核心,负责网络通信;FastJSON 用于处理前后端传输的 JSON 格式信令。
<dependency >
<groupId > org.springframework.boot</groupId >
<artifactId > spring-boot-starter</artifactId >
</dependency >
< >
io.netty
netty-all
4.1.94.Final
com.alibaba
fastjson
2.0.25
dependency
<groupId >
</groupId >
<artifactId >
</artifactId >
<version >
</version >
</dependency >
<dependency >
<groupId >
</groupId >
<artifactId >
</artifactId >
<version >
</version >
</dependency >
2. 核心实体类与消息处理器 信令消息需要包含'消息类型''发送方 ID''接收方 ID''消息内容',所以定义对应的属性。
public class Message {
private String type;
private String from;
private String to;
private String data;
}
接下来是 Netty WebSocket 处理器,这是服务端的核心逻辑,负责处理 WebSocket 连接的建立、消息接收与转发、连接断开等事件。
import com.alibaba.fastjson.JSON;
import com.example.chat.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();
}
}
3. Netty 服务启动与 SpringBoot 集成 我们需要在 SpringBoot 项目启动时,自动启动 Netty 的 WebSocket 服务,监听指定端口(8004)。这里使用了 CommandLineRunner 接口来确保服务在 Spring 容器加载完成后启动。
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();
}
}
}
主启动类需要实现 CommandLineRunner 接口,以便在 Spring 启动后调用 Netty 服务。
import com.example.chat.server.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.example.chat.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();
}
}
前端开发
1. 模板结构 前端主要使用 Vue 3 进行开发,界面包括用户 ID 输入、呼叫按钮以及本地和远程视频的展示区域。
<template>
<div class="container">
<h2>WebRTC 视频聊天</h2>
<!-- 1. 用户 ID 输入与连接服务器区域 -->
<div class="input-group">
<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?.srcObject">
<button @click="hangUp">挂断通话</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 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. 核心交互逻辑
初始化与变量定义 首先导入 Vue 内置的响应式变量和生命周期钩子,定义页面用到的动态数据和 DOM 引用。
<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 ;
</script>
配置 STUN 与 WebSocket 连接 WebRTC 必须配置 STUN 服务器,否则内网设备无法找到彼此的网络地址。这里列出了几个常用的免费 STUN 服务器。
<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 ;
};
socket.onerror = (err ) => {
console .error ('❌ WebSocket 连接错误:' , err);
socketConnected.value = false ;
alert ('连接服务器失败!请检查服务端是否启动,端口是否正确。' );
};
};
</script>
消息发送与处理 封装通用的消息发送函数,并在收到信令时根据类型分发处理逻辑。
<script setup>
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 );
}
};
</script>
WebRTC 核心逻辑 这部分是 WebRTC 最核心的部分,包括初始化 PeerConnection、获取本地流、创建 Offer/Answer 以及处理 ICE 候选。
<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) });
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) });
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);
}
};
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 ('已挂断通话!' );
};
</script>
资源清理 最后,记得在页面销毁时清理资源,避免内存泄漏和设备占用。
<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 ('🔇 停止本地音视频流' );
});
}
});
</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