
系列:30 天打造企業級 PLM|面向:後端|素材:API 層實際程式碼
系統做到後期,REST API 已經幾百支。如果每支 API 的回應格式、錯誤格式都是當下心情決定的,前端每接一支都要重新猜:資料在 data 還是直接在 root?錯誤看 HTTP status 還是看 body?分頁的總筆數叫 total 還是 totalElements?今天講 Mini-PLM 的 API 慣例,以及一個把「統一」做過頭的教訓。
以前要跟 Oracle Agile PLM 進行系統整合,基本上是一場 Java EE 遠端協定的修行:
agile-api.jar 與 WebLogic client libraries,透過 AgileSessionFactory.createSession() 建立遠端 EJB 代理連線。只要外部系統的 Java 版本或 classpath 中的 client jar 稍微不一致,Java RMI 序列化錯誤(InvalidClassException)隨時引爆;更別提如果外部是 Python、Node.js 或 C#,要呼叫 EJB 簡直是天方夜譚。SOAPFaultException。Mini-PLM 徹底摒棄了任何私有 SDK 與 SOAP 依賴,回歸現代標準的 RESTful JSON API(JSON over HTTP)。無論前端 React、自動化腳本或第三方 ERP 系統,都能用最原生的 HTTP client 呼叫。

理想上全系統一個版本前綴,現實是演進中的系統一定新舊並存:既有端點維持相容、新規範端點統一格式。務實的規則是舊的不搬(搬遷是純風險)、新的一律走新規範,並在 Security 設定裡一併納管(Day 6)。
列表回應統一包成 TableResultResponse(實際程式碼,節錄):
@Data
public class TableResultResponse<T> implements Serializable {
private List<T> data;
private long total;
private Boolean success;
private String message;
public TableResultResponse(List<T> allData) {
data = allData;
total = data.size();
success = true;
}
public TableResultResponse(Page<T> allData) {
data = allData.getContent();
total = allData.getTotalElements(); // 分頁時 total 是全量筆數
success = true;
}
public TableResultResponse(T oneData) { // ← 今天的主角
data = new ArrayList<T>();
data.add(oneData);
total = 1;
success = true;
}
}
好處很直接:ProTable 等前端表格元件吃 { data, total, success } 幾乎零轉換;Page<T> 建構子讓 Spring Data 分頁一行變回應。
注意那個 TableResultResponse(T oneData) 建構子,單筆資料也被包成 data: [node]。當初的想法是格式全統一,前端永遠讀 data 陣列。實際發生的事:
前端 create 完一筆資料,直覺地讀
response.data當物件用 →data其實是陣列 → 取屬性全是undefined→ 前端報錯給使用者看。但後端其實建立成功了。使用者看到錯誤訊息,再按一次送出。恭喜,重複資料誕生。
修正方式是前端規範「create 回應一律取 data[0]」。更深的教訓:統一的價值在降低心智負擔,當統一格式反而讓語意變模糊(單筆偽裝成列表),它就在製造心智負擔。重來一次的話,單筆回應會直接回物件,列表才包 TableResultResponse。
錯誤處理集中在一個 @RestControllerAdvice,每類例外對應固定的 HTTP status 加錯誤碼(實際程式碼,節錄):
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
/** 處理資源不存在錯誤。 */
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ApiErrorResponse> handleNotFound(...) {
return build(request, HttpStatus.NOT_FOUND, "NOT_FOUND", ...);
}
/** 處理資源衝突錯誤(如:單號重複)。 */
@ExceptionHandler(ConflictException.class)
public ResponseEntity<ApiErrorResponse> handleConflict(...) {
return build(request, HttpStatus.CONFLICT, "CONFLICT", ...);
}
/**
* 處理必填欄位缺漏錯誤(Start/Review 推進前的驗證)。
* 回傳 400 + code=REQUIRED_FIELDS_MISSING,
* 並在 ApiErrorResponse.missingFields 帶回缺漏的欄位名稱清單。
*/
@ExceptionHandler(RequiredFieldMissingException.class)
public ResponseEntity<ApiErrorResponse> handleRequiredFieldMissing(...) {
// ... base.getBody().setMissingFields(ex.getMissingFields());
}
/** 處理業務規則錯誤。 */
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiErrorResponse> handleBusiness(...) {
return build(request, HttpStatus.BAD_REQUEST, "BUSINESS_RULE_VIOLATION", ...);
}
}
設計上有兩件事想強調。
第一,錯誤碼帶結構化資料。REQUIRED_FIELDS_MISSING 不只說有欄位沒填,還在 missingFields 帶回是哪些欄位,前端可以直接在對應欄位上標紅,而不是彈一個籠統的錯誤框。這是 Day 8 動態表單「後端守門」的另一半。
第二,業務例外與技術例外分層。service 層丟 BusinessException、ConflictException 這類語意化例外,handler 統一翻譯成 HTTP 語言。service 不需要知道 HTTP 的存在。

MethodArgumentTypeMismatchException(例如 ?page=abc 傳給 int 參數)早期沒有專屬 handler,落入兜底邏輯回 500 Unhandled。後果是監控系統把「使用者手滑打錯網址」當成「系統內部錯誤」告警,值班的人白緊張一場。後來補上專屬 handler 改回 400。
原則很簡單:4xx 是你的請求有問題,5xx 是我的系統有問題。分錯邊的代價是監控噪音,以及狼來了效應。
這是 API 層最早、也最貴的一筆學費。Form 查詢 API 起初的想法很樸素:前端什麼都要,那就什麼都給——controller 直接回傳 JPA entity,讓 Jackson 把整個物件序列化成 JSON,一行 return form; 搞定,多省事。
然後帳單分三期寄到:
第一期:循環引用。 JPA 的雙向關聯(Form 有 FormData、FormData 又指回 Form;Workflow 有 Steps、Step 又指回 Workflow)在 Jackson 眼裡是一條走不完的路,序列化直接無限遞迴到 StackOverflow。第一次看到一個查詢 API 把整個服務打掛,就是這樣來的。
第二期:速度。 就算把循環擋掉了,序列化 entity 等於把「碰得到的關聯」全部拖下水——回一筆 Form,Jackson 順手觸發 lazy loading 把 workflow、steps、actions、附件 metadata 全家撈出來,一筆資料背後是十幾條 SQL。開發環境十筆資料看不出來,正式環境列表頁一開就是幾百筆 × 十幾條查詢,秒級回應變十秒級。更陰的是 LazyInitializationException:session 關了才碰 lazy 欄位就爆,當時的直覺反應是把關聯改 EAGER、把 open-in-view 開著——等於把慢永久地焊死在每一條查詢上。
第三期:打地鼠的考古層。 中間有很長一段時間的對策是 @JsonIgnore:哪裡循環貼哪裡,哪裡洩漏貼哪裡。今天翻開 entity 目錄還能看到這段歷史——38 個 entity 檔案裡留著 105 處 @JsonIgnore,每一處都是一次爆炸的彈痕。它能止血,但止不了根本的錯位:API 的輸出形狀被資料庫 schema 綁架了,加個欄位、改個關聯,API 契約就跟著默默變動,前端永遠在猜這次回應長什麼樣。
最後的解法是老老實實把序列化邊界當成契約邊界:entity 不出 service 層,出口一律走 Response DTO。現在 response/ 目錄下有 92 個 *Response 類,Form 查詢出口統一由組裝函式(buildFormResponse)把 entity 翻譯成 DTO——要哪些欄位、label 怎麼轉、沒權限的欄位怎麼處理,全部在這一層說清楚;列表再用 TableResultResponse 包 DTO。循環沒了(DTO 沒有反向引用)、查詢可控了(組裝函式只抓需要的資料,抓法可以批次化)、契約穩了(schema 改動不再自動外洩)。
教訓一句話:entity 是持久層的形狀,不是 API 的形狀;@JsonIgnore 是止血帶,DTO 才是手術。順帶一提,這條原罪後來還換了張臉回來過一次——把整包 entity 圖序列化進 log 欄位,默默撐出 1GB 的 LOB——足見「直接序列化 entity」這個誘惑有多持久,值得在 code review 清單上永久掛號。

慣例一致、包裝有語意、錯誤分層,就這三件事。其中「統一但不過度統一」與「序列化邊界=契約邊界」都是花錢買來的:單筆包陣列那筆學費換來對「一致性」多一分警惕;105 處 @JsonIgnore 的彈痕則換來一條鐵律——entity 永遠不直接出 API。
明日 Day 6:JWT Stateless 認證與 Spring Security——以及「授權規則表越長,越沒人敢動」的簡化決策。