[Flutter] JsonCodable, 그게 뭔데? 3편

Cody Yun
Flutter Seoul
Published in
30 min readMay 27, 2024

Flutter 3.22, Dart 3.5의 마스터 채널에 미리보기 버전으로 추가된 JsonCodable 매크로에 대해 살펴보고 있습니다.

출처 : Wikipedia

되돌아보기

1편에서는 정적 메타 프로그래밍에 대해 간단히 살펴보고, JsonCodable 매크로를 사용해 Json 직렬화와 역-직렬화를 처리해봤습니다.

2편에서는 JsonCodable 매크로와 json_serializable 패키지를 비교해봤습니다. 이후 JsonCodable 매크로를 통해 컴파일 타임에 만들어지는 생성되는 코드와 코드를 생성하는 코드 중 선언부를 생성하는 ClassDeclarationsMacro를 살펴보고 JsonCodable2 매크로를 직접 구현해봤습니다.

오늘은 JsonCodable 매크로의 직렬화와 역-직렬화 코드를 생성하는 ClassDefinitionMacro를 깊게 살펴보겠습니다.

ClassDefinitionMacro 살펴보기

JsonCodable 매크로 코드를 살펴봅시다. 2편에서 살펴본 ClassDeclarationsMacro와 함께 ClassDefinitionMacro를 구현하고 있는걸 확인할 수 있습니다.

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;
}
}

ClassDefinitionMacro는 Macro를 구현하도록 지정된 추상 인터페이스 클래스로, 비동기로 동작하는 buildDefinitionForClass를 구현하도록 추상 메소드가 선언되어있습니다.

/// The interface for [Macro]s that can be applied to any class, and want to
/// augment the definitions of the members of that class.
abstract interface class ClassDefinitionMacro implements Macro {
FutureOr<void> buildDefinitionForClass(
ClassDeclaration clazz, TypeDefinitionBuilder builder);
}

buildDefinitionForClass에 대한 문서를 현재는 찾을 수 없어 주석과 클래스와 메소드의 이름과 인자를 통해 살펴보면 다음과 같은 역할을 합니다. 클래스에 사용되어 클래스 멤버를 추가(augment)하는 인터페이스입니다. buildDefinitionForClass 메소드는 컴파일 타임에 호출되는데, 첫 번째 인자인 ClassDeclaration은 매크로가 지정된 클래스의 정보를 가져오는 객체입니다.

abstract interface class ClassDeclaration
implements ParameterizedTypeDeclaration {
/// Whether this class has an `abstract` modifier.
bool get hasAbstract;

/// Whether this class has a `base` modifier.
bool get hasBase;

/// Whether this class has an `external` modifier.
bool get hasExternal;

/// Whether this class has a `final` modifier.
bool get hasFinal;

/// Whether this class has an `interface` modifier.
bool get hasInterface;

/// Whether this class has a `mixin` modifier.
bool get hasMixin;

/// Whether this class has a `sealed` modifier.
bool get hasSealed;

/// The `extends` type annotation, if present.
NamedTypeAnnotation? get superclass;

/// All the `implements` type annotations.
Iterable<NamedTypeAnnotation> get interfaces;

/// All the `with` type annotations.
Iterable<NamedTypeAnnotation> get mixins;
}

두 번째 인자인 TypeDefinitionBuilder는 필드, 메소드, 생성자 등을 클래스에 추가하는 역할을 객체입니다.

abstract interface class TypeDefinitionBuilder implements DefinitionBuilder {
/// Retrieve a [VariableDefinitionBuilder] for a field with [identifier].
///
/// Throws a [MacroImplementationException] if [identifier] does not refer to
/// a field in this class.
Future<VariableDefinitionBuilder> buildField(Identifier identifier);

/// Retrieve a [FunctionDefinitionBuilder] for a method with [identifier].
///
/// Throws a [MacroImplementationException] if [identifier] does not refer to
/// a method in this class.
Future<FunctionDefinitionBuilder> buildMethod(Identifier identifier);

/// Retrieve a [ConstructorDefinitionBuilder] for a constructor with
/// [identifier].
///
/// Throws a [MacroImplementationException] if [identifier] does not refer to
/// a constructor in this class.
Future<ConstructorDefinitionBuilder> buildConstructor(Identifier identifier);
}

매크로가 지정된 클래스의 정보에 접근하는 ClassDeclaration과 클래스에 코드를 추가하는데 사용되는 TypeDefinitionBuilder를 이용해 ClassDefinitionMacro 인터페이스가 지정된 매크로 클래스에서 buildDefinitionForClass메소드의 구현체가 코드를 생성하는 코드가 되는것 입니다.

buildDefinitionForClass 메소드의 구현 살펴보기

JsonCodable의 buildDefinitionForClass는 아래와 같이 구현되어 있습니다.

macro class JsonCodable
with _Shared, _FromJson, _ToJson
implements ClassDeclarationsMacro, ClassDefinitionMacro {
const JsonCodable();

/// 중략

/// 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;
}
}

헬퍼 클래스인 _SharedIntrospectionData 클래스의 정적 메소드인 build 메소드를 호출하며 ClassDeclaration과 TypeDefinitionBuilder 객체의 인스턴스를 전달합니다. _SharedIntrospectionData의 정적 메서드인 build 메소드에서는 클래스에 코드를 추가하기 위한 다양한 정보를 담아 반환합니다.

final class _SharedIntrospectionData {

/// 중략

static Future<_SharedIntrospectionData> build(
DeclarationPhaseIntrospector builder, ClassDeclaration clazz) async {
final (list, map, mapEntry, object, string) = await (
builder.resolveIdentifier(_dartCore, 'List'),
builder.resolveIdentifier(_dartCore, 'Map'),
builder.resolveIdentifier(_dartCore, 'MapEntry'),
builder.resolveIdentifier(_dartCore, 'Object'),
builder.resolveIdentifier(_dartCore, 'String'),
).wait;
final objectCode = NamedTypeAnnotationCode(name: object);
final nullableObjectCode = objectCode.asNullable;
final jsonListCode = NamedTypeAnnotationCode(name: list, typeArguments: [
nullableObjectCode,
]);
final jsonMapCode = NamedTypeAnnotationCode(name: map, typeArguments: [
NamedTypeAnnotationCode(name: string),
nullableObjectCode,
]);
final stringCode = NamedTypeAnnotationCode(name: string);
final superclass = clazz.superclass;
final (fields, jsonMapType, superclassDecl) = await (
builder.fieldsOf(clazz),
builder.resolve(jsonMapCode),
superclass == null
? Future.value(null)
: builder.typeDeclarationOf(superclass.identifier),
).wait;

return _SharedIntrospectionData(
clazz: clazz,
fields: fields,
jsonListCode: jsonListCode,
jsonMapCode: jsonMapCode,
jsonMapType: jsonMapType,
mapEntry: mapEntry,
objectCode: objectCode,
stringCode: stringCode,
superclass: superclassDecl as ClassDeclaration?,
);
}
}

JsonCodable에서 JSON 직렬화와 역-직렬화 코드를 생성하는 코드는 _buildFromJson, _buildToJson 메소드인데 _FromJson 믹스인과 _ToJson 믹스인을 통해 JsonCodable에서 재사용됩니다.

macro class JsonCodable
with _Shared, _FromJson, _ToJson
implements ClassDeclarationsMacro, ClassDefinitionMacro {
const JsonCodable();

/// 중략

/// 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;
}
}

_FromJson 믹스인의 _buildFromJson은 아래와 같이 구현되어 있습니다. 앞서 살펴 본 ClassDeclaration, TypeDefinitionBuilder, _SharedIntrospectionData 객체의 인스턴스를 전달받아 선언된 클래스의 정보를 가져와 코드를 생성하고 있습니다.

mixin _FromJson on _Shared {
/// Builds the actual `fromJson` constructor.
Future<void> _buildFromJson(
ClassDeclaration clazz,
TypeDefinitionBuilder typeBuilder,
_SharedIntrospectionData introspectionData) async {
final constructors = await typeBuilder.constructorsOf(clazz);
final fromJson =
constructors.firstWhereOrNull((c) => c.identifier.name == 'fromJson');
if (fromJson == null) return;
await _checkValidFromJson(fromJson, introspectionData, typeBuilder);
final builder = await typeBuilder.buildConstructor(fromJson.identifier);

// If extending something other than `Object`, it must have a `fromJson`
// constructor.
var superclassHasFromJson = false;
final superclassDeclaration = introspectionData.superclass;
if (superclassDeclaration != null &&
!superclassDeclaration.isExactly('Object', _dartCore)) {
final superclassConstructors =
await builder.constructorsOf(superclassDeclaration);
for (final superConstructor in superclassConstructors) {
if (superConstructor.identifier.name == 'fromJson') {
await _checkValidFromJson(
superConstructor, introspectionData, builder);
superclassHasFromJson = true;
break;
}
}
if (!superclassHasFromJson) {
throw DiagnosticException(Diagnostic(
DiagnosticMessage(
'Serialization of classes that extend other classes is only '
'supported if those classes have a valid '
'`fromJson(Map<String, Object?> json)` constructor.',
target: introspectionData.clazz.superclass?.asDiagnosticTarget),
Severity.error));
}
}
final fields = introspectionData.fields;
final jsonParam = fromJson.positionalParameters.single.identifier;

Future<Code> initializerForField(FieldDeclaration field) async {
return RawCode.fromParts([
field.identifier,
' = ',
await _convertTypeFromJson(
field.type,
RawCode.fromParts([
jsonParam,
"['",
field.identifier.name,
"']",
]),
builder,
introspectionData),
]);
}

final initializers = await Future.wait(fields.map(initializerForField));

if (superclassHasFromJson) {
initializers.add(RawCode.fromParts([
'super.fromJson(',
jsonParam,
')',
]));
}

builder.augment(initializers: initializers);
}

_FromJson 믹스인의 _buildFromJson 살펴보기

JsonCodable을 통해 생성된 fromJson을 먼저 살펴봅시다.

@JsonCodable()
class Counter {
int _count = 0;
int get count => _count;
Counter();
void increment() => _count++;
}

int 타입의 _counter 필드를 담고 있는 Counter 클래스의 JSON 직렬화와 역-직렬화를 위해 JsonCodable 매크로를 추가했습니다. VS Code의 Go to augmentation 메뉴를 클릭하면 아래와 같이 생성된 코드를 볼 수 있습니다.

augment library 'package:deepdive_macro/main.dart';

import 'dart:core' as prefix0;

augment class Counter {
/// 중략
augment Counter.fromJson(prefix0.Map<prefix0.String, prefix0.Object?> json, )
: this._count = json['_count'] as prefix0.int;
/// 생략
}

생성된 코드에서 Map 타입의 JSON을 통해 Counter 객체를 생성하는 fromJson 네임드 생성자를 확인할 수 있습니다. 생성된 코드를 통해 아래와 같은 결과물을 확인할 수 있습니다.

  • fromJson은 string 타입의 key, Object 타입을 value로 하는 Map을 파라미터로 받는 네임드 생성자
  • fromJson 네임드 생성자에서는 생성자의 파라미터에서 필드의 이름으로 꺼내오고, 필드의 타입으로 캐스팅
  • 필드의 이름으로 꺼내오고, 필드의 타입의 캐스팅한 값을 필드를 초기화

이제 각 결과물을 만드는 코드를 하나씩 살펴봅시다.

fromJson 네임드 생성자를 만드는 코드

먼저 fromJson은 string 타입의 key, Object 타입을 value로 하는 Map을 파라미터로 받는 네임드 생성자를 생성하는 코드를 살펴봅시다. typeBuilder의 constructorsOf에 ClassDeclaration을 전달하면, 클래스의 생성자 목록을 반환합니다. 생성자 목록에서 name이 fromJson이 있는지 검사하고, fromJson이 없다면 매크로 실행을 중단합니다. fromJson 코드를 생성하는 로직은 2편에서 다룬 ClassDelcarationsMacro에 의해 formJson 생성자가 만들어져 조건 검사를 통과하게됩니다.

final constructors = await typeBuilder.constructorsOf(clazz);
final fromJson =
constructors.firstWhereOrNull((c) => c.identifier.name == 'fromJson');
if (fromJson == null) return;

typeBuilder의 buildConstructor 메소드를 호출해 생성자 코드를 만들기 위한 builder 객체를 생성합니다. 역-직렬화할 객체의 슈퍼 클래스가 있는지 검사하합니다. 슈퍼 클래스도 함께 역-직렬화를 하기 위해 fromJson 네임드 생성자가 있는지 검사하고 없다면 DiagnosticException을 던져 매크로 코드 생성에 대한 오류를 발생시킵니다.

final builder = await typeBuilder.buildConstructor(fromJson.identifier);
var superclassHasFromJson = false;
final superclassDeclaration = introspectionData.superclass;
if (superclassDeclaration != null &&
!superclassDeclaration.isExactly('Object', _dartCore)) {
final superclassConstructors =
await builder.constructorsOf(superclassDeclaration);
for (final superConstructor in superclassConstructors) {
if (superConstructor.identifier.name == 'fromJson') {
await _checkValidFromJson(
superConstructor, introspectionData, builder);
superclassHasFromJson = true;
break;
}
}
if (!superclassHasFromJson) {
throw DiagnosticException(Diagnostic(
DiagnosticMessage(
'Serialization of classes that extend other classes is only '
'supported if those classes have a valid '
'`fromJson(Map<String, Object?> json)` constructor.',
target: introspectionData.clazz.superclass?.asDiagnosticTarget),
Severity.error));
}
}

이제 코드를 생성하는 매크로에서 가장 중요한 fromJson 네임드 생성자의 구현체를 생성 코드를 살펴볼 차례입니다. 클래스의 필드 정보와 fromJson 생성자의 파라미터를 가져옵니다.

final fields = introspectionData.fields;
final jsonParam = fromJson.positionalParameters.single.identifier;

클래스의 필드 정보를 담고 있는 fields를 map으로 순회하며, RawCode.fromParts를 호출하며 필드의 초기값으로 _convertTypeFromJson 함수의 인자로 json에서 필드의 이름으로 가져오고 타입으로 캐스팅하는 _convertTypeFromJson 함수를 호출해 역-직렬화 코드를 생성하기 위한 Code 배열을 만들어 builder의 augment에 전달해 코드를 생성합니다.

Future<Code> initializerForField(FieldDeclaration field) async {
return RawCode.fromParts([
field.identifier,
' = ',
await _convertTypeFromJson(
field.type,
RawCode.fromParts([
jsonParam,
"['",
field.identifier.name,
"']",
]),
builder,
introspectionData),
]);
}

final initializers = await Future.wait(fields.map(initializerForField));

if (superclassHasFromJson) {
initializers.add(RawCode.fromParts([
'super.fromJson(',
jsonParam,
')',
]));
}

builder.augment(initializers: initializers);

JsonCodable의 역-직렬화 필드 타입

클래스의 각 필드별 직렬화 코드를 생성하는 _convertTypeFromJson 함수는 아래와 같이 구현되어 있습니다. 아래 코드를 살펴보면 JsonCodable은 List, Set, Map, int, double, num, String, bool 타입이나 fromJson 네임드 생성자를 가지고 있는 필드에 대해서만 직렬화를 지원하는걸 볼 수 있습니다.

Future<Code> _convertTypeFromJson(
TypeAnnotation rawType,
Code jsonReference,
DefinitionBuilder builder,
_SharedIntrospectionData introspectionData) async {
final type = _checkNamedType(rawType, builder);
if (type == null) {
return RawCode.fromString(
"throw 'Unable to deserialize type ${rawType.code.debugString}'");
}

// Follow type aliases until we reach an actual named type.
var classDecl = await type.classDeclaration(builder);
if (classDecl == null) {
return RawCode.fromString(
"throw 'Unable to deserialize type ${type.code.debugString}'");
}

var nullCheck = type.isNullable
? RawCode.fromParts([
jsonReference,
// `null` is a reserved word, we can just use it.
' == null ? null : ',
])
: null;

// Check for the supported core types, and deserialize them accordingly.
if (classDecl.library.uri == _dartCore) {
switch (classDecl.identifier.name) {
case 'List':
return RawCode.fromParts([
if (nullCheck != null) nullCheck,
'[ for (final item in ',
jsonReference,
' as ',
introspectionData.jsonListCode,
') ',
await _convertTypeFromJson(type.typeArguments.single,
RawCode.fromString('item'), builder, introspectionData),
']',
]);
case 'Set':
return RawCode.fromParts([
if (nullCheck != null) nullCheck,
'{ for (final item in ',
jsonReference,
' as ',
introspectionData.jsonListCode,
')',
await _convertTypeFromJson(type.typeArguments.single,
RawCode.fromString('item'), builder, introspectionData),
'}',
]);
case 'Map':
return RawCode.fromParts([
if (nullCheck != null) nullCheck,
'{ for (final ',
introspectionData.mapEntry,
'(:key, :value) in (',
jsonReference,
' as ',
introspectionData.jsonMapCode,
').entries) key: ',
await _convertTypeFromJson(type.typeArguments.last,
RawCode.fromString('value'), builder, introspectionData),
'}',
]);
case 'int' || 'double' || 'num' || 'String' || 'bool':
return RawCode.fromParts([
jsonReference,
' as ',
type.code,
]);
}
}

// Otherwise, check if `classDecl` has a `fromJson` constructor.
final constructors = await builder.constructorsOf(classDecl);
final fromJson = constructors
.firstWhereOrNull((c) => c.identifier.name == 'fromJson')
?.identifier;
if (fromJson != null) {
return RawCode.fromParts([
if (nullCheck != null) nullCheck,
fromJson,
'(',
jsonReference,
' as ',
introspectionData.jsonMapCode,
')',
]);
}

// Unsupported type, report an error and return valid code that throws.
builder.report(Diagnostic(
DiagnosticMessage(
'Unable to deserialize type, it must be a native JSON type or a '
'type with a `fromJson(Map<String, Object?> json)` constructor.',
target: type.asDiagnosticTarget),
Severity.error));
return RawCode.fromString(
"throw 'Unable to deserialize type ${type.code.debugString}'");
}

끝으로

JsonCodable 매크로를 통해 fromJson 네임드 생성자가 구현되는 과정을 자세히 살펴봤습니다. JsonCodable은 프리뷰 버젼으로 제공된 매크로이고, 문서화도 되어있지 않아 현 시점에 프로젝트에 사용할 매크로를 직접 만들기에 부적절해 보입니다. 하지만 코드를 만드는 코드, 정적 메타 프로그래밍 등 듣기만 해도 어렵게 느껴지는 매크로의 내부 코드를 하나씩 뜯어보면 어렵지 않다는걸 알 수 있었습니다. 다음 포스팅에서는 toJson 메서드를 통해 JSON 직렬화 코드를 생성하는 부분을 자세히 살펴보도록 하겠습니다.

그러면 언제나 그렇듯 Happy Coding👨‍💻

--

--

Cody Yun
Flutter Seoul

I wanna be a full stack software engineer at the side of user-facing application.