webRTC入门

是什么

这是一个视频传输协议,实现浏览器 / 应用间的低延迟点对点(P2P)实时音视频通信,无需插件,适用于视频会议、在线聊天等互动场景。

怎么玩

a b两个客户端,可以是本机浏览器上的两个页面、也可以是不同网络下的两台机器;信令服务器 需要自己开发,两个客户端都能访问到。
通常,a,b通过websocket与信令服务器建立链接,交换彼此支持的视频信息SDP(如:编解码器、分辨率等),同时通过ICE协议,在复杂网络环境(如家用路由器后的设备)中找到双方可达的网络路径(生成 ICE 候选者)。
这里有两点需要解释一下,信令服务器更多是转发客户端信息,可能是一个客户端要发给某个客户端的信息,也可能是一个广播信息;ICE可以理解为是两个客户端互相通信的ip:port地址。
拿到ICE候选者之后,两个客户端就可以建立P2P连接了。webRTC的offer、answer消息交换完成之后,开始传输视频流。如果有多个客户端,那么每2个客户端都要建立P2P连接,房间信息也是保存在信令服务器中,同时客户端的加入/退出消息,也是由信令服务器转发给所有客户端。而客户端需要做的就是根据信令服务器传递的控制信息,增加/移除P2P连接,渲染视频流数据。

一些概念

信令(Signaling)的作用,在 P2P 连接建立前,双方需要交换两类关键信息:

  • SDP:描述本地媒体能力(如支持的编解码器、分辨率等)。
  • ICE 候选者:描述本地网络地址(如公网 IP、内网 IP 等),用于找到彼此的网络路径。

webRTC-js核心API,RTCPeerConnection,专门负责点对点(P2P)实时媒体流(音视频、数据)的传输管理,它是 WebRTC 实现低延迟通信的关键。其主要功能包括:

  • NAT 穿透:通过 ICE(Interactive Connectivity Establishment)协议,在复杂网络环境(如家用路由器后的设备)中找到双方可达的网络路径(生成 ICE 候选者)。
  • 媒体协商:通过交换 SDP(Session Description Protocol)描述,协商音视频的编解码器、传输格式等参数。
  • 数据传输:最终的音视频数据通过 UDP 协议 传输(因为 UDP 延迟低,适合实时场景),同时通过 RTCP(RTP Control Protocol)监控传输质量(如丢包率、抖动),动态调整传输策略。

ICE的工作流程

  1. 为什么需要ICE
    互联网中,大多数设备并非直接暴露在公网中,而是通过NAT(网络地址转换) 设备(如家用路由器)接入公网。NAT 会隐藏设备的私有 IP 地址,对外只展示路由器的公网 IP,这导致直接的 P2P 连接难以建立(设备不知道彼此的实际网络地址)。
    ICE 的作用就是:在 NAT 环境中,通过一系列机制找到两个设备都能访问的网络路径,最终建立 P2P 连接。
  2. ICE 的核心组成
  • STUN 服务器(Session Traversal Utilities for NAT)作用,帮助设备发现自己在公网中的 “映射地址”(即 NAT 分配的公网 IP 和端口)。工作流程:设备向 STUN 服务器发送请求,STUN 服务器返回该设备经过 NAT 转换后的公网地址(称为 “服务器自反地址”),设备可将此地址作为自己的 “候选连接地址”。
  • TURN 服务器(Traversal Using Relays around NAT)作用,当 P2P 连接失败时(如严格型 NAT 阻止直接通信),作为 “中继服务器” 转发数据。工作流程:如果两个设备无法直接连接,TURN 服务器会成为中间转发节点,所有数据通过 TURN 服务器中转(此时不再是纯 P2P,而是客户端 - 服务器 - 客户端模式)。
  • ICE 候选者(ICE Candidates)设备可能的网络地址,包括:本地地址(私有 IP,如 192.168.x.x);服务器自反地址(通过 STUN 获得的公网映射地址);中继地址(通过 TURN 服务器获得的转发地址)。
  1. ICE 的工作流程
    收集候选者:设备通过 STUN 服务器获取自反地址,通过 TURN 服务器获取中继地址,再加上本地地址,形成一组 “ICE 候选者”。
    交换候选者:通过信令服务器(如之前 Demo 中的 WebSocket 服务器),两个设备互相交换各自的候选者列表。
    连通性检查:设备对彼此的候选者进行两两配对(如 A 的候选者 1 与 B 的候选者 1、A 的候选者 1 与 B 的候选者 2 等),通过发送 “绑定请求” 测试是否能建立连接。
    选择最优路径:ICE 会优先选择延迟最低的直接连接(如本地地址或自反地址),只有当所有直接连接失败时,才会使用 TURN 中继。

其他架构方案

在 WebRTC 多人通信场景中(如 3 人以上视频会议),纯 P2P 架构会面临带宽爆炸问题:每个设备需要向其他所有设备发送自己的视频流(例如 5 人会议,每个设备需上传 4 路流、下载 4 路流),带宽消耗随人数呈指数增长。解决多人通信的 “带宽爆炸” 问题,其中 MCU 适合高人数、低客户端性能场景,SFU 适合中低人数、低延迟场景。

  1. MCU(Multipoint Control Unit,多点控制单元)
    所有客户端将自己的音视频流发送到 MCU;MCU 对多路视频流进行 “混合”(如拼接成网格布局),对多路音频流进行 “混音”(只保留当前说话人的声音);MCU 将混合后的单路音视频流发送给每个客户端。
  2. SFU(Selective Forwarding Unit,选择性转发单元)
    所有客户端将自己的音视频流发送到 SFU;SFU 不混合流,而是根据每个客户端的需求(如 “想看谁的视频”),将对应的原始流转发给该客户端;客户端接收多路流后,在本地进行布局渲染(如网格排列)。

优化

  1. 网络适应性优化(最关键)
    动态调整码率(Adaptive Bitrate),优化分辨率和帧率,使用更高效的编码格式
  2. 网络连接优化
    优化 ICE 穿透,减少网络抖动和丢包
  3. 架构优化(多人场景)
    使用 SFU 替代纯 P2P,区域部署服务器
  4. 设备和性能优化
    启用硬件编解码,减少不必要的视频处理
  5. 其他细节优化
    优先保障音频流畅,快速重连机制,监控和告警

关键示例代码

信令服务器,维护房间状态,转发消息

{
	// 通知房间内已有用户新用户加入
	for id, existingClient := range room.clients {
		err := existingClient.conn.WriteJSON(map[string]interface{}{
			"type":     "user-joined",
			"userID":   userID,
			"username": username,
			"from":     id, // 添加发送者ID
		})
		if err != nil {
			log.Printf("通知用户 %s 新用户加入失败: %v", id, err)
		}
	}
	room.clients[userID] = client

	// 向新用户发送房间内已有用户列表
	var existingUsers []map[string]string
	for id, c := range room.clients {
		if id != userID { // 不包含自己
			existingUsers = append(existingUsers, map[string]string{
				"userID":   id,
				"username": c.username,
			})
		}
	}
	// 确保即使没有用户也发送空数组而不是nil
	err = client.conn.WriteJSON(map[string]interface{}{
		"type":         "existing-users",
		"users":        existingUsers,
		"yourUserID":   userID,
		"yourUsername": username,
	})
	if err != nil {
		log.Printf("向新用户 %s 发送用户列表失败: %v", userID, err)
	}

	room.mu.Unlock()

	log.Printf("用户 %s(%s) 加入房间 %s,当前房间人数: %d", username, userID, roomID, len(room.clients))

	// 处理消息
	for {
		var msg map[string]interface{}
		err := conn.ReadJSON(&msg)
		if err != nil {
			log.Printf("用户 %s 读取消息错误: %v", userID, err)
			break
		}

		// 添加消息发送者信息
		msg["from"] = userID

		// 将消息转发给目标用户或所有用户
		room.mu.Lock()
		if target, ok := msg["target"].(string); ok && target != "" {
			// 发送给指定用户
			if c, exists := room.clients[target]; exists {
				if err := c.conn.WriteJSON(msg); err != nil {
					log.Printf("向用户 %s 发送消息错误: %v", target, err)
				}
			}
		} else {
			// 广播给所有其他用户
			for id, c := range room.clients {
				if id != userID { // 不发送给自己
					if err := c.conn.WriteJSON(msg); err != nil {
						log.Printf("向用户 %s 发送消息错误: %v", id, err)
					}
				}
			}
		}
		room.mu.Unlock()
	}

	// 客户端断开连接后从房间移除
	room.mu.Lock()
	delete(room.clients, userID)
	// 通知其他用户该用户离开
	for id, c := range room.clients {
		c.conn.WriteJSON(map[string]interface{}{
			"type":   "user-left",
			"userID": userID,
			"from":   id,
		})
	}
	room.mu.Unlock()

	log.Printf("用户 %s 离开房间 %s,当前房间人数: %d", userID, roomID, len(room.clients))

	// 如果房间为空,删除房间
	room.mu.Lock()
	if len(room.clients) == 0 {
		roomsMu.Lock()
		delete(rooms, roomID)
		roomsMu.Unlock()
		log.Printf("房间 %s 已空,已删除", roomID)
	}
	room.mu.Unlock()
}

