跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客我的书AI学习GitHub 精选镜像AI工具UI配色美学关于
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
Java大前端java

WebRTC + Java 信令服务器 + Vue 实时视频聊天实现

通过一个完整项目,详解基于 WebRTC、Java(SpringBoot + Netty)和 Vue 的点对点视频聊天实现。内容涵盖 WebRTC 核心概念(SDP、ICE、STUN)、信令服务器架构设计、后端 Netty WebSocket 服务搭建以及前端 Vue 交互逻辑。旨在帮助开发者理解实时音视频通信原理,掌握从信令交换到媒体流传输的全流程开发。

魔尊发布于 2026/4/6更新于 2026/10/20103 浏览

WebRTC + Java 信令服务器 + Vue 实时视频聊天实现

很多同学用过微信、QQ 视频聊天,但一问到底层怎么实现,十有八九只会说一句:"应该是 WebSocket / WebRTC 吧?"——但是:

  • WebRTC 到底负责什么?
  • WebSocket / Netty 在里面干嘛?
  • STUN / ICE / SDP 是啥?为什么一上来就一堆名词?

这篇文章会用一套完整的小项目,从 0 到 1 带你实现一个:

基于 WebRTC + Java(SpringBoot + Netty)+ Vue 的点对点视频聊天 Demo

重点是: 不是只给你一堆代码,而是把每个概念都讲清楚,让小白也能看懂、改得动、举一反三。


一、整体架构总览:谁负责干什么?

先看一张逻辑图(可以脑补成 PPT):

┌────────────────────────────────────────────┐
│ 后端(Java)                                │
│ ├─ SpringBoot 负责:                        │
│ │   - 启动项目、整合 Netty、管理 Bean       │
│ └─ Netty WebSocket 负责:                   │
│     - 管理 WebSocket 长连接                 │
│     - 维护'用户 ID ⇔ Channel'映射           │
│     - 转发信令:register / call / answer / ice│
└────────────────────────────────────────────┘
▲ ▲
│ WebSocket 信令
▼ ▼
┌────────────────────────┐  ┌────────────────────────┐
│ 前端 A(浏览器,Vue)    │  │ 前端 B(浏览器,Vue)    │
│ - 采集本地音视频        │  │ - 采集本地音视频        │
│ - WebRTC 建立 P2P       │  │ - WebRTC 建立 P2P       │
│ - 通过 WebSocket 发信令  │  │ - 通过 WebSocket 发信令  │
└────────────────────────┘  └────────────────────────┘
▲ ▲
│ WebRTC P2P 音视频流(真正传输媒体)
▼ ▼

一句话概括:

  • WebRTC:浏览器内置的实时音视频引擎,负责采集 + 编码 + 传输音视频。
  • Netty + WebSocket:负责信令交换(谁给谁打电话、会话参数、网络地址)。
  • SpringBoot:把 Netty 集成在 Java 项目中,提供统一的启动入口。
  • Vue 前端:提供 UI、调用 WebRTC API,并与后端 WebSocket 交互。

二、WebRTC 必备概念扫盲(面向小白)

2.1 WebRTC 是什么?

WebRTC(Web Real-Time Communication): 浏览器原生支持的实时音视频通信技术,特点:

  • 无需安装插件 / 客户端,直接在浏览器里用 JS 调用。
  • 可以实现:音视频通话、屏幕共享、文件传输等。
  • 重点:浏览器之间可以点对点(P2P)传输音视频数据。

2.2 为什么还需要 WebSocket / 信令服务器?

WebRTC 只负责媒体通道,但它不负责:

  • 谁给谁打电话?
  • 打电话时双方用什么音视频参数(SDP)?
  • 双方的网络地址(ICE 候选)怎么互相知道?

目录

  1. WebRTC + Java 信令服务器 + Vue 实时视频聊天实现
  2. 一、整体架构总览:谁负责干什么?
  3. 二、WebRTC 必备概念扫盲(面向小白)
  4. 2.1 WebRTC 是什么?
  5. 2.2 为什么还需要 WebSocket / 信令服务器?
  6. 2.3 ICE / STUN / TURN 是什么?
  7. 2.3.1 NAT 问题:为什么两台电脑找不到彼此?
  8. 2.3.2 STUN:帮你查“你在互联网眼中的地址”
  9. 2.3.3 ICE:一整套“试探连接”的框架
  10. 2.3.4 TURN:兜底方案(本教程暂不实现)
  11. 三、整体授课/学习流程设计(给老师也给自学者)
  12. 3.1 步骤总览
  13. 四、后端服务端开发(一):SpringBoot 项目与依赖配置
  14. 4.1 Maven 依赖(pom.xml 关键片段)
  15. 五、后端开发(二):信令消息实体类 Message
  16. 5.1 为什么需要消息实体类?
  17. 5.2 Message.java 示例实现(带详细注释)
  18. 六、后端开发(三):Netty WebSocket 处理器 WebSocketHandler
  19. 6.1 职责说明
  20. 6.2 核心代码实现(附详细注释)
  21. 七、后端开发(四):Netty WebSocket 服务启动类
  22. 7.1 设计目标
  23. 7.2 核心代码实现
  24. 八、后端开发(五):SpringBoot 启动类集成 Netty
  25. 8.1 通过 CommandLineRunner 启动 Netty
  26. 九、前端:基于 Vue3 的 WebRTC 视频聊天页面
  27. 9.1 页面结构与样式(template + style)
  28. 十、前端脚本(script setup):WebSocket + WebRTC 全流程
  29. 10.1 基础变量与依赖导入
  30. 10.2 WebSocket 连接与消息发送
  31. 10.3 处理收到的信令消息
  32. 10.4 WebRTC 核心:PeerConnection 初始化与事件绑定
  33. 10.5 发起呼叫(Caller 端逻辑)
  34. 10.6 接听呼叫(Callee 端逻辑)
  35. 10.7 设置远程 SDP 与添加 ICE 候选
  36. 10.8 挂断通话与资源清理
  37. 十一、联调与测试步骤
  38. 十二、常见问题与排查思路
  39. 12.1 两边都连上了 WebSocket,但看不到视频?
  40. 12.2 控制台报 SDP / ICE 相关错误?
  41. 十三、常见面试题与参考答案(围绕本项目)
  42. 问题 1:WebRTC 和 WebSocket 有什么区别?在这个项目里分别做什么?
  43. 问题 2:什么是 ICE / STUN / TURN?为什么需要它们?
  44. 问题 3:SDP 在 WebRTC 中的作用是什么?
  45. 问题 4:为什么要用 Netty 实现 WebSocket,而不是直接用 SpringBoot 自带的 WebSocket 支持?
  46. 问题 5:如果在实际项目中,要支持多人会议 / 群聊,该怎么扩展?
  47. 十四、总结与延伸学习方向

更多推荐文章

查看全部
  • llama.cpp 量化模型部署实战:从模型转换到 API 服务
  • Phi-3-vision-128k-instruct 开源镜像:支持国产昇腾/寒武纪平台适配指南
  • Linux 进程状态详解:内核视角与代码实例
  • DeepSeek 与通义万相结合实现 AI 视频生成流程指南
  • C 语言实现边缘 AI 推理:量化、算子融合与内存映射实战
  • Bright Data SERP API 测评:高精度全球化 SEO 数据引擎
  • IQuest-Coder-V1 vs Meta-Llama-Code:指令模型精度实测
  • 整车质量估计算法:从原理到实车应用
  • Claude Skills 实战:构建自动化工作流与自定义技能
  • Video-LLaMa 本地部署流程与配置详解
  • Webnovel Writer:基于 Claude Code 的长篇网文 AI 创作系统
  • 前端国际化最佳实践指南
  • FastAPI 构建高性能 Python Web API 入门与实战
  • Bing Webmaster 工具使用指南:站点验证与收录提交
  • C++ 与 Linux 内核:深入剖析 open() 系统调用与文件描述符机制
  • 前端实战:从零实现浏览器桌面通知功能
  • Android 工程师面试准备指南:核心知识点与实战技巧
  • 基于微信小程序的校园服务平台的设计与开发
  • DeepSeek-R1 大模型基于 MS-Swift 框架的部署、推理与微调实践
  • Qwen3-TTS VoiceDesign 在虚拟现实中的沉浸式语音应用

相关免费在线工具

  • 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

这些'撮合、协商、交换信息'的过程,就叫做**'信令(Signaling)'**。 我们通常用 WebSocket + 后端服务器 来做信令通道。

可以把 WebRTC 想成'电话线 + 麦克风 + 摄像头', WebSocket/Netty 相当于'中间的红娘 + 前台接线员'。

2.3 ICE / STUN / TURN 是什么?

2.3.1 NAT 问题:为什么两台电脑找不到彼此?

日常网络中,绝大部分设备使用的都是内网 IP(如 192.168.x.x),对外通过路由器共享一个公网 IP。

这就导致一个问题:

A 和 B 都在各自的内网里,它们只知道自己的内网 IP, 但对方的内网 IP 在自己的网络范围外根本访问不到。

就像:

  • 你知道自己在'小区 1:1 栋 101'
  • 对方在'小区 2:3 栋 602'
  • 但你只知道'101'和'602',不知道两个小区的具体地址和大门。
2.3.2 STUN:帮你查'你在互联网眼中的地址'

STUN(Session Traversal Utilities for NAT) 是一个网络服务,它做的事情很简单:

  1. 设备 A(在内网)给 STUN 服务器发请求:'你看到的我的 IP + 端口是什么?'
  2. STUN 服务器返回:'我看到你的公网 IP 是 X.X.X.X,端口是 YYY'。
  3. 设备 A 就知道了'在互联网眼中的自己'的地址。

同理,设备 B 也这么干一遍。 然后二者通过信令服务器互相交换这个'公网地址',就可以尝试点对点打洞通信。

2.3.3 ICE:一整套'试探连接'的框架

ICE(Interactive Connectivity Establishment) 是 WebRTC 中用于 NAT 穿透的一个框架 / 策略集合:

  • 它会收集各种可能的地址(候选者 Candidate):
    • 本地地址(内网 IP)
    • 通过 STUN 获得的服务器反射地址(公网 IP + 端口)
    • 通过 TURN 中继获得的中转地址(如果有 TURN)
  • 然后双方互相交换这些地址,一边试一边连,看哪条路径能打通。

在代码里,你通常会看到:

const iceServers = {
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
    { urls: 'stun:stun.qq.com:3478' },
    { urls: 'stun:stun.aliyun.com:3478' }
  ]
};

这里的 iceServers 就是传给 RTCPeerConnection 的配置,告诉它:

'你做 ICE 穿透时,可以去这些 STUN 服务器问路。'

2.3.4 TURN:兜底方案(本教程暂不实现)

当各种 NAT 太恶心、P2P 打洞失败时,就只能退而求其次,让媒体流通过 TURN 服务器中转。

  • 好处:几乎能保证连通。
  • 缺点:带宽压力巨大,成本高,一般用于商业产品。

本教程的 Demo 为了简单,只使用 STUN,不涉及 TURN。


三、整体授课/学习流程设计(给老师也给自学者)

3.1 步骤总览

  1. 后端:基于 SpringBoot + Netty 搭建 WebSocket 信令服务器。
  2. 前端:基于 Vue 3 实现:
    • WebSocket 连接与信令发送
    • WebRTC RTCPeerConnection 建立 P2P
    • 本地 / 远程视频展示 + 挂断功能
  3. 联调:本机开 2 个浏览器窗口(或不同浏览器),实现 A ↔ B 视频通话。

接下来我们按这个顺序,一步一步实现。


四、后端服务端开发(一):SpringBoot 项目与依赖配置

4.1 Maven 依赖(pom.xml 关键片段)

<dependencies>
    <!-- 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>
    <!-- FastJSON:JSON 序列化/反序列化,用来解析/构造信令消息 -->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>2.0.25</version>
    </dependency>
</dependencies>

说明:

  • SpringBoot 负责项目的整体启动与 Bean 管理。
  • Netty 用来开启一个 WebSocket 服务端,监听如 ws://localhost:8081/ws。
  • FastJSON 用来把前端发来的 JSON 文本转成 Java 对象 Message,也能把对象转回 JSON。

五、后端开发(二):信令消息实体类 Message

5.1 为什么需要消息实体类?

在 WebRTC 信令交互中,我们需要传递的信息包括:

  • 消息类型:register(注册)、call(呼叫)、answer(应答)、ice(候选)
  • 发送方 ID:from
  • 接收方 ID:to
  • 具体内容:data(比如 SDP、ICE 候选的 JSON 字符串)

用一个统一的实体类封装,可以让服务端逻辑更清晰。

5.2 Message.java 示例实现(带详细注释)

package com.qcby.springboot.entity;

/**
 * 信令消息实体类
 *
 * 在 WebRTC + WebSocket 的信令交互中,客户端与服务器之间传递的
 * 实际上都是 JSON 文本。为了在后端方便处理,我们把这类消息统一
 * 封装成一个 Java 对象。
 *
 * 一个典型的信令消息 JSON 可能长这样:
 *
 * {
 * "type": "call",
 * "from": "user1",
 * "to": "user2",
 * "data": "{...SDP 或 ICE JSON...}"
 * }
 *
 * 其中:
 * - type:消息类型(register / call / answer / ice)
 * - from:发送方用户 ID
 * - to:接收方用户 ID
 * - data:具体的数据内容(字符串形式,一般也是 JSON)
 */
public class Message {
    /**
     * 消息类型:
     * - register:客户端注册自己的 userId
     * - call :发起呼叫,携带 SDP offer
     * - answer :应答呼叫,携带 SDP answer
     * - ice :传递 ICE 候选信息
     */
    private String type;

    /**
     * 发送方用户 ID
     *
     * 由前端在发送消息时指定,例如 user1、user2。
     */
    private String from;

    /**
     * 接收方用户 ID
     *
     * 服务端会根据该字段找到目标 Channel,然后进行消息转发。
     */
    private String to;

    /**
     * 消息数据体
     *
     * 对于不同类型的消息,该字段含义不同:
     * - register:通常为空字符串
     * - call :存放 SDP offer 字符串(JSON 序列化后的对象)
     * - answer :存放 SDP answer 字符串
     * - ice :存放 ICECandidate 的 JSON 字符串
     */
    private String data;

    // ======= Getter / Setter(省略 IDE 自动生成注释) =======
    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;
    }

    @Override
    public String toString() {
        return "Message{" +
                "type='" + type + '\'' +
                ", from='" + from + '\'' +
                ", to='" + to + '\'' +
                ", data='" + data + '\'' +
                '}';
    }
}

六、后端开发(三):Netty WebSocket 处理器 WebSocketHandler

6.1 职责说明

WebSocketHandler 是整个服务端的核心类,主要负责:

  • 管理用户 WebSocket 连接(一个用户对应一个 Netty Channel)。
  • 接收前端发来的信令消息(JSON 文本)。
  • 按消息类型进行分发处理:
    • register:记录用户 ID 与 Channel 的映射。
    • call / answer / ice:根据 to 字段找到目标用户 Channel,转发消息。
  • 处理连接断开 / 异常等情况。

6.2 核心代码实现(附详细注释)

package com.qcby.springboot.handler;

import com.alibaba.fastjson.JSON;
import com.qcby.springboot.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 org.springframework.context.annotation.Configuration;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * WebSocketHandler
 *
 * 作用:处理基于 WebSocket 的文本消息(TextWebSocketFrame),
 * 配合 SpringBoot 与 Netty 实现一个简单的'信令服务器'。
 *
 * 关键点:
 * - 使用 ConcurrentHashMap 存储用户 ID 与 Channel 的映射关系。
 * - 根据消息类型进行不同的业务处理。
 */
@Configuration
@ChannelHandler.Sharable
public class WebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {

    /**
     * 用户 ID 到 Channel 的全局映射表
     *
     * key:String 类型的用户 ID(如 "user1")
     * value:该用户对应的 Netty Channel(一个 WebSocket 连接)
     *
     * 使用 ConcurrentHashMap 保证多线程场景下的线程安全。
     */
    public static final ConcurrentHashMap<String, Channel> USER_CHANNEL_MAP = new ConcurrentHashMap<>();

    /**
     * 当与客户端建立连接时触发
     *
     * 注意:此时客户端还未发送 register 消息,我们只知道有一个新连接,
     * 但暂时不知道它对应的 userId。
     */
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("与客户端建立连接,通道开启:" + ctx.channel().id());
    }

    /**
     * 处理接收到的文本消息(核心逻辑)
     *
     * @param ctx 上下文对象,包含 Channel、Pipeline 等信息
     * @param msg 客户端发送的文本帧(TextWebSocketFrame)
     */
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
        // 1. 获取文本消息内容(JSON 字符串)
        String text = msg.text();
        System.out.println("收到原始消息文本:" + text);

        // 2. 反序列化为 Message 对象
        Message message = JSON.parseObject(text, Message.class);
        System.out.println("解析后的 Message 对象:" + message);

        // 3. 根据消息类型进行处理
        switch (message.getType()) {
            case "register":
                // 注册消息:将用户 ID 与当前 Channel 绑定
                handleRegister(message, ctx.channel());
                break;
            case "call":
            case "answer":
            case "ice":
                // 呼叫 / 应答 / ICE 候选:需要转发给指定接收方
                handleForward(message, text);
                break;
            default:
                System.out.println("未知消息类型:" + message.getType());
        }
    }

    /**
     * 处理用户注册逻辑
     *
     * @param message 包含 from 字段的注册消息
     * @param channel 当前用户的 Channel
     */
    private void handleRegister(Message message, Channel channel) {
        String userId = message.getFrom();
        if (userId == null || userId.trim().isEmpty()) {
            System.out.println("注册失败:userId 为空");
            return;
        }
        // 将 userId 与 Channel 放入映射表 USER_CHANNEL_MAP.put(userId, channel);
        System.out.println("用户 " + userId + " 注册成功,ChannelId=" + channel.id());
    }

    /**
     * 将消息转发给接收方用户
     *
     * @param message 解析后的 Message 对象
     * @param rawText 原始 JSON 文本(直接转发给对方)
     */
    private void handleForward(Message message, String rawText) {
        String targetUserId = message.getTo();
        if (targetUserId == null || targetUserId.trim().isEmpty()) {
            System.out.println("转发失败:目标用户 ID 为空");
            return;
        }
        Channel targetChannel = USER_CHANNEL_MAP.get(targetUserId);
        if (targetChannel != null && targetChannel.isActive()) {
            // 通过对方的 Channel 发送消息
            targetChannel.writeAndFlush(new TextWebSocketFrame(rawText));
            System.out.println("已将消息转发给用户:" + targetUserId);
        } else {
            System.out.println("用户 " + targetUserId + " 不在线或 Channel 不可用");
        }
    }

    /**
     * 处理连接断开事件
     *
     * 注意:这里我们简单地打印日志,更完善的做法是:
     * - 从 USER_CHANNEL_MAP 中移除对应的 userId
     * (可以反向根据 Channel 找到 userId)
     */
    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("与客户端断开连接,通道关闭:" + ctx.channel().id());
        // 从映射表中移除对应的 Channel
        USER_CHANNEL_MAP.entrySet().removeIf(entry -> entry.getValue().id().equals(ctx.channel().id()));
    }

    /**
     * 处理异常情况
     *
     * 常见异常:
     * - 网络中断
     * - 客户端强制关闭
     * - 解码错误等
     */
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        System.out.println("连接异常:" + cause.getMessage());
        // 出现异常时,清理映射表中的 Channel
        USER_CHANNEL_MAP.entrySet().removeIf(entry -> entry.getValue() == ctx.channel());
        // 关闭连接
        ctx.close();
    }
}

七、后端开发(四):Netty WebSocket 服务启动类

7.1 设计目标

  • 在 SpringBoot 项目启动时,自动启动一个 Netty WebSocket 服务器。
  • 监听端口(如 8004),对外提供 ws://localhost:8004/ws。
  • 配置 Netty 的 ChannelPipeline:
    • HTTP 编解码
    • WebSocket 协议处理
    • 自定义 WebSocketHandler

7.2 核心代码实现

package com.qcby.springboot.commun;

import com.qcby.springboot.handler.WebSocketHandler;
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;

/**
 * Netty WebSocket 服务端
 *
 * 与 SpringBoot 集成的方式:
 * - 声明为 @Configuration,让 Spring 管理
 * - 在 SpringBoot 启动后,通过 CommandLineRunner 调用 start() 方法
 */
@Configuration
public class NettyWebSocketServer {

    /**
     * 自定义的 WebSocketHandler,由 Spring 自动注入
     */
    @Autowired
    private WebSocketHandler webSocketHandler;

    /**
     * 启动 Netty WebSocket 服务器
     *
     * @throws Exception 启动异常
     */
    public void start() throws Exception {
        // bossGroup:专门用来接收客户端连接的线程组
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        // workerGroup:处理已建立连接的读写事件
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap serverBootstrap = new ServerBootstrap();
            serverBootstrap
                    // 设置服务端的参数,例如等待队列大小
                    .option(ChannelOption.SO_BACKLOG, 1024)
                    // 绑定两个线程组
                    .group(bossGroup, workerGroup)
                    // 指定使用 NIO 模型的服务端 Channel
                    .channel(NioServerSocketChannel.class)
                    // 指定本地监听端口
                    .localAddress(8004)
                    // 子通道(客户端连接)初始化逻辑
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) {
                            // WebSocket 基于 HTTP 协议,因此先添加 HTTP 编解码器
                            ch.pipeline().addLast(new HttpServerCodec());
                            // 支持大数据流的写入
                            ch.pipeline().addLast(new ChunkedWriteHandler());
                            // 聚合 HTTP 消息,方便处理完整请求
                            ch.pipeline().addLast(new HttpObjectAggregator(8192));
                            // WebSocket 协议处理器:
                            // - 路径为 /ws
                            // - 支持 WebSocket 升级握手
                            ch.pipeline().addLast(new WebSocketServerProtocolHandler("/ws", "WebSocket", true, 65536 * 10));
                            // 最后添加我们自己的业务处理器
                            ch.pipeline().addLast(webSocketHandler);
                        }
                    });
            // 异步绑定端口,sync() 会阻塞直到绑定完成
            ChannelFuture channelFuture = serverBootstrap.bind().sync();
            System.out.println("Netty WebSocket 服务器启动成功,监听端口:8004");
            // 关闭通道(会阻塞,直到服务器通道关闭)
            channelFuture.channel().closeFuture().sync();
        } finally {
            // 关闭线程组,释放资源
            workerGroup.shutdownGracefully().sync();
            bossGroup.shutdownGracefully().sync();
        }
    }
}

八、后端开发(五):SpringBoot 启动类集成 Netty

8.1 通过 CommandLineRunner 启动 Netty

SpringBoot 提供了 CommandLineRunner 接口,当应用启动完成后,会自动调用其中的 run() 方法,非常适合我们在其中启动 Netty。

package com.qcby.springboot;

import com.qcby.springboot.commun.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;

/**
 * SpringBoot 启动类
 *
 * 负责:
 * - 启动 Spring 容器
 * - 在项目启动后,自动启动 Netty WebSocket 服务器
 */
@SpringBootApplication
@MapperScan("com.qcby.springboot.dao") // 如果暂时没有 MyBatis,可去掉
public class Application implements CommandLineRunner {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    /**
     * 自动注入 NettyWebSocketServer
     */
    @Autowired
    private NettyWebSocketServer nettyServer;

    /**
     * SpringBoot 启动完成后会自动回调该方法
     */
    @Override
    public void run(String... args) throws Exception {
        // 启动 Netty WebSocket 服务
        nettyServer.start();
    }
}

到此为止,后端信令服务器就搭好了:

  • 启动 SpringBoot 项目 → 自动启动 Netty WebSocket。
  • 前端可以通过:ws://localhost:8004/ws 建立连接。
  • 信令消息通过 JSON 文本传输,由 WebSocketHandler 转发。

九、前端:基于 Vue3 的 WebRTC 视频聊天页面

9.1 页面结构与样式(template + style)

下面是一份完整的页面模板,已经包含:

  • 用户 ID 输入与连接服务器按钮。
  • 目标用户 ID 输入与'发起视频呼叫'按钮。
  • 本地 / 远程视频展示区域。
  • 基本的 UI 美化。
<template>
    <div>
        <h2>WebRTC 视频聊天</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">
            <button @click="hangUp">挂断通话</button>
        </div>
        <!-- 4. 视频展示区域 -->
        <div>
            <div>
                <p>本地视频</p>
                <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 {
    : center;
}
  {
    : ;
    : ;
    : ;
}
 {
    : ;
    : ;
    :  solid ;
    : ;
    : ;
}
</style>

十、前端脚本(script setup):WebSocket + WebRTC 全流程

10.1 基础变量与依赖导入

<script setup>
// 1. 从 Vue 中导入响应式 API 和生命周期钩子
import { ref, onUnmounted } from 'vue';

// 2. 响应式变量:会影响页面显示
const userId = ref(''); // 本地用户 ID(如 user1)
const targetUserId = ref(''); // 对方用户 ID(如 user2)
const socketConnected = ref(false); // WebSocket 是否已连接

// 3. 视频 DOM 引用,用于绑定媒体流
const localVideo = ref(null); // 本地视频 <video>
const remoteVideo = ref(null); // 远程视频 <video>

// 4. 普通变量:仅脚本内部使用,不需要响应式
let socket = null; // WebSocket 连接实例
let peerConnection = null; // WebRTC RTCPeerConnection 实例
let localStream = null; // 本地音视频流(MediaStream)

// 5. STUN 服务器配置(ICE 服务器),用于 NAT 穿透
const iceServers = {
    iceServers: [
        { urls: 'stun:stun.l.google.com:19302' },
        { urls: 'stun:stun.qq.com:3478' },
        { urls: 'stun:stun.aliyun.com:3478' }
    ]
};
</script>

10.2 WebSocket 连接与消息发送

<script setup>
// ... 上面的代码省略 ...

// 通用消息发送函数:所有 register / call / answer / ice 都通过它发送
const sendMessage = (message) => {
    if (socket && socket.readyState === WebSocket.OPEN) {
        socket.send(JSON.stringify(message));
        console.log('📤 发送消息:', message);
    } else {
        console.error('❌ WebSocket 未连接,无法发送消息');
        alert('未连接服务器,请先点击'连接服务器'!');
    }
};

// 点击'连接服务器'按钮时触发
const connect = () => {
    if (!userId.value.trim()) {
        alert('请输入你的用户 ID!(不能为空)');
        return;
    }
    // 注意:这里的端口和路径要与后端 Netty 配置一致
    socket = new WebSocket('ws://localhost:8004/ws');

    // 连接成功
    socket.onopen = () => {
        console.log('✅ WebSocket 连接成功');
        socketConnected.value = true;
        // 向服务器发送 register 消息,注册当前用户 ID
        sendMessage({ type: 'register', from: userId.value, to: '', data: '' });
    };

    // 收到服务器转发的信令消息
    socket.onmessage = (event) => {
        try {
            const message = JSON.parse(event.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;
    };
};
</script>

10.3 处理收到的信令消息

<script setup>
// ... 前面代码省略 ...

// 根据消息类型处理:call / answer / ice
const handleMessage = async (message) => {
    switch (message.type) {
        case 'call':
            // 收到对方发起的呼叫(包含 SDP offer)
            await answerCall(message);
            break;
        case 'answer':
            // 对方已应答,设置远程 SDP
            await setRemoteSDP(message.data);
            break;
        case 'ice':
            // 收到对方的 ICE 候选
            await addIceCandidate(message.data);
            break;
        default:
            console.log('📌 未处理的消息类型:', message.type);
    }
};
</script>

10.4 WebRTC 核心:PeerConnection 初始化与事件绑定

<script setup>
// ... 继续补充在同一个 <script setup> 内 ...

// 初始化 RTCPeerConnection
const initPeerConnection = async () => {
    // 如果已有连接,先关闭,避免重复
    if (peerConnection) {
        peerConnection.close();
    }
    // 创建新的 PeerConnection,传入 ICE 服务器配置
    peerConnection = new RTCPeerConnection(iceServers);

    // 监听本地 ICE 候选生成事件:每生成一个地址,就通过信令发给对方
    peerConnection.onicecandidate = (event) => {
        if (event.candidate) {
            sendMessage({
                type: 'ice',
                from: userId.value,
                to: targetUserId.value,
                data: JSON.stringify(event.candidate)
            });
        }
    };

    // 监听远程媒体流事件:当对方的音视频流到达时挂载到 <video>
    peerConnection.ontrack = (event) => {
        remoteVideo.value.srcObject = event.streams[0];
        console.log('🎥 收到远程音视频流');
    };
};
</script>

10.5 发起呼叫(Caller 端逻辑)

<script setup>
// ... 继续在同一个 <script setup> 中 ...

// 点击'发起视频呼叫'
const call = async () => {
    if (!targetUserId.value.trim()) {
        alert('请输入对方用户 ID!');
        return;
    }
    try {
        // 1. 初始化 PeerConnection
        await initPeerConnection();

        // 2. 获取本地音视频流(浏览器会弹出权限申请)
        localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });

        // 3. 将本地流显示在本地视频上
        localVideo.value.srcObject = localStream;

        // 4. 将本地流的每一条轨道添加到 PeerConnection
        localStream.getTracks().forEach((track) => {
            peerConnection.addTrack(track, localStream);
        });

        // 5. 创建 SDP offer(描述本地的媒体能力)
        const offer = await peerConnection.createOffer();

        // 6. 将 offer 设置为本地描述
        await peerConnection.setLocalDescription(offer);

        // 7. 通过信令把 offer 发送给对方
        sendMessage({
            type: 'call',
            from: userId.value,
            to: targetUserId.value,
            data: JSON.stringify(offer)
        });
        console.log('📞 已向用户发起呼叫:', targetUserId.value);
    } catch (err) {
        console.error('❌ 发起呼叫失败:', err);
        alert('发起呼叫失败,请检查摄像头/麦克风权限或网络环境');
    }
};
</script>

