C--和--NET-Core-网络编程实用指南-全-

C# 和 .NET Core 网络编程实用指南(全)

原文:zh.annas-archive.org/md5/95d7ed9fced427641b34a6a97c8cc191

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

网络编程是一项复杂的任务,它对所支持软件的性能和可扩展性可能产生极高的影响。学习利用 C#和.NET Core 库的高级语言构造和功能可以帮助工程师微调他们的网络应用程序,以满足现代用户对性能的期望。本书旨在作为对网络编程概念的全面探索,通过.NET Core 框架的视角进行阐述。它旨在解释计算机网络和使这些网络成为可能的软件的各个方面。仅使用.NET Core SDK 和代码编辑器,本书内容展示了如何实现灵活、稳定和可靠的软件,以促进大规模计算机网络。

本书面向对象

如果您有面向对象编程语言的经验,并想了解更多关于 C#和.NET Core 如何促进网络和 Web 编程的信息,这本书适合您。此外,如果您管理或管理网络资源,并想利用.NET Core 中的工具来管理或定制您的网络,这本书是您的一个优秀资源。最后,如果您有网络编程的经验,但想更深入地了解.NET Core 框架,这本书是您的选择。

本书涵盖内容

第一章,《网络概览》,向读者介绍了计算机网络的基础知识以及为分布式系统编写软件的挑战。

第二章,《DNS 和资源定位》,探讨了资源定位的基础以及 DNS 的起源。

第三章,《通信协议》,探讨了 OSI 网络栈及其为该栈每一层设计的各种通信协议。

第四章,《数据包和流》,探讨了数据如何封装在数据包中,以及数据包组如何被您的软件作为流消费。

第五章,《在 C#中生成网络请求》,深入探讨了网络通信的请求/响应模型以及该模型如何在 C#中实现。

第六章,《流、线程和异步数据》,探讨了如何设计 C#程序以异步方式从远程资源消费数据流,从而提高性能和可靠性。

第七章,《网络中的错误处理》,仔细探讨了如何在网络软件中设计和实现错误处理策略。

第八章,套接字和端口,探讨了通过将应用程序代码中的套接字映射到网络接口上的端口,如何在网络主机之间建立逻辑连接。

第九章,.NET 中的 HTTP,全面探讨了在 C#和.NET Core 的上下文中如何实现 HTTP 的各个方面。它探讨了使用 ASP.NET Core 和System.Net.Http库实现 HTTP 客户端和服务器应用程序。

第十章,FTP 和 SMTP,考察了 OSI 网络堆栈应用层中一些不太常用的协议,并在过程中实现了一个功能齐全的 FTP 服务器。

第十一章,传输层 – TCP 和 UDP,详细考察了 OSI 网络堆栈的传输层,探讨了基于连接和无连接通信协议之间的区别,并探讨了如何在 C#中实现每个协议。

第十二章,互联网协议,通过探讨互联网协议IP)如何提供设备寻址和分组交付,来探索现代互联网的骨干。

第十三章,传输层安全,探讨了 SSL 和 TLS 是如何被设计来为在全局未加密网络上传输的数据提供安全性的,以及如何在.NET Core 应用程序中实现 TLS。

第十四章,网络上的身份验证和授权,考虑了如何验证网络软件用户的身份,并根据用户的权限限制对不同的功能和资源的访问。

第十五章,分布式系统的缓存策略,考察了在网络软件中缓存不同资源以改善性能和可靠性的各种好处。

第十六章,性能分析和监控,详细探讨了如何监控网络应用程序的健康状况和性能,以及如何应对和减轻网络不可靠性对软件的影响。

第十七章,.NET Core 中的可插拔协议,考察了.NET 的可插拔协议概念,以及如何使用它来定义自己的自定义应用层网络协议,并将这些协议无缝地集成到.NET 应用程序中。

第十八章,网络分析和数据包检查,检查了.NET Core 框架中用于调查网络流量和主机机器上网络设备状态的工具和资源。它探讨了如何通过数据包检查调查主机处理中的网络流量内容,以及如何使用数据包检查获得的信息来应对安全风险。

第十九章,远程登录和 SSH,探讨了 SSH 协议的起源以及它是如何使在不安全的网络上安全访问远程资源的。它还探讨了用于通过 SSH 与远程主机交互的最流行的 C#库,并考虑了你可以基于 SSH 协议构建的应用程序范围。

为了充分利用本书

本书假设您对面向对象编程的原则有基本了解,并且至少能够阅读和跟随 C#源代码。还假设您对网络概念和原则有基本的、高级的理解。

要充分利用本书,您至少需要下载和安装最新版本的.NET Core SDK 和命令行界面。您还应花时间熟悉 C#源代码编辑器,例如 Visual Studio Community Edition 或 Visual Studio Code,这两者都是免费使用的。

下载示例代码文件

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

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

  1. www.packt.com登录或注册。

  2. 选择 SUPPORT 选项卡。

  3. 点击代码下载和勘误表。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

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

  • Windows 上的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 上的 7-Zip/PeaZip

书籍的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Network-Programming-with-CSharp-and-.NET-Core。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“注意在using指令中包含System.Net.Security命名空间。这是定义AuthenticationLevel枚举的地方。”

代码块设置如下:

var httpRequest = WebRequest.Create("http://test-domain.com");
var ftpRequest = WebRequest.Create("ftp://ftp.test-domain.com");
var fileRequest = WebRequest.Create("file://files.test-domain.com");

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

if(!WebRequest.RegisterPrefix("cpf://", new CustomRequestCreator())) {
    throw new WebException("Failure to register custom prefix protocol handler.");
}

任何命令行输入或输出应如下所示:

$ mkdir css
$ cd css

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中如下所示。以下是一个示例:“从管理面板中选择系统信息。”

警告或重要注意事项如下所示。

技巧和窍门如下所示。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并给我们发送邮件至 customercare@packtpub.com

勘误: 尽管我们已经尽最大努力确保内容的准确性,错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问 www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上发现我们作品的任何非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过链接材料与我们联系至 copyright@packt.com

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或参与一本书,请访问 authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了这本书,为何不在您购买书籍的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解 Packt 的更多信息,请访问 packt.com

第一部分:网络架构基础

本书的第一部分将首先探讨各种网络架构,这些架构使得分布式编程成为可能。它将检查硬件和软件供应商遵循的标准,以允许网络间的通信,包括 DNS 命名系统、用于设备地址的 IPv4 和 IPv6 标准,以及允许用户为这些网络编程的本地硬件级 API 和数据结构,并提供了利用或展示这些概念的 C#软件的基本示例。

本节将涵盖以下章节:

第一章,网络概览

第二章,DNS 与资源定位

第三章,通信协议

第四章,数据包和流

第一章:网络概述

很难想象任何阅读这本书的人对网络实际上是什么没有一些直观的想法. 当我写这篇引言时,我周围至少有六个不同的、可触及的网络连接设备。即使在开始软件工程职业生涯之前,我也能给出一个关于网络构成的大致准确的描述。然而,无论对网络是什么或可能在其上运行什么有怎样的直觉,无论使用在分布式系统上运行的软件,都无法解释分布式架构对代码的影响。正是这种对软件设计和实现决策的影响,我们将在本章中探讨。

我们将尝试给出一个关于网络的明确定义,并考虑在为这些网络编写软件时需要解决的新问题。本书假设读者具备相当程度的 C#语言通用编程技能。我不会花费时间去解释原生语言结构、类型或关键字的使用,也不会讨论或解释在整个过程中使用的通用算法。然而,我不会对读者对网络的了解、设备间通信或.NET Core 中解决这些问题的方法做出任何假设。因此,本章将从最基本的第一原理开始,旨在为至少具备一些编程技能的任何人提供一个稳定的基石,使他们能够胜任本书其余部分的学习。

本章将涵盖以下主题:

  • 在网络上分配计算或数据资源所面临的独特挑战,以及这些挑战如何在软件中体现

  • 网络的不同组成部分,以及如何安排这些组件以实现不同的目标

  • 设备多样性、延迟、不稳定性和网络标准化对为网络使用编写的应用程序复杂性的影响

  • 网络编程中常用的概念、术语和数据结构,以及.NET Core 如何暴露这些概念

  • 理解网络架构使可能的应用程序的范围,以及发展网络编程技能以实现这些类型应用程序的重要性

技术要求

由于这是一个入门章节,我们将不会提供有意义的代码示例,因为我们将涵盖网络的高级概念和词汇,为本书的其余部分建立一个清晰的基础。然而,在本章中,我们将讨论.NET Core 提供的System.Net类库。虽然这次讨论将在非常高的层次上进行,但这将是一个熟悉由 Microsoft Visual Studio Community 版提供的开发工具的好机会。这是免费的,并且提供了丰富的功能套件,默认支持.NET Core 项目管理和维护。当我们讨论.NET Core 工具中提供的某些库时,我鼓励您使用 Visual Studio IDE 将这些库包含到您的项目中,并通过 IDE 的 IntelliSense 开始探索它们。

扩展软件的范畴——分布式系统和它们带来的挑战

理解网络编程的第一步当然是理解网络。定义它们是什么,明确我们关心的网络方面,探讨网络架构对我们编写的程序的影响,以及网络需要什么样的软件解决方案才能有效。

什么是网络?

在最基本的意义上,网络不过是一个无向图的物理实现;一系列节点和节点之间的边,或者说是连接,如下面的图所示:

图片

[一个基本的、无向图]

然而,前面的图并没有完全捕捉到全貌。构成节点的要素是什么,以及什么才足够构成一个连接,这些都是非常相关的细节,需要明确。一个节点可能需要能够与网络上的其他节点进行有意义的交互,否则你可能不得不为通过两根线连接到路由器和服务器上的土豆编写程序。可以说,土豆显然不是节点,而一个活跃且稳定的 Azure 服务器显然是节点,因此网络中节点与非节点的界限可能介于这两者之间。同样,我们可以很容易地识别出,从电脑的电源到墙上的插座之间的电缆并不构成网络连接,而从我们的电脑到路由器的 CAT-5 电缆显然是。这条界限可能介于这两者之间,而且我们一定要小心准确地划出这条线。

为了这本书的目的,我们将从网络的一个可行定义开始,解析这个定义,并考察我们为什么选择做出特定的区分,最后,考虑每个网络的基本属性对我们程序员意味着什么。所以,不再多言,计算机网络的定义如下:

对于我们的目的而言,计算机网络是由任意大量计算或导航设备组成,这些设备通过通信渠道连接,计算资源可以通过这些渠道可靠地发送、接收、转发或处理。

表面上看,这似乎很基本,但那个定义中有很多细微之处值得我们思考。所以,让我们深入探讨一下。

任意大量集合

当我们说“任意大量”时,我们指的是什么?当你为路由器编写软件时(接受你实际上会受到物理可寻址空间最大大小的限制),你不会(也不应该)关心有多少设备实际上连接到你的硬件,或者你需要多少路由来可靠地传递资源或请求。假设你正在为无线路由器编写系统软件。在这样做的时候,你告诉你的产品经理,他们的营销文案应该明确指出这款路由器最多只能连接四台电脑到互联网。你能想象任何产品经理会友好地接受这个消息吗?你很快就会在寻找新的工作了!网络必须能够根据用户的需求进行扩展。

几乎所有计算机网络的一个基本属性是设备无关性,也就是说,网络上的任何设备都应该在任何给定时刻假设不知道该网络上的其他设备数量或种类。事实上,一个程序或设备可能需要判断特定设备或软件是否存在于网络上,但网络连接本身不会传达任何信息。相反,它应该能够以通常为发送消息的通信协议标准化的格式发送和接收消息。然后,使用这些标准化消息,设备可以请求有关网络上其他设备的可用性、配置或功能的信息,而无需知道它期望在网络上可用的设备实际上是否真的可用。

确保任何给定设备发出的任何出站连接的接收端正确连接,或者接收设备已相应配置,这是支持你的软件的网络工程师的关心的问题。响应由你的软件发送的请求是接收软件作者的职责。显然,如果你在一个足够小的软件公司工作,这两个角色可能也会由你担任;但在一个足够成熟的职场环境中,你很可能会依赖其他人来为你处理这些任务。然而,当你的软件部署到网络设备上的时间到来时,仅仅因为连接到网络,你无法简单地获得这些责任是否得到妥善处理的信息。

设备无关性意味着网络不知道连接到它的是什么,因此,它也无法告诉你太多。网络的一个相关属性是,网络上的其他设备无法也不会通知你的设备或软件已经连接并成为资源。

最终,这就是任意大量设备的意思。技术上讲,一台单独的电脑构成一个由一个节点和零连接组成的网络(尽管,为了这本书的目的,我们只会考虑至少有两个节点,并且任何给定节点与网络中任何其他节点之间至少有一个连接的网络),但没有一个固定的节点数量上限,超过这个上限网络就不再是网络。任何任意数量的节点,从一到无穷大(或者可能的最大物理节点数量),只要这些节点之间以及与网络其他部分之间有有效的连接,就构成了一个有效的网络。

计算设备

现在我们知道我们可以在网络中拥有任意数量的计算设备作为节点,那么进一步审视一下究竟什么是计算设备是很有必要的。虽然这看起来可能很明显,至少最初是这样,但我们可以通过一个例子很快地识别出它变得不清楚的地方。

根据我们对网络的定义,我现在用来撰写这本书的设备可能完全符合自包含网络的标准。我有一个键盘、鼠标、显示器和电脑,它们都通过标准化的通信通道连接。在概念层面上,这看起来非常像网络,但直观上,我们可能会倾向于说这是一个由一个设备组成的网络,因此,实际上并不是真正的网络。然而,虽然我的电脑的非网络状态在表面上看起来很明显,但原因可能就不那么明确了。

这就是为什么我们需要明确说明,为了我们定义网络的目的,什么构成了计算设备。仅仅能够执行计算对于网络节点来说是远远不够的。在我的例子中,我可以告诉你,我的鼠标(一款相对高端的游戏鼠标)确实执行了许多复杂的计算,将激光传感器信号转换成方向输入,传递给我的电脑。我的显示器也必须进行相当数量的计算,将像素颜色的原始二进制数据转换成我每秒看到 60 或 120 次的渲染屏幕。这两个设备都通过可靠的、标准化的通信协议连接到我的机器上,但我不一定会倾向于认为它们是网络上的节点。当我的电脑连接到互联网或我的本地家庭网络时,它当然构成一个节点,但它的个别外围设备呢?我倾向于说不是。

那么,如果外围设备不是网络设备,那么它们缺失的基本属性是什么?开放通信。虽然显示器和键盘可以通过连接与各种其他设备通信,但它们可以通信的方式仅限于一个非常特定和有限的信号范围。这突出了在分布式系统和网络之间需要做出的重要区分虽然网络始终是分布式系统,但分布式系统不一定总是构成网络。

我的计算机是一个分布式系统;其组件可以独立于彼此运行,但它们以协调的方式运行以执行计算机的责任。然而,我的计算机显然不是一个网络。它缺乏设备无关性,因为每个组件都是明确配置为将其存在通知图中的下一个节点,以便它可以用于满足最终用户的需求。它也不是任意可扩展的。在任何给定时间,我最多只能将三个显示器连接到我的机器上,并且只有在非常具体的连接接口和组织条件下才能做到。当连接到网络时,我的计算机及其每个外围设备在概念上可以被视为一个单一的、原子的计算设备。因此,在网络中,我们可以指定计算设备是能够促进网络需求的东西。它通过设备无关的通信渠道开放接受和通信,以提供或利用该网络上的计算资源。

导航设备

在我们对网络的定义中,我指定了计算或导航设备。为了这本书的目的,导航设备是一个有效的网络设备,并且构成了我们网络中的一个节点。计算设备和导航设备(或资源)之间的有意义区别在于,导航设备不提供自己的资源,而仅仅存在是为了促进网络中其他设备的成功通信。简单的交换机或路由器就属于这一类别。这些设备仍然被编程以在网络中成功运行,但通常是在系统级别使用 C 或 C++进行,并带有板载固件。对这些中介设备的编程问题通常超出了这本书的范围,但我想要为了清晰和完整性而指出这种区别。

通信渠道

在网络的环境中,构成通信渠道的仅仅是网络中任何两个设备之间数据传输的共享接口。对通信渠道的物理实现没有约束,也没有对通过渠道传输的数据格式的要求,只需至少两个设备可以通过该渠道进行通信。

软件影响

当编写旨在利用或被网络上的其他设备利用的软件时,开发者需要考虑和约束的新问题,当只为本地系统编写代码时,这些问题是受到保护的。如何最好地处理这些问题将在后续章节中更详细地讨论,但就目前而言,考虑这些一般计算机网络方面的方面对我们编写的软件可能产生的影响是值得的。

设备无关性的影响

当我们谈论设备无关性时,我们假设我们的软件没有关于我们期望可用的资源实际上是否可用的信息。因此,回到我的计算机作为分布式系统而不是网络的例子,我可以可靠地编写本地程序,将信息打印或绘制到屏幕上。因为程序是在本地执行的,我可以相信我的操作系统将负责获取与我的监视器的连接,并将从我的程序堆栈帧到监视器显示端口连接的数据传输。

监视器不是分布式系统固有的资源;我可以在我自己的计算机上技术上执行任何一系列命令而不需要监视器。对于系统功能来说,这不是必要的,即使对于系统以我能够理解的方式运行来说,这是必要的。然而,我可以可靠地假设,如果监视器存在于系统中,我的软件将能够访问它们,因为我的操作系统充当了那些外围设备之间请求的智能经纪人。它将始终存在,并且能够提供任何我的软件需要使用的设备的状态信息。

当我的软件需要访问网络上分布的资源时然而,我不能再对这些资源的可用性做出假设。这就是设备无关性的核心,以及它如何影响网络程序。当我的计算机的操作系统作为智能经纪人时,我们不能假设网络也是如此。因此,验证资源的存在以及我们访问它们的能力,成为我们软件设计中的关键组成部分。并且我要指出,当我们有多个设备可以提供我们正在寻找的资源时,这项任务变得更加具有挑战性。

在那种情况下,网络中某些软件的责任是确定哪个特定设备最终为我们软件请求的资源提供服务。这项工作是由我们自己的程序作为其通信算法的一部分来完成,还是由部署到网络中的某些其他智能经纪人来处理以促进这种情况,这项工作都需要完成,以便我们的软件能够在这样的网络上可靠地运行。

为开放通信编写

当我们谈论网络上的开放通信时,我们是在谈论不同设备或软件组件之间的协作。这种协作给每个打算利用其他资源资源的开发者带来了一些责任;即达成一些通信标准的共识,并按照该协议作出响应。在管道中发送和接收数据的方式可能有功能上无限多种,但除非网络上的其他人同意以你决定发送的格式接收你的数据,否则这些都无法被认为是有效的. 你本质上是在对空喊。

可能性的广泛范围产生了一个需要标准化的需求,这个需求由同样广泛数量的组织来满足,包括万维网联盟W3C)和国际标准化组织ISO)。这意味着对你来说,你最终将负责理解你的软件应该遵守哪些标准,以便满足你项目的功能需求,并为你的产品的其他用户提供最大价值。在这本书中,你将了解到的一些常见标准包括通信协议,如 TCP、UDP 和 HTTP,以及寻址和命名标准,如 IP 寻址标准和域名系统。

拓扑结构和物理基础设施

在花费足够的时间讨论了什么是网络之后,我们现在应该考虑网络是如何实际实现的。本节将考虑工程师们为构建符合网络定义的系统而找到的各种解决方案。我们将讨论网络逻辑拓扑和物理拓扑之间的区别,然后检查前者的最常见例子。

物理和逻辑拓扑

就像地理区域的拓扑结构描述了该区域特征如何安排在该区域的面积上一样,网络的拓扑结构描述了该网络组件相对于彼此的排列方式。思考网络组织的方式有两种。正如本小节标题所暗示的,它们是物理拓扑和逻辑拓扑

物理拓扑描述了网络在真实空间中的物理连接和组织方式。它描述了建立连接的介质,连接本身的介质,设备在物理空间中的位置,以及节点之间连接的布局。它部分由网络的具体网络设备和这些设备允许的连接决定(我无法使用同轴电缆连接到只有以太网端口的路由器)。另外,物理拓扑本身决定了网络在性能、弹性和在某些情况下甚至安全性方面的最大能力。想象一下,所有试图访问我拥有的局域网LAN)的入站网络流量都必须通过防火墙进行安全过滤。如果我只暴露一个物理设备作为防火墙,我的网络将不会有很高的容错能力。然而,如果我有多个防火墙设备,每个设备服务于不同区域的请求,我可以大大提高我的容错能力。特别是如果每个设备都能在另一个设备因任何原因离线时作为备份,那么一个防火墙的物理拓扑提供的容错能力将低于多个防火墙的物理拓扑。

物理拓扑还描述了我在任何给定时间点使用的网络设备的多样性。这是我们的高级通信通道和节点或计算设备抽象被具体化的地方。物理拓扑不是用链路或连接来描述的,而是将连接描述为有线或无线。一个健壮的拓扑甚至可能指定使用的线缆类型,如同轴电缆或光纤电缆,这在大多数高速家庭互联网连接中很典型,或者像电信网络中使用的双绞线。

这也是我们的网络节点被固定到具体、特定设备的地方。我们不是使用计算设备,而是使用交换机、路由器、网桥和网络接口控制器NIC)。这些设备中的每一个都在网络上负责不同的任务或服务,并且它们中的某些或全部可能存在于任何特定的实现中。例如,在我的家庭无线网络上,我并不需要网桥,但很难想象整个互联网在没有使用我列出的每一个设备以及更多我没有列出的设备的情况下如何存在。

同时,网络的逻辑拓扑解释了网络中相关参与者之间的概念组织,以及他们可以或必须通过哪些连接路径与该网络上的任何其他参与者进行通信。然而,有一个重要考虑因素需要指出,物理拓扑并不一定直接映射到逻辑拓扑。回顾我们之前关于具有一个防火墙的物理拓扑与具有多个防火墙的物理拓扑的例子,我们可以说明物理和逻辑之间的区别。首先,让我们看看使用单个物理防火墙设备来限制对服务器资源的访问的初始、天真实现:

图片

初始的单防火墙物理拓扑

虽然完整的物理拓扑将定义和描述支持的连接类型,甚至可能定义前面图中表示的物理设备的型号,但这将满足我们的目的。接下来,让我们看看具有多个防火墙和未响应防火墙的故障转移策略的更具弹性的物理拓扑:

图片

很容易看出物理拓扑图为什么会不同,因为其中涉及不同的物理组件。更重要的是,尽管它们之间的差异很简单,但这两个物理之间的差异并非微不足道,因为第二个拓扑对网络所有者在成本、可靠性和性能方面有重要的影响。

然而,我们想要说明的是,在这两种物理实现中,逻辑拓扑保持不变。如果我们把单个防火墙(在第一种物理拓扑的情况下)和多个防火墙以及请求中继路由器(在第二种物理拓扑的情况下)在概念上视为进入我们内部网络的单一安全接入点,那么我们可以很容易地看到这两种物理拓扑如何映射到以下逻辑拓扑:

图片

观察这个图,你可能会注意到它与第一个图的物理布局非常相似,但与第二个图完全不同。这有助于说明逻辑拓扑可能与它的物理对应物一对一映射,但并不一定与它的物理实现一对一映射。

在本书的剩余部分,我们将专门关注网络的逻辑拓扑,因为这种抽象定义了我们将在构建的软件中处理的交互。设备制造商可以处理硬件组件,网络工程师可以努力满足物理性能限制。我们只需考虑我们需要什么资源,或者需要提供什么资源,以及我们如何满足这些需求。逻辑拓扑将足够满足这一点。

然而,我们网络的逻辑拓扑的具体组织可能对我们的软件实现有影响,并且存在各种常见的拓扑,它们各自有其优势和劣势,我们需要考虑,因此我们应该花些时间来做这件事。

点对点拓扑

让我们从最基本的概念开始。点对点拓扑学正如其名。网络中两个节点之间的一条单一逻辑连接。这种拓扑学定义了一个最小完整网络,也就是说,至少两个节点之间至少有一个连接。在实现成本方面,这是最低的,并且对部署到此类网络的软件的工程考虑影响最小。点对点网络可以在两个相关节点之间保持专用连接,或者根据需要动态建立该连接。任何直接的点对点通信都是你系统上点对点网络的一个实例,即使该点对点连接是在更复杂的逻辑网络拓扑上建立的,通信会话本身也是逻辑点对点拓扑的一个实例。

虽然点对点连接的成本可能非常低,但你可以从这些成本中获得的好处也非常低。点对点网络设计解决的问题范围有限,通常与一个直接的问题相关。

线性拓扑(菊花链)

线性拓扑学正如其名——一条线!它是点对点拓扑学最原始的扩展,从概念上讲,是最简单的逻辑拓扑之一,并且在物理实现方面通常也是最便宜的。在线性网络拓扑中,我们扩展我们的点对点模型,使得在任何给定时间只有一个节点连接到最多两个其他节点。这里的优势显然在于物理实现成本(即使具有高可靠性,这种配置也只能变得如此复杂)。然而,缺点也同样明显。从一个节点到除其最近邻节点之外的其他节点的通信将需要中间节点做一些工作,调查目标请求,并确定它们是否适合处理该请求,如果不适合,知道将请求传递给未发起请求的邻居。

注意,不要将请求返回给最初发起请求的邻居。如果节点通过简单地向两个邻居盲目提交请求来响应,你将陷入在两个节点之间提交和重新提交请求的无穷循环。在任何一对节点中,至少有一个节点必须足够意识到不要向其发起者重新提交请求。这突出了这种拓扑最重要的缺点。具体来说,它需要节点与其在网络结构中的概念位置紧密耦合。

虽然这一切并不特别复杂,但你已经可以看到你的网络逻辑组织如何影响你的网络代码设计。随着拓扑复杂性的增加,这一点将变得更加明显。

总线拓扑

总线拓扑是一种网络拓扑,其中网络上的每个节点都通过单个通信通道连接到网络上的每个其他节点,如下面的图所示:

图片

从节点发出的每个连接都通过简单的连接接口连接到所有节点之间的共享连接。在总线拓扑上发送的任何数据包都将与网络上传送的每个其他数据包在同一个总线上传输,并且总线上的每个节点都负责确定它是否是服务该数据包所携带请求的最合适的节点。类似于之前描述的线性网络,总线拓扑上的数据包必须包含有关请求目标节点的信息。

就像每个较低复杂性的拓扑一样,总线拓扑具有明显的优点,即实施成本低,以及相对较低的开销。然而,希望我之前的描述有助于描述与这种特定网络拓扑相关的特定挑战。因为所有网络通信都通过单个通道进行,所以即使在理想情况下,所有流量也受该通道带宽的限制。特别是,在总线拓扑上,健谈的软件表现不佳,因为它往往会垄断节点之间的连接。

此外,由于整个网络中只有一个通信通道,因此该通道成为网络的单一故障点。如果中心总线断开,那么每个节点将同时被隔离。

星型拓扑

最后,我们开始考虑在企业网络中更常见的网络拓扑。星型拓扑以产生类似星形的形状排列,每个外围节点通过单个通道连接到中心集线器节点,如下面的图所示:

图片

星型拓扑的中心节点充当所有外围节点之间的通信代理。它通过直接、点对点连接接收并转发其外围节点中的每个节点的请求。

这种拓扑的优点在于将外围节点或它们与中心节点的连接故障隔离到特定的节点。其他每个节点都可以通过任何一个节点故障来维持与网络中所有其他节点的连接。它至少在概念上(如果不是在物理上)是无限可扩展的。向网络添加节点的唯一必要任务是添加新节点与中心节点之间的链接。

希望到讨论网络拓扑的这一阶段,你已经已经识别出这种方法的明显缺点。如果中心节点离线,整个网络就会消失。从任何一个外围节点的角度来看,中心节点的丢失意味着整个网络的丢失,因为所有通信只能与中心节点进行。

在阅读我的描述时,你可能也已经意识到,某些网络拓扑可以被分解为具有完全不同拓扑的子网络。由任何给定外围节点和星型拓扑的中心节点定义的网络本身就是点对点网络的单个实例。同样,由任何两个外围节点和星型拓扑的中心节点定义的网络在技术上是一种线性拓扑(它本身是总线拓扑的一种特殊实现)。通过逻辑上扩展这些简单的图到更大的组合拓扑,我们可以描述任何可能为软件编写的网络类型。

环形拓扑

环形拓扑与线性拓扑非常相似(正如我之前提到的,线性拓扑在技术上是一种总线拓扑的实现),只不过在环形拓扑的情况下,端点最终是连接在一起的,通信是单向的,如下面的图所示:

环形拓扑

这种特定的网络拓扑的优点可能不会立即显现,但网络中的每个节点都作为链中前一个节点的对等节点,因此不需要任何请求代理或特定的通信软件或硬件。这可以大幅度降低您的网络管理成本。

其缺点与之前每种实现方式相似,即一旦链中的某个链接断裂,网络基本上就变得毫无用处。技术上,由于环形拓扑的单向通信模式,链中断裂链接之后的节点仍然可以与网络中的每个其他节点通信,并保持一定程度的运行。然而,由于任何响应设备都无法将它们的响应传回原始节点,因此链上的所有节点之间的通信将是一向的。我很难想象一个场景,其中网络上的设备可以通过严格的一向通信有意义地与分布式系统交互。

另一个不那么明显的缺点是,整个网络的最大性能将受到网络中任意两个节点之间性能最差链接的限制。这是因为任何两个节点之间请求-响应的往返通信必然要穿越整个链。

网格拓扑

网格拓扑是目前使用中最具弹性和最常见的一种网络拓扑。其原因是它在组织方式上几乎完全是任意的。网格拓扑简单描述了任何非正式的连接拓扑,其中一些节点通过单点对点连接与其他节点相连,而一些节点可能与其他多个节点有多个连接。本章开头的原始图示,如下所示,在技术上是一个网格网络拓扑:

如果你忘记了。

你会注意到,前一个图中的节点与其他网络中的节点有从一到三个直接连接。这可以在必要时提供其他网络拓扑的一些弹性,而不会产生它们的成本。由于网格网络没有明显的规范,除了它没有完全实现我们讨论过的任何其他网络拓扑之外,它可以包括节点之间具有任意连接度的网络,包括完全连接的网格网络。

完全连接的网格网络

一个完全连接的网格网络是这样的,其中每个节点都与网络中的每个其他节点有直接连接,如下面的图示所示:

如果这个图看起来有点拥挤,你已经注意到了完全连接的网格网络最大的缺点。几乎不可能扩展到某个点以上,因为网络上的每个新节点都需要与网络中之前连接的每个节点建立连接。对于每个要添加的新节点,连接的数量呈二次增长。在网络中超过几个节点后,物理上变得几乎不可能。

然而,一个完全连接的网状网络的成本非常高,但它带来了最稳定和最具有弹性的拓扑。没有节点需要负责数据包转发或请求切换,因为不应该存在两个节点间接通信的上下文。任何一个节点或节点之间的连接都可以中断,而网络上的其他每个节点都可以保持完整的连接性,没有任何性能损失。两个节点之间单一薄弱的连接对任何其他两个节点的性能没有影响。从拓扑的角度来看,完全连接的网状拓扑是坚不可摧的。它也往往过于昂贵,因此在除了最小和最简单的情况下,并不常见。

混合和专用拓扑

如我之前所述,你可能需要访问资源的多数大型网络都是由多个拓扑结构组合而成的,通常被称为混合型。例如,一个星型拓扑,其中外围节点之一也是线性拓扑中的一个链路,就是这种混合型拓扑的一个例子。

其他类型的拓扑实际上是我们在本节中讨论的拓扑的变体。例如,一个线性网络拓扑的节点同时也是次要线性拓扑的接入点的情况构成了一个树型拓扑,简单地说,就是一个分层线性拓扑。这些结构的细节不如知道它们的存在重要,以及根据你打算部署到网络上的软件的性质,你知道对于它们有成本和考虑需要做出。说到这些成本...

在网络上分配资源对软件的影响

到目前为止,所有关于设备无关性和开放通信的讨论可能听起来非常抽象。你可能正在阅读这段内容,心想,嗯,那又怎样呢?我不用编写网络交换机的代码,这可能确实如此。确实,你不必使用.NET Core 在 C#中编写网络交换机的代码,因为这远远超出了框架的范围和能力,因此,这本书的范围。然而,网络不可预测性对你的软件的影响将是相当大的,并且这将对网络堆栈的任何部分编程都是如此。所以,让我们看看你的代码应该如何为分布式系统做好准备。

安全性

我首先讨论了最明显且复杂的问题,因为我认为这最有意思。在专业环境中编写软件,总体上要求你编写安全的应用程序。即使你的工作没有明确要求,我也会不遗余力地争论,作为一个工程师,你有道德义务编写安全的软件,无论是否明确要求。这可能是一项艰巨的任务。特别是,因为确保软件安全始终是一个移动的目标。然而,重要的是要记住,让你的软件作为一个资源对广泛的善意消费者有用,这本质上会使你面临恶意意图。

这就是设备无关性和开放通信变得极其重要的地方。设备无关性意味着你无法合理地确信一个恶意行为者没有访问到你可能认为已经在上游托管环境中得到保护的网络安全。你将只会在你的软件的访问点上看到和处理请求。开放通信意味着你可能会收到许多格式不正确的请求,你将尝试最初解析它们,然后确定你无法解析,并将它们丢弃。这种首先读取你给出的消息的需要,以便知道它们是否是你关心的,因为这最终会使你面临恶意命令或代码。

幸运的是,正如我们稍后将要探讨的,.NET Core 库提供了大量的强大安全组件,直接开箱即用,而获取和利用加密库以及请求清理算法的工作,只需知道在源文件顶部包含哪些using语句即可。

通信开销

在网络编程中,你面临的另一个最明显的问题是如何处理开放通信的开放性。这一特性意味着你将不得不花费大量时间熟悉不同通信协议的具体消息标准(我将在本书中深入探讨)。将数据压缩成格式良好的数据包,并添加适当的头部信息,以便告诉你的软件何时开始和何时停止从你的连接中读取以获取完整的、不间断的二进制流,并将其转换回代码中的有意义的数据结构,这需要大量的组织工作。描述这个过程本身就是一件头疼的事情。

在本地托管代码中,你可以通过在消费者应用程序之间共享库的 DLL 来方便地共享数据结构的合同。你可以通过文件系统本身与系统上的其他软件进行通信。你可以仅使用系统文件访问 API 来公开你的消息中存在多少数据,数据的编码方式是什么,并通过随机访问文件来公开这些信息。

在网络中,您必须提供足够的上下文,以便其他人能够通过消息本身理解您的信息。您必须以消费者在获得上下文之前能够理解的方式传达这种上下文。再次强调,.NET 库将在这里为您提供帮助,提供易于使用的类,这些类公开标准化的头和消息格式,以保持您的代码清晰,避免这种开销。

弹性

我在讨论网络拓扑时提到过这个概念几次,但在这里特别提一下,因为您将负责从连接的两端维护您应用程序的弹性。如果您的应用程序在网络中利用任何资源,您必须考虑到这些资源在网络上可能实际上不可用的可能性。在这种情况下,您需要编写代码,以确保即使在出现此类故障的情况下,代码仍然能够以可靠和稳定的方式响应用户。

同样,如果您的软件是您网络中其他系统的依赖项,并且它崩溃了,您最好的做法是制定一个策略来从这种故障中恢复。目前有几种可行的解决方案来通知您的下游消费者您已经从故障中恢复过来,每种方案都有其自身的优势和成本,包括资源使用或开发时间,我们将在本书的后面讨论其中几个。然而,现在,在设计解决方案时考虑这一点并相应调整设计就足够了。

异步

与弹性和开放通信的概念紧密相关的是异步通信的概念。这对于在网络程序中保持任何可靠性能至关重要。简单来说,这是处理当结果变得可用时,系统内部代码未提供的结果的概念。

当您的程序需要从其网络上的另一个节点请求一些资源时,发送初始请求并接收一些有意义响应之间存在往返时间。在这段时间内,您的程序在技术上可以锁定并等待响应返回,但现实中没有理由让它锁定并等待。然而,即使我们的程序可能已经继续前进,决定不对初始请求的响应等待,我们通常希望退一步处理当响应通过网络返回时的响应。这个过程是异步编程,它是开发合理性能网络软件的关键。

您可能遇到的一个异步编程的明显例子是在网络编程之外,即在编程响应式用户界面(UI)时。通常,UI 组件需要积极监听并响应用户输入,无论用户何时选择与之交互。由于程序员永远无法确切知道用户何时会按下他们提供的按钮,他们必须在最早的时刻响应用户输入,而不必在等待响应时保持资源处于等待状态。

.NET Core 中的网络对象和数据结构

从零开始编写网络代码可能听起来是一项艰巨的任务,在某些情况下,这确实如此。然而,.NET Core 类库提供了帮助。有了这些库,您将能够开始使用干净的抽象来处理复杂且经常令人沮丧的网络协议和标准,从而在分布式网络上开始生产有价值的组件。

使用 System.Net

using语句可能是您可以在包含任何类型网络代码的源文件中包含的最重要语句之一。System.Net 命名空间是一套通用的.NET Core 类和实用工具,用于编程大多数协议和网络化系统行为。它是您在阅读本书时将使用的最常见网络类的根命名空间。

该命名空间包括以下类:

  • 域名解析和 DNS 访问

  • 抽象基类WebRequestWebResponse,以及这些类的常见实现,包括FtpWebRequestHttpWebRequest

  • 互联网协议IP)解析和定义

  • Socket 实用类定义

  • 许多其他类

在您开始开发更复杂和强大的软件时,这个命名空间中的类将成为您的日常必需品,您应该花相当多的时间熟悉 System.Net 命名空间封装的功能和功能。

专注于子命名空间

虽然 System.Net 命名空间封装了大量的网络编程有用类,但在 System.Net 包层次结构下还有许多有用的子命名空间,您也应该熟悉,如下所示。

  • System.Net.Http:一个实用类,用于在您的.NET Core 应用程序中提供符合 HTTP 标准的消息和交互

  • System.Net.NetworkInformation:提供有关网络中主机节点的流量数据、地址信息和其他详细信息

  • System.Net.Security:提供可靠的安全网络通信和资源共享及访问

  • System.Net.Sockets:提供对 WinSock 接口的 netcore 托管访问

在本书的整个过程中,我们将更深入地探讨这些命名空间以及它们公开的类。但到目前为止,我想让你了解.NET Core 开箱即用的最常用、最有价值的网络类。

他们的软件是开源的,并且在这里有详尽的文档描述:

docs.microsoft.com/en-us/dotnet/api/?view=netcore-2.1

对于考虑从事网络 Web 开发职业的人来说,花时间检查这些类无疑是值得的。

一个全新的计算世界

一件软件能提供的最大价值受限于能够利用该软件的下游消费者数量。将你的软件部署在广泛可用的网络上可以增加它对你的组织或消费者社区的整体影响。本节的最后部分将探讨这一转变所开启的应用类型。

长距离通信

感谢文件传输协议FTP)和简单邮件传输协议SMTP)等通信协议,在发送者发送后几秒钟内,就可以在地球的另一端写入或接收一封信。这一工程技术成就得益于支持整个互联网的强大、弹性的物理基础设施,到本书结束时,目标是让你具备开发这类应用所需的技能。

使用对等通信协议,我们可以构建网络化多人游戏系统,用于实时、高强度、高动作的游戏。

分享功能,而不是代码

使用如 RESTful API 设计以及 HTTP 消息格式等明确的标准,你可以编写稳定、干净、良好隔离的 Web API 项目,这些项目允许各种消费者按需请求你编写的功能。你不必直接共享代码,而可以通过良好的文档化通信渠道,仅允许对所拥有的业务流程进行概念性访问,从而保持你的抽象抽象化。

摘要

本章深入探讨了少数几个主题。我们给出了一个仔细考虑的网络定义,然后考虑了该定义的关键组成部分如何影响我们的网络程序开发策略。我们考虑了物理网络拓扑和逻辑网络拓扑之间的区别,然后探讨了我们将要工作的最常见逻辑拓扑。最后,我们考虑了在开始编写第一个网络程序时,我们将不得不做出的新设计决策和策略,以及.NET 类将如何帮助我们轻松且干净地实现这些策略。

在下一章中,我们将迈出网络编程的第一步,我们将探讨资源定位和寻址。

问题

  1. 网络的定义是什么?

  2. 物理拓扑和逻辑拓扑之间的区别是什么?

  3. 本章中唯一讨论的、没有暴露给潜在的单点故障的网络拓扑是哪一个?

  4. 在网络上实现通信通道的一些物理设备有哪些?哪些物理设备作为节点?

  5. .NET Core 提供的最常见的网络库和类的根命名空间是什么?

  6. 至少列出 System.Net 命名空间暴露的四个类。

  7. .NET Core 为可靠和稳定的网络编程提供了哪些其他四个最常用的命名空间?

进一步阅读

想要获取有关网络的更多信息,请查看由 Steven Noble 编写的 Building Modern Networks,通过 Packt Publishing 提供。这是一本了解现代网络工程师面临的挑战的绝佳资源,也是对本章讨论的概念应用的深入探讨。

第二章:DNS 和资源定位

上一章花费了大量时间剖析网络,以至于大多数读者可能永远不会再次考虑它们。在本章中,我们将探讨这些概念,并查看它们的实际应用。我们将从查看互联网规模上解决资源分配问题开始。如果没有能力从您的网络中访问资源,分配资源是没有用的,本章将向您介绍使这成为可能的技术系统和标准。在我们调查这些主题时,我们最终将动手编写一些代码。在本章中,我们将查看软件示例,让您熟悉利用.NET Core 框架中可用的工具。

本章将涵盖以下主题:

  • 如何在从您的家庭 Wi-Fi 网络到互联网的任何设备上公开和提供数据和服务

  • 用于识别您网络不同级别的资源的特定标准,从 URL 和域名到设备名称和本地目录访问

  • 使用.NET Core 中的 DNS 类访问外部资源并解决网络内数据请求

技术要求

要跟随本章内容,您需要拥有 Visual Studio Code 或 Visual Studio Community Edition。这两个都是可以在 Visual Studio 网站上免费下载的,网址为visualstudio.microsoft.com/

观看以下视频,了解代码的实际应用:bit.ly/2HVSHad

我们还将使用.NET Core 命令行界面CLI)。这将使我们能够直接从命令提示符调用一系列程序。为了跟上进度,您需要确保您已经在本地上安装了.NET Core SDK,可以从www.microsoft.com/net/download下载。

源代码可在github.com/PacktPublishing/Hands-On-Network-Programming-with-C-and-.NET-Core找到。

海量信息中的针——互联网上的数据

使用网络上公开的资源的第一步是找到它们。虽然这个问题在只有四台计算机的局域网中很容易解决,但您可以想象当您的环境扩展到数十亿个活跃连接到互联网的设备时,这会变成多么艰巨的挑战。为了确保网络广播请求的可靠交付,网络上的每个设备都必须有唯一的地址,并且任何想要与设备通信的软件都必须知道目标设备的地址。考虑到这一点,让我们看看在规模上是如何解决这个问题,并考虑我们如何可以将这种解决方案应用到我们更相关的本地用例中,使用.NET Core。

第一网络地址

正如我提到的,网络上的每个设备都必须具有唯一的可识别性,这样在任何给定时间,针对特定设备的请求都可以被送达。同样,唯一的地址意味着任何响应都可以可靠地返回到原始设备,无论两个设备之间有多少网络节点。如果有人编写了一个解决你问题的服务,只有当你实际上可以使用该服务时,它对你才有用。这意味着要么知道托管该服务的设备的地址,要么至少知道如何询问地址。

幸运的是,这个问题在互联网的最早形态出现之前就已经得到了解决。我当然是指电信网络,以及它们为地址和地址查找建立的成熟系统。在早期的电信网络中,工程师需要为需要唯一标识的大量设备解决问题。然而,无论他们提出什么系统,都必须展现出以下特性,才能在长期内保持其可行性:

  • 可用性:该系统将由任何想要通过电信网络进行通信的人使用,因此该系统不能过于复杂。

  • 可扩展性:最终目标是连接全国每个家庭,使用一个单一、统一的网络。向该网络添加节点的解决方案需要随着人口和地理区域的扩大而增长。

  • 性能:如果电话通话需要像邮政服务一样长时间来回传递信息,没有人会使用它。虽然这种情况永远不会发生,但客户在速度和可靠性方面所能容忍的极限肯定存在。

幸运的是,他们提出的解决方案是一个可持续的解决方案,它已经扩展并运行了几十年。

电信工程师设计的系统是电话号码系统。通过为电信网络上的每一部电话分配 10 位地址,工程师确保了一个能够唯一标识高达 9,999,999,999 个设备的网络。再加上两位数的国家代码,理论上这个网络可以支持高达万亿个设备,或者为地球上每个人提供超过 100 个独特的地址,还有大约 240 亿个地址是多余的。

你可能已经注意到,我指出电话号码系统在理论上只支持高达万亿个设备。然而,电信寻址系统的一些局限性使得达到理论上的最大值变得困难。正如你们大多数人所意识到的,美国电话号码的前三位数字被称为区号。这些数字最初是由电话所在的特定地理位置决定的。这有助于快速路由号码,但也意味着电信网络能够支持的设备总数受到这些设备在地理区域分布的限制。在一个区号内,理论上最多只能有 9,999,999 个可能的设备;这几乎超过了纽约市总人口。

我在这里过于简化了解决方案,但这个权衡为电信工程师提供了一个简单机制,以便尽可能快地将物理电话的可能范围缩小到地址所解析的地址。通过仅检查前三位数字,就可以将电话路由到一个实质上受限的区域。这通过将语义意义应用于句法标准提供了明显的性能优势。电话号码系统简单地指定一个物理电话通过 10 位地址进行寻址。这是一个句法要求。然而,该地址前三位数字所传达的地理信息是一个语义标准。这些前三位数字中蕴含着一种基本含义,它传达了整个地址应该如何处理。

这个数字寻址系统的可扩展性有助于网络设备准确引导流量。然而,对于人类用户来说,一串任意排列的七到十个数字可能难以记忆,且使用时容易出错。那些在智能手机和内置联系人列表出现之前成长起来的人可能还记得,需要有一个罗罗德克斯(Rolodex)或联系簿来组织并随时备好那些经常需要但难以记忆的电话号码。然而,通常情况下,你需要拨打一个你不方便存储的电话号码。这时电话簿就派上用场了。它提供了一种简单的方法,将易于记忆的唯一标识符(特别是全名和街道地址)映射到对应的网络友好地址(他们的电话号码)。

所有这些特性综合起来,为电信行业提供了成功网络实施的标志:易用性(通过电话簿的简单性)、可扩展性(通过广泛的合法地址范围)和性能(通过将语义意义嵌入地址的语法标准中实现的改进路由速度)。然而,到现在,你可能已经正确地猜到了,我们不会用 C#为电话网络编程。那么,让我们看看电信工程师做出的设计决策是如何转化为现代计算机网络的。

DNS – 现代的电话簿

正如我暗示的那样,设计现代计算机网络工程师面临的问题与电信工程师相同:定义一个标准化的语法,以便他们可以为网络上的每一台设备创建唯一的地址。幸运的是,尽管如此,那些计算机网络工程师可以站在巨人的肩膀上(用艾萨克·牛顿爵士的话来说)。

电话号码系统表明,一个简单的固定长度数字地址系统可以快速解析和路由。此外,纯数字地址可以用二进制表示。这意味着不需要额外的标准来一致地表示非数字字符。然而,这在易用性上是一个权衡。用于使用这些地址的软件仍然需要由人类编写。正如经常发生的那样,对计算机来说更容易(且性能更好)的解决方案,对人类来说却更困难。这意味着计算机网络工程师需要设计自己的电话簿。幸运的是,他们做到了。

在所有现代计算网络上,你可以通过可靠的固定长度数字地址来定位外部设备,那就是互联网协议(IP)地址。同时,你可以从可靠的系统中获取特定设备的地址,那就是域名系统(DNS)。这个 DNS,就是计算机网络中的电话簿。它本质上是一个复杂的、分布式的映射,将可读的域名映射到其底层的 IP 地址。

互联网上的每一台设备(或任何本地网络)都将拥有自己的 IP 地址。然而,关于如何确定该 IP 地址的具体细节,以及这些地址语法的优势和局限性,将在本书的后续章节中进行讨论。目前,我们关注的是这些地址如何通过更有意义、易于阅读的域名来解析。在接下来的这一节中,我们将全面探讨每次通过 URL 查找资源时这一过程是如何发生的。

网址、域名和设备地址

在对网络中资源定位必须解决的主要问题有了一个坚实的理解之后,让我们更详细地看看这些问题是如何被解决的。首先,让我们考虑一下,为什么相对较少出错的 URL 命名约定使得访问远程资源更加容易。然后我们将探讨 DNS 如何成为 URL 或域名可用性和 IP 地址速度及可靠性之间的桥梁。

URLs – 用户友好的寻址

我到目前为止一直采用一个非常基本的解释方法来解释定位资源的本质。我当然知道,你们中的大多数人可能至少对在至少一个高级环境中它是如何发生的有一些了解。实际上,你们很可能使用了一个网络浏览器来找到这本书,并且对浏览器地址栏中串联在一起的看似随机的单词和字母实际上是 URL 的事实有一个相当清晰的认识。然而,我确实遇到了一些开发者,他们对 URL 的构建和使用方式感到惊讶。所以,就像我们到目前为止所做的那样,我们将从对 URL 究竟是什么以及我们如何使用它们来找到我们所需的东西进行一个非常基本的解释开始。

统一资源定位符URL)是一个普遍认可的标准,用于(不出所料)在网络上定位资源。它通过指定检索资源的方式以及检索它的具体路径来实现这一点。它通过指定定义任何资源特定物理位置的特定组件的顺序和分隔符来实现这一点。这个规范一开始可能看起来很复杂,但随着我们对组件及其所承担的责任进行详细阐述,它将变得更加直观。

URL 组件

每个 URL 都以一个方案开始,该方案指定了定位资源时应使用的传输机制或位置类型。你可以指定一系列普遍有效的方案,包括 http、ftp,甚至对于本地托管资源,还有 file。方案后面总是跟着一个冒号(:)分隔符。在方案指定之后,一个 URL 可以包含一个可选的权限指定,它本身包含一小部分子组件。

权限组件

权限有一个指定的前缀:两个连续的斜杠(//)特殊分隔符,其存在表示随后应按照 URL 权限的规范进行解析。这个前缀后面可以跟访问凭证或用户信息,它将可选的用户 ID 和/或密码传输到目标主机。如果包含这些值,它们将始终用冒号(:)作为分隔符彼此分开,并用一个 at 符号(@)分隔符与权限组件的其余部分分开。

无论访问凭证是否作为权威机构的一部分,它都将始终包含主机域名。这始终跟随双正斜杠(//)前缀,或者在存在访问凭证的情况下,跟随(@)分隔符。主机域名指定了托管资源的硬件的物理地址。它可以指定为注册的域名,或者硬件的底层 IP 地址。

最后,权威机构可能会指定主机上的监听端口。这由冒号(:)字符与主机域名或 IP 地址分隔,并指示硬件上请求指定资源的唯一端口。

路径组件

路径组件指定了一系列路径段,请求必须通过这些路径段到达要搜索的资源。路径的每个段都由一个正斜杠(/)字符单独分隔。在技术上,可以将空段作为路径的一部分,从而产生两个连续的正斜杠字符(//)。

查询组件

在路径的最后一个部分之后,URL 可能包含一个可选的查询组件,由问号字符(?)分隔符表示。查询组件允许用户为请求的资源指定额外的参数,以获得更具体的结果。每个不同的查询都采用参数的形式,由等号(=)分隔符,以及查询参数的请求值。最后,每个参数由分号(;)或和号(&)分隔符与任何两个查询参数及其值之间的分隔符。

片段组件

URL 的最后一部分,至少通常情况下,是片段组件。它是一个可选的 URL 字符串部分,其存在由保留的井号或哈希(#)前缀表示。片段组件通常用于标识最终返回的资源的一个子组件,并且通常由网络浏览器用于导航到搜索的 HTML 文档的特定片段。

将所有这些放在一起

明确列举了 URL 的所有相关组件后,我们可以通过基本的语法规范来简化问题。每个 URL 最终都可以分解为以下结构,其中可选组件用方括号[]表示:

scheme:[//authority/]path[?query][#fragment]

因此,在这里,我们可以清楚地看到,URL 的唯一必需组件是方案和随后的冒号分隔符,以及路径。其他所有内容都是可选的,并且你会注意到每个可选组件在 URL 中的存在都由其独特的分隔符字符表示。当然,我们还可以扩展以下组件。

权威指定

正如我们之前指定的,权威机构可以分解如下:

//[access_credentials][@]host_domain[:port]

因此,如果存在权威组件,它将始终以双斜杠(//)分隔符为前缀,并且始终包含主机域名。同时,访问凭证组件也被分解如下:

[user_id][:][password]

在这里,只需要一个组件。然而,如果任一组件存在,那么分隔访问凭证和主机域的(@)字符就成为一个必需项。而且,如果user_idpassword功能都存在,那么两个组件之间的冒号(:)分隔符也将是必需的。

查询规范

最后,对于查询组件,有一个明确的规范说明其如何组成。它可以分解如下:

?[parameter=value][(;|&)parameter=value]...

附加分隔符和键值对的序列可以一直延伸到有效 URL 的最大允许长度。

通过遵循这些语法规范,你可以将你遇到的任何 URL 分解为其组成部分,并有效地利用它来访问它所标识的资源。

URL 作为 URI 的子类型

我们在本节的大部分内容中专门讨论了 URL。然而,你可能没有意识到的是,URL 实际上是一种称为统一资源标识符URI)的单个、特定类型的对象,它是一系列遵循良好定义语法的字符,可以在网络上唯一地标识资源。

URL 和 URI 之间的区别是微妙的,几乎完全是概念性的。最简单的方式来描述这种区别是,通过使用 URL,我们保证能够识别和定位请求的资源。给定一个简单的 URI,我们保证的唯一能力是识别它,即区分该资源与任何其他任意资源。

实际上,URL 和 URI 这两个术语经常被互换使用。这是因为,由于 URL 是 URI 的一种特定类型,因此将其描述为 URI 总是有效的。同时,通常将 URI 描述为 URL 就足够了,因为在网络环境中知道资源的特定标识通常足以定位该资源。

如果你想知道我为什么要提及这样一个看似微不足道的话题,那是为了清晰起见。在本书的整个过程中,我将会一致地谈论资源是通过其 URL 来标识的。然而,.NET Core 暴露的用于构建、分解和利用这些地址的类是以更通用的 URI 规范命名的。事实上,让我们现在快速看一下这个类。

System.Net.UriBuilder

如果你已经完整地阅读了关于 URL 规范的定义,你可能想知道如何在实际代码中利用这些知识来访问资源,尤其是当你已经知道具体位置时。亲爱的读者们,请允许我介绍UriBuilder类!

System.Net命名空间中,UriBuilder类是一个用于生成Uri类实例的工厂类。它为用户提供几个重载的构造函数,允许逐步指定更多有效 URL 的组件。它还提供了访问器,用于表示每个组件的属性。最后,它提供了一个函数,可以从组件部分生成格式良好的Uri类实例。

让我们从一个非常简单的例子开始。我们将使用UriBuilder来组合一个只包含SchemeHost组件的Uri实例,如下所示:

public Uri GetSimpleUri() {
   var builder = new UriBuilder();
    builder.Scheme = "http";
    builder.Host = "packt.com";
    return builder.Uri;
}

使用这种方法,我们可以看到UriBuilder类如何将我们指定的组件部分组合成一个格式良好且语法正确的Uri,如下面的代码片段所示:

using System;
using System.Net;
using System.Threading;

namespace UriTests {
    public class TestUriProgram {
        public static Uri GetSimpleUri() {
            //...
        }

        public static void Main(string[[ args) {
            var simpleUri = GetSimpleUri();

            Console.Warn(simpleUri.ToString());

            Thread.Sleep(10000);
        }
    }
}

通过运行此程序,当您的控制台开启十秒钟后,您应该看到http://packt.com输出,然后程序关闭并终止。

在这里,我们不需要指定 URL 的http组件后面应该跟一个冒号字符。我们没有提到我们指定的主机前面应该有//前缀字符。UriBuilder类为我们做了这件事。这个工厂类为我们提供了一个干净的方式来逐步构建一个更具体的期望位置,而无需我们作为开发者,总是要在脑海中记住分隔符、前缀和后缀的琐碎细节。

在这个例子中,我们利用了UriBuilder类提供了对所有属性的公共get访问权限的事实,以便封装Uri的每个组件。然而,如果您在构造时知道它们的值,您也可以通过一系列重载的构造函数应用许多这些属性。

UriBuilder类有七个重载的构造函数。我们已经看到了默认构造函数,它不接受任何参数,但现在让我们看看一个利用每个构造函数的程序,并看看它们提供了什么。鉴于我们知道我们打算查找的传输方案和域名,我们可以简化我们的初始方法,如下所示:

public static Uri GetSimpleUri_Constructor() {
    var builder = new UriBuilder("http", "packt.com");
    return builder.Uri;
}

通过这个更改,我们的TestUriProgram的输出将打印出我们之前看到的相同字符串,但生成该输出的代码大小只有原来的三分之一。在可能的情况下,我建议使用构造函数重载来实例化UriBuilder类。这样做可以缩小我们的代码体积,并在实例化类时使我们的意图更加明确。在可能的情况下,始终使您的代码更加明确。

主机 - 域名和 IP

在我对 URL 的主机部分的描述中,我指定了主机域名可以是域名或 IP 地址。正如我之前提到的,IP 地址是路由硬件和软件用于在网络中导航到资源的基础数字地址。它是特定位置上特定硬件的唯一 ID。然而,域名是由人类可读的单词和字母数字字符组成的字符串,用于使寻址更加容易和一致。它比原始 IP 地址更一致、更容易记忆且更不容易出错。然而,有趣的是,域名及其 IP 地址在功能上是可互换的。在任何可以使用一个的地方,你都可以安全地替换另一个。

由于 IP 地址可以直接由网络传输层解析,并且在使用过程中不需要在节点之前进行解析,所以我们现在暂时忽略它们。我们将在本书的后面部分探讨使用设备 IP 地址的语法、限制和优势。然而,目前我们更关注的是如何首先找到 IP 地址。这就是为什么,至少在本章中,我们只关注域名以及它们是如何通过 DNS 进行解析的。

我敢打赌,在阅读这本书的所有人中,没有一个人认识一个没有在浏览器地址栏中输入google.comen.wikipedia.org的人。我们使用域名的普遍性是毋庸置疑的,然而,我们中的大多数人并不知道它们是如何创建或使用的。即使对我来说,直到我被明确要求编写用于解析内部网络上域名的软件时,我才终于花时间理解是什么让这个系统工作。那时,我了解到 DNS 服务器网络如何促进人类用户的网络使用。虽然我之前已经提到过,现在是时候更深入地考虑 DNS 是什么,以及我们如何使用它了。

DNS 是一个分布式的、去中心化的权威服务器网络,它托管着所有子域名服务器以及可以被该权威服务器解析的任何域名。任何已经注册到认证域名注册商的域名,并且符合域名语法标准(且尚未被注册),都被认为是有效的。有效的域名被添加到由权威服务器托管的分布式注册表中。在您的计算机和您希望使用有效注册域名与之交互的任何其他网络节点之间,您的请求将必须与这些名称服务器中的一个或多个进行交互。

每个服务器将检查提供的域名,并在其自己的域名和 IP 地址映射目录中查找该域名。自然地,服务器将首先确定该服务器是否能够解析该域名,或者至少通过其下属服务器之一进行解析。如果是这样,权威服务器只需将请求中的域名替换为它所映射的 IP 地址,并相应地转发请求。然而,如果当前服务器无法解析域名,它将沿着名称服务器层次结构向上转发,直到根名称服务器,或者直到域名被解析。

C#中的 DNS

有时,在软件的上下文中识别域名的基础 IP 地址是必要的。为此,.NET Core 提供了System.Net命名空间中的静态Dns类。使用Dns类,我们可以访问由能够解析给定名称的最近下游名称服务器返回的目录信息。我们可以请求IPHostEntry类的实例,其中包含 DNS 条目的所有相关目录信息,或者简单地请求一个 IP 地址数组,这些 IP 地址已注册以解析针对域名的请求。

要查看此操作的实际效果,只需在示例程序中调用静态Dns类公开的任何方法,如下所示:

using System;
using System.Net;
using System.Threading;

namespace DnsTest {
    public class DnsTestProgram {
        static void Main(string[] args) {
            var domainEntry = Dns.GetHostEntry("google.com");
            Console.WriteLine(domainEntry.HostName);
            foreach(var ip in domainEntry.AddressList) {
                Console.WriteLine(ip);
            }
            Thread.Sleep(10000);
        }
    }
}

使用此程序,我们应该看到以下输出:

google.com
172.217.10.14

当然,当您查找解析google.com域名的主机条目时,解析出的 IP 地址可能会有所不同。谷歌的服务器分布广泛,距离您的网络位置最近的特定服务器切片(及其关联的 IP 地址)将解析该域名的查找。

如果您想验证返回的 IP 地址确实是该域名的注册地址,您实际上可以通过修改计算机的 hosts 文件在本地拦截主机条目查找。在 Windows 操作系统上,该文件位于C:\Windows\System32\drivers\etc\hosts目录,并且没有文件扩展名。在 macOS 和*nix 系统上,它简单地位于\etc\hosts

此文件是针对由主机名地址的网络资源的任何出站请求的第一个停止点。技术上讲,它是您计算机的内部名称服务器,您可以使用它以任何您想要的方式引导流量。为了演示这一点,按照以下方式向您的 hosts 文件添加条目:

127.0.0.1    fun.with.dns.com

现在,打开您的命令提示符,导航到一个空文件夹,并使用以下 CLI 命令启动一个新的.NET Core Web API 项目:

dotnet new webapi

您的控制台应打印有关.NET Core、遥测、ASP.NET Core 的信息,最后,以以下行结束执行:

Restore succeeded.

假设这成功了,您可以直接从创建项目的同一目录中执行以下命令来运行应用程序:

dotnet run

之后,您应该看到应用程序正在运行并监听,如下面的截图所示:

图片

注意应用程序监听的具体端口。

如果我们查看空白 Web API 应用程序内部,我们可以看到.NET Core 启动了一个名为ValuesController的单个控制器,并且它公开了多个 REST 端点。我们现在所关心的只是 API 指定的路由和监听 HTTP GET 请求的端点,如下所示:

[Route("api/{controller}")]

...

[HttpGet("{id}")]
public ActionResult<string> Get(int id) {
    return "value";
}

这告诉我们,如果我们导航到本地机器监听端口上的/api/values/{id}路径,我们应该期望看到"value"结果。

确实如此,如果你打开你选择的浏览器并在地址栏中输入应用程序的 URL,附加控制器中指定的路径,你应该能在浏览器中看到显示的值字符串,如下面的截图所示:

图片

然而,有趣的是,localhost 本身是127.0.0.1 IP 地址的一个别名。按照惯例,该地址始终解析为当前本地机器。然而,由于我们修改了 hosts 文件,因此我们应该能够将 URL 中的localhost替换为新的域名fun.with.dns.com。在浏览器中进行更改,你将看到相同的响应!

现在我们已经看到了如何在本地设置自己的域名条目,我们可以使用我们的 hosts 文件来更详细地探索Dns类,并验证响应。

首先,在 hosts 文件中添加一个具有新 IP 地址的新条目,但与之前相同的假域名。你的新 hosts 文件应如下所示:

127.0.0.1    fun.with.dns.com
1.0.0.127    fun.with.dns.com

在这里,地址本身并不重要,因为我们不会在那些位置查找资源。重要的是有两个地址。有了这些条目,你可以更具体地看到.NET 中的Dns类如何从最近的域名服务器中公开一个可以解析的主机条目。我们可以修改之前的程序如下:

using System;
using System.Net;

namespace DnsTest {
    public class DnsTestProgram {
        static void Main(string[] args) {
 var domainEntry = Dns.GetHostEntry("fun.with.dns.com");
 Console.WriteLine(domainEntry.HostName);
 foreach(var ip in domainEntry.AddressList) {
 Console.WriteLine(ip);
 }

 var domainEntryByAddress = Dns.GetHostEntry("127.0.0.1");
 Console.WriteLine(domainEntryByAddress.HostName);
 foreach(var ip in domainEntryByAddress.AddressList) {
 Console.WriteLine(ip);
 }
 Thread.Sleep(10000);
 }
    }
}

我们现在可以看到以下输出:

fun.with.dns.com
1.0.0.127
127.0.0.1
fun.with.dns.com
1.0.0.127
127.0.0.1

这演示了我们可以如何使用Dns类访问给定域名或 IP 地址的主机信息。请注意,Dns类的方法返回的HostEntry类实例总是包含在命名服务器中记录的所有 IP 地址。即使我们通过特定的 IP 地址查找HostEntry类,Dns类仍然解析并返回与原始查找 IP 地址匹配的域名注册的所有其他 IP 地址。这提供了灵活性,可以在注册的地址之一无响应的情况下,能够访问和使用替代硬件资源。你将在工作中利用这个类到什么程度可能会有所不同,但我希望你现在能看到它可以是一个有用的工具,可以放在你的工具箱里。

摘要

在本章中,我们探讨了网络工程师识别为使网络可行的必要的主要特征。我们在定义网络寻址的标准语法时,考虑了路由硬件的可用性与人类可读性之间的权衡。考虑到这一点,我们研究了前一代电信工程师的工作如何极大地贡献于今天所有现代网络最终标准化的解决方案。

在这个背景下,我们研究了网络硬件如何使用 IP 地址定位资源,以及 DNS 如何促进 URL 和 URI 更易记、更易读的寻址方案。我们通过实现自己的域名服务器,使用操作系统的 hosts 文件,学习了那些域名名是如何明确映射到其底层 IP 地址的。使用我们自包含的 DNS 服务器沙盒,我们探索了 System.Net 命名空间提供的 C#类,以促进构建语法正确的 URL,并利用 DNS 查找给定 URL 的底层 IP 地址,或解析相同的请求。

在这个基础上,我们将使用下一章来探索允许数据从一个主机传输到另一个主机的通信协议。我们将研究标准化模型如何促进实体之间的通信,并仔细研究在那种通信中最常用的协议。

问题

  1. 网络工程师寻求实现网络寻址标准长期可行性的三个特征是什么?

  2. 电信工程师是如何牺牲电信网络可能的最大规模以实现更高的路由性能的?

  3. 现代互联网的电话号码和电话簿是什么?

  4. URL 是如何在网络上定位资源的?

  5. URL 的有效组成部分有哪些?其中哪些是可选的?

  6. 什么是完全合格域名?

  7. 设备是如何获得域名名的?

进一步阅读

关于 URL、域名和网络上的资源定位的更多信息,请考虑阅读马克·E·杰夫托维奇的《管理关键任务域名和 DNS》。它提供了对与 DNS 合作的更深入和更周到的分析,以及利用该系统在构建自己的网络时获得优势的策略。

第三章:通信协议

我们在这本书的前两章中讨论了什么使得网络难以编程以实现开放通信和设备无关性。网络的这些方面需要标准化,在本章中,我们将探讨标准如何为网络软件提供一种共同的语言,通过网络软件进行通信。首先,我们将了解定义这些标准的监管机构。我们将简要了解他们是谁以及他们试图实现的目标。一旦我们了解了谁定义了网络的共同架构,我们将深入探讨他们是如何组织和分类网络层级的每一层的。

本章将涵盖以下主题:

  • 当前网络架构标准的起源及其简要历史,以及负责该标准的组织背景。

  • 应用代码如何通过应用层与网络资源交互,以及为该层提供的通信标准。

  • 在网络架构标准中,数据如何在传输层上与网络进行通信或从网络中读取。

技术要求

与第一章“网络概要”一样,这将是对于为网络定义的标准的一种概念性考察。本书不需要特定的技术。我们将使用与之前章节相同的技术:NET Core 2.1 SDK 以及 Visual Studio Code 或 Visual Studio Community Edition 作为 IDE。

查看以下视频以查看代码的实际运行情况:[占位符链接]

开放系统互联(Open Systems Interconnection)网络栈

在通过网络从远程源发送或接收资源的过程中,有几个步骤,每个步骤都由负责执行这些步骤的网络工程师进行了深入考虑。在本节中,我们将探讨这些网络工程师是谁,以及他们是如何定义实现该过程中每一步的一般模式的。本节将全部关于 OSI,以及该规范如何定义特定网络设备的网络栈。

开放系统互联(Open Systems Interconnection)究竟是什么?

为了讨论通信协议,我们需要了解每个协议如何适应网络连接的更大图景,为此,我们需要一个共同模型来思考过程中的每一步。为此,我们有用于计算机和电信网络的 OSI 模型。该模型试图将标准化通信的不同步骤组织成一个抽象层级的分层模型。与我们在第一章“网络概要”中讨论的网络逻辑拓扑类似,OSI 模型存在于纯粹的概念和抽象层面。

正如其名所示,它被定义为在保持对模型中定义的任何层级在物理层面上最终如何实现完全无知的条件下,作为一个参考是有用的。事实上,许多通信协议或标准的实现并没有干净地映射到 OSI 网络模型上。然而,该模型被广泛认为是黄金标准,自从 1984 年正式化以来一直如此。因此,让我们看看它是如何成为这样的。

OSI 的起源

对于网络实现需要一个标准化模型的需求几乎在 networking 成为可能的同时就变得明显。为此,早在 1970 年代末,两个不同的组织开始着手定义这样一个模型,以实现这一目标。这些组织中的第一个是国际标准化组织ISO)。另一个组织在几乎同一时间开始解决相同的问题,即国际电报电话咨询委员会CCITT,该名称的法文翻译的首字母缩写)。

有趣的是,国际标准化组织(ISO)的简称并不是该组织名称的缩写词。相反,由于该组织的名称在每种认可的语言中都会有所不同,成员们选择将名称缩短为 ISO。这是指希腊语的 isos,意为相等,反映了该组织实现平等理解的宗旨。

两个组织几乎在同一时间寻求定义自己的模型这一事实并不完全令人惊讶。这个问题被众多学科的工程师所面临,缺乏标准化很快成为这些学科进步的瓶颈。然而,令人惊讶的是,这些解决方案彼此之间是多么相似。就像莱布尼茨和牛顿独立发明微积分一样,这些组织意外地找到了一个共同的解决方案来解决他们共同的问题。然而,这个愉快的巧合有助于加快标准化过程,因为它们解决方案的相似性有助于验证这两个模型都极有可能正确。

由于两个组织的努力都取得了成功,因此仅用了几年时间,这两个模型就被合并为一个单一的标准。因此,在 1983 年,OSI 的基本参考模型诞生了。随着时间的推移,该名称当然被缩短为 OSI 模型。到 1984 年,每个组织都在自己的官方参考文件中发布了这个新的共享模型,将模型及其特定的协议正式化,并在国际社会中确立。因此,让我们看看这个模型包含哪些内容。

基本参考模型

基本参考模型由 ISO 正式化为标准 ISO-7498(以及由 ITU 正式化为标准 X.200,CCITT 的继任者)。该模型可以被干净地分为两部分。第一部分是网络的基本抽象参考模型。第二部分是组织认为适合标准化并用于实现参考模型的协议列表。

参考模型定义了网络通信流,这是由网络上的合规设备实现的,以七个不同的概念层级或层的形式组织在堆栈中。这个堆栈被定义为从物理媒体上原始比特的传输开始,一直到可能使用网络上任何资源的低级应用软件。

对于我们的目的而言,当我们描述这些层级时,当我们说一个层在堆栈中更高时,我们指的是离物理介质上位传输的硬件层级更远。

该模型定义了层之间单向交互的严格机制。根据这个通信标准,一个给定的层只能通过由该下层暴露的抽象接口与直接下方的层进行通信。这个接口被称为层的服务定义,它定义了高层可以通过哪些有效操作与任何低层进行交互。OSI 网络堆栈层之间的交互模型显示了相同的情况:

图片

当数据通过堆栈的层级移动时,每一层都会用自己的一系列头部和尾部包装数据包,以便由接收设备解析。这包含了关于数据包起源于堆栈中哪一层的详细信息,以及如何解析它。在网络堆栈中,层与层之间传递的数据包被称为协议数据单元PDU)。

当服务定义提供从一层到其下一层交互的接口时,协议则为网络堆栈中给定层级的实体提供标准化的交互,以便直接与远程主机上同一层级的相应组件进行交互。这些协议假设原始主机堆栈中的交互顺畅,然后在远程主机上反向堆栈。一旦它冒泡到远程主机的目标层,协议就确定接收实体应该如何处理数据。

因此,我们可以如下描述通过 OSI 堆栈的数据传输整个过程:

  1. 原始主机上的实体在网络的 N 层创建一个数据包,称为 PDU。

  2. 原始层通过利用其下一层的服务定义将其传递到堆栈中。

  3. 低层接收 PDU,每一层都会用一组头部和尾部包装它,以便由远程主机上的相应层解析。

  4. 一旦 PDU 被堆栈最底层的层封装了头部和尾部,它就被传输到远程主机。

  5. 远程主机上的堆栈每一层都移除了由源主机相应层应用的头部和尾部,将 PDU 通过堆栈向上传递。

  6. PDU 由远程主机上的第 N 层接收。接收层随后根据第 N 层协议的规范解析 PDU 的数据,*如源主机所指定。

就这样,我们的数据可靠地通过网络传输。这是使用协议通过网络堆栈中每一层的定义来传输数据单元的完整、如果说是抽象的过程。我知道,一次性吸收这么多信息可能有些困难,但随着我们更完整地构建这幅图景,它将变得稍微清晰一些。因此,让我们看看堆栈的各个单独层是什么,为什么它们以这种方式排序,以及它们最终负责什么。

网络堆栈的层

当我们检查网络堆栈的每一层及其最终负责的内容时,有一些关键事项需要记住。首先,记住这个模型在本质上是非常抽象的,并且仅作为参考。因此,有时可能不清楚某个责任或任务属于哪一层。其次,记住,当我们讨论堆栈中每一层的责任时,我们是在具体讨论该层在数据通过网络成功传输方面的责任。*因此,在网络堆栈的上下文中,会话层的责任与在 Web 应用程序上下文中管理用户会话的责任完全独立。

最后,记住,随着我们向下移动到堆栈的更深处,我们离通过物理介质传输数据就越近。*我们将按从上到下的顺序编号我们的层,这样数字越小,我们就越接近线上的信号。这将在考虑为什么某一层比另一层低,以及其责任如何与上层责任不同时有所帮助。考虑到所有这些,让我们从头到尾深入探讨 OSI 网络堆栈。

主机/媒体的区别

关于网络堆栈的第一件事要理解的是,你可以看到不同级别的抽象。从概念上讲,你观察网络交互的位置越高,层就越少,区分这些层的责任也就越容易。同时,当你构建一个更接近其具体实现的模型时,你会看到模型中每个实体更微妙的角色和责任之间的区别。我们将要查看 OSI 参考模型提供的完整、低级模型,但我想要花一点时间来考虑网络实体之间的高级区分,这可以分为两个基本层。

这些层中的第一层是主机层。它封装了 OSI 堆栈的四个更高层,并描述了特定主机在网络上尝试通信的实体或特定责任。在两个网络主机之间双向通信的最基本背景下,每个主机完全负责其自身在主机层下聚合的 OSI 层的实现(因此得名)。将应用数据打包、指定编码和可靠性期望以及向特定目标发送 PDU 的方法都大致属于主机层。

在网络的高级视图中,第二层是媒体层。这些层描述了两个主机之间网络组件的物理实现。这提供了主机层实体所指定或请求的预期功能。这个层的实体通常在硬件级别或 C 或 C++等低级系统语言中实现。因此,这个层的实体通常超出了本书的范围。然而,C#提供了封装和表示该层实体功能的抽象,因此了解媒体层下层的层如何在基本层面上实际工作是很重要的。

在做出这一高级区分之后,让我们来看看网络的完整 OSI 模型,从最顶层开始。

应用层

网络堆栈的最顶层也是开发者在其职业生涯中会与之互动最多的层。应用层为与网络通信的交互提供了最高级别的接口。这是业务应用软件用来与堆栈其他部分交互的层。应用层上的实体使用了多种协议,我们将在本章后面讨论它们。不过,现在重要的是要记住,应用层是实际终端用户应用与 OSI 网络堆栈之间的接入点。

表示层

虽然听起来像是一种视觉表示数据的方式,但表示层实际上是一种定义数据如何被任何想要查看它的消费者解释的方式。这一层为来自不同主机的应用层实体提供了相互交互 PDU 的上下文。表示层上的实体负责描述从应用层传递的数据在给定数据事务的另一侧应该如何被解释。它完成了将 PDUs 的编码或序列化从应用层实体的更高层业务逻辑中抽象出来的工作。

会话层

会话层上的实体负责在网络上两个主机之间建立、维护、恢复和终止一个活跃的通信会话。在这个层上工作的实体提供如全双工交互、半双工交互和单工交互等通信机制,这些机制由所使用的协议约束指定。

全双工、半双工和单工通信

当两个主机之间建立会话时,有几种方式可以在该会话上进行通信。最常见的是全双工和半双工实现。这些只是简单地描述了一个连接的双方都可以进行通信的会话。

在全双工会话中,双方可以同时相互通信。这种通信的典型例子是电话通话。在电话通话中,双方可以同时说话并听到对方在说话。一个人在说话的同时还能听到别人对他说的话,这允许更高效的数据传输,并可以促进可靠的通信系统。

半双工系统是一种双方可以在会话中通信,但一次只有一个参与者可以通信的系统。这种系统的常见例子是对讲机或步话机。在这些系统中,激活一个无线电的麦克风会锁定频道,防止另一个无线电传输,直到第一个麦克风被断开。这可以在有限的带宽上提供更可靠的通信,因为信号干扰的机会更少。

最后,单工通信会话是指只有一个参与者实际上可以传输数据的情况。也就是说,有一个发送者和一个接收者。这种通信的常见例子是网络电视;有一个单一的广播源,有多个接收器实际接收传输的信号。在大多数现代通信网络中,这种情况并不常见,因为实现双工通信会话的额外成本通常与单工连接相比微不足道。然而,应该注意的是,双工通信系统只是一个由两个单工连接组成的系统,每个主机之间有一个连接方向。

传输层

传输层中的实体使用专门为与其他主机以及中间网络实体交互而设计的协议。鉴于我们对表示层和会话层的描述,这似乎是多余的;然而,这里有一个重要的角色要扮演。表示层关注字符编码,即从平台特定的数据表示到平台无关的描述的映射。然而,传输层查看由表示层传递下来的完整编码数据块,并确定如何将其拆分。它负责将数据切割成其他情况下无用的二进制流段。并且,重要的是,它以这种方式拆分这些段,以便它们可以在连接的另一侧重新组装。传输层还负责错误检测和恢复,不同的协议提供不同级别的可靠性。

这一层是之前描述的主机层伞形结构中的最低层。确定主机可以和将支持哪种传输机制是主机的责任。然而,这是给定主机在成功实现网络交互中的最低边界。此层以下的所有内容都落入媒体层,这是支持主机部署的网络工程师的责任。

网络层

网络层中的实体管理网络拓扑结构上的交互。它们负责地址解析,并在地址解析后将数据路由到目标主机。它们还根据物理网络的约束或资源可用性处理消息传递。因此,虽然传输层确定连接两端的网络堆栈主机层之间的交互,但网络层负责在形成两个主机之间路由的设备链上应用传输协议。相邻层之间的区别可能很微妙,我们将在本章后面讨论一些特定于网络层的责任。所以,如果传输层和网络层之间的区别不清楚,请相信我们(至少,尝试)会在后面澄清这一区别。

数据链路层

数据链路层非常明显地属于媒体层分组,因为这个层中的实体负责在网络中的节点之间实际传输数据。它负责从物理层检测错误,并控制节点之间通过物理媒体传输比特流的流量。例如,在半双工通信设置中,数据链路层中的实体负责在另一方向数据传输时限制一个方向的数据传输。这个层中的实体几乎充当了在节点到节点连接的道路上指挥交通的红绿灯。根据电气和电子工程师协会(IEEE)的标准 802,数据链路层被进一步细分为两个子层。这两个子层如下:

  • 介质访问控制MAC)子层:这个子层控制哪些实体可以通过数据链路层传输数据,以及数据如何传输。

  • 逻辑链路控制LLC)子层:这个子层封装了网络交互的逻辑协议。它本质上是一个接口,提供实体链接作为一系列抽象协议操作。

为了说明数据链路层在网络责任方面的具体性,它最常用的协议是点对点协议PPP)。这仅仅突出了数据链路层的实体实际上只关注促进两点之间的连接。

物理层

最后,我们到达了堆栈的底部,这是最容易理解的层。物理层封装了负责将原始、无结构数据从网络中的一个节点传输到另一个节点的实体。这是负责发送与数据包中比特串相对应的电气信号的层。它封装了负责调制电压、定时信号以及无线发射机和接收机频率的设备。这个层中的实体明确地超出了本书的范围,但无论如何都是一个有趣的问题。

将所有这些放在一起

现在,我们已经看到了 OSI 模型是如何组织数据传输责任的。希望到这一点,你应该已经清楚每一层在堆栈中的意图是为它上面的层提供一个可靠的抽象。然而,与远程主机进行通信的整体过程可能仍然显得有些模糊。所以,让我们考虑一个具体的例子,并在数据传输过程中,针对我们讨论的每个概念进行说明。

首先,让我们假设我们的主机(会话层)的第 5 层上的一个实体想要与远程主机上的第 5 层上的一个实体建立会话。我之前没有明确说明,但我们总是可以假设一个主机上给定层上的实体只与远程主机上同一层的相应实体直接通信。因此,在我们的例子中,第 5 层的实体将与也位于第 5 层的远程实体通信。

与远程实体通信总是通过协议进行的。鉴于这一点,任何寻求与远程主机通信的实体的首要责任是将传输数据包裹在适当的协议头部和尾部中。对于我们的会话层实体,假设他们希望通过会话控制协议(SCP)建立会话。这意味着我们的本地实体将生成建立会话所需的数据,然后将这些数据包裹在 SCP 头部和尾部中,创建一个格式良好的 PDU(希望这清楚地说明了为什么这个名字描述了这个包)。这确保了接收主机将能够根据我们 PDU 头部和尾部中存储的信息解包数据。

由于位于物理层之上的任何层中的实体不能直接相互通信,我们必须将我们的 PDU 向下传递到堆栈。在我们的例子中,我们可以通过利用其服务定义并相信通过该定义公开的逻辑操作被所有位于第 5 层以下的负责实体准确实现,来可靠地将 PDU 向下传递到第 4 层。因此,我们不需要知道第 4 层如何实现传输机制。相反,我们只需要求它使用适用于此特定实例的适当传输机制,并相信它会适当地这样做。

这种相信堆栈中较低层将正确实现堆栈中较高层请求的操作的模式一直延续到第 1 层。在这个过程中,堆栈中的每一层都会在其 PDU 上添加自己的头部和尾部。这些标准化的数据块为接收主机上的每个中间层提供了足够的信息,以便知道将 PDU 向上传递到自己的堆栈。通过持续地将数据包裹在格式良好、易于理解的二进制数据块中,远程主机上的每一层都可以信任向上传递的数据段正是应该向上移动的内容。

这个将 PDU 包裹在越来越深的元数据层的过程一直持续到我们达到第 1 层第 1 层承载着我们的主机与远程主机之间的物理连接。一旦达到这个级别,我们就可以跨越网络的广阔空间,开始观察我们的 PDU 如何沿着网络栈向上移动,直到它到达目标实体的第 5 层。远程主机每一层的实体都将勤奋地移除并读取由原始主机相应层应用的头部和尾部。这些包装器中的信息将表明 PDU 是发往当前层之上的一个层。因此,实体将简单地移除它们的头部,并将剩余的数据向上推送到网络栈。

当数据达到远程主机的第 5 层时,该层的一个实体将读取在原始主机的第 5 层上应用的 PDU 头部和尾部。这些元数据将表明第 5 层实际上是这个特定 PDU 的目标层。元数据还将指示应该使用什么协议来解析传递给远程主机的数据。使用这些信息,接收主机将有足够的数据正确读取 PDU 中的数据,并构建它自己的响应 PDU。

一旦原始主机收到响应,就会建立一个会话,并可供原始主机或远程主机会话层之上的任何实体使用。整个过程在以下 OSI 栈数据传输全生命周期的图中得到体现:

图片

考虑到这个图,很容易看出 OSI 模型提供的标准化如何使工程师更容易为网络编写软件。关注点的清晰分离和通过栈传递数据的明确模式允许形成良好的契约,所有相关方都可以据此设计和开发。在应用层编程实体的工程师可以忽略数据传输的细节。他们只需通过栈向下传递一个格式良好的 PDU。

希望这个描述能够阐明特定层上的实体如何通过服务定义来暴露它们的抽象,以及处于不同主机上同一网络栈层的实体如何通过协议可靠地通信。带着这个观点,让我们更深入地看看我们将要频繁编程的这些栈层,以及 C#提供的用于表示这些层实体的类。

应用层

我之前提到过,但值得再次强调,应用层是日常网络编程的大部分工作将发生的地方。这在.NET Core 框架中尤其如此,因为该框架提供的库为必须在栈的较低层编程的实体或责任提供了广泛清洁、易于使用的抽象。因此,首先,让我们看看为什么我们应该如此关注应用层的责任。我们将查看通常委托给该层实体的责任类型,并看看这些责任与日常.NET Core 开发者面临的要求重叠的频率。然后,鉴于应用层实体的广泛用例,我们将查看该层实体使用的某些常见协议。我们将从基本层面理解它们。我们将查看我们为每一层可用的类和库;然而,在本书的这一章之后,我希望你将拥有足够的理解,能够自己重构这些类。

栈中最常见的层

在这一点上可能感觉有些重复,但确实值得强调的是,应用层是绝大多数.NET 开发者将要进行网络编程的地方。既然这占了你们中的大多数,我们将继续讨论它。但为什么应用层如此重要?

其核心在于应用层充当了业务逻辑对网络活动的大门。当你深入探索.NET 如何彻底隐藏了网络栈低层任何责任实现的细节时,这一点变得非常明显。本质上,如果你需要在栈的任何位置指定关于你的应用程序应该如何行为的内容,你将通过一个.NET 库类来完成。

我真的无法强调理解协议底层行为的重要性。了解库是如何实现的将使你在未来使用它们时更加得心应手。这就像学习手动换挡一样。如果你只学会了换挡所需的步骤,没有持续的练习,你可能会变得生疏。随着时间的推移,你可能会忘记足够多的东西,以至于无法再驾驶手动变速器。然而,如果你学会了这些步骤是如何让你的汽车行驶的,你将永远不会忘记这些步骤本身。即使你已经很多年没有开过手动变速器,你也能根据对这些步骤实际完成的事情的理解来重建所需的步骤。按照同样的标准,理解.NET 核心库为你做了什么将使你能够更高效、更正确地使用它们。你会发现你不需要经常查阅文档,并且能够通过 IntelliSense 更好地找到你需要的函数或属性。话虽如此,让我们仔细看看最常见的网络层协议中的一些最常见协议。

HTTP – 应用程序到应用程序的通信

欢迎来到几乎所有当前正在工作的.NET Core 开发者的基本功。HTTP 是目前应用最普遍且最有用的网络交互协议。为什么这么说呢?因为 HTTP 是互联网上几乎每个网页都是由远程主机提供并由本地客户端请求的协议。仅此一点就足以称其为使用最普遍的协议。如果你还需要更多证据,那么考虑一下,大多数提供托管在 Web 上的数据的原生移动应用程序都是从通过 HTTP 公开的 API 请求这些数据的。几乎感觉没有必要为理解 HTTP 的重要性进行辩护,因为我确信阅读这本书的每个人至少都有一些与 HTTP 相关的经验或理解。

那么,为什么还要如此彻底地介绍它,如果大多数读者都被假设对它有一些基本了解呢?答案有两个方面。首先,这是因为它作为一种通信协议是多么普遍!HTTP 如此普遍,如果不给予它应有的考虑,那就犯了刑事上的疏忽。第二个原因是,至少在我个人的经验中,大多数开发人员,甚至是每天与之打交道的工程师,对规范提供的了解只是肤浅的或表面的。我的希望是,到这本书的结尾,任何从头到尾读过这本书的人都能自信和熟练地编写利用目标网络每个方面的软件。没有对 HTTP 是什么、为什么被定义以及每天每秒都有数千个应用程序如何使用它的深入、全面的理解,这是不可能做到的。考虑到这一点,让我们来看看这个协议。

什么是 HTTP?

几乎每一位这本书的读者可能都已经对着页面大喊,让我们继续到 HTTP正如已经显而易见的那样,HTTP 是在 OSI 网络堆栈的应用层中实现并由软件利用的协议。它是通过互联网暴露的应用程序的主要通信机制,旨在在网络中传输超媒体。超媒体通常指的是包含多媒体信息的超文本文档,以及可以用来导航到并从其他远程主机加载额外资源的超链接。

HTTP 的传输组件本质上是一种请求/响应协议,它假设在活跃的 HTTP 会话中,主机之间存在着客户端-服务器的关系。为了理解这是如何实现的,让我们从客户端-服务器关系的概念开始。

HTTP 中的客户端-服务器模型

在本章中,我们一直将网络上的通信简单地描述为发生在源主机和目标主机之间,好像这两个主机在功能上是相同的,这取决于哪个在发送数据包。然而,在客户端-服务器模型中,这两个主机实际上执行着不同的特定任务,因此它们在概念上不可互换。客户端实体是请求并得到使用由服务器实体(或称为服务端)提供的(或称为服务的)服务或资源的实体。服务器不会主动向客户端发出请求,除非这是完成客户端已经提出的某个服务请求所必需的(例如,当客户端通过请求受保护的数据来启动事务时,从客户端请求额外的登录信息)。同样,客户端也不需要向服务器提供任何特定的资源,除非这些信息对于服务器足够处理并响应请求是必要的。

现在,两个应用程序使用 HTTP 相互交互并不罕见,在这种情况下,根据交互方式,任一应用程序都可以被认为是客户端或服务器。例如,一个桌面财务应用程序可能负责存储本地用户数据,同时使用远程 API 来访问关于不同类型贷款的当前利率的实时数据流。现在假设该桌面应用程序的作者想要定期访问有关其软件用户的信息。在用户登录其应用程序以查找抵押贷款市场利率的情况下,桌面应用程序将从远程 API 请求信息;因此,桌面应用程序是客户端,而 API 是服务器。然而,当远程软件决定查询其桌面应用程序的实例以获取用户数据时,角色会颠倒。远程软件将从桌面应用程序的已知主机请求数据;在这种情况下,远程软件是客户端,从运行桌面应用程序的计算机请求信息,这些计算机是服务器。

或者,一个应用程序或主机可能是一个远程主机的客户端,同时作为另一个远程主机的服务器运行。考虑这样一个 API 的例子,它通过聚合来自多个其他 API 的信息来响应请求。在为下游消费者提供服务请求的过程中,相关应用程序显然是一个服务器。然而,当应用程序从其他 API 上游请求信息时,它扮演的是客户端的角色。

我提出这些例子是为了强调客户端-服务器关系主要是概念性的。将客户端或服务器角色分配给特定主机是特定于特定交互上下文的。如果该上下文发生变化,那么涉及的宿主的观念角色也可能发生变化。我们只在与特定交互上下文相关的范围内提及客户端和服务器,以避免混淆是很重要的。

请求/响应

在描述客户端-服务器关系时,我们也触及了 HTTP 请求/响应协议的本质。作为提供信息的一种方式,该协议相当直观易懂。当客户端向服务器发出请求(请求/响应中的请求部分)时,如果服务器符合协议规范,它应预期以有意义的信息响应该请求的成功或失败,并提供最初请求的具体数据。

有时,请求信息和接收有意义响应的完整过程需要客户端和服务器之间进行几次中间往返,以建立初始连接,确定服务器处理请求的能力,然后提交启动请求所需的信息。然而,从应用层软件的角度来看,整个过程将被视为一个单独的请求/响应会话。这很自然地引出了我们最初是如何建立这些会话的问题。

HTTP 会话

到目前为止,我们讨论了 HTTP 请求/响应通信模式的来回过程,但我们忽略了允许这种交流如此无缝发生的环境。这种流畅的交互是由客户端发出的第一个请求之前建立的底层会话所促进的。历史上,这种会话是由针对主机服务器上特定端口的传输控制协议TCP)连接提供的。当指定目标主机时,可以在 URI 中指定此端口,但通常将使用 HTTP 的默认端口,例如808080443(用于稍后本书中将要介绍的 HTTPS)。一旦建立连接,HTTP 通信的往返就可以自由进行,直到会话终止。

你可能已经注意到,我特别提到 TCP 在历史上被用于 HTTP。这是因为,对于 HTTP 的当前所有版本(1.0、1.1 以及现在的 HTTP/2),TCP 一直是支持它的标准传输层协议。然而,在当前提出的 HTTP/3 规范中,该协议正在被修改以利用替代传输协议,包括用户数据报协议UDP),或者谷歌的实验性快速 UDP 互联网连接QUIC)协议。虽然这些替代传输协议有一些权衡,但它们提供的底层会话对我们来说是一样的。每种协议都旨在与监听主机建立连接,并促进请求和响应消息的传输。接下来,让我们看看客户端可能请求服务器执行的一些操作,以及这些操作是如何通过 HTTP 标准中的请求动词来指定的。

请求方法

当客户端想要向服务器发送请求时,它必须指定服务器将如何响应该请求的方法。这些方法规范通常被称为HTTP 动词,因为它们大多数描述了服务器在处理客户端发送的请求时将采取的动作。标准方法如下:

  • OPTIONS:此操作返回服务器在给定 URL 上支持的其他 HTTP 方法列表。

  • TRACE: 这是一个实用方法,它将简单地回显服务器接收到的原始请求。这对于识别在请求传输过程中网络实体对请求所做的任何修改非常有用。

  • CONNECT: CONNECT 请求在源主机和远程主机之间建立一个透明的 TCP/IP 隧道。

  • GET: 此操作检索由 HTTP 请求发送的 URL 指定的资源的副本。按照惯例,GET 请求将仅检索资源,而不会对服务器上资源的状态产生任何副作用(然而,这些惯例可能会被书中稍后提到的糟糕的编程实践所破坏)。

  • HEAD: 此方法请求与对给定 URL 的 GET 请求相同的响应,但不需要响应体。返回的内容只有响应头。

  • POST: POST 方法在请求体中传输数据,并请求服务器将请求体的内容作为由服务器托管的新资源存储。

  • PUT: PUT 方法与 POST 方法类似,客户端请求服务器存储请求体的内容。然而,在 PUT 操作的情况下,如果请求 URL 上已经存在内容,则该内容将使用请求体的内容进行修改和更新。

  • PATCH: PATCH 方法将对请求 URL 的资源执行部分更新,使用请求体的内容对其进行修改。如果没有在服务器上找到要修补的资源,则典型情况下 PATCH 请求将失败。

  • DELETE: DELETE 方法将永久删除指定 URL 的资源。

服务器不会对针对给定位置的请求方法做出响应,除非服务器已被配置为这样做。这是因为 HTTP 标准定义的一些方法可能会永久影响该服务器上资源的状态,因此只有在安全地不可撤销地更新该状态时才应调用和处理。然而,按照惯例,有许多方法被指定为安全的。这仅仅意味着它们可以被服务器处理,而不会对该服务器上资源的状态产生任何副作用。HEAD、GET、OPTIONS 和 TRACE 都按惯例指定为安全。

状态码

即使应用程序已构造了一个有效的 HTTP 请求对象,并将其提交到活动主机上的有效路径,服务器无法正确响应的情况也并不少见。因此,HTTP 将状态码指定为响应对象的一部分,以传达服务器正确处理请求的能力。按照惯例,HTTP 状态码是作为每个响应的一部分返回的 3 位数数字码。第一位数字表示响应的一般性质,第二位和第三位数字将告诉您遇到的精确问题。这样,我们可以这样说,状态码是根据它们的第一位数字进行分类的。

当你编写响应 HTTP 请求的软件时,向不同的错误发送准确的状态码非常重要。HTTP 是一个标准,开发者必须遵守才能保持其有用性。

HTTP 状态码的第一位数字只有五个有效值,因此有五个响应类别;它们如下所示:

  • 1XX:信息状态码。这表示请求确实已接收,并且该请求的处理正在继续。

  • 2XX:成功状态码。这表示请求已被成功接收并响应。

  • 3XX:重定向。这表示请求的主机必须将它们的请求发送到新位置才能成功处理。

  • 4XX:客户端错误。这是一个由客户端操作产生的错误,例如发送格式不正确的请求或尝试从错误的位置访问资源。

  • 5XX:服务器错误。服务器出现故障,阻止其能够满足请求。客户端正确提交了请求,但服务器未能满足它。

状态码是服务器对每个针对服务器的 HTTP 请求返回的,因此对于构建客户端软件的弹性非常有用。

HTTP 消息格式

HTTP 中的请求和响应始终以纯文本消息发送。这些纯文本消息由一系列有序且结构良好的消息段组成,这些段可以被接收者可靠地解析。在请求中,消息由三个必需的消息组件和一个可选组件组成:

  • 请求行由方法、请求资源的路径以及用于确定消息其余部分有效性的特定协议版本组成;例如,GET /users/id/12 HTTP/1.1

  • 一系列请求头及其值,例如,Accept: application/json

  • 一个空行。

  • (可选)请求消息体。这包括内容头,它提供了关于内容类型以及内容的元数据,以及内容本身。

每个段由一个<CR>回车字符和一个<LF>换行字符分隔;这些是特殊的空白字符,它们的特定美国信息交换标准代码ASCII)值允许它们可靠地用于在消息流中指示段之间的分隔。

同时,HTTP 响应由其几乎具有相同结构的多个段组成,每个段也由<CR><LF>字符分隔。就像请求消息一样,它包含三个必需的段和一个可选的消息体段,如下所示:

  • 由特定协议、HTTP 状态码及其相关原因短语组成的状态行:

    • HTTP/1.1 401 Bad Request:包含 401 客户端错误状态码的响应(表示客户端发送了不正确的请求消息以查找其资源)。

    • HTTP/2.0 201 Created:表示 201 成功状态码的响应,意味着所需的资源已在服务器上创建。

  • 与请求消息段一样,头提供了关于如何解析响应的元数据。

  • 一行空行。

  • 可选的消息体。

这些简单的段完全定义了互联网上发送的每个有效 HTTP 消息。这解释了每秒数百万次请求,涉及数百万或数十亿台设备。正是消息规范的简单性使得这种规模成为可能。

C# 中的 HTTP

记住 HTTP 消息中段的正确字符分隔符和顺序,任何人都可以从头开始构建一个请求。幸运的是,你不必记住这些细节;.NET Core 通过 System.Net.Http 命名空间为你提供了支持。我们将在本书的后面部分更详细地探讨这个命名空间,但到目前为止,只需相信你在应用程序中需要利用 HTTP 通信的任何功能或细节都可以通过该命名空间暴露出来。这个命名空间提供了状态码和头值枚举类型,以及一个 HttpMethod 类来指定你的消息动词。作为一个库,它具有丰富的开箱即用功能,同时足够灵活和可扩展,可以在任何用例中使用。

FTP 和 SMTP – 应用层的其余部分

尽管由于 HTTP 在网络程序员日常生活中的突出地位,我们已经对其有了深入的了解,但我们还必须花时间提及并简要地查看一些今天正在使用的其他常见应用层协议。在本节中,我们将探讨 文件传输协议FTP)和 SSH 文件传输协议SFTP),它们允许远程文件复制操作和文件系统导航;以及 简单邮件传输协议SMTP),它用于在网络上传输电子邮件。

有趣的是,由于这些协议都在应用层运行,因此看到一种协议提供另一种协议历史上所提供的功能并不罕见。例如,HTTP 的纯文本消息结构的无数据感知特性使得通过 HTTP 会话传输完整文件数据变得极其简单。这就像在服务器上编写软件通过响应的消息体传输文件一样简单。因此,FTP 和在某种程度上较小的 SMTP,近年来在网络程序员中已经不再受欢迎,他们更倾向于在支持 HTTP 的软件宿主中实现其职责。然而,这些协议仍然存在,考虑它们的缺陷和优点将对我们有益。

FTP 和 SFTP

FTP(以及 SFTP)利用类似于 HTTP 所使用的客户端-服务器模型,但其连接规范比我们之前看到的要复杂一些。HTTP 通过一系列无状态请求/响应事务在单个连接上发送消息,而 FTP 在状态会话期间维护客户端和服务器之间的两个连接。一个连接建立了一个状态控制管道,该管道跟踪 FTP 服务器暴露的目录的当前状态,并提交执行所需文件传输的必要命令。另一个连接是无状态的,它促进了主机之间原始文件数据的传输。为单个 FTP 会话建立这两个连接,在引入可靠性的同时,也带来了延迟和复杂性的代价。此外,随着时间推移,FTP 作为通信协议能够可靠执行的任务性质有限,这仅仅限制了其流行的使用。幸运的是,正如 HTTP 的情况一样,在.NET 核心中,实现 FTP 服务器或客户端的大部分细节都由System.Net命名空间处理,我们将在本书的后续部分探讨这些工具。

SMTP

与 FTP 类似,SMTP 所支持的功能集相当有限,且针对执行几个特定任务进行了精确定制。然而,实现电子邮件服务器的需求实际上相当普遍,理解通过 SMTP 发送或接收消息的复杂性仍然是一项相关且有用的技能;与 FTP 相比,这一点在当今更为重要。SMTP 是一种面向连接的协议,用于向配置为接收它们的远程服务器发送邮件消息。它利用客户端-服务器模型,通过可靠的会话,一系列命令和数据传输过程将电子邮件单方面从客户端传输到服务器。SMTP 会话的来回实际上比 HTTP 和 FTP 要复杂得多,这种复杂性超出了本章的范围。然而,目前来说,可以说任何有价值的网络程序员都应该对 HTTP、FTP 和 SMTP 有一个良好的理解。

传输层

虽然应用层是大多数.NET 开发者在日常生活中与 OSI 模型打交道的层,但没有在传输层上的可靠、稳定的协议实现,它将毫无用处。正是在这一层上建立连接并传输数据。它是单个主机直接负责的堆栈中的最低层,在传输层,TCP 和 UDP 占据主导地位。每个都提供自己的机制将数据流传输到目的地,并且每个都提供了自己的权衡,这些权衡在选择网络服务的传输协议时需要考虑。与所有这些协议一样,我们将在本书的后面更详细地探讨它们,但现在让我们了解它们是什么以及为什么会出现。

TCP

由 IEEE 工程师于 1974 年开发,TCP 被定义为一种基于连接的通信协议,它提供了有序数据包的可靠交付。它被用于促进各种主机之间的通信,从互联网到 SMTP 客户端和服务器,Secure Shell (SSH)连接,FTP 客户端和服务器,以及 HTTP。作为几乎所有现代应用的首选传输层协议,它无处不在。

TCP 作为支持大多数应用层请求的传输层协议被广泛采用,这主要归功于 TCP 连接的可靠性。按照惯例,实现 TCP 的实体被编写来检测数据包丢失和数据流顺序错误,重新请求丢失的数据,并重新排序顺序错误的数据流。这种错误纠正是在将数据返回到使用 TCP 连接的应用层实体之前解决的。

当然,这种错误处理显然产生的成本是延迟和性能。为了获取本质上相同的数据,多次往返可以给客户端应用增加大量的停机时间。TCP 的可靠性是通过利用往返链请求、请求接收的确认、然后另一个请求,等等来保证的。这种持续的来回交流使得 TCP 对于实时应用,如游戏、视频流或视频会议,远非理想。相反,当可以牺牲可靠性或保证顺序以换取性能时,应使用 UDP 或类似协议作为传输层的选择。

UDP

如果一个应用不需要严格保证 TCP 的可靠性,那么 UDP 由于其简单性和性能开始看起来是一个非常吸引人的选择。UDP 是一种简单、不可靠且无连接的通信协议,用于在网络中传输数据。TCP 通过其重复请求和确认的模式提供了强大的错误处理,而 UDP 没有握手或确认信号来指示数据包是否正确地从主机传输到主机。

虽然 UDP 在数据包丢失或顺序错误的情况下不提供强大的错误处理,但它至少在数据包级别上提供了错误检查。它是通过使用存储在数据包头部的校验和值来实现的。区别在于,当检测到数据包中的错误时,UDP 实体简单地丢弃该数据包,并且不会发送任何请求来尝试再次以有效状态检索该数据包。

这种以数据包投递为导向的模型,即发送单个数据包而不考虑其成功投递,也意味着 UDP 数据请求可以在主机之间没有建立任何先前连接的情况下发送。这种缺乏初始往返极大地减少了需要在大批主机之间频繁进行实时连接的软件系统中的开销。事实上,这种缺乏初始握手是连接和无连接通信协议之间主要的区别之一,这一点值得详细阐述。

连接与无连接通信

无连接通信的概念一开始可能看起来像是自相矛盾。如果两个实体在连接之前就无法通信,它们怎么可能进行交流?一个人甚至怎么知道另一个实体存在以便进行通信呢?

基本原理是,在基于连接的通信协议中,两个主机必须首先建立一条通信线路,然后才能开始传输任何特定应用的数据。TCP 中的握手序列是这一点的最明显例子。必须在成功建立连接之前,发送/接收消息完成一个完整的往返。在这个上下文中,建立的那条通信线路就是“连接”。它消耗时间和带宽,但提供可靠性和错误纠正,在几乎所有情况下,可靠性和错误纠正的价值远远超过所承受的成本。

同时,在无连接通信中,数据可以在没有从客户端到服务器,再从服务器返回客户端的单个完整往返的情况下进行传输和通信终止。数据包在其自己的头部中包含足够的信息,以便正确路由到监听主机。只要该主机没有对初始请求的后续操作,那么通信将仅通过单向数据包投递停止。这种传输模式的低延迟可能在某些应用场景中是一个主要优势。

关于这两个协议,还有很多东西需要进一步探索,但这将是本书后面章节的内容。然而,现在,我希望这能清楚地说明为什么传输层及其协议在设计和实现高性能和高可靠性网络软件中扮演着如此重要的角色。

摘要

在本章中,我们学习了关于 OSI 网络模型的所有知识。首先,我们了解了定义标准参考模型的监管机构,包括他们何时以及为何着手解决统一网络建模的问题。然后,我们仔细研究了他们定义的模型,包括查看其堆栈中的每一层,以及那些层中的实体承担的责任。我们学习了协议如何为在同一网络堆栈层级但位于网络中不同主机上的实体定义标准化的通信模式。我们还看到了服务定义如何允许实体通过网络堆栈传递数据并将消息发送到远程实体。

我们还仔细研究了本书其余部分中我们将与之交互的一些最常用的通信协议。我们从所有网络协议之王 HTTP 开始。我们了解了 HTTP 会话是如何建立的,以便客户端和服务器之间进行通信。我们看到了 HTTP 如何通过一系列请求和响应以及定义良好的动词来操作,以指定在处理这些请求时需要执行的操作。我们还研究了 TCP 和 UDP,以及传输层如何作为所有应用层网络交互必须通过的总线。最后,我们研究了网络层如何通过 IP 寻址系统和离散数据包传输来促进这种通信。

在这个基础上,我们很好地定位了在下一章中深入探讨数据如何被分解成离散的数据包并通过数据流在网络中传输。

问题

  1. OSI 代表什么,哪个组织标准化了它?

  2. OSI 网络堆栈的层通过哪个抽象层与下层的层进行通信?

  3. OSI 网络堆栈有多少层,它们是什么?

  4. 在不同主机上同一网络堆栈层级的实体之间通信的标准机制叫什么名字?

  5. HTTP 代表什么?HTTP 用作哪个网络层的通信协议?

  6. 列出所有可以从客户端发送的 HTTP 动词。

  7. TCP 和 UDP 传输协议之间有哪些主要区别?

进一步阅读

更多关于 OSI 参考模型的信息,请参阅 Steven Noble 所著的《Building Modern Networks》,由 Packt Publishing 出版。

此外,您可以参考由 Ramon Nastase 编写的《Computer Networking: Beginner's Guide for Mastering Computer Networking》和《The OSI Model》以及 Al Rivas 的《The OSI Model for Network Engineers: Improve Your Network Troubleshooting》。这两本书都可以在amazon.com以电子书形式获得,并将提供比我在这章的上下文中有时间或空间覆盖的更彻底的 OSI 堆栈的检查。

第四章:数据包和流

本章将在第三章,通信协议,网络架构讨论的基础上,追踪数据在网络中的流动,并分解您将用 C#编写的处理过程中每个步骤的数据的软件。我们将解释将数据封装成最小数据包以进行网络传输,以及这种封装如何有助于确保数据包被正确交付并正确解码。我们将解释数据流作为序列化离散数据包的概念,并演示在 C#中执行序列化的各种方式。最后,我们将演示System.IO命名空间提供的各种抽象,用于处理流。

本章将涵盖以下主题:

  • 理解数据在网络中的移动方式,以及网络堆栈的各个层在传输过程中的每一步如何展开元数据,以确保正确交付

  • 深入探讨通过网络传输的数据包结构

  • 理解数据流作为离散数据包集合的概念,以及如何利用它通过 C#的许多Stream类来抽象接收和解析数据包的过程

技术要求

对于本章,我们将密切观察网络通信。为此,我将使用Wireshark数据包嗅探工具来演示我们讨论的一些概念。如果您想跟随并探索您自己机器上的网络流量,Wireshark 是一个免费下载,可在www.wireshark.org/获取。

无论您是否计划使用它来跟随本章,我都绝对建议您熟悉它作为一个工具。如果您对使用 C#进行任何有意义的网络编程有认真态度,低级流量检查将是您成功的关键,而且您越早学习这个工具,您就会越受益。

利用网络 – 为远程资源传输数据包

要具体了解数据包是什么,我们首先应该了解网络限制,这些限制是数据包最初产生的必要条件。为了做到这一点,我们需要了解带宽、延迟和信号强度的限制。这些限制在确定可以在给定网络上传输的原子数据单元的最大大小方面起着关键作用。这些限制要求通过网络传输的数据包含一定数量的属性,以确保任何程度的可靠性。网络中节点之间发送的数据包必须小巧,并包含足够的信息以便正确路由。考虑到这一点,让我们看看网络的物理限制如何为针对它们编写的软件解决方案提供信息和驱动。

带宽

任何拥有互联网连接的人可能都相当熟悉带宽这个概念。互联网服务的月费通常(至少在美国)是根据提供的最大带宽进行分级的。在专业的编程术语中,带宽这个术语经常被用来,某种程度上比较宽松地,指代一个团队或团队成员可以投入到新任务中的时间或心理容量。我们每个人都应该对这个概念有一个相当直观的理解。简单来说,它是在给定网络连接上的数据传输的最大速率。

虽然那个定义可能看起来很基础,甚至微不足道,但带宽如何驱动数据包大小和结构的标准可能不那么明显。因此,让我们更深入地考虑带宽描述了什么以及它如何影响数据传输。当我们讨论带宽时,有两个因素需要考虑:吞吐量的速度和信道的最大容量。

通过高速公路的类比来概念化这些概念是最容易的。想象一下,你是这条假设高速公路上的收费站操作员。然而,为了这个类比,让我们说,你不仅负责收取通行费,还负责在给定时间段内统计通过你的收费站的总车辆数。你高速公路上的车辆代表单个数据位。每次一辆车通过你的收费站,你都会计数。在任何给定时间内通过你的收费站的总车辆数代表该时间段内你高速公路的带宽。有了这个类比,让我们看看吞吐量和信道容量如何影响带宽。

在这种描述中,吞吐量的速度类似于你高速公路的速度限制。它是信号可以在连接上传输的物理最大速度。有许多因素可能会影响或改变这个速度,但在大多数情况下,电信号或光信号在其相应媒体中的物理特性使得这些变化的影响微乎其微。速度最终将归结为传输介质的物理极限。因此,例如,光纤电缆将比铜线有更高的吞吐量速度。光纤电缆以接近光速的速度传输数据,但铜线会对电流产生阻力,减缓并削弱通过它的任何数据信号。因此,在我们的高速公路类比中,光纤电缆网络的速度限制比铜线要高得多。在一个班次中坐在你的收费站,高速公路上速度限制更高的地方会有更多的车辆经过。鉴于这个事实,通过基本步骤升级你的传输媒体来增加网络的带宽可以变得非常简单。

虽然吞吐量的速度是带宽的一个强决定因素,但我们也应该花一点时间来考虑给定通道的最大容量。具体来说,这指的是在任何给定时刻,有多少物理电线可以沿着通道主动携带一个单独的比特。在我们的高速公路类比中,通道容量将描述高速公路上汽车可以行驶的车道数量。所以,想象一下,如果我们不是让一辆车沿着高速公路的单车道行驶,而是将其扩展为单向四车道。因此,现在在任何给定时刻,我们可能有四辆车,或者四比特的数据,通过我们的收费站。

显然,编写网络接口设备固件的系统程序员有责任编写支持正确处理多个同时通道的代码。然而,正如你可以想象的那样,可变的通道容量可能需要针对负责将你的数据分割成原子包的网络实体进行非常具体的优化。

延迟

带宽限制只是网络效率的一个考虑因素。工程师必须设计的下一个最常见限制,大多数用户至少是直观熟悉的,是延迟***。简单来说,延迟是信号首次发送和响应该信号可以启动之间的时间。它是网络的延迟。

关于延迟,有两种思考方式。简单来说,你可以将其测量为单程或往返。显然,单程延迟描述了信号从一个设备发送到目标设备接收的延迟。或者,往返延迟描述了信号从一个设备发送到目标设备接收响应的延迟。

然而,需要注意的是,往返延迟实际上排除了接收者在发送响应之前处理初始信号所花费的时间。例如,如果我通过我的软件向外部 API 发送请求,以对一些输入数据进行计算,我合理地期望该软件需要一些非平凡的时间来处理我的请求。所以,首先想象一下,请求在传输中花费了 0.005 秒。然后,一旦收到请求,API 在 0.1 秒内处理请求。最后,响应本身在返回我的软件时又花费了 0.01 秒。从我的软件发送请求到收到响应的总时间是0.005 + 0.1 + 0.01 = 0.115秒。然而,由于花费了 0.1 秒来处理,我们在测量往返延迟时将忽略这部分时间,因此往返延迟将被测量为0.115 - 0.1 = 0.015秒。

软件平台提供的服务简单地回声所接收到的请求,而不对响应应用任何处理,这种情况并不少见。这通常被称为ping服务,用于提供两个设备之间网络请求当前往返延迟的有用测量。因此,延迟通常被称为ping。在任何特定场景中,影响 ping 请求可靠性的因素有很多,因此此类请求的响应时间通常不被认为是准确的。然而,任何 ping 服务提供的测量通常被认为是给定网络往返的近似值,并且可以用来帮助隔离特定请求管道中的其他延迟问题。

如您所想象的那样,一个如此通用的定义如网络延迟可以有许多影响因素,这些因素会影响网络性能。这种延迟可能来自网络事务的任何一点,或者来自原始设备和目标设备之间的任何软件或硬件。在特定的分组交换网络中,可能有数十个中间路由器和网关接收和转发您的数据包以处理任何单个请求。这些设备中的每一个都可能引入一些延迟,当进行性能监控或测试时几乎无法隔离。而且,如果某个网关正在处理数百个并发请求,您可能会因为排在一些与您无关且您可能没有直接了解的请求后面而经历延迟。

机械延迟

导致延迟的不同影响因素有时会被稍微不同地分类。例如,机械延迟描述了物理组件实际生成或接收信号所需时间引入到网络中的延迟。所以,例如,如果你的 64 位计算机的时钟速度为 4.0 GHz,这为给定秒内可以处理的总信息量设定了一个物理的、机械的限制。现在,公平地说,这样一个系统处理的信息量会非常多。假设 CPU 每时钟周期处理一个字节,那么每秒处理 4 亿个 64 位指令;这是一个巨大的数字。但是,这个时钟速度构成了一个机械限制,为任何交易引入了一些可测量的延迟。在这样的系统中,一个 64 位指令不能比至少 0.000000128 秒更快地移动到网络传输设备,假设每个时钟周期间隔处理并交付一个比特到传输流。

操作系统延迟

上述示例描述的是一个有些不切实际的系统,因为在没有中断的情况下,可以直接将 64 字节的数据发送到传输媒体。现实中,操作系统OS)将处理来自应用程序和系统软件的请求,以发送那个假设的数据包,同时它还会处理主机机上运行的数百个其他软件的数千个其他请求。几乎所有现代操作系统都有一个系统,用于交织多个请求的操作,以确保没有进程会因为另一个进程的执行而被不合理地延迟。因此,我们实际上永远不会期望达到由我们的时钟速度定义的最小机械延迟。相反,可能发生的情况是,我们的数据包的第一个字节将被排队等待传输,然后操作系统将切换到处理其程序队列上的另一个操作,执行该操作需要一段时间,然后它可能回来准备我们的数据包的第二字节以进行传输。因此,如果你的软件试图在一个试图执行长时间运行或阻塞软件的操作系统上发送数据包,你可能会遇到完全无法控制的重大延迟。你的软件请求如何被操作系统优先处理和处理的延迟,希望非常明显地被称为操作系统延迟

操作延迟

虽然我之前确实说过,延迟通常只描述数据包在传输过程中花费的时间,但作为网络工程师,考虑延迟对最终用户体验的影响通常是很有用的。虽然我们都希望如此,但没有任何工程师可以通过声称原因超出了自己的控制范围来逃避忽视负面用户体验的责任。因此,即使你的软件可能表现最优,并且部署在超快的光纤网络上,如果它依赖于一个处理请求缓慢的上游资源提供商,你的最终用户最终会感受到这种痛苦,无论你的代码多么完美。因此,跟踪处理特定网络请求所需的总实际时间窗口,包括远程主机上的处理时间,通常是有用的。这种测量在考虑网络操作对用户体验的影响时最有意义,这被称为操作延迟。因此,尽管一个任务的操作延迟的许多影响因素通常超出了你的控制范围,但了解其影响并尽可能将其优化到最低限度通常非常重要。

最终,这些个别指标应该告诉你的是,在整个网络请求过程中,有数十个点可以引入延迟。每个点都有不同程度的影响,并且它们通常受到不同程度的控制,但只要有可能,你应该始终寻求最小化应用程序中引入外部延迟的点数。为最佳网络延迟进行设计总是比事后尝试构建它要容易。然而,这样做并不总是容易或明显的,并且为最小延迟进行优化可能从请求的任何一方看起来都不同。

为了说明这一点,假设我们正在编写一个应用程序,该应用程序负责收集一个或多个交易 ID,查找这些交易的货币价值,然后返回它们的总和。作为一个具有前瞻性的开发者,你已经将这个交易聚合服务从交易数据库中分离出来,以保持你的服务业务逻辑与数据存储实现解耦。为了便于数据访问,你通过一个简单的 REST API 公开了交易表,该 API 通过 URL 中的单个键来提供单个交易的查找端点,例如transaction-db/transaction/{id}。这对你来说是最有意义的,因为每个交易都有一个唯一的键,允许单个交易查找可以让我们最小化数据库服务返回的信息量。通过网络传输的内容越少,意味着延迟越少,因此,从数据生产者的角度来看,我们已经设计得很好。

然而,你的聚合服务却是另一回事。该服务需要多个交易记录来生成有意义的输出。仅通过单个端点一次返回一个记录,聚合服务将向交易服务发送多个同时请求。每个请求都会贡献它们自己的机械、操作系统和操作延迟。虽然现代操作系统允许同时处理多个网络请求的多线程处理,但给定进程中的可用线程数有一个上限。随着交易数量的增加,请求将开始排队,阻止同时处理并增加用户体验到的操作延迟。

在这种情况下,优化这两种情况只是一个简单的问题,即添加一个额外的 REST 端点,并接受请求体中包含多个交易 ID 的POST HTTP 请求。我们中大多数人阅读这个时可能已经知道了这一点,但这个例子作为一个说明最佳性能可以在同一枚硬币的两面看起来非常不同的例子是有用的。通常,我们不会同时负责服务应用程序和数据库 API,在这些情况下,我们将尽我们所能从单一方面来提高性能。

无论你在请求的哪一方,但网络延迟对应用性能的影响都需要你考虑最小化必须通过网络发送的原子数据包的大小。将大请求分解成更小、更易处理的片段,为通信链中的每个设备提供了更多介入、执行其他操作然后继续处理你的数据包的机会。如果我们的单网络请求在整个 5 MB 文件传输期间阻塞其他网络操作,它可能在你的操作系统维护的网络事务队列中拥有较低的优先级。然而,如果我们的操作系统只需要插入一个小的、64 字节的传输数据包,它可能找到更多机会更频繁地发送该请求,从而降低你的操作系统延迟。

如果我们的应用程序必须发送 5 MB 的数据,那么以 64 字节的数据包发送可以给你的应用程序托管环境提供更多的灵活性,以确定满足该需求的最佳方式。

信号强度

我们将要考虑的最后一个网络通信的主要限制是可变的信号强度。在任何非平凡的网络中,给定信号的强度可能会受到无线发射器和接收器之间距离的影响,以及通过电线连接的两个网关之间的距离。在现代光纤网络中,这并不是一个很大的问题,因为这些网络依赖于通过玻璃或塑料光纤传输可见光,因此不受许多干扰旧物理网络标准的因素的影响。然而,可靠的信号强度对于无线网络或使用铜线传输电信号的有线网络来说可能是一个主要问题。

如果你熟悉电阻对信号强度的影响(对于那些还记得大学物理或计算机硬件课程的人来说),你会知道,你想要发送信号的电线越长,接收端的信号就越弱。如果你将位定义为当电线上的电压高于给定阈值时为 1,并且你的电线电阻随时间降低信号电压,那么你的数据包中的一些位可能会因为信号干扰而被目标设备判定为不可确定。信号强度弱意味着传输可靠性低。

而仅仅是抵抗并不是唯一可能削弱你的信号强度的事情。大多数电信号都会受到来自任何其他附近电信号的干扰,或者简单地受到自然渗透地球的电磁场。当然,随着时间的推移,电气工程师们已经设计了无数种方法来减轻这些影响;从减少电磁干扰的电线绝缘到信号中继,以放大信号沿其路径的电阻来减少影响。然而,随着你的软件被部署到越来越广泛的网络中,你可以依赖的现代和设计良好的网络基础设施的范围显著减少。数据丢失是不可避免的,这可能会给那些负责确保你的请求可靠传输的人带来一系列问题。

那么,这种间歇性数据丢失如何影响网络传输格式的设计?它强制我们数据包具有一些必要的属性,我们将在稍后更深入地探讨,但在这里我们会简要提及。首先,它要求传输尽可能小的数据包。这仅仅是因为,如果数据损坏的问题存在,它将使整个数据包的有效载荷无效。在一串零和一之间,单个位的值的不确定性可能会在数据包实际意义中产生巨大的差异。由于有效载荷只是整体请求或响应对象的片段,我们无法依赖在给定数据包本身内拥有足够上下文来正确断定一个不确定位的值。因此,如果一个位变坏并且被认为是不可确定的,整个有效载荷就无效了,必须被丢弃。通过将数据包大小减少到可合理达到的最小尺寸,我们最小化了无效位对我们整个请求有效载荷的影响。由于一个位的不确定性而重新请求一个 64 字节的单一数据包,比重新启动整个 5 Mb 的传输要容易接受得多。

聪明的读者可能已经识别出由不可靠的信号强度驱动的数据包的第二种属性。虽然可变的信号强度和外部干扰可能会简单地使单个位不可确定,但它也可能完全翻转位。因此,尽管接收者可能能够确定其接收到的值,但它最终确定的是错误值。这是一个更微妙的问题,因为正如我之前提到的,数据包可能包含不足以确定其有效载荷中特定位的适当值的信息。这意味着数据包必须有一些机制,至少是嵌入到标准头部的错误检测机制。只要消费设备能够检测到错误,它至少可以知道丢弃错误数据包的内容并请求重新传输。

值得注意的是,将请求分解成越来越小的数据包所带来的好处是有极限的,超过这个极限后,对网络性能的益处就会消失。将这种思维方式推向极端,你会很快发现自己为负载中的每一个比特都分配了一个完整的数据包,包括错误检测等。以我们想象中的 5 Mb 请求负载为例,这意味着同时发送了 4,000,000 个数据包。显然,对于如此小的请求来说,这是一个荒谬的数据包数量。相反,网络工程师已经发现,根据给定的协议,发送的数据包大小有一个可靠的范围,这个范围介于几百字节和几千字节之间。

既然我们已经知道了为什么网络通信使用小而独立的数据包,那么我们应该看看这些数据包是什么。

数据包的结构

尽管我在本章中已经提到了一些特性,但在这里我们将更仔细地研究网络数据包必须展示的属性,以便真正作为信息的一部分有用。我们将探讨网络数据包标准的定义以及所有网络数据包将以某种形式包含的最小功能。然后我们将简要地看看不同的传输协议如何实现它们自己的数据包标准,以及如何扩展一些必需的属性以提供更可靠的数据传输或更高的性能。这将为本书后面的内容奠定基础,我们将探讨网络安全、诊断和优化。

什么是数据包?

因此,首先,我们应该进行一些细节上的探讨。我在本章中一直使用的术语“数据包”并不是严格意义上描述我所描述内容的最佳术语。到目前为止,我一直使用“数据包”这个词来描述通过网络传输的最基本的数据传输单位。然而,为了准确起见,我应该指出,术语“数据包”特指开放系统互联OSI)网络堆栈中网络层传输的最基本的数据单元。在传输层,我们将最关注它(因为那是我们在 C#中直接交互的堆栈中的最低层),数据传输的基本单位实际上被称为数据报。然而,我要指出的是,通常更常见的是将传输层的数据单元称为数据包而不是数据报,因此我将在本章以及本书的其余部分继续使用这个术语。不过,我确实想利用这个机会指出这两个术语之间的区别,以防你在不同的上下文中遇到了这两个术语中的任何一个。有了这个前提,那么数据报或数据包究竟是什么呢?

我们已经对包必须具备哪些特性才能变得有用有了相当的了解,所以让我们将其正式化为一个定义。是数据的一个原子单位,它封装了足够的环境信息,以便在任意网络实现中可靠地传输。

所以基本上,它是一个有效载荷(数据单位)和一个头部(足够的环境信息)。这一点到此时应该不会令人惊讶,但让我们看看这个定义是如何转化为从我们的传输层传递到网络层的实际字节数组的。为了做到这一点,我们将使用 Wireshark 来检查发送到和从我自己的以太网端口的数据包,并查看定义中的每一部分是如何转化为实际的数据报的。

设置 Wireshark

作为网络工程师的工具,Wireshark 极其有用,我强烈建议你熟悉其功能,并开始思考你如何在你的开发任务中利用它。现在,尽管如此,我们将使用其最基本的包嗅探功能来检查通过我们开放的互联网连接的每一个包。因此,一旦安装了 Wireshark,只需打开它,并选择你的以太网连接作为包嗅探的目标,如下面的截图所示:

图片

当你在自己的机器上打开它时,花点时间观察一下流量源右侧的图表增长。这实际上提供了在给定源随时间变化的相对活动的一个快速视图。一旦你选择了主要的互联网源,就可以通过点击工具栏左上角的捕获按钮或简单地双击所需源来开始捕获。让工具捕获几分钟的流量,以获取良好的样本数据范围,然后开始自己探索。如果你以前从未使用过 Wireshark 或 Fiddler 这样的工具,你可能会惊讶于即使没有你的直接输入,实际上也在发生多少对话。

在安装并运行工具后,让我们看看我们定义的包的一些特性,并看看它是如何转化为实际应用中的。

原子数据

如果你有任何数据库设计的经验,你可能已经对构成原子数据的概念有了相当清晰的认识。通常,这意味着记录可以被分解成最小的组成部分,而不会失去其意义。然而,在网络通信的背景下,我们并不真正关心数据包的有效载荷失去意义。它会被接收方重新编译成原始的数据结构,因此,即使通过网络传输的小块数据本身没有意义,这也是可以接受的。相反,当我们谈论网络事务中的原子数据时,我们实际上是在谈论我们可以将数据截断到最小大小的程度,超过这个大小,我们将不再看到将数据缩小成更小块所带来的预期好处。这些块可能会将双精度十进制值分成两部分,一部分在一个数据包中发送,另一部分在完全不同的数据包中发送。因此,在这种情况下,任何一个数据包都没有足够的信息来理解其原始形式的数据。它不会被看作是像数据库中用户记录的FIRST_NAME字段那样最原子化的存储方式。但如果这种分解在当前网络中实现了数据包传输的最有效分布,具有最小的延迟和最大的带宽利用率,那么它就是以网络数据包表示的最原子化方式。

例如,只需查看你在 Wireshark 捕获中记录的任何任意数据包。查看我的数据流中的一个数据包,我们有一个这样的传输控制协议TCP)数据包(或数据报),如下所示:

图片

正如你在 Wireshark 面板底部原始数据视图所选文本中看到的那样,该特定数据包的有效载荷是 117 字节的无意义垃圾。这可能对你或我来说似乎没有太大用处,但一旦将这个特定的 TCP 请求与该请求中的其他数据包重新组装,最终的数据应该对消费软件(在这种情况下,是我电脑上运行的 Google Chrome 实例)有意义。这就是所谓的原子数据单元的含义。幸运的是,这不是我们需要担心的事情,因为这是由传输层的硬件实现直接处理的。因此,尽管我们可以实现直接利用我们选择的传输层协议的软件,但在.NET Core 平台上工作时,分解和重新组合数据包或数据报的实际行为总是超出我们的控制。

封装了足够多的上下文

我们定义的这个方面实际上是真正的核心,也是我们最初使用 Wireshark 的原因。用足够的上下文封装一个数据包究竟意味着什么?让我们从数据报必须具备的上下文开始。这指的是源主机和目的主机之间任何设备都需要的信息,以便相应地路由数据包,以及目的主机在接收到数据包后正确读取和处理所需的信息。出于明显的原因,这些信息包含在数据包的最前面(也就是说,它构成了接收设备将首先读取的位),这就是数据包头部的构成。上下文是正确转发或处理数据包所需的信息。

那么,什么构成了足够的上下文呢?嗯,这实际上取决于数据包构建的具体协议。不同的协议有不同的要求和期望,因此,对正确服务的要求也不同。对某一方面构成足够上下文的内容,可能对另一方面来说就极其不足。

最常用的传输层协议是 TCP 和用户数据报协议UDP),它们各自为利用它们的软件应用提供了不同的服务合同。这意味着它们都有非常不同的头部规范。TCP 旨在为在主机之间传输的数据包提供顺序的、可靠的、经过错误检查的传输服务。与此同时,UDP 作为一种无连接的协议(我们将在本书的后面具体讨论这意味着什么),并不明确旨在提供传输的可靠性或数据顺序的保证。相反,它寻求提供轻量级通信,并使用最少的协议定义来强制执行。因此,UDP 的足够上下文实际上比 TCP 数据包的上下文要少得多。

一个 UDP 数据包头部由 8 个字节的数据组成,分为 4 个单独的字段,每个字段长度为 2 个字节;这些字段如下:

  • 源端口:生成请求的源机器上套接字连接的特定端口。

  • 目的端口:目的机器上连接的端口。

  • 长度:数据包的确切长度,包括紧跟 8 个字节头部的有效载荷。

  • 校验和:用于验证有效载荷数据完整性的简单值。

使用 Wireshark,我们可以看到这个动作。在一个简单的 UDP 数据包中,所有内容都由那些相关字段捕获,正如我在 Wireshark 窗口中间的“数据包详情”视图中所看到的:

图片

然而,由于 TCP 提供可靠交付、保证顺序,并利用 UDP 放弃的手 shake-协议,TCP 数据包头部的规范要长得多。对于 UDP,足够的上下文可以封装在仅仅 8 个字节中,而对于 TCP,足够的上下文需要多达 20 字节的头部。这包括表示更大会话中单个数据包状态的多个标志位,以及一个序列号,以提供协议指定的数据包顺序。在 Wireshark 中简单 TCP 数据包的“数据包详细信息”视图的简要检查应该可以阐明 TCP 数据包头部提供的预期上下文差异,如下所示:

正如你所看到的,尽管 TCP 数据包的实际字节长度比我们之前查看的 UDP 数据包短,但头部提供了比有效 UDP 连接所需的信息多得多的信息。显然存在重叠(源端口和目的端口,以及校验和),但两个头部之间的差距比共同点要大。

因此,希望现在已经很清楚,构成足够上下文的原因是由构建数据包的协议所驱动的。具体什么是足够的可能会变化,但每个协议都会有足够的最小上下文,足以转发或处理。

错误检测与纠正

在我们继续之前,我确实想花一点时间来谈谈错误检测和错误纠正之间的区别。你可能想知道为什么我在对数据包的定义中省略了有关错误纠正或错误检测的任何规定。这是因为对于 OSI 堆栈传输层定义的每个协议,并不能保证数据包总是包含足够的信息来检测或纠正传输过程中产生的错误。

然而,我要说的是,在给定的协议规范中至少有一些形式的错误检测是非常常见的。TCP,甚至是不太可靠的 UDP 传输协议,都提供了校验和来进行简单的错误检测,如下面在 Wireshark 中看到的两个数据包所示:

然而,那些协议没有提供任何错误纠正机制*,这实际上要困难得多,并且对于任何非简单纠正功能,都需要将数据包大小大幅增加。例如,虽然校验和可以告诉你有效载荷在传输过程中是否发生了改变,但它无法告诉你具体在哪里,或者改变的程度如何。要做到这一点,需要足够多的附加数据来从头开始重建数据包。由于数据包传输在一般情况下是可靠的(也就是说,即使一次传输失败,重试传输很可能会成功),并且在传输层通常非常快,因此简单地检测错误、丢弃错误数据包并请求重传总是更好的选择。

在这种情况下,我们对在给定协议下定义的每个数据包必须具备的一切以及如何检查或使用网络数据的各个部分都有一个稳固的概念。但我们的软件不会使用这些微小的数据片段。我们的软件期望的是 JSON 对象、XML 有效载荷或 C#对象的序列化字节流。那么,消费网络流量的软件是如何处理这些随机的小数据包流呢?通过将它们作为流*使用。

流和序列化 - 理解顺序数据传输

因此,当我们的庞大、笨重的 JSON 请求被拆分成微小的、亚千字节的数据包,并以看似随机、不连贯的数据片段数组的形式发送时,我们怎么可能期望我们的接收者处理这些数据呢?好吧,在 C#中,这就是数据流概念出现的地方。在我们应用程序代码的上下文中,我们可以可靠地假设传输层会为我们重新组合数据包,以便我们一有机会就可以消费它们。所以,一旦我们得到了这个位序列,我们如何消费它呢?作为一个 IO 流!

Stream 类

如果你曾在旧版.NET Framework 版本的 C#中读取或写入本地文件系统,你将已经熟悉这个概念。在.NET Core 中,我们可以将System.IO命名空间导入到我们的应用程序中,通过简单地打开一个新的StreamReader对象,并用一个连接到目标套接字的NetworkStream实例初始化它,就可以直接开始处理由 TCP/IP 套接字返回的数据。那么,什么是流?你应该如何使用它?

流是处理序列化数据的一个强大概念。它们提供对顺序数据源的单一访问,并允许你显式地处理该数据。执行Read()ReadAsAsync()方法,或其他相关方法,将触发这种单向遍历;从开始处开始,按需逐字节读取整个序列,直到达到终止字符。.NET 对这个概念的实施非常灵活,以至于,无论你使用的是Stream抽象类的哪个具体实例,StreamReader类都将配备接受数据、相应地遍历它,并允许你根据需要构建非序列化的 C#数据结构的功能。

我们将在后面的章节中更详细地研究流,但到目前为止,我想强调在网络通信的背景下,流是由特定端口或套接字接收到的数据包序列组成的,并通过Stream类的标准化实现返回到您的应用程序。

这只是.NET Core 提供的抽象功能套件的一个例子。因此,尽管你现在已经具备了处理从传输层返回的单独数据包并从头开始重建网络请求响应的必要理解,但你很幸运地不必这样做。Core 框架为你处理了这个头疼的问题。并且,有了这个底层的额外视角,我希望你感觉更有能力去解决未来网络依赖型应用程序中可能出现的性能问题或微妙的网络错误。

摘要

回顾本章,我们涵盖了网络通信的最底层细节。首先,我们学习了物理网络基础设施的三个最常见约束,这些约束要求将网络请求分解成数据包。我们探讨了应用程序托管上下文的各个方面如何对我们的请求贡献一定的延迟,带宽如何改变请求从一个节点移动到另一个节点的方式,以及信号强度如何损害数据包的完整性。

接下来,我们探讨了这些因素如何需要小型、上下文完整、原子性的数据包作为我们的网络请求传输格式。我们分析了某些常见协议如何通过标准化格式在每个数据包中提供完整的上下文。这让我们对如何将较大的网络请求分解并发送到我们的网络管道有了更清晰的认识。

最后,我们探讨了如何将一组在不一致的时间间隔内交付的数据包作为顺序流进行消费。有了这一切,我们基础的最底层已经建立,我们拥有了探索.NET Core 应用程序中 C#如何提供该功能的完整网络基础设施和通信标准的背景。这正是我们将在下一章中探讨的内容,我们将最终在面向用户的应用程序中生成一个网络请求,并完全解析由.NET Core 托管平台实现的该过程的每一步。

问题

  1. 什么网络的三种约束需要将网络请求分解成数据包?

  2. 列出本章讨论的每种类型的延迟。

  3. 为什么不可靠的信号强度需要更小的数据包大小?

  4. 数据报的定义是什么?

  5. 数据报的两个组成部分是什么?

  6. 在数据报或数据包方面,什么算是足够的背景信息?

  7. .NET Core 的哪个功能促进了不可靠数据流的处理?

进一步阅读

为了更好地理解数据包和数据流如何在分布式系统中操作,请查看以下书籍:《使用 Wireshark 进行数据包分析》,作者 Anish Nath,Packt Publishing,详情请见:www.packtpub.com/networking-and-servers/packet-analysis-wireshark

为了更深入地了解实际中的数据流,请考虑以下书籍:《使用 Microsoft Azure 进行流分析》,作者 Anindita Basak、Krishna Venkataraman、Ryan Murphy 和 Manpreet Singh,Packt Publishing,详情请见:www.packtpub.com/big-data-and-business-intelligence/stream-analytics-microsoft-azure

第二部分:通过网络进行通信

书的第二部分将开始深入探讨使用 C#编写网络软件的具体实现细节。它将从解释.NET 框架提供的最易用和最基本抽象开始,这些抽象用于生成和处理网络请求。最后,它将探讨数据传输如何影响软件设计和复杂性。

本节将涵盖以下章节:

第五章,在 C#中生成网络请求

第六章,流、线程和异步数据

第七章,通过线缆的错误处理

第五章:在 C# 中生成网络请求

因此,现在我们对网络的本质有了深入和完整的理解。我们理解了网络对部署在那些网络上的软件和硬件的设计和实现提出的要求。但我们应该用这些知识做什么呢?在本章中,我们将最终探讨在 .NET Core 中利用网络资源的最常见范例。我们将查看在互联网上实现请求/响应事务模型的通用接口(您将与之工作的最普遍的网络),并检查其一些具体实现。在这个过程中,我们将通过拆解我们将要使用的 .NET 类的一些源代码来查看底层的操作。

本章将涵盖以下主题:

  • WebRequest 类的基本结构,以及每个子类通过其方法保证暴露的功能

  • 如何根据您可能遇到的不同用例利用 WebRequest 类的不同子类,并理解它们提供的不同操作

  • C# 实现的请求执行的内部阶段

技术要求

本章的所有代码都可以在本书的 GitHub 仓库中找到,网址为 github.com/PacktPublishing/Hands-On-Network-Programming-with-CSharp-and-.NET-Core/tree/master/Chapter%205

如前所述,本章中的所有代码都可以使用 Visual Studio Code 或 Visual Studio Community Edition(或者对于 macOS 系统上的用户,是 Visual Studio for macOS)进行阅读、操作、构建和部署。您使用的具体源代码控制编辑器通常是个人观点的问题,但我向您保证,您用于处理本章代码的任何工具都足以满足本书其余部分的所有代码。我鼓励您坚持这个决定,并花些时间熟悉它。我预计本书的大多数读者对 .NET Core 开发的最佳环境已经有了根深蒂固的看法。如果您还没有,我鼓励您选择您感觉最舒适的一个(无论是功能丰富的 Visual Studio Community Edition,还是轻量级、多平台友好的 Visual Studio Code)。一旦您做出了选择,请确保您花大量时间熟悉该环境中的工具。学习键盘快捷键并设置您的自动格式化选项。让它成为您的专属;一旦您做到了,您就可以开始学习了。

一切尽在一类——WebRequest 抽象类

任何软件工匠都会告诉你,如果你想了解如何利用其他开发者提供的库或工具集,只需查看公共接口。如果接口设计得足够好,那么如何使用该工具就会一目了然。良好的接口设计能够传达大量关于某块库软件使用限制和原始意图的信息,这正是本节将要探讨的内容。System.Net命名空间中的WebRequest抽象类是创建和操作旨在通过互联网发送的通用网络请求的公共接口。

接口或抽象类

我一直在描述抽象的WebRequest基类,它提供了如何让微软希望开发者与网络操作交互的接口。然而,我必须承认这并不完全准确;从技术上讲,WebRequest是一个抽象类。对于那些对这种区别不熟悉的读者,对我们来说这实际上是非常微不足道的。

抽象类实际上确实定义了一个与其实现一起工作的接口。两者之间的相关区别在于,使用抽象类时,接口中提供的任何给定方法通常在抽象基类本身中定义了一个默认实现。因此,抽象类提供的方法仍然通过接口定义了作为具体类消费者的你将如何与类的实现交互。这实际上只是定义该接口预期行为义务所在的一个区别。既然你不能像实例化接口定义一样实例化抽象类,所以这种区别完全是微不足道的。除非,当然,你选择从WebRequest类继承(我们将在本章末尾这样做)。不过,现在,让我们先回顾一下WebRequest提供的规范。

接口

对于任何类型的抽象类或接口定义,它们恰当的使用方式可以通过两个不同的视角来理解。抽象的形状通过接口的属性变得清晰。这使用户对接口实例应该使用的适当上下文有一个具体的概念。它应该清楚地传达抽象应该操作的领域。同时,抽象的范围通过类的方法签名传达。这是告诉用户类如何在其形状或属性定义的领域内操作的。

一个命名良好的接口应该为类的有用性界限提供清晰的界限。如果一个接口定义良好,就像WebRequest基类一样,它的属性和方法签名应该清楚地表明何时应该使用,以及何时不应该使用。更重要的是,如果应该使用,命名良好且范围明确的函数签名将告诉用户如何使用该方法。

因此,带着这个观点,让我们来看看WebRequest类的基定义中有什么。这个规范将告诉我们它应该如何使用,以及我们如何为自己扩展或实现它。那么,从构造函数开始不是更好吗?

构造函数

WebRequest只为它的子类定义了两个基构造函数。第一个是默认的无参数构造函数。第二个允许开发者指定SerializationInfoStreamingContext类的实例,以便更精确地定义新创建的类实例的有效用例范围。因此,我们的构造函数签名将类似于以下代码块:

public WebRequest() {
    ...
}

public WebRequest(SerializationInfo si, StreamingContext sc) {
    ...
}

到目前为止,这相当直接,但为什么要使用第二个构造函数呢?在WebRequest实例中使用SerializationInfoStreamingContext有什么如此普遍的地方,以至于基类定义了一个接受这些类实例的构造函数?

我们将在后面的章节中更详细地研究流上下文,但在上一章中我们简要讨论了可靠序列化数据的需求,这是一个更全面考虑该概念的好地方。每个请求或响应的有效负载在传输之前都需要进行序列化,在到达目标机器后进行反序列化。正如我们之前讨论的,这是一个将无序、本地寻址的数据块转换为有序的零和一字符串的过程。具体来说,它必须以这种方式排序,以便相同的字符串可以按顺序遍历,并由接收机器用来组合本地寻址的内存对象。

因此,虽然我们的软件可能将有序整数列表存储为一个连续内存地址的数组,但这是一种与表示的数据结构基本无关的实现细节。唯一的关键细节是列表是有序的,并且它是一个整数列表。它同样可以表示为一个底层的链表,其中列表中的每个节点包含存储在该节点的整数,以及列表中下一个节点的地址,这些地址可能连续也可能不连续。在内存中,这两个数据结构有显著的不同:

图片

然而,只要为如何表示这两个列表提供了适当的序列化信息,它们对于接收这些列表作为请求或响应负载的任何接收者来说应该看起来是相同的。它们应该只是整数的一个良好分隔的列表。如果你的序列化机制是典型的JavaScript 对象表示法JSON)格式,这两个实现都会序列化相同的输出:

[
    int,
    int,
    int,
    ...
]

通常,你会发现WebRequestWebResponse实例经常被用于相同类型的消息,并且它们的负载应该以相同的方式进行序列化。能够将SerializationInfo作为构造函数的输入参数,这给了你一次定义你的序列化规则和细节,然后可以在理论上无限数量的请求中利用它们的灵活性。

对于StreamingContext参数也是如此。由于大多数网络软件都是编写来促进在软件的生命周期中以相同方式执行的操作,因此在一个特定的应用程序中,你的请求不太可能需要利用不同类型的 I/O 流。稍后,我们将更详细地查看可用的不同类型的流。这是一个密集的主题;然而,现在,只需知道这个输入参数给你与SerializationInfo参数相同的灵活性。它允许你一次定义你的流上下文,然后重复使用它。

仅通过这两个签名,我们就涵盖了WebRequest基类中明确定义的所有构造函数。这应该给你一个相当清晰的想法,即这个库的编写者预计它可能会如何被使用。当然,如果你想的话,你可以编写一个子类,它接受 HTTP 动词和默认的头部值,以及你发送请求之前可能需要定义的给定请求的所有其他方面。但就其最基本的形式而言,这些构造函数签名告诉你,这是一个旨在提供可靠序列化可靠数据流的类的实例。

类属性

因此,你的构造函数清楚地说明了类预期在什么上下文中使用,而你的属性定义了请求的整体形状。它们定义了类实际是什么的最清晰和最无歧义性的描述。我们可以从WebRequest的属性中学到什么?好吧,让我们更仔细地看看。

根据基类规范,类的公共属性按字母顺序排列(正如它们在 Microsoft 文档中列出,这里:docs.microsoft.com/en-us/dotnet/api/system.net.webrequest?view=netcore-3.0),如下所示:

  • AuthenticationLevel

  • CachePolicy

  • ConnectionGroupName

  • ContentLength

  • ContentType

  • Credentials

  • DefaultCachePolicy

  • DefaultWebProxy

  • Headers

  • ImpersonationLevel

  • Method

  • PreAuthenticate

  • Proxy

  • RequestUri

  • Timeout

  • UseDefaultCredentials

那么,这告诉我们从该抽象类派生的实例有什么信息呢?显然的信息是,它封装了在互联网上使用的任何协议中进行的请求的常见方面。HeadersMethod(即特定的协议方法,如GETPOSTPUT HTTP 方法)和RequestUri都是您从实用程序类中期望得到的。然而,其他如ImpersonationLevelAuthenticationLevelCachePolicy等属性表明,WebRequest类不仅仅是封装请求负载,而是真正旨在封装一个操作。

验证和缓存响应的操作超出了简单请求负载的责任范围,更多地属于负责在您的应用程序和外部资源之间进行请求和响应代理的软件部分。这些方法的存在表明,此类(及其子类)旨在成为请求和响应网络资源的代理。其定义清楚地表明,它可以处理连接到远程主机、验证自身以及通过扩展,您的应用程序的细微细节,序列化负载,反序列化响应,并通过干净简单的接口提供所有这些。

通过ContentTypeContentLength属性,它提供了一种干净的方式来访问和设置任何带有负载的请求最常用的头值。规范告诉您:“就给我这个包,告诉我你想把它发送到哪里,剩下的交给我处理。”它甚至提供了一个接口,通过ConnectionGroupName属性将类似的操作组合在一起,在连接组中进行批量处理。

假设您有多个请求同一个外部 RESTful API,该 API 位于https://financial-details.com/market/api,并且您的应用程序在其运行期间访问了十几个不同的端点。同时,您还有少量请求需要路由到https://real-estate-details.com/market/api。您可以简单地关联所有针对财务详情 API 的请求到一个连接组名称下,并将房地产详情 API 请求关联到另一个。这样做允许.NET 更可靠地管理对单个ServicePoint实例的连接。这允许将多个请求路由到单个端点,通过相同的活跃连接,提高性能并降低所谓的连接池饥饿的风险。

尽可能地,确保你使用 ConnectionGroupName 来将请求关联到单个端点,并通过单个连接进行。在任何 .NET Core 应用程序中,你可以在给定时间内保持有限数量的活动连接,如果没有 ConnectionGroupName 将请求绑定到单个连接,每个请求都将从 .NET Core 运行时的可用连接池中分配其自己的连接。在具有高网络流量或频繁外部请求的应用程序中,这可能导致线程饥饿和不稳定的性能。

实现此功能相当简单,但它可以在性能调整和调试错误时为你节省大量时间。只需为每个你想要利用的连接组定义一个静态常量名称,如下所示:

namespace WebRequest_Samples
{
    // Service class to encapsulate all external requests.
    public class RequestService {
        private static readonly string FINANCE_CONN_GROUP = "financial_connection";
        private static readonly string REAL_ESTATE_CONN_GROUP = "real_estate_connection";

...

然后,每次你需要为目标端点实例化一个新的请求时,你只需通过赋值指定连接组名称,然后底层 ServicePoint 实例将检查任何共享组名称的连接,如果发现了一个,就会为你的新请求利用该连接:

public static Task SubmitRealEstateRequest() 
{
    WebRequest req = WebRequest.Create("https://real-estate-detail.com/market/api");
    req.ConnectionGroupName = REAL_ESTATE_CONN_GROUP;
    ...
}

就这样,你的请求将利用到与相同外部资源建立的任何连接。如果没有其他请求与指定的 ConnectionGroupName 属性相关联,那么 .NET Core 将在其连接池中创建一个连接,并将你的请求作为连接组中的第一个关联。这对于一组请求针对需要访问凭证的资源特别有用,因为连接一旦建立,就会与后续请求共享,这些请求使用相同的访问凭证!

一旦建立了连接,我们就需要知道如何处理该请求的响应。为此,我们有 CachePolicy 属性。该属性指定了你的请求应该如何处理远程资源缓存的响应的可用性。该属性为我们提供了对何时以及如何依赖缓存响应的精确控制,如果确实需要的话。例如,如果我们有一个几乎不断更新的数据集,并且我们始终希望获得最新的响应,我们可以通过相应地设置策略来完全避免缓存:

using System.Net.Cache;
...

public static Task SubmitRealEstateRequest()
{
    WebRequest req = WebRequest.Create("https://real-estate-detail.com/market/api");
    req.ConnectionGroupName = REAL_ESTATE_CONN_GROUP;
    var noCachePolicy = new RequestCachePolicy(RequestCacheLevel.NoCacheNoStore);
    req.CachePolicy = noCachePolicy;
    ...
}

就这样,请求将忽略任何可用的缓存响应,同样,它也不会缓存从外部资源接收到的任何响应。正如你所看到的,该属性期望一个 RequestCachePolicy 对象的实例,通常使用 System.Net.Cache 命名空间中找到的 RequestCacheLevel 枚举定义的值进行初始化(如代码块顶部的包含所示)。

这又是一个熟悉 Visual Studio 的 IntelliSense 工具可以给你一个清晰想法的例子,了解那个枚举中可用的值。当然,如果你使用的是 Visual Studio Code 或其他源代码编辑器,你总是可以在制造商的网站上查找它的源代码或文档。无论你使用哪个编辑器,对于使用起来不易推断的属性或方法,养成查阅实现细节和 Microsoft 文档上的注释的习惯。但是,对于像定义缓存策略的枚举这样明显直接的东西,Visual Studio 的自动完成和 IntelliSense 功能可以节省你从 IDE 转移注意力去查找有效值的时间和精力。

就像你定义围绕缓存或可缓存的响应的行为一样,你可以使用 WebRequest 实例的公共属性来定义和指定应用程序的认证行为以及你对远程资源认证的期望。这是通过 AuthenticationLevel 属性暴露的,其行为与我们刚刚查看的 CachePolicy 属性非常相似。

假设,例如,你的软件依赖于一个明确配置为仅与你的软件一起工作的远程资源。远程服务器需要认证请求以确保它们是由你的软件的有效实例生成的。同样,你也会想确保你是在直接与正确配置的服务器通信,而不是某个中间人代理试图窃取你的宝贵财务和房地产细节。在这种情况下,你可能会想确保每个请求都是双向认证的,我相信你已经能预见我接下来要说什么了。

由于 WebRequest 类旨在封装与远程资源交互的整个操作,我们应该期望能够使用适当的认证策略来配置该类的实例,而不必自己管理它。这正是我们可以做到的。基于我们之前的例子,我们可以定义 AuthenticationLevel 属性来强制执行我们想要使用的策略,然后让 WebRequest 实例接管:

using System.Net.Security;
...
public static Task SubmitRealEstateRequest() 
{
    WebRequest req = WebRequest.Create("https://real-estate-detail.com/market/api");
    req.ConnectionGroupName = REAL_ESTATE_CONN_GROUP;
    var noCachePolicy = new RequestCachePolicy(RequestCacheLevel.NoCacheNoStore);
    req.CachePolicy = noCachePolicy;
    req.AuthenticationLevel = AuthenticationLevel.MutualAuthRequired;
    ...
}

注意到我们在 using 指令中包含了 System.Net.Security 命名空间。这就是 AuthenticationLevel 枚举被定义的地方。这很有道理,因为认证是大多数网络软件认证和授权安全组件的一半。但我们会稍后再深入探讨这一点。

如你所猜,获取你自己的软件认证可能需要一些凭证。

分配凭据就像定义你的身份验证或缓存策略一样简单。在 WebRequest 类定义中,Credentials 属性是 System.Net 命名空间中的 ICredentials 接口的一个实例,通常实现为 NetworkCredential 类的一个实例。同样,实现网络请求可靠安全性的完整范围将在本书的后面部分进行讨论,但就目前而言,让我们看看我们如何向我们的相互认证的 Web 请求添加一些凭据。它使用 System.Net 命名空间,因此不需要额外的 using 语句。相反,我们可以简单地将属性设置为 NetworkCredential 的新实例,然后继续,如下所示:

req.Credentials = new NetworkCredential("test_user", "secure_and_safe_password");

实际上我们应该将密码存储为 SecureString,但这个构造函数是有效的,正如我所说的,我们将在后面的章节中更详细地探讨安全性。

通过这个简短、直接的示例,我们可以清楚地看到 WebRequest 类的属性如何定义了实现和扩展它的具体子类的预期使用场景。现在我们了解了 WebRequest 想要为我们抽象的操作的形状和范围,让我们看看通过类公开的方法来实际执行这些操作。

类方法

现在我们对 WebRequest 类的形状有了足够完整的了解,让我们来探索它的范围或适当的使用。让我们看看它的公共方法。了解基类中可用的功能将为你提供在大多数使用场景中利用任何具体实现所需的一切,可能只需要进行一些小的修改。因此,就像我们查看类属性一样,让我们看看以下公共方法列表,并看看我们可以推断出关于如何使用该类的哪些信息:

  • Abort()

  • BeginGetRequestStream(AsyncCallback, Object)

  • BeginGetResponse(AsyncCallback, Object)

  • Create(string)

  • Create(Uri)

  • CreateDefault(Uri)

  • CreateHttp(string)

  • EndGetRequestStream(IAsyncResult)

  • EndGetResponse(IAsyncResult)

  • GetObjectData(SerializationInfo, StreamingContext)

  • GetRequestStream()

  • GetRequestStreamAsync()

  • GetResponse()

  • GetResponseAsync()

  • GetSystemWebProxy()

  • RegisterPrefix(string, IWebRequestCreate)

我只包括了针对 WebRequest 类的特定方法,并省略了从父类继承的公共方法,例如 MarshalByRefObjectObject,因为那些与我们目的无关。然而,有了这个基本的操作列表,该类的实用性应该相当明显。

最可能引起注意的第一件事是,这个类应该异步使用。所有的 BeginEnd 方法,以及许多其他方法上的 Async 后缀,都告诉你这个类通过 .NET Core 的异步特性支持对请求生命周期的精细控制。现在,如果你从未做过异步编程(正如我经常发现的那样,对于刚刚从学校毕业的新程序员,或者对网络开发新手来说),我们将在下一章中更详细地介绍这个心理飞跃。如何最好地利用异步功能,或者幕后发生了什么,并不总是直观明了;所以,现在就把它想成是推迟方法的实际执行直到以后。就像所有那些方法所暗示的那样,你 Begin 执行一个任务,然后当你准备好时,你 End 它并查看你的结果。

这个类中的方法可以分为两个概念组。有用于状态管理的方法和用于请求执行的方法。状态管理方法允许你修改或进一步定义 WebRequest 实用类实例的状态。利用它们进一步配置和定义实例的行为,类似于我们在上一节“类属性”中设置的任何公共属性。之所以有执行此操作的方法,而不是简单地有更多可设置的属性,是因为这样做至少涉及一些非平凡的逻辑或特定情况下的细节,这些细节在每个方法调用时都会应用。同时,请求执行函数允许你使用实例的行为来定义、调用和解决网络请求。它们是那些使所有早期配置变得有价值的“工作马”。因此,让我们依次查看这些方法集,并完全明确我们对这个类的理解。

状态管理方法

我鼓励你尝试将我列出的方法分类到我将要为你描述的两个类别中。将来,我也鼓励你尝试以这种方式对接口和公共类定义进行分类。这样做将提高你快速阅读和吸收新软件功能的能力,并有效地利用它们,而不是从 StackOverflow.com 复制代码片段,直到你找到可以工作的事情。话虽如此,让我们来看看状态管理函数。

首先,我们有 Create 方法。这些方法中的每一个都将返回一个可用的具体 WebRequest 子类实例。它们都是静态的,因此可以在不首先创建其实例的情况下从类定义中调用它们(显然的原因;为什么您需要创建一个类的实例然后创建另一个类的实例呢?)。根据使用的方法,这将为方法提供的 URI 中指定的给定方案设置默认子类的实例。因此,如果我们想要访问 RESTful HTTP 服务中的数据、从指定的 FTP 服务器收集文件以及从远程文件系统中读取数据,我们可以通过简单调用 Create(uriString) 来完成所有这些操作:

var httpRequest = WebRequest.Create("http://test-domain.com");
var ftpRequest = WebRequest.Create("ftp://ftp.test-domain.com");
var fileRequest = WebRequest.Create("file://files.test-domain.com");

您可能已经从我们在 类属性 部分编写的 SubmitRealEstateRequest 示例方法中认出了这段代码。我之前没有解释它,但因为这个类定义得如此清晰简单,我预计您能够从我的代码中很好地推断出它的用途,而无需这种解释。但以防您想知道为什么它看起来像是在创建一个抽象类的实例(C# 中的编译时错误),那就是原因。我实际上是从抽象基类中请求适当的子类实例,这是一个静态定义。

上述代码块中的三个用例涵盖了您可以使用 Create() 方法直接完成的大部分操作,但这当然并不意味着 Create() 只能应用于这些用例。该功能使用 URI 的通用协议前缀来确定要实例化的默认子类。因此,只需将 test-domain.com 传递给该方法,默认实现就足以返回 HttpWebRequest 类的实例。用于解析前面字符串的相同逻辑也用于告诉 WebRequest 应为哪个协议创建子类。

正如我所说的,但是,默认行为仅针对有限的一组用例进行了定义。有四个特定的协议,它们的具体子类在运行时已预先注册到 WebRequest 类中;它们如下所示:

  • http://

  • https://

  • ftp://

  • file://

因此,任何以这四个前缀之一作为字符串首字符的 URI 字符串,都将被 WebRequest 基类可靠地处理。由于基类为其子类提供了执行核心操作所需足够接口,因此您甚至不需要知道具体返回了哪个子类。多亏了类型继承,您只需将您的实例声明为 WebRequest 类型,并相应地使用它,就像我在之前的示例方法中所做的那样。

但如果你不想使用这四种预注册的类型之一怎么办?如果你编写了自己的自定义WebRequest子类,专门用于处理WebSocketWS)协议,并且你希望通过传递一个带有 WebSocket 前缀ws://的 URI 来从WebRequest获得相同支持?那么,这种确切的使用场景就引出了另一种状态管理方法:RegisterPrefix(string, IWebRequestCreate)

RegisterPrefix是一个强大的新工具,支持所谓的可插拔协议。它基本上是一种让你将自定义实现和WebRequestWebResponse基类的子类纳入应用程序运行时的方式。当正确完成时,你的自定义代码可以被视为System.Net命名空间中的第一类公民,由系统类型和方法适当委派,并能够完全访问网络堆栈,就像你接下来将要学习的原生库类一样。

完全实现自定义协议处理器的范围和深度超出了本章的内容,将在本书的后续章节中更详细地探讨。不过,现在只需知道,一旦完成编写自定义协议处理器的任务,将其连接起来就像调用RegisterPrefix一样简单。这就是为什么这属于状态管理方法范畴;因为它关乎为应用程序运行时配置WebRequest的工作条件。

该方法返回一个bool来指示你尝试注册自定义协议的成功或失败,并相应地抛出或处理异常。因此,虽然设置可插拔协议的过程超出了本章的范围,但在此阶段,只需相信一旦这项工作完成,将其配置为WebRequest类有效状态的一部分是一个简单直接的事情:

if(!WebRequest.RegisterPrefix("cpf://", new CustomRequestCreator())) {
    throw new WebException("Failure to register custom prefix protocol handler.");
}

有了这些,我们就拥有了配置和初始化网络请求所需的所有工具。状态管理已经完成,剩下的就是开始提交请求和处理响应。

请求执行方法

正如我之前所说,这些方法中的大多数都是设计用来异步使用的,但至少有少数几个有同步的或阻塞的对应方法。虽然我们稍后会更多地讨论异步编程,但重要的是现在要注意,围绕WebRequest类有两个主要操作或任务。第一个是访问实际请求数据流,第二个是访问远程资源返回的响应。

使用WebRequest实例,RequestStream是.NET 中表示的开放连接。把它想象成你可以传输信号的电线。任何时候你想通过WebRequest实例传递数据,你首先需要访问这条电线。一旦你拥有了它,你就可以开始通过这条流传递数据,并相信WebRequest类会相应地处理其传输。

请记住,向流写入通常需要给定对象的原始字节数组(这就是序列化的作用所在),因此一旦我们有了流,写入它并不像直接通过线缆传递我们的对象或消息那样简单,尽管它也不是特别复杂。在实践中,无论你选择如何访问WebRequest活动实例的请求流,写入它通常看起来像以下代码块:

using System.Text;
...
// convert message to bytes
string message = "My request message";
byte[] messageBytes = Encoding.UTF8.GetBytes(message);

//write bytes to stream
Stream reqStream = req.GetRequestStream();
reqStream.Write(messageBytes, 0, messageBytes.length);

这就是全部内容。在WebRequest的一些常见子类中,这种方法有一些细微差别,但基本原理始终适用。

正如你所看到的,这解释了请求执行方法的一半左右。BeginGetRequestStream()/EndGetRequestStream()GetRequestStream()GetRequestStreamAsync()方法是以不同方式访问网络事务相同逻辑组件的三种不同方法。它们只是提供了不同级别的操作同步控制。例如,BeginGetRequestStream()/EndGetRequestStream()方法为用户提供了一个在完成传输之前通过显式调用Abort()方法取消请求的机会。同时,GetRequestStreamAsync()方法不提供显式中止操作的机会,但它确实以异步方式执行操作。具体情况将决定你应该使用哪种方法或哪些方法,但如果处理得当并由底层连接正确解决,结果对象是相同的。

最后,我们可以看看响应处理方法,对于大多数网络事务典型的请求/响应模式,响应处理程序几乎与请求处理程序方法签名完全匹配。因此,在WebRequest实例中检索请求流通过四个不同方法暴露,并具有不同级别的操作同步粒度控制,响应处理也是如此。我们可用的方法有BeginGetResponse()/EndGetResponse()(其处理不能被Abort()中断),GetResponseAsync(),当然还有GetResponse()

理解给定响应的形状将取决于它所接收的具体协议。正如WebRequest类有特定协议的子类一样,WebResponse基类也是如此。我们将在各自的章节中探讨它们,并查看如何更具体地处理它们的响应。但到目前为止,可以说WebResponse类为我们提供了一个足够可靠的接口,可以有意义地与请求返回的任何内容进行交互。

到现在为止,你应该对WebRequest类旨在解决的确切问题有极其清晰的理解。你应该了解其范围和使用案例的限制,并且希望你能确切地知道如何调整它,以便在它能节省你时间和精力的任何场景中充分利用它。带着这种理解,让我们看看一些最常见的方式,即通过.NET 标准提供的子类,明确地利用基类。

WebRequest 类的子类

对于许多典型用例,你可以依赖底层WebRequest类提供的基本功能。然而,你实际上永远不会直接在你的代码中使用它的实例(你不能……它是抽象的,记得吗?),所以现在是时候看看当你使用它的常见具体实例时,还有哪些其他功能或特性存在。我们将查看WebRequest具有默认、预先注册处理器的所有子类。

关于已弃用子类的一个说明

在这里,重要的是要注意,WebRequest类主要是用于与网络上其他资源创建低级、协议无关的请求/响应事务的工具。.NET 标准提供的子类,虽然并未明确弃用,但已被稍微更健壮的客户端类,如HttpClientWebClient类,所取代。

实际上,微软最近发布了一项建议,即始终使用较新的客户端类,而不是我即将讨论的任何稍微老旧的子类。这正是为什么本章如此少地致力于具体类的原因。请求/响应模型的重要方面仍然由.NET 的WebRequestWebResponse类在新的WebClient类的底层处理。更重要的是,这些基类是你构建自己的自定义协议处理器的最基本构建块。这就是为什么理解WebRequest类是如何以及为什么被编写成这样,对于任何初学网络或网络编程的读者来说如此重要的原因。然而,正如软件通常的情况一样,时代在变化,因此,作为对特定实现常见模式的实用指南,本课程的有用程度将随着时间的推移而逐渐降低。

话虽如此,考察这些类之间的不同之处以及它们如何从头开始构建网络请求是值得的,让我们简要地看一下。

HttpWebRequest

HttpWebRequest类很有趣,因为在最近非常长的一段时间里,它是.NET 网络编程中的工作马。这一点从类规范的大量爆炸性增长与WebRequest类相对简单性的比较中可以明显看出。对于给定的有效负载,有每个标准 HTTP 头可能定义的属性,以及从基类继承的 headers 属性,用于指定自定义或非标准头。有属性用于指定传输细节,例如TransferEncoding,或者是否以分块段的形式发送数据。有属性用于指定如何处理来自远程主机的异常行为,例如MaximumResponseHeadersLengthMaximumAutomaticRedirections属性。所有这些属性都允许你从头开始构建一个完整的、强大的 HTTP 请求有效负载。然而,正如你可以想象的那样,对于每个 HTTP 资源的每个请求,这样做通常是繁琐的、容易出错的、冗长的。通常,开发者会手动编写自定义 HTTP 客户端类,以将应用程序的这一方面隔离在“一次编写,到处使用”的方法中。正是这种粒度级别使得微软的工程师决定编写一个更健壮、更易于使用的客户端来处理常见的 HTTP 请求。

然而,值得注意的是,如果你并排查看类规范,HttpWebRequest公开的方法签名与WebRequest公开的方法签名完全相同。这两者之间唯一有意义的区别是HttpWebRequest作为类属性提供的上下文特定配置。这进一步突显了WebRequest设计的优雅。通过采用直接、通用的方法来解决问题,它可以使用相同的模式服务于所有可能的特定用例。

FtpWebRequest

FtpWebRequest类提供了与HttpWebRequest类许多相同的属性。区别在于一些特定的属性,用于在处理可能通过不可靠或慢速连接传输的潜在大文件时配置可靠行为。为此,它提供了ReadWriteTimeout属性,该属性指定处理文件流允许的最大时间量。还有 FTP 特定的UsePassive属性,允许用户指定使用被动传输过程,在服务器上留下一个开放的监听连接,以便客户端相应地访问文件。

此外,还有一个显式的EnableSsl参数,你可能已经注意到它不是HttpWebRequest的属性。有趣的是,这对于FtpWebRequest类是必要的,但对于HttpWebRequest类则不是,因为 HTTP 中安全套接字层(SSL)的使用实际上是在 URI 的协议组件中指定的(即 HTTP 与 HTTPS 之间的区别);而 FTP,该功能必须显式启用。

再次强调,FtpWebRequest类的实际使用与WebRequest基类完全相同。一旦通过类属性正确配置了特定协议的设置,FTP 最终只是另一种用于访问远程资源的请求/响应协议。

FileWebRequest

FileWebRequest可能是所有子类中最不常用的。它的签名几乎完美地匹配WebRequest基类。它的目的是暴露相同的可靠请求/响应模式,用于访问本地文件系统上的资源。

到目前为止,你可能想知道为什么这样一个类会有任何用处。好吧,像任何优秀的工程师一样,我们最终都希望能够对我们的网络软件进行单元和集成测试。然而,这并不总是可行的,因为我们期望在生产环境中可用的远程资源可能并不总是可用在我们的开发环境中。在这种情况下,你将想要能够访问你本地系统上的模拟资源。多亏了WebRequest类的共享父类,替换开发环境和生产环境中的FileWebRequestHttpWebRequest实例是一件微不足道的事情。由于每个子类都仅通过WebRequest类上的Create()方法实例化,这样做就像更改应用程序配置文件中存储的远程资源 URI 一样简单。

FileWebRequest类的强大之处在于其接口的一致性。因此,尽管与这个类的实例没有特殊属性或方法相关联,但将WebRequest的行为扩展到本地文件访问实际上才是这个类有价值的地方。

有了这些,我们对网络交互构建块的基础速成课程就完成了。

摘要

在本章中,我们全面审视了WebRequest实用类,以及它如何在.NET 应用程序的上下文中处理各种常见的网络操作。我们通过类的公共接口来推断类的适当使用和用例,以及确定其作用域和操作的局限性。我们考虑了在基类上定义的每个公共属性和方法的适当使用和调用,并编写了一些广泛适用的示例来展示该类及其子类的简单性和实用性。然后,我们考虑了WebRequest的三个最常见的具体子类。我们考察了它们之间的细微差别,并研究了它们如何促进它们设计要操作的特定协议的细节。现在,我们准备探讨如何以最优化方式处理这些请求的结果,以适应.NET 运行时。是时候研究数据流处理、多线程和异步编程了,这些内容将在下一章中探讨。

问题

  1. WebRequest 类的 CachePolicy 属性的有效值有哪些,它们可以在哪里找到?

  2. 用于将 WebRequest 类的定制子类与该定制子类关联的协议的请求关联的方法是什么?

  3. 在 .NET 连接池中,用于关联多个请求到同一连接的属性是什么?

  4. 对于 WebRequest 类配置为从 Create(uri) 方法返回有效子类的四个预注册协议是什么?

  5. BeginGetRequestStream()GetRequestStreamAsync()GetRequestStream() 之间的区别是什么?

  6. 请列举一些 HttpWebRequest 类与 WebRequest 类默认行为不同的方式?

  7. 为什么在可能的情况下始终利用 ConnectionGroupName 非常重要?

进一步阅读

关于这个主题的进一步阅读,或者在你征服了网络编程领域之后拓展视野,可以查看由 Packt Publishing 出版的 Building Microservices with .NET Core,作者为 Gaurav Aroraa, Lalit Kale 和 Kanwar Manish,www.packtpub.com/web-development/building-microservices-net-core

此外,我还建议查看 Packt Publishing 出版的 C# 7 and .NET: Designing Modern Cross-platform Applications,作者为 Mark J. Price 和 Ovais Mehboob Ahmed Khan,以获取关于此处讨论的概念在实际应用中的实用建议。你可以在这本书的www.packtpub.com/application-development/learning-path-c-7-and-net-designing-modern-cross-platform-applications找到这本书。

第六章:流、线程和异步数据

在我们有资源开始发送网络请求的情况下,我们需要考虑如何最好地将这些请求集成到我们的应用程序中。我们需要以一种不会影响应用程序的业务逻辑或用户的体验的方式与这些资源一起工作。因此,在本章中,我们将探讨如何处理数据流,以便对应用程序的其他性能方面具有弹性和非阻塞性。

本章将涵盖以下主题:

  • 理解 C#中 I/O 流的本性,以及如何写入、读取和管理打开的流

  • 如何不同类型的 I/O 流暴露对不同类型数据的访问,以及父Stream类如何简化这些不同流类型的用法

  • 处理大型或性能不佳的数据流可能带来的潜在性能成本,以及如何减轻这种成本

  • 利用 C#的异步编程功能集来最大化软件的性能和可靠性

技术要求

本章将包含多个示例和驱动程序来展示所讨论的概念,所有这些都可以在github.com/PacktPublishing/Hands-On-Network-Programming-with-CSharp-and-.NET-Core/tree/master/Chapter%206找到。

如往常一样,我们鼓励您在本地克隆此存储库并开始尝试源代码,或者编写自己的代码,以便熟悉本章的一些主题。

查看以下视频以查看代码的实际应用:bit.ly/2HYmhf7

随着 C#中的数据流而行——C#中的数据流

在上一章中,当我们讨论WebRequest类的请求流属性时,我们简要地提到了访问数据流。当时我略过了这个主题,但现在我们应该真正理解我们的数据是如何作为请求有效载荷准备进行传输的。我们将查看 C#中数据流的通用接口,并对流的一些更复杂或不太明显的方面给予特别关注,这些方面可能会在您的代码中引入一些难以发现的错误。因此,让我们从Stream类开始,然后继续前进。

初始化数据流

就像网络请求一样,向数据流写入和从数据流读取是软件工程中常见且直接的任务。实际上,微软为此提供了非常精心设计的通用规范,用于在 C#中执行此操作。基类定义的方法是您将用于任何合理需要执行的数据传输类型,因此以此为起点,让我们看看这个类提供了什么。

Stream 类的目标非常简单,就是提供对有序字节序列的直接访问。关于这个信息没有额外的上下文,因此字节序列可以是本地磁盘存储上的文件,也可以是来自传入请求流的字节,或者是在两个协同定位的应用程序进程之间打开的通信管道,完全存在于内存中。

这个简单的定义提供了一个简单的方法来定义通用的、与环境无关的方法,用于处理有序的零和一列表。然而,它没有提供任何有用的方法来解析、处理和将这些字节转换为和从有意义的内存对象,这些对象对应用程序的其他部分来说是有意义的。作为一个编程任务,这可能有点繁琐,但幸运的是,一些特定的实现提供了一些可靠的实用方法,用于更常见的解析情况。这特别令人愉快,因为流的大部分工作都集中在这里。

一旦你的信息准备好通过二进制数据流传递,或者从数据流中获取字节,你只需要关注三个主要操作。前两个很明显:从数据流中按顺序读取和写入,收集字节,或者将你自己的字节推送到它。第三个不那么明显,但同样重要。因为数据流是有序的任意字节数组,从它读取和写入是单向操作。它们总是按顺序处理。然而,我们并不总是需要或想要按顺序从数据流中获取信息,因此能够查找流中的特定索引是关键,这将是遍历你的数据流的主要机制。

因此,带着这个想法,让我们看看它是如何付诸实践的。首先,创建一个基本的应用程序来利用数据流。为此,你可以使用 .NET Core CLI,并创建一个新的控制台应用程序,如下面的截图所示:

截图

类似于我们在第二章,DNS 和资源定位,在 C# 中的 DNS 部分中创建的示例项目,我们使用了 dotnet new 命令来创建一个基本的控制台应用程序作为我们的测试平台。这次的不同之处在于,我们将使用 dotnet new console 命令专门创建一个新的控制台应用程序。我会继续在处理新项目时做笔记,以突出 .NET Core CLI 的速度和价值;它的速度和实用性真的无法过分强调。

现在,我们想要建立一个用于工作的流,所以我们将首先添加一个using指令来包含System.IO命名空间,因为 I/O 流位于 I/O 命名空间中。然后,为了演示的目的,我们将从文件中读取,并将数据写入磁盘上的文件,使用FileStream。我们将我们的变量声明为Stream类型,这样编译器的类型检查就不会允许我们使用FileStream特定的方法或属性。重点是理解如何使用Stream类提供的抽象。实际上,我们读取的内容并不重要;在到达我们的应用程序代码之前,它只是一些输入字节。使用本地文件系统只是让我们能够更直接地访问我们的操作结果,而无需通过设置本地 API 并将其数据发送到该 API 的过程。

在你能够做到的范围内,通常明智的做法是在声明变量时尽可能使用通用的类型。这样,如果你以后需要更改实现策略,你会拥有更多的灵活性。今天可能只是本地存储的文件系统访问,明天可能就变成了远程 API 调用。如果你的代码只关注Stream类的通用概念,那么在以后对不同来源进行更改会容易得多。

要编写这个演示,你首先需要理解的是,流是一个到数据源的活跃连接。这意味着在使用之前需要打开它,完成使用后应该关闭,然后销毁。未能这样做可能会导致内存泄漏、线程饥饿以及其他与代码的性能或可靠性相关的问题。幸运的是,.NET Core 为每个生命周期任务提供了一个内置的模式。大多数Stream类的构造函数将返回一个已经打开的实例,这样你就可以立即开始从流中读取和写入。至于确保流的销毁,我们有永远有用的using语句。

如果你之前没有见过,using语句与文件顶部允许你引用当前命名空间外部的类和数据结构的using指令不同。在方法的上下文中,在 C#中,using语句用于实例化一个可处置的类(也就是说,任何实现了IDisposable接口的类),并定义实例应该保持活跃的作用域。使用此语句的语法如下:

using (variable assignment to disposable instance) {
    scope in which the disposable instance is alive.
}

我们很快就会看到这个功能的实际应用。但就像在for循环或if语句的作用域内声明变量一样,你在using语句的签名内创建的变量在代码块的开闭花括号之外将不再存在。

或者,使用 C# 8,你可以通过选择利用using声明来避免由using语句创建的深层嵌套。这功能与using语句完全相同,但它将变量声明为封装方法的范围,而不是为实例的生命周期建立内部作用域。因此,你不会使用using语句及其开闭括号来定义作用域,而是简单地创建变量,并使用using关键字声明它,就像这里所示:

using var fileStream = new FileStream(someFileName);

两个实例之间的主要区别在于实例所绑定的作用域。使用using语句时,实例的作用域由语句块的括号定义。与此同时,使用using声明时,作用域由声明可处置实例的代码块定义。在大多数情况下,using声明应该足够,并且有助于减少方法中的深层嵌套。然而,你应该始终注意考虑可处置实例的使用方式,并将其绑定到适当的范围以适应其使用场景。

一旦程序控制流的流程退出与实例绑定的作用域,.NET 运行时将采取所有必要的步骤来调用Dispose()方法,该方法负责确保对象的状态对于处置是有效的。在这个过程中,using语句隐式地承担了清理任何未管理资源以及为创建的对象设置的任何连接池的责任。这种明确的作用域意味着每次你离开using指令的作用域时,你都会失去资源句柄,并将不得不实例化一个新的句柄。

这种明确的作用域意味着每次你关闭using语句时,你都会失去资源句柄。这意味着稍后访问资源将需要你为它创建一个新的句柄,然后相应地处置它。这可能会在应用程序的生命周期中产生性能成本,因此你应该在确定不再需要资源句柄时小心处置它。

有趣的是,虽然using语句作用域内声明的对象总是会得到适当的处置,但using语句并不能保证对象创建的任何可处置实例都会被处置。假设如果任何A类将其自身作为成员创建了一个可处置的B类实例,那么A类的拥有实例也应该负责在A类的拥有实例被处置时清理B类的成员实例。规则是,如果你创建了它,你就处置它。

现在我们已经知道了如何创建Stream实例,让我们动手实践,开始使用它吧。

向数据流写入和读取

现在我们知道了Stream类的生命周期是如何管理的,让我们用它来向本地文件写入一条消息。首先,我们将字符串写入流,然后检查流的目的地以确认它已被正确写入:

using System;
using System.Text;
using System.IO;
using System.Threading;

namespace StreamsAndAsync {
  public class Program {
    static void Main(string[] args) {
      string testMessage = "Testing writing some arbitrary string to a stream";
      byte[] messageBytes = Encoding.UTF8.GetBytes(testMessage);
      using (Stream ioStream = new FileStream(@"stream_demo_file.txt", FileMode.OpenOrCreate)) {
        if (ioStream.CanWrite) {
          ioStream.Write(messageBytes, 0, messageBytes.Length);
        } else {
          Console.WriteLine("Couldn't write to our data stream.");
        }
      }
      Console.WriteLine("Done!");
      Thread.Sleep(10000);
    }
  }
}

就像在第五章中提到的在 C#中生成 Web 请求一样,我们无法直接将字符串写入流。字节流的任务不是确定更复杂对象应该如何表示为字节。它只是它们通过的路线。因此,我们负责首先获取我们想要发送的字符串的字节表示。为此,我们使用System.Text.Encoding类来获取我们想要使用的特定字符串编码的字节表示。

一旦我们有了这个,我们就可以将其写入流中。或者至少,我们假设我们可以。但首先进行检查总是明智的。这就是为什么Write操作被包裹在检查我们流CanWrite属性的条件块中。这是Stream类提供的一个非常好的便利,它允许你在尝试执行操作之前确认流中操作的有效状态。这样,我们就可以在不需要在所有内容周围使用笨拙的try/catch块的情况下控制错误处理和纠正。

因此,我们在using块中声明了我们的Stream对象,并将其初始化为打开或创建一个名为stream_demo_file.txt的文件,位于应用程序可执行文件目录的根目录下。然后,一旦我们检查了它,我们就传递了我们的字节数组,并指示流将这个数组写入其目标资源。但Write方法中的那两个附加参数是什么?嗯,就像流不会合理地了解在其上通过的内容一样,它也不知道何时应该从字节数组中读取哪些字节。它需要字节数组,然后是关于从哪里开始读取的指令,以及它应该精确写入多少字节。Write方法签名中的第二个参数是你的起始索引。它从零开始,就像数组一样。第三个参数是你想要在这个Write操作中发送的字节总数。这里有一个运行时错误检查,如果你尝试发送比数组中剩余的字节更多的字节(从你指定的任何索引开始),你会得到一个索引越界错误。

因此,如果你导航到应用程序运行的文件夹,你应该找到一个新的文本文件。打开它,你应该会发现我们的消息;就这么简单。但如果我们再次运行该文件会发生什么?消息会被连接到我们之前写入的第一个消息吗?它会覆盖现有的消息吗?

寻找操作

再次运行你的应用程序,然后在文本编辑器中重新加载文件。无论你期待发生什么,你应该看到文件没有任何变化。然而,假设你的应用程序运行成功,你在控制台上看到的是持续 10 秒的“完成!”消息而不是我们的错误消息,你应该有信心写入操作执行了第二次。所以,这应该告诉你操作是成功的,并且实际上它确实覆盖了原始消息的值。这可能一开始并不明显,因为我们第二次使用了相同的信息,但如果你想确认这种行为,只需将程序中的testMessage变量更改为读取测试向流写入不同的字符串并再次运行。你应该看到新的消息,希望这会使发生的事情更加明显。

每次我们打开一个连接到数据源的流时,我们都会得到存储在该源中的完整有序的字节列表,以及该数组的起始指针。我们对流执行的每个操作都会将我们的指针移动一个方向。如果我们写入 10 个字节,我们会发现自己比开始时在数组中更远 10 个位置。如果我们读取 10 个字节,情况也是一样。所以,我们的每个主要操作员只能从我们在开始执行它们时沿流的任何位置向一个方向移动。那么,我们如何设置这些操作来读取或写入我们想要的位置呢?答案是,使用Seek()方法。

Seek方法通过指定几个简单的参数,让我们能够任意访问字节数组中的任何索引。只需指定相对于一个指定的起始位置你想从哪里开始,然后使用SeekOrigin枚举的三个值之一来指定起始位置。

所以,如果我想从当前数组的最后一个字节开始,并将我的当前消息附加到上一个消息的末尾,代码块将如下所示:

using (Stream ioStream = new FileStream(@"../stream_demo_file.txt", FileMode.OpenOrCreate)) {
  if (ioStream.CanWrite) {
    ioStream.Seek(0, SeekOrigin.End);
    ioStream.Write(messageBytes, 0, messageBytes.Length);
  } else {
    Console.WriteLine("Couldn't write to our data stream.");
  }
}

适当地修改你的using语句,并再次运行程序。查看你的输出文件,你应该看到以下消息:

Testing writing a different string to a streamTesting writing a different string to a stream

我们从原始的字节数组开始,导航到已写入字节的流末尾,然后从那里写入我们的消息;就这么简单。

这可能看起来是一件微不足道的事情,但想象一下你正在解包一个数据大小可变的消息有效负载。通常,你会有一系列头信息或一个映射你的字节数组,指定不同组件的起始索引和总长度。仅使用这两条信息,你可以直接导航到消息的相关组件,并且只读取你需要的确切数量。以Stream类的方式减少这种数据操作在简单性上非常强大。

但也许你不想将数据写入请求流。也许你已经编写了服务器代码来读取请求并相应地做出响应。让我们简要地看看这是如何实现的。

从流中读取

正如我说的,读取是一个单向操作。无论你的当前索引是什么,你都将一次读取一个字节,并在读取过程中将光标在索引中向前移动一个位置。所以,你的下一个Read操作总是从上次读取的地方开始,再向后移动一个字节。这里的技巧是,每次你想读取超过单个字节的内容(你可以简单地将它分配给一个字节类型的变量),你必须将它读取到一个目标数组中。所以,在读取之前,你需要声明并分配一个目标数组。让我们看看这是如何实现的;不过,首先,让我们移除Seek操作,这样每次运行你的应用程序时,你都不会增加你的文本文件的大小:

using (Stream ioStream = new FileStream(@"../stream_demo_file.txt", FileMode.OpenOrCreate)) {
  if (ioStream.CanWrite) {
    ioStream.Write(messageBytes, 0, messageBytes.Length);
  } else {
    Console.WriteLine("Couldn't write to our data stream.");
  }

  if (ioStream.CanRead) {
    byte[] destArray = new byte[10];
    ioStream.Read(destArray, 0, 10);
    string result = Encoding.UTF8.GetString(destArray);
    Console.WriteLine(result);
  }
}

因此,就像我们之前做的那样,我们检查是否从我们的流中读取是有效的。然后,我们指定一个新的字节数组,我们将从我们的数据流中读取字节,然后从索引零开始Read,读取 10 个字节。

我确信在这个阶段,你已经看到了这种方法给开发者带来的许多问题。即使只是使用老式的方括号数组而不是更灵活且易于处理的列表类,也会给开发者带来许多痛点。为了将老式数组作为Read操作的靶子,你必须事先知道数组的确切大小。这意味着你可能需要明确地为你的数组(以及随后的Read操作)设置一个预定的长度,或者你需要有一个变量,你可以从中确定数组的初始长度(因为你不能在不指定长度的情况下初始化方括号数组)。

这是非常僵化和繁琐的。它使得你的反序列化代码变得脆弱。另一种选择是指定一个合理的最大长度,并使用该值来初始化任何将从数据流中读取的字节数组。当然,这种方法将你的软件固定在当前已知的限制上,使其缺乏灵活性,并且在未来难以扩展。所有这些都是Stream类定义的优雅简单性所带来的挑战。幸运的是,随着Stream类的强大功能,还带来了.NET Core 提供的许多实用类库的简单性。

适合这项工作的正确流

使用代表你的网络连接的最底层数据流进行工作确实给你提供了很多权力和控制,可以精确地解析和处理传入的消息。当性能或安全性是一个问题时,这种字节级别的控制对于为熟练的开发者提供他们需要的工具以产生最优化解决方案来说是无价的。

然而,我们中的大多数人不会编写对性能或安全性有如此高要求的网络代码。实际上,我们编写的代码大多数都将遵循相同的简单直接的序列化和消息生成模式。这就是额外的 Stream 类真正派上用场的地方。

流读取器和写入器

虽然在需要直接与数据流工作并弯曲其用途以适应特定目的时,了解如何操作是非常有用的,但简单的事实是,大多数时候你不需要这样做。实际上,在我多年的软件工程师生涯中,我能数得出来的需要自己设计序列化策略并使用低级类来实现以追求性能或安全性的次数屈指可数。在我的职业生涯中,使用更简单、更成熟的序列化策略,这些策略利用了 .NET 核心库提供的实用类,要常见得多。

在现代网络中,通信的通用语言无疑是 JavaScript 对象表示法JSON)。这种用于组合和解析层次数据的简单规范,几乎可以优雅地转换成几乎任何语言中你可能会设计的几乎任何数据结构,因此,到目前为止,它已成为几乎所有正在编写的 API 或网络服务的首选传输格式。

就像我们之前讨论的所有内容一样,它的强大之处在于其简单性。它是一种数据字符串表示,具有简单的规则来界定和嵌套不同的对象及其相应的属性。尽管 JSON 对象的层次结构是严格定义的,但该对象内属性的顺序完全是任意的,这为用户提供了高度的灵活性和可靠性。

在这种普遍的序列化标准下,存在广泛支持且易于使用的工具来处理以 JSON 表示法表示的对象,这并不令人惊讶。不仅如此,由于简单的字符串占据了我们在网络上读取和写入数据源之间的大部分内容,因此存在专门设计用于通过流处理这些字符串的 System.IO 类。

Newtonsoft.Json

让我们熟悉一下这样一个非微软库,它因其可靠性而广受欢迎,最终被微软采纳为官方的 C# 和 .NET 解析 JSON 的库。随着你与网络事务的更多合作,你将更加欣赏 Newtonsoft.Json 库强大的简单性。它并没有太多内容,所以现在让我们花点时间看看它的内部结构,因为我们将在接下来的工作中大量依赖它。

重要的是要知道,尽管Newtonsoft.Json仍然是 C#中 JSON 解析的首选库,但微软实际上已经为.NET Core 3.0 开发了一种替代方法。这个新库已经被添加到System.Text.Json命名空间中。然而,与Newtonsoft.Json为了用户友好性而编写的,提供了一组易于利用的功能相比,这个新的 JSON 库的重点在于性能和对序列化过程的精细控制。因此,与Newtonsoft.Json相比,System.Text.Json库的功能集严重受限。由于我们更关注 JSON 序列化的基本概念,而不是性能,所以在这本书中,我们将使用Newtonsoft.Json作为我们的首选库。

要开始使用它,你需要将库包含到你的项目中。如果你使用的是 Visual Studio Code,你只需在编辑器的终端窗口中输入以下命令:

dotnet add package Newtonsoft.Json

如果你使用的是 Visual Studio,你可以在解决方案资源管理器中右键单击你的项目依赖项,然后选择管理 NuGet 包。从那里,搜索Newtonsoft.Json并安装包。

一旦你有了它,我们就会想要一个稍微复杂一些的对象,以真正展示Newtonsoft能做什么。所以,让我们通过添加一个名为ComplexModels.cs的新文件来向我们的项目中添加一个模型定义,并在其中定义几个类:

using System;
using System.Collections.Generic;

namespace StreamsAndAsync {
    public class ComplexModel {
        public string ComplexModelId { get; set; } = Guid.NewGuid().ToString();
        public int NumberDemonstration { get; set; } = 12354;
        public InnerModel smallInnerModel { get; set; }
        public List<InnerModel> listOfInnerModels { get; set; } = new List<InnerModel>() {
            new InnerModel(),
            new InnerModel() 
        };
    }

    public class InnerModel {
        public string randomId { get; set; } = Guid.NewGuid().ToString();
        public string nonRandomString { get; set; } = "I wrote this here.";
    }
}

在这里,我们有一个类型,它的属性是另一个类型的实例,以及另一个类型的实例列表。请注意,我正在使用 C# 6 中添加的内置属性初始化功能。这允许我们确保不定义默认构造函数的情况下初始化我们类的每个成员。因此,只需添加一个ComplexModel实例,我们就会有一个完全初始化的对象。

现在,我相信你可以想象自己尝试独立遍历这个嵌套结构,然后再将其解析成格式良好的序列化字符串的痛苦。而且,这还是针对我们需要自己定义的对象!考虑一下,为任何可能需要在你的网络流类中传输的对象编写通用序列化代码所增加的复杂性。这将是一团糟,充满了递归或反射,以及一大堆其他繁琐且耗时的工作,很少有开发者喜欢做这些。

幸运的是,我们通常不必这样做。如果我们想将我们刚刚定义的类的实例写入我们的数据流,这只需要一行代码来生成输出字符串。让我们重新设计我们的示例程序,从一个新的ComplexModel类实例开始,然后使用Newtonsoft.Json将其序列化为更易于流传输的格式:

using System;
using System.Text;
using System.IO;
using System.Threading;
using Newtonsoft.Json;

namespace StreamsAndAsync
{
    public class Program
    {
        static void Main(string[] args)
        {
            ComplexModel testModel = new ComplexModel();
            string testMessage = JsonConvert.SerializeObject(testModel);
            byte[] messageBytes = Encoding.UTF8.GetBytes(testMessage);

            using (Stream ioStream = new FileStream(@"../stream_demo_file.txt", FileMode.OpenOrCreate)) {
                if (ioStream.CanWrite) {
                    ioStream.Write(messageBytes, 0, messageBytes.Length);
                } else {
                    Console.WriteLine("Couldn't write to our data stream.");
                }
            }

            Console.WriteLine("Done!");
            Thread.Sleep(10000);
        }
    }
}

在我们方法的第二行那个简单的声明中,我们将我们的模型转换为一个完整的字符串表示形式,适合序列化传输。运行程序,然后再次检查你的目标文件。你应该会发现一串由双引号分隔的属性名称及其值,以及大量的花括号。相反的方向同样简单,只需将你的 JSON 字符串传递给Deserialize<T>()方法,如下所示:

ComplexModel model = JsonConvert.Deserialize<ComplexModel>(testMessage);

就这样,你可以干净可靠地将你的数据序列化和反序列化到网络消息中广泛使用的格式。

JSON 记法的规范不在这个书的范围之外,但如果你有任何编程 JavaScript 的经验,它应该看起来很熟悉。否则,我建议查看 MDN 关于此主题的文章:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON

如果你需要帮助组织 JSON 字符串以使其结构更清晰,你可以将其粘贴到jsonlint.com以验证其结构是否良好,并获得一个格式化的字符串版本。

StreamReaderStreamWriter

因此,如果我们能够轻松高效地将几乎任何可以想象的对象序列化为字符串,那么(你肯定在想)一定有更简单的方法直接用字符串写入和读取流。

当然,有;当你开始这一节时你就知道了。进入始终多才多艺的StreamReaderStreamWriter类。这些类都是专门设计用来读取/写入字符串的。实际上,它们都从System.IO命名空间中的TextReader类派生出来,并扩展了其功能以直接与字节流接口。它们是为处理字符串量身定制的,并且每个类,结合Newtonsoft.Json的简单性,都可以轻松处理通过网络传输的最复杂的数据结构。所以,让我们看看如何使用它们来处理我们的网络流。

首先,我们想要获取我们的流,就像之前一样,使用using语句,如下所示:

using (Stream s = new FileStream(@"../stream_demo_file.txt", FileMode.OpenOrCreate)) {

然而,在我们做任何事情之前,我们还想初始化我们的StreamWriter实例,提供我们的流作为其初始化参数:

using (StreamWriter sw = new StreamWriter(s)) {

StreamReader/StreamWriter 有多个构造函数,可以接受编码规格、字节顺序标记检测和缓冲区大小等参数。然而,在网络编程中,我们始终会使用接受 Stream 作为第一个参数的构造函数。仅接受字符串的构造函数只会创建指向本地文件路径的 FileStream 实例。尽管在这里我们使用 FileStream 进行演示,但在实际的网络编程中,我们希望直接连接到远程资源的数据流。为此,我们首先需要初始化流(可能是 NetworkStream 类的实例),然后将它提供给我们的写入器/读取器实例。

一旦初始化了 StreamWriter,写入就变得简单,只需调用 Write(string)WriteLine(string) 即可。由于该类假设它将处理字符串,我们的示例方法简化如下:

static void Main(string[] args) {
  ComplexModel testModel = new ComplexModel();
  string testMessage = JsonConvert.SerializeObject(testModel);

  using (Stream ioStream = new FileStream(@"../stream_demo_file.txt", FileMode.OpenOrCreate)) {
    using (StreamWriter sw = new StreamWriter(ioStream)) {
      sw.Write(testMessage);
    }
  }

  Console.WriteLine("Done!");
  Thread.Sleep(10000);
}

只需五行代码,我们就成功地序列化了一个复杂的嵌套对象实例,并将其写入输出流。

当处理来自远程资源的字符串时,知道用于将传入的字节转换为特定编码的方法是关键。如果一个字符被编码为 UTF32,并使用 ASCII 进行解码,结果将不会匹配输入,导致输出字符串变得混乱不堪。如果你发现解析的消息无法解读,请确保你使用了正确的编码。

由于这些类旨在专门与字符串内容一起使用,它们甚至提供了有用的扩展,例如 WriteLine(string) 方法,它会在你传入的字符串后面添加一个行终止符字符(在 C# 中,这默认为回车符后跟换行符,即 \r\n,尽管你可以根据你的环境覆盖此值)。同时,ReadLine() 方法将返回从当前索引到下一个行终止符(包括)的字符。这对于序列化对象来说并不特别有用,因为你不想读取 JSON 字符串的一行。然而,如果你正在处理纯文本响应,它可以使读取和写入该响应变得容易。

搜索与预览

然而,可能不明显的一个注意事项是使用StreamWriterStreamReader实例更改当前索引时的差异。对于Stream类及其子类,我们只是简单地应用Seek操作,通过给定数量的位置从给定的起始点向前移动我们的字节数组。然而,当你使用 writer/reader 实用程序类工作时,你会注意到你没有那个选项。包装类只能通过在流上的当前索引使用其基本操作向前移动。但是,如果你想更改那个索引,你可以通过直接访问底层流来做到这一点。包装类通过BaseStream属性暴露它。因此,如果你想在不执行包装器操作的情况下更改流中的位置,你可以使用BaseStreamSeek操作,如下所示:

using (Stream ioStream = new FileStream(@"../stream_demo_file.txt", FileMode.OpenOrCreate)) {
    using (StreamWriter sw = new StreamWriter(ioStream)) {
        sw.Write(testMessage);
        sw.BaseStream.Seek(10, SeekOrigin.Begin);
        sw.Write(testMessage);
    }
}

修改包装类底层的Stream类将直接更改包装类可以写入的位置。运行此代码后,我们的输出文件应该看起来像以下截图:

截图

我们输出文件的前 10 个字符是null,因为底层的Stream类将其写入索引向前移动了 10 个字符!

在字符串中向前搜索直到到达终止字符或标志值并不罕见。使用StreamReader.Read()操作这样做会导致索引移动到终止字符之后,并将终止字符从数组中移除。然而,如果你想简单地读取终止字符之前的最后一个字符,你可以使用Peek()操作。Peek()会返回数组中的下一个字符,而不会前进StreamReader的当前索引。这个小小的技巧在你确定何时停止读取长度不可知的字符串的某个部分时,可以提供相当大的灵活性。

NetworkStream

当我们在寻找适合工作的正确流时,我们应该花点时间看看NetworkStream类。它的工作方式与我们在迄今为止的示例代码中使用的FileStream类非常相似,其底层数据源是连接到外部资源的Socket类的一个实例。然而,除了指定底层Socket连接以供流读取和写入之外,它几乎与FileStream类完全相同。各种ReadWriteSeek方法的行为与我们在本地文件示例中看到的行为完全一致。同样重要的是,NetworkStream的实例可以用作StreamReaderStreamWriter类实例的BaseStream,因此通过电线发送原始文本消息与写入本地文本文件一样简单。当我们开始在后面的章节中实现自己的套接字连接时,我们将大量使用这个类,但这些将仅建立在我们在本章中奠定的基础上。

提高速度 - 多线程数据处理

到目前为止,我们只看了我们数据流上的读写操作的简单示例,而且我们只使用了同步的Read()Write()方法。对于我们的 50 或 500 个字符长度的消息和单一用途的测试应用来说,这并没有成为问题。然而,不难想象数据流足够大,以至于仅仅从开始到结束读取就需要相当多的时间的情况。想象一下请求一个 200MB 大的 FTP 文件,或者想象从远程服务器上托管的数据表请求 200 万个记录。如果必须执行这些操作的进程还负责通过图形界面响应用户行为,那么长时间运行的数据处理任务将使 GUI 完全无响应。这种行为绝对是不可接受的。为此,.NET Core 为程序员提供了线程的概念。

使用线程,某些操作可以被委派为后台任务,这些任务在主机进程可以执行时立即执行,但不会阻塞应用程序的主线程。因此,通过这个简单而强大的概念,我们可以将可能长时间运行或计算密集型的操作分配给后台线程,从而减轻该操作对应用程序其余性能的影响。这种性能提升是使用线程的最大好处。

.NET Core 应用程序的这个方面可以通过System.Threading命名空间访问,该命名空间提供了从ThreadPool类到用于保护资源免受并发访问或修改的信号量,再到用于更精细控制何时以及如何分配后台线程的Timer类和WaitHandles类的所有内容。

由于网络连接的易变性和远程资源的不可靠可用性,任何尝试从远程资源访问数据或服务的操作都应该在后台线程中处理。幸运的是,将这些任务分配给后台线程进行并行处理实际上相当简单。我们只需要开始利用那些我们之前一直忽略的异步方法。

异步编程用于异步数据源

如果你对异步编程不熟悉,那么我们接下来要讨论的内容一开始可能有些令人困惑,但我保证在实践中,它实际上非常简单。它仅仅意味着以不按顺序或不同步的方式执行单个计算任务。它允许工程师将阻塞程序执行的等待长时间任务推迟到他们绝对必须这样做的时候。为了使这一点更清楚,让我们看一个例子。

让我们想象我们有一个方法,它必须让步骤A发送一个请求以获取大量数据,步骤B在本地执行长时间计算,最后C将两个结果作为单个响应返回。如果我们同步地从我们的网络请求中读取响应,那么我们的方法完成所需的时间将是每个步骤的时间总和,A + B + C。处理时间将如下所示:

图片

但如果我们以异步方式运行我们的网络请求,*我们可以在后台任务中同时运行这个任务,与我们的长时间运行的本地进程同步进行。这样做,我们可以将处理时间减少到仅两个任务中较长的那个,再加上C。现在我们的处理时间看起来如下所示:

图片

由于C是唯一依赖于A以完成处理的步骤,我们可以在准备执行C之前,将阻塞我们的应用程序代码在A完成上推迟。为了在代码中看到这一点,让我们首先说我们有一个ResultObject类,它包含我们想要返回给用户的本地和远程信息。接下来,让我们假设这个方法的部分B中进行的长时间运行的工作是在名为(恰当地)LongRunningSlowMethod()的私有本地方法中完成的。所以,有了这些简单的假设,让我们看看处理长时间运行的网络请求的异步方法,如下所示:

public async Task<ResultObject> AsyncMethodDemo() {
  ResultObject result = new ResultObject();
  WebRequest request = WebRequest.Create("http://test-domain.com");
  request.Method = "POST";
  Stream reqStream = request.GetRequestStream();

  using (StreamWriter sw = new StreamWriter(reqStream)) {
    sw.Write("Our test data query");
  }
  var responseTask = request.GetResponseAsync();

  result.LocalResult = LongRunningSlowMethod();

  var webResponse = await responseTask;

  using (StreamReader sr = new StreamReader(webResponse.GetResponseStream())) {
    result.RequestResult = await sr.ReadToEndAsync();
  }

  return result;
}

这里发生了很多事情,但希望现在很明显,我们为什么以这种方式处理最后几章。让我们一点一点地看;首先,注意方法签名,如下所示:

public async Task<ResultObject> AsyncMethodDemo() {

任何利用异步操作编写的方法都必须在其签名中使用 async 关键字进行标记。这告诉方法的用户,这个方法中的操作可能需要一段时间,并且将在后台线程上运行。你可能已经注意到,返回类型并不是简单的 ResultObject,尽管我们的返回值 result 在方法开始时被声明为这样的类型。这是因为异步方法只有三种有效的返回类型:voidTaskTask<T>

如果你的方法返回一个结果,你必须在你方法的签名中将该结果的类型包裹在 Task<> 中。然而,你不需要将实际返回的值包裹在 Task<> 对象中。当你有一个异步方法签名时,编译器会为你完成这个操作。这就是我们能够在方法签名中声明一个看似与我们在方法体中声明的返回值类型不匹配的返回类型的原因。

在我们的方法中继续前进,我们创建一个指向我们的测试域的 WebRequest 类,然后使用 StreamWriter 将我们的数据查询直接写入 WebRequest 的请求流。接下来发生的事情很有趣,也就是说,我们可以在我们的代码中调用以下行:

var responseTask = request.GetResponseAsync();

GetResponseAsync() 方法分配给我们的 responseTask 变量的结果是实际上并不是 WebResponse 类。相反,它是对由 GetResponseAsync() 方法在后台线程中启动的任务的引用。因此,我们不必等待从我们的服务器返回响应,GetResponseAsync 只给我们一个获取该响应的线程的句柄,然后立即将控制权返回到我们方法中的下一个操作。这使得我们几乎可以立即开始执行 LongRunningSlowMethod()

现在,由于我们的 LongRunningSlowMethod() 不是异步的,控制流会阻塞,直到它完成执行,并且其输出被分配到 result.LocalResult。一旦完成,我们实际上不能继续执行函数,直到我们从网络请求中获取完结果。因此,我们程序中的下一行如下:

var webResponse = await responseTask;

通过调用 await 关键字,我们告诉我们的程序,只有在等待的操作完成之前,我们才能有意义地继续执行。因此,如果任务尚未完成,程序现在应该阻塞进一步的执行,直到它完成。这就是我所说的延迟阻塞程序执行。当我们无法在没有异步任务的结果的情况下完成更多工作,我们必须阻塞并等待结果时,我们才能继续执行。这就是我们在 await 调用中所做的。

等待此async任务的结果是async方法中Task<T>返回类型所包装的内容。因此,在这种情况下,分配给webResponse变量的是我们之前期望的WebResponse类的实例。

现在我们已经得到了响应,我们可以从中读取。在接下来的几行中,我们实例化了StreamReader,并提供了从我们得到的WebResponse实例中获取的响应流。最后,我们从响应流中读取并将其分配给我们的结果对象:

result.RequestResult = await sr.ReadToEndAsync();

注意,即使在这个函数中我们没有额外的代码要执行,我们仍然使用ReadToEndAsync()方法并等待结果。这样做的原因是,尽管在我们的方法中没有更多的执行内容,但调用我们的方法的人可能能够延迟处理我们返回的结果。使用await操作符告诉编译器这是另一个延迟执行的机会,因此当我们的方法达到这一点时,控制权可能会返回到调用方法,直到再次等待我们的方法的结果。因此,在可能的情况下始终使用异步方法,并且在整个调用链中使用它们是非常重要的。随着时间的推移,性能提升将显著增加。

关于阻塞代码的最后一句话

你可能会注意到,每次调用异步方法时,任务实例上都有一个Result属性。虽然使用GetResponseAsync().Result来避免等待异步操作,以及避免在整个堆栈中应用异步模式,可能看起来很有吸引力,但这是一种糟糕的做法。

永远不要使用.Result来访问异步任务的结果。

它不仅通过强制同步执行来阻塞你的代码,而且还阻止了调用你的方法的人能够延迟执行。不幸的是,这是新开发者在开始使用异步编程时犯的最常见的错误之一。然而,你几乎永远不应该混合异步和阻塞代码。一个非常简单的规则是,如果你的任何代码需要异步处理,那么所有代码都应该这样做。

摘要

在本章中,我们进一步构建了所有 C#网络编程的基础。我们学习了.NET 如何将基本物理概念——物理流(流入或流出的位流)——封装成一个优雅简单且广泛有用的Stream类。然后我们探讨了通过StreamWriterStreamReader包装类与Stream一起工作的最佳模式。为了便于我们通过这些类传输数据,我们首次了解了 JSON 的惊人力量以及Newtonsoft.Json库。

一旦我们牢固地掌握了数据流,我们就研究了如何优化与它们的工作。我们讨论了多线程的力量,以及这对长时间运行的任务和操作的性能改进意味着什么。最后,我们快速学习了异步编程。通过了解如何利用后台任务和异步方法定义的力量,我们看到了如何充分利用多线程和后台任务来减轻可能长时间运行的操作的延迟。现在,我们更舒适地定位了与远程数据源一起工作,我们将下一章学习如何响应来自远程数据源的错误。

问题

  1. JSON 代表什么,为什么它很有用?

  2. 通过Stream类你可以使用哪些三个主要操作?

  3. using语句的目的是什么?

  4. 在通过StreamReaderStreamWriter类处理字符串时,最重要的因素是什么?

  5. 利用后台线程在程序中的最大单一好处是什么?

  6. 程序员在使用异步方法时最常见的错误是什么?

  7. 异步方法只有三种有效的返回类型,它们是什么?

进一步阅读

关于这些主题的更多信息,我建议查看 C# 多线程食谱,由 Eugene Agafonov 编著,Packt Publishing 出版,你可以在www.packtpub.com/application-development/multithreading-c-cookbook-second-edition找到这本书。

要深入了解现代异步编程实践,你应该查看 C# 7.1 和 .NET Core 2.0 - 现代跨平台开发,由 Mark J. Price 编著,Packt Publishing 出版。你可以在www.packtpub.com/application-development/c-71-and-net-core-20-modern-cross-platform-development-third-edition找到这本书。

第七章:线上错误处理

本章将探讨分布式应用的许多可能的故障点,以及故障的影响如何被你的应用的下游消费者感受到。我们将检查不同错误根据严重性、上下文和网络流量生命周期的阶段如何被报告或检测。我们将探讨 C#中实现的多种错误处理策略,并展示如何利用约定和标准确保你的应用对任何潜在的下游消费者都表现出预期的行为。最后,我们将探讨如何在请求无法合理服务时为你的应用消费者生成有意义的错误。

本章将涵盖以下主题:

  • 不同的故障点应生成不同的错误消息,以及如何从中恢复

  • 已正确实现各自通信协议的服务返回的常见错误代码和消息

  • 根据应用必须满足的需求处理不同类型错误的策略

  • 使用状态码、错误、日志和消息为下游消费者生成和报告自己的错误

技术要求

我们将编写大量的示例代码,这些代码可以在 GitHub 上找到:github.com/PacktPublishing/Hands-On-Network-Programming-with-CSharp-and-.NET-Core/tree/master/Chapter%207

查看以下视频以查看代码的实际应用:bit.ly/2HT1l9z

本章将介绍一个名为Polly的弹性网络客户端来演示常见的错误恢复策略。我建议在这里了解该特定库的一些功能:github.com/App-vNext/Polly.

多设备,多故障点

当你引入网络交互的不确定性时,即使是简单的软件也可能出现无数的问题。上游服务中一个简单的索引错误可能导致 JSON 字符串中缺少闭合花括号,使得整个有效负载无法解析。互联网服务提供商ISP)服务中断或弱无线信号可能导致超时和有效负载不完整交付。同时,你请求资源的远程系统的稳定性完全超出你的控制。由于所有这些因素都引入了错误的可能性,我们不能简单地希望避免软件中的错误或异常。我们必须假设它们会发生,并围绕这种可能性进行设计。

外部依赖

在我们作为专业工程师的时间里,我们可以用一只手数的过来,我们写的应用程序既没有作为下游消费者的网络依赖,也没有对上游网络资源有依赖。每次你的软件必须进行网络跳转以访问必要的资源时,你都在引入失败的风险。

通常情况下,每次从外部依赖中读取数据时,你必须实现适当的异常处理。始终假设可能会出错。我们在上一章没有这样做,是因为我们不想在试图完全阐明数据流的概念和使用方法时引入不必要的复杂性。然而,在这一章中,我们将专门探讨错误处理策略。首先的策略是始终假设访问外部依赖最终会失败。

当你处理另一个外部依赖的响应时,这相当直接,但如果你自己的软件是另一个应用程序的依赖呢?对于具有弹性应用程序行为的下一个策略是始终假设你自己的软件最终会失败。这将鼓励你考虑到这一点,并为在失败时刻使用你软件的任何人提供容错性和有用的错误信息。考虑到这一点,让我们从上一章的网络访问代码开始,并对其进行修改以增强其弹性。

回顾我们的方法,我们有以下内容:

public async Task<ResultObject> AsyncMethodDemo() {
  ResultObject result = new ResultObject();
  WebRequest request = WebRequest.Create("http://test-domain.com");
  request.Method = "POST";
  Stream reqStream = request.GetRequestStream();

  using (StreamWriter sw = new StreamWriter(reqStream)) {
    sw.Write("Our test data query");
  }
  var responseTask = request.GetResponseAsync();

  result.LocalResult = LongRunningSlowMethod();

  var webResponse = await responseTask;

  using (StreamReader sr = new StreamReader(webResponse.GetResponseStream())) {
    result.RequestResult = await sr.ReadToEndAsync();
  }

  return result;
}

在这个方法中,我们有一个外部依赖。当我们尝试访问和处理从服务器收到的响应时,可能会遇到失败。由于任何原因,都可能在这里出现任何数量的问题,因此我们希望将这段代码包裹在try/catch块中,或者在代码中应用异常过滤器(稍后会更详细地介绍)。我们将从一个简单的try/catch块开始,查看对我们目的非常有用的内置Exception类,即WebException类。所以让我们捕捉它,看看我们能从中获得什么样的效用:

try {
  var webResponse = await responseTask;

  using (StreamReader sr = new StreamReader(webResponse.GetResponseStream())) {
    result.RequestResult = await sr.ReadToEndAsync();
  }
} catch (WebException ex) {
  Console.WriteLine(ex.Status);
  Console.WriteLine(ex.Message);
}

这里,你会注意到我们不必在阻塞我们的代码并等待响应返回之前寻找异常。如果我们启动一个异步任务,在执行过程中该任务抛出错误,那么它不会到达我们的代码,直到我们await该任务的结果。当我们捕获我们知道的错误(因为test-domain.com/资源实际上不存在)时,我们将其捕获为WebException。这个类是你从代码中遇到的任何特定于网络的异常中接收到的基异常类。与catchall Exception类相比,使其特别有用的是网络错误特定的Status属性可用。

在这个示例中,我们只是在控制台记录状态和异常消息。然而,如果这段代码存在于我们编写的 API 中,并且通过网络暴露给下游实体,我们就需要负责返回一个有意义的自定义状态码。这样做可以确保如果我们的特定应用程序代码是流程中的主要故障点,我们将尽可能提供信息,以便可靠地响应和恢复异常。

解析异常状态以获取上下文

当在WebException中返回错误状态时,该属性的值可以告诉我们很多关于失败原因的信息。Status属性是WebExceptionStatus枚举的一个实例,返回的值可以告诉我们很多关于导致我们的外部依赖失败的条件。这可能是一个路由问题,或者无法解析缓存查找或保持活跃连接。

仅通过检查状态码的值,你可以了解到很多关于具体失败原因和最有可能产生积极结果的恢复策略。例如,如果你的异常的StatusNameResolutionFailure,你可以安全地假设重试请求不会是一个有效的策略。如果在第一次尝试中 DNS 未能根据提供的名称识别主机,那么使用相同主机名的后续尝试不太可能取得成效。然而,如果你收到一个Timeout类型的错误状态,你可能会增加请求客户端的超时阈值并提交一系列重试,直到达到预定的最大超时长度。

WebException状态的文档是免费可用的,你需要自己确定可能遇到的哪些可能的异常状态。此外,一旦你知道请求链中失败的位置或原因,你就可以确定最适合你应用程序代码的最佳恢复策略。然而,这里的主要启示是,你应该在任何请求传输的应用程序点检查并尝试从WebException事件中恢复。

状态码和错误信息

现在我们知道了我们应该在哪里检查潜在的异常(或为我们消费者提供它们),了解这些异常可能最终看起来是什么样子是很重要的。确定可能的异常的完整范围并为每种可能性编写恢复解决方案将使我们的代码几乎对分布式资源获取的不可靠性具有防弹效果。虽然我们已经看到,我们只需检查由任何失败的网路请求抛出的WebException异常,就可以访问到极其有用的信息,但关于互联网状态码规范和异常响应处理的标准还有很多需要了解。

状态信息和状态码

首先,让我们看看在处理上游依赖项错误的不同状态响应时的一个可靠方法。让我们使用我们之前示例中的相同代码片段,但更稳健地响应我们可能收到的各种状态。为了使我们的请求代码更短,我们将异常处理代码委托给一个名为ProcessException(WebException ex)的不同方法。这个方法的两参数将是生成的异常,以及触发我们代码中异常状态的原请求。这将给异常处理方法足够的上下文,以便尝试优雅地从错误中恢复。因此,在我们的早期示例的catch块中,我们将相应地替换我们的两个Console.WriteLine()语句:

} catch (WebException ex) {
    ProcessException(ex);
}

然后,在ProcessException(WebException ex)方法内部,我们将根据异常的Status属性的可能值进行切换,根据接收到的状态或消息执行有用的恢复逻辑:

public void ProcessException(WebException ex) {
    switch(ex.Status) {
      case WebExceptionStatus.ConnectFailure:
      case WebExceptionStatus.ConnectionClosed:
      case WebExceptionStatus.RequestCanceled:
      case WebExceptionStatus.PipelineFailure:
      case WebExceptionStatus.SendFailure:
      case WebExceptionStatus.KeepAliveFailure:
      case WebExceptionStatus.Timeout:
        Console.WriteLine("We should retry connection attempts");
        break;
      case WebExceptionStatus.NameResolutionFailure:
      case WebExceptionStatus.ProxyNameResolutionFailure:
      case WebExceptionStatus.ServerProtocolViolation:
      case WebExceptionStatus.ProtocolError:
        Console.WriteLine("Prevent further attempts and notify consumers to check URL configurations");
        break;
      case WebExceptionStatus.SecureChannelFailure:
      case WebExceptionStatus.TrustFailure:
        Console.WriteLine("Authentication or security issue. Prompt for credentials and perhaps try again");
        break;
      default:
        Console.WriteLine("We don't know how to handle this. We should post the error message and terminate our current workflow.");
        break;     
    }
}

通过使用WebException返回的描述性和可靠的状态代码,我们可以将类似的错误分组在一起,并针对它们提供解决方法,这些解决方法可能会解决每个问题的共同问题。如果有连接或超时问题,可能只是你的 ISP 有问题,或者远程主机没有从缓存中加载资源,因此处理请求花费了太多时间。在这种情况下,简单地重试可能确实是一个持续可靠的解决方案。然而,如果异常是由于无法解析目标主机名,那么后续请求可能会以相同的方式失败。它们都会由相同的 DNS 处理,除非请求 URI 更新为有效的域名,否则重试请求没有好处。同时,安全问题可能通过刷新身份验证或授权凭据来解决。

你会注意到默认建议发布与WebException类返回的内部消息。这是因为,在没有服务器返回通用状态代码的情况下,该类本身将有一些关于可能发生什么问题的默认消息。因此,即使我们得到WebExceptionStatus.UnknownError的实例,错误消息中也可能返回一些有用的信息。

有用的错误消息

如果我们发现自己处于一个场景中,无法优雅地从尝试从上游依赖项请求资源失败中恢复,并且无法继续提供我们的应用程序提供的服务,那么是时候发送我们自己的错误消息了。我们有责任尽可能提供关于失败状态的信息,以便用户理解出了什么问题,同时避免发送任何可能使我们的应用程序容易受到漏洞攻击的潜在敏感细节。

这就是状态码成为你最好朋友的地方。当你处理针对你应用的 HTTP 请求时,你应该尽可能具体地指定你返回的状态码。每次出问题时返回一个 500 状态码可能看起来非常简单,因为 5XX 是服务器错误的通用代码标识。然而,如果你想让人们愿意使用你的服务,我建议你不要这样做。你越具体地使用状态码,你的消费者在理解和从他们自己那一侧的问题中恢复时需要付出的努力就越少。

使用尽可能具体的状态码也给我们提供了一个风险免费的方式来传达足够关于出了什么问题的信息,同时不传达任何可能使我们的软件处于风险中的信息。如果你用一个 401 状态码(表示“未授权”)响应一个错误的认证请求,用户就会知道他们需要调整他们的认证机制。然而,如果你只是返回一个通用的 400 状态码,以及一个表示密码最小字符要求为八个的消息,那么你刚刚给了潜在的恶意行为者比你之前尝试失败之前更多的关于你的认证方案的信息。而且,对于恶意软件来说,关于你系统具体信息的任何信息都是危险的。

理解多少信息是足够的,与过多信息,可能是一种微妙的平衡行为。了解其他人将从你的软件中看到什么,很大程度上来自于经验。你看到的越多外部服务发送无用的“出了问题。哎呀!”错误消息,你就越会知道你想要知道什么,以及你如何在未来的代码中做得更好。然而,当你刚开始时,一个很好的经验法则是状态码应该尽可能具体,错误消息应该尽可能模糊。在 HTTP 中,错误状态码都集中在 4XX 和 5XX 值上。这两个分组分别指定请求服务错误和服务器错误。

请求服务错误与导致失败发生的请求的结构、来源或性质有关。因此,从请求一个在请求位置实际上不存在的资源(404 - 未找到),到请求一个由监听服务器不支持 HTTP 动词的资源(405 - 方法不允许),再到请求客户端未授权访问的资源(401 - 未授权)。

相反,服务器错误与在接收到正确且格式良好的地址之后发生的问题有关。这些问题的数量要少得多,因为大多数格式良好的请求被认为是格式良好的,特别是因为已经配置了服务器来处理它们。5XX 响应的原因范围从上游服务器无法处理客户端请求的某些方面(502 - 网关错误/504 - 网关超时),到目标服务器在请求时根本无法使用或不可用(503 - 服务不可用)。

如果你发送的是正确的错误代码,那么你几乎不可能在自己的代码中明确返回 5XX 错误代码。如果你的软件编写得很好,导致它抛出错误的问题几乎总是可以追溯到某个入站请求的某个方面。然而,当这种情况发生时,你有绝对的义务尽你所能找出是什么具体关于请求导致了错误,并迅速报告。

错误处理策略

现在我们已经看到了在遇到来自网络资源的错误消息(以及发送自己的错误消息)时你可以使用的工具,让我们来看看我们应该如何处理它们。

我们应该如何最好地响应RequestCancelled异常状态?哪些故障状态可能有一个共同的根源,因此有一个共同的共享解决方案?当我们无法从更上游的错误中恢复时,我们的软件应该如何对我们的用户做出响应?在本节中,我们将探讨这些问题中的每一个,并留下一些可以适应和扩展到几乎所有情况的具体方法。

使用 Polly 实现弹性请求

正如我们在之前的代码示例中已经看到的,用相同的一般恢复解决方案来响应多个类似错误状态并不罕见。这是一种简化代码库的绝佳实践,并且可以在各种常见情况下提供持久的异常处理。

将常见的网络问题分组,以便可以使用类似策略解决的行为,正是 Polly 库为弹性 HTTP 客户端背后的理念。虽然我们现在不是专门研究 HTTP,但它是最健壮的库之一,用于处理最常见的网络协议之一,因此我认为在我们继续研究错误恢复策略时,它值得检查。

首要任务是将在我们的项目中包含该包,无论是通过在 NuGet 包管理器中明确包含,还是使用以下命令行输入:

dotnet add package Polly

一旦安装完成,我们可以声明一个Policy类,用于使用 Polly 的声明性处理程序和恢复方法来处理各种网络异常。Policy类是一个健壮的、短暂的容器,用于特定的任务。我们定义一个委托,然后将该委托提供给Policy以执行。然后Policy类使用活动定义的错误处理程序及其恢复定义来执行任务,并相应地监听和响应异常状态。

我们希望 Polly 响应的异常使用通用的Handle<T>()方法设置,其中TException类型的某个子类。Handle<T>()方法还接受可选的条件参数,指定我们想要使用相应的恢复规范响应的Exception类型的状态。这使我们能够为不同的状态定义特定的恢复策略。让我们看看这个动作,以了解我的意思。

首先,我们将定义一个用于请求某些远程资源的方法。这只是为了演示目的,所以我们希望它偶尔失败,偶尔成功。为此,我们只需生成一个随机数,如果数字是偶数,我们将抛出一个异常;否则,我们将返回一个有效的响应。然而,重要的是,我们希望在屏幕上记录我们的失败尝试,这样我们就可以看到重试的效果:

public static HttpResponseMessage ExecuteRemoteLookup() {
    if (new Random().Next() % 2 == 0) {
        Console.WriteLine("Retrying connections...");
        throw new WebException("Connection Failure", WebExceptionStatus.ConnectFailure);
    }
    return new HttpResponseMessage();
}

这将是我们在定义了恢复策略并想要尝试执行它之后传递给我们的Policy对象的委托。接下来,我们将定义我们之前在简单的错误处理代码中定义的几个错误状态的行为。为了提高可读性,我们将定义一些私有类变量来保存我们逻辑上合并的WebExceptionStatus值组:

private List<WebExceptionStatus> connectionFailure = new List<WebExceptionStatus>() {
 WebExceptionStatus.ConnectFailure,
 WebExceptionStatus.ConnectionClosed,
 WebExceptionStatus.RequestCanceled,
 WebExceptionStatus.PipelineFailure,
 WebExceptionStatus.SendFailure,
 WebExceptionStatus.KeepAliveFailure,
 WebExceptionStatus.Timeout
};

private List<WebExceptionStatus> resourceAccessFailure = new List<WebExceptionStatus>() {
 WebExceptionStatus.NameResolutionFailure,
 WebExceptionStatus.ProxyNameResolutionFailure,
 WebExceptionStatus.ServerProtocolViolation
};

private List<WebExceptionStatus> securityFailure = new List<WebExceptionStatus>() {
 WebExceptionStatus.SecureChannelFailure,
 WebExceptionStatus.TrustFailure
};

这样,我们可以轻松地定义一个Policy对象,该对象对具有我们分组中状态之一的WebException做出响应,如下所示:

public static void ExecuteRemoteLookupWithPolly() {
    Policy connFailurePolicy = Policy
        .Handle<WebException>(x => connectionFailure.Contains(x.Status))
        .RetryForever();

    HttpResponseMessage resp = connFailurePolicy.Execute(() => ExecuteRemoteLookup());
    if (resp.IsSuccessStatusCode) {
        Console.WriteLine("Success!");
    }
}

注意,当我们执行Policy时,我们指定只要我们得到在connectionFailure分组中定义的WebExceptionStatus,我们希望重试请求。所以,现在让我们从这个驱动程序程序中多次调用它,看看每次运行后我们的控制台看起来像什么。假设由于伪随机数生成器的足够随机性,应该至少有几次运行在返回有效响应之前多次失败。(注意,为了演示的目的,所有的 Polly 代码都存在于一个static PollyDemo类中)。让我们看一下以下代码:

using System.Threading;

namespace ErrorHandling {
    public class Program {
        static void Main(string[] args) {
            PollyDemo.ExecuteRemoteLookupWithPolly();
            Thread.Sleep(10000);
        }
    }
}

如果你已经将你的 IDE 配置为在错误时中断,那么每次你的代码失败时,你都会在执行中暂停。然而,我自己运行这段代码时,我看到了立即的成功,然后是五次连续的重试,我的代码才成功执行。我能够在不到 10 行代码中定义这一点是令人难以置信的,这也说明了 Polly 在为应用程序提供弹性方面的价值。

然而,如果我们想要真正地反映我们在之前简单错误处理代码中建立的行为,我们希望根据达到的状态对各种异常状态响应以特定的恢复代码。为此,Polly 允许你定义多个状态处理器,然后将它们包装在PolicyWrap类中,这正是它所做的事情。它将允许你为所需的任何条件状态定义恢复策略,然后将它们包装在一个单一的共同策略中,当调用PolicyWrap实例的Execute(delegate)方法时,将遵守这个策略。

为了演示这一点,我们将为我们的代理定义几个额外的异常状态,这样如果生成的随机数能被3整除,我们将抛出一个名称解析错误;如果数字能被4整除,我们将抛出一个安全错误:

public static HttpResponseMessage ExecuteRemoteLookup() {
    var num = new Random().Next();
    if (num % 3 == 0) {
        Console.WriteLine("Breaking the circuit");
        throw new WebException("Name Resolution Failure", WebExceptionStatus.NameResolutionFailure);
    } else if (num % 4 == 0) {
        Console.WriteLine("Falling Back");
        throw new WebException("Security Failure", WebExceptionStatus.TrustFailure);
    } else if (num % 2 == 0) {
        Console.WriteLine("Retrying connections...");
        throw new WebException("Connection Failure", WebExceptionStatus.ConnectFailure);
    }
    return new HttpResponseMessage();
}

既然我们现在有随机机会满足我们的至少一个异常条件,让我们定义每种情况下的行为。正如你可能从我的控制台消息中注意到的,我们将为每个特定的错误情况使用不同的策略。Polly 默认定义了一些策略,你可以在最需要的情况下调用每个策略。现在我不会详细介绍所有这些策略,但我会花点时间鼓励你阅读 Polly 的文档(github.com/App-vNext/Polly)。它比这一整章都要长,但写得很好,对于任何希望为生产应用程序提供更可靠稳定性的开发者来说,它都是极其有用的。不过,现在我们只关注Circuit-breaker策略和Fallback策略。这两个策略似乎对我们用例最有用,因为它们与我们先前简单方法中确定的战略最为接近。

Fallback策略是两个中较简单的一个。它仅仅允许你在处理指定的异常时返回一个替代响应。在我们的例子中,由于我们将使用Fallback来处理我们的安全异常,我们将简单地返回一个新的HttpResponseMessage实例,并将状态码设置为 401,以通知我们的下游消费者存在需要解决的问题,即授权问题。

断路器策略指定,在多次尝试解决请求失败后,应该打开电路到请求的资源,并在请求开始之前停止后续请求。这在像我们为名称解析失败定义的场景中很有用,在这种情况下,基于错误消息,后续尝试成功的可能性并不比原始请求更高。打开电路(从而停止通过该电路的请求流)给上游系统一个机会,在没有被一系列重试尝试轰炸的情况下恢复。你可以配置电路在指定数量的失败尝试或指定超时后打开,并且你可以设置它保持打开状态,直到你认为这可能是必要的,以便允许上游系统恢复。

与重试策略及其变体不同,然而,断路器在响应抛出的错误时实际上并不做任何事情。事实上,它总是会重新抛出任何捕获到的错误;即使电路已经断开。如果你想在开放电路指定的重置周期后重试请求,你可以自由地实现这种行为,但默认情况下,Polly 的spec不会在其断路器实现中这样做。因此,在我们的示例中,我们将在只有一次失败尝试后断开电路,我们仍然需要在调用代码的try/catch中查找适当的错误消息。

考虑到这一点,让我们更新我们之前的示例。我们首先要做的是为我们的Fallback策略添加一个方法,返回HttpResponseMessage中的 401 状态码:

private static HttpResponseMessage GetAuthorizationErrorResponse() {
    return new HttpResponseMessage(HttpStatusCode.Unauthorized);
}

然后我们将为我们的两种替代错误状态设置策略,并相应地包装它们:

public static void ExecuteRemoteLookupWithPolly() {
    Policy connFailurePolicy = Policy
        .Handle<WebException>(x => connectionFailure.Contains(x.Status))
        .RetryForever();

    Policy<HttpResponseMessage> authFailurePolicy = Policy<HttpResponseMessage>
        .Handle<WebException>(x => securityFailure.Contains(x.Status))
        .Fallback(() => GetAuthorizationErrorResponse());

    Policy nameResolutionPolicy = Policy
        .Handle<WebException>(x => resourceAccessFailure.Contains(x.Status))
        .CircuitBreaker(1, TimeSpan.FromMinutes(2));

    Policy intermediatePolicy = Policy
        .Wrap(connFailurePolicy, nameResolutionPolicy);

    Policy<HttpResponseMessage> combinedPolicies = intermediatePolicy
        .Wrap(authFailurePolicy);

    try {
        HttpResponseMessage resp = combinedPolicies.Execute(() => ExecuteRemoteLookup());
        if (resp.IsSuccessStatusCode) {
            Console.WriteLine("Success!");
        } else if (resp.StatusCode.Equals(HttpStatusCode.Unauthorized)) {
            Console.WriteLine("We have fallen back!");
        }
    } catch (WebException ex) {
        if (resourceAccessFailure.Contains(ex.Status)) {
            Console.WriteLine("We should expect to see a broken circuit.");
        }
    }
}

因此,在我们的改进方法中,我们为每个我们想要响应的可能场景定义一个策略,包括应该处理的特定异常状态和我们要实施的恢复过程。值得注意的是,我们调用了Policy.Wrap()方法两次。这样做的原因是,在Policy<HttpResponseMessage>的强类型实例上使用Fallback()方法是唯一能够指定我们传递给Fallback()的委托方法返回对象类型的方式。然而,通过强类型化策略,我们无法在单个调用中将它Wrap()到其他弱类型策略中。强类型策略的Wrap()方法最多只能接受一个参数。因此,这个问题的解决方案是首先将我们定义的所有弱类型策略包装起来,然后使用那个包装好的Policy实例作为输入到我们强类型PolicyWrap()调用。我明白这最初可能会让人困惑,但随着你使用 Polly、阅读他们出色的文档以及最重要的是,在编写的任何网络软件中实施这些错误处理策略,这将会变得更加清晰。

为了使我们的驱动程序程序在测试目的上使用起来更简单(而不是手动运行程序二十多次来查看所有可能的结果),我们也将更新它。让我们看一下以下代码:

public static void Main(string[] args) {
    for (var i = 0; i < 24; i++) {
        Console.WriteLine($"Polly Demo Attempt {i}");
        Console.WriteLine("-------------");
        PollyDemo.ExecuteRemoteLookupWithPolly();
        Console.WriteLine("-------------");
        Thread.Sleep(5000);
    }
}

运行该程序一次(也许两次,因为毕竟随机性是随机的)你应该会看到我们每个可能场景的适当日志语句。我指望你能理解我们程序中控制流的本质,以了解为什么屏幕上的结果展示了我们定义的Policy对象所承诺的功能。

随着我们继续前进并查看不同网络协议的具体实现,我们将大量依赖 Polly 来定义我们的恢复策略。这个库有很多深度,你可以从中得到你选择投入学习的任何东西。然而,有了这个基础,你将准备好继续阅读本书的其余部分。

摘要

在本章中,我们详细探讨了.NET Core WebException类如何为工程师提供一个稳定、可靠且信息丰富的接口,以便在出现网络错误时理解它们。我们讨论了何时以及如何预期和解释网络异常,以及如何检查这些异常的Status属性以确定其根本原因。我们还考虑了提供有意义的异常消息的责任,以及为我们的软件的任何消费者提供尽可能具体的状态代码的价值。最后,我们探讨了常见的策略,以及一个极其有用的库 Polly,用于持续地从网络异常中恢复,以最大化我们的应用程序的运行时间和增加消费者对我们软件的信任。在未来的工作中,保持这些弹性和优化的想法将非常重要。

在下一章中,我们将进入低级数据传输和主机间通信的世界。

问题

  1. 我们应该始终假设关于外部依赖性的一件事是什么?

  2. HTTP 中的错误状态代码分为哪两类?

  3. 我们可以使用WebException类的哪个属性来确定我们收到的异常的性质?

  4. Polly 的回退策略为异常状态提供了什么?

  5. 电路断路器策略规范与retry策略规范有何不同?

  6. 编写一个示例方法,该方法基于从示例请求的响应中的StatusCode结合fallbackretry策略。

  7. 在哪些情况下,你不想在初始失败后重试网络请求?

进一步阅读

如需了解有关各种网络软件架构的具体错误处理策略的更多信息,请参阅Jason De OliveiraMichel Bruche**t合著的《学习 ASP.NET Core 2.0》。这本书将为基于 ASP.NET Core 的 HTTP 应用场景中的错误处理提供更深入的指导。本书电子版或印刷版均可购买,链接如下:www.packtpub.com/application-development/learning-aspnet-core-20.

或者,我再次推荐由Mark J. PriceOvais Mehboob Ahmed Khan合著的《C# 7 和.NET:设计现代跨平台应用》。关于异常处理的内容对许多常见用例来说很有用,并且聚焦良好。再次提醒,购买电子书或印刷版的链接在这里:www.packtpub.com/application-development/learning-path-c-7-and-net-designing-modern-cross-platform-applications.

第三部分:应用协议和连接处理

在这部分,读者将深入探索网络应用的各个组成部分,通过大量的代码示例,研究网络堆栈的每一层。

本节将涵盖以下章节:

第八章,套接字和端口

第九章,.NET 中的 HTTP

第十章,FTP 和 SMTP

第十一章,传输层 – TCP 和 UDP

第八章:套接字和端口

在这个阶段,我们已经了解了如何处理来自远程主机的数据流,如何在后台线程上异步处理这些流,以及如何处理处理这些数据时出现的错误。现在,我们将探讨你可以与远程主机建立的最原始的连接。在本章中,我们将探讨你的机器将要通过这些物理端口来完成这项工作,并且我们将探讨套接字的概念:一种软件结构,它暴露了对端口的访问以进行网络交互。我们将检查 WinSocks 库以实例化和使用这些端口,并考虑各种方式,你的应用程序代码可以利用套接字与目标主机进行高效、低级别的通信。

本章将涵盖以下主题:

  • System.Net.Sockets类如何作为 C#接口服务于你的硬件级网络 API,用于与外部机器通信

  • 如何连接到网络上另一台机器暴露的套接字

  • 如何编写暴露套接字以接受外部连接请求的服务器应用程序

  • 在串行端口上进行通信的性质,以及如何将串行端口暴露出来以接受用于处理的数据,以及这如何为你打开 C#和.NET Core 的有趣用例。

技术要求

本章将包含多个示例和驱动程序程序来演示所讨论的概念,每个都将在此处提供:github.com/PacktPublishing/Hands-On-Network-Programming-with-CSharp-and-.NET-Core/tree/master/Chapter%208

和往常一样,在本地克隆存储库,开始与源代码一起玩耍,或者编写你自己的代码,与章节中的主题一起熟悉它。

我们还将开始使用外部工具来测试和检查示例 API 的行为。为此,你需要下载并安装 Postman,它可以在以下位置找到:www.getpostman.com/apps,或者你需要 Insomnia REST 客户端,它可以在以下位置找到:: insomnia.rest/

这些应用程序的功能集几乎完全相同,每个都将允许你通过直观的用户界面向本地 API 发送任意请求。我们将使用它们来测试我们的网络软件,并建议你至少花一点时间熟悉你选择的工具的一些基本功能。在本章中,我将展示从 Postman 捕获的屏幕截图,但这并不是对 Postman 优于 Insomnia 的认可,并且跟随步骤和 UI 在两者中应该是几乎相同的。

最后,我们将使用 Docker 来演示端口映射。虽然您可以在没有这个工具的情况下理解本章的具体概念,但我强烈建议您下载并熟悉它。它是现代 Web 应用程序开发体验的核心,您将从中受益匪浅。本章提供了最好的实践机会之一,我肯定会鼓励您尝试。Docker 可以在此处下载:hub.docker.com/editions/community/docker-ce-desktop-windows

查看以下视频以查看代码的实际运行情况:bit.ly/2HYmX49

套接字与端口

当我们查看这些连接机制时,我们应该首先区分这两者。虽然它们是两个标识常见硬件交互的词,但每个术语所标识的软件或抽象概念实际上是相互排斥的。这些术语不像之前章节中的抽象类和接口那样可以互换,因此当我们使用每个术语时,它将具有特定的目的,您需要知道它是什么。

端口 – 硬件接口

正如我们所知,机器通过其 IP 地址或 DNS 注册表中映射到该 IP 地址的主机名来识别。因此,对于任何给定机器之间的连接要得到解决,发起主机最终需要目标主机的底层 IP 地址。然而,仅指定目标 IP 地址不足以针对在主机上运行的服务或应用程序。它只给出了主机本身的位置。这就是端口的作用所在。端口是一个表示目标机器上运行进程的两个字节的无符号整数。

您的主机上的每个将与远程进程交互的应用程序都必须在指定的端口上进行操作。此外,没有任何两个应用程序可以监听相同的端口。每次您想要启动应用程序并将其指定为监听网络请求时,您都必须将其分配到您机器上的一个无符号端口。如果您曾经尝试在本地机器上的80998080端口(或任何其他常见监听端口)上运行多个 API 项目的实例,您将看到启动失败的消息,表明目标端口已被占用。该端口已被占用,因此您必须找到一个不同的端口来处理针对您新应用程序的事务。

这种原因应该是相当明显的。如果你想在一个设备上托管多个服务,你需要一种方法来区分对服务 A 的入站请求和对服务 B 的入站请求。通过为每个托管应用程序指定互斥的监听端口,你将正确的路由负担转回到客户端。这是完全可以接受的,因为客户端已经需要跟踪远程主机的 URI,而且如果你记得我们之前章节的内容,端口号只是该 API 的另一个组成部分。另一种选择需要应用程序作为你的托管监听应用程序和所有入站网络请求之间的中介。这样的应用程序必须对每个监听应用程序的状态或期望有合理的了解,然后它将解析每个入站请求以确定哪些期望被后续请求满足。这将变成一大堆状态管理,很快就会变得不可行。所以,我们只是在我们的 URI 规范中添加 2 字节地址后缀来指定目标监听应用程序,它是直接嵌入到我们的 URI 规范中的。

保留端口

如果你已知一个无符号 2 字节int的有效整数值,那么你已经知道了机器可能暴露的所有端口的完整范围。使用这种数据类型,端口号的指定值可以从065535。然而,仅仅因为端口号的指定值落在这个数据类型允许的范围内,并不意味着你应该尝试监听它。实际上,有一些端口号范围是你用户应用程序代码绝对不应该尝试监听的。这些被指定为保留端口,通常处理非常特定的功能。

第一组保留的端口,因此你不能注册你的应用程序,被称为知名端口。这些端口介于01023之间,用于从 DNS 地址解析(用于确保在注册表中列出的地址上仍然有机器在监听,该端口号为53)到 FTP 数据和控制端口(分别为端口号2021)。如果你在阅读这本书之前做过任何形式的网络编程,你很可能已经熟悉了这样一个事实,即80端口是入站 HTTP 请求指定的端口,而443端口是为 HTTPS 保留的。

您无法注册应用程序的另一组端口被称为动态端口范围。动态端口,或称为私有端口,用于建立私有或定制服务或交互的连接,或用于两个主机之间的临时传输控制协议TCP)或用户数据报协议UDP)交互。当在临时环境中为机器的短暂需求提供服务时,指定的端口被称为临时端口。这些端口不能在给定的主机上注册给互联网数字分配机构IANA)用于通用网络交互。这些端口的范围从端口号49152开始,到65535结束。

互联网数字分配机构IANA)是一个负责管理 IP 地址分配(以及其他事项)的非营利组织。正如我在第一章,“网络概要”中提到的,一个集中化的标准(和名称)系统对于确保每个设备将一个 IP 地址的请求路由到同一设备非常重要。

根据该规范,这似乎意味着在102449151之间以及包括这两个端口号的所有内容都可用于您的应用程序。这些被称为已注册端口。它们可根据需要由用户应用程序或系统服务分配,并且不会干扰您的硬件或其他连接主机的默认行为。

可能看起来将您的应用程序配置为在已注册端口范围内监听就足够使用了。然而,这仍然不是完全如此。如果您曾经运行过 JBoss 应用程序服务器(在像 JBoss 这样的膨胀应用程序服务器的糟糕旧日子里,您可能记得通过访问http://localhost:8080/my-java-application来访问您本地的开发环境,或者至少这是我过去不得不做的。JBoss 总是配置那个端口的原因是它实际上充当了80端口的别名,即 HTTP 端口。8008也是一样。因此,尽管端口位于已注册端口范围内,但对其行为有特定的期望。它实际上只是为用户提供了一个值,以便在已注册端口范围内定义默认的 HTTP 处理器,因为您无法直接将应用程序分配到80端口。

在已注册端口范围内还有其他端口,这些端口可能被您本地机器上的常见服务和应用程序占用。尽管.NET Core 如果无法在指定端口上注册自己就会停止,但您会立即注意到,如果您需要使用不同的端口号更新您的配置。

在单个端口上暴露多个应用程序

如果你已经在网络开发领域工作了相当长的时间,你可能已经熟悉了在 Windows 宿主上部署网络应用程序时,如何处理 Internet Information Services(IIS)或上述 JBoss 的各种配置和设置。这被称为 应用服务器,它本质上为系统上任何公开的网络应用程序提供了一个共享的托管环境。当使用 IIS 时,你可以注册任意数量的应用程序以响应针对单个端口的请求(HTTP 的 80 端口或 HTTPS 的 443 端口),并通过 URI 中的应用程序路径或子域规范来区分它们。

因此,如果你有两个名为 TestAppSampleApp 的应用程序,并且你希望在单个机器上托管它们,同时通过 HTTP 端口公开它们,你可以通过在 IIS 中注册和部署它们来实现。这样做时,你会在 IIS 中指定一个应用程序目录,例如 /sample/test。这将告诉 IIS,任何通过 80 端口到达你的主机名的请求,其请求路径的第一个组件是 /sample 目录,应该被路由到你的 SampleApp,就像请求直接发送到该应用程序一样。这实际上将映射特定端口的问题转化为映射特定应用程序目录的问题。

虽然 IIS 仍然支持部署 .NET Core 应用程序,但在现代网络托管环境中,这种情况要少得多。IIS 特别以其复杂的配置方案以及它对宿主机器造成的巨大内存和 CPU 使用影响而闻名。更不用说 IIS 仅限于 Windows 操作系统,这使得 IIS 托管应用程序的可移植性几乎不存在。

更常见的是,工程师们正在采用更轻量级的方法来处理托管问题。随着 .NET Core 运行时的跨平台支持,那些在绿色场应用中工作的工程师被鼓励追求更前沿的解决方案。通常,.NET Core 开发者通过 Docker 容器将应用程序部署到远程主机。Docker 为你的应用程序提供了一个隔离的托管环境,并通过将容器的内部监听端口映射到运行 Docker 容器的机器上的可用端口,将你的应用程序监听的端口暴露给外界。你可以在所谓的 Dockerfile 中指定应用程序希望监听的端口,该文件指定了 Docker 托管应用程序的构建和部署步骤。它与 PowerShell 脚本或 bash 脚本类似,用于自动化常见的操作系统级操作。一旦指定了所需的端口,你可以在 run 命令中将它映射到宿主机器上的一个端口,如下所示:

docker run -p 80:5000 -p 443:5001 SampleApp

此命令将把我们的Docker 容器内部的5000端口映射到主机机器上的80端口,以及5001端口映射到443端口。因此,从我们的托管上下文来看,我们将收到针对80端口的请求,这将由我们的 Docker 实例监听,并将请求转发到我们的正在运行的.NET Core 应用程序,该应用程序将监听5000端口:

图片

从这里,由 IIS 或 JBoss 解决的在单个端口后面托管多个应用程序的问题通常只是配置问题。如果你的应用程序是云托管,你通常可以执行与 IIS 提供的相同类型的路由前缀映射。在其他上下文中,你可以在所谓的反向代理后面托管你的应用程序套件。

我们将在后面的章节中花时间查看,在某些情况下甚至构建这些方法中的每一个。然而,现在,你只需要理解端口作为外部请求可以通过它访问目标设备上特定服务或应用程序的机制的性质就足够了。当将你的应用程序暴露给网络资源时,你使用的特定端口通常是简单配置和惯例的问题;现在,我们将看看如何通过套接字在我们的软件中与这些特定端口进行交互。

套接字 – 对端口的软件接口

既然我们已经了解了端口是如何用于将请求路由到主机设备上的特定进程的,我们该如何设置我们的应用程序以实际接受通过这些端口的请求呢?这就是套接字发挥作用的地方。

套接字为特定远程主机上的特定端口提供了一个软件接口。它是在你指定的服务器和端口地址处暴露的任何远程应用程序与你的应用程序之间的开放连接流。一旦建立这种连接,你就可以自由地向该连接的开放流写入(或读取)任何你需要的数据。套接字是一个多用途的概念,几乎在任何服务器端编程语言中都有实现,.NET Core 也不例外。

套接字与其底层端口之间的一个关键区别是,端口代表对远程设备上单个进程的访问。因此,端口只能为单个应用程序注册。然而,套接字代表对该单个资源的活动连接。因此,可以连接到资源的活动套接字数量可以由网络和远程主机支持:

图片

因此,端口代表在远程机器上运行的单个进程,而套接字代表一个连接到远程机器上进程的连接,该进程由端口号指定。当我们运行应用程序并打算将其暴露给远程资源时,我们必须将其注册到特定的端口。如果我们想通过一个暴露的端口连接到我们的应用程序,我们使用套接字来完成。端口仅仅是配置问题,而利用套接字则是实现细节,因此现在让我们看看如何实例化和利用套接字进行网络通信。

虽然我把端口注册视为仅仅是配置问题,但这并不意味着你不负责任去理解和配置。全栈网络工程要求你不仅要了解如何编写应用程序,还要了解如何正确配置和部署它们到各种预生产和生产环境中,以便其他人可以使用它们。我们将在下一章中探讨应用程序部署。

在 C#中利用套接字

C#中的套接字是一个极其灵活和通用的概念。正如其定义所示,它们仅暴露对远程资源的连接,而如何使用该连接几乎完全取决于建立该连接的开发者。C#中Socket类的一个实例提供了同步和异步的数据包传输,这些数据包是任意字节数组的集合。这些数据包的内容、结构和甚至用于传输这些数据包的协议都由你决定(尽管我强烈建议你始终优先考虑异步通信而不是同步通信)。因此,让我们看看我们将如何使用它。

网络套接字规范

了解套接字的第一件事是了解如何初始化它。初始化套接字所需的最基本信息是理解我们将要处理哪种类型的套接字,以及它将运行在哪种协议上。

该规范的第一个方面,即套接字类型,告诉我们的代码一旦建立连接,我们将如何与之交互。这由SocketType数组定义,位于System.Net.Sockets命名空间中,该命名空间定义了完整的有效交互机制。enum的值包括Dgram,表示我们将直接与我们的软件和连接的主机之间的无序、无连接的数据报进行工作;Seqpacket类型,它通过有序、边界保护的字节在流中来回传输;以及Stream类型,它提供了我们迄今为止已经熟悉的Stream实例上的异步字节流。还有一些其他的SocketType值,你可以在微软文档页面上找到它们以及它们的意义和用法描述。对于本章,我们只需使用Stream类型,因为它与我们已经非常熟悉的System.IO命名空间中的Stream类最为相似。

Socket可以连接并使用 OSI 网络栈传输层上的多种协议进行通信。这意味着当你构造一个套接字时,你需要指定一旦建立连接,你将使用什么协议进行通信。这会通知远程主机在连接建立后如何解析它将接收到的原始数据报或数据包(前提是主机最初支持请求的协议)。为了定义你的Socket实例将使用的协议,你将查看System.Net.Sockets命名空间中ProtocolType枚举的值。有几个定义的值对应于已建立的传输协议,包括 IPv4、IPv6、TCP、UDP、IDP、原始协议等。为了我们代码的目的,我们将连接到一个本地应用程序,该应用程序正在监听 HTTP 请求,由 TCP 协议处理,因此我们在初始化Socket时将指定 TCP 协议。

这两块信息是我们为套接字所必需的最小细节,公共构造函数的签名如下:

public Socket (System.Net.Sockets.SocketType socketType, System.Net.Sockets.ProtocolType protocolType);

还有一个选项可以指定连接的AddressFamily。这实际上可以从你的连接端点推导出来,并提供给套接字构造函数。通常,对于通过 TCP 传输的 HTTP 资源,你的指定将是AddressFamily.Osi,表示你正在使用 OSI 寻址方案。既然我们已经知道了如何初始化套接字,让我们看看连接套接字到远程端点需要什么。

建立套接字连接

我们首先想要做的是设置一个简单的监听服务器,我们可以将其连接到我们的套接字驱动程序。为此,我们将启动一个简单的WebAPI项目并运行它。首先,打开命令提示符并导航到你想要创建示例 API 的目录。接下来,使用以下命令从.NET Core CLI 创建一个新的WebAPI

dotnet new webapi -n SampleApp

这将从头开始启动一个新的应用程序,该应用程序将准备好接收和响应发送到你的本地机器和预配置端口的 HTTP 和 HTTPS 请求。

为了本次演示的目的,我们实际上需要禁用此应用程序中的一些默认功能。WebAPI 的模板将重定向所有发送到 HTTP 端口的调用到 HTTPS 端口。我们希望阻止这种情况发生,以便 HTTP 端口可以直接服务请求。你稍后会明白原因,但现在你可以通过打开你的SampleApp项目并导航到Startup.cs文件来禁用此功能。在这个文件中,你会找到一个具有以下签名的函数:

public void Configure(IApplicationBuilder app, IHostingEnvironment env)

在此方法的底部,删除或注释掉读取以下代码行的行:

app.UseHttpsRedirection();

完成这些后,您可以关闭该文件夹,并在本示例项目的剩余部分忽略其内容。现在,让我们运行它并测试它,首先导航到刚刚创建的文件夹,然后调用 CLI 的 dotnet run 命令。完成这些操作后,您应该会看到运行中的应用程序以下输出:

图片

如果您对由 dotnet new 命令创建的项目模板感兴趣,可以花些时间进行调查,但我们将在本章的下一部分更详细地介绍 WebAPI 应用程序,以及其他许多内容。现在,重要的是我们有一个正在监听请求并返回响应的应用程序。如果您愿意稍后再学习,可以简单地相信我对其预期功能的前进,如果愿意的话。

您控制台输出的最后三行提供了有价值的信息;它们告诉您应用程序通过哪个确切的主机和端口对外暴露连接。如您所见,由 .NET 创建的新 WebAPIs 的默认端口将是 5000,用于传入的 HTTP 请求,而 5001 用于 HTTPS。

为了确认应用程序正在响应用户请求,请打开 Postman(或如果您选择的是 Insomniac,则打开 Insomniac),并发送一个 GET 请求到 http://localhost:5000/api/values。您应该在输出中看到以下响应:

图片

我们可以在 JSON 数组中看到一个有效的响应,包含两个字符串。一旦您有了这些,我们就可以使用 Socket 进行连接。

SampleApp 的父目录中创建一个新的控制台应用程序,使用 CLI 中的 dotnet new console -n SocketTest 命令。这将是我们与 Socket 类一起工作的驱动应用程序。本示例项目的目标是连接到正在 5000 端口监听的 SampleApp,向 /api/values 端点提交请求,然后解析并打印响应。

因此,我们首先需要为 Socket 定义一个 IPEndPoint 实例以进行连接。IPEndPointEndPoint 抽象类的一个特定实现,该类是我们将要使用的 Socket.ConnectAsync() 方法所要求的。它定义了我们打算连接到的远程资源的特定位置,并公开了有关该端点的元数据。它还为我们 Socket 构造函数签名提供了 AddressFamily 值。所以让我们首先使用主机地址和端口定义它,并使用它来构造 Socket

要做到这一点,我们需要一个IPAddress实例,我们可以根据存储在我们本地主机地址127.0.0.1中的 4 个字节自己构建它,或者我们可以简单地使用Dns.GetHostEntry()方法从第二章,DNS 和资源定位显式请求它。当你跟随时,你可以随意操作,但由于它涉及较少的数学,我将使用 DNS。然而,由于主机条目返回的AddressList可以包含一个任意大的 IP 地址列表,这些地址可以解析该名称,我们希望连接到允许我们连接的第一个地址,并从这里开始。这意味着我们需要在AddressList中循环,直到建立连接。因此,尝试建立我们连接的初始设置将看起来像这样:

static async Task Main(string[] args) {
  string server = "localhost";
  int port = 5000;
  string path = "/api/values";

  Socket socket = null;
  IPEndPoint endpoint = null;
  var host = Dns.GetHostEntry(server);

  foreach (var address in host.AddressList) {
    socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
    endpoint = new IPEndPoint(address, port);
    await socket.ConnectAsync(endpoint);
    if (socket.Connected) {
      break;
    }
  }
  ...
}

这段代码可能让你注意到的是,我们正在使用Main()方法的async版本。这是一个仅在 C# 7.2 版本中添加的功能,如果你的项目没有配置为至少针对那个版本,你将遇到构建错误。要解决这些问题,只需修改.csproj文件的PropertyGroup标签,包括带有其版本设置为latestLangVersion标签,就像这里看到的那样:

<PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp2.2</TargetFramework>
    <LangVersion>latest</LangVersion>
</PropertyGroup>

一旦你做了这个更改,你的源代码将始终针对 C#的最新次要版本。有了这个,你应该没有问题异步运行你的Main()方法。

如果你想要确保一切按预期运行,你可以运行你的应用程序,在break;操作符上放置一个断点,你应该会看到断点被命中,这意味着你的两个应用程序之间已经建立了连接。然而,你会注意到,仅仅建立连接并没有在你的运行 WebAPI 应用程序中触发任何日志消息。这是因为,尽管建立了连接,但我们没有对连接到的资源发出请求。请求必须作为格式良好的消息通过已建立的连接发送。所以现在,让我们构建我们的请求,并通过连接的套接字发送它。

发送请求就像在我们的套接字上调用SendAsync()方法一样简单,该方法需要一个表示要发送的数据缓冲区的字节数组。因此,对于一个 HTTP 请求,我们必须从头开始构建我们的消息。这意味着指定我们将要使用的方法或 HTTP 动词,我们请求的资源的具体 URL,我们打算发送的任何内容的尺寸,以及我们需要附加的任何请求头。我相信到现在你已经能看出直接与套接字工作是多么繁琐。然而,对于这样一个简单的请求,我们可以通过一个简单的实用函数轻松构建我们的消息:

private static string GetRequestMessage(string server, int port, string path) {
  var message = $"GET {path} HTTP/1.1\r\n";
  message += $"Host: {server}:{port}\r\n";
  message += "cache-control: no-cache\r\n";
  message += "\r\n";
  return message;
}

然后,我们可以像写入流一样构建我们的字节数组。所以回到我们的主方法中,我们将获取我们的请求消息,将其转换为字节数组,然后将请求发送到我们的远程主机(SampleApp,运行在http://localhost:5000)。在通过Socket实例建立连接后,将以下几行代码添加到主方法中:

var message = GetRequestMessage(server, port, path);
var messageBytes = Encoding.ASCII.GetBytes(message);
var segment = new ArraySegment<byte>(messageBytes);

await socket.SendAsync(segment, SocketFlags.None);

如果你添加了这段代码并运行你的应用程序,当你开始在 WebAPI 项目的控制台看到日志信息时,你就知道你已经成功了,就像这里看到的那样:

图片

就这样,你已经成功地在套接字连接上发送了你的第一条传输层消息。

现在,为了确认我们正确地收到了服务器的响应,我们将尝试将之前在 Postman(或 Insomnia)请求的响应中看到的相同消息写入我们的SocketTest应用程序的控制台。为了做到这一点,我们必须使用ReceiveAsync()方法来接收服务器针对我们的请求返回的任何字节数组。

就像我们在前面的章节中使用的Stream类的实例一样,ReceiveAsync()方法接受一个字节数组,它将写入这个数组。为此,我们将提供一个长度为 512 字节的空数组。一旦我们定义了这一点,我们就可以接收来自远程资源的响应,并将其逐行写入我们的控制台。只需将以下几行代码添加到Main()方法的底部:

var receiveSeg = new ArraySegment<byte>(new byte[512], 0, 512);

await socket.ReceiveAsync(receiveSeg, SocketFlags.None);

string receivedMessage = Encoding.ASCII.GetString(receiveSeg);

foreach(var line in receivedMessage.Split("\r\n")) {
    Console.WriteLine(line);
}
Thread.Sleep(10000);

当你现在运行应用程序时,你应该会在控制台看到消息头,以及包含我们之前在 Postman 中看到的字符串数组的正文打印出来:

图片

就这样,你已经成功地在 TCP 上从头开始执行了一个 HTTP 请求。

最后一件事情是从你的主机断开连接,并销毁你的套接字。让你的应用程序Main()方法的最后两行看起来像这样:

  ...
  socket.Disconnect(false);
  socket.Dispose();
}

这是你的一大礼貌。尽管端口可以同时处理多个连接,但在任何给定时间点,它能够服务的连接请求数量是有限的。断开你自己的套接字会为其他人腾出远程主机上的资源。虽然对于非活动连接有一个最大时间限制,在此之后远程主机将强制取消连接,但你绝对不应该让非活动连接保持那么长时间。如果你完成了主机的工作,就断开与主机的连接。

解析响应

如您所料,封装您远程主机响应全部内容的简单 ASCII 字符字符串并不是一个计算机友好的格式。接收响应是一回事,但在您的应用程序中利用其内容则是完全不同的一种生物。每次需要访问不同机器上的内容时,从头开始做这种工作将使软件开发周期慢如蜗牛。

这就是为什么.NET Core 为日常可能遇到的具体协议和交互提供了如此多的功能灵活的包装类和实用类。因此,虽然我认为了解如何从您的应用程序直接连接到网络中任何其他机器上运行的任何其他应用程序的建立和利用是非常重要的,但这并不常见,您不太可能需要这样做。随着我们进入下一章,我们将看到.NET Core(以及 ASP.NET Core,在 HTTP 的情况下)提供的模板和库是如何完成所有繁重的工作,这样我们就不必亲自去做。如果您对 C#中低级网络交互感兴趣,那么有一个关于知识和用例的广阔海洋,我简单地没有时间在这一章中涵盖,我鼓励您花些时间深入研究。尽管如此,如果这个内容看起来有点无聊或乏味,请不要担心。它即将变得更有趣。

摘要

在本章中,我们开始利用并最终构建在前几章中奠定的基础,使我们的应用程序能够访问 C#中可用的全部网络功能。我们了解到,我们编写的任何应用程序,如果预期将在我们的网络上使用资源,必须首先通过我们主机机器上的端口向这些资源公开。我们研究了端口的指定和注册方式,并了解了一些关于我们如何注册自己的限制,包括知名端口号的原因和范围,以及我们不能(或至少不应该)注册应用程序的动态或临时端口号的范围。

一旦我们确立了这一概念,我们就转向连接的另一端,并开始使用套接字。我们了解到套接字是远程机器上开放端口上活跃连接的通用代码表示。我们看到了这个概念的简单性如何为基于套接字的网络代码和它对数据包级通信的低级控制开辟了广泛的应用。

通过本书到目前为止所涵盖的概念,你拥有了编写任何可能需要的网络软件所需的资源。理解异步流的本质、数据包构造和解析,以及与远程资源的套接字连接就足以实现任何可能的网络功能。然而,使用这些原始构建块远非理想。这就是为什么.NET Standard 为可能需要编写的各种应用程序提供了如此多的有用模板、模式和库,我们将在下一章开始探讨这些内容,从基于 HTTP 的应用程序开始。

问题

  1. 端口的定义是什么?

  2. 已知端口的范围是什么?

  3. 动态端口的范围是什么?

  4. 应用服务器的主要功能之一是什么?

  5. 端口的定义是什么?

  6. 套接字和端口之间的一些主要区别是什么?

  7. 哪个构造提供了套接字可以连接的协议范围?

  8. 套接字支持哪些协议?

进一步阅读

关于这个主题的进一步阅读,我在前几章推荐的大多数书籍仍然适用。

然而,为了获得更多见解,你可以查看 ASP.NET Core 1.0 高性能詹姆斯·辛顿Packt 出版.* 虽然那本书的主题是特定于应用层网络编程,但他讨论了管理直接连接 I/O 的性能优势,这个主题可能很有趣。你可以在 Packt 出版 上找到它:www.packtpub.com/application-development/aspnet-core-10-high-performance.

第九章:.NET 中的 HTTP

在前面的每一章中,我们探讨了构建网络软件的基本构建块。在本章中,我们将使用这些构建块来构建一个利用最常见网络协议超文本传输协议HTTP)的应用程序。我们将重新审视 HTTP 在开放系统互联OSI)网络堆栈中的位置,以及为什么它被归类为这样的类别。我们将更深入地考虑 HTTP 请求和响应的约定,并花一些时间探索请求、响应和内容头。我们将演示如何使用标准头指定您希望从外部 HTTP 资源获取的内容,以及如何使用自定义头切换应用程序的特定功能和功能。最后,我们将探索如何通过协议提供内容以服务对应用程序发出的 HTTP 请求。

本章将涵盖以下主题:

  • HTTP 协议的背景,以及其规范的优势和局限性

  • HTTP 请求方法,包括如何生成和直接使用 C#响应这些请求

  • 如何构造HttpRequestMessage,或使用HttpClient发送请求,以及可用于以有效 HTTP 响应响应请求的各种类

  • 在 C#中如何实现 HTTPS

  • HTTP/2 支持的新功能以及如何在.NET Core 中利用这些功能

技术要求

在本章中,我们将使用 GitHub 上本书的示例应用程序:github.com/PacktPublishing/Hands-On-Network-Programming-with-CSharp-and-.NET-Core/tree/master/Chapter%209

我们还将使用在第八章中使用的每个工具,套接字和端口。所以,如果您之前没有花时间安装并开始使用它们,我建议您现在就做。具体来说,我推荐从这里安装 Postman:www.getpostman.com/apps

或者,您可以使用 Insomnia REST 客户端,您可以在以下链接找到它:insomnia.rest/.

此外,尽管它不会在本章中重点介绍,因为部署选项将超出本章的范围,但我鼓励您利用这个机会开始使用 Docker。我会指出修改和扩展您的 Dockerfile 以及本地部署更改的机会。

查看以下视频,以查看代码的实际应用:bit.ly/2HY5WaA

打开 HTTP

在第三章,通信协议中,我们有一个部分,应用层,我们探讨了为什么某些协议落在这个层次。在了解了网络事务的细节之后,我希望能够更容易地区分传输层和应用层。对于 HTTP,我们有最好的机会来探索这种区别。作为一个协议,它从 C#库中获得了最广泛、最稳健的支持。这种语言资源的深度将为我们提供一个锐利的视角,通过这个视角我们可以观察应用层及其底层传输层之间的区别。因此,在我们学习如何使用 HTTP 之前,让我们先弄清楚它究竟是什么。

HTTP 的本质

当我们在第八章,端口和套接字中处理低级流时,我们看到了当必须直接与字节流工作、序列化和反序列化数据以及手动解析输入头时,开发者所承担的责任。仅仅为了从一个机器到另一个机器获取一个简单的字符串,就需要大量的样板代码。显然,每次需要发出外部资源请求时都编写相同的样板代码是乏味且容易出错的。这就是协议发挥作用的地方。

我们在第三章,通信协议中简要讨论了这个话题,我们探讨了网络堆栈中每个层定义的协议。然而,在接下来的章节中,我们已经对如何在不同协议之间跨越 OSI 堆栈有了更好的理解。因此,希望那些在第三章,通信协议中可能有些模糊的区别现在会变得更加清晰。

当在.NET 中编写网络软件时,我们将主要在 OSI 堆栈的这两个层级上工作。第一个,也是最明显的,是应用层。HTTP 就生活在这里,FTP 和 SMTP 也在这里,而且你过去可能已经与之交互过的任何 Web 应用程序软件也都会在这里。然而,在.NET 中,通常还会编写传输层。在监听服务器中直接处理 TCP 和 UDP 调用很容易(我们将在后面的章节中看到)使用一些你可能会从.NET Core 中期待的多功能且易于使用的实用类。但程序员从哪个角度来看,这两层之间的区别是什么?当我们编写 HTTP 软件时,我们仍然非常关注我们序列化数据的格式和结构。那么,为什么在编写 HTTP 时与直接处理 TCP 客户端的流相比,这种关注是不同的呢?

应用层和传输层

当你试图理解这种区别时,需要内化的最重要的事情是每个层所关心的最小数据块。应用层主要关注应用对象。因此,只要你所使用的语言提供了对整个网络堆栈的完全实现的抽象(例如 C#),你就可以编写仅通过业务和应用模型与外部资源通信的代码。你永远不必担心从它们的序列化数据报文组合这些模型,或者担心字符编码,或者整数的字节序。

相反,你将用网络事务的结果来思考和说话——例如,“我正在请求一个数据库记录,”而不是“我正在请求数据库记录的一系列字节。”如果你发现自己陷入了使你的应用层软件过度通用化和抽象的反模式,你很快就会发现自己没有提供任何有意义的价值。应用层软件应该描述并依赖于至少一些具体的业务模型;否则,它只是介于传输层和实际使用这些业务模型的代码段之间不必要的额外层。

同时,在这一概念硬币的另一面,我们有传输层。正如你可能已经猜到的,在这一层编写的软件不需要业务上下文就能正确实现并提供其预期的价值。事实上,任何非原始Object的表示,比如简单的泛型之外的Serialize<T>(),都会使你的传输层软件在没有特定业务应用上下文的情况下变得几乎无用。任何围绕具体业务对象构建的传输层软件架构都像是用牙签搭建的房子,用泡泡糖粘合在一起:不稳定且寿命短暂。

我现在强调应用层和传输层软件之间的区别,是为了使本章其余内容的内容更加直观。从现在开始,我们将要使用和思考的一些.NET 中的类,以及我将给你的一些关于这方面的建议,都将依赖于你对这种区别的理解。此外,了解 HTTP 是如何产生,以及它如何演变成今天的样子也是很好的。

HTTP 的历史

如我在第三章中提到的,通信协议,虽然现在 HTTP 是网络软件的事实上的协议,但其原始设计和意图实际上要简单得多,限制也更多。甚至其同名的超文本,也已经远远超出了其原始概念。

超文本最早在 1965 年被描述,其定义为一个在计算机或其他电子设备上渲染文本的规范,其中包含对其他超文本文档的引用,这些文档可以通过称为 超链接 的引用系统立即访问。在最基本的意义上,这描述的不过是原始的网页。事实上,你无疑已经意识到作为概念的 超文本 与用于渲染网页的超文本标记语言(HTML)文件格式之间的联系。这些基本规范成为了现代互联网的先驱。

像我们现代世界的许多事物一样,超文本、HTML 和 HTTP 的起源可以追溯到一部有影响力的科幻作品!1941 年,豪尔赫·路易斯·博尔赫斯(Jorge Luis Borges)所写的短篇小说《分叉的路径花园》(The Garden of Forking Paths)通常被认为是超文本第一定义的灵感来源。

数十年后,在 1989 年,欧洲核研究组织(CERN)的研究人员开始正式化他们定义全球计算机网络标准的努力,现在被称为 万维网WWW)。这项工作包括定义文档表示的标准以及在这些机器之间传输这些文档的协议。1991 年,HTTP 的第一个正式定义被起草,并命名为 v0.9

这个原始定义在范围上极为有限,仅旨在定义从给定服务器请求超文本页面的过程;该规范定义了一种单一的方法,即 GET。然而,随着互联网在早期开始触及消费者,更广泛的受众需求迫使网络标准的演变。到了 1996 年,HTTP v1.0 被正式化并在广泛范围内得到认可时,该标准已经扩展到包括消息头、安全性和更广泛的操作。

然而,尽管如此,它几乎仅用于从服务器向客户端传输网页。每个请求都必须与服务器协商自己的连接,一旦该请求得到处理,连接就会关闭。如果你只是请求一个静态网页,这种行为是有意义的,但如果你想要加入用户交互呢?这种连接协商是有代价的。

CERN 的工程师们认识到了这一点,并在 1997 年仅用一年时间发布了更新的 HTTP v1.1(通常写作 HTTP/1.1)规范,该规范提供了一套更丰富的功能集。这包括用于指定响应缓存行为的头信息、持久连接、身份验证和授权、消息语法以及路由或重定向行为。事实上,HTTP/1.1 仍然在很大程度上保持不变,并且至今仍被广泛使用。然而,尽管直到最近 HTTP/2 的出现(2015 年引入),该协议本身基本保持不变,但工程师们 使用 该协议的方式却呈指数级增长。

网络服务和 HTTP

随着互联网从 20 世纪 90 年代初期的商业和工程专业人士分享资源和信息的利基工具,发展到今天广泛使用的平台,为互联网编写的软件也必须随之增长。HTTP 的简单性和可扩展性提供了一个如此可靠、广泛理解和支持的协议,以至于它迅速超越了其最初声明的意图。工程师开始利用它来处理几乎所有的网络服务和资源访问实例。现在,HTTP 几乎成为互联网上几乎所有开放 API 的首选协议。

早期,微软就认识到了这一点,当它在 2001 年发布.NET 框架的第一个版本时,它完全支持 HTTP 作为通用网络资源访问的事实应用层协议。后来,随着Windows 通信基础WCF)和Windows 表现基础WPF)的出现,微软继续在应用层网络服务上大量依赖 HTTP。

对于可能不知道的人来说,WCF 是微软为网络特定、面向服务的应用程序开发提供的库和框架套件。同时,WPF 是微软为异步用户界面代码范式提供的框架。它旨在为任何具有 UI 的桌面或 Web 应用程序提供相同丰富的一套功能和控件,以保持一致的外观和感觉。

SOAP 的出现

WCF 的大部分内容都是专门针对简单对象访问协议SOAP)服务的实现而量身定制的。乍一看,SOAP 似乎本身就是一个应用层协议。然而,在实践中,它实际上是一个存在于实际 OSI 网络栈之上的协议层。因此,虽然实现 SOAP 协议的应用程序确保了任何消费者都能获得可靠的行为,但它并不以任何必要的方式实现网络可用资源之间的交互,正如我们在第三章“通信协议”中讨论的那样。相反,它舒适地位于 OSI 栈之上,直接与应用层协议交互,以服务 SOAP 应用程序及其消费者所需的实际网络交互。因此,虽然基于 SOAP 的应用程序通过协议严格定义了它们的交互,但这些交互仍然最常通过 HTTP 网络事务来启动。

然而,这产生了一个有趣的副作用。虽然 SOAP 服务在传输协议方面严重依赖 HTTP,但它给新兴的面向消费者的应用程序和服务网络带来了几个缺点。SOAP 在其请求和响应消息结构中都非常冗长。由于其过度依赖 XML 进行序列化,它具有缓慢的传输和消息解析性能。最后,作为一个协议,其实现高度碎片化,导致在大量表面上基于 SOAP 的服务中,没有可靠的 SOAP 请求模式可供使用。

从 2007 年开始,随着第一台 iPhone 的发布,并持续到下一个十年,智能手机迅速成为面向消费者的专用应用平台的可行选择。尽管宽带互联网连接在全球范围内爆炸式增长,但服务于大多数进入市场的互联网连接设备的蜂窝网络,在性能和可靠性方面仍然落后几年。与此同时,SOAP 的所有缺点导致了工程师编写消费一个或多个基于 SOAP 的 Web 服务的应用程序时的性能低下和更长的开发周期。在整个行业中,工程师们意识到他们需要一个替代方案。数百万美元的利润在那里等着被赚取,但鉴于移动应用市场需要一夜之间进行改变,SOAP 几乎没有时间进行适应,甚至没有时间出现一个替代版本。

REST 的兴起

那么,市场空间中的工程师和领导者是如何适应的呢?他们回归基础,并寻求更稳健地利用底层协议——减少冗余,减少中间交互,以及减少序列化、解析和协商连接。相反,他们会使用 HTTP 作为交互协议,并利用其特性以实现更动态和稳健的交互,同时最小化编写定制、特定服务的访问协议所花费的时间。为了满足这些目标,全世界的工程师都会利用 REST。

REST(代表表示状态转移)被设计为一个架构模式,而不是一个访问协议。它是基于约定的,而不是基于合同的。这意味着任何熟悉这些约定的人都可以通过遵循这些约定来使用任何 RESTful Web 服务。这减少了工程师的开发时间,因为这意味着他们只需学习一次架构模式,然后就可以在 REST 所在的任何地方使用它们。这显然比必须消费和为单个 SOAP 服务开发合同,然后为每个新服务可能需要消费的每个新合同进行相同的操作要优越得多。

通过拒绝在 OSI 堆栈的应用层之上实现自定义协议,REST 减少了消息协商和解析的开销。由于 HTTP 远远超过其他应用层协议,成为 REST 服务的最常用协议,因此这些服务可以自由地以 HTTP 能够提供的任何格式提供其响应。这意味着不再严格依赖于 XML 作为首选的序列化语言。相反,JSON 已成为 RESTful Web 服务的快速序列化和快速解析格式的首选。

纯 HTTP 的改进性能和增加的灵活性意味着任何旨在由移动应用程序消费的 API 或服务几乎都是 REST 服务。因此,实际上,REST 在 HTTP 的基础上崛起,取代了 SOAP,成为现代网络交互的首选 Web 服务范式。

.NET 核心中的 HTTP Web 服务

随着基于约定的 REST 范式的广泛应用,微软的 WCF 迅速被遗弃。取而代之的是 ASP.NET 的 Web API 模板,现在则是 ASP.NET Core。利用 Gang of Four 在其书《设计模式:可复用面向对象软件元素》中首次正式描述的图案,ASP.NET 发布了模型-视图-控制器MVC)应用程序模板和库,以允许直接使用基于约定的干净软件模式。

对于那些不熟悉它的人来说,MVC 设计模式描述了一种将复杂、用户交互软件的逻辑责任隔离到逻辑分组和组织结构中的策略。视图层正是其名称所暗示的:任何将被发送到客户端(通常是用户选择的 Web 浏览器)的 UI 代码或标记。模型层描述了数据模型以及如何从应用程序使用的任何持久化机制中访问它们。它通常包括数据访问代码以及发送到或从应用程序发送的数据模型。最后,控制器层描述了将模型与用户有用的上下文联系在一起的业务应用逻辑,并最终将此上下文作为视图返回给用户。它充当其他两个层之间的中介。

当 ASP.NET 团队想要定义一个更新的、基于 REST 的、具有完整功能的 Web 应用程序模式,以摆脱 SOAP 的冗余时,其成员转向了 MVC。MVC 项目模板将每个可用的公共资源(在早期 MVC 的情况下,这几乎总是网页或网页的一部分)与特定 URI 上的特定 HTTP 动词关联起来。这简化了对资源的访问,因为开发者只需要知道目标地址以及如何对该地址发出通用的 HTTP 请求。它还根据访问该资源所需的 HTTP 动词传达了关于资源性质的大量信息。我们将在本章后面看到更多关于这一点的内容,但将语义结构与操作使用相关联是处理新 Web 服务时的巨大捷径。

从 MVC 到 Web API

最初,MVC 是为了设计具有完整功能的 Web 应用程序而设计的,该应用程序提供用户可以与之交互的 UI,以便与后端服务进行交互。然而,项目模板与 REST 范式的紧密匹配程度使其成为没有 UI 的 Web 服务的流行选择。在 2010 年代初,.NET 开发者通常从 MVC 模板开始,然后丢弃任何前端 UI 代码,而是允许他们的控制器组件返回原始 JSON 响应。不用说,微软很快就意识到了这一趋势,并随着 Visual Studio 项目模板库的更新发布了 Web API 项目模板。

使用 Web API 模板,开发者只需在.NET Core CLI 中输入两个简单的命令,就可以快速搭建一个基本的、RESTful 的 Web 服务。控制器端点监听指定的 HTTP 请求方法,并返回任意响应,不对任何对应的 UI 组件做出假设。几乎在.NET 中实现的每个现代 REST API 都是从这个项目模板开始的。为此,我们将在本章的剩余部分从 Web API 项目的角度探讨 HTTP,该项目将监听并响应传入的 HTTP 请求,同时向外部 API 发送出站请求。在现代企业级 Web 开发中,编写中间聚合 API 的模式非常常见,尤其是在微服务和云托管应用程序日益增长的趋势中。考虑到这一点,让我们搭建我们的应用程序,并开始探索.NET 如何使 HTTP 编程变得愉快。

HTTP 的多种方法

虽然我们早在第三章“通信协议”中花了一些时间来探索这个概念,但在本节中,我们将更深入地研究 HTTP 的操作模式:方法。我们将从我们的 Web API 应用程序的上下文中查看每一个,并讨论它们的预期用例、限制以及那些方法你应该遵循的约定。不过,为了做到这一点,我们需要我们的应用程序运行起来。因此,让我们首先看看.NET CLI 创建的 Web API 项目是如何发生的。

创建 Web API 项目

就像我们在第八章“套接字和端口”中为我们的示例应用所做的那样,我们将使用以下命令创建一个 Web API 项目的实例:

dotnet new webapi -n FitnessApp

这个名字可能看起来有些奇怪,所以让我们描述一下我们应用程序的基本预期功能,这样可能就会更加清晰。我们将编写一个 API,允许用户跟踪随时间变化的健身活动,以及一个几乎完全相同的 API,它将作为我们的数据源。这将给我们一个机会看到如何监听我们自己的应用程序发出的不同 HTTP 请求,同时给我们足够的上下文来生成对出站服务的 HTTP 请求。我们的数据存储应用程序的实际形状和功能几乎与面向用户的 API 相同。唯一的区别是,当我们的FitnessApp需要持久化数据时,它将通过向我们的FitnessDataStore应用程序发出 HTTP 调用来实现。同时,当我们的FitnessDataStore应用程序需要存储数据时,它将通过写入磁盘上的文件来实现。

在本章中,我们将只处理FitnessApp的代码,因为这将封装我们想要了解的所有交互。不过,本章的示例代码中包含了后端数据服务,所以如果你感兴趣,可以自由地浏览、扩展和修改它。此外,由于我们的重点将放在这个应用程序的 HTTP 交互上,并且它仅用于演示目的,我们将对数据建模、持久化和错误处理做出许多天真假设。相反,我将把这些考虑留给你自己思考,并在你自己的时间里重新评估。

我们的应用程序将允许消费者创建带有标题的新锻炼;锻炼类型和评论;通过标题、锻炼类型或评论内容查找之前的锻炼;检索所有之前的锻炼列表;编辑现有锻炼的评论;最后,它将允许用户从他们的历史记录中删除之前的锻炼。正如我之前所说,这些操作都是专门设计来突出 HTTP 的一些方面,正如.NET Core Web API 所实现的那样,因此这些操作的实现细节将相对简单和直观,甚至可能完全被忽略。这里重要的是要理解预期的 I/O 以及如何将这些操作建模为适当的 HTTP 方法。考虑到这一点,让我们看看我们为自己创建的项目。

Web 服务器

查看我们的解决方案资源管理器,你会注意到这个项目模板并没有太多内容:

图片

只有两个配置.json文件,一个控制器类,然后是初始化和程序文件。在这个时候,对于.NET Core Web 开发的新手来说,可能会想知道为什么我们既有Program.cs文件又有Startup.cs文件。这是因为,像所有.NET Core 应用程序一样,我们的 Web API 项目实际上是一个在目标机器上运行的dotnet宿主应用程序上下文中执行的控制台应用程序。因此,我们的Program.cs提供了传统的Main()方法作为我们的dotnet执行上下文的入口点来启动我们的应用程序。

然而,由于我们正在运行一个活跃的 Web 应用程序,我们希望启动一个监听 Web 服务器,并为其提供应用程序的上下文,以便它能够适当地响应请求。这就是Startup.cs文件的作用所在。它提供了所有配置,包括注册我们的具体类型以进行依赖注入,以及定义我们打算利用的活跃功能和服务。一旦我们定义了所有这些,Startup类就会被提供给我们的 Web 服务器实例,并用于配置服务器。

IWebHostBuilder

查看我们的Program.cs文件,你可以看到这正是发生的事情:Main()方法只构建我们的 Web 宿主,并启动它运行,没有终止条件:

public class Program
{
    public static void Main(string[] args)
    {
        CreateWebHostBuilder(args).Build().Run();
    }

    public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
            .UseStartup<Startup>();
}

WebHost类是Microsoft.AspNetCore命名空间的一部分,它的默认实现提供了一个运行的 Kestrel Web 服务器,该服务器通过Startup.cs文件与你的应用程序代码交互,该文件被提供给IWebHostBuilder实例,该实例返回到你的Main()方法以由你的程序运行。

默认情况下,通过调用 CreateDefaultBuilder() 创建的 Web 服务器将是一个 Kestrel Web 服务器的实例。Kestrel 是一个跨平台的服务器,它将获取指定的端口以便监听针对该端口的传入请求,并将所有接收到的请求传递到您的应用程序代码。它支持 HTTP/HTTPS、WebSockets、Unix 套接字和 HTTP/2,所有这些功能都是开箱即用的。在现代 .NET Core 应用程序中,很少需要使用除默认 Kestrel 服务器之外的其他服务器。它可以作为边缘服务器运行,这意味着它是针对您的应用程序的任何传入请求的第一个接触点(在主机机器的边缘或边界处监听)。同样,它也可以在反向代理后面运行,例如 Internet Information Services (IIS) 或 Nginx,如第八章 Sockets and Ports 中所述。

在反向代理后面运行 Kestrel 有许多优点。例如,反向代理允许您的 Kestrel 实例监听针对其他监听应用程序已注册的同一端口的请求,而将其作为边缘服务器运行则会阻止其他应用程序使用其注册的端口。这使得您的 Kestrel 实例能够处理针对其注册端口发出的每个传入请求,无论请求头中指定的目标 URI 路径或主机名如何。如果 IP 解析到您的应用程序的主机机器,并且端口是 Kestrel 注册的端口,Kestrel 将提供服务。

如果您的应用程序是唯一部署和运行在发布到云托管平台的 Docker 容器中的软件,那么这种端口阻止行为可能非常合理。然而,如果它部署到托管数十甚至数百个其他 Web 服务的本地服务器上,那么管理端口注册、流量负载和其他资源或配置可能是不明智的,至少是不愉快的。在这种情况下,反向代理将是最可靠的部署解决方案。您需要确定使用反向代理或运行 Kestrel 作为边缘服务器的利弊,但无论您选择哪种方式,您都将拥有使您的决策生效的工具。

因此,当您想要定义应用程序代码的行为时,您在 Startup.cs 文件中这样做,而当您想要定义暴露应用程序代码的 Kestrel Web 服务器的行为时,您使用您的 IWebHostBuilder 来定义。现在让我们看看如何配置我们的服务器,然后深入到我们的应用程序代码的配置中。

首先,让我们设置我们希望应用程序监听的 URL。出于我们的目的,我们将直接控制我们机器上任何软件的整个托管上下文,因此我们将为了简单起见运行 Kestrel 作为边缘服务器。现在,为了将我们的服务器注册为在特定端口上监听,我们将使用在调用WebHost.CreateDefaultBuilder(args)时返回的IWebHostBuilder实例上提供的扩展方法。

当你看它时可能并不明显,但UseStartup<T>()方法实际上也是那些扩展方法之一。它只是碰巧非常常见,以至于 Web API 项目模板在创建新项目时为你预先配置了它。这是一件好事。Startup.cs中的方法为用户提供集中大量样板代码和动态注册其具体类以在运行时进行依赖注入的机会。如果你之前没有使用过依赖注入,你很快就会看到为什么这是一个巨大的生活质量提升,以及为什么这个小代码片段被默认模板包含。

我们将使用的第一个扩展方法是UseUrls(string[])方法,用于注册 Kestrel 实例将积极监听请求的 IP 地址和端口。为此,将CreateWebHostBuilder(string[] args)方法更改为以下内容:

public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
    WebHost.CreateDefaultBuilder(args)
        .UseUrls(new string[] { "http://[::]:80", "https://[::]:443", "http://[::]:65432", "https://[::]:65431" })
        .UseStartup<Startup>();

你会注意到我已经将我们的服务器注册为同时监听 HTTP 和 HTTPS 请求的多个端口。我主要做这件事是为了演示目的。通常情况下,你不会希望一个应用程序主动监听多个端口,但这也突出了UseUrls()方法将允许你注册并监听任意数量的可用端口。

如果你计划使用模拟的主机名 ping 你的 API,并将条目添加到你的hosts文件中,就像我们在第二章中做的那样,DNS 和资源定位,这段代码将不会工作。因为任何你创建的主机条目都将被明确映射到 IP 地址127.0.0.1,因此你必须明确配置 Kestrel 以监听该精确的 IP 地址。你可以查看FitnessDataStore示例代码以获取示例。

使用 launchSettings.json

或者,你可以使用launchSettings.json文件来定义应用程序的监听 URL。此文件允许你根据应用程序部署的具体环境指定行为。启动配置文件可以根据托管应用程序的 Web 服务器(IIS Express 或 Kestrel,当你首次使用 CLI 创建项目时,它被定义为项目名称下的一个配置文件)进行自定义。

当你使用 dotnet run 命令启动应用程序时,.NET 将查找 launchSettings.json 文件,然后搜索第一个 commandName 参数值为 "Project" 的配置文件,该配置文件将使用 Kestrel Web 服务器。在此配置文件内,你可以设置你打算由应用程序利用的环境变量。你定义的任何环境变量都将覆盖宿主系统上存在的环境变量的值。这允许你通过在相应的启动配置文件中设置它们来为不同的情况定义不同的环境变量值。

你也可以在你的启动配置文件中为你的 Web 服务器设置各种配置。如果你查看我们创建 FitnessApp 时创建的 launchSettings.json 文件,你会看到它有一个名为 FitnessApp 的配置文件,其 commandName 值为 Property,如下所示:

"FitnessApp": {
    "commandName": "Project",
    "launchBrowser": true,
    "launchUrl": "api/values",
    "applicationUrl": "https://localhost:5001;http://localhost:5000",
    "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
    }
}

请特别注意那里的 applicationUrl 属性,因为它将在我们第一次运行应用程序时立即出现。

现在,为了简化使用 Postman 的过程,我们将更改我们的服务器设置,使其不使用 HTTPS 重定向来处理 http:// URL 的入站请求。这仅仅防止我们不得不配置 Postman 来跟随重定向,并且让我们在编写代码和测试时观察到的行为之间有更直接的关联。要禁用此行为,只需导航到你的 Startup.cs 文件,并在文件底部附近找到并删除以下读取的行:

app.UseHttpsRedirection();

完成这些后,就是时候运行我们的应用程序了。只需使用命令提示符导航到你创建项目的文件夹,然后执行 dotnet run。一旦执行,你应该在你的终端中看到以下内容:

图片

你可能已经注意到,应用程序当前正在监听我们传递给 UseUrls() 方法的数组中指定的每个端口,但不在 launchSettings.json 配置文件中指定的任何端口上。

为了测试此代码,你首先需要防止 Postman 在你导航到 https:// URL 时检查它期望的 SSL 证书。为此,只需打开你的设置并禁用 SSL 证书验证,如下所示:

图片

我们将在第十三章 传输层安全性中更详细地探讨 SSL 证书以及这个特定的 Postman 设置意味着什么。不过,现在,简单地禁用这个设置并相应地继续操作就足够了。一旦完成,打开 Postman 并向以下每个 URL 发送 GET 请求:

http://localhost/api/values 
https://localhost/api/values
http://localhost:65432/api/values
https://localhost:65431/api/values

你应该会看到所有四个都返回以下 JSON:

[
    "value1",
    "value2"
]

你可能已经注意到,在那个列表中我们没有指定端口 80 或 443。这是因为,如第八章《套接字和端口》中讨论的那样,每个端口分别保留用于 HTTP 和 HTTPS。因此,在http://https://请求上不指定端口等同于指定 80 或 443。

同时,如果您尝试导航到launchSettings.json文件中配置的任一 URL,您将完全得不到任何响应。这是因为,虽然您在launchSettings.json文件中的启动配置文件中配置的设置将始终覆盖任何已定义的系统设置,但您在启动并运行 Kestrel 服务器时在应用程序代码中配置的设置将始终覆盖任何启动配置文件。在您的范围内较低的设置将始终覆盖在您的范围内较高的设置中配置的设置。

由于在应用程序代码中配置您的 Web 服务器将始终覆盖任何其他配置值,因此您应该只为始终希望以单一、特定方式行为的最重要的设置使用它。否则,应使用ASPNETCORE_*环境变量和启动配置文件。它们提供了高度的灵活性,同时维护成本较低。

现在我们已经配置了我们的 Web 服务器以监听对我们指定端口的请求,让我们使用我们的Startup.cs文件来设置我们的应用程序以发送自己的 HTTP 请求。

Startup.cs中注册依赖项

我们将在Startup.cs类中使用ConfigureServices(IServiceCollection services)方法来注册我们的特定类以进行依赖注入,从我们的IHttpClientFactory类实例开始。如果您以前从未使用过它,那么依赖注入是创建软件不同方面之间松散耦合的绝佳工具,这些方面可能会随着时间的推移而发生变化。本质上,每当一个类(ClassA)利用另一个类(ClassB)的属性或方法来执行其自身功能时,就会创建一个依赖项。我们可以这样说,ClassA依赖于ClassB。有几种方法可以解决这个依赖项。最简单的方法是在ClassA中使用ClassB的方法的地方直接创建ClassB的实例,如下所示:

public class ClassA {
    public string CreateStringWithoutClassB() { ... }
    public string CreateStringWithClassB() {
        var bInstance = new ClassB();
        var builder = new StringBuilder();
        builder.Append(CreateStringWithoutClassB());
        builder.Append(bInstance.GetSpecialString());
        return builder.ToString();
    }
}

但这会在开发过程中带来一系列问题。如果ClassB需要更改,那么我们必须在所有引用它的地方进行更改。我们拥有的显式依赖项越多,我们就需要更改的地方就越多。

相反,我们可以注入依赖项。为此,我们定义一个接口,用于 ClassBClassA 提供的有用功能,然后定义 ClassB 为该接口的实现者。在我们的例子中,这个 IClassB 接口需要定义一个具有签名 string GetSpecialString(); 的方法,仅此而已。接下来,我们可以说 ClassA 需要实现 IClassB 接口的东西。为了使这个依赖项清晰,而不要求具体的 ClassB 实例,我们定义了一个接受 IClassB 实现者的构造函数。这改变了我们原始的方法,变成了以下对 ClassA 的定义:

public class ClassA {
    private IClassB _classB;
    public ClassA(IClassB classBInstance) {
        _classB = classBInstance;
    }
    public string CreateStringWithoutClassB() { ... }
    public string CreateStringWithClassB() {
        var builder = new StringBuilder();
        builder.Append(CreateStringWithoutClassB());
        builder.Append(_classB.GetSpecialString());
        return builder.ToString();
    }
}

因此,现在,ClassA 类不再依赖于 ClassB 类;相反,我们可以说它依赖于 ClassB 类偶然提供的一些功能。但它并不关心它是如何获得这些功能的。相反,确定使用最佳具体类的工作落在调用代码上,该代码实例化 ClassA。任何使用 ClassA 的东西都必须确定 IClassB 的最佳具体实例,创建一个实例,然后将它注入到新创建的 ClassA 实例中。如果 IClassB 实现者将根据上下文或新的项目需求而改变,我们可以在不修改 ClassA 的情况下进行这些功能更改。这就是依赖注入的精髓!

当你在专业环境中使用依赖注入时,实际上还有很多内容。关于这个主题已经写了很多本书。而且,就像任何设计模式一样,你会发现关于它的意见和询问意见的工程师一样多,在某些圈子中,它可能是一个激烈辩论的主题。详细探讨依赖注入的许多细微差别远远超出了本章(甚至这本书)的范围。然而,我的希望是,这个解释将足够作为任何从未使用过它的读者的入门,使他们能够理解这个概念,足够理解如何在我们的 Startup.cs 文件中注册依赖项。

IHttpClientFactory

我们首先想要注册的服务将是我们的HttpClientFactory。这个类是一个极其有用的工厂类,它将提供HttpClient类的管理实例。在包装类为我们管理HttpClient实例之前,实际上在使用手动创建和销毁HttpClient实例的常见模式时,会出现一个非常痛苦且难以追踪的 bug。由于HttpClient类实现了IDisposable接口,许多开发者会在using(var client = new HttpClient())语句的上下文中实例化它。虽然这是几乎每个实现IDisposable接口的类的推荐模式,但在HttpClient的具体情况下,存在一个问题,即客户端实例未能释放其监听线程。这个 bug 导致了在高流量出站 HTTP 请求的应用程序中,线程饥饿的频繁和不一致。这是一个痛苦的问题,我们为此提供的解决方案是HttpClientFactory类。

使用这个类,我们可以请求HttpClient的实例,并相信它们将在线程池中正确分配资源,并在可能的情况下重用,以减少内存开销和性能问题。鉴于这一点,这似乎是我们开始在Startup.cs文件夹中注册依赖项的第一步。

首先,我们需要创建一个依赖于HttpClientFactory类的类。在你的项目文件夹根目录下,创建一个名为Services的新文件夹,并在该文件夹内创建一个名为FitnessDataStoreClient.cs的新类文件。这个类是我们最终用来将数据写回到我们的FitnessDataStore API 的类,这意味着这个类需要一个HttpClient类来发送这些请求。

一旦创建了文件,添加一个private readonlyIHttpClientFactory接口实例。然后为你的新类创建一个单构造函数,该构造函数接受一个IHttpClientFactory实例作为其唯一参数。最后,将参数实例分配给你的私有成员变量。完成这些操作后,你的文件应该看起来像这样:

namespace FitnessApp {
  public class FitnessDataStoreClient {
    private readonly IHttpClientFactory _httpFactory;

    public FitnessDataStoreClient(IHttpClientFactory factoryInstance) {
      _httpFactory = factoryInstance;
    }
  }
}

你可能想知道为什么你可以对一个标记为readonly的变量进行赋值。任何标记为readonly的类属性或成员变量只能在类的构造函数中写入,或者当它们被明确声明时。这种语言的小特性在依赖注入的情况下特别有用,因为它给开发者提供了注入可能需要保持不可变性的依赖项的机会。

既然我们已经建立了依赖关系,我们还需要在我们的 Startup.cs 文件中注册它的一个具体实例。对于这个特定的类,由于在许多网络应用程序中需要 HttpClient 类的需求非常普遍,实际上在 IServiceCollection 上有一个扩展方法来注册一个 IHttpClientFactory 实例以供注入。要使用其最基本的形式,我们只需在我们的文件中添加一行。在 ConfigureServices(IServiceCollection services) 方法中,只需插入以下行:

services.AddHttpClient();

就这样,当你运行你的应用程序时,你的 FitnessDataStoreClient 将能够访问一个有效的 HttpClientFactory 实例。

然而,这个实现相当基础。根据我们所写的,每次我们从工厂类请求 HttpClient 实例时,我们都需要配置它。这意味着定义基本 URL、我们可能想要应用的任何默认头信息以及其他细节。相反,我们可以使用 AddHttpClient() 方法的重载版本来创建一个命名的客户端。通过这样做,我们可以将一些客户端配置的样板代码集中到我们的 Startup.cs 文件中,然后当我们在应用程序代码中调用命名的客户端时,我们可以直接发送请求。使用命名的客户端还给我们提供了一个简单的方法来管理连接到多个不同数据源的需求。这样做不仅通过消除建立 HTTP 请求连接的一些样板代码使我们的代码更易于编写,而且还可以通过在相同的命名 HttpClient 实例的各种引用之间共享连接来提高性能。

为了演示这一点,我实际上将在我的主机文件中为 FitnessDataStore API 创建两个不同的别名。所以,尽管在幕后,所有对该 API 的请求都将发送到同一个 IP 地址,但从我们的 FitnessApp API 的角度来看,它看起来就像我们正在利用两个不同的 API,具有两个不同的 URL 和两个不同的命名 HttpClient 实例。要测试这段代码,请将以下行添加到您的 hosts 文件中,类似于我们在 第二章 中所做的那样,DNS 和资源定位

127.0.0.1 fitness.write.data.com
127.0.0.1 fitness.read.data.com

现在,我们可以为这些主机名中的每一个配置我们的 HttpClient 实例。使用命名的 HttpClientFactory 实例,我们可以指定我们将用来识别我们想要创建的特定实例的名称,以及为我们要命名客户端定义一些默认行为。现在,我们将我们的不同客户端称为 WRITERREADER。因此,修改我们的 ConfigureServices(IServiceCollection services) 方法如下:

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

    services.AddHttpClient("WRITER", c => {
        c.BaseAddress = new Uri("http://fitness.write.data.com:56789");
        c.DefaultRequestHeaders.Add("Accept", "application/json");
    });

    services.AddHttpClient("READER", c => {
        c.BaseAddress = new Uri("http://fitness.read.data.com:56789");
        c.DefaultRequestHeaders.Add("Accept", "application/json");
    });
}

虽然Accept头通常不是我们最关心的事情,但这应该足以展示你如何配置由命名客户端生成的所有请求的常见属性。如果你有一个常见的授权方案,你可以在Startup.cs类中一次性定义这些值,而无需担心更新使用这些密钥进行请求的代码中的身份验证或授权密钥。

由于使用相同名称注册的HttpClient实例必须使用该名称创建,因此通常最好将名称保存在一个中央配置文件中,然后在注册命名客户端的Startup.cs代码以及请求该客户端的应用程序代码中引用该文件中的值。尽可能避免使用魔法字符串。

现在我们已经注册了命名客户端,让我们设置我们的数据访问客户端以注册到依赖注入框架中,这样我们就可以开始使用我们的控制器类工作了。

Startup.cs中注册服务

在示例代码中,你可以找到一个简单的模型,它代表我们健身活动数据库中的记录。我将其放置在Models文件夹中,该文件夹位于project目录的根目录。这是 MVC/Web API 应用程序中的常见约定,但你可以根据自己的理解组织代码。这个类本身相对简单,代表了我在这节开头设定的项目要求中描述的所有字段:

namespace FitnessApp {
    public class FitnessRecord {
        public string title { get; set; }
        public string workoutType { get; set; }
        public string comments { get; set; }
        public DateTime workoutDate { get; set; }
    }
}

因此,现在,在我们的FitnessDataStoreClient类中,我们将想要定义我们期望能够在我们的数据存储上执行的操作。根据我们的规范,我们将想要能够查找所有当前记录,通过其唯一的title属性查找单个记录,通过workoutType查找记录,修改comments字段,以及通过给定的title删除记录。目前,让我们定义这些方法以返回模拟响应,或者只是抛出NotImplementedException()来满足我们的构建系统。我们将在本章后面查看格式化和生成请求时返回到实现:

public class FitnessDataStoreClient : IDataStoreClient{
  private readonly IHttpClientFactory _httpFactory;

  public FitnessDataStoreClient(IHttpClientFactory httpFactoryInstance) {
    _httpFactory = httpFactoryInstance;
  }

  public async Task<bool> WriteRecord(FitnessRecord newRecord) {
    return false;
  }

  public async Task<List<FitnessRecord>> GetAllRecords() {
    return new List<FitnessRecord>();
  }

  public async Task<List<FitnessRecord>> GetRecordsByWorkoutType(string workoutType) {
    return new List<FitnessRecord>();
  }

  public async Task<FitnessRecord> GetRecordByTitle(string title) {
    return new FitnessRecord();
  }

  public async Task<bool> UpdateRecord(string title, string newComment) {
    return true;
  }

  public async Task<bool> DeleteRecord(string title) {
    return true;
  }
}

虽然我们已经定义了实现,但我们仍然需要定义我们将用于将类注册到控制器中的依赖注入的接口。我们已经声明了FitnessDataStoreClient类以实现IDataStoreClient,因此我们将创建用于依赖注入的接口名称。因此,无论是在当前文件的顶部还是在同一文件夹中的新文件中,添加以下接口定义:

public interface IDataStoreClient {
  Task<bool> WriteRecord(FitnessRecord newRecord);
  Task<List<FitnessRecord>> GetAllRecords();
  Task<List<FitnessRecord>> GetRecordsByWorkoutType(string workoutType);
  Task<FitnessRecord> GetRecordByTitle(string title);
  Task<bool> UpdateRecord(string title, string newComment);
  Task<bool> DeleteRecord(string title);
}

由于我们的数据存储客户端将完全是无状态的(也就是说,没有可能在类的实例之间变化的实例属性),我们可以在Startup.cs文件中安全地将它注册为单例实例。因此,从ConfigureServices(...)方法内部,添加以下行:

services.AddSingleton<IDataStoreClient, FitnessDataStoreClient>();

这个特定的辅助方法将允许我们的依赖注入容器在第一次创建类时创建一个具体类的单例实例。依赖注入容器随后将为所有后续请求的实例提供一个对该单例实例的引用。WebHost将把对该单例实例的引用注入到请求实现IDataStoreClient接口的每个其他类中,用于依赖注入。如果你需要为请求你的 API 处理程序创建多个实例,你可以使用services.AddScoped<Interface, Implementation>()这个方法的变体,因为这允许创建具有仅存在于返回给定请求响应所需时间内的状态的新的实例。或者,如果你需要在应用程序的任何地方注入实例时都需要一个新的实例,无论范围如何,你可以使用services.AddTransient<Interface, Implementation>()这个变体。这些变体中的每一个都将提供必要的控制反转,以实现依赖注入,而不需要在应用程序的生命周期内保持实现类的单个管理实例,就像AddSingleton()那样。

处理传入的 HTTP 方法

现在我们已经定义并注册了服务类,用于在Startup.cs文件中调用,现在是时候在我们的控制器中利用它并开始响应传入的 HTTP 请求了。不过,首先我们需要修改我们的控制器类,使其对我们的目的更有用。首先,将类名改为FitnessController,并移除初始项目模板提供的所有方法占位符。在类定义的顶部,你会看到定义了两个数据属性,如下所示:

    [Route("api/[controller]")]
    [ApiController]

这些为你的WebServer提供了关于此类文件性质的上下文。第一个指定你的WebServer应该响应所有对 URI 路径api/[controller]的请求,通过在这个特定类中查找有效的方 法。现在,这里的[controller]是一个特殊的占位符,它将被你的控制器类名(类名前缀为controller的部分)替换,所以当我们把我们的类文件名改为FitnessController并运行应用程序时,我们应该通过导航到http://localhost/api/fitness开始看到有效的响应。

第二个DataAttribute[ApiController]方法,如果我们想让我们的控制器按预期行为,实际上非常重要。在类型定义的顶部使用该属性告诉我们的WebHostBuilder使用这个特定的类作为控制器,并探索其方法签名以发现服务器应该公开的端点。它还通知我们的WebServer执行自动模型绑定验证。这意味着每当有请求针对控制器的一个端点时,端点签名中指定的输入应与请求消息的形状相匹配。通过指定我们的控制器为ApiControllerWebServer将确保在调用我们控制器的任何方法之前,请求消息与我们的预期输入相匹配。

名称更改和删除方法后,我们需要设置我们的控制器以使用注册的IDataStoreClient实现实例。因此,让我们创建一个私有成员变量,并使用我们的控制器构造函数注入我们的实例:

public class FitnessController : ControllerBase {
    private readonly IDataStoreClient _dataStore;

    public FitnessController(IDataStoreClient data) {
        _dataStore = data;
    }
    ...
}

现在,以IDataStoreClient操作为指导,我们可以开始实现我们的控制器端点。我们的每个端点都将演示不同的 HTTP 方法,因此让我们考虑这些方法的使用方式以及它们如何影响我们端点的实现。

GET方法

我们将首先实现一个监听器的最基本的方法是GET方法。此方法用于访问特定端点的资源,并且传统上,GET请求不携带内容,因为特定的查找约束通常作为 URL 路径的段或作为 URL 查询参数的一部分发送。如果你选择这样做,这取决于你(或你项目的规范)来强制执行这些规范。值得注意的是,对于大多数工程师来说,以及由 C#实现的HttpClient方法,假设这些规范将被活动的 HTTP 服务遵循。

关于GET方法的一个其他重要注意事项是,它通常被认为既安全幂等。如果一个 HTTP 方法被认为是安全的,那么该方法的请求不应影响请求的资源的状态。因此,如果我使用简单的GET请求从一个服务器请求一个名字列表,服务器应该仍然将其存储在其数据库中。服务器在服务我的请求之后应该与在服务我的请求之前完全相同。同时,如果一个操作不是安全的,这意味着在处理该不安全方法的请求后,服务器上存储的信息状态将不同。始终被认为是安全的唯一 HTTP 方法是OPTIONSHEADGET

如果你之前从未见过“幂等”这个词,不要担心;它并不复杂。对于一个方法或操作被认为是幂等的,你应该能够多次执行该操作,而不会从第一次执行它以来得到不同的结果。所以,如果我请求一个带有其 ID 的记录,我不在乎我请求该记录多少次,我应该总是得到与第一次请求时相同的响应。因此,自然地,GET被认为是一个幂等的 HTTP 方法。

因此,现在我们了解了应该如何处理GET方法(也就是说,安全地,并以使GET请求成为幂等的方式),我们如何配置我们的控制器来响应GET请求呢?好吧,如果你阅读了我告诉你在删除它之前要删除的源代码,你已经知道答案了。但对于那些不知道的人来说,我们使用[HttpGet]方法属性将我们的方法指定为GET请求的处理程序。所以,让我们看看所有的read操作,并在我们的控制器中使用IDataStoreClient实现它们的方法:

[HttpGet]
public async Task<ActionResult<IEnumerable<FitnessRecord>>> Get() {
  return await _dataStore.GetAllRecords();
}

[HttpGet("{title}")]
public async Task<ActionResult<FitnessRecord>> GetRecord(string title) {
  return await _dataStore.GetRecordByTitle(title);
}

[HttpGet("type/{type}")]
public async Task<ActionResult<IEnumerable<FitnessRecord>>> GetRecordsByType(string type) {
  return await _dataStore.GetRecordsByWorkoutType(type);
}

注意,该属性接受一个可选的字符串参数,指定了注解方法打算响应的更具体的 URL 路径。然而,当使用该属性而不带可选参数时,由[HttpGet]属性注解的方法将简单地响应针对由父Controller类处理的任何 URL 路径发出的任何[HttpGet]请求。所以,在这种情况下,我们的Get()方法将响应针对api/fitness路径的任何GET请求。同时,针对api/fitness/type/{type}的请求将返回所有workoutType字段包含{type}参数值的记录。

我还应该在这里指出(正如你可能已经猜到的),在路由规范中的花括号语法被用作动态路径变量的占位符。更重要的是,你总是可以安全地引用在路由内部花括号中使用的任何变量,作为实现方法的参数,并且网络服务器将正确地将请求 URL 路径中使用的任何值映射到你的方法调用。

你可能也注意到,我们每个方法都有一个返回类型为ActionResult<T>(忽略异步方法所需的Task<T>外部类型)。这是ControllerBase应用于我们方法返回值的自定义包装器。它将为我们在方法中返回的任何结果提供适当的 HTTP 状态码和响应头,并且它完全在幕后完成。

POST 方法

因此,在我们的 GET 请求得到处理后,让我们看看 POST 请求是什么,以及如何使用我们的应用程序来处理它。POST 方法通常用于客户端需要向监听服务器发送内容负载的情况,通常用于存储或作为服务器负责的某些计算或处理的输入。虽然在某些情况下,一些特定的 POST 端点可能既安全又幂等(因为,再次强调,这取决于每个开发者是否遵守 HTTP 和 RESTful API 开发的约定),但在一般情况下,POST 作为一种方法应该假定既不安全也不幂等。

POST 请求与 GET 请求的主要区别是 POST 请求关联的负载。因此,让我们看看我们如何访问和解析该负载,以便我们的 Web API 进行处理:

[HttpPost]
public async Task<IActionResult> NewRecord([FromBody] FitnessRecord newRecord) {
    if (await _dataStore.WriteRecord(newRecord)) {
        return Ok("new record successfully written");
    }
    return StatusCode(400);
}

在这里,我们正在使用方法唯一的 [FromBody] 参数属性,并指定我们期望从请求的内容体中接收 FitnessRecord 类的实例。此属性将使用请求的 Content-Type 标头来确定如何解析传入消息的内容体,并尝试将消息反序列化为指定的类型。因此,这里它期望消息以 FitnessRecord 类型的格式。如果消息体无法反序列化为预期的类型,Web API 将抛出一个适当的 4XX 状态码响应,指示发送了一个不良请求。

我在这里主要使用 [FromBody] 属性来演示,作为一种展示 [From*] 属性的方法。有趣的是,尽管如此,由于我们在类定义的顶部使用了 [ApiController] 属性,我们实际上已经获得了这种输入验证的好处,而无需在只有一个类指定为 POST 方法的输入时指定 [FromBody] 属性。

你可以应用多个 [From*] 属性到传入的消息中,以将你的形式参数变量映射到传入请求的某个部分。以下是一些例子:

  • [FromBody]

  • [FromForm]

  • [FromQuery]

  • [FromRoute]

  • [FromHeader]

每个这些参数绑定都将尝试从目标位置检索你的形式参数的值,并且如果可能的话,将尝试将特定的消息组件反序列化为你为形式参数声明的简单类型。

此外,还有一个 [FromServices] 属性。与其他属性不同,它实际上并不尝试从你的传入消息的任何部分解析指定的参数。相反,这允许你从注册的服务中检索依赖注入的接口的实例,并将其分配给仅限于该单个方法的变量,而不是分配给在构造函数中赋值的类成员变量。

我们使用这种方法引入的另一个有趣的概念是使用BaseController类的内置方法发送通用 HTTP 响应。我们两个可能的返回值由Ok()StatusCode()方法确定。这些方法中的每一个都会为所需的StatusCode格式化一个HttpResponse对象,其中Ok()显然返回200代码,而StatusCode()方法返回一个带有你指定的状态码作为参数的响应。

PUTPATCH方法

接下来,让我们看看那些允许我们对服务器上已经存在的记录应用更新的 HTTP 方法。这可以通过PUTPATCH方法中的任何一个来完成。既然我已经将它们描述为更新服务器状态,那么很明显,这两种方法都不被认为是安全的。然而,PUT方法应该是幂等的(如果正确实现)。我们将稍后看到原因,但PATCH通常不被认为是幂等的。两者之间的区别很微妙,并且它们通常以可互换的方式实现,所以让我们学习它们是如何工作的,并考虑哪个最适合我们的更新操作。

PUT方法被实现为 HTTP 标准的一部分时,客户端应提供该最小有效负载的完整实例以及该负载的目标目的地。如果在该目标目的地存在记录,则该负载被视为对该记录的更新,并且整个现有记录将被负载覆盖。同时,如果在该目标目的地没有记录,则负载将被插入,就像POST方法一样,在目标 URI 处。因此,现在,希望你能看到为什么这个操作是幂等的。使用单个给定的负载,无论我们执行PUT方法的次数多少;它总会将目标位置的记录设置为相同负载的值。第一次操作成功后,后续执行不再产生影响。

一些更敏锐的读者可能已经识别出PUT操作固有的风险。具体来说,除非你有你打算更新的记录的最新版本,否则你冒着用PUT覆盖你未知的记录最近更改的风险。因此,在应用任何PUT更新之前,通常明智的做法是在目标记录上执行GET操作,以确保不会意外地撤销最近更新。这引入了性能损失,表现为额外的往返服务器,以及可能的一个资源锁定机制,以确保在GETPUT之间没有人更新你的目标记录。

另一方面,PATCH方法仅要求将请求负载中描述的更改集应用于由请求 URI 标识的实体。这意味着我们的负载可以描述对目标资源上的特定属性进行更改,而无需在每次PATCH请求中发送记录的新、完全更新的状态。例如,一个格式正确的PATCH请求可以有一个负载,如下所示描述您的更改:

{ "operation": "update", "property": "comments", "new_value": "new comments to use for record" }

因此,当调用操作时,您的 API 将拥有更新您希望更新的相关字段所需的信息。然而,请注意,补丁的定义足够模糊,您可以在每个后续请求中描述一组应用新内容并更改服务器状态的更改。例如,想象以下描述:

{ "operation": "append", "property": "comments", "suffix": "... and a longer string" }

如果此操作将后缀值附加到目标属性上,那么具有相同负载的每个后续PATCH操作都会导致comments字段越来越长。由于这种由负载描述的可能更改集范围很广,因此假设PATCH操作不是幂等的。

现在,从技术上讲,由互联网工程任务组IETF)制定的描述PATCH操作(RFC 5789)的标准规定,PATCH请求的负载包含一组指令,说明如何修改当前位于原始服务器上的资源以生成新版本。这似乎表明负载实际上明确描述了要在目标资源上执行的操作序列,而不是仅用要更新的字段表示部分对象状态。根据这种解释,以下PATCH负载可能被视为非标准或不正确:

{ "comments": "New comment to update the record with" }

事实上,有许多纯粹主义者坚持认为,使用PATCH仅发送部分对象结构是明确错误地使用标准中定义的方法。然而,我会争辩说,包含部分对象结构的负载本身就是对应用资源的一组更改的高度精简描述。我实际上会提出这样的实现并不违反标准的意图或文字。具体来说,RFC 5789 声明的意图是提供PUT操作未提供的部分更新功能,而部分对象结构正好做到了这一点。事实上,甚至还有一个新的标准出来指定了确切的负载结构:RFC 7936。

当你决定如何实现PATCH时,我鼓励你阅读其他人关于如何编写的意见,以便满足标准,然后自己决定你是否应该采取更纯粹的方法,或者允许部分对象结构。我已经解释了我接受部分对象方法的原因,因此我们将按照这种方式在我们的 API 中实现我们的评论更新方法:

[HttpPatch("{title}/comments")]
public async Task<IActionResult> UpdateComments(string title, [FromBody] string newComments) {
  if (await _dataStore.UpdateRecord(title, newComments)) {
    return Ok("record successfully updated");
  }
  return StatusCode(400);
}

这里,由于我们只允许用户更新他们的评论,我们将使用 REST 范式,通过我们的路径来描述我们想要与之交互的系统中最具体的部分。因此,我们的路径和方法指定了我们想要在给定标题的记录中PATCH评论的值。从语义上讲,这传达了我们需要了解的所有关于这个操作的信息,并且我会断言,在 HTTP 操作的标准内,这实现了 RESTful 设计的所有目标。

DELETE 方法

最后,我们将探讨 HTTP 中描述的最直观的方法之一,即DELETE方法。向目标资源发送DELETE请求将正好做到这一点:它将从服务器删除资源,并用空值替换它。由于这个方法更新了服务器上的状态,所以这个方法是不安全的。然而,由于你不能删除已经删除的记录,所以这个操作被认为是幂等的。

一个DELETE请求可能会收到一个包含被删除对象描述、操作的成功或失败信息的响应,但通常,在成功删除的情况下,只会发送一个 2XX 状态码的消息。这个方法听起来很简单,所以让我们在我们的控制器中实现它:

[HttpDelete("{title}")]
public async Task<IActionResult> Delete(string title) {
    if (await _dataStore.DeleteRecord(title)) {
        return Ok("record successfully deleted");
    }
    return StatusCode(400);
}

正如你所期望的,该方法被定义为DELETE请求的有效处理程序,使用[HttpDelete("{title}")]属性,并且仅返回200400状态码,以及一个成功消息。

就这样,我们已经编写了一个服务,该服务将监听并正确响应所有有效的 HTTP 方法,对于这些方法,你将需要编写自定义软件。因此,现在我们能够处理传入的 HTTP 请求后,让我们来看看如何构建和发送我们自己的出站请求。

HTTP 请求格式

由于这是大多数现代软件项目的一个如此常见的方面,因此生成 HTTP 请求被.NET Core 标准彻底简化,使用起来绝对轻松。我们将使用HttpClient类来发送与我们在控制器类中定义的FitnessApp API 完美匹配的出站请求,因此理解预期的路径和输入应该是一项直接的任务。所以,考虑到这一点,让我们打开我们的FitnessDataStoreClient类并开始生成请求。

创建 HttpClient 实例

我们将从 API 中的简单读取操作开始,但为了做到这一点,我们需要启动HttpClient类的实例以便我们使用它来生成我们的请求。为此,我们将想要请求我们为读取操作注册的类的实例。这就像用最初注册READER实例时使用的相同密钥调用CreateClient()一样简单。因此,在我们的GetAllRecords()方法中,只需在方法开始处添加以下行即可:

var client = _httpFactory.CreateClient("READER");

现在,我们的客户端变量包含了一个已经配置了我们在Startup.cs文件中设置的BaseAddressDefaultHeaders属性的HttpClient实例。

如果你需要创建一个没有预先注册的HttpClient类(假设你需要它来访问在运行时才知道值的 URL),你可以通过简单地使CreateClient()调用的参数为空来实现。然后,你将负责为你的请求设置BaseAddress属性以及任何必要的头信息在你的新创建的客户端上,但这与我们在Startup.cs文件中所做的方式完全相同。

构建请求消息

现在我们有了我们的客户端,创建一个出站GET请求就像定义一个HttpRequestMessage实例并指定我们打算发送请求的方法和目标路径一样简单。由于GET请求传统上没有内容主体,所以最初的HttpRequestMessage定义就足够给我们的HttpClient进行传输了。如果你需要应用头信息,你可以使用HttpRequestMessage类的Headers属性来实现,但由于我们不需要,让我们只创建我们的消息,并通过我们的客户端发送它:

HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Get, "/api/fitness-data");

var response = await client.SendAsync(message);

就这样,我们有了来自服务器的响应,可以用来工作。在我们继续解析响应消息之前,我们想要确保我们的操作已经成功完成。一个常见的模式是将任何错误处理代码(为了简洁起见,我们在这里不会实现)包裹在一个条件语句中,该语句检查请求的成功。一旦我们确认了这种行为,我们就可以安全地尝试解析我们的response对象:

if (!response.IsSuccessStatusCode) {
  return new List<FitnessRecord>();
}

var json = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<List<FitnessRecord>>(json);
return result;

我们得到的response对象代表了来自服务器的整个 HTTP 响应,包括服务器发送的任何头信息、状态码,甚至是原始请求消息的内容。通常情况下,你只会关心StatusCode属性和Content属性。StatusCode属性实际上是一个枚举类型,它列出了所有有效的 HTTP 响应状态码,并且用于确定派生的IsSuccessStatusCode属性的结果。同时,Content属性是HttpContent类的一个实例,它包含了一个特定于内容的头信息数组,以及一些用于读取和解析内容主体的实用方法。

由于我们知道我们的请求输出将是健身记录列表的 JSON 表示,我们可以使用 JsonConvert 静态类来反序列化和返回响应内容。有了这个简单的模式,我们可以继续定义我们其余的 GET 操作。首先,我们有 GetAllRecords() 方法,它将简单地返回存储在我们数据源中的任何记录列表:

public async Task<List<FitnessRecord>> GetAllRecords() {
    var client = _httpFactory.CreateClient("READER");

    HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Get, "/api/fitness-data");

    var response = await client.SendAsync(message);

    if (!response.IsSuccessStatusCode) {
        return new List<FitnessRecord>();
    }

    var json = await response.Content.ReadAsStringAsync();
    var result = JsonConvert.DeserializeObject<List<FitnessRecord>>(json);
    return result;
}

接下来,我们拥有我们的 GetRecordsByWorkoutType() 方法,它允许用户根据健身记录的 workoutType 字段进行筛选:

public async Task<List<FitnessRecord>> GetRecordsByWorkoutType(string workoutType) {
    var client = _httpFactory.CreateClient("READER");

    HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Get, $"/api/fitness-data/type/{workoutType}");

    var response = await client.SendAsync(message);

    if (!response.IsSuccessStatusCode) {
        return new List<FitnessRecord>();
    }

    var json = await response.Content.ReadAsStringAsync();
    var result = JsonConvert.DeserializeObject<List<FitnessRecord>>(json);
    return result;
}

最后,我们将实现我们的 GetRecordByTitle() 方法,它允许我们通过(假设是)唯一的 title 搜索我们的健身记录:

public async Task<FitnessRecord> GetRecordByTitle(string title) {
    var client = _httpFactory.CreateClient("READER");

    HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Get, $"/api/fitness-data/{title}");

    var response = await client.SendAsync(message);

    if (!response.IsSuccessStatusCode) {
        return new FitnessRecord();
    }

    var json = await response.Content.ReadAsStringAsync();
    var result = JsonConvert.DeserializeObject<FitnessRecord>(json);
    return result;
}

使用这些方法中的每一个,你会发现我们实际上并没有在我们的数据服务类内部实现任何额外的过滤逻辑。相反,我们依赖于服务器来完成这项工作。我们引入的抽象是关于直接与服务器交互,而不是关于过滤记录的逻辑。因此,现在我们已经设置了 GET 操作,我们可以看看为了 POST 数据我们需要采取哪些额外步骤。

发布请求内容

由于 GET 请求和 POST 请求之间的主要区别是附加到 POST 请求的内容,让我们看看如何将我们的内容应用到我们的请求消息中。首先,我们将更改创建 HttpRequestMessage 实例时指定的方法:

 public async Task<bool> WriteRecord(FitnessRecord newRecord) {
  var client = _httpFactory.CreateClient("WRITER");

  HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Post, "/api/fitness-data");

然后,我们需要创建我们的 ContentHttpContent 类是一个抽象类,由各种子类实例化,每个子类实现了不同的有效 POST 内容格式。你可以定义 FormContent 用于 x-www-form-urlencoded 请求;MultiPartFormContent 用于大型消息、文件传输或以几个离散块传输的二进制数据;StreamContent 用于表示一个打开和活动的流连接;还有更多。然而,对于我们的目的,由于我们将序列化和反序列化 JSON,我们只需将有效载荷定义为 StringContent 的一个实例。

当你使用 StringContent 时,有几个覆盖实现允许你指定消息的特定字符编码和媒体类型。这被 HttpClient 类用来应用适当的 content-type 标头到请求中,这意味着在大多数情况下,你无需担心这个特定值。由于我们的字符串将是格式良好的 JSON,HttpClient 类将能够仅从字符串中推断出我们的媒体类型,因此应用 POST 主体是一个相当直接的任务:

var requestJson = JsonConvert.SerializeObject(newRecord);
message.Content = new StringContent(requestJson);

在我们的消息体就绪后,我们的代码其余部分与我们的 GET 请求完全相同,最终实现如下:

public async Task<bool> WriteRecord(FitnessRecord newRecord) {
    var client = _httpFactory.CreateClient("WRITER");

    HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Post, "/api/fitness-data");
    var requestJson = JsonConvert.SerializeObject(newRecord);
    message.Content = new StringContent(requestJson, Encoding.UTF8, "application/json");

    var response = await client.SendAsync(message);
    return response.IsSuccessStatusCode;
}

因此,有了这种模式,我们可以通过添加更新和删除记录的代码来完成我们的实现:

public async Task<bool> UpdateRecord(string title, string newComment) {
    var client = _httpFactory.CreateClient("WRITER");

    HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Patch, $"/api/fitness-data/{title}/comments");
    message.Content = new StringContent($"\"{newComment}\"", Encoding.UTF8, "application/json");

    var response = await client.SendAsync(message);
    return response.IsSuccessStatusCode;
}

public async Task<bool> DeleteRecord(string title) {
    var client = _httpFactory.CreateClient("WRITER");

    HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Delete, $"/api/fitness-data/{title}");

    var response = await client.SendAsync(message);
    return response.IsSuccessStatusCode;
}

将此应用程序与FitnessDataStore应用程序并排运行,你应该会看到我们初始项目描述中描述的所有预期行为。启动它们,打开你选择的 REST 客户端,并开始对每个端点进行操作。在你的代码中设置断点,并查看每一步发生的情况。就这样,我们已经涵盖了.NET Core 中 HTTP 的基本知识。

HTTPS – 在 HTTP 之上的安全

虽然第十三章中我们会更详细地讨论,但我们应该花点时间考虑如何在应用层建立安全连接。在 HTTP 协议中,安全是通过超文本传输协议安全HTTPS)实现的。这提供了一种机制来验证远程资源(如网页或 API 响应)的来源。HTTPS 还提供了保护在客户端和服务器之间每个请求/响应交互中传输的数据。这是通过利用底层的安全套接字层SSL)或更近期的传输层安全TLS)来实现的。

建立出站 HTTPS 连接

对于出站连接,使用 HTTPS 就像在请求 URL 中定义方案为https://...一样简单。虽然这可能看起来微不足道,但 HTTPS 连接的价值在于一个受信任并经过验证的服务器证书,通常由第三方签署,这给消费者带来了信心,即他们试图访问的网站确实是他们想要访问的网站。

在许多情况下,你可能会想在开发服务器上使用生产就绪的配置来测试你的应用程序。通常,在这些场景中,你不会从请求的证书颁发机构CA)那里获得一个受信任的、已签署的 SSL 证书用于该开发服务器。当这种情况发生时,你的调用代码将抛出一个错误,警告用户 HTTPS 连接无法通过受信任的 CA 签发的 SSL 证书进行验证。这是因为,在HttpClient类的幕后,有一个HttpClientHandler函数在检查由受信任的 CA 签发的 SSL 证书之前,会验证任何出站的 HTTPS 连接。这意味着,在你通过 HTTPS 连接到受信任和认证的资源的情况下,你不需要做任何额外的工作来确保安全性。与HttpClient的成功连接确保了安全性。

有一种方法可以通过自定义服务器证书验证器来覆盖这种行为,我们将在后续的章节中探讨,这些章节将重点介绍安全性。不过,一般来说,如果你从任何你直接不控制的远程资源收到那个错误,要重视它。这是.NET Core 免费提供给你的一个出色的警告系统,而且明智的做法是利用它。

在服务器上支持 HTTPS

这点对你们中的任何人都应该不会感到意外,但我们已经看到了如何支持我们的应用程序对传入的 HTTP 请求使用 HTTPS。就是那条我们不断移除的棘手的小代码行:

app.UseHttpsRedirection();

假设我们有这条代码行,并且我们的 Web 服务器配置为至少监听一个 HTTPS URL,我们的应用程序将支持 HTTPS。设置此值后,Web 服务器将对所有传入的 HTTP 连接尝试响应一个302状态码,这表示客户端的请求应该被重定向到 HTTPS URI 进行处理。

我为什么一直在移除这块特定的代码,并在我们所有的示例中使用 HTTP,是因为我在上一节中提到的内容,标题为建立出站 HTTPS 连接。尝试通过 HTTPS 连接到服务器将导致我们的软件(以及大多数如 Postman 或 Insomnia 之类的 REST 客户端)尝试验证 SSL 证书。然而,在我们开发过程中,我们的本地机器通常不会有用于 HTTPS 响应的签名证书。因此,通过移除UseHttpsRedirection()方法,我们只是在开发过程中从等式中移除了这个变量。然而,一旦你准备好将代码部署到生产环境,你将希望尽可能强制执行 HTTPS,我甚至会建议你配置服务器以仅监听 HTTPS URL。

HTTP/2

在 2015 年,互联网工程任务组IETF)推出了自 1997 年 HTTP 1.1 被编码以来 HTTP 标准的第一次重大修订。这个新协议,现在命名为 HTTP/2,在现有的 HTTP 1.1 协议定义之上引入了多项扩展功能,同时几乎完全保留了先前标准的预期行为。

HTTP/2 的新特性

最初由谷歌在SPDY(即“快速”)的代号下开发,HTTP/2 旨在在 HTTP 1.1 之上提供重大的速度提升。其目标是减少延迟以改善浏览器加载内容时的性能。这种延迟减少是通过以下方式实现的:

  • 头部压缩:默认情况下,所有传输头部都使用 gzip 或 DEFLATE 压缩机制进行压缩。基本协议/请求协商的减少的包大小和体积对高延迟网络连接(如蜂窝网络)有显著影响,从而提高了移动设备(如谷歌的 Android 手机)上的页面加载性能。

  • 请求多路复用:通过单个活动连接发送多个队列中的出站请求。这可以防止在内容丰富的页面上的 Web 资源出站请求瓶颈,以及当队列中的第一个请求挂起时发生的头阻塞,这阻止了后续的、可能更小、更快的请求的处理,从而解决问题。

  • 服务器推送: 这允许服务器通过之前建立的连接直接向客户端发送内容。例如,如果你知道每个网页请求很快就会被网页中引用的额外资源请求所跟随,那么这非常有用。而不是强迫客户端为这些额外资源发起额外的 HTTP 请求周期,服务器可以简单地直接推送数据,如果配置得当,客户端可以处理传入的数据,而无需处理完整的 HTTP 消息结构。

  • 请求优先级: 这使开发者能够确定哪些请求最有可能需要首先解决,并提供优先级以确保它们得到解决。类似于多路复用,它旨在减少首字节阻塞的影响,但无需完全支持多路复用传输机制。

.NET core 中的 HTTP/2

配置你的应用程序开始利用这些功能是在你的 WebHostBuilder 中进行的,因为 Web 服务器协商所有传入的 HTTP 请求并确定对各种协议的支持。然而,一旦完成,你的客户端将开始看到协议扩展的好处,而无需对你的代码进行任何额外的更改以支持它。

不幸的是,在撰写本文时,Kestrel 不支持一些功能,例如服务器推送和流优先级。因此,尽管客户端可以在其请求中发送优先级标签,但 Kestrel 将不会对此请求采取行动。另一个关于 HTTP/2 支持的注意事项是,你的托管环境的本地加密库必须支持应用层协议协商ALPN)以建立 HTTP/2 所需的加密连接。这意味着 HTTP2 仅支持部署到 Windows 环境或具有 OpenSSL 1.0.2 或更高版本的 Linux 主机的.NET Core 应用程序。幸运的是,如果你的环境不支持 HTTP/2,Kestrel 将默默地回退到使用标准的 HTTP 1.1 请求处理。这意味着你可以配置协议并将其部署到任何环境,而无需担心特定环境的 Kestrel 配置。

如果你想要支持 HTTP/2,只需在你的 WebHostBuilder 上使用 ConfigureKestrel() 方法,如下所示:

WebHost.CreateDefaultBuilder(args)
  .ConfigureKestrel(options => {
    options.Listen(IPAddress.Any, 8080, listenOptions => {
      listenOptions.Protocols = HttpProtocols.Http1AndHttp2;
      listenOptions.UseHttps("testcert.pfx", "testPassword")
    });
  })
  .UseStartup<Startup>();

就这样,你就可以获得多路复用、头部压缩和请求流的支持,无需对应用程序代码进行任何更改。我还将指出,Kestrel 对 HTTP/2 的支持正在路上,随着协议的成熟(在撰写本文时,它只是一个标准大约三年),预计将看到更广泛的应用,以及随之而来的性能提升。

摘要

本章我们涵盖了大量的内容,这是有充分理由的。HTTP 正如我在本章标题中所建议的,对于软件能够在网络上运行是至关重要的。我们更清晰地了解了应用层协议和传输层协议之间的区别。我们探讨了 HTTP 的历史,并看到了其设计如何使其用途远远超出其原始预期目的。

在有了这个背景知识之后,我们研究了 Web API 项目模板,并学习了.NET Core 如何利用跨平台的 Kestrel 网络服务器来暴露对传入请求的网络感知应用程序。我们研究了如何使用WebHostBuilder扩展类来配置我们的网络服务器。我们学习了如何使用Startup.cs类来配置我们的应用程序代码,以便在给定的托管环境中使用我们的网络服务器。然后我们花时间设置我们的应用程序,以便利用 ASP.NET Core 的依赖注入框架来为我们的服务类和实用工具类提供支持。

在我们的应用程序连接好并准备就绪后,我们探讨了如何将控制器端点暴露出来,以便在特定路由上监听每个特定的 HTTP 方法,以及我们响应这些请求的各种方式。然后我们研究了如何格式化我们自己的出站 HTTP 消息,包括构建内容主体、格式化和应用请求头,最后使用HttpClient发送我们的请求。最后,我们花了一些时间考虑如何允许使用 HTTPS 进行安全连接,以及 HTTP/2 规范的未来前景。有了这个视角,我们很好地定位了在下一章中探索其他应用层协议如何针对其特定用例进行调整,我们将深入探讨文件传输协议FTP)和简单邮件传输协议SMTP)。

问题

  1. HTTP 的定义是什么?

  2. SOAP 是什么意思?REST 是什么意思?

  3. SOAP 服务和 REST 服务之间有哪些主要区别?

  4. MVC 代表什么,它如何应用于 Web API 项目模板?

  5. Kestrel 是什么?它在 ASP.NET Core 中是如何使用的?

  6. HTTP 有哪些不同的方法?哪些是安全的?为什么它们是安全的?

  7. HTTPS 代表什么?它是如何提供安全性的?

  8. HTTP/2 支持哪些新功能?要利用它必须满足哪些要求?

进一步阅读

关于.NET Core 中的 HTTP 或使用 ASP.NET Core 的更多信息,您有丰富的资源可供选择。特别是,我推荐 Tamir Dresher、Amir Zuker 和 Shay Friedman 合著的《Hands-On Full-Stack Web Development with ASP.NET Core》,通过 Packt Publishing 提供:www.packtpub.com/web-development/hands-full-stack-web-development-aspnet-core

如果你想要深入了解启发 ASP.NET Core 开发者的模式和原则,我推荐阅读 Onur Gumus 和 Mugilan T S Ragupathi 所著的 ASP.NET Core 2 Fundamentals。这本书也可以在 Packt 购买,你可以在这里找到它:www.packtpub.com/web-development/aspnet-core-2-fundamentals

如果你更感兴趣于 MVC 设计模式的基础,以及 ASP.NET Core 如何利用它为网络应用提供干净的架构模板,请查看 Engin Polat 和 Stephane Belkheraz 所著的 ASP.NET Core MVC 2.0 Cookbook。这本书可以通过 Packt 出版公司找到:www.packtpub.com/application-development/aspnet-core-mvc-20-cookbook

第十章:FTP 和 SMTP

在本章中,我们通过关于 HTTP 的章节探讨了应用层协议的重磅内容,因此,审视一些不太常见的协议对我们来说将大有裨益。这正是本章我们将要探讨的内容。虽然 HTTP 是一种通用的工作马协议,但有许多原因可能促使你考虑在自己的软件中针对特定任务使用文件传输协议FTP)或简单邮件传输协议SMTP),或者这些协议可能在 .NET Core 的某些常见抽象之下被利用。因此,在本章中,我们将探讨这些原因,并学习在需要时如何实现这些协议。

本章将涵盖以下主题:

  • FTP 标准是如何定义的,以及 C# 在 .NET Core 中如何实现该协议

  • 理解确保文件传输安全的过程

  • 理解 SMTP 的本质及其所扮演的角色

技术要求

在本章中,我们将使用本书 GitHub 仓库中提供的示例应用程序:github.com/PacktPublishing/Hands-On-Network-Programming-with-CSharp-and-.NET-Core/tree/master/Chapter%2010

我们将主要使用控制台应用程序,因此您不需要为本章特别准备任何 REST 或 Web 客户端。

此外,在本章中,我们将编写一个客户端来与 FTP 服务器交互。为此,我们需要一个我们可以自行管理的 FTP 服务器。我将使用 FileZilla,并建议你也这样做。它轻量级、稳定且开源。您可以在以下位置找到它:filezilla-project.org/download.php?type=server。如果您的开发环境不支持 FileZilla,请不要担心。目标仅仅是拥有一个可供应用程序交互的服务器。演示应该足够简单,以便使用支持您操作系统的任何 FTP 服务器进行跟随。

查看以下视频,了解代码的实际应用:bit.ly/2HYmsHj

通过网络进行文件传输

关于 FTP 和文件传输的第一件事要注意的是,作为 开放系统互联OSI)堆栈上的应用层协议,其设计的主要关注点是优化特定常见业务任务在网络上的执行。然而,仅仅因为一项任务在某个协议上执行得最优化,并不意味着它必须通过该协议执行。实际上,理论上几乎任何在应用层执行的任务都可以通过任何应用层协议完成。那么,是什么使得 FTP 对我们这些工程师来说有用呢?

FTP 的意图

虽然 FTP 针对主机之间的文件传输进行了优化,但我敢打赌,这本书的每一位读者都曾将文件作为电子邮件附件在网络中传输过。同样的任务完成了,也就是说,文件从一个网络主机传输到另一个网络主机,但这是通过不同的应用层协议(SMTP 而不是 FTP)完成的。确实,HTTP 规范的多功能性使其能够远远超出其优化的任务(传输超文本文档)并成为网络通信的通用工作马。

事实上,有无数的应用程序直接通过 HTTP 传输文件。甚至有一个广为人知且广泛支持的媒体类型,专门用于与multipart/form-data进行此类交互。那么,使用 FTP 进行文件传输而不是其他更通用的应用层协议,我们能获得哪些优势呢?

建立在客户端-服务器模型架构之上,FTP 被设计和实现为利用两个独立的连接来建立目标文件系统的状态和传输文件。这些连接中的第一个被称为控制连接,它用于保存有关远程主机状态的详细信息,例如向 FTP 客户端公开的当前工作目录。同时,对于每次数据传输,使用控制连接维护的信息建立第二个数据连接。当数据连接建立并传输数据时,控制连接处于空闲状态。

在大多数实现中,建立控制连接可能有些麻烦,并且通常性能较慢。这是因为每个连接都需要多次往返,向远程主机发送各种命令以建立目标目录,协商任何身份验证,并确定和存储远程状态以供数据连接使用。这种高性能成本,以及控制连接的状态性,是使 FTP 不适合用于传输简单的、简短的超文本文档页面的原因,因此需要 HTTP。然而,这种状态连接以及它提供给客户端有关目录当前状态的信息对于 FTP 所支持的一些操作是必不可少的(例如检测远程主机上的文件存在或目录中所有文件的批量下载)。

激活和被动连接

一旦建立了控制连接,数据连接可以使用两种可能的模式之一。服务器可以建立激活连接,这是大多数 FTP 服务器的默认状态,或者被动连接。这些不同类型的连接具体指的是数据连接的建立和处理方式。在任何情况下,客户端都使用底层传输协议(通常是传输控制协议TCP))的消息来初始化控制连接。

在主动连接中,一旦控制连接建立并且可以开始数据传输,服务器将建立数据连接来通过线路传输文件。客户端在命令连接阶段传输信息,通知服务器哪个端口正在积极监听数据连接。然后服务器尝试在指定的端口上与客户端建立连接,该端口用于服务器推送文件数据。服务器被称为主动传输数据到客户端。

在被动连接中,客户端使用控制连接来通知服务器该连接应为被动模式。然后服务器响应一个 IP 地址和端口号,客户端可以据此建立连接。在那个时刻,客户端将建立数据连接到服务器的指定 IP 地址和端口。一旦这个连接建立,客户端就可以传输文件数据。

传输模式和数据处理

一旦实际数据连接被建立为主动或被动模式,并且文件传输准备就绪,就有多种方式来传输文件,使其在目标机器上可读。请记住,在 Unix 系统上编写的文本文件与在 Windows 机器上编写的文本文件将具有不同的字符编码或行终止符。如果你曾经打开过文本编辑器或源代码控制界面,并被提示规范化文件的行终止符,这就是原因。这是为了弥补不同原生环境之间细微差异的一种方式。

由于 FTP 是一种平台无关的网络传输协议,它必须考虑到不同系统上文件内容二进制表示可能存在的差异。为此,FTP 为通过连接传输的文件提供了三种不同的常见数据表示机制:

  • ASCII 模式:这种模式仅应用于文本文件。字符和字节在传输之前从源机器的本地字符表示转换为 8 位 ASCII 编码,然后再次从该 8 位 ASCII 编码转换为目标机器的本地字符表示。当然,如果任一机器的本地字符编码已经是 8 位 ASCII,那么在该机器和数据连接之间不需要进行转换。

  • 图像(或二进制)模式:在这种模式下,源机器上文件的底层二进制数据以未更改的状态,以顺序字节流的形式发送。目标机器随后在本地系统上的目标文件位置,逐字节存储该流,因为它从源接收数据包。

  • 本地模式:这是用于两台具有共享本地配置的计算机,以任何专有表示形式传输数据,而无需将其转换为 ASCII。它与图像模式并不完全不同,只是在专有格式允许的情况下,数据可以非顺序地传输。

然而,一旦你确定了表示数据传输的最佳方式,FTP 还将提供三种执行该传输的机制。它们如下:

  • 流模式:数据报以连续流的形式发送。这有几个性能优势,因为不需要在头部或元数据中封装离散的数据包来启用解析。字节简单地发送出去,直到源系统上的文件结束。

  • 块模式:这种机制将文件数据分成离散的应用层数据包,并一次传输一个数据包到源系统,然后源系统根据数据包元数据中的信息重建原始文件结构。

  • 压缩模式:这种模式简单地启用了一种简单的数据压缩算法,以最小化主机之间发送的数据总量。

暴露目录以供 FTP 使用

我在本节中描述的所有功能都是为了在两个远程主机之间高效传输文件而服务的特定目的。虽然显然该协议不适合像将网页传输到远程浏览器渲染这样的任务,但它确实在 HTTP 等其他应用协议上具有许多优势。这包括给工程师提供灵活性,以确定他们如何以及以何种格式传输大文件。

因此,既然我们已经了解了 FTP 的使用方式和原因,让我们看看我们如何在自己的程序中使用它。我们将使用.NET 库类编写一个简单的客户端。最终状态将是一个可以上传和下载文件,以及从远程主机请求目录信息的客户端。

这次我们的应用程序将是一个控制台应用程序,所以通过你选择的终端导航到你的工作目录,并创建一个新的项目:

dotnet new console -n FtpClientSample

接下来,你需要确保你的文件服务器在本地运行正常。当你安装软件时,你应该会看到一个窗口提示你选择一个端口,服务器将在这个端口上监听管理连接。

配置你的 FTP 服务器

如果您仔细阅读那个提示,您会注意到它非常明确地指出您选择的端口是用于对 FTP 服务器的管理连接,而不是活动 FTP 连接。这是因为(如果您记得回想起第八章中的保留端口部分,套接字和端口),已经配置了一个默认端口来监听传入的 FTP 命令连接。就像80端口是 HTTP 的默认监听端口一样,21端口是 FTP 命令连接的默认端口。这意味着,通常情况下,发送到没有指定端口的ftp://... URI 的连接将自动连接到21端口。

这也是为什么您在 FileZilla 上配置的端口被明确指定为仅用于管理。您并不是连接到 FTP 命令连接。您是连接到管理该 FTP 命令连接的 FileZilla 服务器。因此,考虑到这一点,启动服务器并将其配置为指向安装时指定的 localhost 和端口。

一旦应用程序开始运行,您将看到一个通知,表明传输层安全性TLS)的 FTP 未启用。尽管这条消息是用令人警觉的红色文字写的,但现在您可以忽略它。当我们稍后在后续章节中探讨 TLS 时,我们会进一步研究它。目前,缺少安全密钥只会让我们在尝试理解 FTP 时生活变得稍微容易一些。我们引入的变量越少,在接收新信息时理解起来就越容易。

最后,我们需要在我们的应用程序中注册一个用户到 FTP 管理 UI,并为该用户设置一个工作目录。为此,在 FileZilla 管理员控制台上的菜单中点击编辑 | 用户。然后导航到共享文件夹配置页面,以设置您希望允许特定用户访问的特定目录:

图片

为了演示的目的,我将创建一个名为s_burns的新用户,并授予该用户访问第九章《.NET 中的 HTTP》源代码目录的权限。请注意,我为 FTP 服务器创建的用户与我的本地机器用户名不同。当您编写 FTP 客户端以登录并访问远程目录时,您需要的是注册在 FTP 服务器软件上的用户凭据,而不是主机机的凭据。如果我要尝试使用我的操作系统用户名从我的本地服务器下载文件,即使密码正确,我也会收到一个未经授权的异常。用户凭据应该是注册在服务器本身的用户,通常情况下,除非您编写了一个允许开放和匿名连接的 FTP 服务器,否则您应该始终使用凭据进行连接。

FTP 服务器本身将拥有主机机器授予它的给定目录的权限。因此,如果你在一个系统身份有限制只读权限的进程中运行服务器,那么它能够提供给客户端的操作范围就是这些。然而,默认情况下,FileZilla 是以授予安装用户的相同权限安装的。由于我是本地管理员,FileZilla 在我的系统上安装时具有本地管理员权限。但是,仅仅因为授予了 FileZilla 这些权限,并不意味着任何使用有效凭据连接到服务器的客户端也会拥有本地管理员权限。这将是一个巨大的安全漏洞!相反,当为你的服务器注册新用户时,管理员(在这种情况下是你)为新用户授予在共享目录中写入、读取和删除文件的单独权限。为了本章的目的,只需为自己创建一个具有完全权限的新用户即可。

编写 FTP 客户端

现在我们将设置我们的应用程序以与正在运行的 FTP 服务器进行交互。你们中的一些人可能还记得第五章,在 C#中生成网络请求,我提到了WebRequest实用类中的FtpWebRequest/FtpWebResponse子类。如果你们记得,那么现在你们已经比我领先了。这些是.NET Core 应用程序与 FTP 服务器交互的主要机制,我们将利用它们来满足这个程序的需求。

获取目录列表

我们将请求FtpWebRequest类的实例,然后使用其方法来查看我们监听 FTP 服务器的目录信息。该实例将指向ftp://localhost,正如我提到的,由于ftp://方案的存在,默认端口为21,实际上不需要指定。就像 HTTP 有与服务器交互的方法一样,FTP 也有它自己的方法来确定如何与服务器交互。在代码中,这是通过一系列静态常量属性来设置的,这些属性由FtpWebRequest类使用,以确定如何启动所需的行为:

static async Task<string> GetDirectoryListing() {
  StringBuilder strBuilder = new StringBuilder();
  FtpWebRequest req = (FtpWebRequest)WebRequest.Create("ftp://localhost");
  req.Method = WebRequestMethods.Ftp.ListDirectoryDetails;
  ...
}

HTTP 方法或动词与可以为FtpWebRequest设置的.Method属性之间的区别在于它们在底层是如何操作的。正如我们在第九章《.NET 中的 HTTP》中看到的,HTTP 方法是在请求本身上指定为一个头部的。如果该方法对于指定的地址是允许的,那么请求的其余部分将立即解析和处理,并生成一个响应。然而,在 FTP 连接中,您在FtpWebRequest实例上设置的Method属性实际上是对通过 FTP 命令连接发送到服务器的命令序列的抽象。FtpWebRequest客户端实际上将通过其底层的 TCP 连接发送适当的命令,并且只有在整个交换过程中从服务器获得预期的响应时才会继续进行。

现在,在我们能够向服务器请求我们所需的信息之前,我们需要验证我们的应用程序作为服务器注册用户的代表在操作。为此,我们将使用有用的实用工具类NetworkCredential。这个类封装了用户名和密码的基本概念,并将其映射到进行认证网络请求所需的底层表示。因此,您无需担心您的认证机制是基本认证还是摘要认证,这个类只是让您从登录信息的角度来考虑它。只需使用在 FileZilla 管理应用程序中创建的 FTP 用户的登录凭证实例化一个新的实例,并将这些凭证应用到您的请求中:

req.Credentials = new NetworkCredential("s_burns", "test_password");

一旦我们登录,我们可以像在第五章《在 C#中生成网络请求》中做的那样与请求对象进行交互。我们将请求我们的请求的响应,然后使用StreamReader读取响应数据流,并将结果写入我们的输出以确认我们的预期结果:

using (FtpWebResponse resp = (FtpWebResponse)await req.GetResponseAsync()) {

  using (var respStream = resp.GetResponseStream()) {
    using (var reader = new StreamReader(respStream)) {
      strBuilder.Append(reader.ReadToEnd());

      strBuilder.Append($"Request returned status:  {resp.StatusDescription}");
    }
  }
}
return strBuilder.ToString();

现在只需在您的Main方法中等待对这个方法的调用,并观察结果。由于我在 FTP 服务器上注册的用户只有对第九章《.NET 中的 HTTP》源代码目录根部的权限,所以我运行应用程序时在我的控制台看到以下输出:

图片

如果您以前从未见过每行开头的字符序列,这些是定义您在目录列表中每个文件权限的代码。如果有字母存在,这意味着该文件夹的权限或状态为真。结构如下:

[directoryFlag][owner-set][group-set][other-set]

目录标志表示列出的条目本身是否是一个目录,可以导航到并且可以从它那里提取文件。如果条目开头有d,它是一个包含文件的文件夹,而不是文件本身。接下来是每种可能与之交互的用户类型的权限集。这些是三个字符的组合,表示该组中成员对列出的文件有哪些权限。按显示顺序,这些字符如下:

  • r:读

  • w:写

  • x:执行

因此,对于每一组,有三个字符要么被设置要么为空(在这里用-字符表示),表示该组是否有权限。

使用这个,我们可以看到,在我们的目录中,FitnessAppFitnessDataStore都是目录(它们的权限记录以d开头),并且它们对每个组的权限如下:

  • 所有者:读、写和执行权限

  • 组:读和执行权限

  • 其他:读和执行权限

同时,我们可以看到fitness_data.txt文件不是一个目录,该列表中每个组的权限如下:

  • 所有者:读和写权限

  • 组:只读访问

  • 其他:只读访问

在权限之后,您可以看到文件(在这种情况下,FTP)的当前所有者和组,然后是文件大小(对于目录为 0),最后修改日期和文件或子目录的名称。因此,我们可以看到我们的 FTP 服务器被列为我们的文件的所有者,因此它有rwx权限可以授予用户。

现在我们已经成功连接到我们的 FTP 服务器,并且能够访问我们凭据可以访问的目录信息,让我们回顾一下服务器对该请求的响应。如果您打开了 FileZilla 服务器管理控制台,您会看到我们的运行应用程序和服务器之间的所有交互都显示在服务器控制台中:

图片

注意,第一行表明 TCP 连接是在21端口上发起的,尽管我们在 URI 中从未指定该端口。以时间戳开始的这一行是命令连接的发起。从那里,控制台中的每一行蓝色都表示从我们的应用程序发送给服务器的信号,每一行绿色代表服务器的响应。从我们的应用程序发送的所有的四个大写字母单词和缩写都是 FTP 标准中的 FTP 命令,它们都是我们的应用程序代码发送的,我们甚至都没有意识到。这一切都是在我们的应用程序软件的底层发生的,仅仅因为我们设置了我们的请求方法为WebRequestMethods.Ftp.ListDirectoryDetails

文件传输

因此,现在我们了解了在底层如何通过命令连接到 FTP 服务器,以及它是如何允许我们打开数据连接的,让我们利用这一点来实际请求一个文件。我们将使用我们在第五章中看到的相同的请求处理结构,即[C#中生成网络请求]。到目前为止,这一切都应该感觉非常熟悉,但我将利用FtpWebRequest类的一些特定属性来突出一些你可以使用的选项。那么,让我们编写下载文件的函数。

我们首先要做的事情将是指定我们想要查找的文件的子目录路径。我们将使用 FitnessApp 中的Startup.cs文件,这样就可以很容易地确认我们已经正确地传输了它。然后,我们将请求的Method属性设置为WebRequestMethods.Ftp.DownloadFile的值。最后,我们将明确通知服务器以被动模式操作,这意味着我们的应用程序将在幕后在20(FTP 服务器数据连接的默认连接端口)上建立自己的连接到远程服务器,然后一旦连接打开,就请求该文件。因此,我们的下载方法的初始化代码将看起来像这样:

public static async Task<string> RequestFile() {
  StringBuilder strBuilder = new StringBuilder();
  FtpWebRequest req = (FtpWebRequest)WebRequest.Create("ftp://localhost/FitnessApp/Startup.cs");
  req.Method = WebRequestMethods.Ftp.DownloadFile;

  req.Credentials = new NetworkCredential("s_burns", "test_password");
  req.UsePassive = true;
  ...
  using(FtpWebResponse resp = (FtpWebResponse) await req.GetResponseAsync()) {
    ...
  }
}

现在,为了复制文件,我们将直接从响应流中读取,并将其写入到我们的目标文件的StreamWriter中。由于嵌套的作用域,这看起来可能很复杂,但所有那些制表符只是为了处理每个可处置的Stream对象及其相应的ReaderWriter辅助类:

using (var respStream = resp.GetResponseStream()) {
  strBuilder.Append(resp.StatusDescription);
  if(!File.Exists(@"../Copy_Startup.cs")) {
    using (var file = File.Create(@"../Copy_Startup.cs")) {
      //We only use this to create the file in the path if it doesn't exist.
    }
  }
  using (var respReader = new StreamReader(respStream)) {
    using (var fileWriter = File.OpenWrite(@"../Copy_Startup.cs")) {
      using (var strWriter = new StreamWriter(fileWriter)) {
        await strWriter.WriteAsync(respReader.ReadToEnd());
      }
    }
  }
}

return strBuilder.ToString();

现在,让我们在我们的Main方法中添加对这个方法的调用,它应该看起来像这样:

static async Task Main(string[] args) {
  Console.WriteLine(await GetDirectoryListing());
  Console.WriteLine(await RequestFile());
}

如果你从终端运行此项目,你的输出应该列出目录结构,并且当导航到项目的根目录时,我们应该找到我们的Copy_Startup.cs文件,看起来正好如我们所期望的那样!

通过 FTP 上传文件

为了完成这个客户端示例,让我们看看如何上传到服务器。记住,这是 FTP,而不是文件下载协议。文件传输可以双向进行。对于这个方法,我们将把我们的文件转换为字节流,以便在建立连接后与我们的请求一起上传。

我们还将把我们的WebRequest URI 指向新创建的文件。我们只是将当前项目的Program.cs文件复制到远程目录的根目录。那么,让我们看看初始化代码看起来像什么:

public static async Task<string> PushFile() {
    StringBuilder strBuilder = new StringBuilder();
    FtpWebRequest req = (FtpWebRequest)WebRequest.Create("ftp://localhost/Program.cs");
    req.Method = WebRequestMethods.Ftp.UploadFile;

    req.Credentials = new NetworkCredential("s_burns", "test_password");
    req.UsePassive = true;

到目前为止,一切顺利。现在我们需要创建一个字节数组并将我们的Program.cs文件写入其中。然后,将这个字节数组写入请求流。在你阅读了第六章后,流、线程和异步数据传输,这应该对你来说应该很熟悉:

byte[] fileBytes;

using (var reader = new StreamReader(@"Program.cs")) {
    fileBytes = Encoding.ASCII.GetBytes(reader.ReadToEnd());
}

req.ContentLength = fileBytes.Length;

using (var reqStream = await req.GetRequestStreamAsync()) {
    await reqStream.WriteAsync(fileBytes, 0, fileBytes.Length);
}

最后,为了实际传输上传我们的文件以及该文件内容的流,我们只需从服务器请求一个响应。

  using (FtpWebResponse resp = (FtpWebResponse)req.GetResponse()) {
        strBuilder.Append(resp.StatusDescription);
  }

  return strBuilder.ToString();
}

就这样,我们正在将文件上传到我们的远程 FTP 服务器。只需在我们的Main方法中添加一行,然后运行应用程序。如果您以与我相同的方式配置了您的服务器,现在您应该在我们的第九章的根目录中看到我们刚刚编写的程序的副本,即.NET 中的HTTP源代码,紧挨着FitnessAppFitnessDataStore项目文件夹。

保护 FTP 请求

虽然您可能怀疑向我们的服务器提供用户凭证意味着在文件访问中涉及了一定程度的安全性,但实际上我们是以完全不安全的方式与我们的服务器交互的。这些凭证只有在服务器收到它们并且我们的命令连接建立的那一刻才有价值。然而,在那之后,它们为我们传输的数据提供的保护并不比我们允许完全匿名访问我们的目录更多。因此,让我们看看为什么是这样,以及如何解决这个问题。

FTP 的风险

使用我们的示例软件,我们无需对安全性有任何真正的担忧,因为我们的数据永远不会通过实际的网络连接进行传输。我们的请求永远不会越过我们的hosts文件,因为我们总是指向localhost。然而,如果情况不是这样,我们需要与远程服务器进行身份验证,并且我们使用为这个演示设置的 FTP 标准连接这样做,我们就会遇到麻烦。我们用来登录服务器的凭证完全以纯文本形式发送。此外,最终建立的数据连接也是以完全未加密的方式通过电线发送的。

如果我们仅使用我们提供的服务器凭证尝试与远程服务器进行通信,我们就会面临各种不同恶意攻击的风险。想象一下一个简单的中间人攻击者读取包含个人身份信息PII)的文件的字节流,例如社会安全号码、医疗信息或银行账户详情。FTP 标准实际上并没有设计来考虑这种风险。更重要的是,由于文件传输机制是在命令连接之前协商的,实际的文件数据通常以不间断的字节流的形式发送,容易被恶意方重新组装和读取。只需按顺序接受底层的包流,移除标准的传输层头部,并将接收到的字节拼接起来。因此,在受保护的私有网络之外使用标准 FTP 连接通常是非常危险的。

使用 SFTP 和 FTPS 保护 FTP

虽然 FTP 固有的存在一些安全风险,但幸运的是,你可以采取一些方法来减轻这些风险的影响。我们将探讨的两个方法都旨在使恶意行为者干扰或读取传输中的文件内容变得非常困难。那么,它们是什么呢?

SFTP

我们的老朋友互联网工程任务组IETF)为文件传输交互设计了一个新标准,该标准利用了安全外壳协议SSH)进行身份验证和安全的隧道传输。被称为SSH 文件传输协议,或安全文件传输协议SFTP),它被构建为 SSH 的一个扩展,提供了之前不存在的文件传输能力。

此应用协议通过在两个主机之间建立安全隧道来提供安全性。一旦客户端主机被服务器主机认证(与上一节中我们示例中的简单服务器用户认证不同),所有数据都通过该隧道发送。这与简单地通过 VPN 传输文件并没有太大的不同。它只是使用不同的安全协议。然而,这种机制与其说是 FTP 的安全实现,不如说是 SSH 的扩展,以提供 FTP 功能。因此,在.NET Core 中,对它的支持几乎是默认的。

FTPS

虽然 SFTP 作为一个文件传输子系统被添加到 SSH 中,但加密通过开放连接(尚未在主机之间建立安全隧道)发送的流量的替代方法是用所谓的 FTPS。FTPS 是 FTP over SSL 的缩写,或 FTP Secure,FTPS 利用底层传输层的加密机制为在主机之间传输的数据提供加密。这与我们在第九章中探讨的 HTTPS 协议几乎完全相同。

在现代实现中,这将会使用 FTP 客户端正在使用的底层传输层协议的安全传输机制。今天,这意味着它通常利用 TLS,但历史上选择的加密机制一直是 SSL。因此,当你想要配置你的 FTP 客户端以利用 FTPS 时,你只需将你的FtpWebRequest对象的EnableSsl属性设置为 true。然后,如果你的服务器支持通过 TLS(或 SSL)的 FTP,你每次连接都会利用它。

虽然关于 SSL 和 TLS 及其提供的安全性的话题还有很多可以说的,但这将是后续章节的主题。所以,现在,只需遵循一个简单的规则:在可能的情况下,始终在你的代码中使用 FTPS。风险不值得。

SMTP 和 MIME

最后,我们将通过探讨可能是第二常见的协议(仅次于 HTTP)来结束我们对应用层协议的探索。尽管它如此普遍,但我猜受益于 SMTP 的令人震惊的人数甚至不知道它的存在。那么,它是什么呢?我们如何使用它,为什么我们要使用它?

邮件协议

简单邮件传输协议SMTP)最早在 1982 年定义,是传输电子消息的事实上的协议。它是一个面向连接的协议,使用我们在本书中已经非常熟悉的客户端-服务器架构。与 FTP 类似,SMTP 事务通过一系列命令和响应在专用的 SMTP 会话中传输。这些命令通知服务器消息的地址信息(电子邮件的“收件人”和“发件人”部分),传输消息本身,并最终确认消息的接收。

与 FTP 不同,这些交互都在与服务器相同的单一连接上发生。一旦与服务器建立连接(对于好奇的人来说,通常是在默认的 SMTP 端口25),该连接就构成了一个会话。一旦会话开始,就会接收并响应服务器发送的命令,直到消息的所有组件都已传输,会话结束。

关于 SMTP 的重要一点是,它完全是关于新消息的出站传输。该协议本身没有请求从服务器返回消息的机制。为用户提供邮箱以访问的应用程序是通过利用完全不同的消息协议来做到这一点的,例如互联网消息访问协议IMAP)或邮局协议POP)。然而,尽管这些协议对于更新手机邮件应用很有用,但应用程序仍然依赖于 SMTP 来传输您想要发送到远程地址的任何新消息。

这种仅限出站的属性以及软件实现各种不同协议以满足电子邮件应用用户期望的需要意味着,管理电子邮件接收和交付的整个过程可能是一个痛苦的过程。这通常涉及多个子系统协同工作,以确保可靠地处理交易的每一步。

使用 MIME 扩展 SMTP

由于在 1982 年就已经定义,严格按照标准实施时,SMTP 存在一些局限性。这包括有效字符编码的范围以及某些内容(如图像或声音)的替代表示,这些内容直接在电子邮件的消息正文中。为此,IETF 通过多用途互联网邮件扩展MIME)扩展了该协议。

MIME 为用户提供以下功能:非 ASCII 字符表示;音频、图像、视频和应用附件;多部分消息体;以及消息头中的附加上下文和元数据。有趣的是,这正是 SMTP 与 HTTP 重叠最多的地方。尽管 MIME 最初是作为 SMTP 扩展设计和实现的,但指定传入消息的字符编码和数据结构很快就被识别为其他传输文本内容的应用协议的有用功能。自然地,HTTP 很快就采用了这一功能。"MIME-type"是包含消息体的 HTTP 消息中ContentType头部的值名称,这正是 SMTP 使用扩展的方式。

希望这些 SMTP、FTP 和 HTTP 之间的共性能向您突出显示核心应用层协议实现之间有多么相似。

.NET Core 中的 SMTP

您可能有很多理由想在软件中集成 SMTP。也许您想为用户提供一种直接向开发团队发送电子邮件的反馈机制,并且您希望在幕后使用 SMTP 客户端来实现这一点。或者,也许您想将错误处理基础设施连接起来,以便在应用程序抛出不可接受的异常时向支持人员发送电子邮件。无论情况如何,您可能需要一个客户端来构建这些邮件消息并将它们发送到目标收件人。

尽管您可以使用针对此类问题的解决方案,但目前这些解决方案并非由微软或.NET Core 框架提供。虽然.NET Framework 中包含一个SmtpClient类,该类与MailMessage类一起使用,可以生成并发送自动电子邮件到有效的 SMTP 服务器,但这些类并未包含在.NET Core 1.0 版本的规范中。它们确实包含在最初的.NET Core 2.0 版本中,但几乎一出现,微软就因为对协议现代特性的支持不足而将其弃用。更甚者,在它们的弃用说明中,他们明确表示弃用这些库是因为它们“设计不佳”。我理解为“设计得不够好,无法随着协议的发展而成长。”

因此,现在,微软建议使用一个名为 MailKit 的第三方库,其源代码控制和文档可以在以下链接找到:github.com/jstedfast/MailKit

因此,尽管 SMTP 支持强大的消息生成,但交互的性质是这样的,我们不会在这里涵盖它们。相反,我将仅允许这作为一个例子,说明为什么你总是在将过去使用的任何库纳入新项目之前检查其状态。即使它们在你上次使用时很出色,进步的步伐可能在你能够在新项目中使用它们之前使它们过时和弃用。有了这一点,我们可以从应用层协议转移到查看使其成为可能的所有可能的底层传输层。

摘要

在本章中,我们覆盖了今天仍在使用的某些最常见应用层协议的很多内容。我们探讨了不同的协议是如何设计和优化的,以执行不同的特定任务。然后我们具体探讨了 FTP 是如何优化以执行其远程文件传输和目录查找任务的。

我们学习了 FTP 如何使用两个独立的连接在客户端之间进行通信。我们看到了命令连接是如何设置的,以初始化文件传输、协商身份验证以及确定后续数据连接的机制。我们探讨了 FTP 可以格式化文件进行数据传输的多种方式,以及 FTP 连接可以配置的各种传输技术。我们还利用 FTP 服务器管理控制台观察了当我们在使用.NET FtpWebRequestFtpWebResponse类的高级抽象时,我们的应用程序和远程服务器之间正在进行的底层交互。

一旦我们能够以编程方式与我们的服务器交互,我们就研究了 FTP 需要考虑的安全问题,并大致了解了这是如何完成的。

我们通过查看另一个常见的应用层协议 SMTP 来结束本章。我们将其实现与 FTP 和 HTTP 进行了比较和对比,以了解它做得好的地方,然后探讨了在.NET Core 平台上随着.NET 库的弃用和第三方开源解决方案的认可,其未来的情况。随着这个主题结束我们对 OSI 堆栈应用层的探索,我们终于可以深入探讨使这一切成为可能的底层软件组件。在下一章中,我们将最终探讨使 HTTP、FTP 和 SMTP 成为可能的第一层传输层协议。

问题

  1. FTP 和 HTTP 之间的主要区别是什么?

  2. FTP 连接有两个阶段吗?它们是如何工作的?

  3. FTP 中的数据传输有两种模式,它们之间有什么不同?何时应该使用其中一种而不是另一种?

  4. FTP 数据传输有三种模式吗?

  5. FTP 中编码传输中数据的三种方式是什么?何时应该使用它们?

  6. SMTP 和 MIME 的定义是什么?它们之间有何关联?

  7. SMTP 与 HTTP 或 FTP 有何区别?

进一步阅读

如果你感兴趣于了解其他人如何使用 FTP 和 SMTP 库的一些方法,你可以查阅由 Fiqri Ismail* 编著的 .NET Standard* 2.0 Cookbook,*。其中有一章专门介绍网络应用程序,包括 SMTP 实现。你可以在这里找到它: www.packtpub.com/application-development/net-standard-20-cookbook.

第十一章:传输层 - TCP 和 UDP

在前面的章节中,我们研究了不同应用层协议的交互以及如何在.NET Core 中编程这些交互。在本章中,我们将更接近硬件,开始研究传输层协议,即传输控制协议TCP)和用户数据报协议UDP)。我们将查看每个协议实现的基于连接和无连接通信模式,并查看每种方法固有的优点和缺点。此外,我们将研究如何编写和交互实现每个协议的软件客户端,并使用它来扩展我们的网络应用程序的功能,添加自定义行为。最后,我们将查看传输层协议的一些高级功能,例如多播,以同时与多个主机交互,从而提高我们网络软件的性能。

本章将涵盖以下主题:

  • 哪些责任被委派给了传输层,以及这个层如何与应用层、HTTP/SMTP/FTP 有意义的区别

  • 基于连接和无连接协议之间的区别以及它们试图解决的问题

  • 如何初始化 TCP 连接以及发送和接收 TCP 请求

  • 如何在 C#中建立并利用 UDP 通信

  • 如何利用多播来提高我们的 TCP 客户端的性能

技术要求

我们将在这里使用书中 GitHub 仓库中可用的示例应用程序:github.com/PacktPublishing/Hands-On-Network-Programming-with-CSharp-and-.NET-Core/tree/master/Chapter%2011.

查看以下视频以查看代码的实际运行情况:bit.ly/2HY61eo

我们还将继续使用我们在第八章中使用的工具,即第八章,套接字和端口。具体来说,如果你还没有安装,我建议从以下链接安装 Postman:www.getpostman.com/apps 或者你可以安装 Insomnia REST 客户端,可以在以下链接找到:insomnia.rest/

传输层

当我们开始检查传输层的复杂性时,记住传输层协议与应用层协议之间最基本的一个区别是很重要的;具体来说,就是每一层所关心的交互类型。应用层的协议关注的是业务对象之间的通信。它们应该只处理你应用程序领域实体的高级表示,以及这些实体如何通过你的系统移动。

同时,在传输层协议中,关注的是原子网络数据包,这些数据包用于传输与上下文无关的数据包,以及建立和协商连接。

传输层的目标

在我们迄今为止所检查的所有应用层协议中,我们都能对所传输的网络请求做出一些宽泛的假设。我们只是假设,只要我们的统一资源标识符URI)是正确的,并且远程主机是活跃的,我们就能与目标系统建立连接。此外,我们可以假设所建立的连接是可靠的,并且我们发送的任何请求都将完整地以可由远程主机监听应用程序读取的方式传递。我们可以舒适地假设,如果在传输过程中发生错误,我们会获得足够的信息来识别错误的性质并尝试纠正它。

让我们回顾一下,这些假设是否也可以应用于传输层。

建立连接

在传输层,我们不能对现有连接做出假设,因为这是连接最初建立的层。传输层协议是暴露本地机器上的特定端口并协商将数据包传递到远程机器指定端口的协议。如果该连接需要在交互期间保持会话,那么负责维护该会话状态的是传输层协议(我们将在探讨基于连接的通信时了解更多关于这一点)。

确保可靠性

当涉及到可靠性,即网络数据包的稳定和一致交付以及响应数据包的接受时,这是传输层的职责。如果一个会话由于两个主机之间通信链路的断裂而中断,传输层协议负责尝试重新建立连接并基于其先前状态恢复网络会话。保证数据包成功交付的传输协议必须承担与远程主机传输层通信的责任,以验证应用层数据是否成功接收。

这对于将开放连接视为串行数据流的软件层尤为重要。 incoming data being processed in order 的概念要求它可以按顺序读取。这意味着传输层必须有一些机制来确保网络数据包的可靠、顺序交付。

错误纠正

这种可靠性也是传输层另一个职责的关键:错误纠正。这包括能够修复由于交互中网络层的复杂或中断引起的数据接收差异。而且,不要误解,网络数据包内容受到干扰、操纵或丢失的机会有很多。传输层负责减轻这些可能性,并在数据损坏的情况下重新请求新的数据包。这种数据校正通常通过简单的校验和值来完成,它可以提供一个可靠的指标,表明正在对传输中的数据进行更改。

错误处理也应该存在,以确保数据包的可靠顺序。因为物理网络基础设施可以,并且经常这样做,将来自单个主机的多个请求通过多个可用的网络连接,以及通过多个不同的交换机路由到另一个主机,因此,在流中发送较晚的数据包先于较早发送的数据包到达并不罕见。传输层必须有一种方法来识别这种情况何时发生,并且能够重新请求丢失的数据包或重新排列接收到的数据包以恢复其正确的顺序。

管理流量

起初可能并不明显,但当我们谈论在给定的机器上可用的数千个端口用于监听时,这些数千个端口仅存在于虚拟上。显然,你的 PC 主板并没有插入 65,536 根线。这些端口只是你的(通常只有一个)网络适配器将流量路由到当前正在操作系统上运行的适当进程的一种方式。所有传入和传出的网络流量都必须通过这个单一的网络适配器。

虽然网络层软件负责管理直接流量控制,但它通常只通过为传输层提供在短暂的上时间段内访问物理连接的方式来这样做。传输层软件的职责是管理一个待处理的传入数据队列,以及一个传出请求队列,并在资源可用时将它们交付给网络层。这种使用有限、间歇性可用的资源的方法,如果做得好,可以大幅提升性能,如果实施不当,则可能成为性能瓶颈。

数据分段

正如我在第三章“通信协议”中讨论这个主题时提到的,用于在应用层封装数据的庞大、连续的对象不适合在网络中传输。如果你试图在传输 20 MB 文件或 13 GB 文件期间阻塞你的网络适配器,那么对机器上任何其他依赖网络的软件性能的影响将是绝对不可接受的。这样做会阻塞任何传入或传出的请求,时间过长。

虽然应用层协议可以在所有请求中发送大量有效载荷并假设它们会被正确交付,但传输层的数据包却不能这么说。在传输层和网络适配器之间没有其他中介,因此传输层的责任是将大型请求有效载荷分解成更小、离散的网络数据包,以便在网络层传输。这意味着传输层协议还承担了应用层有效载荷分解数据包的附加责任,以便接收机器能够重建这些数据包,无论交付顺序如何,通常通过数据包头部完成。

在像 C#这样的高级语言中,这通常不是你自己实现的内容,但了解幕后发生的事情将使得理解诸如数据包嗅探和网络跟踪等概念变得更加容易。

传输层协议的类别

尽管我们刚刚讨论了传输层协议可能承担的许多责任,但并非每个传输层的协议都实现了这些功能中的每一个。由于了解给定实现中可用的可选功能很重要,因此标准组织根据它们实现的特性定义了一个连接模式协议的分类系统。根据这个分类方案,存在五种不同的连接模式(或传输层)协议类别,每个类别实现传输协议可能实现的服务列表的不同组合。

定义传输协议不同实现类的分类方案实际上是标准组织的一项联合努力。国际标准化组织ISO)和国际电信联盟ITU)共同发布了 X.224 建议,用于此特定目的。

分类列表从零索引,从类0到类4,它们的描述如下。

简单类

这被描述为提供最简单的传输连接类型,具有足够的数据分段。标准中明确指出,它仅适用于具有可接受残余错误率和可接受信号错误率的网络连接。

基本恢复类

属于类 1 的协议被指定提供具有最小开销的基本传输连接。然而,将类 1 与类 0 区分开来的是,类 1 协议预计可以从信号错误中恢复,或立即可检测到的错误,例如网络断开或网络重置。此类协议适用于具有可接受残余错误率的网络,但信号错误率不可接受。

2 – 复用类

2 协议的标志性特征是它们能够在单个网络连接上复用多个传输连接。它旨在与类 0 协议一样,在极其可靠的网络环境中工作。由于可能利用单个传输层协议上的多个网络连接,此类分类中的协议最终可能会利用显式的流量控制来优化网络层资源的利用。然而,这种显式的流量控制并不是类 2 协议的保证属性。实际上,在不需要复用的情况下可能会避免使用它,因为不显式管理流量控制可以减少传输中的数据包开销。

3 – 错误恢复和复用类

复用类本质上是由类 12 组合而成的。类 3 的协议将类 2 复用功能(或数据包开销)的性能优势引入到具有足够错误恢复能力的协议中,适用于信号错误率较低且类 1 可能更受欢迎的网络。

4 – 检测错误和恢复类

4 协议是所有协议中最稳健的。它们明确指出,适用于具有不可接受的残余错误率和信号错误率(即,基本上是任何具有高干扰或服务中断概率的大规模分布式网络)的网络。鉴于类 4 协议适用于在如此不可靠的网络中使用,因此预期类 4 协议能够检测并从网络错误中恢复。类 4 协议应提供恢复的错误包括但不限于以下内容:

  • 数据包丢失

  • 数据流中数据包的顺序错误

  • 数据包重复

  • 数据包损坏

4 的协议还预期提供对网络故障的最高程度弹性,以及通过改进的复用和分段提高的吞吐量。不用说,这也是在每次网络事务中引入每数据包开销最高的传输层协议类别。

有趣的是,协议的分类仅决定了你应该期望该协议实现的最低服务集。这并不排除该协议实现比其分类指定的更广泛的服务集。TCP 就是这种情况,它实际上提供了一些额外的服务,这些服务可能在网络堆栈更高层的软件中提供更严格的实现。

4 捕获了 TCP/IP,这被广泛认为是目前广泛使用的最稳健(或者至少是最复杂/复杂的)传输层协议,以及 UDP,它在广泛支持和采用方面是 TCP 的无连接对等。这些是你在使用 C# 工作时将直接交互的传输层协议类别。为此,让我们来看看 TCP 和 UDP 之间可能的最大区别:它们的基于连接和无连接通信模式。通过这样做,我们将对何时以及如何利用每个协议有一个更好的了解。

基于连接和无连接的通信

在 C# 中,我们将使用两种主要的传输层协议。第一个是 TCP。由于它在基于互联网的网络软件中的普遍使用以及与互联网协议IP)的紧密耦合,通常被称为 TCP/IP,TCP 是我们迄今为止查看的所有应用层协议背后的传输层协议。我们将查看的第二种协议是 UDP。它作为 TCP 在传输层实现方面的替代方法,旨在在更严格的用例中提供更好的性能。

然而,这两种协议的主要区别在于,TCP 在所谓的基于连接的通信模式下运行,而 UDP 在所谓的无连接通信模式下运行。那么,这些通信模式究竟是什么?

基于连接的通信

初看之下,基于连接的通信可能看起来很明显。从其名称来看,你可能会得出结论,它只是利用两个主机之间连接的任何通信。但当我们说“连接”时,我们究竟指的是什么?它不能仅仅是两个主机之间的一些物理路径。毕竟,根据那个定义,两个主机如果不能以某种方式连接,它们如何进行通信?如果不在连接上,数据如何在两台机器之间传输?

当你考虑到无连接通信是一种有效的通信模式时,这种定义的不足之处变得更加明显。考虑到这一点,很明显,在这个上下文中,“连接”必须指的不仅仅是两个主机之间用于数据传输的简单通道。那么,究竟什么是连接?基于连接的通信模式是如何利用它的?

建立会话的连接

为了清晰和易于理解,我认为我们将连接视为一个会话会很有帮助。在面向连接的协议中,在执行任何有意义的操作之前,必须在两个主机之间建立会话。这个会话必须通过两个主机之间的握手来协商,并且应该协调待传输数据传输的性质。会话允许两个主机确定在请求的生命周期内,两台机器之间是否需要发生任何协调,以确保可靠地完成请求。

一旦建立了会话,就可以实现基于连接的通信机制的好处。这包括保证数据按顺序交付,以及可靠地重传丢失的数据包。这是因为会话上下文为两台机器提供了一个交互机制,允许它们在消息被发送和接收时进行通信。这种共享的、活跃的上下文对于我们理解基于连接的协议非常重要,所以让我们看看底层网络层是如何提供会话上下文的。

电路交换与分组交换连接

为想要建立连接的两个主机提供会话有两种方式。第一种是通过主机之间的直接硬件电路链路来建立会话。这就是所谓的电路交换连接。数据不需要在头部应用路由信息,因为它在两个设备之间的闭合电路中传输。这种物理电路连接是公共电话网络建立连接的方式。如果你曾经见过老照片中电话操作员使用四分之一英寸的电缆连接巨大电路板上的两个不同端口,你就已经看到了这种精确的路由机制在起作用(尽管是一种非常原始的实现)。

在两个主机之间建立专有的、直接的物理连接有很多好处。它保证了所有数据包将在恒定的时间内到达,因为没有中间路由器和交换机需要解析数据包的寻址信息,或者等待数据通道的空闲。它还保证了数据包的顺序,因为每个数据包将正好在下一个传输的数据包之前通过相同的通道传输。

当然,这种基于连接的通信方式的缺点是,其实施成本非常高。必须在网络上的每个可能交点处有一个机制,以建立任何两个其他连接之间的专用电路,而不会干扰可能通过该交点的其他连接。另外,在连接建立和关闭时,管理连接和断开特定电路所需的机械交换成本也非常高。因此,这类物理网络在几十年来都没有在计算网络中得到广泛应用。

另一种方法是所谓的分组交换连接。这些连接是通过在路由设备上使用硬件交换机和部署的软件来虚拟化电路交换连接的行为来建立的。在连接模式下,路由器和交换机设置一个内存中的电路,管理所有针对目标位置的入站请求队列。这些设备解析每个入站数据包的寻址信息,并相应地将其传递到队列中,然后一旦物理资源可用,就按顺序从队列中转发消息。通过这样做,保持了物理电路交换连接的行为预期。因此,对于任何编写以利用基于连接的通信方案的软件,电路交换连接和分组交换连接之间没有功能上的区别。

通过这种虚拟化,物理层面上实现电路交换连接功能的开销得到了缓解。当然,通过减轻电路交换设置的物理成本,我们在性能成本上做出了补偿。在分组交换连接中,每个连接都会增加额外的开销,因为每个数据包必须由两个主机之间网络路径上的每个交换机进行解析。此外,除非给定网络交换机上没有其他流量,否则每次将与此连接相关的数据包放入队列等待物理资源可用时,分组交换连接无疑将出现停机时间。然而,由于这些操作大多在固件级别实现,任何给定连接的总时间成本实际上相当小。这种分组交换网络的模型几乎描述了所有现代广域网WAN),包括互联网。

作为面向连接的协议,TCP

建立连接是 TCP 实现为利用它的应用层软件提供的重要功能之一。当 TCP 层将请求分解成数据包并应用其头部信息时,它是基于假设这些数据包将通过分组交换网络发送的。

这意味着网络路径上的交换机和路由器必须为两个主机之间的每个有效载荷预先配置一个虚拟电路。这种确定性是通过两个主机之间多步骤握手来提供的。握手的具体细节稍微复杂一些,但可以简化为过程中每一步的三个基本事务:

  1. SYN:这代表同步,是客户端发送给服务器的请求,表明希望建立连接。同步发生是因为客户端生成一个随机整数,n,并将其作为序列号传输在 SYN 请求中,服务器使用这个序列号来确认接收到了适当的消息。

  2. SYN-ACK:这代表同步和确认,是服务器对初始 SYN 请求的响应。为了确认请求是以发送时的相同状态接收的,服务器增加并返回它从客户端接收到的随机同步整数,n+1,作为确认消息。它还发送它自己的随机整数,m,作为序列号。

  3. ACK:在这个时候,客户端确认其同步请求已被正确发送和接收,并通过发送一个设置序列号为从服务器接收到的确认值,n+1,然后增加并返回服务器的序列号作为它自己的确认值,m+1,来确认服务器的同步请求也被正确接收。

一旦这些三个信号已经发送并相应接收,连接就已经建立,数据传输可以相应进行。

在数据传输之前,两个主机之间达成这种协议是 TCP 能够实现其与 UDP 区别开来的弹性的原因。由于主机知道要期待一个有序的包序列,它可以在包到达后重新排列那些顺序错误的包,以确保它们以适当的顺序传递给期待它们的上层协议。此外,如果它没有收到它所期待的包,它可以基于缺失的序列号识别出缺失的包,并请求重新传输丢失的部分。最后,这种连接的初始化给了服务器一个机会来传达其处理能力和最大吞吐量的信息。通过告诉客户端在任何给定时间内可以处理多少数据,客户端可以调节自己的输出以最小化数据丢失和网络拥塞。

显然,这些步骤的缺点是,为了建立和利用连接解析数字序列、相应地重新排序数据流以及重新传输数据,这些步骤会为交互带来重大的时间成本。当这种可靠性是必要的(在大多数企业网络软件中,它确实是必要的)时,你别无选择,只能利用 TCP 或类似健壮的协议。然而,当你的软件或支持它的网络基础设施的性质可以支持更低的可靠性时,你有许多高性能的传输层协议替代方案。

无连接通信

正如我们之前所确定的,传输层通信中的连接可以被视为一个会话。因此,无连接通信是一种通信模式,其中数据在首先在主机之间建立相互会话之前被传输。相反,数据包会带有适当的寻址信息被发送出去,确保交付的责任完全落在网络堆栈的底层。这显然引入了交付失败未被检测到的风险:因为没有期望从服务器那里得到任何确认,客户端不会知道数据包交付失败并需要重传,而如果没有首先同步会话,服务器也不会知道期待一个传入的消息。那么,为什么这种机制会被使用,何时这种风险是可以接受的?

无状态协议

由于没有任何会话需要管理,无连接协议通常被描述为无状态的。没有状态需要管理,每个事务都发生在一个更广泛的上下文中,这个上下文不会告诉接收者单个数据包如何适合到更广泛的接收数据包流中。因此,几乎无法确定并确保数据包的正确顺序,以便接收者可以重新构建。没有这种能力,无连接协议通常用于数据包可以完全自包含,或者丢失的数据包中丢失的信息可以根据接收到的下一个数据包由接收应用程序重建的情况。

在后一种情况下,我们可以通过在我们的应用程序中实现状态管理来解释协议的无状态性。例如,想象一下你的服务器托管一个跟踪确定状态的应用程序。现在假设这个状态是由远程客户端更新的,并且这些更新通过无连接协议(如 UDP)以最小延迟实时发送。由于应用程序的状态是确定的,如果丢失单个数据包,服务器仍然可以根据接收到的下一个数据包确定是哪个更新,前提是它自己的更新只能从丢失数据包中设置的具体状态到达。

使用这种架构,应用程序会因每次数据包丢失而产生时间成本,因为需要一些处理来推断丢失数据包的值并相应地更新其内部状态。然而,在网络足够可靠,数据包丢失是罕见事件的情况下,无连接通信模式的降低延迟可以远远弥补应用程序生命周期中偶尔丢失数据包的处理成本。因此,在足够可靠的连接上,这种权衡可能证明极其有价值。虽然这些协议在商业应用中不太常见,但它们在需要高吞吐量和低延迟的交互式应用中,如网络多人视频游戏,经常被利用。

在无连接通信中进行广播

这种缺乏在两个主机之间共享状态管理的优点之一是,无连接通信能够进行多播。通过这种方式,单个主机可以同时向多个接收者发送相同的数据包,因为出站端口不受与单个其他主机单一活跃连接的限制。这种多播或广播对于像实时视频流或数据源这样的服务特别有用,其中单个源服务器可以同时向任意数量的潜在消费者发送数据。由于无连接数据包传输的低开销,这可以允许向广泛的消费者提供高吞吐量的数据。

在无连接通信中建立连接

如果你像我一样,你可能注意到了我最初描述的基于连接的通信模式中存在一点鸡生蛋的问题。具体来说,在没有两个主机之间的会话的情况下,如何在这两个主机之间建立基于连接的会话?

显然,最初的答案是,通过初始的无连接通信请求来建立连接。TCP 连接请求的初始 SYN 消息是通过无连接通信 IP 发送的。通过这种方式,可以说基于连接的通信建立在无连接通信的基础上。事实上,在 TCP 的情况下,基于连接的交互如此依赖于 IP 的无连接交互,以至于这两个通常被合并并标识为 TCP/IP 套件。

UDP 作为无连接通信协议

就像 TCP 是互联网上首选的基于连接的通信协议一样,UDP 通常作为首选的无连接通信协议。UDP 表现出所有预期无连接协议的特性,包括在数据传输之前没有任何握手或会话协商,以及最小化的错误检查和错误纠正技术。那么,UDP 在哪些情况下是有用的呢?

对这种速度的需求以及可接受的间歇性数据包丢失,非常适合低级网络操作,用于发送通知或对网络中其他设备的基本查询。这就是为什么 UDP 是域名系统(DNS)查找和动态主机配置协议(DHCP)的首选协议。在这两种情况下,请求主机需要立即对单个简单查询做出响应。在 DNS 查找的情况下,请求是针对给定域名注册的每个 IP 地址。UDP 数据包可以直接指向 DNS 服务器,并且只包含正在查找的资源域名。一旦收到这些信息,DNS 服务器可以在自己的时间响应,相信请求 IP 地址的应用程序可能会监听响应。一旦客户端最初发送 DNS 查找请求,如果在给定超时期间没有收到响应,客户端将发送一个相同的包。这样,在数据包丢失的情况下,有一个错误恢复机制(超时期);同时,在数据包成功传输的更可能的情况下,查询结果将比首先建立连接返回得更快。

这种相同的行为使得 DHCP 请求能够在近乎实时的情况下得到满足。当一个新网络设备从 DHCP 服务器请求 IP 地址时,它对自己的网络中其他设备没有任何具体信息。因此,它必须广播一个 DHCP 请求,并希望相邻的节点可以作为 DHCP 服务器并为该设备分配一个 IP 地址。这些需求,对于低延迟和广播数据包的需求,意味着 DHCP 请求是 UDP 等无连接协议的理想用例。

检测无连接协议中的错误

我已经详细讨论过,无连接传输层协议更容易出错,因为协议本身没有检测错误的机制。我已经讨论了如何从利用它的应用层检测和纠正无连接传输层协议中的错误。然而,至少在 UDP 中,每个数据包至少包含一个简单的错误检测机制,那就是校验和

如果你之前从未听说过这个术语,校验和类似于哈希函数,其中每个输入都会提供一个截然不同的输出。在 UDP 数据包中,校验和输入基本上是数据包的整个头部和主体。这些字节通过一个标准的生成校验和的算法发送。然后,一旦数据包被接收,接收方将数据包的内容通过与客户端相同的校验和算法进行校验,并验证它接收到的响应与发送的是否相同。如果存在任何微小的差异,接收方可以确信在传输过程中某些数据被修改,并且发生了错误。

对此错误进行响应或纠正超出了 UDP 错误处理机制的范畴。通常情况下,如果数据包的值对于接收方系统的持续运行至关重要,该系统可能会请求重新传输数据包。然而,在大多数情况下,不匹配的校验和仅仅表明接收方数据包无效,可以从处理队列中丢弃。

C# 中的 TCP

因此,现在我们已经深入探讨了各种传输层协议的目标、功能和限制,让我们看看我们如何在 C# 中与这些协议交互。我们将首先仔细研究 .NET Core 提供的类和功能,以便直接从我们的应用程序代码中实现 TCP 请求。我们将通过为我们的两个应用程序中的每一个写入标准输出,查看每个请求和响应的结果,以确认我们软件的预期行为。这两个应用程序就像我们在第九章 HTTP in .NET 中所做的那样。其中一个应用程序将是我们的 TCP 客户端,另一个将是监听 TCP 服务器。我们将通过写入标准输出,查看每个请求和响应的结果,以确认我们软件的预期行为。

初始化 TCP 服务器

让我们先创建我们的 TCP 客户端,就像我们在之前每个应用程序中做的那样,通过为其创建一个目录,然后使用 CLI 创建一个控制台应用程序:

dotnet new console -n SampleTcpClient

然后,在同一个目录下,我们将使用相同的命令创建我们的 TCP 服务器应用程序:

dotnet new console -n SampleTcpServer

现在我们已经准备好开始设置我们的交互。当我们上一次直接与在 第八章 套接字和端口 中暴露端口的套接字交互时,我们使用 Postman 生成针对给定端点的 HTTP 请求。然而,现在,由于我们将直接在代码中编写自己的 TCP 消息,我们不会受到 Postman 生成的标准化 HTTP 头部的限制。我们可以定义主机之间交互的自己的机制。为了便于处理,我们将让我们的客户端和服务器仅使用简单的字符串消息进行交互。

要开始这些交互,我们需要设置一个监听服务器。我们需要这样做是为了知道客户端将连接到哪个端口。因此,导航到你的 SampleTcpServer 应用程序的 Main() 方法,我们首先定义我们的监听端口,然后启动 TcpListener 类的一个实例,如下所示:

public static void Main(string[] args) {
    int port = 54321;
    IPAddress address = IPAddress.Any;
    TcpListener server = new TcpListener(address, port);
    ...
}

TcpListener 类是一个围绕裸 Socket 实例的定制包装器。通过构造函数,我们指定我们想要监听传入请求的端口和 IP。如果我们使用裸套接字,我们就必须处理并响应或丢弃针对我们指定的端口的每个传入网络请求。然而,通过 TcpListener 实例,我们不需要响应任何不是通过 TCP 发送的请求。一旦我们设置了客户端类,我们将会看到这一点,但当你在一个开放的端口上监听这种低级网络请求时,这非常有用。

我们使用的构造函数接受 IPAddress 类的实例以及任何指定有效端口的 int(所以没有负数,也没有超过 65,535 的数)。因此,对于这个项目,我们将使用端口 54321 来监听传入的 TCP 请求。对于我们的 IPAddress 实例,我们使用由类公开的 Any 静态 IPAddress 实例。通过这样做,我们将能够看到并响应任何目标主机 IP 地址或域名解析到我们的主机机器的 TCP 请求。如果我们没有这样做,而是指定了一个单独的 IP 地址,那么即使地址解析到同一台机器,我们也不会响应任何 IP 地址不与之完全匹配的请求。因此,我们会这样做:

IPAddress address = IPAddress.Parse("127.0.0.1");

在这样做之后,我们可以向 tcp://0.0.0.0:54321 发送一个 TCP 请求,但你不会在我们的 TcpListener 实例上看到任何请求注册。你可能预期我们的请求会被检测到,因为 0.0.0.0127.0.0.1 这两个 IP 地址都解析到同一台本地机器,但在这个例子中,我们指定我们的 TcpListener 只监听 127.0.0.1 IP 地址的请求,这正是它所做的事情。同时,我们的 0.0.0.0 请求没有得到解决。因此,除非你为你的主机机器上持有的不同 IP 地址编写不同的监听器(或者你的应用程序可能部署的一系列主机机器),否则我建议尽可能使用 IPAddress.Any

现在,我们必须设置我们的服务器以运行并监听对该端口的请求。首先,我们将启动服务器,然后我们将设置一个上下文,在那里我们无限期地监听传入的请求。这通常是通过一个故意无限循环来完成的。现在,如果你曾经意外地发现自己陷入了一个无限循环中,你知道这应该只在你真正打算这么做的时候才开始。然而,由于我们希望我们的应用程序无限期地监听,最简单且最可靠的方法是防止我们的Main()方法解析,通过将我们的主要业务逻辑封装在一个简单的无限循环中来做到这一点:

server.Start();

var loggedNoRequest = false;
var loggedPending = false;

while (true) {
  if (!server.Pending()) {
    if (!loggedNoRequest) {
      Console.WriteLine("No pending requests as of yet");
      Console.WriteLine("Server listening...");
      loggedNoRequest = true;
    }
  } else {
    if (!loggedPending) {
      Console.WriteLine("Pending TCP request...");
      loggedPending = true;
    }
  }
}

如果你编译并构建到目前为止我们所写的代码,你将看到两个控制台语句打印到屏幕上,然后你的应用程序看起来就像挂了一段时间,这正是你所希望看到的。幕后发生的事情是,你已经通过调用Start()初始化了TcpListener实例。

这将导致实例在其指定的端口上接受传入的请求,直到你明确地在类上调用Stop()方法,或者它接收到的连接总数超过SocketOptionName枚举的MaxConnections属性(该属性设置为超过二十亿,因此在我们的小型本地 TCP 服务器中,这个限制不太可能达到)。

一旦我们的服务器开始监听,我们就启动我们的监听循环,并检查我们的套接字是否收到了任何挂起的请求。如果没有(并且自上次请求以来我们没有记录它),我们通过简单的控制台日志来表明这一点,然后继续,通过while循环继续,直到我们有东西要处理。目前,我们不应该看到任何挂起状态,所以让我们设置我们的客户端项目来改变这一点。

初始化 TCP 客户端

现在,我们需要以与我们的服务器相同的方式初始化我们的 TCP 客户端应用程序。不过,我们将使用TcpClient类而不是TcpListener类来与我们的服务器建立连接,这样我们就可以在我们的项目中写入和读取这些连接。在我们这个例子中,两者之间的区别在于,当我们创建一个TcpListener时,我们需要用它将监听的地址和端口来初始化它。没有默认构造函数,因为没有监听端口的类无法执行其最基本的功能。

然而,使用TcpClient实例,我们不需要用地址或端口规范来初始化它。客户端实例可以合理地用于连接到多个不同的远程进程(单个远程主机上的端口)或主机(完全不同的 IP 地址)。因此,当我们尝试建立连接时,我们只需要指定我们的连接目标。现在,让我们只是建立连接来确认我们的服务器能够适当地响应监听请求:

public static async Task Main(string[] args) {
  int port = 54321;
  IPAddress address = IPAddress.Parse("127.0.0.1");
  using (TcpClient client = new TcpClient()) {
    client.Connect(address, port);
    if (client.Connected) {
      Console.WriteLine("We've connected from the client");
    }
  }
  Thread.Sleep(10000);
}

在这里,我们指定了 IP 地址 127.0.0.1,但正如我之前所说的,我们本可以指定任何解析到我们本地机器的别名 IP 地址。一旦我们创建了客户端,我们就可以使用它来连接到我们在服务器应用程序上指定的监听端口。然后,为了确认连接已经建立,并且客户端知道这一点,我们写入一个简单的日志语句,让线程休眠10秒以观察控制台中的消息,然后终止程序。

为了看到这一成功,首先启动你的服务器应用程序,确保它已经启动并监听指定的端口。然后,一旦你在控制台窗口中看到消息表明服务器正在等待挂起请求,启动你的客户端应用程序。你应该在你的客户端窗口中看到“我们已连接...”消息,在你的服务器窗口中看到“挂起的 TCP 请求...”消息。一旦你看到这两条消息,你就知道你的连接正在建立,然后你可以终止这两个应用程序。

此外,让我们考虑一下为什么我们使用loggedPending标志。为什么我们使用loggedNoRequest标志来防止我们在收到传入请求之前每次遍历循环时打印日志消息应该是相当明显的。然而,我们必须在存在挂起请求时做同样的事情,因为服务器会保持Pending状态,直到其入站消息队列被读取并刷新。所以,由于我们的服务器在没有那个检查的情况下还没有读取并清空传入请求流,并且我们连接到了服务器,我们的控制台会很快因为挂起的 TCP 请求...消息而溢出。

无数据传输的连接信息

在我们开始在项目中构建和解析 TCP 请求之前,我想花一点时间来指出基于连接的方法的好处,以及.NET Core 如何利用它来让工程师对他们的网络事务有精细的控制。请注意,一旦我们从客户端发送连接请求,服务器就会立即通知我们连接已经建立。实际上并没有发送消息,服务器也没有收到任何响应。事实上,即使服务器同步锁定并阻止主动传输任何内容,连接仍然保持打开。这是 TCP 的握手交互在起作用,并为我们提供了关于连接状态的大量信息,这些信息是在实际发送消息之前获得的。

对于应用程序开发者来说,特别令人高兴的是,连接的建立和管理是由TcpClient类本身完成的。只需调用一次Connect(IPAddress, int)方法,TcpClient 库就会通知我们的服务器我们希望建立连接,等待确认,并最终确认服务器的响应以打开连接。这是.NET Core 最伟大的优势之一;高级应用程序编程语言的易用性,以及访问和控制低级网络交互的能力。

在活动连接上传输数据

现在我们已经建立了连接,我们的服务器可以决定如何处理该连接以及通过它传输的请求。然而,在我们回到服务器之前,让我们首先生成一个客户端的消息供服务器处理。我们将使用一种突变测试来确认所有数据都被服务器相应地处理和返回。因此,在每一步,我们都会修改我们的初始消息并记录结果。每一步,我们的消息都应该与最后写入它的系统不同。

如果你从未听说过突变测试这个术语,它是一种简单的方法,用于跟踪对系统所做的更改是否被验证系统(测试)检测到。想法是在你的代码中某个地方进行更改或突变,并确认在下游某个地方,通常是在单元测试中,该更改产生了影响,通常是通过使之前通过的单位测试失败。

我们将从编写一个带有标题和有效负载的消息开始。这将是针对我们服务器的简单问候,以及我们期望服务器返回给我们的消息,作为其响应的一部分,且不改变。我们将使用简单的|分隔符将两个消息分开。然后我们将它转换为适合通过我们的连接传输的字节序列,并发送请求。所以,在我们继续到服务器之前,让我们设置一下:

var message = "Hello server | Return this payload to sender!";
var bytes = Encoding.UTF8.GetBytes(message);
using (var requestStream = client.GetStream()) {
  requestStream.Write(bytes, 0, bytes.Length);
}

我们创建的requestStream变量是NetworkStream类的一个实例,用于在打开的套接字上写入和读取数据。有了这个,我们将能够发送我们的初始消息,然后,最终,从服务器读取响应。但是,在我们改变方向回到服务器之前,让我们看看如何使用我们的TcpListener实例来接受和解析传入的请求。

在服务器上接受传入的 TCP 请求

现在既然我们的客户端实际上正在发送一个可读的消息,让我们在我们的待处理连接上监听请求。为此,我们将直接从我们的监听器获取另一个TcpClient类的实例。这个类是用来与打开的连接交互的,所以一旦我们接受它,我们将以与我们的示例客户端程序相同的方式从该打开连接中读取和写入。首先,我们必须使用线程阻塞的AcceptTcpClient()调用接受待处理连接。由于我们现在正在响应我们的待处理请求,我们可以删除我们的日志消息并用我们的新代码替换它:

loggedNoRequest = false;
byte[] bytes = new byte[256];

using (var client = await server.AcceptTcpClientAsync()) {
  using (var tcpStream = client.GetStream()) {
    await tcpStream.ReadAsync(bytes, 0, bytes.Length);
    var requestMessage = Encoding.UTF8.GetString(bytes);
    Console.WriteLine(requestMessage);
  }
}

启动我们的服务器后,我们应该在我们的服务器日志中看到它正在监听待处理的连接请求。然后,一旦我们运行我们的客户端,我们应该看到客户端的请求消息被记录到服务器的控制台,随后还有一个指示器表明服务器再次开始监听传入的请求。如果我们再次运行客户端,我们将看到相同的序列事件,直到我们最终关闭服务器。

服务器上的请求/响应模型

为了完成请求/响应交互,我们将生成一个新的消息,使用原始请求的有效负载,并将其返回给我们的客户端。随着这两个应用程序的完成,从现在开始,客户端将驱动与服务器之间的交互。因此,我们的服务器将保持运行状态,返回响应,这些响应会回显请求的有效负载,直到它收到一个指示它应该自行关闭的信号。同时,我们的客户端将发送带有新有效负载的间歇性请求,直到最终向我们的服务器发送终止信号。为了实现这个目的,我们将向我们的服务器应用程序添加以下行:

bool done = false;
string DELIMITER = "|";
string TERMINATE = "TERMINATE";

我们将使用这个信号来表示我们应该停止监听请求并终止服务器。接下来,我们将添加以下条件代码到我们服务器的监听循环中:

  ...
  requestStream.Read(bytes, 0, bytes.Length);
  var requestMessage = Encoding.UTF8.GetString(bytes).Replace("\0", string.Empty);

  if (requestMessage.Equals(TERMINATE)) {
    done = true;
  } else {
    Console.WriteLine(requestMessage);
  }
}

我们的响应传输代码将放在那个条件块中的else语句内,因此我们的循环将简单地继续记录请求消息,然后将有效负载附加到响应中,直到接收到终止信号,此时循环被打破,我们将关闭我们的服务器。所以,最后,我们将修改我们的while循环,检查done条件的值而不是运行无限循环:

while (!done) {
    ...

接下来,让我们解析消息以获取其有效负载,使用我们的分隔符来分隔消息的两个组件,然后将结果应用于服务器的响应:

} else {
  Console.WriteLine(requestMessage);
  var payload = requestMessage.Split(DELIMITER).Last();
  var responseMessage = $"Greetings from the server! | {payload}";
  var responseBytes = Encoding.UTF8.GetBytes(responseMessage);
  await tcpStream.WriteAsync(responseBytes, 0, responseBytes.Length);
}

最后,在我们的监听循环的闭合花括号之后的一行,让我们关闭我们的服务器,如果你正在使用 Visual Studio 的调试模式运行应用程序,允许我们的程序在短暂的延迟后结束以检查日志结果:

  }
  server.Stop();
  Thread.Sleep(10000);
}

有了这些,我们的 SampleTcpServer 应用程序就完成了。它将保持活跃状态并监听请求,直到明确地指示它终止自己。在整个过程中,它将记录它接收到的每个请求并返回它自己的自定义响应。您可以使用本章 GitHub 仓库中的源代码来检查您的实现与我的实现是否一致,但就像往常一样,我鼓励您自己修改它并开始调查其他可用的方法。在这样做的时候,始终思考您如何在自己的自定义网络软件中使用此代码。

完成 TCP 客户端

我们的服务器被设计和编写成保持活跃并监听任何潜在的传入请求。另一方面,客户端应该只为单一目的而设置,执行该目的,然后关闭其连接,以便服务器可以为可能需要访问它的其他消费者释放资源。因此,我们不会编写任何持久的监听循环。相反,我们将在终止服务器并关闭我们自己的应用程序之前简单地处理几个请求/响应往返。然而,为了创建一个稍微更现实的模拟,多个客户端访问我们的 TCP 服务器,我们将为每个后续请求丢弃并重新创建我们的 TcpClient 实例,并在每个请求之间注入随机延迟。

然而,首要任务是接受来自我们服务器的响应。因此,在我们的 SampleTcpClient 应用程序中,我们将添加几行代码来创建一个新的字节数组,用作响应的消息缓冲区,然后将我们的 requestStream 读取到我们的缓冲区中进行处理和记录。所以,让我们添加这段代码,然后我们将看到我们如何扩展它来完成我们的模拟:

using (var requestStream = client.GetStream()) {
  await requestStream.WriteAsync(bytes, 0, bytes.Length);
  var responseBytes = new byte[256];
  await requestStream.ReadAsync(responseBytes, 0, responseBytes.Length);
  var responseMessage = Encoding.UTF8.GetString(responseBytes);
  Console.WriteLine(responseMessage);
}

我认为到目前为止,这一切都不足为奇。我们实际上执行的是与服务器完全相同的事情,但顺序相反。在服务器上,我们是读取流,然后写入流,而在客户端代码中,我们首先写入流,然后从流中读取。然而,从机械上讲,这是我们自从在第四章 [9d6266fb-4428-4044-b63b-44f1317f64e7.xhtml],数据包和流 中首次查看如何与原始 C# Stream 对象交互以来所看到的相同类型的交互。希望到现在为止,您已经开始看到我们迄今为止在构建网络编程基础时采用的逐步、砖块式方法的价值(假设您还没有这样做)。

无论如何,让我们修改我们的客户端,在最终发送终止信号之前发送一些预定义的消息。为此,让我们构建一个包含我们将发送到服务器的消息的简短数组,这样我们就可以在我们的代码中轻松地遍历它们,在每个出站请求中发送不同的消息:

var messages = new string[] {
  "Hello server | Return this payload to sender!",
  "To the server | Send this payload back to me!",
  "Server Header | Another returned message.",
  "Header Value | Payload to be returned",
  "TERMINATE"
};

接下来,让我们将请求/响应事务包裹在一个 while 循环中(不是像我们的服务器那样看到的活跃监听循环,而是一个简单的增量循环)。我们将使用一个迭代变量,从零开始,遍历我们的消息,将其值与我们的消息数组长度进行比较,以确定何时跳出循环并让我们的应用程序终止:

var i = 0;
while (i < messages.Length) {
  using (TcpClient client = new TcpClient()) {
    ...

由于我们的 TcpClient 实例是在 while 循环内的 using 语句中创建的,因此每次迭代变量都会超出作用域。因此,我们每次回到循环的开始时都会创建一个新的连接。接下来,我们必须更改构建我们的请求消息字节数组的代码,使其迭代 messages 字符串数组:

var bytes = Encoding.UTF8.GetBytes(messages[i++]);

最后,在 while 循环的末尾,我们将线程休眠一个介于 210 秒之间的随机时间,每次都记录 sleepDuration

  ...
  var sleepDuration = new Random().Next(2000, 10000);
  Console.WriteLine($"Generating a new request in {sleepDuration/1000} seconds");
  Thread.Sleep(sleepDuration);
}

最后,如果你在调试模式下运行,你可能会在 while 循环之后添加一个最后的 Thread.Sleep(),以确保在我们应用程序关闭之前有足够的时间检查我们请求的结果。

在完成客户端并运行这两个应用程序后,我的终端记录了我希望它们记录的确切消息:

图片

并且,通过这种方式,我们已经编写了自己的自定义 TCP 服务器和客户端。虽然这个示例在功能上相当简单,但我希望你能看到这些 .NET 类为你提供的自定义 TCP 实现的高度灵活性。通过这两个示例应用程序,你拥有了编写自己的自定义应用层协议所需的全部工具,该协议由一个针对它优化的自定义 TCP 服务器支持。或者,你可以编写绕过应用层协议开销的网络的交互应用程序!你在个人或专业项目中遇到的问题将决定你如何选择使用这个工具集,但现在,希望你能准备好在需要时利用它。

C# 中的 UDP

现在我们已经了解了如何在 C# 中实现 TCP,接下来让我们看看传输层协议套件中与之对应的无连接协议 UDP。根据其本质,我们将编写的示例客户端和服务器在设置代码方面将比 TCP 简单得多,但我们将使用之前章节中用于定义示例应用程序行为的相同模式。因此,我们将在客户端和服务器之间传输请求并接受和记录响应。

然而,这里的区别是客户端和服务器将以完全相同的方式进行实现。这是因为没有 UdpListener 类,因为 UDP 不主动监听连接。相反,UDP 服务器只是在设置好寻找新的数据包时接受传入的数据包。因此,我们将只查看客户端应用程序的实现,我将把服务器源代码留给你从 GitHub 上下载并使用来测试和验证客户端的行为。

初始化 UDP 客户端

我们将首先创建一个新的控制台应用程序,它将作为我们的 UDP 客户端:

dotnet new console -n SampleUdpClient

然后,在我们的项目中,我们首先想要做的是定义一个已知的 IP 端点,我们的客户端将与之交互。我们将再次使用 localhost,并暴露一个任意端口,就像我们在上一节关于 TCP 的部分中所做的那样。一旦我们定义了它,我们就可以准备开始生成请求。无连接协议的美丽之处在于我们不需要首先与我们的远程主机建立任何形式的交互。只要我们知道主机的地址,我们就可以简单地发送我们的数据报。

public static async Task Main(string[] args) {
  using (var client = new UdpClient(34567)) {
    var remoteEndpoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 45678);

    var message = "Testing UDP";
    byte[] messageBytes = Encoding.UTF8.GetBytes(message);
    await client.SendAsync(messageBytes, messageBytes.Length, remoteEndpoint);
    ...
  }
}

就这样,如果你运行服务器应用程序,然后运行客户端,你将看到你的消息被记录在服务器的控制台上!那么,这里到底发生了什么,当我们初始化我们的 UdpClient 时我们在做什么呢?

我们首先用端口号初始化我们的 UdpClient。如果我们打算使用这个客户端来接收传入的 UDP 数据报(我们最终会这样做),它将在这个初始化时指定的端口上接受它们。因此,我们的客户端将监听端口 34567。接下来,我们花时间定义我们打算发送数据报的显式 IPEndPoint

这在技术上不是必需的,因为你可以使用重载的方法签名,在 SendAsync() 方法中定义你的请求目标,包括它们的域名和端口。然而,由于我们将扩展这个方法以接受响应,在我们的目的中,在方法开始时显式定义 IPEndPoint 实例更容易。最后,我们像上一节那样构建我们的数据报,将其作为表示消息字符串字符的字节数组,并借助我们新初始化的 UdpClient 发送消息。

发送/接收范式

你可能已经注意到,与更健壮的TcpClient类相比,使用UdpClient时没有利用流。由于没有底层连接来表示流,以及 UDP 数据可能丢失或顺序错误的潜在可能性,UDP 请求的行为与Stream实例提供的抽象之间没有直接关联。正因为如此,.NET Core 提供的UdpClient类通过其SendReceive方法实现了一个简单的请求/响应机制。这两个方法中的任何一个都不需要与远程主机进行先前的通信或交互即可执行。相反,它们更像是在网络上触发某些事件发生的“触发器”。

有趣的是,尽管如此,当你想利用SendAsync()方法时,这个方法不会阻塞你的应用程序线程,你可以选择首先与你的远程主机建立连接。但请记住,这并不完全等同于 TCP 中的建立连接。相反,这仅仅配置了你的UdpClient,使其尝试将所有发出的数据包发送到它连接的特定远程主机。

在这种情况下,连接仅是逻辑上的,并且它只存在于建立它的应用程序中。因此,虽然一个建立的 TCP 连接可以从我们的客户端和服务器应用程序同时检测到,但在我们的 UDP 应用程序中并非如此。当同时运行我们的 UDP 客户端和服务器时,服务器应用程序无法检测到客户端建立的连接。

一旦我们将UdpClient连接到特定的IPEndPoint,每个SendAsync()调用都假定是为连接的端点配置的。如果你想在UdpClient实例连接到不同端点时发送消息,你必须首先断开客户端的连接,或者明确地将新端点作为参数传递给SendAsync()调用。在我们的示例应用程序中,这不会成为问题,但在实际环境中,这个问题可能会很快出现,因此当你为特定应用程序定义发送/接收模式时,请记住这一点。

基于这种理解,让我们准备接收我们的 UDP 服务器应用程序的响应。首先,我们将修改我们的应用程序,使其一开始就连接到我们的远程端点。接下来,为了演示如何使用UdpClient实例建立连接,我们将从SendAsync()调用中移除端点参数。最后,我们将使用ReceiveAsync()监听消息。到那时,我们将像处理之前的每个字节数组缓冲区一样处理数据包的缓冲区对象:

public static async Task Main(string[] args) {
  using (var client = new UdpClient(34567)) {
    var remoteEndpoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 45678);

    client.Connect(remoteEndpoint);

    var message = "Testing UDP";
    byte[] messageBytes = Encoding.UTF8.GetBytes(message);
    await client.SendAsync(messageBytes, messageBytes.Length);

    var response = await client.ReceiveAsync();
    var responseMessage = Encoding.UTF8.GetString(response.Buffer);
 Console.WriteLine(responseMessage);

 Thread.Sleep(10000);
 }
}

有了这些,我们已经将 UDP 客户端连接好,可以发送数据包并等待服务器的响应。

你可能已经从本章关于无连接通信的讨论中推断出了这一点,但无论何时你使用 UDP(或任何其他无连接协议)发送消息,它本质上是非阻塞操作。这是由于服务器没有提供任何形式的确认。因此,从我们的应用程序角度来看,一旦 UDP 数据包到达我们的网络卡进行传输,其交付就不再在我们手中。

同时,UDP 中的Receive()操作本质上是阻塞的. 由于没有建立连接或流缓冲区来存储传入的消息,直到我们的服务器或客户端准备好处理数据包,因此我们编写的任何必须接受和接收 UDP 数据包的软件都必须非常明确地说明在等待可能永远不会到达的数据包时,何时以及多长时间可以阻塞我们的执行。传输方法的异步版本提供了一些灵活性,但最终,这是协议的限制,我们无法规避。鉴于这一点,最好是从一开始就注意这个限制,并围绕它设计你的 UDP 软件。

多播数据包

使用无连接通信,例如 UDP,最大的单一优势之一可能是能够在单次事务中向大量接收者发送数据包。这通常被称为多播,或广播,它使得从网络设备发现和主机注册到大多数通过互联网直播的电视或视频流都能实现。这是一个相对小众的功能,如果我要猜测的话,大多数阅读这篇文章的人可能永远不会找到一个很好的理由去利用它,但它确实值得理解。话虽如此,让我们看看如何在我们的.NET Core 应用程序中启用此功能。

.NET 中的多播

在我们迄今为止查看的大多数数据包传输中,我们一直在针对特定机器上的特定端口进行操作,通过主机名或 IP 地址进行寻址。然而,如果我们目标是向尽可能多的可以监听它的 IP 地址发送相同的数据包,这显然不会满足我们的需求。如果我们试图发现网络上的设备,甚至不确定它们的 IP 地址,这肯定也不会起作用。相反,大多数网络设备将监听针对它们特定 IP 地址的请求,以及专门设计来捕获来自其网络上其他设备广播数据包的特殊 IP 地址范围(通常,该多播 IP 地址将是255.255.255.255,但不一定是)。

如果你希望从你的主机的一个端口中多播多个数据包,你可以通过配置你的UdpClient实例,使其通过ExclusiveAddressUse布尔属性允许多个客户端访问一个开放的端口来实现。通过将该属性设置为false,你可以使多个UdpClient同时利用相同的端口,从而让你的应用程序能够向配置了与之交互的客户端数量一样多的远程主机传输消息。

另外,如果你想监听多播数据包,你可以通过将适当的MulticastGroupOptions设置应用于你的客户端或套接字,将一个UdpClient设置为MulticastGroup的一部分。这样做会将你的客户端设置为与其他已注册的监听器一起监听单个发送主机正在多播的数据包。

正如我在本节开头所说,多播和监听多播数据包是一项非常专业的操作,你不太可能在日常工作中需要考虑它。因此,我不会在这个主题上花费更多的时间。然而,如果你对此感兴趣,我强烈建议你在线查阅相关的文档。不过,现在,我只是想确保你对这个概念至少有一些了解,并理解UdpClient类中提供了哪些功能,你可以利用这些功能来实现或监听多播数据传输。不过,现在,我认为是时候转向一个更广泛使用的传输层协议了。因此,让我们深入探讨互联网协议。现在是时候研究 IP 了。

摘要

本章为我们理解网络编程提供了一个重大的范式转变。我们研究了传输层的责任与应用层的责任完全不同,我们非常仔细地审视了传输层的具体责任。我们了解到,互联网工程任务组IETF)根据协议可能支持的服务和功能,对传输层责任的各种方法进行了分类,并且我们学习了如何使用这些分类来确定使用特定传输层协议的最佳情况。

接下来,我们学习了如何使用 TCP 等基于连接的协议,在客户端和服务器之间进行初步握手,以在数据传输之前在两个主机之间建立活跃的连接或会话。我们看到了这些会话如何使基于连接的通信协议能够在主机之间提供可靠的交互,并具有大量的错误检测和错误纠正支持。然后我们考虑了无连接协议本身提供的一些优点,包括在足够可靠的网络上主机之间低开销和低延迟的交互。然后我们查看了一些无连接协议或其上层的应用层协议可以采用的策略,以减轻无连接通信的不可靠性。

最后,带着这种观点,我们能够一头扎进使用 C#和.NET 框架提供的某些极其简单的库来实现基于连接和无连接的客户端和服务器。我们使用了设计用来模拟 TCP 和 UDP 交互的客户端和服务器,并在这个过程中看到了.NET Core 的设计者如何概念化每个协议的一些特性,并在代码中实现这些特性。现在,我们对这两种传输层协议都有了深入的了解,我们准备全面检查最普遍的传输层协议——互联网协议的复杂性和细微差别。这正是我们将在下一章中做的事情。

问题

  1. 传输层协议的四种分类是什么?

  2. 传输层协议的主要功能和职责是什么?

  3. 在基于连接的通信模式中,“连接”是什么意思?

  4. TCP 代表什么?为什么通常被称为 TCP/IP?

  5. 描述用于在 TCP 上建立连接的手动过程。

  6. UDP 代表什么?UDP 有哪些优点?

  7. 无连接通信的最大缺点是什么?

  8. 多播是什么?广播是什么,如何在 UDP 中启用?

进一步阅读

关于 TCP、UDP 和传输层的一般信息,我建议阅读 Alena Kabelová和 Libor Dostálek 所著的《Understanding TCP/IP》,可在以下链接从 Packt Publishing 获取:

www.packtpub.com/networking-and-servers/understanding-tcpip.

第四部分:安全性、稳定性和可扩展性

在这部分,读者将深入探讨如何通过编写更安全的客户端和服务器之间的交互来使他们的软件更加可靠。它将解释如何检查和配置文件您的网络软件的性能,以改进和监控性能。它还将探讨与缓存相关的可扩展性策略。

本节将涵盖以下章节:

第十二章,互联网协议

第十三章,传输层安全

第十四章,网络中的身份验证和授权

第十五章,分布式系统的缓存策略

第十六章,性能分析和监控

第十二章:互联网协议

在上一章中,我们全面了解了开放系统互联OSI)网络堆栈传输层中最常见且最健壮的两个协议:传输控制协议TCP)和用户数据报协议UDP)。在本章中,我们将探讨使这两个传输层服务得以实现的网络层协议。在本章中,我们将学习关于互联网协议IP)的内容。我们将探讨 IP 标准是如何演变为支持数十亿设备的全球网络,并允许它们之间可靠地相互通信的。我们将考虑较早且更常见的 IPv4,查看 IPv4 旨在解决的问题,并讨论其达到的限制。接下来,我们将检查 IPv6 旨在解决这些限制的方式。最后,我们将更详细地查看IPAddress类,并仔细研究核心库是如何实现 IPv4 和 IPv6 的。我们将借此机会讨论并考虑 IP 地址如何映射到域名,以及学习域名系统DNS)服务器如何将地址映射到资源,并查看一些代码示例,这些示例将使我们能够自行实现这些映射。

本章将涵盖以下主题:

  • IP 地址的组成以及它是如何被使用的,包括网络掩码、本地寻址和 DNS 服务器,用于识别物理设备

  • 使用 IPv4 标准分配 IP 地址的方式,以及识别 IPv4 的限制

  • IPv6 标准的详细信息,列举利用 IPv6 的优势以及在全球互联网规模上实施它的成本

  • DNS 级别的域名到 IP 地址解析

技术要求

在本章中,我们将编写示例软件来解析从我们的主机文件配置的 IP 地址,以模拟 DNS 服务器。您需要.NET Core 集成开发环境IDE)或代码编辑器。您可以在github.com/PacktPublishing/Hands-On-Network-Programming-with-C-and-.NET-Core/tree/master/Chapter%2012访问示例代码。查看以下视频以查看代码的实际运行情况:bit.ly/2HYmyi9

IP 标准

在我们开始查看 IP 标准从其起源发展到广泛采用的 IPv4,再到现在的 IPv6 之前,我们首先必须了解该标准是什么以及它与我们所查看的传输层协议有何不同。这对于巩固我们对 OSI 网络堆栈的理解至关重要,因为 IP 是互联网上运行的传输层协议的基础。因此,让我们弄清楚 IP 是为什么而设计的,它是如何实现其设计目标的,以及它为网络软件和硬件提供了哪些功能。

IP 的起源

IP 最初作为 TCP 最早版本中的数据包传输机制实现。IP 首次在 1974 年得到正式描述。在现代计算机历史的早期,计算网络还处于起步阶段。然而,这些网络在范围上不断增长,并开始使用各种交互机制封装多个子网络。随着网络的增长,对网络连接设备之间标准的需求迅速显现。

为了满足这一对标准的需要,美国政府的高级研究计划署(ARPA)赞助了一系列实验,以定义一个能够支持大规模互联网络的协议。在这一赞助下,电气和电子工程师协会(IEEE)的成员撰写了一篇论文,描述了一种利用分组交换技术在网络中的主机之间共享资源的互联网络协议。从 1977 年开始,该组织开始尝试使用这篇论文中描述的协议的各种草案。在 1977 年至 1979 年之间,根据互联网实验笔记IEN)的描述,有四个 IP 的实验版本,分别标记为 IPv0 至 IPv3。这些版本中的每一个都解决了前一个协议版本中的某些主要缺陷,直到团队确信他们的协议足够健壮,可以供更广泛的公众使用。

IPv0 – TCP 的网络层协议

这些实验中的第一个,IEN 2,是在 1977 年 8 月撰写的。它明确指出,工程师们在设计 IP 时违反了分层原则。在其初始草案中,TCP 负责抽象应用层数据包的主机到主机的传输,以及协商两个连接主机之间路由上的网络设备之间的跳数。通过这种方式过度设计 TCP,工程师创建了一个跨越 OSI 网络堆栈的传输和网络层的单一协议。这种违反 OSI 层之间边界的行为几乎立即被认识到是一个糟糕的设计和做法。因此,在 IEN 2 中,作者提出了一个新的、独特的互联网协议,并建议 TCP 严格用作主机级别的端到端协议。通过这个实验,IP 诞生了。

在 IEN 2 中描述的协议和接口描述了两个之前都由 TCP 执行的主要操作。首先,有互联网主机-跳协议,它将成为 TCP。这是为了描述主机之间完整端到端交互的接口,而不关心如何在这两个主机之间导航。它描述了一个基本的负载分片过程和许多今天仍在 TCP 中使用的头部信息。

描述的第二种协议是互联网跳接口。IEN 的这一部分描述了最终成为 IP 的内容。在这个上下文中,“跳”是指网络图中两个节点或主机之间单条边上的跳。IEN 的这一部分的目标是定义与数据包捆绑的最小必要信息,以便路径上的任何一步都能相应地路由它,避免将同一数据包的多个实例路由到目的地,并允许以这种方式进行分片,以便数据包可以在目的地网关重新组装。

IPv1 到 IPv3 – 正式化报头格式

在接下来的两年里,又编写了几个 IEN 来描述不断演变的 IP 接口。这些 IEN 各自以不同的方式,正式化了 IP 的一些细节,这些细节最终成为了广泛发布和普遍支持的 IPv4。从 IPv1 开始,正如 IEN 26 所描述的,工程师们设定的第一个任务是定义成功路由跨越任意大和任意组织的网络所需的最小必要报头,以及它们的最小必要大小规范。

没有某种类型的报头的普遍接受,最终将无法出现我们今天所知道的互联网。然而,直到有某种接口的普遍接受,互联网工程任务组IETF)的成员知道他们的工作将受到反馈和变化的制约。因此,第一份 IP 报头描述的主要任务之一是允许支持多个版本和多种在那些网络上暴露的服务。因此,IEN 26 中描述的报头引入了诸如 IP 版本报头和服务类型TOS)报头等字段。

紧接着,在 IEN 28 中,团队定义了 IPv2,这进一步明确了接口的报头以及网络上的数据包分片过程。这也是第一个提出检测数据包损坏机制的 IEN,尽管它没有提供如何实现的指导。最后,它描述了数据包的基本寻址组件以及网络上的主机寻址机制。然而,值得注意的是,所描述的机制并非最终发布给更广泛公众的机制。

IPv4 – 建立 IP

在对协议进行多次迭代的过程中,团队解决了他们的设计问题,直到 IEN 54,他们最终确定了将被请求评论RFC)791 标准化的报头定义,即 IPv4。随着 RFC 791 的发布,IETF 最终确立了 IP 标准的操作和实施细节。自 1981 年以来,该协议版本在全球范围内使用,即使今天,该接口规范也几乎用于互联网上主机之间发送的所有数据报的 80%左右。

IP 的功能

如 RFC 791 所述,IP 设计为网络提供三个主要功能。在该规范的 1.2 节中,协议的作用域被明确限制在仅提供将一个比特包(一个互联网数据报)从源传输到目的地的必要功能上,这些功能是在一个互联的网络系统中实现的。

你会注意到,在这个定义中,作者们没有提到可靠性、有序交付或连接协商。这完全是他们有意为之的省略。正如他们在 IEN 2 中所说的,试图用网络层协议来处理这些功能将违反 OSI 网络栈的边界。这不仅仅是我个人的推测;在 IP 作用域的定义中,作者们明确指出,没有机制来增强端到端数据可靠性、流量控制、排序或主机到主机协议中发现的其它服务。在这里,作者们指的是传输层协议和接口的职责。

因此,如果可靠的交付、流量控制和排序都不在 IP 的范围内,你可能很想知道它负责哪些功能,以及它是如何实现这些功能的。嗯,根据标准,IP 精确负责两个功能:地址和分片。该协议为上层的传输层协议提供这些功能,它通过利用下层数据链路层的本地网络协议来实现这一点。

IP 地址

地址用于唯一标识可以处理网络请求的主机(或一组主机)。任何必须被连接到其网络的其它主机定位的设备都必须有一个与其关联的地址。这是 IP 从数据链路层请求路由信息的唯一机制。

在这里,区分地址和名称、主机名或域名是值得的。名称,或主机名,是供人类阅读的统一资源标识符URI)结构,而地址是一个唯一、语义结构的键,指示主机名的所有者所在的位置。根据 IP 标准,传输层协议负责在将地址信息传递给网络层以传输到路由中的下一个设备之前,将主机名解析为其特定的地址。

在这里,应该进一步区分地址或识别目标主机的子网和特定位置,以及路由或找到源主机到目标主机之间的完整路径。一旦数据报被主机的 IP 接口接收,目标地址就会被验证,数据包会被分片,并应用所有 IP 头部。然后,数据报会被传递下去,而数据链路层负责在网络的链路和节点之间执行路由任务,以找到两个主机之间的连接路径。

因此,IP 实现的地址功能围绕着在网络上为新节点分配地址,以及解析和解释附加到数据包上的地址。在分配新地址时,它们使用固定长度、语义上有意义的数值键。语义上有意义的数值键就是指可以从键的结构中推断出其含义的键。

在 IP 地址的情况下,地址的不同部分包含有关 IP 地址标识的主机特定位置的信息。例如,在早期地址解析规范中,32 位地址方案的前 8 位用于定位目标主机所在的具体子网。地址中的下一个 24 位在本地网络地址方案中作为主机的地址。

随着网络范围的不断扩展和地址空间的日益增大,IP 地址方案的规范在多年中不断发展和变化,但自 1981 年 IPv4 引入以来,用作主机地址的良好格式化、语义键的原则保持不变。

数据包分片

由于 IP 设计用于促进网络中节点之间的跳转,因此需要为数据包分片(在可能已在传输层执行的分片之上)制定规范。由于大型互联网中的每个连接子网都可以自由指定其自己的数据包大小和交付约束,因此在数据包穿越网络时,数据报的大小和格式要求可能会出现不一致。可能存在的情况是,由源主机子网认为足够小的数据报实际上对于目标主机子网来说太大。因此,运行在路由器或两个子网之间的桥上的 IP 实现可能需要在两个子网之间移动数据报时对其进行分解或重新组装。

规范确实提供了一种机制,用于指示在任何情况下都不应将数据报分片。然而,如果数据链路层的规范阻止数据报在不分片的情况下交付,并且数据报被标记为不分片,那么它就会被简单地丢弃。

标准定义了分片过程的实际步骤,作为一个将较长的数据报分解成若干个较小数据报的通用系统。数据报被分解成更小的二进制数据帧,并添加额外的头部信息,以便将这些较小的数据报重新组合成适当大小的原始数据报。添加到较小数据报中的额外字段如下:

  • 偏移量:新分片在数据报中的位置。这允许正确地重新排序可能已经乱序交付的数据报片段。

  • 长度:这指定了从原始数据报中提取并存储在当前分片有效载荷中的内容的长度。

  • 标识符字段:新的、较小的分片也使用一个标识符来指定它们属于哪个较大的数据报。这有助于确保在重组过程中,来自不同父数据报的较小片段不会混淆。

  • 更多分片标志:最后,有一个更多分片标志字段,用于指示是否需要将额外的较小分片添加到重建的父数据报中。

这些字段(偏移量、长度、标识符和更多分片)结合起来,足以从任意数量的片段在目标主机上重建一个数据报。我们在 RFC 中看到的描述的通用性质允许在几乎任何网络网关、路由器或子网接口的任何用例中进行可靠的分片和重组。现在我们了解了该协议旨在实现的目标,让我们看看自其诞生以来是如何实施和部署的。

IPv4 及其限制

1981 年首次定义,并于 1983 年广泛部署,IPv4 已经成为整个互联网以及几乎所有局域网网络层交互的标准,至今已有三十多年的历史。正如我之前提到的,几乎所有互联网流量都是使用 IP 接口的 IPv4 规范完成的。它的稳定性、可扩展性和可靠性已经得到了充分证明。那么,IPv4 的哪些特性使其在网络层责任实施上如此成功?IPv4 规范中的哪些特性在如此漫长且成功的历史之后,促使定义和部署新的 IPv6 协议?

IPv4 的寻址标准

正如我在关于 IP 寻址功能的上一节中提到的,IPv4 的地址设计具有语义结构,而不是简单地为网络上的每个新设备分配一个任意的键。因此,只要你理解如何解析地址的语义意义,就可以通过分析地址的每个段来进行分层分析,从而确定主机的具体位置。

IPv4 地址语法

IPv4 地址是 32 位的,通常分为四个八位字节(字节),由十进制分隔,每个字节以十进制表示。然而,地址的底层结构足够灵活,可以表示为从点十进制表示法,到 32 位值的原始十进制整数表示,到十六进制,到点十六进制格式。这些表示法中的每一种都只是表达相同二进制值的不同方式。因此,给定 IP 地址的语法表示法并不重要,因为底层 32 位表示法保留了语义意义。

那么,让我们考虑以下 IPv4 地址:

11000000101010000000000110110101

这可能对你来说并不熟悉,作为一个 IP 地址,至少不是以那种形式。那么,让我们看看我们如何以我们更容易识别为 IP 地址的方式表达它。我们将首先将二进制表示分为四个八位字节:

11000000.10101000.00000001.10110101

接下来,我们将每个以点分隔的字节转换为它们的十进制表示:

192.168.1.181

就这样,我们得到了一个我们更熟悉的 IP 地址格式。然而,我们同样可以将字符串转换为它的十六进制点表示法,并得到以下结果:

C0.A8.01.B5

只要我们保持八位字节的顺序,意义就保持不变,并且可以为我们提供有关将路由请求发送到给定地址的有用信息。

类别 IP 寻址

在 IPv4 中,地址中每个八位字节的值可以携带关于该地址主机分层路由信息。当协议版本最初定义时,有一个规定,即地址值的第一个八位字节将指定主机所属的子网。这被称为网络字段。剩下的三个八位字节被用来指定主机在该子网中的地址。这些八位字节共同被称为剩余字段,简称地址的其余部分

现在,如果你对二进制数学很熟悉,你可能会已经意识到了我刚才描述的结构问题。由于只有一个八位字节来指定子网,整个互联网上最多只能有255个子网。这种限制几乎立即被认定为不可行,因此标准文档中包含了针对不同类别的寻址方案的规定。在 RFC 791 中描述了三种特定的 IP 地址类别,每个类别使用不同数量的位来指定主机的子网,并且每个类别都有其自己独特的最大主机数限制。

在 RFC 被起草的时候,只有大约64个子网存在,这意味着最多只有网络字段的六个最低有效位被用来指定已知的子网,直到那时。为了避免重新分配广泛使用的子网地址,网络字段的最高有效位被设置为用于指定给定 IP 地址的类标志。在原始 RFC 中,有三个定义良好的 IP 结构类,第四个留作未来根据需要指定的开放类。这三个原始类被定义为如下:

  • A 类:在一个A 类地址中,最高有效位为零,接下来的七个位用于子网标识。这留下剩余的三个八位作为其余字段,允许在一个由A 类地址标识的子网中有多达16,777,215个可能的唯一宿地址。

  • B 类:在一个B 类地址中,地址的最高有效位值为1,次高有效位值为0。地址的接下来的 14 位用于子网标识,留下两个最低有效八位用于子网内唯一的宿地址。

  • C 类:最后,在一个C 类地址中,地址的前两个最高有效位值为1,而第三个最高有效位值为0。在这三个最高有效位中的这些值,接下来的 21 位用于子网标识,允许有2,097,151个可能的唯一子网。这仅留下最后一个八位用于宿地址标识,在一个C 类IP 地址中最多只有255个宿地址可用。

为了进一步说明这些类在语义上的解析方式,考虑以下三个 IP 地址:

38.117.181.90
183.174.61.12
192.168.1.181

现在,通过将每个地址转换为它们的点二进制表示,我们可以检查最高有效位来确定每个地址属于哪个 IP 地址类。

图片

然而,将三个最高有效位限制用于指示网络类是一个不可持续的长期解决方案。不久之后,IETF 设计了一种新的机制来确定 IP 地址的网络字段。

子网掩码

到 1993 年,在类地址架构下的可用 IP 地址池以不可持续的速度耗尽(我们将在稍后讨论这个问题)。为了减轻这一挑战,IETF 废除了由 RFC 791 描述的类地址架构,并引入了无类域间路由(CIDR)地址语法。CIDR 语法在 IP 地址上应用了一个额外的、可选的后缀,用于精确指示地址中有多少位是用于网络字段。后缀由一个开头的/字符分隔,然后是一个整数,表示子网掩码中有多少个前导1

如果术语子网掩码听起来很熟悉,你可能在你终端中运行ipconfig命令时的诊断输出中见过它。在这个上下文中,术语掩码特指位掩码。基本上,当你将掩码应用于另一个二进制数时,结果是在两个数中至少有一个为1的任何位置上都有一个1值。考虑以下 IP 地址:

11000000.10101000.00000001.10110101

然后是以下子网掩码:

11111111.11111111.11111111.00000000

将掩码应用于 IP 地址的结果如下:

11111111.11111111.11111111.10110101

因此,在这个例子中,如果我们将我们的二进制数转换为点十进制表示法,我们得到的 IP 地址如下:

192.168.1.181

我们还有以下子网掩码:

255.255.255.0

这个子网掩码的作用是指示路由设备哪些 IP 地址的位应该用于网络识别。因此,因为我们刚才查看的子网掩码的前 24 位都是1,这意味着这前 24 位应该用作网络标识符。

这个特定的子网掩码可能对你来说很熟悉,因为它是最现代路由器的默认本地子网和子网掩码。这个 IP 地址所标识的子网是由你的家用路由器创建的,它作为更广泛互联网和你的家庭网络之间的网关。但是,这意味着对于任何只有一个路由器的家庭,可以连接到网络的最大设备数量是有限的。

使用 CIDR 的表示法,相同的 IP 地址和子网掩码组合如下表示:

192.168.1.181/24

这就产生了所谓的可变长度子网掩码VLSM)。它允许我们使用任意数量的位进行网络识别,而无需保留最高位作为标志值。这意味着 IP 地址可以用来识别更大的一组唯一子网,而这些网络可以有更广泛的最大尺寸范围。

地址空间耗尽

所有这些将标准适应以允许地址语法的更大灵活性的工作主要是为了减轻 IPv4 可能的最大限制。我之前已经提到过,IPv4 的地址规范允许地址最多有 32 位。这意味着无论你如何结构化你的网络字段和其余字段,最大唯一 IP 地址的数量始终是4,294,967,296个。

在 1983 年,当 IPv4 被标准化时,互联网还只是一个实验。当然,在 IETF 工作的工程师们有远见,预计他们的网络实验最终会扩展到覆盖整个世界。然而,IPv4 的发展是基于他们的特定网络实验不会超出 ARPA 的计算机网络。然而,即使他们看到他们的标准在新兴互联网上的广泛应用,仍然有一个假设,即 430 亿个唯一地址将提供足够的时间在地址空间耗尽之前设计出一个可行的替代方案。

他们没有预料到的是,计算机的功率会增加,成本会降低的速度。这种组合导致了消费计算机市场的爆炸性增长,随之而来的是需要地址的网络主机数量的爆炸性增长。随着新世纪的临近,最后可用的 IPv4 地址的分配也随之而来。因此,在 1998 年,发布了下一个 IP 版本的草案标准。

IPv6 – 协议的未来

设计用来克服网络主机有效地址数量不足的问题,IPv6 首次在 1998 年被引入,尽管它直到 2017 年才被正式接受为标准(这也说明了工程师在定义标准方面的勤奋)。新的规范是为了处理 IPv4 提出的一些问题而编写的,包括有限的地址空间。该标准还支持多播传输,以及IP 安全IPSec)安全特性。

IPv6 地址方案

与 IPv4 有 32 位地址机制,允许最多有约 430 亿个唯一地址相比,IPv6 标准提供了一个 128 位的地址方案,允许有 3.4 x 10³⁸个唯一地址。那就是 3400 亿个地址!为了有一点背景,该方案允许的地址数量比从地球表面到可观测宇宙边缘的米数还要多。有了如此大的地址空间,IPv6 方案允许更简单的地址分配、路由聚合和我们将要探讨的唯一地址特性。

这 128 位被组织成每组 16 位的八个组。这些组通常以四个十六进制数字的形式书写(与 IPv4 中典型的整数表示形式不同),每个分组之间用冒号分隔。然而,为了最小化数据包头的大小,有一个标准可以缩写 IPv6 地址,而不会丢失有意义的信息。地址缩写的两个步骤如下:

  1. 删除路由中任何 16 位(或四个十六进制)段的前导零

  2. 删除一个连续的零字符串,并用**:**替换被删除的部分

要看到这一过程在实际中的应用,让我们从以下地址开始:

fe08:0000:0000:0000:5584:7902:0028:6f0e

现在,在应用步骤 1 之后,我们有以下内容:

fe08:0:0:0:5584:7902:28:6f0e

现在,移除最长的连续零字符串,我们得到以下内容:

fe08::5584:7902:28:6f0e

虽然这种表示方式在实质上更小,但它实际上只用作便利。IPv6 数据包的报头被配置为使用完整的 128 位地址作为数据包的源地址和目的地址,因此,在传输之前,无论缩写得多短,完整的地址都会应用到数据包上。

网络字段和路由效率

在 IPv4 中,人们投入了大量工作来在有限的 32 位地址内分配足够的空间用于子网标识符。然而,由于 IPv6 设计时考虑到了如此庞大的地址空间,网络标识大大简化。所有 IPv6 地址都将最重要的 64 位分配给子网寻址,而将剩余的 64 位分配给主机或接口标识。

这使得路由器和网络交换机能够更有效地处理数据。因为网络标识符和主机地址总是固定长度的,并且这些长度与 32 位和 64 位硬件的字长很好地对齐,因此路由器可以更高效地解析地址结构。

IPv6 中的分片

在 IPv4 和 IPv6 之间,另一个主要的变化在于对数据包进行适当分片以适应其路由的责任分配。在 IPv4 中,这明确是网络层的关注点,IPv6 试图解决这个问题。关于数据包分片提供的指导是定义 IPv4 的 RFC 中的一个重要部分。与此同时,在 IPv6 中,数据包分片被认为是传输层和数据链路层的共同责任。

这种责任变化背后的理念是断言,在传输层的端到端协议中应该有一个步骤。这个步骤就是确定两个主机之间路由上允许的最大数据包大小。同时,两个主机之间路由的每个边缘的最大传输单元MTU)应在传输开始时从数据链路层可发现。在无法发现两个主机之间特定路由的 MTU 的情况下,传输层应回退到互联网的默认 MTU,即 1280 字节的数据。因此,在理想情况下,数据链路层可以提供特定路由的 MTU,而传输层可以相应地分片其数据包。如果数据链路层未能提供路由的 MTU,传输层将使用默认 MTU 的最坏情况分片大小,即 1280 字节。

IPv6 到 IPv4 接口

由于 IPv6 从根本上改变了 IPv4 数据包头部的结构,这两个版本完全不兼容。当网络工程师需要在整个 IPv4 向 IPv6 过渡的整个生命周期内支持广泛部署的 IPv4 时,这显然是一个问题。为了促进这一过渡,已经设计出一些中间解决方案,以允许 IPv6 流量在 IPv4 网络上运行。

并行 IP 部署

在单个网络上使 IPv4 和 IPv6 共存的最简单方法是在该网络上的每个主机部署每个版本的完整协议实现。这通常在操作系统(OS)级别完成,并允许流量在到达和离开单个硬件接口时与两个 IP 版本交互,一旦物理数据传输被交付给操作系统。使用这种并行部署的设备将同时获得 IPv4 和 IPv6 的地址,如果主机有一个注册的域名,该域名将由 DNS 服务器解析为这两种地址方案。当然,这里的明显缺点是并行部署的效果仅与部署的子网一样好。如果一个主机支持两种协议,但存在于仅支持 IPv4 的网络中,那么就没有任何好处。然而,在一个严格控制的子网中,并行部署是一个可行且通常简单的选项。

隧道接口

IP 流量跨版本支持的另一种替代方案被称为隧道。这是一种通过将 IPv6 数据包封装在 IPv4 数据包中来在 IPv4 网络上隧道传输 IPv6 流量的机制。这一过程在 RFC 4213 中有描述,并被严格使用 IPv6 数据包方案的服务器广泛采用。

最受欢迎的隧道方案之一,Teredo,一直被用来将 IPv6 子网集成到更广泛的 IPv4 互联网中。Teredo 实现这一目标的方式是通过利用我们的老朋友,UDP。IPv6 数据包被封装在一个 UDP 数据包头部中,而这个 UDP 头部本身又被封装在一个 IPv4 数据包中。IPv4 数据包按正常路由,直到被配置为专门分解 IPv4 数据包为原始 IPv6 结构的 Teredo 客户端或服务器接收。

虽然这对任何网络工程师来说都是有用的信息,但作为 C#开发者,我们很幸运,不需要关心这些接口的细节。虽然我们可以从软件中的任何网络流量中获取特定的 IP 信息,但 IP 数据包的翻译和解析主要被抽象化,不为我们所知。因此,现在让我们看看我们如何调查和理解软件中 IP 流量的本质。

在 C#中利用 IP

由于 C# 和 .NET Core 运行时会从我们的应用程序软件中抽象出大多数 IP 交互的细节,所以这个演示将相对简单。我们将编写一个简单的 Web API 来模拟 DNS 名称解析。我们将使用一个简单的 JSON 文件来存储域名及其相关地址,并提供 IPAddress 类(或其实例列表)的一个实例作为我们的响应。这将展示语言在幕后为您提供了大量的解析和协商,以及这如何显著简化您的开发过程。而且,因为我们一直在本书中处理 IP 地址和端口,所以其中很多内容应该对您来说都很熟悉。

设置我们的服务器

我们将使用一个简单的 Web API 项目来完成这个任务,因此我们将使用 命令行界面CLI)来创建它:

dotnet new webapi -n DNSSimulation

一旦设置好,我们将从我们的控制器中移除所有除 POST 端点之外的所有脚手架端点。这将是通过用户从我们的 DNS 服务器查找主机名的路由。我们还将修改我们的路由,以更准确地表达我们的 API 提供的内容。因此,在我们开始编码之前,我们的控制器应该如下所示:

[Route("dns/[controller]")]
[ApiController]
public class HostsController : ControllerBase {
    [HttpPost]
    public IEnumerable<string> Post([FromBody] string domainName) {
    }
}

接下来,我们需要为我们的应用程序添加一个简单的主机注册表,以便执行查找。因此,创建一个表示键值对的 JSON 文件。键将是我们在执行查找的主机名,值将是任意 IP 地址的字符串表示的数组。并且为了演示目的,确保在我们的文件中使用 IPv4 和 IPv6 地址。我的文件看起来像这样,但您的可以使用您喜欢的任何主机名和地址:

{
    "test.com": [ "172.112.98.123" ],
    "fake.net": [
        "133.54.121.89",
        "fe80:0000:0000:0000:5584:7902:d228:6f0e"
    ],
    "not-real.org": [
        "123.12.13.134",
        "dc39::7354:23f3:c34e"
    ]
}

作为最后的设置步骤,我们将添加一个简单的静态类,以便从我们的控制器中更容易地处理 hosts.json 文件。为此,我们将创建一个 Hosts 静态类,给它一个名为 Map 的单个公共属性,然后使用 C# 的静态构造函数特性来初始化 Map 属性,使其包含我们的 JSON 文件的内容。然后,每次我们需要访问我们的主机文件时,我们都会通过到 Hosts.Map 方法的静态引用来这样做,并相应地查询其字典。这种模式非常简单,也非常有用,可以为您的应用程序代码提供直接且易于理解的静态内容文件访问。我们的示例如下所示:

public static class Hosts {
  public static IDictionary<string, IEnumerable<string>> Map;

  static Hosts() {
    try {
      using (var sr = new StreamReader("hosts.json")) {
        var json = sr.ReadToEnd();
        Map = JsonConvert.DeserializeObject<IDictionary<string, IEnumerable<string>>>(json);
      }
    } catch (Exception e) {
      throw e;
    }
  }
}

到此为止,我们已经准备好实现我们的 IP 查找。

C# 中的 IP 解析

现在我们已经准备好从主机文件中读取,我们可以开始解析传入的请求,并以 JSON 字符串的形式返回给我们的消费者 IP 信息。就像我们所有的演示代码一样,我们将假设我们得到的是格式良好的输入,并且暂时忽略错误处理。

我们的输入将是一个完全限定的 URI,因此我们将初始化一个临时的 URI 变量,以便更容易地获取域名:

public string Post([FromBody] string domainName) {
  var uri = new UriBuilder(domainName).Uri;

接下来,我们将尝试访问Hosts.Map方法中的主机名对应的 IP 地址。如果失败,我们将回退到外部的 DNS 服务器,并返回它为我们的主机名提供的任何地址。我们将使用一个名为GetSerializedIpAddresses()的实用方法来完成这项工作,该方法将IPAddress数组序列化为字符串,我们稍后会讨论它。现在,重要的是要理解,当我们无法在我们的服务器注册表中找到主机名时,我们的回退是查找外部的 DNS 服务器以进行名称解析:

IEnumerable<string> ipAddressStrings;
if (!Hosts.Map.TryGetValue(uri.Host, out ipAddressStrings)) {
   return GetSerializedIPAddresses(Dns.GetHostAddresses(uri.Host));
}

一旦我们通过了这一点,我们就知道我们拥有了请求主机的IPAddress条目,我们可以使用 C#的IPAddress类来相应地解析它们。所以,首先,我们将为我们的IPAddress实例创建一个容器。然后,我们将尝试使用IPAddress.TryParse()方法初始化每个实例。假设这成功了(在我的例子中是这样,并且假设你自己的文件中有良好格式的 IP 地址,它也会在你的文件中成功),我们将新的IPAddress实例添加到我们的列表中:

var addresses = new List<IPAddress>();
foreach (var addressString in ipAddressStrings) {
  if (!IPAddress.TryParse(addressString, out var newAddress)) {
    continue;
  }
  addresses.Add(newAddress);
}

如果你一直按照我的例子做,你会发现IPAddress类的TryParse()方法会自动检测并处理我之前讨论的所有寻址方案。我们可以添加从人类可读的点分十进制格式的 IPv4 地址,到简化的 IPv6 地址,再到原始的 32 位二进制字符串,TryParse()方法会相应地构建地址。这种实用工具是为什么本章的软件演示可以如此轻量。几乎所有繁重的工作都是由.NET Core 运行时为您完成的。

在 C#中使用IPAddress

我们对这个服务的最后一个任务是将我们的IPAddress实例列表转换为相应的 JSON。这可能是你代码中遇到的一个相当大的障碍。不幸的是,IPAddress类与JsonConvert.SerializeObject()配合得不是很好。事实上,如果你尝试在一个IPAddress实例上执行该方法,你几乎每次都会得到一个异常。这是因为IPAddress.Address属性实际上是已弃用的。它被定义为长类型,在 C#中是 64 位整数。然而,正如你所知,IPv6 地址是一个 128 位值。不幸的是,JsonConverter类并不足够智能,无法在运行时确定哪些公共属性已弃用。这意味着它将尝试访问你的IPAddress实例的Address属性进行序列化,并且对于包含 IPv6 地址的任何IPAddress实例,这种访问将引发错误。

现在,如果你熟悉编写自己的 JsonConverter 扩展类,你可以覆盖 IPAddressJsonConverter 类,并使用它来序列化你的返回对象。然而,由于这超出了本书的范围,因此,我们将采取不太理想的捷径,通过编写自己的 GetSerializedIPAddresses() 方法来实现序列化。既然我们知道最好不要使用 IPAddress.Address 属性,我们只需使用 ToString() 方法来获取我们的 IPAddress 实例的值。该方法将简单地构建出每个 IPAddress 实例的字符串表示形式,作为 JSON,使用我们知道的每个不是已弃用且可以安全访问的公共属性。该方法如下所示:

private string GetSerializedIPAddresses(IEnumerable<IPAddress> addresses) {
  var str = new StringBuilder("[");
  var firstInstance = true;
  foreach (var address in addresses) {
    if (!firstInstance) {
      str.Append(",");
    } else {
      firstInstance = false;
    }
    str.Append("{");
    str.Append($"\"Address\": {address.ToString()},");
    str.Append($"\"AddressFamily\": {address.AddressFamily},");
    str.Append($"\"IsIPv4MappedToIPv6\": {address.IsIPv4MappedToIPv6}");
    str.Append($"\"IsIPv6LinkLocal\": {address.IsIPv6LinkLocal},");
    str.Append($"\"IsIPv6Multicast\": {address.IsIPv6Multicast},");
    str.Append($"\"IsIPv6SiteLocal\": {address.IsIPv6SiteLocal},");
    str.Append($"\"IsIPv6Teredo\": {address.IsIPv6Teredo}");
    str.Append("}");
  }

  str.Append("]");
  return str.ToString();
}

通过该方法,我们可以确切地看到 IPAddress 类可以通过其公共属性为我们提供有关 IP 版本性质及其实现的哪些信息。我们可以了解用于利用 IPv6 覆盖 IPv4 的映射或接口,或者简单地了解 IPv6 主机的功能支持。

随着最后一块拼图的到位,我们的最终控制器方法应该如下所示:

[HttpPost]
public string Post([FromBody] string domainName) {
  var uri = new UriBuilder(domainName).Uri;
  IEnumerable<string> ipAddressStrings;
  if (!Hosts.Map.TryGetValue(uri.Host, out ipAddressStrings)) {
    return GetSerializedIPAddresses(Dns.GetHostAddresses(uri.Host));
  }

  var addresses = new List<IPAddress>();
  foreach (var addressString in ipAddressStrings) {
    IPAddress newAddress;
    if (!IPAddress.TryParse(addressString, out newAddress)) {
        continue;
    }
    addresses.Add(newAddress);
  }
  return GetSerializedIPAddresses(addresses);
}

如果你运行应用程序并将 POST 主机名发送到你的端点,你会注意到返回的 IP 地址总是格式良好的,甚至完全限定的 IPv6 地址也被缩写了。通过这种简单的功能,你可以抽象出在应用程序代码中解析和操作 IP 地址的所有混乱。你可以信任,这项工作正在由底层的 IPv4 和 IPv6 的强大实现为你正确处理。

摘要

在本章中,我们极其仔细地研究了 IP,首先精确地辨别了为什么 IP 作为一种网络层协议,与之前我们考察过的传输层协议不同,然后通过其起源了解了 IP 的功能和用途。我们考察了 TCP 的传输层责任与 IP 的网络层责任(最终成为 IP)之间的分界点。通过这样做,我们明确了 IP 的范围以及它旨在提供的功能,以及哪些功能超出了其范围。

一旦我们确定了 IP 的范围和意图,我们就仔细研究了它多年来是如何演变的。从 IPv4 开始,我们学习了寻址方案、它是如何产生的以及网络软件如何使用它来唯一标识网络上的主机。我们了解了在 IPv4 寻址架构中区分网络地址和主机地址的常见机制。我们还考察了子网掩码如何帮助区分单个地址中的这两个字段。一旦我们涵盖了 IPv4 的寻址架构,我们就考察了它在支持可寻址主机总数方面的局限性。

在探索了 IPv4 的全貌之后,我们看到了其当前提议的替代品 IPv6,并看到了新标准中更新的地址结构如何支持单个通用网络中的大量主机。然后,简要地,我们检查了一些允许 IPv4 和 IPv6 共存的接口。最后,我们探讨了 C#为我们软件中解析和构建 IPv4 和 IPv6 地址提供的类,以确保我们的网络数据包可靠路由。

现在我们已经看到了信息在最低级别是如何路由和交付的,是时候考虑网络交互的最重要方面了。因此,在下一章中,我们将探讨如何在网络中提供安全性。

问题

  1. IP 的两个主要功能是什么?

  2. 什么是类地址?IPv4 地址有哪些类别?

  3. 什么是可变长度子网掩码?

  4. 什么是地址耗尽?

  5. IPv4 地址空间的上限是多少?

  6. IPv4 地址的结构是什么?IPv6 地址的结构又是什么?

  7. 什么是 Teredo 隧道?

  8. IPv6 启用了哪些功能?

进一步阅读

为了您自己的参考,我强烈建议您阅读 IPv4 的原始 RFC。您会惊讶地发现它有多容易阅读,以及您可以从底层规范中获取多少信息。它也可以免费在线阅读,这里: tools.ietf.org/html/rfc791

我还建议,仅仅为了其简洁性,您阅读原始 IEN 2,以了解 IP 最初发展的确切动机。它也可以免费在线阅读,并且出人意料地引人入胜:www.rfc-editor.org/ien/ien2.txt

此外,如果您想了解其他编程 IP 的方法,我再次推荐由Alena KabelováLibor Dostálek撰写的Understanding TCP/IP,由 Packt Publishing 提供,这里:www.packtpub.com/networking-and-servers/understanding-tcpip

第十三章:传输层安全性

现在我们已经看到了网络交互是如何在最低级别执行的,我们需要了解这些交互如何为用户提供安全保障。公共互联网最基本的一个方面就是能够在两个主机之间安全地执行某些交互。在本章中,我们将探讨这是如何实现的。我们将从查看支持原始安全套接字层SSL)的底层安全机制开始,这是数十年来安全网络交互的标准。然后,我们将仔细研究其继任者传输层安全性TLS),并考虑一些过渡的原因。最后,我们将看到这两种机制是如何通过实现我们自己的协议模拟来为网络主机提供安全交互的。在这个过程中,我们还将看到如何利用 TLS 和网络安全性,直接在.NET Core 中实现。

本章节将涵盖以下主题:

  • 当应用程序利用 TLS 时,用户应该期望的数据完整性和会话隐私水平

  • 为什么 SSL 正在被弃用,以及 TLS 是如何支持安全连接的

  • 理解如何利用.NET Core 的即用功能来支持 TLS

技术要求

在本章中,我们将编写示例软件,从 Web API 应用程序配置和利用 SSL 和 TLS。你需要你的.NET Core IDE 或你选择的代码编辑器,并且你可以在这里访问示例代码:github.com/PacktPublishing/Hands-On-Network-Programming-with-CSharp-and-.NET-Core/tree/master/Chapter%2013

查看以下视频以查看代码的实际运行情况:bit.ly/2HY63Ty

私人连接和 SSL

当互联网开始支持像实时聊天或电子邮件这样简单的东西时,两个网络主机之间建立安全连接的需求就变得明显了。想象一下,如果你无法合理地假设你的消息会保持私密,向你的朋友发送一个机密信息。当然,你只会将在线交互限制在最为平凡的任务和消息上。而且这仅仅考虑了我们对于个人事务隐私的直观需求。对于保护可能被恶意行为者用来进行欺诈的私人、个人识别信息的需求,更是不言而喻。

如果在我们的在线交互中没有一定的安全措施,没有人会梦想进行像银行、访问医疗信息或支付税款这样关键的事情。确实,对现代互联网用户来说,这些看似基本和根本的任务如果没有来自恶意第三方的某种保护,将是不可想象的。正是这些场景使得安全连接被设计出来以促进。但你有没有想过它们是如何工作的?你有没有考虑过 Chrome 地址栏中的那个锁形图标意味着什么?

我们将在本节中探讨这个问题。我们将了解最初为什么需要网络主机之间的安全交互机制。然后,我们将看看如何确保这种安全交互的安全性。我们将弄清楚你的浏览器是如何知道如何警告你潜在的不安全连接,以及我们如何为我们自己的用户提供这种级别的安全性。最后,我们将了解 SSL 是什么,以及如何在我们的软件中利用它为我们自己的消费者提供安心。

建立安全连接

如果你曾经点击链接访问网站,浏览器首先警告你即将进入一个不安全的连接,你可能想知道为什么以及如何生成这个警告。答案是,你的浏览器检测到尝试使用 SSL 标准建立连接的尝试。SSL 是建立远程服务器与其客户端之间加密链接的普遍同意的标准。

你可能还记得第九章,“.NET 中的 HTTP”,https://方案指定是安全超文本传输协议的方案。这个方案指定是向你的浏览器发出信号,表明在机器和远程主机之间来回发送的内容应该首先加密。当你导航到具有方案的 URL 时,你的浏览器将首先尝试与服务器协商一个安全连接。它是否能做到这一点,或者在某些情况下不能做到这一点,决定了你是否在渲染从服务器接收到的内容之前看到一个警告提示。

当用户或情况要求使用安全连接(例如,通过在 URI 中指定 HTTPS 作为方案)时,建立该连接的软件有责任确保其安全性。这意味着,如果你从头开始编写一个网络浏览器(使用像 C++这样的底层语言),你的软件将负责验证用户想要进行的任何 https 请求的接收端服务器。那么,这个连接是如何建立的?建立良好安全连接的主要机制是加密转换和第三方权威。让我们首先看看第三方权威,因为它比 SSL 中工作的加密机制更为直接。

信任的证书颁发机构

当服务器声称支持安全连接(历史上通过 SSL,今天通过 TLS)时,客户端必须有一种方式来确保服务器就是它所声称的那个。如果没有这个身份验证步骤,恶意行为者将很容易使安全连接的前提无效。你只需要设置一个目标网站的模拟版本作为陷阱。然后,通过提供指向恶意网站的欺诈链接,伪装成指向合法网站的链接,他们可以诱骗易受攻击的用户向恶意模拟界面提供访问凭证、用户信息等。HTTPS 的全部目的就是向用户提供一种保证,即他们的信息正在以他们期望的方式发送到他们期望的实体,在信息传输过程中,没有人能看到他们发送的内容。

通过认证证书来确认安全连接另一端的服务器确实是它所声称的实体,这是由受信任的证书授权机构CA)发行的。CA 是一个组织或实体,它将为任何想要支持通过 HTTPS 或 TLS 进行交互的服务器生成、签名和发行认证证书。具体来说,受信任机构发行的证书是加密安全的 X.509 公钥证书。

这种公钥加密我们很快就会看到更多,但基本上是一种单向安全机制,允许私钥的所有者验证一个自由分发的公钥。公钥是通过接收者的身份和私钥的组合生成的,私钥必须保持秘密,以便证书保持有效。然后,每当客户端想要验证服务器的身份时,他们就会将证书以及展示该证书的服务器身份直接发送回受信任的机构。使用他们的私钥,受信任的机构检查身份和证书的公钥,以确保它没有被篡改或欺诈性地生成。

整个过程可以分解为两个关键步骤。首先,服务器请求并从受信任的证书授权机构获得一个 X.509 证书:

然后,每当客户端想要与服务器建立安全连接时,它必须首先通过检查其 X.509 证书与发行 CA 来确认服务器的身份:

通过这一系列往返,可以确保服务器的身份,并且客户端至少在一定程度上可以信任所建立的加密连接是与预期的实体。

证书授权机构信任的基础

但是,你可能已经猜到了,这个证书机构的系统在各个层面上都内置了一定程度的信任。首先,用户必须信任他们的网络浏览器确实已经从远程服务器请求了证书,并从签名机构请求了认证。接下来,用户和浏览器必须信任 CA 只为有效证书持证人认证有效证书。

这可能看起来很明显,但完全有可能认证机构(CA)并不像你希望的那样值得信赖。2013 年,我们得知政府情报机构严重违反了基本的互联网安全协议,包括与受信任的证书机构合作,为无效持证人生成已签名和认证的证书,用于监视和反情报行动。无论你对这些行为的道德影响有何个人看法,都无法否认,通过这样做,负责的机构严重破坏了工程师和更广泛公众对受信任第三方安全措施有效性的信任。

然而,只要 CA 可以被(合理地)信赖,那么该机构签发的证书通常也可以被认为是可信的。有了这些证书和受信任机构的验证,就可以确定服务器的身份。一旦这一步骤完成,就需要确保传输中的数据包安全。

简单和相互认证

到目前为止,我们只关注在建立安全连接时验证服务器的身份。这就是所谓的简单认证机制****。客户端从服务器获得一个证书,并使用证书机构进行验证。只要证书有效,客户端就可以继续进行安全连接。服务器没有努力去验证客户端。

然而,这种模式同样可以应用于客户端。这种证书验证程序的扩展被称为相互认证。在相互认证方案中,双方都使用自己的证书进行认证。这可以允许服务器在每次建立连接时无需直接从客户端请求访问凭证或认证信息来验证用户。

在相互认证中,服务器仍然需要向任何想要建立安全连接的客户端提供由受信任机构签发的 X.509 证书。客户端也仍然需要负责使用该机构验证该证书。不同之处在于客户端证书的获取和验证方式。虽然服务器必须允许第三方机构为其证书签名,但客户端无需麻烦。相反,在大多数相互认证场景中,服务器本身为客户端签名并发起一个 X.509 证书。

服务器需要自己的私钥来生成颁发给客户端的公钥,但只要它拥有这个密钥,它就可以验证它颁发的任何证书。这样,服务器可以可靠地限制只有它颁发证书的客户端才能访问。身份验证步骤是过程问题,服务器所有者负责在颁发证书之前确定什么构成足够的身份验证。然而,一旦这个过程建立,它应该有充分的理由信任客户提供的证书,它可以验证这些证书。

现在,可能并不立即明显为什么服务器需要可信权威机构来签署其身份证书,而客户则不需要。这是因为互联网上客户端-服务器交互的特定性质。在几乎所有情况下,客户端负责与服务器建立连接。按照设计,服务器对何时或从何地收到特定客户端的请求没有任何先验知识。

对于每个请求,服务器必须以客户可以依赖的方式声明自己的身份。它必须能够对任何客户端做到这一点,无论是否已经建立了先前的关系。必须有一种方式来验证服务器对任何特定客户端和任何特定请求的身份。因此,最初验证然后随后验证服务器身份的工作集中在所有潜在客户都可以信任并作为单一共享资源的实体:可信权威机构。

然而,对于客户端证书,服务器可以合理地信任自己的公钥验证,并将自己的私钥应用于验证与客户端声明的身份相符的证书。生成 X.509 证书的加密机制在服务器或可信权威机构执行证书验证时是相同的。唯一的区别是信任放置的位置和原因。

客户使用第三方权威机构,因为否则,如果客户一开始就不知道服务器的声明可以信赖,他们怎么能信任由服务器签发的证书呢?服务器不需要使用第三方权威机构,因为并没有在客户身上建立信任。服务器使用自己的私钥进行自己的验证。无效的客户证书根本无法通过验证。因此,服务器颁发的证书足以识别客户。所以,一旦客户在自己的主机上安装了证书,他们就可以使用它来访问服务器,完全认证,并建立和利用安全连接,无需任何额外步骤。

加密传输数据

一旦以这种方式确立了服务器身份,使得客户端可以信任两个主机之间的任何交互,下一步就是确保没有其他人可以观察这些交互。为此,必须在传输过程中加密数据包。然而,为了利用客户端和服务器都可以使用的加密机制,必须事先确定具体细节。

非对称和对称加密

当使用带有签名 X.509 证书的服务器身份时,客户端正在使用所谓的非对称加密。这仅仅意味着在各方之间,对于加密所需必要秘密信息的分配上存在不平衡。双方使用相同的加密方案,但只有一方可以访问秘密密钥。这种系统对于像安全证书这样的东西是必要的,因为其中一些信息必须对任何请求的人公开。记住,当证书从服务器传到客户端时,安全连接尚未建立。任何想要从传输中的数据包中读取该信息的恶意方都可以自由地这样做。非对称加密方案考虑到这种不可避免性,并且设计成即使在公钥自由分发的情况下也能保持安全。

然而,一旦最终建立了安全连接,主机将利用所知的对称加密。这就是加密和解密消息所需的秘密信息在所有相关方之间平等(或对称)共享的地方。交换中的双方都必须就一个双方都有实现可以利用的安全加密算法达成一致。接下来,他们需要就每个将使用该算法与对方加密的消息进行解密的加密密钥达成一致。正是这种对称加密方法被两个主机用于通过安全传输协议进行通信。

协商加密密钥

现在,你可能已经注意到使用对称加密来避免监听主机之间传输的数据包时存在一点鸡生蛋的问题。具体来说,如何在尚未建立安全连接的情况下发送将被用于建立安全连接的共享私有加密密钥?为此,我们必须利用我们在考虑如何在相互认证设置中验证客户端证书时所考虑的非对称加密。

在通过 CA 验证服务器身份后,建立安全连接的第一步是确定双方将用于加密数据包的算法。存在许多被认为是安全的加密算法(尽管有一些,由于本书范围之外的原因,以前被认为是安全的,但现在不再是这样),它们都可以在 C#的System.Security.Cryptography命名空间中找到。建立算法的原因是,两个主机可能没有相同算法的实现,因此在他们继续之前,确定一个双方都实现的算法是很重要的。

一旦选择了算法,主机必须交换一组唯一的私钥,这些私钥将在会话期间用于加密和解密数据包。为了进行这种交换,服务器首先发送一个只有它自己拥有的私钥的公钥。然后,客户端使用这个公钥加密一个随机数,并将该值返回给服务器。在这个时候,如果数据包被截获是完全可以接受的。相关信息(客户端生成的随机数)被加密了,用于加密的那个公钥没有私钥就无用了,而私钥尚未传输,因此不可能被截获。

当服务器接收到客户端生成的随机数时,它可以使用其私钥对其进行解密,然后使用该数字作为初始化值,为整个会话中将要使用的、商定的算法生成一个合适的加密密钥。客户端也会做同样的事情,这样双方就无需以不安全的方式传输该秘密的细节,就能建立一个共享的秘密。

SSL 协议

多年来,实现安全网络连接的标准是众所周知的SSL。NetScape 公司利用之前为网络交互开发安全传输机制的早期努力,开发了 SSL,作为一种建立全球网络安全标准的方式。SSL 作为标准的历史实际上作为了一个关于网络安全性质的启发性的警告。

网络安全始终是黑客和安全研究人员之间的一场猫捉老鼠的游戏。为了建立一个可靠加密数据的安全算法而做出的每一项努力,几乎都会成为未预见的漏洞的牺牲品,使得该算法作为安全措施变得无用。这一点在从网络安全协议到数字版权管理应用,再到基本的操作系统级库和工具的各个方面都是如此。

尽管这超出了本书的范围,但在计算机科学界正在进行一项非常有趣的研究,这可能会对安全软件的现状产生重大影响。它围绕算法分析中的一个定理,如果最终被证伪,将同时使所有已知的安全算法失效。如果你对此感兴趣,我建议研究 P=NP 问题。如果你开始阅读它,要做好准备迎接高阶数学,但也要注意,截至本书出版时,仍有百万美元的奖金悬赏给第一个证明或证伪该定理的人。

这种在安全算法和算法漏洞之间的持续跳跃,特别是在 SSL 的早期版本中尤为明显。事实上,SSL 1.0 由于在测试阶段发现协议中存在明显的安全漏洞,从未向公众发布。相反,SSL 2.0 在 1995 年初向公众发布。然而,仅仅一年后,下一个版本,即对协议的全面改版,于 1996 年作为 SSL 3.0 发布。这是由于黑客社区迅速发现的一系列重大缺陷,使得 SSL 2.0 对于许多机密交易来说不够安全。与之前的版本相比,SSL 3.0 在其继任者最终于 1999 年被设计出来之前,拥有相对较长的使用寿命。

SSL 生命周期中从一个版本到下一个版本的跳跃多少有些令人震惊,这主要是由于每个标准背后的哈希和加密算法中的缺陷所引起的。在 SSL 2.0 中,处理安全密钥的易受攻击的过程以及生成这些密钥所使用的算法中的缺陷,共同构成了一个明显不安全的协议。它没有为初始握手提供保护,使得交互容易受到我们在上一节中描述的中间人攻击。它使用了已知存在冲突的哈希算法(当两个不同的输入可以生成相同的哈希输出时),使得其密钥在功能上不安全。最后,其在 CA 验证过程中的怪癖使得大多数面向消费者的网站最初都无法支持该协议。所有这些加在一起说明了为什么在 3.0 版本中如此快速地进行协议的重大重新设计是必要的。

SSL 3.0 的故事比其前身要成功得多。尽管其加密密钥生成算法的部分内容完全依赖于不安全的哈希函数,但它也引入了当时的新标准 SHA-1。这个新算法没有已知的哈希冲突,从而增强了新协议的安全声明。它还引入了今天仍可见的 CA 支持模式,使得公共网站能够更广泛地采用和支持该协议。

虽然 3.0 版本并非没有缺陷。因为它至少部分依赖于一个已知碰撞的哈希算法,所以美国政府的联邦信息处理标准FIPS)并没有认为它足够安全,适用于高度关键或机密的应用。此外,尽管在协议设计(与加密漏洞相比)中的程序漏洞要少得多,但在 2014 年 10 月发现它容易受到一种相当复杂的程序攻击。这种漏洞使得 2015 年官方废弃该标准成为必要。这为它的继任者 TLS 敞开了大门。

TLS 作为新的标准

目前,全球安全的网络交互标准 TLS 最初是在 1999 年作为对当时标准 SSL 协议的改进而开发的。虽然它被设计为现有 SSL 3.0 协议的升级,但每个协议的设计差异足够大,使得两种方案之间的互操作性变得不可行。相反,作者将其作为更新、更安全协议的第一个版本发布。

从 SSL 的微小演变

当 TLS 被引入时,它确实代表了相对于 SSL 3.0 的改进。然而,主要的区别在于连接建立握手阶段交换的数据包的头部设计。底层算法和原理基本保持不变。事实上,如果不是因为头部不兼容,TLS 在发布时完全可以被命名为 SSL 4.0。

从第十二章,“互联网协议”中记住,正是这个头部不兼容问题使得 IPv4 和 IPv6 的互操作性成为不可能。解析标准化头部是任何主机之间共享交互的最基本第一步。两个版本协议头部之间的不匹配或不兼容将使这一步成为不可能,使得数据包无法读取。这通常会导致具有不同头部定义的协议版本之间相互兼容性受阻。

然而,TLS 确实有安全性的改进。首先,在 TLS 中,其加密算法的任何一部分都不完全依赖于一个已知碰撞的哈希算法。任何生成的密钥总是至少使用一些来自加密安全哈希算法的输入。

它还在连接尝试的手动阶段引入了对丢失或修改数据的额外保护。它是通过在连接协商的最后一步发送每条消息的安全哈希来实现的。这样,客户端和服务器都可以将结果与其自己的哈希值进行比较,并验证每个主机是否感知到相同的交互,从而消除了中间人攻击修改任何交换部分的可能性。然而,这仍然不能保证中间人攻击在尝试读取数据包时没有成功——只是没有成功修改。

前向保密

TLS 引入的最重要特性之一是前向保密的概念。这是在安全通信中的一个概念,通过在每个交互过程中使用唯一的会话密钥,两个主机可以保证即使某个秘密通过某种攻击被暴露,之前的交互仍然保持安全,即使它们被攻击者记录并存储。这是因为唯一的会话密钥被用作加密机制的一个额外输入,以保护消息。因此,仅凭私钥本身不足以解密之前发送的消息。

为了实现完美的保密,会话密钥必须通过非确定性函数随机生成。对于那些不了解的人来说,在计算机科学的背景下,一个函数被认为是非确定性的,当且仅当它可以在给出相同的输入时返回两个不同的结果。通常,这种非确定性是通过使用随机数生成器和一些其他临时外部状态来实现的。所以,如果我有,例如,GetNextPrime(startingIndex)方法,那么我们就会期望它是确定性的。对于任何数字n,在它之后只有一个下一个素数。每次尝试使用GetNextPrime(n)调用该方法都会产生相同的结果。与此同时,如果我有名为RollDie(sides)的方法,那么我合理地期望它是非确定性的。我可以传递相同的sides参数五次,并得到五个完全不同的结果。这就是非确定性算法的精髓。

这种非确定性的概念在生成会话密钥时很重要,因为它确保了后续尝试生成相同的会话密钥将失败。因此,当攻击者获取到服务器的私钥时,他们仍然缺少解码之前会话消息所需的关键组件。如果会话密钥在会话生命周期结束后仍然持续存在,那么这个缺失的拼图碎片将更容易找到。因此,确保前向保密的关键是,一旦会话终止,就销毁会话密钥。

加密消息的可靠性

数据包中的数据完整性对于其成功交付和解密至关重要。如果即使是一个比特位出现错误,解密消息的值将变得完全不可读。这种安全加密的特性,即消息的微小变化会导致其加密副本发生剧烈变化,反之亦然,这意味着在传输过程中发生错误时,从错误中恢复比不安全消息要困难得多。因此,TLS 利用所谓的消息认证码(MACs)

这些 MACs 用于验证已传输的数据是否以任何方式被修改,以及是否由接收者期望的主机发送。在 TLS 中,这些代码使用顺序包 ID 号作为额外的安全措施来生成。这个顺序标签为消息提供了额外的验证级别,从而增加了恶意行为者成功修改有效载荷及其相应 MAC 以成功发送欺诈数据包的复杂性。

这项额外的安全措施,以及用于前向安全的会话 ID,以及用于生成加密和解密共享密钥的多层安全哈希算法,为 TLS 提供了坚实的基础,并使其在近二十年的时间里在少数几个版本中可靠地使用。

在.NET Core 中配置 TLS

虽然我们已经深入探讨了想要建立安全通信的主机之间的交互性质,但到目前为止,我们的讨论还停留在相当高的层面。这样做是有原因的。在.NET Core 中,你永远不会直接编写 TLS 协议的具体步骤。作为一个在可移植运行时上执行的高级语言,.NET Core 并不是尝试自行实现这些操作的理想环境。而且,正如你可能已经猜到的,用于在主机之间促进低级套接字交互的 ASP.NET Core 库已经为我们实现了 TLS。我们只需要知道如何配置它并强制其使用。因此,虽然 TLS 的逐步交互对于任何网络工程师来说都很重要,但低级细节已经远远超出了本书的范围。

尽管在这个演示中,我们将编写一个简单的 Web API 项目来模拟 TLS 握手的交互过程。希望这样能通过在代码中给出具体的表示来巩固你心中的一些更抽象的概念。我们还将配置我们的应用程序以利用 HTTPS,这样你就可以看到在你的项目中提供此功能时将采取的步骤。

在.NET Core 中启用 HTTPS

我们首先要做的是使用.NET CLI 命令将我们的项目设置为一个新的 Web API 项目:

dotnet new webapi -n DemoTLS

现在,我们希望允许客户端使用 TLS 与我们服务交互。幸运的是,为了启用这一点,我们实际上并不需要做任何事情!敏锐的读者会记得,在我的上一章演示应用程序中,在第九章《.NET 中的 HTTP》使用 launchSettings.json部分,我让您从我们的应用程序的Startup.cs文件中删除了以下代码行:

app.UseHttpsRedirection();

嗯,结果证明,如果没有删除这一行,我们的应用程序的 Web 服务器将使用307 - Temporary Redirect状态码响应任何标准的 HTTP 请求,将客户端重定向到 HTTPS 交互的适当端口。我们在之前的演示中移除了这一点,以简化我们对第九章《.NET 中的 HTTP》中 HTTP 具体讨论,但现在让我们保留它并看看它的实际效果。

简单地运行您的应用程序,当您的默认浏览器打开时,您应该会注意到它被路由到为每个新的 Web API 项目配置的https://localhost:5001/api/values启动 URL。这没有什么特别有趣的地方,但现在打开您的浏览器开发者工具,导航到显示请求流量的标签页。我自己使用 Chrome,可以通过设置导航或简单地按F12来访问开发者工具。一旦进入网络标签页,有一个选项可以保留浏览器会话的网络日志,如下面的截图所示:

在选择该设置后,尝试直接导航到您 API 的不安全 URL http://localhost:5000/api/values,然后查看您在网络标签页中收到的响应。您应该看到浏览器自动将页面重新加载到安全的 URL,并在您的网络标签页中看到以下响应:

当您使用UseHttpsRedirection中间件配置启用 HTTPS 时,您的 Web 服务器会提供这种行为。对不安全 URL 的初始请求没有得到满足,相反,浏览器被赋予了使用安全 URL 的指令。这就是我们日志中的第二行所告诉我们的。它告诉我们,当我们尝试导航到不安全的 URL 时(日志中的发起者字段),服务器启动了对安全 URL 的导航(返回了 200 响应的那个 URL)。这为我们提供了大量的功能和可靠性,而无需我们做任何工作!

使用 HSTS 强制执行 HTTPS

现在,如果我们想通知我们的客户他们应该始终使用 HTTPS,而不是简单地信任他们遵循我们的重定向消息,我们可以通过利用HTTP 严格传输安全HSTS)来实现。这是一种交互机制,其中 Web 服务器可以通知通过 HTTPS 连接与之交互的任何客户端,所有后续交互都应该通过 HTTPS 进行。它通过带有Strict-Transport-Security键和一些任意值(例如过期时间戳)的 HSTS 头来传递此通知,在此之后,客户端可以重新尝试使用未加密的 URL 进行连接。

如果客户端遵守 HSTS,它将通过更新任何包含对未加密 URL 引用的缓存链接来响应头,使其现在引用安全的 URL。它还将防止任何用户与未加密的 URL 交互,即使无法建立到安全 URL 的连接。

现在,您可能想知道我们如何在我们的 Web 服务器中启用此功能,并在我们的响应中开始返回该头。希望您不会对它和最初启用 HTTPS 重定向一样简单感到惊讶。如果您查看检查应用程序是否正在开发环境中运行的if/else条件语句,您将看到以下内容:

if (env.IsDevelopment()) {
  app.UseDeveloperExceptionPage();
} else {
  // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
  app.UseHsts();
}

如您所见,我们已经在利用 HSTS。但我们看不到这么多,因为我们正在本地使用开发设置运行我们的应用程序。这完全是出于设计考虑。强烈建议您在开发环境中不要使用 HSTS,因为浏览器认为其头值及其相应的过期时间被认为是高度可缓存的。这可能会使开发期间的调试和故障排除特别困难。在您的本地环境中,该设置甚至默认不可用,因为中间件默认排除了本地回环地址。

HTTPS 端口配置

重要的是要注意,我们的应用程序成功重定向到我们的 HTTPS URL 的唯一原因是因为我们默认监听一个独立的端口来接收 HTTPS 请求。新 Web API 项目的launchSettings.json文件始终配置为监听 HTTP 和 HTTPS 端口。当在应用程序上调用时,UseHttpsRedirection中间件使用相同的端口。如果没有为我们的应用程序配置要监听的 HTTPS 端口,重定向中间件将无法解析,并且对未加密的 HTTP URL 的请求将被相应地处理和响应。

有多种方式来配置你的中间件应重定向用户到的端口,但在每种情况下,你仍然需要确保你也配置了你的 Web 服务器来监听该端口。这包括在你的应用程序的服务解析器中注册HttpsRedirectionOptions,在ConfigureServices(IServiceCollection services)方法中包含以下代码段,如下所示:

services.AddHttpsRedirection(options => {
    options.RedirectStatusCode = StatusCodes.Status307TemporaryRedirect;
    options.HttpsPort = 443;
});

或者,您可以在配置 Program.cs 文件中的 IWebHostBuilder 对象的 UseUrls() 方法时设置一个安全方案。这还有一个额外的好处,即同时配置您的 web 服务器在安全端口上监听,同时配置 UseHttpsRedirection() 中间件将用户重定向到它。

然而,正如您现在可能希望看到的,.NET Core 提供的 HTTPS 和安全交互的默认支持将使您的生活大大简化。这尤其适用于您必须实现任何类型的机密交互时,正如我们在下一章讨论 Web 应用程序中的身份验证和授权时将要做的。

信任您的开发证书

正如我们在本章关于 CA 的部分详细讨论的那样,任何通过典型浏览器通过 HTTPS 与资源交互的尝试,如果浏览器无法使用受信任的证书颁发机构验证服务器的身份,都会导致警告。由于我们的应用程序在开发和调试期间通常会在本地托管和运行,我们将无法访问由受信任的 CA 签署的证书。相反,我们将使用所谓的自签名证书。这正是我警告您不要信任的证书类型,因为您无法信任服务器的签名,直到您信任自己知道服务器的身份。然而,在这种情况下,当我们在本地的应用程序中进行开发并通过我们的浏览器或 REST 客户端测试响应时,我们确切地知道服务器是谁。那就是我们!

由于这种场景很常见,Microsoft 和 Windows 提供了一种简单的单次机制,用于在本地测试 HTTPS 时绕过不受信任的证书问题。每次您安装 .NET Core SDK 时,它都会包含一个 HTTPS 开发证书,该证书由您的 web 服务器在由 dotnet 运行时应用程序托管时颁发。要配置您的本地网络浏览器和其他客户端信任此自签名证书,您只需使用以下 CLI 命令将其注册到您的操作系统:

dotnet dev-certs https --trust

执行此命令会将包含在您的 .NET SDK 中的自签名证书添加到操作系统的受信任根证书存储中。然后,任何需要验证外部主机身份的应用程序都会使用此存储。通过使用该 CLI 命令将我们的开发证书存储在此存储中,我们可以在测试配置为使用 HTTPS 的应用程序时消除浏览器中的警告和警报。就这样,您就可以在 .NET Core 应用程序中利用 TLS 了。

TLS 握手模拟

为了使事情简单明了,并阐明本章的内容,我们编写的演示 API 的实际实现将突出 TLS 握手中的每个步骤。我已经将我的一个控制器重命名为TlsController,并将每个步骤实现为其自己的控制器操作。这样做的目的是反映您的 Web 服务器在用户通过 TLS 与您的应用程序连接时采取的概念步骤。

身份验证

如您所知,TLS 协议的第一步是建立服务器的身份。在这一步中,客户端只需通过向一个安全端点(利用 HTTPS 的端点)发送请求来启动一个安全连接,服务器就会响应一个 X.509 证书。为此目的,我们创建了一个简单的GET方法,名为initiate-connection,它返回一个证书,这里只是一个字符串:

[HttpGet("initiate-connection")]
public ActionResult<string> GetCertificate() {
    return "SSL_CERTIFICATE";
}

如您所知,与受信任的 CA 交互的责任在于客户端。因此,在这个时候,我们只是等待他们确认我们就是他们所说的那个人。一旦他们通知我们证书已被验证,我们就可以发送我们的公钥加密密钥,他们可以使用它来加密随后的握手协议请求。为此交互,我们有一个名为certificate-verified的方法:

[HttpGet("certificate-verified")]
public ActionResult<string> GetVerification() {
    return "PUBLIC_KEY_FOR_ENCRYPTING_HANDSHAKE";
}

就这样,我们准备好开始协商我们的加密方案了。

协商加密方案

现在我们已经给了他们一个公钥加密密钥,我们正在等待我们的客户使用它来加密他们的下一条消息。双方必须确定他们支持的加密算法,以便可以商定一个共同的强大算法。为此,我们给我们的客户一个名为 hash-algorithms 的端点,该端点将返回我们支持的所有安全算法,并允许他们选择一个他们也支持的算法来使用:

[HttpGet("hash-algorithms-requested")]
public ActionResult<IEnumerable<string>> GetAlgorithms() {
    return new string[] {
        "SHA-256",
        "AES",
        "RSA"
    };
}

一旦他们确定哪种算法最适合他们的需求和目的,他们就会通知我们。因此,我们已配置了另一种方法来处理来自我们客户的响应。然而,一旦这项工作完成,我们就可以使用他们选择的算法,结合我们的私钥,生成一个会话密钥,该密钥将作为我们之间通信会话数据传输段的共享秘密。因此,我们模拟 API 的最后一个方法使用一个静态的SessionService类来存储所选算法,然后使用它来返回一个共享密钥,该密钥是由我们的私钥和一个随机会话密钥生成的:

[HttpPost("hash-algorithm-selected")]
public ActionResult<string> Post([FromBody] string sharedAlgorithm) {
    SessionService.CurrentAlgorithm = sharedAlgorithm;
    return SessionService.GenerateSharedKeyWithPrivateKeyAndRandomSessionKey();
}

使用该方法,我们的会话就建立了,数据传输可以安全进行。希望通过将其分解为最基本、最高级步骤,每次您导航到使用https://方案的网站时,事情会变得更加清晰。更重要的是,现在您知道如何从您的.NET Core 项目中配置和强制执行 HTTPS 和 TLS。

摘要

在本章中,我们涵盖了大量的内容,同时成功地聚焦于一个非常狭窄的主题。我们首先从高层次上审视了在开放网络中保护两个主机之间通信所需的必要步骤。然后,我们探讨了这些步骤是如何实现的(至少在概念上)。首先,我们研究了验证你想要交互的主机身份的过程。我们了解了受信任的证书颁发机构,并学习了它们是如何通过检查签名的加密证书来由 Web 客户端验证服务器身份的。

在探讨这个主题时,我们还考虑了必须对这些 CA 投入多少信任,以及如果这种信任水平被违反,它如何使更广泛的公众面临极高的风险。我们还了解到,CA 是验证服务器身份所必需的,但在相互认证场景中验证客户端身份则不是必需的。

接下来,我们探讨了两个主机(其身份已得到充分验证)如何在会话过程中继续确保其通信的安全性。我们看到了对称加密和非对称加密是如何在传输任何应用数据字节之前就确保交互加密的。

接下来,我们探讨了这些用于保护通信会话的高级步骤是如何在过去的几年中被标准化并由安全协议所利用的。我们看到了安全漏洞如何使协议功能上不安全,以及后续版本或标准如何利用不断增长的工具集来领先于漏洞并随时间发展。

最后,我们探讨了所有这些在.NET Core 框架中的处理方式。我们看到了如何配置我们的 Web 服务以支持并依赖 TLS,以及如何在项目开发阶段避免使用 CA 的一些额外开销。所有这些都使我们能够考虑如何利用这些来允许在应用程序中进行身份验证和授权,这将在下一章中进行探讨。

问题

  1. HTTPS 代表什么?它与 HTTP 有什么区别?

  2. 什么是受信任的证书颁发机构?它们在验证服务器身份方面扮演什么角色?

  3. 简单认证和相互认证之间有什么区别?

  4. 对称加密和非对称加密之间有什么区别?

  5. 前向保密是什么?它是如何提供的?

  6. 非确定性函数是什么?为什么它在保护通信会话中很重要?

  7. 消息认证码是什么?它们是如何在 TLS 中提供可靠性的?

进一步阅读

若想了解更多关于建立安全的软件开发实践以及 SSL、TLS 等网络安全相关原则的信息,我推荐阅读 《网络安全 – 攻击与防御策略》,作者:Yuri Diogenes,Dr. Erdal Ozkaya,Packt Publishing。这是一本关于设计 TLS 等安全协议的工程师日常考量的启发式指南。你可以在 Packt Publishing 购买,链接如下:www.packtpub.com/networking-and-servers/cybersecurity-attack-and-defense-strategies

要了解每次将你的软件暴露在开放网络中时你承担的风险有多大,我也推荐阅读 《网络漏洞评估》,作者:Sagar Rahalkar,Packt Publishing。这本书更多地从 DevOps 工程师或系统工程师的角度来探讨这个问题,而不是从软件工程师的角度,但我认为拥有这种宏观理解是很重要的。这本书也是这方面的宝贵资源。你还可以通过 Packt 购买,链接如下:www.packtpub.com/networking-and-servers/network-vulnerability-assessment

第十四章:网络上的身份验证和授权

在上一章中,我们认真考虑了确保安全连接的传输层解决方案。有了这些知识,我们将在本章中探讨需要传输层安全的主机到主机交互类型。我们将退回到网络堆栈的层级,进入应用层,看看.NET Core 中是如何处理身份验证和授权的。我们将查看 HTTP 授权头支持的各种标准。然后,我们将查看一些广泛使用和广泛支持的开放源代码工具,用于身份验证。最后,我们将探讨如何在 C#应用程序中管理访问控制。

本章将涵盖以下主题:

  • HTTP 请求中有效授权头值支持的多种身份验证方案

  • 理解 OAuth 令牌以及如何利用它们进行用户身份验证和授权

  • 在.NET Core 应用程序中实现授权方案的策略和设置

技术要求

在本章中,就像在前几章中一样,你需要你的 IDE 或你选择的源代码编辑器,以及本章的示例代码 github.com/PacktPublishing/Hands-On-Network-Programming-with-C-and-.NET-Core/tree/master/Chapter%2014.

查看以下视频以查看代码的实际运行情况: bit.ly/2HY64XC

我们还将大量依赖 REST 客户端来对我们的演示 API 发起请求,所以请确保你已经安装了一个。我的两个推荐是 Postman,可以在以下网址找到: www.getpostman.com/downloads/,或者 Insomnia REST 客户端,可以在以下网址找到: insomnia.rest/

授权头

如果你曾经使用浏览器工具检查过登录到网站的外出请求,你可能会在你的网络检查器的请求头部分注意到一个标题为Authorization的头。这个 HTTP 协议中的标准头可以用来指定用于在请求的 URL 上验证和授权用户访问内容的各种方案。如果你不熟悉它,你可能会对你的软件中提供这些基本功能所拥有的选项的多样性感到惊讶。所以,让我们看看这些授权方案是什么,以及我们如何在我们的项目中使用它们。

授权与身份验证的比较

在我们探索Authorization头时,首先需要考虑的是它能够启用的网络安全中的两个功能。虽然它明确地被命名为Authorization头,但在实践中,这通常是一个误称。实际上,它既可以是一个Authorization头,也可以是一个Authentication头,并且通常它同时具备这两种功能。

现在,如果您对这两个操作之间的区别不太清楚,这听起来可能就像我在吹毛求疵。然而,它们各自提供了对健壮访问控制系统基本不同且基本必要的功能。当我们谈论Authorization头时,我们明确地是在谈论控制对软件资源的访问。那么,这些操作究竟是什么,它们如何促进受控访问架构的实现呢?

认证

简单来说,认证是验证用户对其身份声明的过程。您的用户正在对其身份做出声明,而您想要确保这个身份是真实的。这通常是通过让用户提供一些只有他们才能合理预期能够拥有的信息来完成的。在受控访问的软件中,这些信息通常是凭证集,例如用户名和密码组合,但它也可能是任何数量的事物,例如对以前地址的了解或家族关系。

认证凭证可以是任何用户应该合理预期能够知道,而其他人则不太可能知道的信息。一旦提供了这些凭证,就可以验证用户声明的真实性。因此,认证是关于验证用户身份的

授权

在访问控制硬币的另一面是授权。这是关于确定用户在系统中应该被允许做什么,从修改或添加系统存储的信息到最初访问它。正如其名所示,授权是一种预防措施,不允许用户执行操作,直到系统知道用户有权这样做。

在几乎所有受控访问系统中,授权依赖于认证。在你首先不能验证用户是他们所声称的人之前,你不能确定用户是否有权执行某个操作。尽管认证步骤相当直接,只有两种可能的结果:要么用户是他们所声称的人,在这种情况下认证成功,要么他们不是,在这种情况下认证失败。然而,在授权方面,为特定用户返回的权限可能由任何数量的底层规则、程序或条件决定。因此,虽然认证是关于验证用户的身份,但授权是关于确定用户被允许执行的操作。

因此,现在我们了解了认证和授权在访问控制中扮演的角色,让我们来看看使这些角色得以实现的 HTTP 机制。

授权头信息值

当用户尝试访问受控访问的网站时,他们可能会被提示从服务器提供Authorization头信息。为了使客户端能够使用服务器准备处理的认证机制进行授权,他们必须这样做。如果服务器仅设置了基本认证,但客户端尝试传递一个携带令牌,服务器将无法解析Authorization头信息,并将返回 401-未授权的状态码,无论客户端发送的令牌是否有效。

客户端(在这种情况下,不知道服务器支持的合法授权方案)必须首先被告知它应该使用哪种授权机制来与服务器进行认证。这是通过一个WWW-Authenticate响应头信息来完成的,该头信息应用于服务器的初始响应。此头信息用于向客户端指示服务器期望的Authorization协议。那么,HTTP 定义的Authorization协议究竟是什么,它们是如何工作的呢?

基本认证

这个特定的方案是我们第一个例子,展示了Authorization头信息在认证任务中的应用。在 HTTP 交互中,基本认证BA)方案用于从客户端简单传输用户名和密码到服务器进行认证和授权。它被称为基本认证,因为当服务器请求时,客户端只需传递凭据,无需额外的会话密钥或 cookie,也不需要在服务之间进行额外的握手来适当地设置头信息。

拥有 BA(基本认证),服务器通过返回一个具有以下结构的WWW-Authenticate头信息来指示认证方案:

WWW-Authenticate: Basic realm="{description of the access-controlled area}", charset="UTF-8"

在这里,realmcharset参数在技术上不是必需的,但可以为客户端提供有关如何或为什么他们必须传递其凭据的有用指导。在接收到此头后,客户端负责通过后续请求中的授权头传递其凭据。

用户信息 URL 段

我们实际上在第二章,DNS 和资源定位URL、域名和设备地址部分中见过第一种方法。如果一个服务器支持基本认证机制,客户端可以完全绕过Authorization头,并在 URL 本身中直接传输其凭据。

如您所记得,URL 的第一个段,在方案指定之后,实际上是一个可选段,用于访问凭据。所以,让我们想象有一个用户有权访问远程资源。为了这个例子,让我们假设他们的用户名是aesop_rock,密码是A3h4s9f0cjeC。在接收到指定基本认证的WWW-Authenticate方法后,客户端可以简单地将自己重定向到以下带有凭据的前缀的受控访问 URL:

 https://aesop_rock:A3hw4s9f0cjeC@test-domain.com/test/url

虽然这种格式符合有效 URL 的标准,并且允许在不传递认证头的情况下使用基本认证机制,但应不惜一切代价避免使用。将密码作为目的地 URL 的一部分以纯文本形式传输会带来重大的安全风险。因此,使用此username:password格式被认为是过时的,并且通常不被现代网络浏览器支持。然而,大量继续支持基于 URI 的认证技术的服务使得理解并考虑它是值得的。然而,按照规则,您永远不应该支持这种凭据机制的访问。在可能的情况下,您应该在通过电线传输请求之前,从用户信息段中清除任何位于第一个冒号之后的内容,以防止意外持久化用户密码的纯文本记录。

带有头值的基本认证

然而,当在请求头中传输凭据时,客户端会使用以下配置的Authorization头传输所有后续请求:

Authorization: Basic <base64-encoded-credentials>

在这里,凭据首先以用户名和密码通过单个冒号分隔,就像在 URL 格式中一样,然后进行base64字符编码。

对于使用此基本认证格式传递的凭据,第一个冒号始终被解析为用户名和密码字段之间的分隔符。因此,在基本认证系统中,用户名中永远不能包含冒号。

因此,如果我们有之前例子中的用户,用户名为aesop_rock,密码为A3h4s9f0cjeC,那么我们首先会按照以下方式格式化凭据:

j_public:A3r9f0cjeC

然后我们将字符进行base-64编码。将此应用于Authorization头,我们将有一个如下所示的头部值:

Authorization: Basic al9wdWJsaWM6QTNyOWYwY2plQw==

现在,我们有一个基本的认证Authorization头,当传输到服务器时,服务器将能够通过解码base-64编码的访问凭证的值来验证。

加密与编码

重要的是要注意,当我们修改我们的凭证时,我们只是在编码它们,而不是真正地加密它们。这里的区别在于,用户可以从base-64编码转换为纯文本,然后再转换回base-64编码,这一切都不需要任何类型的加密密钥来从一个格式转换到另一个格式。编码仅仅是字符表示的问题。这就像将一个基本的名词从英语翻译成西班牙语,然后再从西班牙语翻译回英语一样。意义并没有被掩盖;只是单词的表示方式不同。与此同时,加密是根据额外的、秘密的输入推导出一个新的、秘密的输入字符串,以完全掩盖原始消息的意义。

到目前为止,我只想指出一个显而易见的事实——任何涉及Authorization头的交互都必须通过使用 HTTPS 的安全连接进行。尽管这可能并不总是可行的,但强制执行这种更安全行为的简单最佳实践就是简单地不要配置您的应用程序来监听 HTTP 请求。我们很快就会看到我们还有哪些其他选项可以防止特权信息在网络上未加密、未安全地传输。现在,如果您能够完全避免支持 HTTP,我强烈建议这样做。

携带令牌授权

虽然基本认证只是提供了一个原始的用户名和密码凭证,但携带认证处理的是所谓的携带令牌。携带令牌只是一个安全令牌,它通知服务器,展示该令牌的用户(携带者)拥有令牌所赋予的凭证和权限。

由于从服务器的角度来看,此令牌用于验证特定用户,因此可以认为携带令牌仅仅是一种不同类型的访问凭证。然而,为了使这种情况成立,服务器必须基于这样的假设:持有特定安全令牌的人除非被授权持有,否则不会拥有它。因此,保护特定携带令牌的价值免受窃听或未经授权的访问与保护用户密码的价值同样重要。应格外小心,确保令牌永远不会以未加密的格式保存在您的服务器上。

Authorization头的上下文中,载体认证方案专门用于支持所谓的OAuth 令牌开放认证OAuth)是一个旨在明确支持基于令牌的认证机制,用于基于 Web 资源的访问控制的标准。它在互联网上极其常见。

OAuth 基础知识

如果你曾经使用谷歌账户、Facebook 或 Twitter 登录过网站,那么你之前已经使用过 OAuth。它是一种访问控制机制,旨在允许代理服务对受控资源进行用户身份验证和授权。因此,当你第一次访问一个新网站时,如果它允许你使用谷歌账户访问,那么这个新网站就是在将验证你的责任委托给谷歌。谷歌请求你的凭证并验证你,你隐式地通知谷歌为新网站提供一个令牌,新网站将认为这个令牌足以进行授权

这与我们在第十三章中学习的受信任证书机构CA)系统并没有太大的不同,即传输层安全性。在这个背景下,谷歌可以类比为 CA,而用户或令牌的持有者实际上是安全证书的持有者。因此,支持 OAuth 访问的系统是信任 OAuth 提供者(如谷歌、Facebook 等)作为认证的可靠来源。

使用载体令牌进行授权

当支持载体令牌时,在Authorization头中传递令牌的语法几乎与基本认证相同。这样做的方式如下:

Authorization: Bearer <token>

这里的主要区别在于,虽然基本认证凭证只是base64编码,但在载体认证方案中的令牌可以经过加密保护,如果客户端尝试通过不安全的传输机制传递令牌,这提供了一定程度的保护。此外,基本认证凭证将仅包含用户的访问凭证,而载体令牌可以包含更多或更丰富的信息或上下文。

虽然在基本认证方案中仅传递访问凭证就足以验证用户身份,但它将确定该用户可能拥有的任何权限的任务留给了服务器。然而,使用载体令牌时,令牌可能包含有关用户身份验证以及授予用户的权限的信息。因此,如果正确实现,载体认证机制可以更加灵活。

摘要认证

使用摘要认证方案,设计者明确寻求提供一种比未加密的基本认证方案更安全的认证实现。然而,实际上,它只提供了一套安全权衡,并且依赖于过时的哈希算法进行加密。尽管如此,它仍然具有许多优点,在某些情况下值得考虑实施。为了了解它引入的优点和缺点,以及何时可能想要使用它,让我们看看它是如何工作的。

摘要认证的 WWW-Authenticate 头

当用户尝试访问采用摘要认证的系统时,他们仍然会被提示使用带有WWW-Authenticate响应头的Authorization头。然而,与基本和载体认证方案返回的简单方案规范不同,摘要认证方案中的WWW-Authenticate头包含一系列客户端将用于生成其认证请求的值。

这些头值包括我们在基本认证方案中看到的作为可选参数的realm参数,它具有相同的作用,描述了认证空间。还有一个必需的nonce值(nonce 的意思是只打算使用一次;在这种情况下,用于一个认证会话),客户端使用它来生成其摘要凭据。这本质上充当了一个共享密钥或会话密钥。除了这两个必需的头参数外,还有几个额外的可选参数。

这些可选参数中的第一个是qop,即保护质量——一个如果包含,则规定用户在构建其认证响应时必须采取的额外步骤的规范。此可选字段指定的值可以确保客户端传输的凭据具有更高的安全性。当服务器未指定时,客户端使用的默认操作对于传输来说是最不安全的。有趣的是,远程过程调用RFC),该 RFC 标准化了摘要认证(RFC 7616),指定qop字段为必需,但其支持和实现因不同的应用服务器而异。

服务器还可以指定一个domain字段,该字段可以包含一个由空格分隔的 URI 列表,用于定义受保护空间。当提供此字段时,客户端可以使用该值来确定所有将被视为有效的相同认证信息的 URI。这在分布式系统中非常有用,其中一种资源负责认证,但用户必须将认证信息转发到另一个服务器以访问其受限制的内容。

服务器可以使用algorithm参数指定客户端必须用于其摘要响应的特定哈希算法。如果没有指定,则预期算法默认为 MD5,这在许多情况下被认为是不可靠的。因此,如果您在实现摘要认证方案时,我强烈建议您使用响应头中的algorithm参数强制执行安全的哈希算法。

此外,服务器还可以提供一个opaque值,其目的是由客户端精确地回显。这是一个有用的机制,通过它服务器可以将状态信息从资源传输到另一个通过认证的客户端。例如,如果服务器 A 负责为访问服务器 B 上的资源对客户端进行认证,那么服务器 A 可以通过opaque字段将访问详情传输到服务器 B。当正确实现时,客户端只需在其随后的请求中回显服务器 A 发送的任何值即可。

最后,还有一些可选参数用于认证交互的细微细节,例如charset,它用于指定客户端可能使用的支持的编码方案。还有一个userhash参数,它通知客户端服务器支持对凭据的密码组件进行哈希处理,同时也可以对用户名进行哈希处理。

综合来看,每个必需的和可选的参数都将生成一个类似于以下示例的WWW-Authenticate头,正如原始 RFC 中摘要认证所展示的:

WWW-Authenticate: Digest
    realm="http-auth@example.org",
    qop="auth, auth-int",
    algorithm=SHA-256,
    nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v",
    opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"

现在,为了了解这些头值是如何用来创建摘要凭据的,让我们看看客户端在这个过程中所扮演的角色。

摘要认证的授权头

一旦客户端从服务器接收到WWW-Authenticate头,他们就有责任使用服务器指定的哈希算法(如果没有指定,则为默认的 MD5)构建他们的摘要响应。为此,他们遵循一系列程序,使用哈希算法对密码进行单向哈希处理,然后对他们的用户名、服务器返回的 nonce 值和他们的密码的组合进行哈希处理。

用户使用他们的用户名、域和密码创建一个哈希值,每个值之间用冒号分隔。假设服务器指定了SHA-256,这将创建一个称为HA1的值,如下所示:

HA1 = SHA256(username:realm:password)

然后他们生成一个二级哈希值,称为HA2,它由 HTTP 方法(用于访问受限制的资源)和指定的 URI 组成,用冒号分隔并用指定的算法进行哈希处理:

HA2 = SHA256(method:resourceURI)

最后,这两个值都会与服务器发送的nonce值一起再次进行哈希处理,以生成Authorization头中的response参数:

response = SHA256(HA1:nonce:HA2)

根据服务器指定的qop值,这三个值的具体输入可能会有所不同,但总体交互是相同的。一旦完成所有这些操作,客户端就会向服务器返回一个包含响应值以及它自己的可选参数数组(以及如果服务器最初发送了的话,回声的透明参数)的Authorization头,如下所示:

Authorization: Digest username="Mufasa",
   realm="testrealm@host.com",
   uri="/dir/secured.html",
   response="6629fae49393a05397450978507c4ef1",
   nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v",
   opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"

这种方法的优点是用户永远不会以明文形式发送他们的密码。原因是,由于服务器应该知道用户的密码,它可以使用相同的散列算法,使用相同的输入,并对照Authorization头中的response参数来确认结果。

当然,你可能已经看到了这种实现固有的风险。为了服务器能够重现相同的response参数,它必须能够访问存储在某个地方的用户的明文密码。由于这通常是一个糟糕的想法,更现代的实现通常会首次用户创建一组新凭证时,将整个 HA1 值存储在安全的数据库中。这项技术仍然允许服务器产生SHA256(HA1:nonce:HA2)的响应计算,同时消除了对 HA1 的明文输入的需要。然而,这也意味着每次更改域值时,都需要为系统上的每个用户重新计算 HA1。

此外,即使存储HA1值也应该被认为相对不安全,因为它被客户端用来创建新的响应并访问数据库仍然会危害底层系统。恶意行为者仍然能够生成一个欺诈请求,即使没有受害者的明文密码。使用HA1的最大好处是,在服务器被入侵的情况下,它至少可以保护用户的原始密码不被泄露,从而在安全漏洞事件中最大限度地减少对用户的风险。

尽管摘要认证方案旨在为系统访问的认证步骤提供更高的安全性,但它引入了自己的复杂性和安全担忧。因此,它使用得较少。

HTTP 源绑定访问

虽然不是一个完全实现的标准,但HTTP 源绑定访问HOBA)认证方案代表了访问控制机制设计中的一个令人兴奋的范式转变。我们之前讨论的所有先前形式的认证都是围绕用户提供凭证(通常是用户名和密码的形式)以获取系统访问权限。在 HOBA 中,永远不会传输凭证。相反,客户端(通常是特定的网络浏览器)会持续一个数字签名,该签名在挑战-响应方案中提供给服务器。

在 HOBA 机制下,当客户端第一次尝试访问系统时,该特定客户端必须向服务器注册。在注册过程中,一旦客户端通过传统方式被认证,客户端就会创建一个客户端公钥CPK)和一个注册到特定源/域的私钥,用于受限制的资源。然后,CPK 被提供给服务器,服务器将 CPK 分配为客户端的数字签名。正是这个 CPK 用于验证和授权用户随后的请求,以便访问指定域内的受限制资源。

这提供了主要的安全优势,即无需在服务器上存储用户凭据的记录,即使是以安全散列格式存储。然而,这种做法的缺点是,CPK 通常由浏览器在客户端机器的本地存储中存储。这意味着每次用户从不同的机器(或者甚至同一台机器上的不同网络浏览器)访问服务器时,客户端都必须再次将新的用户代理注册到服务器。幸运的是,RFC 草案,该草案定义了 HOBA 规范,明确允许服务器将多个 CPK 注册到单个客户端账户。随着对这种身份验证方案的支持增长以及规范的正式标准化,我预计其他身份验证模式将很快被 HOBA 所取代。

授权令牌

最常用的授权机制之一是携带令牌。发行这些令牌最常见的方式是使用 OAuth。然而,尽管携带令牌通常被称为 OAuth 令牌,但实际上,它们只是由 OAuth 提供的。令牌本身可以由任何标准定义,或者根本不定义标准。让我们看看这种令牌与令牌发行者之间的关系如何展开。

OAuth 令牌提供

如我之前提到的,OAuth 是提供客户端有效身份验证令牌的标准。这种做法的正式标准实际上相对简短且高度概括,允许在具体实现中具有很大的灵活性。它最初被设计为允许第三方应用程序代表用户访问目标应用程序。

在 OAuth 交互方案中,客户端必须首先注册为资源服务器的消费者。在这个上下文中,资源服务器简单地是指任何包含受限制资源的服务器,并依赖于代理服务器来处理其访问控制。在注册过程中,资源服务器授予客户端未来访问受限制资源的相关权限。

一旦客户端注册,每当该客户端尝试在未来访问资源服务器时,它都会被提示获取一个 访问令牌。然后,客户端必须从 授权服务器 请求访问令牌。授权服务器是资源服务器已注册为其代表的服务器,并实现了 OAuth 交互标准。客户端提供在注册步骤中由资源服务器给予的任何访问凭证。

在验证访问凭证后,授权服务器返回一个访问令牌。OAuth 2.0 的访问令牌响应构成了大多数用户日常与标准交互的基础。每当一个有效用户成功请求令牌时,他们都会收到包含以下属性的响应体:

  • access_token:此属性是必需的,原因很明显。它是将被返回给资源服务器的令牌字符串的值。

  • token_type:这表示资源服务器将期望的具体令牌授权机制。在几乎所有情况下,此值将简单地是 bearer。

  • expires_in:虽然此属性不是必需的,但强烈建议使用。访问令牌应该是短暂的,OAuth 规范建议大多数令牌的最大生存期仅为 10 分钟。这是一个安全预防措施,以减少暴露的访问令牌被恶意行为者使用的风险。

  • refresh_token:此属性仅在访问令牌有到期时间时使用,即使在这种情况下也是可选的。它指定了一个刷新令牌,客户端可以使用它从授权服务器请求新的访问令牌。

  • 作用域:此字段用于通知用户,如果他们所授予的权限集合比他们最初请求的权限集合更少,则使用。例如,如果我请求一个具有读取、写入和更新权限的访问令牌,但服务器只限制我的访问权限为只读,则此字段将用于指定读取权限。

最后,客户端使用访问令牌作为其 Authorization 携带令牌向资源服务器发送请求。

令牌生成

虽然 OAuth 规定了客户端与相应资源及授权服务器之间的交互,但它并未说明 access_token 字段是如何实际生成的。相反,这一细节留给了每个实现或依赖 OAuth 的服务器。那么,这些令牌是如何生成和验证的呢?

持久令牌

令牌仅仅是随机生成的字符串的情况并不少见。在这种令牌生成机制中,授权服务器和资源服务器共享一个数据库,其中包含所有相关的用户访问凭证和权限。在预定的时间表上,随机生成令牌并将其与数据库中的每个用户关联。然后,在成功认证后,授权服务器查找给定用户的当前令牌并将其作为访问令牌返回。在授权后,资源服务器随后查找数据库中具有相同令牌值的用户并验证其访问权限,并可以查找他们可能拥有的任何权限。

这种方法的明显缺点是需要共享数据库,并且在认证服务器和资源服务器之间在该数据库中存在并发性。这种方法并不特别适合可能同时运行数十个资源或授权服务器实例的云部署系统,这些实例具有多个数据库实例。它也不特别适合极高流量的应用程序,因为多次数据库查找可能会极大地影响应用程序的响应速度。然而,如果你的系统相对集中且处理量合理,那么在实现访问令牌系统时,这绝对不是一种糟糕的选择。那么,替代方案是什么呢?

自编码令牌

可能最稳健且最有用的令牌生成机制之一是自编码令牌。自编码令牌是指其主体包含所有必要信息,以便资源服务器授权持有者的令牌。因此,当用户通过授权服务器认证并授予令牌后,资源服务器可以简单地检查令牌的主体以确定授权请求的成功以及用户拥有的任何权限或声明。由于上下文完全包含在令牌中,资源服务器无需访问共享数据库来验证用户或其权限,从而在分布式环境中节省了资源编排。

可能最广泛使用且广泛支持的自我编码令牌方案是JSON Web Token(JWT)。JWT 通过在编码的 JSON 对象中提供一组作为属性声明的持有者声明来实现自编码令牌机制。JWT 令牌的结构由三个独立的部分组成,这些部分连接在一起,并由句点分隔。

这些组件中的第一个是头部,它是一个base-64编码的字符串,其中包含作为 JSON 对象表示的已知头部参数。这指定了用于签名的算法(我们稍后将讨论)和令牌类型,通常是 JWT。

接下来是正文,它是一个base-64编码的 JSON 对象,包含令牌持有者拥有的完整声明列表。在这个对象中有一些必需的参数,例如 sub(主题,或持有者的名称)和 iat(发行时间)。然而,由于令牌正文的规范完全由资源服务器自行决定,因此可以添加任何数量的声明,并赋予任何给定的值。

JWT 的最后一部分是验证签名。这是资源服务器如何知道令牌来自其关联的授权服务器的方式。验证签名是一个加密的散列消息认证码HMAC),它是由前两个部分的base-64编码字符串以及一个密钥组合而成的。如果令牌的正文或头部的任何部分被修改,那么令牌授权的验证步骤将不会产生相同的 HMAC,资源服务器可以找出令牌是否被篡改并且无效。然而,为了使这起作用,资源服务器和授权服务器必须在两者之间共享密钥,并使用它来签署和随后验证在两者之间传递的任何令牌。

无论令牌是否被确定为有效,令牌的前两个组件仅仅是base-64编码的。因此,它们可以很容易地被任何感兴趣的一方解码和读取。出于这个原因,永远不要在 JWT 的正文以明文形式发送私人信息。如果必须发送机密信息或访问凭证,它们应该以安全加密格式发送,当可能时使用单向哈希,否则使用安全的可逆加密。

.NET Core 中的授权

现在我们已经探讨了 OSI 堆栈应用程序层授权的方方面面,是时候看看我们如何可以利用这些特性在我们的应用程序中。幸运的是,正如强制执行 SSL 一样,在我们的 Web API 上启用授权方案主要是配置问题,而不是其他。为了这个演示的目的,我们将创建一个应用程序,它将作为我们的资源服务器和认证服务器。而且,一如既往地,我们将为了简单起见在错误处理和健壮的应用程序设计方面采取一些捷径。

AuthorizeAttribute

我们首先需要确定哪些资源需要授权访问。稍后我们将处理授权用户的实际过程。为了指定受限资源,我们应用AuthorizeAttribute。这个属性被指定为适用于方法或类。这意味着我们可以直接将其应用于我们应用程序的受限端点或整个控制器。因此,让我们看看每种方法的影响。首先,让我们使用 CLI 命令创建我们的应用程序:

dotnet new webapi -n AuthSample

然后,我们将导航到ValuesController.cs文件,将其重命名为AuthController.cs,然后修改如下:

[Route("api/[controller]")]
[ApiController]
public class AuthController : ControllerBase {

  [HttpGet("secret")]
  public ActionResult<string> GetRestrictedResource() {
    return "This message is top secret!";
  }

  [HttpPost("authenticate")]
  public void AuthenticateUser([FromBody] Credentials creds) {
  }
}

我们希望允许用户在认证时POST他们的凭证,但他们将简单地GET我们的绝密信息。在这里,我创建了一个简单的Credentials类,作为我们POST请求的消息体。这只是为了方便将usernamepassword字符串封装在单个容器类中。现在,让我们看看应用Authorize属性的不同方法。一种方法是将特定的端点显式标记为需要授权。在这种情况下,这将是我们GetRestrictedResource()方法。因此,我们可以在[HttpGet]属性之上或之下应用该属性,然后就这样!让我们看看以下代码:

[Authorize]
[HttpGet("secret")]
public ActionResult<string> GetRestrictedResource() {
    return "This message is top secret!";
}

然而,我相信你可以想象一个场景,其中控制器有数十个端点,每个端点都需要授权。在这种情况下,你可以简单地将Authorize属性应用于控制器类本身,如下所示:

[Route("api/[controller]")]
[ApiController]
[Authorize]
public class AuthController : ControllerBase {

通过将属性应用于控制器,它将自动应用于该控制器中定义的每个端点。当然,在我们的当前控制器中,我们需要某种方式来指定我们的AuthenticateUser()方法不需要授权(毕竟,如果用户必须首先被授权,他们如何成为授权用户呢?)。为此,我们可以用方法级别的AllowAnonymous属性覆盖控制器级别应用的Authorize属性:

[HttpPost]
[AllowAnonymous]
public void AuthenticateUser([FromBody] Credentials creds) {
}

这个属性将始终覆盖应用于当前方法的任何Authorize属性。因此,始终确保你只在绝对必要时应用AllowAnonymous属性是非常重要的。

授权中间件

现在我们已经指定了哪些资源需要使用授权来访问,是时候看看在我们的代码中这个授权步骤看起来是什么样子了。对于这个应用程序,我们将利用 JWT 令牌认证。为了利用这一点,我们将进入我们的Startup.cs文件,并修改我们配置的服务以使用适当的认证方案。

由于其开源分布和广泛的支持,Microsoft.AspNetCoreMicrosoft.IdentityModel命名空间直接支持 JWT 令牌库。这将使定义我们的认证行为变得更加容易。我们将在IServicesCollection上调用AddAuthentication()方法,并使用JwtBeareDefaults库类应用默认的 JWT 令牌认证方案。让我们看看以下代码:

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)

这个AddAuthentication()方法是你可以在给定的Authorize端点上定义任何自定义访问策略的地方。例如,如果你想为基于角色的认证方案定义一个策略,你可以通过将其作为选项添加到AddAuthentication()中间件来定义一个需要Manager权限的策略:

services.AddAuthorization(options => {
  options.AddPolicy("RequireManagerRole", 
    policy => policy.RequireRole("Manager"));
});

然后,对于任何你想要限制管理员访问的端点,只需在Authorize属性中定义该访问策略,如下所示:

[Authorize(Policy = "RequireManagerRole")]

当你有许多不同的访问策略并且需要一次性应用许多策略时,这可能会变得很麻烦。在这种情况下,你通常会发现自己正在编写自己的IServicesCollection类的扩展方法,并直接调用它们。然而,对于这个例子,我们已经有一个来自Microsoft.AspNetCore.Authentication.JwtBearer命名空间的扩展方法来定制我们的授权。

只需将我们的认证方案定义为 JWT 默认值,这意味着任何带有Authorize指令的端点都会调用.NET 的 JWT 验证代码。我们不需要为我们的应用程序编写任何其他代码来从Authorization头中提取令牌值,将其签名与我们的私钥记录进行验证,然后批准请求继续到方法,或者以 401 响应拒绝它。所有这些操作都是通过在AddAuthentication()方法中定义JwtBearerDefaults.AuthenticationScheme来完成的。

我们要采取的唯一剩余步骤是定义什么应该被视为有效的令牌。我们将通过使用AddJwtBearer()扩展方法来完成这项工作。此方法允许我们定义一个动作委托来配置我们希望我们的Authentication代码在验证令牌时使用的选项。对于这个示例代码,我已经将签名密钥、令牌发行者和令牌受众的定义移动到了一个名为SecurityService的静态实用工具类中。这样做只是为了使获取相同的值以供我们的令牌验证选项和令牌生成代码更容易,我们将在稍后查看这些代码。但如果你好奇,这个类所做的只是为我们的令牌的一些关键组件返回一致的价值:

public static class SecurityService {
  public static SymmetricSecurityKey GetSecurityKey() {
      string key = "0125eb1b-0251-4a86-8d43-8ebeeeb39d9a";
      return new SymmetricSecurityKey(Encoding.ASCII.GetBytes(key));
  }

  public static string GetIssuer() {
      return "https://our-issuer.com/oauth";
  }

  public static string GetAudience() {
      return "we_the_audience";
  }
}

因此,通过使用这个类来生成我们的共享对称密钥、发行者和受众,我们可以配置令牌的这些方面,使其成为授权有效用户所必需的。只需将它们应用到JwtBearerOptionsTokenValidationParameters类中,如下所示:

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
  .AddJwtBearer(options => {
    options.TokenValidationParameters = new TokenValidationParameters {
      IssuerSigningKey = SecurityService.GetSecurityKey(),
      RequireSignedTokens = true,
      ValidateActor = false,
      ValidateAudience = true,
      ValidAudience = SecurityService.GetAudience(),
      ValidateIssuer = true,
      ValidIssuer = SecurityService.GetIssuer()
    };
  });

通过这种配置,你可以看到我们的应用程序负责根据它包含的声明来确定携带令牌的有效性。但是,多亏了 JWT 实用工具库中可用的扩展,定义这些参数变得非常简单!

最后,就像在ConfigureServices方法中配置的任何中间件一样,我们需要告诉我们的应用程序通过在Configure方法中添加以下行来利用它:

app.UseAuthentication();

现在,如果你运行应用程序并尝试访问/auth/secret端点,你会收到一个 401 响应,其中WWW-Authenticate消息指示预期的认证方案:

现在,剩下的只是给我们的用户提供一个令牌。让我们看看如何在我们的AuthController类中使用 JWT 库来做到这一点。

生成令牌

在我们的/auth/secret端点安全地锁在authorize属性后面之后,我们需要一种方法来验证和授权用户。为此,我们将使用AuthenticateUser()方法,该方法配置了AllowAnonymous属性以允许任何人尝试登录。我们首先需要的是一个用户列表。为此,我创建了一个简单的username:password组合字典,存储在user_vault.json文件中,我们可以通过静态的UserVault类来访问它。然后,UserVault类公开了一个简单的方法来检查我们的用户数据库中是否存在username:password组合。所以,让我们让user_vault.json定义如下:

{
    "aladdin": "open_sesame",
    "dr_suess": "green_eggs_and_ham",
    "jack_skellington": "halloween"
}

这为我们提供了三个有效的用户,我们可以用它们进行测试。我们的UserVault类允许我们通过首先使用静态构造函数初始化来检查这一点:

private static Dictionary<string, string> _users { get; set; }
static UserVault() {
  try {
    using (var sr = new StreamReader("user_vault.json")) {
      var json = sr.ReadToEnd();
      _users = JsonConvert.DeserializeObject<Dictionary<string, string>>(json);
    }
  } catch (Exception e) {
    throw e;
  }
}

然后,使用我们user_vault.json文件的内存表示,我们可以通过ContainsCredentials()方法检查我们的私有字典中是否存在任何username:password对:

public static bool ContainsCredentials(string userName, string password) {
  if (_users.ContainsKey(userName)) {
    string storedPassword;
    if(_users.TryGetValue(userName, out storedPassword)){
      return storedPassword.Equals(password);
    }
  }
  return false;
}

因此,现在我们能够检查用户是否在我们的用户数据库中,让我们继续定义我们应用程序的认证。我们首先需要做的是构建我们的授权代码将期望从任何我们签发的令牌中获取的关键细节。因此,我们需要使用我们配置认证中间件期望的相同安全密钥。我们还将使用ClaimsIdentity类给用户一个基本的Identity

[HttpPost]
[AllowAnonymous]
public ActionResult<string> AuthenticateUser([FromBody] Credentials creds) {
  if (UserVault.ContainsCredentials(creds.UserName, creds.Password)) {
    var key = SecurityService.GetSecurityKey();
    var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
    var identity = new ClaimsIdentity(new GenericIdentity(creds.UserName, "username"));

在此代码中,SigningCredentials类是一个简单的包装类,它处理使用指定的哈希算法(在这种情况下为 HMAC SHA-256)对给定的安全密钥进行哈希处理的细节。然后,这个类被用来生成结果 JWT 令牌的签名密钥组件。一旦我们有了这个,我们就可以构建我们的 JWT 令牌。为此,我们将使用JwtSecurityTokenHandler类的实例。这本质上是一个工厂类,用于使用传递给它的配置详细信息生成格式良好的 JWT 令牌。

应用用户声明

为了演示的目的,我们将对我们的令牌应用一些任意的声明,这样我们就可以检查结果并看到这些声明是如何在格式良好的令牌中应用和显示的。为此,我们有一个简单的辅助类,它返回一个基本的Claim实例列表:

private IEnumerable<Claim> GetClaims() {
  return new List<Claim>() {
    new Claim("secret_access", "true"),
    new Claim("excellent_code", "true")
  };
}

因此,现在我们已经定义了所有的声明,我们可以将这些声明应用到我们为用户创建的ClaimsIdentity上,如下代码所示:

identity.AddClaims(GetClaims());

这将使我们能够根据用户在特定情况下的权限来限制对单个资源的访问。对于任何给定的请求,HttpContextUser属性是一个ClaimsPrinciple的实例。因此,我们可以通过HttpContext.User.Claims属性检查用户的声明。

当我们使用授权过滤器保护端点时,由认证机制(在这种情况下,JWT)指定的任何用户声明都将应用于HttpContext.User.Claims属性。考虑到这一点,我们可以通过检查用户的Claims属性并确认它们包含我们正在寻找的声明来限制对我们的秘密端点的访问。只需修改你的GetRestrictedResource方法,检查用户声明的Type参数,并确认用户至少有一个由GetClaims返回的有效声明类型,如下所示:

[HttpGet("secret")]
public ActionResult<string> GetRestrictedResource() {
    var validClaims = GetClaims().Select(x => x.Type);
    var userClaims = HttpContext.User.Claims.Select(x => x.Type);
    if (validClaims.Intersect(userClaims).Count() < 1) {
        return StatusCode(403);
    }
    return "This message is top secret!";
}

有了这些,我们已经定义了用户,指定了他们的声明,并将我们的资源限制为具有那些声明的用户。由于我们的签名凭证配置为使用与资源服务器相同的对称密钥,我们准备好构建我们的令牌。这可以通过JwtSecurityTokenHandlerSecurityTokenDescriptor类轻松完成:

var handler = new JwtSecurityTokenHandler();
var token = handler.CreateToken(new SecurityTokenDescriptor() {
  Issuer = SecurityService.GetIssuer(),
  Audience = SecurityService.GetAudience(),
  SigningCredentials = signingCredentials,
  Subject = identity,
  Expires = DateTime.Now.AddMinutes(10),
  NotBefore = DateTime.Now
});

现在,我们只剩下将令牌写入我们的输出,供用户在后续请求中应用。如果你想实现一个完全符合 OAuth 标准的服务器,你的响应体和异常处理需要遵循 OAuth 2.0 定义的标准,你的令牌作为更大响应体的一部分返回。由于我们只是在演示高级工作流程,我将这项额外的研究留给你。相反,我们的输出将只是原始的 JWT 令牌。所以,通过我们刚刚完成的所有操作来填补空白,我们的认证方法外壳应该看起来像这样:

[HttpPost]
[AllowAnonymous]
public ActionResult<string> AuthenticateUser([FromBody] Credentials creds) {
  if (UserVault.ContainsCredentials(creds.UserName, creds.Password)) {
    ... // Build and generate JWT token
    return handler.WriteToken(token);
  } else {
    return StatusCode(401);
}

现在,我们准备将所有这些整合起来。运行应用程序,向认证端点发送一个请求,使用user_vault.json中的任何凭证,你应该会收到一个令牌:

接下来,向秘密端点发送一个请求,提供你新检索到的令牌。为此,转到 Postman(或 Insomnia)的授权选项卡,并将下拉列表中的授权类型设置为 Bearer Token 选项。然后,将你新获得的令牌粘贴到输入框中,并发送请求。如果你遵循了这里的所有步骤,你应该会收到带有 200 状态码的秘密消息:

就这样,你已经在.NET core 中实现了并应用了一个完整的认证和授权方案,这需要你最少量的自定义代码。

在我们继续之前,然而,我想明确指出,这里使用的编码实践仅用于演示目的我的目标仅仅是展示.NET Core 中认证/授权框架的概念流程和基本模式。如果你发现自己正在任何应用程序中实现用户访问,你有责任确保他们的私有访问凭证的安全,这一点绝不能轻视。

话虽如此,我们现在可以探讨其他方法来提高我们应用层网络代码的性能和灵活性。在下一章中,我们将仔细研究.NET Core 中的缓存策略和模式。

摘要

在本章中,我们涵盖了应用层认证和授权的各个方面。首先,我们学习了认证和授权之间关键但细微的区别。我们研究了允许应用程序执行这两项任务以控制对受限资源访问的 HTTP 标准头。然后,我们学习了标准Authorization头支持的每个有效认证方案。

我们看到了基本认证带来的安全风险和实现上的便捷。我们研究了载体令牌认证如何减轻与基本认证相关的某些安全风险,而无需增加太多复杂性。最后,我们学习了摘要认证机制的复杂性和细微差别。在继续之前,我们还花时间考虑了未来可能通过类似 HOBA 方案来处理授权的方式。

接下来,我们深入探讨了载体令牌。我们了解了 OAuth 标准如何定义一个用于访问和提供令牌的交互机制。然后,我们研究了这些令牌如何由资源服务器生成和利用。最后,我们通过学习如何在.NET Core 中实现这些功能,将所有这些内容串联起来。现在,我们准备通过缓存来查看性能改进和弹性策略,这些内容将在下一章中进行探讨。

问题

  1. 认证和授权之间有什么区别?它们是如何应用于基于 Web 资源的访问控制的?

  2. 对于授权 HTTP 头,哪些是有效的认证机制?

  3. HOBA 是什么?它相对于其他认证方案的主要优势是什么?

  4. 载体令牌是什么?它是如何用于认证或授权的?

  5. OAuth 是什么?它与标准授权技术有何关联?

  6. 什么是自编码令牌?

  7. JWT 是什么?它是如何使用的?

进一步阅读

关于现代认证技术,我强烈推荐阅读Mastering OAuth 2.0一书,作者是Charles Bihis。这是一本相对简短的读物,但提供了对标准的广泛探索。您可以通过 Packt 在这里找到它:www.packtpub.com/application-development/mastering-oauth-2

或者,如果您打算在自己的软件中利用 OAuth 系统,并希望对这个主题有更实际的了解,我推荐阅读由Adolfo Eloy Nascimento所著的《OAuth 2.0 烹饪书》。您也可以通过 Packt 购买,链接如下: www.packtpub.com/virtualization-and-cloud/oauth-20-cookbook.

第十五章:分布式系统的缓存策略

在上一章中,我们学习了将安全性应用于托管在网络上的应用程序的常见模式。在本章中,我们将探讨通过建立中间缓存来提高我们网络软件性能的各种方法。我们将看到使用缓存来持久化频繁访问的高可用数据如何使我们获得这些性能提升。我们将探讨缓存是什么以及它可以以各种方式被使用的各种方法。然后,我们将对网络缓存的常见且复杂的架构模式进行彻底的审查。最后,我们将演示如何在应用架构的各个级别使用缓存,以在合理的开发人员努力和延迟减少之间达到我们的目标。

本章将涵盖以下主题:

  • 通过缓存常见请求的结果所能获得的潜在性能提升

  • 缓存会话数据的基本模式,以实现与应用程序并行部署的可靠交互

  • 了解如何在我们的 .NET Core 应用程序中利用缓存

  • 各种缓存提供者的优缺点,包括分布式网络托管缓存、内存缓存和数据库缓存

技术要求

本章将包含示例代码,以演示我们讨论的每种缓存策略。要使用该代码,您需要您信任的 IDE(Visual Studio)或代码编辑器(Visual Studio Code)。您可以从本书的 GitHub 仓库直接下载示例代码:github.com/PacktPublishing/Hands-On-Network-Programming-with-C-and-.NET-Core/tree/master/Chapter%2015

查看以下视频以查看代码的实际运行情况:bit.ly/2HY67CM

我们还将使用 Windows Subsystem for Linux 在本地机器上托管基于 Linux 的 Redis 缓存服务器。当然,如果您已经在运行类似 OS X 或 Linux 发行版的 *nix 系统上运行,您不必担心这一点。或者,当您在本地运行应用程序时,如果您没有管理员权限或对学习 Redis 缓存服务器不感兴趣,您可以稍微修改示例代码以使用不同的缓存提供者,您将在本章的学习过程中了解到。然而,我建议您熟悉 Redis 缓存,因为它被广泛使用,并且是大多数情况下的优秀选择。如果您选择这样做,您可以在以下链接中找到安装 Linux 子系统的说明:docs.microsoft.com/en-us/windows/wsl/install-win10

一旦完成,您可以在以下链接中找到安装和运行 Redis 的说明:redislabs.com/blog/redis-on-windows-10/

为什么需要缓存呢?

虽然它给负责实施它的开发者带来了额外的复杂性,但一个精心设计的缓存策略可以显著提高应用程序的性能。如果你的软件严重依赖网络资源,最大限度地利用缓存可以在更快的性能上节省用户的时间,并在降低网络开销上为公司节省金钱。然而,知道何时缓存数据,何时这样做不合适,对于开发者来说并不总是直观的。那么,你何时应该利用缓存策略,为什么?

理想的缓存场景

假设你有一个基于季度销售数据生成报告的应用程序。想象一下,它必须从几个不同的数据库中提取数十万条记录,每个数据库的响应时间各不相同。一旦获取了所有这些数据,它必须在返回的记录上运行广泛的聚合计算,以生成报告中显示的统计数据。此外,这些报告是由数十甚至数百名不同的业务分析师在一天内生成的。大多数情况下,每个报告都会汇总相同的信息,但有些报告的结构是为了突出不同分析师不同业务关注点的数据。对这个问题的天真方法就是按需访问请求的数据,并可靠地返回结果,但响应时间极差。但这一定是必须的吗?

我刚才描述的实际上是一个设计和实施缓存策略的理想场景。我描述的是一个依赖于外部资源或过程拥有的数据的系统,这意味着往返延迟可以被消除。我还指出这是季度销售数据,这意味着它可能最多每三个月更新一次。最后,我提到有用户每天数十次甚至数百次使用这些远程访问的数据生成报告。对于一个分布式系统来说,几乎没有比在按需应用程序操作中预先缓存远程数据以实现更快和更可靠的访问更明显的情境了。

驱动缓存使用的情境不会总是那么明确,但总的来说,这三个标准将是一个强有力的指导,告诉你何时应该考虑使用缓存。但始终要问自己是否满足以下任何一个条件:

  • 访问应用程序托管环境外的资源

  • 访问那些更新频率不高的资源

  • 访问应用程序频繁使用的资源

如果满足上述任何一种情况,你就应该开始思考通过缓存可能带来的好处。如果所有这些条件都满足,你可能需要提出一个强有力的理由来解释为什么你不应该实施缓存策略。当然,要理解为什么这些标准使缓存变得必要,你必须首先确切地了解缓存是什么,同样重要的是,它不是什么。

数据缓存的原则

简而言之,缓存不过是一个中间数据存储,它可以比缓存数据来源更快地提供其数据。缓存可以提供这些速度提升的原因有很多,每个原因都需要单独考虑,所以让我们看看几个例子。

缓存长时间运行的查询

如果你有任何正式的数据库设计经验,你很可能知道关系数据库倾向于高度规范化的设计,以消除重复数据存储,并提高稳定性和数据完整性。这种规范化将数据记录分解成具有高度原子字段定义的各种表,以及用于聚合分层数据结构的交叉引用表。如果你不熟悉这些数据库设计原则,那么可以说,它通常以牺牲记录查找的访问时间为代价,提高了空间利用率。

如果你有一个高度规范化的关系数据库,存储的信息你想以去规范化扁平记录的形式访问,表示其应用程序模型,那么用于扁平化这些记录的查询通常既耗时又冗余。在这种情况下,你可能有一个存储扁平化记录的缓存,其设计优化了你的应用程序的使用。这意味着每当需要更新记录时,去规范化数据的过程可以恰好发生一次,将扁平结构发送到你的缓存。因此,你的应用程序与底层数据存储的交互可以从对多个表的潜在大量聚合查询减少到对单个应用程序特定表的简单查找。

在这种情况下,只需为长时间运行的数据访问操作添加一个中间缓存,就可以保证性能提升,即使缓存的实际数据存储系统与原始系统相同。缓存缓解的主要性能瓶颈是查询操作本身,因此即使没有减少网络延迟,你仍然可以期待获得一些有意义的收益。

应该指出的是,这种策略可以应用于应用程序流程中的任何长时间运行的操作。我使用慢速数据库查询作为例子,因为那些是大型企业系统中最常见到的瓶颈。然而,在你的工作中,你可能发现缓存应用程序主机进程中执行的计算密集型操作的结果是有益的。在这种情况下,你可能会使用内存缓存或托管在你自己系统上的缓存,因此没有可能提高你的延迟。但想象一下,如果你的应用程序部署到按应用程序运行时间收费的云托管提供商,那么在应用程序最常用的流程中删除多秒的计算可以节省数千美元的计算成本。当你缓存系统本地的方法调用或计算的结果时,这被称为记忆化

缓存高延迟网络请求

缓存的另一个常见动机因素是高延迟的网络请求。在这种情况下,你的软件将依赖于网络资源,但网络基础设施的某些方面使得访问该资源变得非常慢。这可能是因为你的应用程序托管在防火墙后面,而传入或传出的请求验证协议引入了高延迟。或者,这可能是由于地理位置的问题,你的应用程序服务器托管在与你最近的数据中心不同的物理区域。

无论原因如何,解决这个问题的常见方法是通过在更靠近数据存储的地方缓存结果来最小化网络延迟的影响。例如,假设问题是网关或防火墙向你的数据访问请求引入了不可接受的延迟。在这种情况下,你可以在防火墙后面建立一个缓存来消除它引入的延迟。在这种类型的缓存策略中,你的目标是存储你的缓存数据在某些主机上,这些主机引入的延迟比源主机要少。即使在你缓存中查找记录的时间不比在源主机上查找同一记录的时间快,最小化延迟仍然是目标。

缓存以保留状态

缓存数据的最后一种策略是促进状态管理。在云部署的应用程序架构中,你可能有用户与多个应用程序实例交互,这些实例在不同的服务器上并行运行。然而,如果他们的应用程序交互依赖于持久化任何类型的会话状态,你将需要在会话期间可能为单个请求服务的应用程序的所有实例之间共享该状态。在这种情况下,你可能会使用一个共享缓存,所有应用程序实例都可以访问并从中读取,以确定用户的请求是否依赖于由另一个实例确定的某个状态。

何时写入缓存

如我之前所描述的,缓存可能听起来就像是一个优化了的应用程序的数据存储副本。在某种程度上,这是真的,但技术上并不正确,因为缓存永远不应该完美地镜像其源系统。毕竟,如果你可以在一个高性能缓存中存储底层数据存储的完整副本,那么底层数据存储最初的价值在哪里呢?

相反,缓存通常只包含底层数据存储的一小部分。在大多数情况下,缓存的小尺寸对于其性能优势是必要的,因为即使是一个简单的查询也会随着查询集合的大小线性扩展。但是,如果我们的数据缓存不是源系统的完美镜像,那么我们必须确定哪些数据被包含在我们的缓存中,以及何时被包含。

预缓存数据

将数据写入缓存的简单而有效策略被称为预缓存。在一个预缓存其数据的系统中,开发者将确定哪些可能是性能最低或最频繁请求的数据访问操作,这些操作的结果在应用程序的生命周期内最不可能发生变化。一旦做出这种决定,这些操作就会执行一次,通常是在应用程序的初始化过程中,并在接收到或由应用程序处理任何请求之前将其加载到缓存中。

我之前提到的例子,涉及频繁请求的季度销售数据报告,是预缓存数据的理想场景。在这种情况下,我们可以在应用程序启动时请求销售数据并运行所有必要的统计操作以生成报告。然后,我们可以缓存应用程序服务的每种报告类型的完成视图模型。在接收到报告请求时,我们的应用程序可以可靠地查询缓存以获取视图模型,然后根据报告模板相应地填充,从而在应用程序的生命周期中节省时间和计算成本。

然而,这种方法的一个缺点是它需要同步底层数据更新与应用程序缓存的刷新。在应用程序和底层数据存储由同一团队工程师拥有和管理的情况下,这种同步是微不足道的。然而,如果数据存储由负责应用程序的不同团队拥有,协调会引入风险。一次更新同步失败可能导致向客户提供过时数据。为了减轻这种风险,你应该制定一个明确且弹性强的缓存刷新策略,并在可能的情况下自动化这项任务。

按需缓存写入

大多数缓存系统的实现都将设计为按需将数据写入缓存。在一个按需系统中,应用程序需要某些数据,它可以确信这些数据存储在底层数据库中。然而,在将较慢的数据访问请求发送到底层数据库之前,应用程序将首先检查缓存中是否有请求的数据。如果找到数据,就称为缓存命中。在缓存命中时,使用缓存条目,并且不会对数据集进行额外的调用,从而提高应用程序的性能。

在另一种情况下,当请求的数据尚未写入缓存时,应用程序会出现所谓的缓存未命中。在未命中时,应用程序必须对底层数据存储进行较慢的调用。然而,此时访问请求数据的成本已经付出。因此,现在应用程序可以使用为其设置的任何启发式方法来确定检索到的数据是否应该写入缓存,从而在后续请求相同数据时节省时间。

缓存替换策略

如果你的缓存大小固定有限,你可能会发现自己需要定义一个缓存替换策略。缓存替换策略是你确定何时用新的、可能更相关的记录替换旧记录的方法。这通常发生在应用程序遇到缓存未命中时。一旦数据被检索,应用程序将确定是否将其写入缓存。如果最终将记录写入缓存,它将需要确定要删除哪个记录。然而,问题在于很难确定一个一致的启发式方法来识别哪些记录很快就不会再被需要。

你可能在心中想到了一个看似明显的答案,仅仅通过思考就能得出;当我第一次了解到这个问题时,我也是这样。但大多数显而易见的解决方案在仔细审查时并不成立。例如,一种相当流行的替换策略涉及删除最近最少使用的记录。这仅仅意味着替换了在一系列缓存查询中未生成缓存命中的条目。然而,可能的情况是,一个记录被使用的时间越长,它被下一次使用的可能性就越大,记录的查找是按循环顺序进行的。在这种情况下,删除最近最少使用的记录会增加后续请求中另一个缓存未命中的可能性。

或者,你可以尝试使用最少使用替换策略。这将丢弃系统上所有记录中缓存命中次数最少的记录,无论这些命中发生的时间有多近。当然,这种方法的缺点是,没有考虑到最近的使用情况,你忽略了最近使用过的记录可能因为用户打算使用它进行一系列后续操作而变得相关的可能性。由于它的命中率低而被删除,并且忽略了命中的最近性,这增加了未来缓存未命中的可能性。

每种缓存替换策略都有其自身的缺点,应该在应用程序的上下文中考虑。然而,有一个关键指标可以帮助你确定替换策略的相对成功程度。一旦你的初始启发式方法设计并部署,你可以跟踪你的缓存命中率。一个缓存的命中率非常简单,就是缓存命中次数除以缓存未命中次数。这个数字越接近 1.0,你的缓存替换策略就越好。

缓存失效

当你考虑你的缓存替换策略时,你可能会发现缓存中存储的一些信息可能只对应用程序在短时间内相关。在这种情况下,与其等待新的缓存未命中来覆盖无关数据,你可能会想要在某个超时后使条目过期。这就是所谓的缓存失效。简单来说,缓存失效是确定你的缓存中的记录不应再用于服务后续请求的过程。

在我描述的情况下,如果你为写入缓存中的任何给定记录有一个已知的有效期,使这些记录失效就像在写入记录时设置并执行一个过期策略一样简单。然而,在其他情况下,可能并不明显地需要使缓存记录失效。考虑一个网络浏览器缓存来自服务器的响应。如果没有预定的过期日期,浏览器无法确定缓存响应是否仍然代表服务器的当前状态,除非首先检查服务器,从而消除了缓存带来的性能优势。

由于你不应该向用户提供过时或无效的数据,你应该始终设计一些机制来使你的缓存记录无效。我刚刚讨论了两种最常见的方法,你很难找到不实施其中至少一种的理由。所以,如果你控制着缓存本身,比如在你的应用程序架构中包含的缓存,你应该始终在底层数据存储更新时勤勉地使缓存记录无效。而对于你无法控制响应被缓存的情况,确保你始终为你的响应设置合理的缓存过期时间。

到目前为止,我们已经了解了什么是缓存,为什么你可能在你的软件架构中实现一个缓存,以及你可以利用哪些策略来优化其性能。因此,现在是我们来看看现代基于云部署的网络架构中最常见的缓存策略之一的时候了。

分布式缓存系统

在上一节中,我讨论了使用缓存来在相同应用程序的并行部署之间保留应用程序状态的目的。这是现代基于云的架构中缓存最常见用例之一。然而,尽管这种分布式会话缓存可能非常有用,但它可能会给应用程序设计带来一系列挑战。

缓存友好的架构

缓存在历史上一直被用来通过减少延迟或操作时间来提高性能。然而,对于分布式架构中的会话缓存,缓存本身并不旨在对特定操作提供任何特定的性能改进。相反,它旨在促进多个应用程序实例之间必要的交互。其设计是为了促进状态管理,否则将涉及多个主机复杂的编排。为了理解这是如何实现的,让我们考虑一个例子。

假设你有一个托管在云上的 API,该 API 负责验证用户的身份和年龄。为了做到这一点,它请求各种信息,这些信息加在一起可以用来验证用户。为了设计尽可能不干扰用户体验,你首先只问一些关于他们的出生日期和当前地址的问题,这些问题最有可能成功地验证他们的年龄和身份。一旦他们提交了答案,你的应用程序将尝试验证他们。如果成功,用户可以继续,但如果最初的问题集不成功,你的应用程序将跟进更多的问题,这些问题与第一组答案结合在一起,高度可能验证用户的身份。用户提交了答案,你再次尝试失败,最终提出一个要求用户提供社会保险号码最后四位数字的问题。提交后,用户要么成功验证,要么永久无法访问你的系统。

从业务逻辑的角度来看,这个过程相对简单,但你如何在保持网络请求之间完全无状态的网络上实现它?作为一个云托管的应用程序,你如何维护用户在流程中的当前位置,这跨越了处理给定请求的多个可能的应用服务器实例?

分布式缓存的案例

在这个特定情况下,有一些技术可用,每种技术都有其自身的优点和缺点。您可以使用粘性会话来强制后续请求来自特定主机在给定会话中由处理初始请求的同一应用服务器提供服务。这将在会话期间允许进行一些轻微的本地状态管理。这种方法的缺点是,它消除了云托管系统中水平扩展的性能优势。如果用户在整个会话过程中始终被迫与单个服务器交互,无论该服务器的流量如何或其他服务器的可用性如何,他们实际上就是在与单体应用架构的单实例交互。此外,您将不再坚持“无状态”服务的架构理想,因为您将实现某种机制来在您的活动服务交互过程中保留用户在工作流程中的位置。

或者,您可以使用我们在第十四章“网络上的身份验证和授权”中看到的相同原则,即自编码令牌部分。在这种情况下,您将在服务器的响应中自行编码用户的当前状态,而您的用户将负责在后续请求中将该自编码状态返回到服务器。这允许每个请求体充当一条通往客户端首次交互的面包屑路径,服务器可以根据之前的交互重建在后续交互中创建的状态。

这种方法会增加您请求/响应模型的复杂性。它还会引入无法强制执行的验证尝试限制的风险。假设出于安全考虑,您的业务规则规定用户只能尝试每轮问题一次。如果您在请求/响应模型中自行编码用户会话的状态,您就依赖于您的用户在每次请求中返回每个先前尝试的准确表示。一个有意的恶意行为者可以通过简单地从后续请求中清除工作流程状态来随意进行尽可能多的尝试。

在这种情况下,我会认为,在请求之间维护状态的可靠解决方案是云环境中每个应用服务器共享的分布式缓存。这阻止了您在应用服务器之间维护状态,从而保留了云部署服务架构的无状态原则,同时仍然允许您的服务完全控制用户通过验证工作流程的进度。

为了实现这一点,您将需要在独立于您的云中应用程序服务器实例的服务器上托管缓存提供程序。任何成功处理工作流程中给定步骤的服务器都会拒绝完全解析交互并向客户端提供响应,除非并且直到该步骤的结果成功写回到您的数据缓存中。这样,当前的应用程序服务器实例可以执行以下操作:

  • 通过确认用户在缓存中不存在该工作流程步骤的记录,验证没有其他实例已成功处理相同的请求

  • 通知其他应用程序实例该步骤已被处理,以便它们停止重复事务

分布式缓存的好处

我所描述的系统具有确保用户状态在其与所有部署的应用程序实例交互过程中一致性的好处,只需对缓存进行少量读取和写入操作即可实现。这种数据一致性,即使在您的分布式缓存不用于状态管理时也是关键的。它可以防止多个应用程序实例尝试对同一份数据进行两个不兼容的修改,或者在将事务提交到底层数据库之前,允许跨多个应用服务器同步事务。最重要的是,从开发者的角度来看,它可以消除由服务之间的竞争条件引起的难以重现和难以追踪的 bug。

独立于所有其他应用程序托管缓存服务器也为其提供了一定程度的对应用程序重启或崩溃的弹性。通过隔离您的数据存储,您可以隔离与您的云提供商提供的更高可用性和弹性保证相关的成本。这还可以帮助最小化您在应用程序服务器上运行的容器内存占用。如果您按 RAM 使用付费,这可以在缓存扩展时为您节省数千美元。那么,我们如何在代码中具体获得这些好处呢?

在代码中处理缓存

为了了解我们如何从.NET Core 支持的多种缓存机制中受益,我们将设置一个相对复杂的演示应用程序结构。我们首先要做的事情是创建一个远程数据存储,它具有长时间运行的操作以返回查询结果。一旦完成,我们将设置一个依赖于该数据的应用程序,并为其提供一个缓存策略,以减轻我们人为减缓的远程数据存储的影响。

编写我们的后端数据系统

我们将创建我们的后端数据系统作为一个简单的 Web API 项目。目标是创建一个控制器,在该控制器上暴露几个端点,以展示如何将值写入我们的缓存,而不管记录之间的类型差异。首先,让我们使用.NET Core CLI 创建我们的项目。让我们看看以下命令:

dotnet new webapi -n DataSimulation

接下来,由于我们将在缓存准备好的应用程序的同时托管此项目,我们希望配置它使用自己的端口,而不是默认设置。在你的Program.cs文件中,修改你的CreateWebHostBuilder(string[] args)方法以使用你希望此应用程序监听的任何自定义 URL:

public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
  WebHost.CreateDefaultBuilder(args)
    .UseUrls("https://[::]:33333")
    .UseStartup<Startup>();

然后,我们将修改ValuesController.cs类以提供我们的数据。首先,我们将类的名称更改为DataController,以便我们的路由更加直观。我们将移除所有预配置的端点,并用三个新的端点替换它们,每个端点返回唯一的数据类型。首先,让我们为我们返回的数据创建一个新的数据类型。它将是一个简单的模型,具有 ID 和两个任意属性;一个将是string类型,另一个将是List<string>

public class OutputRecord {
  public int Id { get; set; }
  public string SimpleString { get; set; }
  public List<string> StringList { get; set; } = new List<string>();
}

在这个模型设置好之后,我们可以定义我们将要公开的端点。在这个演示中,我们将返回一个简单的List<string>字符串,一个单独的OutputRecord实例,以及一个List<OutputRecord>方法。因此,当我们为每种数据类型定义了查找端点之后,我们将有返回简单字符串、字符串列表、复杂记录和复杂记录列表的方法。让我们看看以下代码:

public class DataController : ControllerBase {

  [HttpGet("value/{id}")]
  public ActionResult<string> GetString(int id) {
    return $"{id}: some data";
  }

  [HttpGet("values/{id}")]
  public ActionResult<IEnumerable<string>> GetStrings(int id) {
    return new string[] { $"{id}: value1", $"{id + 1}: value2" };
  }

这些定义了我们的简单字符串响应,并且用我们的缓存进行测试将会相对简单。然而,对于我们的OutputRecord端点,我们希望为每个属性应用独特的数据,以便我们可以确认整个对象被正确缓存。因此,返回单个OutputRecord实例的端点将看起来像这样:

[HttpGet("record/{id}")]
public ActionResult<OutputRecord> GetRecord(int id) {
  return new OutputRecord() {
    Id = id,
    SimpleString = $"{id}: value 1",
    StringList = new List<string> {
      $"{id}:value 2",
      $"{id}:value 3"
    }
  };
}

这给我们一个具有不同属性值的对象,它们通过相同的 ID 连接在一起,这将使我们能够轻松验证缓存的行为。最后,我们将定义一个端点来返回OutputRecord实例的列表:

[HttpGet("records/{id}")]
public ActionResult<IEnumerable<OutputRecord>> GetRecords(int id) {
  return new List<OutputRecord>(){
    new OutputRecord() {
      Id = id,
      SimpleString = $"{id}: value 1",
      StringList = new List<string> {
        $"{id}:value 2",
        $"{id}:value 3"
      }
    }, new OutputRecord() {
      Id = id + 1,
      SimpleString = $"{id + 1}: value 4",
      StringList = new List<string> {
        $"{id + 1}:value 5",
        $"{id + 1}:value 6"
      }
    }
  };
}

这些端点中的每一个都返回一些带有提供 ID 的简单对象或字符串,这些对象用于响应对象中,但这只是区分一个响应与下一个响应的一种方式。我们响应的重要方面将是我们将应用的感知延迟。为此,我们将在返回结果之前为每个方法添加五秒钟的延迟。这将给我们一个明显的方式来识别当后端数据存储被击中时与当我们的用户界面应用程序成功缓存击中时的情况。

为了模拟这种延迟,我们将使当前线程休眠五秒钟,然后返回一个包含给定 ID 的任意字符串:

[HttpGet("value/{id}")]
public ActionResult<string> GetString(int id) {
    Thread.Sleep(5000);
    return $"{id}: some data";
}

每个额外的方法都将做同样的事情,应用延迟然后使用任意值初始化其预期的返回类型。现在,如果你运行应用程序并 ping 你的/data/value/1234端点,你应该会在五秒后看到结果返回:

图片

注意响应时间为 5269ms。这个延迟将是我们未来缓存未命中的指示。并且有了我们的数据存储就绪,我们可以构建我们的应用程序并定义其缓存策略。

利用缓存

要开始使用我们的缓存,我们首先需要安装并运行一个 Redis 服务器的本地实例。Redis 是一个开源的内存数据存储。它通常在企业部署中用作简单的键值数据存储或缓存。它还由 Azure 云托管环境直接支持,这使得它对于基于.NET 的微服务和基于云的应用程序非常受欢迎。

要安装它,请遵循本章技术要求部分中的说明。一旦完成,你将有一个本地实例正在运行。如果你已经安装了服务器,请确保它已启动并运行,通过打开你的 Windows 子系统 Linux 界面,并输入以下命令以验证其监听端口:

图片

一旦你的 Redis 实例运行起来,你就可以实现你的示例微服务了。由于我们将从我们的后端 API 加载缓存未命中,我们希望在Startup.cs文件中为该特定应用程序配置HttpClient。为此,我创建了一个静态的Constants类,以避免在我的代码中使用魔法字符串,并在ConfigureServices(IServiceCollection services)方法中使用DATA_CLIENT属性注册一个命名的HttpClient实例:

services.AddHttpClient(Constants.DATA_CLIENT, options => {
  options.BaseAddress = new Uri("https://localhost:33333");
  options.DefaultRequestHeaders.Add("Accept", "application/json");
});

接下来,我们将创建一个服务客户端来抽象我们将要进行的 HTTP 请求的细节,使用我们在第九章中建立的相同模式,即.NET 中的 HTTP。我们的接口定义将提供以下简单的方法:

public interface IDataService{
  Task<string> GetStringValueById(string id);
  Task<IEnumerable<string>> GetStringListById(string id);
  Task<DataRecord> GetRecordById(string id);
  Task<IEnumerable<DataRecord>> GetRecordListById(string id);
}

在实现此接口的类中,我们将有一个IHttpClientFactory的私有实例,我们将使用我们的命名HttpClient实例来访问我们的后端数据存储。这个常见任务被隔离到一个私有方法中,用于实际的 HTTP 交互:

private async Task<string> GetResponseString(string path) {
    var client = _httpFactory.CreateClient(Constants.DATA_CLIENT);
    var request = new HttpRequestMessage(HttpMethod.Get, path);
    var response = await client.SendAsync(request);
    return await response.Content.ReadAsStringAsync();
}

然后,每个公共接口方法都实现了这里建立的一般模式的端点特定变体:

public async Task<DataRecord> GetRecordById(string id) {
    var respStr = await GetResponseString($"api/data/record/{id}");
    return JsonConvert.DeserializeObject<DataRecord>(respStr);
}

将此逻辑扩展到我们所有四种访问方法,我们将完成我们的后端数据客户端。在这个时候,我们应该修改我们的控制器以公开每个后端 API 端点,并使用它们来测试我们的数据访问服务。我们将公开与我们的后端 API 相同的同一服务合约,为每种可能查找的记录类型提供四个端点。我们不会重命名我们的文件,而是重新定义我们控制器的路由,并定义一个公共构造函数以允许依赖注入框架提供我们的DataService实例(只是别忘了在Startup.cs中注册具体的实现)。让我们看看以下代码:

[Route("api/cache-client")]
[ApiController]
public class ValuesController : ControllerBase {

    private IDataService _dataService;

    public ValuesController(IDataService data) {
        _dataService = data;
    }
    ...

一旦我们有了我们的数据服务,我们就可以使用我们的 API 端点从我们的后端系统调用每个请求的对象:

[HttpGet("value/{id}")]
public async Task<ActionResult<string>> GetValue(string id) {
    return await _dataService.GetStringValueById(id);
}

[HttpGet("values/{id}")]
public async Task<IEnumerable<string>> GetValues(string id) {
    return await _dataService.GetStringListById(id);
}

[HttpGet("record/{id}")]
public async Task<ActionResult<DataRecord>> GetRecord(string id) {
    return await _dataService.GetRecordById(id);
}

[HttpGet("records/{id}")]
public async Task<IEnumerable<DataRecord>> Get(string id) {
    return await _dataService.GetRecordListById(id);
}

到目前为止,通过运行你的后端 API 和缓存服务 API,你应该能够从你的缓存服务请求相同的值,并且有相同的五秒延迟。所以,现在我们的应用程序已经完全连接到请求后端服务的数据,让我们通过缓存来提高其性能。

.NET 的分布式缓存客户端

使用 Redis 作为我们的分布式缓存解决方案的主要好处之一是它由 .NET Core 默认支持。甚至还有一个针对 IServicesCollection 类的扩展方法,专门用于在应用程序中使用 Redis 缓存进行注册。只需为你的当前项目安装 Microsoft.Extensions.Caching.Redis NuGet 包,然后添加以下代码:

services.AddDistributedRedisCache(options => {
    options.Configuration = "localhost";
    options.InstanceName = "local";
});

这将自动将 RedisCache 类的实例注册为任何注入到你的服务中的 IDistributedCache 实例的具体实现。本地主机配置设置将使用 Redis 客户端本地部署的默认配置,因此除非你明确更改本地部署,否则不需要指定 IP 地址和端口。同时,InstanceName 字段将为由该应用程序设置的缓存条目提供应用程序特定的前缀。所以,在这个例子中,如果我用本地设置设置了一个 1234 键的记录,那么这个键将存储在缓存中为 local1234。通过 AddDistributedRedisCache() 方法注册的 RedisCache 实例将自动查找我们选项中指定的 InstanceName 前缀的键。我们将在稍后检查我们的缓存实例时看到这一点。

在我们的 Redis 缓存运行,并且我们的 IDistributedCache 实例配置并注册到我们的依赖注入容器后,我们可以编写一个 CacheService 类。这个类将遵循与我们的 DataService 类相似的模板,其中只公开少量逻辑操作作为公共方法,隐藏缓存交互的细节。我们为这个 CacheService 类的接口如下:

public interface ICacheService {
    Task<bool> HasCacheRecord(string id);
    Task<string> FetchString(string id);
    Task<T> FetchRecord<T>(string id);
    Task WriteString(string id, string value);
    Task WriteRecord<T>(string id, T record);
}

在这里,我们区分了写入单个字符串和写入更复杂的记录,以区分在每个方法实现中序列化和反序列化我们条目的需求。

获取和设置缓存记录

IDistributedCache 类提供了一个简单的机制来与我们的缓存数据交互。它基于简单的 get/set 模式运行,尝试获取记录将根据给定的 ID 返回缓存的字节数组或字符串,如果不存在记录则返回 null。没有错误处理或状态检查。缓存的速度取决于这种简单的交互机制和失败状态。

同样,设置记录同样简单。只需定义记录的 ID,然后提供记录的序列化表示形式以供存储。这种序列化格式可以是使用SetString(string id, string value)方法的字符串,或者使用Set(string id, byte[] value)方法的字节数组。

此外,当你向缓存写入值时,你可以为你的缓存记录设置额外的选项来指定过期时间范围。你可以应用的过期设置类型如下:

  • 绝对过期:这将在特定的时间点设置过期,此时记录将被失效,无论它最近是否被使用。

  • 绝对过期相对于现在:这将在记录被设置在缓存中的那一刻起,设置一个固定的时间点,无论它最近是否被使用,记录都将被失效。与绝对过期不同的是,过期时间是以记录设置在缓存中的那一刻起的一段时间长度来表示的。

  • 滑动过期:这将在记录最后访问的时间点设置一个过期时间。因此,如果滑动过期设置为 60 分钟,并且记录在 62 分钟后没有被再次访问,它将过期。然而,如果记录在 58 分钟后再次被访问,过期时间将从第二次访问的那一秒重置为 60 分钟。

因此,让我们看看我们将如何实现这个缓存。首先,我们必须注入在Startup.cs类中注册的IDistributedCache实例:

public class CacheService : ICacheService {
    IDistributedCache _cache;

    public CacheService(IDistributedCache cache) {
        _cache = cache;
    }

然后,我们将实现我们的接口方法。第一个方法相当直接,仅在我们缓存命中时通知我们的消费者:

public async Task<bool> HasCacheRecord(string id) {
    var record = await _cache.GetStringAsync(id);
    return record != null;
}

接下来,我们将实现我们的记录检索方法。这些方法之间的唯一区别是,检索复杂的数据类型(记录和字符串列表)将需要额外的反序列化步骤。除此之外,我们的Fetch...()方法应该看起来相当直接:

public async Task<string> FetchString(string id) {
    return await _cache.GetStringAsync(id);
}

public async Task<T> FetchRecord<T>(string id) {
    var record = await _cache.GetStringAsync(id);
    T result = JsonConvert.DeserializeObject<T>(record);
    return result;
}

最后,我们需要实现写入方法。为了演示,我们将使用DistributedCacheEntryOptions类将所有记录的滑动过期时间设置为 60 分钟。之后,我们可以简单地传递我们的键和序列化值(我们将使用 JSON,以利用Newtonsoft.Json库)以及过期选项到缓存中:

public async Task WriteString(string id, string value) {
    DistributedCacheEntryOptions opts = new DistributedCacheEntryOptions() {
        SlidingExpiration = TimeSpan.FromMinutes(60)
    };
    await _cache.SetStringAsync(id, value, opts);
}

public async Task WriteRecord<T>(string id, T record) {
    var value = JsonConvert.SerializeObject(record);
    DistributedCacheEntryOptions opts = new DistributedCacheEntryOptions() {
        SlidingExpiration = TimeSpan.FromMinutes(60)
    };

    await _cache.SetStringAsync(id, value, opts);
}

有了这些,我们的缓存应该已经准备好使用了。现在,是时候在我们的控制器端点中整合所有内容了。为此,每个方法的交互模式都将相同,唯一的区别是我们对缓存执行的读写操作类型。所以,让我们看看我们将如何实现我们的缓存策略:

[HttpGet("record/{id}")]
public async Task<ActionResult<DataRecord>> GetRecord(string id) {
    var key = $"{id}record";
    if (await _cache.HasCacheRecord(key)) {
        return await _cache.FetchRecord<DataRecord>(key);
    }
    var value = await _dataService.GetRecordById(id);
    await _cache.WriteRecord(key, value);
    return value;
}

你首先会注意到,我为给定的 ID 添加了一个后缀,这与我的路由匹配。这是为了允许我的缓存中每个不同数据类型的重复 ID。接下来,我们检查我们的HasCacheRecord(键)方法,以确定我们是否有一个缓存命中。如果有,我们只需获取缓存记录并返回结果。然而,当我们没有命中时,我们必须从我们的底层数据存储中获取数据。一旦我们有了它,我们就将其写入我们的缓存,以便在后续请求中更快地检索,然后返回该值。

在对每个端点进行适当的修改后应用此模式,我们就准备好进行测试了。为了确认缓存的运行行为,首先对具有新 ID 的任何端点运行相同的查询,连续两次。如果一切正常,你的第一次请求应该有 5 秒的延迟,而后续请求的延迟几乎为零。

一旦你在缓存中存储了至少一条或两条记录,你就可以在 Windows Subsystem for Linux 控制台中使用 redis-cli 观察这些值。RedisCache类将条目作为哈希类型存储在底层缓存中,因此你需要使用这些命令来查找键值。我在测试应用程序时查找记录的操作如下:

第一个命令keys *简单地搜索所有匹配给定模式(*是通配符,所以keys *匹配所有键)的活跃键。然后,我使用了hgetall [key]命令来获取我条目哈希中的每个属性。在这个输出中,你可以清楚地看到从我应用程序写入缓存中的 JSON,这证明了我的应用程序和缓存之间成功且预期的交互。

我还想指出键的结构。正如我之前提到的,我设置的键(在这种情况下,2345 条记录)以RedisCacheOptionsInstanceName为前缀,我在Startup.cs文件中配置了RedisCache。通过这个输出,你已经看到了微软为与 Redis 缓存实例交互而建立的完整交互模式。

缓存提供者

尽管我们在示例代码中演示了使用IDistributedCache类实例的数据缓存,但这并不是.NET Core 中我们所能访问的唯一缓存提供者。在我们结束缓存主题之前,我想简要讨论框架中另外两个最常见的提供者。

SqlServerCache 提供者

Redis 无疑是工程师们中流行的高性能缓存实现。然而,它并不是唯一的分布式提供者。实际上,当需要时,微软自己的 SQL Server 也可以作为缓存使用,并且他们为IDistributedCache类定义了一个类似的实现来公开它。

SqlServerCache 提供程序和 RedisCache 实例最大的不同之处在于它们所需的配置。由于 Redis 是一个简单的键值存储,SqlServer 仍然是一个功能齐全的关系型数据库。因此,为了提供高性能缓存所需的轻量级交互,您必须在将其设置为 IDistributedCache 提供程序时指定您打算利用的确切模式、表和数据库连接。并且由于 SQL Server 不支持 Redis 所支持的哈希表,您的应用程序连接用于缓存的表应实现 IDistributedCache 记录的预期结构。幸运的是,.NET Core CLI 提供了一个用于建立此类表的实用命令:sql-cache create 命令。值得注意的是,由于您的应用程序应该始终只与注入的 IDistributedCache 实例交互,您甚至可能不会注意到差异,除非是在性能方面。然而,出于性能的考虑,我建议尽可能使用 Redis。它正迅速成为行业标准,其速度确实无法与 SQL Server 相匹敌。

MemoryCache 提供程序

最后,如果您的应用程序既没有需求,也没有支持独立缓存实例的手段,您始终可以依赖内存缓存策略。System.Runtime.Caching 命名空间中的 MemoryCache 类将提供所需的一切。配置它就像在 Startup.cs 中调用 services.AddMemoryCache() 方法一样简单,并且它提供了一个与我们已经查看过的 IDistributedCache 类相似的接口。

然而,它也带来了一些重要的注意事项。由于您在应用程序自己的进程中托管缓存,内存变得更为宝贵。对缓存替换策略的纪律性使用和积极的过期时间在内存缓存解决方案中变得尤为重要。此外,由于任何必须在会话生命周期内持久化的状态都只会在应用程序的单个实例中持久化,您需要实现粘性会话。这将确保用户始终与具有其数据缓存在内存中的应用服务器交互。

最终,您的业务需求和环境限制将在确定您应在应用程序中利用哪些缓存策略和策略方面发挥重要作用。然而,凭借本章中的信息,您应该能够为您的具体情况做出最佳决策。同时,我们将在下一章继续考虑性能优化,届时我们将考虑网络托管应用程序中的性能监控和数据跟踪。

摘要

在本章中,我们广泛探讨了分布式网络应用程序中数据缓存的动机和用例。我们首先探索了一些可能从缓存策略中受益的常见商业和设计问题。在这样做的时候,我们确定了在确定引入缓存管理系统复杂性的复杂性是否是您应用程序的正确决策时可以做出的基本考虑。然后,我们探讨了缓存可以带来的确切好处,以及缓存如何精确地提供这些好处。

一旦我们了解了为什么我们可能会使用缓存,我们就研究了在实现缓存时必须解决的常见问题。首先,我们解决了预缓存数据和按需缓存结果的战略。然后,我们探讨了如何确定哪些数据或资源应该被缓存。我们学习了如何建立适合您应用程序最常见数据交互的缓存替换策略,以及如何使缓存中的记录失效以确保您永远不会返回过时的结果。

最后,我们看到了如何在我们的应用程序中使用缓存。我们学习了如何运行分布式缓存,并看到了如何在代码中写入和读取该缓存。我们看到了缓存记录可以是任意数据结构,具有任意键,以及如何在我们的缓存实例中检测命中。最后,我们探讨了 C# 和 .NET Core 中可用的替代缓存机制。

在下一章中,我们将继续关注优化我们的应用程序在网络中的性能,并探讨可用于监控应用程序性能和识别网络中任何瓶颈的工具。

问题

  1. 应该有哪些标准来激励缓存策略?

  2. 缓存可以帮助解决哪些常见的痛点?

  3. 缓存命中是什么?缓存未命中是什么?

  4. 什么是缓存替换策略?有哪些常见的缓存替换策略?

  5. 命中率是什么,它与替换策略有何关系?

  6. 缓存失效是什么?

  7. 使用分布式缓存有哪些好处?

进一步阅读

对于在现代 .NET 环境中构建缓存的更多实践指导,我推荐由 Rod Stephens 编著的书籍 The Modern C# Challenge。它深入探讨了与本章中讨论的相同类型的模式和惯例,并以极其易于理解的方式呈现。您可以通过 Packt 出版公司找到它,网址为:www.packtpub.com/application-development/modern-c-challenge-0.

或者,如果你想考虑分布式、水平扩展的应用架构固有的其他挑战,你应该查看Vinicius Feitosa Pacheco《微服务模式和最佳实践》。它也由 Packt 出版,你可以在这里获取: www.packtpub.com/application-development/microservice-patterns-and-best-practices.

第十六章:性能分析和监控

随着你继续发展构建网络软件的知识,我们不能忽视监控和性能调优的关键任务。这两个责任将是本章的重点,我们将探讨 .NET Core 应用程序中可用于监控和测试应用程序性能和稳定性的工具。我们将探讨开发者可用于在受控环境中对应用程序施加重负载并观察其稳定性的工具。我们将探讨一些简单的日志记录和监控方法,并考虑如何使用 .NET Core 的一些功能来加强这些方法。

本章将涵盖以下主题:

  • 识别网络架构中的性能瓶颈,并设计以最小化它们

  • 识别端到端性能测试和报告策略

  • 使用 C# 建立稳健和有弹性的性能监控

技术要求

在本章中,我们将编写一些示例来展示性能跟踪和监控的各个方面,所有这些都可以在这里找到:github.com/PacktPublishing/Hands-On-Network-Programming-with-CSharp-and-.NET-Core/tree/master/Chapter 16。

观看以下视频以查看代码的实际效果:bit.ly/2HYmD5r

要使用此代码,您希望使用我们信任的代码编辑器之一:Visual Studio 或 Visual Studio Code。我们还将使用您所熟悉和喜爱的 REST 客户端,所以请确保您已安装 PostMan (www.getpostman.com/downloads/) 或 Insomnia REST 客户端 (insomnia.rest/)。

网络性能分析

当你开始构建更复杂的分布式软件系统时,你开始失去对较小、更独立的软件项目所拥有的细粒度控制和微调。每个新的网络交互都增加了系统故障的风险,减少了查找错误来源的可见性,并在寻找性能瓶颈时使问题复杂化。减轻这些影响的最佳方式是提前做好准备,并从一开始就将性能监控纳入你的分布式系统设计中。那么,你该如何做呢?你应该关注哪些关键指标和交互?.NET Core 为你提供了哪些支持?

端到端性能影响

想象一下,如果你负责一个云托管的应用程序套件。它包括半打微服务,每个微服务都依赖于另外半打服务。更不用说,每个并行资源都部署在负载均衡网络网关后面,该网关负责将请求路由到当前负载最低的服务器。

现在,想象一下,该系统的每个组件几乎都是独立于其他组件编写的。你的工程师团队专注于关注点分离的设计原则,因此他们彻底地分离了这些关注点。每个数据存储都分配了自己的、有限的公共 API,没有其他系统被允许直接访问其底层数据库。所有聚合 API 都负责访问每个记录系统,以产生与你的业务用例相关的领域模型。在责任上几乎没有重叠。

现在,让我们慷慨地假设你的工程师们在测试策略上也非常自律。每个服务都包含了一套完整的单元测试,代码覆盖率接近 100%。每个单元测试都是完全隔离的,并为每个依赖项定义了良好的模拟。你的工程师们如此自律和细致,以至于那些模拟被配置为返回服务在实时系统中可能返回的每个可能的有效和异常响应的组合。你生态系统中的每个服务的数据契约都定义得很好,所有变更都得到了依赖系统的良好跟踪和记录。

在所有这些工作都得到仔细记录、维护和测试之后,你终于准备好部署你应用程序的版本 1。现在,我想让你想象一下,当有人第一次尝试查询你的应用程序时,整个生态系统变得缓慢,响应需要整整 25 秒才返回。

这可能看起来像是一个荒谬的案例,但我确实见过几乎完全相同的场景,那是经验不足的云架构师团队在部署他们的第一个完全分布式的基于微服务应用时发生的。那么,在我们这个假设的场景中,到底出了什么问题,我们如何在实践中避免这种情况呢?

累积延迟

到目前为止,这一点应该是显而易见的,但在这种特定场景中,性能不佳的主要原因是隔离。由于每个服务都是完全在隔离的环境中开发和测试的,因此工程师们无法衡量所有后端依赖的全面影响。通过始终假设与上游依赖项的接近零延迟的最佳情况,开发者为他们单元测试创造了不切实际的测试场景。

集成和端到端测试对于设计能够承受基于网络托管环境的压力和不一致性的系统至关重要。在我们这个并非完全假设的开发场景中,随着每个新功能或微服务的开发,开发者应该将他们的解决方案部署到一个尽可能接近生产配置的环境。他们应该在实施过程中识别性能瓶颈,并减轻这些影响。

优化网络软件的一个更具挑战性的方面是,你的单个应用程序几乎永远不会是用户与之交互的最后一站。通常,你的.NET Core 服务至少会被用户的浏览器或其他应用程序服务调用。此外,它们也常常依赖于通过 HTTP 或 TCP 等网络协议访问的其他服务。在这个场景中,开发者没有意识到,而你作为构建越来越复杂的分布式系统时应该始终注意到的,是依赖链中的每个网络跳步都会引入延迟和失败的风险。

每个上游依赖都会使你的应用程序的延迟增加其上游依赖的总平均延迟。同样,如果你的上游依赖本身也有上游依赖,那么它的延迟将是其自身操作产生的操作延迟加上其上游依赖的总平均延迟。这种将网络系统延迟复合化的属性,在编写依赖于其他网络服务的网络服务时,是一个非常重要的属性需要记住。这就是为什么经验丰富的云架构师通常会对请求解析流程实施严格的分层架构模型。

三层架构

为了最小化复合延迟的影响,在架构中减少垂直依赖性是很常见的。就我们的目的而言,我们可以将水平依赖性视为源自同一来源的完整上游依赖集。同时,垂直依赖性描述了任何具有额外上游依赖的上游依赖:

图片

在这个图中,架构 A 具有我们所说的三层架构。也就是说,最多有三层服务封装整个应用程序交互的垂直依赖图。这是云托管服务的理想组织结构。由于大多数托管环境(实际上,.NET Core 本身)支持处理大量的并行请求,因此给定系统内水平依赖性的延迟增加将永远不会超过所有水平依赖性中最慢的那个。这与我们在第六章的“加快速度 - 多线程数据处理”部分讨论的并行化异步操作带来的好处并不完全不同。第六章,“流、线程和异步数据传输”。

在严格的分层架构中,用户与最终呈现给他们的任何数据之间最多只能有两个网络跳数。三层架构很容易理解,在我们讨论 MVC 设计范式(第九章,HTTP in .NET)之后,你应该已经熟悉了。第一层是用户交互层,它定义了任何外部用户能够访问你生态系统中的数据或过程的系统或机制。接下来是聚合层,或领域层。这是在用户感兴趣看到的所有数据上执行父用户交互的所有业务逻辑的地方。最后但同样重要的是,是数据层。在现代云系统中,这些通常是基于 HTTP 的 API,它们公开一个内部数据库,作为企业数据集的记录系统。

当三层架构得到良好执行时,不允许任何系统通过其定义良好的 API 之外的方式与数据库交互。同时,如果一个 UI 需要定义在多个聚合服务中的业务逻辑,它必须要么自己访问所有聚合层系统,从而增加其水平依赖图(这被认为是可接受的),或者必须编写一个新的聚合服务来重复其他服务的任务。任何聚合系统都不应该调用任何其他聚合系统。这样做会增加该工作流程的垂直依赖性,并违反三层架构。

考虑到这一点,应该很容易理解为什么确保应用程序高性能并实施可管理的监控系统最可靠的方法之一是通过最小化垂直依赖。如果我们能将瓶颈搜索限制在尽可能少的垂直层级中,我们识别问题和解决问题的能力几乎可以变得非常简单。随着你的软件在网络堆栈中的层级越来越低,这些累积的延迟交互变得更加相关。为任何在本地网络和更广泛的互联网之间移动的流量实施一系列网关或防火墙将影响企业中通过的所有流量。如果你希望有希望最小化网关引入的延迟,那么最小化此类系统的垂直依赖性应该是你的首要任务。

压力下的性能

当一个未经充分测试的系统首次部署到生产环境时,一个常见的问题就是应用程序无法处理施加在其上的负载。即使是严格执行的三层架构也无法最小化由于极高的网络负载而未处理的多个响应的影响。大量网络请求能够完全摧毁网络资源的能力如此之大,以至于这实际上成为了一种极其著名的软件攻击的基础,这种攻击被称为专用拒绝服务DDoS)攻击。在这种攻击中,一个分布式的恶意软件网络向单个主机发送协调一致的简单网络请求。 incoming requests absolutely destroys the host's ability to continue responding to them, locking up the resource for legitimate users, and even destabilizing the host's OS and physical infrastructure.

虽然 DDoS 攻击可能是一个极端且相对罕见的例子,但同样的影响也可能出现在处理大量合法同时请求时带宽和水平可扩展性不足的系统上。这里的挑战在于,在部署时,你并不总是能提前知道你的系统将遇到什么样的流量。

如果你正在为特定的一组内部商业用户编写企业级网络服务,你很可能可以非常清楚地定义你的操作参数。进行简单的员工人数统计,结合用户访谈来确定特定用户将多久与你的系统交互一次,可以给你很高的信心,确保你知道在给定的一天里你可以合理地预期多少流量。然而,如果你的应用程序是为公开发布而编写的,那么事先知道在给定的一天里会有多少用户访问你的服务可能是不可能的。

在那些在遇到大量请求之前无法合理确定最大潜在网络流量的场景中,你至少想知道你的应用程序可以处理多少流量的大致情况。这就是负载测试的作用所在。通过负载测试,你应该针对网络流量对你的系统可能造成的最坏情况做出最佳猜测。

一旦你确定了最大潜在负载,你将执行一系列测试,尽可能多地与你的系统进行交互,包括你预定的最大值。为了真正有价值,你的测试应该在尽可能接近你的生产配置的软件实例上运行。在整个测试过程中,你应该记录和监控响应时间和任何异常响应。当测试完成时,如果设计得当,你应该有一套稳健的指标,这些指标可以告诉你当前基础设施在失败之前可以合理处理多少流量,以及当失败最终发生时它看起来是什么样子。

性能监控

我刚才讨论的每个性能瓶颈和风险都可以通过良好的设计和稳健的测试策略来缓解。然而,在分布式系统中,失败是不可避免的。因此,我们通过系统的测试和设计来寻求最小化的失败风险永远无法完全消除。然而,通过使用我们的性能测试结果作为指导,我们可以最小化这种不可避免失败的影响。为此,我们需要实施一个稳健的系统来监控我们服务的健康和可用性。

天真的监控策略

开发者将应用程序监控的概念与日志记录混淆的情况并不少见。这并不是一个完全不合理的问题。当正确执行时,一个好的日志策略可以作为一个相对低可见性的监控系统。然而,那种方法的问题在于,日志往往非常嘈杂

日志记录过度使用

想想当你代码中捕获异常时的初始方法。我敢打赌,相当一部分人从基本的记录并抛出方法开始。你只是记录发生了错误(通常没有太多细节或上下文),然后重新抛出相同的错误,或者可能抛出一个调用代码可能配置为响应的新错误。

同时,我相信至少有几位读者在这样一个环境中工作过,那里的任何范围变化都会被记录下来。引入一个新方法?记录它。退出当前方法?记录它。向主机发送 TCP 数据包?记录它。接收 TCP 数据包?记录它。我可以继续说,但我会避免让你感到沮丧。

这种记录一切的方法在大规模企业中非常常见。然而,大多数人很少考虑的是,你记录的信息越多,那些日志就越不有用。随着你的日志文件随着记录范围或上下文中的琐碎变化而增长,找到真正重要的日志变得越来越困难。相信我,当你发现自己正在浏览一个 200,000 行长的日志文件时,你将需要找到那些日志。

如果您从未听说过这个术语,那么这就是您日志的信号与噪声比。在这个比率中,信号描述的是您当前时刻有意义的信息。另一方面,噪声描述的是围绕并掩盖您信号的信息,这些信息在当前时刻对您没有意义。纪律性日志不是关于勤奋地记录所有事情。纪律性日志是关于始终只记录重要的事情。这种纪律应该应用于您的日志,以及您在软件中使用的任何性能或健康监控器。

延迟警报

另一种常见的日志方法缺陷是,我们想要了解的应用程序信息直到太晚才呈现给我们。通常,对于性能和健康监控的日志策略,日志是在故障发生后编写的。虽然这当然比完全没有警报系统更有用(当您只能通过接到用户愤怒的电话通知系统故障时),但它远非理想。

您应该在其他人遇到之前了解系统断电。然而,如果您只是被动地写入日志文件,并且只有在抛出异常时才这样做,那么这种可见性是不可能的。在网络软件中,您的应用程序可能被广泛的其他资源以某种方式利用,以至于系统断电可能会传播到您的网络并损害软件旨在支持的业务。那么,您如何能够提前应对那些不可避免的断电,并在任何用户感受到影响之前做出响应?

建立主动监控

最有效地减少对用户造成断电影响的方法是主动识别断电何时发生,或可能发生。为此,您需要为对系统进行健康检查定义一个策略。该策略必须确定向系统发送请求的合理频率。您确定的频率必须在频繁到有高概率在断电影响客户之前检测到断电,以及不频繁到不会在系统最重负载下对系统性能产生负面影响之间取得平衡。除此之外,您还需要识别所有可能的不健康系统信号,以便您的监控器能够针对软件性能的最相关方面进行精准监控。

定义阈值

在定义性能监控策略时,你应该首先定义你应用程序的性能阈值。这些阈值将告诉你,超出这些阈值,你的应用程序很可能会出现故障。例如,如果你对你的应用程序进行了广泛的负载测试,你可能会有一大堆关于你的应用程序服务器可以处理多少流量的指标。通过使用网络跟踪,它将提供针对监听网络应用程序的每个请求的记录,你可以确定你的流量何时出现峰值,在达到故障阈值之前发送警报。

此外,如果你有上游依赖项,你应该注意它们随时间的变化,识别出任何你的依赖项成为系统故障来源的实例。你应该寻求利用识别依赖响应内容或延迟中的信号,这些信号可能导致你的用户系统出现故障的策略。一旦你有了这些信息,你就处于一个很好的位置来应对它们。

积极的健康检查

一旦你了解了系统内潜在的故障点或条件,鲁棒的监控策略就会频繁地测试这些点和条件。旨在积极监控你的系统的服务通常被称为看门狗。大多数云托管编排器或 CI/CD 软件都会允许你配置一个健康检查访问点到你的软件中,这样它们就可以使用自己的看门狗实现来识别应用程序崩溃的情况。如果你的托管解决方案提供了这些功能,例如 Azure 健康验证工具,那么你应该充分利用它们。如果你的主机不提供可靠、定期(或可配置的定期)的健康检查机制,那么我强烈建议你自行实现。这对你的开发过程的影响很小,但好处是巨大的。

我们将在接下来的示例代码中查看这一点,但这种类型的积极健康和性能监控的典型模式是暴露一个可以被你的托管提供商 ping 的端点。然后,该端点将对关键潜在的故障点运行一系列自我诊断检查,并返回成功(健康的应用程序,没有即将发生的可能故障)或失败(系统已关闭,或很快将被关闭)。

活跃的消息传递和活跃的恢复

一个主动的健康监控系统需要主动的响应。对于你的健康监控系统检测到的任何错误状态,你将希望定义一个合理的响应方法。例如,如果你的网络流量激增,主动的方法可能是配置你的健康检查系统自动提供额外的并行应用服务器来响应额外的请求负载。同时,如果你的健康检查表明上游依赖已经离线,主动恢复系统可能会尝试重新启动托管该依赖的应用服务器,以解决该问题。

无论你的系统如何响应(尽管它应该至少定义某种类型的故障响应),它绝对应该通知任何正在处理该应用程序的工程师。具体来说,它应该主动通知工程师。虽然事件当然应该记录到你用来跟踪应用程序可靠性的任何审计系统中,但请记住,日志****不是警报。日志本质上是被动的。对于负责系统的工程师来说,要从日志消息中了解到系统已失败,该工程师必须自己寻找并检查日志。与此同时,通过主动的消息响应,你的监控平台可以配置为获取任何应该知道故障的工程师的联系方式。然后,当发生故障时,主动监控器可以将通知推送到它配置通知的每个工程师的联系方式。

C#中的性能和健康监控

因此,我们应该对什么是健壮和有弹性的性能和健康监控系统有一个相当清晰的理解。它应该配置为响应定义良好的阈值,以便在发生之前发现和缓解潜在的故障。它应该主动检查应用程序的状态,而不是被动地等待用户交互来触发系统故障。最后,一旦识别到故障,它应该主动响应,通知任何可能响应的人,并采取措施将系统恢复到健康状态。那么,在.NET Core 中这看起来是什么样子呢?

一个不稳定的分布式架构

为了展示我们如何使用性能监控来保持对不稳定系统健康状态的了解,我们首先必须构建一个不稳定系统。在这个例子中,我们将使用三层架构,以 PostMan(或 Insomnia)作为我们的交互层,然后使用两个不同的 Web API 来模拟我们的聚合层和数据层。首先,我们将创建我们的聚合服务:

dotnet new webapi -n AggregatorDemo

我们稍后会讨论这个问题,因为这个聚合器将是我们要监控性能的应用程序。不过,现在我们需要定义一个上游依赖。这将设计成随着时间的推移对我们的聚合服务性能产生负面影响:

dotnet new webapi -n DataAccessDemo

我们首先将关注DataAccessDemo应用程序。在这里,我们的目标将是创建一个包含自身恢复机制的失稳系统。我们将使用这个系统在我们的聚合器应用程序中检测到性能不佳时,主动从系统性能下降中恢复。为此,我们的DataAccessDemo应用程序将相对简单。我们将提供两个端点:一个将用作聚合器应用程序的上游依赖项,另一个将用于从性能下降中恢复。

在实践中,提供一个作为性能管理访问点的端点是常见的。它可能重新初始化缓存,强制垃圾回收,或者丢弃其线程池中的任何挂起的线程。在我们的情况下,我们只需重置一个计数器以重新稳定我们应用程序的性能。然后,这个计数器将被我们的依赖端点用来强制应用程序中不断增长的延迟。

为了启动这个过程,我们首先定义我们的监听端口,以避免与我们的聚合器 API 冲突:

public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
    WebHost.CreateDefaultBuilder(args)
        .UseUrls("https://[::]:33333")
        .UseStartup<Startup>();

现在,我们将定义一个静态类来保存我们的延迟计数器。这将设计成每次访问时都会增加下一次调用的延迟。此外,为了方便我们的恢复端点,我们将提供一个机制来重置计数器到其初始状态,如下所示:

public static class Latency {
    private static int initialLatency = 1;
    private static int counter = 1;

    public static int GetLatency() {
        //milliseconds of latency. increase by .5 second per request
        return counter++ * 500; 
    }

    public static void ResetLatency() {
        counter = initialLatency;
    }
}

最后,从我们的控制器中,我们将设置一个延迟到我们的依赖请求,之后我们将返回一个任意值。然后,我们将公开一个重置端点以稳定我们的应用程序,如下所示:

[Route("api/[controller]")]
[ApiController]
public class DependencyController : ControllerBase {
    [HttpGet("new-data")]
    public ActionResult<string> GetDependentValue() {
        Thread.Sleep(Latency.GetLatency());
        return $"requested data: {new Random().Next() }";
    }

    [HttpGet("reset")]
    public ActionResult<string> Reset() {
        Latency.ResetLatency();
        return "success";
    }
}

这个控制器签名将定义我们的聚合器 API 的垂直依赖关系。因此,现在,让我们看看我们将如何监控这个应用程序随时间推移的性能下降。

性能监控中间件

为了监控我们应用程序特定方面的健康状态,我们将创建一个IHealthCheck中间件的实例。这个中间件用于定义确定系统相对健康所需的操作。就我们的目的而言,我们希望定义一个健康检查,确认我们依赖的数据服务的响应时间低于两秒。一旦达到这个阈值,我们将认为系统已经退化。这将允许我们的看门狗通知我们可能需要重启。然而,如果服务响应时间增加到五秒,我们将认为它不健康,通知看门狗启动重置。

这个IHealthCheck中间件的实例将在启动时由我们的系统注册,当我们向应用程序添加健康检查时。而且,正如.NET Core 中的所有事情一样,添加健康检查就像注册一个服务和配置你的应用程序一样简单。所以,首先,在你的ConfigureServices(IServicesCollection services)方法中,只需添加以下代码行来设置所有必要的支持类和扩展,以便使用健康检查:

services.AddHealthChecks();

然后,在你的 Configure(IApplicationBuilder app, IHostingEnvironment env) 方法中,添加以下代码:

app.UseHealthChecks("/health");

这种简单的注册模式将你的应用程序设置为在用户向 UseHealthChecks(<path>) 中指定的路径发送请求时提供所有配置的健康检查中间件的结果。因为我们没有在我们的中间件中指定任何特定的系统检查,所以此端点将返回无趣的内容。要测试它,只需导航到你的应用程序的主机根目录,并附加 /health 路径。你应该看到以下输出:

图片

这确认了我们的应用程序正在响应健康检查。因此,现在,我们需要通过定义我们的中间件来定义我们想要在验证系统健康时使用的特定检查。我们将首先创建 IHealthCheck 接口的一个实现,为了简单起见,我们将称之为 DependencyHealthCheck。这个类将包含我们的响应阈值的定义,以毫秒为单位,如下面的代码所示:

public class DependencyHealthCheck : IHealthCheck {
    private readonly int DEGRADING_THRESHOLD = 2000;
    private readonly int UNHEALTHY_THRESHOLD = 5000;

现在,我们需要实现公共的 CheckHealthAsync() 方法以满足 IHealthCheck 接口的要求。对于这个方法,我们将向我们的上游依赖发送一个请求,并使用 System.Diagnostics 命名空间中的 Stopwatch 类跟踪解决它所需的时间。

CheckHealthAsync() 方法的签名接受一个 HealthCheckContext 实例,该实例将为我们的类提供注册信息以及一个取消令牌。我们将忽略这些信息,但它们仍然是满足接口签名的必需条件。然后,我们将创建我们的 HttpClient 实例(使用我们在第九章(e93c024e-3366-46f3-b565-adc20317e6ec.xhtml),HTTP in .NET)中讨论的 HttpClientFactory)并构建一个请求到我们想要验证其稳定性的端点。因此,我们的 CheckHealthAsync() 方法的第一部分应该看起来像这样:

public async Task<HealthCheckResult> CheckHealthAsync(
    HealthCheckContext context,
    CancellationToken token = default(CancellationToken)
) {
    var httpClient = HttpClientFactory.Create();
    httpClient.BaseAddress = new Uri("https://localhost:33333");
    var request = new HttpRequestMessage(HttpMethod.Get, "/api/dependency/new-data");

在这一点上,我们将想要设置我们的计时器来跟踪请求花费的时间,以便我们可以确认它是否低于我们指定的阈值:

Stopwatch sw = Stopwatch.StartNew();
var response = await httpClient.SendAsync(request);
sw.Stop();
var responseTime = sw.ElapsedMilliseconds;

最后,我们将检查响应时间与我们的阈值,根据外部依赖的稳定性返回一个 HealthCheckResult 实例,如下所示:

if (responseTime < DEGRADING_THRESHOLD) {
    return HealthCheckResult.Healthy("The dependent system is performing within acceptable parameters");
} else if (responseTime < UNHEALTHY_THRESHOLD) {
    return HealthCheckResult.Degraded("The dependent system is degrading and likely to fail soon");
} else {
    return HealthCheckResult.Unhealthy("The dependent system is unacceptably degraded. Restart.");
}

这完成了我们的 IHealthCheck 中间件的实现,因此现在我们只剩下将其注册为健康检查到我们的应用程序服务。为此,只需调用 Startup.cs 文件中 AddHealthChecks() 方法返回的 IHealthChecksBuilder 类的 AddCheck() 方法。此方法接受一个名称以及你的 IHealthCheck 中间件实现的一个显式实例。

添加此代码后,你的 Startup.cs 文件应该包含以下代码:

services.AddHealthChecks()
    .AddCheck("DataDependencyCheck", new DependencyHealthCheck());

并且,有了这个,我们就得到了一个自定义的健康检查,它将通知任何监控系统我们系统中的不稳定状态,这是由于上游依赖项的失败引起的!为了确认它返回了适当的响应,只需 ping 你的 /health 端点 5 到 10 次,以增加依赖服务上的延迟计数器,然后观察健康状态如何超过两秒和五秒的阈值。

在这里,你可以看到当响应时间超过两秒阈值时,我的服务返回的响应为 Degraded

并且在仅经过几次更多请求后,响应下降到 Unhealthy

因此,现在你已经看到了如何仅用几行代码就实现了一个可扩展的系统,用于监控你整个系统的健康状态。

实现看门狗

我们主动监控解决方案的最后一部分是实现我们自己的看门狗服务。由于我们已经为不稳定 API 的性能重置配置了一个外部钩子,我们将想要定义一个利用它的主动恢复机制。因此,对于这个服务,我们将设置一个循环,定期 ping 我们的聚合器 API 的 /health 端点,并在系统返回 DegradedUnhealthy 时重置数据依赖 API。由于在这种情况下这是一个相当简单的练习,我们可以保持我们的看门狗简单。我们将从一个控制台应用程序开始,使用我们的 CLI 创建:

dotnet new console -n WatchdogDemo

我们只会向我们的项目中添加几行代码。首先,我们将设置我们的 HttpClient 实例。由于我们应用程序的范围从未离开 Main() 方法,我们可以安全地创建一个私有、静态、单例的 HttpClient 类,而无需依赖于 HttpClientFactory 来管理我们客户端的多个实例并防止线程饥饿。我们还将把我们的 Healthy 状态代码分配给一个 private readonly 变量:

public class Program {
    private static readonly HttpClient client = new HttpClient();
    private static readonly string HEALTHY_STATUS = "Healthy";

一旦设置好,我们的 Main() 方法就变成了十几行相对简单的代码。首先,我们创建一个无限循环,以便我们的服务在明确停止之前一直运行。然后,我们向我们的聚合器 API 发送请求以获取健康状态响应:

static async Task Main(string[] args) {
  while(true) {
    var healthRequest = new HttpRequestMessage(HttpMethod.Get, "https://localhost:44444/health");
    var healthResponse = await client.SendAsync(healthRequest);
    var healthStatus = await healthResponse.Content.ReadAsStringAsync();

一旦我们收到响应,我们可以确认它已经返回健康状态。如果没有,我们只需向数据服务的 /reset 端点发送另一个请求。同时,如果它已经返回健康状态,我们只需记录请求并继续处理:

if (healthStatus != HEALTHY_STATUS) {
  Console.WriteLine($"{ DateTime.Now.ToLocalTime().ToLongTimeString()} : Unhealthy API. Restarting Dependency");
  var resetRequest = new HttpRequestMessage(HttpMethod.Get, "https://localhost:33333/api/dependency/reset");
  var resetResponse = await client.SendAsync(resetRequest);
} else {
  Console.WriteLine($"{DateTime.Now.ToLocalTime().ToLongTimeString()} : Healthy API");
}
Thread.Sleep(15000);

注意,对于这个例子,我确定我们的健康检查间隔应该是 15 秒。在我们的情况下,我主要是随机选择这个值。然而,当你配置或实现自己的应用程序中的看门狗解决方案时,你最好考虑过于频繁或过于稀疏的间隔的影响,并相应地配置你的系统。当所有三个应用程序都运行起来后,你可以看到我的看门狗系统正在按预期运行,当响应时间变得无法接受地慢时,它会重置我的数据服务:

图片

如此一来,我们的主动监控系统就完成了。虽然在这个示例代码中我们不得不制造不稳定性,但我们制定的监控和缓解这种不稳定性的策略将适用于你未来可能要编写的任何项目。

摘要

本章以一个不幸常见的、天真实现的分布式架构的例子开头。以此为基准,我们考虑了架构设计决策对应用程序性能的影响。我们了解了分布式架构中的垂直依赖,以及我们如何通过严格遵守的三层设计来减轻其影响。

接下来,我们探讨了糟糕的日志记录实践如何使将日志记录作为性能监控解决方案变得不可行。我们讨论了日志解决方案的信噪比的概念,并检查了我们应应用于我们自己的日志策略的实践,以最大限度地提高日志的有效性。

一旦我们全面分析了为什么日志记录构成一个不足够的监控策略,我们就深入研究了真正稳健方法的属性。我们了解到,一个设计良好的监控策略应该利用你进行的各种单元和负载测试,以及它们提供的信息。我们了解到,一个稳健的策略涉及主动监控,并且应该在用户遇到问题之前系统地识别问题。最后,我们看到了这样的解决方案如何以及应该采取主动方法来尝试解决任何健康或性能问题,并通知相关工程师问题。

最后但同样重要的是,我们探讨了如何在.NET Core 中精确地实现这样的监控策略。在 ASP.NET Core 应用程序中实现我们自己的监控中间件,我们看到了框架提供的内置监控解决方案的强大和灵活性。随着这个架构拼图块的到位,我们准备开始探索一些更高级的主题。考虑到这一点,我们将使用下一章来探讨我们如何利用.NET Core 中的可插拔协议的概念来设计自己的应用程序层协议。

问题

  1. 水平依赖和垂直依赖有什么区别?它们如何影响性能?

  2. 什么是三层架构?

  3. 信号与噪声比是什么?为什么它在日志记录中很重要?

  4. 为什么日志记录对于性能监控来说是不足够的?

  5. 稳健的监控策略的关键属性是什么?

  6. 负载测试是什么?它能提供哪些信息?

  7. 严格的日志策略意味着什么?

进一步阅读

对于在您的 ASP.NET Core 应用程序中利用健康检查和性能监控的额外资源,我推荐 Jason de Oliveira 和 Michel Bruchet 所著的 Learning ASP.NET Core 2.0,特别是其中名为 Managing and Supervising ASP.NET Core 2.0 Applications 的章节。这是一本非常优秀的读物,它将提供大量可以横向迁移到不同情境的技能。您可以在 Packt 找到它:www.packtpub.com/application-development/learning-aspnet-core-20

此外,如果您想继续学习架构设计,并专注于基于微服务的生态系统,我推荐 Ganesan Senthilvel、Ovais Mehboob Ahmed Khan 和 Habib Ahmed Qureshi 所著的 Enterprise Application Architecture with .NET Core。他们深入探讨了在云环境中多层架构的广度,非常具有启发性。您可以通过 Packt 找到它:www.packtpub.com/application-development/enterprise-application-architecture-net-core

第五部分:高级主题

在本书简短的最后一部分,我们将探讨更多高级主题,并考虑如何超越.NET Core 直接提供的网络编程功能。在第一章中,我们将探讨如何使用微软的可插拔协议模式扩展功能和预期行为。接下来,我们将思考如何检查我们网络上的流量,并确定其性能和稳定性。最后,我们将探讨实现 SSH 客户端和机器之间的远程访问。

本节将涵盖以下章节:

第十七章,在.NET Core 中的可插拔协议

第十八章,网络分析和数据包检查

第十九章,远程登录和 SSH

第十七章:.NET Core 中的可插拔协议

在前面的章节中,我们涵盖了与一般网络编程概念、挑战和模式相关的大量主题。在本章中,我们将看到如何利用这些知识在.NET Core 中定义自己的应用层协议。我们将了解可插拔协议的概念。我们将看到.NET Core 如何让您扩展WebRequest类以定义自己的网络交互标准和期望。我们将探讨如何注册您的新协议以便由WebRequest工厂方法使用。最后,我们将讨论这样做的好处,以及何时应该考虑使用它。

本章将涵盖以下主题:

  • 理解可插拔协议

  • 如何实现System.Net命名空间中WebRequest类的子类

  • 支持基础设施,允许在.NET Core 中使用自定义通信协议

技术要求

要跟随本章中的代码示例,或者打开它们并根据自己的目的进行修改,你需要一个集成开发环境(IDE)或代码编辑器(当然,Visual Studio 或 Visual Studio Code),以及源代码,可以在以下位置找到:github.com/PacktPublishing/Hands-On-Network-Programming-with-C-and-.NET-Core/tree/master/Chapter%2017.

此外,我们还将继续利用我们偏好的 REST 客户端,所以请准备好 PostMan (www.getpostman.com/downloads/) 或 Insomnia REST 客户端 (insomnia.rest/).

理解可插拔协议

要了解如何最好地利用可插拔协议,以及它们在您的软件中可能何时有价值,我们首先必须了解它们是什么。我们需要知道它们旨在解决什么问题以及它们是如何寻求解决这些问题的。因此,我们将从对可插拔协议的简单定义开始,然后探讨为什么我们可能想要使用它们。

什么是可插拔协议?

可插拔协议是一种自定义的应用层通信协议的实现,旨在与.NET Core 的WebRequest模型集成以处理网络交互。或者,更简单地说,这是.NET Core 如何支持非标准通信协议。就是这样。

在第五章生成 C#网络请求中,我们看到了简单的请求/响应模式在促进我们应用中与源无关的网络通信方面的强大功能。此外,我们还看到了.NET Core 框架如何通过支持开箱即用的WebRequest子类来减少这些类型交互的复杂性,这些子类支持我们源代码中最常见的应用层协议。然而,尽管框架本身为我们提供了常见协议的开箱即用实现,但可插拔协议实现模式为我们提供了一个入口点,通过它可以扩展该系统以支持较少见或定制的协议。它赋予我们定义自己的实现并将其配置为与框架类一样容易和可靠地使用的功能。

为什么使用可插拔协议?

让我们假设一下,你想要简化你软件生态系统中的两个关键资源之间的通信。为了实现这一点,你已经设计了一个高度优化的数据包结构和交互机制。头部的大小仅恰好足够以传达关于专有应用数据包的详细信息。让我们再假设这个协议使用一个简短的协商阶段,这个阶段发生在主机之间以促进数据传输。

由于这是专有的,你可以在每个应用中手动实现两个主机之间的交互。然而,这种方法并不完全随着你协议用户潜在增长而扩展。如果另一个团队决定他们也想利用你的优化协议,你将负责与他们沟通你程序交互的具体细节,这会给知识转移过程中的任何人都带来时间和生产力的成本。

另一方面,这种做法在你自己的团队人员流动方面有多大的持久性?如果你失去了对协议设计和使用有深入了解的原始团队成员,你需要让新团队成员加入以填补空缺。在两个网络主机之间编写专有交互机制需要增加新成员的入职时间。

如果,你不需要为你的协议的任何消费者使用定制的交互机制,而是可以将其封装在一个新用户已经熟悉的模式后面,会怎么样?使用可插拔协议,你就可以做到这一点。利用WebRequestWebResponse类的约定和简单性的唯一先决条件是,你的协议的交互在最基本的概念层面上可以简化为人们已经理解的请求/响应模式。

通过将我们自定义协议的操作封装在WebRequestWebResponse类的约定之下,我们可以减轻由专有协议引入的所有挑战。新的团队不再需要担心您头部结构的复杂性,或者找出启动数据传输所需的手势。您只需在您的自定义WebRequest子类中实现这些组件,并在成功获取响应后暴露生成的数据流。此外,引入新的团队成员不需要学习您协议复杂性的额外开销。

定义一个可插拔协议

尽管封装自定义通信协议可能带来的好处可能很明显,但在WebRequestWebResponse类背后,您可能会惊讶地发现实现这个目标其实相当简单。您将面临的最大挑战是简单地学习在创建子类时需要覆盖或实现的方法和属性。但从高层次来看,创建一个可插拔协议相当直接。让我们看看您需要遵循的步骤,然后我们将在下一节中按照这些步骤实现我们自己的可插拔协议。

定义您的模式

如我之前在本节中提到的,可插拔协议代表了一种针对 Web 交互的自定义应用层协议。通过此协议在两个应用程序之间发送的请求需要以独特的方式进行处理。为了将您自定义协议中传输的请求与其他更常见的协议区分开来,您需要为其定义模式。

如您可能从第二章“DNS 和资源定位”中记得的,URL 的模式组件是路由器和 Web 主机确定如何尝试解析请求数据包的头部的方式。使用httphttps模式表示接收主机,消息细节可以通过解析传入的字节流,根据 HTTP 头部的分区和界定标准来推导。因此,接收带有在请求 URI 中指定为http://的模式请求的主机将期望传入流的前几个字节指定 HTTP 动词、特定的资源路径和版本,之后是一个行终止字符,如下所示:

GET /some/resource HTTP/1.1

然而,假设您的协议旨在去除那些 HTTP 特定的消息细节。比如说,您正在设计一个接受任意数量的记录,记录字段具有单个固定长度的消息队列。现在,假设您的协议的请求消息以一个由管道分隔的值组成的头部进行格式化,这些值说明了数据包结构的各种细节,例如 0 索引的数据包长度、在头部中指定的可选细节的数量以及消息记录中每个字段的字节大小,这样您的消息头部看起来就像这样:

512|1|32

如果你使用http://方案发送消息,并且其接收到的第一行字节包含这些详细信息,消费应用会简单地响应一个401 - Bad Request状态。它无法解析头部的信息,因为它没有进行解析的正确上下文。这就是方案为远程主机提供的内容:解析其消息的初始上下文。因此,定义一个与其他自定义协议或方案(当然也与任何标准化协议,如 HTTP 或 FTP)不同的唯一方案非常重要。你的方案需要具有唯一可识别性,以确保在任何使用它的主机上都不会出现解析消息失败的情况。

实现你的协议交互

正如我在本节中至少提到过几次的那样,将你的协议作为可插拔协议实现的第一项任务是将其交互封装到最基本的请求/响应模式中。你需要在定义自身为WebRequest基类子类的类内部完成这项工作。在这个子类中,你的首要任务是覆盖GetResponse()GetResponseAsync()方法。

这些方法是你定义任何自定义WebRequest特定交互的地方,包括定义消息发送的传输层,以及客户端接收到响应流之前应该发生的任何协议头部解析。当你打算创建WebRequest对象的子类时,编写这些类的自己的实现是你需要采取的最小步骤,以便编写传统的可插拔协议。

如果你的协议规范有任何额外的功能或方面需要考虑,例如认证机制或响应缓存,你还需要覆盖WebRequest类的这些方面。此外,任何不属于WebRequest类公共接口、特定于你的协议的内容,都需要通过一些特定于你的协议的方法或属性来暴露。

注册你的方案

现在你已经了解了如何区分来自我们协议的消息与其他应用层协议的消息,你需要通知应用程序上下文这样做。一旦你定义了你的方案,你就可以通过WebRequest类注册一个处理程序。

你可能还记得在第五章,在 C#中生成网络请求中,在WebRequest 的子类部分,我提到WebRequest的创建方法将为基于其指定方案的请求提供默认的子类实现。所以,如果你调用WebRequest.Create("http://somedomain.com"),返回给你的WebRequest实例将是HttpWebRequest类型的实例(尽管,正如我们在该节中以及在整个第九章,在.NET 中的 HTTP中已经看到的,到这一点上,HttpWebRequest类应该被认为是过时的)。

通过注册我们自定义的WebRequest类子类,并指定它应该使用的方案,我们可以获得相同的行为。用户不需要创建我们类的特定实例来使用它;他们可以直接调用WebRequest.Create(<url>)并传入一个 URL,其方案部分标识我们的协议。到那时,WebRequest类只是简单地返回一个我们指定的、用于处理该方案的类的实例。

现在我们已经知道了在.NET Core 应用中实现和集成可插拔协议所需的内容,是时候看到它实际应用了。

构建我们的自定义子类

我们首先想要做的是定义我们自定义协议的交互机制。这些交互将是我们区分我们的协议与其他应用层协议的方式。你可能还记得我在第十章中提到的FTP 和 SMTP,每个应用层协议都是设计来优化特定业务层应用任务的。这个原则也适用于你实现的任何自定义协议。为此,我们将定义我们的协议以满足一个非常具体的业务需求。当然,由于这完全是出于演示目的,我们不会关心它是否是我们业务需求的最优设计,但只关心它在交互中是否定义良好。一旦我们有了这个基础,我们就可以在我们的WebRequest子类中实现这个规范。

定义我们的协议

在本章的演示中,我们将使用一个针对向数据库服务器发送记录列表进行优化的新协议。因为我们可以随意命名我们的协议,而且我觉得这样做很有趣,所以我们将它称为DwayneTheRockJohnson协议,或者简称为Rock协议,它将在请求 URI 中使用dtrj://作为其方案指定。

在我们的协议中,请求消息将针对数据库中的特定表发送,其正文将包含对那个表进行任意数量更新的更新。我们将为我们的协议中的请求定义三个动词:DELETE,INSERT 和 UPDATE。带有任何给定动词的请求消息只能对表执行指定的更新,正文中的记录将是一个以管道符分隔的字段数组。为了我们的目的,我们的消息头部将包含以下格式的管道符分隔字段:

  • VERB:一个 2 位动词指示器:00 = DELETE01 = INSERT10 = UPDATE

  • SIZE:一个 30 位字段,表示包括所有头部值在内的消息总大小,以字节为单位(提供最大 134 MB 的消息大小)。

  • FIELDS:一个 28 字节的字段,表示每条记录中有多少字段,允许的最大字段数大于任何现有数据库系统可能达到的。我们之所以选择这个,是为了使我们的头部与典型的 16 位字长保持一致的对齐。

  • CHECKSUM:消息正文的 32 字节安全哈希,以防止篡改。

在这个 64 字节的头部之后,其余的正文假定是一系列以管道符分隔的记录,我们的目标主机将能够根据头部中的FIELDS值和管道符分隔的约定来解析这些记录。综合来看,我们的 Rock 消息的结构将如下所示:

图片

当服务器成功接收到一条消息时,其响应(假设一切处理得当),将包括以下头部:

  • STATUS:一个 2 位消息,表示对数据源更新尝试的成功情况:00 = SUCCESS01 = PARTIAL SUCCESS,和10 = FAILURE

  • TIMESTAMP:一个 14 位时间戳,表示更新成功提交到数据库的时间

  • SIZE:一个 16 位字段,表示包含在消息中的大小,包括所有头部值,以字节为单位(提供最大 134 MB 的消息大小)。

这个 32 位头部将跟随任何包含在头部中的状态消息,以及头部中提供的状态代码,这可能包括有关哪些记录成功更新或为什么某些或所有记录未能更新的信息。有了这个,我们的响应将如下所示:

图片

显然,在定义一个通信协议时,需要考虑的不仅仅是这里所提出的,但这对我们在.NET Core 项目中实现它们已经足够了。因此,既然我们已经知道了我们的模式定义将是什么样子,以及我们的协议应该如何组成和解析,那么我们就开始定义我们的WebRequest子类。

实现我们的协议

我们的首要任务是创建一个具有接受 URI 实例或简单 URI 字符串作为输入的构造函数的 WebRequest 子类。这是 WebRequest.Create() 方法在给定具有我们方案的 URI 时用来实例化我们的特定类的。我们将在一个简单的控制台应用程序中实现这个类,所以首先创建你的应用程序:

dotnet new console -n CustomProtocolDemo

接下来,创建一个用于我们的 WebRequest 子类的类,并定义其构造函数。在这个例子中,我将创建一个 RockWebRequest 类,其构造函数用于设置用于向目标主机发送请求时使用的 URI 实例:

public class RockWebRequest : WebRequest {
    public override Uri RequestUri { get; }
    ...

    public RockWebRequest(Uri uri) {
        RequestUri = uri;
    }

在这里,重要的是要注意,WebRequest 实例(或其任何子类的实例)旨在仅使用一次。后续请求需要额外的类实例。这就是为什么 RequestUri 字段(它从父类派生,但必须由我们的子类实现才能使用)是只读的,并且仅在初始化新实例时写入。它不会,也不应该,在请求创建后改变。

下一步,我们需要为我们的 WebRequest 类的用户提供一个机制,使他们能够定义要发送的记录以及那些记录上要使用的信息动词。这将使用户有机会在请求响应之前相应地配置他们的消息。为此,我们将为我们的报头和请求体定义几个属性。我们可以使用从 WebRequest 类派生的 Method 属性来定义我们的信息动词,但我更愿意在我们的组件中强制执行得更严格一些。我们将创建一个新的属性,其类型将是一个 enum,用于定义我们的三个可能的动词。使用 enum 也将给我们带来将用户提供的值映射到其底层 2 位表示的优点:

public enum RockVerb {
    Delete = 0b00000000000000000000000000000000,
    Insert = 0b01000000000000000000000000000000,
    Update = 0b10000000000000000000000000000000
}

在这里,我们用 32 位整数的最高有效位来表示我们的 2 位代码,以便特别容易与我们的 size 报头值执行位运算 OR

接下来,我们将在我们的 RockWebRequest 上定义相应的属性。此外,我们还将定义一个 IEnumerable 用于任何给定请求中要发送的记录,以及一个 int 用于存储每条记录中的字段数。由于我们希望我们的协议能够处理任意的记录定义,但又不想花费太多时间编写足够的序列化代码和泛型对象解析器,因此我们将定义我们的记录为字符串,并让消费者为他们的记录列表生成一个管道分隔的格式:

public RockVerb Verb { get; set; } = RockVerb.Update;
public IEnumerable<string> Records { get; set; }
public long Fields { get;set; }

在这里,我们将默认我们的VerbInsert。为了演示的目的,我们将说Update操作只有在没有与要插入的记录具有相同键的现有记录时才会工作。有了这个规则,Insert是唯一安全执行的操作,因为它永远不会意外地覆盖现有数据。鉴于这一点,它非常适合我们的默认Verb值。然而,一旦这个值就位,我们就准备好实现我们的GetResponse()GetResponseAsync()方法。我们的头部的其他属性可以在不使用用户输入的情况下推导出来,因此我们将在这两个方法中这样做。

实现请求管道

实现我们的可插拔协议的下一步,也是最后一步,是为我们的消息定义请求和响应行为。我们将首先重写同步的GetResponse()方法。此方法将负责构建具有适当值的字节流,以符合我们的头规范,并通过我们选择的传输协议提交。对于这个演示,我们将使用 TCP,因为我们已经在第十一章,“传输层 – TCP、UDP 和多播”中看到了如何这样做。

在决定使用哪种传输层协议来支持您可能编写的任何自定义应用层协议时,请尽可能多地考虑和思考。任何传输层协议的性能和特定用例都可能对您自定义协议的性能产生与设计中的任何其他方面一样大的影响。始终尝试使用适合手头工作的正确工具。

与我们所有的演示一样,我们将假设我们的软件的消费者始终适当地使用它,因此我们将放弃任何请求前的验证。我们只假设我们为消息体配置了一些记录,并且我们的动词已经适当地定义。

在这一点上,根据我们的协议检查构建和发送消息的高级工作流程对我们来说将大有裨益。由于我们假设在尝试发送请求之前我们有记录可用(并且我们的服务器将承担验证我们数据的责任),我们将通过连接每个记录(我们的用户已经方便地将每个记录的所有字段序列化为以管道分隔的字符串)来构建我们的消息体,并且用管道分隔每个记录。接下来,我们将连接的字符串转换为字节数组。从那里,我们将构建我们的校验和哈希,以及定义我们的消息大小。然后,我们将构建包含我们的头和消息体的完整字节数组,并将它们写入针对指定主机 URI 的TcpClient流。

将其付诸实践,我们首先生成我们的消息字节数组:

public override WebResponse GetResponse() {
    var messageString = ConcatenateRecords();
    var message = Encoding.ASCII.GetBytes(messageString);

在以下代码中,实际的连接操作发生在单独的私有方法中,以帮助提高我们代码的可读性:

private string ConcatenateRecords() {
    StringBuilder messageBuilder = new StringBuilder();
    foreach (var record in Records) {
        if (messageBuilder.ToString().Length > 0) {
            messageBuilder.Append(Environment.NewLine);
        }
        messageBuilder.Append(record);
    }
    return messageBuilder.ToString();
}

如你所见,我们使用与我们在记录中分隔字段相同的管道分隔符来分隔记录。我们有这样的自由,因为我们的fields报头通知服务器每条记录应解析多少个单独的字段。使用与字段相同的分隔符来记录可以节省服务器寻找记录结束的异常情况。它只需查找分隔符字符的下一个实例,并据此进行操作,当上一个记录的字段计数达到时,开始新的记录。

接下来,我们将根据我们的请求体内容计算校验和。如果你真正定义了自己的协议用于商业目的,你可能会允许客户端从他们有实现的哈希算法中选择。然而,对于我们来说,我们只需说它总是使用 SHA-256 进行计算。一旦我们计算出了哈希值,我们就可以根据客户端提供的值设置我们的VERB位。接下来,我们将确定我们的数据包大小(64 字节头加上我们的消息字节数组的长度),并将所有字节写入二进制流。在我们的GetResponse()方法中,这简单地如下所示:

var byteList = new List<byte>();

var checksum = SHA256.Create().ComputeHash(message);

byteList.AddRange(GetHeaderBytes(message.Length));
byteList.AddRange(checksum);
byteList.AddRange(message);

GetHeaderBytes(message.Length)方法用于将我们的Verb的二元值、给定消息的大小和Fields属性转换为 32 字节头,该头位于 32 字节校验和之前。该方法相对简单,只是对适当的值应用了一些位操作,如下所示:

private IEnumerable<byte> GetHeaderBytes(int messageSize) {
    var headerBytes = new List<byte>();
    int verbAndSize = (int)Verb | (messageSize >> 2);
    headerBytes.AddRange(BitConverter.GetBytes(verbAndSize));

    //Add empty byte padding in the FIELDS header
    for (var i = 0; i < 20; i++) {
        headerBytes.Add(0b00000000);
    }

    headerBytes.AddRange(BitConverter.GetBytes(Fields));
    return headerBytes;
}

一旦计算出了该报头值,我们就可以将其应用于输出字节流,随后是校验和,然后是我们的请求消息的字节数组。最后,由于我们已经声明我们的协议将使用 TCP 作为其底层传输机制,我们将创建我们的TcpClient实例,连接到由RequestUri字段指定的主机和端口。再次强调,我们将对能够连接到指定的 URI 做出很多假设。在现实情况下,你将实现你的TcpClient连接,并具有更健壮的错误处理和连接验证,正如我们在第十一章中所述,传输层 - TCP 和 UDP。然而,现在,我们只需假设我们的连接成功,将我们的字节写入我们的NetworkStream实例,然后将它传递给我们的RockWebResponse实例,并从我们的方法中返回它:

    TcpClient client = new TcpClient(RequestUri.Host, RequestUri.Port);
    var stream = client.GetStream();
    stream.Write(byteList.ToArray(), 0, byteList.Count);
    return new RockWebResponse(stream);
}

这完成了我们对GetResponse()方法的实现。显然,在实践上实现GetResponseAsync()方法将看起来非常相似,但我们将使用我们在第六章中建立的异步编程模式,流、线程和异步数据传输。我将把它留给你作为练习,在我们的RockWebRequest类中实现这个特定的方法。

WebResponse类派生

如您可能已经注意到的,我们的 RockWebRequestGetResponse() 方法返回一个 RockWebResponse 类的实例。这是编写可插拔协议的实现硬币的另一面。您必须定义一个处理程序,可以从响应流中剥离和验证任何特定于协议的元数据或头信息,并将它们存储在您自定义的 WebResponse 类的只读属性中,然后再将响应流返回给调用它的方法。用户期望从您的可插拔协议中获得的一个关键好处是,它们将抽象出从响应字节流中解析和操作特定于协议信息的所有细节。

我们为此演示的实施方案将非常简单。我们只是试图说明如果你未来承担这个任务时需要采取的概念性步骤。然而,由于可插拔协议的高度特定性,任何超出这一高级概念性方法的内容最终都将证明是徒劳的。鉴于这一点,我们的完整 RockWebResponse 类如下所示:

public class RockWebResponse : WebResponse {
    private Stream _responseStream { get; set; }
    public DateTime TimeStamp { get; set; }
    public RockStatus Status { get; set; }
    public int Size { get; set; }

    public RockWebResponse(Stream responseStream) {
        _responseStream = responseStream;

        byte[] header = new byte[4];
        _responseStream.Read(header, 0, 4);
        var isValid = ValidateHeaders(header);
    }

    public Stream GetResponseStream() {
        return _responseStream;
    }

    private bool ValidateHeaders(byte[] header) {
        //validate headers
        return true;
    }
}

我们首先定义了用户可能关心的每个相关头值的公共属性。在这里,我们定义了一个简单的 RockStatus 枚举来捕获我们三种可能的状态,并将时间戳头值表示为 DateTime

类定义的核心在于其构造函数。在这里,我们的 RockWebResponse 实例负责从响应流中解析头值,并用它们对应的值填充其实例属性。请注意,我们从流中读取了前 4 个字节,这对应于我们的 32 位头定义。一旦完成,我们就将字节数组传递给我们的头验证函数,并返回我们的新实例(当然,在生产代码中,您会在验证失败时抛出错误,而不是返回一个新实例)。由于我们在将实例返回给消费者之前已经从响应流中读取了头信息,因此对 GetResponseStream() 的调用将返回只包含响应体的流。

通过定义这个特定于协议的响应处理程序,我们(在概念上)完成了可插拔协议的实现。如果您真正想要创建一个有效的 WebRequest 子类,您还必须重写许多其他方法,并且微软文档中有大量关于具体需要什么的信息。然而,按照我们在这里建立的模式,当需要这样做时,您应该处于正确的思维状态来处理这些任务。到目前为止,我们唯一剩下的事情就是通过 WebRequest 工厂方法将其暴露给其他工程师,我们将在下一节中探讨这一点。

利用自定义协议

定义我们的可插拔协议实际上是在WebRequestWebResponse范式下定义我们自定义协议的特定交互。现在我们已经做到了这一点,但我们必须通知WebRequest类我们已经满足了任何寻求使用我们方案的消费者使用的要求。这意味着我们需要定义一个工厂类来创建我们派生类的实例。

实现IWebRequestCreate接口

我们希望将我们的类与WebRequest类注册,以便当使用适当的方案调用WebRequest.Create(<uri>)方法时,返回RockWebRequest的实例。然而,为了做到这一点,我们需要向WebRequest提供一个工厂类,该类可以按需构建我们RockWebRequest类的良好格式实例。这种行为由IWebRequestCreate接口定义,这就是我们需要在我们的工厂类中实现以注册其与WebRequest一起使用的接口。

这个接口的足迹,幸运的是,极其简单。实际上,它只有一个方法。我们整个实现不到一打行代码:

public class RockWebRequestCreator : IWebRequestCreate {
    public WebRequest Create(Uri uri) {
        return new RockWebRequest(uri);
    }
}

通过这种方式,我们可以将我们的自定义协议类注册为在WebRequest.Create(<uri>)方法中给出的 URI 中出现我们的方案时使用。只需在应用程序启动时调用RegisterPrefix()方法即可:

WebRequest.RegisterPrefix("dtrj", new RockWebRequestCreator());

现在,你的自定义WebRequest类将可以自由地响应具有指定方案的 URI。

超越WebRequest

在撰写本书时,Microsoft 对WebRequest类的文档明确建议不要使用WebRequest或其任何子类。建议开发人员编写新软件时使用HttpClient或类似类。那么,这对可插拔协议的价值或用途意味着什么呢?

简而言之,这意味着你的工作还没有完全完成。正如我在第五章中提到的,在 C#中生成网络请求WebRequestWebResponse类仍然以某种形式在HttpClient类的底层运行。通过这些类定义你的协议交互仍然是实现.NET Core 中自定义协议的一个基本部分。从这些类派生出来为你提供了直接解析和处理你选择的任何传输协议的NetworkStream中的应用头部的灵活性。实际上,甚至指定你的传输协议这样的功能也无法使用HttpClient类来实现。这种对你原始协议请求的控制只能通过像WebRequestWebResponse这样的工具来实现。

然而,随着新开发者持续地远离WebRequestWebResponse的约定,你需要定义更多现代的抽象来保持你的协议库的相关性。为此,如果你发现自己需要定义自定义协议交互,考虑定义一个标准化的客户端实现。你的客户端可以(也许应该)继续利用从WebRequestWebResponse类派生的可插拔协议。

然而,通过为处理这些请求和响应定义一个干净的抽象,你为你的库用户提供了一个更容易使用、更容易理解的工具。如果你发现自己正在编写任何自定义通信协议,如果你想看到它在.NET 社区中得到更广泛的采用,请记住这一点。

有了这些,我们可以结束对可插拔协议的讨论。在下一章中,我们将继续探讨高级、低级主题。在那里,我们将探讨网络分析和数据包嗅探策略,以更深入地了解你的网络软件存在的更广泛环境。

摘要

本章深入探讨了.NET Core 中可插拔协议的非常狭窄的主题。我们了解到,可插拔协议实际上只是任何你想要定义的自定义应用层通信协议在框架中的表示。在我们建立这种理解的基础上,我们考虑了为什么我们应该花时间在我们的代码中将新的通信协议实现为可插拔协议。我们看到了另一种选择——在我们的整个应用程序中使用自定义、特定协议的代码——引入了时间和生产力成本,这可以通过简单地实现WebRequestWebResponse类的自定义子类来几乎完全消除。

在我们确立了使用可插拔协议的理由之后,我们研究了在我们的项目中如何实现这一点。我们了解了实现可插拔协议必须遵循的三个核心步骤。在我们这样做的时候,我们了解到每个步骤都服务于创建我们自定义协议的干净、基于约定的抽象。最后,我们研究了如何在我们的软件中实现不同的协议。我们设计了自己的测试协议,然后采取了必要的步骤来实现处理该测试协议交互的WebRequestWebResponse子类。

最后,我们研究了如何将我们的新类直接集成到WebRequestWebResponse框架中,以便为新开发者提供一个无缝的过渡,他们可能想利用我们的工作。在我们结束的时候,我们了解了我们应该采取的下一步,以保持与更现代的抽象模式的一致性,即在特定协议的Client类后面抽象交互。在下一章中,我们将探讨如何分析我们宿主机的网络接口和网络流量状态,以及这种能力为我们打开了哪些类型的应用程序。

问题

  1. 什么是可插拔协议?

  2. 将协议实现为可插拔协议有哪些优势?

  3. 在.NET Core 中构建可插拔协议需要哪些步骤?

  4. 为什么精确的架构定义对于监听服务器处理请求很重要?

  5. 你需要覆盖WebRequest方法的最低占用空间是多少来实现可插拔协议?

  6. 你必须提供哪些额外的接口来注册你的可插拔协议,以便由WebRequest.Create()方法使用?

  7. 你应该采取哪些额外步骤来使你的可插拔协议的采用更加容易且与现代惯例保持一致?

进一步阅读

由于这是一个在广泛框架中相对小众的主题,关于可插拔协议本身的特定主题,没有太多推荐的阅读材料。然而,如果你对可能促使需要自定义协议实现的环境和架构感兴趣,我建议查看Heartin Kanikathottu的《Serverless Programming Cookbook》。它从云平台托管源代码的各种网络交互讨论中,描绘了一个令人信服的新、优化的通信范例图景。你可以在 Packt 找到它:www.packtpub.com/application-development/serverless-programming-cookbook.

第十八章:网络分析和数据包检查

在本章中,我们将探讨编写软件和系统,使我们能够探索它们部署的网络。而之前,我们研究了我们的软件在网络环境中的表现,在本章中,我们将具体探讨网络本身的表现。我们将检查如何在网络软件内部实现资源和设备发现,以及我们如何收集、检查并传递通过网络传输的数据包。我们将考虑我们的网络活动如何可能对我们的软件产生负面影响,以及如何积极应对这些影响并从中恢复或完全避免它们。

本章将涵盖以下主题:

  • 广播端口和 IP 地址,以及如何使用它们来识别网络上的自己的软件或主机,或了解网络上其他主机的信息

  • 捕获和分析部署软件的主机上的设备和流量信息

  • 识别我们网络上的一些风险以及如何构建我们的应用程序以抵御这些风险

技术要求

在本章中,我们将使用本书 GitHub 仓库中可用的示例代码,链接如下:github.com/PacktPublishing/Hands-On-Network-Programming-with-C-and-.NET-Core/tree/master/Chapter%2018

查看以下视频以查看代码的实际运行情况:bit.ly/2HUai2a

此外,我们将简要讨论使用 Wireshark 检查和理解与我们的机器交互的网络请求的广度和数量的优点。如果您还没有为前几章下载此软件,它可以在以下链接找到:www.wireshark.org/#download

我强烈建议您熟悉它,因为它可以证明作为网络软件工程师的工具是无价的。

网络资源和拓扑结构

就像我们在第一章“网络概览”中讨论的那样,网络是一个对系统参与者无差别的系统。任何给定的网络对其连接的实体或主机没有任何了解。因此,它不能向任何试图连接到它的新主机提供此类信息。相反,这些主机负责向其他主机广播有关自己的信息。不仅如此,它们还负责监听来自其他主机的广播信息,以便它们可能知道网络上有哪些其他资源或主机。那么,这究竟是如何发生的呢?

节点间通信

在本书中描述的大多数主机到主机的交互中,一个主机必须通过路由器、交换机和网关的路径与另一个主机通信,以解析域名或 IP 地址。然而,通过外部主机设备链的地址解析过程并不能帮助我们建立与网络中下一个主机的初始通信。如果我们想让我们的主机成功地将信息传输到网络路径中的最近邻居,我们需要知道它可能监听的具体地址。

虽然我意识到你已经知道这个事实,但我敢打赌你并不经常思考这样一个事实:当你建立两个主机之间的连接时,那个连接完全是逻辑上的。预期是,在您的主机之间的网络路径上的每个路由器或交换机将简单地转发您的数据包到其目标,创建一个不间断的物理连接链。这些连接的总和构成了一个单一的逻辑连接。然而,为了使这起作用,您的宿主必须首先连接到该连接链中的下一个最近邻居。

每当我们想要通过 TCP 或 HTTP 等协议与远程主机建立高级连接时,运行在设备和网络层的软件必须为我们确定路径。它是通过向其最近的邻居发送消息并本质上询问该邻居是否可以解析路径来做到这一点的。然后,该网络设备将请求转发给其任何邻居,询问同样的问题。只要其中之一做出肯定的回答,你的最近邻居也会做出肯定的回答。如果我们把我们的网络看作一棵树,以我们的主机为根,我们可以用递归树遍历算法来考虑这个过程。这个递归算法会一直持续到做出两种可能的确定之一。在无法建立路径的情况下,网络树中的每个叶节点都会做出否定的回答,如下面的图所示:

图片

同时,在成功确定路径的情况下,目标主机最终将通过我们的网络树中的某些路径到达。在这种情况下,任何可以直接连接到我们的目标主机的主机节点都会做出肯定的回答,肯定的回答会一直传播回我们的源主机,通知它可以建立连接,如下面的图所示:

图片

注意,尽管如此,源端只知道可以建立一条路径。即使在最好的情况下,如果两个主机之间可以建立多条路径,也无法保证任何给定的数据包都会沿着最优路径(如果存在最优路径的话)传输。实际上,也无法保证给定请求的所有数据包都会沿着相同的路径传输。这正是我们在第十一章“传输层——TCP、UDP 和多播”中讨论的乱序数据包传输的原因。

到目前为止,我们只考虑了在传输层或应用层协议之间建立两个主机之间的连接。在这个背景下,我们没有必要使用 IP 地址或域名以及指定的监听端口来识别除了目标主机以外的任何东西。仅使用这些识别细节,网络和设备层协议就完成了在我们的网络上建立路径并将我们的请求数据包沿该路径转发的工作。我们的软件只是简单地接收两个主机之间的开放通信线路。

然而,您可能没有意识到,在网络层运行的软件必须通过其网络地址明确标识我们网络中的最近邻。此外,我们的主机必须通过邻居配置为监听的端口和通信机制与其最近邻进行通信。这使我们陷入了一种常见的鸡生蛋问题,即在尝试解决无差别的网络通信时经常遇到的问题。源主机如何识别与目标主机通信的地址和协议,而不首先与目标主机通信以了解这些细节?答案是使用广播地址。

广播地址

当你首次将设备连接到网络时,在设备能够有效地通过该网络进行通信之前,需要在新的设备和网络上的其他设备之间协调许多事情。您的设备必须分配一个本地 IP 地址,并且必须通知它直接连接的任何设备的 IP 地址和监听端口或套接字。此外,您的子网络中的任何网关都必须知道新设备,这样,如果子网络外的任何主机想要建立连接,网关将对其收到的任何路径请求做出肯定响应,以尝试建立连接。

为了在网络上建立新主机的初始身份,每个标准网络设备都将有一个被称为广播地址的属性。广播地址简单地是指任何给定网络或子网络上的每个设备都期望监听其上的地址。随着这一标准的普遍建立,任何试图连接到网络的新的主机可以通过向广播地址发送基本识别信息来建立其存在,以便任何可能需要了解它的设备。一旦消息被广播,路由器、交换机和网关可以使用它们配置的任何约定或协议来相互同意为新主机提供的新网络地址。

在初始广播消息中,新主机尚未从其网络分配 IP 地址。然而,它仍然需要通过其他设备可以与之通信的唯一标识符来建立身份。为了给其最近的物理邻居提供一个标识符,以便返回配置信息,新主机通常会将其媒体访问控制地址,或MAC 地址,作为其初始广播消息的一部分发送。MAC 地址是一个全球唯一的标识符,分配给每个单独的物理网络接口卡NIC)。

MAC 地址有时被称为烧录地址,因为它在网卡制造时在硬件级别配置并固定,无法通过任何方式更改。MAC 地址由六个十六进制数字组成,由冒号、破折号或没有任何分隔符分隔。由于每个网卡都有一个唯一的 MAC 地址,因此配置了多个网络卡的主机将拥有多个 MAC 地址,以标识它可用的每个物理接口。如果你曾经打开过命令终端并运行此命令,你会看到你机器上安装的任何网络卡的 MAC 地址:

ipconfig /all

在以下命令终端中,它是指示为物理地址的属性:

图片

由于 MAC 地址不能更改且众所周知是全球唯一的,新主机可能会将其广播到其网络中,并希望获得网络配置细节,包括 IP 地址分配和子网掩码指定作为回应。

通过使用每个连接主机的广播信息,网络服务器和路由器可以构建其网络的逻辑拓扑的内部表示。这个拓扑和设备配置的注册表用于控制和监控互联网通信的流量。因此,现在的问题是,当我们需要建立关于我们软件托管网络的相同知识时,我们如何利用这些广播和其他信息?

网络分析

现在我们已经对网络信息如何在我们的网络中任意节点之间进行通信有一个基本的了解,我们可以开始利用这些信息在我们的 .NET Core 项目中实现低级网络软件。那么,我们在寻找什么信息,我们如何在代码中使用它呢?

理解 NetworkInformation 命名空间

我们在上一个章节中讨论的许多细节和网络交互都存在于网络堆栈中比 .NET Core 库可以提供访问权限的软件和设备中。然而,关于通过您的应用程序的监听网络套接字和接口传输的传输层流量,有大量信息。为此信息,.NET Standard 提供了 System.Net.NetworkInformation 命名空间。

NetworkInformation 命名空间提供了一系列实用类和接口,可用于构建软件与连接的网络交互的全面图景。使用这个命名空间中的类,您可以了解数据包流量、设备地址,包括您当前子网络中路由器和网关的注册 IP 地址,以及用于识别网络中远程设备可用性的实用工具。

这个命名空间及其用例特别有趣的地方在于,它可以用来调查所有跨越您主机机器的连接和流量。这使您能够分析针对您机器上不同进程的网络请求可能会如何影响您自己的网络软件的性能和行为。此外,利用这个类可以为您的代码提供管理更多内部进程的连接和套接字所需的信息,从而允许编写自己的应用程序服务器或请求管理解决方案等应用。那么,我们能从这个命名空间中的类中获得哪些信息呢?

查询物理设备信息

假设您想编写一个提供主机计算机上网络设备详细信息的应用程序。当然,您可以使用诸如 Powershell 脚本或简单的批处理过程来运行终端命令,如 ipconfig /allnetsh dump 命令,并将结果写入文本文件。但如果你想要更多关于你系统的信息呢?如果你想要将此软件部署到具有不兼容终端的多台主机上呢?虽然脚本解决方案相对简单,但它非常不灵活,其潜在范围有限。

相反,使用NetworkInterface类及其子类,您可以直接从您的软件解决方案中访问有关网络设备的信息。使用NetworkInterface抽象类,我们可以访问从我们的软件的主操作系统可检测到的每个物理网络设备,并显示有关当前活动状态、接口类型、物理 MAC 地址、当前操作状态等信息。为了看到这一点,让我们编写我们的网络信息显示软件。我们将从为这一章的演示代码创建一个控制台应用程序开始:

dotnet new console -n NetworkAnalysisDemo

然后,从我们的Main()方法内部,我们将使用NetworkInterface类上的GetAllNetworkInterfaces()静态方法获取当前由我们的操作系统检测到的所有网络适配器的列表。这将返回一个NetworkInterface抽象类的子类的数组,数组中的每个实例代表系统上的一个物理设备。一旦我们有了设备列表,我们将遍历它们,并使用此接口查看我们可以发现有关它们的信息:

private static void DisplayDeviceInformation() {
  var adapters = NetworkInterface.GetAllNetworkInterfaces();
  Console.WriteLine($"There were {adapters.Length} devices detected on your machine");
  Console.WriteLine();

NetworkInterface类实际上是一个抽象基类。返回到我们的适配器容器中的实例将是SystemNetworkInterface的实例。现在,让我们找出我们可以从这些适配器实例中获取哪些信息。我们将通过遍历我们的适配器来打印一些更有趣的属性,并找出我们可以学习到的详细信息的类型:

Console.WriteLine("Device Details");
foreach (NetworkInterface adapter in adapters) {
  Console.WriteLine("=========================================================================");
  Console.WriteLine();
  Console.WriteLine($"Device ID: ----------------- {adapter.Id}");
  Console.WriteLine($"Device Name: --------------- {adapter.Name}");
  Console.WriteLine($"Description: --------------- {adapter.Description}");
  Console.WriteLine($"Interface type: ------------ {adapter.NetworkInterfaceType}");
  Console.WriteLine($"Physical Address: ---------- {adapter.GetPhysicalAddress().ToString()}");
  Console.WriteLine($"Operational status: -------- {adapter.OperationalStatus}");
  Console.WriteLine($"Adapter Speed: ------------- {adapter.Speed}");
  Console.WriteLine($"Multicast Support: --------- {adapter.SupportsMulticast}");
}

Thread.Sleep(20000);

如您通过属性所看到的,通过这个类,我们可以收集到与通过终端命令查询我们的网络接口卡(NIC)所收集到的几乎相同水平细节关于我们的宿主机器。实际上,我们可以通过运行一个ipconig /all命令来确认这一点,并将返回的设备细节与我们的程序输出进行比较。在我的机器上这样做,我们可以将我的程序输出与上一节中展示的终端输出进行比较:

图片

在这里,我们可以看到我们可以访问上一节中ipconfig /all调用返回的大部分信息。虽然十六进制数字没有分组为破折号分隔的对,但PhysicalAddress属性显然直接映射到适配器的 MAC 地址,而OperationalStatus属性给出了设备对网络请求可用性的准确表示。我们还可以看到,我的操作系统配置为使用 Teredo 隧道适配器,以便在 IPv6 子网络上进行 IPv4 通信,正如我们在第十二章 The Internet Protocol 中讨论的那样。

更深入地探索NetworkInterface类,你可以找到有关你的网络适配器连接到的设备的信息。通过请求通过GetIPProperties()方法的信息,你可以访问每个适配器当前设置的网络配置信息的细节。这包括 DHCP 服务器、DNS 服务器以及你的子网网关设备的 IP 地址,以及在你机器上注册的任何多播或单播地址,这些地址是为你的网络上的其他设备设置的。

从这门课程中我们可以学习到的有关我们物理适配器的信息深度,比从我们的终端命令中学习到的要广泛得多。特别是当我们考虑到通过GetIPProperties()方法检索到的信息时。然而,使用这个类而不是简单的终端命令的好处是,它让我们能够从软件内部访问所有这些信息。我们可以根据设备可用性实现条件行为,或者向系统健康报告提供有意义的统计数据和信息,而无需依赖于特定主机的终端命令和外部加载模块。

通过这些信息,我们可以实际实现自己的软件来广播我们的 MAC 地址并请求地址配置,以及从我们的网络获取配置信息。即使你永远不会找到自己实现该代码的需求,这也可能清楚地描绘出这个类为你打开的功能类型。这提供了一个主机无关的机制,用于低级网络细节和编程。那么,我们还能从System.Net.NetworkInformation命名空间中的类中学习到哪些其他信息?

查询连接信息

虽然了解我们自己的网络接口的状态和可用性是有用的(实际上,在许多情况下甚至至关重要),但它并不能描绘出完整的画面。为了做到这一点,我们需要检查我们的适配器所暴露的进出网络流量。幸运的是,就像我们网络适配器的信息一样,.NET 标准提供了一套可以通过简洁且易于使用的抽象来显示和监控这些信息的类。

我们不想与 NetworkInterface 方法交互,而想查看我们的 TCP 连接,因此在这个下一部分,我们将查看 IPGlobalProperties 抽象基类。与通过调用 GetAllNetworkInterfaces() 方法检查网络接口信息的方式非常相似,我们可以使用 GetIpGlobalProperties() 静态方法收集大量的 IP 流量信息。一旦我们有了这些信息,我们可以从所有活动 TCP 连接的列表(对于确定设备当前负载很有用)到关于传入和传出 IP 数据包的统计信息获取一切。你甚至可以根据促进其交付的传输协议对 IP 流量统计信息进行排序,有专门针对 TCP 统计信息和 UDP 统计信息的方法。

让我们看看我们可以通过这些类及其查询了解哪些信息。我们将从了解当前时刻正在我们机器上运行的哪些活动 TCP 连接开始。首先,我们将获取我们的全局属性,然后请求我们的活动 TCP 连接信息:

private static void DisplayActiveTcpConnections() {
  var ipStats = IPGlobalProperties.GetIPGlobalProperties();
  var tcpConnections = ipStats.GetActiveTcpConnections();

  Console.WriteLine($"There are {tcpConnections.Length} active TCP connections on this machine");
  Console.WriteLine();

现在我们有了我们的活动连接,我们可以遍历它们以确定谁连接到谁,以及连接的状态:

foreach(var connection in tcpConnections) {
    Console.WriteLine("=============================================");
    Console.WriteLine($"Local host:");
    Console.WriteLine($" Connected On Address: {connection.LocalEndPoint.Address.ToString()}");
    Console.WriteLine($" Over Port Number: {connection.LocalEndPoint.Port}");
    Console.WriteLine($"Remote host: {connection.RemoteEndPoint.Address}");
    Console.WriteLine($" Connected On Address: {connection.RemoteEndPoint.Address.ToString()}");
    Console.WriteLine($" Over Port Number: {connection.RemoteEndPoint.Port}");
    Console.WriteLine($"Connection State: {connection.State.ToString()}");
}

运行此命令后,你应该会看到本地地址(无论是 192.168.1.XXX 还是 127.0.0.1)作为每个连接的本地地址的某种变化,这可能并不完全明显为什么你可能想要访问这些信息。然而,考虑一个场景,你有一个单一的主机注册了多个 IP 地址,并且每个地址都映射到一个单一、独特的应用程序进程。假设你已经通过我们在第九章 HTTP in .NET 中讨论的 IWebHostBuilderUsingUrls(...) 方法配置了这些不同的监听模式。如果那样的话,你可以使用本地地址信息来区分主机上不同应用程序的连接。这可能会让你对应用程序流量和资源使用有更深入的了解。我预计你现在已经对这种信息在许多情况下可能非常有用有了直观的认识。

监控流量和远程设备信息

最后,让我们通过查看通过其类提供的某些流量统计信息和信息来总结我们对 System.Net.NetworkInterface 命名空间的讨论。这将为我们提供关于在特定时间点接收到的总数据包数量、分段失败、丢弃的出站数据包以及更多统计信息的统计信息。我们可以根据 IP 版本请求这些统计信息,使用 GetIPv4GlobalStatistics()GetIPv6GlobalStatistics() 不同的方法,以及通过 GetTcpIPv4Statistics()GetUdpIPv4Statistics() 过滤这些统计信息,按传输协议进行过滤。

通过查看IPGlobalStatistics类实例提供的某些属性,我们可以看到我们可以从我们的流量中提取出的有价值信息。让我们通过一些示例代码来看看其中的几个例子:

private static void DisplayIPv4TrafficStatistics() {
    var ipProperties = IPGlobalProperties.GetIPGlobalProperties();
    var ipStats = ipProperties.GetIPv4GlobalStatistics();
    Console.WriteLine($"Incoming Packets: {ipStats.ReceivedPackets}");
    Console.WriteLine($"Outgoing Packets: {ipStats.OutputPacketRequests}");
    Console.WriteLine($"Discareded Incoming Packets: {ipStats.ReceivedPacketsDiscarded}");
    Console.WriteLine($"Discarded Outgoing Packets: {ipStats.OutputPacketsDiscarded}");
    Console.WriteLine($"Fragmentation Failures: {ipStats.PacketFragmentFailures}");
    Console.WriteLine($"Reassembly Failures: {ipStats.PacketReassemblyFailures}");
}

在这里,你可以看到关于你的网络整体健康和稳定性的各种可能描绘的图片。我们可以获取有关碎片化和重组失败、丢失的数据包以及整体进出流量的信息。在这种情况下,我们正在查看我们的全球 IP 流量,但我们可以很容易地按 TCP 和 UDP 排序,以获得我们网络交互的更有意义的细分。

并非所有IpGlobalStatistics的属性在所有平台上都受支持。有关丢弃的数据包和碎片失败的信息将仅在 Windows 主机上可用。请确保你想要访问的统计信息受你软件将部署到的平台支持,并且始终编写代码以便在主机支持可能有限的情况下优雅地降级。

虽然我希望这已经清楚地说明了你可以了解关于你托管软件周围更广泛网络环境中的哪些信息,但我只是刚刚开始探索通过System.Net.NetworkInformation命名空间可用的信息。我强烈建议你自己阅读 Microsoft 文档,看看还有哪些工具可供使用。

其他工具和分析

虽然我们可以使用System.Net.NetworkInformation命名空间捕获的信息为我们提供了关于我们网络状态的非常清晰的画面,但它在对网络分析来说有一个主要的缺点:它不能提供任何关于实时流量或请求内容的洞察。在代码中访问这些信息的唯一方法是在一个开放的端口上主动注册一个监听器,并相应地处理传入的流量。因此,对于需要监控其网络流量内容以及流量量和上下文的网络和 DevOps 工程师来说,还有哪些其他工具可用?

使用 Wireshark 进行数据包检查

如我们在第四章“数据包和流”中看到的,Wireshark 可以是一个强大的网络分析和所谓的数据包嗅探工具。简单来说,数据包嗅探是在不考虑其预期接收者的上下文的情况下检查数据包的内容。例如,如果我通过浏览器请求了一个网页,然后用 Wireshark 之类的工具调查构成该网页的数据包流,那么这就算作数据包嗅探。这些数据包原本是打算由我的浏览器接收和使用的,而不是 Wireshark。实际上,我是否是第一个通过浏览器请求它们的人并不重要。

在某些情况下,这可能是一个恶意行为者获取对其他互联网流量访问权限的危险工具。然而,在遵守道德的网络工程师手中,它可以是一种识别网络上的意外或不希望的活动的好方法。那么,我们如何利用这一点呢?

考虑这样一个案例,你有一个正在运行的软件,它使用System.Net.NetworkInformation类来提供关于托管环境的实时健康信息。如果你已经很好地设计了你的警报机制(如第十六章[5abf726d-855c-410e-8547-a54da3deac58.xhtml],性能分析和监控所述),你会迅速意识到任何超出给定阈值的意外网络流量峰值。如果发生这种情况,从你的健康监控软件中可获得的信息将受到System.Net.NetworkInformation库提供的限制。

在本章中,我们讨论了这些资源,你可以制定一个合理的策略来检测和响应这些类型的网络事件。简单地将System.Net.NetworkInformation类提供的统计信息作为你的警报系统,并在出现警报时使用更强大的检查工具,如 Wireshark,进行深入分析。这可以让你更深入地了解在数据包高流量峰值中向你的主机传达了哪些信息。

在第四章[9d6266fb-4428-4044-b63b-44f1317f64e7.xhtml],数据包和流数据包解剖学部分中,讨论了使用 Wireshark 进行基本数据包嗅探的基本方法。考虑到这一点,我不会过多地强调这一点,因为 Wireshark 的更高级用法超出了本书的范围。然而,我认为在本章中讨论它很重要,因为任何对网络编程及其所有内容认真的人都应该尽可能拥有尽可能多的工具。为此,我强烈建议你在工作中或甚至在业余时间抽出时间学习和练习使用 Wireshark 和其他数据包检查工具进行深入的网络分析。

在解决了这些问题之后,我们准备处理我们的最后一个主题。在下一章中,我们将探讨 SSH 交互方案。我们将了解它是如何产生的,它是如何随着时间的推移而演变的,以及我们如何使用它进行远程过程调用和主机访问。

摘要

在本章中,我们探讨了使用.NET Core 库进行网络设备分析的非常小众但极其强大的主题。我们首先学习了网络设备如何通过设备无关的连接来传递关于自身的信息,以建立附近设备地址和交互机制的内部注册表。在这个过程中,我们了解了广播和广播地址的使用,即使没有关于您打算与之通信的设备任何连接信息,也能可靠地传输消息。最后,我们学习了如何通过唯一地寻址硬件接口,即使在没有注册的网络地址的情况下,也能促进设备识别。

一旦我们了解了促进网络中主机之间更典型交互所需的特性和交互,我们就研究了如何在.NET 应用程序中访问这些低级网络信息。我们探索了System.Net.NetworkInformation命名空间内的各种类,并看到了如何使用它们来访问有关我们的网络适配器和它们所连接的设备的有价值信息。我们看到了如何通过编程方式访问有关我们的物理网络适配器的关键操作信息,这为我们提供了广泛的诊断和统计信息。我们还研究了如何检查和监控每个网络接口的 IP 流量,以执行数据包检查和网络健康分析。最后,我们考虑了其他可用的工具,这些工具可以提供更多关于网络流量的上下文和细节,以及我们如何使用所有这些信息来识别和响应不稳定的网络。在进入最后一章时,我们将探讨计算机如何通过网络使用 SSH 远程控制和操作主机。

问题

  1. 描述地址解析的过程,如它在网络层发生的那样。

  2. 广播地址是什么?它是如何使用的?

  3. MAC 地址是什么?它与其他网络地址有什么区别?

  4. 我们可以从NetworkInterface类的实例中学习哪些信息?

  5. 数据包嗅探是什么?它在网络分析中有什么用途?

  6. 我们可以使用NetworkInformation类查询哪些类型的流量信息?

  7. 我们如何利用流量统计来检测和响应不稳定的网络条件?

进一步阅读

再次强调,在本章中,我们讨论了一个相对小众的主题,而且学习更多相关内容的额外资源也相对稀少。我当然建议您探索微软关于System.Net.NetworkInformation命名空间的文档,您可以通过以下链接找到:docs.microsoft.com/en-us/dotnet/api/system.net.networkinformation?view=netcore-3.0

此外,如果你对继续深入网络流量分析和数据包检查的路径感兴趣,我推荐阅读 Anish Nath 所著的《Wireshark 数据包分析》一书。这本书提供了详尽的指南,用于调查和理解与你的网络适配器时刻接触的原始网络数据包的本质。你可以在 Packt 购买此书,链接如下:www.packtpub.com/networking-and-servers/packet-analysis-wireshark.

第十九章:远程登录和 SSH

在本章的最后一章中,我们将探讨如何在.NET Core 中实现远程主机访问和控制。在这里,我们将创建一个用于远程访问网络中计算资源的安全壳SSH)客户端。我们将探讨 SSH.NET 库如何支持调用已知的外部资源,并探索 SSH.NET 库的底层源代码,以了解它如何在.NET Core 中支持 SSH。最后,我们将探讨如何利用它通过网络在远程机器上执行各种操作。

本章将涵盖以下主题:

  • 用于远程设备访问和进程执行的 Secure Shell 协议

  • 使用.NET Core 建立 SSH 连接

  • 通过 SSH 连接在远程机器上执行远程命令

技术要求

在本章的最后一章中,我们将使用我们信任的源代码编辑器,无论是 Visual Studio 还是 Visual Studio Code,我们将开发并讨论本书 GitHub 仓库中找到的源代码,这里:github.com/PacktPublishing/Hands-On-Network-Programming-with-C-and-.NET-Core/tree/master/Chapter%2019

我们还将使用免费的(免费的意思是免费)虚拟机主机 VirtualBox 来为我们设置一个虚假的远程主机,以便我们通过 SSH 软件与之交互。如果您想跟随本章中的演示,可以使用您自己机器上已有的任何虚拟化软件。但重要的是,您需要有一个虚拟主机来与之交互。VirtualBox 的安装程序可以在以下位置找到:www.virtualbox.org/wiki/Downloads

最后,我们将使用 Ubuntu 操作系统的安装版加载到我们的虚拟机中。Ubuntu 是免费和开源的,该操作系统的磁盘镜像可以在以下位置找到:www.ubuntu.com/download/desktop

查看以下视频以查看代码的实际操作:bit.ly/2HYQMSu

什么是 SSH?

从一开始,理解我们如何在软件中利用 SSH 的第一步是了解 SSH 是什么,以及它是如何工作的。简单来说,SSH 是一种用于安全登录远程主机的网络协议。一旦建立了远程登录,该协议支持在未加密的网络上执行和操作远程资源。与本书中探索的所有协议一样,其设计有意地通用化,以便在各种环境中使用。那么,它是如何开始的,具体又是如何工作的呢?

SSH 的起源

SSH 是由芬兰研究人员 Tatu Ylönen 在 1995 年最初创建的,旨在提供一个安全的通道,通过该通道可以建立远程终端访问。Ylönen 是由当时他在赫尔辛基科技大学网络中遭遇的最近的一次密码嗅探攻击所激发的。他认识到更常见的(或者至少在当时更常见的)基于文本的远程终端协议(如 Telnet、rlogin 和 FTP)固有的不安全性,他的主要目标是创建足够强大的身份验证和加密,以确保网络通信的隐私。通过他的工作,用户可以远程登录到机器,并通过自己的 SSH 接口访问和操作远程机器的终端。

原始版本以及随后的 1 版本的后续小版本是由 SSH Communications Security 开发的,这是一家由 Ylönen 创立的私营网络安全公司。然而,从 2.0 版本开始,一个名为 Secsh 的 IETF 工作组负责定义和改进该协议。Secsh 团队的工作在 2006 年被正式化和接受为标准,被称为 SSH-2

随着 SSH-2 的发布,团队通过使用改进的密钥交换算法来建立通信的对称密钥,从而提高了 SSH-1 的安全性和功能集。新版本还引入了安全散列消息认证码(HMAC 代码,不要与我们在第十八章网络分析和数据包检查中讨论的 MAC 地址混淆)来安全地散列消息。SSH-2 引入的特性之一是支持通过单个 SSH 连接进行多个并行终端会话。

尽管 SSH 最初是基于免费软件构建并作为免费软件发布的,但该协议已经分裂,存在于各种开放和专有实现和版本中。然而,全球的开发者继续需要该协议的开源实现。为了解决这个问题,开发了新的开源实现,并建立了坚实的基础。这包括由 OpenBSD 后台开发社区开发的 OpenSSH 实现,该实现首次于 1999 年发布。今天,OpenSSH 是该协议套件中最广泛使用的实现,并且是现代 Windows 机器唯一使用的实现。

如果你曾经使用过 GitHub 或 BitBucket 作为远程 Git 仓库,你很可能已经使用了 OpenSSH,甚至可能都没有意识到。许多 Git 客户端,包括 Atlassian 的源树,甚至是默认客户端,都利用 SSH 在您的机器和远程代码仓库之间建立和维护访问凭证。由 SourceTree 支持的单一登录机制完全建立在 OpenSSH 之上。实际上,每次您启动 SourceTree 时,启动过程也会启动一个 PuTTY SSH 客户端的实例,SourceTree 使用它来与您的远程仓库建立自己的连接。但现在您已经知道了 SSH 是什么,我们应该花一点时间来看看它是如何工作的。

SSH 协议设计

SSH 协议利用客户端-服务器架构,这与我们在整本书中检查的应用层协议(如 FTP 和 HTTP)并不完全不同。基本交互机制实际上看起来非常类似于 TLS 握手过程中发生的握手和安全协商。基本交互如下:

  • 客户端与服务器建立连接,通知服务器希望通过 SSH 进行通信

  • 服务器确认请求,并响应以公共加密密钥信息,客户端可以使用这些信息来保护其后续的响应,直到可以建立对称加密方案

  • 服务器和客户端协商连接的安全结构,传输任何必要的密钥,并在 SSH 会话的生命周期内建立用于安全通信的对称加密机制

  • 客户端可以自由安全地登录到远程主机,传输和执行其凭证有权限的命令

SSH 与 TLS 等东西之间的主要区别在于,SSH 被明确设计为通用和连接无关。

这种交互方案由一个由 IETF(4251-4256,如果您感兴趣)定义的一系列 RFC 支持的架构支持,它是一个具有明确责任的三层组件系统。每一层都在客户端和服务器之间建立和维护 SSH 会话的过程中支持特定的任务,并且它们都由各自的标准进行了很好的记录。

传输层

这些层级中的第一个是传输层协议,或SSH-TRANS,它提供了与 TLS 中期望的所有功能的安全消息传递。由于 SSH 完全是关于安全通信的,因此传输层提供强大的身份验证和加密机制非常重要。这应该包括服务器身份验证(通常通过 X.509 或类似的证书验证机制)、公钥加密和消息验证。

虽然 RFC 4251 指出,在大多数情况下,传输层很可能会在 TCP/IP 上实现,但它为新的或替代协议留出了空间。对于 SSH 而言,传输层协议必须具备的仅是传输的可靠性。这排除了 UDP 和其他无连接协议作为可行的传输机制,因为无连接数据报传输被认为是固有不可靠的。

用户认证层级

下一个,也许是最关键的,SSH-2 架构层级是认证协议层,或SSH-USERAUTH。这是负责为任何试图连接到 SSH 服务器的客户端建立和维护验证身份的层级。根据 RFC 4252,服务器负责建立客户端可能使用的有效认证方法集。从该认证方法集中,客户端可以自由选择任何可用方法,并且可以自由尝试任何顺序。根据 RFC,服务器可能列出三种有效的认证方法供客户端尝试认证。

公钥认证模式

根据标准,这些方案中的第一个,也是 SSH-2 服务器实际上必须支持的,是publickey认证方案。然而,尽管所有服务器都必须支持使用公钥认证的能力,但并没有要求所有客户端都必须拥有公钥。公钥方案的行为与我们第十三章中看到的非常相似,即传输层安全。当指定publickey方法时,SSH 客户端将发送一个由私钥生成的签名到服务器。一旦收到,服务器将验证该签名是否由用户的私钥生成,并在验证成功后,客户端的认证成功。

客户端软件通常将注册的私钥以加密值的形式存储在本地系统上,并限制外部进程对其的访问。SSH 客户端通常也会在生成用于传输到服务器的公钥签名之前,使用某种形式的密码认证,尽管这并非总是必需的。

密码认证模式

服务器可能为客户端指定的下一个有效认证机制是密码认证方案。虽然标准并未明确要求支持密码方案,但强烈建议实现应支持密码认证. 在此认证机制中,正如其名所示,连接的客户端在一系列数据包协商过程中向服务器提供明文密码。

由于 SSH-2 架构的用户认证层建立在传输层之上,因此密码以明文形式传输的事实应该通过底层传输协议的安全加密来缓解。然而,RFC 4252 确实规定,在发送明文密码之前,服务器和客户端都有责任确认传输层加密机制的机密性。如果客户端或服务器确定传输协议没有使用加密,或者加密不足,则客户端和服务器应禁用密码认证模式。

服务器在实现密码认证方案时,应提供一种机制,允许用户在认证过程中更改或更新他们的密码,以防用户的密码已过期。这使用户能够在远程终端上通过 SSH 连接建立机制输入和更新他们的密码。然而,与客户端和服务器应在允许密码认证模式之前验证传输层提供足够的安全性一样,他们还必须在支持密码更新机制之前确定是否存在足够的数据完整性支持。这是因为,如果数据在传输过程中被变异或修改,没有某种形式的完整性检查,服务器和客户端都不会知道这种差异。

因此,服务器将为用户的未来认证尝试分配新的、变异的密码,但客户端将不知道这种变异,并且将随后无法使用原始的、未修改的密码进行认证。因此,如果没有 MAC 或 HMAC 作为传输层发送的数据包的一部分提供数据完整性保证,则服务器必须禁用任何密码更新功能。

基于主机的认证模式

最终的认证模式是基于主机模式。这是唯一在标准中被明确指出为可选的模式,它允许用户根据他们连接的主机进行认证(因此得名)。此模式依赖于一个指定的主机机拥有为特定主机机配置的私钥。然后主机从该私钥生成一个签名,该签名可以使用主机的公钥由服务器进行验证。然后,该签名与公钥和特定主机名进行验证。只要带有签名的提供的主机名与请求的来源以及服务器有效主机注册表相匹配,认证就成功。

连接层

SSH-2 架构的连接层旨在在传输层和用户认证层之上运行,定义了支持通过 SSH 连接可用的各种操作和交互的系统。在 RFC 4254 中定义,堆栈的连接层被描述为提供执行 SSH 功能的连接通道。这包括任何客户端可以执行远程命令的远程终端会话,以及允许客户端直接访问远程主机上的网络连接端口的端口转发会话,以及任何用于在远程主机内部导航和与之交互的远程登录会话。

根据 RFC,单个 SSH 连接应在会话期间实现支持多路复用多个通道。这些通道在连接的两端都是唯一标识的,尽管标识符可能从服务器到客户端不同。一旦通道打开,数据就可以在所谓的窗口中在主机之间流动。窗口简单地是一个指定字节数的最大值,一个主机在进一步的数据被阻塞之前可以传输给另一个主机。一旦达到窗口大小,发送主机必须等待接收主机调整窗口。通过这种方式,窗口被用来控制给定 SSH 连接通道上的流量。

在给定 SSH 连接上配置的一系列通道建立了一个所谓的交互会话。根据标准,交互会话仅仅是客户端在服务器上远程执行程序的行为。这可以包括端口或认证转发、会话数据传输,或任何数量的其他工作、任务和活动。

SSH 的通用性

由于 SSH 主要关注在非安全网络上建立安全连接,因此它的应用范围相当广泛。当然,它可以提供远程终端交互机制,这是它旨在取代的协议的一部分。当客户端和服务器之间使用公钥/私钥建立时,它可以作为一个无密码认证机制使用,就像 GitHub 和 BitBucket 中的 OpenSSH 那样。

与 FTP 一起使用

虽然 FTP 可以提供一种可靠的服务器-客户端交互机制,用于在主机之间传输文件,但它通常不会以安全的方式进行。因此,当需要在主机之间安全地复制或传输文件时,会大量使用 SSH。事实上,FTP 的安全协议规范依赖于 SSH,并明确称为SSH 文件传输协议,或SFTP。SSH 提供的 shell 甚至可以允许安全复制scp)Linux 程序通过远程连接执行。

如您从第十章,“FTP 和 SMTP”中可能记得,FTP 利用两个独立且不同的连接进行其文件传输交互:控制连接和数据传输连接。有趣的是,由于 FTP 利用这两个独立的连接,您可以选择性地将 SSH 应用到这两个连接中的任何一个,而不管您是否也在另一个连接上使用了 SSH。这种精确的交互机制被 快速安全协议FASP)所使用。

作为网络隧道使用

当远程 SSH 服务器被正确配置时,它可以为客户端与更广泛的互联网的交互提供一系列网络安全特性和功能,包括端口转发的能力,即客户端建立一个监听网络端口,并将其直接连接到远程主机上的监听网络端口的输入流。在这个机制中,发送到远程主机端口的任何流量都将被客户端主机的监听端口接收和处理。这可能会允许位于防火墙或受限网关后面的客户端设备与位于限制边界之外的远程设备建立 SSH 连接,并开始监听不受限制的网络访问。

将这个想法进一步扩展,OpenSSH 甚至提供了客户端和服务器之间完全加密的 VPN 服务支持。这通过绕过两个主机之间的任何网关、交换机或桥接器,为两个主机之间提供了完全加密的交互,从而创建了一个具有安全交互和受限访问的逻辑“本地”网络。

虽然将 SSH 的可能应用描述为无穷无尽可能有些夸张,但它们确实非常广泛。因此,它们值得您的兴趣和探索。考虑到这一点,让我们看看我们如何在软件中开始利用 SSH。

建立 SSH 连接

虽然 .NET Core 在其自身库中缺乏建立 SSH 会话的原生支持,但存在许多第三方库和 NuGet 包可以填补这一空白。我们将探讨其中最受欢迎的包之一,SSH.NET,并看看我们如何使用它来连接到远程机器并通过虚拟终端与之交互。

设置远程主机

为了演示目的,我们需要设置一个远程主机以便我们进行 SSH 连接。为此,我们将使用配置在我们的系统上的 Ubuntu 虚拟机,使用VirtualBox进行配置。我们将在机器内部安装和操作 Ubuntu 镜像。为了设置它,你需要在 VirtualBox 中创建一个新的虚拟机,点击新建按钮并按照向导操作。在向导中,你将有机会给你的机器命名,并选择操作系统类型(Windows、Linux、macOS X 等)和具体版本。为此,我们将选择 Linux 作为类型,Ubuntu 64 位作为版本,如下所示:

图片

在设置虚拟机时,请确保至少提供足够的资源以满足 Ubuntu 的最小安装要求,包括 8GB 的虚拟磁盘空间和 2GB 的内存。一旦我们设置了机器,我们还需要在机器上安装 Ubuntu。使用我在技术要求中提到的镜像,在你的新配置的虚拟机上安装 Ubuntu。在安装操作系统时,确保在提示创建用户账户时,要求输入密码登录。这将允许我们演示 SSH 远程登录的使用。

一旦你在配置的虚拟机上安装了 Ubuntu,你需要在机器上启用 SSH。为此,打开机器的终端并运行以下命令:

图片

一旦运行该程序,你应该会看到一系列的安装脚本,之后你的 SSH 服务器就已经安装完成了。你可以通过运行以下命令来检查 SSH 服务器的状态:

图片

完成这些后,我想就我们的设置说明几点。首先,请注意虚拟机与我自己的机器完全独立,拥有唯一的用户和主机名。其次,请注意端口正在监听端口22。这是 SSH 服务器默认监听的端口,我们将在 C#项目中与远程机器建立连接时使用这个细节。

最后,我们需要知道如何从我们的机器与我们的虚拟主机交互。为此,我们将在虚拟机上设置端口转发,以便将针对我们主机上指定端口的请求转发到我们的虚拟机上的指定主机。这将使我们建立连接时更加方便。为此,你需要在运行实例的网络设置中打开高级选项,然后打开端口转发对话框。从那里,我们将转发所有的 SSH 连接尝试,这些尝试始终会连接到端口22,到我们的虚拟主机(虚拟机)上的监听 SSH 端口:

图片

一旦我们配置好,我们只需向我们的本地主机 IP 地址127.0.0.1发送请求,由于这是一个针对端口22的 SSH 请求,请求将被转发。因此,现在是时候连接 SSH.NET 并建立连接了。

使用 SSH.NET 连接到 SSH 服务器

就像我们所有的项目一样,我们的首要任务是使用命令行界面(CLI)来创建它。为此演示,我们将使用另一个控制台应用程序,所以请导航到您的目标目录,并在您的命令提示符中运行以下命令:

dotnet new console -n SshDemo

由于 SSH.NET 不是随.NET Core 一起提供的库,我们需要添加这个包。您可以通过 Visual Studio 中的 NuGet 包管理器或使用.NET Core CLI 从项目文件目录文件夹中安装所需的包来完成此操作。后一种方法的 CLI 命令如下:

dotnet add package SSH.NET --version 2016.1.0

现在,当您打开您的Program.cs文件时,您可以通过添加以下using指令来包含引用:

using Renci.SshNet;

然后,我们就准备好建立连接了。SSH.NET 库提供了丰富的抽象,用于根据标准与 SSH 服务器交互。这包括封装认证模式、连接信息和,当然,我们可以像使用TcpClientHttpClient类一样简单地使用的SshClient类。

对于这个初始演示,我们只想使用远程机器的登录凭证建立 SSH 连接。目的是向您展示您如何使用该主机的凭证建立对主机的远程访问。我们将使用密码认证模式,并传递我们在安装 Ubuntu 实例时在 VirtualBox 中创建的用户名和密码。然后,我们将创建一个新的SshClient实例并尝试连接。相应的代码如下:

public static async Task Main(string[] args) {
    AuthenticationMethod method = new PasswordAuthenticationMethod("tuxbox", "xobxut");
    ConnectionInfo connection = new ConnectionInfo("127.0.0.1", "tuxbox", method);
    var client = new SshClient(connection);
    client.Connect();
    if (!client.IsConnected) {
        Console.WriteLine("There was an error establishing the connection!");
    } else {
    Console.WriteLine("We've connected!");
    Thread.Sleep(10000);
}

在我们的第一行中,您可以看到我们正在创建一个PasswordAuthenticationMethod类的实例,提供了我们的用户名和密码。对于我们所讨论的每种认证模式,都有相应的类,包括一个我们没有提到的,即KeyboardInteractiveAuthenticationMode类。这个类简单地提供了一个用户通过开放的连接终端直接进行认证的方法,而不是在客户端和服务器之间来回传递凭证。

一旦我们创建了AuthenticationMethod类,我们就将其传递给ConnectionInfo类的实例。这个类简单地封装了主机、用户名、认证方法和可选的端口号(默认为22)的指定,用于建立 SSH 连接。最后,将连接信息传递给我们的SshClientSshClient实例仅初始化为连接信息的实例属性,因此我们仍然需要显式连接到我们的远程服务器。

如果您已经按照我向您展示的方式配置了您的虚拟机,并运行了应用程序,您应该在应用程序的输出中看到“我们已连接!”的消息。但是,问题是,我们如何验证我们实际上已经连接上了?通过查看我们 Linux 虚拟机中监听 SSH 服务器状态,我们应该看到认证凭据已被接受:

图片

就这样,我们通过 SSH 连接远程登录到主机。所以,现在我们知道我们可以建立连接,一旦登录,我们实际上能做什么呢?

使用 SSH 进行远程执行

SSH.NET 库暴露了其SshClient类的操作,以执行从简单的命令到打开与远程主机连接的 shell 终端的所有操作。然而,SSH 的一个核心方面是它仅打算作为与远程主机的通信隧道。因此,您可以执行的连接和可以利用的资源将始终受限于您与之交互的远程主机。在我们的例子中,我们正在与一个 Ubuntu Linux 主机合作,因此我们受限于在 Ubuntu 上托管的OpenSSH服务器支持的命令和功能。考虑到这一点,让我们看看我们如何可以使用 SSH.NET 执行可用的操作。

在 SSH.NET 中创建命令

现在我们已经使用SshClient类成功连接到远程主机,我们可以使用该客户端在该主机上执行命令。SSH.NET 定义的这个模式是创建一个命令,调用其执行,然后观察其结果。或者,如果您正在执行非常简单的命令(就像我们在这次演示中将要做的),您可以在一个步骤中创建和调用您的命令。调用命令的结果将始终是命令的实例,它将包含对客户端返回的任何输出流的句柄,以及当命令执行时获得的结果。为了在代码中查看这看起来是什么样子,我们将使用 Linux 的uname命令,该命令简单地返回有关当前主机的硬件和操作系统内核的信息。我选择这个命令是因为 Windows 中没有uname命令,所以通过查看我们的结果,我们可以确信我们正在对远程主机执行调用:

var command = client.RunCommand("uname -mrs");
Console.WriteLine(command.Result);

这相对简单,但它演示了执行命令的模式,然后使用返回的Command类实例来查看结果。通过将那些代码行应用到我们的程序中并运行它,我们应该在我们的虚拟机中看到以下结果返回:

图片

就像我们的结果中列出的操作系统一样明显,我们肯定是在连接到我们的 Ubuntu 虚拟机。

修改我们的远程主机

为了验证我们登录管理员账户时应期望拥有的完整权限,让我们添加一个额外的命令来修改我们的虚拟机上的目录。然后,我们可以通过确认我们的新目录存在于我们的 Ubuntu 实例中来确认我们的完整用户访问权限。为此,我们将添加以下代码行到我们的项目中:

var writeCommand = client.RunCommand("mkdir \"/home/tuxbox/Desktop/ssh_output\"");

如果你不太熟悉 Linux 文件系统标准,那么 /home/tuxbox 根目录在 Windows 系统中相当于导航到 C:\Users\tuxbox\ 目录。你可以看到,通过这一行代码,我的意图是在我的远程机器上直接在桌面上创建一个名为 ssh_output 的新目录。运行后,我可以在我的虚拟机中直接打开我的桌面,并看到以下结果:

图片

就这样,我们已经从我们的软件到指定的远程主机建立了一个安全的通信通道。

如果你花点时间探索文档,或者甚至只是 SSH.NET 库的 IntelliSense 代码建议,你会很快看到它提供了比我们在这里看到的多得多的功能。你可以快速轻松地指定一个要转发到远程主机监听端口的端口,或者在一行代码内创建一个完整的 shell。它的全部功能范围当然超出了本书的范围,但我强烈建议你在继续学习和成长于网络编程领域时尝试这些功能。我真心无法过分强调对任何已配置提供此类访问权限的远程资源的简单、安全访问的价值。

然而,就我们的目的而言,我确信你对 SSH 基础、其设计原则和其实施标准的全新理解将为你提供继续学习的所有工具。最终,我希望在整个书中我所做的一切都是如此。我的目标是涵盖网络编程的基础概念,并以一种易于接近和吸引人的方式呈现,通过 C# 开发和使用 .NET Core 框架的视角。我真诚地希望我成功了。

摘要

在我们的最后一章中,我们深入研究了另一个极其专注的主题。我们几乎了解了有关 SSH 及其应用的所有知识。我们花了不少时间学习该协议的起源和发展历史,以及它允许的一些特性和应用。接下来,我们游览了定义 SSH 架构的各个 RFC。

我们学习了协议架构的三个层级,从传输层开始,以及标准对安全性和数据完整性的要求。之后,我们看到了用户认证是如何完成的,考察了标准认证模式是如何定义和通常实现的。最后,我们了解了连接层,以及 SSH 标准如何描述两个通过 SSH 连接的主机之间可以同时发生的多个、不同的交互。

在有了这个视角之后,我们能够探索如何在我们的 C#项目中利用和交互 SSH。我们发现了 SSH.NET 库,并尝试了与我们的远程主机交互。我们看到了这个库如何为我们之前在本章中讨论的每个架构概念提供干净且直观的定义抽象。最后,我们探讨了如何代表我们的用户通过 SSH 在远程主机上执行命令。

有了这些,我们就完成了对网络编程世界的全面概述,这是通过 C#和.NET Core 框架的视角来看的。我真诚地希望,它对您阅读的启发和信息量,就像对我进行研究写作一样。

问题

  1. SSH 最初是如何被创建的?它试图解决哪些问题?

  2. SSH 有哪些更广泛使用的应用?

  3. SSH 应用程序架构的三个层级是什么?

  4. SSH 支持哪三种标准认证模式?

  5. 在 SSH 的上下文中,什么是连接通道?

  6. 什么是窗口?它是如何被使用的?

  7. 什么是交互式会话?

进一步阅读

在我们结束这本书的时候,希望您已经采纳了我提出的许多进一步阅读的建议。这一章再次关注了一个高级且极其狭窄的主题,关于这个主题的资源并不多。然而,如果您仍然对安全网络隧道主题感兴趣,我推荐 Joseph Steinberg 和 Tim Speed 合著的《Understanding SSL VPN》一书。这本书全面地探讨了安全 VPN 的工作原理以及它们的行政管理。这确实是在您将新知识应用于实践并深入网络编程时可能会感兴趣的内容。这本书由 Packt Publishing Ltd.出版,您可以通过以下链接找到副本:www.packtpub.com/networking-and-servers/ssl-vpn-understanding-evaluating-and-planning-secure-web-based-remote-access

posted @ 2025-10-23 15:06  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报