技术规范

Runtime

  • NodeJs 14.16.0
  • npm 7.19.1

前端

  • Vue Cli 4.5.12
  • Vue 2.0
  • TypeScript 4.1.5

服务端

  • ws7.4.5
  • TypeScript 4.1.5

开发工具

  • Intellij IDEA

术语

通信 本文泛指基于TCP、UDP协议的Socket通讯技术,不限于基于Http协议的Web Socket、长连接。

通讯 本文特指基于WebRTC技术实现通讯。

基础结构

服务端实现基本通信、通信协议、通讯业务逻辑的拆分。

客户端实现基本通信、通信协议、单拨逻辑、组拨逻辑的拆分。

服务端

基本通信
|-- 通信协议
    |-- 通讯业务(信令)
        |- 通讯业务(实体)

客户端

基本通信
|-- 通信协议
    |-- 通讯业务(单拨)
    |-- 通讯业务(组拨)

服务端

需要记录每个用户、频道的状态,需要额外实现用户状态、频道状态的管理能力。在设计上,需要考虑以下三个方面:针对WebSocket符合业务逻辑需求的封装、频道状态管理逻辑封装、用户状态管理逻辑封装、以及业务事件的封装。

+ Web socket server
|--+ Web socket instance
   |--+ Business 
      |--+ Channel status managerment
      |--+ Client status managerment

基本通信

服务端与客户端的通信基于标准的WebSocket实现。

通信协议

由于存在频道通讯、事件驱动的概念,所以需要实现websocket的群发功能、事件驱动功能。

通讯业务

'''基础信令'''

用于维护客户端与服务器之间的连接、客户端的合法校验等等。

  • 注册(服务器一旦确定这是一个需要注册的通讯,客户端必须在服务器上注册,否则无法接受到任何来源于服务器的消息,亦无法对任何其他客户端发出消息)
  • 心跳(由客户端发起ping事件,携带''0x9'',服务器回复pong事件,携带''0xA'')

'''单拨信令'''

  • 单点呼叫(由主叫客户端发起到被叫用户的通讯)
  • 接受呼叫(由被叫客户端回应主叫客户端,接受呼叫)
  • 拒绝呼叫(由被叫客户端回应主叫客户端,接受呼叫)
  • 挂断通话(由主/被叫客户端主动关闭通讯)
  • 会话描述协议(用于转发主被叫客户端之间用于实时通讯所需会话描述协议的信令)
  • 网络链路候选协议(用于转发主被叫客户端之间用于实时通讯所需链路候选协议的信令)

'''组拨信令'''

  • 进入频道(用于客户端进入频道)
  • 离开频道(用于客户端离开频道)
  • 设置场景(用于设置频道的通讯场景)
  • 设置角色(用于设置客户端在频道内的角色,0为主持用户,1为发言用户,2为普通用户,3为听众用户)
  • 静音用户(用户主持用户或发言用户静音指定用户的媒体)
  • 抢麦(用于可发言用户申请发言权限,发言权限由角色权限决定)
  • 放麦(用于正在发言的用户放弃发言权限)
  • 会话描述协议(用于转发客户端之间用于实时通讯所需会话描述协议的信令)
  • 网络链路候选协议(用于转发客户端之间用于实时通讯所需链路候选协议的信令)

客户端

客户端同时需要考虑到管理来源于P2P通讯以及多方通讯导致的多个RTC实例的问题。

来源于对端客户的视频应尽量做到在RTC实例内存储,外部页面调用的方式。音频则默认在接收到的时候,就挂载到页面区域,在挂载逻辑处理的位置,因考虑在RTC链接销毁时音频节点取消挂载时的处理逻辑,该逻辑可以通过事件驱动方式实现。

对于本地音视频获取,在非必要的情况下,不推荐多次请求本地的媒体设备以获取媒体资源,多次请求媒体资源会导致RTC通讯出现不必要的卡顿,严重时会导致浏览器,甚至系统的崩溃,因此,本地音视频媒体资源,因尽可能采用单例模式获取。

+ Web socket instance
+ Client
|--+ RTC instance
|--+ Client action

基本通信

客户端基于标准的WebSocket通讯实现。

通信协议

考虑到封装接口、减少耦合等问题。对客户端的处理逻辑、符合业务需求的WebSocket行为的封装应分别处理。

通讯业务

对于socket与客户端之间的耦合关系,可以采用如下两种方案:

  • 抽象sender发送器。sender在webrtc通讯中,主要用于发送数据到服务器。由于不希望直接将socket内聚到websocket的实例中以免造成后期的维护困难,因此,需要单独将webrtc的sender抽象出来,在创建webrtc实例的时候,再将sender实现。抽象receiver接收器。receiver在webrtc的通讯中,主要用于从服务器接收数据,并从接收的数据去判断客户端的处理逻辑。由于很多事件是需要与webrtc实例进行沟通,所以,receiver接收器实现,应在考虑接收事件处理逻辑的同时,考虑通过事件驱动的方式,与webrtc实例进行事件通讯。
  • 直接将socket组合到客户端实例中。

const client = new Client();
client.setEmitter((sender) => {
    return socket.emitter;
});
client.setSender((sender) => {
    return socket.send;
});
client.setPromise(() => {
    return socket.promise;
});
client.setReceiver((handler) => {
   socket.onmessage = handler;
});
对于客户端与socket之间的事件处理,client内部会存在如下监听事件状态及耦合关系:

  • socket的监听事件触发client内部的监听事件,事件调内(socket监听器用$on单独封装)
  • client的内部方法触发client内部的监听事件,内调内(方法名前缀添加#
  • client的外部方法触发client内部的监听事件,外调内(方法名前缀添加!#
  • client的内部事件触发对外部方法对client的监听事件,内调外(方法名保持默认)

''注意:一般而言,以标准的NodeJS的EventEmitter为例,emit表示触发监听事件,on表示(注册)监听事件。''

socket              client             other
  |<-------$on--------|--+               |
  |--------emit------>|  | on            |
  |                   |<-+               |
  |                   |--+               |
  |                   |  | emit          |
  |                   |<-+               |
  |                   |<-------on--------|
  |                   |-------emit------>|

单拨

组拨

通讯场景

普通用户在进入频道内的时候,初始状态为自由发言状态(可以在服务器上配置默认的场景)。当具有频道管理权限的用户进入频道时,频道场景可依据管理人员设定进行变换。支持以下场景:

  • 自由发言。所有在频道内的用户可以自由发言
  • 自由开麦。所有在频道内的用户保持默认闭麦,可以通过发言按钮自由发言,不限制发言人数。
  • 平等抢麦。所有在频道内的用户保持默认闭麦,可以通过发言按钮进行发言,当存在用户发言时,只能等待用户发言完毕后才能发言。
  • 权限抢麦。所有在频道内的用户保持默认闭麦,可以通过发言按钮进行发言,用户角色权限大的用户将使正在发言的低权限用户闭麦,高权限用户发言,低权限用户无发言权力。
  • 全体禁言。所有在频道内的低权限用户保持默认闭麦状态,高权限用户具有通过发言按钮进行发言的权力。
  • 范围禁言。禁止指定权限范围的用户发言。

问题:

  • 开放接口的方案导致所有的用户权限配置、频道场景信息都从客户端上报,是否有其他解决方案?

注意

  • 默认情况下,用户进入频道以后,应默认保持静音状态。可以通过指定stream中每个trackonmute属性为false或通过addTranciver的方式保持默认媒体处于静音状态。
  • 一般执行removeTrack、replaceTrack后都不需要重新协商。

关键数据示例

offer description

// instance of RTCSesssionDescription
{
    sdp: "v=0\r\no=- 1599500056122793161 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=ice-ufrag:Vl1f\r\na=ice-pwd:lY5pSOEO6W5ELBLd4EHJt7a1\r\na=ice-options:trickle\r\na=fingerprint:sha-256 FD:E6:69:08:15:75:47:72:BC:1A:74:24:9D:B9:7C:BB:5F:32:29:0A:95:66:B6:27:EB:D5:36:23:18:A7:00:DB\r\na=setup:actpass\r\na=mid:0\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n"
    type: "offer"
}

// instance in transport
{
    "type": "offer",
    "sdp": "v=0\r\no=- 3031461765400133163 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=ice-ufrag:zYK7\r\na=ice-pwd:lE/U88X1nJQ5dXLPz3MU/AyC\r\na=ice-options:trickle\r\na=fingerprint:sha-256 9F:43:1B:B4:6B:D5:41:F2:48:F9:23:C8:9D:0C:02:CC:E0:23:72:AE:FF:B7:38:14:0E:08:7A:74:F0:D9:80:82\r\na=setup:actpass\r\na=mid:0\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n"
}

answer description

// instance of RTCSessionDescription
{
     sdp: "v=0\r\no=- 7008226251119912509 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=ice-ufrag:48jJ\r\na=ice-pwd:KXtSF/Vf+RBFT2Z0Emp4PQqB\r\na=ice-options:trickle\r\na=fingerprint:sha-256 48:4F:35:9A:61:B9:1F:54:63:F0:3C:7B:B3:F5:CE:21:63:16:9E:99:4A:CC:4E:44:03:6A:C5:A3:EB:B6:50:7C\r\na=setup:active\r\na=mid:0\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n"
    type: "answer"
}

// instance in transport
{
    "type": "answer",
    "sdp": "v=0\r\no=- 578059628300075476 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=ice-ufrag:HS6H\r\na=ice-pwd:LIhRvqPixeg8cFcASL9ct0nL\r\na=ice-options:trickle\r\na=fingerprint:sha-256 1C:14:9C:F0:05:C5:7F:D1:A7:4C:1E:01:92:99:53:8A:27:D5:5C:58:37:36:35:57:D7:E2:A0:BA:FF:9D:A0:9E\r\na=setup:active\r\na=mid:0\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n"
}

candidate

// instance of RTCIceCandidate
{
    address: "192.168.101.116"
    candidate: "candidate:1210791589 1 udp 2122260223 192.168.101.116 57053 typ host generation 0 ufrag 48jJ network-id 1"
    component: "rtp"
    foundation: "1210791589"
    port: 57053
    priority: 2122260223
    protocol: "udp"
    relatedAddress: null
    relatedPort: null
    sdpMLineIndex: 0
    sdpMid: "0"
    tcpType: null
    type: "host"
    usernameFragment: "48jJ"
}

// instance in transport
{
    "candidate": "candidate:1210791589 1 udp 2122260223 192.168.101.116 53519 typ host generation 0 ufrag zYK7 network-id 1",
    "sdpMid": "0",
    "sdpMLineIndex": 0
}
''注意:收到对端发来的candidate,可能需要通过new RTCIceCandidate()方法进行实例化。candidate会发送多次,此处仅取其中的一条作为示例。''

Transceiver

自定义属性

很遗憾,截止到WebRTC 1.0正式版,都无法自定义任何对等的信息以用于Transceiver的属性用于整个通讯。

确定通讯两侧Transceicer之间的对等关系

本地MediaStream的Track添加到RTCPeerConnection后,Sender中Track ID与本地MediaStream的Track ID一致,但对侧接收到的Track,Track的ID与发送的Track的ID并不一致,猜测这是由于Connection会预先建立一个用于接收媒体轨道的空轨道导致的。那么,接收侧如何确定与发送侧之间Transceiver的对应关系呢?

有三种办法,一是通过Transceiver的mid识别是使用的哪一个Transceiver,在onTrack方法中可以得到有效的Transceiver以获取mid;二是通过在addTrack或addTransceiver的时候指定stream对象,通过stream的id识别是哪一个发送器发送的stream,同样在onTrack中可以获取到stream以得到有效的stream的id;三是直接操作sdp,当然这样做的风险非常大,甚至难以在无法建立通讯的时候找到无法建立通讯的原因。[https://stackoverflow.com/questions/55196136/is-there-any-way-to-add-custom-property-to-a-transceiver-in-unified-plan Is there any way to add custom property to a transceiver in Unified plan]