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

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

06.01.2018

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

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

CREATE TABLE profiles
(
  id serial NOT NULL,
  first_name character varying(50NOT NULL,
  last_name character varying(50NOT NULL,
  CONSTRAINT profile_id_pk PRIMARY KEY (id)
);

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

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

При работе с БД нужно использовать пул подключений к БД, чтобы не создавать их заново при каждом новом sql-запросе, иначе выполнение запроса будет занимать продолжительное время. В качестве пула предлагаю использовать один из наиболее производительных в настоящий момент HikariCP. Также нам нужна поддержка работы с БД со стороны Spring Boot и драйвер для работы с СУБД postgresql. Добавим все эти зависимости в наш проект.

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>com.zaxxer</groupId>
            <artifactId>HikariCP</artifactId>
            <version>2.6.0</version>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <version>9.4.1209.jre7</version>
        </dependency>

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

mainPool.jdbcDriver=org.postgresql.Driver
mainPool.jdbcString=jdbc:postgresql://localhost:5432/database_name
mainPool.jdbcUser=username
mainPool.jdbcPassword=verySecretPassword

Чтобы Spring Boot увидел данные настройки, абсолютный путь к файлу следует указывать через параметр командной строки --spring.config.location=/путь/до/файла/application.config. Если запускаете проект при помощи Idea, указывайте данный параметр в строке Program Arguments.

Для удобства работы с этими настройками создадим класс ConnectionSettings, в который Spring автоматически подставит все настройки с префиксом «mainPool» в соответствующие поля, благодаря аннотации @ConfigurationProperties. Вообще это очень хорошая практика - группировать связанные настройки через префикс.

@Component
@ConfigurationProperties(prefix = "mainPool")
public class ConnectionSettings {

    private static int DEFAULT_MAX_POOL_SIZE = 5;

    private String jdbcDriver;
    private String jdbcString;
    private String jdbcUser;
    private String jdbcPassword;
    private int jdbcMaxPoolSize = DEFAULT_MAX_POOL_SIZE;
}

Для каждого из этих полей нужно создать геттер и сеттер, но я для краткости не стал их здесь приводить.

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

Теперь создадим ещё один компонент, в котором будем инициализировать сам пул.

@Configuration
public class DatabaseConfig {

    private final ConnectionSettings connectionSettings;

    @Autowired
    public DatabaseConfig(ConnectionSettings connectionSettings) {
        this.connectionSettings = connectionSettings;
    }

    @Bean
    public DataSource dataSource() {
        HikariConfig hikariConfig = new HikariConfig();
        hikariConfig.setDriverClassName(connectionSettings.getJdbcDriver());
        hikariConfig.setJdbcUrl(connectionSettings.getJdbcString());
        hikariConfig.setUsername(connectionSettings.getJdbcUser());
        hikariConfig.setPassword(connectionSettings.getJdbcPassword());
        hikariConfig.setMaximumPoolSize(connectionSettings.getJdbcMaxPoolSize());
        hikariConfig.setPoolName("main");
        return new HikariDataSource(hikariConfig);
    }
}

Аннотация @Bean позволяет нам вручную создавать бины, которые Spring потом сможет подставлять в другие компоненты.

Для работы с БД принято выделять отдельной слой dao (data access object - объект доступа к данным). Как и для сервиса из предыдущей статьи, здесь будет удобно выделить интерфейс, который будет выглядеть так:

public interface ProfileDao {

    Optional<Profile> getProfileById(int id);
}

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

Реализация dao:

@Repository
public class ProfileDaoImpl implements ProfileDao {

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

    private final ProfileMapper profileMapper;
    private final NamedParameterJdbcTemplate jdbcTemplate;

    @Autowired
    public ProfileDaoImpl(
            ProfileMapper profileMapper,
            NamedParameterJdbcTemplate jdbcTemplate
    ) {
        this.profileMapper = profileMapper;
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public Optional<Profile> getProfileById(int id) {
        MapSqlParameterSource params = new MapSqlParameterSource();
        params.addValue("id", id);
        try {
            return Optional.ofNullable(
                    jdbcTemplate.queryForObject(
                            SQL_GET_PROFILE_BY_ID,
                            params,
                            profileMapper
                    )
            );
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }
}

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

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

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

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

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

@Component
public class ProfileMapper implements RowMapper<Profile> {

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

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

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

@Primary
@Service
public class ProfileServiceImpl implements ProfileService {

    private final ProfileDao profileDao;

    @Autowired
    public ProfileServiceImpl(ProfileDao profileDao) {
        this.profileDao = profileDao;
    }

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

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

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

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

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

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

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

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

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

Итоги

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

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


Исходники