代码改变世界

2.4 将模块合并成程序集

2011-11-22 14:08  iRead  阅读(834)  评论(0编辑  收藏  举报

  上一节讨论的Program.exe文件并非仅仅是一个含有元数据的PE文件,它还是一个程序集。程序集是一个或多个类型定义文件及资源文件的集合。在程序集的所有文件中,有一个文件容纳了清单(manifest)。清单也是一组元数据表的集合,表中主要包含了作为程序集的组成部分的那些文件的名称。此外,它们还描述了程序集的版本、语言文化、发布者、公开导出的类型1以及构成程序集的所有文件。

  CLR操作的是程序集。换言之,CLR总是首先加载包含“清单”元数据表的文件,再根据这个“清单”来获取程序集中的其他文件的名称。下面列出了程序集的一些重要特征:

  • 程序集定义了可重用的类型
  • 程序集标记机了一个版本号
  • 程序集可以有关联的安全信息

  除了包含清单元数据表的那个文件,程序集的其他单独的文件并不具备上述特征。

  类型为了顺利地进行打包、版本控制、安全保护以及使用,必须放在作为程序集的一部分的模块中。大多数时候,程序集只由一个文件构成,就像前面的Program.exe例子那样。然而,程序集还可以由多个文件构成:一些是含有元数据的PE文件,另一些是.gif或.jpg这样的资源文件。为便于理解,可将程序集视为一个逻辑EXE或DLL。

  Microsoft为什么要引入这种新的“程序集”的概念?这是因为使用程序集,可重用类型的逻辑表示与物理表示能够区分开。例如,一个程序集要求从Internet下载并部署,那么对于含有不常用类型的那个文件,假如客户端永远不使用那些类型,该文件就永远不会下载到客户端。例如,擅长制作UI控件的一家独立软件开发商(Independent Software Vendor,ISV)可选择在一个单独的模块中实现Active Accessibility类型(以满足Microsoft徽标认证授权的要求)2。这样一来,只有需要额外“轻松访问”功能的用户才需要下载这个模块。

  可以在应用程序的配置文件中指定一个codeBase元素(详见第3章),从而配置这个应用程序下载程序集。在codeBase元素定义的URL所指向的位置,可找到程序集的所有文件。试图加载程序集的一个文件时,CLR将获取codeBase元素的URL,检查机器的下载缓存,判断文件是否存在。如果是,就直接加载文件。相反,如果文件不在缓存中,CLR会从URL指向的位置将文件下载到缓存中。如果找不到文件,CLR会在运行时抛出一个FileNotFoundException异常。

  我要强调一下使用多文件程序集的三点理由:

  • 可用单独的文件对类型进行划分,允许文件以“增量”方式下载(就像前面在Internet下载的例子中描述的那样)。将类型划分到不同的文件中,还使我们能够对购买和安装的应用程序进行部分或分批打包/部署。
  • 可在自己的程序集中添加资源或数据文件。例如,假如一个类型的作用是计算某些保险资料。该类型可能需要访问一些精度表才能完成计算。在这种情况下,不必在自己的源代码中嵌入精度表。相反,可以使用一个工具(比如稍后要讨论的程序集链接器AL.exe),使数据文件程序程序集的一部分。顺便说一句,这个数据文件可以是任何格式--包括文本文件、Microsoft Office Excel电子表格文件以及Microsoft Office Word表格等--只要应用程序知道如何解析文件的内容。
  • 程序集包含的各个类型可以用不同的编程语言来实现。例如,一些类型可以用C#实现,一些类型用Visual Basic实现,其他类型则用其他语言实现。编译用C#写的类型时,编译器会生成一个模块。编译用Visual Basic写的类型时,编译器会生成另一个模块。然后,可以用一个工具将所有这些模块合并成单个程序集。其他开发人员在使用这个程序集时,只知道这个程序集包含了一系列类型,根本不知道、也不必关心这些类型分别是用什么语言来写的。顺便说一句,如果愿意,可以对每个模块都运行一下ILDasm.exe,以获得相应的IL源代码文件。然后运行ILAsm.exe,将所有IL源代码文件传给它。随后,ILAsm.exe会生成包含全部类型的单独一个文件。要实现这个技术,源代码编译器必须能够生产纯IL代码。

重要提示:

  总之,程序集是进行重用、版本控制和应用安全性设置的一个基本单元。它允许将类型和资源文件划分到单独的文件中。这样一来,无论你自己,还是你的程序集的用户,都可以决定打包和部署哪些文件。一旦CLR加载包含了清单的那个文件,就可以确定在程序集的其他文件中,具体是哪一些文件包含应用程序引用的类型和资源。对于程序集的用户来说(其他开发人员),他们只需知道含有清单的那个文件的名称。这样一来,文件的具体划分方式在程序集的用户面前就是完全透明的,以后可以自由地更改,不会干扰应用程序的行为。

  假如多个类型能共享相同的版本号和安全性设置,建议将所有这些类型都放到一个文件中,而不要将这些类型分散到多个文件中。这是出于对性能的考虑。每次加载一个文件或程序集时,CLR和Windows都要花费一定的时间来查找、加载并初始化程序集。需要加载的文件/程序集的数量越少,性能越好,因为加载较少的程序集有助于减小工作集(working set),并缓解进程地址空间的碎片化。最后,NGen.exe在处理较大的文件时,可以进行更好的优化。

  为了生成一个程序集,必须选择自己的一个PE文件作为“清单”的宿主。否则,也可以创建一个独立的PE文件,并只在其中包含清单。表2-3总结了一些清单元数据表,它们负责将托管模块转换成程序集。

  表2-3  清单元数据表

清单元数据表名称 说明
AssemblyDef 如果该模块标识的是一个程序集,就在这个元数据表中包含单个记录项。该记录项列出了程序集名称(不含路径和扩展名)、版本(major,minor,build和revision)、语言文化(culture)、一些标志(flags)、哈希算法以及发布者的公钥(可为null)
FileDef 作为程序集一部分的每个PE文件和资源文件在这个表中都有一个对应的记录项(包含清单本身的那个文件除外,它在AssemblyDef表中显示为单一的记录项)。在每个记录项中,都包含文件名和扩展名(不含路径)、哈希值和一些标志(flags)。假如该程序集只包含它自己的文件,FileDef表中将无记录3
ManifestResourceDef 作为程序集一部分的每个资源在这个表中都有一个对应的记录项。记录项中包含资源的名称、一些标志(如果在程序集外部可见,就为public;否在为private)以及FileDef表的一个索引(指出资源包含在哪个文件中)。如果资源不是一个独立文件(比如.jpg或者.gif文件),那么资源就是包含在PE文件中的一个流。对于嵌入的资源,这个记录项还包含一个偏移量,指出了资源流在PE文件中的起始位置
ExportedTypesDef 从程序集的所有PE模块中导出的每个public类型在这个表中都有一个对应的记录项。记录项中包含类型的名称、FileDef表的一个索引(指出类型是由该程序集的哪个文件实现的)以及TypeDef表的一个索引。注意,为了节省文件空间,从包含清单的文件导出的类型不在这个表中重复,因为可以使用元数据的TypeDef表来获取类型信息

  由于有了清单的存在,程序集的用户不必关心程序集的划分细节。另外,清单也使程序集具有了自描述性(self-describing)。另外要注意的是,在包含清单的那个文件中,有一些元数据信息描述了哪些文件是程序集的一部分。但是,那些文件本身并不包含元数据来指出它们是程序集的一部分。

注意:包含清单的程序集文件还有一个AssemblyRef表。程序集的所有文件所引用的所有程序集在这个表中都有一个对应的记录项。这样一来,一个工具只需打开程序集的清单,就可以知道它引用的全部程序集,而不必打开程序集的其他文件。同样地,AssemblyRef表的存在加强了程序集的自描述性。

  制定以下任何一个命令行开关,C#编译器都会生成一个程序集:/t[arget]:exe,/t[arget]:winexe或者/t[arget]:library。所有这些开关都会造成编译器生成含有清单元数据表的一个PE文件。最终生成的文件分别是CUI执行体、GUI执行体或者DLL。

  除了这些开关,C#编译器还支持/t[arget]:module开关。这个开关指示编译器生成一个不包含清单元数据表的PE文件。这样生成的肯定是一个DLL PE文件。CLR要想访问其中的任何类型,必须先将该文件添加到一个程序集中。使用/t:module开关时,C#编译器默认为输出文件使用一个.netmodule扩展名。

重要提示:遗憾的是,你不能直接从Microsoft Visual Studio 集成开发环境(IDE)中创建多文件程序集。要创建多文件程序集,只能使用命令行工具。

  有许多方式可以将一个模块添加到程序集中。如果使用C#编译器生成一个含有清单的PE文件,那么可以使用/addmodule开关。为了理解如果生成一个多文件程序集,让我们假定现有两个源代码文件:

  RUT.cs,其中包含不常用的类型

  FUT.cs,其中包含常用的类型

  下面将不常用的类型编译到一个单独的模块中。这样一来,如果程序集的用户永远不使用不常用的类型,就不需要部署这个模块。

  csc /t:module RUT.cs

上述命令行会造成C#编译器创建一个名为RUT.netmodule的文件。这是一个标准的DLL PE文件,但是,CLR不能单独加载它。

  下面将常用类型编译到另一个模块中。我们将使该模块程序集清单的宿主,因为这些类型会经常用到。事实上,由于这个模块现在代表整个程序集,所以我将输出文件的名称指定为JeffTypes.dll,而不是默认的FUT.dll

  csc /out:JeffTypes.dll /t:library /addmodule:RUI.netmodule FUT.cs

  上述命令行指示C#编译器编译FUT.cs文件来生成JeffTypes.dll文件。由于指定了/t:library开关,所以生成的JeffTypes.dll是包含了清单元数据表的一个DLL PE文件。/addmodule:RUT.netmodule开关告诉编译器,RUT.netmodule这个文件应被视为程序集的一部分。具体地说,/addmodule开关告诉编译器将文件添加到FileDef清单元数据表中,并将RUT.netmodule的公开导出的类型添加到ExportedTypesDef清单元数据表中。

  一旦编译器结束所有处理,就会创建图2-1所示的两个文件。清单在右边的模块中。

图2-1 包含两个托管模块的一个多文件程序集,清单在其中的一个模块中

  RUT.netmodule文件包含编译RUT.cs所生成的IL代码。该文件还包含一些定义元数据表,描述了由RUT.cs定义的类型、方法、字段、属性、事件等。还包含一些引用元数据表,描述了由RUT.cs引用的类型、方法等。JeffTypes.dll是一个独立的文件。与RUT.netmodule相似,JeffTypes.dll包含编译FUT.cs所生成的IL代码以及类似的定义/引用元数据表。然而,JeffTypes.dll还包含额外的清单元数据表,这使JeffTypes.dll成为了一个程序集(的主模块文件)。在清单元数据表中,描述了构成程序集的所有文件(JeffTypes.dll文件本身和RUT.netmodule文件)。清单元数据表还包含从JeffTypes.dll和RUT.netmodule导出的public类型。

注意:清单元数据表实际并不包含从清单所在的那个PE文件导出的类型。这是一项优化措施,旨在减少PE文件中的清单信息量。因此,上述说法“清单元数据表还包含从JeffTypes.dll和RUT.netmodule导出的所有public类型”并不是百分之百准确。不过,我们这样说,也确实准确反映了清单从逻辑意义上揭示的内容。

  生成JeffTypes.dll程序集之后,接着可以使用ILDasm.exe检查元数据的清单表,验证程序集文件确实包含了对RUT.netmodule文件的类型的引用。FileDef和ExportedTypesDef元数据表的内容如下所示。

File #1 (26000001)
-------------------------------------------------------
	Token: 0x26000001
	Name : RUT.netmodule
	HashValue Blob : 73 65 fc b5 2b 9c 6f 59  c1 6c 19 e5 ca cc fd fd  4b be 32 7f 
	Flags : [ContainsMetaData]  (00000000)


ExportedType #1 (27000001)
-------------------------------------------------------
	Token: 0x27000001
	Name: RarelyUsedType
	Implementation token: 0x26000001
	TypeDef token: 0x02000002
	Flags     : [Public] [AutoLayout] [Class] [AnsiClass] [BeforeFieldInit]  (00100001)

  可以看出,RUT.netmodule文件已被视为程序集的一部分,它的token是0x26000001。在ExportedTypesDef表中可以看到一个公开导出的类型,名为RarelyUsedType。该类型的实现token是0x26000001,表明类型的IL代码包含在RUT.netmodule文件中。 

注意:对于喜欢究根问底的人,元数据token是一个4字节的值。其中,高位字节指明token的类型(0x01=TypeRef,0x02=TypeDef,0x23=AssemblyRef,0x26=FileDef,0x27=ExportedType)。要获取完整列表,请参见.NET Framework SDK包含的CorHdr.h文件中的CorTokenType枚举类型。token的三个低位字节指明对应的元数据表中的行。例如,0x26000001这个实现token引用的是FileDef表的第一行。对于大多数表,行都是从1开始编号,而不是从0开始。对于TypeDef表,行号实际从2开始。

  任何客户端代码要想使用JeffTypes.dll程序集的类型,必须使用/r[eference]:JeffTypes.dll编译器开关来生成。这个开关指示编译器在搜索一个外部类型时,加载JeffTypes.dll程序集以及FileDef表中列出的所有文件。编译器要求程序集的所有文件都已经安装,而且能够访问。如果删除了RUT.netmodule文件,C#编译器会报告一下错误:

  fatal error CS0009:

      未能打开元数据文件“C:\Zhoujing\JeffTypes.dll“==”导入程序集

      "C:\Zhoujing\JeffTypes.dll“的模块”RUT.netmodule“时出错

      --系统找不到指定的文件。”

  这意味着为了生成一个新的程序集,所引用的程序集中的所有文件都必须存在。

  客户端代码执行时会调用方法。一个方法被首次调用时,CLR会检测作为参数、返回值或者局部变量而被方法引用的类型。然后,CLR尝试加载所引用的程序集中包含了清单的那个文件。如果要访问的类型恰好在这个文件中,CLR会执行其内部登记工作,允许使用这个类型。如果清单指出被引用的类型在一个不同的文件中,CLR会尝试加载需要的文件,同样执行内部登记,并允许访问该类型。注意CLR并非一上来就加载所有可能用到的程序集。只有在调用的一个方法确实引用了未加载的程序集中的类型时,才会加载程序集。换言之,为了让应用程序运行起来,并不要求被引用的程序集的所有文件全部都存在。

  

  返回目录


  1、所谓公开导出的类型,就是程序集中定义的public类型,它们在程序集的外部可见。

  2、Microsoft Active Accessiblity是一种基于COM的技术,能够为应用程序和Active Accessibility客户端提供一个标准的、一致的机制,以便交换信息。其设计宗旨是帮助残障人士更有效地使用计算机。

  3、所谓“假如程序集只包含它自己的文件”,是指程序集只包含它的主模块,包含其他非主模块和资源文件。1.2节已经说过,程序集是一个抽象的概念,是一个或者多个模块文件和资源文件组成的逻辑单元,其中必定含有且只有一个后缀为.exe或者.dll的主模块文件。