Otro artículo sobre macros. Parte 2 / Habr

Continuamos con el artículo anterior, así que sin mucho preámbulo, vayamos a los ejemplos.

Desechar automáticamente

¿Para qué?

Es más conveniente “colgar” una anotación en un campo que debe “desactivarse” al eliminar un objeto, que hacerlo manualmente y bajar al método. dispose.

¿Cómo debería verse?

Definamos las entidades a las que queremos aplicar la macro; estas son entidades que tienen:

  • método dispose;

  • método close (Por ejemplo, StreamController);

  • método cancel (Por ejemplo, StreamSubscription).

  • método alternativo de “apagado”.

¿Qué pasa si aplicamos una macro a un campo que no tiene método? dispose/close/cancel? Nada crítico, solo el analizador nos dirá que, sorpresa, el campo no tiene un método de eliminación.

@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();
	}
}

¿Cómo implementar esto?

Comencemos con lo más simple: crear anotaciones. disposable, cancelable, closable y 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);
}

Es hora de crear una macro. Como en casos anteriores, seleccione la fase macro:

  • la fase de tipos no nos conviene: no vamos a crear nuevos tipos;

  • la fase de declaración nos permite agregar código dentro de la clase; eso es lo que queremos;

  • Es como si no necesitáramos la fase de definición, porque podemos hacer todo lo necesario en la fase de anuncio.

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

Recopilemos un diccionario, donde la clave es el nombre del campo y el valor es el nombre del método que debe llamarse:

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

Es sólo una cuestión de pequeñas cosas: recopilar el código del método. dispose y agregarlo a la clase:

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

¡Parecería una victoria! Pero ahora miramos el código generado y vemos la siguiente imagen:

augment library 'package:test_macros/3.%20auto_dispose/example.dart';

augment class SomeModel {
	void dispose() {
		a.dispose();
		b.close();
		c.cancel();
		d.'customDispose'();
	}
}

De nuevo un cuchillo en la espalda de ExpressionCode. Sólo podemos obtener el código de la expresión, pero no su valor. Y dado que el código de expresión contiene un valor de cadena (entre comillas), no podemos usarlo como nombre de método.

Estamos buscando soluciones. Podríamos permitir que los usuarios implementen sus propias anotaciones. Pero entonces la macro debe conocer los nombres de las nuevas anotaciones para que las tenga en cuenta durante la generación del método. dispose. Además, debe conocer los nombres de los métodos que deben llamarse.

Entonces, la única opción que se nos ocurrió es pasar un diccionario a la macro, donde la clave es el nombre de la anotación y el valor es el nombre del método:

@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() {}
}

Tiene una pinta terrible, sí. Pero no se nos ocurrió nada mejor.

Hagamos cambios en la macro; primero recopilaremos un diccionario de todas las anotaciones y métodos posibles:

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

Ya no necesitamos buscar un método personalizado, al igual que cambiar; ahora será una búsqueda por clave en el diccionario:

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

El resto del código permanece sin cambios. Comprobamos el código generado y, finalmente, vemos lo atesorado:

augment library 'package:test_macros/3.%20auto_dispose/example.dart';

augment class SomeModel {
	void dispose() {
		a.dispose();
		b.close();
		c.cancel();
		d.customDispose();
	}
}

Intentamos lanzar el proyecto y una vez más nos clavan un cuchillo en la espalda:

Error: This macro application didn't apply correctly due to an unhandled map entry.

Aunque nuestro parámetro de entrada respuestas requisitos de la especificación (este es un diccionario con tipos de datos primitivos), la macro no puede procesarlo. Podemos pasar parámetros como una cadena (por ejemplo, 'customDepDispose: customDispose'), pero esto es inconveniente e ilegible.

Además de esto, nuestro ejemplo tiene un problema más: no admite llamar a un método base (no augment) clase. Por ejemplos oficiales puedes llamar al método augmented() adentro augment-método, pero en la práctica obtenemos un error, como si dicho método no existiera.

Resultado

Recibimos una macro que funcionará con entidades predefinidas. Sin embargo, para trabajar con los personalizados, se requiere una configuración adicional que, debido a las restricciones macro actuales, solo se puede realizar con muletas. Pero hemos recuperado la fe en las anotaciones después de su fracaso en el primer experimento.

contenedor DI

¿Para qué?

Un contenedor DI típico en una aplicación Flutter se ve así:

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

Sería genial tener en su lugar un contenedor que:

  • le permite especificar dependencias directamente en el inicializador;

  • admite inicialización asincrónica;

  • protegido de dependencias cíclicas;

  • Parece conciso.

¿Cómo debería verse?

Algo como esto:

@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();
  }
}

¿Cómo implementar esto?

Dividamos la tarea en subtareas:

  • selección de fase y creación de anotaciones;

  • creando una clase Registry;

  • creando un método init;

  • construcción de la orden de inicialización;

  • Creación late final campos.

Seleccionar una fase y crear una anotación

Seleccionemos la fase de declaración: necesitamos agregar código dentro de la clase. Creemos una anotación. DiContainer:

macro class DiContainer implements ClassDeclarationsMacro {
  const DiContainer();
  @override
  FutureOr<void> buildDeclarationsForClass(
    ClassDeclaration clazz,
    MemberDeclarationBuilder builder,
  ) async {}
}

Creando la clase de registro

Creemos una clase Registry:

class Registry<T> {
  final FutureOr<T> Function() create;

  Registry(this.create);

  FutureOr<T> call() => create();
}

Creando el método de inicio

Aquí todo es simple: usamos el viejo 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));
  }

Construcción de la orden de inicialización.

Y aquí comienza la parte más interesante y difícil. Nuestro objetivo es determinar el orden en el que se inicializan los campos. Para esto necesitamos:

  • recopilar una lista de dependencias para cada campo;

  • defina el orden de inicialización para que las dependencias se inicialicen antes que los campos que dependen de ellas.

En primer lugar, armemos un diccionario: la clave será el nombre del campo con la dependencia y el valor será la lista de parámetros necesarios para inicializarlo. Convencionalmente, para nuestro ejemplo el diccionario será así:

{
  someDependency: (),
  anotherDependency: (someDependency),
  thirdDependency: (someDependency, anotherDependency),
}

Hagamos esto:

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

Ahora definamos el orden de inicialización. Para hacer esto, utilizamos clasificación topológica. Ya tenemos la gráfica, solo queda implementar el algoritmo en sí:

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

Ahora que tenemos el orden de llamada, podemos completar la función. 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));
  }

Creando campos finales tardíos

Finalmente, creamos los últimos campos finales. Desafortunadamente, Registry utiliza un genérico de un tipo específico. Debido a esto, no podemos acceder directamente a la interfaz de la clase detrás de la cual queremos ocultar la implementación. Por tanto, cogemos la primera de las interfaces disponibles (si la hay):

 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('_', '')};',
++        ),
++      ),
++    );
++  }

Resultado

Aplicar una macro a una clase 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();
}

y obtenemos:

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

Intentemos agregar IAnotherDependency como parámetro de dependencia SomeDependency:

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

Y obtenemos un error:

Resultado

Hay muchos lugares “delicados” en esta implementación. Por ejemplo, estamos comprometidos con el hecho de que el usuario debe configurar los inicializadores para que sean estrictamente privados. Tampoco podemos establecer los nombres de los campos públicos (incluso usando anotaciones, ya que los parámetros que se les pasen estarán disponibles para nosotros solo como ExpressionCode).

Tampoco podemos especificar explícitamente la interfaz bajo la cual nos gustaría ver el campo público. En teoría, por supuesto, puedes agregar un segundo genérico a Registrypero esto nos privará de brevedad.

A pesar de todo esto, recibimos un prototipo funcional de un contenedor DI que puede desarrollarse y mejorarse aún más.

Actualización de macros

¿Para qué?

La versión clásica de retrofit para Dart funciona con build_runner. Parece un objetivo potencial para pasar a las macros.

¿Cómo debería verse?

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

Por ahora nos limitaremos a solicitudes GET, parámetros de consulta y ruta.

¿Cómo implementar esto?

Según los clásicos, comencemos creando anotaciones:

const query = Query();

class Query {
  const Query();
}

Habrá dos macros:

La fase de macro más apropiada para el cliente es la fase de declaración: necesitamos agregar dos campos a la clase.

Creemos una macro y escribamos los nombres de los campos de clase en constantes:

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\';'));
  }
}

Obtengamos una lista de campos, asegúrese de que falten los campos que vamos a crear y, de ser así, créelos:

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

Ahora echemos un vistazo al método. Para macros GET Pasemos a la fase de definición: necesitamos escribir una implementación de un método ya declarado. También agregamos una fase de declaración para agregar importaciones. Nos harán la vida más fácil y eliminarán la necesidad de importar un montón de tipos manualmente.

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

Nos enfrentamos a varios desafíos:

  • definir el tipo de retorno del valor para implementar el análisis;

  • recopilar parámetros de consulta;

  • sustituir los parámetros en la ruta, si los hay;

  • recogerlo todo.

Necesitamos definir el tipo de devolución. Se supone que debemos aplicarle el método. fromJsonpara analizar la respuesta del servidor. Vale la pena considerar los casos en los que intentamos obtener una colección (List) o no obtenemos ningún valor (void). Creemos una enumeración para los tipos de valores de retorno:

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

Ahora puede definir el tipo de retorno (es decir, obtener el genérico de Future o 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;
    }
  }

Ahora recopilemos los parámetros de consulta en un diccionario del formulario:

final _queryParameters = <String, dynamic>{  
  'from': from,
  'to': to,
};

Para hacer esto, recopilemos todos los campos (con nombre y posicionales) y tomemos aquellos que tienen una anotación. @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();

Agreguemos al número de nuestras constantes el nombre de la variable para los parámetros de consulta:

 const baseUrlVarSignature="baseUrl";
    const dioVarSignature="dio";
++  const queryVarSignature="_queryParameters";

Ahora, si tenemos parámetros de consulta, agreguémoslos al diccionario:

queryParamsCreationCode.addAll((
      '\t\tfinal $queryVarSignature = <String, dynamic>{\n',
      ...queryParams.map((e) => "\t\t\t'${e.name}': ${e.name},\n"),
      '\t\t};\n',
    ));

Echemos un vistazo a la ruta de la solicitud: sustituyamos los parámetros de la ruta en ella.

Por ejemplo, si tenemos un camino /posts/{id}entonces deberíamos obtener la cadena '/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}}';
    });

Es hora de recoger la solicitud. No olvide que podemos obtener no solo un valor único, sino también una colección. Bueno, o nada. Es importante tener esto en cuenta al utilizar el método. fetch y al analizar la respuesta:

 builder.augment(FunctionBodyCode.fromParts((
      'async {\n',
      ...queryParamsCreationCode,
      '\t\tfinal _result  = await $dioVarSignature.fetch<',
      switch (returnType) {
        ReturnType.none => 'void',
        ReturnType.single => 'Map<String, dynamic>',
        ReturnType.collection => 'List<dynamic>',  
      },'>(\n',
      '\t\t\tOptions(\n',
		  '\t\t\t\tmethod: "GET",\n',
		  '\t\t\t)\n',
		  '\t\t.compose(\n',
		  '\t\t\t	$dioVarSignature.options,\n',
		  '\t\t\t	"$substitutedPath",\n',
		  '\t\t\t	queryParameters: $queryVarSignature,\n',
		  '\t\t)\n',
      '\t\t.copyWith(baseUrl: $baseUrlVarSignature ?? $dioVarSignature.options.baseUrl));\n',
		  ...switch (returnType) {
        ReturnType.none => (''),
        ReturnType.single => ('\t\tfinal value=", valueType.code, ".fromJson(_result.data!);\n'),
        ReturnType.collection => (
          '\t\tfinal value = (_result.data as List).map((e) => ', valueType.code, '.fromJson(e)).toList();\n',
          ),
      },
      if (returnType != ReturnType.none) '\t\treturn value;\n',
      '\t}',
    )));

