WebRTCとSignalingを体験する

ブラウザでWebRTCを試したくサンプルアプリを作りました。
また、WebRTCで接続するにはインターネット上にSignalingサーバが必要ですが、それもherokuの代替サービスとして知られているRender.comで立ち上げます。

ソースコードは以下にあげてあります。

https://github.com/poruruba/WebRTC_Signaling_Console

本投稿では、Render.comでSignalingサーバとWebサーバを立ち上げ、複数のクライアントからWebRTCの接続を試してみます。

ブラウザで動作するので、Windowsだけでなく、iMac、Android、iPhoneのブラウザでも動作しますので、ぜひいろんな端末で確かめてみてください。
また、今回立ち上げるシステムだけで動作するので、他のシステムとは連携せず単独で動作します。

以下の順番に説明します。
* システム構成  
* セットアップ  
* 使い方 （すぐに使ってみる）
* 通信シーケンス  

# システム構成

構成要素としては、Signalingサーバ、WebAPIサーバ、Webサーバ、Webページです。
Webページは、Javascriptだけで、ブラウザだけで動作するSPAです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/65c7bbd7-d03b-4d65-affc-41e6091b673c.png)

## 構成要素

各構成要素の役割について説明します。

* Signalingサーバ  
2つの役割があります。1つが各クライアントが存在するローカルネットワークからNATを超えて互いに通信するためのIPアドレス情報を交換すること。2つ目が、通信するビデオや音声のフォーマットに関する情報を交換することです。前者がICE(Interactive Connectivity Establishment)、後者がSDP(Session Description Protocol)と呼んだりします。いろいろな通信プロトコルの実装方法がありますが、今回は一般的なWebSocketを使って実現しています。

* Webページ  
クライアントでは、各クライアントのブラウザで動作します。内容は、単にHTMLとJavascriptからなる静的なコンテンツです。デプロイするためのビルドは不要な構成にしています。WebRTCの基本的な機能はW3Cで仕様化されており各OSのブラウザに組み込まれています。それにより、OSが異なっても同じように動作します。

* Webサーバ  
Webページを提供するサーバです。HTMLやJavascriptをHTTP GETできるようにしているだけで、特に複雑なことはしていません。

* WebAPIサーバ  
任意のクライアント先に接続できるように、現在接続されているクライアント一覧をJSONで取得するためのWebAPIサーバです。後でわかりますが、必須ではありません。

## Signalingサーバの用語

ここで、今回実装したSignalingサーバにおける用語の定義を説明します。
WebRTCで定義されているものではなく、今回の実装独自のものです。

* クライアントID  
接続するクライアントの識別名です。名前は任意ですが、各クライアントは必ず異なる名前にします。

* チャネルID  
接続先を識別するための名前です。Signalingサーバに接続するクライアントすべてが互いに接続できるのではなく、同じチャネルIDを指定したクライアントが互いに接続します。チャットルーム名のようなものです。

* パスワード  
チャネルIDで区別されたところに接続するにはパスワードが必要です。一番最初にチャネルIDで接続したクライアントが指定したパスワードが設定されます。以降接続するクライアントは同じパスワードを指定する必要があります。

* LocalStream  
自分のクライアントから提供する映像や音声です。以下の種類から選べるようにしました。
　・カメラ(user)  
　・カメラ(environment)  
　・スクリーン  
　・なし  

デスクトップPCの場合、USBにWebカメラを接続している場合は、カメラ(user)を使います。スマホでは、カメラ(user)のほかに、カメラ(environment)も選べます。内向きのカメラと外向きのカメラに該当します。
スクリーンは、主にデスクトップで選択でき、表示されるウィンドウや画面全体をキャプチャして相手に送ることができます。スマホでは選べなさそうです。

## Signaligサーバ接続時のロール

Signalingサーバの仲介において、お互いのクライアントの接続方法として3種類があります。ロールと呼んでいます。下記のロールはいずれも、同じチャネルIDとすることで互いに接続できます。

* master  
自身のLocalStreamをたくさんのクライアント(slave)に配信する場合に選択します。master 1つに対して、複数のslaveがつながります。

* slave  
masterに接続するクライアントです。同時に1つのmasterと接続できます。

* direct  
任意のクライアントに接続するためのロールです。相手がdirectである必要があります。

# セットアップ

まずは、Render.comでアカウントを作っておいてください。
作成後、Dashboardを開きます。

https://dashboard.render.com/

右上の「＋ Add new」ボタンを押下して、「Web Service」を選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/a33e5cfb-91f6-41db-9376-9ed8bc80e7a0.png)

「Public Git Repository」タブを選択し、テキスト入力のところに、以下を入力し、「Connect →」ボタンを押下します。

```https://github.com/poruruba/WebRTC_Signaling_Console```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/50311769-9746-4087-b30a-247ceb2a4b72.png)

その後表示されるページで、Instance Typeとして、「Free」を選択し、ページの下のほうにある「Deploy Web Service」ボタンを押下します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/e9247b31-2156-4abd-8383-329159aaddcd.png)

以下のように、表示されればDeploy成功です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/b6a1af05-259f-41ac-95a6-4d8b834ab1e4.png)

このあたりに、URLのリンクがありますので、クリックしてブラウザの別タブを開きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/5c95f15d-037e-4816-98d2-924e76ee8466.png)

こんなページが表示されるはずです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/a3586931-6db6-441b-a099-3484bb92a7b3.png)

これでセットアップ完了です。

# すぐに使ってみる(master-slave接続)

まずはWebRTCっぽいmaster-slave接続の方法です。

〇master側： 以下のように入力します。

ロール： master  
localStream：任意（例：カメラ(user)）  
クライアントID：任意（例：test）  
チャネルID：任意（例：test1）  
パスワード：任意（例：なし）  

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/7e07f3ca-ae72-43da-b83e-b9d08242a6de.png)

「接続」ボタンを押下すると、Webカメラが起動して、ページの右側にWebカメラの映像が表示されるかと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/208e740b-c3ae-4ebe-873e-e1b31c5d539b.png)

次に、別のクライアントのブラウザから接続します。
もし用意ができなければ、masterとは別のブラウザ（例えば、ChromeとEdge）からページを開きます。

〇slave側：  以下のように入力します。

ロール： slave
localStream：任意（例：スクリーン）  
クライアントID：master側と同じ値（例：test）  
チャネルID：任意（例：test2）  
パスワード：任意（例：なし）  

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/48dbe8b9-5006-4f8b-afff-36b48a24544b.png)

「接続」ボタンを押下すると、ウィンドウまたは画面全体を選択するダイアログが表示されますので、いずれかを選択して、「共有」ボタンを押下します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/e4da1f75-9d05-4bbf-8457-75ad2b8535ba.png)

そうすると、接続先を選択するためのダイアログが表示されますので、masterのクライアントIDにある「接続」ボタンを押下することで接続されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/92af03cc-00c5-41a2-8037-c3d73c46b621.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/90c73e52-ed41-4cc1-8dc6-b06893c88a2e.png)

一方で、master側のブラウザには、クライアントが接続された旨のトーストが表示され、RemoteClientListから接続されたクライアントのクライアントIDが選択できるようになります。「せんたく」ボタンをクリックすると、そのクライアントが配信している映像が表示されます。1つのmasterに複数のslaveが接続された場合、複数のクライアントから選択することになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/95cc46e5-f188-43f0-b1f4-b1c2faec457b.png)

# すぐに使ってみる(direct接続)

次に単純な、direct接続での方法です。

〇クライアント1側：  以下のように入力します。

ロール： direct
localStream：任意（例：カメラ(user)）  
クライアントID：任意（例：test）  
チャネルID：任意（例：test1）  
パスワード：任意（例：なし）  

「接続」ボタンを押下すると、Webカメラが起動して、ページの右側にWebカメラの映像が表示されるかと思います。

次に、別のクライアントのブラウザから接続します。
もし用意ができなければ、クライアント1とは別のブラウザ（例えば、ChromeとEdge）からページを開きます。

〇クライアント2側：  以下のように入力します。

ロール： direct
localStream：任意（例：スクリーン）  
クライアントID：master側と同じ値（例：test）  
チャネルID：任意（例：test2）  
パスワード：任意（例：なし）  

「接続」ボタンを押下すると、ウィンドウまたは画面全体を選択するダイアログが表示されますので、いずれかを選択して、「共有」ボタンを押下します。

そうすると、それぞれのブラウザの右側に「チャネルリスト」というボタンが表示されるので、押下します。

 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/85857ade-2bb4-4ec0-8a37-38ce69824428.png)

そうすると、同じチャネルIDでdirect接続しているクライアント一覧が表示されるので、つなぎたいクライアントの「接続」ボタンを押すと、接続できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/9e89dd55-c4a7-462e-93ff-2d09eec7690a.png)

# 処理フロー

masterとslave間の処理フローを以下に示します。
処理には、接続先を選択するまでの流れと、選択後にWebRTCのセッションを確立するまでの流れに分かれます。いずれも今回立ち上げたSignalingサーバが仲介します。

まずは、先にmaster側が接続して、後からslave側が接続するパターンです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/4d94e730-2b8c-4bcc-be2f-aed527cadcf7.png)

master側のアプリ1は、webrtc_master.jsのstartメソッドを呼び、SignalingサーバとのWebソケットの接続を行います。Signalingサーバにreadyパケットを送信しますが、誰もslaveが接続されていないので、まだ何も起こりません。
その後、slave側のアプリ2がwebrtc_slave.jsのstartメソッドを呼び出し、SignalingサーバとのWebソケットの接続を行います。Signalingサーバにreadyパケットを送信すると、すでに接続されているmasterのクライアントID(test1)を含んだ配列が返ってきて、利用者に接続するかを促します。
ここまでが、接続先を選択するまでの流れです。

以降が、WebRTCのセッションを確立する流れです。
利用者は、webrtc_slave.jsのconnectを呼び出し、test1に接続することを伝えると、WebRTCのためのPeerConnectionをインスタンス化し、offerを生成し、setLocalDescriptionを呼び出してから、Signalingサーバに伝えます。Signalingサーバがそれを、test1のmaster側に転送します。
test1のmaster側では、PeerConnectionをインスタンス化してから、受ったofferをsetRemoteDescriptioinで設定します。
次に、応答を返す前に、slave側に通信したいLocalStreamをaddTrackで設定してからanswerを生成し、setLocalDescriptionを呼び出してから、Signalingサーバに伝えます。
Signalingサーバはanswerをslave側に転送し、slave側はsetRemoteDescriptionを呼び出します。
slave側のPeerConnectionでは、master側で設定したLocalStreamがコールバック関数で受信するので、HTMLのVideoタグ等に割り当ててLocalStreamの動画を表示します。
master側は、answerを生成した後に続けて、offerを生成します。ただし、master側がslave側からの動画の受信が不要である場合は不要です。同様に、setLocalDescriptionを呼び出してからofferをSignalingサーバに伝え、Signalingサーバはslave側に転送します。
slave側では、offerをsetRemoteDescriptonで設定します。
応答を返す前に、master側に通信したいLocalStreamをaddTrackで設定してからanswerを生成し、setLocalDescriptionを呼び出してからSignalingサーバに伝えます。
Signalingサーバはanswerをmaster側に転送し、master側はsetRemoteDescriptionを呼び出します。
master側のPeerConnectionでは、slave側で設定したLocalStreamがコールバック関数で受信するので、HTMLのVideoタグ等に割り当ててLocalStreamの動画を表示します。

次の流れは、今度は先にslave側が接続してから後でmaster側が接続するパターンです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/081a987b-53f4-4996-8db4-bdf33efb7475.png)

