實現一個狀態機引擎,教你看清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不僅實現了配置化,還實現了可視化,但是實現成本也最高。他們的關係如下圖所示:
image.png

不同DSL該如何選擇

幾種DSL類型各有各的使用場景,選擇的時候,可以這樣去做一個判斷。

  1. Internal DSL:假如你只是爲了增加代碼的可理解性,不需要做外部配置,我建議使用Internal DSL,簡單、方便、直觀。

  2. External DSL:如果你需要在Runtime的時候進行配置,或者配置完,不想重新部署代碼,可以考慮這種方式。比如,你有一個規則引擎,希望增加一條規則的時候,不需要重複發佈代碼,那麼可以考慮External。

  3. Workbench:配置也好,DSL Script也好,這東西對用戶不夠友好。比如在淘寶,各種針對商品的活動和管控規則非常複雜,變化也快。我們需要一個給運營提供一個workbench,讓他們自己設置各種規則,並及時生效。這時的workbench將會非常有用。
    image.png

總而言之,在合適的地方用合適的解決方案,不能一招鮮喫遍天。就像最臭名昭著的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方法後,才能調用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不是線程安全的。
image.png

這種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:狀態機
    image.png

整個狀態機的核心語義模型(Semantic Model)也很簡單,就是如下圖所示:
image.png

Note:這裏之所以叫Semantic Model,用的是《DSL》書裏的術語,你也可以理解爲是狀態機的領域模型。Martin用Semantic這個詞,是想說,外部的DSL script代表語法(Syntax),裏面的model代表語義(Semantic),我覺得這個隱喻還是很恰當的。

OK,狀態機語義模型的核心代碼如下所示:

//StateMachine
public class StateMachineImpl<S,E,C> implements StateMachine<S, E, C> {

    private String machineId;

    private final Map<S, State<S,E,C>> stateMap;

    ...
}

//State
public class StateImpl<S,E,C> implements State<S,E,C> {
    protected final S stateId;
    
    private Map<E, Transition<S, E,C>> transitions = new HashMap<>();
    
    ...
}

//Transition
public class TransitionImpl<S,E,C> implements Transition<S,E,C> {

    private State<S, E, C> source;

    private State<S, E, C> target;

    private E event;

    private Condition<C> condition;

    private Action<S,E,C> action;
    
    ...
}

狀態機的Fluent API

實際上,我用來寫Builder和Fluent Interface的代碼甚至比核心代碼還要多,比如我們的TransitionBuilder是這樣寫的

class TransitionBuilderImpl<S,E,C> implements ExternalTransitionBuilder<S,E,C>, InternalTransitionBuilder<S,E,C>, From<S,E,C>, On<S,E,C>, To<S,E,C> {

    final Map<S, State<S, E, C>> stateMap;

    private State<S, E, C> source;

    protected State<S, E, C> target;

    private Transition<S, E, C> transition;

    final TransitionType transitionType;

    public TransitionBuilderImpl(Map<S, State<S, E, C>> stateMap, TransitionType transitionType) {
        this.stateMap = stateMap;
        this.transitionType = transitionType;
    }

    @Override
    public From<S, E, C> from(S stateId) {
        source = StateHelper.getState(stateMap, stateId);
        return this;
    }

    @Override
    public To<S, E, C> to(S stateId) {
        target = StateHelper.getState(stateMap, stateId);
        return this;
    }

    @Override
    public To<S, E, C> within(S stateId) {
        source = target = StateHelper.getState(stateMap, stateId);
        return this;
    }

    @Override
    public When<S, E, C> when(Condition<C> condition) {
        transition.setCondition(condition);
        return this;
    }

    @Override
    public On<S, E, C> on(E event) {
        transition = source.addTransition(event, target, transitionType);
        return this;
    }

    @Override
    public void perform(Action<S, E, C> action) {
        transition.setAction(action);
    }

}

通過這種Fluent Interface的方式,我們確保了Fluent調用的順序,如下圖所示,在externalTransition的後面你只能調用from,在from的後面你只能調用to,從而保證了狀態機構建的語義正確性和連貫性。
image.png

狀態機的無狀態設計

至此,狀態機的核心模型和Fluent接口我已經介紹完了。我們還需要解決一個性能問題,也就是我前面說的,要把狀態機變成無狀態的

分析一下市面上的開源狀態機引擎,不難發現,它們之所以有狀態,主要是在狀態機裏面維護了兩個狀態:初始狀態(initial state)和當前狀態(current state),如果我們能把這兩個實例變量去掉的話,就可以實現無狀態,從而實現一個狀態機只需要有一個instance就夠了。

關鍵是這兩個狀態可以不要嗎?當然可以,唯一的副作用是,我們沒辦法獲取到狀態機instance的current state。然而,我也不需要知道,因爲我們使用狀態機,僅僅是接受一下source state,check一下condition,execute一下action,然後返回target state而已。它只是實現了一個狀態流轉的DSL表達,僅此而已,全程操作完全可以是無狀態的。

採用了無狀態設計之後,我們就可以使用一個狀態機Instance來響應所有的請求了,性能會大大的提升。
image.png

使用狀態機

狀態機的實現很簡單,同樣,他的使用也不難。如下面的代碼所示,它展現了cola狀態機支持的全部三種transition方式。

StateMachineBuilder<States, Events, Context> 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);

        StateMachine<States, Events, Context> stateMachine = StateMachineFactory.get(machineId);
        stateMachine.showStateMachine();

可以看到,這種Internal DSL的狀態機顯著的提升了代碼的可讀性和可理解性。特別是在相對複雜的業務狀態流轉中,比如下圖就是我們用cola-statemachine生成的我們實際項目中的plantUML圖。如果沒有狀態機的支持,像這樣的業務代碼將會很難看懂和維護。
image.png

這就是DSL的核心價值——更加清晰地表達系統中,某一部分的設計意圖和業務語義。 當然External DSL所帶來的可配置性和靈活性也很有價值,只是cola-statemachine還沒有支持,原因很簡單,暫時用不上。

最後

最後,如果你覺得這邊文章對你有用,也順便支持下我的新書——《代碼精進之路》

image.png

最後的最後,我團隊正在招賢納士,如果你在技術發展道路上有些迷茫,不妨來我團隊看看

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章