基于 Socket.IO 实现 WebRTC 音视频通话与实时聊天系统(Spring Boot 后端实现)

基于 Socket.IO 实现 WebRTC 音视频通话与实时聊天系统(Spring Boot 后端实现)

技术栈:Spring Boot + Socket.IO (Netty-socketio) + WebRTC + Redis + MongoDB


一、引言

随着远程医疗、在线教育、即时通讯等应用的普及,实时音视频通话和文本聊天功能已成为现代 Web 应用的核心需求。本文将详细介绍如何使用 Java 的 Netty-socketio 框架,在 Spring Boot 项目中实现一个完整的 WebRTC 信令服务器实时文本聊天系统

我们将深入剖析两个核心服务类 WebRTCServiceChatService,并提供完整的功能流程图和前端交互示例。


二、系统架构概览

本系统采用典型的 B/S 架构:

  • 前端 (Web/移动端):使用 WebRTC API 处理音视频流,使用 Socket.IO 客户端与服务器通信。

  • 后端 (Java Spring Boot) :使用 Netty-socketio 作为 Socket.IO 服务器,处理所有信令和消息逻辑。

  • 数据库 :使用 MongoDB 存储聊天消息和用户联系人,使用 Redis 存储在线状态和未读计数。

    +----------------+ +---------------------+ +----------------+
    | | | | | |
    | Web Client |<--->| Netty-socketio |<--->| MongoDB/Redis |
    | (WebRTC + Chat)| | (Java Server) | | (Persistence) |
    | | | | | |
    +----------------+ +---------------------+ +----------------+


三、核心功能模块详解

3.1 文本聊天服务 (ChatService.java)

ChatService 负责处理用户连接、消息收发、状态更新和房间管理。

3.1.1 核心功能
  1. 用户连接与身份认证

    • 客户端连接时,发送 connect_success 事件,携带 userId, role (patient/doctor), institutionId 等信息。
    • 服务端验证信息后,将用户加入对应的"通知房间"和"聊天房间"。
  2. 消息收发

    • 客户端发送 send_msg 事件。
    • 服务端将消息存入 MongoDB,并广播给房间内其他成员。
  3. 消息状态管理

    • 支持 msg_delivered (已送达) 和 msg_read (已读) 状态。
    • 通过 Redis 维护未读消息计数。
  4. 房间成员管理

    • 实时获取和广播房间内的在线成员列表。
3.1.2 关键代码解析
java 复制代码
// 生成唯一的聊天室ID
private String generateChatRoom(String institutionId, String patientId, String doctorId) {
    return "chat_room_" + institutionId + "_" + patientId + "_" + doctorId;
}

// 处理消息发送
private void handleSendMessage(SocketIOClient client, Map<String, Object> data) {
    // ... (参数校验)
    String chatRoom = generateChatRoom(institutionId, patientId, doctorId);
    joinAndTrackMembership(client, chatRoom); // 确保在房间内
    sendMessage(patientId, doctorId, institutionId, msgContent, role, msgType, client);
}

3.2 WebRTC 音视频通话服务 (WebRTCService.java)

WebRTCService 是 WebRTC 信令服务器的核心,负责交换 SDP Offer/Answer 和 ICE Candidate。

3.2.1 WebRTC 信令流程

WebRTC 本身不提供信令机制,需要开发者自行实现。本系统通过 Socket.IO 事件完成信令交换:

复制代码
1. A (发起方)             2. Server (信令服务器)           3. B (接收方)
   |                           |                              |
   |---- CALL_REQUEST -------->|                              |
   |                           |                              |
   |                           |------ CALL_REQUEST --------->|
   |                           |                              |
   |                           |<----- CALL_ACCEPT -----------|
   |<---- CALL_ACCEPT ----------|                              |
   |                           |                              |
   |------ OFFER ------------->|                              |
   |                           |                              |
   |                           |-------- OFFER -------------->|
   |                           |                              |
   |                           |<------- ANSWER ---------------|
   |<------ ANSWER -------------|                              |
   |                           |                              |
   |------ ICE_CANDIDATE ----->|                              |
   |                           |                              |
   |                           |--- ICE_CANDIDATE ----------->|
   |                           |                              |
   | (建立P2P连接)             |                              |
3.2.2 核心事件与处理
事件 (Event) 说明
call_request A 发起通话请求,服务器转发给 B。
call_accept B 接受通话,服务器通知 A。
call_reject B 拒绝通话,服务器通知 A 并清理会话。
call_end 任一方结束通话,通知另一方。
offer A 创建 Offer,通过服务器转发给 B。
answer B 创建 Answer,通过服务器转发给 A。
ice-candidate 双方收集到 ICE Candidate,通过服务器转发给对方。
call_timeout 服务器在 30 秒内未收到 B 的回应,自动通知 A 通话超时。
3.2.3 关键代码解析
java 复制代码
// 处理通话请求
socketServer.addEventListener(CALL_REQUEST, Map.class, (client, data, ackSender) -> {
    String roomId = getRequiredString(data, "roomId");
    String toUserId = getRequiredString(data, "toUserId");
    // ... (校验)
    callSessions.put(roomId, new CallSession(roomId, userId)); // 创建会话
    sendToUser(toUserId, roomId, CALL_REQUEST, payload); // 转发给接收方
    startTimeoutTimer(roomId, 30000); // 启动30秒超时定时器
});

// 处理 SDP 交换 (Offer/Answer)
private void handleSdpExchange(SocketIOClient client, Map<String, Object> data, String eventType) {
    String roomId = getRequiredString(data, "roomId");
    CallSession session = callSessions.get(roomId);
    // ... (会话校验)
    broadcastExceptSender(roomId, eventType, payload, client); // 转发给对方
}
3.2.4 会话管理与断线处理
  • 会话缓存 :使用 ConcurrentHashMap<String, CallSession> 存储所有通话会话。
  • 超时机制 :使用 ScheduledExecutorService 为每个 call_request 设置 30 秒超时。
  • 断线清理 :当用户断开连接时,ChatService 会调用 webRTCService.cleanupSessionsOnDisconnect(),清理该用户作为发起者的所有通话。
java 复制代码
public void cleanupSessionsOnDisconnect(SocketIOClient client) {
    String userId = client.get("userId");
    Iterator<Map.Entry<String, CallSession>> iterator = callSessions.entrySet().iterator();
    while (iterator.hasNext()) {
        Map.Entry<String, CallSession> entry = iterator.next();
        if (userId.equals(entry.getValue().getInitiator())) {
            broadcast(entry.getKey(), CALL_END, endEventPayload); // 通知对方
            iterator.remove(); // 安全移除
        }
    }
}

四、功能实例与交互图

4.1 文本聊天实例

场景:患者 (P1) 向医生 (D1) 发送一条文本消息。

html 复制代码
<!DOCTYPE html>
<html>
<head>
    <title>IM Chat Demo</title>
    <script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
</head>
<body>
    <h2>患者端 - 与医生 D1 聊天</h2>
    <div id="messages"></div>
    <input type="text" id="msgInput" placeholder="输入消息...">
    <button onclick="sendMessage()">发送</button>

    <script>
        const socket = io('http://localhost:8080'); // 连接服务器

        // 连接成功
        socket.emit('connect_success', {
            userId: 'P1',
            role: '2', // 患者
            institutionId: '1001',
            doctorId: 'D1'
        });

        // 接收消息
        socket.on('get_send_msg', function(msg) {
            const div = document.createElement('div');
            div.textContent = `[${msg.userType === 2 ? '患者' : '医生'}] ${msg.msgContent}`;
            document.getElementById('messages').appendChild(div);
        });

        function sendMessage() {
            const content = document.getElementById('msgInput').value;
            socket.emit('send_msg', {
                userId: 'P1',
                role: '2',
                institutionId: '1001',
                doctorId: 'D1',
                msgType: 1,
                msg: { text: content }
            });
            document.getElementById('msgInput').value = '';
        }
    </script>
</body>
</html>

4.2 音视频通话实例图

患者 (P1) 信令服务器 医生 (D1) call_request(roomId, toUserId=D1, offer) call_request(roomId, fromUserId=P1, offer) call_accept(roomId) call_accept(roomId) answer(roomId, sdpAnswer) answer(roomId, sdpAnswer) offer(roomId, sdpOffer) offer(roomId, sdpOffer) ice-candidate(roomId, candidate) ice-candidate(roomId, candidate) ice-candidate(roomId, candidate) ice-candidate(roomId, candidate) loop [交换 ICE Candidate] P2P 连接建立 call_reject(roomId) call_reject(roomId) cleanupCall(roomId) alt [医生接受] [医生拒绝] 患者 (P1) 信令服务器 医生 (D1)

五、Coturn 服务器搭建与 ICE 认证

5.1 为什么需要 Coturn?

WebRTC 使用 ICE (Interactive Connectivity Establishment) 框架来寻找最佳的网络路径。ICE 会收集多种类型的网络地址(称为 Candidate):

  • Host Candidate: 设备自身的内网IP和端口。
  • Server Reflexive Candidate (SRFLX): 通过 STUN 服务器获取的公网IP和端口。
  • Relayed Candidate (RELAY): 当 P2P 连接无法建立时,通过 TURN 服务器中继的媒体流。

STUN 服务器用于发现设备的公网地址,而 TURN 服务器则在 STUN 失败时,作为媒体流的中继服务器。Coturn 是一个开源的、功能强大的 STUN/TURN 服务器实现。

5.2 Coturn 服务器搭建

以下是在 Ubuntu 20.04 系统上搭建 Coturn 服务器的完整步骤。

5.2.1 安装 Coturn
bash 复制代码
# 更新系统包
sudo apt update
sudo apt upgrade -y

# 安装 coturn
sudo apt install coturn -y

# 设置开机自启
sudo systemctl enable coturn
5.2.2 配置 Coturn

编辑 Coturn 的主配置文件 /etc/turnserver.conf

bash 复制代码
sudo nano /etc/turnserver.conf

将以下配置复制到文件中,并根据你的服务器环境进行修改:

conf 复制代码
# Coturn 配置文件

# 外部 IP 地址 (你的服务器公网IP)
external-ip=YOUR_SERVER_PUBLIC_IP

# 监听端口
listening-port=3478
tls-listening-port=5349

# Realm (域名或标识符)
realm=your-domain.com

# 侦听所有接口
listening-ip=0.0.0.0

# 强制使用指定的 IP 作为服务器的 IP
# 通常设置为 external-ip
# 如果你的服务器有多个公网IP,可以在这里指定
# relay-ip=YOUR_SERVER_PUBLIC_IP

# 启用 STUN
stun-only=false

# 启用 TURN
# no-stun=true

# 传输协议
# 可以指定 udp, tcp, tls, dtls
# 通常建议都启用
# no-udp
# no-tcp
# no-tls
# no-dtls

# 转发协议
# 可以指定 udp, tcp, tls, dtls
# 通常建议都启用
# no-udp-relay
# no-tcp-relay
# no-tls-relay
# no-dtls-relay

# 证书文件路径 (用于 TLS/DTLS)
# cert=/etc/ssl/certs/turnserver.pem
# pkey=/etc/ssl/private/turnserver_pkey.pem

# 用户名和密码数据库
# 这里使用 long-term credential mechanism
# 用户名和密码将通过 TURN REST API 在应用层面生成
# 数据库文件路径
userdb=/var/lib/turn/turndb

# 日志文件
log-file=/var/log/turnserver.log
# 日志级别
# 0: DEBUG, 1: INFO, 2: WARNING, 3: ERROR
verbose
# log-level=1

# 限制每个用户的带宽 (kbps)
# total-quota=100000
# user-quota=100000

# 安全设置
# 禁用本地 IP 地址
# no-loopback-peers
# no-multicast-peers

# 允许来自任何域的 WebRTC 客户端
# 这在生产环境中可能需要更严格的限制
# web-admin-address=0.0.0.0
# web-admin-port=5766
# server-name=your-domain.com

# 为 REST API 设置共享密钥
# 这是实现动态凭证的关键
# shared-secret=your-shared-secret-here

# 禁用静态用户,强制使用 REST API (动态凭证)
# 你必须选择一种认证方式:
# 1. 静态用户: 在配置文件中定义 user=username:password
# 2. 动态凭证 (推荐): 使用 shared-secret 和 REST API

# 方法 1: 静态用户 (简单,但不安全,不推荐用于生产)
# user=static_user:static_password

# 方法 2: 动态凭证 (推荐)
# 注释掉或删除所有静态 user 行
# 确保 shared-secret 已设置
shared-secret=your-very-secure-shared-secret-here

# 设置监听端口的协议
# 这将强制 TURN 服务器在这些端口上监听指定的协议
# 例如,让 3478 只监听 UDP,5349 只监听 TLS
# 这有助于防火墙配置
# udp-port-range=49152-65535
# min-port=49152
# max-port=65535

重要参数说明:

  • external-ip: 必须设置为你的服务器公网 IP。
  • realm: 可以是你的域名,如 im.yourcompany.com
  • shared-secret: 一个非常安全的密钥,用于在应用层面生成临时凭证。务必修改为一个强密码
5.2.3 启动 Coturn 服务
bash 复制代码
# 重启 Coturn 服务以应用配置
sudo systemctl restart coturn

# 检查服务状态
sudo systemctl status coturn

# 查看日志
sudo tail -f /var/log/turnserver.log
5.2.4 防火墙配置

确保服务器的防火墙开放了必要的端口。

bash 复制代码
# 使用 ufw
sudo ufw allow 3478/udp
sudo ufw allow 3478/tcp
sudo ufw allow 5349/tcp  # TLS
sudo ufw allow 5349/udp  # DTLS (可选)
# Coturn 会动态分配中继端口,通常在 49152-65535 范围内
sudo ufw allow 49152:65535/udp
sudo ufw allow 49152:65535/tcp

# 重新加载防火墙
sudo ufw reload

5.3 ICE 服务器与凭证

WebRTC 客户端需要知道如何连接到 STUN/TURN 服务器。这通过 RTCPeerConnection 构造函数的 iceServers 配置项完成。

5.3.1 ICE 服务器配置
javascript 复制代码
const iceServers = [
  // 1. 公共 STUN 服务器 (免费,但不可靠)
  { urls: "stun:stun.l.google.com:19302" },
  { urls: "stun:global.stun.twilio.com:3478?transport=udp" },

  // 2. 自建 Coturn 服务器 (推荐)
  {
    urls: [
      "turn:your-domain.com:3478?transport=udp",
      "turn:your-domain.com:3478?transport=tcp",
      "turn:your-domain.com:5349?transport=tcp" // TLS
    ],
    username: "your-generated-username",
    credential: "your-generated-credential"
  }
];
5.3.2 动态凭证 (TURN REST API)

硬编码用户名和密码是不安全的。Coturn 支持通过共享密钥(shared-secret)动态生成临时凭证。

生成临时凭证的 Java 工具类:

java 复制代码
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.time.Instant;

public class TurnCredentialGenerator {

    private static final String SHARED_SECRET = "your-very-secure-shared-secret-here"; // 与 coturn.conf 一致

    /**
     * 生成用于 WebRTC ICE 的 TURN 临时凭证
     * @param userId 用户ID,用于标识
     * @param ttl 凭证有效期 (秒)
     * @return 包含 username 和 credential 的 Map
     */
    public static Map<String, String> generateTemporaryCredentials(String userId, int ttl) {
        long timestamp = Instant.now().getEpochSecond() + ttl; // 过期时间戳
        String username = timestamp + ":" + userId; // 格式: <expire-timestamp>:<username>

        try {
            // 使用 HMAC-SHA1 签名
            Mac mac = Mac.getInstance("HmacSHA1");
            SecretKeySpec keySpec = new SecretKeySpec(SHARED_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA1");
            mac.init(keySpec);
            byte[] digest = mac.doFinal(username.getBytes(StandardCharsets.UTF_8));

            // 将摘要进行 Base64 编码,得到 credential
            String credential = Base64.getEncoder().encodeToString(digest);

            Map<String, String> result = new HashMap<>();
            result.put("username", username);
            result.put("credential", credential);
            return result;
        } catch (Exception e) {
            throw new RuntimeException("生成 TURN 凭证失败", e);
        }
    }

    // 使用示例
    public static void main(String[] args) {
        Map<String, String> credentials = generateTemporaryCredentials("user123", 3600); // 1小时有效
        System.out.println("Username: " + credentials.get("username"));
        System.out.println("Credential: " + credentials.get("credential"));
    }
}

前端获取 ICE 服务器配置:

javascript 复制代码
// 在用户连接或进入聊天页面时,从你的后端API获取ICE配置
async function getIceServers() {
    const response = await fetch('/api/ice-servers'); // 你的Spring Boot API端点
    const data = await response.json();
    return data.iceServers;
}

// 在创建 RTCPeerConnection 前调用
const iceServers = await getIceServers();
const peerConnection = new RTCPeerConnection({ iceServers });

Spring Boot Controller 示例:

java 复制代码
@RestController
@RequestMapping("/api")
public class IceServerController {

