目录
这篇博客文章将重点讨论.NET库中的行为不兼容问题。
因此,您编写了一个.NET库,并将其发布给公众,现在您将要制作2.0或1.1版,甚至只是1.0.0.0b。
您将要进行的任何更改都有可能引入以下一种或多种向后不兼容的风险:
- 行为(您库的行为正在改变)
- 源代码(您的用户代码可能无法编译)
- 二进制(您的用户的应用程序可能会在运行时中断)
这篇博客文章将重点讨论行为不兼容问题。我将在以后的文章中介绍其他两种类型:
行为不相容
当您的新库的行为与先前版本不同时,就会引入行为不兼容性。
总体而言,无法避免行为上的不兼容。这些变化可能会使大多数用户受益,但仍然是行为变化:
- 修正错误
- 提高方法调用的性能
- 修正错误讯息中的错字
- 从方法抛出新的异常类型以更好地限定错误
您永远不会知道您的任何用户是否依赖于现有(对于大多数情况而言是不希望的)行为。
继承剧本
继承是很容易忽略行为改变的领域。
例如,您可能已经在您的库中包含一个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
被调用。
更改为UppercasingStream之前和之后的调用顺序
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,则用户可能会使用组合而不是继承,并且我们的更改将向后兼容。
如果NormalizingStream使用组合而不是继承的调用序列
Obsolescence是你的朋友
当无法避免行为上的重大变化时,我们可以选择创建全新的方法和类,并将现有方法和类标记为obsolete。
这特别好,因为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更好的名称。
沟通是关键
总体而言,我们几乎没有办法避免行为上不兼容的更改,尤其是在解决错误和错误的设计选择时。但是我们可以预先计划如何与客户进行沟通。
想象一下,在保证为库的最新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)。
接下来是什么?
下一篇博客文章将讨论源代码不兼容的问题,然后我们将深入探讨更奇特的主题,即如何确保针对新版本的库编译的代码能够继续使用。