随着远程办公和在线协作的普及,音视频通信的需求日益增长。无论是两点之间的通信还是多人会议,WebRTC(Web Real-Time Communication)作为一种开源技术,提供了低延迟的实时通信能力。
它允许浏览器或移动设备通过直接的点对点(P2P)连接进行音频、视频和数据的实时传输。它使得不依赖中间服务器的实时通信成为可能,尤其适用于视频聊天、文件共享、音频会议等场景。
在本文中,我们将深入介绍从两点之间的通信原理到底层协议,再到多人会议的实现。
在此之前,我们先来了解一下WebRTC的一些基本概念和关键组件。
一、 WebRTC 关键概念
GetUserMedia:用于从用户设备(如摄像头、麦克风)获取媒体流。
RTCPeerConnection:用于建立和维护对等连接,进行音视频流或数据的传输。
LocalDescription: 本地描述是指当前客户端的音视频连接配置,包括了媒体的编解码器、媒体的传输方式(RTP、SRTP)、ICE 候选者(用于 NAT 穿越的网络路径)等。同理,远程描述RemoteDescription
则是描述连接对方的信息。
信令服务器:用于交换 SDP(Session Description Protocol)和 ICE(Interactive Connectivity Establishment)候选者信息。信令服务器不处理实际的音视频数据,仅负责协调通信的初期协商。
SDP(会话描述协议):SDP 提供了媒体类型、编解码器、带宽要求等信息,描述了会话的具体配置。通过 SDP,两个客户端可以相互了解对方的通信能力并进行匹配。
ICE(交互式连接建立):ICE 帮助客户端穿越 NAT(网络地址转换),通过收集不同网络路径的候选者,找到可以建立 P2P 连接的最佳路径。
上述即两点通信的关键概念,有了这些基本概念,接下来我们了解一下他们建立的通信过程和实现原理。
二、 两点之间的通信流程
实现两点之间的 WebRTC 通信时,整个过程通常涉及几个主要步骤:
获取本地媒体流: 使用getUserMedia()
API 获取用户的音视频流。建立连接: 创建 RTCPeerConnection
实例,并配置 ICE 服务器。创建和交换 SDP: 使用 createOffer()
和 createAnswer()
生成 SDP 描述,并通过信令服务器交换 SDP 信息。ICE 候选者交换: 交换 ICE 候选者以建立最优的网络路径。建立点对点连接: 完成所有的 SDP 和 ICE 交换后,WebRTC 会自动建立点对点连接并开始数据传输。 核心代码示例
// Peer A 和 Peer B 都需要的 RTCPeerConnection 配置const peerConnectionConfig = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]};// 在 Peer A 中const peerAConnection = new RTCPeerConnection(peerConnectionConfig);// 获取本地媒体流navigator.mediaDevices.getUserMedia({ video: true, audio: true }) .then(stream => { stream.getTracks().forEach(track => peerAConnection.addTrack(track, stream)); });// 创建 OfferpeerAConnection.createOffer().then(offer => { return peerAConnection.setLocalDescription(offer);}).then(() => { // 通过信令服务器将 Offer 发送给 Peer B signalingServer.send({ offer: peerAConnection.localDescription });});// 处理 ICE 候选者peerAConnection.onicecandidate = (event) => { if (event.candidate) { signalingServer.send({ iceCandidate: event.candidate }); }};// 接收 Peer B 的 Answer 并设置远端描述signalingServer.onmessage = (message) => { if (message.answer) { peerAConnection.setRemoteDescription(new RTCSessionDescription(message.answer)); } else if (message.iceCandidate) { peerAConnection.addIceCandidate(new RTCIceCandidate(message.iceCandidate)); }};// 在 Peer B 中const peerBConnection = new RTCPeerConnection(peerConnectionConfig);// 接收 Peer A 的 Offer 并设置远端描述signalingServer.onmessage = (message) => { if (message.offer) { peerBConnection.setRemoteDescription(new RTCSessionDescription(message.offer)) .then(() => { return peerBConnection.createAnswer(); }) .then(answer => { return peerBConnection.setLocalDescription(answer); }) .then(() => { // 通过信令服务器将 Answer 发送给 Peer A signalingServer.send({ answer: peerBConnection.localDescription }); }); } else if (message.iceCandidate) { peerBConnection.addIceCandidate(new RTCIceCandidate(message.iceCandidate)); }};// 处理 ICE 候选者peerBConnection.onicecandidate = (event) => { if (event.candidate) { signalingServer.send({ iceCandidate: event.candidate }); }};
三、 多人会议
点对点通信的实现为多人会议的搭建奠定了基础。在多人会议中,除了需要协调多个客户端之间的音视频流,如何高效地管理带宽和资源也尤为重要。
多人会议的几种核心架构
在P2P的基础上,WebRTC存在几种架构及各自适用场景,以下是多人会议的3种典型架构:
1、 Mesh(P2P多点通信)
在小规模会议中,可以通过 WebRTC 的 P2P 机制直接实现多人通信。每个客户端与其他客户端建立单独的 P2P 连接,从而实现全连接网络结构。每个客户端都会上传自己的媒体流,同时接收其他客户端的媒体流。
优点:简单直接,信令服务器和 STUN/TURN 服务器的压力较小。缺点:随着参与人数增加,每个客户端需要同时处理多个音视频流,带宽和性能消耗急剧上升。应用场景:适合 3-5 人的小型会议2、 MCU(多点控制单元)
MCU 服务器用于处理大规模或高质量的会议需求。MCU 服务器接收所有参与者的音视频流,并将其合成为一个单独的流,发送给每个客户端。
优点:客户端只需接收和解码一个合成的音视频流,减少了处理复杂度和带宽消耗。缺点:服务器负担较重,需要进行大量的流合成和处理,适合高质量会议场景,但可能带来延迟。应用场景:支持高质量会议,转移压力到服务器,支持会议同时在线人数取决于服务器集群大小。3、 SFU(选择性转发单元)
对于较大规模的会议,使用 SFU 服务器是一种优化方案。每个客户端只需将自己的媒体流上传一次,SFU 服务器负责将这些流转发给所有其他参与者。SFU 不处理流的编码和合成,只做简单的转发。
优点:减少客户端的带宽负担,只需上传一次数据即可。SFU 服务器能够灵活地选择性转发视频流,例如在发言人模式下,只转发当前发言人的视频流。缺点:增加了服务器的处理负担。应用场景:支持大型会议。四、 WebRTC 底层协议解析
在多人会议中,客户端通过信令服务器进行初始协商,获取对方的 SDP 和 ICE 信息,交换完成 SDP 和 ICE 双方才能正式建立连接。然后,基于具体场景,客户端可以选择直接通过 P2P 进行连接,或者通过 SFU / MCU 来进行数据的转发或合成。
WebRTC 实现高效的实时通信依赖于多种底层协议的协同工作:
1. 信令协商
WebRTC 本身不包含信令协议,但它依赖信令服务器进行会话协商。信令服务器用于交换 SDP(Session Description Protocol)和 ICE(Interactive Connectivity Establishment)候选者信息,帮助客户端找到最优的连接路径。SDP(会话描述协议):SDP 提供了媒体类型、编解码器、带宽要求等信息,描述了会话的具体配置。通过 SDP,两个客户端可以相互了解对方的通信能力并进行匹配。ICE(交互式连接建立):ICE 帮助客户端穿越 NAT(网络地址转换),通过收集不同网络路径的候选者,找到可以建立 P2P 连接的最佳路径。2. NAT 穿越
STUN(会话穿越实用程序):STUN 服务器帮助客户端确定自己的公网 IP 地址和端口,确保两点之间的直接通信。TURN(通过中继绕过 NAT):在无法建立直接连接时,TURN 服务器充当中继,帮助客户端进行音视频流的中转,但会增加延迟和带宽开销。3. 数据传输
RTP(实时传输协议):一旦连接建立,WebRTC 通过 RTP 协议传输音视频流。RTP 为数据包提供序列号和时间戳,确保音视频的顺序和同步。SRTP(安全实时传输协议):WebRTC 使用 SRTP 对媒体流进行加密,确保数据的安全性。SCTP(流控制传输协议):SCTP 支持多流传输和消息顺序保证,适合实时数据传输的需求。RTCDataChannel(数据通道协议):除了音视频流,WebRTC 还支持通过RTCDataChannel
传输任意数据,适用于实时文本消息或文件传输。 4. 中间服务器
信令服务器:信令服务器主要用于 会话建立和控制,帮助 WebRTC 客户端交换必要的信息,以便能够建立连接。STUN 服务器:STUN 服务器用于帮助客户端 穿越 NAT (Network Address Translation 网络地址转换),以便客户端能够通过公共 IP 地址进行直接通信。TURN 服务器:TURN 服务器用于在 P2P 连接无法建立 的情况下 中继音视频流,保证通信的进行。五、React Native WebRTC 与 GraphQL 实践
在构建实时通信应用时,WebRTC 和 GraphQL 是一对非常有力的组合。WebRTC 提供了实时的音频、视频和数据传输能力,而 GraphQL 强大的查询语言和实时订阅机制可以让开发者灵活地定义信令信息的传输和处理方式。
使用场景包括视频通话、多人会议、屏幕共享和文件传输等。
1. 服务端核心代码
利用 graphql 发布订阅 + websocket 实现信令的实时下发。
// server.jsconst express = require('express');const { ApolloServer, gql } = require('apollo-server-express');const { withFilter, PubSub } = require('graphql-subscriptions');const { WebSocketServer } = require('ws');const { makeExecutableSchema } = require('@graphql-tools/schema');const { useServer } = require('graphql-ws/lib/use/ws');const http = require('http');// GraphQL schemaconst typeDefs = gql` type Query { _: Boolean } type Mutation { createRoom(roomId: String!): Boolean joinRoom(roomId: String!, userId: String!): Boolean sendOffer(roomId: String!, userId: String!, sdp: String!): Boolean sendAnswer(roomId: String!, userId: String!, sdp: String!): Boolean sendIceCandidate(roomId: String!, userId: String!, candidate: String!): Boolean } type Subscription { roomUpdated(roomId: String!): RoomUpdate } type RoomUpdate { type: String! roomId: String userId: String sdp: String iceCandidates: [String] }`;// Resolver functionsconst resolvers = { Mutation: { createRoom: (_, { roomId }) => { // More Logic to create a room return true; }, joinRoom: (_, { roomId, userId }) => { // More Logic to join a room return true; }, sendOffer: (_, { roomId, userId, sdp }, { pubsub }) => { // More Logic // pubsub.publish... return true; }, sendAnswer: (_, { roomId, userId, sdp }, { pubsub }) => { // More Logic // pubsub.publish... return true; }, sendIceCandidate: (_, { roomId, userId, candidate }, { pubsub }) => { // More Logic pubsub.publish('ROOM_UPDATED', { roomUpdated: { type: 'ice-candidate', roomId, userId, iceCandidates: [candidate], }, roomId, }); return true; }, }, Subscription: { roomUpdated: { subscribe: (_, { roomId }, { pubsub }) => { // Subscribe to the ROOM_UPDATED topic return pubsub.asyncIterator('ROOM_UPDATED'); }, ), }, },};const app = express();// Initialize PubSubconst pubsub = new PubSub();// Create GraphQL schemaconst schema = makeExecutableSchema({ typeDefs, resolvers });// Create an ApolloServer instanceconst server = new ApolloServer({ schema, graphqlPath: '/graphql', context: { pubsub },});server.start().then(() => { server.applyMiddleware({ app }); // Create an HTTP server const httpServer = http.createServer(app); // Create a WebSocket server const wsServer = new WebSocketServer({ server: httpServer, path: server.graphqlPath, }); // Use graphql-ws to handle WebSocket connections useServer({ schema }, wsServer); // This is NOT a React hook, it should be used at the top level in server code // Start the HTTP server with WebSocket support const PORT = 4000; httpServer.listen(PORT, () => { console.log(`Server is now running on http://localhost:${PORT}/graphql`); console.log(`WebSocket is now running on ws://localhost:${PORT}/graphql`); });});
2. 客户端核心代码
注册 @apollo/client,与服务器建立连接import React, { useState } from 'react';import { SafeAreaView } from 'react-native';import { ApolloProvider, InMemoryCache, ApolloClient } from '@apollo/client';import MeetComponent from './MeetComponent';const client = new ApolloClient({ uri: 'http://localhost:4000/graphql', // 替换为你的 GraphQL 服务器地址 cache: new InMemoryCache(),});const AppPage: React.FC = () => { // more logic // .... return ( <ApolloProvider client={client}> <SafeAreaView> <MeetComponent roomId={roomId} userId={roomId} /> </SafeAreaView> </ApolloProvider> );};
利用 @apollo/client 监听服务端会议房间room的数据变化 import React, { useState, useEffect } from 'react';import { View, Button, StyleSheet, Alert } from 'react-native';import { mediaDevices, RTCView, MediaStream } from 'react-native-webrtc';import { useSubscription } from '@apollo/client';const MeetComponent: React.FC<IProps> = ({ roomId, userId }) => { const ROOM_UPDATED = gql` subscription OnRoomUpdated($roomId: String!) { roomUpdated(roomId: $roomId) { type roomId userId sdp iceCandidates } } ` // 监听服务端房间号的数据更新 const { data, error } = useSubscription(ROOM_UPDATED, { variables: { roomId }, }); useEffect(() => { if (data) { handleIncomingMessage(data.roomUpdated); } if (error) { console.error(error); Alert.alert('Something went wrong.'); } }, [data, error, handleIncomingMessage]); const handleIncomingMessage = async (message: any) => { const { userId, type, sdp, iceCandidates } = message; switch (type) { case 'offer': const configuration = { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, // 示例 STUN 服务器 ], }; // Create a new peer connection for the incoming offer const offerPc: any = new RTCPeerConnection(configuration); offerPc.ontrack = (event: any) => { setRemoteStreams(prev => new Map(prev).set(userId, event.streams[0])); }; offerPc.onicecandidate = (event: any) => { if (event.candidate) { sendIceCandidate({ variables: { roomId, candidate: JSON.stringify(event.candidate) } }); } }; // Set the remote description and create an answer await offerPc.setRemoteDescription(new RTCSessionDescription(sdp)); const answerSdp = await offerPc.createAnswer(); await offerPc.setLocalDescription(answerSdp); // Send the answer back await sendAnswer({ variables: { roomId, sdp: JSON.stringify(answerSdp) } }); // Add the peer connection to the map setPeerConnections(prev => new Map(prev).set(userId, offerPc)); break; case 'answer': // Set the remote description on the existing peer connection const answerPc = peerConnections.get(userId); if (answerPc) { await answerPc.setRemoteDescription(new RTCSessionDescription(sdp)); } break; case 'ice-candidate': // Add the ICE candidates to the existing peer connections iceCandidates.forEach(async (candidate: any) => { const iceCandidate = JSON.parse(candidate); const pc = peerConnections.get(userId); if (pc) { await pc.addIceCandidate(new RTCIceCandidate(iceCandidate)); } }); break; } }; // more logic // .... return ( <View> <View> {localStream && <RTCView streamURL={localStream.toURL()} />} </View> <View> {Array.from(remoteStreams.values()).map((stream, index) => ( <RTCView key={index} streamURL={stream.toURL()} /> ))} </View> <View> Children Node </View> </View> );};
六、 未来的挑战与优化
1. 带宽管理
在多人会议中,带宽消耗是一个重要问题。为了解决带宽限制,可以使用 SFU 选择性地转发音视频流,避免所有流都传送给每个客户端。
2. 媒体质量优化
在网络条件较差的情况下,可以通过动态调整媒体流的比特率,降低视频分辨率或切换到音频模式,以保证会议的顺畅进行。
3. 安全性
WebRTC 使用 SRTP 对音视频流进行加密,确保媒体数据的安全。此外,信令服务器和数据通道也应使用加密协议(如 HTTPS 和 DTLS)。