在《.NET Core跨平台的奥秘[上篇]:历史的枷锁》中我们谈到:由于.NET是建立在CLI这一标准的规范之上,所以它天生就具有了“跨平台”的基因。在微软发布了第一个针对桌面和服务器平台的.NET Framework之后,它开始 “乐此不疲” 地对这个完整版的.NET Framework进行不同范围和层次的 “阉割” ,进而造就了像Windows Phone、Windows Store、Silverlight和.NET Micro Framework的压缩版的.NET Framework。从这个意义上讲,Mono和它们并没有本质的区别,唯一不同的是Mono真正突破了Windows平台的藩篱。包括Mono在内的这些分支促成了.NET的繁荣,但我们都知道这仅仅是一种虚假的繁荣而已。虽然都是.NET Framework的子集,但是由于它们采用完全独立的运行时和基础类库,这使我们很难开发一个支持多种设备的“可移植(Portable)”应用,这些分支反而成为制约.NET发展的一道道枷锁。至于为什么“可移植(Portable)”.NET应用的开发如此繁琐呢?
所谓由于目标框架的独立性,意味着不仅仅是作为虚拟机的Runtime是根据具体平台特性设计的,作为编程基础的BCL也不能跨平台共享,它为开发者带来的一个最大的问题就是:很难编写能够在各个目标框架复用的代码。比较极端的场景就是:当我们需要为一个现有的桌面应用提供针对移动设备的支持时,我们不得不从头到尾开发一个全新的应用,现有的代码难以被新的应用所复用用。 “代码复用”是软件设计一项最为根本的目标,在不考虑跨平台的前提下,我们可以应用相应的设计模式和编程技巧来实现代码的重用,但是平台之间的差异导致了跨平台代码重用确实具有不小的困难。虽然作得不算非常的理想,但是微软在这方面确实做出了很多尝试,我们不妨先来聊聊目前我们都有哪些跨平台代码复用的解决方案。
目录
一、源代码复用
源文件共享
文件链接
共享项目
二、程序集复用
程序集一致性
Retargetable程序集
类型的转移
三、可移植类库(PCL)
一、源代码复用
对于包括Mono在内的各个.NET Framework平台的BCL来说,虽然在API定义层面上存在一些共同之处,但是由于它们定义在不同的程序集之中,所以在PCL(Portal Class Library)推出之前,针对程序集的共享是不可能实现的,我们只能在源代码层面实现共享。源代码的共享通过在不同项目之间共享源文件的方式来实现,至于具体采用的方式,我们有三种不同的方案供你选择。
源文件共享
对于一个能够多个针对不同目标框架的项目共享的源文件,定义其中的代码也有不少是针对具体某个目标框架的。对于这种代码,我们需要按照如下的方式进行编写,相应的项目以添加编译的方式选择与自身平台相匹配的代码编译道生成的程序集中。
1: #if WINDOWS 2: <<针对Windows Desktop>> 3: #elif SILVERLIGHT 4: <<针对 Silverlight>> 5: #elif WINDOWS_PHONE 6: <<针对Windows Phone>> 7: #else 8: <<针对其他平台>> 9: #endif |
如果多个针对不同.NET Framework平台的项目文件存在于同一个物理目录下,存在于相同目录下的源文件可以同时包含到这些项目中以实现共享的目的。如下图所示,两个分别针对Silverlight和WPF的项目共享相同的目录,与两个项目文件同在一个目录下的C#文件Shared.cs可以同时被包含到这两个项目之中。
文件链接
当我们采用默认的方式将一个现有的文件添加到当前项目之中的时候,Visual Studio会将目标文件拷贝到项目本地的目录下,所以根本起不到共享的目的。但是针对现有文件的添加支持一种叫做“链接”的方式使添加到项目中的文件指向的依然是原来的地址,我们可以为多个项目添加针对同一个文件的链接以实现源文件跨项目共享。同样还是上面演示分别针对Silverlight和WPF的两个项目,不论项目文件和需要被共享的文件存在于哪个目录下面,我们都可以采用如下图所示的添加文件链接的方式分享这个Shared.cs文件。
共享项目(Shared Project)
普通项目的目的都是组织源文件和其他相关资源并将它们最终编译成一个可被部署的程序集。但是Shared Project这种项目类型则比较特别,它只有对源文件进行组织的功能,却不能通过编译生成程序集,它存在的目的就是为了实现源文件的共享。对于上面我们介绍的两种源代码的共享方式来说,它们都是针对某个单一文件的共享,而Shared Project则可以对多个源文件进行打包以实现批量共享。
如上图所示,我们可以创建一个Shared Project类型的项目Shared.shproj,并将需要共享的三个C#文件(Foo.cs、Bar.cs和Baz.cs)添加进来。我们将针对这个项目的引用同时添加到一个Silverlight项目(SilverlightApp.csproj)和Windows Phone项目(WinPhoneApp.csproj)之中,当我们对这两个项目实施编译的时候,包含在项目Shared.shproj中的三个C#文件会自动作为当前项目的源文件参与编译。
二、程序集复用
我们采用C#、VB.NET这样的编程语言编写的源文件经过编译会生成有IL代码和元数据构成的托管模块,一个或者多个托管模块合并生成一个程序集。程序集的文件名、版本、语言文化和签名的公钥令牌共同组成了它的唯一标识,我们将该标识称为程序集有效名称(Assembly Qualified Name)。除了包含必要的托管模块之外,我们还可以将其他文件作为资源内嵌到程序集中,程序集的文件构成一个“清单(Manifest)”文件来描述,这个清单文件包含在某个托管模块中。
除了作为描述程序集文件构造清单之外,描述程序集的元数据也包含在这个清单文件中。程序集使程序集成为一个自描述性(Self-Describing)的部署单元,除了描述定义在本程序集中所有类型之外,这些元数据还包括对引用自外部程序集的描述。包含在元数据中针对外部程序集的描述是由编译时引用的程序集决定的,引用程序集的名称(包含文件名、版本和签名的公钥令牌)会直接体现在当前程序集的元数据中。针对程序集引用的元数据采用如下的形式(“.assembly extern”)被记录在清单文件中,我们可以看出被记录下来的不仅包含被引用的程序集文件名(“Foo”和“Bar”),还包括程序集的版本,对于签名的程序集(“Foo”)来说,公钥令牌也一并包含其中。
1: .assembly extern Foo 2: { 3: .publickeytoken = (B7 7A 5C 56 19 34 E0 89 ) 4: .ver 1:0:0:0 5: } 6: .assembly extern Bar 7: { 8: .ver 1:0:0:0 9: } |
在回到《.NET Core跨平台的奥秘[上篇]:历史的枷锁》关于.NET多目标框架独立性的问题。虽然不同的目标框架的BCL在API层面具有很多交集,但是这些API实际上被定义在不同的程序集中,这就导致了在不同的目标框架下共享同一个程序集几乎成了不可能的事情。如果要使跨目标平台程序集复用成为现实,就必须要求CLR在加载程序集时放宽“完全匹配”的限制,因为针对当前程序集清单文件中描述的某个引用程序集来说,在不同的目标框架下可能指向不同的程序集。实际上确实存在这样的一些机制或者策略让CLR加载一个与引用元数据的描述不一致的程序集,我们现在就来聊聊这些策略。
程序集一致性
我们都知道.NET Framework是向后兼容的,也就是说原来针对低版本.NET Framework编译生成的程序集是可以直接在高版本CLR下运行的。我们试想一下这么一个问题:就一个针对.NET Framework 2.0编译生成的程序集自身来说,所有引用的基础程序集的版本在元数据描述中都应该是2.0,如果这个程序集在NET Framework 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() 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。也就说在这种情况下,运行时加载的程序集和编译时引用的程序集是一致的。
现在我们在目录“\bin\debug”直接找到以Debug模式编译生成的程序集App.exe,并按照如下的形式修改对应的配置文件(App.exe.config),该配置的目的在于将启动应用时采用的运行时(CLR)版本从默认的2.0切换到4.0。
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> |
在无需重新编译(确保运行的依然是同一个程序集)直接运行App.exe,我们会在控制台上得到如下图所示的输出结果,可以看到三个程序集的版本全部变成了4.0.0.0,也就说真正被CLR加载的这些基础程序集是与当前CLR的版本相匹配的。
这个简单的实例体现了这么一个特征:运行过程中加载的.NET Framework程序集(承载FCL的程序集)是由当前运行时(CLR)决定的,这些程序集的版本总是与CLR的版本相匹配。包含在元数据中的程序集信息提供目标程序集的名称,而版本则由当前运行的CLR来决定,我们将这个重要的机制称为“程序集一致性(Assembly Unification)”,下图很清晰地揭示了这个特性。
Retargetable程序集
在默认情况下,如果某个程序集引用了另一个具有强签名的程序集,CLR在执行的时候总是会根据程序集文件名、版本和公钥令牌去定位目标程序集。如果无法找到一个与之完全匹配的程序集,一般情况下会抛出一个FileNotFoundException类型的异常。如果当前引用的是一个Retargetable程序集,则意味着CLR在定位目标程序集的时候可以 “放宽” 匹配的要求,即指要求目标程序集具有相同的文件名即可。
如下图所示,我们的应用程序(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程序集与普通程序集并没有本质区别。
普通程序集:
.assembly Foobar
Retargetable程序集:
.assembly retargetable Foobar
这样一个retargetable标记可以通过按照如下所示的方式在程序集上应用AssemblyFlagsAttribute特性来添加。不过这样的重定向仅仅是针对.NET Framework自身提供的基础程序集有效,虽然我们也可以通过使用AssemblyFlagsAttribute特性为自定义的程序集添加这样一个retargetable标记,但是CLR并不会赋予它重定向的能力。
[assembly:AssemblyFlags (AssemblyNameFlags.Retargetable)]
如果某个程序集引用了一个Retargetable程序集,自身清单文件针对该程序集的引用元数据同样具有如下所示的retargetable标记。CLR正式利用这个标记确定它引用的是否是一个Retargetable程序集,进而确定针对该程序集的加载策略,即采用针对文件名、版本和公钥令牌的完全匹配策略,还是采用只针对文件名的降级匹配策略。
针对普通程序集的引用:
1: 针对普通程序集的引用 2: .assembly extern Foobar 3: { 4: .publickeytoken = (B7 7A 5C 56 19 34 E0 89 ) 5: .ver 1:0:0:0 6: } |
针对Retargetable程序集的引用:
1: .assembly extern retargetable Foobar 2: { 3: .publickeytoken = (B7 7A 5C 56 19 34 E0 89) 4: .ver 1:0:0:0 5: } |