电子签中台认证环节的状态机实践

背景介绍

项目开发过程中,经常涉及代码老翻新重构,以及对历史业务的梳理。常见的场景是一个历史的事功能需要梳理,然后增加一串新的case进行嵌入。

场景实例:对于电子签中台来说,认证是提供的能力之一。

如上图所示,电子签平台对外提供统一认证平台,底层实际聚合多个厂商能力。每个厂商提供的身份认证能力,相似但是不完全相同,且认证流程也如此。

随着接入成员数量的增加和早期开发人员注重速度忽略梳理,再加上没有文档沉淀,慢慢的认证的逻辑变成了上图,如上图展示,每一次的改动都需温习整体脉络逻辑。我们致力于将未来逻辑梳理为下图,谋求一种手段,将逻辑梳理清晰,各逻辑环节清晰可见。

现实状况是,虽然原来的逻辑行得通,但是可读性很差;每次在历史逻辑中进行开发迭代,梳理和验证变的格外困难;在梳理脉络的过程中,利用有限状态机最终能梳理出的二维表格(状态转移表)是我们所迫切需要的。

下面来介绍下状态机相关的内容。

状态机介绍

状态机本质是一种离散数学统计的模型。

状态机分为两类:有限状态机 finite state machine(FSM )无限状态机 infinite state machine

我们工作中常常讨论的状态机,实际指的是有限状态机。

有限状态机:

表示有限个状态以及这些状态之间的状态转移和动作等行为的数学模型。

数学模型表示:五元组M(A,S,Y,S0,F)

  • A:输入的字母表 (符号的非空有限集合)
  • S:是状态的非空有限集合
  • Y: Y ⊆ S ,Y是S的子集
  • S0:S0 ∈ S ,S0 是开始状态
  • F:S * A -> S: 状态转移函数,指明在某个状态下接受输入字符所引起的状态变迁。

无限状态机:

具有不可数的状态和转换的状态机,另一种说法是目前已知的无限状态机是宇宙。(当前的科学水平定义)

状态转移表:

条件→ 当前状态↓ 条件X 条件Y 条件Z
状态A
状态B 状态C
状态C

工程中状态机的使用:

一般工程中状态机的公式是: 当前状态+触发事件=状态+产生行为(状态变化的作用产物,可以没有)

接下来我们举个例子尝试下

简要故事:

  • 小明早上从家出发,在预备铃响起的时候需要进入教室,否则就被老师在走廊罚站直到上课铃声;
  • 响起上课铃声后,小明和同学们需要进入上课状态进行学习;
  • 当下课铃声响起,课间操场出现同学们欢快的身影;
  • 伴随着悠扬的放学铃声,小明满载着一天的收获,走在回家的路上。

状态机思路:

  • 假设学生状态:下课状态,预备状态,上课状态,放学状态
  • 事件:预备铃声,上课铃声,下课铃声,放学铃声

梳理后的状态转移表:

学生状态\事件 预备铃声 上课铃声 下课铃声 放学铃声
下课状态 预备状态+回到教室 \ \ \
预备状态 \ 上课状态+学习 \ \
上课状态 \ \ 下课状态+课间休息 放学状态+回家
放学状态 预备状态+回到教室或者迟到罚站 \ \ \

这里解释下:(当前状态+触发事件=状态+产生行为)

放学状态 (当前状态) + 预备铃声 (触发事件) = 预备状态 (状态) + 回到教室或者迟到罚站 (产生行为)(当预备铃声事件时,学生是否进入教室,产生的不同行为)

行为是可以没有,可以单一,也可以结合其他因素产生不同的行为表现。

更进一步:

这里我们追加一个条件状态:学生的身体状态 (健康,生病)

补充一段故事,如果小明身体生病了,在学校需要回家,如果是在家的情况需要休养,直到恢复。(这个补充故事简单,实际场景会更复杂,人物更坚强,这里仅是为了便于理解)。

这里我们有两种方式可以解决这个问题.

第一种:扩展表格

结合学生的身体状态,丰富表格:

学生状态\事件 预备铃声 上课铃声 下课铃声 放学铃声
健康+下课状态 身体健康,预备状态+回到教室 \ \ \
健康+预备状态 \ 身体健康,上课状态+学习 \ \
健康+上课状态 \ \ 身体健康,下课状态+课间休息 身体健康,放学状态+回家
健康+放学状态 身体健康,预备状态+回到教室或者迟到罚站 \ \ \
生病+下课状态 生病放学状态+回家 生病放学状态+回家 生病放学状态+回家 生病放学状态+回家
生病+预备状态 生病放学状态+回家 生病放学状态+回家 生病放学状态+回家 生病放学状态+回家
生病+上课状态 生病放学状态+回家 生病放学状态+回家 生病放学状态+回家 生病放学状态+回家
生病+放学状态 生病放学状态+休养 生病放学状态+休养 生病放学状态+休养 生病放学状态+休养

第二种:树形结构

更近一步,状态机能组合条件使用,甚至组合状态机使用, 最终可以产生一个树形结构。

image.png

状态机实践流程

先看下整体流程代码梗概

状态机整体基于状态设计模式,会存在一个状态上下文 (代码仅作为理解,个别代码为业务特殊性)

//初始化认证上下文
CertifyContext certifyContext = new EsignCertifyContext(certify, tenantId, certify.getPlatformType(), esignData, certifyTemplateManager.getPlatMap());

//状态扭转
certifyContext.when(eventType)
        .next();

解释:CertifyContext 认证上下文,一般充当承载环境角色, 作为整体的切换调度,串联流程。

//CertifyContext 类

public abstract class CertifyContext {

    protected Certify certify;

    protected Long tenantId;

    protected Byte platformType;

    protected EsignCertifyMsgData certifyMsgData;

    protected Map<String, StateEventAction> bindActionMap;

    /**
     * 事件条件
     * @param code
     * @return
     */
    public abstract CertifyContext when(String code);

    /**
     * 状态next
     */
    public abstract void next();

    /**
     * 核对当前状态
     * @return
     */
    public abstract boolean checkCertifyStatus();

    public CertifyContext(Certify certify, Long tenantId, Byte platformType, EsignCertifyMsgData certifyMsgData, Map<String, StateAction> bindActionMap) {
        this.certify = certify;
        this.tenantId = tenantId;
        this.platformType = platformType;
        this.certifyMsgData = certifyMsgData;
        this.bindActionMap = bindActionMap;
    }
}

解释:

如下三个为主要方法:
/** 
* 事件条件
* @param*** *code
* @return
*/
public abstract CertifyContext when(String code);

/**
* 状态next
*/
public abstract void next();

/**
* 核对当前状态
* @return
*/
public abstract boolean checkCertifyStatus();
  • when 接收一个事件code
  • checkCertifyStatus 当前状态的核验等校验行为
  • next 上下文中根据对应事件和状态条件,产生的:状态变化+产生行为

补充说明:

//状态扭转
certifyContext.when(eventType)
        .next();
    
//上面中真实逻辑如下    
certifyContext.when(eventType)
        .checkCertifyStatus() 
        .next();
        
//但是实际我们业务书写中,出于业务可读性的考虑,when中嵌入了checkCertifyStatus(),  
//其他同学书写业务结合自身实际考虑即可
certifyContext.when(eventType)
        .next();

如下为三个方法在流程中的作用环节:

image.png

认证状态机实践步骤

接下来描述的是两种重构的选择,从零出发(0包袱)和 重构下的状态机尝试(历史包袱);两者最终会达到一致的实现。

从零出发(0包袱):

这个是没有历史包袱的情况下开发流程,直接输出状态转移表等。

触发事件

我们用枚举类作为触发事件类型


public enum EsignEventType {
    CertifyResult("1", "CertifyResult"),
    ManualReview("2", "ManualReview"),
    SignaturePermissionReview("3", "SignaturePermissionReview"),
    StampCreationApplicationReview("4", "StampCreationApplicationReview"),
    PayToPublic("5", "PayToPublic"),
    LegalRepresentativeSign("6", "LegalRepresentativeSign")
    ;


    private String code;
    private String name;

    EsignEventType(String code, String name) {
        this.code = code;
        this.name = name;
    }

    public String getCode() {
        return code;
    }

    public String getName() {
        return name;
    }

    public static EsignEventType getByCode(String code) {
        for (EsignEventType et : EsignEventType.values()) {
            if (et.getCode().equals(code)) {
                return et;
            }
        }
        return null;
    }
}
认证上下文
//初始化认证上下文
CertifyContext certifyContext = new EsignCertifyContext(certify, tenantId, certify.getPlatformType(), esignData, certifyTemplateManager.getPlatMap());

//状态扭转
certifyContext.when(eventType)
        .next();
状态转移表
@Slf4j
@Component
public class CertifyTemplateManager implements InitializingBean, ApplicationContextAware {

    private ApplicationContext context;

    public final static String KEY_TEMPLATE = "state_%s_code_%s";

    private Map<String, StateEventAction> platMap = new ConcurrentHashMap<>(16);


    public Map<String, StateEventAction> getPlatMap() {
        return platMap;
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.context = applicationContext;
    }


    @Override
    public void afterPropertiesSet() throws Exception {
        //进行状态转移表 注册
        platMap.put(String.format(KEY_TEMPLATE, "xxxState", "xxxEventCode"), xxxStateEventAction);
        platMap.put(String.format(KEY_TEMPLATE, "xxxState", "xxxEventCode"), xxxStateEventAction);
        ...
    }


}

重构下的状态机尝试(历史包袱):

电子签中台当初涉及历史迁移,需要分阶段去进行转换,两者的最终状态是一致的。

考虑到重构是分步去前进,逐步重构,逐步验证,才能规避风险。像切芝士片一样,一小片一小片去操作。

整理流程:

image.png

步骤一:将状态的改变和行为绑定到事件,成为事件函数

以下为部分代码,仅作为理解使用


public enum EsignEventType {

    CertifyResult("1", "CertifyResult") {
        @Override
        public void triggerAction(EsignCertifyMsgData certifyMsgData, Certify certify, Long tenantId, StateAction stateAction) {
            if (Objects.isNull(stateAction)) {
                return;
            }
            stateAction.triggerAction(certifyMsgData, certify, tenantId);
        }
    },
    ManualReview("2", "ManualReview") {
        @Override
        public void triggerAction(EsignCertifyMsgData certifyMsgData, Certify certify, Long tenantId, StateAction stateAction) {
            if (Objects.isNull(stateAction)) {
                return;
            }
            stateAction.triggerAction(certifyMsgData, certify, tenantId);
        }
    },
    SignaturePermissionReview("3", "SignaturePermissionReview") {
        @Override
        public void triggerAction(EsignCertifyMsgData certifyMsgData, Certify certify, Long tenantId, StateAction stateAction) {
            if (Objects.isNull(stateAction)) {
                return;
            }
            stateAction.triggerAction(certifyMsgData, certify, tenantId);
        }
    },
    StampCreationApplicationReview("4", "StampCreationApplicationReview") {
        @Override
        public void triggerAction(EsignCertifyMsgData certifyMsgData, Certify certify, Long tenantId, StateAction stateAction) {
            if (Objects.isNull(stateAction)) {
                return;
            }
            stateAction.triggerAction(certifyMsgData, certify, tenantId);
        }
    },
    PayToPublic("5", "PayToPublic") {
        @Override
        public void triggerAction(EsignCertifyMsgData certifyMsgData, Certify certify, Long tenantId, StateAction stateAction) {
            if (Objects.isNull(stateAction)) {
                return;
            }
            stateAction.triggerAction(certifyMsgData, certify, tenantId);
        }
    },
    LegalRepresentativeSign("6", "LegalRepresentativeSign") {
        @Override
        public void triggerAction(EsignCertifyMsgData certifyMsgData, Certify certify, Long tenantId, StateAction stateAction) {
            if (Objects.isNull(stateAction)) {
                return;
            }
            stateAction.triggerAction(certifyMsgData, certify, tenantId);
        }

    };


