Как стать автором
Обновить

Gradle и нетривиальная конфигурация

Время на прочтение22 мин
Количество просмотров9.6K

Каждый из нас знакомился с новомодными библиотеками, фреймворками, инструментами по getting started статьям из документации. В них всё складывается как по полочкам, в пёстрых красках показывается как всё просто и легко. Однако зачастую картина меняется, когда в Ваш новорожденный проект требуется подключить не одну условную библиотеку, а набор. Getting started осложняются появлением инородных элементов, и в процесс приходится подключать инстинкты. Когда за плечами многолетний опыт разработки и не один поднятый с колен проект, такая задача не видится проблемной. Однако, когда Вы делаете это в первый раз, инстинкты подводят. Впоследствии оглядываясь назад, мы жалеем о том, что в начале у нас не было опыта, который есть сейчас. Да и откуда было бы его получить? Ведь в getting started о таком не пишут, а проекты, в которых мы работаем не с самого начала, уже прошли этап становления.

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

Не смотря на то, что в названии скучает в одиночестве Gradle, в самой статье мы упомянем gradle плагины java-test-suite и java-test-fixtures, testcontainers, liquibase и прочее. Дело в том, что конфигурация у нас заявлена не тривиальная, а для детривиализации конфигурации нужно больше её элементов.

Пролог

Появилась необходимость реализовать проект на Java. С нуля. Проект представляет из себя реинкарнацию древнего легаси, от которого в новом проекте остаются только ожидания. Легаси - это любительская поделка на чуждом заказчику стеке, поэтому никакой код от туда в новый проект не мигрирует, тем более, что процесс ознакомления с ним вызывает грусть и желание выпить. На основе известных ожиданий были сформированы требования к новому проекту: Java 17, Gradle, Spring boot, Hexagonal Architecture, unit-тесты, интеграционные тесты и прочие прелести. Опустим процесс выбора и его причины, сосредоточимся только на реализации.

Часть первая. Высасываем пример из пальца

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

  1. Система должна сохранять заметки и возвращать их список по запросу.

Условимся, что UI - не наша задача. Наша задача - предоставить api для него.

Часть вторая. Строим структуру проекта

Getting Started по Gradle предписывает нам воспользоваться IDE для создания проекта или установить Gradle локально, что бы воспользоваться им в терминале. Не позволю себе наглость советовать Вам, что из этого выбрать, а лишь продемонстрирую экзотический способ

docker run -it --rm -v %cd%:/usr/workdir -w /usr/workdir gradle:latest gradle init

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

Выбираем тип проекта basic, Groovy DSL, и имя проекта - в моём случае это simple-note. Дальше, разумеется, Gradle нужно запускать через wrapper - gradlew.bat, который появился после инициализации в корне проекта - в текущей папке.

Теперь у нас в руках есть чистый лист, который нам нужно расчертить - описать структуру проекта. И тут имеются в виду не продиктованные соглашениями папочки вроде main, resources и прочее. Дело в том, что мы выбрали подход на основе принципов гексагональной архитектуры. А это значит, что у нас будет много модулей и зависимостей между ними. Про Hexagonal Architecture можно погуглить, но если в двух словах, то нам нужно ядро - модуль с минимально возможным количеством зависимостей, имплементирующим бизнес-логику, и несколько модулей-адаптеров, зависящих от ядра и имплементирующих декларированные в нём потребности и возможности его взаимодействия с окружением.

У нас в корне проекта есть файл build.gradle. Он содержит только комментарий со ссылкой на документацию, если вы делали как я. Это корневой файл сборки и всем модули будут его подмодулями, поэтому давайте сразу обозначим в нём общие настройки для всех них:

plugins {
    id "io.spring.dependency-management" version "1.0.11.RELEASE"
}

