[Из песочницы] Принуждение к кэшированию: прикручиваем L2 кэш Apache Ignite к Activiti

e579dc358bf54b169825fb6300943284.jpg Часто так бывает, что есть хорошая библиотека, а чего-то в ней не хватает, каких-нибудь перламутровых пуговиц. Так и мне с Activiti, довольно популярным движком бизнес-процесcов с поддержкой BPMN 2.0, ценным своей Java-нативностью. Не вдаваясь в подробности внутреннего устройства этого продукта с открытым исходным кодом, достаточно очевидно, что в своей работе он использует разнообразные данные: метаданные определений бизнес-процессов, данные экземпляров и исторические данные. Для их хранения Activiti использует СУБД, позволяя выбрать из DB2, H2, Oracle, MySQL, MS SQL и PostgreSQL. Этот движок весьма неплох, и используется не только для маленьких поделок. Возможно, вопрос о поддержке кэширования обращений к БД в этом продукте возник не только у меня. Как минимум единожды он задавался разработчикам, которые на него ответили в том смысле, что метаданные кэшируются, а для остальных данных большого смысла в этом нет и это не просто. В принципе, про отсутствие большого смысла согласиться можно — данные конкретного экземпляра или его исторические данные с небольшой вероятностью могут повторно понадобиться. Но сценарий, когда такое всё-таки случится, тоже возможен. Например, если у нас кластер серверов Activiti с общей базой. В общем, человек с пытливым умом вполне может захотеть иметь приличный кэш в Activiti. Например, использовать в этом качестве Apache Ignite.

Под катом пример решения этой проблемы, код выложен на GitHub.

Обдумывание задачи


Что же у нас для этого есть? Прежде всего, гарантированный разработчиками кэш определений процессов, хранящийся в java.util.HashMap, что нельзя назвать enterprise решением. Для доступа к базе Activiti использует библиотеку Mybatis которая, разумеется, кэширование поддерживает. Для своего функционирования Mybatis использует xml-конфигурации, и этих xml в Activiti есть очень много и в них присутствуют определения запросов примерно такого вида:
	

В ссылках ниже указана хабростатья о том, как скрестить Apache Ignite с Mybatis. Из неё становится понятно, что если бы в тэге select был установлен признак useCache=«true», и был указан тип кэша…

… то этого было бы почти достаточно. Там же указывается микро-библиотека org.mybatis.caches: mybatis-ignite в которой ровно 2 класса и никакой специфики именно Mybatis. То есть, вполне общее решение.

Хотя Activiti живёт на GitHub’е и его можно невозбранно форкнуть, внести изменения в конфиги Mybatis и наслаждаться кэшированием, предлагаю этим путём не идти. Это нас обрекает на поддержание собственной версии весьма немаленького проекта, созданного ради внесения ерундовых изменений. Но Activiti поддерживает Spring Boot и это открывает новые перспективы. Для эксперимента взята последняя на момент написания статьи 4-я бета Activiti версии 6.0.

Решение


Sql-запросы в Mybatis описываются классом org.apache.ibatis.mapping.MappedStatement, у которого, как не трудно догадаться, есть метод isUseCache. Объекты MappedStatement возвращает класс org.apache.ibatis.session.Configuration, у которого есть метод getMappedStatement. А конфигурация создаётся в классе org.activiti.spring.SpringProcessEngineConfiguration, который инжектится в процессе автоконфигурирования Spring Boot. Таким образом, надо как-то повлиять на результат, возвращаемый классом MappedStatement. К сожалению, совсем простых путей для этого нет, и я не нашёл ничего лучшего, как проинструментировать всё подряд с помощью библиотеки cglib, которая попадает к нам вместе со спрингом. Алгоритм вкратце такой: переопределяем автоконфигурацию Spring Boot для объекта SpringProcessEngineConfiguration, который управляет конфигурированием Activiti, подменяя объект его инструментированной версией, которая возвращает инструментированный объект Configuration, который возвращает новые объекты MappedStatement (к сожалению, это финальный класс, его нельзя проинструментировать с помощью cglib), которые думают, что они должны использовать кэш. И да, новый объект Configuration знает о существовании Apache Ignite. Возможно, звучит сложно, но на самом деле всё прозрачно (на всякий случай ссылка гайд по cglib прилагается).
Окончательный код будет такой
@Configuration
@ConditionalOnClass(name = "javax.persistence.EntityManagerFactory")
@EnableConfigurationProperties(ActivitiProperties.class)
public class CachedJpaConfiguration extends JpaProcessEngineAutoConfiguration.JpaConfiguration {
    @Bean
    @ConditionalOnMissingBean
    public SpringProcessEngineConfiguration springProcessEngineConfiguration(
            DataSource dataSource, EntityManagerFactory entityManagerFactory,
            PlatformTransactionManager transactionManager, SpringAsyncExecutor springAsyncExecutor)
                                throws IOException {
        return
                getCachedConfig(super.springProcessEngineConfiguration
                              (dataSource, entityManagerFactory, transactionManager, springAsyncExecutor));
    }

    private SpringProcessEngineConfiguration getCachedConfig(final SpringProcessEngineConfiguration parentConfig) {
        Enhancer enhancer = new Enhancer();
        CallbackHelper callbackHelper = new CallbackHelper(SpringProcessEngineConfiguration.class, new Class[0]) {
            @Override
            protected Object getCallback(Method method) {
                if (method.getName().equals("initMybatisConfiguration")) {
                    return (MethodInterceptor) (obj, method1, args, proxy) ->
                            getCachedConfiguration(
                               (org.apache.ibatis.session.Configuration) proxy.invokeSuper(obj, args));
                } else {
                    return NoOp.INSTANCE;
                }
            }
        };

        enhancer.setSuperclass(SpringProcessEngineConfiguration.class);
        enhancer.setCallbackFilter(callbackHelper);
        enhancer.setCallbacks(callbackHelper.getCallbacks());

        SpringProcessEngineConfiguration result = (SpringProcessEngineConfiguration) enhancer.create();
        result.setDataSource(parentConfig.getDataSource());
        result.setTransactionManager(parentConfig.getTransactionManager());
        result.setDatabaseSchemaUpdate("create-drop");

        return result;
    }

    private org.apache.ibatis.session.Configuration 
                         getCachedConfiguration(org.apache.ibatis.session.Configuration configuration) {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(org.apache.ibatis.session.Configuration.class);
        enhancer.setCallback(new CachedConfigurationHandler(configuration));
        return (org.apache.ibatis.session.Configuration) enhancer.create();
    }

    private class CachedConfigurationHandler implements InvocationHandler {
        private org.apache.ibatis.session.Configuration configuration;
        CachedConfigurationHandler(org.apache.ibatis.session.Configuration configuration) {
            this.configuration = configuration;
            this.configuration.addCache(IgniteCacheAdapter.INSTANCE);
        }

        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            Object originalResult = method.invoke(configuration, args);
            if (method.getName().equals("getMappedStatement")) {
                return getCachedMappedStatement((MappedStatement) originalResult);
            }

            return originalResult;
        }
    }

    private MappedStatement getCachedMappedStatement(MappedStatement mappedStatement) {
        return new MappedStatement
                .Builder(mappedStatement.getConfiguration(), mappedStatement.getId(),
                     mappedStatement.getSqlSource(), mappedStatement.getSqlCommandType())
                .databaseId(mappedStatement.getDatabaseId())
                .resource(mappedStatement.getResource())
                .fetchSize(mappedStatement.getFetchSize())
                .timeout(mappedStatement.getTimeout())
                .statementType(mappedStatement.getStatementType())
                .resultSetType(mappedStatement.getResultSetType())
                .parameterMap(mappedStatement.getParameterMap())
                .resultMaps(mappedStatement.getResultMaps())
                .cache(IgniteCacheAdapter.INSTANCE)
                .useCache(true)
                .build();
    }
}


Обратите внимание на строку:
result.setDatabaseSchemaUpdate("create-drop");

Здесь мы обеспечили автоматическое создание таблиц Activiti. Не делайте так в продакшене.

Теперь надо подключить Ignite. Его установку и настройку здесь описывать не буду, версия использовалась 1.7.0. В простейшем варианте, который я использовал, его достаточно просто скачать и распаковать. Его конфигурирование в приложении можно выполнить двумя способами: через xml, так как Ignite это Spring-приложение, или Java кодом. Я выбрал второй вариант:

Простейший конфиг для Ignite на Java
        IgniteConfiguration igniteCfg = new IgniteConfiguration();
        igniteCfg.setGridName("testGrid");
        igniteCfg.setClientMode(true);
        igniteCfg.setIgniteHome("");

        CacheConfiguration config = new CacheConfiguration();
        config.setName("myBatisCache");
        config.setCacheMode(CacheMode.LOCAL);
        config.setStatisticsEnabled(true);
        config.setWriteSynchronizationMode(CacheWriteSynchronizationMode.FULL_SYNC);
        igniteCfg.setCacheConfiguration(config);

        TcpDiscoverySpi tcpDiscoverySpi = new TcpDiscoverySpi();
        TcpDiscoveryJdbcIpFinder jdbcIpFinder = new TcpDiscoveryJdbcIpFinder();
        jdbcIpFinder.setDataSource(dataSource);
        tcpDiscoverySpi.setIpFinder(jdbcIpFinder);
        tcpDiscoverySpi.setLocalAddress("localhost");
        igniteCfg.setDiscoverySpi(tcpDiscoverySpi);

        TcpCommunicationSpi tcpCommunicationSpi = new TcpCommunicationSpi();
        tcpCommunicationSpi.setLocalAddress("localhost");
        igniteCfg.setCommunicationSpi(tcpCommunicationSpi);


Класс IgniteCacheAdapter, в котором эта конфигурация лежит, основан на упрощённой по-максимуму версии класса из библиотеки org.mybatis.caches: mybatis-ignite. Собственно это всё, наши запросы кэшируются. Обратите внимание на указанный путь к рантайму Ignite, тут надо подставить свой.

Результаты


Протестировать приложение можно с помощью вызовов REST-сервисов, описанных в гайде [2], там есть простенький бизнес-процесс для рассмотрения резюме. Позапускав несколько раз, можно посмотреть статистику, сбор которой был включен командой config.setStatisticsEnabled (true):
Ignition.ignite("testGrid").getOrCreateCache("myBatisCache").metrics();

В дебаге можно посмотреть эти метрики, в частности, количество чтений из кэша и количество промахов. После 2 запусков процесса 16 чтений и 16 промахов. То есть в кэш ни разу не попали.

Выводы


Конкретно в рассмотренном примере, как оказалось, L2 кэш не нужен. Но это был очень простой и не показательный пример. Возможно, в более сложной топологии и с другим характером нагрузки, с несколькими пользователями картина будет другая. Как говорится, будем искать…

Также в статье была показана возможность не очень грубого вмешательства в большую библиотеку для существенного изменения её поведения.

Ссылки


  • [1] Код примера на GitHub
  • [2] Статья об использовании Apache Ignite в качестве MyBatis L2 кэша
  • [3] Getting started with Activiti and Spring Boot
  • [4] Руководство по cglib

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

© Habrahabr.ru