Патчим freezed

от автора

freezed — один из популярнейших пакетов для генерации дата‑классов и перечислений в Dart. Но, к сожалению, он пока не генерирует дружественные классы‑патчи, чтобы можно было легко и быстро пропатчить дата‑класс в рантайме. Исправим же это!

Меня зовут Алексей Букин, я Flutter‑разработчик во FRESH. Давайте посмотрим, как сделать свой кодогенератор для Dart и подружить его с другими генераторами на примере.

Реальный кейс — локализация

Допустим, у нас есть подобный код для локализации с использованием freezed:

import 'package:freezed_annotation/freezed_annotation.dart';  part 'locale.freezed.dart'; part 'locale.g.dart';  @freezed class Locale with _$Locale {   const factory Locale({     required String cardTitle,     required String cardSubtitle,     required String cardButtonText,     // ...   }) = _Locale;    const Locale._();    factory Locale.fromJson(Map<String, dynamic> json) =>     _$LocaleFromJson(json); } 

Здесь по аннотации @freezed генерируются два файла: locale.freezed.dart и locale.g.dart. Первый позволяет нам создать класс из конструктора, а второй из JSON.

Казалось бы, живи и радуйся, но из‑за типа required String мы получаем двоякую ситуацию. С одной стороны, мы в любом месте в коде можем использовать любое поле из класса. С другой, если хочется иметь удалённую версию локализации для исправлений ошибок, то придется отдавать JSON со всеми полями сразу, пропусти одно поле и конструктор fromJson упадёт с ошибкой.

Но ведь можно разбить такой дата‑класс на множество малых, с ними будет удобнее работать!

И всё равно придётся очень аккуратно следить за каждым! Одна ошибка — и не применится вся локализация. Более того, мы ограничены выбором одной из локализаций — с устройства либо с сервера.

Пишем патч руками

Так как мы хотим иметь устойчивую систему и потенциально несколько источников данных напишем патч-класс:

import 'package:freezed_annotation/freezed_annotation.dart';  part 'locale_patch.freezed.dart'; part 'locale_patch.g.dart';  @freezed class LocalePatch with _$LocalePatch {   const factory LocalePatch({     String? cardTitle,     String? cardSubtitle,     String? cardButtonText,     // ...   }) = _LocalePatch;    const LocalePatch._();    factory LocalePatch.fromJson(Map<String, dynamic> json) =>     _$LocaleFromJson(json); }

Теперь, благодаря nullable типу String? мы можем создать такой класс гарантированно из любого JSON! Теперь нужно всего лишь создать новую локализацию с применённым патчем:

// Внутри Locale Locale patch(LocalePatch patch) => copyWith(   cardTitle: patch.cardTitle ?? cardTitle,   cardSubtitle: patch.cardSubtitle ?? cardSubtitle,   cardButtonText: patch.cardButtonText ?? cardButtonText,   // ... );

Теперь чтобы добавить один параметр нам нужно:

  • добавить его в конструктор класса Locale

  • добавить его в конструктор класса LocalePatch

  • запустить кодогенерацию

  • обновить функцию patch в классе Locale

А если мы хотим создать отдельную локализацию, например, для нового экрана в приложении и там 10 текстовок? 100? 1000?

Пишем кодогенератор

Погодите, всё, чем пользуется freezed есть в конструкторе класса и нам тоже хватит этих данных. Классы Locale и LocalePatch отличаются только типами, а функция patch оперирует только полями этих классов!

Начнём с пакета с аннотацией — locale_gen_annotation. В единственном файле пакета объявляем аннотацию:

class LocaleGen {}  // для красоты const localeGen = LocaleGen();

Эта аннотация будет работать так же, как и freezed :

@LocaleGen() class TestLocale {}  // или  @localeGen class TestLocale2 {}

Теперь интереснее, основной пакет locale_generator. В нём надо импортировать пакеты: наш пакет locale_gen_annotation, analyzer, build и source_gen. Уделить внимание нужно лишь трём файлам:

  • build.yaml

  • lib/locale_generator.dart

  • lib/src/locale_generator.dart

Начнём в обратном порядке, сначала код генератора
(lib/src/locale_generator.dart):

import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/nullability_suffix.dart'; import 'package:build/build.dart'; import 'package:locale_gen_annotation/locale_gen_annotation.dart'; import 'package:source_gen/source_gen.dart';  class LocaleGenerator extends GeneratorForAnnotation<LocaleGen> {   @override   String? generateForAnnotatedElement(     Element element,     ConstantReader annotation,     BuildStep buildStep,   ) {     final definitions = element.children.where((c) =>         c.kind == ElementKind.CONSTRUCTOR &&         c is ConstructorElement &&         c.isConst);          if (definitions.length != 1) {       return null;     }          final constructor = definitions.first;      final patchContents = constructor.children.map((element) {       if (element.kind == ElementKind.PARAMETER &&           element is ParameterElement) {         final suffix =             element.type.nullabilitySuffix == NullabilitySuffix.question                 ? ''                 : '?';         return '${element.type}$suffix ${element.name},';       }       return '';     }).join('\n');      final originalFile = element.librarySource!.shortName;     final filenameBase = originalFile.substring(0, originalFile.length - 5);      final copyWithEntries = constructor.children.map((element) {       if (element.kind == ElementKind.PARAMETER &&           element is ParameterElement) {         return '${element.name}: patch.${element.name} ?? ${element.name},';       }       return '';     }).join('\n');          return '''     import 'package:freezed_annotation/freezed_annotation.dart';          import '$originalFile';      part '$filenameBase.lg.freezed.dart';     part '$filenameBase.lg.g.dart';      @freezed     class ${element.name}Patch with _\$${element.name}Patch {       const factory ${element.name}Patch({         $patchContents       }) = _${element.name}Patch;              factory ${element.name}Patch.fromJson(Map<String, dynamic> json) => _\$${element.name}PatchFromJson(json);     }      extension ${element.name}PatchExtension on ${element.name} {         ${element.name} patch(${element.name}Patch patch) => copyWith(           $copyWithEntries         );     }     ''';   } }

Расширяем класс GeneratorForAnnotation с типом нашей аннотации в виде дженерика и переопределяем функцию generateForAnnotatedElement. Из трёх аргументов здесь нам понадобится лишь один — element. Это то, к чему прикреплена аннотация, в данном случае — класс локализации.

На выходе у нас получится единственный файл, содержащий класс-патч и расширение для оригинального класса с функцией patch.

Сначала находим конструктор и убеждаемся что он один. Далее сохраняем его в переменную constructor и теперь нам доступны параметры конструктора как constructor.children.

Подставляем имя класса в текст как ${element.name}. Переменные originalFile и filenameBase вычисляются тривиально.

Для patchContents убеждаемся, что не сделаем тип nullable второй раз nullable c помощью строчки

// Чтобы не было `String??` и подобных типов final suffix =   element.type.nullabilitySuffix == NullabilitySuffix.question   ? ''   : '?';

Для copyWithEntries даже этого делать не надо. В обоих случаях соотносим параметры конструктора в строчки сгенерированного файла с помощью map и подставляем как $patchContents и $copyWithEntries.

Теперь надо заставить этот генератор сделать тыр тыр тыр запуститься. Для этого сначала объявим builder. Это тот самый кирпичик, который встроится в общую систему генерации Dart
(lib/locale_generator.dart):

import 'package:build/build.dart'; import 'package:source_gen/source_gen.dart';  import 'src/locale_generator.dart';  Builder localeGenerator(BuilderOptions options) => LibraryBuilder(       LocaleGenerator(),       generatedExtension: '.lg.dart',     );

Осталось только описать наш генератор в декларативном виде в файле build.yaml:

builders:   # Название генератора   locale_generator:     # Абсолютный путь с файлом билдера в Dart проекте     import: "package:locale_generator/locale_generator.dart"     # Название функции-билдера     builder_factories: ["localeGenerator"]     # Описание маппинга файлов-источников и файлов-артефактов     build_extensions: {".dart": [".lg.dart"]}     # Условие активности - при данном варианте достаточно     # будет импортировать пакет locale_generator     auto_apply: dependents     # Артефакты положить в кеш или в исходный код -      build_to: source

Та‑да! Наш генератор готов и можно попробовать его в действии.

Проверяем

В нашем изначальном проекте указываем целевые файлы для генерации и последовательность вызова генераторов. Так как наш пакет сгенерирует патч, который потом надо сгенерировать еще раз с помощью freezed, надо строго прогонять наш генератор ДО freezed. И то и другое достигается внесением соответствующих строчек в файл build.yaml:

# Что еще умеет `build.yaml` можно  # почитать тут - https://pub.dev/packages/build_config  global_options:   locale_generator:locale_generator:     # Буквально - запускать до freezed     runs_before:       - freezed:freezed  targets:   $default:     builders:       # Из пакета locale_generator генератор locale_generator       # будет активен для всех файлов, заканчивающихся на `locale.dart`       locale_generator|locale_generator:         generate_for:           - lib/*/*locale.dart

И как в итоге выглядит код локализации:

import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:locale_gen_annotation/locale_gen_annotation.dart';  /// Экспортируем `.lg` файл для простоты использования метода `patch` export 'simple_page_locale.lg.dart';  part 'simple_page_locale.freezed.dart'; part 'simple_page_locale.g.dart';  /// Сразу проходимся двумя генераторами по одному классу @localeGen @freezed class SimplePageLocale with _$SimplePageLocale {   const factory SimplePageLocale({     required String title,     required String subtitle,   }) = _SimplePageLocale;    factory SimplePageLocale.fromJson(Map<String, dynamic> json) =>       _$SimplePageLocaleFromJson(json); }

Запускаем команду (или любую аналогичную, которая запустит процесс генерации):

dart run build_runner build --delete-conflicting-outputs

Получим пять новых файлов:

  • simple_page_locale.freezed.dart — содержит оригинальный конструктор и copyWith.

  • simple_page_locale.g.dart — позволит сериализовать локализацию, например, для логов

  • simple_page_locale.lg.dart — наш сгенерированный файл

  • simple_page_locale.lg.freezed.dart — содержит конструктор патча

  • simple_page_locale.lg.g.dart — позволит сериализовать патч, чтобы парсить его из JSON

И, наконец, момент ради которого мы тут собрались:

final defaultLocale = SimplePageLocale(   title: 'My title',   subtitle: 'My subtitle', );  final remotePatch = SimplePageLocalePatch.fromJson(json);  final locale = defaultLocale.patch(remotePatch);

Экосистема языка Dart позволяет очень просто писать, настраивать и запускать множество кодогенераторов. Вы не поверите, сколько рутинных задач станут проще, если уделить этой области немного времени! А данного примера вполне хватит для начала.

Код с примером на GITHUB. Пакет на pub.dev. Иллюстрация за авторством DALL·E. Спасибо за внимание.


Хотите купить или продать авто?  Ищите раздел на сайте FRESH >>>

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Поделитесь опытом кодогенерации в Dart

16.67% Не пользовался1
0% Запускал в образовательных целях0
16.67% Использую не больше одного генератора в проекте1
33.33% Активный пользоваетль, могу подружить несколько генераторов2
33.33% Автор собственных пакетов2

Проголосовали 6 пользователей. Воздержавшихся нет.

ссылка на оригинал статьи https://habr.com/ru/articles/851666/


Комментарии

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

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