Введение

Всем привет. После моей предыдущей статьи о Maven-плагине, где я предложил новый подход к реализации и создал свою версию для IDEA (вместо того чтобы писать свой мини-Maven, я делегировал всю основную работу ему посредством Maven плагина), меня пригласили работать над Spring плагином в IT-стартап Explyt. Компания занимается автоматической генерацией тестов на базе AI и формальных методов. В процессе работы столкнулся с проблемами, похожими на те, которые решал в своем Maven плагине. У меня возникло некое дежавю, и я подумал: почему бы не использовать подобный подход, чтобы доработать и улучшить Dependency Injection Explyt Spring плагина? Текст для тех, кто работает со Spring-плагинами и хочет разобраться, как эффективно применять готовую логику Спринга для новых задач. Заходите под кат, расскажу более подробно про наш плагин, проблемы которые решал и поделюсь некоторыми примерами кода.

Проблема

Одна из первоочередных и основных задач Spring плагина - это поддержка Dependency Injection, а именно надо уметь понимать, является ли класс частью контекста. Помимо общей подсветки бинов и навигации по ним (декларация/использование), это также часто необходимо при создании инспекций, «комплишенов» кода и пр. Какие тут могут быть проблемы? 

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

@SpringBootApplication(scanBasePackages = "com.example.simple")
public class DemoApplication { 
}

А если у нас там какое-то нестандартное выражение или какая-нибудь регулярка, которую надо вычислить? Кроме того, аннотация @ComponentScan позволяет создавать очень сложные фильтры для пакетов с возможностью частичного исключения и прочее.

@SpringBootApplication(scanBasePackages = "com.*.spel")
public class DemoApplicationSpel {    
}

А это совсем непростая задача.

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

Кроме того, на то находится бин в контексте или нет, также влияют аннотации @Conditional/@DependOn/@Profile и многие другие. А если у нас аннотация @Conditional с пользовательским кодом, то это равносильно - миссия невыполнима. 

@SpringBootApplication
public class DemoApplicationConditional {
    @Autowired ConditionalBean bean;

    public static void main(String[] args) {
        SpringApplication.run(DemoApplicationConditional.class, args);
    }
}


@Conditional(MyCondition.class)
@Component
class ConditionalBean {}

class MyCondition implements Condition {
    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        //some user logic
        return false;
    }
}

Чтобы вычислить такое выражение, нам надо скомпилировать код со всеми зависимостями и выполнить его. Обладая на входе только данными о код-модели, сделать это на основе ее анализа практически невозможно. Это не работает даже в IDEA Ultimate (все сравнения с Ultimate версии 2022.2, когда это еще было можно)

Такие примеры можно приводить очень долго. По факту мы не можем поддержать все возможные случаи. А действуем по закону Парето, что 20% усилий дают 80% результата. Такой подход уже использовался на момент моего прихода в команду, когда все реализовывалось на основе анализа когда, это самый типовой подход и он применяется так или иначе во всех спринг плагинах, в том числе Spring Ultimate.

Поддержать все cлучаи это, по сути, написать свой спринг, только вместо java-рантайма с *.class файлами у нас в данном случае модель кода идеи. А это очень трудоемкая задача и чтобы поддержать все эти возможности не хватит и жизни. Фактически пишем свой спринг на минималках.

В процессе работы у меня в голове постоянно витала мысль: почему бы не попробовать переиспользовать готовую логику из спринга по нахождению бинов, а не писать ее самому. Играя в догонялки со Spring, всегда оказываешься в роли отстающих. Так как один к одному воспроизвести ее практически не реально и на это уходит слишком много ресурсов. Да и находимся мы в разных условиях. У них на входе полноценный java runtime, у нас моделькода — исходники. Так что ошибки и неточности в воспроизведении логики спринга неизбежны. То есть это ровно тоже самое что я и пытался исправить в своем IDEA Maven плагине. Посмотрим, что из этого вышло.

Идея

Как известно, процесс инициализации контекста Spring, если очень грубо, состоит из двух основных шагов:

  • построение метаданных — bean definition

  • создание bean instances — непосредственно экземпляров класса

Более подробно про это можно почитать тут и тут.

Bean definition – это специальная структура спринга, которая представляет метаданные о бинах. К этим метаданным уже применены все возможные фильтры: @Conditional/@DependOn/@Profile и др. На следующем этапе по ним создаются непосредственно экземпляры бинов и происходит дополнительная валидация - есть ли нужный бин в контексте и прочее.

То есть после первого шага у нас уже есть все нужные данные о доступных бинах. Идея была следующая: нам надо как-то вклиниться в этот процесс поднятия контекста и прервать его после первого шага. 

Выполнять инициализацию/создание бинов нам не надо, тут могут уже начать выполняться скрипты миграции БД (liquibase/flyway), @PostConstruct методы, запуск веб-сервера. Это может привести к множеству нежелательных «сайд» эффектов (изменение БД или порт может быть занят, если приложение уже запущено в фоне). Да и к тому же именно процесс создания бинов занимает большую часть времени старта приложения.

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

Реализация

Вот так выглядит основная часть создания контекста в абстрактном классе AbstractApplicationContext, от которого наследуются все остальные контексты приложений(ServletWebServerApplicationContext, ReactiveWebServerApplicationContext и др.)

public void refresh() throws BeansException, IllegalStateException {
    synchronized (this.startupShutdownMonitor) {
        StartupStep contextRefresh = this.applicationStartup.start("spring.context.refresh");

        // Prepare this context for refreshing.
        prepareRefresh();
        // Tell the subclass to refresh the internal bean factory.
        ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();
        // Prepare the bean factory for use in this context.
        prepareBeanFactory(beanFactory);

        try {
            // Allows post-processing of the bean factory in context subclasses.
            postProcessBeanFactory(beanFactory);

            StartupStep beanPostProcess = this.applicationStartup.start("spring.context.beans.post-process");
            // Invoke factory processors registered as beans in the context.
            invokeBeanFactoryPostProcessors(beanFactory);

            // Register bean processors that intercept bean creation.
            registerBeanPostProcessors(beanFactory);
            beanPostProcess.end();

            // Initialize message source for this context.
            initMessageSource();
            // Initialize event multicaster for this context.
            initApplicationEventMulticaster();
            // Initialize other special beans in specific context subclasses.
            onRefresh();
            // Check for listener beans and register them.
            registerListeners();

            // Instantiate all remaining (non-lazy-init) singletons.
            finishBeanFactoryInitialization(beanFactory);
            // Last step: publish corresponding event.
            finishRefresh();
        } catch (BeansException ex) {
            ....
        }
    }
}

На строке beanPostProcess.end() завершается процесс построения метаданных, и нам надо как-то прервать этот процесс после этого шага.

Первое, что пришло в голову — это написать свой BeanFactoryPostProcessor и кинуть в нем Exception. Но тут загвоздка в том, что его надо регистрировать как бин в приложении и положить в пакет, который будет просканирован @PackageScan, который мы заранее не знаем и настраивать порядок его выполнения, чтобы он был последним. В целом, это все решаемо, но мне этот вариант показался наименее перспективным, тем более, в голове уже были другие мысли.

Идея номер раз

Идея заключалась в том, чтобы использовать cglib который входит в состав Spring-framework, для того чтобы кинуть ошибку до вызова метода initMessageSource().

Стандартный метод запуска Spring Boot приложения выглядит вот так: 

public static void main(String[] args) {
  SpringApplication.run(DemoApplication.class, args);
}

Где в статическом методе run происходит создание экземпляра SpringApplication и его запуск: new SpringApplication(DemoApplication.class)).run(args). Быстренько накидал примерчик, переписав метод main запуска приложения вот так:

@SpringBootApplication
public class DemoApplicationV1 {

    public static void main(String[] args) {
        SpringApplication springApplication = new SpringApplication(DemoApplicationV1.class) {
            @Override
            protected ConfigurableApplicationContext createApplicationContext() {
                ConfigurableApplicationContext applicationContext = super.createApplicationContext();
                Enhancer enhancer = new Enhancer();
                enhancer.setSuperclass(applicationContext.getClass());
                enhancer.setCallback(new MethodInterceptorCglib());
                return (ConfigurableApplicationContext) enhancer.create();
            }
        };
        springApplication.run(args);
    }

    static class MethodInterceptorCglib implements MethodInterceptor {

        @Override
        public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
            if (method.getName().equals("initMessageSource")) {
                //printBeans((ConfigurableApplicationContext) obj);
                throw new RuntimeException("I am Explyt Plugin!!!");
            } else {
                return proxy.invokeSuper(obj, args);
            }
        }
    }
}

И это сработало… Как видно на скриншоте, переменная obj — это и есть наш контекст, который инициализируется. В нем уже заполнена нужными нам данными beanDefinitionMap. Получается, нам надо всего лишь «переписать» метод, отвечающий за запуск проекта. Тут нет проблем с регистрацией нового бина и пакетом, где он находится. Но есть нюанс: в cglib объект контекста создается конструктором без параметров “enhancer#create()”, а это не всегда может быть так, но на практике работает.

Идея номер два

Совершенно случайно мое внимание в процессе отладки привлек интерфейс SpringApplicationRunListener, который содержит методы обратного вызова для этапов инициализации контекста:

public interface SpringApplicationRunListener {

	default void starting(ConfigurableBootstrapContext bootstrapContext) {
	}

	default void environmentPrepared(ConfigurableBootstrapContext bootstrapContext,
			ConfigurableEnvironment environment) {
	}

	default void contextPrepared(ConfigurableApplicationContext context) {
	}

	default void contextLoaded(ConfigurableApplicationContext context) {
	}

	default void started(ConfigurableApplicationContext context, Duration timeTaken) {
	}

	default void ready(ConfigurableApplicationContext context, Duration timeTaken) {
	}

	default void failed(ConfigurableApplicationContext context, Throwable exception) {
	}

}

В момент обработки ошибки, что я создавал на этапе выполнения метода initMessageSource(), процесс управления переходил в метод failed дефолтного листенера. Я подумал: может быть, в данном листенере есть метод, который вызывается после этапа завершения построения beanDefinitionMap и до инициализации бинов?

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

Тогда мое внимание привлекла строка beanPostProcess.end() в методе  инициализации контекста. Что это за объект такой beanPostProcess? Это интерфейс StartupStep, который отвечает за… читаем документацию — «пошаговую запись показателей конкретной фазы или действия, происходящего во время запуска приложения». Для него есть дефолтная реализация, которая совсем не содержит логики и имеет пустые методы заглушки. Кроме того, у класса SpringApplication, который отвечает за запуск Spring Boot приложения, есть публичный метод, где можно задать свою имплементацию, тогда метод запуска приложения можно будет переписать без кодогенерации вот так:  

@SpringBootApplication
public class DemoApplicationV2 {
    public static void main(String[] args) {
        SpringApplication springApplication = new SpringApplication(DemoApplicationV2.class);
        springApplication.setApplicationStartup(new ExplytApplicationStartup());
        SpringApplicationHook applicationHook = application -> new ExplytSpringApplicationRunListener();
        SpringApplication.withHook(applicationHook, () -> springApplication.run(args));
    }

   private static class ExplytSpringApplicationRunListener implements SpringApplicationRunListener {
        @Override
        public void failed(ConfigurableApplicationContext context, Throwable exception) {
            //printBeans(context);
        }
    }
}

Мы указываем свою имплементацию StartupStep в методе SpringApplication#setApplicationStartup, которая кидает ошибку при вызове beanPostProcess.end() на фазе this.applicationStartup.start("spring.context.beans.post-process") . Далее мы через SpringApplication.withHook регистрируем свой листенер, где в методе SpringApplicationRunListener#failed получаем ссылку на текущий объект контекста, где есть метаданные beanDefinitionMap, из которых мы можем получить все, что нам нужно. Кажется, все хорошо, мы без всякой кодогенерации публичными методами фреймворка смогли получить то, что нам нужно.

Но есть опять нюанс… SpringApplication.withHook доступен только начиная с версии Spring Boot 3.0, а это не очень хорошо так как много проектов до сих пор используют версию 2.0. Про версию 1.0 говорить небудем, тут как в анекдоте: «неудачники нам не нужны».

Идея номер три

Надо как-то исправлять положение. В голову пришло вот что:

@SpringBootApplication
public class DemoApplicationV3 {
    public static void main(String[] args) {
        ExplytApplicationStartup applicationStartup = new ExplytApplicationStartup();
        SpringApplication springApplication = new SpringApplication(DemoApplicationV3.class) {
            @Override
            protected ConfigurableApplicationContext createApplicationContext() {
                applicationStartup.context = super.createApplicationContext();
                return applicationStartup.context;
            }
        };
        springApplication.setApplicationStartup(applicationStartup);
        springApplication.run(args);
    }
}

Переопределяем метод SpringApplication#createApplicationContext, в котором сохраняем ссылку на контекст. Когда в applicationStartup мы будем бросать исключение,чтобы прервать процесс и не выполнять создание бинов, у нас будет ссылка на контекст с нужными нам данными. Таким образом, мы добавили поддержку, начиная с версии 2.4 без всякой кодогенерации и прочих нелегальных трюков, только используя публичное api. Напомню, что версия 2.4 вышла 10.2020, то есть срок давности имеет. Для более старых версий я решил оставить вариант с cglib, которым можно пользоваться на свой страх и риск, включив опцию «explyt.spring.native.old» в настройках Registry IDEA (double shift -> explyt.spring.native.old).

Как это в итоге работает

Что же у нас получилось в итоге и как это работает? 

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

  • скомпилировать наше приложение,

  • добавить наш jar файл в -classpath запуска приложения,

  • запустить приложение, используя main метод из jar файла,

  • через “-D” параметр передать имя класса исходного приложения нашего проекта, которое нужно запустить внутри SpringApplication,

  • вывести любым способом (output процесса или в виде файла) данные о бинах, которые формирует наш процесс, в формате, который мы сможем легко обработать,

  • загрузить эти данные в IDE.

Команда для запуска java процесса будет выглядеть примерно вот так:

java -Dexplyt.spring.appClassName=com.example.DemoApplication 
  -classpath explyt-spring-boot-bean-reader-0.1.jar:other.jars. com.explyt.spring.boot.bean.reader.SpringBootBeanReaderStarter

Все это происходит автоматически под капотом. Пользователю надо только выполнить загрузку Spring Boot проекта - нажав соответствующую кнопку. Подробности далее.

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

Логика работы нашего плагина следующая. При открытии спринг проекта, мы пытаемся распознать бины на основе код модели IDEA. Как правило, этого достаточно для типовых случаев. 

Далее есть возможность через Explyt Spring RunConfiguration загрузить данные о бинах непосредственно из Spring. Именно благодаря ран конфигурации мы легко можем запустить в IDEA свой java процесс, добавив туда свой кастомный jar файл в classpath и поменять метод запуска. («иконка» Spring Boot с лупой)

Да, это требует компиляции проекта. Это может быть не очень приятно, но IDEA умеет инкрементальную компиляцию и требования компиляции проекта в IDE мягче, чем требование запуска приложения. Мне приходилось работать на проектах, которые не возможно было запустить локально и вся разработка велась через тесты. После загрузки данных отображается панель с данными о бинах проекта, где по двойному клику можно перейти непосредственно к бину.

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

Для создания данной панели я использовал External System Integration, о котором более подробно рассказывал в своей статье. Там есть пара дефектов с дублированием и лишними «иконками» вверху панели. Я заводил на это issue (раз и два) и даже прикладывал git‑patch, но воз и ныне там.

Работа с данным функционалом напоминает работу с билд системами Maven/Gradle. У нас есть конфигурационные файлы Spring Boot проектов - @SpringBootApplication, мы можем загружать/обновлять/удалять их через тул окно ExplytSpringBoot. Где входной точкой для начала загрузки является Explyt Spring Run Configuration.

В этой панели также можно искать бины по имени и использовать некое подобие dependency analyzer, где добавлена возможность поиска бинов по типу.

Для примера:

public interface MyInterface {}

@Service
class FooBean implements MyInterface {}

@Service
class BazBean implements MyInterface {}

class BarBean implements MyInterface {}

Вот так выглядела бы панель dependency analyzer при попытке найти все бины, которые реализуют MyInterface:

На одном из прошлых проектов я никак не мог понять, кто создает в контексте бин для jackson конвертации. Оказалось, это был кастомный стартер, разработанный внутри компании, который, помимо прочего, не давал переопределить его. Конечно, это можно понять из логов, включив режим debug, но так, по-моему, гораздо удобнее.

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

В панель можно добавлять несколько проектов, если у вас в репозитории несколько @SpringBootApplication. Также там отображаются доступные профили, по аналогии с  Maven панелью, которые можно включать/отключать двойным кликом. Данные о включенных профайлах синхронизируются с соответствующей ран конфигурацией. 

Данные о процессе синхронизацию находятся в Build Tab. Там будут отображается ошибки компиляции, если проект не удалось собрать и логи запуска Spring. В случае нештатной ситуации - все ошибки будут там. Это упрощает процесс отладки и разбор дефектов пользователей.

В Ultimate есть похожая панель, которая отображается, когда мы запускаем спринг из ран конфигурации. На ней видны данные о бинах из актуатора. Но насколько я могу судить, эти данные не используются для получения данных о бинах - это просто возможность посмотреть данные из актуаторов. Это более тяжелый процесс, так как приложение может очень долго стартовать, быть требовательным к наличию внешнего окружения (базы, очереди и пр.) Кроме того, не всегда есть возможность запустить проект локально. С другой стороны, эти данные более точные, поскольку бины могут создаваться сложными FactoryBean, например, Spring Data Repository. В нашем плагине мы поддерживаем такие случаи для самых известных спринг библиотек, но потенциально это один из недостатков нашего подхода.

Подведем итоги

Рассмотрим плюсы и минусы нативного подхода получения бинов.

Плюсы:

  • используется логика Spring,

  • легче поддерживать — не пишем лишний код,

  • меньше вероятность ошибиться

  • IDE независимое решение.

Так как мы запускаем отдельный спринг процесс со всеми зависимостями проекта, то можем использовать выходные данные нашего процесса (информацию о бинах) в любой IDE и минимальными усилиями добавить поддержку спринга в любую среду разработки максимально нативным способом. Также с помощью такого подхода мы легко смогли добавить поддержку @Aspect. Поскольку мы находимся в процессе спринга, мы можем использовать все его утилитные методы, чтобы найти все PointCut выражения и проверить, подходит ли под это условие «java.lang.reflect.Method». Сделать такое только через анализ модели кода - очень трудозатратно, так как нужно создать свой движок вычисления PointCut выражения на основе исходных кодов проекта. В Ultimate это реализовано именно на основе исходников.

Кроме того, с помощью данного подходамы получаем «бесплатную» поддержку xml конфигураций. Кто-то может сказать, что этим никто не пользуется, но как говорится, мелочь, а приятно. Тем более, мы для этого специально ничего не делали.

Минусы:

  • в настоящее время поддерживается только Spring Boot из-за простоты нахождения root’вых точек проекты и их запуска,

  • требует компиляции,

  • если в main есть пользовательский код, то это проблема,

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

Заключение

Помимо распознавания бинов, плагин также содержит большое число инспекций и автодополнений кода и распознования контекста для различный Spring и смежных проектов: Spring-Core, Spring-Data, Spring-Web, Spring-AOP, Spring-Initializr, JPQL, OpenApi.

Поддерживаются языки: Java/Kotlin/Scala.

Примеры кода, использованные в статье, доступны на github.

Скачать можно тут.

На wiki есть небольшие видеоролики, с примерами использования плагина.

Для обратной связи и сообщений об ошибках: github

Для общения: t.me/explytspring

Будем рады любой обратной связи и предложениям.

Комментарии (0)