«Чистая» архитектура в Xamarin.Forms

Автор:

fullstackmark.com

Качество программного обеспечения, высокое или низкое, достаточно сильно влияет на людей, имеющих отношение к этому программному обеспечению, начиная от разработчиков, заканчивая конечным пользователем.

Как оценивается программное обеспечение?

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

Значение и вес каждого из критериев достаточно сильно варьируется в зависимости от того, к каким требованиям, технологиям, методологиям, инструментам они применяются. Каждая команда или проект, могут сильно отличаться от других подобных, поэтому их очень тяжело сравнивать.

Однако все же есть несколько критериев, которые мы можем применять, когда разговор заходит об оценке качества приложения:

Deployment – развертка – оценка того, насколько быстро приложение может быть собрано и запущено, объем «ручной» донастройки приложения в ходе его запуска.

Testing – тестируемость – можно ли быстро протестировать ключевую логику с использованием набора автотестов? Какие зависимости требуются для тестирования: определенные базы данных, сетевые соединения, пользовательские интерфейсы?

Maintainability – поддерживаемость – мера оценки того, насколько быстро можно изменить уже имеющуюся часть программы или добавить новый функционал так, чтобы имеющийся рабочий функционал не был сломан.

Лучшие программы с чистой архитектурой

С верой в то, что хорошее ПО начинается с хорошей архитектуры, в далеком 2012 Дядюшка Боб представил миру свое видение Чистой Архитектуры.

Изначально эта идея не была чем-то принципиально новым, она была основана на уже существующих паттернах по типу Гексагональной архитектуры.

Ключевые особенности Чистой Архитектуры в Xamarin.Forms:

Отсутствие зависимостей от фреймворков

Архитектура не должна зависеть от какой-либо специфичной библиотеки или технологии. Это значит использовать фреймворки как инструменты, вместо того, чтобы подстраивать свой код под их ограничения.

Тестируемость

Должна быть возможность тестирования бизнес-логики без пользовательского интерфейса, базы данных, веб-сервера или какой-либо другой внешней зависимости.

Независимый UI

Должна быть возможность изменять пользовательский интерфейс без изменения остальной части кода. Не имеет значения, какое приложение мы разрабатываем, SPA веб-приложение или мобильное приложение – у нас должны быть возможности менять пользовательский интерфейс не затрагивая бизнес-логику.

Независимость от баз данных

Вы собираетесь использовать реляционные базы данных Sql server или MySql? Или же документно-ориентированные базы данных по типу Mongo или CochDB? Или может быть их комбинация? Также, как и в случае с UI, в Чистой Архитектуре не должно быть зависимостей между базой данных и бизнес-логикой.

Отсутствие каких-либо внешних зависимостей

Бизнес-логика не должна знать ничего о внешнем мире.

Золотое правило зависимостей

Правило гласит, что зависимости исходного кода могут указывать только вовнутрь. Внутренний круг не может знать ничего о внешнем. - Дядюшка Боб.

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

Первый внешний слой представляет собой веб-фреймворки, драйверы баз данных и другие детали, необходимые для запуска нашего приложения. Обычно мы пишем очень мало кода здесь.

Слой интерфейс-адаптеров конвертирует или адаптирует данные под формат необходимый для сценариев использования и сущностей. Такие вещи как контроллеры, модели представления (view model), презентации (presentation) и UI код относятся к этому слою.

Слой сценариев использования содержит бизнес-логику приложения. Код, относящийся к этому слою, никак не взаимодействует с внешними слоями и их зависимостями. То есть такие элементы как базы данных или HTTP клиенты абстрагируются интерфейсом, что облегчает тестирование с использованием данных имитирующих реальное использование приложения (mock data).

Сущности описывают данные, объекты и их поведение, которые могут использоваться в нескольких приложениях внутри предприятия. Если же вы пишете просто одиночное приложение, в этом случае сущностями являются бизнес-объекты этого приложения. Аналогично слою сценариев сущности остаются «чистыми» и содержат код, необходимый только для обеспечения работоспособности каких-либо высокоуровневых политик или бизнес-логики, при этом оставаясь свободными от зависимостей внешних слоев.

Управляющий поток и границы

В нижнем правом углу диаграммы показана схема, которая объясняет как контроллеры (controllers) и презентеры (presenters) взаимодействуют и обмениваются данными со сценариями использования.

Чистая Архитектура и Xamarin.Forms

Итак, хватит теории. Давайте на практике посмотрим, как можно применить принципы Чистой Архитектуры к мобильному приложению. Мобильные приложения – хороший пример для демонстрации, т.к. даже простое с виду приложение может скрывать под капотом сложные решения.

Практически каждое приложение требует подключения для получения данных с внутреннего ресурса через Интернет. Большинству приложений требуется локальная база данных, расположенная на устройстве пользователя. Многие приложения используют доступные аппаратные функции, такие, как камеры, датчики движения, положения, окружающей среды и другие.

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

Если мы не будем осторожны, этот код может легко запутаться в более важной бизнес-логике, в результате чего мы получим большие участки кодовой базы, которые будут глючными, трудно тестируемыми и сложными для изменения.

Давайте посмотрим, как чистая архитектура может помочь нам избежать некоторых из этих ловушек, позволяя нам писать более чистый код, достаточно хорошо скомпонованный, выразительный и относительно простой для тестирования и сопровождения.

Демо-проект

Наше приложение «Open Standup» предоставляет пользователям GitHub простую функцию для публикации информации о проектах и репозиториях, над которыми они работают. Оно также предлагает несколько основных социальных функций, таких как комментарии и лайки.

Репозиторий GitHub для этого проекта находится здесь, а пользователи Android могут загрузить приложение из магазина Google Play (требуется учетная запись GitHub).

Структура проекта

Давайте начнем с определения каждого из проектов в нашем решении, а также с краткого описания их назначения.

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

OpenStandup.Core

Как можно предположить из названия, находится в центре нашего проекта и включает в себя слои сущностей и сценариев использования. Этот проект является местом, где живет наша бизнес-логика, и, как таковой, в основном является чистым C# без знания внешнего мира (без прямых ссылок на внешние слои). Любая зависимость на внешнем уровне, необходимая здесь, абстрагируется за интерфейсом и внедряется через IoC. У нас есть пара вспомогательных зависимостей в Autofac, которая обеспечивает механизм dependency injection, и библиотека Mediatr, используемая для реализации паттерна Mediator, который позволяет объектам взаимодействовать в свободной связке. Мы увидим, как они реализованы немного позже.

OpenStandup.Mobile

Основной проект Xamarin.Forms состоит из общего кода пользовательского интерфейса, включая представления, элементы управления, модели представления, стилевые ресурсы и логику начальной загрузки для установки таких вещей, как контейнер Autofac.

OpenStandup.Mobile.Infrastructure

Содержит реализацию для таких вещей, как наша база данных SQLite, клиенты GraphQL, REST API и другие сервисы. Эти сервисы абстрагированы за интерфейсами, которые внедряются во внутренние слои, где живет бизнес-логика в соответствии с правилом зависимости.

OpenStandup.Mobile.Android

Проект платформы Android, куда мы помещаем все пользовательские рендереры пользовательского интерфейса и нативные вызовы API, которые не доступны в кроссплатформенном API Xamarin.Forms. Для наших целей мы не будем писать здесь много кода.

OpenStandup.Mobile.iOS

Проект для платформы iOS, мы не будем его использовать, так как наша демонстрация нацелена только на Android.

Реализация сценария использования

Основной сценарий использования нашего приложения: отправка сообщения. Давайте рассмотрим дизайн и реализацию этой функциональности в рамках Чистой Архитектуры.

UI нашего приложения структурирован с использованием MVVM, поэтому код, определяющий макет, элементы управления и стиль, находится в представлениях (view) и отделен от кода, содержащего методы и команды, вызывающие бизнес-логику, которая в основном находится внутри моделей представлений (view model).

Выше было кратко упомянуто о потоке управления и о том, как код во внешних (UI) слоях взаимодействует с бизнес-логикой в слое сценариев использования. Теперь мы посмотрим, как это реализовано.

Для функции публикации поста в нашем приложении цепочка обработки начинается на странице EditPostPage, где обработчик события нажатия кнопки post запускает метод PublishPost модели представления, как показано ниже.

public async Task PublishPost()
{
   _indicatorPageService.ShowIndicatorPage();
   await _mediator.Send(new PublishPostRequest(Text, PhotoPath));
   _indicatorPageService.HideIndicatorPage();
}

Как мы видим, здесь не так уж много всего происходит. _indicatorPageService используется для показа/скрытия индикатора загрузки. Единственная интересная строка – средняя, где мы используем MediatR для вызова метода публикации поста.

MediatR – это очень удобная библиотека, которая обеспечивает простую реализацию паттерна посредник в .NET. Этот паттерн управляет тем, как объекты взаимодействуют друг с другом, сохраняя слабую связанность. В случае с MediatR это достигается с помощью относительно простого API, который мы можем использовать в нашем коде для отправки и обработки сообщений между классами в процессе работы.

Ссылка _mediator модели представления, как следует из названия, представляет объект посредника, который обеспечивает модель обмена сообщениями «запрос/ответ», к которой могут подключаться наши классы. MediatR поддерживает две простые модели обмена сообщениями: режим запроса/ответа, который мы будем использовать, который отправляет сообщения одному обработчику. И сообщения уведомления, которые отправляются нескольким обработчикам.

Сообщение PublishPostRequest, отправляемое моделью представления, обрабатывается PublishPostUseCase, который находится в проекте Core.

public class PublishPostUseCase : IRequestHandler
{
    private readonly IFileUtilities _fileUtilities;
    private readonly IImageUtilities _imageUtilities;
    private readonly IOpenStandupApi _openStandupApi;
    private readonly IOutputPort _outputPort;
 
    public PublishPostUseCase(IFileUtilities fileUtilities, IImageUtilities imageUtilities, IOpenStandupApi openStandupApi, IOutputPort outputPort)
    {
        _fileUtilities = fileUtilities;
        _imageUtilities = imageUtilities;
        _openStandupApi = openStandupApi;
        _outputPort = outputPort;
    }
 
    public async Task Handle(PublishPostRequest request, CancellationToken cancellationToken)
    {
        try
        {
            byte[] imageBytes = null;
            var rawImageBytes = await _fileUtilities.GetBytes(request.PhotoPath).ConfigureAwait(false);
 
            if (!rawImageBytes.IsNullOrEmpty())
            {
                // Downsize raw image
                imageBytes = _imageUtilities.Resize(rawImageBytes, 2272, 1704);
            }
            var apiResponse = await _openStandupApi.PublishPost(request.Text, imageBytes);
 
            if (apiResponse.Succeeded)
            {
                _fileUtilities.TryDelete(request.PhotoPath);
            }
 
            await _outputPort.Handle(new PublishPostResponse(apiResponse)).ConfigureAwait(false);
        }
        catch (Exception e)
        {
            await _outputPort.Handle(new PublishPostResponse(Dto.Failed(e)));
        }
 
        return new Unit();
    }
}

Эти два класса демонстрируют шаблон запроса/ответа MediatR и его простое соглашение, требующее, чтобы класс запроса реализовал IRequest, а класс обработки - IRequestHandler. После этого MediatR может совершить свою магию, позволяя этим двум классам взаимодействовать полностью развязанным образом.

Обратите внимание, что модель представления не имеет прямой зависимости от класса сценария использования. Она использует интерфейс IMediator от MediatR, который заботится о маршрутизации сообщения в класс, ответственный за его обработку.

Класс варианта использования содержит единственный метод: Handle (PublishPostRequest request, CancellationToken cancellationToken), в котором находится вся логика публикации сообщения.

Все зависимости, необходимые для данного сценария использования, инкапсулируются контейнером Autofac. Их интерфейсы определены в проекте/слое Core, но их реализации определены в других местах во внешних слоях UI и инфраструктуры. Это и есть правило зависимости в действии. Бизнес-логика в нашем примере использования ссылается только на абстракции этих сервисов и ничего не знает об их низкоуровневых деталях или дополнительных зависимостях, которые они используют. Это помогает сохранить код сценария использования чистым и легко тестируемым.

Единственная зависимость, которая может показаться немного загадочной, это _outputPort типа IOutputPort.

IOutputPort представляет собой абстракцию, которая позволяет классам-представителям пользовательского интерфейса на внешнем уровне взаимодействовать с вариантами использования. PublishPostPresenter в данном случае реализован в проекте OpenStandup.Mobile вместе с остальным кодом пользовательского интерфейса приложения. Метод Handle() порта вывода ожидает один параметр, который представляет собой объект ответа от сценария использования, используемый для передачи любых данных между сценарием использования и презентатором.

Здесь используется интерфейс, поскольку прямой вызов и ссылка между сценарием использования и презентером нарушили бы правило зависимостей. Вариант использования обращается к интерфейсу IOutputPort во внутреннем круге, который класс ведущего реализует во внешнем (UI) слое. Это пример принципа инверсии зависимостей, который мы можем использовать для поддержания свободной связи между классами.

Метод Handle презентера задает некоторые свойства модели представления и запускает навигацию на следующее представление, если все сработало (вызов API прошел успешно), или отображает диалоговое окно с ошибкой, отправленной из сценария использования, если это не удалось.

public class PublishPostPresenter : IOutputPort
{
   private readonly EditPostViewModel _viewModel;
   private readonly IDialogProvider _dialogProvider;
   private readonly INavigator _navigator;
 
   public PublishPostPresenter(EditPostViewModel viewModel, IDialogProvider dialogProvider, INavigator navigator)
   {
     _viewModel = viewModel;
     _dialogProvider = dialogProvider;
     _navigator = navigator;
   }
 
   public async Task Handle(PublishPostResponse response)
   {
     if (!response.ApiResponse.Succeeded)
     {
        await _dialogProvider.DisplayAlert("Post Failed", $"msg: {response.ApiResponse.Errors.FirstOrDefault()} \nPlease check your connection and try again.", "Ok").ConfigureAwait(false);
     }
     else
     {
       _viewModel.Text = "";
       _viewModel.PhotoPath = "";
       await _navigator.PopAsync();
    }
  }
}   

Поток управления

Если кратко описать цепочку обработки, то она начинается в модели представления, проходит через сценарий использования, а затем завершается в ведущем.

Подведение итогов

В этой статье мы рассмотрели, как Чистая Архитектура может помочь нам построить хорошо структурированное и надежное приложение, в котором важный код находится на переднем плане и в центре дизайна приложения.

Такой подход дает ряд значительных преимуществ:

  • Выразительная и последовательно структурированная бизнес-логика, которую легче понять и устранить неполадки.
  • Бизнес-логика не привязана к какому-либо конкретному виду базы данных, внешнему API или формату представления.
  • Улучшенная тестируемость, поскольку основная логика сценария использования свободна от каких-либо зависимостей или фреймворков, настройка которых может нетривиальной задачей во время тестирования.

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

Оглавление

Материалы по теме