Современная серверная разработка на языке Java: 2. Архитектура серверного приложения

от автора

Первая часть здесь

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

Выполнив последующие задания, вы получите приложение с такой архитектурой.
Выполнив последующие задания, вы получите приложение с такой архитектурой.

            Запросы от клиента через транспортный слой попадают в слой сервисов. Сервисы при помощи классов 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 тоже не стоит потому что:

  1. Нарушается принцип единой ответственности, класс начинает терять свою специализацию «работа с таблицей user_info»

  2. 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

                Они могут быт очень полезными при диагностике неполадок. Общие рекомендации по логгированию такие:

  1.  Информационное сообщение сразу при получении запроса с выводом содержимого запроса.

  2. Информационное сообщение об успешности операции перед выходом из метода в сервисном классе.

  3. Сообщение об ошибке сразу после catch. Не забудьте показать само исключение.

  4. Сообщение об ошибке перед throw, если не было catch

  5. Отладочные сообщения в сложных алгоритмах

Продолжение следует


ссылка на оригинал статьи https://habr.com/ru/post/677614/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *