В первой части статьи мы рассмотрели основные и порождающие шаблоны и особенности их реализации на Dart. Теперь пришло время поговорить о структурных шаблонах и о их реализации с использованием синтаксических конструкций языка Dart и возможностей стандартной библиотеки. Мы будем стараться исключать возможности, предоставляемые такими сущностями языка как символы и рефлексия (пакет dart:mirrors), поскольку они не поддерживаются во Flutter (но обозначим возможные их применения при использовании Dart для бэкэнда), предпочитая использовать кодогенерацию во всех ситуациях, где это возможно.
Прежде всего обозначим назначение структурных и поведенческих шаблонов. Первые используются для расширения интерфейсов существующих классов и могут применяться в ситуациях, когда требуется улучшить функциональность класса, который недоступен для расширения или модификации, а также организовать взаимодействие с классами с различным интерфейсом через использование унифицированного прокси-интерфейса. Второй тип шаблонов определяет часто используемые способы объединения классов с точки зрения передачи потоков управления или данных.
Значимую роль в Dart для реализации структурных шаблонов выполняют механизмы рефлексии и кодогенерации. Рефлексия (пакет dart:mirrors) недоступна при использовании фреймворка Flutter (во многом это связано с оптимизацией runtime-библиотеки), но может использоваться при создании консольных утилит, а также разработки на Dart для бэкэнда или web. Рефлексия позволяет выполнить два класса задач, с помощью которых можно динамически изменять поведения классов и анализировать их структуру:
исследование (introspection)
- пакет представляет набор *Mirror-классов для описания классов, методов и их аргументов, переменных и других сущностей. Информация о классах может быть получена как на этапе компиляции черезcurrentMirrorSystem()
, так и во время выполнения кода черезreflectClass
иreflectType
. В объектеClassMirror
(возвращается первым методом) представлены методы для определения модификаторов класса, объявленных переменных, конструкторов, методов и др. Также во всех Mirror-объектах есть ссылка (.source) на исходный текст программы, где определяется рефлексируемая сущность.вызов (invocation)
- обращение к методу объекта класса через выполнение invoke вObjectMirror
. В метод могут быть переданы позиционные и именованные аргументы, результатом выполнения являетсяInstanceMirror
, из которого можно извлечь возвращенное значение.
Ярким примером использования рефлексии в Dart является библиотека Conduit, которая использует анализ структуры классов для реализации ORM. Также рефлексия может использоваться для "обертывания" класса в дополнительную функциональность (например, для замера времени выполнения каждого метода) без необходимости наследования класса и создания реализаций всех методов вручную.
Альтернативой рефлексии в Dart может считаться кодогенерация. Она не является в полном смысле слова аналогом, поскольку не позволяет выполнять анализ структуры объектов во время выполнения кода, но все же дает возможность расширения функциональности классов через автоматическую генерацию необходимого кода и включения его в исходный класс (или замены его реализации). Кодогенерация в Dart основана на обработке абстрактного синтаксического дерева (AST) с созданием нового дерева, из которого в дальнейшем формируется корректный исходный код. Сгенерированные файлы могут располагаться как в отдельном каталоге (так, например, делает встроенная во Flutter кодогенерация делегатов локализации через flutter gen-l10n) или записываться рядом с исходным файлом (по умолчанию при использовании пакета build_runner) и подключаться через механизм part (некоторый аналог #include в C для включения внешнего файла с фрагментом кода в существующий исходный код на Dart).
В действительности генерацией кода занимается пакет build
(классы Builder и BuildStep), который может читать и записывать новые файлы (не обязательно исходные тексты). Поскольку кодогенерация подразумевает необходимость разбора существующего исходного кода и создание нового, представлен также дополнительный класс Resolver, который взаимодействует с пакетом analyzer для исследования исходного кода (экспортирует классы Visitor
и Element
для итеративного анализа исходных текстов). Для упрощения реализации можно использовать пакет source_gen, который создает API для разбора и создания нового AST, а также можно посмотреть на пакет code_builder, который представляет код в виде коллекции объектов, которые представляют собой сущности языка и позволяет сформировать на основе описания синтаксический корректный текст на языке Dart. Кодогенерация может использовать как для расширения возможностей существующих классов (в этом случае сгенерированный код может подключаться как mixin или вызываться как объект приватного класса), например пакет to_string для создания преобразования объекта в строку или freezed для создания неизменяемых классов.
Build использует конфигурацию из файла build.yaml (в корне проекта), в которой описываются именованные builder, которые определяются через название функции, создающей объект класса Builder (builder_factories), расширения исходных и сгенерированных файлов (builder_extensions), а также позволяет определить цепочку из действий через связывание в apply_builders. Builder может запускаться как на этапе компиляции (ключ builders), так и после ее завершения (post_process_builder). Для создания Builder часто используется SharedPartBuilder для размещения сгенерированных классов в одном файле (часто .g.dart), а сама реализация класса генератора наследуется от GenerateForAnnotation<T>, из методов которого возвращается текст (должен быть корректным dart-кодом), который добавляется в сгенерированный файл при обнаружении аннотации. Для разбора исходного текста можно создать класс-расширение от SimpleElementVisitor<void> и реализовать методы, которые будут вызываться при обнаружении конструкторов, полей или методов (visitConstructorElement, visitFieldElement, visitMethodElement, а также функций, параметров, импортов, инструкций part и других, более подробно можно посмотреть в документации). Объект класса ElementVisitor вызывается из метода разбора генератора (element.visitChildren). В дальнейшем мы будем обращаться к возможностям кодогенерации при рассмотрении шаблонов проектирования, а сейчас рассмотрим простой пример создания миксина и вспомогательного класса для автоматического преобразования класса данных в строку (реализация метода toString).
Создадим проект samplecodegen и добавим необходимые зависимости в pubspec.yaml:
dependencies:
build:
source_gen:
code_builder:
dev_dependencies:
lints: ^2.0.0
test: ^1.16.0
build_runner:
build_test:
Определим класс аннотации (обязательно нужен константный конструктор или переменная с экземпляром класса аннотации):
class ToString {
const ToString();
}
final toString = ToString();
Создадим тестовый класс:
import 'annotations.dart'; //здесь определена аннотация
part 'test.g.dart'; //генерируемый файл
@ToString()
class MyDataClass {
String field1;
int field2;
//конструктор по умолчанию (нужен для определения названия класса, хотя можно и извлечь из factory)
MyDataClass(this.field1, this.field2);
//создание экземпляра вспомогательного класса (который будет включать mixin)
factory MyDataClass.withString(field1, field2) => _$MyDataClassWithString(field1, field2);
}
void main() {
print(MyDataClass.withString('Value1', 42));
}
Сгенерированный файл должен выглядеть следующим образом:
mixin _$MyDataClass on MyDataClass {
@override
String toString() {
return "{field1: $field1,field2: $field2}";
}
}
class _$MyDataClassWithString extends MyDataClass with _$MyDataClass {
_$MyDataClassWithString(String field1, int field2) : super(field1, field2);
}
Определим файл конфигурации build (build.yaml):
targets:
$default:
builders:
samplecodegen|generators:
enabled: true
builders:
generators:
target: ":samplecodegen"
import: "package:samplecodegen/generator.dart"
builder_factories: ["generateToStringExtension"]
build_extensions: { ".dart": [".g.dart"] }
auto_apply: dependents
build_to: cache
applies_builders: ["source_gen|combining_builder"]
Здесь мы указываем название функции, которая создает Builder (должна быть определена в импортированном файле generator.dart), определяем тип файлов с исходным текстом (не обязательно .dart, может например разбирать csv или файлы изображений) и результатом (.g.dart). Поскольку .g.dart может собирать фрагменты из разных builder, используем также combining_builder.
Создадим Visitor для анализа исходного текста:
class ModelVisitor extends SimpleElementVisitor<void> {
String? className;
Map<String, String> elements = {};
@override
void visitConstructorElement(ConstructorElement element) {
if (element.isFactory) return; //пропускаем factory
className = element.displayName; //сохраняем название класса
}
@override
void visitFieldElement(FieldElement element) {
elements[element.name] = element.type.toString(); //сохраняем название и тип поля
}
}
И определим функциональность Builder для аннотации ToString (будем использовать возможности пакета code_builder для создания сгенерированного файла, но также можно его создать и обычной строкой):
import 'package:build/build.dart';
import 'package:code_builder/code_builder.dart';
import 'package:samplecodegen/annotations.dart';
import 'package:dart_style/dart_style.dart';
import 'package:source_gen/source_gen.dart';
import 'package:built_collection/built_collection.dart';
Builder generateToStringExtension(BuilderOptions options) =>
SharedPartBuilder([ToStringBuilder()], 'toString');
class ToStringBuilder extends GeneratorForAnnotation<ToString> {
@override
generateForAnnotatedElement(
Element element, ConstantReader annotation, BuildStep buildStep) {
//разбираем исходный текст
final visitor = ModelVisitor();
element.visitChildren(visitor);
if (visitor.className != null) {
//получаем код для toString (строкой)
final elementsPair = visitor.elements.keys.map((e) => '$e: \$$e');
final values = elementsPair.join(",");
final body = Code('return "{$values}";');
//создаем элементы - mixin, class
final mixinName = r'_$' + visitor.className!;
final supplClassName = r'_$' + visitor.className! + 'WithString';
//параметры для конструктора вспомогательного класса
final parameters = visitor.elements.entries.map((e) => (ParameterBuilder()
..named = false
..name = e.key
..type = refer(e.value))
.build());
//конструктор вспомогательного класса
final supplClassConstructor = Constructor((c) => c
..requiredParameters.addAll(parameters)
..initializers = ListBuilder(
[Code("""super(${visitor.elements.keys.join(',')})""")]));
//вспомогательный класс (расширение исходного класса + миксин)
final supplClass = Class((c) => c
..name = supplClassName
..extend = refer(visitor.className!)
..mixins.add(refer(mixinName))
..constructors.add(supplClassConstructor));
//метод toString
final method = Method((m) => m
..name = 'toString'
..returns = refer('String')
..annotations.add(refer('override'))
..body = body);
//определение миксина (с методом)
final mixin = Mixin((m) => m
..name = mixinName
..on = refer('${visitor.className}')
..methods.add(method));
//создаем сгенерированный код
final emitter = DartEmitter();
final formatter = DartFormatter();
return formatter.format(emitter.visitMixin(mixin).toString() +
emitter.visitClass(supplClass).toString());
}
return '// no default constructor is found';
}
}
Теперь запустим зарегистрированный builder и получим сгенерированный код:
dart pub run build_runner build
Мы рассмотрели основы кодогенерации в Dart и теперь можем перейти к шаблонам проектирования и возможным использованиям кодогенерации для их реализации. Мы будем только описывать общий алгоритм для создания генератора без подробностей, поскольку схема будет полностью аналогично рассмотренной выше.
Структурные шаблоны
Адаптер (Adapter)
Используется в ситуации, когда объект не предоставляет необходимый интерфейс, но должен взаимодействовать с другим объектом, который ожидает реализацию интерфейса. Например, в нашем коде есть общий интерфейс для всех систем отправки оповещений (содержит в себе два метода isAvailable и send) и необходимо подключить к системе оповещений новый класс (отправка sms), который реализует только метод deliver. Адаптер позволит обернуть второй класс и выполнить согласование интерфейса:
abstract class Notification {
bool isAvailable();
void send(String message);
}
class EMail implements Notification {
@override
bool isAvailable() => true;
@override
void send(String message) => print('Send with email $message');
}
class Sms {
void deliver(String text) => print('Send with sms');
}
//адаптер для смс
class SmsAdapter implements Notification {
Sms? _sms;
SmsAdapter(this._sms);
@override
bool isAvailable() => _sms!=null;
@override
void send(String message) => _sms?.deliver(message);
}
class Notifications {
List<Notification> _handlers = [];
void registerHandler(Notification n) => _handlers.add(n);
void unregisterHandler(Notification n) => _handlers.remove(n);
void send(String message) {
for (final handler in _handlers) {
if (handler.isAvailable()) handler.send(message);
}
}
}
void main() {
final notification = Notifications()..registerHandler(EMail())..registerHandler(SmsAdapter(Sms()));
notification.send('Hello World!');
}
Мост (Bridge)
Данный шаблон определяет общую идею возможности замены интерфейса вызова и реализации и в целом похожа на адаптер, но позволяет выполнить независимое изменение как со стороны интерфейса вызова (в нашем случае - метод send), так и со стороны интерфейса реализации (Notification). Для реализации паттерна "мост" нужно добавить еще один интерфейс и реализовать его в Notifications:
abstract class NotificationProcessorInterface {
void send(String message);
}
class Notifications implements NotificationProcessorInterface {
Notification _handler;
Notifications(this._handler);
void send(String message) => _handler.send(message);
}
void main() {
NotificationProcessorInterface notification = Notifications(EMail());
notification.send('Hello World!');
}
Компоновщик (Composite)
Шаблон используется при необходимости использования объекта как контейнера для объектов такого же типа. Наиболее часто этот шаблон встречается при иерархическом хранении, где каждый объект одновременно выступает в роли контейнера (например, это реализовано в игровом движке Flame, где каждый объект является наследником Component и может содержать в себе другие компоненты). Реализуется через внутренние коллекции в экземпляре класса и методы для управления ими (add / remove / clear и другие):
abstract class Item {
abstract String name;
void describe(int shift);
}
class SimpleItem implements Item {
@override
String name;
SimpleItem(this.name);
@override
void describe(int shift) {
print(name.padLeft(name.length + shift, '-'));
}
}
class ItemContainer implements Item {
@override
String name;
ItemContainer(this.name);
final _items = [];
void addItem(Item item) => _items.add(item);
void removeItem(Item item) => _items.remove(item);
void clear() => _items.clear();
@override
void describe(int shift) {
print(name.padLeft(name.length + shift, '-'));
for (final item in _items) {
item.describe(shift + 2);
}
}
}
void main() {
final boxes = ItemContainer('Products');
final red = ItemContainer('Red');
red.addItem(SimpleItem('Apple'));
red.addItem(SimpleItem('Tomato'));
boxes.addItem(red);
final yellow = ItemContainer('Yellow');
yellow.addItem(SimpleItem('Yellow'));
yellow.addItem(SimpleItem('Banana'));
boxes.addItem(yellow);
boxes.describe(0);
}
Декоратор (Decorator)
Используется в случае, когда надо расширить функциональность другого класса (например, добавить измерение времени выполнения методов или сделать логирование вызова метода) с сохранением или расширением исходного интерфейса. Реализация здесь может быть выполнена как вручную (через обертывание вызовов методов связанного объекта или суперкласса), так и с использование кодогенерации. Рассмотрим сначала ручную реализацию:
abstract class Animal {
void talk();
}
class Cat implements Animal {
@override
void talk() => print('Meow');
}
class LoggingAnimal implements Animal {
Animal _animal;
int _calls = 0;
LoggingAnimal(this._animal);
@override
void talk() {
_calls++;
print('Talk is started');
_animal.talk();
print('Talk is finished');
}
int get calls => _calls;
}
void main() {
LoggingAnimal animal = LoggingAnimal(Cat());
animal.talk();
print('Calls: ${animal.calls}');
}
При автоматической генерации необходимо создать новый класс (реализующий тот же интерфейс, как и исходный класс), повторить сигнатуры методов (использовать visitMethodElement для сохранения информации об определенных методах) и определить в них необходимую логику вокруг вызова исходного объекта. При необходимости нужно также сохранить результат вызова метода и вернуть его в последней строке кода.
Фасад (Facade)
Этот шаблон подразумевает сокрытие деталей одного или нескольких сложных интерфейсов и объединение их в общем интерфейсе. Например, у нас может быть представлен интерфейс для отправки оповещений и воспроизведения звуков и мы бы хотели объединить их в едином интерфейсе с методом notify.
class Notifier {
void send() => print('Send notification');
}
class Sound {
void play() => print('Playing sound');
}
class NotificationFacade {
final _notifier = Notifier();
final _sound = Sound();
void notify() {
print('Notification is processing');
_notifier.send();
_sound.play();
}
}
void main() {
final facade = NotificationFacade();
facade.notify();
}
Единая точка входа (Front Controller)
Этот шаблон является разновидностью фасада и позволяет создать промежуточный класс для единого доступа к большому количеству объектов. Одна из возможных реализаций - создание единого метода для переадресации вызовов на методы объектов, созданных внутри фронт-контроллера.
class Notifier {
void send() => print('Send notification');
}
class Sound {
void play() => print('Playing sound');
}
enum NotificationType {
message,
sound,
}
class NotificationFrontController {
final _notifier = Notifier();
final _sound = Sound();
void notify(NotificationType type) {
switch (type) {
case NotificationType.message:
_notifier.send();
break;
case NotificationType.sound:
_sound.play();
break;
}
}
}
void main() {
final facade = NotificationFrontController();
facade.notify(NotificationType.message);
facade.notify(NotificationType.sound);
}
Приспособленец (Flyweight)
Используется в ситуациях, когда часть состояния у разных объектов может повторяться (и переиспользоваться), а часть должна быть изменяемый. Например, при описании форматирования текста в статье, эти буквы имеют одинаковое оформление, но разное содержание и позицию. Для реализации можно использовать два связанных объекта, один из которых будет использоваться повторно (например, можно использовать factory и пул объектов), а второй определять уникальные свойства каждой буквы.
class FontStyle {
static Map<int, FontStyle> pool = {};
final int _size;
FontStyle._(this._size);
factory FontStyle(int size) {
if (pool.containsKey(size)) return pool[size]!;
pool[size] = FontStyle._(size);
return pool[size]!;
}
}
class Letter {
int position;
String char;
FontStyle _fontStyle;
Letter(this.position, this.char, int size): _fontStyle = FontStyle(size);
get style => _fontStyle;
}
void main() {
final a = Letter(0, 'a', 12);
final b = Letter(0, 'b', 12);
assert(a.style==b.style);
assert(a.char!=b.char);
}
Прокси (Proxy)
Паттерн очень похож на декоратор с единственной разницей, что интерфейс остается неизменным и прокси только выполняет дополнительные действия. Ручная реализация и кодогенерация аналогичны декоратору, за исключение того, что в прокси интерфейс в точности совпадает с интерфейсом исходного объекта.
Мы рассмотрели структурные паттерны, примеры их реализации на Dart и использование кодогенерации для автоматической генерации кода вспомогательных классов и миксинов. В третьей части статьи мы рассмотрим поведенческие шаблоны.
В преддверии старта нового потока курса "Архитектура и шаблоны проектирования" хочу пригласить всех желающих на бесплатный урок по теме: "Обработка исключений и SOLID".