给 DSL 开个脑洞:无状态的状态机

最近在一个项目中,因为涉及很多状态的流转,我们选择使用状态机引擎来表达状态流转。因为状态机 DSL(Domain Specific Languages)带来的表达能力,相比较于 if-else 的代码,要更优雅更容易理解。另一方面,状态机很简单,不像流程引擎那么华而不实。

一开始我们选用了一个开源的状态机引擎,但我觉得不好用,就自己写了一个能满足我们要求的简洁版状态机,这样比较 KISS(Keep It Simple and Stupid)。

作为 COLA 开源的一部分,我已经将该状态机(cola-statemachine)开源,你可以访问获取: https://github.com/alibaba/COLA

在实现状态机的过程中,有幸看到 Martin Fowler 写的《Domain Specific Languages》。书中的内容让我对 DSL 有了不一样的认知。

这也是为什么会有这篇文章的原因,希望你看完这边文章以后, 可以对什么是 DSL、如何使用 DSL、如何使用状态机都能有一个不一样的体会

DSL

在介绍如何实现状态机之前,不妨让我们先来看一下什么是 DSL,在 Martin Fowler 的《Domain Specific Languages》书中。开篇就是以 State Machine 来作为引子介绍 DSL 的。有时间的话,强烈建议你去读读这本书。没时间的话,看看下面的内容也能掌握个大概了。

下面就让我提炼一下书中的内容,带大家深入了解下 DSL。

什么是 DSL

DSL 是一种工具,它的核心价值在于,它提供了一种手段,可以更加清晰地就系统某部分的意图进行沟通。

这种清晰并非只是审美追求。一段代码越容易看懂,就越容易发现错误,也就越容易对系统进行修改。因此,我们鼓励变量名要有意义,文档要写清楚,代码结构要写清晰。基于同样的理由,我们应该也鼓励采用 DSL。

按照定义来说,DSL 是针对某一特定领域,具有受限表达性的一种计算机程序设计语言。这一定义包含 3 个关键元素:

  • 语言性(language nature):DSL 是一种程序设计语言,因此它必须具备连贯的表达能力——不管是一个表达式还是多个表达式组合在一起。
  • 受限的表达性(limited expressiveness):通用程序设计语言提供广泛的能力:支持各种数据、控制,以及抽象结构。这些能力很有用,但也会让语言难于学习和使用。DSL 只支持特定领域所需要特性的最小集。使用 DSL,无法构建一个完整的系统,相反,却可以解决系统某一方面的问题。
  • 针对领域(domain focus):只有在一个明确的小领域下,这种能力有限的语言才会有用。这个领域才使得这种语言值得使用。

比如正则表达式:

复制代码

/\d{3}-\d{3}-\d{4}/

就是一个典型的 DSL,解决的是字符串匹配这个特定领域的问题。

DSL 的分类

按照类型,DSL 可以分为三类:内部 DSL(Internal DSL)、外部 DSL(External DSL)、以及语言工作台(Language Workbench)。

  • Internal DSL 是一种通用语言的特定用法。用内部 DSL 写成的脚本是一段合法的程序,但是它具有特定的风格,而且只用到了语言的一部分特性,用于处理整个系统一个小方面的问题。用这种 DSL 写出的程序有一种自定义语言的风格,与其所使用的宿主语言有所区别。例如我们的状态机就是 Internal DSL,它不支持脚本配置,使用的时候还是 Java 语言,但并不妨碍它也是 DSL。

复制代码

builder.externalTransition()
.from(States.STATE1)
.to(States.STATE2)
.on(Events.EVENT1)
.when(checkCondition())
.perform(doAction());
  • External DSL 是一种“不同于应用系统主要使用语言”的语言。外部 DSL 通常采用自定义语法,不过选择其他语言的语法也很常见(XML 就是一个常见选 择)。比如像 Struts 和 Hibernate 这样的系统所使用的 XML 配置文件。
  • Workbench 是一个专用的 IDE,简单点说,工作台是 DSL 的产品化和可视化形态。

三个类别 DSL 从前往后是有一种递进关系,Internal DSL 最简单,实现成本也低,但是不支持“外部配置”。Workbench 不仅实现了配置化,还实现了可视化,但是实现成本也最高。他们的关系如下图所示:

不同 DSL 该如何选择

几种 DSL 类型各有各的使用场景,选择的时候,可以这样去做一个判断。

  • Internal DSL:假如你只是为了增加代码的可理解性,不需要做外部配置,我建议使用 Internal DSL,简单、方便、直观。
  • External DSL:如果你需要在 Runtime 的时候进行配置,或者配置完,不想重新部署代码,可以考虑这种方式。比如,你有一个规则引擎,希望增加一条规则的时候,不需要重复发布代码,那么可以考虑 External。
  • Workbench:配置也好,DSL Script 也好,这东西对用户不够友好。比如在淘宝,各种针对商品的活动和管控规则非常复杂,变化也快。我们需要一个给运营提供一个 workbench,让他们自己设置各种规则,并及时生效。这时的 workbench 将会非常有用。

总而言之,在合适的地方用合适的解决方案,不能一招鲜吃遍天。就像最臭名昭著的 DSL —— 流程引擎,就属于那种严重的被滥用和过渡设计的典型,是把简单的问题复杂化的典型。

最好不要无端增加复杂性。然而,想做简单也不是一件容易的事,特别是在大公司,我们不仅要写代码,还要能沉淀“NB 的技术”,最好是那种可以把老板说的一愣一愣的技术,就像尼古拉斯在《反脆弱》里面说的:

在现代生活中,简单的做法一直难以实现,因为它有违某些努力寻求复杂化以证明其工作合理性的人所秉持的精神。

Fluent Interfaces

在编写软件库的时候,我们有两种选择。一种是提供 Command-Query API,另一种是 Fluent Interfaces。比如 Mockito 的 API :

复制代码

when(mockedList.get(anyInt())).thenReturn("element")

就是一种典型连贯接口的用法。

连贯接口(fluent interfaces)是实现 Internal DSL 的重要方式,为什么这么说呢?

因为 Fluent 的这种连贯性带来的可读性和可理解的提升,其本质不仅仅是在提供 API,更是一种领域语言,是一种 Internal DSL。

比如 Mockito 的 API:

复制代码

when(mockedList.get(anyInt())).thenReturn("element")

就非常适合用 Fluent 的形式,实际上,它也是单元测试这个特定领域的 DSL。

如果把这个 Fluent 换成是 Command-Query API,将很难表达出测试框架的领域。

复制代码

String element= mockedList.get(anyInt());
boolean isExpected="element".equals(element);

这里需要注意的是,连贯接口不仅仅可以提供类似于 method chaining 和 builder 模式的方法级联调用,比如 OkHttpClient 中的 Builder:

复制代码

OkHttpClient.Builderbuilder=new OkHttpClient.Builder();
OkHttpClientokHttpClient=builder
.readTimeout(5*1000, TimeUnit.SECONDS)
.writeTimeout(5*1000, TimeUnit.SECONDS)
.connectTimeout(5*1000, TimeUnit.SECONDS)

他更重要的作用是,限定方法调用的顺序。比如,在构建状态机的时候,我们只有在调用了 from 方法后,才能调用 to 方法,Builder 模式没有这个功能。

怎么做呢?我们可以使用 Builder 和 Fluent 接口结合起来的方式来实现,下面的状态机实现部分,我会进一步介绍。

状态机

好的,关于 DSL 的知识我就介绍这么多。接下来,让我们看看应该如何实现一个 Internal DSL 的状态机引擎。

状态机选型

我反对滥用流程引擎,但并不排斥状态机,主要有以下两个原因:

  • 首先,状态机的实现可以非常的轻量,最简单的状态机用一个 Enum 就能实现,基本是零成本。
  • 其次,使用状态机的 DSL 来表达状态的流转,语义会更加清晰,会增强代码的可读性和可维护性。

然而,我们的业务场景虽然也不是特别复杂,但还是超出了 Enum 仅支持线性状态流转的范畴。因此不得不先向外看看。

开源状态机太复杂

和流程引擎一样,开源的状态机引擎不可谓不多,我着重看了两个状态机引擎的实现,一个是 Spring Statemachine,一个是 Squirrel statemachine。这是目前在 github 上的 Top 2 的状态机实现,他们的优点是功能很完备,缺点也是功能很完备。

当然,这也不能怪开源软件的作者,你好不容易开源一个项目,至少要把 UML State Machine 上罗列的功能点都支持掉吧。

就我们的项目而言(其实大部分项目都是如此),我实在不需要那么多状态机的高级玩法:比如状态的嵌套(substate),状态的并行(parallel,fork,join)、子状态机等等。

开源状态机性能差

除此之外,还有一个我不能容忍的问题是,这些开源的状态机都是有状态的(Stateful)的,表面上来看,状态机理所当然是应该维持状态的。但是深入想一下,这种状态性并不是必须的,因为有状态,状态机的实例就不是线程安全的,而我们的应用服务器是分布式多线程的,所以在每一次状态机在接受请求的时候,都不得不重新 build 一个新的状态机实例。

以电商交易为例,用户下单后,我们调用状态机实例将状态改为“Order Placed”。当用户支付订单的时候,可能是另一个线程,也可能是另一台服务器,所以我们必须重新创建一个状态机实例。因为原来的 instance 不是线程安全的。

这种 new instance per request 的做法,耗电不说。倘若状态机的构建很复杂,QPS 又很高的话,肯定会遇到性能问题。

鉴于复杂性和性能(公司电费)的考虑,我们决定自己实现一个状态机引擎,设计的目标很明确,有两个要求:

  1. 简洁的仅支持状态流转的状态机,不需要支持嵌套、并行等高级玩法。
  2. 状态机本身需要是 Stateless(无状态)的,这样一个 Singleton Instance 就能服务所有的状态流转请求了。

状态机实现

  • 状态机领域模型

鉴于我们的诉求是实现一个仅支持简单状态流转的状态机,该状态机的核心概念如下图所示,主要包括:

  1. State:状态
  2. Event:事件,状态由事件触发,引起变化
  3. Transition:流转,表示从一个状态到另一个状态
  4. External Transition:外部流转,两个不同状态之间的流转
  5. Internal Transition:内部流转,同一个状态之间的流转
  6. Condition:条件,表示是否允许到达某个状态
  7. Action:动作,到达某个状态之后,可以做什么
  8. StateMachine:状态机

整个状态机的核心语义模型(Semantic Model)也很简单,就是如下图所示:

Note:这里之所以叫 Semantic Model,用的是《DSL》书里的术语,你也可以理解为是状态机的领域模型。Martin 用 Semantic 这个词,是想说,外部的 DSL script 代表语法(Syntax),里面的 model 代表语义(Semantic),我觉得这个隐喻还是很恰当的。

OK,状态机语义模型的核心代码如下所示:

复制代码

//StateMachine
publicclassStateMachineImplimplementsStateMachine {

privateString machineId;
privatefinalMap<S, State> stateMap;

...
}

//State
publicclassStateImplimplementsState {
protectedfinalS stateId;
privateMap<E, Transition> transitions = new HashMap();

...
}

//Transition
publicclassTransitionImplimplementsTransition {

privateState source;
privateState target;
privateE event;
privateCondition condition;
privateAction action;

...
}
  • 状态机的 Fluent API

实际上,我用来写 Builder 和 Fluent Interface 的代码甚至比核心代码还要多,比如我们的 TransitionBuilder 是这样写的

复制代码

classTransitionBuilderImplimplementsExternalTransitionBuilder,InternalTransitionBuilder,From,On,To {
...
@Override
publicFromfrom(S stateId) {
source = StateHelper.getState(stateMap,stateId);
returnthis;
}

@Override
publicTo to(S stateId) {
target = StateHelper.getState(stateMap, stateId);
returnthis;
}
...
}

通过这种 Fluent Interface 的方式,我们确保了 Fluent 调用的顺序,如下图所示,在 externalTransition 的后面你只能调用 from,在 from 的后面你只能调用 to,从而保证了状态机构建的语义正确性和连贯性。

  • 状态机的无状态设计

至此,状态机的核心模型和 Fluent 接口我已经介绍完了。我们还需要解决一个性能问题,也就是我前面说的,要把状态机变成无状态的。

分析一下市面上的开源状态机引擎,不难发现,它们之所以有状态,主要是在状态机里面维护了两个状态:初始状态(initial state)和当前状态(current state),如果我们能把这两个实例变量去掉的话,就可以实现无状态,从而实现一个状态机只需要有一个 instance 就够了。

关键是这两个状态可以不要吗?当然可以,唯一的副作用是,我们没办法获取到状态机 instance 的 current state。然而,我也不需要知道,因为我们使用状态机,仅仅是接受一下 source state,check 一下 condition,execute 一下 action,然后返回 target state 而已。它只是实现了一个状态流转的 DSL 表达,仅此而已,全程操作完全可以是无状态的。

采用了无状态设计之后,我们就可以使用一个状态机 Instance 来响应所有的请求了,性能会大大的提升。

使用状态机

状态机的实现很简单,同样,他的使用也不难。如下面的代码所示,它展现了 cola 状态机支持的全部三种 transition 方式。

复制代码

StateMachineBuilder builder = StateMachineBuilderFactory.create();
//externaltransition
builder.externalTransition()
.from(States.STATE1)
.to(States.STATE2)
.on(Events.EVENT1)
.when(checkCondition())
.perform(doAction());

//internaltransition
builder.internalTransition()
.within(States.STATE2)
.on(Events.INTERNAL_EVENT)
.when(checkCondition())
.perform(doAction());

//externaltransitions
builder.externalTransitions()
.fromAmong(States.STATE1, States.STATE2, States.STATE3)
.to(States.STATE4)
.on(Events.EVENT4)
.when(checkCondition())
.perform(doAction());

builder.build(machineId);

可以看到,这种 Internal DSL 的状态机显著的提升了代码的可读性和可理解性。特别是在相对复杂的业务状态流转中,比如下图就是我们用 cola-statemachine 生成的我们实际项目中的 plantUML 图。如果没有状态机的支持,像这样的业务代码将会很难看懂和维护。

这就是 DSL 的核心价值——更加清晰地表达系统中,某一部分的设计意图和业务语义。当然 External DSL 所带来的可配置性和灵活性也很有价值,只是 cola-statemachine 还没有支持,原因很简单,暂时用不上。

本文转载自公众号阿里技术(ID:ali_tech)。

原文链接:

https://mp.weixin.qq.com/s/S4DQZmTKiKW7Crwco3hdPA