戲說領域驅動設計(廿七)——Saga設計模型

SKevin發表於2022-05-23

  上一節我們講解了常用的事務,也提及了Saga,這是在分散式環境下被經常使用的一種處理複雜業務和分散式事務的設計模式。本章我們的主要目標是編寫一個簡單版本的Saga處理器,不同於Seata框架中那種可獨立部署的事務服務,我們所編寫的Saga和業務整合在一起也不支援通過手畫流程的方式實現,因為我們的目標是將Saga作為一種設計模式(不是框架)來使用,類似於您經常使用的“工廠”、“策略”等,重點學習它的思想,在真實專案中使用肯定是需要根據需求做二次加工的。而且,簡單版本的優勢就是足夠簡單,投入雖然不多但能從中獲取的收益卻很大。在程式碼演示後我們還會重點描述一下如何解決Saga事務的隔離性問題。

一、Saga種類說明

  常用的Saga包含兩類:協同式和編排式。前者把流程的走向與協調全盤由事務的參考者來完成,比如最簡單的場景:下單同時對庫存進行扣減,訂單服務本地事務完成後就把事件訊息傳送給庫存服務,庫存服務如果本地事務處理失敗則由它將回滾的訊息傳送給訂單服務。雖然整個流程當中訂單服務與庫存服務並沒有產生耦合,但由於沒有一個總的事務協調者,一旦服務參與者多起來那業務流程的可理解性就非常差,出了問題也不好定位。第二類為編排型事務也是我們本章要主要介紹的,通過把Saga的執行順序交由一個集中的Saga編排器,由它指揮並決策業務的流向,相對於協同式整個流程要清晰很多。除非您使用的是足夠成熟的第三方的框架,要不然集中式Saga也可能會存在事務參與者不清晰的問題,比如本文我們要介紹的Saga就會有類似的問題,畢竟以個人的精力很多事情的確無法做到極致,有犧牲也很正常。基於訊息式的Saga很多時候你並不知道誰訂閱了訊息,所有的消費情況都體現在程式碼中,非常不方便後續的業務擴充套件以及程式碼閱讀。個人在使用的時候偷了一個懶:通過圖形文件的方式說明整個事務的走向包括會由誰傳送命令,由誰來訂閱事件,也就是把流程的邏輯定義與程式碼進行了分離,好的方式當然是把這些資訊作為後設資料來用,誰都喜歡好的但付出的成本也很高的。況且我是在2015年開始使用Saga,我倒是想用Seata呢,沒有啊。

二、基於編排式的設計思想

  基於編排的模式設計思路我們在前文中已經大概介紹,那要如何實現呢?可參考如下圖所示。這裡使用了四個設計原則:1)方便起見Saga會和全域性事務發起的一起部署,這樣就不用花費精力考慮如何獨立部署Saga服務;2)Saga所有的服務遵循了這樣的一個模式:傳送命令,接受事件。也就是說Saga只向外釋出領域命令,事件參與者執行完本地事務後傳送領域事件並由Saga進行訂閱,各服務間使用訊息佇列進行解耦;3)Saga的實現也被分為應用服務和領域實體。所有的命令其實都是由Saga領域實體在處理事件後生成的,在應用服務中呼叫“RabbitMQ”的客戶端進行傳送;4)只儲存命令訊息,不儲存事件。

三、程式碼實現

  根據上述的模式說明您會發現釋出命令與事件並不需要進行程式碼的說明,通過RabbitMQ的客戶端即可搞定。由於我們並不會將Saga實現為一個框架,所以也不涉及到框架內部複雜的邏輯程式碼,那麼唯一需要介紹的是Saga如何與業務整合,這也是為什麼我為本章所起的標題是“Saga設計模式”。

1、命令分發與處理

  我們把傳送命令和事件的元件稱之為“命令匯流排”或“事件匯流排”,這樣的封裝在使用的時候不用使用者(一般是工程師)考慮訊息佇列使用的各種細節也可以實現元件的複用,畢竟您使用Saga的場景可能不只一個。

  熟悉RabbitMQ的朋友都知道想要讓消費者正確的收到訊息我們可以使用“Topic”模式,相當於給訊息一個路由的策略,Exchange會根據Topic的名稱把訊息投遞到某個佇列中,而消費者通過這與個佇列進行繫結就可以進行消費了。針對命令匯流排我們仍然這用這個模式,我們把Topic命名為“command.bus”。由於命令的特性是隻有一個消費者,所以不論消費者啟動了多少個例項,反正只要有一個能消費就行。這樣做以後……這樣做就什麼都做不了了,這個思路是錯誤的。以上圖為例,因為所有命令型的訊息的Topic都是“command.bus”,很有可能這些命令全被“事務參與者2”消費了,他處理“命令1”自然沒問題,“命令2”他可真搞不定了。所以,很可惜,“Topic”模式根本不適用。

  讓我們再翻翻其它的模式,“Direct”貌似也差點意思,搞不定!那隻能使用“Fanout”做點文章了。所以正確的方式應該是使用這個模式,訊息被髮出去後讓所有的事務參與者都去訂閱,然後在每個事務參考者內部維護一個類似路由表的東西,可以完成“通過某個命令的名稱便可知道應該哪段程式碼來處理這個命令”的需求,正好是一個鍵值對:鍵是命令的名稱,值是命令處理器。當根據鍵找不到對應的處理器時則把訊息直接扔掉,因為事件參與者使用的是廣播模式,但是其只關心自己能夠處理的命令。至此我們已經大致把方案確認了,那就讓我們一點點搞起來,music……

  首先我們先說命令物件的構成。由於有“命令路由表”的存在且這個表是一個鍵值對的形式,其中鍵是命令的名稱,程式會根據這個名稱去尋找對應的命令處理器。所以我們就得給每個命令一個獨一無二的名稱(如果全稱重複,那就出現Bug了),在我給出的實現中使用的是這個命令的類全名。此外,命令的路由過程我們直接寫在訊息的消費者裡,這樣我們在獲得命令名稱後就可以第一時間找到對應的處理器了,參考程式碼如下所示。之所以引入了一個“Message”的類是因為對事件的處理和命令是類似的,所以它實際上是事件與命令共同的父類。Command中的方法“from”用於將訊息反序列為命令物件,其返回的結果是真實的命令物件而不是“Command”這個父類。友情提示一下:清注意“Message”是從什麼物件繼承的。

public abstract class Message extends EntityModel {
    private String name;

    public Message() {
        this.name = this.getClass().getName();
    }
}

public class Command extends Message {
    public static Command from(String json) {
        JsonNode jsonNode = objectMapper.readValue(json, JsonNode.class);
        String className = jsonNode.get("name").asText();
        Command command = (Command)objectMapper.readValue(json, Class.forName(className));
    }
}

  我們再展示一下訊息監聽的程式碼,如下所示。其實很簡單就兩行:反序列化命令同時使用“CommandDispatcher”進行命令的分發處理,分發過程其實就是根據命令的名稱找到對應的命令處理器。這個類就是我們前方中提及的“命令路由器”

@RabbitListener(queues = “command.bus”)
public void listenCommand(String message, Message message1, Channel channel) {
    Command command = Command.from(message);
    CommandDispatcher.INSTANCE.dispatch(command);
}

  按一般的習慣我們應該開始介紹“CommandDispatcher”,但我們一般都喜歡不走尋常路,所以請移動您的尊駕我們先看看命令處理器要如何搞,程式碼如下所示  。“CommandHandlerUnite”是一個抽象類,所有的命令處理器都需要從它繼承。“process”方法是通過反射的形式呼叫到實際接收這個命令的方法,這句好是不是很不好理解?那我們細說一下:1)通過前面的程式碼我們知道命令的訊息雖然可以被成功的反序列化成真實的命令物件,但宣告它的型別仍然是抽象型別“Command”;2)一個應用服務中可能會有多個命令的處理方法,比如下文中的“AccountService ”所示。那麼我要如何根據一個宣告為抽象型別的物件呼叫到能夠以這個物件的實際型別為引數的方法呢?答案就是反射。

public abstract class CommandHandlerUnite {

    private static final String COMMAND_HANDLER_METHOD_NAME = "process";
   
    @Override
    public void process(Command command) {
        if (command == null) {
            return;
        }
        Class clazz = Command.from(command.getName());
        Method handler = this.getClass().getMethod(COMMAND_HANDLER_METHOD_NAME, new Class[]{clazz});
        handler.invoke(this, command);
    }
}

@Service
public class AccountService extends CommandHandlerUnite {
    public void handle(IncreaseRewardPoints command) {
        
    }
    
    public void handle(DecreaseRewardPoints command) {
        
    }
}

  到這裡相信聰明的您應該還可以跟得上節奏,那我們們再把前面的坑埋上,也就是“CommandDispatcher”,這是一個命令路由表物件,程式碼好下所示。“dispatch”用於分發命令,呼叫的是“CommandHandlerUnite”中宣告的方法“process”;“register”用於註冊命令處理器,也就是把命令處理器放到HashMap中。

public class CommandDispatcher {

    //命令處理器物件列表
    private Map<String, CommandHandlerUnite> commandHandlers = new HashMap<>();

    /**
     * 命令分發器例項
     */
    public final static CommandDispatcher INSTANCE = new CommandDispatcher();


    /**
     * 分發訊息方法
     */
    public void dispatch(Command command) {
        CommandHandler commandHandler = this.commandHandlers.get(command.getName());
        if (commandHandler != null) {
            commandHandler.process(command);
        }
    }

    /**
     * 註冊處理分發器
     *
     */
    public void register(String commandName, CommandHandlerUnite commandHandler) {
        this.commandHandlers.put(commandName, commandHandler);
    }
}

  到此,命令相關的處理已經講完了,雖然沒有說到Saga但離我們的目標已經不遠了。下面我們再講一下事件的處理。

2、事件分發與處理

  事件的分發與處理其實和命令的處理是一樣的,唯一的區別是一個事件可以被多個消費者同時消費 。如果事件的消費者分佈在多個服務中,在使用了“Fanout”模式後訊息可以被同時分發至對應的服務;如果是分佈在同一個服務中則仍然使用類似上面的路由表的形式,只是我們給它一個新的名稱“事件路由器”,程式碼和命令路由器略有不同,所下所示。

public class EventDispatcher {

    //事件處理器物件列表
    private Map<String, List<EventHandlerUnite>> eventHandlers = new HashMap<>();
    

    /**
     * 分發訊息方法
     */
    public void dispatch(Event event) {
        List<EventHandler> eventHandlers = this.eventHandlers.get(event.getName());
        for (EventHandler eventHandler : eventHandlers) {
            if (eventHandler != null) {
                eventHandler.process(event);
            }
        }
    }

    /**
     * 註冊處理分發器
     */
    public void register(String eventName, EventHandlerUnite eventHandler) {
        List<EventHandlerUnite> eventHandlers = this.eventHandlers.get(eventName);
        if (eventHandlers == null) {
            eventHandlers = new ArrayList<>();
        }
        eventHandlers.add(eventHandler);
        this.eventHandlers.put(eventName, eventHandlers);
    }
}

3、Saga

  我們的主角開始上場,不過講Saga還得有案例才行,所以還得使用我們前面介紹過的那個一句話業務“訂單支付後使用者積分加10”。其實螢幕前的朋友不要覺得案例簡單,我們學習的是模式不是表面的程式碼。書歸正文,Saga要分成兩個層次:應用服務和領域模型。在應用服務中我們註冊自己為事件處理器並編寫用於處理事件的方法,程式碼如下所示。再強調一下它的工作模式:傳送命令——接收事件。

@Service
public class OrderProcessSagaService extends EventHandlerUnite {
    
    OrderProcessSaga {        
        EventDispatcher.INSTANCE.register(OrderPaid.getClass().getName(), this);
        EventDispatcher.INSTANCE.register(RewardPointsIncreased.getClass().getName(), this);
    }
    
    public void handle(OrderPaid event) {
        OrderProcessSaga saga = this.orderProcessSagaRepository.findBy(event.getOrderId());
        if (saga == null) {
            saga = new OrderProcessSaga();
        }
        List<Command> commands = saga.handle(event);
        //命令和Saga物件一起進行儲存
        this.orderProcessSagaRepository.updateOrSave(saga);
        this.commandBus.post(commands);
    }
    
    public void handle(RewardPointsIncreased event) {
        OrderProcessSaga saga = this.orderProcessSagaRepository.findBy(event.getOrderId());
        sava.handle(event);
        //命令和Saga物件一起進行儲存
        this.orderProcessSagaRepository.update(saga);
        this.commandBus.post(commands);
    }
}

  上面程式碼中的“OrderProcessSaga”其實就是Saga的領域模型,所有的事件處理都在它裡面進行處理,貼一段程式碼供參考。兩個“handle”方法分別應對兩個不同的事件,在這些事件中您可以按自己的需求決策業務的走向也可以進行業務的補償。本案例只演示了正向的業務流程,其實反向(業務補償)的也是一樣的,只不過是處理不同的事件。注意一點:業務補償的程式碼要寫在“OrderProcessSagaService ”中,不可以寫在“OrderProcessSaga ”裡面,因為它只負責處理Saga的業務(只做業務的排程)不負責處理業務。什麼?怎麼決策業務的走向?命令啊,您通過傳送不同的命令不就實現了業務流程的控制了嗎?

public class OrderProcessSaga extends EntityModel {
    private String orderId;
    private String accountId;
    private Status status;
    private List<Command> commands = new ArrayList<>();
    
    //處理訂單支付事件
    public List<Command> handle(OrderPaid event) {
        if (this.status == Status.FINISHED || this.status == Status.CLOSED) {
            throw new OrderProcessException();
        }
        this.status = Status.STARTING_REWARD_POINTS_PROCESS;
        //傳送賬戶增加10積分的命令
        this.commands.add(new IncreaseRewardPoints(10L, this.accountId, this. orderId));
        
        return this.commands;
    }
    
    //處理積分增加事件
    public List<Command> handle(RewardPointsIncreased event) {
        if (this.status != Status.STARTING_REWARD_POINTS_PROCESS) {
            throw new OrderProcessException();
        }
        this.status = Status.FINISHED;        
        return this.commands;        
    }
}

  您知道為什麼我每次都會把Saga物件儲存起來嗎?一是為了流程更好的觀察,根據Saga的狀態便可知曉每個流程的當前狀態,加個頁面當然也可以啦;二是流程伴隨著命令資訊在一個事務內共同儲存不僅可以保障訊息不丟失,當遇到命令無法被正確傳送或傳送後訊息丟失、訊息沒有被路由到消費者的情況,我們只需要把命令查詢出來再傳送一下就解決了,隨隨便便就實現了斷點續傳。

總結

  本節主要講了Saga模式的實現方式及相關的程式碼。本來還想講一下如何處理Saga的隔離性問題,奈何最近精力有限,我們先發出一部分,後面我再把坑填上。對了,針對上面說過的命令或事件的名稱 我使用了類全名其實並不是很好,在分佈架構下反而容易洩露內部資訊。您其實也可以使用“服務名+命令類名”的方式,反正只要保障名稱不重複就行。此外,案例程式碼僅用於演示,真實環境中還需要做很多的驗證處理,請務必注意。

相關文章