Продвинутый курс · Java 25 LTS

Продвинутое программирование на Java

От «код работает» к «система понятна, проверена и выдерживает сбои». Архитектура, типы, HTTP, многопоточность, тесты и внутренняя работа JVM.

  • 18 уроков
  • Advanced Java Core
  • Задачи и code review
  • Проект NeroWatch
Начать обучение
Кому подойдет Выпускникам базового курса и стажерам, уверенно знающим Java Core
Как заниматься 90–150 минут на урок, восемь недель с практикой и code review
Что получится NeroWatch — конкурентный монитор HTTP-сервисов с тестами и отчетом
Перед стартом

Это продолжение, а не повторение базового курса

Здесь мы перестаем оценивать программу только по фразе «она запускается». Продвинутый разработчик думает о контрактах, границах модулей, ошибках, конкурентном доступе, тестируемости, наблюдаемости и стоимости решений.

Проектировать устойчивые API и модели данных
Осознанно выбирать коллекции и алгоритмы
Писать потокобезопасный конкурентный код
Измерять, тестировать и диагностировать JVM-приложение

Что уже нужно уметь

  • уверенно использовать условия, циклы, методы, классы и интерфейсы;
  • работать с List, Set, Map, файлами и исключениями;
  • понимать наследование, композицию, generics и базовый Stream API;
  • читать stack trace и самостоятельно исправлять простые ошибки;
  • собрать небольшое консольное приложение из нескольких классов.
Если половина списка пока вызывает затруднения Сначала завершите курс «Java для начинающих» и его итоговый менеджер задач. Advanced-курс намеренно не объясняет заново, что такое переменная или цикл.

Входная проверка на 60–90 минут

Задача диагностика

Напишите каталог заказов. У заказа есть ID, клиент, сумма и статус. Программа должна добавлять заказ, менять статус, искать по ID, группировать заказы по статусу и сохранять их в файл.

Критерий готовности: вы можете объяснить структуру решения, проверить неверный ввод и написать минимум три тестовых сценария без готового ответа.

Что должен проверить наставник
  • поля модели закрыты и не меняются произвольно;
  • ID не сравниваются через случайные индексы списка;
  • для статуса используется enum, а не набор строк;
  • ошибка файла не проглатывается;
  • методы имеют одну ответственность;
  • стажер понимает каждую строку, даже если использовал ИИ.

Как проходить Advanced-курс

  1. Сформулируйте контракт. До кода запишите вход, выход, ошибки и инварианты.
  2. Напишите самый простой корректный вариант. Не оптимизируйте предположения.
  3. Покройте поведение тестами. Особенно границы и отрицательные сценарии.
  4. Измерьте спорное место. Профилировщик важнее догадок о производительности.
  5. Проведите ревью. Объясните компромиссы и только затем улучшайте код.
Техническая база курса Java 25 LTS, Maven 3, JUnit 6.1.0 и только стабильные возможности языка. Preview-функции упоминаются отдельно, но не требуются для заданий и итогового проекта.
Урок 1

От работающего кода к профессиональному проекту

≈ 90 минут

Рабочий код решает один известный пример. Профессиональный код сохраняет понятное поведение при неверных данных, изменениях требований и работе другого разработчика.

Отделять требования, контракт и реализацию
Определять границы пакетов и публичного API

Четыре уровня готовности

УровеньГлавный вопросПризнак
КомпилируетсяПонимает ли код Java?Нет ошибок компиляции.
РаботаетРешен ли основной сценарий?Получен ожидаемый результат.
НадежноЧто произойдет на границах?Ошибки предсказуемы и проверены.
ПоддерживаемоМожно ли безопасно изменить?Контракты, тесты и границы ясны.

Начинаем не с класса, а с контракта

Операция: зарегистрировать платеж

        Вход:
        - orderId — существующий положительный ID
        - amount — положительная сумма в валюте заказа

        Результат:
        - создан уникальный paymentId
        - заказ получает статус PAID

        Ошибки:
        - заказ не найден
        - валюта не совпадает
        - заказ уже оплачен

        Инвариант:
        - один заказ нельзя успешно оплатить дважды

Такой список не является бюрократией. Он заранее показывает места, где понадобятся проверки, типы ошибок, идемпотентность и тесты.

Пакет выражает ответственность

ru.nerosky.payments
        ├── api          // публичные команды и результаты
        ├── domain       // правила предметной области
        ├── application  // сценарии использования
        └── infrastructure
            ├── file     // файловая реализация
            └── http     // интеграция по HTTP
Направление зависимостей важнее названий папок Доменная логика не должна знать, что данные пришли из консоли, HTTP или файла. Инфраструктура зависит от контракта домена, а не наоборот.

Минимальный публичный API

package ru.nerosky.payments.api;

        public interface PaymentService {
            PaymentReceipt pay(PaymentCommand command);
        }
package ru.nerosky.payments.api;

        import java.math.BigDecimal;
        import java.util.Currency;

        public record PaymentCommand(
            long orderId,
            BigDecimal amount,
            Currency currency
        ) {
        }

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

Не превращайте каждый класс в public Если тип используется только внутри пакета, оставьте package-private доступ. Скрытый внутренний код можно менять без нарушения внешнего контракта.

Definition of Done для учебной задачи

Основной и ошибочные сценарии определены
Сборка воспроизводится одной командой
Тесты проходят на чистой машине
Нет секретов и локальных путей в коде
Публичные классы имеют ясный контракт
Автор способен защитить решение

Практика архитектура

  1. Возьмите итоговый проект базового курса и выпишите контракты всех команд.
  2. Разделите классы на API, домен, приложение и инфраструктуру.
  3. Найдите типы, которые не обязаны быть публичными.
  4. Составьте Definition of Done для новой команды «изменить срок задачи».
Пример контракта команды «изменить срок»
  • вход: существующий ID и дата не раньше сегодняшней;
  • результат: срок изменен и сохранен;
  • ошибки: задача не найдена, дата в прошлом, хранилище недоступно;
  • инвариант: завершенную задачу изменять нельзя;
  • тесты: обычная дата, сегодняшняя дата, прошлое, неизвестный ID, завершенная задача.
Урок 2

Maven, пакеты и модули

≈ 120 минут

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

Собрать проект командой Maven
Понимать зависимости, scopes и JPMS-модули

Стандартная структура

advanced-app/
        ├── pom.xml
        ├── src/
        │   ├── main/
        │   │   ├── java/ru/nerosky/app/
        │   │   └── resources/
        │   └── test/
        │       ├── java/ru/nerosky/app/
        │       └── resources/
        └── target/       ← результат сборки, не хранится в Git

Минимальный 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>ru.nerosky.training</groupId>
            <artifactId>advanced-app</artifactId>
            <version>1.0.0-SNAPSHOT</version>

            <properties>
                <maven.compiler.release>25</maven.compiler.release>
                <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
                <junit.version>6.1.0</junit.version>
            </properties>

            <dependencies>
                <dependency>
                    <groupId>org.junit.jupiter</groupId>
                    <artifactId>junit-jupiter</artifactId>
                    <version>${junit.version}</version>
                    <scope>test</scope>
                </dependency>
            </dependencies>

        <build>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.14.1</version>
                </plugin>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-surefire-plugin</artifactId>
                        <version>3.6.0</version>
                    </plugin>
                </plugins>
            </build>
        </project>

Свойство maven.compiler.release задает одновременно синтаксис, формат class-файлов и доступный публичный API выбранного релиза.

Главные команды

mvn clean test       # чистая компиляция и тесты
        mvn clean package    # создать JAR в target/
        mvn dependency:tree # показать дерево зависимостей
        mvn help:effective-pom # итоговая конфигурация после наследования

Scopes зависимостей

ScopeКомпиляцияЗапускПример
compileдадаОсновная библиотека приложения
runtimeнетдаДрайвер, нужный только при запуске
testтолько тестытолько тестыJUnit
providedдапредоставляет средаAPI контейнера
Не исправляйте конфликт версий случайным исключением Сначала откройте mvn dependency:tree, найдите путь транзитивной зависимости и только затем управляйте версией или исключением.

JPMS: явная граница модуля

module ru.nerosky.monitor {
            requires java.net.http;

            exports ru.nerosky.monitor.api;
        }

requires объявляет зависимость, exports открывает пакет другим модулям. Не каждый проект обязан немедленно переходить на JPMS, но продвинутый разработчик должен понимать разницу между classpath и module path.

Сначала надежная обычная сборка Модульность JPMS добавляйте осознанно. Не смешивайте ее внедрение с большим функциональным изменением: иначе источник ошибки будет трудно определить.

Практика инструменты

  1. Перенесите старый проект в стандартную Maven-структуру.
  2. Добавьте JUnit только в scope test и проверьте dependency:tree.
  3. Соберите JAR на чистом клоне проекта одной командой.
  4. Создайте module-info.java для приложения, использующего java.net.http.
Чек-лист воспроизводимой сборки
  • JDK и Maven проверяются командами java --version и mvn --version;
  • в Git нет target/, IDE-файлов и секретов;
  • mvn clean test проходит без заранее созданных локальных файлов;
  • версии прямых зависимостей зафиксированы;
  • ресурсы лежат в src/main/resources, тестовые — в src/test/resources.
Урок 3

Контракты объектов и неизменяемость

≈ 120 минут

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

Соблюдать контракт equals/hashCode
Проектировать неизменяемые value objects

Равенство значений и идентичность ссылок

String first = new String("java");
        String second = new String("java");

        System.out.println(first == second);      // false
        System.out.println(first.equals(second)); // true

== для объектов проверяет одну и ту же ссылку. equals отвечает на доменный вопрос «считаются ли значения одинаковыми?».

Контракт equals

  • рефлексивность: x.equals(x) всегда true;
  • симметричность: если x равен y, то y равен x;
  • транзитивность: x = y и y = z означает x = z;
  • стабильность: результат не меняется без изменения участвующих данных;
  • null: x.equals(null) возвращает false;
  • равные объекты обязаны иметь одинаковый hashCode.
Изменяемый ключ ломает поиск Если поле, участвующее в hashCode, поменялось после помещения объекта в HashMap, ключ может оказаться «не в той корзине» и перестать находиться.

Value object для денег

import java.math.BigDecimal;
        import java.math.RoundingMode;
        import java.util.Currency;
        import java.util.Objects;

        public record Money(BigDecimal amount, Currency currency) {
            public Money {
                Objects.requireNonNull(amount, "amount");
                Objects.requireNonNull(currency, "currency");

                int scale = currency.getDefaultFractionDigits();
                if (scale < 0) {
                    throw new IllegalArgumentException("Валюта без стандартной точности");
                }

                amount = amount.setScale(scale, RoundingMode.HALF_EVEN);
            }

            public Money add(Money other) {
                requireSameCurrency(other);
                return new Money(amount.add(other.amount), currency);
            }

            private void requireSameCurrency(Money other) {
                if (!currency.equals(other.currency)) {
                    throw new IllegalArgumentException("Валюты не совпадают");
                }
            }
        }

Record создает корректные equals, hashCode и toString по компонентам. Операция add не меняет объект, а возвращает новое значение.

double не подходит для точных денежных расчетов Двоичное представление дробей дает неожиданные остатки. Используйте BigDecimal с явно выбранными точностью и правилом округления.

Защитная копия коллекции

import java.util.List;
        import java.util.Objects;

        public final class Order {
            private final long id;
            private final List<OrderLine> lines;

            public Order(long id, List<OrderLine> lines) {
                if (id <= 0) {
                    throw new IllegalArgumentException("id");
                }
                Objects.requireNonNull(lines, "lines");
                if (lines.isEmpty()) {
                    throw new IllegalArgumentException("Заказ не может быть пустым");
                }

                this.id = id;
                this.lines = List.copyOf(lines);
            }

            public List<OrderLine> lines() {
                return lines;
            }
        }

Если сохранить входной ArrayList напрямую, вызывающий код сможет изменить заказ после конструктора. List.copyOf разрывает этот канал изменения. Элементы списка при этом тоже должны быть неизменяемыми или копироваться отдельно.

BigDecimal.equals и compareTo

var a = new BigDecimal("10.0");
        var b = new BigDecimal("10.00");

        System.out.println(a.equals(b));        // false: разный scale
        System.out.println(a.compareTo(b) == 0); // true: численно равны

Практика модель

  1. Создайте неизменяемый EmailAddress с нормализацией и валидацией.
  2. Создайте TimeRange(start, end), запрещающий конец раньше начала.
  3. Докажите тестом, что изменение исходного списка не меняет объект Order.
  4. Объясните, можно ли использовать Money как ключ HashMap.
Решение TimeRange
import java.time.Instant;
        import java.util.Objects;

        public record TimeRange(Instant start, Instant end) {
            public TimeRange {
                Objects.requireNonNull(start, "start");
                Objects.requireNonNull(end, "end");

                if (end.isBefore(start)) {
                    throw new IllegalArgumentException("Конец раньше начала");
                }
            }

            public boolean contains(Instant moment) {
                Objects.requireNonNull(moment, "moment");
                return !moment.isBefore(start) && !moment.isAfter(end);
            }
        }
Урок 4

Продвинутые generics: гибкость без потери типов

≈ 135 минут

Generics — не украшение коллекций. Это способ выразить связь между входами и выходами API так, чтобы ошибку обнаружил компилятор, а не пользователь в production.

Использовать bounded types и wildcards
Понимать PECS, инвариантность и type erasure

Два параметра типа

import java.util.Optional;

        public interface Repository<ID, T> {
            Optional<T> findById(ID id);
            void save(T entity);
            boolean deleteById(ID id);
        }
Repository<Long, User> users = ...;
        Repository<String, Session> sessions = ...;

Один контракт сохраняет связь между типом идентификатора и сущностью. Возврат Object заставил бы клиента выполнять небезопасные приведения.

Ограниченный параметр

static <T extends Comparable<? super T>> T max(
                List<? extends T> values) {
            if (values.isEmpty()) {
                throw new IllegalArgumentException("Пустой список");
            }

            T max = values.getFirst();
            for (T value : values) {
                if (value.compareTo(max) > 0) {
                    max = value;
                }
            }
            return max;
        }

Сигнатура говорит: T можно сравнивать с T или его родительским типом, а входной список может содержать T или его подтип.

Почему List<Integer> не является List<Number>

List<Integer> integers = new ArrayList<>();
        // List<Number> numbers = integers;
        // Иначе сюда можно было бы добавить Double:
        // numbers.add(3.14);

Generics в Java инвариантны. Связь задается wildcard-типами.

PECS: Producer Extends, Consumer Super

static <T> void copy(
                List<? extends T> source,
                List<? super T> target) {
            for (T value : source) {
                target.add(value);
            }
        }

        List<Integer> source = List.of(1, 2, 3);
        List<Number> target = new ArrayList<>();
        copy(source, target);

? extends T

Источник производит T. Безопасно читать как T, но нельзя добавлять новое значение.

? super T

Приемник потребляет T. Безопасно добавлять T, но чтение дает только Object.

Type erasure

Большая часть информации о параметрах типа стирается при компиляции. Поэтому ограничения существуют не случайно:

// Нельзя:
        // new T()
        // new T[10]
        // value instanceof List<String>
        // static T shared;

        // Можно:
        if (value instanceof List<?> list) {
            System.out.println(list.size());
        }

Фабрика вместо new T()

import java.util.function.Supplier;

        public final class BoxFactory<T> {
            private final Supplier<T> creator;

            public BoxFactory(Supplier<T> creator) {
                this.creator = creator;
            }

            public T create() {
                return creator.get();
            }
        }

        BoxFactory<ArrayList<String>> factory =
            new BoxFactory<>(ArrayList::new);
Не используйте wildcard в возвращаемом типе без необходимости List<? extends User> заставляет каждого клиента разбираться с неизвестным типом. Обычно wildcard полезнее на входных параметрах API.

Практика типизация

  1. Реализуйте filter(List<? extends T>, Predicate<? super T>).
  2. Напишите метод, добавляющий числа из List<Integer> в List<Object>.
  3. Объясните каждую часть сигнатуры метода max выше.
  4. Найдите raw types в старом проекте и удалите небезопасные cast.
Решение generic filter
static <T> List<T> filter(
                List<? extends T> source,
                Predicate<? super T> predicate) {
            List<T> result = new ArrayList<>();
            for (T value : source) {
                if (predicate.test(value)) {
                    result.add(value);
                }
            }
            return List.copyOf(result);
        }
Урок 5

Коллекции и алгоритмическая сложность

≈ 135 минут

«Работает на десяти элементах» ничего не говорит о миллионе. Выбор коллекции — это выбор гарантий: порядка, уникальности, поиска и стоимости операций.

Оценивать операции через Big O
Выбирать реализацию по требуемым гарантиям

Big O — скорость роста, а не секундомер

ОбозначениеСмыслПример
O(1)Стоимость почти не зависит от размераДоступ ArrayList.get(index)
O(log n)Размер можно многократно делитьПоиск в сбалансированном дереве
O(n)Нужно пройти элементыПоиск в несортированном списке
O(n log n)Типичная эффективная сортировкаList.sort
O(n²)Каждый элемент сравнивается со многимиДва вложенных прохода
Big O не заменяет измерение Оно помогает отсеять плохую форму алгоритма. Константы, распределение данных, память, кэш процессора и реализация JVM проверяются профилированием.

Практическая карта коллекций

НужноЧастый выборПочему
Порядок и доступ по индексуArrayListКомпактный массив, быстрый random access
Уникальность без сортировкиHashSetСредний поиск O(1)
Ключ → значениеHashMapСредний поиск O(1)
Ключи отсортированыTreeMapОперации O(log n), навигация по диапазонам
Порядок добавленияLinkedHashMapХеш-таблица плюс связанный порядок
Ключ — enumEnumMapКомпактная и быстрая специализированная структура
Очередь по приоритетуPriorityQueueБыстро извлекает минимальный/максимальный элемент

Почему LinkedList редко ускоряет приложение

Теоретически вставка по известному узлу O(1), но сначала узел обычно нужно найти за O(n). Узлы занимают больше памяти и хуже используют процессорный кэш. Для очереди чаще выбирают ArrayDeque, для списка — ArrayList.

Атомарные операции над Map

Map<String, Long> frequency = new HashMap<>();

        for (String word : words) {
            frequency.merge(word, 1L, Long::sum);
        }
Map<String, List<Order>> byCustomer = new HashMap<>();

        for (Order order : orders) {
            byCustomer
                .computeIfAbsent(order.customerId(), ignored -> new ArrayList<>())
                .add(order);
        }

Comparator должен быть предсказуемым

Comparator<User> byNameThenId =
            Comparator.comparing(User::name)
                .thenComparingLong(User::id);

        List<User> sorted = users.stream()
            .sorted(byNameThenId)
            .toList();
TreeSet считает элементы одинаковыми по comparator Если comparator вернул 0 для двух разных объектов, второй не попадет в множество. Добавляйте стабильный tie-breaker, например ID, когда объекты должны оставаться различными.

Простой ограниченный LRU-кэш

import java.util.LinkedHashMap;
        import java.util.Map;

        public final class LruCache<K, V> extends LinkedHashMap<K, V> {
            private final int capacity;

            public LruCache(int capacity) {
                super(capacity, 0.75f, true);
                if (capacity <= 0) {
                    throw new IllegalArgumentException("capacity");
                }
                this.capacity = capacity;
            }

            @Override
            protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
                return size() > capacity;
            }
        }

Третий аргумент true включает порядок по обращениям. Это учебный однопоточный кэш: для production нужны политика истечения, метрики и потокобезопасность.

Практика выбор структуры

  1. Подберите коллекцию для истории последних 100 команд.
  2. Подберите структуру для расписания задач по ближайшему времени запуска.
  3. Оптимизируйте поиск пересечения двух списков ID без вложенного цикла.
  4. Напишите тест вытеснения самого давно не использованного элемента LRU-кэша.
Решение поиска пересечения
static <T> Set<T> intersection(
                Collection<T> left,
                Collection<T> right) {
            Set<T> index = new HashSet<>(left);
            index.retainAll(right);
            return Set.copyOf(index);
        }

Построение индекса и проход дают ожидаемую сложность O(n + m) вместо O(n × m), если хеш-функции распределяют значения нормально.

Урок 6

Streams, Collectors и Optional без злоупотреблений

≈ 150 минут

Продвинутый Stream API — это не самая длинная цепочка методов. Это прозрачный pipeline без скрытых изменений, с правильно выбранной терминальной операцией.

Строить группировки и downstream collectors
Понимать ленивость, Optional и ограничения parallel stream

Pipeline выполняется только по запросу результата

Stream<String> pipeline = names.stream()
            .filter(name -> {
                System.out.println("Проверяем " + name);
                return name.length() > 4;
            });

        // Пока ничего не напечатано.
        long count = pipeline.count(); // здесь начинается обход

Stream одноразовый. После terminal operation повторно использовать его нельзя. Храните коллекцию или фабрику stream, а не stream в поле.

Группировка с последующей агрегацией

Map<OrderStatus, BigDecimal> totals = orders.stream()
            .collect(Collectors.groupingBy(
                Order::status,
                () -> new EnumMap<>(OrderStatus.class),
                Collectors.reducing(
                    BigDecimal.ZERO,
                    Order::total,
                    BigDecimal::add
                )
            ));

Здесь явно выбрана EnumMap, а downstream collector суммирует значения внутри каждой группы, не создавая промежуточные списки заказов.

flatMap: один объект содержит несколько значений

Set<String> uniqueProductIds = orders.stream()
            .flatMap(order -> order.lines().stream())
            .map(OrderLine::productId)
            .collect(Collectors.toUnmodifiableSet());

Два результата за один проход

record Summary(long count, BigDecimal total) {}

        Summary summary = orders.stream().collect(Collectors.teeing(
            Collectors.counting(),
            Collectors.reducing(
                BigDecimal.ZERO,
                Order::total,
                BigDecimal::add
            ),
            Summary::new
        ));

Optional описывает возможное отсутствие

Optional<User> user = repository.findById(id);

        String displayName = user
            .filter(User::active)
            .map(User::displayName)
            .orElse("Гость");
УместноНеуместно
Возвращаемое значение поискаПоле entity или DTO
Результат max/minПараметр каждого метода
Цепочка преобразований отсутствующего значенияКоллекция: пустой список уже выражает отсутствие
Явная альтернатива null в APIOptional.get() без проверки

orElse и orElseGet

User first = optional.orElse(loadExpensiveFallback());
        // fallback вычисляется всегда

        User second = optional.orElseGet(this::loadExpensiveFallback);
        // fallback вычислится только при пустом Optional
Побочный эффект внутри map или filter — тревожный сигнал Не изменяйте внешнюю коллекцию, счетчик или базу данных из промежуточной операции. Pipeline должен быть понятен как преобразование данных.

Почему parallelStream() не кнопка ускорения

  • работа должна быть достаточно крупной, иначе накладные расходы выше пользы;
  • операции должны быть независимыми и без общего изменяемого состояния;
  • общий ForkJoinPool делится с другим кодом процесса;
  • блокирующий I/O обычно не является хорошей задачей для parallel stream;
  • ускорение нужно измерять на реалистичных данных.

Практика pipeline

  1. Сгруппируйте сотрудников по отделу и найдите максимальную зарплату в каждом.
  2. Получите частоту тегов из списка статей, где у статьи несколько тегов.
  3. Перепишите цепочку с Optional.get() без небезопасного вызова.
  4. Сравните читаемость решения циклом и Stream API; выберите лучшее и объясните.
Решение группировки максимальной зарплаты
Map<Department, Optional<Employee>> highestPaid =
            employees.stream().collect(Collectors.groupingBy(
                Employee::department,
                Collectors.maxBy(
                    Comparator.comparing(Employee::salary)
                )
            ));

Optional появляется потому, что collector формально умеет работать с пустой группой. Если нужен только размер зарплаты, используйте downstream mapping или собственную модель результата.

Урок 7

Records, sealed-типы и pattern matching

≈ 120 минут

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

Создавать алгебраические модели из sealed и records
Использовать исчерпывающий pattern switch

Команда как закрытый набор вариантов

public sealed interface Command
                permits AddTask, CompleteTask, DeleteTask {
        }

        record AddTask(String title) implements Command {
            AddTask {
                if (title == null || title.isBlank()) {
                    throw new IllegalArgumentException("title");
                }
            }
        }

        record CompleteTask(long id) implements Command {
        }

        record DeleteTask(long id) implements Command {
        }

Исчерпывающая обработка

public final class CommandHandler {
            public String handle(Command command) {
                return switch (command) {
                    case AddTask(var title) ->
                        "Добавить: " + title;
                    case CompleteTask(var id) ->
                        "Завершить #" + id;
                    case DeleteTask(var id) ->
                        "Удалить #" + id;
                };
            }
        }

default не нужен: компилятор знает все разрешенные реализации. Если позже добавить RenameTask, он покажет все switch, которые нужно обновить.

Невозможное состояние должно быть невозможно создать Вместо одного результата с nullable-полями data, error и retryAfter создайте отдельные варианты Success, Rejected и TemporaryFailure.

Модель результата операции

public sealed interface PaymentResult {
            record Success(String paymentId) implements PaymentResult {}

            record Rejected(String reason) implements PaymentResult {}

            record TemporaryFailure(
                String reason,
                Duration retryAfter
            ) implements PaymentResult {}
        }
String message = switch (result) {
            case PaymentResult.Success(var id) ->
                "Платеж " + id + " принят";
            case PaymentResult.Rejected(var reason) ->
                "Отклонено: " + reason;
            case PaymentResult.TemporaryFailure(var reason, var retryAfter) ->
                "Повтор через " + retryAfter + ": " + reason;
        };

Когда record не подходит

  • объект имеет сложный изменяемый жизненный цикл;
  • нужно скрыть представление и предоставить другой публичный API;
  • идентичность важнее равенства всех полей;
  • требуется наследование от другого класса;
  • компоненты record не являются настоящим контрактом модели.
Record не делает вложенные объекты неизменяемыми Если компонент — изменяемый список, record хранит ссылку на него. Используйте защитную копию в compact constructor.

Sealed и расширяемость

Sealed-иерархия хороша, когда владелец модели контролирует все варианты. Для plugin API, куда сторонние команды должны добавлять реализации, обычный открытый интерфейс подходит лучше.

Практика моделирование

  1. Смоделируйте результат валидации: Valid и Invalid со списком ошибок.
  2. Смоделируйте события заказа и обработайте их исчерпывающим switch.
  3. Найдите класс с несколькими nullable-полями и замените его закрытой иерархией.
Пример результата валидации
public sealed interface Validation<T> {
            record Valid<T>(T value) implements Validation<T> {}

            record Invalid<T>(List<String> errors)
                    implements Validation<T> {
                public Invalid {
                    errors = List.copyOf(errors);
                    if (errors.isEmpty()) {
                        throw new IllegalArgumentException("Нужна хотя бы одна ошибка");
                    }
                }
            }
        }
Урок 8

Контракты API, валидация и стратегия ошибок

≈ 135 минут

Исключение — часть контракта. Клиент должен понимать, можно ли исправить вход, повторить операцию, показать сообщение пользователю или остановить процесс.

Разделять ошибки клиента, домена и инфраструктуры
Проектировать идемпотентные и предсказуемые операции

Таксономия ошибок

КатегорияПримерОбычная реакция
Неверный входПустой emailОтклонить до выполнения операции
Доменный отказНедостаточно средствВернуть ожидаемый результат/ошибку
Не найденоНет заказа с IDЯвно сообщить клиенту
Временная инфраструктураTimeout внешнего APIОграниченный retry с backoff
Дефект программыНарушен внутренний инвариантЗаписать контекст и исправить код

Перевод инфраструктурной ошибки

public final class FileUserRepository implements UserRepository {
            @Override
            public User findRequired(long id) {
                try {
                    return readUser(id);
                } catch (NoSuchFileException error) {
                    throw new UserNotFoundException(id, error);
                } catch (IOException error) {
                    throw new RepositoryException(
                        "Не удалось прочитать пользователя " + id,
                        error
                    );
                }
            }
        }

Верхний слой не обязан знать детали NIO. При этом исходная причина сохраняется через cause, поэтому диагностика не теряется.

Checked или unchecked

Checked

Клиент действительно может и должен принять решение прямо в этой точке.

Unchecked

Нарушен контракт, инвариант или ошибка переводится на границе приложения.

Универсального правила нет. Важно не количество throws, а понятный сценарий восстановления. Не оборачивайте все подряд в один безымянный RuntimeException.

Идемпотентность

public record PaymentCommand(
            UUID idempotencyKey,
            long orderId,
            Money amount
        ) {
        }

Повтор одной команды с тем же ключом должен вернуть прежний результат, а не создать второй платеж. Это особенно важно, когда timeout не сообщает, успел ли удаленный сервис выполнить операцию.

