net分布式编程C#篇


.NET分布式编程——C#篇 今天,诸如企业编程、分布式编程、n 层和可扩展性等流行词汇出现在每一个产品的宣传中。 所以,要抓住.NET 中分布式开发的细微区别,就不能从字面上考虑这些术语,而应该考虑 这些特殊词汇的真实含义和上下文环境。而且,由于这本书主要是一本“操作指南”,所以, 清楚地理解为什么要分布应用程序以及如何设计一个分布式应用是非常重要的。在本章结尾 提出了五项原则,它们可以指导您在.NET 平台及其他平台上进行分布式开发。 最后,为了了解分布式编程的历史,本章回顾了原有的分布式开发模型,以及这些旧模型被 新模型取代的原因。如同您将要看到的,要解释清楚为什么微软创造出新的开发平台.NET 来取代 COM 需要花费很长时间。 1.1 分布式编程概述 什么是分布式编程?现在几乎很少有人再敢问这个问题。这个术语现在是如此普及,以至于 去询问它的含义会让人觉得非常尴尬。而其他人则认为没有必要再去询问这个术语的含义。 当我在按照惯例让学生定义分布式编程时,却很少能得到相同的答案。 分布式编程的特点是让几个物理上独立的组件作为一个单独的系统协同工作。在这里,“物 理上独立的组件”可能指多个 CPU,或者更普遍的是指网络中的多台计算机。分布式编程 可用于解决很多类型的问题,从预测天气到购买图书。作为分布式编程的核心,它做了如下 的假定:如果一台计算机能够在 5 秒钟内完成一项任务,那么 5 台计算机以并行的方式一起 工作时就能在 1 秒钟内完成一项任务。 当然,分布式编程不会如此简单。问题就在于“以并行方式协同工作”,很难让网络中的 5 台计算机高效协作。实际上,要达到如此高效,应用软件必须经过特殊设计。对此可以举一 个只有一匹马拉车的例子。马是强大的动物,但是从力量与重量之比来说,蚂蚁要比马强壮 很多倍(这里仅假设强壮 10 倍)。这样,如果聚集了与一匹马质量相同的一堆蚂蚁并利用它 们来工作,则可以拉动 10 倍于一匹马所能拉的物质。这是一个非常好的分布负载示例。这 种计算是合理的,但让数百万的蚂蚁身上都套着细小的缰绳去拉动货物却是不现实的。 1.1.1 应用程序的分层 通过马与蚂蚁的类比说明,分布式计算提出几台计算机协同工作的问题。这也是将应用程序 分解为几个可被分布式处理的任务的问题。幸运的是,我们可以利用从以前的应用程序中学 到的知识。经过这些年的发展,可以清楚地了解到大多数业务应用程序是由 3 个主要逻辑部 分构成:表示逻辑,业务逻辑和数据源逻辑。 ● 表示逻辑。表示逻辑是应用程序的一部分,终端用户可以通过它输入订单、查找用户信息和查看业务报表。对于用户来 说,这部分逻辑就是应用程序。 ● 业务逻辑。这部分是应用程序的中心,开发人员将在这里花费大部分的时间和精力。它包括定义业务运行方式的业务规 则。例如,业务逻辑规定客户何时收到折扣、如何计算运费以及订单上所必需的信息。 ● 数据源逻辑。这部分逻辑用于保存将来可能用到的订单、客户信息以及其他一些信息。幸运的是,SQL Server 和 Oracl e 等数据库产品会实现大部分工作。不过您仍然需要设计数据层以及检索数据所使用的查询。 设计任何商业应用程序的首要之处是将应用程序的各个部分逻辑划分为不同的层次。换句话 说,不能将业务逻辑代码与表示逻辑代码混在一起。然而,不要想当然地认为每一层必须运 行在单独的机器上或单独的进程中。除此之外,每一层的代码只能通过定义良好的界面与另 一层的代码进行交互。典型的情况是在独立的代码库(DLLs)中从物理上实现某些层。 1.1.2 分布式设计的 5 个原则 分层结构允许在不影响其他层的情况下修改某一层的实现。同时,它也允许将来从物理上灵 活地分隔各个层。但是,正如下面紧接着的部分中所述,不应该轻易决定在独立进程或机器 上执行每一层。如果您决定对某一层进行分布处理,那么必须对它进行特殊的分布设计。令 人迷惑的是,某些设计策略实际上与传统的面向对象原则相矛盾。为弄清这些问题,这一部 分阐述了几项用于有效分布应用程序的原则以及使用这些原则的理由。 原则 1:保守分布 对于分布式编程的书籍来说,这个原则看起来让人有些惊奇。然而,这项原则却是基于计算 中一个简单且不可否认的事实:调用不同进程上对象的方法要比调用进程内对象的方法慢数 百倍;将对象移动到网络中的另一台计算机上,这种方法调用又会慢数十倍。 那什么时候才应当进行分布式处理呢?以前的观点是只有必须进行分布时才这样做。但是您 可能想了解更多的细节,所以让我们从数据层开始考虑几个示例。通常,应用的数据库运行 在独立的专用服务器上—— 换句话说,它相对于其他层是分布式的。这样做有几个很好的理 由: ● 数据库软件复杂而昂贵,而且通常需要高性能的硬件,所以分布数据库软件的多个副本将导致开销太大。 ● 数据库可包含和关联由许多应用程序共享的数据。但是,只有当每个应用程序正在访问单独的数据库服务器而不是自己 的本地副本时,这种情况才可能发生。 ● 数据库被设计为运行在独立的物理层上。它们提供最终的“chunky”接口:结构化查询语言(SQL)。(请参考原则 3 以获 得与 chunky 接口相关的细节。) 因此,当您决定使用数据库时,一般需要决定分布数据源逻辑。然而,决定分布表示逻辑会 更复杂一些。首先,除非所有应用程序用户都使用公共的终端(例如 ATM),否则表示层的某 些部分就必须分布到每个用户机上。但问题是分布到什么程度。当然,近来的趋势是在服务 器上执行大部分逻辑,而将简单的 HTML 发送到客户端 Web 浏览器。实际上,这正是遵守 了保守分布的原则。然而,它也需要每个用户交互都遍历服务器,从而这些用户交互才能产 生正确的响应。 在 Web 迅速发展之前,普遍的情况是在每个客户机上执行整个表示逻辑(遵守原则 2)。这样 可与用户更快地交互,因为它最小化了服务器上的遍历,但它也需要更新用户接口并被部署 到整个用户群。最后,选择使用哪一个客户机基本上与分布设计原则无关,但都与期望的用 户经验和部署问题有关。 数据逻辑几乎总是在一个独立的计算机上执行,表示层通常也是如此。现在只剩下业务逻辑, 它是整个问题组中最复杂的部分。业务层有时被部署到每个用户,而其他时候则被保存到服 务器上。在许多情况下,业务层被分解成两个或更多个组件。与用户接口交互相关的组件被 部署到客户机处,而与数据访问相关的组件则被保存到服务器上。这就遵守了下一个原则, 即相关内容本地化。 可以看到,您有许多分布选项。分布的时间、原因以及如何分布等受到很多因素的影响—— 其中的许多因素又互相竞争。下面几个原则会提供进一步的指导。 原则 2:本地化相关内容 如果决定或被迫分布全部或部分业务逻辑层,那么应当保证经常交互的组件被放置在一起。 换句话说,您应当本地化相关内容。例如,参考图 1-1 所示的电子商务应用。这个应用程序 将客户组件、商品组件和购物车组件分隔到指定的服务器上,这会在表面上允许并行执行。 然而,当一件商品添加到购物车时,组件之间就会进行多次交互。每一次交互都会带来跨网 络方法调用的系统开销。因此,这种跨网络的行为会抵消掉并行处理带来的好处。再考虑到 几千用户会同时使用,可想其后果是破坏性的。如果还用前面提及的马和马车的类比,这种 情况就等同于利用马的每条腿而不是整匹马。 图 1-1 一个不成功的分布式应用例子 在本地化相关内容的同时,如何利用分布式编程(也就是并行处理)的作用呢?再买一匹马? 那就意味着复制整个应用程序并使它运行在另一台专门的服务器上。可以使用负载平衡方法 将每个客户机请求路由到特殊的服务器,即如图 1-2 所示的这种结构。基于 Web 的应用程 序经常通过在几个 Web 服务器上驻留相同的 Web 站点来使用这种模型,有时该设置被称为 We b 场。 图 1-2 一个成功的分布式应用示例 对应用程序服务器进行复制和均衡负载可以很好地提高应用程序的容量或不伸缩性。然而, 您需要非常清楚如何管理状态。更详细的信息请参考原则 4。 原则 3:使用 Chunky 接口,而不是 chatty 接口 面向对象编程的思想之一是创建具有许多简单方法的对象,每一个方法都专注于一个特殊的 行为。考虑下面的 Customer 类。 Class Customer { public string FirstName() { get; set;} public string LastName() { get; set;} public string Email() { get; set;} //etc. for Street, State, City, Zip, Phone ... public void Create(); public void Save(); } 这种实现会受到大多数面向对象专家的肯定。但是,我的第一反应是:相对于调用代码而言 该对象在何处运行。如果直接在过程中访问 Customer 类,即使从大多数标准来看,这种设 计也是非常正确的。但是,如果这个类被执行在其他过程或机器中的代码所调用,则无论现 在或是将来,这种设计都是非常糟糕的。要了解具体原因,可考虑下面的代码,并且设想它 正运行在纽约的客户机上,而 Customer 对象运行在伦敦的服务器上。 Static void ClientCode() { Customer cust = new Customer(); cust.Create(); cust.FirstName = ″Nigel ″; cust.LastName = ″Tufnel ″; cust.Email = ″ntufnel@spinaltap.com ″; //etc. for Street, State, City, Zip, Phone... cust.Save(); } 与前面相同的是,如果 Customer 对象位于客户机进程中,则这个示例不会产生任何问题。 可是,设想一下每个属性和方法调用都要跨越大西洋进行遍历,这将会产生很严重的性能 问题。 这个 Customer 类具有典型的 chatty 接口,或被更专业地称作细粒度接口。相反,哪怕是被 进程外代码偶尔访问的对象也应当被设计成具有 chunky 接口,或者说是粗粒度接口。下面 是具有 chunky 接口的 Customer 类。 Class Customer { public void Create(string FirstName, string LastName, string Email, //etc for Street, State, City, Zip, Phone ... ); public void Save(string FirstName, string LastName, string Email, //etc for Street, State, City, Zip, Phone ... ); } 可以看出,这段代码不如第一个 Customer 类那么清晰。但是相对于前者所具有的更多面向 对象的特点而言,当 Web 站点的访问量突然变大,以至于需要扩充容量来满足新用户的访 问时,后者却会提供更多的保护。 顺带提一下,可以简化具有 chunky 接口的 Customer 类。不需要将每一份客户数据作为独立 的参数来传输,可以将客户数据封装到一个客户类中,而只需传输这个客户类。下面就是这 种情况的示例。 [Serializable] // <-- Explained in Chapter 2! class CustomerData { public string FirstName() { get; set;} public string LastName() { get; set;} public string Email() { get; set;} //etc for Street, State, City, Zip, Phone ... } class Customer { public void Create(CustomerData data); public void Save(CustomerData data); } 初看这段代码,它将 chatty 接口从 Customer 类移到了 Customerdata 类中。这样做有什么好处?关键之处是在 CustomerData 类 定义前的 Serializable 特性。它告诉.NET 运行库,只要对象越出进程边界就复制整个对象。因此,当客户机代码调用 CustomerData 类的 属性时,实际上在访问一个本地对象。在第 2 章和第 3 章中会进一步讨论串行化和可串行化对象。 原则 4:优先选用无状态对象,而不是有状态对象 如果上一个原则违背了面向对象拥护者的看法,那这个原则可能会激怒他们。与严格的面向对象定义相比,术语“无状态对象” 就显得有些矛盾。然而,如果想利用在图 1-2 中显示的负载平衡体系结构,您就需要仔细管理分布式对象中的状态,或者干脆不使用状 态。要记住,这条原则和原则 3 一样只能适用于分布在边界上并可能跨越进程边界的对象。而进程内的对象则可以自由地保存状态,而 不会危及应用程序的可伸缩性。 无状态对象这个术语看起来会在开发人员中引起混淆。下面尽可能简洁地定义它:无状态对象是能够在方法调用之间被安全创建 和销毁的对象。这是个简单的定义,但包含很多含义。首先,注意“能够”这个词。应用程序不需要在方法调用之后销毁无状态对象。 但如果应用程序选择销毁它,这个动作也不会影响到其他用户。这个特性不是轻易就能实现的,您必须对类进行特殊实现,这样类才不 会依赖于公共方法调用之后实例字段是否继续存在。因为对实例字段没有依赖关系,所以无状态对象倾向于使用 chunky 接口。 有两个原因可说明有状态对象对于可伸缩性具有负面的影响。首先,有状态对象通常会在服务器上存在很长一段时间。在它的生 存期中,它会聚集并使用服务器资源。这样即使有状态对象不工作或正在等待其他用户的请求,它也会阻止其他对象使用这些资源。虽 然一些人认为内存是资源竞争中的关键资源,但实际上这只是相对次要的因素。如图 1-3 所示,有状态对象是耗费稀有资源(例如数据库 连接)的真正罪魁祸首。 图 1-3 计算机资源的相对数量 有状态对象对可伸缩性具有负面影响的第二个原因是它们最小化了跨越多个服务器对应用 程序进行复制和负载平衡的效率。考察图 1-4 的场景。 将图 1-4 看作是应用程序在某一时间点的快照。在该快照之前系统的负载很重。然而,在快 照时间点许多用户已离开,只有 3 个客户机仍与系统进行连接。不过,应用程序使用了有状 态对象,并且在系统重负载期间,所有的 3 个对象都被创建在服务器 A 上。现在,即使服 务器 B 完全闲置,从客户机传输过来的请求也必须被发送到负载很重的服务器 A 上,因为 在服务器 A 上保存着客户状态。如果这个应用程序使用了无状态对象,那么负载平衡器会 直接将客户请求传递到负载最轻的服务器上,而不用考虑以前使用的是哪个服务器。 图 1-4 有状态对象在需要负载平衡的环境下不能很好地工作 通过使用智能缓存、会话管理和负载均衡的方法,可以避免或者最小化使用有状态对象所带 来的问题。这就是原则 4 中选用无状态对象的原因,但不是必须使用。这里需要再次说明, 这个原则只能应用于为在其他进程或机器中执行的代码所提供的对象。 原则 5:接口编程,而不是具体实现的编程 因为前两个原则直接与典型的面向对象经验相矛盾,看起来似乎面向对象编程对分布式编程 用处不大。然而这根本不是所倡导的。所提倡的是某些面向对象原则(例如 chatty 接口和有 状态对象)不应该用于应用程序中位于分布边界的对象。 其他的面向对象原则可被很好地移植到分布式环境中。特别是接口(而不是具体实现)编程在 分布式编程领域得到了广泛的共识。它解决的问题与性能或可伸缩性无关。相反,接口提供 了更简单的方式,从而可以减少频繁的且时常出问题的部署。 考虑到 COM 是完全基于接口的,向.NET 平台的转变会使人们认为基于接口的编程不再受 人欢迎。其实不然。虽然.NET 允许直接的对象引用,但它也完全支持基于接口的编程。而 且,如同在第 5 章将要学到,接口为将类型信息发布到客户端提供了一个简便的方法。 1.1.3 定义可伸缩性 在这一部分中使用了可伸缩性这个术语。因为这是个模糊的术语而又经常用在产品说明中, 仔细研究一下它的含义会非常有帮助。 首先,虽然可伸缩性与性能相关,但它们并不相同。性能是用于测量当前系统运行的速度。 可伸缩性是用于测量当向系统中增加资源时的性能改善,例如 CPU、内存或计算机等(请参 见图 1-5)。 图 1-5 可伸缩性与性能相关 有两种伸缩方式: ● 垂直伸缩(按比例增加)。当使用新的、快速的硬件取代慢速硬件时,例如将 Pentium 500 换成 Pentium 1G,就是重直 伸缩。对于没有经过良好设计的应用程序,垂直伸缩是惟一可进行扩展的方式。然而,垂直伸缩相对昂贵并且容易产生错误。 ● 水平伸缩(按比例增加)。当为现有的应用程序添加额外的、负载均衡的服务器时,即可实现水平伸缩。这种伸缩可保护 当前的硬件资源,并且如果其他的服务器损坏,它也会为应用程序提供相应的故障保护。在硬件方面,水平伸缩相对较便宜, 但是应用程序必须支持前面讨论的全部五项原则才能进行水平伸缩。 一些短期内优化性能的技术从长远观点看可能会削弱可伸缩性。举例来说,参看图 1-6 中对 两个应用程序的比较。第一个应用程序在牺牲可伸缩性的基础上优化了性能。第二个则优化 了可伸缩性。最初,性能被优化的应用程序执行良好,因为它为了吞吐量尽可能地占用硬件 资源。然而,当添加额外的硬件时,应用程序所必要的稳定性就开始影响其吞吐量。很快它 达到平稳状态,同时可伸缩的应用程序的吞吐量却会继续提高。 一个性能最大化但可伸缩性受限的技术的经典示例是使用 ASP 会话对象来缓存每个用户信 息。对于一个仅使用一台服务器的 Web 站点来说,这项技术提高了应用程序的性能,因为 它最小化应用程序调用数据库的次数。然而,如果 Web 站点的用户数量超出了单台服务器 的容量,通常的做法就是再添加一台服务器。不幸的是,使用 ASP 会话对象的做法违背了 优先使用无状态对象的原则。因此,它不能成比例伸缩。 图 1-6 性能与可伸缩性的优化对比 1.2 分布式编程的简短历史 分布式编程的 5 个原则来之不易。它们经过多年的准备工作,在不断的技术革新和数以千计 的失败项目的基础上才建立起来。为了更好地认识这些原则以及定义其他通用的分布式编程 术语,让我们回顾一下分布式计算的历史。 1.2.1 集中式计算 在早期,应用程序是围绕一个中心大型主机建立的。大型主机的特点是庞大、昂贵和专用。 大型主机管理和控制应用程序的所有方面,包括业务处理、数据管理和屏幕显示。使用者一 般通过只有一个屏幕、一个键盘和一根主机连接线的“哑终端”与主机的应用程序进行交互。 因为终端没有处理能力,任何事都需要依靠主机来做,包括终端显示。用户界面是基于字符 的简单屏幕,在许多情况下,需要对使用者进行几周的培训才能使其熟练使用用户界面。 集中式计算与分布式计算不同。然而最近有许多趋势在模仿一些集中式计算的概念。例如, 哑终端实际上就是基本的瘦客户。而仅仅在几年前,一些计算机提供商致力于推荐使用称为 Web 设备的廉价计算机,它们只有不多的硬件和专职的 Internet 连接。这些趋势使集中式计 算的主要优点更加突出:部署的花费很少。因为整个应用程序(包括表示逻辑)都包含在一台 机器中,更新和安装新版本都是快速且容易的。而且,一旦进行了更新,所有的使用者都能 立即使用到被改善的新应用程序。 尽管集中式计算具有部署方面的优势,但它却受到很多问题的影响,包括如下方面: ● 一台计算机中进行全部的处理,包括数据访问、业务逻辑和表示逻辑。 ● 单片机的应用程序由于它们的绝对尺寸而非常难于维护。 ● 专用特性使得它们非常难于集成其他平台上的其他应用程序。 最终,许多公司都用比较便宜的微型计算机来代替大型主机,通常在微型计算机上运行的是 Unix 操作系统的某一版本,而不是专用操作系统。然而,应用程序还是集中式的,因为为 用户配置硬件要比哑终端昂贵许多。而相对低成本的微型计算机的使用预示着客户机/服务 器模式的来临和分布式编程的起步。 1.2.2 两层的客户机/服务器体系结构 随着硬件越来越便宜,为用户提供个人计算机变得切实可行,这些个人计算机要比哑终端更 强大。实际上,早期的 PC 具有足够的能力处理所有或者至少是大部分的负载。最重要的是, 这些 PC 能够为用户提供图形用户界面,它比哑终端中基于文本的界面更加直观。客户机/ 服务器模型的所有形式都尝试利用 PC 的计算能力。换句话说,部分负载被分布到 PC 上。 这样就消除了大型主机上的处理循环,同时为用户提供一个更具美感和直观的界面,而且还 可以显著地减少用户培训费用。 早期的客户机/服务器系统是两层的。在这个体系结构中,处理被分散在两台机器上:客户 机和服务器。客户机一般执行表示逻辑和业务逻辑,而服务器则提供对数据的访问。服务器 通常专用于运行一个关系型数据库管理系统(RDMS),例如 Oracle 或 SQL Server 的服务器。 在客户端上,类似于 Visual Basic 的开发工具为开发 Windows 的用户接口大开方便之门, 从而公司可为自己的雇员创建自定义的应用程序。实际上,类似于 Visual Basic 的开发工具 在提高开发人员生产力方面非常有效,以至于产生出一种新的开发思想,称为快速应用程序 开发(RAD,Rapid Application Development)。 从 80 年代后期到 90 年代初,各个公司都积极地采用两层体系结构。它花费不高,并且可以 快速地构建应用程序,而且用户也乐于接受好看的新界面。然而不久,业界就发现了这种体 系结构的缺点: ● 两层工具促进了 RAD 技术。然而,随着系统的发展,它在客户机上将业务逻辑和表示逻辑混合在一起,这为维护带来 难以想象的难度。 ● 根据相关的记录,对业务逻辑和表示逻辑的更新必须被部署到整个用户群,这包括数以千计的雇员计算机。除了部署应 用程序的更新,同时也必须考虑数据库驱动程序、网络堆栈和其他第三方组件的更新。一般而言,部署是一项消耗大量时间 和金钱的工作。 ● 如果应用程序从几个数据源访问数据,那就需要特定的客户端逻辑。这进一步使前面的问题更复杂。 ● 客户机不可能共享诸如数据库连接等稀有资源。因为它需要花费几秒的时间来建立数据库连接,于是两层体系结构的客 户机一般会提前打开连接,并且在会话的持续时间内将一直保持该连接。所以一个允许 20 个并发连接的数据库只能为 20 个 客户机应用程序服务,即使其中许多应用程序置闲。 尽管有这些缺点,但对于并发用户不多的小型应用程序来说,两层的客户机/服务器系统仍 然工作良好。然而,应用程序和用户端会不断增长,如果决定对当前项目使用两层体系结构, 那就一定要确保实现不会阻碍向 3 层体系结构的过渡。在这种情况下,最好的做法是在逻辑 上将业务层与表示层和数据层区分开。 1.2.3 3 层和 n 层客户机/服务器体系结构 在计算机科学界流传的一句话是任何问题都可以通过其他间接的方法来解决。在 90 年代, 这个思想应用在两层体系结构中,用于解决直到现在还非常著名的问题。由于采用了其他间 接的方法,这种新体系结构被称为 3 层体系结构。 在两层体系结构中,业务层很少被单独实现为一个逻辑实体。相反,它与表示逻辑混合在一 起,或者与数据逻辑混合在一起作为存储过程。在 3 层体系结构的计算中,业务逻辑变得最 为重要。业务逻辑最低限度上也会与表示层和数据层在逻辑上分隔,大多数情况下,它会被 物理上分离出去,并且驻留在称为应用服务器的指定服务器上。如果应用服务器作为业务逻 辑的主机,那么许多客户机就能够连接到应用服务器,并且共享业务逻辑。 对于 n 层计算的精确定义存在一些不同见解。一些人认为在 3 层模型上添加其他额外的层次 (例如 Web 服务器层)就构成 n 层开发环境。其他一些人将 n 层模型与业务逻辑在多个应用服 务器上的分配等同起来。有时,每个应用程序服务器被指定给业务过程的特定部分—— 例如 指定的客户管理服务器或订购条目服务器。甚至更复杂的论点是主张任何一个逻辑层就可构 成一层。争论这些语义的差别毫无用处。因此在最终,对 n 层架构的最好定义是由 3 层或更 多层体系结构构成的分布式设计。 n 层架构具有如下优点: ● 因为客户机不包含业务逻辑,所以它们变得更加简洁。这就使部署和维护工作更加容易,因为更新业务逻辑只需要对应 用服务器进行操作。假如业务逻辑层是最易发生变化的层次,那么这个优点将更加显著。 ● 客户机与数据库细节相分离。应用服务器能够与几个不同的数据源协同工作,并且只对客户机提供单一的访问点。 ● n 层编程促进了应用层的严格划分,并使各层间通过定义好的接口进行通信。从长远的观点看,这样为维护提供了更多 的方便,因为不用改变层的接口就可以对它的实现进行更新。 ● n 层应用程序能够水平伸缩。如果设计正确,业务逻辑就能够被复制和分布到几个负载均衡的应用服务器上。如果用户 需求增加,则可以添加更多的服务器以满足要求。 ● 应用服务器能将稀有的企业资源放入缓冲池中,这样可以在多个客户机上共享它们。 最后一项值得多解释一下,因为我认为它可能是 n 层编程中最重要的优点。稀有资源的一个 经典示例是数据库连接。使用先前允许 20 个并发连接的数据库示例,应用服务器能够打开 所有 20 个连接并使用这些连接来满足引入的用户请求。因为客户应用程序在多个请求之间 会有大量的“思考时间”,所以应用服务器能够利用同样的 20 个连接来处理数百个用户请求。 当一个客户机正在处理它最后一个请求返回的结果时(也即是“思考”),应用服务器能够使 用打开的数据库连接为其他的客户机请求服务。 虽然具有这些重要的优势,但 n 层应用程序有一个关键性的缺点:它们的实现非常困难。此 外,在关键点上设计不好将会削弱 n 层应用程序的作用,而且它的性能和伸缩都不比它所取 代的两层应用程序更有优势。然而,与 n 层开发相关的许多问题都出现在所有的 n 层的实现 中,这包括数据库连接管理、线程池、安全性和事务监控。所以在很短的时间之后,软件提 供商就提供了能够简化这些任务,并且允许开发人员集中于业务问题而不是基础结构的软件 产品。在这个市场上,微软尤其强大,它提供了诸如 COM、DCOM、MTS、COM+等技术, 以及最近的.NET 技术。这些技术将在下一部分更详细地进行解释。 1.2.4 Web 体系结构 显然,自从 90 年代中期以来,Web 在分布式编程中扮演关键性角色。不过具有讽刺意味的 是,Web 却是首先使用 Web 服务器将静态 HTML 传送到浏览器上。与 Web 相关的所有事物 都被设计得非常简单。网络协议 HTTP 就是 TCP/IP 的简化,而 HTML 是 SGML 的一个简 单实现。Web 浏览器(至少是早期的浏览器)仅以图形的方式提供基于文本的 HTML。Web 服 务器(至少是早期的服务器)仅监听 80 端口来引入 HTTP 请求,并发送回所请求的 HTML 文 档。然而,Web 的成功在于这种简易的初始状态。因为 HTTP、HTML 和浏览器软件都很简 单,所以 Web 浏览器很快就得到了普及。它允许包含在静态 HTML 文档中的信息被传递到 用户处,并以美化后的形式展现在用户面前,而不管它们使用何种硬件或操作系统。 万物都在不断发展。今天,Web 浏览器几乎可以做任何事情。除了提供 HTML,它们还可 以执行嵌入在页面上的响应用户动作的脚本代码。Web 浏览器提供复杂的对象模型,并且 通过插件或 ActiveX 技术使二进制组件驻留其中。并不是所有的浏览器都支持更先进的技 术,但现在即使是最一般的浏览器,也会支持比最初的浏览器复杂许多的技术。 Web 服务器已经发展到一定的复杂程度,在这一点上它已经变成了应用服务器。现在的 We b 服务器能够驻留服务器端的业务逻辑、访问数据库、验证安全证书和集成事务监控器(例 如 COM+)。然而与 n 层模型中的应用服务器不同,Web 服务器要执行比驻留业务逻辑更多 的工作;它也可以通过产生 HTML 与嵌入式客户端脚本的混合结构来构建用户接口,并将 该接口发送到浏览器上。 有趣的是,在这种体系结构中,表示逻辑、业务逻辑和数据逻辑都位于服务器端。在这方面, 它类似于集中式模型,于是我就将 Web 浏览器称为“美妙的哑终端”。这并不会使我受到 W eb 编程合作者的青睐,因为他们指出,基于浏览器的接口比哑终端更具交互性、更让人满 意。此外,如果更新应用程序的任何部分,包括用户界面,只需在服务器端进行更新即可。 因此,Web 体系结构具有 n 层的所有优点,并且还具有集中式模型中客户端容易部署的优 点。 然而,Web 模型不仅具有与 n 层应用程序相同的问题,而且情况还可能更严重。在传统的(即 不是基于 Web 的)n 层应用程序中,通常要知道目标用户群的规模以及预期的并发用户数。 但是,部署的简易性及普遍的浏览器可以跨越整个组织、国家或世界将 Web 应用程序提供 给用户。因此,Web 应用程序上的负载就变得不可预测;它可能为几个或数千个并发用户 服务。这就使为公共使用而设计的 Web 应用程序中需要为可伸缩性赋予更高的优先级。要 获得这种级别的可伸缩性必须进行精心的设计。 Web 体系结构的另一个问题与作为客户机的浏览器有关。今天,高级的浏览器使基于浏览 器的用户界面与传统的胖客户界面一样内容丰富,并且交互性很强。但是,在实现基于浏览 器的复杂用户界面时,必须注意以下几个问题: ● 虽然使用浏览器可以更容易地创建简单的用户界面,但是要创建复杂的表示逻辑就非常困难。例如,在 Visual Basic 中 很容易实现的用户界面在 DHTML 和 JavaScript 中就很难实现。 ● 创建复杂的用户界面通常需要使用一些与浏览器相关的技术。例如,只有 Internet Explorer 才能驻留 ActiveX 控件。这 就大大削弱了 Web 体系结构的一个主要优势:不论通过什么样的平台或浏览器都能直接到达用户处的能力。 ● 即使只需要一个简单的用户界面,对于胖客户端开发人员来说,都需要时间来适应 Web 的无状态特性和数据在网页之 间传输的方式。 尽管有这些困难,许多 IT 部门还是在内部企业员工所使用的局域网中推广 Web 体系结构。 虽然这些应用程序中有一些盲目跟随了“Web 万能”论,但确实也反应了部署问题的严重 性。因为 Web 体系结构能够节省部署费用,减少与其相关的其他麻烦,所以 IT 开发小组还 是非常乐意解决基于浏览器的接口开发的难题。 1.3 微软和分布式计算 因为微软是 PC 应用的主要软件供应商,它也在客户机/服务器计算方面起着主导作用。起初, 微软的工具和技术都是集中在客户层,包括 Visual Basic、Access 和 FoxPro。然而,随着 W indows NT 的发布,微软将重点转向服务器端,并且还提供了各种简化 n 层应用开发的技术。 在最近的 10 年当中,COM 几乎是微软各种技术的基础。然而,随着.NET 几乎完全取代了 COM,这对于使用 COM 工具的公司和广大开发人员(如同你和我)来说是根本的改变。所以 现在回顾微软的整个软件体系对于理解目前的.NET 技术也是非常有帮助的。 1.3.1 PC 统治时代 因为微软是早期 PC 软件和开发工具的先行者,所以它迅速在两层的客户机/服务器开发中获 益。在这个时期的 PC 市场上,Windows 操作系统取得了统治地位。而且,几乎对于所有人 来说,使用 Visual Basic 都能够非常容易地开发出 Windows 用户界面和与 RDMS(例如 Orac le 或 SQL Server)相交互的程序。 同时,微软改进了它的动态数据交换(Dynamic Data Exchange,DDE)技术,以寻找一种更 灵活的、能够在应用程序之间(尤其是在它的 Office 中各个应用程序之间)共享数据的方法。 这项工作产生了对象连接与嵌入(Object Linking and Embedding,OLE)技术。现在这种技术 已经消亡了,但是它的衍生技术和 OLE 这个术语仍然在使用。在这儿提及 OLE,是因为微 软很快就认识到 OLE 能解决的问题超过了电子表单和字处理的范围,并且可被用作一种新 编程样式(称为基于组件编程)的基础。组件编程技术将我们带入到了下一个纪元。 1.3.2 启蒙时期 “启蒙时期”有两个标志性的变化。第一个变化是趋向于基于组件的编程,一个单层应用程 序被分解为几个协同工作的小型二进制组件。这种思想本质上与分布式三层或 n 层应用程序 相一致,并且是建立在面向对象原则的基础上。然而,为了使效率更高,组件模型需要这样 的技术,它们能够使各个独立的组件更容易协同工作,这些工作也就是调用方法和共享位于 其他组件上的对象数据。当对组件模型进行总结时,您会发现这就是以前 OLE 技术所实现 的。因此,微软完善 OLE 并创建了组件对象模型(Component Object Model,COM),在剩 下的时间当中,它很快就成为微软世界中的主要技术角色。 COM 本身也是一个规范。利用能创建符合 COM 规范的二进制图像的语言,开发人员可以 编写 COM 组件。使用不同语言编写的 COM 对象在理论上是能够互操作的。但是,在实际 应用时,使用 C++ 的 COM 开发人员需要注意,只能提供那些能被弱势语言(如 JavaScript 或 VBScript 等)所使用的类型。Visual Basic 从版本 3 到版本 4 过渡时被重新设计了一下, 从而使 COM 开发对一般的程序员来说更容易实现,这是一个巨大的成功。 当微软投向服务器端市场时,它对 COM 进行了扩展,从而产生了分布式 COM(DCOM)。D COM 提供了 COM 组件在网络上协同操作时所需的基础结构,使得这些组件就像在同一台 机器上一样。有了 DCOM,在无需改动客户层代码或业务层代码的前提下,您可以收集为 每个客户机部署的 COM 业务对象,并将它们放置到一台集中式服务器上。 第二个有意义的变化是 Web 的出现。与组件技术不同,微软在 Web 技术上跟进得比较缓慢。 直到 Web 技术已经确立,微软才开始寻找一种方式进入这个市场。并没有过多地寻找,微 软就将一种低级的并鲜为人知的技术包装成一个名称具有诱惑力的技术:ActiveX。当然, 这里指的鲜为人知的技术是 COM。无论如何,微软接连使用一系列“Active”技术出击 We b 领域:ActiveX 控件、ActiveX 文档、活动服务器页面(ASP)和活动数据对象(ADO)。所有 这些技术都是以这样或那样的方式从 COM 中衍生出来或依赖于 COM 的。 微软也提供 Web 浏览器和服务器实现,分别是 IE (Internet Explorer)和 IIS(Internet Informat ion Server)。IE 是众多浏览器中比较独特的一个,因为它能驻留 ActiveX 控件和 ActiveX 文 档。不过,虽然 IE 占据着浏览器市场,这些客户端的 ActiveX 技术却从来没有赢得相应的 市场份额。 另一方面,IIS 也在为它的 Web 服务器市场占有率而奋斗着。然而,只有在 IIS 上才能运行 的服务器端脚本引擎 ASP 却赢得了广大 IIS 用户的欢迎。在 IIS 之前,Web 服务器与应用程 序之间的交互是通过公共网关接口(CGI)或一些专用 Web 服务器 API 进行的。在这两种情况 下,Web 服务器将一个引入的 HTTP 请求转发到一些能产生动态响应的应用程序上。一般 来说,响应是混有少量动态 HTML 的静态 HTML,这就需要一种能够快速并且简单地进行 文本处理的语言。因此,CGI 就使得一些脚本语言(如 Perl 等)普及开来,因为它们能使用功 能很强的字符串和规则表达式。 与其他 Web 服务器一样,IIS 也提供了 API。IIS 的 API 称为 ISAPI,使用这种 API 的应用 程序都称为 ISAPI 扩展。在 ISAPI 出现以后,IIS 很快就过渡到称为 ASP 的 ISAPI 扩展上。 有了 ASP,Web 页开发人员不必使用 CGI 或懂得 ISAPI 来编写动态内容。取而代之的是可 以照常编写静态 HTML,在需要产生动态 HTML 的地方插入一些脚本代码即可。当 IIS 处 理这样的页面时,ASP ISAPI 扩展就解释每一段嵌入的脚本代码,并将脚本产生的 HTML 插入到返回浏览器的 HTML 流中。这样,从浏览器来看,所有的 HTML 都来自一个简单的 静态页面。 ASP 有效地提高了动态 Web 页的开发。在任何 CGI 使用嵌入 HTML 的程序代码的地方,A SP 都可以使用嵌入一些程序代码(VBScript 或 JavaScript)的静态 HTML 页面来代替。这样就 能够更有效地构建动态 Web 内容。但是,ASP 解释性的特点会使进行复杂计算的速度降低, 而且在一个页面上混有脚本和 HTML 会使页面非常复杂,并且很难维护。对于 COM 来说, ASP 脚本代码能够创建 COM 对象并调用它们的方法。因此,可以将复杂的业务规则和数据 访问逻辑编译进 COM 组件中,并且通过 ASP 访问它们。这就使 ASP 页面中的脚本代码变 得最少,而且能够使页面专注于一项任务:HTML 的动态创建。 在这里要感谢(D)COM 以及一些创造性的思想和无穷无尽的市场,微软看来已准备占领服务 器端的市场了。但事实却不尽如此,这会在下一部分中加以说明。在进入下一部分前,还应 该再次说明的是,在这个时代,COM 技术同样适用于整个微软体系结构,而且这个事实还 会持续到下一个阶段。 1.3.3 觉醒时期 让微软和它的客户认识到大型主机和其他在线事务处理(Online Transaction Processing,OL TP)的供应商所了解的事实仅仅是个时间问题。OLTP 应用程序允许大量远程客户机读取和 修改中央数据库,与一个桌面应用程序有很大区别。没有不经重新设计就将“chatty”COM 对象从客户机移到应用服务器端的早期实践更让人厌烦,这的确产生了一个 3 层体系结构, 但性能极差,因为用户接口层需多次调用网络与 COM 对象进行通信。更糟的是,那些坚持 面向对象原则的开发人员在构建他们的业务对象时,如果尝试将对象分布到中间层中,就会 碰到前所未有的困难。开发人员在努力将面向对象的概念与分布式编程关联起来时,会很容 易产生混淆。 当微软发行 Visual Basic 4 后,立刻产生了数以百万的新 COM 程序员。但在这一阶段,开 发人员逐步了解到用 Visual Basic 进行 COM 开发有其自身的缺陷。与 Visual C++ 相比,V isual Basic 进行 COM 开发要简单得多。然而,一些使 Visual Basic 中 COM 开发工作较为 容易的设计会阻止其在中间层的应用。例如,所有 Visual Basic 的 COM 对象都是“单元线 程”(apartment threaded)类型。当在 ASP 会话对象中保存 Visual Basic 对象时就会造成很严 重的伸缩性问题,并且会阻止这些对象进入对象池。公平地说,在许多业务情况下,这些问 题是可以避免的,或者可最小化它们对应用程序的影响。但是这种局限性足以使许多开发人 员更愿意使用 Visual C++进行 COM 开发。 这一阶段使 COM 本身也逐渐受到冷落。有下面几个主要原因: ● COM 组件的形式和注册过程复杂且难于理解,这是由于 DLL 使用起来不方便,安装一个产品可能会导致另一个产品出 问题。 ● 为 ASP 脚本应用而开发的 COM 对象需要进行特殊设计,因为接口(COM 编程的关键之处)不能被 ASP 脚本所使用。 ● 在 DCOM 中,远程 COM 对象在默认情况下是通过引用而被编组的。这意味着客户机的方法调用必须通过网络到达 COM 对象处。将一个自定义的 COM 对象从一个应用程序复制到另一个应用程序时,实现通过值来编组的模式是非常重要的,因为 这样可使客户机调用本地副本上的方法。 尽管这一阶段被冠以比较平静的称谓,可微软还是做了许多让人印象深刻的工作。其中一个 是微软事务服务器(Microsoft Transaction Server,MTS)。这个产品使用了将对象与事务生存 期相联系的方法,从而将 OLTP 应用程序编程的现实情况与面向对象编程的理想化连接起 来。微软又将 MTS 改进为人们所熟知的 COM+。同时微软也开始大力提倡分布式 interNet 体系结构(Distributed internet Architecture,DNA),这个术语涵盖了 n 层服务和 COM+、IIS、 ASP 及 SQL Server 等技术。更重要的是,DNA 包括了建议和推荐体系结构,这样人们就能 避免早期采用 n 层体系结构时的错误。最后,微软发布了 Windows 2000,它证明微软能够 构建一个稳定的、具有光明前景的、用于服务器端计算的操作系统。 用这些技术武装自身,并且对分布式编程的危险性有新的认识,开发人员使用 DNA 开发出 许多高质量的应用程序。但尽管有这么多功能强大的产品,微软的服务器市场占有份额仍然 停滞不前,甚至开始减少。来自于 Java 的 J2EE 技术和开放源代码的 Linux 的强有力竞争开 始显露出来。当面临着来自 Web 的如此挑战时,微软转向 COM 以寻求解决方案。现在他 们认识到,COM 就是问题所在。将各种 DNA 连接在一起,使用 C++进行 COM 开发非常复 杂,而使用 Visual Basic 又有很多限制。而且,微软发现服务器端应用程序经常会涉及不同 供应商提供的硬件和操作系统平台。然而,COM 从一开始就是为 Windows 技术而设计的, 试图将 COM 转移到其他操作系统上都很不成功。 最终,可以清楚地认识到,经过微软在技术核心的 9 年统治之后,需要其他的技术来取代 C OM。于是,就出现了现在的.NET 平台,这也是本书的目标。 1.3.4 当前的技术:.NET 经过 9 年大肆宣传 COM 优势之后,可以理解为什么微软将.NET 看作是一种发展,而不是 一种革命性变化。老实说,从面向目标的观点来看,这种描述是准确的。COM 和.NET 都有 共同的目标,只是.NET 做得更好。 ● 语言独立性。可以使用任何支持通用语言规范(Common Language Specification)的语言来构建.NET 应用程序。目前, 可以选用大约 20 种不同的语言。 ● 组件协作性。.NET 组件共享一个公共类型系统,因此,.NET 可以在组件间获得几乎是无缝的协同工作性,而不用考虑 语言的实现。事实上,可以从 C#类派生出 VB.NET 类。 ● 位置透明性。访问本地(进程内)对象的代码与访问远程(进程外)对象的代码可以相同。具体细节会通过配置来处理。 ● 健壮的版本控制。.NET 提供并加强了一种灵活且健壮的版本控制方法,避免了 DLL 带来的问题。 在第 2 章中可以看到关于这些内容的更多细节。现在,要理解虽然 COM 和.NET 的目标相 似,但它们的基础技术却完全不同。例如,IUnknown,IDispatch 和其他标准的 COM 接口 都不在.NET 中。.NET 组件无需注册到系统注册表中,而且对象的生存期通过垃圾收集来决 定,而不是通过引用计数。 不管使用何种开发平台,分布式应用程序都具有相同的问题集。所以.NET 中的许多新技术 只是取代了基于 COM 的现有技术的功能。表 1-1 列出了在分布式编程环境中 COM 技术和. NET 技术之间的关系。 表 1-1 COM 技术与 .NET 技术的对比 分布式问题 COM 方案 .NET 方案 应用程序如何与数据库互动 ADO ADO.NET 应用程序如何访问其他应用的服务 DCOM DCOM,Remoting 或 Web 服务 数据如何在应用程序间传输?使用 IMarshal 或严格的强制串行化 CLR 串行化 使用什么来提供分布式事务、及时激活和其他应用程序服务器所 需的服务 COM+ COM+ 如何保证异步通信的传输 MSMQ MSMQ 当然,这些技术是本书后面部分的核心内容。如同将要看到的,.NET 为开发分布式应用程序提供了一个更具生产力的、更强大的 和可伸缩的框架。 1.4 小结 设计和实现分布式应用程序是困难的。可用的选项和它们不可避免的折衷都很难处理。本章 讨论的几个主题,希望能给这一困难问题讲解清楚: ● 5 项分布式设计原则以及它们如何影响您的应用程序。 ● 分布式编程的发展史以及过去所产生的问题。 ● 为什么微软要创造.NET 来代替 COM。 在下一章,您将学到.NET 的基础知识。特别地,下一章的焦点将集中在对分布式编程有巨 大影响的.NET 基础技术。 .NET 是一种全新技术。如果从作者的角度来看,把读者都看作专业的.NET 编程人员,不太 现实,但如果对所有的.NET 细节都详尽解释,本书内容很容易超过 1000 页。因此,本章只 涉及到一些最为基本和重要的概念。 本章首先对构成.NET 基础的技术和概念作一简单概述。为有助于对后续章节中更深层次内 容的理解,所以接着对程序集(assemblies)、垃圾回收(garbage collection)、特性、反射和串 行化等名词进行较详细的解释。 2.1 理解.NET 体系结构 .NET 对于 Windows 编程人员来说是全新的世界。这里,Win32 API 已不再是重点,COM 也仅仅作为一种过去的技术,甚至提到的编译组件的名称也有所不同。本节对于关键的概念 和术语作一概述,以助于开发.NET 应用程序。 注意: 如果想了解更多有关这一节中的内容,请参阅 Andrew Troelsen 编著的《C# and .NET Runtime》一书,也来自于 Apress。 2.1.1 类型的重要性 请注意!这是什么? 0000 0000 0000 0000 0000 0000 0100 1101 如果回答“一组 1 和 0”则没有多少实际意义。如果回答“77”,是对的;如果回答“M”, 也是对的;如果回答“1.079e-403”,则把这个问题又看得太复杂,不过答案还是对的。那所 有这些不同的答案怎么都是正确的呢? 秘密在于数据“类型”。如果把这些比特序列作为 32 位整数类型,结果是 77。如果作为字 符类型,结果就是 M;如果作为浮点类型,结果就是 1.079e-403。根本原因在于数据本身并 不能完全表达信息,运行的程序只有结合了数据类型后才能正确理解数据。 类型的问题已成为语言和平台互操作性的大敌。语言和平台往往因为它们支持和实现的数据 类型不同而有所区别,因此语言之间和/或平台之间互操作也就成问题。在.NET 出现之前, 存在两种相互竞争的解决方案:Java 方式和 COM 方式。 Java 解决方案惊人地有效。它包括语言(Java)和平台(Java)的标准化,能够有效解决类型问题。 而且,Jave 平台已在多种不同的操作系统上移植,具有“一次写入,任意运行”的能力,深 受 Jave 倡导者的推崇。然而,存在的问题在于您必须愿意而且能够用 Java 编写所有组件。 而 COM 解决方案更是雄心勃勃。COM 定义了一个二进制标准,要求所支持的语言确保编 译得到的二进制可执行代码符合这一标准。COM 可执行代码通过一种定义的接口进行通信, 这种接口称为变量兼容类型,是原始类型的“最小公分母”的集合。对于 COM,即使使用 不同的语言开发,组件之间也能互相通信。但是,对于 COM 的开发来说,实际可运行的系 统只有 Windows 家族的操作系统,而且 COM 体系结构又十分复杂和脆弱。 2.1.2 .NET 的 3C:CTS、CLS 和 CLR .NET 结合 Java 和 COM 解决方案两者优点来解决互操作性问题。类似于 COM 定义的标准 二进制格式,.NET 定义了一个称为通用类型系统 Common Type System(CTS)的类型标准。 这个类型系统不但实现了 COM 的变量兼容类型,而且还定义了通过用户自定义类型的方式 来进行类型扩展。任何以.NET 平台作为目标的语言必须建立它的数据类型与 CTS 的类型间 的映射。所有.NET 语言共享这一类型系统,实现它们之间无缝的互操作。该方案还提供了 语言之间的继承性。例如,用户能够在 VB.NET 中派生一个由 C#编写的类。 很显然,编程语言的区别不仅仅在于类型。例如,一些语言支持多继承性,一些语言支持无 符号数据类型,一些语言支持运算符重载。用户应认识到这一点,因此.NET 通过定义公共 语言规范(CLS:Common Language Specification),限制了由这些不同引发的互操作性问题。 CLS 制定了一种以.NET 平台为目标的语言所必须支持的最小特征,以及该语言与其他.NET 语言之间实现互操作性所需要的完备特征。认识到这点很重要,这里讨论的特征问题已不仅 仅是语言间的简单语法区别。例如,CLS 并不去关心一种语言用什么关键字实现继承,只 是关心该语言如何支持继承。 CLS 是 CTS 的一个子集。这就意味着一种语言特征可能符合 CTS 标准,但又超出 CLS 的 范畴。例如:C#支持无符号数字类型,该特征能通过 CTS 的测试,但 CLS 却仅仅识别符号 数字类型。因此,如果用户在一个组件中使用 C#的无符号类型,就可能不能与不使用无符 号类型的语言(如 VB.NET)设计的.NET 组件实现互操作。这里用的是“可能不”,而不是“不 可能”,因为这一问题实际依赖于对 non-CLS-compliant 项的可见性。事实上,CLS 规则只 适用于或部分适用于那些与其他组件存在联系的组件中的类型。实际上,用户能够安全实现 含私有组件的项目,而该组件使用了用户所选择使用的.NET 语言的全部功能,且无需遵守 CLS 的规范。另一方面,如果用户需要.NET 语言的互操作性,那么用户的组件中的公共项 必须完全符合 CLS 规范。让我们来看下面的 C#代码: public class Foo { // The uint(unsigned integer)type is non-CLS compliant. //But since this item is private,the CLS rules do not apply. private uint A = 4; // Since shis uint member is public,we have a CLS // compliance issue. public uint B = 5; // The long type is CLS compliant. public long GetA() { return A; } } 最后一个 C 是公共语言运行库 Common Language Runtime(CLR)。简单地说,CLR 是 CTS 的实现,也就是说,CLR 是应用程序的执行引擎和功能齐全的类库,该类库严格按照 CTS 规范实现。作为程序执行引擎,CLR 负责安全地载入和运行用户程序代码,包括对不用对 象的垃圾回收和安全检查。在 CLR 监控之下运行的代码,称为托管代码(managed code)。 作为类库,CLR 提供上百个可用的有用类型,而这些类型可通过继承进行扩展。对于文件 I /O、创建对话框、启动线程等类型—— 基本上能使用 Windows API 来完成的操作,都可由 其完成。 让我们正确看待“3C”。开发人员在构建自己的分布式应用程序时,因为用户在编程时将直 接面对 CLR,应将主要精力放在学习了解 CLR 上,而不是 CTS 和 CLS。而对于希望以.NE T 平台为目标的语言和工具开发商来说,就需要深入理解 CTS 和 CLS。互操作性组件是分 布式应用的关键,因此理解.NET 如何通过定义公共类型实现这一目标,也就显得十分重要。 2.1.3 命名空间 过去 Windows 编程人员必须使用 Windows API 来构建应用程序,而 Windows API 由上千 个全局函数构成,内容相当庞大,而且没有逻辑关系,无疑增加了学习这么一个库的难度。 从技术上来看,虽然 Microsoft Foundation Classes(MFC)和 Visual Basic 在某种程度上缓解 了这一问题,但是实现一个任务时仍然须经常求助于 API 调用。在 Visual Basic 中这一问题 显得尤为突出,因为 API 函数基本上以 C 语言写出,其类型与 Visual Basic 使用的类型有很 大区别(这里又提到了类型的问题)。 CLR 提供了一个类型库,该库涵盖了以前由 Windows API 提供的绝大多数功能,其本身就 能通过简单的方式,完成从数以千计的全局函数向全局类类型的复杂性转换。这样,CLR 通过使用命名空间来对相关类型进行有机组织和分类,使其查找和使用更容易。 例如,CLR 实现一个以控制台方式访问 window I/O 的 Console 类。该类在 System 命名 空间中定义,如要访问这个类,用户必须提供命名空间的名称和类名,代码如下所示: class HelloWorld { static void Main(string[] args) { // Access Console class in the System namespace. System.Console.WriteLine("Hello World"); } } 该例显示了用全限定名来访问 Console 类。由于输入命名空间很麻烦,许多.NET 语言提供 了捷径,在使用类型时不必一一指定命名空间。在 C#中,这种捷径使用“using”指令。如 下面代码所示: using System; // Types in the System namespace can now be accessed // without qualifying the namespace. class HelloWorld { static void Main(string[] args) { // Use Console class in the System namespace. Console.WriteLine("Hello World"); } } 用户能够使用命名空间组织自己的自定义类型。例如,下面代码所示的在 MyCustomStuff 命名空间中定义的 Foo 和 Bar 类: namespace MyCustomStuff { public class Foo { ... } public class Bar { ... } } // End MyCustomStuff namespace 在.NET 中所有成员均属于某一命名空间。通过这本书,将接触到许多不同命名空间中的类 型和功能。 2.1.4 程序集和清单 过去,编程人员在编译 Windows C++或 Visual Basic 应用程序时,编译结果是以.exe 或.dll 为扩展名的文件。在.NET 中这一点仍然不变,但这些结果文件被赋予了一个新的名称:程 序集。当然差别并不仅仅于此。在.NET 中,这些文件的内部格式与以前相比有很大区别。. NET 之前,DLL 和 EXE 文件是包含了有平台特征的代码,而所有的.NET 程序集包含了称 为通用中间语言(Common Intermediate Language)的跨平台代码。 通常,一个程序集正好由一个 DLL 或 EXE 文件构成,然而程序集也可由多个 DLL 或 EXE 组成。用户无需考虑其内部物理构成,就可以使用程序集中定义的类型。因此,从逻辑的角 度来看,可以把程序集简单地看着一个类、结构、枚举等的类型集合体。程序集又是通过清 单(Manifest)巧妙地实现了这一点。 程序集清单包含了有关程序集的重要信息。以多文件程序集为例,清单列出了构成程序集的 全部 DLL 和 EXE,还包括版本号、区域信息和类型参考信息等。如果一个程序集依赖于其 他程序集,清单中还要列出依赖关系。由于清单信息用于描述程序集,在通用术语中被称为 元数据(metadata)。当然,清单并非存放元数据的惟一地方,在程序集的核心,每个实现类 型均有与之对应的元数据。其概要是,每个程序集都有完整的自身描述。 程序集有两种形式:私有和共享。私有程序集只能被一个程序使用,而共享程序集能被多个 应用程序使用。COM 组件与共享程序集存在许多共同点,当然也存在诸多不同之处,而管 理多个共享程序集比管理多个 COM 组件容易许多。但是即使这样,默认的程序集类型仍是 私有的,开发人员必须清楚将私有程序集转换成共享程序集的具体步骤。 稍后在本章,读 者将了解到更多关于私有和共享程序集的解释。 对于开发分布式程序的编程人员,深刻理解程序集十分重要。程序集是在.NET 中可部署的 单元,其定义了版本和安全范围。换句话说,程序集也是组件。稍后在本章,读者将看到有 关构建程序集的详细细节。 2.1.5 中间语言 所有.NET 语言编译器生成与平台无关的代码,称为通用中间语言(CIL,简称 IL)。从概念上 来讲,这一点非常类是 Java 中的字节码。但与 Java 字节码所不同的是,Microsoft 设计的 I L 可以由任一语言编译器很容易地生成。 前面提到的,程序集包含 IL,而不是源代码。在运行过程中,当第一次调用一个方法,为 了更快的执行程序,实时 Just-In-Time (JIT)编译器将该方法的 IL 代码转换成源代码(与平台 有关)。.NET 只编译需要在运行库中使用的那部分 IL。当然,它将会把转换得到的源代码放 入缓存中,以便以后的调用能够直接使用该源代码。 同时考虑到 CTS 和 CLR,IL 为.NET 提供了除了 Windows 之外,还能移植到其他操作系统 的可能。如果可能的话,用户可以用任何一种语言开发与平台无关的应用程序。当然,这里 有一个关键词是“可能”,因为目前.NET 只能在 Windows 操作系统上运行。 2.2 构建和配置.NET 程序集 任何.NET 程序均由程序集构成。开发人员在编译自己的应用程序时,编译器将构建程序集; 部署一个.NET 应用程序伴随着程序集的部署;指定.NET 应用程序的版本时,也将同时为应 用程序中的每个程序集分配版本号;当使用从其他供应商中得到的组件时,就要调用由该供 应商提供的程序集。 这一点很明确:程序集的概念已经渗透到.NET 程序开发、发布和其他的方方面面。如果编 程人员能够充分地理解程序集这个概念,将能够很快地发现并找出自己开发的或第三方提供 的组件中的部署错误、版本管理问题,并且对何时使用共享程序集、何时使用私有程序集等 问题做出正确的判断。因此,本节不厌其烦地阐述如何构建和使用程序集。 为了帮助掌握涉及到的细节,下面通过构建两个简单的程序集,逐步深入。一个程序集是名 为 MathLibrary 的 DLL,它包含了一个 SimpleMath 类,该类又由一个名为 MathClient 的 E XE 程序集调用。这两个程序集会用来举例说明许多不同的概念,建议读者能够亲自实践并 体会其过程。 2.2.1 构建私有程序集 下面通过使用 C#类库项目类型开始创建一个私有程序集。前面提到的,一个私有程序集只 能由一个应用程序调用。首先,我们在 Visual Studio 中建立一个新项目,并选择一个 C#类 库项目类型(如图 2-1)。 图 2-1 创建一个类库程序集 然后在 SimpleMath.cs 的文件中输入一下代码: // InSimpleMath.cs namespace MathLibrary { public class SimpleMath { public static int Add(int n1, int n2) { return n1 + n2; } public static int Subtract(int n1, int n2) { return n1 - n2;: } } } 该程序集的惟一目的是提供一个 SimpleMath 类,以供其他客户端程序集调用,所以没有主 函数。 在构建该项目时,会生成 MathLibrary.dll 文件。但记住,它不同于任何旧的 DLL,它是一个. NET 程序集。因此,它包含了 IL 代码,以即前面提到的元数据。 1. 使用程序集类型 现在建立调用 SimpleMath 类的另一个程序集。这次选择一个 C#控制台应用项目,输入以下 代码: using System; namespace MathClient { class MathClient { static void Main(string[] args) { Console.WriteLine(" 5 + 3 = {0}", MathLibrary.SimpleMath.Add(5,3)); Console.WriteLine(" 5 - 3 = {0}", MathLibrary.SimpleMath.Subtract(5,3)); Console.ReadLine(); } } } 但是,当对该项目编译时,会出现许多编译错误,这是因为在项目中还没有引用 MathLibra ry 程序集,导致编译器不能识别 SimpleMath 类类型。 要更正这一错误,打开 Add Reference 对话框(Project | Add Reference)。单击 Browse 按钮, 查找包含 MathLibrary.dll 文件的目录,并选择该文件(见图 2-2)。 图 2-2 添加一个程序集引用 Visual Basic 6.0 编程人员可能对这一过程比较熟悉,因为它与添加 COM DLL 引用的过程 很相似。主要的区别在于,在 VS.NET 中 IDE 从它当前位置向用户项目中建立的目标路径 复制程序集。 在引用 MathLibrary 程序集之后,就能成功地构建客户项目。图 2-3 显示了目标生成目录怎 样维护构建的项目文件。目前,MathLibrary 仍然是私有程序集,这样,只有它与客户端可 执行程序同属一个目录,客户程序才可使用它。后面将介绍如何将一个私有程序集驻留在应 用程序目录的一个子目录中。 图 2-3 MathClient 目标生成目录 2. 探究程序集清单 现在让我们来探究一下 MathClient 组件的内容。前面提到程序集清单列出了它需要的其他 所有程序集,这里可以通过在 ILDasm 内查看程序集来测试这一内容。ILDasm 是.NET 框架 SDK 带的非常有用的工具,专为查看.NET 程序集而设计的。图 2-4 显示了在载入 MahtClie nt 程序集后的 ILDasm 工具。 图 2-4 用 ILDasm 查看 MathClient 程序集 双击 Manifest 节点,ILDasm 列出 manifest 的内容。在 manifest 的顶端,将看到以下内容: .assembly extern mscorlib { .publickeytoken = (B7 7A 5C 56 19 34 E0 89 ) .ver 1:0:2411:0 } .assembly extern MathLibrary { .ver 0:0:0:0 } [.assembly extern]项用来列举当前程序集所依赖的那些程序集。正如所见,MathClient 程序 集依赖于 mscorlib 和 MathLibrary 两个程序集。mscorlib 程序集是 CLR 的核心库,包含了许 多有用类型。例如它实现了 MathClient 所使用的 System.Console 类. 当一个客户端程序集需要一个外部程序集定义的类型时,它向 CLR 程序集加载服务发出程 序集请求。程序集请求由程序集名和相应的[.assembly extern]块的所有信息组成。程序集加 载器通过一个被称为程序集绑定(assembly binding)的过程,使用该信息来查找和加载请求的 程序集。这里,程序集绑定在概念上类似于 COM 服务控制管理器 COM Service Control M anager(简称 SCuM)的工作过程,但 COM 服务控制管理过程从来不检查系统注册,而绑定机 制就完全不同。 当前 MathLibrary 程序集需要记录的信息相当少。下面的摘录详细列出了程序集引用的所有 内容。表 2-1 对每一项做了解释。 .assembly extern SomeLibrary { .publickeytoken = (99 CB 5A D9 7D 10 88 C5 ) .ver 1:0:552:41586 .locale = (65 00 6E 00 00 00 ) } 表 2-1 程序集请求详述 程序集请求项 描 述 .assembly extern SomeLibrary 说明引用外部程序集的友好名称 .publickeytoken = ( …) 惟一表示外部程序集发布者的 8 位散列。参见“构建共享程序集”一节 .ver=1:0:552:41586 外部程序集的版本号。格式是:major:minor:build:revision .locale =( … ) 表示外部程序集的区域信息。例如:英语、德语。专门用于只提供资源(字符串、位图等)的程序集。默认 的区域信息是“neutral” 如果查看每一个被引用的程序集的记录信息,可能会发现忽视了一个重要的部分:程序集所 在的位置。程序集加载器如何找到它的呢?答案是,至少在这种情况下,由于 MathLibrary 程序集与 MathClient 程序集同在一个目录,加载器就能找到它。然而应当注意,搜寻当前 应用程序所在目录的操作,仅仅是查找外部程序集引用诸多步骤中的一步。事实上,要完成 这一系列操作,可以采用被称为“探测(probing)”的过程,它是程序集绑定过程中的一部分。 源代码: 本例的源代码参见 Chapter2\BasicAssembly。 3. 创建应用程序配置文件 探测过程的第一步是要查找一种特殊格式的文件,被称为应用程序配置文件(application con figuration file)。该文件必须存在于应用程序目录中,其文件名与应用程序名相同,外加扩 展名.config。例如,MathClient.exe 程序集的配置文件命名为 MathClient.exe.config。通过编 写配置文件,编程人员能够指示加载器在应用程序目录的任一子目录中搜索指定的程序集。 例如,为了清理应用程序的目录结构,可能希望把所有的库,包括 MathLibrary,移到名为 libs 的子目录中。然而,如果执行这样的操作后运行 MathClient 程序,将会出现如图 2-5 所 示的异常报告。 图 2-5 从应用程序目录移出被引用的程序集引发一个异常 要解决这一问题,用户可以创建应用配置文件。为创建和维护应用程序配置文件,Visual S tudio .NET 提供了一种方便实用的机制。首先,到 Project | Add New Item,选择 Text Fil e 图标,输入“app.config”文件名(见图 2-6),单击 Open。 图 2-6 添加 app.config 文件 这样就创建与用户源代码和项目文件同属一个目录的 app.config 文件。但是记住,它必须与 应用程序的 EXE 和 mathclient.exe.config 文件同在一个目录下。事实上,在每次建立项目时 Visual Studio .NET 自动完成此项操作,甚至能根据目标可执行文件名称的变化,相应改变 目标应用配置文件的名称。图 2-7 显示了在向项目中加入该文件和编译时,该项目和 bin\de bug 目录的情况。 现在在配置文件中输入以下内容: 图 2-7 在编译时 app.config 文件自动转换为 mathclient.exe.config 文件 可以看出,配置文件采用 XML 格式。根据定义,配置文件必须以根元素〈configuration〉 作为开始。这里最终目标是确定 privatePath 特性,但是之前必须首先确定〈runtime〉和〈a ssemblyBinding〉元素。如果需要,可以按下面所示指定多个待搜索的子目录名: 现在再运行 MathClient 程序,就不会报错。 源代码: 本例的源代码参见 Chapter2\Probing。 4. 探寻探测过程 探测并不只是简单地搜索当前应用程序目录和指定目录,实际执行的操作还很多。例如, 如果程序集请求提供区域信息,那么运行库会为程序集搜索带区域信息的子目录。 在许多复杂的情况下,最好用伪代码对整体探测过程进行描述。设想由一种被称为 Db(发音:D-flat)的假想语言写成,一个名为 P robeForAssembly 的函数,如下面所示。该函数依据代码逐步执行,一旦搜索成功,立即退出。 function ProbeForAssembly( AsmName, AppBase, Culture, PrivatePath) // AsmName = The friendly name of the assembly, e.g., MathLibrary // AppBase = Path where the requesting application resides // Culture = The assembly reference culture, e.g., "En" // PrivatePath = The list of search paths specified in the app config file // Search first for DLL extension then EXE extension. for each Ext in {"dll", "exe"} Search( AppBase\AsmName.Ext ) if Culture == "neutral" Then Search( AppBase\AsmName\AsmName.Ext ) else Search( AppBase\Culture\AsmName.Ext ) Search( AppBase\Culture\AsmName\AsmName.Ext ) end if // Search in all the paths specified in the app config file for each Path in PrivatePath if Culture == "neutral" Then Search( AppBase\Path\AsmName.Ext ) Search( AppBase\Path\AsmName\AsmName.Ext ) else Search( AppBase\Path\Culture\AsmName.Ext ) Search( AppBase\Path\Culture\AsmName\AsmName.Ext ) end if next Path next Ext end function 如果对 Db 语法不是很清楚地说,这里解释几个要点: ● 探测过程用了 4 条主要信息来作为输入:被请求程序集的友好名称、提出请求程序集的路径、被请求程序集的区域信息 以及应用程序配置文件中的 privatePath 设置。 ● 探测过程由两个主要工作周期组成。第一个周期搜索一个带 DLL 扩展名的程序集,第二个周期搜索一个带 EXE 扩展名的 程序集。 ● 无论是 privatePath 或者区域信息,探测过程总是先搜索应用程序所在目录。 ● 探测过程将判断与程序集名称匹配的子目录是否存在。如果存在,就搜索该子目录。 ● 如果区域信息不是“neutral”,探测过程将判断是否存在与区域信息匹配的子目录名。如果存在,就搜索该子目录。 ● 如果列举出 privatePath 中指定的子目录名,探测过程也会搜索目录名与程序集或区域信息匹配的子目录。 5. 运用私有程序集和进行探测的好处 现在花一点时间思考一下针对绑定程序集引用的.NET 探测机制到底有什么含义。为了弄清 这个问题,现假设 MathLibrary 是一个由 MathClient 调用的 COM DLL。要部署这个应用程 序,安装程序必须复制和注册 MathLibrary.dll。一旦部署后,如改变 MathLibrary.dll 的存放 位置,就需重新注册。而且,如果在不希望影响用户使用的情况下部署新的 MathLibrary.dll 版本,必须对后续的每个步骤进行仔细地检查,以确保新、旧版本间的兼容性。最后,考虑 一下如果构建了新的 MathClient 应用程序,更新了所有支持的 COM DLL,但是又想在同一 个机器上运行新、旧两个版本可能会出现的什么问题。 好了,现在从假设中回到现实,因为.NET 程序集探测机制大大简化了这种情况。如果要部 署应用程序,要做的就是复制整个应用程序目录和包含支撑程序集的子目录。只需通过调用 一个简单的 XCOPY 命令即可完成,无需注册。而且,可以改变 MathLibrary 程序集的位置 到应用程序所在目录的任意一个子目录中。在这种情况下,所要做的就是更新配置文件,指 示程序集加载器查找相应的目录。因为运行库不会校验私有程序集版本(关于程序集版本的 细节,接下来将进行介绍),甚至可以通过复制旧版本,来部署 MathLibrary 的一个新版本。 最后,如果希望完整的 MathClient 程序新旧两个版本在同一机器上运行,只需将新、旧版 本安装在不同的目录中即可。 到现在为止,如果在不需要与其他应用程序共享程序集的情况下,应该认识到私有程序集的好处了。但如需共享程序集,就必须通过额外的 步骤将私有程序集转变成共享程序集。 2.2.2 构建共享程序集 无论怎样试图避免与共享程序集打交道,仍然会遇到需要在多个应用程序之间共享程序集的 情况。共享程序集与私有程序集主要存在两方面的区别:位置和标识。私有程序集必须为于 应用程序所在目录或子目录中,而共享程序集安装在被称为全局程序集缓存(Global Assemb ly Cache,GAC)的一个特定的全局缓存中。在 Windows 操作系统中,GAC 位于 Windows 系统目录下的 Assembly 目录中(例如:C:\WinNT\Assembly)。 虽然共享程序集友好名称与私有程序集一样,但是.NET 运行库通过强名(strong name)(也称 共享名:shared name)来标识共享程序集。一个强名称由友好名称(如 MathLibrary)、区域信 息(如:英语)、版本号(如 1.2.0.0)、公钥(public key)和数字签名等组成。这里,需要强名称 提供的严格认证等级,因为: ● 希望本公司开发的程序集具有唯一的名称,且不会被其他公司复制。 ● 希望针对不同的实现方式或不同的区域信息,能够共享多个不同版本的程序集。 ● 防止黑客的“特洛伊木马”程序集替代合法程序集,利用有效访问权限对系统进行严重破坏。 为了更为清楚地说明这些问题,下面举例说明 MathLibrary 程序集转换成共享程序集的 步骤。 1. 在程序集中使用强名称 将 MathLibrary 转换成共享程序集首先需要生成一个强名称。如前所述,强名称实际上是许 多程序集信息的集合体。现在如果我们检查一下 MathLibrary 程序集清单,会发现以下内容: .assembly MathLibrary { // Note, some lines cut for clarity ... .hash algorithm 0x00008004 .ver 0:0:0:0 } 注意: 如果按照前面例子中程序集建立步骤练习,并希望查看清单中的详细内容,建议注释掉 AssmblyInfo.cs 文件中的内容。Visual Studio 会在 项目创建时自动生成该文件。 这里[.assembly] 标记不带“extern”,表示该部分的信息用于当前程序集。而黑体部分内容 表示友好名称(MathLibrary)和版本号(0:0:0:0),而要创建强名称,仍然缺少一些信息。 看起来像缺少区域信息。实际上,如果不专门声明,该程序集的区域信息默认为“neutral”。 如果建立的程序集内部包含各种资源,如字符串、位图等,就需要根据不同的语言定制区域 信息。这种类型的程序集称为附属程序集(satellite assembly),规定不含任何代码。由于 Ma thLibrary 包含了特定的代码,也就无须使用专门的区域信息。 不对称密码术和强名称 强名称让人迷惑不解的地方在于使用不对称(也称公钥)密码技术。注意运用这一密码体系的 目的不是对程序集的内容加密,而是为下列操作提供安全保障: ● 一旦程序集被建立或安装后,任何人不能篡改程序集的内容。 ● 任何两个发布者不能生成具有相同强名称的程序集。 ● 程序集的全部版本均来自同一发布者。 下面解释一下不对称密码术的工作机理。不对称密码术包含两种密钥:公钥和私钥。这些密 钥通过数学的方法联系起来,只有相应的公钥才能将由私有密钥加密的数据解密。当用户建 立一个共享程序集,也相应提供带这一公钥/私钥对的编译器(在下一节读者可了解到这一工 作过程)。编译器使用私钥对程序集的内容进行“数字签名”。签名的过程就是对程序集的内 容进行散列编码,编码结果大约几百字节。然后使用私钥对其进行加密,得到程序集的数据 签名。该程序集在它的清单中存放公钥,并在 CLR 能够访问的某个位置嵌入数字签名。 现在程序集就可以在 GAC 中进行安装了。在安装时,GAC 需获取程序集的数字签名,并用 存放在程序集清单中的公钥将其解密,从而得到编译器编译时生成的程序集的散列值。然后 GAC 重新使用在前面编译过程中使用的散列算法对程序集的内容进行散列编码。如果这个 散列值与在数字签名中的散列值相同,说明程序集内容没有被改变。 现在解释一下如何从其他程序集引用这个共享程序集。当编译器编译消费程序集(consuming assembly)时,它从共享程序集的清单中获取公钥,并将其散列编码成一个八字节的数值, 该数值称为公钥令牌(public key token)。散列编码过程的目的是将公钥压缩成更小的数据 块。由于公钥内容相当大,任何一个程序集需要存储多个被引用的共享程序集所提供的公钥。 因此,消费程序集仅在它的清单中存放公钥令牌。尽管这是压缩格式,从统计学的角度来看, 公钥令牌仍然能够区分出所需程序集的发布者。 注意: 在.NET 以前的 beta 版中将公钥令牌称为始发者 (originator)。读者在阅读以前的文档时,请注意这一差别。 最后,讨论一下当消费程序集需要载入共享程序集中的类型时,完成了哪些操作。首先,当 运行库调用共享程序集时,它读取共享程序集的公钥,并产生一个公钥令牌。然后将该令牌 与存放在用户程序集清单中的公钥令牌进行对比。如果结果匹配,运行库校验成功,说明从 发布者中得到的共享程序集正是用户需要的;如果不匹配,运行库就发出一个异常警告。 在完成公钥令牌校验后,运行库将共享程序集的内容进行散列编码,并将结果与存放在共享 程序集中的数字签名值进行对比。实质上,当用户安装共享程序集时,它模拟了 GAC 的执 行过程。不过,运行库要进行检查,以确保没有人对被安装在 GAC 的程序集进行过恶意篡 改。 除非对不对称密钥密码术很熟悉,本节的内容会使读者头晕脑涨。但是应充满信心。在下一 节,将了解到如何将这些知识运用到 MathLibrary 程序集。在进行后续内容之前,必须明白 前面部分介绍的内容是一种简化,其假定程序集只有一个文件。多文件程序集使过程变得复 杂,但是最终的结果是一样的。 生成公钥/私钥对 回忆一下,在对共享程序集进行签名时,编译器需要一个公钥/私钥对。这里可以通过使用 强名称命令行工具(sn.exe)产生这些密钥。该命令有多个可选项,但大多数情况下仅需要掌 握-k 选项。该选项命令工具在产生一个新的公钥/私钥对,并将其存入指定的文件(见图 2-8)。 图 2-8 使用强名称实用程序(sn.exe) 前面提到,编译器使用这些密钥对程序集进行数字签名,但是之前必须告知密钥存放位置。 可以使用名为 AssemblyKeyFile 的程序集特性指定存放密钥的文件路径,并以程序化的方式 实现该操作。示例代码如下所示: // The AssemblyKeyFile attribute in this namespace using System.Reflection; [assembly: AssemblyKeyFile(@"D:\MyKey.snk")] 如果程序集特性出现在任何命名空间或类声明之前,那么可以将这一代码放在项目的任何代 码文件中。然而,一般存放的位置是在 AssemblyInfo.cs 文件中。当构建一个新的 C#项目时, Visual Studio 自行将该文件加入项目。 现在构建 MathLibrary 程序集时,编译器使用密钥对对程序集进行签名,并可以通过 ILDas m 在程序集清单中查看结果并确认,结果如图 2-9 所示。 请注意身份块的[.publickey]项。正如所料,这就是公钥。MathLibrary 程序集现在就可以安 装在 GAC 中了。 图 2-9 使用 ILDasm 查看 MathLibrary 清单中的公钥 对于组件供应商来说,把公钥/私钥对存放在安全可靠的地方非常重要。它就是您的身份, 绝对不能丢失,绝对不能泄露给其他人。如果丢失了,需要重新创建,基于以前构建的程序 集的客户端如果不重新编译,就不能与后续版本的程序集进行绑定。如果一个黑客得到该密 钥对,他(或她)就可能使用您的身份发布一个恶意的程序集版本,仅通过更新机器配置文件 就能引导客户转到他(或她)的程序集。 警告: 把公共/私钥对存放在安全可靠的地方非常重要。 重建 MathClient 程序集 在将 MathLibrary 程序集安装入 GAC 之前,可通过建立 MathClient 程序集来观察 MathLibr ary 发生的变化。首先检查 MathClient 的清单,查看它现在是如何引用 MathLibrary 程序集 的,如图 2-10 所示。 图 2-10 在基于强名称的 MathLibrary 建立之前的 MathClient 的清单 图 2-11 显示了在针对 MathLibrary 程序集新的强名称版本构建 MathClient 后的引用情况。注 意在程序集引用信息中增加了[.publickeytoken]一项。 技巧: 为了使 Visual Studio 识别程序集有了强名称,应该从 MathClient 项目中删除对 MathLibrary 引用,而在后面再添加进来。也可通 过在 Solution Explorer 窗口中突出显示 MathLibrary 引用,并在 Property 窗口检查 Strong Name 属性来断定 Visual Studio 可以识别强 名称。 如果运行 MathClient 程序,会发现结果与以前一样,说明即使 MathLibrary 有了强名称, 仍然能够作为私有程序集使用。在下一节,将示例如何在 GAC 中安装 MathLibrary,并作为 共享程序集使用。 图 2-11 在基于强名称的 MathLibrary 建立之后的 MathClient 的清单 在 GAC 中安装程序集 .NET 通过特殊的外壳扩展(shfusion.dll)允许在 Windows 资源管理器中浏览 GAC。在 GAC 中安装 MathLibrary 程序集最简单的办法是在 Windows 资源管理器中打开它,“拖放”Math Library.dll 即可,与将一个文件复制到其他目录的方法一样。 图 2-12 在 GAC 中查看 MathLibrary 程序集 在批处理文件和安装文件中可以使用 gacutil.exe 命令安装共享程序集。选项/i 将指定的程序 集安装到 GAC 中。 gacutil /i MathLibrary.dll 注意: 只有具备计算机管理员权限才能在 GAC 中安装程序集。 不论什么运行机制,一旦在 GAC 中安装了 MathLibrary,计算机上的任一程序集都可能使用 它。为了证明这一点,可以删除 MathClient 的应用程序配置文件(MathClient.exe.config)和 M athLibrary.dll 的本地拷贝,然后运行 MathClient.exe。由于加载器现在能在 GAC 中找到 Mat hLibrary 程序集,因此应用程序可以正常运行。 回想前面从 MathClient 项目中添加对私有 MathLibrary 程序集的引用,Visual Studio 自动向 应用程序所在目录复制程序集。但现在 MathLibrary 具有了强名称并安装在 GAC 中,如果 再次引用这个程序集,Visual Studio 就不再复制了。用户可以通过查看 Property 窗口中的 C opy Local 属性,检查 Visual Sudio 是否每次构建时都进行程序集复制操作(如图 2-13 所示)。 图 2-13 Visual Studio 中的 MathLibrary 引用的 Copy Local 属性 最后需要注意的,读者可能会对下面的问题感到疑惑:如果向用户应用程序目录中也复制 M athLibrary 程序集会出现什么情况,到底哪一个拷贝会被载入呢?是 GAC 中的呢还是应用 程序目录中的呢?答案还要看在 MathClient 清单中是如何引用程序集的。如果 MathClient 清单中存在 MathLibrary 程序集的公钥令牌,那么运行库首先检查 GAC;如果程序集没有在 GAC 中,此时运行库就要进行探测。另一方面,如果在 MathClient 清单中没有 MathLibrar y 公钥令牌,那么运行库直接进行探测,不再查找 GAC。由于程序集引用的公钥令牌是空 的,不可能与找到的程序集的公钥令牌 8609A7F82BCFCECE 匹配,因此后者肯定失败。但 是,由于引用带有强名称的程序集时编译器自动生成一个公钥令牌,所以后者绝不会发生。 这里涉及到的部分内容会在本章“绑定过程小结”中阐述。 2. 使用延迟签名(Delayed Signing) 由于用于对强名程序集签名的公钥/私钥的敏感性,有的机构希望尽量避免将密钥对分发给 软件开发人员。另外,出于测试的目的,软件开发人员需要建立一个接近实际的部署环境。 这就意味着,要共享一个程序集,最好在开发过程中按照希望的方式开发和测试。如果不生 成程序集的强名称,就不可能做到这点。 遇到这种难题的解决办法是采用延迟签名(delayed signing)。在这个过程中,只有公钥提供给开 发人员。正常情况下,这个公钥嵌入在程序集清单中,利用该程序集建立的客户端程序集,将 相应的公钥令牌存放进它自己的程序集清单中。但是,由于没有私钥,程序集没有进行数字签 名。那么在部署应用程序时,所有程序集就可以转到负责维护该机构的私钥和进行数字签名的 人员手中。 延迟签名的第一步是从密钥对中获取公钥。强名称工具(sn.exe)专门为此提供了-p 选项。下 面命令演示了如何从 Mykey.snk 中获取公钥、并将其存入 MyPublicKey.snk 文件的方法。 sn -p MyKey.snk MyPublicKey.snk 这个公钥文件现在就可以采用延迟签名,免费发布给所有的软件开发人员。有了公钥后,还 需告诉程序集应使用延迟签名。这一步可利用设置程序集特性 AssemblyDelaySign 来实现。 例如: // Specify the use of delayed signing [assembly: AssemblyDelaySign(true)] // Specify the location of the public key [assembly: AssemblyKeyFile(@"D:\MyPublicKey.snk")] 需要注意的是,AssemblyKeyFile 特性只指定了前面创建的公钥文件存放的位置,而不是指 含有整个密钥对的文件本身。 当程序集建立好了以后,编译器将公钥放在清单中,并且为数字签名预留好空间。但是,由 于没有进行数字签名,在 GAC 中安装程序集或客户端在运行时加载它时,须将签名验证功 能关闭。可以使用强名称工具的-Vr 选项完成此项操作,如下例所示: sn -Vr mathlibrary.dll 现在,无论何时客户端向该程序集提出请求,验证过程均被忽略。而且,如具备一个完整的 强名称,就可以将该程序集安装在 GAC 中,用以开发客户程序。由于该程序集清单包含合 法的公钥,针对该程序集建立的客户端程序集包含了合法的公钥令牌,因而在程序集使用私 钥签名时无需重建。记住前面提到的命令不会改变程序集本身,相反,它仅仅是注册程序集, 使其只在当前机器中省略验证过程。如果要恢复对程序集的校验,使用-Vu 选项即可。 当然,一旦完成应用程序设计,这个延迟签名程序集就必须在部署之前用私钥完成签名。这 时读者可能已猜到,强名称工具也提供了一个选项:-R。下面的例子显示了用 MyKey.snk 文件中的私钥对 MathLibrary 程序集进行签名。 sn -R mathlibrary.dll d:\mykey.snk 3. 区别术语:强名称与共享 坦率地说,作者本人并不喜欢私有程序集、共享程序集这样的术语,因为这些术语只涉及到程 序集如何使用,并没有描述其的内部结构。例如,强名称程序集是私有程序集还是共享程序集, 主要是看它是否被安装在 GAC 中。但是,需要使用强名称时,往往不正确地使用了“共享” 这个词。 基于这个原因,作者以后只是在描述安装在 GAC 中的强名称程序集时才使用“共享(shared)” 这个词,用常规程序集(regular assembly)来描述没有强名称的程序集。当程序集的用法比它 的内部结构更重要时,使用私有程序集这个词汇。如果需要,会使用带限定词汇的完整术语 来描述程序集引用,例如“强名称私有程序集“或者“常规私有程序集”。 2.3 理解.NET 版本控制 提到“版本控制”一词,许多 COM 开发人员表现出极度的反感。虽然 COM 版本规则很简 单,但是实现起来并非易事。而且,像 Visual Basic 和 Visual C++这样的 Windows 开发工 具,也在版本升级过程中不断变化。Visual Basic 6.0 在一个简单的对话框后面隐藏了许多 深层次的版本细节,这些细节使得在出现版本错误时不能为开发人员提供更多的帮助。Vis ual C++ 6.0 则把繁琐的版本工作全部交给了开发人员来做。.NET 版本的解决方案与之相 比,更显卓越。但是需要记住,无论什么版本,其固有的复杂性需要一个开发团队的所有成 员付出努力。 2.3.1 设置程序集的版本信息 一个程序集的版本由存放在清单中的四部分版本号组成,每部分通常使用句点(.)或冒号(:) 作为分隔符。 ... 开发人员可以利用 AssemblyVersion 特性来设置一个程序集的版本号,和 AssemblyKeyFile 特性一样,AssemblyVersion 特性通常位于 AssemblyInfo.cs 文件中。 // Format: ... [assembly: AssemblyVersion("1.0.*")] 在前面的例子中,主版本号是 1,次版本号是 0,星号告诉编译器自动生成内部版本号和修 订号。然后编译器将得到的版本号存入清单中。现在可以在 MathLibrary 程序集上尝试一下。 图 2-14 显示的是编译完项目之后的清单情况。 如果愿意的话,编程人员也可按如下例子的格式明确地设置自己的生成版本号和修订号。 图 2-14 清单中记录的 MathLibrary 的版本号 // Format: ... [assembly: AssemblyVersion("1.0.5.121")] 无论什么时候,只要在构建项目时引用了其他程序集,那么编译器会将所有被引用的程序集 的版本号存入清单中。现在重建 MathClient 程序集,再查看其清单。图 2-15 显示了该客户 端清单记录的版本信息。 前面提到,CLR 使用程序集引用中的信息,在运行中将其与正确的程序集绑定。如没有更 多说明,CLR 仅仅绑定到带有这些特性的程序集。只有运行库能够找到版本号为 1.0.550.39 732 的 MathLibrary 程序集,MathClient 程序才能成功运行。但是注意,这项策略只适合于 强名称程序集。如果一个常规程序集具有相应的版本号,运行库会将其忽略掉。 图 2-15 在 MathClient 清单中记录的 MathLibrary 的版本号 注意: 早期的 beta 版.NET 有一个概念,称为快速修复引擎 QFE(Quick Fix Engineering),被作为默认的版本控制策略。如果程序集的修 订号和内部版本号与请求的版本号不同,则允许运行库载入一个程序集。实际中,该策略被证明条件太宽松,逐步被称作发布者策略 (pu blisher policy)(后面将讨论 )的更为明确的绑定形式所代替。如果在旧文档中遇到该术语请记住这点。 在需要更新被引用的程序集之前,会一直觉得这种严格的版本策略很好。如果,在部署应用 程序后发现 MathLibrary 程序集中存在错误,如何构建和部署一个没有错误的程序集呢?这里 有两个选择。 ● 更新和部署整个应用程序。编程人员可以修复 MathLibrary 程序集,并利用开发环境重建整个应用程序。这将更新 Mat hClient 清单中的引用版本,然后把整个应用程序作为一个单元进行部署。在上面所举的特殊例子中,这种方法较为可行,但 是如果是一个带有许多程序集的大型程序,就不太实用。 ● 更新和部署 MathLibrary 程序集。很显然,这是一种更为可取的方法。但是如何使以前部署的 MathClient 应用程序与 M athLibrary 程序集的新版本正确的绑定呢?如果是为了不改变新程序集的版本而固定版本号,就可能导致整个版本号的失效。 要解决这类棘手问题,关键在于应用程序配置文件。 2.3.2 再论应用程序配置文件 前面提到,使用应用程序配置文件来列出运行库探测被引用程序集的子目录清单。也可以使 用该文件,利用元素,将对一个特定的程序集版本的请求重定向到另一版 本。这种方案的好处在于无需重新编译应用程序就可以绑定新程序集。 例如,假如已经修复并重建好 MathLibrary 程序集,它的新版本号为 1.0.550.41003,需将 Mat hClient 程序集重定向加载它,替代清单中所列的 1.0.550.39732 版本。以下配置文件完成该功能: 该例中,使用了元素的 oldVersion 特性来指定需要重定向的版本号,newVersion 特性来指定重定向的版本号。 如果希望将多个旧版本重定向到同一个新版本,也可用 OldVersion 特性指定一个范围。例如: 元素实际上是的一个子元素,每一个元素为在元素中指定的程序集封装绑定信息。提 供的 name 和 publicKeyToken 特性用来指定需要重定向的程序集。这就意味着配置文件可以 有多个元素,每一个都可以将被引用的程序集重定向到一个新的版本。 例如: 2.3.3 设置机器范围的版本策略 应用程序配置文件的一个局限在于它只能对单个应用程序起作用,如果希望将所有引用从一 个程序集版本重定向到另一个版本,而不考虑应用程序本身,那应该怎么办?要实现这一点, 可以使用机器配置文件(machine configuration file)。该文件的路径是<.NET Install Path>\Co nfig\Machine.config. 机器配置文件和应用程序配置文件采用类似的格式。虽然前者为配置诸如 ASP.NET 等机器范围系统而提供了更多的元素,但是重 定向请求的方式却是一样的。而对于冲突设置,应用程序配置文件则优于机器配置文件。 2.3.4 使用.NET 框架配置工具 .NET 框架建议使用简单的文本编辑器,比如 Notepad,来编辑应用程序配置文件。这也是 使用 XML 的好处。但是,XML(基于设计的原因)对于格式不好的文档来说非常不适合。设 想如果编辑一个机器配置文件过程中,放错了一个“>”字符会出现什么情况。作者试验了 一下,计算机上每个.NET 应用程序均出现如图 2-16 的消息提示。 图 2-16 一个无效的机器配置文件引起的错误提示 基于这一理由,推荐使用.NET 框架配置工具(mscorcfg.msc)来编辑机器配置文件。.msc 扩展 名表示该工具实际是 Microsoft 管理控制台 MMC(Microsoft Management Console)管理单元。 MMC 插件为管理多种业务提供了一个通用用户界面。如果具有配置过 MTS 或 COM+应用 程序,创建过 SQL Server 数据库,或者生成过 IIS 虚拟目录的经验,那么就应熟悉 MMC 的使用方法,因为所有这些操作都通过各种 MMC 管理单元实现的。 可以采用如下命令使用该工具(假设 Windows 安装在 C:盘) mmc c:\winnt\Microsoft.NET\Framework\\mscorcfg.msc 图 2-17 显示了工具初始化时的情况。类似所有的管理单元,它的左边有一个树控制,可以 选择需要了解的内容,右边面板则显示在树控制中选中节点的相关内容。如果右键单击任何 一个节点,将显示该节点所能执行操作的上下文敏感菜单。 图 2-17 .NET 框架配置工具 现在来使用配置工具为 MathLibrary 程序集设置机器版本控制策略。首先,用右键单击“Co nfigured Assemblies”节点,选择“Add”。出现对话框,提示从 GAC 选择一个程序集或手 动输入一个程序集信息。然后从 GAC 中选择 MathLibrary,配置工具会显示一个对话框,提 示在“Binding Policy”选项卡下输入版本重定向,如图 2-18 所示。 图 2-18 使用.NET 框架配置工具设置机器配置作用域的绑定策略 单击 OK,配置工具在机器配置文件中写入下列代码: 可见,配置工具很容易地生成了本应由编程人员输入的 XML 代码。 也可以使用该管理单元编辑应用程序配置文件。首先,右键单击 Applications 节点,从上下 文菜单中选择“Add”,就可以浏览希望配置的应用程序。一旦添加了应用程序,就能查看 它所依赖的所有程序集,并且类似于机器配置文件那样配置版本绑定。但是,这里不是更新 机器配置文件,而是更新应用程序配置文件。 该工具使用简单方便,功能强大,但这里并不对所有细节进行介绍。如果读者以前使用过 M MC 管理单元,就更会感到其操作界面直观、方便。本书的后续部分将把重点放在手动配置 上,以利于读者理解如何处理实际情况。当然,作者还会提到用配置工具,来确信读者能够 使用该工具完成相同的任务。 2.3.5 配置发布者策略 这里快速回顾一下。目前可以使用应用程序配置文件在应用程序级进行版本绑定配置,也可 使用机器配置文件在机器级进行版本绑定配置。如果您是一个组件供应商,正在发布的程序 集存在明显的或潜在的错误,是否其愿意指导焦急的用户来编辑这些配置文件?如果用户在 GAC 中安装了新的(希望没有 bug)程序集,而且所有引用该程序集的应用程序自动绑定到新 的版本上,这样做不是更明智吗?回答是肯定的。这里就涉及到.NET 对发布者策略的支持。 发布者策略允许程序集供应商将应用程序重定向到程序集的新版本上。发布者策略随着新的 程序集一起发布,并安装在 GAC 中。应用程序配置文件和机器配置文件是简单文本文件, 而发布者策略文件实际上是程序集本身。 1. 构建发布者策略程序集 对运行库来说,要将一个程序集确认为发布者策略,必须按照以下约定来命名: policy... 这里是主版本号,是次版本号,是使用策略的程序集名。例 如,MathLibrary 程序集的发布者策略文件命名为 policy.1.0.MathLibrary.dll。 但是直到写本书为止,Visual Studio IDE 还没有为构建发布者策略程序集提供项目模板。 不过可以使用程序集链接器(Assembly Linker)al.exe 命令来实现这一目的。首先创建一个带 有相应版本重定向的 XML 文件。这个 XML 文件的格式与应用配置文件完全相同。下例举 例说明如何将对 MathLibrary 1.0.1.1 的请求重定向到 1.0.1.2 版本。 假设这个 XML 存放在名为 PublisherPolicy.xml 的文件中,可以使用下面程序集链接器命令 将其打包进程序集中: al /link:publisherpolicy.xml /out:policy.1.0.MathLbrary.dll /keyf:d:\mykey.snk/v:1.0.0.0. 该命令将 XML 文件编译进名为 policy.1.0.MathLibrary.dll 的程序集中,同时使用以前创建的 公共/私有密钥文件为程序集分配一个强名称。最后把发布者策略程序集的版本号设为 1.0.0. 0。需要注意,这个版本号不会对有重定向要求的 MathLibrary 起什么作用,只适用于发布 者策略程序集。 一旦建立发布者策略程序集,只需将其安装在 GAC 中即可(见图 2-19)。发布者策略文件一 就位,对于 1.0.1.1 版本的 MathLibrary 的所有请求都会重定向到 1.0.1.2 版本的 MathLibrary。 实际上,根据命名规范,运行库为任何 1.0.*版本请求读取该策略文件。这里可以得出结论: 发布者策略文件只能用于针对主版本号和次版本号的重定向请求。 虽然 Visual Studio .NET 没有发布者策略项目模板,但是可以使用类库项目建立一个策略文 件。操作时,只需删掉生成的默认类,并向项目中添加 PublisherPolicy.xml 文件即可,并在 AssemblyInfo.cs 文件中用 AssemblyKeyFile 和 AssemblyVersion 特性设置密钥文件和版本号。 图 2-19 查看 GAC 中的 MathLibrary 发布者策略文件 2. 强制关闭发布者策略 在发布一个带有新程序集版本的发布者绑定策略之前,应充分考虑新版本的后向兼容能力。 特别地,这就意味着应当保持现有公共接口,对实现的改变不要有损客户应用程序的性能。 在增加诸如新类型、方法和属性等程序集项目或修复漏洞时,要避免引起新程序集的不兼容。 如果将应用程序错误地重定向到不兼容的程序集上,客户端有两种选择解决这一问题。其一, 在应用程序配置文件中设置正确的重定向版本号;其比发布者策略有更高优先级,后者被忽 略。其二,向应用程序配置文件中添加用来关闭发布者策略的元素,这种 解决方案成为安全模式(safe mode)。 根据元素在应用程序配置文件中放置的位置,来决定是对整个应用程序有 效,还是对特定的程序集请求有效。如果它是元素的子元素,那么对整 个应用程序有效。 但是,如果是元素的子元素,那么它仅仅应用于特定的程序集。 2.3.6 策略优先 这里介绍了第三种,也是最后一种指定绑定策略的方法。可组合不同的方法以实现不同的绑 定。为了便于理解,特别注意以下几点: ● 运行库按照应用配置文件、发布者策略文件、机器配置文件的顺序进行处理。 ● 如果在配置文件中指定了多个策略文件,那么按照优先级,先应用程序策略,而后发布者策略,最后机器策略。 ● 不可以链接策略。例如,设想一个应用程序请求 1.0.0.0 版本,应用程序策略将 1.0.0.0 版本重定向到 1.0.0.1 版本,发 布者策略将 1.0.0.1 版本重定向到 1.0.0.2 版本,那么运行库会绑定到 1.0.0.2 版本吗?回答是不会的,因为运行库只采用一 个重定向。在这种情况下,由于应用程序策略的优先级最高,运行库将绑定到 1.0.0.1 版本。 2.3.7 使用元素 前面介绍过,程序集存放在两个地方:一个是在 GAC 或应用程序目录下的直接或间接子目 录中;另一个位置可能在计算机、网络或 Web 站点中的某处,运行库将对其进行查找。当 然,这需要指定程序集的确切位置。可以使用元素来实现,举例如下: 类似于元素,也是的子元素。通过设置 h ref 特性告知运行库按照给定的 URL 来查找程序集,设置 version 特性表示路由到该 URL 的 请求版本。注意,运行库在版本绑定重定向后会一直检查 元素的值。即使用户 请求 1.0.550.39732 版本,元素也会将这个版本请求重定向到 1.0.550.4100 3。现在这个版本请求与元素中的值匹配,运行库就从指定的 URL 提取程序集。 在这个例子中,URL 指向文件系统中的一个位置。当然也能指定一个 Web 站点: 如前面例子所示,元素指向强名称程序集,当然也可以用它来查询常规程序集。 但是,如果常规程序集位于应用程序目录的直接或间接子目录中,运行库将只允许一个常规 程序集绑定。因此,从 Web 站点下载的常规程序集不能被绑定。另外,对于常规程序集, 运行库忽略 version 特性,也就不必设置的 version 特性。下面配置示例显示了如 何使用元素查找常规程序集。 2.3.8 查看程序集绑定日志 很显然,程序集绑定处理过程较为复杂。引起程序集载入失败的因素较多:有可能是配置文 件中公钥令牌或者版本号输入错误、或者配置文件丢失了 XML 密钥令牌、也可能是程序集 没有安装在 GAC 中;如果使用从 Web 站点下载程序集,而 Web 站点可能已关 闭。为了便于查找问题的原因,.NET 提供了程序集绑定日志查看器(fuslogvw.exe)。 当出现程序集绑定失败时,系统将作两件事:首先,运行库触发一个异常,通常是 TypeLoad Execption;其次是运行库在程序集绑定日志中记录相应内容。虽然引发的异常告知了何处出 错,但不能提供足够的信息,要快速找出问题的缘由,就需通过查看日志文件中的详细记录。 图 2-20 显示了日志查看器的主窗口,使用时可以选择任何一个条目,单击 ViewLog 按钮, 可查看详细的日志内容。图 2-21 是一个日志项的示例。 默认情况下,出现绑定错误,运行库只记录出错项目。如果希望查看所有绑定的细节,包括 成功的操作,通过设置系统注册表中的 HKLM\Software\Microsoft\Fusion\ForceLog 即可,如 图 2-22 所示。这种做法非常有效。除了书中介绍的内容,日至还可以帮助确定运行库是否 绑定到目标程序集,例如,应绑定到应用程序目录中的程序集,而不是 GAC 中的。 技巧: .NET 安装时没有在注册表中增加 ForceLog 项,可以手动增加。需要注意的是应选择 DWORD 值类型。 图 2-20 程序集绑定日志查看器工具的主窗口 图 2-21 查看一个程序集绑定日志项 图 2-22 通过设置 FroceLog 注册表项强制记录所有程序集绑定的情况 2.3.9 绑定过程小结 正如所见,运行库定位和绑定程序集的过程非常灵活,但也较为复杂。通过这一部分的介绍, 会很自然地联想到许多的“如果…会出现什么情况”。如果一个私有程序集在应用程序目录中, 而配置文件却指向另一个子目录来载入程序集,会出现什么情况?如果运行库在 GAC 中和指 定的中,都找到程序集,会出现什么情况?如此多的可能性是无法避免的。由于无 法对所有的可能性做出解释,这里提供一个流程图来帮助阐明绑定过程(图 2-23)。如果与前面 提到的伪代码实现过程结合起来,不管什么样的情况,都能够清楚准确的决定怎样对程序集请 求进行绑定。 需要提醒注意的是,该流程图仅仅展示了运行库怎样发现被请求的程序集。一旦程序集被发现,运行库将针对程序集请求进行校验。如果是 一个强名称程序集,这就意味着找到的程序集必须与请求的程序集严格匹配;对于常规程序集,除了运行库忽略版本信息之外,其余情况类 似。 2.4 理解特性和反射 如果回避对特性的了解,开发人员就不能深入掌握.NET 的运行库。事实上,前面已经提到 了许多特性:如 AssemblyKeyFile 和 AssemblyVersion。这些特性为认识程序集提供了更多 的信息。有如上面两个例子,所有特性为某些项目提供了分配更多信息的方式。除了程序集, 可以在类、结构、接口、字段、方法、属性、返回值等中使用特性。 图 2-23 程序集绑定过程 2.4.1 使用 CLR 特性 CLR 定义了几百个特性。在代码中一个项目上运用时,这些特性指示运行库或编译器对该 项目采取某种特殊的处理。一个简单的例子是 Serializable 特性。例如可以在类中使用该特 性,允许运行库在必要时串行化类,如下所示: [Serializable] public class Car { // Class implementation } 如果以前和 IDL 打过交道,可能对这种语法比较熟悉。在所描述类前的方括号中定义特性。 如果用到多种特性,可以用逗号作为分界符。例如: [Serializable, Obsolete(@"No one uses horse and buggies anymore. Use the Car class instead", false)] public class HorseAndBuggy { // Class implementation } 编译器通过识别 Obsolete 特性,只要另外一个编程人员试图使用 HorseAndBuggy 类,就发 出一个警告信息(如图 2-24 所示)。 图 2-24 引用 Obsolete 特性的结果 本例还说明了特性可以带参数。一个警告信息和布尔值参数,表明要么报告一个编译错误, 要么发出一个警告信息。事实上,特性就是从基于 CLR、名为 Attribute 的特殊类派生出来 的。这样,可以更清楚地理解前面例子所示的特性代码:Serializable 特性调用默认的构造函 数,而 Obsolete 构造函数使用一个字符串和布尔值。 注意: 一般地,任何特性的实际类名都是附加了“ Attribute”的特性名。这样, Serializable 特性实际是一个名为 SerializableAttribute 的 类。作为简写, C#和 VB.NET 允许不使用 Attribute 后缀。 2.4.2 自定义特性的实现 除了由 CLR 声明的许多特性外,也可以创建自己的自定义特性。要定义一个自定义特性, 需要从 CLR 的 Attribute 类派生一个类: [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public class MyCustomAttribute : Attribute { private string mDescription; public MyCustomAttribute(string Description) { mDescription = Description; } public string Description { get {return mDescription;} } } 如上所示,除了使用 AttributeUsage 特性定义自定义特性类,其余部分与任何一个类的定义 相同。AttributeUsage 允许指定需要使用的自定义特性的项,而 CLR 定义的 AttributeTargets 枚举列举了所有可能的项。本例中使用了按位“或”运算符(|)来连接目标 Class 和 Field(这 样做仅仅是因为 AttributeTargets 枚举使用了 Flags 特性)。因此,MyCustomAttribute 只对类 和方法适用。 下面的代码举例说明了实际使用自定义特性的方法。 [MyCustom("This is yet another car class")] public class Car { // Uncomment line below to get compile error. //[MyCustom("Can't apply to a field")] private string mColor; [MyCustom("Apply to a method")] public int Accelerate() { ... } } 2.4.3 反射上的反射 这时您可能想知道这到底有什么意义。毕竟,系统内置特性会影响运行库和编译器的操作, 其意义显而易见。但是自定义特性能做什么呢?要解决这个问题,需要用点哲学头脑来考虑 另一个.NET 概念:反射(reflection)。这里花读者一点耐性,将这个概念与自定义特性的使用 问题结合起来进行解释。 正如笛卡尔所说:“我思故我在”,.NET 程序集说:“我有元数据故我在”。回想一下程序集 对自身的描述,细到声明的每一个类、每一个方法,和每一个私有字段。ILDasm 以一种便 于理解和使用的图形化方式来描述元数据。如果能在用户自己的运行程序中访问这类信息岂 不是很好?事实上能做到这一点,这个过程就被称作反射。通过反射,能够在运行库中发现 一个程序集内定义的所有类型;对于给定的任一一个类型,可以列出定义的所有属性、字段 和方法;对于给定的方法,可以列出每个方法的参数,对于给定的参数…总之,能得到想要 得到的东西。 在反射中起着核心作用的是 System.Type 类,通过它可以访问程序集元数据。作为一个抽象 类,不能使用 new 直接生成实例。然而,C#提供的 typeof 操作符可构造一个给定类型名的 Type 对象,也可通过调用某个对象的 GetType 方法(从 System.Object 继承的)、或调用 Type 类自身的静态 GetType 方法获取一个 Type 对象。下面的代码举例说明了如何使用这些方法。 // The many ways of getting a Type object… Type t; // Use the typeof operator t = typeof(SimpleMath); // Use the GetType method inherited from Object t = new SimpleMath().GetType(); // Use the static Type.GetType method. // String format: ".." t = Type.GetType("MathLibrary.SimpleMath, MathLibrary"); 在建立 Type 对象后,可通过调用 GetMethods 方法使用它。这里 GetMethods 方法用于获取 由 MethodInfo 对象组成的数组,每个 MethodInfo 对象包含类中一个方法的元数据。下面的 代码举例说明如何反射一个类型的方法。图 2-25 所示的是输出的结果。 t = typeof(SimpleMath); foreach (MethodInfo mi in t.GetMethods()) { Console.WriteLine(mi.ToString()); } 图 2-25 SimpleMath 类的反射结果 读到这里可能已经猜到,Type 类也能实现与 GetMethods 类似的其他方法,如:GetProperti es、GetFields、GetConstructors 等。 Type 类还能够实现 GetCustomAttributes 方法,这也就回到前面提到的有关反射的问题—— 自定义特性能做什么呢?回答是:能完成想完成的事。问题就是自定义特性只在一定范围内 有效。在该范围内,客户端对类型反射并且根据任何自定义特的存在与设置采取特殊的动作。 例如,下面的代码对在前面讨论特性时定义的 Car 类进行反射,并搜索 MyCustomAttribute 特 性。 class MyCustomAttributeDriver { static void Main(string[] args) { // Process the attributes applied to the class. Although the function // takes a MemberInfo arg, this still works because Type derives from /// the MemberInfo class. ProcessCustomAttributes(typeof(Car)); // Process the attributes applied to all members in the Car class foreach (MemberInfo mi in typeof(Car).GetMembers()) { ProcessCustomAttributes(mi); } Console.ReadLine(); } // Process all the custom attributes on any given member. private static void ProcessCustomAttributes(MemberInfo info) { MyCustomAttribute myAttr; foreach (Attribute a in info.GetCustomAttributes(false)) { // Is the attribute of type MyCustomAttribute? if ((myAttr = a as MyCustomAttribute) != null) { Console.WriteLine(myAttr.Description); } } } } // MyCustomAttributeDriver 前面例子中,ProcessCustomAttributes 函数包含了重要的逻辑。对于给定的 MemberInfo 示例, 该函数列举出成员使用的所有特性。对于每个特性而言,它决定它是否为 MyCustomAttribu te 类型的,如果是,那么在控制台窗口中显示 Description 属性。 观察 main 函数,您可能不太理解下面的语句: ProcessCustomAttributes(typeof(Car)); Typeof 操作符返回一个 Type 对象,但 ProcessCustomAttributes 函数需要一个 MemberInfo 参 数,那如何进行编译呢?实际上,由于 Type 是从 MemberInfo 派生出来的,就能够将一个 T ype 实例传递进 MemberInfo 参数。 2.4.4 正确认识特性和反射 必须承认,仅通过前面提到的简单例子还无法完全理解特性是“一个特别重要的概念”。由 于本章是概述,不可能对特性和反射的强大功用进行全面介绍。但在应用中,假定应用程序 能够检查一个自定义的 Persistence 特性,而 Persistence 特性可能定义了表的名称,如果找到 该特性,就将该特性存入数据库中;或者假定有一个自定义的记录错误例程,该程序用来检 查 DeveloperInfo 特性,并显示负责维护导致该错误的类的编程人员的姓名和电话号码。其 实这种可能性很大。 如果仍然不能理解,那么在本书后续的内容中,将会看到许多不同类型的 CLR 特性和自定 义特性,同时提供许多关于特性使用方面的示例。现在这里只提到了几个假设,如 John Le nnon 所说:“只要您去做,一切都很容易。” 2.5 理解垃圾回收 如何有效地管理系统资源,这一问题从一开始就困扰着计算机编程人员。一旦一个程序分配 了资源(如内存、文件句柄、事务锁定等),系统怎样才能确保在最佳时机释放其占有的资源, 以供别的程序使用呢? 不同的语言具有不同的资源管理体系。例如 C++,将整个负担都交给了编程人员,编程人员 必须显式地调用删除运算符来释放一个对象。尽管这样的效率较高,但是任何一个微小的编 程错误都会很容易地引起内存泄漏,而这种类型的问题很难发现。Visual Basic 借助 COM 实现内存管理,COM 使用引用计数(reference counting)来跟踪对象的使用情况,并自动地在 引用计数值递减到 0 时释放对象。 .NET 中最根本的也是颇具争议的转变就是使用垃圾回收实现资源管理。这种方法代替了在 COM 中使用的引用计数机制。在大部分日常编程工作中,垃圾回收以透明的方式运作,只 要需要,随时清理不使用的对象。不过,有几种情形下的垃圾回收机制需要进一步探讨。 2.5.1 引用计数与垃圾回收 COM 开发人员非常熟悉在资源管理中的引用计数的概念。在引用计数中,每一个对象负责 维护对象所有引用的计数值。当一个新的引用指向对象时,引用计数器就递增,当去掉一个 引用时,引用计数就递减。当引用计数到零时,该对象就将释放占有的资源。 依靠开发工具,前面所述的过程即可自动完成,也可手动完成。例如,在 Visual Basic 中所 有引用计数都是完全透明的;但在 C++中,引用一个 COM 对象的客户端必须显式地调用 I Unknown 接口的 Release 方法递减引用计数。 1. 引用计数的优点 引用计数提供了 3 点重要好处: ● 确定性终止(Deterministic Finalization)。当一个对象的引用计数到零时,线程执行的下一个操作是销毁对象和其他使用 的资源。这称为确定性终止,因为您可以预知该对象将被销毁(终止)的确切时间。 ● 资源共享(Resource Sharing)。引用计数能将对对象的引用安全地传递到程序的其他部分。引用计数规则只是简单地要 求当结束对象的使用时,用户应调用对象的 Release 方法。在 Visual Basic(.NET 之前)中,当一个对象引用变量超出了作用域, 就会自动执行该操作。实际上,资源共享的好处源于下面一个优点。 ● 生存期封装(Lifetime Encapsulation)。对象负责维护自身的对象引用计数器。当最后的用户释放对象后,对象将销毁自 身。也就是说,用户无需关心是保留对象的最后引用还是保留多个引用中的任意一个。 2. 引用计数的缺点 至此,引用计数似乎是一个很好的解决方案,那为什么.NET 却采用垃圾回收来代替它呢? 答案在于引用计数的缺点: ● 循环引用(Circular References)。如果对象 A 包含了对对象 B 的引用,而对象 B 又包含了对对象 A 的引用,在这种情况 下就会出现循环调用。如果没有其他外部干涉,这种循环引用不会被打破。同时这些对象连同分配给它们的资源,将会在应 用程序整个生命周期被占用。 ● 线程安全(Thread Safety)。引用计数机制听起来非常明了,但是如果考虑到多个线程共享一个对象时,就不再是一件简 单的事了。为了解决这一问题,必须使用专门的 Windows API,针对对象完成引用计数器的递增和递减,以确保每一个操作 和相应的测试自动完成。否则,由于到不可预期的上下文切换,引用计数无法同步。在面对多线程时,为了保证计数器安全 地进行递增和递减操作,分配两个对象引用这样极为普通任务,也是相当地麻烦。 因此,作为资源管理的通用方案,这两个缺点使得引用计数不太理想。例如,在.NET 之前, Visual Basic 对所有对象使用引用计数,但是为了避免确保线程安全地进行引用计数而带来 的开销,一个 Visual Basic 对象必须存活于一个单线程的上下文中,因此生成该对象时就需 将其与线程绑定。换句话说,Visual Basic 的对象与线程的关系极为密切。所以,一个 Visu al Basic 对象不论存放在 COM+对象池中,还是存放在任何一个机器范围的缓存中(类似于 A SP 会话对象),都会引起明显的伸缩性问题。 在典型的 C++程序中,只有应用程序边界的一些对象是 COM 对象,能够进行引用计数,而 剩下的对象需要以手动的方式管理资源。因此,只有在少部分应用程序中存在线程安全引用 计数的开销问题。但是,手工进行资源管理,虽然有效,但程序员很难避免出错,从而导致 可怕的内存泄漏。 3. 为什么使用垃圾回收 垃圾回收提供了自动的资源管理机制,不会遇到前面提到的引用计数的缺点带来的影响。. NET 垃圾回收能够对循环引用进行检测,并正确地清理对象。而且,由于不使用引用计数, 采用垃圾回收的对象在多线程应用程序中工作效果会更好。但是,垃圾回收不能提供确定性 终止,同时如要提供生存期封装和资源共享,需要编程人员自己实现自定义引用计数。 如果 C++开发人员转向 C#,垃圾回收将展现出巨大的优势。因手工管理内存出现的问题, 以及内存泄漏等也就挥之而去。那些使用或开发 COM 组件的编程人员也将从中受益。. NET 对象的用户无须调用 Release 来释放对象,而开发人员也不必不停地检查是否出现 循环引用。 但 VB 开发人员转向 VB.NET 或 C#时,情况就不同了。所有的 VB 对象都采用引用计数, 需要时 VB 会自动调用 Release,与之相比,垃圾回收所提供的好处就不明显。正如前面所 讨论到的,由于垃圾回收具有非确定性行为,这方面比 VB 的引用计数会略逊一筹。最终, 一个 VB 开发人员的观念通常取决于他(或她)对多线程和对象池的理解程度和循环引用漏洞 带来的不快经历。 2.5.2 垃圾回收的内部机理 在大多数情况下,您无需关心垃圾回收是如何工作的。可是,有时不必关心如何回收对象却 需要了解对象的分配过程,有时又需要了解垃圾回收对性能的影响。因此最好理解了垃圾回 收的工作机理,才能有助于清楚地认识,使思维更为清晰。 首先,垃圾回收器只有在必要的时候才会工作。运行库根据各种因素综合考虑做出是否运行 的决定,其中考虑的主要因素是托管堆是否已满。它还考虑当前线程活动,确保是安全停止 了线程,以便运行垃圾回收器。当垃圾回收器运行时,运行库暂停应用程序中的所有线程。 其原因稍后会揭晓。 一旦垃圾回收器启动,它首先对应用程序“根”引用进行定位。这些“根”引用均是全局的、 静态的,或在线程堆栈(也就是局部变量引用)中已分配的。然后检查每一个根对象,搜索引 用数据成员。接下来,逐一检查这些成员对象,搜索更多的引用成员,以此类推。当垃圾回 收器执行这一操作时,它在图表中记录访问的每一个对象。这样就可以告诉它已经访问了的 引用对象,将不再试图访问。如果没有这种机制,那么循环引用可能会使垃圾回收器陷入永 无休止的循环中。 以上过程完成后,结果图表将显示“可获取的”应用程序对象。根据该信息,垃圾回收器将 图表中的对象与在托管堆中分配的对象进行比较。如果发现托管堆中的对象没有在图表中 (也即“不可获得的”),将会采取下面操作中的一种:如果对象没有实现 Finalize 方法,垃 圾回收器立即销毁该对象并重新申明内存空间;如果对象实现了 Finalize 方法,那么运行库 在被称为 freachable 队列的内部结构中为该对象放置一个引用。这点在后面进行解释。 垃圾回收器清理完未使用的对象之后,托管堆内的当前存活对象之间存在因清除不用的对象后而遗留的间隙。为了提高托管堆的分配速 度,使整个托管堆更为紧凑,垃圾回收器删除全部的空闲间隙。当然,这就需要在堆内部移动当前存活对象,同时为反射它们的新位置 而更新应用程序中的全部引用。这就是垃圾回收器运行时,运行库必须暂停所有应用程序线程的原因。 前面已经提到,垃圾回收器以不同方式处理实现 Finalize 的对象。当垃圾回收器判断出对象 是不可获得的(unreachable),它将为该对象在 freachable 队列中添加一个引用。从本质上讲, 这使得以前不可获得的对象成为可以获得(reachable)的对象。换句话说,这使得对象再次复 活,但在它后半部分生存期中使用受到局限。在垃圾回收器完成对不可获得对象的清除后, 它将启动另一个线程,调用 freachable 序列中每个引用的 Finalize 方法,然后清除队列。最 后,在下一次垃圾回收时,把终止的对象从托管堆中清除掉。 2.5.3 实现 Finalize 方法 正如所见,如果一个对象要实现 Finalize 方法,势必引起更多的垃圾回收开销。当运行 库分配一个可终止(finalizable)的对象时,它在被称为终止队列的内部数据结构中放置 一个指向该对象的指针,这使得分配时间变得更长。基于这些原因,应当尽可能生成无 需终止的对象。 实际上,可终止对象的使用频率并不是很低。终止器(finalizer)允许对象清理在其生存期内分 配的资源。例如,它可能生成其他对象、打开文件、获得数据库的连接等等。垃圾回收将编 程人员从考虑如何释放被分配资源的复杂过程中解放出来,同时也把对象解放出来。如果一 个对象只包含托管(也即垃圾回收)资源,就不必实现 Finalize 方法。只有在进行非托管资源 分配时,才有必要使用 Finalize 方法。可以回避使用 CLR,直接调用 Windows API 函数来 构成可访问的 OS 资源。不过记住,CLR 将大多数 Windows API 封装于托管类中,因此上 述情况应该很少。 下列代码是使用终止器的 C#语法。 class SimpleMath { ~SimpleMath() { // Finalization code here } } 该语法对于 C++编程人员来说非常熟悉,因为 C++是用同样的语法来声明析构函数(destruct ors)。事实上,C#文件中把终止器称作析构函数。可这种称谓并不恰当,因为 C++的析构函 数和终止器有很大的区别。首先,C++编程人员在编写的每个类上经常使用析构函数;而在 C#中,如前所述,这样做实际破坏了垃圾回收的性能。其次,在 C++中析构函数只有在分 配了堆栈的对象超出了作用域,或使用 delete 操作符明确释放分配了堆栈的对象时才运行; 相反,C#析构函数的运行不是确定的,您不可能知道垃圾回收器实际运行析构函数的时间。 因此不要把 C#的析构函数与 C++的析构函数混为一谈,它们虽然看起来可能一样。事实上 对 IL 来说,C#析构函数解决了相当于如下的问题: protected override void Finalize() { try { // Finalization code here } finally { base.Finalize(); } } 但是,可以试一下不使用析构函数语法,而使用这种 C#代码。其实真这样做,C#编译器会 报错,提示使用析构函数语法。 尽管这种情况可能会引起 C++开发人员的混淆,但是使用析构函数的术语来描述 C#的终结 机制,主要是考虑到维护 Microsoft C#文档的一致性。 2.5.4 实现 IDisposable 接口 与引用计数不同,垃圾回收只保证在以后的某个时间终止无用对象。这种终止方式的不确定 性会产生什么样的影响呢?如果程序中为对象在其生存期内分配稀有资源会出现什么情况 呢?这里提到的“稀有资源”,是指为专属访问而打开的文件、Windows 对象的句柄和数据 库的连接等。作为一个优秀的编程人员,应当能通过实现对象的析构函数来释放所有需要的 资源。但是,垃圾回收器只是周期性地运作,因此垃圾回收器在运行它的析构函数和释放对 象时,该对象往往已经有较长时间没有被使用。从这个意义上讲,所有稀有资源也是空闲的。 这样,如果一个对象在其生存期内必须分配稀有资源时,它应当使用一个公开的拆卸方法, 使得用户尽可能显式地释放对象。Microsoft 推荐使用 Dispose 方法,实际上,为了帮助体现 这种形式,CLR 提供了名为 IDisposable 的接口: public interface IDisposable { void Dispose(); } 甚至如果一个类实现了 IDisposable,在许多情况下这是提供析构函数的好办法。如果用户为 拆卸对象调用 Dispose 失败(这种情况总会发生),它就起安全网的作用。下面例子中的 Reso uceWrapper 类演示了该实现模式。 public class ResourceWrapper : IDisposable { public void Dispose() { //1. Call Dispose on contained disposable objects //2. Free any contained unmanaged resources //3. Suppress finalization as shown below: GC.SuppressFinalize(this); } ~ResourceWrapper() { //1. Free any contained unmanaged resources //2.Do NOT call Dispose on contained managed objects } } 这里详细描述每个 Dispose 步骤的细节: (1) 针对所有包含的托管对象调用 Dispose。如果对象指派了其他可清除(disposable )对象, 那么对每一个调用 Dispose。 (2) 释放所有包含的非托管资源。如果对象指派的资源不采用垃圾回收,应当使用适当 机制来释放这些资源。 (3) 限制终止操作。Dispose 一旦运行,垃圾回收器不再需要终结对象。为了提高垃圾 回收的性能,确保对象不会重复释放一个资源,应如上一个例子那样,调用 GC.SuppressFi nalize 方法。这将避免垃圾回收器执行析构函数。 您可能对析构函数中第 2 步还存在疑虑,为什么不能对包含的托管资源对象调用 Dispose 呢?问题在于垃圾回收的非确定性。因为不光对垃圾回收时间的不可预测,而且对垃圾回收 的顺序也是不可预测的。换句话说,在析构函数运行时,包含的托管对象很有可能已经执行 了垃圾回收操作,如果试图对已释放的对象再调用该方法,会引发异常出现。基于这个理由, 千万不要在析构函数中调用包含的托管对象,因为此时它们可能已不存在。 1. 使用可清除对象 初一看,使用可清除对象看起来似乎很简单。首先生成该对象,然后使用,使用结束后调用 Dispose。例如: // How NOT to use a disposable object. public void SomeFunction() { ResourceWrapper rw = new ResourceWrapper(); // Use the resource wrapper object. // What if an exception happened? // When finished, dispose of the object rw.Dispose(); } 前面代码的问题是未考虑到异常的影响。若使用对象过程中出现异常,程序跳过对 Dispose 的调用和所有内部实现的实时资源释放逻辑,直接进入最近的 try 模块,然后在 try 模块的 f inally 部分使用 Dispose 调用。正确使用可清除对象的方式如下例所示: public void SomeFunction() { ResourceWrapper rw = new ResourceWrapper(); try { // use the resource wrapper object } finally { // Now dispose is called,guaranteed. rw.Dispose(); } } 2. 使用 Using 关键字 在 finally 部分进行 Dispose 调用,能保证出现异常时仍然能够调用该方法。但是仅是为了一 个可清除对象编写异常处理代码又显得较为麻烦,因此 C#提供了一种捷径:使用 using 关 键字。下面的例子显示了使用 using 关键字后重写的代码: public void SomeFunction() { using (ResourceWrapper rw = new ResourceWrapper()) { // Use the resource wrapper object } // Dispose is called on resource wrapper object } 但是,这里 C#继承了 C++传统的重载关键字的含义。在上下文中,using 对于指定命名空间 提供了一种快捷方式,同时对一个指定的可清除对象,它限定了一个特殊的作用域。当超出 该作用域,无论出现了异常与否,对象的 Dispose 方式都会被调用。 注意: 在 using 关键字后面的表达式必须返回一个实现 IDisposable 接口的对象。 2.5.5 正确使用垃圾回收 如果是开发分布式 COM(+)的高级编程人员,在读完这一节后可能感到难以理解。编写 可伸缩系统的一个重要原则是尽可能快地将宝贵的资源释放回到可用池中。虽然 Dispose 方 式能做到这些,但是却增加了编程人员调用 Dispose 的负担,而且经验丰富的分布式开发人 员知道依赖客户端对释放的资源不可靠。 但是如果了解下面的几点,就不会有上面提到的问题感到太多的顾虑。首先,在分布式环境 中,许多生成的资源敏感(resource-intensive)对象是无状态的,意味着对象只能在一个方法调 用期间存活,因此只能在方法调用期间分配和使用资源。其次,客户端激活的远端对象具有 另一种称为租赁(Lease-based)对象生存期的对象生命期模型,该模型构建在垃圾回收方式之 上。对这一机制将在下一章中进行详细地介绍。现在就可以有充分的理由说在分布式资源管 理中,这种方法比原来的引用计数方式更为有效。 2.6 串行化 串行化(serialization)是指将一个对象的当前状态转换成字节流(a stream of bytes)的过程,而 反串行化(deserialization)则指串行化过程的逆过程,将字节流转换成一个对象。初听起来可 能对此不太感兴趣,但是使用串行化却有许多重要的原因。一旦将某一对象串行化,得到的 字节可以存储在文件、数据库,或内存中—— 只要是可以存储的任何地方。需要恢复对象时, 仅仅只需从它存储的位置反串行化即可。对象固有的这种特性对于无状态的 Web 应用程序 是非常重要的,因为它允许重要的状态信息可以在用户请求之间保留。 串行化也允许对象在应用程序边界之间传递,编程人员可以将描述对象状态的字节流在网络 上传递,并在另一端反串行化成一个匹配的对象。从本质上讲,串行化允许对象以“数值” 的方式传递给另一个应用程序。.NET 远程框架(将在第 3 章中讨论)广泛地在进程之间使用 串行化来编组对象数据。 串行化不是.NET 新引入的概念,Windows 程序员同各种各样的串行化打交道:MFC 中的归 档机制、Visuabl Basic 的 PropertyBag 和 COM 的 IPersistStream 接口等,都具有类似的特性。 那么.NET 串行化与这些方式又有什么区别呢?一个字,简单。在.NET 以前的开发环境中, 串行化是一个手工定制,对于类设计人员来说,实现类的串行化并不困难,但是实现难度较 大的设计,它就显得能麻烦、耗时。在.NET 中,该过程完全是自动进行的,用户所做的就 是使用 Serializable 特性来标记一个能串行化的类,运行库做余下的事。 .NET 运行库怎样实现这一捷径的呢?当然是利用镜像,或更为准确地说是反射。前面提到, 程序集含有名为元数据的自描述(self-describing)数据,程序集的元数据完整地表述了内部每 一个类型,包括类型中每个私有字段的信息。通过反射过程,运行库能识别元数据,并用它 来决定类应怎样被串行化。 2.6.1 使用 Serializable 特性 前面已经提到,如果允许运行库串行化一个对象,必须将该类标记上 Serializable 特性。下 面的代码定义了两个标记了 Serialable 特性的类。 [Serializable] public class Car { private string mColor; private int mTopSpeed; //Reference to another object private Radio mRadio; [NonSerialized] //Runtime will not serialize this field private string mNickName; public Car(string nickName, int topSpeed, string color) { mNickName = nickName; mTopSpeed = topSpeed; mColor = color; mRadio = new Radio(); } } [Serializable] public class Radio { private int mVolume = 5; } 如上所示,Car 类的 mNickName 字段用 NonSerialized 特性标注,指示运行库进行串行化时 跳过该项,而其他部分允许进行串行化。这里注意,mRadio 是对另一对象的引用,意味着 运行库必须对其串行化。事实上,当运行库被告知要对某个对象串行化时,使用给定的对象 作为根,建立一个对象图表(类似于垃圾回收器)。所有在图表中的对象也必须可被串行化的, 否则运行库将引发异常。 下面的代码举例说明如何将 Car 对象串行化到一个文件之中。 static void Main(string[] args) { Car myCar = new Car("Christine", 150, "Red"); FileStream mySoapFile = File.Create("Car.txt"); // Use a SOAP formatter object to serialize the object. new SoapFormatter().Serialize(mySoapFile, myCar); mySoapFile.Close(); } 运行库不控制串行化数据的实际格式,但是有一个专门进行格式化的对象负责对串行化输出 进行格式化。.NET 框架有两种格式化程序:SOAP 格式化程序和二进制格式化程序。在前 面的例子中,SoapFormatter 对象将对象串行化进一个 SOAP 报文,并将其发送到特定的流 中。图 2-26 显示了在执行完这段代码后 Car.txt 中的内容。 如果仔细查看图 2-26,很容易发现 SOAP 是如何描绘每个对象和它的数据成员。这种方式通 过增加文件大小来提高了可读性。二进制格式化程序使用了更为紧凑的格式。如果需要在网 络间发送对象,这种方式就更重要,尤其是在需要考虑网络带宽而不强调可读性的情况下。 通过调用 SoapFormatter.Dewerialize 方法,就可将该对象读入内存中: static void Rehydrate() { FileStream mySoapFile = File.Open("Car.txt", FileMode.Open); Car myCar = (Car) new SoapFormatter().Deserialize(mySoapFile); mySoapFile.Close(); } 图 2-26 使用 SOAP 格式化程序对 Car 对象进行串行化 2.6.2 ISerializable 接口和 Formatter 类 在某些情况下,需要比默认的串行化机制更强的功能时,可以实现 ISerializable 接口。它提 供了一种对串行化数据更为精确控制,但不提供对数据格式的控制。这时,就需要扩展 For matter 类。 下面的代码演示了如何实现 ISerializable 接口。 [Serializable] public class Car : ISerializable { private string mColor; static private int mTopSpeed; private Radio mRadio; [NonSerialized] private string mNickName; // Required by the ISerializable interface public void GetObjectData(SerializationInfo info, StreamingContext context) { info.AddValue("mColor", mColor); info.AddValue("mTopSpeed", mTopSpeed); info.AddValue("mRadio", mRadio); } // This contructor is required todeserialize the object private Car(SerializationInfo info, StreamingContext context) { mTopSpeed = info.GetInt32("mTopSpeed"); mColor = info.GetString("mColor"); mRadio = (Radio) info.GetValue("mRadio", typeof(Radio)); } . . . } 当运行库串行化对象时,将调用 ISerializable.GetObjectData 方法,并传递 SerializationInfo 对象和 StreamingContext 结构。SerializationInfo 对象提供 AddValue 方法,允许在对象中使 用相关的名称存储一个值。VB6.0 编程人员也许认为 SerializationInfo 对象是 PropertyBag 的 另一个表示方法,但是与 PropertyBag 仅存储变量不同,SerializationInfo 对象能存储强类型 数据。AddValue 经过几次重载,允许加入任何一个原始 CLR 类型。可以研究 StreamingCon text 结构的内容,从而准确地判定加速串行化处理过程的因素是什么。例如,如果一个对象 被传递给另一处理过程时,希望采用不同的串行化该对象的方法。如下代码所示: public void GetObjectData(SerializationInfo info, StreamingContext context) { // If serializing across proxess , then skip the radio object if (context.State != StreamingContextStates.CrossProcess) { mRadio = (Radio) info.GetValue("mRadio", typeof(Radio)); } info.AddValue("mColor", mColor); info.AddValue("mTopSpeed", mTopSpeed); } 该例考察 StreamingContext 结构的 State 字段。这个枚举指定了被串行化对象的源或目标地。 如果它与 StreamingContextStates.CrossProcess 相同,目的地就是同一机器上的另一进程。在 那种情况下,代码不能对包含的 Radio 对象串行化。 通过对 ISerializable 接口的实现允许控制“什么”被串行化。而控制对象“如何”被串行化, 就必须采用格式化程序类。这里还需要实现其他几种方法,包括 Serialize、Deserialize、Wr iteBoolean、WriteByte、WriteArray 等。这比本书实际用到的要深,因此把它留给细心的读 者作为练习。 2.7 小结 本章目的是探讨对于分布式程序设计十分重要的.NET 基本概念,具体包括: ● 通过构建 CTS 和 CLS,.NET 实现了语言和平台的互操作性。 ● CLR 是对 CTS 的实现的描述。它是一种为.NET 程序提供的围绕 Windows 操作系统和运行环境的面向对象包装。 ● .NET 程序集表示一个可控制版本的部署单元。它提供了包含所有定义类型的容器。强名称程序集可用于单个应用程序(私 有程序集),或将其安装在 GAC 中,供其他应用程序使用(共享程序集)。 ● 特性为代码项提供附加的信息。有些特性能被编译器和运行库识别,并能指导它们对代码项完成特殊的操作。自定义特 性可以自己生成,如果被检测到并通过反射实现操作时是很有用的。 ● .NET 使用垃圾回收实现资源管理,代替了在 COM 中使用的引用计数。垃圾回收虽然能很好解决循环引用问题并在多线 程环境中提高运行性能,但是不像引用计数,不能提供确定性终止。 ● 通过反射,.NET 提供了自动类型串行化。如果希望由运行库实现串行化,只需要在希望串行化的对象前使用 Serializabl e 特性;并且可通过实现 ISerializable 接口来控制串行化过程。 当然,.NET 的内容很多,不可能在一章中全部涉及。希望通过本章的介绍,为读者在本书 后续部分的学习过程中提供必要的知识储备。 —— Andrew Troelsen 编写 这是《C# and the .NET Platform》一书(Apress,2001 年出版)的第 13 章。因此,这里所有 的参考都来自于 Troelsen 所著的书。 除非您的职业是视频游戏开发人员,否则您就应该对数据库操作感兴趣。正如您所期望的,. NET 平台定义了很多类型(在一堆相关的命名空间中),这些类型可以和本地或远程数据存储 进行交互。总的来说,这些命名空间就是 ADO.NET,您将看到的内容其实只是对典型 AD O 对象模型作了全面修订。 本章将首先看到的是定义在 System.Data 命名空间中的一些核心类型—— 特别是 DataColum n、DataRow 和 DataTable。这些类允许你定义和操作本地内存中的数据表。然后,您将会花 大量的时间来了解 ADO.NET 的重要部分:DataSet(数据集)。可以看到,DataSet 是相关表 集合在内存中的表示形式。在这部分讨论中,您将会了解到如何以编程的方式模拟表关系, 通过给定的表创建自定义视图以及对内存中 DataSet 提交查询。 在讨论如何操作内存中的 DataSet 后,本章的后面部分将阐述如何从数据库管理系统(比如 M S SQL Server、Oracle、MS Access)中获得一个已填充的 DataSet。然后我们看一下.NET“托 管提供者”以及 OleDbDataAdapter 和 SqlDataAdapter 类型。 A.1 ADO.NET 了解 ADO.NET 时首先要明白的就是 ADO.NET 并不只是典型 ADO 的最新和最大的版本。 虽然这两个系统之间确实有一些对应的地方(比如都有“Connection”和“Command”对象 的概念),但另一些熟悉的类型已经没有了(比如 RecordSet)。而且还出现了很多新的 ADO. NET 类型,这些新的类型并不能在典型 ADO 中找到直接的替代品(比如 DataSet)。 总之,ADO.NET 是一个新的数据库访问技术,这个技术尤其适用于使用.NET 平台的断开 连接的系统开发。对于大多数的新的开发工作而言,N 层应用程序(特别是基于 Web 的应用 程序)正越来越普遍。 与典型 ADO 主要用于紧密耦合的客户端/服务器系统不同,ADO.NET 通过一个新类型 Dat aSet 大大扩展了 ADO 中 RecordSet 的功能。这个类型能表示任意数目的关系表的本地副本。 当与数据源断开连接后,客户端能够使用 DataSet 来操作并更新其内容,并使用相关的“数 据适配器”把修改的数据提交回去进行处理。 典型 ADO 和 ADO.NET 另外一个最大的不同就是 ADO.NET 完全支持 XML 数据表示。实 际上,从数据存储中获得的数据已经以 XML 形式内部表示并传输。由于 XML 可以通过标 准的 HTTP 协议在多层之间传输,因此 ADO.NET 就不会受到防火墙约束的任何限制。 可以了解到,典型 ADO 使用 COM 编组协议(marshaling protocol)在多层之间传输数据。虽 然这个技术在某些环境下特别适用,但 COM 编组还是有很多局限性。典型的情况就是很多 防火墙都被配置为拒绝 COM RPC 包,这样就很难在机器之间传输数据。 典型 ADO 和 ADO.NET 最基本的不同点可能就在于 ADO.NET 是一个代码的托管库,因此 它和其他托管库受到的规则约束是一样的。包含 ADO.NET 的类型使用 CLR 内存管理协议, 遵循相同的编程模式并且能使用多种语言。因此,可以用完全相同的方式访问这些类型(以 及这些成员),而不管您使用的是哪一种.NET 语言。 ADO.NET:宏伟蓝图 组成 ADO.NET 的类型相互协作只有一个共同目的:填充 DataSet、断开与数据存储之间的连 接,然后把 DataSet 返回给调用者。DataSet 是一个非常有意思的数据类型,它表示客户端应 用程序使用表的本地集合(同样也表示了这些表之间的关系)。有时,这会让您想起典型 ADO 中断开连接的 RecordSet。主要区别在于断开连接的 RecordSet 只能表示数据表,而 ADO.NE T 的 DataSet 则能模拟关联表的集合。实际上,完全可以用一个客户端的 DataSet 来表示整个 远程数据库。 在获得一个 DataSet 后,就可以对本地表进行查询来获得特定的信息子集,同样可以用编程 的方式在关联表之间导航。正如您所期望的,可以对 DataSet 中某个表添加一些新的行,或 者删除、过滤和更新已有的记录。在完成这些修改后,客户端可以把修改后的 DataSet 提交 回数据存储进行处理。 这时候就会出现一个显而易见的问题:“怎么获得 DataSet?”在 ADO.NET 模型下,可以用一 个托管提供者来填充 DataSet,这个提供者就是实现了定义在 System.Data 命名空间中一些核 心接口类的集合;特别是 IDbCommand、IDbDataAdapter、IDbConnection 以及 IDataReader(见 图 A-1)。 图 A-1 客户端和托管提供者进行交互 ADO.NET 中带了两个特殊的托管提供者。第一个就是 SQL 提供者,它能提供与存储在 MS SQL Server(7.0 或更高版本)中的数据高度优化的交互。如果所要的数据不在 SQL Server 数据文件中,您还可以使用 OleDb 提供者,它可以访问支持 OLE DB 协议的数据存储。然 而,要记住 OleDb 提供者使用本地的 OLE DB(因此也就需要 COM 交互)来进行数据访问。 正如您所猜想的,通常这要比用其本机语言与数据存储通信慢得多。很快其他开发商都会在 自己的数据存储带上自定义的托管提供者。到那时,OleDb 提供者会获得成功。 A.2 了解 ADO.NET 命名空间 和.NET 领域其他方面一样,ADO.NET 定义在大量相关的命名空间中。表 A-1 给出了每个 命名空间的快速参考。 表 A-1 ADO.NET 命名空间 ADO.NET 命名空间 意 义 System.Data 这是 ADO.NET 的核心命名空间。它定义了表示表、行、列、约束和 DataSet 的类型。这个命名 空间并没有定义连接到数据源的类型。而且,它所定义的类型只能表示数据本身 System.Data.Common 这个命名空间包含了能在托管提供者之间共享的类型。这些类型中很多都能作为由 OleDb 和 SqlClient 托管提供者所定义的具体类型的基类 System.Data.OleDb 这个命名空间定义了可以连接到 OLE DB 兼容的数据源、提交 SQL 查询和填充 DataSet 的类型。 这个命名空间中的类型有些类似于典型 ADO 中的类(但不完全相同) System.Data.SqlClient 这个命名空间定义了组成 SQL 托管提供者的类型。使用这些类型可以直接与 Microsoft SQL Server 进行交互,避免了 OleDb 对等物间接性的缺点 System.Data.SqlTypes 这个类型表示了原先在 Microsoft SQL Server 中使用的原始数据类型。虽然您还是可以任意使用 相应的 CLR 数据类型,但是这些 SqlTypes 类型已经被优化来应用到 SQL Server 中 所有这些 ADO.NET 命名空间都存放在一个名为 System.Data.dll 的程序集中(图 A-2),和其 他引用外部程序集的项目中一样,您必须确保设置了对这个.NET 二进制文件的引用。 在所有 ADO.NET 命名空间中,System.Data 是最基本的共同点。如果您在数据访问应用程 序中没有指定这个命名空间就不能创建 ADO.NET 应用程序。另外,如果要建立与某个数据 存储的连接,您还必须指定 using 指令,导入 System.Data.OleDb 或者 System.Data.SqlClient 命名空间。为什么要这样做,我们很快就要讨论到。现在开始了解定义在 System.Data 中的 一些核心类型。 图 A-2 Sytem.Data.dll 程序集 A.3 Sytem.Data 中的类型 前面已经提到,这个命名空间包含了能够表示从数据存储中获得的数据类型,但并没有作字 面连接的类型。另外,除了一些数据库方面的异常之外(NoNullAllowedException,RowNotI nTableException,MissingPrimaryKeyException 等),其他类型就是常见数据库基本元素(表、 行、列以及约束等)的面向对象(OO)的表现形式。表 A-2 列出了一些核心类型,根据它们相 关的功能进行分组。 表 A-2 System.Data 命名空间的类型 System.Data 意 义 DataColumnCollection DataColumn DataColumnCollection 用来表示特定 DataTable 用到的所有 DataColumn 列。DataColumn 表示 DataTable 中的某个列 ConstraintCollection Constraint ConstraintCollection 表示某个 DataTable 上所指定的所有约束(外键约束,惟一约束)。Constraint 表示了指 派到一个或多个 DataColumn 上的某个约束的面向对象的包装器 DataRowCollection DataRow 这些类型表示了 DataTable(DataRowCollection)的行的集合,和 DataTable(DataRow)中特定数据行 DataRowView DataView DataRowView 允许从已有行划出预定义 DataView。DataView 表示 DataTable 的自定义视图,这个视图可 以排序、过滤、搜索、编辑和导航 DataSet 表示数据在内存中的缓存,这可能包含多个关联 DataTable ForeignKeyConstraint UniqueConstraint ForeignKeyConstraint 表示对主键/外键关系下的一组列强制的约束。UniqueConstraint 类型表示在一组列上 的约束,其中所有值必须惟一 DataRelationCollection DataRelation 这个集合表示了 DataSet 中表之间的所有关系(也就是 DataRelation 类型) DataTableCollection DataTable DataTableCollection 表示某个 DataSet 上所有的表(也就是 DataTable 类型) 本章前面一部分讨论了如何手动操作断开连接模式下的这些项。在您初步了解如何创建未经 处理的 DataSet 之后,在操作由托管提供者填充的 DataSet 时就不会出现什么问题了。 A.4 检查 DataColumn 类型 DataColumn 类型表示了 DataTable 上的一列。总的来说,绑定到某个 DataTable 的所有 Data Column 类型的集合就表示一个表。例如,假设现在有一个 3 列(EmpID、FirstName 和 Last Name)组成的表 Employees,您将使用 3 个 ADO.NET 的 DataColumn 对象来在内存中表示它 们。稍后您将会看到,DataTable 保留了一个内部的集合(可以用 Columns 属性来访问)来维 护它的 DataColumn 类型。 如果您有一些关系数据库的背景知识,就一定知道可以对数据表中的列指派一组约束(比如, 配置为主键、指派一个默认值、配置为包含只读信息等)。另外,表中的每一列都必须映射 为一个基本数据类型(int、varchar 等)。例如,这个 Employees 表的模式就可以要求列 EmpI D 必须映射为整型,而 FirstName 和 LastName 映射为字符数组。DataColumn 类提供了大量 的属性,允许您配置这些内容。表 A-3 列出了一些核心属性的概述。 表 A-3 DataColumn 的属性 DataColumn 属性 意 义 AllowDBNull 用于表明是否有一行可以在列上指定 null 值。默认值为 true AutoIncrement AutoIncrementSeed AutoIncrementStep 这些属性用于配置给定列的自动递增行为。当您想确保某个 DataColumn(例如主键)上都是惟一值时,这非常有帮助。默 认情况下,DataColumn 并不支持自动递增 Caption 获取或者设置这个列的显示标题(例如,终端用户在 DataGrid 上看到的内容) ColumnMapping 这个属性定义了当 DataSet 用 DataSet.WriteXml()方法保存为一个 XML 文档时,如何表示 DataColumn ColumnName 获取或设置在 Columns 集合(表示 DataTable 如何进行内部表示)中列的名称。如果您没有显式地设置 ColumnName,默 认的值就是列名加上(n+1)的数字后缀(例如,Column1,Column2,Column3 等) DataType 定义了列中存储的数据类型(boolean、string、float 等) DefaultValue 当插入新行时,获取或设置分配给这个列的默认值。如果没有特别指定,就设定这个值 Expression 获取或设置用来过滤行、计算列的值或者创建聚集列的表达式 Ordinal 获得这个列保存在 DataTable 的 Columns 集合中的数字索引 ReadOnly 确定在一行添加到表后这个列是否可以被修改。默认为 false Table 获取包含有 DataColumn 的 DataTable Unique 获取或设置表示这列每行中的值是否惟一或者是否允许有重复值。如果有一列被指派了主键约束,Unique 属性必须为 true A.4.1 构建 DataColumn 下面来说明 DataColumn 的基本用法,假设需要模拟一个 FirstName 列,这个列内部被映射 为一个字符数组。另外还假设这个列(为了某个原因)必须只读。可以以编程的方式编写下列逻 辑: protected void btnColumn_Click (object sender, System.EventArgs e) { // Build the FirstName column. DataColumn colFName = new DataColumn(); // Set a bunch of values. colFName.DataType = Type.GetType("System.String"); colFName.ReadOnly = true; colFName.Caption = "First Name"; colFName.ColumnName = "FirstName"; // Get a bunch of values. string temp = "Column type: " + colFName.DataType + "\n" + "Read only? " + colFName.ReadOnly + "\n" + "Caption: " + colFName.Caption + "\n" + "Column Name: " + colFName.ColumnName + "\n" + "Nulls allowed? " + colFName.AllowDBNull; MessageBox.Show(temp, "Column properties"); } 结果如图 A-3 所示。 图 A-3 DataColumn 选择的属性 由于 DataColumn 提供了一些重载的构造函数,您就可以直接在创建时指定许多属性,如下 所示: // Build the FirstName column (take two). DataColumn colFName = new DataColumn("FirstName", Type.GetType("System.String")); colFName.ReadOnly = true; colFName.Caption = "First Name"; 除了这些属性之外,DataColumn 还有一些方法,我想您肯定会亲自研究一下的。 A.4.2 把 DataColumn 添加到 DataTable 中 DataColumn 类型一般不以独立实体存在,而是插入到 DataTable 中。下面先从新建一个 Dat aTable 类型开始(将会在本章稍后部分详细讨论)。然后通过 Columns 属性把每个 DataColum n 插入到 DataTable.DataColumnCollection 类型中。示例如下: // Build the FirstName column. DataColumn myColumn = new DataColumn(); // Create a new DataTable. DataTable myTable = new DataTable("MyTable"); // The Columns property returns a DataColumnCollection type. // Use the Add() method to insert the column in the table. myTable.Columns.Add(myColumn); A.4.3 把 DataColumn 配置为主键 数据库开发的一个通常规则就是表至少得有一个列作为主键。主键约束用于惟一标识给定表 中的一条记录(行)。继续前面的 Employees 例子,假设您现在需要新建一个 DataColumn 列来 表示 EmpID 字段。这个列将作为表的主键,它必须有 AllowDBNull 和 Unique 属性,配置如 下所示: // This column is functioning as a primary key. DataColumn colEmpID = new DataColumn(EmpID, Type.GetType("System.Int32")); colEmpID.Caption = "Employee ID"; colEmpID.AllowDBNull = false; colEmpID.Unique = true; 如果已经把这个 DataColumn 正确地设置为主键,下一步就是将这个 DataColumn 指派给 Da taTable 的 PrimaryKey 属性。您将在 DataTable 的讨论中看到如何只要少量代码就可以完成 这个任务,因此我们把这个作次要讨论。 A.4.4 启用 Autoincrementing 字段 您可以为 DataColumn 配置的是其自动递增功能。简单地说,自动增加列可以确保当一个新 行被添加到给定表时,可以基于当前的递增步长值自动指定这个列的值。当您想确保某个列 上没有重复值时(比如主键),这个功能就特别有用。这个功能可以用 AutoIncrement、AutoI ncrementSeed 和 AutoIncrementStep 属性来控制。 让我们创建一个支持自动递增的 DataColumn 来说明一下。种子值用于标记列的起始值,步 长值表示递增时增加种子值的数值,如下所示: // Create a data column. DataColumn myColumn = new DataColumn(); myColumn.ColumnName = "Foo"; myColumn.DataType = System.Type.GetType("System.Int32"); // Set the autoincrement behavior. myColumn.AutoIncrement = true; myColumn.AutoIncrementSeed = 500; myColumn.AutoIncrementStep = 12; 这儿把 Foo 列配置为能使这个字段的值在行添加到特定表时自动增加 12。由于种子值被定 为 500,前面 5 个值应该是 500、512、524、536 和 548。 可以往一个 DataTable 中添加这个 DataColumn 来测试一下。然后往这个表中添加一些新行, 当然会自动转储 Foo 列中的值,如下所示: protected void btnAutoCol_Click (object sender, System.EventArgs e) { // Make a data column that maps to an int. DataColumn myColumn = new DataColumn(); myColumn.ColumnName = "Foo"; myColumn.DataType = System.Type.GetType("System.Int32"); // Set the autoincrement behavior. myColumn.AutoIncrement = true; myColumn.AutoIncrementSeed = 500; myColumn.AutoIncrementStep = 12; // Add this column to a new DataTable. DataTable myTable = new DataTable("MyTable"); myTable.Columns.Add(myColumn); // Add 20 new rows. DataRow r; for(int i =0; i < 20; i++) { r = myTable.NewRow(); myTable.Rows.Add(r); } // Now list the value in each row. string temp = ""; DataRowCollection rows = myTable.Rows; for(int i = 0;i < myTable.Rows.Count; i++) { DataRow currRow = rows[i]; temp += currRow["Foo"] + " "; } MessageBox.Show(temp, "These values brought ala auto-increment"); } 如果您运行了这个应用程序(然后单击相应的按钮),将会看到如图 A-4 所示的消息。 图 A-4 一个自动增加列 A.4.5 配置列的 XML 表示形式 尽管剩下的很多 DataColumn 属性都是自说明的(假设您已经熟悉数据库的术语了),我们还 是讨论一下 ColumnMapping 属性。如果所属的 DataSet 使用 WriteXml()方法转储它其中的内 容,我们就可以用这个 DataColumn.ColumnMapping 属性来配置如何用 XML 表示列。可以 使用 MappingType 枚举来配置 ColumnMapping 属性的值(见表 A-4)。 表 A-4 MappingType 枚举的值 MAPPINGTYPE 枚举值 意 义 Attribute 列被映射为 XML 特性 Element 列被映射为 XML 元素(默认设置) Hidden 列被映射为一个内部结构 TableElement 列被映射为一个表的值 Text 列被映射为文本 ColumnMapping 属性的默认值为 MappingType.Element。假设您已经指示所属 DataSet 把它 的内容用 XML 形式写入一个新文件流。根据默认的设置,EmpID 列将会如下显示: 500 然而,如果这个 DataColumn 的 ColumnMapping 属性被设为 MappingType.Attribute,您将会 看到如下的 XML 显示: 在讨论 DataSet 时,本章会用大量篇幅来检查 ADO.NET/XML 的集成。然而,就目前来说, 您应该明白了如何创建一个独立的 DataColumn 类型。现在来检查一下 DataRow 的基本行为。 源代码: DataColumn 应用程序位于第 13 章子目录中。 A.5 检查 DataRow 属性 可以看到,DataColumn 对象集合表示了表的模式(Schema)。DataTable 通过内部的 DataColu mnCollection 类型保存表中所有列。相反,DataRow 类型集合就表示表中的实际数据。这样, 如果 Employees 表中有 20 个记录,就可以使用 20 个 DataRow 类型来表示它们。使用 Data Row 类的成员可以对表中的值进行插入、删除、求值和操作操作。 使用 DataRow 与使用 DataColumn 有些不同,因为不可以直接创建这个类型的实例,而是获 得一个来自给定 DataTable 的引用。例如,假设您想往 Employees 表中添加新行。DataTable. NewRow()方法可以获得下一空位,然后在上面填充每列的数据,如下所示: // Build a new Table. DataTable empTable = new DataTable("Employees"); // . . .Add EmpID, FirstName and LastName columns to table. . . // Build a new Employee record. DataRow row = empTable.NewRow(); row["EmpID"] = 102; row["FirstName"] = "Joe"; row["LastName"] = "Blow"; // Add it to the Table's DataRowCollection. empTable.Rows.Add(row); 注意 DataRow 类如何定义了一个索引器,通过这个索引器可通过数字索引以及列名获得对 给定 DataColumn 的访问。同时还要注意到 DataTable 用另外一个内部集合(DataRowCollecti on)来保存每一行的数据。DataRow 类型定义了下面的核心成员,根据相应的功能分组(见表 A-5)。 表 A-5 DataRow 的成员 DataRow 的成员 意 义 AcceptChanges() RejectChanges() 在前一次 AcceptChanges 被调用后,提交或拒绝对这一行的改变 BeginEdit() EndEdit() CancelEdit() 开始、终止或取消对一个 DataRow 对象的编辑操作 Delete() 在调用 AcceptChanges()方法时把一行标记为被删除 HasErrors GetColumnsInError() GetColumnError() ClearErrors() RowError HasErrors 属性返回表明列的集合中是否存在错误的一个 boolean 值。这样就可以用 GetColumnsInError()方法获得错误的成员,用 GetColumnError()可以获得错误描述,ClearErrors()方法 可以删除行的每个错误列表。RowError 属性可以为给定行配置错误的文本描述 IsNull() 获得表示指定列是否包含有 null 值的一个值 ItemArray 使用一个对象数组来获得或设置该行的所有值 RowState 使用 RowState 枚举的值来检查 DataRow 的当前状态 Table 使用这个属性获得对一个包含有该 DataRow 的 DataTable 的引用 A.5.1 了解 DataRow.RowState 属性 DataRow 类中大部分方法只在所属 DataTable 的上下文中才有意义。稍后您可以看一下添加、 删除以及更新行的过程;但是首先您必须了解一下 RowState 属性。在您需要以编程方式来 标记表中已经被修改的或者新插入的行时,这个属性很有用。可以用 DataRowState 枚举给 这个属性赋任意值(表 A-6)。 表 A-6 DataRowState 枚举的值 DATAROWSTATE 枚举的值 意 义 Deleted 使用 DataRow 的 Delete 方法删除行 Detached 行已经被创建,但并不是任何 DataRowCollection 的一部分。DataRow 在创建后并在添加到集合之前,或 是从集合中删除后就是这种状态 Modified 行已被修改,但没有调用 AcceptChanges()方法 New 行已经被添加到 DataRowCollection 中,但没有调用 AcceptChanges()方法 Unchanged 前面已经调用了 AcceptChanges()方法,但行没有发生改变 为了说明 DataRow 可能有的不同状态,下面的类把一个新 DataRow 创建、插入和从 DataTa ble 中删除时对 RowState 属性所作的改变作了归纳。 public class DRState { public static void Main() { // Build a single-column DataTable. DataTable myTable = new DataTable("Employees"); DataColumn colID = new DataColumn("EmpID", Type.GetType("System.Int32")); myTable.Columns.Add(colID); // The DataRow. DataRow myRow; // Create a new (detached) DataRow. myRow = myTable.NewRow(); Console.WriteLine(myRow.RowState.ToString()); // Now add it to table. myTable.Rows.Add(myRow); Console.WriteLine(myRow.RowState.ToString()); // Trigger an 'accept. myTable.AcceptChanges(); Console.WriteLinemyRow.RowState.ToString()); // Modify it. myRow["EmpID"] = 100; Console.WriteLine(myRow.RowState.ToString()); // Now delete it. myRow.Delete(); Console.WriteLine(myRow.RowState.ToString()); myRow.AcceptChanges(); } } 输出结果应该很清晰,如图 A-5 所示。 图 A-5 行状态的改变 可以看到,ADO.NET 中的 DataRow 是非常智能的,它可以记住事件的当前状态。因此,所 属 DataTable 能识别哪一行被修改过。这也是 DataSet 的主要功能,假设有更新的信息要发 送到数据存储,只提交已修改的值。显然,这个功能可以对系统层之间的数据传输进行优化。 A.5.2 ItemArray 属性 DataRow 的另外一个很有帮助的成员就是 ItemArray 属性。这个方法以 System.Object 类型的 数组形式返回当前行的完整快照。同时,可以使用 ItemArray 属性来添加新行,而不用显式 地列出每个 DataColumn。假设表已经有两个 DataColumn(EmpID 和 FirstName)。下面的逻辑 通过将对象数组赋给 ItemArray 属性来添加一些新行,然后很快地打印出结果(见图 A-6)。 // Declare the array variable. object [] myVals = new object[2]; DataRow dr; // Create some new rows and add to DataRowCollection. for(int i = 0; i < 5; i++) { myVals[0] = i; myVals[1]= "Name " + i; dr = myTable.NewRow(); dr.ItemArray = myVals; myTable.Rows.Add(dr); } // Now print each value. foreach(DataRow r in myTable.Rows) { foreach(DataColumn c in myTable.Columns) { Console.WriteLine(r[c]); } } 图 A-6 使用 ItemArray 属性 源代码: DataRowState 源代码位于第 13 章子目录中。 A.6 DataTable 的细节 DataTable 是表格数据块在内存中的表示。虽然可以手动以编程形式构建一个 DataTable,但 通常使用 DataSet 和定义在 System.Data.OleDb 或 System.Data.SqlClient 命名空间中的类型, 以动态获得一个 DataTable。表 A-7 描述了 DataTable 中的一些核心属性。 表 A-7 DataTable 的属性 DataTable 属性 意 义 CaseSensitive 表明表中的字符串比较是否区分大小写。默认的值为 false ChildRelations 返回 DataTable 的子关系(DataRelationCollection)的集合 Columns 返回属于这个表的列的集合 Constraints 获得表约束的集合(ConstraintCollection) DataSet 获得包含这个表的 DataSet DefaultView 获得表的自定义视图,它可能包含已过滤的视图或游标位置 MinimumCapacity 获得或设置表中行的初始数目(默认为 25) ParentRelations 获得这个 DataTable 上的父关系的集合 PrimaryKey 获得或设置作为数据表主键的列数组 Rows 返回属于这个表的行集合 TableName 获得或设置表的名称。这个属性还可以被指定为构造函数的参数 图 A-7 可以帮助您更加清楚地了解 DataTable 的关键部分。要知道这并不是一个传统的类层次 结构,说明类型之间 is-a 关系(例如,DataRow 不是派生自 DataRowCollection)。这个图只是显 示了 DataTable 的核心项之间的 has-a 逻辑关系(例如,DataRowCollection 有一些 DataRow 类 型)。 图 A-7 DataTable 的集合 A.7 构建一个完整的 DataTable 现在您已经了解到最基础的东西,让我们来看一个完整的创建并操作内存中的数据表的例 子。假设您想构建一个显示 Cars 数据库中当前存货的 DataTable。这个 Inventory 表有 4 个 列:CarID,Make,Color 和 PetName。同时,CarID 列作为这个表的主键(PK)并支持自动递 增。PetName 列允许 null 值(很遗憾,并不是每个人都和我们一样喜爱自己的车)。图 A-8 显 示了该表。 图 A-8 存货 DataTable 整个过程将从创建一个新的 DataTable 类型开始。创建完这个类型后,可以把这个表的名称 指定为构造函数的参数。可以用这个名称从所在 DataSet 引用这个表,如下所示: // Create a new DataTable. DataTable inventoryTable = new DataTable("Inventory"); 下一步是以编程方式使用 DataColumnCollection 的 Add()方法插入每列(使用 DataTable.Colu mns 属性访问)。下面的逻辑将 CarID、Make、Color 和 PetName 列添加到当前 DataTable 中 (每列的基本数据类型使用 DataType 属性设置): // DataColumn var. DataColumn myDataColumn; // Create CarID column and add to table. myDataColumn = new DataColumn(); myDataColumn.DataType = Type.GetType("System.Int32"); myDataColumn.ColumnName = "CarID"; myDataColumn.ReadOnly = true; myDataColumn.AllowDBNull = false; myDataColumn.Unique = true; // Set the autoincrement behavior. myDataColumn.AutoIncrement = true; myDataColumn.AutoIncrementSeed = 1000; myDataColumn.AutoIncrementStep = 10; inventoryTable.Columns.Add(myDataColumn); // Create Make column and add to table. myDataColumn = new DataColumn(); myDataColumn.DataType = Type.GetType("System.String"); myDataColumn.ColumnName = "Make"; inventoryTable.Columns.Add(myDataColumn); // Create Color column and add to table. myDataColumn = new DataColumn(); myDataColumn.DataType = Type.GetType("System.String"); myDataColumn.ColumnName = "Color"; inventoryTable.Columns.Add(myDataColumn); // Create PetName column and add to table. myDataColumn = new DataColumn(); myDataColumn.DataType = Type.GetType("System.String"); myDataColumn.ColumnName = "PetName"; myDataColumn.AllowDBNull = true; inventoryTable.Columns.Add(myDataColumn); 在添加行之前,花点时间来设置一下表的主键。可以对需要设置的列设定 DataTable.PrimaryKe y 属性。由于作为表主键的列可能不止一个,因此要知道 PrimaryKey 的属性需要一个 DataColu mn 类型的数组。假设 CarID 列就是 Invetory 表主键的惟一组成部分,如下所示: // Make the ID column the primary key column. DataColumn[] PK = new DataColumn[1]; PK[0] = inventoryTable.Columns["CarID"]; inventoryTable.PrimaryKey = PK; 最后但相当重要的是,您需要往表中添加有效的数据。假设有一个合适的 ArrayList 保存 Ca r 类型,可以用如下的方式把它填充到表中: // Iterate over the array list to make rows (remember, the ID is // autoincremented). foreach(Car c in arTheCars) { DataRow newRow; newRow = inventoryTable.NewRow(); newRow["Make"] = c.make; newRow["Color"] = c.color; newRow["PetName"] = c.petName; inventoryTable.Rows.Add(newRow); } 为了显示新的本地内存表,假定有一个 Windows Forms 应用程序,包含一个显示 DataGrid 的主窗体。如第 11 章所示,DataSource 属性用于把 DataTable 绑定到 GUI 上。输出结果如 图 A-9 所示。 图 A-9 把 DataTable 绑定到 DataGrid 上 这儿通过指定要修改的列名称的字符串来添加行。当然还可以指定列的数字索引,在需要迭 代每个列时,它特别有用。这样,前面的代码可以更新为如下的代码(得到同样的最终结果): foreach(Car c in arTheCars) { // Specify columns by index. DataRow newRow; newRow = inventoryTable.NewRow(); newRow[1] = c.make; newRow[2] = c.color; newRow[3] = c.petName; inventoryTable.Rows.Add(newRow); } A.7.1 操作 DataTable:删除行 如果您想从数据表中删除一行该怎么做呢?我们可以调用 DataRowCollection 类型的 Delete() 方法。只要指定要删除行的索引(或者时 DataRow)就可以。假设您已经按照图 A-10 更新了 GUI。 图 A-10 从一个 DataTable 中删除行 如果您查看前面的图,就会注意到由于指定了 DataTable 的第二行,CarID1020 就被删除掉 了。下面新按钮的单击事件处理逻辑就是删除内存中 DataTable 表中的指定行。 // Remove this row from the DataRowCollection. protected void btnRemoveRow_Click (object sender, System.EventArgs e) { try { inventoryTable.Rows[(int.Parse(txtRemove.Text))].Delete(); inventoryTable.AcceptChanges(); } catch(Exception ex) { MessageBox.Show(ex.Message); } } 或许将这个 Delete()方法命名为 MarkedAsDeletable()更好一点,因为这一行只有到 DataTabl e.AcceptChanges()方法调用后才会真正被删除。实际上,Delete()只是简单地设定一个标记表 示“I am ready to die when my table tells me”。还要明白,如果有一行被标记为删除,那 么 DataTable 可能会在 AcceptChanges()调用之前拒绝这些修改,如下所示: // Mark a row as deleted, but reject the changes. protected void btnRemoveRow_Click (object sender, System.EventArgs e) { inventoryTable.Rows[txtRemove.Text.ToInt32()].Delete(); // Do more work. . . inventoryTable.RejectChanges(); // Restore RowState. } A.7.2 操作 DataTable:应用过滤器和排序顺序 或许您想查看 DataTable 数据的一个子集,可以用一些过滤条件来指定。例如,如果您只想 从这个内存中的 Inventory 表中看到某个牌子的汽车该怎么做呢?DataTable 类上的 Select() 方法恰好提供了这个功能。再次更新您的 GUI,这次允许用户指定一个字符串来表示他们感 兴趣查看的车的牌子(图 A-11)。 图 A-11 指定一个过滤器 这个 Select()方法已经被重载多次,以提供不同的选择语义。传递给 Select()的最基本参 数可以是一个包含有某个条件操作的字符串。首先看一下新按钮的单击事件处理逻辑: protected void btnGetMakes_Click (object sender, System.EventArgs e) { // Build a filter based on user input. string filterStr = "Make='" + txtMake.Text + "'"; // Find all rows matching the filter. DataRow[] makes = inventoryTable.Select(filterStr); // Show what we got! if(makes.Length = = 0) MessageBox.Show("Sorry, no cars. . .", "Selection error!"); else { string strMake = null; for(int i = 0; i < makes.Length; i++) { DataRow temp = makes[i]; strMake += temp["PetName"].ToString() + "\n"; } MessageBox.Show(strMake, txtMake.Text + " type(s):"); } } 这儿,您首先建立一个基于相关的文本框值的过滤器条件。如果您指定 BMW,那么过滤器 条件就是 Make = ‘BMW’。把这个过滤器发送给 Select()方法后,就会得到一个 DataRow 类 型的数组,这个数组表示了匹配每个符合过滤条件的行,如图 A-12 所示。 图 A-12 过滤后的数据 可以用很多相关的操作符组成一个过滤字符串。例如,如果想查找所有 ID 大于 1030 的车怎么做呢?您可以编写如下的代码(见图 A-13 的输出结果): // Now show the pet names of all cars with ID greater than 1030. DataRow[] properIDs; string newFilterStr = "ID > '1030'"; properIDs = inventoryTable.Select(newFilterStr); string strIDs = null; for(int i = 0; i < properIDs.Length; i++) { DataRow temp = properIDs[i]; strIDs += temp["PetName"].ToString() + " is ID " + temp["ID"] + "\n"; } MessageBox.Show(strIDs, "Pet names of cars where ID > 1030"); 图 A-13 指定一个数据范围 模拟标准 SQL 语法编写过滤逻辑。为了验证这一点,假设想根据 pet 名称的字母顺序来获 得前面 Select()命令的结果。在 SQL 术语中,这会被解释为基于 PetName 列进行排序。幸运 的是,Select()方法已经被重载过,它可以传递一个排序条件,如下所示: makes = inventoryTable.Select(filterStr, "PetName"); 这样会返回图 A-14 所示的信息。 图 A-14 已排序的数据 如果您想用降序来对结果排序,调用 Select(),如下所示: // Return results in descending order. makes = inventoryTable.Select(filterStr, "PetName DESC"); 一般来说,排序字符串是列名后跟着“ASC”(升序,默认设置)或“DESC”(降序)。如果需 要的话,可以用逗号来把多个列分开排序。 A.7.3 操作 DataTable:更新行 您需要了解的关于 DataTable 的最后一个方面就是怎样用新值更新已有的行。一个方法就是 先用 Select()方法获得符合给定过滤条件的行。一旦获得这些 DataRow,就对它们作相应的 修改。例如,假定有一个新按钮在被单击后,搜索 DataTable 中所有 Make 为 BMW 的行。 一旦标识这些项后,就可以把 Make 从“BMW”改为“Colt”。 // Find the rows you want to edit with a filter. protected void btnChange_Click (object sender, System.EventArgs e) { // Build a filter. string filterStr = "Make='BMW'"; string strMake = null; // Find all rows matching the filter. DataRow[] makes = inventoryTable.Select(filterStr); // Change all Beemers to Colts! for(int i = 0; i < makes.Length; i++) { DataRow temp = makes[i]; strMake += temp["Make"] = "Colt"; makes[i] = temp; } } 这个 DataRow 类也提供了 BeginEdit()、EndEdit()和 CancelEdit()方法,这些方法可以在任何 相关的验证规则被临时挂起时对一个行的内容进行编辑。在前面的逻辑中,每一行都用一个 指派作了验证(而且如果从 DataRow 中捕获事件的话,这些事件会在每次修改时触发)。在 对 某个 DataRow 调用 BeginEdit()时,这一行就被设置在编辑模式下。这时您可以根据需要来 作些改动,并调用 EndEdit()提交修改或者 CancelEdit()把所作的修改回滚到原先的版本。例 如: // Assume you have obtained a row to edit. // Now place this row in edit mode'. rowToUpdate.BeginEdit(); // Send the row to a helper function, which returns a Boolean. if( ChangeValuesForThisRow( rowToUpdate) ) { rowToUpdate.EndEdit(); // OK! } else { rowToUpdate.CancelEdit(); // Forget it. } 虽然您可以随意地对某一 DataRow 手动调用这些方法,但如果把一个 DataGrid 绑定到 Data Table,这些成员就可以被自动地调用。例如,如果您想从 DataGrid 中选择一行进行编辑的 话,该行就会自动处于编辑模式下。当把焦点换到另一行时,就会自动调用 EndEdit()。为 了测试这个行为,假设您已经手动地使用 DataGrid 把每个车更新为某个 Make(图 A-15)。 如果现在您想查询所有的 BMW,消息对话框会正确地返回所有行,因为关联到这个 DataG rid 的底层 DataTable 已经被自动更新了(图 A-16)。 图 A-15 在 DataGrid 中编辑行 图 A-16 Inventory DataTable A.8 了解 DataView 类型 在数据库术语中,视图对象(view object)就是指一个表的固定样式的表示。例如,使用 Micr osoft SQL Server 可以对 Inventory 表创建一个视图,返回的新表中就只包括了指定颜色的 车。在 ADO.NET 中,DataView 类型允许您用编程的方式从这个 DataTable 中提取数据的子 集。 保存同一个表的多个视图,其最大好处就是您可以把这些视图绑定到不同的 GUI 上(比如 D ataGrid)。例如,可以把一个 DataGrid 绑定到一个显示 Inventory 中所有汽车的 DataView, 而另一个被配置为只显示绿颜色的车。另外,DataTable 类型提供了 DefaultView 属性来返回 表的默认 DataView。 下面举一个例子。目的就是把当前 Windows Forms 应用程序的用户界面更新为支持两个附 加的 DataGrid 类型。一个显示 Inventory 表中符合 Make=‘Colt’条件的行。另一个只显示红 颜色的车(也就是 Color=‘Red’)。图 A-17 显示了更新后的 GUI。 图 A-17 创建 Inventory 表的多个视图 首先需要创建两个 DataView 类型的成员变量: public class mainForm : System.Windows.Forms.Form { // Views of the DataTable. DataView redCarsView; // I only show red cars. DataView coltsView; // I only show Colts. . . . } 接下来,假定您有一个新的帮助函数叫做 CreateViews(),这个方法可以在 DataTable 完全构 造好后直接调用,如下所示: protected void btnMakeDataTable_Click (object sender, System.EventArgs e) { // Make the data table. MakeTable(); // Make views. CreateViews(); . . . } 下面是这个新帮助函数的实现。注意,每个 DataView 的构造函数被传递了用来构建自定义 数据行集的 DataTable: private void CreateViews() { // Set the table that is used to construct these views. redCarsView = new DataView(inventoryTable); coltsView = new DataView(inventoryTable); // Now configure the views using a filter. redCarsView.RowFilter = "Color = 'red'"; coltsView.RowFilter = "Make = 'colt'"; // Bind to grids. RedCarViewGrid.DataSource = redCarsView; ColtsViewGrid.DataSource = coltsView; } 可以看到,这个 DataView 类支持一个名为 RowFilter 的属性,这个属性包含了一个可以提取 匹配行的过滤条件字符串。在视图建立好后,就相应设置表格的 DataSource 属性。就是这 样!由于 DataGrid 可以智能地检测对底层数据源的改变,如果您单击 Make Beemers Colts 按钮,ColtsViewGrid 就会被自动更新。 除了这个 RowFilter 属性之外,表 A-8 还描述了其他 DataView 类的成员。 表 A-8 DataView 类型的成员 DataView 的成员 意 义 AddNew() 往 DataView 中添加一个新行 AllowDelete AllowEdit AllowNew 配置 DataView 是否允许删除、插入和更新行 Delete() 删除指定索引处的行 RowFilter 获得或设置一个表达式来过滤在 DataView 中查看到的行 Sort 获得或设置列的排序列以及表的排序顺序 Table 获得或设置源 DataTable 源代码: 完整的 CarDataTable 项目的代码位于第 13 章子目录中。 A.9 了解 DataSet 的角色 您已经了解到如何构建 DataTable 来表示内存中单个数据表。虽然 DataTable 可用作独立实 体,但更多情况下都把它们包含在 DataSet 中。实际上,ADO.NET 提供的大部分数据访问 类型都只返回一个已填充的 DataSet,而不是单个 DataTable。 简单点说, DataSet 就是任意数目的表(也可以是一个 DataTable)在内存中的表示形式,以及 这些表和任何(可选)约束之间的关系(可选)。为了让您更好地明白这些核心类型之间的关系, 可以参考一下图 A-18 所示的逻辑结构。 图 A-18 DataSet 的集合 DataSet 的 Tables 属性可以访问那些包含单独 DataTable 的 DataTableCollection。DataSet 用到 的另一个重要集合就是 DataRelationCollection。由于 DataSet 是一个数据库模式的断开连接 的版本,我们就可以以编程方式表示它的表之间父/子关系。 例如,可以用 DataRelation 类型创建两个表之间的关系来模拟一个外键约束。这个对象可以 通过 Relations 属性添加到 DataRelationCollection 中。这样您可以在被连接的表之间查找所 需的数据。在本章稍后的部分您将看到这些内容。 ExtendedProperties 属性能够访问 PropertyCollection 类型,这样就可以把外部的信息以名/值 对的形式关联到 DataSet 上。这个信息可以是任何信息,即便它和数据本身一点关系也没有 也可以。例如,可以把您的公司名关联到 DataSet 上,这样就可以把它作为一个内存元数据 使用,如下所示: // Make a DataSet and add some metadata. DataSet ds = new DataSet("MyDataSet"); ds.ExtendedProperties.Add("CompanyName", "Intertech, Inc"); // Print out the metadata. Console.WriteLine(ds.ExtendedProperties["CompanyName"].ToString()); 还有其他一些扩展属性的例子,例如一个必须提供来访问 DataSet 内容的内部密码、一个代 表数据刷新率的数值等。注意,DataTable 本身也支持 ExtendedProperties 属性。 A.9.1 DataSet 的成员 在探究更多的编程细节之前,先来看一下 DataSet 的公共接口。定义在 DataSet 中的属性都 集中于对内部集合的访问、生成 XML 数据表示以及提供详细的错误信息。表 A-9 列出了一 些比较重要的核心属性。 表 A-9 强大的 DataSet 的属性 DataSet 属性 意 义 CaseSensitive 表示 DataTable 对象的字符串比较是否区分大小写 DataSetName 获得或设置 DataSet 的名称。一般把这个参数当作构造函数的参数 DefaultViewManager 建立 DataSet 中数据的自定义视图 EnforceConstraints 获得或设置在试图执行任何更新操作时是否遵循约束规则的值 HasErrors 获得一个表示 DataSet 中任何表上的行是否有错误的值 Relations 获得连接表的关系集合,并可以从父表导航到子表 Tables 可以访问 DataSet 中表的集合 DataSet 的方法模拟了一些由上述属性提供的功能。除了能和 XML 流交互之外,其他方法 还可以复制 DataSet 的内容,当然也可以为一个批处理更新操作建立开始点和结束点。表 A -10 列出了一些核心的方法。 表 A-10 功能强大的 DataSet 的方法 DataSet 方法 意 义 AcceptChanges() 在该 DataSet 加载后或者前一次 AcceptChanges()方法调用时提交对它所做的修改 Clear() 完全清除 DataSet 的数据,删除每个表上的行 Clone() 克隆 DataSet 的结构,包括所有的 DataTable 以及所有的关系和约束 Copy() 复制 DataSet 的结构和数据 GetChanges() 返回 DataSet 的副本,包括它被加载后或者前一次 AcceptChanges()方法调用时对 DataSet 所做的修改 GetChildRelations() 返回属于指定表的子关系集合 GetParentRelations() 获得属于指定表上的父关系集合 HasChanges() 已重载。获得一个值表示这个 DataSet 是否被修改,包括添加、删除或修改行 Merge() 已重载。把一个指定的 DataSet 和这个 DataSet 进行合并 ReadXml() ReadXmlSchema() 可从一个有效流中读取 XML 数据到 DataSet 中(这个流可以基于文件、基于内存或网络) RejectChanges() 回滚它被创建后或者前一次 AcceptChanges()方法调用时对 DataSet 所做的修改 WriteXml() WriteXmlSchema() 可把一个 DataSet 的内容写入到有效流中 现在您应该已经能很好地明白 DataSet 所扮演的角色了(当然您也可以有些其他想法),让我 们通过一些特例来加深了解。在讨论完 ADO.NET 的 DataSet 之后,本章后面的部分将会着 重讨论如何使用由 System.Data.SqlClient 和 System.Data.OleDb 命名空间定义的类型,从外 部源获取 DataSet 类型。 A.9.2 构建内存中的 DataSet 下面新建一个保存单个 DataSet 的 Windows Forms 应用程序来说明其用法,这个 DataSet 包 含有 3 个分别叫作 Inventory、Customers 和 Orders 的 DataTable 对象。每个表中的列都非常 少,但都很完整,而且每个表上都有一列被标记为主键。更重要的是,您可以用 DataRelati on 类型定义表之间的父/子关系。下面的任务就是要在内存中创建一个如图 A-19 所示的数 据库。 图 A-19 内存中的 Automobile 数据库 这儿的 Inventory 表是 Orders 表的父表,Orders 表中有一个外键列(CarID)。同时 Customers 表也是 Orders 表的父表(还是将 CarID 作为外键)。稍后将看到,当往 DataSet 中添加 DataRe lation 类型后,可以用这些类型在表之间导航来获得并操作相关数据。 首先假定您已经往主 Form 中添加了一些成员变量,这些变量表示 DataTable 和 DataSet,如 下所示: public class mainForm : System.Windows.Forms.Form { // Inventory DataTable. private DataTable inventoryTable = new DataTable("Inventory"); // Customers DataTable. private DataTable customersTable = new DataTable("Customers"); // Orders DataTable. private DataTable ordersTable = new DataTable("Orders"); // Our DataSet! private DataSet carsDataSet = new DataSet("CarDataSet"); . . . } 现在尽可能把任务面向对象化,创建一些非常简单的包装类来表示系统中的 Car 和 Custome r 类。注意,Customer 类有一个表示客户感兴趣购买车的字段,如下所示: public class Car { // Make public for easy access. public string petName, make, color; public Car(string petName, string make, string color) { this.petName = petName; this.color = color; this.make = make; } } public class Customer { public Customer(string fName, string lName, int currentOrder) { this.firstName= fName; this.lastName = lName; this.currCarOrder = currentOrder; } public string firstName, lastName; public int currCarOrder; } 主 Form 中用两个 ArrayList 类型来保存一组 Car 和 Customer,它们用 Form 构造函数中的一 些示例数据填充。接下来构造函数会调用许多私有的帮助函数来构建表以及表之间的关系。 最后,这个方法把 Inventory 和 Customer 的 DataTable 分别绑定到相应的 DataGrid 上。注意, 下面的代码使用 SetDataBinding()方法绑定到 DataSet 中的特定 DataTable 上: // Your list of Cars and Customers. private ArrayList arTheCars, arTheCustomers; public mainForm() { // Fill the car array list with some cars. arTheCars = new ArrayList(); arTheCars.Add(new Car("Chucky", "BMW", "Green")); . . // Fill the other array list with some customers. arTheCustomers = new ArrayList(); arTheCustomers.Add(new Customer("Dave", "Brenner", 1020)); . . . // Make data tables (using the same techniques seen previously). MakeInventoryTable(); MakeCustomerTable(); MakeOrderTable(); // Add relation (seen in just a moment). BuildTableRelationship(); // Bind to grids (Param1 = DataSet, Param2 = name of table in DataSet). CarDataGrid.SetDataBinding(carsDataSet, "Inventory"); CustomerDataGrid.SetDataBinding(carsDataSet, "Customers"); } 可以使用本章前面讲到的技术来构造出每个 DataTable。为了继续关注 DataSet 逻辑,我不再 重复关于表构建的逻辑。然而,要知道每个表都指定了自动增加的主键。下面列出了一部分 表构建的逻辑(通过相同的代码可了解详细的内容): private void MakeOrderTable() { . . . // Add table to the DataSet. carsDataSet.Tables.Add(customersTable); // Create OrderID, CustID, CarID columns and add to table. . . // Make the ID column the primary key column. . . // Add some orders. for(int i = 0; i < arTheCustomers.Count; i++) { DataRow newRow; newRow = ordersTable.NewRow(); Customer c = (Customer)arTheCustomers[i]; newRow["CustID"] = i; newRow["CarID"] = c.currCarOrder; carsDataSet.Tables["Orders"].Rows.Add(newRow); } } MakeInventoryTable()和 MakeCustomerTable()帮助函数的行为几乎完全一样。 A.10 使用 DataRelation 类型表示关系 真正有趣的工作都在 BuildTableRelationship()帮助函数中。在用一些表填充 DataSet 后,您 可以选择用编程的方式来模拟表之间的父/子关系。要知道这并不是强制的。您可以用一个 DataSet 将 DataTable 集合(甚至是 DataTable)保存在内存中。然而,如果在 DataTable 之间建 立了内部的相互关系,您就可以快速在表之间导航,并收集任何您感兴趣的信息,所有信息 这时候都已经和数据源断开连接。 System.Data.DataRelation 类型是一个包含表对表关系的 OO 包装器。创建新的 DataRelation 类型时必须指定一个名称,然后是父表(例如,Inventory)和关联的子表(Orders)。要建立关系 的话,那么每个表都必须有一个相同数据类型(本例中为 Int32)的同名列(CarID)。这样,Dat aRelation 必须根据关系数据库的相同规则来约束。下面是完整的 BuildTableRelationship()帮助 函数的实现: private void BuildTableRelationship() { // Create a DR obj. DataRelation dr = new DataRelation("CustomerOrder", carsDataSet.Tables["Customers"].Columns["CustID"], // Parent. carsDataSet.Tables["Orders"].Columns["CustID"]); // Child. // Add to the DataSet. carsDataSet.Relations.Add(dr); // Create another DR obj. dr = new DataRelation("InventoryOrder", carsDataSet.Tables["Inventory"].Columns["CarID"], // Parent. carsDataSet.Tables["Orders"].Columns["CarID"]); // Child. // Add to the DataSet. carsDataSet.Relations.Add(dr); } 可以看到,DataSet 所维护的 DataRelationCollection 中保存了一个 DataRelation。这个 DataR elation 类型提供了很多属性,根据这些属性您可以获得对参与该关系中的父/子表的引用、 指定关系的名称等(见表 A-11)。 表 A-11 DataRelation 类型的属性 DataRelation 属性 意 义 ChildColumns ChildKeyConstraint ChildTable 获得这个关系中子表以及这个表的相关信息 DataSet 获得关系集合所属的 DataSet ParentColumns ParentKeyConstraint ParentTable 获得这个关系中父表以及这个表的相关信息 RelationName 获得或设置在父表数据集的 DataRelationCollection 中查找这个关系所用的名称 在关联表之间导航 我们可以把 GUI 扩展为包含一个新的按钮类型和相关的文本框,这样可以说明 DataRelatio n 如何允许在关联表进行移动。终端用户可以输入客户的 ID 并获得这个客户的所有订单信 息,这些信息都放在一个简单的消息框中(见图 A-20)。 图 A-20 导航数据关系 下面显示了按钮的单击事件处理程序(为了简洁起见,已经删除了错误检查部分): protected void btnGetInfo_Click (object sender, System.EventArgs e) { string strInfo = ""; DataRow drCust = null; DataRow[] drsOrder = null; // Get the specified CustID from the TextBox. int theCust = int.Parse(this.txtCustID.Text); // Now based on CustID, get the correct row in Customers table. drCust = carsDataSet.Tables["Customers"].Rows[theCust]; strInfo += "Cust #" + drCust["CustID"].ToString() + "\n"; // Navigate from customer table to order table. drsOrder = drCust.GetChildRows(carsDataSet.Relations["CustomerOrder"]); // Get customer name. foreach(DataRow r in drsOrder) strInfo += "Order Number: " + r["OrderID"] + "\n"; // Now navigate from order table to inventory table. DataRow[] drsInv = drsOrder[0].GetParentRows(carsDataSet.Relations["InventoryOrder"]); // Get Car info. foreach(DataRow r in drsInv) { strInfo += "Make: " + r["Make"] + "\n"; strInfo += "Color: " + r["Color"] + "\n"; strInfo += "Pet Name: " + r["PetName"] + "\n"; } MessageBox.Show(strInfo, "Info based on cust ID"); } 可以看到,在数据表之间移动最关键就是使用一些 DataRow 类型定义的方法。让我们一步 步地看这段代码。首先从文本框中获得正确的客户 ID,然后用它来找到 Customer 表中正确 的行(当然是使用 Rows 属性),如下所示: // Get the specified CustID from the TextBox. int theCust = int.Parse(this.txtCustID.Text); // Now based on CustID, get the correct row in Customers table. DataRow drCust = null; drCust = carsDataSet.Tables["Customers"].Rows[theCust]; strInfo += "Cust #" + drCust["CustID"].ToString() + "\n"; 接下来通过 CustomerOrder 数据关系从 Customers 表导航到 Orders 表。注意,DataRow.Get ChildRows()方法可以获得子表中的行,这样就可以读取表中信息,如下所示: // Navigate from customer table to order table. DataRow[] drsOrder = null; drsOrder = drCust.GetChildRows(carsDataSet.Relations["CustomerOrder"]); // Get customer name. foreach(DataRow r in drsOrder) strInfo += "Order Number: " + r["OrderID"] + "\n"; 最后一步就是使用 GetParentRows()方法从 Orders 表导航到父表(Inventory)。这时您可以用 M ake、PetName 和 Color 列来读取 Inventory 表的信息,如下所示: // Now navigate from order table to inventory table. DataRow[] drsInv = drsOrder[0].GetParentRows(carsDataSet.Relations["InventoryOrder"]); foreach(DataRow r in drsInv) { strInfo += "Make: " + r["Make"] + "\n"; strInfo += "Color: " + r["Color"] + "\n"; strInfo += "Pet Name: " + r["PetName"] + "\n"; } 作为以编程方式导航关系的最后一个例子,下面的代码打印出间接通过 InventoryOrders 关 系获得的 Orders 表中的值: protected void btnGetChildRels_Click (object sender, System.EventArgs e) { // Ask the CarsDataSet for the child relations of the inv. table. DataRelationCollection relCol; DataRow[] arrRows; string info = ""; relCol = carsDataSet.Tables["inventory"].ChildRelations; info += "\tRelation is called: " + relCol[0].RelationName + "\n\n"; // Now loop over each relation and print out info. foreach(DataRelation dr in relCol) { foreach(DataRow r in inventoryTable.Rows) { arrRows = r.GetChildRows(dr); // Print out the value of each column in the row. for (int i = 0; i < arrRows.Length; i++) { foreach(DataColumn dc in arrRows[i].Table.Columns ) { info += "\t" + arrRows[i][dc]; } info += "\n"; } } MessageBox.Show(info, "Data in Orders Table obtained by child relations"); } } 图 A-21 显示了输出结果。 图 A-21 导航父/子关系 希望最后这个例子能让您彻底了解 DataSet 类型的用法。由于 DataSet 完全断开了与底层数 据源的连接,您就可以对数据的内存副本进行操作,并在每个表之间导航来进行所需的更新、 删除或添加。当完成操作后,您可以把修改提交到数据存储中去处理。当然,您还不知道如 何进行连接!在讨论这个问题之前,还有一个与 DataSet 有关的有趣主题。 A.11 读取和写入基于 XML 的 DataSet ADO.NET 最主要的设计目的就是广泛使用 XML 结构。使用 DataSet 类型,可以把表内容、 关系和其他表细节的 XML 表示形式写入某个流中(比如一个文件)。只要简单地调用 WriteX ml()方法就可以了,如下所示: protected void btnToXML_Click (object sender, System.EventArgs e) { // Write your entire DataSet to a file in the app directory. carsDataSet.WriteXml("cars.xml"); MessageBox.Show("Wrote CarDataSet to XML file in app directory"); btnReadXML.Enabled = true; } 如果在 Visual Studio.NET 的 IDE 中打开这个新建的文件(图 A-22),您就会看到整个 DataSe t 已经被转换成 XML(如果您不太适应这个 XML 语法,不要担心。DataSet 能很好地了解 X ML)。 图 A-22 XML 格式的 DataSet 可以用一个小的试验来测试一下 DataSet 的 ReadXml()方法。CarDataSet 应用程序有一个按 钮会完全清除掉当前的 DataSet(包括所有的表和关系)。在取出所有内存中的内容后,指示 D ataSet 读取文件 Cars.xml,这个文件可恢复整个 DataSet,如下所示: protected void btnReadXML_Click (object sender, System.EventArgs e) { // Kill current DataSet. carsDataSet.Clear(); carsDataSet.Dispose(); MessageBox.Show("Just cleared data set. . ."); carsDataSet = new DataSet("CarDataSet"); carsDataSet.ReadXml( "cars.xml" ); MessageBox.Show("Reconstructed data set from XML file. . ."); btnReadXML.Enabled = false; // Bind to grids. CarDataGrid.SetDataBinding(carsDataSet, "Inventory"); CustomerDataGrid.SetDataBinding(carsDataSet, "Customers"); } 注意, XML 的这些核心方法都使用了 System.Xml.dll 程序集(特别是 XmlReader 和 XmlWr iter 类)中定义的类型。因此,除了设置一个对这个程序集的引用之外,您还必须显式地引用 它的类型,如下所示: // Need this namespace to call ReadXml() or WriteXml()! using System.Xml; 图 A-23 显示了最终的结果。 图 A-23 最终的内存中的 DataSet 应用程序 源代码: CarDataSet 应用程序位于第 13 章子目录中。 A.12 构建一个简单的测试数据库 现在您已经知道如何创建和操作内存中的 DataSet,下面您将会看到怎样建立数据连接以及 如何填充 DataSet。为使全书连贯,我使用了两个版本的示例 Cars 数据库(可以从 www.apre ss.com 下载),这两个数据库模拟了本章用到的 Inventory、Orders 和 Customers 表。 第一个版本是一个可以构建表(包括表之间的关系)的 SQL 脚本,SQL Server 7.0(或更高版 本)的用户可以使用它。打开 SQL Server 中附带的 Query Analyzer 实用程序,可以创建 Car s 数据库。然后连接到您的主机,打开 cars.sql 文件。在运行这个脚本之前,确保这个 SQL 文件中所列的路径指向的就是您的 MS SQL Server 安装路径。然后根据需要编辑如下的 DD L(粗体显示): CREATE DATABASE [Cars] ON (NAME = N'Cars_Data', FILENAME =N' D:\MSSQL7\Data \Cars_Data.MDF' , SIZE = 2, FILEGROWTH = 10%) LOG ON (NAME = N'Cars_Log', FILENAME = N' D:\MSSQL7\Data\Cars_Log.LDF' , SIZE = 1, FILEGROWTH = 10%) GO 现在运行这个脚本。运行之后打开 SQL Server Enterprise Manager(图 A-24)。您会看到 有 3 个相关表的 Cars 数据库(有一些示例数据)。 图 A-24 SQL Server 版本的 Cars 数据库 第二个 Cars 数据库版本是针对 MS Access 用户的。在 Access DB 文件夹下,您可以找到 c ars.mdb 文件,这个文件包含了与 SQL Server 版本相同的信息和底层结构。在后面的部分, 都假定您连接的是 SQL Server 版本的 Cars 数据库而不是 Access 版本的数据库。其实您也 可以看到如何配置一个 ADO.NET 连接字符串来连接到*.mdb 文件。 A.13 ADO.NET 托管提供者 如果您准备从典型 ADO 背景步入 ADO.NET,可假定.NET 中的托管提供者就等同于 OLE DB 提供者。换句话说,托管提供者就是原始数据存储和已填充的 DataSet 之间的通道。 本章前面已经提到,ADO.NET 提供了两种托管提供者。第一个就是 OleDb 托管提供者,这 个是由 System.Data.OleDb 命名空间定义的类型组成。OleDb 提供者可以访问所有支持 OLE DB 协议的数据存储中的数据。因此,和典型 ADO 一样,也可以使用 ADO.NET 托管提供 者来访问 SQL Server、Oracle 或 MS Access 数据库。由于 System.Data.OleDb 命名空间中的 类型必须和非托管代码进行通信(比如 OLE DB 提供者),您就必须意识到在这背后有大量. NET 和 COM 之间的转换,这当然也会影响执行性能。 另外一个托管提供者(SQL 提供者)能直接访问 MS SQL Server 数据存储,而且只能是 SQL Server 数据存储(7.0 版本和更高版本)。System.Data.SqlClient 命名空间包含了 SQL 提供者所 用的类型,并提供了与 OleDb 提供者相同的功能。实际上,这两个命名空间大部分的命名 项都相似。关键的不同之处就是 SQL 提供者不能使用 OLE DB 或典型 ADO 协议,但它却 提供更加强大的性能。 您应该记得 System.Data.Common 命名空间定义了很多抽象类,这些类为每个托管提供者提 供一个通用接口。首先,每个类型都定义了一个 IDbConnection 接口的实现,可以用这个接 口配置和打开与数据存储的会话。实现 IDbCommand 接口的对象可用于对数据库进行查询。 下一个就是 IDataReader,这个接口可以使用一个只前向只读的游标来读取数据。最后但很 重要的就是实现 IDbDataAdapter 类型,这个接口负责根据客户端需要填充 DataSet。 大多数时候您不用直接与 System.Data.Common 命名空间交互。然而,如果使用这些提供者, 会要求您指定正确的 using 指令,如下所示: // Going to access an OLE DB compliant data source. using System.Data; using System.Data.OleDb; // Going to access SQL Server (7.0 or greater). using System.Data; using System.Data.SqlClient; A.14 使用 OleDb 托管提供者 如果您熟悉了某个托管提供者,那么就可以很容易地操作其他提供者。首先来看一下如何使 用 OleDb 托管提供者进行连接。当您需要连接到除 MS SQL Server 之外的数据源时,就得 使用定义在 System.Data.OleDb 中的类型。表 A-12 列出了一些核心的类型。 表 A-12 System.Data.OleDb 命名空间的类型 System.Data.Oledb 类型 意 义 OleDbCommand 表示一个可用于数据源的 SQL 查询命令 OleDbConnection 表示对数据源的一个开放连接 OleDbDataAdapter 表示一些数据命令和用来填充 DataSet、更新数据源的数据库连接 OleDbDataReader 能够从一个数据源中读取一个前向型的数据记录流 OleDbErrorCollection OleDbError OleDbException OleDbErrorCollection 拥有一些从数据源返回的警告和错误集合,每个 OleDbException 都表示为 OleDbError 类型。如果遇到错误,就会抛出 OleDbException 类型的异常 OleDbParameterCollection OleDbParameter 与典型 ADO 非常类似,OleDbParameterCollection 集合保存了要传递给数据库中存储过程的参 数。每个参数的类型都是 OleDbParameter A.14.1 使用 OleDbConnection 类型建立连接 使用 OleDb 托管提供者的第一步就是使用 OleDbConnection 类型建立一个与数据源的会话。 类似于典型 ADO Connection 对象,OleDbConnection 类型也提供了一个格式化的连接字符 串,包含了一些名/值对。您可以用这个信息来表示标识要连接的机器名称、所需的安全设 置、机器上数据库的名称,以及最重要的 OLE DB 提供者的名称(可以从在线帮助中找到每 个名/值对的完整说明)。 可以使用 OleDbConnection 来设置连接字符串。ConnectionString 属性可以作为构造函数的 参数。假设您想用 SQL OLE DB 提供者连接到一个叫做 BIGMANU 的机器上的 Cars 数据 库。可以用下面的逻辑来完成这一步: // Build a connection string. OleDbConnection cn = new OleDbConnection(); cn.ConnectionString = "Provider=SQLOLEDB.1;" + // Which provider? "Integrated Security=SSPI;" + "Persist Security Info=False;" + // Persist security? "Initial Catalog=Cars;" + // Name of database. "Data Source=BIGMANU;"; // Name of machine. 从前面代码的注释可以知道,Initial Catalog 名称指的就是您要建立与之会话的数据库(Pubs, Northwind,Cars 等)。Data Source 名称表示维护这个数据库的机器名称。最后一个就是 Pr ovider 部分,它指定了用来访问数据存储的 OLE DB 提供者的名称。表 A-13 列出了一些 可能值。 表 A-13 核心的 OLE DB 提供者 提供者部分值 意 义 Microsoft.JET.OLEDB.4.0 可以用 Jet OLE DB 提供者连接 Access 数据库 MSDAORA 可以用 OLE DB 提供者连接 Oracle SQLOLEDB 可以用 OLE DB 提供者连接 MS SQL Server 当配置好连接字符串后,接下来就是打开与数据源的会话,执行一些操作,然后释放与这个 数据源的连接,如下所示: // Build a connection string (can specify User ID and Password if needed). OleDbConnection cn = new OleDbConnection(); cn.ConnectionString = "Provider=SQLOLEDB.1;" + // Which provider? "Integrated Security=SSPI;" + "Persist Security Info=False;" + // Persist security? "Initial Catalog=Cars;" + // Name of database. "Data Source=BIGMANU;"; // Name of machine. cn.Open(); // Do some interesting work here. cn.Close(); 除了 ConnectionString、Open()和 Close()成员之外,OleDbConnection 类还提供了很多可以配 置与连接相关的设置的成员,比如超时设置和事务信息。表 A-14 显示了一部分内容。 表 A-14 OleDbConnection 类型的成员 OleDbConnection 成员 意 义 BeginTransaction() CommitTransaction() RollbackTransaction() 用来以编程方式提交、取消或回滚当前事务 Close() 关闭与数据源的连接。这是首选的方法 ConnectionString 获得或设置用于打开一个与数据存储的会话的字符串 ConnectionTimeout 获得或设置在终止和生成错误之前建立一个连接需要等待的事件。默认值为 15 秒 Database 获得或设置当前数据库或者是连接打开后所用到的数据库名称 DataSource 获得或设置要连接的数据库名称 Open() 用当前的属性设置打开数据库连接 Provider 获得或设置提供者的名称 State 获得当前连接的状态 A.14.2 构建 SQL 命令 OleDbCommand 类是 SQL 查询的 OO 表示形式,并且可以用 CommandText 属性操作查询。 ADO.NET 命名空间中的很多类型都需要 OleDbCommand 作为一个方法参数来发送请求到 数据源。在保留了原先的 SQL 查询之外,OleDbCommand 类型还定义了其他一些成员,您 可以使用它们来配置不同类型的查询(表 A-15)。 表 A-15 OleDbCommand 的成员 OleDbCommand 成员 意 义 Cancel() 取消命令的执行 CommandText 获得或设置对数据源执行的 SQL 命令文本或提供者特定的语法 CommandTimeout 获得或设置在终止企图和生成错误之前执行一条命令所等待的事件。默认值为 30 秒 CommandType 获得或设置 CommandText 属性如何被解析 Connection 获得或设置 OleDbCommand 的实例所用到的 OleDbConnection ExecuteReader() 返回一个 OleDbDataReader 实例 Parameters 获得的 OleDbParameterCollection 集合 Prepare() 在数据源上创建一个预制(或已编译)版本的命令 OleDbCommand 类型使用起来非常简单,而且跟 OleDbConnection 对象一样,它也有很多的 方式可以获得相同的最终结果。比如,注意,下面一个用活动的 OleDbConnection 对象配置 SQL 查询的方式(语义一样)。每个例子都假设已经有一个名为 cn 的 OleDbConnection: // Specify a SQL command (take one). string strSQL1 = "Select Make from Inventory where Color='Red'"; OleDbCommand myCommand1 = new OleDbCommand(strSQL1, cn); // Specify SQL command (take two). string strSQL2 = "Select Make from Inventory where Color='Red'"; OleDbCommand myCommand2 = new OleDbCommand(); myCommand.Connection = cn; myCommand.CommandText = strSQL2; A.14.3 使用 OleDbDataReader 在建立好活动连接和 SQL 命令后,下一步就是向数据源提交查询。有很多方式可以完成这 一步。OleDbDataReader 是最简单、最快但或许是最不灵活的从数据存储中获取信息的方式。 这个类表示了一个只读只前向的数据流,一次返回一条记录作为 SQL 命令的结果。 如果想非常快速地迭代大量数据时,这个 OleDbDataReader 就非常有用了,因为无需再处理 内存中的 DataSet 表示了。例如,如果从一个表中查询了 2000 行记录并存储到一个文本文 件中,用 DataSet 来保存这些信息就可能造成内存紧张。更好的方式就是创建一个 DataRea der,它能以最快速的方式遍历每条记录。然而要注意 DataReader(和 DataSet 不同)保持了一 个到数据源的连接,该连接会等到显式地关闭掉这个会话后才被关闭。 为了进行说明,下面的类对 Cars 数据库执行了一个简单的 SQL 查询,它使用了 OleDbCom mand 类型的 ExecuteReader()方法。使用这个返回的 OleDbDataReader 的 Read()方法就可以 把每个成员转储到标准的输入输出流中: public class OleDbDR { static void Main(string[] args) { // Step 1: Make a connection. OleDbConnection cn = new OleDbConnection(); cn.ConnectionString = "Provider=SQLOLEDB.1;" + "Integrated Security=SSPI;" + "Persist Security Info=False;" + "Initial Catalog=Cars;" + "Data Source=BIGMANU;"; cn.Open(); // Step 2: Create a SQL command. string strSQL = "SELECT Make FROM Inventory WHERE Color='Red'"; OleDbCommand myCommand = new OleDbCommand(strSQL, cn); // Step 3: Obtain a data reader ala ExecuteReader(). OleDbDataReader myDataReader; myDataReader = myCommand.ExecuteReader(); // Step 4: Loop over the results. while (myDataReader.Read()) { Console.WriteLine("Red car: " + myDataReader["Make"].ToString()); } myDataReader.Close(); cn.Close(); } } 结果就是 Cars 数据库中所有红色车的清单(图 A-25)。 图 A-25 运行的 OleDbDataReader 应该记得 DataReader 是只前向只读的数据流。因此,不可能在 OleDbDataReader 的内容中 进行导航。您要做的就是读取每条记录并在应用程序中使用它: // Get the value in the 'Make' column. Console.WriteLine("Red car: {0}", myDataReader["Make"].ToString()); 在使用好 DataReader 后,确保用 Close()方法终止会话。除了 Read()和 Close()方法之外,还 有很多方法可以根据给定的格式从指定列中获取值(比如 GetBoolean()、GetByte()等)。另外, FieldCount 属性返回了当前记录的列数等。 源代码: OleDbDataReader 应用程序代码位于第 13 章子目录中。 A.14.4 连接到 Access 数据库 现在您已经知道如何从 SQL Server 读取数据了,下面让我们花点时间来看看如何从 Access 数据库中得到数据。为了进行说明,把前面的 OleDbDataReader 应用程序修改为读取 cars. mdb 文件。 与典型 ADO 非常类似,使用 ADO.NET 连接到 Access 数据库的这个过程只要求更新您的构 造字符串即可。首先,设定 Provider 部分为 JET 引擎,而不是 SQLOLEDB。除了这个改动 之外,还要把数据源部分指向*.mdb 文件的路径,如下所示: // Be sure to update the data source segment if necessary! OleDbConnection cn = new OleDbConnection(); cn.ConnectionString = "Provider=Microsoft.JET.OLEDB.4.0;" + @"data source = D:\Chapter 13\Access DB\cars.mdb"; cn.Open(); 当连接完成后,您就可以读取和操作数据表的内容。另外一个要注意的就是,由于使用 JE T 引擎需要 OLEDB,因此必须使用定义在 System.Data.OleDb 命名空间中的类型(比如 OleD b 托管提供者)。记住,SQL 提供者只能访问 MS SQL Server 数据存储! A.14.5 执行存储过程 当您在构建分布式应用程序时,要面临的一个设计选择就是存放业务逻辑的位置。一个方法 就是建立可复用的二进制代码库,这个库可以由代理进程比如 Windows 2000 Component S ervices 管理器来管理。另外就是用存储过程来表示数据层上的系统业务逻辑。当然还有另 外的方法,那就是把上面两种技术混合使用。 存储过程就是一个存储在数据库中的已命名的 SQL 代码块。可以通过构建存储过程来返回 一些行(或者原始数据类型)给调用组件,另外存储过程还可以采用一些可选的参数。最终就 是一个行为类似于典型函数的工作单元,明显的不同就是存储过程存放在数据存储中而不是 二进制业务对象中。 下面来看已有的 Cars 数据库上的 GetPetName 存储过程,它接受一个整型类型的输入参数(如 果运行我提供的 SQL 脚本,会发现已经定义好这个存储过程了)。这是车的数字 ID,通过它 可获得 pet 名称,pet 名称作为一个字符类型的输出参数返回。语法如下: CREATE PROCEDURE GetPetName @carID int, @petName char(20) output AS SELECT @petName = PetName from Inventory where CarID = @carID 现在已经有了一个存储过程,下面来看一下执行这个过程所需的代码。当然总是先从创建一 个新的 OleDbConnection 开始,然后配置连接字符串,最后打开会话。接着创建一个新的 O leDbCommand 类型,确保指定了这个存储过程的名称,并相应设定好 CommandType 属性, 如下所示: // Open connection to data store. OleDbConnection cn = new OleDbConnection(); cn.ConnectionString = "Provider=SQLOLEDB.1;" + "Integrated Security=SSPI;" + "Persist Security Info=False;" + "Initial Catalog=Cars;" + "Data Source=BIGMANU;"; cn.Open(); // Make a command object for the stored proc. OleDbCommand myCommand = new OleDbCommand("GetPetName", cn); myCommand.CommandType = CommandType.StoredProcedure; 这个 OleDbCommand 类的 CommandType 属性可以通过相关的 CommandType 枚举值来设定 (表 A-16)。 表 A-16 CommandType 枚举的值 CommandType 枚举的值 意 义 StoredProcedure 用来配置一个可以触发存储过程的 OleDbCommand TableDirect 这个 OleDbCommand 表示返回其所有列的表名称 Text OleDbCommand 类型包含了一个标准的 SQL 命令。这是默认的值 当对数据源进行一些基本的 SQL 查询(比如,“SELECT * FROM Inventory”)时,默认的 C ommandType.Text 设置很合适。然而如果要用命令调用存储过程,则需要指定 CommandTyp e.StoredProcedure。 1. 使用 OleDbParameter 类型指定参数 下面的任务就是为这个调用建立参数。OleDbParameter 类型是传递给(或从中接收的)存储过 程中特定参数的一个 OO 包装器。这个类有很多属性,可以配置参数的名称、大小、数据类 型以及它的传递方向。表 A-17 列出了 OleDbParameter 类型的一些关键属性。 表 A-17 OleDbParameter 类型的成员 OleDbParameter 属性 意 义 DataType 在.NET 中,建立参数的类型 DbType 使用 OleDbType 枚举获得或设置数据源的原始数据类型 Direction 获得或设置参数是否只输入、只输出、双向或者是一个返回值参数 IsNullable 获得或设置参数是否能接收 null 值 ParameterName 获得或设置 OleDbParameter 的名称 Precision 获得或设置用来表示这个值的最大位数 Scale 获得或设置数据的最大小数位数 Size 获得或设置数据的最大参数大小 Value 获得或设置参数的值 由于上面的存储过程有一个输入和一个输出参数,因此也就需要按照下面的方式配置您的类 型。注意,您得把这些项添加到 OleDbCommand 类型的 ParametersCollection 中去(这个当然 也可以通过 Parameters 属性访问): // Create the parameters for the call. OleDbParameter theParam = new OleDbParameter(); // Input. theParam.ParameterName = "@carID"; theParam.DbType = OleDbType.Integer; theParam.Direction = ParameterDirection.Input; theParam.Value = 1; // Car ID = 1. myCommand.Parameters.Add(theParam); // Output. theParam = new OleDbParameter(); theParam.ParameterName = "@petName"; theParam.DbType = OleDbType.Char; theParam.Size = 20; theParam.Direction = ParameterDirection.Output; myCommand.Parameters.Add(theParam); 最后一步就是用 OleDbCommand.ExecuteNonQuery()执行这个命令。注意,可以通过访问 Ol eDbParameter 类型的 Value 属性来取得返回的 pet 名称,如下所示: // Execute the stored procedure! myCommand.ExecuteNonQuery(); // Display the result. Console.WriteLine("Stored Proc Info:"); Console.WriteLine("Car ID: " + myCommand.Parameters["@carID"].Value); Console.WriteLine("PetName: " + myCommand.Parameters["@petName"].Value); 图 A-26 显示了输出结果。 图 A-26 触发存储过程 源代码: OleDbStoredProc 项目的源代码位于第 13 章子目录中。 A.15 OleDbDataAdapter 类型的角色 这时您应该了解到如何通过 OleDbConnection 类型连接到数据源、发送命令(使用 OleDbCo mmand 和 OleDbParameter 类型)并处理 OleDbDataReader 了。当您想非常快速地迭代大量数 据或者触发存储过程时这非常有用。然而,如果想从数据存储中获得一个完整的 DataSet, 最灵活的方式就是使用 OleDbDataAdapter。 简而言之,这个类型从数据存储中获取信息,并用 OleDbDataAdapter.Fill()方法在 DataSet 中填充一个 DataTable,Fill()方法已经重载很多次。下面列出了几种可能(FYI,这个整型返 回类型保存了返回的记录数)。 // Fills the data set with records from a given source table. public int Fill(DataSet yourDS, string tableName); // Fills the data set with the records located between // the given bounds from a given source table. public int Fill(DataSet yourDS, string tableName, int startRecord, int maxRecord); 在调用这个方法之前,您必须有一个有效的 OleDbDataAdapter 对象引用。构造函数同样也 被多次重载,但多数情况下必须提供用来填充 DataTable 的连接信息和 SQL SELECT 语句。 这个 OleDbDataAdapter 类型不仅仅是帮助您填充 DataSet 中表的实体,而且也负责维护一些 核心的 SQL 语句,这些语句可用来更新数据存储。表 A-18 列出了 OleDbDataAdapter 类型 的一些核心成员。 表 A-18 OleDbDataAdapter 的核心成员 OleDbDataadapter 成员 意 义 DeleteCommand InsertCommand SelectCommand UpdateCommand 用来建立 SQL 命令,当调用 Update() 方法时可以把它发到数据存储。这些属性都可以通过 OleDbCommand 类型设定 Fill() 用一些记录填充 DataSet 的指定表 GetFillParameters() 当执行 select 命令时返回所有用到的参数 Update() 在 DataSet 中为指定的表进行添加、更新和删除行操作时调用各自的 INSERT、UPDATE 或 DELETE 语句 OleDbDataAdapter(与 SqlDataAdapter 一样)关键的几个属性就是 DeleteCommand、InsertCom mand、SelectCommand 以及 UpdateCommand。数据适配器知道如何根据给定的命令提交所 作的改变。例如在调用 Update()时,数据适配器会自动使用存储在每个属性中的 SQL 命令。 可以看到,配置这些属性所需的代码比较冗长。在直接检查这些属性之前,让我们先来了解 一下如何通过编程的方式使用数据适配器填充 DataSet。 使用 OleDbDataAdapter 类型填充 DataSet 下面的代码使用了 OleDbDataAdapter 填充 DataSet(包含一个表): public class MyOleDbDataAdapter { // Step 1: Open a connection to Cars db. OleDbConnection cn = new OleDbConnection(); cn.ConnectionString = "Provider=SQLOLEDB.1;" + "Integrated Security=SSPI;" + "Persist Security Info=False;" + "Initial Catalog=Cars;" + "Data Source=BIGMANU;"; cn.Open(); // Step 2: Create data adapter using the following SELECT. string sqlSELECT = "SELECT * FROM Inventory"; OleDbDataAdapter dAdapt = new OleDbDataAdapter(sqlSELECT, cn); // Step 3: Create and fill the DataSet, close connection. DataSet myDS = new DataSet("CarsDataSet"); try { dAdapt.Fill(myDS, "Inventory"); } catch(Exception ex) { Console.WriteLine(ex.Message); } finally { cn.Close(); } // Private helper function. PrintTable(myDS); return 0; } 注意,和本章前面一部分所做的不同,您不用手动处理 DataTable 类型并把它加入到 DataSe t 中去。这儿您只要把 Inventory 表作为 Fill()方法的第二个参数即可。Fill()在内部使用 SEL ECT 命令创建一个 DataTable,采用数据存储中的表名。在这个迭代中,SQL SELECT 语句 和 OleDbDataAdapter 之间的连接共同组成了构造函数的参数: // Create a SELECT command as string type. string sqlSELECT = "SELECT * FROM Inventory"; OleDbDataAdapter dAdapt = new OleDbDataAdapter(sqlSELECT, cn); 还有一个更加面向对象的方式,那就是使用 OleDbCommand 类型来控制 SELECT 语句。可 以使用 SelectCommand 属性把 OleDbCommand 和 OleDbDataAdapter 关联起来,如下所示: // Create a SELECT command object. OleDbCommand selectCmd = new OleDbCommand("SELECT * FROM Inventory", cn); // Make a data adapter and associate commands. OleDbDataAdapter dAdapt = new OleDbDataAdapter(); dAdapt.SelectCommand = selectCmd; 注意,本例把 OleDbConnection 作为参数附加到 OleDbCommand 中。图 A-27 显示了最终 的结果。 图 A-27 活动的 OleDbDataAdapter PrintTable()方法的格式看起来有点混乱: public static void PrintTable(DataSet ds) { // Get Inventory table from DataSet. Console.WriteLine("Here is what we have right now:\n"); DataTable invTable = ds.Tables["Inventory"]; // Print the Column names. for(int curCol= 0; curCol< invTable.Columns.Count; curCol++) { Console.Write(invTable.Columns[curCol].ColumnName.Trim() + "\t"); } Console.WriteLine(); // Print each cell. for(int curRow = 0; curRow < invTable.Rows.Count; curRow++) { for(int curCol= 0; curCol< invTable.Columns.Count; curCol++) { Console.Write(invTable.Rows[curRow][curCol].ToString().Trim() + "\t"); } Console.WriteLine(); } } 源代码: FillSingleDSWithAdapter 项目的源代码位于第 13 章子目录。 A.16 使用 SQL 托管提供者 在了解如何使用数据适配器添加、更新以及删除记录的细节之前,这里先介绍一下 SQL 托 管提供者。应该记得,OleDb 提供者可以访问任意一个支持 OLE DB 的数据存储,但不能 达到 SQL 提供者的优化水平。 当您知道要操作的数据源是 MS SQL Server 时,如果直接使用 System.Data.SqlClient 会得到 很好的性能。总的来说,下面这些类构成了 SQL 托管提供者所有的功能,这个提供者看起 来非常类似于前面用到的 OleDb 提供者(表 A-19)。 表 A-19 System.Data.SqlClient 命名空间的核心类型 System.Data.Sqlclient 类型 意 义 SqlCommand 表示在 SQL Server 数据源上执行的 Transact-SQL 查询 SqlConnection 表示对 SQL Server 数据源的开放连接 SqlDataAdapter 表示用来填充 DataSet 和更新 SQL Server 数据源的数据命令和数据库连接 SqlDataReader 能从 SQL Server 数据源读取一个只前向的数据记录流 SqlErrors SqlError SqlException SqlErrors 维护 SQL Server 返回的警告和错误的集合,每个都用 SQLError 类型表示。如果遇到一个错误, 就会抛出 SQLException 类型的异常 SqlParameterCollection SqlParameter SqlParametersCollection 保存发送到数据库中存储过程的参数。每个参数的类型都为 SQLParameter 由于使用这些类型几乎和使用 OleDb 托管提供者完全一样,您就应该已经知道如何处理这 些类型,因为它们都有相同的公共接口。为了帮助您适应这些新的类型,下面的例子都会使 用 SQL 托管提供者。 A.16.1 System.Data.SqlTypes 命名空间 作为一个快速提示,在使用 SQL 托管提供者时,您还需要额外使用大量表示原始 SQL Ser ver 数据类型的托管类型。表 A-20 给出了一个快速参考。 表 A-20 System.Data.SqlTypes 命名空间的类型 System.Data.Sqltypes 包装 原始的 SQL Server SqlBinary binary, varbinary, timestamp, image SqlInt64 Bigint SqlBit Bit SqlDateTime datetime, smalldatetime SqlNumeric Decimal SqlDouble Float SqlInt32 Int SqlMoney money, smallmoney SqlString nchar, ntext, nvarchar, sysname, text, varchar, char SqlNumeric Numeric SqlSingle Real (续表) System.Data.Sqltypes 包装 原始的 SQL Server SqlInt16 Smallint System.Object sql_variant SqlByte Tinyint SqlGuid Uniqueidentifier A.16.2 使用 SqlDataAdapter 插入新记录 现在您已经从 OleDb 提供者转移到 SQL 提供者了,接下来的任务还是来了解数据适配器的 角色。可以使用 SqlDataAdapter 来看一下如何往给定表中插入新的记录(这和使用 OleDbDat aAdapter 几乎相同)。通常都是先从创建一个活动连接开始,如下所示: public class MySqlDataAdapter { public static void Main() { // Step 1: Create a connection and adapter (with select command). SqlConnection cn = new SqlConnection("server=(local);uid=sa;pwd=;database=Cars"); SqlDataAdapter dAdapt = new SqlDataAdapter("Select * from Inventory", cn); // Step 2: Kill record you inserted. cn.Open(); SqlCommand killCmd = new SqlCommand("Delete from Inventory where CarID = '1111'", cn); killCmd.ExecuteNonQuery(); cn.Close(); } } 可以看到,连接字符串变得非常简单。特别是您不需要定义提供者部分了(因为 SQL 类型只 能和 SQL server 进行对话)。然后创建一个新的 SqlDataAdapter 并把 SelectCommand 属性的 值指定为构造函数的一个参数(非常类似于 OleDbDataAdapter)。 第二步纯粹是些“小事”。这儿创建了一个新的 SqlCommand 类型来销毁将要键入的记录(为 了防止主键冲突)。下一步有些复杂,目的是创建一个新的 SQL 语句来作为 SqlDataAdapter 的 InsertCommand。首先,创建 SqlCommand 并指定一个标准的 SQL 插入,接下来是描述 I nventory 表中每一列的 SqlParameter 类型,如下所示: public static void Main() { . . . // Step 3: Build the insert Command! dAdapt.InsertCommand = new SqlCommand("INSERT INTO Inventory" + "(CarID, Make, Color, PetName) VALUES" + "(@CarID, @Make, @Color, @PetName)", cn)"; // Step 4: Build parameters for each column in Inventory table. SqlParameter workParam = null; // CarID. workParam = dAdapt.InsertCommand.Parameters.Add(new SqlParameter("@CarID", SqlDbType.Int)); workParam.SourceColumn = "CarID"; workParam.SourceVersion = DataRowVersion.Current; // Make. workParam = dAdapt.InsertCommand.Parameters.Add(new SqlParameter("@Make", SqlDbType.VarChar)); workParam.SourceColumn = "Make"; workParam.SourceVersion = DataRowVersion.Current; // Color. workParam = dAdapt.InsertCommand.Parameters.Add(new SqlParameter("@Color", SqlDbType.VarChar)); workParam.SourceColumn = "Color"; workParam.SourceVersion = DataRowVersion.Current; // PetName. workParam = dAdapt.InsertCommand.Parameters.Add(new SqlParameter("@PetName", SqlDbType.VarChar)); workParam.SourceColumn = "PetName"; workParam.SourceVersion = DataRowVersion.Current; } 现在已经格式化好每个参数,最后就是填充 DataSet,然后添加新行(注意,在本例中还用到 了帮助函数 PrintTable()): public static void Main() { . . . // Step 5: Fill data set. DataSet myDS = new DataSet(); dAdapt.Fill(myDS, "Inventory"); PrintTable(myDS); // Step 6: Add new row. DataRow newRow = myDS.Tables["Inventory"].NewRow(); newRow["CarID"] = 1111; newRow["Make"] = "SlugBug"; newRow["Color"] = "Pink"; newRow["PetName"] = "Cranky"; myDS.Tables["Inventory"].Rows.Add(newRow); // Step 7: Send back to database and reprint. try { dAdapt.Update(myDS, "Inventory"); myDS.Dispose(); myDS = new DataSet(); dAdapt.Fill(myDS, "Inventory"); PrintTable(myDS); } catch(Exception e){ Console.Write(e.ToString()); } } 运行这个应用程序后,您可以看到图 A-28 所示的输出结果。 图 A-28 运行的 InsertCommand 属性 源代码: InsertRowsWithSqlAdapter 项目位于第 13 章子目录中。 A.16.3 使用 SqlDataAdapter 更新已有记录 现在您已经能插入新行了,来看一下如何更新已有的行。同样,行获得一个连接(使用 SqlC onnection 类型),然后创建一个新的 SqlDataAdapter。接下来使用和设定 InsertCommand 属 性的值一样的方法设定 UpdateCommand 属性的值。下面是 Main()的相关代码: public static void Main() { // Step 1: Create a connection and adapter (same as previous code) . . . // Step 2: Establish the UpdateCommand. dAdapt.UpdateCommand = new SqlCommand ("UPDATE Inventory SET Make = @Make, Color = " + "@Color, PetName = @PetName " + "WHERE CarID = @CarID" , cn); // Step 3: Build parameters for each column in Inventory table. // Same as before, but now you are populating the ParameterCollection // of the UpdateCommand. For example: SqlParameter workParam = null; workParam = dAdapt.UpdateCommand.Parameters.Add(new SqlParameter("@CarID", SqlDbType.Int)); workParam.SourceColumn = "CarID"; workParam.SourceVersion = DataRowVersion.Current; // Do the same for PetName, Make, and Color params. // Step 4: Fill data set. DataSet myDS = new DataSet(); dAdapt.Fill(myDS, "Inventory"); PrintTable(myDS); // Step 5: Change columns in second row to 'FooFoo'. DataRow changeRow = myDS.Tables["Inventory"].Rows[1]; changeRow["Make"] = "FooFoo"; changeRow["Color"] = "FooFoo"; changeRow["PetName"] = "FooFoo"; // Step 6: Send back to database and reprint. try { dAdapt.Update(myDS, "Inventory"); myDS.Dispose(); myDS = new DataSet(); dAdapt.Fill(myDS, "Inventory"); PrintTable(myDS); } catch(Exception e) { Console.Write(e.ToString()); } } 图 A-29 显示了输出结果。 图 A-29 更新已有的行 源代码: UpdateRowsWithSqlAdapter 项目的源代码位于第 13 章子目录。 A.17 自动生成的 SQL 命令 现在您可以使用数据适配器类型(OleDbDataAdapter 和 SqlDataAdapter)来选择、删除、插入 并更新给定数据源的记录。虽然这个过程一般不会像火箭科学一样繁琐,但在建立所有的参 数类型和手动配置 InsertCommand、UpdateCommand 和 DeleteCommand 属性时还是会有点 麻烦的。您可能会想到,应该有一些帮助。 我们可以使用 SqlCommandBuilder 类型。如果有一个来自于单个表的 DataTable(不是多个连 接的表),SqlCommandBuilder 会自动根据原先的 SelectCommand 设置 InsertCommand、Upd ateCommand 和 DeleteCommand 属性。除了非连接约束之外,这个表必须有一个主键列,而 且必须在原先的 SELECT 语句中指定这个列。这样做的好处就是您可以不用手动创建那些 S qlParameter 类型。 为了说明这一点,先假设您已经有一个新的 Windows Forms 示例,它可以让用户在 DataGr id 中编辑这些值。完成编辑后,用户可以用一个 Button 类型把修改提交回数据库。首先, 假设有如下的构造函数逻辑: public class mainForm : System.Windows.Forms.Form { private SqlConnection cn = new SqlConnection("server=(local);uid=sa;pwd=;database=Cars"); private SqlDataAdapter dAdapt; private SqlCommandBuilder invBuilder; private DataSet myDS = new DataSet(); private System.Windows.Forms.DataGrid dataGrid1; private System.Windows.Forms.Button btnUpdateData; private System.ComponentModel.Container components; public mainForm() { InitializeComponent(); // Create the initial SELECT SQL statement. dAdapt = new SqlDataAdapter("Select * from Inventory", cn); // Autogenerate the INSERT, UPDATE, // and DELETE statements. invBuilder = new SqlCommandBuilder(dAdapt); // Fill and bind. dAdapt.Fill(myDS, "Inventory"); dataGrid1.DataSource = myDS.Tables["Inventory"].DefaultView; } . . . } 在退出时关闭连接!现在这个 SqlDataAdapter 已经有了所有将修改提交给数据存储所需的信 息。假设您已经有了如下的按钮单击事件的逻辑: private void btnUpdateData_Click(object sender, System.EventArgs e) { try { dataGrid1.Refresh(); dAdapt.Update(myDS, "Inventory"); } catch(Exception ex) { MessageBox.Show(ex.ToString()); } } 通常可以调用 Update()并指定需要更新的 DataSet 和表。如果把这个调用从测试运行中取走, 可以看到如图 A-30 所示的信息(确保在提交结果之前您已经不再编辑这个 DataTable 了)。 很好!我确信您已经认为自动生成的命令要比使用原始的参数简单得多。当然,和所有事情 一样,这也有代价。特别是当您有通过连接操作组成的 DataTable 时,就不能再使用这个技 术。同时可以看到,使用这些原始的参数可以进行更好的粒度控制。 图 A-30 在 DataSet 中扩展新的 DataRow 源代码: WinFormSqlAdapter 项目位于第 13 章子目录中。 A.18 填充多表的 DataSet(添加 DataRelation) 最后,让我们重新建立一个 Windows Forms 例子来模拟本章前面部分创建的应用程序。GU I 非常简单。在图 A-31 中,您可以看到有 3 个保存了从 Cars 数据库的 Inventory、Orders 和 Customers 表检索到的数据的 DataGrid 类型。另外,还有一个按钮会把所有修改送回到数据 存储中。 图 A-31 显示了多表的 DataSet 为了使事情更加简单,可以对每个 SqlDataAdapter(一个 SqlDataAdapters 对应着一个表)使用 自动生成命令。首先看一下 Form 的状态数据: public class mainForm : System.Windows.Forms.Form { private System.Windows.Forms.DataGrid custGrid; private System.Windows.Forms.DataGrid inventoryGrid; private System.Windows.Forms.Button btnUpdate; private System.Windows.Forms.DataGrid OrdersGrid; private System.ComponentModel.Container components; // Here is the connection. private SqlConnection cn = new SqlConnection("server=(local);uid=sa;pwd=;database=Cars"); // Our data adapters (for each table). private SqlDataAdapter invTableAdapter; private SqlDataAdapter custTableAdapter; private SqlDataAdapter ordersTableAdapter; // Command builders (for each table). private SqlCommandBuilder invBuilder = new SqlCommandBuilder(); private SqlCommandBuilder orderBuilder = new SqlCommandBuilder(); private SqlCommandBuilder custBuilder = new SqlCommandBuilder(); // The dataset. DataSet carsDS = new DataSet(); . . . } 这个 Form 的构造函数完成了一些乏味的工作,比如创建数据成员变量、填充 DataSet。还 要注意到有一个对私有帮助函数 BuildTableRelationship()的调用,如下所示: public mainForm() { InitializeComponent(); // Create adapters. invTableAdapter = new SqlDataAdapter("Select * from Inventory", cn); custTableAdapter = new SqlDataAdapter("Select * from Customers", cn); ordersTableAdapter = new SqlDataAdapter("Select * from Orders", cn); // Autogenerate commands. invBuilder = new SqlCommandBuilder(invTableAdapter); orderBuilder = new SqlCommandBuilder(ordersTableAdapter); custBuilder = new SqlCommandBuilder(custTableAdapter); // Add tables to DS. invTableAdapter.Fill(carsDS, "Inventory"); custTableAdapter.Fill(carsDS, "Customers"); ordersTableAdapter.Fill(carsDS, "Orders"); // Build relations between tables. BuildTableRelationship(); } 这个 BuildTableRelationship()帮助函数实现了需要完成的工作。应该记得,Cars 数据库表示 了很多父/子关系。这个代码看起来和本章前面的逻辑相同,如下所示: private void BuildTableRelationship() { // Create a DR obj. DataRelation dr = new DataRelation("CustomerOrder", carsDS.Tables["Customers"].Columns["CustID"], carsDS.Tables["Orders"].Columns["CustID"]); // Add relation to the DataSet. carsDS.Relations.Add(dr); // Create another DR obj. dr = new DataRelation("InventoryOrder", carsDS.Tables["Inventory"].Columns["CarID"], carsDS.Tables["Orders"].Columns["CarID"]); // Add relation to the DataSet. carsDS.Relations.Add(dr); // Fill the grids! inventoryGrid.SetDataBinding(carsDS, "Inventory"); custGrid.SetDataBinding(carsDS, "Customers"); OrdersGrid.SetDataBinding(carsDS, "Orders"); } 现在已经填充好 DataSet,并断开了与数据源之间的连接,这样您就可以本地化操作每个表。 只要对 3 个 DataGrid 中的任意一个简单地插入、更新或删除值即可。如果您准备提交数据 进行处理的话,单击 Form 的 Update 按钮。Click 事件后的代码非常清晰,如下所示: private void btnUpdate_Click(object sender, System.EventArgs e) { try { invTableAdapter.Update(carsDS, "Inventory"); custTableAdapter.Update(carsDS, "Customers"); ordersTableAdapter.Update(carsDS, "Orders"); } catch(Exception ex) { MessageBox.Show(ex.Message); } } 更新之后您可以发现 Cars 数据库中的每个表都已经被正确地修改了。 这时您应该感觉到使用 OleDb 和 SQL 这两个托管提供者的方便之处,并且也能明白如何操 作和更新输出的 DataSet。显然 ADO.NET 还有很多其他方面的问题,比如事务编程、安全 问题等。我想您可能会自己再进一步摸索它们吧。 ADO.NET 中您还没有了解的另一个方面就是大量的 VS.NET 数据向导。例如,把一个 Dat a 组件(从 Toolbox 窗口)拖到设计模板中时,可以运行一些向导,包括为 SqlConnection 和 O leDbConnection 创建连接字符串;自动为一个数据适配器建立 SELECT、INSERT、DELET E 和 UPDATE 命令等。如果您仔细阅读完本章后,就会知道和这些工具打交道应该非常轻 松。 源代码: MultiTableDataSet 项目位于第 13 章子目录中。 A.19 小结 ADO.NET 是随人们所熟知的断开连接的 N 层应用程序发展起来的一个新的数据访问技术。S ystem.Data 命名空间包含了很多需要用编程的方式与行、列、表以及视图进行交互的核心类。 可以看到,System.Data.SqlClient 和 System.Data.OleDb 命名空间定义了建立活动连接所需的 类型。 ADO.NET 的中心就是 DataSet。这个类型提供了任意数目的表和任意数目的可选内部关系、 约束和表达式在内存中的表示。在本地表之间创建关系的好处就是在断开与远程数据存储的 连接时,可用编程的方式对它们进行导航。 最后,本章讨论了数据适配器的角色(OleDbDataAdapter 和 SqlDataAdapter)。通过这个类型(相 关的 SelectCommand、InsertCommand、UpdateCommand 和 DeleteCommand 属性),适配器 可以将对 DataSet 的修改更新到原始的数据存储中。当然在 ADO.NET 命名空间中有太多的 内容,而我也不能在一章中全部讲到,但您现在应该有一个很扎实的基础了。
还剩153页未读

继续阅读

下载pdf到电脑,查找使用更方便

pdf的实际排版效果,会与网站的显示效果略有不同!!

需要 8 金币 [ 分享pdf获得金币 ] 0 人已下载

下载pdf

pdf贡献者

gxw6

贡献于2015-05-03

下载需要 8 金币 [金币充值 ]
亲,您也可以通过 分享原创pdf 来获得金币奖励!
下载pdf