Создание инициализатора Flutter-проектов. Чисто и SOLIDно

от автора

Сколько раз вам приходилось запускать 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, открыть репозиторий и распространить на весь отдел. Важно не забыть написать инструкцию: она поможет при использовании стартера в дальнейшем.

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

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

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

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


ссылка на оригинал статьи https://habr.com/ru/company/surfstudio/blog/680480/


Комментарии

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

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