🤝

cloud_firestoreのwithConverterと仲良くなる

2021/09/20に公開

※この記事は、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 型の扱いと挙動については中々理解できず、1つずつ実行しながら中身を見ていきました。色々と苦戦したり試行錯誤しながらの内容ですが、何かのヒントになりましたら幸いです。

参考

Discussion