10.6 接听呼叫(Callee 端逻辑)

<script setup>
// ... 继续 ...

// 收到对方的 call 消息后自动应答(简化逻辑,实际可加'是否接听'弹窗)
const answerCall = async (message) => {
    // 记录对方 ID,后续发送 answer / ice 时需要用
    targetUserId.value = message.from;
    try {
        // 1. 初始化 PeerConnection
        await initPeerConnection();

        // 2. 获取本地音视频流
        localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
        localVideo.value.srcObject = localStream;
        localStream.getTracks().forEach((track) => {
            peerConnection.addTrack(track, localStream);
        });

        // 3. 设置对方的 SDP offer 为远程描述
        await peerConnection.setRemoteDescription(JSON.parse(message.data));

        // 4. 创建 SDP answer
        const answer = await peerConnection.createAnswer();

        // 5. 设置本地 SDP
        await peerConnection.setLocalDescription(answer);

        // 6. 通过信令把 answer 发送回呼叫方
        sendMessage({
            type: 'answer',
            from: userId.value,
            to: targetUserId.value,
            data: JSON.stringify(answer)
        });
        console.log('📞 已应答视频呼叫:', targetUserId.value);
    } catch (err) {
        console.error('❌ 应答呼叫失败:', err);
        alert('应答呼叫失败!');
    }
};
</script>

10.7 设置远程 SDP 与添加 ICE 候选

<script setup>
// ... 继续 ...

// 设置远程 SDP(无论是 Caller 还是 Callee,在收到 answer 或 offer 后都要调用)
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);
    }
};

// 添加 ICE 候选
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);
    }
};
</script>

10.8 挂断通话与资源清理

<script setup>
// ... 继续 ...

// 挂断通话
const hangUp = () => {
    // 停止本地流
    if (localStream) {
        localStream.getTracks().forEach((track) => track.stop());
        localVideo.value.srcObject = null;
        localStream = null;
    }
    // 清空远程视频
    if (remoteVideo.value) {
        remoteVideo.value.srcObject = null;
    }
    // 关闭 PeerConnection
    if (peerConnection) {
        peerConnection.close();
        peerConnection = null;
    }
    // 重置对方 ID
    targetUserId.value = '';
    console.log('📞 已挂断通话');
    alert('已挂断通话!');
};

// 页面销毁时自动清理资源,防止摄像头被占用、连接泄漏
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>

到这里,前端完整逻辑就已经搭建完成。


十一、联调与测试步骤

  1. 启动后端 SpringBoot 项目(确保 Netty WebSocket 端口如 8004 已监听)。
  2. 启动前端 Vue 项目,打开浏览器访问页面。
  3. 打开两个浏览器标签页(或两个不同浏览器):
    • 页面 A:输入 user1,点击'连接服务器'。
    • 页面 B:输入 user2,点击'连接服务器'。
  4. 在 A 页面输入对方 ID user2,点击'发起视频呼叫'。
  5. 浏览器会弹出摄像头/麦克风权限授权,点击允许。
  6. 正常情况下,两边都能看到:
    • 自己的本地画面。
    • 对方的远程画面。

十二、常见问题与排查思路

12.1 两边都连上了 WebSocket,但看不到视频?

可能原因:

  • 浏览器拒绝了摄像头 / 麦克风权限 → 重新授权。
  • STUN 服务器被墙或不可达 → 尝试换其他 STUN 地址,或在局域网内先不依赖 STUN。
  • 前后端端口、路径不一致 → 检查:
    • Netty 是否监听 8004。
    • 前端 WebSocket 地址是否为 ws://localhost:8004/ws。

12.2 控制台报 SDP / ICE 相关错误?

常见错误:

  • setRemoteDescription called in wrong state:
    • 通常是 SDP 设置顺序有误,需要先 setRemoteDescription 再 createAnswer。
  • addIceCandidate failed:
    • 可能是 SDP 还没设置好就添加 ICE,需要确保先完成 SDP 的 setRemoteDescription。

十三、常见面试题与参考答案(围绕本项目)

问题 1:WebRTC 和 WebSocket 有什么区别?在这个项目里分别做什么?

参考答案:

  • WebRTC 是浏览器原生提供的实时音视频通信技术,重点在于:
    • 采集本地音视频。
    • 编解码媒体数据。
    • 建立点对点(P2P)媒体通道。
  • WebSocket 是一种全双工的长连接通信协议,通常跑在 TCP 之上,用于:
    • 实现浏览器与服务器之间的实时消息传输。
  • 在本项目中:
    • WebRTC 负责在两个浏览器之间建立 P2P 连接,传输音视频流。
    • WebSocket + Netty 负责信令交换(register / call / answer / ice),相当于为 WebRTC 做'牵线搭桥'和'会话协商'。

问题 2:什么是 ICE / STUN / TURN?为什么需要它们?

参考答案:

  • 由于大多数用户在 NAT(如家用路由器)后面,只拥有内网 IP,导致两台设备很难直接互相访问。
  • STUN:Session Traversal Utilities for NAT
    • 一种轻量级的网络服务,用于让设备知道自己在互联网眼中的'外网 IP + 端口'。
  • ICE:Interactive Connectivity Establishment
    • 一整套用于 NAT 穿透的框架,会综合尝试各种候选地址(本地、STUN、TURN),找到一条能打通的路径。
  • TURN:Traversal Using Relays around NAT
    • 当 P2P 无法直接打通时,通过中继服务器中转媒体流,保证可靠性,但成本较高。
  • 在本项目中,我们通过配置 iceServers(STUN 列表)让浏览器可以获取自己的公网候选地址,从而尝试建立 P2P 连接。

问题 3:SDP 在 WebRTC 中的作用是什么?

参考答案:

  • SDP(Session Description Protocol) 是会话描述协议,用来描述一次多媒体会话的参数。
  • 在 WebRTC 中,SDP 主要包含:
    • 媒体类型(音频/视频)。
    • 编码格式(codec,如 VP8、H264)。
    • 传输协议、带宽限制等。
  • WebRTC 通过 createOffer() / createAnswer() 生成 SDP,并通过信令通道(本项目中是 WebSocket)在两个端之间交换。

简单理解:

SDP 就是一张'合同',双方约定好'怎么说话、用什么语言、在什么频道说',然后再真正开始传输。


问题 4:为什么要用 Netty 实现 WebSocket,而不是直接用 SpringBoot 自带的 WebSocket 支持?

参考答案:

  • SpringBoot 本身也支持基于 STOMP / SockJS 的 WebSocket 方案,更偏向业务开发的方便性。
  • Netty 是一个更底层的高性能网络框架,优势在于:
    • 性能好,可控性强,适用于高并发、多连接场景。
    • 可以灵活定制 Pipeline 与 Handler。
  • 在实际生产中:
    • 如果只是简单的消息推送,小项目可以直接使用 Spring 的 WebSocket 支持。
    • 如果需要高性能 IM、网关、长连接服务等,Netty 更适合。
  • 本项目的目的之一是让学习者接触 Netty,理解更底层的网络处理方式。

问题 5:如果在实际项目中,要支持多人会议 / 群聊,该怎么扩展?

参考答案(思路型回答):

  1. 信令层面:
    • 当前项目是一对一,通过 from / to 建立单对单信令。
    • 多人会议可以采用房间(room)的概念,引入 roomId,让服务器管理房间内成员列表,并进行广播式信令分发。
  2. 媒体层面:
    • 纯 P2P 的网状拓扑:n 人则最多有 n*(n-1)/2 条连接,浏览器负担较大。
    • 生产环境通常会引入 SFU(Selective Forwarding Unit)等媒体服务器,对流进行中转、混流或转发。
  3. 带宽与性能:
    • 多人会议对上行带宽要求高,纯前端 P2P 方案不够稳定。
    • 需要结合业务场景选择合适的架构(如 Janus、Jitsi、mediasoup 等现成 SFU)。

十四、总结与延伸学习方向

通过本篇文章与配套 Demo,你应该已经掌握:

  • WebRTC 基本概念:SDP、ICE、STUN 的作用与关系。
  • 信令服务器的职责:为何需要 WebSocket + 后端。
  • Netty + SpringBoot 集成方式:如何启动一个高性能 WebSocket 服务端。
  • Vue3 + WebRTC 的端到端流程:从采集本地流、建立 PeerConnection,到 P2P 传输与挂断。

接下来可以进一步扩展:

  • 增加'是否接听'的弹窗,而不是自动应答。
  • 支持文字聊天、屏幕共享功能。
  • 引入 TURN 服务器,提高复杂网络环境下的连通率。
  • 尝试对接真实域名与 HTTPS(wss + https),部署到公网演示。

如果你把这套 Demo 完整跑通,并能向别人清楚讲解其中的每一个名词与步骤,那么在大多数初中级面试中,关于实时音视频 / WebRTC / Netty 长连接相关的问题,你已经可以比较自信地应对了。希望这篇文章能成为你学习 WebRTC + Java 全栈实时通讯的一个良好起点。

text-align
.video-item
p
margin-bottom
8px
font-size
16px
color
#333
video
width
400px
height
300px
border
1px
#ccc
border-radius
8px
background-color
#f5f5f5