在工作中,我接觸到的產品均採用了微服務架構,後端項目開發普遍採用了六邊形架構:六邊形架構提供了一套良好的設計思想,但它缺乏對項目代碼組織細節的指導;同時,項目中並沒有使用專門的微服務框架,而是普遍使用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 數據模型
模塊劃分
項目中的模塊分為核心與適配器,其中核心為業務邏輯,適配器為系統與外部通信的模塊。
核心
- 將每個獨立的業務模塊稱為
服務(Service),負責組裝業務流程。 - 每個服務都有一個專屬的
資源庫(Repository),負責完成業務邏輯中的數據處理操作。 資源庫的接口由服務定義,並通過聚合從動適配器來實現,以此實現依賴倒置。資源庫必需在服務之前進行實例化,以完成依賴注入。
適配器
驅動適配器(DriverAdapter)和從動適配器(DrivenAdapter):這是基於模塊調用的邏輯關係來劃分的,被核心調用的部分叫從動適配器,調用核心的部分叫驅動適配器。出棧適配器(PopAdapter)和入棧適配器(PushAdapter):這是基於數據流向來劃分的,數據流入核心的部分叫入棧適配器,數據流出核心的部分叫出棧適配器。- 工作中遇到許多微服務項目錯誤地使用
入棧適配器和出棧適配器的概念,導致了項目代碼結構組織邏輯的混亂;在系統中,模塊之間的數據流動往往是雙向的(比如HTTP的請求和響應),所以不適用上述劃分標準。 驅動適配器和從動適配器是六邊形架構中常用的概念,需要注意應當從邏輯上而非物理上劃分,否則會無法處理一些特殊的情況(如消息的訂閲與發佈)。
項目架構
設計思想
- 數據驅動設計:通常,數據存儲格式相對穩定,而業務邏輯經常需要調整和擴展,所以優先明確業務數據模型,並圍繞它來組織業務邏輯、實現業務流程。
- 六邊形架構:分離系統的核心與外部,兩者通過端口實現交互;業務邏輯定義端口,適配器實現端口。
- 分層架構原則:禁止下層模塊調用上層模塊。
- 依賴倒置原則:上層模塊不依賴於下層模塊,兩者都依賴於抽象;抽象不依賴於實現,實現依賴於抽象。
- 關注點分離:每個模塊只關注自己的職責:禁止同一層模塊間的相互調用,模塊內使用的常量非不要不暴露給外部。
- 顯式管理依賴:向開發人員暴露不同模塊間的調用鏈路,以便於排查問題根源和預防循環依賴。
- 職責歸納分類:在從動適配器和驅動適配器中,將適配器模塊根據外部依賴的類別不同劃分到不同的包中。
實踐指導
職責劃分
| 目錄 | 內容 | 説明 |
|---|---|---|
| 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的消息處理。 |
編碼順序
-
定義業務數據模型
- 根據實現設計來編寫類型聲明,在進入編碼開發前必須充分明確實現業務所需的數據模型。
- 根據數據庫設計定義數據持久化對象PO,結構體屬性字段與數據庫表字段一一對應。
- 根據API設計定義值對象VO,此時只需明確字段名稱和類型。
- 響應結構體屬性字段與接口響應參數字段一一對應。
- 請求結構體屬性字段聚合接口請求體、查詢參數、路徑參數、請求頭中所需字段。
- 根據緩存數據模型設計定義緩存對象。
- 消息結構直接使用公共數據模型,有必要時定義消息對象。
- 按需添加業務域內通用的公共數據模型。
-
定義Service接口
- 明確Service需要暴露給接口調用的方法。
- 對於供Controller調用的方法,參數只有一個請求值對象,返回值只能包含響應值對象和error。
- 對於供Broker調用的方法,參數只有一個消息對象,返回值只有一個error。
-
定義Repo接口
- 明確Service方法實現業務流程所需完成的業務數據操作,抽象為Repo接口上的方法。
- 定義Repo實例的Set和Get方法。
- 定義DTO對象。
-
編寫Service的UT
- 根據核心業務流程編寫Service接口方法的UT。
- 編寫UT專用的構造函數生成注入mock對象的Service實例。
- 針對業務錯誤編寫對應分支的UT,不考慮與業務錯誤無關的error分支。
- 對日誌打印方法調用進行mock時,不關注其執行的時機和次數。
- 斷言error時,只關注是否為nil,不關注具體是什麼error。
-
實現Service
- 根據接口生成Repo和infra的mock以便於運行UT。
- 根據UT實現Service接口上的方法,按需定義業務錯誤碼和描述信息,並生成業務錯誤對象。
- 服務初始化所需的處理寫在私有方法裏,並在實例化的構造函數裏調用。
-
定義DrivenAdapters接口
- 明確Repo方法實現業務數據操作所需的底層數據操作,抽象出多個DrivenAdapter接口。
- RDS的適配器接口暴露的每個方法內部的操作都在同一個事務中完成,特殊情況是Repo需要聚合多個數據庫適配器的操作時,將事務對象暴露在方法參數中由Repo控制。
- Redis的適配器接口暴露的每個方法內部的操作都對應Redis的一個命令或事務。
- 每個消息訂閲方法都對應一種msg結構,通過參數指定topic和channel。
- 每個消息發佈方法都對應一對topic和msg的組合。
- HTTP服務適配器接口暴露的每個方法都對應一個HTTP請求。
-
編寫Repo的UT
- 根據業務數據處理邏輯編寫Repo接口方法的UT。
- 編寫UT專用的構造函數生成注入mock對象的Repo實例。
- 覆蓋具有系統性價值的邏輯分支,不考慮錯誤分支。
-
實現Repo
- 根據接口生成DrivenAdapter和infra的mock以便於運行UT。
- 根據UT實現Repo接口上的方法。
- 負責管理業務使用的但獨立於業務流程外的狀態。
-
實現DrivenAdapters
- 實現接口上的方法,內部的處理邏輯按需提取拆分出私有方法。
- RDS的適配器處理SQL語句的生成與執行,並打印事務提交/回滾出錯的日誌。
- Redis的適配器管理靜態key,並在接口方法內調用私有方法給參數加前綴生成動態key。
- 消息隊列適配器中,消息訂閲方法處理消息結構的反序列化,消息發佈方法處理消息結構的序列化,出錯時均需打印錯誤日誌。
- HTTP服務適配器管理請求接口URL,進行請求參數的包裝後發送請求,並根據HTTP響應狀態碼處理響應參數或業務錯誤的解析。
-
實現DriverAdapters
- 控制器接口按需暴露註冊開放接口和註冊私有接口的方法,方法內部掛載路由處理方法和所需中間件。
- 處理通用參數校驗,並生成相應的通用錯誤對象。
- 定製參數校驗在控制器層定義私有方法進行處理,按需定義服務通用業務錯誤碼和描述信息,並生成業務錯誤對象。
- 消息適配器中發佈方法定義topic和msg結構並處理其序列化,訂閲方法處理msg結構的反序列化。
-
編寫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做中介。 |
其它
編碼注意
- 在異常情況/錯誤處理中,使用衞語句代替else分支減少嵌套;邏輯上並列的分支仍使用else。
- 衞語句:將異常/錯誤分支前置,判斷不通過時直接return,以降低圈複雜度(即最大嵌套層數)。
- 單個方法/函數邏輯中專用的多分支邏輯或複雜的多分支邏輯使用switch-case,多個方法/函數通用的簡單多分支邏輯使用表驅動法。
- 表驅動:用hash表存儲一組鍵值對映射,根據入參匹配key來獲取value使用,以代替等值匹配的多條件分支。
- 拼接字符串的邏輯不復雜時,推薦使用+而不是fmt庫方法。
- 能提前確定size時,調用make創建map/slice時直接傳入size,此時slice通過索引賦值元素。
- 函數/方法傳參/返回struct儘量用指針類型,slice/map則不要用指針類型。
- 在函數/方法的返回值列表中聲明的非基本類型參數不會做初始化(因為只是聲明),所以map/slice/chan/*struct類型需要在函數體內手動賦初值。
- 使用工具庫flex簡化代碼並高效實現邏輯。
併發安全
- 在無法捕獲panic的協程中做類型斷言時,使用第二個參數來預防panic。
- 可能出現併發讀寫map的場景,使用sync.Map/sync.Mutex/chan來防止panic。
- 使用細粒度的數據操作控制來預防併發寫導致的數據衝突,同時適用於緩存和數據庫。
- 多實例場景下僅需要單次執行的操作,通過分佈式鎖使後訪問數據的服務實例跳過操作。
- 只在邏輯處理有必要異步的場景下使用go協程,以避免帶來額外的維護複雜度。
示例項目
這裏提供了不同語言的參考示例項目,可以直接作為搭建新的微服務項目的代碼模板使用,同時也歡迎貢獻未收錄編程語言的示例項目。
- Go