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