[Redux/Fluxでも役立つ] サーバーが返却するデータは正規化して格納すべし

a wall of a container

この記事は eureka Advent Calendar 2018 2日目の記事です。


こんにちは、Pairs Global事業部のiOSエンジニアのmuukiiです 🤠

今回はクライアントサイドにおけるデータの保管方法についての話をします。

最近ではGraphQLやProtocol Buffersなどの利用機会が増え、サーバーAPIが返却するレスポンスは美しくネストされた構造化が進んでいます。

構造化を行うことは反復的な表現を表すのに優れ、ネーミングの明快さを向上し、データを見やすく保つことに貢献します。

GitHub API v4 GraphQLを用いたレスポンス

レスポンス構造と保存時の構造は必ずしも一致するわけではない

しかし、このような構造はあくまで通信先にデータを渡すときに作り上げられるものであり、レスポンス構造と保存時の構造は必ずしも一致するわけではないということです。

逆に言えば、GraphQLの返却する深くネストされたデータ構造は利用用途に合わせて崩しても良いのです。

(Swift的にはCodableを用いた構造の変更は少し面倒ですが)

どのようなレスポンスは崩すべきか

「崩す」をそれっぽい表現にすると「正規化 (Normalize)」とでもいいましょうか。

では、どのように正規化を行うべきなのでしょうか?そのために正規化を行わない場合のデメリットを知る必要がありそうです。

これについては次の記事やOSSが大変参考になりました。


これらの記事を解説するような形で説明を行います。

これらの記事内で共通して触れている点は、ネストされたデータ構造はフラットなデータ構造に正規化して保管するほうが有効なケースが多いということです。

たとえば「子データが複数の親データによって参照されている one-to-many なデータ」はその例に該当します。

もう少し具体的な例でいうと、本(Book)と著者(Author)という関係はone-to-manyになります。

Author と Bookという one-to-many なレスポンスは次のようになものとします。

Book

  • id ユニークID
  • title タイトル
  • tags 本の特徴を表すタグ
  • author 著者 (Author)

Author

  • id
  • name
  • point 著者の得点 (変動していくもの)

Authorは複数のBookを持つようなデータ構造となります。

これらをSwiftオブジェクトに変換すると次のような構成をとることが出来ます。

このようなオブジェクトの構成でも開発しているアプリがキャッシュを行うことなく、取得するだけのビュワーアプリであれば、このままで問題はありません。

ですが、データをキャッシュしたり、データの再取得により保持しているデータの更新を行っていく場合には課題が出てきます。

それは主にデータの一貫性(Consistency)を実現しようとしたときの実行コストが課題となります。

例えば、Bookが非常に多く、Authorのpointが変化した場合にBookはそれぞれAuthorの実体を保持しています。

同じAuthorであるすべてのBookが持つAuthorを同期するためには、Authorの更新のたびに関連するすべてのBookも更新する必要があります

あるAuthorがn個のBookを持っていれば n(Book) + 1(Author) 回の更新処理が発生することになります。 (Bookが1000個なら 1000 + 1 = 1001回の処理)

これはときに大きな負荷となりえます。Redux/Fluxにおいても発生しうる問題とも言えるでしょう。

まさにReduxはその問題について触れています。

どのように正規化を行うか

本記事で挙げているすべての記事内で正規化の方法に触れていますが、さきほどのデータ構造を用いて同じように解説を行います。

更新されうるデータは階層化をなくしてフラットに管理するようにします。

Bookは関連するAuthorの実体ではなく、idを持つようになります。
データベース的に言うならばリレーションシップを組むようになり、それぞれのテーブルを用意するようなイメージです。

さきほどのSwiftのオブジェクト構造に変更を加えます。

Bookが持つAuthorがAuthorのidを持つように変化しています。

補足として、tagsに関してはそれ自体が更新されるケースはなく、他のBookと共有する必要がないと判断できる例として直接埋め込んだままにしています。 (データベースを利用するケースではこれは少し工夫が必要になります。)

このままではAuthorの実体が保持できないのでStorage的なものを用意する必要があります。

ひとまず次のようなDictionaryを用意します。

ここにデータを格納します。

storageはフラットなデータ構造であるため、オブジェクト間でidが重複しないように何らかのprefixを加えたKeyを使ってデータを格納しています。 ここではstructの名前をprefixとしています。

実際には自動的に生成されるような上手な仕組みを用意すると良いでしょう。

次にデータの取り出しです。

例として、Bookオブジェクトが持つAuthorを取り出してみます。

このようにフラットに管理することにより、Authorの実体はidごとにユニークであり、もしAuthorに更新があったとしても、関連するすべてのBookを更新することなく、1回の更新処理のみで済みます。加えて、どのBookからでも常に同じAuthorが取得可能となります。

扱うオブジェクト数の増大にも耐えうる設計とも言えるでしょう。

一方で、BookはAuthorの実体を持たなくなることで、storageへのアクセスが必須になります。Bookだけ持っていてもstorageがなければAuthorは取得できません。

storageがシングルトンであれば難しくはないですが、そうでない場合、オブジェクトと一緒にstorageへの参照を持ち運ぶ必要が出てきます。


eure/FlatStore

まだまだ開発段階ではありますが、本記事で紹介した正規化のコンセプトをもとにデータの追加・取得を行いやすくしたライブラリを公開しており、Pairsの海外版で実際に使用を開始しています。

このアプローチはUnidirectional-Flowなアーキテクチャにおいても有効なものなので参考になれば嬉しいです。