    @GetMapping("/ice-servers")
    public ResponseEntity<Map<String, Object>> getIceServers(@RequestParam String userId) {
        Map<String, Object> response = new HashMap<>();
        try {
            // 生成临时凭证
            Map<String, String> credentials = TurnCredentialGenerator.generateTemporaryCredentials(userId, 3600);

            List<Map<String, Object>> iceServers = new ArrayList<>();
            // 添加公共 STUN
            iceServers.add(Map.of("urls", List.of("stun:stun.l.google.com:19302")));
            // 添加自建 TURN
            Map<String, Object> turnServer = new HashMap<>();
            turnServer.put("urls", List.of(
                "turn:your-domain.com:3478?transport=udp",
                "turn:your-domain.com:3478?transport=tcp"
            ));
            turnServer.put("username", credentials.get("username"));
            turnServer.put("credential", credentials.get("credential"));
            iceServers.add(turnServer);

            response.put("iceServers", iceServers);
            return ResponseEntity.ok(response);
        } catch (Exception e) {
            log.error("获取ICE服务器配置失败", e);
            response.put("error", "Internal Server Error");
            return ResponseEntity.status(500).body(response);
        }
    }
}

5.4 集成与测试

  1. 前端集成 :确保前端在创建 RTCPeerConnection 时,使用从后端获取的、包含动态凭证的 iceServers 配置。
  2. 信令流程WebRTCService 负责交换 SDP 和 ICE Candidate。当 RTCPeerConnection 收集到 Candidate 时,会触发 icecandidate 事件,前端通过 socket.emit('ice-candidate', ...) 发送给服务端,服务端再转发给对方。
  3. 测试
    • 使用 chrome://webrtc-internals 查看 ICE Candidate 收集情况。
    • 如果看到 relay 类型的 Candidate,说明 TURN 服务器正在工作。
    • 模拟网络问题(如关闭一方的 WiFi),观察通话是否能通过 TURN 中继恢复。

六、总结

至此,构建了一个功能完整、生产可用的实时通讯系统。

系统核心组件:

  1. 信令服务器 (WebRTCService): 基于 Netty-socketio,处理通话的建立、协商和结束。
  2. 消息服务器 (ChatService): 处理文本消息的收发、状态同步和用户在线管理。
  3. STUN/TURN 服务器 (Coturn): 解决 NAT 穿透问题,确保全球范围内的连接成功率。
  4. 持久化层 (MongoDB/Redis): 存储消息、联系人和在线状态。

优势与最佳实践:

  • 高可用:信令和 TURN 服务可以独立部署和扩展。
  • 安全性:使用动态凭证避免了密钥硬编码。
  • 可维护性:代码结构清晰,信令与业务逻辑分离。

注意事项

  • 生产环境需考虑集群部署,使用 Redis 存储 callSessions 以实现多节点共享。
  • 增加更完善的安全认证(如 JWT)。
  • 对 SDP 和 ICE 数据进行更严格的校验。

希望本文能为你的实时通讯项目提供有价值的参考!

相关推荐
꧁༺摩༒西༻꧂1 小时前
Spring Boot Actuator 监控功能的简介及禁用
java·数据库·spring boot
王者鳜錸2 小时前
VUE+SPRINGBOOT从0-1打造前后端-前后台系统-文章详情、评论、点赞
前端·vue.js·spring boot
熊猫片沃子4 小时前
浅谈SpringBoot框架的优势
java·spring boot·后端
Asu52025 小时前
思途spring学习0807
java·开发语言·spring boot·学习
埃泽漫笔5 小时前
什么是SpringBoot
java·spring boot
曹瑞曹瑞5 小时前
itextPdf获取pdf文件宽高不准确
spring boot·pdf
码银6 小时前
什么是逻辑外键?我们要怎么实现逻辑外键?
java·数据库·spring boot
尘心不灭7 小时前
Spring Boot 项目代码笔记
spring boot·笔记·后端
你喜欢喝可乐吗?8 小时前
微信小程序与后台管理系统开发全流程指南
spring boot·微信小程序·vue