Паттерны проектирования в Dart. Часть 3

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

Несколько слов о встроенных в Dart возможностях по передаче сообщений между различными компонентами системы. Все виды взаимодействий можно разделить на три типа:

  • использование потоков (Stream) для создание канала передачи данных между двумя частями системы или рассылки уведомлений об изменениях неограниченному количеству подписчиков;

  • передача данных между изолированными процессами с собственной памятью (изолятами), в этом случае используется встроенных механизм "портов", которые по смыслу близки к потокам, но при этом создают временные области памяти, разделяемые между несколькими изолятами (полезно для передачи больших объемов информации);

  • взаимодействие с нативным кодом (например через dart:ffi), эта схема подразумевает использование механизмов удаленного вызова и сериализации с использованием dart:typed_data.

В действительности фреймворк на Dart может создавать дополнительные абстракции (например, Flutter реализует механизм платформенных каналов для передачи сообщений, событий и вызова нативных методов из Dart-кода и наоборот). В конечном счете общая схема состоит в решении трех задач:

  • определить, какое действие должно быть выполнено и обнаружить код (нативный или Dart), который его будет исполнять;

  • выполнить преобразование представления данных для вызова метода (передать аргументы);

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

Фактически фреймворк выступает в роли медиатора, который скрывает от объектов необходимость прямого взаимодействия и позволяет опосредованно отправлять уведомления или вызывать действия, используя общую схему именования. Для реализации может использоваться любой доступный механизм (Stream, Port, FFI, Platform Channels), но прежде всего нужно определиться, какая логика взаимодействия объектов для нас является предпочтительной. Мы будем последовательно смотреть разные поведенческие шаблоны (с примерами кода на Dart) и определять задачи, в которых оптимально их применить.

Цепочка ответственности (Chain of responsibility)

По смыслу этот шаблон предполагает наличие общего интерфейса для обработки сообщений, при этом если ни один объект из зарегистрированных не может ответить на запрос, он делегирует выполнение объекту другого уровня. Типичная ситуация - реализация Middleware, когда каждый из них реализует общий интерфейс (например, метод processMessage) и передает управление следующему Middleware (вместе с объектом, в котором собирается ответ), если не произошло ошибки или не стал известен финальный результат, который может быть возвращен как ответ web-сервера. При этом Middleware может перейти на другой уровень и обратиться к методам других подсистем (например, запросить данные аутентификации) и по результату обработки трансформировать ответ.

При реализацию в функцию обработки сообщения передается ссылка на следующую функцию (или объект класса-реализации интерфейса) или маркер (например, null), если обработка должна быть завершена.

Рассмотрим несколько вариантов реализации паттерна на Dart. Поскольку функции в Dart могут быть переданы в аргументе другой функции (или возвращены как результат), то можно реализовать модель цепочки ответственности без использования классов. Но здесь возникнет проблема, поскольку определение типа (typedef) не может прямо или косвенно ссылаться на себя, поэтому такая сигнатура будет считаться некорректной:

typedef MiddlewareFunction = Function(
  CommandInfo command,
  Response response,
  Function? next,
);

typedef MiddlewareFunctionBuilder = MiddlewareFunction? Function(
  CommandInfo command,
  Response response,
);

Можно решить эту проблему через использование общего типа Function для вызова next, при этом в определении функций мы будем использовать тип последнего аргумента MiddlewareFunctionBuilder. Определим несколько типов данных и функции, которые будут выполнять роль исполнителей команды в цепочке:

enum ResponseStatus {
  notAuthenticated,
  ok,
}

class Response {
  String? result;
  ResponseStatus? status;
}

enum Command {
  authenticate,
}

class CommandInfo {
  Command? command;
  Object? info;

  CommandInfo(this.command, this.info);
}

MiddlewareFunction? Authenticate(
    CommandInfo command, Response response, MiddlewareFunctionBuilder? next) {
  if (command.command == Command.authenticate) {
    if (command.info != 'admin') {
      response.status = ResponseStatus.notAuthenticated;
      //передаем управление дальше, поскольку там может быть логирование
    } else {
      response.status = ResponseStatus.ok;
    }
  }
  return next?.call(command, response);
}

MiddlewareFunction? Logger(
    CommandInfo command, Response response, MiddlewareFunctionBuilder? next) {
  print('Action: ${command.command}. Response status: ${response.status}');
  return next?.call(command, response);
}

void main() {
  final command = CommandInfo(Command.authenticate, 'admin');
  final response = Response();
  Authenticate(
    command,
    response,
    (command, response) => Logger(
      command,
      response,
      (command, response) {
        print(response.status);
        return null;
      },
    ),
  );
}

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

abstract class Processor {
  void processMessage(CommandInfo command, Response response);
}

mixin Middleware {
  Middleware? next;

  void registerNext(Middleware? next) => this.next = next;
}

class Authenticate extends Processor with Middleware {
  @override
  void processMessage(CommandInfo command, Response response) {
    if (command.command == Command.authenticate) {
      if (command.info != 'admin') {
        response.status = ResponseStatus.notAuthenticated;
        //передаем управление дальше, поскольку там может быть логирование
      } else {
        response.status = ResponseStatus.ok;
      }
    }
    if (next is Processor) {
      (next as Processor).processMessage(command, response);
    }
  }
}

class Logger extends Processor with Middleware {
  @override
  void processMessage(CommandInfo command, Response response) {
    print('Action: ${command.command}. Response status: ${response.status}');
    if (next is Processor) {
      (next as Processor).processMessage(command, response);
    }
  }
}

void main() {
  final command = CommandInfo(Command.authenticate, 'admin');
  final response = Response();
  final authenticator = Authenticate();
  final logger = Logger();
  authenticator.registerNext(logger);
  authenticator.processMessage(command, response);
}

Команда (Command)

Этот шаблон проектирования наиболее актуален для реализации обработчиков входящих запросов (например, для веб-серверов), поскольку предполагает, что переданный в исполнитель объект описывает необходимое действие и информацию для его выполнения. В общем случае тип команды определяется перечисляемым объектом и мы можем использовать тот факт, что ключом в Map может быть любой объект, для которого можно определить хэш (в том числе, варианты значений в enum). Таким образом, реальные обработчики могут быть сохранены как Map<CommandType, Function> или Map<CommandType, ImplementationInterface>.

enum Command {
  turnOn,
  turnOff,
}

class LampInfo {
  final int id;

  const LampInfo(this.id);
}

class Action {
  Command command;
  LampInfo info;

  Action({
    required this.command,
    required this.info,
  });
}

typedef CommandImplementation = Function(LampInfo);

void turnOnLamp(LampInfo info) {
  print('Lamp is turned on ${info.id}');
}

void turnOffLamp(LampInfo info) {
  print('Lamp is turned off ${info.id}');
}

class SmartHome {
  final actions = <Command, CommandImplementation>{
    Command.turnOff: turnOffLamp,
    Command.turnOn: turnOnLamp,
  };

  void execute(Action action) {
    actions[action.command]?.call(action.info);
  }
}

void main() {
  final smartHome = SmartHome();
  smartHome.execute(
    Action(command: Command.turnOn, info: LampInfo(1)),
  );
  smartHome.execute(
    Action(command: Command.turnOff, info: LampInfo(1)),
  );
}

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

class SmartHome {

  final actions = <Symbol, CommandImplementation>{
    Symbol("turnOn"): turnOnLamp,
    Symbol("turnOff"): turnOffLamp,
  };

  @override
  void noSuchMethod(Invocation invocation) {
    actions[invocation.memberName]?.call(invocation.positionalArguments[0]);
  }
}

void main() {
  dynamic smartHome = SmartHome();
  smartHome.turnOn(LampInfo(1));
  smartHome.turnOff(LampInfo(1));
}

Интерпретатор (Interpreter)

Наиболее часто этот шаблон встречается в задачах разбора выражений по известной грамматике (например, разбор математических выражений или алгоритма, записанного в виде последовательности простых операций). Часто результатом работы интерпретатора становится абстрактное синтаксическое дерево (AST), которое является представлением исходного выражения, удобным для перемещения и исполнения. Интерпретатор последовательно разбирает элементы входной последовательности и разделяет их на терминальные (завершают дерево, например числа в математическом выражении) и нетерминальные (могут содержать вложенные деревья). Рассмотрим простейший пример разбора математического выражения без унарных символов. Встроенного типа для представления деревьев в Dart нет, но он легко может быть реализован. Для упрощения синтаксиса мы воспользуемся переопределением операторов.

enum Operation {
  plus,
  minus,
  unknown;

  factory Operation.fromString(String op) {
    switch (op) {
      case '+': return Operation.plus;
      case '-': return Operation.minus;
      default: return Operation.unknown;
    }
  }
}

class TreeNode<T> {
  TreeNode? left;
  TreeNode? right;
  T? value;
  Operation? op;

  TreeNode({this.value, this.op});


  @override
  String toString() {
    final buffer = StringBuffer();
    if (value!=null) {
      return value.toString();
    }
    if (left!=null) buffer.write(left.toString());
    if (op!=null) buffer.write(' $op ');
    if (right!=null) buffer.write(right.toString());
    return buffer.toString();
  }

  operator +(Object t) {
    right = (t is TreeNode) ? t : TreeNode(value: t);
  }

  operator -(Object t) {
    left = (t is TreeNode) ? t : TreeNode(value: t);
  }
}

class Interpreter {

  List<String> ops = ['+','-'];

  String expression = '';

  Interpreter(this.expression);

  TreeNode? prev;

  TreeNode? top;

  TreeNode interpret() {
    String number = '';
    for (final c in expression.split('')) {
      if (!ops.contains(c) || c=='\n') {
        number+=c;
      } else {
        top = TreeNode<int>(op: Operation.fromString(c));
        final num = (int.tryParse(number))!;
        number = '';
        if (prev!=null) {
          prev! + num;  //добавляем справа к предыдущему
          top! - prev!;
        } else {
          top! - num;
        }
        prev = top;
      }
    }
    final num = (int.tryParse(number))!;
    prev! + num;    //добавляем последнее число
    return top!;
  }
}

void main() {
  final interpreter = Interpreter('10+20+30-40\n');
  print(interpreter.interpret());
}

Итератор (Iterator)

Паттерн итератор используется в Dart повсеместно, поскольку по всем типам коллекций (записи Map, элементы Set, List) можно перемещаться последовательно через конструкцию for (... in ...). Традиционно класс итератор предоставляет минимум два метода: hasNext (проверка наличия оставшихся элементов в итераторе) и next (получение следующего непрочитанного элемента).

Наиболее важной особенностью языка Dart является возможность определения специальной функции (генератора), которая будет возвращать потенциально бесконечный итерируемый объект, как объект класса Iterable. Для определения таких функций после аргументов необходимо указать ключевое слово sync* и отправлять значения с использованием оператора yield. Также существует асинхронный вариант генератора (async*), в этом случае тип возвращаемого значения должен быть Stream<int>.

final primes = [];

bool checkPrime(int i) => !primes.any((element) => i % element==0);

Iterable<int> nextPrime() sync* {
  var num = 2;
  while (num<1000) {
    if (checkPrime(num)) yield num;
    num++;
  }
}

void main() {
  for (final x in nextPrime()) {
    primes.add(x);
  }
  print(primes);
}

Посредник (Mediator)

Паттерн предполагает, что объекты системы не взаимодействуют напрямую, а передают сообщения через посредника (медиатора). Например, подобную схему реализуют операционные системы, которые изолируют программы друг от друга, но позволяют обеспечить передачу сообщений через использование межпроцессных каналов (IPC). В Dart медиатор можно создать как через использование callback-функций, так и с помощью потоков, а также этот паттерн является типичным для взаимодействия изолятов (поскольку они непосредственно не могут взаимодействовать между собой).

Начнем с варианта использования изолята, для примера будем использовать предыдущий алгоритм для определения простых чисел до 1000. Для создания изолята можно использовать конструктор Isolate.spawn, он скрывает логику запуска функции через управляющий порт. Чтобы обмениваться информацией нужно передать в качестве входного параметра SendPort, который связывается внутри Dart VM с объектом ReceivePort (он будет выполняться на стороне изолята, создавшего новый изолят).

import 'dart:isolate';

void calcPrimes(SendPort port) {

  final primes = [];

  bool checkPrime(int i) => !primes.any((element) => i % element==0);

  Iterable<int> nextPrime() sync* {
    var num = 2;
    while (num<1000) {
      if (checkPrime(num)) yield num;
      num++;
    }
  }

  for (final prime in nextPrime()) {
    primes.add(prime);
    port.send(prime);		//отправляем следующее число
  }
}

void main() {
  final port = ReceivePort();		//через порт мы получим числа
  Isolate.spawn(calcPrimes, port.sendPort);	//функция запустится в изоляте
  port.forEach((element) => print(element));
}

Альтернативное решение - использование Stream (важное отличие от портов для изолятов в том, что процесс-отправитель и получатель потока должны быть запущены в одном изоляте). Создадим класс-посредник для передачи чисел подписчикам потока.

import 'dart:async';

class Mediator {
  final _streamController = StreamController<int>.broadcast();

  void subscribe(Function(int) subscriber) =>
      _streamController.stream.listen(subscriber);

  void broadcast(int number) => _streamController.add(number);
}

class CalcPrimes {
  CalcPrimes(this.mediator);

  Mediator mediator;

  final primes = [];

  bool checkPrime(int i) => !primes.any((element) => i % element == 0);

  Iterable<int> nextPrime() sync* {
    var num = 2;
    while (num < 1000) {
      if (checkPrime(num)) yield num;
      num++;
    }
  }

  void calc() {
    for (final prime in nextPrime()) {
      primes.add(prime);
      mediator.broadcast(prime);
    }
  }
}

class CalcConsumer {
  Mediator mediator;

  CalcConsumer(this.mediator);

  void subscribe() => mediator.subscribe((val) => print(val));
}

void main() {
  final mediator = Mediator();
  final consumer = CalcConsumer(mediator);
  final primes = CalcPrimes(mediator);
  consumer.subscribe();
  primes.calc();
}

Хранитель (Memento)

В некоторых случаях требуется сохранение внутреннего состояния объекта с его последующим восстановлением. Например, такая реализация может быть полезна при перезапуске мобильного приложения после аварийного завершения (или остановки операционной системой при оптимизации расхода энергии), в этом случае рационально вернуться на ту же страницу в навигации, которая была открыта последней перед завершением приложения. Единого механизма реализации для такого шаблона нет, как минимум нужно предусмотреть какое-либо долговременное хранилище (например, можно сохранить состояние в файл или в локальную базу данных) и общий объект для хранения состояния. Альтернативно можно использовать переопределение метода noSuchMethod (только в реализациях, где доступна рефлексия) для сохранения обычного способа доступа к свойствам, но с возможностью сохранения и восстановления (восстановить состояние можно через factory-метод).

import 'dart:io';

class Memento {

  Memento() {}

  Map<Symbol,String> state = {};

  void save() {
    //сохранение состояния
    final file = File('state.txt');
    final content = state.entries.map((e) => "${e.key.toString().split('"')[1]}:${e.value}").join("\n");
    file.writeAsBytesSync(content.codeUnits);
  }

  factory Memento.restore() {
    final content = String.fromCharCodes(File('state.txt').readAsBytesSync()).split("\n");
    final memento = Memento();
    for (final element in content) {
      final line = element.split(":");
      memento.state[Symbol(line[0])] = line[1];
    }
    return memento;
  }

  @override
  dynamic noSuchMethod(Invocation invocation) {
    if (invocation.isGetter) {
      return state[invocation.memberName];
    }
    if (invocation.isSetter) {
      //получим название символа без =
      final name = invocation.memberName.toString().split('"')[1];
      final prop = Symbol(name.split('=')[0]);
      state[prop] = invocation.positionalArguments.first;
    }
  }
}

void main() {
  dynamic memento = Memento();
  memento.hello = 'World';
  print(memento.hello);
  memento.save();

  dynamic restored = Memento.restore();
  print(restored.hello);
}

Null object

Этот паттерн используется при необходимости работы с null-объектами с целью подстановки значений по умолчанию вместо null. В Dart паттерн реализуется средствами языка через оператор ?? (могут быть объединены несколько операторов в цепочку, в этом случае обработка закончится на первом значении, отличном от null).

Издатель-подписчик (Publish-subscribe) или наблюдатель (Observer)

Очень частый паттерн в реальных приложениях (как в мобильных, так и в бэкэнде, когда нужно обеспечить реакцию на изменение источника данных). Реализуется преимущественно с использованием Stream и подписок (может быть множественной, если Stream был преобразован в broadcastStream). Примеры были рассмотрены выше при рассмотрении паттерна Mediator.

Состояние (State)

Очень частый паттерн для реактивных приложений, когда состояние интерфейса обновляется по результатам подписки на объект состояния. Один из возможных механизмов реализации представлен в пакете state_notifier, где объект состояния (может быть иммутабельным, например через freezed, или изменяться, но отправлять уведомления об изменении). Класс StateNotifier представляет свойства для определения текущего состояния и получения потока изменений, на который может быть подписан интерфейс или фоновый процесс.

Добавим в pubspec.yaml зависимость state_notifier, создадим класс состояния и StateNotifier, который будет модифицировать состояние через свойство state. Обновление state будет приводить к отправке нового состояния в поток, подписка на который создается из StateNotifier.

import 'package:state_notifier/state_notifier.dart';

class Counter {
  int counter;

  Counter(this.counter);
}

class CounterNotifier extends StateNotifier<Counter> {
  CounterNotifier() : super(Counter(0));

  increment() {
    state = Counter(state.counter+1);
  }
}

void main() {
  final notifier = CounterNotifier();
  final subscription = notifier.stream.listen((event) {
    print('New value is ${event.counter}');
    
  });
  notifier.increment();
  notifier.increment();
  notifier.increment();
  subscription.cancel();
}

Стратегия (Strategy)

Паттерн используется в ситуациях, когда одно и то же действие может быть выполнено различными реализациями, между которыми можно переключаться. Например, в приложении может быть доступно несколько реализаций алгоритма сортировки и класс-посредник может выбрать один из них по запросу клиента. Реализация может быть выполнена через использование Map с соотнесением стратегии и конкретного класса.

abstract class Algorithm {
  void sort();
}

enum SortAlgorithm {
  bubble,
  quick,
}

class BubbleSort implements Algorithm {
  @override
  void sort() => print('Bubble sort');
}

class QuickSort implements Algorithm {
  @override
  void sort() => print('Quick sort');
}

class Strategy {

  SortAlgorithm? algorithm;

  Map<SortAlgorithm, Algorithm> mapping = {
    SortAlgorithm.bubble: BubbleSort(),
    SortAlgorithm.quick: QuickSort(),
  };

  void setAlgorithm(SortAlgorithm algorithm) => this.algorithm = algorithm;

  void sort() => mapping[algorithm]?.sort();
}

void main() {
  final strategy = Strategy();
  strategy.setAlgorithm(SortAlgorithm.quick);
  strategy.sort();
  strategy.setAlgorithm(SortAlgorithm.bubble);
  strategy.sort();
}

Посетитель (Visitor)

Часто используемый шаблон в ситуациях работы с коллекцией объектов (списком, деревом или иной структурой). Паттерн подразумевает последовательное применение действия над всеми объектами коллекции. Например, он может использоваться для скрытия всех вложенных элементов или, например, для координированного обновления всех объектов сцены при анимациях. Для реализации используются методы работы с коллекциями:

class Widget {
  int id;

  Widget(this.id);

  void hide() {
    print('Hiding $id');
  }

  void show() {
    print('Showing $id');
  }
}

class Visitor {
  Visitor(this.widgets);

  List<Widget> widgets;

  void hideAll() => widgets.forEach((e) => e.hide());
}

void main() {
  final v = Visitor([
    Widget(1),
    Widget(2),
  ]);
  v.hideAll();
}

Мы рассмотрели основные шаблоны, которые могут быть полезны для проектирования сложных систем (ну, или по крайней мере, для обсуждения решений с командой на одном языке). Конечно же список шаблонов этим не ограничивается, например могут быть выделены типовые шаблоны для разработки многопоточных приложений (в Dart это не так актуально, поскольку приложения запускаются в одном потоке, а при использовании изолятов они не имеют разделяемой памяти и проблем, связанных с состоянием гонки из-за конкурентной модификации), но прежде всего хотелось обозначить те шаблоны, которые могут иметь интересные реализации в Dart (и фреймворках, работающих на его основе). Пишите, пожалуйста, в комментариях, какие шаблоны проектирования вам кажется интересным рассмотреть и про какие аспекты технологии Dart хотелось бы узнать подробнее.

Данная серия статей подготовлена в преддверии старта курса "Архитектура и шаблоны проектирования". Узнать подробнее о курсе и зарегистрироваться на бесплатный урок можно по ссылке ниже.

  • Зарегистрироваться на бесплатный урок

Источник: https://habr.com/ru/company/otus/blog/681954/


Интересные статьи

Интересные статьи

Работа с плохим кодом лично у меня вызывает отвращение и — по правде говоря — ещё и неприличные мысли в отношении того, кто его писал. Сейчас я уверен, что кроме программистов проблему плохого написан...
В предыдущей части были описаны подходы, примененные при написании парсера для схемы MTProto. Статья получилась чуть более общей, чем я рассчитывал, на этот раз я постараюсь рассказать бо...
Со времени публикации первой статьи об сконструированном мной девайсе ESPboy на микроконтроллере ESP8266 прошло довольно много времени. Однако за это время удалось кое что сделать: Дов...
Мы наблюдаем общество, которое все больше зависит от машин, но при этом использует их все неэффективнее. — Douglas Rushkoff Эта фраза должна служить мотивацией для каждого программиста. Ведь и...
Публикуется с разрешения автора. От переводчика Текст, перевод которого я намерен представить вашему вниманию, — краткая автобиография (называющаяся в оригинале «Mathematical Software and Me:...