ZHANGYU.dev

October 14, 2023

JsSIP踩坑笔记

JavaScript5.5 min to read

近日做的一个功能是页面打电话,使用了WebRTC的技术,实际上使用了JsSIP后,难度就直线下降到库的使用了,光学习库的使用是没有意义的,所以还是得先了解一下WebRTC的原理

原理简述

WebRTC是点到点,数据通道是P2P的,但是依旧需要服务器的支持,服务器的作用基本就是传输WebRTC所需要的信令信息,告诉浏览器端应该如何连接

在连接过程中,比较重要的点就是Network Address Translation (NAT)穿透,通常也叫做打洞,因为为了解决ipv4地址不足,大多数客户端都处在路由器的内网环境,比如内网地址为192.168.1.3,这时候需要绕过防火墙,建立一个在公网可见的唯一地址,通过NAT穿透,这个地址会被映射为公网IP+唯一端口,如182.150.184.98:52054

而这个穿透的过程通常使用Interactive Connectivity Establishment (ICE),也就是ICE协议框架,在此协议框架里,有两种服务器,分别为Session Traversal Utilities for NAT(STUN)服务器,和Traversal Using Relays around NAT(TURN)服务器,一般情况只会使用到STUN服务器,获得公网唯一可见地址后两端便可建立连接,但是如果路由器不允许主机直连,就需要TURN服务器来转发数据

STUN连接过程图示

image

TURN连接过程图示

image

JsSIP踩坑笔记

SIP协议和上面的WebRTC其实没有太大关系,通常的WebRTC并不会使用SIP协议,SIP协议用于发起、维持和终止实时会话包括语音、视频、消息的应用程序

JsSIP它主要就是解析SIP信令,让我们和服务器知道现在应该打电话,还是接电话,顺便封装了WebRTC的东西,不需要手动建立RTCPeerConnection连接

看看它官网的示例

var socket = new JsSIP.WebSocketInterface('wss://sip.myhost.com');var configuration = {  sockets  : [ socket ],  uri      : 'sip:alice@example.com',  password : 'superpassword'};var ua = new JsSIP.UA(configuration);ua.start();// 注册打电话的回调事件var eventHandlers = {  'progress': function(e) {    console.log('call is in progress');  },  'failed': function(e) {    console.log('call failed with cause: '+ e.data.cause);  },  'ended': function(e) {    console.log('call ended with cause: '+ e.data.cause);  },  'confirmed': function(e) {    console.log('call confirmed');  }};var options = {  'eventHandlers'    : eventHandlers,  'mediaConstraints' : { 'audio': true, 'video': true }};var session = ua.call('sip:bob@example.com', options);

先说基本流程

它的问题在于事件绑定有点迷惑,我现在也不是特别懂,它有2种事件绑定方式

ua绑定事件

ua.on('connected', () => console.log('[SIP Phone] : Connected (On Call)'));ua.on('registered', () => console.log('[SIP Phone] : Registered (On Call)'));ua.on('registrationFailed', () => console.log('[SIP Phone] : Registration Failed (On Call)'));

这是连接、注册、注册失败的事件,有一个最重要的事件,是newRTCSession,代表有新的通话

ua.on('newRTCSession', (e) => {  // 其中能拿到session对象和originator对象  // originator代表通话时本地呼出还是远程呼入  const { session, originator } = e;  // session绑定的事件才是最重要的  // 连接中  session.on('connecting', () => {});  // 连接已接受  session.on('accepted', () => {});  // 接通,在这一步可以处理音频播放  // 接通并不代表对方已经接受,接通代表 滴 滴 滴  session.on('confirmed', () => {});  // 结束  session.on('ended', () => {});  // 失败  session.on('failed', () => {});  // 手动让打孔结束,最多4次,有时候等待时间会很长  let iceCandidateCount = 0;  session.on('icecandidate', (data) => {    if (iceCandidateCount++ > 4) data.ready();  });}

事件注册上了,那么如何播放音频呢?

通过查阅了很多资料,发现了至少3种方式

const audioElement; // 获取到的页面audio元素ua.on('newRTCSession', (e) => {  const { session } = e;  // session.connection代表的即是RTCPeerConnection实例对象  session.on('confirmed', (event) => {    // 第一种方式,已被废弃掉,但是chrome 80可用,此api在MDN上无法找到    audioElement.srcObject = session.connection.getRemoteStreams()[0];    // 第二种方式,已被废弃掉了,但是还是可用    session.connection.onaddstream = (e) => {      audioElement.srcObject = e.stream;    }    // 第三种方式,未被废弃,呼出触发,呼入不触发……    session.connection.ontrack = (e) =>{      audioElement.srcObject = e.streams[0]    }    // 前三种都被废弃了,最后研究了半天    // 闪亮登场的最终方式    const stream = new MediaStream();    const receivers = session.connection?.getReceivers();    if (receivers) receivers.forEach((receiver) => stream.addTrack(receiver.track));		audioElement.srcObject = stream;    // 最后都要播放    audioElement.play();  });}

确定了如何将声音播放了,下一步研究如何接电话

接电话、挂电话

在流程里,应该是这样的

ua.on('newRTCSession', (e) => {  const { session } = e;  session.answer();}

但是这样实际肯定行不通,因为这个事件肯定是需要绑定到按钮上,所以需要把这个事件挪出来,所以需要一个变量保存session对象

function sip() {  // ...  let currentSession;  ua.on('newRTCSession', (e) => {    const { session } = e;    currentSession = session;  });  // 包一层接电话函数  const answer = (options) => {    try {      if (currentSession) currentSession.answer(options);      else console.error('接通时RTCSession对象为空');    } catch (e) {      console.warn('电话接通失败:', e.message);    }  };  return {    answer  };}

打电话和挂电话同理,都需要将事件返回出去,挂在元素上,最后封装后应该这样返回

return {  call, // 打电话  terminate, // 挂电话  answer, // 接听};

在封装过程中,有一个比较重要的点就是this,实际上session的每一个回调都可以用this访问session对象,在箭头函数写多了、函数体里不使用this的规则后都忘记了this这个东西了

所以可以直接从外部传入一个整体回调,它们都this都可以访问session对象,所以也可以自己加自定义的回调,最后使用call绑定this就好了

// 当前状态export enum Originator {  Local = 'local',  Remote = 'remote',  Idle = 'idle',}// 可传入的回调export interface CallEventHandlers {  connecting?(this: RTCSession, event: SessionConnectingEvent): void;  accepted?(this: RTCSession, event: SessionAcceptedEvent): void;  confirmed?(this: RTCSession, event: SessionConfirmedEvent, stream: MediaStream): void;  ended?(this: RTCSession, event: SessionEndedEvent): void;  failed?(this: RTCSession, event: SessionFailedEvent): void;  // 自定义当有新来电时的事件  newCall?(this: RTCSession, originator: Originator): void;  // 自定义电话呼叫滴滴滴后对方接通的事件  connected?(this: RTCSession, originator: Originator): void;}

这里还有一个坑,我没有找到官方对应的解决方案,就是呼出 滴 滴 滴后,没有对方接听的事件,这个事件对应我上面的connected事件,我这里的解决方式是后台得在写一个websocket,通过这个来告知我是否接听

总结

使用JsSIP不需要处理SIP信令,也不需要学WebRTC相关知识,虽然个人觉得它的事件形式不太好,ts类型也不完善,但是已然是一个非常好,非常简单易用的库了

个人认为页面打电话这一套的技术难点主要还是在后台,所以我很佩服我们这儿的后台,我脑子里完全无法想象他一个人是怎么搞设备插了几百张卡,又怎么连接到linux,又怎么搭STUNTURN服务器,又怎么写服务能和我通话,真到牛皮

我觉得也许SIP协议都不应该放在前端处理,应该服务端再搞一个SIP协议解析的中间件,前端单纯使用WebRTC就更好了,说起来本来觉得这个打电话没什么难度,周末想自己学习WebRTC写一个在线的视频demo,结果好像难度很大的样子就放弃了,今天想想怎么能轻言放弃,下周一定得搞一搞


参考资料:https://developer.mozilla.org/zh-CN/docs/Web/API/WebRTC_API