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

Комментарии 14

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

  • com.example.users.entities

  • com.example.users.repositories

  • com.example.issues.entities

  • com.example.issues.repositories

Это делается по двум причинам. Во-первых, это технически проще. Ссылочная целостность данных обеспечивается на уровне СУБД. Проще писать миграции. Проще потом работать с этими данными, строить аналитику и т.п. Да, и распиливание на отдельные проекты или тем более микросервисы особо ничего не даёт. А, во-вторых, и это наверное даже более важная причина, за схему данных должен отвечать один человек. Он гарантирует, что одинаковые атрибуты у разных сущностей одинаково называются, гарантирует, что нет дублирующих сущностей, что сущности правильно выделены и декомпозированы. Если распределять эту ответственность между разными людьми или командами, то начинается полный хаос. Изначально разрабатывать свои кусочки схемы данных могут и разные люди, но в итоге всё равно всё это должен посмотреть один человек.

Это похоже на проектирование по компонентам из вашей статьи. Но только репозитории лежат рядом с сущностями, там практически нет кода, нет смысла их куда-то выносить. А сервисы, контроллеры, DTO, мапперы и т.п. просто нафиг не нужны в большинстве случаев. Достаточно Spring Data REST.

Хотя наверное в большинстве примеров в сети описывается именно стандартная архитектура с сервисами, контроллерами. А в идеале ещё и распиленными на отдельные микросервисы. Обычно это доходит до полного абсурда, когда, например, 3 отдельных микросервиса: пользователи, товары, заказы. При этом каждый микросервис имеет свою небольшую помоечку для сваливания данных, хочешь PostgreSQL, хочешь MongoDB, хочешь Kafka, хочешь просто в файлах храни данные или в реестре Windows - где угодно. Ссылочная целостность? Дублирование данных? Последующее использование этих данных за пределами микросервиса? Не, зачем нам всё это? Главная задача выполнена, монолит распилен, всё остальное по барабану.

Я долго думал почему эта "стандартная" архитектура так популярна:

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

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

  3. Третья причина зачем может понадобиться весь этот код - это упрощение тестирования, чтобы можно было всё замокать, сделать какой-нибудь сервис, который сохраняет пользователей не в реальной базе, а просто в списке. Но тут всё просто: нет лишнего кода - нечего тестировать. А для моканья базы данных есть инструменты типа TestContainers, OpenTable, Zonky Embedded Database и других. К тому же лучше тестировать на реальной СУБД, которая будет использоваться в продакшене, а не на списках объектов в памяти.

Вам спасибо за комментарий:)

Ссылочная целостность данных обеспечивается на уровне СУБД. Проще писать миграции. Проще потом работать с этими данными, строить аналитику и т.п.

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

Да, и распиливание на отдельные проекты или тем более микросервисы особо ничего не даёт.

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

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

Тут я в целом с вами согласен, но зависит от масштабов. У меня нет отсечки в таблицах, но в есть в людях - имхо, при появлении пятого бакэндера в команде надо начинать всё пилить пополам - команду, схему, систему. И там за схему снова будет отвечать один человек.

А в идеале ещё и распиленными на отдельные микросервисы. Обычно это
доходит до полного абсурда, когда, например, 3 отдельных микросервиса:
пользователи, товары, заказы.

Угу, мне прямо щяс такой легась прилетел, последний баг отлаживал запустив 6 (шесть) микросервисов. Надеюсь продать идею тотального реинженеринга:) Так вот я даже на уровне модулей не это предлагаю, боже упаси - моя идея в том, чтобы провести "транзакционный анализ" и в идеале так нарезать систему, чтобы в выполнении любой транзакции задействовался код только одного модуля.

Проблема же в том, что в разных случаях использования и с разных точек зрения (сравним "тот, кто пишет библиотеку" vs "тот, кто использует библиотеку") будет удобен разный принцип сбора понятий и операций в контейнеры.

Сравним

class Отчет {
    Записать отчет в базу данных(...);
    Вывести отчет на принтер(...);
}
class Заказ {
    Записать заказ в базу данных(...);
    Вывести заказ на принтер(...);
}

и

class База данных {
    Записать отчет в базу данных(...);
    Записать заказ в базу данных();
}
class Принтер {
    Вывести отчет на принтер(...);
    Вывести заказ на принтер(...);
}

С точки зрения одних удобней и понятней первый способ. А с точки зрения других - второй.

Есть очень интересная книга как-раз на эту тему Chris Partridge "Business Objects: Re-Engineering for Re-Use", там в деталях описывается эта проблема. Если в нескольких словах, то идея следующая. Есть реальность (хотя фиг знает есть ли, а если есть, то фиг знает что это), в которой есть некие объекты реального мира: отчет, заказ, база данных, принтер. Есть языки концептуального моделирования (RDF/OWL, Object-role model, ...), которые эти объекты ровно так и моделируют, максимально приближенно к реальности.

Ещё в этой модели могут описываться действия или события: запись отчета в базу данных, вывод отчета на принтер, ... Причем в концептуальной модели они описываются не просто как метод какого-то класса, а как полноценные сущности, у которых может быть атрибут "дата события", "место события", ... Может быть достаточно сложная иерархия, например, выделяем базовую сущность событие (дата, место и другие атрибуты), более специфическую сущность действие (тут добавляется ссылка на субъекта действия, ссылка на объект действия, ...), на их основе определяем более специфические типы действий "запись", "вывод", на их основе определяем ещё более специфические действия "вывод отчета на принтер". Читаем тонны статей типа такой или такой, смотрим разные онтологии верхнего уровня, пытаясь максимально полно и точно описать предметную область.

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

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

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

За книжку - спасибо, добавил себе в список прочтения.

Может быть после неё я уже наконец всё пойму и соглашусь с тем, что ООП хорошее средство для моделирования реальности :trollface:

Я собственно и пошёл изобретать велосипед, т.к. я не понимаю, как проектировать системы через моделирование реальности. А вот как проектировать системы через операции и ресурсы - я понимаю

Правильнее все-таки делать так.


class ОтчетСервис {
    конструктор(БазаДанных базаДанных, Принтер принтер) { ... }

    Записать отчет в базу данных(Отчет отчет) { ... }

    Вывести отчет на принтер(Отчет отчет) { ... }
}

class ЗаказСервис {
    конструктор(БазаДанных базаДанных, Принтер принтер) { ... }

    Записать заказ в базу данных(Заказ заказ) { ... }

    Вывести заказ на принтер(Заказ заказ) { ... }
}

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

Тут дело в том, что с точки зрения того, кто библиотеку для печати (или записи в базу данных) пишет - то его "бизнес логика" - это все, что про принтер. А как устроены отчет или заказ и что они умеют или что еще с ними делать можно - это для него "техническая компонента". Ибо точка зрения другая.

Поэтому у него будет "ПринтерСервис". Ну и так далее.

его "бизнес логика" — это все, что про принтер

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


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

Это просто переносит выбор на другой уровень/слои.

У нас получится четыре куска логики:

Подготовить ДанныеДляПечати из Отчета

Подготовить ДанныеДляПечати из Заказа

Подготовить ДанныеДляЗаписиВБазуДанных из Отчета

Подготовить ДанныеДляЗаписиВБазуДанных из Заказа

Которые так же можно сгруппировать в модули/пакеты/классы двумя способами.

"Подготовить ДанныеДляПечати из Отчета" и "Подготовить ДанныеДляЗаписиВБазуДанных из Отчета" будут находиться в ОтчетСервис, и скорее всего будут private, так как это деталь реализации соответствующей бизнес-логики. Иногда они могут быть вынесены в соответствующие отдельные классы фабрики или билдеры, но точно не в общий класс "Подготовитель данных для записи в базу", который зависит от 20 сущностей проекта. Делать классы с таким количеством зависимостей просто архитектурно неправильно.


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

Я не могу понять "требования" за этим кодом. Все 4 операции доступны для конечного пользователя? Операции записи и печати как-то связанны между собой? Например печать берёт отчёт, ранее записанный в базу? Откуда берётся отчёт для записи в базу?

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

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

На самом деле оно вообще для программирования не специфично, а просто проблема построения плоской классификации. Вот пример:

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

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

Ну и мне всё больше кажется, что вы говорите о "проблеме выражения". Плюс вспомнил ООПный способ её решения

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории