SkyWayを利用したWebアプリ作成

こんにちは。

今回は、WebRTCの技術を利用して、異なるネットワーク間での通信を検証した。
また、RaspberryPiを用いての通信も検証する。

参考になれば、幸いです。

# 目次
* WebRTCとは？
* 今回のやること
* システム構成図
* コード
* 結果
* 感想
* 今後目指していきたいこと

# WebRTCとは？
WebRTCとは、Web Real Time Communicationの略。
WebRTCを一言で言うと、"データ"をネットワークを通じて、端末間で送受信するためのプロトコルの集まりのことである。
2011年にGoogleで提唱され、APIレベルでの標準化が進んでいる

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468943/32cc2f03-17a1-e227-14df-2842e5b5308f.png)
 
メディアチャネル：DTLS-SRTP 
データチャネル：DTLS
で暗号化されている。

また、WebRTCはUDPプロトコルでの通信（TURNなど一部TCPでの通信のケースもあり）を行っており、データを相手に送り付ける、及び独自の再送や輻輳制御を行っているため、遅延を抑える仕組みがある。

# 今回のやること
・リモート（例：自宅）に配置したRaspberryPiのカメラの映像を社内の環境（ローカル）から、WebRTCを利用してリアルタイムに映像を確認すること。
・NAT配下からの通信が成功した際に使用したシグナリングのプロトコルの調査

# システム構成図
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468943/b8808112-5fb7-990e-de78-072cfb1c1365.png)


# 環境
* ローカルPC
    * Windows10
    * Microsoft edge（ブラウザ）

* RaspberryPi 4B
    * bullsea（OS）
    * Chronium（ブラウザ）

* Webアプリ
    * Vue3（Webフレームワーク）
    * SkyWay SDK
    * Bulma （CSSフレームワーク）

