模型/視圖/控制器 (MVC)

Carson Gross

我看到使用 htmx 和超媒體的一個常見反對意見是類似這樣的:

從伺服器回傳 HTML(而不是 JSON)的問題在於,您可能也想為行動應用程式提供服務,而且不想重複您的 API。

我已經在另一篇文章中概述過,我認為您應該將 JSON API 和超媒體 API 分割成不同的組件。

在那篇文章中,我明確建議「複製」(在某種程度上)您的 API,以便將回傳 HTML 的「變動性」網路應用程式 API 端點,從穩定、正規且具表達力的 JSON 資料 API 中解耦出來。

在回顧我與人們就這個想法進行的對話時,我認為我一直假設大家熟悉一種模式,而許多人對這種模式的熟悉程度不如我:模型/視圖/控制器 (MVC) 模式。

#MVC 簡介

我有點震驚地發現在最近的 Podcast 中,許多年輕的網頁開發人員對於 MVC 沒有太多經驗。這可能是由於單頁應用程式成為常態時發生的前端/後端分割所致。

MVC 是一種簡單的模式,其歷史早於網路,幾乎可以用於任何為使用者提供圖形介面的程式。

大致概念如下:

有很多變體,但這就是核心概念。

在網路開發的早期,許多伺服器端框架明確採用了 MVC 模式。我最熟悉的實作是 Ruby On Rails,它有關於這些主題的文檔:模型被持久化到資料庫、用於生成 HTML 視圖的視圖,以及在兩者之間進行協調的控制器

在 Rails 中,大致的概念是:

Rails 在底層 HTML、HTTP 請求/回應生命週期之上,建構了一個相當標準(儘管有點「淺薄」和簡化)的 MVC 模式實作。

#胖模型/瘦控制器

在 Rails 社群中經常出現的一個概念是 「胖模型,瘦控制器」。這裡的想法是,您的控制器應該相對簡單,可能只調用模型上的一兩個方法,然後立即將結果傳遞給視圖。

另一方面,模型可能會「厚重」得多,其中包含大量特定於領域的邏輯。(有些人反對說這會導致上帝物件,但我們先把它放在一邊。)

當我們通過一個簡單的 MVC 模式範例以及它為何有用時,請牢記這個胖模型/瘦控制器的概念。

#MVC 風格的 Web 應用程式

在我們的範例中,讓我們看一下我最喜歡的一個:線上聯絡人應用程式。以下是該應用程式的控制器方法,該方法透過生成 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)

在這裡,我使用 PythonFlask,因為我在我的 Hypermedia Systems 書中使用它們。

在這裡,您可以看到控制器非常「精簡」:它只是透過 Contact 模型物件查找聯絡人,從請求中傳入 page 引數。

這是非常典型的:控制器的工作是將 HTTP 請求對應到某些領域邏輯,提取 HTTP 特定資訊並將其轉換為模型可以理解的資料,例如頁碼。

然後,控制器將分頁的聯絡人集合傳遞給 index.html 範本,以將它們呈現為 HTML 頁面並傳送回給使用者。

現在,另一方面,Contact 模型在內部可能相對「厚重」:all() 方法內部可能包含大量領域邏輯,執行資料庫查找、以某種方式對資料進行分頁、可能套用一些轉換或業務規則等。這很好,該邏輯被封裝在 Contact 模型中,控制器無需處理它。

#建立 JSON 資料 API 控制器

因此,如果我們有這個相對完善的 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 速率

首先,讓我們在 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。)

#MVC 框架

許多較舊的網路框架,例如 SpringASP.NET、Rails 都有非常強大的 MVC 概念,可讓您以這種方式非常有效地分割邏輯。

Django 有一個稱為 MVT 的想法變體。

對 MVC 的這種強力支援是這些框架與 htmx 非常搭配,並且這些社群對此感到興奮的原因之一。

而且,雖然上面的例子明顯偏向物件導向程式設計,但相同的概念也可以應用在函數式程式設計的環境中。

#結論

我希望,如果對您來說是新的概念,這能讓您對 MVC 的概念有良好的理解,並展示如何在您的網路應用程式中採用這種組織原則,可以有效地解耦您的 API,同時避免大量的程式碼重複。

</>