簡介
因爲身處在應對ToB需求的SAAS行業,複雜的需求在代碼上造成的混亂始終是我們的一大困擾,所以我們在一些項目中嘗採用整潔架構的分層模式對部分代碼做了一些改善和實踐。
在這篇文章中我來分享一下我在分層架構上的思考,一些實踐方法。
爲什麼要分層?
我們都知道ToB行業的一大特點就是需求非常複雜,我們面對的客戶都是大型企業,企業的流程和需求都各不相同。
可以想象一下,當你在家裏用兩個路由器去組建一個網絡的時候,還是比較簡單的事情。但當你需要幫助一個機房幾百個交換機組建網絡的時候,這個事情就會變得複雜起來。更大規模的組網需求導致了這個這個組網工作的複雜度陡增。
再舉一個例子,把你丟在一個只有四棟樓的陌生小區裏不給你任何的工具,讓你走出這個小區,我相信你能很快走出這個小區;但是把你丟在一個陌生城市裏不給你任何的工具,讓你去一個指定的地點,你是很容易迷路的。更大規模的路線需要你去了解,如果沒有地圖,你很難憑藉自己的判斷來了解這個城市的路線。
由此可見覆雜性和規模有很大的關係,再回到我們系統面臨的問題上,隨着系統承載的需求規模的增加,需求之間交叉的影響會越來越多,對系統的全面理解會越來越困難。
在《A Philosophy of Software Design》這本書中總結了三種複雜症狀:
- 變更放大(Change amplification): 看似簡單的變更需要在許多不同地方進行代碼修改。
- 認知負荷(Cognitive load): 開發人員需要許多知識才能完成一項任務。
- 未知的未知(Unknown unknowns): 開發人員在參與開發的工作中,不知道自己不知道什麼。
分層架構很大程度上就是在試圖解決這幾種複雜症狀,通過分層架構,我們將系統不斷拆解,分離形成自治的穩定空間,去降低系統的認知負擔和理解成本,簡化和明確系統變更造成的影響範圍。
分層架構的探索過程
巨人的肩膀
說到分層架構,除了經典的三層架構之外,最出名的就是Uncle Bob的《架構整潔之道》中提到的整潔架構了,他的關鍵結構表示如下:
除此之外,在DDD的六邊形架構中,也有差不多的一個分層架構:
六邊形架構給出了更加具體的層次,他將代碼的層次分爲了四層架構的:
- 用戶展示層
- 應用服務層
- 領域層
- 基礎設施層
幾個層次之間的依賴關係如下:
其實以上提到的兩種模式核心的想法都是一樣的,以自己要承載的目標業務對象爲最底層,高層代碼允許依賴底層,但是底層代碼不要依賴高層,這也是對依賴倒置原則很好的實踐。
項目分包結構思考過程
我們通過學習整潔架構和六邊形架構的內容,將他們的思想映射到系統中,我們形成了這樣的分包演進路線,按照四層架構的分層,我們可以先將系統分成以下幾個package:
.
├── view // 視圖
├── usecase // 用例
├── domain // 領域層
└── infrastructure // 基礎設施
在現實項目中會遇到幾個問題:
- 系統會存在一些配置信息,存放項目所需要的配置類
- 像目前前後端分離的情況下,前端的頁面作爲用戶展示層並不會放入後端的項目, 所以view層需要被移除掉。
- 用例usecase往往與具體的業務有關,我們將其與domain的對象聚合在一個package下,所以將usecase層移除
這樣思考之後,我們形成了這樣的一個目錄結構:
.
├── config // 配置類
├── domain // 領域層
└── infrastructure // 基礎設施
以上的每個package(用例,領域層,基礎設施)都分開分析一下,先看基礎設施,實際上,基礎設施是分爲兩個維度的的:
- 對外暴露的服務接口,可以命名爲gateway,表示對外的接入層
- 對外部中間件和數據庫的依賴框架,也是我們最習慣認爲的基礎設施
將這個package結構繼續改進之後,形成這樣的結構:
.
├── config // 配置類
├── domain // 領域層
├── gateway // 視圖
└── infrastructure // 基礎設施
在項目中還會存在非常基礎和通用的代碼,這個部分的基礎設施實際上是橫跨整個項目,有點像是當前這個項目中java.lang包,我們將其命名爲tool。
.
├── config // 配置類
├── domain // 領域層
├── gateway // 視圖
├── infrastructure // 基礎設施
└── tool // 基礎工具包
截止到以上的部分,就是第一級比較宏觀的分層了。但是一套業務系統之中,其核心還是業務,如何在domain這個包下面要劃分package,則真的要完全基於業務來進行劃分了,從技術層面對這些層次(用例,基礎設施等等)定義,還能有一些通用的說法,但是業務千變萬化,是很難有通用的劃分層次的,所以對業務進行劃分也是最難的。
我們的業務中會分爲幾種不同的業務:
- 核心業務: 我們系統核心賣點,具有清晰的業務目標的業務
- 支撐業務: 支持着核心業務多個模塊完成運轉的業務,比如發送短信消息,發送開發者回調,文件存儲等等。
所以domain下面我們可以分爲這樣兩個包:
.
├── corebiz // 核心業務
└── support // 支撐業務
對於任何一個核心業務或支撐業務的單一的小範圍業務而言,他們最好是都能獨立自治,形成一個迷你係統,根據《架構整潔之道》中的模型,每個業務package可以分爲這樣幾個層次:
.
├── application // 應用服務
│ └── param // 與用例相關的入參和出參
├── acl // 防腐層
├── model // 領域對象
├── repo // 倉儲層
└── service // 領域服務
以下是對上面分包每一種package結構的解釋:
- application包中, 存放業務的主流程
- 前面提到的usecase,表達業務的動作
- 能講明白業務的流程,表達業務的含義,不要出現複雜的數據組裝邏輯和和複雜的判斷邏輯
- 具體的業務判斷邏輯在domainService或者model中完成
- 數據組裝邏輯應該儘量在基礎設施層完成
- acl是對外部系統的調用,使用Java interface表示
- 當業務使用到外部系統的時候,使用ACL屏蔽外部對接的實現,讓業務只關心做什麼,而不是怎麼做。
- 命名應該採用具有業務含義的命名,不要出現:RedisClient、CacheClient,MySQLClient等等,應該出現: IMessageClient, IUserClient等等
- model是對業務對象的呈現
- 每個業務應該儘量新建自己的業務model,複用對象應該謹慎,避免出現過大的類,大類容易出現信息過載,當自己使用get方法找屬性要停頓思考一下的時候就意味着類太大了。
- model的類可以有一些自己的行爲方法(method)
- 業務對象其實是有分類的:
- entity:具有完整生命週期的對象,就是完整的具有業務含義的對象,比如:Receiver、Label、Document
- value object:值對象,本身只是爲了承載一些數據,離開entity沒有意義,例如Label的Position對象
- aggregate:表示聚合,當多個entity需要協作會內聚到一個聚合中,聚合也是核心的業務操作對象,比如:AutoSignContract
- repo表達的是model的數據源,使用Java interface表示
- 應該僅考慮給聚合model提供repo
- 數據的組裝在實現中完成
- domain service:
- domain service相當於業務對象(model)的延伸
- domain service應該只處理與自己對應model相關的業務,比如:ReceiverDomainService只處理Receiver相關方法,不要出現以處理Document對象爲主的方法。
- 每個方法應該只完成一個動作(粒度要小),靠application service編排domain service的方法
經過以上的思考和定義之後,我們形成了這樣的一個分層架構:
.
├── config // 配置類
├── domain // 領域層
│ ├── corebiz // 核心業務
│ │ ├── business1
│ │ │ ├── application // 應用服務,usecase
│ │ │ │ └── param // 與用例相關的入參和出參
│ │ │ ├── acl // 防腐層
│ │ │ ├── model // 領域對象
│ │ │ ├── repo // 倉儲層
│ │ │ └── service // 領域服務
│ │ └── business2
│ └── support // 支撐業務
│ │ ├── business3
│ │ │ ├── application // 應用服務,usecase
│ │ │ │ └── param // 與用例相關的入參和出參
│ │ │ ├── acl // 防腐層
│ │ │ ├── model // 領域對象
│ │ │ ├── repo // 倉儲層
│ │ │ └── service // 領域服務
│ │ └── business4
├── gateway // 視圖
├── infrastructure // 基礎設施
└── tool // 基礎工具包
有了業務的分層之後,兩個基礎設施層(gateway和infrastructure)要開始適配我們的業務分層。
先看gateway,它要作爲對外暴露的協議接入層,所以不同的協議上會有一些集中處理,所以我們給出了這樣的分層:
.
└── gateway // 協議接入層
├── dubbo // Dubbo協議層
├── http // HTTP協議接入層
├── mq // MQ協議接入層
└── schedule // 定時任務接入層
另外一遍infrastructure會包含很多中間件的本身的代碼,還有一部分是中間件的代碼和業務代碼交互的部分,所以應該形成這樣的結構去承載這兩種功能:
.
└── infrastructure // 基礎設施層
├── impl // 業務代碼和基礎設施的適配層
│ ├── corebiz
│ │ ├── business1 // 與domain層的package分類要適配
│ │ │ ├── acl // 防腐層實現
│ │ │ └── repo // 倉儲層實現
│ │ └── business2
│ └── support
│ ├── business3 // 與domain層的package分類要適配
│ │ ├── acl // 防腐層實現
│ │ └── repo // 倉儲層實現
│ └── business4
├── mysql // mysql的客戶端實現,存放比如數據庫的映射代碼,XXXDAO等,方便impl統一調用
├── redis // redis的客戶端實現,存放一些和redis交互代碼,方便impl統一調用
├── kafka // kafka的客戶端實現,存放一些和kafka交互代碼,方便impl統一調用
└── thirdparty // 與第三方系統交互的適配代碼,方便impl統一調用
還剩下一個部分沒有進行分包,那就是tool,之前提到說這個包下主要是當做這個項目的java.lang包用的,也就是一些可以在項目中比較通用的代碼,這個包下的分類就比較看自己項目的需求了, 對於我們而言,比較通用的代碼有這些:
- symbol
- document
.
└── tool // 工具代碼
├── livingdocument // 我們的業務文檔註解聚集地
├── symbol // 一些標記代碼
└── utils // 一些常用的Utils,比如StringUtils,PDFUtils等等
但是一定要注意tool這個包下對代碼的規模控制,否則也會造成過多的信息,導致出現大量無用的Utils。
至此,我們形成了這樣的一個分層架構:
.
├── config // 配置類
├── domain // 領域層
│ ├── corebiz // 核心業務
│ │ ├── business1
│ │ │ ├── application // 應用服務,usecase
│ │ │ │ └── param // 與用例相關的入參和出參
│ │ │ ├── acl // 防腐層
│ │ │ ├── model // 領域對象
│ │ │ ├── repo // 倉儲層
│ │ │ └── service // 領域服務
│ │ └── business2
│ └── support // 支撐業務
│ │ ├── business3
│ │ │ ├── application // 應用服務,usecase
│ │ │ │ └── param // 與用例相關的入參和出參
│ │ │ ├── acl // 防腐層
│ │ │ ├── model // 領域對象
│ │ │ ├── repo // 倉儲層
│ │ │ └── service // 領域服務
│ │ └── business4
├── gateway // 協議接入層
│ ├── dubbo // Dubbo協議層
│ ├── http // HTTP協議接入層
│ ├── mq // MQ協議接入層
│ └── schedule // 定時任務接入層
├── infrastructure // 基礎設施
│ ├── impl // 業務代碼和基礎設施的適配層
│ │ ├── corebiz
│ │ │ ├── business1 // 與domain層的package分類要適配
│ │ │ │ ├── acl // 防腐層實現
│ │ │ │ └── repo // 倉儲層實現
│ │ │ └── business2
│ │ └── support
│ │ ├── business3 // 與domain層的package分類要適配
│ │ │ ├── acl // 防腐層實現
│ │ │ └── repo // 倉儲層實現
│ │ └── business4
│ ├── mysql // mysql的客戶端實現,存放比如數據庫的映射代碼,XXXDAO等,方便impl統一調用
│ ├── redis // redis的客戶端實現,存放一些和redis交互代碼,方便impl統一調用
│ ├── kafka // kafka的客戶端實現,存放一些和kafka交互代碼,方便impl統一調用
│ └── thirdparty // 與第三方系統交互的適配代碼,方便impl統一調用
└── tool // 基礎工具包
├── livingdocument // 我們的業務文檔註解聚集地
├── symbol // 一些標記代碼
└── utils // 一些常用的Utils,比如StringUtils,PDFUtils等等
這裏通過一個圖例更加直觀地說明這個層次結構:
分層架構的實踐
如何在項目中加入分層架構
在探索得到這樣的一個分層架構之後,首要面臨的問題就是如何將這樣的架構應用到系統中,也不會對系統造成很大的影響。在敏捷開發中,很重要的一個實踐就是要做精益交付,就是一次性不要嘗試做大型交付,比如將系統推到重來,或者一次交付一個需要幾周才能完成的工作,要想辦法儘快讓其有反饋。
在不影響原來的代碼的情況下,我們決定在項目中添加了新的一個package,使得形成這樣的一個結構:
.
├── extant-package // 現有的代碼package
└── new-package // 分層架構的package
這樣我們在找定一個業務中之後,可以在不影響原來項目的情況下立馬開始實踐,即使出問題了,在發佈分支上刪除掉整個package也沒有問題。(可以Google查詢Martin Fowler的“絞殺者模式”)
新舊需求如何使用分層架構
新需求往往包含着一些完整的業務邏輯,所以可以比較方便在分層架構下構建代碼,在這種分層架構下新編寫業務邏輯,然後暴露協議給外部或者老代碼使用。
對於一些老的需求,我們也是採用了演進式的方式來進行適配,我們會把老需求修改的部分通過分層架構進行編寫,然後暴露一個interface給老代碼去使用。
但不論是新需求還是老的需求改造,其中非常重要的兩件事情:
- 每一個business package下要能夠獨立自治,形成高度內聚的邏輯,這樣就能一定程度上減緩Unkonw unkonws的困擾。
- 每一個business package下的規模要足夠的小,只有足夠的小,才能讓後面來維護的其他人認知負擔小,讓這個模塊可以做到快速交付。
對於代碼層面具體的編寫是一個比較大的內容,我們會單獨去寫一篇文章來進行分享。
分層架構是如何幫助單元測試的
單元測試一直是我們希望去強調的一個質量保證手段,但是很長一段時間單元測試的執行效果是不理想的,但分層架構一定程度上幫助到了單元測試的推進。
原來單元測試對於大部分開發同事來說是最痛苦的就是運行它,因爲一段充滿了依賴和壞味道的代碼其實是不好運行的,也非常慢,這也阻礙了我們進行測試。單元測試本意是爲了測試我們那一小塊業務邏輯,我們並不應該將無關的代碼啓動起來,通過分層架構我們可以將基礎設施和業務代碼分開。
前面我們提到了一個business的結構如下:
.
├── application // 應用服務
│ └── param // 與用例相關的入參和出參
├── acl // 防腐層,只有interface
├── model // 領域對象
├── repo // 倉儲層,只有interface
└── service // 領域服務
我們只去測試business代碼,從application爲入口,進行測試。由於ACL和Repo都是interface,我們很容易就能mock這些interface,給這些interface一些我們預期的輸入和返回值,然後驗證我們在application中的編排和業務邏輯是否是正確的,這裏去構建單元測試的時候,是不需要藉助任何運行時候的框架的(比如spring,Dubbo等等),僅僅是我們在驗證業務邏輯,快速獲得我們編寫的業務邏輯是否符合我們的預期。
這裏尤其要注意使用spring的項目,做IOC注入的時候應該使用構造器注入或者是setter注入,這樣才能方便單元測試。
如何管理日益新增的需求
隨着需求日益增加,package會陷入另外一種混亂。給大家感受一下:
.
├── corebiz
│ ├── business1
│ ├── business2
│ ├── business3
│ ├── business4
│ ├── business5
│ ├── business6
│ ├── business7
│ ├── business8
...
│ └── business100
└── support
├── business1
├── business2
├── business3
├── business4
├── business5
├── business6
├── business7
├── business8
...
└── business50
由於過多的package,還是會陷入另外一種由於規模造成的認知負擔。如果我們可以有一個需求目錄,想象一下如果用腦圖的形式讓你組織自己負責的產品現在的需求目錄你會怎麼做?經過精心地整理,是否可以整理成這樣:
.
├── corebiz
│ ├── business1
│ │ ├── business1.1
│ │ ├── business1.2
│ │ └── business1.3
│ ├── business2
│ │ ├── business2.1
│ │ ├── business2.2
│ │ └── business2.3
│ ├── business3
│ │ ├── business3.1
│ │ │ ├── business3.1.1
│ │ │ ├── business3.1.2
│ │ └── business3.2
│ └── business4
└── support
├── business1
│ ├── business1.1
│ ├── business1.2
│ └── business1.3
├── business2
│ ├── business2.1
│ ├── business2.2
│ └── business2.3
└── business3
可以讓這些package本身就組織地與文檔一樣,在需求演進的過程中設定一個規則,若模塊超過x個,就進行拆分。最終的目的一定是:
- 讓這個結構本身成爲文檔,讓package的名稱具有業務含義
- 讓這個結構認知負擔低,方便其他人查找。
由此還可以可見,當這個package下的目錄達到一定的規模之後,我們就應該思考,這個系統是否應該進行拆分了。
總結
以上就是我們對於分層架構的探索實踐的分享,《架構整潔之道》中有這麼一句話來形容軟件架構的目標:
軟件架構的最終目標是:用最小的人力成本來滿足構建和維護系統的需求。
通過分層架構的實踐,產出的代碼一定程度上降低了大家理解系統的認知負擔,改善了修改系統的成本,算是達到了我們使用分層架構的目的。