Сколько раз вам приходилось запускать 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»:
Собирает конфиг проекта от пользователя.
Скачивает архив шаблона.
Распаковывает и удаляет архив.
Заменяет шаблонные данные на значения из конфига.
Тестирование и внедрение
Теперь осталось протестировать инструмент, выложить в pub.dev, открыть репозиторий и распространить на весь отдел. Важно не забыть написать инструкцию: она поможет при использовании стартера в дальнейшем.
Создание проекта — критически важный момент, так как на моменте инициализации мы и закладываем основные подходы в плане разработки: архитектура, стейт-менеджмент, правила линта. Именно поэтому документирование процессов — это очень важно.
Не стоит начинать создание инструмента с программирования
В заключение хотелось бы дать совет: если появилось желание или потребность сделать собственный инструмент, ни в коем случае не стоит начинать его создание с программирования. Сначала лучше уделить внимание проектированию: обдумать проблему, определить архитектуру и возможные способы реализации.
Часто у неопытных разработчиков можно наблюдать подход «нам бы побыстрее закодить». Он приводит к некачественным неподдерживаемым решениям, которые не могут пройти проверку временем. Попробуйте составить примерный план имплементации, нарисовать пару диаграмм: это упростит для вас как процесс создания проекта, так и его использование в дальнейшем.
Комментарии (7)
koliane
04.08.2022 14:48+1Как то тоже создавал подобный функционал для инициализации и дальнейшей работы со своими проектами на flutter, т.к. проблема насущная)
Концепция была похожая - есть шаблонный проект и файл конфигурации в .yaml формате. Только помимо инициализации, можно работать ещё с уже существующим проектом.
Для уже существующего проекта можно задать определенную команду и она будет вставлять необходимый код как в уже существующие файлы в определенные места, так и создавать новые файлы/директории. Например, можно создать команду addEntity=Product и будут созданы директория product вместе с репозиториями и сервисами, детальная страница для Product, и вставлены соответствующие url в роуты(т.е. изменения в существующем файле)
Вот если что код этого функционала: https://github.com/koliane/koliane
Mitai
"Соблюдать иерархию: высшие уровни могут зависеть от низших, но не наоборот."
тут нет ошибки? мне казалось там как раз противоположное или я не правильно его понял...
Ivatar39
Смотря с какой стороны посмотреть. Здесь самое главное, чтобы это разграничение было, а что уже считается верхним или нижнем уровнем - дело нейминга.
Но а так вообще согласен, что обычно верхнеуровневые сущности (в диаграмме зависимостей, например) не должны иметь строгую зависимость от нижних
koliane
Все же это не просто дело нейминга, т.к. от этого зависит то, куда мы будем добавлять интерфейс.
Интерфейс в DIP должен принадлежать модулю верхнего уровня. Если его добавить в модуль нижнего уровня, то инверсии зависимостей не будет.
Т.е. модуль верхнего уровня - это тот модуль, который в конечном счете использует/потребляет реализацию другого модуля.
Существует большое заблуждение что DIP - это про зависимость от абстракций.
Ключевое в этом принципе как раз первый пункт: "модули верхних уровней не должны зависеть от модулей нижних уровней", который непосредственно отражает само название "инверсия зависимостей".
Сами по себе абстракции ничего не инвертируют. Например, в связках (класс Родитель)<-(класс Потомок), (абстрактный класс Родитель)<-(класс Потомок), (интерфейс Родитель)<-(класс Потомок) все зависимости направлены в одном направлении.
Весь проект может быть построен на абстракциях, но при этом в нем не будет ни одной инверсии зависимостей, если интерфейсы находятся не в модулях верхнего уровня