【KukuのTech Sharing】使用Apollo + Typescript + WebRTC,打造一个多人通信SDK

1,225 阅读27分钟

Agenda

  • 技术栈简述
  • WebRTC知识点
  • GraphQL知识点
  • SDK架构
  • 代码实现
  • 总结

Code Base First

Github Repo传送门🚪

技术栈简述

Apollo GraphQL

什么是GraphQL?

2015年九⽉,Facebook推出了一种声明式、结构化的查询语⾔GraphQL,制定了 GraphQL spec,旨在提供RESTful架构体系的替代方案。2018年七⽉GraphQL项⽬被转交给新创建的GraphQL基⾦会下。

GraphQL本质上是一种查询语⾔,它并没有要求⽹络层选型(通常是HTTP)。也没有要求传输数据格式(通常是JSON)。甚⾄没有要求应⽤架构(通常是前后端分离架构)。它只是一个查询语⾔,是与 RESTfulRPC处于同一级别的查询语⾔。

Layer

GraphQL 使⽤ schema 查询数据,schema 描述客⼾端想要的数据结构,不⽤考虑后端的具体实现(当然后端最后也要完成对应的数据结构的逻辑)。

image.png

Apollo GraphQL

在实际进行基于GraphQL的开发中,社区里已经推出了不少优秀的GraphQL框架帮助我们专注于需求的实现。常⽤的库有:Relay(React亲儿子),以及时下最完整的Apollo

Apollo is a platform for building a unified graph, a communication layer that helps you manage the flow of data between your application clients (such as web and native apps) and your back-end services. At the heart of the graph is a query language called GraphQL.

如果大家需要深入了解Apollo带给我们的便利,可以移步官网浏览tutorial~ 传送门🚪

Typescript

类型安全

我们前面聊到GraphQLGraphQL规范保证了我们API请求的层面是强类型的。这意味着可以在执行查询之前验证查询的语法正确性和有效性,从而消除运行时错误。强类型模式还允许我们完全了解shape of thing,在业务开发中,这种代码阅读起来效率也非常高。这也是为什么GraphQL这几年越来越火热的原因。

类型安全是指使用强类型语言和模式的好处,它可以帮助开发人员更快地编写代码并减少错误。仅在API层面的类型安全并不能获得可以提供的所有好处。而如果我们在开发过程中使用Typescript,就可以做到尽可能多消除在开发和生产中产生的所有语法错误。

本实践希望大家对于Typescript事先已经有过一定的了解,如果没有也没关系,可以先参考官方tutorial或者社区的其他资源先进行学习:

使用Typescript+GraphQL进行服务端开发

我们先来聊聊服务端开发。在使用Typescript进行GraphQL服务端开发时,一般会有两种常用的开发模式。代码优先(Code First)模式优先(Schema First)

Code First

在选用代码优先的开发方式时,我们一般会使用TypeScript提供的一些特性诸如:Decorator, Interface, Type等先完成开发,再通过一些GrapQL Schema生成工具将代码在运行时转换并提供给GraphQL Server。

Pros

  • 保证了单一数据源,因为TS类型定义同时保存了GraphQL Schema定义并提供类型给Resolver(相当于MVC架构中的Controller)使用;
  • 代码优先可以很容易地克服模式优先方法遇到的困难,而不需要使用大量的工具;
  • 如果业务复杂度会越来越高,那么使用代码优先更易于我们管理项目。

Cons

  • 同时同时保存GraphQL Schema定义并提供类型给Resolver可能使代码可读性变差;
  • API设计更容易受到代码实现而不是业务逻辑的影响;
  • 代码很可能向后不兼容。

Schema First

Pros

  • 大多数情况下,使用这种模式有利于设计优秀的API;
  • 通过遵循依赖倒置原则(DIP),代码定义更抽象,无复杂依赖;
  • 前后端可以复用同一份Schema,从而提高开发效率(Schema很容易进行mock,例如使用graphql-editor模拟后端功能🚀)。

Cons

  • Schema必须与Resolver保持同步,否则可能会导致严重的问题;
  • 导致代码冗余,因为SDL定义不易复用;
  • 将多个Schema组装成单一Schema会困难一些(不过Apollo3已经解决了这个问题!)。

客户端开发

可以参考Apollo官方文档: 传送门🚪

WebRTC

WebRTC 是一个可以在 Web 应用程序中实现音频,视频和数据的实时通信的开源项目。在实时通信中,音视频的采集和处理是一个很复杂的过程。比如音视频流的编解码、降噪和回声消除等,但是在 WebRTC 中,这一切都交由浏览器的底层封装来完成。我们可以直接拿到优化后的媒体流,然后将其输出到本地屏幕和扬声器,或者转发给其对等端。长话短说,就是一个支持网页浏览器进行实时语音对话、视频对话、数据传输的API。

在2013年咕咕噜(Google) I/O会议上,有对WebRTC大致介绍:io13webrtc.appspot.com/#1

WebRTC已经实现了对于实时通信,免插件音视频数据传输的标准制定,需求是:

  • 许多网络服务已经使用了 RTC,但是需要下载,本地应用或者是插件;
  • 下载安装升级插件是复杂的,可能出错的,令人厌烦的;
  • 插件可能很难部署、调试、故障排除等;
  • 插件可能需要技术授权,复杂集成和昂贵的技术;

因此,WebRTC 项目的指导原则是APIs应该是开源的,免费的,标准化的,浏览器内置的,比现有技术更高效的。

image.png

架构图颜色标识说明:

  • 紫色部分是Web开发者API层;
  • 蓝色实线部分是面向浏览器厂商的API层;
  • 蓝色虚线部分浏览器厂商可以自定义实现;

对WebRTC有兴趣的同学可以结合我的另一片教程做一个入门:传送门🚪

RxJS

RxJS 是当前 web 开发中最热门的库之一。它提供强大的功能性方法来处理事件,并将集成点集中到越来越多的框架、库和实用程序中,这一切使得学习 Rx 变得前所未有的吸引人。并且它还有能力利用你之前所掌握的语言知识,因为它几乎涵盖了所有语言。如果熟练掌握响应式编程 (reactive programming) 的话,那它所提供的一切似乎都可以变得很容易。

在遇到类似我们实践这种多人通话的场景时,应用往往会被许多异步事件驱动起来。此时RxJS便可以充当这个高效处理异步事件的得力助手。

但是...

学习RxJS和响应式编程很。它有着众多的概念,大量的表层API和从命令式到声明式风格的思维转换。本文档会使用部分RxJS提供的API进行开发,也希望对你学习理解RxJS有一定的帮助。如果你想提前对RxJS有一些了解的话,可以参考一下这个网站:传送门🚪

WebRTC知识点

在开始实践之前,我们需要先了解一下WebRTC的通话原理。思考一下一次WebRTC通话的难点痛点。比如,在两个完全不同的网络环境、多媒体硬件的设备上,如何进行实时的音视频通话?

媒体协商

首先进行通话的两端之间应该协商好彼此支持的媒体格式。 image.png 如上图假设有两个设备Peer A以及Peer B,通过协商两台设备知道彼此兼容的视频编解码器是H.264。 因此要完成媒体信息的交换,就要用到上述的SDP协议了。

SDP,会话描述协议(Session Description Protocol或简写SDP)描述的是流媒体的初始化参数。此协议由IETF发表为 RFC 2327。 SDP最初的时候是会话发布协议(Session Announcement Protocol或简写SAP)的一个部件,1998年4月推出第一版,但是之后被广泛用于和RTSP以及SIP协同工作,也可被单独用来描述多播会话。

因此,在WebRTC中,媒体能力最终通过 SDP 呈现。在传输媒体数据之前,首先要进行媒体能力协商,看双方都支持那些编码方式,支持哪些分辨率等。协商的方法是通过信令服务器交换媒体能力信息。

image.png

WebRTC 媒体协商的过种如上图所示。

  • 第一步,Amy 调用 createOffer 方法创建 offer 消息。offer 消息中的内容是 Amy 的 SDP 信息。
  • 第二步,Amy 调用 setLocalDescription 方法,将本端的 SDP 信息保存起来。
  • 第三步,Amy 将 offer 消息通过信令服务器传给 Bob。
  • 第四步,Bob 收到 offer 消息后,调用 setRemoteDescription 方法将其存储起来。
  • 第五步,Bob 调用 createAnswer 方法创建 answer 消息, 同样,answer 消息中的内容是 Bob 的 SDP 信息。
  • 第六步,Bob 调用 setLocalDescription 方法,将本端的 SDP 信息保存起来。
  • 第七步,Bob 将 anwser 消息通过信令服务器传给 Amy。
  • 第八步,Amy 收到 answer 消息后,调用 setRemoteDescription 方法,将其保存起来。

网络协商

彼此要了解对方的网络情况,这样才有可能找到一条相互通讯的链路。首先总结一下结论,网络协商的理想步骤是:

  • 获取当前端的外网IP地址映射
  • 通过信令服务交换网络信息

NAT & NAT穿透

NAT即网络地址转换,就是替换IP报文头部的地址信息。NAT通常部署在一个组织的网络出口位置,通过将内部网络IP转换为出口的IP地址提供公网可达以及和上层协议的连接能力。

RFC1918规定了三个保留地址段落:

  • 10.0.0.0-10.255.255.255
  • 172.16.0.0-172.31.255.255
  • 192.168.0.0-192.168.255.255

这三个范围分别处于A,B,C类的地址段,不向特定的用户分配,被IANA作为私有地址保留。这些地址可以在任何组织或企业内部使用,和其他Internet地址的区别就是,仅能在内部使用,不能作为全球路由地址。对于有Internet访问需求而内部又使用私有地址的网络,就要在组织的出口位置部署NAT网关,在报文离开私网进入Internet时,将源IP替换为公网地址,通常是出口设备的接口地址。一个对外的访问请求在到达目标以后,表现为由本组织出口设备发起,因此被请求的服务端可将响应由Internet发回出口网关。出口网关再将目的地址替换为私网的源主机地址,发回内部。这样一次由私网主机向公网服务端的请求和响应就在通信两端均无感知的情况下完成了。依据这种模型,数量庞大的内网主机就不再需要公有IP地址了。

所有NAT都可分为几类:

  1. 静态NAT: 将单个私有IP地址与单个公共地址映射,即将私有IP地址转换为公共IP地址。
  2. 动态NAT: 在这种类型的NAT中,多个专用IP地址映射到公用IP地址池。当我们知道固定用户想要在给定的时间点访问Internet的数量时,将使用它。
  3. PAT(NAT重载): 使用NAT重载可以将许多本地(专用)IP地址转换为单个公用IP地址。端口号用于区分流量,即哪个流量属于哪个IP地址。这是最常用的方法,因为它具有成本效益,因为仅使用一个真实的全局(公共)IP地址就可以将数千个用户连接到Internet。

STUN协议

STUN协议全称Simple traversal of UDP over NATs protocol,是一种网络协议,它允许位于NAT后的客户端找出自己的公网地址,查出自己位于哪种类型的NAT之后以及NAT为某一个本地端口所绑定的Internet端端口。这些信息被用来在两个同时处于NAT路由器之后的主机之间创建UDP通信。默认端口号是3478。它将NAT实现分为四类:

  1. Full-cone NAT / 完全锥形NAT。所有从同一个内网IP和端口号发送过来的请求都会被映射成同一个外网IP和端口号,并且任何一个外网主机都可以通过这个映射的外网IP和端口号向这台内网主机发送包。

image.png

  1. (Address)-restricted-cone NAT / 限制锥形NAT。它也是所有从同一个内网IP和端口号发送过来的请求都会被映射成同一个外网IP和端口号。与完全锥形不同的是,外网主机只能够向先前已经向它发送过数据包的内网主机发送包。

image.png

  1. Port-restricted cone NAT / 端口限制锥形NAT。与限制锥形NAT很相似,只不过它包括端口号。也就是说,一台IP地址X和端口P的外网主机想给内网主机发送包,必须是这台内网主机先前已经给这个IP地址X和端口P发送过数据包。

image.png

  1. Symmetric NAT / 对称NAT。所有从同一个内网IP和端口号发送到一个特定的目的IP和端口号的请求,都会被映射到同一个IP和端口号。如果同一台主机使用相同的源地址和端口号发送包,但是发往不同的目的地,NAT将会使用不同的映射。此外,只有收到数据的外网主机才可以反过来向内网主机发送包。

image.png

方案

一旦客户端得知了Internet端的UDP端口,通信就可以开始了。如果NAT是完全圆锥型的,那么双方中的任何一方都可以发起通信。如果NAT是受限圆锥型或端口受限圆锥型,双方必须一起开始传输。

需要注意的是,要使用STUN RFC中描述的技术并不一定需要使用STUN协议,还可以另外设计一个协议并把相同的功能集成到运行该协议的服务器(TURN)上。

SIP之类的协议是使用UDP分组在Internet上传输音频/视频数据的。不幸的是,由于通信的两个末端往往位于NAT之后,因此用传统的方法是无法创建连接的。这也就是STUN发挥作用的地方。

STUN是一个CS协议。一个VoIP电话或软件包可能会包括一个STUN客户端,而WebRTC中的RTCPeerConnection接口则给予了我们直接调用STUN服务器的能力。这个客户端会向STUN服务器发送请求,之后,服务器就会向STUN客户端报告NAT路由器的公网IP地址以及NAT为允许传入流量传回内网而开通的端口,以组装正确的UDP数据包。

以上的响应同时还使得STUN客户端能够确定正在使用的NAT类型——因为不同的NAT类型处理传入的UDP分组的方式是不同的。四种主要类型中有三种是可以使用的:完全圆锥型NAT、受限圆锥型NAT和端口受限圆锥型NAT——但大型公司网络中经常采用的对称型NAT(又称为双向NAT)则不能使用。

TURN协议

使用TURN协议可以穿透对称型NATTURN协议允许一台主机使用中继服务与对端进行报文传输。TURN不同于其它中继协议在于它允许客户机使用一个中继地址与多个对端同时进行通讯。完美弥补了STUN无法穿透对称型NAT的缺陷。

RTCPeerConnection尝试通过UDP建立对等端之间的直接通信。 如果失败的话,RTCPeerConnection就会使用TCP进行连接。如果使用TCP还失败的话,可以用 TURN服务器作为后备,在终端之间转发数据。 重申: TURN用于中继对等端之间的音频/视频/数据流 TURN服务器具有公共地址,因此即使对等端位于防火墙或代理之后也可以与其他人联系。 TURN服务器有一个概念上来讲简单的任务—中继数据流—但是与 STUN服务器不同的是,他们会消耗大量的带宽。换句话说, TURN服务器需要更加的强大。

具体原理不做过多介绍了,本实践的重点并不在这里。重要的是,通过这两个协议,我们可以轻而易举的获取当前端的外网IP地址映射从而完成网络协商了。

通过上边的介绍,我们不难总结出,如果想要建立起一次WebRTC通话,我们需要完成前端部分的逻辑来创建并且交换信令信息,并且创建一个信令服务器来转发每一端的SDP。那对于多人通话呢?其实就是进行多次上述过程而已。

GraphQL知识点

为什么要选择GraphQL?

  • 在 REST API 中,访问数据时经常需要访问不同的接⼝,举个🌰,假设:
    • /users/ 接口⽤于抓取初始的⽤户数据
    • /users//posts 接⼝返回⽤户的所有⽂章
    • /users//followers 返回⽤户的关注者列表

RESTful接口实现

按需获取

REST API容易出现过度抓取的现象,再举个🌰: Mobile端可能因为展示空间有限不需要某个字段,⽽PC端正好⽤到了这个字段,对于Mobile⽽⾔该字段是多余的,此时可能需要通过通过额外定义一个接口或者在当前接口中增加参数的方式,才可以完成对于双端的兼容。

而在GraphQL中,客户端仅需请求想要的数据,实现按需获取。

GraphQL自定义Schema

自文档性

GraphQL具有⾃⽂档性,服务端会预先定义好数据结构以及查询接⼝,⽐如可以在 GraphiQL中查看服务端的数据能⼒,包含查询和实体类型(GraphQL 是一⻔强类型的查询语⾔)。

如何使用GraphQL

GraphQL后端通常暴露一个POST接⼝来接收queries,这意味着可以使⽤fetchrequest等库直接访问GraphQL接⼝,甚⾄可以使⽤PostmanInsomnia以及curl命令。

image.png

更多优势

在⽇常引⼊了GraphQL的Web开发中,有许多逻辑能够被复⽤。例如:

  • 缓存来⾃server的数据
  • 与UI框架集成(ReactAngular 以及Vue
  • mutation后,保持本地缓存数据与 server 一致
  • 管理websocket,⽤于subscriptions(也是本实践会使用到的部分)
  • 分⻚查询

Query & Mutation

请阅读GraphQL官方文档:中文版 |英文版

Subscription

除了QueryMutation之外,GraphQL还支持第三种操作类型:Subscription

Query一样,Subscription也被用作获取数据。与Query不同,Subscription帮我们创建了一个长链接,可以随时间改变其结果(在服务端最常见是通过WebSocket来实现),从而使得服务器拥有推送订阅结果更新的能力。

声明Subscription

QueryMutation一样,我们需要在服务器端和客户端同时定义Subscription

服务端

我们通过创建typeSubscriptionSchema,来表示一个长链接。下面的commentAdded Subscription会在一个新的评论被添加到一个特定的博客文章(由postID参数指定)通知订阅客户端。

type Subscription {
  commentAdded(postID: ID!): Comment
}

客户端

客户端也同理,需要编写对应的请求Schema来表明自己想要发起一个长链接请求:

const COMMENTS_SUBSCRIPTION = gql`
  subscription OnCommentAdded($postID: ID!) {
    commentAdded(postID: $postID) {
      id
      content
    }
  }
`;

有了这些知识储备,我们应该可以开始构思一下如何实现这个多人协同SDK了。

SDK架构

信令服务器

本实例通过GraphQL Subscription来创建WebRTC信令服务器。

对象类型定义

GraphQL Schema中最基本的组件是对象类型,它只定义可以从服务中获取的一种对象及其字段。对于我们的信令服务器,我设计了如下字段(主要字段):

基本字段

  • Channel image.png
  • Participant image.png
  • ChannelWithParticipant image.png

WebRTC信令字段(来自标准HTML DOM API)

  • Offer image.png
  • Answer image.png
  • Candidate image.png

Mutation

Mutation类型是GraphQL中一种特殊的对象类型,用于修改服务器端数据。信令服务器主要用于交换信令,因此在这里我设计了如下的Mutation来给予客户端提交信令的能力。

  • link image.png
  • offer image.png
  • answer image.png
  • candidate image.png

Subscription

我们需要拥有将Mutation产生的变化广播给所有订阅者的能力,因此服务应该具有被订阅的能力,此处我设计了如下的Subscription

  • linked 我们希望,当订阅了指定Channel时,每当当前Channel中有新的link mutation被执行时,订阅者可以得到新的订阅者是谁。 image.png
  • offered SDP Offer同样也应该被广播给当前频道的订阅者们。 image.png
  • answered image.png
  • candidated image.png

小结

当我们的信令服务实现了上述的基础类型,Mutation以及Subscription时,便可以满足一个简单多人通信应用的全部逻辑了。假设现在有用户A用户B两人加入了信令服务器的频道A,若想建立起一次WebRTC通话事件序列如下: Untitled Diagram.drawio123.png

多个人加入频道时,借助Subscription的广播能力,便可以使得同一频道里的多人成功建立通信。

SDK客户端

结合如上的流程图,我们首先可以确定SDK客户端需要具备的几个能力:

  • 指定请求的GraphQL服务器
  • 发送GraphQL Mutationlinkofferanswercandidate
  • 订阅GraphQL Subscriptionlinkedofferedansweredcandidated
  • 创建WebRTC PeerConnection并通过connection实例创建SDP Offer, SDP Answer以及IceCandidate

同时多人通信应该具有收发消息的能力,并且应该支持一些事件具柄:

  • 支持send方法,调用此方法可以向其他端发送消息
  • 支持onmessage方法,当收到message时我们可以指定handler去处理它

我们可以大概得到这样一个数据结构:

export default class WebrtcChannelClient<ChannelMessage> {
    /**
     * 构造函数
     * @param uri GraphQL服务地址 
     * @param wsuri GraphQL Websocket服务地址
     * @param channel 当前实例要加入频道
     */
    constructor(uri: string, wsuri: string, channel: Channel);

    /**
     * 客户端用户ID
     */
    id: string;
    /**
     * Apollo Client
     */
    client: ApolloClient<NormalizedCacheObject>;
    /**
     * WebRTC 连接列表
     */
    connections: {
        id: string;
        connection: RTCPeerConnection;
    }[];
    /**
     * 消息发送频道列表
     */
    sendChannels: RTCDataChannel[];
    /**
     * 消息接收频道列表
     */
    receiveChannels: RTCDataChannel[];

    /**
     * 发送消息给频道中的其他人
     * @param message 消息对象,由ChannelMessage泛型指定
     */
    send(message: ChannelMessage): void;

    /**
     * 支持绑定事件具柄如:message, candidate
     */
    addEventListener: <K extends keyof WebrtcChannelClientEventMap<ChannelMessage>>(type: K, listener: (this: WebrtcChannelClient<ChannelMessage>, ev: WebrtcChannelClientEventMap<ChannelMessage>[K]) => any) => void;
}

代码实现

服务端

初始化Code Base

cd path/to/project
npm init -y

安装项目所需依赖

"dependencies": {
  "apollo-server-express": "^3.3.0",
  "express": "^4.17.1",
  "graphql": "^15.5.3",
  "graphql-subscriptions": "^1.2.1",
  "subscriptions-transport-ws": "^0.9.19"
},
"devDependencies": {
  "@types/graphql": "^14.5.0",
  "@graphql-codegen/cli": "2.2.0",
  "@types/eslint": "^7.2.13",
  "@types/express": "^4.17.12",
  "@types/node": "^16.0.0",
  "@typescript-eslint/eslint-plugin": "^4.28.1",
  "@typescript-eslint/parser": "^4.28.1",
  "eslint": "^7.30.0",
  "eslint-config-airbnb-typescript": "^12.3.1",
  "eslint-plugin-import": "^2.23.4",
  "nodemon": "^2.0.9",
  "ts-node": "^10.0.0",
  "typescript": "^4.4.2"
},

运行时依赖主要有:

  • apollo-server-express: express middleware for Apollo Server
  • express: Node最流行的服务端框架
  • graphql: GraphQL base
  • graphql-subscriptions: GraphQL Subscription Base
  • subscriptions-transport-ws: 用于实现Apollo Subscription Server

Beginning in Apollo Server 3, subscriptions are not supported by the "batteries-included" apollo-server package. To enable subscriptions, you must first swap to the apollo-server-express package (or any other Apollo Server integration package that supports subscriptions).

如Apollo官网所述,从Apollo Server 3开始,若想使用subscription功能,我们需要借助express的帮助,这也是为什么我们在项目中额外添加了express

开发依赖有:

  • typescript: 使我们的项目支持Typescript
  • ts-node: node.js的TypeScript代码解释器和运行器
  • nodemon: 当开发时遇到文件更改时,自动帮我们重启服务
  • eslint: 规范代码
  • @graphql-codegen/cli: GraphQL Schema > Typescript 代码生成器
  • @types/*: Typescript类型辅助

创建tsconfig

{
  "compilerOptions": {
    "module": "commonjs",
    "declaration": true,
    "removeComments": true,
    "esModuleInterop": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true,
    "target": "es6",
    "sourceMap": true,
    "baseUrl": ".",
    "outDir": "./build",
    "incremental": true,
    "lib": [
      "esnext.asynciterable"
    ]
  },
  "exclude": [
    "node_modules"
  ]
}

Schema First

我们前边讲过,大多数情况下,使用这种模式有利于设计优秀的API,因此这里我们采用Schema First的方式来创建应用:

// schema/channel.schema.ts

import { gql } from 'apollo-server-express';

export const channelSchema = gql`

  """订阅者,订阅对应信令频道的所有消息"""
  type Participant {
    """订阅者ID"""
    id: String!
  }

  """信令频道,用户广播消息给订阅者们"""
  type Channel {
    """信令频道ID"""
    id: String!
  }

  """创建信令频道所需的参数"""
  input ChannelInput {
    """信令频道ID"""
    id: String!
  }

  """创建订阅者所需的参数"""
  input ParticipantInput {
    """订阅者ID"""
    id: String!
  }
  
  """信令频道对象,同时包含当前加入信令频道的订阅者信息"""
  type ChannelWithParticipant {
    """信令频道ID"""
    id: String!
    """当前订阅者"""
    participant: Participant!
  }

  type Query {
    default: String
  }

  type Mutation {
    """通过订阅者参数以及信令频道参数来完成一次订阅操作"""
    link(
      """信令频道参数,这里指信令频道的ID"""
      channel: ChannelInput!
      """订阅者参数,这里指订阅者的ID"""
      participant: ParticipantInput!
    ): Channel!
    """在指定信令频道中向指定的订阅者发送SDP Offer"""
    offer(
      """信令频道参数,这里包含信令频道的ID"""
      channel: ChannelInput!
      """Offer来自于谁"""
      from: ParticipantInput!
      """Offer要发送给谁"""
      to: ParticipantInput!
      """SDP Offer"""
      offer: TransferRTCSessionDescriptionInput!
    ): Boolean!
    """在指定信令频道中向指定的订阅者发送SDP Answer"""
    answer(
      """信令频道参数,这里包含信令频道的ID"""
      channel: ChannelInput!
      """Answer来自于谁"""
      from: ParticipantInput!
      """Answer要发送给谁"""
      to: ParticipantInput!
      """SDP Answer"""
      answer: TransferRTCSessionDescriptionInput!
    ): Boolean!
    """在指定信令频道中向指定的订阅者发送SDP Candidate"""
    candidate(
      """信令频道参数,这里包含信令频道的ID"""
      channel: ChannelInput!
      """Candidate来自于谁"""
      from: ParticipantInput!
      """Candidate要发送给谁"""
      to: ParticipantInput!
      """RTC Ice Candidate"""
      candidate: TransferRTCIceCandidateInput!
    ): Boolean!
  }

  type Subscription {
    """订阅指定信令频道的link mutation"""
    linked(channel: ChannelInput!): ChannelWithParticipant!
    """订阅指定信令频道的offer mutation"""
    offered(channel: ChannelInput!): Offer!
    """订阅指定信令频道的answer mutation"""
    answered(channel: ChannelInput!): Answer!
    """订阅指定信令频道的candidate mutation"""
    candidated(channel: ChannelInput!): Candidate!
  }
`;
// schema/signaling.schema.ts
import { gql } from 'apollo-server-express';

export const signalingSchema = gql`
  
  """SDP Offer对象"""
  type Offer {
    """Offer送往的信令通道"""
    channel: Channel!
    """Offer的发送方"""
    from: Participant!
    """Offer的接收方"""
    to: Participant!
    """RTCSessionDescription 详情请查阅MDN"""
    offer: RTCSessionDescription
  }

  """SDP Answer对象"""
  type Answer {
    """Answer送往的信令通道"""
    channel: Channel!
    """Answer的发送方"""
    from: Participant!
    """Answer的接收方"""
    to: Participant!
    """RTCSessionDescription 详情请查阅MDN"""
    answer: RTCSessionDescription
  }

  """IceCandidate对象,通过交换P2P双方的该对象,从而完成一次通信的建立"""
  type Candidate {
    """Candidate送往的信令通道"""
    channel: Channel!
    """Candidate的发送方"""
    from: Participant!
    """Candidate的接收方"""
    to: Participant!
    """RTCIceCandidate 详情请查阅MDN"""
    candidate: RTCIceCandidate
  }

  type RTCSessionDescription {
    sdp: String
    type: RTCSdp
  }

  type RTCIceCandidate {
    candidate: String
    component: RTCIceComponent
    foundation: String
    port: Int
    priority: Int
    protocol: RTCIceProtocol
    relatedAddress: String
    relatedPort: Int
    sdpMLineIndex: Int
    sdpMid: String
    tcpType: RTCIceTcpCandidate
    type: RTCIceCandidateType
    usernameFragment: String
  }

  enum RTCSdp {
    answer
    offer
    pranswer
    rollback
  }

  enum RTCIceComponent {
    rtcp
    rtp
  }

  enum RTCIceProtocol {
    tcp
    udp
  }

  enum RTCIceTcpCandidate {
    active
    passive
    so
  }

  enum RTCIceCandidateType{
    host
    prflx
    relay
    srflx
  }

  input TransferRTCSessionDescriptionInput {
    sdp: String
    type:RTCSdp
  }

  input TransferRTCIceCandidateInput {
    candidate: String
    component: RTCIceComponent
    foundation: String
    port: Int
    priority: Int
    protocol: RTCIceProtocol
    relatedAddress: String
    relatedPort: Int
    sdpMLineIndex: Int
    sdpMid: String
    tcpType: RTCIceTcpCandidate
    type: RTCIceCandidateType
    usernameFragment: String
  }
`;

注:"""***"""是GraphQL Docs的格式,使用这种注解可以帮助我们生成对应的接口文档。 image.png

创建Apollo Server

// main.ts

import { createServer } from 'http';
import { execute, subscribe } from 'graphql';
import { SubscriptionServer } from 'subscriptions-transport-ws';
import { makeExecutableSchema } from '@graphql-tools/schema';
import express from 'express';
import { ApolloServer } from 'apollo-server-express';

// 导入刚刚创建的schema
import { channelSchema } from './schema/channel.schema';
import { signalingSchema } from './schema/signaling.schema';

(async function start() {
    
  // 创建express app
  const app = express();

  // 创建node http server
  const httpServer = createServer(app);

  // 通过makeExecutableSchema方法,将我们声明好的两个schema文件batch在一起
  const schema = makeExecutableSchema({
    typeDefs: [
      channelSchema,
      signalingSchema,
    ],
  });
  
  // 创建Apollo Server,并在server即将销毁时同时销毁subscriptionServer
  const server = new ApolloServer({
    schema,
    plugins: [{
      async serverWillStart() {
        return {
          async drainServer() {
            // eslint-disable-next-line @typescript-eslint/no-use-before-define
            subscriptionServer.close();
          },
        };
      },
    }],
  });

  // 创建subscriptionServer
  const subscriptionServer = SubscriptionServer.create(
    { schema, execute, subscribe },
    {
      server: httpServer,
      path: server.graphqlPath,
    },
  );

  // 启动apollo Server并加入express middleware
  await server.start();
  server.applyMiddleware({ app });

  const PORT = 4000;
  // 启动http server
  httpServer.listen(PORT, () => console.log(`Server is now running on http://localhost:${PORT}/graphql`));
}());

启动服务

我们可以借助ts-node以及nodemon来启动我们的服务:

nodemon --exec 'ts-node ./src/main.ts'

编辑package.json,将命令加入`scripts:

"scripts": {
  "start": "nodemon --exec 'ts-node ./src/main.ts'"
}

生成typescript types

在服务启动后,我们可以通过访问http://localhost:4000/graphql来检索我们刚刚创建的Schema;此时我们也可以使用@graphql-codegen/cli来将Schema转换成Typescript文件啦!

npx graphql-codegen init

# 完成如下配置

? What type of application are you building? 
❯◉ Backend - API or server
 ◯ Application built with Angular
 ◯ Application built with React
 ◯ Application built with Stencil
 ◯ Application built with other framework or vanilla JS

? Where is your schema?: (path or url) https://localhost:4000/graphql

? Pick plugins: 
 ◉ TypeScript (required by other typescript plugins)
 ◉ TypeScript Resolvers (strongly typed resolve functions)
 ◯ TypeScript MongoDB (typed MongoDB objects)
❯◉ TypeScript GraphQL document nodes (embedded GraphQL document)

? Where to write the output: src/type.ts
? Do you want to generate an introspection file? (Y/n) Y

? How to name the config file? codegen.yml
? What script in package.json should run the codegen? codegen

创建GraphQL Resolver

我们可以先创建一个存放Subscriptionenum

// constant.ts
export enum TOPIC {
  linked = 'linked',
  offered = 'offered',
  answered = 'answered',
  candidated = 'candidated',
}

然后创建GraphQL Resolver

// channel.resolver.ts
import { ApolloError } from 'apollo-server-express';
import { withFilter, PubSub } from 'graphql-subscriptions';

// 刚刚codegen生成的结果
import {
  Answer, Candidate,
  Channel, ChannelWithParticipant, MutationAnswerArgs, MutationCandidateArgs,
  MutationLinkArgs, MutationOfferArgs, Offer,
  Participant, Resolvers, SubscriptionLinkedArgs, SubscriptionOfferedArgs,
} from '../type';


import { TOPIC } from '../constant';

const pubsub = new PubSub();

export const channelResolver: Resolvers = {
  Subscription: {
    linked: {
      // 这里withFilter用做限制只给订阅指定Channel的订阅者发送消息
      subscribe: withFilter(
        () => pubsub.asyncIterator([TOPIC.linked]),
        (
          payload: { linked: Channel },
          variables: SubscriptionLinkedArgs,
        ) => payload.linked.id === variables.channel.id,
      ),
    },
    offered: {
      subscribe: withFilter(
        () => pubsub.asyncIterator([TOPIC.offered]),
        (
          payload: { offered: Offer },
          variables: SubscriptionOfferedArgs,
        ) => payload.offered.channel.id === variables.channel.id,
      ),
    },
    answered: {
      subscribe: withFilter(
        () => pubsub.asyncIterator([TOPIC.answered]),
        (
          payload: { answered: Answer },
          variables: SubscriptionOfferedArgs,
        ) => payload.answered.channel.id === variables.channel.id,
      ),
    },
    candidated: {
      subscribe: withFilter(
        () => pubsub.asyncIterator([TOPIC.candidated]),
        (
          payload: { candidated: Candidate },
          variables: SubscriptionOfferedArgs,
        ) => payload.candidated.channel.id === variables.channel.id,
      ),
    },
  },
  Mutation: {
    async link(_, args: MutationLinkArgs) {
      try {
        const channel = {
          id: args.channel.id,
          participant: args.participant as Participant,
        } as ChannelWithParticipant;

        // 有新订阅者加入频道时,发送给订阅者
        await pubsub.publish(TOPIC.linked, {
          [TOPIC.linked]: channel,
        } as { linked: ChannelWithParticipant });

        return args.channel;
      } catch (e) {
        throw new ApolloError(e.message);
      }
    },
    async offer(_, args: MutationOfferArgs) {
      // 广播Offer给订阅者
      await pubsub.publish(TOPIC.offered, {
        offered: args,
      } as { offered: Offer });

      return true;
    },
    async answer(_, args: MutationAnswerArgs) {
      await pubsub.publish(TOPIC.answered, {
        answered: args,
      } as { answered: Answer });

      return true;
    },
    async candidate(_, args: MutationCandidateArgs) {
      await pubsub.publish(TOPIC.candidated, {
        candidated: args,
      } as { candidated: Candidate });

      return true;
    },
  },
};

最后来到main.ts导入刚刚创建的resolver文件:

import { channelResolver } from './resolver/channel.resolver';

const schema = makeExecutableSchema({
  typeDefs: [
    channelSchema,
    signalingSchema,
  ],
  resolvers: [
    channelResolver,
  ],
});

运行服务,检视结果

npm start

image.png

客户端

初始化Code Base

cd path/to/project
npm init -y

安装项目所需依赖

"dependencies": {
  "@apollo/client": "^3.4.13",
  "graphql": "^15.6.0",
  "rxjs": "^7.3.0",
  "subscriptions-transport-ws": "^0.9.19",
  "uuid": "^8.3.2"
},
"devDependencies": {
  "@graphql-codegen/cli": "2.2.0",
  "@types/uuid": "^8.3.1",
  "casual": "^1.6.2",
  "fork-ts-checker-webpack-plugin": "^6.3.3",
  "ts-loader": "^9.2.5",
  "webpack": "^5.52.0",
  "webpack-cli": "^4.8.0"
  "@types/graphql": "^14.5.0",
  "@types/eslint": "^7.2.13",
  "@types/node": "^16.0.0",
  "@typescript-eslint/eslint-plugin": "^4.28.1",
  "@typescript-eslint/parser": "^4.28.1",
  "eslint": "^7.30.0",
  "eslint-config-airbnb-typescript": "^12.3.1",
  "eslint-plugin-import": "^2.23.4",
  "ts-node": "^10.0.0",
  "typescript": "^4.4.2"
},
"peerDependencies": {
  "react": "^17.0.2"
},

运行时依赖主要有:

  • @apollo/client: Apollo GraphQL客户端
    • 同时该依赖引入了react,为了使得我们的SDK构建产物中不包含react而导致多个react包无法启动应用的问题,此处将其声明为peerDependencies,同时为此我们也需要调整一下我们代码的构建方式。
  • rxjs: 处理异步事件流
  • subscriptions-transport-ws: Webscoket Subscription Client
  • uuid: 生成随机ID

开发依赖则主要增加了Webpack来做SDK代码的构建。

GraphQL Codegen

刚刚在服务端我们通过使用GraphQL Codegen,完成了GraphQL SchemaTypescript的转换;在客户端,我们需要重复上述步骤。

npx graphql-codegen init
npm run codegen

创建Apollo Client

// subscription-client.ts
import {
  ApolloClient, HttpLink, InMemoryCache, split,
} from '@apollo/client';
import { WebSocketLink } from '@apollo/client/link/ws';
import { getMainDefinition } from '@apollo/client/utilities';

export const createSubscriptionClient = (uri: string, wsuri: string) => {
  const httpLink = new HttpLink({
    uri,
  });

  const wsLink = new WebSocketLink({
    uri: wsuri,
    options: {
      reconnect: true,
    },
  });

  const splitLink = split(
    ({ query }) => {
      const definition = getMainDefinition(query);
      return (
        definition.kind === 'OperationDefinition'
                && definition.operation === 'subscription'
      );
    },
    wsLink,
    httpLink,
  );

  return new ApolloClient({
    uri,
    cache: new InMemoryCache(),
    link: splitLink,
  });
};

创建WebRtcChannelClient

我们可以通过刚刚生成的类型以及GraphQL Client实例快速创建需要用到的Mutation以及Subscription

private mutationLink(args: MutationLinkArgs) {
  return this.client.mutate<
  { link: Channel },
  MutationLinkArgs
  >({
    mutation: MUTATION_LINK,
    variables: args,
  });
}

private mutationOffer(args: MutationOfferArgs) {
  return this.client.mutate<
  { offer: boolean },
  MutationOfferArgs
  >({
    mutation: MUTATION_OFFER,
    variables: args,
  });
}

private mutationAnswer(args: MutationAnswerArgs) {
  return this.client.mutate<
  { answer: boolean },
  MutationAnswerArgs
  >({
    mutation: MUTATION_ANSWER,
    variables: args,
  });
}

private mutationCandidate(args: MutationCandidateArgs) {
  return this.client.mutate<
  { candidate: boolean },
  MutationCandidateArgs
  >({
    mutation: MUTATION_CANDIDATE,
    variables: args,
  });
}

private subscriptionLinked(args: SubscriptionLinkedArgs) {
  return this.client.subscribe<
  { linked: ChannelWithParticipant },
  SubscriptionLinkedArgs
  >({
    query: SUBSCRIPTION_LINKED,
    variables: args,
  });
}

private subscriptionOffered(args: SubscriptionOfferedArgs) {
  return this.client.subscribe<
  { offered: Offer },
  SubscriptionOfferedArgs
  >({
    query: SUBSCRIPTION_OFFERED,
    variables: args,
  });
}

private subscriptionAnswered(args: SubscriptionAnsweredArgs) {
  return this.client.subscribe<
  { answered: Answer },
  SubscriptionAnsweredArgs
  >({
    query: SUBSCRIPTION_ANSWERED,
    variables: args,
  });
}

private subscriptionCandidate(args: SubscriptionCandidatedArgs) {
  return this.client.subscribe<
  { candidated: Candidate },
  SubscriptionCandidatedArgs
  >({
    query: SUBSCRIPTION_CANDIDATED,
    variables: args,
  });
}

我们希望SDK一被创建,就可以像服务端发起link mutation从而与其他人建立连接:

private triggerConnection(channel: Channel) {
  // 使用RxJS延迟500ms执行Mutation
  of(null)
    .pipe(delay(500))
    .subscribe(async () => {
      await this.mutationLink({
        channel: { id: channel.id },
        participant: {
          id: this.id,
        },
      });
    });
}

同时我汇总了所有可能被触发的事件,并归纳为RxJS Subject:

private connection$ = new Subject<{ id: string, connection: RTCPeerConnection }>();
private sendChannel$ = new Subject<RTCDataChannel>();
private receiveChannel$ = new Subject<RTCDataChannel>();
private linked$ = new Subject<ChannelWithParticipant>();
private offered$ = new Subject<Offer>();
private answered$ = new Subject<Answer>();
private candidated$ = new Subject<Candidate>();
private message$ = new Subject<ChannelMessage>();
private connected$ = new Subject<void>();

当这些Subject接收到新的值时,触发相应的方法:

private monitorConnectionSubjects(channel: Channel) {
  this.sendChannel$.subscribe((sendChannel) => this.sendChannels.push(sendChannel));
  this.receiveChannel$.subscribe((receiveChannel) => {
    this.receiveChannels.push(receiveChannel);
    this.enableReceivedChannels();
  });
  this.connection$.subscribe((connection) => this.connections.push(connection));
  this.linked$.subscribe(async (channelWithParticipant) => {
    const { id, participant } = channelWithParticipant;

    if (this.id === participant.id) return;

    const peerConnection = await this.createRTCPeerConnectionAndSetupSendChannel(
      PeerConnectionType.OFFER,
      { channel: { id }, participant } as MutationLinkArgs,
    );

    const offer = await peerConnection.createOffer();
    await peerConnection.setLocalDescription(offer);

    await this.mutationOffer({
      channel: { id: channel.id },
      from: { id: this.id },
      to: { id: participant.id },
      offer: offer as TransferRtcSessionDescriptionInput,
    });
  });
  this.offered$.subscribe(async (offer) => {
    if (this.id !== offer.to.id) return;

    if (this.id === offer.from.id) return;

    const peerConnection = await this.createRTCPeerConnectionAndSetupSendChannel(
      PeerConnectionType.ANSWER,
      offer as MutationOfferArgs,
    );

    await peerConnection.setRemoteDescription(offer.offer as RTCSessionDescriptionInit);
    const answer = await peerConnection.createAnswer();
    await peerConnection.setLocalDescription(answer);

    await this.mutationAnswer({
      channel: { id: channel.id },
      from: { id: this.id },
      to: { id: offer.from.id },
      answer: answer as TransferRtcSessionDescriptionInput,
    });
  });
  this.answered$.subscribe(async (answer) => {
    if (this.id !== answer.to.id) return;

    if (this.id === answer.from.id) return;

    const peerConnection = this.connections.find((c) => c.id === answer.from.id);

    if (!peerConnection) return;

    await peerConnection.connection
      .setRemoteDescription(answer.answer as RTCSessionDescriptionInit);
  });
  this.candidated$.subscribe(async (candidate) => {
    if (this.id !== candidate.to.id) return;

    if (this.id === candidate.from.id) return;

    const peerConnection = this.connections.find((c) => c.id === candidate.from.id);

    if (!peerConnection) return;

    await peerConnection.connection.addIceCandidate(candidate.candidate as RTCIceCandidate);
  });
}

对于创建RTCPeerConnection来说,发送方和接收方除了一些细微差异,其他的逻辑是大致相同的,因此我们可以声明一个方法来创建RTCPeerConnection以及DataChannels

// 指定当前PeerConnection的类型,有可能是因为发送Offer主动创建;也可能是收到Answer被动创建
enum PeerConnectionType {
  OFFER,
  ANSWER,
}
private async createRTCPeerConnectionAndSetupSendChannel(
  type: PeerConnectionType, args: MutationLinkArgs | MutationOfferArgs,
) {
  const peerConnection = new RTCPeerConnection();

  let sendChannel: RTCDataChannel;

  if (type === PeerConnectionType.OFFER) {
    const { participant } = args as MutationLinkArgs;
    sendChannel = peerConnection.createDataChannel(participant.id);
    this.connection$.next({ id: participant.id, connection: peerConnection });
  } else if (type === PeerConnectionType.ANSWER) {
    const { from } = args as MutationOfferArgs;
    sendChannel = peerConnection.createDataChannel(from.id);
    this.connection$.next({ id: from.id, connection: peerConnection });
  }

  peerConnection.onicecandidate = async (e) => {
    const { candidate } = e;

    if (!candidate) return;

    if (type === PeerConnectionType.OFFER) {
      const { participant, channel } = args as MutationLinkArgs;
      await this.mutationCandidate({
        channel: { id: channel.id },
        from: {
          id: this.id,
        },
        to: { id: participant.id },
        candidate: candidate as TransferRtcIceCandidateInput,
      });
      return;
    }

    if (type === PeerConnectionType.ANSWER) {
      const { channel, from, to } = args as MutationOfferArgs;
      await this.mutationCandidate({
        channel: { id: channel.id },
        from: { id: to.id },
        to: { id: from.id },
        candidate: candidate as TransferRtcIceCandidateInput,
      });
    }
  };

  peerConnection.ondatachannel = (e) => {
    const { channel } = e;

    if (!channel) return;

    this.receiveChannel$.next(channel);
  };

  this.sendChannel$.next(sendChannel);

  return peerConnection;
}

使用RxJS的另一个好处就是,我们可以轻易的把事件同时暴露给SDK的消费者使用,这里我们可以模仿DOM API声明一个addEventListener方法:

public addEventListener: <K extends keyof WebrtcChannelClientEventMap<ChannelMessage>>(
  type: K,
  listener: (
    this: WebrtcChannelClient<ChannelMessage>,
    ev: WebrtcChannelClientEventMap<ChannelMessage>[K]) => any
) => void = (type, listener) => {
  switch (type) {
    case 'message':
      this.message$.subscribe((message) => {
        listener.call(this, message);
      });
      break;
    case 'candidate':
      this.receiveChannel$
        .pipe(delay(1_000))
        .subscribe(() => {
          listener.call(this);
        });
      break;
    case 'connected':
      this.connected$.subscribe(() => {
        listener.call(this);
      });
      break;
    default:
      break;
  }
};

你或许已经注意到了上述代码中的ChannelMessage泛型。其实WebRTC DataChannel的传输,只支持stringify的内容传递,但通过泛型约束,我们可以轻而易举的保证stringify的内容可以被parse回其原来的类型;且使用了泛型约束,在编写代码时可以获得充足的代码提示,我们就不必再为WebRTC DataChannel到底传输什么数据而烦恼了:

export default class WebrtcChannelClient<ChannelMessage> {}
public send(message: ChannelMessage) {
  try {
    this.sendChannels.forEach((channel) => {
      if (typeof message === 'string') {
        channel.send(message);
      } else {
        channel.send(JSON.stringify(message));
      }
    });
  } catch (e) {
    console.log(e);
  }
}
private message$ = new Subject<ChannelMessage>();
// 从WebRTC Datachannel 获取到的数据,parse后trigger $message Subject
private enableReceivedChannels() {
  this.receiveChannels.forEach((channel) => {
    // eslint-disable-next-line no-param-reassign
    channel.onmessage = (ev) => {
      const { data } = ev;

      if (!data) return;

      try {
        this.message$.next(JSON.parse(data) as ChannelMessage);
      } catch {
        this.message$.next(data as ChannelMessage);
      }
    };
  });
}

纯享版客户端代码如下:

import { ApolloClient, NormalizedCacheObject } from '@apollo/client';
import { v4 as uuid } from 'uuid';
import {
  delay, of, Subject,
} from 'rxjs';
import { createSubscriptionClient } from './subscription-client';
import {
  Channel, ChannelWithParticipant,
  Offer, Answer, Candidate,
  MutationLinkArgs, MutationOfferArgs,
  MutationAnswerArgs, MutationCandidateArgs,
  SubscriptionLinkedArgs, SubscriptionOfferedArgs,
  SubscriptionAnsweredArgs, SubscriptionCandidatedArgs,
  TransferRtcIceCandidateInput,
  TransferRtcSessionDescriptionInput,
} from './type';
import {
  MUTATION_LINK, MUTATION_OFFER,
  MUTATION_ANSWER, MUTATION_CANDIDATE,
  SUBSCRIPTION_LINKED, SUBSCRIPTION_OFFERED,
  SUBSCRIPTION_ANSWERED, SUBSCRIPTION_CANDIDATED,
} from './api';

enum PeerConnectionType {
  OFFER,
  ANSWER,
}

interface WebrtcChannelClientEventMap<ChannelMessage> {
  message: ChannelMessage,
  candidate: void
  connected: void
}

export default class WebrtcChannelClient<ChannelMessage> {
  constructor(uri: string, wsuri: string, channel: Channel) {
    this.client = createSubscriptionClient(uri, wsuri);

    this
      .subscriptionLinked({ channel })
      .subscribe(({ data }) => {
        if (data?.linked) this.linked$.next(data.linked);
      });

    this
      .subscriptionOffered({ channel })
      .subscribe(({ data }) => {
        if (data?.offered) this.offered$.next(data.offered);
      });

    this
      .subscriptionAnswered({ channel })
      .subscribe(({ data }) => {
        if (data?.answered) this.answered$.next(data.answered);
      });

    this
      .subscriptionCandidate({ channel })
      .subscribe(({ data }) => {
        if (data?.candidated) this.candidated$.next(data.candidated);
      });

    this.monitorConnectionSubjects(channel);

    this.triggerConnection(channel);
  }

  public id: string = uuid() + Date.now();

  public client: ApolloClient<NormalizedCacheObject>;

  public connections: { id: string, connection: RTCPeerConnection }[] = [];

  public sendChannels: RTCDataChannel[] = [];

  public receiveChannels: RTCDataChannel[] = [];

  public addEventListener: <K extends keyof WebrtcChannelClientEventMap<ChannelMessage>>(
    type: K,
    listener: (
      this: WebrtcChannelClient<ChannelMessage>,
      ev: WebrtcChannelClientEventMap<ChannelMessage>[K]) => any
  ) => void = (type, listener) => {
    switch (type) {
      case 'message':
        this.message$.subscribe((message) => {
          listener.call(this, message);
        });
        break;
      case 'candidate':
        this.receiveChannel$
          .pipe(delay(1_000))
          .subscribe(() => {
            listener.call(this);
          });
        break;
      case 'connected':
        this.connected$.subscribe(() => {
          listener.call(this);
        });
        break;
      default:
        break;
    }
  };

  public connected() {
    this.connection$.complete();
    this.sendChannel$.complete();
    this.receiveChannel$.complete();
    this.linked$.complete();
    this.offered$.complete();
    this.candidated$.complete();

    this.connected$.next();
    this.connected$.complete();

    this.enableReceivedChannels();
  }

  public finish() {
    this.message$.complete();
  }

  public send(message: ChannelMessage) {
    try {
      this.sendChannels.forEach((channel) => {
        if (typeof message === 'string') {
          channel.send(message);
        } else {
          channel.send(JSON.stringify(message));
        }
      });
    } catch (e) {
      console.log(e);
    }
  }

  private connection$ = new Subject<{ id: string, connection: RTCPeerConnection }>();

  private sendChannel$ = new Subject<RTCDataChannel>();

  private receiveChannel$ = new Subject<RTCDataChannel>();

  private linked$ = new Subject<ChannelWithParticipant>();

  private offered$ = new Subject<Offer>();

  private answered$ = new Subject<Answer>();

  private candidated$ = new Subject<Candidate>();

  private message$ = new Subject<ChannelMessage>();

  private connected$ = new Subject<void>();

  private monitorConnectionSubjects(channel: Channel) {
    this.sendChannel$.subscribe((sendChannel) => this.sendChannels.push(sendChannel));
    this.receiveChannel$.subscribe((receiveChannel) => {
      this.receiveChannels.push(receiveChannel);
      this.enableReceivedChannels();
    });
    this.connection$.subscribe((connection) => this.connections.push(connection));

    this.linked$.subscribe(async (channelWithParticipant) => {
      const { id, participant } = channelWithParticipant;

      if (this.id === participant.id) return;

      const peerConnection = await this.createRTCPeerConnectionAndSetupSendChannel(
        PeerConnectionType.OFFER,
        { channel: { id }, participant } as MutationLinkArgs,
      );

      const offer = await peerConnection.createOffer();
      await peerConnection.setLocalDescription(offer);

      await this.mutationOffer({
        channel: { id: channel.id },
        from: { id: this.id },
        to: { id: participant.id },
        offer: offer as TransferRtcSessionDescriptionInput,
      });
    });

    this.offered$.subscribe(async (offer) => {
      if (this.id !== offer.to.id) return;

      if (this.id === offer.from.id) return;

      const peerConnection = await this.createRTCPeerConnectionAndSetupSendChannel(
        PeerConnectionType.ANSWER,
        offer as MutationOfferArgs,
      );

      await peerConnection.setRemoteDescription(offer.offer as RTCSessionDescriptionInit);
      const answer = await peerConnection.createAnswer();
      await peerConnection.setLocalDescription(answer);

      await this.mutationAnswer({
        channel: { id: channel.id },
        from: { id: this.id },
        to: { id: offer.from.id },
        answer: answer as TransferRtcSessionDescriptionInput,
      });
    });

    this.answered$.subscribe(async (answer) => {
      if (this.id !== answer.to.id) return;

      if (this.id === answer.from.id) return;

      const peerConnection = this.connections.find((c) => c.id === answer.from.id);

      if (!peerConnection) return;

      await peerConnection.connection
        .setRemoteDescription(answer.answer as RTCSessionDescriptionInit);
    });

    this.candidated$.subscribe(async (candidate) => {
      if (this.id !== candidate.to.id) return;

      if (this.id === candidate.from.id) return;

      const peerConnection = this.connections.find((c) => c.id === candidate.from.id);

      if (!peerConnection) return;

      await peerConnection.connection.addIceCandidate(candidate.candidate as RTCIceCandidate);
    });
  }

  private triggerConnection(channel: Channel) {
    of(null)
      .pipe(delay(500))
      .subscribe(async () => {
        await this.mutationLink({
          channel: { id: channel.id },
          participant: {
            id: this.id,
          },
        });
      });
  }

  private mutationLink(args: MutationLinkArgs) {
    return this.client.mutate<
    { link: Channel },
    MutationLinkArgs
    >({
      mutation: MUTATION_LINK,
      variables: args,
    });
  }

  private mutationOffer(args: MutationOfferArgs) {
    return this.client.mutate<
    { offer: boolean },
    MutationOfferArgs
    >({
      mutation: MUTATION_OFFER,
      variables: args,
    });
  }

  private mutationAnswer(args: MutationAnswerArgs) {
    return this.client.mutate<
    { answer: boolean },
    MutationAnswerArgs
    >({
      mutation: MUTATION_ANSWER,
      variables: args,
    });
  }

  private mutationCandidate(args: MutationCandidateArgs) {
    return this.client.mutate<
    { candidate: boolean },
    MutationCandidateArgs
    >({
      mutation: MUTATION_CANDIDATE,
      variables: args,
    });
  }

  private subscriptionLinked(args: SubscriptionLinkedArgs) {
    return this.client.subscribe<
    { linked: ChannelWithParticipant },
    SubscriptionLinkedArgs
    >({
      query: SUBSCRIPTION_LINKED,
      variables: args,
    });
  }

  private subscriptionOffered(args: SubscriptionOfferedArgs) {
    return this.client.subscribe<
    { offered: Offer },
    SubscriptionOfferedArgs
    >({
      query: SUBSCRIPTION_OFFERED,
      variables: args,
    });
  }

  private subscriptionAnswered(args: SubscriptionAnsweredArgs) {
    return this.client.subscribe<
    { answered: Answer },
    SubscriptionAnsweredArgs
    >({
      query: SUBSCRIPTION_ANSWERED,
      variables: args,
    });
  }

  private subscriptionCandidate(args: SubscriptionCandidatedArgs) {
    return this.client.subscribe<
    { candidated: Candidate },
    SubscriptionCandidatedArgs
    >({
      query: SUBSCRIPTION_CANDIDATED,
      variables: args,
    });
  }

  private async createRTCPeerConnectionAndSetupSendChannel(
    type: PeerConnectionType, args: MutationLinkArgs | MutationOfferArgs,
  ) {
    const peerConnection = new RTCPeerConnection();

    let sendChannel: RTCDataChannel;

    if (type === PeerConnectionType.OFFER) {
      const { participant } = args as MutationLinkArgs;
      sendChannel = peerConnection.createDataChannel(participant.id);
      this.connection$.next({ id: participant.id, connection: peerConnection });
    } else if (type === PeerConnectionType.ANSWER) {
      const { from } = args as MutationOfferArgs;
      sendChannel = peerConnection.createDataChannel(from.id);
      this.connection$.next({ id: from.id, connection: peerConnection });
    }

    peerConnection.onicecandidate = async (e) => {
      const { candidate } = e;

      if (!candidate) return;

      if (type === PeerConnectionType.OFFER) {
        const { participant, channel } = args as MutationLinkArgs;
        await this.mutationCandidate({
          channel: { id: channel.id },
          from: {
            id: this.id,
          },
          to: { id: participant.id },
          candidate: candidate as TransferRtcIceCandidateInput,
        });
        return;
      }

      if (type === PeerConnectionType.ANSWER) {
        const { channel, from, to } = args as MutationOfferArgs;
        await this.mutationCandidate({
          channel: { id: channel.id },
          from: { id: to.id },
          to: { id: from.id },
          candidate: candidate as TransferRtcIceCandidateInput,
        });
      }
    };

    peerConnection.ondatachannel = (e) => {
      const { channel } = e;

      if (!channel) return;

      this.receiveChannel$.next(channel);
    };

    this.sendChannel$.next(sendChannel);

    return peerConnection;
  }

  private enableReceivedChannels() {
    this.receiveChannels.forEach((channel) => {
      // eslint-disable-next-line no-param-reassign
      channel.onmessage = (ev) => {
        const { data } = ev;

        if (!data) return;

        try {
          this.message$.next(JSON.parse(data) as ChannelMessage);
        } catch {
          this.message$.next(data as ChannelMessage);
        }
      };
    });
  }
}

客户端代码构建 最后是客户端代码的构建部分,实践中使用了:

  • webpack
  • ts-loader
  • fork-ts-checker-webpack-plugin

最终的webpack配置如下:

/* node modules import */
const path = require('path');

/* webpack plugins */
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');

/**
 * @description webpack build config ( for typescript )
 */
const config = {
  mode: 'none',
  entry: './src/index.ts',
  devtool: 'inline-source-map',
  target: 'web',
  output: {
    path: path.resolve(__dirname, 'lib'),
    filename: 'index.js',
    libraryTarget: 'umd',
  },
  module: {
    rules: [
      {
        test: /.ts?$/,
        use: [
          {
            loader: 'ts-loader',
          },
        ],
        exclude: /node_modules/,
      },
    ],
  },
  resolve: {
    extensions: ['.ts', '.js'],
  },
  // webpack4 optimizations
  optimization: {
    nodeEnv: false,
    minimize: true,
  },
  // to let __dirname & __filename not a relative path
  node: {
    __filename: false,
    __dirname: false,
  },
  // fork tsconfig.json
  plugins: [
    new ForkTsCheckerWebpackPlugin({
      typescript: {
        configFile: path.resolve(__dirname, 'tsconfig.json'),
      },
    }),
  ],
  externals: [
    // apollo client依赖了react,但并不是SDK依赖,所以将其排除在构建列表之外
    'react',
  ],
};

export default config;

视频通话

这部分逻辑目前还没有涉及,但遵循现在的设计,实现起来其实也是换汤不换药的,如果大家有兴趣对代码继续研究下去,可以试着完成这部分逻辑。

总结

WebRTC带来了大量的复杂API给开发者,这让人很头疼。而这个时候考虑使用GraphQL以及Typescript来规范我们的代码,注定是一件事半功倍的事情。

如果你喜欢我的分享,欢迎你关注我噢!留在最后,我的Wechat:ohkuku