Para comprobar el resultado usaremos JSONMarcador de posición – API gratuita para probar solicitudes 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: '

  const idOfExistingPost = 1;

  final post = await client.getPostById(idOfExistingPost);
  
  final userId = post.userId;

  if (userId != null) {
    final posts = await client.getPostsByUserId(userId);
    print(posts);
  }
}

Lanzamos y vemos lo siguiente:

 Error: 'String' isn't a type.
  Error: 'int' isn't a type.
  Error: 'dynamic' isn't a type.
  ...

Este es otro descubierto mientras escribía este artículo. problema macro. Debido al hecho de que hay importaciones idénticas en un archivo (con y sin prefijo), el proyecto no puede iniciarse:

import "dart:core";
import "dart:core" as prefix01;

Por lo tanto, tendremos que reescribir casi todo el código de macro para usar solo tipos con prefijo.

Estos tipos los obtendremos mediante el método resolveIdentifierque toma una uri de biblioteca y un nombre de tipo, y también está marcado como deprecated incluso antes del lanzamiento:

 @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, '>');
    ...
  }

Ahora necesitamos reemplazar todas las ocurrencias. String, dynamic, Map, Options y List a los tipos “permitidos” que recibimos:

 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',
    ));

Y continuamos con este espíritu en todos los demás lugares (_dio.fetch<Map<String, dynamic>>, Options etcétera).

Ahora admiremos el resultado.

Resultado

Aplicar una macro a una clase 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;
	}
}

De hecho, esto es la punta del iceberg: la versión completa del llamado macrofit se puede encontrar en pub.dev. Este paquete está en desarrollo, pero con su ayuda puede realizar solicitudes GET, POST, PUT y DELETE y trabajar con parámetros de consulta, ruta y parte, establecer encabezados y cuerpos de solicitudes. Sin embargo, esto sólo comenzará a funcionar después de que se haya corregido. problemas.

En cuanto a nuestro pequeño ejemplo, las macros son ideales para tareas como generar solicitudes de red. Y si combinas esto con @JsonCodable y @DataClassluego obtenemos un proceso totalmente automatizado para crear solicitudes de red. Y todo lo que se requiere de nosotros es escribir un esqueleto de clase y agregar anotaciones.

Conclusiones

A pesar de todas las posibilidades, las macros no te permiten generar código con tanta libertad como puedas. constructor_código. Tienen limitaciones, algunas de las cuales analizamos en este artículo.

Aun así, las macros son un gran paso adelante para Dart. Su aparición cambiará radicalmente la forma de escribir código y permitirá la automatización de muchas tareas rutinarias. Al mismo tiempo, están llenos de peligros: el código rico en macros será difícil de leer. Y la posibilidad de efectos secundarios, cuya causa no será obvia, aumentará significativamente. Pero si utiliza esta herramienta con prudencia, sus beneficios superarán significativamente las posibles desventajas.

Al mismo tiempo, tenemos la fuerte sensación de que las macros todavía están demasiado crudas e inestables para publicarse a principios de 2025. Quiero creer que estamos equivocados.

Todos los ejemplos de la primera y segunda parte de este artículo. lo encontrarás en el repositorio.

Más información útil Aleteo — en el canal de Telegram del equipo Surf Flutter.

Casos, mejores prácticas, noticias y vacantes para el equipo de Flutter Surf en un solo lugar. Únase a nosotros!

Publicaciones Similares

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *