オンライン同期可能なシーケンサーを作った話 後編

今回作ったもの

DEMO
ソースコード

詳しい話は前編を参照

nomusiclife.hatenablog.jp

Firebase

個人開発の強い味方、Firebase。
HostingやAuthも使っているが、今回はデータの保存と同期のため、Cloud Firebaseを重点的に使った。

Cloud Firestore

Firebaseには従来からあるRealtime Databaseと
新しく追加されたCloud Firestoreという2つのデータベースがあり、今回は後者のCloud Firestoreを使った。

Realtime DatabaseとCloud Firestoreの違いは下記にまとまっている

データベースを選択: Cloud Firestore または Realtime Database  |  Firebase

Snapshot Listener

Cloud Firestoreではドキュメントへの変更を検知するために onSnapshot でスナップショットリスナーのコールバックを登録することができる。

const doc = firebase.firestore().doc(`/sessions/${sessionId}`)
doc.onSnapshot((changed: firebase.firestore.DocumentSnapshot) => {
  const data = changed.data() as Session
  actions.updateSession(data) // Reduxのstoreを更新
})

コールバックは以下の条件で発火する。

  • リッスンしているドキュメントに対してリモートで変更が行われる
  • ドキュメントをローカルで更新する

これが、Firestoreにおける同期やオフライン対応の基本となる。

データフローの変更

当初は、ローカルで行った操作の結果をリモートに送信する。つまり、Redux Stateにて行われた変更をCloud Firestoreに同期する想定だった。

f:id:nishaya:20180911133711p:plain

しかし、Snapshot listenerがローカルへの変更に対し即時に発火することがわかったのでフローを変更し、

  1. Firestoreドキュメントを変更
  2. ドキュメントのonSnapShotコールバックが発火
  3. コールバックからReduxのactionをdispatch
  4. Redux stateの変更をmapStateToPropsでpresentational componentに伝播させる
  5. リモートのCloud Firestore DBへの同期を行う

とした。

f:id:nishaya:20180911133725p:plain

DBへの書き込みは非同期で行われ、クライアントはドキュメントの更新が成功したものとして動作するため、楽観的UIの実現が可能になる。

追加実装なしでマルチクライアント同期を手に入れる

上記の変更により、マルチクライアント間の同期を追加実装なしで手に入れることができた。

f:id:nishaya:20180911133737p:plain

ドキュメントを変更したクライアントから、変更内容がFirestore DBに同期されると、同じドキュメントをlistenしている別のクライアントでもonSnapShotコールバックが発火し(7)、Redux stateの変更(8)を経てpresentational componentに変更内容が伝わる。

ただし、書き込みしすぎに注意

Firestoreでは、ドキュメントへの書き込みに1秒に2回までという制限がありスライダー等、連続的変化を伴うUIによる値の変化を逐次ドキュメントへのupdateで反映するとすぐにrate limitに達してしまう。

f:id:nishaya:20180911133931p:plain

そのため、UIへの反映とドキュメントの更新タイミングは、

  • 変更を適度に間引く
  • 一定期間中の変更を1回のupdateにまとめる
  • スライダーを動かしている最中の変更はRedux Stateに直接反映し、スライダーが止まったときの値のみをドキュメントに反映

といった工夫が必要になる。

基本的な戦略としては、永続化される必要のない変更をRedux Stateに直接渡し、永続化が必要な変更のみをドキュメントに反映させることになる。

TypeScript

TypeScriptは create-react-app-typescript を使用して導入した。

User-Defined Type Guard

シンセのプリセットごとに表示させるコンポーネントを切り替えるため、実行時型チェック用にUser-Defined Type Guardを用意した。

Advanced Types · TypeScript

export type SynthPreset = OscSynthPreset | FmSynthPreset | DrumsSynthPreset

export interface BaseSynthPreset {
  type: SynthPresetType
}

export function isOscSynthPreset(v: any): v is OscSynthPreset {
  return v.type === 'osc'
}

export interface OscSynthPreset extends BaseSynthPreset {
  type: 'osc'
  oscillator: OscillatorType
  aeg: ADSR
  cutoff: number
  resonance: number
}

所感

  • マルチクライアント間の同期を前提とする場合、クライアント内のデータフローを再考する必要がある
    • Firebase Cloud Firestoreは近道の一つ
    • APIがGraphQLであれば、Apollo Clientを使って同じようなパターンの実装ができそう
  • TypeScriptはいいぞ
    • これ以降に作ったプロダクトは全てTypeScriptを使うようになったのだが、それについてはまた別の機会に書く