通过引擎插件替换 Camunda 执行命令及 SQL ,解决 Camunda Comment FullMessages 乱码以及 Messages 长度问题

2,233 阅读3分钟

环境: Spring-boot 嵌入式开发 、Canmunda版本7.16.0 、Canmunda依赖 camunda-bpm-spring-boot-starter

问题概况

在Camunda中,可以通过 TaskService#createComment 来为任务创建附言/评论,通常来说,在审批流中审批不通过时需要一些附言来帮助流程申请者了解详情,这些附言既可以放在流程业务中自行保存,也可以委托给工作流引擎保存,因为采用工作流引擎自带的 Comment 功能比较方便,所以项目初期就采用了这种方式,后来开发到后面,发现 Camunda 中的 Comment 具有一些限制,包括

  1. org.camunda.bpm.engine.impl.cmd.AddCommentCmd中,将Comment messages 字段限制为163字符:
String eventMessage = message.replaceAll("\s+", " ");
if (eventMessage.length() > 163) {
  eventMessage = eventMessage.substring(0, 160) + "...";
}
comment.setMessage(eventMessage);
  1. 在数据库表act_hi_commentFULL_MSG_使用longBlob类型,而在org.camunda.bpm.engine.impl.persistence.entity.CommentEntity 中直接使用java.lang.String#getBytes() 使用平台默认编码获取字节:
public byte[] getFullMessageBytes() {
  return (fullMessage!=null ? fullMessage.getBytes() : null);
}

public void setFullMessageBytes(byte[] fullMessageBytes) {
  fullMessage = (fullMessageBytes!=null ? new String(fullMessageBytes) : null );
}

问题原因

由于CommentEntity使用byte数组存储字符串且没有指定编码格式,就可能由于环境问题如 windows、linux 或者 docker 镜像 openjdk-docker issues 105 在没有显式指定jvm编码时,出现平台默认编码System.getProperty("file.encoding") 不同,从而导致字节编解码格式出现不一致从而导致乱码问题

Comment Full Message longblob UTF-8 以及 camunda BPMCAM-3035中,该问题具有详细的记录。

解决办法

一. 显式指定jvm编码

添加-Dfile.encoding=UTF-8 -Dsun.jnu.encoding=utf-8 jvm 启动参数,或者在dockerFile中 ENTRYPOINT ["java","-XX:+UseContainerSupport -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=utf-8 -Duser.timezone=GMT+8","-jar","/XXX.jar"] 指定jvm参数

二. 替换org.camunda.bpm.engine.impl.persistence.entity.CommentEntity

//...
public byte[] getFullMessageBytes() {
    return (fullMessage != null ? fullMessage.getBytes(StandardCharsets.UTF_8) : null);
}

public void setFullMessageBytes(byte[] fullMessageBytes) {
    fullMessage = (fullMessageBytes != null ? new String(fullMessageBytes, StandardCharsets.UTF_8) : null);
}
//...

将修改后java文件替换掉源文件,由于类替换的限制,这种方法只适合直接引入camunda组件的程序,不适合提供组件jar包形式。

三. 通过无侵入的 ProcessEnginePlugin 来解决:

在Camunda中,我们可以实现org.camunda.bpm.engine.impl.cfg.AbstractProcessEnginePlugin,在引擎配置前后以及引擎初始化后三个节点修改引擎配置来添加自定义的各种组件,如org.camunda.bpm.spring.boot.starter.configuration.impl.DefaultDatasourceConfiguration 设置引擎的数据源,或者添加自定义的bpmn解析监听器org.camunda.bpm.engine.impl.bpmn.parser.BpmnParseListener ,由于可配置的属性相当全面,我们甚至可以覆盖引擎中的各种Service甚至是SQL,从而采用我们自己的实现方案

  1. 子类化CommentEntity 重写 fullMessages getter/setter 方法
public static class UTF8CommentEntity extends CommentEntity {
    @Override
    public byte[] getFullMessageBytes() {
        return (fullMessage != null ? fullMessage.getBytes(StandardCharsets.UTF_8) : null);
    }

    @Override
    public void setFullMessageBytes(byte[] fullMessageBytes) {
        fullMessage = (fullMessageBytes != null ? new String(fullMessageBytes, StandardCharsets.UTF_8) : null);
    }
}
  1. 子类化AddCommentCmd 重写 execute 方法,使用我们的实现类来执行插入命令,此处也可在不超过数据库字段4000字符限制的情况下 随意更改 messages 163字符长度的限制
private static class UTF8AddCommentCmd extends AddCommentCmd {
    //...省略
    @Override
    public Comment execute(CommandContext commandContext) {
        //...省略
        CommentEntity comment = new UTF8CommentEntity();
        //...省略
        String eventMessage = message.replaceAll("\s+", " ");
        if (eventMessage.length() > 3000) {
            eventMessage = eventMessage.substring(0, 3000) + "...";
        }
        //...省略
    }
}
  1. 子类化TaskServiceImpl 重写 createComment方法,将我们的CMD 命令替换原有的命令
private static class UTF8CommentTaskService extends TaskServiceImpl {
    @Override
    public Comment createComment(String taskId, String processInstance, String message) {
        return commandExecutor.execute(new UTF8AddCommentCmd(taskId, processInstance, message));
    }
}
  1. 实现org.camunda.bpm.engine.impl.cfg.AbstractProcessEnginePlugin将我们的TaskService 替换原有的 TaskSerivce
public class UTF8CommentPlugin extends AbstractProcessEnginePlugin {
    @Override
    public void preInit(ProcessEngineConfigurationImpl processEngineConfiguration) {
        processEngineConfiguration.setTaskService(new UTF8CommentTaskService());
        processEngineConfiguration.getDbEntityCacheKeyMapping().registerEntityCacheKey(UTF8CommentEntity.class, CommentEntity.class);
    }
}

