WebRTC 实现无插件多端视频通话
基于 WebRTC 技术实现无插件多端视频通话的方案。主要内容包括:搭建基于 SpringBoot 和 WebSocket 的后端信令服务器,处理 SDP 交换、ICE 候选转发及通话排队逻辑;编写前端 HTML5 页面,利用 jQuery 和原生 WebRTC API 实现音视频采集、P2P 连接建立及 UI 交互;配置浏览器以支持内网 IP 访问摄像头权限。方案支持一对一呼叫及多对一排队呼叫功能,具备完善的连接状态管理和异常处理机制。

基于 WebRTC 技术实现无插件多端视频通话的方案。主要内容包括:搭建基于 SpringBoot 和 WebSocket 的后端信令服务器,处理 SDP 交换、ICE 候选转发及通话排队逻辑;编写前端 HTML5 页面,利用 jQuery 和原生 WebRTC API 实现音视频采集、P2P 连接建立及 UI 交互;配置浏览器以支持内网 IP 访问摄像头权限。方案支持一对一呼叫及多对一排队呼叫功能,具备完善的连接状态管理和异常处理机制。

WebRTC 负责浏览器间直接的音视频数据传输,HTML 负责前端音视频的采集和展示,信令服务器则是'牵线搭桥'的角色,解决 WebRTC 无法直接交换连接信息的问题。本文以实现网页端之间的视频通话为主,安卓端需要自行开发测试,原理是相通的。
| 概念 | 作用 |
|---|---|
| WebRTC | 浏览器原生的实时通信 API,让两个浏览器(端)直接建立 P2P 连接,实现无插件传输音视频/数据 |
| RTCPeerConnection | WebRTC 核心对象,负责管理 P2P 连接、处理音视频数据传输、收集 ICE 候选 |
| SDP | 描述音视频编码格式、网络信息等会话规则 |
| ICE | 解决 NAT/防火墙穿透问题,生成可访问的网络地址(ICE 候选),让不同内网的设备能找到彼此 |
| HTML | 通过 video 标签展示音视频流,配合 JavaScript 调用 WebRTC API 完成采集、连接等逻辑 |
| WebSocket | 实现浏览器与信令服务器之间的双向实时通信 |
| 信令服务器 | 负责交换连接参数(如 SDP、ICE 候选)和通话的处理结果 |
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
/**
* 呼叫排队
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CallWaitItem {
private String fromUserId; // 呼叫方 ID
private String toUserId; // 被叫方 ID
private String offerData; // 缓存的 Offer 数据
private long queueTime; // 排队时间戳(毫秒)
private int queueIndex; // 队列位置
}
/**
* WebRTC 信令消息
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true) // 忽略未知字段
public class WebrtcMessage {
private String type; // 消息类型(对应 WebrtcMessageType)
private String fromUserId; // 发送方 ID
private String toUserId; // 接收方 ID
private String data; // 核心数据(SDP/ICE/提示信息等)
}
/**
* WebRTC 消息类型枚举
*/
@Getter
public enum WebrtcMessageType {
OFFER("offer"), // 发起呼叫
ANSWER("answer"), // 接听呼叫
ICE_CANDIDATE("iceCandidate"), // 网络候选信息
LEAVE("leave"), // 主动挂断/取消呼叫
REJECT(),
ERROR(),
PING(),
QUEUE_UPDATE(),
QUEUE_TIMEOUT(),
OFFLINE_NOTIFY();
String type;
WebrtcMessageType(String type) {
.type = type;
}
WebrtcMessageType {
(WebrtcMessageType messageType : values()) {
(messageType.getType().equals(type)) {
messageType;
}
}
;
}
}
/**
* WebRTC 信令消息处理器
*/
@Slf4j
@Component
public class WebrtcSignalingHandler extends TextWebSocketHandler {
// ========== 全局核心映射 ==========
private static final Map<String, WebSocketSession> ONLINE_SESSIONS = new ConcurrentHashMap<>(); // 在线用户会话映射
private static final Map<String, String> CALL_BIND_MAP = new ConcurrentHashMap<>(); // 通话绑定映射
private static final Map<String, List<CallWaitItem>> CALL_WAIT_QUEUE_MAP = new ConcurrentHashMap<>(); // 通话排队队列映射
// ========== 可配置常量 ==========
private static final long QUEUE_TIMEOUT_MS = 5 * 60 * 1000; // 排队超时时间(毫秒)
private static final boolean ENABLE_QUEUE = true; // 排队功能开关
private static final long PING_TIMEOUT_MS = ;
();
Exception {
getUserIdFromSession(session);
(userId == || userId.trim().isEmpty()) {
session.close(CloseStatus.BAD_DATA.withReason());
log.warn(, session.getUri(), session.getId());
;
}
(ONLINE_SESSIONS.containsKey(userId)) {
ONLINE_SESSIONS.get(userId);
(oldSession != && oldSession.isOpen()) {
oldSession.close(CloseStatus.POLICY_VIOLATION.withReason());
log.info(, userId, oldSession.getId());
}
ONLINE_SESSIONS.remove(userId);
}
ONLINE_SESSIONS.put(userId, session);
session.getAttributes().put(, userId);
log.info(, userId, session.getId(), ONLINE_SESSIONS.size());
}
Exception {
(String) session.getAttributes().get();
(userId == ) {
log.warn(, session.getId(), status);
;
}
cleanUserRelatedData(userId, );
log.info(, userId, session.getId(), ONLINE_SESSIONS.size(), status);
}
Exception {
(String) session.getAttributes().get();
(userId != ) {
cleanUserRelatedData(userId, );
log.error(, userId, session.getId(), ONLINE_SESSIONS.size(), exception);
} {
log.warn(, session.getId(), exception);
}
(session.isOpen()) {
session.close(CloseStatus.SERVER_ERROR.withReason());
}
}
Exception {
(String) session.getAttributes().get();
(fromUserId == ) {
log.warn(, session.getId());
;
}
message.getPayload();
log.info(, fromUserId, session.getId(), rawMessage);
{
OBJECT_MAPPER.readValue(rawMessage, WebrtcMessage.class);
webrtcMessage.setFromUserId(fromUserId);
webrtcMessage.getToUserId();
(toUserId == || toUserId.trim().isEmpty()) {
sendErrorMsg(session, );
;
}
(fromUserId.equals(toUserId)) {
sendErrorMsg(session, );
;
}
WebrtcMessageType.getByType(webrtcMessage.getType());
(msgType == ) {
sendErrorMsg(session, + webrtcMessage.getType());
;
}
(msgType) {
OFFER:
handleOfferMessage(session, webrtcMessage, fromUserId, toUserId);
;
ANSWER:
handleAnswerMessage(webrtcMessage, fromUserId, toUserId);
;
REJECT:
handleRejectMessage(webrtcMessage, fromUserId, toUserId);
;
LEAVE:
handleLeaveMessage(webrtcMessage, fromUserId, toUserId);
;
ICE_CANDIDATE:
handleIceCandidateMessage(webrtcMessage, fromUserId, toUserId);
;
PING:
;
:
sendErrorMsg(session, + msgType.getType());
}
} (Exception e) {
sendErrorMsg(session, );
log.error(, fromUserId, session.getId(), rawMessage, e);
}
}
Exception {
log.info(, fromUserId, toUserId);
(!isUserOnline(toUserId)) {
log.warn(, toUserId, fromUserId);
sendErrorMsg(session, + toUserId + );
;
}
(isUserBusy(toUserId)) {
(!ENABLE_QUEUE) {
log.warn(, toUserId, fromUserId);
sendErrorMsg(session, + toUserId + );
;
}
log.info(, fromUserId, toUserId);
List<CallWaitItem> waitQueue = CALL_WAIT_QUEUE_MAP.getOrDefault(toUserId, <>());
waitQueue.stream().anyMatch(item -> item.getFromUserId().equals(fromUserId));
(isDuplicate) {
waitQueue.stream()
.filter(item -> item.getFromUserId().equals(fromUserId))
.findFirst()
.orElse();
(existItem != ) {
sendQueueUpdateMsg(getUserSession(fromUserId), toUserId, existItem.getQueueIndex(), waitQueue.size());
log.debug(, fromUserId, toUserId, existItem.getQueueIndex());
}
;
}
();
waitItem.setFromUserId(fromUserId);
waitItem.setToUserId(toUserId);
waitItem.setOfferData(message.getData());
waitItem.setQueueTime(System.currentTimeMillis());
waitItem.setQueueIndex(waitQueue.size() + );
waitQueue.add(waitItem);
CALL_WAIT_QUEUE_MAP.put(toUserId, waitQueue);
sendQueueUpdateMsg(session, toUserId, waitItem.getQueueIndex(), waitQueue.size());
log.info(, fromUserId, toUserId, waitItem.getQueueIndex(), waitQueue.size());
;
}
CALL_BIND_MAP.put(toUserId, fromUserId);
forwardMessage(message, toUserId);
log.info(, fromUserId, toUserId);
}
Exception {
log.info(, answererId, callerId);
(!isUserOnline(callerId) || !isUserOnline(answererId)) {
log.warn(, answererId, callerId);
sendErrorMsg(getUserSession(answererId), );
;
}
CALL_BIND_MAP.put(callerId, answererId);
CALL_BIND_MAP.put(answererId, callerId);
forwardMessage(message, callerId);
log.info(, answererId, callerId);
}
Exception {
log.info(, rejecterId, callerId);
getUserSession(callerId);
(callerSession != && callerSession.isOpen()) {
message.setData( + rejecterId + );
forwardMessage(message, callerId);
log.debug(, rejecterId, callerId);
}
cleanCallBind(rejecterId, callerId);
processNextWaitItem(rejecterId);
log.info(, rejecterId, callerId);
}
Exception {
log.info(, operatorId, targetId);
(.equals(message.getData())) {
(isUserOnline(targetId)) {
message.setData( + operatorId + );
forwardMessage(message, targetId);
log.debug(, operatorId, targetId);
}
(List<CallWaitItem> waitQueue : CALL_WAIT_QUEUE_MAP.values()) {
waitQueue.removeIf(item -> item.getFromUserId().equals(operatorId));
}
List<CallWaitItem> updatedWaitQueue = CALL_WAIT_QUEUE_MAP.getOrDefault(targetId, <>());
(operatorId.equals(CALL_BIND_MAP.get(targetId))) {
cleanCallBind(operatorId, targetId);
}
(CALL_BIND_MAP.get(targetId) == ) {
(updatedWaitQueue.size() > ) {
updatedWaitQueue.remove();
{
();
offerMsg.setType(WebrtcMessageType.OFFER.getType());
offerMsg.setFromUserId(nextItem.getFromUserId());
offerMsg.setToUserId(targetId);
offerMsg.setData(nextItem.getOfferData());
CALL_BIND_MAP.put(targetId, nextItem.getFromUserId());
forwardMessage(offerMsg, targetId);
updateWaitQueueIndex(updatedWaitQueue, targetId);
log.info(, nextItem.getFromUserId(), targetId, updatedWaitQueue.size());
} (Exception e) {
log.error(, targetId, nextItem, e);
processNextWaitItem(targetId);
}
}
} {
updateWaitQueueIndex(updatedWaitQueue, targetId);
}
log.info(, operatorId, targetId);
} {
(isUserOnline(targetId)) {
message.setData( + operatorId + );
forwardMessage(message, targetId);
log.debug(, operatorId, targetId);
}
cleanCallBind(operatorId, targetId);
processNextWaitItem(operatorId);
processNextWaitItem(targetId);
log.info(, operatorId, targetId);
}
}
Exception {
log.debug(, fromUserId, toUserId);
(isUserOnline(toUserId)) {
forwardMessage(message, toUserId);
log.info(, fromUserId, toUserId);
} {
log.warn(, toUserId, fromUserId);
sendErrorMsg(getUserSession(fromUserId), );
}
}
{
(!ENABLE_QUEUE) {
CALL_WAIT_QUEUE_MAP.remove(toUserId);
log.debug(, toUserId);
;
}
List<CallWaitItem> waitQueue = CALL_WAIT_QUEUE_MAP.getOrDefault(toUserId, <>());
(waitQueue.isEmpty()) {
CALL_WAIT_QUEUE_MAP.remove(toUserId);
log.debug(, toUserId);
;
}
waitQueue.remove();
{
();
offerMsg.setType(WebrtcMessageType.OFFER.getType());
offerMsg.setFromUserId(nextItem.getFromUserId());
offerMsg.setToUserId(toUserId);
offerMsg.setData(nextItem.getOfferData());
CALL_BIND_MAP.put(toUserId, nextItem.getFromUserId());
forwardMessage(offerMsg, toUserId);
updateWaitQueueIndex(waitQueue, toUserId);
log.info(, nextItem.getFromUserId(), toUserId, waitQueue.size());
} (Exception e) {
log.error(, toUserId, nextItem, e);
processNextWaitItem(toUserId);
}
(waitQueue.isEmpty()) {
CALL_WAIT_QUEUE_MAP.remove(toUserId);
} {
CALL_WAIT_QUEUE_MAP.put(toUserId, waitQueue);
}
}
{
log.debug(, toUserId, waitQueue.size());
( ; i < waitQueue.size(); i++) {
waitQueue.get(i);
item.setQueueIndex(i + );
{
sendQueueUpdateMsg(getUserSession(item.getFromUserId()), toUserId, item.getQueueIndex(), waitQueue.size());
log.debug(, item.getFromUserId(), item.getQueueIndex(), waitQueue.size());
} (Exception e) {
log.error(, item.getFromUserId(), e);
}
}
}
{
ONLINE_SESSIONS.get(userId);
session != && session.isOpen();
log.debug(, userId, isOnline);
isOnline;
}
{
CALL_BIND_MAP.containsKey(userId);
log.debug(, userId, isBusy);
isBusy;
}
Exception {
ONLINE_SESSIONS.get(targetId);
(targetSession != && targetSession.isOpen()) {
OBJECT_MAPPER.writeValueAsString(message);
targetSession.sendMessage( (msgJson));
log.debug(, message.getType(), targetId, msgJson);
;
}
log.warn(, targetId);
}
String {
{
session.getUri().getQuery();
(query == || query.isEmpty()) {
log.warn(, session.getId());
;
}
(String param : query.split()) {
String[] keyValue = param.split(, );
(keyValue.length == && .equals(keyValue[])) {
keyValue[];
log.debug(, session.getId(), userId);
userId;
}
}
log.warn(, session.getId(), query);
;
} (Exception e) {
log.error(, session.getId(), e);
;
}
}
WebSocketSession {
ONLINE_SESSIONS.getOrDefault(userId, );
log.debug(, userId, session != );
session;
}
Exception {
(session == || !session.isOpen()) {
log.warn(, errorMsg);
;
}
();
errorMessage.setType(WebrtcMessageType.ERROR.getType());
errorMessage.setData(errorMsg);
OBJECT_MAPPER.writeValueAsString(errorMessage);
session.sendMessage( (msgJson));
log.debug(, session.getId(), errorMsg);
}
Exception {
(session == || !session.isOpen()) {
log.warn(, toUserId, queueIndex);
;
}
();
queueMsg.setType(WebrtcMessageType.QUEUE_UPDATE.getType());
queueMsg.setToUserId(toUserId);
String.format(, queueIndex, totalCount);
queueMsg.setData(queueData);
OBJECT_MAPPER.writeValueAsString(queueMsg);
session.sendMessage( (msgJson));
log.debug(, session.getId(), toUserId, queueIndex, totalCount);
}
Exception {
(session == || !session.isOpen()) {
log.warn();
;
}
();
timeoutMsg.setType(WebrtcMessageType.QUEUE_TIMEOUT.getType());
timeoutMsg.setData();
OBJECT_MAPPER.writeValueAsString(timeoutMsg);
session.sendMessage( (msgJson));
log.debug(, session.getId());
}
Exception {
();
offlineMsg.setType(WebrtcMessageType.OFFLINE_NOTIFY.getType());
offlineMsg.setData( + offlineUserId + );
forwardMessage(offlineMsg, targetId);
log.debug(, targetId, offlineUserId);
}
{
CALL_BIND_MAP.remove(userId1);
CALL_BIND_MAP.remove(userId2);
log.debug(, userId1, userId2);
}
Exception {
log.info(, userId, notifyPartner);
ONLINE_SESSIONS.remove(userId);
CALL_BIND_MAP.get(userId);
(callPartnerId != && notifyPartner) {
sendOfflineNotifyMsg(callPartnerId, userId);
cleanCallBind(userId, callPartnerId);
processNextWaitItem(callPartnerId);
}
CALL_WAIT_QUEUE_MAP.remove(userId);
(List<CallWaitItem> waitQueue : CALL_WAIT_QUEUE_MAP.values()) {
waitQueue.removeIf(item -> item.getFromUserId().equals(userId));
}
log.info(, userId);
}
{
(!ENABLE_QUEUE) {
;
}
System.currentTimeMillis();
(Map.Entry<String, List<CallWaitItem>> entry : CALL_WAIT_QUEUE_MAP.entrySet()) {
entry.getKey();
List<CallWaitItem> waitQueue = entry.getValue();
List<CallWaitItem> expiredItems = waitQueue.stream()
.filter(item -> (currentTime - item.getQueueTime()) > QUEUE_TIMEOUT_MS)
.collect(Collectors.toList());
(CallWaitItem expiredItem : expiredItems) {
waitQueue.remove(expiredItem);
{
sendQueueTimeoutMsg(getUserSession(expiredItem.getFromUserId()));
log.debug(, currentTime, CALL_WAIT_QUEUE_MAP.size());
} (Exception e) {
log.error(, expiredItem.getFromUserId(), e);
}
log.info(, expiredItem.getFromUserId(), toUserId);
}
updateWaitQueueIndex(waitQueue, toUserId);
(waitQueue.isEmpty()) {
CALL_WAIT_QUEUE_MAP.remove(toUserId);
}
}
}
{
(String userId : ONLINE_SESSIONS.keySet()) {
ONLINE_SESSIONS.get(userId);
(session == || !session.isOpen()) {
{
cleanUserRelatedData(userId, );
} (Exception e) {
log.error(, userId, e);
}
log.info(, userId);
} {
sendPingWithTimeout(session);
(!isSessionValid) {
log.info(String.format(, userId));
ONLINE_SESSIONS.remove(userId);
(session.isOpen()) {
{
session.close(CloseStatus.GOING_AWAY.withReason());
} (Exception ex) {
log.error(, userId, ex);
}
}
}
}
}
}
{
FutureTask<Boolean> pingTask = <>(() -> {
{
session.sendMessage( ());
;
} (Exception e) {
;
}
});
(pingTask, );
pingThread.start();
{
pingTask.get(PING_TIMEOUT_MS, TimeUnit.MILLISECONDS);
} (TimeoutException e) {
pingTask.cancel();
;
} (InterruptedException | ExecutionException e) {
log.warn(String.format(, e.getMessage()));
;
}
}
}
/**
* WebSocket 配置类
*/
@Configuration
@EnableWebSocket
@EnableScheduling
public class WebSocketConfig implements WebSocketConfigurer {
@Resource
private WebrtcSignalingHandler webrtcSignalingHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
webSocketHandlerRegistry
.addHandler(webrtcSignalingHandler, "/webrtc") // 绑定处理器和访问端点
.setAllowedOrigins("*"); // 允许跨域
}
/**
* 定时任务线程池
* @return
*/
@Bean(name = "customWebSocketTaskScheduler")
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5); // 5 个线程处理定时任务
scheduler.setThreadNamePrefix("websocket-scheduler-"); // 线程名前缀
scheduler.setAwaitTerminationSeconds(10); // 任务关闭时等待秒数
scheduler.setWaitForTasksToCompleteOnShutdown(true); // 关闭时等待任务完成
return scheduler;
}
}
@SpringBootApplication
@EnableScheduling
public class WebrtcServerApplication {
private static Logger log = LoggerFactory.getLogger(WebrtcServerApplication.class);
public static void main(String[] args) {
SpringApplication.run(WebrtcServerApplication.class, args);
log.info("========================================================================");
log.info("=========================WebRTC 信令服务器启动成功==========================");
log.info("--------WebSocket 访问端点:ws://[IP]:[Port]/webrtc?userId=[用户 ID]");
log.info("========================================================================");
}
}
使用 Apipost 或其他工具创建 WebSocket 连接,连接地址(根据实际配置填写):ws://[IP]:[Port]/webrtc?userId=[用户 ID]

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>WebRTC 视频对讲</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Microsoft YaHei", sans-serif; }
.container { width: 90%; max-width: 1200px; margin: 20px auto; }
.input-area, .queue-area, .call-area { margin-bottom: 20px; padding: 15px; border: 1px solid #eee; border-radius: 8px; }
.queue-area, .call-area { display: none; }
.video-area { display: flex; gap: 20px; flex-wrap: wrap; }
.video-box { flex: 1; min-width: ; }
{ : ; : ; : solid ; : ; : ; }
{ : ; : ; : none; : ; : pointer; : ; }
{ : ; : white; }
, { : ; : white; }
{ : ; : white; }
{ : ; : ; : solid ; : ; : ; }
, { : ; : ; : ; }
{ : ; : bold; }
基础配置
自身用户 ID:
初始化连接
要呼叫的用户 ID:
呼叫
挂断/取消呼叫
提示:先输入自身 ID 并初始化连接,再输入对方 ID 进行呼叫
排队状态
你正在呼叫
当前排队位置:第 0 位
队列总人数:0 人
提示:排队超时 5 分钟将自动退出,被叫方空闲后将自动为你转发请求
来电提醒
来自 的呼叫
接听
拒接
本地视频(静音)
远程视频
浏览器获取本地摄像头/麦克风需要有限制,地址需要满足任意一项:localhost、127.0.0.1、https,内网环境下想通过 IP 来访问需要配置 unsafely-treat-insecure-origin-as-secure
谷歌 / 360:chrome://flags/#unsafely-treat-insecure-origin-as-secure
Edge:edge://flags/#unsafely-treat-insecure-origin-as-secure



地址栏输入【about:config】

创建了 4 个浏览器分别登录不同的 id,用来模拟一对一呼叫和多对一呼叫的情况

2 >>> 1


2 >>> 1,3 >>> 1,4 >>> 1





微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online