* Skyway認証用API
    * Node.js(express）
    * dotenv

* デプロイ環境
    * GAE（Google App Engine）

# 手順／コード
## 認証APIの作成
SkyWayの開発者ドキュメントでも記載があるが、SDKを使用するための認証APIはバックエンドで行うことが望ましい。
SkyWay Auth Tokenを作成するためのアプリケーションIDやシークレットキーが必要となる。
これらの取得については、[公式ガイド](https://skyway.ntt.com/ja/docs/user-guide/javascript-sdk/quickstart/)を参照。


従って、認証のAPIを

1. SkyWayライブラリ(token)のインストール
    ```
    $ npm @skyway-sdk/token
    ```
1. 認証API作成
    今回、SkyWay Auth Token作成に必要なアプリケーションIDやシークレットキーは.envに記述する。
    ```
    APP_ID="アプリケーションID"
    SECRET_KEY="シークレットキー"
    ```
    下記の内容をindex.jsを用意して、記述する
    ```javascript:index.js
    const express = require('express');
    const app = express();
    const sdkToken = require('@skyway-sdk/token')
    const fs = require('fs');

    require('dotenv').config();
    
    const { APP_ID } = process.env
    const { SECRET_KEY } = process.env
    const iat = Math.floor(Date.now() / 1000);
    const exp = Math.floor(Date.now() / 1000) + 36000;

    app.set('trust proxy', true);
    app.use(express.json());
    app.use(express.urlencoded({ extended: true }));
    app.use((_, res, next) => {
      // 異なるオリジン間でデータをやり取りする
      res.header("Access-Control-Allow-Origin", "*");
      res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
      res.header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTION");
      next();
    });
    
    // Test用　GETメソッド
    app.get('/', (req, res) => {
      res.send('Hello World!');
    });

    // /authenticateにGETリクエストすると、SkyWay Auth Tokenを返す
    app.get('/authenticate', (req, res) => {
      res.send(makeToken);
    });
    
    const listener = app.listen(process.env.PORT || 8080, () => {
      console.log(`Server listening on port ${listener.address().port}`)
    });
    
    const makeToken = new sdkToken.SkyWayAuthToken({
      jti: sdkToken.uuidV4(),
      iat: iat,
      exp: exp,
      scope: {
        app: {
          id: APP_ID,
          turn: true,
          actions: ['read'],
          channels: [
            {
              id: '*',
              name: '*',
              actions: ['write'],
              members: [
                {
                  id: '*',
                  name: '*',
                  actions: ['write'],
                  publication: {
                    actions: ['write'],
                  },
                  subscription: {
                    actions: ['write'],
                  },
                },
              ],
              sfuBots: [
                {
                  actions: ['write'],
                  forwardings: [
                    {
                      actions: ['write'],
                    },
                  ],
                },
              ],
            },
          ],
        },
      },
    }).encode(SECRET_KEY);
    ```

1. デプロイ
    GCPのアカウント作成やプロジェクト作成、アプリ作成等はここでは記述しない。
    そちらについては、公式ドキュメントや他者Qiitaの記事等を参照のこと。

    app.yamlをプロジェクトのルートディレクトリに作成して、下記内容を記述する。
    ```yaml:app.yaml
    runtime: nodejs18
    build_env_variables:
      GOOGLE_NODE_RUN_SCRIPTS: ''
    ```
    
    GAEに、gcloudコマンドを利用して、デプロイする。
    ```
    $ gcloud app deploy
    ```
## Webアプリ作成

1. SkyWayライブラリ(room)のインストール
    ```
    $ npm @skyway-sdk/room
    ```
1. Webアプリ作成
    app.vueを用意して、下記を記述する。
    ```typescript:app.vue
    <script setup lang="ts">
      import {computed, ref, onMounted} from 'vue';
      import axios from 'axios';
    
      const authToken = async() => {
        return axios.get('ここにSkyWay Auth Tokenを取得するAPIのパスを入れてください',{
        }).then((Response) => Response.data)
      }
    
      // 通信処理
      import { nowInSec, SkyWayAuthToken, SkyWayContext, SkyWayRoom, SkyWayStreamFactory, uuidV4 } from '@skyway-sdk/room';
    
      const myVideo = ref(null)
      const myAudio = ref(null)
    
      const localVideo = ref(null)
      const remoteVideoArea = ref("")
      // const remoteAudioArea = ref(null)
    
      const myId = ref('')
      const roomName = ref('')
    
      // 即時実行のasyncで関数全体を囲むと、非同期処理awaitが可能
      const join = async () => {

        if (roomName.value == '') return

        // promise型で返ってくるため、非同期処理awaitで行う
        const token = await authToken()
    
        // Roomを作成
        const context = await SkyWayContext.Create(token)
        const room = await SkyWayRoom.FindOrCreate(context, {
          type: 'p2p',
          name: roomName.value,
        })

        // Roomに入る
        const me = await room.join()
        myId.value = me.id
    
        room.onMemberJoined.add((e) => {
          console.log(e)
        })
    
        // Cameraのストリームを作成
        const videoStream = await SkyWayStreamFactory.createCameraVideoStream();
    
        // 自分の映像と音声を公開する
        await me.publish(videoStream)
    
        // 他のユーザーがいた・入室してきた時の処理
        const subscribeAndAttach = async (publication: any) => {
    
          if (publication.publisher.id === me.id) return;
    
          const { stream } = await me.subscribe(publication.id);
          
          // DOMに直接videoとaudioのelementを追加
          let newMedia;
          switch (stream.track.kind) {
            case 'video':
              newMedia = document.createElement('video')
              newMedia.width = 300
              newMedia.playsInline = true
              newMedia.autoplay = true
    
              stream.attach(newMedia)
              remoteVideoArea.value.appendChild(newMedia)
              break
            /*
              case 'audio':
              newMedia = document.createElement('audio')
              newMedia.controls = true
              newMedia.autoplay = true
    
              stream.attach(newMedia)
              remoteAudioArea.value.appendChild(newMedia)
              break
            */
              default:
              return
          }
        }
    
        room.publications.forEach(subscribeAndAttach)
        room.onStreamPublished.add((e: any) => subscribeAndAttach(e.publication))
      }
    
      onMounted(async () => {
        // 自分の音声と映像を取得
        const { audio, video } = await SkyWayStreamFactory.createMicrophoneAudioAndCameraStream()
        myVideo.value = video
        // myAudio.value = audio

        // 自分の映像を表示
        myVideo.value.attach(localVideo.value)
        await localVideo.value.play()
      })
    </script>

    <template>
      <section class="hero is-primary">
        <div class="hero-body">
          <p class="title">
            Video2
          </p>
          <p class="subtitle">
            接続しているカメラデバイスから映像を取得できます
          </p>
        </div>
      </section>
    
      <div class="has-text-centered">
        <div>ID: <span>{{ myId }}</span></div>
        <video ref="localVideo" muted playsinline class="local-video" />
        <div class="room-name">
          Room Name:
          <div v-if="!myId">
            <input v-model="roomName" type="text" />
            <button @click="join">Join</button>
          </div>
          <div v-else>{{roomName}}</div>
        </div>
        <div ref="remoteVideoArea" class="remote-videos" />
        <div ref="remoteAudioArea" class="remote-audios" />
      </div>


    </template>
    
    <style scoped>
    .hero{
      margin-top: 10px;
    }
    .has-text-centered{
      margin: 10px;
    }
    </style>

    ```
1. ビルド
    下記コマンドを実行して、distフォルダにビルドしたファイルが含まれていることを確認。
    ```bash
    $ npm run build
    ```

1. デプロイ
    こちらも認証API同様で、app.yamlをプロジェクトのルートディレクトリに作成して、下記内容を記述する。
    ```yaml:app.yaml
    runtime: php55
    handlers:
    - url: /
      static_files: dist/index.html
      upload: dist/index.html
    
    - url: /(.*)
      static_files: dist/\1
      upload: dist/(.*)
    ```

    GAEに、gcloudコマンドを利用して、デプロイする。
    ```
    $ gcloud app deploy
    ```
    ※app.yaml内で、serviceを指定すると同プロジェクト内の別サービスとしてデプロイすることが可能。

# 結果
・リモート（例：自宅）に配置したRaspberryPiのカメラの映像を社内の環境（ローカル）から、WebRTCを利用してリアルタイムに映像を確認すること。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468943/22cdef68-6d9c-3ff9-eae1-5cf5d6a66ed6.png)

無事、自宅にあるRaspberryPiとNAT配下の社内ネットワークに接続しているPC間で通信が成功した。
今回のように、社内のようなNAT配下やファイアウォールの環境下でもTURNサーバを利用することで通信が可能。

・NAT配下からの通信が成功した際に使用したシグナリングのプロトコルの調査
Chromeを利用して、端末間の通信がどのようなプロトコルで行われているかを確認することができる。
[TURNサーバを利用しているかを確認する方法](https://support.skyway.io/hc/ja/articles/236031388-TURN%E3%82%B5%E3%83%BC%E3%83%90%E3%82%92%E5%88%A9%E7%94%A8%E3%81%97%E3%81%A6%E6%8E%A5%E7%B6%9A%E3%81%97%E3%81%A6%E3%81%84%E3%82%8B%E3%81%8B%E7%A2%BA%E8%AA%8D%E3%81%99%E3%82%8B%E6%96%B9%E6%B3%95%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468943/267f604d-a5e2-0642-d8a4-74397ef12537.png)

携帯（キャリア）と社内ネットワーク内のPC間の接続を確認した。
〇ローカル（社内）
NAT配下のネットワークに接続しているPCのシグナリングに用いられたプロトコルの結果である。
TURNサーバで、tcpプロトコルを用いていることが分かる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468943/75b4136d-b6f3-0aa3-88c1-828970a687e3.png)

〇リモート（携帯）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468943/d9a10934-94ca-2b29-a950-0a8a21c87642.png)


# 感想
SkyWayのSDKを使用することで自前で実装するよりも簡単にオンラインコミュニケーションアプリを動作させることができた。
SDKを利用することで、TURNサーバなどを用意／実装することなく通信することが可能である。
全体の通信の約10~20%がTURNサーバを介した通信であること、また、WebRTCのアップデートの対応の観点からも、WebRTCプラットフォームを提供しているサービスを利用することが望ましいと感じた。

携帯のキャリアのネットワークとも接続ができるので、ネットワークのある環境ならどこでも通信することが可能。

# 今後目指していきたいこと
* Zoomのような会議アプリ
今回は1対1での通信であったが、SFUサーバを利用した多対多の通信も行いたい。
* ARを利用したリアルタイムコミュニケーション
今回は、RaspberryPiであったが、カメラ付きのAR機器（ゴーグル）と組み合わせることで、リアルタイムに作業指示を始めとする情報をARで表示することが可能になる。
ユースケースとしては、工事現場を始めとする作業現場や歩行者の立体的な道案内などである。
* ドローンや産業機器などの遠隔操作
ドローンや産業機器から送られてくる映像を遠隔でリアルタイムに確認しながら、操作を行う


# 参考資料
* [TURNサーバを利用しているかを確認する方法](https://support.skyway.io/hc/ja/articles/236031388-TURN%E3%82%B5%E3%83%BC%E3%83%90%E3%82%92%E5%88%A9%E7%94%A8%E3%81%97%E3%81%A6%E6%8E%A5%E7%B6%9A%E3%81%97%E3%81%A6%E3%81%84%E3%82%8B%E3%81%8B%E7%A2%BA%E8%AA%8D%E3%81%99%E3%82%8B%E6%96%B9%E6%B3%95%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)
* [ブラウザ毎のAPI（WebRTC）対応状況の確認](https://caniuse.com/)
* [開発者ドキュメント　SkyWay（スカイウェイ）](https://skyway.ntt.com/ja/docs/user-guide/javascript-sdk/quickstart/)
* [SkyWay接続確認テスト（公式）](https://skyway.ntt.com/ja/experience/)
* [WebRTC コトハジメ](https://gist.github.com/voluntas/67e5a26915751226fdcf)
* [Goocle Cloud App Engine ドキュメント](https://cloud.google.com/appengine/docs?hl=ja)