我這兩年在用的全端架構:monorepo、微服務與多租戶是怎麼組起來的
這兩年我參與的專案幾乎都長在同一套架構上,Next.js 前端、NestJS 的 API Gateway 跟微服務、以網域辨識租戶的多租戶設計,全部放在一個 monorepo 裡。這篇從一個請求的路徑開始,把整套架構怎麼組起來、每一層負責什麼、多租戶怎麼貫穿前後端整理一遍。後端跟基礎設施是同事設計的,我從前端的角度來寫。
目錄
這兩年我參與的專案,不管是能源管理、微電網還是後來的案子,幾乎都是從同一套架構長出來的。新專案開始的時候不是從零建專案,而是複製這套架構,把業務的部分換掉。
我在這套架構裡負責的是前端,後端跟基礎設施是同事設計的。這篇不講實作細節,講它怎麼組起來、每一層負責什麼,還有多租戶這件事怎麼從瀏覽器一路貫穿到資料庫。
一個 repo 裝全部
整套系統放在一個 monorepo 裡,用 pnpm workspace 管套件、Turborepo 管建置。資料夾分三層:
apps/ 是會各自跑起來的應用程式,包括 Next.js 前端、API Gateway、業務服務(例如能源管理)跟排程服務。
libs/ 是後端服務共用的程式碼,DTO、entity、enum、介面都在這裡,服務之間傳訊息用的名稱也是從這裡的 enum 來的。
packages/ 放 ESLint 跟 TypeScript 的共用設定,每個 app 都繼承同一份。
放在一起最大的好處是型別只有一份,前端呼叫的 API 回傳什麼、服務之間傳什麼,都是同一份定義,改了就全部一起跟著改。另外一個好處是指令,turbo build 會照相依順序把全部建好,lint 跟型別檢查也是一道指令跑完。
從一個請求看整套架構
瀏覽器發出的一個請求會經過這些地方:
瀏覽器
└─ Next.js(頁面+API Routes 當 BFF)
└─ API Gateway(NestJS,HTTP)
└─ 業務服務(NestJS microservice,TCP)
└─ PostgreSQL(每個租戶一個 schema)旁邊還有幾個不在這條路徑上的東西。租戶服務負責回答「這個網域是哪個租戶」,通知服務負責寄信跟推播,電表訂閱服務透過 MQTT 接設備資料寫進資料庫,排程服務跑定時任務。服務之間的非同步訊息走 RabbitMQ,設備資料走 EMQX,快取用 KeyDB,檔案放 MinIO。
前端:Next.js 加上 API Routes 當 BFF
前端是 Next.js App Router,資料夾用 Feature-based 的結構,這個我之前寫過一篇。
比較特別的是前端有八十幾條 API Routes,瀏覽器不直接打 Gateway,全部先打自己的 API Routes,再由它轉給 Gateway。這一層就是所謂的 BFF(Backend for Frontend)。
會多這一層主要是為了 token。登入用 next-auth 的 Credentials Provider,拿到的 access token 存在加密的 JWT cookie 裡,瀏覽器端的程式碼碰不到它。
API Routes 在伺服器端把 token 解出來加到 Authorization header。
多租戶需要的幾個 header 也在這裡統一加,租戶的網域從請求的 host 取,目前操作的公司從瀏覽器帶上來的 x-current-company 取。
還有像設備即時資料這種 SSE 串流,也是由 API Routes 轉送,瀏覽器只需要面對同源的端點。
一個典型的 API Route 長這樣,取 token、組 header、轉發:
// app/api/devices/route.ts
export async function GET(req: NextRequest) {
const token = await getToken({ req })
if (!token?.accessToken) {
return NextResponse.json({ error: '未授權' }, { status: 401 })
}
const response = await fetch(`${GATEWAY_URL}/devices`, {
headers: {
Authorization: `Bearer ${token.accessToken}`,
'x-tenant-domain': req.headers.get('host')!.split(':')[0],
'x-current-company': req.headers.get('x-current-company') ?? '',
},
})
return NextResponse.json(await response.json(), { status: response.status })
}前端的元件跟 hooks 完全不用知道 token 跟租戶的事,它們只是呼叫 /api/devices。
API Gateway:單一入口
所有請求進後端只有一個門,就是 Gateway。它是一個普通的 NestJS HTTP 應用程式,但自己不做業務邏輯,工作是驗證、辨識租戶、轉發,還有把所有服務的錯誤跟回應整理成同一種格式。
每個請求進來先配一個 request id,之後的 log 都帶著它,跨服務追問題的時候用得到。接著租戶的 middleware 看 x-tenant-domain,拿著網域去問租戶服務這個租戶對應哪個資料庫 schema,問到了就把 schema 名稱放進 header 往下傳。
轉發的部分,Gateway 的 controller 對應到服務的 message pattern,把請求的資料跟 header 一起包成一個訊息,用 NestJS 的 ClientProxy 送過去:
protected sendWithTenantHeader(pattern: string, data?: unknown) {
return this.client.send(pattern, {
...data,
headers: {
'x-tenant-schema': this.request.headers['x-tenant-schema'],
'x-current-company': this.request.headers['x-current-company'],
authorization: this.request.headers['authorization'],
},
})
}租戶的資訊就這樣跟著每一個訊息走到服務那一端,服務不用再查一次。
服務:NestJS microservice
每個業務服務都是 NestJS 的 microservice,用 TCP 跟 Gateway 溝通,一個 @MessagePattern 對應一個操作,能源管理的服務裡有兩百多個。服務收到訊息後,從 header 拿到 schema 名稱,之後的資料庫操作都在那個 schema 裡進行。
服務跟服務之間如果需要非同步的事件,例如設備告警要觸發通知,走 RabbitMQ。設備資料則是另一條路,電表透過 MQTT 把量測值送到 EMQX,電表訂閱服務收下來寫進資料庫。
多租戶:從網域到 schema
多租戶貫穿了這套架構的每一層。
辨識租戶靠的是網域,每個租戶有自己的網域,同一套程式碼同一組服務,用網域分辨現在服務的是誰。前端從 host 取出網域、Gateway 拿網域換 schema、服務用 schema 操作資料。
資料的隔離在 PostgreSQL 的 schema 這一層。一個租戶一個 schema,連線的時候設定 search_path,之後的查詢自動落在那個租戶的資料表裡,程式碼本身不用每一句 SQL 都帶租戶條件。
服務裡有一個 DataSource 的管理員,依 schema 建立並快取連線設定,還有一個 request scope 的資料庫連線 provider,確保同一個請求裡的操作都在同一個租戶底下。
migration 跟 seed 也是以租戶為單位跑,新租戶加進來就是建一個 schema、跑一次 migration。
租戶底下還有一層公司。一個租戶可能有好幾間公司或廠區,使用者在不同公司裡可以有不同的角色,所以前端會記住使用者目前操作的公司,每個請求都帶 x-current-company,後端用它過濾資料跟檢查權限。
設備的量測資料量很大,這部分用 TimescaleDB 的 hypertable 存,查一段時間的用電趨勢才不會慢。
基礎設施與開發環境
所有的服務跟基礎設施都是容器,用 Docker Compose 起來。開發的時候有一個 dev 容器把整個 monorepo 掛進去,在裡面跑 turbo dev,前端、Gateway、服務同時起來。Makefile 包了常用的指令,重建某一個服務、跑 migration、灌測試資料都是一行。
部署的部分有兩套 compose,一套給雲端,一套給需要地端安裝的客戶,差別主要在網路跟儲存的設定,程式本身不用改。CI 跑測試跟 SonarQube 的程式碼掃描,架構文件用 arc42 的章節跟 C4 的圖放在 docs/。
當成起點的意義
把這些放在一起,新專案要開始的時候,前端拿到的是一個已經有登入、多租戶、權限、i18n、feature 結構的 Next.js,後端拿到的是已經有 Gateway、租戶服務、通知、排程、資料庫隔離的一組服務。要做的是把業務服務換成新案子的內容,前端把 feature 換掉。
我經手的幾個案子都是這樣開始的,第一週就能有登入畫面跟後台骨架,接下來的時間都花在業務本身。
回顧
從前端的角度,這套架構讓我最有感的是 BFF 那一層把 token 跟租戶的事全部擋在伺服器端,前端元件只管畫面跟資料。再來是多租戶靠幾個 header 從瀏覽器一路帶到資料庫,每一層只做自己那一小段,沒有哪一層需要知道全貌。
還有 monorepo 讓前後端共用型別,介面改了編譯器會先告訴你,不用等到串接才發現對不上。
後端跟基礎設施是同事設計的,我比較像是這套架構的使用者,前端的部分才是我做的。寫這篇是想把它整理成一張地圖,之後不管是誰接手還是我自己回頭看,都能快速知道一個請求從瀏覽器出發之後經過了哪些地方。