事件执行器:不用写代码,给 CRM 加业务逻辑

0 阅读5分钟

事件执行器:不用写代码,给 CRM 加业务逻辑

上一篇文章写了字段怎么做到动态配置,加字段不用改代码。但字段只是数据,一个 CRM 真正麻烦的地方在业务逻辑。

"客户创建后自动通知销售","合同金额改了以后自动更新这个客户的累计成交额","工单超时三天自动升级"。这些需求放在传统系统里,一般就两条路。要么硬编码,每来一个新客户就改一次代码。要么上一套规则引擎,但那个东西很重,光配置界面就能写一个月。

我的做法是做一个事件执行器。简单说,就是让运维在后台界面上配置"当什么事件发生时做什么事",系统在每次数据写入时统一触发。这篇文章讲它的实现。

一条执行器记录长什么样

执行器本身也是元数据,存在 EventExecutor 这个实体里。一条记录大概包含这些字段:

  • sourceEntity:监听哪个实体,比如 Customer
  • eventTiming:什么时机触发,创建时、更新时还是删除时
  • condition:附加的过滤条件,只有满足条件的记录才触发
  • eventExecutorFields:只在这些字段发生变化时才触发
  • eventType:执行什么动作,字段赋值、统计回填、发通知这些
  • content:这个动作的具体参数,一个 JSON 配置体
  • priority:多个执行器之间的执行顺序

举个具体的例子,一条"客户成交后自动回填销售业绩"的配置,就是 sourceEntity 填 Customer,eventType 填统计回填,content 里写清楚统计哪个字段、按什么条件汇总。

触发时机用位掩码

eventTiming 这个字段我用了位掩码。创建是 1,更新是 2,删除是 4。

这样做的好处是一个 int 字段就能同时勾选多种时机。比如"创建和更新都触发",就是 1 和 2 按位或,存 3。查询的时候用位与判断:

List<Record> records = linkerDB.queryNoPermission(
    "select * from EventExecutor where sourceEntity = ? and {{{eventTiming}} & 2 > 0} order by priority",
    entity.getName()).records();

这里的 {{{eventTiming}} & 2 > 0 是上一篇提过的自研 DSL 的位运算语法,翻译成 SQL 就是判断这个 int 字段的二进制第二位是不是 1。要是不用位掩码,就得搞几个布尔字段或者拼字符串,查起来又丑又慢。

触发点只有一个

事件执行器能成立,前提是所有的数据写入都走同一个入口。要是分散在几十个 service 里各写各的 insert,执行器就没法统一挂勾子。

我这个系统里,所有实体的增删改都收口到 GeneralEntityService.createOrUpdate。执行器就在这个入口统一触发:

linkerDB.update(record, enablePermission);
createOrUpdateAfter(record, false);
eventExecutorManager.execute(record, EventTiming.ON_UPDATE);

execute 里做的事情很简单:查出监听这个实体、且当前时机命中的执行器,按 priority 排序,一个个执行。执行器本身是独立的 service,靠 eventType 找到对应的那个。

执行器怎么注册

这里用了个 Spring 的小技巧。每个执行器 service 初始化的时候,把自己注册到全局的一个 Map 里:

@Override
public void afterPropertiesSet() {
    applicationEventPublisher.publishEvent(new ExecutorServiceRegisterEvent(this));
}

EventExecutorManager 监听这个事件,收到后把执行器按 eventType 存起来。这样一来,以后要加一种新的执行器类型,新建一个继承 AbstractExecutorService 的类就行,事件机制会自动把它注册进去,不用改任何分发代码。

内置的执行器有六种:字段赋值、统计回填、创建记录、分配记录、通知、字段匹配。字段赋值最简单,就是"满足条件时把某字段设成某值"。统计回填最复杂,后面单独说。

模板方法统一了骨架

所有执行器都继承 AbstractExecutorService,execute 是个模板方法,统一做了三件事。

先过滤条件。把配置里的 condition 翻译成查询,判断当前这条记录到底符不符合触发条件,不符合直接跳过。

再过滤字段。eventExecutorFields 指定的字段如果这次操作没改到,也不触发。比如配置了"只有金额字段变更才触发",那改个备注就不会触发这个执行器。

最后包异常。执行器里抛出的异常会被包装后继续上抛,让外层事务回滚。这意味着执行器执行失败,连带着用户的这次写入也会回滚,保证数据一致。

两个难啃的地方

统计回填是这套东西里最绕的。场景是这样的:客户表里有个"累计成交额",合同表里每条合同有个金额。合同新增或修改时,自动把该客户的累计成交额重新算一遍。

逻辑本身不难,难在更新引用字段的时候。假设一条合同原来属于客户 A,后来被改成了客户 B。如果只拿最新的记录去回填,A 的累计成交额就会少减掉这笔合同的钱,永远对不上。所以处理时必须先拿到修改前的旧值,对 A 回填一次,再对 B 回填一次。代码里的注释我印象很深,大意是"如果使用最新的记录,修改关联字段时旧记录不会被更新,因此不能使用最新的值"。

另一个问题是递归。统计回填会去更新客户记录,客户记录更新又可能触发别的事件,事件再触发事件,套起来就死循环了。我加了个保护,用 ThreadLocal 记下每个"记录 id + 事件 id"组合被链式触发了多少次,超过三次就跳过。

这个保护能挡住死循环,但 ThreadLocal 我一直没在 finally 里 remove 掉。单机部署无所谓,以后如果线程池复用线程,就会有脏数据串号的风险。这是我留的一个坑,先记在这里。

这套东西我做成了产品

事件执行器是 Linker 里的一个模块。配合上一篇说的动态字段,整个系统能做到字段和业务逻辑都配置化,实施人员不用动代码就能搭出一套像样的业务系统。

如果你也在做类似的动态系统,或者想找一个不用改代码就能加业务逻辑的 CRM,欢迎在评论区聊。