Генерация исходного кода во Flutter & Dart (часть 1): Отражение и генерирование кода

Если вы работали над проектами Flutter (или вы Java-разработчик), вы можете быть знакомы с библиотекой Mockito.

Mockito позволяет разработчикам создавать объекты-макеты из существующих классов, заглушки их методов и проверять или утверждать их поведение во время тестирования.

Если вы использовали Mockito для проектов Flutter или Dart, то в их документации вы найдете следующее:

Чтобы использовать сгенерированные Mockito mock-классы, добавьте зависимость build_runner в файл pubspec.yaml вашего пакета в разделе dev_dependencies; что-то вроде build_runner: ^1.11.0.

В некоторых примерах кода будет показано, как высмеять существующий класс, например Cat, следующим образом:

@GenerateMocks([ Cat ])
Войти в полноэкранный режим Выйти из полноэкранного режима

Затем ожидается, что вы выполните следующую команду:

flutter pub run build_runner build
Войти в полноэкранный режим Выйти из полноэкранного режима

Команда build_runner build создает файл под названием *.mocks.dart. Что именно здесь происходит?

Отражение (и его отсутствие)

FAQ по Flutter содержит следующий вопрос:

***Поставляется ли Flutter с системой отражения/зеркал? ***Нет. Dart включает dart:mirrors, который обеспечивает отражение типов. Но поскольку приложения Flutter предварительно компилируются для производства, а размер двоичных файлов всегда является проблемой для мобильных приложений, эта библиотека недоступна для приложений Flutter. Используя статический анализ, мы можем удалить все, что не используется («tree shaking»)… Эта гарантия надежна только в том случае, если Dart может определить путь кода во время компиляции. На сегодняшний день мы нашли другие подходы для конкретных нужд, которые предлагают лучший компромисс, например, генерацию кода.

Согласно Википедии, отражение — это:

В информатике рефлексивное программирование или рефлексия — это способность процесса исследовать, анализировать и изменять свою собственную структуру и поведение.

В других языках, таких как Java, библиотеки типа Mockito используют отражение для получения информации во время выполнения о классах, которые они модифицируют. Например, функция Java может получить все доступные методы класса с помощью следующего кода:

Class c = Class.forName("java.lang.String");
Method m[] = c.getDeclaredMethods();
Войти в полноэкранный режим Выйти из полноэкранного режима

В этом примере функция получает массив, содержащий все функции, которые являются частью класса java.lang.String.

Мы видим, как это может быть полезно для таких библиотек, как Mockito: она может найти все методы заданного класса и обеспечить реализацию заглушек на основе их типов возврата и параметров. И все это во время выполнения.

Обычный подход заключается в том, чтобы пометить классы аннотациями, затем найти эти классы с помощью рефлексии и что-то с ними сделать:

  • Создать имитационные реализации.
  • Инстанцировать синглтоны для класса.
  • Создавать и внедрять экземпляры других классов в конструктор (как это делает фреймворк Spring).
  • Украшать классы для обеспечения такого поведения, как сериализация JSON или представление строк, и так далее.

Хорошим примером использования отражения является фрагмент Java, который мы включили в предыдущую статью «Легкий параллелизм и многопоточность с помощью CompletableFuture от Java» (обновлено, чтобы удалить общие классы для ясности):

private static <Book> List<Book> parseJSON(String textResponse) {
    final ObjectMapper objectMapper = new ObjectMapper();
    List<T> objects = new ArrayList<>();
    try {
      objects =
        objectMapper.readValue(textResponse, new TypeReference<List<Book>>() {});
    } catch (JsonMappingException e) {
      // TODO: Do something with the error
    } catch (JsonProcessingException e) {
      // TODO: Do something with the error
      e.printStackTrace();
    }
    return objects;
  }
Вход в полноэкранный режим Выход из полноэкранного режима

В этом примере кода Jackson mapper использует отражение, чтобы узнать тип класса, который он должен использовать для де-сериализации строки JSON:

List<T> objects = objectMapper.readValue(textResponse, new TypeReference<List<Book>>() {});
Войти в полноэкранный режим Выход из полноэкранного режима

Параметр new TypeReference<List<Book>>() {} создает ссылку на класс, которая позволяет Jackson’sObjectMapper знать, что он должен разобрать JSON и создать экземплярList<Book>.

Однако мы не можем выполнить подобную де-сериализацию JSON во Flutter. Без рефлексии нам нужно рассмотреть другие подходы для решения этих же задач. Например, пакет json_serializable использует генерацию исходного кода для оформления классов с методами для сериализации и де-сериализации строк JSON.

Генерация исходного кода

Когда у нас нет доступа к метаданным о классах во время выполнения, одной из альтернатив является интроспекция во время компиляции.

В то время как Flutter ограничивает отражение во время выполнения, такие инструменты, как source_get, полагаются на два низкоуровневых пакета Dart:

  • build: «Определяет основные части того, как происходит сборка и как они взаимодействуют».
  • analyzer: «Этот пакет предоставляет библиотеку, выполняющую статический анализ кода Dart. Она полезна для интеграции и встраивания инструментов.»

Build предоставляет интерфейс для реализации билдеров: классов, которые обеспечивают точку входа в процесс компиляции, позволяя нам создавать файлы в результате нашего исходного кода.

Пакет analyzer предоставляет инструменты для получения информации о наших классах: Методы и переменные, которые они содержат, аннотации, которые они используют, среди прочего.

Инструмент build_runner (который упоминается в документации Mockito) использует эти два низкоуровневых пакета для генерации исходного кода.

Высокоуровневый процесс: json_serializable

На высоком уровне пакет json_serializable выполняет следующие действия:

  • Поручить разработчикам аннотировать классы с помощью @JsonSerializable. Аннотированный класс должен соответствовать набору рекомендаций, чтобы процесс работал правильно.
  • Пусть разработчики запустят инструмент build_runner для генерации кода, который позволит этим классам сериализовать и де-сериализовать строки JSON.

На более низком уровне процесс генерации исходного кода происходит следующим образом:

  • Разработчики выполняют инструмент build_runner и, в свою очередь, build_runner выполняет классы построителя json_serializable.
    • Строитель использует build для определения того, какие файлы он будет читать из исходного кода, и какие файлы он будет генерировать.
    • Строитель использует analyzer для поиска всех классов, аннотированных @JsonSerializable.
    • Конструктор использует analyzer, чтобы найти все атрибуты и конструкторы для сериализуемых классов.
    • Конструктор создает классы Dart для сериализации и десериализации строк JSON в атрибуты, найденные с помощью analyzer.
    • Конструктор очищает и помещает эти новые классы в тот же пакет, что и аннотированные классы, в виде файлов .g.dart.
    • Эти файлы .g.dart являются частями, что позволяет разделить класс на несколько файлов.

Следуя примеру из документации json_serializable, мы аннотируем следующий класс, содержащийся в example.dart:

@JsonSerializable()
class Person {
  /// The generated code assumes these values exist in JSON.
  final String firstName, lastName;

  /// The generated code below handles if the corresponding JSON value doesn't
  /// exist or is empty.
  final DateTime? dateOfBirth;

  Person({required this.firstName, required this.lastName, this.dateOfBirth});

  /// Connect the generated [_$PersonFromJson] function to the `fromJson`
  /// factory.
  factory Person.fromJson(Map<String, dynamic> json) => _$PersonFromJson(json);

  /// Connect the generated [_$PersonToJson] function to the `toJson` method.
  Map<String, dynamic> toJson() => _$PersonToJson(this);
}
Вход в полноэкранный режим Выход из полноэкранного режима

После запуска build_runner создается следующий файл example.g.dart:

part of 'example.dart';

Person _$PersonFromJson(Map<String, dynamic> json) => Person(
      firstName: json['firstName'] as String,
      lastName: json['lastName'] as String,
      dateOfBirth: json['dateOfBirth'] == null
          ? null
          : DateTime.parse(json['dateOfBirth'] as String),
    );

Map<String, dynamic> _$PersonToJson(Person instance) => <String, dynamic>{
      'firstName': instance.firstName,
      'lastName': instance.lastName,
      'dateOfBirth': instance.dateOfBirth?.toIso8601String(),
    };
Вход в полноэкранный режим Выход из полноэкранного режима

Все, что нам нужно сделать, это импортировать example.g.dart в example.dart и мы получим доступ к приватным функциям _$PersonFromJson и _$PersonToJson.

Преимущества и недостатки

Отсутствие отражения заставляет разработчиков четко формулировать типы и случаи использования, которые им необходимо поддерживать. Тем не менее, мы все еще можем полагаться на дженерики для создания многократно используемого кода.

Генерация исходного кода уменьшает количество ручной работы, которую разработчикам приходится выполнять для повторяющихся задач. Без source_gen и json_serializable нам пришлось бы вручную создавать функции toJson и fromJson, перечисляющие каждый атрибут для каждого класса. Для этого есть альтернативные варианты, использующие дженерики и наследование. Однако это все равно требует значительного объема ручной работы.

Хотя генерация исходного кода сокращает эту ручную работу, она вносит дополнительный уровень сложности в наш проект: Теперь у нас есть большой кусок исходного кода, который не будет доступен для проверки, пока мы не запустим инструмент build. Мы всегда можем проверить сгенерированный код в системе контроля версий, что поможет лучше отслеживать изменения, сделанные при повторном запуске инструмента build.

Но главным недостатком является дополнительная сложность. Генераторы исходного кода зависят от наличия в коде очень специфических условий (например, наличие открытых конструкторов, соблюдение соглашений об именах), которые могут привести к сбою процесса сборки, если они не соблюдены. Легко обнаружить, что мы тратим часы своего рабочего дня только на то, чтобы выяснить, что сборщик был настроен неправильно.

Что дальше

В следующей статье блога мы создадим простой плагин для сборки исходного кода для проектов Flutter. Мы начнем углубляться в процесс сборки и в то, как создавать многократно используемые модули, которые могут быть импортированы другими проектами, подобно тому, как это делают json_serializable или mockito.

Оцените статью
devanswers.ru
Добавить комментарий