Koa2 搭建信令伺服器,JS 也能搞定視訊通話!

楊成功發表於2022-04-08

大家好,我是楊成功。

上一篇介紹了 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')

正常情況下,瀏覽器結果如下:

socket-broser.png

這裡的 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 伺服器。

加入學習群

本文來源公眾號:程式設計師成功。這裡主要分享前端工程與架構的技術知識,歡迎關注公眾號,點選“加群”一起加入學習隊伍,與大佬們一同探索學習進步!~

相關文章