羊をめぐるブログ

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

2022

はじめに

これは2022年の振り返りです。 今年は主に以下のイベントがありました。

  • 業務委託終了
  • 大学院を卒業する
  • 大きい会社に入社し、大規模家族向けサービスに関わる
  • マッチングアプリをガチではじめる

生活スタイル共に考え方が変わることが多かった一年だったので、その都度思っていたことを書いていこうと思います。

趣味を対外的にすることおよび人生の目標について

退職した直後は、自分を追うものが研究しか無くなったので、何をすべきか、人生の目標は何かみたいなことを考えていた。

この時よく考えていたのは、継続して熱量を持って取り組めるかどうかが結果のためには大事ということである。人生をかけるならばもちろんだが、単なる趣味だとしても、たとえすぐにお金にならなくても、10年続けられれば大きな差別化になる。なので自分の趣味の中で長く続けて行きたいものを、なんらかの形で外的に露出するためのプラットフォームを作ろうと考えた。趣味の中で自分が長く続けて行きたいと思っていたことは以下である。

  • 本や音楽、映像などから気に入った文章や場面を記録していくこと。
  • 気に入った作品の作者の方のバックグラウンドを知ること。

その時点までは、気に入った場面を記録して以下のbotに反映するなどをしていた。

参加型名文bot (@LovelyQuoteBot) / Twitter

これをWebサイト化、アフィ化などすることで、より元作品へリーチしてもらえる&ワシもお金がもらえて嬉しいというものである。

ということで出来たのが以下であった。

https://meibun-house.com/

とてもつまらない。ひどい。作者さんたちに申し訳ない。当初は何度も見返したいものになるだろうと思っていたのだが、全然であった。 人にも見てもらったが、自分の感じている文章への感動というのはやはり文脈の理解あってのものも多いのだと実感させられた(文章単体で美しいものもあるが、文脈の理解の有無は大きい)。 あと、無駄にGraphQLを採用したせいで詰まりまくり、開発にとても時間がかかってしまった。 技術の勉強をしたいなら稼ぐためのプロジェクトでするべきではない、勉強用のプロジェクトや副業とかで勉強すべき、というのも学びになった。

しかし、以前として文章などを記録していくのは好きであり、サイトの改善は行なっていく予定。

ビールの記録

また、文章だけでなく、ビールの記録も行なっていた。ビールをよく飲む割には前に飲んだものを忘れてしまっており、単なる消費で済ませてしまうのは非常に勿体無いと考えたのが主な理由である。

review

検索性や継続性を考慮して一旦 notion にて管理することとした。こちらも継続できており、レビュー数は100を超えた。 しかし、世の中にはこんなのよりもっと面白い形でビールを紹介してる人もいるということを友達に教えてもらった(よく考えたらいて当たり前だが全然頭になかった)。

https://www.instagram.com/beer_joy_/?hl=ja

自分の汚い机の上で撮った写真とは美しさが違いすぎる。飲みたいと思わせる力がレベチ。しかしこれは見せ方的な問題でもあると思うので、既存の適当な写真や情報でどうにかできないかというのは検討して行きたい。

IT技術による現実世界の拡張的なこと

人生を通したIT活動の大義として何をするべきかということを考えていた。自分がITに興味を持った理由の一つとしてロックマンエグゼがある。ロックマンエグゼといえばAIのようなイメージもあり、学生時代はそれに関連して機械学習をしていたが、やってみた結果、結局自分が憧れていたのは人工知能と喋ることではなくて、高度にIT化された効率的かつ価値拡張された社会だったということに気づいた。この20年のIT界はそれによってWeb上に新たなコンテンツを複数生み出してきたが、一方で現実世界の仕組みはまだまだ物理的で非効率的なものも多い。そして、Webの世界と現実世界はかなり別個として成長してきてしまっている印象があった。なのでそれをさらにいい感じに融合できるのではないかと考えていた。Googleの検索結果や広告が位置やその時現実世界で行なっている物理的行動によって変わるとか、道端に存在している情報的なもの(チラシとか看板とか)にWeb上の概念が紐づいていて効果を測定できるとかそんなことだ。

ただ、考えていただけで結局具体的に何もしていない。その目標に向かってできる最小でお金になることは何かとかを考えていたが、結局思い浮かばなかった。そもそもこれを書いていてそんなことを考えていたのを思い出したので、今年はなんかできたらいいな〜。

読んでいた本としては以下などがある。

ポケモンGOのユーザーがいかに熱狂的だったか、また副社長をされている方の人生が波瀾万丈過ぎて、先を考えずに飛び込むことで得られるパワーというのは重要だなと思わされた。あとは純粋にPokeGoの人を誘致する広告という形態がすごく面白いなと思った。社内の事業立案研修ではこれを対抗馬として考えたりした。

