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

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

Авто-dispose

Зачем?

Удобнее «повесить» аннотацию на поле, которое нужно «выключить» при удалении объекта, чем делать это вручную и спускаться в метод dispose.

Как это должно выглядеть?

Определим сущности, к которым хотим применить макрос — это сущности, у которых есть:

  • метод dispose;

  • метод close (например, StreamController);

  • метод cancel (например, StreamSubscription).

  • альтернативный метод «выключения».

Что будет, если мы применим макрос к полю, у которого нет метода dispose/close/cancel? Ничего критичного, просто анализатор сообщит нам, что, сюрприз, метода dispose у поля нет.

@AutoDispose()
class SomeModel {
  @disposable
  final ValueNotifier a;
  @closable
  final StreamController b;
  @cancelable
  final StreamSubscription 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 buildDeclarationsForClass(ClassDeclaration clazz, MemberDeclarationBuilder builder) async {
    final fields = await builder.fieldsOf(clazz);
  }
}

Соберём словарь, где ключом будет имя поля, а значением — имя метода, который надо вызвать:

final fields = await builder.fieldsOf(clazz);

    /// Ключ - имя поля, значение - имя метода для вызова.
    final disposables = {};

    for (final field in fields) {
      Object? methodName;

      final annotations = field.metadata;

      /// Ищем аннотацию Disposable с кастомным именем метода.
      final customDispose = annotations.whereType().firstWhereOrNull(
            (element) => element.type.identifier.name == customDisposableAnnotationName,
          );

      if (customDispose != null) {
        methodName = customDispose.namedArguments[customDisposableFieldName];
      } else {
        /// Если аннотация не найдена, ищем стандартные аннотации.
        /// 
        /// - отсеиваем константные аннотации;
        /// - ищем аннотации, которые содержат нужные нам идентификаторы.
        /// - сопоставляем идентификаторы с методами.
        methodName = switch ((annotations.whereType().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 = [
      '\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 a;
  @closable
  final StreamController b;
  @cancelable
  final StreamSubscription 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 disposeMethodNames;
  const AutoDispose({
    this.disposeMethodNames = const {},
  });

  @override
  FutureOr 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().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 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 = Registry(() {
    return SomeDependency();
  });
  late final Registry _anotherDependency = Registry(() {
    return AnotherDependency(someDependency);
  });
  late final Registry _thirdDependency = Registry(() {
    return ThirdDependency(someDependency, anotherDependency);
  });
}

augment class AppScope {
  late final ISomeDependency someDependency;
  late final IAnotherDependency anotherDependency;
  late final IThirdDependency thirdDependency;

  Future 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 buildDeclarationsForClass(
    ClassDeclaration clazz,
    MemberDeclarationBuilder builder,
  ) async {}
}

Создание класса Registry

Создадим класс Registry:

class Registry {
  final FutureOr Function() create;

  Registry(this.create);

  FutureOr call() => create();
}

Создание метода init

Тут всё просто — используем старый-добрый builder.declareInType:

@override
  FutureOr buildDeclarationsForClass(
    ClassDeclaration clazz,
    MemberDeclarationBuilder builder,
  ) async {
    final initMethodParts = [
      'Future init() async {\n',
    ];

    initMethodParts.add('}');

    builder.declareInType(DeclarationCode.fromParts(initMethodParts));
  }

Построение порядка инициализации

А здесь начинается самое интересное и сложное. Мы стремимся определить порядок инициализации полей. Для этого нам нужно:

  • собрать список зависимостей для каждого поля;

  • определить порядок инициализации таким образом, чтобы зависимости инициализировались раньше зависимых от них полей.

В первую очередь соберём словарь: ключом будет название поля с зависимостью, а значением — список параметров, которые требуются для её инициализации. Условно, для нашего примера словарь будет таким:

{
  someDependency: [],
  anotherDependency: [someDependency],
  thirdDependency: [someDependency, anotherDependency],
}

Сделаем это:

final dependencyToConstructorParams = >{};

    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 _topologicalSort(
    Map> graph,
    MemberDeclarationBuilder builder,
  ) {
    /// Обработанные вершины.
    final visited = {};

    /// Вершины, в которых мы находимся на текущий момент.
    final current = {};

    /// Вершины, записанные в топологическом порядке.
    final result = [];

    /// Рекурсивная функция обхода графа.
    /// Возвращает [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 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 = Registry(() {
    return SomeDependency();
  });
  late final Registry _anotherDependency = Registry(() {
    return AnotherDependency(someDependency);
  });
  late final Registry _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 init() async {
		someDependency = await _someDependency();
		anotherDependency = await _anotherDependency();
		thirdDependency = await _thirdDependency();
	}
}

Попробуем добавить IAnotherDependency как параметр для зависимости SomeDependency:

@DiContainer()
class AppScope {
  late final Registry _someDependency = Registry(() {
    return SomeDependency(anotherDependency);
  });
  ...
}

И получим ошибку:

4ca7f9de3f47b2b0f387101a9b8ad62b.jpeg

Результат

В этой реализации много «тонких» мест. Например, мы завязаны на том, что пользователь должен задавать инициализаторы строго приватными. А ещё мы не можем задавать имена публичных полей (даже с использованием аннотаций, так как переданные в них параметры будут доступны нам только как ExpressionCode). 

Не можем и в явном виде указывать интерфейс, под которым хотели бы видеть публичное поле. В теории, конечно, можно добавить второй дженерик к Registry, но это лишит нас лаконичности. 

Несмотря на всё это, мы получили работающий прототип DI-контейнера, который можно дорабатывать и улучшать.

Retrofit на макросах

Зачем?

Классическая версия retrofit для Dart работает с помощью build_runner. Похоже на потенциальную цель, чтобы перенести её на макросы.

Как это должно выглядеть?

@RestClient()
class Client {
  Client(
    this.dio, {
    this.baseUrl,
  });

  @GET('/posts/{id}')
  external Future getUserInfo(int id);

  @GET('/convert')
  external Future convert(@Query() String from, @Query() String to);
}

augment class Client {
  final Dio dio;
  final String? baseUrl;

  augment Future getUserInfo(int id) async {
		final queryParameters = {};
		final _result  = await dio.fetch>(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 convert(String from, String to) async {
		final queryParameters = {
			'from': from,
			'to': to,
		};
		final _result  = await dio.fetch>(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();
}

Тут будет два макроса:

Наиболее подходящая фаза макроса для клиента — фаза объявления: нам нужно добавить два поля в класс.

Создадим макрос и запишем название полей класса в константы:

import 'dart:async';

import 'package:macros/macros.dart';

const baseUrlVarSignature = 'baseUrl';
const dioVarSignature = 'dio';

macro class RestClient implements ClassDeclarationsMacro {
  @override
  FutureOr 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 buildDeclarationsForMethod(MethodDeclaration method, MemberDeclarationBuilder builder) async {
    builder.declareInLibrary(DeclarationCode.fromString('import \'dart:core\';'));
  }

  @override
  FutureOr buildDefinitionForMethod(MethodDeclaration method, FunctionDefinitionBuilder builder) async {
    
  }
}

Перед нами стоит несколько задач:

  • определить возвращаемый тип значения, чтобы реализовать парсинг;

  • собрать query-параметры;

  • подставить параметры в path, если они есть;

  • собрать это всё.

Нам нужно определить возвращаемый тип. Предполагается, что мы применим к нему метод fromJson, чтобы спарсить ответ сервера. Стоит учесть кейсы, когда мы пытаемся получить коллекцию (List) или не получаем никакого значения (void). Заведём enum для типов возвращаемых значений:

/// Общий тип, который возвращает метод:
/// - коллекция
/// - одно значение
/// - ничего
enum ReturnType { collection, single, none }

Теперь можно определять возвращаемый тип (то есть, достать дженерик из Future либо Future):

 @override
  FutureOr buildDefinitionForMethod(MethodDeclaration method, FunctionDefinitionBuilder builder) async {
  /// Здесь у нас будет что-то вроде `Future`.
    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 = {  
  'from': from,
  'to': to,
};

Для этого соберём все поля (именованные и позиционные) и возьмём те, у которых есть аннотация @query:

/// Сюда будем собирать код для создания query параметров.
    final queryParamsCreationCode = [];

    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 = {\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',
        ReturnType.collection => 'List',  
      },'>(\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 getPostById(int id);

  @GET('/posts')
  external Future> 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 json) {
    return PostEntity(
      userId: json['userId'],
      id: json['id'],
      title: json['title'],
      body: json['body'],
    );
  }

  Map toJson() {
    return {
      'userId': userId,
      'id': id,
      'title': title,
      'body': body,
    };
  }
}

Future 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 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);

    /// Шорткат для ``.
    final stringDynamicMapType = ['<', stringType, ', ', dynamicType, '>'];
    ...
  }

Теперь нам следует заменить все вхождения String, dynamic, Map, Options и List на полученные нами «разрешённые» типы:

 queryParamsCreationCode.addAll([
--    '\t\tfinal $queryVarSignature = {\n',
++    '\t\tfinal $queryVarSignature = ', ...stringDynamicMapType, '{\n',
      ...queryParams.map((e) => "\t\t\t'${e.name}': ${e.name},\n"),
      '\t\t};\n',
    ]);

И в таком духе продолжаем во всех остальных местах (_dio.fetch>, Options и так далее).

Теперь любуемся на результат.

Результат

Применим макрос к классу Client:

@RestClient()
class Client {
  Client(this.dio, {this.baseUrl});

  @GET('/posts/{id}')
  external Future getUserInfo(int id);

  @GET('/convert')
  external Future 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 getPostById(prefix2.int id, ) async {
		final _queryParameters = {
		};
		final _result  = await dio.fetch>(
			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> getPostsByUserId(prefix2.int user_id, ) async {
		final _queryParameters = {
			'user_id': user_id,
		};
		final _result  = await dio.fetch>(
			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 в одном месте. Присоединяйтесь!

© Habrahabr.ru