    public abstract void triggerAction(EsignCertifyMsgData certifyMsgData, Certify certify, Long tenantId, StateAction stateAction);

}

这里我们采取的手段是,将状态+产生行为统一作为一个整体函数,绑定到枚举值当中(认证的枚举值),这样的话,一个事件就紧密相连一个业务逻辑;

这里绑定的业务逻辑,更多的是原有逻辑,未做拆分,目的是为了将事件清晰化。

步骤二:将通过验证的事件函数拆解,归纳整理为状态转移表

下图为某一历史时刻梳理的关系图,仅作为辅助理解使用

image.png

状态转移表

事件类型:

1 CertifyResult 2 ManualReview 3 SignaturePermissionReview 4 StampCreationApplicationReview 5 PayToPublic 6 LegalRepresentativeSign

事件内还包含有效荷载,结果和编号等等信息,重点关注事件类型

image.png

每一个状态转移表内的(状态 | 事件类型)单元格,可以作为一个单元函数来进行逻辑处理

步骤三: 根据状态和触发事件,应用梳理后的状态转移表,最终完成状态机的实现
触发事件
我们用枚举类作为触发事件类型

public enum EsignEventType {
    CertifyResult("1", "CertifyResult"),
    ManualReview("2", "ManualReview"),
    SignaturePermissionReview("3", "SignaturePermissionReview"),
    StampCreationApplicationReview("4", "StampCreationApplicationReview"),
    PayToPublic("5", "PayToPublic"),
    LegalRepresentativeSign("6", "LegalRepresentativeSign")
    ;


    private String code;
    private String name;

    EsignEventType(String code, String name) {
        this.code = code;
        this.name = name;
    }

    public String getCode() {
        return code;
    }

    public String getName() {
        return name;
    }

    public static EsignEventType getByCode(String code) {
        for (EsignEventType et : EsignEventType.values()) {
            if (et.getCode().equals(code)) {
                return et;
            }
        }
        return null;
    }
}

认证上下文
//初始化认证上下文
CertifyContext certifyContext = new EsignCertifyContext(certify, tenantId, certify.getPlatformType(), esignData, certifyTemplateManager.getPlatMap());

//状态扭转
certifyContext.when(eventType)
        .next();

状态转移表
@Slf4j
@Component
public class CertifyTemplateManager implements InitializingBean, ApplicationContextAware {

    private ApplicationContext context;

    public final static String KEY_TEMPLATE = "state_%s_code_%s";

    private Map<String, StateEventAction> platMap = new ConcurrentHashMap<>(16);


    public Map<String, StateEventAction> getPlatMap() {
        return platMap;
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.context = applicationContext;
    }


    @Override
    public void afterPropertiesSet() throws Exception {
        //进行状态转移表 注册
        platMap.put(String.format(KEY_TEMPLATE, "xxxState", "xxxEventCode"), xxxStateEventAction);
        platMap.put(String.format(KEY_TEMPLATE, "xxxState", "xxxEventCode"), xxxStateEventAction);
        ...
    }


}

总结:

在与实际生产业务中,会涉及多平台厂商,为了避免状态转移表的扩张,合理控住状态转移表的大小。选择的是树形结构去处理和应用状态机。

相关的数据流图如下

面对业务条件的扩张是选择扩展表,还是组合状态机。取决于实际业务的条件,没有绝对正确,只有更适合业务的,才是最好的。甚至会是一段时间内适合,未来扩张会继续转化,甚至是扩张表和组合状态机的混合使用。 状态机不是解决业务的银弹,本质是为了梳理业务的关系,让业务更清晰。

加入我们

我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。

扫码发现职位&投递简历

官网投递:job.toutiao.com/s/FyL7DRg

猜你喜欢

转载自juejin.im/post/7125364034685108255