到此为止,插入数据库中的Comment已经是UTF-8格式了,但是由于在 mybatis xml org.camunda.bpm.engine.impl.mapping.entity.Comment.xmlresultMap 依旧是原有的实体类,查询的结果依然还是会乱码

  1. 将resultmap为我们自己实现类的xml字符串嵌入类中
    private final static String UTF_8_COMMENT_XML = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
            "<!DOCTYPE mapper PUBLIC \"-//mybatis.org//DTD Mapper 3.0//EN\" \"http://mybatis.org/dtd/mybatis-3-mapper.dtd\">\n" +
            "<mapper namespace=\"" + UTF8CommentPlugin.class.getName() + "\">\n" +
            "    <resultMap id=\"commentResultMap\" type=\""+UTF8CommentEntity.class.getName()+"\">\n" +
            "        <id property=\"id\" column=\"ID_\" jdbcType=\"VARCHAR\"/>\n" +
            "        <result property=\"type\" column=\"TYPE_\" jdbcType=\"VARCHAR\"/>\n" +
            "        <result property=\"userId\" column=\"USER_ID_\" jdbcType=\"VARCHAR\"/>\n" +
            "        <result property=\"time\" column=\"TIME_\" jdbcType=\"TIMESTAMP\"/>\n" +
            "        <result property=\"taskId\" column=\"TASK_ID_\" jdbcType=\"VARCHAR\"/>\n" +
            "        <result property=\"rootProcessInstanceId\" column=\"ROOT_PROC_INST_ID_\" jdbcType=\"VARCHAR\"/>\n" +
            "        <result property=\"processInstanceId\" column=\"PROC_INST_ID_\" jdbcType=\"VARCHAR\"/>\n" +
            "        <result property=\"action\" column=\"ACTION_\" jdbcType=\"VARCHAR\"/>\n" +
            "        <result property=\"message\" column=\"MESSAGE_\" jdbcType=\"VARCHAR\"/>\n" +
            "        <result property=\"fullMessageBytes\" column=\"FULL_MSG_\" jdbcType=\"BLOB\"/>\n" +
            "        <result property=\"tenantId\" column=\"TENANT_ID_\" jdbcType=\"VARCHAR\"/>\n" +
            "        <result property=\"removalTime\" column=\"REMOVAL_TIME_\" jdbcType=\"TIMESTAMP\"/>\n" +
            "    </resultMap>\n" +
            "    <select id=\"selectCommentsByTaskId_UTF8\" parameterType=\"org.camunda.bpm.engine.impl.db.ListQueryParameterObject\"\n" +
            "            resultMap=\"commentResultMap\">\n" +
            "        select *\n" +
            "        from ${prefix}ACT_HI_COMMENT\n" +
            "        where TASK_ID_ = #{parameter,jdbcType=VARCHAR}\n" +
            "          and TYPE_ = 'comment'\n" +
            "        order by TIME_ desc\n" +
            "    </select>\n" +
            "    <select id=\"selectCommentsByProcessInstanceId_UTF8\"\n" +
            "            parameterType=\"org.camunda.bpm.engine.impl.db.ListQueryParameterObject\" resultMap=\"commentResultMap\">\n" +
            "        select *\n" +
            "        from ${prefix}ACT_HI_COMMENT\n" +
            "        where PROC_INST_ID_ = #{parameter,jdbcType=VARCHAR}\n" +
            "        order by TIME_ desc\n" +
            "    </select>\n" +
            "    <select id=\"selectCommentByTaskIdAndCommentId_UTF8\" parameterType=\"map\" resultMap=\"commentResultMap\">\n" +
            "        select *\n" +
            "        from ${prefix}ACT_HI_COMMENT\n" +
            "        where TASK_ID_ = #{taskId,jdbcType=VARCHAR}\n" +
            "          and ID_ = #{id,jdbcType=VARCHAR}\n" +
            "    </select>\n" +
            "</mapper>";
  1. 将我们的SQL 替换原有的SQL:
    Camunda执行Sql的方式是通过org.camunda.bpm.engine.impl.db.sql.DbSqlSession 直接从org.apache.ibatis.session.SqlSession 中通过 statementId 直接执行Sql,由于Camunda 为了支持不同类型的数据库,所以通过 org.camunda.bpm.engine.impl.db.sql.DbSqlSessionFactory#mapStatement 映射所有的mybatis statementId ,而正好这一层映射,可以让我们将自己的SQL替换Camunda的SQL,我们要做的就是获取Camunda内部的Mybatis org.apache.ibatis.session.Configuration ,向其中注册我们的Mapper xml,然后将Comment.xml中查询sql的statementId替换为我们的 xml 中的 statementId
public class UTF8CommentPlugin extends AbstractProcessEnginePlugin {
    @Override
    public void preInit(ProcessEngineConfigurationImpl processEngineConfiguration) {
        processEngineConfiguration.setTaskService(new UTF8CommentTaskService());
        processEngineConfiguration.getDbEntityCacheKeyMapping().registerEntityCacheKey(UTF8CommentEntity.class, CommentEntity.class);
    }

    @Override
    @SneakyThrows
    public void postInit(ProcessEngineConfigurationImpl processEngineConfiguration) {
        // org.camunda.bpm.engine.impl.db.sql.DbSqlSessionFactory.getInsertStatement
        // org.camunda.bpm.engine.impl.db.sql.DbSqlSessionFactory.getStatement 使用insert 前缀 + CommentEntity 去除 Entity 作为 mybatis 映射 如 [insertComment]
        // 然后通过mybatis SqlSession 通过直接使用 statementId=[insertComment]
        // (正常mapper 的statementId 为 org.apache.ibatis.binding.MapperMethod.SqlCommand.resolveMappedStatement)== mapperInterface.getName() + "." + methodName == xml namespace + "." + sqlId
        // 但是 由于iBatis的留下的方式,如果mybatis xml sql语句id 全局唯一,直接使用语句id也可
        // 所以必须手动添加映射 否则无法对应到正确的 sql语句
        // sql 文件位于 org.camunda.bpm.engine.impl.mapping.entity.Comment.xml
        DbSqlSessionFactory dbSqlSessionFactory = processEngineConfiguration.getDbSqlSessionFactory();
        dbSqlSessionFactory.getInsertStatements().put(UTF8CommentEntity.class, "insertComment");
        // 添加替换的sql
        Map<String, String> statementMappings = dbSqlSessionFactory.getStatementMappings();
        InputStream inputStream = new InMemoryResource(UTF_8_COMMENT_XML).getInputStream();
        Configuration configuration = processEngineConfiguration.getSqlSessionFactory().getConfiguration();
        XMLMapperBuilder xmlParser = new XMLMapperBuilder(inputStream, configuration, getClass().getCanonicalName(), configuration.getSqlFragments());
        xmlParser.parse();
        //添加sql statementId 映射,将org.camunda.bpm.engine.impl.persistence.entity.CommentManager.findCommentsByTaskId 中的statementId映射为我们的
        statementMappings.put("selectCommentsByTaskId", "selectCommentsByTaskId_UTF8");
        statementMappings.put("selectCommentsByProcessInstanceId", "selectCommentsByProcessInstanceId_UTF8");
        statementMappings.put("selectCommentByTaskIdAndCommentId", "selectCommentByTaskIdAndCommentId_UTF8");
    }
}
  1. 在spring中 我们直接将此插件注册为Bean即可生效 (源码位置: org.camunda.bpm.spring.boot.starter.CamundaBpmConfiguration#processEngineConfigurationImpl)
@Bean
UTF8CommentPlugin utf8CommentPlugin() {
    return new UTF8CommentPlugin();
}

完整代码

import lombok.SneakyThrows;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.session.Configuration;
import org.camunda.bpm.engine.ProcessEngineException;
import org.camunda.bpm.engine.impl.TaskServiceImpl;
import org.camunda.bpm.engine.impl.cfg.AbstractProcessEnginePlugin;
import org.camunda.bpm.engine.impl.cfg.ProcessEngineConfigurationImpl;
import org.camunda.bpm.engine.impl.cmd.AddCommentCmd;
import org.camunda.bpm.engine.impl.db.sql.DbSqlSessionFactory;
import org.camunda.bpm.engine.impl.interceptor.CommandContext;
import org.camunda.bpm.engine.impl.persistence.entity.CommentEntity;
import org.camunda.bpm.engine.impl.persistence.entity.ExecutionEntity;
import org.camunda.bpm.engine.impl.persistence.entity.TaskEntity;
import org.camunda.bpm.engine.impl.util.ClockUtil;
import org.camunda.bpm.engine.task.Comment;
import org.camunda.bpm.engine.task.Event;
import org.springframework.security.util.InMemoryResource;

import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Map;

import static org.camunda.bpm.engine.impl.util.EnsureUtil.ensureNotNull;

/**
 * 更改消息160字符的限制,以及blob使用UTF8编解码
 *
 * @see <a href="https://groups.google.com/g/camunda-bpm-users/c/c78oP4wtCLE">Comment FullMessage longblob UTF-8</a>
 */
public class UTF8CommentPlugin extends AbstractProcessEnginePlugin {
    @Override
    public void preInit(ProcessEngineConfigurationImpl processEngineConfiguration) {
        processEngineConfiguration.setTaskService(new UTF8CommentTaskService());
        processEngineConfiguration.getDbEntityCacheKeyMapping().registerEntityCacheKey(UTF8CommentEntity.class, CommentEntity.class);
    }

    @Override
    @SneakyThrows
    public void postInit(ProcessEngineConfigurationImpl processEngineConfiguration) {
        // org.camunda.bpm.engine.impl.db.sql.DbSqlSessionFactory.getInsertStatement
        // org.camunda.bpm.engine.impl.db.sql.DbSqlSessionFactory.getStatement 使用insert 前缀 + CommentEntity 去除 Entity 作为 mybatis 映射 如 [insertComment]
        // 然后通过mybatis SqlSession 通过直接使用 statementId=[insertComment]
        // (正常mapper 的statementId 为 org.apache.ibatis.binding.MapperMethod.SqlCommand.resolveMappedStatement)== mapperInterface.getName() + "." + methodName == xml namespace + "." + sqlId
        // 但是 由于iBatis的留下的方式,如果mybatis xml sql语句id 全局唯一,直接使用语句id也可
        // 所以必须手动添加映射 否则无法对应到正确的 sql语句
        // sql 文件位于 org.camunda.bpm.engine.impl.mapping.entity.Comment.xml
        DbSqlSessionFactory dbSqlSessionFactory = processEngineConfiguration.getDbSqlSessionFactory();
        dbSqlSessionFactory.getInsertStatements().put(UTF8CommentEntity.class, "insertComment");
        // 添加替换的sql
        Map<String, String> statementMappings = dbSqlSessionFactory.getStatementMappings();
        InputStream inputStream = new InMemoryResource(UTF_8_COMMENT_XML).getInputStream();
        Configuration configuration = processEngineConfiguration.getSqlSessionFactory().getConfiguration();
        XMLMapperBuilder xmlParser = new XMLMapperBuilder(inputStream, configuration, getClass().getCanonicalName(), configuration.getSqlFragments());
        xmlParser.parse();
        //添加sql statementId 映射,将org.camunda.bpm.engine.impl.persistence.entity.CommentManager.findCommentsByTaskId 中的statementId映射为我们的
        statementMappings.put("selectCommentsByTaskId", "selectCommentsByTaskId_UTF8");
        statementMappings.put("selectCommentsByProcessInstanceId", "selectCommentsByProcessInstanceId_UTF8");
        statementMappings.put("selectCommentByTaskIdAndCommentId", "selectCommentByTaskIdAndCommentId_UTF8");
    }

    private static class UTF8CommentTaskService extends TaskServiceImpl {

        @Override
        public Comment createComment(String taskId, String processInstance, String message) {
            return commandExecutor.execute(new UTF8AddCommentCmd(taskId, processInstance, message));
        }
    }

    private static class UTF8AddCommentCmd extends AddCommentCmd {

        public UTF8AddCommentCmd(String taskId, String processInstanceId, String message) {
            super(taskId, processInstanceId, message);
        }

        @Override
        public Comment execute(CommandContext commandContext) {

            if (processInstanceId == null && taskId == null) {
                throw new ProcessEngineException("Process instance id and task id is null");
            }

            ensureNotNull("Message", message);

            String userId = commandContext.getAuthenticatedUserId();
            CommentEntity comment = new UTF8CommentEntity();
            comment.setUserId(userId);
            comment.setType(CommentEntity.TYPE_COMMENT);
            comment.setTime(ClockUtil.getCurrentTime());
            comment.setTaskId(taskId);
            comment.setProcessInstanceId(processInstanceId);
            comment.setAction(Event.ACTION_ADD_COMMENT);

            ExecutionEntity execution = getExecution(commandContext);
            if (execution != null) {
                comment.setRootProcessInstanceId(execution.getRootProcessInstanceId());
            }

            if (isHistoryRemovalTimeStrategyStart()) {
                provideRemovalTime(comment);
            }

            String eventMessage = message.replaceAll("\\s+", " ");
            if (eventMessage.length() > 3000) {
                eventMessage = eventMessage.substring(0, 3000) + "...";
            }
            comment.setMessage(eventMessage);

            comment.setFullMessage(message);

            commandContext
                    .getCommentManager()
                    .insert(comment);

            TaskEntity task = getTask(commandContext);
            if (task != null) {
                task.triggerUpdateEvent();
            }

            return comment;
        }
    }

    public static class UTF8CommentEntity extends CommentEntity {
        @Override
        public byte[] getFullMessageBytes() {
            return (fullMessage != null ? fullMessage.getBytes(StandardCharsets.UTF_8) : null);
        }

        @Override
        public void setFullMessageBytes(byte[] fullMessageBytes) {
            fullMessage = (fullMessageBytes != null ? new String(fullMessageBytes, StandardCharsets.UTF_8) : null);
        }
    }

    private final static String UTF_8_COMMENT_XML = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
            "<!DOCTYPE mapper PUBLIC \"-//mybatis.org//DTD Mapper 3.0//EN\" \"http://mybatis.org/dtd/mybatis-3-mapper.dtd\">\n" +
            "<mapper namespace=\"" + UTF8CommentPlugin.class.getName() + "\">\n" +
            "    <resultMap id=\"commentResultMap\" type=\""+UTF8CommentEntity.class.getName()+"\">\n" +
            "        <id property=\"id\" column=\"ID_\" jdbcType=\"VARCHAR\"/>\n" +
            "        <result property=\"type\" column=\"TYPE_\" jdbcType=\"VARCHAR\"/>\n" +
            "        <result property=\"userId\" column=\"USER_ID_\" jdbcType=\"VARCHAR\"/>\n" +
            "        <result property=\"time\" column=\"TIME_\" jdbcType=\"TIMESTAMP\"/>\n" +
            "        <result property=\"taskId\" column=\"TASK_ID_\" jdbcType=\"VARCHAR\"/>\n" +
            "        <result property=\"rootProcessInstanceId\" column=\"ROOT_PROC_INST_ID_\" jdbcType=\"VARCHAR\"/>\n" +
            "        <result property=\"processInstanceId\" column=\"PROC_INST_ID_\" jdbcType=\"VARCHAR\"/>\n" +
            "        <result property=\"action\" column=\"ACTION_\" jdbcType=\"VARCHAR\"/>\n" +
            "        <result property=\"message\" column=\"MESSAGE_\" jdbcType=\"VARCHAR\"/>\n" +
            "        <result property=\"fullMessageBytes\" column=\"FULL_MSG_\" jdbcType=\"BLOB\"/>\n" +
            "        <result property=\"tenantId\" column=\"TENANT_ID_\" jdbcType=\"VARCHAR\"/>\n" +
            "        <result property=\"removalTime\" column=\"REMOVAL_TIME_\" jdbcType=\"TIMESTAMP\"/>\n" +
            "    </resultMap>\n" +
            "    <select id=\"selectCommentsByTaskId_UTF8\" parameterType=\"org.camunda.bpm.engine.impl.db.ListQueryParameterObject\"\n" +
            "            resultMap=\"commentResultMap\">\n" +
            "        select *\n" +
            "        from ${prefix}ACT_HI_COMMENT\n" +
            "        where TASK_ID_ = #{parameter,jdbcType=VARCHAR}\n" +
            "          and TYPE_ = 'comment'\n" +
            "        order by TIME_ desc\n" +
            "    </select>\n" +
            "    <select id=\"selectCommentsByProcessInstanceId_UTF8\"\n" +
            "            parameterType=\"org.camunda.bpm.engine.impl.db.ListQueryParameterObject\" resultMap=\"commentResultMap\">\n" +
            "        select *\n" +
            "        from ${prefix}ACT_HI_COMMENT\n" +
            "        where PROC_INST_ID_ = #{parameter,jdbcType=VARCHAR}\n" +
            "        order by TIME_ desc\n" +
            "    </select>\n" +
            "    <select id=\"selectCommentByTaskIdAndCommentId_UTF8\" parameterType=\"map\" resultMap=\"commentResultMap\">\n" +
            "        select *\n" +
            "        from ${prefix}ACT_HI_COMMENT\n" +
            "        where TASK_ID_ = #{taskId,jdbcType=VARCHAR}\n" +
            "          and ID_ = #{id,jdbcType=VARCHAR}\n" +
            "    </select>\n" +
            "</mapper>";
}