專案筆記

六種語言寫同一套 API:一致性靠共用測試,還要讓框架「不要太聰明」

2026-09-30 #.NET#Python#Java#Node.js#PHP#Go#OpenAPI

同一套待辦清單 API 用 Node、.NET、PHP、Python、Go、Java 各寫一次,要回出一模一樣的結果,靠的不是「寫的時候小心一點」,而是一支六個版本共用的 smoke test。真正花時間的反而是關掉框架的預設行為:FastAPI 的 422、Spring 的 @RequestBody、Go 的 null 陣列,都會讓回應跟契約對不上。這是連載的第三篇,前兩篇講了部署架構與 Cloudflare Tunnel 路徑分流。

API 本身

方法 路徑 說明
GET /health 會查資料庫;DB 掛掉回 503;回傳版本號(commit SHA)
GET / 服務名稱
GET /items 列表
POST /items 新增,回 201 + Location header
GET / PUT / DELETE /items/:id 單筆操作

資料表在程式啟動時以 CREATE TABLE IF NOT EXISTS 建立,資料庫還沒好會重試。

六個版本的技術選擇

版本 技術 選擇理由
Node 22 純 node:http + pg 第一版,刻意不用框架
.NET 10 Minimal API + Npgsql 評估改用 .NET 的主要對象
PHP 8.5 FrankenPHP,無框架,PDO 一個容器、一個程序就能跑(內建 Caddy),不用 nginx + php-fpm
Python 3.14 FastAPI + uvicorn + psycopg 3 目前 Python 寫 API 最主流的組合
Go 標準函式庫 net/http + pgx Go 社群寫小型 API 常不用框架
Java 25 Spring Boot 4 + JdbcTemplate Java 後端最主流的組合

怎麼確保六個版本一致

  1. 契約:API 規格寫成 OpenAPI 3.1,CI 用 Redocly 做 lint。
  2. 共用的端到端測試:一支 smoke-test.sh,20 項檢查,包含:
    • 完整 CRUD 流程
    • 錯誤狀態碼與錯誤訊息(400、404、405、413、415)
    • 欄位名稱(snake_case)
    • Location header 帶回路徑前綴
    • 只刪除自己建立的資料,可以安全地對 staging 跑
  3. 各版的單元測試:輸入驗證用相同的案例(.NET xUnit、PHP PHPUnit、Python pytest、Go go test、Java JUnit)。
  4. 請求紀錄格式統一:每個請求一行 方法 路徑 狀態碼 耗時,例如 POST /go/items 201 5ms。成功的 /health 不記錄(healthcheck 每 10 秒打一次,會洗版)。CI 會用 grep 檢查格式。
  5. 已知差異寫下來:
    • 時間戳精度:Node 毫秒,其他各版微秒
    • GET / 的 name 不同
    • /items/%31 這種 URL 編碼的 id:Node、PHP、Go 比對原始路徑回 404;.NET、Python、Java 的框架會先解碼成 1

規則是:改其中一版的行為,其他五版也要改,並在 smoke test 補上檢查。 沒寫進測試的一致性,遲早會在某一版悄悄走樣。

讓框架「不要太聰明」

為了回一模一樣的錯誤格式(400 + {"error": "..."}),常常要關掉框架的預設行為。框架越完整,要關的越多。

Python(FastAPI)

  • 自動驗證的錯誤會回 422 + {"detail": [...]},跟契約不同。所以不用 Pydantic model 當參數,自己讀 JSON、自己檢查。
  • 路由比對失敗的 404/405 用 exception handler 改成一樣的訊息(只有 /items 相關路徑回 405,其他一律 404)。
  • 內建的 /docs 關掉。

Java(Spring Boot)

  • 404/405/未預期例外的格式,用 @RestControllerAdvice 自己接手。404 要能被攔到,需要 spring.web.resources.add-mappings=false。
  • 不用 @RequestBody,自己讀 body,才能回和其他版一樣的 415/413/400 訊息。
  • Spring Boot 4 用 Jackson 3,套件從 com.fasterxml.jackson.databind 改成 tools.jackson.*,asText() 改名 stringValue()。

Go

  • map 轉 JSON 時欄位會依字母排序({"db":…,"status":…}),要和其他版順序相同就用 struct。
  • 空的 slice 會輸出 null,要輸出 [] 需要先初始化。

PHP

  • (bool) 'f' 是 true!PostgreSQL 回來的布林值不能直接轉型。
  • 名稱長度上限要和 Node 的 String.length 一樣以 UTF-16 計算。mb_strlen 算的是字元數,emoji 會少算。所以先用 mb_convert_encoding 轉成 UTF-16,再把位元組數除以 2:
1
return intdiv(strlen(mb_convert_encoding($value, 'UTF-16LE', 'UTF-8')), 2);

容器要能被乾淨地停止

docker stop 會先送 SIGTERM,10 秒內沒結束就強制終止。如果程式卡在「等資料庫就緒」而沒處理訊號,每次部署或重啟都要白等 10 秒。

語言 問題 解法
Go 程式是容器的 PID 1,預設不處理 SIGTERM signal.NotifyContext,等 DB 時也能中斷
Python uvicorn 在啟動階段收到 SIGTERM 不會中斷 FastAPI 的 lifespan 建立資料表改放在 entrypoint,成功後 os.execvp 換成 uvicorn(PID 不變)
Java Spring 啟動中收到 SIGTERM,關閉流程會等啟動完成 migration 放在 SpringApplication.run 之前
PHP 沒有「程式啟動時」(每個請求重跑 index.php) migration 放在容器 entrypoint,背景執行並 wait,成功後 exec frankenphp run

Go 版的做法是讓等待資料庫的重試迴圈同時聽取消訊號:

1
2
3
4
5
6
7
8
9
10
11
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop()

for attempt := 1; ; attempt++ {
// ...嘗試建立資料表,成功就 break
select {
case <-ctx.Done():
return // 收到 SIGTERM,不再等資料庫
case <-time.After(time.Duration(min(attempt, 10)) * time.Second):
}
}

其他細節

  • .NET 容器內 port 是 8080(.NET 8 起的預設),compose 對應成主機的 3001。執行用的 image 要選 aspnet:10.0-alpine,標準版沒有 curl/wget,healthcheck 會失敗。
  • FrankenPHP 官方 image 沒有 PostgreSQL 驅動,用 image 內建的 install-php-extensions pdo_pgsql 安裝。
  • psycopg 連線池要設 check:資料庫重啟後池裡的舊連線會失效,借出前先檢查,重啟後的第一批請求才不會失敗。
  • Java image 分層:用 java -Djarmode=tools -jar api.jar extract --layers 把依賴(約 22MB)和程式(約 100KB)拆成不同 layer,平常部署只需下載程式那層。
  • 所有容器都以非 root 使用者執行。

實測數字(閒置時)

版本 記憶體 備註
Go 約 6MB 執行檔約 11MB
Python 約 55MB
Java 約 190MB heap 上限設 256MB,啟動約 3 秒
每個 PostgreSQL 約 25–40MB 六個版本各一個

PHP 採傳統模式,每個請求新開一次 DB 連線(約 10ms);有連線池的版本(如 Python)約 1–2ms。

小結

一致性不是靠「小心寫」,而是靠共用的測試把差異擋下來。框架的預設行為是為了一般情境設計的,一旦要對齊一份外部契約,就得清楚知道它在背後做了什麼、怎麼關掉。下一篇講 CI/CD 的細節,以及部署一次花 15 分鐘的問題怎麼解決。

系列文章

  1. 架構與 Pull-based CD
  2. 部署主機與 Cloudflare Tunnel
  3. 本篇:六種語言、同一套 API
  4. CI/CD 細節與部署加速(10/1 刊出)
  5. 前端:Cloudflare Workers 代轉(10/2 刊出)
  6. 公開與保護:Cloudflare Access 排錯全紀錄(10/3 刊出)

完整程式碼、ADR 與專案的 CLAUDE.md 都在 murmur-wu/DevBuildSample。

Creative Commons 姓名標示 非商業性

本文採用 CC BY-NC 4.0 授權

歡迎轉載與引用,請標明出處(六種語言寫同一套 API:一致性靠共用測試,還要讓框架「不要太聰明」 — mur mur);禁止用於商業用途。

商業使用或合作提案,歡迎來信洽談:[email protected]

留言
分享

留言