YOUTRUSTアプリを支えるデータストアの技術

どうも、株式会社YOUTRUSTのアプリ開発のリードエンジニアをやっているashdikこと朝日(YOUTRUST / Twitter)です。

最近、また趣味のボードゲームの時間を少しずつ増やせててとても嬉しいです。
とはいえいつでも複数人で出来るわけではないので、直近のマイブームはブラッディインというゲーム(のソロプレイ)です。
ちょっと物騒な面持ちだったりするのですが運と実力の割合がちょうど良くてすごい面白いです。

※物騒な面持ちなので、写真は割愛させていただきます笑
※基本的には、人とやるのが好きなのでボードゲーム仲間はいつでも募集しています笑

⚓️ 概要

本記事は、第三部です。

第一部は、こちらの記事でYOUTRUSTアプリのレイヤー構成についてざっと説明しました。

tech.youtrust.co.jp

第二部は、この記事に登場する図のうち、ApiClient 及び Request の部分を説明しました。

tech.youtrust.co.jp

今回は、第三部と称して、 Store の部分について触れていこうかなと思います。

Store

💻 筆者環境 (執筆時)

name version
Flutter 3.10.5
Dart 3.0.5
flutter_hooks 0.18.6
hooks_riverpod 2.3.6

📓 大前提(お詫び)

riverpod 黎明期の頃から使っており、 FutureProvider は使われておらず StateNotfierProvider も最近導入し始めたところです。
その辺の構成の参考にしようとしている方のお役にはもしかしたらお役に立てないかもしれません 🙏

ですが、2年半くらい今の構成で作り続けており、非常に作りやすいと感じています。
また、新しくジョインしていただいた方々にもコードが読みやすいと好評いただいているので
一つのパターンとしてありなのではないかなと個人的には思っています。

それではそんな言い訳をしたところで笑、本編の解説に入りたいと思います。

Store

まずは、弊社のレイヤー構成における Store の責務は「データを保持/返却すること」です。

実装

/// 公開用
/// 指定したTodoIdを持つTodoインスタンスを返す
final todoProvider = Provider.family<Todo?, int>(
  (ref, todoId) => ref.watch(_todosStateProvider)[todoId],
);

/// 公開用
/// 管理している全Todoを返す
final todosProvider = Provider(
  (ref) => ref.watch(_todosStateProvider),
);

/// 内部用
/// 内部で管理するためのProvider
final _todosStateProvider = StateProvider<List<Todo>?>(
  (ref) => null;
);

class TodoStore {
  const TodoStore._();

  /// 保持している全てのTodoを返す
  static List<Todo>? getAll({
    required Reader read,
  }) {
    return read(todosProvider);
  }

  /// 保持している指定されたtodoIdを持つTodoを返す
  /// なければnull
  static Todo? get({
    required Reader read,
    required int todoId,
  }) {
    return read(todoProvider(todoId));
  }

  /// 保持している配列の先頭に指定された配列を追加する
  static void prepend({
    required Reader read,
    required List<Todo> toods,
  }) {
    read(_todosStateProvider.notifier).update(
      (oldState) => // todos + oldState から重複を排除
    );
  }  

  /// 保持している配列の末尾に指定された配列を追加する
  static void append({
    required Reader read,
    required List<Todo> todos,
  }) {
    read(_todosStateProvider.notifier).update(
      (oldState) => // oldState + todos から重複を排除
    );
  }

  // 保持している配列を置換する
  static void replace({
    required Reader read,
    required List<Todo> todos,
  }) {
    read(_todosStateProvider.notifier).update(
      (_) => todos,
    );
  }
}

解説

store ファイルでは、

  • Provider の定義
  • Provider の操作クラスの提供

を行っています。

Providerの提供

store ファイルでは、非公開の Provider と公開している Provider を定義しています。
使い分けとしては、主に値を保持する StateProvider を非公開にしており、主に表示に用いる Provider は公開しています。

お察しの通り、 StateProvider は公開してしまうと、
どのレイヤーからも変更されてしまう恐れがあります。
なので、「確実にこのStore から変更されていることを保証する」ために非公開にしています。
あとは、適宜UIなどの必要に応じて Provider を公開しています。
お馴染み、ref.watch(todosProvider) などとしてUIで監視して使います。

Provider の操作クラスの提供

StateProvider の更新、保持データを使った値の返却、を行うクラスを提供します。

まず、何かの配列を保持する場合、 replace, prepend, append の三つを提供することが多いです。

メソッド 役割 利用用途
replace 置換 PullToRefresh など最新の情報を取得した場合
append 末尾への追加 ページングなどで次のデータを取得した場合
prepend 先頭への追加 バックグラウンド復帰時、最新のデータを取得した場合

getgetAll などの取得メソッドは、Facade クラス(今後記事化する予定) やテスト時などUI用途以外で保持データを取得する際に使っています。

🖊️ 補足

今回は、すごく簡単な例を挙げるために上記の様に書きましたが
実際はマスタとそれを参照する構成になっていたりします。

なぜマスタが必要?

例えば、弊社のサービスでは投稿と言うリソースがあり、様々な文脈でレスポンスとして返ってきます。
フィード、マイページ、カンパニーページのアクティビティなどなど。
それぞれの文脈ごとに投稿モデルを Provider で保持すると大変なことが起きます。

そんな時、投稿にいいねなんかしようもんなら全ての投稿モデルが
保持されていそうな Provider 定義を更新しにいくことになります。

そのため、マスタを用意し、各文脈では表示すべきidの配列を管理するだけになっております。

実装

// master_post_store.dart

/// 公開用
/// 指定postIdを持つPostインスタンスを返す、なければnull
final postProvider = Provider.family<Post?, String>(
  (ref, postId) => ref.watch(_postsMasterStateProvider)[postId],
);

/// 公開用
/// 投稿マスタのProvider
final postsMasterProvider = Provider<Map<String, Post>>(
  (ref) => ref.watch(_postsMasterStateProvider),
);

/// 管理用
/// 投稿マスタの元データ
final _postsMasterStateProvider = StateProvider<Map<String, Post>>(
  (ref) => {},
);

class MasterPostStore {
   const MasterPostStore._();

  static Post? get({
    required Reader read,
    required String postId,
  }) {
    return read(_postsMasterStateProvider)[postId];
  }

  static void put({
    required Reader read,
    required List<Post> posts,
  }) {
    final newValues = // postsをMap<String, Post>の形にする

    read(_postsMasterStateProvider.notifier).update(
      (Map<PostId, Post> oldState) => {
        ...oldState,
        ...newValues,
      },
    );
  }

  static void appendComment({
    required Reader read,
    required String postId,
    required PostComment comment,
  }) {
    final oldPost = get(read: read, postId: postId);
    if (oldPost == null) {
      return;
    }

    final newPost = oldPost.appendingComment(
      comment: comment,
    );
    read(_postsMasterStateProvider.notifier).update(
      (oldState) => {
        ...oldState,
        {postId: newPost},
      },
    );
  }
}

実装 (利用側の一部)

final feedPostsProvider = Provider<List<Post>?>(
  (ref) {
    // 表示すべき投稿ID一覧
    final postIds = ref.watch(_postIdsProvider);
    if (postIds == null) {
      return null;
    }

    final master = ref.watch(postsMasterProvider);
    return postIds.map((postId) => master[postId]).whereNotNull();
  },
);

...

解説

いいねのトグルや、コメントの追加/削除などモデルに変更を加える場合はこの MasterStore
責任を持って更新します。

そして、表示するための投稿モデルの配列を取得する場合は、
保持している postIds を元に master から引っ張ってきて返却しています。

🎥 最後に

いかがでしたでしょうか? YOUTRUSTアプリがいかにデータを扱っているかが少しでも伝わって、参考になったなら幸いです。

エンジニア募集中っ!

YOUTRUSTでは、エンジニアを募集しています! YOUTRUSTに興味を持っていただけた方は下のリンクよりご応募お待ちしておりまっす!

herp.careers