Ещё одна статья про макросы. Часть 2

от автора

Продолжаем предыдущую статью — так что без долгих предисловий идём к примерам. 

Авто-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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *