ImageFluxでClubhouse風サービスを作ってみた! 低コスト大規模配信システムの作り方(前編)

こんにちは、テリーです。Clubhouse使ってますか? ラジオ代わりにずっとつなぎっぱなしにして、興味のある話題を聞くような使い方ができますし、カメラがないと服や髪型を気にせずに済むので、仕事仲間との雑談も気を使わずにすぐに始められますね。うちの娘はラーメンズというお笑いユニットのファン同士の交流に使っています。顔が見えないので、年齢差が気にならないのがよいようです。ポストコロナ時代の幕開けを感じることができました。

今回は、話題沸騰中の音声SNS、Clubhouse(クラブハウス)と同等のWebサイトを、FirebaseImageFlux LiveStreamingを使って短いコードで実現する方法を二部構成で紹介します。本記事は前編です。

Clubhouseの主要な機能

  • 部屋を開設する、閉じる(モデレーター)
  • 部屋一覧を表示する
  • 部屋に入室する、退室する(リスナー)
  • リスナーとして会話を視聴する
  • リスナー一覧(スピーカー含む)を表示する
  • リスナーをスピーカーに昇格する
  • スピーカーからリスナーに降格する
  • モデレーター・スピーカーとして双方向の会話をする
  • スピーカーがマイクをミュートする
  • 声を出して話をしている人を強調する

他にもフォロー、紹介、Twitter連携、SMS、電話帳インポートなど、たくさんの機能はありますが、配信に関係する部分を中心にしています。

紹介する技術

  • Firebase Authentication
  • Firebase Cloud Functions
  • Firebase Realtime Database
  • ImageFlux LiveStreaming (後編で紹介)

ゼロから作る大規模ライブ配信サービスの作り方のコツ

映像・音声配信サービスは文字と比べて通信量がとても大きくなります。またライブ配信の場合、ユーザー数の見込みを立てることが非常に困難であるため、最初はスモールスタートをしようと思ったとしても、突然バズって、サーバと回線がパンクし、貴重なユーザーを取りこぼす事例がよく見られます。

私が強く記憶に残っているのは2017年のAbemaTVの企画「亀田興毅に勝ったら1000万円」です。Abemaの潤沢な予算と、優秀なエンジニア群をもってしても、あの規模の同時視聴者数とサーバの負荷を予測し、配信障害に即時対応することができませんでした。

ではどうすればよいかと言うと、大規模配信に対応した潤沢な回線とサーバの設備を持った他社の配信サービスを従量制で間借りするのです。運用コストの支払額は自社運用に比べてべらぼうに高くなりますが、興味を持ってサービスを利用してくれたユーザーがリピーターになってくれれば、そのコストはいずれ回収できます。アクティブユーザー数が上がり続ければ広告も取りやすくなるでしょう。逆に、目先のコストを意識しすぎて、バズったときにサービスが維持できず、新規ユーザーを取りこぼし、悪評を別のSNSで拡散されるようなことがあれば、本末転倒です。

もちろんコスト意識は重要なので、いつかバズる前提で低コストの自社運用も準備をしておくとよいでしょう。Dropboxというオンラインストレージサービスは当初AWSでサービスを開始しました。ユーザー数が増え、出資を受け、エンジニアも大幅に増員したのちに、自社運用に切り替えてコストの大幅削減に成功しました。

そういう観点で見ると、さくらインターネットが提供するImageFlux LiveStreamingは、商用サービスをスモールスタートで実現するために最適なサービスです。さくらインターネットの極太バックボーンネットワークと、潤沢なサーバリソースを低価格で利用することができます。

開発環境

本記事の開発環境は以下のとおりです。

  • MacBook Pro 2019 macOS BigSur 11.2.3
  • Docker Desktop 3.1.0
  • VSCode 1.54.3, Remote-Container プラグイン 0.163.2
  • Node.js 14.15.4
  • Vue.js 2.6.11 / Vuetify 2.4.6 / Vuex 3.6.2 / Vuexfire 3.2.5
  • Firebase JavaScript SDK 8.3.0 (クライアント側)
  • Firebase UI 4.8.0 (クライアント側)
  • Firebase Admin SDK 9.5.0 (サーバ側)
  • Firebase Cloud Functions 3.13.2 (サーバ側)
  • Sora JavaScript SDK 2020.6.2(後編)

