“前.NET Core时代”如何实现跨平台代码重用 ——程序集重用

除了在源代码层面实现共享(“前.NET Core时代”如何实现跨平台代码重用 ——源文件重用)之外,我们还可以跨平台共享同一个程序集,这种独立于具体平台的“中性”程序集通过创建一种名为“可移植类库(PCL: Portable Class Library)”项目来实现。为了让读者朋友们对PCL的实现机制具有充分的认识,我们先来讨论一个被我称为“程序集动态绑定”的话题。

目录
一、何谓程序集动态绑定?
二、程序集一致性
三、程序集重定向
四、类型的转移
五、可移植类库(PCL)

一、何谓程序集动态绑定?

我们采用C#、VB.NET这样的编程语言编写的源文件经过编译会生成有IL代码和元数据构成的托管模块,一个或者多个托管模块合并生成一个程序集。除了包含必要的托管模块之外,我们还可以将其他文件作为资源内嵌到程序集中,程序集的文件构成一个“清单(Manifest)”文件来描述,这个清单文件包含在某个托管模块中。

元数据使程序集成为一个自描述性(Self-Describling)的部署单元,除了描述定义在本程序集中所有类型之外,这些元数据还包括对引用自外部程序集的所有类新的描述。包含在元数据中针对外部程序集的描述是由编译时引用的程序集决定的[1],引用程序集的名称(包含文件名、版本、语言文化和签名的公钥令牌)会直接体现在当前程序集的元数据中。

在运行时,通过元数据描述的引用程序集信息是CLR定位目标程序集的依据,但是这并不意味着它与实际加载的程序集是完全一致的,后者实际上是根据当前执行环境动态加载的,我们姑且将这个机制成为“程序集动态绑定”。

二、程序集一致性

我们都知道.NET Framework是向后兼容的,也就是说原来针对低版本.NET Framework编译生成的程序集是可以直接在高版本CLR下运行的。我们试想一下这么一个问题:就一个针对.NET Framework 2.0编译生成的程序集自身来说,所有引用的.NET Framework程序集的版本都是2.0,如果这个程序集在4.0环境下执行,CLR在决定加载它所依赖程序集的时候,应该选择2.0还是4.0呢?

我们不妨通过实验来获得这个问题的答案。我们利用Visual Studio创建一个针对.NET Framework 2.0的控制台应用(命名为App),并在作为程序入口的Main方法上编写如下一段代码。如下面代码片断所示,我们在控制台上输出了三个基本类型(Int32、XmlDocument和DataSet)所在程序集的全名。

   1: class Program
   2: {
   3:     static void Main(string[] args)
   4:     {
   5:         Console.WriteLine(typeof(int).Assembly.FullName);
   6:         Console.WriteLine(typeof(XmlDocument).Assembly.FullName);
   7:         Console.WriteLine(typeof(DataSet).Assembly.FullName);
   8:     }
   9: }

直接运行这段程序使之在默认版本的CLR(2.0)下运行会在控制台上输出如下的结果,我们会发现上述三个基本类型所在程序集的版本都是2.0.0.0。在这种情况下,运行时加载的程序集和编译时引用的程序集是一致的。

   1: mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
   2: System.Xml, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
   3: System.Data, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089

现在我们直接在目录“\bin\debug”直接找到以Debug模式编译生成的程序集App.exe,并为之创建一个配置文件(命名为App.exe.config)。我们编写了如下一段配置,其目的在于选择4.0版本的CLR运行这个程序。

   1: <configuration>
   2:   <startup>
   3:     <supportedRuntime version="v4.0"/>
   4:   </startup>
   5: </configuration>

或者:

   1: <configuration>
   2:   <startup>
   3:     <requiredRuntime version="v4.0"/>
   4:   </startup>
   5: </configuration>

在无需重新编译(确保运行的依然是针对.NET Framework 2.0编译生成的程序集)直接运行App.exe,我们会在控制台上得到如下所示的输出结果,可以看到三个程序集的版本编程了4.0.0.0。

   1: mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
   2: System.Xml, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
   3: System.Data, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089

这个简单的实例体现了这么一个特征:运行过程中加载的.NET Framework程序集(承载FCL的程序集)是由当前运行时(CLR)决定的,这些程序集的版本总是与CLR的版本相匹配。包含在元数据中的程序集信息提供目标程序集的名称,而版本则由当前运行的CLR来决定,我们将这个重要的机制称为“程序集一致性(Assembly Unification)”,下图很清晰地揭示了这个特性。

09

三、程序集重定向

在默认情况下,如果某个程序集引用了另一个具有强签名的程序集,CLR在执行的时候总是会根据程序集有效名称(Assembly Qualified Name,由程序集文件名、版本、语言文化和公钥令牌组成)去定位目标程序集,如果无法找到一个与之完全匹配的程序集,一般情况下会抛出一个FileNotFoundException类型的异常。程序集的重定向机制实际上是让CLR在定位目标程序集的时候“放宽”了匹配的条件,即指要求目标程序集的文件名与元数据描述的程序集一致即可。

如下图所示,程序集(Lib.dll)在编译的时候引用了可被重定向的程序集“Retargetable, Version=1.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a”。在采用运行时Runtime1和Runtime2所在的执行环境下,真正绑定的目标程序集分别为“Retargetable, Version=2.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35”和“Retargetable, Version=3.0.0.0, Culture=neutral, PublicKeyToken
=30ad4fe6b2a6aeed”,除了程序集文件名称,它们的版本和公钥令牌与编译时引用的程序集均不相同。

10

实际上通过PCL项目编译生成的程序集所引用的都是这种能够被重定向的程序集(以下简称Retargetable程序集)。与普通程序集相比较,这种可被重定向的程序集的唯一不同之处在于它多了一个如下所示的retargetable标记。

   1: 普通程序集
   2: .assembly Lib
   3:  
   4: 可被重定向程序集
   5: .assembly retargetable Lib

这样一个标记可以通过按照如下所示的方式在程序集上应用AssemblyFlagsAttribute特性来添加。不过这样的重定向仅仅是针对.NET Framework自身的程序集有效,虽然我们也可以通过使用AssemblyFlagsAttribute特性为自定义的程序集添加这样一个retargetable标记,但是CLR并不会赋予它重定向的能力。

   1: [assembly:AssemblyFlags(AssemblyNameFlags.Retargetable)]

对于某个程序集来说,针对普通程序集的引用和Retargetable程序集的引用的不同支持会反映在自身的元数据中。下面的代码片断体现了元数据对引用程序集的描述,我们可以看到针对Retargetable程序集的引用同样具有一个retargetable标记。当CLR在定位目标程序集的时候就是根据这个标记决定是否需要重定向到当前运行时环境下与之匹配的程序集,并且这个程序集有可能在版本和公钥令牌均与元数据描述不同。

   1: 针对普通程序集的引用
   2: .assembly extern Lib
   3: {
   4:   .publickeytoken = (B7 7A 5C 56 19 34 E0 89 )                         
   5:   .ver 1:0:0:0
   6: }
   7:  
   8: 针对Retargetable程序集的引用
   9: .assembly extern retargetable Lib
  10: {
  11:   .publickeytoken = (B7 7A 5C 56 19 34 E0 89)                         
  12:   .ver 1:0:0:0
  13: }


四、类型的转移

所谓类型转移(Type Forwarding)就是将定义在某个程序集中的类型转移到另一个程序集中。我们先通过一个简单的实例让读者朋友们对类型转移有一个感官上的认识。我们利用Visual Studio创建一个针对.NET Framework 3.5的控制台应用,并编写如下一端简单的程序输出两个常用的类型(Function<T>和TimeZoneInfo)所在程序集的名称。现在我们直接运行这个程序,会在控制台上得到如下所示的输出结果,可以看出.NET Framework 3.5(CLR 2.0)环境下的这两个类型定义在程序集System.Core.dll中。

   1: class Program
   2: {
   3:     static void Main(string[] args)
   4:     {
   5:         Console.WriteLine(typeof(Func<>).Assembly.FullName);
   6:         Console.WriteLine(typeof(TimeZoneInfo).Assembly.FullName);
   7:     }
   8: }

输出结果:

   1: System.Core, Version=3.5.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
   2: System.Core, Version=3.5.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089

现在我们对该程序的配置文件(App.config)作如下的修改,其目的在于采用CLR 4.0来运行该程序。再次运行该程序集之后,我们会在控制台上得到不一样的输出结果。通过如下所示的输出结果我们可以看出当.NET Framework从3.5升级到4.0的时候,将原本定义在程序集System.Core.dll中的部分类型转移到了程序集mscorelib.dll之中。

   1: <configuration>
   2:   <startup>
   3:     <supportedRuntime version="v4.0"/>
   4:   </startup>
   5: </configuration>

输出结果:

   1: mscorelib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 
   2: mscorelib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089

跨程序集之间的类型转移帮助框架或者类库的提供者解决这样的难题:某个类型在框架1.0版本的时候定义在程序集A中,当升级到2.0的时候被转移到了程序集B中,使用旧版本的应用可以在不做任何修改的情况下直接对使用的框架进行升级。类型转移需要使用到一个特殊的特性TypeForwardedToAttribute,我们现在通过一个简单的实例来演示如何利用这个特性来解决框架或者类库升级过程在类型跨程序集转移的问题。

11

这个演示的场景如上图所示:代表应用的App.exe在编译的时候引用了代表框架的程序集Lib.dll,具体使用的是定义其中的类型Foobar,框架进行升级之后新增了一个程序集Lib2.dll,原来定义在Lib.dll中的类型Foobar被转移到了Lib2.dll中。充分利用CLR针对类型转移的支持,我们只需要直接部署新版本的Lib.dll(不包含类型Foobar)和Lib2.dll,现有的程序能够照常运行。

12

我们利用Visual Studio创建了如上图所示的解决方案。类库项目Lib1代表版本1.0的框架,我们将编译生成的程序集名称设置成Lib,并在其中定义了一个类型Foobar。控制台应用直接应用Lib1,并与其中编写了如下一段简单的程序,其目的在于确认类型Foobar所在的程序集。

   1: class Program
   2: {
   3:     static void Main(string[] args)
   4:     {
   5:         Console.WriteLine(typeof(Foobar).AssemblyQualifiedName);
   6:         Console.Read();
   7:     }
   8: }

类库项目Lib2和Lib3编译生成代表框架升级之后的两个程序集,我们通过修改项目属性将目标程序集名称设置成Lib和Lib2,Lib2具有针对Lib3的项目引用。我们在Lib3中重新定义了代表被转移的类型Foobar,而Lib2实际上是一个空的项目。要体现类型Foobar从Lib.dll转移到Lib2.dll,我们需要在Lib2项目上应用如下所示的一个TypeForwardedToAttribute特性(定义在AssemblyInfo.cs中)。

   1: [assembly:TypeForwardedTo(typeof(Foobar))] 

现在我们对整个解决方案进行编译,然后定位到控制台App项目编译后的输出目录(app\bin\debug),并将项目Lib1编译生成的程序集Lib.dll删除,而将Lib2和Lib3编译生成的程序集Lib.dll和Lib2.dll拷贝到该目录下。现在我们直接运行App.exe,我们会在控制台上得到如下所示的输出结果。

   1: Lib.Foobar, Lib2, Version=2.0.0.0, Culture=neutral, PublicKeyToken=null 

如果某个项目应用了TypeForwardedToAttribute特性指向定义在另一个程序集中的被转出类型,类型转移相关的信息会体现在编译生成的元数据中。就我们的实例而言,项目Lib2编译的生成的程序集通过如下的元数据来指向被转移出去的类型所在的目标程序集。

   1: .class extern forwarder Lib.Foobar
   2: {
   3:   .assembly extern Lib2
   4: }

当App.exe被执行的时候,由于元数据体现的依然是针对程序集Lib.dll的引用,所以CLR任然会试图从该程序集中加载类型Foobar。但是通过分析程序集Lib.dll的元数据,CLR知道Foobar已经被转移到程序集Lib2.dll中,所以定义在其中的同名类型Foobar最终会被加载。

五、可移植类库(PCL)

