3 февраля 2017 г.

Работа с базой данных в Spring Boot на примере postgresql

Актуальная версия статьи доступна на моём новом сайте devmark.ru.

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

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

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

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

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

При работе с БД нужно использовать пул подключений к БД, чтобы не создавать их заново при каждом новом 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 {

    @Autowired
    private 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 {

    Profile getProfileById(int id);
}

Его реализация будет выглядеть так:

@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 Profile getProfileById(int id) {
        MapSqlParameterSource params = new MapSqlParameterSource();
        params.addValue("id", id);
        List<Profile> profiles = jdbcTemplate.query(
                SQL_GET_PROFILE_BY_ID, 
                params, 
                profileMapper
        );
        if (profiles.isEmpty()) {
            throw new ProfileNotFoundException(id);
        }
        return profiles.get(0);
    }
}

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

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

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

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

Сам 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 в сервисный слой.

@Component
public class ProfileService {

    private final ProfileDao profileDao;

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

    public Profile getProfile(int personId) {
        return profileDao.getProfileById(personId);
    }
}

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"
}

Исходники проекта вы можете посмотреть здесь: https://github.com/nordmine/spring-boot-restful-service.

Если вам помог данный материал, порекомендуйте его другим пользователям и поставьте +1. Если у вас появились вопросы - пишите их в комментариях, постараюсь ответить.

1 февраля 2017 г.

Простой пример dependency injection при помощи Spring

Актуальная версия статьи доступна на моём новом сайте devmark.ru.

Рассмотрим базовые возможности внедрения зависимостей (dependency injection), которые открывает нам Spring.

Создадим обычный maven-проект, где в pom.xml добавим сам Spring и стандартную секцию <build>.

<dependencies>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    <version>4.3.5.RELEASE</version>
</dependency>
</dependencies>

<build>
<plugins>
    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <configuration>
            <source>1.8</source>
            <target>1.8</target>
        </configuration>
    </plugin>
</plugins>
</build>

Теперь создадим главный класс TestApp, который будет точкой входа.

public class TestApp {

    public static void main(String[] args) {
        ApplicationContext context = new ClassPathXmlApplicationContext("app-config.xml");
        MainService service = context.getBean(MainService.class);
        service.doWork();
    }
}

Здесь мы создаём контекст на основе конфигурационного файла Spring, который после сборки maven'ом окажется в том же jar-файле. Затем из контекста получаем главный бин MainService и вызываем у него целевой метод.

Поскольку все бины мы будем конфигурировать прямо в коде при помощи аннотаций (секция <annotation-config>), содержимое app-config.xml будет минимальным. Мы лишь указываем базовый пакет (секция <component-scan>), внутри которого Spring автоматически будет искать все бины.

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
                           http://www.springframework.org/schema/beans/spring-beans.xsd
                           http://www.springframework.org/schema/context
                           http://www.springframework.org/schema/context/spring-context.xsd">

    <context:annotation-config />
    <context:component-scan base-package="ru.devmark.test"/>
</beans>

Теперь определим интерфейс нашего главного бина MainService. В связи с особенностями работы Spring, инициализация бина происходит быстрее, если у него есть интерфейс. Да и вообще, с точки зрения ООП всегда хорошо выделять интерфейс. Просто возьмите это за правило.

public interface MainService {

    void doWork();
}

Его реализация MainServiceImpl, помеченная аннотацией @Service.

@Service
public class MainServiceImpl implements MainService {

    @Autowired
    private Handler handler;

    @Override
    public void doWork() {
        System.out.println(handler.getString());
    }
}

Все spring-бины помечаются одной из четырёх аннотаций:
  1. Controller - бины, содержащие маппинг входящих http-запросов
  2. Service - бины, реализующие бизнес-логику приложения
  3. Repository - бины, работающие с БД
  4. Component - все остальные бины
Component - наиболее обобщённая аннотация и три другие на самом деле от неё наследуются. По большому счёту, разницы между ними нет и почти любой бин будет работать с любой аннотацией, но лучше придерживаться такого деления.

Также обратите внимание на аннотацию @Autowired. Она указывает, что данное  поле класса должно быть автоматически инициализировано бином, имеющим такой же интерфейс, как и это поле класса. В нашем примере это некий интерфейс Handler (от англ. "обработчик").

Сам интерфейс Handler предельно прост. В нём только один метод, который возвращает строку.

public interface Handler {

    String getString();
}

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

@Component
public class DateHandler implements Handler {

    @Override
    public String getString() {
        return LocalDateTime.now().toString();
    }
}

Обратите внимание, что данный бин DateHandler помечен аннотацией @Component.

Хорошей практикой является создание бинов таким образом, чтобы они не имели внутреннего состояния. Например, не создавать поля класса, которые могут менять значения между вызовами методов. Зачем это нужно? Дело в том, что по умолчанию Spring создаёт каждый бин в одном экземпляре, который затем будет передан во все бины, которые в нём нуждаются. И если ваш бин будет хранить состояние, то неизвестно, как он будет себя вести, если его состояние будут одновременно менять различные компоненты.

Теперь, если вы запустите приложение, то увидите на экране текущее время.

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

public class NameHandler implements Handler {

    private final String name;

    public NameHandler(String name) {
        this.name = name;
    }

    @Override
    public String getString() {
        return name;
    }
}

Теперь нам потребуется специальный конфигурационный класс с аннотацией @Configuration, который позволяет более гибко инициализировать бины.

@Configuration
public class AppConfiguration {

    @Bean
    public Handler firstNameHandler() {
        return new NameHandler("Alice");
    }

    @Bean
    public Handler secondNameHandler() {
        return new NameHandler("Bob");
    }
}

Здесь всё просто: один метод с аннотацией @Bean - один бин. В конструктор мы передаём ему константу. В итоге Spring создаст два бина, один из который всё время будет возвращать "Alice", а другой - "Bob".

Теперь для вызова новых обработчиков нам нужно внедрить их в наш сервис MainServiceImpl. Можно конечно прописать ещё два поля, но и тут Spring облегчает нам задачу. Достаточно создать одно поле, представляющее из себя коллекцию List из интерфейсов Handler, в которую Spring автоматически добавит ВСЕ бины, имеющие этот интерфейс. Вот ещё одна причина, по которой удобно использовать интерфейсы.

Наш сервис примет такой вид:

@Service
public class MainServiceImpl implements MainService {

    @Autowired
    private List<Handler> handlers;

    @Override
    public void doWork() {
        handlers.forEach(h -> System.out.println(h.getString()));
    }
}

В итоге в нашей коллекции обработчиков будет ровно три бина (DateHandler и два экземпляра NameHandler).

Для отображения значения каждого из них воспользуемся Stream API из Java 8, что равносильно обычному циклу foreach и вызову метода getString() у каждого из элементов.

Исходники проекта: https://github.com/nordmine/spring-simple-example

Если вам помог данный материал, поставьте +1.