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

 

评论区留言送新书:《代码精进之路》...

阿里妹导读:什么是 DSL ?DSL 是一种工具,其核心价值在于提供了,一种手段,可以更加清晰地就系统,某部分的意图进行沟通。本文将通过实现一个状态机,引擎来看清 DSL 的本质,介绍状态机的核心模型和 Fluent 接口,并解决状态机的性能问题。

最近在一个项目中,因为涉及很多状态的流转,我们选择使用状态机引擎,来表达状态流转。因为状态机 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.Builder builder=new OkHttpClient.Builder();


              OkHttpClient okHttpClient=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
                public class StateMachineImpl implements StateMachine {


                private String machineId;


                private final Map stateMap;


                ...
                }


                //State


                public class StateImpl implements State {


                protected final S stateId;


                private Map transitions = new HashMap();


                ...
                }


                //Transition


                public class TransitionImpl implements Transition {


                private State source;


                private State target;


                private E event;


                private Condition condition;


                private Action action;


                ...
                }


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

                  class

                  TransitionBuilderImpl implements ExternalTransitionBuilder, InternalTransitionBuilder, From, On, To {



                  ...



                  @Override



                  public From from(S stateId) {



                  source = StateHelper.getState(stateMap,stateId);



                  return this;



                  }


                  @Override



                  public To to(S stateId) {



                  target = StateHelper.getState(stateMap, stateId);

                       return this;    


                  }



                  ...
                  }


                  通过这种 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();


                    //external transition



                    builder.externalTransition()



                    .from(States.STATE1)



                    .to(States.STATE2)



                    .on(Events.EVENT1)



                    .when(checkCondition())    


                    .perform(doAction());


                    //internal transition



                    builder.internalTransition()



                    .within(States.STATE2)



                    .on(Events.INTERNAL_EVENT)



                    .when(checkCondition())



                    .perform(doAction());


                    //external transitions

                      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 还没有支持,原因很简单,暂时用不上。

                    福利来了 

                    评论区留言

                    送本文作者新书

                    《代码精进之路》
                    亲爱的同学,看完本文你对 DSL 有什么新的认识呢?写 DSL 的时候遇到过哪些坑?对于 DSL 你还有什么疑惑的地方吗?

                    欢迎同学们在评论区留言,作者张建飞将选出 5 个评论回复,并送出他的新书《代码精进之路》,这是一本专门为,程序员写的书,帮助广大程序员培养良好的编程习惯和思维。

                    阿里巴巴 2020 实习生招聘空中宣讲会,来了!6 位阿里大咖助你拿 offer,识别下方二维码或点击文末“阅读原文”即可收看回放:
                    你可能还喜欢
                    点击下方图片即可阅读

                    一分钟搭建会话机器人,阿里是怎么做到的?



                    AI训练数据不够用?支付宝3D合成方案揭秘
                    关注「阿里技术」
                    把握前沿技术脉搏


                    戳我,看招聘宣讲会回放。


                        关注 阿里技术


                    微信扫一扫关注公众号

                    0 个评论

                    要回复文章请先登录注册