《實現領域驅動設計》筆記——架構

Ruby_Lu發表於2023-12-18

  DDD的一大好處便是它並不需要使用特定的架構。由於核心域位於限界上下文中,我們可以在整個系統中使用多種風格的架構。有些架構包圍著領域模型,能夠全域性性地影響系統,而有些架構則滿足了某些特定的需求。我們的目標是選擇合適於自己的架構和架構模式。

  在選擇架構風格和架構模式時,我們應該將軟體質量考慮在內,而同時,避免濫用架構風格和架構模式也是重要的。質量驅動的架構選擇是種風險驅動方式,即我們採用的架構是用來減少失敗風險的,而不是增加失敗風險。因此,我們必須對每種架構做出正確的評估。

  對架構風格和模式的選擇受到功能需求的限制,比如用例或使用者故事。換句話說,在沒有功能需求的情況下,我們是不能對軟體質量做出評判的,亦不能做出正確的架構選擇。這也說明用例驅動架構在當今的軟體開發中依然使用。

 

  分層

  分層架構模式被認為是所有架構的始祖。它支援N層架構系統,因此被廣泛應用於Web、企業級應用和桌面應用。在這種架構中,我們將一個應用程式或系統分為不同的層次。

  在分層架構中,我們將領域模型和業務邏輯分離出來,並減少對基礎設施、使用者介面甚至應用層邏輯的依賴,因為它們不屬於業務邏輯。將一個複雜的系統分為不同的層,每層都應該具有良好的內聚性,並且只依賴於比其自身更低的層。

  

  分層架構的一個重要原則是:每層只能與位於其下方的層發生耦合。分層架構也分為幾種:在嚴格分層架構中,某層只能與直接位於其下方的層發生耦合;而鬆散分層架構則允許任意上方層與任意下方層發生耦合。由於使用者介面層和應用服務通常需要與基礎設施打交道,許多系統都是基於鬆散分層架構的。

  事實上,較低層也是可以和較高層發生耦合的,但這隻侷限於採用觀察者模式或者調停者模式的情況。 較低層絕對不能直接訪問較高層的。例如,在使用調停者模式時,較高層可能實現了較低層的介面,然後將實現物件作為引數傳遞到較低層。當較低層呼叫該實現時,它並不知道實現出自何處。

 

  使用者介面只用於處理使用者顯示和使用者請求,它不應該包含領域或業務邏輯。有人可能會認為,既然使用者介面需要對使用者輸入進行驗證,那麼它就應該包含業務邏輯。事實上,使用者介面所進行的驗證和對領域模型的驗證是不同。在實體中會講到,對於那些粗製濫造的,並且只面向領域模型的驗證行為,我們依然應該予以限制。

  如果使用者介面使用了領域模型中的物件,那麼此時的領域物件僅限於資料的渲染展示。在採用這種方式時,可以使用展現模型對使用者介面與領域物件進行解耦。

  由於使用者可能是人,也可能是其他的系統,有時使用者介面層將採用開放主機服務的方式向外提供API。

 

  使用者介面層是應用層的直接使用者。

  應用服務位於應用層中。應用服務和領域服務是不同的,因此領域邏輯也不應該出現在應用服務中。應用服務可以用於控制持久化事務和安全認證,或者向其他系統傳送基於事件的訊息通知,另外還可以用於建立郵件以傳送給使用者。應用服務本身並不處理業務邏輯,但它確實領域模型的直接使用者。

  應用服務是很輕量的,它主要用於協調對領域物件的操作,比如聚合。同時,應用服務是表達用例和使用者故事的主要手段。因此,應用服務的通常用途是:接受來自使用者介面的輸入引數,再透過資源庫獲取聚合例項,然後執行相應的命令操作。

 

  如果應用服務功能比較複雜,這通常意味著領域邏輯已經滲透到應用服務中了,此時的領域模型將變成貧血模型。因此,最佳實踐是將應用層做成很薄的一層。當需要建立新的聚合時,應用服務應該使用工廠或聚合的建構函式來例項化物件,然後採用資源庫對其進行持久化。應用服務還可以呼叫領域服務來完成和領域相關的任務操作,此時操作應該是無狀態的。

   在傳統的分層架構中,卻存在著一些與領域相關的挑戰。在分層架構中,領域層或多或少地需要使用基礎設施層。並不是說核心的領域物件會直接參與其中,而是領域層的中的有些介面實現依賴於基礎設施層。比如,資源庫介面的實現需要基礎設施層提供的持久化機制。那麼,如果我們將資源庫介面直接實現在基礎設施層會怎樣?由於基礎設施層位於領域層之下,從基礎設施層向上引用領域層則違反了分層架構的原則。

 

  依賴倒置原則

  有一種方法可以改進分層架構——依賴倒置原則(Dependency Inversion Principle,DIP),它透過改變不同層之間的依賴關係達到改進目的。

  高層模組不應該依賴於低層模組,兩者都應該依賴於抽象。

  抽象不應該依賴於細節,細節應該依賴於抽象。

  根據定義,低層服務(比如基礎設施層)應該依賴於高層元件(比如使用者介面層,應用層或領域層)所提供的介面。在架構中採用依賴倒置原則有很多種表達方式,這裡我們採用下圖的方式:

  

  我們應該將關注點放在領域層上,採用依賴倒置的原則,使領域層和基礎設施層都只依賴於由領域模型所定義的抽象介面。由於應用層是領域層的直接客戶,它將依賴於領域層介面,並且間接地訪問資源庫和由基礎設施層提供的實現。應用層可以採用不同的方式來獲取這些實現,包括依賴注入(Dependency Injection)、服務工廠(Service Factory)和外掛(Plug In)。 

  當我們在分層中採用依賴倒置原則時,我們可能會發現,事實上已經不存在分層的概念了。無論是高層還是低層,它們都只依賴於抽象,好像把整個分層架構給推平了一樣。

 

  六邊形架構(埠與介面卡)

  六邊形架構是一種具有對稱性特徵的架構風格。在這種架構中,不同的客戶透過“平等”的方式與系統互動。需要新的客戶嗎?不是問題,只需要新增一個新的介面卡將客戶輸入轉化成能被系統API所理解的引數就行。同時,系統輸出,比如圖形介面、持久化和訊息等都可以透過不同的方式實現,並且可以互換。這是可能的,因為對於每種特定的輸出,都有一個新建的介面卡負責完成相應的轉化功能。

 

  現在很多聲稱使用分層架構的團隊實際上使用的是六邊形架構。這是因為很多專案都使用了某種形式的依賴注入。並不是說依賴注入天生就是六邊形架構,而是說使用了依賴注入的架構自然地具有了埠與介面卡風格。

  我們通常將客戶與系統互動的地方稱為“前端”;同樣,我們將系統中獲取、儲存持久化資料和傳送輸出資料的地方稱為“後端”。但是六邊形架構提倡用一種新的視角來看待整個系統,如下圖所示。該架構中存在兩個區域,分別是“外部區域”和“內部區域”。在外部區域中,不同的客戶均可以提交輸入;而內部的系統則用於獲取持久化資料,並對程式輸出進行儲存(比如資料庫),或者在中途將輸出轉發到另外的地方(比如訊息)。

   在上圖中,每種型別的客戶都有它自己的介面卡,該介面卡用於將客戶輸入轉化為程式內部API所能理解的輸入。六邊形每條不同的邊代表了不同種型別的埠,埠要麼處理輸入,要麼處理輸出。圖中有3個客戶請求均抵達相同的輸入埠(介面卡A、B和C),另一個客戶請求使用了介面卡D。可能前3個請求使用了HTTP協議(瀏覽器、REST和SOAP等),而後一個請求使用了AMQP協議(比如Rabbit MQ)。

  埠並沒有明確的定義,它是一個非常靈活的概念。無論採用哪種方式對埠進行劃分,當客戶請求到達時,都應該有相應的介面卡對輸入進行轉化,然後埠將呼叫應用程式的某個操作或者嚮應用程式傳送一個事件,控制權由此交給內部區域。

 

  我們不必自己實現埠

  通常來說,我們都不用自己實現埠。我們可以將埠想成是HTTP,而將介面卡想成是請求處理類。或者可以為NServiceBus 或 Rabbit MQ 建立訊息監聽器,在這種情況下,埠是訊息機制,而介面卡則是訊息監聽器,因為訊息監聽器將負責從訊息中提取資料,並將資料轉化為應用層API(領域模型的客戶)所需的引數。

 

  應用程式透過公共API接收客戶請求。應用程式邊界,即內部六邊形,也是用例邊界。換句話說,我們應該根據應用程式的功能需求來建立用例,而不是客戶數量或輸出機制。當應用程式透過API接收到請求時,它將使用領域模型來處理請求,其中便包括對業務邏輯的執行。因此,應用層API透過應用服務的方式展現給外部。再次提醒,這裡的應用服務是領域模型的直接客戶,就像在分層架構中一樣。

  

  對於上圖中右側的埠和介面卡,我們應該如何看待呢?我們可以將資源庫的實現看作是持久化介面卡,該介面卡用於訪問先前儲存的聚合例項,或者儲存新的聚合例項。正如圖中的介面卡E、F和G所展示的,我們可以透過不同的方式實現資源庫,比如關係型資料庫、基於文件的儲存、分散式快取和記憶體儲存等。如果應用程式向外界傳送領域事件資訊,我們將使用介面卡H進行處理。該介面卡處理訊息輸出,而剛才提到的處理AMQP訊息的介面卡則是處理訊息輸入的,因此因該使用不同的埠。

  六邊形架構的一大好處在於,我們可以輕易地開發用於測試的介面卡。整個應用程式和領域模型可以在沒有客戶和儲存機制的條件下進行設計。

  如果你採用的是嚴格分層架構,那麼你應該考慮推平這種架構,然後開始採用埠與介面卡。如果設計得當,內部六邊形——也即應用程式和領域模型——是不會洩漏到外部區域的,這樣也有助於形成一種清晰的應用程式邊界。在外部區域,不同的介面卡可以支援自動化測試和真實的客戶請求,還有儲存、訊息和其他輸出機制。

  六邊形架構的功能如此強大,以以致於它可以用來支援系統中的其他架構。比如,我們可能採用SOA架構、REST或者事件驅動架構;也有可能採用CQRS;或者資料網織或基於網格的分散式快取;還有可能採用Map- Reduce這種分散式並行處理方式。

  以下是一個簡單案例的程式碼Demo:

  假設我們有一個線上商店系統,需要處理訂單、庫存管理、支付等業務。我們可以使用六邊形架構來設計系統。

   核心業務邏輯包括訂單處理、庫存管理和支付邏輯,這些邏輯位於系統的中心部分。我們可以定義介面(埠)來定義訂單處理、庫存管理和支付邏輯的行為。 外部介面卡可以包括與資料庫的互動、與支付閘道器的通訊、與物流系統的整合等。每個外部介面卡透過實現核心業務邏輯定義的介面來與核心業務邏輯進行通訊。

  這種架構使得系統的核心業務邏輯與外部介面卡分離,易於擴充套件和維護。例如,當需要更換支付閘道器時,我們只需實現新的支付介面卡,並透過介面與核心業務邏輯進行互動,而不需要修改核心邏輯。

// 定義訂單類
public class Order
{
    public int Id { get; set; }
    // 其他訂單屬性
}

// 定義訂單倉儲介面
public interface IOrderRepository
{
    void Save(Order order);
}

// 實現訂單倉儲
public class DatabaseOrderRepository : IOrderRepository
{
    public void Save(Order order)
    {
        // 儲存訂單到資料庫
    }
}

// 定義訂單處理器介面
public interface IOrderProcessor
{
    void ProcessOrder(Order order);
}

// 實現訂單處理器
public class OrderProcessor : IOrderProcessor
{
    public void ProcessOrder(Order order)
    {
        // 處理訂單邏輯
    }
}

// 定義訂單服務介面
public interface IOrderService
{
    void PlaceOrder(Order order);
}

// 實現訂單服務
public class OrderService : IOrderService
{
    private readonly IOrderRepository _orderRepository;
    private readonly IOrderProcessor _orderProcessor;

    public OrderService(IOrderRepository orderRepository, IOrderProcessor orderProcessor)
    {
        _orderRepository = orderRepository;
        _orderProcessor = orderProcessor;
    }

    public void PlaceOrder(Order order)
    {
        _orderProcessor.ProcessOrder(order); // 呼叫訂單處理器的邏輯
        _orderRepository.Save(order); // 儲存訂單到資料庫
    }
}

