Comments 31
Однако за многолетний опыт разработки, побывав в нескольких компаниях, сменив кучу проектов я НЕ ВСТРЕЧАЛ паттерн "Спецификация" совместно с паттерном "Репозиторий".
Сочувствую вашему профессиональному опыту, думаю что все таки репозиторий с спецификацией часто объединяют (если не ошибаюсь даже в видео Дмитрия Нестерука спецификация через фильтрацию к базе иллюстрирована), вот одмн из опенсорс проектов: https://github.com/NikitaEgorov/nuSpec
Ошибся, у Нестерука без базы, просто на коллекции фильтр https://youtu.be/_Fec7ZsVn44?si=GR_s8AAmh3NlPFYc&t=503
Очень похоже, что кто-то пытается переизобрести Biarity/Sieve: ⚗️ Clean & extensible Sorting, Filtering, and Pagination for ASP.NET Core (github.com)
Как по мне, спецификации слишком "специфичны", как и сам репозиторий и можно обойтись без них.
Отличная библиотека, кстати.
Репозитории бывают дженириковыми, когда по сути один класс и один интерфейс, а в конструктор ты просто с указанием типа принимаешь, очень удобно работать.
Ага, и куча не всегда нужных не универсальных методов, не говоря уже о том, что неплохо было бы вообще не работать с реальными моделями, а хотя-бы с Dto.
Могут быть сущности, для которых, в принципе, не подразумевается удаление. Например, не стоит удалять пользователя. А этот дженерик такую возможность даст, так ещё и не отключишь.
куча не всегда нужных не универсальных методов
В моем представлении такие репозитории-дженерики имеют только базовые методы (доступ к айкверибл, материализацию, сейв/инсерт, делит), никаких внутренних агрегаций, расчетов, получения взаимосвязанных сущностей - это все через айкверибл/линкью/ту же спецификацию (которая умеет задать через фетч указание пути/глубины получаемых за один запрос данных из базы).
Бизнес ограничения по взаимодействию с какими то корректными типами - не их задача, и да, тогда пользователя можно удалить будет, но и это можно прикрыть доп сущностью и констрейнтом в бд (да костыли, как и запрос что юзера удалять нельзя).
А зачем такая прокладка над dbset-ами?
То, что юзера нельзя удалять это не костыль, так как на него каскадно ссылается много чего. А если мы ещё и с деньгами дело имеем, то выпиливать контрагента совсем не стоит
Удаление пользователя легко запретить, наследуя конкретный IUserRepostory
не от IGenericRepository<User>
, а от ICanRead<User>, ICanUpdate<User>, GenericRepository<User>
, но не ICanDelete<User>
, и где GenericRepository<> - скрытый от потребителя тип.
Но согласен с тем, что это лишняя абстракция. DbSet - это уже репозиторий, отвязанный от конкретной БД. Смысл иметь два?
Переопределить метод удаления в реализации репозитория пользователя, не?
Тогда получишь просто гениальный код вида
await DeleteAsync<User>(userId); //nothing happent
И это все еще не объясняет, зачем делать обертку из "паттерна" спецификация над "паттерном" репозиторий, которые в свою очередь оборачивают dbset, который уже является репозиторием и ef, как спецификация к нему.
А за чем делать свою дополнительную обертку, если в языке уже есть встроенная реализация паттерна в виде Expression? С репозиторием аналогично - любая ормка же предоставляет реализацию паттерна из коробки.
Я использовал подобный подход и по итогу запросы из репозитария расползаются по всей программе и реализованы немного по разному
. За этим нужно следить.
В итоге использую всевозможные построили запросов только внутри репозитария. Это внутреннее дело репы, как оно там всё хранится и как строить запросы. Но у меня своя специфика в виде даппера.
Чтобы справиться с комбинаторным взрывом использую концепцию фильтров, когда null обозначает отсутствие фильтра. Например: GetEmployees(Sex? sexFilter = null, int? minAgeFilter = null, ...)
. При вызове явно указываю названия использованных фильтров, чтобы не было GetEmployees(false, 24).
Ситуации, когда нужно много и хитро фильтровать у меня возникают редко, обычно делаю выборки по включению значений или диапазону. Т.е. нет необходимости как-то хитро, а главное разнообразно делать выборки в прикладном коде, хватает небольшого количества методов репозитария.
Вижу некоторое лукавство. Если от интерфейса/фронта приходит фильтр, то его всё равно надо будет собрать в спецификацию. И код со спецификациями получится очень похожим по структуре на код BadManRepository2
.
В конечном счёте, разве IQueryable - это не реализация паттерна спецификация?
Upd. Немного подумал и понял, что со спецификацией возможно удобнее делать условия OR
Не используется данный по подход по простой причине - излишний оверинжиниринг и решение несуществующей проблемы. Слишком много минусов:
В случае подхода со спецификацией, вместо условного метода репозитория с sql запросом или ORM, мы получаем кучу новых классов, которые надо поддерживать, про которые надо знать, которые являются проектно специфичными (в отличие от sql или ORM'ов, API которых знают все), а значит и увеличивается стоимость поддержки, онбординга новых разработчиков в проект.
Предложенная вами система с спецификациями это создание новой абстракции поверх существующей (ОRM) поверх существующей (SQL). Но ради каких профитов? Ваши плюсы не являются плюсами перед существующим подходами:
> использование абстракций для доступа к данным – правильное решение;
Репозиторий как паттерн - это и есть абстракция для доступа к данным. Она инкапсулирует в себя низкоуровневые запросы к дравйверу бд и возвращает доменную модель.
> спецификация предлагает стандартизованный подход к созданию репозиториев, что облегчает разработку, сопровождение и масштабирование приложений;
Как будут выглядеть запросы с join? Кажется, как будто здесь также придется делать отдельные non-generic методы для сложных запросов, в итоге вся унификация теряется.
> Добавление разных вариаций запросов данных сводится к созданию одной строки кода;
В вашем же примере BadManRepository2 это делается точно также - вы меняете Request модель, и затем добавляете 1 строчку изменений в репозиторий (Where).
> изменение запросов производится на уровне пользовательского кода — нет необходимости менять код репозитория;
В вашем случае необходимости нет, потому что ответственность репозитория расслаивается по проекту на спецификации. Фактически, меняя спецификации вы напрямую аффектите генерируемые запросы, тем самым tight coupling как оставался так и продолжает оставаться
Кол-во оверхеда, как со стороны когнитивной нагрузки разработчиков, так и со стороны производительности просто напросто того не стоят.
var query = CreateQuery();
return query.FirstOrDefault();
Объект query сам по себе неплохая спецификация.
Только использовать его лучше так.
class ManService {
public void doSomething() {
var query = CreateQuery();
query = query.Where(x => x.departmentId == 123);
List<Man> manList = this.manRepository.findByQuery(query);
}
}
В этом случае можно менять реализацию manRepositоry как угодно, он не обязательно будет привязан к SQL. Я так понимаю, у вас примерно это и получилось, repository.Get(spec2)
это аналог findByQuery.
Если query в используемой ORM слишком низкоуровневый, то да, можно сделать более абстрактную обертку. Проблема с методами типа WithAge в том, что со временем понадобится не только 'равно', а еще 'больше' и 'меньше', и придется или делать метод на каждую комбинацию, или универсальный механизм задания операторов, и получится тот же самый query builder.
class BadManRepository2 {
public Man Get(GetManRequest request) {
}
}
GetManRequest это должно быть входное DTO, где поля заполняются пользователем, список полей соответствует полям ввода пользовательского интерфейса. Оно должно использоваться в сервисе, а не в репозитории.
В приложении не должно быть внутренних Request DTO, они быстро превращаются в God-object со всеми возможными полями и операторами, каждое из которых может быть null. Должны быть или специфичные методы репозитория findBySomething, или query builder.
class ManService {
public PaginatedResult<Man> list(GetManRequest request) {
if (request.Name is not null) {
query = query.Where(x => x.Name == request.Name);
}
pageSize = 30;
query = query.offset((request.Page - 1) * pageSize);
query = query.limit(pageSize);
List<Man> result = this.manRepository.findByQuery(query);
int total = this.manRepository.findByQuery(query->count());
int totalPages = Math.ceil(total / pageSize);
return new PaginatedResult(request->page, totalPages, result);
}
Как скомбинировать спецификации, у которых разные Skip и Take?
Хороший вопрос, не думал об этом, по идее, такая ситуация при моей реализации возникнуть может.
Скорее всего будут использованы skip и take последней спецификации, у которой они проставлены.
Думаю правильным решением оставить это на отслеживание разработчику, так как бросать ошибку - не вариант из-за того, что могут быть кейсы, когда надо перезаписать Skip и Take новой спецификаций.
Если отвечать на вопрос прямо, при текущей реализации, возможно менять Skip и take извне. Поэтому после объединения двух разных спецификаций, можно поставить необходимые Skip и Take
Возник вопрос - почему в иерархии наследования репозиториев появляется класс StorageBase<Entity>, хотя совершенно логично его назвать ARepository<Entity>. В чём причина такого названия для базового класса?
Как реализована комбирнация выражений left.And(right) и left.Or(right)?
Вы можете увидеть это в примерах кода. Обратите внимание на классы AndSpecification и OrSpecification.
В классе Expression<> нет методов And и Or. Они есть в классе Expression, но они статические. Так что код left.And(right)
не должен компилироваться, вместо него должно быть написано Expression.And(left, right)
. Либо есть какой-то экстеншн-метод And, которого нет в коде из статьи. Так что вопрос про комбинацию актуален, в статье ответ на него увидеть нельзя.
И оставшийся без ответа вопрос про рабочий сэмпл кода тоже актуален )
Вы сами ответили на свой вопрос) либо я до сих пор не понимаю вас
В And или Or спецификация (приведённых в статье) в методе SatisfiedBy() для комбинации двух Expression используется вызов left.Or(right)
Эти методы реализованы на уровне Expression
Про рабочий пример - не могу показать, репозитории являются приватными
Но использование в реальных кодах несильно отличается от приведенного в примерах, разве что немного более сложные комбинации спецификаций
репозитории являются приватными
можно сдерать сэмпл на гитхабе, верно? )
для комбинации двух Expression используется вызов left.Or(right)
такой код не скомпилируется так как метод Or - статический (ни в Expression, ни в Expresions<> нет нестатического метода и это два разных класса)Expression<Func<int, bool>> left = x => x > 0;
Expression<Func<int, bool>> right = y => y > 0;
var res = left.Or(right);
А вот такой скомпилируется (правда выдаст исключение)Expression<Func<int, bool>> left = x => x > 0;
Это все к чему - нет стандартного способа комбинации выражений. Это делается через подмену параметра, которую надо писать самому. Или использовать библиотеку PredicateBuilder, где все уже сделано. Очень странно что в статье про это ни слова не сказано.
Expression<Func<int, bool>> right = y => y > 0;
var res = Expression.Or(left, right);
Пока добавлю скромный простой комментарий, по личному опыту, использование Спецификаций в нескольких проектах не принесло сколько-нибудь существенной выгоды. Но принесло существенные накладные расходы, разработку усложнило и утяжелило. Т.е. другими словами, лучше не стало, стало хуже.
При нашем анализе факапа выяснили, что цели мы не достигли, ради чего вообще внедряли подход. Заявленные плюсы не работают. Всё дело в том, что бизнес обычно значительно сложнее, чтобы можно было сверстать любой запрос комбинацией спецификаций с помощью And/Or. Немаловажный критерий к качеству продукта, это скорость, отзывчивость -- чему спецификации не способствуют, а только создают ещё один слой проблем для разработчика. Т.е. вместо того, чтобы решать задачу бизнеса, он борется с паттерном и его реализацией, стараясь как-то вот так в раскоряку расширить, иногда путём явных нарушений введённых принципов. А иногда и вовсе задача не решается никак, и приходится, наплевав на слой спецификаций сбоку городить обычный класс с SQL-запросами.
Если оно и работает, то на весьма примитивных проектах, или мне не повезло со спецификой бизнеса.
Недооцененный паттерн «Спецификация» в связке с паттерном «Репозиторий»