Нельзя автоматически повторять любую операцию GET обычно безопасен, а повтор POST без idempotency key может создать дубликат. Retry — решение только после анализа семантики операции.

Проверка аргументов на границе

public record RegisterUser(String email, String displayName) {
            public RegisterUser {
                email = requireText(email, "email").toLowerCase(Locale.ROOT);
                displayName = requireText(displayName, "displayName");
            }

            private static String requireText(String value, String field) {
                if (value == null || value.isBlank()) {
                    throw new IllegalArgumentException(field + " обязателен");
                }
                return value.trim();
            }
        }

Прерывание потока нужно сохранять

try {
            return client.send(request, BodyHandlers.ofString());
        } catch (InterruptedException error) {
            Thread.currentThread().interrupt();
            throw new ServiceInterruptedException(error);
        }
InterruptedException — просьба остановиться Если метод не может пробросить checked exception, восстановите флаг через interrupt(). Иначе верхний уровень может потерять сигнал отмены.

Практика контракт

  1. Опишите ошибки операции «создать заказ» и реакцию клиента на каждую.
  2. Добавьте idempotency key в учебный платежный сервис.
  3. Найдите catch, который теряет cause или interrupt status, и исправьте.
  4. Решите, когда поиск должен вернуть Optional, а когда бросить NotFound.
Optional или NotFound: ориентир
  • findById обычно возвращает Optional: отсутствие — нормальный результат поиска;
  • updateRequiredUser может бросить NotFound: без сущности команда невыполнима;
  • не возвращайте null рядом с Optional — контракт станет двусмысленным;
  • на HTTP-границе оба варианта могут преобразоваться в 404, но внутри приложения семантика различается.
Урок 9

SOLID и управление зависимостями

≈ 135 минут

SOLID — не экзамен на пять букв и не требование создать интерфейс для каждого класса. Это набор вопросов, помогающих локализовать изменения и сделать код проверяемым.

Разделять причины изменения
Передавать зависимости явно через конструктор

Пять принципов в практической формулировке

ПринципВопрос на ревью
SRPСколько независимых причин изменить этот класс?
OCPМожно ли добавить вариант без правки стабильной логики?
LSPЛюбая реализация честно соблюдает контракт базового типа?
ISPКлиент зависит только от операций, которые ему нужны?
DIPБизнес-правило зависит от абстракции или от файла/HTTP/БД?

Класс, который трудно тестировать

public final class RegistrationService {
            public User register(String email) {
                var repository = new PostgresUserRepository();
                var mailer = new SmtpMailer();
                var user = new User(UUID.randomUUID(), email, Instant.now());

                repository.save(user);
                mailer.sendWelcome(user);
                return user;
            }
        }

Создание конкретных зависимостей, случайность и текущее время спрятаны внутри. Unit-тесту придется подключать PostgreSQL и SMTP, а результат меняется при каждом запуске.

Явные зависимости

public final class RegistrationService {
            private final UserRepository users;
            private final Mailer mailer;
            private final Clock clock;
            private final Supplier<UUID> idGenerator;

            public RegistrationService(
                    UserRepository users,
                    Mailer mailer,
                    Clock clock,
                    Supplier<UUID> idGenerator) {
                this.users = Objects.requireNonNull(users);
                this.mailer = Objects.requireNonNull(mailer);
                this.clock = Objects.requireNonNull(clock);
                this.idGenerator = Objects.requireNonNull(idGenerator);
            }

            public User register(String email) {
                User user = new User(
                    idGenerator.get(),
                    new EmailAddress(email),
                    Instant.now(clock)
                );

                users.save(user);
                mailer.sendWelcome(user);
                return user;
            }
        }

Production-композиция передает PostgreSQL, SMTP, системные часы и UUID. Тест передает in-memory repository, fake mailer, фиксированные часы и известный ID.

Dependency Injection — это прежде всего передача объекта Контейнер Spring может автоматизировать сборку, но принцип работает обычным конструктором и не зависит от фреймворка.

Interface Segregation

interface UserReader {
            Optional<User> findByEmail(EmailAddress email);
        }

        interface UserWriter {
            void save(User user);
        }

        final class RegistrationService {
            private final UserReader reader;
            private final UserWriter writer;
            // ...
        }

Разделение оправдано, если клиенты действительно имеют разные потребности. Делить интерфейс механически по одному методу без причины не нужно.

Liskov: реализация не должна удивлять

Если контракт Repository.save обещает сохранение, реализация ReadOnlyRepository, бросающая UnsupportedOperationException, нарушает ожидание. Лучше отдельный интерфейс чтения, чем «реализация», не способная выполнить контракт.

Слишком много абстракций тоже связывает код Интерфейс нужен на настоящей границе, для нескольких стратегий или тестовой подмены. Не создавайте UserServiceInterface только потому, что существует UserService.

Практика рефакторинг

  1. Сделайте сервис с текущим временем детерминированным через Clock.
  2. Разделите большой интерфейс хранилища для read-only клиента.
  3. Найдите класс с тремя причинами изменения и предложите границы.
  4. Напишите ручную composition root — место, где создаются реальные зависимости.
Пример composition root
public static void main(String[] args) {
            DataSource dataSource = createDataSource();
            UserRepository users = new JdbcUserRepository(dataSource);
            Mailer mailer = new SmtpMailer(loadMailSettings());

            RegistrationService service = new RegistrationService(
                users,
                mailer,
                Clock.systemUTC(),
                UUID::randomUUID
            );

            new ConsoleApplication(service).run();
        }

Внутри бизнес-сервиса больше нет знания о способе создания инфраструктуры.

Урок 10

Паттерны проектирования без «паттернов ради паттернов»

≈ 135 минут

Паттерн — имя повторяющегося решения и его компромиссов. Он полезен, когда проблема уже существует, а не когда хочется усложнить три строки кода.

Применять Strategy, Decorator, Factory и Adapter
Распознавать стоимость лишней абстракции

Strategy: заменяемый алгоритм

@FunctionalInterface
        public interface DiscountPolicy {
            Money discountFor(Order order);
        }
DiscountPolicy noDiscount =
            order -> new Money(BigDecimal.ZERO, order.currency());

        DiscountPolicy loyaltyDiscount = order ->
            order.total().multiply(new BigDecimal("0.05"));

Контекст вызывает единый контракт и не содержит растущий switch по типам скидки.

Decorator: добавляем поведение вокруг контракта

public final class CachingUserReader implements UserReader {
            private final UserReader delegate;
            private final Map<Long, User> cache = new HashMap<>();

            public CachingUserReader(UserReader delegate) {
                this.delegate = delegate;
            }

            @Override
            public Optional<User> findById(long id) {
                User cached = cache.get(id);
                if (cached != null) {
                    return Optional.of(cached);
                }

                Optional<User> loaded = delegate.findById(id);
                loaded.ifPresent(user -> cache.put(id, user));
                return loaded;
            }
        }

Тот же прием добавляет метрики, retry, tracing или авторизацию. Клиент продолжает зависеть от UserReader.

Учебный кэш не готов к production Здесь нет ограничения размера, TTL, инвалидации и потокобезопасности. Паттерн показывает структуру, но эксплуатационные требования остаются.

Factory: выбор и создание реализации

public final class ReportWriterFactory {
            public ReportWriter create(ReportFormat format) {
                return switch (format) {
                    case CSV -> new CsvReportWriter();
                    case JSON -> new JsonReportWriter();
                };
            }
        }

Factory полезна, когда создание имеет правила, зависимости или несколько вариантов. Для простого new User() отдельная фабрика обычно не нужна.

Adapter: перевод чужого интерфейса

public final class LegacySmsAdapter implements NotificationSender {
            private final LegacySmsClient client;

            public LegacySmsAdapter(LegacySmsClient client) {
                this.client = client;
            }

            @Override
            public void send(Notification notification) {
                client.sendText(
                    notification.recipient().phone(),
                    notification.message()
                );
            }
        }

Как выбрать паттерн

СимптомКандидат
Один алгоритм имеет несколько вариантовStrategy
Нужно добавить сквозное поведениеDecorator
Создание объекта разветвлено и сложноFactory
Чужой API не совпадает с нашим контрактомAdapter
Нужно уведомлять подписчиков о событииObserver, но с контролем ошибок и жизненного цикла
Сначала запах, потом паттерн Опишите конкретную изменчивость: «появится третий алгоритм», «нужны метрики вокруг всех вызовов». Если проблемы нет, прямой код обычно лучше.

Практика дизайн

  1. Вынесите расчет стоимости доставки в Strategy.
  2. Добавьте к репозиторию decorator, считающий число вызовов и время.
  3. Адаптируйте старый интерфейс оплаты к новому PaymentGateway.
  4. Найдите в проекте паттерн, который можно удалить и заменить прямым кодом.
Каркас decorator с метриками
public final class MeasuredUserReader implements UserReader {
            private final UserReader delegate;
            private final Metrics metrics;

            public MeasuredUserReader(UserReader delegate, Metrics metrics) {
                this.delegate = delegate;
                this.metrics = metrics;
            }

            @Override
            public Optional<User> findById(long id) {
                long started = System.nanoTime();
                try {
                    return delegate.findById(id);
                } finally {
                    metrics.record(
                        "users.find",
                        System.nanoTime() - started
                    );
                }
            }
        }
Урок 11

Аннотации, Reflection API и динамическое поведение

≈ 150 минут

Framework видит @Test, @Entity или @Inject потому, что читает метаданные и строит поведение. Создадим маленький механизм сами.

Объявлять runtime-аннотации
Безопасно читать типы и вызывать методы через reflection

Собственная аннотация

import java.lang.annotation.ElementType;
        import java.lang.annotation.Retention;
        import java.lang.annotation.RetentionPolicy;
        import java.lang.annotation.Target;

        @Retention(RetentionPolicy.RUNTIME)
        @Target(ElementType.METHOD)
        public @interface Command {
            String value();
            String description() default "";
        }
  • RUNTIME сохраняет аннотацию для Reflection API;
  • METHOD ограничивает допустимое место применения;
  • элементы аннотации имеют ограниченный набор типов и могут иметь default.

Аннотированные методы

public final class AdminCommands {
            @Command(value = "status", description = "Состояние системы")
            public void status() {
                System.out.println("Система работает");
            }

            @Command("cleanup")
            public void cleanup() {
                System.out.println("Очистка завершена");
            }
        }

Сканируем известный объект

import java.lang.reflect.Method;
        import java.util.HashMap;
        import java.util.Map;

        public final class CommandRegistry {
            private final Map<String, Runnable> commands = new HashMap<>();

            public void register(Object handler) {
                for (Method method : handler.getClass().getDeclaredMethods()) {
                    Command annotation = method.getAnnotation(Command.class);
                    if (annotation == null) {
                        continue;
                    }
                    validate(method);

                    Runnable action = () -> invoke(method, handler);
                    Runnable previous = commands.putIfAbsent(
                        annotation.value(),
                        action
                    );
                    if (previous != null) {
                        throw new IllegalStateException(
                            "Команда уже зарегистрирована: " + annotation.value()
                        );
                    }
                }
            }

            private static void validate(Method method) {
                if (method.getParameterCount() != 0
                        || method.getReturnType() != void.class) {
                    throw new IllegalArgumentException(
                        "Команда должна быть void-методом без параметров: " + method
                    );
                }
            }

            private static void invoke(Method method, Object target) {
                try {
                    method.invoke(target);
                } catch (ReflectiveOperationException error) {
                    throw new CommandExecutionException(method.getName(), error);
                }
            }
        }
Не сканируйте весь classpath самостоятельно без необходимости Загрузка классов имеет побочные эффекты, модульные границы ограничивают доступ, а startup становится сложнее. Часто лучше явный список компонентов или готовая библиотека.

Dynamic Proxy для интерфейса

import java.lang.reflect.Proxy;

        public final class TimingProxy {
            @SuppressWarnings("unchecked")
            public static <T> T wrap(
                    Class<T> contract,
                    T target,
                    Metrics metrics) {
                return (T) Proxy.newProxyInstance(
                    contract.getClassLoader(),
                    new Class<?>[]{contract},
                    (proxy, method, args) -> {
                        long started = System.nanoTime();
                        try {
                            return method.invoke(target, args);
                        } finally {
                            metrics.record(
                                method.getName(),
                                System.nanoTime() - started
                            );
                        }
                    }
                );
            }
        }

JDK Proxy работает только с интерфейсами. Вызов через reflection оборачивает исключение целевого метода в InvocationTargetException; production-код должен аккуратно извлекать и переводить исходную причину.

Reflection или annotation processing

Reflection во время запуска

Гибко, но ошибки обнаруживаются позже и доступ контролируется JPMS.

Annotation Processor при компиляции

Может генерировать код и выдавать ошибки раньше, но требует отдельного инструментария.

Не отключайте инкапсуляцию через setAccessible(true) по привычке Это создает хрупкую зависимость от внутреннего устройства класса и может быть запрещено модульной системой. Сначала используйте публичный контракт.

Практика метапрограммирование

  1. Добавьте аннотацию @Retry(attempts = 3) и прочитайте ее значение.
  2. Расширьте registry: выводите description всех команд.
  3. Проверьте дубликат команды и неверную сигнатуру тестами.
  4. Объясните, почему обычный decorator может быть лучше dynamic proxy.
Объявление @Retry
@Retention(RetentionPolicy.RUNTIME)
        @Target(ElementType.METHOD)
        public @interface Retry {
            int attempts() default 3;
        }
Retry retry = method.getAnnotation(Retry.class);
        int attempts = retry == null ? 1 : retry.attempts();

        if (attempts < 1) {
            throw new IllegalArgumentException("attempts должен быть положительным");
        }
Урок 12

NIO.2 и надежная работа с файлами

≈ 150 минут

Запись напрямую в основной файл может оставить половину данных после сбоя. Продвинутый код явно выбирает кодировку, закрывает ресурсы и публикует результат атомарно.

Работать с Path, Files и ленивыми потоками
Безопасно заменять файл через temporary + move

Path вместо склеивания строк

Path base = Path.of("data").toAbsolutePath().normalize();
        Path report = base.resolve("reports").resolve("daily.csv");

        if (!report.startsWith(base)) {
            throw new SecurityException("Выход за разрешенную папку");
        }

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

Ленивый обход обязательно закрывается

try (Stream<Path> paths = Files.walk(base)) {
            List<Path> javaFiles = paths
                .filter(Files::isRegularFile)
                .filter(path -> path.toString().endsWith(".java"))
                .toList();
        }
Files.lines, Files.list и Files.walk возвращают ресурс Оборачивайте stream в try-with-resources. Обычный stream коллекции закрывать не требуется.

Атомарная публикация файла

import java.io.IOException;
        import java.nio.charset.StandardCharsets;
        import java.nio.file.AtomicMoveNotSupportedException;
        import java.nio.file.Files;
        import java.nio.file.Path;
        import java.nio.file.StandardCopyOption;

        public final class AtomicTextFile {
            public static void write(Path target, String content)
                    throws IOException {
                Path absolute = target.toAbsolutePath();
                Path directory = absolute.getParent();
                Files.createDirectories(directory);

                Path temporary = Files.createTempFile(
                    directory,
                    absolute.getFileName().toString(),
                    ".tmp"
                );

                boolean published = false;
                try {
                    Files.writeString(
                        temporary,
                        content,
                        StandardCharsets.UTF_8
                    );
                    moveIntoPlace(temporary, absolute);
                    published = true;
                } finally {
                    if (!published) {
                        Files.deleteIfExists(temporary);
                    }
                }
            }

            private static void moveIntoPlace(Path source, Path target)
                    throws IOException {
                try {
                    Files.move(
                        source,
                        target,
                        StandardCopyOption.ATOMIC_MOVE,
                        StandardCopyOption.REPLACE_EXISTING
                    );
                } catch (AtomicMoveNotSupportedException error) {
                    Files.move(
                        source,
                        target,
                        StandardCopyOption.REPLACE_EXISTING
                    );
                }
            }
        }

Temporary-файл создается в том же каталоге: так выше шанс поддержки атомарного move в пределах одной файловой системы. Fallback честно признает, что гарантия зависит от платформы.

Потоковое чтение больших данных

long errors;
        try (Stream<String> lines = Files.lines(log, StandardCharsets.UTF_8)) {
            errors = lines
                .filter(line -> line.contains(" ERROR "))
                .count();
        }

Формат данных — отдельный контракт

  • зафиксируйте версию формата;
  • определите escaping разделителей и переводов строк;
  • решите, как читать старую версию;
  • не смешивайте доменную модель с деталями CSV/JSON;
  • проверяйте размер и структуру недоверенного файла.
Не используйте Java native serialization для новых внешних форматов Она тесно связывает данные с классами и имеет опасную историю десериализации. Для обмена выбирайте явный формат и безопасную библиотеку с ограниченной моделью.

Channels и ByteBuffer

Для высокопроизводительного или неблокирующего I/O изучайте FileChannel, SocketChannel и ByteBuffer. Но не заменяйте простой Files.writeString сложным буферным кодом без измеренной причины.

Практика надежность

  1. Реализуйте атомарное сохранение списка задач с UTF-8.
  2. Посчитайте уровни логов в файле, не загружая его целиком.
  3. Защитите выгрузку отчета от path traversal.
  4. Добавьте версию формата и миграцию одной старой строки.
Проверка безопасного имени файла
static Path safeResolve(Path root, String userName) {
            Path normalizedRoot = root.toAbsolutePath().normalize();
            Path candidate = normalizedRoot
                .resolve(userName)
                .normalize();

            if (!candidate.startsWith(normalizedRoot)) {
                throw new IllegalArgumentException("Недопустимый путь");
            }
            return candidate;
        }

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

Урок 13

HTTP и внешние сервисы

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

110–140 минут · интеграции
Настроить и повторно использовать HttpClient
Различать транспортную ошибку и HTTP-ответ с ошибкой
Выбирать безопасную стратегию timeout и retry

HTTP-разговор простыми словами

Клиент отправляет request: метод, адрес, заголовки и иногда тело. Сервер возвращает response: статус, заголовки и тело. Статус 404 — это успешно доставленный HTTP-ответ, а не исключение Java. Исключение возникает, например, когда не удалось установить соединение.

Диапазон Смысл Обычная реакция клиента
2xxЗапрос выполненОбработать тело
3xxПеренаправлениеСледовать только по заданной политике
4xxПроблема запроса или доступаИсправить запрос; обычно не повторять
5xxПроблема сервераИногда повторить с задержкой

Один клиент — много запросов

import java.net.URI;
        import java.net.http.HttpClient;
        import java.net.http.HttpRequest;
        import java.net.http.HttpResponse;
        import java.time.Duration;

        public final class HealthClient {
            private final HttpClient client;

            public HealthClient() {
                this.client = HttpClient.newBuilder()
                    .connectTimeout(Duration.ofSeconds(3))
                    .followRedirects(HttpClient.Redirect.NORMAL)
                    .build();
            }

            public int status(URI uri)
                    throws java.io.IOException, InterruptedException {
                HttpRequest request = HttpRequest.newBuilder(uri)
                    .timeout(Duration.ofSeconds(5))
                    .header("Accept", "application/json")
                    .GET()
                    .build();

                HttpResponse<Void> response = client.send(
                    request,
                    HttpResponse.BodyHandlers.discarding()
                );
                return response.statusCode();
            }
        }

HttpClient безопасен для совместного использования. Создавайте его на уровень приложения или компонента: так клиент сможет повторно использовать соединения. Отдельный клиент на каждый запрос теряет эту пользу и усложняет управление ресурсами.

Connect timeout

Сколько ждать установления соединения с узлом.

Request timeout

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

Timeout — часть контракта «Ждать сколько потребуется» почти всегда означает, что очередь запросов однажды заполнится. Значение выбирают по пользовательскому сценарию и измерениям, а не наугад.

Синхронно и асинхронно

var future = client.sendAsync(
            request,
            HttpResponse.BodyHandlers.ofString()
        );

        future
            .thenApply(HttpResponse::statusCode)
            .thenAccept(code -> System.out.println("Статус: " + code))
            .exceptionally(error -> {
                System.err.println("Сеть недоступна: " + error.getMessage());
                return null;
            });

send проще читать и отлично сочетается с virtual threads. sendAsync полезен, когда приложение уже строит неблокирующую цепочку CompletableFuture. Не выбирайте асинхронный API только ради слова «производительность»: сначала определите модель выполнения.

Retry: повторять можно не всё

GET обычно идемпотентен: повтор не меняет смысл операции. Повторный POST может создать второй заказ или второй платеж. Для изменяющих операций нужны идемпотентные ключи и договоренность с сервером.

static Duration retryDelay(int attempt) {
            long baseMillis = 200L * (1L << Math.min(attempt - 1, 5));
            long jitterMillis = java.util.concurrent.ThreadLocalRandom
                .current()
                .nextLong(100);
            return Duration.ofMillis(baseMillis + jitterMillis);
        }

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

Не проглатывайте прерывание Если ожидание бросило InterruptedException, восстановите флаг через Thread.currentThread().interrupt() и завершите операцию. Прерывание — сигнал отмены, а не случайная ошибка сети.

Защитный пояс интеграции

  • Rate limit ограничивает частоту обращений.
  • Bulkhead не дает одной интеграции занять все ресурсы.
  • Circuit breaker временно прекращает заведомо бесполезные запросы.
  • Валидация не доверяет внешнему JSON, даже если сервер «наш».
  • Наблюдаемость записывает длительность, статус и имя операции без секретов.

Практика интеграция

  1. Напишите клиент, который проверяет три URI и возвращает статус с длительностью.
  2. Разделите результат на успешный ответ, HTTP-ошибку и транспортную ошибку.
  3. Добавьте не более трех попыток только для 5xx и сетевых сбоев.
  4. Проверьте, что секретный токен не попадает в сообщение исключения и лог.
Мини-модель результата без исключений в вызывающем коде
public sealed interface HttpResult {
            record Ok(int statusCode) implements HttpResult {}
            record Rejected(int statusCode) implements HttpResult {}
            record Unavailable(String reason) implements HttpResult {}
        }
static HttpResult classify(int statusCode) {
            if (statusCode >= 200 && statusCode < 300) {
                return new HttpResult.Ok(statusCode);
            }
            return new HttpResult.Rejected(statusCode);
        }

Транспортное исключение перехватывается на границе HTTP-адаптера и превращается в Unavailable. Доменный код больше не обязан знать классы сетевой библиотеки.

Урок 14

Java Memory Model: почему потоки видят мир по-разному

Потоки работают с общей памятью, но чтения и записи могут наблюдаться в разном порядке. Java Memory Model задает правила видимости. Это не магия процессора, а контракт, на котором строится вся корректная конкурентность.

120–150 минут · многопоточность
Различать atomicity, visibility и ordering
Объяснить связь happens-before
Выбирать immutability, lock, atomic или concurrent collection

Три разные проблемы

ПроблемаВопросПример решения
Atomicity Может ли операция вклиниться посередине другой? synchronized, AtomicLong
Visibility Увидит ли поток новое значение? volatile, lock, безопасная публикация
Ordering В каком порядке эффекты видны другим потокам? happens-before через средства синхронизации

counter++ — не одна операция

final class BrokenCounter {
            private long value;

            void increment() {
                value++; // прочитать → прибавить → записать
            }

            long value() {
                return value;
            }
        }

Два потока могут прочитать одно значение и оба записать одинаковый результат. Одно увеличение потеряется. volatile long value улучшит видимость, но не сделает составную операцию атомарной.

import java.util.concurrent.atomic.AtomicLong;

        final class AtomicCounter {
            private final AtomicLong value = new AtomicLong();

            void increment() {
                value.incrementAndGet();
            }

            long value() {
                return value.get();
            }
        }

Happens-before — гарантия видимости

Если действие A happens-before действия B, то эффекты A гарантированно видны B. Это отношение порядка, а не измерение времени по часам.

  • выход из synchronized happens-before следующим входом в тот же монитор;
  • запись в volatile поле happens-before последующему чтению этого поля;
  • вызов Thread.start() публикует ранее подготовленные данные новому потоку;
  • завершение потока happens-before успешному возврату из join();
  • отношение транзитивно: A → B и B → C означают A → C.
Самая дешевая синхронизация — отсутствие изменения Неизменяемый объект с корректно заполненными final-полями намного проще безопасно передавать между потоками, чем защищать изменяемый граф объектов.

Когда подходит volatile

final class Worker {
            private volatile boolean running = true;

            void stop() {
                running = false;
            }

            void runLoop() {
                while (running) {
                    doOneSmallStep();
                }
            }

            private void doOneSmallStep() {
                // Короткая единица работы.
            }
        }

Здесь одно поле хранит независимое состояние-флаг. Но условие if (balance >= amount) balance -= amount уже состоит из нескольких связанных действий: ему нужен общий lock или другая атомарная модель.

Инвариант определяет границу lock

final class Inventory {
            private int available;
            private int reserved;

            Inventory(int available) {
                this.available = available;
            }

            synchronized boolean reserveOne() {
                if (available == 0) {
                    return false;
                }
                available--;
                reserved++;
                return true;
            }

            synchronized int total() {
                return available + reserved;
            }
        }

Инвариант «общее количество не меняется» связывает два поля, поэтому они защищены одним монитором. Два разных lock сделали бы снимок состояния несогласованным.

Concurrent collections

var counters =
            new java.util.concurrent.ConcurrentHashMap<String, AtomicLong>();

        counters
            .computeIfAbsent("http.200", ignored -> new AtomicLong())
            .incrementAndGet();

ConcurrentHashMap безопасна для конкурентных операций, но не превращает произвольную цепочку get → вычислить → put в одну транзакцию. Используйте ее атомарные методы и держите функцию compute… короткой: не выполняйте внутри медленный I/O и не пытайтесь рекурсивно обновлять тот же ключ.

Deadlock: два замка, два ожидания

Если поток A держит lock X и ждет Y, а поток B держит Y и ждет X, никто не продолжит. Базовая защита — единый порядок захвата: например, всегда сначала lock с меньшим id. Еще лучше — уменьшить число одновременно удерживаемых замков.

«Работает тысячу запусков» не доказывает потокобезопасность Race condition зависит от редкого порядка исполнения. Корректность доказывают через состояние, инварианты и happens-before; стресс-тест лишь помогает найти ошибку.

Практика потоки

  1. Объясните, какие три действия скрыты в count++.
  2. Реализуйте потокобезопасный лимит: списать можно, только если остаток не отрицателен.
  3. Сделайте счетчик событий по ключам через ConcurrentHashMap.
  4. Нарисуйте happens-before для «главный поток заполнил список → запустил worker».
Решение задачи с лимитом
final class Limit {
            private long remaining;

            Limit(long remaining) {
                if (remaining < 0) {
                    throw new IllegalArgumentException("Лимит меньше нуля");
                }
                this.remaining = remaining;
            }

            synchronized boolean tryAcquire(long amount) {
                if (amount <= 0) {
                    throw new IllegalArgumentException("Сумма должна быть положительной");
                }
                if (remaining < amount) {
                    return false;
                }
                remaining -= amount;
                return true;
            }

            synchronized long remaining() {
                return remaining;
            }
        }

Проверка и изменение выполняются под одним lock — другой поток не может вклиниться между ними. Именно весь инвариант, а не отдельное поле, задает критическую секцию.

Урок 15

Конкурентность и virtual threads

Конкурентность помогает ждать много независимых операций одновременно. Она не делает вычисления бесплатными. Сначала определяем вид нагрузки, затем выбираем executor, границы отмены и лимиты.

130–170 минут · concurrency
Отличать I/O-bound от CPU-bound работы
Управлять задачами через ExecutorService и Future
Правильно применять virtual threads и backpressure

CPU-bound

Сжатие, хеширование, вычисления. Параллелизм ограничен ядрами процессора; слишком много потоков добавят переключения.

I/O-bound

HTTP, база данных, файлы. Задача большую часть времени ждет; virtual threads позволяют удобно держать много таких ожиданий.

Structured lifetime без preview API

import java.util.ArrayList;
        import java.util.concurrent.Executors;
        import java.util.concurrent.Future;

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var futures = new ArrayList<Future<Integer>>();

            for (var uri : uris) {
                futures.add(executor.submit(() -> client.status(uri)));
            }

            for (var future : futures) {
                System.out.println(future.get());
            }
        }

На каждую задачу создается легкий virtual thread. Блокирующий код остается прямым и читаемым. Закрытие executor прекращает прием новых задач, инициирует штатное завершение и ждет выполнения уже отправленных задач.

Virtual thread — не новый тип задачи Это реализация Thread, которую JVM может временно снимать с carrier thread, пока она ждет I/O. Семантика исключений, ThreadLocal, interruption и синхронизации остается важной.

Virtual threads не нужно объединять в маленький pool

Их создают по одной на задачу. Если внешний ресурс выдерживает только 20 одновременных запросов, ограничивайте ресурс через Semaphore, пул соединений или rate limiter — не количество virtual threads.

import java.util.concurrent.Semaphore;

        final class LimitedProbe {
            private final Semaphore permits = new Semaphore(20);

            ProbeResult check(Endpoint endpoint) throws InterruptedException {
                permits.acquire();
                try {
                    return performRequest(endpoint);
                } finally {
                    permits.release();
                }
            }

            private ProbeResult performRequest(Endpoint endpoint) {
                // HTTP-вызов.
                throw new UnsupportedOperationException("пример");
            }
        }

Future: результат, timeout, отмена

try {
            var result = future.get(6, java.util.concurrent.TimeUnit.SECONDS);
            consume(result);
        } catch (java.util.concurrent.TimeoutException e) {
            future.cancel(true); // просьба прервать задачу
            reportTimeout();
        } catch (InterruptedException e) {
            future.cancel(true);
            Thread.currentThread().interrupt();
            return;
        } catch (java.util.concurrent.ExecutionException e) {
            throw new IllegalStateException("Задача завершилась с ошибкой", e.getCause());
        }

cancel(true) не уничтожает поток: это кооперативный сигнал. Задача обязана не игнорировать interruption и регулярно доходить до прерываемых операций или проверять флаг.

CompletableFuture для графа зависимостей

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var profile = java.util.concurrent.CompletableFuture.supplyAsync(
                () -> loadProfile(userId),
                executor
            );
            var orders = java.util.concurrent.CompletableFuture.supplyAsync(
                () -> loadOrders(userId),
                executor
            );

            var page = profile.thenCombine(orders, UserPage::new).join();
        }

Передавайте executor явно, особенно для блокирующей работы. Иначе async-операция часто попадет в общий ForkJoinPool, который вы не контролируете. Длинные цепочки обрабатывайте с единой политикой ошибок и timeout.

Что может пойти не так

  • неограниченная очередь быстрее принимает работу, чем система успевает выполнять;
  • один потерянный timeout удерживает задачу бесконечно;
  • общий изменяемый объект создает race между тысячами легких потоков;
  • тяжелые ThreadLocal-объекты умножаются на число virtual threads;
  • долгий I/O внутри широкой критической секции блокирует остальных владельцев lock.
А что с pinning? Начиная с JDK 24 JVM устранила почти все случаи, когда блокировка внутри synchronized закрепляла virtual thread за carrier thread. Но длинные критические секции по-прежнему создают обычную конкуренцию за lock, а редкие native/foreign-вызовы могут закреплять поток. Измеряйте реальную нагрузку.
Не ускоряйте CPU-задачу тысячей virtual threads Для вычислений используйте ограниченный executor примерно по числу доступных ядер и подтверждайте размер бенчмарком. Virtual threads созданы прежде всего для высокой пропускной способности блокирующего I/O, а не для ускорения арифметики.

Structured Concurrency в Java 25 остается preview-возможностью, поэтому курс намеренно не делает ее основой production-кода. Сначала уверенно освойте стабильные ExecutorService, Future и отмену.

Практика конкурентность

  1. Проверьте 50 адресов через virtual-thread-per-task executor.
  2. Ограничьте одновременные HTTP-запросы десятью разрешениями.
  3. Добавьте общий deadline и корректно отмените незавершенные задачи.
  4. Сравните время последовательного и конкурентного запуска на искусственном I/O.
Мини-лаборатория: почему ожидание хорошо масштабируется
import java.time.Duration;
        import java.time.Instant;
        import java.util.concurrent.Executors;
        import java.util.concurrent.Future;
        import java.util.stream.IntStream;

        public class VirtualThreadLab {
            public static void main(String[] args) throws Exception {
                Instant started = Instant.now();

                try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
                    var tasks = IntStream.range(0, 1_000)
                        .mapToObj(i -> executor.submit(() -> {
                            Thread.sleep(Duration.ofMillis(100));
                            return i;
                        }))
                        .toList();

                    for (Future<Integer> task : tasks) {
                        task.get();
                    }
                }

                System.out.println(Duration.between(started, Instant.now()));
            }
        }

Это демонстрация ожидания, а не полноценный benchmark. Замените sleep вычислением и увидите, что тысяча задач не создает тысячу процессорных ядер.

Урок 16

Тестирование с JUnit 6

Тест — это исполняемый пример контракта. Хороший тест объясняет поведение, быстро находит регрессию и не ломается от безопасного рефакторинга.

120–150 минут · качество
Проектировать тест из правила, а не из строк реализации
Писать параметризованные и файловые тесты JUnit 6
Разделять unit, integration и end-to-end проверки

Аранжировка из трех частей

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

        import java.math.BigDecimal;
        import org.junit.jupiter.api.Test;

        class PriceCalculatorTest {
            @Test
            void appliesTenPercentDiscountForPremiumCustomer() {
                // Arrange
                var calculator = new PriceCalculator();
                var price = new BigDecimal("100.00");

                // Act
                var result = calculator.discounted(price, CustomerType.PREMIUM);

                // Assert
                assertEquals(new BigDecimal("90.00"), result);
            }
        }

Название отвечает на вопрос: какое правило проверяется и при каком условии? Комментарии Arrange–Act–Assert полезны в начале; в коротком очевидном тесте их можно убрать.

Один тест — одна причина падения Это не значит «один assert». Можно проверить несколько свойств одного результата. Но не смешивайте в одном методе скидку, запись файла и сетевой retry.

Проверяйте границы таблицей примеров

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

        import org.junit.jupiter.params.ParameterizedTest;
        import org.junit.jupiter.params.provider.CsvSource;

        class PasswordPolicyTest {
            private final PasswordPolicy policy = new PasswordPolicy(8);

            @ParameterizedTest(name = "[{index}] «{0}» → {1}")
            @CsvSource({
                "'', false",
                "1234567, false",
                "12345678, true",
                "strong-pass, true"
            })
            void validatesMinimumLength(String password, boolean expected) {
                assertEquals(expected, policy.isValid(password));
            }
        }

Для числовой границы нужны примеры граница − 1, граница и граница + 1. Для коллекции — пустая, один элемент, несколько элементов и повторения. Таблица делает пропуски заметными.

Исключение — тоже часть контракта

var error = org.junit.jupiter.api.Assertions.assertThrows(
            IllegalArgumentException.class,
            () -> new Endpoint("", java.net.URI.create("https://example.com"))
        );

        org.junit.jupiter.api.Assertions.assertEquals(
            "Имя не должно быть пустым",
            error.getMessage()
        );

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

Файловый тест без мусора на машине

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

        import java.nio.file.Files;
        import java.nio.file.Path;
        import org.junit.jupiter.api.Test;
        import org.junit.jupiter.api.io.TempDir;

        class EndpointLoaderTest {
            @TempDir
            Path directory;

            @Test
            void ignoresCommentsAndBlankLines() throws Exception {
                Path config = directory.resolve("endpoints.txt");
                Files.writeString(config, """
                    # production

                    Site|https://example.com
                    """);

                var endpoints = new EndpointLoader().load(config);

                assertEquals(1, endpoints.size());
                assertEquals("Site", endpoints.getFirst().name());
            }
        }

Fake вместо настоящей сети

final class FakeProbe implements Probe {
            private final ProbeResult answer;
            private int calls;

            FakeProbe(ProbeResult answer) {
                this.answer = answer;
            }

            @Override
            public ProbeResult check(Endpoint endpoint) {
                calls++;
                return answer;
            }

            int calls() {
                return calls;
            }
        }

Fake — маленькая рабочая реализация интерфейса для теста. Она часто понятнее сложной конфигурации mock-фреймворка. Mock полезен, когда важно проверить конкретное взаимодействие, но злоупотребление делает тест копией внутренней реализации.

Пирамида проверок

УровеньЧто проверяетСкорость и количество
Unit Правило в одном компоненте, зависимости заменены Миллисекунды; большинство тестов
Integration Работу с БД, HTTP, файлами, сериализацией Медленнее; целевые границы
End-to-end Критический сценарий всей системы Самые дорогие; немного

Детерминизм: время и случайность должны приходить снаружи

import java.time.Clock;
        import java.time.Duration;
        import java.time.Instant;

        final class TokenService {
            private final Clock clock;
            private final Duration lifetime;

            TokenService(Clock clock, Duration lifetime) {
                this.clock = clock;
                this.lifetime = lifetime;
            }

            Instant expiresAt() {
                return clock.instant().plus(lifetime);
            }
        }
@org.junit.jupiter.api.Test
        void calculatesExpirationFromInjectedClock() {
            var now = java.time.Instant.parse("2026-07-27T10:00:00Z");
            var clock = java.time.Clock.fixed(now, java.time.ZoneOffset.UTC);
            var service = new TokenService(clock, java.time.Duration.ofMinutes(15));

            org.junit.jupiter.api.Assertions.assertEquals(
                java.time.Instant.parse("2026-07-27T10:15:00Z"),
                service.expiresAt()
            );
        }
Не лечите flaky-тест длинным sleep Такой тест остается гонкой, только более медленной. Дождитесь наблюдаемого события через Future.get(timeout), latch, очередь или тестовый scheduler. Timeout нужен, чтобы ошибка завершалась, а не зависала.

Что тестировать в первую очередь

  • денежные, доступные и юридически важные правила;
  • границы ввода, пустые значения и дубликаты;
  • ветви восстановления после ошибок;
  • форматы, которыми обмениваются разные компоненты;
  • баг, который уже случился: сначала воспроизводящий тест, затем исправление.

Практика тест-дизайн

  1. Напишите таблицу тестов для трех диапазонов HTTP-статусов.
  2. Проверьте retry через fake, считающий вызовы, без реального ожидания.
  3. Протестируйте атомарный writer через @TempDir.
  4. Найдите тест, который знает приватную реализацию, и перепишите его через контракт.
Тест количества попыток
import static org.junit.jupiter.api.Assertions.assertEquals;
        import static org.junit.jupiter.api.Assertions.assertInstanceOf;

        import java.net.URI;
        import java.time.Clock;
        import java.time.Duration;
        import java.time.Instant;
        import org.junit.jupiter.api.Test;

        class RetryingProbeTest {
            @Test
            void stopsImmediatelyAfterSuccess() {
                var endpoint = new Endpoint(
                    "NeroSky",
                    URI.create("https://nerosky.ru")
                );
                var success = new ProbeResult.Success(
                    endpoint,
                    200,
                    Instant.EPOCH,
                    Duration.ZERO
                );
                var fake = new FakeProbe(success);
                var probe = new RetryingProbe(
                    fake,
                    3,
                    Duration.ofMillis(100),
                    duration -> {},
                    Clock.systemUTC()
                );

                var result = probe.check(endpoint);

                assertInstanceOf(ProbeResult.Success.class, result);
                assertEquals(1, fake.calls());
            }
        }

Функция ожидания передана как зависимость, поэтому unit-тест не спит. В итоговом проекте увидите полноценную реализацию этой идеи.

Урок 17

JVM, производительность и надежность

Оптимизация начинается не с хитрого цикла, а с вопроса «что именно тормозит?». JVM собирает мусор, компилирует горячий код и умеет записывать события приложения. Наша задача — наблюдать, измерять и менять только доказанное узкое место.

130–170 минут · production
Понимать heap, stack, metaspace, JIT и GC на рабочем уровне
Снимать профиль JFR и строить честный benchmark JMH
Добавить приложению наблюдаемость и защитные пределы

Путь кода внутри JVM

  1. Class loader находит и загружает bytecode классов.
  2. Verifier проверяет структурную корректность и безопасность bytecode.
  3. Interpreter начинает выполнять инструкции без долгого разогрева.
  4. JIT compiler замечает горячие участки и компилирует их в машинный код.
  5. Deoptimization позволяет JVM отказаться от предположения, если оно перестало быть верным.

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

Где живут данные

ОбластьЧто тамТипичная проблема
Heap Объекты и массивы, доступные из разных потоков Высокая аллокация, удержание объектов, длинные GC pause
Stack Фреймы вызовов и локальные данные каждого потока StackOverflowError при глубокой рекурсии
Metaspace Метаданные загруженных классов Утечка class loader или чрезмерная генерация классов
Native memory Потоки, буферы, библиотеки, структуры JVM Процесс растет, хотя heap выглядит нормальным
Сборщик мусора освобождает недостижимые объекты, а не «ненужные» Если забытая Map продолжает ссылаться на миллион записей, для GC они живые. Java-утечка памяти — обычно неожиданное удержание ссылок.

Рабочий цикл расследования

  1. зафиксировать симптом и пользовательский эффект;
  2. собрать метрики, логи и профиль в условиях, похожих на реальные;
  3. назвать гипотезу: CPU, блокировка, I/O, аллокации, GC или внешний сервис;
  4. изменить одну вещь и повторить измерение;
  5. проверить корректность, p95/p99 latency и использование ресурсов.

Java Flight Recorder

java -XX:StartFlightRecording=filename=nerosky.jfr,duration=60s \
             -jar target/nerowatch.jar
jcmd <PID> JFR.start \
              name=investigation \
              settings=profile \
              duration=2m \
              filename=investigation.jfr

Запись открывают в JDK Mission Control. Начните с CPU samples, allocation, lock contention, thread states, file/socket I/O и пауз GC. Не собирайте чувствительные данные без политики доступа и хранения.

Микробенчмарк — только через JMH

import java.util.HashSet;
        import java.util.List;
        import java.util.Set;
        import java.util.concurrent.TimeUnit;
        import org.openjdk.jmh.annotations.Benchmark;
        import org.openjdk.jmh.annotations.BenchmarkMode;
        import org.openjdk.jmh.annotations.Mode;
        import org.openjdk.jmh.annotations.OutputTimeUnit;
        import org.openjdk.jmh.annotations.Scope;
        import org.openjdk.jmh.annotations.State;

        @State(Scope.Benchmark)
        @BenchmarkMode(Mode.AverageTime)
        @OutputTimeUnit(TimeUnit.NANOSECONDS)
        public class LookupBenchmark {
            private final List<Integer> list =
                java.util.stream.IntStream.range(0, 10_000).boxed().toList();
            private final Set<Integer> set = new HashSet<>(list);

            @Benchmark
            public boolean listLookup() {
                return list.contains(9_999);
            }

            @Benchmark
            public boolean setLookup() {
                return set.contains(9_999);
            }
        }

JMH управляет warmup, повторениями, fork-процессами и защитой от части оптимизаций. Самодельный цикл с System.nanoTime() почти всегда отвечает не на тот вопрос. Но даже JMH не заменяет нагрузочный тест целого пользовательского сценария.

Наблюдаемость: три сигнала и один контекст

  • Logs рассказывают о конкретном событии; используйте уровни и структуру.
  • Metrics показывают тенденцию: rate, errors, duration, saturation.
  • Traces связывают путь запроса через несколько сервисов.
  • Correlation id помогает найти относящиеся к одному запросу события.
logger.info(
            "probe_completed endpoint={} status={} latency_ms={} attempt={}",
            endpoint.name(),
            statusCode,
            latency.toMillis(),
            attempt
        );
Секреты не являются диагностикой Не пишите в лог пароли, access token, cookie, полные платежные данные и содержимое персональных полей. Маскируйте идентификаторы по правилам компании и ограничивайте доступ.

Production-готовность — это пределы

На входе

  • максимальный размер запроса;
  • проверка формата и диапазонов;
  • лимит параллельной работы;
  • безопасный разбор внешних данных.

На выходе

  • timeout и deadline;
  • ограниченный retry;
  • backpressure и circuit breaker;
  • graceful shutdown.

Graceful shutdown

После сигнала остановки приложение прекращает принимать новую работу, дает текущим операциям ограниченное время завершиться, сохраняет нужное состояние и закрывает ресурсы. Shutdown hook должен быть коротким, идемпотентным и иметь собственный deadline.

Перед релизом

  • зависимости проверяются на известные уязвимости и регулярно обновляются;
  • секреты приходят из защищенной конфигурации, а не из репозитория;
  • права процесса минимальны;
  • есть health/readiness-сигналы и понятный runbook;
  • резервное восстановление проверено практикой, а не только наличием файла backup.

Практика production

  1. Снимите минутную JFR-запись программы с CPU- и I/O-нагрузкой.
  2. Найдите самое горячее место и сформулируйте гипотезу до изменения кода.
  3. Добавьте к HTTP-проверке метрики количества, ошибок и длительности.
  4. Составьте release checklist из десяти проверяемых пунктов.
Мини-чеклист расследования медленной операции
  1. Назовите endpoint и интервал, сравните median, p95 и p99.
  2. Разделите время на очередь, приложение, базу/HTTP и сериализацию.
  3. Проверьте saturation: CPU, heap, GC, pool, очередь, соединения.
  4. Сопоставьте trace с логами и JFR на том же временном отрезке.
  5. Сделайте минимальное изменение, нагрузите повторно и сохраните результаты.

«Похоже, медленная база» — не результат. «На p99 820 из 940 мс занимает ожидание соединения из пула при 100% utilization» — проверяемое наблюдение.

Урок 18 · Итоговый проект

NeroWatch: монитор доступности сервисов

Соберем знания курса в одно консольное приложение. NeroWatch читает список HTTP-адресов, проверяет их конкурентно, повторяет временные сбои и атомарно записывает CSV-отчет.

6–10 часов · портфолио
Production-мышление в маленьком проекте

Явные контракты, неизменяемые данные, границы зависимостей, timeout, корректное interruption, тестируемость и наблюдаемый результат.

Сценарий

Команда эксплуатации хранит файл адресов. Раз в запуск NeroWatch проверяет каждый сервис. Проверки идут одновременно в virtual threads, но не более заданного числа. Результат появляется либо целиком, либо не заменяет предыдущий отчет.

# Имя|URL
        NeroSky|https://nerosky.ru
        Example|https://example.com
        Java|https://dev.java
mvn clean test
        mvn clean package
        java -jar target/nerowatch-1.0.0.jar config/endpoints.txt reports

        Проверено: 3
        Доступно: 3
        Ошибок: 0
        Отчет: reports/nerowatch-20260727-101530.csv

Функциональные требования

  1. Игнорировать пустые строки и комментарии, начинающиеся с #.
  2. Разрешать только абсолютные URI со схемой http или https.
  3. Использовать один HttpClient, connect timeout и request timeout.
  4. Считать успешными ответы 2xx.
  5. Повторять сетевые ошибки и 5xx, но не 4xx.
  6. Запускать одну virtual thread на endpoint и ограничить одновременный I/O.
  7. Сохранить UTF-8 CSV через временный файл и atomic move, если он поддерживается.
  8. Не требовать реальной сети в unit-тестах.

Нефункциональные требования

  • Java 25 без preview-возможностей;
  • воспроизводимая Maven-сборка;
  • неизменяемые модели и constructor injection;
  • нет бесконечного ожидания, неограниченного retry и проглоченного interruption;
  • ошибка одной проверки не отменяет отчет по остальным сервисам;
  • код разделен на model, port, adapter и application.

Архитектура

endpoints.txt
             │
             ▼
        EndpointLoader ──► List<Endpoint>
                                 │
                                 ▼
                          MonitorService
                            │         │
                   virtual thread   Semaphore
                            │         │
                            ▼         ▼
                      RetryingProbe ──► JdkHttpProbe ──► HTTP
                            │
                            ▼
                     List<ProbeResult>
                            │
                            ▼
                     CsvReportWriter ──► reports/*.csv

Probe — порт приложения. JdkHttpProbe — сетевой адаптер. RetryingProbe — decorator. Благодаря этой границе orchestration тестируется через fake без DNS, TLS и случайной задержки сети.

Структура проекта

nerowatch/
        ├── pom.xml
        ├── config/
        │   └── endpoints.txt
        ├── src/
        │   ├── main/java/ru/nerosky/nerowatch/
        │   │   ├── Main.java
        │   │   ├── application/MonitorService.java
        │   │   ├── adapter/CsvReportWriter.java
        │   │   ├── adapter/EndpointLoader.java
        │   │   ├── adapter/JdkHttpProbe.java
        │   │   ├── adapter/LimitedProbe.java
        │   │   ├── adapter/RetryingProbe.java
        │   │   ├── model/Endpoint.java
        │   │   ├── model/ProbeResult.java
        │   │   └── port/Probe.java
        │   └── test/java/ru/nerosky/nerowatch/
        │       ├── EndpointLoaderTest.java
        │       └── RetryingProbeTest.java
        └── reports/                    ← создается при запуске
Сначала соберите свою версию Создавайте проект по требованиям этапами. Эталон ниже — не текст для механического копирования, а материал для сравнения решений после работающей попытки.

Этапы реализации

  1. Модель. Endpoint и два варианта ProbeResult с проверкой инвариантов.
  2. Ввод. Загрузчик конфигурации и тесты ошибочных строк.
  3. Порт. Интерфейс Probe и fake-реализация в тестах.
  4. HTTP. Один запрос с двумя timeout и измерением monotonic clock.
  5. Надежность. Decorator retry и decorator concurrency limit.
  6. Оркестрация. Virtual-thread-per-task executor.
  7. Вывод. Корректный CSV и атомарная публикация.
  8. Сборка. Main, JAR, негативные сценарии и review.
Эталонная реализация: 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>ru.nerosky.training</groupId>
            <artifactId>nerowatch</artifactId>
            <version>1.0.0</version>

            <properties>
                <maven.compiler.release>25</maven.compiler.release>
                <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
                <junit.version>6.1.0</junit.version>
            </properties>

            <dependencies>
                <dependency>
                    <groupId>org.junit.jupiter</groupId>
                    <artifactId>junit-jupiter</artifactId>
                    <version>${junit.version}</version>
                    <scope>test</scope>
                </dependency>
            </dependencies>

        <build>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.14.1</version>
                </plugin>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-surefire-plugin</artifactId>
                        <version>3.6.0</version>
                    </plugin>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-jar-plugin</artifactId>
                    <version>3.5.0</version>
                        <configuration>
                            <archive>
                                <manifest>
                                    <mainClass>ru.nerosky.nerowatch.Main</mainClass>
                                </manifest>
                            </archive>
                        </configuration>
                    </plugin>
                </plugins>
            </build>
        </project>
Эталонная реализация: модель и порт
package ru.nerosky.nerowatch.model;

        import java.net.URI;
        import java.util.Locale;
        import java.util.Objects;
        import java.util.Set;

        public record Endpoint(String name, URI uri) {
            private static final Set<String> ALLOWED_SCHEMES =
                Set.of("http", "https");

            public Endpoint {
                Objects.requireNonNull(name, "name");
                Objects.requireNonNull(uri, "uri");

                name = name.strip();
                if (name.isEmpty()) {
                    throw new IllegalArgumentException("Имя не должно быть пустым");
                }
                if (!uri.isAbsolute() || uri.getHost() == null) {
                    throw new IllegalArgumentException("Нужен абсолютный HTTP URI");
                }

                String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
                if (!ALLOWED_SCHEMES.contains(scheme)) {
                    throw new IllegalArgumentException("Разрешены только http и https");
                }
            }
        }
package ru.nerosky.nerowatch.model;

        import java.time.Duration;
        import java.time.Instant;
        import java.util.Objects;

        public sealed interface ProbeResult
                permits ProbeResult.Success, ProbeResult.Failure {

            Endpoint endpoint();
            Instant checkedAt();
            Duration latency();

            record Success(
                    Endpoint endpoint,
                    int statusCode,
                    Instant checkedAt,
                    Duration latency
            ) implements ProbeResult {
                public Success {
                    Objects.requireNonNull(endpoint, "endpoint");
                    Objects.requireNonNull(checkedAt, "checkedAt");
                    Objects.requireNonNull(latency, "latency");
                    if (statusCode < 200 || statusCode >= 300) {
                        throw new IllegalArgumentException("Успех должен иметь статус 2xx");
                    }
                    if (latency.isNegative()) {
                        throw new IllegalArgumentException("Отрицательная длительность");
                    }
                }
            }

            record Failure(
                    Endpoint endpoint,
                    String reason,
                    boolean retryable,
                    Instant checkedAt,
                    Duration latency
            ) implements ProbeResult {
                public Failure {
                    Objects.requireNonNull(endpoint, "endpoint");
                    Objects.requireNonNull(reason, "reason");
                    Objects.requireNonNull(checkedAt, "checkedAt");
                    Objects.requireNonNull(latency, "latency");
                    if (reason.isBlank()) {
                        throw new IllegalArgumentException("Нужна причина ошибки");
                    }
                    if (latency.isNegative()) {
                        throw new IllegalArgumentException("Отрицательная длительность");
                    }
                }
            }
        }
package ru.nerosky.nerowatch.port;

        import ru.nerosky.nerowatch.model.Endpoint;
        import ru.nerosky.nerowatch.model.ProbeResult;

        @FunctionalInterface
        public interface Probe {
            ProbeResult check(Endpoint endpoint);
        }
Эталонная реализация: HTTP-адаптер
package ru.nerosky.nerowatch.adapter;

        import java.io.IOException;
        import java.net.http.HttpClient;
        import java.net.http.HttpRequest;
        import java.net.http.HttpResponse;
        import java.time.Clock;
        import java.time.Duration;
        import java.util.Objects;
        import ru.nerosky.nerowatch.model.Endpoint;
        import ru.nerosky.nerowatch.model.ProbeResult;
        import ru.nerosky.nerowatch.port.Probe;

        public final class JdkHttpProbe implements Probe {
            private final HttpClient client;
            private final Duration requestTimeout;
            private final Clock clock;

            public JdkHttpProbe(
                    HttpClient client,
                    Duration requestTimeout,
                    Clock clock
            ) {
                this.client = Objects.requireNonNull(client, "client");
                this.requestTimeout = Objects.requireNonNull(
                    requestTimeout,
                    "requestTimeout"
                );
                this.clock = Objects.requireNonNull(clock, "clock");
                if (requestTimeout.isZero() || requestTimeout.isNegative()) {
                    throw new IllegalArgumentException("Timeout должен быть положительным");
                }
            }

            @Override
            public ProbeResult check(Endpoint endpoint) {
                long startedNanos = System.nanoTime();
                HttpRequest request = HttpRequest.newBuilder(endpoint.uri())
                    .timeout(requestTimeout)
                    .header("Accept", "*/*")
                    .GET()
                    .build();

                try {
                    var response = client.send(
                        request,
                        HttpResponse.BodyHandlers.discarding()
                    );
                    Duration latency = elapsedSince(startedNanos);
                    int status = response.statusCode();

                    if (status >= 200 && status < 300) {
                        return new ProbeResult.Success(
                            endpoint,
                            status,
                            clock.instant(),
                            latency
                        );
                    }
                    return new ProbeResult.Failure(
                        endpoint,
                        "HTTP " + status,
                        status >= 500,
                        clock.instant(),
                        latency
                    );
                } catch (InterruptedException error) {
                    Thread.currentThread().interrupt();
                    return new ProbeResult.Failure(
                        endpoint,
                        "Проверка прервана",
                        false,
                        clock.instant(),
                        elapsedSince(startedNanos)
                    );
                } catch (IOException error) {
                    return new ProbeResult.Failure(
                        endpoint,
                        safeMessage(error),
                        true,
                        clock.instant(),
                        elapsedSince(startedNanos)
                    );
                }
            }

            private static Duration elapsedSince(long startedNanos) {
                return Duration.ofNanos(System.nanoTime() - startedNanos);
            }

            private static String safeMessage(IOException error) {
                String type = error.getClass().getSimpleName();
                return type.isBlank() ? "Ошибка ввода-вывода" : type;
            }
        }
