Работа с БД в Spring Boot на примере postgresql
Данная статья является продолжением 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
Для выполнения запросов в БД нам нужно добавить в наш проект ещё две зависимости:
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 getProfileById( int id);
>
При поиске по id здесь мы будем возвращать Optional. То есть объект может быть в базе, а может и не быть. И в зависимости от контекста это может трактоваться и как ошибка, и как нормальное поведение. Решение о том, ошибка это или нет, будет принимать сервисный слой, который мы модифицируем далее.
Реализация record-класса Profile рассматривалась в предыдущей статье. Его единственное назначение – это отображать поля таблицы в поля класса на Java.
@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 >;
private final ProfileMapper profileMapper;
private final NamedParameterJdbcTemplate jdbcTemplate;
public ProfileRepositoryImpl(
ProfileMapper profileMapper,
NamedParameterJdbcTemplate jdbcTemplate
) this .profileMapper = profileMapper;
this .jdbcTemplate = jdbcTemplate;
>
@Override
public Optional 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
@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, то наш ErrorController корректно обработает исключение ProfileNotFoundException и выдаст пользователю json с описанием ошибки:
Итоги
В результате мы добавили в наше приложение слой взаимодействия с БД, а также создали новую реализацию сервисного слоя, который вместо заглушки теперь использует репозиторий.
В следующей статье Добавление записи через POST-запрос в Spring Boot мы научимся создавать записи в БД.
Вставка данных в таблицу
Мы разобрались, как создавать таблицы, давай теперь детально рассмотрим, как добавлять данные в таблицу с помощью SQL-запроса.
На самом деле это делать очень просто, но есть несколько нюансов. Самый простой вариант вставки данных в таблицу требует указания двух вещей:
Имена колонок нужно указывать обязательно, так как очень часто колонки имеют значения по умолчанию, которые не указывают при вставке данных. И обычно в каждой таблице есть хотя бы одна такая колонка, например, id строки.
Общий вид запроса вставки данных в таблицу выглядит так:
INSERT INTO таблица (колонка1, колонка2, колонка3) VALUES (значение1, значение2, значение3), (значение1, значение2, значение3), (значение1, значение2, значение3);
Например, ты хочешь вставить в таблицу user новую запись, вот как будет выглядеть такой запрос:
INSERT INTO user (name, level, created_time) VALUES (‘Рабинович’, 5, ‘2022-06-06’);
Оператор INSERT INTO SELECT
Еще один частый сценарий вставки данных в таблицу — это взять их из другой таблицы, схемы и даже СУБД.
Для этого есть другой формат запроса INSERT INTO, у которого вместо части VALUES можно указать запрос на выборку данных.
Общий вид такого запроса имеет вид:
INSERT INTO таблица (колонка1, колонка2, колонка3) SELECT-запрос;
Давай напишем запрос, с помощью которого добавим всех пользователей из таблицы employee в таблицу user:
INSERT INTO user (name, created_time) SELECT employee.name, employee.join_date FROM employee;
У нас в таблице employee есть различные данные, но из них мы выбираем только два поля – имя и время прихода в компанию.
Так же таблица user требует указать ей уровень пользователя – level. В таблице employee у сотрудников нет уровня, поэтому мы воспользуемся тем, что у таблицы user у поля level есть значение по умолчанию. Мы просто не будем указывать level, и SQL установит значение по умолчанию.
Допустим, нас не устраивает значение по умолчанию, и мы хотим, чтобы level был 99, а user.created_time заменить на сегодняшнюю дату, тогда можно написать так:
INSERT INTO user (name, level, created_time) SELECT employee.name, 99, CURDATE() FROM employee;
Можно наворотить еще кучу всего интересного, но, думаю, хватит пока и этого. Подробнее можно почитать на официальный странице MySQL.