產業新聞

.NET 的演變:在現代網路應用程式中整合 AI 和行動原生功能

Milan Jovanović 最近發表了一篇強烈反對過早進行 API 版本化的論點。 核心觀點:大多數團隊過早地使用 v2,因為他們缺乏合約演進策略。 版本化是一種相容性工具,而不是設計策略。

這個論點引起了 Iron Software 工程團隊的共鳴。 我們發佈 .NET 程式庫,這意味著我們產品的公共介面就是一個 API。 每個方法簽名,每個屬性,每個預設行為都是數千個客戶程式碼庫中的合約。 一個主要版本的提升不是一次發布。 這是每個下游的遷移計劃。

以下是從程式庫作者的角度看 Milan 的文章,以及無論您是發佈 REST API 還是 NuGet package,如何應用相同的相容性規則。

簡而言之

  • 版本化不是設計策略。 當共存失敗時,它是逃生出口。
  • 破壞性變更隱藏在行為中,而不僅僅是在 URL 或模式中。
  • 四個相容性規則:不要移除,不要改變處理過程,不要收緊驗證,保持新增加項的可選性。
  • 新操作幾乎總是比新版本更便宜。
  • 真正的棄用需要運行時信號和遙測,而不僅僅是文件更新。

HTTP 規則也適用於程式庫 API

Milan 將討論設置在 /orders 的 REST API 上,但當您的 API 是一個在 NuGet 套件中發佈的公共 C# 類時,相同的規則也適用。 映射是直接的:

REST API 變更NuGet 程式庫等效
重命名 JSON 字段重命名公共屬性
移除端點移除公共方法
收緊請求驗證新增不可空參數
改變操作行為改變方法在底層的作用
新增必需字段新增必需構造函式參數

如果您曾經拉取過一個流行的 .NET 程式庫的主要版本,並花了半天修復重命名的 API,那麼您就是受到了v2 決策的影響,而該問題很可能可以用增量處理來處理。

什麼實際上會破壞消費者

Milan 的列表很精準:

  • 移除或重命名字段
  • 改變現有資料的含義
  • 收緊請求驗證
  • 更改分頁或錯誤格式
  • 假設類似枚舉的值永遠是封閉的

第二項最常令團隊措手不及:改變現有資料的含義而不改變其形狀。 JSON 看起來一樣。 C# 簽名看起來一樣。 一切都可以編譯。 運行時沒有拋出錯誤。但字段現在的意思是不同的,所有依賴於舊語義的消費者都默默地錯了。

Milan 的例子:

// Before
{ "total": 100 }

// After
{ "total": { "amount": 100, "currency": "USD" } }

同名字段。 相同的端點。 每個用作數字解析 total 的客戶端現在都壞了。

程式庫等效為改變方法返回的內容或其解釋其輸入的方式。 一個以前覆寫現在追加的 Save() 方法。 預設從 true 翻轉為 falseTrim 參數。 一個曾在無效輸入時拋出並現在靜默返回預設值的方法。

四個相容性規則

Milan 將這些規則總結為:不要拿走任何東西,不要改變處理規則,不要將可選的東西變為必需,任何新增項都必須是可選的。 這四個原則值得任何負責公共 API 的團隊放在心上:

  1. 保留現有字段和行為。
  2. 不要將可選的請求資料變為必需資料。
  3. 不要改變現有操作的作用。
  4. 將任何新增項預設為增量和可選的。

這些與程式庫設計直接對應。 "不要拿走任何東西" 意味著不要刪除公共成員。 "不要改變處理規則" 意味著現有方法應該保持它們發佈時的行為。 "不要將可選的變為必需" 意味著不要向現有方法新增必需參數; 而是提供一個重載。 "增量和可選" 意味著新功能屬於新方法或帶有合理預設值的可選參數。

這在實踐中如何實現

用實際的 API 判斷來說明這些規則最清晰,所以這裡有我們的一個例子。

幾個版本之前,IronPDF 需要支持更豐富的選項集以進行 HTML 到 PDF 的轉換:自定義紙張尺寸、自定義邊距、CSS 媒體模擬、頁眉和頁腳模板等。 直接的方法是改變現有的渲染方法以接受新選項。 該決定會破壞所有使用 API 簡單形式的客戶。

作為上下文,程式庫通過標準 .NET 包通道安裝:

# .NET CLI
dotnet add package IronPdf

# Package Manager Console
Install-Package IronPdf
# .NET CLI
dotnet add package IronPdf

# Package Manager Console
Install-Package IronPdf
SHELL

IronPdf NuGet package 累計下載超過1800萬次,這是 API 穩定性的重要原因:每次破壞性的更改在這麼多的整合間波動。

我們發佈的方法是:

// The original, three-year-old API. Still works. Still unchanged.
var renderer = new ChromePdfRenderer();
PdfDocument pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>");

