オンライン診療・商談・ビデオ会議サービスの技術選定はこれだ!WebRTCエキスパートが1ヶ月以内で構築する最新テクニックを紹介😎

オンライン診療・商談・ビデオ会議サービスの技術選定はこれだ!WebRTCエキスパートが1ヶ月以内で構築する最新テクニックを紹介😎

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは!

最近ではコロナの影響もあるのか、リアルタイム通信を行うオンライン動画サービスの開発依頼が増えてきました。

リアルタイムな WEB サービス構築にノウハウが溜まってきたので、私たちが普段使用している技術などを一挙紹介したいと思います!リアルタイムな WEB サービスの構築を検討している方はぜひご一読ください。

想定する読者

  • リアルタイムな WEB サービスの構築を検討しているヒト
  • リアルタイムな WEB サービスのアーキテクチャーで悩んでいるヒト
  • 最新のクラウドサービス&サードパーティーツールでサクッと構築をしたいヒト

はじめに

本記事は紹介するサードパーティツールの広告記事ではありません

銀の弾丸はありませんので、本記事で紹介していない製品・サードパーティサービスでの構築も十分に検討の上、ご自身のプロジェクトにフィットするアーキテクチャーを検討するようにして下さい。本記事はあくまでも、私たちの過去のリアルタイム系サービスの開発経験に基づき執筆しているものです。

無料でアーキテクチャーの相談も受け付けておりますので、お気軽にご連絡ください。

WEB リアルタイムサービス開発に必要な前知識

本記事では、WebRTC に関する詳しい解説はしません。WebRTC に精通していなくとも、極力理解しやすい内容に努めますが、理解していた方がよりスムーズにキャッチアップできるかと思います。

以下に、理解しておいた方が良い用語をリストで記載しますので、お時間あればぜひ一度調べてみて下さい。一般的な WEB エンジニア・プロジェクトマネージャー職の方が、独学で深く理解するのは中々難易度が高いですので概要レベルの理解で結構です。(私たちも実装しながら深く理解していった次第です)

  • WebRTC
  • TCP/UDP プロトコル
  • Signaling ( シグナリング ) サーバー
  • ICE サーバー ( STUN/TURN サーバー )
  • NAT 及び NAT 超え
  • SFU サーバー
  • MCU 通信 ( Mesh 型 )

アーキテクチャー&実装解説

採用技術一覧

リアルタイム通信に関連しない、その他の細かな技術仕様については記載しません。

ユースケース採用技術
WebRTC ( ICE/SFU サーバー )Vonage
接続中の資料共有機能Vonage
AWS AppSync
接続中の画面共有機能Vonage
AWS AppSync
通話のレコーディング ( 録画 / 録音 )Vonage
AWS AppSync
接続中のリアルタイムチャットAWS AppSync
音声文字起こしAWS Transcribe
文字起こし結果の多言語翻訳AWS Translate
文字起こし結果の機械学習 自然言語処理 (NLP) AWS Comprehend
決済 ( 例 : オンライン診療後の診療代の決済 )PayJP
AWS AppSync
AWS SQS
AWS Lambda ( TypeScript )
利用者の認証認可(例 : Facebook / Auth0 / Email サインイン)AWS Cognito
AWS IAM STS
WEB フロントエンドNuxtJS(Vue3 API)
TypeScript
Lodash
SCSS
HTML Video API
HTML RTCPeerConnection API
使用技術一覧

この技術選定の中で最も重要なのは、WebRTC ( ICE/SFUサーバー ) の技術選定です。後述にて補足します。

WebRTC ( ICE/SFUサーバー )の技術選定の補足

まず、WebRTC を提供するサービスは海外含めれば無数に存在します。その中で私たちは、以下指標で技術選定を行いました。

  • 開発ドキュメントが充実していること
  • WebRTC 利用ユーザーに物理的に近い位置に ICE/SFU/Signaling サーバーが存在すること
  • WebRTC SFU サーバーを提供していること
  • MCU 通信 ( フルメッシュ ) 可能なこと
  • JavaScript の SDK が提供されていること
  • サービスの継続性・可用性 ( 提供会社の規模、提供開始日、サーバーの所在地 )
  • 平均故障間隔&稼働時間(過去の障害記録などを参照)

