羊をめぐるブログ

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

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を用いた不適切画像の検知について書こうと思います.