.NET 库与向后兼容的艺术 – 第 1 部分





5.00/5 (5投票s)
如何规划将在库的下一个版本中进行的更改,以保护现有用户的体验
所以,您编写了一个 .NET 库,将其公开发布,现在您准备发布 2.0 或 1.1 版本,甚至是 1.0.0.0b 版本。
您将进行的任何更改都有可能引入以下一种或多种类型的向后不兼容性:
- 行为性(您的库的行为正在发生变化)
- 源代码性(您的用户的代码可能编译失败)
- 二进制性(您的用户的应用程序可能在运行时崩溃)
这篇博文将重点介绍行为性不兼容性。我将在接下来的几篇文章中介绍另外两种类型。
行为性不兼容性
当您新发布的库的行为与先前版本不同时,就会引入行为性不兼容性。
总的来说,无法完全避免行为性不兼容性。有些更改可能会使大多数用户受益,但尽管如此,它们仍然是行为性更改。
- 修复 bug
- 提高方法调用的性能
- 修复错误消息中的拼写错误
- 从方法中抛出一种新型异常,以更好地限定错误
您永远不知道您的用户中是否有人依赖于现有的(对大多数人来说不理想的)行为。
继承的麻烦
在继承方面,很容易忽视行为性更改。
例如,您可能在库中包含了一个 Stream
类,并遵循 Microsoft 提供的说明,只实现了 Read
方法。
The asynchronous methods ReadAsync(Byte[], Int32, Int32), WriteAsync(Byte[], Int32, Int32),
and CopyToAsync(Stream) use the synchronous methods Read(Byte[], Int32,
Int32) and Write(Byte[], Int32, Int32) in their implementations.
Therefore, your implementations of Read(Byte[], Int32, Int32) and Write(Byte[],
Int32, Int32) will work correctly with the asynchronous methods.
我将使用以下代码作为示例:一个从另一个流读取并转换 ASCII 字符为大写的流。
public class UppercasingStream : Stream
{
private readonly Stream BaseStream;
private const byte UppercaseOffset = (byte)('a' - 'A');
public UppercasingStream(Stream baseStream) => BaseStream = baseStream;
public override int Read(byte[] buffer, int offset, int count)
{
int len = BaseStream.Read(buffer, offset, count);
for (int i = 0; i < len; i++)
{
byte b = buffer[i + offset];
if (b >= 'a' && b <= 'z')
{
buffer[i + offset] -= UppercaseOffset;
}
}
return len;
}
...
此库的用户可能扩展了此类,还移除了非字母字符。
public class NormalizingStream : UppercasingStream
{
public NormalizingStream(Stream baseStream) : base(baseStream) { }
public override int Read(byte[] buffer, int offset, int count)
{
int len = base.Read(buffer, offset, count);
for (int i = 0; i < len; i++)
{
byte b = buffer[i + offset];
if (b < 'A' || b > 'Z')
{
buffer[i + offset] = (byte)'_';
}
}
return len;
}
}
NormalizingStream
类即使在异步使用时也能按预期运行。
using (var reader = new StreamReader(
new NormalizingStream(
new MemoryStream(
Encoding.ASCII.GetBytes("matteo.tech.blog"))),
Encoding.ASCII))
{
var result = await reader.ReadToEndAsync();
//Writes "MATTEO_TECH_BLOG"
Console.WriteLine(result);
}
改进我们的 UppercasingStream
类的一个合理方法是实现 ReadAsync
方法。
public override async Task<int> ReadAsync
(byte[] buffer, int offset, int count, CancellationToken ct)
{
int len = await BaseStream.ReadAsync(buffer, offset, count, ct);
for (int i = 0; i < len; i++)
{
byte b = buffer[i + offset];
if (b >= 'a' && b <= 'z')
{
buffer[i + offset] -= UppercaseOffset;
}
}
return len;
}
不幸的是,这个看似无害的改进会破坏我们客户的应用程序,阻止调用重写的 NormalizingStream
.Read
方法。
using (var reader = new StreamReader( new NormalizingStream( new MemoryStream( Encoding.ASCII.GetBytes("matteo.tech.blog"))), Encoding.ASCII)) { var result = await reader.ReadToEndAsync(); //Writes "MATTEO.TECH.BLOG" instead of "MATTEO_TECH_BLOG" Console.WriteLine(result); }
一种防止此类向后不兼容性的好方法是,除非我们的 public
类明确设计为供用户扩展,否则 密封 所有这些类。如果出现合理的用例,我们总是可以在库的后续版本中取消密封类。
如果我们密封了 UppercasingStream
,用户可能更倾向于使用组合而不是继承,而我们的更改将是向后兼容的。
过时是你的朋友
当无法避免重大的行为更改时,我们可以选择创建全新的方法和类,并将现有方法标记为 过时。
这尤其有用,因为 Obsolete
属性允许我们提供一条消息,当用户尝试使用过时组件时,该消息会显示在用户的 IDE 中或作为编译消息。这样我们就可以引导用户使用替换的方法和类。
[Obsolete("This class is obsolete, use SeverelyBuggedClassV2 instead.")]
public class SeverelyBuggedClass
{
}
我们甚至可以在客户尝试使用过时组件时强制引发编译错误。
[Obsolete("This class is obsolete, use SeverelyBuggedClassV2 instead.", error: true)]
public class SeverelyBuggedClass
{
}
这在极端情况下很有用,当我们真的希望客户放弃弃用的组件时。这比移除组件要好,因为它允许我们在过时消息中提供指导。它也比仅仅修复组件要好,因为它迫使用户承认破坏性更改并采取行动,而不是可能因意外的行为变化而感到惊讶。
使用 Obsolete
的缺点是,随着时间的推移,我们的库会变得越来越笨拙:SeverelyBuggedClass
这个名字本身就比 SeverelyBuggedClassV2
更讨人喜欢。
沟通是关键
总的来说,我们几乎无能为力来避免行为性不兼容的更改,尤其是在处理 bug 和不良设计选择方面。但我们可以提前制定一个计划,说明我们将如何与客户沟通这些更改。
想象一下,在刚刚承诺为您库的最新 LTS 版本提供 10 年支持后,才发现您必须打破向后兼容性。
一些建议。
- 不要承诺比必要更长的支持期限:违背承诺比不承诺更糟。
- 确保您知道在需要告知客户重要信息时如何联系到他们:例如,可以通过月度新闻通讯吗?
- 确保您联系到正确的人:您希望您的新闻通讯发送给工程师,而不是发送到支付许可证费用的会计人员的垃圾邮件文件夹。
- 让用户准备好接受破坏性更改的想法:也许可以每年发布一次主要版本更新,包含大量新功能和一些破坏性更改。
如果您的客户期望每年都会发布一个新的主要版本,并且其中包含好东西,他们很可能会为此做好准备,并且他们会期待阅读有关更改的公告。这就是为什么游戏玩家在他们喜爱的游戏更新新版本时会阅读发行说明!
文档和未定义行为
行为兼容性问题在很大程度上受到您在文档中所写内容的限制。您可以通过承诺比您能负担的更多的兼容性,或者让您的客户认为他们当前遇到的行为是您保证合同的一部分,从而打破客户的期望。
确保您的文档尽可能明确地说明您的库应该如何使用,以及在什么条件下它实际上经过了测试。例如,在权限较低的账户下或在区分大小写的文件系统上,您的库可能以截然不同的方式运行。
还要确保让用户知道您为库保留了哪些增长领域。如果您在文档中说明您的应用程序将遵守 FOO_THREADS
和 FOO_PROXY
环境变量,您可能还希望包含所有其他名为 FOO_something
的变量都被视为保留供将来使用,不应被使用。
最后,明确指出哪些行为是未定义的,这是一个好主意。例如,如果您的文档说
Method Foo will throw
- ArgumentOutOfRangeException if offset is negative
- ObjectDisposedException if the current stream is closed
- System.Exception in case of unexpected error.
关于 System.Exception
的声明使得将方法更改为抛出新类型的异常完全向后兼容(因为任何新的异常类型都继承自 System.Exception
)。
What Next?
下一篇博文将介绍源代码性不兼容性,然后我们将深入探讨一个更奇特的课题:如何确保用新版本库编译的代码在与新版本库一起使用时仍然可以正常工作。