1. Две задачи в support-слое
Если до этого момента ваш ContextFlow воспринимался как “честный учебный сервис”, то сегодня мы добавим две вещи, которые очень похожи на то, что встречается в реальных проектах. Первая — выбор нужной реализации по конфигурации без размазывания if/else по сервисам. Вторая — техническая диагностика контекста, но без того, чтобы бизнес-код учился “жить от контейнера”.
Представим две задачи на человеческом языке.
Первая задача: у нас есть интерфейс ReportFormatter и две реализации — условно “текстовый” и “CSV-подобный”. В разных профилях и окружениях нам хочется подсовывать разный формат отчёта, но так, чтобы ReportingService оставался максимально простым: он должен зависеть от контракта, а не от логики выбора реализации.
Вторая задача: иногда хочется быстро увидеть, в каком режиме поднялся контекст. Какие активные профили? Какое имя приложения? Может, даже какой формат отчёта выбрали? И хочется сделать это так, чтобы не писать в каждом сервисе “ну-ка дай мне Environment… а теперь getProperty(...)…”. Для этого мы заведём маленький диагностический bean, которому контейнер сам отдаст нужную служебную информацию.
Ниже — маленькая “карта” решений, чтобы было видно, кто за что отвечает:
| Что хотим сделать | Где это живёт | Почему именно там |
|---|---|---|
| Выбрать реализацию ReportFormatter по свойству contextflow.report.format | support.factory + config.reporting | Это логика сборки/создания объектов, а не бизнес-сценарий |
| Вывести краткую сводку про контекст (имя bean-а, профили, пару properties) | support.diagnostics + config.core | Это техническая диагностика, и ей разрешено знать про Spring-окружение |
2. ReportFormatter и выбор формата
Перед тем как мы добавим FactoryBean, важно ещё раз проговорить простую мысль: сервис должен делать свою работу, а не играть в “сборщика приложения”. Когда ReportingService начинает решать, какую реализацию форматтера выбрать, он берёт на себя лишнюю ответственность. Сначала это кажется “ну чего там, один if”, а потом внезапно становится “пять if, три свойства, два профиля и один баг, который появляется только по пятницам”.
Допустим, контракт форматтера у нас выглядит так (если он у вас уже есть — просто сверяйтесь по смыслу):
package com.example.contextflow.domain.ports;
import com.example.contextflow.domain.model.DailyReport;
public interface ReportFormatter {
// Форматируем доменный отчёт в строковое представление (конкретный формат решает реализация)
String format(DailyReport report);
}
Реализации обычно лежат где-то в инфраструктуре или рядом с reporting-частью. Для примера, пусть будет так:
package com.example.contextflow.infrastructure.reporting;
import com.example.contextflow.domain.model.DailyReport;
import com.example.contextflow.domain.ports.ReportFormatter;
public class TextReportFormatter implements ReportFormatter {
@Override
public String format(DailyReport report) {
// Простой “человеческий” формат: пригоден для логов и консоли
return "DAILY REPORT: " + report;
}
}
И вторая:
package com.example.contextflow.infrastructure.reporting;
import com.example.contextflow.domain.model.DailyReport;
import com.example.contextflow.domain.ports.ReportFormatter;
public class CsvReportFormatter implements ReportFormatter {
@Override
public String format(DailyReport report) {
// CSV-стиль: сначала заголовок, затем данные отчёта
return """
date,orderCount,cancelledCount,totalAmount
""" + report;
}
}
Теперь ключевой момент. ReportingService должен выглядеть примерно так: он получает один ReportFormatter и просто пользуется им, не решая “какой именно это форматтер”. Пример (упрощённый до сути):
package com.example.contextflow.application.reporting;
import com.example.contextflow.domain.model.DailyReport;
import com.example.contextflow.domain.ports.ReportFormatter;
public class ReportingService {
private final ReportFormatter formatter;
public ReportingService(ReportFormatter formatter) {
// Важно: зависимость — от контракта, а не от конкретной реализации
this.formatter = formatter;
}
public String format(DailyReport report) {
// Бизнес-код “скучный”: просто вызывает контракт
return formatter.format(report);
}
}
Если вы сейчас чувствуете лёгкое разочарование в стиле “и всё?” — поздравляю, вы как раз увидели цель хорошего wiring: бизнес-код становится скучным. Скучный бизнес-код — это часто комплимент, а не оскорбление.
3. ReportFormatterFactoryBean
Сейчас мы подойдём к FactoryBean не как к “хитрому API”, а как к способу спрятать логику создания/выбора туда, где ей и место: в инфраструктуру. Представьте, что ReportFormatterFactoryBean — это бариста. Бариста сам не является кофе, но именно он решает, будет вам латте или эспрессо, и отдаёт результат, а не себя самого.
Создадим фабрику в support.factory. Она будет зависеть только от контрактов и реализаций форматтеров, а не от бизнес-сервисов.
package com.example.contextflow.support.factory;
import org.springframework.beans.factory.FactoryBean;
import com.example.contextflow.domain.ports.ReportFormatter;
import com.example.contextflow.infrastructure.reporting.CsvReportFormatter;
import com.example.contextflow.infrastructure.reporting.TextReportFormatter;
public class ReportFormatterFactoryBean implements FactoryBean<ReportFormatter> {
// Значение по умолчанию: если свойство не задано, берём “text”
private String format = "text";
public void setFormat(String format) {
// Spring установит это значение из properties через конфигурацию
this.format = format;
}
@Override
public ReportFormatter getObject() {
// Здесь и живёт логика выбора конкретной реализации (а не в сервисе)
return "csv".equalsIgnoreCase(format)
? new CsvReportFormatter()
: new TextReportFormatter();
}
@Override
public Class<?> getObjectType() {
// Сообщаем контейнеру тип “продукта”, который производит фабрика
return ReportFormatter.class;
}
@Override
public boolean isSingleton() {
// Один форматтер на всё приложение — логично для данного кейса
return true;
}
}
Здесь важно две вещи.
Во-первых, consumer по-прежнему видит только ReportFormatter: FactoryBean остаётся в support, а наружу контейнер отдаёт результат getObject().
Во-вторых, контейнеру нужна служебная информация о produced object — для этого и существуют getObjectType() и isSingleton(). Благодаря этому ReportingService не знает ни про TextReportFormatter, ни про CsvReportFormatter, ни про сам ReportFormatterFactoryBean. Он получает один контракт и работает дальше.
Это и есть весь смысл конструкции: логика выбора уехала в инфраструктуру, а бизнес-код остался скучным. А скучный бизнес-код, как мы уже знаем, обычно живёт дольше и ломается реже. И да, FactoryBean не нужно писать для всего подряд; здесь он уместен именно потому, что логика выбора стала отдельной инфраструктурной обязанностью.
4. Конфигурация и отличие фабрики от продукта
Теперь нам нужно встроить фабрику в конфигурацию так, чтобы формат выбирался из properties. Здесь мы используем уже знакомую технику: @Configuration, @Bean и @Value с дефолтом. Главное — аккуратно выбрать имя bean-а, потому что в мире FactoryBean имя становится особенно заметным.
Создадим модуль config.reporting.ReportingConfig:
package com.example.contextflow.config.reporting;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.example.contextflow.support.factory.ReportFormatterFactoryBean;
@Configuration
public class ReportingConfig {
@Bean
public ReportFormatterFactoryBean reportFormatter(
@Value("${contextflow.report.format:text}") String format) {
// Создаём фабрику и передаём ей настройку формата из properties
ReportFormatterFactoryBean factory = new ReportFormatterFactoryBean();
factory.setFormat(format);
return factory;
}
}
Обратите внимание на имя метода reportFormatter(...). Для Spring это имя становится bean name. И вот тут начинается интересное: с точки зрения контейнера bean с именем reportFormatter — это FactoryBean, но с точки зрения потребителей reportFormatter ведёт себя как ReportFormatter.
То есть такой код будет работать (и отдаст produced object):
// Получаем продукт фабрики (реальный ReportFormatter), а не саму фабрику
ReportFormatter formatter = context.getBean("reportFormatter", ReportFormatter.class);
А вот если вы захотите получить сам объект фабрики (например, чисто для диагностики), используется специальный префикс &. Да, это именно та самая “магическая амперсанда”, которая нужна ровно в одном месте: когда вы хотите factory-object, а не produced-object.
// Получаем именно объект фабрики (FactoryBean), а не произведённый форматтер
Object factory = context.getBean("&reportFormatter");
Чтобы это почувствовать руками, можно сделать маленькую проверку прямо в main() на этапе запуска (и это как раз тот случай, когда getBean() допустим: мы в bootstrap/diagnostic code, а не в бизнес-сервисе):
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import com.example.contextflow.config.core.ContextFlowAppConfig;
import com.example.contextflow.domain.ports.ReportFormatter;
try (var context = new AnnotationConfigApplicationContext(ContextFlowAppConfig.class)) {
// По имени "reportFormatter" получаем продукт фабрики (TextReportFormatter или CsvReportFormatter)
ReportFormatter formatter = context.getBean("reportFormatter", ReportFormatter.class);
System.out.println(formatter.getClass().getSimpleName()); // TextReportFormatter
// С префиксом "&" получаем сам объект фабрики
Object factory = context.getBean("&reportFormatter");
System.out.println(factory.getClass().getSimpleName()); // ReportFormatterFactoryBean
}
Теперь про properties. В базовом contextflow.properties (или profile-specific файле) у вас появится настройка:
# По умолчанию используем текстовый формат
contextflow.report.format=text
А, например, в contextflow-demo.properties можно сделать:
# В demo-профиле хотим CSV
contextflow.report.format=csv
И вы получите простую и приятную вещь: один и тот же ReportingService остаётся неизменным, а вот состав и поведение приложения в части форматирования отчёта меняется конфигурацией.
5. ContextDiagnosticsBean для диагностики
С диагностикой держим ту же дисциплину. Для проекта зафиксируем явный infrastructure-bean, зарегистрированный через config: так Aware остаётся в support-слое и не выглядит случайным @Component, который просто нашёлся scanning-ом.
Это полезно, когда приложение становится profile-aware и property-heavy: при старте вы сразу видите, в каком режиме поднялся контекст и какой конфиг реально подхватился. Такой bean не должен обрабатывать заказы и не должен искать зависимости. Его роль — смотреть и сообщать.
Сделаем класс в support.diagnostics:
package com.example.contextflow.support.diagnostics;
import org.springframework.beans.factory.BeanNameAware;
import org.springframework.context.EnvironmentAware;
import org.springframework.core.env.Environment;
public class ContextDiagnosticsBean implements BeanNameAware, EnvironmentAware {
private String beanName;
private Environment environment;
@Override
public void setBeanName(String name) {
// Контейнер сообщает bean-у его имя
this.beanName = name;
}
@Override
public void setEnvironment(Environment environment) {
// Контейнер отдаёт доступ к Environment (profiles + properties)
this.environment = environment;
}
public String summary() {
// Берём пару свойств с дефолтами — чисто для быстрой диагностики
String appName = environment.getProperty("contextflow.app-name", "ContextFlow");
String format = environment.getProperty("contextflow.report.format", "text");
String profiles = String.join(",", environment.getActiveProfiles());
if (profiles.isBlank()) {
// Если активных профилей нет, считаем, что работаем в дефолтном режиме
profiles = "default";
}
// Возвращаем одну строку, удобную для лога на старте
return "[" + beanName + "] appName=" + appName
+ ", profiles=" + profiles
+ ", report.format=" + format;
}
}
Здесь мы сделали два Aware-интерфейса, которые чаще всего оказываются полезными в диагностике.
BeanNameAware позволяет контейнеру сказать bean-у: “твое имя вот такое”. Это удобно, чтобы в больших конфигурациях не теряться (особенно если вы используете aliases или явные имена). EnvironmentAware отдаёт нам Environment, который мы отлично знаем с дней про properties, profiles и внешнюю конфигурацию. То есть мы не “влезли в контейнер”, мы просто получили официально переданный объект окружения.
Зарегистрируем диагностический bean через config (в явной форме — чтобы было видно, что это инфраструктура, а не “просто случайный компонент, который нашёлся сканированием”):
package com.example.contextflow.config.core;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.example.contextflow.support.diagnostics.ContextDiagnosticsBean;
@Configuration
public class DiagnosticsConfig {
@Bean
public ContextDiagnosticsBean contextDiagnosticsBean() {
// Явно создаём диагностический bean (инфраструктура/поддержка)
return new ContextDiagnosticsBean();
}
}
В проекте фиксируем именно этот вариант: отдельный support-bean + явная регистрация через DiagnosticsConfig.
И теперь в bootstrap-коде можно сделать так:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import com.example.contextflow.config.core.ContextFlowAppConfig;
import com.example.contextflow.support.diagnostics.ContextDiagnosticsBean;
try (var context = new AnnotationConfigApplicationContext(ContextFlowAppConfig.class)) {
// Это bootstrap/diagnostic code: getBean() здесь уместен
ContextDiagnosticsBean diag = context.getBean(ContextDiagnosticsBean.class);
System.out.println(diag.summary());
// [contextDiagnosticsBean] appName=ContextFlow, profiles=dev, report.format=text
}
Эта строка в начале запуска — как наклейка на коробке: “внутри именно то, что вы хотели”. И да, иногда это спасает больше времени, чем самый умный дебаггер: дебаггер хорош, но он не умеет читать ваши мысли о том, какой профиль вы “точно включили”.
6. Общая картина и структура пакетов
Теперь соберём всё в одну понятную картину: где эти классы лежат, кто кого знает, и почему это не превращает ContextFlow в “приложение, которое думает только о Spring”. В этом месте полезно посмотреть на проект как на карту города: бизнес-улицы — в domain и application, технические коммуникации — в support и config.
Если опираться на целевую структуру курса, нужные нам части выглядят так:
com.example.contextflow
├── application
│ └── reporting
│ └── ReportingService
├── domain
│ └── ports
│ └── ReportFormatter
├── infrastructure
│ └── reporting
│ ├── TextReportFormatter
│ └── CsvReportFormatter
├── support
│ ├── factory
│ │ └── ReportFormatterFactoryBean
│ └── diagnostics
│ └── ContextDiagnosticsBean
└── config
├── core
│ ├── DiagnosticsConfig
│ └── ContextFlowAppConfig
└── reporting
└── ReportingConfig
Чтобы это ещё лучше уложилось, вот маленькая схема зависимостей (без “лишних стрелок”):
flowchart TD RS["ReportingService
(application)"] --> RF["ReportFormatter
(port)"] subgraph Support["support слой"] FBF["ReportFormatterFactoryBean
(FactoryBean)"] -->|"getObject()"| RF DIAG["ContextDiagnosticsBean
(Aware)"] end subgraph Config["config слой"] RC["ReportingConfig"] --> FBF DC["DiagnosticsConfig"] --> DIAG end ENV["Environment
(properties + profiles)"] --> RC ENV --> DIAG
Обратите внимание на полезную “тишину” в этой схеме: ReportingService не знает ни про фабрику, ни про Environment, ни про ApplicationContext. Он знает только про ReportFormatter, и это именно то состояние, к которому мы стремимся.
А ContextDiagnosticsBean знает про Environment, но это нормально, потому что его работа — диагностика окружения. Он не выбирает формат отчёта, он его показывает. Выбор делает конфигурация + фабрика.
На практике вы увидите примерно такой эффект: при старте приложения в профиле dev диагностика скажет report.format=text, а при старте в demo — report.format=csv. И ReportingService при этом останется одним и тем же классом, без единой строчки “если demo — то …”.
Это и есть рабочий snapshot ContextFlow: выбор форматтера и диагностика живут в support/config, а ReportingService остаётся обычным consumer-ом ReportFormatter.
7. Типичные ошибки при FactoryBean и Aware
Ошибка №1: делать ReportingService зависимым от ReportFormatterFactoryBean.
Это очень соблазнительно: “ну раз фабрика умеет делать форматтер, давайте я её и заинжекчу”. Но тогда ваш сервис начинает знать про контейнерный механизм. У него появится знание “у меня есть фабрика”, а значит вы теряете главную пользу FactoryBean: consumer должен видеть только produced object (ReportFormatter) и жить спокойной жизнью.
Ошибка №2: не понимать, что по имени bean-а вы получаете produced object, а не фабрику.
Классическая ситуация: вы пишете context.getBean("reportFormatter"), ожидаете фабрику, а получаете TextReportFormatter. Это не “Spring сломался”, это его нормальная логика для FactoryBean. Если вам реально нужен объект фабрики, используйте &reportFormatter. И старайтесь, чтобы этот трюк оставался в диагностике/инфраструктуре, а не переезжал в бизнес-слой.
Ошибка №3: забыть корректно реализовать getObjectType() и получить странные wiring-ошибки.
Если getObjectType() возвращает слишком общий тип, null или что-то не то, контейнеру сложнее делать автосвязывание и диагностику. В учебном проекте это проявится как “почему контейнер не видит мой ReportFormatter”, а в реальном проекте — как “почему это работает только в одном профиле”. Держите этот метод честным: если фабрика производит ReportFormatter, пусть так и скажет.
Ошибка №4: использовать Aware как замену constructor injection.
Aware — это не “более мощная инъекция”, это специальный канал для контейнерной информации. Как только вы начинаете делать ApplicationContextAware в обычном сервисе “чтобы не писать конструктор”, вы отрезаете себе прозрачность зависимостей и возвращаетесь к скрытому графу. В итоге класс выглядит простым, но внутри у него спрятан доступ к миру.
Ошибка №5: превращать ContextDiagnosticsBean в “бизнес-сервис с правами администратора”.
Иногда диагностический bean начинает обрастать методами “а давай-ка я ещё создам заказ”, “а давай-ка я подменю формат отчёта на лету”. Это уже не диагностика, это второй слой приложения с непонятной ответственностью. Диагностический bean должен быть скучным: прочитал пару свойств, выдал строку, максимум — проверил наличие каких-то условий.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