Статьи Утилиты Telegram YouTube RuTube Отзывы

Работа с БД в Spring Boot на примере postgresql

Исходники

28 декабря 2023

Тэги: Java, PostgreSQL, rest, Spring Boot, SQL, Stream API, руководство.

Содержание

  1. Создание и заполнение таблицы
  2. Добавляем зависимость JDBC API
  3. Параметры подключения
  4. Слой взаимодействия с БД
  5. Маппинг результатов выборки из БД
  6. Новый сервисный слой
  7. Итоги

Данная статья является продолжением Spring Boot Restful Service, где была бы раскрыта тема работы с БД в Spring Boot. Давайте рассмотрим эту тему подробнее на примере СУБД postgresql, а в качестве основы возьмём проект, который мы делали в той статье.

Напомню, что проект представляет из себя простой restful-service, который принимает GET-запрос по HTTP и возвращает профиль пользователя по его id.

Создание и заполнение таблицы

Сам профиль содержит кроме id также имя, фамилию и возраст. Поэтому создадим таблицу profile в базе данных.

CREATE TABLE public.profile
(
    id         serial,
    first_name character varying(50) NOT NULL,
    last_name  character varying(50) NOT NULL,
    age        integer               NOT NULL,
    CONSTRAINT profile_id_pk PRIMARY KEY (id)
);

insert into profile (first_name, last_name, age)
values ('Иван', 'Петров', 23);

Для поля id можно использовать тип serial. Он представляет собой целое число, которое увеличивается на 1 автоматически при вставке новой записи в таблицу.

Добавляем зависимость JDBC API

Для выполнения запросов в БД нам нужно добавить в наш проект ещё две зависимости:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

spring-boot-starter-jdbc позволяет выполнять sql-запросы в базу, а postgresql – это драйвер для работы с соответствующей СУБД. Обратите внимание, что он имеет scope = runtime. То есть зависимость нужна только в процессе работы приложения.

Параметры подключения

Для интеграции с БД также требуется указать параметры подключения, такие как логин, пароль и т.п. Создадим файл application.yml в папке resources с примерно таким содержимым:

spring:
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://localhost:5432/example_db
    username: example_user
    password: "!QAZxsw2"

Если в проекте по умолчанию был файл application.properties – удалите его.

Обратите внимание, что достаточно лишь прописать параметры подключения – и всё остальное Spring Boot сделает за вас. Например, инициализирует пул подключений.

Слой взаимодействия с БД

Для работы с БД принято выделять отдельный слой repository. Как и для сервиса из предыдущей статьи, здесь будет удобно выделить интерфейс:

public interface ProfileRepository {

    Optional<Profile> getProfileById(int id);
}

При поиске по id здесь мы будем возвращать Optional. То есть объект может быть в базе, а может и не быть. И в зависимости от контекста это может трактоваться и как ошибка, и как нормальное поведение. Решение о том, ошибка это или нет, будет принимать сервисный слой, который мы модифицируем далее.

Реализация record-класса Profile рассматривалась в предыдущей статье. Его единственное назначение – это отображать поля таблицы в поля класса на Java.

public record Profile(
        int id,
        String firstName,
        String lastName,
        int age
) {
}

Реализация репозитория:

@Repository
public class ProfileRepositoryImpl implements ProfileRepository {

    private static final String SQL_GET_PROFILE_BY_ID =
            "select id, first_name, last_name, age from profile where id = :id";

    private final ProfileMapper profileMapper;
    private final NamedParameterJdbcTemplate jdbcTemplate;

    public ProfileRepositoryImpl(
            ProfileMapper profileMapper,
            NamedParameterJdbcTemplate jdbcTemplate
    ) {
        this.profileMapper = profileMapper;
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public Optional<Profile> getProfileById(int id) {
        var params = new MapSqlParameterSource();
        params.addValue("id", id);
        return jdbcTemplate.query(
                        SQL_GET_PROFILE_BY_ID,
                        params,
                        profileMapper
                ).stream()
                .findFirst();
    }
}

Обратите внимание, что ВСЕ репозитории снабжаются аннотацией @Repository, которая является частным случаем @Component. Она обеспечивает маппинг ошибок, специфичных для СУБД, в стандартные исключения JDBC.

Сам SQL-запрос для выборки профиля пользователя здесь вынесен в качестве константы в начало класса. Для подстановки целевого id используется именованный параметр с двоеточием в начале, а не простая конкатенация строки и числа. Это позволяет нам сделать запрос более устойчивым к хакерским атакам типа sql injection с одной стороны и более производительным с другой, т.к. СУБД сможет закешировать шаблон данного запроса.

NamedParameterJdbcTemplate – стандартный компонент, предоставляющий методы для взаимодействия с БД. Как видно из названия, он поддерживает именованные параметры. ProfileMapper преобразует данные, полученные из БД в объект Profile. То есть он хранит в себе логику маппинга полей таблицы на поля класса. Более подробно мы рассмотрим его чуть ниже.

Реализация нашего целевого метода getProfileById() предельно проста. Сначала подставляем требуемый id в sql-запрос через именованный параметр благодаря классу MapSqlParameterSource. Затем вызываем метод query, передавая ему сам sql-запрос, именованные параметры и маппер полей таблицы. В качестве результата получаем список объектов типа Profile.

Исходя из того, что id является первичным ключом в таблице и его значение уникально, мы можем здесь использовать преобразование в стрим и findFirst(). Такая связка более универсальна и безопасна, чем queryForObject(), который может выкинуть исключение, если найдено более одной записи.

Маппинг результатов выборки из БД

Сам ProfileMapper не хранит внутреннего состояния и всего лишь реализует интерфейс RowMapper, типизированный нашим объектом Profile.

@Component
public class ProfileMapper implements RowMapper<Profile> {

    @Override
    public Profile mapRow(ResultSet rs, int rowNum) throws SQLException {
        return new Profile(
                rs.getInt("id"),
                rs.getString("first_name"),
                rs.getString("last_name"),
                rs.getInt("age")
        );
    }
}

На вход он получает ResultSet, представляющий собой результат выборки. Из этого ResultSet мы извлекаем значения полей благодаря методам getInt() и getString() по имени колонки в таблице.

Новый сервисный слой

Теперь осталось только внедрить наш ProfileDao в сервисный слой. В предыдущей статье мы уже создавали реализацию сервисного слоя ProfileServiceMock, которая является заглушкой и на самом деле ни в какую базу не ходит. Сейчас мы создадим другую реализацию того же сервиса:

@Primary
@Service
public class ProfileServiceImpl implements ProfileService {

    private final ProfileRepository profileRepository;

    public ProfileServiceImpl(ProfileRepository profileRepository) {
        this.profileRepository = profileRepository;
    }

    @Override
    public Profile getProfile(int personId) {
        return profileRepository.getProfileById(personId)
                .orElseThrow(() -> new ProfileNotFoundException(personId));
    }
}

Обратите внимание на аннотацию @Primary. Если её не указывать, то спринг не сможет заинжектить в ProfileController нужную нам реализацию сервиса, т.к. по факту у нас их две. Чтобы указать, что по умолчанию нам нужна именно эта реализация, мы и используем данную аннотацию. Ещё разные реализации интерфейса можно подставлять в зависимости от профиля приложения с помощью аннотации @Profile.

Как я уже говорил, именно сервисный слой находится в контексте выполнения запроса и может правильно трактовать пустой результат из репозитория. В данном случае это ошибка и здесь Optional предоставляет очень удобный метод orElseThrow(), в который мы передаём наше исключение через лямбда-выражение.

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

ProfileService, в свою очередь, вызывается из контроллера. Таким образом, вырисовывается типичная трёхслойная архитектура: контроллер (с аннотацией @Controller) -> сервис (@Service) -> dao (@Repository). Контроллер отвечает за маппинг входящих http-запросов, сервисный слой реализует бизнес-логику, а dao работает непосредственно с БД.

Теперь если вы запустите приложение и выполните GET-запрос по адресу http://localhost:8080/profiles/1, то получите профиль с id = 1:

{
  "id": 1,
  "firstName": "Иван",
  "lastName": "Петров",
  "age": 21
}

Если же выполнить запрос с другим id, то наш ErrorController корректно обработает исключение ProfileNotFoundException и выдаст пользователю json с описанием ошибки:

{
  "message": "Profile with id = 111 not found"
}

Итоги

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

В следующей статье Добавление записи через POST-запрос в Spring Boot мы научимся создавать записи в БД.



Комментарии

29.06.2022 13:25 Артур

Запускаю через Idea, с указанным параметром в моем случае --spring.config.location=/Users/arthurchebotkov/Desktop/SpringBootRestfulService/spring-boot-restful-service/src/main/resources/application.config
Приложение не запускается, пишет в лог:
[main] ru.devmark.app.RestfulApplication        : No active profile set, falling back to default profiles: default

Подскажите, с чем может быть связано?

29.06.2022 13:29 devmark

А у вас точно не запускается? Потому что "No active profile set" - это не ошибка, а всего лишь предупреждение, что профиль не указан явно и используется профиль default.

Если профиль явно не выставлен, то приложение всё равно запустится. Но профиль также можно выставить через параметры запуска.

29.06.2022 13:46 Артур

Не запускается: ERROR 9976 --- [         main] o.s.boot.SpringApplication             : Application startup failed
Насколько я понял указывает на следующие ошибки:
1) Cannot load configuration class: ru.devmark.app.RestfulApplication
2) java.lang.reflect.InaccessibleObjectException-->Unable to make protected final java.lang.Class java.lang.ClassLoader.defineClass(java.lang.String,byte[],int,int,java.security.ProtectionDomain) throws java.lang.ClassFormatError accessible: module java.base does not "opens java.lang" to unnamed module @7791a895
3) Unable to make protected final java.lang.Class java.lang.ClassLoader.defineClass(java.lang.String,byte[],int,int,java.security.ProtectionDomain) throws java.lang.ClassFormatError accessible: module java.base does not "opens java.lang" to unnamed module @7791a895

29.06.2022 15:10 devmark

А на какой версии Java вы этот пример запускаете? Изначально он создавался под Java 8. Судя по тексту ошибки, ругается на то, что в проекте недоступен модуль java.lang.

Но вообще данный пример немного устарел. У меня на YouTube канале есть актуальная серия видео как создавать restful приложение на jdbc: https://youtu.be/hta62ffKcK4. Также есть аналогичное про Spring Data https://youtu.be/BSJcA6IHIZw.

29.06.2022 16:47 Артур

Запускаю на Java 18.
Просто хотел запустить проект на Java+Maven.

Спасибо за информацию!

01.07.2022 00:26 devmark

Я переработал статью и исходники проекта под актуальную версию Spring Boot и Java 17.

02.07.2022 12:13 Артур

Огромное спасибо! :)

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

×

devmark.ru