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

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

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

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

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

Комментарии (10)


  1. aleksandy
    19.07.2022 06:59
    +3

    А архитектура-то приложения неправильная. DAO-слой занимается управлением транзакцией, что приводит к неконсистентным данным.


  1. DmitryZlobec
    19.07.2022 08:26

    Такой небольшой проект и без Lombok'а. А почему контроллеры ResponseEntity не возвращают?


    1. upagge
      21.07.2022 22:59

      А зачем, если можно не возвращать?)) Можно даже опшинал прокидывать, все нормально отработает


  1. Nialpe
    19.07.2022 08:52
    +2

    По моему опыту могу добавить, что контроллеры весьма неплохо разносить по сущностям - UserController, TaskController и т.п. В большом проекте очень много эндпоинтов и класс WebController в вашей интерпретации выйдет очень большим, заодно узнаете про @RequestMapping. Успехов.


  1. karambaso
    19.07.2022 10:58
    +1

    Большое количество шаблонного и в значительной части ненужного кода. И всё ради использования спринг и следования модному веянию "всё на микросервисах".

    Второй момент - за кадром оставлена большая часть работы, касающаяся взаимодействия с пользователем. Она включает браузерную часть и серверную. Браузерную часть на спринге, понятно, вообще не стоит пробовать писать, но если писать серверную, то мы увидим ещё больше шаблонов и ещё больше ненужного текста.

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

    Если же разумно подходить к делу, то окажется, что все так называемые "стартеры" из спринга ускоряют только простейшие составляющие проекта, которые и без них заняли бы совсем немного времени в сравнении с наполнением полезным для бизнеса функционалом. А потому экономия на генерации микросервиса через спринг становится неоправданной не только из-за несущественности сэкономленного времени, но, самое важное, из-за необходимости везде тащить за собой навязываемую спрингом шаблонную модель работы, которая во многом неудобна, а местами просто вредна (например - навязывание показанного выше подхода к взаимодействию с БД).

    ЗЫ. Интересно, как автору удалось слить карму в минус 33, имея всего одну статью и не имея ни одного комментария? На лицо какая-то нездоровая особенность системы.


  1. ads83
    19.07.2022 18:24
    +4

    Ох, сколько вопросов.


    1. Почему Builder-классы пишутся "руками" и не используется Lombock?
    2. Почему data-слой реализован через JDBC, а не Spring Data и какой-нибудь UserRepo extends JpaRepository ?
    3. Почему в тестовых запросах есть только happy path? Что должен ожидать неофит, если сделает тестовый запрос на создание пользователя "admin"?
    4. Почему проверка isUserExists() реализована через ловлю исключения, а не специфический запрос к БД?
    5. Действительно ли нужны все бины и параметры в DatabaseConfiguration для первого проекта?
    6. На ваш взгляд, является ли хорошей практикой бросание исключений в контроллере, когда нет обработчиков исключений?

    Пока у меня нет впечатления, что описанный вами подход — это "современная серверная разработка".


    1. upagge
      21.07.2022 23:02

      Ну на второй вопрос можно ответить, что для скорости))


  1. DonAlPAtino
    20.07.2022 13:29

    org.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 не найден и параметры оттуда не прочитаны. А что сказать чтобы его наши?


  1. mrLowton
    21.07.2022 09:13
    +1

    Никого не смутило отсутствие аннотации Service на сервисе?


    1. DonAlPAtino
      21.07.2022 15:35

      Спасибо - сэкономил мне время на попытки понять на что ругается IDEA.