在当今社交与在线协作应用中,实时音视频通话已成为不可或缺的核心能力。WebRTC 技术让浏览器无需任何插件即可实现点对点的音视频传输,而 Netty 凭借其卓越的 NIO 性能,为信令交换提供了稳定可靠的 WebSocket 通道。本文将带领你使用 SpringBoot、Vue、Netty 与 WebRTC 四大技术栈,构建一套完整的一对一视频聊天系统,深入解析从服务端架构到前端交互的全链路实现。

一、系统架构与核心原理

整个视频聊天系统采用经典的三层架构设计,其核心在于信令转发点对点音视频传输的协同工作:

  • 前端展示层(Vue):负责采集本地音视频流、创建 WebRTC 连接,并通过 WebSocket 通道发送与接收各类信令消息。
  • 通信中继层(Netty + WebSocket):维护所有客户端的连接状态,高效转发呼叫请求、ICE 候选信息等信令数据。
  • 后端服务层(SpringBoot):作为项目的基础骨架,整合 Netty 服务生命周期,管理用户连接映射关系。
技术栈核心作用
SpringBoot后端快速开发框架,整合 Netty、配置 WebSocket,提供接口支撑
Vue前端工程化框架,负责音视频界面渲染、WebRTC API 调用
Netty高性能网络通信框架,实现 WebSocket 服务端,处理客户端连接和信令转发
WebSocket全双工通信协议,用于前端和后端之间的信令(如呼叫、应答、ICE 候选)传输
WebRTC实时通信标准,提供音视频采集、编码、点对点传输能力

WebRTC 核心概念速览:WebRTC 是浏览器内置的实时通信引擎,支持网页直接进行音视频通话与数据传输。而 ICE(交互式连接建立)框架则负责解决复杂的 NAT 穿透问题,其中STUN 服务器的作用尤为关键——它帮助处于内网环境的设备获取自身的公网 IP 与端口映射,就像为小区里的住户查询对外通信的“门牌号”。通过 STUN 服务器,不同内网下的设备才能互相发现并建立直接的 P2P 连接。

二、服务端开发:SpringBoot 与 Netty 整合

服务端的开发工作主要围绕 SpringBoot 项目搭建、Netty 服务配置以及核心消息处理逻辑展开。在项目初始化阶段,我们需要引入关键依赖:Netty 负责底层网络通信,而 FastJSON 则用于处理前后端交互的 JSON 格式信令,确保数据序列化的高效与便捷。



    org.springframework.boot
    spring-boot-starter



    io.netty
    netty-all
    4.1.94.Final



    com.alibaba
    fastjson
    2.0.25

2.1 定义信令消息实体

信令消息作为客户端与服务端沟通的载体,其结构设计至关重要。我们定义一个通用的 Message.java 实体类,包含消息类型发送方 ID接收方 ID 以及消息内容四个核心属性。这一设计能够满足注册、呼叫、应答、ICE 交换等多种信令场景的通用传输需求。

public class Message {
    // 消息类型:register(注册)、call(呼叫)、answer(应答)、ice(ICE候选)
    private String type;
    // 发送方ID
    private String from;
    // 接收方ID
    private String to;
    // 消息内容(SDP/ICE 数据)
    private String data;
}

2.2 核心消息处理器实现

Netty 的 WebSocket 处理器是整个服务端的中枢神经,它负责处理连接建立、消息路由与转发、连接断开等关键事件。在处理器中,我们通过维护一个全局的用户通道映射表,实现精准的信令定向转发,确保消息能够准确到达目标客户端。

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 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 {
    // 存储用户ID与Channel的映射(线程安全)
    public static final ConcurrentHashMap 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 {
        // 解析JSON消息
        String text = msg.text();
        Message message = JSON.parseObject(text, Message.class);
        System.out.println("收到消息:" + text);
        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":
                // 转发消息到接收方
                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();
    }
}

三、Netty 服务启动与生命周期管理

为了让 Netty 服务与 SpringBoot 项目无缝集成,我们需要借助 Spring 的 @PostConstruct@PreDestroy 注解,在应用启动时自动拉起 Netty 服务,并在应用销毁前优雅释放资源。Netty 的线程模型采用主从 Reactor 模式:bossGroup 负责接收客户端连接,workerGroup 则处理大量的读写事件,这种设计是 Netty 高性能的基石。

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 服务端*/
@Configurationpublic
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) // 指定使用的channel
                    .localAddress(8004)// 绑定监听端口
                    .childHandler(new ChannelInitializer() { // 绑定客户端连接时候触发操作
                        @Override
                        protected void initChannel(SocketChannel ch) throws Exception {
                        //websocket协议本身是基于http协议的,所以这边也要使用http解编码器
                        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();
        }
    }
}

在通道处理器链的编排上,由于 WebSocket 协议基于 HTTP 握手升级,因此需要依次添加 HTTP 编解码器、HTTP 聚合器以及 WebSocket 处理器,最后加入我们自定义的业务逻辑处理器,形成完整的处理流水线。

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;
@SpringBootApplication
@MapperScan("com.qcby.springboot.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();
    }
}

四、Vue 前端:从 UI 到 WebRTC 逻辑实现

前端部分的开发重点在于 Vue 组件的搭建与 WebRTC 核心逻辑的编写。首先,我们定义视频展示区域的模板结构,包括本地视频与远端视频的容器,以及呼叫、挂断等操作按钮。


4.1 响应式状态与依赖导入

script setup 语法糖中,我们使用 Vue 3 的 ref 函数创建响应式变量,例如连接状态、呼叫状态等。这些变量的变化会自动驱动视图更新,提升开发效率。同时,利用 onUnmounted 钩子进行页面销毁时的资源清理,避免内存泄漏。

<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; // 本地音视频流(用于后续停止流)
</script>

4.2 WebSocket 连接与消息分发

建立 WebSocket 连接是信令交互的前提。在连接函数中,我们需要配置 STUN 服务器地址,并对用户输入的 ID 进行 trim() 处理,防止非法字符。同时,通过 try-catch 对服务端返回的数据进行 JSON 解析,增强脚本的健壮性。

<script setup>
// (接上一步代码)
// 5. 配置 STUN 服务器(WebRTC 必需,用于获取公网ICE候选)
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 连接成功回调
  socket.onopen = () => {
    console.log('✅ WebSocket连接成功');
    socketConnected.value = true; // 更新连接状态,显示呼叫区域
    // 发送注册消息:告诉服务端“我上线了”
    sendMessage({
      type: 'register',
      from: userId.value,
      to: '',
      data: ''
    });
  };
  // 6.2 接收服务端消息回调(核心:处理转发的信令)
  socket.onmessage = (e) => {
    try {
      const message = JSON.parse(e.data); // 解析JSON消息
      console.log(' 收到服务端消息:', message);
      handleMessage(message); // 专门处理消息的函数(后续定义)
    } catch (err) {
      console.error('❌ 消息解析失败:', err);
    }
  };
  // 6.3 连接关闭回调
  socket.onclose = () => {
    console.log('❌ WebSocket连接关闭');
    socketConnected.value = false; // 更新连接状态,隐藏呼叫区域
  };
  // 6.4 连接错误回调
  socket.onerror = (err) => {
    console.error('❌ WebSocket连接错误:', err);
    socketConnected.value = false;
    alert('连接服务器失败!请检查服务端是否启动,端口是否正确。');
  };
};
</script>

为了减少重复代码,我们封装一个通用的消息发送函数。该函数会检查 WebSocket 的连接状态(WebSocket.OPEN),确保在连接可用时才发送数据,从而避免因状态异常导致的发送失败。

<script setup>
// (接上一步代码)
// 7. 通用发送WebSocket消息函数(复用,避免重复代码)
const sendMessage = (message) => {
  // 校验:WebSocket必须处于打开状态
  if (socket && socket.readyState === WebSocket.OPEN) {
    socket.send(JSON.stringify(message)); // 转为JSON字符串发送
    console.log(' 发送消息:', message);
  } else {
    console.error('❌ WebSocket未连接,无法发送消息');
    alert('未连接服务器,请先点击“连接服务器”!');
  }
};
</script>

消息处理函数采用分支结构,根据服务端返回的消息类型(register、call、answer、ice)执行相应的逻辑。这里大量使用 async/await 处理异步操作,确保 SDP(会话描述协议)的设置与 ICE 候选的添加按序完成。

<script setup>
// (接上一步代码)
// 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);
  }
};
</script>

4.3 WebRTC 连接核心逻辑

WebRTC 的核心逻辑封装在几个关键函数中:getUserMedia 用于获取本地音视频流;PeerConnection 负责处理连接的协商与数据传输。通过监听 ontrack 事件接收远端流并绑定到视频元素,通过 onicecandidate 事件将本地 ICE 候选发送给远端,逐步建立 P2P 连接。

<script setup>
// (接上一步代码)
// 9. 初始化 PeerConnection(WebRTC核心,复用函数)
const initPeerConnection = async () => {
  // 如果已有PeerConnection,先关闭(避免重复创建)
  if (peerConnection) {
    peerConnection.close();
  }
  // 创建PeerConnection实例(传入STUN服务器配置)
  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
    await initPeerConnection();
    // 获取本地音视频流(请求摄像头/麦克风权限)
    localStream = await navigator.mediaDevices.getUserMedia({
      video: true, // 开启视频
      audio: true  // 开启音频
    });
    // 将本地流绑定到本地视频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 offer)给对方
    sendMessage({
      type: 'call',
      from: userId.value,
      to: targetUserId.value,
      data: JSON.stringify(offer)
    });
    console.log(' 发起视频呼叫:', targetUserId.value);
  } catch (err) {
    console.error('❌ 发起呼叫失败:', err);
    alert('发起呼叫失败!请检查摄像头/麦克风权限,或是否已连接服务器。');
  }
};
// 11. 应答呼叫请求函数
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;
    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(' 应答视频呼叫:', targetUserId.value);
  } catch (err) {
    console.error('❌ 应答呼叫失败:', err);
    alert('应答呼叫失败!');
  }
};
// 12. 设置远程SDP函数
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);
  }
};
// 13. 添加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>

⚠️ 注意事项getUserMedia 会触发浏览器的权限弹窗,若用户拒绝授权,需通过 try-catch 捕获异常并给出友好提示。此外,ICE 候选的收集与交换是连接成功的关键,务必确保信令通道的畅通。

4.4 资源清理与挂断功能

通话结束后的资源释放同样重要。我们编写清理函数,停止本地流的音轨,并主动关闭 PeerConnection。同时,在 onUnmounted 生命周期中调用该函数,确保用户离开页面时摄像头与麦克风被及时释放。

<script setup>
// (接上一步代码)
// 14. 页面销毁时清理资源(避免内存泄漏/设备占用)
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>

最后,我们在界面上补充“挂断”按钮,并实现对应的挂断逻辑,完成整个通话流程的闭环。


<script setup>
// (添加在 initPeerConnection 之后)
// 15. 挂断通话函数
const hangUp = () => {
  // 停止本地流
  if (localStream) {
    localStream.getTracks().forEach(track => track.stop());
    localVideo.value.srcObject = null; // 清空本地视频
  }
  // 清空远程视频
  if (remoteVideo.value) {
    remoteVideo.value.srcObject = null;
  }
  // 关闭PeerConnection
  if (peerConnection) {
    peerConnection.close();
    peerConnection = null;
  }
  // 重置目标用户ID
  targetUserId.value = '';
  console.log(' 挂断通话');
  alert('已挂断通话!');
};
</script>

五、技术要点总结与展望

本文详细拆解了基于 SpringBoot、Vue、Netty 与 WebRTC 的一对一视频聊天系统实现方案。从服务端的 Netty 高性能通信,到前端的 WebRTC 点对点连接,再到信令的桥接与 STUN 的穿透辅助,每一个环节都至关重要。值得注意的是,虽然 JavaScript 是 WebRTC 的主要语言,但理解 Java 服务端的并发模型(如 Netty 的 EventLoop)同样有助于优化整体性能。对于希望深入底层原理的开发者,C++ 的 WebRTC 原生库也提供了更细粒度的控制能力。

[AFFILIATE_SLOT_1]

六、扩展思考:从一对一走向多人会议

当前的架构已具备良好的扩展性。若需支持多人视频会议,可以引入 SFU(选择性转发单元) 架构,通过服务端进行音视频流的混合与转发。在技术选型上,Python 的 AI 能力可用于实现实时语音识别与字幕生成,而 TypeScript 则能为前端代码提供更严格的类型安全保障,降低大型项目的维护成本。此外,生产环境部署时,务必使用 wss:// 协议并配置 TURN 服务器作为中继兜底,以应对复杂网络环境下的连接失败问题。

[AFFILIATE_SLOT_2]

核心要点回顾:整个系统的成功运行依赖于信令的准确转发与媒体流的成功穿透。掌握 Netty 的线程模型、WebSocket 的消息机制以及 WebRTC 的连接协商流程,是构建稳定音视频应用的关键。