OSに依存する部分はほとんどないので、適宜読み替えてください。Vueとその関連ライブラリはバージョン3系の提供が始まっていますが、今回は2系の最新版で実装しています。サーバ側の実装はクライアントと別プロジェクトとして実装することが多いですが、今回は小規模なため、同一プロジェクト内のフォルダに記述しています。

事前準備

  • ImageFlux LiveStreaming の認証キーを取得
    こちらのサイトからトライアルを申し込み、認証キーを取得してください。認証キーは100文字程度の暗号化された単一行文字列です。ImageFlux LiveStreamingのWeb APIを呼び出すために使用します。認証キーの文字列はプログラムに直接埋め込まず、環境変数に指定して使用します。取得まで数営業日かかりますので、本記事を体験し、ライブ配信と関係しない部分を改良しながら待つと良いでしょう。本記事前編では、認証キーがなくても動作する部分を解説します。

新規Webサイト作成までの手順

サンプルとして新規にWebサイトを作成するまでの流れを紹介します。

  1. Firebaseのエミュレータ環境を整える
  2. DBモデルを決める
  3. API名を決める、モックを作る
  4. Vueの初期設定
  5. クライアント側ページを作る
  6. APIを実装する
  7. 動作確認
  8. 5〜7を繰り返す

1. Firebaseのエミュレータ環境を整える

Firebaseは、Googleが提供するモバイル・Webアプリケーション開発プラットフォームです。FirebaseにはAuthentication、Cloud Functions、Realtime Databaseなどたくさんの機能があり、それぞれを独立して使うことも、連携して使うこともできます。エミュレータを整えることにより、Firebaseのサービスコストを気にすることなく無料で開発を進めることができます。

Firebase Authenticationを導入すると、会員制Webサイトを短期間で実装することができます。会員登録、ログイン、SNS連携ログイン、メール認証、パスワード再発行の処理はゼロから作るとセキュリティリスクが高く、検証に時間がかかります。今回はFirebase AuthenticationとFirebase UIを使用しました。詳しくは本家を参照ください。

Firebase Cloud Functionsを導入すると、会員認証付きのWebAPIを秒で実装することができます。主にDBへの書き込み処理を担当します。

Firebase Realtime Databaseを導入すると、サーバからDBに書き込んだ情報がクライアントに即時更新・通知されるため、ほぼすべてのイベント処理をクライアント側のみで記述することができます。サーバへのポーリングやプッシュ通知の実装が必要ありません。

Firebase HostingはWebサーバに相当しますが、Vue CLI内蔵のwebpackが担当するので、開発時は使用しません。

Webサイトのポート番号と、Firebaseのエミュレータを動かすポート番号を決めます。Firebaseのポート番号は歴史的経緯から、デフォルト番号が分散していますが、dockerで開発する場合は毎回変わってしまうので、変わらないように連番で定義します。firebase.jsonの中に記述します。

サービス名 ポート番号
Webサイト 2000
Firebase Authentication 2001
Firebase Cloud Functions 2002
Firebase Firestore 2003
Firebase Realtime Database 2004
Firebase Hosting 2005
Firebase PubSub 2006
Firebase Emulator UI 2007

ポート番号が他のなにかと衝突してどうしても変更しなければならない場合は、firebase.json, .devcontainer/devcontainer.json, .vscode/launch.json, functions/index.js, src/plugins/firebase.js の数値を適宜修正してください。

.devcontainer/devcontainer.jsonのフォルダとファイルを作成し、下記のように最小限のものを記入します。

{
 "name": "imagefluxhouse",
 "image": "mcr.microsoft.com/vscode/devcontainers/javascript-node",
 "remoteEnv": {
   "PATH": "${containerWorkspaceFolder}/node_modules/.bin:${containerEnv:PATH}",
 },
 "forwardPorts": [2000, 2001, 2002, 2004, 2007],
 "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached",
 "workspaceFolder": "/workspace"
}

Firebase関連をインストールします。

