Spring Boot стартер для Apache Ignite своими руками

9c6f06103f0443899a4c2de4d1d582cb.jpeg

Вот уже вышло две статьи в потенциально очень длинной серии обзоров распределённой платформы Apache Ignite (первая про настройку и запуск, вторая про построение топологии). Данная статья посвящена попытке подружить Apache Ignite и Spring Boot. Стандартным способом подключения некой библиотеки к Spring Boot является создание для этой технологии «стартера». Несмотря на то, что Spring Boot весьма популярен и на Хабре описывался не единожды, про то, как делать стартеры, вроде бы ещё не писали. Этот досадный пробел я постараюсь закрыть.

Статья посвящена преимущественно Spring Boot’у и Spring Core, так что те, кого тема Apache Ignite не интересует, всё равно могут узнать что-то новое. Код выложен на GitHub, стартера и демо-приложения.

При чём тут Spring Boot?


Как известно, Spring Boot это очень удобная вещь. Среди его многочисленных приятных особенностей особенно ценно его свойство путём подключения нескольких maven-зависимостей превратить маленькое приложеньце в мощный программный продукт. За это в Spring Boot’е отвечает механизм стартеров (starter). Идея состоит в том, что можно спроектировать и реализовать некоторую конфигурацию по-умолчанию, которая будучи подключена настроит базовое поведение вашего приложения. Эти конфигурации могут быть адаптивными и делать предположения о ваших намерениях. Таким образом, можно сказать, что Spring Boot закладывает в приложение некоторое представление об адекватной архитектуре, которое он дедуктивно выводит из той информации, которую вы ему предоставили, положив те или иные классы в classpath или указав настройки в property-файлах. В хрестоматийном примере Spring Boot выводит «Hello World!» через веб-приложение, запущенное на встроенном Tomcat’e при буквально паре строчек прикладного кода. Все дефолтные настройки можно переопределить, и в предельном случае прийти к ситуации, как если бы Spring Boot’а у нас не было. Технически, стартер должен обеспечить инжектирование всего, что нужно, предоставляя при это осмысленные значения по-умолчанию.

В первой статье серии было рассказано, как создавать и использовать объекты Ignite. Хотя это и не очень сложно, хотелось бы ещё проще. Например, чтобы можно было воспользоваться таким синтаксисом:

    @IgniteResource(gridName = "test", clientMode = true)
    private Ignite igniteClient;

Далее будет описан стартер для Apache Ignite, в который заложено простейшее видение адекватного Ignite-приложения. Пример исключительно демонстрационный и не претендует на то, чтобы отражать какой-либо best practice.

Делаем стартер


Прежде чем сделать стартер, надо придумать, о чём он будет, в чём будет состоять предлагаемый им сценарий использования подключаемой им технологии. Из предыдущих статей мы знаем, что Apache Ignite предоставляет возможность создать топологию из узлов клиентского и серверного типа, и для их описания используются xml-конфигурации в стиле Spring core. Знаем также, что клиенты могут подключаться к серверам и выполнять на них задания. Серверы для выполнения задания могут быть отобраны по каким-то критериям. Поскольку разработчики не предоставили нам описания best practice, для данного простейшего случая я его сформулирую сам так: в приложении должен быть хотя бы один клиент, который будет слать нагрузку на сервера с теми, что и у него, значением gridName.
Руководствуясь этой генеральной идеей наш стартер попытается сделать всё, чтобы приложение не обвалилось при самых минимальных конфигурациях. С точки зрения прикладного программиста это сводится к тому, что надо получить объект типа Ignite и выполнить над ним какие-то манипуляции.

Для начала создадим каркас нашего стартера. Во-первых, подключим maven-зависимости. В первом приближении достаточно будет этого:

Основные зависимости
    
        1.8

        1.4.0.RELEASE
        4.3.2.RELEASE
        1.7.0
    

    
        org.springframework.boot
        spring-boot-starter-parent
        1.4.0.RELEASE
    

    
        
            org.springframework.boot
            spring-boot-starter
        
        
            org.springframework
            spring-context
            ${spring.version}
        
        
            org.springframework.boot
            spring-boot-configuration-processor
            true
        
        
            org.apache.ignite
            ignite-core
            ${ignite.version}
        
        
            org.apache.ignite
            ignite-spring
            ${ignite.version}
        
    


Здесь мы подключаем базовый стартер spring-boot-starter-parent, основные зависимости Ignite и Spring. Когда прикладное подключение подключит наш стартер, ему уже это делать не придётся. Следующим шагом надо сделать так, чтобы аннотация @IgniteResource корректно инжектила объект типа Ignite без участия программиста, с возможностью переопределения умолчаний. Сама аннотация довольно простая:
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Autowired
public @interface IgniteResource {
    String gridName();
    boolean clientMode() default true;
    boolean peerClassLoadingEnabled() default true;
    String localAddress() default "";
    String ipDiscoveryRange() default "";
    boolean createIfNotExists() default true;
}

Ожидается, что в аннотированную таким образом переменную заинжектится объект Ignite со свойствами, сообразно заданным. Будет произведён поиск по всем конфигам, и если найдётся подходящий, Ignite будет создан на его основе, если нет, будет принята во внимание настройка createIfNotExists (), и Ignite будет создан на основе дефолтных и переданных значений. Как нам этого добиться? Надо, что бы параметры нашей аннотации были учтены в процессе инстанциации бинов. За этот процесс в Spring отвечают объекты типа ConfigurableListableBeanFactory, а конкретно в Spring Boot это DefaultListableBeanFactory. Естественно, этот класс ничего не знает про Ignite. Напоминаю, что конфигурации Ignite хранятся в виде xml-конфигураций, которые являются Spring-конфигурациями. Или же их можно создать вручную, создав объект типа IgniteConfiguration. Таким образом, надо обучить Spring правильно инжектировать. Поскольку BeanFactory создаётся контекстом приложения, нам надо сделать свой:
public class IgniteApplicationContext extends AnnotationConfigApplicationContext {
    public IgniteApplicationContext() {
        super(new IgniteBeanFactory());
    }
}

Наш контекст отнаследован от AnnotationConfigApplicationContext, но Spring Boot для Web-приложений использует другой класс. Этот случай мы здесь не рассматриваем. Соответственно, Ignite-Spring Boot-приложение должно использовать этот контекст:
    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(DemoIgniteApplication.class);
        app.setApplicationContextClass(IgniteApplicationContext.class);
        app.run(args);
    }

Теперь надо настроить BeanFactory. Однако сначала надо позаботиться о душевном спокойствии Spring’а. Spring не дурак, Spring умный, он знает, что если есть @Autowired, то должен быть Bean. Поэтому в наш стартер мы добавим автоконфигурацию:
@Configuration
@ConditionalOnClass(name = "org.apache.ignite.Ignite")
public class IgniteAutoConfiguration {
    @Bean
    public Ignite ignite() {
        return null;
    }
}

Она будет загружена при наличии класса org.apache.ignite.Ignite и будет делать вид, что кто-то умеет возвращать объекты Ignite. На самом деле мы тут возвращать ничего не будем, так как отсюда нам не видно конфигурационных параметров, заданных в аннотации @IgniteResource. Подключение автоконфигурации обеспечивается конфигом spring.factories, помещаемым в META-INF, подробности в документации Spring Boot. Возвращаемся к BeanFactory и делаем так:
public class IgniteBeanFactory extends DefaultListableBeanFactory {
    private IgniteSpringBootConfiguration configuration;

    @Override
    public Object resolveDependency(DependencyDescriptor descriptor, String beanName,
                                    Set autowiredBeanNames,
                                    TypeConverter typeConverter) throws BeansException {
        if (descriptor == null
                || descriptor.getField() == null
                || !descriptor.getField().getType().equals(Ignite.class))
            return super.resolveDependency(descriptor, beanName,
                    autowiredBeanNames, typeConverter);
        else {
            if (configuration == null)
                configuration = new IgniteSpringBootConfiguration(
                        createBean(DefaultIgniteProperties.class));
            return configuration.getIgnite(
                    descriptor.getField().getAnnotationsByType(IgniteResource.class));
        }
    }

То есть, если у нас просят объект типа Ignite мы делегируем выполнение IgniteSpringBootConfiguration, о которой ниже, а если нет — оставляем всё, как есть. В IgniteSpringBootConfiguration мы передаём аннотации IgniteResource, навешенные на поле. Продолжаем распутывать этот клубок и смотрим, что это за IgniteSpringBootConfiguration.
IgniteSpringBootConfiguration, часть 1
public class IgniteSpringBootConfiguration {

    private Map> igniteMap = new HashMap<>();
    private boolean initialized = false;
    private DefaultIgniteProperties props;

    IgniteSpringBootConfiguration(DefaultIgniteProperties props) {
        this.props = props;
    }

    @Autowired
    private DefaultIgniteProperties props;

    private static final class IgniteHolder {
        IgniteHolder(IgniteConfiguration config, Ignite ignite) {
            this.config = config;
            this.ignite = ignite;
        }

        IgniteHolder(IgniteConfiguration config) {
            this(config, null);
        }

        IgniteConfiguration config;
        Ignite ignite;
    }


Тут мы ссылаемся на property-класс и определяем структуры для хранения данных Ignite. В свою очередь, DefaultIgniteProperties использует механизм «Type-safe Configuration Properties», о котором я рассказывать не буду и отошлю к мануалу. Но важно, что под ним лежит конфиг, в котором определены главные значения по-умолчанию:
ignite.configuration.default.configPath=classpath:ignite/**/*.xml
ignite.configuration.default.gridName=testGrid
ignite.configuration.default.clientMode=true
ignite.configuration.default.peerClassLoadingEnabled=true
ignite.configuration.default.localAddress=localhost
ignite.configuration.default.ipDiscoveryRange=127.0.0.1:47500..47509
ignite.configuration.default.useSameServerNames=true

Эти параметры могут быть переопределены в вашем приложении. Первый из них указывает, где мы будем искать xml-конфигурации Ignite, остальные определяют свойства конфигурации, которые мы будем использовать, если профиль не нашли и надо создать новый. Далее в классе IgniteSpringBootConfiguration будем искать конфигурации:
IgniteSpringBootConfiguration, часть 2
        List igniteConfigurations = new ArrayList<>();
        igniteConfigurations.addAll(context.getBeansOfType(IgniteConfiguration.class).values());

        PathMatchingResourcePatternResolver resolver =
              new PathMatchingResourcePatternResolver();
        try {
            Resource[] igniteResources = resolver.getResources(props.getConfigPath());
            List igniteResourcesPaths = new ArrayList<>();
            for (Resource igniteXml : igniteResources)
                igniteResourcesPaths.add(igniteXml.getFile().getPath());

            FileSystemXmlApplicationContext xmlContext =
                    new FileSystemXmlApplicationContext
                      (igniteResourcesPaths.stream().toArray(String[]::new));

            igniteConfigurations.addAll(xmlContext.getBeansOfType(IgniteConfiguration.class).values());


Вначале мы ищем уже известные нашему приложению бины типа IgniteConfiguration, а затем ищем конфиги по указанному в настройках пути, и найдя создаём из них бины. Бины конфигураций складываем в кэш. Затем, когда к нам приходит запрос на бин, мы ищем в этом кэше IgniteConfiguration по имени gridName, и если находим — создаём на основе этой конфигурации объект Ignite и прихраниваем, чтобы потом вернуть при повторном запросе. Если нужной конфигурации не нашли, создаём новую на основе настроек:
IgniteSpringBootConfiguration, часть 3
    public Ignite getIgnite(IgniteResource[] igniteProps) {
        if (!initialized) {
            initIgnition();
            initialized = true;
        }

        String gridName = igniteProps == null || igniteProps.length == 0
                ? null
                : igniteProps[0].gridName();
        IgniteResource gridResource = igniteProps == null || igniteProps.length == 0
                ? null
                : igniteProps[0];

        List configs = igniteMap.get(gridName);
        Ignite ignite;
        
        if (configs == null) {
            IgniteConfiguration defaultIgnite = getDefaultIgniteConfig(gridResource);
            ignite = Ignition.start(defaultIgnite);
            List holderList = new ArrayList<>();
            holderList.add(new IgniteHolder(defaultIgnite, ignite));
            igniteMap.put(gridName, holderList);
        } else {
            IgniteHolder igniteHolder = configs.get(0);
            if (igniteHolder.ignite == null) {
                igniteHolder.ignite = Ignition.start(igniteHolder.config);
            }
            ignite = igniteHolder.ignite;
        }
        
        return ignite;
    }

    private IgniteConfiguration getDefaultIgniteConfig(IgniteResource gridResource) {
        IgniteConfiguration igniteConfiguration = new IgniteConfiguration();
        igniteConfiguration.setGridName(getGridName(gridResource));
        igniteConfiguration.setClientMode(getClientMode(gridResource));
        igniteConfiguration.setPeerClassLoadingEnabled(getPeerClassLoadingEnabled(gridResource));

        TcpDiscoverySpi tcpDiscoverySpi = new TcpDiscoverySpi();
        TcpDiscoveryMulticastIpFinder ipFinder = new TcpDiscoveryMulticastIpFinder();
        ipFinder.setAddresses(Collections.singletonList(getIpDiscoveryRange(gridResource)));
        tcpDiscoverySpi.setIpFinder(ipFinder);
        tcpDiscoverySpi.setLocalAddress(getLocalAddress(gridResource));
        igniteConfiguration.setDiscoverySpi(tcpDiscoverySpi);

        TcpCommunicationSpi communicationSpi = new TcpCommunicationSpi();
        communicationSpi.setLocalAddress(props.getLocalAddress());
        igniteConfiguration.setCommunicationSpi(communicationSpi);

        return igniteConfiguration;
    }

    private String getGridName(IgniteResource gridResource) {
        return gridResource == null
                ? props.getGridName()
                : ifNullOrEmpty(gridResource.gridName(), props.getGridName());
    }

    private boolean getClientMode(IgniteResource gridResource) {
        return gridResource == null
                ? props.isClientMode()
                : gridResource.clientMode();
    }

    private boolean getPeerClassLoadingEnabled(IgniteResource gridResource) {
        return gridResource == null ? props.isPeerClassLoadingEnabled() : gridResource.peerClassLoadingEnabled();
    }

    private String getIpDiscoveryRange(IgniteResource gridResource) {
        return gridResource == null
                ? props.getGridName()
                : ifNullOrEmpty(gridResource.ipDiscoveryRange(), props.getIpDiscoveryRange());
    }

    private String getLocalAddress(IgniteResource gridResource) {
        return gridResource == null
                ? props.getGridName()
                : ifNullOrEmpty(gridResource.localAddress(), props.getLocalAddress());
    }

    private String ifNullOrEmpty(String value, String defaultValue) {
        return StringUtils.isEmpty(value) ? defaultValue : value;
    }


Теперь изменим стандартное поведение Ignite для выбора серверов, на которые будут распределяться задания, которое состоит в том, что нагрузка распределяется на все сервера. Допустим, мы хотим, чтобы по-умолчанию выбирались сервера, у которых тот же gridName, что и у клиента. В предыдущей статье рассказывалось, как это сделать штатными средствами. Тут мы немного извратимся, и проинструментируем получаемый объект Ignite с помощью cglib. Замечу, что в этом нет ничего ужасного, Ызкштп сам так делает.
IgniteSpringBootConfiguration, часть 4
        if (props.isUseSameServerNames()) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(Ignite.class);
            enhancer.setCallback(new IgniteHandler(ignite));

            ignite = (Ignite) enhancer.create();
        }

        return ignite;
    }

    private class IgniteHandler implements InvocationHandler {

        private Ignite ignite;

        IgniteHandler(Ignite ignite) {
            this.ignite = ignite;
        }

        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            return method.getName().equals("compute")
                    ? ignite.compute(ignite.cluster()
                    .forAttribute(ATTR_GRID_NAME, ignite.configuration().getGridName())
                    .forServers())
                    : method.invoke(ignite, args);
        }
    }


И это всё, теперь бины Ignite выдаются согласно настроек. В Spring Boot приложении мы можем вызвать теперь Ignite так:
    @Bean
    public CommandLineRunner runIgnite() {
        return new CommandLineRunner() {
            @IgniteResource(gridName = "test", clientMode = true)
            private Ignite igniteClient;

            public void run(String... args) throws Exception {
                igniteClient.compute().broadcast(() -> System.out.println("Hello World!"));
                igniteClient.close();
            }
        };
    }

В JUnit-тестах @IgniteResource работать не будет, это оставляется в качестве упражнения.

Выводы


Сделан простейший стартер для Apache Ignite, позволяющий существенно упростить клиентский код, убирая из него большую часть специфики Ignite. Далее этот стартер можно дорабатывать, сделать для него более удобные настройки, предусмотреть более адекватные умолчания.

Ссылки


  • Код стартера на GitHub
  • Код демо-приложения на GitHub

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

© Habrahabr.ru