Hammerhall Iron Ledger

Что нужно знать до Spring

Статья 0 · читать минут 20–25

Для кого. Ты знаешь Java по курсу: классы, методы, if, циклы, списки. Spring-приложение не видел ни разу. Этого достаточно — статья начинается ровно отсюда.

Что будет. Три вещи, которые встретятся в первой же рабочей задаче: немного Java, которой могло не быть в курсе; Maven — кто собирает проект; тесты по таблице — как одним тестом проверить десять случаев.

Откуда статья. Серия написана для команды, которая пишет на Spring Boot 4 шлюз уведомлений — сервис платформы Hammerhall, который рассылает письма и сообщения. Примеры — учебные: их можно повторить у себя.


Зачем начинать не со Spring#

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

Разобраться в такой ошибке можно, только если понимаешь, что лежит под Spring: обычная Java, сборка проекта и тесты. Поэтому первые задачи новичка в команде шлюза — без Spring вовсе: записи, перечисления, проверки и тесты к ним.

Примеры в статье — из мира шлюза. Сквозной пример — разбор ключа задачи: на доске задач команды у каждой задачи есть ключ — буквы проекта и номер, вроде GW-130.


Часть 1. Java, которая встретится в первых задачах

Запись — класс, который просто хранит данные

Представь, что нужно хранить номер задачи. Обычным классом это — поле, конструктор, метод чтения, а ещё equals, hashCode и toString — строк сорок ради одного числа. И в каждой из них можно ошибиться.

Запись (record) — короткий способ сказать Java: «это просто данные, остальное сделай сама».

public record GwKey(int number) {}

Одна строка, и вот что ты получаешь бесплатно:

⚠️ Метод чтения называется как поле: number(), а не getNumber(). В старых туториалах ты увидишь get… повсюду — у записей его нет.

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

Проверка при создании. Номер задачи не бывает нулём или отрицательным. Такую проверку пишут в компактном конструкторе — это конструктор записи без списка параметров:

public record GwKey(int number) {

    public GwKey {                           // компактный конструктор: скобок с параметрами нет
        if (number < 1) {
            throw new IllegalArgumentException("номер задачи — от 1, а пришло " + number);
        }
    }
}

Откуда внутри взялось number? Это параметр из заголовка записи — record GwKey(int number), — его повторять не нужно. Если проверка прошла, Java сама запишет значение в поле после конструктора.

Теперь new GwKey(0) просто не создастся — Java бросит ошибку. 🔑 Это главная мысль: неверный объект не должен существовать вовсе. Тогда его не придётся ловить потом, в десяти других местах.

Перечисление с кодом#

Перечисление (enum) — тип с заранее известным набором значений. Например, области задач на доске: Backend, DB, Infra, Test, Docs, Monitoring. Других не бывает.

Часто у значения есть «внешнее имя» — как оно пишется в JSON (текстовом формате, которым обмениваются программы), в базе или на доске. И оно не совпадает с именем в Java. Его хранят полем:

public enum Area {
    BACKEND("Backend"), DB("DB"), INFRA("Infra"),
    TEST("Test"), DOCS("Docs"), MONITORING("Monitoring");

    private final String code;               // как область пишется снаружи

    Area(String code) {
        this.code = code;
    }

    public String code() {
        return code;
    }

    public static Area fromCode(String code) {
        for (Area area : values()) {         // values() — все значения по порядку
            if (area.code.equals(code)) {
                return area;
            }
        }
        throw new IllegalArgumentException("незнакомая область: " + code);
    }
}

Как это читать: BACKEND("Backend") — это значение и сразу вызов конструктора Area(String code) с аргументом "Backend". Так у каждого значения появляется свой code. Точка с запятой после списка значений обязательна: дальше идёт обычный код класса.

Почему не встроенный Area.valueOf("Backend")? Он ищет Java-имя BACKEND буква в букву — и на "Backend" падает. Отдельный код развязывает два мира: можно переименовать значение в Java, и JSON, база и доска этого не заметят.

Что делать с незнакомым кодом — бросить ошибку или тихо пропустить, — решает задача. В шлюзе бывает и так и так, и в тексте задачи это всегда сказано.

Исключения: проверяемые и нет#

В Java два рода исключений:

В шлюзе ошибки — непроверяемые. Почему: ошибку вроде «пришёл неверный номер» нельзя исправить там, где её заметили. Её нужно донести наверх, до того места, которое знает, что ответить человеку. В шлюзе это одно место — обработчик ошибок, один на всё приложение. С проверяемыми исключениями каждый метод по дороге пришлось бы украшать throws.

⚠️ Позже, в задачах про JSON, ты встретишь в интернете Jackson (библиотеку для JSON) с catch (JsonProcessingException e). Это Jackson 2, там ошибки проверяемые. В шлюзе Jackson 3, и его ошибки — непроверяемые.

Фабричный метод вместо конструктора

Ключ приходит строкой: "GW-130". Превратить его в GwKey удобнее не конструктором, а статическим методом с понятным именем — фабричным методом:

GwKey key = GwKey.parse("GW-130");

Имя parse («разобрать») сразу говорит, что происходит. И перед созданием можно подготовить строку и проверить её:

public static GwKey parse(String raw) {
    if (raw == null) {
        throw new IllegalArgumentException("ключ не задан");
    }
    String text = raw.strip();                   // убрать пробелы по краям
    if (!text.startsWith("GW-")) {
        throw new IllegalArgumentException("ключ начинается с GW-: " + raw);
    }
    String digits = text.substring(3);           // всё, что после «GW-»
    if (!digits.matches("[1-9][0-9]*")) {        // только цифры, первая — не ноль
        throw new IllegalArgumentException("после GW- нужен номер: цифры, без нуля впереди: " + raw);
    }
    return new GwKey(Integer.parseInt(digits));
}

Строка "[1-9][0-9]*" — регулярное выражение, короткая запись шаблона. Квадратные скобки — «один символ из набора»: [1-9] — одна цифра от 1 до 9. Звёздочка — «предыдущее сколько угодно раз, хоть ни разу»: [0-9]* — любые цифры дальше. Метод matches проверяет строку целиком, а не ищет кусок внутри неё.

Итого: «первая цифра от 1 до 9, дальше только цифры». Так GW-0130 не пройдёт: по правилам шлюза номер пишется без нулей впереди.


Часть 2. Maven — кто собирает проект

Какую работу он делает#

Чтобы просто запустить тесты, нужно:

  1. скачать библиотеки — JUnit, AssertJ, Jackson — нужных версий;
  2. скачать то, без чего не работают они сами, — а у них тоже есть свои зависимости;
  3. скомпилировать твой код, потом тесты;
  4. запустить тесты и собрать отчёт.

Руками — часы работы и десятки мест для ошибки. Maven делает всё это одной командой по описанию проекта.

pom.xml — паспорт проекта#

Описание лежит в файле pom.xml в корне репозитория. Вот его важная часть — из настоящего шлюза:

<parent>                                      <!-- «родитель»: откуда брать версии -->
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.0</version>
</parent>

<groupId>io.hammerhall</groupId>             <!-- чей проект -->
<artifactId>hammerhall-gateway</artifactId>   <!-- какой проект -->
<version>0.1.0-SNAPSHOT</version>            <!-- версия; SNAPSHOT — «ещё в работе» -->

<dependencies>
    <dependency>                               <!-- библиотека для JSON -->
        <groupId>tools.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>
    <dependency>                               <!-- всё для тестов: JUnit, AssertJ и другие -->
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>                    <!-- нужна только тестам -->
    </dependency>
</dependencies>

Четыре слова, которые встретятся постоянно:

Заметь: у зависимостей нет версий. Их подставляет «родитель» — блок <parent> в начале. Он знает, какие версии библиотек проверены вместе.

«Но ведь статья про то, что Spring пока не нужен?» Так и есть: сейчас Spring Boot в проекте — только «завхоз», который выбирает версии библиотек. Сам код первых задач Spring не использует.

⚠️ В интернете Jackson подключают с groupId com.fasterxml.jackson.core. Это Jackson 2. У Jackson 3 — tools.jackson.core.

Откуда берутся библиотеки#

Из Maven Central — огромного общего хранилища библиотек. При первой сборке Maven скачивает всё нужное в папку на твоём компьютере — ~/.m2/repository, где ~ — твоя домашняя папка (на Windows это C:\Users\<имя>). Дальше берёт оттуда. Поэтому первая сборка идёт долго, а следующие — быстро.

Одна команда — много шагов#

У сборки есть фазы — шаги по порядку. Главные:

фаза что делает
compile компилирует твой код
test компилирует и запускает тесты
package собирает готовое приложение — файл .jar
verify финальные проверки

🔑 Попросишь фазу — Maven выполнит и все, что перед ней. Команда verify — это компиляция, тесты, сборка и проверки разом.

В шлюзе verify проверяет ещё и покрытие тестами — это делает инструмент JaCoCo, о нём в конце статьи. Если его меньше порога, сборка красная. Поэтому перед пул-реквестом — просьбой влить твои изменения в общий код — запускают именно:

./mvnw verify

Зелёная надпись BUILD SUCCESS в конце — можно сдавать.

mvnw — Maven, который приносит себя сам

mvnw — обёртка: маленький скрипт в репозитории. Он сам скачивает нужную версию Maven и запускает её. Ставить Maven отдельно не надо, и у всех в команде он одинаковый. В Git Bash на Windows команда та же: ./mvnw verify; в обычной командной строке Windows — mvnw.cmd verify.

Папка target#

После сборки в проекте появляется папка target. В ней всё, что сделал Maven: скомпилированные классы, отчёты, готовый .jar. Это результат сборки, а не исходники: в git её не кладут, а ./mvnw clean её удаляет — и следующая сборка начнётся с чистого листа.

Правило шлюза: зависимости добавляет мидл

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


Часть 3. Тесты по таблице#

Тест — программа, которая проверяет программу

Модульный тест — маленький метод, который вызывает твой код с известными данными и сверяет результат с ожидаемым. «Модуль» здесь — небольшой кусок программы: один класс или один метод.

В курсах код часто проверяют руками: написал main, запустил, посмотрел глазами на вывод. На работе так не делают — проверок сотни, и запускать их надо после каждого изменения. Для этого есть два инструмента. Оба уже в проекте: их принёс стартер spring-boot-starter-test из части 2.

JUnit — самая распространённая в Java библиотека для тестов. Ты пишешь методы-тесты, а JUnit сам находит их и запускает по одному — main писать не нужно. После прогона он сообщает, какие тесты прошли, какие упали и почему. Запустить тесты можно двумя способами: командой ./mvnw test (Maven зовёт JUnit на фазе test) или зелёным треугольником рядом с тестом в IDE.

AssertJ — библиотека проверок. Проверить «результат равен 130» можно и средствами самого JUnit, но у AssertJ проверка читается почти как фраза — assertThat(номер).isEqualTo(130), «утверждаю, что номер равен 130». А когда проверка не сходится, AssertJ говорит, что ждали и что пришло:

expected: 130
 but was: 13

Тесты живут отдельно от кода — в папке src/test/java, в том же пакете, что и проверяемый класс. Имя класса с тестами оканчивается на Test: по этому окончанию Maven их и находит.

class GwKeyTest {

    @Test                                         // «это тест» — JUnit найдёт его и запустит
    void parsesKey() {
        GwKey key = GwKey.parse("GW-130");        // 1. вызвать код
        assertThat(key.number()).isEqualTo(130);  // 2. проверить: «утверждаю, что номер — 130»
    }
}

@Test — это аннотация: пометка над методом. Её читает не Java, а инструмент — здесь JUnit: «этот метод — тест, запусти его». Аннотации будут повсюду, особенно в Spring.

Если parse вернёт не 130, тест покраснеет и скажет, что пришло вместо ожидаемого.

В коротких примерах статьи нет строк import — иначе каждый пример вырос бы вдвое. Откуда берутся @Test, assertThat и остальные, видно в полном примере в конце.

Беда: случаев много#

Посмотри, сколько всего надо проверить для одного parse:

на входе итог
GW-130 130
GW-130 с пробелами по краям 130
GW-1 1
gw-130 — маленькие буквы ошибка
GW-0130 — ноль впереди ошибка
GW- — номера нет ошибка
130 — нет приставки ошибка
GW-12a — буква в номере ошибка
null — ключа нет вовсе ошибка

Девять случаев. Девять почти одинаковых методов — это копипаста, а в копипасте ошибаются: поменял одно место и забыл второе.

Решение: один тест — много строк

Параметризованный тест — тест, который JUnit запускает много раз, подставляя каждый раз новую строку данных:

@ParameterizedTest(name = "{0} → {1}")           // name — как строка будет видна в отчёте
@CsvSource(delimiter = '|', textBlock = """
        GW-130     | 130
        ' GW-130 ' | 130
        GW-1       | 1
        """)
void parsesValidKeys(String raw, int expected) {
    assertThat(GwKey.parse(raw).number()).isEqualTo(expected);
}

Как это читать:

Строки с ошибками — отдельным тестом. assertThatThrownBy значит «утверждаю, что вот это бросит исключение».

Обрати внимание на () -> GwKey.parse(raw). Это лямбда — кусочек кода, который передают как значение, не выполняя его сразу. Зачем: если написать просто GwKey.parse(raw), исключение вылетит раньше проверки и уронит сам тест. А так AssertJ сам запустит этот кусочек, поймает исключение и проверит его. IllegalArgumentException.class — способ назвать класс исключения, которое ждём.

@ParameterizedTest(name = "{0} — ошибка")
@ValueSource(strings = {"gw-130", "GW-0130", "GW-", "130", "GW-12a"})
void rejectsBadKeys(String raw) {
    assertThatThrownBy(() -> GwKey.parse(raw))
            .isInstanceOf(IllegalArgumentException.class);
}

@Test
void rejectsNull() {
    assertThatThrownBy(() -> GwKey.parse(null))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining("не задан");
}

null отдельно, потому что @ValueSource его не принимает.

🔑 Так в шлюзе устроена каждая задача джуна: в тексте задачи есть таблица «на входе → итог». Каждая строка таблицы — отдельный случай в тестах: строка в @CsvSource или @ValueSource, а если туда не положить, как null, — отдельный тест. В нашей таблице девять строк — и все девять проверены. На ревью это смотрят первым делом.

💡 Таблица в задаче — минимум, а не потолок. Что будет с ключом GW-99999999999? Номер не влезет в int, и Integer.parseInt бросит NumberFormatException. Добавь такую строку в rejectsBadKeys — тест пройдёт: это наследник IllegalArgumentException. Но сообщение будет английским и непонятным: For input string: "99999999999". Нашёл такой край у себя — напиши о нём в пул-реквесте.

Покрытие: что тесты на самом деле прошли

Тесты зелёные — значит, всё проверено? Не обязательно. Можно написать пять тестов, и все они пройдут по одной и той же дороге, а половину кода не тронут вовсе. Чтобы это увидеть, нужен ещё один инструмент.

JaCoCo (от Java Code Coverage — «покрытие кода Java») следит за тестами, пока они идут, и отмечает каждую строку твоего кода, через которую прошло выполнение. Как маркер, которым ведут по тексту: после прогона видно, какие строки тесты прошли, а какие — ни разу.

В учебных проектах JaCoCo почти не встречается, а на работе без него редко обходятся. Команда договаривается о пороге — скажем, «покрыто не меньше 80% строк», — и сборка, которая не дотянула, не принимается. Так в проект не просачивается код, который никто ни разу не запускал.

Ставить JaCoCo отдельно не надо: он подключён в pom.xml как плагин — дополнение к Maven, которое тот скачивает и запускает сам. На фазе test JaCoCo следит за тестами и собирает отчёт, на фазе verify сверяет покрытие с порогом. Пороги в шлюзе:

Как читать отчёт. После ./mvnw verify открой в браузере target/site/jacoco/index.html. Там список пакетов с процентами; нажми на пакет, потом на класс — и увидишь свой код, раскрашенный по строкам:

⚠️ Красное здесь — не «тест упал», а «сюда тесты не заходили».

Ветки. Каждый if даёт две дороги: условие выполнилось или нет. Ветка покрыта, только если тесты проехали по обеим. Рядом с такими строками JaCoCo рисует ромбик того же цвета: зелёный — обе дороги пройдены, жёлтый — одна, красный — ни одной.

Наш пример показывает, зачем это нужно. Все тесты выше зелёные, но в отчёте проверка number < 1 в конструкторе будет жёлтой — ветка покрыта наполовину, — а строка с throw под ней красной. Через parse до неё не добраться: шаблон не пропустит ноль. Значит, нужен ещё один тест, прямо на конструктор:

@Test
void rejectsZero() {
    assertThatThrownBy(() -> new GwKey(0))
            .isInstanceOf(IllegalArgumentException.class);
}

Добавил тест, снова запустил ./mvnw verify — и строка стала зелёной.

⚠️ Покрытие — не доказательство. Вот тест, который даёт покрытие и не проверяет ничего:

@Test
void looksCoveredButChecksNothing() {
    GwKey.parse("GW-130");      // вызвали и не проверили результат
}

Строки зелёные, а если parse начнёт возвращать не тот номер, тест всё равно пройдёт. JaCoCo видит, где тесты были, но не знает, что они там проверили. Поэтому ревьюер смотрит не только на проценты, но и на то, что проверяет тест.


Проверь себя#

Ответь своими словами — вслух или на бумаге. Не получается — перечитай раздел.

  1. Чем запись отличается от обычного класса? Назови три вещи, которые она даёт бесплатно.
  2. Зачем перечислению Area отдельный code, если есть Area.valueOf(...)?
  3. Почему в шлюзе исключения непроверяемые?
  4. Что делает ./mvnw verify? Почему перед пул-реквестом нужна именно она, а не ./mvnw test?
  5. Где Maven хранит скачанные библиотеки и почему вторая сборка быстрее первой?
  6. В таблице задачи семь строк. Сколько случаев должны проверить тесты и куда положить строку с null?
  7. Что показывает JaCoCo и чего он показать не может?
  8. Покрытие 100%, а в коде ошибка. Как такое возможно?

Что дальше#

Статья 1 — «Зачем Spring». Напишем три объекта, которые зависят друг от друга, соберём их руками — и увидим, какую работу Spring забирает на себя.

Первоисточники#


Пример целиком — с импортами#

Учебный пример. Собрать его можно в любом проекте на Maven, где есть JUnit и AssertJ, — проще всего в заготовке со start.spring.io: как её завести, рассказано в статье 1. Так выглядят полные файлы: что лежит в src/main/java, что в src/test/java и откуда берутся @Test и assertThat. import static подключает статические методы класса — после него assertThat(...) пишется без имени класса впереди.

// src/main/java/example/GwKey.java
package example;

public record GwKey(int number) {

    public GwKey {
        if (number < 1) {
            throw new IllegalArgumentException("номер задачи — от 1, а пришло " + number);
        }
    }

    public static GwKey parse(String raw) {
        if (raw == null) {
            throw new IllegalArgumentException("ключ не задан");
        }
        String text = raw.strip();
        if (!text.startsWith("GW-")) {
            throw new IllegalArgumentException("ключ начинается с GW-: " + raw);
        }
        String digits = text.substring(3);
        if (!digits.matches("[1-9][0-9]*")) {
            throw new IllegalArgumentException("после GW- нужен номер: цифры, без нуля впереди: " + raw);
        }
        return new GwKey(Integer.parseInt(digits));
    }
}
// src/test/java/example/GwKeyTest.java
package example;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class GwKeyTest {

    @ParameterizedTest(name = "{0} → {1}")
    @CsvSource(delimiter = '|', textBlock = """
            GW-130     | 130
            ' GW-130 ' | 130
            GW-1       | 1
            """)
    void parsesValidKeys(String raw, int expected) {
        assertThat(GwKey.parse(raw).number()).isEqualTo(expected);
    }

    @ParameterizedTest(name = "{0} — ошибка")
    @ValueSource(strings = {"gw-130", "GW-0130", "GW-", "130", "GW-12a"})
    void rejectsBadKeys(String raw) {
        assertThatThrownBy(() -> GwKey.parse(raw))
                .isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void rejectsNull() {
        assertThatThrownBy(() -> GwKey.parse(null))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("не задан");
    }

    @Test
    void rejectsZero() {
        assertThatThrownBy(() -> new GwKey(0))
                .isInstanceOf(IllegalArgumentException.class);
    }
}

Попробовать руками

В кузницах Hammerhall — задачи по Java, которые проверяет сервер: решаешь в своей IDE, отправляешь одной командой, проверка запускает твои тесты и закрытые. Сложность растёт вместе с решённым, первые задачи бесплатны.