subprojects {
    group = "simple.note"

    apply plugin: "java"
    apply plugin: "io.spring.dependency-management"

    dependencyManagement {
        imports {
            mavenBom("org.springframework.boot:spring-boot-dependencies:2.7.1")
        }
    }

    compileJava {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
}

Применение плагина io.spring.dependency-management обеспечит нам одинаковую версию spring boot во всех зависящих от него модулях. Ну и Java 17.

Добавим модуль business-core, то самое ядро с бизнес-логикой. как Вы помните у него минимум зависимостей, поэтому его build.gradle будет пустым.

Условимся, что пакет simple.note будет корнем для всего кода приложения и создадим в модуле business-core пакет simple.note.core. Затем создадим в нём описание доменной сущности - заметки.

package simple.note.core.domain;

import java.time.LocalDateTime;

public record Note(
    Long id,
    String text,
    Integer size,
    LocalDateTime created
) { }

Я использую record потому, что мне нравится эта конструкция. Только ради неё стоит "соскочить" с Java 8. Обратите внимание, так же, что я добавил сущность в пакет domain, для порядка.

Объявим в нашем ядре сервис, позволяющий выполнять заявленный в первой части бизнес сценарий - сохранение заметки и получение их списка.

package simple.note.core.port.in;

import simple.note.core.domain.Note;

import java.util.List;

public interface NoteService {
    Note create(String text);
    List<Note> getAll();
}

Опять же, в гексагональной архитектуре есть порты, поэтому пакет port. В данном случае порт входящий, поэтому in. Для порядка.

Для реализации сервиса нам понадобится взаимодействие с хранилищем данных. Мы пока не знаем какое оно будет, но нам пока и не надо. Объявим ещё один порт - исходящий. В пакете port.out.

package simple.note.core.port.out;

import simple.note.core.domain.Note;

import java.util.List;

public interface NoteStorage {
    long create(Note note);
    Note get(long id);
    List<Note> getAll();
}

Теперь мы можем реализовать наш бизнес-сервис в пакете service

package simple.note.core.service;

import simple.note.core.domain.Note;
import simple.note.core.port.in.NoteService;
import simple.note.core.port.out.NoteStorage;

import java.time.LocalDateTime;
import java.util.List;

public class NoteServiceImplementation implements NoteService {

    private final NoteStorage noteStorage;

    public NoteServiceImplementation(NoteStorage noteStorage) {
        this.noteStorage = noteStorage;
    }

    @Override
    public Note create(String text) {
        var noteId = noteStorage.create(
                new Note(
                        null,
                        text,
                        text.length(),
                        LocalDateTime.now()
                )
        );

        return noteStorage.get(noteId);
    }

    @Override
    public List<Note> getAll() {
        return noteStorage.getAll();
    }
}

Наибольший интерес тут представляет метод create. На вход он принимает текст, однако при сохранении в хранилище заполняется ещё размер заметки. После сохранения получаем идентификатор объекта в хранилище и уже используя этот идентификатор, получаем его от туда целиком. Чем не кейс для unit теста?

Часть вторая. Unit тесты

Да, строить структуру проекта мы ещё не закончили, но про это будет вся статья. А сейчас время для unit тестов. Мало, кто из дочитавших до сюда, получил какое-то откровение, я думаю. Пока что всё на уровне "Hello, world!". Должен предупредить, что в этой части для вас тоже вряд ли будет что-то новое.

Однако, продолжим. Unit тесты распространяются на все модули, поэтому опять идём в корневой gradle.build и добавляем:

...
subprojects {
    ...
    repositories {
        mavenCentral()
    }
    ...
    dependencies {
        testImplementation "org.junit.jupiter:junit-jupiter:5.8.2"
        testImplementation "org.mockito:mockito-core:4.6.1"
    }

    test {
        useJUnitPlatform()
    }
}

То есть, для всех модулей мы добавляем одинаковые зависимости на JUnit и Mockito. Уверен, Вы знаете, что это такое. Если нет - можно погуглить.

А так выглядит тест на нашу реализацию бизнес-сервиса:

package simple.note.core.service;

import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import simple.note.core.domain.Note;
import simple.note.core.port.out.NoteStorage;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

public class NoteServiceImplementationTest {

    @Test
    void saveNote_calculateTextSize_getByStorageId() {
        var text = "17 character text";
        var noteId = 12345L;

        var noteStorage = mock(NoteStorage.class);
        var noteCaptor = ArgumentCaptor.forClass(Note.class);
        when(noteStorage.create(noteCaptor.capture())).thenReturn(noteId);
        var noteIdCaptor = ArgumentCaptor.forClass(Long.class);
        when(noteStorage.get(noteIdCaptor.capture())).thenReturn(null);

        var noteService = new NoteServiceImplementation(noteStorage);

        noteService.create(text);

        assertNotNull(noteCaptor.getValue());
        assertEquals(text, noteCaptor.getValue().text());
        assertEquals(17, noteCaptor.getValue().size());

        assertEquals(noteId, noteIdCaptor.getValue());
    }
}

Запускаем в корне проекта gradlew test, наблюдаем зелёный BUILD SUCCESSFUL и идём дальше.

Часть третья. Реализация хранилища

Теперь нужно обеспечить бизнес-сервису реализацию исходящего порта для хранения наших заметок. Для этого добавим модуль adapters. Он будет пустой. Мы будем использовать его в качестве агрегата для модулей адаптеров, первым из которых станет storage. Добавляем.

Так как базовая конфигурация сборки у нас уже готова, в build.gradle модуля :adapters:storage указываем только кое-какие зависимости:

dependencies {
    implementation project(":business-core")
    implementation "org.springframework.boot:spring-boot-starter-data-jpa"

    runtimeOnly "org.postgresql:postgresql"
}

Вот тут у нас наглядный пример того, что адаптеры зависят от ядра, а не наоборот. А так же появляется первый компонент Spring boot. Мы будем использовать JPA для взаимодействия с БД PostgreSQL. Поэтому создаём entity-класс:

package simple.note.adapters.storage.entity;

import lombok.*;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "note")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NoteEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Long id;

    @Column(name = "text")
    private String text;

    @Column(name = "size")
    private Integer size;

    @Column(name = "created")
    private LocalDateTime created;
}

Так как JPA не умеет работать с записями (record), то я добавил lombok, что бы не писать руками шаблонный код, который я не люблю больше, чем lombok. Добавим и репозиторий заодно:

package simple.note.adapters.storage.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import simple.note.adapters.storage.entity.NoteEntity;

public interface NoteEntityRepository extends JpaRepository<NoteEntity, Long> {
}

Обращаем внимание на пакеты, в которых расположен этот код. И идём реализовывать порт:

package simple.note.adapters.storage;

import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import simple.note.adapters.storage.entity.NoteEntity;
import simple.note.adapters.storage.repository.NoteEntityRepository;
import simple.note.core.domain.Note;
import simple.note.core.port.out.NoteStorage;

import java.util.List;

@Component
public class NoteStorageImplementation implements NoteStorage {

    private final NoteEntityRepository repository;

    public NoteStorageImplementation(NoteEntityRepository repository) {
        this.repository = repository;
    }

    @Override
    @Transactional
    public long create(Note note) {
        var entity = repository.save(NoteEntity.builder()
                .text(note.text())
                .size(note.size())
                .created(note.created())
                .build());

        return entity.getId();
    }

    @Override
    public Note get(long id) {
        return map(repository.getReferenceById(id));
    }

    @Override
    public List<Note> getAll() {
        return repository.findAll()
                .stream()
                .map(this::map)
                .toList();
    }

    private Note map(NoteEntity entity) {
        return new Note(
                entity.getId(),
                entity.getText(),
                entity.getSize(),
                entity.getCreated()
        );
    }
}

Не забудем аннотировать класс как Component, ведь мы уже имеем дело со Spring Boot.

Часть четвёртая. Интеграционные тесты

Нашу реализацию порта хранилища тестировать unit тестами особого смысла не имеет, поэтому давайте разбираться с интеграционными. Обычно в гайдах по интеграционным тестам их пишут в стандартном наборе test (который рядом с main). Однако в test у нас уже unit тесты, и такой подход приведёт к путанице, в которой различать unit и интеграционные тесты будет не просто. Мне приходилось видеть решение в виде соглашения об именовании, согласно которому классы с unit тестами должны заканчиваться на *Test, а классы с интеграционными на *Tests. В конце концов, почему бы и нет? Ведь вся экосистема Java строится на фундаменте соглашения об именовании. Но в таком случае в набор test свалится куча зависимостей, которые в руках человека сотрут грань между типами тестов. А мы собрались тут не для этого, поэтому давайте так не делать. И да, мы условились не спорить о том, почему это нужно, а разобраться как это сделать. Давайте сделаем по gradle'вски.

Для этого нам понадобится штатный JVM Test Suite Plugin. По первым двум абзацам его описания в документации можно сделать вывод, что создан он как раз под нашу задачу - группировку тестов. Когда я наткнулся на этот плагин, то был удивлён, что ранее мне не приходилось видеть его применения в реальных проектах. Я видел разные велосипеды, но только не то решение, которое Gradle предлагает из коробки. Хотя, конечно, у меня в этом не так много опыта.

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

subprojects {
    testing {
        suites {
            integrationTest(JvmTestSuite) {
                dependencies {
                    implementation project
                    implementation "org.springframework.boot:spring-boot-starter-test"ЭЭЭ
                }

                targets {
                    all {
                        testTask.configure {
                            shouldRunAfter(test)
                        }
                    }
                }

                useJUnitJupiter()
            }
        }
    }

    configurations {
        integrationTestImplementation.extendsFrom implementation
        integrationTestRuntimeOnly.extendsFrom runtimeOnly
    }

    tasks.named("check") {
        dependsOn (testing.suites.integrationTest)
    }
}

Этим самым во все подмодули adapters мы добавляем новый source set - integrationTest. Эта группа зависит от самого модуля, в котором расположена (строка 6), тесты из неё запускаются после тестов из группы test (строка 13), так же используется JUnit (строка 18), Implementation и RuntimeOnly группы расширяются аналогичными самого модуля (строки 24 и 25), а стандартная задача check так же запускает и тесты из созданной группы (строка 29). Согласно документации группа test устроена аналогичным образом, но, в виду её широкого распространения, конструкция, объявляющая группу test спрятана внутри Gradle (под плагином java) и включена в сборку по умолчанию. Ещё мы добавили группе integrationTest зависимость spring-boot-starter-test (строка 7). Раз приложение у нас строится с помощью spring-boot, то здесь ей самое место.

Теперь мы можем добавить новые директории в src нашего адаптера storage: integrationTest/java и intergationTest/resources. Если Вы пользуетесь IDE, то она теперь даже предложит вам эти имена сама.

Классы интеграционных тестов для компонентов Spring boot обвешиваются группой аннотаций, и что бы не обвешивать каждый класс, давайте сделаем для этого абстрактный, который тестовые классы будут расширять:

package simple.note.adapters.storage;

import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@EnableAutoConfiguration
@EntityScan(basePackages = { "simple.note.adapters.storage.entity" })
public abstract class IntegrationTest { }

Здесь есть всё, что нужно для запуска интеграционного теста, в отдельности про каждый атрибут расписывать, пожалуй, не буду - об этом кричат все гайды. Да и погуглить можно. А нам пришло время сделать тест:

package simple.note.adapters.storage;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import simple.note.core.domain.Note;
import simple.note.core.port.out.NoteStorage;

import java.time.LocalDateTime;

import static org.junit.jupiter.api.Assertions.*;

@ContextConfiguration(classes = {
        NoteStorageImplementation.class
})
public class NoteStorageImplementationTest extends IntegrationTest {
    @Autowired
    private NoteStorage noteStorage;

    @Test
    void saveNote() {
        var text = "note text";
        var size = 9;
        var created = LocalDateTime.now();

        var noteId = noteStorage.create(new Note(null, text, size, created));
        var note = noteStorage.get(noteId);

        assertNotNull(note);
        assertAll("note",
                () -> assertEquals(noteId, note.id()),
                () -> assertEquals(text, note.text()),
                () -> assertEquals(size, note.size()),
                () -> assertEquals(created, note.created())
        );
    }
}

Здесь мы проверяем, что реализация порта хранилища способна сохранить наш объект и сохранить именно так как мы его попросили. Для этого мы получаем в наш тестовый класс реализацию NoteStorage через аннотацию Autowired, а сам класс аннотируем ContextConfiguration и обозначаем, что нам тут нужен класс с реализацией.

Запускаем тесты командой gradlew integrationTest в корне нашего проекта и видим, что сборка валится. Самые внимательные давно догадались, что так этим всё и закончится потому, что у нас нет никакой БД, интеграцию с которой мы собрались тестировать. И тут на сцену выходит...

Часть пятая. Testcontainers

