那個我維護了四年的對外 API:v2、錯誤碼,與「別人會用很久」這件事
後端工程師,設計與維護過多個對內對外的 API。
我在某家交易所待的那幾年,有一套對外的 REST API 是我從頭設計、然後接著維護了大概四年的。它服務的是做量化、做搬磚、做自動下單的那群人——他們不是偶爾打一下,是寫好程式掛在那裡七天二十四小時打。這種使用者跟一般 App 後端的使用者完全是兩種生物,他們會用到你 API 的每一個角落,會依賴你每一個欄位的型別、每一個錯誤碼的含義,連你回傳欄位的順序他們都可能在解析時偷懶寫死。
「設計一個會被別人用很久的 API」這句話,我以前覺得是漂亮話。真正體會它的重量,是在你某天想改一個小欄位、結果發現要先去翻三個下游的程式碼、再寫一封公告、再給三個月遷移期的時候。
這篇我想拉這套 API 當主線,講三件我真的踩過的事:一次不得不開 v2 的破壞性改動、一次因為錯誤碼設計太爛而炸掉的事故,還有「改一個欄位到底要顧慮多少人」的真實感受。分頁我只會點到,因為站內另有一篇專門講游標分頁的,那套細節我不想在這裡重講。
先說那套 API 長什麼樣
它就是很典型的交易所對外介面:查行情、查帳戶餘額、下單、撤單、查歷史成交、查訂單狀態。路徑一開始就帶版本,像 /v1/orders、/v1/account/balance 這樣。當初會把版本放路徑裡,不是因為我多有遠見,老實說是因為我看過前公司一個沒版本的 API 被改到天怒人怨,所以這次學乖了,第一天就把 v1 寫進去。
回頭看,這個決定救了我至少兩次。版本放路徑這件事最大的好處不是技術上多優雅,而是它逼著你跟自己約定:v1 這條線一旦發出去,裡面的東西就只能加、不能改、不能刪。這個約定一開始覺得綁手綁腳,後來變成我最感激的護欄。
那次不得不開 v2 的改動
事情的起因是訂單狀態。
v1 設計的時候,我把訂單狀態設成一個字串欄位,叫 status,值有 NEW、FILLED、CANCELED 這幾個。當時想得很單純,覺得這幾個夠用了。問題出在後來業務要支援「部分成交」這種狀態——一張單成交了一半,另一半還掛在簿子上。
理論上我可以加一個新值叫 PARTIALLY_FILLED 就好,加一個 enum 值聽起來是往後相容的吧?不是。我吃過這個虧。因為下游的客戶端是這樣寫的:
- 有人寫成 if status == FILLED 就當作這單結束了,把它從本地的掛單表移掉
- 有人寫成 switch status,剩下的 default 直接當 NEW 處理
- 最慘的是有人寫 if status != NEW and status != CANCELED 就視為完全成交,拿去更新他自己的庫存
你一加 PARTIALLY_FILLED,第三種人就直接資料錯亂——半成交被他當成全成交,他的庫存帳就歪了,而且歪得無聲無息,不會報錯。enum 加值對「會窮舉判斷」的 client 來說,本質上就是破壞性的。這是我第一個刻骨銘心的教訓:對外 API 的 enum,新增一個值不是免費的,它會打破所有「假設值是固定那幾個」的程式碼。
光是這一個還不至於逼我開 v2。真正逼我的是另一個結構問題一起爆出來:v1 的成交資訊是平鋪在訂單物件上的,executedQty、avgPrice 直接掛在訂單下面。但部分成交、然後再分多筆成交之後,使用者開始要「每一筆成交明細」,我發現我得在訂單裡塞一個 fills 陣列,而且每筆 fill 裡的手續費幣別、手續費金額這些,跟原本平鋪的欄位語意上會打架。要在 v1 硬塞,整個物件會變成新舊兩套欄位並存的怪物,文件根本講不清楚哪個該信哪個。
我評估下來,這已經不是加欄位能解決的,是訂單物件的形狀要重新長。於是開 v2。
v2 怎麼開,才能讓舊 client 不爆
開 v2 最大的恐懼不是寫新版,是怕舊版那群掛了好幾個月沒人維護的程式突然全死。所以我給自己定了幾條規矩:
第一,v1 和 v2 同時活著,v1 完全不動。新的 /v2/orders 走新結構,舊的 /v1/orders 一個字節都不改。這點要做到,後端就不能讓兩個版本共用同一段序列化邏輯——我那時候是讓 v1 跟 v2 走各自的 response 組裝層,底下共用同一份 domain model,但往外吐的形狀分開維護。多寫一份 mapping 很煩,但這是讓 v1 凍結的代價,值得。
第二,給長到不像話的遷移期。我當時公告的是 v1 維護到次年底,等於給了快十四個月。對內部來說這很痛,等於有一年多要同時揹兩套介面、改 domain model 的時候每次都要想「這會不會影響到 v1 的輸出」。但對那些掛著機器人跑套利的使用者來說,你給他三個月他根本來不及——很多人是「程式跑著沒出事我就不會碰」的那種,你不給夠長的緩衝,到期那天就是一片哀嚎。
第三,主動測舊 client,不要只靠公告。我做了一件後來覺得很值得的事:把幾個大客戶實際在打的請求 pattern,自己寫了一組回放測試,每次動到共用的 domain model,就跑一遍確認 v1 的輸出 byte 級別沒變。因為「往後相容」這四個字很容易自我感覺良好,你以為你只是改了內部,結果某個欄位的序列化順序變了、某個數字的小數位數變了,對方寫死的解析就掛了。
第四,新舊之間給一個明確的對照表。我在文件裡放了一張 v1 欄位對到 v2 哪裡去的表,連「這個欄位在 v2 改叫什麼、為什麼拆開」都寫清楚。遷移期最花使用者時間的不是改 code,是搞懂「我原本依賴的那個值現在在哪」。你把對照表做好,等於幫所有人省下各自摸索的時間。
整個 v2 從設計到 v1 真正下線,前後拖了一年半。這件事教會我的是:破壞性改動的成本,九成不在你寫新版花多少力氣,而在你要陪舊版走完多長的路。
那次因為錯誤碼太爛而炸掉的事故
版本是「設計沒做好要付的長期利息」,錯誤碼則是「設計沒做好會直接出人命」的那種。
v1 早期我的錯誤回應設計得很懶,大概就是 HTTP 狀態碼加一段 message 字串,像這樣:狀態碼 400,body 裡一句 message 寫「餘額不足」。當時覺得很夠用啊,人看得懂就好。
問題就出在「人看得懂」這四個字。打我 API 的是程式不是人,而且我那段 message 文字還會隨著文案調整偷偷變。有一次行銷那邊要我把幾個錯誤訊息的措辭改得「友善一點」,我想說 message 又不是契約,改文字而已,就改了。
結果出事。有個下單量很大的客戶,他的風控邏輯是去比對 message 字串——他寫死了 if message.contains("餘額不足") 就暫停這個帳戶的自動下單去補錢。我把「餘額不足」改成「您的可用餘額不足以完成此筆委託」之後,他的字串比對失效了,風控沒觸發,程式在餘額不夠的情況下繼續瘋狂重試下單。雖然每筆都被我擋下回 400,但他那邊以為是網路問題不斷重送,幾分鐘內把請求量打到觸發我的限流,連帶影響到同一個風控群組的其他帳戶。
追這個事故的時候我一開始完全沒往「改文案」想,因為在我認知裡那不算改 API。直到對方工程師把他比對字串的那段 code 貼給我看,我才意識到問題:在 v1 裡,我從來沒有給過一個穩定的、機器可判讀的錯誤碼,於是使用者只能去 parse 我給人看的文字。那段文字一旦被我當成可以隨便動的東西,它就成了一顆不定時炸彈。
這件事之後我把錯誤回應整個重新設計,原則到今天我都還在用:
- 每個錯誤一定有一個穩定的、不會變的錯誤碼,獨立於 message 之外。我用的是帶前綴的字串碼,像 INSUFFICIENT_BALANCE、ORDER_NOT_FOUND、RATE_LIMITED 這種,而不是純數字。純數字碼到後面你自己都記不得 4017 是什麼,帶語意的字串碼至少看 log 時人讀得懂
- message 明確定位成「給開發者看的、可能會變的」,並且在文件裡白紙黑字寫「請不要對 message 做字串比對,請用 code」。我把這句話放在錯誤章節最上面,加粗
- 錯誤碼一旦發布就跟欄位一樣是契約,只能加新的、不能改舊的含義。INSUFFICIENT_BALANCE 永遠是餘額不足,不會哪天被我拿去表示別的意思
- HTTP 狀態碼歸狀態碼、業務錯誤碼歸業務錯誤碼,兩者分工。狀態碼負責「這大類是誰的錯」(400 你的請求、401/403 認證授權、429 你太快、500 我的鍋),錯誤碼負責「具體是哪件事」。我看過有人想用 HTTP 狀態碼表達所有業務語意,搞到自己發明一堆奇怪的 4xx,那是另一種災難
回頭看那次事故,技術上一點都不難修,補一個錯誤碼欄位的事。但它讓我真正懂了一件事:對外 API 裡,凡是你沒有明確宣告「這個可以變」的東西,使用者都會當成「這個不會變」來依賴。連你以為只是裝飾的錯誤訊息文字都不例外。
分頁這塊,我只說一句
歷史成交、歷史訂單這種列表端點,我當然有做分頁,沒分頁的列表端點在交易所這種資料量下根本是自殺,一個查全部歷史的請求就能讓你的 DB 哭給你看。
v1 早期我用的是 offset 那種翻頁,後來在「邊翻頁、邊有新成交插進來」的場景吃了重複跟遺漏的虧——使用者翻到第二頁的時候,因為前面插了新資料,第一頁最後一筆又跑到第二頁開頭,他就拿到重複的成交,或反過來漏掉。這個坑跟為什麼我後來改用游標、游標的 token 怎麼設計才不會被人猜、怎麼處理刪改,站內有另一篇專門講游標分頁的文章,我在那裡講得比較完整,這裡就不重複那套對比了。這裡只留一句結論:列表端點從第一天就要分頁,而且「會即時長新資料」的列表,offset 遲早會咬你。
「別人會用很久」到底是什麼感覺
講完三個故事,我想把那個一開始覺得是漂亮話的命題,換成具體的感受。
「別人會用很久」對我來說最真實的畫面,是有一次我想把訂單回應裡一個沒什麼人用、長得有點醜的欄位拿掉。就一個欄位,內部大家都覺得它是歷史包袱。我打算動手之前,習慣性去查了一下到底有誰在讀它——結果是有的,有兩個量不大但很穩定的客戶在用,而且其中一個還是用它來對帳。
那一刻我才真的懂:在對外 API 裡,沒有「沒人用的欄位」這種東西,只有「你還沒發現誰在用的欄位」。一旦發出去,它就不再屬於你了。你想刪一個欄位,要做的事不是改 code,是去確認沒人依賴、不確定就得發公告、給遷移期、可能還得開新版本。一個欄位的去留,背後是一整套社會成本。
這也徹底改變了我加東西的態度。以前我加欄位很隨便,覺得多給點資訊是好事。現在我加任何對外的欄位之前都會想一句:這個我以後拿得掉嗎?因為加進去是一秒鐘的事,拿掉是一年的事。能不對外暴露的內部細節,我就盡量不暴露,因為你暴露的每一樣東西,都是你未來要替別人維護的承諾。
所以如果要我講設計長壽 API 最核心的心法,不是什麼版本策略或錯誤碼規範那些可以條列的東西——那些都只是手段。真正的核心是一種覺悟:你寫下的每個欄位、每個錯誤碼、每段你以為只是裝飾的文字,只要它出了門,就有可能變成某個你永遠不會見到面的工程師半夜被叫起來救火的原因。想清楚這件事,你自然會在加東西時保守、在改東西時慎重、在凍結舊版時甘願多揹一年的重量。會被用很久的 API,不是設計得多聰明,是設計的人願意為很久以後、為素未謀面的人,提前把後悔的空間留好。
留言討論
有想法、有不同經驗、或想糾正我?歡迎在下面留言,免註冊,填個暱稱就能留。