Web3が流行っていたのでP2Pのメリットを知るために読んだ。治安を保ったりシステムを最新に保つためには結局中央集権的仕組みを取り込む必要がありそうで、適材適所だなという所感。何か最強のユースケースがあるんだろうか。

入社後について

入社後の1, 2ヶ月ほどは割と同期と遊ぶことが多かった(最近でもたまに遊ぶ)。話していて思ったのは、みんな人間力が高すぎるということである。すごい。自分は学生時代後期の就労によって社会不適合者およびコミュ障をある程度脱出して人間としてまともになったと思っていたのだが、同期の人たちに比べたら全くであった。会社の理念がFor Communicationということもあり、コミュ力が高い上にそもそも人と話すことが好きな人が多い。自分は必要な時以外に話すのが苦手なタイプなので、研修中は結構圧倒されていた。中学、高校、大学と周りの人の雰囲気が変わっていっているのは感じたが、新卒入社が最もいい意味でのフィルタリングを感じたかもしれない。自分が現在の会社に入社した理由の一つとして人柄という点があるが、それをいきなり感じさせられた。入社1ヶ月後に同期で50km歩くというイカれイベントに3, 4割ぐらいの人が参加したのは今思えば異常である。

部署配属後について

部署配属後はサーバーサイドおよびネイティブアプリのエンジニアをしていた。今までの仕事と比較して感動したこと、新鮮に感じたこととしては以下などがある。

  • アプリケーション開発のみに集中できるような環境になっている。
    • 今までの仕事はインフラ含めて一気通貫で対応することが多かったが、既存の仕組みが大体揃っているので、ユーザへの価値を良く、素早く提供するというところに集中できる。
    • サーバサイドの開発環境は各人にk8s上のコンテナが割り当てられており、自由にいじることが可能。他ではローカルでdocker立ち上げまくって重くなったり突如エラーが出て再起動で直すみたいなことがあったので感動した。
  • ちゃんとしたスクラム開発になっている。
    • 今までは◯◯作るみたいな抽象的な業務を受けてできるだけ早めに終わらせる、という仕事が多かった。また、スクラムという名を関していても、ポイント計測やスプリントレビュー的なものもなかった。
    • 設計タスクと実装タスクが分かれているので、週の消化ポイントからスケジュールの見積もりが可能になっている。全員が設計レビューをしたりとかスクラムイベントなどのオーバーヘッドはあるが、ビジネス価値と強く結びついているのでスケジュールや意識共有なども含めて仕方ない気がする。
  • エンジニア含めてUXレビューがあり、そこでは細かいが鋭い指摘などもたくさん飛んでいてすごい(文言やクリエイティブとかまで)。
    • 自分以外の人は大体実ユーザーなので、意見に体験が伴っていて強い。自分も家族内で使い始めているが、子供がいるわけではないのでむずさを少し感じている。
    • 自分は家族が多いこともあり、家族内インスタとしてみんな楽しく使えているのでそれはそれでいいのかもしれない(お金にならなくて申し訳ないが...)
  • 新規で何かをゴリゴリ設計して作るよりは、既存の設計の上でどう最適に実装するかを考えることが多い。コードベースを読み解く能力が今までよりより重要。
    • 自分がガッツリ作った機能がないので、どうポジションを取るかみたいなのに結構悩んでいる。
    • 何かを導入するとしても規模が大きいので、しっかりメリットデメリットを整理して、かつチーム全体の合意をとって一緒にやってもらうという流れになるので、整理力、巻き込み力が重要
  • 自分以外は全員10個以上年上なのでとても大人な方が多い。とても尊敬している。ただ近い年の人がいないので寂しくなることはある。
  • 初めて評価というのを受けた。自分のやったことをちゃんとアピールするというのは大事だというのと、やるべきことをやっているかをシビアに評価されるのは重要だなと思った。

仕事に勉強としては以下を読んだりしていた。技術系(ネイティブアプリ、rails)は結局仕事ドリブンで勉強した方が効率がいいなとなり、最初の概念的なものだけ学ぶ程度で終わった。rails 周りはそれで済んでいるが、ネイティブ周りはやはりガッツリやらないとわからないなと思ったので、今年は何か作ってみようと思う。

スクラムガイド https://scrumguides.org/docs/scrumguide/v2020/2020-Scrum-Guide-Japanese.pdf

Documentation  |  Android Developers

マッチングアプリをガチではじめてみる

一昨年は作っていたくせに全くやっていなかったが、以下の理由から始めてみた。

  • 周りの遊んでいた友人が彼女ができたり結婚できたりしており、話の3割ぐらいがその話になるが全くついていけない。時間が経つと関われなくなるんだろうなと感じた。
  • 部署の人、外部の人と話すが、大体そういう話が出る。理研の人とすらそういう話になった。年齢という最強の手札を使えるうちに頑張るべきである。
  • 人間としての成長のために他人との関わりを自分から求めるというのは重要だと思った(今まで友人すら自分から作ったことがないため)
  • 勧められて読んだフロムの愛するということに人を愛する能力は才能ではなく鍛えるものと書いてあってなるほどとなった

最近

プライベートでもコードを書いたりすることが多かったが、年末年始に全然コードを書かなかったら、すごく時間に余裕ができることに気づいた。なので今まで諦めていた別にキャリアのためにならないけどやってみたかったこと(絵やギター)を始めてみている。これらをやることで別の道が見えてくるんではないかと思っているが、やはり目的なしにやってもどこへ向かっているのかわからず不安になる。しかし、何か自分で納得するラインまでは続けてみたいと思う。

仕事においては、今までは自分がやった感を感じるのにこだわり過ぎていたかもしれない。周りの人たちが優秀なので設計なども結局人の意見になることが多く、俺必要なのか...?みたいになっていたが、むしろそういう他の人を頼ればすぐ終わるものは頼ってすぐ終わらせ、自分の思う新たな価値を見つけてそちらに取り組み、その中で出てくる未知な部分でオーナーシップを持つべきなんではないかということを思った。 なので今年は頼れるだけ人を頼って早く終わらせる、自分の思う新たな価値に色々取り組んでいってそこでオーナーシップをもつ、みたいなことを頑張ってみようかなと考えている。

2021

はじめに

「2022年の振り返り」の間違いと思いましたか? 2023年に書かれてはいますが、これは間違いなく、2021年の振り返りです。本当は昨年の初めに、一人アドベントカレンダーの最終として書こうとしていたのですが、途中で力尽きてしまったので、今更ながら残しておきます。 色々考えさせられるようなことがあったのですが、大体忘れてしまったので、今でも覚えている自分の中の教訓的なものを残しておこうと思います。

逃げられる人間は逃げる

2021年はスタートアップにお世話になっていた。 明確にすべき事業も無いぐらいのフェーズ&規模感。 いくつかサービスの立ち上げに関わらせてもらったが、最終的に僕は、もううまく行く気がしないので、新規には関わりません、という様な感じで逃げた。他の人が会社や学校を辞めるようなリスクを取っている中、僕は修士課程を無事に終えようとしていたし、新卒での就職先もあった。そんな中で以下の記事を見つけた。以下の記事を書いている人達は逃げられた側だが、僕は完全に逃げた側であった。ある程度日常生活を犠牲にしていたものの、それ以上の対価や経験を得られたことを感謝していたし、できる限り頑張るつもりであった。しかし、逃げた。これは僕が、何も不可逆的なものを捧げていなかったからだと。だから今後、もし自分が逃げることを許されないような環境に身を置くならば、何か失えないものを捧げるべきだし、他の人が逃げても大丈夫なように心を備えようと思ったのだった。

ぼくは村作りビジネスをやめる。そう決断するにいたった全経緯と教訓について

起業失敗の話。起業を志す皆さんに敗残者からお伝えしたいこと | ニュー アキンド センター

継続的でない成功はどんなに大きくても単なる運である

ある程度大きい仕事をもらえ、それが順調にいき、大きいお金をもらえた時期があった。完全に他の人のおかげとはわかっていたものの、ある程度自分が頑張ったと思える部分もあったので、多少の自信を持ってしまった。しかしその後、結局自分がいたからと行って事業がうまくいくわけは1mmもないし、今の仕事でバリューを発揮できているわけでもない。なのに、あの時の成功体験が自分にしょぼい自己肯定感を持たせてしまっている。人間、ある程度何かに取り組んでいれば運によって救われるタイミングが来たりする。2回波に乗れれば実力と言っていいだろうが、1回ならばただの運である。もし何度か乗れたとしても、過去の成功体験に囚われては行けない。運をつかむのでなく、引きづり込めるようになってからが本当の自分の実力と言えると思うし、そうあれるよう努力していきたい。

このサービスに賭ける、という様なことはしない

基本的にサービスは失敗する。失敗するというよりは、当初見ていた目標が、目指すものではなくなったりする。 なので、最初に決めたコンセプトに従って、これがダメだったら終わる、などとするのは良くないし、もし違うと思ったならまた別のものに変更すべき。作ったら作ったで愛着が湧いてしまうが、とにかく微妙なものに固執すべきではない。

適切な目的が決まったなら大体のことは終わっている

研究で言われたことだが、他のことにも言えると思う。 目標が適切なら必然とやるべきことが決まるし、それを達成したいという欲も尽きない。 だから焦って中途半端な目的を設定してはいけない。目的に沿って何か行動をするのは、着実に何かが進んでいる快感があるので、つい安易な目的を設定してしまいがちだと思うが、これは完全な間違いであると思う。かと言って何も行動しないのは良くないので、

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をたくさんしたくはないが...).