.NET 库与向后兼容性的艺术——第三部分





5.00/5 (2投票s)
这是 .NET 库与向后兼容性系列文章的第三篇。
这是《.NET 库与向后兼容性的艺术》系列文章的第三篇。
- 第一部分 – 简介和行为不兼容性
- 第二部分 – 源码不兼容性
- 第三部分 – 本文
本系列的第一部分和第二部分讨论了如何更新您的库代码,使其不会因为改变行为(行为不兼容性)或导致编译错误(源码不兼容性)而破坏您的客户应用程序。行为不兼容性是隐蔽的,必须不惜一切代价避免;源码不兼容性是客户需要解决的痛点,应尽量减少。
二进制不兼容性
第三种不兼容性发生在用户通过将 .dll 文件放入应用程序文件夹来更新您的库,而无需重新编译应用程序本身。这种“更新”可以由应用程序的作者或甚至最终用户执行。
承诺的恐惧
您需要做的第一件事是决定是否应允许这种形式的更新。允许和禁止它都有其优缺点。您的选择将影响您编写库和文档的方式,因此您应该尽早决定。
优点
- 特别是对于安全补丁,最终用户可以更新您的库,而无需等待应用程序作者发布新版本。
- 如果您的库被广泛使用,有人可能会编写一个应用程序,其中包含两个使用您的库不同版本的依赖项。如果您不允许较新的版本被这两个依赖项透明地使用,这将成为一个问题。
缺点
- 保证二进制兼容性很难,如果您不小心,很可能会打破您的承诺。
- 强制客户在更新您的库时重新构建他们的应用程序,将导致他们运行测试,这些测试可能会捕获行为不兼容性。您甚至可以故意引入源码不兼容性,以强制客户解决您的库行为的改变。
- 不保证二进制兼容性将使您在设计新版本的库时拥有更大的自由度,并可能随着时间的推移带来更好的用户体验。
强命名难题
二进制兼容性仅在您的库的 .dll 文件可以被更新版本替换时才有用,否则行为和源码兼容性是您唯一需要担心的!值得注意的是,强命名您的库可能会阻止用户将其替换为更新版本。
.NET 的一个令人困惑的特性,它通过使用加密密钥对程序集进行签名,根据其名称和版本为其分配一个唯一标识。
奇怪的是,即使涉及加密,强命名也不应该依赖于安全性。
请参阅 Microsoft 的指南此处。
图片由XxDBZCancucksFanxX提供,根据知识共享许可使用
如果您没有对您的库进行强命名,那么您就没问题了。只需要知道,如果潜在客户想要对他们的程序集进行强命名,他们将无法使用您的库。
如果您想对您的库进行强命名,通常的方法是除非您确实进行了破坏性更改,否则保持程序集版本不变。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<Version>1.0.1</Version>
<FileVersion>1.0.1</FileVersion>
<!-- Don't increase the AssemblyVersion unless you are making breaking changes-->
<AssemblyVersion>1.0.0</AssemblyVersion>
<SignAssembly>true</SignAssembly>
<AssemblyOriginatorKeyFile>SNKey.pfx</AssemblyOriginatorKeyFile>
</PropertyGroup>
</Project>
这允许您的所有不同版本可以互换,即使程序集是强命名的。微软自己也意识到这很令人困惑和繁琐,因此在 .NET Core 中更改了严格的程序集版本加载,使其更加宽松。如果您的库面向 .NET Standard,它将根据使用它的应用程序是 .NET Framework 还是 .NET Core 来使用程序集加载规则。
二进制不兼容性的类型
二进制不兼容性有两种类型:导致异常的和导致行为改变的。
由二进制不兼容性引起的典型异常是TypeLoadException或MissingMethodException。它们特别难以捕获,因为它们在 CLR 首次尝试从您的库访问受影响的类型或成员时抛出,这比实际代码行中首次引用该类型或成员更早。
与二进制不兼容性相关的行为更改与“正常”行为不兼容性不同,因为它们可以通过重新编译使用您的库的代码来解决。这可能对用户造成很大的困惑,因为他们可能会尝试在应用程序的新编译调试版本上重现问题,而该版本不会受到影响。
一个有趣的例子是重新排序 enum
的条目。由于 .NET 会自动为 enum
条目分配一个数值,并且此值在编译时嵌入到消费程序集中,因此重新排序 enum
会引入两种行为更改:一种是由于二进制不兼容性导致的,另一种是重新编译应用程序时发生的。
以下代码
static void Main(string[] args)
{
Console.WriteLine(
$"This is Enum1.a: '{Enum1.a}'. It's value is 0: '{(int)Enum1.a}'.");
}
//This must be in a separate library
public enum Enum1
{
a, b
}
……通常会打印
This is Enum1.a: 'a'. It's value is 0: '0'.
如果我们将库中的 enum
定义更改为
public enum Enum1
{
b, a
}
……应用程序现在将打印
This is Enum1.a: 'b'. It's value is 0: '0'.
这是因为 Enum1.a
在应用程序的程序集中被编译为 0
。因此,当我们在不重新编译的情况下切换到新库时,0
值被保留,但它现在对应于 Enum1.b
。
如果我们重新编译应用程序,我们现在会得到第三种不同的行为!
This is Enum1.a: 'a'. It's value is 0: '1'.
二进制兼容性与源码兼容性
人们可能会认为所有的二进制不兼容性,至少是那些导致 TypeLoadException 或 MissingMethodException 的不兼容性,也是源码不兼容性。事实并非如此。
以下是源码兼容但二进制不兼容的代码更改列表。
以前
public class Class1
{
public static void F()
{
Console.WriteLine("1");
}
}
操作后
public class Class1
{
//Adding a parameter with a default
//results in MissingMethodException.
//Create an overloaded method instead.
public static void F(int n = 1)
{
Console.WriteLine(n);
}
}
以前
public class Class1
{
public int Number = 0;
}
操作后
public class Class1
{
//Changing a field into a property will
//results in MissingFieldException
public int Number { get; set; } = 0;
}
以前
public interface IFoo
{
void F();
}
操作后
public interface IFooBase
{
void F();
}
//Moving an interface member to a base
//interface results in
//MissingMethodException
public interface IFoo : IFooBase
{
}
大多数源码不兼容性也是二进制不兼容的。只有少数例外。
以前
public class Class1
{
public static void F(int n)
{
Console.WriteLine(n);
}
}
操作后
public class Class1
{
//Changing parameter names break
//compilation if your customer uses
//named arguments
public static void F(int x)
{
Console.WriteLine(x);
}
}
一些行为更改只有在重新编译后才生效。
以前
public class Class1
{
public static void F(int n = 1)
{
Console.WriteLine(n);
}
}
操作后
public class Class1
{
//Default values are embedded in the
//calling assembly so this change
//require recompilation to show an
//effect
public static void F(int n = 2)
{
Console.WriteLine(n);
}
}
以前
public class Class1
{
public static void Print(object o)
{
Console.WriteLine(o);
}
}
操作后
public class Class1
{
public static void Print(object o)
{
Console.WriteLine(o);
}
//The new overload would be used only
//by an application compiled against
//the new library
public static void Print(object[] o)
{
Console.WriteLine(
string.Join("; ", o));
}
}
如何不抓狂
由于二进制兼容性与源码兼容性之间的关系如此复杂,我强烈建议
- 要么根本不保证二进制兼容性
- 要么同时保证二进制和源码兼容性
现在是时候回去重新阅读文章开头的优缺点部分了。
好消息是,尽管源码兼容性永远无法完全保证(参见第二部分),但二进制兼容性实际上是完全可以实现的。坏消息是,哪些是二进制兼容的,哪些不是,一点也不明显!
幸运的是,测试一种更改是否向后兼容非常容易
- 创建一个包含两个项目的解决方案:一个应用程序和一个类库。
- 在应用程序项目中添加对类库的引用。
- 在库和应用程序中实现最少的代码以重现用例。
- 构建解决方案,测试程序是否按预期工作,并备份应用程序的 bin/Debug 文件夹。
- 进行您想要测试兼容性的更改。
- 构建解决方案,测试程序是否正常工作。
- 仅将类库的 .dll 文件(而不是应用程序的 .exe)复制到步骤 4 中创建的备份文件夹中。
- 从备份文件夹运行应用程序,并验证其行为是否仍然正确。
例如,通过测试以下内容,我们可以很容易地验证将方法移动到基类是二进制兼容的(我可没猜到)。
以前
//In the application
class Program
{
static void Main(string[] args)
{
new Foo().DoSomething();
Console.WriteLine("Press ENTER");
Console.ReadLine();
}
}
//In the class library
public class Foo
{
public void DoSomething()
{
Console.WriteLine("Something");
}
}
操作后
//In the application
class Program
{
static void Main(string[] args)
{
new Foo().DoSomething();
Console.WriteLine("Press ENTER");
Console.ReadLine();
}
}
//In the class library
public class Foo: FooBase
{
}
public class FooBase
{
public void DoSomething()
{
Console.WriteLine("Something");
}
}
所有美好的事物都必须走到尽头
嗯,这个过长的系列到此结束。
在过去几年中,维护拥有数十万用户的库的向后兼容性一直是我的主要关注点之一。我相信我还没有掌握关于这个主题的所有知识,但我真诚地希望这能对其他 .NET 开发人员有所帮助。
祝您好运,感谢阅读。