Spring Data JPA и Hibernate: решаем проблему динамически изменяемого запроса к базам данных

Изображение — Mohammad Rahmani  — Unsplash.com

Привет, Хабр! Меня зовут Николай Пискунов, я ведущий разработчик в подразделении Big Data. В блоге beeline cloud мои коллеги уже знакомили вас с SOLID, заглядывали под капот Python. Я же расскажу о том, как с наименьшими затратами получить динамически изменяемый запрос к БД, используя Spring Data JPA и Hibernate. 

Самый простой пример — реализация эндпоинта, который обрабатывает запросы фильтра интернет-магазина. Например, когда вы задаете характеристики, согласно которым возвращаются определенные товары.

Определяемся с задачами по уровням

Level 1

Что дано: фронт, с которого в бэк на Spring boot летят запросы, а бэк, в свою очередь, должен сделать соответствующую выборку в БД Postgresql и вернуть результат на фронт.

Если кто не помнит, Spring boot — это фреймворк на java, заточенный под веб. Хотя с его помощью решается множество других задач.

Предположим, что у нас есть база данных, а в ней одна простая таблица:

34c9c5c9181e9e34cca649cdc520d887.png

 Из нее для фронта мы должны отдать записи, отфильтрованные по полю fooFieldOne. Для этого в Spring Data JPA реализован довольно приятный синтаксис:

public interface FooEntityRepository extends JpaRepository {
   List findByFooFieldOne(String fooFieldOne);
}

При вызове метода findByFooFieldOne Hibernate конструирует запрос, эквивалентный нативному SQL-запросу:

 SELECT * FROM foo WHERE foo_field_one = «blabla»;

 В итоге мы получим массив элементов, которые удовлетворяют запросу, и после обработки мы возвращаем данные на фронт.

Если же потребуется фильтровать данные по двум полям, то Spring boot JPA также порадует. Ничего сложного:  

public interface FooEntityRepository extends JpaRepository {
   List findByFooFieldOne(String fooFieldOne);
 
   List findByFooFieldOneAndFooFieldTwo(String fooFieldOne, String fooFieldTwo);
}

Level 2

А теперь к тому, что было дано выше, мы добавляем дополнительную таблицу bar, которая связана с таблицей foo. Структура БД станет вот такой:

d9d4ee9587ba2fe0620dc7fb51349c3a.png

Теперь фронту понадобится фильтрация по полю bar_field_one. На этот случай Spring Data JPA предоставляет аннотацию @Query, с помощью которой мы сможем расширить функционал методов нашего репозитория следующим образом:

public interface FooEntityRepository extends JpaRepository {
 
   @Query(value = """
       SELECT f.* FROM foo f
       JOIN bar b on f.id = b.foo_id
       WHERE f.foo_field_one = ?1 and b.bar_field_one = ?2
  """, nativeQuery = true)
   List findByFooFieldOne(String fooFieldOne, String barFieldOne);
 
   @Query(value = """
       SELECT f.* FROM foo f
       JOIN bar b on f.id = b.foo_id
       WHERE f.foo_field_one = ?1 and f.foo_field_two = ?2 and b.bar_field_one = ?3
  """, nativeQuery = true)
   List findByFooFieldOneAndFooFieldTwo(String fooFieldOne, String fooFieldTwo, String barFieldOne);
}

Как видим, достаточно знать SQL, чтобы получить нужный фронту результат.

 Level 3

Но что, если нам нужно получать данные на основе динамически заполняемых полей? То есть с фронта нам может прийти запрос, где заполнены не все поля.

 Конечно, мы можем проработать все варианты заполненных полей примерно следующим образом:  

public interface FooEntityRepository extends JpaRepository {
 
   List findByFooFieldOne(String fooFieldOne);
 
   List findByFooFieldTwo(String fooFieldTwo);
 
   List findByFooFieldOneAndFooFieldTwo(String fooFieldOne, String fooFieldTwo);
 
   @Query(value = """
       SELECT f.* FROM foo f
       JOIN bar b on f.id = b.foo_id
       WHERE f.foo_field_one = ?1 and b.bar_field_one = ?2
  """, nativeQuery = true)
   List findByFooFieldOneAndBarFieldOne(String fooFieldOne, String barFieldOne);
 
   @Query(value = """
       SELECT f.* FROM foo f
       JOIN bar b on f.id = b.foo_id
       WHERE f.foo_field_two = ?1 and b.bar_field_one = ?2
  """, nativeQuery = true)
   List findByFooFieldTwoAndBarFieldOne(String fooFieldTwo, String barFieldOne);
 
   @Query(value = """
       SELECT f.* FROM foo f
       JOIN bar b on f.id = b.foo_id
       WHERE f.foo_field_one = ?1 and f.foo_field_two = ?2 and b.bar_field_one = ?3
  """, nativeQuery = true)
   List findByFooFieldOneAndFooFieldTwoAndBarFieldOne(String fooFieldOne, String fooFieldTwo, String barFieldOne);
 
