同一套待辦清單 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 後端最主流的組合 |
怎麼確保六個版本一致
- 契約:API 規格寫成 OpenAPI 3.1,CI 用 Redocly 做 lint。
- 共用的端到端測試:一支
smoke-test.sh,20 項檢查,包含:- 完整 CRUD 流程
- 錯誤狀態碼與錯誤訊息(400、404、405、413、415)
- 欄位名稱(snake_case)
Locationheader 帶回路徑前綴- 只刪除自己建立的資料,可以安全地對 staging 跑
- 各版的單元測試:輸入驗證用相同的案例(.NET xUnit、PHP PHPUnit、Python pytest、Go go test、Java JUnit)。
- 請求紀錄格式統一:每個請求一行
方法 路徑 狀態碼 耗時,例如POST /go/items 201 5ms。成功的/health不記錄(healthcheck 每 10 秒打一次,會洗版)。CI 會用 grep 檢查格式。 - 已知差異寫下來:
- 時間戳精度: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 | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) |
其他細節
- .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 分鐘的問題怎麼解決。
系列文章
- 架構與 Pull-based CD
- 部署主機與 Cloudflare Tunnel
- 本篇:六種語言、同一套 API
- CI/CD 細節與部署加速(10/1 刊出)
- 前端:Cloudflare Workers 代轉(10/2 刊出)
- 公開與保護:Cloudflare Access 排錯全紀錄(10/3 刊出)
完整程式碼、ADR 與專案的 CLAUDE.md 都在 murmur-wu/DevBuildSample。
留言