動態

詳情 返回 返回

六邊形架構最佳實踐探索 - 動態 詳情

在工作中,我接觸到的產品均採用了微服務架構,後端項目開發普遍採用了六邊形架構:六邊形架構提供了一套良好的設計思想,但它缺乏對項目代碼組織細節的指導;同時,項目中並沒有使用專門的微服務框架,而是普遍使用Gin框架,這使得代碼組織過於靈活,沒有提供充分的編碼約束,以致於在過去的業務需求實現中,後端服務的項目代碼組織充斥着各種各樣的問題;為了解決這些問題、提高開發效率、保障工程質量,基於工作一年的實踐和探索設計了一套新的方案,現在此分享。

項目結構

目錄結構

├── common  公共模塊
│   ├── config.go  依賴配置
│   ├── constants.go  公共常量
│   └── utils.go  通用工具函數
├── drivenadapters  從動適配器
│   ├── cache  緩存適配器
│   ├── db  數據庫適配器
│   ├── http  HTTP服務適配器
│   ├── mq  消息隊列適配器
│   └── repository  資源庫實現,完成依賴注入
├── driveradapters  驅動適配器
│   ├── api  同步WEB接口適配器
│   │   ├── health.go  健康檢查接口控制器
│   │   ├── middleware  路由中間件
│   │   └── router.go  路由模塊,進行路由註冊
│   ├── async 異步WEB接口適配器
│   │   └── init.go  消息網關模塊,進行異步接口註冊
│   └── cmd 命令行接口適配器
│       ├── processor.go  處理器模塊,進行命令註冊
│       └── server.go 服務啓動命令執行器
├── errors  業務錯誤
│   ├── general.go  通用業務錯誤 
│   └── custom.go  自定義業務錯誤
├── infra  基礎設施
├── logics  核心業務邏輯
│   ├── pipeline  消息發送管道
│   ├── proxy  服務代理接口
│   └── dependency  資源庫接口,實現依賴倒置
├── main.go  服務入口
└── models  數據模型

模塊劃分

項目中的模塊分為核心與適配器,其中核心為業務邏輯,適配器為系統與外部通信的模塊。

核心

  1. 將每個獨立的業務模塊稱為服務(Service),負責組裝業務流程。
  2. 每個服務都有一個專屬的資源庫(Repository),負責完成業務邏輯中的數據處理操作。
  3. 資源庫的接口由服務定義,並通過聚合從動適配器來實現,以此實現依賴倒置
  4. 資源庫必需在服務之前進行實例化,以完成依賴注入

適配器

  1. 驅動適配器(DriverAdapter)從動適配器(DrivenAdapter):這是基於模塊調用的邏輯關係來劃分的,被核心調用的部分叫從動適配器,調用核心的部分叫驅動適配器
  2. 出棧適配器(PopAdapter)入棧適配器(PushAdapter):這是基於數據流向來劃分的,數據流入核心的部分叫入棧適配器,數據流出核心的部分叫出棧適配器
  3. 工作中遇到許多微服務項目錯誤地使用入棧適配器出棧適配器的概念,導致了項目代碼結構組織邏輯的混亂;在系統中,模塊之間的數據流動往往是雙向的(比如HTTP的請求和響應),所以不適用上述劃分標準。
  4. 驅動適配器從動適配器是六邊形架構中常用的概念,需要注意應當從邏輯上而非物理上劃分,否則會無法處理一些特殊的情況(如消息的訂閲與發佈)。

項目架構

項目架構

設計思想

  1. 數據驅動設計:通常,數據存儲格式相對穩定,而業務邏輯經常需要調整和擴展,所以優先明確業務數據模型,並圍繞它來組織業務邏輯、實現業務流程。
  2. 六邊形架構:分離系統的核心與外部,兩者通過端口實現交互;業務邏輯定義端口,適配器實現端口。
  3. 分層架構原則:禁止下層模塊調用上層模塊。
  4. 依賴倒置原則:上層模塊不依賴於下層模塊,兩者都依賴於抽象;抽象不依賴於實現,實現依賴於抽象。
  5. 關注點分離:每個模塊只關注自己的職責:禁止同一層模塊間的相互調用,模塊內使用的常量非不要不暴露給外部。
  6. 顯式管理依賴:向開發人員暴露不同模塊間的調用鏈路,以便於排查問題根源和預防循環依賴。
  7. 職責歸納分類:在從動適配器和驅動適配器中,將適配器模塊根據外部依賴的類別不同劃分到不同的包中。

實踐指導

職責劃分

目錄 內容 説明
models 數據模型 1. 定義業務邏輯層與適配器層通信用的數據模型。
2. 數據模型上只有屬性沒有方法。
3. 依據數據模型的來源拆分類型定義拆分到不同文件中。
common 公共組件 1. config統一管理項目的靜態配置常量,基礎設施連接配置除外。
2. constants統一管理項目中不同模塊間通用的常量。
3. utils統一管理業務無關的通用工具函數。
infra 基礎設施 1. 統一管理服務對數據庫/緩存/消息隊列等的訪問。
2. 封裝訪問基礎設施的靜態配置,並提供創建/獲取客户端對象實例的簡單方法。
3. 當有特殊需要時,封裝新的模塊實現客户端對象接口,並在對應的方法中添加處理邏輯。
errors 業務錯誤 1. 統一管理drivenadapter中返回的業務錯誤對象。
2. general提供生成通用業務錯誤對象的工具,custom提供生成自定義業務錯誤對象的工具。
3. 業務錯誤對象由driveradapters中的適配器模塊或logics中的Service模塊生成。
drivenadapters 從動適配器 1. db管理訪問數據庫的適配器,按照數據對象實體關係劃分模塊。
2. cache管理訪問緩存的適配器,按照緩存數據來源劃分模塊。
3. http管理訪問HTTP服務的適配器,按照提供接口的服務劃分模塊。
4. mq管理訪問消息隊列的適配器模塊,按照定義消息的服務劃分模塊。
5. 基於相同外部服務的適配器按照具體用途放入不同的目錄(如基於OpenSearch的適配器可能歸入db/cache/http,基於Redis的適配器可能歸入db/cache/mq)
6. repository實現logics層中dependency定義的Repo接口,並進行依賴注入;每個Repo對象會聚合不同的適配器模塊,並完成數據處理。
logics 核心業務邏輯 1. 按照業務職責劃分出不同的Service模塊,根據Service定義對應的Repo,負責處理業務邏輯所需的數據操作。
2. dependency管理Repo接口,並提供實例對象的Set/Get函數;通常情況下,一個Repo只有一個實現,如有多個實現則需採用工廠模式。
3. 不同Service之間可能需要相互調用,按照以下方式實現(假設有兩個Service為X和Y):
    X依賴Y的方法返回值時
        1. 在dependency中定義Proxy接口。
        2. 在Y的構造函數中創建實例後調用Set函數注入Proxy實例。
        3. 在X的Repo接口中聚合上述Proxy接口。
        4. 在repository中Repo的實現中添加Proxy上方法的實現,在每個方法中通過Proxy接口的Get方法動態獲取實例。
    X不依賴Y的方法返回值時,但改變Y的狀態時
        1. 創建一個EventLoop實例(具體實現不限制)。
        2. X發佈事件A到EventLoop。
        3. Y訂閲事件A並進行處理。
driveradapters 驅動適配器 1. api提供同步WEB接口,按照路由所屬業務劃分controller模塊,負責解析請求參數並作通用性校驗、調用logics中Service進行處理、封裝並放回請求響應;middleware提供路由中間件,health為健康檢查接口,router負責統一註冊controller的路由處理。
2. cmd提供命令行調用接口,按照業務劃分executor模塊,processor負責統一註冊executor的命令處理,server是負責啓動服務的特殊命令。
3. async提供異步WEB接口,按照業務劃分broker模塊,init負責統一註冊broker的消息處理。

編碼順序

編碼順序

  1. 定義業務數據模型

    • 根據實現設計來編寫類型聲明,在進入編碼開發前必須充分明確實現業務所需的數據模型。
    • 根據數據庫設計定義數據持久化對象PO,結構體屬性字段與數據庫表字段一一對應。
    • 根據API設計定義值對象VO,此時只需明確字段名稱和類型。
    • 響應結構體屬性字段與接口響應參數字段一一對應。
    • 請求結構體屬性字段聚合接口請求體、查詢參數、路徑參數、請求頭中所需字段。
    • 根據緩存數據模型設計定義緩存對象。
    • 消息結構直接使用公共數據模型,有必要時定義消息對象。
    • 按需添加業務域內通用的公共數據模型。
  2. 定義Service接口

    • 明確Service需要暴露給接口調用的方法。
    • 對於供Controller調用的方法,參數只有一個請求值對象,返回值只能包含響應值對象和error。
    • 對於供Broker調用的方法,參數只有一個消息對象,返回值只有一個error。
  3. 定義Repo接口

    • 明確Service方法實現業務流程所需完成的業務數據操作,抽象為Repo接口上的方法。
    • 定義Repo實例的Set和Get方法。
    • 定義DTO對象。
  4. 編寫Service的UT

    • 根據核心業務流程編寫Service接口方法的UT。
    • 編寫UT專用的構造函數生成注入mock對象的Service實例。
    • 針對業務錯誤編寫對應分支的UT,不考慮與業務錯誤無關的error分支。
    • 對日誌打印方法調用進行mock時,不關注其執行的時機和次數。
    • 斷言error時,只關注是否為nil,不關注具體是什麼error。
  5. 實現Service

    • 根據接口生成Repo和infra的mock以便於運行UT。
    • 根據UT實現Service接口上的方法,按需定義業務錯誤碼和描述信息,並生成業務錯誤對象。
    • 服務初始化所需的處理寫在私有方法裏,並在實例化的構造函數裏調用。
  6. 定義DrivenAdapters接口

    • 明確Repo方法實現業務數據操作所需的底層數據操作,抽象出多個DrivenAdapter接口。
    • RDS的適配器接口暴露的每個方法內部的操作都在同一個事務中完成,特殊情況是Repo需要聚合多個數據庫適配器的操作時,將事務對象暴露在方法參數中由Repo控制。
    • Redis的適配器接口暴露的每個方法內部的操作都對應Redis的一個命令或事務。
    • 每個消息訂閲方法都對應一種msg結構,通過參數指定topic和channel。
    • 每個消息發佈方法都對應一對topic和msg的組合。
    • HTTP服務適配器接口暴露的每個方法都對應一個HTTP請求。
  7. 編寫Repo的UT

    • 根據業務數據處理邏輯編寫Repo接口方法的UT。
    • 編寫UT專用的構造函數生成注入mock對象的Repo實例。
    • 覆蓋具有系統性價值的邏輯分支,不考慮錯誤分支。
  8. 實現Repo

    • 根據接口生成DrivenAdapter和infra的mock以便於運行UT。
    • 根據UT實現Repo接口上的方法。
    • 負責管理業務使用的但獨立於業務流程外的狀態。
  9. 實現DrivenAdapters

    • 實現接口上的方法,內部的處理邏輯按需提取拆分出私有方法。
    • RDS的適配器處理SQL語句的生成與執行,並打印事務提交/回滾出錯的日誌。
    • Redis的適配器管理靜態key,並在接口方法內調用私有方法給參數加前綴生成動態key。
    • 消息隊列適配器中,消息訂閲方法處理消息結構的反序列化,消息發佈方法處理消息結構的序列化,出錯時均需打印錯誤日誌。
    • HTTP服務適配器管理請求接口URL,進行請求參數的包裝後發送請求,並根據HTTP響應狀態碼處理響應參數或業務錯誤的解析。
  10. 實現DriverAdapters

    • 控制器接口按需暴露註冊開放接口和註冊私有接口的方法,方法內部掛載路由處理方法和所需中間件。
    • 處理通用參數校驗,並生成相應的通用錯誤對象。
    • 定製參數校驗在控制器層定義私有方法進行處理,按需定義服務通用業務錯誤碼和描述信息,並生成業務錯誤對象。
    • 消息適配器中發佈方法定義topic和msg結構並處理其序列化,訂閲方法處理msg結構的反序列化。
  11. 編寫Adapters的UT

    • 按需編寫UT,不必考慮error。
    • 從動適配器的UT優先級高於驅動適配器。
    • 優先給複雜的數據庫查詢操作編寫UT,對SQL語句的mock要儘可能精準匹配。
    • 消息訂閲和發佈的處理方法不需要編寫UT,因為它們職責簡單、邏輯單薄。
    • 緩存操作通常也不需要編寫UT,除非有部分方法包含了複雜的操作邏輯,編寫UT時對redis命令的mock要儘可能精準匹配。
    • HTTP請求方法中,如果包含複雜的響應參數處理邏輯,可以編寫UT。
    • 控制器方法中,如果包含定製參數校驗邏輯,可以編寫UT。

操作手冊

編號 參考項 具體規則
1 常量定義 1. 當字面量具有特定邏輯含義時,聲明為常量。
2. 使用array將int常量映射為string常量。
3. 使用map將string常量映射為int常量。
4. 當一組枚舉值的業務語義存在上下文時,使用struct的public屬性聚合它們。
2 錯誤處理 1. 對於業務不允許的err,使用%w封裝添加來源後return。
2. 從動適配器不屏蔽err,將其封裝後留給Repo處理。
3. 在Repo中遇到err時,根據業務邏輯需要決定忽略或return。
4. 在Service中遇到業務允許的err時,生成業務錯誤對象後return。
5. 在服務初始化時,panic內部err,for err != nil輪詢刷新外部err。
3 日誌打印 1. 服務初始化成功打印info日誌,失敗打印error日誌。
2. Service中遇到業務不允許的err時,打印error日誌。
3. Repository中遇到Service中無法捕獲的錯誤時,打印error日誌。
4. 數據庫事務提交/回滾失敗時,打印error日誌。
5. 異步消息發送/處理失敗時,打印error日誌。
4 命名約定 1. 從動適配器
    1. 緩存適配器:業務實體+Cache
    2. 數據庫適配器:業務實體+Store
    3. HTTP服務適配器:服務名+Client
    4. 消息隊列適配器:服務名+Broker
2. 業務邏輯層
    核心業務邏輯模塊Service:業務名+Service;
    業務數據處理模塊Repository:業務名+Repo
    業務服務代理接口:業務名+Proxy
3. 驅動適配器
    1. 同步WEB接口適配器:路由組前綴+Controller
    2. 命令行接口適配器:指令名+Executor
    3. 異步WEB接口適配器:業務名+Broker
4. 數據模型
    1. 緩存數據模型:數據實體+Cache
    2. 驅動適配器定義的數據模型:接口標識+VO(值對象)
    3. 數據庫適配器定義的數據模型:數據實體+PO(持久化對象)
    4. Repo定義的數據模型:數據實體+DTO(數據傳輸對象)
5 方法調用 當模塊調用其它層模塊時,傳入參數/返回值必須使用基本類型/公共數據模型/指定類型:
    1. 驅動適配器調用Service:VO
    2. Service調用Repo:DTO
    3. Repo調用數據庫適配器:PO
    4. Repo調用緩存適配器:Cache
    5. Repo調用HTTP服務適配器:適配器模塊內定義的類型
6 邏輯實現 1. 方法抽象:通過interface暴露方法,通過struct實現interface。
2. 屬性封裝:struct用private屬性保存局部狀態,不允許包含public屬性。
3. 單向數據流:外部改變模塊狀態必須通過調用其interface上的方法來執行。
4. 無副作用:一個模塊的方法A調用其它模塊方法B時,B對傳入參數狀態的改變不能影響到A。
7 依賴注入 1. dependency中提供Repo的Set函數。
2. repository中每個Repo使用init函數進行初始化,其內部調用Set函數,傳入參數為調用Repo實現模塊的構造函數返回的Repo對象。
8 路由註冊 1. Controller對象按需實現PublicController和PrivateController接口。
2. Router模塊提供RegisterPrivateAPI方法註冊內部接口,它會調用PrivateController上的RegisterPrivate方法。
3. Router模塊提供RegisterPublicAPI方法註冊開放接口,它會調用PublicController上的RegisterPublic方法。
4. Router模塊實例化時,將Controller實例注入PublicController列表和PrivateController列表。
5. main.go中實例化Router並調用其RegisterPublicAPI和RegisterPrivateAPI方法統一註冊路由。
9 模塊通用 1. 建議使用基於sync.Once+構造函數實現的懶漢式單例,以便於UT進行mock。
2. 被調用接口按調用方需求暴露方法,隱藏其內部實現細節。
3. 不允許將整個方法內部的處理邏輯包裝到go協程中,由調用方決定是否異步執行。
10 數據庫操作 1. 一個適配器模塊對應一組業務數據,這些數據邏輯上屬於同一業務實體。
2. 每個方法最多直接管理一條SQL語句。
3. 執行參數可變的SQL語句時,必須使用參數化查詢,不允許使用字符串拼接。
4. 對於SQL語句查詢參數的邊界情況,在調用它的最上層方法中處理。
5. 批量更新記錄時,優先考慮增量更新而不是全量更新。6.
事務使用
    1. 單獨使用的SELECT語句不需要顯式開啓事務。
    2. 操作包含UPDATE/DELETE/INSERT語句時,必須使用事務。
    3. 多個SELECT語句組成的查詢操作,在要求強一致性場景下使用事務。
    4. 只在有必要的情況下將事務對象暴露給Repo層使用,不允許暴露給Service層。
11 緩存操作 1. 一個適配器模塊對應一個數據源,可以是logics裏的某個Service或某個外部HTTP服務。
2. 每個方法最多直接管理一條Redis指令。
3. 緩存key使用
    1. 將可變key的生成邏輯封裝在內部,接口暴露的方法傳參只需要傳入緩存對象的唯一標識。
    2. 將不可變key存在struct的私有屬性上,接口暴露的方法不需要傳入相關參數。
4. value的數據類型選擇
    1. 只需要全量更新和整體獲取的緩存對象使用string類型。
    2. 需要增量更新或局部獲取的緩存對象:
        1. 包含唯一標識的項集合使用hash。
        2. 值唯一的無序簡單項集合使用set。
        3. 值唯一的有序簡單項集合使用zset。
    4. 值不唯一的有序簡單項集合使用list。
5. 緩存一致性問題
    1. 當前服務內管理的持久化數據可以設置不過期,服務更新數據時同步到緩存;追求強一致時緩存只更新不刪除並設置過期時間。
2. 外部來源的緩存數據根據一致性強弱要求設置合適的過期時間,條件允許時同步更新。
12 HTTP請求 1. 一個適配器模塊對應一個外部HTTP服務,主要是其它的業務服務。
2. 每個方法最多直接管理一個HTTP請求。
3. HTTP請求發送成功,但響應狀態碼不正常時,檢測是否為業務錯誤,是則解析後賦值給err。
4. 按照適配服務的API設計定義方法的返回值/參數結構,固定參數可以封裝在方法內而不暴露到接口上,接口方法使用的參數結構定義直接寫在模塊對應的源碼文件裏。
13 消息處理 1. 一個消息隊列適配器對應一組消息業務,可以是logics裏的某個Service或某個外部HTTP服務。
2. 從動適配器中,每個消息發佈方法將msg結構暴露在參數中,將topic封裝在方法內。
3. 從動適配器中,每個消息訂閲方法處理一種結構的消息
    1. 將topic、channel封裝在方法內,handler暴露在參數中
    2. handler接收參數類型根據消息接口定義,msg在方法內部序列化
    3. 由Repo調用從動適配器提供的方法,傳入handler進行消息訂閲
4. 驅動適配器中,每個訂閲接口方法發佈消息,通過注入pipeline供Service模塊取用。
5. 驅動適配器中,每個消費接口方法內部訂閲消息,並通過Service接口暴露的方法進行處理,所有方法共用一個channel。
14 資源庫定義 1. 一個Repo接口對應一組業務數據處理需求,對應唯一的Service。
2. 每個Repo使用全局變量保存實例,提供Get函數獲取實例,Set函數注入實例。
3. 接口方法使用的DTO結構定義直接寫在接口聲明所在的源碼文件裏。
4. 當一個Repo存在不同實現時,採用工廠模式實現,使用map[string]Repo保存多個不同實例,將key作為參數傳入Get/Set函數用於指定獲取/注入哪個實例。
5. Repo上的方法主要為Service服務,不需要像從動適配器一樣強的通用性。
15 資源庫實現 1. 在init函數中調用Set函數注入Repo的實例,通過環境變量控制是否實例化,以允許執行UT時跳過此過程
2. Repo實例按需聚合多個從動適配器,並實現供Service調用的接口方法。
3. 獲取/更新數據時,緩存與持久化數據的同步在Repo中處理,不暴露給Service。
4. 與業務流程無關的狀態由Repo管理,不暴露給Service。
5. 基於其它HTTP服務的業務錯誤響應完成的邏輯處理在Repo內完成,不暴露給Service。
16 路由控制器 1. 一個Controller適配器對應一組業務相關的路由接口。
2. 每個Controller按需實現RegisterPublic/RegisterPrivate方法,將路由註冊委託給Router。
3. 按需補充中間件供接口註冊路由時掛載。
4. 對於Service返回的err,業務錯誤直接返回響應,其它錯誤封裝成通用500錯誤響應。
5. 處理通用的參數校驗。
17 業務服務實現 1. 一個Service對應一個業務服務,並定義一個對應的Repo。
2. 每個Service使用私有屬性保存狀態,且狀態只能由Service自身變更。
3. 嚴格根據業務域拆分Service,將不涉及Service狀態的公共部分下沉到Repo中。
4. 對於涉及Service狀態重疊部分的Service間調用,根據業務核心邏輯決定歸屬的Service,並通過Proxy接口將該Service對象注入Repo中供其它Service調用。
5. 不允許Service直接調用從動適配器,必須通過Repo做中介。

其它

編碼注意

  1. 在異常情況/錯誤處理中,使用衞語句代替else分支減少嵌套;邏輯上並列的分支仍使用else。
  2. 衞語句:將異常/錯誤分支前置,判斷不通過時直接return,以降低圈複雜度(即最大嵌套層數)。
  3. 單個方法/函數邏輯中專用的多分支邏輯或複雜的多分支邏輯使用switch-case,多個方法/函數通用的簡單多分支邏輯使用表驅動法。
  4. 表驅動:用hash表存儲一組鍵值對映射,根據入參匹配key來獲取value使用,以代替等值匹配的多條件分支。
  5. 拼接字符串的邏輯不復雜時,推薦使用+而不是fmt庫方法。
  6. 能提前確定size時,調用make創建map/slice時直接傳入size,此時slice通過索引賦值元素。
  7. 函數/方法傳參/返回struct儘量用指針類型,slice/map則不要用指針類型。
  8. 在函數/方法的返回值列表中聲明的非基本類型參數不會做初始化(因為只是聲明),所以map/slice/chan/*struct類型需要在函數體內手動賦初值。
  9. 使用工具庫flex簡化代碼並高效實現邏輯。

併發安全

  1. 在無法捕獲panic的協程中做類型斷言時,使用第二個參數來預防panic。
  2. 可能出現併發讀寫map的場景,使用sync.Map/sync.Mutex/chan來防止panic。
  3. 使用細粒度的數據操作控制來預防併發寫導致的數據衝突,同時適用於緩存和數據庫。
  4. 多實例場景下僅需要單次執行的操作,通過分佈式鎖使後訪問數據的服務實例跳過操作。
  5. 只在邏輯處理有必要異步的場景下使用go協程,以避免帶來額外的維護複雜度。

示例項目

這裏提供了不同語言的參考示例項目,可以直接作為搭建新的微服務項目的代碼模板使用,同時也歡迎貢獻未收錄編程語言的示例項目。

  1. Go

Add a new 評論

Some HTML is okay.