應用程式的分層設計 (3) - DDD、六角、與洋蔥架構

網友 itplayer 在上一篇文章的留言中提到了洋蔥架構,我後來爬了一些文,發現其理念蠻有意思,而且跟自己上一篇文章裡面提到的作法有那麼一絲絲雷同處--這顯然是往自己臉上貼金。於是,將爬文所見整理一下,包括 Domain-Drive Design 多層式架構、六角架構、洋蔥架構等,作為此入門系列的一個概念總整理。上一版的範例程式也稍有修改,使其層次與模組的命名比較接近洋蔥架構。謬誤之處,還請各方不吝指正。

傳統的多層架構

傳統的多層(三層)架構類似堆疊,最底層是資料層,其上為領域層(和服務層、應用程式層等),最頂端是展現層,然後還有各層都會用到的基礎建設(infrastructure),如下圖所示。


以變動幅度來看,這種架構有點像蓋房子--底層地基的變動通常比較少,然後是房子本身,然後頂部加蓋和和房子外觀裝飾的部分允許最多變動的彈性。

這樣的比喻有些不符實情。在真實世界中,有誰會在房子蓋好之後還改地基的呢?很少吧。可是在軟體開發的世界裡卻很常見。自從有這了這點覺悟,我已經不再拿蓋房子來比喻軟體開發。

離題了。回到主題,這種堆疊架構有什麼缺點呢?

The birthday greetings kata 文章中提到,傳統的三層(layers)架構有下列缺點:
  1. It assumes that an application communicates with only two external systems, the user (through the user interface), and the database. Real applications often have more external systems to deal with than that; for instance, input could come from a messaging queue; data could come from more than one database and the file system. Other systems could be involved, such as a credit card payment service.
    譯:傳統分層架構假設應用程式只會跟兩種外部系統溝通,即使用者(透過 UI)和資料庫。在真實世界裡,應用程式通常要應付更多外部系統,比如說,輸入可能來自訊息佇列、資料可能來自一個以上的資料庫和檔案系統。此外,還可能與其他系統銜接,例如信用卡付款服務。
  2. It links domain code to the persistence layer in a way that makes external APIs pollute domain logic. References to JDBC, SQL or object-relational mapping frameworks APIs creep into the domain logic.
    譯:其領域層連結至儲存層的方式會導致外部 API 汙染領域邏輯。對 JDBC、SQL、或 ORM 框架等 API 的參考會悄悄溜進領域邏輯。
  3. It makes it difficult to test domain logic without involving the database; that is, it makes it difficult to write unit tests for the domain logic, which is where unit tests should be more useful.
    譯:一旦少了資料庫,就很難測試領域邏輯。也就是說,領域邏輯的單元測試會很不好寫,然而這個部分卻是單元測試應該能發揮其功效的地方。

大致了解傳統三層式架構的缺點之後,接著來看幾種改良的架構。

Domain-Driven Design 多層架構

在 Domain-Driven Design N-Layerd .NET 4.0 Architecture Guide 裡面所描繪的 DDD 多層架構,應用程式服務層和資料存取層都會用到領域層(商業邏輯層),如下圖所示:

DDD N-layer 架構

相較於傳統三層式架構,在領域層這邊有個微妙而關鍵的變化:領域層已經不依賴資料存取層,而是反過來由資料存取層依賴領域層。這點其實跟六角架構和洋蔥架構的精神一致。

接著就來看一下什麼是六角架構和洋蔥架構。

六角架構

六角架構(Hexagonal Architecture)又叫做 Ports and Adapters 模式,是由 Alistair Cockburn 於 2005 年提出。Duncan Nisbet 用六角形的桌子來比喻六角架構:
  • 所有的 domain objects 都在桌上。
  • 環繞桌子周圍的椅子就是 adapters。
  • 站在椅背後面的人等同於外部系統或服務。

桌子的結構基本上不會變動(領域物件不變);會變動的部分是跟外界銜接的 adapters。

椅子的規格必須符合桌子的 port,才能卡進去--adapters 必須符合 domain objects 的 port,彼此才能銜接得上。下圖取自 Duncan Nisbet 部落格


內六角是領域邏輯,外六角是 adapters,作為內六角與外部系統銜接的橋樑。

