RxJS 和 Angular Signal 响应式模式(三)
原文:
zh.annas-archive.org/md5/6b9451a708fdf5156dec14c45492969b译者:飞龙
第十一章:执行批量操作
批量操作是在大规模上执行的任务,例如一次性上传多个文件、一次性删除或插入多个项目,或者同时对列表中的多个元素应用转换或计算。
这些操作旨在处理单个操作中的多个更新,通常与单独处理每个项目相比,效率更高,性能更好。跟踪批量操作的进度对于向用户提供反馈、监控操作的健康状况以及识别潜在问题至关重要。
在本章中,我们将首先解释批量操作需求以及我们将考虑的批量操作类型。然后,我们将向您介绍实现批量操作的响应式模式的各个步骤。最后,我们将学习用于跟踪批量操作进度的响应式模式。
在本章中,我们将涵盖以下主要主题:
-
定义批量操作需求
-
学习用于批量操作的响应式模式
-
学习用于跟踪批量操作进度的响应式模式
技术要求
本章假设您对 RxJS 有基本的了解。
定义批量操作需求
在 Web 应用程序中,批量操作由一个动作或事件表示;然而,在后台,有两种可能的行为:
-
为所有任务运行一个网络请求
-
为每个任务运行并行网络请求
在本章中,我们将使用第二种行为。我们希望允许用户一次性上传菜谱图片,跟踪上传操作的进度,并向用户显示进度条。我们可以在这里看到它将是什么样子:
图 11.1 – 上传菜谱的图片
在RecipeCreation接口中,我们将更改ImageUrl字段的布局,将其更改为我们组件库 PrimeNG 中可用的文件上传布局,如图所示。文件上传布局允许用户选择多个文件、清除选择并上传文件。
上传将在服务器上完成,我们有一个专门的上传服务,该服务接受要上传的文件和关联菜谱的标识符作为输入。由于后端上传 API 一次只支持一个文件,我们将并行运行N个网络请求来上传N个文件(即,如果我们上传两个文件,将发送两个请求)。这是我们将在本章中考虑的大规模更改用例。
在 UI 中,我们将有一个事件,该事件将同时触发多个请求。以下图表提供了批量操作的图形表示:
图 11.2 – 批量操作可视化
因此,总结一下,我们想要做以下事情:
-
允许用户在点击一次 上传 按钮后上传多个文件
-
显示此批量操作的进度
既然我们已经定义了需求,让我们看看我们如何以响应式的方式实现它。
学习批量操作的响应式模式
如同往常,我们必须将我们的任务视为流。由于我们即将执行的任务是在后端上传食谱图片,让我们想象一个名为 uploadRecipeImage$ 的流,它将文件和食谱标识符作为输入并执行 HTTP 请求。如果我们有 N 个文件需要上传,那么我们将创建 N 个流。
我们希望一起订阅所有这些流,但我们对每个流在过程中发射的值不感兴趣。相反,我们只关心最终结果(最后一次发射)——文件是否成功上传,或者发生错误导致上传失败。
有没有 RxJS 操作符可以收集一组可观察对象以获得累积结果?幸运的是,是的:我们有 forkJoin 操作符。
forkJoin 操作符
forkJoin 操作符属于组合操作符类别。如果我们查看官方文档,我们会找到以下定义:
“接受一个 ObservableInput 的数组或一个包含 ObservableInput 的字典对象,并返回一个 Observable,该 Observable 会以与传入数组相同的顺序发射值数组,或者以与传入字典相同形状的值字典。”
换句话说,forkJoin 接受一个可观察对象的列表作为输入,等待可观察对象完成,然后将它们最后发射的值合并到一个数组中并返回。结果数组中值的顺序与输入可观察对象的顺序相同。
让我们考虑以下大理石图来更好地理解这一点:
图 11.3 – 一个 forkJoin 大理石图
在这里,forkJoin 有三个输入可观察对象(由操作符框之前的三个时间线表示)。
第一个可观察对象发射的 forkJoin 不发射任何内容(查看操作符框之后的最后一个时间线,它代表了 forkJoin 返回的结果)。
然后,第三个可观察对象发射了 forkJoin。为什么?因为,正如我们在定义中所说的,当所有可观察对象都完成时,forkJoin 才会发射一次。
因此,如图中大理石图所示,当最后一个可观察对象(第二个)完成时,forkJoin 只发射了一次。让我们来分析一下:
-
第三个可观察对象(由第三个时间线表示)首先完成,最后发射的值是 4。
-
然后,第一个可观察对象(由第一个时间线表示)完成,最后一个发出的值是
forkJoin没有发出任何值,因为还有一个可观察对象正在运行。 -
最后,最后一个可观察对象(由第二个时间线表示)完成,最后一个发出的值是
forkJoin返回一个包含每个输入可观察对象结果的数组,顺序与输入可观察对象(e、j和4)的顺序相同。
完成顺序不考虑;否则,我们会有[4,e,j]。即使第三个可观察对象在第一个和第二个可观察对象之前完成,forkJoin也尊重输入可观察对象的顺序,并在4和j值之前返回e值。
因此,请记住,当所有输入可观察对象都完成时,forkJoin会发出一次,并保留输入可观察对象的顺序。
这很好地符合了我们的要求!forkJoin在您有一系列可观察对象且只关心每个可观察对象的最终发出值时使用最佳。这正是我们想要做的。在我们的情况下,我们将发出多个上传请求,并且我们只想在收到所有输入流的响应时采取行动。
现在让我们看看批量操作响应式模式在实际中的应用。
批量操作响应式模式
要在我们的菜谱应用中利用此模式,首先,我们需要在src/app/core/services下创建一个名为UploadRecipesPreviewService的新服务,该服务负责上传文件。以下是该服务的代码:
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { UploadStatus } from '../model/upload.status.model';
import { environment } from 'src/environments/environment';
const BASE_PATH = environment.basePath
@Injectable({
providedIn: 'root'
})
export class UploadRecipesPreviewService {
constructor(private http: HttpClient) { }
upload(recipeId: number|undefined|null, fileToUpload:
File): Observable<UploadStatus> {
const formData = new FormData()
formData.append('fileToUpload', fileToUpload as File)
return this.http.post< UploadStatus >(
`${BASE_PATH}/recipes/upload/${recipeId}`,
formData
)
}
}
upload方法发出 HTTP 上传请求并返回上传状态(是否成功或失败)。此方法接受两个参数作为输入:
-
recipeId:菜谱的标识符 -
fileToUpload:要上传的文件
然后我们使用FormData将文件发送到服务器。FormData是 JavaScript 中的一个对象,它允许您轻松构建一组键值对,分别代表表单字段及其值。
现在我们需要实现RecipeCreationComponent模板的行为,我们需要指定当点击我们的onUpload方法时将被调用的方法——并将其作为值放入由我们使用的组件库提供的回调——uploadHandler——以在用户上传文件时触发。以下是 HTML 模板片段:
<div class="form-row">
<div class="col-12">
<label for="ImageUrl">ImageUrl</label>
<p-fileUpload name="imageUrl" [multiple]=true
[customUpload]="true" (uploadHandler)=
"onUpload($event.files)">
</p-fileUpload>
</div>
</div>
注意
为了简洁起见,这里已经从模板中删除了一些代码。您可以在书籍的 GitHub 仓库中找到完整的模板代码,该链接可以在技术要求部分找到。
接下来,我们需要实现onUpload方法并在RecipeCreationComponent中定义我们的响应式流。因此,我们将定义以下内容:
-
一个
BehaviorSubject,它将始终发出上传文件的最后一个值,称为uploadedFilesSubject$,并用空数组初始化它:uploadedFilesSubject$ = new BehaviorSubject<File[]>([]); -
onUpload (files: File[])方法,当点击uploadedFilesSubject$时调用,并带有最后一个上传文件的数组如下:onUpload(files: File[]) { this.uploadedFilesSubject$.next(files); } -
一个名为
uploadRecipeImages$的流,负责执行批量上传,如下所示:uploadRecipeImages$ = this.uploadedFilesSubject$.pipe( switchMap(uploadedFiles=>forkJoin( uploadedFiles.map((file: File) => this.uploadService.upload( this.recipeForm.value.id, file)))) )让我们逐个分析这里代码中正在发生的事情。
每次我们点击
uploadedFilesSubject$时,都会发射要上传的文件。我们需要监听uploadedFilesSubject$的发射,然后使用switchMap(我们在 第六章,转换流) 将uploadedFilesSubject$发射的每个值转换为我们将使用forkJoin构建的 Observable。对于
forkJoin,我们传递一个数组,其中包含负责上传每个文件的 Observables。我们通过将uploadedFiles数组中的每个文件映射到由调用UploadRecipesPreviewService中的upload方法生成的流来构建 Observables 数组,该方法接受来自recipeForm的菜谱的id属性(我们从中检索)和文件作为输入。
现在我们已经建立了上传逻辑并定义了上传流,是时候订阅 uploadRecipeImages$ 流了。我们需要在构造函数中注入 UploadRecipesPreviewService 并在模板中订阅 uploadRecipeImages$,如下所示:
<ng-container *ngIf="uploadRecipeImages$ | async"></ng-
container>
现在,假设其中一个内部流出现错误。forkJoin 操作符将不再为我们发射任何值。这是在使用此操作符时需要注意的另一个重要事项。如果你没有正确捕获内部 Observable 上的错误,你将丢失任何其他已经完成的流的值。因此,在这种情况下捕获错误是至关重要的!
这就是我们处理它的方式:
uploadRecipeImages$ = this.uploadedFilesSubject$.pipe(
switchMap(uploadedFiles=>forkJoin(uploadedFiles.map((
file: File) =>
this.uploadService.upload(this.recipeForm.value.id,
file).pipe(
catchError(errors => of(errors)),
))))
在这里,我们在 upload 方法返回的内部流上调用 catchError。然后,我们将错误包装在另一个 Observable 中并返回它。这样,forkJoin 流将保持活跃并发射值。
捕获错误以向用户显示一些有意义的内容是非常有意义的 - 例如,在我们的案例中,如果上传失败是因为达到了最大图像文件大小或图像扩展名不被允许,那么系统应该向用户显示这样的异常,帮助他们修复文件。
forkJoin 操作符的优点
总结一下,forkJoin 有以下优点:
-
当你对组合结果并只获取一次值感兴趣时,它非常有用
-
它只发射一次,当所有 Observables 完成时
-
它保留了输入 Observables 在发射中的顺序
-
当其中一个流出现错误时,它将完成,所以请确保你处理了错误
现在,在这个阶段,我们的代码运行得很好。但如果我们需要在过程中了解某些信息,比如已经上传了多少文件?操作进度如何?我们还需要等待多长时间?
在当前的 forkJoin 实现中,这是不可能的,但让我们看看在下一节中我们如何做到这一点。
学习反应式模式以跟踪批量操作进度
跟踪大量操作的进度非常重要,因为它为用户提供反馈并可以识别潜在问题。当涉及到跟踪进度的方法时,根据大量操作的性质和所使用的技术堆栈,有不同的策略和技术。例如,你可以使用递增计数器来显示每次操作的处理情况,使用百分比来跟踪操作的进度,或者甚至将进度记录到文件或数据库中。
在我们的食谱应用中,为了跟踪大量上传的进度,我们将使用完成百分比策略。为了实现此策略,我们将使用一个非常有用的运算符,称为finalize。
finalize运算符允许你在 Observable 完成或出错时调用一个函数。想法是调用此运算符并执行一个计算进度的函数。这样,每次 Observable 完成时,进度都会得到更新。
这就是代码的样子:
counter: number = 0;
uploadProgress: number=0;
uploadRecipeImages$ = this.uploadedFilesSubject$.pipe(
switchMap(uploadedFiles =>
forkJoin(uploadedFiles.map((file: File) =>
this.uploadService.upload(this.recipeForm.value.id,
file).pipe(
catchError(errors => of(errors)),
finalize(() => this.calculateProgressPercentage(
++this.counter, uploadedFiles.length))
))))
)
private calculateProgressPercentage(completedRequests:
number, totalRequests: number) {
this.uploadProgress =
Math.round((completedRequests / totalRequests) *
100);
}
onUpload(files: File[]) {
this.uploadProgress=0;
this.counter=0;
this.uploadedFilesSubject$.next(files);
}
finalize运算符调用calculateProgressPercentage私有函数,该函数接受以下参数:
-
完成的请求数量:我们只声明一个
counter属性,每次 Observable 完成时我们将增加它 -
请求总数:此数字是从
uploadedFiles数组中检索的
在calculateProgressPercentage函数内部,我们执行一个简单的计算来识别完成百分比并将结果存储在uploadProgress属性中。当用户点击uploadProgress和counter属性时,应将它们重置为0。
然后,你可以将此属性的值映射到 UI 中的任何ProgressBar组件。在我们的例子中,我们使用了 PrimeNG 的p-progressBar组件,如下所示:
<div class="row">
<div class="col-12">
<label for="ImageUrl">ImageUrl</label>
<!-- <input type="text" name="imageUrl"
formControlName="imageUrl"> -->
<p-fileUpload name="imageUrl" [multiple]=true
[customUpload]="true"
(uploadHandler)="onUpload($event.files)"
accept="image/*"></p-fileUpload>
@if(uploadProgress>0) {
<p-progressBar [value]=uploadProgress>
</p-progressBar>
}
</div>
</div>
在这里,我们只在上传过程中显示p-progressBar(当uploadProgress>0时)并将uploadProgress值作为输入传递给进度组件。这样,你就能向用户显示进度。
在我们的应用中,这是结果:
图 11.4 – 文件上传进度条
摘要
在本章中,我们解释了大量操作的概念,并学习了如何以响应式的方式实现一个实际的大量任务示例。我们学习了forkJoin运算符的行为和用例,并了解了实现大量上传的不同步骤。最后,我们通过使用finalize运算符实现跟踪进度功能的方法进行了响应式技术介绍。
在下一章中,我们将探讨实时更新模式以及 RxJS 中可用的不同技术,以最低的成本实现它们。
第十二章:处理实时更新
实时指的是应用程序能够立即处理和响应数据或事件的能力,没有任何明显的延迟或延迟。这在当今是一个非常热门的话题,因为对实时功能的需求在 Web 应用程序中不断增长,尤其是在实时金融交易、实时跟踪系统、实时监控、分析和医疗保健等领域。最终,你获取数据越快,你就能越快做出反应和决策,从而提高获得更高利润的机会。
那么,你如何在前端处理实时消息并自动更新 UI 中显示的数据?这正是本章将要涵盖的内容。我们将首先解释实时要求,然后我们将向您介绍实现消费实时更新的反应式模式的各个步骤。最后,我们将学习用于处理重连的反应式模式。
在本章中,我们将涵盖以下主要主题:
-
定义实时要求
-
学习用于消费实时消息的反应式模式
-
学习用于处理重连的反应式模式
技术要求
本章假设您对 RxJS 有基本的了解。
我们使用了 ws 库,这是一个 WebSocket Node.js 库,以便在我们的后端支持 WS。更多详情,请查看此链接:github.com/websockets/ws。
本章的源代码可在github.com/PacktPublishing/Reactive-Patterns-with-RxJS-for-Angular-16-2nd-Edition/tree/main/Chap12找到。
定义实时要求
在网络上发布实时数据有两种技术可用:
-
拉取技术:这是客户端发起请求以获取最新数据版本的地方。HTTP 轮询和HTTP 长轮询是这种拉取技术实现的两个例子。
-
推送技术:这是服务器将更新推送到客户端的地方。WebSocket 和 服务器发送事件是这种推送技术的两种实现。
我们不会详细讨论或比较这些技术,因为这不是本章的目标;然而,一般来说,推送技术相比拉取技术具有更低的延迟。因此,我们将使用推送技术和 WebSocket 作为我们需求的实现。
简而言之,WebSocket 协议是一种有状态的通信协议,它在一个客户端和服务器之间建立了一个低延迟的双向通信通道。这样,消息可以在服务器和客户端之间来回发送。
下图说明了 WebSocket 通信流程:
图 12.1 – WebSocket 通信
如上图所示,WebSocket 通信有三个步骤:
-
打开连接:在这个步骤中,客户端发出一个 HTTP 请求来告诉服务器将发生协议升级(从 HTTP 到 WebSocket)。如果服务器支持 WebSocket,则协议切换将被接受。
-
建立通信通道:一旦完成协议升级,将创建一个双向通信通道,服务器和客户端之间开始发送和接收消息。
-
关闭连接:当通信结束时,将发出一个请求来关闭连接。
在这个层面,这就是你需要了解的所有关于 WebSocket 的内容。现在,让我们快速回顾一下我们将在应用中做什么。
在我们的食谱应用中,RecipesListComponent 负责显示食谱列表。我们将在 RecipesListComponent 渲染后延迟 5 秒模拟添加一个新的食谱(辣子鸡的食谱)。然后,UI 应立即更新,通过在 RecipesList 页面上渲染来包括这个新食谱。
你将在 recipes-book-api 文件夹下找到一个现成的 WebSocket 后端;这是在建立连接 5 秒后向前端推送新食谱的。我们还将使用后端中的计时器来模拟新食谱的到达。然后 RecipesListComponent 应该消费来自 WebSocket 服务器的消息,并将新接收到的食谱推送到已显示的食谱列表中。UI 应自动更新,无需触发任何 刷新 按钮来获取更新。
因此,无需多言,在下一节中,让我们看看如何使用 RxJS 的 WebSocketSubject 来实现所有这些。
学习用于消费实时消息的反应式模式
RxJS 有一种特殊类型的主题称为 WebSocketSubject;这实际上是对 W3C WebSocket 对象的包装,它在浏览器中可用。它允许你通过 WebSocket 连接与 WebSocket 服务器进行通信,发送和消费数据。
让我们探索 WebSocketSubject 的功能,并学习如何在我们项目中使用它来消费实时消息。
创建和使用 WebSocketSubject
为了使用 WebSocketSubject,你必须调用 webSocket 工厂函数,它产生这种特殊类型的主题,并接受你的 WebSocket 服务器端点作为输入。以下是其函数签名:
webSocket<T>(urlConfigOrSource: string | WebSocketSubjectConfig<T>): WebSocketSubject<T>;
它接受两种类型的参数,以下两种之一:
-
表示你的 WebSocket 服务器端点 URL 的字符串
-
一个包含你的端点 URL 以及其他属性的
WebSocketSubjectConfig类型的特殊对象(我们将在 学习用于处理 重连 的反应式模式部分详细探讨WebSocketSubjectConfig)
以下代码是调用 webSocket 工厂函数并使用第一种类型参数的示例:
import { webSocket } from "rxjs/webSocket";
const subject = webSocket("ws://localhost:8081");
下一段代码是使用第二种类型的参数调用 webSocket 工厂函数的示例:
import { webSocket } from 'rxjs/webSocket';
const subject$ = webSocket({url:'ws://localhost:8081'});
在我们这个例子中,我们端点的 URL 是 ws://localhost:8081。你可以使用 wss 来进行安全的 WebSocket 连接(这和安全的 HTTP 连接中的 HTTPS 相同)。
在本章中,我们将使用这两种类型的参数。
现在我们来看一下如何在下一节中建立 WebSocket 的连接。
打开连接
现在你有了 WebSocketSubject 的参考,你应该订阅它:
import { webSocket } from 'rxjs/webSocket';
const subject$ = webSocket({url:'ws://localhost:8081'});
subject$.subscribe();
这将建立与你的 ws 端点的连接,并允许你开始接收和发送数据。当然,如果你不订阅,连接将不会创建。
监听来自服务器的传入消息
WebSocketSubject 仅仅是一个常规的 RxJS 主题,你可以注册回调来监听和处理来自 WebSocket 服务器的传入消息。
为了监听消息,你应该从 webSocket 工厂函数订阅生成的 WebSocketSubject 并注册一个回调,如下所示:
const subject$ = webSocket('ws://localhost:8080');
// Listen to messages from the server
const subscription = subject$.subscribe(msg => {
console.log('Message received from the socket'+ msg);
});
在这里,我们只是订阅 WebSocket 主题以与 WebSocket 服务器建立连接,并将接收到的任何消息记录到控制台。
向服务器推送消息
要向服务器发送消息,我们只需使用 subject 类型中可用的 next 方法:
// Push messages to the server
subject$.next('Message to the server');
处理错误
你也可以像往常一样使用 catchError 来捕获来自服务器的错误,并通过调用 error 方法将错误推送到服务器。以下是一个示例:
// Push errors to the server
subject$.error('Something wrong happens')
// Handle incoming errors from the server
subject$.pipe(catchError(error=>of('Something wrong happens')))
然而,请注意,当你发送错误时,服务器将收到这个错误的通知,然后连接将被关闭。因此,之后将不会发出任何内容。
关闭连接
你可以使用 unsubscribe 或 complete 来关闭连接:
// Close the connection
subject$.complete();
//or
subject$.unsubscribe();
因此,为了总结我们讨论的内容,只有 WebSocketSubject 的创建是特定于这种特殊类型的主题。然而,所有其他使用的 API(subscribe、unsubscribe、complete、catchError、next 等)与常规主题使用的相同。以下图示展示了整个过程:
图 12.2 – WebSocketSubject 可能的事件
现在我们已经涵盖了各种 WebSocket 操作,从创建和建立连接到发送消息、处理错误以及消费传入的消息,让我们来探讨一个你应该注意的常见陷阱。
连接管理
在这一点上,你应该注意一个特定的行为。如果同一个 WebSocketSubject 实例有多个订阅者,那么它们将共享相同的连接以节省资源。然而,如果我们有两个不同的 WebSocketSubject 实例,即使它们引用的是同一个端点,它们也会建立两个不同的连接。
以下代码解释了两种用例的连接管理:
const firstSubject$ = webSocket('ws://localhost:8080');
const secondSubject$ = webSocket('ws://localhost:8080');
// the first subscriber, opens the WebSocket connection
const subscription1 = firstSubject$.subscribe(msg => {
});
// the second subscriber, uses the already opened WebSocket
connection
const subscription2 = firstSubject$.subscribe(msg => {
});
//this subscriber opens a new connection
const subscription3 = secondSubject$.subscribe(msg => {
});
让我们解释一下这段代码中发生的事情。首先,我们创建了两个名为firstSubject$和secondSubject$的WebSocketSubject实例,它们都引用了同一个ws端点。
然后,我们创建了一个订阅firstSubject$;这个第一个订阅将打开 WebSocket 连接。接着,我们为同一个 Observable,即firstSubject$,创建了一个第二个订阅;这个第二个订阅将使用已经打开的 WebSocket 连接。
然而,对secondSubject$的订阅将打开一个新的 WebSocket 连接。为什么?因为它是对 WebSocket 主题的新引用,尽管它引用了与firstSubject$相同的ws端点。
现在,如果我们有多个共享相同连接的订阅者,并且其中一个订阅者决定完成,那么除非没有更多的订阅者监听,否则连接将被释放,正如以下代码块中描述的那样:
const subject$ = webSocket('ws://localhost:8080');
// the first subscriber, opens the WebSocket connection
const subscription1 = subject$.subscribe(msg => {});
// the second subscriber, uses the already opened WebSocket connection
const subscription2 = subject$.subscribe(msg => {});
// the connection stays open
subscription1.unsubscribe();
// closes the connection
subscription2.unsubscribe();
这就是你需要知道的所有内容,以便使基本场景工作。简单,对吧?
现在,让我们看看将我们的食谱应用部署到位的推荐模式。
WebSocketSubject 在行动
既然我们已经知道了如何创建到ws端点的连接,那么现在是时候探索在RecipesApp中消费实时消息的不同步骤了。特别是,我们将建立与 WebSocket 服务器的连接,一旦新的食谱被发送到前端,我们将在 UI 中更新它。让我们深入了解满足这一要求所需的各个步骤。
第一步 - 创建实时服务
第一步是将与WebSocketSubject的所有交互隔离到一个单独的 Angular 服务中。为此,我们将在src/app/core/services路径下创建一个名为RealTimeService的 Angular 服务。RealTimeService将如下所示:
import { Injectable } from '@angular/core';
import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
import { environment } from '../../../environments/environment';
import { Recipe } from '../model/recipe.model';
export const WS_ENDPOINT = environment.wsEndpoint;
@Injectable({
providedIn: 'root'
})
export class RealTimeService {
private socket$: WebSocketSubject<Recipe[]> | undefined;
private messagesSubject$ = new
BehaviorSubject<Observable<Recipe[]>>(EMPTY);
private getNewWebSocket(): WebSocketSubject<Recipe[]> {
return webSocket(WS_ENDPOINT);
}
sendMessage(msg: Recipe[]) {
this.socket$?.next(msg);
}
close() {
this.socket$?.complete();
} }
让我们分析一下在定义的服务中,代码层面的具体操作:
-
我们有一个私有属性
socket$,其类型为WebSocketSubject<Recipe[]>|undefined,因为我们将从后端接收包含一个或多个食谱的数组。socket$包含我们将使用getNewWebSocket()方法创建的 WebSocket 主题的引用。 -
我们有一个名为
messagesSubject$的私有BehaviorSubject,它负责将 WebSocket 服务器发送的最新消息传输给新的订阅者。我们为messagesSubject$提供了类型Observable<Recipe[]>,因为它将发出包含一系列食谱对象的 Observable。最初,我们将其设置为EMPTY,这是一个立即完成而不发出任何值的 Observable。 -
我们有一个名为
getNewWebSocket()的私有方法,它调用webSocket工厂函数,传入一个名为WS_ENDPOINT的常量作为输入,并返回WebSocketSubject。WS_ENDPOINT代表在src/environments/environment.ts文件中定义的 WebSocket 服务器端点,作为wsEndpoint。请注意,URL 是特定于环境的配置,这意味着它们可以从一个环境更改为另一个环境(例如,开发、测试和生产)。在environment.ts文件中定义端点 URL 是 Angular 应用程序中的常见做法,因为它提供了一个集中位置来处理特定于环境的配置设置,因此您可以轻松地在环境之间切换,而无需修改应用程序代码。 -
我们有一个公共方法
sendMessage(),它将作为输入发送的消息发送到套接字,该套接字将消息转发到服务器。 -
最后,我们有一个公共方法
close(),它通过完成主题来关闭连接。
然后,我们将添加 connect() 方法,该方法将以响应式的方式监听传入的消息,并将消息作为如下所示发送给订阅者:
public connect(): void {
if (!this.socket$ || this.socket$.closed) {
this.socket$ = this.getNewWebSocket();
const messages = this.socket$.pipe(
tap({
error: error => console.log(error),
}), catchError(_ => EMPTY));
this.messagesSubject$.next(messages);
}
}
让我们分解这个方法中正在发生的事情。如果 socket$ 未定义(尚未创建)或已关闭,则 socket$ 将由 getNewWebSocket 方法产生的新 WebSocketSubject 填充。
然后,我们将组合 tap 和 catchError 操作符;tap 操作符用于在发生错误或连接关闭时记录消息,而 catchError 操作符处理错误并返回一个空的可观察对象。
管道操作返回的可观察对象将被存储在一个名为 messages 的常量中。messagesSubject$ 可观察对象将发出消息的可观察对象(因此,它是一个可观察对象的可观察对象):
之后,我们将通过在 RealTimeService 中定义的 messages$ 公共可观察对象提供 messagesSubject$ 可观察对象的只读副本,如下所示:
public messages$ = this.messagesSubject$.pipe(
switchAll(), catchError(e => { throw e }));
我们使用了 SwitchAll 操作符来展平可观察对象的可观察对象,我们将订阅 messages$ 在每个需要消费实时更新的组件中。我们为什么要这样做?想法是保护 Subject$ 和传入的消息免受任何外部更新的影响,并将消息作为只读形式暴露给消费者。这样,任何对消费实时消息感兴趣的组件都必须订阅 messages$,而与套接字相关的所有逻辑都将在这个服务中私下处理。
第二步 – 触发连接
在放置服务之后,我们应该调用 connect 方法。由于我们希望 connect 方法只触发一次,我们将在注入 RealTimeService 之后从根组件 src/app/app.component.ts 中调用它。以下是需要添加的代码:
constructor(private service: RealTimeService ) {
this.service.connect();
}
第三步 – 定义发出实时更新的可观察对象
接下来,我们应该在适当的 Angular 组件中调用 messages$ 可观察对象。由于我们希望用最新的食谱更新列表,我们应该在 RecipesListComponent 中定义可观察对象。
但是等等!我们已经在 RecipesListComponent 中有一个名为 recipes$ 的 Observable,它从 RecipesService 获取食谱列表:
recipes$=this.service.recipes$;
我们能否使用这个现有的 Observable 而不是创建一个新的?当然可以!
我们的目标是首先显示 recipes$ 发射的食谱列表,然后无缝地结合 messages$ 发射的任何新添加的食谱。我们可以使用 RxJS 中的 combineLatest 操作符来实现这一点。
combineLatest 操作符将多个 Observables 的最新值合并到一个数组中,并在任何源 Observable 发射值时发射一个新的数组。通过利用这个操作符,我们可以将 recipes$ 和 messages$ 结合如下:
recipes$=combineLatest([this.service.recipes$,
this.realTimeservice.messages$]).pipe(map(([recipes,
updatedRecipes]) => {
// Merge or concatenate the two arrays into a single
array
return [...recipes, ...updatedRecipes];
}));
在代码中,我们结合了 recipes$ 和 messages$,然后使用 map 操作符提取每个发射的最新值。然后我们将这些值合并到一个数组中,然后返回。这确保了 recipes$ 一致地发射包含所有食谱的统一数组。
使用 scan 操作符防止数据丢失
现在,让我们快速考虑一个场景,即一个 ID 为 12 的食谱最初被推送到并添加到食谱列表中。如果之后从服务器推送另一个 ID 为 14 的食谱,那么最新的推送食谱(ID 14)将覆盖之前的(ID 12)。因此,ID 12 的食谱将会丢失。为了防止这种数据丢失,我们可以使用 scan 操作符。
RxJS 中的 scan 操作符类似于 JavaScript 中的 reduce 函数。它对 Observable 序列应用一个累加函数,并返回每个中间结果,每次源 Observable 发射新值时都会发射累加的值。用更简单的术语来说,它持续地对源 Observable 发射的每个值应用一个函数,随着时间的推移积累这些值,并发射中间结果。这个操作符对于维护状态、累加值或对 Observable 流执行任何类型的带状态转换非常有用。
因此,在我们的情况下,我们可以如下使用 scan 操作符:
recipes$ = combineLatest([
this.service.recipes$,
this.realTimeService.messages$
]).pipe(
scan((acc: Recipe[], [recipes, updatedRecipes]:
[Recipe[], Recipe[]]) => {
// Merge or concatenate the two arrays into a single
array
return acc.length === 0 &&
updatedRecipes.length === 0 ? recipes : [...acc,
...updatedRecipes,];
}, [])
);
在这种情况下,scan 确保所有发射的食谱,包括从 this.service.recipes$ 流中获取的初始食谱和从 this.realTimeService.messages$ 收到的任何后续更新,都被累积到一个数组中。这防止了如果使用简单的映射操作可能发生的数据丢失。因此,recipes$ Observable 流包含了一个全面且最新的食谱列表,反映了其整个生命周期中从两个来源的所有变化。
第四步 - 订阅发射实时更新的 Observable
最后,我们只需在我们的组件模板中使用异步管道订阅 recipes$ Observable,这在 recipes-list.component.html 中已经完成:
@if ( recipes$ | async; as recipes) {
....
}
然而,我们还有一个需要考虑的调整!既然我们已经确定messages$在recipes$发出后的 5 秒延迟后发出,就有一个小问题:combineLatest只在两个 Observables 都发出值时才发出。
为了在等待messages$发出时绕过这段短暂的延迟,在RealTimeService中,我们可以在messages$主题上使用startWith()运算符来提供一个空数组的初始值,如下所示:
public messages$ =
this.messagesSubject$.pipe(switchAll(), startWith([]),
catchError(e => { throw e }));
执行此代码后,您会注意到在显示 11 个菜谱后的 5 秒内,ID 为12的菜谱(辣子鸡)将被添加到我们卡片列表的第二页上。如果之后推送另一个菜谱,它将被累积到当前的菜谱列表中。
注意,在 UI 频繁更新的情况下,强烈建议将更改检测策略设置为onPush以优化性能,如下所示:
@Component({
selector: 'app-recipes-list',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush
})
就这样!您将能够使用这种模式以响应式的方式消费实时更新。
到目前为止,您可能想知道如何处理重新连接。当服务器重启或连接因任何原因崩溃时,这个主题会在幕后恢复丢失的连接吗?
答案是WebSocketSubject。
然而,您可以使用 RxJS 轻松地在您的 Web 应用程序中实现这一点。让我们在下一节中学习您如何做到这一点。
学习处理重新连接的响应式模式
当 WebSocket 服务器的连接丢失时,通道将被关闭,WebSocketSubjet将不再发出值。在实时世界的预期行为中,这不是预期的行为。在大多数情况下,重新连接功能是必需的。
因此,让我们假设,例如,在断开连接后,系统每 3 秒尝试重新连接一次。在这种情况下,解决方案是拦截 socket 的关闭并重试连接。我们如何拦截连接的关闭?
这一切都要归功于WebSocketSubjectConfig,它负责自定义 socket 生命周期中的某些行为。RxJS 中的WebSocketSubjectConfig接口提供了几个属性,您可以使用这些属性来配置 WebSocketSubject。这些属性允许您自定义 WebSocket 通信的各个方面:
export interface WebSocketSubjectConfig<T> {
url: string;
protocol?: string | Array<string>;
/** @deprecated Will be removed in v8\. Use {@link
deserializer} instead. */
resultSelector?: (e: MessageEvent) => T;
openObserver?: NextObserver<Event>;
serializer?: (value: T) => WebSocketMessage;
deserializer?: (e: MessageEvent) => T;
closeObserver?: NextObserver<CloseEvent>;
closingObserver?: NextObserver<void>;
WebSocketCtor?: { new(url: string,
protocols?:string|string[]): WebSocket };
binaryType?: 'blob' | 'arraybuffer';
}
让我们解释WebSocketSubjectConfig中可用的不同属性:
-
url:此属性指定要连接的 WebSocket 端点的 URL(我们已经在本章中解释并使用了这个属性)。 -
protocol:此属性指定 WebSocket 握手期间要使用的子协议(参见图 12**.1**)。它可以是单个字符串或表示子协议的字符串数组。 -
resultSelector:此属性指定一个函数,该函数接受 WebSocket 事件作为输入,并返回由WebSocketSubject发出的值。它通常用于从 WebSocket 事件中提取特定数据;然而,它已被弃用,将在 RxJS 的版本 8 中删除。 -
closeObserver: 这个属性指定了一个监听 WebSocket 连接关闭的观察者对象。它可以用来处理清理任务或在连接关闭时执行操作。 -
openObserver: 这个属性指定了一个监听 WebSocket 连接打开的观察者对象。它可以用来在连接成功建立时执行操作。 -
binaryType: 这个属性指定了 WebSocket 消息的二进制类型。它可以是 JavaScript 类型blob或arraybuffer之一。默认情况下,它设置为blob。 -
serializer: 这个属性指定了一个用于在通过 WebSocket 连接发送之前序列化输出消息的函数。它通常用于将对象或复杂的数据结构转换为字符串。 -
deserializer: 这个属性指定了一个用于在 WebSocket 连接上接收到的消息进行反序列化的函数。它通常用于将接收到的字符串解析回对象或其他数据类型。
这些属性提供了对 RxJS 中 WebSocket 通信的灵活性和控制力。您可以根据具体需求自定义它们,以优化应用程序中的 WebSocket 交互。
注意
每个属性的完整描述都可以在官方文档链接中找到:bit.ly/RxJS-WebSocket。
为了从WebSocketSubjectConfig中受益,你应该调用webSocket工厂函数,它接受第二种类型的参数。以下代码使用WebSocketSubjectConfig创建WebSocketSubject,并简单地拦截关闭事件以显示自定义消息:
private getNewWebSocket() {
return webSocket({
url: WS_ENDPOINT,
closeObserver: {
next: () => {
console.log('[RealTimeService]: connection
closed');
}
},
});
}
现在我们知道了如何拦截连接的关闭,让我们学习如何重试重新连接。我们可以使用retryWhen操作符结合delayWhen操作符来设置两次连续连接之间的延迟,以在Observable完成之后有条件地重新订阅。
因此,让我们创建一个函数,该函数将尝试为每个可配置的RECONNECT_INTERVAL重新连接到给定的可观察对象;我们将在每次重新连接尝试时在浏览器控制台记录日志:
private reconnect(observable: Observable< Recipe[] >):
Observable< Recipe[] > {
return observable.pipe(retryWhen(errors =>
errors.pipe(
tap(val => console.log('[Data Service]
Try to reconnect', val)),
delayWhen(_ => timer(RECONNECT_INTERVAL)))));
}
这个reconnect函数将被用作 RxJS 自定义操作符,在RealTimeService的connect()方法中处理套接字关闭后的重新连接,如下所示:
public connect(cfg: { reconnect: boolean } = { reconnect: false }): void {
if (!this.socket$ || this.socket$.closed) {
this.socket$ = this.getNewWebSocket();
const messages = this.socket$.pipe(cfg.reconnect ?
this.reconnect : o => o,
tap({
error: error => console.log(error),
}), catchError(_ => EMPTY))
this.messagesSubject$.next(messages);
}
}
如您所见,connect函数中添加了一个新的布尔参数reconnect,用于区分重新连接和第一次连接。这优化了代码,避免了添加额外的函数。
然后,您只需在拦截连接关闭时调用connect函数并传递reconnect: true即可:
private getNewWebSocket() {
return webSocket({
url: WS_ENDPOINT,
closeObserver: {
next: () => {
console.log('[DataService]: connection
closed');
this.socket$ = undefined;
this.connect({ reconnect: true });
}
},
});
以这种方式,在连接关闭后,您将看到客户端每 3 秒尝试连接服务器的许多输出请求。
在实时世界的世界中,重连能力是必不可少的。这就是我们如何使用 RxJS 在几行代码中处理它的。许多开发者不知道 RxJS 提供了这个功能,它使你能够消费来自 WebSocket 的实时消息,并添加许多第三方库来处理这个需求,而且它也是现成的。因此,在这种情况下选择 RxJS,就少了一个依赖!
摘要
在本章中,我们深入探讨了以响应式方式从 WebSocket 服务器消费实时消息的实践演示。我们首先概述了需求并提供了实现背景。随后,我们探讨了 WebSocketSubject 的功能,并详细描述了从建立连接到处理来自套接字的传入消息的逐步过程。接下来,我们将这些概念应用于食谱应用中的实际场景,从而获得了实现实时功能并确保稳健连接控制的最佳实践见解。
最后,我们通过在响应式方式中引入重连机制,利用 WebSocketSubjectConfig 和 RxJS 运算符实现了无缝的连接管理,从而扩展了我们的理解。
现在,随着我们接近本书的最后一章,让我们转换思路,专注于测试可观察对象。
第五部分:最终润色
在这部分,你将了解测试响应式流的多种策略。我们将探讨它们的优点以及何时使用每种策略,并通过实际示例巩固你的学习。
本部分包括以下章节:
- 第十三章,测试 RxJS 可观察对象
第十三章:测试 RxJS 可观察者
可观察者在管理异步数据流和事件驱动交互中扮演着核心角色。通过彻底测试可观察者,开发者可以验证其异步代码的正确性,预测并处理各种边缘情况,并确保在不同环境和用例中的一致行为。
对可观察者的全面测试不仅增强了应用程序的健壮性,还提高了代码质量,减少了错误和回归的可能性,并最终提升了整体用户体验。有了严格的测试实践,开发者可以自信地部署符合高标准的可靠性、性能和可用性的响应式应用程序。
许多开发者认为测试可观察者是一项具有挑战性的任务。这是真的。然而,如果您掌握了正确的技术,您就可以以非常有效的方式实现可维护和可读的测试。
在本章中,我们将向您介绍三种常用的测试流模式。我们将首先解释订阅和断言模式,然后讨论弹珠测试模式。最后,我们将通过关注我们的食谱应用中的具体示例,突出一种适合测试由HTTPClient返回的流的模式。
在本章中,我们将涵盖以下主要内容:
-
了解订阅和断言模式
-
了解弹珠测试模式
-
使用
HTTPClientTestingModule突出显示测试流
技术要求
本章假设您对 RxJS 和 Angular 中使用 Jasmine 进行单元测试有基本的了解。有关更多信息,请点击此链接:angular.dev/guide/testing#set-up-testing。
注意
angular.dev将成为 Angular 开发者的新文档网站;它提供了更新的功能和文档。angular.io将在未来的版本中弃用。
我们将在 Angular 环境中测试可观察者。本章的源代码可在github.com/PacktPublishing/Reactive-Patterns-with-RxJS-for-Angular-16-2nd-Edition/tree/main/Chap13找到。
我们将完成对saveRecipe方法的单元测试,该方法位于RecipesService类下。您可以在recipes.service.spec文件中找到完整的代码。
了解订阅和断言模式
如您所知,可观察者是懒惰的,我们只有在订阅它们之后才能获得任何值。在测试中,情况也是如此;可观察者不会发出任何值,直到我们订阅它们。为了解决这个问题,程序员总是倾向于在测试中手动订阅可观察者,然后对发出的值进行断言。这就是我们所说的订阅和断言模式。
让我们深入探讨使用订阅和断言模式在三个不同场景下的测试。我们将演示测试返回单个值的方法、返回多个值的方法以及返回定时值的方法(在指定时间后返回的值)。
测试单值输出方法
假设我们必须测试一个返回单个值的方法。该方法名为 getValue(value: boolean),并在名为 SampleService 的 Angular 服务中可用。
该方法本身非常简单,返回一个 Observable,将按照以下方式发出布尔输入值:
import { Observable, of } from 'rxjs';
export class SampleService {
getValue(value: boolean): Observable<boolean> {
return of(value);
}
}
该方法的测试看起来是这样的:
describe('SampleService', () => {
let service: SampleService;
beforeEach(() => {
service = TestBed.inject(SampleService);
});
it('should return true as a value', () => {
service.getValue(true).subscribe(
result=>expect(result).toEqual(true))
});
});
在这里,我们首先使用 Jasmine 的 describe() 函数定义我们的测试套件。该函数用于定义一个测试套件,它是一组逻辑上相关的测试用例,用于执行单个任务的不同测试场景。它作为组织和管理测试的方式,使测试更加可读和可维护。
describe() 函数接受两个参数:
-
测试套件的字符串描述(在前面的代码片段中,
SampleService是我们要测试的服务名称,它指的是测试套件的描述)。 -
包含该套件测试用例的函数(在测试框架中,“测试用例”和“规格”通常指测试套件内的单个测试单元)。在这个函数中,我们将
SampleService注入到beforeEach语句中,以提供我们将要在所有测试用例中使用的服务的共享实例。最后,我们使用 Jasmine 的it()函数定义我们的getValue(value: boolean)方法的测试用例。it()函数接受两个参数:-
测试用例的字符串描述(在前面的代码片段中,
should return true as a value指的是测试用例的描述)。 -
包含测试逻辑的函数。在这个函数中,我们订阅
getValue(true)方法,并期望结果等于true,因为我们传递了true作为输入值。期望是通过使用 Jasmine 的expect()函数构建的,并在测试执行期间用于断言或验证是否满足某些条件。
-
现在,让我们运行 ng test;测试通过,一切正常:
图 13.1 – ng 测试输出
非常简单,对吧?这是运行正面场景时的预期行为。正面场景通常涉及提供与正在测试的代码预期行为一致的输入或条件,从而在没有错误或失败的情况下成功执行。
现在,让我们通过提供旨在触发正在测试的代码中失败的输入或条件来处理一个负面场景。为此,我们将以下断言中的 true 替换为 false:
it('should return true as a value', () => {
service.getValue(true).subscribe(
result=>expect(result).toEqual(false))
});
当你再次运行 ng test 时,我们的测试将失败。
然而,在某些情况下,测试仍然会通过。这是怎么可能的?
测试中的预期问题是,如果你有一个未满足的断言,它会抛出一个错误。此外,如果你在 RxJS 订阅中有一个未处理的错误,它将在另一个调用堆栈上抛出,这意味着它是异步抛出的。因此,使用订阅和断言模式的测试有时可能会显示为绿色,尽管实际上它们是失败的。
为了克服这个问题,我们应该向测试函数传递一个done回调,并在测试完成后的预期时间手动调用它,如下所示:
it('should return true as a value', (done) => {
service.getValue(true).subscribe(
result => {
expect(result).toEqual(false);
done();
}
);
});
done回调是在异步测试中用于向测试框架发出测试用例完成信号的一种机制。它被许多测试框架支持,如 Jasmine、Jest 和 Mocha。调用done回调确保在所有异步任务执行和断言验证之前,测试不会提前完成。因此,我们防止了假阳性,并确保我们的测试准确地反映了被测试代码的行为,尤其是在异步场景中。所以,不要忘记在异步场景中调用done回调!
现在,让我们考虑一个更复杂的方法,该方法将返回多个值而不是一个值。
测试多值输出方法
让我们考虑一个名为getValues的方法,它将返回多个值,如下所示:
export class SampleService {
getValues(): Observable<String> {
return of('Hello', 'Packt', 'Readers');
}
}
值将按照上述顺序逐个发出。
当使用断言和订阅模式时,测试将看起来像这样:
it('should return values in the right order', (done) => {
const expectedValues = ['Hello', 'Packt', 'Readers'];
let index = 0;
service.getValues().subscribe(result => {
expect(result).toBe(expectedValues[index]);
index++;
if (index === expectedValues.length) {
done();
}
});
});
在前面的代码中,我们创建了一个数组,表示预期的值顺序;然后,我们订阅了getValues方法,并使用计数器(expectedValues[index])将发出的值与预期值进行比较。完成后,我们调用了done()回调。
然而,我们可以使用 RxJS 的toArray操作符而不是计数器,它将发出的值放入一个数组中,然后比较得到的数组与我们定义的预期数组:
it('should return values in the right order', (done) => {
const expectedValues = ['Hello', 'Packt', 'Readers'];
service.getValues().pipe(toArray()).subscribe(result => {
expect(result).toEqual(expectedValues);
done();
});
});
好吧,这工作得很好,ng test将会通过。然而,在这两种情况下,尽管我们处理的是一个简单的流,但我们被迫添加一些逻辑;在第一个例子中,我们添加了一个计数器,而在第二个例子中,我们使用了toArray操作符。这简化了测试并添加了一些不必要的测试逻辑;这些都是订阅和断言模式的最显著缺点。
现在,让我们转向一个不同的例子,并探索输出定时值的测试方法。
测试定时值输出方法
让我们更新getValues()方法,并添加一个计时器,在特定持续时间后返回值,如下所示:
getValues(): Observable<String> {
return timer(0, 5000).pipe(
take(3),
switchMap((result) => of('Hello', 'Packt',
'Readers'))
)
这里,我们在该方法中使用了 RxJS 的timer,每 5 秒发出一个值。由于timer产生一个无止境的流,我们调用take操作符来返回前三个发出值并完成它们。然后,对于每个发出值,我们使用switchMap操作符返回一个连续发出三个值的 Observable。
这很棘手,对吧?如果我们在这里使用 subscribe 和 assert 模式,测试将会非常复杂,并且可能需要很多时间,这取决于传递给timer的值。然而,单元测试应该是快速且可靠的。
在这种情况下,拥有一个虚拟计时器可能会有所帮助。虚拟计时器指的是由测试框架控制的模拟时间流逝。我们不需要等待实际时间的流逝,这可能导致测试缓慢且不可靠,虚拟计时器允许测试人员以编程方式控制时间。这意味着他们可以根据需要向前或向后推进时间,以触发某些事件或测试场景,这使得为依赖于基于时间的行为的代码编写可靠且确定性的测试变得更容易。这种方法确保测试快速、可预测,并且独立于实时条件。
简而言之,subscribe 和 assert 模式是一种有效且易于采用的技术,大多数开发者都会采用。然而,它也有一些我在本节中指出的缺点:
-
我们需要记住在异步测试中调用
done回调;否则,测试将返回无效的结果。 -
在某些情况下,我们可能会遇到过度的测试和不需要的测试逻辑。
-
定时观察者非常难以测试。
现在,让我们探索另一种测试可观察者的方法:使用 RxJS 测试工具的弹珠测试。
了解弹珠测试模式
弹珠图对于可视化可观察者执行非常有用。您已经知道了这一点,因为我们早在第一章中介绍了弹珠图,深入响应式范式,并且我们在本书中实现的几乎所有响应式模式中都用到了它们。它们易于理解,阅读起来令人愉悦。那么,为什么不在代码中也使用它们呢?您可能会惊讶地知道,RxJS 引入了弹珠测试作为一种直观且干净的测试可观察者的方式。
让我们首先解释下一节中的语法,然后学习我们如何在代码中编写弹珠测试。
理解语法
要理解语法,我们应该了解以下语义:
| 字符 | 含义 |
|---|---|
' ' | 这代表一个特殊字符,它不会被解释。它可以用来对齐您的弹珠字符串。 |
'-' | 这代表虚拟时间的流逝帧。 |
'|' | 这代表了一个可观察对象的完成。 |
[``a-z] | 这代表由可观察者发出的值。它是一个字母数字字符。 |
'#' | 这代表了一个错误。 |
'()' | 这代表在同一帧中发生的事件组。它可以用来组合任何发出的值、错误和完成。 |
'^' | 这代表订阅点,并且仅在您处理热可观察者时使用。 |
[``0-9]+[ms|s|m] | 这代表时间进度,并允许你通过特定数量推进虚拟时间。它是一个数字,后面跟着一个时间单位,以 毫秒(ms)、秒(s)或 分钟(m)表示,它们之间没有空格。 |
图 13.2 – 宝石测试语法
这是基本语法。让我们看看一些例子来练习语法:
-
---: 这代表一个永远不会发出的可观察对象。 -
-x--y--z|: 这代表一个在第一帧发出x,在第四帧发出y,在第七帧发出z的可观察对象。在发出z之后,可观察对象完成。 -
--xy--#: 这代表一个可观察对象,在第二帧发出x,在第三帧发出y,并在第六帧发出错误。 -
-x^(yz)--|: 这是一个在订阅之前发出x的热可观察对象。
你已经明白了,现在让我们学习如何在我们的代码中实现宝石测试。
介绍 TestScheduler
有不同的包可以帮助你编写宝石测试,包括 jasmine-marbles、jest-marbles 和 rxjs-marbles。然而,RxJS 提供了开箱即用的测试实用工具,所有库都是围绕 RxJS 测试实用工具的包装。我建议使用 RxJS 实用工具,以下是一些原因:
-
你不需要包含第三方依赖项
-
你可以保持核心实现的最新状态
-
你可以保持对最新功能的了解
提供的 RxJS API 用于测试是基于 TestScheduler 的。这个 API 允许你以可控和确定性的方式测试基于时间的 RxJS 代码,这对于编写基于时间操作符的可靠和可预测的可观察对象测试至关重要。
要定义我们的测试逻辑,TestScheduler API 提供了一个具有以下签名的 run 方法:
run<T>(callback: (helpers: RunHelpers) => T): T;
run 方法接受一个 callback 函数作为参数。这个 callback 函数是你定义测试逻辑的地方,包括设置可观察对象、定义期望和进行断言。callback 函数接受一个名为 helpers 的参数,其类型为 RunHelpers,它提供了各种实用函数和属性,以帮助你编写可观察对象的宝石测试。
RunHelpers 接口包含以下属性:
export interface RunHelpers {
cold: typeof TestScheduler.prototype.
createColdObservable;
hot: typeof TestScheduler.prototype.
createHotObservable;
flush: typeof TestScheduler.prototype.flush;
expectObservable: typeof TestScheduler.
prototype.expectObservable;
expectSubscriptions: typeof TestScheduler.
prototype.expectSubscriptions;
}
让我们逐一查看这些属性:
-
cold: 这根据给定的宝石图产生一个冷可观察对象。以下是该方法的签名:/** * @param marbles A diagram in the marble DSL. Letters map to keys in `values` if provided. * @param values Values to use for the letters in `marbles`. If ommitted, the letters themselves are used. * @param error The error to use for the `#` marble (if present). */ createColdObservable<T = string>(marbles: string, values?: { [marble: string]: T; }, error?: any): ColdObservable<T>; -
hot: 这根据给定的宝石图产生一个热可观察对象。以下是该方法的签名:/** * @param marbles A diagram in the marble DSL. Letters map to keys in `values` if provided. * @param values Values to use for the letters in `marbles`. If ommitted, the letters themselves are used. * @param error The error to use for the `#` marble (if present). */ createHotObservable<T = string>(marbles: string, values?: { [marble: string]: T; }, error?: any): HotObservable<T>;当你创建一个热可观察对象时,可以使用
^来指出第一帧: -
flush: 这开始虚拟时间。只有在你在run回调之外使用辅助工具或想要多次使用flush时才需要。 -
expectObservable: 这断言一个可观察对象与宝石图匹配。 -
expectSubscriptions: 这断言一个可观察对象与预期的订阅匹配。
现在,让我们学习如何使用 TestScheduler 在下一节中实现弹珠测试。
实现弹珠测试
在本节中,我们将考虑实现之前在订阅和断言模式中提到的 getValues 方法的弹珠测试:
export class SampleService {
getValues(): Observable<String> {
return of('Hello', 'Packt', 'Readers');
}
}
编写弹珠测试实现模式的步骤很简单:
-
从
rxjs/testing中导入TestScheduler:import { TestScheduler } from 'rxjs/testing'; -
在
beforeEach语句中,注入SampleService。然后,实例化TestScheduler并传递一个输入函数,该函数比较实际输出与 Observable 的预期输出:import { TestScheduler } from 'rxjs/testing'; describe('Service: SampleService', () => { let scheduler : TestScheduler; let service: SampleService; beforeEach(() => { service = TestBed.inject(SampleService); scheduler = new TestScheduler((actual, expected) => { expect(actual).toEqual(expected); }); }); });如果预期输出和实际输出不相等,它会抛出一个错误,导致测试失败。
-
使用
TestScheduler通过调用run方法并传递一个回调来测试你的流(记住,回调需要接受RunHelpers作为第一个参数):it('should return values in the right order', () => { scheduler.run((helpers) => { }); });将辅助函数解构到变量中并直接使用它们来实现弹珠测试也是有用的。我们将解构
expectObservable变量,因为我们将会使用它来断言 Observable 是否与弹珠图匹配,如下所示:it('should return values in the right order', () => { scheduler.run(({expectObservable}) => { }); }); -
最后,声明预期的弹珠和值,并执行期望:
it('should return values in the right order', () => { scheduler.run(({expectObservable}) => { const expectedMarble = '(abc|)' ; const expectedValues = {a:'Hello', b:'Packt', c:'Readers'}; expectObservable(service.getValues()).toBe( expectedMarble, expectedValues) }); });expectedMarble常量代表弹珠图。由于getValues方法连续返回三个值,我们使用了括号来分组a、b和c的发射。然后流完成,所以我们使用|字符。expectedValues常量代表我们放入expectedMarble中的a、b和c字符的值。它代表'Hello'、'Packt'和'Readers',连续的,这不过是我们要测试的 Observable 发射的值。最后一条指令是期望;我们应该提供我们的方法应该返回的预期结果。在这里,我们必须使用
expectObservable,它接受我们想要测试的 Observable 作为参数,并将其与expectedMarble和expectedValues匹配。
就这些。让我们看看完整的测试设置:
describe('SampleService marble tests', () => {
let scheduler : TestScheduler ;
let service: SampleService;
beforeEach(() => {
service = TestBed.inject(SampleService);
scheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
});
it('should return values in the right order', () => {
scheduler.run(({expectObservable}) => {
const expectedMarble = '(abc|)' ;
const expectedValues = {a:'Hello', b:'Packt',
c:'Readers'};
expectObservable(service.getValues()).toBe(
expectedMarble, expectedValues)
});
});
});
当你运行 ng test 时,这个测试将会通过。如果你在 expectedValues 中输入错误值,测试将会失败:
图 13.3 – ng test 失败
好吧,这比订阅和断言模式实现要干净。
现在,让我们看一个更复杂的例子,看看我们如何使用弹珠测试来实现它。
测试定时值输出方法
我们将考虑测试一个使用订阅和断言模式实现起来复杂的定时 Observable。让我们回顾一下我们在订阅和断言模式部分中解释过的定时器示例:
getValues(): Observable<String> {
return timer(0, 5000).pipe(
take(3),
switchMap((result) => of('Hello', 'Packt',
'Readers'))
)
}
在这里能帮到我们的酷炫功能是虚拟时间;这允许我们通过虚拟化时间来同步测试异步流,并确保正确的时间发出正确的内容。多亏了时间进度语法,我们可以以毫秒(ms)、秒(s)甚至分钟(m)为单位推进虚拟时间。这在测试定时 Observables 的情况下非常有用。
让我们考虑以下大理石图:
e 999ms (fg) 996ms h 999ms (i|)';
在这里,图表明e立即发出。然后,1 秒后,f和g发出。然后,1 秒后,h发出,之后I发出,流最终完成。
为什么使用999和996?嗯,我们使用999是因为e发出需要 1 毫秒,而996是因为(fg)组中的字符每个需要 1 毫秒。
考虑到所有这些,getValues的大理石测试将看起来像这样:
const expectedMarble ='(abc) 4995ms (abc) 4995ms
(abc|)' ;
值组(abc)每 5 秒或 5000 毫秒发出一次,由于字符在组内计数,所以我们放置4995ms。因此,整个测试用例将看起来像这样:
it('should return values in the right time', () => {
scheduler.run(({expectObservable}) => {
const expectedMarble ='(abc) 4995ms (abc) 4995ms (abc|)';
const expectedValues = {a:'Hello', b:'Packt',
c:'Readers'};
expectObservable(service.getValues()).toBe(
expectedMarble, expectedValues)
});
});
这就是我们如何通过大理石测试解决了定时 Observables 的测试。
大理石测试非常强大且有用。它允许您测试非常详细和复杂的事情,如并发和定时 Observables。它还使您的测试更加简洁。然而,它要求您学习一种新的语法,并且不建议用于测试业务逻辑。大理石测试是为测试具有任意时间的操作符而设计的。
注意
关于大理石测试的更多细节,您可以在rxjs.dev/guide/testing/marble-testing的官方文档中查看。
现在,让我们突出一个用于测试业务逻辑的非常常见的模式。
使用 HttpClientTestingModule 突出测试流
从 HTTP 客户端返回的 Observables 在我们的 Angular 代码中经常被使用,但我们是怎样测试这些流的呢?让我们看看我们可以用来测试这些 Observables 的模式。我们将把我们的重点从一般测试实践转移到具体测试我们的食谱应用。
考虑以下RecipeService中的方法:
saveRecipe(formValue: Recipe): Observable<Recipe> {
return this.http.post<Recipe>(
`${BASE_PATH}/recipes`, formValue);
}
saveRecipe方法发出一个 HTTP 请求并返回一个食谱的 Observable。为了测试输出 Observable,有一个非常有用的 API 可以用来:HttpClientTestingModule。此 API 允许我们测试使用 HTTP 客户端的 HTTP 方法。它还允许我们通过提供HttpTestingController服务来轻松模拟 HTTP 请求。简而言之,它使我们能够在测试时模拟请求,而不是向我们的 API 后端发出真实的 API 请求。
让我们看看使用HttpClientTestingModule测试saveRecipe方法所需的步骤:
-
在您可以使用
HttpClientTestingModule之前,请在beforeEach语句中导入并注入它到您的TestBed中,如下所示:import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule} from '@angular/common/http/testing'; describe('RecipesService', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], }); }); }); -
然后,导入并注入
HttpTestingController和RecipesService,并为每个测试提供一个共享实例:import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { RecipesService } from './recipes.service'; describe('RecipesService', () => { let service: RecipesService; let httpTestingController: HttpTestingController; beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [RecipesService] }); httpTestingController = TestBed.inject(HttpTestingController) service = TestBed.inject(RecipesService) }); }); -
接下来,实现保存菜谱的测试用例。我们将按照以下方式模拟
saveRecipe:it('should save recipe from API', () => { const recipeToSave : Recipe= { "id": 9, "title": "Lemon cake", "prepTime": 10, "cookingTime": 35, "rating": 3, "imageUrl": "lemon-cake.jpg" } const subscription = service.saveRecipe(recipeToSave) .subscribe(_recipe => { expect(recipeToSave).toEqual(_recipe, 'should check mock data') }); const req = httpTestingController.expectOne( `/api/recipes`); req.flush(recipeToSave); subscription.unsubscribe(); });在这里,我们创建了一个名为
recipeToSave的常量,它代表我们将要发送到服务器保存的模拟菜谱。然后,我们订阅了saveRecipe方法,并将recipeToSave作为参数传递给它。在订阅内部,我们定义了我们的期望。然后,我们调用了expectOne方法,它期望一个已发送到匹配给定 URL(在我们的情况下,是/api/recipes)的单个请求,并使用flush方法返回模拟数据,该方法通过返回模拟体来解析请求。最后,我们释放了订阅。 -
最后一步是添加一个
afterEach()块,在其中运行我们控制器的verify方法:afterEach(() => { httpTestingController.verify(); });verify()方法确保没有未处理的 HTTP 请求未被处理或刷新。当您在测试中使用HttpClientTestingModule发送 HTTP 请求时,它们会被httpTestingController截获,而不是通过网络发送。verify()方法确保所有请求都得到了适当的处理,并且只有当没有挂起的请求时,测试才能通过。总结来说,在 Angular 测试中使用
afterEach()块和httpTestingController.verify()来清理并验证在每个测试用例之后没有未处理的 HTTP 请求留下。这有助于确保您的测试是隔离和可靠的,没有意外的网络交互。
就这样,测试发出 HTTP 请求的方法的模式就完成了。只需运行 ng test 命令并确保一切正常。
注意
HttpClientTestingModule 在这个用例中非常有用。有关更多详细信息,请参阅 angular.dev/guide/testing/services#httpclienttestingmodule。
摘要
在本章中,我阐述了在 RxJS 和 Angular 中测试可观察对象的三种常见方法。每种解决方案都有其优点和缺点,并没有一种适合所有情况的答案。
首先,我们学习了订阅和断言模式,以及它的优点和缺点。这种模式易于理解,但可能无法涵盖所有边缘情况,尤其是在处理复杂的异步行为时。
然后,我们学习了宝石测试模式及其语法、特性、优点和缺点。我们研究了一个基本示例和一个使用虚拟时间来测试定时可观察对象的示例。宝石测试提供了可观察对象行为的可视化表示;它适用于测试复杂的异步场景。然而,它需要特殊的语法,这意味着对于初学者来说可能有一个陡峭的学习曲线。
最后,我们了解了一种可以用来测试从 HTTP 客户端返回的流的模式。这个模式可以控制响应,并且不依赖于外部 API。然而,设置和维护它可能很繁琐,并且在某些情况下可能无法准确模拟现实世界的网络行为。
总之,每种测试方法都提供了其优势和权衡。根据您的项目需求,您可以选择最适合您测试需求和项目约束的解决方案。
到目前为止,我们对反应式模式的探索即将结束。在这本书中,我试图突出最常用的反应式模式,这些模式解决了许多在 Web 应用程序中反复出现的用例。您可以直接在当前项目中使用它们,根据您的需求进行修改,或者从中获得灵感来创建您自己的反应式模式。
尽管这本书不仅仅关于模式;它还涉及反应式方法以及如何将你的思维方式从命令式转变为反应式思考;在大多数章节中,这就是为什么我在反应式模式之前先强调经典模式,以便您在这两种模式之间有一个平滑的过渡。
就这样,我们的共同之旅到达了终点。感谢您阅读并与我一起踏上这场反应式冒险之旅!