我傾向不使用內容協商的原因

Carson Gross

我寫了很多關於超媒體 API 與資料 (JSON) API 的文章,包括兩者之間的差異REST「真正」的含義,以及只要您的 API 與超媒體客戶端互動,HATEOAS 並不那麼糟糕的原因。

當我與來自「REST 是 HTTP 上的 JSON」世界(也就是正常世界)的人們進行討論時,我常常必須處理許多語言和概念上的問題。

最後一點常常讓習慣單一、通用 JSON API 的人覺得很笨:當您可以使用單一 API 來滿足任何數量的客戶端類型時,為什麼要有兩個 API?我試著在上面的文章中盡可能地回答了這個問題,但這當然是一個合理的問題。

與使用一個通用 API 相比,在某些方面看起來(也確實是)額外的工作。

在對話的此時,一個大致同意我對 REST、超媒體驅動應用程式等觀點的人,常常會插話說:

「喔,這很簡單,您只要使用內容協商,它已經內建在 HTTP 中了!」

我並不滿足於只疏遠通用 JSON API 愛好者,現在讓我進一步疏遠我過去的超媒體愛好者盟友,我說:

我認為對於大多數應用程式來說,內容協商通常不是返回 JSON 和 HTML 的正確方法。

#什麼是內容協商?

首先,什麼是「內容協商」?

內容協商是 HTTP 的一項功能,允許客戶端與伺服器協商回應的內容類型。在 HTTP 中完整處理實作已超出本文的範圍,但讓我們考慮 HTTP 中最知名的內容協商機制:Accept 請求標頭

Accept 請求標頭允許客戶端(例如瀏覽器)指示它願意從伺服器接收的 MIME 類型。

此標頭的一個範例值為:

Accept: text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8

這個 Accept 標頭會告知伺服器,客戶端願意接受哪些格式。偏好設定透過 q 加權因子表示。萬用字元使用星號 * 表示。

在這種情況下,客戶端正在說:

我最希望接收 text/html、application/xhtml+xml 或 image/webp。接下來我比較偏好 application/xml。最後,我會接受您給我的任何內容。

然後,伺服器可以取得此資訊,並決定提供給客戶端的最佳內容類型。

這就是「內容協商」的行為,它確實是 HTTP 的一個有趣功能。

#在 API 中使用內容協商

據我所知,Ruby On Rails 社群首先大力使用內容協商,從同一個 URL 提供 HTML 和 JSON(以及其他)格式。

在 Rails 中,這是透過控制器中可用的 respond_to 輔助方法完成的。

暫且不論 Rails 的複雜細節,您可能會有一個類似 HTTP GET/contacts 的請求,最終會調用 ContactsController 類別中的函式,如下所示:

def index
  @contacts = Contacts.all

  respond_to do |format|
    format.html # default rendering logic
    format.json { render json: @contacts }
  end
end

透過使用 respond_to 輔助方法,如果客戶端使用上述 Accept 標頭發出請求,控制器將使用 Rails 樣板系統呈現 HTML 回應。

但是,如果客戶端的 Accept 標頭值為 application/json,Rails 將為客戶端呈現一個 JSON 陣列的聯絡人。

一個非常巧妙的技巧:您可以保留所有控制器邏輯(例如查詢聯絡人),並使用一些 ruby/Rails 魔法,透過內容協商來呈現兩種不同的回應類型。除了正常的 Model/View/Controller 邏輯之外,幾乎沒有額外的額外工作。

您可以看到為什麼人們會喜歡這個想法!

#所以問題是什麼?

那麼,為什麼我認為這不是一個拆分 JSON 和 HTML API 的好方法?

這歸結於我之前暗示的 JSON API 和超媒體 (HTML) API 之間的差異。特別是:

雖然所有這些差異都很重要,並且會影響您的控制器程式碼,使其朝兩個不同的方向發展,但真正讓我選擇不在應用程式中使用內容協商的是第一個和最後一個項目。

您的 JSON API 需要是一組穩定的端點,客戶端程式碼可以依賴它們。

另一方面,您的超媒體 API 可以根據您的應用程式的使用者介面需求而發生巨大的變化。

這兩件事很難融合在一起。

為了給您一個具體的範例,請考慮一個呈現聯絡人詳細檢視的端點,例如在 /contacts/:id(其中 :id 是一個包含要呈現的聯絡人 ID 的參數)。假設此頁面有一個「相關聯絡人」的 UI 區段,此外,計算這些相關聯絡人由於某些原因很耗費效能。

在這種情況下,您可能會選擇使用延遲載入模式,將相關聯絡人的載入延遲到初始聯絡人詳細資料畫面呈現之後。這可以提高使用者頁面的感知效能。

如果您這樣做,您可能會將延遲載入的內容放在端點 /contacts/:id/related

現在,稍後,也許您可以最佳化相關聯絡人的計算。在這一點上,您可能會選擇移除 /contacts/:id/related 端點,並直接在初始頁面呈現中呈現相關聯絡人資訊。

所有這些對於您的超媒體 API 都很好:透過統一介面和 HATEOAS,超媒體設計為處理這些類型的變更。

但是,您的 JSON API…就沒那麼好了。

您的 JSON API 應保持穩定。您不能隨意新增和移除端點。是的,您可以讓某些端點回應 JSON 或 HTML,而其他端點僅回應 HTML,但這會變得一團糟。例如,如果您不小心在某處複製貼上錯誤的程式碼,會怎麼樣?

考慮到所有這些,以及速率限制等等,我認為您可以有力地論證,JSON API 和超媒體 API 之間應該關注點分離

(是的,我意識到發明了行為局部性一詞的人正在提出 SoC 論點,這是很諷刺的。)

#所以替代方案是什麼?

正如我在拆分您的 API 中提倡的那樣,替代方案是,呃,拆分您的 API。這表示為您的 JSON API 和超媒體 (HTML) API 提供不同的路徑(或子網域,或任何其他)。

回到我們的聯絡人 API,我們可能會遇到以下情況:

這種佈局表示有兩個不同的控制器,我認為這是一件好事:JSON API 控制器可以實作 JSON API 的要求:速率限制、穩定性,也許還有一個具表達性的查詢機制,例如 GraphQL。

同時,您的超媒體 API(實際上只是您的超媒體驅動應用程式端點)可以隨著您的使用者介面需求變化而發生巨大變化,具有高度調整的資料庫查詢、支援特殊 UI 需求的端點等等。

透過分離這兩個關注點,您的 JSON API 可以保持穩定、規律且低維護,而您的超媒體 API 可以是混亂、專業化和靈活的。每個都可以在自己的控制器環境中蓬勃發展,而不會彼此衝突。

這就是為什麼我寧願將我的 JSON 和超媒體 API 拆分為不同的控制器,而不是使用 HTTP 內容協商來嘗試將控制器重複用於兩者。

</>