Сколько раз вам приходилось запускать flutter create, затем удалять старый добрый «Counter App», добавлять правила линта в анализатор и настраивать структуру папок и файлов? Смею предположить, что это происходит довольно часто. А теперь представьте себе компанию с десятками коммерческих проектов и сотнями внутренних: стало страшно, не правда ли? Старт нового проекта для Flutter-разработчика — это неоптимизируемый процесс.

Если с проблемой инициализации вы не сталкивались, то, я точно уверен, у вас возникало желание сделать свой инструмент или, например, какой-нибудь pet-проект. Как вообще стоит подойти к разработке, какие подходы использовать и какие этапы необходимо пройти? 

Меня зовут Иван Таранов, я Flutter-разработчик в Surf. На примере нашего стартера расскажу, как сделать свой инструмент правильно. Советы в определённой мере универсальны для любого проекта.

Проектирование консольной утилиты

Цель — разработать консольную утилиту, которая будет взаимодействовать с пользователем и внешнем слоем — Git-репозиторием. Ключевое требование — максимальная устойчивость к изменениям. Этим изменениям необходимо следовать и максимально безболезненно обновлять нашу «CLI-тулзу». 

Задача по созданию проекта может показаться весьма тривиальной со стороны. Решение в виде скрипта уместится в один файл: зачем усложнять? На самом деле «скрипт» не продержался бы и несколько проектов: такой подход не эффективен при создании сложных, долгоиграющих систем.

Соответствие SOLID-принципам и чистой архитектуре позволяет создать не просто скрипт, а рабочий инструмент, который можно развивать и обновлять. Теперь давайте остановимся и разберём основные понятия, чтобы дальше общаться на одном языке.

SOLID

SOLID — аббревиатура пяти основных принципов проектирования в объектно-ориентированном программировании:

  • single responsibility — принципы единственной ответственности,

  • open-closed — открытости и закрытости,

  • Liskov substitution — подстановки Барбары Лисков,

  • interface segregation — разделения интерфейса,

  • dependency inversion — инверсии зависимостей.

Это означает, что все элементы программы:

  • Должны обладать единой ответственностью: один класс не может отвечать за принципиально разные вещи.

  • Быть открытыми для расширения, но не изменения.

  • Соблюдать иерархию: высшие уровни могут зависеть от низших, но не наоборот.

  • Иметь раздельные интерфейсы без строгой зависимости на частную реализацию.

Чистая архитектура

Чистая архитектура — понятие более высокоуровневое: его применяют на стратегическом уровне при составлении архитектуры проекта. Подразумевает независимость от фреймворков, интерфейсов и любых внешних агентов. Это значит, что программа должна быть разделена на логические, не тесно связанные слои: это позволяет изменять и дорабатывать ПО, не нарушая его работоспособность. 

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

Взаимодействие и связи инструмента 

Давайте схематично отобразим, с чем и посредством каких связей инструмент будет взаимодействовать.

Спецификации

Или же конфиг. В конфигурации задаём кастомные параметры, необходимые для проекта. Происходить это может посредством заполнения файла или в моменте интерактивного взаимодействия CLI и пользователя.

CLI-тулза

Запуск инструмента происходит, когда Flutter-разработчик создаёт проект. Главная задача скрипта — сбор конфигурации и создание проекта. «Скелет», благодаря которому происходит генерация, — это репозиторий на GitHub.

Шаблон проекта

Единый стандарт для проектов. Можно сказать, он служит точкой истины для хранения лучших практик, правил анализатора и пакетов, которые используем в разработке. 

Архитектура: связи между классами и интерфейсами

Проект в мире ООП и чистой архитектуры — это сложная система связей и зависимостей от множества групп элементов и параметров. Держать в голове всю эту конструкцию — занятие неблагодарное и, по большей части, бесполезное. Лучше всего проектирование начинать с составления диаграммы зависимостей. Это схема, которая подразумевает определение связей между классами и интерфейсами внутри программы. 

