Продолжаем предыдущую статью — так что без долгих предисловий идём к примерам.
Авто-dispose
Зачем?
Удобнее «повесить» аннотацию на поле, которое нужно «выключить» при удалении объекта, чем делать это вручную и спускаться в метод dispose
.
Как это должно выглядеть?
Определим сущности, к которым хотим применить макрос — это сущности, у которых есть:
метод
dispose
;метод
close
(например,StreamController
);метод
cancel
(например,StreamSubscription
).альтернативный метод «выключения».
Что будет, если мы применим макрос к полю, у которого нет метода dispose
/close
/cancel
? Ничего критичного, просто анализатор сообщит нам, что, сюрприз, метода dispose у поля нет.
@AutoDispose()
class SomeModel {
@disposable
final ValueNotifier<int> a;
@closable
final StreamController<int> b;
@cancelable
final StreamSubscription<int> c;
@Disposable('customDispose')
final CustomDep d;
SomeModel({required this.a, required this.b, required this.c, required this.d});
}
class CustomDep {
void customDispose() {}
}
augment library 'package:test_macros/3.%20auto_dispose/example.dart';
augment class SomeModel {
void dispose() {
a.dispose();
b.close();
c.cancel();
d.customDispose();
}
}
Как это реализовать?
Для начала самое простое — создадим аннотации disposable
, cancelable
, closable
и Disposable
:
const disposeMethod = 'dispose';
const closeMethod = 'close';
const cancelMethod = 'cancel';
const disposableAnnotationName = 'disposable';
const closableAnnotationName = 'closable';
const cancelableAnnotationName = 'cancelable';
const customDisposableAnnotationName = 'Disposable';
const customDisposableFieldName = 'disposeMethodName';
const disposable = Disposable(disposeMethod);
const closable = Disposable(closeMethod);
const cancelable = Disposable(cancelMethod);
class Disposable {
final String disposeMethodName;
const Disposable(this.disposeMethodName);
}
Пора создавать макрос. Как и в предыдущих случаях, выберем фазу макроса:
фаза типов нам не подходит — мы не собираемся создавать новые типы;
фаза объявления позволяет нам добавлять код внутри класса — мы этого и хотим;
фаза определения словно бы нам не нужна, потому что всё необходимое мы можем сделать в фазе объявления.
import 'dart:async';
import 'package:macros/macros.dart';
macro class AutoDispose implements ClassDeclarationsMacro {
const AutoDispose();
@override
FutureOr<void> buildDeclarationsForClass(ClassDeclaration clazz, MemberDeclarationBuilder builder) async {
final fields = await builder.fieldsOf(clazz);
}
}
Соберём словарь, где ключом будет имя поля, а значением — имя метода, который надо вызвать:
final fields = await builder.fieldsOf(clazz);
/// Ключ - имя поля, значение - имя метода для вызова.
final disposables = <String, Object>{};
for (final field in fields) {
Object? methodName;
final annotations = field.metadata;
/// Ищем аннотацию Disposable с кастомным именем метода.
final customDispose = annotations.whereType<ConstructorMetadataAnnotation>().firstWhereOrNull(
(element) => element.type.identifier.name == customDisposableAnnotationName,
);
if (customDispose != null) {
methodName = customDispose.namedArguments[customDisposableFieldName];
} else {
/// Если аннотация не найдена, ищем стандартные аннотации.
///
/// - отсеиваем константные аннотации;
/// - ищем аннотации, которые содержат нужные нам идентификаторы.
/// - сопоставляем идентификаторы с методами.
methodName = switch ((annotations.whereType<IdentifierMetadataAnnotation>().firstWhereOrNull(
(element) => [
disposableAnnotationName,
closableAnnotationName,
cancelableAnnotationName,
].contains(element.identifier.name),
))?.identifier.name) {
disposableAnnotationName => disposeMethod,
closableAnnotationName => closeMethod,
cancelableAnnotationName => cancelMethod,
_ => null,
};
}
if (methodName != null) {
disposables[field.identifier.name] = methodName;
}
}
Дело за малым — собираем код метода dispose
и добавляем его в класс:
final code = <Object>[
'\tvoid dispose() {\n',
...disposables.entries.map((e) {
return ['\t\t${e.key}.', e.value, '();\n'];
}).expand((e) => e),
'\t}\n',
];
builder.declareInType(DeclarationCode.fromParts(code));
Казалось бы, победа! Но вот мы смотрим на сгенерированный код и видим такую картину:
augment library 'package:test_macros/3.%20auto_dispose/example.dart';
augment class SomeModel {
void dispose() {
a.dispose();
b.close();
c.cancel();
d.'customDispose'();
}
}
Снова нож в спину от ExpressionCode
. Мы можем получить только код выражения, но не его значение. А поскольку код выражения содержит значение строки (с кавычками), то мы не можем использовать его в качестве имени метода.
Ищем обходные пути. Мы могли бы дать возможность пользователям реализовывать собственные аннотации. Но тогда макрос должен знать названия новых аннотаций — чтобы он принимал их во внимание во время генерации метода dispose
. Кроме того, он должен знать и названия методов, которые нужно вызвать.
Так, единственный вариант, который мы придумали — передавать в макрос словарь, где ключ это название аннотации, а значение — название метода:
@AutoDispose(
disposeMethodNames: {
'customDepDispose': 'customDispose',
},
)
class SomeModel {
@disposable
final ValueNotifier<int> a;
@closable
final StreamController<int> b;
@cancelable
final StreamSubscription<int> c;
@customDepDispose
final CustomDep d;
SomeModel({required this.a, required this.b, required this.c, required this.d});
}
const customDepDispose = Disposable('customDispose');
class CustomDep {
void customDispose() {}
}
Выглядит ужасно, да. Но ничего лучше не пришло нам в головы.
Внесём изменения в макрос — сначала соберём словарь всех возможных аннотаций и методов:
macro class AutoDispose implements ClassDeclarationsMacro {
final Map<String, String> disposeMethodNames;
const AutoDispose({
this.disposeMethodNames = const {},
});
@override
FutureOr<void> buildDeclarationsForClass(ClassDeclaration clazz, MemberDeclarationBuilder builder) async {
final allMethodNames = {
disposableAnnotationName: disposeMethod,
closableAnnotationName: closeMethod,
cancelableAnnotationName: cancelMethod,
...disposeMethodNames,
};
...
}
}
Поиск кастомного метода нам больше не нужен, как и switch — теперь это будет поиск по ключу в словаре:
for (final field in fields) {
Object? methodName;
final annotations = field.metadata;
final annotationName = ((annotations.whereType<IdentifierMetadataAnnotation>().firstWhereOrNull(
(element) => allMethodNames.keys.contains(element.identifier.name),
))?.identifier.name);
methodName = allMethodNames[annotationName];
if (methodName != null) {
disposables[field.identifier.name] = methodName;
}
}
Остальной код остаётся без изменений. Проверяем сгенерированный код и, наконец-то, видим заветное:
augment library 'package:test_macros/3.%20auto_dispose/example.dart';
augment class SomeModel {
void dispose() {
a.dispose();
b.close();
c.cancel();
d.customDispose();
}
}
Пробуем запустить проект и в очередной раз получаем нож в спину:
Error: This macro application didn't apply correctly due to an unhandled map entry.
Несмотря на то, что наш входной параметр отвечает требованиям спецификации (это словарь с примитивными типами данных), макрос не может его обработать. Можем передавать параметры в виде строки (например, 'customDepDispose: customDispose'), но это неудобно и нечитаемо.
Помимо этого, у нашего примера есть ещё одна проблема — он не поддерживает вызов метода базового (не augment
) класса. По официальным примерам можно вызывать метод augmented()
внутри augment
-метода, однако на практике мы получаем ошибку — будто бы такого метода не существует.
Результат
Мы получили макрос, который будет работать с предустановленными сущностями. Однако для работы с кастомными нужна дополнительная настройка, которая из-за текущих ограничений макросов может быть огранизована только через костыли. Но мы вернули веру в пользу аннотаций после их провала в первом эксперименте.
DI контейнер
Зачем?
Типичный DI-контейнер в условиях Flutter-приложения выглядит так:
class AppScope implements IAppScope {
late final SomeDep _someDep;
late final AnotherDep _anotherDep;
late final ThirdDep _thirdDep;
ISomeDep get someDep => _someDep;
IAnotherDep get anotherDep => _anotherDep;
IThirdDep get thirdDep => _thirdDep;
AppScope(String someId) {
_someDep = SomeDep(someId);
}
Future<void> init() async {
_anotherDep = await AnotherDep.create();
_thirdDep = ThirdDep(_someDep);
}
}
abstract interface class IAppScope {
ISomeDep get someDep;
IAnotherDep get anotherDep;
IThirdDep get thirdDep;
}
Было бы здорово вместо этого получить контейнер, который:
позволяет указывать зависимости прямо в инициализаторе;
поддерживает асинхронную инициализацию;
защищён от циклических зависимостей;
выглядит лаконично.
Как это должно выглядеть?
Примерно так:
@DiContainer()
class AppScope {
late final Registry<SomeDependency> _someDependency = Registry(() {
return SomeDependency();
});
late final Registry<AnotherDependency> _anotherDependency = Registry(() {
return AnotherDependency(someDependency);
});
late final Registry<ThirdDependency> _thirdDependency = Registry(() {
return ThirdDependency(someDependency, anotherDependency);
});
}
augment class AppScope {
late final ISomeDependency someDependency;
late final IAnotherDependency anotherDependency;
late final IThirdDependency thirdDependency;
Future<void> init() async {
someDependency = await _someDependency();
anotherDependency = await _anotherDependency();
thirdDependency = await _thirdDependency();
}
}
Как это реализовать?
Разобьём задачу на подзадачи:
выбор фазы и создание аннотации;
создание класса
Registry
;создание метода
init
;построение порядка инициализации;
создание
late final
полей.
Выбор фазы и создание аннотации
Выберем фазу объявления — нам нужно добавить код внутри класса. Создадим аннотацию DiContainer
:
macro class DiContainer implements ClassDeclarationsMacro {
const DiContainer();
@override
FutureOr<void> buildDeclarationsForClass(
ClassDeclaration clazz,
MemberDeclarationBuilder builder,
) async {}
}
Создание класса Registry
Создадим класс Registry
:
class Registry<T> {
final FutureOr<T> Function() create;
Registry(this.create);
FutureOr<T> call() => create();
}
Создание метода init
Тут всё просто — используем старый-добрый builder.declareInType
:
@override
FutureOr<void> buildDeclarationsForClass(
ClassDeclaration clazz,
MemberDeclarationBuilder builder,
) async {
final initMethodParts = <Object>[
'Future<void> init() async {\n',
];
initMethodParts.add('}');
builder.declareInType(DeclarationCode.fromParts(initMethodParts));
}
Построение порядка инициализации
А здесь начинается самое интересное и сложное. Мы стремимся определить порядок инициализации полей. Для этого нам нужно:
собрать список зависимостей для каждого поля;
определить порядок инициализации таким образом, чтобы зависимости инициализировались раньше зависимых от них полей.
В первую очередь соберём словарь: ключом будет название поля с зависимостью, а значением — список параметров, которые требуются для её инициализации. Условно, для нашего примера словарь будет таким:
{
someDependency: [],
anotherDependency: [someDependency],
thirdDependency: [someDependency, anotherDependency],
}
Сделаем это:
final dependencyToConstructorParams = <String, List<String>>{};
for (final field in fields) {
final type = field.type;
if (type is! NamedTypeAnnotation) continue;
/// Отсекаем все поля, которые не являются Registry.
if (type.identifier.name != 'Registry') continue;
final generic = type.typeArguments.firstOrNull;
if (generic is! NamedTypeAnnotation) continue;
final typeDeclaration = await builder.typeDeclarationOf(generic.identifier);
if (typeDeclaration is! ClassDeclaration) continue;
final fields = await builder.fieldsOf(typeDeclaration);
final constructorParams = fields.where((e) => !e.hasInitializer).toList();
dependencyToConstructorParams[field.identifier.name.replaceFirst('_', '')] = constructorParams.map((e) => e.identifier.name.replaceFirst('_', '')).toList();
}
Теперь определим порядок инициализации. Для этого используем топологическую сортировку. Граф у нас уже есть, осталось реализовать сам алгоритм:
List<T> _topologicalSort<T>(
Map<T, List<T>> graph,
MemberDeclarationBuilder builder,
) {
/// Обработанные вершины.
final visited = <T>{};
/// Вершины, в которых мы находимся на текущий момент.
final current = <T>{};
/// Вершины, записанные в топологическом порядке.
final result = <T>[];
/// Рекурсивная функция обхода графа.
/// Возвращает [T], который образует цикл. Если цикла нет, возращает null.
T? process(T node) {
/// Если вершина уже обрабатывается, значит, мы нашли цикл.
if (current.contains(node)) {
return node;
}
/// Если вершина уже обработана, то пропускаем её.
if (visited.contains(node)) {
return null;
}
/// Добавляем вершину в текущие.
current.add(node);
/// Повторяем для всех соседей.
for (final neighbor in graph[node] ?? []) {
final result = process(neighbor);
if (result != null) {
return result;
}
}
current.remove(node);
visited.add(node);
result.add(node);
return null;
}
for (final node in graph.keys) {
final cycleAffectingNode = process(node);
/// Если обнаружен цикл, то выбрасываем исключение.
if (cycleAffectingNode != null) {
builder.report(
Diagnostic(
DiagnosticMessage(
'''Cycle detected in the graph. '''
'''$cycleAffectingNode requires ${graph[cycleAffectingNode]?.join(', ')}''',
),
Severity.error,
),
);
throw Exception();
}
}
return result;
}
Теперь, когда у нас есть порядок вызовов, можем дособрать функцию init
:
@override
FutureOr<void> buildDeclarationsForClass(
ClassDeclaration clazz,
MemberDeclarationBuilder builder,
) async {
...
final sorted = _topologicalSort(
dependencyToConstructorParams,
builder,
);
for (final dep in sorted) {
if (!dependencyToConstructorParams.keys.contains(dep)) continue;
/// Получаем что-то вроде:
/// ```
/// someDependency = await _someDependency();
/// ```
initMethodParts.addAll([
'\t\t$dep = await _$dep();\n',
]);
}
initMethodParts.add('}');
builder.declareInType(DeclarationCode.fromParts(initMethodParts));
}
Создание late final полей
Наконец, создаём late final поля. К сожалению, Registry
использует дженерик конкретного типа. Из-за этого напрямую нам недоступен интерфейс класса, за которым мы хотим скрыть реализацию. Поэтому мы берём первый из доступных интерфейсов (если он есть):
for (final field in fields) {
...
dependencyToConstructorParams[field.identifier.name.replaceFirst('_', '')] =
constructorParams.map((e) => e.identifier.name.replaceFirst('_', '')).toList();
++ final superClass = typeDeclaration.interfaces.firstOrNull;
++
++ builder.declareInType(
++ DeclarationCode.fromParts(
++ [
++ 'late final ',
++ superClass?.code ?? generic.code,
++ ' ${field.identifier.name.replaceFirst('_', '')};',
++ ],
++ ),
++ );
++ }
Результат
Применим макрос к классу AppScope
:
@DiContainer()
class AppScope {
late final Registry<SomeDependency> _someDependency = Registry(() {
return SomeDependency();
});
late final Registry<AnotherDependency> _anotherDependency = Registry(() {
return AnotherDependency(someDependency);
});
late final Registry<ThirdDependency> _thirdDependency = Registry(() {
return ThirdDependency(someDependency, anotherDependency);
});
AppScope();
}
и получим:
augment library 'package:test_macros/5.%20di_container/example.dart';
import 'package:test_macros/5.%20di_container/example.dart' as prefix0;
import 'dart:core';
import 'dart:async';
augment class AppScope {
late final prefix0.ISomeDependency someDependency;
late final prefix0.IAnotherDependency anotherDependency;
late final prefix0.IThirdDependency thirdDependency;
Future<void> init() async {
someDependency = await _someDependency();
anotherDependency = await _anotherDependency();
thirdDependency = await _thirdDependency();
}
}
Попробуем добавить IAnotherDependency
как параметр для зависимости SomeDependency
:
@DiContainer()
class AppScope {
late final Registry<SomeDependency> _someDependency = Registry(() {
return SomeDependency(anotherDependency);
});
...
}
И получим ошибку:
Результат
В этой реализации много «тонких» мест. Например, мы завязаны на том, что пользователь должен задавать инициализаторы строго приватными. А ещё мы не можем задавать имена публичных полей (даже с использованием аннотаций, так как переданные в них параметры будут доступны нам только как ExpressionCode
).
Не можем и в явном виде указывать интерфейс, под которым хотели бы видеть публичное поле. В теории, конечно, можно добавить второй дженерик к Registry
, но это лишит нас лаконичности.
Несмотря на всё это, мы получили работающий прототип DI-контейнера, который можно дорабатывать и улучшать.
Retrofit на макросах
Зачем?
Классическая версия retrofit для Dart работает с помощью build_runner. Похоже на потенциальную цель, чтобы перенести её на макросы.
Как это должно выглядеть?
@RestClient()
class Client {
Client(
this.dio, {
this.baseUrl,
});
@GET('/posts/{id}')
external Future<UserInfoDto> getUserInfo(int id);
@GET('/convert')
external Future<SomeEntity> convert(@Query() String from, @Query() String to);
}
augment class Client {
final Dio dio;
final String? baseUrl;
augment Future<PostEntity> getUserInfo(int id) async {
final queryParameters = <String, dynamic>{};
final _result = await dio.fetch<Map<String, dynamic>>(Options(
method: 'GET',
)
.compose(
dio.options,
"/posts/${id}",
queryParameters: queryParameters,
)
.copyWith(baseUrl: baseUrl ?? dio.options.baseUrl));
final value = PostEntity.fromJson(_result.data!);
return value;
}
augment Future<PostEntity> convert(String from, String to) async {
final queryParameters = <String, dynamic>{
'from': from,
'to': to,
};
final _result = await dio.fetch<Map<String, dynamic>>(Options(
method: 'GET',
)
.compose(
dio.options,
"/convert",
queryParameters: queryParameters,
)
.copyWith(baseUrl: baseUrl ?? dio.options.baseUrl));
final value = PostEntity.fromJson(_result.data!);
return value;
}
}
Пока мы ограничимся GET-запросами, query- и path-параметрами.
Как это реализовать?
По классике — начнём с создания аннотаций:
const query = Query();
class Query {
const Query();
}
Тут будет два макроса:
RestClient
для класса;GET
для методов.
Наиболее подходящая фаза макроса для клиента — фаза объявления: нам нужно добавить два поля в класс.
Создадим макрос и запишем название полей класса в константы:
import 'dart:async';
import 'package:macros/macros.dart';
const baseUrlVarSignature = 'baseUrl';
const dioVarSignature = 'dio';
macro class RestClient implements ClassDeclarationsMacro {
@override
FutureOr<void> buildDeclarationsForClass(ClassDeclaration clazz, MemberDeclarationBuilder builder) async {
/// Добавим импорт Dio.
builder.declareInLibrary(DeclarationCode.fromString('import \'package:dio/dio.dart\';'));
}
}
Получим список полей, убедимся, что поля, которые мы собираемся создать, отсутствуют, и, если так, создадим их:
final fields = await builder.fieldsOf(clazz);
builder.declareInLibrary(DeclarationCode.fromString('import \'package:dio/dio.dart\';'));
/// Проверяем, имеет ли класс поле baseUrl.
final indexOfBaseUrl = fields.indexWhere((element) => element.identifier.name == baseUrlVarSignature);
if (indexOfBaseUrl == -1) {
final stringType = await builder.resolveIdentifier(Uri.parse('dart:core'), 'String');
builder.declareInType(DeclarationCode.fromParts(['\tfinal ', stringType, '? $baseUrlVarSignature;']));
} else {
builder.report(
Diagnostic(
DiagnosticMessage('$baseUrlVarSignature is already defined.'),
Severity.error,
),
);
return;
}
final indexOfDio = fields.indexWhere((element) => element.identifier.name == dioVarSignature);
if (indexOfDio == -1) {
builder.declareInType(DeclarationCode.fromString('\tfinal Dio $dioVarSignature;'));
} else {
builder.report(
Diagnostic(
DiagnosticMessage('$dioVarSignature is already defined.'),
Severity.error,
),
);
return;
}
Теперь займёмся методом. Для макроса GET
берём фазу определения — нам нужно написать реализацию уже объявленного метода. Также добавляем фазу объявления, чтобы добавить импорты. Они упростят нам жизнь и избавят от необходимости импортировать кучу типов вручную.
macro class GET implements MethodDeclarationsMacro, MethodDefinitionMacro {
final String path;
const GET(this.path);
@override
FutureOr<void> buildDeclarationsForMethod(MethodDeclaration method, MemberDeclarationBuilder builder) async {
builder.declareInLibrary(DeclarationCode.fromString('import \'dart:core\';'));
}
@override
FutureOr<void> buildDefinitionForMethod(MethodDeclaration method, FunctionDefinitionBuilder builder) async {
}
}
Перед нами стоит несколько задач:
определить возвращаемый тип значения, чтобы реализовать парсинг;
собрать query-параметры;
подставить параметры в path, если они есть;
собрать это всё.
Нам нужно определить возвращаемый тип. Предполагается, что мы применим к нему метод fromJson
, чтобы спарсить ответ сервера. Стоит учесть кейсы, когда мы пытаемся получить коллекцию (List
) или не получаем никакого значения (void
). Заведём enum для типов возвращаемых значений:
/// Общий тип, который возвращает метод:
/// - коллекция
/// - одно значение
/// - ничего
enum ReturnType { collection, single, none }
Теперь можно определять возвращаемый тип (то есть, достать дженерик из Future
либо Future<List>
):
@override
FutureOr<void> buildDefinitionForMethod(MethodDeclaration method, FunctionDefinitionBuilder builder) async {
/// Здесь у нас будет что-то вроде `Future<UserInfoDto>`.
var type = method.returnType;
/// Сюда запишем тип возвращаемого значения.
NamedTypeAnnotation? valueType;
late ReturnType returnType;
/// На случай, если тип возвращаемого значения опущен при объявлении метода, попробуем его получить.
if (type is OmittedTypeAnnotation) {
type = await builder.inferType(type);
}
if (type is NamedTypeAnnotation) {
/// Проверяем, что тип возвращаемого значения - Future.
if (type.identifier.name != 'Future') {
builder.report(
Diagnostic(
DiagnosticMessage('The return type of the method must be a Future.'),
Severity.error,
),
);
return;
}
/// Получаем джинерик типа. У Future он всегда один.
final argType = type.typeArguments.firstOrNull;
valueType = argType is NamedTypeAnnotation ? argType : null;
switch (valueType?.identifier.name) {
case 'List':
returnType = ReturnType.collection;
valueType = valueType?.typeArguments.firstOrNull as NamedTypeAnnotation?;
case 'void':
returnType = ReturnType.none;
default:
returnType = ReturnType.single;
}
} else {
builder.report(
Diagnostic(
DiagnosticMessage('Cannot determine the return type of the method.'),
Severity.error,
),
);
return;
}
if (valueType == null) {
builder.report(
Diagnostic(
DiagnosticMessage('Cannot determine the return type of the method.'),
Severity.error,
),
);
return;
}
}
Теперь соберём query-параметры в словарь вида:
final _queryParameters = <String, dynamic>{
'from': from,
'to': to,
};
Для этого соберём все поля (именованные и позиционные) и возьмём те, у которых есть аннотация @query
:
/// Сюда будем собирать код для создания query параметров.
final queryParamsCreationCode = <Object>[];
final fields = [
...method.positionalParameters,
...method.namedParameters,
];
/// Собираем query параметры.
final queryParams = fields.where((e) => e.metadata.any((e) => e is IdentifierMetadataAnnotation && e.identifier.name == 'query')).toList();
Добавим к числу наших констант название переменной для query-параметров:
const baseUrlVarSignature = 'baseUrl';
const dioVarSignature = 'dio';
++ const queryVarSignature = '_queryParameters';
Теперь, если у нас есть query-параметры, добавим их в словарь:
queryParamsCreationCode.addAll([
'\t\tfinal $queryVarSignature = <String, dynamic>{\n',
...queryParams.map((e) => "\t\t\t'${e.name}': ${e.name},\n"),
'\t\t};\n',
]);
Займёмся путём запроса — подставим в него path-параметры.
Например, если у нас есть путь /posts/{id}
, то мы должны получить строку '/posts/$id'
.
пример, если у нас есть путь /posts/{id}, то мы должны получить строку '/posts/$id'.
final substitutedPath = path.replaceAllMapped(RegExp(r'{(\w+)}'), (match) {
final paramName = match.group(1);
final param = fields.firstWhere((element) => element.identifier.name == paramName, orElse: () => throw ArgumentError('Parameter \'$paramName\' not found'));
return '\${${param.identifier.name}}';
});
Пришло время собрать запрос. Не забываем, что мы можем получить не только одиночное значение, но и коллекцию. Ну или ничего. Это важно учесть при использовании метода fetch
и при парсинге ответа:
builder.augment(FunctionBodyCode.fromParts([
'async {\n',
...queryParamsCreationCode,
'\t\tfinal _result = await $dioVarSignature.fetch<',
switch (returnType) {
ReturnType.none => 'void',
ReturnType.single => 'Map<String, dynamic>',
ReturnType.collection => 'List<dynamic>',
},'>(\n',
'\t\t\tOptions(\n',
'\t\t\t\tmethod: "GET",\n',
'\t\t\t)\n',
'\t\t.compose(\n',
'\t\t\t $dioVarSignature.options,\n',
'\t\t\t "$substitutedPath",\n',
'\t\t\t queryParameters: $queryVarSignature,\n',
'\t\t)\n',
'\t\t.copyWith(baseUrl: $baseUrlVarSignature ?? $dioVarSignature.options.baseUrl));\n',
...switch (returnType) {
ReturnType.none => [''],
ReturnType.single => ['\t\tfinal value = ', valueType.code, '.fromJson(_result.data!);\n'],
ReturnType.collection => [
'\t\tfinal value = (_result.data as List).map((e) => ', valueType.code, '.fromJson(e)).toList();\n',
],
},
if (returnType != ReturnType.none) '\t\treturn value;\n',
'\t}',
]));
Для проверки результата воспользуемся JSONPlaceholder — бесплатным API для тестирования HTTP-запросов.
// ignore_for_file: avoid_print
@DisableDuplicateImportCheck()
library example;
import 'package:test_macros/5.%20retrofit/annotations.dart';
import 'package:test_macros/5.%20retrofit/client_macro.dart';
import 'package:dio/dio.dart';
@RestClient()
class Client {
Client(this.dio, {this.baseUrl});
@GET('/posts/{id}')
external Future<PostEntity> getPostById(int id);
@GET('/posts')
external Future<List<PostEntity>> getPostsByUserId(@query int user_id);
}
class PostEntity {
final int? userId;
final int? id;
final String? title;
final String? body;
PostEntity({
required this.userId,
required this.id,
required this.title,
required this.body,
});
factory PostEntity.fromJson(Map<String, dynamic> json) {
return PostEntity(
userId: json['userId'],
id: json['id'],
title: json['title'],
body: json['body'],
);
}
Map<String, dynamic> toJson() {
return {
'userId': userId,
'id': id,
'title': title,
'body': body,
};
}
}
Future<void> main() async {
final dio = Dio()..interceptors.add(LogInterceptor(logPrint: print));
final client = Client(dio, baseUrl: 'https://jsonplaceholder.typicode.com');
const idOfExistingPost = 1;
final post = await client.getPostById(idOfExistingPost);
final userId = post.userId;
if (userId != null) {
final posts = await client.getPostsByUserId(userId);
print(posts);
}
}
Запускаем и видим следующее:
Error: 'String' isn't a type.
Error: 'int' isn't a type.
Error: 'dynamic' isn't a type.
...
Это ещё одна обнаруженная в ходе написания статьи проблема макросов. Из-за того, что в одном файле есть одинаковые импорты — с префиксом и без — проект не может запуститься:
import "dart:core";
import "dart:core" as prefix01;
Так что нам придётся переписать почти весь код макроса, чтобы использовать только типы с префиксами.
Получать эти типы мы будем с помощью метода resolveIdentifier
, который принимает uri-библиотеки и название типа, а также отмечен как deprecated
ещё до релиза:
@override
FutureOr<void> buildDefinitionForMethod(MethodDeclaration method, FunctionDefinitionBuilder builder) async {
const stringTypeName = 'String';
const dynamicTypeName = 'dynamic';
const mapTypeName = 'Map';
const optionsTypeName = 'Options';
const listTypeName = 'List';
final stringType = await builder.resolveIdentifier(Uri.parse('dart:core'), stringTypeName);
final dynamicType = await builder.resolveIdentifier(Uri.parse('dart:core'), dynamicTypeName);
final mapType = await builder.resolveIdentifier(Uri.parse('dart:core'), mapTypeName);
final optionsType = await builder.resolveIdentifier(Uri.parse('package:dio/src/options.dart'), optionsTypeName);
final listType = await builder.resolveIdentifier(Uri.parse('dart:core'), listTypeName);
/// Шорткат для `<String, dynamic>`.
final stringDynamicMapType = ['<', stringType, ', ', dynamicType, '>'];
...
}
Теперь нам следует заменить все вхождения String
, dynamic
, Map
, Options
и List
на полученные нами «разрешённые» типы:
queryParamsCreationCode.addAll([
-- '\t\tfinal $queryVarSignature = <String, dynamic>{\n',
++ '\t\tfinal $queryVarSignature = ', ...stringDynamicMapType, '{\n',
...queryParams.map((e) => "\t\t\t'${e.name}': ${e.name},\n"),
'\t\t};\n',
]);
И в таком духе продолжаем во всех остальных местах (_dio.fetch<Map<String, dynamic>
>, Options
и так далее).
Теперь любуемся на результат.
Результат
Применим макрос к классу Client
:
@RestClient()
class Client {
Client(this.dio, {this.baseUrl});
@GET('/posts/{id}')
external Future<UserInfoDto> getUserInfo(int id);
@GET('/convert')
external Future<SomeEntity> convert(@query String from, @query String to);
}
и получим следующий код:
augment library 'package:test_macros/5.%20retrofit/example.dart';
import 'dart:async' as prefix0;
import 'package:test_macros/5.%20retrofit/example.dart' as prefix1;
import 'dart:core' as prefix2;
import 'package:dio/src/options.dart' as prefix3;
import 'package:dio/dio.dart';
import 'dart:core';
augment class Client {
final String? baseUrl;
final Dio dio;
augment prefix0.Future<prefix1.PostEntity> getPostById(prefix2.int id, ) async {
final _queryParameters = <prefix2.String, prefix2.dynamic>{
};
final _result = await dio.fetch<prefix2.Map<prefix2.String, prefix2.dynamic>>(
prefix3.Options(
method: "GET",
)
.compose(
dio.options,
"/posts/${id}",
queryParameters: _queryParameters,
)
.copyWith(baseUrl: baseUrl ?? dio.options.baseUrl));
final value = prefix1.PostEntity.fromJson(_result.data!);
return value;
}
augment prefix0.Future<prefix2.List<prefix1.PostEntity>> getPostsByUserId(prefix2.int user_id, ) async {
final _queryParameters = <prefix2.String, prefix2.dynamic>{
'user_id': user_id,
};
final _result = await dio.fetch<prefix2.List<prefix2.dynamic>>(
prefix3.Options(
method: "GET",
)
.compose(
dio.options,
"/posts",
queryParameters: _queryParameters,
)
.copyWith(baseUrl: baseUrl ?? dio.options.baseUrl));
final value = (_result.data as prefix2.List).map((e) => prefix1.PostEntity.fromJson(e)).toList();
return value;
}
}
На самом деле, это вершина айсберга — с полной версией так называемого macrofit можно познакомиться на pub.dev. Этот пакет находится в стадии разработки, но с его помощью можно делать GET, POST, PUT и DELETE запросы и работать с query-, path- и part-параметрами, задавать заголовки и тело запроса. Однако работать это начнёт только после того, как будет исправлена эта проблема.
Что же касается нашего маленького примера — макросы идеально подходят для таких задач, как генерация сетевых запросов. А уж если объединить это с @JsonCodable
и @DataClass
, то мы получаем полностью автоматизированный процесс создания сетевых запросов. И всё, что от нас требуется, — это написать каркас класса и добавить аннотации.
Выводы
Несмотря на все возможности, макросы не позволяют генерировать код столь же свободно, как это позволяет code_builder. У них есть ограничения, некоторые из которых мы обсудили в этой статье.
Но даже с учётом этого, макросы — это гигантский шаг вперёд для Dart. Их появление коренным образом изменит подход к написанию кода и позволит автоматизировать многие рутинные задачи. При этом они таят в себе опасность — код, обильно сдобренный макросами, будет сложно читать. А возможность сайд-эффектов, причина которых будет неочевидна, существенно возрастёт. Но если использовать этот инструмент с умом, его польза значительно превысит возможные недостатки.
При этом у нас сформировалось стойкое ощущение, что макросы ещё слишком сыры и нестабильны для релиза в начале 2025 года. Хочется верить, что мы ошибаемся.
Все примеры из первой и второй частей этой статьи вы найдёте в репозитории.
Больше полезного про Flutter — в Telegram-канале Surf Flutter Team.
Кейсы, лучшие практики, новости и вакансии в команду Flutter Surf в одном месте. Присоединяйтесь!