Применение архитектуры в приложениях Flutter
Когда мы начинаем писать приложение Flutter (или любой другой язык/фреймворк в этом отношении), мы всегда имеем в виду архитектуру, которую мы хотим, чтобы наш код уважал и следовал ей. Разные архитектурные стили преследуют разные цели, и каждый из них дает вам разные возможности.
Однако, когда проект становится больше и задействовано больше программистов, код может сбиться с пути, и мы начинаем отклоняться от нашей желаемой архитектуры (исключая случай, когда сама архитектура меняется в рамках эволюционного дизайна). Это явление известно как разрыв между моделью и кодом:
Пробел в модельном коде - это идея из книги Джорджа Фэрбенкса “Архитектура программного обеспечения Just enough”. В нем описывается концептуальный разрыв между абстракциями, которые мы используем для обсуждения архитектуры программного обеспечения (модели), и реальностью выполняемого исходного кода. — Из блога model code gap|IcePanel
Чтобы устранить разрыв между моделью и кодом, у нас есть несколько инструментов и техник, которые мы можем использовать для “принудительного применения архитектуры” (мы рекомендуем прочитать статью, поскольку в ней подробно рассказывается о принудительном применении архитектуры). Эти инструменты можно обобщить от более быстрых и ограниченных до более медленных и гибких следующим образом:
- Принудительное выполнение во время компиляции: Эта категория содержит все, что у нас есть в языке, что позволяет нам применять правила и указания по инкапсуляции, упаковке и зависимостям. (например: частные и общедоступные поля, зависимости пакетов и т.д.).
- Автоматические проверки: Это включает в себя дополнительную конфигурацию или дополнительный код, который мы используем специально для того, чтобы разрешить / запретить выполнение определенных действий в нашей кодовой базе. Техника статического анализа кода относится к этой категории, и в некоторых экосистемах мы можем писать тесты для архитектуры кода с помощью таких инструментов, как ArchUnitNET
- Проверка кода: Это включает в себя человеческий фактор, либо при парном программировании, либо при асинхронных проверках кода, чтобы “вручную” проверить, соблюдается ли архитектура и соответствует ли код желаемому дизайну, который, вероятно, был задокументирован в отчете об архитектурном решении.
Если отбросить теорию, когда дело доходит до кода, архитектура - это не прямоугольники и не стрелки. Это то, какие части кода находятся вместе (сплоченность) и кто это вызывает (сцепление). Итак, применение архитектуры сводится к размещению кода в нужном месте и разрешению его вызова оттуда, где мы хотим, чтобы он был вызван. И в этой статье мы попытаемся рассмотреть то, что есть в наших руках как разработчиков flutter, чтобы достичь этого.
Пример Flutter
Глядя на экосистему Flutter, большинство из нас склонны просто создавать проект и начинать с того, что помещаем все в lib/ directory, не слишком заботясь о границах нашего приложения, даже если мы имели в виду конкретную архитектуру (как здесь и здесь), мы склонны думать об этом в терминах папок и файлов, которые на самом деле вообще не имеют принудительного исполнения, и добавление зависимости из папки в другую - это всего лишь вопрос доступа к действиям IDE и автоматического импорта отсутствующего файла, который добавляет знаменитый импорт ‘file.dart’ в начале файла.
Простота такого механизма “импорта” подобна наличию крана, который можно бесплатно открыть в любое время, чтобы добавить воды в большой ком грязи, который мы формируем.
Набор инструментов:
Flutter, или, более конкретно, dart, имеет множество механизмов, которые мы можем использовать для обеспечения соблюдения нашей архитектуры и применения одного из вышеупомянутых методов. Мы также постараемся рассмотреть плюсы/минусы каждого из них, чтобы мы могли выбрать подходящий для наших нужд.
Ради демонстрации и простоты мы создадим простое/примерное приложение для запроса вопроса из Trivia API.
Случай с одним файлом
Поскольку dart предоставляет два модификатора доступа, которые являются общедоступными и закрытыми для библиотеки, мы можем использовать это для управления нашей архитектурой. Идея состоит в том, чтобы поместить все соответствующие части функциональности в один файл (который по умолчанию является одной библиотекой) и предоставить общественности только то, что нам нужно из нашего пользовательского интерфейса, этот метод является принудительным во время компиляции.
Вы можете найти пример здесь.
В нашем случае у нас есть QuizzService
, который представляет собой сервис, который будет содержать различные варианты использования, на данный момент он содержит только один вариант использования, “Получить случайный вопрос”. Единственные части, которые нам нужны общедоступными, - это сервис и тип викторины. Все остальные (например, интерфейс репозитория и реализации) являются частными.
Плюсы:
- Разрешить строгий контроль общедоступного API: доступны только служба и модель предметной области.
- Сплоченность очень высока: поскольку мы собрали все, что связано с функциональностью, в один файл, у нас есть все вместе (но это одновременно и благословение, и проклятие).
- Простая структура: использование файлов упрощает навигацию по проекту, а структура папок очень плоская.
- Соответствует пакету (файлу, в данном случае) по функциональному дизайну.
Минусы:
- Ремонтопригодность /удобочитаемость: Наличие всего в одном файле может затруднить работу с различными частями кода и может ухудшить удобочитаемость кода. Особенно, если файл становится больше, а его части (например, база данных или HTTP-вызовы) становятся сложными или очень низкоуровневыми. Это можно решить, имея несколько файлов с функциями dart “library/part/part of”, которые позволят нам создавать несколько файлов, логически эквивалентных одному и тому же файлу, но команда dart не одобряет эти механизмы, и они в любом случае плохо документированы.
- Односторонняя защита: Хотя мы защитили пользовательский интерфейс от прямого использования репозитория, у нас нет такой защиты наоборот. Таким образом, наш отдельный файл по-прежнему может импортировать виджет (или любого другого типа) и ссылаться на него без каких-либо предупреждений/ошибок.
- Жесткий:
Для тестирования: трудно выполнить простой модульный тест без загрязнения кода (_FakeRepository
и testCompose()
) И это побочный продукт нашей жесткой границы. Мы можем настаивать на внедрении зависимостей, но это противоречит цели хранения всего в одном файле, поскольку мы нарушим жесткие границы, которые мы установили с помощью механизма конфиденциальности. Но если мы можем позволить себе использовать только интеграционные тесты, то, возможно, это и прекрасно. Мы попытались использовать ключевые слова ‘library /part/part of’, но их функциональность, похоже, не распространяется на тестовую папку — не могли сказать наверняка, так как не смогли найти никакой официальной документации.
Для расширяемости и возможности повторного использования: Если у нас есть код или логика, которыми мы хотим поделиться между функциями, то мы обязаны поместить все в один файл, иначе создадим дублирование — логика не является частью общедоступного API.
Мы не слишком одобряем это решение из-за различных вышеупомянутых минусов, и нам захотелось сразиться с ним, чтобы протестировать его. и поскольку занимаясь TDD, иногда нужна возможность включать и протестировать логику в сущностях, особенно если это сложная логика (или логика, которая может создать комбинаторный взрыв, если протестирована из клиентского класса/кода и т.д.). Но это наше мнение, и у разных людей разные взгляды, и всегда есть компромисс между детализацией и тестируемостью.
Пакет и анализ
Этот подход использует использование пакетов dart (или flutter) и статического анализатора dart. И, как мы вскоре увидим, это может быть либо автоматическая проверка, либо проверка во время компиляции.
Разница между пакетами dart и flutter заключается в том, что последний зависит от flutter SDK, а первый зависит только от dart SDK. Мы склонны использовать пакеты dart всякий раз, когда у нас есть пакеты, которым не нужны какие-либо элементы пользовательского интерфейса или типы и которые сохраняют зависимость от Flutter только в пакете пользовательского интерфейса (обычно основного проекта).
Идея этого подхода состоит в том, чтобы создать проект flutter и создать несколько других пакетов, чтобы инкапсулировать то, что мы хотим упаковать вместе, например, в случае чистой архитектуры у нас может быть пакет по уровням, например, пакет ядра, уровень инфраструктуры и т.д. В случае архитектуры вертикального среза мы можем упаковывать по функциям или по набору взаимосвязанных функций.
В нашем примере мы создали пакет под названием trivia_quiz
, который содержит логику и все типы, связанные с получением случайного вопроса из Trivia API. Пакет также содержит соответствующие модульные и контрактные тесты.
Вы можете найти пример здесь.
Наряду с организацией пакета мы можем использовать анализатор дротика для обеспечения соблюдения стиля импорта и разрешать только операторы импорта, ссылающиеся на пакет, а не на отдельные файлы внутри пакетов с относительным импортом файлов. Как вы можете видеть в analysis_options.yaml
, мы пометили две подсказки анализатора как ошибки, таким образом у нас будет более сильное оповещение, когда они произойдут. Нам также нужно было включить always_use_package_imports
, так как он не включен в импортированном файле flutter.yaml
. в отличие от Avoid_relative_lib_imports
.
include: package:flutter_lints/flutter.yaml
analyzer:
errors:
avoid_relative_lib_imports: error
always_use_package_imports: error
linter:
rules:
- always_use_package_imports
Теперь, если у нас есть какая-либо IDE с поддержкой dart или мы запускаем flutter analyze
, мы можем увидеть ошибки, когда пытаемся импортировать что-то, используя относительный путь, минуя общедоступный API пакета. (документация очень хорошо объясняет, как работают пакеты).
Также возможно интегрировать flutter analyze в ваш конвейер CI, чтобы предотвратить фиксацию/PR от нарушения вышеупомянутых правил.
Размещение созданных файлов
Поведение этого подхода зависит от того, куда вы помещаете создаваемые вами пакеты:
- Внутри папки lib flutter: Это вызовет ошибки linter, упомянутые ранее (случай с предыдущим скриншотом), но не заставит вас импортировать пакет в pubspec.yml flutter.
- Вне папки lib flutter: это вызовет упомянутые ошибки linter, а также ошибку компиляции, и проект вообще не будет компилироваться, так что у вас есть гораздо более надежная защита от импорта того, что вы не хотите импортировать. И на любой пакет, который вам нужно использовать, должна быть ссылка в pubspec.yaml ссылающегося пакета. Вызванная ошибка заключается в следующем:
Плюсы:
- Разрешить контроль над общедоступным API: у нас может быть столько файлов, сколько мы хотим в пакете, но мы можем выбрать, какие из них мы хотим, чтобы другие пакеты зависели от использования механизма
export
иshow
. - Контролируйте зависимости пакетов: Поскольку каждый пакет имеет свой собственный
pubspec.yaml
, мы можем явно контролировать, от чего зависит каждый пакет, и выяснить, что использует пакет, довольно просто. - Проще поддерживать и расширять: Наличие полного пакета с исходным кодом и тестом вместе делает его приятнее и проще в обслуживании. И мы не сильно беспокоимся о том, что поместить в публичное и частное (по крайней мере, внутри пакета).
- Естественность: по сравнению с решением с одним файлом более естественно работать с пакетами для обеспечения соблюдения требований.
Минусы:
- Слишком много папок, слишком много файлов: количество пакетов может создать сложную структуру папок, что может затруднить работу над проектом, особенно при работе с функциями, охватывающими несколько пакетов (но это зависит от того, как мы структурируем наши пакеты).
- Полагаться только на ошибки линтера: ошибки линтера — это поддельные ошибки, они не препятствуют компиляции приложения, поэтому для реализации этого потребуется CI, который запускает анализатор и контролирует коммиты, которые нарушают правила линтера. Но это зависит от того, выберете ли вы варианты подходов 1 или 2 указанных выше.
Мы предпочитаем это варианту с одним файлом, и это наш текущий способ работы, как только проект начинает набирать большую кодовую базу и команда начинает расти. (действительно, когда это необходимо).
Внешние пакеты
Подходы, которые мы обсуждали до сих пор, являются родными для языка и фреймворка. Наряду с этим, мы можем добавить дополнительные уровни принудительного исполнения, используя внешние пакеты:
- import_lint: это может быть очень практично в нашем случае использования, поскольку оно предоставляет правила lint для анализа импорта в кодовой базе. Из README:
analyzer:
plugins:
- import_lint
import_lint:
rules:
use_case_rule:
target_file_path: "use_case/*_use_case.dart"
not_allow_imports: ["use_case/*_use_case.dart"]
exclude_imports: ["use_case/base_use_case.dart"]
repository_rule:
target_file_path: "repository/*_repository.dart"
not_allow_imports:
[
"use_case/*_use_case.dart",
"repository/*_repository.dart",
"space\ test/*.dart",
"repository/sub/**/*.dart",
]
exclude_imports: []
domain_rule:
target_file_path: "domain/**/*_entity.dart"
not_allow_imports: ["domain/*_entity.dart"]
exclude_imports: ["domain/base_entity.dart"]
package_rule:
target_file_path: "**/*.dart"
not_allow_imports: ["package:import_lint/import_lint.dart"]
exclude_imports: []
core_package_rule:
target_file_path: "package:core/**/*.dart"
not_allow_imports: ["package:module/**/*.dart"]
exclude_imports: []
Это указывает, какой импорт не разрешен, not_allow_imports
для файлов, включенных в целевой путь target_file_path
.
Этот пакет наследует то, что мы говорили об использовании анализатора ранее, но он предлагает более детальный контроль над зависимостями внутри нашей кодовой базы. Пакет выглядит ухоженным, и его популярность выросла на 85%.
- dart_code_metrics: Хотя это общий пакет статического анализа, он предоставляет правило, которое может быть нам полезно, избегать запрещенного импорта, и вы можете определить запрещенный импорт следующим образом:
dart_code_metrics:
rules:
- avoid-banned-imports:
entries:
- paths: ["some/folder/.*\.dart", "another/folder/.*\.dart"]
deny: ["package:flutter/material.dart"]
message: "Do not import Flutter Material Design library, we should not depend on it!"
- paths: ["core/.*\.dart"]
deny: ["package:flutter_bloc/flutter_bloc.dart"]
message: 'State management should be not used inside "core" folder.'
Этот пакет также очень популярен и поддерживается, и у него есть другие способы использования, помимо запрета на импорт. Это может быть полезно, если вы уже используете этот пакет в своих проектах и не хотите зависеть от другого пакета статического анализа.
- dart_arch_test: Этот пакет предоставляет набор инструментов тестирования, который позволяет вам создавать автоматизированные тесты для определения правил зависимостей в вашей кодовой базе.
import 'package:my_package/main.dart';
import 'package:arch_test/arch_test.dart';
void main() {
archTest(classes.that
.areInsideFolder('entity')
.should
.extendClass<BaseEntity>(),
);
}
Поскольку это автоматизированный тест, он может быть интегрирован в CI, и это может быть сильным показателем того, что мы ввели разрыв между нашим кодом и моделью.
В отличие от первого пакета, этот не выглядит популярным и ориентирован только на Linux, macOS и Windows (судя по тому, что мы видим в pub.dev), и это действительно облом для разработчика flutter. И, глядя на репозиторий, видим, что в некоторых филиалах ведется работа, но активности особой нет.
Заключительные слова
Эта статья была написана, чтобы изучить различные методы, которые разработчик flutter или команда могут использовать для обеспечения соблюдения архитектуры в кодовой базе. А также вы можете разочароваться тем, как легко было просто зависеть от чего-то с помощью инструкции import, которую, вероятно, добавила IDE, и не получать никаких ошибок или предупреждений, когда мы нарушаем правило зависимости (есть, по крайней мере, еще один человек, подобный мне).
Это может относиться ко всем, а может и не относиться, некоторые люди предпочитают проводить обзоры кода, чтобы поддерживать кодовую базу в форме, но это не альтернатива этому, а инструменты, которые мы можем использовать наряду с этим.