Spring Data Specification: наложение фронтенд-фильтров на репозитории spring data

—

от автора

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

Рассмотрим ситуацию: мы реализуем интернет-магазин. Со стороны UI пользователь имеет возможность осуществлять поиск товаров, задавания произвольное количество фильтров. Например, он может указать в качестве фильтров для поиска:

  • Найти все книги с заданным названием.

  • Найти все книги, у которых жанр — фантастика и имя автора начинается с “абв”.

  • Найти все книги с обложкой желтого или красного цвета.

Со стороны бекенда сущности Книга, Жанр, Автор и Тег хранятся в разных таблицах и соединяются по foreign key. Для каждой сущности есть свой отдельный репозиторий и Entity.

Когда бекенд получает от фронтенда запрос на поиск, то необходимо преобразовать этот произвольный набор фильтров и в соответствии с ними выбрать:

  • нужный репозиторий

  • нужный метод в этом репозитории

  • определить, нужно ли делать join, чтобы отфильтровать сущности по полю соединенной таблицы

Задача: на основе произвольных json-фильтров, приходящих с фронтенда формировать sql-запросы с условием по полям, указанным в фильтрах.

Более подробно про особенности интерфейса Specification описано в статье.

Если кратко, то Specification помогают наложить фронтенд-фильтры на репозитории spring data.

Объект фильтра, приходящего с фронтенда:

@Data @Builder @NoArgsConstructor @AllArgsConstructor public class SearchCriteria implements Filter { private final String type = "SEARCH"; /** * Название поля по которому ищем (должно соответствовать названию поля в таблице БД) */ @ApiModelProperty(value = "Название поля по которому ищем (должно соответствовать названию поля в таблице БД)") private String key; /** * Название таблицы, которую присоединяем (для вложенных полей, например, пользователь author) */ @ApiModelProperty(value = "Название таблицы, которую присоединяем (для вложенных полей, например, пользователь author)") private String table; /** * Операция сравнения для фильтрации (EQ, GR, LO, IN) */ @ApiModelProperty(value = "Операция сравнения для фильтрации (EQ, GR, LO, IN)") private String operation; /** * Значение по которому ищем */ @ApiModelProperty(value = "Значение по которому ищем") private Object value; } 

Поскольку поиск может осуществляться не по одному, а по нескольким полям сразу,

можно добавить фильтры для OR и AND, а также общий интерфейс для всех фильтров:

@JsonTypeInfo( use = JsonTypeInfo.Id.NAME, property = "type", include = JsonTypeInfo.As.EXISTING_PROPERTY ) @JsonSubTypes({ @JsonSubTypes.Type(value = OrFilter.class, name = "OR"), @JsonSubTypes.Type(value = AndFilter.class, name = "AND"), @JsonSubTypes.Type(value = SearchCriteria.class, name = "SEARCH") }) public interface Filter { } @Data public class AndFilter implements Filter { private final String type = "AND"; /** * Значение по которому ищем */ @ApiModelProperty(value = "Набор фильтров") private List<Filter> value; } @Data public class OrFilter implements Filter { private final String type = "OR"; /** * Значение по которому ищем */ @ApiModelProperty(value = "Набор фильтров") private List<Filter> value; } 

После того, как фильтры были получены с фронтенда через контроллер, их необходимо передать в сервис, для того, чтобы сформировать объект Specification:

Specification<Workspace> specification = SpecificationCreator.create(filter); Page<Workspace> results = workspaceRepository.findAll(specification, pageable);

Преобразование filter в specification происходит в SpecificationCreator:

@UtilityClass public class SpecificationCreator { public Specification<Workspace> create(Filter filter) { if (filter instanceof OrFilter) { OrFilter orFilter = (OrFilter) filter; List<Specification<Workspace>> specs = createInnerSpecifications(orFilter.getValue()); return createOrSpecification(specs); } else if (filter instanceof AndFilter) { AndFilter andFilter = (AndFilter) filter; List<Specification<Workspace>> specs = createInnerSpecifications(andFilter.getValue()); return createAndSpecification(specs); } else if (filter instanceof SearchCriteria) { SearchCriteria searchCriteria = (SearchCriteria) filter;  return createSearhSpecification(searchCriteria); } return null; } private List<Specification<Workspace>> createInnerSpecifications(List<Filter> value) { List<Specification<Workspace>> specs = Lists.newArrayList(); if (value != null && !value.isEmpty()) { value.forEach(f -> { Specification<Workspace> specification = create(f); specs.add(specification); }); } return specs; } private Specification<Workspace> createAndSpecification(List<Specification<Workspace>> specs) { Specification<Workspace> orSpecification = Specification.where(specs.get(0)); for (int i = 1; i < specs.size(); i++) { orSpecification = specs.get(i).or(orSpecification); } return orSpecification; } private Specification<Workspace> createOrSpecification(List<Specification<Workspace>> specs) { Specification<Workspace> andSpecification = Specification.where(specs.get(0)); for (int i = 1; i < specs.size(); i++) { andSpecification = specs.get(i).and(andSpecification); } return andSpecification; } private Specification<Workspace> createSearhSpecification(SearchCriteria searchCriteria) { return new WorkspaceSpecification(searchCriteria); } } 

Спецификации соединяются друг с другом по условию AND с помощью метода and() и по условию OR с помощью метода or().

Отдельная спецификация представлена классом WorkspaceSpecification, который наследует Specification<E> : 

@RequiredArgsConstructor public class WorkspaceSpecification implements Specification<Workspace> { private final SearchCriteria criteria; @Override public Predicate toPredicate(Root<Workspace> root, CriteriaQuery<?> query,  CriteriaBuilder builder) { Join<Object, Object> join = null; if (criteria.getTable() != null) { join = root.join(criteria.getTable()); } if (Operation.GR.name() .equalsIgnoreCase(criteria.getOperation())) { return builder.greaterThanOrEqualTo( root.<String> get(criteria.getKey()), criteria.getValue().toString()); } else if (Operation.LO.name() .equalsIgnoreCase(criteria.getOperation())) { return builder.lessThanOrEqualTo( root.<String> get(criteria.getKey()), criteria.getValue().toString()); } else if (Operation.EQ.name() .equalsIgnoreCase(criteria.getOperation())) { if (criteria.getTable() != null) { if (join.get(criteria.getKey()).getJavaType() == String.class) { return builder.like( builder.lower(join.get(criteria.getKey())), "%" + criteria.getValue().toString().toLowerCase() + "%"); } else { return builder.equal(join.get(criteria.getKey()), criteria.getValue()); } } else { if (root.get(criteria.getKey()).getJavaType() == String.class) { return builder.like( builder.lower(root.get(criteria.getKey())), "%" + criteria.getValue().toString().toLowerCase() + "%"); } else { return builder.equal(root.get(criteria.getKey()), criteria.getValue()); } } } else if (Operation.IN.name() .equalsIgnoreCase(criteria.getOperation())) { List<String> values = Lists.newArrayList((List<String>)criteria.getValue()); if (criteria.getTable() != null) { return join.get(criteria.getKey()).in(values); } else { return root.get(criteria.getKey()).in(values); } } return null; } }

После того, как спецификация создана, ее можно использовать в репозитории. Для этого репозиторий наследуется от JpaSpecificationExecutor<Workspace>:

public interface WorkspaceRepository extends  JpaRepository<Workspace, String>, JpaSpecificationExecutor<Workspace> { }

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

Преимущества использования Specification:

  • универсальный подход при работе с ui-фильтрами

  • упрощаются репозитории — нет необходимости вообще добавлять методы

  • упрощается логика выбора нужного репозитория

Недостатки реализованного решения:

  • сложно использовать параметризованный код с дженериками

  • хотелось бы чтобы Entity и Repository генерировались автоматически

  • нужен механизм для преобразования конкретного entity в универсальный response.

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


ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/707724/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *