Давайте представим себе, что вы разрабатываете самый обычный REST сервис на стеке Spring, например, с использованием таких зависимостей (build.gradle):
...
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
...
}
Все идет как обычно. Вы пишете репозиторий для сущности User:
public interface UserRepository extends CrudRepository<User, UUID> {
}
Затем пишете самый обычный сервис для работы с пользователем, в котором есть метод для получения пользователя по его Id:
public class UserService {
...
public User getUser(UUID id) {
return userRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
...
}
И, наконец, используете этот метод в вашем REST контроллере, чтобы получать данные пользователя по REST API извне:
@RestController
@RequestMapping("/users")
public class UserController {
...
@GetMapping(path = "/{id}")
public User getUser(@PathVariable UUID id) {
return userService.getUser(id);
}
...
}
Я намеренно не останавливаюсь на настройках подключения Spring к базе данных в application.properties или каким-то иным способом, а также прочих деталях настройки и создания сервиса, предполагая, что вы это уже умеете, база данных у вас есть и доступна, пользователи в ней созданы, класс сущности User также создан в приложении.
Далее наступает момент тестирования приложения. Воспользуемся для этого Postman. Прежде всего, запускаем наше приложение (предположим, мы работаем в Intellij Idea) и видим, что оно успешно стартовало:
Теперь переходим в Postman и пытаемся выполнить GET запрос к методу REST API для получения пользователя, о котором мы наверняка знаем, что он есть в БД, по его Id:
Какой ужас! Мы видим, что вообще не можем получить никакого ответа на наш запрос, даже 500 Interal error! В чем же дело? Ведь это, казалось бы, самый обычный REST сервис. Далее, как уже более-менее опытный junior разработчик, вы снова заходите в консоль Intellij Idea и видите такую ошибку:
Итак, давайте разберемся, в чем дело. По логу мы видим, что у нас есть какие-то сущности Contact и Profile (причем тут они, ведь мы работали с сущностью User?), и Spring не способен сформировать ответ в формате JSON для нашего REST запроса, потому что попадает в бесконечную рекурсию. Почему? Вот теперь начинается самое интересное.
Давайте теперь подробно посмотрим, что такое происходит с нашим пользователем. Прежде всего, заглянем в саму сущность User:
...
@Entity
@Getter
@Setter
@ToString
@RequiredArgsConstructor
public class User {
@Id
@GeneratedValue
private UUID id;
@OneToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "profile_id", referencedColumnName = "id")
private Profile profile;
...
}
Как интересно! Оказывается, данные пользователя хранятся у нас не только в сущности User, но и в привязанной к нему сущности Profile. Вы сделали такую связь один-к-одному, но забыли об этом.
Заглянем в сущность Profile:
...
@Entity
@Getter
@Setter
@ToString
@RequiredArgsConstructor
public class Profile {
@Id
@GeneratedValue
private UUID id;
private String name;
private String surname;
@Column(name = "second_name")
private String secondName;
@Column(name = "birth_date")
private Instant birthDate;
@Column(name = "avatar_link")
private String avatarLink;
private String information;
@Column(name = "city_id")
private Integer cityId;
@OneToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "contact_id", referencedColumnName = "id")
private Contact contact;
@Column(name = "gender_id")
private UUID gender_id;
@OneToOne(mappedBy = "profile")
User user;
}
...
Упс! Помимо «обратного конца» связи один-к-одному профиля с пользователем:
@OneToOne(mappedBy = "profile")
User user;
мы видим, что и сам профиль связан по типу один-к-одному еще с одной сущностью — Contact. То есть вы выделили контакты пользователя также в отдельную сущность. Можем взглянуть и на нее:
@Entity
@Getter
@Setter
@RequiredArgsConstructor
@ToString
public class Contact {
@Id
@GeneratedValue
private UUID id;
private String email;
private String phone;
@OneToOne(mappedBy = "contact")
Profile profile;
}
Ну, кажется, здесь конец цепочки связанных сущностей — больше ни с чем контакт не связан.
Итак, у нас есть три взаимосвязанных сущности — пользователь, профиль и контакт. Не будем на этом подробно останавливаться, но очевидно, что вы и в БД наложили на них соответствующие constraint, примерно как-то так:
alter table users_scheme.user add constraint user_profile_fk foreign key (profile_id) references users_scheme.profile(ID) on delete cascade on update cascade;
alter table users_scheme.profile add constraint profile_contact_fk foreign key (contact_id) references users_scheme.contact(ID) on delete cascade on update cascade;
Именно поэтому вы добавили реализацию этих ограничений в ваши классы сущностей, чтобы Hibernate мог нормально работать с ними. Но он не работает.
О причине, вероятно, вы уже догадались, если вспомнили, что взаимосвязь один-к-одному у нас двухсторонняя. Поэтому при попытке сериализовать экземпляр класса пользователя fasterxml jackson попадает в цикл и не может этого сделать. Вывод — нужно этот цикл каким-то образом обработать, сделав его не бесконечным, или прервать.
Для вас такая ситуация новая, поэтому сразу приведу самое интересное, на мой взгляд, решение, позволяющее получить в ответе на запрос наиболее полную информацию в формате JSON, включая информацию из связанных сущностей.
Существуют и другие способы сделать то же самое, но они работают иначе и могут не сработать вообще или не выдать такую же полную информацию. Поэтому я остановлюсь на том, чтобы показать вам только этот. Возможно, другие способы я еще рассмотрю в других статьях.
Итак, что нужно сделать. Маркируем в каждой из сущностей следующие поля вот такой аннотацией:
//пользователь
@OneToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "profile_id", referencedColumnName = "id")
@JsonIdentityInfo(
generator = ObjectIdGenerators.PropertyGenerator.class,
property = "id")
private Profile profile;
//профиль
@OneToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "contact_id", referencedColumnName = "id")
@JsonIdentityInfo(
generator = ObjectIdGenerators.PropertyGenerator.class,
property = "id")
private Contact contact;
//контакт
@OneToOne(mappedBy = "contact")
@JsonIdentityInfo(
generator = ObjectIdGenerators.PropertyGenerator.class,
property = "id")
Profile profile;
После чего перезапускаем приложение и повторяем запрос в Postman:
Вот теперь все в полном порядке, мы получили нормальный JSON ответ с данными о пользователе из всех трех связанных сущностей. В консоли IDE также ошибок больше нет.
Скоро в OTUS состоится открытое занятие «Аспекты в Java и в Spring», на котором рассмотрим аспекты — что это и зачем нужно; как создавать аспекты в Java, используя разные технологии, и как они используются в Spring. Регистрация для всех желающих — по ссылке.