Продолжаем предыдущую статью — так что без долгих предисловий идём к примерам.
Авто-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\tqueryParameters: $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 в одном месте. Присоединяйтесь!
ссылка на оригинал статьи https://habr.com/ru/articles/844640/
Добавить комментарий