随着远程办公和在线协作的普及,音视频通信的需求日益增长。无论是两点之间的通信还是多人会议,WebRTC(Web Real-Time Communication)作为一种开源技术,提供了低延迟的实时通信能力。
它允许浏览器或移动设备通过直接的点对点(P2P)连接进行音频、视频和数据的实时传输。它使得不依赖中间服务器的实时通信成为可能,尤其适用于视频聊天、文件共享、音频会议等场景。
在本文中,我们将深入介绍从两点之间的通信原理到底层协议,再到多人会议的实现。
在此之前,我们先来了解一下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 服务器。createOffer()
和 createAnswer()
生成 SDP 描述,并通过信令服务器交换 SDP 信息。// 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));
});
// 创建 Offer
peerAConnection.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种典型架构:
在小规模会议中,可以通过 WebRTC 的 P2P 机制直接实现多人通信。每个客户端与其他客户端建立单独的 P2P 连接,从而实现全连接网络结构。每个客户端都会上传自己的媒体流,同时接收其他客户端的媒体流。
MCU 服务器用于处理大规模或高质量的会议需求。MCU 服务器接收所有参与者的音视频流,并将其合成为一个单独的流,发送给每个客户端。
对于较大规模的会议,使用 SFU 服务器是一种优化方案。每个客户端只需将自己的媒体流上传一次,SFU 服务器负责将这些流转发给所有其他参与者。SFU 不处理流的编码和合成,只做简单的转发。
在多人会议中,客户端通过信令服务器进行初始协商,获取对方的 SDP 和 ICE 信息,交换完成 SDP 和 ICE 双方才能正式建立连接。然后,基于具体场景,客户端可以选择直接通过 P2P 进行连接,或者通过 SFU / MCU 来进行数据的转发或合成。
WebRTC 实现高效的实时通信依赖于多种底层协议的协同工作:
RTCDataChannel
传输任意数据,适用于实时文本消息或文件传输。在构建实时通信应用时,WebRTC 和 GraphQL 是一对非常有力的组合。WebRTC 提供了实时的音频、视频和数据传输能力,而 GraphQL 强大的查询语言和实时订阅机制可以让开发者灵活地定义信令信息的传输和处理方式。
使用场景包括视频通话、多人会议、屏幕共享和文件传输等。
利用 graphql 发布订阅 + websocket 实现信令的实时下发。
// server.js
const 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 schema
const 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 functions
const 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 PubSub
const pubsub = new PubSub();
// Create GraphQL schema
const schema = makeExecutableSchema({ typeDefs, resolvers });
// Create an ApolloServer instance
const 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`);
});
});
注册 @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 (
);
};
利用 @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 = ({ 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 (
{localStream && }
{Array.from(remoteStreams.values()).map((stream, index) => (
))}
Children Node
);
};
在多人会议中,带宽消耗是一个重要问题。为了解决带宽限制,可以使用 SFU 选择性地转发音视频流,避免所有流都传送给每个客户端。
在网络条件较差的情况下,可以通过动态调整媒体流的比特率,降低视频分辨率或切换到音频模式,以保证会议的顺畅进行。
WebRTC 使用 SRTP 对音视频流进行加密,确保媒体数据的安全。此外,信令服务器和数据通道也应使用加密协议(如 HTTPS 和 DTLS)。