CrudRepository в Spring Data

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

27 мая 2018

В статье Hibernate и Spring Boot мы рассматривали использование Hibernate для того, чтобы не писать sql-запросы в слое доступа к данным. Сегодня мы пойдём ещё дальше и рассмотрим, как Spring Data может генерировать за вас сам слой доступа к данным со всеми методами, которые вам нужны в сервисном слое.

В качестве примера возьмём сущность «Страна» с её названием в качестве единственного параметра и на примере этой сущности шаг за шагом создадим все необходимые операции для поиска, добавления, редактирования и удаления этой сущности. В СУБД postgres надо создать следующую таблицу:

CREATE TABLE country
(
  id serial,
  name character varying(50NOT NULL,
  CONSTRAINT country_id_pk PRIMARY KEY (id)
);

Теперь создадим типовой maven-проект и добавим в pom.xml необходимые зависимости. Полную версию файла можно посмотреть на github, ссылка в конце статьи.

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

<dependencies>
    <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>
</dependencies>

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

Точка запуска приложения имеет стандартный для Spring Boot вид:

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

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

Параметр scanBasePackages говорит Spring, какие пакеты сканировать для поиска и создания бинов.

Начнём со слоя доступа к данным. В Hibernate сущность, которая мапится на нашу таблицу, будет выглядеть так:

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

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

    private String name;

    // далее идут get- и set-методы...
}

@Entity говорит о том, что это - класс-сущность. @Table позволяет в явном виде задать имя таблицы в БД, но если имя класса совпадает с названием таблицы, то эту аннотацию можно не указывать. @Id указывает на поле, которое будет идентификатором сущности. В таблице это поле должно быть помечено как primary key. @GeneratedValue позволяет задать способ генерации этого идентификатора. В нашем случае мы используем то значение, которое будет присваиваться в БД. Поле name совпадает с названием поля в базе, поэтому явно имя колонки указывать не требуется.

Spring Data предоставляет абстракцию CrudRepository, которая типизируется целевой сущностью и её id. CrudRepository имеет набор базовых методов для работы с сущностью, названия которых говорят сами за себя:

<S extends TS save(S var1);

<S extends T> Iterable<S> save(Iterable<S> var1);

T findOne(ID var1);

boolean exists(ID var1);

Iterable<T> findAll();

Iterable<T> findAll(Iterable<ID> var1);

long count();

void delete(ID var1);

void delete(T var1);

void delete(Iterable<? extends T> var1);

void deleteAll();

Нам достаточно расширить этот интерфейс, добавить сигнатуры необходимых нам методов и Spring автоматически создаст реализацию этого интерфейса!

public interface CountryDao extends CrudRepository<Country, Integer> {

    List<Country> findAllByOrderByIdDesc();

    Country findByName(String name);
}

Для того, чтобы Spring создал реализацию этих методов, требуется соблюдать определённый порядок именования. Хоть в базовом интерфейс уже имеется метод findAll(), мы хотим задать сортировку элементов так, чтобы более поздние записи (с большим id) оказывались в начале списка. Для этого называем наш новый метод findAllByOrderByIdDesc(). Также мы хотим искать страну не только по id, но и по её имени, поэтому добавляем метод findByName().

Как уже упоминалось выше, достаточно создать интерфейс без реализации, назвать новые методы определённым образом и мы уже можем подставлять наше dao в сервисный слой при помощи @Autowired.

Получение всех записей

Начнём с метода получения всех стран. Его реализация довольно проста: мы просто вызываем целевой метод у нашего dao,

@Service
public class CountryServiceImpl implements CountryService {

    @Autowired
    private CountryDao countryDao;

    @Override
    public List<Country> getAll() {
        return countryDao.findAllByOrderByIdDesc();
    }
}

Обработчик входящих http-запросов (контроллер) будет выглядеть следующим образом:

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

    @Autowired
    private CountryService countryService;

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

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

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

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
        show_sql: true

В настройках в секции datasource мы должны указать драйвер для работы с целевой СУБД, полный путь до БД, логин и пароль. В секции jpa мы указываем соответствующий диалект, который учитывает все особенности нашей СУБД, а также включаем вывод генерируемых sql-запросов в консоль при помощи параметра show_sql.

Теперь если мы выполним get-запрос по адресу http://127.0.0.1:8080/api/countries/, то получим список всех записей, которые есть в таблице country.

Поиск по id и по имени

Добавим в CountryServiceImpl ещё два метода:

@Override
public Country getById(int id) {
    Country country = countryDao.findOne(id);
    if (country == null) {
        throw new NotFoundException(id);
    }
    return country;
}

@Override
public Country getByName(String name) {
    Country country = countryDao.findByName(name);
    if (country == null) {
        throw new NotFoundException(name);
    }
    return country;
}

В методе getByName() для поиска по имени мы вызываем наш одноимённый метод из dao-слоя. В методе getById() для поиска по номеру записи вызываем стандартный метод findOne(), который ищет запись по указанному id, а если такой записи нет, то возвращает null. В случае, если запись не найдена, мы кидаем исключение NotFoundException.

@ResponseStatus(HttpStatus.NOT_FOUND)
public class NotFoundException extends RuntimeException {

    public NotFoundException(int id) {
        super("Country with id=" + id + " not found");
    }

    public NotFoundException(String name) {
        super("Country with name=" + name + " not found");
    }
}

Реализация этого исключения довольно проста. Аннотация @ResponseStatus позволяет указать, какой http-статус вернуть в случае ошибки. Согласно rest-архитектуре, если запись не найдена, следует возвращать код 404.

Добавим новые обработчики в контроллер:

@GetMapping("/countries/{countryId:\\d+}")
public Country getCountryById(@PathVariable("countryId"int id) {
    return countryService.getById(id);
}

@GetMapping("/countries/{countryName:\\D+}")
public Country getCountryById(@PathVariable("countryName") String name) {
    return countryService.getByName(name);
}

Поскольку оба запроса принимают по одному параметру, разделим их по типу через регулярное выражение. id записи у нас всегда числовое, о чём говорит регулярка \d+. А имя у нас всегда «не число», о чём говорит выражение \D+. @PathVariable позволяет получать именованную часть урла и подставлять её как параметр метода.

Теперь мы можем выполнять поиск страны, добавляя к урлу http://127.0.0.1:8080/api/countries/ номер искомой страны или её имя. Если же запись не найдена, получим соответствующий ответ в формате json и http-статус 404.

Добавление и удаление записи

В rest-архитектуре создание записи производится через POST-запрос, а обновление - через PUT. При этом параметры запроса можно передавать в теле этого запроса. В нашем случае единственным параметром запроса на создание новой страны будет её имя. Для этого создадим класс, в который будет мапится входящий json.

public class CountryRequest {

    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

Тогда запросы на добавление и обновление страны в нашем сервисе будут выглядеть так:

@Override
public Country create(CountryRequest request) {
    Country country = new Country();
    country.setName(request.getName());
    return countryDao.save(country);
}

@Override
public Country update(int id, CountryRequest request) {
    if (!countryDao.exists(id)) {
        throw new NotFoundException(id);
    }
    Country country = new Country();
    country.setId(id);
    country.setName(request.getName());
    return countryDao.save(country);
}

CrudRepository для добавления и обновления предоставляет один метод - save(). Однако при обновлении мы получаем id целевой записи, поэтому должны предварительно проверить, что такая запись действительно есть. Для этого используем стандартный метод exists(). Если запись не найдена - кидаем исключение. Иначе создаём новый объект Country и инициализируем его соответствующими значениями.

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

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

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

@PostMapping и @PutMapping отвечают за связь с POST- и PUT-запросами соответственно, а @RequestBody связывает тело входящего запроса с нужным классом.

Теперь мы можем добавлять новые страны следующим образом:

curl -X POST -H 'Content-Type: application/json' -i 'http://127.0.0.1:8080/api/countries/' --data '{
"name":"Россия"
}'

В ответ мы получим сущность с id, которое было присвоено ей при вставке в таблицу.

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

Удаление записи производится через стандартный метод delete(), но перед этим мы проверяем существование записи с таким id и если не находим, то кидаем исключение:

@Override
public void delete(int id) {
    if (!countryDao.exists(id)) {
        throw new NotFoundException(id);
    }
    countryDao.delete(id);
}

В контроллер добавим такой метод:

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

@DeleteMapping указывает на обработчик DELETE-запросов, а @ResponseStatus говорит, что в случае успеха вернём 204 http-статус - «No content».

Теперь мы можем удалять записи из таблицы, если знаем id.

Итоги

Как видите, при помощи абстракции CrudRepository мы получили всю базовую функциональность для работы с сущностью в БД и при этом не написали ни одного sql-запроса.

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


Исходники