typed_firestore를 소개합니다

Page content

이 글은 google/built_value.dart를 모르면 이해하기 힘들 수도 있습니다.

typed_firestore 이란?

flutter 프레임워크와 firestore를 같이 사용할 때 유용한 라이브러리입니다.

기능

typed

dart는 타입이 있는 언어입니다. 2.0이 나오면서 타입 시스템 관련된 기능이 많이 개선되었죠. 공식 firestore 라이브러리는 Map<String, dynamic>을 사용하는데,dynamic은 타입 시스템의 이점을 전부 날려버립니다.

그래서 나온 라이브러리가 typed_firestore입니다. 이 라이브러리는 dart 2.0의 타입 시스템을 충분히 활용합니다.

final _fb = TypedFirestore(Firestore.instance, serializers);

// CollRef<Car> extends TypedQuery<Car>
final CollRef<Car> cars = _fb.collection<Car>("cars");

// 쿼리
final TypedQuery<Car> query = cars.limit(5);
final TypedQuerySnapshot<Car> qs = await query.getDocs();
final List<DocSnapshot<Car>> cars = qs.docs;

// 문서 접근
final DocRef<Car> carRef = cars.doc('foo');
final DocSnapshot<Car> car = await carRef.get();

(물론 firestore의 특성상, 가끔은 dynamic이 필요합니다)

상속


@BuiltValue(instantiable: false)
abstract class Schedule {
  DateTime get start;

  DateTime get end;
}

/// 일회성 이벤트를 나타냅니다.
@BuiltValue(wireName: 'once')
abstract class Once implements Built<Once, OnceBuilder>, Schedule {
  static Serializer<Once> get serializer => _$onceSerializer;

  Once._();

  factory Once([updates(OnceBuilder b)]) = _$Once;
}

abstract class Repeated implements Built<Repeated, RepeatedBuilder>, Schedule {
  TimeOfDay get startTime;

  TimeOfDay get endTime;

  RepeatMode get mode;

  static Serializer<Repeated> get serializer => _$repeatedSerializer;

  Repeated._();

  factory Repeated([updates(RepeatedBuilder b)]) = _$Repeated;
}

.raw

가끔은 dynamic이 필요합니다. 필드 값을 서버 시간에 맞출 때와 필드를 삭제할 때는 dynamic (혹은 Object) 타입이 필요합니다. firestoreFieldValue가 특수한 값을 사용하는 대신 Object타입의 객체와 비교하기 때문인데요, 이럴 때를 위해 DocRef 클래스에 .raw라는 getter가 정의되어 있습니다.

Dogfooding

이 라이브러리를 이용해서 4개의 앱을 제작했으며, 그 중 하나는 다트 코드만 3만 5천 줄이 넘는 대형 플랫폼 앱입니다. 앱별로 요구사항이 상이했는데, 이 라이브러리는 그 요구사항을 모두 만족합니다.

사용법

1. 타입 정의

이 라이브러리는 built_value를 이용해서 json을 변환합니다.

import 'package:typed_firestore/typed_firestore.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';

abstract class Activity extends DocData
    implements Built<Activity, ActivityBuilder> {
  // built_value 에선 이런 식으로 필드를 지정합니다.
  String get content;

  // 모든 필드가 항상 존재할 필요는 없죠. 그럴 땐 @nullable 을 활용하시면 됩니다.
  @nullable
  String get location;

  // 중요한 부분입니다. typed_firestore은 built_value를 이용해
  // serialization, deserialization을 처리하기 때문에 아래와 같은 코드가 필요합니다.
  static Serializer<Activity> get serializer => _$activitySerializer;

  // 아래 두 줄은 built_value에서 사용하는 생성자입니다.
  Activity._();
  factory Activity([updates(ActivityBuilder b)]) = _$Activity;
}

2. 코드 생성

@SerializersFor([
  Activity,
])
final Serializers mySerializers = _$mySerializers;
final Serializers serializers = (mySerializers.toBuilder()
      ..addPlugin(StandardJsonPlugin())
    .build();
# 코드 변경되면 자동으로 업데이트
flutter packages pub run build_runner watch
# CI에서 사용하는 명령어
flutter packages pub run build_runner build

3. 사용

final _fb = TypedFirestore(Firestore.instance, serializers);

// CollRef<Car> extends TypedQuery<Car>
final CollRef<Activity> activities = _fb.collection<Activity>("activities");

// 쿼리
final TypedQuery<Activity> query = activities.limit(5);
final TypedQuerySnapshot<Activity> qs = await query.getDocs();
final List<DocSnapshot<Activity>> activities = qs.docs;

// 문서 접근
final DocRef<Car> activityRef = activities.doc('foo');
final DocSnapshot<Car> activity = await activityRef.get();

bt 스니펫

이 파트의 내용은 built_value.dart/README.md의 관련 섹션과 같은 내용입니다.

IntelliJ live template에 아래 내용을 bt로 저장해놓으시면 매우 편리합니다.

abstract class $CLASS_NAME$ implements Built<$CLASS_NAME$, $CLASS_NAME$Builder> {
  $CLASS_NAME$._();
  factory $CLASS_NAME$([void Function($CLASS_NAME$Builder) updates]) = _$$$CLASS_NAME$;
}

DateTime

final Serializers serializers = (mySerializers.toBuilder()
      ..addPlugin(_TimestampSerializerPlugin()))
    .build();


class _TimestampSerializerPlugin implements SerializerPlugin {
  @override
  Object beforeSerialize(Object object, FullType specifiedType) {
    if (object is DateTime && specifiedType.root == DateTime) {
      return object.toUtc();
    }
    return object;
  }

  @override
  Object afterSerialize(Object object, FullType specifiedType) {
    if (specifiedType.root == DateTime) {
      return Timestamp.fromMicrosecondsSinceEpoch(object);
    }
    return object;
  }

  @override
  Object beforeDeserialize(Object object, FullType specifiedType) {
    if (object is Timestamp && specifiedType.root == DateTime) {
      return object.microsecondsSinceEpoch;
      // return object.toDate();
    } else {
      return object;
    }
  }

  @override
  Object afterDeserialize(Object object, FullType specifiedType) {
    return object;
  }
}

TimeOfDay

flutter에서 제공하는 TimeOfDay를 직렬화하는 예시입니다.

final Serializers serializers = (mySerializers.toBuilder()
      ..add(_TimeOfDaySerializer())
    .build();

/// TimeOfDay <-> minutes (int)
///
///  - 1시 10분 <-> 70
///  - 3시 5분 <-> 185
class _TimeOfDaySerializer implements PrimitiveSerializer<TimeOfDay> {
  @override
  Iterable<Type> get types => BuiltList<Type>([TimeOfDay]);

  @override
  String get wireName => 'TimeOfDay';

  @override
  TimeOfDay deserialize(Serializers serializers, Object serialized,
      {FullType specifiedType = FullType.unspecified}) {
    final v = serialized as int;

    return TimeOfDay(hour: v ~/ 60, minute: v - (v ~/ 60) * 60);
  }

  @override
  Object serialize(Serializers serializers, TimeOfDay object,
      {FullType specifiedType = FullType.unspecified}) {
    return object.hour * 60 + object.minute;
  }
}

계획

partial update

partial 업데이트를 더 잘 지원하기 위해 코드 생성기를 만들 계획입니다.

가능한지는 확실하지 않지만, 현재 생각하고 있는 API는 이렇습니다.

final DocRef<Car> ref = _fb.collection('cars').doc('foo');
await ref.set(
  CarUpdate()..brand = "newBrand",
  merge: true
);

merge

클라이언트에서 여러 개의 쿼리를 merge 해주는 함수를 추가할지 말지 고민중입니다.