羊をめぐるブログ

趣味の色々について書きます

GCP Video Intelligenceを用いた不適切動画の検知 with Cloud Functions

はじめに

これは2021年振り返りカレンダーの10日目の記事です.

前回はTwilio Sync + Reactを用いてリアルタイム通信を実装するという内容でした.

serenard.hatenablog.com

今回はGCPに戻って,Video Intelligenceを使って不適切な動画を弾く内容について書いていこうと思います.

ユーザが動画などのコンテンツを自由に投稿できるサービスはよくありますが,ユーザ数が一定以上いると, ポルノ画像など他のユーザに害を与えるものをアップロードする人が間違いなく現れます. そのため今回は,そのような不適切コンテンツを検知し,アラートしてくれる簡易的なシステムを実装します.

超大きいサービスだと検出したいケースが複雑でこうはいかないでしょうが, 初期段階のサービスでは役に立つんではないかと思います.

仕様

Cloud Storageの特定のバケットに新たな動画がアップロードされた時,Video Inteligenceで その動画が不適切かどうか(ポルノ要素を含むかどうか)を判定し,結果をslackに通知するとします.

動画の判定処理や通知には,Cloud Functions(typescript)をもちいます.

実装

Video Intelligenceの不適切コンテンツ検出(セーフサーチ)の仕様について

ドキュメントは以下になっています.

cloud.google.com

使い方の要点をまとめると以下の感じです.

  • 判定したいビデオを,GCS上のURI(gs://~~)で指定する.
    • 動画のURLを直で与えることはできない.
  • 判定結果としては,ビデオが数フレーム毎に分割され,それぞれに6段階で判定が行われる(VERY LIKELY 〜 VELY UNLIKELY).
  • 各フレームの判定結果をもとに自分で適切な閾値を実装し,ビデオ全体の判定結果を出す.

Video Intelligenceを用いた判定処理

次は実際に,Cloud Functionにのせる用のコードを書いていきます.

GCSの特定のバケットにオブジェクトが追加された時に処理を走らせるため, Cloud FunctionsのGCSトリガーを用います.

また,Video Intelligenceでは動画全体で1つの判定結果が返ってくるのではなく, 複数あるフレームの単位で判定結果が返ってくるので,それを1つの判定結果に落とし込む必要があります. 今回は以下のようなフローで判定を行うこととしました.

  1. 全フレームのうち,1つでもVERY LIKELYの箇所があるなら,不適切と判定.
  2. 全フレームのうち,40%以上がPOSSIBLEなら,不適切と判定.
  3. 上記に当てはまらないなら,適切な動画と判定.

実際の実装は以下のような感じです.

import videoIntelligence, { protos } from "@google-cloud/video-intelligence";

const pornoFrameRatioThreshold = 0.4;

export const safeSearch = functions
  .region(REGION)
  .storage.bucket(functions.config().storage.bucket)
  .object()
  .onFinalize(async (object) => {
    // GCSの新規オブジェクトのパスを取得
    const filePath = object.name;

    // パスのバリデーション
    if (!filePath) {
      functions.logger.error("Filepath is null.");
      return;
    }
    if (!validateVideoEdit(object)) {
      return;
    }

    // オブジェクトが入っているバケット名を取得
    const bucket = admin.storage().bucket(object.bucket);

    const userUID = filePath.split("/")[0];
    const request = {
      inputUri: `gs://${bucket.name}/${filePath}`,
      features: [
        protos.google.cloud.videointelligence.v1.Feature
          .EXPLICIT_CONTENT_DETECTION,
      ],
    };

    // Video Intelligenceでセーフサーチをする
    const client = new videoIntelligence.VideoIntelligenceServiceClient();
    const [operation] = await client.annotateVideo(request);

    const [operationResult] = await operation.promise();

    // 結果の取り出し
    const annotations = operationResult.annotationResults
      ? operationResult.annotationResults[0]
      : undefined;

    // 判定されたフレームの数を取得
    const frameCount = annotations?.explicitAnnotation?.frames?.length;

    // フレーム全体を見て判定
    const pornographyPossibleCount =
      annotations?.explicitAnnotation?.frames?.filter(
        (v) =>
          v.pornographyLikelihood ==
          protos.google.cloud.videointelligence.v1.Likelihood.POSSIBLE
      ).length;
    if (typeof pornographyPossibleCount === "undefined" || !frameCount) {
      functions.logger.error("Video Annotation Failed.");
      return;
    }
    const isPossiblyPorno =
      annotations?.explicitAnnotation?.frames?.some((v) =>
        v?.pornographyLikelihood
          ? v?.pornographyLikelihood >
            protos.google.cloud.videointelligence.v1.Likelihood.POSSIBLE
          : false
      ) || pornographyPossibleCount / frameCount >= pornoFrameRatioThreshold;

    // 結果出力
    console.log(isPossiblyPorno);
  });

結果をSlackへ通知

不適切な動画がサービスに投稿されたらすぐにお知らせしてほしいので, slackに通知を送ることとします.

前述したコードの最後の判定結果 isPossiblyPorno を用いた以下の感じの処理を挟みます.

export const safeSearch = functions
  .region(REGION)
  .storage.bucket(functions.config().storage.bucket)
  .object()
  .onFinalize(async (object) => {

      // 前述の判定処理

      // 不適切判定がTrueなら通知
      if (isPossiblyPorno) {
      // slack通知のためのトークンなどを環境変数から取得
      const SLACK_TOKEN: string = functions.config().slack.oauth_token;
      const SLACK_INSPECTION_CHANNEL: string =
        functions.config().slack.inspection_channel;
      const slackClient = new WebClient(SLACK_TOKEN);

      const STORAGE_PUBLIC_HOST: string =
        functions.config().storage.public_host;
      const videoUrl: string = new URL(
        path.join(bucket.name, filePath),
        STORAGE_PUBLIC_HOST
      ).href;

      try {
        await slackClient.chat.postMessage({
          channel: SLACK_INSPECTION_CHANNEL,
          blocks: [
            {
              type: "section",
              text: {
                type: "mrkdwn",
                text:"<!channel>\n危険な動画が投稿された可能性があります!!!",
              },
            },
            {
              type: "actions",
              elements: [
                {
                  type: "button",
                  text: {
                    type: "plain_text",
                    text: "ビデオを確認",
                  },
                  url: videoUrl,
                },
              ],
            },
          ],
        });
      } catch (error) {
        functions.logger.error(`Sending a slack message failed`);
      }
    }

    return;
}

これで実装が完了です.

実サービスに投入しての,精度とかに関する所感

実際に,定期的に動画がアップロードされる実サービスに導入してみた所感が以下のような感じです. アップロード数は大体日に50~100ぐらい?

  • 稀にアップロードされる完全にアウトな動画(陰部丸出しの自撮り,AVなど)は,ほぼLIKELY以上となっており,全部弾けていた.数千上がってきて2つぐらいあった.
  • 水着や男性の上半身裸などにもLIKELYぐらいで反応するので,その辺は人手の追加チェックが必要(サービスの仕様によると思う).大体日に3, 4件ぐらいは反応していた.
  • 手だけ映っているような,セーフだが肌色が多めな画像にも時々反応していた.

ということで,人目で見て明らかにアウトなものは漏れなく検出できましたが,セーフなものも数%ぐらいは検出してしまうので,人手による追加チェックは必須だなという印象でした(AIの判断結果を100%信用するというのもあんまりなさそうな気はしますが).

ちなみに処理時間は,動画の長さにもよりますが大体30秒~1分ぐらい.時々Cloud Functionsの実行時間のデフォルトlimitである1分を超えてしまっていたので,その辺は実際に導入して調整する感じになると思います.

その他細かい知見など

  • Video Intelligenceは処理回数,処理時間に対してrate limitがあり,動画数個ですぐ引っかかるので,最初からあげておいた方が良い.
  • 公式的には判定できるのはポルノ動画かどうかのみになっており,バイオレンス動画かどうかなどは判定できない模様.ただ,銃撃戦とかの動画をあげたら反応したので,全くというわけでもないのかもしれない(たまたまかもです).
  • 今回は使わなかったが,人物検出とかロゴ認識とか色々あるので,初期サービスで人間的処理の自動化を簡単にでも導入するにはとても便利だと思いました.

最後に

GCPのVideo Intelligenceを用いて,GCSに上がった動画に自動でセーフサーチを行う内容について書きました. もしこれを自分で1からやることを考えると,モデルの学習や管理,MLサーバのホスティングなど大量の作業が必要になるので, こんな感じでML処理をラップしてくれるサービスはありえんほど便利だなと思いました. 一方,サービスの要件によってどの程度の精度が必要か,目的のタスクを解けるかはかなり違ってきそうなので, その辺の検証を行うのは大事だなと実感しました(今回だとどんぐらいの誤検知は許容するかなど).

次回はアプリの方に戻って,強制アップデートの実装について書いていこうと思います.

Twilio Syncを用いたリアルタイム通信 with React

はじめに

これは2021年振り返りカレンダーの9日目の記事です.

前回はTwilio Programmable Videoで作ったビデオ通話アプリのUXを向上させるという内容でした.

serenard.hatenablog.com

今回は,Twilioが提供する,ブラウザやアプリで簡単にリアルタイム通信を実装できるサービス,Twilio Syncについて解説していきます.

Twilio Syncでは以下の種類のオブジェクトの更新をリアルタイムに共有させることができます.

  • Documents.JSONオブジェクト.
  • List.JSONオブジェクトなどのリスト.
  • Map.keyにJSONオブジェクトが対応してるケース.
  • Message Stream

自分はReactアプリで,ブラウザ間でJSONオブジェクトを共有させるのを実サービスに導入していたため, どんな感じでやったかとかを書こうと思います.

ちなみに選定理由は以下の感じです.

  • Twilio Programmable Videoをサービスで用いていて,同じTwilioで都合が良かったので.
  • 時間がなかった上にWebsocketサーバの実装が未経験で,できればマネージドサービス使いたいと思ったため.
    • 共有オブジェクトの更新は低頻度かつ利用箇所が一部だけだったため,料金的にも問題ないだろうという感じ.
  • バックエンドサーバにCloud Runを用いていて,サーバレスでWebsocket厳しそうだったので(今はゴリゴリ対応してます Using WebSockets  |  Cloud Run Documentation  |  Google Cloud

仕様

フロントエンドはReact,バックエンドはgoで書いてるとします. また,Syncで扱えるもののうち,Documentsを利用します.

実装

処理は以下のような手順になります.

  1. twilioコンソールからTwilio Syncを利用するためのService SIDなどを取ってくる.
  2. クライアント側でAPIからSync用のトークンを取得する(オブジェクトごとにユニーク).
  3. 取得したトークンをもとにSyncオブジェクトに接続.
  4. オブジェクトの更新を送ったり受け取ったりする.
  5. オブジェクトを削除.

1個ずつ解説していきます.

Syncサービスの作成と,サービスSIDの取得

参考

qiita.com

Sync用トークンの生成

まず,Syncオブジェクトに接続するためのトークンを生成します. 生成には公式が出しているgoのパッケージを用います.

github.com

twilio周りの処理をラップしたパッケージのコードは以下のような感じです.

import (
    "context"
    "net/url"
    "time"

    "github.com/twilio/twilio-go"
    "github.com/twilio/twilio-go/client/jwt"
)

type Client struct {
    service        *twilio.Client
    accountSID     string
    apiKeySID      string
    apiKeySecret   string
}

func New(accountSID, apiKeySID, apiKeySecret, syncServiceSID string) *Client {
    return &Client{
        service:        nil,
        accountSID:     accountSID,
        apiKeySID:      apiKeySID,
        apiKeySecret:   apiKeySecret,
        syncServiceSID: syncServiceSID,
    }
}

func (c *Client) NewSyncToken(identity string, ttl time.Duration) (string, error) {
        params := jwt.AccessTokenParams{
                AccountSid:   c.accountSID,
                SigningKeySid: c.apiKeySID,
                Secret:        c.apiKeySecret,
                Identity: identity,
                Ttl:      c.Ttl,
        }
    jwtToken := jwt.CreateAccessToken(params)
    syncGrant := &jwt.SyncGrant{
                ServiceSid: c.syncServiceSID,
        }
    jwtToken.AddGrant(syncGrant)
    return jwtToken.ToJwt()
}

トークンを生成するNewSyncTokenメソッドには,オブジェクトのユニーク名を表す Identityと有効期間を表すttlを渡します.

例えばユーザが集合するルームで共有するオブジェクトを 生成するならば,そのルームのIDとともに生成する感じになります.

また,ttlはルームの存続時間より少し長く設定する形になると思います.

上記のパッケージを以下のような感じで呼び出してtokenをクライアントに返します.

func f() {
        TWILIO_ACCOUNT_SID := "hogehogehoge"
        TWILIO_API_KEY_SID := "fugaugafuga"
        TWILIO_API_KEY_SECRET := "piyopiyopiyo"
        TWILIO_SYNC_SERVICE_SID := "paupau"
        twl := twilio.New(TWILIO_ACCOUNT_SID, TWILIO_API_KEY_SID, TWILIO_API_KEY_SECRET, TWILIO_SYNC_SERVICE_SID)
        token, err := twl.NewSyncToken("room:1", 4 * time.Hour)
}

クライアント側の実装

クライアント側では,SyncオブジェクトをReact hooksのsetStateと同じように利用するために, 以下のようなState Providerを用意しました.

import React, { createContext, ReactNode, useState, useRef } from "react";

import SyncClient from "twilio-sync";

export const SyncContext = createContext<ISyncContext>(null);

interface SyncProviderProps {
  fetchSyncToken: () => Promise<string>;
  sessionID: string;
  ttl: number;
  children: ReactNode;
}

// 共有するオブジェクトの型
export type SyncState = {
  shareObj: ShareObj;
};

export type ShareObj = {
  text: string;
};

const defaultSyncState = {
  shareObj: {
    text: "initial text",
  },
};

export interface ISyncContext {
  syncState: SyncState;  // 共有する状態
  initSyncState: () => void;  // オブジェクトの初期化を行う
  deleteSyncState: () => void;  // オブジェクトを削除する
  setSyncState: (syncState: SyncState) => void;  // 共有オブジェクトを更新(他のユーザにも更新が共有される)
  createSyncClient: (token: string) => void;  // syncオブジェクトへの接続
}

export const SyncProvider = ({
  fetchSyncToken,
  sessionID,
  ttl,
  children,
}: SyncProviderProps): JSX.Element => {
  const client = useRef<SyncClient>();
  const [syncState, setSyncStateLocal] = useState<SyncState>({
    syncObj: {
      text: "init",
    },
  });

  // Syncクライアントの初期化
  const createSyncClient = async (token: string) => {
    client.current = new SyncClient(token, { logLevel: "info" });

    // Syncオブジェクトへの接続状態が変化した時の動作を定義.
    client.current.on("connectionStateChanged", (status) => {
      if (status === "connected") {
        loadState();
      }
    });

    // トークンの時間切れが近い時の処理.トークンを再fetchしたりする.
    client.current.on("tokenAboutToExpire", async () => {
      const token = await fetchSyncToken();
      if (!token) return;
      refreshSyncClient(token);
    });

    // トークンが時間切れになった時.
    client.current.on("tokenExpired", async () => {
      const token = await fetchSyncToken();
      if (!token) return;
      refreshSyncClient(token);
    });
  };

  const refreshSyncClient = (token: string) => {
    client.current.updateToken(token);
  };

  // Syncオブジェクトをデフォルト状態にする.
  const initSyncState = () => {
    setSyncState(defaultSyncState);
  };

  // Syncオブジェクトを削除.誰もオブジェクトを参照しなくなった時などに利用.
  const deleteSyncState = () => {
    client.current.document(sessionID).then(async (doc) => {
      doc.removeDocument();
    });
  };

  // Sync オブジェクトを更新する.この更新がユーザ間に共有される.
  const updateSyncState = (syncState: SyncState): void => {
    if (!client.current) {
      return;
    }
    client.current.document(sessionID).then((doc) => {
      doc.set(syncState, { ttl: ttl });
    });
  };

  // 接続成功時などに,すでにあるオブジェクトの内容を取得してくる
  const loadState = () => {
    if (!client.current) {
      return;
    }
    client.current.document(sessionID).then((doc) => {
      setSyncStateLocal(doc.data as SyncState);
      doc.on("updated", (data) => {
          setSyncStateLocal(data.data as SyncState);
      });
    });
  };

  // 実装者が使うsetSate.
  const setSyncState = (syncState: SyncState) => {
    setSyncStateLocal(Object.assign({}, syncState));
    updateSyncState(syncState);
  };

  return (
    <SyncContext.Provider
      value={{
        syncState,
        setSyncState,
        createSyncClient,
        initSyncState,
        deleteSyncState,
      }}
    >
      {children}
    </SyncContext.Provider>
  );
};

このProviderを利用して,前述した以下の手順を実現していきます.

Syncオブジェクトへの接続

共有Stateを利用したいAppコンポーネントを,SyncProviderでラップします.

SyncProviderには,トークンのフェッチを行うためのasync関数と,SyncオブジェクトのユニークID,ttlを渡します.

ここで初期化の際にSync用のトークンを直で渡さないのは,Syncオブジェクトへの接続が不安定になった際に トークンの再フェッチを行うので,そことトークン取得処理を共通化させたいためです.

 <SyncProvider
      fetchSyncToken={async () => {
          const { response, error } = await getToken();
          if (error) return;
          return response.data.sync_token;
      }}
      sessionID={`room:${RoomID}`}
      ttl={(ROOM_MINUTE}
    >
      <div>
        <App />
      </div>
</SyncProvider>

Syncオブジェクトの取得と更新

SyncProvider以下のコンポーネントでは,以下のような形でSyncオブジェクトの取得と更新を行います.

syncStateで共有されたSyncオブジェクトを取得し,setSyncStateで更新します.

SyncProvider内で面倒な処理をラップしているので,通常のstateと似たような感じで扱うことができます (更新の衝突などは起きるので,動作確認やテストはしっかりしないといけませんが...).

import useSyncContext from "~/app/hooks/useSyncContext";

const App = (): JSX.Element => {
  const { syncState, setSyncState } = useSyncContext();
  
  return (
    <div>
      <input type="text" onChange={e => setSyncState(e.target.value)} value={syncState.text}>
    </div>
  );
}

Syncオブジェクトの初期化・削除

最後に,共有オブジェクトを利用しているルームなどが終了した場合,Syncオブジェクトも同様に削除します.

削除もしくは初期化を行うかしないと,再度同じルームに接続した時に,前に操作してた時の状態が残っているという, 不気味な感じのことが起きてしまいます(仕様によりますが).

初期化は以下のような感じで行います.initSyncStateを呼び出すだけです.

オブジェクトを初期化するか,削除するかは,仕様によって選択する感じになると思います. 単純に削除してしまうと,他のユーザが取得するオブジェクトが急にnullだったかになってしまうので, その辺のハンドリングをうまくやる必要があります.

import useSyncContext from "~/app/hooks/useSyncContext";

const App = (): JSX.Element => {
  const { syncState, initSyncState } = useSyncContext();
  
  return (
    <div>
      <button type="text" onClick={}>
        leave room
      </button>
    </div>
  );
}

その他細かい知見など

  • Twilio Syncベストプラクティスをとりま読んでみる(Best Practices for Building with Sync SDKs | Twilio).
  • 共有オブジェクトの更新と読み込みの処理が連なっていると無限更新ループが走ってしまうので注意.
    • Provider内でローカルオブジェクトとSyncオブジェクトを分けているのはこれに対応するためだった...はず?(書いたの1年ぐらい前なので忘れちゃいました...)
  • オブジェクトのリアルタイム共有はバグおきやすいのでデバッグのケースが多い.
    • 削除後の参照とか,初回アクセス時のオブジェクトがない場合どうするかとか.
  • Syncオブジェクトの削除は他のユーザに影響があるので,基本は初期化の方がいい気がする.
    • オブジェクトは時間が経つと自動で消えてくれるらしい.TTLの間は持つが,消える時間は厳密に決まってない模様.

最後に

Twilio Syncを用いて単純なリアルタイム通信を実装してみました. ゲームなど複雑なものをこれでやるのはかなり厳しそうですが,SaaSなどの細かい1機能として リアルタイム通信導入したいみたいな場合には実装も楽ですごく便利なんじゃないかと思います(利用箇所が多いなら 自分でwebsocketサーバ立てた方が良さそうですが).

今回紹介したSyncProviderは,自分がそこまでreactやリアルタイム通信に詳しくないこともあり もっと良くできそうな箇所は多々ありそうですが,ReactにすぐにSyncを導入するためのサンプルとして 使ってもらえたら嬉しいです.

次回はGCPのVideo Intelligenceを用いた不適切画像の検知について書こうと思います.

Twilio Programmable Videoを用いたビデオ通話の実装 with React(UX編)

はじめに

これは2021年振り返りカレンダーの8日目の記事です.

前回はTwilio Programmable VideoとReactを用いてビデオ通話を実装するという話でした.

serenard.hatenablog.com

今回は,実装したTwilioのビデオ通話の質をどんな感じで調整するかについて書いていきます.

仕様

前回同様,フロントエンドはReact (typescript),バックエンドはgoとします. また,ビデオ通話は最大でも十数人程度で行うとします(ライブ配信みたいに 数百,数千人とかは想定していない).

参考

Developing High Quality Video Applications | Twilio qiita.com

パフォーマンスチューニング

パフォーマンスチューニングについては,Twilioエバンジェリストの方が書いてくださっている以下の記事が とても参考になります.というか自分が書くのは以下の記事の劣化版です. 一応自分がやった部分とかを書いていこうと思います.

qiita.com

Default Connection Optionsの設定

Twilio Video GroupRoomでは,room作成の際に接続に関するパラメータの設定を行います. 以下のような感じです.

const defaultConnectionOptions: ConnectOptions = {
  bandwidthProfile: {
    video: {
      mode: "collaboration",
      trackSwitchOffMode: "detected",
      dominantSpeakerPriority: "standard",
      maxTracks: 10,
    },
  },
  dominantSpeaker: true,
  networkQuality: { local: 1, remote: 1 },
  maxAudioBitrate: 16000,
  preferredVideoCodecs: [{ codec: "VP8", simulcast: true }],
  region: "jp1",
};

Regionの設定

Video Regions and Global Low Latency | Twilio

GroupRoom方式では,Twilio側のサーバを経由してビデオ通話を行います. その際,サーバのロケーションをユーザに最も近い場所に設定しておくことで 通話の品質を向上することができます.

Regionには,ユーザ個別に関わるSignaling Regionと, ルームのユーザ全体に関わるMedia Regionの2つがあります.

connection optionではSignaling Regionを設定します.

例えば,日本のユーザの場合はregionを"jp1"にすることで レイテンシが上がり,通話の速度や品質が向上します.

"gll"を設定した場合は,ユーザにとって最もレイテンシが低いregionが選択されるとのことなので, 複数国にわたってサービスを展開している場合などはそうするのが良さそうです.

また,Media RegionはTwilioのコンソールより設定できます.

f:id:sheep96:20220209113250p:plain

こちらのRegionは通話に参加しているユーザからの平均レイテンシが最も低くなるように設定します. 日本国内のみならば,Signalingと同様に"jp1"にするのが良さそうです.

また,グローバルサービスではユーザのRegionが複数あるので,全てに対して 平均的に近い場所を選ぶのが難しいです. そのような場合は"gll"に設定します. ただ,"gll"と言ってもSignalingの時とは挙動が違い,最初にRoomに接続したユーザのRegionに 設定されるようです(SFUサーバは全ユーザに共通するので).

Bandwidth Profile

Using Network Bandwidth Profile API for Programmable Video | Twilio

mode

mode にはcollaboration, grid, presentationがあり,デフォルトはcollaborationになっています.

それぞれを簡単に解説すると,

  • collaborationは一部の人のトラックを他より優先して表示するものです.優先表示する人については,喋っている人(dominant speaker)を自動認識させたり,プログラム側で設定することもできます(優先側はpriorityをhigh, 他の人はlowにする).授業や,小・中規模のミーティングなど,全員いて欲しいが喋る人を優先したい場合に向いているでしょう.
  • gridは全員のトラックを均等に配信するものです(全員のpriorityをstandardにする).向いてるケースは友達同士のお話や,ミーティングなどみんなおんなじ感じで出てて欲しい場合でしょうか.
  • presentationは一部の人のトラックに帯域を全て割り振るというものです.帯域が余った場合は他の参加者のトラックにも使われます.collaborationとの違いは,優先されている人以外が表示されるかどうかです.公演のような聴衆がたくさんいるケースに向いてると思います.

自分の場合は,先生と生徒がいる授業サービスをやっていたので,collaborationに設定しました.

trackSwitchOffMode

Using Network Bandwidth Profile API for Programmable Video | Twilio

trackSwitchOffModeでは,参加者の下り帯域幅が足りなくなった時の挙動をpredicted, detected, disabledから指定できます (デフォルトはpredicted). それぞれ以下のような挙動をします.

  • predictedでは,実際に帯域が足りなくなるのを自動で予測し,一部のトラックをオフにします(audioとpriorityがhighのもの以外).喋ってる人以外は最悪見えなくても問題ないケースで用います.
  • detectedでは,実際に帯域が足りなくなった時にのみトラックをオフにします.不必要なトラックオフを防げる一方で,ビデオが止まったり,音声が途切れる現象がユーザ側に出てしまいます.継続して表示することが重要になるケース(授業など通話に課金が関わるケース)で有用なんじゃないかと思います.

  • disabledでは,トラックオフを行いません.そのため,停止したビデオがずっと表示されることが起こり得ます.公式的には,よっぽどの理由がなければ使わない方がいいとのこと.

自分の場合は通話に対して課金が発生するサービスをやっていたので,お金を払っているユーザに不利益が出ないよう,detectedにしていました.

dominantSpeaker & dominantSpeakerPriority

Detecting the Dominant Speaker | Twilio

dominant speakerとは,先ほども少し出てきましたが,Room中で声を発しているユーザのことを指します. この設定をtrueにしておくと,trackに動的に変化するdominant speakerかの情報が付与されるので,それを用いて 優先表示したりできます.ZoomやMeetなどのように,話してる人を大きく表示したい時に使えます.

dominantSpeakerPriorityは,名前の通りdominant speakerのpriorityを設定するものであり, 例えばこれをhighにしておけば,喋ってる人のトラックは絶対オフにならないようにできます.

自分の場合は,生徒と教師のように必ずしも喋ってる人が重要ではないという感じだったので,dominantSpeakerPriorityはstandard にしてました(そもそもこの機能onにしなくてよかったかも...).

networkQuality

Twilioではユーザのネットワーク品質を得るための,Network Quality APIが用意されています. 通話サービスだと通信強度を表すバーなどが出ていますが,あれのために利用します.

Using the Network Quality API | Twilio

optionsにおけるnetworkQualityでは, ローカルとリモートのネットワーク状況をどの程度詳細に取るかを指定します.

0〜3(none, minimal, moderate, detailed)の範囲でレベルがあるようですが, 自分は公式で推奨されていた1 (minimal) にしていて,高いレベルにしたときに 取れるデータをどう使うかはよくわかっていないです. 一応まとめておくと,

  • minimalでは参加者にNQレベルをレポートする.
  • moderateではNQレベル以外にNetworkQualityStatsを提供する.NQSは音声,動画の受信・送信状況より構成される.
  • detailedでは,NQSを,音声・動画の送受信状況に加え,NetworkQualityMediaStatsを持っている.NQMSは帯域やレイテンシなどの情報を含む.

minimalで,一般的な通信状況を表すバーは出せるのですが,検証を行う場合などにより詳細なメトリックが欲しい場合は このレベルを上げたりするのかも?

preferredVideoCodecs

こちらにおいては,通信を行う際のコーデックと,Simulcastを有効かするかの指定をします.

今回はcollaborationモードがSimulcastでしか使えないということで,VP8 Simulcastとしましたが, 前述の記事によると,コーデックは以下のような基準で選ぶと良いそうです.

パフォーマンスチューニングの視点でみると、利用するデバイスの多くがモバイル端末であり、H.264による恩恵が受けられそうな場合や、H.264端末しか利用できないデバイスが必須の場合ではH.264を積極的に採用し、それ以外のケースではVP8で通信できるようにしておくとよいでしょう。特にVP8には、オプションとしてVP8 Simulcastが利用できるため、複数の参加者が利用するビデオ会議でパフォーマンスを出しやすくなります。 by Twilio Videoのパフォーマンスチューニング - Qiita

コーデックはデバイスやブラウザによって対応・非対応があるため,詳細は以下を参照.

Managing Codecs | Twilio

Simulcastは,ネットワークの下りが遅いユーザがいた時,それに合わせてpublishする データのクオリティを下げなくても良いよう,各ユーザが複数の品質のトラックをpublish し,最適なものをsubscribeさせる仕組みです.詳細は以下など.

Working with VP8 Simulcast | Twilio

WebRTC Simulcast コトハジメ · GitHub

maxAudioBitrate

maxAudioBitrateについては,公式によると,音声として人の話し声が主の場合は,帯域制限のために 16kbps程度にし,それ以外の場合は設定しない(制限なし)のが推奨らしいです. 音楽などを流す場合などは特に制限をかけない方がいいそうです.

Developing High Quality Video Applications | Twilio

動画のサイズ

動画の品質と通信品質はトレードオフなので,用途に合わせて適宜調整します. サンプルコードとしては以下あたりで設定します.

twilio-video-app-react/constants.ts at master · twilio/twilio-video-app-react · GitHub

調整のための考え方は以下が参考になります.Twilio側で,コーデックと解像度に応じて必要な 帯域を示してくれているので,ユーザのネットワーク環境に応じて適切な動画解像度を設定する感じになります.

Twilio Videoのパフォーマンスチューニング - Qiita

また,VP8 Simulcastを用いる場合は,以下のリンク先の表のように,3段階で異なる解像度の映像を生成し, ユーザによって最適なものを流してくれるので,あまりネット環境が悪いユーザに合わせて解像度を決めなくてよくなります.

Working with VP8 Simulcast | Twilio

ネットワーク状況の表示

ユーザのネットワーク状況が悪いと,アプリが正常だったとしてもユーザ側に不具合が出てるように見え,クレームが入ったりします. そのため,前述したNetwork Quality APIを使って,適切なネットワーク状況かをユーザ側に常に提示するように します. 例えば,接続前のチュートリアルなどで,ネットワーク状況のバーが2本以下だと動作が不安定になります,などの注意書きをしておきす.

サンプルの実装の以下が参考になります.

twilio-video-app-react/NetworkQualityLevel.tsx at 923fa2c799b39fc6191c5ea0892c2f448b59a5ab · twilio/twilio-video-app-react · GitHub

エラーハンドリング

ビデオ通話においては,以下のような様々なエラーが発生し,それに応じてユーザに適切な対応をしてもらう必要があります.

  • カメラなどのデバイスにアクセスできない
    • 権限がない -> 許可してもらう
    • 他のアプリケーションに使われている -> 他のアプリ停止をお願いします
    • そもそもカメラついてない -> 使えへんよ!
  • ネットワーク環境が悪い
    • ユーザ側のネットワークが重い -> 場所移動してもらう
    • アプリケーション側が死亡 -> ごめんなさい
  • トークンの有効期限が切れている

こちら側に非がないエラーもあるのが大変です. そのため,吐かれたエラーに応じて,ユーザにしてもらうべき動作を提示する通知を行うのが良いです.

自分は以下のようなutil関数を作り,エラー通知を行なっていました.

const getErrorContent = (
  hasAudio: boolean,
  hasVideo: boolean,
  error?: Error
): string => {
  switch (true) {
    // This error is emitted when the user or the user's system has denied permission to use the media devices
    case error?.name === "NotAllowedError":
      if (error?.message === "Permission denied by system") {
        // Chrome only
        return "OSによりマイク、カメラへのアクセスがブロックされました。設定の見直しを行ってください。";
      } else {
        return "ユーザによってデバイスへのアクセスが拒否されました。ブラウザにデバイスへのアクセス権限を与えてください。";
      }

    // This error is emitted when input devices are not connected or disabled in the OS settings
    case error?.name === "NotFoundError":
      return "マイク、カメラが見つかりませんでした。デバイスが接続,有効化されていることを確認してください。";

    case error?.name === "NotReadableError":
      return "マイク、カメラを取得できませんでした。デバイスを他のアプリケーションで利用している場合は終了させてください。";

    case error?.name === "AbortError":
      return "マイク、カメラを取得できませんでした。デバイスを他のアプリケーションで利用している場合は終了させてください。また、それでも起動しない場合は、ブラウザの再起動を行ってください。";

    case !hasAudio && !hasVideo:
      return "カメラ及びマイクが検出されませんでした";

    case !hasVideo:
      return "カメラが検出されませんでした";

    case !hasAudio:
      return "マイクが検出されませんでした";
  }
};

export default getErrorContent;

以下のように呼び出します.

const { hasAudioInputDevices, hasVideoInputDevices } = useDevices();
getAudioAndVideoTracks().catch((error) => {
    notify.error(
      `${getErrorContent(
        hasAudioInputDevices,
        hasVideoInputDevices,
        error
      )}`
);

自分は特に,WindowsLinuxで発生する,他のアプリがカメラを使っていると出るエラーに 苦しめられました(Macだと起きないので).できたと思って一旦先方に出したら, エラー出て動きませんと来て,なんで!?となり,実はZoom繋ぎながら触ってたからだったという ことがありました.

参考はサンプルのこの辺です.

twilio-video-app-react/MediaErrorSnackbar.tsx at master · twilio/twilio-video-app-react · GitHub

その他細かい知見など

  • ネットワーク状況とかのテストは,ブラウザの検証機能についてるのでやっていた.
  • パフォーマンスチューニングをちゃんとやるならばユーザタイプごとにpublishする動画サイズを変えた方がいいのだろうが,そこまでできなかった.
  • Twilioのjs sdkだと,VP8 Simulcastはfirefoxに対応していない.
  • Twilioでは,コンソールから,過去のRoomの参加者の通話・切断履歴やデバイス,OSなどがみれるので それをもとにエラーが起きてないか,ネットワーク状況の下限はどのぐらいかとかを 観察することができます(切断を繰り返してるケースとかがたまにある).
  • また,ビデオの録画もできるので,エラーの詳細やユーザ調査も可能です. Understanding Video Recordings and Compositions | Twilio

Roomの履歴の実例 f:id:sheep96:20220209165109p:plain

最後に

Twilioでビデオ通話を作るときに,パフォーマンスやUXのために気をつけたことについて書きました. パフォーマンス周りはほとんど記事まんま(劣化版)みたいな感じですが,体験記として みてもらえると嬉しいです.あとWebRTCとか動画まわりについて知らないことばかりだったので記事を読んでいてとても 勉強になりました.

その他,ネットワーク周りやエラーハンドリング周りについては,開発側の非とユーザ側の環境要因を 分離するのが難しく,結構悩まされました.例えユーザ起因であっても,エラーが出た時の利用者側ストレスは 半端ないと思いますし,クレームも受けたりしたので,そこら辺の対応処理は今後も気をつけていきたいと思いました(原因と対応方法を詳細に表示するだけで全然違うことを理解した).

次回は,Twilioが提供する,複数ブラウザ間でリアルタイムでの状態同期(jsオブジェクトなど)のためのSDKである Twilio Syncの利用方法について書いていこうと思います.

Twilio Programmable Videoを用いたビデオ通話の実装 with React(通話編)

はじめに

これは2021年振り返りカレンダーの7日目の記事です.

前回はsqldefをCloud Build上で動かし,マイグレーションの自動化を行うという内容でした.

serenard.hatenablog.com

今まではCI/CD周りの内容を主に書いてきましたが,今回はうって変わって ビデオ通話機能についてです.

ビデオ通話機能を実現するためのSDK, SaaSとして有名なものとしては以下があります.

  • Twilio
    • SendGridとかでも有名
  • Agora
    • clubhouseで使われてたやつ
    • 数百,数千人規模の接続も可能なのが強み?
  • SkyWay
    • 国産
  • Zoom SDK (New!)

今回はとてもしっかりしたReactサンプルがあるTwilio Programmable Videoを採用することとしました.

github.com

Google Meetに近いものがすぐに立ち上がりますし,コードもすごいしっかりしています. これをベースとして独自のビデオ通話を作る時のポイントを書いていきます.

Twilioの料金回り(グループ通話まわり)

  • 初期費用なし
  • ユーザ1人,1分につき0.44円
  • グループ通話は最大50人まで(相談可能ぽい?)

詳しくは以下(回者じゃないです).

www.twilio.com

仕様

フロントエンドはReact (typescript),バックエンドはgoとします. また,ビデオ通話は最大でも十数人程度で行うとします(ライブ配信みたいに 数百,数千人とかは想定していない).

画面は一旦,twilioのサンプルみたいな形を想定します.

https://user-images.githubusercontent.com/12685223/94631109-cfca1c80-0284-11eb-8b72-c97276cf34e4.png

実装

プロジェクトの設定

以下を参考にプロジェクトを作成し,APIキーの取得を行います.

dev.classmethod.jp

ビデオ通話のための最小構成

Twilioでは1対1での通話を行うためのGo Room,P2Pで複数人通話を行うP2P Room,WebRTC SFUで複数人 通話を行うGroup Roomがあります.

Understanding Video Rooms | Twilio

今回はGroup Roomを用います.

通話開始から終了までの流れは以下の感じになっています.

  1. クライアント側でユーザがカメラやマイクを確認するための準備画面を出す.

  2. クライアントは,サーバから識別子とともに生成したRoom接続用のTokenを取得する.

  3. クライアントは,取得したTokenをもとに通話Roomへの接続を行う.

  4. 他ユーザが接続してきたらそれを表示.適宜画面共有など.

  5. 通話終了.

通話参加のためのトークンを返すAPI

ユーザが通話用ルームに入るためには,識別子をもとに生成したトークンが必要です. 今回はgoのtwilioクライアントとして,twilio-goを用います.

github.com

まだ開発中なようですが,公式のやつも出てました.

GitHub - twilio/twilio-go: A Go package for communicating with the Twilio API.

twilio周りの処理をラップしたパッケージのコードは以下のような感じです.

package twilio

import (
    "context"
    "net/url"
    "time"

    "github.com/kevinburke/twilio-go"
    "github.com/kevinburke/twilio-go/token"
)

type Client struct {
    service        *twilio.Client
    accountSID     string
    apiKeySID      string
    apiKeySecret   string
}

func New(accountSID, apiKeySID, apiKeySecret, syncServiceSID string) *Client {
    return &Client{
        service:        nil,
        accountSID:     accountSID,
        apiKeySID:      apiKeySID,
        apiKeySecret:   apiKeySecret,
        syncServiceSID: syncServiceSID,
    }
}

func (c *Client) NewVideoToken(identity, roomName string, ttl time.Duration) (string, error) {
    twToken := token.New(
        c.accountSID,
        c.apiKeySID,
        c.apiKeySecret,
        identity,
        ttl,
    )
    videoGrant := token.NewVideoGrant(roomName)
    twToken.AddGrant(videoGrant)
    return twToken.JWT()
}

func (c *Client) NewRoom(ctx context.Context, authToken, roomName string) (*twilio.Room, error) {
    client := twilio.NewVideoClient(c.accountSID, authToken, nil)
    param := url.Values{}
    param.Add("uniqueName", roomName)
    return client.Rooms.Create(ctx, param)
}

上記のパッケージを以下のような感じで呼び出してtokenをクライアントに返します.

ユーザ名にはユーザ固有のidなどを入れてユニークにするとともに, クライアント側で表示するための名前も入れましょう.

また,ルーム名もユニークになるようにします.例えば1つのミーティングに対して ルームを作るなら,ミーティングIDなどをもとにすると良いです.

最後にトークンの有効時間(TTL)ですが,こちらは想定される通話時間よりも少し長いぐらいに 設定するのが良いです.短めに設定してしまうと,通話中にネットが不安定になるなどして再接続を 試みる際にそれが失敗してしまいます.

func f() {
        TWILIO_ACCOUNT_SID := "hogehogehoge"
        TWILIO_API_KEY_SID := "fugaugafuga"
        TWILIO_API_KEY_SECRET := "piyopiyopiyo"
        TWILIO_SYNC_SERVICE_SID := "paupau"
    twl := twilio.New(accountSID, apiKeySID, apiKeySecret, syncServiceSID)
        token, err := twl.NewVideoToken("user:1:けんた", "room_abc", 4 * time.Hour)
}

クライアント側の実装

クライアント側の実装は前述したサンプルに準拠させます(1から自分で作るにはかなりの修練と時間が必要そう). 特にhooksとcomponentsをほぼコピーしてきて魔改造します.

github.com

肝になる部分について解説していきます.

通話関連の状態を管理する VideoProvider

まず,VideoProviderです.

twilio-video-app-react/index.tsx at master · twilio/twilio-video-app-react · GitHub

これは通話関連のstateなどを持つProviderです.以下のような変数,関数を提供しています.

  • 通話ルームの実態 room
  • クライアントのローカルトラック(ビデオやマイクなど)
  • roomへの接続用関数 connect
  • roomへ接続属中かを表す状態変数 isConnecting
  • 画面共有のオンオフに利用する toggleScreenShare
  • その他諸々...

通話用のコンポーネントはVideoProviderの下におき,上記のような 関数,変数を参照しながら通話画面の制御を行なっていきます.

const Video: FC = () => (
  <VideoProvider
    options={defaultConnectionOptions}
    onError={() => {
      console.log("error");
    }}
  >
      <VideoApp />
  </VideoProvider>
);

ビデオ,音声などの確認画面

次は,通話ルーム接続前の確認画面です. このステップではクライアントのローカルトラックを 画面上に出すだけです.

実装は以下のような感じになります.

VideoProviderが持っているlocal tracksからビデオを探し,それをVideoTrackコンポーネントで表示しています. また,ユーザがキャンセルを行なった場合はlocal tracksをremoveし,前の画面に戻ります. ユーザが入室ボタンを押した場合は,取得したトークンをconnectに渡し,ルームへの接続を行います.

const PreJoinRoom: FC = () => {
  const {
    connect,
    isConnecting,
    localTracks,
    removeLocalAudioTrack,
    removeLocalVideoTrack,
  } = useVideoContext();

  const handleClickEnter = async () => {
    let lessonTokens: LessonTokens;
    const {
        response: tokenRes,
        error: tokenErr,
      } = await apiGet(`/token`, {});
      const token = tokenRes.data;
    }
    connect(token);
  };

  const handleCancel = () => {
    removeLocalAudioTrack();
    removeLocalVideoTrack();
  };

  const videoTrack = localTracks.find((track) =>
    track.name.includes("camera")
  ) as LocalVideoTrack;

  const audioTrack = localTracks.find(
    (track) => track.kind === "audio"
  ) as LocalAudioTrack;

  return (
    <div>
        <VideoTrack track={videoTrack} isLocal />
      <div css={homeBar}>
        <button
          css={enterButton(isConnecting)}
          onClick={handleClickEnter}
          disabled={isConnecting}
        >
          {isConnecting ? "接続中" : "入室"}
        </button>
        <button css={cancelButton} onClick={handleCancel}>
          "戻る"
        </button>
      </div>
    </div>
  );
};

export default PreJoinRoom;

ルームへの接続

前述した準備画面で,connect関数を用いてルームへの接続を行いました. connectが成功すると,VideoProvider中にあるroomStateが disconnectedからconnectedに変化するので,それをもとに表示する画面の切り替えを行います. 以下のような感じです.

また,接続が不安定になった場合などは,roomStateがreconnectingになるので, その場合はユーザに通知を行うなどすると親切です.

const VideoApp: FC = () => {
  const history = useHistory();
  const roomState = useRoomState();
  const { getAudioAndVideoTracks, localTracks } = useVideoContext();
  const { hasAudioInputDevices, hasVideoInputDevices } = useDevices();

  useEffect(() => {
    getAudioAndVideoTracks().catch((error) => {
      console.log(error);
      notify.error(
        `${getErrorContent(
          hasAudioInputDevices,
          hasVideoInputDevices,
          error
        )} ${error}`
      );
      history.push("/");
    });
  }, [getAudioAndVideoTracks]);

  useEffect(() => {
    if (roomState === "reconnecting") {
      notify.error(
        "接続が中断されました。ルームへ再接続中です。",
        "video_reconnecting"
      );
    }
  }, [roomState]);

  // roomStateをもとに画面を切り替える
  return (
    <div css={{ height: "100%", width: "100%", background: colors.black }}>
      {localTracks.length > 0 &&
        (roomState === "disconnected" ? <PreJoinRoom /> : <Room />)}
    </div>
  );
};

ビデオ通話画面

通話画面については内容が多いかつ仕様によってかなり異なると思うので,サンプル中のRoomコンポーネントを起点として,コードを 読んで勉強するのが一番良い気がします. それとMenuBarあたり.

twilio-video-app-react/Room.tsx at master · twilio/twilio-video-app-react · GitHub

twilio-video-app-react/MenuBar.tsx at master · twilio/twilio-video-app-react · GitHub

よく出てくる用語をまとめると以下の感じです.

Participant

ビデオ通話への参加者.Main Participantは大きく表示される参加者.

Track

各ユーザが通話のために使うメディア?のこと.ビデオやオーディオなど.入力デバイスごとに存在する感じぽい. 特にローカルユーザのものに関してはlocalTrackと呼ぶ.

Publication

ユーザが通話のために配信するもののこと.Trackの他,オンオフの状態,優先度なども持っている.リモートと ローカルで異なる.

そして,Roomを起点とするコンポーネントの階層とそれぞれの役割は以下のような感じになっています.

Room
├── MainParticipant(メインの参加者.喋ってる人など.)
|  └── MainParticipantInfo(メインの参加者の名前や,ビデオ,ミュートなどの状態の表示を行う)
|      └── ParticipantTracks(publicationのうち,ビデオ,共有スクリーンなど対応してるもののみを取り出し下に渡す)
|         └── Publication(参加者とそのトラックの状態をもとにVideoかAudioかなどを切り替える)
|            └── VideoTrack, AudioTrack(ビデオ or 音声の用コンポーネント)
└── ParticipantList(メイン以外の参加者)
   └── Participant(メイン以外の参加者)
       └── ParticipantInfo(メイン以外の参加者の名前や,ビデオ,ミュートなどの状態の表示を行う)
            └── ParticipantTracks
               └── 以下Main下と同様なので省略

上記のものを読んで依存関係や使い方などを理解した後,自分の要件に合う形で実装するのがいいと思います.

画面共有

画面共有は,VideoProviderが持つ toggleScreenShareを呼び出すことで行います(めちゃ簡単). これを呼ぶと,呼び出したクライアントのpublicationがスクリーンになるので,それを他ユーザが受け取るという感じです.

サンプルだと以下のあたりのコードが参考になります.

twilio-video-app-react/MenuBar.tsx at master · twilio/twilio-video-app-react · GitHub

通話の終了

通話を終了するには,VideoProviderが持つroomのdisconnect関数を呼び出します.

実際のコードはEndCallButtonなどが参考になります.

twilio-video-app-react/EndCallButton.tsx at master · twilio/twilio-video-app-react · GitHub

これで一旦,通話の開始から終了までの実装が完了しました. 他にも配信設定やルーム作成のタイミング,通信状態の表示,エラーハンドリングなどたくさんあるのですが, 次回に回そうと思います.

その他細かい知見など

  • クライアント側でユーザをグループごとに分けたい場合(教師と生徒,スピーカーと聴衆など)は,トークン作成の際のidentityにグループの識別子を入れ,それをもとに制御する.
  • トークンの有効期限が切れてもユーザは強制的にルームから退出させられたりしないので,強制退出を行いたい場合はクライアント側で再起動をタイマーで仕込むのが良いと思う.
    • TwilioのAPI経由でRoomを終了できるが,バッチ処理必要になるので,クライアント側でやった方がいい気がしてます.
  • Windows, Linuxだと,他のアプリケーション(Zoom, Meetその他諸々)でビデオカメラやオーディオなどを使っている場合,トラックの取得部分でNotReadableが起きて失敗する.Macだと起きないので気づかなかった...
    • WebRCT上の問題らしい.https://github.com/twilio/twilio-video.js/issues/325
    • 動作確認の際は他の通話アプリは落とすように注意.
    • ユーザ側にもその旨の通知をした方が良い.
    • Mac以外でブラウザ2つ立ち上げてテストができなかったのでだるい.

最後に

twilio programmable videoを用いてビデオ通話を実装してみました. 自分はtwilioを触るまでWebRTCに関わったことがなかったのですが, これのおかげでそこそこ短いスパンでプロダクション用の通話機能を実装することができました(まだWebRTC詳細について理解できてない部分も多いですが...). よくできたReactサンプルの存在が何よりの助けになった気がします. また,やはりマネージドなSaaSは強い. それと,独自でWebRTC使って実装してる人すげぇ...ってなりました.いつかちゃんと勉強してみたいです.

課金形態は人数及び時間による従量課金なので,ライブ配信みたいな無料通話が発生するケースには向いてないかもですが, レッスンのような通話単位でお金が発生するケースにおいては非常に便利だと思います.

次回は配信設定やエラーハンドリングなど, 通話のクオリティやUXを向上させるための部分について書いていこうと思います.

sqldef + Cloud Buildで自動migration

はじめに

これは2021年振り返りカレンダーの6日目の記事です.

前回は,firebase周りのCI/CD構築と,環境分離を行うという内容でした.

serenard.hatenablog.com

今回は,CDに,sqldefを用いたデータベース(RDB)migrationを組み込む内容について書いていきます.

sqldefはRDBmysql, postgresなど)用のとても便利なmigrationツールで,テーブル定義のsqlファイルと 現状のスキーマを比較し,自動で差分のapplyを行ってくれます. 詳細は以下.

github.com

k0kubun.hatenablog.com

また,使い方は以下などを参照されたし.

qiita.com

sqldefはとても便利ですが,migrationを実行する際に,対象のDBと接続する必要があります. ローカルのDBにかけるだけならば特に気にすることはないのですが,開発環境,本番環境などに 接続してコマンドを実行するのはホストやパスワードの指定が必要で面倒臭いですし, 間違えてapply先を間違えるようなミスすると大変です. よって,sqldefのapply操作をラッピングし,CDフローに組み込むことで,いろんな面倒さを 解消することを目指します.

仕様

今回は,GCP上で,開発環境(service-dev)と本番環境(service-prd)の2つが存在し, それぞれのために以下のsqldefによるmigration操作を行いたいとします.

  • sqldefのdry-run (apply されるスキーマの差分の確認).自動で行われる.
  • sqldefのapply(スキーマ変更の実施).任意のタイミングでコマンドを実行し行う.

また,DBはCloud SQL上に立っており,接続にcloud-sql-proxyが必要とします.

実装

まず,ディレクトリ構成は以下のようになります.

root/
├── api
│  └── schema
│     └── schema.sql
└── release
   ├── dev
   │   ├── migration_dry_run.yaml
   │   └── migration.yaml
   └── prd
       ├── migration_dry_run.yaml
        └── migration.yaml

release下にdev, prd,それぞれのディレクトリを作り,更にその下にdry-run及び 実際のapplyを行うCloud Buildの設定ファイルを作成します.

Migration Dry-run

新機能などに関するPRがマージされた時,それによってDBスキーマの 変更が起きるかを知りたいというケースは往々にしてあると思います.

sqldefではdry-run機能があり,テーブル定義SQLと現状のDBのスキーマの差分を比較し, 実行されるSQLを確認することが可能です.

今回はCloud Build上でDry-runを行い,その結果をslackに通知することとします.

流れは以下のようになります.

  1. cloud-sql-proxyを開始し,Cloud SQLと繋げるようにする.
  2. cloud-sql-proxyの接続が確立されるのを待つ.
  3. dry-runを行う & 結果をslack通知.
  4. cloud-sql-proxyの接続を切る.

最終的な設定ファイルは以下の感じです.これを,任意のトリガーで実行させます.

# migration_dry_run.yaml
steps:
  - id: start_sql_proxy
    name: gcr.io/cloudsql-docker/gce-proxy:1.16
    args:
      - /cloud_sql_proxy
      - -dir=/cloudsql
      - -instances=$_INSTANCE_CONNECTION_NAME
    volumes:
      - name: cloudsql
        path: /cloudsql
  - id: wait_sql_proxy_start
    name: gcr.io/cloud-builders/gcloud
    entrypoint: bash
    args:
      - -c
      - |
        while [ ! -e "/cloudsql/$_INSTANCE_CONNECTION_NAME" ]; do
          sleep 1
        done
    volumes:
      - name: cloudsql
        path: /cloudsql
    waitFor: ["-"]
  - id: migration
    name: gcr.io/cloud-builders/gcloud
    dir: api/schema
    entrypoint: bash
    args:
      - -c
      - |
        apt -y update && apt -y install wget curl \
        && wget https://github.com/k0kubun/sqldef/releases/download/v0.8.7/mysqldef_linux_amd64.tar.gz \
        && tar -zxvf mysqldef_linux_amd64.tar.gz \
        && curl -X POST --data-urlencode "payload={ \"attachments\":[
                {
                   \"fallback\":\"sqldef dry-run notification\",
                   \"color\":\"#1E90FF\",
                   \"fields\":[
                      {
                         \"title\": \"sqldef dry-run $TAG_NAME\",
                          \"value\": \"
                            $(
                              ./mysqldef --dry-run -S /cloudsql/$_INSTANCE_CONNECTION_NAME \
                              -u $(gcloud secrets versions access latest --secret=$_SECRET_DB_USERNAME) \
                              -p$(gcloud secrets versions access latest --secret=$_SECRET_DB_PASSWORD) \
                              service < schema.sql 2>&1
                            )
                          \"
                      }
                   ]
                }
              ]
            }" $(gcloud secrets versions access latest --secret=$$SECRET_SLACK_WEBHOOK_URL)
    volumes:
      - name: cloudsql
        path: /cloudsql
    waitFor: ["wait_sql_proxy_start"]
  - id: kill_sql_proxy
    name: gcr.io/cloud-builders/docker
    entrypoint: bash
    args:
      - -c
      - docker kill -s TERM $$(docker ps -q --filter ancestor=gcr.io/cloudsql-docker/gce-proxy:1.16)
    waitFor: ["migration"]
substitutions:
  _INSTANCE_CONNECTION_NAME: service-dev:asia-northeast1:service-dev
availableSecrets:
  secretManager:
    - versionName: projects/service-dev/secrets/service-dev-mysql-username/versions/latest
      env: "MYSQL_USER"
    - versionName: projects/service-dev/secrets/service-dev-mysql-password/versions/latest
      env: "MYSQL_PASSWORD"
    - versionName: projects/service-dev/secrets/service-dev-slack-webhook-url/versions/latest
      env: "SLACK_WEBHOOK_URL"

面倒な点としてはCloud BuildからCloud SQLに接続するのにsql-proxyが必要という点です. 他ステップでCloud SQLに繋げられるように,最初にバックグラウンドでsql-proxyの接続ステップを走らせて, 全部が終わってからそれを切断します. 詳細は以下などが参考になります.

qiita.com

medium.com

sqldef実行の際は,公式レポジトリよりバイナリを落としてきて,それを実行します. バイナリポン,最強.取ってくるバージョンは適当なものを指定します.

また,結果の通知の際に2>&1を最後に入れていますが,これは標準エラー出力も表示させるためです. これがないと,migration実行が失敗した際に空の通知が送られてきます.

qiita.com

Migration Apply

applyについては,前述のdry-runオプションを消すだけです. 胆汁. うまくやればdry-runとファイルを共通化できると思います.

実際のマイグレーションは,sqldefによるスキーマの変更だけで済まないケースも多いので, 以下のようなコマンドを用いて任意実行できるようにしておくのがいいと思います.

#!/bin/bash

function sqldef_apply() {
  gcloud beta builds triggers run --tag $2 service-dev-migration
}

$1 $@

その他細かい知見など

  • コマンドの実行結果を送ったりする際は,標準エラー出力も出るように2>&1をつける.
  • Cloud BuildからCloud SQLにつなぐ方法は,なるほど〜となった(接続確立=unixソケットが生成される?までwait).
  • 権限周りはよしなに

最後に

migrationを簡単に,安全に行うために,sqldefをCloud Build上から呼び出す方法について書きました. 実際のサービスだとDBのデータをいじるようなmigarationも多く起きると思うので,今回書いたように 単純にはいかないと思いますが,開発中&リリース初期などのスキーマ追加を多く行うケースでは便利 なんではないでしょうか. 複雑なmigrationをどうラップするかについてはまた勉強したいと思います(そもそもmigrationをたくさんしたくはないが...).

Firebase周りのCI/CDと,環境分離について

はじめに

これは2021年振り返りカレンダーの5日目の記事です.

前回はBitrise上でExpo OTAを行うワークフローを構築するという内容でした.

serenard.hatenablog.com

今回は,最近の初期アプリにはほとんど使われてそうなFirebase周りのCI/CDと, 環境分けについて書いていきます.

CI/CDの対象となるFirebaseのサービスはFirebase Functionです. また,storageやfirestoreのセキュリティールールのデプロイは 環境ごとに対象バケット名が少し変わったりするので, そこをどう分けて管理するかについてです.

仕様

今回は,service-dev,service-prd の2つのfirebase projectがあり, それぞれのために以下を作りたいとします.

  • firestore のセキュリティルール及びインデックスのデプロイコマンド
  • firebase storage のセキュリティルールのデプロイコマンド
  • firebase functions のCI/CD

実装

まず,ディレクトリ構造は以下のようになります.

root/
├── firebase
│  ├── functions
│  ├── .firebaserc
│  ├── firebase_dev.json
│  ├── firebase_prd.json
│  ├── firestore.indexes.json
│  ├── firestore.rules
│  └── storage_assets.rules
├── release
   ├── dev
    │   └── deploy_functions.yaml
   └── prd
        └── deploy_functions.yaml

セキュリティルールなどのデプロイコマンド

各環境で,どのセキュリティールールをデプロイするかなどを,firebase_{dev|prd}.jsonで管理します. 内容は以下の感じになります.

{
  "firestore": {
    "rules": "firestore.rules",
    "indexes": "firestore.indexes.json"
  },
  "storage": [
    {
      "rules": "storage_assets.rules",
      "bucket": "service-dev-public-assets"
    }
  ]
}

また,デプロイは以下のようなコマンドを用意しておきます.

# ストレージのセキュリティルールのデプロイ
firebase deploy --only storage:rules --config firebase_{dev|prd}.json
# firestoreのセキュリティルール
firebase deploy --only firestore:rules  --config firebase_{dev|prd}.json

configを用意したことで毎回それをオプションで指定する必要が出てきますが, 常に環境を意識させられるのでまぁいいかぁという印象です. デフォルトはfirebase.jsonを参照するので,devはそれにしてもいいかもしれません.

firebase functions のCI/CD

functions のCI/CDの設定は,release下の各環境ごとのCloud Buildファイルを参照させて行います.

Cloud Buildの設定ファイルの流れは以下のようになっています.

  1. functionsのコードのビルド
  2. デプロイ先の環境を設定
  3. 環境変数,シークレット変数を設定
  4. デプロイ

シークレット変数は,secret managerに入れておき,Cloud Buildからそれを参照します.

cloud.google.com

実際のCloud Buildのファイルは以下のような感じです.トリガーのタイミング自体は適当に設定しましょう.

steps:
  - id: build_functions
    name: node:14.15.4
    dir: firebase/functions
    entrypoint: bash
    args:
      - -c
      - |
        npm i \
        && npm run build
  - id: deploy_functions
    name: gcr.io/$_PROJECT_ID/firebase
    dir: firebase
    entrypoint: bash
    args:
      - -c
      - |
        firebase use dev --config $_FIREBASE_CONFIG_JSON \
        && firebase functions:config:set slack.channel=$_SLACK_NOTIFICATION_CHANNEL --config $_FIREBASE_CONFIG_JSON \
        && firebase functions:config:set slack.oauth_token=$$SLACK_OAUTH_TOKEN --config $_FIREBASE_CONFIG_JSON \
        && firebase deploy --project=$_PROJECT_ID --only functions --config $_FIREBASE_CONFIG_JSON
    secretEnv: ["SLACK_OAUTH_TOKEN"]
    timeout: "600s"
substitutions:
  _PROJECT_ID: service-dev
  _FIREBASE_CONFIG_JSON: firebase_dev.json
  _SLACK_NOTIFICATION_CHANNEL: service_dev_notice_test
availableSecrets:
  secretManager:
    - versionName: projects/service-dev/secrets/service-dev-slack-oauth-token/versions/latest
      env: "SLACK_OAUTH_TOKEN"

その他細かい知見など

  • firebase functions のCloud Build上でのビルドはコードにエラーが無くてもたまに落ちることがある(nodeのエラーが出てたがよくわからなかった).再実行しやすくしておくとよい.
  • GCPのプロジェクト切り替えてもfirebaseは切り替わらないのでデプロイの際は注意.

最後に

内容薄いのですが備忘録として.firebaseも色々terraform化できるようになったら嬉しいな〜と少し思いました.

Bitrise上でExpo OTA

はじめに

これは2021年振り返りカレンダーの4日目の記事です.

前回は,はじめてBitriseを用いてreact-nativeのCI/CD環境を構築する(新春SP)という内容でした.

serenard.hatenablog.com

Expoには Over The Air (OTA) update という,審査を通さなくてもアプリのアップデートを 配信できる機能があります. 細かいが致命的なバグなどを緊急に修正したい場合には,2,3日かかってしまう審査を 通さずにすぐ修正版を配信できるので便利です.

前回の記事ではOTAを実行するワークフローの構築については省略したので, 今回はそれについて書こうと思います.

OTAってなんぞってときには以下が,

docs.expo.dev

Expo OTAの仕様の詳細については以下がとても参考になりました.

zenn.dev

仕様

今回はreact-native (expo bare workflow) で開発しているiOSアプリのOTAを行います.

環境はstgとprdの2つあるとします.

OTAは緊急時に行うデプロイ手段のため,自動トリガーではなく,手動でトリガーすることとします.

実装

Expo projectの作成

Expo OTAを行うには,ExpoアカウントとExpoのプロジェクトが必要です.

https://expo.dev/へ行き,Create new projectからプロジェクトを作りましょう.

今回はserviceという名前で作ったとします.

Bitrise上でOTAワークフローの構築

先ほど作成したアカウントとプロジェクトを用いてOTAワークフローを作ります.

以下が全体像です.

f:id:sheep96:20220118233958p:plain

多くの部分は前回解説した部分と同じなので,異なる部分を解説していきます.

Expo publish

ここではOTAを行っています.内部では以下のスクリプトを実行しています.

  1. expo-cliをインストール
  2. 先ほど作成したexpoアカウントにログイン.パスワード,ユーザ名はsecretに保存したものを参照する.
  3. OTA配信を行う.release-channelを指定することにより,dev環境にだけ配信するようにする.

とてもシンポゥーです.

#!/usr/bin/env bash
# fail if any commands fails
set -e
# debug log
set -x

# expo publish
npm install -g expo-cli
expo login -u $EXPO_USERNAME -p $EXPO_PASSWORD
cd ui
cp .env.dev .env
expo publish --release-channel dev

prdについては,release-channelをprdにするだけです.

ワークフローの実行コマンド

こちらについては,前回解説したワークフロートリガー用のweb apiを叩くことで行います.

以下のようなものをdev, prd環境ごとに用意します.

#!/bin/bash

function deploy_ui_ota() {
  TAG_NAME=$2
  command="curl https://app.bitrise.io/app/hogehogehoge/build/start.json --data '{\"hook_info\":{\"type\":\"bitrise\",\"build_trigger_token\":\"hogehogehoge\"},\"build_params\":{\"branch\":\"production\",\"workflow_id\":\"deploy_ota\",\"tag\":\"${TAG_NAME}\"},\"triggered_by\":\"curl\"}'"
  eval $command
}

$1 $@

実行は以下のような感じです.

sh deploy.sh deploy_ui_ota v0.0.0

OTAで間違えてバグを配信した時

こちらが参考になります.

docs.expo.dev

手段としては以下が考えられそうです.

  • バグを修正して新規バージョンをリリース
  • バグが入ってない箇所までバージョンをロールバックし,OTAリリース(Expoは常に最新のOTAがないかを見に行くため.バージョンを落としても,リリースが後ならば取りに行く.)
    • https://qiita.com/kaba/items/b6e777d1c0b7ce1f78c6 の記事によるとExpoは1回の起動で1個しかOTA updateを確認しに行かないため,ユーザは1回はバグバージョンを見ることになる(バグをとってきた次の起動時に修正後を取りに行く).
  • expoのロールバックコマンドを利用する.
    1. expo publish:history --platform ios で過去のバージョンを取得.
    2. expo publish:set --release-channel prd --publish-id id で指定したバージョンに戻す.1個前ならばexpo publish:rollback --release-channel production --sdk-version 36.0.0も使える.

その他細かい知見など

  • OTAしたバージョンが1つしかない場合,ロールバックや他のビルドへの移動ができないため,再度OTAを行うか,Expoプロジェクトを削除するしかなさそう.
  • 自分は間違えて1度v999.0.0をOTA配信し,ユーザにアップデート通知が出ない状態にしてしまいましたが,バージョンを修正したものを再度OTAすることでことなきを得ました.
  • プロジェクトページからだとOTA履歴が見れないので,あったら嬉しい.
  • 一応BitriseにもExpo ejectというpublishを行うステップはあるが,release-channelを設定できない.

最後に

Expo OTAをBitrise上でワークフロー化する内容について書きました.とても便利で心強いOTAですが, 少し間違えると想定してないものを配信してしまったりするので,注意が必要です. ローカルで実行しようとするとExpoアカウントやrelease-channelの切り替えが必要になりますが, 今回のようにワークフロー化することで,手間やミスを減らせるんじゃないかと思います. 間違ってる部分は往々にしてありそうなので,ぜひお伝えいただけると嬉しいです.