客户端监听信息做不同处理

ws.onmessage = handleSignalingMessage;

// 处理信令消息
async function handleSignalingMessage(event) {
    try {
        const message = JSON.parse(event.data);
        console.log('收到信令消息:', message);

        // 验证消息类型
        if (!message.type) {
            console.error('收到无效消息,缺少type字段:', message);
            return;
        }

        switch (message.type) {
            case 'existing-users':
                // 处理已存在的用户列表,添加空数组检查
                myUserID = message.yourUserID;
                myUsername = message.yourUsername;
                currentRoomElement.textContent = currentRoom;
                roomInfo.classList.remove('hidden');
                
                statusElement.innerHTML = `<i class="fa fa-check-circle text-green-500"></i> 已加入房间: ${currentRoom}`;
                
                // 为每个已存在的用户创建连接,确保users是数组
                const existingUsers = message.users || [];
                if (Array.isArray(existingUsers)) {
                    existingUsers.forEach(user => {
                        if (user && user.userID && user.username) {
                            addUser(user.userID, user.username);
                            createPeerConnection(user.userID, true); // 作为发起方创建连接
                        }
                    });
                } else {
                    console.warn('existing-users消息中的users不是数组:', message.users);
                }
                updateUserList();
                break;
                
            case 'user-joined':
                // 新用户加入,验证必要字段
                if (!message.userID || !message.username) {
                    console.error('收到无效的user-joined消息:', message);
                    return;
                }
                
                addUser(message.userID, message.username);
                createPeerConnection(message.userID, false); // 作为接收方创建连接
                updateUserList();
                showNotification(`${message.username} 加入了房间`);
                break;
                
            case 'user-left':
                // 用户离开,验证必要字段
                if (!message.userID) {
                    console.error('收到无效的user-left消息:', message);
                    return;
                }
                
                const username = users.get(message.userID) || '未知用户';
                removeUser(message.userID);
                updateUserList();
                showNotification(`${username} 离开了房间`);
                break;
                
            case 'offer':
                // 收到offer,验证必要字段
                if (!message.from || !message.sdp) {
                    console.error('收到无效的offer消息:', message);
                    return;
                }
                
                await handleOffer(message);
                break;
                
            case 'answer':
                // 收到answer,验证必要字段
                if (!message.from || !message.sdp) {
                    console.error('收到无效的answer消息:', message);
                    return;
                }
                
                await handleAnswer(message);
                break;
                
            case 'candidate':
                // 收到ICE候选者,验证必要字段
                if (!message.from || !message.candidate) {
                    console.error('收到无效的candidate消息:', message);
                    return;
                }
                
                await handleCandidate(message);
                break;
                
            case 'media-type-changed':
                // 处理媒体类型变更(摄像头/屏幕共享切换)
                if (!message.from || !message.mediaType) {
                    console.error('收到无效的media-type-changed消息:', message);
                    return;
                }
                
                const userId = message.from;
                const mediaType = message.mediaType;
                remoteMediaTypes.set(userId, mediaType);
                
                // 更新UI显示
                const videoContainer = document.getElementById(`video-${userId}`);
                if (videoContainer) {
                    const statusElement = videoContainer.querySelector('.video-status');
                    if (mediaType === 'screen') {
                        if (!statusElement) {
                            const newStatus = document.createElement('div');
                            newStatus.className = 'video-status';
                            newStatus.textContent = '屏幕共享';
                            videoContainer.appendChild(newStatus);
                        } else {
                            statusElement.textContent = '屏幕共享';
                        }
                    } else {
                        if (statusElement) {
                            statusElement.remove();
                        }
                    }
                }
                break;
                
            default:
                console.log('收到未知类型的消息:', message.type);
        }
    } catch (error) {
        console.error('处理信令消息错误:', error);
        showNotification(`发生错误: ${error.message}`, 'error');
    }
}

客户端建立P2P连接

// 创建对等连接
async function createPeerConnection(peerID, initiator = true) {
    // 如果已存在连接,则不重复创建
    if (peerConnections.has(peerID)) {
        return peerConnections.get(peerID);
    }

    console.log(`创建与 ${peerID} 的连接,initiator: ${initiator}`);
    
    // 创建新的RTCPeerConnection
    const pc = new RTCPeerConnection(configuration);
    peerConnections.set(peerID, pc);

    // 添加本地媒体流到连接
    if (localStream) {
        localStream.getTracks().forEach(track => {
            try {
                pc.addTrack(track, localStream);
                console.log(`已添加轨道到与 ${peerID} 的连接`);
            } catch (error) {
                console.error(`添加轨道到与 ${peerID} 的连接失败:`, error);
            }
        });
    }

    // 监听远程流
    pc.ontrack = (event) => {
        console.log('收到来自', peerID, '的远程流,轨道数量:', event.streams[0].getTracks().length);
        if (!remoteStreams.has(peerID)) {
            remoteStreams.set(peerID, event.streams[0]);
            const username = users.get(peerID) || '未知用户';
            const mediaType = remoteMediaTypes.get(peerID) || 'camera';
            createVideoElement(peerID, username, false, event.streams[0], mediaType);
        }
    };

    // 监听连接状态变化
    pc.onconnectionstatechange = () => {
        console.log(`与 ${peerID} 的连接状态:`, pc.connectionState);
        const username = users.get(peerID) || '未知用户';
        
        switch (pc.connectionState) {
            case 'connected':
                showNotification(`已与 ${username} 建立连接`);
                break;
            case 'disconnected':
                showNotification(`与 ${username} 的连接已断开`);
                break;
            case 'failed':
                showNotification(`与 ${username} 的连接失败,正在尝试重连...`);
                // 连接失败时尝试重新连接
                setTimeout(() => {
                    if (peerConnections.has(peerID)) {
                        peerConnections.delete(peerID);
                        createPeerConnection(peerID, initiator);
                    }
                }, 3000);
                break;
            case 'closed':
                console.log(`与 ${peerID} 的连接已关闭`);
                break;
        }
    };

    // 发送ICE候选者
    pc.onicecandidate = (event) => {
        if (event.candidate && ws && ws.readyState === WebSocket.OPEN) {
            try {
                ws.send(JSON.stringify({
                    type: 'candidate',
                    target: peerID,
                    candidate: event.candidate
                }));
                console.log('已向', peerID, '发送ICE候选者');
            } catch (error) {
                console.error('发送ICE候选者失败:', error);
            }
        }
    };

    // ICE连接状态变化
    pc.oniceconnectionstatechange = () => {
        console.log(`与 ${peerID} 的ICE连接状态:`, pc.iceConnectionState);
    };

    // 如果是发起者,创建并发送offer
    if (initiator) {
        try {
            const offer = await pc.createOffer();
            await pc.setLocalDescription(offer);
            ws.send(JSON.stringify({
                type: 'offer',
                target: peerID,
                sdp: offer
            }));
            console.log('已向', peerID, '发送offer');
        } catch (error) {
            console.error('创建offer失败:', error);
            showNotification(`创建连接失败: ${error.message}`, 'error');
        }
    }

    return pc;
}
posted @ 2025-09-21 17:54  柠檬水请加冰  阅读(15)  评论(0)    收藏  举报