yarn add firebase firebaseui firebaseui-ja
yarn add firebase-tools firebase-admin firebase-functions --dev

Firebaseのエミュレータを実行するにはGoogleアカウントを紐付ける必要があります。下記のコマンドを実行すると、URLが表示され、Webページが開きます。

firebase login

Firebase CLIに関連付けるGoogleアカウントでログインし、許可ボタンを押します。

「Firebase CLI Login Successful」と表示されたら、そのWebページを閉じます。VSCodeのターミナルにも「Success! Logged in as ****@gmail.com」と表示されます。

下記のコマンドを実行し、Firebaseの使用する機能を選択します。

firebase init

下記のように表示されるので、「Database、Functions、Emulators」にチェックを入れてEnterを押します。

? Which Firebase CLI features do you want to set up for this folder? Press Space to select features, then Enter to confirm your choices. 
 ◉ Database: Configure Firebase Realtime Database and deploy rules
 ◯ Firestore: Deploy rules and create indexes for Firestore
 ◉ Functions: Configure and deploy Cloud Functions
 ◯ Hosting: Configure and deploy Firebase Hosting sites
 ◯ Storage: Deploy Cloud Storage security rules
 ◉ Emulators: Set up local emulators for Firebase features
 ◯ Remote Config: Get, deploy, and rollback configurations for Remote Config

下記のように表示されるので、「Create a new project」の行を選択します。

? Please select an option: 
  Use an existing project 
❯ Create a new project 
  Add Firebase to an existing Google Cloud Platform project 
  Don't set up a default project 

下記のように表示されるので、プロジェクト名を入力します。(例: imagefluxhouse)

? Please specify a unique project id (warning: cannot be modified afterward) [6-30 characters]:
 () 
? What would you like to call your project? (defaults to your project ID) () 

Realtime Databaseの設定を行います。下記のように3問の質問が表示されるので、デフォルトのままEnterを押します。

? It seems like you haven’t initialized Realtime Database in your project yet. Do you want to set it up? Yes
? Please choose the location for your default Realtime Database instance: us-central1
? What file should be used for Realtime Database Security Rules? database.rules.json

Functionsの設定を行います。下記のように3問の質問が表示されるので、言語はお好みでJavaScriptかTypeScriptを選択、ESLintはNoを選択、npmはNoを選択します。

? What language would you like to use to write Cloud Functions? JavaScript
? Do you want to use ESLint to catch probable bugs and enforce style? No
? Do you want to install dependencies with npm now? No

Emulatorの設定を行います。下記のように表示されるので、「Authentication、Functions、Database」の行を選択します。

? Which Firebase emulators do you want to set up? Press Space to select emulators, then Enter to confirm your choices. 
 ◉ Authentication Emulator
 ◉ Functions Emulator
 ◯ Firestore Emulator
 ◉ Database Emulator
 ◯ Hosting Emulator
 ◯ Pub/Sub Emulator

下記のように表示されるので、「Authentication、Functions、Database、UI」のポート番号(2001,2002,2004,2007)を入力します。

? Which Firebase emulators do you want to set up? Press Space to select emulators, then Enter to confirm your choices. Authentication Emulator, Functions 
Emulator, Database Emulator
? Which port do you want to use for the auth emulator? 2001
? Which port do you want to use for the functions emulator? 2002
? Which port do you want to use for the database emulator? 2004
? Would you like to enable the Emulator UI? Yes
? Which port do you want to use for the Emulator UI (leave empty to use any available port)? 2007
? Would you like to download the emulators now? Yes

firebase.jsonが生成され、指定したポート番号が保存されます。

{
 "database": {
   "rules": "database.rules.json"
 },
 "emulators": {
   "auth": {
     "port": 2001
   },
   "functions": {
     "port": 2002
   },
   "database": {
     "port": 2004
   },
   "ui": {
     "enabled": true,
     "port": 2007
   }
  },
 "functions": {
   "predeploy": [
     "npm --prefix \"$RESOURCE_DIR\" run lint"
   ]
 }
}

他にも.firebaserc, database.rules.json, functions/index.js, functions/package.json などが生成されます。

セットアップが完了したら、下記のコマンドでエミュレータを実行します。

firebase emulators:start

Javaが必要というニュアンスのエラーが出たら、Java11をインストールします。

apt update && apt install -y openjdk-11-jre

VSCodeでF5キーを押すとエミュレータが起動・デバッグできるように、.vscode/launch.jsonというファイルを作ります。

{
 "version": "0.2.0",
 "configurations": [
   {
     "type": "node",
     "request": "launch",
     "name": "Functions",
     "runtimeExecutable": "firebase",
     "runtimeArgs": ["emulators:start", "--inspect-functions"],
     "port": 9229,
     "skipFiles": ["/**"]
   }
 ]
}

F5キーを押すと、VSCode下部のターミナルに「DEBUG CONSOLE」タブが現れ、大量のWarningが表示されますが、30秒ほど待つと「Web / API server started at http://localhost:2007」という表示がされます。ブラウザでアクセスすると、下記のような画面が現れ、各サービスのポート番号、稼働状況が確認できます。

Firebaseの設定は以上です。

2. DBモデルを決める

Firebase Realtime DatabaseはJSONオブジェクトなので、本来スキーマレスですが、日が経つと忘れますので、先に決めておきます。

  • ユーザー情報 /users/$uid
    userId ユーザーID
    name 名前
    picture 画像URL
    email メールアドレス
    roomId 参加しているルームID
  • ルーム情報 /rooms/$roomId
    roomId ルームID
    name ルーム名
    userId 開設者ユーザーID
    channelInfo WebRTC接続情報
    moderators モデレーターユーザーIDリスト
    speakers スピーカー(話者)情報リスト
    listeners リスナー(聴者)情報リスト
    pictures 画像URLリスト
  • モデレーター権限 /rooms/$roomId/moderators
    userId ユーザーID
  • スピーカーユーザー /rooms/$roomId/speakers/$userId
    userId ユーザーID
    name ユーザー名
    picture 画像URL
    playlist_url HLS URL
    talking 発話中フラグ
    mute ミュートフラグ
  • リスナーユーザー /rooms/$roomId/listeners/$userId
    userId ユーザーID
    name ユーザー名
    picture 画像URL
    hand 挙手フラグ

3. API名を決める、モックを作る

次にクライアントからサーバにリクエストするWebAPIを定義します。サーバ側をCloud Functionsで実装し、クライアント側をFirebase Javascript SDK経由で呼び出せば、ユーザー認証トークンのやりとりと実装は不要です。

クライアントから呼び出すサーバ側のAPI名は次のようにしました。

公開API名 引数 戻り値 説明
CreateRoom name roomId 部屋を開設
JoinRoom roomId 部屋に入室
LeaveRoom roomId, close 部屋を退室、閉鎖
ListRoom roomIdのリスト 部屋一覧を部分取得
UpgradeUser roomId, userId, kind モデレーター、スピーカー、リスナーを切替
SetTalking roomId, active 話し中または無言
SetMute roomId, active ミュート通知

サーバ上に実装するその他のイベント関数は、以下のようにしました。

公開関数名 説明
processSignUp Firebase Authenticationの新規会員登録完了イベント
AuthWebhook ImageFlux(Sora)のauth_webhook (後編参照)
EventWebhook ImageFlux(Sora)のevent_webhook (後編参照)

サーバからクライアントへの通知はFirebase Realtime Databaseにより自動的になされますので、命名は必要ありません。

次にCloud FunctionsでWebAPIと関数のモックを作ります。関数の中身は記述しません。クライアントからJavaScript SDK経由で呼び出すものはfunctions.https.onCallでラップし、認証のいらないwebhookはfunctions.https.onRequestでラップします。新規会員登録イベントのみ特殊な書き方でfunctions.auth.user().onCreateを呼び出します。

exports.CreateRoom = functions.https.onCall((data, context) => {
});

exports.helloWorld = functions.https.onRequest((request, response) => {
 functions.logger.info("Hello logs!", {structuredData: true});
 response.send("Hello from Firebase!");
});
 
exports.processSignUp = functions.auth.user().onCreate((user) => {
});
...(以下略)

F5キーを押し、Firebase Emulatorが動いている状態で、curlコマンドで応答が返ってくることを確認します。確認したらCtrl+CでFirebase Emulatorを終了します。

curl -v http://localhost:2002/(プロジェクトID)/us-central1/(公開関数名)

4. Vueの初期設定

Vueを使ってクライアントを実装します。本記事ではVueの解説はしませんので、詳しくない方は読み流してください。Vueの初期設定は下記のコマンドで行いました。

yarn add @vue/cli --dev
vue create -d -n .
vue add router
vue add vuetify
yarn add vuex vuexfire

vue createで作成すると.gitignore 18行目付近に「.vscode」が含まれますが、このフォルダはバージョン管理対象にしたいので、この行を削除します。また、Firebase Emulatorのログがプロジェクトルートディレクトリに生成されますが、これはバージョン管理対象外にしたいので、「*-debug.log」を追加します。「yarn.lock」「package-lock.json」はお好みでバージョン管理対象外にします。

下記のコマンドで、Vue CLI付属のwebpack devserverを実行し、http://localhost:2000/ にブラウザでアクセスして確認します。確認したらCtrl+Cでwebpack devserverを終了します。

yarn serve

VSCodeでF5キーを押すとwebpack devserverが起動できるように、.vscode/launch.jsonを修正します。compoundsとconfigurationsの両方に"name": "Vue_Functions"というキーがありますが、これはVSCodeでF5キーを押したときに、compoundsの項目をデフォルト選択とするためのハックコードです。この書き方をすると、マウス操作の必要なく、F5キーを押すだけで、webpack devserverとFirebase Emulatorが同時に起動し、両方ともブレイクポイントを設定するなどのデバッグができます。

{
 "version": "0.2.0",
 "compounds": [
   {
     "name": "Vue_Functions",
     "configurations": ["VueCLI", "Functions"]
   }
 ],
 "configurations": [
   {
     "type": "node",
     "name": "Vue_Functions",
     "request": "launch"
   },
   {
     "type": "node",
     "request": "launch",
     "name": "VueCLI",
     "program": "${workspaceFolder}/node_modules/@vue/cli-service/bin/vue-cli-service.js",
     "args": ["serve", "--port=2000"]
   },
   {
     "type": "node",
     "request": "launch",
     "name": "Functions",
     "runtimeExecutable": "firebase",
     "runtimeArgs": ["emulators:start", "--inspect-functions"],
     "port": 9229,
     "skipFiles": ["/**"]
   },
   {
     "type": "pwa-chrome",
     "request": "launch",
     "name": "Chrome",
     "url": "http://localhost:2000/",
     "webRoot": "${workspaceFolder}"
   }
 ]
}

最後に、このdockerコンテナをいつでもリビルドしたり、バージョン管理できるように、devcontainer.jsonを修正します。修正したらVSCodeウインドウ左下の緑色エリアをクリックし、出てきたメニューから「Remote-Containers: Rebuild Container」を選択してください。

{
 "name": "imagefluxhouse",
 "image": "mcr.microsoft.com/vscode/devcontainers/javascript-node",
 "remoteEnv": {
   "PATH": "${containerWorkspaceFolder}/node_modules/.bin:${containerEnv:PATH}",
   "PROMPT_COMMAND": "history -a",
   "HISTFILE": "/commandhistory/.bash_history",
 },
 "extensions": ["mubaidr.vuejs-extension-pack"],
 "forwardPorts": [2000, 2001, 2002, 2004, 2007],
 "mounts": [
   "source=bashhistory,target=/commandhistory,type=volume",
   "source=${localEnv:HOME}/.ssh,target=/root/.ssh,type=bind,consistency=cached,readonly"
 ],
 "postCreateCommand": "apt update && apt install -y openjdk-11-jre && yarn install",
 "settings": {
   "editor.defaultFormatter": "esbenp.prettier-vscode",
   "editor.formatOnSave": true,
   "files.exclude": {
     "**/*.log": true
   }
 },
 "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached",
 "workspaceFolder": "/workspace"
}

以上で開発環境設定は終わりです。コードを書き始める前にgitでバージョン管理を始めるとよいでしょう。

5. クライアント側ページを作る

開発環境が整ったところで、クライアントページをコツコツ作っていきます。最初に必要なのは会員登録兼ログインです。
Firebase UIを使うと、Twitter, Facebook, Googleアカウントを使用したSNSログインがほぼコードレスですぐに実現可能です。もちろん従来型のメールアドレス+パスワードも可能です。本記事執筆時点では、エミュレータ環境では、メールログインと、ゲストログインでしかログインできませんでした。

ログインページを作るには、下記のようなファイル(src/views/Login.vue)を作成します。最終行付近のsignInSuccessUrlには、ログイン成功後にリダイレクトするURLを記入します。たったこれだけのコードでSNSログインができるなんてすごすぎます。

<template>
 <v-container>
   <div id="firebaseui-auth-container"></div>
 </v-container>
</template>
 
<script>
import firebase from "firebase/app";
import firebaseui from "firebaseui-ja";
import "firebaseui-ja/dist/firebaseui.css";
 
export default {
 mounted() {
   const ui =
     firebaseui.auth.AuthUI.getInstance() ||
     new firebaseui.auth.AuthUI(firebase.auth());
   ui.start("#firebaseui-auth-container", {
     signInOptions: [
       firebase.auth.EmailAuthProvider.PROVIDER_ID,
       firebase.auth.GoogleAuthProvider.PROVIDER_ID,
       firebase.auth.FacebookAuthProvider.PROVIDER_ID,
       firebase.auth.TwitterAuthProvider.PROVIDER_ID,
       firebase.auth.GithubAuthProvider.PROVIDER_ID,
       firebase.auth.PhoneAuthProvider.PROVIDER_ID,
       firebaseui.auth.AnonymousAuthProvider.PROVIDER_ID,
     ],
     signInSuccessUrl: "/roomlist",
   });
 },
};
</script>

また、Firebaseをどこのページ、コンポーネントからでも呼び出せるように、pluginを作成します。ファイル名は src/plugins/firebase.js としています。エミュレータを使っていることをSDKに知らせるコードを含んでいます。

import firebase from "firebase/app";
import "firebase/auth";
import "firebase/functions";
import "firebase/database";
 
const firebaseConfig = {
 apiKey: "apiKey",
 projectId: "imagefluxhouse",
 databaseURL: "imagefluxhouse?ns=imagefluxhouse",
};
firebase.initializeApp(firebaseConfig);
 
export const auth = firebase.auth();
export const functions = firebase.app().functions("us-central1");
export const db = firebase.database();
if (location.hostname === "localhost") {
 auth.useEmulator("http://localhost:2001");
 functions.useEmulator("localhost", 2002);
 db.useEmulator("localhost", 2004);
}
 
export const CreateRoom = functions.httpsCallable("CreateRoom");
export const ListRoom = functions.httpsCallable("ListRoom");
export const JoinRoom = functions.httpsCallable("JoinRoom");
export const LeaveRoom = functions.httpsCallable("LeaveRoom");

Cloud Functionsには、ログインが完了すると、イベント関数が呼ばれます。それを定義し、Realtime Databaseにユーザー情報を保存します。これらの処理をするコードを functions/index.js として作成します。

const admin = require("firebase-admin");
const functions = require("firebase-functions");
admin.initializeApp({
 databaseURL: "http:///imagefluxhouse?ns=imagefluxhouse",
});
const ref = admin.database().ref();
exports.processSignUp = functions.auth.user().onCreate((user) => {
 const updates = {};
 updates["users/" + userId + "/userId"] = user.uid;
 updates["users/" + userId + "/name"] = user.displayName;
 updates["users/" + userId + "/picture"] = user.photoURL;
 updates["users/" + userId + "/email"] = user.email;
 return ref.update(updates);
});

ログインができたらあとはコツコツ1ページずつ、必要なUIとAPIを実装します。Clubhouseのアプリを参考にそれっぽいUIをマテリアルデザインアイコンで作ってみました。

以上でFirebaseを使った開発手順の紹介は終わりです。近日公開する後編では、ImageFlux LiveStreamingに接続し、WebRTCを用いた双方向音声通話と、多数のリスナーに向けた、HLS視聴コードの書き方を紹介します。コード一式もまとめて公開します。