Web会議システムってどういう仕組みなのか？Angular＋NestJSで試しに作ってみる

ふと、Web会議ってどういう仕組みなんだろうと思ったので、作ってみることにしました。


# Web会議システムの仕組み

WebRTC(Web Real-Time Communications)というものを使ってP2Pにて相互通信を行っているそうです。

>
WebRTC (Web Real-Time Communications、ウェブリアルタイムコミュニケーション) は、ウェブアプリケーションやウェブサイトにて、仲介を必要とせずにブラウザー間で直接、任意のデータの交換や、キャプチャしたオーディオ／ビデオストリームの送受信を可能にする技術です。 WebRTC に関する一連の標準規格は、ユーザーがプラグインやサードパーティ製ソフトウェアをインストールすることなく、ピア・ツー・ピアにて、データ共有や遠隔会議を実現することを可能にします。

引用：[WebRTC API](https://developer.mozilla.org/ja/docs/Web/API/WebRTC_API)

サーバーを開始て通信を行うMCUやSFUといった方式もあるようですが、今回はピア接続についてみていこうと思います。


## WebRTCの接続手順

以下の手順で接続を行うようです。

>
1. 呼び出す側が navigator.mediaDevices.getUserMedia() を通じてローカルメディアを取得する
2. 呼び出す側が RTCPeerConnection を作成し、 RTCPeerConnection.addTrack() を実行する。(addStream が非推奨であるため)
3. 呼び出す側がオファーを作成するために RTCPeerConnection.createOffer() を実行する
4. 呼び出す側がオファーを local description (ローカル側の接続の記述) として設定するために RTCPeerConnection.setLocalDescription() を実行する
5. 呼び出す側は setLocalDescription() を実行した後、STUN サーバーに問い合わせて ICE 候補を生成する
6. 呼び出す側がシグナリングサーバーを使ってオファーを届けたい相手に送る
7. 受け取る側がオファーを受け取り、それを remote description (相手側の接続の記述) として記録するために RTCPeerConnection.setRemoteDescription() を実行する
8. 受け取る側がコールに必要なセットアップを行う。ローカルメディアを取得し、 RTCPeerConnection.addTrack() を通じてメディアトラックをピア接続にアタッチする
9. 受け取る側が RTCPeerConnection.createAnswer() を実行することでアンサーを作成する
10. 受け取る側が RTCPeerConnection.setLocalDescription() に作成したアンサーを渡して実行し、アンサーを自身の local description としてセットする。この時点で受け取る側は両側の接続設定を知ることになる。
11. 受け取る側がシグナリングサーバーを使ってアンサーを呼び出す側に送る
12. 呼び出す側がアンサーを受け取る。
13. 呼び出す側がアンサーを remote description として設定するために RTCPeerConnection.setRemoteDescription() を実行する。これで呼び出す側も両者の設定を知ることになる。設定した通りにメディアが流れ始める。

引用：[シグナリング](https://developer.mozilla.org/ja/docs/Web/API/WebRTC_API/Connectivity#%E3%82%B7%E3%82%B0%E3%83%8A%E3%83%AA%E3%83%B3%E3%82%B0)


シーケンス図で表すと以下のようになるそうです

![The entire exchange in a complicated diagram](https://mdn.mozillademos.org/files/6119/webrtc-complete-diagram.png)
引用：[The entire exchange in a complicated diagram](https://developer.mozilla.org/ja/docs/Web/API/WebRTC_API/Connectivity#The_entire_exchange_in_a_complicated_diagram)





# 環境構築

WebRTCを実現するためにはサーバーを４つ立てる必要があります

- Webサーバー
- アプリサーバー
- シグナリングサーバー
- TURNサーバー

アプリサーバーについては[Angular＋NestJSのモノリポ環境をなるべくシンプルに作る](https://qiita.com/teracy55/items/4429645ad1fef081f6db)で作ったプロジェクトをベースに作成し、シグナリングサーバーの役割もNestJSの機能を使って一緒に載せちゃいます

他のサーバーについてはめんどくさいのでdocker-composeで一括で構築します。


## dockerコンテナ

- https-portalでWebサーバーを立てます
    - 後でAWS上にデプロイしますが、SSL化したいので（ドメインはwww.teracy.tkです）
    - DOMAINSはRoute53でEC2と紐づけているドメインを指定
    - STAGEは.envで環境ごとに切り替えられるようにしておきます（ローカルだと不要なのでここまでやる必要もないですが。。。）
- TURNサーバーは良く分からないので、`instrumentisto/coturn`イメージを使います
    - usernameとpasswordを設定するためにconfファイルをボリュームに設定してます

```docker-compose.yml
version: '3'
services:

  https-portal:
    image: steveltn/https-portal:1
    ports:
      - '80:80'
      - '443:443'
    environment:
      DOMAINS: 'www.teracy.tk -> http://server:3000'
      STAGE: $STAGE

  server:
    image: node:12-alpine
    container_name: server
    ports:
      - 3000:3000
    volumes:
      - .:/develop
    links:
      - turn-server
    working_dir: /develop
    command: [/bin/sh, -c, npm install && npm run dev]

  turn-server:
    image: instrumentisto/coturn
    volumes:
      - ./turnserver/turnserver.conf:/etc/coturn/turnserver.conf
    container_name: turn-server
    ports:
      - 3478:3478
      - 49152-65535:49152-65535/udp
    network_mode: host
```

```turnserver/turnserver.conf
# ユーザ名とパスワードを指定
user=username:password
# 長期的な資格情報メカニズムを使用
lt-cred-mech
# TLSは使用しない
no-tls
# realm
realm=www.teracy.tk
```

(2020/11/10 追記)
`lt-cred-mech`と`no-tls`を追記


# シグナリングサーバー

NestJSの[Gateway](https://docs.nestjs.com/websockets/gateways)機能でシグナリングサーバーを実現します

## socket.ioのインストール

```shell
npm i --save @nestjs/websockets @nestjs/platform-socket.io
npm i --save-dev @types/socket.io
```

## シグナリングサーバー

- `namespace: 'signaling'`を指定して、シグナリングだけで使うようにします
- messageを受け取ったら、接続中のクライアントに対して受け取ったデータをそのままemitするだけです

```packages/server/src/gateways/connection.gateway.ts
import { SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';

@WebSocketGateway({ namespace: 'signaling' })
export class ConnectionGateway {
    @WebSocketServer() server: Server;

    @SubscribeMessage('message')
    handleMessage(client: Socket, payload: any) {
        console.log('message', payload.type)
        this.server.clients().emit('message', payload);
    }
}
```

AppModuleでprovidersに指定することでウェブソケットが使えるようになります

```packages/server/src/app.module.ts
・・・
@Module({
  ・・・
  providers: [AppService, ConnectionGateway],
})
export class AppModule {}
```



# フロント側の実装

## シグナリングサーバーのURLを定義

AWS環境ではhttpsのURLを指定します

```packages/client/src/environments/environment.prod.ts
export const environment = {
  production: true,
  signalingServerUrl: 'https://www.teracy.tk/signaling',
};
```

```packages/client/src/environments/environment.ts
export const environment = {
  production: true,
  signalingServerUrl: 'http://localhost:3000/signaling',
};
```

## シグナリングサーバーとやり取りするサービスを作成

[socket.io-client](https://www.npmjs.com/package/socket.io-client)をラッピングするサービスです

まずAngular側にsocket.io-clientをインストールします

```shell
npm install socket.io-client --save
npm install @types/socket.io-client --save-dev
```

サービスを作成します

```packages/client/src/app/shared/services/signaling.service.ts
import { Injectable, OnInit } from '@angular/core';
import { environment } from 'src/environments/environment';
import * as io from 'socket.io-client';

export type MessageType = 'offer' | 'answer' | 'connect_client' | 'new-ice-candidate';
interface SignalingMessage {
    type: MessageType;
    data: any;
}

@Injectable()
export class SignalingService {
    private socket: SocketIOClient.Socket;
    onconnect: () => void;
    ondisconnect: () => void;
    onmessage: (type: MessageType, data: any) => void;

    /**
     * シグナリングサーバーと接続
     */
    init() {
        this.socket = io(environment.signalingServerUrl, {});
        this.socket.on('connect', () => {
            if (this.onconnect) {
                this.onconnect();
            }
        });
        this.socket.on('disconnect', () => {
            if (this.ondisconnect) {
                this.ondisconnect()
            }
        });
        this.socket.on('message', (event: SignalingMessage) => this.onmessage(event.type, event.data));
    }

    /**
     * シグナリングサーバーから切断
     */
    disconnect() {
        this.socket?.disconnect();
    }

    sendMessage(type: MessageType, data: any) {
        const message: SignalingMessage = { type, data };
        this.socket?.emit('message', message);
    }

}
```

## WebRTC接続を行うクラスを作成

とりあえず、シンプルにピア接続して映像を出すだけの機能を想定しているので、映像や音声のON/OFF切り替えとかには対応してないです。

[このページ](https://developer.mozilla.org/ja/docs/Web/API/WebRTC_API/Connectivity#%E3%82%B7%E3%82%B0%E3%83%8A%E3%83%AA%E3%83%B3%E3%82%B0)に書いてある手順にあわせてコメントに番号書いてます

```packages/client/src/app/shared/services/web-rtc.service.ts
import { Injectable } from '@angular/core';
import { SignalingService, MessageType } from './signaling.service';

/**
 * 参考：https://developer.mozilla.org/ja/docs/Web/API/WebRTC_API/Connectivity#%E3%82%B7%E3%82%B0%E3%83%8A%E3%83%AA%E3%83%B3%E3%82%B0
 */
@Injectable()
export class WebRTCService {
    localMediaStream: MediaStream;
    remoteMediaStream: MediaStream = new MediaStream();
    private isHost: boolean;

    constructor(private signaling: SignalingService) { }

    /**
     * ホストとして接続
     */
    async connectionAsHost(constraints: MediaStreamConstraints) {
        this.isHost = true;
        try {
            // 1. ホスト側のメディアストリームを取得
            this.localMediaStream = await navigator.mediaDevices.getUserMedia(constraints);

            // 2. ホスト側でPeerConnectionを作成し、トラックを追加
            const pc = this.createPeerConnection();
            this.localMediaStream.getTracks().forEach(track => pc.addTrack(track, this.localMediaStream));

            this.signaling.init();
            // ソケット接続時にオファーを送信
            this.signaling.onconnect = () => this.sendOffer(pc);
            this.signaling.onmessage = this.handleMessage(pc);
        } catch (err) {
            console.error(err);
        }
    }

    /**
     * クライアントとして接続
     */
    async connectionAsClient(constraints: MediaStreamConstraints) {
        this.isHost = false;
        try {
            this.localMediaStream = await navigator.mediaDevices.getUserMedia(constraints);

            const pc = this.createPeerConnection();

            this.signaling.init();
            // クライアント接続時にホストからofferを送ってもらうためにシグナル送信
            this.signaling.onconnect = () => this.signaling.sendMessage('connect_client', null);
            this.signaling.onmessage = this.handleMessage(pc);
        } catch (err) {
            console.error(err);
        }
    }

    /**
     * 切断
     */
    disconnect() {
        // メディアを停止
        [this.localMediaStream, this.remoteMediaStream].forEach(stream => stream?.getTracks().forEach(track => track.stop()));
        // ソケット切断
        this.signaling.disconnect();
    }

    private createPeerConnection() {
        const pcConfig: RTCConfiguration = {
            iceServers: [
                { urls: "stun:www.teracy.tk:3478" },
                { urls: "turn:www.teracy.tk:3478?transport=udp", "username": "username", "credential": "password" },
                { urls: "turn:www.teracy.tk:3478?transport=tcp", "username": "username", "credential": "password" },
            ]
        };

        const pc = new RTCPeerConnection(pcConfig);
        // リモートのトラック受信
        pc.addEventListener('track', (event: RTCTrackEvent) => this.remoteMediaStream.addTrack(event.track));
        // 5． ICE候補を追加
        pc.addEventListener('icecandidate', event => {
            if (event.candidate) {
                this.signaling.sendMessage('new-ice-candidate', event.candidate);
            }
        });

        return pc;
    }

    private async sendOffer(peerConnection: RTCPeerConnection) {
        // 3. オファーを作成
        const offer = await peerConnection.createOffer();
        // 4. 作成したオファーをローカル接続の記述として設定
        await peerConnection.setLocalDescription(offer);
        // 6. オファーを送信
        this.signaling.sendMessage('offer', offer);
    }

    private handleMessage(peerConnection: RTCPeerConnection,) {
        return async (type: MessageType, data: any) => {
            switch (type) {
                case 'offer':
                    if (!this.isHost) {
                        // 7. クライアント側で受信したofferをリモート側の接続情報としてセット
                        const remoteDesc = new RTCSessionDescription(data);
                        await peerConnection.setRemoteDescription(remoteDesc);
                        // 8. ローカルのメディアトラックをピア接続にアタッチ
                        this.localMediaStream.getTracks().forEach(track => peerConnection.addTrack(track, this.localMediaStream));
                        // 9. アンサー作成
                        const answer = await peerConnection.createAnswer();
                        // 10. アンサーをローカルの接続情報としてセット
                        await peerConnection.setLocalDescription(answer);
                        // 11. アンサーを送信
                        this.signaling.sendMessage('answer', answer);
                    }
                    break;
                case 'answer':
                    // 12. ホスト側でアンサーを受信
                    if (this.isHost) {
                        // 13. アンサーをリモート側の接続情報としてセット
                        const remoteDesc = new RTCSessionDescription(data);
                        await peerConnection.setRemoteDescription(remoteDesc);
                    }
                    break;
                case 'connect_client':
                    if (this.isHost) {
                        // クライアントが接続してきたらオファーを投げてやる
                        this.sendOffer(peerConnection);
                    }
                    break;
                case 'new-ice-candidate': peerConnection.addIceCandidate(data); break;
            }
        }
    }

}
```

(2020/11/10 修正)
STUN/TURNサーバーの設定を修正しました


## 画面の作成

```packages/client/src/app/web-meeting/web-meeting.component.ts
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { WebRTCService } from '../shared/services/web-rtc.service';

@Component({
    selector: 'app-web-meeting',
    templateUrl: './web-meeting.component.html',
    styleUrls: ['./web-meeting.component.scss']
})
export class WebMeetingComponent implements OnInit, OnDestroy {
    isLoading = true;

    get localMediaStream() { return this.webRtcService.localMediaStream; }
    get remoteMediaStream() { return this.webRtcService.remoteMediaStream; }
    get isAvailableLoalMedia() { return this.localMediaStream?.getTracks().length; }
    get isAvailableRemoteMedia() { return this.remoteMediaStream?.getTracks().length; }

    constructor(private webRtcService: WebRTCService, private activatedRoute: ActivatedRoute) { }

    async ngOnInit() {
        if (this.activatedRoute.snapshot.queryParams?.host) {
            this.webRtcService.connectionAsHost({ audio: true, video: true });
        } else {
            this.webRtcService.connectionAsClient({ audio: true, video: true });
        }
        this.isLoading = false;
    }

    ngOnDestroy() {
        this.webRtcService.disconnect();
    }
}
```

```packages/client/src/app/web-meeting/web-meeting.component.html
<div class="panel">
    <div class="video-panel">
        <div class="my-video" *ngIf="isAvailableLoalMedia">
            <video [srcObject]="localMediaStream" autoplay muted playsinline></video>
        </div>
        <div class="remote-video" *ngIf="isAvailableRemoteMedia">
            <video [srcObject]="remoteMediaStream" autoplay muted playsinline></video>
        </div>
    </div>
</div>
```

あとはイイ感じにcss当てます




# 動かしてみる

## AWS上にデプロイ

実際に複数の端末で接続してみたいのでAWS上にデプロイします。

- EC2インスタンスを立ち上げ
- Route53でEC2のIPにドメインを紐づけ
- EC2内でシステム起動（docker-compose up）

※EC2は[EC2をt2.microで立てたときにやることメモ](https://qiita.com/teracy55/items/c1dfc09901c7f242a13f)で書いている内容で立ち上げています

無料ドメインの取得とRoute53については以下を参考にさせていただきました
[無料ドメイン(.tk)とRoute53を利用して0円でHTTPS環境を設定してみた](https://dev.classmethod.jp/articles/mesoko-r53-cdn/)

## いざ！

いよいよデプロイした環境にアクセスします。
PCでホストとして接続を開始し、スマホでクライアントとして接続します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/3eb290ab-bf5d-fd01-1a15-197ddaadb39d.png)

映像が出ました！！
ホスト（PC）の映像が右下にクライアント（スマホ）の映像が中央に出てます。
ちゃんと映像も更新されます＾＾



~~ただ、スマホのWiFiを切って4Gにすると接続できませんでした。。。~~
~~NAT越えが出来てないと思うので、今後TURNサーバーについても調べて分かり次第更新しようと思います。~~

TURNサーバーの設定が足りてませんでした。
以下を参考にturnserver.confを修正したところ接続できました！


[壁を越えろ！WebRTCでNAT/Firewallを越えて通信しよう](https://html5experts.jp/mganeko/5554)
[Configure TURN | BigBlueButton](https://docs.bigbluebutton.org/2.2/setup-turn-server.html)


# TURNサーバーの確認 

(2020/11/10 追記)

Tricle-ICEというサイトで確認できるので、外部のネットワークとつながらない場合はここで見てみると良いです。
https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/

（私の場合は「rtp relay」が来てませんでした。）

↓つながった時の出力
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/450d4671-320e-d9e2-4191-625f3adf8aa7.png)



# まとめ

ちょこちょこ躓きはしたものの、基本的には手順に沿って実装していくだけで実現することが出来ました！
今後は複数人でのWeb会議とかも作ってみたいですね＾＾

今回作成したものはGitHubにプッシュしてます
https://github.com/teracy55/webrtc-sample


# 参考

[WebRTC API](https://developer.mozilla.org/ja/docs/Web/API/WebRTC_API)
[webrtc.org](https://webrtc.org/)
[【初心者向け】STUN/TURNサーバをざっくり解説してみた](https://qiita.com/okyk/items/a405f827e23cb9ef3bde)
[壁を越えろ！WebRTCでNAT/Firewallを越えて通信しよう](https://html5experts.jp/mganeko/5554/)
[instrumentisto/coturn | docker hub](https://hub.docker.com/r/instrumentisto/coturn)
[無料ドメイン(.tk)とRoute53を利用して0円でHTTPS環境を設定してみた](https://dev.classmethod.jp/articles/mesoko-r53-cdn/)