🔥

Firestoreに保存するデータ構造を独自言語で管理しようとして失敗した話

2020/12/31に公開

はじめに

弊チームではLanternというツールを作って(!)Firebase Cloud Firestoreのスキーマを管理する、ということに挑戦していました。

https://github.com/kikuchy/lantern

結論から言うとこの取り組みは失敗に終わりました
この記事は当初の問題を振り返りながら、なぜこの取り組みが上手くいかなかったのか、どうすればうまく行きそうなのかをまとめたものです。

読者として以下のバックグラウンドがある方を想定しています。

  • Firestore(スキーマレスDB)のメリット、デメリットを知っている
  • 上記デメリットによって困った経験をしたことがある

当初の問題

問題を総合すると データ構造(スキーマ)の共有が困難 というところに行き着きます。

コードベース間でのスキーマ共有

Firestoreを使用するとなると、大体のプロジェクトではCloudFunctionsを使用することになり、フロントエンド/クライアントアプリケーションと合わせて最低でも2つのコードベースを抱えることとなると思います。
コードベースが2つ、かつ、チームの構成人数が少なければ、スキーマの共有はソースコードを見ながら口頭で補足しながらでも行えたかもしれません。

弊プロダクトはiOS, Androidのクライアントアプリケーション、Firestoreのトリガーで動作するCloudFUnctions、CFsでまかないきれない機能を提供するサーバサイドアプリケーションから成っています。

そのためコードベースは大まかに以下の4つに分かれています。

  • iOSアプリ(Swift)
  • Androidアプリ(Dart)
  • CloudFunctionsスクリプト(TypeScript)
  • サーバーアプリ(Go)

全アプリケーションがFirestoreにアクセスしうるため、それぞれの言語と使用フレームワークに合わせたスキーマの共有が必要でした。

構成員間でのスキーマと意図の共有

上記のコードベースを、以下の3チームで開発していきます。
なお、CloudFunctionsは全員でメンテしていました。

  • クライアント開発陣
    • iOSクライアントチーム
    • Androidクライアントチーム
  • サーバチーム

さらに機能開発はそれぞれのチームから数人を抽出してタスクフォースを作る形で行われています
小規模チームならではのフットワークの良さをメリットとして享受しつつも、コードベースが大きくなるにつれて、「同じチーム内でも機能担当者でないと詳細は把握していない」ということが起こるようになります。

スキーマ共有ができていないということ

共有に失敗するということは、以下の混乱を引き起こします。

  • 正しいドキュメントのフィールド名がわからない
  • 正しいフィールドのデータ型がわからない
    • 値域がわからない
    • 配列型やmap型の内容がわからない
  • ドキュメントのIDの生成規則がわからない
  • 下手するとコレクション名とドキュメントパスもわからない

開発初期の機能実装の際は基本的に新しいドキュメントが増えていきます。
あちこちの実装で使い回されるドキュメント(ユーザーの基本情報を収めたドキュメントなど)を使用するときや、実装者以外のエンジニアがメンテナンスしたりする際に上記の問題が起き、ドキュメントのパースエラーなどによる不具合が起きてき始めました。

既存のスキーマ共有方法の問題点

当然、チーム間、またはチーム内でスキーマを共有しようという試みはありました。

基本的には前述のタスクフォースが、企画者からもらった要件を元に実装詳細を詰める会合を行い、要件のドキュメント(Notionに記録していました)にドキュメント構造のメモなどを残していました。
実装担当者間での共有は基本的にこれでもなんとかなりますが、以下の問題も起きていました。

  • フィールド名や値がsnake_caseやlowerCamelCaseになっていたりと不揃い
    • userId / userID どちらで表記するかとか
      • Dart文化圏のエンジニアが名付けると前者、Swift文化圏エンジニアが名付けると後者になる
    • 全体的なポリシーが決まっていないのも問題
  • 資料にスペルミスがあった場合に実装担当者が気を利かせて直すも、別コードベースで実装がすでに済んでいてパースエラーを起こす
  • タスクフォース外のメンバーがほとんどスキーマを知らないか理解できない
    • 機能のできはじめでユビキタス言語も定着せず、資料を検索してもなかなかヒットしない
    • 担当者ごとにメモのフォーマットがバラバラだったり、実装上の不具合が出た際に担当者間だけで話し合って修正し、メモが残らない場合もあった
  • メモが役に立たない場合は各コードベースを参照するも、コードベース間で差異があった場合、どれが正しいのかわからない
  • 開発の比較的初期段階ですらこうなのに、時間が経ち当時の開発担当者がいなくなってしまったら…?

当時の解決策

基本的には、実装のメモ以外の方法で共有する方針を目指しました。

上記問題を改善するために

冒頭に記した通り、Lanternという独自のスキーマ記述言語とそれを用いたコードジェネレータを作り、単一のリポジトリにスキーマを記録、Githubでスキーマ管理からコード生成まで行うことにしました。
これにより以下のように問題を解決できると考えたためです。

  • フィールドの正しい名前と型がわかる
    • 自動生成により、コードベース間で独自にクラスを作ったりする必要がない
    • map型に入ることを期待されている構造体も定義可能にした
    • enum型も生成するため値域にブレがない
  • スキーマ記述が一箇所に集まる
    • 実装担当者以外でも、リポジトリを見れば現行のスキーマがわかる
  • どのスキーマが正なのかわかる
    • 「このリポジトリに記載されているものが正」という決まりごとによる
    • 後述の自動生成により基本的にスキーマ記述ファイルとのズレはなくなるはず
  • チェックはコンピューターに行わせる
    • IntelliJなど一部のコードエディタを使用すればスペルチェッカーが働く
  • ほかのドキュメントがどう命名や記述を行っているかわかる

解決策の問題点

しかし以下の問題を解決できませんでした。

言語仕様の更新が容易ではない

独自言語を自分で作ることで、プロジェクトの状況などにあったコードを生成できる柔軟性を確保したつもりでいました。
しかし実際のところは、独自言語の新しい言語仕様を追加したりするコストが高く付き、更新が容易ではなくなってしまいました。

例えば、Lanternには未だにコメントを記入するシンタックスがありません。
言語にスキーマのみの表現力しか無いとスキーマの解説などが記入できず、ドキュメントやフィールドの設計意図を記録できない問題があります。
この実装は私のパーサライブラリの理解不足なところが原因で実装に手間取り、結局開発に使える時間を使い切ってしまって諦めた経緯があります。
これによってLanternは使いやすくならず、チームメンバーはスキーマファイルの更新が億劫になり、更に使いやすく改修する機会を逃す…という負のスパイラルに陥りました。

また、これは後述の問題点とも深く関連します。

特定のフレームワークに依存してしまった

当初、コード生成においては「標準ライブラリにのみ依存したコードを生成する」ことを目標にしていましたが、以下の理由から断念しました

  • iOS, CloudFunctionsのコードベースでは既にフレームワークを使ったスキーマの実装がなされていた
  • 生成コード全体を通して重複するイデオムが多かった

初めはLantern用にランタイムライブラリを用意しようかと思っていたのですが、それだけのリソースを割けなかったため、既存のフレームワークに依存する形でコード生成を行わざるを得なくなりました。

結果として、以下の問題が発生します。

  • フレームワークのAPIに変更があった際に追従しないといけなくなる
  • フレームワークが依存しているライブラリが更新できなくなり、フレームワークの更新もできなくなる
    • 嘘のような本当の話
      • 弊プロダクトではFlutterで開発した機能をiOSコードベースにAdd-to-appで導入している
      • Firebase系ライブラリのアップデートに際して、Add-to-appのホスト/ゲストアプリ双方でcloud_firestoreを使用しているとクラッシュするバージョンが登場
      • クラッシュを避けるためcloud_firestoreのバージョンアップができなくなる
      • 使用していたフレームワークもアップデートできなくなる

開発者がボトルネック

私が一人でLanternの開発を行っていたのですが、タスクを回す速度が徐々に早くなり始め、私のリソースがLantern開発に割けなくなってきました。
開発言語はDartでしたが、言語開発に興味のある/手を割けるDartを使えるメンバーがいなかったため、誰も改修にリソースを割けなくなります。

前述の言語仕様の更新が容易ではない状態も相まって、徐々に現場で求められるものとLanternの機能の間の乖離が大きくなってゆきました。

単純に更新しづらい

普段メンテナンスするコードベースではないところでスキーマを管理していたため、スキーマファイルの更新が億劫になりやすい状況になってしまいました。
特に開発初期段階ではプラットフォーム側で実装しながら開発を行い、実装されたものを後からドキュメントとしてスキーマファイルに転記する、ということが行われておりました。
(実装しないとちゃんと動くかもわからないので、当然こうなります。ドキュメントを先に書いたりはしないですよね)

そうしたことが常態化したまま開発は佳境を迎え、スキーマファイルは「実装の後に書き残すもの」という認識が定着しました。
ドキュメンテーションは開発に必要不可欠ではないので、段々と更新される頻度が低くなり、開発開始から1年弱経った頃には数えるほどのメンバーしか更新しない状態になっていました。

今改めて取り組むなら

そうしてLanternを使ったFirebaseスキーマの記述を残す試みは失敗に終わりました。
二の轍を踏まないためにどうしたらよいのでしょうか。
今一度考えた結果が以下のとおりです。

新しい言語を作らない、コードに付随する

そもそも、というところです。
先述の通り、独自言語の作成はメンテナンスにかなりのコストがかかります。
メンテナンスにかかるコストをどうにか捻出できないのであれば、そうした選択肢は取らないほうが良いでしょう。
既存のプラットフォームの内、アノテーションなどの言語機能を使ったコード自動生成システムを作りやすかったり、チーム内で全員がメンテナンスしやすいプラットフォームを選択して、そこのスキーマを正とする、などがわかりやすいかと思います。

今私が作り直しをするなら、CloudFucntionsのリポジトリを正として、TypeScriptで宣言されてるクラスにアノテーションを付けるなどすることでiOS/Android用のソースコードを生成できるようにします。

中間表現を用意しておく

多言語に対応する、サポートしてくれるメンバーを増やすという観点です。
LanternはLantern記述言語を読んだら直接Swift/Dart/Typescriptを吐き出すようにしていました。
当初はサーバーチームからもGoの出力を要望されていたのですが、リソースの都合でそうした機能の追加は見送られ続け、そのまま運用停止まで実装されないままでした。
原因はいくつかありますが、一つにはすべてがDartのコードで書かれており、メンテナンスできるメンバーが限られていたことがあると思っています。

Lanternは内部的には抽象構文木を持っていたため、今思えばJSONかなにかでASTを吐き出すなどした中間表現を出力するようにすればよかったと思います。
中間表現のフォーマットさえ一貫していれば、パーサーはDartでも、ジェネレーターはSwiftやGoなど各言語で書くことができたはずです。
(gRPCなどはそうなっていますよね!)

まとめ

Firebase Cloud Firestoreのスキーマをチーム内で共有するためのスキーマ記述言語とその生成ツールLanternを使ってスキーマを共有する試みは失敗に終わりました。

メンテナンスコスト、リソースの問題、更新のしづらさなどが原因として考えられます。
また、これらが相互作用を起こし更に更新のしづらさに拍車をかけました。

同様のことをしようと思うのならば、(スキーマの変更によって更新される)既存コードを使った自動コード生成をすることをおすすめします。
また、生成時は中間表現を出力することで多言語での生成を簡単にするなどの工夫をできると思います。

現チームで同様のことにトライするチャンスが今後あるかどうかはまだわかりませんが、もしあればまた挑戦したいと思います。

Discussion