基于 Socket.IO 实现 WebRTC 音视频通话与实时聊天系统(Spring Boot 后端实现)
技术栈:Spring Boot + Socket.IO (Netty-socketio) + WebRTC + Redis + MongoDB
一、引言
随着远程医疗、在线教育、即时通讯等应用的普及,实时音视频通话和文本聊天功能已成为现代 Web 应用的核心需求。本文将详细介绍如何使用 Java 的 Netty-socketio
框架,在 Spring Boot 项目中实现一个完整的 WebRTC 信令服务器 和 实时文本聊天系统。
我们将深入剖析两个核心服务类 WebRTCService
和 ChatService
,并提供完整的功能流程图和前端交互示例。
二、系统架构概览
本系统采用典型的 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 核心功能
-
用户连接与身份认证
- 客户端连接时,发送
connect_success
事件,携带userId
,role
(patient/doctor),institutionId
等信息。 - 服务端验证信息后,将用户加入对应的"通知房间"和"聊天房间"。
- 客户端连接时,发送
-
消息收发
- 客户端发送
send_msg
事件。 - 服务端将消息存入 MongoDB,并广播给房间内其他成员。
- 客户端发送
-
消息状态管理
- 支持
msg_delivered
(已送达) 和msg_read
(已读) 状态。 - 通过 Redis 维护未读消息计数。
- 支持
-
房间成员管理
- 实时获取和广播房间内的在线成员列表。
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 集成与测试
- 前端集成 :确保前端在创建
RTCPeerConnection
时,使用从后端获取的、包含动态凭证的iceServers
配置。 - 信令流程 :
WebRTCService
负责交换 SDP 和 ICE Candidate。当RTCPeerConnection
收集到 Candidate 时,会触发icecandidate
事件,前端通过socket.emit('ice-candidate', ...)
发送给服务端,服务端再转发给对方。 - 测试 :
- 使用
chrome://webrtc-internals
查看 ICE Candidate 收集情况。 - 如果看到
relay
类型的 Candidate,说明 TURN 服务器正在工作。 - 模拟网络问题(如关闭一方的 WiFi),观察通话是否能通过 TURN 中继恢复。
- 使用
六、总结
至此,构建了一个功能完整、生产可用的实时通讯系统。
系统核心组件:
- 信令服务器 (
WebRTCService
): 基于Netty-socketio
,处理通话的建立、协商和结束。 - 消息服务器 (
ChatService
): 处理文本消息的收发、状态同步和用户在线管理。 - STUN/TURN 服务器 (
Coturn
): 解决 NAT 穿透问题,确保全球范围内的连接成功率。 - 持久化层 (MongoDB/Redis): 存储消息、联系人和在线状态。
优势与最佳实践:
- 高可用:信令和 TURN 服务可以独立部署和扩展。
- 安全性:使用动态凭证避免了密钥硬编码。
- 可维护性:代码结构清晰,信令与业务逻辑分离。
注意事项:
- 生产环境需考虑集群部署,使用 Redis 存储
callSessions
以实现多节点共享。 - 增加更完善的安全认证(如 JWT)。
- 对 SDP 和 ICE 数据进行更严格的校验。
希望本文能为你的实时通讯项目提供有价值的参考!