《RX.NET实战》第1章 反应式编程

第一章 反应式编程

本章包括

  • 反应式

  • 将事件视为流

  • 引入反应式扩展(Rx)

近年来,反应式编程范式作为一种旨在简化事件驱动应用程序的实现和异步代码的执行的模型越来越受欢迎。反应式编程专注于变化的传播及其影响,简单地说,就是如何对变化做出反应并创建依赖于变化的数据流。

随着Facebook和Twitter等应用程序的兴起,地球一端发生的每一个变化(例如,状态更新)都会在另一端立即被观察到,应用程序内部会立即产生一系列反应。需要一个简化的模型来表达这个反应链,这并不奇怪。今天,现代应用程序高度受到外部环境变化(如GPS定位、电池和电源管理以及社交网络消息)以及应用程序内部变化(如网络呼叫响应、文件读写和计时器)的驱动。对于所有这些事件,应用程序都会做出相应的反应,例如,通过更改显示的视图或修改存储的数据。

我们认为有必要在许多类型的应用程序中建立一个简化的模型来对事件做出反应:机器人、移动应用程序、医疗保健等等。以经典的命令式方式对事件做出反应会导致繁琐、难以理解和容易出错的代码,因为负责协调事件和数据更改的可怜程序员必须手动处理可以更改相同数据的孤立代码孤岛。这些更改可能以不可预测的顺序发生,甚至同时发生。响应式编程为随时间变化的事件和状态提供了抽象,以便我们可以在创建在这些事件发生时运行的执行链时摆脱管理这些值之间的依赖关系。

Reactive Extensions (Rx) 是为 .NET 应用程序提供反应式编程模型的库。Rx 通过使用声明性操作(LINQ 样式)创建针对单个事件序列的查询,使事件处理代码更简单、更具表现力。Rx 还提供了称为组合器(组合操作)的方法,使您能够联接事件序列,以便处理事件发生的模式或它们之间的相关性。在撰写本文时,Rx 库中有 600 多个操作(带重载)。每个都封装了重复出现的事件处理代码,否则您必须自己编写。

本书的目的是教你为什么你应该接受反应式编程的思维方式,以及如何使用Rx轻松构建事件驱动的应用程序。最重要的是——有趣。本书将逐步教您有关构建 Rx 的各个层,从允许您创建反应式数据和事件流的构建块,到 Rx 提供的丰富查询功能,以及允许您控制代码的异步性和反应式处理程序处理的 Rx 并发模型。但首先,您需要了解响应式编程的含义,以及传统的命令式编程与处理事件的响应式方式之间的区别。

1.1 反应式

当应用程序中发生更改时,代码需要对它们做出反应。这就是“反应式”的意思。变化有多种形式。最简单的方法是更改变量值,这是我们在日常编程中习惯的。该变量保存一个值,该值可以通过特定操作在特定时间更改。例如,在 C# 中,您可以编写如下内容:

int a = 2;
int b = 3;
int c = a + b;
Console.WriteLine("before: the value of c is {0}",c);
a=7;
b=2;
Console.WriteLine("after: the value of c is {0}",c);

程序输出:

before: the value of c is 5
after: the value of c is 5

在这个小程序中,两个打印输出显示相同的 c 变量值。在我们的命令式编程模型中,c 的值为 5,除非您显式覆盖它,否则它将保持为 5。

有时您希望 c 在 a 或 b 更改时更新。响应式编程引入了一种不同类型的变量,该变量是随时间变化的:该变量不是固定在其分配的值上,而是通过对随时间发生的变化做出反应来改变值。

再看看我们的小程序;当它在响应式编程模型中运行时,输出为

before: the value of c is 5
after: the value of c is 9

“神奇的”c值发生了变化。这是由于其依赖项发生了更改。这个过程就像一台机器一样,它从两个平行的输送机进料,并从两侧的输入中产生一个物品,如图 1.1 所示。

图 1.1 函数 c = a + b 的反应式表示。随着 a 和 b 的值在变化,c 的值也在变化。当 a 为 7 且 b 为 2 时,c 自动更改为 9。当 b 变为 1 时,c 变为 8,因为 a 的值仍然是 7。

您可能会感到惊讶,但您可能已经使用反应式应用程序多年了。这种反应性的概念使您最喜欢的电子表格应用程序使用起来如此简单有趣。在电子表格单元格中创建此类公式时,每次更改输入公式的单元格中的值时,最终单元格中的结果都会自动更改。

1.1.1 应用程序中的反应式

