2020-03-08
json_serializable
はJSONのシリアライズ/デシリアライズを行うパッケージ。
クラスに@JsonSerializable
アノテーションをつけると、JSONの変換コードを生成してくれるという方式になっている。
生成するコードのオプションは色々と揃っていて、カスタムの型変換を指定する方法も当然用意されている。フィールドに指定する@JsonKey
アノテーションにはfromJson
とtoJson
が設定でき、ここで型変換の関数を指定できる。
@JsonSerializable
class TestObject {
@JsonKey(fromJson: dateTimeFromJson, toJson: dateTimeToJson)
DateTime dateTime;
}
プロジェクトが小さいうちはこれで十分かもしれないが、プロジェクトが大きくなって何度も同じ設定を書くようになると、段々とつらくなってくる。
そんなわけで、@JsonKey
アノテーションなしでカスタムの型変換を実現する方法を模索した。
json_serializable
のコード生成は、build_runner
というパッケージが提供する仕組みに乗っかって実現されている。pub run build_runner build
を実行すると、json_serializable
のbuild.yaml
から設定を読み出し、何をすべきか判断するという流れになっている。
で、json_serializable
ではJsonSerializableGenerator
クラスを呼び出すように設定されているので、ここに手を加えればカスタムの型変換を追加できることがわかる。
JsonSerializableGenerator
クラスの内部では、個々の型変換はTypeHelper
のサブクラス群が担っており、使用するTypeHelper
はJsonSerializableGenerator
クラスのコンストラクタで指定できる。ここにカスタムの型変換を行うTypeHelper
を追加すれば、カスタムの型変換をビルトインの型変換と同様に扱えるようになる。
以上をまとめると、やりたいことが実現するには次のものが必要そうだ。
TypeHelper
を用意する。build.yaml
で↑を使うコードジェネレーターを設定する。TypeHelper
クラスを継承し、serialize()
メソッドとdeserialize()
メソッドを実装する。それぞれのメソッドには変換対象の式と変換元(先)の型が渡されるので、必要に応じて変換するコードを文字列として返す。
json_serializable
のjson_serializable/lib/src/type_helpers
に実際に使われているTypeHelper
の例があるので、参考にすると理解が早い。
例として、JSONのUNIXミリ秒のnumberをDartのDateTime
クラスに変換するTypeHelper
を実装した。
class UnixmillisecondHelper extends TypeHelper {
final _typeChecker = TypeChecker.fromUrl('dart:core#DateTime');
UnixmillisecondHelper();
@override
String serialize(
DartType targetType,
String expression,
TypeHelperContext context,
) {
if (!_typeChecker.isExactlyType(targetType)) {
return null;
}
if (context.nullable) {
expression = '$expression?';
}
return '$expression.millisecondsSinceEpoch';
}
@override
String deserialize(
DartType targetType,
String expression,
TypeHelperContext context,
) {
if (!_typeChecker.isExactlyType(targetType)) {
return null;
}
return context.nullable
? '$expression == null ? null : DateTime.fromMillisecondsSinceEpoch($expression)'
: 'DateTime.fromMillisecondsSinceEpoch($expression)';
}
}
build_runner
にカスタムのJsonSerializableGenerator
を認識してもらうには、build.yaml
でBuilder
を返す関数を指定する。build.yaml
の設定の書き方やBuilder
を返す関数の書き方は、json_serializable
のjson_serializable/build.yaml
やjson_serializable/lib/builder.dart
を参考にすれば良い。
今回は次のようにして、UnixmillisecondHelper
を使うBuilder
を返した。
Builder customJsonSerializable(BuilderOptions options) {
try {
final config = JsonSerializable.fromJson(options.config);
return SharedPartBuilder(
[
JsonSerializableGenerator(
config: config,
typeHelpers: [
UnixmillisecondHelper(),
],
),
const JsonLiteralGenerator()
],
'custom_json_serializable',
);
} on CheckedFromJsonException catch (e) {
final lines = <String>[
'Could not parse the options provided for `json_serializable`.'
];
if (e.key != null) {
lines.add('There is a problem with "${e.key}".');
}
if (e.message != null) {
lines.add(e.message);
} else if (e.innerError != null) {
lines.add(e.innerError.toString());
}
throw StateError(lines.join('\n'));
}
}
build.yaml
は以下のように書いた。
builders:
custom_json_serializable:
import: "package:custom_json_serializable/builder.dart"
builder_factories: ["customJsonSerializable"]
build_extensions: {".dart": ["custom_json_serializable.g.part"]}
auto_apply: dependents
build_to: cache
applies_builders: ["source_gen|combining_builder"]
例として、以下のようなファイルを用意した。
import 'package:json_annotation/json_annotation.dart';
part 'item.g.dart';
@JsonSerializable()
class Item {
String name;
DateTime createdAt;
Item(this.name, this.createdAt);
factory Item.fromJson(Map<String, dynamic> json) => _$ItemFromJson(json);
Map<String, dynamic> toJson() => _$ItemToJson(this);
}
pubspec.yaml
のdependencies
にjson_annotation
を追加し、dev_dependencies
に作成したパッケージとbuild_runner
を追加する。そして、pub run build_runner build
を実行すると、以下のようなファイルが生成された。
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'item.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Item _$ItemFromJson(Map<String, dynamic> json) {
return Item(
json['name'] as String,
json['createdAt'] == null
? null
: DateTime.fromMillisecondsSinceEpoch(json['createdAt']),
);
}
Map<String, dynamic> _$ItemToJson(Item instance) => <String, dynamic>{
'name': instance.name,
'createdAt': instance.createdAt?.millisecondsSinceEpoch,
};
生成されたコードからDateTime
とUNIXミリ秒の相互変換を行っていることがわかる。また、実際にItem('Apple', DateTime(2020, 3, 8))
をJSONに変換すると、以下のようになった。
{
"name": "Apple",
"createdAt": 1583593200000
}
json_serializable
のコードベースはカスタマイズしやすい構造になっていると感じた。とはいえ、この記事で紹介したようなを調査して、実際にカスタマイズできるようになるにはそれなりに時間が掛かったし、パッケージの利用者がカスタマイズしやすい状態になっているとは言い難い。
プラグインみたいな機構ができて、アプリケーションのpubspec.yaml
とbuild.yaml
の設定だけで型変換のルールを追加できたら、もっと幅広い利用者が使えるようになるんじゃないかと思う。