Отношения One To Many в проекте Java Spring (Hibernate)

Я давно хотел сопровождать своё изучение Java и связанных с ней инструментов написанием статей. Во-первых, это помогает мне закреплять материал. Во-вторых, может помочь другим начинающим (или не очень) программистам. И тема отношений One to Many показалась мне достойной для начала этой рубрики. Потому что я сам потратил достаточно времени над изучением этого вопроса и поиска ошибок в своём коде.

Задача довольно простая: нужно связать две сущности отношением один ко многим (one-to-many). Сначала я опишу таблицы в своей реляционной базе данных, а потом уже перейду к реализации кода соответствующих классов.

Подготовка Базы Данных

В своей практике я решил попробовать сделать музыкальный интернет-магазин с REST API (это отлично подходит и к другой теме моего блога), поэтому работать буду с сущностями Артист, Альбом и Релиз. У одного артиста может выйти множество альбомов, а каждый альбом сопровождается множеством релизов (у каждого свой каталожный номер): например, один релиз на CD, другой – на виниле, а третий через пару лет, потому что спрос был слишком большим.

one album to many releases:
альбом Rumours в моей коллекции представлен в нескольких разных изданиях на разных форматах

В результате мы получаем три сущности, которые связаны отношениями один-ко-многим, как показано на диаграмме.

Так как проект ненастоящий, а учебный, то многие ограничения я опустил, да и саму базу использовал встроенную h2. Это всё должно было несколько упростить разработку. Чтобы добавить базу в проект, необходимо дописать зависимость в pom-файле:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

Код ниже подойдёт для создания таблиц:

CREATE TABLE ARTIST(
  ARTIST_ID BIGINT AUTO_INCREMENT PRIMARY KEY,
  NAME VARCHAR(36) NOT NULL,
  COUNTRY CHAR(2),
  BIO VARCHAR(256)
);

CREATE TABLE ALBUM(
  ALBUM_ID BIGINT AUTO_INCREMENT PRIMARY KEY,
  TITLE VARCHAR(64),
  ARTIST_ID BIGINT,
  YEAR INT,
  INFO VARCHAR(1000)
);

CREATE TABLE RELEASE(
  RELEASE_ID BIGINT AUTO_INCREMENT PRIMARY KEY,
  ALBUM_ID BIGINT NOT NULL,
  RELEASE_DATE DATE,
  FORMAT VARCHAR (5),
  NOTES VARCHAR (20),
  LABEL VARCHAR(64),
  PRICE INT,
  IMG VARCHAR(100)
);

Для создания внешних ключей нужно добавить строчки:

ALTER TABLE RELEASE ADD FOREIGN KEY (ALBUM_ID) REFERENCES ALBUM(ALBUM_ID);
ALTER TABLE ALBUM ADD FOREIGN KEY (ARTIST_ID) REFERENCES ARTIST(ARTIST_ID);

Одностороннее отношение @OneToMany с аннотацией @JoinColumn

Одностороннее отношение между сущностями оформляется с помощью аннотаций @OneToMany и @JoinColumn. Чтобы показать, что одному артисту в моей базе соответствует сразу несколько альбомов, необходимо в класс добавить поле List<Album> albums. Вместо List можно использовать другую коллекцию – Set, если вам она больше по душе.

Но сейчас нам интересна не сама коллекция, а аннотации над ней. @OneToMany звучит весьма прямолинейно: она указывает на то, что это поле – часть отношения один-ко-многим. А вот @JoinColumn подсказывает Hibernate, с какой конкретно колонкой в таблице базы данных связано это поле. В моём случае оно связано с колонкой ARTIST_ID.

Ниже представлен класс Artist. И так как я подключил lombok, то геттеры, сеттеры и конструкторы можно не писать. В результате класс получается очень компактным:

@Entity
@Data
public class Artist {

    @Id
    @GeneratedValue
    private long artistId;

    private String name;

    private String country;

    private String bio;

    @OneToMany
    @JoinColumn(name = "ARTIST_ID")
    List<Album> albums = new ArrayList<>();

}

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

@Entity
@Data
public class Album implements Serializable {

    @Id
    @GeneratedValue
    private long albumId;

    private String title;

    private int year;

    private String info;

    @OneToMany
    @JoinColumn(name = "ALBUM_ID")
    List<Release> releases;

    public String getArtistName() {
        return artist.getName();
    }

}

На самом деле, обойтись можно и без аннотации @JoinColumn, но это не особо хорошая практика, потому что за кулисами такая простая связь превращается в многие-ко-многим, создаётся дополнительная «транзитная» таблица, которая занимает место. Конечно, в моём проекте с полупустой базой я бы не заметил никаких неудобств, но всё же. Нужно сразу же учиться лучшим практикам.

Двустороннее отношение @OneToMany или @ManyToOne

Двустороннее / двунаправленное (оно же bidirectional) отношение реализовать не намного сложнее. Нужно добавить пару строчек кода и предоставить больше информации в обоих классах, т. е. обозначить владельца отношений. Если выбирать между Artist и Album, то владельцем оказывается альбом, а в отношениях между Album и Release – релиз.

Во владельце мы обозначаем отношение аннотацией @ManyToOne («много альбомов к одному артисту» – всё логично) над полем типа Artist. То есть поле должно быть объектом класса, которым «владеет» в этих отношениях наша главная сторона.

А вот @OneToMany сопровождается в этом случае атрибутом mappedBy, после которого нужно указать имя того самого поля из стороны-владельца (то есть artist из класса Album). А аннотация @JoinColumn (всё ещё необязательная) перемещается в класс владельца.

Звучит сложно, надеюсь, в коде будет понятнее. Ниже представлен код всех трёх классов, как они выглядели, когда я разобрался со всеми ошибками и получил рабочий проект.

@Entity
@Data
public class Artist {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long artistId;

    private String name;

    private String country;

    private String bio;

    @OneToMany(mappedBy = "artist",
            fetch = FetchType.LAZY,
            cascade = CascadeType.ALL)
    List<Album> albums = new ArrayList<>();

    public void addAlbum(Album album) {
        albums.add(album);
        album.setArtist(this);
    }

    public void removeAlbum(Album album) {
        albums.remove(album);
        album.setArtist(null);
    }
}
@Entity
@Data
public class Album {

    @Id
    @GeneratedValue(strategy=GenerationType.IDENTITY)
    private long albumId;

    private String title;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name="ARTIST_ID", nullable = false)
    @JsonIgnore
    private Artist artist;

    private int year;

    private String info;

    @OneToMany(mappedBy = "album",
            fetch = FetchType.LAZY,
            cascade = CascadeType.ALL)
    List<Release> releases;

    public void addRelease(Release release) {
        releases.add(release);
        release.setAlbum(this);
    }

    public void removeRelease(Release release) {
        releases.remove(release);
        release.setAlbum(null);
    }

    @Override
    public boolean equals(Object o) {
        if ( this == o ) {
            return true;
        }
        if ( o == null || getClass() != o.getClass() ) {
            return false;
        }
        Album album = (Album) o;
        return albumId == album.albumId;
    }

    @Override
    public int hashCode() {
        return Objects.hash( albumId );
    }
}
@Entity
@Data
public class Release {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long releaseId;

    @ManyToOne
    @JoinColumn(name="ALBUM_ID")
    @JsonIgnore
    private Album album;

    private Date releaseDate;

    private String format;

    private String notes;

    private String label;

    private int price;

    private String img;
}

Обратите внимание, что я добавил атрибут FetchType.LAZY, так как пишут, что так надо. Мол, лучше для производительности. Я сам не проверял и не сравнивал, но поверил и вас тоже ставлю перед фактом.

Также стоит добавить, что для целостности нужно добавить в родительские сущности методы add и remove, чтобы добавлять и удалять данные из списков и устанавливать значения соответствующим полям. Поэтому в коде появились addAlbum и removeAlbum в классе Artist и addRelease и removeRelease в классе Album.

А аннотация @JsonIgnore появляется, чтобы при запросе информации не получить бесконечный цикл, в котором в релизе окажется вложенный родительский альбом, в который будет вложен список релизов, в каждый из которых будет вложен родительский альбом, в который будет вложен список релизов…

И напоследок разберу пару ошибок, которые заставили меня пострадать некоторое время и хорошенько погуглить.

Мои ошибки при настройке @OneToMany и @ManyToOne

Ошибка первая. Слепое копирование структуры базы данных.

Сообщение об ошибке: org.springframework.beans.factory.BeanCreationException: Error creating bean with name ‘entityManagerFactory’ defined in class path resource [org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]: Invocation of init method failed; nested exception is java.lang.NullPointerException

Я долго гуглил и нашёл разные варианты этой ошибки, с разным вложенным исключением (NullPointerException ни у кого не видел), а потом оказалось, что в классах @Entity я наивно скопировал всю структуру таблиц из базы данных. И вместо того, чтобы в альбоме хранить объект типа Artist, я пытался хранить там long artistId – только внешний ключ, как в базе данных. На осознание этого у меня ушло довольно много времени.

Ошибка вторая. Невнимательность в базовых вопросах.

Не относится непосредственно к Spring/Hibernate/JPA, но я был невнимателен при написании метода equals, из-за чего добавление новых объектов происходило некорректно, и в итоге ничего не работало и крашилось с NullPointerException.

Зачем-то я сравнивал объект, который нужно добавить в список List, с самим собой. А мораль такова: даже если вы делаете задачи новые, интересные и даже сложные, нельзя упускать из внимания фундаментальные мелочи. Я довольно долго искал ошибку, но даже не думал, что она может быть именно в этом методе – мол, чего там можно было написать не так?


О том, как делать сервисы и контроллеры, я напишу в другой раз. Закончу рассказ об этом проекте с альбомами и релизами. А на сегодня, пожалуй, хватит. Для первой статьи получилось очень даже объёмно.

Следующие статьи:

Составной первичный ключ в JPA/ Hibernate

Реклама

6 Comments

  1. Спасибо за блог!
    В статьях можно найти разъяснения моментов с которыми сталкиваются новички и которые у других не найдешь, им то все очевидно)

    Нравится

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

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход /  Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход /  Изменить )

Connecting to %s