為什麼要拆多 Repo
專案要跟外部團隊合作,對方只能看到、用到特定範圍的程式碼,不能把整個專案暴露出去,所以後端、前端都必須拆成多個獨立 repo。但交付端的要求沒變:後端最終要組裝成單一 fat JAR 部署,前端則是多包靜態資源丟在同一個 Nginx 上。開發期間也希望前端能跨 repo 直接整合測試,不用每次都等別人發版。
這帶出三個核心挑戰:
- 前端多個 Angular app 要共享元件、共享狀態,但各自獨立部署
- 後端多個 Spring Boot 服務要互相呼叫,但不能用 Maven compile-time dependency 直接引入(會循環依賴)
- 跨服務的請求要能串起來追蹤,方便 debug
先講結論:這次選的 Modular Monolith 不是過渡到微服務的中間站,是要長期用下去的目標架構。 唯一的理由是交付時就只能有一個 JAR——FeignClient 模式順便保留了「以後真要拆成微服務,業務邏輯零改動」的彈性,但那只是附帶好處,不是現在的路線圖。
GitLab Group
shell(組裝層):backend/(JWT 認證、模組組裝、CORS)+ frontend/(Host, 4200)
feature-a:backend/(domain 邏輯,發布 JAR 到 Nexus)+ frontend/(Remote, 4201)
feature-b:backend/(domain 邏輯,發布 JAR 到 Nexus)+ frontend/(Remote, 4202)
shared:backend/ → 共用 DTO(JAR)/frontend/ → 共用元件(Angular library)
↓ 依賴 / import
Nexus(maven-snapshots/releases、npm-group/registry)
↓
shell fat JAR(單一部署單位,port 8080)
├── feature-a module → Controller → GET /api/v1/xxx ←──┐ HTTP(localhost)
└── feature-b module → XxxClient(@FeignClient)────────┘
前端 Host(4200)動態載入 feature-a remote(4201)+ feature-b remote(4202)每個 feature repo 內部同時放 backend/ 跟 frontend/,因為團隊是全端制、按功能垂直切分,不是前後端分組。
前端多 Repo:Webpack 5 Module Federation
Module Federation 是 Webpack 5 的原生功能,讓多個獨立打包的應用在 runtime 動態載入彼此的模組,不需要在 build time 就把所有東西打包在一起。
角色分三種:
- Host:主應用,負責載入其他模組
- Remote:子應用,把自己的模組暴露給 Host 用
- Shared:雙方共用的套件(如 Angular core),避免重複打包
// webpack.config.js(Remote 端)
new ModuleFederationPlugin({
name: 'remoteApp',
filename: 'remoteEntry.js',
exposes: {
'./Component': './src/app/some/component',
},
shared: {
'@angular/core': { singleton: true }, // Host/Remote 間只會有一個實例
'@angular/common': { singleton: true },
},
})
// webpack.config.js(Host 端)
new ModuleFederationPlugin({
remotes: {
remoteApp: 'remoteApp@http://localhost:4201/remoteEntry.js',
},
shared: { ... },
})singleton: true 對 Angular 是必要設定,不然 Host 和 Remote 各自跑一份 Angular,DI 系統直接壞掉。Remote URL 在不同環境會換成實際部署位址,抽到 environment.ts,Host 端透過 DefinePlugin 注入。
還沒解決的問題:多個 Feature Team 各自獨立發布 Remote 時,Host 跟所有 Remote 的 Angular 版本相容性要有人盯著——目前靠前後端都各自維護的 shared library 統一版本定義,但「consumer 有沒有真的升級」還是人工決定,沒有強制機制。
後端多 Repo:為什麼選 Modular Monolith
想到後端要多 repo,第一直覺是拆成微服務加 API Gateway。但交付要求是單一 fat JAR,所以這次先不拆真微服務,改用 Modular Monolith:每個後端模組仍是獨立 repo、獨立開發、獨立 CI,但最終組裝成一個 fat JAR 共同部署,模組之間透過 FeignClient 打 HTTP(同一個 JVM 內的 localhost 呼叫,仍會走 DispatcherServlet),不是直接 inject Java bean。
| 架構 | 核心概念 | 這次為什麼沒選 / 選了 |
|---|---|---|
| 傳統單體 | 所有功能綁一個專案部署 | 模組邊界會混在一起,多團隊協作不適合 |
| 模組化單體 | 邏輯上拆模組,部署仍是一個服務 | 選了——滿足單一 JAR 的交付要求,同時保留模組獨立開發 |
| 微服務 | 物理獨立、各自部署 | 沒有獨立部署/擴展的真實需求,維運成本不值得 |
| 事件驅動 | 透過事件非同步溝通 | 這裡是同步查詢場景,不需要 eventual consistency |
| 六角/乾淨架構 | 核心邏輯與外部技術解耦 | 不是這次要解決的分層問題 |
「REST + @FeignClient interface」驗證的是通訊介面的定義方式,不要求模組必須跑在不同 process。@FeignClient interface 本身就是提供方與消費方共同遵守的介面定義,可以放進共用的 contracts library,雙方都參照同一份。
取捨:
- 比起直接 inject Java bean,多了一層 HTTP overhead,量級在 1–5ms(這是估計值,還沒做正式 load test),一般業務場景可接受
- 比起真微服務,少了獨立部署/擴縮的彈性,換來更低的維運複雜度
- 各模組各自有獨立 repo / CI / 版本號,多 repo 開發協作模式是真的驗證到了;只有「部署」這一層收斂成單一 process
FeignClient 基本寫法
@FeignClient(name = "service-b", url = "${service-b.url}")
public interface ServiceBClient {
@GetMapping("/api/resource/{id}")
ResourceDto getResource(@PathVariable String id);
}// 使用端直接注入 interface
@Service
public class ServiceALogic {
private final ServiceBClient serviceBClient;
public void doSomething(String id) {
ResourceDto resource = serviceBClient.getResource(id);
}
}設定步驟:加 spring-cloud-starter-openfeign dependency → 主類加 @EnableFeignClients → 定義 @FeignClient interface → application.yml 設定 target URL。記得搭配 Spring Cloud BOM 管理版本,錯誤處理建議用 ErrorDecoder 統一處理 4xx/5xx。
兩個實際踩過的坑
坑①:回應物件反序列化失敗
跨模組共用的回應包裝物件(例如 ApiResponse<T>)一開始用一般 getter/setter 寫法,FeignClient 收到回應後 Jackson 反序列化不出來。
// 加上 @JsonCreator + 建構子參數的 @JsonProperty
public class ApiResponse<T> {
private final int code;
private final T data;
@JsonCreator
public ApiResponse(@JsonProperty("code") int code, @JsonProperty("data") T data) {
this.code = code;
this.data = data;
}
// getters...
}明確告訴 Jackson 怎麼從 JSON 建構物件,不能依賴預設的無參建構子 + setter。
坑②:JWT Authorization header 沒有跟著 Feign 呼叫傳遞
原本的 JWT Authorization header 只存在於原始 request,FeignClient 發出的是一個全新的 HTTP request,不會自動帶上原 request 的 header,下游模組收到請求後認證失敗。
@Component
public class FeignAuthInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
String authHeader = attrs.getRequest().getHeader("Authorization");
if (authHeader != null) {
template.header("Authorization", authHeader);
}
}
}
}從目前 request context 取出 Authorization header,手動加到每個 Feign 呼叫上。目前還沒解的情境:這個機制依賴「呼叫鏈裡有原始使用者 request」,如果呼叫是排程或非同步觸發、沒有原始 request context,目前沒有對應的服務間認證設計。
分散式追蹤:MDC + X-Correlation-ID
多個服務各自記 log,出事時無法把同一個 request 在不同服務的 log 串起來。
// Filter:收到 request 時,取出或產生 Correlation ID,存入 MDC
@Component
public class CorrelationIdFilter extends OncePerRequestFilter {
private static final String CORRELATION_ID_HEADER = "X-Correlation-ID";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String correlationId = request.getHeader(CORRELATION_ID_HEADER);
if (correlationId == null || correlationId.isBlank()) {
correlationId = UUID.randomUUID().toString();
}
MDC.put("correlationId", correlationId);
response.setHeader(CORRELATION_ID_HEADER, correlationId);
try {
filterChain.doFilter(request, response);
} finally {
MDC.remove("correlationId");
}
}
}// FeignClient RequestInterceptor:呼叫下游服務時把 ID 帶過去
@Component
public class FeignCorrelationIdInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
String correlationId = MDC.get("correlationId");
if (correlationId != null) {
template.header("X-Correlation-ID", correlationId);
}
}
}logback-spring.xml 的 pattern 加上 %X{correlationId},log 裡就能看到串接的 ID:
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%X{correlationId}] %-5level %logger{36} - %msg%n</pattern>還沒踩到的坑:MDC 是 thread-local,非同步(@Async、reactive)情境需要額外處理傳遞,目前還沒有解法。這次 POC 模組間呼叫都在同一個 fat JAR 的 in-process HTTP(localhost),還沒真正拆成多個獨立 process,所以「Correlation ID 真的需要跨網路傳遞」這個情境也還沒被驗證到。
CI/CD 階段設計
後端模組 repo(library JAR,發布到 Nexus):
stages:
- build
- publish-snapshot # develop branch → SNAPSHOT
- publish-rc # release/* branch → RC
- publish-release # production branch → RELEASE組裝層 repo(fat JAR + 前端,部署到環境):
stages:
- build
- deploy-sit # develop branch → 自動部署 SIT
- deploy-uat # release/* branch → 手動核准部署 UAT
- deploy-production # production branch → 手動核准部署 PROD老實說目前的限制:deploy-sit / deploy-uat / deploy-production 的 script 還是 echo 模擬指令,原因是 SIT/UAT/PROD 環境本身還沒建好,不是部署機制沒設計——stage 跟 branch 觸發規則的邏輯已經能用 branch 對應規則驗證,只是還沒有真實環境可以接上去跑。
已驗證 vs 還沒驗證
| 驗證點 | 狀態 |
|---|---|
| 多 repo 各自開發、各自 CI | ✅ 已驗證 |
| 前端跨 repo 整合(Module Federation) | ✅ 已驗證 |
| 共用 library 版本管理(Nexus) | ✅ 已驗證 |
| 後端跨 repo 呼叫(FeignClient) | ⚠️ 部分驗證——同一 fat JAR 內的 HTTP,還不是真正跨 process |
| 分散式追蹤跨網路傳遞 | ❌ 還沒驗證 |
| 真實環境部署 | ❌ 還沒驗證 |
| 跨模組契約自動化防呆(如 contract testing) | ❌ 還沒做,目前靠人工同步 |
這套架構不是為了把系統切得多炫,而是在「外部團隊權限隔離」「單一 JAR 交付」「跨 repo 開發便利性」三個現實限制中找一個都顧到的解法。Modular Monolith + FeignClient 剛好卡在這個交集上:多 repo 開發體驗有了,循環依賴解了,部署複雜度沒有暴增。代價是還留了幾個沒解的坑——容錯機制(circuit breaker)、契約自動化驗證、真實多 process 部署——這些等實際排上日程再補。