[Flutter] JsonCodable, 그게 뭔데? 마지막편
다트 3.5에 프리뷰로 공개된 macro에 대해서 살펴보고 있습니다. 구글은 macro 공개와 함께 JSON 직렬화와 역직렬화를 위한 toJson와 fromJson을 macro를 통해 처리하는 JsonCodable을 함께 공개했는데, 총 4편의 글을 통해 JsonCodable 내부 원리를 분석하며 macro에 대한 이해도를 높이고 있습니다.
이번에는 JsonCodable을 통해 toJson 메소드를 생성하는 macro 코드를 살펴보며 JsonCodable 시리즈 포스팅을 마치도록 하겠습니다.
JsonCodable 매크로
리마인드 차원에서 JsonCodable 매크로 클래스 구현체를 다시 살펴봅시다. JsonCodable 매크로 클래스는 ClassDefinitionMacro 인터페이스를 통해 buildDefinitionForClass 메소드를 구현하고 있습니다. buildDefinitionForClass의 buildToJson이 이번 포스팅에서 살펴볼 JsonCodable을 통해 생성되는 toJson을 생성하는 private 메소드입니다.
macro class JsonCodable
with _Shared, _FromJson, _ToJson
implements ClassDeclarationsMacro, ClassDefinitionMacro {
const JsonCodable();
/// Declares the `fromJson` constructor and `toJson` method, but does not
/// implement them.
@override
Future<void> buildDeclarationsForClass(
ClassDeclaration clazz, MemberDeclarationBuilder builder) async {
final mapStringObject = await _setup(clazz, builder);
await (
_declareFromJson(clazz, builder, mapStringObject),
_declareToJson(clazz, builder, mapStringObject),
).wait;
}
/// Provides the actual definitions of the `fromJson` constructor and `toJson`
/// method, which were declared in the previous phase.
@override
Future<void> buildDefinitionForClass(
ClassDeclaration clazz, TypeDefinitionBuilder builder) async {
final introspectionData =
await _SharedIntrospectionData.build(builder, clazz);
await (
_buildFromJson(clazz, builder, introspectionData),
_buildToJson(clazz, builder, introspectionData),
).wait;
}
}
_ToJson 믹스인
_ToJson 믹스인에는 _buildToJson, _checkNoToJson, _checkValidToJson, _convertTypeToJson, _declareToJson 메소드가 구현되어 있습니다.
mixin _ToJson on _Shared {
/// Builds the actual `toJson` method.
Future<void> _buildToJson(
ClassDeclaration clazz,
TypeDefinitionBuilder typeBuilder,
_SharedIntrospectionData introspectionData) async {
final methods = await typeBuilder.methodsOf(clazz);
final toJson =
methods.firstWhereOrNull((c) => c.identifier.name == 'toJson');
if (toJson == null) return;
if (!(await _checkValidToJson(toJson, introspectionData, typeBuilder))) {
return;
}
final builder = await typeBuilder.buildMethod(toJson.identifier);
// If extending something other than `Object`, it must have a `toJson`
// method.
var superclassHasToJson = false;
final superclassDeclaration = introspectionData.superclass;
if (superclassDeclaration != null &&
!superclassDeclaration.isExactly('Object', _dartCore)) {
final superclassMethods = await builder.methodsOf(superclassDeclaration);
for (final superMethod in superclassMethods) {
if (superMethod.identifier.name == 'toJson') {
if (!(await _checkValidToJson(
superMethod, introspectionData, builder))) {
return;
}
superclassHasToJson = true;
break;
}
}
if (!superclassHasToJson) {
builder.report(Diagnostic(
DiagnosticMessage(
'Serialization of classes that extend other classes is only '
'supported if those classes have a valid '
'`Map<String, Object?> toJson()` method.',
target: introspectionData.clazz.superclass?.asDiagnosticTarget),
Severity.error));
return;
}
}
final fields = introspectionData.fields;
final parts = <Object>[
'{\n final json = ',
if (superclassHasToJson)
'super.toJson()'
else ...[
'<',
introspectionData.stringCode,
', ',
introspectionData.objectCode.asNullable,
'>{}',
],
';\n ',
];
Future<Code> addEntryForField(FieldDeclaration field) async {
final parts = <Object>[];
final doNullCheck = field.type.isNullable;
if (doNullCheck) {
parts.addAll([
'if (',
field.identifier,
// `null` is a reserved word, we can just use it.
' != null) {\n ',
]);
}
parts.addAll([
"json['",
field.identifier.name,
"'] = ",
await _convertTypeToJson(
field.type,
RawCode.fromParts([
field.identifier,
if (doNullCheck) '!',
]),
builder,
introspectionData),
';\n ',
]);
if (doNullCheck) {
parts.add('}\n ');
}
return RawCode.fromParts(parts);
}
parts.addAll(await Future.wait(fields.map(addEntryForField)));
parts.add('return json;\n }');
builder.augment(FunctionBodyCode.fromParts(parts));
}
/// Emits an error [Diagnostic] if there is an existing `toJson` method on
/// [clazz].
///
/// Returns `true` if the check succeeded (there was no `toJson`) and false
/// if it didn't (a diagnostic was emitted).
Future<bool> _checkNoToJson(
DeclarationBuilder builder, ClassDeclaration clazz) async {
/// 중략
}
/// Checks that [method] is a valid `toJson` method, and throws a
/// [DiagnosticException] if not.
Future<bool> _checkValidToJson(
MethodDeclaration method,
_SharedIntrospectionData introspectionData,
DefinitionBuilder builder) async {
/// 중략
}
/// Returns a [Code] object which is an expression that converts an instance
/// of type [type] (referenced by [valueReference]) into a JSON map.
Future<Code> _convertTypeToJson(
TypeAnnotation rawType,
Code valueReference,
DefinitionBuilder builder,
_SharedIntrospectionData introspectionData) async {
/// 중략
}
/// Declares a `toJson` method in [clazz], if one does not exist already.
Future<void> _declareToJson(
ClassDeclaration clazz,
MemberDeclarationBuilder builder,
NamedTypeAnnotationCode mapStringObject) async {
/// 중략
}
}
JsonCodable 매크로를 통해 toJson을 생성하는 _buildToJson 살펴보기
_buildToJson이 실질적으로 toJson 메소드를 생성하는 메소드입니다. 대략 100여 라인의 메소드인데 코드 동작을 자세히 들여다봅시다. 먼저 메소드의 인자로 전달된 ClassDeclaration, TypeDefinitionBuilder, _SharedIntrospectionData를 통해 toJson 메소드를 찾고, 없으면 매크로에 의한 코드 생성 로직을 중단합니다.
mixin _ToJson on _Shared {
/// Builds the actual `toJson` method.
Future<void> _buildToJson(
ClassDeclaration clazz,
TypeDefinitionBuilder typeBuilder,
_SharedIntrospectionData introspectionData) async {
final methods = await typeBuilder.methodsOf(clazz);
final toJson =
methods.firstWhereOrNull((c) => c.identifier.name == 'toJson');
if (toJson == null) return;
if (!(await _checkValidToJson(toJson, introspectionData, typeBuilder))) {
return;
}
/// 중략
}
}
매크로 생성을 하지도 않았는데, toJson이 없거나 유효하지 않으면 생성 로직을 중단하는 이유는 Go to Augmentation을 통해 생성된 코드를 보면 알 수 있습니다. 바로 external을 통해 선언된 toJson을 검사하기 위함입니다.
augment library 'package:deepdive_macro/main.dart';
import 'dart:core' as prefix0;
augment class Counter {
/// 중략
external prefix0.Map<prefix0.String, prefix0.Object?> toJson();
augment prefix0.Map<prefix0.String, prefix0.Object?> toJson() {
final json = <prefix0.String, prefix0.Object?>{};
json['_count'] = this._count;
return json;
}
}
다음은 fromJson과 마찬가지로 super 클래스가 toJson 메소드를 구현하고 있는지 검사합니다. 유효하지 않거나 슈퍼클래스가 toJson 메소드를 구현하고 있지 않다면 에러를 뱉으며 매크로에 의한 코드 생성을 중단합니다.
mixin _ToJson on _Shared {
/// Builds the actual `toJson` method.
Future<void> _buildToJson(
ClassDeclaration clazz,
TypeDefinitionBuilder typeBuilder,
_SharedIntrospectionData introspectionData) async {
/// 중략
/// augment prefix0.Map<prefix0.String, prefix0.Object?> toJson()
final builder = await typeBuilder.buildMethod(toJson.identifier);
// If extending something other than `Object`, it must have a `toJson`
// method.
var superclassHasToJson = false;
final superclassDeclaration = introspectionData.superclass;
if (superclassDeclaration != null &&
!superclassDeclaration.isExactly('Object', _dartCore)) {
final superclassMethods = await builder.methodsOf(superclassDeclaration);
for (final superMethod in superclassMethods) {
if (superMethod.identifier.name == 'toJson') {
if (!(await _checkValidToJson(
superMethod, introspectionData, builder))) {
return;
}
superclassHasToJson = true;
break;
}
}
if (!superclassHasToJson) {
builder.report(Diagnostic(
DiagnosticMessage(
'Serialization of classes that extend other classes is only '
'supported if those classes have a valid '
'`Map<String, Object?> toJson()` method.',
target: introspectionData.clazz.superclass?.asDiagnosticTarget),
Severity.error));
return;
}
}
다음은 toJson 코드를 생성하는 로직입니다. 모든 필드를 순회하며 nullable 인 경우 null 여부를 검사하는 코드와 json에 필드의 이름으로 값을 할당하는 로직을 생성합니다. 값을 타입에 따라 변환하는 코드는 _convertTypeToJson 을 통해 생성합니다. _convertTypeToJson은 List, Set, Map, Primitive 타입인 경우와 toJson을 구현하고 있는 오브젝트 케이스에 대해서 코드를 생성합니다. toJson 메소드의 본문 코드 생성을 위한 Code 객체를 배열에 모두 추가한 뒤 builder의 augment 메소드를 호출하며, FunctionBodyCode의 생성자 메소드인 fromParts를 호출해 생성된 객체를 전달해 toJson 코드 생성을 마칩니다.
augment class Counter {
/// 중략
external prefix0.Map<prefix0.String, prefix0.Object?> toJson();
augment prefix0.Map<prefix0.String, prefix0.Object?> toJson() {
final json = <prefix0.String, prefix0.Object?>{};
json[r'_count'] = this._count;
return json;
}
}
mixin _ToJson on _Shared {
/// Builds the actual `toJson` method.
Future<void> _buildToJson(
ClassDeclaration clazz,
TypeDefinitionBuilder typeBuilder,
_SharedIntrospectionData introspectionData) async {
/// 중략
final fields = introspectionData.fields;
/// final json = <prefix0.String, prefix0.Object?>{};
final parts = <Object>[
'{\n final json = ',
if (superclassHasToJson)
'super.toJson()'
else ...[
'<',
introspectionData.stringCode,
', ',
introspectionData.objectCode.asNullable,
'>{}',
],
';\n ',
];
Future<Code> addEntryForField(FieldDeclaration field) async {
final parts = <Object>[];
final doNullCheck = field.type.isNullable;
if (doNullCheck) {
parts.addAll([
'if (',
field.identifier,
// `null` is a reserved word, we can just use it.
' != null) {\n ',
]);
}
/// json[r'_count'] = this._count;
parts.addAll([
"json['",
field.identifier.name,
"'] = ",
await _convertTypeToJson(
field.type,
RawCode.fromParts([
field.identifier,
if (doNullCheck) '!',
]),
builder,
introspectionData),
';\n ',
]);
if (doNullCheck) {
parts.add('}\n ');
}
return RawCode.fromParts(parts);
}
/// json[r'_count'] = this._count;
parts.addAll(await Future.wait(fields.map(addEntryForField)));
/// return json;
parts.add('return json;\n }');
builder.augment(FunctionBodyCode.fromParts(parts));
}
}
끝으로
JsonCodable 내부 코드를 살펴보며 JSON 직렬화와 역직렬화 코드를 생성하는 코드를 살펴봤습니다. 아직까지는 Preview로 제공되는 기능이라 본격적으로 사용하기엔 어려움이 많습니다. 하지만 빌드 러너에 의한 코드 생성 방식으로 동작하던 패키지들이 매크로 기반으로 대체되는건 시간 문제라 생각됩니다. 대표적으로 Preview 버젼으로 제공되는 매크로의 특성 상 Preview 버젼에서 사용할 수 있는 dataclass 라는 패키지가 공개됐습니다. dataclass 패키지는 Freezed 패키지를 매크로 기반으로 rename한 패키지입니다. 레미가 X에서 소개했는데, 활발히 개발중인것으로 보여집니다.
다트팀에서 매크로를 공식적으로 출시하기 위해 열심히 개발중인 상태라 실제 출시할 때에는 시리즈 포스팅을 통해 살펴본 JsonCodable 내부 코드와는 달라질 가능성이 많은게 사실입니다. 다만 미리 준비해서 매크로와 함께 더욱더 생산성 높은 플러터 개발 환경이 되길 바라며 포스팅을 마치겠습니다.
그럼 언제나 그렇듯 Happy Coding👨💻