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

发布于:2025-08-10 ⋅ 阅读:(13) ⋅ 点赞:(0)

基于 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 关键代码解析
// 生成唯一的聊天室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 关键代码解析
// 处理通话请求
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(),清理该用户作为发起者的所有通话。
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) 发送一条文本消息。

<!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
# 更新系统包
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

sudo nano /etc/turnserver.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 服务
# 重启 Coturn 服务以应用配置
sudo systemctl restart coturn

# 检查服务状态
sudo systemctl status coturn

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

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

# 使用 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 服务器配置
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 工具类:

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 服务器配置:

// 在用户连接或进入聊天页面时,从你的后端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 示例:

@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 数据进行更严格的校验。

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


网站公告

今日签到

点亮在社区的每一天
去签到