Сколько раз вам приходилось запускать 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, открыть репозиторий и распространить на весь отдел. Важно не забыть написать инструкцию: она поможет при использовании стартера в дальнейшем.
Создание проекта — критически важный момент, так как на моменте инициализации мы и закладываем основные подходы в плане разработки: архитектура, стейт-менеджмент, правила линта. Именно поэтому документирование процессов — это очень важно.
Не стоит начинать создание инструмента с программирования
В заключение хотелось бы дать совет: если появилось желание или потребность сделать собственный инструмент, ни в коем случае не стоит начинать его создание с программирования. Сначала лучше уделить внимание проектированию: обдумать проблему, определить архитектуру и возможные способы реализации.
Часто у неопытных разработчиков можно наблюдать подход «нам бы побыстрее закодить». Он приводит к некачественным неподдерживаемым решениям, которые не могут пройти проверку временем. Попробуйте составить примерный план имплементации, нарисовать пару диаграмм: это упростит для вас как процесс создания проекта, так и его использование в дальнейшем.
Больше полезного про Flutter — в телеграм-канале Surf Flutter Team. Публикуем кейсы, лучшие практики, новости и вакансии Surf, а также проводим прямые эфиры. Присоединяйтесь!
Больше кейсов команды Surf ищите на сайте >>