В третьей части про реализацию хранилища уже было обозначено, что в нашем проекте в качестве БД будет использоваться PostgreSQL. Давайте подключим её для наших интеграционных тестов. Сначала добавляем необходимые зависимости в build.gradle модуля :adapters:storage:

    integrationTestImplementation "org.testcontainers:postgresql:1.17.2"
    integrationTestImplementation "org.testcontainers:junit-jupiter:1.17.2"
    integrationTestImplementation "org.testcontainers:postgresql:1.17.2"

А так же создадим в ресурсах этого модуля файл application.properties со следующим содержимым:

spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}

Теперь нам нужен класс, который будет предоставлять нам экземпляр контейнера БД для наших тестов:

package simple.note.adapters.storage;

import org.testcontainers.containers.PostgreSQLContainer;

public class SimpleNotePostgreSQLContainer extends PostgreSQLContainer<SimpleNotePostgreSQLContainer> {
    private static final String IMAGE_VERSION = String.format(
            "%s:%s",
            PostgreSQLContainer.IMAGE,
            PostgreSQLContainer.DEFAULT_TAG
    );
    private static SimpleNotePostgreSQLContainer container;

    private SimpleNotePostgreSQLContainer() {
        super(IMAGE_VERSION);
    }

    public static SimpleNotePostgreSQLContainer getInstance() {
        if (container == null) {
            container = new SimpleNotePostgreSQLContainer();
        }

        return container;
    }

    @Override
    public void start() {
        super.start();
        System.setProperty("DB_URL", container.getJdbcUrl());
        System.setProperty("DB_USERNAME", container.getUsername());
        System.setProperty("DB_PASSWORD", container.getPassword());
    }
}

Это класс с приватным конструктором и публичным методом для получения экземпляра контейнера. Такая конструкция выдаст нам один контейнер на весь прогон. В методе start устанавливаются значения системным переменным, которые обозначены в только что созданном файле application.properties. Благодаря этому тестируемый сервис будет знать о том, как подключиться к БД в запущенном контейнере. Объявленная в классе статическая константа IMAGE_VERSION в моём примере несёт исключительно демонстрационный характер. Формат строки, которую мы передаём в конструктор базового класса имеет вид "image:tag". Здесь мы используем обычное имя образа с дефолтным тэгом, хотя сам я обычно использую банальный "postgres:14".

На случай, если читатель впервые слышит про Testcontainers и ещё не успел загуглить, то стоит отменить, что эта штука работает через Docker, и если по какой-то причине Вы его ещё не установили, то сейчас самое время. Это как раз тот момент, о котором я писал выше, утверждая, что Docker нам ещё понадобится.

Следуем гайдам по Testconteqners и добавляем ещё одну абстракцию:

package simple.note.adapters.storage;

import org.junit.ClassRule;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers
public abstract class StorageIntegrationTest {
    @ClassRule
    @Container
    public static SimpleNotePostgreSQLContainer dbContainer = SimpleNotePostgreSQLContainer.getInstance();
}

И расширим этой абстракцией предыдущую - IntegrationTest. Не забудем проверить, что Docker у нас запущен и снова выполняем gradlew integrationTest. И снова видим, что сборка провалилась - тест не прошёл.

Часть шестая. Liquibase

Всё верно, мы запустили БД, однако там нет ничего, включая таблицу, в которой должны храниться наши заметки. Возвращаемся в main нашего адаптера storage и создаём там в ресурсах changelog для liquibase. Я не буду заострять на этом внимание, просто покажу changeSet для создания таблицы note, вдруг читатель не знает, что changeSet можно писать как XML, а не SQL:

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.4.xsd">
    <changeSet id="create_table_note" author="elusive avenger">
        <createTable tableName="note">
            <column name="id" type="bigint" autoIncrement="true">
                <constraints primaryKey="true" nullable="false"/>
            </column>
            <column name="text" type="clob" />
            <column name="size" type="bigint" />
            <column name="created" type="datetime" />
        </createTable>
    </changeSet>
</databaseChangeLog>

Что бы liquibase автоматически применял наши миграции для БД на старте приложения, добавим в зависимости адаптера storage следующее:

implementation "org.liquibase:liquibase-core"

А в application.properties группы integrationTest следующее:

spring.liquibase.change-log=classpath:changelog.xml

Здесь changelog.xml - это, разумеется, наш файл со списком миграций БД. Запускаем gradlew integrationTest и видим, что теперь наше хранилище данных работает - тест проходит.

Однако наш тест проверяет запись, которую сам и создал. А как будет работать адаптер с данными, которые уже есть в БД? Давайте проверим:

    @Test
    void readExistsNote() {
        var noteId = 100L;

        var note = noteStorage.get(noteId);

        assertNotNull(note);
        assertAll("note",
                () -> assertEquals(noteId, note.id()));
    }

Запустим этот тест и увидим, что он ожидаемо проваливается. Разумеется, ведь у нас нет в БД записи с идентификатором 100.

Часть седьмая. Тестовые данные

Данные для интеграционных тестов будет добавлять тем же liquibase. Для этого уже в ресурсах группы integrationTest модуля :adapters:storage добавляем свой changeset, который будет добавлять в базу данные, используемые тестами (назовём этот файл 0000.add_row_to_note.xml в папке test-data в ресурсах integrationTest модуля :adapters:storage):

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.4.xsd">
    <changeSet id="add_row_to_note" author="elusive avenger">
        <insert tableName="note">
            <column name="id" value="100" />
            <column name="text" value="text" />
        </insert>
    </changeSet>
</databaseChangeLog>

А вот теперь я не могу воздержаться от демонстрации своей структуры файлов для liquibase. В ресурсы группу integrationTest, кроме файла, содержимое которого представлено выше, я добавил ещё один changeset.storage.intergation-test.xml со следующим содержимым:

<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.4.xsd">

    <include file="changelog.xml" relativeToChangelogFile="true"/>

    <include file="test-data/0000.add_row_to_note.xml" relativeToChangelogFile="true"/>

</databaseChangeLog>

Тут мы сначала просим liquibase применить миграции к нашей БД, а затем применить changeset с тестовыми данными. Ещё нам нужно поменять значение параметра spring.liquibase.change-log в application.properties в ресурсах набора IntegrationTest адаптера storage:

spring.liquibase.change-log=changeset.storage.integration-test.xml

Запускаем gradlew integrationTest - тесты проходят.

Часть с liquibase получилась немного запутана. Если Вам не удалось понять что где лежит, то можно заглянуть в репозиторий и разобраться. А я постараюсь ответить на вопрос, почему я не использовал context для выделения тестовых данных от общей массы миграций. Дело в том, что при использовании контекста, все файлы changeset должны лежать в одной куче. Если это будет так, то при сборке приложения для вывода в условный production, в неё попадут все файлы для liquibase, включая файлы с тестовыми данными. Разумеется, тестовые данные в продуктивной среде игнорируются благодаря настройкам фильтра контекста, однако само существование их в сборке допускает вероятность нежелательного сценария. Вспоминаем закон Мёрфи - "Если что-нибудь может пойти не так, оно пойдёт не так". Подход, выбранный нами для нашего проекта исключает такой сценарий - если в сборке нет тестовых данных, то они не смогут попасть в прод.

На этом, пожалуй, оставим адаптер хранилища. Кажется, он работает как надо.

Часть восьмая. REST API

В первой части мы договорились предоставить API для ребят из отдела UI, что бы они могли с ним поработать между митингами, пока пьют кофе.

Добавляем в проект новый адаптер - модуль :adapters:rest-api, а содержимое его build.gradle делаем таким:

dependencies {
    implementation project(":business-core")
    implementation "org.springframework.boot:spring-boot-starter-web"    
    implementation "org.springdoc:springdoc-openapi-ui:1.6.9"
}

Здесь нам нужна зависимость от ядра нашего приложения, что бы пользоваться бизнес логикой, а так же spring-boot-starter-web что бы реализовать задуманное. Зависимость springdoc-openapi-ui добавим в качестве бонуса, что бы не писать документацию по API самостоятельно, а ребятам из отдела UI отдать ссылку на автосгенерированную (по которой они, кстати, смогут сгенерировать клиентский код, пока наливают кофе).

Добавляем контроллер:

package simple.note.adapters.rest.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import simple.note.adapters.rest.model.CreateNoteView;
import simple.note.adapters.rest.model.NoteView;
import simple.note.core.domain.Note;
import simple.note.core.port.in.NoteService;