С помощью легенды можно установить элементы связи из UML, которые мы используем: наследование, имплементация, наличие и передача. Из сущностей есть два типа элементов: классы и интерфейсы.

Зачем это всё нужно? Ответ прост: при проектировании любого инструмента очень важно выявлять и устранять «красные флаги». Это могут быть перегруженные «God-классы», неявные зависимости, нарушение иерархии слоёв и многое другое. Чем раньше обнаружим проблему, тем быстрее сможем исправить и уменьшить общую стоимость ошибки. 

Диаграмма зависимостей отлично подходит для этой задачи: на ней явно будет выявлено, как выглядит архитектура и нет ли на этапе проектирования костылей.

Процессы «внутри»

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

Swimlane используют в технологических схемах, которые описывают, что или кто работает на определённой части процесса. «Плавательные дорожки» расположены либо по горизонтали, либо по вертикали и используются для группировки процессов или задач в соответствии с обязанностями этих ресурсов, ролей или отделов.

В нашем примере идёт разделение по порядку вызовов и тому, как устроены уровни абстракции в архитектуре. Поэтому получается разделение сверху вниз:
Command > Creator > Job > Repository > Service

На легенде видим, как выглядит стандартный блок диаграммы:

Так выглядит Swimlane нашего алгоритма:

  • Сверху поступают элементы управления: аргументы и параметры. 

  • Слева — точка входа: как мы оказались в этом месте. 

  • Справа — вызовы: куда переходим. 

  • Снизу — точка выхода, то есть артефакты, которые могли создаться из этого процесса.

После создания диаграммы становится ясен путь, по которому «идёт» алгоритм. Это не просто связи в диаграмме зависимостей, а чётко сформулированный процесс с разделением на этапы и определением, какой слой чем занят. Подробнее всего это расписано у Job — «рабочих лошадок» нашей «тулзы». Здесь мы можем видеть, как сначала определяется конфиг проекта, затем создаётся архив, как он распаковывается, переименовывается содержание и получается проект.

Разработка

Основная часть работы заключается в том, чтобы ещё до начала разработки ответить на вопросы, которые возникают во время неё. Если этого не сделать, стоимость ошибок возрастёт. Если устранить максимум возможной неопределённости заранее, разрабатывать и поддерживать проект будет легче .

Config

/// Describes a new project that is being created.
///
/// Consists of values & parameters, that are being inserted
/// into a new project when it's being created by the user. User
/// defines those values & parameters as [ConfigParameter]s
/// whilst interacting with CLI.
class Config { /* ... */ }

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

В этом объекте все значимые поля объявлены через Config Parameter — другой объект, в котором хранится простейшее значение определённого параметра и логика его валидации. По сути он чем-то напоминает Value Object из парадигмы Domain Driven Design (DDD): это позволяет получить больший контроль над значениями, хранящимися в конфиге.

/// Directory, in which a new project is created.
  final ProjectPath projectPath;

  /// Name of new project.
  ///
  /// See also:
  /// * https://dart.dev/tools/pub/pubspec#name
  final ProjectName projectName;

  /// Application Label (name).
  ///
  /// See also:
  /// * https://developer.android.com/guide/topics/manifest/manifest-intro#iconlabel
  final AppLabel appLabel;

  /// Application ID.
  ///
  /// See also:
  /// * https://developer.android.com/studio/build/configure-app-module#set_the_application_id
  final AppID appID;

ConfigBuilder

/// Builds [Config].
///
/// As a whole, it is based on a builder-pattern. It functions as an easier
/// method of building [Config] objects, adding its [ConfigParameter]s
/// on the way.
abstract class ConfigBuilder {
  /// [Config] private instance.
  ///
  /// Default to an empty config with empty parameters.
  Config _config = Config.empty();

  /* Builder methods */

  /// Returns [Config] instance.
  Config build() => _config;