// New rendering options live on an options object, not in the method signature.
var renderer = new ChromePdfRenderer();
renderer.RenderingOptions.PaperSize = PdfPaperSize.A4;
renderer.RenderingOptions.MarginTop = 20;
renderer.RenderingOptions.CssMediaType = PdfCssMediaType.Print;
renderer.RenderingOptions.HtmlHeader = new HtmlHeaderFooter { HtmlFragment = "..." };
PdfDocument pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>");
// The original, three-year-old API. Still works. Still unchanged.
var renderer = new ChromePdfRenderer();
PdfDocument pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>");

// New rendering options live on an options object, not in the method signature.
var renderer = new ChromePdfRenderer();
renderer.RenderingOptions.PaperSize = PdfPaperSize.A4;
renderer.RenderingOptions.MarginTop = 20;
renderer.RenderingOptions.CssMediaType = PdfCssMediaType.Print;
renderer.RenderingOptions.HtmlHeader = new HtmlHeaderFooter { HtmlFragment = "..." };
PdfDocument pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>");
' The original, three-year-old API. Still works. Still unchanged.
Dim renderer As New ChromePdfRenderer()
Dim pdf As PdfDocument = renderer.RenderHtmlAsPdf("<h1>Hello</h1>")

' New rendering options live on an options object, not in the method signature.
renderer = New ChromePdfRenderer()
renderer.RenderingOptions.PaperSize = PdfPaperSize.A4
renderer.RenderingOptions.MarginTop = 20
renderer.RenderingOptions.CssMediaType = PdfCssMediaType.Print
renderer.RenderingOptions.HtmlHeader = New HtmlHeaderFooter With {.HtmlFragment = "..."}
pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>")
$vbLabelText   $csharpLabel

有關該決定的三個觀察:

  1. 原始 RenderHtmlAsPdf(string html) 簽名未改變。 升級的客戶不需要修改一行程式碼。
  2. 新功能存在於一個選項物件上,消費者可以選擇加入。 該方法沒有新增必需參數。
  3. RenderingOptions 的預設值生產輸出等同於先前的 API。 對於沒有配置任何東西的人來說,行為是保持不變的。

這是 Milan 清單中第1、2 和 4 條規則一次應用的結果。 產品更新了。 合同沒有。

發佈 RenderHtmlAsPdfV2(string html, RenderingOptions options) 的誘惑是真實的。 它會使 API 參考頁顯得更整潔。 它會令每位客戶付出遷移的成本。 我們選擇了別的方法。

容忍讀者

Milan 的 "增加而不是替代" 論點的另一半是消費者也要承擔責任。 表現良好的客戶端應該忽略它不了解的字段。

在 .NET 中,System.Text.Json 預設忽略未知屬性,這是正確的預設值。 風險通常出現在兩個地方:

  • 具有嚴格模式的生成 SDK 會拒絕意外字段
  • 合約測試斷言精確的 JSON 相等性

兩者都將所聲稱的"我們忽略未知字段"保證轉化為觸發線。如果您的 CI 在伺服器新增一個可選屬性時崩潰,您就沒有向後相容性。 您有一個偽裝成相容性策略的回歸檢測器。

行為也是合約的一部分

Milan 關於 DELETE /orders/{id} 悄然從軟刪除轉為硬刪除的章節是我們所見到的對此議題最清晰的表述。

URL 是一致的。 請求主體是一樣的。 響應的形狀是一樣的。 伺服器上的操作是不同的。

這是一個最危險的破壞性變化類別,因為沒有什麼能在模式差異中捕捉到它。 OpenAPI 規範是相同的。 生成的客戶端能編譯。 綜合測試通過。 而每一個基於 "被刪除的訂單是可恢復的" 構建工具的消費者卻在生產中默默破壞資料。

程式庫等效為改變方法的作用而不改變它的簽名。 我們明確避免的例子有:

  • 一個以前同步刷新的 Save() 方法悄然變為異步即拋後忘記
  • 返回原始結果的OCR方法開始對其進行後處理
  • 在不可讀輸入時拋出的條碼閱讀器開始返回一個空字串

每一個這些都是包裝成改進的合約破壞。 正確的響應與 Milan 一樣:新增一個方法或選項,保持舊的行為不變,只有當遙測顯示安全時才廢棄舊的路徑。

驗證收緊

這個類別最終會影響每個團隊。 同一錯誤有兩個變體:

  • 將現有的可選字段變為必需的
  • 新增一個字段並從第一天起標記為必需

兩者都會破壞舊客戶端。 端點路徑沒有移動,但以前成功的請求現在在運行時失敗。

這個錯誤的程式庫版本是新增一個必需的構造函式參數或將現有的可選參數設為必需。 每個現有的調用者在編譯時被破壞,這比運行時失敗更可取,但它仍然對每個消費者造成遷移成本。

更安全的路徑:

  • 在過渡窗口期間接受遺漏值並參考預設值。
  • 新增一個新的重載或生成器,要求更豐富的輸入形狀。
  • 為更嚴格的工作流程引入一個新操作或構造函式。

根本規則是一致的:任何新增到合約中的都必須是可選的,任何先前可選的必須保持可選。 如果確實需要嚴格的要求,則它們應位於新操作中,而不是在現有操作中收緊。

新操作幾乎總是比新版本更便宜

這是最值得內化的原則。

當一個用例的演進已超過現有端點的清晰支持時,常見的反應是用標誌覆蓋端點:

POST /orders?validateOnly=true&includeTaxEstimate=true&reserveInventory=true

或者,更具破壞性的是,將改變聲稱為一個版本控制問題,開始 /v2/orders 的工作。 這兩者通常都是錯的。 更清晰的方法是一個與現有操作並存的新操作:

POST /orders
POST /orders/quote
POST /checkout-sessions

每個操作都有一個清晰的合同、獨立的權限、獨立的驗證和自己的演進路徑。 原始的端點保持簡單。 其餘的 API 不被拖入主要版本提升。

在程式庫環境中,等效的是新增一個新方法,而不是用可選參數進行重載直到它變得不可讀。 ExtractText() 仍然是簡單的文字提取器。 而ExtractTextWithLayout() 成為了更豐富的變體。 ExtractStructuredDocument() 成為了最豐富的。擁有清晰合同的三個方法比帶有八個可選參數的一個方法更可取。

刻意棄用

這是 API 更改管理最被跳過的一半,也是決定策略是否奏效的一半。

真正的棄用不是更改日誌中的一個註釋。 它涉及四個步驟:

  1. 在 OpenAPI 描述中標記字段或端點為棄用(或在 .NET 的世界中用 [Obsolete] 屬性)。
  2. 在運行時信號棄用,以便活流量將其表面化。
    3.連結到實際的遷移指南。
  3. 使用遙測測量使用,以確定何時移除是安全的。

對於 HTTP API,運行時信號是直接的:

Deprecation: true
Sunset: Wed, 31 Dec 2026 23:59:59 GMT
Link: <https://docs.example.com/migrations/orders-total>; rel="deprecation"

對於 .NET 程式庫,等效的是 [Obsolete("Use NewMethod instead. 這將在 v2026.x 與 attribute paired with a 被移除 UrlFormat] 指向一個遷移頁面。 編譯器警告在每個消費者的構建輸出中出現,診斷標識符允許故意抑制,連結為消費者提供了一個文件化的遷移路徑。

遙測步驟是不可協商的。 在不知道哪些客戶仍然依賴於棄用的方法時,移除就變得毫無根據。 結果要麼是過早的移除,破壞活躍的整合,要麼是無限期的攜帶成本,破壞了棄用的目的。

何時版本控制是正確的決策

Milan 不是反版本控制,我們也不是。 版本控制適用於:

  • 舊的和新的語義確實無法共存
  • 資源模型基本上改變了
  • 相容性規則會強制一個無法理解的合同

重點不是完全避免版本控制。 重點是在共存失敗時才追求它,而不是因為它是桌上首先想到的主意。

當版本控制是必要時,應與真正的棄用過程配合。 困難的工作不是發佈 v2。 困難的工作是讓消費者擺脫 v1

決策規則

Milan 的框架是應用的正確方式:

  1. 我能增加而不是替代嗎?
  2. 舊的和新的合同能在遷移窗口中共存嗎?
  3. 我能引入新的操作而不是改變舊的嗎?
  4. 我能用文件、標題和遙測廢棄舊形狀嗎?

如果對所有四個問題的回答是肯定的,新版本可能是不必須的。 如果答案是否定的,並且兩者確實無法共存,則故意版本化。

設計合同以便演進。 將消費者視為長久的整合而不是今天的程式碼。 將版本控制保留給相容性確實已耗盡的情況。

欲了解完整文章,包括更長的範例,請閱讀 Milan 的原帖


當選擇一個要依賴的 .NET 程式庫時,值得問的問題是基於 Milan 的文章構建的:這個程式庫在三年後是否仍像我整合的 API 一樣?

這是我們每次發布努力解答的問題。 來自2020年的簡單調用仍然有效。 新的功能與它們一起存在,且是可選和增量的。沒有強制的主要版本遷移。

如果這種程式庫設計的方法符合您的需求,開始免費的30天試用,並自行查看 API 參考。 五分鐘快速入門 走查安裝、授權激活和首次渲染的 PDF。 該包本身離任何 .NET 專案只有一個命令之遙:

對於 NuGet 不是首選路徑的環境,直接下載 提供 DLL 和 Windows 安裝程式。