08.

Spring Boot and MVC
1 часть
06.
Spring Boot
and MVC
1 часть
уроки
Что к чему
Spring — это общее название для группы фреймворков, реализующих разнообразную функциональность: безопасность, web-приложения, инструменты для доступа к данным, интеграции между сервисами, обработки больших объемов данных.

Фреймворк предоставляет способ удобно и быстро создавать Java-приложения. На этой странице можно посмотреть, какие проекты существуют. Spring придерживается модульной структуры, так что вы можете собрать нужную именно вам конфигурацию. В данном курсе мы остановимся на нескольких основных частях, которые используются в большинстве компаний: Core, MVC, Testing, Security, Data Access.

Отдельно остановимся на Spring Boot. В какой-то момент конфигурация каждого нового проекта с использованием Spring'a стала превращаться в отдельную большую и очень скучную задачу. Boot минимизирует необходимую конфигурацию и позволяет легко создавать приложения на основе Spring, которые можно «просто запустить».

Quick Start
Для создания проекта воспользуемся spring initializr. В "Dependencies" добавим "Spring Web", остальные настройки пока трогать не будем.
Скачиваем сгенеренный проект, распаковываем и открываем в любимой ide. Теперь можно перейти к разбору структуры проекта и того, как именно работает Spring Boot.

Сборка проекта и управление зависимостями
Для начала разберемся, что у нас получилось на выходе из initializr'а. В нашем проекте стандартная структура пакетов Java приложения:
  • в папке src/main/java содержатся java-классы
  • в src/main/resources — ресурсы, которые использует наше приложение (HTML-страницы, картинки, проперти и тд);
  • src/test — тесты
И один класс DemoApplication.java с аннотацией @SpringBootApplication, добавление которой приводит к созданию всего инфраструктурного кода за нас. Для того чтобы это работало нам нужна библиотека spring-boot-autoconfigure, которая в свою очередь зависит от библиотеки spring-boot, а она еще от двух и так далее, при этом каждая библиотека имеет версию. Некоторые версии библиотек могут быть несовместимы между собой. У двух разных библиотек могут быть зависимости от третьей библиотеки, но разных ее версий.

Даже в таком маленьком примере возникает много проблем. Для их решения были придуманы инструменты управления проектами. Мы будем использовать Maven.

Что такое Maven?
Maven — это фреймворк для автоматизации сборки проектов на основе описания их структуры в файлах POM (project object model). В корне нашего проекта есть файл pom.xml Это и есть главный файл для управления Maven'ом. Давайте разберем, из чего он состоит.
  • Корневой элемент project, в котором прописана json-схема и версия POM.
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 
   ... 
</project>
  • Maven-проекты можно наследовать. Это полезно в ситуации, когда необходимо объединить несколько независимых модулей в один проект. Для этого используется тег parent. В нашем случае spring-boot-starter-parent предоставляет дефолтную конфигурацию для некоторых плагинов и другие настройки по мелочи. При желании можно легко обойтись без него.
  • properties — это переменные, которые потом можно использовать в других частях конфигурации.
  • В Maven каждый проект идентифицируется парой groupId, artifactId. Во избежание конфликта имён используется следующая конвенция:
groupId – наименование организации (обычно это доменное имя, записанное в обратном порядке
artifactId – название проекта. Внутри тега version хранится версия проекта. Тройкой groupId, artifactId, version можно однозначно идентифицировать jar-файл приложения или библиотеки. Пример:

<dependency>
    <groupId>com.example</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</dependency>
  • Теги name и description не используются самим Maven'ом. Они нужны для других программистов, чтобы понять, о чем этот проект.
  • Тег packaging определяет, во что мы будем собирать наше приложение. Возможные варианты – jar, war, ear. Тег является необязательным, значение по умолчанию – jar. Есть отдельно стоящий тип – pom. Он указывает Maven'у, что данный проект является контейнером для подпроектов (submodules). Каждый из подпроектов находится в отдельной поддиректории со своим pom.xml.
  • Под тегом dependencies хранится список всех библиотек (зависимостей), которые используются в проекте. Каждая библиотека идентифицируется так же, как и сам проект – тройкой groupId, artifactId, version. Можно указать, для чего будет использоваться библиотека: например, у spring-boot-starter-test указан тег <scope>test</scope> — это указывает Maven'у, что библиотека нужна только для выполнения тестов.
После добавления зависимости необходимо "обновить" проект, чтобы можно было ей пользоваться. Если работаете в IDEA, то нужно нажать на кнопку Reload project во вкладке Maven. Также можно просто вызвать mvnw package, в процессе сборки проекта Maven обновит зависимости.

  • Тег build содержит информацию про саму сборку:
→ место, где находятся исходные файлы
→ место, где находятся ресурсы
→ используемые плагины

Пара слов про плагины.
Maven работает на плагинах, каждый из которых выполняет какую-то определенную задачу. Есть плагины, подключенные по умолчанию, например:
  • maven-compile-plugin – компилирует исходный код проекта
  • maven-jar-plugin – собирает ваш проект в jar-пакет
  • maven-resources-plugin – копирует ресурсы вашего приложения в указанную директорию для последующих шагов
Вы можете подключать другие плагины или писать свои собственные — это очень мощный инструмент, позволяющий расширить функционал Maven'а. Например, плагин maven-checkstyle-plugin используется для проверки качества исходного кода, а maven-javadoc-plugin - для генерации документации с использованием утилиты javadoc. При описании плагина в pom.xml можно зафиксировать версию плагина, задать ему необходимые параметры и привязать к фазам сборки (то есть указать, когда именно плагин нужно запускать).

Жизненный цикл сборки
Maven-команды, которые вы запускаете в своем проекте, могут зависеть от других задач. Например, нельзя опубликовать jar-пакет в репозиторий, не собрав его предварительно, или собрать jar, не скомпилировав код. Жизненный цикл — это описание последовательности выполнения команд при сборке.

Основные фазы сборки:
  1. compile – компилирование проекта
  2. test – тестирование с помощью JUnit тестов
  3. package – создание jar-файла (war, ear, в зависимости от типа проекта)
  4. integration-test – запуск интеграционных тестов
  5. install – копирование jar (war, ear) в локальный репозиторий
  6. deploy – публикация файла в удалённый репозиторий.

Если вызвать
  • mvn package, то перед созданием jar-файла будут выполняться все предыдущие фазы (compile и test), а фазы integration-test, install, deploy не выполнятся. Если вызвать
  • mvn deploy, то выполнятся все приведённые выше фазы.

Особняком стоят фазы clean и site. Они не выполняются, если специально не указаны в строке запуска.
  • clean – предназначена для удаления всех созданных в процессе сборки артефактов: .class, .jar и др. файлов. В простейшем случае результат — это просто удаление каталога target.
  • site – предназначена для создания документации.
Команда mvn понимает, когда передают несколько фаз, поэтому для сборки проекта "с нуля" и создания документации можно вызвать mvn clean package site.

Репозитории
Репозитории – это место, где хранятся артефакты: jar-файлы, pom-файлы, javadoc, исходники. Существует несколько видов репозиториев.

Локальный репозиторий по умолчанию расположен в /.m2/repository. Здесь лежат артефакты, которые были скачаны из центрального репозитория либо добавлены другим способом. Например, если набрать команду
mvn install в текущем проекте, то соберётся jar, который установится в локальный репозиторий.

Центральный репозиторий. Чтобы самому каждый раз не создавать репозиторий, сообщество поддерживает центральный репозиторий. Если для сборки вашего проекта не хватает зависимостей, то они по умолчанию автоматически скачиваются с http://repo1.maven.org/maven2 (Maven Central). В этом репозитории лежат практически все опенсорсные фреймворки и библиотеки. Самому в центральный репозиторий добавить артефакт нельзя. Так как этот репозиторий используют все, то перед тем как туда попадают артефакты, они проверяются, тем более, что если артефакт однажды попал в репозиторий, то по правилам изменить его нельзя.

Существуют также и другие репозитории, поддерживаемые сообществом, но их нужно руками добавить в pom-файл, чтобы Maven начал искать там артефакты. Вот тут, например, список популярных репозиториев.

Если вы хотите создать свой репозиторий, содержимое которого вы сможете полностью контролировать (как локальный), и сделать так, чтобы он был доступен не только вам, то для этого есть специальные решения, например GitHub Packages, Nexus или Artifactory.

Что за wrapper такой?
В корне проекта также лежит папка .mvn/wrapper, в ней находится jar-файл плагина Maven Wrapper и файл его конфигурации. Maven Wrapper — это простой способ убедиться, что у конечного пользователя вашего билда есть все необходимое, чтобы запустить этот билд. Это может пригодиться, если требуется предоставлять полностью инкапсулированную настройку сборки.

Есть такая штука Gradle
Maven уже можно считать стандартом в индустрии, его можно встретить в большом количестве open-source продуктов. Но это не единственная система сборки в мире Java. Gradle — автоматическая система сборки и управления зависимостями, построенная на принципах Maven, но предоставляющая DSL на groovy/kotlin для конфигурации проекта, что делает Gradle более мощным и выразительным. Есть отличия и в подходе к управлению жизненным циклом сборки — Gradle использует направленный граф для определения порядка выполнения задач, что позволяет существенно ускорить сборку для крупных многомодульных проектов (так как можно определить, изменились ли входные/выходные данные для задачи, и если нет, то не выполнять ее).
Многомодульные проекты в Maven
Maven (как и Gradle) поддерживает многомодульные проекты. То есть в одном репозитории у вас может быть несколько микросервисов, которые объединены одним Maven-проектом. Это позволяет запускать все тесты и все билды одной командой.

Поскольку в следующих модулях курса мы добавим еще один микросервис в тот же репозиторий, давайте сразу превратим наш проект в многомодульный (хотя сейчас модуль там будет всего один):
  1. Создайте новую директорию book-service в проекте.
  2. Перенесите туда директорию src, а также pom.xml.
  3. Добавьте в корень проекта новый pom.xml со следующим содержанием:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>base-project</artifactId>
    <version>0.1.0</version>
    <packaging>pom</packaging>

    <modules>
        <module>book-service</module>
    </modules>

</project>
В <modules> мы указываем список других сервисов, которые нужно объединить в один Maven-проект.

Обновите проект – если вы все сделали правильно, то теперь модуль book-service находится внутри корневого Maven-проекта.

Если вы у вас возникли трудности, посмотрите пример готового проекта по этой ссылке.
Инверсия управления
Прежде чем перейти к разбору деталей работы Spring'а, рассмотрим принципы, лежащие в его основе. Одним из них является инверсия управления, она же IoC (Inversion of Control). Инверсия управления - это то, что по сути различает фреймворк и библиотеку. Библиотека — это набор функций/классов, которые вы можете вызывать. Каждый вызов выполняет некоторую работу и возвращает управление обратно пользователю.

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

Существуют различные способы подключить ваш код для его дальнейшего вызова. Например, можно использовать замыкания, или же фреймворк может определять события, но тогда код пользователя должен быть подписан на эти события. Или фреймворк может определять интерфейс, который ваш код должен будет реализовать для соответствующих вызовов. Например, в JUnit код фреймворка вызывает методы setUp и tearDown для вас, чтобы создавать и очищать ваш тест. Происходит вызов, ваш код реагирует — это тоже инверсия управления.

IoC — это общий принцип, который используется почти повсеместно. Spring использует IoC для "сборки" приложения из маленьких кусков. Такой паттерн называется внедрением зависимостей, он же Dependency Injection (DI). Давайте разберем, как это работает, на простом примере. Допустим у нас есть компонент, который умеет находить курсы по имени автора.
public class CourseLister {
    // ...

    public List<Course> сoursesByAuthor(String name) {
        List<Course> allCourses = finder.findAll();
        return allCourses
                   .stream()
                   .filter(course -> course.getAuthor().equals(name))
                   .collect(Collectors.toList());
    }
}
Такая реализация этой функции супер наивная: получаем все курсы от finder'а и просматриваем каждый курс на совпадение его автора с запрашиваемым. Конечно, в продакшене не стоит фильтровать объекты в памяти, а лучше сразу передать параметр в sql-запрос. Но сейчас мы не будем это фиксить, так как хотим обсудить другой вопрос.
Что такое и откуда взялся finder? finder — это та сущность, которая непосредственно отвечает за чтение данных из того места, где они хранятся, и поиск по ним (метод findAll, как следует из названия, возвращает просто все, что есть). Мы хотим сделать наш метод сoursesByAuthor полностью независимым от способа и формата хранения курсов в нашей системе, а для этого нам нужно иметь возможность быстро и удобно подменять конкретную реализацию finder'а. Любой класс, реализующий finder, должен знать, как отвечать на findAll метод. Давайте определим интерфейс этого finder' а:
public interface CourseFinder {
    List<Course> findAll();
}
Теперь наши классы хорошо разделены между собой, но в какой-то момент мы должны определиться и создать экземпляр конкретного класса, который будет искать курсы.

Давайте добавим это определение в конструктор нашего класса:
public class CourseLister {
    private CourseFinder finder;

    public CourseLister() {
        this.finder = new FileBasedCourseFinder("courses.txt");
    }
    //...
}
Пока мы одни работаем с этим кодом, все хорошо. Давайте представим ситуацию: кто-то из команды захотел переиспользовать наш код. Но вот незадача, он хранит данные о курсах в другом формате. Это может быть XML, SQL-база данных, web-сервис и т.д. В таком случае нам нужен другой класс для получения данных. Так как мы абстрагировали наш метод сoursesByAuthor от способа получения курсов, то вносить изменения в него теперь не нужно. Но мы все еще должны каким-то способом указать правильную реализацию finder'а. На диаграмме показаны зависимости для наших классов:
Наш класс зависит от интерфейса CourseFinder и от конкретной его реализации. Хочется, чтоб зависимость была только от интерфейса, в таком виде код будет сильно легче менять или переиспользовать. Как мы можем этого достичь? Так как мы хотим, чтобы наш Lister мог работать с любой имплементацией, то эта реализация должна встраиваться в наш класс где-то выше уровнем и без нашего ведома. Для решения этой проблемы Spring использует паттерн Dependency Injection.

Основная идея DI заключается в том, что за выбор нужной имплементации отвечает отдельная сущность-"сборщик" (Assembler), он же внедряет ее в наш Lister. Диаграмма зависимостей начинает выглядеть так:
Добавлять зависимость в класс можно разными способами (Constructor Injection, Setter Injection, Interface Injection), но это все детали, не меняющие базовую идею. Перепишем наш пример с использованием этой возможности:
@Component
public class CourseLister {
    private final CourseFinder finder;

    @Autowired
    public CourseLister(CourseFinder finder) {
        this.finder = finder;
    }

    public List<Course> сoursesByAuthor(String name) {
        List<Course> allCourses = finder.findAll();
        return allCourses
                   .stream()
                   .filter(course -> course.getAuthor().equals(name))
                   .collect(Collectors.toList());
    }
}
В Spring используются аннотации для указания деталей конфигурации того или иного класса. Аннотация @Component указывает на то, что объект этого класса должен создавать сборщик. @Autowired указывает на то, что в этом месте нужно внедрить зависимость. Теперь наш код полностью изолирован от конкретной реализации finder'а, и мы можем его свободно переиспользовать.

Зачем нужны аннотации?
Аннотации сами по себе не содержат никакой логики. Это просто маркер. Но если вы откроете main функции вашего проекта, то увидите там такую строчку:
public class Main {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}
В вашем случае класс может называться не DemoApplication, а как-то по-другому.

По сути, когда стартует Spring Boot приложение, оно начинает сканировать все классы вверх по иерархии в вашем проекте. То есть если ваш main находится в пакете com.mycompany, то будут просканированы все классы, у которых пакет начинается c com.mycompany. Значит, что com.mycompany.service, com.mycompany.repository, а также сам пакет com.mycompany тоже будут просканированы.

Когда Spring видит класс, на котором стоит аннотация @Component, он понимает, что он является его частью. Так что он создает экземпляр этого класса (в коде вы бы это сделали через new) и добавляет его в Spring Context. Если по-простому, Spring Context – это HashMap, который содержит экземпляры всех классов, которые Spring воспринял как beans (так называются те классы, которые просканированы Spring, а их экземпляры добавлены в контекст). Бины хранятся в качестве значения HashMap. Ключом же выступает название бина. По умолчанию это имя класса с маленькой буквы. Если в вашем приложении несколько классов с одинаковыми именами, то Spring может поменять стратегию наименования по умолчанию, чтобы сохранить уникальность.

У вас может возникнуть вопрос: а как Spring может создать экземпляр класса CourseLister, если его конструктор принимает зависимость? Все просто. Если параметры конструктора также являются бинами, то Spring просто подставит их. Таким образом, вы можете добавлять новые классы в ваше приложение и при этом не заботиться о том, чтобы собирать их целиком в единую матрешку (то, что вы делали в практике в курсе Java Core). Spring сделает это за вас. Аннотация @Autowired над конструктором как раз сигнализирует о том, что с помощью него нужно создавать объект и внедрять туда зависимости. Отсюда и название – Autowired. Строго говоря, в современных версиях Spring ставить аннотацию @Autowired не обязательно, если конструктор в классе у вас один. Spring и так поймет, что его нужно вызвать. Но многие разработчики продолжают ее ставить для наглядности.

Все бины по умолчанию синглтоны. То есть если вы инжектите один и тот же класс в разных местах, то будете получать один и тот же объект, который создал для вас Spring. Это поведение логично во многих сценариях. Тем не менее вам следует помнить, что если вы хотите внутри класса, который является Spring Bean, хранить и менять какое-то состояние, то вам нужно соблюдать правила потокобезопасного доступа, которые вы изучили в модуле Java Core. То есть использовать потокобезопасные коллекции, атомики, locks и так далее.

Создание экземпляров класса происходит с помощью механизма Reflection API. Дело в том, что class в Java тоже является объектом, у которого есть методы. Получив его, можно создавать экземпляры этого объекта.

Посмотрите на пример кода ниже:
public class Main {
    public static void main(String[] args) {
        Class<?> petClass = Pet.class;
        Constructor<?> constructor = pet.getConstructors()[0];
        // экземпляр класса Pet
        Object pet = constructor.newInstance("Барбос", 4);
    }
}

public class Pet {
    public Pet(String name, int age) {
        /* инициализация в конструкторе... */
    }
}
Spring работает похожим образом. Он сканирует все классы в проекте, находит те, которые являются bean'ами (аннотация @Component), создает экземпляры классов и добавляет их в контекст приложения.
Если вам интересно узнать подробнее о работе Spring, а также причины его появления, рекомендуем посмотреть
доклад «Spring-построитель».

Циклические зависимости
А что, если два бина будут ссылаться друг на друга? Предположим, что у нас есть Husband и Wife каждый из них принимает другого в качестве параметра. Посмотрите на пример кода ниже:
@Component
public class Wife {
    private final Husband husband;

    public Wife(Husband husband) {
        this.husband = husband;
    }
}

@Component
public class Husband {
    private final Wife wife;

    public Husband(Wife wife) {
        this.wife = wife;
    }
}
Что произойдет, если мы попытаемся запустить такое Spring приложение? Вы получите ошибку и приложение завершится аварийно. Это вполне логично, потому что такую зависимость выстроить невозможно. Посмотрите на пример кода ниже:
public class Main {
    public static void main(String[] args) {
        new Wife(
            new Husband(
                /* что подставлять сюда? */
            )
        );
    }
}
Как видите, Husband в качестве параметра принимает Wife.
Но Wife уже и так находится в процессе создания и пока мы не можем получить ссылку на этот объект.

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

Через сеттеры:
@Component
public class Wife {
    private Husband husband;

    @Autowired
    public void setHusband(Husband husband) {
        this.husband = husband;
    }
}

@Component
public class Husband {
    private Wife wife;

    @Autowired
    public void setWife(Wife wife) {
        this.wife = wife;
    }
}
Через поля:
@Component
public class Wife {
    @Autowired
    private Husband husband;
}

@Component
public class Husband {
    @Autowired
    private Wife wife;
}
В обоих случаях просходит следующее:
  1. Создаются объекты Wife и Husband с помощью конструкторов без аргументов.
  2. Зависимости инжектируются по очереди.
Теперь никакого противоречия нет, но мы не рекомендуем пользоваться таким способом. Циклические зависимости – это серьезная архитектурная проблема. Лучше ее совсем не допускать, чем исправлять впоследствии.

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

Вы можете поинтересоваться, как Spring смог внедрить зависимости напрямую в поле. Оно ведь private, а к ним есть доступ только у класса, верно? Дело в том, что в Java есть механизм reflection, который позволяет обращаться к полям класса напрямую, невзирая на объявленный тип доступа. С помощью этого механизма Spring также определяет, присутствует ли интересующая его аннотация над классом, или нет.
@Component/@Service/@Repository и @Configuration
Не только аннотацией @Component можно объявлять бины в Spring. Есть еще @Service. На текущий момент технической разницы между ними нет и они используются скорее для зрительного разделения частей приложения. Можете использовать любую из них, на работоспособность это не повлияет.

Есть еще аннотация @Repository. Она тоже маркирует класс как бин, но также при ее наличии Spring дополнительно обернет полученный объект в прокси. Этот прокси будет отлавливать все возможные исключения, которые могут произойти, и оборачивать их в специальные спринговые исключения. Эта аннотация применяется при использовании Spring Data (рассмотрим далее в курсе). Если при доступе к БД произошло неожиданное исключение, то Spring автоматически обернет его в свое, кастомное. Процесс же оборачивания в прокси называется аспектно-ориентированным программированием. Про него читайте в конце данного модуля.

Также бывают ситуации, когда мы явно хотим создавать наши бины через new. Например, мы хотим зашить какую-то императивную логику, которую нельзя описать через аннотации. Для этого используются аннотации @Configuration и @Bean. Посмотрите на пример кода ниже:
@Configuration
public class Config {
    @Bean
    public Husband husband() {
        return new Husband();
    }

    @Bean
    public Wife wife() {
        return new Wife();
    }
}
Spring вызовет методы husband() и wife() и добавит полученные объекты в Spring Context точно так же, как сделал бы это при наличии аннотации @Component.

Важный момент. Если вы объявили какие-то бины с помощью @Confiuguration и @Bean, то не ставьте дополнительно над классом @Component/@Service/@Repository. Потому что иначе Spring создаст два объекта вместо одного и вас больше не будет синглтонов. Во-первых, это может привести к неожиданным ошибкам, если вы храните в классе какой-то state. Во-вторых, если у вас два инстанса Husband и вы с помощью @Autowired заходите куда-то внедрить Husband, то Spring-приложение упадет при старте. Потому что существует два объекта класса Husband и не понятно, какой из них следует внедрять. Это ограничение можно обойти. Об этом также читайте далее в модуле.

Теперь можно перейти к рассмотрению деталей работы Spring изнутри и Spring Boot'а в частности.

Вместо заключения
Какие проблемы мы решили в нашем коде, используя инструменты DI в Spring?

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

DI — не единственный паттерн, позволяющий решать такого рода проблемы. Если тема вызвала интерес, можно еще почитать про Service Locator.

Spring
Небольшая историческая справка. Когда-то давно еще компания Sun представила спецификацию JavaBeans. Это набор соглашений, которых следует придерживаться при написании классов. Например, у всех филдов должны быть сеттеры и геттеры в формате getX и setX соответственно. Это нужно в первую очередь для создателей фреймворков, так как дает определенные гарантии при работе с пользовательски кодом. Отсюда пошло, что объекты в Spring'е называются Beans, а их описание BeanDefinition.

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

Нам нужен некий сборщик (в Spring это BeanFactory), который может создать за нас наши бины, внедрить в них зависимости и правильно их настроить. Место для хранения всех бинов называется ApplicationContext. Если очень сильно упрощать, то можно думать о нем как о хешмапе, в которой ключом является имя бина, а значением — созданный и настроенный объект. Последняя важная часть в этом процессе — это конфигурация приложения. Spring поддерживает разные варианты конфигураций – XML, Component Scan, Java Config и некоторые другие. Хотя некоторые из них морально устарели (например, xml), все равно иногда нужно понимать, что происходит, например, при работе с легаси проектом. Дальше в примерах мы будем использовать Annotation based подход к конфигурации.

Итак, наша задача — это собрать приложение ("поднять контекст").
Основные этапы в этом процессе следующие:
  • Парсинг конфигурации. За это отвечает BeanDefinitionReader. Основная задача этого этапа — собрать вместе все декларации бинов, описанные вами.
  • Настройка конфигурации. BeanFactoryPostProcessor. На этом этапе в распаршенное описание могут вноситься изменения. Например, именно на этом этапе происходит замена пропертей на их реальные значения. (Дальше будет подробнее про это рассказано)
  • Создание бинов. По заданным описаниям BeanFactory создает уже настоящие объекты для нашего приложения.
  • Настройка созданных бинов. BeanPostProcessor. На этом этапе происходит донастройка бинов. Может быть сгенерен дополнительный код и добавлен к вашему.
Необходимости понимать в деталях, что и на каком уровне происходит, для повседневной работы нет. Главное знать, что нет никакой магии, и это просто "конвейер", который собирает ваш код в готовое приложение.

Что такое Spring Boot?
В какой-то момент конфигурация Spring'а стала сложной задачей сама по себе. Для того чтобы облегчить жизнь разработчикам и снять с них головную боль от настройки проекта, был разработан Spring Boot. Что же он умеет?
  • Приносит в проект набор зависимостей через наследование в pom-файлах. Эти зависимости собраны и протестированы командой разработки Spring'а, поэтому вам не надо думать над вопросом: "А какую же версию очередной библиотеки выбрать, чтобы она была совместима с остальными?" Тут есть небольшая особенность, которую мы не обсудили раньше в части про Maven. Спринг знает, какая версия будет работать "хорошо". Но чтобы не тащить тысячи либ в каждый проект, он указывает это как хинты. Можно указать зависимость без версии, и тогда версия возьмется из этого хинта. Вам понадобился kafka-клиент в вашем проекте? Просто добавляете org.springframework.kafka:spring-kafka в зависимости проекта, а подходящая версия выберется автоматически.
  • 95% инфраструктурных бинов настраиваются однотипным образом. DataSource, EntityManagerFactory, TransactionManager — все эти сущности обычно одинаковы в настройке и требуют осмысленных изменений только в специфичных задачах. Сюда же можно отнести настройки бинов MVC фреймворка - ViewResolver, MessageSource и т.д. Для того чтобы не писать один и тот же код из проекта в проект в Spring Boot есть механизм SpringBootStarter'ов. Каждый из страртеров создает некоторый набор преднастроенных бинов за вас и помещает их в контекст, а вы уже можете спокойно ими пользоваться в вашем коде.
  • Spring Boot предоставляет простую возможность для кастомизации автонастроенных бинов. Не хотите класть Thymeleaf-темплейты в /WEB-INF/views/, а вместо этого держать их в /WEB-INF/templates/? Это делается добавлением одной строчки, просто надо переопределить переменную в своем проекте.
Приведем небольшой пример того, сколько же настроек приносит с собой Spring Boot. Немного изменим код в нашем проекте и поставим точку остановки в main методе, как на скриншоте:
Посмотрим, сколько бинов создалось в нашем контексте:
То есть в приложении, состоящем из одной строчки SpringApplication.run(DemoApplication.class, args) Spring Boot создал и настроил за нас 132 инфраструктурных бина. И это не предел, существуют много разных стартеров под разные задачи, которые можно добавить в свой проект.
GoF: Декоратор в Spring
Ранее вы уже познакомились с шаблоном Декоратор. Давайте на примере того же UserService, который мы рассматривали в ураке по GoF-паттернам, посмотрим, как можно реализовать Декоратор при использовании Spring.
Чтобы созданием и внедрением классов, реализующих интерфейс UserService, занимался фреймворк, необходимо сделать их бинами. Для этого добавим аннотации @Component над классами и @Autowired над полями, в которые они должны инжектиться.
@Component
public class UserServiceImpl implements UserService {
    ...
}

@Component
public class AuditUserService implements UserService {
    @Autowired
    private UserService origin;
    ...
}

@Component
public class EmailUserService implements UserService {
    @Autowired
    private UserService origin;
    ...
}
Однако теперь в спринговом контексте будет несколько бинов с одинаковым типом UserService, в результате чего при старте приложения возникнет исключение, поскольку Spring не сможет определить, какой именно из этих бинов нужно заинжектить. Чтобы исправить это, воспользуемся аннотацией @Qualifier, указав в её параметре нужный бин:
@Component("userServiceImpl")
public class UserServiceImpl implements UserService {
    ...
}

@Component("auditUserService")
public class AuditUserService implements UserService {
    @Autowired
    @Qualifier("userServiceImpl")
    private UserService origin;
    ...
}

@Component("emailUserService")
public class EmailUserService implements UserService {
    @Autowired
    @Qualifier("auditUserService")
    private UserService origin;
    ...
}
Обратите внимание, что у аннотаций @Component появился параметр. Значение именно этого параметра используется в аннотации @Qualifier.

В результате у нас получился такой Декоратор: EmailUserService будет вызывать AuditUserService, а он в свою очередь будет вызывать UserServiceImpl.

Здесь в параметре аннотации @Component мы указываем название Spring Bean. У каждого Spring Bean в приложении название должно быть уникально. Если в @Component ничего не указывать, Spring автоматически сгенерирует название bean как camelCase строчку.

В аннотации же @Qualifier мы указываем название бина, который хотим заинжектить.

Чтобы в клиентском коде без проблем подключить самый первый класс из этой цепочки (EmailUserService), можно тоже воспользоваться аннотацией @Qualifier, указав emailUserService в качестве её параметра. Однако так мы получим сильное связывание клиентского кода и классов в Декораторе. Поэтому если его реализация поменяется и в нём изменится первое звено, то придётся модифицировать и клиентский код.

В данном случае лучше не использовать @Qualifier. А чтобы Спринг понял, какой конкретно бин нужно заинжектить в клиентском коде, над классом EmailUserService необходимо поставить аннотацию @Primary:
@Component
@Primary
public class EmailUserService implements UserService {
    ...
}
Параметр у аннотации @Component над этим классом можно убрать за ненадобностью.
Теперь везде, где инжектится UserService без использования @Qualifier, будет использоваться именно EmailUserService.

Цепочка обязанностей
Предположим, что мы разрабатываем систему авторизации пользователей. Пусть в начальной реализации мы поддерживаем Basic-Auth и JWT. Чтобы успешно авторизовать пользователя, требуется проверить следующие условия:
  1. Если значение в заголовке Authorization начинается с Basic, пытаемся авторизовать пользователя по Basic-Auth.
  2. Если значение в заголовке Authorization начинается с Bearer, пытаемся авторизовать пользователя по JWT.
  3. Иначе возвращаем код 401.
Наивная реализация может выглядеть следующим образом.
@Service
public class SecurityService {
  public Response authorize(Request request) {
    if (request.getHeader(AUTHORIZATION).startsWith("Basic")) {
      return tryBasicAuth(request);
    }
    if (request.getHeader(AUTHORIZATION).startsWith("Bearer")) {
      return tryJwt(request);
    }
    return new Response(UNAUTHORIZED);
  }
}
Все выглядит неплохо до того момента, пока число возможных вариантов авторизации невелико. Но представьте, во что превратится метод, если мы добавим OAuth2, авторизацию через HTTP Cookie, доступ через одноразовые токены и так далее.

Цепочка обязанностей поможет нам разделить функциональность.
Во-первых, объявим абстрактный сервис безопасности.
public abstract class SecurityService {
  protected SecurityService next;
  
  public void setNext(SecurityService next) {
    this.next = next;
  }
  
  public abstract Response authorize(Request request);
}
Далее для каждой проверки добавим кастомный сервис.
@Service
public class BasicAuthSecurityService extends SecurityService {
  
  @Override
  public Response authorize(Request request) {
    if (request.getHeader(AUTHORIZATION).startsWith("Basic")) {
      return tryBasicAuth(request);
    }
    return next.authorize(request);
  }
}

@Service
public class JwtAuthSecurityService extends SecurityService {

  @Override
  public Response authorize(Request request) {
    if (request.getHeader(AUTHORIZATION).startsWith("Bearer")) {
      return tryJwtAuth(request);
    }
    return next.authorize(request);
  }
}

@Service
public class UnauthorizedSecurityService extends SecurityService {

  @Override
  public Response authorize(Request request) {
    return new Response(UNAUTHORIZED);
  }
}
Каждый элемент в цепочке пытается выполнить некую бизнес-логику. Если требуемые условия не соблюдаются, вызов передается далее.

UnauthorizedSecurityService – это специальный объект, который служит последним элементом в цепочке. Срабатывает в том случае, если условия для всех предыдущих фильтров не были удовлетворены.

Теперь возникает вопрос: как настроить эту цепочку в экосистеме Spring? Во-первых, нам нужен признак, который будет однозначно идентифицировать ту или иную реализацию. Для этого можно воспользоваться bean id. Чтобы задать кастомный идентификатор, достаточно указать его в аннотации @Service/@Component/@Repository.
@Service("basic")
public class BasicAuthSecurityService extends SecurityService {
  ...
}

@Service("jwt")
public class JwtAuthSecurityService extends SecurityService {
  ...
}

@Service("unauthorized")
public class UnauthorizedSecurityService extends SecurityService {
  ...
}
Теперь объявим SecurityServiceFacade, куда "заинжектим" все бины типа SecurityService.
@Service
public class SecurityServiceFacade {
  private final SecurityService entry;
  
  public SecurityServiceFacade(Map<String, SecurityService> services) {
    SecurityService basic = services.get("basic"); 
    SecurityService jwt = services.get("jwt"); 
    SecurityService unauthorized = services.get("unauthorized");
    basic.setNext(jwt);
    jwt.setNext(unauthorized);
    this.entry = basic;
  }

  public Response authorize(Request request) {
    return this.entry(request);
  }
}
Spring позволяет инжектировать не только конкретные bean-ы, но и List и Map. Если указать List<SecurityService>, то Spring подставит список всех bean-ов типа SecurityService. Если же указать Map<String, SecurityService>, то Spring проинжектит «мапу» вида название bean -> сам bean.

SecurityServiceFacade.authorize выступает точкой входа, которая проксирует запрос в entry. Далее вызов идет по выстроенной нами цепочке.

Вариант с идентификацией бинов по их id не лишен изъянов. Во-первых, нам приходится завязываться на строки без возможности использовать enum, что лишает нас преимуществ статической типизации. Во-вторых, bean id является инфраструктурной информацией. Если мы поменяем способ декларации сервиса (например, решим объявить его с помощью @Bean), то по невнимательности можем изменить bean id.

Поэтому есть другой вариант дифференциации различных реализаций одного интерфейса.
Давайте добавим метод getType в SecurityService.
public abstract class SecurityService {
  protected SecurityService next;
  
  public void setNext(SecurityService next) {
    this.next = next;
  }
  
  public abstract SecurityType getType();
  
  public abstract Response authorize(Request request);
}
Каждая реализация должна сообщать, к какому типу она относится.
@Service
public class BasicAuthSecurityService extends SecurityService {
  ...
  public SecurityType getType() {
    return BASIC;
  }
}

@Service
public class JwtAuthSecurityService extends SecurityService {
  ...
  public SecurityType getType() {
    return JWT;
  }
}

@Service
public class UnauthorizedSecurityService extends SecurityService {
  ...
  public SecurityType getType() {
    return UNAUTHORIZED;
  }
}
SecurityServiceFacade претерпит ряд изменений.
@Service
public class SecurityServiceFacade {
  private final SecurityService entry;
  
  public SecurityServiceFacade(List<SecurityService> servicesList) {
    Map<SecurityType, SecurityService> services = 
        servicesList.stream()
                    .collect(toMap(SecurityService::getType, identity()));
    SecurityService basic = services.get(BASIC); 
    SecurityService jwt = services.get(JWT); 
    SecurityService unauthorized = services.get(UNAUTHORIZED);
    basic.setNext(jwt);
    jwt.setNext(unauthorized);
    this.entry = basic;
  }

  public Response authorize(Request request) {
    return this.entry(request);
  }
}
Теперь реализации SecurityService однозначно идентифицированы с помощью enum.

Может возникнуть вопрос: "А что будет, если два бина вернут одинаковый SecurityType?" В случае bean id возникнет ошибка во время запуска. getType же не спровоцирует подобного поведения. Но в данном случае можно этого не бояться. Коллектор toMap(SecurityService::getType, identity()) по умолчанию выбрасывает исключение, если у двух и более элементов одинаковые ключи. Поэтому вариант, когда у нескольких реализаций ошибочно оказался одинаковый тип, будет исключен.

Этот способ лучше, но у него тоже есть проблемы. Что если добавятся новые элементы цепочки? Или мы захотим поменять местами существующие? Тогда нам придется каждый раз менять код в SecurityServiceFacade.

Давайте немного поменяем определение bean-ов SecurityService и добавим над ними аннотацию @Order:
@Service
@Order(0)
public class BasicAuthSecurityService extends SecurityService {
  ...
  public SecurityType getType() {
    return BASIC;
  }
}

@Service
@Order(1)
public class JwtAuthSecurityService extends SecurityService {
  ...
  public SecurityType getType() {
    return JWT;
  }
}

@Service
@Order(2)
public class UnauthorizedSecurityService extends SecurityService {
  ...
  public SecurityType getType() {
    return UNAUTHORIZED;
  }
}
Значение в @Order указывает на порядок сортировки bean'ов при инжектировании списка. Проще говоря, если подставлять List<SecurityService>, то bean'ы там уже будут отсортированы в соответствии с @Order. Значит, код в конструкторе SecurityServiceFacade можно упростить:
@Service
public class SecurityServiceFacade {
  private final SecurityService entry;
  
  public SecurityServiceFacade(List<SecurityService> servicesList) {
    if (servicesList.isEmpty()) {
        throw new IllegalStateException("List cannot be empty");
    }
    for (int i = 1; i < servicesList; i++) {
        SecurityService previous = servicesList.get(i - 1);      
        SecurityService current = servicesList.get(i);
        previous.setNext(current);
    }
    this.entry = servicesList.get(0);
  }

  public Response authorize(Request request) {
    return this.entry(request);
  }
}
Теперь код в конструкторе SecurityServiceFacade не зависит ни от количества реализаций SecurityService, ни от их порядка.
Логирование
В ходе работы приложения могут возникать различные нештатные ситуации. Ведение логов – журнала произошедших событий – впоследствии поможет найти их причину. Также можно настроить мониторинг логов в режиме реального времени для сбора важных показателей и для оперативного уведомления о возникновении нештатных ситуаций.

Самый простой способ писать логи – делать это при помощи System.out.println – тогда они попадут в консоль. Однако этот вариант имеет ряд существенных недостатков:
  • по старым логам в консоли сложно выполнить поиск;
  • у консоли есть ограничение на размер истории, соответственно старые логи мы просто не увидим.
На практике принято использовать другие подходы: писать логи в файлы или в централизованное хранилище, в роли которого может выступать, например, Elasticsearch.

Логирование в Spring Boot
Spring Boot берёт на себя вопросы конфигурирования логгера, поэтому мы можем просто создать его инстанс:
Logger logger = LoggerFactory.getLogger(LoggingDemoController.class);
Пользоваться логгером достаточно просто (по умолчанию логи будут просто выводиться в консоль):
logger.trace("Маршрут выполнения программы");
logger.debug("Отладочное сообщение");
logger.info("Информационное сообщение");
logger.warn("Предупреждение");
logger.error("Сообщение об ошибке");
Если создать простое Spring Boot-приложение и выполнить в нём данный код, то в консоли мы увидим лишь последние 3 сообщения. Первые два сообщения не будут отображены, поскольку их уровень логирования ниже, чем уровень INFO, который используется по умолчанию.

Уровни логирования
Лог-записи бывают различных уровней: TRACE, DEBUG, INFO, WARN, ERROR. Уровень обозначают важность сообщения: TRACE – уровень для логов с минимальной важностью, ERROR – с максимальной. Чем важнее в программе то событие, которое логируется, тем более высокий уровень нужно использовать:

TRACE
  • Сопутствующие/второстепенные операции и проверки
DEBUG
  • Логин, логаут
  • Ошибки регистрации и аутентификации пользователя
  • Ошибки при добавлении, изменении и удалении бизнес-объектов
INFO
  • Успешное добавление, изменение, удаление бизнес-объектов
  • Успешная регистрация пользователя
  • Изменение учётных данных пользователя
WARN
  • Ошибки валидации на сервере, которые штатно не должны возникать
  • Прочие некорректные запросы с клиента, которые могут сигнализировать о нештатной или потенциально зловредной деятельности пользователя
ERROR
  • Исключения и ошибки
  • Обычно получается так, что больше всего логируется сообщений с низкой важностью (TRACE, DEBUG), а с высокой – меньше. И если на начальном этапе жизни программы часто нужны подробные логи, то со временем такая потребность снижается и нас будут интересовать только важные события. И чтобы не менять код, удаляя из него трейсы, можно в конфигурации задать интересующий нас уровень логирования - тогда логироваться будут сообщения с заданным и более высоким уровнем.
Напомним, что по умолчанию используется уровень INFO. Именно поэтому в примере выше вывелись лишь сообщения с уровнями INFO, WARN и ERROR.

Библиотеки логирования
Существует несколько библиотек, при помощи которых можно выполнять логирование: logback, log4j, пакет java.util.logging. Чтобы не завязываться на какую-то конкретную из них и иметь в своём проекте возможность заменить одну библиотеку на другую, можно воспользоваться библиотекой-фасадом SLF4J.

SLF4J действует как универсальный адаптер: он предоставляет общий API, делая логирование в проекте независимым от конкретной библиотеки логирования.

При использовании Sprig Boot-стартеров будет использоваться SLF4J и Logback в качестве конкретной библиотеки логирования. Чтобы изменить уровень логирования, можно внести изменения в application.properties:
logging.level.root=TRACE
Также можно задать этот уровень при запуске уже собранного приложения:
java -jar myApp-0.0.1-SNAPSHOT.jar --trace
Основы Model View Controller (MVC)
Одним из главных принципов хорошей программной архитектуры является грамотное распределении функционала между различными компонентами приложения. Если говорить о разработке пользовательского интерфейса (далее UI – User Interface), то архитектура MVC является отличным примером такого распределения, именно с этим связана её важность и популярность.

При разработке UI-приложений мы решаем две основных задачи:
  1. Обработка запросов от пользователя. Например, он ввёл текст в поле ввода и нажал кнопку "Поиск". Теперь нам нужно выполнить последовательность действий, чтобы найти данные, удовлетворяющие условиям запроса.
  2. Построение пользовательского интерфейса, согласованного с данными, получаемыми при выполнении запроса.
Бывает, что разработчики решают обе эти задачи в одних и тех же фрагментах кода, не обращая внимания на принципиальную разницу между ними. Для маленьких проектов такое смешение не создает очевидных проблем, но при повышении сложности общего функционала системы это приводит к очень быстрому и неоправданному усложнению кода. Практика показывает, что в достаточно короткие сроки доработка и поддержка такого приложения становится крайне сложной. Из-за проросших повсюду зависимостей между частями кода внесение даже маленького изменения может привести к серьёзным поломкам в самых неожиданных местах.

Как следствие, для решения этих задач подходят языки с принципиально разными парадигмами:
  1. Для обработки запросов пользователя лучше всего подходят императивные языки, такие как Java. На императивном языке мы описываем последовательность действий, которую нужно выполнить для достижения какой-то цели.
  2. Для формирования UI лучше всего подходят декларативные языки. Если говорить о Web-приложениях, то это прежде всего это HTML и CSS, но существуют и другие подобные языки, довольно часто созданные на основе XML. Здесь мы описываем не последовательность действий, а компоненты пользовательского интерфейса, их свойства и положения относительно друг друга.
Как следует из названия, архитектура MVC состоит из трех компонентов:
  1. Представление (View) — это декларативный компонент, ответственный за формирование структуры UI на основании данных
  2. Контроллер (Controller) — это императивный компонент, ответственный за обработку запросов пользователя и получение данных
  3. Модель (Model) — это компонент, ответственный за единый формат данных для взаимодействия контроллера и представления
Функционирование приложения с MVC-архитектурой можно описать при помощи следующей простой схемы:
Порядок действий при работе такой:
  1. Пользователь отправляет запрос контроллеру
  2. Контроллер обрабатывает запрос и сохраняет результаты обработки в модель
  3. Контроллер сообщает модели о том, что нужно сформировать новую структуру для дальнейшего отображения (согласно полученным моделью данным)
  4. Представление формирует нужную структуру UI на основании данных из модели
  5. Сформированный UI отображается пользователю
Если мы имеем дело с Web-приложением, то контроллер будет принимать HTTP-запрос от пользователя, а представление будет отправлять сформированный HTML в качестве ответа на этот HTTP-запрос. Обратите внимание, как хорошо архитектура MVC подходит для взаимодействия по основному Web-протоколу!

Есть один важный момент, связанный с неправильным пониманием сути модели в архитектуре MVC. Многие её понимают как модель данных наподобие набора классов-сущностей, описанных средствами какой-либо ORM-системы. На самом деле это не так. Модель в MVC нужна для создания общего формата передачи данных между контроллером и представлением. Чаще всего она реализованна как хранилище типа ключ-значение, наподобие HashMap из Java Collection Framework. Связь с моделью данных заключается только в том, что если для выполнения какого-либо запроса контроллер получит данные в виде объектов из БД, то он поместит их в хранилище модели, чтобы представление могло их использовать.

Давайте чуть подробнее остановимся на том, какое место взаимодействие с БД занимает в MVC-архитектуре. Если репозиторий, извлекающий данные из БД, добавить в выше приведенную схему архитектуры MVC, то она изменится так:
Обращение контроллера к репозиторию произойдет при обработке запроса пользователя. Полученные данные будут помещены в MVC-модель и далее будут использованы для формирования представления.

Отметим, что существует множество вариаций на основе разобранной в этом разделе классической MVC-архитектуры, например, MVP (Model-View-Presenter) или MVVM (Model-View-ViewModel).
Работа с таблицей данных
Одна из самых распространенных задач при разработке API — это отображение некоторого набора данных в виде списка, который на UI может быть преобразован в таблицу или галерею. В этом разделе мы разберем, как подобные задачи решаются средствами Spring MVC.

Контроллер и представление для отображения таблицы
Пришло время создать контроллер и endpoint для отображения списка курсов. Мы уже обладаем всеми необходимыми знаниями для создания класса-контроллера. Он будет выглядеть примерно так:
@RestController
@RequestMapping("/api")
public class CourseController {
    private final CourseRepository courseRepository;
    
    public CourseController(CourseRepository courseRepository) {
        this.courseRepository = courseRepository;
    }

    @GetMapping("/courses")
    public List<Course> courseTable() {
        return courseRepository.findAll();
    }
}
Разместите его в пакете с именем controller.

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

@RestController – это аннотация, которая сочетает в себе @Controller и @ResponseBody. Последняя сообщает фреймворку о том, что результат необходимо сериализовать (по умолчанию в JSON) и вернуть клиенту в качестве тела ответа.

Так будет выглядеть возможный ответ:
[
  {
    "id": 1,
    "author": "Петров А.В.",
    "title": "Основы кройки и шитья"
  },
  {
    "id": 2,
    "author": "Мошкина А.В",
    "title": "Введение в архитектурный дизайн"
  }
]
У вас может возникнуть вопрос: как Spring сумел преобразовать в List<Course> в JSON-массив. Дело в том, что внутри фреймворк использует библиотеку Jackson. Главный класс в ней – это ObjectMapper. Spring создает его бин и использует для преобразования Java-объектов в JSON и обратно.

Так как ObjectMapper доступен как Spring Bean (при условии установки зависимости Spring Web, что мы сделали в начале модуля), вы можете внедрить его и попробовать использовать на практике. Посмотрите на пример кода ниже.
@Component
public class Listener {
  private final ObjectMapper objectMapper;

  public Listener(ObjectMapper objectMapper) {
    this.objectMapper = objectMapper;
  }

  @EventListener(ApplicationStartedEvent.class)
  public void onApplicationStart() throws Exception {
    String json = objectMapper.writeValueAsString(new Course(1L, "Иван Иванов", "Курс по Java"));
    Course parsedCourse = objectMapper.readValue(json, Course.class);
    // parsedCourse будет таким же, как и изначальный Course
  }
}
Метод onApplicationStart будет вызван один раз, благодаря аннотации @EventListener и параметру ApplicationStartedEvent.class. В экосистеме Spring существует много событий (мы также можем объявлять и кастомные). Здесь мы явно говорим о том, что хотим отловить событие, связанное со стартом приложения. Если вы хотите получить доступ к объекту самого события, тогда нужно указывать его не в качестве параметра аннотации, но передавать явно как аргумент метода, над котором стоит @EventListener.

CourseRepository – это интерфейс, который представляет некоторый слой доступа к данным. Например, вы можете объявить его так:
public class Course {
    private Long id;
    private String author;
    private String title;
    
    // getters, setters, constructor
}

public interface CourseRepository {
  Optional<Course> findById(Long id);

  List<Course> findAll();
  
  Course save(Course course);
}
Реализация же, в самом простом варианте, может хранить все в памяти. Посмотрите на пример ниже:
@Component
public class InMemoryCourseRepository implements CourseRepository {
   private final List<Course> courses;
   private final AtomicLong idGenerator = new AtomicLong(0);

   public InMemoryCourseRepository() {
     courses = new CopyOnWriteArrayList<>();
     courses.add(new Course(idGenerator.incrementAndGet(), "Петров А.В.", "Основы кройки и шитья"));
     courses.add(new Course(idGenerator.incrementAndGet(), "Мошкина А.В", "Введение в архитектурный дизайн"));
   }

   @Override
   public Optional<Course> findById(Long id) {
     return courses.stream()
                  .filter(c -> c.getId() == id)
                  .findFirst();
   }

   @Override
   public List<Course> findAll() {
     // чтобы случайное изменение в полученном списке не поменяло данные внутри InMemoryCourseRepository
     return new ArrayList<>(courses);
   }

   @Override
   public Course save(Course course) {
     // если id == null, курс новый
     if (course.getId() == null) {
        course.setId(idGenerator.incrementAndGet());
        courses.add(course);
        // чтобы изменение в Course не повлияло на состояние в InMemoryCourseRepository
        return new Course(course);
     }
     // иначе - это операция update
     else {
        synchronized(this) {
            for (Course c : courses) {
              if (c.getId() == course.getId()) {
                c.setAuthor(course.getAuthor());
                c.setName(course.getName());
                return course;
              }
            }
            // иначе курс не найден
            throw new CourseNotFoundException("No course with id=" + course.getId());
        }
     }
   }
}
Этот класс не является конкретным указанием к действию. Вы можете реализовать его по-своему.
Теперь пришло время запустить приложение и набрать в строке браузера http://localhost:8080/course. Если все сделано правильно, то вы увидите примерно следующее:
Разбираемся, как работает
Контроллер и маршрутизация запросов
Теперь попытаемся понять, что именно мы сделали. Начнем с контроллера. Прежде всего, аннотация @Controller (следовательно, и @RestController) родственна аннотациям @Component или @Service, но несет на себе и дополнительную нагрузку.

Помимо того, что этот класс будет создан и помещен в SpringContext в качестве бина, информация о нем будет передана другому важному классу, который называется DispatcherServlet. Можно сказать, что DispatcherServlet — это основной класс в Spring MVC. Именно он принимает все HTTP-запросы и решает, в метод какого из зарегистрированных контроллеров будет передано управление для обработки запроса.
То, какой контроллер и какой метод будут обрабатывать запрос, определяется по параметрам из аннотации @RequestMapping.

Вы можете заметить, что в данном примере аннотация @RequestMapping не использовалась над методом. Вместо нее была задействована @GetMapping. Дело в том, что @GetMapping является алиасом для @RequestMapping(method = RequestMethod.GET). Аналогично можно использовать @PostMapping, @DeleteMapping и так далее. Это легко понять, если в IDEA вы зажмете Ctrl и левой кнопкой мыши нажмете на аннотацию, которую поставили над методом или классом. Вы увидите, что в случае @GetMapping над ней также присутствует @RequestMapping.

Аннотацию @RequestMapping можно ставить над контролером, чтобы задать префикс для всех его эндпойнтов. В данном случае префикс /api.

Если в строку адреса был введен адрес http://localhost:8080/api/courses, то сначала DispatcherServlet будет просматривать все зарегистрированные контроллеры, пока не найдет среди них наш HelloController, у которого в аннотации @RequestMapping указан префикс course. Это означает, что среди методов этого контроллера может быть такой, который мог бы обработать данный запрос. Такой метод действительно есть — это метод courseTable(), над ним указана аннотация @RequestMapping без дополнительного префикса.

Просмотр, редактирование, создание и удаление записей
Сейчас наше приложение использует только один метод из репозитория, возвращающий полный список всех курсов. Давайте доработаем функциональность, чтобы пользователь мог не только просматривать список курсов, но и добавлять в него новые, изменять существующие и удалять ненужные. Приложение, способное выполнять эти четыре операции (создание, просмотр, изменение, удаление), часто называют CRUD-приложением (сокращение от Create, Read, Update, Delete).

Просмотр курса
Начнем с получения информации о конкретном курсе. Доработаем контроллер, чтобы он мог обрабатывать запрос на отображение курса по id:
@RestController
@RequestMapping("/api")
public class CourseController {
    
    @GetMapping("/courses/{id}")
    public Course getCourse(@PathVariable("id") Long id) {
        return courseRepository.findById(id).orElseThrow();
    }
}
Этот метод будет вызван, если мы перейдем по URL вида http://localhost:8080/api/courses/1. Вместо 1 может быть указан любой другой числовой идентификатор курса, который хотелось бы отобразить. Т.к. метод findById() возвращает нам ответ в виде контейнера Optional, то для получения собственно курса нам нужно вызвать ещё и метод orElseThrow(), который бросит исключение NoSuchElementException в случае отсутствия информации.

Если курс отсутствует, необходимо корректно обрабатывать сообщение об ошибке. Но мы это сделаем в разделе про обработку исключений.

Пришло время запустить приложение. Если все сделано правильно, то, перейдя по ссылке http://localhost:8080/api/courses/2, вы увидите информацию о соответствующем курсе. Ответ в браузере будет выглядеть примерно так:
Редактирование курса
Сначала необходимо определиться с тем, какую информацию о курсе мы будем редактировать. Предположим, что мы хотим иметь возможность менять название и автора. Определим соответствующее DTO.
public class CourseRequestToUpdate {
  private String author;
  private String title;
  
  // геттеры, сеттеры...
}
Инстанс данного класса будет нести в себе информацию о требуемом обновлении.

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

Для изменения информации о курсе добавим новый эндпойнт с HTTP-методом PUT.
@RestController
@RequestMapping("/api")
public class CourseController {
    
    @PutMapping("/courses/{id}")
    public void updateCourse(@PathVariable Long id, 
                             @RequestBody CourseRequestToUpdate request) {
        Course course = courseRepository.findById(id).orElseThrow();
        course.setTitle(request.getTitle());
        course.setAuthor(request.getAuthor());
        courseRepository.save(course);
    }
}
Информация о том, какие поля необходимо отредактировать, будет передаваться в теле PUT-запроса в виде JSON. Spring же десериализует его в объект CourseRequestToUpdate. ID курса, который будет отредактирован, передается в качестве PathVariable.

GET-запросы можно проверять в браузере. Однако с остальными методами это сделать не получится.
Нам понадобится HTTP-клиент. Например, можно использовать Postman.

Запрос на изменения курса в Postman будет выглядеть так:
Создание курса
Перейдем к созданию курса. Аналогичным образом объявим DTO.
public class CourseRequestToCreate {
  private String author;
  private String title;
  
  // геттеры, сеттеры...
}
И соответствующий endpoint.
@RestController
@RequestMapping("/api")
public class CourseController {
    
    @PostMapping("/courses")
    public Course createCourse(@RequestBody CourseRequestToCreate request) {
        Course course = new Course(request.getTitle(), request.getName());
        return courseRepository.save(course);
    }
}
Перезапустите приложение и проверьте работоспособность добавленного функционала.
Удаление курса
Для удаления курса достаточно знать только его ID, дополнительных DTO не потребуется.
Добавим новый эндпойнт.
@RestController
@RequestMapping("/api")
public class CourseController {

    @DeleteMapping("/courses/{id}")
    public void deleteCourse(@PathVariable Long id) {
        courseRepository.deleteById(id);
    }
}
Вот и все. Давайте ещё раз перезапустим наше приложение и проверим, что новый функционал работает:
Обработка исключений
В процессе работы приложения могут возникать ошибки, требующие корректной обработки. Пользователь должен иметь возможность понимать, что именно пошло не так и как можно исправить ситуацию. В нашем приложении есть минимум один метод, в котором может возникнуть ошибка, нуждающаяся в специальной обработке — это метод getCourse() в контроллере. Если в ссылке будет указан идентификатор курса, которого нет в репозитории, то вызов метода orElseThrow() выдаст исключение. Т.к. это исключение никак не обработано, то пользователь увидит в браузере сообщение об ошибке с кодом 500 (Internal Server Error), хотя в этом случае правильным был бы код ошибки 404 (Not Found) и страница с информацией о том, что курс с таким кодом не найден.

Spring MVC позволяет обработать это исключение простым и элегантным способом. В прошлом уроке мы добавили CRUD-операции, некоторые из которых могут выбрасывать NoSuchElementException. Давайте напишем обработчик для этого исключения.

Для этого необходимо добавить метод с аннотацией @ExceptionHandler в класс контролера.
@ExceptionHandler
public ResponseEntity<ApiError> noSuchElementExceptionHandler(NoSuchElementException ex) {
    return new ResponseEntity<>(
      new ApiError(ex.getMessage()),
      HttpStatus.NOT_FOUND
    );
}
ApiError – это кастомное DTO, содержащее информацию об ошибке. Именно оно будет возвращено клиенту в качестве тела запроса.

Данный метод будет вызван, если при выполнении какого-либо метода в данном контроллере возникнет исключение типа NoSuchElementException (параметр именно этого типа передается в метод ExceptionHandler). В данном методе нам нужно не только указать DTO, но и HTTP-статус ответа на запрос. Поэтому мы возвращаем экземпляр класса ResponseEntity.

Этот класс позволяет нам указать как контент для тела ответа, так и код.
Стоит заметить, что данный обработчик привязан к контролеру, в котором он объявлен. Если такая же ошибка произойдет в другом контроллере, этот обработчик не будет вызван. Чтобы объявить глобальный обработчик ошибок, нужно поместить метод в отдельный класс с аннотацией @RestControllerAdvice.
@RestControllerAdvice
public class HandleErrorService {
  
  @ExceptionHandler
  public ResponseEntity<ApiError> noSuchElementExceptionHandler(NoSuchElementException ex) {
    return new ResponseEntity<>(
        new ApiError(ex.getMessage()),
        HttpStatus.NOT_FOUND
    );
  }
}
Валидация
При отправке запроса на бэкенд часто возникают ситуации, когда перед сохранением информации её необходимо проверить на корректность. Например, пользователь может случайно не заполнить одно из полей формы. При этом в репозиторий будет сохранена запись о курсе без названия и имени автора, что не является корректным. Для предотвращения подобных ситуаций в Spring MVC есть механизм валидации форм.
Относительно недавно этот функционал был выделен в отдельный модуль. Начнем с того, что добавим его к зависимостям проекта. В раздел <dependencies> добавим следующий код:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
После добавления необходимо обновить зависимости Maven.

Теперь добавим валидацию в форму редактирования курса — будем проверять, что название и автор курса указаны. Прежде всего нам нужно указать, какие конкретно поля и каким образом нужно валидировать в классе Course. Сделаем это при помощи аннотаций:
public class CourseRequestToUpdate {

  @NotBlank(message = "Course author has to be filled")
  private String author;
  @NotBlank(message = "Course title has to be filled")
  private String title;

  // геттеры, сеттеры...
}
Следует отметить, что в модуле валидации присутствует множество подобных аннотаций для различных проверок. Все они находятся в пакете javax.validation.constraints. Вот несколько примеров:
  • @NotNull — поле не должно быть null
  • @NotBlank — строковое поле должно быть не null и не должно содержать пустую строку
  • @Email — строковое поле должно содержать корректный email
  • @Pattern — строка должна удовлетворять заданному регулярному выражению
  • @Size — проверка для длины строки, которая должна находиться в указанных пределах (не более и не менее)
  • @Max — числовое значение не должно быть больше заданного
  • @Min — числовое значение не должно быть меньше заданного
Доработаем метод контроллера:
    @PutMapping("/{id}")
    public void updateCourse(@PathVariable Long id,
                             @Valid @RequestBody CourseRequestToUpdate request) {
    Course course = courseRepository.findById(request.getId()).orElseThrow();
    course.setTitle(request.getTitle());
    course.setAuthor(request.getAuthor());
    courseRepository.save(course);
}
Перед параметром, через который передаются данные из формы, мы добавили аннотацию @Valid. Это указание Spring MVC о том, что передаваемое в метод значение нужно валидировать. Если условия валидации не выполнятся, будет выброшено MethodArgumentNotValidException. По умолчанию для этого исключения зарегистрирован обработчик, который выставит HTTP-код 400 и вернет стандартное сообщение об ошибке. Это поведение можно переопределить, объявив собственный ExceptionHandler, как мы это рассматривали ранее.

Пришло время запустить приложение и проверить работоспособность нового функционала!
Понравился спринт?