🤝

cloud_firestoreのwithConverterと仲良くなる

10 min read

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

はじめに

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 modelsRef = FirebaseFirestore
     .instance
     .collection('models')
     .withConverter<Model>(  // Reference型に連結
       fromFirestore: (snapshot, _) => Model.fromJson(snapshot.data()!), // デコード
       toFirestore: (model, _) => model.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クラスをアプリ内で受け渡す実装をする場合、HogeクラスとPiyoクラスで取り間違いが発生してしまうなどの誤用を許してしまう問題がありました(人為的に気をつけれていればある程度防げるものではありますが脆い状態ではありました)。

具体的なメリットとして

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

などが挙げられます。

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

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

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

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

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

withConverterの記述箇所を考える

type-safeで良い面はありつつもどうしてもコード量が増えてしまいがちなので、下記のようなExtensionを用意して機械的にまとめておくと少し管理が楽になります(JS版のインタフェースと同等の使い方ができるようになります)。ただし、必要に応じてCollectionReference, DocumentReference, Query(CollectionGroup用)の最大3つのExtensionを書く必要があり、場合によっては面倒な側面もあります。

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

// 使うとき
FirebaseFirestore.instance.collection('hoge').withHogeConverter();

また、この際1点注意なのがwithConverterする度に違うインスタンスを生成してしまうため、同じ型の_WithConverterReference<T>でも一致判定がfalseになってしまいます(下記参照)。

final collectionRef = FirebaseFirestore.instance.collection('hoge');
final hogeRef = collectionRef.withHogeConverter();
final hogeRef2 = collectionRef.withHogeConverter();
print(hogeRef == hogeRef2)
// -> false

// path指定すれば一応一致判定はできるが..
print(hogeRef.path == hogeRef2.path)
// -> true

fromFirestore/toFirestoreの関数部分をstatic関数にするなどしてで同一インスタンスとして扱えるようにする必要があります(まだ試せていないです)。


もしくは、RepisitoryクラスのようなものでFirestore操作を1ファイル内に制限している場合は、下記のように書くのもありかもしれません。

hoge_repository.dart
class HogeRepository {
  final _firestore = FirebaseFirestore.instance;
  late final CollectionReference<Hoge> _hogeCollectionRef =
      _firestore.collection('hoge').withConverter(
        fromFirestore: (snapshot, _) => Hoge.fromJson(snapshot.data()!),
        toFirestore: (hoge, _) => hoge.toJson(),
      );
}

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

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

ソースコードを見れば分かることではありますが、通常のReference型として扱ってハマってしまったので記しました。発生した問題は下記です。

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

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<Hoge>'

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

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

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

そのため、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('hogeRef', isEqualTo: ref) // -> 正常に機能します

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

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

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

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

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

hoge_ref_converter.dart
class HogeRefConverter
    implements JsonConverter<DocumentReference<Hoge>, Object> {
  
  DocumentReference<Hoge> fromJson(Object json) {
    if (json == null) {
      return null;
    }
    final jsonMap = json as DocumentReference<Map<String, dynamic>>;
    // JSONデコードの 過程で`withConverter`して型をつける
    return jsonMap.withHogeConverter();
  }
  
  Object toJson(DocumentReference<Hoge> object) {
    // withConverter前のDocumentReference型に戻してFirestoreへ
    return object.raw;
    // もしくは
    // return object == null ? null : FirebaseFirestore.instance.doc(object.path);
  }
}

// 利用側
class Foo {
  final HogeRefConverter hogeRef;
}

また、下記のような抽象的な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 HogeRefConverter extends DocumentReferenceConverterBase<Hoge> {
  
  DocumentReference<Hoge> convert(
          DocumentReference<Map<String, dynamic>> ref) =>
      ref.withHogeConverter();
}

まとめ

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

参考

Discussion

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