六角架構的一個重點是,domain model 並不依賴任何 layer(例如資料存取層),而是所有的 layers 都依賴 domain model。像這樣(取自 http://matteo.vaccari.name/blog/archives/154):

+-----+-------------+----------+
| gui | file system | database |
|-----+-------------+----------+
|          domain              |
|------------------------------+

下圖是一個設計範例,取自 Kamil Dworakowski 部落格



洋蔥架構

洋蔥架構(Onion Architecture)是由 Jeffrey Palermo 於 2008 年提出,如下圖所示:

洋蔥架構圖(取自 Epic 網站:The bellis perennis

其主要精神為:
  1. 應用程式係圍繞著一個獨立的物件模型來建構。
  2. 內層定義介面,外層實作介面。
  3. 耦合的方向是朝向中央。
  4. 應用程式的所有核心程式碼可以在與基礎建設分離的情況下正常運行。(按:實務上有人真的試過把基礎建設抽掉,用其他 mock 物件或 null objects 取代嗎?有此經驗的朋友煩請舉個手,更希望能分享一些心得。善哉!)
摘錄自Palermo 的系列文章的幾個重點:
  • 此架構比較適用於大型的、壽命較長的複雜系統,不適合小型網站。
  • 強調針對介面來寫程式,以及把基礎建設(infrastructure)抽離至外部層次。
  • 傳統上,基礎建設往往橫跨各層,以至於增加了許多無謂的耦合。
  • 此架構的一個倍受爭議處,是 UI 和 商業邏輯層都會相依於資料存取層。對,UI 也會依賴資料存取層。遞移相依仍然是一種相依。
  • 此架構的概念並非全是創新,只是原創者賦予了一個更正式的名字,且更詳細的闡述其架構模式,方便開發人員溝通。
  • 資料庫不是核心,而是外部元件。
  • 領域模型才是核心。
  • 大量使用 Dependency Injection 技巧。

哪些要放在核心、哪些要放在基礎建設?

很明顯的,領域模型要放在核心。可是,有些 cross cutting 的東西怎麼辦呢?這問題肯定會在設計架構時碰到。

關於這個問題,Palermo 本人在回答網友留言時曾這麼說:
When deciding what to add to the core, you are making a judgement call about stability. By referencing System.dll, you are making a bet that this will be stable enough in the future to allow upgrading without too much hassle. It's a pretty safe bet as long as .Net is around. If you add, or reference logging frameworks, security frameworks, or utility functions that in turn reference frameworks, you much make a judgement call about the stability of these as well. On a few projects, I have accepted Log4Net into the core because of its history of stability and small surface area. Data access libraries are a terrible bet in the core because they have a long-standing track record of being very unstable over time with new approaches coming out every 18 months. In face, just as Entity Framework comes on the scene, the NoSQL movement is currently jumping the chasm.
喔,他曾在某些專案中把 Log4Net 放在 core 裡面耶!你看,在決定哪些元件該放到 infrastructure,哪些該放到 core 時,連 Palermo 都跟我一樣很隨便彈性啊。因為這主要是取決於你對各相依元件的「穩定性」的看法。

我想作者的意思大概是這樣:跟你的應用程式的壽命來比較,能活得比它還久的元件就視為相對穩定,可納入 core 或被 core 參考。比應用程式還短命的元件當然就不夠穩定,該放到 infrastructure 裡面。舉例來說,我們的應用程式可能要 run 個十年,可是我們目前使用的資料存取技術或網路服務 API 可能三年之後就變了,那麼這些相對短命的技術或 API 就該放到 infrastructure 裡面。
寫到這裡,我想起先前的<Dependency Injection 筆記 (6)>裡面也有討論到元件的穩定性,於是自己又再複習了一下。

實作範例

如果你對洋蔥架構有興趣,這裡有幾份實作範例可供參考學習:

這幾個範例應該都比我在這裡提供的好多啦!

不過,本系列的範例程式,由於進展得非常慢,各版本的變化不是太劇烈,再加上有討論到一些細節,我想對於初次嘗試撰寫這類架構的朋友應該有點參考價值。至少我是這麼希望啦!

修改上一個版本的範例程式

由於先前版本的 Domain Model 已經是由各組件共用,這個部分已經有點接近洋蔥架構了,所以這次修改的幅度不大(我也不想改太多),主要是:
  • 命名空間的改變:Northwind.Core.Domain 和 Northwind.Core.BusinessLayer。
  • Repository 介面(IOrderRepository)改放到 NorthwindApp.Core.Domain.Interfaces 命名空間,其實作類別則放在 NorthwindApp.DataAccess。

修改之後的組件相依圖:



現在 NorthwindApp.Service 組件也會參考 NorthwindApp.DataAccess(先前版本沒有),主要是因為我在 NorthwindApp.Service 裡面處理物件組合的工作時,會需要用到 Repository 類別。參考底下的程式片段:

public class OrderService
{
    private OrderManager orderManager;

    public OrderService()
    {
        IOrderRepository orderRepository = new OrderRepository();
        orderManager = new OrderManager(orderRepository);
    }
    //....(略)
}

這裡使用了 Constructor Injection 技巧,來降低 OrderManager 與 OrderRepository 的耦合。當然,我們也可以用 IoC container 來解決組件層級的相依問題,讓 NorthwindApp.Service 不要直接參考 NorthwindApp.DataAccess。未來若打算加入 IoC container,可以加入一個組件,用來專門負責處理型別解析,例如 NorthwindApp.DependencyResolution.dll。

下面這張圖是展開後的組件相依關係,從這裡可以大致看出幾個關鍵類別和介面被劃分到哪些命名空間。



在組件名稱上,我並沒有像其他洋蔥架構範例程式那樣使用 *.Infrastructure.* 來命名,主要是覺得名稱過於冗長,而且懶得改太多。

另外可以注意的地方是 NorthwindApp.Core.Domain.dll 組件中有兩個子命名空間:*.Model 和 *.Interfaces。這個 *.Interfaces 命名空間,將來若有需要,可以考慮獨立出去,自成一個組件,作為其他「外圍」組件與核心 Domain Model 之間溝通的橋樑。顯然,到時候免不了要使用 dependency injection 技巧或 IoC container 來處理執行時期的型別解析(小心濫用 IoC container 反而變成了 anti-pattern)。

小結

本文簡介的三種架構,儘管名稱和實作細節有些差異,但無論是堆疊狀的 DDD 多層架構還是六角形、洋蔥、甚至向日葵架構,其基本精神並無二致--它們骨子裡都是 domain-driven、domain-centric、domain-oriented....whatever you call it。

Happy coding :)

延伸閱讀

6 則留言:

  1. domain 不依賴任何 layer 的作法,才可以方便未來 domain 的重用(或是實作的更換)。例如產品的開發,都希望可以 design 出一個 general 的 model, 如果這個 model 成功,可預期的是這個產品生命週期會超過五年以上,但是 IT 技術五年已經兩個世代更迭了,這時候不依賴特定 layer 的 domain 也意味著部會依賴到特定技術,五年內就可以用新的實作技術來完成。huanlin 兄實在是非常有心,雖然這個洋蔥架構我用了好多年,但始終也沒有勁去演繹中間這麼多的思考並且為文。非常感謝您又為台灣軟體開發人員開了一扇大窗。 :D

    回覆刪除
  2. =====關於抽掉基礎建設=====

    1. 系統使用基礎建設,引用的方式通常會是使用靜態參考或是直接建立。
    2. 而物件導向中要抽離某個相依,最常使用的就是套用一層IoC,讓系統改相依介面而不依賴實做。
    結合這兩個概念不難總結出,只要套用IoC就可以抽離基礎建設。

    但實際實做上會遇到的困難是,
    採用IoC來隔離基礎建設實做,必須要將實做傳遞到每個使用基礎建設的物件。
    這點會讓整個系統充滿了基礎建設參考的傳遞,光是用想的就覺得頭皮發麻。

    為了解決這個問題,在專案開發上都使用下列文章的模式,來隔離各種基礎建設。
    [Architecture Pattern] Inversion of Logging
    http://www.dotblogs.com.tw/clark/archive/2012/09/02/74538.aspx

    文章中的模式,也是採用IoC來完成隔離基礎建設的功能。
    不同的只是改採用一個靜態屬性,
    作為系統內部取得基礎建設的參考、也做為外部DI注入IoC實做的參考點。
    (可以把這個靜態屬性,看成簡化版的 Service Locator)

    透過這樣的方式,使用IoC將基礎建設隔離在系統之外,
    複雜了一點但是提高不少系統重用實的適用範圍。 :D


    =====IoC=====
    最後補充一個IoC的相關資料,其實比想像中簡單很多,不用IoC Container也可以完成。:D
    [Object-oriented] 控制反轉
    http://www.dotblogs.com.tw/clark/archive/2010/11/29/19772.aspx

    回覆刪除
  3. 反覆讀了這篇文章,真的是淺顯易懂。
    受教了~ ^^

    回覆刪除
  4. IoC 我覺得不用太快引入,先利用 Factory 的做法來進行雛形的設計規劃。等到有二次需求的時候,確認變動點,再來進行 IoC 的引入,比較可以看到效用。

    過早引入 IoC 增加開發風險(風險來自於學習成本,以現在的大環境來看,junior developer 都不具備這種能力,一時半刻要訓練起來,頗為辛苦)當然,架構師如 Clark 這種等級的,一開始規畫得宜,又另當別論。 :D

    這些系列文章也是我當年入門大型系統架構時候,掙扎多年所過濾下來的好文,在經歷過多年的實作後,更覺得此架構的平衡。

    huanlin 兄點出了洋蔥架構的相依問題,但是對於生命週期長的大型企業應用系統,或是產品,相依的犧牲,以個人的淺見,是相對划算的。

    追求無(超低)相依完全隔離的代價很高,最起碼,老闆一定得幫該架構師保險。 :D

    尤其現代講求 SOA 的作法,洋蔥或是六角架構所能體現的價值,更佳能表現出來喔。

    再次謝謝 huanlin 兄的撰文推廣,以及 Clark 精闢的實戰經驗分享。

    回覆刪除
  5. itplayer 和 Clark 真是太客氣啦!
    我只是做點文字整理和打字的粗淺工夫,主要是花時間而已。不過,能再看到兩位專家出手分享實戰的寶貴經驗,值得! 多謝多謝 ^^

    回覆刪除
  6. 的確,剛開始使用 DI 時,不應急著用 IoC container 解決一切問題,而應優先考慮其他 DI 技巧或設計模式,例如建構式注入或工廠模式。否則很可能因為誤用而成了 anti-pattern 的實踐者。為了避免誤導,我對於文中提到 IoC container 的部分也稍微加了點潤飾。
    Thanks!

    回覆刪除

技術提供:Blogger.
回頂端⬆️