Два вида времени System.nanoTime() подходит для длительности: он монотонный. Clock.instant() дает календарную отметку, которую легко заменить в тесте.
Эталонная реализация: retry и ограничение параллелизма
package ru.nerosky.nerowatch.adapter;

        import java.time.Clock;
        import java.time.Duration;
        import java.util.Objects;
        import ru.nerosky.nerowatch.model.Endpoint;
        import ru.nerosky.nerowatch.model.ProbeResult;
        import ru.nerosky.nerowatch.port.Probe;

        public final class RetryingProbe implements Probe {
            @FunctionalInterface
            public interface Sleeper {
                void sleep(Duration duration) throws InterruptedException;
            }

            private final Probe delegate;
            private final int maxAttempts;
            private final Duration baseDelay;
            private final Sleeper sleeper;
            private final Clock clock;

            public RetryingProbe(
                    Probe delegate,
                    int maxAttempts,
                    Duration baseDelay,
                    Sleeper sleeper,
                    Clock clock
            ) {
                this.delegate = Objects.requireNonNull(delegate, "delegate");
                this.baseDelay = Objects.requireNonNull(baseDelay, "baseDelay");
                this.sleeper = Objects.requireNonNull(sleeper, "sleeper");
                this.clock = Objects.requireNonNull(clock, "clock");
                if (maxAttempts < 1) {
                    throw new IllegalArgumentException("Нужна хотя бы одна попытка");
                }
                if (baseDelay.isNegative()) {
                    throw new IllegalArgumentException("Задержка меньше нуля");
                }
                this.maxAttempts = maxAttempts;
            }

            @Override
            public ProbeResult check(Endpoint endpoint) {
                ProbeResult last = null;

                for (int attempt = 1; attempt <= maxAttempts; attempt++) {
                    last = delegate.check(endpoint);
                    if (!(last instanceof ProbeResult.Failure failure)
                            || !failure.retryable()
                            || Thread.currentThread().isInterrupted()) {
                        return last;
                    }
                    if (attempt == maxAttempts) {
                        break;
                    }

                    try {
                        sleeper.sleep(delayFor(attempt));
                    } catch (InterruptedException error) {
                        Thread.currentThread().interrupt();
                        return new ProbeResult.Failure(
                            endpoint,
                            "Ожидание повтора прервано",
                            false,
                            clock.instant(),
                            Duration.ZERO
                        );
                    }
                }
                return Objects.requireNonNull(last);
            }

            private Duration delayFor(int completedAttempt) {
                long multiplier = 1L << Math.min(completedAttempt - 1, 5);
                return baseDelay.multipliedBy(multiplier);
            }

            public static void sleepThread(Duration duration)
                    throws InterruptedException {
                Thread.sleep(duration);
            }
        }
package ru.nerosky.nerowatch.adapter;

        import java.time.Clock;
        import java.time.Duration;
        import java.util.Objects;
        import java.util.concurrent.Semaphore;
        import ru.nerosky.nerowatch.model.Endpoint;
        import ru.nerosky.nerowatch.model.ProbeResult;
        import ru.nerosky.nerowatch.port.Probe;

        public final class LimitedProbe implements Probe {
            private final Probe delegate;
            private final Semaphore permits;
            private final Clock clock;

            public LimitedProbe(Probe delegate, int maxConcurrent, Clock clock) {
                this.delegate = Objects.requireNonNull(delegate, "delegate");
                this.clock = Objects.requireNonNull(clock, "clock");
                if (maxConcurrent < 1) {
                    throw new IllegalArgumentException("Лимит должен быть положительным");
                }
                this.permits = new Semaphore(maxConcurrent);
            }

            @Override
            public ProbeResult check(Endpoint endpoint) {
                boolean acquired = false;
                try {
                    permits.acquire();
                    acquired = true;
                    return delegate.check(endpoint);
                } catch (InterruptedException error) {
                    Thread.currentThread().interrupt();
                    return new ProbeResult.Failure(
                        endpoint,
                        "Ожидание разрешения прервано",
                        false,
                        clock.instant(),
                        Duration.ZERO
                    );
                } finally {
                    if (acquired) {
                        permits.release();
                    }
                }
            }
        }

Порядок decorators важен. В LimitedProbe(RetryingProbe(...)) одно разрешение удерживается на весь цикл retry, включая паузу. В RetryingProbe(LimitedProbe(...)) разрешение освобождается между попытками. Для NeroWatch выберем второй вариант: редкий повтор не занимает слот.

Эталонная реализация: конкурентная оркестрация
package ru.nerosky.nerowatch.application;

        import java.time.Clock;
        import java.time.Duration;
        import java.util.ArrayList;
        import java.util.List;
        import java.util.Objects;
        import java.util.concurrent.ExecutionException;
        import java.util.concurrent.Executors;
        import java.util.concurrent.Future;
        import ru.nerosky.nerowatch.model.Endpoint;
        import ru.nerosky.nerowatch.model.ProbeResult;
        import ru.nerosky.nerowatch.port.Probe;

        public final class MonitorService {
            private record Pending(
                    Endpoint endpoint,
                    Future<ProbeResult> future
            ) {}

            private final Probe probe;
            private final Clock clock;

            public MonitorService(Probe probe, Clock clock) {
                this.probe = Objects.requireNonNull(probe, "probe");
                this.clock = Objects.requireNonNull(clock, "clock");
            }

            public List<ProbeResult> checkAll(List<Endpoint> endpoints)
                    throws InterruptedException {
                Objects.requireNonNull(endpoints, "endpoints");
                var results = new ArrayList<ProbeResult>(endpoints.size());

                try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
                    var pending = endpoints.stream()
                        .map(endpoint -> new Pending(
                            endpoint,
                            executor.submit(() -> probe.check(endpoint))
                        ))
                        .toList();

                    for (var item : pending) {
                        try {
                            results.add(item.future().get());
                        } catch (ExecutionException error) {
                            results.add(new ProbeResult.Failure(
                                item.endpoint(),
                                "Внутренняя ошибка задачи",
                                false,
                                clock.instant(),
                                Duration.ZERO
                            ));
                        } catch (InterruptedException error) {
                            pending.forEach(task -> task.future().cancel(true));
                            Thread.currentThread().interrupt();
                            throw error;
                        }
                    }
                }
                return List.copyOf(results);
            }
        }

Ошибка внутри одной задачи становится результатом именно этого endpoint. Но interruption всей операции отменяет оставшиеся задачи и передается выше: это другой уровень события.

Эталонная реализация: чтение конфигурации
package ru.nerosky.nerowatch.adapter;

        import java.io.IOException;
        import java.net.URI;
        import java.nio.charset.StandardCharsets;
        import java.nio.file.Files;
        import java.nio.file.Path;
        import java.util.ArrayList;
        import java.util.List;
        import ru.nerosky.nerowatch.model.Endpoint;

        public final class EndpointLoader {
            private static final long MAX_FILE_BYTES = 1_000_000;
            private static final int MAX_ENDPOINTS = 10_000;

            public List<Endpoint> load(Path source) throws IOException {
                if (Files.size(source) > MAX_FILE_BYTES) {
                    throw new IllegalArgumentException("Файл конфигурации слишком большой");
                }

                List<String> lines = Files.readAllLines(
                    source,
                    StandardCharsets.UTF_8
                );
                var endpoints = new ArrayList<Endpoint>();

                for (int index = 0; index < lines.size(); index++) {
                    String line = lines.get(index).strip();
                    if (line.isEmpty() || line.startsWith("#")) {
                        continue;
                    }
                    if (endpoints.size() == MAX_ENDPOINTS) {
                        throw new IllegalArgumentException("Слишком много endpoint");
                    }
                    endpoints.add(parse(line, index + 1));
                }
                if (endpoints.isEmpty()) {
                    throw new IllegalArgumentException("В конфигурации нет endpoint");
                }
                return List.copyOf(endpoints);
            }

            private Endpoint parse(String line, int lineNumber) {
                String[] parts = line.split("\\|", -1);
                if (parts.length != 2) {
                    throw invalidLine(lineNumber, "ожидается Имя|URL");
                }

                try {
                    return new Endpoint(parts[0], URI.create(parts[1].strip()));
                } catch (IllegalArgumentException error) {
                    throw invalidLine(lineNumber, error.getMessage());
                }
            }

            private IllegalArgumentException invalidLine(
                    int lineNumber,
                    String reason
            ) {
                return new IllegalArgumentException(
                    "Строка " + lineNumber + ": " + reason
                );
            }
        }

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

Эталонная реализация: атомарный CSV-отчет
package ru.nerosky.nerowatch.adapter;

        import java.io.IOException;
        import java.nio.charset.StandardCharsets;
        import java.nio.file.AtomicMoveNotSupportedException;
        import java.nio.file.Files;
        import java.nio.file.Path;
        import java.nio.file.StandardCopyOption;
        import java.util.List;
        import ru.nerosky.nerowatch.model.ProbeResult;

        public final class CsvReportWriter {
            public void write(Path target, List<ProbeResult> results)
                    throws IOException {
                Path absoluteTarget = target.toAbsolutePath().normalize();
                Path directory = absoluteTarget.getParent();
                if (directory == null) {
                    throw new IllegalArgumentException("Не удалось определить каталог");
                }
                Files.createDirectories(directory);

                Path temporary = Files.createTempFile(
                    directory,
                    "." + absoluteTarget.getFileName(),
                    ".tmp"
                );

                try {
                    Files.writeString(
                        temporary,
                        toCsv(results),
                        StandardCharsets.UTF_8
                    );
                    moveIntoPlace(temporary, absoluteTarget);
                } finally {
                    Files.deleteIfExists(temporary);
                }
            }

            String toCsv(List<ProbeResult> results) {
                StringBuilder csv = new StringBuilder(
                    "name,url,state,status,checked_at,latency_ms,reason\n"
                );
                for (var result : results) {
                    csv.append(toRow(result)).append('\n');
                }
                return csv.toString();
            }

            private String toRow(ProbeResult result) {
                return switch (result) {
                    case ProbeResult.Success success -> row(
                        success.endpoint().name(),
                        success.endpoint().uri().toString(),
                        "UP",
                        Integer.toString(success.statusCode()),
                        success.checkedAt().toString(),
                        Long.toString(success.latency().toMillis()),
                        ""
                    );
                    case ProbeResult.Failure failure -> row(
                        failure.endpoint().name(),
                        failure.endpoint().uri().toString(),
                        "DOWN",
                        "",
                        failure.checkedAt().toString(),
                        Long.toString(failure.latency().toMillis()),
                        failure.reason()
                    );
                };
            }

            private String row(String... values) {
                return java.util.Arrays.stream(values)
                    .map(this::quote)
                    .collect(java.util.stream.Collectors.joining(","));
            }

            private String quote(String value) {
                return "\"" + value.replace("\"", "\"\"") + "\"";
            }

            private void moveIntoPlace(Path source, Path target)
                    throws IOException {
                try {
                    Files.move(
                        source,
                        target,
                        StandardCopyOption.ATOMIC_MOVE,
                        StandardCopyOption.REPLACE_EXISTING
                    );
                } catch (AtomicMoveNotSupportedException error) {
                    Files.move(
                        source,
                        target,
                        StandardCopyOption.REPLACE_EXISTING
                    );
                }
            }
        }

Каждое значение заключено в кавычки, а внутренняя кавычка удваивается по правилам CSV. Временный файл находится рядом с целевым — это важно для atomic move.

Эталонная реализация: точка сборки Main
package ru.nerosky.nerowatch;

        import java.io.IOException;
        import java.net.http.HttpClient;
        import java.nio.file.Path;
        import java.time.Clock;
        import java.time.Duration;
        import java.time.ZoneOffset;
        import java.time.format.DateTimeFormatter;
        import ru.nerosky.nerowatch.adapter.CsvReportWriter;
        import ru.nerosky.nerowatch.adapter.EndpointLoader;
        import ru.nerosky.nerowatch.adapter.JdkHttpProbe;
        import ru.nerosky.nerowatch.adapter.LimitedProbe;
        import ru.nerosky.nerowatch.adapter.RetryingProbe;
        import ru.nerosky.nerowatch.application.MonitorService;
        import ru.nerosky.nerowatch.model.ProbeResult;
        import ru.nerosky.nerowatch.port.Probe;

        public final class Main {
            private static final DateTimeFormatter FILE_TIME =
                DateTimeFormatter.ofPattern("uuuuMMdd-HHmmss")
                    .withZone(ZoneOffset.UTC);

            private Main() {}

            public static void main(String[] args) {
                try {
                    run(args);
                } catch (InterruptedException error) {
                    Thread.currentThread().interrupt();
                    System.err.println("Проверка отменена");
                } catch (IOException | IllegalArgumentException error) {
                    System.err.println("Ошибка: " + error.getMessage());
                }
            }

            private static void run(String[] args)
                    throws IOException, InterruptedException {
                Path config = args.length >= 1
                    ? Path.of(args[0])
                    : Path.of("config", "endpoints.txt");
                Path reportDirectory = args.length >= 2
                    ? Path.of(args[1])
                    : Path.of("reports");

                Clock clock = Clock.systemUTC();
                var endpoints = new EndpointLoader().load(config);

                try (HttpClient client = HttpClient.newBuilder()
                        .connectTimeout(Duration.ofSeconds(3))
                        .followRedirects(HttpClient.Redirect.NORMAL)
                        .build()) {

                    Probe http = new JdkHttpProbe(
                        client,
                        Duration.ofSeconds(5),
                        clock
                    );
                    Probe limited = new LimitedProbe(http, 20, clock);
                    Probe resilient = new RetryingProbe(
                        limited,
                        3,
                        Duration.ofMillis(250),
                        RetryingProbe::sleepThread,
                        clock
                    );

                    var service = new MonitorService(resilient, clock);
                    var results = service.checkAll(endpoints);

                    Path report = reportDirectory.resolve(
                        "nerowatch-" + FILE_TIME.format(clock.instant()) + ".csv"
                    );
                    new CsvReportWriter().write(report, results);
                    printSummary(results, report);
                }
            }

            private static void printSummary(
                    java.util.List<ProbeResult> results,
                    Path report
            ) {
                long successes = results.stream()
                    .filter(ProbeResult.Success.class::isInstance)
                    .count();

                System.out.println("Проверено: " + results.size());
                System.out.println("Доступно: " + successes);
                System.out.println("Ошибок: " + (results.size() - successes));
                System.out.println("Отчет: " + report);
            }
        }
CLI-код для production стоит возвращать exit code Учебная версия печатает ошибку. Для cron/CI добавьте System.exit(1) при ошибке запуска и договоритесь, означает ли недоступный endpoint ошибку процесса или только строку DOWN в отчете.
Эталонная реализация: тесты без сети и ожидания
package ru.nerosky.nerowatch.adapter;

        import static org.junit.jupiter.api.Assertions.assertEquals;
        import static org.junit.jupiter.api.Assertions.assertThrows;

        import java.nio.file.Files;
        import java.nio.file.Path;
        import org.junit.jupiter.api.Test;
        import org.junit.jupiter.api.io.TempDir;

        class EndpointLoaderTest {
            @TempDir
            Path directory;

            @Test
            void loadsDataAndIgnoresComments() throws Exception {
                Path source = directory.resolve("endpoints.txt");
                Files.writeString(source, """
                    # Production

                    NeroSky|https://nerosky.ru
                    Java|https://dev.java
                    """);

                var result = new EndpointLoader().load(source);

                assertEquals(2, result.size());
                assertEquals("NeroSky", result.getFirst().name());
            }

            @Test
            void reportsLineNumberForInvalidValue() throws Exception {
                Path source = directory.resolve("endpoints.txt");
                Files.writeString(source, """
                    Good|https://example.com
                    Broken line
                    """);

                var error = assertThrows(
                    IllegalArgumentException.class,
                    () -> new EndpointLoader().load(source)
                );

                assertEquals(
                    "Строка 2: ожидается Имя|URL",
                    error.getMessage()
                );
            }
        }
package ru.nerosky.nerowatch.adapter;

        import static org.junit.jupiter.api.Assertions.assertEquals;
        import static org.junit.jupiter.api.Assertions.assertInstanceOf;

        import java.net.URI;
        import java.time.Clock;
        import java.time.Duration;
        import java.time.Instant;
        import java.time.ZoneOffset;
        import java.util.ArrayDeque;
        import java.util.ArrayList;
        import org.junit.jupiter.api.Test;
        import ru.nerosky.nerowatch.model.Endpoint;
        import ru.nerosky.nerowatch.model.ProbeResult;
        import ru.nerosky.nerowatch.port.Probe;

        class RetryingProbeTest {
            private final Endpoint endpoint = new Endpoint(
                "NeroSky",
                URI.create("https://nerosky.ru")
            );
            private final Clock clock = Clock.fixed(
                Instant.parse("2026-07-27T10:00:00Z"),
                ZoneOffset.UTC
            );

            @Test
            void retriesTemporaryFailureAndStopsAfterSuccess() {
                var answers = new ArrayDeque<ProbeResult>();
                answers.add(new ProbeResult.Failure(
                    endpoint,
                    "ConnectException",
                    true,
                    clock.instant(),
                    Duration.ofMillis(20)
                ));
                answers.add(new ProbeResult.Success(
                    endpoint,
                    200,
                    clock.instant(),
                    Duration.ofMillis(30)
                ));
                Probe fake = ignored -> answers.removeFirst();
                var delays = new ArrayList<Duration>();
                var probe = new RetryingProbe(
                    fake,
                    3,
                    Duration.ofMillis(100),
                    delays::add,
                    clock
                );

                ProbeResult result = probe.check(endpoint);

                assertInstanceOf(ProbeResult.Success.class, result);
                assertEquals(java.util.List.of(Duration.ofMillis(100)), delays);
                assertEquals(0, answers.size());
            }

            @Test
            void doesNotRetryClientError() {
                var failure = new ProbeResult.Failure(
                    endpoint,
                    "HTTP 404",
                    false,
                    clock.instant(),
                    Duration.ofMillis(10)
                );
                var calls = new java.util.concurrent.atomic.AtomicInteger();
                Probe fake = ignored -> {
                    calls.incrementAndGet();
                    return failure;
                };
                var probe = new RetryingProbe(
                    fake,
                    3,
                    Duration.ofMillis(100),
                    duration -> {},
                    clock
                );

                probe.check(endpoint);

                assertEquals(1, calls.get());
            }
        }

Приемочные сценарии

СценарийКак проверитьОжидаемый результат
Успешный сервис Локальный stub возвращает 200. Строка UP, status 200, latency неотрицательна.
Ошибка клиента Stub возвращает 404. DOWN, ровно одна попытка.
Временный сбой Два ответа 503, затем 200. Три попытки, итог UP.
Медленный ответ Stub отвечает дольше request timeout. Ограниченное число попыток, запуск завершается.
Поврежденная строка Убрать разделитель |. Понятная ошибка с номером строки, HTTP не запускается.
Кавычка в имени Имя Team "A". CSV содержит "Team ""A""" и открывается корректно.
Отмена Прервать поток во время retry delay. Флаг interruption восстановлен, дальнейших попыток нет.
Сбой записи Записать в каталог без права записи. Старый отчет не превращается в частичный новый.

Review-чеклист перед защитой

  • Все зависимости создаются в Main, доменная модель не знает HTTP и файлов.
  • У HttpClient и запроса заданы разные timeout.
  • Повтор ограничен числом попыток и применяется только к временным сбоям.
  • Каждое acquire имеет условный release в finally.
  • InterruptedException восстанавливает флаг или передается выше.
  • CSV экранирует кавычки, а временный файл удаляется после сбоя.
  • Тесты выполняются без интернета и реального sleep.
  • mvn clean test проходит на чистом checkout.

Дополнительные уровни

  1. Bronze: добавить CLI-параметры timeout, attempts и concurrency limit.
  2. Silver: добавить JSON-отчет через библиотеку и контрактные тесты формата.
  3. Gold: хранить историю в PostgreSQL с миграциями и транзакциями.
  4. Platinum: превратить NeroWatch в REST-сервис с метриками и graceful shutdown.
Готово — когда решение можно объяснить На защите наставник меняет правило: например, 429 нужно повторять с учетом Retry-After. Стажер должен назвать затронутый компонент, добавить тест и внести изменение без переписывания всего приложения.
Для команды обучения

Как провести продвинутый курс со стажерами

Это не марафон чтения. Цель — научить принимать инженерные решения, доказывать корректность и безопасно изменять код после review. Оптимальный ритм — восемь недель.

План на восемь недель

Неделя Уроки Артефакт Фокус ревью
1 1–2 Многомодульный или пакетный Maven-проект Границы, public API, воспроизводимая сборка
2 3–5 Типобезопасный in-memory repository Контракты объектов, generics, сложность операций
3 6–8 Конвейер обработки заказов Collectors, sealed model, ошибки как контракт
4 9–11 Расширяемый command processor Dependencies, patterns, оправданность reflection
5 12–13 Импорт данных и HTTP-адаптер Ресурсы, atomic write, timeout, retry
6 14–15 Конкурентный агрегатор Happens-before, отмена, лимиты, virtual threads
7 16–17 Тестовый набор и отчет JFR Детерминизм, профилирование, наблюдаемость
8 18 NeroWatch и техническая защита Целостность решения и изменение требования

Ритм недели

Понедельник:  теория и прогноз поведения кода
Вторник:  лабораторные задачи и короткие коммиты
Среда:  самостоятельная фича без решения
Четверг:  pull request и письменное review
Пятница:  исправления и 15-минутная защита
Каждый день:  небольшой журнал решений и вопросов

Как давать задачу

  1. Опишите наблюдаемое поведение. Вход, результат, ошибки, пределы.
  2. Не диктуйте классы заранее. Пусть стажер предложит границы и объяснит их.
  3. Добавьте изменение требования. Оно показывает, выдерживает ли структура развитие.
  4. Попросите доказательство. Тест, JFR, complexity analysis или happens-before.
  5. Оставьте время на исправление. Review без второй итерации почти не обучает.
Вопрос лучше готового исправления Вместо «замени HashMap на ConcurrentHashMap» спросите: «Какие операции выполняются одновременно и какая гарантия нужна всей последовательности?». Ответ покажет понимание, а не способность применить подсказку.

Рубрика NeroWatch

За каждый критерий поставьте 0 — отсутствует, 1 — частично, 2 — уверенно. Максимум — 20 баллов.

КритерийЧто означает 2 балла
Контракты модели Недопустимое состояние невозможно создать, данные неизменяемы.
Архитектура HTTP, orchestration, модель и файл разделены; зависимости направлены внутрь.
HTTP-надежность Есть оба timeout, корректная классификация и ограниченный retry.
Конкурентность Нет общего опасного состояния; есть лимит I/O и корректная отмена.
Файловая целостность CSV экранируется, UTF-8 явен, частичный отчет не публикуется.
Тест-дизайн Ключевые ветви проверены детерминированно без сети и sleep.
Наблюдаемость Результат содержит достаточный контекст без секретов.
Сборка mvn clean test и JAR работают на чистой машине с JDK 25.
Читаемость Имена отражают смысл, методы компактны, комментарии объясняют причины.
Защита Стажер объясняет trade-offs и самостоятельно меняет одно правило.

Вопросы для защиты

  1. Почему Probe находится между сервисом и HTTP-адаптером?
  2. Почему длительность измеряется nanoTime, а дата — через Clock?
  3. Почему volatile не исправит count++?
  4. Что изменится, если decorators retry и limit поменять местами?
  5. Почему virtual threads не ограничивают давление на внешний сервис?
  6. Какие ответы безопасно повторять и откуда это известно?
  7. Что произойдет, если поток прервать во время Semaphore.acquire?
  8. Как доказать, что CSV корректен для запятой, кавычки и перевода строки?
  9. Как найти причину выросшего p99, не угадывая по коду?
  10. Как добавить новый transport, не изменив MonitorService?

Изменения требования для живой части

  • считать 3xx отдельным состоянием REDIRECTED;
  • учесть Retry-After для 429;
  • ограничить не только параллелизм, но и пять запусков в секунду;
  • выдать результаты в порядке завершения, а не конфигурации;
  • добавить общий deadline всей партии;
  • поддержать два независимых формата отчета.
Правило использования ИИ ИИ можно использовать для объяснения, поиска вариантов и review. Принятым считается только код, для которого стажер может назвать контракт, риски, проверку и самостоятельно внести небольшое изменение. Непонятное решение возвращается на доработку независимо от того, проходит ли оно тесты.

Интерпретация результата

0–9: есть пробелы в Java Core

Вернуться к контрактам объектов, exceptions, collections и тестам.

10–14: выполняет с поддержкой

Функции работают, но надежность и обоснование требуют наставника.

15–17: самостоятельный advanced trainee

Проектирует небольшой сервис и уверенно проходит code review.

18–20: готов к backend-треку

Можно переходить к SQL, Spring, интеграциям и распределенным системам.

Справочник

Словарь продвинутого Java-разработчика

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

ТерминПростое объяснение
AtomicityОперация наблюдается целиком, без промежуточного состояния.
BackoffУвеличивающаяся пауза между повторными попытками.
BackpressureСпособ не принимать работу быстрее, чем система ее обрабатывает.
BenchmarkКонтролируемое измерение производительности конкретной операции.
BulkheadИзоляция ресурсов, чтобы сбой одной части не занял всё приложение.
Checked exceptionИсключение, которое сигнатура заставляет обработать или передать.
Circuit breakerВременная остановка вызовов к явно недоступной зависимости.
CollectorПравило свертки элементов Stream в итоговую структуру.
ContentionСоперничество потоков за один lock или ограниченный ресурс.
DeadlockЦикл ожиданий, в котором участники не могут продолжить.
Defensive copyКопия, не позволяющая внешнему коду менять внутреннее состояние.
ErasureМеханизм реализации generics, убирающий большинство параметров типов в runtime.
Happens-beforeГарантия порядка и видимости действий между потоками.
IdempotencyПовтор операции имеет тот же итоговый эффект, что один вызов.
ImmutabilityСостояние объекта не меняется после создания.
InvariantПравило, которое обязано оставаться истинным для корректного объекта.
JFRВстроенная в JDK запись событий JVM и приложения.
JITКомпилятор JVM, превращающий горячий bytecode в машинный код во время работы.
JitterСлучайная добавка к backoff, разводящая одновременные повторы клиентов.
Latency p99Время, быстрее которого завершились 99% операций.
LockМеханизм исключительного доступа к критической секции.
PECSProducer Extends, Consumer Super — правило выбора wildcard.
PinningСитуация, когда virtual thread временно нельзя снять с carrier thread.
Port / adapterИнтерфейс нужной операции и внешняя реализация этого интерфейса.
Race conditionРезультат зависит от неконтролируемого порядка конкурентных действий.
Safe publicationПередача объекта другому потоку с гарантиями видимости.
Sealed typeТип с явно ограниченным списком допустимых реализаций.
ThroughputКоличество завершенных операций за единицу времени.
TimeoutМаксимальное ожидание отдельной операции.
Type boundОграничение допустимых параметров generic-типа.
Unchecked exceptionОшибка контракта или состояния, не требующая объявления в сигнатуре.
Virtual threadЛегкий Thread, планируемый JVM и удобный для большого числа блокирующих I/O-задач.
VisibilityГарантия, что один поток увидит запись другого.
WarmupПериод разогрева, когда JVM загружает классы и JIT компилирует код.

Памятка: чем защитить общее состояние

СитуацияПервый кандидатПроверочный вопрос
Данные не меняютсяImmutable objectМожно ли вернуть List.copyOf?
Независимый флагvolatileНет ли составного read-modify-write?
Один счетчикAtomicLongНужен ли общий инвариант с другими полями?
Несколько связанных полейsynchronized / lockВесь ли инвариант внутри одной границы?
Map с независимыми ключамиConcurrentHashMapИспользуются ли атомарные методы Map?
Ограниченный внешний ресурсSemaphoreОсвобождается ли permit в finally?

Памятка: надежный внешний вызов

□ Один переиспользуемый клиент
        □ Connect timeout
        □ Request timeout или общий deadline
        □ Явная классификация HTTP и transport errors
        □ Retry только для безопасных временных сбоев
        □ Предел попыток + backoff + jitter
        □ Лимит конкурентности / rate
        □ Корректное interruption
        □ Метрики rate, errors, duration, saturation
        □ В логах нет token, cookie и персональных данных

Памятка code review

  • Correctness: какие инварианты и крайние случаи?
  • Contracts: понятны ли null, ошибки, порядок, mutability?
  • Complexity: как растут время и память на реальном объеме?
  • Concurrency: где общее состояние и какая happens-before связь?
  • Reliability: что произойдет при timeout, отмене и частичном сбое?
  • Security: вход ограничен, секреты защищены, права минимальны?
  • Tests: проверяют контракт и остаются детерминированными?
  • Operations: как заметить проблему и безопасно остановить приложение?

Куда двигаться после курса

  1. SQL и JDBC. Индексы, транзакции, уровни изоляции, connection pool.
  2. Spring Boot. DI container, configuration, REST, validation, data access.
  3. Интеграционные тесты. Testcontainers, contract testing и локальные stubs.
  4. Распределенные системы. Идемпотентность, очереди, consistency, failure modes.
  5. Наблюдаемость. OpenTelemetry, SLI/SLO, alerting и incident review.
  6. Доставка. Containers, CI/CD, dependency scanning и безопасные секреты.
Фреймворк усиливает фундамент Spring сделает wiring и инфраструктуру удобнее, но не отменит контракты объектов, Java Memory Model, timeout, транзакции и профилирование. Эти знания переносятся между библиотеками и версиями.

Официальные материалы

Версия курса: 1.0, июль 2026 года. Учебная база — Java 25 LTS и JUnit 6.1.0; preview-возможности не используются.

Финальная мысль Продвинутый Java-разработчик не тот, кто помнит больше API. Он делает контракты явными, локализует изменчивость, проектирует сбои, измеряет производительность и способен доказать, почему система останется корректной при неудобном сценарии.