Отношение @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 — а для этого нам надо иметь объект Topic. Решается проблема с помощью встроенного метода — getById(),который возвращает не сам топик, а прокси. То есть он реально не обращается к базе данных и не выполняет select. Попробуем:
@Transactional public Comment addToTopicUsingGetById(long topicId, String text) { Topic topic = topicRepository.getById(topicId); Comment comment = new Comment(); comment.setTopic(topic); comment.setText(text); comment = commentRepository.save(comment); return comment; }
Теперь Hibernate генерирует только один insert.
Извлечение комментария
Теперь решим задачу извлечения комментария по его 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 важно помнить пару нюансов, чтобы не ухудшить производительность:
- Не использовать findById() там, где достаточно getById().
- Использовать join fetch, чтобы избежать n+1 проблемы.
Отношение ManyToOne удобное (особенно unidirectional, как у нас), стоит его почаще использовать при проектировании сущностей.
Исходный код есть на GitHub.
Метод getOne() помечен как Deprecated, в комментарии к методу написно, что нужно вместо него использовать getById(). Надо бы обновить статью, а статья годная 😉
Спасибо, обновлено.