跳到主要内容
极客日志极客日志
首页博客AI提示词GitHub精选代理工具
搜索
|注册
博客列表
Java大前端java

基于 SpringBoot+Vue+Netty+WebSocket+WebRTC 的视频聊天系统搭建

本方案基于 SpringBoot、Vue、Netty、WebSocket 及 WebRTC 技术栈构建全栈视频聊天系统。后端采用 SpringBoot 集成 Netty 实现高性能 WebSocket 信令服务,负责用户连接管理与消息转发;前端利用 Vue3 结合原生 WebRTC API 处理音视频采集与 P2P 传输。核心流程涵盖 STUN 服务器 NAT 穿透、ICE 候选交换及 SDP 协商。系统无需第三方 SDK,支持内网设备间实时音视频通话,包含用户注册、呼叫、应答、挂断及异常处理机制,适用于在线会议、远程面试等场景。

星云发布于 2026/3/28更新于 2026/4/265 浏览

在实时通信场景中,音视频聊天是最核心的需求之一,比如在线会议、远程面试、社交视频等。本文将手把手教你搭建一套基于 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)</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>
// 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 等核心技术。

查看浏览器控制台报错信息,对照本文'常见问题排查'部分,基本都能解决。

目录

  1. 一、核心技术栈原理铺垫
  2. 1.1 WebRTC:浏览器原生的实时通信“利器”
  3. 1.2 ICE 与 STUN:解决内网设备“找不到彼此”的难题
  4. 1.3 其他技术栈的核心作用
  5. 1.4 核心流程梳理
  6. 二、后端开发:SpringBoot+Netty+WebSocket(服务端)
  7. 2.1 项目搭建与依赖配置
  8. 2.2 核心实体类:Message(信令消息封装)
  9. 2.3 Netty WebSocket 处理器(核心逻辑)
  10. 2.4 Netty WebSocket 服务启动类
  11. 2.5 SpringBoot 启动类
  12. 后端测试要点
  13. 三、前端开发:Vue+WebRTC(客户端)
  14. 3.1 项目搭建与页面布局
  15. 3.2 核心脚本编写(WebRTC+WebSocket 逻辑)
  16. 四、全流程测试(关键步骤)
  17. 4.1 测试准备
  18. 4.2 测试步骤
  19. 五、常见问题排查
  20. 5.1 后端报错:Netty 服务启动失败,端口被占用
  21. 5.2 前端报错:WebSocket 连接失败,无法连接到 ws://localhost:8081/ws
  22. 5.3 前端无视频画面,控制台报错:getUserMedia 权限被拒绝
  23. 5.4 能连接服务器,但无法发起呼叫/接收呼叫
  24. 5.5 有本地视频,但无对方视频,控制台报错:ICE 候选添加失败
  25. 六、项目优化与扩展建议
  26. 七、总结
  • 💰 8折买阿里云服务器限时8折了解详情
  • 💰 8折买阿里云服务器限时8折购买
  • 🦞 5分钟部署阿里云小龙虾了解详情
  • 🤖 一键搭建Deepseek满血版了解详情
  • 一键打造专属AI 智能体了解详情
极客日志微信公众号二维码

微信扫一扫,关注极客日志

微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

更多推荐文章

查看全部
  • Telegram 群组管理机器人搭建指南
  • Web-Rooter:基于 IR + Lint 模式的 AI Agent 联网工具
  • Java 21 + Spring Boot 3.3 并发编程实战:虚拟线程与结构化并发
  • HTTP 请求方式详解:GET、POST 及其他常用方法
  • AI 辅助学术论文写作工具功能解析
  • GitHub Copilot 登录失败排查指南
  • BaseCTF Week3 Web & Misc 解题报告
  • ComfyUI 高效工作流指南:节点思维与模板复用
  • OpenClaw 远程访问配置指南:SSH 隧道与免密登录
  • 2025 年 DeepSeek 开启 AI 算法变革元年深度解析
  • Windows 11 安装 JDK 25:下载、配置与验证
  • 高德地图 JSAPI 加载器集成与 Key 配置指南
  • 西门子PLC1500与Fanuc机器人焊装系统集成解析
  • 速卖通商品详情 API 接口详解:注册、签名与 Python 调用示例
  • pdf-lib:JavaScript 全栈 PDF 处理方案
  • 基于 C++11 手写 Promise 实现
  • AIAgent 项目文件上传与解析:MD5、MinIO 及 Spring 配置
  • 基于 GraphRAG 打造知识图谱增强的 LLM:以解读《红楼梦》为例
  • Java AI 辅助开发实战:从代码生成到架构优化指南
  • Claude Code vs GitHub Copilot CLI 深度评测与选型指南

相关免费在线工具

  • 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