Коллеги, добрый день. Во многих проектах для работы с данными на бекенде используется 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-репозитории.

Комментарии (3)


  1. aleksandy
    27.12.2022 09:07
    +3

    Не надо так делать никогда.

    должно соответствовать названию поля в таблице БД

    Название таблицы, которую присоединяем

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

    join = root.join(criteria.getTable());

    Не взлетит, как только нужно будет фильтровать по нескольким атрибутам одной вложенной сущности.

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

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


    1. poxvuibr
      28.12.2022 15:58

      Да тут впринципе со статьёй что-то не так. Сначала написано, что будем искать книги, у которых название начинается с какой-то строки, но в коде ничего такого нет. Энтити с книгами и авторами нет, есть какой-то Workspace. Ну и так далее


  1. alexdoublesmile
    27.12.2022 09:23

    Не могу понять одного, чем этот метод лучше простого наследования repository от QuerydslPredicateExecutor (вместо наследования JpaSpecificationExecutor)? Ведь в его методы можно передавать адекватные предикаты, которые хотя бы читаемы в отличие от этого хаоса Specification и выглядят как нормальный код (без кучи дженериков, магических названий, констант, приведений типов и пр., не говоря уже про сомнительные правила для клиента, по типу передачи названия полей таблиц в виде строк...? серьезно?). Это, видимо, для м̶̶а̶з̶о̶х̶и̶с̶т̶о̶в̶ фанатов Criteria API способ создания фильтров или тут есть еще какой-то смысл помимо незнания querydsl?