最近在一个项目中,因为涉及很多状态的流转,我们选择使用状态机引擎来表达状态流转。因为状态机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的APIwhen(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.Builder builder=new OkHttpClient.Builder();
OkHttpClient okHttpClient=builder
.readTimeout(5*1000, TimeUnit.SECONDS)
.writeTimeout(5*1000, TimeUnit.SECONDS)
.connectTimeout(5*1000, TimeUnit.SECONDS)
.build();
他更重要的作用是,限定方法调用的顺序。比如,在构建状态机的时候,我们只有在调用了from方法后,才能调用

本文介绍了一种使用状态机领域特定语言(DSL)简化状态流转的实践。通过内部DSL和Fluent接口,实现了简洁且高性能的状态机引擎,提升了代码可读性和可维护性。
最低0.47元/天 解锁文章
755





