Как стать автором
Обновить
80.95

Чистая архитектура, или проектируя клиента RuBackup

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

Привет, Хабр! Меня зовут Андрей, и я являюсь частью команды разработчиков в компании «Рубэкап» в ГК «Астра». Да, помимо команды разработки Astra Linux, у нас еще несколько продуктовых команд.

Здесь описан путь, которым мы шли, проблемы, с которыми столкнулись, и обзор наших решений. Здесь не пересказ книги дяди Боба, и мы полагаем, что вы знакомы с трудом Роберта Мартина “Чистая архитектура. Искусство разработки программного обеспечения”. Это скорее интерпретация с различными дополнениями в контексте разработки нашего клиентского приложения.

Еще про проектирование: тут, тут и тут.

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

Наша цель — уменьшить трудозатраты на разработку и дальнейшую поддержку приложения.

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

Предыстория

Началось всё с того, что мне передали старый проект. Клиент для мониторинга и управления с графическим интерфейсом, который цепляется к БД и местному серверу. Передо мной стояла задача лишь переписать графику. Но жить дальше с тем, что уже написано «под капотом», не хотелось и было бы сложно в дальнейшем — я понимал, что поддерживать это придётся мне.

Стоит отметить, что в одном из файлов исходников было 17 тысяч строк, а некоторые конструкторы имели по три реализации, мало отличающихся между собой. Рефакторить я считаю нецелесообразным, да и, конечно, не хочется. Нам и так приходится поддерживать проект, пока не выкатили новый. Итак, начнём.

Битва за архитектуру

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

“Функциональность или архитектура? Что более ценно? Что важнее — правильная работа системы или простота её изменения?”

Поговорим про наболевшее: есть ли в вашей компании архитекторы? Окей, простое приложение пишем сами, а через полгода из-за хотелок/новых фич и беспорядочного участия разных лиц ваш проект всё так же легко поддерживать? А через год? Ты приходишь в проект, а там легаси…, «Синдром сантехника»: правила работы с легаси-кодом в тестировании

"Есть два вида дел: срочные и важные. Срочные дела, как правило, не самые важные, а важные — не самые срочные. В этом старом изречении есть глубокая истина. Срочное действительно редко бывает важным, а важное — срочным. Первая ценность программного обеспечения — поведение — это нечто срочное, но не всегда важное. Вторая ценность — архитектура — нечто важное, но не всегда срочное.”

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

Во-первых, было сделано так, что если заказчики нас попросят переделать «автомобиль» в «самолёт», то мы будем хоть сколько-то к этому готовы. Речь, как вы понимаете, про принципы SOLID, а конкретнее про принципы открытости-закрытости и подстановки Барбары Лисков. Тысячная статья про принципы SOLID

Во-вторых, данный проект у нас не единственный, и кто-то должен был начать писать программный интерфейс (API), из которого далее вырастет слой, содержащий сценарии/энтерпрайз-логику для всего стека приложений. В наших планах, чтобы это API далее поехало в rest API.

В-третьих, составлен перечень модулей, спроектировано взаимодействие между ними. Мы постарались максимально отвязать API от контроллера над ним, интерактор — от графики и, конечно, сохранить независимость модулей посредством интерфейсов, чтобы их было удобно заменять и применять где-либо ещё. 

Не обошлось без синглтонов. Для справки: синглтон — неоднозначный паттерн, который удобен в нужное время в нужном месте и не является гибким. Синглтоны и общие экземпляры Например, сущность, несущая в себе текущую сессию. Или объект, отвечающий за организацию уведомлений пользователя.

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

Функционал

Для начала мы составили тест-кейсы (TDD — test-driven development), упомянутые ранее, чтобы использование определяло реализацию. Тесты вызывают методы, реализующие функционал приложения, такие как «удалить A», «создать B» и т. д. (методы API). В результате пользователь приложения будет создавать последовательность вызовов, а мы сможем воспроизводить подобные сценарии с помощью наших тестов.

Тестирование показывает присутствие ошибок, а не их отсутствие. (Э. В. Дейкстра)

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

Мнения о полном покрытии можно посмотреть здесь и здесь.

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

“Структурное программирование даёт возможность рекурсивного разложения модулей на доказуемые единицы, что в свою очередь означает возможность функциональной декомпозиции.”

Каждый метод из API обособлен и реализует минимальную логику, не затрагивая чужие зоны ответственности. Первично функционал разделён на две группы по целевому взаимодействию: на блок-схеме «To DB» и «To Server». Среди методов существуют обобщения, которые нужно инкапсулировать через интерфейс или вынести в общий метод. Дублирование кода — плохо, и мы от него избавляемся. Так, например, у нас спрятано объектно-реляционное отображение (ORM — Object-Relational Mapping) в интерфейсном классе, в инстансах объект — конкретная таблица с проведением валидации DTO (Data Transfer Object). Или взаимодействие с нашим сервером универсализировано адаптером (DTO — сообщение серверу). Подобный подход изолирует методы/инкапсулирует сущности друг от друга, так как у каждого своя зона ответственности (SRP — Single-responsibility principle).

Далее я задумался, в чём и как разместить минимальные методы. Самый, казалось бы, простой путь — пространство имён тех самых методов. Без классов, без логики поведения. Физически же это динамическая библиотека, поскольку наш клиент не единственный, где будет задействовано API. Но здесь одно из тех мест, где нужно подумать о будущем — речь о той самой переделке автомобиля в самолет. Нужен интерфейс и по инстансу на каждый метод API. Это лаконично уложилось бы в поведенческий паттерн «Команда». Сможем применить очередь вызовов, индикаторы выполнения и массовые отмены, даже если не всё из перечисленного нам сейчас требуется.

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

Модель данных

Отдельным модулем стоит модель для представления табличных данных.

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

Далее в качестве модели мы создаём самого младшего наследника, а указатель выдаём на самого старшего. Когда представление создаёт себе модель через фасад, оно указывает наследника по умолчанию, но работает со всеми сразу. Такая конструкция позволяет реализовать несколько моделей в рамках одной (полиморфизм). Кстати, модель ничего не знает про данные в ней, поэтому она универсальна. Модель используется в пользовательском интерфейсе, именно он выбирает её поведение и наоборот — модель определяет реализацию в интерфейсе (DIP — Dependency Inversion Principle).

Данная конструкция «Модель — Отображение — Фасад над моделью» есть MVC (Model-View-Controller). Логика у нас ушла в контроллер. Есть задумка — реализовать две одинаковые модели к одному контроллеру с целью получить двойную буферизацию для регулярно обновляющихся таблиц. Думаю, это дешевле, чем реализовывать второй буфер в одной модели.

Приведу пример неразмазывания логики из нашей практики. В нашем старом приложении были модели, которые находились в UI-слое, отчего заблаговременно знали, что они будут отображать/делать. То есть модель и представление были не разделены и зависимы друг от друга. Одно без другого не имеет смысла. Сейчас модель и представление разделены. Модель не знает, где она будет использоваться, что она будет отображать. У неё есть только некоторые общие для модели методы. Вся логика реализована в UI-представлении — в том, с чем взаимодействует пользователь. Именно там говорится, какую таблицу мы будем отрисовывать, какие поля, какой фильтр и все остальные надстройки над отображаемой частью. Благодаря этому мы получаем универсальную модель, которую в дальнейшем можно использовать в любом другом приложении.

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

Бизнес-логика

Бизнес-логика подразумевает реализацию правил и ограничений автоматизируемых операций. Это синонимом термина «логика предметной области».

Здесь нужно объяснить. Да, у нас есть модель данных из предыдущего блока «Модель данных», и она нужна исключительно для табличного отображения. Контроллер этой модели занимается фильтрами, сортировкой, всей той однотипной логикой, применимой к отображению. Можно было бы добавить ещё какую-либо функциональность сверх этого — например, действие над выбранным полем таблицы.

Но вот тут с MVC начинаются проблемы. Мы хотим показать, что модель-представление с контроллером – это неплохо, это архитектурное решение для определенных целей. И не нужно в нём раздувать божественный класс: чем больше складываем туда логики, тем сложнее его потом поддерживать. Так мы и CRUD (create, read, update, delete) вынесли.

Давайте попробуем всё упорядочить.

Инстанс метода API или же обернутый метод — это entity доменного слоя. Сущность несёт в себе id, status и foo, где foo — функциональный объект, то есть он содержит метод API, а не является им, поэтому мы назвали его не execute(), который применительно к DDD является value object. Уточним, мы говорим о сущностях для пользовательского интерфейса, основанного на заданиях (Task based UI).

Ресурс — агрегатор доменного слоя. Он агрегирует некоторые сущности и в нём же определяется поведение (интерактор). Здесь логика типа исполнения операций. Например, синхронные и асинхронные задачи. Более длительные операции, которые могут сказаться на работоспособности интерфейса, должны исполняться асинхронно. Ещё одним типом являются задачи, возвращающие трекер отслеживания состояния запущенной задачи на сервере. Особого внимания заслуживает последний тип операций, поскольку он является аналогом менеджера задач, который управляет уведомлениями пользователя, а также поведением графических компонентов. Примером может быть необходимость включения сирены и добавления красных индикаторов в графический интерфейс в случае аварии.

Контейнер ресурсов — служба предметной области (Domain Service). Он же — фабрика, вызываемая в UI-слое, генерирующая объекты (ресурсы). Управление памятью происходит также в контейнере, отчего мы так его и называем.

Немного про DDD (Domain-Driven Design): Domain-driven design: рецепт для прагматика, Domain Driven Design на практике, Проектирование микрослужбы, ориентированной на DDD, Domain-Driven Design: тактическое проектирование. Часть 2

Очевидно, мы говорим про богатую доменную модель, которой противопоставляется Anemic Domain Model. Мы считаем, что объект должен нести ответственность за поддержание согласованного внутреннего состояния. Применимо к нашему проекту: объект (ресурс) может вести отслеживание результата своей бизнес-операции. Однако замечу, что не являюсь противником бедной модели — при первичном проектировании мы даже пытались её соблюсти, так как она проще, потому что более декомпозирована и не нарушает SRP.

Критика

Мы применили шаблон ORM, который также признаётся антипаттерном. ORM или как забыть о проектировании БД, ORM — отвратительный анти-паттерн Это и SQLResource, и интерфейс в API, стандартизирующий взаимодействие с рядом сущностей из БД. Такой выбор обосновывается достаточной простотой проекта и однотипной логикой реализуемых запросов.

Мы используем синглтоны, потому что это удобно. Но да, мы этим не увлекаемся. Вызов синглтона Authorizer в API нарушает DIP, поэтому эта зависимость спрятана в новой сущности Session.

У нас отвязанная модель табличных данных, не согласующаяся с доменной моделью. Одним из аргументов является вынесение бизнес-логики в предметную область. Модель только для отображения. И есть ещё одна проблема: модель с отображением завязаны на фреймворке. Нам это удобно, и мы не хотим отказываться от фич фреймворка, но и создавать интерфейс модели — избыточный труд, фреймворк уже содержит её абстракцию. В рамках проекта реализация хранения данных в модели и реализация отображения единственны.

В заключение

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

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

Теги:
Хабы:
Всего голосов 8: ↑6 и ↓2+4
Комментарии0

Публикации

Информация

Сайт
astragroup.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия