Объекты-значения: приведение, проверки, маскировка
Для кого. Ты прочитал статьи 0–7, и у тебя есть песочница
game-dayпосле статьи 7. Особенно пригодятся запись, компактный конструктор, фабричный метод и тесты по таблице (статья 0). Самим объектам-значениям Docker не нужен.Что будет. Как сделать из строки объект, который сам приводит себя к одному виду и не бывает неверным; почему
toString()записи выдаёт секреты и как это закрыть; как перевести чужой код в своё перечисление — и как всё это проверить тестами.Откуда статья. Раздел «Буткемп / Лаборатория» — о том, как пишут код команды Hammerhall: буткемп работает в открытых репозиториях, лаборатория — в закрытых. Серия «От Spring к системе» — следующий шаг после «От Java к Spring Boot»: как приложение живёт среди других программ. Примеры — учебные: их можно повторить у себя.
Зачем объекты-значения#
Посмотри, где в журнале живёт имя дежурного. В
DutyRoster — строка "duty-1". В
Notifier.send(String dutyName, String text) — строка. В
IncidentLog, который спрашивает, кто дежурит, — тоже
строка. Везде String.
Пока имя приходит из одного места, GameDayConfig, всё
работает. Теперь представь, что оно приходит ещё и из настроек, как
стоп-слово в статье 2. А ещё из таблицы дежурств, которую тимлид набрал
руками, и из запроса к журналу. Кто-то напишет Duty-1,
кто-то вставит duty-1 с пробелом из копипасты. Для
String.equals это разные строки.
Журнал хранит происшествия в базе, и поиск по Duty-1
вернёт пустой список — без ошибки, без следа в логе. Дежурный «чист»,
хотя у него три открытых происшествия.
У такого «строкового» кода три беды:
- одно значение — много написаний. Сравнение, поиск в
базе, ключ в
Map(карта «ключ → значение», о ней ниже) молча промахиваются; - одна проверка в десяти местах. «Имя задано»
придётся проверять в
DutyRoster, потом в настройках, потом на каждом новом входе — ещё копия, ещё одна. Где-то забудут, где-то напишут по-своему; - строки путаются местами.
notifier.send(text, dutyName)— порядок перепутан, а компилятор молчит: обе строки.
Лекарство — объект-значение: маленький тип вместо голой строки. Он один раз, при создании, приводит значение к одному виду и проверяет его. Дальше по коду ходит не «какая-то строка», а годное имя дежурного. И перепутать его с текстом оповещения не даст компилятор: типы разные.
В рабочих проектах таких типов много: адрес почты, код валюты, номер
заказа, ключ задачи — TaskKey из статьи 0 как раз такой.
Пишут их обычно джуны: маленькая задача с таблицей случаев. Статья
объясняет, откуда берутся строки такой таблицы: разные написания, секрет
в toString(), незнакомый чужой код.
Сквозной пример — журнал игрового дня, учебный пример серии. Построим
три вещи: имя дежурного DutyName, стоп-слово игры
StopWord и исход атаки AttackOutcome.
Часть 1. DutyName: привести, проверить, сравнить
Значение, а не «кто-то»#
Происшествие в журнале — «кто-то». Два раза записали «брокер не отвечает» — это два разных происшествия, даже если всё остальное совпало: брокер падал дважды. Различают их по номеру, а не по тексту; такой объект называют сущностью (entity). Объект-значение устроен наоборот: его узнают только по содержимому. Как дату в календаре: «5 октября» на двух листках — одна и та же дата, какой листок ни возьми.
Что от объекта-значения ждут:
- приведён к одному виду —
Duty-1иduty-1внутри становятся однимduty-1; - всегда годен — неверный не создаётся вовсе (главная мысль статьи 0);
- неизменяем — проверил один раз, и он годен навсегда;
- равен по значению — два одинаковых равны.
Последние два запись даёт даром. Первые два пишем сами.
В коротких примерах статьи нет строк import; полные
файлы — в конце.
Фабрика of: сначала привести#
public static DutyName of(String raw) {
if (raw == null) { // сначала null: иначе strip() бросит NullPointerException
throw new IllegalArgumentException("имя дежурного не указано");
}
return new DutyName(raw.strip().toLowerCase(Locale.ROOT)); // привести; проверит конструктор
}Фабричный метод ты знаешь по TaskKey.parse из статьи 0.
Имя of — обычное для фабрики, которая берёт значение почти
как есть: так зовут и фабрики самой Java — List.of,
Path.of. Фабрика, которая разбирает строку на части, —
parse, как TaskKey.parse и
LocalDate.parse.
Порядок важен: сначала привести, потом проверить.
Наоборот — и duty-1 с пробелом из копипасты получит отказ
из-за пробела, хотя имя хорошее. Проверка должна видеть ровно то, что
будет храниться.
⚠️ strip(), а не trim()#
В интернете пробелы по краям срезают trim() — он в Java
с самого начала. У нас — strip(), он появился в Java 11 и
считает пробелом другое:
trim()срезает все знаки с номером до U+0020 включительно (так пишут номер знака в Unicode; U+0020 — обычный пробел): пробел, табуляцию, перевод строки и служебные знаки вроде U+0000;strip()срезает то, что Java считает пробелом (Character.isWhitespace): обычный пробел, табуляцию, перевод строки и пробелы Unicode — например, широкий пробел U+2003, который прилетает при копировании из документа. Служебные знаки вроде U+0000 он оставит.
После trim() широкий пробел останется: голая строка не
совпадёт с duty-1, а наш DutyName откажет
хорошему имени. Служебный знак после strip() тоже
останется, но его отвергнет проверка формы ниже. Это честнее, чем молча
срезать мусор. Поэтому в объектах-значениях — strip().
💡 Неразрывный пробел U+00A0 Character.isWhitespace
пробелом не считает, а его номер больше U+0020: его не снимет ни
strip(), ни trim(). Для DutyName
это не беда: проверка формы отвергнет такое имя. Ошибка лучше, чем тихое
«другое имя».
⚠️ Нижний регистр — только с Locale.ROOT
Локаль (locale) — языковые настройки машины: как
писать числа и даты, как менять регистр букв. Частая ошибка — просто
toLowerCase(): он берёт локаль машины, а у некоторых языков
правила свои. В турецком у заглавной I строчная пара —
ı, «и без точки»:
"KIRA".toLowerCase(Locale.forLanguageTag("tr")); // турецкая локаль: "kıra" — i без точки
"KIRA".toLowerCase(Locale.ROOT); // "kira" — на любой машинеLocale.ROOT — «без языка»: одни правила везде. На машине
с турецкой локалью приведение без Locale.ROOT сделало бы из
KIRA строку kıra. Проверка формы отвергла бы
её как не латиницу, хотя набрали чистую латиницу.
Сервер, ноутбук коллеги, сборка — у каждого своя локаль. Такая ошибка
видна на одной машине из многих. Документация Java прямо советует
toLowerCase(Locale.ROOT) для строк, которые не зависят от
языка: имён, ключей, кодов.
Так же приводят адреса почты и коды. В рабочем проекте к такому типу
бывает тест с турецкой локалью по умолчанию
(Locale.setDefault). Локаль по умолчанию одна на весь
запуск и достанется всем тестам, что пойдут следом. Поэтому такой тест
обязан вернуть прежнюю.
Проверки — в компактном конструкторе
public record DutyName(String value) {
private static final int MAX_LENGTH = 50; // столько вместит столбец VARCHAR(50) в базе
public DutyName {
if (value == null || value.isEmpty()) { // пусто — первым: у пустого нечего проверять
throw new IllegalArgumentException("имя дежурного не указано");
}
if (value.length() > MAX_LENGTH) {
throw new IllegalArgumentException("имя дежурного длиннее " + MAX_LENGTH + " знаков");
}
if (!value.matches("[a-z][a-z0-9-]*")) { // буква, дальше буквы, цифры, дефис
throw new IllegalArgumentException(
"имя дежурного: латинские строчные буквы, цифры и дефис, первой — буква; создавай через DutyName.of");
}
}
// of — выше
}- Пустое — первым.
of(" ")послеstrip()даёт"", и человек должен прочитать «не указано», а не «неверная форма». - Длина — с расчётом на базу. Журнал хранит имя дежурного в столбце
duty_name VARCHAR(50)(статья 3а): имя длиннее 50 знаков база откажется сохранить. Лучше узнать при создании. - Форма — регулярное выражение, как в статье 0: сначала одна строчная
латинская буква
[a-z], дальше сколько угодно букв, цифр и дефисов[a-z0-9-]*. Дефис в конце набора — просто знак. - Само имя в текст ошибки не вставляем: в таблицу дежурств могут вписать и настоящее имя человека.
💡 duty- и d--1 форма пропустит — для
журнала не беда. Строки таблицы в задаче — минимум, а не потолок: нашёл
такой край у себя — напиши о нём в пул-реквесте.
Почему приведение в of, а проверки в конструкторе?
Конструктор записи всегда открыт: new DutyName(" Duty-1 ")
написать можно, мимо of. Но форма сама требует приведённого
вида — заглавной букве и пробелу в неё не попасть. У
DutyName неверный объект не создаётся ни через
of, ни через new, а текст ошибки подскажет,
как надо.
В рабочем коде встретишь и другие раскладки. Бывает, конструктор
сторожит только «не null и уже приведён», а проверки формы
живут в of. Тогда new создаст приведённое, но
негодное значение, и в таком проекте значения создают только через
of. Бывает, приводить нечего: все проверки — в
конструкторе, а parse только режет строку на части.
Раскладки разные, и все годятся, пока соблюдено общее:
🔑 Приводят один раз, на входе, и неприведённого объекта не
бывает — ни через фабрику, ни через new.
Неизменяемость#
Поля записи final, сеттеров нет, String сам
неизменяем. Проверенный DutyName годен навсегда: его можно
отдать в любой метод, положить ключом в Map — никто по
дороге его не испортит.
Равенство: даром — и правильное
equals и hashCode запись пишет сама, по
полям (статья 0). Сами по себе они сравнивают строки как есть. Но внутри
DutyName всегда приведённое значение — и обычное равенство
становится правильным:
DutyName.of(" Duty-1 ").equals(DutyName.of("duty-1")); // true: внутри у обоих "duty-1"
" Duty-1 ".equals("duty-1"); // false: строки разныеОтсюда главное: коллекции начинают понимать, что это одно и то же.
Set — набор без повторов, а Map хранит пары
«ключ → значение». Их обычные реализации — HashSet и
HashMap.
Set<DutyName> onDuty = new HashSet<>();
onDuty.add(DutyName.of("duty-1"));
onDuty.add(DutyName.of(" DUTY-1 ")); // false: такой уже есть
onDuty.size(); // 1; с двумя строками было бы 2
Map<DutyName, Integer> openByDuty = new HashMap<>();
openByDuty.put(DutyName.of("duty-1"), 3);
openByDuty.get(DutyName.of("DUTY-1")); // 3: ключ найденhashCode — число, которое объект
считает из своих полей. HashSet ищет элемент так: сначала
по hashCode выбирает «ящик», потом внутри ящика сравнивает
equals. Поэтому у Java есть договор: равные по
equals объекты обязаны иметь равный hashCode.
Запись выполняет его сама.
⚠️ В обычном классе легко переопределить equals и забыть
hashCode. Тогда два «равных» объекта попадут в разные
ящики, и HashSet сохранит оба. Пишешь объект-значение
классом — переопределяй оба метода вместе.
Почему не сравнивать строки через equalsIgnoreCase?
Пробелов он не снимет, а в HashSet и ключ Map
его не подставишь: там сравнивают equals и
hashCode. Привести один раз дешевле, чем помнить о регистре
и пробелах в каждом сравнении и каждом запросе.
Где приводить в журнале#
В этой статье журнал не переделываем: DutyRoster и
Notifier по-прежнему работают со строкой. Будь
DutyName в деле, приводить пришлось бы на обоих концах — и
при записи, и при поиске. Журнал хранит происшествия в базе (статья 3б),
и искать их пришлось бы тем же видом, каким записывали: по
DutyName.of(raw).value(), а не по raw, как
пришло.
Хранить в столбце сам объект, а не строку, умеет преобразователь (конвертер). До него серия ещё дойдёт.
Что делать со старыми строками, которые уже лежат в базе в разном виде, и как база сторожит уникальность — вопросы побольше. В рабочем проекте их решает мидл, а джуну хватает правила приведения из задачи.
Часть 2. 🔴 StopWord: секрет не печатается
toString() записи выдаёт всё#
Запись сама пишет toString(): имя класса, имена полей и
их значения. Удобно, пока значения не секретны:
DutyName.of("duty-1").toString(); // "DutyName[value=duty-1]"Точный вид этой строки документация Java не обещает — разбирать её в коде нельзя.
Теперь вспомни настройки игры из статьи 2:
GameDayProperties(Duration length, String stopWord, int attackers).
Там мы печатали поля по одному и стоп-слово не трогали. Но хватит одной
небрежной строки:
System.out.println("настройки: " + gameDay);
// настройки: GameDayProperties[length=PT3H, stopWord=red-fox, attackers=2]GameDayProperties в этой статье не трогаем:
StopWord ниже показывает приём на отдельном типе.
toString() зовут неявно: склейка строк через
+, печать, лог, текст исключения. Стоп-слово ушло в лог. А
лог читают многие, хранят долго и копируют в общий сбор логов:
остановить игру теперь может любой, кто видит логи стенда.
В рабочем коде в лог пишут не System.out, а логгером,
как в статье 4: logger.info("настройки: {}", gameDay). На
место {} логгер подставит строковое значение объекта, тот
же toString(). Стоп-слово уйдёт в лог точно так же.
Правило рабочего проекта: в логе, «журнале приложения», нет секретов и личных данных, то есть адресов, имён, текстов писем.
StopWord: значение — только по имени метода
public record StopWord(String value) {
public StopWord {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("стоп-слово не задано");
}
if (value.contains("${")) { // ловушка из статьи 2 — теперь в самом типе
throw new IllegalArgumentException("стоп-слово не задано: переменная окружения не подставилась");
}
if (!value.equals(value.strip())) { // мимо of с пробелами не пройти
throw new IllegalArgumentException("стоп-слово с пробелами по краям — создавай через StopWord.of");
}
}
public static StopWord of(String raw) {
if (raw == null) {
throw new IllegalArgumentException("стоп-слово не задано");
}
return new StopWord(raw.strip());
}
@Override
public String toString() { // своё вместо того, что пишет запись
return "StopWord[скрыто]";
}
}toString()переопределён, как у любого класса: всё, что запись пишет сама, можно объявить и руками — тогда она берёт твоё. Теперь"стоп-слово: " + wordдаётстоп-слово: StopWord[скрыто].- Сверить со словом тимлида можно, не доставая значение:
StopWord.of(said).equals(stopWord).value()— только там, где строка правда нужна снаружи; в коде видно, кто её берёт. - Проверка
${из статьи 2 живёт в самом типе: где бы стоп-слово ни создали, ловушка закрыта. - Тексты ошибок — без значения. Даже «с пробелами по краям» не повторяет того, что пришло.
- Равенство не пострадало:
StopWord.of(" red-fox ")равенStopWord.of("red-fox"). ДляequalstoString()не нужен.
🔑 Маска в типе работает и внутри других объектов.
Запись, которая держит StopWord, напечатает
stopWord=StopWord[скрыто]. Спрятал один раз — и не нужно
помнить об этом в каждой строке с +.
Бывает и обратное переопределение — у ключей и кодов, которые не
секрет: toString() возвращает само значение, чтобы лог
показывал ключ так, как его пишут люди, — GD-130, а не
TaskKey[number=130].
Где ещё утекает#
toString() закрывает не всё. Вот три места, о которых
помнят отдельно.
Текст исключения.
"неверное стоп-слово: " + said — сказанное слово может
оказаться настоящим с опечаткой, а текст исключения попадёт в лог. В
тексте — причина, а не значение. Адрес почты в ошибку
не вставляют; ответ чужого сервера целиком — тоже: в нём бывает адрес. А
то, что не секрет и не личные данные, вставлять можно и нужно: оно
помогает починить. TaskKey.parse из статьи 0 честно
повторяет пришедший ключ — ключ задачи не секрет.
Объекты, которые печатают всё. Запись, у которой
среди полей секрет или личное, выдаст его своим toString()
— как GameDayProperties выше. Такую запись целиком в лог не
выводят. А иногда запись не годится вовсе: если объект держит, скажем,
список адресов, запись напечатала бы их и отдала бы наружу методом
чтения. Тогда пишут обычный класс и сами решают, что он показывает.
JSON. Jackson переводит запись в JSON по полям,
методами чтения, а не через toString(). Положишь
StopWord в запись, которую приложение отдаёт наружу
JSON-ом, — Jackson возьмёт значение методом чтения value().
Стоп-слово окажется в ответе, а секретам в ответах не место вообще.
Бывает и частичная маска: показать кусок значения, чтобы человек
понял, о чём речь, — i***@example.com. Её пишут отдельным
типом тем же приёмом: значение внутри, наружу — только маска.
Часть 3. AttackOutcome: чужой код — в свой исход
Три написания одного исхода#
Атаки в игровой день тимлид запускает скриптами. Чем кончилась атака, скрипт сообщает кодом выхода, как в статье 6. У тимлида заведено: 0 — атака удалась, 1 — стенд отбился. А ещё bash сам вернёт 127, если скрипт не нашёлся, и число больше 128, если скрипт прервали — скажем, нажали Ctrl+C.
Как число доходит до журнала, здесь неважно: его вносит тимлид или
передаёт обёртка вокруг скрипта. Журнал получает int.
Этот код — чужой: его придумали не мы, и новая версия скрипта может его поменять. Поэтому у исхода атаки три написания:
| где | пример | кто решает |
|---|---|---|
| имя константы в Java | HIT |
мы, в коде |
| наш код — для базы и JSON | hit |
мы, договор с базой и клиентами |
| чужой код | 0 |
автор скрипта |
Перечисление со своим кодом ты знаешь по Area из статьи
0. Новое — разбор чужого:
public enum AttackOutcome {
HIT("hit"), REPELLED("repelled"), SCRIPT_FAILED("script-failed");
// поле code, конструктор и code() — как у Area в статье 0
public static AttackOutcome fromExitCode(int exitCode) {
return switch (exitCode) { // switch-выражение: ветка сразу даёт значение
case 0 -> HIT;
case 1 -> REPELLED;
default -> SCRIPT_FAILED; // всё прочее: 2, 127, 130…
};
}
}Это switch-выражение из статьи 7 (в Java оно с версии
14). Новое здесь — default. Ответ нужен на любое число,
поэтому он обязателен: значений у int не перечесть, и без
него код не скомпилируется. В статье 7 switch шёл по
закрытому набору типов — там наоборот: default прячет
забытые случаи.
Незнакомое — в самый осторожный исход
Почему default — SCRIPT_FAILED, а не
исключение? Скрипт чужой, вернуть он может что угодно, и падать на
каждом новом коде журналу незачем. А «скрипт сломался» — самый
осторожный вывод: мы не утверждаем ни что стенд упал, ни что он
устоял.
Это выбор журнала. Где неверная догадка стоит дорого, незнакомый код
делают ошибкой: скажем, когда от исхода зависит, отправлять ли письмо
ещё раз. Что делать с незнакомым, в рабочем проекте всегда сказано в
задаче — как и с Area в статье 0. Бывает и так, что своего
кода у перечисления нет: в базу пишут имя константы, и потому её не
переименовывают. Оба способа и чем ломается каждый — дальше в серии.
Сравни с Area.fromCode из статьи 0. Там код
свой, и разбор строг: Backend — да,
backend — нет. Свой код проверяют строго, а чужой переводят
в свой — один раз, на входе. Дальше по коду ходит только
AttackOutcome, и смена скрипта затронет один метод.
⚠️ Не по номеру константы#
Соблазн — покороче: AttackOutcome.values()[exitCode],
«константа номер 0, номер 1». Переставишь константы местами — смысл тихо
поменяется. А код 127 бросит ArrayIndexOutOfBoundsException
посреди игры. Номер по порядку (ordinal()) по той же
причине не кладут и в базу; как хранить перечисление кодом — дальше в
серии.
Часть 4. Тесты объекта-значения
Что проверить, следует из свойств части 1:
| свойство | случаи |
|---|---|
| приведение | разные написания — один объект: equals и
hashCode |
| проверки | на каждое правило — строка с нарушением; границы: 50 знаков годно, 51 — нет |
null |
отдельный тест: @ValueSource его не примет (статья 0) —
или @NullSource |
| обход фабрики | new с неприведённым значением — ошибка |
| секрет | toString() и текст ошибки — без значения |
Здесь — по одной-две проверки на приём, а не полный набор (статья 0).
В тестах объектов-значений встретишь ещё два источника данных:
@NullSource — подать null ещё одним запуском
рядом с @ValueSource; @EnumSource — запустить
тест по всем константам перечисления. @MethodSource из
статьи 7 берут, когда строки удобнее построить кодом: имя ровно в 51
знак — "d".repeat(51), руками такое без ошибки не
набрать.
Главная строка — «разные написания — один объект»:
@ParameterizedTest(name = "{0} — это duty-1")
@ValueSource(strings = {"duty-1", " duty-1 ", "Duty-1", "DUTY-1", "\tduty-1\n", "\u2003duty-1"})
void spellingsAreOneName(String raw) {
DutyName name = DutyName.of(raw);
assertThat(name).isEqualTo(DutyName.of("duty-1")); // равны
assertThat(name).hasSameHashCodeAs(DutyName.of("duty-1")); // и для HashSet тоже
assertThat(name.value()).isEqualTo("duty-1"); // внутри — приведённое
}\u2003 — знак по номеру в Unicode, здесь широкий пробел;
\t и \n — табуляция и перевод строки. Именно
строка с \u2003 покраснеет, если кто-то заменит
strip() на trim().
hasSameHashCodeAs — проверка AssertJ «одинаковый
hashCode».
Негодные — по строке на правило:
@ParameterizedTest(name = "{0} — ошибка")
@ValueSource(strings = {"", " ", "duty 1", "1-duty", "duty_1", "дежурный-1", "\u00A0duty-1"})
void rejectsBadNames(String raw) {
assertThatThrownBy(() -> DutyName.of(raw))
.isInstanceOf(IllegalArgumentException.class);
}Секрет проверяют там, где он мог бы утечь, — например, внутри другой записи:
@Test
void hidesValueInsideOtherRecords() {
record Game(String title, StopWord stopWord) {} // запись прямо в методе
Game game = new Game("игровой день", StopWord.of("red-fox"));
assertThat(game.toString()).doesNotContain("red-fox");
}Запись можно объявить прямо внутри метода — так удобно для одного
теста. Для текстов ошибок есть
hasMessageNotContaining("red-fox"): «в тексте этого
нет».
Перечисление — таблицей. Текст HIT JUnit сам превратит в
константу по имени, как превращал 130 в число в статье
0:
@ParameterizedTest(name = "код выхода {0} → {1}")
@CsvSource(delimiter = '|', textBlock = """
0 | HIT
1 | REPELLED
127 | SCRIPT_FAILED
-1 | SCRIPT_FAILED
""")
void readsExitCode(int exitCode, AttackOutcome expected) {
assertThat(AttackOutcome.fromExitCode(exitCode)).isEqualTo(expected);
}Коды выхода — от 0 до 255, но int шире; строка
-1 проверяет, что и мусор не уронит разбор.
💡 Ветку value == null в конструкторе
DutyName через of не пройти: of
отвергнет null раньше. Нужна строка
new DutyName(null) — как new TaskKey(0) в
статье 0. Без неё JaCoCo покажет условие жёлтым; в учебном примере так и
будет — там только приёмы.
Проверь себя#
Ответь своими словами — вслух или на бумаге. Не получается — перечитай раздел.
- Чем объект-значение отличается от сущности? Объясни на двух
DutyName«duty-1» и двух происшествиях с одним заголовком. - Почему в
DutyName.ofсначала приводят, а потом проверяют? Что получил быduty-1при обратном порядке? - Чем
strip()отличается отtrim()? Какой пробел не снимает ни тот, ни другой — и что с ним сделаетDutyName? - Что вернёт
"KIRA".toLowerCase()на машине с турецкой локалью? Чем это кончилось бы дляDutyName.ofбезLocale.ROOT— и почему с ним всё в порядке? - Компактный конструктор
DutyNameничего не приводит. Почемуnew DutyName(" Duty-1 ")всё равно не создастся? - Почему
HashSetизDutyName.of("duty-1")иDutyName.of("DUTY-1")хранит один элемент? Что, кромеequals, ему для этого нужно? - Стоп-слово спрятано переопределённым
toString(). Где оно всё ещё может утечь? - Скрипт атаки вернул 137. Что запишет журнал, почему не исключение —
и почему нельзя
values()[exitCode]?
Что дальше#
Дальше в серии — данные: как положить перечисление в столбец базы кодом, подробности атаки — JSON-ом в столбце, выборки по времени и индексы под них.
Первоисточники#
- JEP 395: Records —
компактный конструктор, явные
toString/equals, записи внутри метода; - java.lang.Record
(Java 25) — что именно обещают
equals,hashCodeиtoStringзаписи; - java.lang.String
(Java 25) —
strip,trimи примечание кtoLowerCase()про турецкую локаль; - JEP 361: Switch
Expressions —
switchсо стрелками и почему нуженdefault; - JUnit: Parameterized Classes and Tests — как строка из таблицы превращается в число и в константу перечисления.
Пример целиком — с импортами#
Учебный пример. Запускай в песочнице game-day — той, что
осталась после статьи 7. Ни Docker, ни база, ни веб самим
объектам-значениям не нужны, но прогон ./mvnw verify идёт
по всей песочнице, а её тесты с базой ждут запущенный Docker, как в
статье 3а. Новые классы — на чистой Java, тестам хватает
spring-boot-starter-test (JUnit, AssertJ), который положил
Initializr: в pom.xml ничего добавлять не надо. Java-код
песочницы — без изменений.
Шесть файлов ниже. StopWord — настройка игры, поэтому
лежит в example.gameday, рядом с
GameDayProperties. DutyName и
AttackOutcome — то, с чем работает журнал, они в
example.gameday.journal, рядом с DutyRoster.
Тесты — в тех же пакетах под src/test/java. Запуск: один
класс — треугольником в IDE или
./mvnw test -Dtest=DutyNameTest; весь
./mvnw test прогонит и тесты песочницы.
// src/main/java/example/gameday/journal/DutyName.java
package example.gameday.journal;
import java.util.Locale;
public record DutyName(String value) {
private static final int MAX_LENGTH = 50;
public DutyName {
if (value == null || value.isEmpty()) {
throw new IllegalArgumentException("имя дежурного не указано");
}
if (value.length() > MAX_LENGTH) {
throw new IllegalArgumentException("имя дежурного длиннее " + MAX_LENGTH + " знаков");
}
if (!value.matches("[a-z][a-z0-9-]*")) {
throw new IllegalArgumentException(
"имя дежурного: латинские строчные буквы, цифры и дефис, первой — буква; создавай через DutyName.of");
}
}
public static DutyName of(String raw) {
if (raw == null) {
throw new IllegalArgumentException("имя дежурного не указано");
}
return new DutyName(raw.strip().toLowerCase(Locale.ROOT));
}
}// src/main/java/example/gameday/StopWord.java
package example.gameday;
public record StopWord(String value) {
public StopWord {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("стоп-слово не задано");
}
if (value.contains("${")) {
throw new IllegalArgumentException("стоп-слово не задано: переменная окружения не подставилась");
}
if (!value.equals(value.strip())) {
throw new IllegalArgumentException("стоп-слово с пробелами по краям — создавай через StopWord.of");
}
}
public static StopWord of(String raw) {
if (raw == null) {
throw new IllegalArgumentException("стоп-слово не задано");
}
return new StopWord(raw.strip());
}
@Override
public String toString() {
return "StopWord[скрыто]";
}
}// src/main/java/example/gameday/journal/AttackOutcome.java
package example.gameday.journal;
public enum AttackOutcome {
HIT("hit"), REPELLED("repelled"), SCRIPT_FAILED("script-failed");
private final String code;
AttackOutcome(String code) {
this.code = code;
}
public String code() {
return code;
}
public static AttackOutcome fromExitCode(int exitCode) {
return switch (exitCode) {
case 0 -> HIT;
case 1 -> REPELLED;
default -> SCRIPT_FAILED;
};
}
}// src/test/java/example/gameday/journal/DutyNameTest.java
package example.gameday.journal;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class DutyNameTest {
@ParameterizedTest(name = "{0} — это duty-1")
@ValueSource(strings = {"duty-1", " duty-1 ", "Duty-1", "DUTY-1", "\tduty-1\n", "\u2003duty-1"})
void spellingsAreOneName(String raw) {
DutyName name = DutyName.of(raw);
assertThat(name).isEqualTo(DutyName.of("duty-1"));
assertThat(name).hasSameHashCodeAs(DutyName.of("duty-1"));
assertThat(name.value()).isEqualTo("duty-1");
}
@ParameterizedTest(name = "{0} — ошибка")
@ValueSource(strings = {"", " ", "duty 1", "1-duty", "duty_1", "дежурный-1", "\u00A0duty-1"})
void rejectsBadNames(String raw) {
assertThatThrownBy(() -> DutyName.of(raw))
.isInstanceOf(IllegalArgumentException.class);
}
}// src/test/java/example/gameday/StopWordTest.java
package example.gameday;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class StopWordTest {
@Test
void hidesValueInsideOtherRecords() {
record Game(String title, StopWord stopWord) {}
Game game = new Game("игровой день", StopWord.of("red-fox"));
assertThat(game.toString()).doesNotContain("red-fox");
}
@Test
void rejectsUnresolvedVariable() {
assertThatThrownBy(() -> StopWord.of("${GAME_DAY_STOP_WORD}"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("не подставилась");
}
}// src/test/java/example/gameday/journal/AttackOutcomeTest.java
package example.gameday.journal;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.assertj.core.api.Assertions.assertThat;
class AttackOutcomeTest {
@ParameterizedTest(name = "код выхода {0} → {1}")
@CsvSource(delimiter = '|', textBlock = """
0 | HIT
1 | REPELLED
2 | SCRIPT_FAILED
127 | SCRIPT_FAILED
130 | SCRIPT_FAILED
-1 | SCRIPT_FAILED
""")
void readsExitCode(int exitCode, AttackOutcome expected) {
assertThat(AttackOutcome.fromExitCode(exitCode)).isEqualTo(expected);
}
}