🤝

cloud_firestoreのwithConverterと仲良くなる

2021/09/20に公開約10,600字

※この記事は、FlutterFireのcloud_firestoreプラグインについて言及しています。

CHANGELOG
  • 2022.03.25
    • withConverterのextensionを生やした際の同一性判定にの注意について追記

はじめに

withConverterメソッドはcloud_firestore2.0.0で追加された、エンコード/デコード時に型を明示してコレクションやドキュメントをtype-safeに扱えるようにするメソッドです。

cloud_firestore 2.0.0

下記が該当のPRです。当初CollectionReferenceとDocumentReferenceのみでQueryが未対応でCollectionGroupでは使えませんでしたが、今ではその対応も済んでいます。

https://github.com/FirebaseExtended/flutterfire/pull/6015

v1からのマイグレーションガイドも一応ドキュメントにありますがMap<String,dynamic>で互換性を維持する形に留まっており、実際はユースケースに合わせて型をつけた方が良いです。
(参考)Migration to cloud_firestore 2.0.0 | FlutterFire

また余談ですが、Javascript用のSDKでは既にFirestoreDataConverterインタフェースがあり、このインタフェースと同等の使い方がFlutterでもできるようになった、ということらしいです(この辺りはあまり詳しくないです)。

https://firebase.google.com/docs/reference/js/v8/firebase.firestore.FirestoreDataConverter

文法的なこと

Reference型にwithConverterメソッドを連結して書くことができます。

 final userRef = FirebaseFirestore
     .instance
     .collection('user')
     .withConverter<User>(  // Reference型に連結
       fromFirestore: (snapshot, _) => User.fromJson(snapshot.data()!), // デコード
       toFirestore: (user, _) => user.toJson(), // エンコード
     );

withConverter()の引数にとる2つの関数はtypedefで定義されています。

typedef FromFirestore<T> = T Function(
  DocumentSnapshot<Map<String, dynamic>> snapshot,
  SnapshotOptions? options,
);
typedef ToFirestore<T> = Map<String, Object?> Function(
  T value,
  SetOptions? options,
);

SnapshotOptionsについては2021年9月現在FlutterFireでは未対応となっており、FromFirestore()の第ニ引数は利用できません。

Currently unsupported by FlutterFire, but exposed to avoid breaking changes
in the future once this class is supported.

https://github.com/FirebaseExtended/flutterfire/blob/b6232364e5269f1ee8b8ea6511925adb98b9c893/packages/cloud_firestore/cloud_firestore/lib/src/document_snapshot.dart#L16-L20

ちなみにこのSnapshotOptionsですが、現在日時などをFieldValueでサーバタイムスタンプから取得する際に、サーバで算出中に先んじてローカル時刻をベースにした推定時刻を返すなどの指定ができるオプションになります。
(参考)SnapshotOptions | JavaScript SDK  |  Firebase
(参考)Firestoreに書き込み後FieldValueがnullになる問題

withConverterで嬉しいこと

参照クラスの返り値を見ると分かりますが、そのままだとMap<String, dynamic>型が返ってきます。withConverter()が無い時代(2.0.0以前)では参照クラスに型がつけられなかったため、例えばDocumentReferenceクラスをアプリ内で受け渡す実装をする場合、UserクラスとHogeクラスで取り間違いが発生してしまうなどの誤用を許してしまう問題がありました(人為的に気をつけれていればある程度防げるものではありますが脆い状態ではありました)。

具体的なメリットとして

  • データのエンコード/デコード時の割と早い段階で型をつけることができる
  • アプリ内でReference型を取り回す際の取り違えを防げる
  • 利用箇所でfromJson()などのデコード処理を書く必要がなくdata()のみで対象のモデルのデータを取得できる

などが挙げられます。

例えばuserコレクションのデータ一覧を取得したい場合に、下記のように書くことが出来ます(違いが分かるように前後比較を記載しました)。

2.0.0以前
// CollectionReference<Map<String,dynamic>>
final userRef = FirebaseFirestore
    .instance
    .collection('user');

final snapshot = await userRef.get();
snapshot.docs.map(
    // Map<String, dynamic>型のまま
    (doc) => doc.data(),
);

snapshot.docs.map(
    // `withConverter()`が追加されるまでは得られたsnapshotに対して都度デコード処理をしていた
    (doc) => user.fromJson(doc.data()),
);
2.0.0以降
// CollectionReference<User>
final userRef = FirebaseFirestore
    .instance
    .collection('user')
    .withConverter<User>(
        fromFirestore: (snapshot, _) => user.fromJson(snapshot.data()!),
        toFirestore: (user, _) => user.toJson(),
    );

final snapshot = await userRef.get();
snapshot.docs.map(
    // `withConverter()`でuser型にデコードしする関数を定義しているためuserクラスが得られる
    (doc) => doc.data(),
);

withConverterのextensionを生やす

type-safeで良い面はありつつもどうしてもコード量が増えてしまいがちなので、下記のようなExtensionを用意して機械的にまとめておくと少し管理が楽になります(JS版のインタフェースと同等の使い方ができるようになります)。

with_converter_extension.dart
// 必要に応じてCollectionReferenceやQueryも生やす必要がある
extension DocumentReferenceX<E> on DocumentReference {
  DocumentReference<User> withUserConverter() => withConverter(
        fromFirestore: (snapshot, _) => user.fromJson(snapshot.data()!),
        toFirestore: (user, _) => user.toJson(),
      );
}

// 使うとき
FirebaseFirestore.instance.collection('user').withUserConverter();

extensionとして生やす場合は型ごとに定義が必要なため、場合によってはCollectionReference, DocumentReference, Query(CollectionGroup用)の最大3つのExtensionを書く必要があり若干面倒な側面があります。

また、この方法には以下の注意が必要です。
FromFirestore/ToFirestoreが無名関数となっているため、extensionとして生やした場合にwithConverterが呼び出される度に参照が異なってしまうため(hashCodeが異なる)同じ型の_WithConverterReference<T>でも同一判定(Operator==)でfalseになってしまいます(下記参照)。

final usersRef = FirebaseFirestore.instance.collection('user').withUserConverter();
final usersRef2 = FirebaseFirestore.instance.collection('user').withUserConverter()
print(usersRef == usersRef2)
// -> false

// withConverterされたObjectそのものではなく得られたプロパティ`String`型などであれば問題ない
print(usersRef.path == usersRef2.path)
// -> true

これを防ぐためにはfromFirestore/toFirestoreの関数部分をstatic関数にするかトップレベル関数などとして参照が変わらないようにする必要があります。

extension DocumentReferenceX<E> on DocumentReference {
  static User _toUser(
    DocumentSnapshot<Map<dynamic,String>> snapshot,
    SnapshotOptions? options,
  ) =>
      User.fromJson(snapshot.data()!);
  static JsonMap _fromUser(
    User user,
    SetOptions? options,
  ) =>
      user.toJson();
  DocumentReference<User> withUserConverter() => withConverter(
        fromFirestore: _toUser,
        toFirestore: _fromUser,
      );
}

final usersRef = FirebaseFirestore.instance.collection('user').withUserConverter();
final usersRef2 = FirebaseFirestore.instance.collection('user').withUserConverter();
print(usersRef == usersRef2)
// -> true

もしくは、RepisitoryクラスのようなものでFirestoreのインタフェースを1ファイル内にまとめている場合などは、下記のように書くのもありかもしれません。

user_repository.dart
class userRepository {
  final _firestore = FirebaseFirestore.instance;
  late final CollectionReference<User> _collectionRef =
      _firestore.collection('user').withConverter(
        fromFirestore: (snapshot, _) => user.fromJson(snapshot.data()!),
        toFirestore: (user, _) => user.toJson(),
      );
}

どちらの書き方でもアプリケーションコード内で統一しておいたほうが良いですね。

withConverter後のReference型の実体は加工後の別物

ソースコードを見れば分かることではありますが、通常のReference型として扱っていて暫くハマってしまったので記しました。withConverter後のDocumentReferenceをFirestoreのクエリ条件に指定するとExceptionが発生します

// `userRef`は適当なDocumentReferenceのフィールド
// `withConverteruserRef`は既にwithConverterで変換されたReference
.where('userRef', isEqualTo: withConverterUserRef)

I/flutter (10018): ══╡ EXCEPTION CAUGHT BY SERVICES LIBRARY ╞══════════════════════════════════════════════════════════
I/flutter (10018): The following ArgumentError was thrown while activating platform stream on channel
I/flutter (10018): plugins.flutter.io/firebase_firestore/query/9a93d8fc-015e-4b9a-8513-170f9bb9cf82:
I/flutter (10018): Invalid argument: Instance of '_WithConverterDocumentReference<user>'

これはDocumentReferenceのwithConverter後の型は_WithConverterDocumentReferenceとなり、DocumentReference型とは全くの別物の加工物であることが原因です。下記の通りrutimeTypeを見ると別物となっています。

print(userRef.runtimeType);
// -> _JsonDocumentReference

print(withConverterRef.runtimeType);
// -> _WithConverterDocumentReference<user>

そのため、withConverter()後のReference型をアプリ内で取り扱う実装をしていた場合に、そのままwhereなどのクエリで適用すると機能しません。

ちなみに内部実装を見ると、下記の型判定でDocumentReferencePlatformに該当せずelse分岐に渡っていることにより発生したExceptionでした。

value is DocumentReferencePlatform

https://github.com/FirebaseExtended/flutterfire/blob/8ffd3a159bd71eaad8415113c9b2f430c200899c/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/method_channel/utils/firestore_message_codec.dart#L69

長くなってしまいましたが、ワークアラウンドとしてwithConverter前のDocumentReferenceに戻してあげるとwhereクエリでも機能します。

// pathから一度ピュアなReference型に戻してあげる
final ref = FirebaseFirestore.instance.doc(withConverterRef.path);
.where('userRef', isEqualTo: ref) // -> 正常に機能します

ただ、クエリ判定の度にこれを書くのは煩わしいので、withConverter前のReference型を得られるextentionを用いて普段は開発しています。

extension DocumentReferenceX<E> on DocumentReference<E> {
  DocumentReference<Map<String, dynamic>> get raw =>
      FirebaseFirestore.instance.doc(path);
}

// クエリとして利用する際は`.raw`で変換前に戻す
.where('userRef', isEqualTo: withConverterRef.raw)

Jsonデコード時に型をつける

型をつけるのはなるべく早いに越したことはないので、Jsonのデコード時にwithConverterする方法です。json_annotationの抽象クラスJsonConverter<T,S>をimplementsして下記のように書けます。

user_ref_converter.dart
class UserRefConverter
    implements JsonConverter<DocumentReference<User>, Object> {
  
  DocumentReference<User> fromJson(Object json) {
    if (json == null) {
      return null;
    }
    final jsonMap = json as DocumentReference<Map<String, dynamic>>;
    // JSONデコードの 過程で`withConverter`して型をつける
    return jsonMap.withUserConverter();
  }
  
  Object toJson(DocumentReference<User> object) {
    // withConverter前のDocumentReference型に戻してFirestoreへ
    return object.raw;
    // extensionを生やさない場合は
    // return FirebaseFirestore.instance.doc(object.path);
  }
}

// 利用側
class Foo {
  final UserRefConverter userRef;
}

また、下記のような抽象的なBaseクラスを用意しておくと別の型のDocumentReferenceにも適用でき汎用性が高いです。

document_ref_converter.dart
abstract class DocumentReferenceConverterBase<E>
    implements JsonConverter<DocumentReference<E>, Object> {
  const DocumentReferenceConverterBase();
  
  DocumentReference<E> fromJson(Object json) {
    return convert(json as DocumentReference<Map<String, dynamic>>);
  }
  DocumentReference<E> convert(DocumentReference<Map<String, dynamic>> ref);
  //(省略)...
}

class userRefConverter extends DocumentReferenceConverterBase<user> {
  
  DocumentReference<user> convert(
          DocumentReference<Map<String, dynamic>> ref) =>
      ref.withuserConverter();
}

まとめ

今回はcloud_firestoreプラグインのwithConverterメソッドについて扱いました。当初登場した際は「型安全になる」とポジティブでしたが、蓋をあけてみるとやや扱い慣れていない影響か苦戦しました(JS版のFirestoreDataConverterに慣れている方にとってはスムーズに受け入れられたのでしょうか)。特に_WithConverterXxxxReference型の扱いと挙動については中々理解できず、一つずつ実行しながら中身を見ていきました。色々と苦戦したり試行錯誤しながらの内容ですが、何かのヒントになりましたら幸いです。

参考

Discussion

ログインするとコメントできます