大類的技術手記

淺談 REST API 的設計和規劃

  • 分類:
  • 字數:雞 x 57雞數:計算文長的常見計量單位,一般而言數字大小與文章長度呈正相關

前言

工作多年,我一直都很好奇一件事,不知道 REST API 到底有什麼神奇的魔力,為什麼大家會這麼喜歡拿來說嘴?公司的面試考題中,總會有一道考題會問--請問什麼是 REST API?

究竟 REST API 有什麼好?值得大家這麼喜歡在設計 API 的時候,言必稱 RESTful 呢?

而且最玄幻的是--明明公司的設計 API 一點都不 RESTful,考這個幹啥?

雖然由於定義的模糊不清,所以就像每個人心中都有一個哈姆雷特一樣,大家心中也都有屬於自己的 REST API。

但其實還是有一些基本原則,不是只要用 HTTP 當作 API 就都是 REST API 好嗎?

因為現在的自己也時常需要面試別人,所以免不了也在思考,如果面試的時候有主考官問我這個問題,我會怎麼答呢?

而這,就是我寫下這篇筆記的原因。

什麼是 REST API?

如果面試的時候有主考官問我:「要怎麼用一句話解釋 REST API 呢?」

我會說:「以『資源』為中心,用 HTTP 方法(Method) 操作,並且符合 HATEOAS 要求設計的 API。」

所以說關鍵詞是「資源 (Resource)」。

至於什麼是「資源」?為什麼是「資源」?這個我們等會兒再說,因為詳細解釋 REST API 之前,還是要回到歷史,了解這個玩意兒到底是怎麼出現的?

它是在 2000 年由 Roy Fielding 提出的想法,全稱叫 Representational State Transfer,翻作「表現層狀態轉換」。

概念是利用傳統 Web 的特點與服務端溝通,不使用 session,每次請求都得帶上身份認證訊息,除此之外,它幾乎沒有什麼明確的規範,安全特性也都要自行處理,所以你可以說它更像是一種設計風格。

也因為如此,演變到現在其實也產生了許多歧異,好比說 REST API 是否一定要是 HTTP ?

光是這一點就有許多不同的意見,有人說是必須,但也有人說並非如此,像是 Google 就搞了一個「REST 風格」的 gRPC 規範。

又好比提出者 Roy Fielding 曾表示必須要符合 HATEOAS 的才算 REST API,但結果很多大廠設計出的「REST API」其實也沒有完全符合這個要求,大家一樣說自己是 REST API 說得很開心。

說到底其實我也不覺得完全符合 REST API 的規範就一定是好的,畢竟這仍只是「風格」而非「標準」,沒有說非要遵守 REST 風格才是好的。何況實話說大部分公司設計使用的 API,通常都也只有自己公司內部會使用,所以其實只要能符合自身業務需求,老實說怎麼定也無所謂,畢竟公司的終極目標是賺錢,不是定美美的 API 啊。

但也不是說 REST API 就一無是處,使用 REST 風格的 API 的一個重大好處是大家都相對熟悉這個規範,一個有良好設計的 REST API 幾乎可以做到給你一個 Endpoint,你就可以自行推斷出所有 API 的用法,這對使用者體驗的加成自然是顯而易見的。

但話說回來,雖然體驗很好,但可以輕易推斷 API 這一點可能也會加大了被人攻擊成功的風險就是了。

雖說本來就不應該依賴這種只能祈禱別人不知道 Endpoint 的防禦方式,但畢竟我們不是 Google,能做多少算多少不盡然是壞事,需要我們個別去權衡。

回到正題,REST 的核心就是將一切事物抽象化成「資源」這個概念,一切的操作皆是以資源為中心,我可以新增它、修改它,甚至刪除它。

一個 REST 的系統,就是一系列資源的互動,用 URI 代表資源,再透過 HTTP 本身的方法來對資源做操作,舉個例子:

GET /apis/hens   # 代表取得資源 hens (順帶一提,hen 是母雞的意思)

一般來說資源通常會是名詞(此例是 hens),所有的行為操作都靠 HTTP 方法解決,而不是直接在 URI 裡表示,因此我們不會下面這種方法新增母雞:

POST  /apis/add-hens

而是會用 POST 動作本身代表「新增」母雞這個概念:

POST /apis/hens

(再強調一次,我不是說這樣一定比較好,只是 REST API 會這麼做而已)

這個方法乍看之下簡單直覺,使用並不困難,但實際上這常需要做思維的轉換,畢竟業務邏輯這麼複雜,如何才能透過「資源」將整個業務邏輯包含在裡面?尤其是許多「動作」並不只是單純的新增、修改、刪除,那我又該如何處理呢?

好比說我想要「餵母雞」,「餵」這個動作顯然就很難簡單歸類成新增、修改、刪除的任何一種。

這時就必須要進行概念上的轉換,一種可能的方案是將「餵母雞」這件事當成一個「任務」,這樣一來「任務」就是資源,我可以用新增這個「餵母雞任務」來代表「餵母雞」,以此類推。只是個別的轉換簡單,但要讓整個業務全部自洽的轉成這種形式就不見得這麼容易了。

POST /apis/feed-hen-tasks   # 把「餵母雞任務」當作資源就可以新增了

不過正如我先前所說, REST API 只是「風格」而非「標準」,所以我覺得大部分的情況並不需要如此嚴格要求,允許少數的例外可以顯著減少設計上的難度。

談到「資源」,很多人在設計這些資源時,會習慣直接和資料庫的資料表(Table)直接做對應,但其實這個作法並不嚴謹,因為資料庫是內部使用的概念,不應該直接暴露給外界。

由於概念上的「資源」和內部實作的資料結構不一能直接對應,所以資料庫的「資料表」和「資源」也不見得會一樣。兩者有可能沒辦法直接一對一對應,有時一個「資源」可能會同時關聯多個「資料表」,也可能反過來,多個「資源」對應同個「資料表」。

要記得內部開發者和外部開發者使用者是完全兩個不同的角色,在設計的過程中我們要懂得切換自己的角色,因為兩者思考的角度是不一樣的。

內部開發者容易開發使用不代表外部開發者容易串接;反之,外部開發者容易串接的 API,內部開發者也不一定容易做到。

身為開發者,我們當然會希望盡可能設計得讓外部開發者容易使用,並且隱藏內部困難的實作細節。

其中需要判斷的是「資源」對外界來說概念是否清晰?使用上是否容易?至於內部是否有一個對應的資料表,不需要、也不應該是外部開發者需要考慮的事情。

再來就是 REST 最常被人們忽略的特性就是 HATEOAS (Hypermedia as the Engine of Application State,超媒體即應用狀態引擎)。

根據 Roy Fielding (提出 REST API 概念的那位) 在 2014 年的訪談中有提到:

「HATEOAS 並不是個選項,而是必須實現的約束,否則就不是在做 REST。」

換言之,對他而言 HATEOAS 不只是非常重要,而是必要的要求。

REST 背後的其中一個重要動機是它可以在不需要事先知道 URI 的情況下操作瀏覽資源,回傳的資源都要包含其關連資源的資訊。

白話就是回傳值必須要告訴你:「現在的狀態是什麼?」和「接下來可以幹什麼?」

當你拿到一個初始的 URI,你就可以對這個系統做任何操作,每一個回傳值都會告訴你有哪些資源可以用,從這個資源到另一個資源,其實這就是一個超媒體(Hypermedia) 的概念,把一個個資源都鏈接起來。

舉一個例子:

// GET /apis/hens/1
{
    "id": 1,
    "name": "母雞一號",
    "_links": {
        "self": {
            "href": "http://localhost:8000/apis/hens/1"
        },
        "eggs": {
            "href": "http://localhost:8080/apis/hens/1/eggs"
        }
    }
}

回傳的資源裡包含相關連的 API,這樣一來,當你收到這個回傳值後,就可以根據這些連結取得其他的資源 (這個例子就是與這隻母雞關聯的雞蛋)。

這種做法還有另一個附帶的好處,由於資源會不停的演化改變,透過 HATEOAS 可以減少假設,對客戶端來說,在資料格式相容的情況下,即使連結修改了也可以直接透過回傳值取得修改的連結,不一定需要修改程式,減少服務端和客戶端之間的偶合性。

Note

這邊可能有些人會好奇,為什麼有些 API,會像上例一樣,透過類似 self 的方式顯示自己的資源連結呢?理由是因為有些情況下,比如在建立新資源的時候,可能當下還不知道自己的 URI 為何,這時就可以透過 self 來取得。

在 2008 年時,Leonard Richardson 曾提出「成熟度模型」給 REST API 評等級:

等級 0 雖然使用 HTTP,但全都只使用同一個 URI,而且所有操作都是 POST。換言之其實就是單純把 HTTP 當作傳輸方式而已。
等級 1 針對個別資源建立不同的 URI,也就是引入「資源」的概念。
等級 2 使用 HTTP 方法來定義資源上的作業,如 GET 獲取資源,DELETE 刪除資源,並用 HTTP 狀態碼來表示不同的結果。
等級 3 符合 HATEOAS 的要求。