slave側のアプリ2がwebrtc_slave.jsのstartメソッドを呼び出し、SignalingサーバとのWebソケットの接続を行います。Signalingサーバにreadyパケットを送信すると、すでに接続されているmasterはいないため、空の配列が返ってきます。
同様に、slave側のアプリ3がwebrtc_slave.jsのstartメソッドを呼び出し、SignalingサーバとのWebソケットの接続を行いますが、すでに接続されているmasterはいないため、空の配列が返ってきます。
master側のアプリ1が、webrtc_master.jsのstartメソッドを呼び出し、SignaligサーバとのWebソケット接続を行い、Siganlingサーバにreadyパケットを送信すると、Signalingサーバがすでに接続されている各slaveに対して、masterのクライアントID(test1)を含んだ配列が返し、それぞれのslave側の利用者に接続するかを促します。
以降は、master側が先に接続されていた時と同様の流れです。

# Signalingサーバのソースコード

Signalingサーバに関連するソースコードを示します。

## Signalingサーバ側のソースコード(Node.js)

WebSocketの処理の部分だけ抜粋しました。
WebSocketは30秒間立つと、接続が切断されるため、Pin-Pong通信を25秒ごとに行うようにしてあります。

```js:api\controllers\signaling-ws\index.js
'use strict';

const WEBRTC_PING_MESSAGE = "9ca3b441-9558-4ba2-afbf-ebd518ecdc03";
const WEBRTC_PONG_MESSAGE = "9ca3b442-9558-4ba2-afbf-ebd518ecdc03";
const WEBRTC_PING_INTERVAL = 25000;

let channel_list = [];
let ping_interval_id = 0;

exports.ws_handler = async (event, context) => {
//  console.log(event);
//  console.log(context);

  // Websocketの接続維持
  if( event.body == WEBRTC_PING_MESSAGE ){
    await context.wslib.postToConnection({
      ConnectionId: event.requestContext.connectionId,
      Data: WEBRTC_PONG_MESSAGE
    }, null);
    return { statusCode: 200 };
  }else if( event.body == WEBRTC_PONG_MESSAGE ){
    return { statusCode: 200 };
  }

  try{
    var body = JSON.parse(event.body);

    // チャネルの検索
    var channel_item = channel_list.find(item => item.channelId == body.channelId );
    if( !channel_item ){
      channel_item = {
        channelId: body.channelId,
        password: body.password,
        clients: [],
      };
      channel_list.push(channel_item);
    }

    // クライアントの検索
    var client_item;
    if( body.type == "ready" ){
      // クライアントからの接続
      // パスワードのチェック
      if( channel_item.password != body.password ){
        console.log("invalid password");
        await context.wslib.postToConnection({
          ConnectionId: event.requestContext.connectionId,
          Data: JSON.stringify({
            type: "error",
            clientId: body.clientId,
            message: "invalid password"
          })
        }, null);
        return { statusCode: 200 };
      }
      // クライアントの再登録
      var index = channel_item.clients.findIndex(item => item.clientId == body.clientId );
      if( index >= 0 ){
        channel_item.clients.splice(index, 1);
      }
      client_item = {
        role: body.role,
        connectionId: event.requestContext.connectionId,
        clientId: body.clientId,
        state: "ready"
      };
      channel_item.clients.push(client_item);
    }else{
      // クライアントの検索
      client_item = channel_item.clients.find(item => item.clientId == body.clientId);
      if( !client_item ){
        console.log("unknown clientId");
        throw new Error("unknown clientId");
      }
      // コネクションIDのチェック
      if( client_item.connectionId != event.requestContext.connectionId ){
        console.log("invalid connection");
        throw new Error("invalid connection");
      }
    }

    // 応答処理
    if( body.type == "ready"){
      // for クライアントからの接続
      if( client_item.role == "master" ){
        // masterの場合：各クライアントにreadyを返却
        for( let item of channel_item.clients ){
          if( item.clientId == client_item.clientId || item.role != 'slave')
            continue;
          await context.wslib.postToConnection({
            ConnectionId: item.connectionId,
            Data: JSON.stringify({
              type: "ready",
              clientId: client_item.clientId,
              clients: [{
                role: client_item.role,
                clientId: client_item.clientId,
                state: client_item.state
              }]
            })
          }, null);
        }
      }else
      if( client_item.role == "slave" ){
        // slaveの場合： 現在コネクションの返却
        var clients = [];
        for( let item of channel_item.clients ){
          if( item.clientId == client_item.clientId || item.role != 'master' )
            continue;
          clients.push({
            role: item.role,
            clientId: item.clientId,
            state: item.state
          });
        }
        await context.wslib.postToConnection({
          ConnectionId: client_item.connectionId,
          Data: JSON.stringify({
            type: "ready",
            clientId: client_item.clientId,
            clients: clients
          })
        }, null);
      }else
      if( client_item.role == "direct" ){
        // directの場合： 現在コネクションの返却
        var clients = [];
        for( let item of channel_item.clients ){
          if( item.clientId == client_item.clientId || item.role != 'direct' )
            continue;
          clients.push({
            role: item.role,
            clientId: item.clientId,
            state: item.state
          });
        }
        await context.wslib.postToConnection({
          ConnectionId: client_item.connectionId,
          Data: JSON.stringify({
            type: "ready",
            clientId: client_item.clientId,
            clients: clients
          })
        }, null);
      }      
    }else
    if( body.type == "sdpOffer1" || body.type == "sdpOffer2" || body.type == "sdpAnswer" || body.type == "iceCandidate" ){
      // 通信内容をターゲットクライアントに転送
      var item = channel_item.clients.find(item => item.clientId == body.target );
      if( item ){
        await context.wslib.postToConnection({
          ConnectionId: item.connectionId,
          Data: JSON.stringify({
            type: body.type,
            clientId: client_item.clientId,
            data: body.data
          })
        }, null);
        if( body.type == "sdpOffer1" || body.type == "sdpOffer2") client_item.state = "offering";
        else if( body.type == "sdpAnswer" ) item.state = "answered";
      }
    } 

    return { statusCode: 200 };
  }catch(error){
    console.error(error);
  }
};

function loop_ping(wslib){
  wslib.getConnectionList((err, list) =>{
    if( err )
      return;
    for( let item of list){
      wslib.postToConnection({
        ConnectionId: item,
        Data: WEBRTC_PING_MESSAGE
      }, null);
    }
  });
}

exports.connect_handler = async (event, context) => {
  console.log("connect_handler");

  // 接続維持の開始
  if( ping_interval_id == 0 ){
    ping_interval_id = setInterval(() =>{
      loop_ping(context.wslib);
    }, WEBRTC_PING_INTERVAL );
  }

  return { statusCode: 200 };
};

exports.disconnect_handler = async (event, context) => {
  console.log("disconnect_handler");

  // チャネルリストの更新
  var list = [];
  for( let channel of channel_list ){
    var clients = [];
    for( let client of channel.clients ){
      if( client.connectionId != event.requestContext.connectionId )
        clients.push(client);
    }
    if( clients.length > 0 ){
      channel.clients = clients;
      list.push(channel);
    }
  }
  channel_list = list;

  context.wslib.getConnectionList((err, list) =>{
    if( err )
      return;
    if( list.length == 0 ){
      // 接続維持の停止
      clearInterval(ping_interval_id);
      ping_interval_id = 0;
    }
  });

  return { statusCode: 200 };
};
```

## Signalingサーバとの通信ライブラリ(Javascript)

ブラウザ上で動作するSignalingサーバと通信するためのライブラリです。

```js:public\js\signaling.js
const WEBRTC_PING_MESSAGE = "9ca3b441-9558-4ba2-afbf-ebd518ecdc03";
const WEBRTC_PONG_MESSAGE = "9ca3b442-9558-4ba2-afbf-ebd518ecdc03";

class WebrtcSignalingClient{
  constructor(role, url){
    this.role = role;
    this.url = url;
    this.callbacks = [];
  }

  async open(channelId, clientId, password){
    this.channelId = channelId;
    this.clientId = clientId;

    this.ws_socket = new WebSocket(this.url);

    await new Promise((resolve, reject) =>{
      let connected = false;

      // Websocket接続時処理
      this.ws_socket.onopen = (event) => {
  //      console.log("websocket opened", event);

        connected = true;
        resolve();
      };

      this.ws_socket.onerror = (event) =>{
  //      console.error("websocket error", event);

        if( !connected )
          return reject(event);

        var callback = this.callbacks.find(item => item.type == "error" );
        if( callback )
          callback.callback(event);
      };

      this.ws_socket.onclose = (event) =>{
        //      console.log("websocket closed", event);
      
        if( !connected )
          return reject(event);

        var callback = this.callbacks.find(item => item.type == "close" );
        if( callback )
          callback.callback();
      };

      
      this.ws_socket.onmessage = (event) => {
        // Websocket接続維持処理
        if( event.data == WEBRTC_PING_MESSAGE ){
          this.ws_socket.send(WEBRTC_PONG_MESSAGE);
          return;
        }else if( event.data == WEBRTC_PONG_MESSAGE ){
          return;
        }

        var body = JSON.parse(event.data);
  //      console.log("websocket message", body);

        if( body.type == "ready"){
          var callback = this.callbacks.find(item => item.type == "ready" );
          if( callback )
            callback.callback(body.clients, body.clientId);
        }else
        if( body.type == "sdpOffer1" || body.type == "sdpOffer2" || body.type == "sdpAnswer" || body.type == "iceCandidate" ){
          var callback = this.callbacks.find(item => item.type == body.type );
          if( callback )
            callback.callback(body.data, body.clientId);
        }else
        if( body.type == 'error' ){
          var callback = this.callbacks.find(item => item.type == "error" );
          if( callback )
            callback.callback(body.message);
        }
      };
    });

    // readyの送信
    this.ws_socket.send(JSON.stringify({
      type: "ready",
      role: this.role,
      clientId: this.clientId,
      channelId: this.channelId,
      password: password
    }));

    var callback = this.callbacks.find(item => item.type == "open" );
    if( callback )
      callback.callback();
  }

  close(){
    this.ws_socket.close();
    this.callbacks = [];
    this.channelId = null;
    this.clientId = null;
  }

  // type=ready, open, ready, sdpOffer, sdpAnswer, iceCandidate, close, error
  on(type, callback){
    var item = this.callbacks.find(item => item.type == type);
    if(!item){
      this.callbacks.push({ type: type, callback: callback});
    }else{
      item.callback = callback;
    }
  }

  sendSdpOffer1(offer, remoteClientId){
    this.ws_socket.send(JSON.stringify({
      type: "sdpOffer1",
      clientId: this.clientId,
      channelId: this.channelId,
      target: remoteClientId,
      data: offer
    }));
  }

  sendSdpOffer2(offer, remoteClientId){
    this.ws_socket.send(JSON.stringify({
      type: "sdpOffer2",
      clientId: this.clientId,
      channelId: this.channelId,
      target: remoteClientId,
      data: offer
    }));
  }

  sendIceCandidate(candidate, remoteClientId){
    this.ws_socket.send(JSON.stringify({
      type: "iceCandidate",
      clientId: this.clientId,
      channelId: this.channelId,
      target: remoteClientId,
      data: candidate
    }));
  }

  sendSdpAnswer(answer, remoteClientId){
    this.ws_socket.send(JSON.stringify({
      type: "sdpAnswer",
      clientId: this.clientId,
      channelId: this.channelId,
      target: remoteClientId,
      data: answer,
    }));
  }  
}
```

# 15分以上Render.comで立ち上げたサーバにアクセスしないでいると

こんな感じで、少し待たされた後に、Webページが表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/4e1504e8-349d-4894-a944-6a33a760f55e.png)


以上