多读书多实践,勤思考善领悟

解读.NET Core跨平台

本文于1940天之前发表,文中内容可能已经过时。

历史

微软推出的第一个版本的.NET Framework是一个面向Windows桌面和服务器的基础框架,在此之后,为此微软根据设备自身的需求对.NET Framework进行裁剪,不断推出了针对具体设备类型的.NET Framework版本以实现针对移动、平板和嵌入式设备提供支持。除此之外,在Windows平台之外一致游荡着一只特立独行的猴子(Mono)。.NET平台看起来欣欣向荣,而实际上却日薄西山,就在这个时候微软走了一条唯一正确的道路,那就是基于跨平台理念重新设计的.NET Core,以及由此驱动地对整个.NET平台进行全新布局。

对于计算机从业人员来说,“平台(Platform)”是一个我们司空见惯的词语,在不同的语境中它具有不同的语义,比如它可以指代操作系统环境和CPU架构类型,也可以表示硬件设备类型。经过多年的苦心经营,微软已经为在Windows平台下构建了一个完整的支持多种设备的.NET生态系统。与此同时,通过借助于Mono和Xamarin,.NET已经可以被成功移植到包括Mac OS X、Linux、iOS、Android和FreeBSD等非Windows平台。

一、Windows下的.NET

微软在2002年推出了第一个版本的 .NET Framework,这是一个主要面向Windows 桌面(Windows Forms)和服务器(ASP.NET Web Forms)的基础框架。在此之后,PC的霸主地位不断受到其他设备的挑战甚至取代,为此微软根据设备自身的需求对.NET Framework作了相应的简化和改变,不断推出了针对具体设备类型的.NET Framework,主流的包括Windows Phone、Windows Store、Silverlight和.NET Micro Framework等,它们分别对移动、平板和嵌入式设备提供支持。由于这些不同的.NET Framework分支是完全独立的,这使我们很难开发一个支持多种设备的“可移植(Portable)”应用。

.NET Framework的层次结构

针对不同设备.NET Framework的独立性导致了在很多情况下我们不得不针对具体的设备平台进行编程,跨设备平台代码的重用显得异常困难。为了让读者朋友们对这个问题具有深刻地理解,我们从.NET Framework的结构开始讲起。从结构组成的角度来讲,.NET Framework由如下图所示的两个层析构成,它们分别是提供运行环境的CLR(Common Language Runtime)和提供API的FCL(Framework Class Library)。

2-1

CLR之于.NET等同于JVM之于Java,它是.NET虚拟机。作为一个运行时(Runtime),CLR为程序的执行提供一个托管(Managed)的执行环境,它是.NET Framework的执行引擎,为托管程序的执行提供内存分配、垃圾回收、安全控制、异常处理和多线程管理等方面的服务。CLR是.NET Framework的子集,但是两者却具有不同的版本策略。到目前为止,微软仅仅发布了4个版本的CLR,它们分别是1.0、1.1、2.0和4.0,.NET Framework 1.0和1.1分别采用CLR 1.0和1.1,CLR 2.0被.NET Framework 2.0和3.x共享,.NET Framework 4.x下的运行时均为CLR 4.0。

FCL是一个旨在为开发人员提供API的类库,由它提供的API又可以划分为如上图所示的两个层次。处于最底层的部分被称为BCL(Basic Class Library),它提供了一系列基础类型,它们用于描述一些基本的数据类型和数据结构(比如字符串、数字、日期/时间和集合等)和提供一些基础性的操作(比如IO、诊断、反射、文本编码、安全控制、多线程管理等)。在BCL之上的则是面向具体应用类型的API,我们大体上可以将它们划分为入下三种类型:

  • 面向应用(比如ASP.NET、WPF和Windows Forms等)
  • 面向服务(比如WCF、WF和Data Services等)
  • 面向数据(比如ADO.NET、Entity Framework和LinQ to SQL等)

我们也可以采用另一种方式对FCL进行重新划分:将面向某种应用或者服务类型(比如Windows Forms、WPF、ASP.NET和WCF等)的部分成为AppModel,那么整个.NET Framework则具有了如下图所示的三层结构。

2-2

大而全的BCL

我们知道微软的.NET战略是在千禧年提出来的,两年之后第一个.NET Framework版本和IDE(VS.NET 2002)随之问世。在之后的10多年中,一系列版本的.NET Framework被先后推出。微软目前发布的最新.NET Framework版本为4.7,下图为你展示了整个.NET Framework不断升级的演进过程,以及各个版本提供的主要特性。

2-3

上图勾勒出.NET Framework这些年的发展历程旨在说明一个问题:作为整个.NET平台的基础框架,.NET Framework在不断升级过程中是自己变得更加强大和完备,但是在另一方面也是自己变得越来越臃肿。随着版本的不断升级,构成.NET Framework的应用模型、BCL和运行时(CLR)都在不断地膨胀(.NET Framework 2.0/3.x和.NET Framework 4.x分别采用CLR 2.0和CLR 4.0),下图很直观地说明了这个问题。

2-4

我们知道程序集是.NET最基本的部署单元,不论定义其中的多少类型被使用,CLR总是将整个程序集加载到内存中。对于上面介绍的构成.NET Framework的三个层次来说,应用模型是针对具体应用/服务类型的,相应的API通过独立的程序集来承载(比如ASP.NET的核心框架定义在程序集System.Web.dll中,承载整个Windows Forms框架的程序集则是System.Windows.Forms.dll),所以.NET Framework的各个应用模型是相互独立的。在开发某种类型的应用时,我们只需要引用应用模型对应的程序集就可以了,也就是说我们开发一个Windows Forms应用,是不需要去引用System.Web.dll程序集的。

但是BCL的绝大部分核心代码都定义在mscorlib.dll这个核心程序集中,所以BCL基本上来说是作为一个不可分割的整体存在于.NET Framework之中。.NET Framework需要对运行在本机各种类型的托管程序提供支持,针对所有应用类型的基础类型均需要定义在BCL中。在很多情况下,我们的应用可能仅仅需要使用到BCL一个很小的子集,但是我们不得不将定义整个程序集都加载到内存之中。

一方面BCL总是作为一个不可分割的整体被加载,另一方面其自身的尺寸也在随着.NET Framework的升级而不断地膨胀。对于客户端应用(比如Windows Forms/WPF应用)来说,这应该不算是一个大不了的问题,但是对于移动和服务端应用(包括部署于云端应用)来说,由此带来的对性能和吞吐量的响应就成了一个不得不考虑的问题。

理想的BCL消费方式是“按需消费”,我们需要那个部分就加载那个部分。由于作为独立部署单元的程序集总是作为一个整体被CLR加载到内存中,要完全实现这种理想的BCL消费方式,唯一的办法就是将其划分为若干小的单元,并分别定义到独立的程序集中。除此之外,按照模块化的原则对整个BCL进行拆分也是版本升级变得更加容易,如果现有版本具有需要修复的Bug,或者性能需要改进,那么只需要改动并升级相应的模块就可以了。下图展示了具有模块化BCL的.NET Framework层级结构。

2-5

多个设备平台独自为政

经过多年的经营,微软已经为我们构建了一个完整的支持多种设备的.NET生态系统,从最初单纯的桌面平台,逐渐扩展到移动、平板和嵌入式等平台。设备运行环境的差异性导致了针对它们的应用不能构建在一个统一的.NET Framework平台上,所以微软采用独立的.NET Framework平台来对它们提供针对性的支持。就目前来说,除了支持Windows 桌面和服务器设备的“完整版 .NET Framework”之外,微软还先后推出了一系列“压缩版.NET Framework”,这其中就包括Windows Phone、Windows Store、Silverlight和.NET Micro Framework等,它们分别对移动、平板和嵌入式设备提供支持。

这些.NET Framework并不是仅仅在AppModel层次提供针对相应设备平台的开发框架,它们提供的BCL和Runtime也是不同。换句话说,这些.NET Framework平台是完全独立的,不同.NET Framework平台之间的独立性很直观地体现在下图之中。目标平台的独立性导致我们很难编写能够在各个平台复用的代码,关于这一点我们会在下面一节“复用之伤”中做重点讨论。

2-6

二、非Windows下的.NET

尽管微软自身多年以来基本上都只在Windows平台下的一亩三分地上进行耕耘,但是.NET 则通过Mono和Xamarin将触角延伸到其他平台(Mac OS X、Linux、iOS和Android等)。虽然目前做得并不算完美,但是我们可以说.NET具备跨平台的能力。

从CLI谈起

.NET跨平台的能力建立在一种开放的标准或者规范之上,这个所谓的标准/规范就是CLI。CLI的制定旨在解决这样一个问题:由不同(高级)编程语言开发的.NET应用能够在无需任何更改的情况下运行于不同的系统环境下。要实现这个目标,必需有效地解决这里涉及到两种类型的差异,即编程语言的差异和运行时环境的差异。编程语言之间能够实现相互兼容、运行时环境能够得到统一,跨平台的伟业方能实现。

CLI全称为Common Language Infrastructure,其中Common Language说的是语言,具体来说是一种通用语言,它旨在解决各种高级开发语言的兼容性问题。Infrastructure指的则是运行时环境,旨在弥合不同平台之间执行方式的差异。Common Language是对承载应用的二进制内容的静态描述,Infrastructure则表示动态执行应用的引擎,所以CLI为可执行代码和执行引擎确立一个统一的标准。

编程语言有编译型和解释型之别,前者需要通过编译器进行编译以生成可执行代码,CLI涉及的Common Language指的是编译型语言。要实现真正的跨平台,最终需要解决的是可执行代码在不同平台之间的兼容和可移植的问题,而编程语言的选择仅仅决定了应用源文件的原始状态,应用的兼容性和可移植性由编译后的结果来决定。如果通过不同编程语言开发的应用通过相应的编译器编译后能够生成标准的目标代码,那么编程语言之间的差异就不再是一个问题了。

按照CLI的规定,用来描述可执行代码的是一种叫做CIL(Common Intermediate Language)的语言,这是一种介于高级语言和机器语言之间的中间语言。如下图所示,虽然程序源文件由不同的编程语言编写,但是我们可以借助相应的编译器将其编译成CIL代码。原则上讲,我们可以设计出新的编程语言并将其加入到.NET大家庭中,只需配以相应的编译器生成统一的CIL代码即可。我们也可以为现有的某个编程语言设计一种以CIL为目标语言的编译器使之成为.NET语言。CIL是一门中间语言,同时也是一门面向对象的语言,所以对于一个CIL程序来说,类型是基本的组成单元和核心要素。微软制定了一个名为CTS(Common Type System)的规范为CLI确立了一个统一的类型系统。

2-7

编程语言的差异通过编译器这个适配器得以“同一化”,运行环境的差异则可以通过虚拟机(VM:Virtual Machine)技术来解决。虚拟机是CIL的执行容器,它能够在执行CIL代码的过程中采用及时编译的方式将它动态地翻译成与当前执行环境完全匹配的机器指令。虚拟机屏蔽了不同操作系统之间的差异,让目标程序可以不做任何修改的情况下就能运行于不同的底层执行环境中,而CIL实际上是一种虚拟机语言。

2-8

从实现原理来看,让.NET能够跨平台其实不难,但是让各种相关的人员参与进行以构建一个健康而完善的跨平台.NET生态圈则注定不是一件一蹴而就的事情,这里涉及的利益相关方包括编程语言的设计者,以及设计和开发编译器、虚拟机、IDE以及其他相关工具的人,当然还包括广大的应用开发者。跨平台.NET生态环境必须建立在一个标准的规范之上,所以微软为此制定了CLI,然后提交给欧洲计算机制造商协会(ECMA:European Computer Manufacturers Association)并被后者接受,成为了一个编号为335的规范,所以CLI又被称为ECMA-335(顺便说一下,ECMA还接受了微软为C#这们编程语言制定的规范,即ECMA-334)。

Mono与Xamarin

CLI(ECMA-335)这一开放的规范在.NET诞生的那一刻起就赋予了它跨平台的基因,但是被烙上Windows这一印记的微软似乎根本就不曾想过将.NET推广到其他的平台,真正完成这一使命了是一个叫做Mono的项目。虽然Mono已经是一个不算年轻的项目了,但是依然有很多人对它不是很了解,所以我们不妨来简单介绍一下它的历史。

1999年,Miguel de Icaza创建了一家叫做Ximian的公司,这是一家旨在为GNOME项目(这是一个为类Unix系统提供桌面环境的GNU项目,GNOME是目前Linux最常用的桌面环境之一)开发软件和提供支持的公司。2000年6月,微软正式发布.NET Framework,Miguel de Icaza被个“基于互联网的全新开发平台”(.NET在发布的时候被标榜为“a new platform based on Internet standards”)深深吸引。同年11月,微软发布了CLI规范(ECMA-335)并为公众开放了独立实现的许可,Miguel de Icaza从中看到了商机,因为这实际上为.NET走向非Windows平台提供了可能。Miguel de Icaza在2001年7月开启了Mono这个项目,并采用C#作为主要的开发语言(目前支持VB .NET),所以针对CLI和C#的两个ECMA规范是构建Mono项目的理论基础,如果访问Mono的官方网站,我们会发现它是这样定义Mono的:“Mono is an open source implementation of Microsoft’s .NET Framework based on the ECMA standards for C# and the Common Language Runtime.”

Mono的使命不仅仅局限于能够将.NET应用正常运行在其他非Windows平台,它还希望帮助开发人员能够直接在其他平台进行. NET应用的开发,所以Mono不仅仅根据CLI为相应的平台开发了作为虚拟机的CLR和编译器,还提供给了IDE和相应的开发工具(被称为MonoDevelop)。Mono的第一个正式版本(Mono 1.0)在项目开启差不多三年之后(2004年6月)发布。

2003年8月,Ximian被另一家叫做Novell的公司收购,后者继续支持Miguel de Icaza开发Mono项目,在这期间Mono陆续推出了若干Mono 2.x版本。2011年4月,Novell又被另一件叫做Attachmate的公司收购,后者决定放弃Mono,于是Miguel de Icaza带着整个Mono团队成立了一个家新的公司,起名为Xamarin。同年7月,Xamarin向原来的母公司Novell拿到了Mono的开发许可。在此之后的几年内,Xamarin先后发布了Mono 3.x、Mono 4.0和Mono 5.x,目前的最新版本为5.4。Mono现今的目标是实现.NET 4.5除WPF、WF和部分WCF外的所有特性,目前缺失的部分的开发正在通过一个叫做Olive(Mono的一个子项目)的项目进行着。

在Mono项目的基础之上,Xamarin开始开发以新公司命名的产品,其中最重要版本当属2013年2月发布的Xamarin 2.0。Xamarin 2.0由Xamarin.Android、Xamarin.iOS和Xamarin.Windows组成,它们使我们可以采用C#开发针对Android、iOS和Windows的Native应用。除此之外,Xamarin 2.0还携带着一个叫做Xamarin Studio(MonoDevelop的升级版)的IDE以及与一些与Visual Studio集成的工具。2014年5月Xamarin 3.0发布,作为其核心的Xamarin.Forms为不同平台的Native应用提供统一的控件,也就是说我们利用Xamarin.Forms API开发Native应用可以在无需做任何改变的情况下运行在Android、iOS和Windows上。

2016年2月,微软和Xamarin宣布双方签署协议达成了前者针对后者的收购。在2016年Build大会上,微软宣布将整个Xamarin SDK开源,并将它作为一个免费的工具集成到Visual Studio中,Visual Studio企业版的用户还可以免费使用Xamarin企业版的所有特性。

综上所述,由于.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应用的开发如此繁琐。

复用

在微软发布了第一个针对桌面和服务器平台的.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也不能跨平台共享,它为开发者带来的一个最大的问题就是:很难编写能够在各个目标框架复用的代码。比较极端的场景就是:当我们需要为一个现有的桌面应用提供针对移动设备的支持时,我们不得不从头到尾开发一个全新的应用,现有的代码难以被新的应用所复用用。 “代码复用”是软件设计一项最为根本的目标,在不考虑跨平台的前提下,我们可以应用相应的设计模式和编程技巧来实现代码的重用,但是平台之间的差异导致了跨平台代码重用确实具有不小的困难。虽然作得不算非常的理想,但是微软在这方面确实做出了很多尝试,我们不妨先来聊聊目前我们都有哪些跨平台代码复用的解决方案。

三、源代码复用

对于包括Mono在内的各个.NET Framework平台的BCL来说,虽然在API定义层面上存在一些共同之处,但是由于它们定义在不同的程序集之中,所以在PCL(Portal Class Library)推出之前,针对程序集的共享是不可能实现的,我们只能在源代码层面实现共享。源代码的共享通过在不同项目之间共享源文件的方式来实现,至于具体采用的方式,我们有三种不同的方案供你选择。

源文件共享

对于一个能够多个针对不同目标框架的项目共享的源文件,定义其中的代码也有不少是针对具体某个目标框架的。对于这种代码,我们需要按照如下的方式进行编写,相应的项目以添加编译的方式选择与自身平台相匹配的代码编译道生成的程序集中。

1
2
3
4
5
6
7
8
9
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可以同时被包含到这两个项目之中。

2-9_thumb[2\]

文件链接

当我们采用默认的方式将一个现有的文件添加到当前项目之中的时候,Visual Studio会将目标文件拷贝到项目本地的目录下,所以根本起不到共享的目的。但是针对现有文件的添加支持一种叫做“链接”的方式使添加到项目中的文件指向的依然是原来的地址,我们可以为多个项目添加针对同一个文件的链接以实现源文件跨项目共享。同样还是上面演示分别针对Silverlight和WPF的两个项目,不论项目文件和需要被共享的文件存在于哪个目录下面,我们都可以采用如下图所示的添加文件链接的方式分享这个Shared.cs文件。

2-10_thumb[2\]

共享项目(Shared Project)

普通项目的目的都是组织源文件和其他相关资源并将它们最终编译成一个可被部署的程序集。但是Shared Project这种项目类型则比较特别,它只有对源文件进行组织的功能,却不能通过编译生成程序集,它存在的目的就是为了实现源文件的共享。对于上面我们介绍的两种源代码的共享方式来说,它们都是针对某个单一文件的共享,而Shared Project则可以对多个源文件进行打包以实现批量共享。

2-11_thumb[2\]

如上图所示,我们可以创建一个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
2
3
4
5
6
7
8
9
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: }

包含在当前程序集清单文件中针对引用程序集的元数据是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在决定加载它所依赖程序集的时候,应该选择2.0还是4.0呢?

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

1
2
3
4
5
6
7
8
9
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。也就说在这种情况下,运行时加载的程序集和编译时引用的程序集是一致的。

2-11_thumb4

现在我们在目录“\bin\debug”直接找到以Debug模式编译生成的程序集App.exe,并按照如下的形式修改对应的配置文件(App.exe.config),该配置的目的在于将启动应用时采用的运行时(CLR)版本从默认的2.0切换到4.0。

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

或者:

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

在无需重新编译(确保运行的依然是同一个程序集)直接运行App.exe,我们会在控制台上得到如下图所示的输出结果,可以看到三个程序集的版本全部变成了4.0.0.0,也就说真正被CLR加载的这些基础程序集是与当前CLR的版本相匹配的。

2-12_thumb2

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

2-13png_thumb[3\]

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程序集,我们提供的将作为目标程序集被加载并使用。

2-14_thumb[2\]

除了定义程序集的元数据多了如下一个retargetable标记之外,Retargetable程序集与普通程序集并没有本质区别。

普通程序集:

1
.assembly Foobar

Retargetable程序集:

1
.assembly retargetable Foobar

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

1
[assembly:AssemblyFlags(AssemblyNameFlags.Retargetable)]

如果某个程序集引用了一个Retargetable程序集,自身清单文件针对该程序集的引用元数据同样具有如下所示的retargetable标记。CLR正式利用这个标记确定它引用的是否是一个Retargetable程序集,进而确定针对该程序集的加载策略,即采用针对文件名、版本和公钥令牌的完全匹配策略,还是采用只针对文件名的降级匹配策略。

针对普通程序集的引用:

1
2
3
4
5
6
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
2
3
4
5
1: .assembly extern retargetable Foobar
2: {
3: .publickeytoken = (B7 7A 5C 56 19 34 E0 89)
4: .ver 1:0:0:0
5: }

类型的转移

在进行框架或者产品升级过程,我们经常会遇到针对程序集的合并和拆分的场景,比如在新版本中需要对现有的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” 。

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

App.exe.config

1
2
3
4
5
1: <configuration>
2: <startup>
3: <supportedRuntime&nbsp;version="v2.0.50727"/></startup>
4: </startup>
5: </configuration>

现在我们直接以命令行的执行执行编译生成的App.exe后会在控制台上得到如下图所示的输出结果。可以看出对于我们给出的这两个基础类型(String和Func<>),只有String类型被定义在程序集mscorlib.dll之中,而类型Func<>其实被定义在另一个叫做System.Core.dll的程序集之中。其实Framework 2.0、3.0和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-15_thumb2

现在我们看看.NET Framework 4.0(CLR 4.0)环境下运行同一个应用程序(App.exe)是否会有不同的输出结果。为此我们在不对项目做重新编译情况下直接修改配置文件App.exe.config,并按照如下所示的方式将运行时版本设置为4.0。

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

下图是同一个App.exe在.NET Framework 4.0环境下的输出结果,可以看出我们提供的两个基础类型所在的程序集都是mscorlib.dll。也就是当.NET Framework升级到4.0之后,不仅仅运行时升级到了全新的CLR 4.0,微软同时也对承载基础类型的mscorelib.dll程序集进行了重新规划,所以定义在System.Core.dll程序集中的基础类型也基本上又重新回到了mscorlib.dll这个本应该属于它的程序集中。

2-16_thumb2

我们来继续分析上面演示的这个程序。由于App.exe这个程序集最初是针对目标框架.NET Framework 3.5编译生成的,所以它的清单文件将包含针对mscorlib.dll(2.0.0.0)和System.Core.dll(3.5.0.0)的程序集引用。下面的代码片段展示了针对这两个程序集引用的元数据的定义。

1
2
3
4
5
6
7
8
9
10
 1: .assembly extern mscorlib
2: {
3: .publickeytoken = (B7 7A 5C 56 19 34 E0 89 )
4: .ver 2:0:0:0
5: }
6: .assembly extern System.Core
7: {
8: .publickeytoken = (B7 7A 5C 56 19 34 E0 89 )
9: .ver 3:5:0:0
10: }

当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<>已经转移到程序集mscorelib.dll之中了呢?如果分析程序集System.Core.dll中的元数据,我们可以看到如下一段于此相关的代码。在程序集的清单文件中,每一个被转移的类型都对应这个这么一个 “.class extern forwarder” 指令。

1
2
3
4
1: .class extern forwarder System.Func`1
2: {
3: .assembly extern mscorlib
4: }

不同于上面介绍的Retargetable程序集,类型的转移并不是只针对.NET Framework提供的基础程序集,如果我们自己开发的项目也需要提供类似的向后兼容性,也可以使用这个特性。针对类型转移类型的编程只涉及到一个类型为TypeForwardedToAttribute的特性,接下来我们通过一个简单的实例来演示一下如何利用这个特性将某个类型转移到一个新的程序集中。

我们利用Visual Studio创建了如下图所示的解决方案,它演示了这样一个场景:控制台应用使用到了V1版本的类库Lib(v1\Lib),其中涉及到一个核心类型Foobar。该类库升级到V2版本时,我们选择将所有的核心类型统一定义在新的程序集Lib.Core中,所以类型Foobar需要转移到Lib.Core中。作为类库的发布者,我们希望使用到V1版本的应用能够直接升级到V2版本,也就是升级的应用不需要在引用新的Lib.Core程序集情况下对源代码进行重新编译,而是直接部署V2版本的两个程序集(Lib.dll和Lib.Core)就可以了。

2-17_thumb2

上图中的虚线箭头和实线箭头分别代表项目之间的引用关系,我们从中可以看出v2目录下的Lib项目具有对Lib.Core项目的引用,因为它需要引用转移到Lib.Core项目中的类型。为了完成针对类型Foobar的转移,我们只需要在v2\Lib中定义如下一行简单的代码就可以了,我们将这行代码定义在AssemblyInfo.cs文件中。

1
[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(Lib.Foobar))]

为了检验针对Foobar类型的转移是否成功,我们在控制台应用App中定义了如下一段程序,它负责将Foobar类型当前所在程序集的名称输出到控制台上。接下来我们只需要编译(以Debug模式)整个解决方案,那么V2版本的两个程序集(Lib.dll和Lib.Core.dll)将保存到\v2\lib\bin\debug\目录下。

1
2
3
4
5
6
7
1: class Program
2: {
3: static void Main()
4: {
5: Console.WriteLine(typeof(Foobar).Assembly.FullName);
6: }
7: }

接下来我们采用命令行的形式来运行控制台程序App.exe。如下图所示,我们将当前目录切换到App.exe所在的目录(\app\bin\debug)下并执行App.exe,输出的结果表明Foobar类型当前所在的程序集为Lib.dll。接下来我们将针对V2版本的两个程序集拷贝进来后再次执行App.exe,我们发现此时的Foobar类型已经是从新的程序集Lib.Core.dll中加载的了。

2-18_thumb2

我们顺便来查看一下V2版本程序集Lib.dll的清单文件的内容。如下面的代码片段所示,在源代码中通过使用TypeForwardedToAttribute特性定义的类型转移在编译之后被转换成了一个“.class extern forwarder”指令。

1
2
3
4
5
6
7
8
9
1: .assembly extern Lib.Core
2: {
3: .ver 1:0:0:0
4: }
5: .class extern forwarder Lib.Foobar
6: {
7: .assembly extern Lib.Core
8: }
9: …

五、可移植类库(PCL)

在.NET Framework的时代,创建可移植类库(PCL:Portable Class Library)是实现跨多个目标框架程序集共享的唯一途径。上面介绍的内容都是在为PCL做铺垫,只有充分理解了Retargetable程序集和类型转移的前提下才可能了解PCL的实现原理有正确的理解。考虑到很多读者朋友并没有使用PCL的经历,所以我们先来介绍一下如何创建一个PCL项目。 当我们采用Visualization Studio的Class Library(Portal)项目模板创建一个PCL项目的时候,需要在如下图所示的对话框中选择支持的目标框架及其版本。Visual Studio会为新建的项目添加一个名为 “.NET” 的引用,这个引用指向一个由选定目标框架决定的程序集列表。由于这些程序集提供的API能够兼容所有选择的平台,我们在此基础编写的程序自然也具有平台兼容性。

2-20_thumb2

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

2-21_thumb2

如上图所示,本机所在目录“%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来说,它们的BCL在API的定义上存在交集,从理论上来说,建立在这个交集基础上的程序是可以被这两个平台中共享的。如下图所示,如果我们编写的代码需要分别对Windows Desktop/Phone、Windows Phone/Store和Windows Store/Desktop平台提供支持,那么这样的代码依赖的部分仅限于两两的交集A+B、A+C和A+D。如果要求这部分代码能够运行在Windows Desktop/Phone/Store三个平台上,那么它们只能建立在三者之间的交集A上。

2-22_thumb1

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

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

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

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

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

综上所述,虽然微软在针对多个目标框架的代码复用上面为我们提供了一些解决方案。在源代码共享方面,我们可以采用共享项目,虽然共享项目能够做到将一组源文件进行打包复用,但是我个人基本上不怎么用它,因为如果我们在其中定义一些公有类型,那么引用该共享项目的项目之间会造成命名冲突。从另一方面讲,我们真正需要的是程序集层面的复用,但是在这方面微软只为我们提供了PCL。PCL这种采用提取目标框架API交集的方式注定了只能是一种临时的解决方案,试着想一下:如果目标框架由10种,每种有3个版本,我们需要为多少种组合创建相应的Profile。对于开发者来说,如果目标框架(包括版本),我们在创建PCL项目进行兼容框架的选择都会成问题。所以我们针对希望的是能够提供给全平台支持的BCL,你可以已经知道了,这就是Net Standard,那么Net Standard是如何能够在多个目标框架中复用的呢?

全新布局

从本质上讲,按照CLI规范设计的.NET从其出生的那一刻就具有跨平台的基因,这与Java别无二致。由于采用了统一的中间语言,微软只需要针对不同的平台设计不同的虚拟机(运行时)就能弥合不同操作系统与处理器架构之间的差异,但是“理想很丰满,现实很骨感”。在过去十多年中,微软将.NET引入到了各个不同的应用领域,表面上看起来似乎欣欣向荣,但是由于采用完全独立的多目标框架的设计思路,导致针对多目标框架的代码平台只能通过PCL,这种“妥协”的方式来解决。如果依然按照这条道路走下去,.NET的触角延伸得越广,枷锁将越来越多,所以.NET 已经到了不得不做出彻底改变的时刻了。

六、跨平台的.NET Core

综上所述,要真正实现.NET 的跨平台伟业,主要需要解决两个问题,一是针对不同的平台设计相应的运行时为中间语言CIL提供一个一致性的执行环境,而是提供统一的BCL以彻底解决代码复用的难题。对于真正跨平台的.NET Core来说,微软不仅为它设计了针对不同平台被成为CoreCLR的运行时,同时还重新设计了一套被称为CoreFX的BCL。

2-221

如上图所示,NET Core目前支持的AppModel主要有两种,其中ASP.NET Core用于开发服务器Web应用和服务,而UWP(Universal Windows Platform)则用于开发能够在各种客户端设备(Mobile、PC、Xbox、Devices + IOT、HoloLens和Surface Hub等)上以自适应方式运行的Windows 10应用。CoreFX是经过完全重写的BCL,除了自身就具有跨平台执行的能力之外,其提供的API也不再是统一定义在少数几个单一的程序集中,而是经过有效分组之后被定义在各自独立的模块中。这些模块对应着一个单一的程序集,并最终由对应的NuGet包来分发。至于底层的虚拟机,微软则为主流的操作系统类型(Windows、Mac OS X和Linux)和处理器架构(x86、x64和ARM)设计了针对性的运行时,被称为CoreCLR。

作为运行时的CoreCLR和提供BCL的CoreFX是.NET Core两根重要的基石,但是就开发成本来看,微软在后者投入的精力是前者无法比拟的。我们知道.NET Core自诞生到现在已经有好些年了,目前的版本还只是到了2.0,从发布进度上显得稍显缓慢,其中一个主要的原因是:重写CoreFX提供的基础API确实是一件繁琐耗时的工程,而且这项工程远未结束。为了对CoreFX提供的BCL有一个大致的了解,我们看看这些常用的基础API究竟定义在哪些命名空间下。

  • System.Collections:定义了我们常用的集合类型。
  • System.Console:提供API完成基本的控制台操作。
  • System.Data:提供用于访问数据库的API,相当于原来的ADO.NET。
  • System.Diagnostics:提供基本的诊断、调试和追踪的API。
  • System.DirectoryServices:提供基于AD(Active Directory)管理的API。
  • System.Drawing:提供GDI相关的API。
  • System.Globalization:提供API实现多语言以及全球化支持。
  • System.IO:提供针对文件输入输出相关的API。
  • System.Net:提供与网络通信相关的API。
  • System.Reflection:提供API以实现与反射相关的操作。
  • System.Runtime:提供与运行时相关的一些基础类型。
  • System.Security:提供与数据签名和加解密相关的API。
  • System.Text:提供针对字符串/文本编码与解码相关的API。
  • System.Threading:提供用于管理线程的API。
  • System.Xml:提供API用以操作XML结构的数据。

我们知道对于传统的.NET Framework来说,承载BCL的API几乎都定义在mscorlib.dll这个程序集中,这些API并不是全部都转移到组成CoreFX的众多程序集中,那些与运行时(CoreCLR)具有紧密关系的底层API被定义到一个叫做System.Private.CoreLib.dll的程序集中,所以下图反映了真正的.NET Core层次结构。我们在编程过程中使用的基础数据类型基本上都定义在这个程序集中,所以目前这个程序集的尺寸已经超过了10M。由于该程序集提供的API与运行时关联较为紧密,较之CoreFX提供的API,这些基础API具有较高的稳定性,所以它是随着CoreCLR一起发布的。

2-23

虽然我们编程过程中使用到的绝大部分基础类型都定义在System.Private.CoreLib.dll程序集中,但是这却是一个“私有”的程序集,我们可以从其命名看出这一点。我们将System.Private.CoreLib.dll称为一个私有程序集,并不是说定义其中的都是一些私有类型,而是因为我们在编程的过程不会真正引用这个程序集,这与.NET Framework下的mscorlib.dll是不一样的。不仅如此,当我们编写的.NET Core代码被编译的时候,编译器也不会链接到这个程序集上,也就是说编译后生成的程序集中同样也没有针对该程序集引用的元数据。但是当我们的应用被真正执行的时候,所有引用的基础类型全部会自动 “转移” 到这个程序集中。至于如何实现运行过程中的类型转移,其实就是利用了我们上面介绍的Type Forwarding技术。

实例演示:针对System.Private.CoreLib.dll程序集的类型转移

对上面介绍的针对System.Private.CoreLib.dll程序集的类型转移,可能很多人还是难以理解,为了让大家对这个问题具有彻底的认识,我们不妨来做一个简单的实例演示。我们利用Visual Studio创建一个.NET Core控制台应用,并在作为程序入口的Main方法中编写如下几行代码,它们会将我们常用的几个数据类型(System.String、System.Int32和System.Boolean)所在的程序集名称打印在控制台上。

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

根据我们上面的分析,程序运行过程中使用到的这些基础类型全部来源于System.Private.CoreLib.dll这个程序集中,关于这一点在如下图所示的输出结果中得到了证实。我们通过图2-24所示的输出结果,我们不仅仅知道了这个核心程序集的名称,还知道了该程序集目前的版本(4.0.0.0);

2-24

我们说应用编译后生成的程序集并不会具有针对System.Private.CoreLib.dll程序集引用的元数据,为了证明这一点,我们只需要利用Windows SDK(在目录“%ProgramFiles(x86)%Microsoft SDKs\Windows{version}\Bin”下)提供的反编译工具ildasm.exe就可以了。利用ildasm.exe打开这个控制台应用编译后生成的程序集之后,我们会发现它具有如下这两个程序集的应用。

1
2
3
4
5
6
7
8
9
10
 1: .assembly extern System.Runtime
2: {
3: .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
4: .ver 4:2:0:0
5: }
6: .assembly extern System.Console
7: {
8: .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
9: .ver 4:1:0:0
10: }

实际上我们的程序只涉及到四个类型,即一个Console类型和三个基础数据类型(String、Int32和Boolean),而程序集层面则只有针对System.Runtime和System.Console程序集的引用,那么毫无疑问,后面这三个数据类型肯定与System.Runtime程序集有关,那么该程序集针对这三个数据类型具有怎样的定义呢?为了得到答案,我们先得知道这个程序集究竟被保存在哪里。我们知道“%ProgramFiles%dotnet\”是.NET Core的应用根目录,而这个System.Runtime.dll作为“共享”程序集被保存在子目录“\shared\Microsoft.NETCore.App\2.0.0”下面,这个目录下面还保存着很多其他的共享程序集。

我们依然利用反编译工具ildasm.exe查看System.Runtime.dll程序集清单文件的元数据定义。我们会发现整个程序集除了定义少数几个核心类型(比如两个重要的委托类型Action和Func就定义在这个程序集中),它的作用就是将所有基础的类型采用Type Forwarding的方式转移到System.Private.CoreLib.dll程序集中,下面的代码片段为你展示了针对我们程序使用的三个基础数据类型转移的相关定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 1: .assembly extern System.Private.CoreLib
2: {
3: .publickeytoken = (7C EC 85 D7 BE A7 79 8E )
4: .ver 4:0:0:0
5: }
6: .class extern forwarder System.String
7: {
8: .assembly extern System.Private.CoreLib
9: }
10: .class extern forwarder System.Int32
11: {
12: .assembly extern System.Private.CoreLib
13: }
14: .class extern forwarder System.Boolean
15: {
16: .assembly extern System.Private.CoreLib
17: }

我们演示实例体现的程序集直接的引用关系,以及如上代码片段体现的相关基础类型(System.String、System.Int32和System.Boolean)的转移方向基本体现在如下图所示的关系图中。

2-25

复用.NET Framework程序集

我们将上述这种利用Type Forwarding方式实现跨程序集类型转移的技术成为“垫片(Shim)”,这是实现程序集跨平台复用的重要手段。除了System.Runtime.dll,.NET Core还提供了其他一些其他垫片程序集,正是源于这这些垫片程序集的存在,我们可以将在.NET Framework环境下编译的程序集在.NET Core应用中使用。为了让读者朋友们对此有深刻的认识,我们照例来做一个简单的实例演示。

我们利用Visual Studio创建一个空的解决方案,并添加如下三个项目(NetApp、NetCoreApp、NetLib),其中NetApp和NetCoreApp分别是针对.NET Framework(4.7)和.NET Core(2.0)的控制台程序,而NetLib则是针对.NET Framework的类库项目,该项目定义的API将在NetApp和NetCoreApp被调用。

2-26

我们在NetLib项目中定义了一个Utils工具类,并在其中定义了一个PrintAssemblyNames方法。如下面的代码片段所示,我们在这个方法中打印出三个常用的类型(Task、Uri和XmlWriter)所在的程序集的名称。通过在不同类型(.NET Framework和.NET Core)的应用中调用这个方法,我们就可以确定它们在运行时究竟是从那个程序集中加载的。我们分别在NetApp和NetCoreApp这两个不同类型的控制台程序中调用了这个方法。

NetLib:

1
2
3
4
5
6
7
8
9
1: public class Utils
2: {
3: public static void PrintAssemblyNames()
4: {
5: Console.WriteLine(typeof(Task).Assembly.FullName);
6: Console.WriteLine(typeof(Uri).Assembly.FullName);
7: Console.WriteLine(typeof(XmlWriter).Assembly.FullName);
8: }
9: }

NetApp:

1
2
3
4
5
6
7
8
1: class Program
2: {
3: static void Main()
4: {
5: Console.WriteLine(".NET Framework 4.7");
6: Utils.PrintAssemblyNames();
7: }
8: }

NetCoreApp:

1
2
3
4
5
6
7
8
1: class Program
2: {
3: static void Main()
4: {
5: Console.WriteLine(".NET Core 2.0");
6: Utils.PrintAssemblyNames();
7: }
8: }

直接运行NetApp和NetCoreApp这两个控制台程序后,我们会发现不同的输出结果。如下图所示,对于我们指定的三个类型(System.Threading.Tasks.Task、System.Uri和System.Xml.XmlWriter),分别在.NET Framework和.NET Core环境下承载它们的程序集是不同的。具体来说,.NET Framework环境下的这三个类型分别定义在mscorlib.dll、System.dll和System.Xml.dll中;当切换到.NET Core环境下后,运行时则会从三个私有的程序集System.Private.CoreLib.dll、System.Private.Uri.dll和System.Private.Xml.dll中加载这三个类型。

2-27

由于NetApp和NetCoreApp这两个控制台应用使用的都是同一个针对.NET Framework编译的程序集NetLib.dll,所以我们先利用反编译工具ildasm.exe查看一下它具有怎样的程序集引用。如下面的代码片段所示,程序集NetLib.dll引用的程序集与控制台应用NetApp的输出结果是一致的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 1: .assembly extern mscorlib
2: {
3: .publickeytoken = (B7 7A 5C 56 19 34 E0 89 )
4: .ver 4:0:0:0
5: }
6: .assembly extern System
7: {
8: .publickeytoken = (B7 7A 5C 56 19 34 E0 89 )
9: .ver 4:0:0:0
10: }
11: .assembly extern System.Xml
12: {
13: .publickeytoken = (B7 7A 5C 56 19 34 E0 89 )
14: .ver 4:0:0:0
15: }

那么我们的核心问题变成了:Task、Uri和XmlWriter这三个类型在.NET Core的运行环境下是如何转移到其他程序集中的。要回答这个问题,我们只需要利用ildasm.exe查看mscorlib.dll、System.dll和System.Xml.dll反编译这三个程序集就可以了。这三个程序集同样存在于“%ProgramFiles%dotnet\shared\Microsoft.NETCore.App\2.0.0”目录下,通过反编译与它们相关的程序集,我们得到如下所示的相关元数据。

mscorlib.dll

1
2
3
4
5
6
7
8
9
1: .assembly extern System.Private.CoreLib
2: {
3: .publickeytoken = (7C EC 85 D7 BE A7 79 8E )
4: .ver 4:0:0:0
5: }
6: .class extern forwarder System.Threading.Tasks.Task
7: {
8: .assembly extern System.Private.CoreLib
9: }

System.dll

1
2
3
4
5
6
7
8
9
1: .assembly extern System.Private.Uri
2: {
3: .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
4: .ver 4:0:4:0
5: }
6: .class extern forwarder System.Uri
7: {
8: .assembly extern System.Private.Uri
9: }

System.Xml.dll

1
2
3
4
5
6
7
8
9
1: .assembly extern System.Xml.ReaderWriter
2: {
3: .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
4: .ver 0:0:0:0
5: }
6: .class extern forwarder System.Xml.XmlWriter
7: {
8: .assembly extern System.Xml.ReaderWriter
9: }

System.Xml.ReaderWriter.dll

1
2
3
4
5
6
7
8
9
1: .assembly extern System.Private.Xml
2: {
3: .publickeytoken = (CC 7B 13 FF CD 2D DD 51 )
4: .ver 4:0:0:0
5: }
6: .class extern forwarder System.Xml.XmlWriter
7: {
8: .assembly extern System.Private.Xml
9: }

如上面的代码片段所示,针对Task、Uri和XmlWriter这三个类型的转移一共涉及到七个程序集,其中mscorlib.dll、System.dll和System.Xml.dll是NetLib.dll直接引用的三个程序集,而System.Private.CoreLib.dll、System.Private.Uri.dll和System.Private.Xml.dll则是最终承载这三个类型的程序集。对于Task和Uri类型来说,它们只经历一次转移,而XmlWriter则经历了两次类型转移,它转移到程序集System.Xml.ReaderWriter.dll中,再借助后者转移到目标程序集System.Private.Xml.dll,程序集引用和类型转移关系体现在下图中。

2-28

七、多平台复用的BCL

虽然.NET Core借助于CoreCLR和CoreFX实现了真正的跨平台,但是目前的.NET Core仅仅提供ASP.NET Core和UWP这两种编程模型,虽然后者旨在实现多种设备的统一编程,但依然还是关注于Windows平台。对于传统.NET Framework下面向桌面应用的WPF和Windows Forms,它们并没有跨平台的意义,所以依然是今后.NET的一大分支。除此之外,虽然我们有了跨平台的ASP.NET Core,传统的ASP.NET依然被保留了下来,并且在今后一段时间内还将继续升级。除了.NET Framework和.NET Core,.NET还具有另一个重要的分支,那就是Xamarin,它可以帮助我们为iOS、OS X和Android编写统一的应用。在.NET诞生十多年后,微软开始对.NET进行了全新的布局,建立了 “大一统” 的.NET平台。总的来说,这个所谓的大一统.NET平台由如下图所示的.NET Framework、.NET Core和Xamarin这三个分支组成。

2-29

虽然被微软重新布局的.NET平台只包含了三个分支,但是之前遇到的一个重要的问题依然存在,那就是代码的复用,说的更加具体的是应该是程序集的复用而不是源代码的复用。我们知道之前解决程序集服务的方案就是PCL,但这并不是一种理想的解决方案,由于各个目标框架具有各种独立的BCL,所以我们创建的PCL项目只能建立在指定的几种兼容目标框架的BCL交集之上。对于全新的.NET平台来说,这个问题通过提供统一的BCL得到根本的解决,这个统一的BCL被称为.NET Standard。

我们可以将.NET Standard称为新一代的PCL,PCL提供的可移植能力仅仅限于创建时就确定下来的几种目标平台,但是.NET Standard做得更加彻底,因为它在设计的时候就已经考虑针对三大分支的复用。如下图所示,.NET Standard为.NET Framework、.NET Core和Xamarin提供了统一的API,那么我们在这组标准API基础上编写的代码自然就能被所有类型的.NET应用复用。

2-30

.NET Standard提供的API主要是根据现有.NET Framework来定义的,它的版本升级反映了其提供的API不断丰富的过程,目前最新版本(.NET Standard 2.0)提供的API数量在前一版本基础上几乎翻了一番。Visual Studio提供相应的项目模板帮助我们创建基于.NET Standard的类库项目,这样的项目会采用专门的目标框架别名netstandard{version}。一个针对.NET Standard 2.0的类库项目具有如下的定义,我们可以看到它采用的目标框架别名为 “.NET Standard 2.0” 。

1
2
3
4
5
1: <Project Sdk="Microsoft.NET.Sdk">
2: <PropertyGroup>
3: <TargetFramework>netstandard2.0</TargetFramework>
4: </PropertyGroup>
5: </Project>

顾名思义,.NET Standard仅仅是一个标准,而不提供具体的实现。我们可以简单理解为.NET Standard为我们定义了一整套标准的接口,各个分支需要针对自身的执行环境对这套接口提供实现。对于.NET Core来说,它的基础API主要由CoreFX和System.Private.CoreLib.dll这个核心程序集来承载,这些API基本上就是根据.NET Standard来设计的。但是对.NET Framework来说,它的BCL提供的API与.NET Standard存在着很大的交集,实际上.NET Standard基本上就是根据.NET Framework现有的API来设计的,所以微软不可能在.NET Framework上重写一套类型于CoreFX的实现,只需要采用某个技术 “链接” 到现有的程序集上就可以了。

一个针对.NET Standard编译生成的程序集在不同的执行环境中针对真正提供实现的程序集的所谓“链接”依然是通过上面我们介绍的“垫片”技术来实现的,为了彻底搞清楚这个问题,我们还是先来作一个简单的实例演示。如下图所示,我们创建了与上面演示实例具有类似结构的解决方案,与之不同的是,分别针对.NET Framework和.NET Core的控制台应用NetApp和NetCoreApp共同引用的类库NetStandardLib是一个.NET Standard 2.0类库项目。

2-31

与上面演示的实例一样,我们在NetStandardLib中定义了如下一个Utils类,并利用定义其中的静态方法PrintAssemblyNames数据两个数据类型(Dictionary<,>和SortedDictionary<,>)所在的程序集名称,该方法分别在NetApp和NetCoreApp的入口Main方法中被调用。

NetStandardLib:

1
2
3
4
5
6
7
8
1: public class Utils
2: {
3: public static void PrintAssemblyNames()
4: {
5: Console.WriteLine(typeof(Dictionary<,>).Assembly.FullName);
6: Console.WriteLine(typeof(SortedDictionary<,>).Assembly.FullName);
7: }
8: }

NetApp:

1
2
3
4
5
6
7
8
1: class Program
2: {
3: static void Main()
4: {
5: Console.WriteLine(".NET Framework 4.7");
6: Utils.PrintAssemblyNames();
7: }
8: }

NetCoreApp:

1
2
3
4
5
6
7
8
1: class Program
2: {
3: static void Main()
4: {
5: Console.WriteLine(".NET Core 2.0");
6: Utils.PrintAssemblyNames();
7: }
8: }

直接运行这两个分别针对.NET Framework和.NET Core的控制台应用NetApp和NetCoreApp,我们会发现它们会生成不同的输出结果。如下图所示,在.NET Framework和.NET Core 执行环境下,Dictionary<,>和SortedDictionary<,>这另个泛型字典类型其实来源于不同的程序集。具体来说,我们常用的Dictionary<,>类型在.NET Framework 4.7和.NET Core 2.0环境下分别定义在程序集mscorlib.dll和System.Private.CoreLib.dll中,而SortedDictionary<,>所在的程序集则分别是System.dll和System.Collection.dll。

2-32

对于演示的这个实例来说,这个NetStandardLib类库项目针对的目标框架为.NET Standard 2.0,后者最终体现为一个名为NetStandard.Library.nupkg的NuGet包,这一点其实可以从Visual Studio针对该项目的依赖节点可以看出来。如下图所示,这个名为NetStandard.Library的NuGet包具有一个核心的程序集netstandard.dll,上面我们所说的.NET Standard API就定义在该程序集中。

2-33

也就是说,所有.NET Standard 2.0项目都具有针对程序集netstandard.dll的依赖,这个依赖自然也会体现在编译后生成的程序集上。对于我们演示实例中的这个类库项目NetStandardLib编译生成的同名程序集来说,它针对程序集netstandard.dll的依赖体现在如下所示的元数据中。

1
2
3
4
5
6
7
8
9
10
 1: .assembly extern netstandard
2: {
3: .publickeytoken = (CC 7B 13 FF CD 2D DD 51 )
4: .ver 2:0:0:0
5: }
6: .assembly NetStandardLib
7: {
8: ...
9: }
10: ...

按照我们即有的知识,原本定义在netstandard.dll的两个类型(Dictionary<,>和SortedDictionary<,>)在不同过的执行环境中需要被转移到另一个程序集中,我们完全可以在相应的环境中提供一个同名的垫片程序集并借助类型的跨程序集转移机制来实现,实际上微软也就是这么做的。我们先来看看针对.NET Framework的垫片程序集netstandard.dll的相关定义,我们可以直接在NetApp编译的目标目录中找到这个程序集。借助于反编译工具ildasm.exe,我们可以很容易地得到与Dictionary<,>和SortedDictionary<,>这两个泛型字典类型转移的相关元数据,具体的内容下面的代码片段所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 1: .assembly extern mscorlib
2: {
3: .publickeytoken = (B7 7A 5C 56 19 34 E0 89 )
4: .ver 0:0:0:0
5: }
6: .assembly extern System
7: {
8: .publickeytoken = (B7 7A 5C 56 19 34 E0 89 )
9: .ver 0:0:0:0
10: }
11: .class extern forwarder System.Collections.Concurrent.ConcurrentDictionary`2
12: {
13: .assembly extern mscorlib
14: }
15: .class extern forwarder System.Collections.Generic.SortedDictionary`2
16: {
17: .assembly extern System
18: }

针对.NET Core的垫片程序集netstandard.dll被保存在我们前面提到的共享目录“%ProgramFiles%dotnet\shared\Microsoft.NETCore.App\2.0.0”下,我们采用同样的方式提取出与Dictionary<,>和SortedDictionary<,>这两个泛型字典类型转移的元数据。从如下的代码片段我们可以清晰地看出,Dictionary<,>和SortedDictionary<,>这两个类型都被转移到程序集System.Collections.dll之中。

1
2
3
4
5
6
7
8
9
10
11
12
13
 1: .assembly extern System.Collections
2: {
3: .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
4: .ver 0:0:0:0
5: }
6: .class extern forwarder System.Collections.Generic.Dictionary`2
7: {
8: .assembly extern System.Collections
9: }
10: .class extern forwarder System.Collections.Generic.SortedDictionary`2
11: {
12: .assembly extern System.Collections
13: }

从演示实例的执行结果我们知道,SortedDictionary<,>确实是定义在程序集System.Collections.dll中,但是我们常用的Dictionary<,>类型则出自核心程序集System.Private.CoreLib.dll,那么我们可以断定Dictionary<,>类型在System.Collections.dll中必然出现了二次转移。为了确认我们的断言,我们只需要采用相同的方式反编译程序集System.Collections.dll,该程序集也被存储在共享目录 “%ProgramFiles%dotnet\shared\Microsoft.NETCore.App\2.0.0” 中,该程序集中针对Dictionary<,>类型的转移体现在如下所示的元数据中。

1
2
3
4
5
6
7
8
9
1: .assembly extern System.Private.CoreLib
2: {
3: .publickeytoken = (7C EC 85 D7 BE A7 79 8E )
4: .ver 4:0:0:0
5: }
6: .class extern forwarder System.Collections.Generic.Dictionary`2
7: {
8: .assembly extern System.Private.CoreLib
9: }

上面针对Dictionary<,>和SortedDictionary<,>这两个类型分别在.NET Framework 4.7和.NET Core环境下的跨程序集转移路径基本上体现在下图之中。简单来说,.NET Framework环境下的垫片程序集netstandard.dll将这两个类型分别转移到了程序集mscorlib.dll和System.dll之中。如果执行环境切换到了.NET Core,这两个类型先被转移到System.Collection.dll中,但是Dictionary<,>这个常用类型最终是由System.Private.CoreLib.dll这个基础程序集承载的,所有System.Collection.dll中针对该类型作了二次转移。

2-34

上面这个简单的类型基本上揭示了.NET Standard为什么能够提供全平台的可移植性,我们现在来对此做一个简单的总结。.NET Standard API由NetStandard.Library这个NuGet包来承载,后者提供了一个名为netstandard.dll的程序集,保留在这个程序集中的仅仅是. NET Standard API的存根(Stub),而不提供具体的实现。所有对于一个目标框架为.NET Standard的类库项目编译生成的程序集来说,它们保留了针对程序集netstandard.dll的引用。

.NET平台的三大分支(.NET Framework、.NET Core和Xamarin)按照自己的方式各自实现了.NET Standard规定的这套标准的API。由于在运行时真正承载.NET Standard API的类型被分布到多个程序集中,所以. NET Standard程序集能够被复用的前提是运行时能够将这些基础类型链接到对应的程序集上。由于. NET Standard程序集是针对netstandard.dll进行编译的,所以我们只需要在各自环境中提供这个同名的程序集来完成类型的转移即可。