Colorly | 3. 專案目錄結構 (Clean Architecture)

Colorly | 3. 專案目錄結構 (Clean Architecture)

相信身為一個 main Java 的 碼儂 火箭製造者,應該都用過 Controller、Service、Repository 經典的三層架構。這好上手,分層直觀,基本上照著寫,就算是剛畢業的新人也能輕鬆把功能跑起來。

用久了也不難發現它的限制,像是 Service 越寫越肥,一個 class 打開來幾千行(還沒寫註解), 程式過手好幾個人變成縫合怪, 維護起來壓力山大, 為了把邏輯拆出去出現各種 helper 或 utils 類別來解耦。

到後期看到某段業務邏輯拆到 helper 裡,實際上那段邏輯只會被那個 Service 用到;或者是專案裡建立 BffUtils, BizUtils,聽起來像是共用的工具,其實裡面藏了不少跟特定業務綁死的邏輯。

接著身邊不少同事開始研究 (吹捧) DDD 搭配 Clean Architecture 更有彈性, 邏輯與技術解耦, 模組之間的邊界清晰等,後續要擴充或替換元件會輕鬆很多。

剛好藉這次 Side Project 的機會來實作一下, 主要參考 Tom Hombergs 的 Clean Architecture, 其中有部分調整的地方也會特別說明。

核心概念

我認為 Clean Architecture 的實作有三大概念:

  1. 依賴方向保護核心邏輯
  2. 用語意建構程式架構
  3. 責任分離與加強替換性

下面我用一個進行中的範例來呈現:


Image-Service 依賴關係設計

常見作法是拆成 Entity、UseCase、Controller、Presenter、Gateway 這幾層, 而這裡把 UseCase 的邏輯放進了 Domain Layer 裡的 ImageService, 然後用 port 來取代 Gateway 的概念。

主要是 UseCase 相對單純, 未來再抽出來獨立維護。

整體流程大概是: Controller 接收照片後, 會先執行一連串的驗證(格式、大小、完整性等), 驗證通過後, 將圖片資訊封裝成事件, 透過 MQ 發送給後續服務。

其中由 port 來定義需要的操作, ImageValidator、ImageIntegrityValidator 和 MessageQueuePublisher 三個 interface。就像是 對外的合約,負責描述 ImageService 需要哪些功能,但不關心底層實作。

實作部分則由 adapter 負責, 如 RabbitMQPublisher, 把實際怎麼驗證、怎麼送 MQ 的細節寫在 infrastructure layer, 如果未來要換掉 MQ 或改用另一套驗證邏輯,只要改 adapter implements 就好。

對應到剛剛說的三個核心概念:

  1. 依賴方向保護核心邏輯
    ImageService 只依賴 port interface,不會碰任何技術實作。這讓核心邏輯可以在完全不依賴框架、資料庫、MQ 的情況下運作與測試,也能在未來隨時替換 adapter 而不影響主流程。

  2. 用語意建構程式架構
    每個元件的命名都與實際業務流程對應,ImageValidator 驗證圖片、MessageQueuePublisher 發送事件 (但好像還能再優化一下)。

  3. 責任分離與加強替換性
    Controller、流程邏輯、技術實作這三層之間邊界清楚,測試時也能針對單一元件做 mock,不需要啟動整個應用程式。


另外在測試方面,我是設定用 Jacoco 做測試覆蓋率檢查,重點會放在:

  • domain service、infra repo、adapter 做 unit test
  • controller 則用 integration test 來跑整體流程

雖然開發時常會聽到大家推崇 TDD, 不過我自己體感下來, 很多人其實不是不會寫測試, 而是測試環境根本還沒準備好。例如資料庫沒用 test container, 測資不好造、mock 太麻煩等等, 會導致測試寫起來反而拖慢節奏。

雖然這些都有相應的解法, 但這部分測試相關的心得應該會再另外整理一篇來分享。

專案目錄結構

說了這麼多, 就來上主菜吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
Colorly

├─.gitignore
│ pom.xml

├─analytic-service (略)..

├─auth-service
│ │ pom.xml
│ │
│ └─src
│ ├─main
│ │ ├─java
│ │ │ └─com
│ │ │ └─colorly
│ │ │ └─auth
│ │ │ │ AuthApplication.java
│ │ │ │
│ │ │ ├─application
│ │ │ │ ├─controller
│ │ │ │ │ AuthController.java
│ │ │ │ │
│ │ │ │ └─dto
│ │ │ │ LoginRq.java
│ │ │ │ LoginRs.java
│ │ │ │ OauthLoginRq.java
│ │ │ │
│ │ │ ├─config
│ │ │ │ SecurityConfig.java
│ │ │ │
│ │ │ ├─domain
│ │ │ │ ├─exception
│ │ │ │ │ AuthServiceException.java
│ │ │ │ │
│ │ │ │ ├─model
│ │ │ │ │ AuthUser.java
│ │ │ │ │
│ │ │ │ └─service
│ │ │ │ │ AuthService.java
│ │ │ │ │ CustomOidcUserService.java
│ │ │ │ │ OAuth2AuthenticationSuccessHandler.java
│ │ │ │ │
│ │ │ │ └─port
│ │ │ │ OutboxPublisher.java
│ │ │ │
│ │ │ └─infrastructure
│ │ │ ├─adapter
│ │ │ │ RabbitMqOutboxPublisher.java
│ │ │ │ RabbitMqPublisher.java
│ │ │ │
│ │ │ ├─filter
│ │ │ │ JwtAuthFilter.java
│ │ │ │
│ │ │ ├─persistence
│ │ │ │ ├─entity
│ │ │ │ │ OutboxEvent.java
│ │ │ │ │ UserCredentialEntity.java
│ │ │ │ │
│ │ │ │ └─repo
│ │ │ │ OutboxEventRepository.java
│ │ │ │ UserCredentialsRepository.java
│ │ │ │
│ │ │ └─rabbitmq
│ │ │ RabbitMQConfig.java
│ │ │
│ │ └─resources
│ │ application-uat.yml
│ │ application.yml
│ │ logback-spring.xml
│ │
│ └─test
│ ├─java
│ │ └─com
│ │ └─colorly
│ │ └─auth
│ │ │ RedisTestContainer.java
│ │ │
│ │ ├─infrastructure
│ │ │ └─repo
│ │ │ OutboxEventRepositoryTest.java
│ │ │ UserCredentialsRepositoryTest.java
│ │ │
│ │ ├─integration
│ │ │ AuthControllerTest.java
│ │ │
│ │ ├─redis
│ │ │ RedisTest.java
│ │ │
│ │ └─security
│ │ BCryptTest.java
│ │ JwtUtilsTest.java
│ │
│ └─resources
│ application.yml

├─color-service(略)

├─common(略)

├─gateway
│ │ pom.xml
│ │
│ └─src
│ ├─main
│ │ ├─java
│ │ │ └─com
│ │ │ └─colorly
│ │ │ └─gateway
│ │ │ │ GatewayApplication.java
│ │ │ │
│ │ │ ├─config
│ │ │ │ GatewayRouteConfig.java
│ │ │ │ SecurityConfig.java
│ │ │ │
│ │ │ └─infrastructure
│ │ │ └─filter
│ │ │ JwtGatewayFilter.java
│ │ │
│ │ └─resources
│ │ application-uat.yml
│ │ application.yml
│ │ logback-spring.xml
│ │
│ └─test
│ └─java
│ └─com
│ └─colorly
├─image-service
│ │ pom.xml
│ │
│ └─src
│ ├─main
│ │ ├─java
│ │ │ └─com
│ │ │ └─colorly
│ │ │ └─image
│ │ │ │ ImageApplication.java
│ │ │ │
│ │ │ ├─application
│ │ │ │ ├─controller
│ │ │ │ │ ImageController.java
│ │ │ │ │
│ │ │ │ └─dto
│ │ │ │ ColorsRs.java
│ │ │ │
│ │ │ ├─config
│ │ │ ├─domain
│ │ │ │ ├─exception
│ │ │ │ │ ImageServiceException.java
│ │ │ │ │
│ │ │ │ ├─model
│ │ │ │ │ ColorClientRq.java
│ │ │ │ │ ColorClientRs.java
│ │ │ │ │ ImageIntegrityResult.java
│ │ │ │ │ PhotoInfoMqDto.java
│ │ │ │ │
│ │ │ │ └─service
│ │ │ │ │ ImageService.java
│ │ │ │ │
│ │ │ │ └─port
│ │ │ │ ImageIntegrityValidator.java
│ │ │ │ ImageValidator.java
│ │ │ │ MessageQueuePublisher.java
│ │ │ │
│ │ │ └─infrastructure
│ │ │ ├─adapters
│ │ │ │ ImageIntegrityValidatorImpl.java
│ │ │ │ ImageValidatorImpl.java
│ │ │ │ RabbitMQPublisher.java
│ │ │ │
│ │ │ ├─client
│ │ │ │ ├─config
│ │ │ │ │ FeignTracingConfig.java
│ │ │ │ │
│ │ │ │ └─feign
│ │ │ │ ColorServiceClient.java
│ │ │ │
│ │ │ └─rabbitmq
│ │ │ RabbitMQConfig.java
│ │ │
│ │ └─resources
│ │ application-uat.yml
│ │ application.yml
│ │ logback-spring.xml
│ │
│ └─test
│ ├─java
│ │ └─com
│ │ └─colorly
│ │ └─image
│ │ └─infrastructure
│ │ └─adapters
│ │ ImageValidatorTest.java
│ │
│ └─resources
│ application.yml

├─storage-service (略)

└─user-service (略)

最後其實架構沒有什麼絕對的對或錯, 只有符不符合團隊當下的需求。

套句我教授當年吐槽我的話, 你辛辛苦苦花一堆成本搞出這些東西, 結果沒多少人用, 還不是給老闆唾棄…

當年覺得他不可理喻, 現在慢慢能理解了, 但還是超不爽的 (笑~