系列定位 :jeeflow 系列第 8 篇(第二季「核心设计」第 5 篇) 平台 :掘金(代码密度高,原理讲透) 素材版本 :引擎 v1.8.18,源码取自 jeeflow-java 前置阅读 :第 7 篇 · 零依赖的秘密:8 大 SPI 设计
一、为什么需要会签?
上一篇我们讲了引擎靠 8+1 个 SPI 做到零依赖。这一篇回到流程模型本身------多个人审批同一个节点。
普通审批是一对一的:assignee: "leader",任务创建给一个人,他同意就流转。
但现实中经常是"一个人说了不算":
- 财务报销:金额超过一定额度,需要财务 + 部门经理 + 分管领导一起点头
- 合同会签:法务、财务、业务三个部门都要审
- 民主表决:5 个人投票,超过 3 人同意才通过
这时候就引出第一个问题:多个人审批同一个节点,到底是谁的待办?
这就是 performType 要回答的。
二、performType:先分清"是谁的活"
ProcessTaskPerformTypeEnum 只有两个取值:
| code | 枚举 | 语义 |
|---|---|---|
| 0 | NORMAL |
普通参与:多人参与同一任务,任一人完成即可驱动下一步 |
| 1 | COUNTERSIGN |
会签参与:为每人创建独立任务,满足条件后才驱动下一步 |
看代码(CreateTaskHandler):
java
List<ProcessTask> tasks;
if (ProcessTaskPerformTypeEnum.COUNTERSIGN.equals(taskModel.getPerformType())) {
// 会签:为每个办理人建独立任务
tasks = instance.createCountersignTasks(taskModel, actors, operator);
} else {
// 普通:只建一个任务,多个 actor 挂在同一任务上
ProcessTask task = instance.createTask(taskModel, taskModel.getDisplayName(), actors, operator);
tasks = new ArrayList<>();
tasks.add(task);
}
关键区别在待办列表:
performType=0:assignee: "userA,userB"→ 1 个任务,userA/userB 都看得到,谁先点谁办理performType=1:assignee: "userA,userB"→ 2 个独立任务,userA 有一个、userB 有一个,互不干扰
那会签什么时候才"算通过"?这就要 countersignType 来定了。
三、会签三兄弟:一个节点,三种放行逻辑
countersignType 定义会签节点"谁全做完才推进"。CountersignTypeEnum 表面上只有两个值:
java
public enum CountersignTypeEnum implements IDictEnum {
/** 并行会签 */
PARALLEL(0, "并行会签"),
/** 串行会签 */
SEQUENTIAL(1, "串行会签");
...
}
但实际有三种 放行模式。第三种「按比例」不留在一个独立枚举里------它是 PARALLEL + 一个完成条件表达式(下详)。
3.1 并行会签:全员同时开工,全做完才过
流程定义(05-countersign-parallel.json):
json5
{
"id": "task1",
"type": "snaker:task",
"properties": {
"form": "countersign-form",
"assignee": "userA,userB,userC",
"taskType": 0,
"performType": 1,
"countersignType": "PARALLEL",
"field": { "candidateUsers": "userA,userB,userC" }
},
"text": { "value": "会签审批" }
}
并行会签在创建任务时一次性把所有人任务全建出来 (createCountersignTasks 的末尾循环):
java
for (int i = 0; i < actorIds.size(); i++) {
ProcessTask task = ProcessTask.create(...);
list.add(task);
this.tasks.add(task);
}
userA、userB、userC 三人的待办同时出现,三个都做完 节点才放行。判据在 CountersignHandler:
java
// 无特殊条件 → 全部完成
merged = finishedCount >= allTasks.size();
3.2 串行会签:排队一个一个来
流程定义(06-countersign-sequential.json):
json5
{
"id": "task1",
"type": "snaker:task",
"properties": {
"form": "seq-form",
"assignee": "userA,userB",
"taskType": 0,
"performType": 1,
"countersignType": "SEQUENTIAL",
"field": { "candidateUsers": "userA,userB" }
},
"text": { "value": "串行会签" }
}
串行会签最特殊:任意时刻只有一个 DOING 任务。创建时只建第一位:
java
if (CountersignTypeEnum.SEQUENTIAL.equals(taskModel.getCountersignType())) {
ProcessTask first = ProcessTask.create(..., singletonList(actorIds.get(0)), operator);
// 把全量成员 + 进度计数器写进首个任务变量
first.getVariables().put(COUNTERSIGN_OPERATOR_LIST + "_" + node, new ArrayList<>(actorIds));
first.getVariables().put(LOOP_COUNTER + "_" + node, 0);
first.getVariables().put(NR_OF_INSTANCES + "_" + node, actorIds.size());
list.add(first);
return list;
}
userA 先办;办完了,CountersignHandler 才创建下一位的待办:
java
// 串行会签逐个创建:每次仅 1 个 DOING 成员任务
ProcessTask completed = execution.getProcessTask();
List<String> operatorList = toStringList(completed.getVariables().get(COUNTERSIGN_OPERATOR_LIST + "_" + node));
int loopCounter = completed.getVariables().getInt(LOOP_COUNTER + "_" + node, 0);
if (operatorList != null && !operatorList.isEmpty() && loopCounter + 1 < operatorList.size()) {
// 还有下一位 → 创建下一位成员任务并停留
createNextCountersignTask(instance, taskModel, operatorList.get(loopCounter + 1),
loopCounter + 1, operatorList.size(), execution);
} else {
// 最后一位完成 → 流转
merged = true;
}
3.3 按比例(RATIO):X 人同意即通过
第三种模式最灵活,所以它不是一个枚举值,而是一个表达式 。/schemas/07-countersign-ratio.json:
json5
{
"id": "task1",
"type": "snaker:task",
"properties": {
"form": "ratio-form",
"assignee": "userA,userB,userC,userD",
"taskType": 0,
"performType": 1,
"countersignType": "PARALLEL",
"field": {
"candidateUsers": "userA,userB,userC,userD",
"countersignCompletionCondition": "#nrOfCompletedInstances==2"
}
},
"text": { "value": "2人同意即通过" }
}
注意:countersignType 仍然是 PARALLEL(任务全员同时建),但 countersignCompletionCondition 是 #nrOfCompletedInstances==2------4 个人里只要 2 个同意就放行,其余 2 人的剩余待办被废弃。
判据在 CountersignHandler 的并行分支:
java
String cond = taskModel.getCountersignCompletionCondition();
if (StringUtils.isEmpty(cond)) {
merged = finishedCount >= allTasks.size();
} else {
IExpressionEvaluator evaluator = ServiceContext.find(IExpressionEvaluator.class);
if (evaluator != null) {
FlowData vars = buildCountersignVars(instance, taskModel, allTasks);
vars.putAll(execution.getArgs());
Object result = evaluator.eval(cond, vars);
merged = Boolean.TRUE.equals(result);
}
}
支撑表达式的变量在 buildCountersignVars 里组装,统一带 csv_{node}_ 前缀:
变量(csv_{node}_ 前缀) |
语义 |
|---|---|
csv_{node}_nrOfInstances |
该节点总人数 |
csv_{node}_nrOfActivateInstances |
进行中的任务数 |
csv_{node}_nrOfCompletedInstances |
已完成的任务数 |
所以「按比例」本质是并行会签 + 阈值表达式 ,跟 RATIO 这个字面枚举没有绑定关系。文档站把它和 PARALLEL/SEQUENTIAL 并列,是为了读者好理解。
四、特殊成员:一票否决(submitType=20)
会签还有一种"一人否决全盘"的场景。mldong 契约里对应 submitType=20(COUNTERSIGN_DISAGREE):
java
public enum ProcessSubmitTypeEnum {
...
/** 拒绝申请(会签) */
COUNTERSIGN_DISAGREE(20, "拒绝申请");
...
}
但一票否决有个取舍:否决到底是"软拒绝"(只记一笔,不阻断)还是"硬否决"(一个人不同意就废弃全单)?
jeeflow 用了定义级开关 来兼顾------节点配了 countersignCompletionCondition: "ONE_VOTE_VETO" 才是硬否决(13-countersign-one-vote-veto.json):
json5
{
"id": "task1",
"type": "snaker:task",
"properties": {
"assignee": "userA,userB,userC",
"performType": 1,
"countersignType": "PARALLEL",
"countersignCompletionCondition": "ONE_VOTE_VETO",
"field": { "candidateUsers": "userA,userB,userC" }
},
"text": { "value": "会签审批(一票否决)" }
}
处理逻辑在 CountersignHandler 开头:
java
Integer submitType = execution.getArgs().getInt(SUBMIT_TYPE);
if (COUNTERSIGN_DISAGREE.getCode().equals(submitType)
&& isOneVoteVeto(taskModel.getCountersignCompletionCondition())) {
// 硬否决:节点立即推进 + 废弃剩余 DOING 会签任务
execution.setMerged(true);
abandonRemainingCountersignTasks(instance, execution.getOperator());
return;
}
isOneVoteVeto 判断开关是否开启:
java
private boolean isOneVoteVeto(String condition) {
return condition != null && FlowConst.ONE_VOTE_VETO.equalsIgnoreCase(condition.trim());
}
设计取舍:
- 未配置
ONE_VOTE_VETO(默认):submitType=20是软拒绝 ------该成员的会签任务正常完成,countersignDisagreeFlag=1写入流程变量供下游节点参考,审批流程不阻断,继续按常规会签条件(全部完成 / 表达式)等待。 - 配置了
ONE_VOTE_VETO:任一成员submitType=20,会签节点立即推进,并废弃本节点剩余所有 DOING 任务。
这个取舍很重要:会签意见默认只做参考。因为"意见相左就否决全单"在很多业务里太激进了,容易误伤。把决定性一票交给流程设计师显式配置,而不是默认开。
级联废弃------无论并行、串行、按比例,只要节点 merged(满足放行条件),剩余未办的会签任务都要弃掉,不留孤儿待办:
java
private void abandonRemainingCountersignTasks(ProcessInstance instance, String operator) {
for (ProcessTask task : instance.getTasks()) {
if (taskModel.getName().equals(task.getTaskName()) && task.isDoing()) {
task.abandon(operator); // taskState → 99 已废弃
}
}
}
五、跨语言对齐:串行会签"逐个建"的坑
这一篇最后想讲一个真实的对齐故事。
在早期版本里,Java 和 PHP 的串行会签是"一次建全" ------节点入口就把所有人的任务全建出来,只是让它们排队。而 Go / Python / Node 是逐个创建,任意时刻只有一个 DOING。
这两种实现的行为差异很隐蔽:
| 一次建全(旧 Java/PHP) | 逐个创建(Go/Python/Node) | |
|---|---|---|
| 待办列表 | 全员同时出现在待办 | 只有当前一人在待办 |
| 进度 | 靠任务创建顺序排队 | 靠 loopCounter 变量推进 |
| 前端进度条 | 统计"已建任务"会漏后续成员 | 读 operatorList_{node} 全量成员 |
在一次集成验收(issues/93)中,前端进度条暴露了问题:一次建全下,进度条统计"已建任务处理人",串行场景永远显示不齐。
最终 Java(createCountersignTasks)和 PHP 都改成了逐个创建 + 任务变量存计数,对齐 Go/Python/Node:
java
// 串行会签(issues/93):仅创建第一位成员任务,把会签计数状态写入该任务变量
if (CountersignTypeEnum.SEQUENTIAL.equals(taskModel.getCountersignType())) {
ProcessTask first = ProcessTask.create(..., singletonList(actorIds.get(0)), operator);
first.getVariables().put(COUNTERSIGN_OPERATOR_LIST + "_" + node, new ArrayList<>(actorIds));
first.getVariables().put(LOOP_COUNTER + "_" + node, 0);
first.getVariables().put(NR_OF_INSTANCES + "_" + node, actorIds.size());
list.add(first);
return list;
}
这个案例说明 jeeflow 跨语言对齐的方法论:同一个语义在五语言里可以有不同实现,但对外行为必须一致。串行会签的"任意时刻只有一个待办"是行为契约,实现上可以"一次建全"(旧)或"逐个建"(新),但前者会在待办列表、进度条上露馅------所以对齐成了逐个建。
而项目里用一张 cross-language-diffs.md 台账专门记录这些"可接受差异"和"待修缺陷",避免维护时把差异当 bug 乱修、或把 bug 当本来如此。
结语
performType 和 countersignType 两把钥匙,组合出会签的三种放行逻辑:并行全过、串行排队、按比例阈值。
会签的精髓不在"多个人审批",而在谁来定义"算通过" 。jeeflow 用两个字段 + 一个可选表达式把这句话说清:performType=1 把任务拆成独立任务,countersignType 决定是全员还是排队,countersignCompletionCondition 决定阈值和一票否决开关。
下一篇预告:[第 9 篇 · 一套流程定义,四种语言跑出相同结果](#第 9 篇 · 一套流程定义,四种语言跑出相同结果 "#") ------ 多语言联邦架构下,同一份 LogicFlow JSON 怎么在 Java/Go/Python/Node 跑出一致结果?
参考资料
- jeeflow GitHub 仓库
- jeeflow 文档站 · 会签节点
- CountersignHandler 源码
- ProcessInstance.createCountersignTasks 源码
- 开源演示站(五语言后端切换)
下一篇预告 :[第 9 篇 · 一套流程定义,四种语言跑出相同结果](#第 9 篇 · 一套流程定义,四种语言跑出相同结果 "#") ------ 多语言联邦架构下,同一份 LogicFlow JSON 怎么在 Java/Go/Python/Node 跑出一致结果?