一般的なサードパーティ製品の選定基準と類似しますが、中でも特徴的なのは WebRTC を提供するサーバーが利用者の所在地に物理的に近いこと、SFU サーバーが存在することを入念に確認しています。

これは、WebRTC 通信は UDP 通信を行う性質上、データの欠損が発生する頻度・可能性が高く、可能な限り欠損のない通信が実現できる低レイテンシーのサーバーを検討する必要があるためです。(結果画質が良くなります)

上記の指標で検討した結果、私たちは以下のサービスの技術検証を実施しました。

SkyWay

SkyWay は大手 NTT コミュニケーションズの提供する WebRTC サービスです。ベンダーの規模・知名度ともに申し分なく、かつ日本語のドキュメントが充実しており、WebRTC 初心者にはオススメなサービスです。

ただ、SkyWay には録画機能が存在しないのと、録音機能でエクスポート先に指定可能なのが GCP のクラウドストレージのみなので、私たちは採用を見送りました。

※ 録画機能自体は自前でストリーミング処理用のサーバーを用意し、MediaStream をバッファリングすれば実現できなくもないですが、本記事のテーマでもある「1ヶ月以内で構築」を実現できない可能性が上がるため却下とします。

Kinesis Video Stream ( WebRTC )

意外と知られていないですが、実は Kinesis ファミリーには WebRTC を提供する Kinesis Video Stream ( WebRTC ) サーバーが存在します。私たちは通常 AWS サーバーレス製品を使用して WEB サービス/スマフォアプリを構築するため、実は一番最初に目をつけたサービスです。

KinesisWebRTCは、なんといってもスペックの高い ICE サーバー+ SFU サーバーを提供してくれるので、画質の良い ( つまり低レイテンシー ) 通信を実現させてくれます。

ただKinesisWebRTCは、「1対1」 or 「1対N」 の接続を前提とするため、標準では MCU 非対応となっており採用を見送りしました。もう一点残念なのは、KinesisVidesStream を使用した録画機能の実現について、IoT やウェアラブルデバイス、スマフォアプリを前提としているため、WEB 通信向けの JavaScript の SDK が提供されていない様子でした。

また、KinesisWebRTC を使用する場合、フロントエンド実装の際にある程度 WebRTC の仕様の理解を求められますので、やや実装の敷居が高い印象です。SDK でよしなに Video タグをストリームへ流すなどしてくれないので、自身でストリームイベントを検知し Video タグへ流すなどの実装が別途必要です。逆にいうと柔軟な実装に対応できるので、玄人向けの実装と言えます。

※ 実は Master を各デバイスのストリームの HUB にすれば無理矢理ですが MCU を実現可能です ( 本来の用途ではないため非推奨 )

Vonage

最後に、私たちが採用している Vonage です。まず Vonage の強みとしてストリーミング処理の豊富な機能が挙げられます。以下のようなリアルタイム通信でよくある機能を Vonage が提供している SDK を使用するとサクッと実装することが可能です。

  • 録音 / 録画
  • 画面共有 / 資料共有
  • 音声検出
  • MCU 標準対応
  • Video タグの自動生成 / WEB フロントエンドのストリーミング機能

後述で実装方法を紹介します。

Vonage による WebRTC の実装

接続のワークフロー

引用:https://tokbox.com/developer/guides/basics/

Vonage は、以下のワークフローで WebRTC 通信を行います。

  1. [ ServerSide ] SessionID を作成
  2. [ ServerSide ] SessionID への接続用の Token を生成
  3. [ Frontend ] SessionID と Token を使用し接続を開始
  4. [ Frontend ] 自身の接続を公開 ( Publish )
  5. [ Frontend ] 自分以外の接続を取得 ( Subscribe )

※ セッション ( SessionID ) = 接続先のミーティングルームの ID

※ トークン ( Token ) = ミーティングルームへの接続用の鍵

