Encodera

Спасибо, что читаете!

Пожертвуйте сайту, мы стараемся ради вас.

Оглавление

Связи между сущностями

Автор статьи пока хранится строкой "anna". Если Анна сменит имя, придётся править все её статьи. В базах данных такие вещи решают связями: в таблице статей хранится номер пользователя (внешний ключ), а сам пользователь лежит в своей таблице. JPA описывает связи аннотациями, и Hibernate сам делает нужные соединения таблиц.

Пользователь

// src/main/java/com/example/blog/User.java
@Entity
@Table(name = "users")
public class User {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false, unique = true)
    private String username;
 
    protected User() {
    }
 
    public User(String username) {
        this.username = username;
    }
 
    public Long getId() {
        return id;
    }
 
    public String getUsername() {
        return username;
    }
}

Таблица называется users, потому что user — зарезервированное слово во многих базах. Регистрацию через POST /api/users с полем username сделайте так же, как создание статьи: запись NewUserRequest, UserRepository и контроллер.

Статья ссылается на автора

В Post поле author меняет тип со строки на User, и появляется список комментариев:

// src/main/java/com/example/blog/Post.java
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private User author;
 
@OneToMany(mappedBy = "post", cascade = CascadeType.REMOVE)
private List<Comment> comments = new ArrayList<>();
АннотацияСмысл
@ManyToOneМного статей у одного автора; в таблице posts появится столбец author_id
fetch = FetchType.LAZYАвтор загружается из базы, только когда к нему обратились
@OneToMany(mappedBy = "post")Обратная сторона: список комментариев; ключ хранится в comments.post_id
cascade = CascadeType.REMOVEПри удалении статьи удаляются и её комментарии

Комментарий

// src/main/java/com/example/blog/Comment.java
@Entity
@Table(name = "comments")
public class Comment {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false, length = 1000)
    private String text;
 
    @Column(nullable = false)
    private Instant createdAt;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private Post post;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private User author;
 
    protected Comment() {
    }
 
    public Comment(String text, Post post, User author, Instant createdAt) {
        this.text = text;
        this.post = post;
        this.author = author;
        this.createdAt = createdAt;
    }
 
    // геттеры getId(), getText(), getCreatedAt(), getPost(), getAuthor()
}

Hibernate создаст таблицы с внешними ключами:

Hibernate: create table comments (id bigint generated by default as identity, created_at timestamp(6) with time zone not null, text varchar(1000) not null, author_id bigint not null, post_id bigint not null, primary key (id))
Hibernate: alter table if exists comments add constraint FKh4c7lvsc298whoyd4w9ta25cr foreign key (post_id) references posts

Запросы через связь

В имени метода можно пройти по связи: findByAuthorUsernameOrderByCreatedAtDesc(String username) найдёт статьи по имени автора — Spring сам соединит posts и users. Для комментариев к статье:

// src/main/java/com/example/blog/CommentRepository.java
public interface CommentRepository extends JpaRepository<Comment, Long> {
 
    List<Comment> findByPostIdOrderByCreatedAt(Long postId);
}

В NewPostRequest строка author заменяется на @NotNull Long authorId, а PostResponse.from берёт имя через post.getAuthor().getUsername().

Ленивый автор читается из базы при первом обращении, поэтому соединение должно быть открыто, пока работает метод сервиса. Добавьте над классами PostService и CommentService аннотацию @Transactional(readOnly = true) из пакета org.springframework.transaction.annotation, а над методами, которые меняют данные (create, update, delete, add), — просто @Transactional. Подробно разберём это в уроке о транзакциях.

Вложенный адрес

Комментарии принадлежат статье, поэтому их адрес начинается с адреса статьи:

// src/main/java/com/example/blog/CommentController.java
@RestController
@RequestMapping("/api/posts/{postId}/comments")
public class CommentController {
 
    private final CommentService commentService;
 
    public CommentController(CommentService commentService) {
        this.commentService = commentService;
    }
 
    @GetMapping
    public List<CommentResponse> list(@PathVariable Long postId) {
        return commentService.findByPost(postId);
    }
 
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public CommentResponse add(@PathVariable Long postId, @Valid @RequestBody NewCommentRequest request) {
        return commentService.add(postId, request);
    }
}

CommentService.add находит статью и автора через репозитории, создаёт Comment и сохраняет его. Проверка:

curl -X POST http://localhost:8080/api/posts/1/comments \
  -H "Content-Type: application/json" -d '{"text":"Отличная статья!","authorId":2}'
curl http://localhost:8080/api/posts/1/comments
{"id":1,"text":"Отличная статья!","author":"oleg","createdAt":"2026-10-04T08:59:23.244858700Z"}
[{"id":1,"text":"Отличная статья!","author":"oleg","createdAt":"2026-10-04T08:59:23.244859Z"}]

Частая ошибка

Если вернуть из контроллера саму сущность Post, Jackson попробует прочитать ленивого автора уже после закрытия соединения с базой:

Could not write JSON: Could not initialize proxy [com.example.blog.User#1] - no session

Клиент получит 500. Поэтому ответы собираются в записи PostResponse и CommentResponse внутри сервиса, пока соединение открыто.

Спасибо, что читаете!

Пожертвуйте сайту, мы стараемся ради вас.