オンライン同期可能なシーケンサーを作った話 後編
今回作ったもの
詳しい話は前編を参照
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に同期する想定だった。
しかし、Snapshot listenerがローカルへの変更に対し即時に発火することがわかったのでフローを変更し、
- Firestoreドキュメントを変更
- ドキュメントのonSnapShotコールバックが発火
- コールバックからReduxのactionをdispatch
- Redux stateの変更をmapStateToPropsでpresentational componentに伝播させる
- リモートのCloud Firestore DBへの同期を行う
とした。
DBへの書き込みは非同期で行われ、クライアントはドキュメントの更新が成功したものとして動作するため、楽観的UIの実現が可能になる。
追加実装なしでマルチクライアント同期を手に入れる
上記の変更により、マルチクライアント間の同期を追加実装なしで手に入れることができた。
ドキュメントを変更したクライアントから、変更内容がFirestore DBに同期されると、同じドキュメントをlistenしている別のクライアントでもonSnapShotコールバックが発火し(7)、Redux stateの変更(8)を経てpresentational componentに変更内容が伝わる。
ただし、書き込みしすぎに注意
Firestoreでは、ドキュメントへの書き込みに1秒に2回までという制限がありスライダー等、連続的変化を伴うUIによる値の変化を逐次ドキュメントへのupdateで反映するとすぐにrate limitに達してしまう。
そのため、UIへの反映とドキュメントの更新タイミングは、
- 変更を適度に間引く
- 一定期間中の変更を1回のupdateにまとめる
- スライダーを動かしている最中の変更はRedux Stateに直接反映し、スライダーが止まったときの値のみをドキュメントに反映
といった工夫が必要になる。
基本的な戦略としては、永続化される必要のない変更をRedux Stateに直接渡し、永続化が必要な変更のみをドキュメントに反映させることになる。
TypeScript
TypeScriptは create-react-app-typescript を使用して導入した。
User-Defined Type Guard
シンセのプリセットごとに表示させるコンポーネントを切り替えるため、実行時型チェック用にUser-Defined Type Guardを用意した。
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 }