JavaRush /Курсы /Spring Core /Сборка ContextFlow ...

Сборка ContextFlow по профилям

Spring Core
14 уровень , 4 лекция
Открыта

1. Матрица режимов в ContextFlow

Профили удобнее всего воспринимать не как абстрактную “магическую настройку”, а как контракт сборки: в каком режиме мы запускаем приложение, какие реализации портов попадают в контейнер и какие побочные эффекты вообще допустимы. Если такого контракта нет, профили превращаются в хаотичный набор переключателей, и уже через пару дней никто не помнит, чем demo2 отличается от demo-old.

В ContextFlow нам достаточно трёх простых режимов, которые реально встречаются в жизни. dev — для ежедневной разработки: максимум наблюдаемости и минимум инфраструктурных хлопот. demo — для демонстрации поведения: хочется показать “похоже на настоящее”, но без внешних систем. test — для предсказуемости: никаких случайных ID и никаких побочных эффектов, которые мешают проверять сценарий. В каждом запуске активен один из этих режимов.

Ниже — “матрица вариативности”, то есть какие интерфейсы мы будем по-разному реализовывать в разных профилях. Обратите внимание: мы меняем инфраструктурные порты (id generation, audit, notifications), а не сервисы use-case уровня.

Контракт (интерфейс) dev demo test Зачем так
OrderIdGenerator UuidOrderIdGenerator UuidOrderIdGenerator DeterministicOrder IdGenerator в test нужен повторяемый результат
AuditWriter ConsoleAuditWriter FileAuditWriter ConsoleAuditWriter в demo хочется “как будто серьёзно”: файл в build/
NotificationSender ConsoleNotification Sender ConsoleNotification Sender NoOpNotificationSender в test убираем шум и побочные эффекты

Эта матрица и есть рабочий контракт сборки: use-case-сервисы остаются теми же, а контейнер переключает только инфраструктурные реализации портов. Для верхнеуровневых режимов этого достаточно; более общий @Conditional нужен там, где правило уже не помещается в человеческое имя режима.

Для наглядности можно держать в голове такую схему:

flowchart TD
    P["Активный профиль: dev/demo/test"] --> B["@Profile решает, какие beans регистрируются"]
    B --> C["ApplicationContext (готовый граф объектов)"]
    C --> R["ScenarioRunner запускает сценарий"]
    R --> O["Разное поведение без изменений в сервисах"]

2. Профили vs бизнес-логика

Самый частый способ “сломать” профили — заставить бизнес-код знать про них напрямую. Сначала кажется, что это мелочь: ну подумаешь, один if. Потом добавляется второй, третий, появляется “временно захардкоженный” режим, и вы внезапно обнаруживаете, что у вас есть несколько приложений, которые притворяются одним. Профили нужны ровно для того, чтобы этого не случилось.

Поэтому в ContextFlow мы держим правило: сервисы application-слоя зависят только от интерфейсов. Они не читают Environment, не смотрят на spring.profiles.active, не выбирают реализацию “по настроению”. Если вы сможете открыть конструктор сервиса и понять все зависимости — значит вы всё сделали правильно, и Spring действительно помогает, а не маскирует сложность.

Вот короткий пример “правильного” сервиса: он получает NotificationSender как зависимость и вообще не знает, что в test это будет no-op.

import org.springframework.stereotype.Service;

@Service
public class NotificationDispatchService {
    private final NotificationSender sender;

    public NotificationDispatchService(NotificationSender sender) {
        // Зависимость приходит снаружи: контейнер сам подставит реализацию по профилю
        this.sender = sender;
    }

    public void notifyOrderCreated(String message) {
        // Сервис ничего не знает про dev/demo/test — он просто вызывает порт
        sender.send(message);
    }
}

Если сейчас вы подумали “а где тут dev/demo/test?” — отлично. Значит, мы попали в правильную архитектурную точку: режим выбирает контейнер, а бизнес-сервис остаётся одинаковым.

То же самое касается AuditService и OrderPlacementService: они зависят от AuditWriter и OrderIdGenerator как от портов. В зависимости от профиля мы просто подставим другие реализации. Сервисный слой при этом не будет переписываться под каждый режим, и это очень важно: иначе профили превращаются в три параллельных приложения, которые случайно компилируются в одном проекте.

3. Конфиги по ответственности

Когда вы впервые начинаете использовать профили, очень легко сделать “AppConfig на стероидах”: один огромный класс на 300 строк, в котором вперемешку идут @Bean, @Profile, @Value, а в конце ещё пара “временных” решений, которые никто не удалил. Формально оно даже работает. Но читать такое потом — всё равно что пытаться понять сериал на 10 сезонов, начав с 7 серии 8 сезона.

Гораздо спокойнее (и для мозга, и для будущего вас) держать конфигурацию по ответственности. Для нашей сегодняшней цели достаточно трёх маленьких конфигов: один отвечает за генерацию идентификаторов, второй за аудит, третий за уведомления. Да, это “чуть больше файлов”, но зато каждый файл легко объяснить, и Spring-контейнер перестаёт быть “магическим мешком”.

Базовый AppConfig пусть занимается component-based частью приложения, а @Configuration-классы с профильной вариативностью подключим явно в launcher. Тогда composition root видно глазами, и не возникает вопроса, кто именно регистрирует профильные бины.

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;

@Configuration
@ComponentScan(
    basePackages = "com.example.contextflow",
    // Сервисы и раннеры приезжают через scanning,
    // а @Configuration-классы подключаем явно в launcher
    excludeFilters = @ComponentScan.Filter(
        type = FilterType.ANNOTATION,
        classes = Configuration.class
    )
)
public class AppConfig {
}

А дальше мы подключаем “профильную инфраструктуру” отдельными @Configuration-классами. В чистом Spring (без Boot) это нормально делать через context.register(...) в точке входа, чтобы было видно, какие именно конфигурации участвуют в сборке. Это остаётся честным composition root, только уже на стороне контейнера.

4. Генераторы ID по профилям

Генерация идентификаторов — прекрасный кандидат для профилей, потому что она одновременно простая и очень показательная. В “живом” режиме (dev, demo) нам нормально иметь UUID: он уникальный, случайный, и нам всё равно. А вот в test-режиме случайность — враг. Не потому что “UUID плохой”, а потому что от случайных значений тяжело проверять сценарии и сравнивать результаты запусков. Вы запускаете одно и то же — а получаете разные ID, и отладка превращается в гадание.

Контракт у нас простой:

public interface OrderIdGenerator {
    // Возвращаем ID в виде строки, чтобы пример не упирался в конкретный тип (UUID/long/и т.д.)
    String nextId();
}

Реализация для test — детерминированная. В учебном проекте мы сделаем её максимально простой: счётчик, который выдаёт TEST-1000, TEST-1001 и так далее.

import java.util.concurrent.atomic.AtomicInteger;

public class DeterministicOrderIdGenerator implements OrderIdGenerator {
    // AtomicInteger здесь просто чтобы пример был безопасен при возможной многопоточности
    private final AtomicInteger seq = new AtomicInteger(1000);

    @Override
    public String nextId() {
        // В test важно, чтобы значения были воспроизводимыми между запусками
        return "TEST-" + seq.getAndIncrement();
    }
}

Теперь самое главное — конфигурация, которая по профилю решает, какой генератор будет зарегистрирован как bean. Мы делаем это аккуратно: в test один bean, в dev/demo другой. И мы следим, чтобы в каждом профиле был ровно один OrderIdGenerator, иначе Spring честно упадёт на старте (и будет прав).

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

@Configuration
class IdGenerationProfileConfig {

    @Bean
    @Profile("test") // В тестах хотим детерминированные ID
    OrderIdGenerator deterministicOrderIdGenerator() {
        return new DeterministicOrderIdGenerator();
    }

    @Bean
    @Profile({"dev", "demo"}) // В dev/demo достаточно уникальности, пусть будет UUID
    OrderIdGenerator uuidOrderIdGenerator() {
        return new UuidOrderIdGenerator();
    }
}

Обратите внимание на важный нюанс: если вы забыли выставить профиль вообще, то активным станет “профиль по умолчанию”, и ваши dev/demo/test-бины могут не зарегистрироваться. Поэтому в точке входа разумно задать dev как default profile. Тогда приложение будет дружелюбно запускаться “просто так”, как нормальная консольная программа, а не требовать шаманский бубен уже на первом main().

5. Аудит по профилям

Аудит — это классическая вещь, которая в реальных проектах очень быстро становится “инфраструктурой со вкусом бизнеса”. Сегодня нам не нужен полноценный аудит-движок, но нам нужна демонстрация того, что в разных режимах окружения одна и та же система может писать аудит по-разному. В dev хочется видеть всё в консоли прямо сейчас. В demo хочется показать, что “сервис умеет писать файл” (и да, это часто впечатляет менеджера больше, чем правильная модель зависимостей). В test мы не хотим непредсказуемости и лишних side effects; поэтому оставим аудит консольным, но при желании позже его легко заменить.

Контракт простой:

public interface AuditWriter {
    // Пишем сообщение об аудите туда, куда решит профиль (консоль/файл/и т.д.)
    void write(String message);
}

Реализация для консоли максимально честная: просто System.out.println(...). Она идеально подходит для dev и вполне годится для test, потому что консоль — это хотя бы не файловая система, которая может “не туда писать”.

public class ConsoleAuditWriter implements AuditWriter {
    @Override
    public void write(String message) {
        // В dev/test хотим видеть аудит "здесь и сейчас" — в stdout
        System.out.println("AUDIT: " + message); // AUDIT: ...
    }
}

А вот для demo мы хотим FileAuditWriter, который пишет в файл внутри build/. Не потому что файл — это священная корова, а потому что это хороший учебный пример: путь не должен быть абсолютным, артефакты — должны быть в build/, и поведение — должно быть наблюдаемым.

В конфигурации это выглядит так: в demo создаём FileAuditWriter, в dev/testConsoleAuditWriter. Обратите внимание, что путь к файлу лучше хранить в properties, чтобы не хардкодить его в Java-коде.

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.*;

import java.nio.file.Path;

@Configuration
class AuditProfileConfig {

    @Bean
    @Profile({"dev", "test"}) // В dev/test пишем аудит в консоль
    AuditWriter consoleAuditWriter() {
        return new ConsoleAuditWriter();
    }

    @Bean
    @Profile("demo") // В demo хотим "серьёзный" эффект — пишем в файл
    AuditWriter fileAuditWriter(@Value("${contextflow.audit.file}") String file) {
        // Путь задаётся снаружи через property, а не хардкодится в коде
        return new FileAuditWriter(Path.of(file));
    }
}

Если вы увидели знакомую границу “profile выбирает bean, property настраивает bean” — это она и есть. Профиль решает: “в demo аудит в файл”. А property решает: “в какой именно файл писать”. Это куда чище, чем пытаться внутрь AuditService засунуть условие “если demo — пиши в файл”.

6. Уведомления по профилям

Уведомления — ещё один идеальный кандидат для профилей, потому что они почти всегда завязаны на внешние штуки. В реальной жизни это может быть SMS-шлюз, email-провайдер, Telegram-бот… и почти всегда вы не хотите дёргать это в тестовом режиме и даже иногда в демо-режиме. В учебном проекте мы не подключаем внешние сервисы, но принцип сохраняется: в dev/demo отправка уведомления должна быть наблюдаемой, а в test — лучше быть no-op.

Контракт:

public interface NotificationSender {
    // Отправить уведомление (куда именно — решает реализация/профиль)
    void send(String message);
}

Реализация для консоли:

public class ConsoleNotificationSender implements NotificationSender {
    @Override
    public void send(String message) {
        // В dev/demo нам достаточно "симуляции" — просто выводим в stdout
        System.out.println("NOTIFY: " + message); // NOTIFY: ...
    }
}

И реализация “тишины” для test:

public class NoOpNotificationSender implements NotificationSender {
    @Override
    public void send(String message) {
        // ничего не делаем — в test режиме так и задумано
    }
}

Теперь профильная конфигурация. Мы регистрируем разные beans одного и того же интерфейса в разных профилях. Именно в этом месте профили дают “чистую магию без магии”: сервис NotificationDispatchService не меняется, а поведение приложения — меняется.

import org.springframework.context.annotation.*;

@Configuration
class NotificationProfileConfig {

    @Bean
    @Profile({"dev", "demo"}) // В dev/demo хотим видеть отправку (хотя бы в консоли)
    NotificationSender consoleNotificationSender() {
        return new ConsoleNotificationSender();
    }

    @Bean
    @Profile("test") // В test никаких побочных эффектов
    NotificationSender noOpNotificationSender() {
        return new NoOpNotificationSender();
    }
}

Здесь есть важная дисциплина: в каждом профиле должен быть ровно один NotificationSender, иначе при constructor injection вы поймаете NoUniqueBeanDefinitionException. И это не “проблема Spring”, это сигнал, что вы сделали неоднозначную сборку.

Ещё один нюанс: иногда хочется зарегистрировать “настоящие” реализации (EmailNotificationSender, SmsNotificationSender) как компоненты и потом выбирать. Это нормально, но тогда профили нужно применять на уровне этих компонентов или использовать @Primary/@Qualifier. Сегодня мы оставляем пример максимально прозрачным: один профиль — один активный sender.

7. Запуск и проверка профиля

Самое приятное в профилях — их легко проверить: достаточно поднять ApplicationContext в разных режимах и посмотреть, какие реальные классы стоят за интерфейсами. Это тот момент, когда Spring перестаёт быть “теорией” и становится инженерным инструментом: вы буквально видите, как меняется сборка приложения. Главное — помнить две вещи: профиль выбирается до refresh(), и для ContextFlow на один запуск мы ждём одно значение dev, demo или test.

Ниже — пример простого “лаунчера” для консольного приложения. Он делает три вещи: задаёт default profile dev, читает активный профиль из system property, регистрирует конфигурации и стартует контекст.

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class ContextFlowApplication {
    public static void main(String[] args) {
        // Ожидаем одно верхнеуровневое значение: -Dspring.profiles.active=demo
        String profile = System.getProperty("spring.profiles.active");

        try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
            // Если профиль не задан — считаем, что это dev (дружелюбный режим по умолчанию)
            context.getEnvironment().setDefaultProfiles("dev");

            // Если профиль задан явно — активируем его
            if (profile != null && !profile.isBlank()) {
                context.getEnvironment().setActiveProfiles(profile);
            }

            // Явно регистрируем конфиги: видно, из каких "кирпичиков" собран контекст
            context.register(
                AppConfig.class,
                IdGenerationProfileConfig.class,
                AuditProfileConfig.class,
                NotificationProfileConfig.class
            );

            // Важно: refresh() запускает создание бинов и собирает граф зависимостей
            context.refresh();

            // Запускаем сценарий приложения
            context.getBean(ScenarioRunner.class).run();
        }
    }
}

Теперь “быстрая проверка” сборки — это буквально пара строчек. Вставьте их временно в main() (или в какой-нибудь диагностический runner), запустите с разными профилями и посмотрите, что реально подставилось.

// Смотрим, какая реализация порта реально оказалась в контейнере
OrderIdGenerator idGen = context.getBean(OrderIdGenerator.class);
System.out.println("OrderIdGenerator = " + idGen.getClass().getSimpleName());
// OrderIdGenerator = DeterministicOrderIdGenerator (в test)

NotificationSender sender = context.getBean(NotificationSender.class);
System.out.println("NotificationSender = " + sender.getClass().getSimpleName());
// NotificationSender = NoOpNotificationSender (в test)

Если вы запустите приложение с -Dspring.profiles.active=test, то увидите детерминированный генератор и no-op sender. Если запустите с demo, то увидите UUID-генератор, console sender и file audit writer, а файл появится в build/ по вашему contextflow.audit.file. В dev будет всё максимально “говорливое”: консольный аудит и консольные уведомления.

Самое важное, что мы получили: бизнес-сервисы не менялись. Мы не добавляли в них if-else, не прокидывали туда Environment, не делали “режим работы” частью доменной модели. Мы всего лишь собрали контейнер по-разному — и получили разные runtime-варианты одного приложения.

8. Типичные ошибки при работе с профилями

Ошибка №1: профили “просачиваются” в business code.
Обычно это начинается с невинного: “ну я просто проверю spring.profiles.active и чуть-чуть поменяю поведение”. Проблема в том, что это мгновенно ломает идею DI: сервис перестаёт быть просто сервисом и превращается в смесь use-case логики и логики сборки. Правильное место для профилей — @Configuration и registration beans, а не OrderPlacementService.

Ошибка №2: в одном профиле нет полного набора beans.
Новички часто добавляют @Profile("demo") на один bean и забывают, что в demo он теперь единственный кандидат, а в dev его нет. Или наоборот: в test включили DeterministicOrderIdGenerator, но забыли, что NotificationSender теперь тоже должен быть определён, пусть даже как no-op. В результате приложение падает на старте, и это не “сложность Spring”, а честный сигнал: сборка не завершена.

Ошибка №3: случайно активируют два взаимоисключающих профиля и получают неоднозначность.
Если запустить приложение с активными профилями dev,test одновременно, а у вас есть @Profile({"dev", "test"}) на одном bean и отдельный @Profile("test") на другом, можно легко получить два кандидата одного интерфейса. Spring не обязан угадывать, какой вам больше нравится. Лучше держать модель простой: в нашем ContextFlow предполагается ровно один активный профиль из трёх.

Ошибка №4: не задают dev как default profile и удивляются, что “без параметров не работает”.
Если вы описали beans только под dev/demo/test, а профиль не выставили, Spring будет считать активным default и не зарегистрирует ваши dev beans. В результате вы получите NoSuchBeanDefinitionException. Лечится просто: либо делайте dev default profile через setDefaultProfiles("dev"), как мы сделали, либо явно добавляйте default в @Profile там, где хотите поведение “по умолчанию”.

Ошибка №5: клонируют большой конфиг под каждый профиль.
Очень соблазнительно: “сделаю DevAppConfig, DemoAppConfig, TestAppConfig и буду счастлив”. Потом вы меняете одну зависимость и правите три файла; потом добавляете четвёртый профиль; потом начинает расходиться логика. Гораздо спокойнее держать общую базу — component scanning, общие бины — и маленькие profile-specific куски по ответственности, как IdGenerationProfileConfig, AuditProfileConfig, NotificationProfileConfig.

1
Задача
Spring Core, 14 уровень, 4 лекция
Недоступна
Мини-сборка `ContextFlow` с профилем `test`
Мини-сборка `ContextFlow` с профилем `test`
1
Задача
Spring Core, 14 уровень, 4 лекция
Недоступна
Разделённая profile-aware конфигурация для `TicketDesk`
Разделённая profile-aware конфигурация для `TicketDesk`
1
Опрос
Профили Spring, 14 уровень, 4 лекция
Недоступен
Профили Spring
Условная регистрация bean-ов
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