Two articles have already been published in a potentially very long series of reviews of the distributed
Apache Ignite platform (the
first is about setting up and running, the
second is about building topology). This article focuses on trying to make friends with Apache Ignite and Spring Boot. The standard way to connect a certain library to Spring Boot is to create a “starter” for this technology. Despite the fact that Spring Boot is very popular and was described more than once on Habré, it seems that they haven’t written 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,
starter and
demo applications .
What does the 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 Spring Boot, the starter mechanism (starter) is responsible. The idea is that you can design and implement some default configuration that, when connected, will customize 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 down in the application some idea of ​​an adequate architecture, which it deductively derives from the information that you provided to it, 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 literally a couple of lines of application code. All default settings can be overridden, and in the limiting case we can come up with a situation, as if we didn’t have Spring Boot. Technically, the starter should inject everything that is needed, while providing meaningful default values.
The
first article in the series explained how to create and use Ignite objects. Although it is not very difficult, I would like it even easier. For example, to be able 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 demonstration and does not pretend to reflect any best practice.
Making a starter
Before you make a starter, you need to think about what it will be, what will be its proposed scenario of using the technology connected to it. From previous articles, we know that Apache Ignite provides the ability to create topology from nodes of the client and server types, and to describe them using xml-configuration in the style of Spring core. We also know that clients can connect to servers and perform tasks on them. Servers for the job can be selected by some criteria. Since the developers have not provided us with the best practice descriptions, for this simplest case, I will formulate it myself: the application must have at least one client that will send the load to the servers with those that it has, the value of gridName.
Guided by this general idea, our starter will try to do everything so that the application does not collapse with the most minimal configurations. From the point of view of the application programmer, it comes down to the fact that you need to get an object like
Ignite and perform some manipulations on it.
To begin with, let's create a frame for our starter. First, let's connect maven-dependencies. As a first approximation, this will suffice:
Main dependencies <properties> <java.version>1.8</java.version> <spring-boot.version>1.4.0.RELEASE</spring-boot.version> <spring.version>4.3.2.RELEASE</spring.version> <ignite.version>1.7.0</ignite.version> </properties> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>1.4.0.RELEASE</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.apache.ignite</groupId> <artifactId>ignite-core</artifactId> <version>${ignite.version}</version> </dependency> <dependency> <groupId>org.apache.ignite</groupId> <artifactId>ignite-spring</artifactId> <version>${ignite.version}</version> </dependency> </dependencies>
Here we connect the spring-boot-starter-parent base starter, the main dependencies of Ignite and Spring. When the application connection connects our starter, he will not have to do it. The next step is to make the @IgniteResource annotation correctly inject an Ignite object without the participation of a programmer, with the possibility of overriding defaults. The abstract itself is fairly 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 that are specified will be injected into a variable annotated in this way. A search will be made for all configs, and if a suitable one is found, Ignite will be created based on it, if not, the createIfNotExists () setting will be taken into account, and Ignite will be created based on 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 instantiating bins. For this process in Spring, objects of type
ConfigurableListableBeanFactory are responsible, and specifically in Spring Boot this is the DefaultListableBeanFactory. Naturally, this class knows nothing about Ignite. I remind you 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, Spring needs to be trained to properly inject. 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 for Web applications uses a different class. We do not consider 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, you must first 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 an autoconfiguration to our starter:
@Configuration @ConditionalOnClass(name = "org.apache.ignite.Ignite") public class IgniteAutoConfiguration { @Bean public Ignite ignite() { return null; } }
It will be loaded if you have the org.apache.ignite.Ignite class and will pretend that someone can return Ignite objects. In fact, we will not return anything here, because from here we don’t see the configuration parameters specified in the @IgniteResource annotation. The autoconfiguration connection is provided by spring.factories config, placed in META-INF, details in the
Spring Boot documentation . Go back to the BeanFactory and do this:
public class IgniteBeanFactory extends DefaultListableBeanFactory { private IgniteSpringBootConfiguration configuration; @Override public Object resolveDependency(DependencyDescriptor descriptor, String beanName, Set<String> 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 the Ignite type, we delegate the execution of the IgniteSpringBootConfiguration, which is described below, and if not, we leave everything as is. In IgniteSpringBootConfiguration, we pass in the IgniteResource annotations hung on the field. We continue to unravel this tangle and see what the IgniteSpringBootConfiguration is.
IgniteSpringBootConfiguration Part 1 public class IgniteSpringBootConfiguration { private Map<String, List<IgniteHolder>> 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 the structures for storing the Ignite data. In turn, DefaultIgniteProperties uses the “Type-safe Configuration Properties” mechanism, which I will not talk about and will
refer to the manual . But it is important that under it lies the 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 parameters can be overridden in your application. The first one indicates where we will look for the Ignite xml configurations, the rest determine the configuration properties that we will use if the profile is not found and a new one needs to be created. Further, in the IgniteSpringBootConfiguration class, we will look for configurations:
IgniteSpringBootConfiguration Part 2 List<IgniteConfiguration> igniteConfigurations = new ArrayList<>(); igniteConfigurations.addAll(context.getBeansOfType(IgniteConfiguration.class).values()); PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); try { Resource[] igniteResources = resolver.getResources(props.getConfigPath()); List<String> 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 search for already known to our application bins of the IgniteConfiguration type, and then we search for configs according to the path specified in the settings, and finding them to create bins. Bean configurations add to the cache. Then, when a request for a bin comes to us, we search for this IgniteConfiguration cache named gridName, and if we find, we create an Ignite object based on this configuration and store it so that we can return it when we re-request it. 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<IgniteHolder> configs = igniteMap.get(gridName); Ignite ignite; if (configs == null) { IgniteConfiguration defaultIgnite = getDefaultIgniteConfig(gridResource); ignite = Ignition.start(defaultIgnite); List<IgniteHolder> 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 let's change the standard Ignite behavior to select servers to which tasks will be distributed, which is that the load is distributed to all servers. Suppose we want servers to be selected by default with the same gridName as the client. The
previous article described how to do it by regular means. Here we distort a little and instruct the resulting Ignite object with cglib. Note that there is nothing terrible about 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 all, now the Ignite beans 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 @UgniteResource JUnit tests will not work, it is left as an exercise.
findings
Made the simplest starter for Apache Ignite, which allows to significantly simplify the client code, removing from it most of the specifics of Ignite. Further, this starter can be refined, make more convenient settings for it, provide more adequate defaults. As a further development, much can be done, for example, to make it more transparent than described in
my article , screwing Ignite as an L2 cache to Activiti.
Links