Отношение ManyToOne в Hibernate и Spring

Отношение @ManyToOne – самое популярное. В этой статье рассмотрим, как с ним работать максимально эффективно.  А именно: как правильно добавить комментарий к топику, зная id топика (не извлекая топик из базы). И как выбрать комментарий с топиком, избежав n+1 проблемы (уже было, но еще раз со Spring).

Модель

Пример простой и уже знакомый в контексте рассмотрения n+1 проблемы – топик и комментарии. В базе таблицы выглядят так:

Таблицы в базе
Таблицы в базе

У одного топика может быть много комментариев, и каждый комментарий относится ровно к одному топику.

Вышеприведенная схема генерируется Hibernate если поставить в классе Comment аннотацию @ManyToOne:

@Entity
public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private long id;
    private String text;
    @ManyToOne(fetch = FetchType.LAZY)
    private Topic topic;
    // getters/setters/constructors
}

Отношение у нас unidirectional, то есть в классе Topic ссылка на коллекцию Comment отсутствует:

@Entity
public class Topic {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private long id;
    private String title;
    // getters/setters/constructors
}

Добавление комментария

Задача такая: имея id топика, добавить к нему еще один комментарий.

Распространенная ошибка – найти топик и извлечь его из базы с помощью findById, а затем сохранить комментарий с найденным topic:

@Service
public class CommentService {
    @Autowired
    private TopicRepository topicRepository;
    @Autowired
    private CommentRepository commentRepository;

    @Transactional
    public Comment addToTopicUsingFindById(long topicId, String text) {
        Optional<Topic> topic = topicRepository.findById(topicId);
        Comment comment = new Comment();
        comment.setTopic(topic.get());
        comment.setText(text);
        comment = commentRepository.save(comment);
        return comment;
    }

   ...
}

Получается два оператора: select и insert:

select topic0_.id as id1_1_0_, topic0_.title as title2_1_0_ 
   from topic topic0_ 
where topic0_.id=?

insert into comment (text, topic_id, id) values (?, ?, ?)

Хотя глядя на структуру базы и сам insert, ясно, что достаточно одного insert. Ведь для добавления комментария нужен только id топика (который у нас уже есть), а не весь Topic.

Однако сущности Comment надо назначить Topic. Решается проблема с помощью встроенного метода – getOne(), который возвращает прокси. То есть он реально не обращается к базе данных и не выполняет select:

@Transactional
public Comment addToTopicUsingGetOne(long topicId, String text) {
    Topic topic = topicRepository.getOne(topicId);
    Comment comment = new Comment();
    comment.setTopic(topic);
    comment.setText(text);
    comment = commentRepository.save(comment);
    return comment;
}

Теперь Hibernate генерирует только один insert.

Методу getOne() репозитория соответствует getReference() в EntityManager – он делает то же самое.

Извлечение комментария

Теперь решим задачу извлечения комментария по его id.

Обратите внимание, что мы выбрали стратегию LAZY:

@ManyToOne(fetch = FetchType.LAZY)
private Topic topic;

Это сделано для того, чтобы при выборе комментария вместе с ним по умолчанию не извлекался топик с помощью второго оператора select (а именно так и будет, ведь по умолчанию в @ManyToOne стратегия EAGER).

Но допустим, нам в самом деле нужно выбрать комментарий вместе с топиком. В этом случае тоже можно избежать второго оператора select. Для этого нужно написать запрос с использованием JOIN FETCH, который извлекат топик вместе с комментарием в едином запросе:

public interface CommentRepository extends JpaRepository<Comment, Long> {
    @Query("select c from Comment c join fetch c.topic where c.id=:id")
    Optional<Comment> findWithJoinFetch(long id);
}

Протестируем запрос и увидим в консоли ровно один оператор select:

@Test
@DisplayName("если метод findWithJoinFetch, то один select")
public void givenQuery_whenFetchCommentWithTopic_thenTwoSelects(){
    Optional<Comment> comment=commentRepository.findWithJoinFetch(-4l);
    Assertions.assertTrue(comment.isPresent());
    Assertions.assertEquals("title1", comment.get().getTopic().getTitle());
}

SQL в консоли:

select comment0_.id as id1_0_0_, topic1_.id as id1_1_1_, comment0_.text as text2_0_0_, 
comment0_.topic_id as topic_id3_0_0_, topic1_.title as title2_1_1_ 
    from comment comment0_ 
    inner join topic topic1_ on comment0_.topic_id=topic1_.id 
where comment0_.id=?

Тогда как если бы мы использовали встроенный метод findById() репозитория, то обращение к топику comment.get().getTopic() потянуло бы выполнение второго select из таблице Topic:

@DataJpaTest
public class CommentRepositoryTest {
    @Autowired
    private CommentRepository commentRepository;

    @Test
    @DisplayName("если метод FindById, то два select")
    public void givenMethod_whenFetchCommentWithTopic_thenOneSelect(){
        Optional<Comment> comment=commentRepository.findById(-4l);
        Assertions.assertTrue(comment.isPresent());
        Assertions.assertEquals("title1", comment.get().getTopic().getTitle());
    }
....
}

Генерируемые два оператора select:

select comment0_.id as id1_0_0_, comment0_.text as text2_0_0_, 
comment0_.topic_id as topic_id3_0_0_ 
   from comment comment0_ where comment0_.id=?


select topic0_.id as id1_1_0_, topic0_.title as title2_1_0_ 
   from topic topic0_ where topic0_.id=?

Как уже упоминалось, подробнее об этом есть отдельная статья – как избежать n+1 проблемы – там пример рассмотрим на чистом Hibernate без Spring.

Итоги

Таким образом, при работе с отношением ManyToOne важно помнить пару нюансов, чтобы не ухудшить производительность:

  1. Не использовать findById() там, где достаточно getOne().
  2. Использовать join fetch, чтобы избежать n+1 проблемы.

Отношение ManyToOne удобное (особенно unidirectional, как у нас), стоит его почаще использовать при проектировании сущностей.

Исходный код есть на GitHub.

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

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