  /// Builds [Config] with given parameters.
  Config buildWithParameters({
	required String projectPathValue,
	required String projectNameValue,
	required String appLabelValue,
	required String appIDValue,
  }) {
	buildProjectPath(projectPathValue);
	buildProjectName(projectNameValue);
	buildAppLabel(appLabelValue);
	buildAppID(appIDValue);
	return build();
  }
}

Строитель (builder) — порождающий паттерн проектирования: действует пошагово и упрощает создание объектов. Бывает полезен для инициализации сложных объектов со множеством полей и параметров.

Config Builder реализует этот паттерн и облегчает создание экземпляра Config. Интерфейс содержит ряд билдер-методов, каждый отвечает за инициализацию определённого Config Parameter (скрыто за комментарием). Помимо этого, внутри класса содержится инстанс конфига, который по умолчанию содержит пустые параметры.

MinimalConfigBuilder

/// '[Config]'-MVP like builder, used for initial [Creator.start].
///
/// Consists of:
/// [ProjectName],
/// [ProjectPath],
/// [AppLabel],
/// [AppID].
///
/// Is bare minimal of a project entity & its builder only used for
/// quick & easy [Creator.start].
class MinimalConfigBuilder extends ConfigBuilder { ... }

Minimal Config Builder реализует интерфейс строителя, переопределяя билдер-методы. Задача конкретно этого строителя: создать простой Config в стиле MVP — то есть с минимальным необходимым набором параметров. Пока что этот билдер единственный в проекте, однако по мере развития возможно будет создать и новых «строителей».

Creator

/// Interface for Project creation.
abstract class Creator {
  /// Main [Creator] entry-point.
  Future<void> start() async {
    final config = await prepareConfig();
    return createByConfig(config);
  }

  /// Retrieves [Config] from somewhere.
  @protected
  Future<Config> prepareConfig();

  /// Creates Project by given [Config].
  ///
  /// Runs series of [Job]s to do so.
  @protected
  Future<void> createByConfig(Config config);
}

Creator — «создатель» проекта. В нём описан алгоритм операций, приводящих проект из исходного шаблонного вида в специфический. Более того, Creator играет роль точки входа в систему: отвечает за то, как мы запустили CLI-утилиту. Это может быть как «Interactive CLI creator» для интерактивного взаимодействия с пользователем по ходу создания проекта, так и «Automatic Creator» для автоматической генерации по готовому config-файлу.

При проектировании мы решили использовать паттерн «стратегия». Благодаря нему у утилиты появилась возможность запускать различные Creator из единой точки входа. Это значит, что Creator может реализовать разные сценарии поведения, сохраняя при этом единый интерфейс. В реальности это очень полезное свойство, которое отвечает LSP (Liskov Substitution Principle) из SOLID и упрощает работу с кодовой базой в дальнейшем.

Job

/// Atomic task, which does something and returns `Object?` on completion.
///
/// [Job]'s are used for the project generation process. They are top-level entities,
/// which define several technical steps of creating a new project. [Job]'s are
/// expandable. Meaning, that series of more [Job]'s can create more complex
/// structure.
abstract class Job {
  /// Executes specific task for project template creation.
  ///
  /// Returns `Object?`
  Future<Object?> execute();
}

Job — атомарная задача, которая выполняет определённое действие. Job, например, может отвечать за скачивание архива проекта или переименование его составляющих файлов. Удобство применения Job заключается в том, что при изменении бизнес-требований к шаблону проекта, можно с лёгкостью подкорректировать одну из задач, поменять выполнение местами или добавить что-то совершенно новое, не создавая конфликтов с предыдущими Job.

/// [Job] requires [Config], as a project-describing entity.
abstract class ConfigurableJob extends Job {
  /// Instance of [Config].
  ///
  /// Holds [Job]-specific instance of [Config], required for
  /// [Job.execute] & project creation process.
  late final Config config;

  /// Sets up [Job] before its' [Job.execute].
  ///
  /// Requires [Config].
  void setupJob(Config config) {
	this.config = config;
  }
}

Помимо обычного Job, мы активно используем его подвид — Configurable Job. Суть объекта не меняется, так как он всё ещё ответственен за выполнение атомарной задачи, однако теперь для её реализации ему необходим Config. С помощью паттерна Object Injection и метода «setup job» передаём инстанс, который Job сможет использовать при исполнении.

Можно сказать, что Job в какой-то мере реализован по паттерну «цепочка событий»: чтобы не превращать Creator в God-class и оставлять всю специфику создания проекта под его ответственность, мы выделили серию Job, которые собирают проект планомерно — «по кирпичикам». 

Серия «Job»:

  1. Собирает конфиг проекта от пользователя.

  2. Скачивает архив шаблона.

  3. Распаковывает и удаляет архив.

  4. Заменяет шаблонные данные на значения из конфига.

Тестирование и внедрение

Теперь осталось протестировать инструмент, выложить в pub.dev, открыть репозиторий и распространить на весь отдел. Важно не забыть написать инструкцию: она поможет при использовании стартера в дальнейшем.

Создание проекта — критически важный момент, так как на моменте инициализации мы и закладываем основные подходы в плане разработки: архитектура, стейт-менеджмент, правила линта. Именно поэтому документирование процессов — это очень важно.

Не стоит начинать создание инструмента с программирования

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

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

Комментарии (7)


  1. Mitai
    04.08.2022 09:58
    +1

    "Соблюдать иерархию: высшие уровни могут зависеть от низших, но не наоборот."
    тут нет ошибки? мне казалось там как раз противоположное или я не правильно его понял...


    1. Ivatar39
      04.08.2022 13:03
      +1

      Смотря с какой стороны посмотреть. Здесь самое главное, чтобы это разграничение было, а что уже считается верхним или нижнем уровнем - дело нейминга.
      Но а так вообще согласен, что обычно верхнеуровневые сущности (в диаграмме зависимостей, например) не должны иметь строгую зависимость от нижних


      1. koliane
        04.08.2022 15:01
        +1

        Все же это не просто дело нейминга, т.к. от этого зависит то, куда мы будем добавлять интерфейс.
        Интерфейс в DIP должен принадлежать модулю верхнего уровня. Если его добавить в модуль нижнего уровня, то инверсии зависимостей не будет.
        Т.е. модуль верхнего уровня - это тот модуль, который в конечном счете использует/потребляет реализацию другого модуля.
        Существует большое заблуждение что DIP - это про зависимость от абстракций.
        Ключевое в этом принципе как раз первый пункт: "модули верхних уровней не должны зависеть от модулей нижних уровней", который непосредственно отражает само название "инверсия зависимостей".

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


  1. YOCA
    04.08.2022 12:56

    по другому взглянула на эту тему , спасибо


  1. koliane
    04.08.2022 14:48
    +1

    Как то тоже создавал подобный функционал для инициализации и дальнейшей работы со своими проектами на flutter, т.к. проблема насущная)

    Концепция была похожая - есть шаблонный проект и файл конфигурации в .yaml формате. Только помимо инициализации, можно работать ещё с уже существующим проектом.

    Для уже существующего проекта можно задать определенную команду и она будет вставлять необходимый код как в уже существующие файлы в определенные места, так и создавать новые файлы/директории. Например, можно создать команду addEntity=Product и будут созданы директория product вместе с репозиториями и сервисами, детальная страница для Product, и вставлены соответствующие url в роуты(т.е. изменения в существующем файле)

    Вот если что код этого функционала: https://github.com/koliane/koliane


  1. aaabramenko
    05.08.2022 08:52

    Следующий шаг генерить проект сразу из UML?


    1. Ivatar39
      05.08.2022 12:57

      Да, либо по какому-нибудь config.json