※ ServerSide の処理にはシークレットトークンを用いるがシークレットトークンは漏洩するとまずいので AWS のシークレットマネージャーでエンクリプトして利用することを推奨

Vonage へ接続する WEB フロントエンド Javascript を紹介

Vonage の開発ドキュメントのソースコードを、私たち独自に TypeScript で実装したサンプルです。ご自身の環境に合わせてカスタムして利用してください。尚、以降のサンプルは MCU のビデオ通話には対応していますが、アーカイブ機能(録画/録音)には対応していません。(実装はお気軽にお問い合わせください)

$ yarn add @opentok/client
# or
$ npm install @opentok/client --save
import OT, { Session, Event, Connection, Stream } from "@opentok/client";

type SessionDisconnectedEvent = Event<"sessionDisconnected", Session> & {
  reason: string;
};

type ConnectionCreatedEvent = Event<"connectionCreated", Session> & {
  connection: Connection;
};

type ConnectionDestroyedEvent = Event<"connectionDestroyed", Session> & {
  connection: Connection;
  reason: string;
};

type StreamCreatedEvent = Event<"streamCreated", Publisher> & {
  stream: Stream;
};

type Handler = {
  handleError?: Function;
  onConnectionCreated?: Function;
  onConnectionDestroyed?: Function;
  onSessionDisconnected?: Function;
  onConnected?: Function;
  onPublisherCompleted?: Function;
  onPublisherConnected?: Function;
};

type Publisher = {
  element: HTMLElement;
  opt: Record<string, unknown>;
};

type Subscriber = {
  element: HTMLElement;
  opt: Record<string, unknown>;
};

export default class VonageService {
  private apiKey: string;
  private sessionId: string;
  private token: string;
  private handleErrorFunction: Function;
  private onConnectionCreated: Function;
  private onConnectionDestroyed: Function;
  private onSessionDisconnected: Function;
  private onConnected: Function;
  private onPublisherCompleted: Function;
  private onPublisherConnected: Function;
  private publisher: Publisher;
  private subscriber: Subscriber;
  private session: Session;

  constructor(
    apiKey: string,
    sessionId: string,
    token: string,
    publisher: Publisher,
    subscriber: Subscriber,
    handler?: Handler
  ) {
    if (OT.checkSystemRequirements() !== 1)
      throw new Error("ご使用のブラウザはWebRTC非対応です。");
    this.handleErrorFunction = handler?.handleError || this.defaultHandleError;
    this.onConnectionCreated =
      handler?.onConnectionCreated || this.defaultHandler;
    this.onConnectionDestroyed =
      handler?.onConnectionDestroyed || this.defaultHandler;
    this.onSessionDisconnected =
      handler?.onSessionDisconnected || this.defaultHandler;
    this.onConnected = handler?.onConnected || this.defaultHandler;
    this.onPublisherCompleted =
      handler?.onPublisherCompleted || this.defaultHandler;
    this.onPublisherConnected =
      handler?.onPublisherConnected || this.defaultHandler;
    this.sessionId = sessionId;
    this.publisher = publisher;
    this.subscriber = subscriber;
    this.apiKey = apiKey;
    this.token = token;
    this.session = OT.initSession(this.apiKey, this.sessionId);
  }

  public get getSession(): Session | undefined {
    return this.session;
  }

