Давайте представим себе, что вы разрабатываете самый обычный 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. Регистрация для всех желающих — по ссылке.

Комментарии (17)


  1. awfun
    01.08.2022 18:16
    +4

    JPA размазывает трехуровневую архитектуру, создавая сильную связь между моделью и ее представлением в БД. Автор усиливает этот подход и переиспользует класс модели для предоставления api, из-за чего ловит неочевидные проблемы. Следующий проблемный этап возникнет, когда потребуется раздельно изменить структуру БД и апи.


    1. AYamangulov Автор
      01.08.2022 19:26
      +1

      Совершенно верно, такие проблемы есть, и не только они. Если вы понимаете, в чем дело, будете сразу многое делать иначе. Например, даже перепроектируете БД - это решение напрашивается в том числе, как одно из. Однако, есть такое понятие, как постановка задачи или, как говорят физики и математики "граничные условия". Суть его в том, что в определенных обстоятельствах вам необходимо сделать именно так, в таких условиях. Вам не разрешено внешними обстоятельствами изменить начальную задачу, поэтому надо решать в рамках того, что уже есть. На унаследованных проектах такое часто случается. Надеюсь, поможет многим.


  1. IAncientD
    01.08.2022 19:20

    Добрый день. Спасибо за статью. Недавно боролся с аналогичной проблемой. Решил по итогу через@JsonIgnore. Вероятно мой метод более "дендро-фекальный", поэтому хотелось бы побольше узнать про использование @JsonIdentityInfo. Не могли бы вы более развернуто рассказать об этом?


    1. AYamangulov Автор
      01.08.2022 19:30
      +1

      Нет, все абсолютно правильно, ничего "дендро-фекального" в этом нет. Нормальное решение для многих случаев. Просто все зависит от формата данных, которые вам нужно получить. С @JsonIgnoreсвойство будет игнорироваться, вы получите меньше связанных данных. Если этого не требуется в задаче, то все нормально. @JsonIdentityInfoприводит к выдаче максимального количества связанных данных.


  1. zKey
    01.08.2022 19:20
    +10

    Статья ни о чем! Проблемы бы не возникло, если бы использовались DTO. А в любом мало-мальски сложном проекте, никто не использует Entity для отдачи через Rest. Для того, чтобы показать принципы работы сериализатора не обязательно было приплетать Spring и Hibernate. Но если вы считаете, что я не прав, то жду статью "Как получать сущности со связями без поля password в Spring Rest контроллере", где вы расскажите, как пользоваться аннотацией @JsonIgnore.

    Хотя поздно... уже есть товарищи, которые это смогли сделать:

    https://stackoverflow.com/questions/23101260/ignore-fields-from-java-object-dynamically-while-sending-as-json-from-spring-mvc


    1. AYamangulov Автор
      01.08.2022 19:32

      Спасибо, статья не для тех, кто "уже умеет", а для тех, кто только встал на эти грабли )) Ваше предложение интересное, у меня в плане стоит нечто подобное, смотря по обстоятельствам, может быть и напишу. Давайте иметь еще ввиду, что в Интернете полно описаний решения любой проблемы - но в сильно разрозненном виде. Нагуглить можно все, если знать где и как, особенно, если вы свободно читаете по английски - тогда информации в десятки раз больше. Тем не менее, полезно джунам иметь более-менее собранное в кучку описание реального проблемного кейса, с которым люди действительно сталкиваются. Мне не раз задавали вопрос на эту тему коллеги, поэтому тема и была выбрана для статьи, как вызывающая интерес.


      1. panzerfaust
        02.08.2022 09:21
        +6

        статья не для тех, кто "уже умеет", а для тех, кто только встал на эти грабли

        Мне кажется, вам указывают не на сами грабли с подзапросом к связанным сущностям, а на то что вы от имени образовательного ресурса демонстрируете сразу пачку антипаттернов, с последствиями которых героически боретесь. В статье критически не хватает сноски о том, что IRL подобные фокусы с доступом к репо из контроллера просто неприемлемы, а в архитектуре приложения должен главенствовать принцип изоляции и слабой связности. И это не вопрос вкусовщины и разнообразия мнений в интернете, а реальная практика, к которой вы якобы готовите своих студентов.


        1. AYamangulov Автор
          03.08.2022 00:02

          Хм, забавно, и где же вы увидели в приведенном коде доступ к репо из контроллера? По коду статьи конкретно ткните пальцем. Ах, нет такого места? Так я вас поздравляю, батенька, вы просто-напросто соврамши! Там есть обращение только к сервису, а вот сервис уже обращается к репо, и это нормальная архитектура, вполне себе стандартная, обычный классический MVC. То есть, сударь, это вы обычнейший MVC только что обозвали антипаттерном, половина Интернета, думаю, покатывается со смеху ))) Выделение слоя DTO - не обязательный момент в принципе. Просто иногда выделяют слой DTO между контроллером и репо, а иногда - нет. Это скорее вопрос вкусовщины и холиваров, а не реальной пользы в таких простейших случаях, когда данные не нужно трансформировать. DTO полезны только, если вы делаете интеграционный REST API сервис, вот эту задачу они решают - трансформации данных, а не тот бред, который вы написали. Подумать только - добавить три аннотации - "героическая борьба с антипаттернами", кто бы мог подумать )) Работы на полторы минуты, если знать где и для чего )) И в том, что проблема не возникла бы вообще, если использовать DTO вы тоже ошибаетесь, она бы осталась, как была, вы бы просто "спрятали" ее за DTO, на один слой глубже, где ее все равно пришлось бы решить, только другими способами. И кстати, сложнее, чем тремя аннотациями, больше букофф в коде) получить одну сущность без связей, получить отдельно связанные сущности, соединеть все данные в единый объект DTO, и потом уже отдать его... уфф. Не проще - сложнее намного. И оправдано не для таких простых случаев, как я описал, а для сложных интеграционных сервисов, где и DTO как раз вполне оправданы.


  1. vladi_geras
    01.08.2022 23:01
    +2

    Есть такая property, связанная с функционалом Open Session In View, которая по умолчанию включена и которую нужно выключать.

    Данный подход, когда entity попадает в слой контроллера и возвращается в ответе (минуя конвертацию в DTO) сломается в случае, когда у данной сущности будет Lazy дочерняя коллекция. Потому что при сериализация Jackson будут выполняться доп запросы к БД для получения этих самых объектов коллекции, и будет ошибка LazyInitializationException: No Session.

    Все же лучше было бы правильно данный сервис в статье написать, а не надеяться, что читатель знает все тонкости


    1. AYamangulov Автор
      03.08.2022 00:17
      +1

      entity не являются частью слоя repo, поэтому вполне допускается получать их в контроллерах и не только получать, но даже передавать в методы контроллеров в качестве параметра, это обычный data маппинг, используется широко. Если у сущности lazy дочерняя коллекция - никто не помешает вам модифицировать ситуацию и поставить вместо @JsonIdentityInfo - @JsonIgnore, и это уже - другая частная ситуация, не такая, как описана в статье, не нужно чрезмерно обобщать описанную методику на случаи, когда следует применять другие подходы. Отдельный слой DTO-entity можно и нужно применять, если у вас есть резонная причина скрыть состав полей entity от внешней среды, в которой будет работать данный REST API, если таких причин нет - вы получите избыточное усложнение архитектуры, не оправданное текущей частной задачей. Например, вам нужен небольшой микросервис, который будет отдавать свой REST API в изолированной локальной сети, а не большой апи для публичного доступа из Интернета. А статья, кстати, решает именно ОДНУ ЧАСТНУЮ ЗАДАЧУ. Почему-то все, кому не лень, пытаются доказать всему миру и автору статьи, что он обязан был привести единственное универсальное решение, пригодное на все случаи применения REST API? Может быть, сразу написать единое уравнение суперполя Вселенной, объясняющее все? )))


      1. vladi_geras
        03.08.2022 05:51

        Но ведь статью встают новички, которые будут переносить это решение в реальный код, не понимая тонкостей, которые были опущены автором.


        1. AYamangulov Автор
          03.08.2022 10:47
          +1

          Вот поэтому я и написал:

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

          Чтобы новички были настороже, прямое и явное предупреждение, что "мир велик и не ограничивается одним домом и одной калиткой". И действительно, при удобном случае опишу и иные ситуации. Слона надо есть по частям - он слишком большой. Статьи для того и делаются статьями, чтобы не писать целую книгу сразу, хотел бы описать все случаи - написал бы книгу. Люди так и делают, когда хотят хоть сколько-то полно описать какой-то стек или отдельную технологию. А требовать от маленькой статьи, рамки которой ограничены определенным размером, той же полноты, что от книги или подробного туториала по всему стеку - это несколько избыточно. Мой главный критерий всегда - помочь людям, если кто-то пишет "спасибо, помогло", мне уже приятно - не зря работал. Я понимаю, что в Хабре очень много персонажей, которые ставят своей целью, например, повышение рейтинга за счет холиваров, или самореализацию глубокой внутренней потребности безнаказанно нагадить авторам статей, наслаждаясь воображаемой буйной внутренней реакцией у них ("какой я плохой, ха-ха-ха, хочется быть дьяволом!!! пусть помучается автор от моего комментария, как я его подцепил, э?!!!") Но мне это безразлично, я просто помогаю людям там, где могу, делаю мое дело, и все. Мне наплевать, если кто-то пришел сюда, чтобы выпендриться и самоутвердиться за счет других. Уверяю всех, у кого эта психопатология действительно проявляется, что в моей душе даже малейшего раздражения на их выходки не возникает, не первый день на свете живу, повидал таких персонажей "будь здоров", они мне безразличны (не буду называть конкретных людей, зачем? - те, у кого это есть, сами прекрасно осознают, в чем их проблема). ИМХО - философия кота Леопольда это лучшая философия в мире. Ну уж если очень достанут, могу выпить таблетку "озверина", как Леопольд в мультфильме. Но это действует недолго )))


  1. GerrAlt
    03.08.2022 17:06
    +1

    Подскажите пожалуйста, а вот без этого:

    @Getter

    @Setter

    @RequiredArgsConstructor

    @ToString

    не заработает?

    Если даже оставить за скобками претензии относительно использования модели в контроллере для представления данных ваша статья все еще плоха для новичков там, что содержит абсолютно не обязательные для воспроизведения вещи, которые зачем-то там присутствуют, и никак не комментируются.


    1. AYamangulov Автор
      03.08.2022 18:52
      -1

      Ну батенька, это уж совсем не к чему придраться? Это стандартные аннотации lombok, можете использовать вместо них кучу бойлерплейта, конкретно к теме статьи прямого отношения не имеют, но нужны, конечно же, в самом приложении для его нормальной работы, просто лень было их вырезать при копипасте из проекта. Многие, когда пишут куски кода в статьях, так вообще приводят полные тексты классов со всеми импортами, длиннющую такую простыню, и ничего ))


      1. vladi_geras
        03.08.2022 20:00

        Вопрос про краткие пояснения) вот используется аннотация ToString из Lombok. На данном примере, действительно, ее использование в таком виде ничего не предвещает, но все стоит написать, что связи из ToString стоит исключать, чтобы не получить неожиданные side эффекты


      1. vladi_geras
        03.08.2022 20:31

        Статья же для тех, «кто только встал на эти грабли» (то есть новички)


        1. AYamangulov Автор
          04.08.2022 08:53
          -1

          Ну все-таки не для таких "новичков", которые совсем уж с полного нуля начинают) те, кто дополз хотя бы до проектов на Spring - это уже junior. У нас так не принято в градациях, но, например, за бугром тех, кого вы назвали "новичками", называют даже не junior, а starter, и отправляют учить основы языка и стека и читать документацию. Те, кто наступил на эти грабли у нас уже, скажем так "продвинутые новички", и о lombok имеют определенное представление, почитают сами или найдут еще одну очень короткую статью, где описаны и эти грабли тоже - коротко, только по существу и без лишней "воды" и "спотыканий" на попутные замечания.

          @ToStringдействительно, имеет смысл подправить в том случае, если вы 1) явно задали какие-то поля, как lazy loaded (в этом "кусочке слона" их нет) 2) предполагаете пакетную обработку больших данных (опять же, для личных данных пользователей такое не предвидится, с ними всегда работают индивидуально). Поэтому в этой статье не считаю это обязательным - опять получится "растекашеся мыслию по древу" (то есть будем отвлекаться на попутные детали, не имеющие прямого отношения к теме статьи) Опять же, это вполне подходит для отдельной статьи для джунов. Ну если очень кратко, то в таких случаях самое простое на lazy полях можно добавить дополнительно аннотацию @ToString.Exclude. Но и это вызовет side эффект - если вам все же в результатах операции toString к этой сущности необходимо, чтобы была какая-то информация о lazy поле, добавьте ее вручную каким-то простым и безопасным способом, переопределив в классе сущности метод toString ручками, без аннотаций вообще.

          Видите, сколько деталей пришлось описать даже в "кратком пояснении"? А теперь представьте себе, что вы "запинаетесь" в каждом таком интересном месте. И вот новичок читает эту статью, тоже на каждом таком "пояснении" запинается и думает "упс, вероятно, это важно для этого кейса, без этого работать вообще не будет, раз автор написал об этом тоже" и начинает реплицировать эти детали у себя в проекте, утопая в них по самые уши )) Зачем? Это нужно выносить в отдельную тему в отдельной статье - или писать книгу, в крайнем случае, большой туториал с продолжениями на несколько страниц. А краткие статьи тем и ценны, что человек наступает на грабли, гуглит, находит короткий рецепт, делает, получает результат. Потом, вполне возможно, наступает на следующие грабли, опять находит короткую статью, снова решает проблему, и так далее. Очень рациональный подход, большинство вопросов-ответов на stackoverflow, например, приведены именно в таком ключе. Очень разумно и по делу, без "воды".