В проекте Spring обязательно присутствует папка model. Она содержит в себе классы-сущности (entity). Без них никуда – это кирпичики back-end-приложения. Именно они представляют собой данные. Для удобного создания сущностей используется фреймворк Hibernate. Будем разбираться, что к чему. Приступим к наполнению первой из папок структуры Spring-проекта!
Что такое entity
В структуре Spring-проекта, entity – это сущность в контексте работы с БД, это сырые данные. Сущность – это объект, который отражён в базе данных. Сущностью может быть книга (класс-сущность Book) со своим названием, автором, годом издания (поля String title, String author, int publishYear). Это может быть пользователь (класс User) или статья (класс Article). И многие подобные понятия. Так проще представлять данные и оперировать ими. В общем, данные без поведения, так что никакого ООП.
В общем говоря, сущность – это класс, где хранятся данные. Причём эти классы соответствуют таблицам в базе данных. Один класс – одна таблица. Один объект этого класса – одна строка из соответствующей таблицы.
Но как данные из таблиц привести к объектам? Успокойтесь, нам не нужно это делать ручками. Ведь у нас есть Hibernate!
Базовые аннотации Hibernate
Hibernate – это фреймворк для работы с базами данных в среде Java. Он представляет данные в виде объектов Java. Главная цель Hibernate – облегчить и ускорить процесс разработки приложений, связанных с базами данных.
И это отлично. Это помогает программистам-разработчикам бэк-энда жить. Можно удобно прописать классы-сущности вместо многословного кода конвертаций туда-сюда, который включал бы ещё и написание запросов SQL. Да, вы правильно понимаете: нам не придётся писать никакого SQL-кода, хоть мы и работаем с базами данных!
Давайте рассмотрим базовые аннотации, которые предоставляет нам Hibernate.
Entity
Во-первых, каждая сущность должна быть помечена аннотацией @Entity:
1 2 3 4 |
@Entity public class User { ... } |
Так ваш Spring-проект поймёт, что этот класс есть сущность, и будет обращаться с его объектами соответствующе.
Table
В JPA и Hibernate по умолчанию имя таблицы формируется на основе имени класса, но оно приводится к стандартному формату базы данных. В разных СУБД может быть разное поведение. Обычно в большинстве баз данных имена таблиц приводятся к нижнему регистру. Если класс назван User, то таблица будет названа user.
Если же вы хотите указать другое название таблицы, нужно указать его явно при помощи аннотации @Table:
1 2 3 4 5 |
@Entity @Table(name = "users") public class User { ... } |
Id и GeneratedValue
Теперь приступим к содержимому класса. У таблиц, как правило, есть уникальный столбец – первичный ключ – идентификатор. Для сущности Hibernate он вообще обязателен. И задаётся он аннотацией @Id перед полем класса. Она помечает, что поле является идентификатором. Также прописывается, что это поле генерируется автоматически (аннотация @GeneratedValue). Эта аннотация используется, чтобы сказать, как именно нужно генерировать значения для первичного ключа сущностей (а можно по-разному).
1 2 3 4 5 6 7 8 9 |
@Entity @Table(name = "users") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; ... } |
По умолчанию стоит GenerationType.AUTO. Часто используется GenerationType.IDENTITY. Но давайте поговорим о всех способах генерации уникальных значений, об их отличиях и где они используются.
GenerationType.AUTO. Часто является хорошим выбором, так как Hibernate самостоятельно выбирает стратегию генерации, опираясь на базу данных. Обычно используется автоинкрементируемый столбец (т.е. аналогично действию GenerationType.IDENTITY).
1 |
@GeneratedValue(strategy = GenerationType.AUTO) |
GenerationType.IDENTITY. Использует автоинкрементируемый столбец в базе данных. Этот подход часто используется с СУБД, поддерживающими автоинкремент. Однако, может вызвать проблемы в случае, если требуется сохранение и получение идентификаторов до фактической вставки записей.
1 |
@GeneratedValue(strategy = GenerationType.IDENTITY) |
GenerationType.SEQUENCE. Использует последовательность базы данных (если она поддерживается). Требует определения генератора последовательности.
1 |
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "your_sequence_generator") |
GenerationType.TABLE. Использует таблицу базы данных для хранения и управления значениями идентификаторов. Как правило, медленнее в сравнении с другими стратегиями, так как таблица генерации требует дополнительных запросов. Может быть полезен, если в базе данных не поддерживаются последовательности.
1 |
@GeneratedValue(strategy = GenerationType.TABLE, generator = "your_table_generator") |
Column
Как и в случае с названием класса, проект по умолчанию считает, что столбцы в таблице называются так же, как и поля класса. Если это не так, можно задать другие названия столбцов, соответствующих полям класса:
1 2 3 4 5 6 7 8 |
@Entity @Table(name = "users") public class User { ... @Column(name = "first_name") private String firstName; } |
Temporal
Наверняка рано или поздно вам понадобится хранить дату и время в базе данных. Тогда пригодится и аннотация @Temporal:
1 2 3 4 5 6 7 8 |
@Entity @Table(name = "users") public class User { ... @Temporal(TemporalType.DATE) private Date eventDate; } |
Она указывает, каким именно образом нужно хранить дату/время. По умолчанию – TemporalType.TIMESTAMP. TemporalType может принимать 3 значения:
- TemporalType.TIMESTAMP – Значение TIMESTAMP используется для сохранения полной метки времени, включая и дату, и время. Это наиболее общий случай и используется, когда важны как дата, так и время.
- TemporalType.DATE – Значение DATE используется, когда вам нужно сохранить только дату без времени. Например, если у вас есть поле, представляющее дату рождения, и вам не важен конкретный момент времени в этот день.
- TemporalType.TIME – Значение TIME используется, когда вам нужно сохранить только время без даты. Например, если у вас есть поле, представляющее время проведения события, и вам не важна дата.
Transient
Последняя базовая аннотация Hibernate, которую мы рассмотрим, это @Transient. Аннотация @Transient используется для пометки поля, которое не должно быть сохранено в базе данных. Если поле помечено как @Transient, Hibernate проигнорирует его при выполнении операций CRUD (create, read, update, delete). Это может быть полезно, например, для временных или вычисляемых полей, которые не должны быть отражены в таблице базы данных.
1 2 3 4 5 6 7 8 |
@Entity @Table(name = "products") public class Product { ... @Transient private double calculatedPrice; } |
Требования к entity
Нельзя просто взять и пометить класс как @Entity. Ему ещё надо соответствовать определённым требованиям. Ваша среда разработки даже для некоторых требований будет подсказывать, подчёркивать класс красным, мол, не соответствует.
Итак, требования к классу-сущности:
- Аннотация @Entity. Класс, который представляет сущность, должен быть помечен аннотацией @Entity. Это указывает Hibernate, что этот класс представляет объект, который может быть сохранен в базе данных.
- Присутствие первичного ключа (@Id). У сущности должно быть поле, которое является первичным ключом. Это поле должно быть помечено аннотацией @Id.
- Пустой конструктор. Класс сущности должен иметь пустой конструктор. Hibernate использует его для создания объектов при чтении из базы данных.
- Геттеры и сеттеры для всех полей. Чтобы Hibernate мог правильно установить значения полей и получить их при работе с объектами, должны быть предоставлены геттеры и сеттеры для всех полей сущности.
- Соответствие типов данных. Типы полей сущности должны соответствовать типам данных в базе данных. Если у вас в столбце name в БД хранится строка, вы не можете в классе написать поле Integer name.
Соблюдайте эти правила и всё будет хорошо.
Между делом, всякие конструкторы, геттеры и сеттеры можно прописывать не ручками и даже не автоматической генерацией кода среды разработки. Нет. Можно сделать так, чтобы этот код не мозолил глаза и не отвлекал от главного. А делается это при помощи библиотеки Lombok. Например, одна аннотация @Getter над классом – и у всех полей есть геттеры. Одна аннотация @Setter – … Ну, вы сами догадались.
Аннотации связей между таблицами
Куда же без связей между таблицами? Ведь каждый мало-мальски серьёзный Spring-проект имеет таблицы, ссылающиеся на другие таблицы. Как нам в этом деле поможет Hibernate?
Сначала узнаем про параметры, которые постоянно используются при различных связях. Это атрибуты cascade и fetch.
Cascade
Параметр cascade (каскадирование) определяет, нужно ли распространять всякие операции текущей сущности на помечаемое поле. Попроще: у вас сущность собака Dog, которая хранит у себя в поле ссылку на связанную сущность хозяина Owner. Если вы в этом поле хозяину поменяете имя, а потом сохраните сущность Dog, то таблица dog в БД в любом случае обновится, но обновлять ли таблицу owner?
Именно за это отвечает cascade. И вот какие значения у него есть:
- По умолчанию – если параметр cascade не задан, тогда каскадирование не применяется вовсе, и в случае с собакой и хозяином таблица owner никак бы не изменилась, хотя в сущности собаки имя хозяина и было изменено.
- CascadeType.ALL – самая частая вместе с предыдущей. Каскадирование всех операций: обновления свежими данными из базы (REFRESH), создания (PERSIST), удаления (REMOVE), сохранения сущностей (MERGE), а также отсоединения сущностей друг от друга (DETACH). В случае с собачкой обновится и таблица owner тоже, хотя напрямую запрос на обновление сущности Owner послан не был (а только обновление сущности Dog, которая имеет ссылку на Owner).
- CascadeType.REFRESH – каскадирование обновления данных сущностей свежей информацией из базы.
- CascadeType.PERSIST – каскадирование создания новых сущностей.
- CascadeType.REMOVE – каскадирование удаления сущностей.
- CascadeType.MERGE – каскадирование сохранения изменений сущностей в базе данных.
- CascadeType.DETACH – каскадирование отсоединения сущностей друг от друга. Например, если у собачки Dog поставить другого хозяина Owner в поле, то в базе данных предыдущий хозяин должен отсоединиться, больше не ссылаться на эту собаку.
Эти значения можно использовать как по одному:
1 |
cascade = CascadeType.REFRESH |
Так и сразу массивом:
1 |
cascade = {CascadeType.REFRESH, CascadeType.PERSIST} |
Fetch
Параметр fetch определяет, как информация из связанных сущностей будут извлекаться из базы данных: сразу при получении этой сущности, или попозже.
- FetchType.LAZY означает отложенную загрузку данных. Например, когда объект Dog загружается, связанный с ним объект Owner не будет автоматически загружен из базы данных. Вместо этого данные Owner будут загружены только тогда, когда вы явно обратитесь к полю owner.
- FetchType.EAGER означает мгновенную (жадную) загрузку данных. Связанный объект (поле owner) будет загружен сразу же вместе с родительским объектом (объектом класса Dog) при запросе.
Значение по умолчанию (если не задано) для fetch – это FetchType.LAZY для связей @OneToMany и @ManyToMany, и FetchType.EAGER для связей @OneToOne и @ManyToOne. Это логично: когда надо подгружать только один объект, можно это сделать и сразу. Но когда там целое множество объектов – не, давайте загрузим только при необходимости.
С cascade и fetch более-менее разобрались. Можно будет их применять ниже, не так сильно удивляясь, что происходит в коде. Теперь давайте рассмотрим использование Hibernate во всех трёх ситуациях связей: один к одному, один ко многим и многие ко многим.
One to one
Один к одному – то есть каждой одной сущности соответствует единственная другая, и наоборот. Например, каждому пользователю User соответствует его собственный зашифрованный пароль EncryptedPassword, который хранится в другой таблице. В таблице user есть столбец password_id, который указывает на первичный ключ таблицы encrypted_password.
Вы можете хранить в объектах обоих сущностей ссылки друг на друга, как в примере ниже. А можете и только в одной: например, чтобы класс User имел поле encryptedPassword, но класс EncryptedPassword не имел поля user.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@Entity public class User { ... @OneToOne @JoinColumn(name = "password_id") private EncryptedPassword password; } @Entity public class EncryptedPassword { ... @OneToOne(mappedBy = "password", cascade = CascadeType.ALL) private User user; } |
Причём из-за параметра cascade = CascadeType.ALL при сохранении сущности User, изменения в поле password также будут сохраняться в таблицу паролей.
Так как параметр fetch не указан, то по умолчанию для связи @OneToOne он равен FetchType.EAGER – жадная загрузка, то есть данные пароля подгружаются в объект пользователя сразу же. И наоборот.
One to many
Один ко многим – это связь, когда одному объекту определённой сущности соответствует множество объектов другой сущности. Например, сущности запись Post и комментарий Comment. Одной записи соответствует множество комментариев. Хранить данные о связи в БД будет именно таблица comment. У неё столбец post_id – идентификатор записи, к которой принадлежит комментарий.
В коде это выражается так (опять же, здесь обе сущности имеют ссылки друг на друга, но это не всегда обязательно):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@Entity public class Post { ... @OneToMany(mappedBy = "post") private List<Comment> comments; } @Entity public class Comment { ... @ManyToOne @JoinColumn(name = "post_id") private Post post; } |
Здесь параметр cascade по умолчанию пуст. Это означает, что изменения, сделанные в поле post объекта Comment, не повлияют на таблицу post. Чтобы отразить изменения в таблице post, придётся явно взять объект post и запросить для него сохранение изменений. А не сделать это через сохранение объекта Comment.
Many to many
Самая сложная связь – это многие ко многим. Означает это, что каждому объекту первой сущности может соответствовать множество объектов второй сущности, и в то же время каждому объекту второй сущности может соответствовать множество объектов первой сущности.
Рекомендуется минимизировать её использование по возможности, так как с другими двумя связями управляться попроще. Но не всегда возможно избежать использования many to many.
Итак, возьмём за пример студентов и курсы. Каждый студент может посещать множество курсов. Вместе с тем, каждый курс может посещаться множеством студентов. Many to many налицо. Получается, есть таблица student и таблица course со своими первичными ключами id. Но двумя таблицами тут не обойтись. Нужна третья таблица-посредник, называемая student_course. Она будет хранить соответствия между студентами и курсами. У неё будут столбцы student_id и course_id.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
@Entity public class Student { ... @ManyToMany @JoinTable( name = "student_course", joinColumns = @JoinColumn(name = "student_id"), inverseJoinColumns = @JoinColumn(name = "course_id")) private List<Course> courses; } @Entity public class Course { ... @ManyToMany(mappedBy = "courses") private List<Student> students; } |
Создание классов сущностей
Итак, в статье про настройку БД и её подключение к проекту мы создали 2 таблицы: users и posts. Теперь на основе них будем создавать классы-сущности.
Вот так выглядят столбцы таблицы users. Их всего 4, и на основе их названий и типов данных мы создадим класс User. Пока что просто запишем туда эти поля, без создания связи с таблицей posts.
С помощью аннотаций Hibernate и сил библиотеки Lombok удалось создать такой ёмкий класс User:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@Data @NoArgsConstructor @Entity @Table(name = "users") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String email; private String password; private String username; } |
Благодаря аннотациям @Data и @NoArgsConstructor не нужно писать геттеры, сеттеры, конструкторы, а также перегружать методы equals(), hashCode() и toString(). Так как поля называются ровно так же, как и столбцы, нет нужны помечать их аннотациями @Column и дополнительно указывать название столбца.
Теперь создадим класс для сущности Post, таблица posts. Пока что также не будем прописывать связь с таблицей users: просто поля, соответствующие столбцам таблицы в базе данных.
Итак, класс Post:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@Data @NoArgsConstructor @Entity @Table(name = "posts") public class Post { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; private String text; @Temporal(TemporalType.TIMESTAMP) private ZonedDateTime publishingTime; } |
Между делом, аннотация @Temporal здесь указана для большей явности. На самом деле, так как у @Temporal значение по умолчанию и так TIMESTAMP, можно было не указывать это явно.
А теперь давайте добавим связь между этими двумя сущностями. Пользователь User связан с записью Post связью one to many – один ко многим. Один пользователь может иметь множество постов (записей). Давайте сделаем так, чтобы и пользователь, и запись знали друг о друге:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
@Data @NoArgsConstructor @Entity @Table(name = "users") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String email; private String password; private String username; @OneToMany(mappedBy = "user") private List<Post> posts = new ArrayList<>(); } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
@Data @NoArgsConstructor @Entity @Table(name = "posts") public class Post { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne @JoinColumn(name = "user_id") private User user; private String title; private String text; @Temporal(TemporalType.TIMESTAMP) private ZonedDateTime publishingTime; } |
Вот и готовы наши сущности User и Post. И они друг о друге знают.
Кстати говоря, из-за такой вот взаимной связи, может возникать рекурсия и бум – StackOverflowError, одна из иерархии ошибок и исключений в Java. Объекты будут постоянно подгружать друг друга до бесконечности, так и не отдав ответ серверу. Для нейтрализации такого эффекта либо выдают ссылку на другую сущность только одному из двух классов, либо используют секретный приём в виде DTO и mapper-ов. Но это – уже совсем другая история.