  @Query(value = """
       SELECT f.* FROM foo f
       JOIN bar b on f.id = b.foo_id
       WHERE b.bar_field_one = ?1
  """, nativeQuery = true)
   List findByBarFieldOne(String barFieldOne);
}

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

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

Для решения этой проблемы предусмотрена аннотация @PersistenceContext. Это значит, что мы сможем вытащить из контекста Hibernate«овый EntityManager.

Предположим, что все поля фильтра из запроса складываются в dto:

@Data
@Builder
@FieldDefaults(level = AccessLevel.PRIVATE)
public class GetParams {
   String fooFieldOne;
   String fooFieldTwo;
   String barFieldOne;
}

И, создав дополнительный репо-класс, нам нужно реализовать требуемый запрос к БД:

@Repository
public class FooEntityRepository {
   @PersistenceContext
   EntityManager entityManager;
 
   public List findFoos(GetParams params) {
   Map parameterMap = new HashMap<>();
   StringBuilder builder = new StringBuilder();
 
   // WHERE 1 = 1 требуется на случай, если с фронта не будет получено никаких значений фильтра
   StringBuilder whereBuilder = new StringBuilder("WHERE 1 = 1").append(System.lineSeparator())
 
   // Первая строка селекта
   builder
       .append("SELECT f.* FROM foo f ")
       .append(System.lineSeparator())
  
   // Добавим условие «если было передано поле fooFieldOne»
   if (StringUtils.isNotBlank(params.getFooFieldOne)) {
      whereBuilder
       .append("AND f.foo_field_one = :fooFieldOne")
       .append(System.lineSeparator());
      parameterMap.put("fooFieldOne", params.getFooFieldOne());
   }
 
   // Добавим условие «если было передано поле fooFieldTwo»
   if (StringUtils.isNotBlank(params.getFooFieldTwo)) {
      whereBuilder
       .append("AND f.foo_field_two = :fooFieldTwo")
       .append(System.lineSeparator());
      parameterMap.put("fooFieldTwo", params.getFooFieldTwo());
   }
  
   // Приджойним таблицу bar и добавим условие «если было передано поле barFieldOne»
   if (StringUtils.isNotBlank(params.getBarFieldOne)) {
      builder
       .append("JOIN bar b on f.id = b.foo_id")
       .append(System.lineSeparator())
 
      whereBuilder
       .append("AND b.bar_field_one = :barFieldOne")
       .append(System.lineSeparator());
      parameterMap.put("barFieldOne", params.getBarFieldOne());
   }
  
   // Соберем и подготовим весь заброс
   builder
   	.append(whereBuilder);
   String nativeQuery = builder.toString();
  
   Query query = entityManager.createNativeQuery(nativeQuery, RentCarCarEntity.class);
   for (Map.Entry entry : parameterMap.entrySet()) {
      nativeQuery = nativeQuery.replace(":" + entry.getKey(), entry.getValue().toString());
      query.setParameter(entry.getKey(), parameterMap.get(entry.getKey()));
   }
   log.debug("\n{}\n", nativeQuery);
  
   // Выполним и вернем результат
   return query.getResultList();
}

В результате мы получаем динамически сформированный нативный запрос к БД, который вернет требуемый результат на фронт.

А если у нас появятся дополнительные поля для фильтрации или дополнительные таблицы и связки, и, тем более, потребуется использование SQL-операторов order, having или получать данные постранично, то лучше переписать этот класс, используя один из порождающих паттернов проектирования.

Ну, а если количество запросов возрастет до 100 в секунду и выше, то лучше внедрять Redis и Elastic. Хотя в моей практике были случаи использования исключительно мощностей БД, которая обрабатывала подобные запросы при нагрузке в ~300 rps.

Другие статьи по разработке

Как развернуть CRM за 7 шагов? CRM помогает компании провести клиентов от знакомства до формирования лояльности, не теряя по пути информацию и автоматизируя часть процесса. Рассказываем, как за 7 шагов внедрить CRM-платформу так, чтобы сотрудники не перестали ей пользоваться.

Почему middle- и senior-разработчикам нужно участвовать в хакатонах? Если вы считаете, что хакатоны — конкурсы только для начинающих специалистов, пришло время изменить мнение. Рассказываем, какую пользу участие в хакатонах может принести продвинутым разработчикам. Создать что-то новое, приобрести полезные контакты, получить признание — только небольшая часть аргументов.

Как Jmix помогает создавать бизнес-приложения? Jmix — открытый фреймворк, в основе которого лежат Java и Spring Boot. Он помогает разрабатывать высокопроизводительные приложения, работать с данными и проектировать интерфейс. В материале рассказываем о том, почему стоит выбрать фреймворк, какие преимущества он дает при работе с клиентами и как его применять на практике.

beeline cloud — secure cloud provider. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.

© Habrahabr.ru