Статьи
YouTube-канал

Hibernate и Spring Boot

Исходники

20 мая 2018

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

Содержание

  1. Чтение данных
  2. Добавление новых записей
  3. Обновление записей
  4. Удаление записей
  5. Выводы

Ранее мы уже рассматривали, как работать с базой данных через 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(50) NOT 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(50) NOT 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, поменяв буквально пару строк кода в файле настроек.


Облако тэгов

Kotlin, Java, Java 16, Java 11, Java 10, Java 9, Java 8, Spring, Spring Boot, Spring Data, SQL, PostgreSQL, Oracle, Linux, Hibernate, Collections, Stream API, многопоточность, файлы, Nginx, Apache, maven, gradle, JUnit, YouTube, новости, руководство, ООП, алгоритмы, головоломки, rest, GraphQL, Excel, XML, json, yaml.

Последние статьи


Комментарии

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

×

devmark.ru