我看到使用 htmx 和超媒體的一個常見反對意見是類似這樣的:
從伺服器回傳 HTML(而不是 JSON)的問題在於,您可能也想為行動應用程式提供服務,而且不想重複您的 API。
我已經在另一篇文章中概述過,我認為您應該將 JSON API 和超媒體 API 分割成不同的組件。
在那篇文章中,我明確建議「複製」(在某種程度上)您的 API,以便將回傳 HTML 的「變動性」網路應用程式 API 端點,從穩定、正規且具表達力的 JSON 資料 API 中解耦出來。
在回顧我與人們就這個想法進行的對話時,我認為我一直假設大家熟悉一種模式,而許多人對這種模式的熟悉程度不如我:模型/視圖/控制器 (MVC) 模式。
我有點震驚地發現在最近的 Podcast 中,許多年輕的網頁開發人員對於 MVC 沒有太多經驗。這可能是由於單頁應用程式成為常態時發生的前端/後端分割所致。
MVC 是一種簡單的模式,其歷史早於網路,幾乎可以用於任何為使用者提供圖形介面的程式。
大致概念如下:
「模型」層包含您的「領域模型」。這一層包含特定於應用程式的領域邏輯。因此,例如,聯絡人管理應用程式將在此層中包含與聯絡人相關的邏輯。它不會參考其中的視覺元素,並且應該相對「純粹」。
「視圖」層包含呈現給使用者的「視圖」或視覺元素。這一層通常(但不總是)使用模型值向使用者呈現視覺資訊。
最後,有一個「控制器」層,它協調這兩層:例如,它可能會接收來自使用者的更新、更新模型,然後將更新後的模型傳遞給視圖,以向使用者顯示更新的使用者介面。
有很多變體,但這就是核心概念。
在網路開發的早期,許多伺服器端框架明確採用了 MVC 模式。我最熟悉的實作是 Ruby On Rails,它有關於這些主題的文檔:模型被持久化到資料庫、用於生成 HTML 視圖的視圖,以及在兩者之間進行協調的控制器。
在 Rails 中,大致的概念是:
Rails 在底層 HTML、HTTP 請求/回應生命週期之上,建構了一個相當標準(儘管有點「淺薄」和簡化)的 MVC 模式實作。
在 Rails 社群中經常出現的一個概念是 「胖模型,瘦控制器」。這裡的想法是,您的控制器應該相對簡單,可能只調用模型上的一兩個方法,然後立即將結果傳遞給視圖。
另一方面,模型可能會「厚重」得多,其中包含大量特定於領域的邏輯。(有些人反對說這會導致上帝物件,但我們先把它放在一邊。)
當我們通過一個簡單的 MVC 模式範例以及它為何有用時,請牢記這個胖模型/瘦控制器的概念。
在我們的範例中,讓我們看一下我最喜歡的一個:線上聯絡人應用程式。以下是該應用程式的控制器方法,該方法透過生成 HTML 頁面來顯示給定的聯絡人頁面:
@app.route("/contacts")
def contacts():
contacts = Contact.all(page=request.args.get('page', default=0, type=int))
return render_template("index.html", contacts=contacts)
在這裡,我使用 Python 和 Flask,因為我在我的 Hypermedia Systems 書中使用它們。
在這裡,您可以看到控制器非常「精簡」:它只是透過 Contact
模型物件查找聯絡人,從請求中傳入 page
引數。
這是非常典型的:控制器的工作是將 HTTP 請求對應到某些領域邏輯,提取 HTTP 特定資訊並將其轉換為模型可以理解的資料,例如頁碼。
然後,控制器將分頁的聯絡人集合傳遞給 index.html
範本,以將它們呈現為 HTML 頁面並傳送回給使用者。
現在,另一方面,Contact
模型在內部可能相對「厚重」:all()
方法內部可能包含大量領域邏輯,執行資料庫查找、以某種方式對資料進行分頁、可能套用一些轉換或業務規則等。這很好,該邏輯被封裝在 Contact 模型中,控制器無需處理它。
因此,如果我們有這個相對完善的 Contact 模型來封裝我們的領域,您可以輕鬆地建立一個不同的 API 端點/控制器來執行類似的操作,但返回 JSON 文件而不是 HTML 文件。
@app.route("/api/v1/contacts")
def contacts():
contacts = Contact.all(page=request.args.get('page', default=0, type=int))
return jsonify(contacts=contacts)
此時,看到這兩個控制器函式,您可能會想:「這太愚蠢了,這些方法幾乎完全相同」。
您是對的,目前它們幾乎完全相同。
但是,讓我們考慮一下對我們系統的兩個潛在新增項目。
首先,讓我們在 JSON API 中新增速率限制,以防止 DDOS 或編寫不佳的自動化用戶端淹沒我們的系統。我們將新增 Flask-Limiter 函式庫
@app.route("/api/v1/contacts")
@limiter.limit("1 per second")
def contacts():
contacts = Contact.all(page=request.args.get('page', default=0, type=int))
return jsonify(contacts=contacts)
很簡單。
但請注意:我們不希望該限制套用至我們的網路應用程式,我們只希望它套用於我們的 JSON 資料 API。而且,由於我們已將兩者分開,因此我們可以實現這一點。
讓我們考慮另一個變更:我們想在我們基於 HTML 的網路應用程式中的 index.html
範本中新增每天新增聯絡人數量的圖表。事實證明,計算該圖表的成本很高。
我們不想在圖表產生時阻止 index.html
範本的呈現,因此我們將改用延遲載入模式。為此,我們需要建立一個新的端點 /graph
,它會回傳該延遲載入內容的 HTML。
@app.route("/graph")
def graph():
graphInfo = Contact.computeGraphInfo(page=request.args.get('page', default=0, type=int))
return render_template("graph.html", info=graphInfo)
請注意,在這裡,我們的控制器仍然「精簡」:它只是委派給模型,然後將結果傳遞給視圖。
容易忽略的是,我們已在我們的網路應用程式 HTML API 中新增了一個新端點,但我們尚未將其新增到我們的 JSON 資料 API。因此,我們沒有向其他非網路用戶端承諾,這個(專門的)端點(完全由我們的 UI 需求驅動)將永遠存在。
由於我們沒有向所有用戶端承諾此資料將永遠在 /graph
上提供,並且由於我們在基於 HTML 的網路應用程式中使用將超媒體作為應用程式狀態引擎,因此我們可以隨時移除或重構此 URL。
或許某些資料庫最佳化突然使圖表的計算速度變快,我們可以將其內嵌在對 /contacts
的回應中:我們可以移除此端點,因為我們沒有將其公開給其他用戶端,它只是用於支援我們的網路應用程式。
因此,我們為超媒體 API 獲得了我們想要的彈性,並為 JSON 資料 API 獲得了我們想要的功能。
就 MVC 而言,最需要注意的是,由於我們的領域邏輯已收集在模型中,因此我們可以靈活地變更這兩個 API,同時仍能實現大量的程式碼重複使用。是的,JSON 和 HTML 控制器最初有很多相似之處,但隨著時間的推移,它們會產生差異。
同時,我們沒有複製我們的模型邏輯:兩個控制器都保持相對「精簡」,並委派給我們的模型物件來完成大部分工作。
我們的兩個 API 已解耦,而我們的領域邏輯仍然集中。
(請注意,這也說明了為什麼我傾向於不使用內容協商,以及從同一個端點回傳 HTML 和 JSON。)
許多較舊的網路框架,例如 Spring、ASP.NET、Rails 都有非常強大的 MVC 概念,可讓您以這種方式非常有效地分割邏輯。
Django 有一個稱為 MVT 的想法變體。
對 MVC 的這種強力支援是這些框架與 htmx 非常搭配,並且這些社群對此感到興奮的原因之一。
而且,雖然上面的例子明顯偏向物件導向程式設計,但相同的概念也可以應用在函數式程式設計的環境中。
我希望,如果對您來說是新的概念,這能讓您對 MVC 的概念有良好的理解,並展示如何在您的網路應用程式中採用這種組織原則,可以有效地解耦您的 API,同時避免大量的程式碼重複。