就目前来说,创建PCL项目是实现跨.NET Framework平台程序集共享唯一的方式。当我们采用Class Library(Portal)项目模板创建一个PCL项目的时候,需要在如下图所示的对话框中选择支持的目标平台及其版本。Visual Studio会为新建的项目添加一个名为“.NET”的引用,这个引用指向一个由选定.NET Framework平台决定的程序集列表。由于这些程序集提供的API能够兼容所有选择的平台,我们在此基础编写的程序自然也具有平台兼容性。

image

如果查看这个特殊的.NET引用所在的地址,我们会发现它指向目录“%ProgramFiles%\Reference Assemblies\Microsoft\Framework\.NETPortable\{version}\Profile\ProfileX”。如果查看 “%ProgramFiles%
\Reference Assemblies\Microsoft\Framework\.NETPortable” 目录,我们会发现它具有如下图所示的结构。

14

如图上所示,目录“%ProgramFiles%\Reference Assemblies\Microsoft\Framework\.NETPortable”下具有三个代表.NET Framework版本的子目录(v4.0、v4.5和v4.6)。具体到针对某个.NET Framework版本的目录(比如v4.6),其子目录Profile下具有一系列以“Profile”+“数字”(比如Profile31、Profile32和Profile44等)命名的子目录,实际上PCL项目引用的就是存储在这些目录下的程序集。

对于两个不同平台的.NET Framework来说,它们的Core Library在API的定义上存在交集,从理论上来说,建立在这个交集基础上的程序是可以被这两个平台中共享的。如下图所示,如果我们编写的代码需要分别对Windows Desktop/Phone、Windows Phone/Store和Windows Store/Desktop平台提供支持,那么这样的代码依赖的部分仅限于两两的交集A+B、A+C和A+D。如果要求这部分代码能够运行在Windows Desktop/Phone/Store三个平台上,那么它们只能建立在三者之间的交集A上。

image 

针对所有可能的.NET Framework平台(包括版本)的组合,微软会将体现在Core Library上的交集提取出来并定义在相应的程序集中。比如说所有的.NET Framework平台都包含一个核心的程序集mscorelib.dll,虽然定义其中的类型及其成员在各个.NET Framework平台不尽相同,但是它们之间肯定存在交集,微软针对不同的.NET Framework平台组合将这些交集提取出来并定义在一系列同名程序集中,并同样命名为mscorelib.dll。 微软按照这样的方式创建了其他针对不同.NET Framework平台组合的基础程序集,这些针对某个组合的所有程序集构成一系列的Profile,并定义在上面我们提到过的目录下。值得一提的是,所有这些针对某个Profile的程序集均为Retargetable程序集。

当我们创建一个PCL项目的时候,第一个必需的步骤是选择兼容的.NET Framework平台,Visual Studio会根据我们的选择确定一个具体的Profile,并为创建的项目添加针对该Profile的程序集引用。由于所有引用的程序集是根据我们选择的.NET Framework平台“度身定制”的,所以定义在PCL项目的代码才具有可移植的能力。

上面我们仅仅从开发的角度解释了定义在PCL项目的代码本身为什么能够确保是与目标.NET Framework平台兼容的,但是在运行的角度来看这个问题,却存在额外两个问题:

  • 元数据描述的引用程序集与真实加载的程序集不一致,比如我们创建一个兼容.NET Framework 4.5和Silverlight 5.0的PCL项目,被引用的程序集mscorellib.dll的版本为2.0.5.0,但是Silverlight 5.0运行时环境中的程序集mscorellib.dll的版本则为5.0.5.0。
  • 元数据描述的引用程序集的类型定义与运行时加载程序集类型定义不一致,比如引用程序集中的某个类型被转移到了另一个程序集中。

由于PCL项目在编译时引用的均为Retargetable程序集,所以程序集的重定向机制帮助我们解决了第一个问题。因为在CLR在加载某个Retargetable程序集的时候,如果找不到一个与引用程序集在文件名、版本、语言文化和公钥令牌完全匹配的程序集,则会只考虑文件名的一致性。至于第二个问题,自然可以通过上面我们介绍的类型转移机制来解决。


[1] 当我们执行C#编译器(csc.exe)以命令行的形式编译C#源代码时,引用的程序集通过“/reference”开关指定。

posted @ 2016-06-12 09:09  Artech  阅读(4609)  评论(2编辑  收藏  举报