文章目录

  • 序言
  • 逻辑设计
  • 私人 WebRTC 工具类代码

序言

在山大会议中,我们不仅要实现多人视频会议,我们还需要实现一个类似 QQ、微信 这样的即时通讯服务。在这个一对一的私人聊天服务中,我们添加了一个一对一的私人视频通话功能,一来是增加软件功能的多样性,二来也是为实现多人聊天做铺垫,先熟悉 WebRTC 在实际环境下的运行。

逻辑设计

首先,我们需要对私人视频通话的代码逻辑进行设计。我们之前在 【山大会议】WebRTC基础之对等体连接 这篇文章中介绍过基本的 WebRTC 对等体连接的过程。它的建立本质上是一个两次握手的过程:

  • 发起方向接收方发送 OFFER 请求,并携带上自己的 SessionDescriptionProtocol (以后简写为 sdp);
  • 接收方接到发送方的 sdp ,创建 ANSWER ,生成 sdp 返回给发送方;
  • 发送方接到接收方的 sdp 后,建立起 WebRTC 对等体连接。

在私人视频聊天模块中,我们决定采用去中心化的、P2P的架构设计,中心服务器仅做信令转发功能,由此可以绕过服务器本身带宽不足,难以支撑起高分辨率画面的问题。
其次,由于我们加入了会话加密功能,发起方可以选择本次通话是否需要加密,而接收方则需要知道对方是否开启了加密,以提示用户是否进行加密对话。
由于我们的服务器没有 SSL 证书,服务器与客户端发送的数据均是明文,这意味着我们无法在不可信信道上传递密钥。因此,我们采用了一种协商算法来生成一次性的密钥,而协商的过程也需要一次握手。最终,我们将过程简化,得到了最终的连接建立过程的逻辑流程:

为了描述的方便起见,我们将 主动方 称为 A被动方 称为 B

  1. AB 发起会话请求,其中携带密钥协商所需要的一些信息;
  2. B 接到 A 的请求,根据携带的 协商信息 判断 A 是否开启加密,并作出回复,如果同意进行会话且 A 启用了加密,则继续根据 协商信息 计算得到 公钥私钥 ,将 公钥 发回给 A
  3. A 接到应答消息后,判断 B 是否同意会话,如果同意则生成 OFFER 请求,携带 sdp 发送给 B ,如果开启了加密,则获取 B 发回的 公钥 ,通过算法得出 私钥
  4. B 接到 OFFER 请求与 sdp,将 sdp 保存为 远程描述符 ,并创建 ANSWER 请求得到 本地描述符 ,并将其发送回 A
  5. A 接到 ANSWER ,将其中的 sdp 保存为 远程描述符 ,双方分别添加 Ice候选者 ,建立起对等体连接。

私人 WebRTC 工具类代码

// ChatRTC.tsx
import {AlertOutlined,CheckOutlined,CloseOutlined,ExclamationCircleOutlined,
} from '@ant-design/icons';
import Modal from 'antd/lib/modal';
import { globalMessage } from 'Components/GlobalMessage/GlobalMessage';
import { EventEmitter } from 'events';
import React from 'react';
import { ChatSocket } from 'Utils/ChatSocket/ChatSocket';
import {CALL_STATUS_ANSWERING,CALL_STATUS_CALLING,CALL_STATUS_FREE,CALL_STATUS_OFFERING,ChatWebSocketType,DEVICE_TYPE,PRIVATE_WEBRTC_ANSWER_TYPE,receiverCodecs,senderCodecs,
} from 'Utils/Constraints';
import eventBus from 'Utils/EventBus/EventBus';
import { getDeviceStream, getMainContent } from 'Utils/Global';
import { AUDIO_TYPE, buildPropmt } from 'Utils/Prompt/Prompt';
import { setCallStatus, setNowChattingId, setNowWebrtcFriendId } from 'Utils/Store/actions';
import store from 'Utils/Store/store';
import { eWindow } from 'Utils/Types';
import { setupReceiverTransform, setupSenderTransform } from 'Utils/WebRTC/RtcEncrypt';interface ChatRtcProps {socket: ChatSocket;myId: number;
}export class ChatRTC extends EventEmitter {callAudioPrompt: (() => void)[];answerAudioPrompt: (() => void)[];socket: ChatSocket;myId: number;localStream: null | MediaStream;remoteStream: null | MediaStream;sender?: number;receiver?: number;peer!: RTCPeerConnection;answerModal!: null | {destroy: () => void;};offerModal!: null | {destroy: () => void;};candidateQueue: Array<any>;useSecurity: boolean;security: string;constructor(props: ChatRtcProps) {super();this.callAudioPrompt = buildPropmt(AUDIO_TYPE.WEBRTC_CALLING, true);this.answerAudioPrompt = buildPropmt(AUDIO_TYPE.WEBRTC_ANSWERING, true);this.socket = props.socket;this.myId = props.myId;this.localStream = null;this.remoteStream = null;this.useSecurity = false;this.security = '[]';this.candidateQueue = new Array();this.socket.on('ON_PRIVATE_WEBRTC_REQUEST', (msg) => {this.responseCall(msg);});this.socket.on('ON_PRIVATE_WEBRTC_RESPONSE', ({ accept, sender, receiver, security }) => {if (sender === this.sender && receiver === this.receiver) {this.callAudioPrompt[1]();if (this.offerModal) {this.offerModal.destroy();this.offerModal = null;}if (accept === PRIVATE_WEBRTC_ANSWER_TYPE.ACCEPT) {this.createOffer(security);} else {switch (accept) {case PRIVATE_WEBRTC_ANSWER_TYPE.BUSY:globalMessage.error({content: '对方正在通话中',duration: 1.5,});break;case PRIVATE_WEBRTC_ANSWER_TYPE.OFFLINE:globalMessage.error({content: '呼叫的用户不在线',duration: 1.5,});break;case PRIVATE_WEBRTC_ANSWER_TYPE.REJECT:globalMessage.error({content: '对方拒绝了您的通话邀请',duration: 1.5,});break;}this.onEnded();}}});this.socket.on('ON_PRIVATE_WEBRTC_OFFER', (msg) => {if (msg.sender === this.sender && msg.receiver === this.receiver)this.createAnswer(msg.sdp);});this.socket.on('ON_PRIVATE_WEBRTC_ANSWER', (msg) => {this.receiveAnswer(msg.sdp);});this.socket.on('ON_PRIVATE_WEBRTC_CANDIDATE', (msg) => {if (msg.sender === this.sender && msg.receiver === this.receiver) {this.handleCandidate(msg);}});this.socket.on('ON_PRIVATE_WEBRTC_DISCONNECT', (msg) => {globalMessage.info('对方已挂断通话');this.onHangUp(msg);});}callRemote(targetId: number, myName: string, offerModal: any) {this.useSecurity = localStorage.getItem('securityPrivateWebrtc') === 'true';this.callAudioPrompt[0]();store.dispatch(setCallStatus(CALL_STATUS_OFFERING));store.dispatch(setNowWebrtcFriendId(targetId));this.sender = this.myId;this.receiver = targetId;let pgArr: Array<string> = [];(async () => {if (this.useSecurity) {pgArr = await eWindow.ipc.invoke('DIFFIE_HELLMAN');}this.socket.send({type: ChatWebSocketType.CHAT_PRIVATE_WEBRTC_REQUEST,sender: this.myId,senderName: myName,security: JSON.stringify(pgArr),receiver: targetId,});this.offerModal = offerModal;})();}responseCall(msg: any) {this.sender = msg.sender;this.receiver = this.myId;const rejectOffer = (reason: number) => {this.socket.send({type: ChatWebSocketType.CHAT_PRIVATE_WEBRTC_RESPONSE,security: '',accept: reason,sender: msg.sender,receiver: msg.receiver,});};if (store.getState().callStatus === CALL_STATUS_FREE) {eventBus.emit('GET_PRIVATE_CALLED');this.answerAudioPrompt[0]();store.dispatch(setNowChattingId(msg.sender));const pgArr = JSON.parse(msg.security);const useSecurity = pgArr.length === 3;this.answerModal = Modal.confirm({icon: useSecurity ? <AlertOutlined /> : <ExclamationCircleOutlined />,title: '视频通话邀请',content: (<span>用户 {msg.senderName}(id: {msg.sender})向您发出视频通话请求,是否接受?{useSecurity ? (<span><br />注意:对方启用了私聊视频会话加密功能,接受此会话可能会导致您的CPU占用被大幅度提高,请与对方确认后选择是否接受此会话</span>) : ('')}</span>),cancelText: (<><CloseOutlined />拒绝接受</>),okText: (<><CheckOutlined />同意请求</>),onOk: () => {this.useSecurity = useSecurity;if (useSecurity) {eWindow.ipc.invoke('DIFFIE_HELLMAN', pgArr[0], pgArr[1], pgArr[2]).then((serverArr) => {const [privateKey, publicKey] = serverArr;this.security = privateKey;this.socket.send({type: ChatWebSocketType.CHAT_PRIVATE_WEBRTC_RESPONSE,accept: PRIVATE_WEBRTC_ANSWER_TYPE.ACCEPT,sender: this.sender,receiver: this.receiver,security: publicKey,});});} else {this.socket.send({type: ChatWebSocketType.CHAT_PRIVATE_WEBRTC_RESPONSE,accept: PRIVATE_WEBRTC_ANSWER_TYPE.ACCEPT,sender: this.sender,receiver: this.receiver,security: '',});}store.dispatch(setCallStatus(CALL_STATUS_ANSWERING));store.dispatch(setNowWebrtcFriendId(msg.sender));},onCancel: () => {rejectOffer(PRIVATE_WEBRTC_ANSWER_TYPE.REJECT);this.answerModal = null;this.sender = undefined;this.receiver = undefined;},afterClose: this.answerAudioPrompt[1],centered: true,getContainer: getMainContent,});} else rejectOffer(PRIVATE_WEBRTC_ANSWER_TYPE.BUSY);}async createOffer(publicKey: string) {this.peer = this.buildPeer();this.localStream = new MediaStream();this.localStream.addTrack((await getDeviceStream(DEVICE_TYPE.VIDEO_DEVICE)).getVideoTracks()[0]);this.localStream.addTrack((await getDeviceStream(DEVICE_TYPE.AUDIO_DEVICE)).getAudioTracks()[0]);for (const track of this.localStream.getTracks()) {this.peer.addTrack(track, this.localStream);}// NOTE: 加密if (publicKey) {const privateKey = await eWindow.ipc.invoke('DIFFIE_HELLMAN', publicKey);this.security = privateKey;this.peer.getSenders().forEach((sender) => {setupSenderTransform(sender, privateKey);});} else {this.peer.getTransceivers().find((t) => t.sender.track?.kind === 'video')?.setCodecPreferences(senderCodecs);}this.peer.createOffer({offerToReceiveAudio: true,offerToReceiveVideo: true,}).then((sdp) => {this.peer.setLocalDescription(sdp);this.socket.send({type: ChatWebSocketType.CHAT_PRIVATE_WEBRTC_OFFER,sdp: sdp.sdp,sender: this.sender,receiver: this.receiver,});});}async createAnswer(remoteSdp: any) {this.peer = this.buildPeer();this.peer.setRemoteDescription(new RTCSessionDescription({sdp: remoteSdp,type: 'offer',}));while (this.candidateQueue.length > 0) {this.peer.addIceCandidate(this.candidateQueue.shift());}this.localStream = new MediaStream();this.localStream.addTrack((await getDeviceStream(DEVICE_TYPE.VIDEO_DEVICE)).getVideoTracks()[0]);this.localStream.addTrack((await getDeviceStream(DEVICE_TYPE.AUDIO_DEVICE)).getAudioTracks()[0]);this.emit('LOCAL_STREAM_READY', this.localStream);for (const track of this.localStream.getTracks()) {this.peer.addTrack(track, this.localStream);}// NOTE: 加密if (this.useSecurity) {this.peer.getSenders().forEach((sender) => {setupSenderTransform(sender, this.security);});} else {this.peer.getTransceivers().find((t) => t.sender.track?.kind === 'video')?.setCodecPreferences(senderCodecs);}this.peer.createAnswer({mandatory: {OfferToReceiveAudio: true,OfferToReceiveVideo: true,},}).then((sdp) => {this.peer.setLocalDescription(sdp);this.socket.send({type: ChatWebSocketType.CHAT_PRIVATE_WEBRTC_ANSWER,sdp: sdp.sdp,sender: this.sender,receiver: this.receiver,});store.dispatch(setCallStatus(CALL_STATUS_CALLING));});}async receiveAnswer(remoteSdp: any) {this.peer.setRemoteDescription(new RTCSessionDescription({sdp: remoteSdp,type: 'answer',}));store.dispatch(setCallStatus(CALL_STATUS_CALLING));this.emit('LOCAL_STREAM_READY', this.localStream);}handleCandidate(data: RTCIceCandidateInit) {this.candidateQueue = this.candidateQueue || new Array();if (data.candidate) {// NOTE: 需要等待 signalingState 变为 stable 才能添加候选者if (this.peer && this.peer.signalingState === 'stable') {this.peer.addIceCandidate(data);} else {this.candidateQueue.push(data);}}}hangUp() {this.callAudioPrompt[1]();this.socket.send({type: ChatWebSocketType.CHAT_PRIVATE_WEBRTC_DISCONNECT,sender: this.sender,receiver: this.receiver,target: store.getState().nowWebrtcFriendId,});this.offerModal = null;this.onEnded();}onHangUp(data: { sender: number; receiver: number }) {const { sender, receiver } = data;if (sender === this.sender && receiver === this.receiver) {if (this.answerModal) {this.answerAudioPrompt[1]();this.answerModal.destroy();}this.answerModal = null;this.onEnded();}}/*** 创建 RTCPeer 连接* @returns 创建后的 RTCPeer 连接*/private buildPeer(): RTCPeerConnection {const peer = new (RTCPeerConnection as any)({iceServers: [{urls: 'stun:stun.stunprotocol.org:3478',},],encodedInsertableStreams: this.useSecurity,}) as RTCPeerConnection;peer.onicecandidate = (evt) => {if (evt.candidate) {const message = {type: ChatWebSocketType.CHAT_PRIVATE_WEBRTC_CANDIDATE,candidate: evt.candidate.candidate,sdpMid: evt.candidate.sdpMid,sdpMLineIndex: evt.candidate.sdpMLineIndex,sender: this.sender,receiver: this.receiver,target: store.getState().nowWebrtcFriendId,};this.socket.send(message);}};peer.ontrack = (evt) => {// NOTE: 解密if (this.useSecurity) setupReceiverTransform(evt.receiver, this.security);elsepeer.getTransceivers().find((t) => t.receiver.track.kind === 'video')?.setCodecPreferences(receiverCodecs);this.remoteStream = this.remoteStream || new MediaStream();this.remoteStream.addTrack(evt.track);if (this.remoteStream.getTracks().length === 2)this.emit('REMOTE_STREAM_READY', this.remoteStream);};// NOTE: 断连检测peer.oniceconnectionstatechange = () => {if (peer.iceConnectionState === 'disconnected') {this.emit('ICE_DISCONNECT');}};peer.onconnectionstatechange = () => {if (peer.connectionState === 'failed') {this.emit('RTC_CONNECTION_FAILED');}};return peer;}changeVideoTrack(newTrack: MediaStreamTrack) {if (this.localStream && this.peer) {const oldTrack = this.localStream.getVideoTracks()[0];this.localStream.removeTrack(oldTrack);this.localStream.addTrack(newTrack);this.peer.getSenders().find((s) => s.track === oldTrack)?.replaceTrack(newTrack);}}/*** 结束通话后清空数据*/onEnded() {this.sender = undefined;this.receiver = undefined;this.useSecurity = false;this.security = '[]';store.dispatch(setNowWebrtcFriendId(null));this.localStream = null;this.remoteStream = null;if (this.peer) this.peer.close();this.candidateQueue = new Array();store.dispatch(setCallStatus(CALL_STATUS_FREE));}
}

【山大会议】私人聊天频道 WebRTC 工具类相关推荐

  1. 【山大会议】多人视频通话 WebRTC 工具类搭建

    文章目录 前言 系统架构 Mesh 架构 MCU 架构 SFU 架构 具体代码 RTC.ts SFU.ts 前言 山大会议 基于 WebRTC 技术实现多人同时在线的视频会议功能.但是 WebRTC ...

  2. 【山大会议】注册页的编写

    文章目录 渲染进程代码 index.jsx App.jsx 修改登录页代码 渲染进程代码 在 src/Views 文件夹下,我们新建一个 Register 文件夹,其中是我们的注册页面. index. ...

  3. 关于工具类应用产品界面设计的一点思考

    2019独角兽企业重金招聘Python工程师标准>>> 很多人设计产品的时候,都想设计出独特用户体验的界面.操作方式,以此来博得用户的眼球.获得用户的认可.甚至引领界面设计的潮流,像 ...

  4. JUC 常用 4 大并发工具类

    欢迎关注方志朋的博客,回复"666"获面试宝典 什么是JUC? JUC就是java.util.concurrent包,这个包俗称JUC,里面都是解决并发问题的一些东西 该包的位置位 ...

  5. [Google Guava] 2.3-强大的集合工具类:java.util.Collections中未包含的集合工具

    原文链接 译文链接 译者:沈义扬,校对:丁一 尚未完成: Queues, Tables工具类 任何对JDK集合框架有经验的程序员都熟悉和喜欢java.util.Collections包含的工具方法.G ...

  6. Java多线程系列(九):CountDownLatch、Semaphore等4大并发工具类详解

    之前谈过高并发编程系列:4种常用Java线程锁的特点,性能比较.使用场景 ,以及高并发编程系列:ConcurrentHashMap的实现原理(JDK1.7和JDK1.8) 今天主要介绍concurre ...

  7. Java操作大数据量Excel导入导出万能工具类(完整版)

    Java操作大数据量Excel导入导出万能工具类(完整版) 转载自:https://blog.csdn.net/JavaWebRookie/article/details/80843653 更新日志: ...

  8. 计算机高水平竞赛,星光不问赶路人,山大计算机类竞赛勇创新高!

    原标题:星光不问赶路人,山大计算机类竞赛勇创新高! 姗姗:你听说过计算机系统与程序设计竞赛吗? 大山:当然!中国计算机学会是中国计算机领域最具权威性和影响力的专业组织,全国大学生计算机系统与程序设计竞 ...

  9. 客快物流大数据项目(五十六): 编写SparkSession对象工具类

    编写SparkSession对象工具类 后续业务开发过程中,每个子业务(kudu.es.clickhouse等等)都会创建SparkSession对象,以及初始化开发环境,因此将环境初始化操作封装成工 ...

最新文章

  1. 机器学习之贝叶斯分类(python实现)
  2. redis 集群搭建_一文轻松搞懂redis集群原理及搭建与使用
  3. Apache Hook机制解析(下)——实战:在自己的代码中使用Apache的钩子
  4. 深度学习练手项目(二)-----利用PyTorch进行线性回归
  5. STM32开发 -- YModem详解
  6. maven创建的工程eclipse 项目--属性--为什么没有deployment assembly 按钮呢
  7. 用SQL语句添加删除修改字段_常用SQL
  8. 宣布EAXY:在Java中简化XML
  9. C语言是菜鸟和大神的分水岭
  10. 人生苦短,喝点python性能鸡汤
  11. android编译的tool版本有多少,android gradle tool版本从3.3升级到3.6.3问题记录
  12. 实现视频播放器倍速、清晰度切换、m3u8下载功能
  13. 据说是学习python最全的资料
  14. 新建网站的长尾词应该如何去做优化
  15. win10主题更换_WIN10好用的小软件
  16. 传染病模型——波利亚坛子
  17. C#生成Code39条形码而非条形码字体的方法
  18. 非线性发展方程定解问题
  19. 电磁场理论笔记03:自由空间中微分形式电磁场定律和边界条件
  20. java实现12306查票_java爬取12306查询余票的操作

热门文章

  1. JSP的标签有哪些如何使用jsp标签
  2. html中加入公告,添加公告.html · 举子/layuiadmin-templete - Gitee.com
  3. BS和CS架构,软件开发的瀑布模型,快速原型模型、螺旋模型、敏捷开发、软件测试分类,测试的分类和理解
  4. 开发者建议使用谷歌浏览器?
  5. IA300加密狗使用
  6. 既可加边也可删边的动态最小生成树
  7. JFS及JFS2文件系统
  8. 如何使用Excel管理项目?
  9. 如何人体穴位自我按摩
  10. 网络分布视频技术与盈利性视频站点技术