Monday, April 23, 2012

Hacking Activiti BPM engine: how to use custom MyBatis queries

The issue

One big change that Activiti has introduced over jBPMv4 was to forego Hibernate ORM and use MyBatis. MyBatis is simpler and cleaner to use, but - it does not provide simple interface to perform queries that are not defined in annotations or XML files.

And even though Activiti provides great query interface, so you can do things like this:

List<Task> tasks = getProcessEngine().getTaskService().createTaskQuery()  
    .taskName(taskName)  
    .executionId(taskExecutionId)  
    .taskAssignee(user.getLogin())  
    .listPage(0, 1);

with relative ease, there are still some limitations to the API provided by Activiti Service classes (e.g. you cannot add IN clause for task assignees).

Possible solutions

One thing that you can do, is to just skip Activiti interface, and access database directly. Surely it will work, but such approach has many downsides - you will be fetching information about Activiti processes in two different ways, you will have to map data to Activiti objects yourself, and so on.

What we have managed to do for Aperte Workflow <-> Activiti integration (in version 2.0, where API is a bit more demanding), is indeed a hack. It is not as elegant as constructing query for jBPM, but still is quite simple and manageable. It propably can be done with Spring in a similiar fashion, or with SelectBuilder - but the principle is similiar.

The hack

1. Enhance Activiti's MyBatis mapping file - by adding our own version in a different package.

The enhanced file looks almost like the original - with one exception:

<?xml version="1.0" encoding="UTF-8"?>  
    
 <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" 
  "http://mybatis.org/dtd/mybatis-3-config.dtd">  
    
 <configuration>  
 <settings>  
 <setting name="lazyLoadingEnabled" value="false" />  
 </settings>  
 <mappers>  
     <mapper resource="org/activiti/db/mapping/entity/Attachment.xml" />  
     <mapper resource="org/activiti/db/mapping/entity/Comment.xml" />  
     <mapper resource="org/activiti/db/mapping/entity/Deployment.xml" />  
     <mapper resource="org/activiti/db/mapping/entity/Execution.xml" />  
     <mapper resource="org/activiti/db/mapping/entity/Group.xml" />  
     <mapper resource="org/activiti/db/mapping/entity/HistoricActivityInstance.xml" />  
     <mapper resource="org/activiti/db/mapping/entity/HistoricDetail.xml" />  
     <mapper resource="org/activiti/db/mapping/entity/HistoricProcessInstance.xml" />  
     <mapper resource="org/activiti/db/mapping/entity/HistoricTaskInstance.xml" />  
     <mapper resource="org/activiti/db/mapping/entity/IdentityInfo.xml" />  
     <mapper resource="org/activiti/db/mapping/entity/IdentityLink.xml" />  
     <mapper resource="org/activiti/db/mapping/entity/Job.xml" />  
     <mapper resource="org/activiti/db/mapping/entity/Membership.xml" />  
     <mapper resource="org/activiti/db/mapping/entity/ProcessDefinition.xml" />  
     <mapper resource="org/activiti/db/mapping/entity/Property.xml" />  
     <mapper resource="org/activiti/db/mapping/entity/Resource.xml" />  
     <mapper resource="org/activiti/db/mapping/entity/TableData.xml" />  
     <mapper resource="org/activiti/db/mapping/entity/Task.xml" />  
     <mapper resource="org/activiti/db/mapping/entity/User.xml" />  
     <mapper resource="org/activiti/db/mapping/entity/VariableInstance.xml" />  
     <mapper resource="org/aperteworkflow/ext/activiti/mybatis/Task-enhanced.xml" />  
 </mappers>  
 </configuration>

And the addition is:

<mapper resource="org/aperteworkflow/ext/activiti/mybatis/Task-enhanced.xml" />

2. Create enhanced mapper resource

This mapper resource add additional query conditions to the initial task query. The beginning of it is the same as original, but at the end we have added support for in clauses on several fields:

<!-- new conditions -->  
                 <if test="owners != null && owners.size() > 0">  
                     and T.OWNER_ IN  
                     <foreach item="owner" index="index" collection="owners"  
                              open="(" separator="," close=")">  
                         #{owner}  
                     </foreach>  
                 </if>  
                 <if test="notOwners != null && notOwners.size() > 0">  
                     and T.OWNER_ NOT IN  
                     <foreach item="owner" index="index" collection="notOwners"  
                              open="(" separator="," close=")">  
                         #{owner}  
                     </foreach>  
                 </if>  
                 <if test="groups != null && groups.size() > 0">  
                     and T.ASSIGNEE_ is null  
                     and I.TYPE_ = 'candidate'  
                     and I.GROUP_ID_ IN  
                     <foreach item="group" index="index" collection="groups"  
                              open="(" separator="," close=")">  
                         #{group}  
                     </foreach>  
                 </if>  
                 <if test="taskNames != null && taskNames.size() > 0">  
                     and T.NAME_ IN  
                     <foreach item="name" index="index" collection="taskNames"  
                              open="(" separator="," close=")">  
                         #{name}  
                     </foreach>  
                 </if>  
             </foreach>  
         </where>  
     </sql>  

The entire mapper file can be downloaded here from Aperte Workflow's github repository - core/activiti-context/src/main/resources/org/aperteworkflow/ext/activiti/mybatis/Task-enhanced.xml.

3. Introduce new configuration to MyBatis