// 示例用法
public class Program
{
    public static void Main()
    {
        IOrderRepository orderRepository = new DatabaseOrderRepository();
        IOrderProcessor orderProcessor = new OrderProcessor();
        IOrderService orderService = new OrderService(orderRepository, orderProcessor);

        // 客戶端程式碼可以直接使用訂單服務來處理訂單
        Order order = new Order();
        orderService.PlaceOrder(order);
    }
}
```

 

  面向服務架構

  面向服務架構(Service- Oriented Architecture,SOA)對於不同人來說具有不同的意思。以下是由 Thomas Erl 所定義的一些SOA原則。服務除了擁有互操作性外,還具有以下8種設計原則:

   下面是SOA的概念描述:
  面向服務架構(Service-Oriented Architecture,SOA)是一種軟體架構模式,它將應用程式的不同功能劃分為獨立的服務,這些服務可以獨立部署、管理和使用。每個服務都是一個具有明確定義介面的獨立單元,可以透過網路進行通訊並與其他服務進行互動。

  面向服務架構的核心思想是將應用程式的功能分解為可重用的服務,這些服務可以被其他應用程式或服務所使用。每個服務都提供特定的功能,並且可以被動態地組合和重用,從而提高了系統的靈活性和可擴充套件性。

  面向服務架構通常使用標準的通訊協議和資料格式,如Web服務(如SOAP和RESTful)來實現服務之間的通訊。這使得不同平臺和技術的應用程式能夠相互協作,從而促進了系統的整合和互操作性。

  透過面向服務架構,企業可以更好地管理和組織其軟體系統,降低系統的複雜性和耦合度,提高系統的可維護性和可擴充套件性。同時,面向服務架構也可以促進業務流程的最佳化和自動化,提高企業的業務靈活性和響應速度。

 

  我們可以將上面的服務設計原則和六邊形架構結合起來,此時服務邊界位於最左側,而領域模型位於中心位置,如下圖所示。消費方可以透過 REST、SOAP和訊息機制獲取服務。請注意,一個六邊形架構系統支援多種型別的服務端點(endpoint),這依賴於DDD是如何應用於SOA的。

 

  業務服務可以由任意數目的技術服務來提供。

  技術服務可以是REST資源、SOAP介面或者訊息型別。業務服務強調業務戰略,即如何對業務和技術進行整合。然而,定義單個業務服務與定義單個子域或限界上下文是不同的。在我們對問題空間和解決方案空間進行評估時,我們會發現,此兩者均包含業務服務。因此,上圖只是單個限界上下文的架構,該限界上下文可以提供一系列的技術服務,包括REST資源、SOAP介面或者訊息型別,而這些技術服務只是整個業務服務的一部分。在SOA的解決方案空間中,我們希望看到更多個限界上下文,而不管這些上下文使用的是六邊形架構還是其他結構。SOA和DDD均沒有必要制定如何對技術服務進行設計和部署,因為存在很多中這樣的方式。

  在使用DDD時,我們所建立的限界上下文應該包含一個完整的,能很好表達通用語言的領域模型。在限界上下文中已經提到,我們並不希望架構對領域模型的大小產生影響。但是,如果一個或多個技術服務端點,比如REST資源、SOAP介面或訊息型別被用於決定限界上下文的大小,那麼上述情況是有可能發生的,結果是將導致許多非常小的限界上下文和領域模型,這樣的模型中很有可能只包含一個實體物件,並且該實體作為某個單一聚合的根物件而存在。

  雖然這種方式在技術上具有優點,但是它卻沒有達到戰略DDD所要求的目標。對於通用語言來說,這種方式會起分化破壞作用。非自然地分化限界上下文並不是SOA精神所在:

  1. 業務價值高於技術策略
  2. 戰略目標高於專案利益

  就像限界上下文中所講到的,技術元件對於劃分模型來說並沒有那麼重要。

 

  REST

  REST作為一種架構風格

  在使用REST之前,我們首先需要理解什麼是架構風格。架構風格之於架構就像設計模式之於設計一樣。它將不同架構實現所共有的東西抽象出來,使得我們在談及到架構時不至於陷入技術細節中。

  REST屬於Web架構的一種架構風格。當然,Web——體現為URI、HTTP和HTML。

  那麼現在,我們為什麼將REST作為構建系統的另一種方式呢?或者更嚴格地說,是構建Web服務的一種方式。原因在於,和其他技術一樣,我們可以透過不同的方式來使用Web協議。有些使用方式符合設計者的初衷,而有些就不見得了。比如,關係型資料庫管理系統便是一例。

  同樣的道理,Web協議即可以按照它原先的設計初衷為人所用——此時便是一種遵循REST架構風格的方式——也可以透過一種不遵循其設計初衷的方式為人所用。因此,在我們沒有獲得由使用“REST”風格的HTTP所帶來的好處時,另一種不同的分散式系統架構可能是合適的。

 

  RESTful HTTP 伺服器的關鍵方面

  那麼,對於採用“RESTful HTTP”的分散式系統來說,它具有哪些關鍵方面呢?我們先看伺服器端。請注意,在我們討論伺服器端時,無論客戶是操作Web瀏覽器的某個人,還是由程式語言開發的客戶端程式,對它們都是同等處理的,沒有什麼區別。

  首先,就像其名字所指出的,資源是關鍵的概念。作為一個系統設計者,你決定哪些有意義的“東西”可以暴露給外界,並且給這些“東西”一個唯一的身份標識。通常來說,每種資源都擁有一個URI,更重要的是,每個URI都需要指向某個資源——即你向外界暴露的“東西”。比如,你可能會做出這樣的決定:每一個客戶、產品、產品列表、搜尋結果和每次對產品目錄的修改都應該分別作為一種資源。資源是具有展現(representation)和狀態的,這些展示的格式可能不同。客戶透過資源的展現與伺服器互動,格式可以為XML、JSON、HTML或二進位制資料。

  另一個關鍵方面是無狀態通訊,此時我們將採用具有自描述功能的訊息。比如,HTTP請求便包含了伺服器所需的所有資訊。當然,伺服器也可以使用其本身的狀態來輔助通訊,但是重要的是:我們不能依靠請求本身來建立一個隱式上下文環境(對話)。無狀態通訊保證了不同請求之間的相互獨立性,這在很大程度上提高了系統的可伸縮性。

  如果你將資源看作物件——這是合理的——那麼你應該問問它們應該擁有什麼樣的介面。這個問題的答案是REST的另一個關鍵面,它將REST與其他架構風格區別開來。你可以呼叫的方法集合是固定的。每一個物件都支援相同的介面。在RESTful HTTP中,物件方法便可以表示為操作資源的HTTP動詞,其中最重要的有GET、PUT、POST和DELETE。

  雖然乍一看這些方法將會轉化成CRUD操作,但是事實卻並非如此。通常,我們所建立的資源並不表示任何持久化實體,而是封裝了某種行為,當我們將HTTP動詞應用在這些資源上時,我們實際上呼叫這些行為。在HTTP規範中,每種HTTP方法都有一個明確的定義。比如,GET方法只能用於“安全”的操作:(1)它可能完成一些客戶並沒有要求的動作行為;(2)它總是讀取資料;(3)它可能被快取起來。

  有些HTTP方法是冪等的,即我們可以安全地對失敗的請求進行重試。這些方法包括GET、PUT和DELETE等。

  最後透過,透過使用超媒體,REST伺服器的客戶端可以沿著某種路徑發現應用程式可能的狀態變化。簡單來說,就是單個資源並不獨立存在。不同資源是相互連結在一起的。這並不意外,畢竟,這就是Web稱為Web的原因。對於伺服器來說,這意味著在返回中包含對其他資源的連結,由此客戶便可以透過這些連結訪問到相應的資源。

 

  RESTful HTTP客戶端的關鍵方面

  RESTful HTTP客戶端可以透過兩種方式在不同資源之間進行轉換,一種是上面提到的超媒體,一種是伺服器端的重定向。伺服器端和客戶端將協同工作以動態地影響客戶端的分散式行為。由於URI包含了對地址進行解引用的所有資訊——包括主機名和埠——客戶端可以根據超媒體連結訪問到不同的應用程式,不同的主機,甚至不同公司的資源。

  在理想情況下,REST客戶端將從單個眾所周知的URI開始訪問,然後透過超媒體連結繼續訪問不同的資源。這和Web瀏覽器顯示HTML頁面是一樣的,HTML中包含了各種連結和表單,瀏覽器根據使用者輸入與不同的Web應用程式互動,此時它並不需要知道Web應用程式的介面或實現。

  然而,瀏覽器並不能算是一個自給自足的客戶端,它需要由人來做出實際決定。但是一個程式客戶端卻可以模擬人來做出決定,其中甚至包含了一些硬編碼邏輯。它可以跟隨不同的連結訪問不同的資源,同時它將根據不同的媒體型別發出不同的請求。

 

  REST和DDD

  RESTful HTTP是具有誘惑力的,但是我們並不建議將領域模型直接暴露給外界,因為這樣會使系統介面變得非常脆弱,原因在於對領域模型的每次改變都會導致對系統介面的改變。要將DDD與RESTful HTTP結合起來使用,我們有兩種方式。

  第一種方法是為系統介面層單獨建立一個限界上下文,再在此上下文中透過適當的策略來訪問實際的核心模型。這是一種經典的方法,它將系統介面看作一個整體,透過資源抽象將系統功能暴露給外界,而不是透過服務或者遠端介面。

  這在核心域和系統介面模型之間完成了解耦,這使得我們可以優先對領域模型進行修改,然後再決定修改哪些應該反映到系統介面模型上。

  下面是一個demo:

  這裡應用服務來隱藏領域模型。應用服務是領域模型和系統介面之間的中介,它將外部請求轉換為領域模型可以理解的操作,並處理領域模型的呼叫和返回結果。這樣可以更好地保護領域模型,避免直接暴露給外界。

// 應用服務
public class OrderAppService
{
    private readonly OrderService _orderService;

    public OrderAppService(OrderService orderService)
    {
        _orderService = orderService;
    }

    public OrderDto GetOrder(int id)
    {
        var order = _orderService.GetOrderById(id);
        // 將領域模型轉換為DTO並返回給外界
        return MapToDto(order);
    }

    public void CreateOrder(OrderDto orderDto)
    {
        var order = MapToDomain(orderDto);
        _orderService.CreateOrder(order);
    }

    // 其他應用服務方法
}

// DTO
public class OrderDto
{
    public int Id { get; set; }
    public string CustomerName { get; set; }
    public List<OrderItemDto> OrderItems { get; set; }
    // 其他屬性
}

public class OrderItemDto
{
    public int Id { get; set; }
    public string ProductName { get; set; }
    public int Quantity { get; set; }
    // 其他屬性
}

// 控制器
public class OrderController : ApiController
{
    private readonly OrderAppService _orderAppService;

    public OrderController(OrderAppService orderAppService)
    {
        _orderAppService = orderAppService;
    }

    [HttpGet]
    public IHttpActionResult GetOrder(int id)
    {
        var order = _orderAppService.GetOrder(id);
        if (order == null)
        {
            return NotFound();
        }
        return Ok(order);
    }

    [HttpPost]
    public IHttpActionResult CreateOrder(OrderDto orderDto)
    {
        _orderAppService.CreateOrder(orderDto);
        return Created(Request.RequestUri + orderDto.Id.ToString(), orderDto);
    }

    // 其他介面方法
}

   上述示例中並沒有為系統介面層建立一個單獨的限界上下文。要建立一個單獨的限界上下文,您可以將介面層視為一個獨立的上下文,與領域模型和服務層進行明確的分離。

  要建立一個單獨的限界上下文,您可以考慮以下步驟:

  1. 定義介面層的上下文:明確介面層的職責和範圍,並將其與領域模型和服務層進行分離。這可以透過建立一個單獨的名稱空間、專案或微服務等方式實現。

  2. 定義介面層的介面:為介面層定義一組明確的介面,用於與領域模型和服務層進行通訊。這些介面應該只暴露與RESTful HTTP資源相關的操作和功能。

  3. 實現介面層的邏輯:在介面層中實現與RESTful HTTP資源相關的操作和功能。這可以包括處理HTTP請求、驗證輸入、呼叫領域模型和服務層的方法等。

  4. 對映和轉換:在介面層中進行適當的對映和轉換,以確保RESTful HTTP資源與領域模型之間的資料一致性。這可能涉及使用DTO(資料傳輸物件)或其他對映技術。

  5. 管理依賴關係:確保介面層與領域模型和服務層之間的依賴關係得到適當管理。這可以透過依賴注入、介面隔離原則等技術實現。

 

  另一種方法用於需要使用標準媒體型別的時候。如果某種媒體並用於支援單個系統介面,而是用於一組相似的客戶端-伺服器互動場景,此時我們可以建立一個領域模型來處理每一種媒體型別。

  該方法的核心思想是為每種媒體型別定義一個領域模型,該模型負責處理與該媒體型別相關的操作和邏輯。這個領域模型可以看作是一個專門的上下文,用於處理特定媒體型別的請求和響應。
  以下是該方法的一些關鍵方面:
   1. 媒體型別定義:首先,需要定義所使用的標準媒體型別。這些媒體型別通常是廣泛接受的、標準化的格式,例如JSON、XML、CSV等。對於每種媒體型別,我們需要明確其結構和語義。
  2. 領域模型建立:針對每種媒體型別,建立一個領域模型。這個領域模型應該包含與該媒體型別相關的操作和邏輯。例如,對於JSON媒體型別,可以建立一個處理JSON請求和響應的領域模型。這個領域模型可以包含解析JSON資料、驗證資料結構、執行相關業務邏輯等功能。
  3. 介面抽象:為了與RESTful HTTP資源進行互動,領域模型需要提供適當的介面。這些介面應該抽象出與媒體型別相關的操作,例如解析請求資料、生成響應資料等。這樣,RESTful HTTP資源可以透過呼叫這些介面來處理與特定媒體型別相關的請求。
  4. 資料對映和轉換:當接收到請求時,領域模型需要將請求資料對映到領域模型的資料結構中進行處理。同樣地,當生成響應時,領域模型需要將領域模型的資料結構轉換為特定媒體型別的格式。這可能涉及使用DTO(資料傳輸物件)或其他對映技術來確保資料的一致性。
  5. 客戶端-伺服器互動:當客戶端傳送請求時,伺服器端的RESTful HTTP資源會根據請求的媒體型別選擇合適的領域模型進行處理。領域模型執行相關操作並生成響應資料後,RESTful HTTP資源將響應資料傳送回客戶端。這樣,客戶端和伺服器之間可以使用標準媒體型別進行互動,而無需關心底層的領域模型實現細節。 透過將每種媒體型別與特定的領域模型相關聯,我們可以更好地組織和管理與不同媒體型別相關的邏輯和操作。這種方法提高了程式碼的可維護性和可擴充套件性,並允許我們更靈活地處理不同型別的客戶端-伺服器互動場景。
  
using System;
using System.Collections.Generic;
using System.Net;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;

namespace MyApplication.Domain
{
    public class CoreModel
    {
        // 核心模型的屬性和行為
    }
}

namespace MyApplication.MediaTypes
{
    public class JsonMediaType
    {
        public class RequestData
        {
            // JSON請求資料的結構定義
        }

        public class ResponseData
        {
            // JSON響應資料的結構定義
        }

        public class JsonMediaTypeHandler
        {
            public CoreModel ProcessRequest(RequestData requestData)
            {
                // 處理JSON請求資料的邏輯...
                return new CoreModel();
            }

            public ResponseData ProcessResponse(CoreModel coreModel)
            {
                // 處理JSON響應資料的邏輯...
                return new ResponseData();
            }
        }
    }
}

namespace MyApplication.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class MyController : ControllerBase
    {
        private readonly JsonMediaType.JsonMediaTypeHandler _jsonMediaTypeHandler;

        public MyController(JsonMediaType.JsonMediaTypeHandler jsonMediaTypeHandler)
        {
            _jsonMediaTypeHandler = jsonMediaTypeHandler;
        }

        [HttpPost]
        [Consumes("application/json")]
        [Produces("application/json")]
        public IActionResult Post([FromBody] JsonMediaType.RequestData requestData)
        {
            var coreModel = _jsonMediaTypeHandler.ProcessRequest(requestData);
            var responseData = _jsonMediaTypeHandler.ProcessResponse(coreModel);
            return Ok(responseData);
        }
    }
}
  在上面的示例中,我們建立了一個名為`JsonMediaType`的類,用於處理JSON媒體型別。該類包含`RequestData`和`ResponseData`兩個內部類,分別定義了JSON請求資料和響應資料的結構。`JsonMediaTypeHandler`類負責處理JSON請求和生成JSON響應的邏輯。在`MyController`中,我們定義了一個POST方法,該方法使用JSON媒體型別進行請求和響應。在方法內部,我們透過呼叫`JsonMediaTypeHandler`的`ProcessRequest`和`ProcessResponse`方法來處理請求和生成響應。請注意,上述示例只是一個簡單的演示,用於說明如何建立一個處理特定媒體型別的領域模型。實際的應用程式可能需要更多的邏輯、資料驗證和錯誤處理機制。同時,具體的媒體型別和處理邏輯取決於您的應用程式需求和所使用的技術棧。您可以根據需要進行修改和擴充套件。

 

  為什麼是REST?

  符合REST原則的系統將具有更好的松耦合性。通常來講,新增新資源並在已有資源中建立新資源的連結是非常簡單的。要新增新的格式同樣如此。另外,基於REST的系統也是非常容易理解的,因為此時系統被分為很多較小的資源塊,每一個資源塊都可以獨立地測試和除錯,並且每一個資源塊都表示了一個可重用的入口點。HTTP設計本身以及URI成熟的重寫與快取機制使得RESTful HTTP 成為一種不錯的架構選擇,該架構具有很好的松耦合性和可伸縮性。

 

  命令和查詢職責分離——CQRS

  從資源庫中查詢所有需要顯示的資料是困難的,特別是在需要顯示來自不同聚合型別與例項的資料時。領域越複雜,這種困難程度越大。

  因此,我們並不期望單單使用資源庫來解決這個問題。因為我們需要從不同的資源庫獲取聚合例項,然後再將這些例項資料組裝成一個資料傳輸物件(Data Transfer Object,DTO)。或者,我們可以在同一個查詢中使用特殊的查詢方法將不同資源庫的資料組合在一起。如果這些辦法都不合適,我們可能需要在使用者體驗上做出妥協,使介面顯示生硬地服從於模型的聚合邊界。然而,很多人都認為,這種機械式的使用者介面從長遠看來是不夠的。

  那麼,有沒有一種完全不同的方法可以將領域資料對映到介面顯示中呢?答案是CQRS(Command- Query Responsibility Segregation)。CQRS 是將緊縮物件設計原則和命令-查詢分離(CQS)應用在架構模式中的結果。

 

  在物件層面,這意味著:

  1. 如果一個方法修改了物件的狀態,該方法便是一個命令(Command),它不應該返回資料。在Java和C#中,這樣的方法應該宣告為void。
  2. 如果一個方法返回了資料,該方法便是一個查詢(Query),此時它不應該透過直接的或間接的手段修改物件的狀態。在Java和C#中,這樣的方法應該以其返回的資料型別進行宣告。

  這樣的指導原則是非常直接明瞭的,同時具有實踐和理論基礎作為支撐。但是,在DDD的架構模式中,我們為什麼應該使用CQRS呢,又如何使用呢?

  在領域模型中,我們通常會看到同時包含有命令和查詢的聚合。同時,我們也經常在資源庫中看到不同的查詢方法,這些方法對物件屬性進行過濾。但是在CQRS中,我們將忽略這些看似常態的情形,我們將透過不同的方式來查詢用於顯示的資料。

  現在,對於同一個模型,考慮將那些純粹的查詢功能從命令功能中分離出來。聚合將不再有查詢方法,而只有命令方法。資源庫也將變成只有add() 或 save() 方法(分別支援建立和更新操作),同時只有一個查詢方法,比如fromId() 。這個唯一的查詢方法將聚合的身份標識作為引數,然後返回該聚合例項。資源庫不能使用其他方法來查詢聚合,比如對屬性進行過濾等。在將所有查詢方法移除之後,我們將此時的模型稱為命令模型(Command Model)。但是我們仍然需要向使用者顯示資料,為此我們將建立第二個模型,該模型專門用於最佳化查詢,我們稱之為查詢模型(Query Model)。

  

  這不是增加了複雜性嗎?

  你可能會認為:這種架構風格需要大量的額外工作,我們解決了一些問題,但同時又帶來了另外的問題,並且我們需要編寫更多的程式碼。

  但無論如何,不要急於否定這種架構。在某些情況下,新增的複雜性是合理的。請記住,CQRS旨在解決資料顯示覆雜性問題,而不是什麼絢麗的新風格。

 

  因此,領域模型將被一分為二,命令模型和查詢模型分開進行儲存。最終,我們得到的元件系統如下圖:

  

   CQRS的各個方面

  1、客戶端和查詢處理器

  客戶端(圖最左側)可以是Web瀏覽器,也可以是定製開發的桌面應用程式。它們將使用執行在伺服器端的一組查詢處理器。圖中並沒有顯示伺服器的架構層次。不管使用什麼樣的架構層,查詢處理器都表示一個只知道如何向資料庫執行基本查詢的簡單元件。

  這裡並不存在多麼複雜的分層,查詢元件最多是對資料儲存進行查詢,然後可能將查詢結果以某種格式進行序列化。如果客戶端執行的是Java或者C#,那麼它可以直接對資料庫進行查詢。然而,這可能需要大量的資料庫連線,此時使用資料庫連線池則是最佳辦法。

  如果客戶端可以處理資料庫結果集(比如JDBC),此時我們可能不需要對查詢結果進行序列化,但依然建議使用。這裡存在兩種不同的觀點。一種觀點是客戶直接處理結果集,或者是一些非常基本的序列化資料,比如XML和JSON。另一種觀點認為應該是將返回資料轉換成DTO讓客戶端處理。這可能只是一個偏好問題,但是任何時候我們引入DTO和DTO組裝器(DTO Assembler),系統的複雜性都會隨之增加。

  2、查詢模型(讀模型)

  查詢模型是一種非規範化資料模型,它並不反映領域行為,只是用於資料顯示(也有可能是生成資料包告)。如果資料模型是SQL資料庫,那麼每張資料庫表便是一種資料顯示檢視,它可以包含很多列,甚至是所顯示資料的一個超集。表檢視可以透過多張表進行建立,此時每張表代表整個顯示資料的一個邏輯子集。

  3、客戶端驅動命令處理

  使用者介面客戶端向伺服器傳送命令(或者間接地執行應用服務)以在聚合上執行相應地行為操作,此時的聚合即屬於命令模型。提交的命令包含了行為操作的名稱和所需引數。命令資料包是一個序列化的方法呼叫。由於命令模型擁有設計良好的契約和行為,將命令匹配到相應的契約是很直接的事情。

  要達到這樣的目的,使用者介面客戶端必須收集到足夠的資料以完成命令呼叫。這表明我們需要慎重考慮使用者體驗設計,因為使用者體驗設計需要引導使用者如何正確地提交命令,此時最好的方法是使用一種誘導式的,任務驅動式的使用者介面設計,這種方法會把不必要的資料過濾掉,然後執行準確的命令呼叫。因此,設計出一種演繹式的,能夠生成顯式命令的使用者介面式可能的。

  4、命令處理器

  客戶端提交的命令將被命令處理器所接收。命令處理器可以有不同的型別風格,這裡我們將分別討論它們的優缺點。

  我們可以使用分類風格,此時多個命令處理器位於同一個應用服務中。在這種風格中,我們根據命令類別來實現應用服務。每一個應用服務都擁有多個方法,每個方法處理某種型別的命令。該風格最大的優點是簡單。分類風格命令處理器易於理解,建立簡單,維護方便。

  在分類風格的命令處理器中,通常有一個基類或介面,定義命令處理器的基本行為。然後,每個具體的命令處理器都繼承自這個基類或實現這個介面,並處理特定型別的命令。 下面是一個簡單的C#示例,演示了分類風格的命令處理器:
using System;
using System.Collections.Generic;

// 定義命令介面
public interface ICommand
{
}

// 定義幾個具體的命令
public class CreateUserCommand : ICommand
{
    public string Username { get; set; }
    public string Password { get; set; }
}

public class UpdateUserCommand : ICommand
{
    public string Username { get; set; }
    public string NewPassword { get; set; }
}

// 定義命令處理器介面
public interface ICommandHandler<T> where T : ICommand
{
    void Handle(T command);
}

// 定義具體的命令處理器
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand>
{
    public void Handle(CreateUserCommand command)
    {
        Console.WriteLine($"Creating user: {command.Username}");
        // 這裡可以新增建立使用者的實際邏輯
    }
}

public class UpdateUserCommandHandler : ICommandHandler<UpdateUserCommand>
{
    public void Handle(UpdateUserCommand command)
    {
        Console.WriteLine($"Updating user: {command.Username}");
        // 這裡可以新增更新使用者的實際邏輯
    }
}

// 定義命令匯流排,用於分發命令給相應的命令處理器
public class CommandBus
{
    private readonly Dictionary<Type, object> _handlers = new Dictionary<Type, object>();

    public void RegisterHandler<T>(ICommandHandler<T> handler) where T : ICommand
    {
        _handlers[typeof(T)] = handler;
    }

    public void Send<T>(T command) where T : ICommand
    {
        if (_handlers.ContainsKey(typeof(T)))
        {
            var handler = _handlers[typeof(T)];
            ((ICommandHandler<T>)handler).Handle(command);
        }
        else
        {
            throw new InvalidOperationException($"No handler registered for command of type {typeof(T).Name}");
        }
    }
}

  使用示例:

ar bus = new CommandBus();
bus.RegisterHandler(new CreateUserCommandHandler()); // 註冊命令處理器到命令匯流排
bus.RegisterHandler(new UpdateUserCommandHandler()); // 註冊命令處理器到命令匯流排
var createCommand = new CreateUserCommand { Username = "Alice", Password = "password123" };
bus.Send(createCommand); // 透過命令匯流排傳送命令
var updateCommand = new UpdateUserCommand { Username = "Alice", NewPassword = "newpassword123" };
bus.Send(updateCommand); // 透過命令匯流排傳送命令

  在這個示例中,我們定義了一個`ICommand`介面,以及兩個實現該介面的具體命令:`CreateUserCommand`和`UpdateUserCommand`。然後,我們定義了一個`ICommandHandler<T>`介面,以及兩個實現該介面的具體命令處理器:`CreateUserCommandHandler`和`UpdateUserCommandHandler`。每個命令處理器都負責處理特定型別的命令。最後,我們定義了一個`CommandBus`類,它負責維護一個命令處理器字典,並根據命令的型別分發命令給相應的處理器。

 

  我們也可以使用專屬風格,此時每種命令都對應於某個單獨的類,並且該類只有一個方法。這種風格的優點是:每個處理器的職責是單一的,命令處理器之間相互獨立,我們可以透過增加處理器類來處理更多的命令。

  專屬風格可能發展成為訊息風格,其中每個命令將透過非同步的訊息傳送到某個命令處理器。訊息風格使得每個命令處理器可以處理某種特殊的訊息型別,同時我們可以透過單獨增加單種處理器的數量來緩解訊息負載。但是,訊息風格並不能作為預設的命令處理方式,因為它的設計比其他兩種都複雜。因此,我們應該首先考慮使用前兩種同步方式的命令處理器,只有在有伸縮性需要的情況下才採用非同步方式。

 

  下面是一個簡單的C#示例,演示了專屬風格的命令處理器。在專屬風格中,命令處理器通常會包含與該命令相關的所有業務邏輯和驗證。這意味著每個命令處理器都是相對獨立和自包含的,可以更容易地進行測試和維護。此外,由於命令處理器是針對特定領域的,因此可以更容易地新增新的業務規則和邏輯,而無需對整個應用程式進行大規模的修改。
  假設我們有一個電子商務應用程式,其中有一個處理訂單的命令。在專屬風格的命令處理器中,我們可以建立一個名為`OrderCommandHandler`的類,該類專門負責處理與訂單相關的命令。
using System;

// 定義訂單命令
public class CreateOrderCommand
{
    public string CustomerId { get; set; }
    public string ProductId { get; set; }
    public int Quantity { get; set; }
}

// 定義訂單領域模型
public class Order
{
    public string Id { get; set; }
    public string CustomerId { get; set; }
    public string ProductId { get; set; }
    public int Quantity { get; set; }
    public decimal TotalPrice { get; set; }
}

// 定義訂單命令處理器
public class OrderCommandHandler
{
    private readonly IOrderRepository _orderRepository; // 注入訂單倉庫介面
    private readonly IProductRepository _productRepository; // 注入產品倉庫介面

    public OrderCommandHandler(IOrderRepository orderRepository, IProductRepository productRepository)
    {
        _orderRepository = orderRepository;
        _productRepository = productRepository;
    }

    public void Handle(CreateOrderCommand command)
    {
        // 驗證命令資料,例如檢查顧客和產品是否存在,數量是否有效等
        if (string.IsNullOrEmpty(command.CustomerId))
            throw new ArgumentException("Customer ID is required.");
        if (string.IsNullOrEmpty(command.ProductId))
            throw new ArgumentException("Product ID is required.");
        if (command.Quantity <= 0)
            throw new ArgumentException("Quantity must be greater than zero.");

        // 從產品倉庫獲取產品價格資訊
        var product = _productRepository.GetById(command.ProductId);
        if (product == null)
            throw new InvalidOperationException($"Product with ID '{command.ProductId}' not found.");
        var unitPrice = product.Price;

        // 計算訂單總價格
        var totalPrice = unitPrice * command.Quantity;

        // 建立訂單物件並設定相關屬性
        var order = new Order
        {
            Id = Guid.NewGuid().ToString(), // 生成唯一訂單ID
            CustomerId = command.CustomerId,
            ProductId = command.ProductId,
            Quantity = command.Quantity,
            TotalPrice = totalPrice
        };

        // 將訂單儲存到倉庫中
        _orderRepository.Save(order);
    }
}

  使用示例:

var command = new CreateOrderCommand { CustomerId = "123", ProductId = "456", Quantity = 2 };
var orderCommandHandler = new OrderCommandHandler(orderRepository, productRepository);
orderCommandHandler.Handle(command);
Console.WriteLine("Order created successfully.");

  在這個示例中,我們建立了一個名為`CreateOrderCommand`的命令,該命令包含建立訂單所需的所有資訊。然後,我們建立了一個名為`OrderCommandHandler`的類,該類負責處理`CreateOrderCommand`命令。在`Handle`方法中,我們進行了命令資料驗證、產品價格獲取、訂單總價計算和訂單儲存等操作。這個命令處理器是專門為處理訂單建立命令而設計的,並緊密整合了相關的領域模型(如`Order`和`Product`)。這確保了業務規則的準確實施,並提供了更好的程式碼組織和可讀性。

  我們使用分類風格實現一下這個電子商務案例:

// 定義命令介面
public interface ICommand
{
}

// 定義建立訂單命令類
public class CreateOrderCommand : ICommand
{
    public string CustomerId { get; set; }
    public string ProductId { get; set; }
    public int Quantity { get; set; }
}
```

接下來,我們定義一個訂單領域模型類,並實現一個用於處理建立訂單命令的方法:

```csharp
// 定義訂單領域模型類
public class Order
{
    public string Id { get; set; }
    public string CustomerId { get; set; }
    public string ProductId { get; set; }
    public int Quantity { get; set; }
    public decimal TotalPrice { get; set; }

    // 實現處理建立訂單命令的方法
    public void HandleCreateOrder(CreateOrderCommand command)
    {
        // 在這裡實現訂單建立的業務邏輯和驗證
        // ...
    }
}
```

然後,我們定義一個命令處理器類,該類負責將命令分派給相應的領域模型進行處理:

```csharp
// 定義命令處理器類
public class CommandHandler
{
    private readonly IDictionary<Type, Action<ICommand>> _handlers;

    public CommandHandler()
    {
        _handlers = new Dictionary<Type, Action<ICommand>>();
    }

    // 註冊領域模型的處理程式
    public void RegisterHandler<T>(Action<T> handler) where T : ICommand
    {
        _handlers[typeof(T)] = cmd => handler((T)cmd);
    }

    // 處理命令
    public void Handle(ICommand command)
    {
        if (_handlers.ContainsKey(command.GetType()))
        {
            _handlers[command.GetType()].Invoke(command);
        }
        else
        {
            throw new InvalidOperationException($"No handler registered for command of type {command.GetType().Name}");
        }
    }
}

  使用示例:

var order = new Order();
var command = new CreateOrderCommand { CustomerId = "123", ProductId = "456", Quantity = 2 };
order.HandleCreateOrder(command); // 直接在領域模型上處理命令
var commandHandler = new CommandHandler();
commandHandler.RegisterHandler<CreateOrderCommand>(cmd => order.HandleCreateOrder(cmd)); // 註冊領域模型的處理程式
commandHandler.Handle(command); // 透過命令處理器處理命令
Console.WriteLine("Order created successfully.");

  在這個示例中,我們建立了一個名為`CommandHandler`的類,該類負責將命令分派給相應的領域模型進行處理。我們透過`RegisterHandler`方法將領域模型(即`Order`類)的處理程式註冊到命令處理器中。當接收到一個命令時,命令處理器使用反射來確定要呼叫的處理程式,並將命令分派給相應的處理程式進行處理。在領域模型類(即`Order`類)中,我們實現了一個名為`HandleCreateOrder`的方法,該方法包含訂單建立的業務邏輯和驗證。當接收到一個`CreateOrderCommand`命令時,命令處理器將呼叫該方法來處理該命令。請注意,這種方法將業務邏輯和驗證直接實現在領域模型中,而不是在單獨的命令處理器中。這與通用風格和專屬風格的實現方式有所不同。這種分類風格的實現方式強調了領域模型的責任和自治性,使其能夠更好地封裝和管理自己的業務邏輯和狀態。然而,它可能需要更多的程式碼和複雜性來支援命令的分派和處理機制。

 

  分類風格和專屬風格兩種風格的本質區別在於命令處理器與領域模型之間的關係以及業務邏輯的實施方式。

   在通用風格中,命令處理器被設計為處理多種不同型別的命令,並且通常使用一種通用的方式來處理業務邏輯。這種風格強調命令處理器的通用性和靈活性,以便能夠適應不同的領域模型和業務需求。通用風格的命令處理器通常會包含一些公共的邏輯和驗證,以確保命令的有效性和一致性。然而,由於通用風格的命令處理器需要處理多種不同型別的命令,因此可能會在處理某些特定領域的命令時缺乏針對性和精確性。

  相比之下,在專屬風格中,每個命令處理器都是為特定領域模型和業務邏輯量身定製的。這種風格強調命令處理器與領域模型之間的緊密關係,以確保業務規則的準確實施。專屬風格的命令處理器通常會包含與該命令相關的所有業務邏輯和驗證,以確保在處理該領域的命令時具有高度的針對性和精確性。由於專屬風格的命令處理器是針對特定領域的,因此可以更容易地新增新的業務規則和邏輯,而無需對整個應用程式進行大規模的修改。

  因此,兩種風格的本質區別在於命令處理器的設計目標和處理業務邏輯的方式。通用風格強調通用性和靈活性,而專屬風格強調針對性和精確性。選擇哪種風格取決於應用程式的具體需求和架構師的偏好。

 

  無論採用哪種風格的命令處理器,我們都應該在不同的處理器間進行解耦,不能使一個處理器依賴於另一個處理器。這樣,對一種處理器的重新部署不會影響到其他處理器。

 

  5、命令模型(寫模型)執行業務行為

  命令模型上每個方法在執行完成時都將釋出領域事件

  6、事件訂閱更新查詢模型

  一個特殊的事件訂閱器用於接收命令模型所發出的所有領域事件。有了領域事件,訂閱器會根據命令模型的更改來更新查詢模型。這意味著,每種領域事件都應該包含足夠的資料以正確地更新查詢模型。

  對查詢模型的更新應該是同步還是非同步?這取決於系統的負荷,也有可能取決於查詢模型資料庫的儲存位置。資料的一致性約束和效能需求等因素對此也有很大的影響作用。

  7、處理具有最終一致性的查詢模型

  如果查詢模型需要滿足最終一致性——即在命令模型更新之後,查詢模型會得到相應的非同步更新——那麼使用者介面可能有些額外的問題需要處理。比如,當上一個使用者提交命令之後,下一個使用者是否能夠及時地檢視到更新後的查詢模型資料?這可能與系統負荷等因數有關。但是,我們最好還是假定:在使用者介面所檢視到的資料永遠都不能與命令模型保持一致。因此,我們需要為最壞的情況考慮。

  一種方式是讓使用者介面臨時性的顯示先前提交給命令模型的引數,這使得使用者可以及時地看到將來對查詢模型的改變。

  但是,對於某些使用者介面,以上方式可能並不現實。而即便是現實的,同樣有可能發生在使用者介面中顯示陳舊資料的情況,比如在一個使用者進行操作的剎那,另一個使用者卻正試圖檢視資料。那麼,我們應該如何應對?

  另一種方法是顯式地在使用者介面上顯示出當前查詢模型的日期和時間。要達到這樣的目的,查詢模型的每一條記錄都需要維護最後更新時的日期和時間。這是很容易的,通常可以藉助於資料庫觸發器。有了最近更新的日期和時間,使用者介面便可以通知使用者資料的新舊程度。如果使用者認為資料過於陳舊,他們可以發出更新資料的請求。

  然而,有時命令模型和查詢模型之間的不同步並不是什麼大的問題。我們也可以透過其他方式予以客服,比如Comet(即Ajax Push);或者透過另一種靜默更新的方式,比如觀察者或者分散式快取/網路的事件訂閱。

 

  事件驅動架構

  事件驅動架構一種用於處理事件的生成、發現和處理等任務的軟體架構。

  

   一個系統的輸出埠所發出的領域事件將被髮送到另一個系統的輸入埠,此後輸入埠的事件訂閱方將對事件進行處理。對於不同的限界上下文來說,不同的領域事件具有不同含義,也有可能沒有任何含義。在一個限界上下文處理某個事件時,應用程式API將採用該事件中的屬性值來執行相應的操作。應用程式API所執行的命令操作將反映到命令模型中。

  有可能出現這樣一種情況:在一個多工處理過程中,某種領域事件只能表示該過程的一部分。只有在所有的參與事件都得到處理之後,我們才能認為這個多工處理過程完成了。但是,這個過程是如何開始的?它是如何分佈在整個企業範圍之內的?我們如何跟蹤處理進度?這些問題將在“長時處理過程”部分回答。

  下面時一些基礎知識,基於訊息的系統呈現出一種管道和過濾器風格。

  管道和過濾器

 

  長時處理過程(也叫Saga)

  長時處理過程(Long- Running Process)是一種事件驅動的、分散式的並行處理模式。

  

  設計長時處理過程的不同方法:

  • 將處理過程設計成一個組合任務,使用一個執行元件對任務進行跟蹤,並對各個步驟和任務完成情況進行持久化。
  • 將處理過程設計成一組聚合,這些聚合在一系列的活動中相互協作。一個或多個聚合例項充當執行元件並維護整個過程的狀態。
  • 設計一個無狀態的處理過程,其中每一個訊息處理元件都將對所接收到的訊息進行擴充——即向其中加入額外的資料資訊——然後再將訊息傳送到下一個處理元件。在這種方法中,整個處理過程的狀態包含在每條訊息中。

 

  執行器和跟蹤器?

  有人認為執行器和跟蹤器這兩種概念合併成一個物件——聚合——是最簡單的方法。此時,我們在領域模型中實現這樣一個聚合,再透過該聚合來跟蹤長時處理過程的狀態。這是一種解放性的技術,我們不需要開發一個單獨的跟蹤器來作為狀態機,而事實上這也是實現基本長時處理過程的最好方法。

  在六邊形架構中,埠—介面卡的訊息處理元件將簡單地將任務分發給應用服務(或命令處理器),之後應用服務載入目標聚合,再呼叫聚合上的方法。同樣,聚合也會發出領域事件,該事件表明聚合已經完成了。

  這種方式屬於上面提到的第二種方法。然而,將執行器和跟蹤器分開討論時一種更有效的方法。

 

  在實際的領域中,一個長時處理過程的執行器將建立一個新的類似聚合的狀態物件來跟蹤事件的完成情況。該狀態物件在處理過程開始時建立,它將與所有的領域事件共享一個唯一標識。同時,將處理過程開始時的事件戳儲存在該狀態物件中也是有好處的。

 

  當並行處理的每個執行流執行完畢時,執行器都會接收到相應的完成事件。然後,執行器根據事件中的過程標識獲取到與該過程相對應的狀態跟蹤物件例項,再在這個物件例項中修改該執行流所對應的屬性值。

  長時處理過程的狀態例項通常有一個名為isCompleted() 的方法。每當某個執行流執行完成,其對應的狀態屬性也將隨之更新,隨後執行器將呼叫isCompleted() 方法。該方法檢查所有的並行執行流是否全部執行完畢。當isCompleted()返回true時,執行器將根據業務需要釋出最終的領域事件。如果該長時處理過程是更大的並行處理過程的一個分支,那麼向外釋出該事件便是非常有必要的了。

  有些訊息機制可能並不能保證訊息的單次投遞。對於一個領域事件有可能被多次投遞的情況,我們可以透過長時處理過程的狀態例項來消除重複。那麼,這是否需要訊息機制提供額外的特殊功能呢?讓我們看看在沒有這些特殊功能的時候應該被如何處理。

  當一個完成事件到達時,執行器將檢查該事件中相應的狀態屬性,該狀態屬性表示該事件是否已經存在。如果狀態已經被設值,那麼該事件便是一個重複事件,執行器將忽略事件,但是還是會對該事件做出應答。另一種方式將狀態物件設計成冪等的。這樣,如果執行器接收到了重複訊息,它將同等對待,即執行器依然會使用該訊息來更新處理過程的狀態,但是此時的更新不會產生任何效果。在以上兩種方法中,雖然只有第二種方法將狀態物件本身設計成冪等的,但是在結果上他們都能達到訊息傳輸的冪等性。關於事件消重的更多資訊,在後面領域事件部分會講到。

   

  對於跟蹤有些長時處理過程來說,我們需要考慮時間敏感性。在過程處理超時,我們既可以採用被動的,也可以採用主動。回憶一下,狀態跟蹤器可以包含處理過程開始時的時間戳。如果再向跟蹤器新增一個最大允許處理時間,那麼執行器便可以管理那些對時間敏感的長時處理過程了。

  被動超時檢查由執行器在每次並行執行流的完成事件達到時執行。執行器根據狀態跟蹤器來決定是否出現超時,比如呼叫名為hasTimedOut() 的方法。如果執行流的處理時間超過了最大允許處理時間,狀態跟蹤器將被標記為“遺棄”狀態。此時,執行器甚至可以釋出一個表明處理失敗的領域事件。被動超時檢查的一個缺點是,如果由於某些原因導致執行器始終接收不到完成領域事件,那麼即便處理過程已經超時,執行器還是會認為處理過程正處於活躍狀態。如果還有更大的併發過程依賴於該處理過程,那麼這將是不可接受的。

  主動超時檢查可以透過一個外部定時器來進行管理。在處理過程開始時,定時器便被設以最大允許處理時間。定時時間到,定時監聽器將訪問狀態跟蹤器的狀態。如果此時的狀態顯示處理還未完畢,那麼處理狀態將被標記為“遺棄”狀態。主動超時檢查的一個缺點是,它需要更多的系統資源,這可能加重系統的執行負擔。同時,定時器和完成事件之間的竟態條件有可能會造成系統失敗。

  

  長時處理過程通常和分散式並行處理聯絡在一起,但是它與分散式事務沒有什麼關係。長時處理過程需要的是最終一致性。我們應該慎重地設計長時處理過程,在基礎設施或處理過程本身失敗的時候,我們應該能夠採取適當的修復措施。只有在執行器接收到整個處理過程成功的通知時,我們才能認為處理過程的各個參與方達到了最終一致性。誠然,對於有些長時處理過程來說,整個處理過程的成功並不需要所有的並行執行流都成功。還有可能出現的情況是,一個處理過程在成功完成之前可能會延遲好幾天的時間。但是,如果一個處理過程被擱淺,那麼所有的參與系統都將處於一種不一致的狀態,此時做出一些補償是必要的。但是,補償可能增加處理過程的複雜性。還有可能是,業務需求是允許失敗情況發生的,此時採用工作流方案可能更加合適。

  值得注意的是,長時處理過程的執行器可以釋出一個或者多個事件來觸發並行處理流程。同時,事件的訂閱方也不見得只能是兩個,而是可以有多個。換句話說,在一個長時處理過程中,可能存在許多彼此分離的業務處理過程同時執行。

  當與遺留系統的整合存在很大的時間延遲時,採用長時處理過程將非常有用。當然,即便時間延遲和遺留系統並不是我們的主要關注點,我們依然能從長時處理過程中得到好處,即由分散式和並行處理帶來的優雅性,這樣也有助於我們開發高可伸縮性、高可用性的業務系統。

  有些訊息機制中已經構建了對長時處理過程的支援,[NServiceBus]和[MassTransit]。

 

相關文章