软件库版本升级与向后兼容性问题

在软件开发过程中,版本升级是不可避免的。然而,每次升级都可能引入向后兼容性问题,这些问题可能会影响现有用户的应用。本文将重点讨论行为变化这一类型的向后兼容性问题,并在后续的文章中探讨源代码兼容性和二进制兼容性问题。

行为变化

当新版本的库与旧版本的行为不同时,就会引入行为变化。虽然无法完全避免这种行为变化,但有些变化可能会给大多数用户带来好处,例如修复bug、提高方法调用的性能、修正错误消息中的拼写错误,或者抛出新类型的异常以更准确地描述错误。然而,永远无法预测是否有用户依赖于现有的(对大多数人来说不理想的)行为。

继承是引入行为变化的一个容易忽视的领域。例如,库中可能包含了一个Stream类,并且根据微软的建议实现了Read方法。异步方法ReadAsync和WriteAsync在它们的实现中使用了同步方法Read和Write。因此,Read和Write方法的实现将能够正确地与异步方法一起工作。

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"))))) { var result = await reader.ReadToEndAsync(); // 输出 "MATTEO_TECH_BLOG" Console.WriteLine(result); }

对UppercasingStream类进行合理的改进是实现ReadAsync方法:

public override async Task 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方法被调用。

过时的方法

当无法避免重大的行为变化时,可以选择创建全新的方法和类,并将现有的标记为过时。这特别有用,因为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 { }

这在极端情况下很有用,当真的希望客户远离弃用的组件时。这比移除组件更好,因为它允许在过时消息中向用户提供指导。这也比仅仅修复组件更好,因为它迫使用户承认破坏性的变化,并采取行动,而不是可能被意外的行为变化所惊讶。

沟通是关键

总的来说,几乎无法避免行为不兼容的变化,特别是当涉及到修复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.
沪ICP备2024098111号-1
上海秋旦网络科技中心:上海市奉贤区金大公路8218号1幢 联系电话:17898875485