行业新闻

.NET 的演变:将 AI 和移动原生功能集成到现代 Web 应用中

Milan Jovanović最近发表了反对过早API版本控制的有力论点。 核心问题:大多数团队过早地使用v2,因为他们缺乏合同演变策略。 版本控制是一个兼容性工具,不是设计策略。

这条论点在我们Iron Software的工程团队中引起共鸣。 我们发布.NET库,这意味着我们产品的公共界面是一种API。 每个方法签名、每个属性、每个默认行为都是坐落在成千上万客户代码库中的一个合同。 主要版本升级不是一个发布。 它是所有下游的迁移项目。

以下是开发人员对Milan文章的看法,从库作者的角度出发,并且无论您是发布REST API还是NuGet包,这些相同的兼容性规则都是适用的。

简而言之

  • 版本控制不是设计策略。 它是共存失败时的逃生出口。
  • 破坏性变化隐藏在行为中,而不仅仅是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()方法。 一个其默认值从Trim参数。 一个以抛出无效输入异常为常态的方法现在静默返回默认值。

四条兼容性规则

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包已经获得超过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. 链接到实际的迁移指南。
  4. 使用遥测测量使用情况以确定何时安全地删除。

对于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中移除", DiagnosticId = "IRON001")] 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安装程序。