WebRTCの仕組みをざっくり理解する
2019-12-21
azblob://2022/11/11/eyecatch/2019-12-21-understand-webrtc-protocol-through-vuejs-demo-app-000.jpg

この記事はFIXER Advent Calendar 2019 (https://adventar.org/calendars/4579) 21日目の記事です。

前日は横田君の『Power Platform「CoE Starter Kit」使ってみた』でした。簡単に作れる故、数が多くなりがちなPower Apps、Power Automateのリソースを管理できるのはいいですね!私が以前Power Appsを使ったときは、普通にプログラミングするアプリ開発と比べて圧倒的お手軽さに衝撃を受けました。そんな、今熱いらしいPower Platformにもしっかり注目していきたいです。

また、本記事の主題のWebRTCについては門下さんも記事を書いているのでおすすめです。

この記事の概要

本記事は、WebRTCがP2P(Peer to Peer)の通信を実現している基本的なプロトコルへの理解を深めるためにデモアプリをVue.jsで作ってみた記録になります。Webアプリと、ユーザ同士が接続するのを助けるSignaling serverのコードの実際の動作フローを追っていき、プロトコル通りになってるねという確認をします。

WebRTCとは

WebRTCは、ブラウザを通して映像や音声、その他データをP2Pでやり取りすることができる技術です。 ブラウザが提供しているWebRTCのAPIを用いれば、簡単にビデオ通話などのWebアプリを作れるらしいのですが、ネットワーク関連に疎い私は実装に手こずりました。教訓として、WebRTCをやる上では兎にも角にも基本的なプロトコルと概念を押さえる必要があると学びました。こちらのMDNドキュメントを参照するのがそれに丁度いいのですが、それだと話を進められないので雑に要約+追記します。

  • ICE (Interactive Connectivity Establishment)
    P2P接続を何としても行うためのフレームワークで、STUN / TURN Serverを用いる
  • NAT (Network Address Translation)
    ルータ内の各機器のprivate IPアドレスを一意なport番号を持つルータのPublic IPアドレスに変換するため、外から見た自身のPublic IPアドレスを知らないNAT下のクライアントはP2P通信が妨げられる
  • STUN (Session Traversal Utilities for NAT)
    STUN Serverへのリクエストにより通信するために相手に伝える必要のあるNAT下の自身のPublic IPアドレス 、そもそもP2P通信できるか(NATによる制限がないか)を知る
  • TURN (Traversal Using Relays around NAT)
    TURN ServerはNATの制限によりP2P通信できない場合にパケットを中継する
  • SDP (Session Description Protocol)
    マルチメディアの解像度、フォーマット、コーデック、暗号化、port番号、IPアドレスなどの情報をまとめて記述する方式

今回はローカル環境で動かすので、STUN / TURN 周りの話は扱いません。ICE フレームワークは上記のようにP2P接続が上手くいかない場合に何とかつなぐ方法を規定していますが、P2Pでのマルチメディアのやり取りを開始するためにはマルチメディアのメタデータを含んだSDPを交換させる必要があります。そこで利用されているのがOffer / Answerモデルです。これが一見シンプルなようで、プロトコルを理解してないとコードを書く際に困ったので、今回作成したデモアプリを通じて確認していきます!

アプリ画面

よくあるP2Pでビデオ通話ができるアプリです。実際にPeer同士が接続されるまでを以下で確認します。

コードの詳細

SDPのやり取りをする方法を書いてませんでしたが、これはSignaling Serverと呼ばれるサーバーを通して行います。シグナリングを行う仕組みについては決められてないそうなので、自前で実装する必要があります。それがこちらです。

const WebSocket = require("ws");
const server = new WebSocket.Server({ port: 8082, clientTracking: true });
const clients = server.clients;
const CAPACITY = 2;

server.on("connection", ws => {
  ws.on("message", message => {
    const msg = JSON.parse(message);

    if (msg.type === "join") {
      if (clients.size === 1) {
        ws.send(JSON.stringify({ type: "joined" }));
        ws.name = msg.name;
        console.log("joined: ", msg.name, " nums: ", clients.size);
      }
      else if (clients.size <= CAPACITY) {
        ws.name = msg.name;
        console.log("joined: ", msg.name);
        for (const client of clients) {
          if (ws !== client) {
            client.send(JSON.stringify({ type: "matched", remoteName: ws.name, offerer: false }));
            ws.send(JSON.stringify({ type: "matched", remoteName: client.name, offerer: true }));
          }
        }
      }
      else {
        ws.send(JSON.stringify({ type: "failed" }));
        console.log("cannnot join: ", msg.name);
      }
    }
    if (msg.type === "accept") {
      for (const client of clients) {
        if (ws !== client) {
          console.log("send accept");
          client.send(JSON.stringify({ type: "accepted" }));
        }
      }
    }
    if (msg.type === "offer" || msg.type === "answer") {
      for (const client of clients) {
        if (ws !== client) {
          console.log("send ", msg.type, " to ", client.name, " of ", ws.name);
          client.send(JSON.stringify(msg));
        }
      }
    }
  });

  ws.on("close", () => {
    console.log("bye. remains: ", clients.size);
  })
})

node.jsで作成してます。WebSocketを利用して定員2名の部屋でPeer同士が接続を確立するまでのサーバーとのやり取りを記述していて、次のようなフローになってます。決まっているのはOffer / Answerのやり取り部分だけなので、それ以外は自由です。

  1. 一人目のclientが入室する
  2. 二人目のclientが入室する。"matched"というメッセージとともに、相手の名前とOfferを作成する人かどうかの情報をclientに送る。(必ず最後に入室した人がOfferを作成するように実装)
  3. 一人目のclientは、サーバーからの "matched" というメッセージに対し、もう一人のclientとの接続を希望する場合"accept"を送り返す(今回は必ずそうなるように実装)
  4. サーバーは"accept"を受けると、二人目のclientに"accepted"を送る
  5. 両者の確認が取れたので、Offer / Answerの手続きに入る。まず、二人目のclientが"offer"というメッセージとともにSDPをサーバーへ送る
  6. サーバーはSDPをそのまま一人目のclientに送る
  7. 一人目のclientは後述の方法でSDPを作成し、"answer"というメッセージとともにサーバーに送る
  8. サーバーはSDPをそのまま二人目のclientに送る
  9. SDPの交換が無事終了したので、P2P接続が確立する

Offer / Answer自体は割とシンプルで、こんな感じです。次に、Webアプリ側のコードを見ていきます。vue-cliでvue createしてお好みのオプションで作ります。デフォルトで表示されるApp.vueに主にviewの部分を、同階層の別ファイルにWebRTCのロジックを実装しました。htmlとcssは、重要ではないので省略します。また、WebRTCのAPIはブラウザによって微妙に実装が異なるので、今回はchromeオンリーでやってます。

<script>
import { MyRTC } from "./MyRTC";

let myRTC = null;

export default {
  name: "app",
  data() {
    return {
      name: "",
      localDisplayName: "",
      remoteDisplayName: ""
    };
  },
  methods: {
    async handleJoin() {
      if (myRTC !== null) {
        console.log("既に参加しています");
        return;
      }

      const stream = await navigator.mediaDevices.getUserMedia({
        audio: true,
        video: true
      });

      const { local } = this.$refs;
      if (local.srcObject === null) {
        local.srcObject = stream;
      }

      // Signalingサーバへの接続を開始
      myRTC = new MyRTC();
      myRTC.on("init", () => {
        console.log("onnection opened");
        myRTC.join(this.name);
      });

      // 通信相手の候補が見つかった時
      myRTC.on("matched", remoteName => {
        this.remoteDisplayName = remoteName + " さん";
      });

      // Signalingの準備ができたとき
      myRTC.on("signalingReady", () => {
        console.log("local stream: ", stream);
        myRTC.sendOffer(stream);
      });

      // Offer側のOfferを受けたとき
      myRTC.on("offerArrived", sdp => {
        myRTC.sendAnswer(stream, sdp);
      });

      // remoteのstreamを取得したとき
      myRTC.on("stream", strm => {
        console.log("remote stream: ", strm);
        const { remote } = this.$refs;
        if (remote.srcObject === null) {
          remote.srcObject = strm;
        }
      });

      this.localDisplayName = this.name;
    }
  },
  mounted() {
    const { local, remote } = this.$refs;
    local.onloadedmetadata = function() {
      local.play();
    };
    remote.onloadedmetadata = function() {
      remote.play();
    };
  },
  beforeDestroy() {
    if (myRTC !== null) {
      myRTC.close();
    }
  }
};
</script>

アプリ画面の参加ボタンを押したときのイベントハンドラがhandleJoin()で、クリック時にgetUserMedia()によってカメラが起動します。また、ここでWebRTC周りをラップしたMyRTCクラスを初期化し、viewとのやり取りが必要なイベントハンドラを登録しています。mounted()のタイミングで、localremote (<video>)にstreamが追加された段階で自動再生されるように設定しています。

import { EventEmitter } from "events";

export class MyRTC extends EventEmitter {
  constructor() {
    super();
    this.ws = new WebSocket("ws://localhost:8082/");
    this.pc = new RTCPeerConnection();
    const { ws, pc } = this;

    ws.onopen = () => {
      this.emit("init");
    };
    ws.onclose = () => {
      console.log("connection ended");
    };
    ws.onerror = error => {
      console.log(error);
    };
    ws.onmessage = message => {
      const msg = JSON.parse(message.data);
      console.log(msg.type, pc.signalingState);

      if (msg.type === "joined") {
        console.log("部屋に参加しました. 通信相手が来るのを待機中です...");
      }
      if (msg.type === "failed") {
        console.log("部屋が満員でした");
        this.close();
      }
      if (msg.type === "matched") {
        console.log("通信相手が見つかりました");
        this.emit("matched", msg.remoteName);
        if (msg.offerer) {
          // とりあえず全てaccept
          ws.send(JSON.stringify({ type: "accept" }));
        }
      }
      if (msg.type === "accepted") {
        console.log("通信が受け入れられました");
        console.log("オファーを送ります");
        this.emit("signalingReady");
      }
      if (msg.type === "offer") {
        console.log("オファーが届きました");
        console.log("アンサーを返します");
        this.emit("offerArrived", msg);
      }
      if (msg.type === "answer") {
        console.log("アンサーが届きました", msg);
        pc.setRemoteDescription(msg);
      }
    };

    pc.ontrack = ev => {
      this.emit("stream", ev.streams[0]);
    };
    pc.onicecandidate = ev => {
      // 全ての通信経路の候補が出尽くした
      if (ev.candidate === null) {
        const sdp = pc.localDescription;
        ws.send(JSON.stringify(sdp));
      }
    };
    pc.onnegotiationneeded = ev => {
      console.log("onnegotiationneeded: ", ev);
    };
  }

  join(name) {
    const msg = {
      type: "join",
      name: name
    };
    this.ws.send(JSON.stringify(msg));
  }

  async sendOffer(stream) {
    const { pc } = this;
    for (const track of stream.getTracks()) {
      pc.addTrack(track, stream);
    }
    const offer = await pc.createOffer();
    pc.setLocalDescription(offer);
  }

  async sendAnswer(stream, sdp) {
    const { pc } = this;
    pc.setRemoteDescription(sdp);
    for (const track of stream.getTracks()) {
      pc.addTrack(track, stream);
    }
    const answer = await pc.createAnswer();
    pc.setLocalDescription(answer);
  }

  close() {
    this.ws.close();
    this.pc.close();
  }
}

こちらがMyRTCの中身で、WebRTCでメディアやSDPのやり取りをするためのRTCPeerConnectionとSignaling serverとやり取りするためのWebsocket、viewとやり取りするためのEventEmitterを内包したものになってます。サーバー編で後述すると言っていたofferやanswerの作成はsendOffer(), sendAnswer()メソッドで行っています。 offerを作る際は RTCPeerConnectionaddTrack()メソッドによってメディアの情報を認識できるようになり、createOffer()で得られるSDPに反映されます。その後setLocalDescription()を行うと、offerを送信する準備段階になります。その後、通信経路の候補が見つかるたびにonicecandidateが呼ばれ、面倒くさいのですべてが出そろったタイミングでサーバーにSDPを送る実装になってます。

offerを受け取る側も基本的には同じで、どこかのタイミングでローカルのメディアを addTrack()した後、setRemoteDescription(), createAnswer(), setLocalDescription()によって準備が整います。ここまでざっくりと、プロトコルの流れをコード上で追ってきたので、最後にコンソールへの出力を見て終わりにしたいと思います。

デモ

一瞬なので分かりにくいのですが、ちゃんとプロトコル通りにやり取りが行われていることが確認でき、最終的に相手のメディアが送られてきているので、OKです。

さいごに

コードべた書きなので長くなってしまってすみません。また、元々ネットワーク系にそんなに詳しくなく、解釈が間違っている箇所があるかもしれないので、その時は指摘していただけるとありがたいです。

参考にさせていただいた文献