在实际应用中,您可以在许多情况下发现可能随时间变化的变量,例如 GPS 位置、温度、鼠标坐标,甚至文本框内容。所有这些都包含一个随时间变化的值,应用程序会对该值做出反应,因此是时间变量。还值得一提的是,时间本身是一个时间变量;它的值一直在变化。在命令式编程模型(如 C#)中,你将使用事件来创建对更改做出反应的机制,但这可能会导致代码难以维护,因为事件分散在各种代码片段中。

想象一下,一个移动应用程序可以帮助用户在周边地区的商店中找到折扣和特价。我们称之为Shoppy。图 1.2 描述了 Shoppy 架构。

您希望从 Shoppy 获得的强大功能之一是,当用户靠近(从某个最小半径)时,使地图上的商店图标的大小变大,如图 1.3 所示。您还希望系统在更新可用时将新交易推送到应用程序。

在这种情况下,您可以说store.Location, myLocation 和 iconSize 变量是随时间变化的。对于每个商店,可以修改图标大小:

distance = store.Location – myLocation;
iconSize = (MINIMAL_RADIUS / distance)*MinIconSize

由于您使用了时变变量,因此每次 myLocation 变量中发生更改时,都会在距离变量中触发更改。应用程序最终将通过使商店图标显示得更大或更小来做出反应,具体取决于与商店的距离。请注意,为简单起见,我没有处理允许的最小图标大小的边界检查,该距离可能是 0 或接近它。

这是一个简单的例子,但正如你将看到的,使用响应式编程模型的强大之处在于它能够组合和连接,以及分区和拆分每个时变变量正在推送的值流。这是因为响应式编程可以让你专注于你想要实现的目标,而不是让它工作的技术细节。这会导致代码简单易读,并消除大多数样板代码(如更改跟踪或状态管理),这些代码会分散您对代码逻辑意图的注意力。当代码简短而集中时,它的错误更少,更容易掌握。

现在,我们可以停止从理论上讨论,这样您就可以了解如何在 Rx 的帮助下在 .NET 中将响应式编程付诸实践。

1.2 Reactive Extensions 介绍

现在我们已经介绍了响应式编程,是时候了解我们的明星了:Reactive Extensions,通常缩写为 Rx。 Microsoft 开发了响应式扩展库,以便轻松处理事件和数据流。在某种程度上,时变值本身就是事件流;每个值更改都是您订阅的一种事件类型,并更新依赖于它的值。

Rx 通过将事件流抽象为可观察序列来简化事件流,这也是 Rx 表示时变值的方式。可观察意味着您作为用户可以观察序列携带的值,序列意味着所携带内容的顺序存在。Rx由Erik Meijer和Brian Beckman设计,并从函数式编程风格中汲取灵感。在 Rx 中,流由可观察量表示,你可以从 .NET 事件、任务或集合创建这些可观察量,也可以从其他源自行创建。使用 Rx,您可以使用 LINQ 运算符查询可观察量,并使用调度程序控制并发性;这就是为什么 Rx 在 Rx.NET 源中通常定义为 Rx = Observables + LINQ + Schedulers 。 图 1.4 显示了 Rx.NET 架构分层。

您将在本书中探索 Rx 各层的每个组件以及它们的相互作用,但首先让我们看一下 Rx 起源的简短历史。

1.2.1 Rx历史

我相信,要完全控制某些东西(尤其是技术),您应该了解幕后的历史和细节。让我们从带有电鳗的 Rx 徽标开始,如图 1.5 所示;这条鳗鱼是Microsoft Live Labs的Volta项目标志。

Volta项目是一个实验性的开发人员工具集,用于在正式定义企业云之前为云创建多层应用程序。使用Volta,您可以指定应用程序的哪个部分需要在云(服务器)中运行,哪些部分需要在客户端(桌面,JavaScript或Silverlight)上运行,Volta编译器将为您完成繁重的工作。很快,很明显,在将服务器产生的事件传输到客户端时存在差距。由于 .NET 事件不是一等公民,因此无法序列化它们并将其推送到客户端,因此形成了可观察和观察者对(尽管当时没有这样称呼)。

Rx并不是Volta项目产生的唯一技术。JavaScript编译器的中间语言(IL)也被发明出来,它是Microsoft TypeScript的起源。在Volta上工作的同一个团队是使Rx栩栩如生的团队。

自 2010 年发布以来,Rx 已成为许多公司采用的成功案例。它的成功在 .NET 以外的其他社区中可见一斑,并且很快就被移植到其他语言和技术中。例如,Netflix在其服务层中广泛使用Rx,并负责RxJava端口.4微软还在内部使用Rx来运行Cortana,Cortana是托管在每个Windows Phone设备中的智能个人助理;创建事件时,将在后台创建可观察量。

在撰写本文时,Rx 支持超过 10 种语言,包括 JavaScript、C++、Python 和 Swift。反应式扩展现在是开源项目的集合。您可以在 http://reactivex.io/ 找到有关它们的信息以及文档和新闻。.NET 的反应式扩展托管在 GitHub 存储库下,位于 https://github.com/Reactive-Extensions/Rx.NET。

现在我们已经介绍了一些历史并有幸在接下来讲述它,让我们开始探索 Rx 内部。

1.2.2 客户端和服务器端的Rx

Rx 非常适合事件驱动的应用程序。这是有道理的,因为事件(如前所述)是创建时变值的命令式方法。从历史上看,事件驱动编程主要出现在客户端技术中,因为用户交互是作为事件实现的。例如,您可能已经使用过 OnMouseMove 或 OnKeyPress 事件。因此,难怪您会看到许多客户端应用程序使用 Rx。此外,一些客户端框架基于Rx,例如ReactiveUI(http://reactiveui.net)。

但让我向您保证,Rx 不是客户端专用技术。相反,对于 Rx 非常适合的服务器端代码,存在许多方案。此外,正如我之前所说,Rx用于大型应用程序,如Microsoft Cortana,Netflix和使用Microsoft StreamInsight的复杂事件处理(CEP)。Rx 是处理应用程序接收的消息的优秀库,无论它是在服务层还是客户端层上运行都无关紧要。

1.2.3 Observables

Observables 用于在 Rx 中实现时变值(我们将其定义为可观察序列)。它们表示推送模型,其中新数据被推送到(或通知)观察者。

Observables 定义为事件(或通知)的源,或者,如果您愿意,还可以定义为数据流的发布者。推送模型意味着,不是让观察者从源获取数据并始终检查是否有尚未获取的新数据(拉取模型),而是在数据可用时将数据传递给观察者。

Observables 实现了自 .NET Framework 版本 4.0 以来一直驻留在 System 命名空间中的IObservable<T> 接口。

Listing 1.1 The IObservable interface

public interface IObservable<T>
{
	IDisposable Subscribe(IObserver<T> observer); //将观察者订阅到可观察序列
}

IObservable<T> 接口只有一个方法 Subscribe,它允许观察者订阅通知。Subscribe 方法返回一个 IDisposable 对象,该对象表示订阅,并允许观察者通过调用 Dispose 方法随时取消订阅。可观察量保存订阅的观察者的集合,并在有值得通知的内容时通知他们。这是使用 IObserver<T> 接口完成的,该接口自 .NET Framework 版本 4.0 以来也驻留在 System 命名空间中,如下所示。

public interface IObserver<T>
{
	void OnNext(T value);	//通知观察者可观察序列中新增元素
	void OnError(Exception error);	// 通知观察者发生了异常
	void OnCompleted();	// 通知观察者可观察序列已完成,不再发出通知
}	

使用 IObservableIObserver 的基本流程如图 1.6 所示。可观察量并不总是完整的;它们可以是潜在无限数量的序列元素(例如无限集合)的提供者。可观察量也可以是“静默的”,这意味着它永远不会推送任何元素。可观察量也可能失败;故障可能发生在可观察量已经推送元素之后,也可能在没有任何元素被推送的情况下发生。

这个可观察的代数在下面的表达式中形式化(其中 * 表示零次或多次,? 表示零次或一次,| 是 OR 运算符):

OnNext(t)* (OnCompleted() | OnError(e))?

失败时,将使用 OnError 方法通知观察器,并将异常对象传递给观察器进行检查和处理(参见图 1.7)。发生错误后(以及完成后),不会再向观察者推送任何消息。当观察者不提供错误处理程序时,Rx 使用的默认策略是升级异常并导致崩溃。您将在第 10 章中了解优雅处理错误的方法。

反应式设计模式

在某些编程语言中,事件有时作为一等公民提供,这意味着您可以使用语言提供的关键字和类型定义和注册事件,甚至可以将事件作为参数传递给函数。

对于不支持事件作为一等公民的语言,观察者模式是一种有用的设计模式,允许您向应用程序添加类似事件的支持。此外,事件的 .NET 实现基于此模式。

观察者模式是由四人帮(GoF)在设计模式:可重用面向对象软件的元素(Addison-Wesley Professional,1994)中引入的。该模式定义了两个组件:主体和观察者(不要与 Rx 的 IObserver 混淆)。观察者是对事件感兴趣并订阅引发事件的主题的参与者。这是它在统一建模语言 (UML) 类图中的外观:

观察者模式很有用,但有几个问题。观察者只有一种方法来接受事件。如果要附加到多个主题或多个事件,则需要实现更多更新方法。另一个问题是,该模式没有指定处理错误的最佳方法,并且由开发人员找到一种方法来通知错误(如果有的话)。最后但并非最不重要的是如何知道主题何时完成的问题,这意味着将不再有通知,这对于正确的资源管理可能至关重要。Rx IObservableIObserver 基于 Observer 设计模式,但对其进行扩展以解决这些缺点。

1.2.4 运算符

反应式扩展还带来了一组丰富的运算符。在 Rx 中,运算符是表示操作的好方法,但除此之外,它也是以声明方式描述事件处理的域特定语言 (DSL) 的一部分。Rx 运算符允许您获取可观察量和观察器,并创建查询、转换、投影和其他事件处理器的管道,您可能从 LINQ 中知道。Rx 库还包括基于时间的操作和特定于 Rx 的操作,用于查询、同步、错误处理等。

例如,这是您订阅可观察字符串序列的方式,该字符串序列将仅显示以 A 开头的字符串,并将其转换为大写:

IObservable<string> strings= ... 	//可观察的字符串,将字符串推送给观察者
IDisposable subscription =			// 保存订阅以便您可以稍后取消订阅
strings.Where(str => str.StartsWith("A"))	   //仅允许将以 A 开头的字符串传递给观察者
    .Select(str => str.ToUpper())			  //字符串在继续之前转换为大写。
    .Subscribe(...);						//订阅观察者以接收经过筛选和转换的字符串
//Rest of the code
:
subscription.Dispose();						//当您不再希望接收字符串时,释放订阅。

注意 如果您不理解每个关键字的所有语法或含义,请不要害怕。我将在接下来的章节中解释所有这些。

在这个简单的示例中,您可以看到 Rx 运算符的声明性样式 一一说出您想要的内容,而不是您想要的方式 — 因此代码读起来就像一个故事。由于我想重点介绍此示例中的查询运算符,因此不演示如何创建可观察量。可以通过多种方式创建可观察量:从事件、枚举量、异步类型等。第4章和第5章将讨论这些问题。现在,您可以假设可观察量是在后台为您创建的。

运算符和组合器(组合多个可观察量的运算符)可以帮助您创建涉及多个可观察量的更复杂的方案。若要在 Shoppy 示例中实现商店的可调整大小图标,可以编写以下 Rx 表达式:

IObservable<Store> stores = ...			//可观察,提供有关系统中商店的信息
IObservable<Location> myLocation = ...	//可观察的,携带我们当前地理位置的信息
IObservable<StoreIconSize> iconSize =
	from store in stores		//处理每个存储并为其分配存储变量(该变量的类型为 Store,因为存储是 Store 的可观察对象)
	from currentLocation in myLocation	//类似于可观察的商店。每次位置更改时获取所有商店对和当前位置。
	let distance = store.Location.DistanceFrom(currentLocation)		 // 允许您为每对商店和位置创建新变量。
	let size = (MINIMAL_RADIUS / dist) * MIN_ICON_SIZE				// 以这种方式创建两个变量来计算与商店的距离,
	select new StoreIconSize { Store=store , Size=size };			// 然后计算商店图标的大小。
iconSize.Subscribe( iconInfo => iconInfo.Store.Icon = iconInfo.Size); //lambda表达式充当观察者的OnNext实现,每次商店图标更新大小时都会调用该表达式。

即使不知道反应式扩展的所有细节,您也可以看到在 Shoppy 应用程序中实现此功能所需的代码量很小,并且易于阅读。组合各种数据流的所有样板文件都是由 Rx 完成的,并为您节省了编写处理数据更改事件所需的独立代码片段的负担。

1.2.5 Rx运算符的可组合性

大多数 Rx 运算符满足以下格式:

IObservable<T> OperatorName(arguments)

请注意,返回类型是可观察的。这允许 Rx 运算符的可组合性质;可以将运算符添加到可观察管道,每个运算符都会生成一个可观察量,该可观察量封装从原始源发出通知的那一刻起应用于通知的行为。

另一个重要的要点是,从观察者的角度来看,添加或不添加运算符的可观察量仍然是可观察量,如图 1.8 所示。

由于您不仅可以在创建可观察量时向管道添加运算符,还可以在订阅观察对象时向管道添加运算符,因此即使您无权访问创建可观察量的代码,它也使您能够控制可观察量。

1.2.6 弹珠图

一张图片胜千言。这就是为什么在解释响应式编程和Rx时,展示可观察序列的执行管道很重要。在本书中,我使用弹珠图来帮助您理解操作及其关系。

弹珠图使用水平轴来表示可观察的序列。在可观察对象上携带的每个通知都标有一个符号,通常是一个圆圈(尽管不使用其他符号),以区分值。通知的值写在符号内或作为符号上方的注释,如图 1.9 所示。

在弹珠图中,时间从左到右,符号之间的距离显示两个事件之间经过的时间量。距离越长,历经的时间就越多,但只是相对的方式。无法知道时间是以秒、小时还是其他度量单位。如果此信息很重要,则会将其写为注释。

要显示可观察量已完成,请使用 | 标记。为了显示发生了错误(这也结束了可观察量),请使用 X。 如图 1.10 所示:

若要显示一个运算符(或多个运算符)对可观察量的输出,可以使用指示源事件与结果之间关系的箭头。请记住,每个运算符(至少绝大多数运算符)都返回自己的可观察量,因此在图中,我将在左侧编写作为管道一部分的运算符,在右侧编写表示从管道返回的可观察量的行。图 1.11 显示了前面可观察字符串序列示例的弹珠图,该字符串序列仅显示以 A 开头并将其转换为大写的字符串。

本书中使用弹珠图来展示运算符的效果,以及组合运算创建可观察管道的示例。此时,您可能想知道可观察序列与不可观察序列的关系。答案见下文。

1.2.7 拉(pull)模型与推(push)模型

不可观察序列是我们通常所说的枚举(或集合),它实现 IEnumerable 接口并返回实现 IEnumerator 接口的迭代器。使用枚举时,通常使用循环从集合中提取值。Rx 可观察量的行为不同:值不是拉取,而是推送给观察者。表 1.1 和 1.2 显示了拉动和推移模型如何相互对应。两者之间的这种关系称为对偶原则。

表 1.1 IEnumeratorIObserver 对应关系

IEnumerator IObserver
MoveNext—when false OnCompleted
MoveNext—when exception OnError(Exception exception)
Current OnNext(T)

表 1.2 IEnumerable 和 IObservable 对应关系

IEnumerable IObservable
IEnumerator GetEnumerator(void) IDisposable Subscribe(IObserver) 这里有一个对偶性的例外,因为 GetEnumerator 参数
的孪生体(为 void)应该已转换为订阅方法返回类型(并保持 void),但使用的是 IDisposable。

可观察量和观察者填补了 .NET 在处理异步操作时遇到的空白,该操作需要在推送模型中返回一系列值(推送序列中的每个项目)。与Task<T>提供单个值的异步或IEnumerable提供多个值但同步拉取模型不同,可观察量异步发出一系列值。表1.3对此作了总结。

表1.3 推模型和拉模型数据类型

单个值 多个值
Pull/Synchronous/Interactive T IEnumerable<T>
Push/Asynchronous/Reactive Task<T> IObservable<T>

由于可观察量和可枚举量之间存在反向对应关系(对偶性),因此s可以从值序列的一种表示形式移动到另一种表示形式。可以将固定集合(如 List<T>)转换为可观察量,该可观察量通过将所有值推送给观察者来发出所有值。更令人惊讶的事实是,可观察量可以转换为基于拉取的集合。您将在后面的章节中详细介绍如何以及何时进行这些转换。现在,要了解的重要一点是,因为您可以将一个模型转换为另一个模型,因此您可以使用基于拉取的模型执行的所有操作也可以使用基于推送的模型来完成。因此,当您遇到问题时,您可以在最简单的模型中解决它,然后根据需要转换结果。

我要在这里要说的最后一点是,因为您可以将单个值视为一个项目的集合,因此您可以通过相同的逻辑获取异步单个项(Task<T>)并将其视为一个项的可观察量,反之亦然。请记住这一点,因为这是理解“一切都是可观察的”的重要一点。

1.3 使用响应式系统和响应式宣言

到目前为止,我们已经讨论了 Rx 如何为应用程序增加响应性。许多应用程序不是独立的,而是整个系统的一部分,该系统由更多应用程序(桌面、移动、Web)、服务器、数据库、队列、服务总线以及创建工作有机体所需的其他组件组成。响应式编程模型(以及作为该模型实现的 Rx)简化了应用程序处理更改传播和事件使用的方式,从而使应用程序具有响应式。但是,如何使整个系统具有响应性呢?

作为一个系统,反应性的定义是响应式的、弹性的和消息驱动的。响应式系统的这四个特征在响应式宣言(www.reactivemanifesto.org)中定义,这是软件社区的协作努力,旨在定义构建响应式系统的最佳架构风格。您可以通过签署宣言和传播信息来加入提高对响应式系统的认识的努力。

重要的是要明白,反应式宣言并没有发明任何新东西,反应式应用程序早在发布之前就已经存在了。一个例子是已经存在了几十年的电话系统。这种类型的分布式系统需要对动态负载(呼叫)做出响应、从故障中恢复、并保持可用并响应呼叫者和被呼叫者,所有这些都是通过将信号(消息)从一个运营商传递到另一个运营商来实现的。

宣言在这里将响应式系统术语放在图上,并收集创建此类系统的最佳实践。让我们深入了解这些概念。

1.3.1 响应能力

当您转到自己喜欢的浏览器并输入URL时,您希望在短时间内加载您正在浏览的页面。当加载时间超过几毫秒时,你会有一种不好的感觉(甚至可能会生气)。您可能决定离开该站点并浏览到另一个站点。如果您是网站所有者,那么您已经失去了一个客户,因为您的网站没有及时响应。

系统的响应能力取决于系统响应收到的请求所花费的时间。显然,更短的响应时间意味着系统的响应速度更快。来自系统的响应可以是肯定的结果,例如您尝试加载的页面或您尝试从 Web 服务获取的数据或您希望在财务客户端应用程序中查看的图表。响应也可能是负面的,例如一条错误消息,指定您作为输入提供的值之一无效。

在任何一种情况下,如果系统响应所需的时间是合理的,则可以说应用程序是有响应的。但是,定义合理的时间是一件有问题的事情,因为它取决于上下文和您正在测试的系统。对于具有按钮的客户端应用程序,假定应用程序响应按钮单击所需的时间为几毫秒。对于需要进行大量计算的 Web 服务,一两秒也可能是合理的。设计应用程序时,需要分析操作,并定义操作完成和响应所需的时间范围。高响应能力是反应系统试图实现的目标。

1.3.2 容错性(Resiliency )

每隔一段时间,您的系统可能会面临故障。网络断开连接、硬盘驱动器故障、电源关闭或内部组件遇到异常情况。弹性系统是在发生故障时保持响应的系统。换句话说,在编写应用程序时,您希望以不阻止用户获取响应的方式处理故障。

向应用程序添加复原能力的方式因应用程序而异。一个应用程序可能会捕获异常并将应用程序返回到一致状态。另一个应用程序可能会添加更多服务器,以便在一台服务器崩溃时,另一台服务器将补偿并处理请求。要提高系统的复原能力,应遵循的一个好原则是避免单点故障。这可以通过使应用程序的每个部分与其他部分隔离来完成;您可以将部件分成不同的应用程序域、不同的进程、不同的容器或不同的计算机。通过隔离部件,可以降低整个系统不可用的风险。

1.3.3 可伸缩性(Elasticity )

您正在编写的应用程序将由许多用户使用——希望是大量用户。每个用户都会向您的系统发出请求,这可能会导致您的系统需要处理的高负载。系统中的每个组件对它可以处理的负载级别都有限制,当负载超过该限制时,请求将开始失败,组件本身可能会崩溃。这种负载增加的情况也可能是由系统遇到的分布式拒绝服务 (DDoS) 攻击引起的。

为了克服过载的原因,您的系统需要具有弹性:它需要随着负载的增加而跨越实例,并在负载减少时删除实例。自从云进入我们的生活以来,这种自动行为变得更加明显。在云上运行时,你会得到无限资源的错觉;通过一些简单的配置,您可以将应用程序设置为纵向扩展或缩减,具体取决于您定义的阈值。您只需要记住,运行额外的服务器会产生成本。

1.3.4 消息驱动

在这一点上,你可以说响应能力是你的目标,容错性是确保你保持响应的方式,弹性是提高容错性的一种方法。反应式系统拼图的缺失部分是系统各部分相互通信的方式,以允许我们探索的反应性类型。

异步消息传递是最适合我们需求的通信过程,因为它允许我们控制每个组件上的负载级别,而无需限制生产者——通常使用中间通道,如队列或服务总线。它允许将消息路由到正确的目标,并在组件崩溃时重新发送失败的消息。它还增加了内部系统组件的透明度,因为用户不需要知道内部系统结构,除了它可以处理的消息类型。消息驱动是使所有其他响应式概念成为可能的原因。图 1.12 显示了使用消息队列的消息驱动方法如何帮助提高系统中的消息处理速率并实现容错性和弹性。

图 1.12 消息驱动方法与负载均衡和弹性的关系。在左侧,消息以很高的频率到达,但系统处理被调平到恒定速率,并且队列的填充速度快于清空速度。在右侧,即使处理辅助角色崩溃,用户仍然可以填充队列;当系统恢复并添加新工作人员时,处理将继续。

在图中,参与者通过消息队列以消息驱动的方法进行通信。客户端发送一条消息,稍后由服务器检索。这种异步通信模型提供了对系统中处理的更大控制 - 控制速率和处理故障。存在许多消息队列实现,具有不同的功能集。有些允许消息的持久性,从而提供持久性,有些还提供“事务”传递模式,该模式锁定消息,直到使用者发出处理成功完成的信号。无论您选择哪个消息队列(或消息驱动平台),您都需要以某种方式掌握已发送的消息并开始处理它们。这就是 Rx 适合的地方。

1.3.5 Rx在哪里?

反应式扩展库在组成反应式系统的应用程序中发挥作用,它与消息驱动的概念有关。Rx 不是在应用程序或服务器之间移动消息的机制,而是负责在消息到达时处理消息并沿着应用程序内部的执行链传递消息的机制。重要的是要声明,即使您没有开发具有许多组件的完整系统,也可以使用 Rx 进行操作。即使是单个应用程序也会发现 Rx 对于响应事件和应用程序可能想要处理的消息类型很有用。所有响应式宣言概念和Rx之间的关系如图1.13所示。

图 1.13 响应式宣言核心概念之间的关系。Rx 位于消息驱动概念中,因为 Rx 提供了抽象来处理消息进入应用程序。

要获得一个完全响应式的系统,图表中的所有概念都必须存在。每个都可以在不同的系统中以不同的方式实现。Rx 是允许更轻松地使用消息的一种方法,因此它显示为消息驱动块的一部分。Rx 是作为处理异步和基于事件的程序的一种方式引入的,就像消息一样,因此我解释异步的含义以及为什么它很重要很重要。

1.4 理解异步性

异步消息传递是反应式系统的一个关键特征。但是,异步性到底是什么,为什么它对反应式应用程序如此重要?我们的生活由许多异步任务组成。您可能没有意识到这一点,但是如果您的日常活动本质上不是异步的,那会很烦人。要了解什么是异步性,首先需要了解非异步执行或同步执行。

定义 同步:在同一时间发生、存在或出现

同步执行意味着您必须等待任务完成,然后才能继续执行下一个任务。同步执行的一个真实示例发生在快餐店:您接近柜台的工作人员,在店员等待时决定点什么,点餐,然后等到饭菜准备好。店员等到你交出付款,然后给你食物。只有这样,您才能继续下一个任务,即去餐桌上吃饭。此顺序如图 1.14 所示。

图 1.14 同步食物订单,其中每个步骤都必须完成才能进入下一个步骤

这种类型的序列感觉像是在浪费时间(或者,更好地说,浪费资源),所以想象一下当你为应用程序做同样的事情时,你的应用程序是什么感觉。下一节将对此进行演示。

1.4.1 一切都与资源使用有关

想象一下,如果你必须等待每一个操作完成,然后才能做其他事情,你的生活会是什么样子。想想当时将等待和使用的资源。同样的问题在计算机科学中也相关:

writeResult = LongDiskWrite();
response = LongWebRequest();
entities = LongDatabaseQuery();

在这个同步代码片段中,LongDatabaseQueryLongWebRequestLongDiskWrite完成之前不会开始执行。在执行每个方法期间,调用线程被阻塞,它持有的资源实际上被浪费了,不能用于处理其他请求或处理其他事件。如果这种情况发生在 UI 线程上,则应用程序将看起来冻结,直到执行完成。如果这种情况发生在服务器应用程序上,则在某些时候,您可能会用完空闲线程,并且请求将开始被拒绝。在这两种情况下,应用程序都会停止响应。

运行上述代码片段所需的总时间如下:
$$
total_time = TimeOf(LongDiskWrite) + TimeOf(LongWebRequest) + TimeOf(LongDatabaseQuery)
$$
总完成时间是其组件的完成时间之和。如果可以在不等待上一个操作完成的情况下启动操作,则可以更好地使用资源。这就是异步执行的用途。

异步执行 表示操作已启动,但其执行在后台进行,并且调用方未被阻止。相反,当操作完成时,将通知调用方。在此期间,调用方可以继续执行有用的工作。

在点餐示例中,异步方法类似于坐在餐桌旁并由服务员服务。首先,你坐在桌子旁,服务员来递给你菜单然后离开。当您决定点什么时,服务员仍然可为其他顾客服务。当您决定要吃什么饭后,服务员会回来接收您的订单。在准备食物时,您可以自由聊天、使用手机或欣赏美景。你没有被阻塞(服务员也没有)。食物准备好后,服务员将其带到您的餐桌上,然后回去为其他顾客服务,直到您要求账单并付款。

此模型是异步的:任务并发执行,执行时间与请求时间不同。这样,资源(例如服务员)就可以自由地处理更多请求。

异步执行在哪里发生?

在计算机程序中,我们可以区分两种类型的异步操作:基于 CPU 和基于 I/O。

在基于 CPU 的操作中,异步代码在另一个线程上运行,当另一个线程上的执行完成时返回结果。

在基于 I/O 的操作中,操作是在 I/O 设备(如硬盘驱动器或网络)上进行的。在网络上,向另一台计算机发出请求(通过使用 TCP 或 UDP 或其他网络协议),当计算机上的操作系统通过中断从网络硬件获得结果返回的信号时,操作将完成。

在这两种情况下,调用线程都可以自由执行其他任务并处理其他请求和事件。

异步运行代码的方法不止一种,这取决于所使用的语言。附录 A 显示了在 C# 中完成此操作的方法,并深入探讨了每个方法的位和字节。现在,让我们看一个使用 .NET 实现执行异步工作的示例 — Task 类:

上述代码段的异步版本如下所示:

taskA = LongDiskWriteAsync();
taskB = LongWebRequestAsync();
taskC = LongDatabaseQueryAsync();
Task.WaitAll(taskA, taskB, taskC);

在此版本中,每个方法都返回Task<T>。此类表示在后台执行的操作。调用每个方法时,不会阻止调用线程,并且该方法会立即返回。然后调用下一个方法,而前一种方法仍在执行。调用所有方法后,使用 Task.WaitAll 方法等待其完成,该方法获取任务和块的集合,直到所有任务和块都完成。另一种编写方法如下:

taskA = LongDiskWriteAsync();
taskB = LongWebRequestAsync();
taskC = LongDatabaseQueryAsync();
taskA.Wait();
taskB.Wait();
taskC.Wait();

这样,您可以获得相同的结果;您等待每个任务完成(当它们仍在后台运行时)。如果在调用 Wait 方法时任务已完成,它将立即返回。

运行代码片段的异步版本所需的总时间如下所示:
$$
total_time = MAX(T(LongDiskWrite), T(LongWebRequest), T(LongDatabaseQuery))
$$
由于所有方法都同时运行(甚至可能并行运行),因此运行代码所需的时间将是最长操作的时间。

1.4.2 异步性和Rx

异步执行不限于仅使用 Task<T> 进行处理。在附录 A 中,将向您介绍 .NET Framework 中用于提供异步执行的其他模式。

回顾IObservable<T>,时变变量的 Rx 表示形式,您可以使用它来表示任何异步模式,因此当异步执行完成(成功或出现错误)时,将运行执行链并评估依赖项。Rx 提供了将各种类型的异步执行(如Task<T>转换为IObservable<T>的方法。

例如,在 Shoppy 应用中,您不仅希望在位置发生变化时获得新的折扣,而且希望在连接状态更改为在线时获得新的折扣(例如,如果您的手机在短时间内失去信号,然后重新连接)。对 Shoppy Web 服务的调用以异步方式完成,完成后,您希望更新视图以显示新项目:

IObservable<Connectivity> myConnectivity=...
IObservable<IEnumerable<Discount>> newDiscounts =
	from connectivity in myConnectivity
	where connectivity == Connectivity.Online
	from discounts in GetDiscounts()	//GetDiscount 返回隐式转换为可观察量的任务。
	select discounts;
newDiscounts.Subscribe(discounts => RefreshView(discounts));	//RefreshView 显示折扣信息。

private Task<IEnumerable<Discount>> GetDiscounts()
{
	//Sends request to the server and receives the collection of discounts
}

在此示例中,您将对myConnectivity可观察对象上发生的连接更改做出反应。每次连接发生更改时,您都会检查是否是因为您处于联机状态,如果是,则调用异步GetDiscounts方法。方法执行完成后,选择返回的结果。此结果将被推送到从您的代码创建的newDiscounts可观察对象的观察者。

1.5 了解事件和流

在软件系统中,事件是一种消息,用于指示已发生某些事情。该事件可能表示技术事件 — 例如,在 GUI 应用程序中,您可能会看到按下的每个键或每次鼠标移动的事件。该事件还可以表示业务事件,例如在金融系统中完成的货币交易。

事件由事件源引发,并由事件处理程序使用。如您所见,事件是表示时变值的一种方法。在 Rx 中,事件源可以由可观察量表示,事件处理程序可以由观察器表示。但是,我们的应用程序正在使用的简单数据呢,例如位于数据库中的数据或从Web服务器获取的数据。它在反应世界中有一席之地吗?

1.5.1 一切皆是流

您编写的应用程序最终将处理某种数据,如图 1.15 所示。数据可以有两种类型:静态数据和动态数据。静态数据以数字格式存储,通常从持久存储(如数据库或文件)中读取数据。动态数据在网络(或其他介质)上移动,并被推送到应用程序或由应用程序从任何外部源拉取。

图 1.15 动态数据和静态数据作为一个数据流。来自外部环境的连接点非常适合创建可观察物。这些可观察量可以轻松地与 Rx 合并,以创建一个合并的可观察量,内部模块可以在不知道数据元素的确切来源的情况下订阅该可观察量。

无论您在应用程序中使用哪种类型的数据,现在是时候了解所有内容都可以作为流进行观察,即使是静态数据和对应用程序看起来是静态的数据。例如,配置数据被视为静态的,但即使是配置也会在某个时候发生变化,无论是在很长一段时间后还是在短时间之后。从应用程序的角度来看,这并不重要;您希望做出反应并在这些更改发生时进行处理。当您将静态数据视为另一个数据流时,可以更轻松地组合这两种类型的数据。对于您的应用程序,数据来自何处并不重要。

例如,应用程序启动通常从其持久存储中加载数据以还原其状态(在应用程序关闭之前保存的状态)。当然,此状态可以在应用程序运行期间更改。关心状态的应用程序内部部分可以查看承载状态的数据流。当应用程序启动时,流将传送已加载的数据,当状态更改时,流将携带更新。

我喜欢用来解释水流的一个很好的类比是水管,但这个软管有数据包通过它,就像你在图 1.16 中看到的那样。使用水管时,您可以用它做很多事情。您可以在末尾放置过滤器。您可以添加不同的软管头,以提供不同的功能。您可以添加压力监测器来帮助您调节流量。您可以对数据流执行相同的操作。您需要构建一个管道,让信息流过它,最终给出适合您的逻辑的最终结果;这包括筛选、转换、分组、合并等。

数据和事件流非常适合 Rx 可观察量。使用 IObservable 抽象它们使您能够组合运算符并创建复杂的执行管道。这类似于您对 Shoppy 示例所做的操作,其中对服务器的调用获得了折扣,作为更复杂的执行管道的一部分,该管道还使用过滤(在连接上)并最终刷新视图(如洒水器溅水)。

图1.16 数据流就像一根软管:每一滴水都是一个数据包,需要经过站点直到到达终点。您的数据还需要被过滤和转换,直到它到达真正的处理程序,该处理程序对它执行一些有用的操作。

1.6 小结

本章介绍了响应式意味着什么,以及如何使用 Rx 在应用程序中实现响应式编程。

  • 在响应式编程中,您使用时变变量来保存通过对其依赖项发生的更改来更改的值。您在 Shoppy 示例中看到了这些变量的示例:位置、连通性、图标大小等。
  • Rx是微软开发的库,用于在.NET应用程序中实现响应式编程。
  • 在 Rx 中,时变变量由实现IObservable<T>接口的可观察序列抽象。
  • 可观察对象是通知的生产者,观察者订阅它以接收这些通知。
  • 每个观察者订阅都表示为 IDisposable,允许随时取消订阅。
  • 观察器实现IObserver<T>接口。
  • 可观察量可以发出带有有效负载的通知,在其完成时通知,并在出现错误时通知。
  • 在可观察量通知观察者其完成或错误后,不会再发出通知。
  • 可观察量并不总是完整的;它们可以是潜在无限通知的提供者。
  • 可观察量可以是“静默的”,这意味着它们从未推动过任何元素,也永远不会。
  • Rx 提供的运算符用于以与 LINQ 使用的语法相同的语法创建查询、转换、投影等管道。
  • 弹珠图用于可视化 Rx 管道。
  • 反应式系统被定义为响应式、容错性、弹性和消息驱动。反应式系统的这些特征在反应式宣言中定义。
  • 在反应式系统中,Rx 放置在消息驱动的插槽中,作为您希望处理应用程序接收的消息的方式。
  • 异步性是反应式最重要的部分之一,因为它允许您更好地利用资源,从而使应用程序响应更快。
  • “一切都是流”解释了为什么 Rx 可以轻松使用任何源,即使它是数据库等数据源。

在下一章中,您将有机会构建您的第一个 Rx 应用程序,并将其与传统的事件处理方式编写相同的应用程序进行比较。你会亲眼看到Rx有多棒。

posted @ 2023-03-16 09:06  叫我灰太良  阅读(1260)  评论(0)    收藏  举报