65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2020 年 10 月 26 日

Apache

6分钟阅读

viewsIcon

6617

这是 .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 来使用程序集加载规则。

二进制不兼容性的类型

二进制不兼容性有两种类型:导致异常的和导致行为改变的。

由二进制不兼容性引起的典型异常是TypeLoadExceptionMissingMethodException。它们特别难以捕获,因为它们在 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'.

二进制兼容性与源码兼容性

人们可能会认为所有的二进制不兼容性,至少是那些导致 TypeLoadExceptionMissingMethodException 的不兼容性,也是源码不兼容性。事实并非如此。

以下是源码兼容但二进制不兼容的代码更改列表。

以前

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));
  }
}

如何不抓狂

由于二进制兼容性与源码兼容性之间的关系如此复杂,我强烈建议

  1. 要么根本不保证二进制兼容性
  2. 要么同时保证二进制和源码兼容性

决策时间

现在是时候回去重新阅读文章开头的优缺点部分了。

照片由尼克 杨森拍摄,根据知识共享许可使用

好消息是,尽管源码兼容性永远无法完全保证(参见第二部分),但二进制兼容性实际上是完全可以实现的。坏消息是,哪些是二进制兼容的,哪些不是,一点也不明显!

幸运的是,测试一种更改是否向后兼容非常容易

  1. 创建一个包含两个项目的解决方案:一个应用程序和一个类库。
  2. 在应用程序项目中添加对类库的引用。
  3. 在库和应用程序中实现最少的代码以重现用例。
  4. 构建解决方案,测试程序是否按预期工作,并备份应用程序的 bin/Debug 文件夹。
  5. 进行您想要测试兼容性的更改。
  6. 构建解决方案,测试程序是否正常工作。
  7. 仅将类库的 .dll 文件(而不是应用程序的 .exe)复制到步骤 4 中创建的备份文件夹中。
  8. 从备份文件夹运行应用程序,并验证其行为是否仍然正确。

例如,通过测试以下内容,我们可以很容易地验证将方法移动到基类是二进制兼容的(我可没猜到)。

以前

//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 开发人员有所帮助。

祝您好运,感谢阅读。

© . All rights reserved.