Hibernate и Spring Boot

Вернуться назад

20 мая 2018

Ранее мы уже рассматривали, как работать с базой данных через jdbc в статье Работа с БД в Spring Boot на примере postgresql. А сегодня возьмём Hibernate - самый популярный фреймворк для работы с БД - и убедимся, что он значительно облегчает реализацию типовых операций над сущностями.

Предположим, в БД у нас есть две сущности: страна и город. В одной стране может быть несколько городов (отношение «один-ко-многим»). Структура таблиц выглядит примерно так:

CREATE SEQUENCE country_id_seq;

CREATE TABLE country
(
  id integer NOT NULL DEFAULT nextval('country_id_seq'::regclass),
  name character varying(50NOT NULL,
  CONSTRAINT country_id_pk PRIMARY KEY (id)
);

CREATE SEQUENCE city_id_seq;

CREATE TABLE city
(
  id integer NOT NULL DEFAULT nextval('city_id_seq'::regclass),
  name character varying(50NOT NULL,
  country_id integer NOT NULL
);

И мы хотим совершать типовые действия над этими сущностями: просмотр всего списка, поиск по id, добавление, обновление и удаление записей. Для этого создадим типовой Spring Boot проект. В pom-файле нужно прописать следующий parent:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>1.5.12.RELEASE</version>
</parent>

Зависимости также стандартные:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.2.2.jre7</version>
</dependency>
<dependency>
    <groupId>javax.xml.bind</groupId>
    <artifactId>jaxb-api</artifactId>
    <version>2.3.0</version>
</dependency>

spring-boot-starter-web нужен для функциональности rest-контроллера, spring-boot-starter-data-jpa - для работы с БД. В качестве СУБД мы выберем postgres, поэтому нам понадобится драйвер для работы с этой СУБД.

Этот pom-файл и остальные исходники вы сможете посмотреть на github (ссылка в конце статьи).

Main-класс также абсолютно стандартный:

@SpringBootApplication(scanBasePackages = "ru.devmark")
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Параметр scanBasePackages указывает, какой пакет вместе со вложенными пакетами сканировать для создания spring-бинов.

В Hibernate класс, который мапится на таблицу в БД, называется сущностным классом (entity). Для таблицы country такой класс будет выглядеть следующим образом:

@Entity
@Table(name = "country")
public class Country {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    private String name;

    @OneToMany(mappedBy = "country", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<City> cities;

    // далее идут геттеры и сеттеры
}

Аннотация @Entity говорит Hibernate о том, что этот класс является сущностью. Аннотация @Table позволяет указать название таблицы в БД, которая связана с этой сущностью. Если имя класса и имя таблицы совпадает, то эту аннотацию можно не указывать. @Id указывает на то поле, которое является уникальным идентификатором сущности. @GeneratedValue позволяет задать несколько различных способов генерации этого идентификатора. В нашем случае мы используем GenerationType.IDENTITY, т.к. на уровне БД используем последовательности (sequence), которые заполняют это значение при вставке новой записи автоматически.

Аннотация @OneToMany задаёт отношение «один-ко-многим» между страной и городом, т.е. в одной стране может быть несколько городов. Поэтому такая аннотация всегда вешается на коллекцию (список). Параметр mappedBy указывает поле в сущности City, которое содержит ссылку на свой родительский объект (страну). Параметр cascade указывает, что действия по модификации родительского объекта также затрагивают и все дочерние. Параметр orphanRemoval указывает, что при удалении родительского объекта нужно также удалить все «осиротевшие» дочерние объекты.

Сущность City содержит обратную аннотацию:

@Entity
@Table(name = "city")
public class City {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    private String name;

    @ManyToOne(cascade = CascadeType.ALL)
    @JoinColumn(name = "country_id")
    private Country country;

    @JsonIgnore
    public Country getCountry() {
        return country;
    }

   // остальные геттеры и сеттеры

@ManyToOne является обратной по отношению к @OneToMany и располагается в дочерней сущности. @JoinColumn указывает имя внешнего ключа в БД, по которому происходит связь с родительской сущностью.

Также обратите внимание, что на get-методе для поля country стоит аннотация @JsonIgnore. Если её не указать, то при просмотре вложенных в страну городов в формате json будет сгенерён очень большой json из-за того, что объекты указывают друг на друга. Поскольку мы хотим видеть города вложенными в страну, то просто не будем выводить информацию о стране в дочерних сущностях.

Параметры подключения к БД указываем в файле настроек, который можно положить либо в папку resources нашего maven-проекта, либо указать путь до него в командной строке через параметр --spring.config.location. Я предлагаю хранить настройки в yaml формате в файле application.yml:

spring:
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://localhost:5432/example_db
    username: example_user
    password: secret_password
  jpa:
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQL9Dialect

Yaml - это формат для хранения иерархии настроек. Если вам привычнее обычные текстовые файлы, добавьте вместо него равноценный файл application.properties:

spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://localhost:5432/devmark_ru
spring.datasource.username=devmark
spring.datasource.password=secret_password
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQL9Dialect

Здесь мы указываем драйвер для нашей СУБД, урл самой БД, логин и пароль к ней, а также диалект Hibernate, который учитывает специфику postgres.

Чтение данных

Создадим сервис для работы с БД:

@Repository
@Transactional
public class CountryDao {

    @PersistenceContext
    private EntityManager entityManager;

    public List<Country> getAll() {
        return entityManager.createQuery("from Country c order by c.id desc", Country.class).getResultList();
    }

    public Country getById(int id) {
        return entityManager.find(Country.class, id);
    }
}

@Repository говорит о том, что это слой взаимодействия с БД. @Transactional делает каждый публичный метод транзакционным. @PersistenceContext подставляет имеющийся в контексте выполнения EntityManager - сервис, который позволяет все основные манипуляции с сущностями.

Далее объявляем два метода: просмотр всех стран и поиск страны по её id. В первом методе мы создаём запрос, очень похожий на sql, но на самом деле это hql - Hibernate Query Language. Hql, в отличие от sql, оперирует сущностями в стиле ООП. Метод поиска по id использует стандартный метод find(), который принимает тип сущности и значение id. Как видите, чтение записей из БД происходит буквально в одну строку!

Создадим rest-контроллер, который на вход будет получать запросы в формате json:

@RestController
@RequestMapping(value = "/api", produces = MediaType.APPLICATION_JSON_VALUE)
public class GeoController {

    @Autowired
    private CountryDao countryDao;

    @GetMapping("/countries")
    public List<Country> getAllCountries() {
        return countryDao.getAll();
    }

    @GetMapping("/countries/{countryId}")
    public Country getCountryById(@PathVariable("countryId"int id) {
        return countryDao.getById(id);
    }
}

@RequestMapping позволяет указать базовый урл, на который следует слать rest-запросы. Параметр produces в данном случае указывает, что ответы на запросы также будут в формате json. При помощи @Autowired подгружаем наш репозиторий для работы с БД.

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

@GetMapping помечает те методы, которые будут обрабатывать GET-запросы, а @PathVariable позволяет использовать часть урла в качестве параметра запроса (в данном случае это числовой id страны).

Добавление новых записей

Давайте теперь добавим в наш репозиторий метод для добавления новой записи в БД:

public Country create(Country country) {
    for (City city : country.getCities()) {
        city.setCountry(country);
    }
    entityManager.persist(country);
    return country;
}

Поскольку на вход вместе со страной будут приходить вложенные сущности городов, причём только с указанием их имён, мы должны сначала привязать каждый новый город к текущей стране, а затем вызвать метод persist(), передав ему родительскую сущность. Этот метод вызывается только для создания новых записей и благодаря правильно размеченным сущностям вместе с родительской в БД будут добавлены и дочерние!

В контроллер также добавим соответствующий метод:

@PostMapping("/countries")
@ResponseStatus(HttpStatus.CREATED)
public Country createCountry(@RequestBody Country country) {
    return countryDao.create(country);
}

@PostMapping указывает на обработчик POST-запросов, а @ResponseStatus задаёт http-код ответа на данный запрос. Согласно архитектуре rest это не 200, а 201 - Created. @RequestBody указывает, какой класс будет использоваться для маппинга тела запроса. В нашем случае это класс Country.

Теперь мы готовы к тому, чтобы добавить страну вместе с городами! Запустим приложением и выполним следующий post-запрос по адресу http://127.0.0.1:8080/api/countries/. Не забудьте также указать заголовок Content-Type: application/json.

{
   "name""Россия",
   "cities":[{"name":"Москва"}]
}

Если всё сделано правильно, в двух наших таблицах появится по одной записи. Обратите внимание, что для обоих сущностей мы указываем только имена. В ответ вы получите ту же самую сущность, только с указанием id, которое было присвоено записи при добавлении в БД. Это очень удобная возможность, которая работает в Hibernate «из коробки».

Для просмотра списка всех стран с городами достаточно отправить get-запрос на адрес http://127.0.0.1:8080/api/countries/.

Обновление записей

Теперь давайте разберёмся с обновлением. Будем считать, что при обновлении мы можем изменить название страны, а связанные с ней города каждый раз будем удалять и добавлять заново. Метод в репозитории будет выглядеть так:

public Country update(int id, Country country) {
    Country original = entityManager.find(Country.class, id);
    if (original != null) {
        original.setName(country.getName());
        for (City city : original.getCities()) {
            entityManager.remove(city);
        }
        original.getCities().clear();
        for (City city : country.getCities()) {
            city.setCountry(original);
            original.getCities().add(city);
            entityManager.persist(city);
        }
        entityManager.merge(original);
    }
    return original;
}

Сначала пробуем подгрузить по указанному id уже существующую в БД сущность вместе с городами. Если нашли такую, то устанавливаем новое имя, затем проходимся по всем существующим городам и удаляем их при помощи метода entityManager.remove(). Заметьте, что для удаления записи достатчно передать связанный с ней объект. Затем очищаем список городов и добавляем новые, которые пришли к нам в запросе. Для каждого города вызываем метод persist(). В конце вызываем метод merge() для родительской сущности. Этот метод нужно использовать только для обновления уже существующей записи.

Добавим соответствующий метод в контроллер:

@PutMapping("/countries/{countryId}")
public Country updateCountry(@PathVariable("countryId"int id, @RequestBody Country country) {
    return countryDao.update(id, country);
}

Согласно rest-архитектуре, для обновления данных используется PUT-запрос, о чём говорит аннотация @PutMapping.

Тело запроса на обновление по формату точно сопадает с запросом на добавление новой записи, но слать его нужно на урл, который будет содержать id обновляемой страны. Например, http://127.0.0.1:8080/api/countries/27.

Удаление записей

В завершение жизненного цикла нашей записи осталось добавить метод удаления. В репозитории он будет выглять так:

public void delete(int id) {
    Country country = entityManager.find(Country.class, id);
    if (country != null) {
        entityManager.remove(country);
    }
}

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

На уровне контроллера метод выглядит так:

@DeleteMapping("/countries/{countryId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteCountry(@PathVariable("countryId"int id) {
    countryDao.delete(id);
}

@DeleteMapping указывает на обработчик DELETE-запроса. @ResponseStatus в этом методе говорит, что в ответ на запрос будет приходить статус 204 - «No content» и пустое тело запроса. Урл запроса также содержит id удаляемой записи, как и в случае с обновлением.

Выводы

Благодаря Hibernate мы реализовали основные виды операций над сущностями буквально в одну строку. А благодаря Spring Boot сделали это согласно rest-архитектуре. Аннотации @OneToMany и @ManyToOne задают связь между сущностями, что позволяет легко манипулировать всеми связнными сущностями в рамках вызова одного метода.

При этом Hibernate скрывает от вас детали sql-запроса, что делает ваш код лёгким для понимания и не зависимым от реализации конкретной СУБД. Вы легко можете перейти с postgres на mysql или oracle, поменяв буквально пару строк кода в файле настроек.

Тэги: Hibernate, Java, PostgreSQL, Spring, Spring Boot, maven, rest.


Исходники