Я давно хотел сопровождать своё изучение Java и связанных с ней инструментов написанием статей. Во-первых, это помогает мне закреплять материал. Во-вторых, может помочь другим начинающим (или не очень) программистам. И тема отношений One to Many показалась мне достойной для начала этой рубрики. Потому что я сам потратил достаточно времени над изучением этого вопроса и поиска ошибок в своём коде.
Задача довольно простая: нужно связать две сущности отношением один ко многим (one-to-many). Сначала я опишу таблицы в своей реляционной базе данных, а потом уже перейду к реализации кода соответствующих классов.
Подготовка Базы Данных
В своей практике я решил попробовать сделать музыкальный интернет-магазин с REST API (это отлично подходит и к другой теме моего блога), поэтому работать буду с сущностями Артист, Альбом и Релиз. У одного артиста может выйти множество альбомов, а каждый альбом сопровождается множеством релизов (у каждого свой каталожный номер): например, один релиз на CD, другой – на виниле, а третий через пару лет, потому что спрос был слишком большим.

альбом 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, с самим собой. А мораль такова: даже если вы делаете задачи новые, интересные и даже сложные, нельзя упускать из внимания фундаментальные мелочи. Я довольно долго искал ошибку, но даже не думал, что она может быть именно в этом методе – мол, чего там можно было написать не так?
О том, как делать сервисы и контроллеры, я напишу в другой раз. Закончу рассказ об этом проекте с альбомами и релизами. А на сегодня, пожалуй, хватит. Для первой статьи получилось очень даже объёмно.
Следующие статьи:
Спасибо за блог!
В статьях можно найти разъяснения моментов с которыми сталкиваются новички и которые у других не найдешь, им то все очевидно)
НравитсяНравится
спасибо) пишу как отчёт того, что я сам узнал, поэтому наверно так и получается
НравитсяНравится