В этой части мы создадим работоспособный микросервис, придерживаясь правильной архитектуры, которая обеспечивает качества, важные для промышленного проекта: гибкость, простота внесения доработок и исправления ошибок, масштабируемость.
Запросы от клиента через транспортный слой попадают в слой сервисов. Сервисы при помощи классов DAO-слоя посылают запросы к базе данных. Совершив необходимые операции, классы сервисного слоя передают их результат в транспортный слой, где формируется ответ на обработанный запрос. Иногда, операции сервисного слоя инициируются не по запросу, а по таймеру при помощи планировщика заданий.
Spring Boot
Spring – это стандартный framework для создания backend-сервисов на языке Java. Spring Boot - это расширение Spring, которое позволяет быстро подключать типовые функции приложения (web-сервер, подключение к базе данных, безопасность и т.д.) при помощи «стартеров».
Подключите библиотеки Spring Boot в проект, отредактировав файл build.gradle:
plugins {
id 'java'
// Плагины для Spring Boot проектов
id "org.springframework.boot" version "2.6.7"
id "io.spring.dependency-management" version "1.0.11.RELEASE"
}
group 'org.example'
version '1.0-SNAPSHOT'
repositories {
mavenCentral()
}
dependencies {
// Стартер для web-сервиса
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1'
}
test {
useJUnitPlat
}
Плагины облегчают жизнь, гарантируя использование неконфликтующих между собой версий библиотек Spring Boot. Стартер spring-boot-starter-web создаст и запустит для нас готовый к эксплуатации web-сервер.
Затем необходимо задекларировать и стартовать Spring Boot приложение в файле Main.java:
package org.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
// Декларируем Spring Boot приложение
@SpringBootApplication
public class Main {
public static void main(String[] args) {
// Стартуем приложение
SpringApplication.run(Main.class, args);
}
}
При старте приложения мы увидим в журнале сообщений на консоли, как стартует web-сервер Tomcat:
Обратите внимание, приложение не завершается, как это было раньше. Оно работает как сервис - web-сервер ожидает запросов на порту 8080.
DTO – объекты для передачи данных
В большинстве запросов к сервисам передаются какие-то данные. Например, если мы хотим создать пользователя, то скорее всего нам надо передать в запросе на его создание хотя бы имя. Стандартом передачи данных в REST запросах являются Data Transfer Objects (DTO).
Предположим, функциональность нашего приложения будет связана с пользователями. Тогда первым делом надо написать обработчик запросов на их создание, а значит нам нужно создать соответствующий DTO-класс, чтобы передавать данные о новых пользователях.
Добавьте новый java-package, где будут размещаться DTO-классы, назовите его web.dto:
Добавьте новый класс CreateUserDto в пакет web.dto:
package org.example.web.dto;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
/**
* Запрос на создание пользователя
*/
/**
Чтобы воспользоваться DTO-классом необходим механизм десериализации -
превращения JSON-строки вида {"name": "John Doe"} в экземпляр класса
CreateUserDto. Класс Builder реализует шаблон Строитель,
который принято использовать в классах моделей и DTO
*/
@JsonDeserialize(builder = CreateUserDto.Builder.class)
public class CreateUserDto {
/**
Имя пользователя
*/
private final String name;
public static Builder builder() { return new Builder(); }
/**
* Конструктор сделан закрытым, потому что объекты этого класса
* надо порождать таким образом:
* dto = CreateUserDto.builder().setName("John Doe").build()
*/
private CreateUserDto(Builder builder) {
this.name = builder.name;
}
public String getName() { return name; }
/**
* Используется при выводе сообщений на экран
*/
@Override
public String toString() {
return "{" +
"name='" + name + '\'' +
'}';
}
/**
* Подсказываем механизму десериализации,
* что методы установки полей начинаются с set
*/
@JsonPOJOBuilder(withPrefix = "set")
public static class Builder {
private String name;
public Builder setName(String name) {
this.name = name;
return this;
}
public CreateUserDto build() { return new CreateUserDto(this); }
}
}
REST-контроллеры - обработчики запросов
Стандартом для написания web-сервисов является REST-архитектура. Она крайне проста – сервис получает http-запросы, обрабатывает их и отправляет ответы. Занимаются этим REST-контроллеры приложения.
На данном этапе у нас есть работающий web-сервер и описано, как будут передаваться данные для создания пользователя. Пришла пора написать первый REST-контроллер, который будет обрабатывать запросы на создание новых пользователей.
Создайте класс WebController в java-пакете web:
package org.example.web;
import org.example.web.dto.CreateUserDto;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
/**
* Обработчик web-запросов
*/
@RestController
public class WebController {
/**
* Средство для вывода сообщений на экран
*/
private static final Logger LOGGER = LoggerFactory.getLogger(WebController.class);
/**
* Обработчик запросов на создание пользователя
* @param createUserDto запрос на создание пользователя
*/
@PostMapping("/users")
public void createUser(@RequestBody CreateUserDto createUserDto) {
/**
* Получили запрос на создание пользователя,
* пока можем только залогировать этот факт
*/
LOGGER.info("Create user request received: {}", createUserDto);
}
}
Создайте в проекте папку http-test, а в ней создайте файл test.http:
### Запрос на создание пользователя
POST http://localhost:8080/users
Content-type: application/json
{
"name": "JohnDoe"
}
Запустите приложение, а затем тестовый POST-запрос в файле test.http
В результате запуска запроса вы должны увидеть ответ Response code: 200 – это значит, что запрос выполнился успешно. В консоли приложения будет выведено сообщение: Create user request received: {name='JohnDoe'} – это значит, что запрос «дошел» до приложения. Но на данном этапе мы пока не можем ничего сделать – нам негде хранить пользователей.
Валидация данных
Наше приложение уже умеет получать запросы на создание пользователей. Но, прежде чем приступить к обработке запроса, было бы неплохо проверить пришедшие данные на корректность. Предположим, мы хотим, чтобы в имени пользователя было от 5 до 25 символов и присутствовали только буквы латинского алфавита.
Добавьте стартер валидации в файл build.gradle:
plugins {
id 'java'
// Плагины для Spring Boot проектов
id "org.springframework.boot" version "2.6.7"
id "io.spring.dependency-management" version "1.0.11.RELEASE"
}
group 'org.example'
version '1.0-SNAPSHOT'
repositories {
mavenCentral()
}
dependencies {
// Стартер для web-сервиса
implementation 'org.springframework.boot:spring-boot-starter-web'
// Стартер для валидации
implementation 'org.springframework.boot:spring-boot-starter-validation'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1'
}
test {
useJUnitPlatform()
}
Добавьте правила валидации на поле name в классе CreateUserDto:
package org.example.web.dto;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
/*
Запрос на создание пользователя
*/
/**
Чтобы воспользоваться DTO-классом необходим механизм десериализации -
превращения JSON-строки вида {"name": "John Doe"} в экземпляр класса
CreateUserDto. Класс Builder реализует шаблон Строитель,
который принято использовать в классах моделей и DTO
*/
@JsonDeserialize(builder = CreateUserDto.Builder.class)
public class CreateUserDto {
/**
* Имя пользователя
* Ключ "name" - обязательный
* Длина - от 5 до 25 символов
* Может содержать только символы латинского алфавита
*/
@NotNull(message = "Key 'name' is mandatory")
@Length(min = 5, max = 25, message = "Name length must be from 5 to 25")
@Pattern(regexp = "^[a-zA-Z]+$", message = "Name must contain only letters a-z and A-Z")
private final String name;
public static Builder builder() { return new Builder(); }
/**
* Конструктор сделан закрытым, потому что объекты этого класса
* надо порождать таким образом:
* dto = CreateUserDto.builder().setName("John Doe").build()
*/
private CreateUserDto(Builder builder) {
this.name = builder.name;
}
public String getName() { return name; }
/**
* Используется при выводе сообщений на экран
*/
@Override
public String toString() {
return "{" +
"name='" + name + '\'' +
'}';
}
/**
* Подсказываем механизму десериализации,
* что методы установки полей начинаются с set
*/
@JsonPOJOBuilder(withPrefix = "set")
public static class Builder {
private String name;
public Builder setName(String name) {
this.name = name;
return this;
}
public CreateUserDto build() { return new CreateUserDto(this); }
}
}
Добавьте проверку входного параметра метода createUser в классе WebController:
package org.example.web;
import org.example.web.dto.CreateUserDto;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
/**
* Обработчик web-запросов
*/
@RestController
public class WebController {
/**
* Средство для вывода сообщений на экран
*/
private static final Logger LOGGER = LoggerFactory.getLogger(WebController.class);
/**
* Обработчик запросов на создание пользователя
* @param createUserDto запрос на создание пользователя
*/
@PostMapping("/users")
public void createUser(@Valid @RequestBody CreateUserDto createUserDto) {
/*
Получили запрос на создание пользователя,
пока можем только залогировать этот факт
*/
LOGGER.info("Create user request received: {}", createUserDto);
}
}
Запустите приложение и попробуйте послать тестовые запросы в файле http.test с разными вариантами значения поля name. Убедитесь, что при допустимых значениях код ответа равен 200 (успех), иначе – 400 (некорректный запрос).
Модель данных
Итак, наше приложение умеет получать запросы на создание пользователя и проверяет полученные данные на корректность. Пора каким-то образом сохранить нового пользователя в базе данных. Но прежде создадим класс модели пользователя.
Создайте java-пакет model, а в нем java-класс UserInfo, для операций над сущностью пользователя:
package org.example.model;
/*
Информация о пользователе
*/
public class UserInfo {
/**
* Имя пользователя
*/
private final String name;
public static Builder builder() { return new Builder(); }
/**
* Конструктор сделан закрытым, потому что объекты этого класса
* надо порождать таким образом:
* dto = User.builder().setName("John Doe").build()
*/
private UserInfo(Builder builder) {
this.name = builder.name;
}
public String getName() { return name; }
/**
* Используется при выводе сообщений на экран
*/
@Override
public String toString() {
return "{" +
"name='" + name + '\'' +
'}';
}
public static class Builder {
private String name;
public Builder setName(String name) {
this.name = name;
return this;
}
public UserInfo build() { return new UserInfo(this); }
}
}
Внимательный читатель может заметить, что класс UserInfo очень похож на класс CreateUserDto. Неудивительно – если честно, я создал его копированием, удалив аннотации и поправив комментарии. Зачем в приложении два почти одинаковых класса?
CreateUserDto – это класс транспортного слоя для передачи данных, а UserInfo – это класс для оперирования сущностью пользователя на уровне бизнес-логики. Их одинаковость – это временное состояние. В дальнейшем, по мере появления новых требований к транспорту, может меняться класс CreateUserDto, а по мере появления нового бизнес-функционала будет дополнятся класс UserInfo. Иногда эти изменения синхронны, иногда – нет, и классы начнут все больше отличаться.
Транспортные классы отделены в транспортный слой от остального приложения – это признак хорошей архитектуры. В данном случае транспортный слой находится в пакете web. Зачем это нужно?
Представьте, что мы написали отличный менеджер пользователей, но в какой-то момент весь проект включили в платформу, где уже есть соглашение о том, как передаются данные о пользователях, и вам надо работать по указанному протоколу. Например, в целевой платформе вместо HTTP используется обмен сообщениями при помощи Kafka, или вместо ключа name в их системах ходят запросы с ключом userName.
При этом требования к работоспособности по старому протоколу тоже остаются в силе, например, «на время переезда» или «на время внедрения новой платформы». Это состояние может продлиться месяцы, а то и годы.
Если транспортные классы вашего приложения «проникли» куда-то за пределы транспортной логики, то придется переписывать все приложение. Придется иметь несколько версий приложения: «старую» и «новую», и дорабатывать их параллельно. А это уже не просто дублирование кода – это грозит дублированием всего процесса ведения доработок: постановка, разработка, тестирование.
Но если вы «выдержали» архитектуру, то в вашем приложении просто появится новый транспортный сервис, а бизнес-логику можно будет переиспользовать. Как это делается, будет продемонстрировано позже.
Liquibase - создание базы данных и подключение к ней
На этом этапе у нас уже есть модель сущности пользователя, которого мы хотим сохранить, но пока нет базы данных для этого. Воспользуемся библиотекой Liquibase для создания базы данных.
В промышленных приложениях используют СУБД PostgreSQL, Oracle или MS SQL Server, но мы воспользуемся H2, которая отлично подходит для учебных целей и может создать эфемерную базу данных в оперативной памяти при каждом запуске приложения.
Подключите стартер JDBC, библиотеку Liqubase и библиотеку H2 в файле build.gradle:
...
dependencies {
...
// Стартер jdbc
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
// Библиотеки Liquibase
implementation 'org.liquibase:liquibase-core:4.9.1'
// Библиотеки H2
implementation 'com.h2database:h2:2.1.212'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1'
}
...
Создайте файл application.yml в каталоге resources, указав параметры подключения к базе данных:
db:
driverClassName: org.h2.Driver
url: jdbc:h2:mem:user_db;DB_CLOSE_DELAY=-1;INIT=CREATE SCHEMA IF NOT EXISTS user_db
username: admin
password: admin
maxPoolSize: 10
В каталоге resources создайте каталог db, а в нем файл changelog.xml, по которому liquibase создаст для нас таблицу user_info:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
<changeSet id="user" author="dev">
<sql>
create table user_info
(
name varchar(25) primary key
);
</sql>
</changeSet>
</databaseChangeLog>
Создайте java-пакет configuration, а в нем класс DataBaseConfiguration, который обеспечит для приложения возможность отправлять запросы к базе данных:
package org.example.configuration;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import liquibase.integration.spring.SpringLiquibase;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.liquibase.LiquibaseProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.support.TransactionTemplate;
import javax.sql.DataSource;
/**
* Конфигурация компонентов для работы с БД
*/
@Configuration
@EnableTransactionManagement
public class DatabaseConfiguration {
@Value("${db.driverClassName}")
private String driver = "org.postgresql.Driver";
@Value("${db.maxPoolSize}")
private int poolLimit = 10;
private final String dbUrl;
private final String userName;
private final String userPassword;
@Autowired
public DatabaseConfiguration(@Value("${db.username}") String userName,
@Value("${db.password}") String userPassword,
@Value("${db.url}") String dbUrl) {
this.userName = userName;
this.userPassword = userPassword;
this.dbUrl = dbUrl;
}
@Bean(destroyMethod = "close")
public HikariDataSource hikariDataSource() {
HikariConfig config = new HikariConfig();
config.setDriverClassName(driver);
config.setJdbcUrl(dbUrl);
config.setUsername(userName);
config.setPassword(userPassword);
config.setMaximumPoolSize(poolLimit);
return new HikariDataSource(config);
}
@Bean
public TransactionAwareDataSourceProxy transactionAwareDataSource() {
return new TransactionAwareDataSourceProxy(hikariDataSource());
}
@Bean
public DataSourceTransactionManager dataSourceTransactionManager() {
return new DataSourceTransactionManager(transactionAwareDataSource());
}
@Bean
public TransactionTemplate transactionTemplate() {
return new TransactionTemplate(dataSourceTransactionManager());
}
@Bean
public JdbcTemplate jdbcTemplate() {
return new JdbcTemplate(hikariDataSource());
}
@Bean
public NamedParameterJdbcTemplate namedParameterJdbcTemplate() {
return new NamedParameterJdbcTemplate(jdbcTemplate());
}
@Bean
@ConfigurationProperties(prefix = "spring.datasource.liquibase")
public LiquibaseProperties mainLiquibaseProperties() {
LiquibaseProperties liquibaseProperties=new LiquibaseProperties();
liquibaseProperties.setChangeLog("classpath:/db/changelog.xml");
return liquibaseProperties;
}
@Bean
public SpringLiquibase springLiquibase() {
LiquibaseProperties liquibaseProperties = mainLiquibaseProperties();
return createSpringLiquibase(hikariDataSource(), liquibaseProperties);
}
private SpringLiquibase createSpringLiquibase(DataSource source, LiquibaseProperties liquibaseProperties) {
return new SpringLiquibase() {
{
setDataSource(source);
setDropFirst(liquibaseProperties.isDropFirst());
setContexts(liquibaseProperties.getContexts());
setChangeLog(liquibaseProperties.getChangeLog());
setDefaultSchema(liquibaseProperties.getDefaultSchema());
setChangeLogParameters(liquibaseProperties.getParameters());
setShouldRun(liquibaseProperties.isEnabled());
setRollbackFile(liquibaseProperties.getRollbackFile());
setLabels(liquibaseProperties.getLabels());
}
};
}
}
Если все сделано правильно, то в журнале сообщений при старте приложения будет запись:
ChangeSet db/changelog.xml::user::dev ran successfully
Это означает, что в памяти была создана база данных user_db, а в ней таблица user_info, в которой мы будем сохранять данные о пользователях.
DAO - отправка запросов к базе данных
Благодаря Liquibase наше приложение обеспечено базой данных с необходимой нам таблицей user_info. Пришла пора научиться записывать туда данные. Делается это при помощи Data Access Object (DAO) – специализированного класса, который принято выносить в отдельный DAO-слой приложения.
Создайте java-пакет dao и в нем класс UserInfoDao, который будет отвечать за отправку запросов к таблице user_info:
package org.example.dao;
import org.example.dao.mapper.UserInfoRowMapper;
import org.example.model.UserInfo;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
/**
* Запросы к таблице user_info
*/
public class UserInfoDao {
/**
* Объект для отправки SQL-запросов к БД
*/
private final NamedParameterJdbcTemplate jdbcTemplate;
public UserInfoDao(NamedParameterJdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
/**
* Создает запись о пользователе в БД
* @param userInfo информация о пользователе
*/
public void createUser(UserInfo userInfo) {
jdbcTemplate.update(
"INSERT INTO user_info (name) VALUES (:name) ",
new MapSqlParameterSource("name", userInfo.getName())
);
}
/**
* Возращает информацию о пользователе по имени
* @param userName имя пользователя
* @return информация о пользователе
*/
public UserInfo getUserByName(String userName) {
return jdbcTemplate.queryForObject("SELECT * FROM user_info WHERE name = :name",
new MapSqlParameterSource("name", userName),
new UserInfoRowMapper()
);
}
/**
* Удаляет пользователя из БД
* @param userName имя пользователя
*/
public void deleteUser(String userName) {
jdbcTemplate.update(
"DELETE FROM user_info WHERE name = :name",
new MapSqlParameterSource("name", userName)
);
}
}
DAO-классу UserInfoDao необходим вспомогательный класс, отвечающий за преобразование записи из таблицы БД в java-класс UserInfo. В пакете dao создайте пакет mapper и в нем класс UserInfoRowMapper:
package org.example.dao.mapper;
import org.example.model.UserInfo;
import org.springframework.jdbc.core.RowMapper;
import java.sql.ResultSet;
import java.sql.SQLException;
/**
* Трансляция записи из таблицы user_info в java-класс UserInfo
*
* Используется в {@link org.example.dao.UserInfoDao}
*/
public class UserInfoRowMapper implements RowMapper<UserInfo> {
/**
* Возвращает информацию о пользователе
* @param rs запись в таблице user_info
* @param rowNum номер записи
* @return информация о пользователе
* @throws SQLException если в таблице нет колонки
*/
@Override
public UserInfo mapRow(ResultSet rs, int rowNum) throws SQLException {
return UserInfo.builder()
.setName(rs.getString("name"))
.build();
}
}
Мы описали DAO-класс, теперь надо добавить конфигурацию, по которой Spring Boot создаст при старте приложения “bean” – экземпляр этого класса. В java-пакете configuration создайте класс DaoConfiguration:
package org.example.configuration;
import org.example.dao.UserInfoDao;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
/**
* Создание "бинов" DAO-классов
*/
@Configuration
public class DaoConfiguration {
@Bean
UserInfoDao userInfoDao(NamedParameterJdbcTemplate jdbcTemplate) {
return new UserInfoDao(jdbcTemplate);
}
}
Добавьте в класс WebController использование класса UserInfoDao для работы с БД:
package org.example.web;
import org.example.dao.UserInfoDao;
import org.example.model.UserInfo;
import org.example.web.dto.CreateUserDto;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
/**
* Обработчик web-запросов
*/
@RestController
public class WebController {
/**
* Средство для вывода сообщений на экран
*/
private static final Logger LOGGER = LoggerFactory.getLogger(WebController.class);
/**
* Объект для операциями с БД
* TODO: Позже надо перейти на использование сервисного слоя
*/
private final UserInfoDao userInfoDao;
/**
* Инъекция одних объектов в другие происходит через конструктор
* и обеспечивается библиотеками Spring
*/
public WebController(UserInfoDao userInfoDao) {
this.userInfoDao = userInfoDao;
}
/**
* Обработчик запросов на создание пользователя
* @param createUserDto запрос на создание пользователя
*/
@PostMapping("/users")
public void createUser(@Valid @RequestBody CreateUserDto createUserDto) {
LOGGER.info("Create user request received: {}", createUserDto);
/**
* Сохраняем пользователя, преобразуя DTO в модель
*/
userInfoDao.createUser(
UserInfo.builder().setName(createUserDto.getName()).build()
);
}
/**
* Обработчик запросов на получение информации о пользователе
* @param userName имя пользователя
* @return информация о пользователе
*/
@GetMapping("/users/{userName}")
public UserInfo getUserInfo(@PathVariable String userName) {
return userInfoDao.getUserByName(userName);
}
/**
* Обработчик запросов на удаление пользователя
* @param userName имя пользователя
*/
@DeleteMapping("/users/{userName}")
public void deleteUser(@PathVariable String userName) {
userInfoDao.deleteUser(userName);
}
}
Дополните тестовый файл test.http новыми запросами:
### Запрос на создание пользователя
POST http://localhost:8080/users
Content-type: application/json
{
"name": "JohnDoe"
}
### Запрос информации о пользователе
GET http://localhost:8080/users/JohnDoe
### Запрос на удаление пользователя
DELETE http://localhost:8080/users/JohnDoe
Выполните запросы последовательно. Если все сделано правильно, будут получены ответы с кодом 200. Наше приложение теперь умеет: сохранять информацию о пользователе, возвращать ее по запросу, удалять информацию о пользователе.
Сервисный слой и бизнес-логика
Приложение работает, но имеет пока скрытую архитектурную проблему – обращение к DAO-классу происходит напрямую из транспортного слоя.
Предположим, мы хотим избежать появления пользователей с именами типа «administrator», «root» или «system». Или, прежде чем посылать запросы на создание и удаление пользователя, неплохо было бы проверить его наличие в БД.
Писать эту логику в транспортном слое нельзя – при появлении нового транспорта, этот фрагмент кода придется дублировать.
Добавлять эту проверку в UserInfoDao тоже не стоит потому что:
Нарушается принцип единой ответственности, класс начинает терять свою специализацию «работа с таблицей user_info»
DAO-классы – это тоже, в известном смысле, «деталь» приложения, которую, возможно, придется заменить или дополнить при переходе на новую СУБД. Это будет сложнее сделать, если код утяжелен какой-то дополнительной логикой, кроме отсылки SQL-запроса.
Для написания подобной «бизнес-логики» правильно будет создать отдельный «сервисный» слой – это смысловое ядро приложения, вокруг которого крутятся сравнительно легко заменяемые «детали»: транспорт, база данных, клиенты других сервисов т.д.
Добавьте java-пакет service и создайте в нем класс UserInfoService, который будет отвечать за бизнес-операции над сущностью пользователя:
package org.example.service;
import org.example.dao.UserInfoDao;
import org.example.model.UserInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.EmptyResultDataAccessException;
import java.util.Set;
/**
* Бизнес-логика работы с пользователями
*/
public class UserInfoService {
private static final Logger LOGGER = LoggerFactory.getLogger(UserInfoService.class);
/**
* Объект для работы с таблице user_info
*/
private final UserInfoDao userInfoDao;
/**
* Иньекция испольуземых объектов через конструктор
* @param userInfoDao объект для работы с таблице user_info
*/
public UserInfoService(UserInfoDao userInfoDao) {
this.userInfoDao = userInfoDao;
}
/**
* Создание пользователя
* @param userInfo информация о пользователе
*/
public void createUser(UserInfo userInfo) {
checkNameSuspicious(userInfo.getName());
if (!isUserExists(userInfo.getName())) {
userInfoDao.createUser(userInfo);
LOGGER.info("User created by user info: {}", userInfo);
} else {
// TODO Заменить на своё исключение
RuntimeException exception = new RuntimeException("User already exists with name " + userInfo.getName());
LOGGER.error("Error creating user by user info {}", userInfo, exception);
throw exception;
}
}
/**
* Возвращает информацию о пользователе по его имени
* @param userName имя пользователя
* @return информация о пользователе
*/
public UserInfo getUserInfoByName(String userName) {
try {
return userInfoDao.getUserByName(userName);
} catch (EmptyResultDataAccessException e) {
LOGGER.error("Error getting info by name {}", userName, e);
// TODO Заменить на своё исключение
throw new RuntimeException("User not found by name " + userName);
}
}
/**
* Удаление пользователя
* @param userName имя пользователя
*/
public void deleteUser(String userName) {
if (isUserExists(userName)) {
userInfoDao.deleteUser(userName);
LOGGER.info("User with name {} deleted", userName);
}
}
/**
* Проверка на сущестование пользователя с именем
* @param userName имя пользователя
* @return true - если пользователь сущестует, иначе - false
*/
private boolean isUserExists(String userName) {
try {
userInfoDao.getUserByName(userName);
return true;
} catch (EmptyResultDataAccessException e) {
return false;
}
}
/**
* Проверка на то, что имя пользователя не содержится в стоп-листе
* @param userName имя пользователя
*/
private void checkNameSuspicious(String userName) {
if (Set.of("administrator", "root", "system").contains(userName)) {
// TODO: Заменить на свое исключение
RuntimeException exception = new RuntimeException(userName + " is unacceptable");
LOGGER.error("Check name failed", exception);
throw exception;
}
}
}
Замените использование dao-объекта на использование сервисного класса в WebController:
package org.example.web;
import org.example.model.UserInfo;
import org.example.service.UserInfoService;
import org.example.web.dto.CreateUserDto;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
/**
* Обработчик web-запросов
*/
@RestController
public class WebController {
/**
* Средство для вывода сообщений на экран
*/
private static final Logger LOGGER = LoggerFactory.getLogger(WebController.class);
/**
* Объект для работы с информацией о пользователе
*/
private final UserInfoService userInfoService;
/**
* Иньекция одних объектов в другие происходит через конструктор
* и обеспечивается библиотеками Spring
*/
public WebController(UserInfoService userInfoService) {
this.userInfoService = userInfoService;
}
/**
* Обработчик запросов на создание пользователя
* @param createUserDto запрос на создание пользователя
*/
@PostMapping("/users")
public void createUser(@Valid @RequestBody CreateUserDto createUserDto) {
LOGGER.info("Create user request received: {}", createUserDto);
/**
* Сохраняем пользователя, преобразуя DTO в модель
*/
userInfoService.createUser(
UserInfo.builder().setName(createUserDto.getName()).build()
);
}
/**
* Обработчик запросов на получение информации о пользователе
* @param userName имя пользователя
* @return информация о пользователе
*/
@GetMapping("/users/{userName}")
public UserInfo getUserInfo(@PathVariable String userName) {
LOGGER.info("Get user info request received userName={}", userName);
return userInfoService.getUserInfoByName(userName);
}
/**
* Обработчик запросов на удаление пользователя
* @param userName имя пользователя
*/
@DeleteMapping("/users/{userName}")
public void deleteUser(@PathVariable String userName) {
LOGGER.info("Delete user info request received userName={}", userName);
userInfoService.deleteUser(userName);
}
}
Запустите последовательно три тестовых запроса. Если все сделано правильно, будут получены ответы с кодом 200. Обратите внимание на записи в консоли приложения:
Create user request received: {name='JohnDoe'}
User created by user info: {name='JohnDoe'}
Get user info request received userName=JohnDoe
Delete user info request received userName=JohnDoe
User with name JohnDoe deleted
Они могут быт очень полезными при диагностике неполадок. Общие рекомендации по логгированию такие:
Информационное сообщение сразу при получении запроса с выводом содержимого запроса.
Информационное сообщение об успешности операции перед выходом из метода в сервисном классе.
Сообщение об ошибке сразу после catch. Не забудьте показать само исключение.
Сообщение об ошибке перед throw, если не было catch
Отладочные сообщения в сложных алгоритмах
Продолжение следует
Комментарии (10)
DmitryZlobec
19.07.2022 08:26Такой небольшой проект и без Lombok'а. А почему контроллеры ResponseEntity не возвращают?
upagge
21.07.2022 22:59А зачем, если можно не возвращать?)) Можно даже опшинал прокидывать, все нормально отработает
Nialpe
19.07.2022 08:52+2По моему опыту могу добавить, что контроллеры весьма неплохо разносить по сущностям - UserController, TaskController и т.п. В большом проекте очень много эндпоинтов и класс WebController в вашей интерпретации выйдет очень большим, заодно узнаете про @RequestMapping. Успехов.
karambaso
19.07.2022 10:58+1Большое количество шаблонного и в значительной части ненужного кода. И всё ради использования спринг и следования модному веянию "всё на микросервисах".
Второй момент - за кадром оставлена большая часть работы, касающаяся взаимодействия с пользователем. Она включает браузерную часть и серверную. Браузерную часть на спринге, понятно, вообще не стоит пробовать писать, но если писать серверную, то мы увидим ещё больше шаблонов и ещё больше ненужного текста.
В целом данный пример является стандартным для всех учившихся по методу "делай только так, и никак иначе, потому что я так сказал". И текст в итоге получился таким же приказом следовать всё тому же "я так сказал".
Если же разумно подходить к делу, то окажется, что все так называемые "стартеры" из спринга ускоряют только простейшие составляющие проекта, которые и без них заняли бы совсем немного времени в сравнении с наполнением полезным для бизнеса функционалом. А потому экономия на генерации микросервиса через спринг становится неоправданной не только из-за несущественности сэкономленного времени, но, самое важное, из-за необходимости везде тащить за собой навязываемую спрингом шаблонную модель работы, которая во многом неудобна, а местами просто вредна (например - навязывание показанного выше подхода к взаимодействию с БД).
ЗЫ. Интересно, как автору удалось слить карму в минус 33, имея всего одну статью и не имея ни одного комментария? На лицо какая-то нездоровая особенность системы.
ads83
19.07.2022 18:24+4Ох, сколько вопросов.
- Почему Builder-классы пишутся "руками" и не используется Lombock?
- Почему data-слой реализован через JDBC, а не Spring Data и какой-нибудь
UserRepo extends JpaRepository
? - Почему в тестовых запросах есть только happy path? Что должен ожидать неофит, если сделает тестовый запрос на создание пользователя "admin"?
- Почему проверка isUserExists() реализована через ловлю исключения, а не специфический запрос к БД?
- Действительно ли нужны все бины и параметры в
DatabaseConfiguration
для первого проекта? - На ваш взгляд, является ли хорошей практикой бросание исключений в контроллере, когда нет обработчиков исключений?
Пока у меня нет впечатления, что описанный вами подход — это "современная серверная разработка".
DonAlPAtino
20.07.2022 13:29org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'databaseConfiguration' defined in file [/Users/valp/IdeaProjects/web/build/classes/java/main/com/example/configuration/DatabaseConfiguration.class]: Unexpected exception during bean creation; nested exception is java.lang.IllegalArgumentException: Could not resolve placeholder 'db.username' in value "${db.username}"
Я понял, что application.yml не найден и параметры оттуда не прочитаны. А что сказать чтобы его наши?
aleksandy
А архитектура-то приложения неправильная. DAO-слой занимается управлением транзакцией, что приводит к неконсистентным данным.