引擎从不发一条消息:jeeflow 的两类扩展点与消息模块的分工

二、引擎有两类"伸手"的地方:拦截器和事件

引擎核心是零框架依赖的,但它知道一件事:业务方总要"在流程跑的时候干点自己的事"------发通知、改业务单据状态、按规则算审批人、写代码做决策。这些需求它不自己实现,而是开出两类扩展点让外面插进来:

  • 拦截器(FlowInterceptor :插在流程执行过程中 ,能看到、甚至能正在发生的事。
  • 事件(ProcessEventListener :流程走到某个关键点时喊一声 ,外面的人听到后自己决定要不要做点什么。

这两个接口的长相,朴素到可以各贴一行:

java 复制代码
// jeeflow-core/src/.../interceptor/FlowInterceptor.java
public interface FlowInterceptor {
    void intercept(Execution execution);   // 拿到整个执行上下文,能读能改
}

// jeeflow-core/src/.../event/ProcessEventListener.java
public interface ProcessEventListener {
    void onEvent(ProcessEvent event);      // 只拿到一个"喊声",改不了流程
}

差别就在这一行入参上,而且差别是本质的:

  • 拦截器拿到的是 Execution------整条正在跑的上下文(实例、当前节点、这一批要建的任务......),它在事务内、落库前 执行,所以它能改走向 :典型是"委托代理"拦截器,任务建好但还没落库时,它查一下"这个人是不是委托了别人",命中就把代理人加进这个任务的参与者列表------流程接下来谁收待办,被它当场改了
  • 事件拿到的是 ProcessEvent------一个几乎只有 id 的小盒子(后面细说),它在落库后 才被 fire,而且 fire 完引擎就继续往下走了,管不了也不该管你听到之后干嘛。

这条界线,jeeflow 文档站里用一句话总结,我一直觉得是写扩展点最该有的心态:

要"拦"用拦截器,要"通知"用事件。两者不要混。

"拦"意味着你要影响 流程本身(谁能办、走哪条边、前置条件不满足就不让建任务)------那必须同步、必须能改上下文、必须排在正确的位置,这是拦截器的事。"通知"意味着你只是想在事实已经发生后被知会一声(发个消息、记个日志、推个 MQ)------你不关心也不应该关心流程内部,事件就够了。

你们的消息模块,用的是右边这个------事件。 站内信是"流程走完一步之后通知一声",不是"影响流程怎么走",所以它天然是事件监听器的活,而不是拦截器的活。想清楚了这一点,后面消息模块那一节就顺了。


三、事件:引擎只"喊",而且喊得很省

那引擎到底在哪些点"喊"?答案是------很少,只有四类

java 复制代码
// jeeflow-core/src/.../enums/ProcessEventTypeEnum.java
public enum ProcessEventTypeEnum {
    PROCESS_INSTANCE_START(1, "流程实例开始事件"),
    PROCESS_INSTANCE_END(2,   "流程实例结束事件"),
    PROCESS_TASK_START(3,     "流程任务开始事件"),
    CC_CREATE(4,              "抄送知会事件");
    // ...
}

对比一下重量级引擎:Flowable、Camunda 的事件监听点能列出一长串(实体级、生命周期级、各类 before/after)。jeeflow 只有四个,而且是流程生命周期级 的粗粒度喊声------它不给你"某个实体的某个字段被改了"这种细粒度事件。这是克制,不是偷懒:引擎核心不背"通知谁、通知什么"的复杂度,它只保证"在正确的时机、以统一的语义、喊那么几声"。

四个喊声分别在哪里 fire(以 Java 参考实现为准):

事件 在哪 fire 时机
INSTANCE_START 开始节点 StartModel.exec 流程启动、走出开始节点时
TASK_START JeeflowEngineImpl.notifyTaskStart 任务行落库之后,逐任务 fire(会签每个子任务各喊一次)
INSTANCE_END EndProcessHandler.handle 到达 end 节点,办结(同意)和拒绝两条路都喊这一个
CC_CREATE JeeflowEngineImpl.handleCcActors 抄送实例落库后,逐抄送人喊一次

注意两个刻意为之的设计。

第一,事件体里几乎没有东西。 打开 ProcessEvent

java 复制代码
public class ProcessEvent {
    private ProcessEventTypeEnum eventType;  // 喊的是哪一类
    private Long sourceId;                   // 一个 id(实例 id 或任务 id)
    private String ccActorId;                // 仅 CC_CREATE 用:抄送人 id
    private FlowData data;                   // 空容器,引擎不填
}

就一个类型、一个 id(抄送那种多一个 id)。没有业务快照,没有表单数据,没有审批意见。 为什么?因为事件是**"通知你发生了",不是 "把数据传给你"**。监听器想要更多数据,自己拿着这个 id 去仓储查(findTaskByIdfindInstanceById)。这样事件体永远不膨胀,也永远不会把一份"可能已经过期"的快照塞给监听器------数据以仓储里当前那份为准。

第二,也是最容易踩的:TASK_START 是在任务落库之后才 fire 的,不是建任务时就 fire。 这个点当年是踩过坑的。直觉上"创建任务"这个动作一发生就该喊,但那时任务行还没落库、taskId 还是 null------监听器拿到 sourceId==null 直接守卫跳过,结果"新待办"那条消息漏发 了。所以引擎把 fire 挪到了 saveTask(真正分配 taskId)之后:

java 复制代码
// JeeflowEngineImpl:任务落库后才 fire,taskId 一定非空
private void notifyTaskStart(ProcessTask task) {
    if (task == null || task.getTaskId() == null) {
        return;
    }
    ProcessPublisher.notify(ProcessEvent.builder()
            .eventType(ProcessEventTypeEnum.PROCESS_TASK_START)
            .sourceId(task.getTaskId())
            .build());
}

这个"时机"不是实现细节,是契约------六语言必须对齐成"任务落库后逐任务 fire",否则各语言的监听器行为就漂了。

顺带说一个我挺喜欢的细节:这四个喊声里的第 4 号位,是个"死而复生"的槽位。 它最早叫 PROCESS_TASK_END(任务结束),但引擎从来不 fire 独立的任务结束事件------办结和拒绝都用 INSTANCE_END 覆盖了,全联邦 grep 下来这个枚举值零引用,是个挂了名字却没被用过一次的死码。后来需要一个"抄送知会"的喊声,与其新增第 5 号、把码值表改乱,不如把这个从没人用的 4 号位复用过来,语义从"任务结束"改成"抄送知会",码值不动、1/2/3 也不动。一个没人记得的死枚举,就这么变成了在用的第 4 类事件。

这四个喊声、各自的 fire 时机,画出来长这样:

再补一个关键事实,它是下一节所有故事的引子:notify 本身就是一个 for 循环。 打开 ProcessPublisher

java 复制代码
public static void notify(ProcessEvent event) {
    List<ProcessEventListener> listeners = ServiceContext.findList(ProcessEventListener.class);
    if (listeners != null) {
        for (ProcessEventListener listener : listeners) {
            listener.onEvent(event);   // 挨个喊,喊完就返回,不等谁做没做完
        }
    }
}

没有注册表魔法、没有异步队列、没有重试。引擎把事件挨个递给监听器,递完就走。 它不知道、也不关心监听器有没有把消息写进数据库。"喊"这个动作到此为止------喊了不等于送达


四、消息模块:把"喊声"翻译成"站内信"

现在讲你们消息模块那一侧。它干的事,本质是把引擎喊的那几声,翻译成一条一条站内信落进 sys_message 表,让消息中心能读到。

先看它长什么样(Java 集成层 WfMessageProcessEventListeneronEvent 主干):

java 复制代码
@Override
public void onEvent(ProcessEvent event) {
    if (!wfMessageEnabled) {          // 铁律 1:消息开关,关掉直接跳过
        return;
    }
    ProcessEventTypeEnum type = event.getEventType();
    Long sourceId = event.getSourceId();
    if (type == null || sourceId == null) {
        return;                        // 铁律 3 的守门:没有 id 就无从反查
    }
    try {
        Runnable action;
        switch (type) {
            case PROCESS_TASK_START:   // 任务落库 → 给处理人发"待审批" TODO
                action = () -> handleTaskStart(sourceId);
                break;
            case PROCESS_INSTANCE_END: // 办结/拒绝 → 给发起人发"已办结" NOTICE
                action = () -> handleInstanceEnd(sourceId);
                break;
            case CC_CREATE:            // 抄送知会 → 给抄送人发 NOTICE
                action = () -> handleCcCreate(sourceId, event.getCcActorId());
                break;
            default:                   // INSTANCE_START 不产生消息
                return;
        }
        afterCommitIfActive(action);   // 引擎在事务内 fire → 延迟到提交后再落消息
    } catch (Throwable e) {            // 铁律 2:消息出任何错,绝不打断审批
        log.error("[工作流消息] 监听器异常(已忽略,不影响流程)", e);
    }
}

对着上一节那张 fire 表读,你会发现一个一一映射:引擎喊的每一种,消息模块都规定了"翻译成什么":

引擎喊的(事件语义) 翻译成(站内信) 接收人 文案(真实)
TaskCreate TODO 待办(msgType=10) 任务处理人 actorIds 标题 待审批:{流程名}({节点名}),正文 {发起人}发起的流程已到达【{节点名}】节点,请及时处理。
InstanceEnd(办结拒绝都触发) NOTICE 通知(msgType=20) 发起人 createUser 标题 流程已办结:{流程名},正文 你发起的流程已全部审批完成,正式归档。
CC_CREATE NOTICE 抄送知会(msgType=20) 事件直传的 ccActorId 标题 你被抄送:{流程名},正文 {发起人}发起的【{流程名}】已抄送给你,请知悉。
InstanceStart / TaskComplete 不产生消息 --- ---

注意 TaskCreate → 处理人 TODO 这条是消息模块的主力 :一次提交产生几个新待办,就 fire 几次 TASK_START,就落几条 TODO。你手机上那条"待审批",就是这么来的。

这里埋着全文最深的一个坑,值得单独说------afterCommitIfActive 这一行

引擎是在事务内、同步地 fire 事件的(CreateTaskHandlerrunInTx 里调 notify)。但监听器要发"新待办",得先拿着 taskId 去仓储 findTaskById 反查处理人------而那个反查走的是独立连接的自动提交 ,在 READ COMMITTED 下,引擎当前事务还没提交,这条刚落库的任务它根本读不到 。如果监听器当场就处理,就会因为查不到任务而漏发这条 TODO(办结那条 NOTICE 反而侥幸没事,因为实例是上一个事务提交的)。

所以 Java 监听器没有当场干,而是把活挂到 Spring 的 afterCommit :等引擎那个事务真正提交之后,任务对独立连接可见了,再反查、再落消息;万一引擎事务回滚了,afterCommit 不触发,也就不会误发一条"其实没发生"的消息

java 复制代码
private void afterCommitIfActive(Runnable action) {
    if (TransactionSynchronizationManager.isSynchronizationActive()) {
        // 有活跃事务 → 注册 afterCommit,等提交后再落消息
        TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {
                @Override public void afterCommit() {
                    try { action.run(); }
                    catch (Throwable e) { log.error("afterCommit 落消息异常(已忽略)", e); }
                }
            });
    } else {
        action.run();   // 没走事务模板的路径 → 立即执行
    }
}

这件事的意义在于:它证明了**"引擎只 fire、不保证落库"不是一句口号**,而是一个会真实咬人的边界------引擎 fire 的时机(事务内)和监听器要读数据的安全时机(事务后)之间,隔着一整个事务提交。把这条边界想反了(以为引擎 fire 时数据已经全可见、或者以为引擎会替你保证消息一定落库),就会出现"消息时有时无、还和事务回滚纠缠在一起"的诡异现象。这条经验,是六语言里 Java 栈用 Spring 换来的;别的语言(Go 的 defer recover、Rust 的 catch_unwind)各有各的"全局兜底"写法,但**"引擎只喊、落库归你"**这条边界本身是一样的。

把整条链画出来,左半是引擎、右半是集成层,中间那道线就是这条职责边界:

这条边界不是拍脑袋定的,是踩坑踩出来的契约。它有一条清晰的演进史,正好对应三个 issue:

  1. issue 100 · 消息写路径整个缺失 :最早移动端走查发现 pro 站的 /wf/message/page 恒空 ------不是没数据、不是种子没造,而是全链路没有任何一段代码把流程事件组装成站内信落库。读接口好好的,写路径整条是空的。修法是双管齐下:引擎补齐"任务创建"事件(Go/Python/Node 之前根本不 fire,Java/Rust 有,先对齐),再给七套集成层各补一个"事件→消息"监听器。
  2. issue 101 · PHP 引擎压根没有事件机制 :补监听器时发现,PHP 引擎是六语言里唯一没有 fire 通道 的------没有 ProcessPublisher、没有监听器接口,监听器无处可挂。最后走"引擎补事件机制"(jeeflow-php 1.3.8 补上 ProcessEvent/Registry/Publisher,纯增量、不注册监听器时和 1.3.7 逐字节一致),PHP 才算真正并进"事件驱动"这条主流。
  3. issue 102 · 抄送人从来没收到过站内信 :抄送实例数据一直在(ccList 能查),但抄送人的消息中心永远没有提醒------因为引擎建 cc 实例时既没发抄送消息、也没 fire 抄送事件 。补法就是给引擎加一个 CC_CREATE 喊声(也就是上面那个"死而复生"的 4 号位),监听器收到后给抄送人落一条 NOTICE。

三个 issue 串起来,就是"消息模块"这套东西从无到有 的全过程:先发现"引擎喊了但没人翻译"(100),再发现"有一门语言连喊都不会"(101),最后发现"有一类该喊的没喊"(102)。它不是一开始设计好的,是一条边界被反复确认、一个缺口被逐个补上 长出来的。这也解释了为什么"引擎只喊、集成层翻译"会被写进 spec 当成契约------因为它不是某一次的设计选择,是踩过三层坑之后的共识。

顺带把三条铁律说全,任何一栈的消息监听器都得满足,少一条都会在某个极端场景漏消息或打断审批:

  1. 开关 :有一个消息开关(默认开),关了监听器直接 return------性能零损耗,也给了业务方"这套流程不要发通知"的口子。
  2. 全局兜底 :消息组装、落库的任何异常,只记日志,绝不打断审批主流程 。审批是主业务,消息是旁路------旁路炸了不能把主流程拖下水。这就是 onEvent 外面那层 catch (Throwable) 存在的唯一理由。
  3. 接收人过滤:接收人去空、只认纯数字 ID、去重;过滤完是空就干脆不发。这一条防的是"审批人是个部门号/角色号、不是真实用户 id"时,把消息发进一个不存在的收件箱。

五、六语言,怎么把这条边界对齐

上面讲的是 Java 一栈。但 jeeflow 是六语言联邦,"引擎只喊、集成层翻译"这条边界,六门语言都得遵守,而且遵守的方式各不相同------这恰恰是这套东西最有意思的地方。

先说最容易漂的地方:事件的名字,六语言并不统一。触发语义 是统一的,监听器是按语义对齐,不是按名字对齐

语义 Java Rust Go Node Python PHP
实例开始 PROCESS_INSTANCE_START ProcessInstanceStart EventProcessStart ProcessStart PROCESS_START INSTANCE_START
任务创建(落库后) PROCESS_TASK_START ProcessTaskStart EventTaskCreate TaskCreate TASK_CREATE TASK_START
实例结束 PROCESS_INSTANCE_END ProcessInstanceEnd EventProcessFinish/EventProcessReject ProcessFinish/ProcessReject PROCESS_FINISH/PROCESS_REJECT INSTANCE_END
抄送知会 CC_CREATE(4) CcCreate(4) EventCCCreate(5) CcCreate(5) CC_CREATE CC_CREATE(4)

(表里是各语言引擎的事件名。这张表如今已原样收进 spec §4.1,成为官方映射表 ------集成方以它为唯一对照,不用再逐语言读源码。CC_CREATE 那行的码值也顺带标了:Rust 的 4 是后来补的,复用的正是那个从没人 fire 的 ProcessTaskEnd 死位,和 Java/PHP 同派。)

两处差异最值得记住:

  • 办结和拒绝,是合并还是一个? Java 和 Rust 只有一个 INSTANCE_END,办结和拒绝两条路都 fire 它 (没有独立的 finish/reject);Go、Node、Python 则拆成 FinishReject 两个 事件。所以"实例结束"这个语义,在不同语言里对应的事件个数都不一样 。你的监听器不能写死事件名,得认"语义"------spec 里给这组差异起了个正经名字:语义锚点,"实例结束(办结+拒绝)"就是一个锚点,合并派接 1 个事件、拆分派两个都得接,漏接一个就是静默缺口。
  • CC_CREATE 的码值,也是各说各话 :Java、PHP、Rust 里它是 4 (4 号死位复用派),Go/Node 里是 5 (它们 4 号位是活着的 TaskComplete,只能往后追加),Python 干脆用字符串标识 。同一个"抄送知会",码值三套------spec 的官方态度很明确:各语言枚举独立,码值无强制统一必要,记进映射表即可。

挂载方式 则是四家:Java 和 Rust 把监听器注册进 ServiceContext(Java 那个 SimpleContext 是纯 Map,不自动发现 Spring bean ,所以监听器得在 @PostConstruct 里显式 put 进去);Go/Node 走 EngineExtensions 的监听器数组、Python 是 event_listener 单个 async 回调;PHP 用静态 ProcessEventListenerRegistry,在 ServiceProvider::boot() 里显式注册。四种形态在 spec 映射表里并列为官方------挂载是各语言惯用形态,不强制统一。

实现进度 (截至 2026-09-04,issues/104 收口批次):CC_CREATE 这条喊声,六门语言 6/6 全部就位 。上一版稿子写这篇时还是"五门已加、Rust 待补"------收口批次里 Rust 引擎把 CcCreate 补上了(复用 4 号位死码,jeeflow-rust 1.0.8 发版),salvo 集成层同批接线;Java / Go / Python / Node 引擎 bump 到 1.8.25、PHP 到 1.3.9,八栈集成仓同步升级,六个生产镜像全部换新。抄送知会这条消息,现在在任何一门语言的引擎上发流程,抄送人都收得到------"契约已冻结、实现按批次推进"的中间态结束,全绿。

顺带交代这批收口里最有意思的一个发现:"旁路不能拖垮主流程"这条铁律,引擎自己此前并不都做得到。 盘点六语言引擎的 publisher(issues/104 P2)发现:只有 PHP 是逐监听器 try/catch 的;Java/Go/Node/Rust 都是裸调 for-loop------一个坏监听器抛异常,后续监听器全部不执行,异常还会直接传播进审批主流程 ;Python 的单回调异常同样直接传播。收口批次把五门语言统一修成了 per-listener 兜底(Java try/catch+JUL、Go defer recover、Node try-catch、Python try-except、Rust catch_unwind),每门语言配了一条兜底测试:塞一个必抛异常的坏监听器,断言后续监听器仍被调用、主流程不受影响。"消息是旁路"从此由引擎侧自己兜住,不再只依赖集成层自觉。

这一节想说的是:六语言对齐的不是"名字一样",是"行为一样"。 事件叫什么、码值是几、挂在哪儿,随语言习惯各走各的;但"任务落库后 fire 一次""办结和拒绝都算实例结束""抄送逐人 fire""引擎只喊不保证落库"------这些语义是硬约束,一栈漂了,那栈的消息就会和其它栈对不上。


六、想接钉钉 / 企微 / 邮箱,加个监听器就行

把这篇收拢,其实是三条线:

  1. 引擎开两类口子 :要 流程走哪/谁能办,用拦截器(同步、能改 Execution、排在落库前);要被通知发生了什么,用事件(落库后 fire、只带 id、不改流程)。"要拦用拦截器,要通知用事件。"
  2. 消息模块站在事件的下游 :引擎喊 TaskCreate / InstanceEnd / CC_CREATE,监听器把它们一一翻译成 TODO / NOTICE / 抄送知会,反查仓储补全数据,落进 sys_message。中间那道线是契约:引擎只喊、不保证落库;落库、过滤、兜底、开关,全是集成层的活。
  3. 这条边界是踩坑踩出来的,不是设计出来的:写路径整个缺失(100)、一门语言不会喊(101)、一类该喊的没喊(102),三层缺口逐个补上;然后 104 把"六语言实现参差"这个最后的坑也填了------CC_CREATE 补齐 6/6、publisher 兜底统一、差异表固化成 spec 官方映射表,"引擎只喊、集成层翻译"从共识变成了有官方对照表的契约。

而这套分工最爽的一点在最后:引擎对"消息往哪发"完全无感。 你的消息今天落 sys_message 表,明天想同时推一条钉钉、发一封邮箱、投一个 MQ------引擎一行代码都不用动 ,你只需要再写一个监听器 ,往 ServiceContext(或各语言的 extensions/registry)里再 register 一个,去消费同一批事件就行。

一个引擎,六门语言都在用的消息模块,未来要扩成 N 个渠道------加的都是监听器,动的是集成层。引擎始终只做它最擅长的那件事:把流程稳稳地推到下一个该停的地方,然后在关键点上喊那么几声。 剩下的,交给听到喊声的人。


参考资料

相关推荐
君顾11 小时前
智慧零售实战指南:从技术架构到落地方案全解析
java·开发语言·零售
zhanghe6872 小时前
es的密码带有特殊字符,导出
java
pnoker2 小时前
IoT DC3 概念解读:把设备抽象成一套语义模板——位号、指令、事件与面向智能体的设备建模
java·人工智能·物联网·iot·dc3
my_realmy2 小时前
Java SE 基础学习笔记(一):面向对象、继承多态、抽象接口与异常(JDK 8)
java·多线程·并发··生产者消费者模式
ZGIAI3 小时前
Agent 越来越强,企业为什么反而更需要“运行层”?
人工智能·架构
ZGIAI3 小时前
企业级 Agent 平台开源:ZGI 把模型、知识库、Skills 和 Workflow 放进同一个 Runtime
人工智能·架构
天远数科3 小时前
风险治理实战:基于天远车信盟出险构建自动化理赔核保流水线
java·网络·人工智能·自动化
骇客野人3 小时前
SpringBoot电商系统用户注册、登录、留存及数据埋点设计与落地实施方案
java·spring boot·后端
qiuhaipeng13 小时前
Claude code 升级后上下文长度变短问题
java·前端·数据库