根據 Roy Fielding 的定義,只有等級 3 才算是真正的 REST API,但實話說,我所見的大部分「號稱自己是 REST API」的 API 也就差不多只有等級 2 而已。

但真有什麼問題嗎?好像也沒有。

所以我個人覺得也不用這麼在意 HATEOAS 就是了。

設計 REST API

知道了什麼是 REST API,那麼接下來就是討論如何設計。

設計的方式我想每個人都有自己的做法,這裡我整理一下我的思路,還有幾個我覺得要特別注意的地方。

在實際設計之前,首先是要先全局思考 API 需要的所有功能,並且整理出大概會有哪些資源和支援哪些操作,這裡可以同時參考開發者和使用者的意見。

另外還要設計資料庫,了解大概有哪些資料需要怎樣被儲存和操作。

設計資料庫的時候,要注意型態、格式要正確、大小要合理、該下的 index 要下等等,不過這個超出本文的範疇,所以就不多提了。

有了這些資訊後,我們就可以把功能全部化為一系列的 Action,比如說:

  • 農夫 (資源)
    • 列舉所有農夫
    • 取得指定的農夫
    • 修改農夫的資訊
  • 母雞 (資源)
    • 列舉所有雞
    • 取得指定的一隻雞
    • 修改雞的資訊
    • 殺掉
  • 雞蛋 (資源)
    • 找出所有蛋
    • 取得一顆指定的蛋
    • 吃掉

這時可以先決定一些基本的參數,比如說指定一隻母雞,需要母雞的 ID,找出所有蛋可能要能支援 ?hen_id=<id> 篩選指定母雞的蛋等等。

接下來就是將 action 轉為實際的 Endpoint,如:

  • GET /apis/farmers
  • GET /apis/farmers/1
  • PUT /apis/farmers/1
  • DELETE /apis/farmers/1

原則上「資源」應該都要是名詞,然後利用 HTTP 方法決定動作。資源要採用一致的命名慣例,不用完全和別人一樣沒關係,但同一個產品盡可能必須一致。

在設計 Endpoint 的時候,建議資源名稱使用複數,比如說:

GET /apis/farmers/1  # 指定的農夫
GET /apis/farmers    # 全部的農夫

之所以不用單數 apis/farmer 是因為可能會出現歧異,比如說 farmer 可能代表「全部農夫」,也可能代表「通稱概念上的農夫」,如果這是一個給農夫用的網站,單純的 /api/farmer 其實也可能解釋為「農夫自己」。

GET /apis/farmer/1  # 指定的農夫
GET /apis/farmer    # 這裡如果代表全部的農夫很怪

對我來說,這些解釋都有問題,首先對英文使用者而言,如果 /api/farmer 代表「全部農夫」,使用單數會覺得很怪;但如果代表「通稱的農夫」的話,那又要如何代表「全部農夫」呢?而且其實也不是所有資源都有這種需求;而如果代表「使用者農夫自己」,同樣也不是所有資源都有類似的需求,如果碰上資源是 garbage 豈不是很尷尬?

所以結論是不如直接全用複數比較實在。

當然這是我一家之言,但不管怎麼選擇,至少都要做到一致,我認為這是最基本的要求。

提到 ID ,最好也要小心使用 Auto Increment 的功能,像是 /farmers/1/farmers/2 ,雖然這種方式簡單好實作,但攻擊者卻能很輕易地透過腳本猜數字找到其他所有農夫。對於商業競爭者而言,也可以很簡單的透過這個數字來推估你業務的概況,而這對許多公司而言都是非常重要的機密。

為了避免這個問題,可以考慮用 Universally Unique Identifier (UUID) 或雪花算法(Snowflake) 取代使用一般的數字 ID。

在設計資源時,可以考慮將有明顯父子關係的資源用不同層級關聯在一起,通常會有不錯的效果。

比如用 /farmers/5/hens 來代表農夫 5 的所有母雞就明顯比 /farmer-hens?farmer=5 還要清楚明瞭。

但這件事不要做得太過火,把沒有明顯關聯的資源合在一起,或是把層級定得太深,比如說設計 /farmers/1/hens/99/eggs 可能就不是好的做法。因為這些資源的關聯性在未來有可能會變更,而這種做法限制了彈性。

有時候一個概念並不是這麼明確,在設計之初不容易判斷是否該當成一個獨立的資源還是某個資源的部分內容,比如剛剛例子的 egg 可能在某些業務場景會覺得並不是一個資源,而是包含在 hen 裡的內容。如果猶豫的話,建議可以直接先當成資源看待,未來再考慮多支援直接放進 hens 內當裡頭的內容。

基本上,REST API 的一個核心概念就是透過 HTTP 協定來做操作,所以設計上最好也盡可能遵守協定的要求。

比如說支援 Accept,用戶要用什麼格式,就回傳什麼格式,如果不支援就回傳 HTTP 狀態碼 415 Unsupported Media Type ,而回傳的時候,要加上 Content-Type 表示回傳的格式。

原則上最好都要支援 JSON,因為這大概是最通用的格式了,基本所有現代程式語言都有支援,而且也方便人類閱讀。

其他常見的格式:

application/x-www-form-urlencoded 內容會類似 foo=something&bar=1&baz=0 ,雖然常見,但我覺得不算是好的方法,雖然大部分的客戶端都可以處理,但讀取有時會有點麻煩,像 bar=1 的 1 可能是代表字串 1,可能是數字 1,也可能是代表 true,難以判斷。
text/xml 我覺得也不是好的方法,雖然也很常見,但同樣不太容易判斷型態,因為他把所有東西都當成字串。如果透過 attribute 表示型態也有侷限,因為使用者的實作常會忽略這段內容 (理由是不好實作)

既然提到 HTTP 協定,這裡就整理一下我們在操作「資源」的時候,可以有哪些 HTTP Method 可以使用:

GET

取得資源。

最常見的方法,可以取得所需的資源,成功就會回傳 HTTP 狀態碼 200 OK ,如果資源不存在就會回傳 404 Not Found

POST

通常用來建立新資源或是新任務。

請求內容通常會包含建立新資源所需要資訊,接著服務端便會回傳新資源的 URI 和資源的詳細內容。

如果確實建立了新資源,會回傳 HTTP 狀態碼 201 Created ,如果這個要求進行了處理,但未建立新資源,則可選擇回傳狀態碼 200 OK

有時建立的新資源沒有可回傳的內容,那麼就可以直接回傳 204 No Content

如果用戶端在建立新資源的時候,內容不合法(比如說缺失內容或格式不對等),可以回傳狀態碼 400 Bad Request ,並在回傳內容包含關於錯誤的資訊。

PUT

會建立資源或更新現有的資源。

請求內容會包含要建立或更新的資源,若具有此 URI 的資源已經存在,則會取代此資源。否則會建立新的資源 (若伺服器支援此動作),但多數情況主要都是用來更新資源內容。

與 POST 相同,如果建立新資源會回傳 201 OK ,如果更新了現有資源,就會傳回 200 OK204 No Content

在某些情況下可能會無法更新資源,這時可以考慮回傳狀態碼 409 Conflict ,並且回傳衝突的原因讓用戶端重送,比較常見的情況是上傳的資源比當前的資源還舊的時候發生。或是內容格式不對,回傳 400 Bad Request

PUT 有一個重要的特性即是等冪性。若用戶端多次送出相同的 PUT 要求,結果應該永遠保持不變。

PATCH

要求會針對現有的資源執行「部分更新」。

用戶端會指定資源的 URI。要求本文會指定要套用到資源的「變更」集。 這可能比使用 PUT 更有效率,因為用戶端只會傳送變更,而不是傳送整個資源的內容。

理論上 PATCH 也可以建立新的資源 (比如說透過指定一組「null」資源的更新),但實際上我不曾見過。

我所知使用的方式有兩種,分別是:

  • JSON 修補
  • JSON 合併修補

其中後者是相對簡單的方式,簡單來說就是直接傳和資源相同格式的內容,但只包含了想更新的欄位。

{
    "price": 12,
    "color": null, // 有時會用 null 代表要刪除該欄位的內容,但這招不一定適合所有情況
    "size": "small",
    // ... 其餘沒有要更新的欄位就不傳
}

Note

  • 如需 JSON 合併修補程式的確切詳情,請參閱 RFC 7396。
  • JSON 合併修補程式的媒體類型為 application/merge-patch+json

回傳的內容和 PUT 的情況差不多,但要注意 PATCH 並不保證冪等性。

DELETE

很簡單,就是移除指定的資源。

通常刪除就會直接回傳狀態碼 204 No Content 。畢竟都刪除了,自然也不會有內容可以回傳。而如果對應的資源不存在,則會回傳 404 Not Found 代表不存在該資源。

HTTP 狀態碼

剛才提到了很多不同的 HTTP 狀態碼,有的代表成功,有的代表失敗,雖然有很多,但大略可以分類幾類:

2xx 代表請求成功,可以再細分成單純的成功 200 OK、成功新增 201 Created 或是成功但沒有內容 204 No Content 等。
3xx 代表轉址。
4xx 代表客戶端的錯誤,代表客戶有什麼地方做錯了,比如請求的內容錯了 400 Bad Request、沒有認證 401 Unauthorized 或是沒有權限的 403 Forbidden 等。
5x 代表服務端的錯誤,如內部服務錯誤 500 Internal Server Error,身為一位後端工程師,理想上最好所有錯誤都是 4xx 而不是 5xx。

盡量就不要讓失敗只有 400 Bad Request 或是 500 Internal Server Error 這兩種回傳,使用多種不同的狀態碼來區分不同的情況可以讓前端更了解發生了什麼事,以便做出不同的應對。

回傳的內容

訂好了 Endpoint 和操作方式,接下來就是決定服務端回傳的內容。

首先自然是要先考慮安全性的問題,有些敏感資料像是密碼,雖然使用者創建的時候會需要,但是回傳的時候就不應該出現。

還有就是可讀性,回傳內容一個很重要的要點是需要根據「使用者的需求」來設計。如果目標使用者單純只是對公司內部的人還好說,畢竟可能會有別的不同因素要考量,但如果是會對外開放的 API 就不要忽略這一點。

畢竟如果用你設計的 API,用戶使用時還得不停的查文件,然後驚呼被騙,體驗就會很差。

所以不要用大家看不懂的語言、不要用奇怪的型態、不要使用自定義的縮寫、不要用自以為是的「常識」來假設用戶,這樣都可以減少用戶必須查文件的需求。

比如說型態和內容要符合使用者的預期,有時最讓人不爽的不是看不懂,而是讓使用者以為自己看得懂,但結果卻不符合預期的情況。明明欄位是「message」,但卻回傳一個數字;或欄位是「status」卻回傳 1。請問誰知道 1 代表什麼意思?是 0 代表成功還是 1 代表成功?

除了內容本身以外,考慮網速,我們還得盡可能的減少請求(Request) 的數量,但又不能一次讓使用者下載太多資料造成延遲,這兩者之間必須要取得平衡。

如果一次回應必要資訊給得不足,使用者就得被迫多打幾次請求來拿取必要資訊,造成使用者體感上的延遲和前端開發的麻煩。

那如果一次給完所有資訊呢?

也不見得是好事。

因為雖然對前端開發者來說,一次拿好資料,之後就不用再拿,開發上會比較簡單。但對真正的使用者而言,一次給太大包的資料可能會增加初次顯示的延遲,造成使用者覺得網站很慢的觀感。

而有可能大部分的資料可能不是使用者第一眼就需要看到的,可以用骨架屏顯示大致的框架和部分的內容,再依次顯示其餘的內容,雖然整體其實並不會比較快,但卻有更好的使用者體驗。

這邊提供兩個小技巧:

首先是同樣的資源不用重覆給多次。

如果評論和作者都相同,不需要給每一則評論都給一次作者資料。

// 每則評論都會有對應的作者,但有可能這些評論都是同一個作者
{
    "comments": [
        {
            "content": "頭香",
            "author": {
                "id": 1,
                "name": "兩大類"
            }
        },
        {
            "content": "一樓有病",
            "author": {
                "id": 2,
                "name": "小雞"
            }
        },
        // ...
    ]
}

// 可以把評論和作者拆開來,變成這樣
{
    "comments": [
        {
            "content": "頭香",
            "author": 1
        },
        {
            "content": "一樓有病",
            "author": 2
        },
        // ...
    ],
    // 拆出來,或是直接拆成兩個資源分別請求
    "author": {
        "1": {
                "id": 1,
                "name": "兩大類"
        },
    "2": {
                "id": 2,
                "name": "小雞"
        }
    }
}

另一個技巧就是讓使用者自行決定內容的詳細程度。有時會發生一種情況,那就是 A 畫面需要精簡的資料,而 B 畫面需要比較詳細的資料,所以 API 為了能同時支援 A、B 兩個畫面,就會直接給 B 畫面所需的所有資訊,但其實對於 A 畫面來說,這些多餘的資訊是不必要的。

但其實這件事我們可以給使用者選擇,比如說 A 畫面只需要評論內容:

// GET /comments
{
    "comments": [
        {
            "content": "頭香"
        },
        {
            "content": "一樓有病"
        },
        // ...
    ]
}

B 畫面除了評論內容還需要作者資訊,可以用 query string 的方式指定:

// GET /comments?embed=author
{
    "comments": [
        {
            "content": "頭香",
            "author": {
                "id": 1,
                "name": "兩大類"
            }
        },
        {
            "content": "一樓有病",
            "author": {
                "id": 2,
                "name": "小雞"
            }
        },
        // ...
    ]
}

這樣 A 畫面就不會拿到不需要的資訊。

至於 API 具體回傳的格式,如果公司內部本來就有規範,那自然就繼續延用。但如果沒有的話,我推薦可以參考通用標準的規範,比如說 JSON:API

一方面是溝通方便,如果開發者原本就知道這個規範就可以省去學習的成本,而且這類規範除了一些特別極端的例子,幾乎已經考慮到了所有的情況,通常應該會比少數幾個人,在趕工壓力下一拍腦袋想出來的格式還要全面許多。

這些舉一個 JSON:API 官網的例子:

{
  "links": {
    "self": "http://example.com/articles",
    "next": "http://example.com/articles?page[offset]=2",
    "last": "http://example.com/articles?page[offset]=10"
  },
  "data": [{
    "type": "articles",
    "id": "1",
    "attributes": {
      "title": "JSON:API paints my bikeshed!"
    },
    "relationships": {
      "author": {
        "links": {
          "self": "http://example.com/articles/1/relationships/author",
          "related": "http://example.com/articles/1/author"
        },
        "data": { "type": "people", "id": "9" }
      },
      "comments": {
        "links": {
          "self": "http://example.com/articles/1/relationships/comments",
          "related": "http://example.com/articles/1/comments"
        },
        "data": [
          { "type": "comments", "id": "5" },
          { "type": "comments", "id": "12" }
        ]
      }
    },
    "links": {
      "self": "http://example.com/articles/1"
    }
  }],
  "included": [{
    "type": "people",
    "id": "9",
    "attributes": {
      "firstName": "Dan",
      "lastName": "Gebhardt",
      "twitter": "dgeb"
    },
    "links": {
      "self": "http://example.com/people/9"
    }
  }, {
    "type": "comments",
    "id": "5",
    "attributes": {
      "body": "First!"
    },
    "relationships": {
      "author": {
        "data": { "type": "people", "id": "2" }
      }
    },
    "links": {
      "self": "http://example.com/comments/5"
    }
  }, {
    "type": "comments",
    "id": "12",
    "attributes": {
      "body": "I like XML better"
    },
    "relationships": {
      "author": {
        "data": { "type": "people", "id": "9" }
      }
    },
    "links": {
      "self": "http://example.com/comments/12"
    }
  }]
}

看這個例子,我想通常大部分的人需要思考的反而不是缺了什麼,而是不用什麼,所以是一份非常實用的參考資料。

當然 API 並不只是定好 Endpoint 和功能就行了,還有一些重要的議題必須考慮。

重要議題

授權 (Authentication)

首先,你怎麼知道使用你 API 的人是目標使用者而不是攻擊者呢?

在設計 API 的時候,這幾乎是不可避免必須要討論的東西。

當然了,不同的使用情境會有不同的需求,像是如果只有提供一些唯讀而且沒有敏感的資料也許就不用管這件事,又或是某些公司內部 API 可能也不需要這麼做。

(但也不好說,畢竟如果不小心讓駭客進了內網,那麼問題就大條了)

但如果授權確實是考量的話,最簡單的方式就是 Basic Authentication ,這是最基本的模式,不需要 cookies、session 甚至也不用自行實作網站登入頁面,瀏覽器會自動跳出對話框讓使用者填帳密。

但缺點是非常不安全,其原理就是將帳密用 base64 編碼後放進 HTTP 的 Header 傳給服務端,服務端再以此來確認身份。

但由於 base64 是可以輕易反編碼的,所以一旦被人攔截到封包,你的帳密就直接被人看光光了。

雖然如果網站使用 HTTPS 可以避免這個問題,至少別人無法輕易攔截封包查看裡面的內容,但兩端仍然藏不住。

身為客戶端的瀏覽器會把帳密存起來,如果有人能碰到這台電腦就有可能拿到。

服務端也是如此,使用者可能也不想讓網站維護者實際拿到自己的密碼,畢竟不少人會用同樣的帳密在不同的網站上。如果心存不良的網站持有者(或是能登上那台機器的員工)可能會藉此登入你其他網站,取得機密資訊。

所以通常我們會使用 hash 的方式在傳上服務端之前便用 hash 加密,服務端也只儲存 hash 後的結果做比對。這樣一來使用者就不需要真正上傳密碼給服務端,而服務端又能驗證使用者。

大概念是這樣,但其實還有很多細節,比如說如果單純用密碼 hash,那麼常見的密碼仍會被別人猜到,所以還需要加上「鹽」才行;而因為可能會被 Replay Attack,可能還得加上時間資訊,才不會被人透過重送同樣的封包破解;甚至 Hash 函數本身如果是用 MD5 也不成,因為 MD5 屬於已經被破解的 Hash 函式,所以必須用其他的代替等等,因為不是本文主題,所以這裡就不多提。

總之,因為要考量的點非常多,做得不好反而不安全,所以通常不會自己做,而是用一些成熟的框架解決。

其他還有第三方登入的方式,像 OAuth 等不同的方法,根據自己的業務需要來決定方案。如果你是不知名的廠商,如果不是用第三方登入,使用者可能會直接放棄使用你的網站;如果你的網站足夠大,這種做法可能反而會有反效果,所以還是得視情況而定。

另外還要注意一點,HTTP 的標準提供了一個方式可以傳遞這類密鑰的加密資訊,也就是放在 Header 的 Authorization 中,盡量不要自作聰明放在其它地方,因為其他地方有不同的用途,可能會不利於安全性。

比如說如果直接把密鑰放進 query string 傳遞,就有可能會被存進 log 或是瀏覽器的瀏覽紀錄中,可能就不是好的選擇。

但事情沒有絕對,經過合理的設計,配合一些 sign 的機制,我也是有見過放在 query string 的。

錯誤處理

在設計 API 的時候,錯誤處理絕對是非常重要的一環。其中最基本的方式就是直接用 HTTP 的狀態碼來表示。

前面有說到 HTTP 定義了非常多狀態碼可以代表失敗的情況,但畢竟是通用規則,顯然不可能滿足所有的業務需求,所以有些人會自行定義更多狀態碼,反正 400 到 499 還有很多空的狀態碼沒有用到。

但我覺得這個做法很奇怪,因為大部分的錯誤都是業務上定義的錯誤,我們不太可能直接用狀態碼表示。

所以到頭來,我們還是得另外定義一個錯誤碼來表示我們業務上的各種不同錯誤狀態。

{
    "code": "400001",  // 另外定義錯誤碼
    "reason": "小雞飛出大氣層啦!"
}

既然如此,似乎就沒有必要另外延伸定義新的狀態碼。HTTP 的狀態碼應該只用來定位大略的問題,而真正的錯誤則由裡頭的錯誤碼決定才對。

更進一步來說,即使是標準,不常見的狀態碼可能也沒必要使用,直接整合成常見的幾個即可。

畢竟真正的錯誤是用自定義的錯誤碼來判斷,那就沒必要用一些奇怪少見的狀態碼來造成前端開發者的困擾。

至於要怎麼做,我覺得還是看公司,還是前面的老話,反正大部分公司的 API 通常都也只有自己公司內部會使用,只要能符合自身業務需求,怎麼定都沒差,能用就成。

不過說是這樣說,也不要所有回傳的狀態碼都全部是 200,也不要不管客戶端錯或是服務端錯誤就全部回傳 500。

理由是因為前端所使用的函式庫很有可能會針對這2xx、4xx 和 5xx 的狀態碼有不同的處理,如果把應該是 4xx 的狀態碼給成 500 反而可能會造成前端開發人員的困擾。

版本 (Versioning)

事情並不是做完就結束,業務會不停地變動,需求會不斷地來,假如不來,那表示你就沒事可幹,那麼老闆就會把你幹掉,所以你最好祈禱事情永遠做不完……

所以說 API 鐵定是會持續更新的。

(如果你還沒被老闆幹掉的話)

但是對外開放的 API,也不能說改就改,畢竟你也不可能要求客戶做到即時更新,所以這時服務端就必須同時提供新舊多個版本的 API 才行。

至於怎麼做?方法有很多,一個常見的做法就是直接將版號放在 URI 裡,如:

http[s]://api.marco79423.net/v1/hens
http[s]://api.marco79423.net/v2/hens

這個方法的好處是非常好管理,直接改 v1 和 v2 即可,對服務端和客戶端都相當簡單,所以通常是第一個採用的做法。

至於缺點的話,概念上不太 RESTful,「資源」應該比較像是永久連結,理論上不應該可以修改,如果說 Internet 是藉由連結互相連結而產生的,改來改去就會爆炸。

以此例來說,明明「雞(hen)」這個資源就是同一個概念,卻用不同的 URI,會讓人覺得「難道 v1 版的雞(hen) 和 v2 版的雞 (hen) 有什麼本質的不同嗎?

不過我個人覺得這比較像是在挑毛病,並不是什麼大的問題,所以有名的案例有很多,像是 Disqus、Tumblr、Twitter、Youtube 等都是這麼做的。

另外似乎也有人用 host 區分:

http[s]://api-v1.marco79423.net/hens/1
http[s]://api-v2.marco79423.net/hens/2

這個方法理論上很簡單,幾乎有剛才的方法的所有好處,而且分不同 Server 很容易,甚至可以輕易做到 v1 和 v2 用完全不同程式碼實現。

但實務上,管理域名的和實作 API 的時常是不同的部門,遠不如實作者自行控制(不同版本直接改路由)比較容易。

而且這個方法同樣也有前者的缺點,因為不同版本的資源還是用不同的 URI。

如果不想改路由,另一個可能的方案就是把版本資訊放在 body 裡,如:

POST /apis/hens HTTP/1.1
Host: marco79423.net
Content-Type: application/json

{
    "version": "1.0"
}

這個方案好處是路由是一致的,但缺點是不同的 Content-Type 會有不同的回傳方式,如果碰到 JSON 或是 XML 好說,但碰到 CSV 或是 JPG/PNG 這類的格式就麻煩了。

如果是放 query string 的話,也等於是在改路由,那倒不如用第一種方法比較方便。

剩下的選擇就是放 Header 了,比如說:

GET /apis/hens HTTP/1.1
Host: marco79423.net
APIVersion: 1.0

但要注意使用自定義的 Header 可能會有 Cache 問題,所以回傳必須要加上 Vary 才能正確運作,類似這樣:

HTTP/1.1 200 OK
APIVersion: 1.0
Vary: APIVersion

不過這種方式使用者可能會不容易注意到版本的變化,畢竟不會有多少人會檢查回傳回來的 Header。而且自定義 Header 就等於要求使用者必須要看文檔才會知道,畢竟有可能是 API-Version 也有可能是 X-Api-Version ,如果不看文檔,誰會猜得出是哪一種啊?

但你說有沒有著名案例呢?還真的有,那就是很愛搞自定義規則的微軟的 Azure。

(怎麼感覺好像一點都不意外?)

話說回來,既然都可以接受放 Header 了,為什麼不直接用 Content Negotiation 的方式判斷版本呢?畢竟 Accept 本來就是設計用來指定資源的不同格式,所以用來指定版本感覺也很合理?

所以 github 就是這麼做的:

# 格式:application/vnd.github[.version].param[+json]
Accept: application/vnd.github+json
Accept: application/vnd.github.v3+json

這樣的好處是放在 Accept 不會有快取的問題,同時也不會有 URL 不一致的狀況,最重要的是非常 RESTful。

另外 Facebook 還有一種比較特別的做法,叫做「 Feature Flagging」,那就是每個 App 都可以設定自選的版本。

如果 API 有更新,就會主動傳訊息給開發者哪些 APP 使用到的 API 更新了,要求使用者調整。

如果改動不影響開發者,開發者就可以選擇 Enable,如果會影響,就可以先暫停。

⋯⋯但幾個月以後還是會強迫更新。

這個方法的好處是官方可以不用一直支援舊版的 API,只需要維護一份和一小部分新的 API 而已,而對使用者來說,如果改的是與 App 無關的功能也不用擔心出問題。

壞處是當使用者轉換 API 的過程中,可能會有一瞬間不能用的情況發生,因為你不能先放 new code 上去。結果為了解決這個問題,可能要在同時寫支援兩個版本才行。

更大的壞處是如果你不是在 facebook 這種超強的大公司,大概很難強迫開發者這麼做。

雖然前面說了這麼多方法,但也許最好的方法是直接詢問你的目標使用者他們想要的是什麼?畢竟每間公司的業務場景可能都不太一樣,不管什麼方法,如果目標使用者覺得不好用就沒意義了。

後記

洋洋灑灑的寫了一大堆,是我在這些年寫的一些筆記和心得。

而我也不知道所有事,我所能做的也只是盡我所能分享我所見的、所知的使用心得,因此不必然我說的就是比較好,雖然可以拿來當參考,但也僅此而已,不必奉為圭臬。

雖然其實還是很多沒寫,像是原本想寫非同步任務、傳大型檔案、服務端主動推送、API 文件之類的,但再多下去估計就沒人看了,所以就先這樣吧,如果真有人有興趣,我再回頭為這篇文章加料,讓這篇文章長到天荒地老,喔喔喔喔喔喔~~~

以上。

⋯⋯雖然估計現在就已經長到不會有人想看就是了。

參考資料