import java.util.List;

@RestController
@RequestMapping("/api/note")
@Tag(name = "Заметки")
public class NoteController {
    private final NoteService noteService;

    public NoteController(NoteService noteService) {
        this.noteService = noteService;
    }

    @PostMapping
    @Operation(summary = "Создать заметку")
    public ResponseEntity<NoteView> create(@RequestBody CreateNoteView request) {
        return ResponseEntity.ok(map(noteService.create(request.text())));
    }

    @GetMapping
    @Operation(summary = "Список заметок")
    public ResponseEntity<List<NoteView>> getAll() {
        return ResponseEntity.ok(noteService.getAll().stream().map(this::map).toList());
    }

    private NoteView map(Note note) {
        return new NoteView(note.id(), note.text(), note.size(), note.created());
    }
}

Обращаем внимание на пакет, с котором контроллер находится. Для порядка.

Часть девятая. Запускаем приложение

Нам осталось заставить всё это работать вместе. У нас есть пара адаптеров, которые ничего друг про друга не знают. И не должны. Теперь нам нужно "очертить" внешний контур нашего приложения спроектированного по принципам гексагональной архитектуры. Для этого мы создадим модуль, который будет иметь в зависимостях все наши адаптеры и при этом запускаться будет как обычное web приложение Spring boot. Назовём тип таких модулей словом "конфигурация", а сам модуль webapp. Забегая вперёд скажу, что модулей конфигурации у нас может быть несколько с разной геометрией внешнего контура, то есть с разным набором адаптеров. Это могут быть, например, консольные приложения, потребители сообщений из очереди, приложения, предоставляющие api по QraphQL или всё это вместе одновременно - всё зависит от функций нашего бизнес-ядра, количества адаптеров к нему и потребности в декомпозиции отдельных экземпляров нашего приложения. Поэтому объединим их в одном модуле - configurations, аналогично адаптерам. Build.gradle нашей конфигурации будет выглядеть вот так:

dependencies {
    implementation project(":adapters:storage")
    implementation project(":adapters:rest-api")

    implementation "org.springframework.boot:spring-boot-starter-web"
}

Наглядно видно, что он зависит от наших адаптеров и не зависит от бизнеса. А так же, видно, что мы будем использовать spring-boot-starter-web для запуска. Создаём класс приложения:

package simple.note;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SimpleNoteWebApplication {
    public static void main(String[] args) {
        SpringApplication.run(SimpleNoteWebApplication.class, args);
    }
}

Обратите внимание, что класс приложения лежит в корневом пакете simple.note. Это позволяет Spring'у сканировать все компоненты находящиеся ниже по иерархии, а они все именно там и находятся. Класс выглядит вполне канонически, не хватает только канонического application.properties. Исправляемся:

spring.datasource.url=jdbc:postgresql://localhost:5432/simple_note
spring.datasource.username=simple_note_user
spring.datasource.password=simple_note_password
spring.datasource.driver-class-name=org.postgresql.Driver
spring.liquibase.change-log=changelog.xml

Обращаем внимание на то, что в application.properties нашей конфигурации нужно поместить все необходимые параметры, включая change-log для liquibase, что бы применить новые изменения для БД, которая, кстати, нашему приложению нужна для работы. В этот раз мы про неё не забудем:

docker run -it --rm -e POSTGRES_DB=simple_note -e POSTGRES_USER=simple_note_user -e POSTGRES_PASSWORD=simple_note_password postgres

Эта команда запустит контейнер с экземпляром PostreSQL, в котором будут нужная нам БД и логин/пароль для подключения. Теперь можно запускать приложение. И убедиться, что оно не запустится.

Дело в том, что наш rest контроллер из восьмой части использует порт NoteService из самой первой части. А класс с его реализацией не аннотирован как Component, поэтому в контекст Spring он не попадает. Очевидным решением было бы добавить нужный атрибут и забыть про это недоразумение. Однако для этого нам понадобится добавить в модуль business-core зависимость от spring-context, чего в ядре нашего приложения мы всеми силами стараемся избежать. Поэтому пойдём другим путём. Мы создадим ещё один модуль - common. И добавим зависимость ему. А внутри common создадим свою аннотацию:

package simple.note.common;

import org.springframework.core.annotation.AliasFor;
import org.springframework.stereotype.Component;

import java.lang.annotation.*;

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface UseCase {
    @AliasFor(annotation = Component.class)
    String value() default "";
}

Так как она по сути является алиасом для Component, мы можем вешать их на классы и получить тот же эффект. Добавляем модулю business-core зависимость от common, а классу NoteServiceImplementation аннотацию UseCase. Теперь приложение запустится без проблема и мы даже сможем увидеть пустой список заметок по адресу http://localhost:8080/api/note (в json формате, разумеется).

Часть десятая. Test Fixtures

Прежде чем отдать наш backend ребятам из отдела UI, давайте проверим, что он работает. То есть давайте напишем e2e тесты. Это новая группа тестов, а мы уже знаем как эти группы создаются. По аналогии с integrationTest в adapters создаём набор e2eTest в configurations:

subprojects {
    testing {
        suites {
            e2eTest(JvmTestSuite) {
                dependencies {
                    implementation project
                    implementation "org.springframework.boot:spring-boot-starter-test"
                }

                targets {
                    all {
                        testTask.configure {
                            shouldRunAfter(test)
                        }
                    }
                }

                useJUnitJupiter()
            }
        }
    }

    configurations {
        e2eTestImplementation.extendsFrom implementation
        e2eTestRuntimeOnly.extendsFrom runtimeOnly
    }

    tasks.named("check") {
        dependsOn (testing.suites.e2eTest)
    }
}

Давайте для начала проверим, что ребята из отдела UI не поперхнутся кофе из-за того, что страница с документацией недоступна:

package simple.note;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

public class SwaggerDocsAvailableTest extends SimpleNoteWebApplicationTest {
    @Autowired
    private MockMvc mockMvc;

    @Test
    void successResponse() throws Exception {
        mockMvc.perform(get("/swagger-ui/index.html"))
                .andExpect(status().isOk());
    }
}

Запускаем тест, наблюдаем как он валится. Мы опять забыли про БД. Testcontainers мы уже подключали в адаптере storage. Давайте попробуем его переиспользовать. Получается нам для этого нужно набору e2eTest модуля :configurations:webapp добавить зависимость от группы integrationTest модуля :adapters:storage. Привычными нам способами Gradle это сделать не позволит. Зато у него есть ещё один штатный плагин специально для этих целей. И называется он java-test-fixtures. После применения его на адаптере хранилища у него появляется возможность добавить ещё один source set - testFixtures. Делаем это и переносим туда SimpleNotePostgreSQLContainer, StorageIntegrationTest и все зависимости testcontainers:

plugins {
    id "java-test-fixtures"
}

dependencies {
    ...
    testFixturesImplementation "org.testcontainers:postgresql:1.17.2"
    testFixturesImplementation "org.testcontainers:junit-jupiter:1.17.2"
    testFixturesImplementation "org.testcontainers:postgresql:1.17.2"

    integrationTestImplementation(testFixtures(project))
}

Обратите внимание на последнюю строку - именно так указывается зависимость на testFixtures. Тоже самое нужно сделать для набора e2e в :configurations:webapp:

e2eTestImplementation(testFixtures(project(":adapters:storage")))

Теперь можно расширить SimpleNoteWebApplicationTest тем же StorageIntegrationTest и запустить e2e тесы на выполнение. В этот раз они проходят. Делаем gradlew check, что бы запустить все наши тесты и убедиться, что в этот раз ничего не сломалось.

Эпилог

Итак. Получили в распоряжение задачу на старт нового проекта. Взяли чистый лист и начертили на нём структуру. Поставили на места тесты, позаботились об автоматическом обновлении БД и о тестовых данных в ней. Для реализации полноценного проекта ещё предстоит много над чем поработать. Но имея на руках готовую к работе структуру развивать проект на порядок проще. Нам всем знаком процесс внесения изменений в проект. Но вот самое первое изменение - расчертить чистый лист - это всегда вызов.

Раз уж Вы дочитали мою первую статью до конца, то, надеюсь, она Вам понравилась, а я заслужил право минусовать комментарии :)

Теги:
Хабы:
Всего голосов 11: ↑6 и ↓5+1
Комментарии12

Публикации