As MyBatis runs inside of Activiti internals, we have to access and alter Activiti configuration mechanisms. To do that, we simply override one of the methods with our copy (which points to a new mapping file):

 public class CustomStandaloneProcessEngineConfiguration 
           extends StandaloneProcessEngineConfiguration {  
    
         @Override  
         protected void initSqlSessionFactory() {  
             if (sqlSessionFactory == null) {  
                 InputStream inputStream = null;  
                 try {  
                     inputStream = ReflectUtil.getResourceAsStream(
                       "org/aperteworkflow/ext/activiti/mybatis/mappings-enhanced.xml");  
    
                     // update the jdbc parameters to the configured ones...  
                     Environment environment = new Environment("default", transactionFactory, 
                                                               dataSource);  
                     Reader reader = new InputStreamReader(inputStream);  
                     XMLConfigBuilder parser = new XMLConfigBuilder(reader);  
                     Configuration configuration = parser.getConfiguration();  
                     configuration.setEnvironment(environment);  
                     configuration.getTypeHandlerRegistry().register(VariableType.class, 
                             JdbcType.VARCHAR,  
                             new IbatisVariableTypeHandler());  
                     configuration = parser.parse();  
    
                     sqlSessionFactory = new DefaultSqlSessionFactory(configuration);  
    
                 } catch (Exception e) {  
                     throw new ActivitiException(
                      "Error while building ibatis SqlSessionFactory: " + e.getMessage(), e);  
                 } finally {  
                     IoUtil.closeSilently(inputStream);  
                 }  
             }  
         }  
     }  

You can of course do lots of amazing customizations here. And use this new class for Activiti BPM engine initialization:

CustomStandaloneProcessEngineConfiguration customStandaloneProcessEngineConfiguration =   
         new CustomStandaloneProcessEngineConfiguration();  
         customStandaloneProcessEngineConfiguration  
         .setDatabaseSchemaUpdate(ProcessEngineConfiguration.DB_SCHEMA_UPDATE_TRUE)  
         .setDataSource(getDataSourceWrapper(sess))  
         .setHistory(ProcessEngineConfiguration.HISTORY_FULL)  
         .setTransactionsExternallyManaged(true); 

4. Enhance query object

Activiti uses wonderful, clean and simple query objects, that provide structure to MyBatis query parameters. To introduce new parameters, all we have to do, is to enhance TaskQueryImpl class with our own:

 package org.aperteworkflow.ext.activiti.mybatis;  

 import org.activiti.engine.impl.TaskQueryImpl;  
 import java.util.HashSet;  
 import java.util.Set;  
 /** 
  * @author tlipski@bluesoft.net.pl 
  */  
 public class TaskQueryImplEnhanced extends TaskQueryImpl {  
     private Set<String> creators = new HashSet<String>();  
     private Set<String> owners = new HashSet<String>();  
     private Set<String> groups = new HashSet<String>();  
     private Set<String> notOwners = new HashSet<String>();  
     private Set<String> taskNames = new HashSet<String>();  
    
     public Set<String> getGroups() {  
         return groups;  
     }  
    
     public Set<String> getNotOwners() {  
         return notOwners;  
     }  
    
     public Set<String> getOwners() {  
         return owners;  
     }  
    
     public Set<String> getCreators() {  
         return owners;  
     }  
    
     public Set<String> getTaskNames() {  
         return taskNames;  
     }  
    
     public TaskQueryImplEnhanced addTaskName(String name) {  
         taskNames.add(name);  
         return this;  
     }  
     public TaskQueryImplEnhanced addOwner(String login) {  
         owners.add(login);  
         return this;  
     }  
    
     public TaskQueryImplEnhanced addGroup(String name) {  
         groups.add(name);  
         return this;  
     }  
    
     public TaskQueryImplEnhanced addNotOwner(String login) {  
         notOwners.add(login);  
         return this;  
     }  
    
     public TaskQueryImplEnhanced addCreator(String login) {  
         creators.add(login);  
         return this;  
     }  
 }  

5. Invoke new query

And finally, we can invoke our new query with multiple assignees and other custom where clauses:

final TaskQueryImplEnhanced q = new TaskQueryImplEnhanced();  
 for (UserData u : filter.getOwners()) {  
     q.addOwner(u.getLogin());  
 }  
 for (UserData u : filter.getCreators()) {  
     q.addCreator(u.getLogin());  
 }  
 for (UserData u : filter.getNotOwners()) {  
     q.addNotOwner(u.getLogin());  
 }  
 for (String qn : filter.getQueues()) {  
     q.addGroup(qn);  
 }  
    
 ActivitiContextFactoryImpl.CustomStandaloneProcessEngineConfiguration 
   processEngineConfiguration = getProcessEngineConfiguration();  
 CommandExecutor commandExecutorTxRequired = processEngineConfiguration
   .getCommandExecutorTxRequired();  
 List<Task> tasks = commandExecutorTxRequired.execute(
   new Command<List<Task>>() {  
     @Override  
     public List<Task> execute(CommandContext commandContext) {  
        return commandContext.getDbSqlSession()
         .selectList("selectTaskByQueryCriteria_Enhanced", q);  
     }  
 });  

Please note, that we are also exposing CommandExecutor object instance to access DbSqlSession.

Summary

The technique presented here would be unnecessary or much simpler if Activiti would provide external means to configure MyBatis. Maybe that is a thing, that will be available in the future versions of Activiti.

Still, even at this moment, it is fairly easy to enhance/alter Activiti's behaviour. I haven't seen any final classes (a common sight in Hibernate - e.g. org.hibernate.impl.SessionImpl) and the internals of Activiti are quite simple to understand.

3 comments :

  1. https://app.camunda.com/confluence/display/foxUserGuide/Performance+Tuning+with+custom+Queries - an alternative for custom queries.

    ReplyDelete
  2. Yeah, the principle stays the same, as Fox is a fork of Activiti. But please note, that since March 2013, Camunda Fox != Activiti.

    ReplyDelete
  3. Thank you :)
    this not just helped me complete my task. but has brought in a new light of understanding activiti.
    Thank you

    ReplyDelete