Как стать автором
Обновить
70.27
Surf
Создаём веб- и мобильные приложения

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

Время на прочтение9 мин
Количество просмотров4K

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

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

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

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

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

Больше полезного про Flutter — в телеграм-канале Surf Flutter Team. Публикуем кейсы, лучшие практики, новости и вакансии Surf, а также проводим прямые эфиры. Присоединяйтесь!

Больше кейсов команды Surf ищите на сайте >>

Теги:
Хабы:
+5
Комментарии8

Публикации

Информация

Сайт
surf.ru
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия