Do it yourself spring boot starter for Apache Ignite


    Two articles have already been published in a potentially very long series of reviews of the distributed Apache Ignite platform (the first about setting up and running, the second about building a topology). This article is about trying to make friends with Apache Ignite and Spring Boot. The standard way to connect a library to Spring Boot is to create a “starter” for this technology. Despite the fact that Spring Boot is very popular and has been described more than once on the Habré, it seems that they have not yet written about how to make starters. I will try to close this annoying gap.

    The article is mainly devoted to Spring Boot and Spring Core, so those who are not interested in the Apache Ignite theme can still learn something new. The code is posted on GitHub, the starter anddemo applications .

    What does Spring Boot have to do with it?


    As you know, Spring Boot is a very convenient thing. Among its many pleasant features, its property is especially valuable by connecting several maven-dependencies to turn a small application into a powerful software product. For this, in the Spring Boot, the starter mechanism is responsible. The idea is that you can design and implement some default configuration, which, when connected, will configure the basic behavior of your application. These configurations can be adaptive and make assumptions about your intentions. Thus, it can be said that Spring Boot lays in the application some idea of ​​an adequate architecture, which it deductively derives from the information that you provided to it by putting certain classes in the classpath or specifying settings in property files. In a textbook example, Spring Boot displays “Hello World!” through a web application running on the built-in Tomcat with just a couple of lines of application code. All default settings can be overridden, and in the extreme case, come to the situation as if we did not have Spring Boot. Technically, the starter should ensure that everything is injected, while providing meaningful default values.

    The first article in the series described how to create and use Ignite objects. Although not very difficult, I would like to be even simpler. For example, to use this syntax:

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

    Next, the starter for Apache Ignite will be described, which contains the simplest vision of an adequate Ignite application. The example is purely demonstrative and does not pretend to reflect any best practice.

    Make starter


    Before you make a starter, you need to figure out what it will be about, what will be the scenario of its proposed use of the technology it connects to. From previous articles, we know that Apache Ignite provides the ability to create a topology from client and server type nodes, and they are described using spring core-style xml configurations. We also know that clients can connect to servers and perform tasks on them. Servers for the job can be selected according to some criteria. Since the developers did not provide us with a description of best practice, for this simplest case, I will formulate it myself this way: the application must have at least one client that will send the load to the server with those with it with the gridName value.
    Guided by this general idea, our starter will try to do everything so that the application does not crash with the most minimal configurations. From the point of view of the application programmer, this boils down to the fact that you need to get an object of type Ignite and perform some manipulations on it.

    First, create the frame of our starter. First, let's connect the maven dependencies. In a first approximation, this will be enough:

    Main dependencies
    1.81.4.0.RELEASE4.3.2.RELEASE1.7.0org.springframework.bootspring-boot-starter-parent1.4.0.RELEASEorg.springframework.bootspring-boot-starterorg.springframeworkspring-context${spring.version}org.springframework.bootspring-boot-configuration-processortrueorg.apache.igniteignite-core${ignite.version}org.apache.igniteignite-spring${ignite.version}


    Here we connect the basic starter spring-boot-starter-parent, the main dependencies of Ignite and Spring. When the application connection connects our starter, he will not have to do this already. The next step is to make the @IgniteResource annotation correctly inject an object of type Ignite without the participation of a programmer, with the possibility of overriding defaults. The annotation itself is pretty simple:

    @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;
    }
    

    It is expected that an Ignite object with properties set accordingly will be injected into the variable annotated in this way. All configs will be searched, and if there is a suitable one, Ignite will be created on its basis, if not, the createIfNotExists () setting will be taken into account, and Ignite will be created based on the default and transferred values. How do we achieve this? It is necessary that the parameters of our annotation be taken into account in the process of instantiation of bins. In Spring, objects of type ConfigurableListableBeanFactory are responsible for this process.and specifically in Spring Boot it is DefaultListableBeanFactory. Naturally, this class does not know anything about Ignite. I recall that Ignite configurations are stored as xml configurations, which are Spring configurations. Or you can create them manually by creating an object of type IgniteConfiguration. Thus, you need to train Spring to inject correctly. Since BeanFactory is created by the application context, we need to make our own:

    public class IgniteApplicationContext extends AnnotationConfigApplicationContext {
        public IgniteApplicationContext() {
            super(new IgniteBeanFactory());
        }
    }
    

    Our context is inherited from AnnotationConfigApplicationContext, but Spring Boot uses a different class for Web applications. We are not considering this case here. Accordingly, the Ignite-Spring Boot application should use this context:

        public static void main(String[] args) {
            SpringApplication app = new SpringApplication(DemoIgniteApplication.class);
            app.setApplicationContextClass(IgniteApplicationContext.class);
            app.run(args);
        }
    

    Now you need to configure BeanFactory. However, first you need to take care of Spring’s peace of mind. Spring is not a fool, Spring is smart, he knows that if there is @Autowired, then there must be a Bean . Therefore, we will add autoconfiguration to our starter:

    @Configuration
    @ConditionalOnClass(name = "org.apache.ignite.Ignite")
    public class IgniteAutoConfiguration {
        @Bean
        public Ignite ignite() {
            return null;
        }
    }
    

    It will be loaded with the org.apache.ignite.Ignite class and will pretend that someone knows how to return Ignite objects. In fact, we will not return anything here, since from here we can not see the configuration parameters specified in the @IgniteResource annotation. Autoconfiguration is enabled by the spring.factories config placed in META-INF, details in the Spring Boot documentation . Go back to BeanFactory and do this:

    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));
            }
        }
    

    That is, if we are asked for an object of type Ignite, we delegate the execution of IgniteSpringBootConfiguration, which is described below, and if not, we leave everything as it is. In IgniteSpringBootConfiguration, we pass the IgniteResource annotations hung on the field. We continue to unravel this tangle and see what kind of IgniteSpringBootConfiguration it is.

    IgniteSpringBootConfiguration, part 1
    public class IgniteSpringBootConfiguration {
        private Map> igniteMap = new HashMap<>();
        private boolean initialized = false;
        private DefaultIgniteProperties props;
        IgniteSpringBootConfiguration(DefaultIgniteProperties props) {
            this.props = 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;
        }
    


    Here we refer to the property class and define structures for storing Ignite data. In turn, DefaultIgniteProperties uses the “Type-safe Configuration Properties” mechanism, which I will not talk about and refer to the manual . But it is important that under it lies a config in which the main default values ​​are defined:

    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

    These options may be overridden in your application. The first of them indicates where we will look for the Ignite xml configurations, the rest determine the configuration properties that we will use if we did not find the profile and we need to create a new one. Next, in the IgniteSpringBootConfiguration class, we will look for configurations:

    IgniteSpringBootConfiguration, part 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());
    


    First, we look for bins of the IgniteConfiguration type already known to our application, and then we look for configs according to the path specified in the settings, and after finding them we create bins from them. We add the configuration beans to the cache. Then, when a request for a bin comes to us, we look in this cache for IgniteConfiguration by the name gridName, and if we find it, we create an Ignite object based on this configuration and save it, so that we can return it after a second request. If the desired configuration is not found, create a new one based on the settings:

    IgniteSpringBootConfiguration, part 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;
        }
    


    Now we will change the standard Ignite behavior to select the servers to which the tasks will be distributed, which consists in the fact that the load is distributed to all servers. Suppose we want to select by default servers that have the same gridName as the client. The previous article described how to do this by regular means. Here we pervert a little, and instruct the resulting Ignite object with cglib. I note that there is nothing terrible in this, Spring does it himself.

    IgniteSpringBootConfiguration, part 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);
            }
        }
    


    And that’s it, now Ignite bins are issued according to the settings. In the Spring Boot application, we can now call Ignite like this:
        @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();
                }
            };
        }
    

    In JUnit tests, @IgniteResource will not work, this is left as an exercise.

    conclusions


    The simplest starter for Apache Ignite was made, which allows to significantly simplify client code, removing most of the Ignite specifics from it. Further, this starter can be modified, make more convenient settings for it, provide for more adequate defaults. As a further development it is possible to do many things, such as to make more transparent than those described in my article , screwing Ignite as the L2 cache to the Activiti.

    References



    Also popular now: