«У лука есть слои, и у людоедов есть слои! Ты понял?» - Шрек
Эта заметка навеяна выступлением Jimmy Bogard на 0redev под названием “Solid Architecture in Slices not Layers”, которую я всячески рекомендую.
Как и у людоеда, у любого современного приложения есть слои. Классической моделью является мордочка наверху, бизнес-логика по центру и базейка снизу. Такое разделение вполне уместно и определяется множеством причин.
Conway’s Law
Любой сложный проект требует вовлечения людей с разной специализацией и интересами. Кто-то пилит базы данных, кто-то лабает UI, а кто-то отвечает за логику. Такое разделение позволяет людям специализировать, а менеджеру масштабировать разработку: выделяя группы и подгруппы, отвечающие за отдельные куски.
Такой разделение еще сильнее выстраивает барьеры между слоями приложения, поскольку тут начинает во всей красе проявляться Conway’s Law, когда архитектура приложения начинает повторять структуру организации.
Повторное использование слоев
Реюз – это наше все. Мечта с детства и все такое.
Если слой ярко выражен и выделен физически, то, например, артихектор может тешить себя мыслью, что когда-то на существующий слой бизнес-логики будет прикручена другая, более симпотичная мордашка.
И хотя на практике, последующее прикручивание новой мордочки не происходит, а если и происходит, то с переделкой всего приложения, существует устойчивое мнение, что вьюшки, вью-модельки, контроллеры и прочие кусочки головолмки должны лежать в разных папочках, сборочках или, чем вы там обеспечиваете физическое разделение слоев приложения.
Проблема
Само по себе, логическое разбиение компонентов на слои является критическим для любого вменяемого по сложности приложения. Например, отделение модели от представления, является важным инструментом борьбы со сложностью. Но вот классическое физическое разделение компонентов может доставить немало хлопот.
Давайте посмотрим, как сейчас обычно пилят софт: команда разработчиков вытягивает очередной сторик из бэк-лога, бодро оценивает его в 22 попугая, выясняет приоритет у мудрого заказчика, и приступает к делу.
Если задача видна пользователю (а именно ради этих зануд мы клавиатуры и топчем), то это значит, что полученный функционал с гарантией в процентов 90% будет покрывать разные слои. Нужно добавить новую формочку? Ок, нужно сделать новую вьюху, потом вью-модельку (или контроллер, или презентер), добавить модельку, поменять что-то в слое доступа к данным, прикрутить новую табличку в базу и добавить туда вьюшку или хранимочку.
Ну ладно, новый функционал – это бывает редко, у меня система в режиме глубокого сопровождения. Так что в основном, я меняю что уже написано. Хорошо. Тогда, что нам нужно сделать чтобы «немного» поправить текущий функционал и добавить новое поле? Нужно поправить вьюху, потом вью-модельку (или контроллер, или презентер), изменить модельку, поменять что-то в слое доступа к данным, обновить табличку, а вместе с ней вьюшку или хранимочку. Хм…
Так вот, помимо небольшого периода времени в начале проекта, основные задачи команды являются сквозными и затрагивают множество слоев приложения.
Давайте вспомним, как выглядит классическая архитектура:
Это означает, что слои четко выражены, а интерфейсы, представляющие определенные слои могут быть весьма массивными, с попыткой спрятать компоненты нижних уровней.
А что предлагается? А предлагается сделать сэндвич: взять инфраструктуру снизу/сверху, а внутрь расположить компоненты, связанные по функциональности.
Важность такого разделения в сознании разработчика/архитектора важна тем, что она будет приводить к другим паттернам, другому расположению компонент и т.п. В этом случае, например, мы сразу же уйдем от паттерна Repository – громадной штуки, отвечающей за доступ к данным. Вместо этого, мы возьмем что-то типа паттерна Specification или Query, когда каждый класс будут ответственен за выполнение одной задачи с точки зрения пользователя/бизнес-логики.
Это же позволит разместить все типы, связанные с определенным функционалом в месте. Например, в случае работы с входом пользователя в систему мы создадим папочку Login, в которой уже будут файлы разных «уровней»: LoginView.cs, LoginVm.cs, LoginQuery.cs, LoginModel.cs etc. При изменении требований «компонента входа в систему», нам все равно придется поменять все те же вьюху, модель, вью-модельку и запрос, но в этом случае, все эти элементы уйдут находиться в одном месте.
Я тут осознанно не хочу вдаваться в технические подробности описанного подхода. Для этого можно посмотреть выступление Джимми, и глянуть на его библиотеку MediatR. Мой главный посыл здесь в том, чтобы мы не забывали, зачем мы крошим приложения на слои. Чтобы сделать нашу жизнь легче. Правда ведь? А что если текущее «проекция» не сильно упрощает нашу жизнь? Ну тогда стоит подумать о других способах физической декомпозиции, которые будут решать существующие проблемы.
Это значит, что если разумнее группировать компоненты по слоям, то делайте это. Но если более разумно группировать их по функциональным возможностям, то не стесняйтесь помещать вьюхи с вьюмодельками в одну сборку или папку.
Хочу заметить, что подобная схема давно реализована (возможно неосознанно) в современных веб-фреймворках. Сам фреймворк предоставляет единое API для доступа к БД, UI и пр. Кастомный же код группируется в модулях (bundle'ах), каждый из которых представляет собой мини-приложение со своими вьюшками, модельками и вот этим вот всем, вплоть до конфигурации.
ОтветитьУдалитьСпасибо за дополнение.
УдалитьДумаю, что все же осознанно.
УдалитьТоварищ Джимми называет этот подход Feature Folders и даже сэмпл предоставил на своем стеке https://github.com/jbogard/ContosoUniversityCore/tree/master/src/ContosoUniversityCore/Features
ОтветитьУдалитьСпасибо за ссылку.
Удалитьпомимо всего прочего, такой дизайн удобно еще и на микросервисы растаскивать
ОтветитьУдалитьЕсть такое дело, хотя в микросервисы уедет модель с DAL-ом, а вьюхи и вью-модельки скорее останутся в другом месте.
УдалитьА мне кажется это два ортогональнльных взгляда. Слой может состоять из сервисов, а сервис может состоять из слоев. А условное разбиение зависит от детализации и точки зрения наблюдателя.
ОтветитьУдалить"Само по себе, логическое разбиение компонентов на слои является критическим для любого вменяемого по сложности приложения." Я бы сказал что в принципе разбиение на компоненты является критическим и наиболее сложным. Любое разбиение должно быть оправдано и учитывать только ключевые факторы и не учиывать незначительные.
"Давайте посмотрим, как сейчас обычно пилят софт: команда разработчиков вытягивает очередной сторик из бэк-лога, бодро оценивает его в 22 попугая, выясняет приоритет у мудрого заказчика, и приступает к делу." Это обезъяний подход, должен быть человек, с широким взглядом, который видит картину целиком.
По поводу отражения. Оно есть всегда и везде. Любая компания является отражением руководства. Любой проект/команда в некоторой степени является зеркалом своего лида. Если говорить более общо, то любое творение человека, отражает его внутренние черты и сущность (я называю это "зеркалит"). Когда хочешь поменять что-то снаружи, необходимо найти то, что надо поменять у себя внутри, а окружающий мир отреагирует на эти изменения.
Глубоко копнул в последнем абзаце:) Но полностью согласен.
УдалитьНЕбольшая ремарка по поводу способов разбиения. Когда выделяются компоненты в приложении, то появляется более чем один способ их группировки. Вот, например, LoginView - это ближе к View или к другим классам, связанных с Login-ом? "Классический" вид, что классы внутри слоев являются более связанными друг с другом, поэтому все они должны быть помещены в одном месте. На самом же деле, это как грится depends:)
Я бы предположил, что связанные классы обслуживают только LoginView, то они скорее должны дежать рядом с View. Если есть перспектива использования их еще кем-то, это это скорее SomeNameSpace.Login и при использовании LoginView возможно будет создавать какие-то свои сущности, которые будут нужны только ему.
УдалитьТы пропустил букву g в фамилии Джимми :)
ОтветитьУдалитьСпасибо! Поправил.
УдалитьСпасибо за замечательную статью, Сергей.
ОтветитьУдалитьОгромным плюсом для использования такого подхода, на мой взгляд, был бы плагин кодогенерации, который бы создавал файлы-заготовки на основе шаблона Model, VM, View, Query или Command. Что-то по типу того что генерирует студия при добавлении MVC View или Controller.
А может кто-то видел подобное?
Есть вариант взять какой-нить T4, возможно. Про отдельные плагины для такой генерации я пока не слышал.
УдалитьМожно с помощью решапера и Multi-File Templates создавать нужный набор классов.
УдалитьТакое разбиение удобно когда всё в одном проекте. Мы тоже приходили в итоге к такой схеме, когда всё для фичи разбито в одной-двух папках, а не рабросано везде по проекту, когда навигация становится крайне противной.
ОтветитьУдалитьНо когда действительно выносишь бизнес логику и/или слой работы с данными в другую сборку, так к сожалению уже не получается :(
А вот вынос слоя работы с базой для меня на данный момент кажется логичным и довольно таки критичным (в случае всяких ОРМ, во всяком случае). Я стараюсь отделить сущности маппинга от сущностей с которыми работает приложение, поэтому их делаю internal, чтобы за пределами этого слоя никто о них ничего не знал. Попутно, прячу "репозиторий" и предоставляю только контракт который будет использоваться снаружи, вообще без деталей работы с базой снаружи.
Т.е. я не верю в Persistence ignorance (который так любят в книжках) и не пытаюсь обмануть себя и других. Я просто выношу все детали работы с базой в одно место и не даю другим частям приложения лезть в эти потроха
Подход очень здравый. Единственная его особенность в том, что если само приложение является довольно тонким клиентом, то придется вместо использования ORM-а напрямую с LINQ-запросами из контроллеров городить свои абстракции.
УдалитьЕсли же в приложении слой доступа - это относительно небольшая часть приложения, то я всегда поступаю, так как ты написал. В случае же достаточно тонкого клиента (что со мной случалось крайне редко), я не буду прятать ORM.
Я не совсем понимаю, как одно противоречит другому. Feature-based архитектура как раз таки и построена на плечах классической архитектуры. Что изменилось? Business logic layer переименовался внезапно в Business logic core. Ух ты! А ведь по задумке архитектора слой бизнес-логики выделялся для реюза. Добро пожаловать в понятие «модуль»! Нормальная практика разработки, это выделение и обособление различных фич в модули, и чтобы не повторяться и всё контролировать существуют слои.
ОтветитьУдалитьВот интересный пример был приведён. Папка Login, там всё: модель данных, вью-модель, контроллер, запросы и т.д. Отдельно от слоя бизнес-логики. И всё вроде хорошо, пока Login живёт сам по себе, изолированно и обособленно. Но всё меняется, если Login начинает жёстко взаимодействовать с другими сущностями. Идеальный мир начинает разрушаться.
Основной корень проблемы, который обсуждается, это сквозное изменение через слои. Это конечно так. Но не совсем так. Во вью модель может добавиться поле, которое подтягивает какие-то данные дополнительно, например, количество входов пользователя. Никакого поля в модель данных не было добавлено, в БД нет изменений, а во вью и вью модели есть. Это нормально. И наоборот. В модель данных вносятся изменения, которые вовсе не обязательно отражаются на UI напрямую. Типа добавили колонку в таблицу == отобразили поле в UI. На деле всё гораздо сложнее, чем проецирование колонки на поле редактирования. При чём намного сложнее. И чаще изменения в слоях происходят ассиметричные, чем сквозные. Бизнес-логика становится сложнее, изощрённее. И часто приходится оперировать не таким понятием, как Feature, а понятием Модуль.
Если же надо пилить такой проект, в котором постоянно видоизменяется модель данных в отражении 1 к 1 (данные-UI), то возможно стоит вообще изменить подход, и делать EAV, когда поля вообще через админку добавляются, без программирования вообще. А логика пишется на скриптах через ту же админку.
В общем проблема надумана, одно не отменяет другого. Слои как были, так и останутся. Надо выбирать решение исходя из задачи и контекста. Смена горизонтали слои vs features не даёт ничего. Это не взаимоисключающие вещи.
Вот ещё интересный пример. Возьмём Login, всё хорошо, он «упакован» в отдельную папочку со всеми причиндалами. Но вдруг оказывается, что системно у нас есть всего один Login, а с точки зрения UI из аж 4 штуки! И все они разные. Login пользователя-владельца, Login с точки зрения администратора, Login с точки зрения менеджера и какого-нибудь модератора. У нас 4 совершенно разные UI на одну и ту же сущность. Что делать? Как это вырулить с одной Feature?
ОтветитьУдалитьИли заказ. С точки зрения покупателя это один заказ. С точки зрения продавца это другой заказ. С точки зрения складовщика, бухгалтера, аудитора, директора... Это всё разные заказы. У них разное UI, разный срез информации. И логика разная.
Тут не работает принцип «добавили поле и протащили по всем слоям», это не работает просто по своей сути. Оно может и работает ну совсем в примитивном проекте. В таком проекте можно вообще не заморачиваться со слоями и даже с Feature, а пилить один проект.
Корреляций с микросервисами тут вообще не вижу, проблема которую увидели авторы идеи не в классической архитектуре приложений, а в способе решения.
Мне показалось, что сейчас были рассмотрены две крайности: много бизнес логики - это наше все, значит классика - вполне ок. Если все сильно плоско, то тут логика не нужна, давайте вообще в один класс все впихнем.
УдалитьЯ нигде не писал, что фича-бейсд подход *всегда* лучше. Я лишь пытаюсь показать, что в некоторых ситуациях такой подход к физическому расположению компонентов является более предпочтительным.
Теперь еще раз: в современной системе есть лишь один вид физической иерархии - того, как сущности/модули расположены скомпонованы. И, как я уже писал, есть два классических разделения: по "классу" модуля - модели в одну кучу, вьюхи в другую. А есть по "типу решаемой проблемы" - логины в одну кучу, а заказы в другую.
Разумность одного или другого подхода определяет то, каким именно образом модули связаны друг с другом и какие связи сильнее. Если класс из модуля модели обладает сильными связями на другие модели, и эти связи является essential, а не accidental, то физическое расположение должно быть одним.
Если же классы внутри модулей являются автономными и основной вид каплинга проходит по вертикали (UI <-> VM <-> M <-> DAL), то более разумно *физически* расположить модули по иному: группируя их по логически выполняемым задачам.
Да, сои есть, но физическое распложение меняется. Является ли это дико принципиальным? Для определенного типа задач это может быть вполне ощутимым. Противоречит ли это общепринятым принципам моделирования, конструирования и всего того, чему учили нас Буч с Мейером - нет.
hi, thank you for sharing this blog
ОтветитьУдалитьjava full stack online trainingin hyderabad