乐趣区

关于前端:Koa2-搭建信令服务器JS-也能搞定视频通话

大家好,我是杨胜利。

上一篇介绍了 WebRTC 是什么,它的通信流程有哪些步骤,并搭建了本地通信的 Demo,最初讲了一对多实现的思路,原文地址:音视频通信加餐 —— WebRTC 一探到底

在这篇文章里,咱们介绍局域网两端通信的时候,用到了 信令服务器 去传输 SDP。过后咱们没有认真介绍信令服务器,只是用两个变量来模仿连贯。

在理论利用场景中,信令服务器的实质就是一台 WeSocket 服务器,两个客户端必须与这个服务器建设 WeSocket 连贯,能力相互发送音讯。

然而信令服务器的作用不仅仅是发送 SDP。多端通信咱们个别是和某一个人或者某几个人通信,须要对所有连贯分组,这在音视频通信中属于“房间”的概念。信令服务器的另一个作用是保护客户端连贯与房间的绑定关系。

那么这篇文章,就基于 Node.js 的 Koa2 框架,带大家一起实现一个信令服务器。

纲要预览

本文介绍的内容包含以下方面:

  • 再谈信令
  • koa 遇见 ws
  • 如何保护连贯对象?
  • 发动端实现
  • 接收端实现
  • Ready,传令兵开跑!
  • 退出学习群

再谈信令

上一篇咱们讲到,一个局域网内的两个客户端,须要单方屡次调换信息能力建设 WebRTC 对等连贯。发送信息是各端被动发动,另一端监听事件接管,因而实现计划是 WebSocket

而基于 WebSocket 近程调换 SDP 的过程,被称为 信令

事实上,WebRTC 并没有规定用什么样的形式实现信令。也就是说,信令并不是 WebRTC 通信标准的一部分。比方咱们在一个页面实现两个 RTCPeerConnection 实例的通信,整个连贯过程就不须要信令呀。因为单方的 SDP 都定义在一个页面,咱们间接获取变量就能够。

只不过在多个客户端的状况下,单方须要相互获取对方的 SDP,因而才有信令一说。

koa 遇见 ws

咱们用 Node.js 搭建信令服务器,有两个局部最要害:

  1. 框架:Koa2
  2. 模块:ws

Node.js 开发须要抉择一个适宜的框架,之前始终用 Express,这次尝尝 Koa2 香不香。不过它们两个相差不大,可能某些 API 或者 npm 包有些差别,根本构造简直都一样。

ws 模块是非常简单纯正的 WebSocket 实现,蕴含客户端和服务端。我在这篇文章 前端架构师破局技能,NodeJS 落地 WebSocket 实际 中具体介绍了 ws 模块的用法和如何与 express 框架集成,不理解 ws 模块的能够看这篇。

这里咱们间接开始搭建 Koa2 的构造以及引入 ws 模块。

koa 我的项目构造搭建

首先是初始化我的项目并装置:

$ npm init && yarn add koa ws

创立实现之后,生成了 package.json 文件,而后在同级目录增加三个文件夹:

  • routers:寄存独自路由文件
  • utils:寄存工具函数
  • config:寄存配置文件

结下来编写最重要的入口文件,根底构造如下:

const Koa = require('koa')
const app = new Koa()

app.use(ctx => {ctx.body = 'Hello World'})

server.listen(9800, () => {console.log(`listen to http://localhost:9800`)
})

看到没,和 express 根本一样,都是实例化之后,设置一个路由,监听一个端口,一个简略的 web 服务器就启动了。

这里要说的比拟大的区别,就是她们的 中间件函数 的区别。中间件函数就是应用 app.use 或者 app.get 时传入的回调函数,更多中间件常识参阅这里。

中间件函数的参数蕴含了 申请 响应 两大块的要害信息,在 express 中应用两个参数别离示意,而在 koa 中将这两个对象合在了一起,只用一个参数示意。

express 的示意办法如下:

app.get('/test', (req, res, next) => {
  // req 是申请对象,获取申请信息
  // res 是响应对象,用于响应数据
  // next 进入下一个中间件
  let {query} = req
  res.status(200).send(query)
})

而 koa 是这样的:

app.get('/test', (ctx, next) => {
  // ctx.request 是申请对象,获取申请信息
  // ctx.response 是响应对象,用于响应数据
  // next 进入下一个中间件
  let {query} = ctx
  ctx.status = 200
  ctx.body = query
})

尽管说 ctx.request 示意申请对象,ctx.response 示意响应对象,然而 koa 又把罕用的一些属性间接挂到了 ctx 下面。比方 ctx.body 示意的是响应体,那要获取申请体怎么办呢?得用 ctx.request.body,而后获取 URL 参数又是 ctx.query,总之用起来感觉比拟凌乱,这部分集体还是喜爱 express 的设计。

根底构造是这样,咱们还要做两个解决:

  • 跨域解决
  • 申请体解析

跨域嘛不用说,做前端的都懂。申请体解析是因为 Node.js 接管申请体基于流的形式,不能间接获取,因而须要独自解决下,不便用 ctx.request.body 间接获取到。

首先装置两个 npm 包:

$ yarn add @koa/cors koa-bodyparser

而后在 app.js 中配置下即可:

const cors = require('@koa/cors')
const bodyParser = require('koa-bodyparser')

app.use(cors())
app.use(bodyParser())

ws 模块集成

实质上来说,WebSocket 与 Http 是两套服务,尽管都集成在一个 Koa 框架外面,但它们实际上各自独立。

因为同在一个 Koa 利用,所以咱们心愿 WebSocket 与 Http 能够共用一个端口,这样的话,启动 / 销毁 / 重启 这些操作,咱们只需管制一处就能够了。

要共用端口,首先对下面入口文件 app.js 做一些革新:

const http = require('http')
const Koa = require('koa')

const app = new Koa()
const server = http.createServer(app.callback())

server.listen(9800, () => {console.log(`listen to http://localhost:9800`)
}) // 之前是 app.listen

而后咱们在 utils 目录下新建 ws.js

// utils/ws.js
const WebSocketApi = (wss, app) => {wss.on('connection', (ws, req) => {console.log('连贯胜利')
  }
}

module.exports = WebSocketApi

再将这个文件引入 app.js 中,增加代码如下:

// app.js
const WebSocket = require('ws')
const WebSocketApi = require('./utils/ws')

const server = http.createServer(app.callback())
const wss = new WebSocket.Server({server})

WebSocketApi(wss, app)

此时从新运行 node app.js,而后关上浏览器控制台,写一行代码:

var ws = new WebSocket('ws://localhost:9800')

失常状况下,浏览器后果如下:

这里的 readyState=1 阐明 WebSocket 连贯胜利了。

如何保护连贯对象?

上一步集成了 ws 模块,并且测试连贯胜利,咱们把 WebSocket 的逻辑都写在 WebSocketApi 这个函数内。上面咱们持续看这个函数。

// utils/ws.js
const WebSocketApi = (wss, app) => {wss.on('connection', (ws, req) => {console.log('连贯胜利')
  }
}

函数接管了两个参数,wss 是 WebSocket 服务器的实例,app 是 Koa 利用的实例。兴许你会问这里 app 有什么用?其实它的作用很简略:设置全局变量

信令服务器的次要作用,就是找到连贯的两方并传递数据。那么当有许多客户端连贯到服务器的时候,咱们就须要在泛滥的客户端中,找到相互通信的两方,因而要对所有的客户端连贯做 标识 分类

上述代码监听 connection 事件的回调函数中,第一个参数 ws 就示意一个已连贯的客户端。ws 是一个 WebSocket 实例对象,调用 ws.send() 就能够向该客户端发送音讯。

ws.send('hello') // 发消息
wss.clients // 所有的 ws 连贯实例

为 ws 做标识很简略,就是增加一些属性用于辨别。比方增加 user_idroom_id 等,这些标识能够在客户端连贯的时候作为参数传过来,而后通过上述代码中的 req 参数中获取。

设置完标识后,将这个“有名有姓”的 ws 客户端保存起来,前面就能找失去了。

然而怎么保留?也就是如何保护连贯对象?这个问题须要认真思考。WebSocket 连贯对象是在内存当中,它与客户端连贯实时开启的。所以咱们要把 ws 对象存到内存里,形式之一就是设置在 Koa 利用的全局变量当中,这也是结尾说到 app 参数的意义。

Koa 利用的全局变量在 app.context 下增加,所以咱们以“发动端”和“接收端”为组,创立两个全局变量:

  • cusSender:数组,保留所有发动端的 ws 对象
  • cusReader:数组,保留所有接收端的 ws 对象

而后在别离获取这两个变量和申请参数:

// utils/ws.js
const WebSocketApi = (wss, app) => {wss.on('connection', (ws, req) => {let { url} = req // 从 url 中解析申请参数
    let {cusSender, cusReader} = app.context
    console.log('连贯胜利')
  }
}

申请参数从 url 当中解析,cusSender, cusReader 是两个数组,保留了 ws 的实例,接下来所有的连贯查找和状态保护,都是在这两个数组上面操作。

发动端实现

发动端是指发动连贯的一端,发动端连贯 WebSocket 须要携带两个参数:

  • rule:角色
  • roomid:房间 id

发动端的 role 固定为 sender,作用只是标识这个 WebSocket 是一个发动角色。roomid 示意以后这个连贯的惟一 ID,一对一通信时,能够是以后的用户 ID;一对多通信时,会有一个相似“直播间”的概念,roomid 就示意一个房间(直播间)ID。

首先在客户端,发动连贯的 URL 如下:

var rule = 'sender',
  roomid = '354682913546354'
var socket_url = `ws://localhost:9800/webrtc/${rule}/${roomid}`
var socket = new WebSocket(socket_url)

这里为示意 webrtc 的 WebSocket 连贯增加一个 url 前缀 /webrtc,同时咱们把参数间接带到 url 里,因为 WebSocket 不反对自定义申请头,只能在 url 里携带参数。

服务端接管 sender 代码如下:

wss.on('connection', (ws, req) => {let { url} = req // url 的值是 /webrtc/$role/$uniId
  let {cusSender, cusReader} = app.context
  if (!url.startsWith('/webrtc')) {return ws.clode() // 敞开 url 前缀不是 /webrtc 的连贯
  }
  let [_, role, uniId] = url.slice(1).split('/')
  if(!uniId) {console.log('短少参数')
    return ws.clode()}
  console.log('已连贯客户端数量:', wss.clients.size)
  // 判断如果是发动端连贯
  if (role == 'sender') {
    // 此时 uniId 就是 roomid
    ws.roomid = uniId
    let index = (cusReader = cusReader || []).findIndex(row => row.userid == ws.userid)
    // 判断是否已有该发送端,如果有则更新,没有则增加
    if (index >= 0) {cusSender[index] = ws
    } else {cusSender.push(ws)
    }
    app.context.cusSender = [...cusSender]
  }
}

如上代码,咱们依据 url 中解析出的 sender 来判断以后连贯属于发送端,而后为 ws 实例绑定 roomid,再依据条件更新 cusSender 数组,这样保障了即便客户端屡次连贯(如页面刷新),实例也不会反复增加。

这是发动连贯的逻辑,咱们还要解决一种状况,就是敞开连贯时,要革除 ws 实例:

wss.on('connection', (ws, req) => {ws.on('close', () => {if (from == 'sender') {
      // 革除发动端
      let index = app.context.cusSender.findIndex(row => row == ws)
      app.context.cusSender.splice(index, 1)
      // 解绑接收端
      if (app.context.cusReader && app.context.cusReader.length > 0) {
        app.context.cusReader
          .filter(row => row.roomid == ws.roomid)
          .forEach((row, ind) => {app.context.cusReader[ind].roomid = null
            row.send('leaveline')
          })
      }
    }
  })
})

接收端实现

接收端是指接管发动端的媒体流并播放的客户端,接收端连贯 WebSocket 须要携带两个参数:

  • rule:角色
  • userid:用户 id

角色 role 与发动端的作用一样,值固定为 reader。连接端咱们能够看作是一个用户,所以发动连贯时传递一个以后用户的 userid 作为惟一标识与该连贯绑定。

在客户端,接管方连贯的 URL 如下:

var rule = 'reader',
  userid = '6143e8603246123ce2e7b687'
var socket_url = `ws://localhost:9800/webrtc/${rule}/${userid}`
var socket = new WebSocket(socket_url)

服务端接管 reader 发送音讯的代码如下:

wss.on('connection', (ws, req) => {
  // ... 省略
  if (role == 'reader') {
    // 接收端连贯
    ws.userid = uniId
    let index = (cusReader = cusReader || []).findIndex(row => row.userid == ws.userid)
    // ws.send('ccc' + index)
    if (index >= 0) {cusReader[index] = ws
    } else {cusReader.push(ws)
    }
    app.context.cusReader = [...cusReader]
  }
}

这里 cusReader 的更新逻辑与下面 cusSender 统一,最终都会保障数组内存储的只是连贯中的实例。同样的也要做一下敞开连贯时的解决:

wss.on('connection', (ws, req) => {ws.on('close', () => {if (role == 'reader') {
      // 接收端敞开逻辑
      let index = app.context.cusReader.findIndex(row => row == ws)
      if (index >= 0) {app.context.cusReader.splice(index, 1)
      }
    }
  })
})

Ready,传令兵开跑!

后面两步咱们实现了对客户端 WebSocket 实例的信息绑定,以及对已连贯实例的保护,当初咱们能够接管客户端传递的音讯,而后将音讯传给指标客户端,让咱们的“传令兵”带着信息开跑吧!

客户端内容,咱们持续看 上一篇文章 中 局域网两端通信 一对多通信 的局部,而后从新残缺的梳理一下通信逻辑。

首先是发动端 peerA 和接收端 peerB 均曾经连贯到信令服务器:

// peerA
var socketA = new WebSocket('ws://localhost:9800/webrtc/sender/xxxxxxxxxx')
// peerB
var socketB = new WebSocket('ws://localhost:9800/webrtc/reader/xxxxxxxxxx')

而后服务器端监听发送的音讯,并且定义一个办法 eventHandel 解决音讯转发的逻辑:

wss.on('connection', (ws, req) => {
  ws.on('message', msg => {if (typeof msg != 'string') {msg = msg.toString()
      // return console.log('类型异样:', typeof msg)
    }
    let {cusSender, cusReader} = app.context
    eventHandel(msg, ws, role, cusSender, cusReader)
  })
})

此时 peerA 端曾经获取到视频流,存储在 localStream 变量中,并开始直播。咱们上面开始梳理 peerB 端与其连贯的步骤。

第 1 步:客户端 peerB 进入直播间,发送一个退出连贯的音讯:

// peerB
var roomid = 'xxx'
socketB.send(`join|${roomid}`)

留神,socket 信息不反对发送对象,把须要的参数都转换为字符串,以 | 宰割即可

而后在信令服务器端,监听到 peerB 发来的这个音讯,并找到 peerA,发送连贯对象:

const eventHandel = (message, ws, role, cusSender, cusReader) => {if (role == 'reader') {let arrval = data.split('|')
    let [type, roomid] = arrval
    if (type == 'join') {let seader = cusSender.find(row => row.roomid == roomid)
      if (seader) {seader.send(`${type}|${ws.userid}`)
      }
    }
  }
}

第 2 步:发动端 peerA 监听到 join 事件,而后创立 offer 并发给 peerB

// peerA
socketA.onmessage = evt => {
  let string = evt.data
  let value = string.split('|')
  if (value[0] == 'join') {peerInit(value[1])
  }
}
var offer, peer
const peerInit = async usid => {
  // 1. 创立连贯
  peer = new RTCPeerConnection()
  // 2. 增加视频流轨道
  localStream.getTracks().forEach(track => {peer.addTrack(track, localStream)
  })
  // 3. 创立 SDP
  offer = await peer.createOffer()
  // 4. 发送 SDP
  socketA.send(`offer|${usid}|${offer.sdp}`)
}

服务器端监听到 peerA 发来音讯,再找到 peerB,发送 offer 信息:

// ws.js
const eventHandel = (message, ws, from, cusSender, cusReader) => {if (from == 'sender') {let arrval = message.split('|')
    let [type, userid, val] = arrval
    // 留神:这里的 type, userid, val 都是通用值,不论传啥,都会原样传给 reader
    if (type == 'offer') {let reader = cusReader.find(row => row.userid == userid)
      if (reader) {reader.send(`${type}|${ws.roomid}|${val}`)
      }
    }
  }
}

第 3 步:客户端 peerB 监听到 offer 事件,而后创立 answer 并发给 peerA

// peerB
socketB.onmessage = evt => {
  let string = evt.data
  let value = string.split('|')
  if (value[0] == 'offer') {transMedia(value)
  }
}
var answer, peer
const transMedia = async arr => {let [_, roomid, sdp] = arr
  let offer = new RTCSessionDescription({type: 'offer', sdp})
  peer = new RTCPeerConnection()
  await peer.setRemoteDescription(offer)
  let answer = await peer.createAnswer()
  await peer.setLocalDescription(answer)
  socketB.send(`answer|${roomid}|${answer.sdp}`)
}

服务器端监听到 peerB 发来音讯,再找到 peerA,发送 answer 信息:

// ws.js
const eventHandel = (message, ws, from, cusSender, cusReader) => {if (role == 'reader') {let arrval = message.split('|')
    let [type, roomid, val] = arrval
    if (type == 'answer') {let sender = cusSender.find(row => row.roomid == roomid)
      if (sender) {sender.send(`${type}|${ws.userid}|${val}`)
      }
    }
  }
}

第 4 步:发动端 peerA 监听到 answer 事件,而后设置本地形容。

// peerA
socketB.onmessage = evt => {
  let string = evt.data
  let value = string.split('|')
  if (value[0] == 'answer') {
    let answer = new RTCSessionDescription({
      type: 'answer',
      sdp: value[2]
    })
    peer.setLocalDescription(offer)
    peer.setRemoteDescription(answer)
  }
}

第 5 步:peerA 端监听并传递 candidate 事件并发送数据。该事件会在上一步 peer.setLocalDescription 执行时触发:

// peerA
peer.onicecandidate = event => {if (event.candidate) {let candid = event.candidate.toJSON()
    socketA.send(`candid|${usid}|${JSON.stringify(candid)}`)
  }
}

而后在 peerB 端监听并增加 candidate:

// peerB
socket.onmessage = evt => {
  let string = evt.data
  let value = string.split('|')
  if (value[0] == 'candid') {let json = JSON.parse(value[1])
    let candid = new RTCIceCandidate(json)
    peer.addIceCandidate(candid)
  }
}

好了,这样就功败垂成了!!

本篇的内容比拟多,倡议肯定要入手跟着写一遍,你才会整明确一对多通信的流程。当然本章还没有实现网络打洞,信令服务器能够部署在服务器上,然而 WebRTC 客户端还得在局域网内能力连通。

下一篇,也就是 WebRTC 系列第三篇,咱们实现 ICE 服务器。

退出学习群

本文起源公众号:程序员胜利 。这里次要分享前端工程与架构的技术常识,欢送关注公众号,点击“ 加群”一起退出学习队伍,与大佬们一起摸索学习提高!~

退出移动版