2.2 复用之伤
针对不同平台的.NET Framework相互独立的特性,给应用的开发者带来的最大的问题就是代码难以复用。比较极端的场景就是,如果为一个现有的桌面应用提供针对移动设备的支持,我们需要重新开发一个全新的应用,现有的代码难以被新的应用重用。“代码复用”是软件设计最根本的目标,但是平台之间的差异导致跨平台代码重用确实困难重重。虽然做得不算理想,但微软在这方面确实做出了很多尝试,在某些方面甚至具有创新之处,下面介绍跨平台代码重用的一些实现方案。
2.2.1 源代码复用
对于包括 Mono在内的各个.NET Framework平台的 BCL来说,虽然在 API定义层面存在一些共同之处,但是由于它们定义在不同的程序集之中,所以在 PCL(Portal Class Library)推出之前,针对程序集的共享是不可能实现的,只能在源代码层面实现共享。源代码的共享通过在不同项目之间共享源文件的方式实现,下面介绍3种不同的方式。
源文件共享
如果针对不同.NET Framework平台的多个项目文件存在于同一个物理目录下,那么该相同目录下的源文件可以同时包含在这些项目中以达到共享的目的。如图 2-9 所示,两个分别针对Silverlight和WPF的项目共享相同的目录,与两个项目文件在同一个目录下的C#文件Shared.cs可以同时被包含在这两个项目之中。
图2-9 源文件共享
文件链接
如果采用默认的方式将一个现有的文件添加到当前项目之中,Visual Studio会将目标文件复制到项目本地的目录下,所以无法达到共享的目的。但是针对现有文件的添加,它支持一种叫作“链接”的方式,使添加到项目中的文件指向的依然是原来的路径,我们可以采用这种方式为多个项目添加针对同一个文件的链接,以实现源文件跨项目共享。如果针对的是Silverlight和WPF 这两个项目,不论项目文件和需要被共享的文件存在于哪个目录下面,我们都可以采用添加文件链接的方式分享Shared.cs文件(见图2-10)。
图2-10 文件链接
Shared Project
项目一般都是为了组织源文件和其他相关资源并最终将它们编译成一个可被部署的程序集。但 Shared Project这种项目类型比较特别,它只具有对源文件进行组织的功能,无法通过编译生成程序集,它的存在就是为了实现源文件的共享。对于上面介绍的两种源代码的共享方式来说,它们都是针对某个单一文件的共享,而 Shared Project则可以对多个源文件进行打包以实现批量共享。
如图2-11所示,我们可以创建一个Shared Project类型的项目Shared.shproj,并将需要共享的 3 个 C#文件(Foo.cs、Bar.cs 和 Baz.cs)添加进来。将针对这个项目的引用同时添加到Silverlight项目(SilverlightApp.csproj)和Windows Phone项目(WinPhoneApp.csproj)之中,如果需要对这两个项目实施编译,包含在项目Shared.shproj中的3个C#文件就会自动作为当前项目的源文件参与编译。
图2-11 Shared Project
2.2.2 程序集复用
采用C#、VB.NET这样的编程语言编写的源文件经过编译会生成有IL代码和元数据构成的托管模块,一个或者多个托管模块合并生成一个程序集。程序集的文件名、版本、语言文化和签名的公钥令牌共同组成了它的唯一标识,我们将该标识称为程序集有效名称(Assembly Qualified Name)。除了包含必要的托管模块,我们还可以将其他文件作为资源内嵌到程序集中,程序集的文件构成由一个清单(Manifest)文件进行描述,这个清单文件包含在某个托管模块之中。
除描述程序集文件构成外,这个清单文件也包含描述程序集的元数据。元数据使程序集成为一个自描述性(Self-Describing)的部署单元,除了描述定义在本程序集中的所有类型,这些元数据还包括对引用的外部程序集的所有类型的描述。包含在元数据中针对外部程序集的描述,是由编译时引用的程序集决定的,引用程序集的名称(包含文件名、版本和签名的公钥令牌)会直接体现在当前程序集的元数据中。针对程序集引用的元数据采用“.assembly”形式被记录在清单文件中,所以被记录下来的不仅包含被引用的程序集文件名(Foo 和 Bar),还包括程序集的版本,对于签名的程序集(Foo)来说,公钥令牌也一并包含其中。
包含在当前程序集清单文件中针对引用程序集的元数据是 CLR 加载目标程序集的依据。在默认情况下,CLR 要求加载与程序集引用元数据完全一致的程序集。换句话说,如果引用的是一个未被签名的程序集(Bar),那么只要求被加载的程序集具有一致的文件名和版本就可以。如果引用的是一个经过签名的程序集,那么必须要求被加载的程序集具有一致的公钥令牌。
前面介绍了关于.NET多目标框架的问题。虽然不同的目标框架的 BCL在 API层面具有很多交集,但是这些 API 实际上被定义在不同的程序集中,所以在不同的目标框架下共享同一个程序集几乎成了不可能的事情。如果要使跨目标平台程序集复用成为现实,就必须要求 CLR在加载程序集时放宽“完全匹配”的限制,因为当前程序集清单文件中描述的某个引用程序集,在不同的目标框架下可能指向不同的程序集。实际上,确实存在一些机制或者策略可以使 CLR加载一个与引用元数据的描述不一致的程序集。
程序集一致性
.NET Framework是向后兼容的,也就是说,原来针对低版本.NET Framework编译生成的程序集是可以直接在高版本CLR下运行的。对于.NET Framework 2.0编译生成的程序集来说,所有引用的基础程序集的版本都应该是2.0,如果这个程序集在.NET Framework 4.0环境下执行,CLR在决定加载它所依赖的程序集时,应该选择.NET Framework 2.0还是.NET Framework 4.0?
如果利用 Visual Studio 创建一个针对.NET Framework 2.0 的控制台应用(将程序命名为App),并在作为程序入口的 Main 方法上编写如下一段代码。如下面的代码片段所示,控制台上输出了3个基本类型(Int32、XmlDocument和DataSet)所在程序集的全名。
直接将这段程序在默认版本的 CLR(2.0)下运行,在控制台上输出的结果如图 2-12 所示,由此可以发现上述 3 个基本类型所在程序集的版本都是 2.0.0.0。也就是说,在这种情况下,运行时加载的程序集和编译时引用的程序集是一致的。
图2-12 加载程序集与编译程序集保持一致
如果在目录“\bin\debug”下可以直接找到以 Debug 模式编译生成的程序集 App.exe,并按照如下形式修改对应的配置文件(App.exe.config),该配置的目的在于将启动应用时采用的运行时(CLR)版本从默认的2.0切换到4.0。
或者:
无须重新编译(确保运行的依然是同一个程序集)直接运行App.exe,在控制台上得到的输出结果如图2-13所示,可以看到3个程序集的版本全部变成4.0.0.0,也就是说,真正被CLR加载的这些基础程序集与当前CLR的版本是匹配的。
图2-13 加载程序集与运行时版本保持一致
这个简单的实例说明:运行过程中加载的.NET Framework 程序集(承载 FCL 的程序集)是由当前运行时(CLR)决定的,这些程序集的版本总是与 CLR的版本相匹配。包含在元数据中的程序集信息提供目标程序集的名称,而版本则由当前运行的 CLR决定,我们将这个重要的机制称为程序集一致性(Assembly Unification),图2-14揭示了程序集一致性。
图2-14 程序集一致性
Retargetable程序集
在默认情况下,如果某个程序集引用了另一个具有强签名的程序集,那么 CLR在执行的时候总是会根据程序集文件名、版本和公钥令牌定位目标程序集。如果无法找到一个与之完全匹配的程序集,通常会抛出一个 FileNotFoundException 类型的异常。如果当前引用的是一个Retargetable程序集,则意味着CLR在定位目标程序集时可以“放宽”匹配的要求,即只要求目标程序集具有相同的文件名即可。
如图 2-15 所示,应用程序(App)引用了具有强签名的程序集“Foobar,Version=1.0.0.0,Culture=neutral,PublicKeyToken=b03f5f7f11d50a3a”,所以对于编译后生成的程序集App.exe来说,对应的程序集引用将包含目标程序集的文件名、版本和公钥令牌。如果在运行的时候只提供了一个有效名称为“Foobar,Version=2.0.0.0,Culture=neutral,PublicKeyToken=d7fg7asdf7asd7aer”的程序集,除了文件名,后者的版本号和公钥令牌都与程序集引用元数据的描述不一样。在默认情况下,系统此时会抛出一个 FileNotFoundException类型的异常。倘若 Foobar是一个 Retargetable程序集,它将作为目标程序集被正常加载并使用。除了定义程序集的元数据多了如下一个 retargetable 标记,Retargetable程序集与普通程序集并没有本质区别。
图2-15 针对Retargetable程序集的引用
普通程序集:
Retargetable程序集:
retargetable标记可以通过在程序集上标注 AssemblyFlagsAttribute特性设定,但这样的重定向仅仅对.NET Framework自身提供的基础程序集有效。虽然可以通过使用 AssemblyFlagsAttribute特性为自定义的程序集添加一个retargetable标记,但是CLR并不会赋予它重定向的能力。
如果某个程序集引用了一个Retargetable程序集,自身清单文件针对该程序集的引用元数据同样具有如下所示的retargetable标记。CLR正是利用这个标记确定它引用的是否是一个Retargetable程序集,进而确定针对该程序集的加载策略,即采用针对文件名、版本和公钥令牌的完全匹配策略,还是采用只针对文件名的降级匹配策略。
针对普通程序集的引用:
针对Retargetable程序集的引用:
类型转移
在进行框架或者产品升级过程中,经常会遇到针对程序集合并和拆分的场景。例如,在新版本添加额外的API之后需要对现有的API重新进行规划,可能会将定义在程序集A中的类型转移到程序集 B 中。但即使发生了这样的情况,我们依然需要为新框架或者产品提供向后兼容的能力,这就需要使用类型转移(Type Forwarding)的特性。
为了使读者了解类型转移,下面用一个简单的实例进行演示。我们利用 Visual Studio 创建一个针对.NET Framework 3.5的控制台应用App,并在作为程序入口的Main方法中编写了两行代码,从而将两个常用的类型(String 和 Func<>)所在的程序集名打印出来。程序编译之后会在“\bin\Debug”目录下生成可执行文件 App.exe 和对应的配置文件 App.exe.config。从下面的配置文件的内容可以看出,.NET Framework 3.5采用的运行时(CLR)版本为v2.0.50727。
App.exe.config:
如果直接以命令行的形式执行编译生成的 App.exe,就会呈现图 2-16 所示的输出结果。可以看出,两个基础类型(String和 Func<>)中只有 String类型被定义在 mscorlib.dll程序集之中,而 Func<>类型其实被定义在 System.Core.dll 程序集之中。其实,.NET Framework 2.0、.NET Framework 3.0和.NET Framework 3.5不仅仅共享相同的运行时(CLR 2.0),对于提供基础类型的核心程序集 mscorlib.dll 也是共享的。也就是说,.NET Framework 2.0 发布时提供的程序集mscorlib.dll在.NET Framework 3.x时代没有升级。Func<>类型是在.NET Framework 3.5发布时提供的一个基础类型,所以不得不将其定义在一个另一个程序集之中,微软将这个程序集命名为System.Core.dll。
图2-16 Func<>类型所在的程序集(.NET Framework 3.5)
下面验证在.NET Framework 4.0(CLR 4.0)环境下运行同一个应用程序(App.exe)是否会输出不同的结果。为此,在不对项目做重新编译的情况下需要直接修改配置文件 App.exe.config,并将运行时版本设置为v4.0。
图2-17所示是同一个App.exe在.NET Framework 4.0环境下的输出结果,可以看出,提供的两个基础类型所在的程序集都是mscorlib.dll。也就是当.NET Framework升级到4.0之后,不仅运行时升级到了全新的 CLR 4.0,微软也对承载基础类型的 mscorlib.dll 程序集进行了重新规划,所以定义在 System.Core.dll程序集中的基础类型也基本上重新回到了 mscorlib.dll程序集中。
图2-17 Func<>类型所在的程序集(.NET Framework 4.0)
下面继续分析上面演示的程序。由于App.exe最初是针对目标框架.NET Framework 3.5编译生成的,所以它的清单文件将包含针对 mscorlib.dll(2.0.0.0)和 System.Core.dll(3.5.0.0)的程序集引用。下面是针对这两个程序集引用的元数据的定义的代码片段。
当 App.exe 在.NET Framework 4.0 环境中运行时,由于它的元数据提供的是针对 System.Core.dll 程序集的引用,所以 CLR 总是试图加载该程序集并从中定位目标类型(如演示实例中的Func<>类型)。如果当前运行环境无法提供这个程序集,那么一个FileNotFoundException类型的异常就会被抛出。换句话说,虽然Func<>类型在.NET Framework 4.0环境中已经转移到了新的程序集mscorlib.dll中,但当前环境依然需要提供一个文件名为System.Core.dll的程序集。
System.Core.dll 程序集存在的目的是告诉 CLR 它需要加载的类型已经发生转移,并将该类型所在的新的程序集名称告诉它,那么.NET Framework 4.0环境中的 System.Core.dll程序集是如何描述Func<>类型已经转移到mscorlib.dll程序集之中的?如果分析System.Core.dll程序集中的元数据,可以看到如下一段与此相关的代码。在程序集的清单文件中,每个被转移的类型都对应一个.class extern forwarder指令。
不同于上面介绍的 Retargetable程序集,类型的转移并不是只针对.NET Framework提供的基础程序集,如果项目需要提供类似的向后兼容行,也可以使用这个特性。针对类型转移的编程只涉及一个类型为TypeForwardedToAttribute的特性。
可移植类库
在.NET Framework 时代,创建 PCL 是实现跨多个目标框架程序集共享的唯一途径。上面介绍的内容都是在为 PCL 做铺垫,只有充分理解 Retargetable 程序集和类型转移,才可能对PCL的实现原理具有深刻的认识。由于读者可能没有使用 PCL的经历,所以下面先介绍如何创建一个PCL项目。
如果采用Visual Studio(2015)的 Class Library(Portal)项目模板创建一个PCL项目,就需要在图 2-18 所示的对话框中选择支持的目标平台及其版本。Visual Studio 会为新建的项目添加一个名为.NET的引用,这个引用指向一个由选定.NET Framework平台决定的程序集列表。由于这些程序集提供的 API 能够兼容所有选择的平台,所以在此基础上编写的程序也具有平台兼容性。
图2-18 为PCL项目选择目标平台
如果查看这个特殊的.NET 引用所在的地址,我们会发现它指向目录“%Program Files%\ReferenceAssemblies\Microsoft\Framework\.NETPortable\{version}\Profile\ProfileX”。如果查看“%ProgramFiles%\ReferenceAssemblies\Microsoft\Framework\.NETPortable”目录,我们会发现它具有图2-19所示的结构。该目录下具有几个代表.NET Framework版本的子目录(v4.0、v4.5和v4.6)。具体到针对某个.NET Framework 版本的目录(如 v4.6),其子目录“Profile”下具有一系列以“Profile”+“数字”(如Profile31、Profile32和Profile44等)命名的子目录,而PCL项目引用的就是存储在这些目录下的程序集。
图2-19 PCL引用程序集所在目录结构
对于两个不同平台的.NET Framework来说,它们的 BCL在 API的定义上肯定存在交集。从理论上来说,建立在这个交集基础上的程序是可以被这两个平台共享的。如图 2-20 所示,如果我们编写的代码需要分别对 Windows Desktop/Phone、Windows Phone/Store 和 Windows Store/Desktop平台提供支持,那么这样的代码依赖的部分仅限于两两的交集A+B、A+C和A+D。如果要求这部分代码能够运行在 Windows Desktop/Phone/Store这 3个平台上,那么它们只能建立在三者之间的交集A上。
图2-20 针对.NET Framework平台在BCL上的交集
对于所有可能的.NET Framework平台(包括版本)的组合,微软都会将它们在 BCL 上的交集提取出来并定义在相应的程序集中。例如,所有的.NET Framework平台都包含一个核心的程序集mscorlib.dll,虽然定义中的类型及其成员在各个.NET Framework平台不尽相同,但是它们之间肯定存在交集,微软针对不同的.NET Framework平台组合将这些交集提取出来并定义在一系列同名程序集中,同样命名为mscorlib.dll。微软按照这样的方式创建了其他针对不同.NET Framework 平台组合的基础程序集,这些针对某个组合的所有程序集构成一系列的 Profile,并定义在上面提到过的目录下。值得注意的是,所有这些针对某个 Profile 的程序集均为Retargetable程序集。
创建一个 PCL 项目时,第一个必需的步骤是选择兼容的.NET Framework 平台,VisualStudio 会根据我们的选择确定一个具体的 Profile,并为创建的项目添加针对该 Profile 的程序集引用。由于所有引用的程序集是根据我们选择的.NET Framework平台“量身定制”的,所以定义在PCL项目中的代码才具有可移植的能力。
上面仅仅从开发的角度解释了定义在 PCL 项目中的代码本身能够确保与目标.NET Framework平台兼容的原因,但是从运行的角度来看这个问题,却存在另外两个问题。
● 元数据描述的引用程序集与真实加载的程序集不一致。例如,如果创建一个兼容.NET Framework 4.5 和 Silverlight 5.0 的 PCL 项目,被引用的程序集 mscorllib.dll 的版本为2.0.5.0,但是Silverlight 5.0运行时环境中的程序集mscorllib.dll的版本则为5.0.5.0。
● 元数据描述的引用程序集的类型定义与运行时加载程序集类型定义不一致。例如,引用程序集中的某个类型被转移到另一个程序集中。
由于 PCL 项目在编译时引用的均为 Retargetable 程序集,所以程序集的重定向机制可以解决第一个问题。如果 CLR 在加载某个 Retargetable 程序集时找不到一个与引用程序集在文件名、版本、语言文化和公钥令牌完全匹配的程序集,通常只考虑文件名的一致性。第二个问题可以通过上面介绍的类型转移机制解决。