写在前面
前面剖析了状态机的源码之后,怎能不实操一番呢?如果前面对COLA状态机的源码有所了解,那么对其的使用不能说是信手拈来,也可以说手拿把攥。因而我实现了一个小的,简易的流程引擎,能支持的操作有限,但若是能看清其中的原理,定制化仿写也相当简单。
在该案例中,数据库我使用的是sqlite,项目架构springboot3,编码语言Java17,ORM框架使用mybatis-plus,没有前台页面与接口暴露。因此如果在该案例中你没有看懂该状态机是如何实现流程驱动的,那么将之看作sqlite+springboot+mybatisplus的整合方案,我相信也能有所收获。
一.流程流转
既然是实现一个简易的流程引擎,流程图必不可少。先看看我自己画的流程图,了解其状态改变逻辑。
整个图形很简单,该流程引擎除了支持正常流程流转外,我只实现了退回,退回上一步,挂起与取消挂起的操作。
因为在这里我的想法是用一个状态机实现,所以流程需求并不复杂。在实际使用中,如果涉及到复杂需求,状态机又不好定义时,我的建议是定义多个状态机搭配使用,共同驱动流程。比如上图我就可以优化为一个外部状态机和一个内部状态机,外部的做异常状态驱动,内部的做正常状态驱动。
但我这里的实现是整合了一个状态机。先看看状态机定义的代码:
/**
* @author : singularity
* @date : 2023/12/12 19:34
* @description : 状态机配置类
*/
@Configuration
public class MyFlowApiStateMachineConfig {
@Resource
FlowApiCheckCondition flowApiCheckCondition;
@Resource
FindFirstNodeAction findFirstNodeAction;
@Resource
PassAndFindNextNodeAction passAndFindNextNodeAction;
@Resource
PassAndDoneAction passAndDoneAction;
@Resource
PassAndBackAction passAndBackAction;
@Resource
PassAndReturnAction passAndReturnAction;
@Resource
PassAndHangupAction passAndHangupAction;
@Resource
PassAndCancelHangupAction passAndCancelHangupAction;
@Bean
public StateMachine<BillState, BillSateEvent, BillEntity> billEntityFlowApiStateMachine(){
String machineId = "FlowApiStateMachine_0001";
//参照test的样例实例化一个状态机
StateMachineBuilder<BillState,BillSateEvent,BillEntity> builder = StateMachineBuilderFactory.create();
//创建
builder.externalTransition().from(BillState.CS).to(BillState.CL).on(BillSateEvent.CJ).when(flowApiCheckCondition.isAllowCreateBill()).perform(findFirstNodeAction.doAction());
//通过
builder.internalTransition().within(BillState.CL).on(BillSateEvent.TJ).when(flowApiCheckCondition.isPassAndFindNextNode()).perform(passAndFindNextNodeAction.doAction());
//结束
builder.externalTransition().from(BillState.CL).to(BillState.JS).on(BillSateEvent.TJJS).when(flowApiCheckCondition.isPassAndDone()).perform(passAndDoneAction.doAction());
//退回
builder.externalTransition().from(BillState.CL).to(BillState.TH).on(BillSateEvent.TH).when(flowApiCheckCondition.isAllowReturn()).perform(passAndBackAction.doAction());
//退回上一步
builder.internalTransition().within(BillState.CL).on(BillSateEvent.THSYB).when(flowApiCheckCondition.isAllowReturnLastNode()).perform(passAndReturnAction.doAction());
//挂起
builder.externalTransition().from(BillState.CL).to(BillState.GQ).on(BillSateEvent.GQ).when(flowApiCheckCondition.isAllowHangup()).perform(passAndHangupAction.doAction());
//取消挂起
builder.externalTransition().from(BillState.GQ).to(BillState.CL).on(BillSateEvent.QXGQ).when(flowApiCheckCondition.isAllowCancelHangup()).perform(passAndCancelHangupAction.doAction());
//重新提起
builder.externalTransition().from(BillState.TH).to(BillState.CL).on(BillSateEvent.CJ).when(flowApiCheckCondition.isAllowCreateBill()).perform(findFirstNodeAction.doAction());
StateMachine<BillState, BillSateEvent, BillEntity> stateMachine = builder.build(machineId);
System.out.println(stateMachine.generatePlantUML());
return stateMachine;
}
}
我们看看绘制好的uml图形是怎样的。
如果觉得不是很好理解,我这里贴上掩码。
package com.singularity.flowapi.bean.billenum;
/**
* @author : singularity
* @date : 2023/12/12 19:39
* @description : 状态事件
*/
public enum BillSateEvent {
CJ("创建"),
TJ("提交"),
TJJS("提交结束"),
TH("退回"),
GQ("挂起"),
CXFQ("重新发起"),
QXGQ("取消挂起"),
THSYB("退回上一步")
;
private String name;
BillSateEvent(String name){
this.name = name;
}
}
/**
* @author : singularity
* @date : 2023/12/12 19:37
* @description : 状态
*/
public enum BillState {
CS("CS"),
CL("CL"),
TH("TH"),
GQ("GQ"),
JS("JS");
private String name;
BillState(String name){
this.name = name;
}
}
至此,我们这台状态机是怎么运作的,就基本说完了。
二.状态机填充完善
如果说,上面的状态机定义,是一个大骨架。那么接下来的操作,就需要为其填充血肉。
在状态机定义类里面可以看见引入了一个condition类和无数的action类。简单来说,condition类为我们明确是否允许进行状态变更,而action类则是状态变更了,需要做的操作是什么。(例子中有一些mapper和service可以先不用管,涉及业务下一节会讲)
/**
* @author : singularity
* @date : 2023/12/12 19:59
* @description : description
*/
@Repository
public class FlowApiCheckCondition {
@Resource
FlowMessageService flowMessageService;
public Condition<BillEntity> isPassAndFindNextNode(){
return new Condition<BillEntity>() {
@Override
public boolean isSatisfied(BillEntity bill) {
return BillSateEvent.TJ.name().equals(bill.getOpinion());
}
};
}
public Condition<BillEntity> isPassAndDone(){
return new Condition<BillEntity>() {
@Override
public boolean isSatisfied(BillEntity bill) {
List<FlowMessage> flowMessages = flowMessageService.queryAllFlowMessageById(bill.getFlowId());
return BillSateEvent.TJ.name().equals(bill.getOpinion()) && Objects.equals(bill.getNodeId(), flowMessages.get(flowMessages.size() - 1).getFlowNode());
}
};
}
public Condition<BillEntity> isAllowReturnLastNode(){
return new Condition<BillEntity>() {
@Override
public boolean isSatisfied(BillEntity bill) {
return BillSateEvent.THSYB.name().equals(bill.getOpinion());
}
};
}
public Condition<BillEntity> isAllowReturn(){
return new Condition<BillEntity>() {
@Override
public boolean isSatisfied(BillEntity bill) {
return BillSateEvent.TH.name().equals(bill.getOpinion());
}
};
}
public Condition<BillEntity> isAllowHangup(){
return new Condition<BillEntity>() {
@Override
public boolean isSatisfied(BillEntity bill) {
return BillSateEvent.GQ.name().equals(bill.getOpinion());
}
};
}
public Condition<BillEntity> isAllowCancelHangup(){
return new Condition<BillEntity>() {
@Override
public boolean isSatisfied(BillEntity bill) {
return BillSateEvent.QXGQ.name().equals(bill.getOpinion());
}
};
}
public Condition<BillEntity> isAllowCreateBill() {
return new Condition<BillEntity>() {
@Override
public boolean isSatisfied(BillEntity bill) {
List<FlowMessage> flowMessages = flowMessageService.queryAllFlowMessageById(bill.getFlowId());
return !flowMessages.isEmpty();
}
};
}
}
condition我做的比较简单,就是单据处理意见和要处理的事件相符合,那么就允许状态变更。
action太多,我就不放完了,放一个示例说明一下。
/**
* @author : singularity
* @date : 2023/12/12 19:58
* @description : description
*/
@Repository
public class PassAndReturnAction{
@Resource
FlowMessageServiceImpl flowMessageService;
@Resource
BillStateMapper billStateMapper;
public Action<BillState, BillSateEvent, BillEntity> doAction() {
return (from, to, event, ctx) -> {
List<FlowMessage> flowMessages = flowMessageService.queryAllFlowMessageById(ctx.getFlowId());
if ("01".equals(ctx.getNodeId()) || StringUtils.isBlank(ctx.getNodeId())) {
throw new RuntimeException("PassAndReturnAction:[初始节点] 不允许退回上一步。");
}
AtomicInteger i = new AtomicInteger();
flowMessages.forEach(flowMessage -> {
if (flowMessage.getFlowNode().equals(ctx.getNodeId())) {
i.set(flowMessages.indexOf(flowMessage));
}
});
UpdateWrapper<BillEntity> updateWrapper = new UpdateWrapper<>();
updateWrapper.set("node_id",flowMessages.get(i.get()-1)).set("bill_status",BillState.TH.name()).eq("bill_id",ctx.getBillId());
billStateMapper.update(ctx, updateWrapper);
System.out.println("doAction");
};
}
}
这个示例就是退回的action。
三.带入实际业务
如果说上面两部分状态机的定义看的你一脸懵的话,无妨,这很正常。关于上面的内容,非常好解释,只需记住这样一个过程就行:状态A经过事件B转换为状态C,其中校验条件为Condition,状态变更成功后的行为是action。因此可以猜测出来,有很多的condition和action,因为各个状态间变换的condition和action都不一样,实际上也是这样,为了更好理解这一点,我们带入实际业务。
假定工单A,需要走流程,别人来处理流程。最基础的也需要两个表,一个工单表,一个流程信息表。
-- 先忽略员工,岗位,部门,工单日志等等一系列表
create table bill_base_message
(
bill_id CHAR(36) not null
primary key,
bill_title CHAR(100) not null,
bill_status CHAR(30),
node_id CHAR(20),
flow_id CHAR(20),
opinion CHAR(20)
);
create table base_flow_message
(
flow_id CHAR(20) not null,
flow_name CHAR(60),
flow_node CHAR(10) not null,
node_name CHAR(60)
);
create unique index base_flow_message_u01
on base_flow_message (flow_id, flow_node);
随即插入流程数据,这个是基准数据,工单数据测试时再插入。
按照mybatisplus框架将相关类及文件定义好
接下来再定义一个接口,这个接口的意义在于,减少业务代码,事实上这个接口的代码也可以写在状态机调用之前的各个业务模块,但整合到这个接口可以解耦。
/**
* @author : singularity
* @date : 2023/12/13 20:48
* @description : description
*/
public interface FlowService {
void createBill(BillEntity bill);
void submitBill(BillEntity bill);
void backBill(BillEntity bill);
void returnBill(BillEntity bill);
void hangUp(BillEntity bill);
void cancelHangup(BillEntity bill);
}
实现类这里也写的较简单:
/**
* @author : singularity
* @date : 2023/12/12 20:48
* @description : description
*/
@Slf4j
@Service
public class FlowServiceImpl implements FlowService {
@Resource
StateMachine<BillState, BillSateEvent, BillEntity> flowApiStateMachine;
@Resource
FlowMessageServiceImpl flowMessageService;
@Override
public void createBill(BillEntity bill) {
log.info("订单"+bill.getBillId()+"已成功更新状态为:"+flowApiStateMachine.fireEvent(BillState.valueOf(bill.getBillStatus()), BillSateEvent.CJ, bill));
}
@Override
public void submitBill(BillEntity bill) {
List<FlowMessage> flowMessages = flowMessageService.queryAllFlowMessageById(bill.getFlowId());
if (flowMessages.get(flowMessages.size()-1).getFlowNode().equals(bill.getNodeId())) {
log.info("订单"+bill.getBillId()+"已成功更新状态为:"+flowApiStateMachine.fireEvent(BillState.valueOf(bill.getBillStatus()), BillSateEvent.TJJS, bill));
}else {
log.info("订单"+bill.getBillId()+"已成功更新状态为:"+flowApiStateMachine.fireEvent(BillState.valueOf(bill.getBillStatus()), BillSateEvent.TJ, bill));
}
}
@Override
public void backBill(BillEntity bill) {
log.info("订单"+bill.getBillId()+"已成功更新状态为:"+flowApiStateMachine.fireEvent(BillState.valueOf(bill.getBillStatus()), BillSateEvent.TH, bill));
}
@Override
public void returnBill(BillEntity bill) {
log.info("订单"+bill.getBillId()+"已成功更新状态为:"+flowApiStateMachine.fireEvent(BillState.valueOf(bill.getBillStatus()), BillSateEvent.THSYB, bill));
}
@Override
public void hangUp(BillEntity bill) {
log.info("订单"+bill.getBillId()+"已成功更新状态为:"+flowApiStateMachine.fireEvent(BillState.valueOf(bill.getBillStatus()), BillSateEvent.GQ, bill));
}
@Override
public void cancelHangup(BillEntity bill) {
log.info("订单"+bill.getBillId()+"已成功更新状态为:"+flowApiStateMachine.fireEvent(BillState.valueOf(bill.getBillStatus()), BillSateEvent.QXGQ, bill));
}
}
看到这里总结一下可以在哪些位置进行判断能否变换状态,到数据持久化(最终目的)之前,我们一共会有三个位置,调用的业务代码(上文的flowserviceimpl),状态机condition,状态机的action。我的看法是,基准的,不涉及业务数据的判断,放在condition中,action中是最终校验,很可能出现状态机通过,但在action不给予数据持久化。业务代码中就判断涉及到业务数据的。
举个例子。工单A为员工差旅报销业务(flowId),需要判断的条件有3个。1.超额报销不属于当前业务,应退回重新发起。2.输入金额与系统计算金额不符(税率,汇率等影响)。3.收款人与发单人不符(不允许代提)。在该例子中,我个人认为,条件1的判断应放在业务代码中,因为这是进入状态机之前的大基准,方向错了,再怎么试都是徒劳无功。条件2应放在condition中,输入金额与系统计算金额和是哪个业务,是谁提起的无关,它只关注这两个金额相等与否,第三点类似,但我却认为第三点可以放到action中去。因为前文我提到过,很有可能状态机通过了,但实际业务不通过的情况。这里,就可以这么理解(可以看作实际业务就是付款),纸面上一切条件都符合,状态已经变更,就等付款了,但在付款的action中,条件判断有误,立即终止。
但其实这里也不是什么重要的点,只是我个人认为的逻辑规范。大前提 -> 正常条件判断 -> 兜底条件判断。
四.效果测试
该定义的都定义了,我们直接看效果。先插入一条初始状态的工单。
4.1发起
直接创建提上流程。
状态机通过。
持久化到数据库。
4.2 提交
模拟提交操作
状态机内部流转,无状态改变(状态机通过)。
流转到下一节点。
4.3退回
模拟退回
状态机通过
退回状态,无法处理。
4.4重新发起
退回单据模拟重新发起
状态机通过。
数据成功持久化。
4.5挂起
模拟挂起
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
状态机通过
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
成功持久化
4.6取消挂起
4.6结束
现将工单一直提交到第四节点(共4个节点)。注:节点任意定义,CL(处理)状态视为内部流转,可一直循环。
如果再submit,是否会报错呢?
答案是不会。
因为在业务处理代码中做了不同处理,走的是不同状态变换。
4.7异常情况
举个例子,单据无节点信息,就要挂起。
这个错是我在代码中手抛的,此外状态机已经能帮我们避免很多错误。
比如:
这个组合虽然没有报错,但是实际上是没有执行代码的,因为我们需要更改的状态是CS->GQ,没有这样的组合,状态机就不会执行代码。比如GQ ->GQ这样的状态变换,就不用手动去做了。
写在最后
这个demo作为我学习COLA的一个小demo,以及整合sqllits及springboot3及mybatis-plus的小demo。还有不少不足之处,比如模型太过简易,还没有支持跳过节点,会签等情况。但单从状态机学习来说,这个demo练手足矣。
源代码: