什么是DSL?DSL是一种工具,其核心价值是提供一种更清晰地传达系统某个部分的意图的手段。本文将通过实现一个状态机引擎来理解DSL的本质,介绍状态机的核心模型和流畅接口,解决状态机的性能问题。最近在一个项目中,因为涉及到很多状态流,所以选择了使用状态机引擎来表达状态流。因为状态机DSL(DomainSpecificLanguages)带来的表达能力,相比if-else代码,更加优雅,也更容易理解。另一方面,状态机很简单,不像流程引擎那样华丽。一开始我们选择了一个开源的状态机引擎,但是觉得不太好用,所以自己写了一个简洁版的状态机可以满足我们的要求,以此来对比KISS(KeepItSimpleandStupid).作为COLA开源的一部分,我开源了状态机(cola-statemachine),大家可以访问获取:https://github.com/alibaba/COLA在实现状态机的过程中,我很幸运足以看到马丁福勒的《Domain Specific Languages》。书中的内容让我对DSL有了不同的理解。这也是本文的起因。希望看完这篇文章后,你能对什么是DSL、如何使用DSL、如何使用状态机有不一样的认识。DSL在介绍如何实现状态机之前,让我们先看看MartinFowler的《Domain Specific Languages》一书中什么是DSL。开头是介绍DSLwithStateMachine作为介绍。如果你有时间,我强烈推荐你阅读这本书。如果你没有时间,你可以看看下面的内容,大概了解一下。下面就让我提炼一下书中的内容,带你深入了解DSL。什么是DSLDSL是一种工具,其核心价值在于它提供了一种更清晰地传达系统某个部分的意图的手段。这种清晰不仅仅是一种审美追求。一段代码越容易理解,就越容易发现错误并对系统进行更改。因此,我们鼓励有意义的变量名、清晰的文档和清晰的代码结构。出于同样的原因,我们也应该鼓励采用DSL。根据定义,DSL是一种计算机编程语言,对特定领域的表达能力有限。该定义包含3个关键要素:语言性质:DSL是一种编程语言,因此它必须具有连贯的表达能力——无论是单个表达式还是多个表达式的组合。Limitedexpressiveness(有限表现力):通用编程语言提供广泛的能力:支持各种数据、控制和抽象结构。这些能力很有用,但也会使语言难以学习和使用。DSL仅支持特定域所需的最少功能集。使用DSL并不能构建一个完整的系统,相反,可以解决系统的一个方面。专注于领域:这种能力有限的语言仅在定义明确的小领域中有用。这个领域是使语言值得使用的原因。比如正则表达式:/\d{3}-\d{3}-\d{4}/就是一个典型的DSL,解决的是这个特定字段的字符串匹配问题。DSL的分类DSL按照类型可以分为三类:内部DSL(InternalDSL)、外部DSL(ExternalDSL)和语言工作台(LanguageWorkbench)。内部DSL是通用语言的特定用法。用内部DSL编写的脚本是合法的程序,但它具有一定的风格,并且只使用语言特性的一个子集来处理整个系统的一个小方面。用此DSL编写的程序具有自定义语言的风格,与使用它的宿主语言不同。比如我们的状态机是InternalDSL,不支持脚本配置,使用的时候还是Java语言,但是不妨碍它也是一个DSL。builder.externalTransition().from(States.STATE1).to(States.STATE2).on(Events.EVENT1).when(checkCondition()).perform(doAction());外部DSL是一种“不同于应用程序系统主要使用的语言”。外部DSL通常使用自定义语法,尽管选择另一种语言的语法并不少见(XML是一种常见的选择)。示例包括Struts和Hibernate等系统使用的XML配置文件。Workbench是一个专用的IDE。简单来说,Workbench就是DSL的产品化和可视化形式。三类DSL从前到后是递进关系。内部DSL最简单,实现成本低,但不支持“外部配置”。Workbench不仅实现了配置,还实现了可视化,但是实现成本也是最高的。它们的关系如下图:如何选择不同的DSL类型。每种DSL类型都有自己的使用场景。选择的时候,可以这样判断。InternalDSL:如果你只是想增加代码的可理解性,不需要做外部配置,我推荐使用InternalDSL,简单、方便、直观。ExternalDSL:如果需要在运行时配置,或者配置后不想重新部署代码,可以考虑这种方式。例如,如果你有一个规则引擎,想添加一个规则而不需要重新发布代码,你可以考虑External。Workbench:无论是配置还是DSLScript,这个东西都不够人性化。比如淘宝,商品的各种活动和管控规则非常复杂,变化也很快。我们需要一个运营工作台,让他们制定各种规则,及时生效。这时候工作台就会非常有用。总而言之,对的地方对的解,不能一招包吃。就像最臭名昭著的DSL-processengine一样,是严重滥用和过渡设计的典型,是把简单问题复杂化的典型。最好不要增加不必要的复杂性。但是,要简单并不是一件容易的事,尤其是在大公司,我们不仅要写代码,还要能够积累“NB技术”,最好是那种能被老板怼的技术,如Nicholas在《反脆弱》中说:在现代生活中,简单一直难以捉摸,因为它违背了一些力求复杂以证明其工作合理性的精神。当FluentInterfaces编写软件库时,我们有两种选择。一是提供Command-QueryAPI,二是FluentInterfaces。比如Mockito的API:when(mockedList.get(anyInt())).thenReturn("element")就是一个典型的连贯接口的用法。流畅的接口是实现内部DSL的重要方式。你为什么这么说?因为Fluent的连贯性带来的可读性和可理解性的提升,不仅仅是提供API,而是一种领域语言,是一种InternalDSL。比如Mockito的API:when(mockedList.get(anyInt())).thenReturn("element")就非常适合Fluent的形式使用。其实它也是针对单元测试这个特定领域的DSL。如果把这个Fluent换成Command-QueryAPI,就很难表达测试框架的领域。Stringelement=mockedList.get(anyInt());booleanisExpected="元素".equals(元素);这里需要注意的是,连贯接口不仅仅可以提供类似于方法链和构建器模式的方法级联调用,比如在OkHttpClientBuilder中:OkHttpClient.Builderbuilder=newOkHttpClient.Builder();OkHttpClientokHttpClient=builder.readTimeout(5*1000,TimeUnit.SECONDS).writeTimeout(5*1000,TimeUnit.SECONDS).connectTimeout(5*1000,TimeUnit.SECONDS)he更重要的是,它限制了方法调用的顺序。例如,在构建状态机时,我们只能在调用from方法后调用to方法。Builder模式没有这个功能。怎么做?我们可以使用Builder和Fluent接口的结合来实现。下面我将进一步介绍状态机实现部分。好的状态机,这就是我要说的关于DSL的全部内容。接下来,让我们看看如何实现一个内部DSL状态机引擎。状态机选择我反对滥用流程引擎,但我不排斥状态机。主要有两个原因:首先,状态机的实现可以非常轻量级。最简单的状态机用一个Enum就可以实现,基本是零成本。其次,使用状态机的DSL来表达状态的流转,语义会更加清晰,代码的可读性和可维护性也会增强。不过,虽然我们的业务场景不是特别复杂,但还是超出了只支持线性状态流的Enum的范围。所以不得不先看看外面。开源状态机太复杂了。和流程引擎一样,开源的状态机引擎并不多。我重点介绍了两个状态机引擎的实现,一个是SpringStatemachine,一个是Squirrelstatemachine。这是目前github上排名前2的状态机实现。它们的优点是功能齐全,缺点也很全。当然,这不能怪开源软件的作者。如果你最终开源了一个项目,至少你必须支持UMLStateMachine上列出的所有功能点。就我们的项目而言(其实大部分项目都是这样),我真的不需要那么多状态机的高级玩法:比如状态嵌套(substate),状态并行(parallel,fork,join),子状态机等。除了开源状态机性能不佳之外,还有一个问题是我不能容忍的。这些开源状态机都是有状态的(Stateful)。从表面上看,状态机制当然应该维护状态。但是仔细想想,这种状态是没有必要的,因为状态是有的,状态机的实例不是线程安全的,而我们的应用服务器是分布式多线程的,所以每次状态机器接受请求时不时地,一个新的状态机实例必须被重建。以电商交易为例,用户下单后,我们调用状态机实例将状态变为“OrderPlaced”。当用户支付订单时,可能是另外一个线程,也可能是另外一个服务器,所以我们必须重新创建一个状态机实例。因为原始实例不是线程安全的。这个每个请求方法的新实例消耗功率,更不用说了。如果状态机构造复杂,QPS高,肯定会遇到性能问题。考虑到复杂性和性能(公司电费),我们决定自己实现一个状态机引擎。设计目标很明确,有两个要求:简单的状态机,只支持状态流,不需要支持嵌套、并行等高级玩法。状态机本身需要是Stateless(无状态)的,这样一个SingletonInstance就可以服务于所有的状态转移请求。状态机实现状态机领域模型鉴于我们的诉求是实现一个只支持简单状态流的状态机,状态机的核心概念如下图所示,主要包括:State:状态Event:事件,状态由事件触发,引起ChangeTransition:流,表示从一种状态到另一种状态ExternalTransition:外部流,两种不同状态之间的流InternalTransition:内部流,相同状态之间的流Condition:条件,表示是否允许达到某个状态StateAction:动作,达到某个状态后可以做什么StateMachine:状态机整个状态机的核心语义模型(SemanticModel)也很简单,如下图所示:注:这里之所以叫SemanticModel,是因为使用了《DSL》书中的一个术语,也可以理解为状态机的领域模型。Martin用Semantic这个词来表示,外部DSL脚本代表句法(Syntax),内部模型代表语义(Semantic)。我觉得这个比喻很贴切。OK,状态机语义模型核心代码如下://StateMachinepublicclassStateMachineImplimplementsStateMachine{privateStringmachineId;privatefinalMap>stateMap;...}//StatepublicclassStateImplimplementsState{protectedfinalSstateId;privateMapimplementsTransition{privateStatesource;privateStatetarget;privateEevent;privateConditionaction;...}状态机的FluentAPI其实我用来写Builder和FluentInterface的代码比核心代码还要多。比如我们的TransitionBuilder是这样写的:classTransitionBuilderImplimplementsExternalTransitionBuilder,InternalTransitionBuilder,From,On,To{...@OverridepublicFromfrom(SstateId){source=StateHelper.getState(stateMap,stateId);returnthis;}@OverridepublicToto(SstateId){target=StateHelper.getState(stateMap,stateId);returnthis;}...}通过这个FluentInterface方法,我们保证了Fluent的顺序ofcalls,如下图,externalTransition之后只能调用from,from之后只能调用to,从而保证了状态机构建的语义正确性和连贯性。状态机的无状态设计到目前为止,我已经介绍了状态机的核心模型和Fluent接口。我们还需要解决一个性能问题,也就是前面说的,让状态机无状态。分析市面上开源的状态机引擎不难发现,它们之所以有状态,是因为它们在状态机中维护了两种状态:初始状态和当前状态。如果我们能把这两个实例变量去掉,就可以实现无状态,这样只需要一个实例就足以实现一个状态机。关键是这两个状态能不能省略?当然,唯一的副作用是我们无法获取状态机实例的当前状态。然而,我不需要知道,因为我们使用状态机,只是接受源状态,检查条件,执行动作,然后返回目标状态。它只是实现了一个状态流的DSL表达,仅此而已,整个操作可以完全无状态。采用无状态设计后,我们可以使用一个状态机Instance来响应所有的请求,性能会有很大的提升。使用状态机状态机的实现非常简单,同样,它的使用也不难。如下代码所示,它显示了可乐状态机支持的所有三种转换方法。StateMachineBuilder
