用一篇文章带你搞懂 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 候选)怎么互相知道?

这些“撮合、协商、交换信息”的过程,就叫做 “信令(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 示例实现(带详细注释)

packagecom.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) */publicclassMessage{/** * 消息类型: * - register:客户端注册自己的 userId * - call :发起呼叫,携带 SDP offer * - answer :应答呼叫,携带 SDP answer * - ice :传递 ICE 候选信息 */privateString type;/** * 发送方用户 ID * * 由前端在发送消息时指定,例如 user1、user2。 */privateString from;/** * 接收方用户 ID * * 服务端会根据该字段找到目标 Channel,然后进行消息转发。 */privateStringto;/** * 消息数据体 * * 对于不同类型的消息,该字段含义不同: * - register:通常为空字符串 * - call :存放 SDP offer 字符串(JSON 序列化后的对象) * - answer :存放 SDP answer 字符串 * - ice :存放 ICECandidate 的 JSON 字符串 */privateString data;// ======= Getter / Setter(省略 IDE 自动生成注释) =======publicStringgetType(){return type;}publicvoidsetType(String type){this.type = type;}publicStringgetFrom(){return from;}publicvoidsetFrom(String from){this.from = from;}publicStringgetTo(){returnto;}publicvoidsetTo(Stringto){this.to=to;}publicStringgetData(){return data;}publicvoidsetData(String data){this.data = data;}@OverridepublicStringtoString(){return"Message{"+"type='"+ type +'\''+",+ from +'\''+",+to+'\''+",+ data +'\''+'}';}}

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

6.1 职责说明

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

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

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

packagecom.qcby.springboot.handler;importcom.alibaba.fastjson.JSON;importcom.qcby.springboot.entity.Message;importio.netty.channel.Channel;importio.netty.channel.ChannelHandler;importio.netty.channel.ChannelHandlerContext;importio.netty.channel.SimpleChannelInboundHandler;importio.netty.handler.codec.http.websocketx.TextWebSocketFrame;importorg.springframework.context.annotation.Configuration;importjava.util.Map;importjava.util.concurrent.ConcurrentHashMap;/** * WebSocketHandler * * 作用:处理基于 WebSocket 的文本消息(TextWebSocketFrame), * 配合 SpringBoot 与 Netty 实现一个简单的“信令服务器”。 * * 关键点: * - 使用 ConcurrentHashMap 存储用户 ID 与 Channel 的映射关系。 * - 根据消息类型进行不同的业务处理。 */@Configuration@ChannelHandler.SharablepublicclassWebSocketHandlerextendsSimpleChannelInboundHandler<TextWebSocketFrame>{/** * 用户 ID 到 Channel 的全局映射表 * * key :String 类型的用户 ID(如 "user1") * value :该用户对应的 Netty Channel(一个 WebSocket 连接) * * 使用 ConcurrentHashMap 保证多线程场景下的线程安全。 */publicstaticfinalConcurrentHashMap<String,Channel> USER_CHANNEL_MAP =newConcurrentHashMap<>();/** * 当与客户端建立连接时触发 * * 注意:此时客户端还未发送 register 消息,我们只知道有一个新连接, * 但暂时不知道它对应的 userId。 */@OverridepublicvoidchannelActive(ChannelHandlerContext ctx)throwsException{System.out.println("与客户端建立连接,通道开启:"+ ctx.channel().id());}/** * 处理接收到的文本消息(核心逻辑) * * @param ctx 上下文对象,包含 Channel、Pipeline 等信息 * @param msg 客户端发送的文本帧(TextWebSocketFrame) */@OverrideprotectedvoidchannelRead0(ChannelHandlerContext ctx,TextWebSocketFrame msg)throwsException{// 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 */privatevoidhandleRegister(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 文本(直接转发给对方) */privatevoidhandleForward(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(newTextWebSocketFrame(rawText));System.out.println("已将消息转发给用户:"+ targetUserId);}else{System.out.println("用户 "+ targetUserId +" 不在线或 Channel 不可用");}}/** * 处理连接断开事件 * * 注意:这里我们简单地打印日志,更完善的做法是: * - 从 USER_CHANNEL_MAP 中移除对应的 userId * (可以反向根据 Channel 找到 userId) */@OverridepublicvoidchannelInactive(ChannelHandlerContext ctx)throwsException{System.out.println("与客户端断开连接,通道关闭:"+ ctx.channel().id());// 从映射表中移除对应的 Channel USER_CHANNEL_MAP.entrySet().removeIf( entry -> entry.getValue().id().equals(ctx.channel().id()));}/** * 处理异常情况 * * 常见异常: * - 网络中断 * - 客户端强制关闭 * - 解码错误等 */@OverridepublicvoidexceptionCaught(ChannelHandlerContext ctx,Throwable cause)throwsException{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 核心代码实现

packagecom.qcby.springboot.commun;importcom.qcby.springboot.handler.WebSocketHandler;importio.netty.bootstrap.ServerBootstrap;importio.netty.channel.ChannelFuture;importio.netty.channel.ChannelInitializer;importio.netty.channel.ChannelOption;importio.netty.channel.EventLoopGroup;importio.netty.channel.nio.NioEventLoopGroup;importio.netty.channel.socket.SocketChannel;importio.netty.channel.socket.nio.NioServerSocketChannel;importio.netty.handler.codec.http.HttpObjectAggregator;importio.netty.handler.codec.http.HttpServerCodec;importio.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;importio.netty.handler.stream.ChunkedWriteHandler;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.context.annotation.Configuration;/** * Netty WebSocket 服务端 * * 与 SpringBoot 集成的方式: * - 声明为 @Configuration,让 Spring 管理 * - 在 SpringBoot 启动后,通过 CommandLineRunner 调用 start() 方法 */@ConfigurationpublicclassNettyWebSocketServer{/** * 自定义的 WebSocketHandler,由 Spring 自动注入 */@AutowiredprivateWebSocketHandler webSocketHandler;/** * 启动 Netty WebSocket 服务器 * * @throws Exception 启动异常 */publicvoidstart()throwsException{// bossGroup:专门用来接收客户端连接的线程组EventLoopGroup bossGroup =newNioEventLoopGroup();// workerGroup:处理已建立连接的读写事件EventLoopGroup workerGroup =newNioEventLoopGroup();try{ServerBootstrap serverBootstrap =newServerBootstrap(); serverBootstrap // 设置服务端的参数,例如等待队列大小.option(ChannelOption.SO_BACKLOG,1024)// 绑定两个线程组.group(bossGroup, workerGroup)// 指定使用 NIO 模型的服务端 Channel.channel(NioServerSocketChannel.class)// 指定本地监听端口.localAddress(8004)// 子通道(客户端连接)初始化逻辑.childHandler(newChannelInitializer<SocketChannel>(){@OverrideprotectedvoidinitChannel(SocketChannel ch){// WebSocket 基于 HTTP 协议,因此先添加 HTTP 编解码器 ch.pipeline().addLast(newHttpServerCodec());// 支持大数据流的写入 ch.pipeline().addLast(newChunkedWriteHandler());// 聚合 HTTP 消息,方便处理完整请求 ch.pipeline().addLast(newHttpObjectAggregator(8192));// WebSocket 协议处理器:// - 路径为 /ws// - 支持 WebSocket 升级握手 ch.pipeline().addLast(newWebSocketServerProtocolHandler("/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。

packagecom.qcby.springboot;importcom.qcby.springboot.commun.NettyWebSocketServer;importorg.mybatis.spring.annotation.MapperScan;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.boot.CommandLineRunner;importorg.springframework.boot.SpringApplication;importorg.springframework.boot.autoconfigure.SpringBootApplication;/** * SpringBoot 启动类 * * 负责: * - 启动 Spring 容器 * - 在项目启动后,自动启动 Netty WebSocket 服务器 */@SpringBootApplication@MapperScan("com.qcby.springboot.dao")// 如果暂时没有 MyBatis,可去掉publicclassApplicationimplementsCommandLineRunner{publicstaticvoidmain(String[] args){SpringApplication.run(Application.class, args);}/** * 自动注入 NettyWebSocketServer */@AutowiredprivateNettyWebSocketServer nettyServer;/** * SpringBoot 启动完成后会自动回调该方法 */@Overridepublicvoidrun(String... args)throwsException{// 启动 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 { 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> 

十、前端脚本(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 设置顺序有误,需要先 setRemoteDescriptioncreateAnswer
  • 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 全栈实时通讯的一个良好起点。 🎯

Read more

FPGA 工程师到底有哪些方向?每个岗位都在干什么?一篇给你讲清楚

FPGA 工程师到底有哪些方向?每个岗位都在干什么?一篇给你讲清楚

很多人说“学 FPGA 就是写 Verilog”,但真正进了行业才发现—— FPGA 工程师并不是一个岗位,而是一整个岗位族群。 不同公司、不同项目,对 FPGA 工程师的要求差异非常大。 如果方向选错,可能学了半年发现岗位根本不对口。 这篇文章就系统地给你拆一拆: 👉 FPGA 工程师到底有哪些岗位? 👉 每个岗位具体干什么? 👉 需要掌握哪些能力? 👉 适合什么样的人? 一、FPGA 工程师整体岗位划分(先给结论) 从企业招聘角度来看,FPGA 岗位大致可以分为 6 类: 岗位方向关键词偏向FPGA 逻辑设计工程师Verilog / 时序 / 接口核心开发FPGA 算法 / 加速工程师图像 / AI / DSP算法落地FPGA 底层驱动工程师DDR / PCIe / SerDes硬件接口FPGA 系统应用工程师Linux + FPGA系统集成FPGA 验证 / 测试仿真 / 验证质量保障FPGA 技术支持 / FA客户 / 项目支持应用型

OpenClaw本地部署接入飞书机器人完全安装指南

OpenClaw本地部署接入飞书机器人完全安装指南

作者:网心 2026-3-10 在 Windows 系统上从头开始部署 OpenClaw,并将其配置为可以接入飞书的智能机器人。我们将以实战中遇到的问题为鉴,确保安装过程顺畅无误。 第一章:准备工作与环境检查 在正式开始安装前,请确保您的电脑满足以下基础条件,并理解我们将要使用的关键命令。 1. 系统要求 操作系统: Windows 10 或 Windows 11 (需使用管理员权限运行 PowerShell)。 网络环境: 能够正常访问 GitHub 和 npm 仓库。如果您在网络受限的环境中,可能需要提前准备代理或镜像配置。 2. 核心命令解释 在整个安装过程中,有两个核心命令您需要理解: 一键安装命令:iwr -useb https://openclaw.ai/install.ps1 | iex iwr:Invoke-WebRequest 的别名,用于从指定网址下载文件。

FPGA开发必看!Xilinx Vivado付费IP核License状态解读与获取/vivado最新license获取

FPGA开发必看!Xilinx Vivado付费IP核License状态解读与获取/vivado最新license获取

Xilinx(AMD) vivado软件全部付费IP核及license许可介绍和获取 制作不易,记得三连哦,给我动力,持续更新!!! License或IP src源码 文件下载:Xilinx IP 完整license获取 (点击蓝色字体获取)(可提供IP源码) 一、介绍 Vivado是Xilinx(现属AMD)FPGA开发的核心工具,其内置的IP核资源库极为丰富。这些IP核根据来源可分为两大类: 一类是Xilinx官方提供的IP核,另一类则来自第三方供应商。从授权方式来看,又可划分为免费授权和商业授权两种类型。对于需要商业授权的IP核,用户必须获取对应的License文件方可正常使用。 二、Xilinx IP核 2.1 Xilinx 免费IP Xilinx(AMD)自主开发的IP核主要提供基础功能模块和必要接口组件,涵盖数字信号处理、通信协议、存储控制等通用功能。这类IP核已集成在Vivado开发环境中,用户完成软件安装后即可直接调用,无需额外授权文件。其完整支持设计全流程,包括功能仿真、逻辑综合、布局布线以及比特流生成。在Vivado的License管理界面中,

无人机仿真与强化学习的终极指南:gym-pybullet-drones项目深度解析

无人机仿真与强化学习的终极指南:gym-pybullet-drones项目深度解析 【免费下载链接】gym-pybullet-dronesPyBullet Gym environments for single and multi-agent reinforcement learning of quadcopter control 项目地址: https://gitcode.com/gh_mirrors/gy/gym-pybullet-drones 你是否想过,无需购买昂贵的无人机设备,就能在电脑上体验真实的飞行控制?今天要介绍的gym-pybullet-drones项目,正是这样一个完美的解决方案。这个开源项目为单机和多机无人机提供了基于PyBullet的强化学习环境,让你能够轻松探索无人机控制的世界。😊 快速上手:5分钟开启无人机仿真之旅 想要立即体验这个强大的无人机仿真平台?只需简单几步: 1. 克隆项目:git clone https://gitcode.com/gh_mirrors/gy/gym-pybullet-drones 2. 创建虚拟环境:conda