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


Ось вже вийшло дві статті в потенційно дуже довгої серії оглядів розподіленої платформи 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-залежності. У першому наближенні достатньо буде цього:

Основні залежності
<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>


Тут ми підключаємо базовий стартер 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<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));
}
}

Тобто, якщо у нас просять об'єкт типу Ignite ми делегуємо виконання IgniteSpringBootConfiguration, про яку нижче, а якщо ні — залишаємо все, як є. У IgniteSpringBootConfiguration ми передаємо анотації IgniteResource, навішені на полі. Продовжуємо розплутувати цей клубок і дивимося, що це за IgniteSpringBootConfiguration.

IgniteSpringBootConfiguration, частина 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;
}

@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<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());


Спочатку ми шукаємо вже відомі нашим додатком бины типу 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<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;
}


Тепер змінимо поведінку 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. Далі цей стартер можна допрацьовувати, зробити для нього більш зручні налаштування, передбачити більш адекватні умовчання.

Посилання
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.