Кастомизация резолвинга зависимостей в Spring

в 6:57, , рубрики: java, spring, spring framework, Блог компании Сбербанк, Программирование, разработка

Привет! Меня зовут Андрей Неведомский и я главный инженер в СберТехе. Я работаю в команде, которая занимается разработкой одного из системных сервисов ЕФС (Единой Фронтальной Системы). В своей работе мы активно используем Spring Framework, в частности его DI, и время от времени сталкиваемся с тем, что резолвинг зависимостей в спринге оказывается недостаточно «умным» для нас. Эта статья – результат моих попыток сделать его умнее и в целом разобраться с тем, как он работает. Надеюсь, и вы сможете узнать из неё что-то новое об устройстве спринга.

Кастомизация резолвинга зависимостей в Spring - 1

Перед прочтением статьи настоятельно рекомендую ознакомиться с докладами Евгения EvgenyBorisov Борисова: Spring-потрошитель, часть 1; Spring-потрошитель, часть 2. Ещё есть плейлист из них.

Введение

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

  • Globa, который будет имплементировать интерфейс FortuneTeller и заниматься предсказанием судьбы;

    Кастомизация резолвинга зависимостей в Spring - 2

  • Gypsy, которая будет имплементировать интерфейс HoroscopeTeller и заниматься составлением гороскопов.

    Кастомизация резолвинга зависимостей в Spring - 3

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

RestrictionAspect.java

@Aspect
@Component
@Slf4j
public class RestrictionAspect {

    private final Predicate<String> ipIsAllowed;

    public RestrictionAspect(@NonNull final Predicate<String> ipIsAllowed) {
        this.ipIsAllowed = ipIsAllowed;
    }

    @Before("execution(public * com.github.monosoul.fortuneteller.web.*.*(..))")
    public void checkAccess() {
        val ip = getRequestSourceIp();
        log.debug("Source IP: {}", ip);

        if (!ipIsAllowed.test(ip)) {
            throw new AccessDeniedException(format("Access for IP [%s] is denied", ip));
        }
    }

    private String getRequestSourceIp() {
        val requestAttributes = currentRequestAttributes();
        Assert.state(requestAttributes instanceof ServletRequestAttributes,
                "RequestAttributes needs to be a ServletRequestAttributes");

        val request = ((ServletRequestAttributes) requestAttributes).getRequest();

        return request.getRemoteAddr();
    }
}

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

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

Как это тестировать?

Давайте поговорим о том, как нам протестировать корректность применения аспектов. У нас есть несколько способов сделать это.

Можно написать отдельные тесты на аспект и на контроллеры, без поднятия спрингового контекста (который как раз создаст прокси с аспектом для контроллера, подробнее об этом можно почитать в официальной документации, но в этом случае мы не протестируем именно то, что аспекты корректно применяются к контроллерам и работают именно так, как мы этого ожидаем;

Можно написать тесты, в которых мы будем поднимать полный контекст нашего приложения, но в этом случае:

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

Но мы-то хотим протестировать именно то, что аспект применился и выполняет свою работу. Мы не хотим тестировать сервисы, вызываемые контроллером, и потому не хотим озадачиваться тестовыми данными и жертвовать временем запуска. Поэтому напишем тесты, в которых будем поднимать только часть контекста. Т.е. в нашем контексте будет реальный бин аспекта и реальный бин контроллера, а всё остальное – будет моками.

Как создавать бины-моки?

Создавать бины-моки в спринге мы можем несколькими способами. Для наглядности в качестве примера мы возьмём один из контроллеров нашего сервиса – PersonalizedHoroscopeTellController, код его выглядит так:

PersonalizedHoroscopeTellController.java

@Slf4j
@RestController
@RequestMapping(
        value = "/horoscope",
        produces = APPLICATION_JSON_UTF8_VALUE
)
public class PersonalizedHoroscopeTellController {

    private final HoroscopeTeller horoscopeTeller;
    private final Function<String, ZodiacSign> zodiacSignConverter;
    private final Function<String, String> nameNormalizer;

    public PersonalizedHoroscopeTellController(
            final HoroscopeTeller horoscopeTeller,
            final Function<String, ZodiacSign> zodiacSignConverter,
            final Function<String, String> nameNormalizer
    ) {
        this.horoscopeTeller = horoscopeTeller;
        this.zodiacSignConverter = zodiacSignConverter;
        this.nameNormalizer = nameNormalizer;
    }

    @GetMapping(value = "/tell/personal/{name}/{sign}")
    public PersonalizedHoroscope tell(@PathVariable final String name, @PathVariable final String sign) {
        log.info("Received name: {}; sign: {}", name, sign);

        return PersonalizedHoroscope.builder()
                                    .name(
                                            nameNormalizer.apply(name)
                                    )
                                    .horoscope(
                                            horoscopeTeller.tell(
                                                    zodiacSignConverter.apply(sign)
                                            )
                                    )
                                    .build();
    }
}

Java Config с зависимостями в каждом тесте

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

В этом случае тест для нашего контроллера будет выглядеть так:

javaconfig/PersonalizedHoroscopeTellControllerTest.java

@SpringJUnitConfig
public class PersonalizedHoroscopeTellControllerTest {

    private static final int LIMIT = 10;

    @Autowired
    private PersonalizedHoroscopeTellController controller;
    @Autowired
    private Predicate<String> ipIsAllowed;

    @Test
    void doNothingWhenAllowed() {
        when(ipIsAllowed.test(anyString())).thenReturn(true);

        controller.tell(randomAlphabetic(LIMIT), randomAlphabetic(LIMIT));
    }

    @Test
    void throwExceptionWhenNotAllowed() {
        when(ipIsAllowed.test(anyString())).thenReturn(false);

        assertThatThrownBy(() -> controller.tell(randomAlphabetic(LIMIT), randomAlphabetic(LIMIT)))
                .isInstanceOf(AccessDeniedException.class);
    }

    @Configuration
    @Import(AspectConfiguration.class)
    @EnableAspectJAutoProxy
    public static class Config {

        @Bean
        public PersonalizedHoroscopeTellController personalizedHoroscopeTellController(
                final HoroscopeTeller horoscopeTeller,
                final Function<String, ZodiacSign> zodiacSignConverter,
                final Function<String, String> nameNormalizer
        ) {
            return new PersonalizedHoroscopeTellController(horoscopeTeller, zodiacSignConverter, nameNormalizer);
        }

        @Bean
        public HoroscopeTeller horoscopeTeller() {
            return mock(HoroscopeTeller.class);
        }

        @Bean
        public Function<String, ZodiacSign> zodiacSignConverter() {
            return mock(Function.class);
        }

        @Bean
        public Function<String, String> nameNormalizer() {
            return mock(Function.class);
        }
    }
}

Выглядит такой тест достаточно громоздко. В этом случае нам придётся написать Java Config для каждого из контроллеров. Хотя он будет разным по содержанию, смысл у него будет один и тот же: создать бин контроллера и моки для его зависимостей. Так что по сути он будет одинаковым для всех контроллеров. Я, как и любой программист, человек ленивый, поэтому от такого варианта отказался сразу же.

Аннотация @MockBean над каждым полем с зависимостью

Аннотация @MockBean появилась в Spring Boot Test версии 1.4.0. Она похожа на @Mock из Mockito (и на самом деле она даже использует его внутри), с той лишь разницей, что при использовании @MockBean, созданный мок будет автоматически помещён в спринговый контекст. Такой способ объявления моков будет декларативным, поскольку нам не придется говорить спрингу, как именно нужно создавать эти моки.

В этом случае тест будет выглядеть так:

mockbean/PersonalizedHoroscopeTellControllerTest.java

@SpringJUnitConfig
public class PersonalizedHoroscopeTellControllerTest {

    private static final int LIMIT = 10;

    @MockBean
    private HoroscopeTeller horoscopeTeller;
    @MockBean
    private Function<String, ZodiacSign> zodiacSignConverter;
    @MockBean
    private Function<String, String> nameNormalizer;
    @MockBean
    private Predicate<String> ipIsAllowed;
    @Autowired
    private PersonalizedHoroscopeTellController controller;

    @Test
    void doNothingWhenAllowed() {
        when(ipIsAllowed.test(anyString())).thenReturn(true);

        controller.tell(randomAlphabetic(LIMIT), randomAlphabetic(LIMIT));
    }

    @Test
    void throwExceptionWhenNotAllowed() {
        when(ipIsAllowed.test(anyString())).thenReturn(false);

        assertThatThrownBy(() -> controller.tell(randomAlphabetic(LIMIT), randomAlphabetic(LIMIT)))
                .isInstanceOf(AccessDeniedException.class);
    }

    @Configuration
    @Import({PersonalizedHoroscopeTellController.class, RestrictionAspect.class, RequestContextHolderConfigurer.class})
    @EnableAspectJAutoProxy
    public static class Config {

    }
}

В этом варианте по-прежнему есть Java Config, но он значительно компактнее. Из недостатков – мне пришлось объявить поля с зависимостями контроллера (поля с аннотацией @MockBean), даже несмотря на то, что дальше в тесте они никак не используются. Ну и в случае, если вы используете по какой-то причине Spring Boot версии ниже чем 1.4.0, то воспользоваться этой аннотацией вы не сможете.

Поэтому у меня родилась идея для ещё одного варианта мокирования. Мне хотелось бы, чтобы это работало так…

Аннотация @Automocked над зависимым компонентом

Мне бы хотелось, чтобы у нас была аннотация @Automocked, которую я мог бы поставить только над полем с контроллером, а дальше для этого контроллера были бы автоматически созданы моки и помещены в контекст.

Тест в этом случае мог бы выглядеть так:

automocked/PersonalizedHoroscopeTellControllerTest.java

@SpringJUnitConfig
@ContextConfiguration(classes = AspectConfiguration.class)
@TestExecutionListeners(listeners = AutomockTestExecutionListener.class, mergeMode = MERGE_WITH_DEFAULTS)
public class PersonalizedHoroscopeTellControllerTest {

    private static final int LIMIT = 10;

    @Automocked
    private PersonalizedHoroscopeTellController controller;
    @Autowired
    private Predicate<String> ipIsAllowed;

    @Test
    void doNothingWhenAllowed() {
        when(ipIsAllowed.test(anyString())).thenReturn(true);

        controller.tell(randomAlphabetic(LIMIT), randomAlphabetic(LIMIT));
    }

    @Test
    void throwExceptionWhenNotAllowed() {
        when(ipIsAllowed.test(anyString())).thenReturn(false);

        assertThatThrownBy(() -> controller.tell(randomAlphabetic(LIMIT), randomAlphabetic(LIMIT)))
                .isInstanceOf(AccessDeniedException.class);
    }
}

Как видите, этот вариант – самый компактный из представленных, здесь есть только бин контроллера (плюс предикат для аспекта), над которым стоит аннотация @Automocked, а вся магия по созданию бинов и размещении их в контексте написана один раз и может быть использована во всех тестах.

Как это работает?

Давайте разберемся, как это работает и что нам для этого нужно.

TestExecutionListener

В спринге есть такой интерфейс – TestExecutionListener. Он предоставляет API для встраивания в процесс выполнения теста на разных его этапах, например при создании инстанса тестового класса, перед вызовом тестового метода или после него и т.д. У него есть несколько имплементаций «из коробки». Например DirtiesContextTestExecutionListener, которая выполняет очистку контекста в случае, если вы поставили соответствующую аннотацию; DependencyInjectionTestExecutionListener – выполняет инъекцию зависимостей в тестах и т.д. Чтобы применить ваш кастомный Listener к тесту, нужно поставить над ним аннотацию @TestExecutionListeners и указать вашу имплементацию.

Ordered

Также в спринге есть интерфейс Ordered. Он используется для указания на то, что объекты должны быть некоторым образом упорядочены. Например, когда у вас есть несколько имплементаций одного и того же интерфейса и вы хотите заинжектить их в коллекцию, то в этой коллекции они будут упорядочены в соответствии с Ordered. В случае с TestExecutionListener’ами эта аннотация указывает на то, в каком порядке они должны применяться.

Итак, наш Listener будет имплементировать 2 интерфейса: TestExecutionListener и Ordered. Назовём мы его AutomockTestExecutionListener и выглядеть он будет так:

AutomockTestExecutionListener.java

@Slf4j
public class AutomockTestExecutionListener implements TestExecutionListener, Ordered {

    @Override
    public int getOrder() {
        return 1900;
    }

    @Override
    public void prepareTestInstance(final TestContext testContext) {
        val beanFactory = ((DefaultListableBeanFactory) testContext.getApplicationContext().getAutowireCapableBeanFactory());
        setByNameCandidateResolver(beanFactory);

        for (val field : testContext.getTestClass().getDeclaredFields()) {
            if (field.getAnnotation(Automocked.class) == null) {
                continue;
            }
            log.debug("Performing automocking for the field: {}", field.getName());

            makeAccessible(field);
            setField(
                    field,
                    testContext.getTestInstance(),
                    createBeanWithMocks(findConstructorToAutomock(field.getType()), beanFactory)
            );
        }
    }

    private void setByNameCandidateResolver(final DefaultListableBeanFactory beanFactory) {
        if ((beanFactory.getAutowireCandidateResolver() instanceof AutomockedBeanByNameAutowireCandidateResolver)) {
            return;
        }
        beanFactory.setAutowireCandidateResolver(
                new AutomockedBeanByNameAutowireCandidateResolver(beanFactory.getAutowireCandidateResolver())
        );
    }

    private Constructor<?> findConstructorToAutomock(final Class<?> clazz) {
        log.debug("Looking for suitable constructor of {}", clazz.getCanonicalName());

        Constructor<?> fallBackConstructor = clazz.getDeclaredConstructors()[0];
        for (val constructor : clazz.getDeclaredConstructors()) {
            if (constructor.getParameterTypes().length > fallBackConstructor.getParameterTypes().length) {
                fallBackConstructor = constructor;
            }

            val autowired = getAnnotation(constructor, Autowired.class);
            if (autowired != null) {
                return constructor;
            }
        }

        return fallBackConstructor;
    }

    private <T> T createBeanWithMocks(final Constructor<T> constructor, final DefaultListableBeanFactory beanFactory) {
        createMocksForParameters(constructor, beanFactory);

        val clazz = constructor.getDeclaringClass();
        val beanName = forClass(clazz).toString();
        log.debug("Creating bean {}", beanName);

        if (!beanFactory.containsBean(beanName)) {
            val bean = beanFactory.createBean(clazz);
            beanFactory.registerSingleton(beanName, bean);
        }

        return beanFactory.getBean(beanName, clazz);
    }

    private <T> void createMocksForParameters(final Constructor<T> constructor, final DefaultListableBeanFactory beanFactory) {
        log.debug("{} is going to be used for auto mocking", constructor);

        val constructorArgsAmount = constructor.getParameterTypes().length;

        for (int i = 0; i < constructorArgsAmount; i++) {
            val parameterType = forConstructorParameter(constructor, i);
            val beanName = parameterType.toString();

            if (!beanFactory.containsBean(beanName)) {
                beanFactory.registerSingleton(
                        beanName,
                        mock(parameterType.resolve(), withSettings().stubOnly())
                );
            }

            log.debug("Mocked {}", beanName);
        }
    }
}

Что здесь происходит? Для начала в методе prepareTestInstance() он находит все поля с аннотацией @Automocked:

for (val field : testContext.getTestClass().getDeclaredFields()) {
    if (field.getAnnotation(Automocked.class) == null) {
        continue;
    }

Затем делает эти поля доступными для записи:

makeAccessible(field);

Затем в методе findConstructorToAutomock() находит подходящий конструктор:

Constructor<?> fallBackConstructor = clazz.getDeclaredConstructors()[0];
for (val constructor : clazz.getDeclaredConstructors()) {
    if (constructor.getParameterTypes().length > fallBackConstructor.getParameterTypes().length) {
        fallBackConstructor = constructor;
    }

    val autowired = getAnnotation(constructor, Autowired.class);
    if (autowired != null) {
        return constructor;
    }
}

return fallBackConstructor;

Подходящим в нашем случае будет либо конструктор с аннотацией @Autowired, либо конструктор с наибольшим количеством аргументов.

Затем найденный конструктор передается в качестве аргумента методу createBeanWithMocks(), который в свою очередь вызывает метод createMocksForParameters(), где происходит создание моков для аргументов конструктора и их регистрация в контексте:

val constructorArgsAmount = constructor.getParameterTypes().length;

for (int i = 0; i < constructorArgsAmount; i++) {
    val parameterType = forConstructorParameter(constructor, i);
    val beanName = parameterType.toString();

    if (!beanFactory.containsBean(beanName)) {
        beanFactory.registerSingleton(
                beanName,
                mock(parameterType.resolve(), withSettings().stubOnly())
        );
    }
}

При этом важно отметить, что в качестве имени бина будет использоваться строковое представление типа аргумента (вместе с дженериками). То есть для аргумента типа packages.Function<String, String> строковым представление станет строка "packages.Function<java.lang.String, java.lang.String>". Это важно, мы к этому ещё вернёмся.

После создания моков для всех аргументов и регистрации их в контексте мы возвращаемся к созданию бина зависимого класса (т.е. контроллера в нашем случае):

if (!beanFactory.containsBean(beanName)) {
    val bean = beanFactory.createBean(clazz);
    beanFactory.registerSingleton(beanName, bean);
}

Также следует обратить внимание на то, что мы использовали Order 1900. Это нужно потому, что наш Listener должен вызываться после очистки контекста DirtiesContextBeforeModesTestExecutionListener'ом (order=1500) и до инъекции зависимостей DependencyInjectionTestExecutionListener’ом (order=2000), потому что наш Listener создаёт новые бины.

AutowireCandidateResolver

AutowireCandidateResolver используется для определения того, соответствует ли BeanDefainition описанию зависимости. У него есть несколько имплементаций «из коробки», среди них:

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

Работает резолвер следующим образом:

  1. Спринг берёт дескриптор зависимости – DependencyDescriptor;
  2. Затем берёт все BeanDefinition’ы подходящего класса;
  3. Перебирает полученные BeanDefinition’ы, вызывая метод isAutowireCandidate() резолвера;
  4. В зависимости от того, подходит описание бина под описание зависимости или нет, метод возвращает true или false.

Зачем понадобился свой резолвер?

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

public class PersonalizedHoroscopeTellController {

    private final HoroscopeTeller horoscopeTeller;
    private final Function<String, ZodiacSign> zodiacSignConverter;
    private final Function<String, String> nameNormalizer;

    public PersonalizedHoroscopeTellController(
            final HoroscopeTeller horoscopeTeller,
            final Function<String, ZodiacSign> zodiacSignConverter,
            final Function<String, String> nameNormalizer
    ) {
        this.horoscopeTeller = horoscopeTeller;
        this.zodiacSignConverter = zodiacSignConverter;
        this.nameNormalizer = nameNormalizer;
    }

Как видите, у него есть две зависимости одинакового типа – Function, но с разными дженериками. В одном случае – String и ZodiacSign, в другом – String и String. И проблема с этим в том, что Mockito не умеет в дженерики. Т.е. если мы создадим моки для этих зависимостей и поместим их в контекст, то Spring не сможет заинжектить их в этот класс, так как они не будут содержать информации о дженериках. И мы увидим исключение о том, что в контексте есть более одного бина класса Function. Как раз эту проблемы мы и решим с помощью своего резолвера. Ведь, как вы помните, в нашей имплементации Listener’а мы использовали в качестве имени бина тип с дженериками, а значит всё, что нам нужно сделать – это научить спринг сравнивать тип зависимости с именем бина.

AutomockedBeanByNameAutowireCandidateResolver

Итак, наш резолвер будет делать именно то, о чем я писал выше, а имплементация метода isAutowireCandidate() будет выглядеть так:

AutowireCandidateResolver.isAutowireCandidate()

@Override
public boolean isAutowireCandidate(BeanDefinitionHolder beanDefinitionHolder, DependencyDescriptor descriptor) {
    val dependencyType = descriptor.getResolvableType().resolve();
    val dependencyTypeName = descriptor.getResolvableType().toString();

    val candidateBeanDefinition = (AbstractBeanDefinition) beanDefinitionHolder.getBeanDefinition();
    val candidateTypeName = beanDefinitionHolder.getBeanName();

    if (candidateTypeName.equals(dependencyTypeName) && candidateBeanDefinition.getBeanClass() != null) {
        return true;
    }

    return candidateResolver.isAutowireCandidate(beanDefinitionHolder, descriptor);
}

Здесь он получает строковое представление типа зависимости из описания зависимости, получает имя бина из BeanDefinition’а (которое уже содержит строковое представление типа бина), затем сравнивает их и если совпали – возвращает true. Если не совпали – делегирует внутреннему резолверу.

Варианты мокирования бинов в тестах

Итого, в тестах мы можем использовать следующие варианты мокирования бинов:

  • Java Config – это будет императивно, громоздко, с boilerplate’ом, но, пожалуй, максимально информативно;
  • @MockBean – будет декларативно, менее громоздко, чем Java Config, но по прежнему будет небольшой boilerplate в виде полей с зависимостями, которые в самом тесте никак не используются;
  • @Automocked + кастомный резолвер – минимум кода в тестах и boilerplate, но потенциально довольно узкая область применения и это ещё нужно написать. Но может быть очень удобно там, где вы хотите убедиться, что спринг корректно создает прокси.

Добавим декораторов

Мы в нашей команде очень любим шаблон проектирования «Декоратор» за его гибкость. По сути аспекты реализуют именно этот шаблон. Но в случае, если вы конфигурируете контекст спринга аннотациями и используете package scan, вы столкнетесь с проблемой. Если у вас в контексте окажется несколько имплементаций одного и того же интерфейса, то при старте приложения вывалится NoUniqueBeanDefinitionException, т.е. спринг не сможет разобраться какой из бинов куда должен инжектиться. У этой проблемы есть несколько вариантов решения и дальше мы посмотрим на них, но сначала давайте разберемся, как изменится наше приложение.

Сейчас у интерфейсов FortuneTeller и HoroscopeTeller есть по одной имплементации, мы добавим еще по 2 имплементации для каждого из интерфейсов:

Кастомизация резолвинга зависимостей в Spring - 4

  • Caching… — кэширующий декоратор;
  • Logging… — логирующий декоратор.

Так как же решить проблему определения порядка бинов?

Java Config с верхнеуровневым декоратором

Можно снова воспользоваться Java Config’ом. В этом случае мы будем описывать бины в виде методов класса-конфига, причем аргументы, необходимые для вызова конструктора бина, нам придётся указать как аргументы метода. Из чего следует, что в случае изменения конструктора бина нам придётся менять и конфиг, что не очень круто. Из достоинств такого варианта:

  • между декораторами будет низкая связность, т.к. связь между ними будет описана в конфиге, т.е. друг о друге они ничего знать не будут;
  • все изменения порядка декораторов будут локализованы в одном месте – конфиге.

В нашем случае Java Config будет выглядеть так:

DomainConfig.java

@Configuration
public class DomainConfig {

    @Bean
    public FortuneTeller fortuneTeller(
            final Map<FortuneRequest, FortuneResponse> cache,
            final FortuneResponseRepository fortuneResponseRepository,
            final Function<FortuneRequest, PersonalData> personalDataExtractor,
            final PersonalDataRepository personalDataRepository
    ) {
        return new LoggingFortuneTeller(
                new CachingFortuneTeller(
                        new Globa(fortuneResponseRepository, personalDataExtractor, personalDataRepository),
                        cache
                )
        );
    }

    @Bean
    public HoroscopeTeller horoscopeTeller(
            final Map<ZodiacSign, Horoscope> cache,
            final HoroscopeRepository horoscopeRepository
    ) {
        return new LoggingHoroscopeTeller(
                new CachingHoroscopeTeller(
                        new Gypsy(horoscopeRepository),
                        cache
                )
        );
    }
}

Как видите, для каждого из интерфейсов здесь объявлен только один бин, а методы содержат в аргументах зависимости всех объектов, создаваемых внутри.  В этом случае логика создания бинов достаточно очевидна.

Qualifier

Можно использовать аннотацию @Qualifier. Это будет более декларативно, чем Java Config, но в этом случае нужно будет явно указать имя бина, от которого зависит текущий бин. Из чего следует недостаток: повышается связность между бинами. А поскольку повышается связность, то и в случае изменения порядка декораторов изменения будут размазаны ровным слоем по коду. То есть в случае добавления нового декоратора, например, в середину цепочки, изменения затронут минимум 2 класса.

LoggingFortuneTeller.java

@Primary
@Component
public final class LoggingFortuneTeller implements FortuneTeller {

    private final FortuneTeller internal;
    private final Logger logger;

    public LoggingFortuneTeller(
            @Qualifier("cachingFortuneTeller")
            @NonNull final FortuneTeller internal
    ) {
        this.internal = internal;
        this.logger = getLogger(internal.getClass());
    }

На примере логирующего декоратора для предсказателя судьбы видно, что, поскольку он у нас является верхнеуровневым (именно он должен инжектиться во все классы, использующие FortuneTeller, например в контроллеры), то над ним стоит аннотация @Primary. А над аргументом конструктора internal стоит аннотация @Qualifier с указанием имени бина, от которого он зависит — cachingFortuneTeller. В нашем случае в него должен инжектиться кэширующий декоратор.

Custom qualifier

Начиная с версии 2.5 спринг предоставляет возможность для объявления собственных Qualifier’ов, чем мы можем воспользоваться. Выглядеть это может следующим образом.

Для начала мы объявим enum с типами декораторов:

public enum DecoratorType {
    LOGGING,
    CACHING,
    NOT_DECORATOR
}

Затем объявим свою аннотацию, которая будет являться qualifier’ом:

@Qualifier
@Retention(RUNTIME)
public @interface Decorator {

    DecoratorType value() default NOT_DECORATOR;
}

Обратите внимание: чтобы ваша аннотация работала, нужно либо поставить над ней аннотацию @Qualifier, либо объявить бин CustomAutowireConfigurer, которому как раз можно передать класс вашей аннотации.

Ну а сами декораторы в случае использования кастомного Qualifier’а будут выглядеть так:

CachingFortuneTeller.java

@Decorator(CACHING)
@Component
public final class CachingFortuneTeller implements FortuneTeller {

    private final FortuneTeller internal;
    private final Map<FortuneRequest, FortuneResponse> cache;

    public CachingFortuneTeller(
            @Decorator(NOT_DECORATOR)
            final FortuneTeller internal,
            final Map<FortuneRequest, FortuneResponse> cache
    ) {
        this.internal = internal;
        this.cache = cache;
    }

Кэширующий декоратор – средний в нашей цепочке, поэтому над ним стоит аннотация @Decorator с указанием того, что он кэширующий, а в его конструкторе – та же аннотация с указанием того, что в него должен инжектиться не декоратор, то есть дефолтная имплементация FortuneTeller’а, в нашем случае – Globa.

Выглядит такой вариант по сравнению с Qualifier’ами чем-то лучше, чем-то хуже. Хуже, потому что теперь аннотацию нужно ставить не только в конструкторе, но и над самим классом. Лучше, потому что связность между бинами всё-таки стала чуть ниже – бины теперь не знают имя декоратора, который должен инжектиться в них, они знают только тип этого декоратора.

DecoratorAutowireCandidateResolver

Последний вариант – написать свой резолвер! Ведь мы здесь именно для этого! :) Мне бы хотелось, чтобы у нас был какой-то способ явно объявить порядок декораторов, как в Java Config’е, при этом не объявляя все зависимости этих декораторов. Например, с помощью какого-то кастомного бина в конфиге, который бы содержал порядок декораторов. Выглядеть это могло бы так:

DomainConfig.java

@Configuration
public class DomainConfig {

    @Bean
    public OrderConfig<FortuneTeller> fortuneTellerOrderConfig() {
        return () -> asList(
                LoggingFortuneTeller.class,
                CachingFortuneTeller.class,
                Globa.class
        );
    }

    @Bean
    public OrderConfig<HoroscopeTeller> horoscopeTellerOrderConfig() {
        return () -> asList(
                LoggingHoroscopeTeller.class,
                CachingHoroscopeTeller.class,
                Gypsy.class
        );
    }
}

Выглядит вроде бы неплохо – мы получаем преимущества Java Config’а в виде более явного объявления и локализованности, при этом избавляемся от его недостатка – громоздкости. Посмотрим, что нам для этого понадобится!

Для начала нам понадобится какой-то способ объявления порядка. Им может быть, например, интерфейс с одним методом, который бы возвращал упорядоченный список классов. Выглядеть он может так:

@FunctionalInterface
public interface OrderConfig<T> {

    List<Class<? extends T>> getClasses();
}

BeanDefinitionRegistryPostProcessor

Также нам понадобится BeanDefinitionRegistryPostProcessor, который расширяет BeanFactoryPostProcessor, вызывается до него, и, согласно документации, может использоваться для регистрации новых BeanDefinition’ов. Не то, чтобы для этого нельзя использовать BeanFactoryPostProcessor, просто так кажется правильней.

Делать он будет следующее:

  • переберет все BeanDefinition’ы;
  • удалит BeanDefinition’ы классов, объявленных внутри OrderConfig’ов. Это нужно, т.к. эти классы могут иметь аннотации стереотипов спринга и для них могли быть созданы BeanDefinition’ы при сканировании пакетов;
  • создаст для классов, объявленных внутри OrderConfig’ов, новые BeanDefinition’ы, которые будут содержать аннотцию, указывающую на родительский бин (декоратор) класса.

BeanFactoryPostProcessor

Не обойдём мы стороной и BeanFactoryPostProcessor, который используется для манипуляции с BeanDefinition’ами до того, как начнется инициализация бинов. Пожалуй, наиболее известен этот класс как «жертва Spring-потрошителя».

Кастомизация резолвинга зависимостей в Spring - 5

Всё, что он будет делать для нас, – это регистрировать в фабрике нашу имплементацию AutowireCandidateResolver’а:

DecoratorAutowireCandidateResolverConfigurer.java

@Component
class DecoratorAutowireCandidateResolverConfigurer implements BeanFactoryPostProcessor {

    @Override
    public void postProcessBeanFactory(final ConfigurableListableBeanFactory configurableListableBeanFactory) throws BeansException {
        Assert.state(configurableListableBeanFactory instanceof DefaultListableBeanFactory,
                "BeanFactory needs to be a DefaultListableBeanFactory");
        val beanFactory = (DefaultListableBeanFactory) configurableListableBeanFactory;

        beanFactory.setAutowireCandidateResolver(
                new DecoratorAutowireCandidateResolver(beanFactory.getAutowireCandidateResolver())
        );
    }
}

DecoratorAutowireCandidateResolver

Ну а сам резолвер будет выглядеть так:

DecoratorAutowireCandidateResolver.java

@RequiredArgsConstructor
public final class DecoratorAutowireCandidateResolver implements AutowireCandidateResolver {

    private final AutowireCandidateResolver resolver;

    @Override
    public boolean isAutowireCandidate(final BeanDefinitionHolder bdHolder, final DependencyDescriptor descriptor) {
        val dependentType = descriptor.getMember().getDeclaringClass();
        val dependencyType = descriptor.getDependencyType();
        val candidateBeanDefinition = (AbstractBeanDefinition) bdHolder.getBeanDefinition();

        if (dependencyType.isAssignableFrom(dependentType)) {
            val candidateQualifier = candidateBeanDefinition.getQualifier(OrderQualifier.class.getTypeName());
            if (candidateQualifier != null) {
                return dependentType.getTypeName().equals(candidateQualifier.getAttribute("value"));
            }
        }

        return resolver.isAutowireCandidate(bdHolder, descriptor);
    }

Он получает из descriptor’а тип зависимости (dependencyType) и тип зависимого класса (dependentType):

val dependentType = descriptor.getMember().getDeclaringClass();
val dependencyType = descriptor.getDependencyType();

Затем получает из bdHolder’а BeanDefinition:

val candidateBeanDefinition = (AbstractBeanDefinition) bdHolder.getBeanDefinition();

Сравнивает тип зависимости и тип зависимого класса. Таким образом мы проверяем, что имеем дело с декоратором:

dependencyType.isAssignableFrom(dependentType)

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

Получает из BeanDefinition’а аннотацию с указанием на класс родительского декоратора:

val candidateQualifier = candidateBeanDefinition.getQualifier(OrderQualifier.class.getTypeName());

И если аннотация есть, то сравнивает указанный в ней класс с зависимым классом:

if (candidateQualifier != null) {
    return dependentType.getTypeName().equals(candidateQualifier.getAttribute("value"));
}

Если они совпали – мы нашли подходящий бин (декоратор), если нет – возвращаем false.

Другие варианты реализации

  • Возможно, реализовать кастомный конфигурационный бин было бы правильней через расширение функциональности ConfigurationClassBeanDefinitionReader’а;
  • Вместо кастомной аннотации с указанием на класс родительского бина декоратора можно было бы наоборот, в родительских BeanDefintion’ах проставлять в конструкторах Qualifier’ы. Но в этом случае пришлось бы явно описывать все аргументы конструктора и что в них нужно инжектить, а мне этого делать не хотелось.

Варианты определения порядка инъекции

Итак, для определения порядка инъекции в спринге можно использовать следующие варианты:

  • Java Config – это будет императивно, слегка громоздко, придётся менять при изменении зависимостей бинов, но он обеспечивает низкую связность и локальность изменений;
  • @Qualifier – декларативный вариант, но с высокой связностью и из-за этого не локализованными в одном месте изменениями;
  • Custom qualifier – чуть меньше связность, чем при использовании обычных Qualifier’ов, но больше кода;
  • Свой резолвер и кастомный конфиг-бин – императивно, низкая связность, изменения локализованы в одном месте, но придется написать и поддерживать.

Выводы

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

В любом случае, пользоваться этим или не пользоваться – решать вам, но я надеюсь, что из этой статьи вам удалось узнать что-то новое. Спасибо за чтение!

Все исходники доступны по ссылке: https://github.com/monosoul/spring-di-customization.

Автор: monosoul

Источник


* - обязательные к заполнению поля