  public connect() {
    const handleError = this.handleErrorFunction;
    const onConnectionCreated = this.onConnectionCreated;
    const onConnectionDestroyed = this.onConnectionDestroyed;
    const onSessionDisconnected = this.onSessionDisconnected;
    const onConnected = this.onConnected;
    const onPublisherCompleted = this.onPublisherCompleted;
    const publish = () => {
      const publisher = OT.initPublisher(
        this.publisher.element,
        this.publisher.opt,
        (err) => {
          if (err) {
            handleError();
          } else {
            onPublisherCompleted();
          }
        }
      );
      this.session.publish(publisher, (err) => {
        if (err) {
          handleError();
        } else {
          this.onPublisherConnected();
        }
      });
    };
    const subscribe = (event: StreamCreatedEvent) => {
      function handleError(error: any) {
        if (error) {
          alert(error.message);
        }
      }
      this.session.subscribe(
        event.stream,
        this.subscriber.element,
        this.subscriber.opt,
        handleError
      );
    };
    this.session.on({
      streamCreated: function (event: StreamCreatedEvent) {
        subscribe(event);
      },
      connectionCreated: function (event: ConnectionCreatedEvent) {
        onConnectionCreated(event);
      },
      connectionDestroyed: function (event: ConnectionDestroyedEvent) {
        onConnectionDestroyed(event);
      },
      sessionDisconnected: function sessionDisconnectHandler(
        event: SessionDisconnectedEvent
      ) {
        if (event.reason == "networkDisconnected")
          throw new Error("Your network connection terminated.");
        onSessionDisconnected(event);
      },
    });
    this.session.connect(this.token, function (error) {
      if (error) {
        handleError(error);
      } else {
        publish();
        onConnected();
      }
    });
  }

  public disconnect() {
    this.session.disconnect();
  }

  private defaultHandleError(error?: any): void {
    if (error) throw new Error(error);
  }

  private defaultHandler(event: any): void {
    console.info(event);
  }
}

上記を、フロントエンドの View 側で利用してください。以下は NuxtJS ( Vue3 / Composition-API ) の実装サンプルです。

<template>
  <div>
    <div id="publisher-view" />
    <div id="subscriber-view" />
  </div>
</template>
import {
  defineComponent,
  onMounted,
  useContext,
  ref,
} from "@nuxtjs/composition-api";
import _ from "lodash";
import VonageService from "~/services/vonageService";

export default defineComponent({
  middleware: "authenticated",
  onMounted(() => {
    const connectionCount = ref(0); // 接続人数
    const context = useContext();
    const start = async () => {
      // TODO: セッションIDはWEBサーバーで作成し取得すること
      const sessionId = "XXXX";
      // TODO: トークンはWEBサーバーで作成し取得すること
      const token = "XXXX";

      const onConnectionCreated = () => {
        console.log("onConnectionCreated on vue file.");
        connectionCount.value++;
      };
      const onConnectionDestroyed = () => {
        console.log("onConnectionDestroyed on vue file.");
        connectionCount.value--;
      };
      const onSessionDisconnected = () => {
        console.log("onSessionDisconnected on vue file.");
        connectionCount.value--;
      };
      const onConnected = () => {
        console.log("onConnected on vue file.");
        connectionCount.value = 0;
      };
      const onPublisherCompleted = () => {
        console.log("onPublisherCompleted on vue file.");
      };
      const onPublisherConnected = () => {
        console.log("onPublisherConnected on vue file.");
      };
      const publisher = {
        element: document.getElementById("publisher-view") as HTMLElement,
        opt: {
          insertMode: "append",
          width: "calc(50vw - 15px)",
          height: "calc(100% - 40px)",
        },
      };
      const subscriber = {
        element: document.getElementById("subscriber-view") as HTMLElement,
        opt: {
          insertMode: "append",
          width: "calc(50vw - 15px)",
          height: "calc(100% - 40px)",
        },
      };
      const apiKey = context.$config.VONAGE_API_KEY; // nuxt.config.jsへ要設定(publicRuntimeConfigプロパティー)
      const vonageService = new VonageService(
        apiKey,
        sessionId,
        token,
        publisher,
        subscriber,
        {
          onPublisherCompleted,
          onPublisherConnected,
          onConnectionCreated,
          onConnectionDestroyed,
          onSessionDisconnected,
          onConnected,
        }
      );
      vonageService.connect();
    });
    return {};
  }
});

まとめ

最新の WebRTC の仕組み・実装を覚えれば、WEB 上でのリアルタイムな通信を行うアプリ構築が可能となり、WEB で表現可能な範囲が一気に広がると思います。

日本の開発市場では WebRTC を自由に触れるエンジニアがまだまだ希少なので、本記事をきっかけに WebRTC を使用した高度な実装にチャレンジする方が増えていけば幸いです。

リアルタイムなアプリケーション構築はお気軽にお問い合わせください。