PHP-反应式编程-全-

PHP 反应式编程(全)

原文:zh.annas-archive.org/md5/a1551220cbc6d68c712d2aa7b9a0096e

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

近年来,响应式编程获得了显著的人气。这得益于 JavaScript Web 框架,如 Angular2 或 React,同时也因为支持多种编程范式的语言(如 JavaScript、Java、Python 或 PHP)中函数式和异步编程的日益流行。

现在,响应式编程与响应式扩展(也称为 ReactiveX 或简称 Rx)紧密相关;这是利用响应式编程最流行的库。值得注意的是,RxJS 5,Rx 的 JavaScript 实现,可能是许多开发者第一次接触响应式编程。在这本书中,我们将主要关注使用 Rx 的 PHP 端口,称为 RxPHP (github.com/ReactiveX/RxPHP)。

异步编程不是 PHP 开发者通常要处理的内容。实际上,它有点像一片未知的领域,因为在 PHP 中关于这个主题的资源并不多。由于响应式编程与异步编程紧密相连,我们将大量使用事件循环、阻塞和非阻塞代码、子进程、线程和 IPC。

然而,我们的主要目的是学习使用 RxPHP 来掌握响应式扩展和响应式编程。本书包括 RxPHP 1 和 RxPHP 2。所有示例都是为 RxPHP 1 编写的,因为 API 几乎相同,并且在撰写本书时,RxPHP 2 仍在开发中。此外,RxPHP 1 只需要 PHP 5.6+,而 RxPHP 2 则需要 PHP 7+。尽管如此,当 RxPHP 1 和 RxPHP 2 的 API 不同时,我们会适当强调并解释。

本书涵盖内容

第一章, 响应式编程简介,解释了典型编程范式如命令式、异步、函数式、并行和响应式编程的定义。我们将了解在 PHP 中使用函数式编程的先决条件,以及所有这些与响应式扩展的关系。最后,我们将介绍 RxPHP 库作为本书整个内容的首选工具。

第二章, 使用 RxPHP 进行响应式编程,介绍了在 RxPHP 中使用的响应式编程的基本概念和常用术语。它介绍了 Observables、observers、operators、Subjects 和 disposables 作为任何 Rx 应用程序的构建块。

第三章, 使用 RxPHP 编写 Reddit 阅读器,基于上一章的知识编写一个基于 RxPHP 的 Reddit 阅读器应用程序。这需要通过 cURL 下载数据,处理用户输入,以及比较 PHP 中与 RxPHP 相关的阻塞和非阻塞代码之间的差异。我们还将一瞥 PHP 中的事件循环的使用。

第四章, 响应式与典型事件驱动方法比较,展示了为了在实际中应用 Rx,我们需要知道如何将 RxPHP 代码与一些基于 Rx 的现有代码相结合。因此,我们将使用随 Symfony3 框架一起提供的 Event Dispatcher 组件,并扩展其 Rx 功能。

第五章, 测试 RxPHP 代码,涵盖了测试,这是每个开发过程中的关键部分。除了 PHPUnit,我们还将使用随 RxPHP 一起提供的特殊测试类。我们还将探讨一般性的异步代码测试以及我们需要注意的注意事项。

第六章, PHP 流 API 和高阶 Observables,介绍了 PHP 流 API 和事件循环。这两个概念在 RxPHP 中紧密相连,我们将了解为什么以及如何。我们将讨论在使用同一应用程序中的多个事件循环时可能遇到的问题以及 PHP 社区如何试图解决这些问题。我们还将介绍高阶 Observables 的概念,它是 Rx 的更高级功能。

第七章, 实现套接字 IPC 和 WebSocket 服务器/客户端,展示了为了编写更复杂的异步应用程序,我们将构建一个聊天管理器、服务器和客户端作为三个独立的过程,它们通过 Unix 套接字和 WebSocket 互相通信。我们还将实际使用上一章中的高阶 Observables。

第八章, RxPHP 和 PHP7 pthreads 扩展中的多播,介绍了 Rx 中的多播概念以及 RxPHP 为此目的提供的所有组件。我们还将开始使用 PHP7 的 pthreads 扩展。这将使我们能够在多个线程中并行运行我们的代码。

第九章,使用 pthreads 和 Gearman 进行多线程和分布式计算,将上一章中关于 pthreads 的知识封装成可重用的组件,这些组件可以与 RxPHP 一起使用。我们还介绍了 Gearman 框架作为在多个进程之间分配工作的方法。最后,我们将比较使用多个线程和进程并行运行任务的优缺点。

第十章,在 RxPHP 中使用高级操作符和技术,将重点关注 Rx 中不太常见的原则。这些主要是针对特定任务的先进操作符,但也包括 RxPHP 组件的实现细节和它们在特定用例中的行为,这些是我们应该注意的。

附录,在 RxJS 中重用 RxPP 技术,通过实际示例展示了如何处理 RxPHP 或 RxJS 有用的典型用例。我们将看到异步编程在 JavaScript 环境中的应用,并将其与 PHP 进行比较。最后一章还将更详细地介绍 RxJS 5 是什么以及它与 RxPHP 的区别。

你需要为这本书准备的东西

本书的大部分主要先决条件是 PHP 5.6+ 解释器和任何文本编辑器。

我们将在示例中使用 Composer (getcomposer.org/) 工具安装所有外部依赖项。对 Composer 和 PHPUnit 的基本知识有帮助,但并非绝对必要。

在后面的章节中,我们还将使用 pthreads PHP 扩展,它需要 PHP 7 或更高版本和 Gearman 作业服务器;这两个都应该适用于所有平台。

此外,对 Unix 环境的一些基本知识(套接字、进程、信号等)也有帮助。

本书面向的对象

本书旨在为至少具备平均 PHP 知识的中级开发者编写,他们想了解 PHP 中的异步和响应式编程,特别是响应式扩展。

除了 RxPHP 库之外,本书是框架无关的,因此你不需要了解任何 Web 框架。

所有关于 RxPHP 的主题通常都适用于任何 Rx 实现,因此从 RxPHP 切换到 RxJS,例如,将会非常容易。

约定

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

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 处理方式如下所示:"每次我们编写一个 Observable,我们都会扩展基类 Rx\Observable"。

代码块设置如下:

Rx\Observable::just('{"value":42}')
    ->lift(function() {
        return new JSONDecodeOperator();
    })
    ->subscribe(new DebugSubject());

当我们希望将你的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:

use Rx\Observable\IntervalObservable;
class RedditCommand extends Command {
  /** @var \Rx\Subject\Subject */
  private $subject;
  private $interval;

任何命令行输入或输出都应如下编写:

$ sleep.php proc1 3 
proc1: 1 
proc1: 2 
proc1: 3

新术语重要词汇将以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,将以如下方式显示:“PHP 必须使用线程安全选项编译。”

注意

警告或重要注意事项将以如下方式显示。

提示

小技巧和技巧将以如下方式显示。

读者反馈

我们欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出你真正能从中获得最大收益的标题。要发送一般反馈,请简单地发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书名。如果你在某个主题领域有专业知识,并且对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

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

下载示例代码

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

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

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

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

  3. 点击代码下载与勘误

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

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

  6. 从下拉菜单中选择你购买这本书的地方。

  7. 点击代码下载

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

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

该书的代码包也托管在 GitHub 上github.com/PacktPublishing/PHP-Reactive-Programming。我们还有其他来自我们丰富图书和视频目录的代码包可供选择,请访问github.com/PacktPublishing/。查看它们!

勘误

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

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

盗版

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

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

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

问题

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

第一章:反应式编程简介

反应式编程在过去的几年中已经成为一个非常受欢迎和需求旺盛的主题,尽管其背后的理念并不新颖,但它从多个不同的编程范式中学到了优点。本书的目的是教会你如何带着反应式编程的原则,结合现有的库来编写 PHP 应用程序。

在本章中,我们将学习将指导我们贯穿整本书的最重要的原则:

  • 回顾众所周知的编程范式,并简要解释它们对人类的意义。

  • 我们将看到如何使用实用的例子,即使在今天,我们也可以使用函数式 PHP 编程。我们特别关注我们如何使用匿名函数。

  • 解释什么是反应式编程,以及它从其他编程范式中学到了哪些优点。

  • 我们将探讨一些广泛使用的 JavaScript 和 PHP 库的例子,这些库已经使用了与反应式编程非常相似的原则。

  • 介绍反应式扩展,并了解这些如何融入反应式编程的世界。

  • 使用 RxJS 展示使用反应式扩展的样子,以及它是如何融入整个方案的。

  • 使用 RxPHP 库创建一个简单的第一个演示。

由于反应式编程是一种编程范式,因此我们将快速浏览其他所有我们可能已经听说过的常见范式,你会在阅读或听到反应式编程时经常看到它们。

命令式编程

命令式编程是一种围绕执行改变程序状态的语句的编程范式。

这在人类语言中的含义是:

  • 编程范式:这是一组定义构建和结构化程序风格的概念。大多数编程语言,如 PHP,支持多种范式。我们也可以将其视为一种心态和一种当我们使用这些范式时解决问题的方法。

  • 语句:在命令式编程中,具有副作用的行为单元,通常包含表达式,按顺序评估。语句执行是为了它们的副作用,表达式是为了它们的返回值。考虑以下示例:

            $a = 2 + 5 
    
    

    这行代码是一个语句,其中2 + 5是一个表达式。预期的副作用是将值7赋给$a变量。这会导致改变程序的当前状态。另一个例子可以是:

            if ($a > 5) { } 
    
    

    这个语句有一个表达式,但没有返回值。

  • 状态:在任何给定时间内存中程序变量的值。在命令式编程中,我们定义一系列控制程序流程的语句,因此改变其状态。

声明式编程

声明式编程是一种关注于描述程序逻辑而不是特定执行步骤的范式。换句话说,在声明式编程中,我们定义我们想要什么,而不是我们想要如何实现。与命令式编程相比,声明式编程中的程序是用表达式而不是语句定义的。

非常常见的例子可以是 SQL 和 HTML 语言。考虑以下数据库查询:

SELECT * FROM user WHERE id = 42 

在 SQL 中,我们定义我们想要查询哪些表中的数据,但实现细节对我们来说完全隐藏。我们甚至不想担心数据库引擎如何存储或索引数据。

在 HTML 中,我们定义元素的结构;对于我们来说,浏览器渲染过程背后的细节并不重要。我们只想在屏幕上看到页面。

顺序和并行编程

我们可以将顺序和并行编程视为对立面。

在顺序编程中,我们按顺序执行进程。这意味着当一个进程完成时,才会启动下一个进程。换句话说,始终只有一个进程正在执行。以下图示了这一原则:

顺序和并行编程

在并行编程中,可以同时执行多个进程:

顺序和并行编程

为了使理解更加容易,并且与 PHP 更加相关,我们可以不将进程视为代码行。PHP 解释器始终是顺序执行的,它永远不会并行执行代码。

在第九章《使用 pthreads 和 Gearman 进行多线程和分布式计算》中,我们将使用 PHP 模块 pthreads,它使得在多个线程中运行 PHP 代码成为可能,但我们会看到这并不像看起来那么简单。实际上,pthreads 模块创建了多个独立的 PHP 解释器,每个解释器都在一个单独的线程中运行。

异步编程

异步编程这个术语在 JavaScript 等语言中非常常见。一个非常普遍的定义是,在异步编程中,我们以不同于定义的顺序执行代码。这对于任何基于事件的程序都是典型的。

例如,在 JavaScript 中,我们首先定义一个事件监听器及其处理程序,该处理程序将在适当的事件发生一段时间后执行。

在 PHP 中,这可能是例如一个需要在创建新博客文章时发送电子邮件的 Web 应用程序。只是,我们考虑的是任务,而不是代码行。以下图示了一个异步触发的事件:

异步编程

当 Web 应用程序保存文章(处理任务)时,它触发了一个事件,发送了电子邮件,然后继续执行原始任务。事件处理程序必须在开始此任务之前定义。

异步编程与并行编程的比较

一个非常常见的误解是异步编程和并行编程是相同的,或者认为一个是由另一个引起的。这在 JavaScript 中非常常见,从用户的角度来看,事情看起来像是并行运行的。

这并不正确,但许多编程语言(实际上,只是它们的解释器)在它们仍然是顺序执行的同时,创造出并行运行的错觉。它们看起来像是并行的,这要归因于它们基于事件的本性(如 JavaScript),或者归因于它们的解释器内部机制。

例如,Python 通过在不同部分的应用程序之间切换执行上下文来模拟线程。Python 解释器仍然是单线程的,按顺序执行指令,但创造出并行运行代码的错觉。

函数式编程

函数式编程范式将程序流程视为函数的评估。它利用了几个概念,其中对我们来说最重要的是消除副作用、避免可变数据、函数作为一等公民和高阶函数。每个函数的输出只依赖于其输入参数值,因此,调用同一个函数两次必须总是返回相同的值。它基于声明式编程,即在表达式中使用而不是使用语句。

让我们更深入地看看这意味着什么:

  • 消除副作用:在命令式编程中,副作用在程序执行期间是期望的,而在函数式编程中则正好相反。每个函数都应该是一个独立的构建块,其返回值仅基于其输入值。请注意,在函数式编程中,定义一个没有参数且不返回值的函数几乎没有任何意义。假设函数没有副作用,这意味着这个函数不能做任何事情(或者至少从外部观察不到任何事情)。这与命令式编程形成对比,在命令式编程中,使用这样的函数是有意义的,因为它们可以修改某些内部状态(例如对象的内部状态)。消除副作用导致代码更加独立和易于测试。

  • 避免可变数据:不修改任何输入值并使用它们的副本的概念与不产生任何副作用很好地结合。使用相同的输入参数执行相同的函数将始终返回相同的值。

  • 一等公民和高阶函数:在编程语言中,说类型/对象/函数是一等公民(或一等元素)意味着这个实体支持通常对所有其他实体都适用的操作。通常,这包括:

    • 它可以作为参数传递给函数

    • 它可以从函数中返回

    • 它可以被分配给一个变量

    高阶函数有非常相似的意义,并且至少要做以下之一:

    • 将一个函数作为参数

    • 返回一个函数作为结果

    在函数式编程中,这种高级函数的概念经常与集合上的方法如 map()filter()reduce()concat()zip() 结合使用。

PHP 中的函数式编程

让我们暂时离开一下,看看上述提到的三个概念如何与 PHP 相关联。

消除副作用

这主要是一个良好的编程风格和自律的问题。当然,PHP 并没有限制我们违反这一规则。请注意,通过副作用,我们也指代以下用例:

function sum($array) { 
    $sum = 0; 
    foreach ($array as $value) { 
        $sum += $value; 
    } 
    saveToDatabase($sum); 
    return $sum; 
} 
sum([5, 1, 3, 7, 9]); 

尽管我们没有自己定义函数 saveToDatabase()(例如,它来自我们使用的框架),但它仍然是一个副作用。如果我们再次执行相同的函数,它将返回相同的值,但最终状态是不同的。例如,它将在数据库中创建两次记录。

避免可变数据

对于原始数据类型,这个概念很简单,例如:

function add($first, $second) { 
    return $first + $second; 
} 
add(5, 2); 

然而,当与集合一起工作时,这一原则需要创建一个新的集合并将旧集合中的值复制到新集合中:

function greaterThan($collection, $threshold) { 
    $out = []; 
    foreach ($collection as $val) { 
        if ($val > $threshold) { 
            $out[] = $val; 
        } 
    } 
    return $out; 
} 
greaterThan([5, 12, 8, 9, 42], 8); 
// will return: [12, 9, 42] 

上述例子展示了这一原则在实际中的应用。

在 PHP 中,出于性能原因,数组是通过引用传递的,直到第一次尝试修改它们。然后解释器将在幕后创建原始数组的副本(所谓写时复制)。然而,对象始终作为引用传递,因此我们在处理它们时必须非常小心。

这种不可变集合(或一般对象)的概念在 JavaScript 中变得非常流行,例如 Facebook 制作的 Immutable.js 库(facebook.github.io/immutable-js/),或者 Angular2 中所谓的 onPush 变化检测机制。

除了使我们的代码更可预测外,当它被适当使用时,它将简化对大型集合中变化的检查,因为如果其任何项已更改,则整个集合将被新的实例替换。

为了检查两个集合是否包含相同的数据,我们可以使用身份运算符(=== 三等号)而不是逐个比较集合的项。

在 PHP 中,已经有库使这项任务更容易,例如,Immutable.phpgithub.com/jkoudys/immutable.php)。例如,PHP 5.5+ 默认包含一个不可变的 DateTime 类版本,称为 DateTimeImmutable

一等公民和高级函数

现在事情开始变得有趣。PHP 中的函数已经是一等公民很长时间了。此外,自 PHP 5.3+以来,我们可以使用匿名函数,这极大地简化了高级函数的使用。

考虑一个非常简单的例子,使用内置的 array_map() 函数对集合中的每个元素应用一个函数:

$input = ['apple', 'banana', 'orange', 'raspberry']; 
$lengths = array_map(function($item) { 
    return strlen($item); 
}, $input); 
// $lengths = [5, 6, 6, 9]; 

我们使用了 PHP 的 array_map() 函数来迭代数组并返回每个字符串的长度。如果我们只考虑这个函数调用,它使用了我们在上面解释的多个范式的许多概念:

array_map(function($item) { 
    return strlen($item); 
}, $input); 

这具体意味着什么:

  • 单个表达式 strlen($item) 和没有赋值(声明式编程)。

  • 实际迭代数组的实现细节对我们来说是隐藏的(声明式编程)。

  • 首类公民和高级函数(函数式编程)。

  • 不可变数据 - 这个函数调用不会改变原始数据,而是创建一个新的数组(函数式编程)。

  • 没有副作用 - 所有操作都在内部闭包中进行。如果我们使用了任何变量,它们将只存在于这个闭包中(函数式编程)。

仅为了比较,如果我们想用过程式编程写出相同的例子,它将只多一行:

$result = []; 
foreach ($input as $value) { 
    $result[] = strlen($value); 
} 

让我们更进一步,假设我们想要获取所有长度大于 5 的总和。首先,我们将从最明显的过程式方法开始:

$input = ['apple', 'banana', 'orange', 'raspberry']; 
$sum = 0; 
foreach ($input as $fruit) { 
    $length = strlen($fruit); 
    if ($length > 5) { 
        $sum += $length; 
    } 
} 
// $sum = 21 
printf("sum: %d\n", $sum); 

现在,我们可以使用函数式编程来写出相同的内容,利用我们之前提到的三种方法:map、filter 和 reduce。在 PHP 中,这些分别被称为 array_map()array_filter()array_reduce()

$lengths = array_map(function($fruit) { 
    return strlen($fruit); 
}, $input); 
$filtered = array_filter($lengths, function($length) { 
    return $length > 5; 
}); 
$sum = array_reduce($filtered, function($a, $b) { 
    return $a + $b; 
}); 

我们去掉了所有语句,只使用了表达式。生成的代码并不短,我们还得创建三个变量来保存部分处理的数组。所以让我们将其转换成一个大的嵌套调用:

$sum = array_reduce(array_filter(array_map(function($fruit) { 
    return strlen($fruit); 
}, $input), function($length) { 
    return $length > 5; 
}), function($a, $b) { 
    return $a + $b; 
}); 

这要短一些;我们可以看到函数应用的顺序和它们相应的表达式,顺序相同。我们已经遇到了 PHP 中函数声明的矛盾,如下所示,这已经受到了高度批评:

array array_map(callable $callback, array $array1 [, $... ]) 
array array_filter(array $array, callable $callback) 
mixed array_reduce(array $array, callable $callback) 

这些是从 PHP 文档中缩短的函数定义。我们可以看到,有时第一个参数是迭代的集合;有时是回调函数。同样的问题也存在于字符串函数及其 haystack-needle 参数中。我们可以尝试使用 functional-PHP 库(github.com/lstrojny/functional-php)来提高可读性,这是一个 PHP 函数式编程的函数集合。

以下代码表示与上面相同的例子,但使用了 lstrojny/functional-php 库:

use function Functional\map; 
use function Functional\filter; 
use function Functional\reduce_left; 

$sum = reduce_left(filter(map($input, function($fruit) { 
    return strlen($fruit); 
}), function($length) { 
    return $length > 5; 
}), function($val, $i, $col, $reduction) { 
    return $val + $reduction; 
}); 

它确实看起来更好,但当我们使用标准的 PHP 数组时,这可能是我们能得到的最好的结果。

让我们看看在数组是对象且 map、filter 和 reduce 是其方法的编程语言中,如何解决相同的问题。例如,JavaScript 就是这样一种语言,因此我们可以再次重写上面的例子:

var sum = inputs 
    .map(fruit => fruit.length) 
    .filter(len => len > 5) 
    .reduce((a, b) => a + b);  

注意

在本书中,我们将使用新的 ES6 标准来展示任何 JavaScript 代码。

嗯,这相当简单,并且比 PHP 中的函数式编程更好地满足了我们的期望。这可能是我们几乎从不使用高阶函数的原因。它们太难编写、阅读和维护了。

在我们继续之前,我们应该看看与 PHP 中的函数式编程相关的一个值得注意的话题。

PHP 中的匿名函数

每个匿名函数在内部都表示为闭包类的一个实例,如下所示(我们也会将匿名函数称为闭包或可调用对象):

$count = function() { 
    printf("%d ", count($this->fruits)); 
}; 
var_dump(get_class($count)); 
// string(7) "Closure" 

不寻常的是,我们可以在调用闭包时绑定自定义的 $this 对象,这是一个在 JavaScript 中非常常见但在 PHP 中很少使用的概念。

让我们定义一个简单的类,我们将用它来进行演示:

class MyClass { 
    public $fruits; 
    public function __construct($arr) { 
        $this->fruits = $arr; 
    } 
} 

然后,在两个对象上测试存储在 $count 变量中的函数:

// closures_01.php 
// ... the class definition goes here 
$count = function() { 
    printf("%d ", count($this->fruits)); 
}; 

$obj1 = new MyClass(['apple', 'banana', 'orange']); 
$obj2 = new MyClass(['raspberry', 'melon']); 

$count->call($obj1); 
$count->call($obj2); 

这个例子将以下输出打印到控制台:

$ php closures_01.php
3
2

在 PHP 中,我们可以使用 use 关键字指定我们想要从父作用域传递到闭包中的变量。变量也可以通过引用传递,类似于在函数调用中通过引用传递变量。考虑以下示例,它演示了这两个原则:

// closures_03.php 
$str = 'Hello, World'; 

$func = function() use ($str) { 
    $str .= '!!!'; 
    echo $str . "\n"; 
}; 
$func(); 
echo $str . "\n"; 

$func2 = function() use (&$str) { 
    $str .= '???'; 
    echo $str . "\n"; 
}; 
$func2(); 
echo $str . "\n"; 

我们有两个闭包 $func$func2。第一个闭包使用 $str 的一个副本,所以当我们将其打印到函数外部时,它不会被修改。然而,第二个闭包 $func2 使用原始变量的引用。这个演示的输出如下:

$ php closures_03.php
Hello, World!!!
Hello, World
Hello, World???
Hello, World???

在这本书中,我们将多次将对象传递给闭包。

还有一个具有类似功能的 bindTo($newThis) 方法。它不是评估闭包,而是返回一个新的闭包对象,其中 $this 被绑定到 $newThis,之后可以通过例如 call_user_func() 方法调用。当在对象内部使用闭包时,上下文 $this 会自动绑定,所以我们不需要担心它。

注意

匿名函数和闭包类在官方文档中解释得非常好,所以如果你有任何疑问,请前往那里:php.net/manual/en/functions.anonymous.php

PHP 魔术方法

PHP 定义了一组可以用于具有特殊效果类方法的名称。这些名称都以前缀两个下划线 __ 开头。就我们的目的而言,我们将特别关注其中的两个,称为 __invoke()__call()

当我们尝试将对象用作普通函数时,会使用 __invoke() 方法。当我们使用高阶函数时,这很有用,因为我们可以将对象和函数以完全相同的方式对待。

第二个 __call() 方法用于当我们尝试调用一个不存在的对象方法(更准确地说,是一个不可访问的方法)时。它接收原始方法名和尝试调用它时使用的参数数组作为参数。

我们将在 第二章 使用 RxPHP 的响应式编程 中使用这两个魔法方法。

这里展示的原则在 PHP 中并不常见,但当我们使用函数式编程时,我们会在几个场合遇到它们。

注意

在整本书中,我们将尝试遵循 PSR-1 和 PSR-2 编码标准 (www.php-fig.org/psr/)。然而,我们经常会故意违反它们,以使源代码尽可能短。

现在,我们终于要掌握响应式编程了。

响应式编程

响应式编程是另一种编程范式。它基于轻松表达数据流和自动传播变化的能力。

让我们更深入地探讨这个问题:

  • 数据流(或数据流):在响应式编程中,我们希望将变量视为“随时间变化的值”。例如,这可以是鼠标位置、用户点击或通过 WebSocket 传入的数据。基本上,任何基于事件的系统都可以被视为数据流。

  • 变化的传播:一个非常合适的例子是电子表格编辑器。如果我们把单个单元格的值设置为 A1 = A2 + A3,这意味着对单元格 A2A3 的任何更改都将传播到 A1。在程序员的用语中,这对应于观察者设计模式,其中 A2A3 是可观察的,而 A1 是观察者。我们将在本章后面再次讨论观察者模式。

  • 轻松表达数据流:这与我们使用的库有关,而不仅仅是与语言本身有关。这意味着,如果我们想有效地使用响应式编程,我们需要能够轻松地操作数据流。这个原则还表明,响应式编程属于声明性范式类别。

如我们所见,定义非常广泛。

关于数据流和变化传播的第一部分看起来像迭代器的观察者设计模式。使用函数式编程轻松表达数据流是可以做到的。这一切基本上描述了我们已经在本章中看到的内容。

与观察者模式的主要区别在于我们如何思考和操作数据流。在先前的例子中,我们总是以数组作为输入进行工作,这些数组是同步的,而数据流可以是同步的也可以是异步的。从我们的角度来看,这并不重要。

让我们看看观察者模式在 PHP 中的典型实现可能是什么样子:

// observer_01.php 
class Observable { 
    /** @var Observer[] */ 
    private $observers = []; 
    private $id; 
    static private $total = 0; 

    public function __construct() { 
        $this->id = ++self::$total; 
    } 

    public function registerObserver(Observer $observer) { 
        $this->observers[] = $observer; 
    } 

    public function notifyObservers() { 
        foreach ($this->observers as $observer) { 
            $observer->notify($this, func_get_args()); 
        } 
    } 

    public function __toString() { 
        return sprintf('Observable #%d', $this->id); 
    } 
} 

为了通知任何由可观察者做出的更改,我们需要另一个名为 Observer 的类,它订阅了一个可观察者:

// observer_01.php 
class Observer { 
    static private $total = 0; 
    private $id; 

    public function __construct(Observable $observable) { 
        $this->id = ++self::$total; 
        $observable->registerObserver($this); 
    } 

    public function notify($obsr, $args) { 
        $format = "Observer #%d got "%s" from %s\n"; 
        printf($format, $this->id, implode(', ', $args), $obsr); 
    } 
} 

然后,一个典型的用法可能如下所示:

$observer1 = new Observer($subject); 
$observer2 = new Observer($subject); 
$subject->notifyObservers('test'); 

这个例子将在控制台打印出两条消息:

$ php observer_01.php
// Observer #1 got "test" from Observable #1
// Observer #2 got "test" from Observable #1

这几乎遵循我们定义反应式编程范式的定义。数据流是从可观察者来的事件序列,变化被传播到所有监听观察者。我们上面提到的最后一个点——能够轻松表达数据流——实际上并不存在。如果我们想过滤掉所有不符合特定条件的事件,就像我们在array_filter()和函数式编程的示例中所做的那样,该怎么办?这种逻辑必须放入每个Observer类的实现中。

反应式编程的原则实际上在一些库中非常常见。我们将查看其中的三个,并看看它们如何与我们刚刚学到的反应式和函数式编程相关。

jQuery Promises

可能每个 Web 开发者都曾在某个时候使用过 jQuery。在处理异步调用时,使用 Promise 来避免所谓的回调地狱是一个非常方便的方法。例如,调用jQuery.ajax()返回一个Promise对象,当 AJAX 调用完成时解决或拒绝:

$.get('/foo/bar').done(response => { 
    // ... 
}).fail(response => { 
    // ... 
}).complete(response => { 
    // ... 
}); 

一个Promise对象代表未来的一个值。它是非阻塞的(异步的),但允许我们以声明性的方式处理它。

另一个有用的用例是链式回调,形成一个链,其中每个回调都可以在进一步传播之前修改值:

// promises_01.js 
function functionReturningAPromise() { 
    var d = $.Deferred(); 
    setTimeout(() => d.resolve(42), 0); 
    return d.promise(); 
} 

functionReturningAPromise() 
    .then(value => value + 1) 
    .then(value => 'result: ' + value) 
    .then(value => console.log(value)); 

在这个例子中,我们有一个单一的资源,即functionReturningAPromise()调用,以及三个回调,只有最后一个打印出解决 Promise 的值。我们可以看到,当通过回调链传递时,数字42被修改了两次:

$ node promises_01.js 
result: 43

注意

在反应式编程中,我们将使用与 Promise 非常相似的方法,但Promise对象总是只解决一次(它只携带一个值);数据流可以生成多个或甚至无限多个值。

Gulp 流式构建系统

Gulp 构建系统已经成为 JavaScript 中最受欢迎的构建系统。它完全基于流和流操作。考虑以下示例:

gulp.src('src/*.js') 
  .pipe(concat('all.min.js')) 
  .pipe(gulp.dest('build')); 

这创建了一个匹配谓词src/*.js的文件流,将所有这些文件连接在一起,最后将一个单独的文件写入build/all.min.js。这让你想起了什么吗?

这是我们上面在谈论 PHP 中的函数式编程时使用的相同声明性和功能性方法。特别是,这个concat()函数可以用 PHP 的array_reduce()来替换。

gulp 中的流(即 vinyl-source-stream)可以被修改成我们想要的任何方式。例如,我们可以将一个流拆分为两个新的流:

var filter = require('gulp-filter'); 
var stream = gulp.src('src/*.js'); 
var substream1 = stream.pipe(filter(['*.min.js'])); 
var substream2 = stream.pipe(filter(['!/app/*'])); 

或者,我们可以合并两个流并将它们压缩(最小化和混淆源代码)成一个流:

var merge = require('merge2'); 
merge(gulp.src('src/*.js'), gulp.src('vendor/*')) 
    .pipe(uglify()); 
    .pipe(gulp.dest('build')); 

这种流操作与我们在定义反应式编程范式时使用的最后一个概念非常吻合——轻松表达数据流——同时它既功能性强又声明性强。

PHP 中的 EventDispatcher 组件

可能每个 PHP 框架都附带某种类型的事件驱动组件,用于通过事件通知应用程序的各个不同部分。

其中一个组件是 Symfony 框架自带的功能(github.com/symfony/event-dispatcher)。它是一个独立的组件,允许订阅和监听事件(观察者模式)。

事件监听器可以后来根据它们订阅的事件进行分组,也可以分配自定义标签,如下面的代码所示:

use Symfony\Component\EventDispatcher\EventDispatcher; 
$dispatcher = new EventDispatcher(); 
$listener = new AcmeListener(); 
$dispatcher->addListener('event_name', [$listener, 'action']); 

这个原则与在 Zend Framework 中使用的 Zend\EventManager 非常相似。它只是可观察者-观察者组合的另一种变体。

我们将在第四章“反应式与典型事件驱动方法”中回到 Symfony EventDispatcher 组件,探讨如何将反应式编程方法应用于基于事件的系统,这应该会导致代码简化并更有组织性。第四章,反应式与典型事件驱动方法

反应式扩展

现在我们已经看到反应式编程范式中的原则对我们来说并不完全陌生,我们可以开始思考如何将这些全部组合起来。换句话说,为了开始编写反应式代码,我们真正需要哪些库或框架。

反应式扩展(ReactiveX 或简称 Rx)是在各种语言中使反应式编程变得容易的库集合,即使在异步和函数式编程概念笨拙的语言中,如 PHP。然而,有一个非常重要的区别:

反应式编程不等于反应式扩展。

反应式扩展是一个库,它将某些原则作为接近反应式编程的可能方法之一。当有人告诉你他们正在使用反应式编程在他们的应用程序中做某事时,他们实际上是在谈论他们最喜欢的语言中特定的反应式扩展库。

反应式扩展最初由微软为 .NET 创建,称为 Rx.NET。后来,Netflix 将其移植到 Java,称为 RxJava。现在,有超过十种支持的语言,其中最受欢迎的可能就是 RxJS - JavaScript 的实现。

所有端口都遵循一个非常相似的 API 设计,然而,差异仍然存在,我们将在几个地方讨论它们。我们将主要关注 RxPHP 和 RxJS 之间的差异。

RxPHP 主要是一片未知的领域。我们遇到异步事件的更典型环境是 JavaScript,因此我们首先将在 JavaScript(以及 RxJS 5)中演示示例,之后我们将查看 RxPHP。

使用 RxJS 自动完成

假设我们想要实现一个自动完成功能,从维基百科下载建议(此示例来自 RxJS 的 GitHub 页面上的官方演示集合):

function searchAndReturnPromise(term) { 
    // perform an AJAX request and return a Promise 
} 

var keyup = Rx.Observable.fromEvent($('#textInput'), 'keyup') 
    .map(e => e.target.value) 
    .filter(text => text.length > 2) 
    .debounceTime(750) 
    .distinctUntilChanged(); 
var searcher = keyup.switchMap(searchAndReturnPromise); 

让我们更详细地看看它是如何工作的:

  1. 我们从表单输入的 keyup 事件创建一个可观察对象。这个函数是内置在 RxJS 中的,用于简化可观察对象的创建。我们当然也可以创建自己的可观察对象。

  2. 应用 map() 函数。这正是我们上面已经看到的内容。请注意,这个 map() 函数实际上不是 Array.map(),而是 Observable.map(),因为我们这里不处理数组。

  3. 使用 filter() 方法进行链式操作。这与 map() 的情况完全相同。

  4. 方法 debounceTime() 用于限制在一段时间后只将事件向下传播一次。在这种情况下,我们使用 750ms,这意味着当用户开始输入时,它不会在每次 keyup 事件时从维基百科下载数据,而只是在两个事件之间至少有 750ms 延迟之后。

  5. distinctUntilChanged() 方法确保我们只有在值真正从上一次改变时才调用 AJAX 请求,因为下载相同的建议两次是没有意义的。

  6. 最后一条使用 keyup.switchMap() 的语句保证了在执行多个异步调用时,只有流中的最后一个被处理。其他所有调用都被忽略。这很重要,因为在处理 AJAX 调用时,我们绝对无法控制哪个 Promise 首先解析。

如果我们没有使用 RxJS,这个功能将需要多个状态变量。至少需要保持输入的最后一个值、事件发生的最后时间以及 AJAX 调用的最后一个请求值。使用 RxJS,我们可以专注于我们想要做的事情,而不必担心其实现细节(声明式方法)。

使用响应式扩展,这种方法满足了我们所描述的所有关于响应式编程、函数式编程以及主要是声明式编程的内容。

拖放鼠标位置

让我们看看一个稍微复杂一点的 RxJS 示例。我们想要跟踪从开始拖动 HTML 元素的位置到释放它(mouseup 事件)的相对鼠标位置。

注意这个例子是如何结合多个可观察对象的(这个例子也来自 RxJS 的 GitHub 页面上的官方演示集合):

var mouseup   = Rx.Observable.fromEvent(dragTarget, 'mouseup'); 
var mousemove = Rx.Observable.fromEvent(document, 'mousemove'); 
var mousedown = Rx.Observable.fromEvent(dragTarget, 'mousedown'); 

var mousedrag = mousedown.mergeMap(md => { 
    var sX = md.offsetX, sY = md.offsetY; 
    return mousemove.map(mm => { 
        mm.preventDefault(); 
        return {left: mm.clientX - sX, top: mm.clientY - sY}; 
    }).takeUntil(mouseup); 
}); 

var subscription = mousedrag.subscribe(pos => { 
    dragTarget.style.top = pos.top + 'px'; 
    dragTarget.style.left = pos.left + 'px'; 
}); 

注意,mousedrag 是通过调用 return mousemove(...) 创建的可观察对象,并且它只会在发出 mouseup 事件时停止发出事件,这是通过 takeUntil(mouseup) 实现的。

通常情况下,如果没有使用 RxJS,并且采用典型的命令式方法,这会比上一个例子更复杂,需要更多的状态变量。

当然,这需要一些关于可观察对象可用函数的基本知识,但即使没有任何先前的经验,代码也应该相对容易理解。再次强调,实现细节对我们来说是完全隐藏的。

介绍 RxPHP

RxPHP (github.com/ReactiveX/RxPHP ) 是 RxJS 的一个端口。我们将使用 Composer 来处理我们 PHP 项目的所有依赖。它已经成为一个前沿的工具,所以如果你之前没有使用过它,请先下载并查看一些基本用法 getcomposer.org/

然后,创建一个新的目录并初始化一个 Composer 项目:

$ mkdir rxphp_01
$ cd rxphp_01
$ php composer.phar init

通过交互式向导填写所需的字段,然后添加 RxPHP 作为依赖项:

$ php composer.phar require reactivex/rxphp

当库成功下载后,Composer 将创建 autoload.php 文件来处理所有按需类自动加载。

然后,我们的代码将打印不同类型水果的字符串长度:

// rxphp_01.php 
require __DIR__ . '/vendor/autoload.php'; 

$fruits = ['apple', 'banana', 'orange', 'raspberry']; 
$observer = new \Rx\Observer\CallbackObserver( 
    function($value) { 
        printf("%s\n", $value); 
    }, null, function() { 
        print("Complete\n"); 
    }); 

\Rx\Observable::fromArray($fruits) 
    ->map(function($value) { 
        return strlen($value); 
    }) 
    ->subscribe($observer); 

注意

在所有未来的示例中,我们不会包括 autoload.php 文件,以使示例尽可能简短。然而,显然它对于运行示例是必需的。如果你不确定,请查看每个章节提供的源代码。

我们首先创建了一个观察者 - 精确地说,是 CallbackObserver - 它接受三个函数作为参数。这些函数在流中的下一个项目上、在错误发生时以及在输入流完成且不会发出更多项目时被调用。

CallbackObserver 类的优势在于,我们不需要每次都编写一个自定义观察者类来以特殊且不太可重用的方式处理传入的项目。使用 CallbackObserver,我们只需编写我们想要处理的信号的调用者。

当我们运行这个示例时,我们会看到:

$ php rxphp_01.php 
5
6
6
9
Complete

这个示例非常简单,但与 JavaScript 环境相比,在 PHP 中使用异步操作并不常见,如果我们确实需要异步工作,那可能是一些非平凡的事情。在 第三章,使用 RxPHP 编写 Reddit 阅读器,我们将使用 Symfony 控制台组件来处理来自命令行的所有用户输入,并在可能的情况下,使用与上面两个 RxJS 示例中看到类似的原理来处理鼠标事件。

JavaScript 示例非常适合作为使用响应式扩展进行响应式编程的示例,以及它的好处。

注意

如果你想了解更多关于响应式扩展的信息,请访问 reactivex.io/。此外,在继续下一章之前,你可以查看 Rx 支持的不同操作符 reactivex.io/documentation/operators.html 以及这些操作符如何在不同的语言中使用。

RxPHP 1.x 和 RxPHP 2

截至 2017 年 4 月,RxPHP 有两个版本。

RxPHP 1.x 是稳定的,需要 PHP 5.5+。本书中的所有示例都是为 RxPHP 1.x 制作的,更具体地说,是 RxPHP 1.5+。它的 API 主要基于 RxJS 4,但也借鉴了一些 RxJS 5 的功能。

还有正在开发的 RxPHP 2,它需要 PHP 7.0+。从用户的角度来看,RxPHP 2 API 几乎与 1.x 相同,它只是使某些事情变得更容易(例如,我们在第六章[part0050_split_000.html#1FLS41-bd355a22cf10407cb10df27e65585b8d "第六章。PHP 流 API 和高阶观察者"]中将会看到,与事件循环一起工作),PHP 流 API 和高阶观察者)。当我们遇到任何值得注意的差异时,我们会给予它们额外的空间。

注意

新的 RxPHP 2 是打算基于 PHP 循环互操作规范(github.com/async-interop/event-loop)。然而,该规范仍处于预发布阶段,并且在未来一段时间内不会稳定。因此,RxPHP 团队决定将 async-interop 支持留给未来的版本。更多信息请访问 github.com/ReactiveX/RxPHP/pull/150

摘要

在本章中,我们试图解释在大多数编程语言中使用的常见编程范式。这些是:命令式、声明式和函数式编程。我们还比较了异步和并行代码的含义。

我们花了一些时间讨论 PHP 中函数式编程的实际示例及其缺点,并介绍了某些不太常见的特性的示例,例如 Closure 类。

然后,我们考察了反应式编程的定义以及它与本章中我们之前看到的所有内容的关联。

我们将反应式扩展(Rx)介绍为一个用于反应式编程可能方法之一的库。

在两个 RxJS 的示例中,我们看到了在实际中与反应式扩展一起工作的样子,以及这与我们反应式编程定义的匹配程度。

最后,我们介绍了 RxPHP,这是我们将在整本书中使用的库。我们还简要地讨论了 RxPHP 1.x 和 RxPHP 2 之间的差异。

在下一章中,我们将更深入地探讨 RxPHP 库的各个部分,并更多地讨论在反应式扩展中使用的原则。

第二章。使用 RxPHP 进行反应式编程

在本章中,我们将更深入地了解如何使用 PHP 的反应式扩展库 RxPHP。我们将主要基于前一章的内容,但会进行更详细的探讨。

尤其是以下内容:

  • 我们将在本章和所有后续章节中使用 RxPHP 的各种组件。

  • 我们将快速浏览如何阅读和理解 Rx 文档。特别是,我们将查看解释 Rx 操作符功能的宝石图。

  • 列出我们将全书使用的几个基本操作符及其功能说明。

  • 编写自定义操作符,将 JSON 字符串解码成适当的数组表示,同时正确处理错误。

  • 实现一个简单的脚本,通过 cURL 下载 HTML 页面。然后比较使用 RxPHP 的相同方法。

  • 如何为我们的 cURL 示例编写自定义的可观察对象。

  • 我们将深入研究 RxPHP 的源代码,看看当我们使用内置的可观察对象和操作符时会发生什么。

在我们单独查看 RxPHP 的各个部分之前,我们将简要提及一些非常常见的术语,这些术语将在我们讨论反应式扩展的各个方面时使用。

反应式扩展的基本原理

让我们看看一个非常简单的 RxPHP 示例,类似于我们在前一章中做的,并使用它来展示反应式扩展背后的基本原理。

我们现在不会麻烦定义观察者,而只关注可观察对象和操作符:

// rxphp_basics_01.php 
use Rx\Observable; 
$fruits = ['apple', 'banana', 'orange', 'raspberry']; 

Observable::fromArray($fruits) // Observable 
    ->map(function($value) { // operator 
        return strlen($value); 
    }) 
    ->filter(function($len) { // operator 
        return $len > 5; 
    }) 
    ->subscribe($observer); // observer 

在这个例子中,我们有一个可观察对象,两个操作符和一个观察者。

可观察对象可以与操作符链式连接。在这个例子中,操作符是 map()filter()

可观察对象有 subscribe() 方法,观察者使用它来在链的末尾开始接收值。

我们可以用以下图表来表示这个链:

反应式扩展的基本原理

每个箭头都显示了项目通知的传播方向。

我们可能需要解释使用可观察对象和迭代数组之间的区别。

可观察对象就像一个推送模型,当值准备好时,它会被推送到操作符链中。这一点非常重要,因为可观察对象决定了何时应该发出下一个值。可观察对象的内部逻辑可以执行它需要的任何操作(例如,运行一些异步任务),并且仍然保持完全隐藏。

与可观察对象类似的概念是承诺。然而,虽然承诺代表未来将存在的单个值,但可观察对象代表一系列值。

另一方面,迭代数组就像一个拉模型。我们会一个接一个地拉取项目。重要的后果是,我们必须事先准备好数组(即在开始迭代之前)。

另一个重要的区别是,Observables 的行为类似于数据流(或数据流)。我们在第一章,响应式编程简介中讨论了流。在实践中,这意味着 Observable 知道它已经发出了所有项目,或者当发生错误时能够向下发送适当的通知。

因此,Observables 可以在它们的观察者上调用三种不同的方法(我们将在本章后面编写自定义操作符和自定义 Observable 时看到这是如何实现的):

  • onNext:当下一个项目准备好发出时,会调用此方法。我们通常说“Observable 发出一个项目”。

  • onError:当发生错误时调用的通知。这可能是由Exception类的实例表示的任何类型的错误。

  • onComplete:当没有更多项目要发出时调用的通知。

每个 Observable 可以发出零个或多个项目。

每个 Observable 可以发送一个错误,或者一个完整的通知;但不能同时发送两者。

这就是为什么我们在第一章,响应式编程简介中使用的CallbackObserver类,将三个可调用函数作为参数。这些可调用函数分别在观察者收到下一个项目、错误通知或完成通知时被调用。所有三个可调用函数都是可选参数,我们可以决定忽略任何一个。

例如,我们可以创建一个如下所示的观察者:

use Rx\Observer\Callback\Observer; 

$observer = new CallbackObserver( 
    function($value) { 
        echo "Next: $value\n"; 
    }, 
    function(Exception $err) { 
        $msg = $err->getMessage(); 
        echo "Error: $msg\n"; 
    }, 
    function() { 
        echo "Complete\n"; 
    } 
); 

这个观察者定义了所有三个可调用函数。我们可以在上面定义的 Observable 上测试它,并查看其输出:

$ php rxphp_basics_01.php
Next: 6
Next: 6
Next: 9
Complete

我们可以看到,只有三个值通过了filter()操作符,并在最后有一个适当的完成通知。

在 RxPHP 中,每个接受可调用函数作为参数的操作符都会用try…catch块内部包装其调用。如果可调用函数抛出Exception,则此Exception作为onError通知发送。考虑以下示例:

// rxphp_basics_02.php 
$fruits = ['apple', 'banana', 'orange', 'raspberry']; 
Observable::fromArray($fruits) 
    ->map(function($value) { 
        if ($value[0] == 'o') { 
            throw new Exception("It's broken."); 
        } 
        return strlen($value); 
    }) 
    ->filter(function($len) { 
        return $len > 5; 
    }) 
    ->subscribe($observer); 

使用我们之前定义的相同观察者,此示例将产生以下输出:

$ php rxphp_basics_02.php
Next: 6
Error: It's broken.

重要的是要注意,当发生错误时,不再发出更多项目,也没有完整的通知。这是因为,当观察者收到错误时,它会自动取消订阅。

我们将在第三章,使用 RxPHP 编写 Reddit 阅读器和第十章,在 RxPHP 中使用高级操作符和技术中更多地讨论订阅和取消订阅背后的过程。

在第八章中,我们将更深入地探讨当观察者接收到错误或完成通知时在观察者内部发生的情况。

在我们继续之前,有一点需要说明。我们提到可观察对象代表数据流。这个优点在于我们可以轻松地合并或拆分流,类似于我们在第一章中提到的gulp构建工具。

让我们来看一个稍微复杂一点的例子,合并两个可观察对象:

// rxphp_basics_03.php 
$fruits = ['apple', 'banana', 'orange', 'raspberry']; 
$vegetables = ['potato', 'carrot']; 

Observable::fromArray($fruits) 
    ->map(function($value) { 
        return strlen($value); 
    }) 
    ->filter(function($len) { 
        return $len > 5; 
    }) 
    ->merge(Observable::fromArray($vegetables)) 
    ->subscribe($observer); 

我们使用了merge()操作符将现有的可观察对象与另一个可观察对象合并。注意,我们可以在任何我们想要的地方添加操作符。由于我们在filter()操作符之后和subscribe()调用之前添加了它,第二个可观察对象的项将直接发送到观察者,并跳过前面的操作符链。

我们可以用以下图表来表示这个链:

响应式扩展的基本原理

这个示例的输出如下所示:

$ php rxphp_basics_03.php
Next: 6
Next: 6
Next: 9
Next: potato
Next: carrot
Complete

这些原则适用于所有 Rx 实现。现在,我们应该对在 Rx 中使用可观察对象、观察者和操作符有一个基本的了解,然后我们可以分别讨论它们。

响应式扩展的命名规范

当我们谈论可观察对象时,我们使用诸如发出/发送值/项之类的术语。通常,我们说一个可观察对象发出一个项,但我们同样理解一个可观察对象发送一个值

通过发出/发送,我们指的是一个可观察对象在观察者上调用onNext方法。

当我们谈论可观察对象时,我们使用诸如发送错误/完成通知/信号之类的术语。我们也经常提到一个可观察对象完成了,这意味着一个可观察对象已经发送了一个完成通知。

通过通知/信号,我们指的是一个可观察对象在观察者上调用onErroronComplete方法。

在前面的段落中,我们使用了一个简单的 RxPHP 演示,其中有一个可观察对象、两个操作符和一个观察者。

这种结构形成了一个操作符/可观察对象链。我们将从操作符链可观察对象链(有时也称为可观察对象操作符链)这两个术语中理解相同的内容。这是因为从我们的角度来看,我们正在链式调用操作符;但在底层,每个操作符都返回Observable类的另一个实例,所以实际上我们是在链式调用可观察对象。在实践中,这并不重要,所以我们只需记住它们具有相同的意义。

当谈到可观察对象链时,我们有时会使用术语源可观察对象。这是链中项目的来源。换句话说,它是链中的第一个可观察对象。在先前的示例中,源可观察对象是Observable::fromArray($fruits)

当谈到算子时,我们使用术语源可观察对象来描述直接位于此特定算子之前的可观察对象(因为它为该算子提供项目来源)。

有时,你可能会遇到只是nexterrorcomplete这样的术语和方法名,而不是onNextonErroronComplete。这来自 RxJS 5,它遵循 ES7 可观察对象规范(github.com/tc39/proposal-observable),但它们的含义完全相同。大多数 Rx 实现使用名称onNextonErroronComplete

所有这些术语都用于各种关于 Rx 的文献和文章中,因此我们将容忍所有这些术语。

RxPHP 组件

由于本章将主要关于可观察对象、观察者和算子,我们将从它们开始。

我们在本章中已经看到了一个预览,现在我们将更详细地介绍。

可观察对象

可观察对象发出项目。换句话说,可观察对象是值的来源。观察者可以订阅可观察对象,以便在下一个项目准备好、所有项目都已发出或发生错误时被通知。

可观察对象(在响应式编程的意义上)与观察者模式之间的主要区别是,可观察对象可以告诉你何时所有数据都已发出,何时发生错误。所有三种类型的事件都被观察者消费。

RxPHP 附带了几种基本类型的可观察对象,适用于一般用途。以下是一些易于使用的例子:

  • ArrayObservable:它从一个数组创建一个可观察对象,并在第一个观察者订阅后立即发出所有值。

  • RangeObservable:它从预定义的范围内生成一个数字序列。

  • IteratorObservable:它遍历并发出可迭代中的每个项目。这可以是任何作为迭代器包装的数组。考虑以下示例,我们遍历一个数组而不是使用ArrayObservable

            $fruits = ['apple', 'banana', 'orange', 'raspberry']; 
            new IteratorObservable(new ArrayIterator($fruits)); 
    
    

    注意,这还包括生成器。考虑另一个使用匿名函数和yield关键字的例子。

            $iterator = function() use ($fruits) { 
                foreach ($fruits as $fruit) { 
                    yield $fruit; 
                } 
            }; 
            new IteratorObservable($iterator()) 
                ->subscribe(new DebugSubject()); 
    
    

调用 $iterator() 函数返回一个实现了 Iterator 接口的 Generator 类的实例。然而,这些基本 Observables 主要用于演示目的,在现实世界的使用中并不实用。在 PHP 环境中,我们无法像在 JavaScript 和 RxJS 中那样从鼠标事件创建 Observables,因此我们将在本章中很快学习如何编写自定义 Observables,以便创建一些真实世界的示例。在 第三章,使用 RxPHP 编写 Reddit 阅读器,我们将学习 Observable::create() 静态方法来创建具有一些基本自定义逻辑的 Observables。但,关于这一点我们稍后再说。

根据它们开始发出值的时间,Observables 可以分为两组:

  • : 在这个组中,即使在没有观察者订阅的情况下也会发出值。例如,这是我们在 第一章 中使用的 RxJS 的 Rx.Observable.fromEvent响应式编程简介。这从一个任何 JavaScript 事件创建一个 Observables。值立即发出,所以当你稍后订阅这个 Observables 时,你只会收到新值,而不会收到之前发出的值。

  • : 在这个组中,当至少有一个观察者订阅时才会发出值。例如,RxPHP 的 ArrayObservable。它创建一个 Observables,并且每次我们订阅时,我们都会收到传递给 fromArray() 方法的所有值。

所有在 RxPHP 中的内置 Observables 都可以通过从 Rx\Observable 命名空间调用静态方法轻松实例化。以下列表代表了上面提到的三个 Observables:

  • RxObservable::fromArray() 方法返回 Rx\Observable\ArrayObservable

  • RxObservable::range() 方法返回 Rx\Observable\RangeObservable

  • RxObservable::fromIterator() 方法返回 Rx\Observable\IteratorObservable

不要惊讶静态方法名不一定与返回的类名匹配。此外,通常使用静态调用比直接实例化 Observables 更容易。

观察者

观察者是 Observables 的消费者。换句话说,观察者对 Observables 做出反应。我们已经看到了 CallbackObserver 类,它接受三个可选参数,代表每种类型信号的调用者。

考虑一个类似的例子,我们在 第一章 的结尾使用的例子,响应式编程简介,其中我们定义了我们的观察者:

$observer = new Rx\Observer\CallbackObserver(function($value) { 
    printf("%s\n", $value); 
}, function() { 
    print("onError\n"); 
}, function() { 
    print("onCompleted\n"); 
}); 

CallbackObserver 类允许我们创建一个自定义观察者,而不必一定扩展基类。它的构造函数接受三个可选参数:

  • onNext:当源可观察者发射新项目时,将调用此调用函数。这是我们最常用的回调之一。

  • onComplete:当没有剩余项目并且可观察者完成项目发射时,将调用此调用函数。一些可观察者会产生无限数量的项目,并且此回调永远不会被调用。

  • onError:当链中出现错误时,将调用此调用函数。

我们可以将相同的示例编写成更可重用的形式,以便快速测试可观察者链内部的情况:

// rxphp_03.php 
$fruits = ['apple', 'banana', 'orange', 'raspberry']; 

class PrintObserver extends Rx\Observer\AbstractObserver { 
    protected function completed() { 
        print("Completed\n"); 
    } 
    protected function next($item) { 
        printf("Next: %s\n", $item); 
    } 
    protected function error(Exception $err) { 
        $msg = $err->getMessage(); 
        printf("Error: %s\n", $msg); 
    } 
} 

$source = Rx\Observable::fromArray($fruits); 
$source->subscribe(new PrintObserver()); 

当扩展AbstractObserver时,我们需要实现的方法是completed()next()error(),其功能与之前描述的相同。

我们正在使用subscribe()方法将观察者订阅到可观察者上。

此外,还有subscribeCallback()方法,它只接受三个调用函数作为参数。自 RxPHP 2 以来,subscribeCallback()方法已被弃用,其功能已合并到subscribe()中。

这意味着,在 RxPHP 2 中,我们也可以编写以下代码:

$source->subscribe(function($item) { 
    printf("Next: %sn", $item); 
}); 

我们使用了一个单一的调用函数,而不是使用观察者进行订阅。这仅处理onNext信号。

单个值

单个值就像可观察者一样;唯一的区别是它们总是只发射一个值。在 RxPHP 中,我们不会区分可观察者和单个值之间的任何区别,因此我们可以使用Observable::just()静态方法:

// single_01.php/ 
require __DIR__ . '/PrintObserver.php'; 

RxObservable::just(42) 
    ->subscribe(new PrintObserver()); 

这将创建一个新的可观察者,它使用值42调用onNext(),然后立即调用onComplete()。这个非常简单的示例的输出如下:

$ php single_01.php
Next: 42
Completed

与前面的解释类似,调用RxObservable::just()静态方法返回一个Rx\Observable\ReturnObservable实例。

注意

“单个”一词在 RxJS 4 中使用得最多。由于 RxPHP 最初是从 RxJS 4 移植过来的,后来也借鉴了 RxJS 5 的一些内容,你可能会偶尔遇到这个术语。如果你只熟悉 RxJS 5,那么你可能从未听说过它。尽管如此,我们始终将所有值源称为可观察者,即使它们只发射一个或没有任何值。

主题

Subject是一个同时充当可观察者和观察者的类。这意味着它可以像观察者一样订阅可观察者,也可以像可观察者一样发射值。最终,它还可以独立于其源可观察者发射自己的值。

为了了解Subject类如何在不同的场景中使用,我们将基于本章开头使用的相同示例进行三个示例。

我们可以使用Subject类而不是可观察者。但是,我们需要通过在Subject实例上调用onNext()来手动发射项目:

// subject_01.php 
use Rx\Subject\Subject; 

$subject = new Subject(); 
$subject 
    ->map(function($value) { 
        return strlen($value); 
    }) 
    ->filter(function($len) { 
        return $len > 5; 
    }) 
    ->subscribe(new PrintObserver()); 

$subject->onNext('apple'); 
$subject->onNext('banana'); 
$subject->onNext('orange'); 
$subject->onNext('raspberry'); 

此代码产生的输出与原始示例中的可观察者相同:

$ php subject_01.php
Next: 6
Next: 6
Next: 9

另一个用例可能是使用Subject来订阅可观察者。我们将重用我们刚才创建的PrintObserver类来打印通过Subject实例的所有项目和通知:

// subject_02.php 
use Rx\Subject\Subject; 
use Rx\Observable; 

$subject = new Subject(); 
$subject->subscribe(new PrintObserver()); 

$fruits = ['apple', 'banana', 'orange', 'raspberry']; 
Observable::fromArray($fruits) 
    ->map(function($value) { 
        return strlen($value); 
    }) 
    ->filter(function($len) { 
        return $len > 5; 
    }) 
    ->subscribe($subject); 

注意,我们将PrintObserver订阅到Subject上,然后在操作符链的末尾订阅Subject。正如我们所看到的,默认情况下,Subject类只是传递两个项目和通知。输出与上一个示例相同。

我们想要展示的最终情况是在操作符链的中间使用Subject的一个实例:

// subject_03.php  
use Rx\Subject\Subject; 
use Rx\Observable; 

$fruits = ['apple', 'banana', 'orange', 'raspberry']; 

$subject = new Subject(); 
$subject 
    ->filter(function($len) { 
        return $len > 5; 
    }) 
    ->subscribe(new PrintObserver()); 

Observable::fromArray($fruits) 
    ->map(function($value) { 
        return strlen($value); 
    }) 
    ->subscribe($subject); 

再次强调,控制台输出是相同的。

在本章的后面部分,我们将编写DebugSubject类,我们将在整本书中多次使用它,以便快速查看我们的可观察链中发生了什么。

可丢弃对象

所有 Rx 实现都内部使用销毁模式。这个设计决策有两个原因:

  • 能够从可观察对象中取消订阅

  • 能够释放该可观察对象使用的所有数据

例如,如果我们有一个可观察对象,它从互联网下载一个大型文件并将其保存到临时位置,直到完全下载,我们希望在观察者取消订阅或发生任何错误时删除该临时文件。

在 RxPHP 中,已经有一些现成的类,每个类都有不同的用途。我们现在不需要担心可丢弃对象。我们将在下一章第三章,使用 RxPHP 编写 Reddit 阅读器中查看它们如何在内置的可观察对象和操作符中使用。

注意

你可以在维基百科上了解更多关于销毁模式的信息en.wikipedia.org/wiki/Dispose_pattern,或者更具体地说,为什么它在反应式扩展中在 StackOverflow 上被使用stackoverflow.com/a/7707768/310726

然而,了解在 Rx 中像释放资源这样的操作很重要,我们需要对此有所了解。

调度器

可观察对象和操作符通常不会直接执行它们的工作,而是使用Scheduler类的一个实例来决定如何以及何时执行。

实际上,Scheduler接收一个动作作为匿名函数,并根据其内部逻辑安排其执行。这对于所有需要与时间一起工作的可观察对象和操作符特别相关。例如,所有延迟或周期性发射都需要通过调度器进行安排。

在像 JavaScript 这样的语言中,这相对简单,例如使用setTimeout()函数和 JavaScript 解释器的基于事件的本质。然而,在 PHP 中,所有代码都是严格顺序执行的,我们将不得不使用事件循环。

在 RxPHP 的大多数情况下,我们甚至不需要担心调度器,因为如果没有设置不同的,所有可观察对象和操作符内部都使用ImmediateScheduler类,该类立即执行所有操作,而不需要任何进一步的逻辑。

我们将在本章的末尾再次遇到调度器,当我们讨论事件循环时。

在第六章,PHP Streams API 和高级 Observables中,我们将更详细地讨论 PHP 中的事件循环。我们还将讨论事件循环互操作性规范(github.com/async-interop/event-loop)以及它与 RxPHP 的关系。

注意

在 RxPHP 2 中,使用调度器已经显著简化,大多数时候我们根本不需要担心事件循环,正如我们将在第六章,PHP Streams API 和高级 Observables中看到的。

操作符

我们已经使用了操作符,但没有进一步的解释,但现在我们知道了如何使用 Observables、observers 和 Subjects,是时候看看操作符是如何将这些全部粘合在一起的了。

Rx 的核心原则是使用各种操作符来修改数据流。通常,一个操作符返回另一个 Observable,因此允许操作符调用的链式。

在 Rx 中,有大量的操作符,特别是在 RxPHP 中,已经有大约 40 个。其他实现,如 RxJS,甚至更多。这包括我们在上一章讨论函数式编程时看到的全部内容,例如map()filter()以及更多。这也包括针对非常特定用例的操作符,例如merge()buffer()retry(),仅举几例。

在底层创建操作符链的过程比表面上看起来要复杂一些。我们现在不需要担心它,因为我们在第三章,使用 RxPHP 编写 Reddit 阅读器中还会再次讨论它。在我们开始实际使用更高级的操作符之前,我们应该看看文档中每个操作符是如何描述的。这主要是因为一些功能一开始并不明显,而且当涉及到异步事件时,有时很难理解每个操作符的作用。

理解操作符图表

每个操作符都在文档中使用称为宝石图的图表进行描述,其中每个宝石代表一个发出的值。

filter()操作符

首先,我们将看看filter()操作符是如何定义的。我们在上一章中使用了 PHP 函数array_filter(),所以我们知道它接受值和谓词函数作为输入。然后它使用谓词评估每个值,并根据它返回 true 或 false,将其值添加到其响应数组中或跳过该值。filter()操作符的行为相同,只是它处理的是数据流而不是数组。这意味着它从其源(前面的 Observable)接收项目,并将它们传播到其后续观察者(或链式操作符)。

使用水滴图,它看起来像以下图示:

filter() 操作符

来自 http://reactivex.io/documentation/operators/filter.html 的 filter() 操作符的水滴图

让我们更详细地解释这个图:

  • 在顶部和底部,我们有两条时间线,代表 Observables。右上角的箭头表明时间从左到右流动。

  • 我们可以将矩形以上的内容视为输入 Observable,将矩形以下的内容视为输出 Observable。通常有一个或多个输入,只有一个输出。

  • 每个圆圈(水滴)代表其各自的 Observable 在时间上发出的单个值。圆圈内部的数字代表其值。所有值按它们发出的时间顺序排列,从左到右。使用不同的颜色使其明显,顶部和底部的值是相同的(例如,顶部的蓝色 "30" 与底部的 "30" 是相同的值)。

  • 中间的矩形代表顶部和底部 Observables 之间的转换。其功能通常用文字或伪代码描述。在这种情况下,我们有一个看起来像 ES6 语法表达式的表达式,它表示如果 x 大于 10 则返回 true。用 PHP 重新编写,它等于以下内容:

        function($x) { 
            return $x > 10; 
        } 

  • 因此,最下面的一行只包含值大于 10 的圆圈。

  • 每一行的右侧垂直线标记了这些 Observables 完成的点。这意味着它们已经发出了所有值并发送了一个 onComplete 通知。filter() 操作符对 onComplete 通知没有影响,因此这两个 Observables 同时结束。

这相当简单。水滴图是一种非常舒适的方式来表示数据流,而不必担心实现细节(这让我们想起了我们在第一章中定义的声明式编程,不是吗?)。

在某些图中,你还可以看到时间线上的一个交叉符号,它代表一个错误(一个精确的 onError 通知)。我们将在本章后面看到,我们可以像处理 onNext 一样处理 onCompleteonError 通知。

debounceTime() 操作符

让我们看看另一个图。这次我们有一个来自 RxJS 5 的 debounceTime() 操作符,我们在第一章的 使用 RxJS 实现自动完成 示例中见过:

debounceTime() 操作符

来自 http://reactivex.io/documentation/operators/debounce.html 的 debounceTime() 操作符的水滴图

在中间的矩形中,这次我们没有伪代码;只有一个表达式 debounceTime(20)。好吧,为了弄清楚它做什么,我们需要查看文档,或者尝试分析这个图。

debounceTime()操作符接收到一个值时,它会等待一定的时间间隔后再重新发出它。如果在间隔结束之前有其他值到达,原始值将被丢弃,并使用后来的值;同时,间隔也会重新开始。这可以无限进行下去。

图表精确地描述了上一段文字:

  • 首先,值a到达。转换函数等待 20 毫秒的间隔结束,然后操作符重新发出该值。间隔通过在时间线上稍微向右移动底部值来表示。正如我们之前所说的,水平线代表时间中的值。当标记为a的底部圆圈向右移动时,这意味着这个事件发生在顶部的a圆圈之后。

  • 然后,又有两个值到达,它们都非常短。第一个被丢弃,但在第二个之后,有一个更长的时间间隔,所以只有第二个值被重新发出。

  • 使用最后一个值d的过程与第一个类似。

当我们知道可以忽略一些快速连续发生的事件时,这个操作符很有用。一个典型的例子是在用户停止输入关键字后,我们想要开始搜索时使用debounceTime()进行自动完成功能。

concat操作符

现在我们可以看看一个稍微复杂一些的操作符,即concat()。看看下面的图表,并尝试猜测它做什么:

The concat operator

来自 http://reactivex.io/documentation/operators/concat.html 的 Marble 图表表示concat()操作符

在查看文档之前,让我们一起来分析一下:

  • 在顶部,我们有两个可观测量作为操作符的输入。

  • 两个可观测量应该同时发出一个值,但只有第一个可观测量的值被传递。同样,对于第二个和第三个值也是如此。

  • 然后,第一个可观测量到达了末尾,并发送了一个onComplete通知。

  • 紧接着,操作符开始从第二个可观测量发出值。

concat()操作符将多个可观测量合并成一个。它内部按顺序订阅每个输入可观测量,一个接一个。这意味着,当第一个可观测量完成时,它会订阅下一个。重要的是要知道,一次只有一个源可观测量被订阅(我们将在第四章,响应式与典型事件驱动方法)中与concat()和类似的merge()操作符一起工作)。

换句话说,concat()操作符将多个数据流连接成一个单一的流。

在第一章中,我们讨论了函数式编程以及大多数原则在响应式编程中是相同的。实现这样的功能会比较复杂,因为没有内置的 PHP 函数专门用于处理这种用例。

如果我们再次回到第一章,我们说过响应式编程的一个关键概念是“轻松表达数据流”。这个操作符展示了这意味着什么。

其他常见操作符

这些只是 RxPHP 中超过 40 个可用操作符中的三个。除了非常简单的如 filter()map() 之外,还有一些更复杂的操作符。我们已经看到了 concat(),但这里有一些在后续章节中会使用的有趣操作符:

  • buffer(): 这个操作符有多个变体,但所有变体都会收集接收到的值,并以预定义大小的组重新发出。例如,我们可以创建包含三个项目的组如下:

            Rx\Observable::range(1, 4) 
                ->bufferWithCount(3) 
                ->subscribe(new DebugSubject()); 
    
    

    这将打印以下输出:

            13:58:13 [] onNext: [1, 2, 3] (array)
            13:58:13 [] onNext: [4] (array)
            13:58:13 onCompleted
    
    

    注意

    注意,最后一个数组只包含一个值,因为可观察对象发送了一个 onComplete 通知。

  • merge(): 这个操作符将所有输入可观察对象合并为一个输出可观察对象,并立即重新发出所有值(与 concat() 相反)。

  • distinct(): 这个操作符只重新发出之前未通过此操作符的值。

  • take(): 这个操作符只重新发出到达操作符的第一个设定数量的值,然后发送一个 onComplete 通知。

  • retry(): 当源可观察对象发送 onError 时,这个操作符会尝试自动重新订阅。你也可以让它只尝试有限次数,直到发出 onError 通知(我们将在 第四章,响应式与典型事件驱动方法)。

  • catchError(): 当发生 onError 通知时,这个操作符允许我们通过订阅其回调返回的另一个可观察对象来继续操作。

  • toArray(): 这个操作符从其源可观察对象收集所有项目,并在源可观察对象完成时将它们作为一个单独的数组重新发出。

  • timeout(): 如果在特定时间段内没有到达任何值,这个操作符会发送一个 onError 通知。

理论已经足够,让我们开始编写我们的第一个自定义类,我们将在整本书中多次使用它。

编写 DebugSubject 类

Subject 类的一个常见用途是代理其源可观察对象的所有值和通知。

在前面的段落之一中,我们编写了 PrintObserver 类,它打印它接收到的所有值。然而,更常见的情况是我们希望在输出可观察对象的同时,能够将其与另一个操作符或观察者链式连接。Subject 类正好符合这个用例,因此我们将重写前面的 PrintObserver 类,并继承 Subject 而不是 AbstractObserver

class DebugSubject extends Rx\Subject\Subject { 
  public function __construct($identifier=null, $maxLen=64){ 
    $this->identifier = $identifier; 
    $this->maxLen = $maxLen; 
  } 
  public function onCompleted() { 
    printf("%s%s onCompleted\n", $this->getTime(), $this->id());
    parent::onCompleted(); 
  }  
  public function onNext($val) { 
    $type = is_object($val) ? get_class($val) : gettype($val); 

    if (is_object($val) && method_exists($val, '__toString')) { 
      $str = (string)$val; 
    } elseif (is_object($val)) { 
      $str = get_class($val); 
    } elseif (is_array($val)) { 
      $str = json_encode($val); 
    } else { 
      $str = $val; 
    } 

    if (is_string($str) && strlen($str) > $this->maxLen) { 
      $str = substr($str, 0, $this->maxLen) . '...'; 
    } 
    printf("%s%s onNext: %s (%s)\n", 
        $this->getTime(), $this->id(), $str, $type); 
    parent::onNext($value); 
  } 
  public function onError(Exception $error) { 
    $msg = $error->getMessage(); 
    printf("%s%s onError (%s): %s\n", $this->getTime(),$this-> 
        $this->id(), get_class($error), $msg); 
    parent::onError($error); 
  } 
  private function getTime() { 
    return date('H:i:s'); 
  } 
  private function id() { 
    return ' [' . $this->identifier . ']'; 
  } 
} 

DebugSubject类会打印出所有值、它们的类型以及它们被DebugSubject接收的时间。它还允许我们为每个DebugSubject实例设置一个唯一的标识符,以便能够区分它们的输出。我们将在整本书中多次使用这个类,以便快速查看 Observable 链内部的情况。

然后,使用这个类就像使用任何其他观察者一样:

// rxphp_04.php 
$fruits = ['apple', 'banana', 'orange', 'raspberry']; 
$observer = Rx\Observable::fromArray($fruits) 
    ->subscribe(new DebugSubject()); 

控制台输出如下:

$ php rxphp_04.php
17:15:21 [] onNext: apple (string)
17:15:21 [] onNext: banana (string)
17:15:21 [] onNext: orange (string)
17:15:21 [] onNext: raspberry (string)
17:15:21 [] onCompleted

链式连接 Subjects 和操作符与连接 Observables 一样:

// rxphp_05.php 
$subject = new DebugSubject(1); 
$subject 
    ->map(function($item) { 
        return strlen($item); 
    }) 
    ->subscribe(new DebugSubject(2)); 

$observable = Rx\Observable::fromArray($fruits); 
$observable->subscribe($subject); 

在这个例子中,我们首先创建了一个DebugSubject实例,然后使用map()操作符将其链式调用,该操作符返回每个项目的长度。最后,我们订阅了另一个只打印数字的DebugSubject,因为它放在map()之后。然后,我们从一个数组创建了一个 Observable(我们之前已经看到过这个静态方法),它将作为所有项目的源发出。结果如下:

17:33:36 [1] onNext: apple (string)
17:33:36 [2] onNext: 5 (integer)
17:33:36 [1] onNext: banana (string)
17:33:36 [2] onNext: 6 (integer)
17:33:36 [1] onNext: orange (string)
17:33:36 [2] onNext: 6 (integer)
17:33:36 [1] onNext: raspberry (string)
17:33:36 [2] onNext: 9 (integer)
17:33:36 [1] onCompleted
17:33:36 [2] onCompleted

注意,消息的顺序与我们的假设相符,即源 Observable 一次发出一个值,这个值在整个链中传播。

注意

使用我们这样的 Subjects 有一个重要的副作用,不是很明显。由于我们将其订阅到前面的 Observable 上,它将其从“冷”状态转换为“热”状态,这在某些用例中可能是不希望的。

RxPHP 提供了一系列以“doOn”前缀开头的操作符,目的是将它们放置在操作符链中,以执行副作用而无需订阅 Observable。我们将在第五章中更详细地介绍它们,测试 RxPHP 代码

编写 JSONDecodeOperator

我们将在整本书中多次处理对远程 API 的调用,因此有一个将 JSON 字符串响应转换为 PHP 数组表示的操作符将非常有用。

这个例子看起来只需要使用map()操作符就可以轻松完成:

// rxphp_06.php  
Rx\Observable::just('{"value":42}') 
    ->map(function($value) { 
        return json_decode($value, true); 
    }) 
    ->subscribe(new DebugSubject()); 

这肯定能打印出正确的结果,正如我们可以在下面的输出中看到:

$ php rxphp_06.php
16:39:50 [] onNext: {"value": 42} (array)
16:39:50 [] onCompleted

好吧,但是关于格式错误的 JSON 字符串呢?如果我们尝试解码以下内容会发生什么:

Rx\Observable::just('NA') 
    ->map(function($value) { 
        return json_decode($value, true); 
    }) 
    ->subscribe(new DebugSubject()); 

函数json_decode()在尝试处理无效的 JSON 字符串时不会抛出异常;它只返回null

15:51:06 [] onNext:  (NULL)

这可能不是我们想要的。如果 JSON 字符串无效,那么就会出现问题,因为这种情况不应该发生,我们希望发送一个onError通知。

如果我们想了解任何关于发生错误的进一步信息,我们必须调用json_last_error()。因此,这是一个编写自定义操作符的绝佳机会,该操作符可以解码 JSON 字符串,如果发生任何错误,将发送一个onError

所有操作符都实现了OperatorInterface__invoke()方法。这个所谓的“魔法”方法从 PHP 5.3+开始支持,允许使用对象作为函数:

// __invoke.php 
class InvokeExampleClass { 
    public function __invoke($x) { 
        echo strlen($x); 
    } 
} 
$obj = new InvokeExampleClass(); 
$obj('apple'); 
var_dump(is_callable($obj)); 

当类实现了 __invoke() 方法时,它也会自动被视为可调用的:

$ php __invoke.php
int(5)
bool(true)

编写操作符非常相似。我们的类的存根将如下所示:

// JSONDecodeOperator.php 
use Rx\ObservableInterface as ObservableI; 
use Rx\ObserverInterface as ObserverI; 
use Rx\SchedulerInterface as SchedulerI; 
use Rx\Operator\OperatorInterface as OperatorI; 

class JSONDecodeOperator implements OperatorI { 
    public function __invoke(ObservableI $observable, 
            ObserverI $observer, SchedulerI $scheduler = null) { 
        // ... 
    } 
} 

方法 __invoke() 接受三个参数并返回一个可丢弃的对象。现在,我们只使用前两个参数,而不用担心 $scheduler

  • ObservableInterface $observable:这是我们订阅的输入可观察对象

  • ObserverInterface $observer:这是我们从这个操作符发出所有输出值的地方

我们将遵循与编写自定义 Subject 类几乎相同的原理。我们将使用 CallbackObserver 订阅可观察对象并执行所有逻辑:

class JSONDecodeOperator implements OperatorI { 
  public function __invoke(ObservableI $observable, 
      ObserverI $observer, SchedulerI $scheduler = null) { 

    $obs = new CallbackObserver( 
      function ($value) use ($observer) { 
        $decoded = json_decode($value, true); 
        if (json_last_error() == JSON_ERROR_NONE) { 
          $observer->onNext($decoded); 
        } else { 
          $msg = json_last_error_msg(); 
          $e = new InvalidArgumentException($msg); 
          $observer->onError($e); 
        } 
      }, 
      function ($error) use ($observer) { 
        $observer->onError($error); 
      }, 
      function () use ($observer) { 
        $observer->onCompleted(); 
      } 
    ); 

    return $observable->subscribe($obs, $scheduler); 
  } 
} 

有几点有趣的事情需要注意:

  • 当发生 onErroronComplete 通知时,我们只是将它们传递下去,而不进行任何进一步的逻辑处理。

  • 操作符可以在任何时候发送任何信号。在 CallbackObserver 类的 onNext 闭包内部,我们检查从源可观察对象接收到的输入 JSON 字符串解码时是否发生了任何错误,使用 json_last_error()

  • 操作符可以完全访问源可观察对象。

  • 操作符可以独立于源可观察对象中的值独立发出值。

为了使用我们的操作符,我们必须使用 Observable::lift() 方法,该方法接受一个闭包作为参数,该闭包需要返回一个操作符的实例(此函数被称为操作符工厂):

// rxphp_07.php 
Rx\Observable::just('{"value":42}') 
    ->lift(function() { 
        return new JSONDecodeOperator(); 
    }) 
    ->subscribe(new DebugSubject()); 

在 RxPHP 2 中,使用自定义操作符大大简化了,但使用 lift() 方法是通用的,并且在 RxPHP 的两个版本中都适用。

有效的 JSON 字符串按预期解码:

$ php rxphp_07.php
17:58:49 [] onNext: {"value": 42} (array)
17:58:49 [] onCompleted

另一方面,我们上面使用的相同无效 JSON 字符串不会调用 onNext,而是调用 onError。它使用 InvalidArgumentException 类的实例和 json_last_error_msg() 的错误消息发送此通知,如下面的输出所示:

17:59:25 onError (InvalidArgumentException): Syntax error

如同往常,我们将在这个书中重用这个类。下一章将大量处理远程 API,因此这个操作符将非常方便。

简化通知的传播

JSONDecodeOperator 类中,我们不想修改 onErroronComplete 通知,只是将它们传递下去。然而,由于 PHP 与可调用对象的工作方式,有更简单的方法来做这件事。一个有效的可调用对象也是一个包含两个项目的数组:一个对象和一个方法名。

这意味着我们可以将上面的 CallbackObserver 实例化重写如下:

$callbackObserver = new CallbackObserver( 
    function ($value) use ($observer) { 
        // ... 
    }, 
    [$observer, 'onError'], 
    [$observer, 'onCompleted'] 
); 

功能完全相同。我们不需要为每个通知创建一个匿名函数,只需直接传递可调用即可。

在 RxPHP 2 中使用自定义操作符

在 第一章,响应式编程简介中,我们提到了一个神奇的 __call() 方法。RxPHP 2 使用此方法允许通过在两个命名空间格式中自动发现它们来使用自定义操作符。

第一种选项是在 Rx\Operator 命名空间下定义我们的操作符类:

// JSONDecodeOperator.php 
namespace Rx\Operator; 

use Rx\ObservableInterface as ObservableI; 
use Rx\ObserverInterface as ObserverI; 
use Rx\Operator\OperatorInterface as OperatorI; 
use Rx\DisposableInterface as DisposableI; 

class JSONDecodeOperator implements OperatorI { 
  public function __invoke(ObservableI $observable, 
      ObserverI $observer): DisposableI { 

   return $observable->subscribe( 
     function ($value) use ($observer) { 
       $decoded = json_decode($value, true); 
       if (json_last_error() == JSON_ERROR_NONE) { 
         $observer->onNext($decoded); 
       } else { 
         $msg = json_last_error_msg(); 
         $e = new InvalidArgumentException($msg); 
         $observer->onError($e); 
       } 
     }, 
     [$observer, 'onError'], 
     [$observer, 'onCompleted'] 
   ); 
  } 
} 

这是相同的 JSONDecodeOperator 类,只是针对 RxPHP 2 更新了。使用此操作符非常简单:

Observable::just('{"value":42}') 
    ->JSONDecode() 
    ->subscribe(new DebugSubject()); 

由于我们的操作符位于 Rx\Operator 命名空间下,它通过 __call() 方法扩展为 Rx\Operator\JSONDecodeOperator。这意味着我们根本不需要使用 lift() 方法。

另一种方法是使用下划线 _ 前缀操作符名称和命名空间,然后它们将被合并为一个完整的类名。这意味着我们可以将所有特定于应用程序的操作符放在一个自定义命名空间下:

// JSONDecodeOperator.php 
namespace MyApp\Rx\Operator; 
... 
class JSONDecodeOperator implements OperatorI { ... } 

现在,我们可以按以下方式使用操作符:

Observable::just('{"value":42}') 
    ->_MyApp_JSONDecode() 
    ->subscribe(new DebugSubject()); 

编写 CURLObservable

正如我们所说,我们将与 API 调用一起工作,因此我们需要一种舒适的方式来创建 HTTP 请求。可能不会令人惊讶,我们将编写一个自定义的 Observable,它下载一个 URL 并将其响应传递给观察者,然后我们将使用上面创建的操作符从 JSON 中解码它。

我们将使用 PHP 的 cURL 模块,它是对 libcurl(curl.haxx.se/libcurl/)的封装 - 一个用于通过任何可想象协议传输数据的 C 库。

我们将首先使用 PHP 中的简单 cURL,我们将看到它支持某种异步方法。

命令式方法和 cURL

如果我们只想下载单个 URL,我们不需要任何特殊的东西。然而,我们希望使这个,以及所有未来的 CURLObservable 类的应用,更加交互式,因此我们还将跟踪下载进度。

一种简单的方法可能如下所示:

// curl_01.php 
$ch = curl_init(); 
curl_setopt($ch, CURLOPT_URL, "http://google.com"); 
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 
curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, 'progress'); 
curl_setopt($ch, CURLOPT_NOPROGRESS, false); 
curl_setopt($ch, CURLOPT_HEADER, 0); 
$html = curl_exec($ch); 
curl_close($ch); 

function progress($res, $downtotal, $down, $uptotal, $up) { 
    if ($download_size > 0) { 
        printf("%.2f\n", $down / $downtotal * 100); 
    } 
    ob_flush(); 
    usleep(100 * 1000); 
} 

我们正在使用 CURLOPT_PROGRESSFUNCTION 选项来设置一个回调函数,该函数由 cURL 模块内部调用。它接受四个参数,帮助我们跟踪页面总大小已下载的百分比。

我们可能不需要显示其输出,因为它非常明显。

此外,还有一小部分 cURL 函数可以同时与多个 cURL 处理程序一起工作。这些函数都以前缀 curl_multi_ 开头,并通过调用 curl_multi_exec() 来执行。不过,curl_multi_exec() 函数是阻塞的,解释器需要等待它完成。

将 cURL 实现到自定义的 Observable 中

我们已经看到了如何编写自定义观察者、Subject 和操作符。现在是我们编写 Observable 的正确时机。我们希望 Observable 在下载 URL 时发出值,并在最后返回一个完整的响应。我们可以通过检查它们的类型来区分这两种类型的消息。进度始终是双精度浮点数,而响应始终是字符串。

让我们从我们的类概要开始,看看它将如何工作,然后分别实现每个方法,并附上简短说明:

use Rx\Observable; 
use Rx\ObserverInterface as ObserverI; 

class CURLObservable extends Observable { 
    public function __construct($url) {} 
    public function subscribe(ObserverI $obsr, $sched = null) {} 
    private function startDownload() {} 
    private function progress($r, $downtot, $down, $uptot, $up) {} 
} 

每次我们编写一个 Observable,我们都会扩展基类 Rx\Observable。理论上我们可以只实现 Rx\ObservableInterface,但大多数情况下,我们还想继承其所有内部逻辑和所有现有操作符。

构造函数和方法 startDownload() 将会非常简单。在 startDownload() 中,我们开始下载 URL 并监控其进度。

请注意,此代码位于 CURLObservable 类内部;我们只是试图使代码更短、更易于阅读,所以在本例中省略了缩进和类定义:

public function __construct($url) { 
    $this->url = $url; 
} 

private function startDownload() { 
    $ch = curl_init(); 
    curl_setopt($ch, CURLOPT_URL, $this->url); 
    curl_setopt($ch, CURLOPT_PROGRESSFUNCTION,[$this,'progress']); 
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 
    curl_setopt($ch, CURLOPT_NOPROGRESS, false); 
    curl_setopt($ch, CURLOPT_HEADER, 0); 
    curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 ...'); 
    // Disable gzip compression 
    curl_setopt($ch, CURLOPT_ENCODING, 'gzip;q=0,deflate,sdch'); 
    $response = curl_exec($ch); 
    curl_close($ch); 

    return $response; 
} 

这基本上与使用命令式方法的示例相同。唯一的有趣差异是我们使用了一个可调用的 [$this,'progress'] 而不是像之前那样只使用函数名。

实际的值发射发生在 progress() 方法内部:

private function progress($res, $downtotal, $down, $uptotal, $up){ 
    if ($downtotal > 0) { 
        $percentage = sprintf("%.2f", $down / $downtotal * 100); 
        foreach ($this->observers as $observer) { 
            /** @var ObserverI $observer */ 
            $observer->onNext(floatval($percentage)); 
        } 
    } 
} 

由于我们继承了原始 Observable,我们可以利用其受保护的属性 $observers,它持有所有已订阅的观察者,正如其名称所暗示的。要向所有这些观察者发射值,我们可以简单地遍历数组并在每个观察者上调用 onNext

我们迄今为止还没有看到的方法是 subscribe()

public function subscribe(ObserverI $obsr, $sched = null) { 
    $disp1 = parent::subscribe($obsr, $sched); 

    if (null === $sched) { 
        $sched = new ImmediateScheduler(); 
    } 

    $disp2 = $sched->schedule(function() use ($obsr, $started) { 
        $response = $this->startDownload(); 
        if ($response) { 
            $obsr->onNext($response); 
            $obsr->onCompleted(); 
        } else { 
            $msg = 'Unable to download ' . $this->url); 
            $obsr->onError(new Exception($msg)); 
        } 
    }); 

    return new CompositeDisposable([$disp1, $disp2]); 
} 

此方法结合了本章中我们看到的一些东西:

  • 我们肯定想保留 Observable 的原始功能,所以我们将调用其父实现。这会将观察者添加到之前提到的观察者数组中。

  • parent::subscribe() 方法返回一个可丢弃的对象。这是我们用来从 Observable 中取消订阅观察者的对象。

  • 如果我们没有指定此 Observable 应该使用哪个调度器,它将回退到 ImmediateScheduler。当我们谈论调度器时,我们已经提到了 ImmediateScheduler。在 RxPHP 2 中,我们将使用 Scheduler::getImmediate() 而不是直接使用类名。

  • 随后,我们安排工作(在调度器的术语中通常称为“动作”)由调度器执行。请注意,动作本身是一个闭包。

  • 然后,我们开始下载 URL。如果我们向同一个 Observable 订阅另一个观察者,它将再次下载相同的 URL。下载进度将根据 cURL 的内部频率发射。我们将在下一章中更多地讨论订阅过程。

  • 下载完成后,我们发出响应或错误。

  • 在此方法结束时,它返回另一个可丢弃的对象。这次,它使用 CompositeDisposable 来包装其他可丢弃的对象。当调用其 dispose() 方法时,这些包装的对象也会被正确地处理。

所以,就是这样。现在我们可以测试我们的 Observable 并查看其输出。我们可以尝试获取带有 functional-programming 标签的 www.stackoverflow.com 上最新的问题列表:

$url = 'https://api.stack...&tagged=functional-programming'; 
$observable = new CurlObservable($url); 
$observable->subscribe(new DebugSubject()); 

这将打印出几个数字,然后是响应的 JSON 字符串:

16:17:52 onNext: 21.39 (double)
16:17:52 onNext: 49.19 (double)
16:17:52 onNext: 49.19 (double)
16:17:52 onNext: 76.99 (double)
16:17:52 onNext: 100 (double)
16:17:52 onNext: {"items":[{"tags":["javascript","... (string)
16:17:52 onCompleted

你可以看到一个值被发射了两次。这是因为 cURL 评估回调时的时机和网络延迟,这并不罕见。如果我们不想看到重复的值,我们可以使用我们在“宝石图”中提到的distinct()操作符。

现在,让我们将其与我们的JSONDecodeOperator结合起来。由于我们现在只对字符串响应感兴趣,并希望忽略所有进度发射,我们还将使用filter()操作符:

// rxphp_curl.php 
$observable 
    ->filter(function($value) { 
        return is_string($value); 
    }) 
    ->lift(function() { 
        return new JSONDecodeOperator(); 
    }) 
    ->subscribe(new DebugSubject(null, 128)); 

这返回了响应数组的部分(为了演示目的,我们添加了缩进并使输出略微变长):

$ php rxphp_curl.php 
16:23:55 [] onNext: { 
    "items": [ 
        { 
            "tags": [ 
                "javascript", 
                "functional-programming", 
       ... (array) 
16:23:55 [] onCompleted 

当我们使用filter()操作符时,你可能注意到我们没有必要使用lift()方法就调用了Observable::filter()。这是因为几乎所有操作符实际上都是带有预定义闭包的lift()调用,这些闭包返回适当的操作符类。一个有趣的问题是,当我们已经扩展了基础 Observable 类时,我们是否可以为我们自己的JSONDecodeOperator编写一个简写。也许可以像Observable::jsonDecode()一样?

答案是肯定的,我们可以。然而,在 RxPHP 1.x 版本中,这并不会给我们带来太多帮助。当我们链式调用操作符时,它们返回的不是我们控制的 Observable 实例。理论上,我们可以在创建CurlObservable之后立即使用Observable::jsonDecode(),因为我们知道它将是一个此类实例,但与filter()链式调用会带我们回到原始的 Observable,它不知道任何jsonDecode()方法。特别是,filter()操作符返回一个Rx\Observable\AnonymousObservable实例。

异步运行多个请求

一个有趣的使用案例可能是异步启动多个请求。所有对curl_exec()的调用都是阻塞的,这意味着它们会阻塞执行上下文,直到完成。

很不幸,这是一个非常棘手的问题,没有使用任何额外的 PHP 模块(例如pthreads)就很难解决,正如我们将在第九章第九章中看到的,使用 pthreads 和 Gearman 的多线程和分布式计算

然而,我们可以利用 PHP 的proc_open()标准函数来创建非阻塞的子进程,这些子进程可以并行运行,然后只需请求它们的输出。

proc_open()和阻塞的 fread()

我们的目标是拥有异步启动各种子进程的手段。在这个例子中,我们将使用一个简单的 PHP 脚本,它将简单地暂停几秒钟,代表我们的异步任务:

// sleep.php 
$name = $argv[1]; 
$time = intval($argv[2]); 
$elapsed = 0; 

while ($elapsed < $time) { 
    sleep(1); 
    $elapsed++; 
    printf("$name: $elapsed\n"); 
} 

此脚本接受两个参数。第一个是我们选择的标识符,我们将用它来区分多个进程。第二个是脚本将运行多少秒,同时打印其名称和每秒经过的时间。例如,我们可以运行:

$ sleep.php proc1 3
proc1: 1
proc1: 2
proc1: 3

现在,我们将编写另一个使用proc_open()来创建子进程的 PHP 脚本。正如我们所说的,我们需要脚本是非阻塞的。这意味着我们需要能够读取子进程的输出,就像使用上面的printf()打印一样,同时如果需要,能够创建更多的子进程:

// proc_01.php 
$proc = proc_open('php sleep.php proc1 3', [ 
    0 => ['pipe', 'r'], // stdin 
    1 => ['pipe', 'w'], // stdout 
    2 => ['file', '/dev/null', 'a'] // stderr 
], $pipes); 

stream_set_blocking($pipes[1], 0); 

while (proc_get_status($proc)['running']) { 
    usleep(100 * 1000); 
    $str = fread($pipes[1], 1024); 
    if ($str) { 
        printf($str); 
    } else { 
        printf("tickn"); 
    } 
} 
fclose($pipes[1]); 
proc_close($proc); 

我们启动一个子进程php sleep.php proc1 3,然后进入循环。每 100 毫秒检查一次,看是否有来自子进程的新输出使用fread()。如果有,就打印它;否则,只写“tick”。循环将在子进程终止时结束(这是proc_get_status()函数的条件)。

在这个例子中,最重要的是调用stream_set_blocking()函数,这使得此流的操作非阻塞。

事件循环和 RxPHP

将事件循环应用于 Observables 将以类似的方式工作。我们将创建 Observables,启动一个事件循环,并定期检查它们的进度。幸运的是,RxPHP 已经为此做好了准备。结合 ReactPHP 库(github.com/reactphp/react),我们可以使用一个专为我们的需求设计的调度器。

例如,我们可以查看周期性地发出值的IntervalObservable

// rxphp_eventloop.php 
$loop = new ReactEventLoopStreamSelectLoop(); 
$scheduler = new RxSchedulerEventLoopScheduler($loop); 

RxObservable::interval(1000, $scheduler) 
    ->take(3) 
    ->subscribe(new DebugSubject()); 

$loop->run(); 

这将打印三个值,每个值之间有 1 秒的延迟:

$ php rxphp_eventloop.php
23:12:44 [] onNext: 0 (integer)
23:12:45 [] onNext: 1 (integer)
23:12:46 [] onNext: 2 (integer)
23:12:46 [] onCompleted

注意

在 RxPHP 2 中,使用事件循环已经简化了,大多数时候我们甚至不需要担心自己启动循环。我们将在第六章中讨论 RxPHP 1.x 和 RxPHP 2 在事件循环方面的差异,PHP Streams API 和高级观察者

摘要

在本章中,我们更详细地研究了 RxPHP 的所有组件。

尤其是我们在 Rx 中看到了所有三种类型的通知:Observables、观察者、Subjects、Singles 和操作符。在实践示例中,我们设计了自定义的观察者、Subject、Observable 和一个操作符。我们将在接下来的章节中使用所有这些。

我们看到有关 Rx 操作符的文档通常以“宝石图”的形式描述。

下一章将利用本章所做的一切。我们将创建一个使用 RxPHP 和 Symfony Console 组件的 CLI Reddit 阅读器。我们还将更深入地讨论 Observable 链中的订阅过程。

第三章. 使用 RxPHP 编写 Reddit 阅读器

在前面的章节中,我们讨论了很多关于 PHP 中的异步编程以及它与反应式编程的关系,特别是如何开始使用 RxPHP,以及如何异步使用常见的 PHP 函数,如proc_open()cURL

本章将涵盖使用 RxPHP、Symfony Console 和 Symfony Process 组件编写 CLI Reddit 阅读器应用。我们还将使用上一章中学到的几乎所有内容:

  • 我们将更深入地探讨在创建可观察对象链和订阅可观察对象时内部发生的事情。

  • 我们将了解 Disposables 在 RxPHP 默认类中的使用方式,以及这些如何在我们的应用中取消订阅可观察对象时变得有用。

  • 当与操作符链一起工作时,Subject 有时可以简化我们的生活。

  • 如何使用Observable::create()Observable::defer()静态方法来创建具有自定义订阅逻辑的新可观察对象。

  • Symfony Console 库将成为我们在本书中大多数 CLI 交互中的首选工具。在我们开始使用它之前,我们将快速看一下它的实际好处。

  • 上一章的事件循环将成为我们应用的核心。我们将利用它使应用在任何给定时间都能做出响应(我们也可以说,是反应式的)。

  • 为了方便地处理子进程,我们将使用 Symfony Process 组件,它为我们处理所有与子进程管理相关的繁重工作。

  • 我们将使用我们在实践中已经看到的非阻塞流处理,结合来自终端的输入和来自子进程的输出。

  • 我们将列出 RxPHP 提供的一次性类。

在我们深入之前,现在是仔细观察 RxPHP 内部功能的好时机,到目前为止这并不是很重要。然而,这种知识将在本章节和接下来的大多数章节中变得至关重要。

检查 RxPHP 的内部结构

在上一章中,我们简要提到了 Disposables 作为释放观察者、可观察对象、Subject 等使用的资源的一种手段。在实践中,当订阅一个可观察对象时,会返回一个一次性操作,例如,以下是从默认的Rx\Observable::subscribe()方法中的代码:

function subscribe(ObserverI $observer, $scheduler = null) { 
    $this->observers[] = $observer; 
    $this->started = true; 

    return new CallbackDisposable(function () use ($observer) { 
        $this->removeObserver($observer); 
    }); 
} 

此方法首先将观察者添加到所有已订阅观察者的数组中。然后,它将此可观察对象标记为已启动(记住“冷”和“热”可观察对象之间的区别,参见第二章,使用 RxPHP 进行反应式编程),最后,它返回一个CallbackDisposable类的新实例。这个类接受一个闭包作为参数,并在它被销毁时调用它。这可能是 Disposables 最常见的使用场景。

这个一次性操作仅从数组中移除观察者,因此,它将不再接收从这个可观察对象发出的任何值。

仔细观察订阅可观察对象

应该很明显,Observables 需要以这种方式工作,以便所有订阅的观察者都可以迭代。然后,通过可取消订阅的实例取消订阅需要从所有已订阅观察者的数组中删除一个特定的观察者。

然而,如果我们看看大多数默认的 Observables 是如何工作的,我们会发现它们总是覆盖Observable::subscribe()方法,并且通常完全省略应该持有订阅者数组的部分。相反,它们只是向已订阅的观察者发出所有可用的值,并在之后立即发出onComplete()信号。例如,我们可以查看 RxPP 1 中Rx\ReturnObservable类的subscribe()方法的实际源代码:

function subscribe(ObserverI $obs, SchedulerI $sched = null) { 
    $value = $this->value; 
    $scheduler = $scheduler ?: new ImmediateScheduler(); 
    $disp = new CompositeDisposable(); 

    $disp->add($scheduler->schedule(function() use ($obs, $val) { 
        $obs->onNext($val); 
    })); 
    $disp->add($scheduler->schedule(function() use ($obs) { 
        $obs->onCompleted(); 
    })); 

    return $disp; 
} 

ReturnObservable类在其构造函数中接受一个单一值,并将此值发送给每个订阅的观察者。

以下是一个 Observable 的生命周期可能看起来很棒的例子:

  • 当观察者订阅时,它会检查是否也传递了一个调度器作为参数。通常情况下,并没有传递调度器,因此它会创建一个ImmediateScheduler实例。请注意,在 RxPHP 2 中,调度器只能在类构造函数中设置。

  • 然后,创建一个CompositeDisposable实例,它将保留这个方法使用的所有可取消订阅的实例数组。当调用CompositeDisposable::dispose()时,它会迭代它包含的所有可取消订阅的实例,并调用它们的相应dispose()方法。

  • 在此之后,我们开始用以下内容填充我们的CompositeDisposable

        $disposable->add($scheduler->schedule(function() { ... })); 

  • 这是我们经常会看到的事情。SchedulerInterface::schedule()方法返回一个DisposableInterface,它负责取消操作并释放资源。在这种情况下,当我们使用没有其他逻辑的ImmediateScheduler时,它只是立即评估闭包:
        function () use ($obs, $val) { 
            $observer->onNext($val); 
        } 

  • 由于ImmediateScheduler::schedule()不需要释放任何资源(它没有使用任何资源),它只是返回一个Rx\Disposable\EmptyDisposable实例,这个实例实际上什么也不做。

  • 然后返回一个可取消订阅的实例,可以用来从这个 Observable 中取消订阅。然而,正如我们在前面的源代码中看到的,这个 Observable 不允许你取消订阅,如果我们仔细想想,这甚至没有意义,因为ReturnObservable类的值在订阅时立即发出。

同样的情况也适用于其他类似的Observables,例如IteratorObservableRangeObservableArrayObservable。这些只是包含调度器的递归调用,但原理是相同的。

一个好问题是,为什么这会如此复杂?所有前面的代码都可以简化为以下三行(假设我们不对使用调度器感兴趣):

function subscribe(ObserverI $observer) { 
    $observer->onNext($this->value); 
    $observer->onCompleted(); 
} 

好吧,对于ReturnObservable来说这可能是对的,但在实际应用中,我们很少使用这些原始的 Observables。调度器的另一个非常重要的用例是测试。我们可以提供一个模拟延迟执行的测试调度器,以确保我们的 Observables 和操作符按正确的顺序发出值。我们将在第五章深入探讨这个主题,测试 RxPHP 代码

从 Observables 取消订阅或清理取消订阅时的任何资源的能力非常重要,我们将在不久的将来使用它。

使用调度器发出多个值

我们已经看到了如何使用RangeObservable。现在,当我们知道为什么使用Scheduler->schedule()很重要时,为了教程的目的,我们可以考虑如何自己实现RangeObservable Observable 的功能。

例如,它可能看起来像以下这样:

// custom_range_01.php 
use Rx\Observable; 
use Rx\ObserverInterface; 

class CustomRangeObservable extends Observable { 
  private $min; 
  private $max; 

  public function __construct($min, $max) { 
    $this->min = $min; 
    $this->max = $max; 
  } 

  public function subscribe($observer, $sched = null) { 
    if (null === $sched) { 
      $sched = new \Rx\Scheduler\ImmediateScheduler(); 
    } 

    return $sched->schedule(function() use ($observer) { 
      for ($i = $this->min; $i <= $this->max; $i++) { 
        $observer->onNext($i); 
      } 
      $observer->onCompleted(); 
    }); 
  } 
} 

(new CustomRangeObservable(1, 5)) 
    ->subscribe(new DebugSubject()); 

当我们运行此示例时,我们将看到它产生了正确的结果:

$ php custom_range_01.php
1
2
3
4
5

然而,原始的RangeObservable有一个有趣的功能。它能够在循环内部取消订阅,这意味着我们可以在任何时候停止生成值。

考虑以下示例,其中我们在观察者的可调用内部取消订阅:

// range_01.php 
use Rx\Observable; 
use Rx\Scheduler\EventLoopScheduler; 
use React\EventLoop\StreamSelectLoop; 

$loop = new StreamSelectLoop(); 
$scheduler = new EventLoopScheduler($loop); 

$disposable = Observable::range(1, 5) 
    ->subscribeCallback(function($val) use (&$disposable) { 
        echo "$val\n"; 
        if ($val == 3) { 
            $disposable->dispose(); 
        } 
    }, null, null, $scheduler); 

$scheduler->start(); 

此示例仅发出前三个值,然后使用$disposable->dispose()取消订阅。

注意

我们必须使用异步的EventLoopScheduler,因为我们希望在订阅后开始执行计划的操作。使用EventLoopScheduler,执行是通过调用$scheduler->start()开始的。如果我们使用默认的ImmediateScheduler,那么$disposable变量将始终为 null(未分配),因为所有计划的操作都将执行在subscribeCallback()方法中,并且$disposable变量永远不会被分配。

当我们运行此演示时,我们将看到仅前三个数字:

$ php range_01.php 
1
2
3

如果我们尝试使用我们刚刚创建的CustomRangeObservable,我们会看到它不会取消订阅,并且我们总是接收到所有值。为了处理此类用例,调度器有一个scheduleRecursive()方法,其行为类似于schedule(),但其可调用参数是一个可调用的本身,用于重新调度另一个发射。

在实践中,我们可以重写CustomRangeObservable::subscribe()方法,使用scheduleRecursive()而不是schedule()

public function subscribe($observer, $sched = null) { 
  if (null === $sched) { 
    $sched = new \Rx\Scheduler\ImmediateScheduler(); 
  } 
  $i = $this->min; 

  return $sched->scheduleRecursive( 
      function($reschedule) use ($observer, &$i) { 
    if ($i <= $this->max) { 
      $observer->onNext($i); 
      $i++; 
      $reschedule(); 
    } else { 
      $observer->onCompleted(); 
    } 
  }); 
} 

注意,我们没有创建任何循环,而是让$reschedule()递归地调用自身。现在我们可以正确地调用从$sched->scheduleRecursive()返回的可处置对象的dispose()方法来停止调度更多操作。我们可以使用与RangeObservable相同的场景来测试这一点:

// php custom_range_02.php 
$loop = new StreamSelectLoop(); 
$scheduler = new EventLoopScheduler($loop); 

$disposable = (new CustomRangeObservable(1, 5)) 
    ->subscribeCallback(function($val) use (&$disposable) { 
        echo "$val\n"; 
        if ($val == 3) { 
            $disposable->dispose(); 
        } 
    }, null, null, $scheduler); 

$scheduler->start(); 

现在它只打印前三个数字:

$ php custom_range_02.php 
1
2
3

深入了解操作符链

我们已经在上一章中使用了操作符链。在我们开始编写 Reddit 阅读器之前,我们应该简要地讨论一下可能发生的一个有趣的情况,这样我们就不至于后来措手不及。

我们还将介绍一种新的 Observable 类型,称为ConnectableObservable。考虑这个简单的包含两个订阅者的操作符链:

// rxphp_filter_observables_01.php 
use Rx\Observable\RangeObservable; 
use Rx\Observable\ConnectableObservable; 

$connObs = new ConnectableObservable(new RangeObservable(0, 6)); 
$filteredObs = $connObs 
    ->map(function($val) { 
        return $val ** 2; 
    }) 
    ->filter(function($val) { 
        return $val % 2; 
    }); 

$disposable1 = $filteredObs->subscribeCallback(function($val) { 
    echo "S1: ${val}\n"; 
}); 
$disposable2 = $filteredObs->subscribeCallback(function($val) { 
    echo "S2: ${val}\n"; 
}); 

$connObs->connect(); 

ConnectableObservable类是一种特殊的 Observable 类型,其行为类似于 Subject(实际上,在内部,它确实使用了一个Subject类的实例)。任何其他 Observable 都会在你订阅后立即发出所有可用值。然而,ConnectableObservable接受另一个 Observable(源 Observable)作为参数,并允许你订阅观察者而不发出任何内容。当你调用ConnectableObservable::connect()时,它订阅了源 Observables,所有值依次发送给所有订阅者。

在内部,它包含了一个Subject类的实例,当我们调用subscribe()方法时,它只是将每个观察者订阅到其内部的 Subject。然后当我们调用connect()方法时,它将内部的 Subject 订阅到源 Observable。

$filteredObs变量中,我们保存了从filter()操作符返回的Observable的引用,它是一个AnnonymousObservable的实例,在接下来的几行中,我们为两个观察者都调用了subscribe()方法。

现在,让我们看看这个操作符链打印了什么:

$ php rxphp_filter_observables_01.php 
S1: 1 
S2: 1 
S1: 9 
S2: 9 
S1: 25 
S2: 25 

如我们所见,所有值都按照它们发出的顺序通过了两个观察者。出于好奇,我们也可以看看如果我们没有使用ConnectableObservable,而是使用RangeObservable会发生什么:

$ php rxphp_filter_observables_02.php 
S1: 1 
S1: 9 
S1: 25 
S2: 1 
S2: 9 
S2: 25 

这次,RangeObservable将所有值发送给了第一个观察者,然后,再次,将所有值发送给了第二个观察者。我们可以看到源 Observable 必须两次生成所有值,这是低效的,并且在大数据集上,这可能会造成性能瓶颈。

订阅到 ConnectableObservable

让我们回到第一个ConnectableObservable的例子,并修改filter()调用,使其打印所有通过的数据:

$filteredObservable = $connObservable 
    ->map(function($val) { 
        return $val ** 2; 
    }) 
    ->filter(function($val) { 
        echo "Filter: $val\n"; 
        return $val % 2; 
    }); 

现在我们再次运行代码,看看会发生什么:

$ php rxphp_filter_observables_03.php
Filter: 0
Filter: 0
Filter: 1
S1: 1
Filter: 1
S2: 1
Filter: 4
Filter: 4
Filter: 9
S1: 9
Filter: 9
S2: 9
Filter: 16
Filter: 16
Filter: 25
S1: 25
Filter: 25
S2: 25

嗯,这很意外!每个值都被打印了两次,尽管我们使用了ConnectableObservable。但这并不意味着 Observable 必须两次生成所有值(正如我们将在第八章中看到的那样,“RxPHP 和 PHP7 pthreads 扩展中的多播”)。一开始并不明显发生了什么,但问题是我们是在操作符链的末尾订阅了 Observable。

如前所述,$filteredObservable是一个包含许多嵌套闭包的AnnonymousObservable实例。通过调用其subscribe()方法,它运行由其前驱创建的闭包,依此类推。这导致每次调用subscribe()都必须调用整个链。虽然这可能在许多用例中不是问题,但在某些情况下,我们可能想在其中一个过滤器内部执行特殊操作。

这个示例的操作符链看起来像以下图示,其中每个订阅都由一个箭头表示:

订阅 ConnectableObservable

所有这一切最重要的后果是,操作符和AnnonymousObservable类都不共享通过它们传递的值。实际上,它们中的任何一个都没有跟踪订阅的观察者。

此外,请注意,对subscribe()方法的调用可能不受我们的控制,可能是由另一个想要使用我们为他们创建的可观察对象的其他开发者执行的。

了解这种情况可能发生并且可能导致不希望的行为是很好的。

注意

有时候很难看到可观察对象内部发生了什么。特别是在我们必须处理 PHP 中的多个嵌套闭包时,很容易迷失方向。调度器是典型的例子。请随意尝试这里显示的示例,并使用调试器逐步检查代码的执行顺序和顺序。

因此,让我们找出如何解决这个问题。一种方法是我们重构代码,将$filteredObservable转换为ConnectableObservable而不是直接转换为RangeObservable。考虑以下代码:

// rxphp_filter_observables_04.php 
$source = new RangeObservable(0, 6); 
$filteredObservable = $source 
    ->map(function($val) { 
        return $val ** 2; 
    }) 
    ->filter(function($val) { 
        echo "Filter: $val\n"; 
        return $val % 2; 
    }); 

$connObs = new ConnectableObservable($filteredObservable); 

$disposable1 = $connObs->subscribeCallback(function($val) { 
    echo "S1: ${val}\n"; 
}); 
$disposable2 = $connObs->subscribeCallback(function($val) { 
    echo "S2: ${val}\n"; 
}); 
$connObs->connect(); 

当我们运行此代码时,我们可以看到filter()操作符对每个值只调用一次:

$ php rxphp_filter_observables_04.php 
Filter: 0
Filter: 1
S1: 1
S2: 1
Filter: 4
Filter: 9
S1: 9
S2: 9
Filter: 16
Filter: 25
S1: 25
S2: 25

为了更好地理解与上一个示例的不同之处,我们可以查看表示此操作符链的图示:

订阅 ConnectableObservable

我们可以看到,ConnectableObservable被移动到链的下方,并订阅了filter()操作符而不是RangeObservable

使用 Subject 代替 ConnectableObservable

我们说过我们不想在链的末端多次订阅,因此我们可以创建一个Subject类的实例,其中我们将订阅两个观察者,而Subject类本身将订阅$filteredObservable,正如刚才讨论的那样:

// rxphp_filter_observables_05.php 
use Rx\Subject\Subject; 

$subject = new Subject(); 
$source = new RangeObservable(0, 6); 
$filteredObservable = $source 
    ->map(function($val) { 
        return $val ** 2; 
    }) 
    ->filter(function($val) { 
        echo "Filter: $val\n"; 
        return $val % 2; 
    }) 
    ->subscribe($subject); 

$disposable1 = $subject->subscribeCallback(function($val) { 
    echo "S1: ${val}\n"; 
}); 
$disposable2 = $subject->subscribeCallback(function($val) { 
    echo "S2: ${val}\n"; 
}); 
$filteredObservable->subscribe($subject); 

我们可以运行脚本并看到它返回的输出与上一个示例完全相同:

$ php rxphp_filter_observables_05.php
Filter: 0
Filter: 1
S1: 1
S2: 1
Filter: 4
Filter: 9
S1: 9
S2: 9
Filter: 16
Filter: 25
S1: 25
S2: 25

这可能看起来像是一个边缘情况,但很快我们会看到,如果这个问题没有得到妥善处理,可能会导致一些非常不可预测的行为。当我们开始编写我们的 Reddit 阅读器时,我们将讨论这两个问题(正确使用可处置对象和操作符链)。

Observable::create() 和 Observable::defer()

我们知道如何使用ReturnObservableRangeObservable创建可观察对象。我们还编写了一个自定义的CURLObservable。然而,在某些情况下,我们可能希望创建一个具有某些自定义逻辑的可观察对象,这些逻辑不容易用现有的可观察对象类重现。当然,我们可以编写另一个继承基本可观察对象类的可观察对象,但如果我们需要处理一个非常具体、单次使用的场景,使用静态方法Observable::create()Observable::defer()将更容易。

使用Observable::create()创建可观察对象

使用Observable::create(),我们可以创建一个在订阅时自动将值推送到每个观察者的可观察对象。考虑以下示例:

// observable_create_01.php 
use Rx\Observable; 
use Rx\ObserverInterface; 

$source = Observable::create(function(ObserverInterface $obs) { 
    echo "Observable::create\n"; 
    $obs->onNext(1); 
    $obs->onNext('Hello, World!'); 
    $obs->onNext(2); 
    $obs->onCompleted(); 
}); 

$source->subscribe(new DebugSubject()); 
$source->subscribe(new DebugSubject()); 

传递给Observable::create()的可调用参数接受一个观察者作为参数,它可以直接开始发射值。重要的是要记住,这个可调用参数将为每个观察者调用。这个示例输出了以下内容:

$ php observable_create_01.php
Observable::create
21:00:52 [] onNext: 1 (integer)
21:00:52 [] onNext: Hello, World! (string)
21:00:52 [] onNext: 2 (integer)
21:00:52 [] onCompleted
Observable::create
21:00:52 [] onNext: 1 (integer)
21:00:52 [] onNext: Hello, World! (string)
21:00:52 [] onNext: 2 (integer)
21:00:52 [] onCompleted

注意,字符串Observable::create被打印了两次。另外,注意我们亲自调用了onCompleted以正确完成可观察对象。

可调用函数可以可选地返回一个Rx\DisposableInterface实例,该实例在取消订阅/完成可观察对象时将被销毁。我们可以修改相同的示例以返回CallbackDisposable实例:

$source = Observable::create(function(ObserverInterface $obs) { 
    ... 
    return new CallbackDisposable(function() { 
        echo "disposed\n"; 
    }); 
}); 

现在,每个CallbackDisposable都将被调用,以正确清理每个观察者的资源。

使用Observable::defer()创建可观察对象

想象一个用例,我们希望为订阅我们可观察对象的每个观察者生成一个随机数字范围。这意味着我们希望每个观察者都有一个不同的数字范围。

让我们看看如果我们只使用RangeObservable会发生什么:

// observable_defer_01.php  
use Rx\Observable; 
$source = Observable::range(0, rand(1, 10)); 

$source->subscribe(new DebugSubject('#1')); 
$source->subscribe(new DebugSubject('#2')); 

由于我们创建了一个单个源可观察对象,两个观察者将始终接收到相同的数字范围。范围维度在调用Observable::range()时设置一次。例如,这个脚本的输出可能如下所示:

$ php observable_defer_01.php 
21:38:29 [#1] onNext: 0 (integer)
21:38:29 [#1] onNext: 1 (integer)
21:38:29 [#1] onNext: 2 (integer)
21:38:29 [#1] onCompleted
21:38:29 [#2] onNext: 0 (integer)
21:38:29 [#2] onNext: 1 (integer)
21:38:29 [#2] onNext: 2 (integer)
21:38:29 [#2] onCompleted

我们当然可以创建两个源可观察对象,但使用Observable::defer()静态方法有一个更优雅的方式:

// observable_defer_02.php 
use Rx\Observable; 
$source = Observable::defer(function() { 
    return Observable::range(0, rand(1, 10)); 
}); 

$source->subscribe(new DebugSubject('#1')); 
$source->subscribe(new DebugSubject('#2')); 

静态方法Observable::defer()接受一个可调用参数,每次观察者订阅时都会调用该参数,类似于Observable::create()。然而,这个可调用参数需要返回另一个观察者将订阅的可观察对象。我们不是只创建一个RangeObservable,而是为每个观察者创建一个新的。

这个示例的输出可能看起来像以下这样:

$ php observable_defer_02.php 
21:40:58 [#1] onNext: 0 (integer) 
21:40:58 [#1] onNext: 1 (integer) 
21:40:58 [#1] onNext: 2 (integer) 
21:40:58 [#1] onNext: 3 (integer) 
21:40:58 [#1] onCompleted 
21:40:58 [#2] onNext: 0 (integer) 
21:40:58 [#2] onCompleted

注意,每个观察者接收到了不同的数字范围。

使用 RxPHP 编写 Reddit 阅读器

我们将要构建的许多即将到来的应用程序将是纯 CLI 应用程序。因此,拥有一个统一的库来帮助我们处理 CLI 环境中的常见问题将是有帮助的:

使用 RxPHP 编写 Reddit 阅读器

我们选择使用的工具将是 Symfony Console 组件(symfony.com/doc/current/components/console.html)。这是一个与 Symfony 框架一起开发的开源库,但它被设计成可以在任何项目中独立使用,这对我们来说非常理想。

它处理从输入到输出的所有事情,并且还附带了一些非常实用的助手。特别是,我们将使用以下内容:

  • 着色和格式化控制台输出

  • 将 CLI 应用拆分为多个独立的命令

  • 从输入参数定义中自动生成帮助信息

  • 处理输入参数,包括验证和默认值

  • 创建一组统一的函数来处理用户输入

在这个例子中,我们将只使用前两个项目符号,但在后面的章节中,我们将使用这里列出的所有功能。

使用 Symfony Console 组件

首先,通过 composer 安装 Symfony Console 组件:

$ composer require symfony/console

每个 CLI 应用都被拆分为多个可以独立运行的命令。由于我们的应用非常简单,我们可以将所有逻辑放入一个命令中,因此我们将设置一个默认命令。

我们应用的人口点只是注册命令,然后让 Console 库为我们处理所有事情:

// console_reddit.php 
require_once __DIR__ . '/../vendor/autoload.php'; 
require_once 'RedditCommand.php'; 

$application = new Symfony\Component\Console\Application(); 
$application->setDefaultCommand('reddit'); 
$application->add(new RedditCommand()); 
$application->run(); 

运行$application::run()方法会检查 PHP 的全局 CLI 参数,并根据这些参数选择正确的命令。由于我们的应用只有一个命令,我们不需要从终端传递任何参数;应用将使用默认的,即RedditCommand,我们现在就开始编写它。

每个命令都继承自Symfony\Component\Console\Command类,并且至少应该定义其名称:

// RedditCommand.php 
use Symfony\Component\Console\Command\Command; 
use Symfony\Component\Console\Input\InputInterface as InputI; 
use Symfony\Component\Console\Output\OutputInterface as OutputI; 

class RedditCommand extends Command { 
    protected function configure() { 
        $this->setName('reddit'); 
        $this->setDescription( 
            'CLI Reddit reader created using RxPHP library.'); 
    } 

    protected function execute(InputI $input, OutputI $output) { 
        $output->writeln('<info>Hello, World!</info>'); 
    } 
} 

这个命令的名称是reddit,它需要与我们使用setDefaultCommand()设置的名称相匹配。

注意,我们可以使用类似于 HTML 的标签来进行一些基本的样式设置,虽然功能非常有限,但对于典型的 CLI 应用来说已经足够了。我们将使用四种预定义的颜色,但如果你想要更详细地了解,可以自由地查看有关在symfony.com/doc/current/console/coloring.html上着色输出的文档:

  • <info> = 绿色

  • <comment> = 黄色

  • <question> = 在青色背景上显示黑色

  • <error> = 在红色背景上显示白色文本

当 Symfony Console 库识别到一个命令时,它会调用其execute()方法,同时传递两个用于处理输入和输出的对象。我们通常不希望自己处理输入或输出,因为不同平台之间存在不一致,而 Console 库可以为我们做所有这些。

一个合适的例外是当我们想要使用非阻塞的用户输入而不是内置的提问助手。碰巧的是,这正是我们接下来要做的,但让我们先看看如何从终端运行这个命令:

$ php console_reddit.php
Hello, World!

由于RedditCommand也是默认命令,所以我们不需要设置任何 CLI 参数来执行它。这实际上与运行以下命令相同:

$ php console_reddit.php reddit

一个 CLI 应用程序可以包含多个命令,如前所述。我们可以使用以下方法列出此应用程序支持的所有命令:

$ php console_reddit.php list

这将打印出所有命令的彩色概览,以及所有应用程序默认允许的一些常见选项:

使用 Symfony 控制台组件

在其中,还有我们上面设置的描述的reddit命令。我们也可以使用help命令来获取有关特定命令的详细信息,但由于我们的reddit命令没有输入参数,我们不会看到任何有趣的内容,所以我们将它留到以后。

注意

注意到helplist只是像其他任何命令一样。

非阻塞用户输入和事件循环

在上一章的结尾,我们讨论了在 PHP 中使用proc_open()stream_set_blocking()实现的阻塞和非阻塞流。我们还提到,我们需要某种类型的事件循环,它在定期检查用户输入的同时,不会阻塞执行线程,以便在任何时候使应用程序保持响应。

我们将要使用的命令的基本原则如下:我们将创建一个 Observable,它为接收到的每一行输入发出一个值(这是一个字符串,后跟Enter键)。这个 Observable 将具有多个观察者,它们将根据当前应用程序的内部状态进行订阅和取消订阅。我们将始终至少有一个活动观察者,它将寻找终止事件循环并结束应用程序的q(退出)字符串。

让我们扩展execute()方法,以便从终端读取用户的输入以及事件循环本身:

use Rx\Observable\IntervalObservable; 

class RedditCommand extends Command { 
  /** @var \Rx\Subject\Subject */ 
  private $subject; 
  private $interval; 

  protected function execute(InputI $input, OutputI $output) { 
    $this->subject = new \Rx\Subject\Subject(); 
    $stdin = fopen('php://stdin', 'r'); 
    stream_set_blocking($stdin, false); 

    $loop = new React\EventLoop\StreamSelectLoop();
    $scheduler = new Rx\Scheduler\EventLoopScheduler($loop); 
    $this->interval = new IntervalObservable(100, $scheduler); 

    $disposable = $this->interval 
      ->map(function($count) use ($stdin) { 
        return trim(fread($stdin, 1024)); 
      }) 
      ->filter(function($str) { 
        return strlen($str) > 0; 
      }) 
      ->subscribe($this->subject); 
    $loop->run(); 
  } 
} 

已经使用了一些值得注意的概念,因此让我们分别查看每个概念:

  • 我们使用fopen('php://stdin', 'r')打开了一个输入流,并使用stream_set_blocking()函数将其设置为非阻塞。这与我们在上一章中使用的proc_open()原理完全相同。

  • 事件循环的工作方式与我们之前章节中看到的一样。我们在这里使用它来创建一个每 100 毫秒触发一次(或称为EventLoopScheduler中的“tick”)的稳定计时器。

  • 所有用户输入都被缓冲,这意味着fread()将始终返回一个空字符串,直到我们按下Enter键。

  • 使用filter()运算符,我们可以过滤掉所有空字符串。

  • 通过这个运算符链成功通过的价值将由一个Subject类观察。这就是我们将稍后订阅观察者的类,它只发出有效的用户输入。

使用EventLoopScheduler实际上非常简单。它确保它在精确的时间间隔内发出值,尽管在运算符链中始终有一些代码被执行。它内部测量上次触发的时间和传播值所花费的时间,然后只睡眠必要的间隔。

注意,我们在本章开头已经解释了关于操作符链的问题。我们将订阅/取消订阅的 Observable 总是 $this->subject,而不是直接使用 IntervalObservable

此外,请注意,我们创建了一个 $disposable 变量,它包含通过调用 subscribe($this->subject) 创建的 Disposable 对象。这基本上是对 IntervalObservable 的订阅。如果我们取消订阅(这意味着调用 $disposable->dispose()),事件循环将自动结束,整个应用也将结束。

订阅用户输入

我们已经提到,当用户输入 q 时,应用应该优雅地结束。我们现在可以实施这个功能。一旦我们准备好了 Subject 的实例,我们就可以开始订阅它:

protected function execute(InputI $input, OutputI $output) { 
  // The rest of the method is the same as above 

  $this->subject 
    ->filter(function($value) { 
      return strval($value) == 'q'; 
    }) 
    ->take(1) 
    ->subscribeCallback(null, null, 
        function() use ($disposable, $output, $stdin) { 
      fclose($stdin); 
      $output->writeln('<comment>Good bye!</comment>'); 
      $disposable->dispose(); 
    } 
  ); 

  $loop->run(); 
} 

注意

为了节省空间并使代码示例简短,我们省略了类名、缩进和未更改的已定义方法。

这与订阅任何其他 Observable 完全相同。这里有趣的是,我们将 $disposable 变量传递给 Closure,在其中调用其 dispose() 方法,从而取消 Subject 对 IntervalObservable 的订阅,并最终终止事件循环。这次,我们不需要保留从 subscribeCallback() 返回的任何 Disposable 对象的引用,因为我们知道我们永远不会想要取消这个观察者的订阅。

注意,我们使用 take(1) 来接受最多一个退出信号。然后接下来的 subscribe() 调用只为完整信号定义了一个可调用对象,并完全忽略了剩余的两个。

我们在本章开头讨论了可处置对象时提到了这个问题,实际上这些是必要的。

我们显然想让用户选择他们最喜欢的 subreddit。这将是 $this->subject 的另一个订阅者,但这次我们将保留其可处置对象,因为稍后我们需要能够订阅其他观察者并取消这个观察者的订阅,这个观察者只需要订阅 subreddit 名称,不需要做更多:

/** @var string */ 
private $subreddit; 
/** @var \Rx\DisposableInterface */ 
private $subredditDisposable; 

protected function execute(InputI $input, OutputI $output) { 
  // The rest of the method is the same as above 
  $this->askSubreddit(); 

  $loop->run(); 
} 

protected function askSubreddit() {
  $this->output->write('Enter subreddit name: ');
  $this->subredditDisposable =
    $this->subject->subscribeCallback(function($value) {
      $this->subreddit = $value;
      $this->subredditDisposable->dispose();
      $this->refreshList();
    });
}

在我们开始事件循环之前,我们安排了一个动作,要求用户输入他们想要下载的 subreddit 名称,然后订阅一个新的观察者。当它接收到有效值时,我们将它存储在 $this->subreddit 变量中,然后它使用 $this->subredditDisposable->dispose() 来取消订阅自己。

我们已经可以看到有一个调用另一个方法,名为 refreshList()。这个方法将通过 Reddit API 以 JSON 格式下载该 subreddit 的帖子,并打印出一个包含标题的列表,用户可以通过输入帖子的索引号来选择他们想要阅读的帖子。

为了下载列表,我们将使用 cURL PHP 模块。我们已经在 第二章 使用 RxPHP 进行响应式编程 中使用过它,我们创建了 CURLObservable 来实现这个目的,这在这里也很有用。此外,我们已编写了 JSONDecodeOperator 用于解码 JSON 字符串,我们也将使用它:

const API_URL = 'https://www.reddit.com/r/%s/new.json'; 

protected function refreshList() {
  $curlObservable = new CurlObservable( 
      sprintf(self::API_URL, $this->subreddit)); 

  $curlObservable 
    ->filter(function($value) { 
      return is_string($value); 
    }) 
    ->lift(function() { 
      return new JSONDecodeOperator(); 
    }) 
    ->subscribeCallback(function(array $response) { 
      $articles = $response['data']['children']; 
      foreach ($articles as $i => $entry) { 
        $this->output->writeln("<info>${i}</info> " . 
            $entry['data']['title']); 
      } 

      $this->printHelp(); 
      $template = ', <info>[%d-%d]</info>: Read article'; 
      $this->output->writeln( 
          sprintf($template, 0, count($articles))); 

      $this->chooseArticleDetail($articles); 
    }), function($e) { 
      $this->output->writeln( 
          '<error>Unable to download data</error>'); 
    }); 
} 

这是我们已经看到的,应该很容易理解。我们使用 CURLObservable 下载 URL,然后使用 JSONDecodeOperator 将其从 JSON 解码为 PHP 数组。然后我们遍历它包含的所有文章的列表,并打印它们的索引和标题。

我们还介绍了一个名为 printHelp() 的小方法,它只打印一个提示,即输入 q 并按 Enter 键将退出应用。然后我们添加一些仅与当前状态相关的提示,例如 [b] 返回列表,正如我们在下面的屏幕截图中所见:

订阅用户输入

然后,类似地,它调用 chooseArticleDetail(),允许用户输入他们想要查看的文章索引号。

这可以一直进行下去,但原则始终相同。我们订阅一个观察者到存储在 $this->subject 中的主 Subject 类,只检查与当前应用程序状态相关的值,执行一些操作,然后取消订阅。可能没有必要在这里包含完整的源代码,因为这会非常重复。

注意

如果你想查看此应用的所有实现方法,请查看本章的完整源代码。

相反,让我们关注与 CURLObservable 和子进程相关的事情,使用 Symfony Process 组件。

非阻塞 CURLObservable

在我们的 Reddit 阅读器应用中,我们使用 PHP 的 cURL 从远程 API 下载数据。即使使用其异步回调,如 CURLOPT_PROGRESSFUNCTION,我们也必须记住,无论我们选择什么选项,curl_exec() 仍然是一个阻塞调用。

这是因为 PHP 在单个执行线程中运行,当它开始执行 curl_exec() 时,其他所有操作都需要等待它完成。虽然这个方法可能会调用一些回调函数,但如果其中任何一个卡住了,例如,在一个无限循环中,curl_exec() 函数将永远不会结束。

这对我们 Reddit 阅读器的实际响应能力有严重影响。当 CURLObservable 下载数据时,它不会响应用户输入,这可能不是我们想要的。

当我们谈论 IntervalObservable 以及它如何能够非常精确地保持所需的间隔时,我们没有提到的是,这实际上是一种它无法处理的情况。

让我们编写一个简单的脚本,演示这种行为。我们将使用 IntervalObservable 每秒触发一次:

use Rx\Observable\IntervalObservable; 

function getTime() { 
    $t = microtime(true); 
    $micro = sprintf("%06d", ($t - floor($t)) * 1000000); 
    return date('H:i:s') . '.' . $micro; 
} 

$loop = new React\EventLoop\StreamSelectLoop(); 
$scheduler = new Rx\Scheduler\EventLoopScheduler($loop); 
$observable = new IntervalObservable(1000, $scheduler); 

$observable->map(function($tick) { 
    printf("%s Map: %d\n", getTime(), $tick); 
    return $tick; 
})->subscribeCallback(function($tick) { 
    printf("%s Observer: %d\n", getTime(), $tick); 
}); 
$loop->run(); 

此示例非常精确地打印当前时间,包括微秒。如果我们让它运行一段时间,我们仍然会看到它在每增加一秒时仍然很好地保持到微秒:

$ php blocked_intervalobservable.php
00:27:14.306441 Map: 0
00:27:14.306509 Observer: 0
00:27:15.305033 Map: 1
00:27:15.305116 Observer: 1
...
00:28:22.306071 Map: 68
00:28:22.306124 Observer: 68

我们已经可以观察到 map() 操作符在观察者之前被调用。现在,让我们在 map() 操作符中添加一个 usleep(1250 * 1000); 调用。我们可以看到,这个间隔甚至比 IntervalObservable 的 1 秒间隔还要大,这使得它完全不同步:

$ php blocked_intervalobservable.php
00:41:25.606327 Map: 0
00:41:26.859891 Observer: 0
00:41:26.860455 Map: 1
00:41:28.113972 Observer: 1

这意味着,即使我们依赖于 IntervalObservable 来完成所有必要的定时,当操作符链中的任何地方有代码阻塞执行时,它也无法做任何事情。这就是我们使用 CURLObservable 时发生的情况,当 curl_exec() 运行时,应用程序没有响应。

不幸的是,PHP 本身(没有任何额外模块)并没有给我们提供很多编写非阻塞代码的选项。

但在上一章中,我们使用了 proc_open()stream_set_blocking() 来运行非阻塞子进程,因此我们可以使用相同的技巧,并将 CURLObservable 包装成一个可以作为一个子进程运行的独立应用程序。

由于我们已经知道如何使用 Symfony Console 组件编写 CLI 应用程序,因此我们也将在这里使用它:

// wrap_curl.php 
use Symfony\Component\Console\Command\Command; 
use Symfony\Component\Console\Input\InputInterface as InputI; 
use Symfony\Component\Console\Output\OutputInterface as OutputI; 
use Symfony\Component\Console\Input\InputArgument; 

class CURLCommand extends Command { 
  protected function configure() { 
    $this->setName('curl'); 
    $this->setDescription( 
        'Wrapped CURLObservable as a standalone app'); 
    $this->addArgument('url', 
        InputArgument::REQUIRED, 'URL to download'); 
  } 

  protected function execute(InputI $input, OutputI $output) { 
    $returnCode = 0; 
    (new CURLObservable($input->getArgument('url'))) 
      ->subscribeCallback(function($res) use ($output) { 
        if (!is_float($response)) { 
          $output->write($res); 
        } 
      }, function() use (&$returnCode) { 
        $returnCode = 1; 
      }); 
    return $returnCode; 
  } 
} 

$application = new Symfony\Component\Console\Application(); 
$application->add(new CURLCommand()); 
$application->run(); 

此命令有一个必需的参数,即它应该下载的 URL。它内部使用 CURLObservable 下载 URL,然后将响应打印到其标准输出。如果发生错误,它还会设置正确的 UNIX 返回码。

如果我们尝试在没有任何参数的情况下运行该命令,它会打印一个错误,告诉我们这个命令必须恰好有一个参数:

非阻塞 CURLObservable

我们可以手动测试这个命令;例如,使用以下命令:

$ php wrapped_curl.php curl https://www.reddit.com/r/php/new.json
{"kind": "Listing", "data": {"modhash": "", "children": ...

现在,我们可以像上一章中那样使用 proc_open(),但除了启动进程之外,还有很多事情需要我们自己处理,所以更容易将所有重工作交给另一个库。

使用 Symfony Process 组件

如同往常,我们将使用 composer 安装这个库:

$ php composer require symfony/process

这个库让我们可以创建新的进程,以非阻塞的方式读取它们的输出,发送输入,发送信号,使用超时,终止进程等等。

为了测试这些,我们将编写一个小脚本,使用 IntervalObservable 每秒打印一个数字,同时等待子进程完成:


// php curl_subprocess.php 
use Symfony\Component\Process\Process; 
use Rx\Observable\IntervalObservable; 

$c='php wrap_curl.php curl https://www.reddit.com/r/php/new.json'; 
$process = new Process($c); 
$process->start(); 

$loop = new React\EventLoop\StreamSelectLoop(); 
$scheduler = new Rx\Scheduler\EventLoopScheduler($loop); 

(new IntervalObservable(1000, $scheduler)) 
    ->takeWhile(function($ticks) use ($process) { 
        return $process->isRunning(); 
    }) 
    ->subscribeCallback(function($ticks) { 
        printf("${ticks}\n"); 
    }, function() {}, function() use ($process) { 
        echo $process->getOutput(); 
    }); 
$loop->run(); 

Process 类在其构造函数中接受一个它应该执行的完整命令。然后,调用 Process::start() 将以异步非阻塞的方式启动子进程,就像我们之前做的那样。我们可以随时使用 getOutput() 方法检查可用输出。然后,isSuccessful()isRunning() 方法分别在进程成功终止(返回码等于 0)时返回 true,以及进程是否仍在运行。

takeWhile() 操作符

我们还使用了一个新的操作符,称为 takeWhile()。这个操作符接受一个谓词闭包作为参数,为它接收到的每个值执行。如果谓词返回 true,它将值传递到链中(通过在其观察者上调用 onNext()),但如果谓词返回 false,它将发出 onComplete() 信号,因此循环结束,因为没有其他观察者订阅它。这与本章前面我们使用可处置的从 IntervalObservable 取消订阅并结束应用的情况完全相同。以下 Marble 图表示了 RxJS 中的 takeWhile() 操作符(reactivex.io/rxjs/class/es6/Observable.js):

takeWhile() 操作符

如果我们运行这个示例,它将打印几个滴答声,然后转储整个响应并结束。这正是我们所需要的。因此,我们可以删除临时的 printf() 语句并使用这个子进程在我们的 Reddit 阅读器应用中。

将子进程实现到 Reddit 阅读器应用中

这次最终改进需要对现有代码进行一些修改。首先,refreshList() 方法不需要使用 CURLObservableJSONDecodeOperator,因为我们将从 Process 类的实例直接读取响应。

此外,检查用户输入的主要 Subject 类和检查子进程是否终止的观察者都需要使用相同的调度器实例。与每次刷新帖子列表时创建一个新的实例相比,共享同一个 IntervalObservable 的实例更容易,所以我们将其引用作为 property 类保存在 $this->intervalObservable 中:

protected function refreshList() { 
  $url = sprintf(self::API_URL, $this->subreddit); 
  $this->process = new Process( 
      'php wrap_curl.php curl '.$url); 
  $this->process->start(); 

  $this->intervalObservable 
    ->takeWhile(function() { 
      return $this->process->isRunning(); 
    }) 
    ->subscribeCallback(null, null, function() { 
      $jsonString = $this->process->getOutput(); 
      if (!$jsonString) { 
        return; 
      } 

      $response = json_decode($jsonString, true); 
      $articles = $response['data']['children']; 
      // ... the rest is unchanged 

然后,当我们想要退出应用时,我们必须确保子进程已经终止,或者最终由我们自己终止。如果我们不终止它,PHP 解释器将不得不等待它完成。

这就是更新后的检查退出条目的观察者将看起来像:

$this->subject->filter(function($value) { 
  return strval($value) == 'q'; 
}) 
->take(1) 
->subscribeCallback(null, null, 
  function() use ($disposable, $output, $stdin) { 
    fclose($stdin); 
    $output->writeln('<comment>Good bye!</comment>'); 
    if ($this->process && $this->process->isRunning()) { 
      $this->process->stop(); 
    } 
    $disposable->dispose(); 
  } 
); 

因此,最终这使我们能够在任何时候退出(或执行任何其他操作),即使在这个时候 cURL 正在下载数据,因为我们以一个单独的非阻塞进程运行下载,并定期在同一事件循环中检查响应。

注意

在 第六章 中,我们将看到如何使用 StreamSelectLoop 直接从使用 fopen() 创建的文件句柄读取。

可处置类的类型

在本章中,我们一直在大量订阅和取消订阅 Observables。虽然我们知道什么是可处置的,但我们还没有讨论 RxPHP 中默认可用的不同类型的可处置类。

我们不会为它们中的每一个都编写示例,因为这些类非常简单,如果你不确定它们的实现细节,请随时查看它们的源代码。

  • BinaryDisposable:一个内部包含两个更多可释放对象的类。然后通过调用它的dispose(),它将自动调用两个内部可释放对象的dispose()

  • CallbackDisposable:此类包装了一个在调用dispose()时稍后执行的调用。

  • CompositeDisposable:一个包含可释放对象的集合,这些对象将一起释放。

  • EmptyDisposable:一个不做任何事的可释放对象。有时即使我们没有什么可以释放的,也要求传递或返回DisposableInterface的实例。

  • RefCountDisposable:一个包含另一个可释放对象和计数器的可释放对象,当计数器达到 0 时将被释放(基本上与编程语言中的自动引用计数原理相同)。

  • ScheduledDisposable:此类包装另一个不会直接释放的可释放对象,而是通过Scheduler::schedule()进行调度。

  • SerialDisposable:一个包含可释放对象的集合,在添加新的可释放对象时,自动释放前一个(Scheduler::scheduleRecursive()方法返回此类可释放对象)。

  • SingleAssignmentDisposable:这是一个包装另一个可释放对象的包装器,该对象只能分配一次。如果我们尝试将此可释放对象分配两次,将会引发异常。

注意

由于 RxPHP 主要基于 RxJS 4,它使用其可释放对象风格。如果你来自 RxJS 5,你习惯于始终只使用Subscription类,它与CompositeDisposable非常相似。

摘要

在本章中,我们更深入地探讨了如何使用可释放对象和操作符,它们是如何在内部工作的,以及这对我们意味着什么。我们还看到了如何使用Observable::create()Observable::defer()来创建具有自定义逻辑的新可观察对象。

我们构建的应用程序旨在成为一个简单的 Reddit 阅读器,它结合了我们迄今为止学到的所有 RxPHP 方面。我们还看到了如何通过使所有长时间运行的任务非阻塞来实现真正响应的应用程序。我们使用 Symfony Console 组件来处理来自终端的用户输入和输出。此外,我们还使用了 Symfony Process 组件来轻松生成并控制子进程。

我们还探讨了 RxPHP 的一些新类,例如ConnectableObservableCompositeDisposabletakeWhile()操作符。

在下一章中,我们将处理一些在流行的 PHP 框架中使用的基于事件的系统,例如 Symfony、Silex 和 Zend Framework,并了解我们如何将它们与响应式编程的原则结合起来。

第四章。响应式与典型的事件驱动方法

到目前为止,我们主要关注 CLI 应用程序。在本章中,我们将把我们已经学到的知识应用到所有 Web 框架的典型组件中,并在此基础上增加一些内容。我们将使用 Symfony 的EventDispatcher组件,这是一个可以在任何框架中使用的独立库。

它的主要目的是在应用程序的生命周期中派发事件,并且易于扩展。最值得注意的是,它是 Symfony3 框架和 Silex 微框架的核心构建块。

在本章中,我们将做以下事情:

  • 查看 RxPHP 中的错误处理,并解释retry()retryWhen()catchError()操作符。我们将看到这三个操作符如何与我们之前章节中讨论的内容相关联。

  • 我们将看到如何使用concat()merge()操作符组合两个 Observables。然后我们还将看看concatMap()及其与有序 HTTP 请求的非常常见的用例。

  • 使用示例快速介绍EventDispatcher组件。

  • 编写一个默认EventDispatcher类的替代品,称为ReactiveEventDispatcher,它基于默认的EventDispatcher,并使用 RxPHP 的响应式方法。

  • 看看我们如何使用 Subjects 动态构建 Observable 链。

  • 使用 Observables 而不是闭包作为事件监听器来增强我们的事件派发器实现。

  • 在介绍默认EventDispatcher时使用的相同示例上测试我们的事件派发器实现。

在我们深入探讨EventDispatcher组件之前,我们也应该谈谈如何在操作符链中处理错误状态。

我们已经在第二章,使用 RxPHP 进行响应式编程中与onError处理程序一起工作过,例如。然而,我们还没有看到如何优雅地恢复错误以及这些可能带来的意外影响。

在操作符链中处理错误状态

如果我们回到第二章,使用 RxPHP 进行响应式编程CURLObservable,我们知道当它无法下载任何数据时会发出onError。问题是,如果我们想再次尝试下载 URL 怎么办?甚至更有趣的是,每隔几秒重复失败的尝试。

使用subscribeCallback()方法的第二个参数仅订阅onError信号非常简单:

(new CURLObservable('https://example.com')) 
    ->subscribeCallback(null, function($e) { ... }); 

显然,将另一个CURLObservable嵌套到onError处理程序中可能不是一个选择。这正是retry()操作符的设计目的。

retry()操作符

retry() 操作符收到 onError 信号时,它会捕获它并尝试重新订阅其源 Observable。它将尝试重新订阅的次数作为参数,直到它将错误信号传递到操作符链。

让我们用 retry() 操作符重写前面的例子:

(new CURLObservable('https://example.com')) 
    ->retry(3) 
    ->subscribe(new DebugSubject()); 

这尝试重新订阅 CURLObservable 三次,直到 DebugSubject 收到 onError 信号。默认情况下,retry() 操作符不接收任何参数,并无限尝试重新订阅。

好吧,在第三方网络服务上测试错误状态并不方便,因为我们无法强制它返回错误状态。因此,从现在开始,我们最好使用 map() 操作符来触发 onError 信号。

优势在于,map() 操作符在其 try...catch 块中调用其可调用包装器,因此任何抛出的异常都将转换为 onError 信号:

// snippet from Rx\Operator\MapOperator class 
try { 
    $value = call_user_func_array($this->selector, [$nextValue]); 
} catch (\Exception $e) { 
    $observer->onError($e); 
} 

考虑以下代码,它本应打印从 16 的数字,但每次在数字 3 处都会失败:

// retry_01.php 
Observable::range(1, 6) 
    ->map(function($val) { 
        if ($val == 3) { 
            throw new \Exception('error'); 
        } 
        return $val; 
    }) 
    ->retry(3) 
    ->subscribe(new DebugSubject()); 

现在,在查看实际输出之前,试着猜测会发生什么,并记住我们在 第三章,使用 RxPHP 编写 Reddit 阅读器,在名为 操作符链的深入探讨观察者订阅的深入探讨 的部分中讨论的内容:

$ php retry_01.php
09:18:32 [] onNext: 1 (integer)
09:18:32 [] onNext: 2 (integer)
09:18:32 [] onNext: 1 (integer)
09:18:32 [] onNext: 2 (integer)
09:18:32 [] onNext: 1 (integer)
09:18:32 [] onNext: 2 (integer)
09:18:32 [] onError (Exception): error

它只打印数字 12 三次,然后以 onError 结束。

起初可能会让人困惑的是,常识告诉我们预期这段代码会打印出数字 12456。数字 3 抛出异常,但多亏了 retry() 操作符,它继续下一个值。

然而,情况并非如此,因为 retry() 重新订阅其源 Observable,并且发出 onError 信号总是会使链停止传播更多值。在 第三章,使用 RxPHP 编写 Reddit 阅读器 中,我们看到了订阅一个 Observable 会触发生成整个 Observable 链,这些 Observable 按照它们定义的顺序相互订阅。最后,它订阅了开始发出值的源 Observable。

我们在这里遇到了完全相同的情况。当 map() 操作符发出 onError 信号时,由于 retry() 操作符的存在,它会立即重新订阅,然后 retry() 操作符会重新订阅到 RangeObservable 并从开始处开始发出值。

这通过以下操作符的宝石图得到了很好的展示(注意红色和黄色的宝石):

retry() 操作符

表示 retry() 操作符的宝石图,来自 http://reactivex.io/documentation/operators/retry.html

如果我们想要模拟一个从 1 到 6 的数字(除了数字 3)的情况,我们可以创建一个外部变量 $count 并递增它,而不是依赖于来自 RangeObservable 的值。为了停止发出值,我们可以使用 takeWhile(),当其可调用返回 false 时,它会调用 onCompleted

// retry_05.php 
$count = 0; 
Rx\Observable::range(1, 6) 
    ->map(function($val) use (&$count) { 
        if (++$count == 3) { 
            throw new \Exception('error'); 
        } 
        return $count; 
    }) 
    ->retry(3) 
    ->takeWhile(function($val) { 
        return $val <= 6; 
    }) 
    ->subscribe(new DebugSubject()); 

输出结果正如我们所预期的:

$ php retry_05.php
14:18:01 [] onNext: 1 (integer)
14:18:01 [] onNext: 2 (integer)
14:18:01 [] onNext: 4 (integer)
14:18:01 [] onNext: 5 (integer)
14:18:01 [] onNext: 6 (integer)
14:18:01 [] onCompleted

CURLObservable 和 retry() 操作符

我们可以创建一个更接近真实世界应用的简单测试场景。我们将使用我们的 CURLObservable 并尝试重复进行三次 HTTP 请求。我们将选择任何不存在的 URL 以确保每次都失败,以便查看在使用 retry() 时错误是如何通过操作符链传播的:

// retry_04.php 
Rx\Observable::defer(function() { 
        echo "Observable::defer\n"; 
        return new CurlObservable('https://example.com123'); 
    }) 
    ->retry(3) 
    ->subscribe(new DebugSubject()); 

我们已经在 第三章 中看到了 Observable::defer() 静态方法,使用 RxPHP 编写 Reddit 读者。我们在这里使用它来显示 retry() 操作符会导致重新订阅到源 Observable。

此示例将以下输出打印到控制台:

$ php retry_04.php 
Observable::defer()
Observable::defer()
Observable::defer()
13:14:20 [] onError (Exception): Unable to download https://ex...

我们可以看到错误(实际上是一个异常)在达到 DebugSubject 之前经过了三次迭代。

retryWhen() 操作符

retry() 类似,还有一个名为 retryWhen() 的操作符,与 retry() 不同的是,它不会立即重新订阅。操作符 retryWhen() 将一个可调用参数作为参数,该参数返回另一个 Observable。然后,当发生 onError 信号时,使用此 Observable 来安排重新订阅。

retryWhen() 操作符

表示 retryWhen() 操作符的宝石图,来自 http://reactivex.io/rxjs/class/es6/Observable.js~Observable.html#instance-method-retryWhen

CURLObservable 和 retryWhen() 操作符

例如,我们可以再次考虑我们的 CURLObservable 并想象我们希望在延迟一秒后重复失败的请求。由于 retryWhen() 的功能稍微复杂一些,我们将从一个示例开始:

// retry_when_01.php 
$loop = new \React\EventLoop\StreamSelectLoop(); 
$scheduler = new \Rx\Scheduler\EventLoopScheduler($loop); 

(new CURLObservable('https://example.com123')) 
    ->retryWhen(function($errObs) use ($scheduler) { 
        $notificationObs = $errObs 
            ->delay(1000, $scheduler) 
            ->map(function() { 
                echo "\$notificationObs\n"; 
                return true; 
            }); 
        return $notificationObs; 
    }) 
    ->subscribe(new DebugSubject(), $scheduler); 

$scheduler->start(); 

注意

我们需要使用事件循环来安排 delay() 操作符。

传递给 retryWhen() 的可调用参数是一个 Observable,并且必须返回一个 Observable。然后,当发生错误信号时,它作为 onNext 推送到 $errObs,这样我们就可以根据错误的类型决定我们想要做什么。根据返回的 $notificationObs 的发射,我们可以控制接下来会发生什么:

  • onNext:当 $notificationObs 发出 onNext 信号时,retryWhen() 操作符会重新订阅到其源 Observable。请注意,发出的值并不重要。

  • onError:错误会进一步传播到操作符链。

  • onCompleteonComplete 信号会进一步传播到操作符链。

前面的例子所做的事情应该是显而易见的。当 CURLObservable 失败(发出 onError)时,retryWhen() 操作符会等待一秒钟,多亏了 delay() 操作符,然后重新订阅,这将使 CURLObservable 无限期地再次尝试下载 URL。此示例的输出如下所示:

$ php retry_when_01.php
onNext
onNext
onNext
...

由于 retryWhen() 操作符稍微复杂一些,我们可以查看其内部结构来理解它为什么能按这种方式工作:

  • 它创建了一个 Subject 实例并将其引用存储在一个名为 $errors 的变量中。Subjects 同时作为 Observables 和观察者工作。它需要使用 Subject,因为它很重要,能够手动触发信号,如 onNext,而仅仅使用 Observables 是不可能的。

  • 当操作符调用其可调用函数时,它会传递 $errors->asObservable() 并期望接收一个 Observables,该 Observables 存储在另一个变量中,称为 $whenasObservable() 方法用 AnonymousObservable 包装 Subject,因此隐藏了它实际上是一个 Subject 实例的事实。

  • 然后,CallbackObserver 订阅了 $when,它稍后可以重新订阅此操作符的源 Observables。

  • 这意味着我们有 Observables 链的“头部”和“尾部”分别存储在变量 $errors$when 中。

  • 之后,当接收到 onError 信号时,操作符会调用 $errors->onNext(),这会将值通过 Observables 的链式传递。在我们的例子中,它通过 delay() 操作符。

如果我们将前面的点重写为实际的、高度简化的代码,它看起来会像以下这样:

$errors = new Subject(); 
$when = call_user_func($callable, $errors->asObservable()); 

$subscribe = function() use ($observable, $observer, $errors) { 
    $observable->subscribe(new CallbackObserver( 
        [$observer, 'onNext'], 
        function() use ($errors) { 
            $errors->onNext($errors); 
        }), 
        [$observer, 'onCompleted'] 
    ); 
}; 
$when->subscribe(new CallbackObserver(function() use ($subscribe){ 
    $subscribe(); 
})); 

$subscribe(); 

这个操作符不关心 onNextonComplete,而是直接将它们传递给 $observer。它需要处理的唯一信号是 onError,该信号会调用 $errors->onNext(),从而触发 Observables 的链式调用,最终在 $when->subscribe() 可调用内部重新订阅源 Observables。

使用 Subject 实例来手动触发信号并同时订阅观察者的这种技术非常有用。我们将在实现事件分发器时使用它。

CURLObservable 和受控的重试次数

当讨论 retry() 操作符时,我们做了一个演示,尝试下载一个 URL 三次,然后失败。重试次数被固定为 3。

我们可以使用 retryWhen() 操作符创建相同的示例,同时如果我们想要重试 HTTP 请求,我们可以有更多的控制。考虑以下示例,我们尝试下载一个 URL 三次,然后进一步传播错误:

// retry_when_02.php 
use Rx\Observable; 
$loop = new \React\EventLoop\StreamSelectLoop(); 
$scheduler = new \Rx\Scheduler\EventLoopScheduler($loop); 

(new CURLObservable('https://example.com123')) 
    ->retryWhen(function($errObs) use ($scheduler) { 
        echo "retryWhen\n"; 
        $i = 1; 
        $notificationObs = $errObs 
            ->delay(1000, $scheduler) 
            ->map(function(Exception $val) use (&$i) { 
                echo "attempt: $i\n"; 
                if ($i == 3) { 
                    throw $val; 
                } 
                $i++; 
                return $val; 
            }); 

        return $notificationObs; 
    }) 
    ->subscribe(new DebugSubject(), $scheduler); 

$loop->run(); 

在这个示例中,我们尝试了三次,每次延迟一秒然后重新抛出异常,该异常被 map() 操作符捕获并作为 onError 信号传递。由于 $notificationObs 发送了 onError 信号,retryWhen() 操作符将此错误进一步传递,正如之前解释的那样。我们还打印了字符串 retryWhen 来证明即使有多次重试,可调用函数也只被调用一次。

这个示例的输出如下:

$ php retry_when_02.php
retryWhen
attempt: 1
attempt: 2
attempt: 3
14:36:13 [] onError (Exception): Unable to download https://ex...

这个演示有趣的地方在于它根本不需要以错误结束。我们可以使用 $notificationObs 来发出 onComplete 信号。

内部可调用函数可能看起来像以下代码:

// retry_when_03.php 
... 
$notificationObs = $errObs 
    ->delay(1000, $scheduler) 
    ->map(function(Exception $val) use (&$i) { 
        echo "attempt: $i\n"; 
        $i++; 
        return $val; 
    }) 
    ->take(3); 
... 

与前一个示例相比,我们不是重新抛出异常,而是只发出 onComplete

$ php retry_when_03.php 
retryWhen
attempt: 1
attempt: 2
attempt: 3
15:30:01 [] onCompleted

这可能在即使多次失败重试也不一定意味着错误状态的情况下很有用。

catchError() 操作符

操作符 catchError() 也只处理错误信号。当它收到一个 onError 时,它会调用一个返回 Observable 的可调用函数,然后使用该 Observable 来继续 Observable 序列,而不是源 Observable。

考虑以下示例:

use Rx\Observable; 
Observable::range(1,6) 
    ->map(function($val) { 
        if ($val == 3) { 
            throw new Exception(); 
        } 
        return $val; 
    }) 
    ->catchError(function(Exception $e, Observable $sourceOb) { 
        return Observable::just(42); 
    }) 
    ->subscribe(new DebugSubject()); 

在这个示例中,onError 信号被 catchError() 捕获,并且由于 Observable::just(),它继续以单个值结束整个 Observable 序列,然后以 onComplete 结束:

$ php catch_01.php 
06:43:04 [] onNext: 1 (integer)
06:43:04 [] onNext: 2 (integer)
06:43:04 [] onNext: 42 (integer)
06:43:04 [] onCompleted

concat() 和 merge() 操作符

使用 retry()retryWhen(),我们遇到了一些接受其他 Observables 作为参数并处理它们的发射的算子。将多个 Observables 合并成一个单一的链是 RxJS 中的常见做法,这主要是由于 JavaScript 设计上的异步性质。在 RxPHP 中,我们并不经常使用它们,但快速看一下它们是值得的。

merge() 操作符

为了将两个 Observables 合并成一个单一的 Observable,该 Observable 发射来自它们的所有值(包括 onError 信号),我们可以使用 merge() 操作符。

merge() 操作符

来自 http://reactivex.io/rxjs/class/es6/Observable.js~Observable.html#static-method-merge 的 merge() 操作符的宝石图

如我们从宝石图中可以看到,这个操作符重新发射来自源和合并的 Observables 的值。这意味着它订阅了它们两个,并且随着它们的到达而发射值。

为了更好地理解它是如何工作的,我们可以用一个简单的示例来演示,其中包含两个间隔 Observables,每个间隔以不同的延迟发射三个值:

// merge_01.php 
use Rx\Observable; 
$loop = new \React\EventLoop\StreamSelectLoop(); 
$scheduler = new \Rx\Scheduler\EventLoopScheduler($loop); 

$merge = Observable::interval(100) 
    ->map(function($value) { 
        return 'M' . $value; 
    }) 
    ->take(3); 

$source = Observable::interval(300) 
    ->map(function($value) { 
        return 'S' . $value; 
    }) 
    ->take(3) 
    ->merge($merge) 
    ->subscribe(new DebugSubject(), $scheduler); 

$loop->run(); 

$merge Observable 发射其值的速度比 $source 快。我们还为每个值添加前缀以标记其来源,所以这个示例的输出如下:

$ php merge_01.php
22:00:28 [] onNext: M0 (string)
22:00:28 [] onNext: M1 (string)
22:00:28 [] onNext: S0 (string)
22:00:28 [] onNext: M2 (string)
22:00:29 [] onNext: S1 (string)
22:00:29 [] onNext: S2 (string)
22:00:29 [] onCompleted

我们可以看到值被混合在一起。然而,当两个 Observables 都完成时,只有一个 onComplete 信号,所以整体上它表现得像一个单一的 Observable。

concat() 操作符

merge()相反,有时我们可能想要合并两个 Observable,但首先发出第一个 Observable 的所有值,当它完成时,订阅第二个 Observable 并发出其所有值。因此,也存在concat()操作符:

concat()操作符

表示 concat()操作符的宝石图,来自 http://reactivex.io/rxjs/class/es6/Observable.js~Observable.html#instance-method-concat

我们可以拿与merge()相同的例子,只需将merge()操作符切换为concat()

// concat_01.php 
use Rx\Observable; 
$loop = new \React\EventLoop\StreamSelectLoop(); 
$scheduler = new \Rx\Scheduler\EventLoopScheduler($loop); 

$concat = Observable::interval(100) 
    ->map(function($value) { 
        return 'C' . $value; 
    }) 
    ->take(3); 

$source = Observable::interval(300) 
    ->map(function($value) { 
        return 'S' . $value; 
    }) 
    ->take(3) 
    ->concat($concat) 
    ->subscribe(new DebugSubject(), $scheduler); 

$loop->run(); 

由于concat()应该订阅源 Observable 并依次连接其他 Observable,因此我们预期首先接收到源 Observable 的所有值,当它完成时,再接收到$concat Observable 的所有值。

$ php concat_01.php 
22:25:45 [] onNext: S0 (string)
22:25:45 [] onNext: S1 (string)
22:25:46 [] onNext: S2 (string)
22:25:46 [] onNext: C0 (string)
22:25:46 [] onNext: C1 (string)
22:25:46 [] onNext: C2 (string)
22:25:46 [] onCompleted

即使连接的 Observable 比源 Observable 更快地发出值,但其值仍然跟在源 Observable 的所有值之后。

concatMap()和 flatMap()操作符

merge()concat()操作符都有它们的*map()变体。特别是这些是flatMap()concatMap()。这些操作符结合了merge()/concat()map()操作符的功能。如果我们看看我们刚才做的两个例子,我们会看到我们需要事先知道内部 Observable。这意味着内部 Observable 在创建 Observable 链时被传递给concat()/merge()一次。

我们将选择两个操作符中的一个,并通过一个示例来解释其优点。

让我们假设我们想要依次进行三个 HTTP 请求。这看起来像是concat()操作符的理想用例。然而,每个请求都将依赖于前一个请求的结果,因此我们需要使用concatMap(),因为它的可调用参数是源 Observable 的当前值,并返回一个将被连接到链中的 Observable:

// concat_map_01.php 
use Rx\Observable; 

function createCURLObservable($num) { 
    $url = 'http://httpbin.org/get?num=' . $num; 
    echo "$url\n"; 
    return (new CURLObservable($url)) 
        ->filter(function($response) { 
            return is_string($response); 
        }); 
} 

$source = Observable::emptyObservable() 
    ->concat(createCURLObservable(rand(1, 100))) 
    ->concatMap(function($response) { 
        $json = json_decode($response, true); 
        return createCURLObservable(2 * $json['args']['num']); 
    }) 
    ->concatMap(function($response) { 
        $json = json_decode($response, true); 
        return createCURLObservable(2 * $json['args']['num']); 
    }) 
    ->subscribe(new DebugSubject()); 

我们正在使用httpbin.org/get网络服务,它作为测试服务器,并将我们发送的请求作为 JSON 字符串返回。

我们使用Observable::emptyObservable()创建一个立即完成的空 Observable,并将其与一个concat()和两个concatMap()操作符连接起来。然后,每个concatMap()解码前一个请求的 JSON,将其num参数乘以 2,并重新发送 HTTP 请求。

然后,从控制台输出中,我们可以看到请求是按顺序调用的,并且在concat()操作符调用中创建的随机num参数在每次请求中都会乘以 2:

$ php concat_map_01.php
http://httpbin.org/get?num=51
http://httpbin.org/get?num=102
http://httpbin.org/get?num=204
22:54:37 [] onNext: {
 "args": {
 "num": "204"
 }, 
 "headers": {
 "Accept"... (string)
22:54:37 [] onCompleted

使用flatMap(),例子将是相同的。然而,由于 PHP 不像 JavaScript 那样是异步的,flatMap()操作符在这个特定用例中并不那么有用。

我们将在第六章中查看更多结合多个 Observables 的操作符,PHP Streams API 和 Higher-Order Observables

编写响应式事件派发器

Symfony 的EventDispatcher组件是一个用于在对象之间交换消息的 PHP 库。它基于中介者设计模式(en.wikipedia.org/wiki/Mediator_pattern),其实现相对简单。

一个非常常见的场景是我们有一个想要通过插件扩展的应用程序。在这种情况下,我们会创建一个EventDispatcher的单例实例,并让插件监听各种事件。每个事件都是一个对象,它可以持有对其他对象的引用。这正是 Symfony3 框架所广泛使用的。

事件派发器快速入门

如果你还没有这样做,请通过composer安装事件派发器组件:

$ composer require symfony/event-dispatcher

首先,我们将查看默认实现的实际使用方法,这样我们就可以稍后将其与我们的响应式实现进行比较,并检查从开发者的角度来看,两者是否都能正常工作,尽管内部实现不同。

与事件监听器一起工作

在最基本的情况下,我们只想设置几个监听器并派发事件:

// event_dispatcher_01.php  
use Symfony\Component\EventDispatcher\EventDispatcher; 
use Symfony\Component\EventDispatcher\Event; 

$dispatcher = new EventDispatcher(); 
$dispatcher->addListener('my_action', function() { 
    echo "Listener #1\n"; 
}); 
$dispatcher->addListener('other_action', function() { 
    echo "Other listener\n"; 
}); 
$dispatcher->addListener('my_action', function() { 
    echo "Listener #2\n"; 
}); 

$dispatcher->dispatch('my_action'); 

我们为两个不同的事件my_actionother_action创建了三个事件监听器。然后,通过$dispatcher->dispatch(),我们告诉事件派发器通知所有事件监听器,事件my_action已经发生。

控制台中的输出应该是明显的:

$ php event_dispatcher_01.php  
Listener #1 
Listener #2 

dispatch()方法接受一个可选的第二个参数,该参数是一个Event类的实例,它可以包含有关事件的更多信息。如果需要,事件监听器也可以修改事件数据。此外,所有可调用对象都恰好接受一个事件对象作为参数,该参数来自对dispatch()方法的初始调用。由于我们没有提供任何事件对象,我们的可调用对象不需要接受任何参数。

注意,事件派发器不需要知道它支持哪些事件,因为它们是即时创建的。这也意味着你可能会意外地尝试派发一个不存在的事件:

$dispatcher->dispatch('foo_my_action'); 

这不会引发错误,但不会派发任何事件。

EventDispatcher类支持两个重要的特性:

  • 优先级:默认情况下,监听器按照它们订阅事件派发器的顺序执行。我们可以通过向addListener()方法提供一个带有特定监听器优先级的第三个参数来改变这种行为(默认为 0)。优先级较高的监听器会先执行。如果多个监听器具有相同的优先级,那么它们的添加顺序就很重要了。

  • 停止事件传播:在某些场景中,能够停止将特定事件传播给后续监听器是很重要的。因此,Event类有一个名为stopPropagation()的方法。然后事件派发器负责不再传播此事件。

这两个特性可以用于以下情况:

// event_dispatcher_02.php  
$dispatcher = new EventDispatcher(); 

$dispatcher->addListener('my_action', function(Event $event) { 
    echo "Listener #1\n"; 
}); 
$dispatcher->addListener('my_action', function(Event $event) { 
    echo "Listener #2\n"; 
    $event->stopPropagation(); 
}, 1); 

$dispatcher->dispatch('my_action', new Event()); 

第一个事件监听器应该在第二个监听器之后被调用,因为它具有更高的优先级,但它使用 $event->stopPropagation() 停止进一步传播此事件,所以它永远不会被调用。

控制台输出非常简短:

$ php event_dispatcher_02.php
Listener #2

与事件订阅者一起工作

虽然 addListener() 订阅单个事件监听器,但还有一个 addSubscriber() 方法,它接受一个实现 EventSubscriberInterface 的类的实例,并一次性订阅多个事件。实际上,addSubscriber() 使用 addListener() 内部添加监听器。有时将所有监听器包装到一个类中比逐个添加它们要简单得多。

在本章的整个内容和即将到来的示例中,我们还将使用一个自定义的 Event 类,以便正确测试默认和我们的响应式实现是否工作相同。

首先,让我们声明我们的事件类:

// MyEvent.php 
use Symfony\Component\EventDispatcher\Event; 

class MyEvent extends Event { 
  private $name; 
  private $counter = 0; 

  public function __construct($name = null, $counter = 0) { 
    $this->name = $name; 
    $this->counter = $counter; 
  } 
  public function getCounter() { 
    return $this->counter; 
  } 
  public function inc() { 
    $this->counter++; 
  } 
  public function __toString() { 
    return sprintf('%s (%d)', $this->name, $this->counter); 
  } 
} 

这是一个相当简单的类。我们将使用 inc() 方法来验证所有监听器都使用相同的 MyEvent 实例。我们还使用了 __toString() 魔法方法,这样我们就可以通过类型转换将这个类转换为字符串。

现在,为了演示目的,我们将声明一个具有三个事件监听器的 MyEventSubscriber 类:

// MyEventSubscriber.php 
use Symfony\Component\EventDispatcher\EventDispatcher; 
use Symfony\Component\EventDispatcher\Event; 
use Symfony\Component\EventDispatcher\EventSubscriberInterface; 

class MyEventSubscriber implements EventSubscriberInterface { 
  public static function getSubscribedEvents() { 
    return [ 
      'my_action' => [ 
        ['onMyActionA'], 
        ['onMyActionAgain', 1], 
      ], 
      'other_action' => 'onOtherAction', 
    ]; 
  } 

  public function onMyActionA(MyEvent $event) { 
    $event->inc(); 
    echo sprintf('Listener [onMyAction]: %s\n', $event); 
  } 

  public function onMyActionAgain(MyEvent $event) { 
    $event->inc(); 
    echo sprintf('Listener [onMyActionAgain]: %s\n', $event); 
  } 

  public function onOtherAction(Event $event) { } 
} 

EventSubscriberInterface 接口只需要一个静态方法 getSubscribedEvents(),它返回一个包含事件名称及其相应可调用对象的关联数组。

这个示例类声明了两个监听器用于 my_action 事件(第二个监听器的优先级高于第一个)和一个监听器用于 other_action 事件。

订阅到这个类的方式与订阅监听器的方式相同:

$dispatcher = new EventDispatcher(); 
$dispatcher->addSubscriber(new MyEventSubscriber()); 
$dispatcher->dispatch('my_action', new MyEvent('my-event')); 

这次,示例还打印了事件的字符串表示:

$ php event_dispatcher_03.php 
Listener [onMyActionAgain]: my-event (1)
Listener [onMyAction]: my-event (2)

这是我们需要处理的另一件事,因为我们希望允许在事件订阅者类中定义事件观察者。

现在我们知道了默认的 EventDispatcher 类如何使用以及它应该满足哪些用例。我们的目标将是基于 RxPHP 和响应式编程编写我们自己的实现。

使用 RxPHP 编写 ReactiveEventDispatcher

事件调度器需要实现一个 EventDispatcherInterface 接口,该接口定义了我们之前看到的所有方法,我们还将添加一些更多。幸运的是,我们可以重用默认 EventDispatcher 类的大部分内容。例如,removeListener()removeSubscriber() 方法将无需任何修改即可工作。

事件监听器作为观察者的内部表示

原始的 EventDispatcher 有一个非常简单的任务。在 dispatch() 调用中,它只是根据优先级对特定事件的监听器数组进行排序,并逐个评估它们,在一个循环中:

// snippet from Symfony\Component\EventDispatcher\EventDispatcher 
foreach ($listeners as $listener) { 
    if ($event->isPropagationStopped()) { 
        break; 
    } 
    call_user_func($listener, $event, $eventName, $this); 
} 

在我们的例子中,我们将所有事件监听器表示为观察者。实际上,当我们添加一个新的事件监听器时,我们会将其可调用对象转换为观察者。然后,在调用 dispatch() 时,我们将创建一个包含所有观察者的可观察对象链,这些观察者已经订阅在特定的点上。当然,我们还需要自己处理 isPropagationStopped() 条件。

例如,让我们考虑事件分发器的最简单用法,如之前所示:

$dispatcher->addListener('my_action', function() { 
    echo "Listener #1\n"; 
}); 
$dispatcher->addListener('my_action', function() { 
    echo "Listener #2\n"; 
}); 

我们必须将这些两个事件监听器转换成一个可观察对象链,同时确保在执行每个事件监听器之前,检查事件对象是否设置了停止传播标志:

// reactive_dispatcher_03.php 
$subject = new Subject(); 

$tail = $subject->filter(function(Event $event) { 
    return !$event->isPropagationStopped(); 
}); 
$tail->subscribe(new CallbackObserver(function(Event $event) { 
    echo "Listener #1\n"; 
    $event->stopPropagation(); 
})); 

$tail = $tail->filter(function(Event $event) { 
    return !$event->isPropagationStopped(); 
}); 
$tail->subscribe(new CallbackObserver(function(Event $event) { 
    echo "Listener #2\n"; 
})); 

$subject->onNext(new Event()); 

我们在这里使用 Subject 的原因与我们在本章前面解释 retryWhen() 操作符时相同。不过,让我们更详细地解释这段代码:

  • $subject 变量持有对 "链头" 的可观察对象链的引用

  • $tail 变量始终持有对链中最后一个可观察对象的引用。这是我们进一步链式连接更多可观察对象的地方,也是我们附加 filter() 操作符以检查停止事件的地方。

  • 当我们想要分发一个事件时,我们只需调用 $subject->onNext()

为了更清楚地表示当前的可观察对象链,我们可以将其表示为一个树结构:

事件监听器作为观察者的内部表示

现在我们只需要将这些内容转换成一个真正的 PHP 类。

编写 ReactiveEventDispatcher 类

好事是,我们可以实际重用 EventDispatcher 中已经编写的大量逻辑,只需覆盖需要以不同方式工作的某些方法。

首先,我们将简单地写一个类占位符来看看前方等待着我们的是什么,并对每个方法进行简要说明:

// ReactiveEventDispatcher.php 
class ReactiveEventDispatcher extends EventDispatcher { 
  /** 
   * @var Subject[]; 
   */ 
  private $subjects = []; 

  public function dispatch($eventName, Event $event = null) {} 

  public function addListener($eventName, $listener, $prio=0) {} 

  public function addObservable($eventName, $create, $prio=0) {} 

  public function addSubscriber($subscriber) {} 

  private function observerFromListener($listener) {} 

  private function getSubject($eventName) {} 
} 

只有 EventDispatcher 中的 dispatch()addListener()addSubscriber() 方法需要重写;其余的可以保持原样。我们还添加了三个额外的方法来帮助我们处理可观察对象。

让我们看看每个组件的作用:

  • $subjects: 一个关联数组,持有对所有可观察对象链头(它们的主题)的引用。

  • addListener(): 我们已经从之前的例子中知道了这个方法。然而,现在这个方法也接受观察者作为事件监听器。

  • addObservable(): 这是一个方法,允许我们将一个可观察对象追加到由 getSubject() 生成的可观察对象链的特定点上。

  • addSubscriber(): 这个方法使用订阅类订阅多个事件。它使用 addObserver() 方法。

  • dispatch(): 这个方法接受特定事件的 Subject 实例,并使用事件对象作为参数调用 onNext()

  • observerFromListener(): 一个辅助方法,将任何监听器转换为观察者。基本上,这只是在每个可调用对象上包装一个 CallbackObserver 对象。

  • getSubject():我们的事件调度器将使用 Subjects。这个方法内部按优先级对监听器数组进行排序,并从它们构建一个 Observables 链。它还会在$subjects关联数组中保留 Subjects,以便可以轻松重用,而无需在每次dispatch()调用时重新创建 Observable 链。

因此,我们对这个事件调度器的工作方式有了相当清晰的了解,我们可以开始实现每个方法。

添加事件监听器

前两个方法将是addListener()observerFromListener()。第一个方法依赖于第二个,所以我们将同时编写这两个方法:


// ReactiveEventDispatcher.php 
class ReactiveEventDispatcher extends EventDispatcher { 
  /** 
   * @param string $eventName 
   * @param callable|ObserverInterface $listener 
   * @param int $prio 
   * @throws Exception 
   */ 
  public function addListener($eventName, $listener, $prio = 0) { 
    $observer = $this->observerFromListener($listener); 
    parent::addListener($eventName, $observer, $prio); 
    unset($this->subjects[$eventName]); 
  } 

  /** 
   * @param callable|ObserverInterface $listener 
   * @return ObserverInterface 
   */ 
  private function observerFromListener($listener) { 
    if (is_callable($listener)) { 
      return new CallbackObserver($listener); 
    } elseif ($listener instanceof ObserverInterface) { 
      return $listener; 
    } else { 
      throw new \Exception(); 
    } 
  } 

  /* rest of the class */ 
} 

注意

在本章的其余示例中,我们还将包括每个方法的 doc blocks 和类型提示,以明确它接受的参数。

observerFromListener()方法检查$listener的运行时类型,并将其始终转换为观察者实例。

addListener()方法内部使用observerFromListener(),然后以观察者的形式调用其父addListener(),尽管它最初只接受可调用对象。父方法通过事件名称和优先级将监听器存储在一个嵌套的关联数组中。由于父代码相当通用,我们不需要对其进行任何修改,因此我们将保持原样。

注意,在我们调用父addListener()之后,我们将一个 Subject 从特定的$subjects数组中移除。这是因为我们修改了这个事件的 Observable 链,它需要从头开始创建。这发生在稍后调用dispatch()方法时。

添加 Observables

说到监听器,我们现在也可以实现addObservable(),这是addListener()的一个稍微修改过的版本。这个方法将不同于addListener()的使用方式,因此它值得特别注意:

class ReactiveEventDispatcher extends EventDispatcher { 
  /** 
   * @param string $evtName 
   * @param callable $create 
   * @param int $prio 
   */ 
  public function addObservable($evtName, $create, $prio=0) { 
    $subject = new Subject(); 
    $create($subject->asObservable()); 
    $this->addListener($evtName, $subject, $prio); 
  } 

  /* rest of the class */ 
} 

我们创建了一个Subject实例并调用asObservable(),以便用户定义的可调用对象将其操作符附加到它上。然后我们使用之前解释过的$subject变量调用addListener()。这同样是我们之前用retryWhen()操作符描述过的相同技术。

这个方法很有趣,因为它允许我们添加一个“子链”的 Observables 作为监听器。考虑以下代码:

$dispatcher->addObservable('my_action', function($observable) { 
  $observable 
    ->map(function($value) { return $value; }) 
    ->filter(function($value) { return true; }) 
    ->subscribe(new DebugSubject()); 
}); 

如果我们像之前那样将这段代码表示为一个树状结构,在$dispatcher内部,它将看起来如下(这个结构是在getSubject()方法中稍后生成的):

添加 Observables

事件“my_action”有两个事件监听器

因此,这个事件监听器在其可调用对象中附加了一系列操作符,然后订阅它。在addObservable()方法中,我们只传递$subject本身到addListener(),当调用dispatch()时,它将被附加到filter()操作符上。这要归功于 Subjects 同时作为观察者工作并可以订阅 Observables 的事实。

这就是编写我们自定义的 ReactiveEventDispatcher 的主要好处。我们使用响应式编程以非常直接的方式轻松地操作我们感兴趣的事件。如果我们使用默认的事件派发器,我们就必须在可调用的内部放置所有与监听器相关的特定条件。

添加事件订阅者

与原始的 EventDispatcher 类似,我们希望能够一次订阅多个监听器到多个事件,使用一个订阅者类。然而,我们还将支持将监听器作为可观察的添加,就像我们在 addObservable() 中做的那样。如果没有重载父类的 addSubscriber() 方法并以特殊方式处理可观察的,这是无法实现的。

基本上,我们需要调用 addObservable() 而不是 addListener() 方法:

首先,让我们定义一个接口,我们可以用它来识别一个同时定义了可观察作为监听器的事件订阅者类:

// EventObservableSubscriberInterface.php 
use Symfony\Component\EventDispatcher\EventSubscriberInterface; 

interface EventObservableSubscriberInterface extends  
    EventSubscriberInterface { 
  public static function getSubscribedEventsObservables(); 
} 

现在,addSubscriber() 可以检查类是否是这个接口的实例,如果是,就将其所有监听器处理成好像它们都是可观察的:

use EventObservableSubscriberInterface as RxEventSubscriber; 
class ReactiveEventDispatcher extends EventDispatcher { 
  /** 
   * @param EventSubscriberInterface $subscriber The subscriber 
   */ 
  public function addSubscriber(EventSubscriberInterface $sub) { 
    parent::addSubscriber($sub); 

    if ($sub instanceof RxEventSubscriber) { 
      $events = $sub->getSubscribedEventsObservables(); 
      foreach ($events as $evt => $params) { 
        if (is_callable($params)) { 
          $this->addObservable($evt, $params); 
        } else { 
          foreach ($params as $listener) { 
            $prio = isset($listener[1]) ? $listener[1] : 0; 
            $this->addObservable($evt, $listener[0], $prio); 
          } 
        } 
      } 
    } 
  } 

  /* rest of the class */ 
} 

事件监听器的数组可以定义为一个键为事件名称,值为可调用的数组。然而,我们的实现也支持使用数组值作为另一个数组,定义可调用和优先级(这就是第二个嵌套的 foreach 循环)。

在这个方法的开头,我们也调用了其父类,因为我们想允许默认的功能。

为了演示目的,我们将扩展我们之前定义的 MyEventSubscriber 类,并实现 getSubscribedEventsObservables() 方法,该方法将返回两个事件监听器:

// MyObservableEventSubscriber.php 
use Symfony\Component\EventDispatcher\EventSubscriberInterface; 
use Rx\Observable; 
require_once __DIR__ . '/MyEventSubscriber.php'; 

class MyObservableEventSubscriber extends MyEventSubscriber  
    implements EventObservableSubscriberInterface { 

  public static function getSubscribedEventsObservables() { 
    return [ 
      'my_action' => [ 
        [ 
          function(Observable $observable) { 
            $observable->subscribe(new DebugSubject()); 
          }, 10 
        ], [ 
          function(Observable $observable) { 
            $observable 
              ->subscribe(new DebugSubject()); 
          } 
        ] 
      ], 
      'other_action' => function(Observable $observable) { 
        $observable->subscribe(new DebugSubject()); 
      } 
    ] 
  } 
} 

我们为两个不同的事件定义了三个事件监听器,其中第一个 my_action 事件的优先级为 10,第二个为默认的 0

为事件创建可观察链

getSubject() 方法是生成可观察链的地方。这个方法仅在派发事件时被调用:

class ReactiveEventDispatcher extends EventDispatcher { 
  /** 
   * @param string $eventName 
   * @return Subject 
   */ 
  private function getSubject($eventName) { 
    if (isset($this->subjects[$eventName])) { 
      return $this->subjects[$eventName]; 
    } 

    $subject = new Subject(); 
    $this->subjects[$eventName] = $subject; 
    $tail = $subject->asObservable(); 

    foreach ($this->getListeners($eventName) as $listener) { 
      $newTail = $tail->filter(function (Event $event) { 
        return !$event->isPropagationStopped(); 
      }); 
      $newTail->subscribe($listener); 
      $tail = $newTail; 
    } 
    return $subject; 
  } 

  /* rest of the class */ 
} 

如果这个事件的 Subject 不存在,我们创建一个新的,然后调用 getListeners()。这个方法定义在父类 EventDispatcher 中,并返回一个按顺序排列的监听器(或观察者,在我们的例子中)数组。然后我们遍历这个数组,添加一个 filter() 操作符,然后根据我们是否使用了 addListener()addObservable() 来订阅观察者或 Subject。

注意

注意,操作符(如本例中的 filter())总是返回一个新的可观察对象,而调用 subscribe() 则返回一个可丢弃的对象。

我们不需要每次调用这个方法时都创建 $subject,因为它在我们添加新的监听器之前不会改变,所以我们可以将其引用保存在 $subjects 数组中。

比较 filter() 和 takeWhile()

在之前的第三章使用 RxPHP 编写 Reddit 阅读器中,我们提到了一个可能比filter()更好的操作符。我们使用了takeWhile(),它也接受一个谓词可调用参数,并且可以在 Observable 链中停止传播值。

重要的区别是filter()操作符决定是否在其关联的观察者上内部调用onNext()。另一方面,takeWhile()决定是否调用onComplete()。调用onComplete()会导致调用可丢弃对象,这将取消订阅观察者,这绝对不是我们想要的。如果我们取消订阅,我们就必须在每次dispatch()调用时为每个事件创建 Subject。

注意

在第八章RxPHP 和 PHP7 pthreads 扩展中的多播中,我们将更详细地讨论在Subject上调用onComplete可能产生的意外后果。

分发事件

最后,分发事件非常简单:

class ReactiveEventDispatcher extends EventDispatcher { 
  public function dispatch($eventName, Event $event = null) { 
    if (null === $event) { 
      $event = new Event(); 
    } 
    $subject = $this->getSubject($eventName); 
    $subject->onNext($event); 
    return $event; 
  } 

  /* rest of the class */ 
} 

在我们的反应式分发器中分发一个事件意味着获取该特定事件的 Subject 并调用其onNext()方法,将事件作为参数。除非调用stopPropagation()方法,否则事件会继续传播,因为我们会在调用每个观察者之前使用filter()操作符检查其状态。

我们还从方法中返回事件,以保持与默认EventDispatcher实现的兼容性。

就这样。我们的ReactiveEventDispatcher就完成了,我们可以运行几个测试场景。

ReactiveEventDispatcher 的实际示例

我们在本章的第一部分专门解释了默认的EventDispatcher是如何与 Symfony 的EventDispatcher组件一起工作的,以及我们期望它处理的用例。

现在,我们需要确保ReactiveEventDispatcher也适用同样的规则。

与事件监听器一起工作

我们知道我们的重写addListener()方法现在接受可调用和观察者,因此我们可以在一个示例中测试这两种用例:

// reactive_dispatcher_02.php 
$disp = new ReactiveEventDispatcher(); 
$disp->addListener(' my.action ', function(Event $event) { 
  echo "Listener #1\n"; 
}); 
$disp->addListener(' my.action ', new CallbackObserver(function($e) { 
  echo "Listener #2\n"; 
}), 1); 
$disp->dispatch(' my.action ', new Event()); 

此示例首先调用第二个监听器,然后调用第一个监听器,因为第二个监听器的优先级更高:

$ php reactive_dispatcher_02.php
Listener #2
Listener #1

现在,让我们使用与前面示例中相同的MyEventSubscriber类来测试事件订阅者。使用方式和输出完全相同,因此我们不需要再次打印输出:

// reactive_dispatcher_04.php 
$dispatcher = new ReactiveEventDispatcher(); 
$dispatcher->addSubscriber(new MyEventSubscriber()); 
$dispatcher->dispatch('my_action', new MyEvent()); 

addListener() as well.

一个稍微修改的例子,它分发多个事件,使用 Observables 并玩弄条件停止事件传播,可能看起来像以下这样:

// reactive_dispatcher_05.php 
$dispatcher = new ReactiveEventDispatcher(); 
$dispatcher->addListener('my_action', function(MyEvent $event) { 
  echo "Listener #1\n"; 
}); 
$dispatcher->addObservable('my_action', function($observable) { 
  $observable 
    ->map(function(MyEvent $event) { 
      $event->inc(); 
      return $event; 
    }) 
    ->doOnNext(function(MyEvent $event) { 
      if ($event->getCounter() % 2 === 0) { 
        $event->stopPropagation(); 
      } 
    }) 
    ->subscribe(new DebugSubject()); 
}, 1); 

foreach (range(0, 5) as $i) { 
  $dispatcher->dispatch('my_action', new MyEvent('my-event', $i)); 
} 

其输出是显而易见的。当事件的getCounter()方法返回一个可以被 2 整除的数字时,事件会被停止,并且永远不会达到使用addListener()添加的第一个事件监听器:

$ php reactive_dispatcher_05.php 
23:27:08 [] onNext: my-event (1) (MyEvent)
Listener #1
23:27:08 [] onNext: my-event (2) (MyEvent)
23:27:08 [] onNext: my-event (3) (MyEvent)
Listener #1
23:27:08 [] onNext: my-event (4) (MyEvent)
23:27:08 [] onNext: my-event (5) (MyEvent)
Listener #1
23:27:08 [] onNext: my-event (6) (MyEvent)

与事件订阅者一起工作

让我们也测试一下之前定义的事件订阅者MyObservableEventSubscriber是否按预期工作:

// reactive_dispatcher_06.php 
$dispatcher = new ReactiveEventDispatcher(); 
$dispatcher->addSubscriber(new MyObservableEventSubscriber()); 
$dispatcher->dispatch('my_action', new MyEvent('my-event')); 

记住,我们扩展了原始的MyEventSubscriber并添加了两个额外的监听器,因此事件分发器首先添加从getSubscribedEvents()返回的监听器,然后添加从getSubscribedEventsObservables()返回的监听器:

$ php reactive_dispatcher_06.php
11:14:01 [] onNext: my-event (0) (MyEvent)
Listener [onMyActionAgain]: my-event (1)
Listener [onMyAction]: my-event (2)
11:14:01 [] onNext: my-event (2) (MyEvent)

优先级最高的监听器首先被调用。在我们的例子中,是优先级为 10 的第一个 Observable 监听器,然后调用onMyActionAgain(),优先级为 1,然后按照添加的顺序调用剩下的两个监听器。

摘要

本章主要关注 RxPHP 与典型非响应式代码的实用结合,并提出了对现有基于事件解决方案的不同方法。

具体来说,我们遇到了在 Observable 链中使用retry()RetryWhen()catch()操作符进行错误处理的问题。我们将 Observables 与concat()merge()concatMap()操作符相结合。我们使用 Subjects 动态创建 Observable 链并手动发出值。我们还介绍了 Symfony 的EventDispatcher组件,通过一系列示例展示了默认的EventDispatcher类如何使用。我们扩展并部分重写了EventDispatcher类,创建了ReactiveEventDispatcher,它增加了对 Observables 的支持。最后,我们使用ReactiveEventDispatcher重用了EventDispatcher的示例,以证明我们的实现可以作为即插即用的替代品。

Symfony 的EventDispatcher组件为解决大型应用程序中常见的通信问题和可扩展性问题提供了一个易于实现的解决方案。我们编写了ReactiveEventDispatcher来添加使用观察者作为事件监听器的功能。

在下一章中,我们将学习如何编写单元测试来测试 Observables、操作符和观察者。我们还将更深入地了解调度器,并了解它们在测试 RxPHP 代码中的重要性。

第五章:测试 RxPHP 代码

在本章中,我们将开始基于 RxPHP 测试代码。到目前为止,我们只是通过运行代码并观察控制台中的预期输出来进行代码测试。当然,这不是一个非常系统化的方法,因此是时候以自动化的方式开始测试我们的代码了。

更确切地说,在本章中,我们将做以下几件事:

  • 介绍doOn*()算子

  • 开始使用 PHPUnit 库进行单元测试

  • 讨论一般性的异步代码测试,并尝试一些常见的陷阱

  • 探索 RxPHP 附带用于测试的类,了解如何单独使用它们,以及它们如何融入整个方案中

  • 为了演示目的,创建一个SumObservable类,该类计算通过的所有整数的总和,并使用 RxPHP 测试工具对其进行测试

  • 编写一个简化的ForkJoinObservable类并对其进行测试

  • 强调在测试 Observables 和算子时关注时间的重要性

本章将非常注重代码,尽管大多数示例都很简单,目的是从单元测试的角度来看待之前章节的内容。虽然具有使用 PHPUnit 进行单元测试的经验会有所帮助,但并非必需。

除了编写单元测试之外,使用doOn*()算子调试 Observable 链还有一种非常常见的方法。

doOn*()算子

在上一章中,我们多次使用了map()算子,只是为了将 Observable 链内部发生的事情打印到控制台。然而,这并不方便。map()算子总是需要返回一个值,并将其传递到链的下一部分,并且它只能捕获onNext信号。

这就是为什么 RxPHP 有几个以doOn*为前缀的算子:

  • doOnNext()doOnError()doOnCompleted():每个算子都接受一个参数,当它们接收到相应的信号时执行该参数指定的可调用对象

  • doOnEach(): 这个算子接受一个ObserverInterface的实例作为参数,并为每个信号执行其处理程序

因此,这些算子与subscribeCallback()subscribe()方法非常相似。最大的优势在于doOn*算子内部的工作方式。它们永远不会修改通过的价值,只是执行我们的可调用对象,这对于快速调试 Observable 链或执行副作用而不创建订阅(这包括我们在第三章中讨论的与订阅 Observables 相关的一切)是非常理想的。

我们可以通过一个非常简单的例子来看出这一点:

// do_01.php 
use Rx\Observable; 
use Rx\ObserverInterface; 

Observable::create(function(ObserverInterface $obs) { 
        $obs->onNext(1); 
        $obs->onNext(2); 
        $obs->onError(new \Exception("it's broken")); 
    }) 
    ->doOnError(function(\Exception $value) { 
        echo $value->getMessage() . "\n"; 
    }) 
    ->subscribeCallback(function($value) { 
        echo "$value\n"; 
    }, function() {}); 

我们有一个单独的订阅者来处理onNextonError信号。注意,onError处理程序是空的,而doOnError()算子会打印异常信息。本例的控制台输出如下:

$ php do_01.php 
1
2
doOnError: it's broken

使用剩余的doOn*()操作符的方式完全相同。显然,我们不会使用这些操作符来测试 RxPHP 代码,但这些通常是查看我们的 Observables 发出的内容的最简单方式。

注意

这些操作符在 RxPHP v2 中已被简化,并且与 RxPHP v2 中的subscribe()方法具有相同的签名,即只是do()而不是所有变体。其功能保持不变。

安装 PHPUnit 包

由于我们将在整本书中通过 composer 安装所有依赖项,因此我们将对 PHPUnit 做同样的事情:

composer require phpunit/phpunit

这也在vendor/bin/phpunit中创建了一个符号链接,我们将使用它从控制台运行单元测试。

PHPUnit 支持多种安装方式,包括PHARPHP 存档)格式和通过以下方式全局安装:

composer global require phpunit/phpunit

注意

如果你在安装 PHPUnit 时遇到麻烦,请前往安装说明phpunit.de/manual/5.6/en/installation.html

然而,除非你有很好的理由使用一个 PHPUnit 的全局实例,否则最好按项目安装。这样我们可以避免处理为不同 PHPUnit 版本编写的单元测试代码的问题。

使用 PHPUnit 编写测试的基本知识

我们不会详细介绍如何使用 PHPUnit,而是将其留给其深入的文档(phpunit.de/manual/5.6/en/index.html)。然而,为了本章的目的,我们应该快速查看我们将用于测试 RxPHP 代码的一些基础知识。

我们应该遵循一些基本规则:

  • 所有针对单个类MyClass的测试都应放入一个名为MyClassTest的类中,该类应继承自PHPUnit\Framework\TestCase

  • 每个测试场景都由一个以test为前缀或带有@test注解的函数表示。这样,它就可以被 PHPUnit 自动发现。

  • 每个测试函数由一个或多个使用assert*方法(稍后详细介绍)的断言组成。如果其中任何一个失败,整个测试场景(一个测试函数)将被标记为失败。所有断言都继承自PHPUnit\Framework\TestCase

  • 我们可以使用@depends testname注解来指定测试场景之间的依赖关系,以改变测试执行的顺序。

因此,让我们编写一个最小化的测试类来演示前面的要点。我们可以将这个测试类命名为DemoTest,并且它只需进行几个断言:

// phpunit_01.php 
use PHPUnit\Framework\TestCase; 

class DemoTest extends TestCase { 
    public function testFirstTest() { 
        $expectedVar = 5; 
        $this->assertTrue(5 == $expectedVar); 
        $this->assertEquals(5, $expectedVar); 

        $expectedArray = [1, 2, 3]; 
        $this->assertEquals([1, 2, 3], $expectedArray); 
        $this->assertContains(2, $expectedArray); 
    } 
} 

我们使用了三种不同类型的断言。一般来说,所有断言都会比较一个预期的值和一个从测试函数返回的实际值。以下三个断言也是以这种方式工作的:

  • assertTrue($condition): 测试的条件需要为真。

  • assertEquals($expected, $actual): 检查$expected$actual的值是否相等。这个单独的断言可以分别处理多种类型的数据,即使它们不能用==运算符进行比较。除了比较基本类型,如字符串、数组、布尔值和数字之外,它还可以比较DOMDocument实例或任何对象的属性。

  • assertContains($needle, $haystack): 通常检查一个数组(haystack)是否包含一个值,但也可以检查一个字符串是否包含另一个字符串。

PHPUnit 包含数十种不同的断言方法,并且它们都遵循相同的原理。完整的列表可以在文档中找到(phpunit.de/manual/current/en/appendixes.assertions.html),我们当然可以编写自己的。我们将使用非常有限的相关断言,所以这些基本的断言就足够了。

然后,我们可以从控制台使用 PHPUnit 命令行可执行文件执行类中的所有测试场景。它位于vendor/bin目录中,由于我们将大量使用它,我们将为项目根目录创建一个符号链接。我们还将为位于供应商目录中的autoload.php脚本做同样的事情:

$ ln -s vendor/bin/phpunit ./phpunit
$ ln -s vendor/autoload.php ./autoload.php

现在,我们可以使用以下命令运行我们的测试类:

$ ./phpunit --bootstrap autoload.php phpunit_01.php
PHPUnit 5.6.2 by Sebastian Bergmann and contributors.
.                                   1 / 1 (100%)
Time: 72 ms, Memory: 4.00MB
OK (1 test, 4 assertions)

注意

从命令行输出中故意删除了一些空行,以保持输出合理简短。

我们在这里使用了两个命令行参数:

  • --bootstrap: 由于我们期望测试与我们的项目中的各种类和函数一起工作,我们需要告诉 PHPUnit 它们的位置。此参数允许您指定自定义类加载器(基本上是一个 PHP SPL 自动加载器)。幸运的是,Composer 已经为我们做了所有这些工作,并从我们的composer.json文件中生成了autoload.php。如果我们不使用--bootstrap参数,PHPUnit 将抛出一个错误,因为它将无法找到PHPUnit\Framework\TestCase

  • phpunit_01.php: 这是包含我们想要运行的测试的文件。请注意,我们也可以使用目录路径来测试该目录中的所有文件,或者只使用点(.),来测试当前目录中的所有文件。

注意

PHPUnit 允许创建一个带有其配置的自定义 XML 文件,因此我们不必每次都包含--bootstrap参数。为了保持简单,我们没有使用它。有关更多信息,请参阅phpunit.de/manual/current/en/appendixes.configuration.html的文档。

控制台输出总结了关于已处理的测试所需了解的所有信息。我们可以看到它运行了一个测试用例,包含四个断言。只有一个点(.)后面跟着1 / 1 (100%)的行表示我们执行了一个测试用例并且它成功了。这并不非常描述性,因此我们可以使用另一个参数--testdox来使其更易于阅读:

$ ./phpunit --testdox --bootstrap autoload.php phpunit_01.php 
PHPUnit 5.6.2 by Sebastian Bergmann and contributors.
Demo
 [x] First test

现在,而不是使用点(.),PHPUnit 将类和函数名称转换为字符串,并标记那些通过测试的。这确实更容易理解;然而,它没有在失败的测试上显示错误消息,所以我们不知道为什么它失败了。

我们将在本章中根据情况使用这两种格式。通常,当我们期望测试通过时,我们会使用第二种、更易读的格式。当我们期望测试失败时,我们会使用第一种格式来查看它在哪里失败以及为什么(如果它失败了)。

为了演示目的,我们还会添加一个失败的测试和一个依赖于第一个测试的测试:

class DemoTest extends TestCase { 
    // ... 
    public function testFails() { 
        $this->assertEquals(5, 6); 
        $this->assertContains(2, [1, 3, 4]); 
    } 

    /** 
     * @depends testFails 
     */ 
    public function testDepends() { 
        $this->assertTrue(true); 
    } 
} 

第一个测试用例失败了,因为它断言5 == 6。第二个测试用例被跳过,因为它依赖的测试失败了。失败的测试被正确地标记为失败,而跳过的测试被省略:

$ ./phpunit --testdox --bootstrap autoload.php phpunit_01.php 
PHPUnit 5.6.2 by Sebastian Bergmann and contributors.
Demo
 [x] First test
 [ ] Fails

目前我们只需要知道这些。在深入研究测试 RxPHP 代码之前,我们应该快速讨论一下一般性的异步代码测试,以及我们需要注意的一个常见陷阱。

测试异步代码

在测试异步代码时,我们需要注意的一个重要注意事项,由于我们使用 RxPHP 做的所有事情都是异步的,这对我们来说非常相关。让我们考虑以下即将测试的函数asyncPowIterator()

// phpunit_async_01.php 
use PHPUnit\Framework\TestCase; 

function asyncPowIterator($num, callable $callback) { 
    foreach (range(1, $num - 1) as $i) { // intentional 
        $callback($i, pow($i, 2)); 
    } 
} 

class AsyncDemoTest extends TestCase { 
    public function testBrokenAsync() { 
        $callback = function($i, $pow) use (&$count) { 
            $this->assertEquals(pow($i, 2), $pow); 
        }; 
    } 
} 

我们有一个函数asyncPowIterator(),它对范围 1 到 5 中的每个数字调用一个可调用函数。请注意,我们故意制造了一个错误,而不是迭代范围 1 到 5,我们只迭代 1 到 4。

为了测试这个方法是否产生正确的值,我们将断言直接放入可调用函数中。那么,让我们运行测试看看会发生什么:

$ ./phpunit --testdox --bootstrap autoload.php phpunit_async_01.php 
PHPUnit 5.6.2 by Sebastian Bergmann and contributors.
AsyncDemo
 [x] Broken async

好吧,测试通过了,尽管我们知道有一个错误。

实际上,该函数生成了正确的结果,只是没有像我们预期的那样被调用那么多次。这意味着为了正确测试这个函数,我们还需要计算可调用函数的调用次数,并将其与预期值进行比较:

class AsyncDemoTest extends TestCase { 
    public function testBrokenAsync() { 
        $count = 0; 
        $callback = function($i, $pow) use (&$count) { 
            $this->assertEquals(pow($i, 2), $pow); 
            $count++; 
        }; 
        asyncPowIterator(5, $callback); 
        $this->assertEquals(5, $count); 
    } 
} 

现在,每次我们通过可调用函数时,都会增加$count变量,如果我们再次运行测试,我们会看到它失败了,正如预期的那样:

$ ./phpunit --bootstrap autoload.php phpunit_async_01.php 
PHPUnit 5.6.2 by Sebastian Bergmann and contributors.
F                                          1 / 1 (100%)
Time: 57 ms, Memory: 4.00MB
There was 1 failure:
1) AsyncDemoTest::testBrokenAsync
Failed asserting that 4 matches expected 5.
/path/Chapter 05/phpunit_async_01.php:22
FAILURES!
Tests: 1, Assertions: 5, Failures: 1.

现在它失败了,正如我们预期的那样,我们知道有问题。

这是一个重要的范式。在测试异步代码时,我们不仅需要测试它是否返回正确的结果;我们还需要确保它确实被调用。

我们已经了解的单元测试知识可能足以开始测试我们的 Observables 和操作符。RxPHP 附带了一些用于测试 RxPHP 代码的类,可以使我们的工作更轻松。所有这些都是在 RxPHP 内部用于测试自身的,所以花点时间学习它们,并在测试我们自己的代码时开始使用它们。

测试 RxPHP 代码

自从我们在 第二章,使用 RxPHP 进行响应式编程,其中我们介绍了调度器,我们就通过 ImmediateSchedulerEventLoopScheduler 使用它们。内部,EventLoopScheduler 扩展了另一个名为 VirtualTimeScheduler 的调度器,该调度器也被 TestScheduler 内部使用,我们将稍后用于测试。但在我们这样做之前,让我们看看 VirtualTimeScheduler 有什么有趣之处。

介绍 VirtualTimeScheduler

使用 ImmediateScheduler,所有内容都会立即执行。VirtualTimeScheduler 维护一个待执行动作的优先队列,并让我们控制它们的调用顺序。

在这个示例中,我们将创建一个 VirtualTimeScheduler 实例,并使用 schedule($actionCallable, $delay) 方法堆叠几个具有不同延迟的动作:

// virtual_time_scheduler_01.php 
use Rx\Scheduler\VirtualTimeScheduler; 

$scheduler = new VirtualTimeScheduler(0, function($a, $b) { 
    return $a - $b; 
}); 

$scheduler->schedule(function() { 
    print("1\n"); 
}, 300); 
$scheduler->schedule(function() { 
    print("2\n"); 
}, 0); 
$scheduler->schedule(function() { 
    print("3\n"); 
}, 150); 
$scheduler->start(); 

当我们实例化 VirtualTimeScheduler 类时,我们还需要传递一个起始时间和一个典型的比较函数,该函数决定哪个动作首先被调用。然后,为了实际以正确的顺序执行所有动作,我们需要手动调用 start() 方法。

schedule() 方法还将其最后一个参数作为执行时的起始时间延迟。这意味着我们可以定义比它们实际执行顺序不同的动作。

这个示例将按照以下顺序打印数字:

$ php virtual_time_scheduler_01.php
2
3
1

这实际上是当我们将它与允许延迟执行的 Observable(例如 IntervalObservable)一起使用时,EventLoopScheduler 所做的。让我们再次看看 RxPHP 1.x 中 interval() 操作符的非常基本的示例:

$loop = new React\EventLoop\StreamSelectLoop(); 
$scheduler = new Rx\Scheduler\EventLoopScheduler($loop); 

Rx\Observable::interval(1000, $scheduler) 
    ->subscribe(...); 
$loop->run(); 

EventLoopScheduler 类基于与 VirtualTimeScheduler 相同的原则(它也继承自 VirtualTimeScheduler)。主要区别在于 EventLoopScheduler 使用循环在指定的间隔内反复重新调度动作调用。在这个例子中,“动作”指的是来自 IntervalObservableonNext() 调用。

schedule() 的默认延迟为 0,因此我们也可以使用 VirtualTimeScheduler 而不是 ImmediateScheduler。考虑以下示例:

// virtual_time_scheduler_02.php 
use Rx\Scheduler\VirtualTimeScheduler; 
use Rx\Observable; 
use Rx\Observer\CallbackObserver;  
$scheduler = new VirtualTimeScheduler(0, function($a, $b) { 
    return $a - $b; 
}); 
$observer = new CallbackObserver(function($val) { 
    print("$val\n"); 
}); 

$observable = Observable::fromArray([1,2,3,4]); 
$observable->subscribe($observer, $scheduler); 
$scheduler->start(); 

如预期的那样,它按照指定的顺序打印出数组中的所有项:

$ php virtual_time_scheduler_02.php 
1
2
3
4

现在应该很明显,为什么我们总是在所有方法中检查是否传递了调度器,如果没有,我们就使用最简单的 ImmediateScheduler。这允许我们轻松切换到任何其他调度器,如果我们有理由这样做的话。好吧,一个很好的理由当然是单元测试。

在测试 RxPHP 代码时,VirtualTimeScheduler 本身并不使用,但它被另一个名为 TestScheduler 的调度器所包装,该调度器在底层使用其原则,并允许我们调度比仅仅动作更多的内容。由于 TestScheduler 在内部使用一些与测试相关的其他类,我们将首先查看它们,然后再回到 TestScheduler

注意

正如其名所示,VirtualTimeScheduler不与真实时间一起工作。当我们调用schedule()方法时设置的延迟仅用于按正确顺序执行动作。

HotObservable 和 ColdObservable

我们从第二章,使用 RxPHP 进行响应式编程中了解到热 Observables 和冷 Observables 是什么。它们有通用的变体,作为HotObservableColdObservable类。请注意,这些仅用于测试,而不是用于生产使用。

我们首先看看如何使用HotObservable,然后分别讨论这个例子中使用的每个类:

// hot_observable_01.php 
use Rx\Scheduler\VirtualTimeScheduler; 
use Rx\Testing\HotObservable;  
use Rx\Testing\Recorded; 
use Rx\Notification\OnNextNotification; 

$scheduler = new VirtualTimeScheduler(0, function($a, $b) { 
    return $a - $b; 
}); 
$observable = new HotObservable($scheduler, [ 
    new Recorded(100, new OnNextNotification(3)), 
    new Recorded(150, new OnNextNotification(1)), 
    new Recorded(80, new OnNextNotification(2)), 
]); 
$observable->subscribeCallback(function($val) { 
    print("$val\n"); 
}); 
$scheduler->start(); 

我们使用了两个新的类,RecordedOnNextNotification,我们之前还没有遇到过,所以让我们来谈谈它们:

  • HotObservable/ColdObservable: 这个类分别创建一个热或冷 Observables。它接受一个调度器和需要在我们提供的调度器上调度执行的动作数组作为其参数。

  • Recorded: 这个类表示一个用于延迟执行的单个消息(而不是我们在上一个例子中使用的可调用者)。这个类有一个非常重要的方法,equal(),用于比较两个实例是否具有相等的值、调用时间和消息类型。

  • OnNextNotification: 这个动作本身由这个类的实例表示。它只接受一个参数来表示其值,并且它的唯一目的是在调用时在观察者上调用onNext()。还有OnErrorNotificationOnCompletedNotification类,分别调用onErrorOnComplete方法。

当我们运行这个例子时,我们得到以下结果:

$ php hot_observable_01.php
2
3
1

HotObservableColdObservable之间的区别在于它们调度动作的时间。HotObservable类在其构造函数中立即调度所有内容,而ColdObservable在订阅时执行所有操作。

MockObserver

就像当我们讨论测试异步代码并且需要能够判断可调用者是否根本未调用时,在测试 RxPHP 中的 Observables 时,我们也需要相同的功能。RxPHP 提供了一个名为MockObserver的类,它记录它接收到的所有消息(包括每个记录的确切时间从调度器),这样我们就可以稍后按正确的顺序将它们与预期的消息进行比较。

考虑以下代码,它打印出MockObserver的所有消息:

// mock_observer_01.php 
use Rx\Testing\MockObserver; 
use Rx\Scheduler\VirtualTimeScheduler; 
use Rx\Testing\HotObservable; 
use Rx\Testing\Recorded; 
use Rx\Notification\OnNextNotification;  
use Rx\Notification\OnCompletedNotification; 

$scheduler = new VirtualTimeScheduler(0, function($a, $b) { 
    return $a - $b; 
}); 
$observer = new MockObserver($scheduler); 

(new HotObservable($scheduler, [ 
    new Recorded(100, new OnNextNotification(3)), 
    new Recorded(150, new OnNextNotification(1)), 
    new Recorded(80, new OnNextNotification(2)), 
    new Recorded(140, new OnCompletedNotification()), 
]))->subscribe($observer); 
$scheduler->start(); 

foreach ($observer->getMessages() as $message) { 
    printf("%s: %s\n", $message->getTime(), $message->getValue()); 
} 

注意我们还包括了OnCompletedNotification,它在最后一个值之前被调用:

$ php mock_observer_01.php 
80: OnNext(2)
100: OnNext(3)
140: OnCompleted()
150: OnNext(1)

我们可以看到,每个消息中的值都被我们使用的通知类型所包装。此外,最后一个onNext调用也被记录下来,即使它是在onComplete之后发出的。这是MockObserver的正确行为,因为它的唯一目标是记录消息,而不是执行任何逻辑。

TestScheduler

现在让我们回到之前在谈论 VirtualTimeScheduler 时提到的 TestScheduler 类。这个类继承自 VirtualTimeScheduler,并提供了一些与调度事件相关的方 法。

我们将再次通过一个示例来开始,看看 TestScheduler 为我们做了什么:

$scheduler = new TestScheduler(); 
$observer = $scheduler 
    ->startWithCreate(function() use ($scheduler) { 
        return new HotObservable($scheduler, [ 
            new Recorded(200, new OnNextNotification(3)), 
            new Recorded(250, new OnNextNotification(1)), 
            new Recorded(180, new OnNextNotification(2)), 
            new Recorded(240, new OnCompletedNotification()), 
            new Recorded(1200, new OnNextNotification(4)), 
        ]); 
}); 

$expected = [ 
    new Recorded(200, new OnNextNotification(3)), 
    new Recorded(240, new OnCompletedNotification()), 
    new Recorded(250, new OnNextNotification(1)), 
]; 

$actual = $observer->getMessages(); 
printf("Count match: %d\n", count($actual) == count($expected)); 
foreach ($actual as $i => $message) { 
    printf("%s: %d\n", $message->getTime(), 
        $message->equals($expected[$i])); 
} 

我们创建了五条消息,我们期望只收到三条。此外,这次我们使用 equals() 方法在 Recorded 实例上比较它们,以确保我们以正确的顺序收到了正确数量的消息。

让我们运行这个示例,并检查我们是否按照 $expected 数组中预期的那样收到了消息,然后讨论内部发生的事情以及原因:

$ php mock_observer_02.php 
Count match: 1
200: 1
240: 1
250: 1

那么,其他两条消息去哪里了呢?TestScheduler 类有两个非常重要的方法用于调度动作,我们在测试 RxPHP 代码时会使用到这些方法:

  • startWithTiming($create, $createTime, $subscribeTime, $disposeTime): 这个方法调度三个动作。这些动作包括:创建源 Observable 的实例、订阅 Observable 以及最终处置从 subscribe() 调用返回的可处置对象。每个动作都通过其中一个参数调度到特定的时间。由于创建 Observable 实例是调度动作之一,因此需要传递一个返回 Observable 的可调用对象,而不是直接作为参数。

  • startWithCreate($create): 这个方法使用默认值调用 startWithTiming() 方法。它等同于调用 startWithTiming($create, 100, 200, 1000)。唯一的参数是一个返回源 Observable 的可调用对象。

这两个方法都返回 MockObserver 的一个实例,它也用于订阅源 Observable,因此我们不需要自己创建它。

现在应该很明显,为什么我们实际上调度了五条消息,但只收到了三条。延迟 180 毫秒的消息在我们订阅源 Observable 之前发生,而最后一条延迟 1200 毫秒的消息在我们已经调用 dispose() 方法之后发生,这导致 TestObserver 从源 Observable 中取消订阅。

使用 foreach 循环比较实际和预期的消息当然是可能的,但在我们编写的每个测试中这样做都会非常繁琐。这就是为什么 RxPHP 提供了 Rx\Functional\FunctionalTestCase 类,我们可以用它来代替 PHPUnit\Framework\TestCase,并且它添加了针对 RxPHP 代码的特定断言方法,最显著的是 assertMessages() 方法,它比较消息数组,就像我们在本例中所做的那样。

测试 SumOperator

所有这些类都是 RxPHP 用于测试其自身代码的。现在我们将使用它们来测试我们自己的 Observables 和算子。

为了测试目的,我们将编写一个简单的算子,该算子计算它接收到的所有整数的总和。当 onComplete 到达时,它发出一个包含所有数字总和的单个 onNext。它还在接收到非整数值时发出 onError

// SumOperator.php 
class SumOperator implements OperatorInterface  { 
  private $sum = 0; 

  function __invoke($observable, $observer, $scheduler=null) { 
    $observable->subscribe(new CallbackObserver( 
      function($value) use ($observer) { 
        if (is_int($value)) { 
          $this->sum += $value; 
        } else { 
          $observer->onError(new Exception()); 
        } 
      }, 
      [$observer, 'onError'], 
      function() use ($observer) { 
        $observer->onNext($this->sum); 
        $observer->onCompleted(); 
      } 
    )); 
  } 
} 

这个算子非常直接,因为我们已经知道所有必要的工具来正确测试它,所以我们可以直接使用 PHPUnit 进行单元测试。

注意

事实上,RxPHP 已经有一个 sum() 算子,它内部实现为一个 reduce() 算子,只是添加值。

我们将使用 Rx\Functional\FunctionalTestCase 而不是 PHPUnit\Framework\TestCase,它内部创建 TestScheduler 并自动将其传递给新的热/冷可观察对象,所以我们根本不需要担心调度器。

RxPHP 还包含一些辅助函数来简化创建 Recorded 对象。我们不需要调用 new Recorded(200, new OnNextNotification(3)),而是可以使用在 rxphp/test/helper-functions.php 文件中定义的 onNext(200, 3) 函数。

为了使用这些函数以及 FunctionalTestCase 类,我们需要通过更新我们的 composer.json 来告诉自动加载器它们的位置:

{ 
  "name": "rxphp_unittesting_demo", 
  ... 
  "require": { 
    "reactivex/rxphp": "¹.5", 
    "phpunit/phpunit": "⁵.6", 
    ... 
  }, 
  "autoload": { 
    "psr-4": { 
      "Rx": "vendor/reactivex/rxphp/test/Rx" 
    }, 
    "files": [ 
      "vendor/reactivex/rxphp/test/helper-functions.php" 
    ] 
  } 
} 

在更新 composer.json 之后,我们还需要重新生成 autoload.php 脚本:

$ composer update

现在,我们可以使用 onNext()onComplete()onError() 以及 FunctionalTestCase 类(不要将 helper-functions.php 中的 onNext() 函数与观察者中的 onNext() 方法混淆;这是两件不同的事情)。多亏了所有这些,测试类将会相当短:

// SumOperatorTest.php 
use Rx\Functional\FunctionalTestCase; 

class SumOperatorTest extends FunctionalTestCase { 
  public function testSumSuccess() { 
    $observer = $this->scheduler->startWithCreate(function () { 
      return $this->createHotObservable([ 
        onNext(150, 3), 
        onNext(210, 2), 
        onNext(450, 7), 
        onCompleted(460), 
        onNext(500, 4), 
      ])->lift(function() { 
        return new SumOperator(); 
      }); 
    }); 

    $this->assertMessages([ 
      onNext(460, 9), 
      onCompleted(460) 
    ], $observer->getMessages()); 
  } 
} 

这个测试安排了一些消息并在时间 460 时完成可观察对象,这导致 SumOperator 发出其累积的值,并在之后立即完成。

startWithCreate() 方法的可调用函数创建了一个 HotObservable 类,并使用我们广泛讨论并用于 第三章,编写 Reddit 读者使用 RxPHP 中提到的 lift() 方法,将其与我们的 SumOperator 连接起来。最后,我们使用 assertMessages() 来比较 MockObserver 收到的消息与预期消息,就像我们在上一个示例中所做的那样。使用 FunctionalTestCase 中的 assertMessages() 确实更加方便。

我们可以运行测试来查看它是否真的成功通过:

$ ./phpunit --bootstrap ./vendor/autoload.php SumOperatorTest.php
PHPUnit 5.6.2 by Sebastian Bergmann and contributors.
.                                         1 / 1 (100%)
Time: 84 ms, Memory: 4.00MB
OK (1 test, 1 assertion)

注意,即使 assertMessages() 必须比较两个消息并确保两个数组大小相同,它也只算作一个断言。

现在让我们也测试一种情况,我们传递一个无效的值(在这种情况下是一个字符串),这会导致一个 onError 消息:

class SumOperatorTest extends FunctionalTestCase { 
  // ... 
  public function testSumFails() { 
    $observer = $this->scheduler->startWithCreate(function () { 
      return $this->createHotObservable([ 
        onNext(150, 3), 
        onNext(250, 'abc'), 
        onNext(300, 2), 
        onCompleted(460) 
      ])->lift(function() { 
        return new SumOperator(); 
      }); 
    }); 

    $this->assertMessages([ 
      onError(250, new Exception()), 
    ], $observer->getMessages()); 
  } 
} 

我们期望在 250 时收到一个 onError 消息,就这些。即使还有两个更多消息被安排,它们也不会到达 TestObservable

当然,这两个测试正如预期那样通过:

$ ./phpunit --testdox --bootstrap autoload.php SumOperatorTest 
PHPUnit 5.6.2 by Sebastian Bergmann and contributors.
SumOperator
 [x] Sum success
 [x] Sum fails

测试 ForkJoinObservable

现在我们可以看看一个稍微复杂一点的例子。在 RxPHP 中,有一个有趣的算子叫做 forkJoin()。这个算子接受一个可观察对象的数组作为参数,收集每个可观察对象发出的最后一个值,当它们全部完成时,它会发出一个包含每个可观察对象最后一个值的单个数组。

当我们查看 RxJS 中forkJoin()操作符的以下 Marble 图时,这会更有意义:

测试 ForkJoinObservable

以下是在 RxJS 中代表forkJoin()操作符的 Marble 图(http://reactivex.io/documentation/operators/zip.html)

我们将实现forkJoin()操作符的一个简化版本作为可观察者。为了使其作用更加清晰,我们将从一个示例开始:

// fork_join_test_01.php 
use Rx\Observable; 

(new ForkJoinObservable([ 
    Observable::fromArray([1, 2, 3, 4]), 
    Observable::fromArray([7, 6, 5]), 
    Observable::fromArray(['a', 'b', 'c']), 
]))->subscribeCallback(function($values) { 
    print_r($values); 
}); 

这将打印出每个源可观察者的最后一个值:

$ php fork_join_test_01.php 
Array
(
 [0] => 4
 [1] => 5
 [2] => c
)

我们的实现将订阅每个源可观察者,并保留每个可观察者发出的最新值。然后,当所有这些都完成时,它发出一个onNext()和一个onComplete

// ForkJoinObservable.php 
class ForkJoinObservable extends Observable { 
  private $observables; 
  private $lastValues = []; 
  private $completed = []; 

  public function __construct($observables) { 
    $this->sources = $observables; 
  } 

  public function subscribe($observer, $sched = null) { 
    $disp = new CompositeDisposable(); 

    if (null == $sched) { 
      $sched = new ImmediateScheduler(); 
    } 

    foreach ($this->observables as $i => $obs) { 
      $inDisp = $obs->subscribeCallback(function($v) use ($i) { 
          $this->lastValues[$i] = $v; 
        }, function($e) use ($observer) { 
          $observer->onError($e); 
        }, function() use ($i, $observer) { 
          $this->completed[$i] = true; 

          $completed = count($this->completed); 
          if ($completed == count($this->observables)) { 
            $observer->onNext($this->lastValues); 
            $observer->onCompleted(); 
          } 
        } 
      ); 
      $disp->add($inDisp); 
    } 
    return $disp; 
  } 
} 

这里只有几个嵌套的匿名函数。请注意,我们还需要将所有可处置对象存储在CompositeDisposable中,以便能够正确地处置它们。

测试这个类与我们之前所做的方法非常相似。请注意我们为每个消息调用使用的延迟:

// ForkJoinObservableTest.php 
class ForkJoinObservableTest extends FunctionalTestCase { 

  public function testJoinObservables() { 
    $observer = $this->scheduler->startWithCreate(function () { 
      return new ForkJoinObservable([ 
        $this->createHotObservable([ 
          onNext(200, 1), 
          onNext(300, 2), 
          onNext(400, 3), 
          onCompleted(500), 
          onNext(600, 4), 
        ]), 
        $this->createHotObservable([ 
          onNext(200, 8), 
          onNext(300, 7), 
          onNext(400, 6), 
          onCompleted(800), 
        ]) 
      ]); 
    }); 

    $this->assertMessages([ 
        onNext(800, [3, 6]), 
        onCompleted(800) 
    ], $observer->getMessages()); 
  } 
} 

我们期望在800时接收到onNext(),因为这是第二个可观察者完成的时刻。尽管第一个可观察者在onComplete调用后发出了一个额外的值,但这将被忽略,因为它已经完成了。

然后,如果我们运行测试用例,它将按预期通过:

$ ./phpunit --testdox --bootstrap autoload.php  
    ForkJoinObservableTest
PHPUnit 5.6.2 by Sebastian Bergmann and contributors.
ForkJoinObservable
 [x] Join observables

在测试 RxPHP 代码时,我们应该记住的最重要的事情是调用时间很重要。

当然,我们只是测试我们的可观察者和操作符是否产生正确的值,但这可能会遗漏一些未注意且难以发现的错误。以一个具体的例子来说,一个错误可能会使可观察者在应该完成之后仍然传递值,或者在错误上失败。

另一个我们可以测试的有趣场景是当一个可观察者永远不会完成。在这种情况下,ForkJoinObservable不会发出任何值,甚至不会发出onComplete信号:

public function testJoinObservablesNeverCompletes() { 
  $observer = $this->scheduler->startWithCreate(function () { 
    return new ForkJoinObservable([ 
      $this->createHotObservable([ 
        onNext(200, 1), 
        onNext(300, 2), 
        onCompleted(500), 
      ]), 
      $this->createHotObservable([ 
        onNext(200, 8), 
        onNext(300, 7), 
      ]) 
    ]); 
  }); 

  $this->assertMessages([], $observer->getMessages()); 
} 

如果我们重新运行ForkJoinObservableTest类,我们会看到这个测试也通过了。

注意

ForkJoinObservable的真实实现是在 RxPHP 中,自 1.5 版本以来可用,并且稍微复杂一些。我们将在第十章中回到它,使用 RxPHP 的高级操作符和技术。在第附录中,在 RxJS 中重用 RxPHP 技术,我们将了解 RxPHP 2 和 RxJS 5 中实现的新测试 Rx 代码的方法,称为“Marble 测试”。

摘要

本章介绍了使用 PHPUnit 和 RxPHP 包提供的实用工具编写的单元测试代码。

最重要的是,我们学习了 doOn*() 操作符以及使用 PHPUnit 进行单元测试的基础,以及在进行异步代码单元测试时需要注意的问题。接下来,我们深入探讨了 RxPHP 提供的旨在单元测试的类,如何使用它们,以及它们解决的问题。特别是,这些是 VirtualTimeSchedulerHotObservableColdObservableTestSchedulerFunctionalTestCase 类。除此之外,我们还编写了示例 SumOperatorForkJoinObservable 类,以展示在正确的时间发出和接收消息的重要性。

在接下来的章节中,我们将更深入地探讨 PHP 中的事件循环,并介绍 RxPHP 中的更高阶的概念——高阶可观察者。

第六章:PHP Streams API 和更高阶 Observables

在本章中,我们将介绍许多我们为下一章所需的新特性。本章涵盖的几乎所有内容都与PHP Streams API、Promises 和事件循环(在我们的案例中是reactphp/event-loop项目)相关。这还包括一些更高级的 RxPHP 操作符,它们与所谓的更高阶 Observables 一起工作。

在本章中,我们将做以下几件事:

  • 快速了解使用 PHP 中的reactphp/promise库来使用 Promises

  • 介绍 PHP Streams API,并通过示例查看它带来的好处,无需或几乎无需额外努力

  • 检查StreamSelectLoop类的内部结构,这次是在 PHP Streams API 的上下文中

  • 查看我们在使用事件循环中的非阻塞代码时需要注意的注意事项

  • 讨论更高阶 Observables

  • 介绍四个新的更高级的操作符,concatAll()mergeAll()combineLatest()switchMap(),它们旨在与更高阶 Observables 一起工作

本章将介绍许多我们尚未遇到的新事物。然而,所有这些在实践中都有其益处,正如我们在下一章中将要看到的,我们将编写一个在运行时产生多个子进程的应用程序。每个子进程本身就是一个自给自足的 WebSocket 服务器,我们将使用本章中获得的知识与他们通信并从他们那里收集信息。

在 PHP 中使用 Promises

当使用响应式扩展时,我们认为数据是连续的流,随着时间的推移发出数据。一个类似且可能更熟悉的概念是 Promises,它代表未来的单个值。

你可能已经在 jQuery 等库中遇到过 Promises,它通常用于处理 AJAX 请求的响应。PHP 中有多种实现,但原则始终相同。我们将使用一个名为reactphp/promisegithub.com/reactphp/promise)的库,该库遵循 Promises/A 提案(wiki.commonjs.org/wiki/Promises/A)并添加了一些额外的功能。由于我们将使用这个库为本章和下一章,我们将看看如何使用它。

使用 composer 安装react/promise包:

$ composer require react/promiseWe

我们将使用两个基本类:

  • Promise:这个类表示一个延迟计算的结果,该结果将在未来可用。

  • Deferred:这个类表示一个挂起的操作。它返回一个 Promise 的单个实例,该实例将被解决或拒绝。通常,通过解决一个 Promise,我们理解操作已成功结束,而拒绝则意味着它失败了。

每个 Promise 都将被解决或拒绝,我们可以通过多个方法(我们也可以将它们称为操作符,因为它们具有类似的作用,如 Rx)来处理其结果。这些方法中的每一个都返回一个新的 Promise,因此我们可以以与在 Rx 中执行的方式非常相似的方式将它们链式调用:

  • then(): 此方法接受两个回调函数作为参数。第一个回调函数仅在 Promise 被解决时调用,而第二个回调函数仅在它被拒绝时调用。每个回调函数都可以返回一个修改后的值,该值将被传递给下一个操作符。

  • done(): 与 then() 类似,它接受两个回调函数作为参数。然而,此方法返回 null,因此不允许链式调用。它仅用于消费结果,并防止您进一步修改它。

  • otherwise(): 当 Promise 被拒绝或前面的 then() 方法抛出异常时,这是一个处理程序。

  • always(): 这是当 Promise 被解决或拒绝时调用的清理方法。

使用 then() 和 done() 方法

我们可以通过以下示例演示如何使用 PromiseDeferred 类以及 then()done() 方法:

// deferred_01.php 
use React\Promise\Deferred; 
$deferred = new Deferred(); 

$deferred->promise() 
    ->then(function($val) { 
        echo "Then #1: $val\n"; 
        return $val + 1; 
    }) 
    ->then(function($val) { 
        echo "Then #2: $val\n"; 
        return $val + 1; 
    }) 
    ->done(function($val) { 
        echo "Done: $val\n"; 
    }); 

$deferred->resolve(42); 

promise() 方法返回 Promise 类的一个实例,然后通过两个 then() 和一个 done() 调用来链式调用。我们提到,Promise 类代表未来的单个值。因此,多次调用 promise() 方法总是返回相同的 Promise 对象。

在第一个 then() 调用中,我们将打印值并返回 $val + 1。这个修改后的值将被传递到后续的 then() 调用,该调用再次更新值并将其传递给 done()done() 方法返回 null,因此不能与任何更多的操作符链式调用。

输出如下:

$ php deferred_01.php 
Then #1: 42
Then #2: 43
Done: 44

注意,负责解决或拒绝 Promise 类的是 Deferred 类的实例,因为它表示异步操作。另一方面,Promise 类仅表示 Deferred 类的结果。

使用 otherwise() 和 always() 方法

与使用 then()done() 类似,我们将使用 otherwise() 处理异常,并且还会附加 always(),无论 Promise 类是被解决还是被拒绝,它都会被调用:

// deferred_02.php  
$deferred = new Deferred(); 
$deferred->promise() 
    ->then(function($val) { 
        echo "Then: $val\n"; 
        throw new \Exception('This is an exception'); 
    }) 
    ->otherwise(function($reason) { 
        echo 'Error: '. $reason->getMessage() . "\n"; 
    }) 
    ->always(function() { 
        echo "Do cleanup\n"; 
    }); 

$deferred->resolve(42); 

现在,then() 的可调用部分抛出一个异常,该异常被下面的 otherwise() 方法捕获。即使我们在 then() 中抛出异常,Promise 链总是以一个 always() 调用结束。

如果我们运行此示例,我们将收到以下输出:

$ php deferred_02.php 
Then: 42
Error: This is an exception
Do cleanup

otherwise() 方法实际上只是 then(null, $onRejected) 的快捷方式,因此我们可以将其写为单个调用。然而,这种表示法被分成两个单独的方法调用,这使得它更容易理解。我们还可以测试一个场景,其中我们拒绝 Promise 类而不是解决它:

$deferred->reject(new \Exception('This is an exception'));

这将跳过 then() 调用,并仅触发 otherwise() 可调用部分:

$ php deferred_02.php 
Error: This is an exception
Do cleanup

注意,在两种情况下都调用了 always() 方法。此外,注意 otherwise() 方法允许为不同的异常类创建多个处理器。如果我们没有在可调用定义中指定类类型,它将在任何异常上触发。

PHP Streams API

如果我们想在 PHP 中使用套接字,我们提供了两组方法,从以下两个前缀之一开始:

  • socket_*:自 PHP 4.1 起可用的套接字通信的低级 API。在编译 PHP 时需要启用此扩展,可以使用 --enable-sockets 选项。您可以通过在控制台中运行 php -i 并在 Configure Command 选项下查看 --enable-sockets 来检查您的 PHP 是否支持此 API。

  • stream_*:PHP 4.3 中引入的 API,它将文件、网络和其他操作的一般化使用统一到一组函数中。在这个 API 的意义上,流是具有一些共同行为的资源对象。此扩展是 PHP 的一部分,不需要任何额外步骤即可启用。PHP 5 中添加了更多流函数,例如 stream_socket_server(),我们将在稍后使用它。

通常,我们总是希望使用更新的 stream_* API,因为它 PHP 的内置部分,并提供了更好的功能。

核心特性是它围绕使用资源构建。在 PHP 中,资源是一个特殊的变量,它持有对某些外部资源的引用(这可以是套接字连接、文件句柄等)。这些有一些限制。例如,由于显而易见的原因,它们不能被序列化,并且某些方法对特定类型的资源不适用,例如 fseek()

与资源和流一起工作是一致的,因此当我们例如向文件写入数据时,我们可以使用 stream_* 函数而不是典型的 fwrite() 函数。考虑以下示例,我们将一个文件的内容复制到另一个文件,而不是使用 fwrite()file_get_content()file_put_content(),我们将使用 stream_copy_to_stream()

// streams_00.php 
$source = fopen('textfile.txt', 'r'); 
$dest = fopen('destfile.txt', 'w'); 
stream_copy_to_stream($source, $dest); 

$source$dest 都是资源。stream_copy_to_stream() 函数只是将一个流的内容复制到另一个流。一个资源如何读取数据以及第二个资源如何写入数据取决于该资源的内部实现。我们也可以使用 fseek() 将读取光标移动到某个位置,而不是从文件开头读取数据:

$source = fopen('textfile.txt', 'r'); 
fseek($source, 5); 
... 

现在我们已经跳过了文件的前五个字节。

有许多类型的资源。我们可以使用 get_resource_type() 函数查看我们支持哪些类型。

在以下示例中,我们创建了三种不同类型的资源:

// streams_01.php 
$source = fopen('textfile.txt', 'r'); 
echo get_resource_type($source) . "\n"; 

$xml = xml_parser_create(); 
echo get_resource_type($xml) . "\n"; 

$curl = curl_init(); 
echo get_resource_type($curl) . "\n"; 

我们可以看到每种资源类型都由不同的字符串标识:

$ php streams_01.php 
stream
xml
curl

在 第三章,《使用 RxPHP 编写 Reddit 读者》,我们通过打开到 php://stdin 的流并使用 fread() 来从控制台读取输入,通过 IntervalObservable 定期获取当前读取缓冲区的内容。我们还使用了 stream_set_blocking() 函数来使读取流非阻塞,这样如果没有任何数据可用,fread() 将返回一个空字符串。

使用事件循环当然是一个可行的选项,但还有一个专门为此目的创建的函数,称为 stream_select()

使用 stream_select() 函数

而不是遍历所有流并手动检查它们是否有任何可用数据,我们可以使用 stream_select() 函数(php.net/manual/en/function.stream-select.php)。此函数接受流数组作为参数,并等待至少其中一个流上有活动发生。

由于使用 fopen() 创建的任何资源都是流,我们可以使用此函数等待用户输入,而不是使用循环:

// streams_02.php 
$stdin = fopen('php://stdin', 'r'); 
stream_set_blocking($stdin, false); 

$readStreams = [$stdin]; 
$writeStreams = []; 
$exceptStreams = []; 

stream_select($readStreams, $writeStreams, $exceptStreams, 5); 
echo "stdin: " . strrev(fgets($stdin)); 

stream_select() 函数返回活动流的数量,如果超时则返回零。它总共接受五个参数,其中前四个是必需的:

  • array &$read:这是读取流的数组(检查流是否有可读数据)。

  • array &$write:这是写入流的数组。列出的流需要指示它们已准备好写入数据。

  • array &$except:这是具有更高优先级的流的数组。

  • int $tv_sec:这是等待至少一个流变为活动状态的最大秒数。

  • int $tv_usec(可选):这是添加到超时秒数中的微秒数。

每个数组都是通过引用传递的,所以我们不能只留下 [];我们需要将其作为变量传递(null 也可以接受)。最后一个整数参数 5 是该函数返回的超时时间,即使它没有在其任何流上捕获任何活动。

因此,在这个例子中,我们使用 fopen() 创建了一个资源 $stdin,然后等待五秒钟以获取任何用户输入(在按下 Enter 键后,数据由终端发送到缓冲区),然后使用 fgets() 从缓冲区获取数据并按相反顺序打印。

注意,我们无论如何都必须使 $stdin 流非阻塞。如果我们不这样做,stream_select() 将永远不会结束,无论超时与否。

StreamSelectLoop 和 stream_select() 函数

我们在 第三章 ,使用 RxPHP 编写 Reddit 读者 中使用了 StreamSelectLoop 类,以周期性地使用 IntervalObservable 发射值,或者在 第二章 ,使用 RxPHP 进行响应式编程 中检查用户输入。让我们将我们关于 PHP 流、stream_select() 函数和 StreamSelectLoop 的知识结合起来,并更新之前的示例以使用 StreamSelectLoop

StreamSelectLoop 类有一个 addReadStream() 方法,用于添加流(资源)和可调用函数,这些函数在流活跃时执行。然后它内部调用 stream_select() 并在一个循环中等待任何流的活动:

// stdin_loop_01.php 
use React\EventLoop\StreamSelectLoop; 
$stdin = fopen('php://stdin', 'r'); 

$loop = new StreamSelectLoop(); 
$loop->addReadStream($stdin, function($stream) { 
    $str = trim(fgets($stream)); 
    echo strrev($str) . "\n"; 
}); 

$loop->run(); 

最后,很明显为什么事件循环类被称为 StreamSelectLoop,而不是 EventLoop 或仅仅是 Loop:它内部使用了 stream_select()

现在我们知道了 StreamSelectLoop 如何与 PHP 流一起工作。然而,一个非常好的问题是,当 Observables,如 IntervalObservable,周期性地发射值时,它们不使用任何流是如何工作的?

使用 StreamSelectLoop 安排事件

除了使用 StreamSelectLoop 来处理流之外,我们还可以通过指定一个间隔和一个可调用函数来安排一次性或周期性事件。

考虑以下示例,它创建了两个计时器:

// loop_01.php 
use React\EventLoop\StreamSelectLoop; 

$loop = new StreamSelectLoop(); 
$loop->addTimer(1.5, function() { 
    echo "timer 1\n"; 
}); 

$counter = 0; 
$loop->addPeriodicTimer(1, function () use (&$counter, $loop) { 
    printf("periodic timer %d\n", ++$counter); 
    if ($counter == 5) { 
        $loop->stop(); 
    } 
}); 
$loop->run(); 

周期性计时器每秒触发一次,而一次性计时器在 1500 毫秒后只触发一次。控制台输出将打印出增加的 $counter 变量的值:

$ php loop_01.php 
periodic timer 1
timer 1
periodic timer 2
periodic timer 3
periodic timer 4
...

那么,当我们根本不使用流时,StreamSelectLoop 是如何安排事件的呢?

答案是 stream_select() 函数及其第四和第五个参数。即使我们不是在等待任何流活动,我们仍然可以利用 stream_select() 提供的超时。实际上,如果我们只使用 usleep() 函数来暂停脚本执行一段时间,我们也可以达到相同的结果。然而,如果我们使用了 usleep(),我们就无法将计时器与流结合起来。

当我们使用 $loop->run() 启动事件循环时,它会迭代所有时间并检查哪个计时器应该首先触发。在我们的例子中,是将在一秒后触发的周期性计时器,所以 StreamSelectLoop 调用 stream_select() 并将其第四个参数(超时)设置为 一秒。由于我们没有向循环中添加任何流,stream_select() 调用将始终以超时结束,这在当前情况下是故意的。

如果我们向循环中添加了一个流,该流会在计时器应该触发之前任何时候发出活动信号,那么 stream_select() 可能会被中断,流会在计时器之前被处理。

我们可以回到我们的示例,其中 StreamSelectLoop 类的工作方式如下:

  • 我们安排了一个一秒的超时,即使没有流活动,stream_select()也会返回。

  • StreamSelectLoop类检查哪些定时器已到期,并调用它们的可调用函数。然后,如果定时器是周期性的,它会重新安排定时器,以便在未来再次触发。

  • 这是内部循环的第一次迭代,其中stream_select()导致了暂停。

  • 在第二次迭代中,它再次检查最近的定时器。这次是将在 500ms(已过去 1000ms)触发的单次定时器,因此stream_select()的超时时间将仅为 500ms。

这会一直持续到我们从其中一个可调用函数中调用$loop->stop()

我们可以用周期性定时器重写这个示例,同时使用IntervalObservable读取任何来自php://stdin的输入:

// loop_02.php 
use React\EventLoop\StreamSelectLoop; 
use Rx\Observable; 
use Rx\Scheduler\EventLoopScheduler; 

$loop = new StreamSelectLoop(); 
$scheduler = new EventLoopScheduler($loop); 

Observable::interval(2000, $scheduler) 
    ->subscribeCallback(function($counter) { 
        printf("periodic timer %d\n", $counter); 
    }); 

$stdin = fopen('php://stdin', 'r'); 
$loop->addReadStream($stdin, function($stream) { 
    $str = trim(fgets($stream)); 
    echo strrev($str) . "\n"; 
}); 

$loop->run(); 

可观察者不能直接与StreamSelectLoop一起工作,因此我们需要用调度器包装它。EventLoopScheduler类继承自我们在上一章详细解释的VirtualTimeScheduler类,当时我们讨论了测试及其与TestScheduler类的使用。EventLoopScheduler的原则是相同的。

EventLoopScheduler类在StreamSelectLoop实例上安排定时器,这并不禁止我们为流使用相同的循环。

使用 StreamSelectLoop 的最简 HTTP 服务器

使用仅StreamSelectLoop创建简单 HTTP 服务器的良好示例可在react/event-loop包的 GitHub 页面找到:

// streams_03.php 
$loop = new React\EventLoop\StreamSelectLoop(); 
$server = stream_socket_server('tcp://127.0.0.1:8080'); 
stream_set_blocking($server, 0); 

$loop->addReadStream($server, function ($server) use ($loop) { 
  $c = stream_socket_accept($server); 
  $data = "HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nHi\n"; 

  $loop->addWriteStream($c, function($c) use (&$data, $loop) { 
    $written = fwrite($c, $data); 
    if ($written === strlen($data)) { 
      fclose($conn); 
      $loop->removeStream($c); 
    } else { 
      $data = substr($data, 0, $written); 
    } 
  }); 
}); 

$loop->addPeriodicTimer(5, function () { 
  $memory = memory_get_usage() / 1024; 
  $formatted = number_format($memory, 3).'K'; 
  echo "Current memory usage: {$formatted}\n"; 
}); 

$loop->run(); 

这个示例使用stream_socket_server()创建一个监听 TCP 套接字服务器,仅接受来自本地主机端口8080的连接。然后,$server流被添加到事件循环中,每次建立新的连接时,它都会被stream_select()捕获。然后,为了实际接受连接,我们需要调用stream_socket_accept()函数,该函数返回另一个流,代表到该客户端的流。然后,通过addWriteStream()我们将知道客户端何时准备好开始接收数据。

有四个重要的事情需要注意:

  • 使用stream_socket_server(),我们可以使用多种不同的协议。最常见的是tcpudpunix。我们可以使用stream_get_transports()获取所有可用协议的完整列表。

  • 如果我们有N个客户端,循环中始终有N+1个流。这是因为接受连接的服务器流也在事件循环内部。

  • 当我们向客户端流写入数据时,我们需要意识到它可能无法一次写入整个响应,我们需要分块发送。这就是为什么我们总是检查使用fwrite()写入流中的字节数。

  • 在我们完成向写入流写入数据后,我们使用fclose()关闭它,并从循环中移除,因为我们不再需要它。当接受新的客户端连接时,它将有自己的写入流。

关于非阻塞事件循环的说明

StreamSelectLoop的实现细节表明,它不能保证所有计时器都会在它们应该的时间精确触发。例如,如果我们创建了两个都需要在 500ms 后触发的计时器,那么我们可以相当准确地预测第一个可调用将在 500ms 后精确执行。然而,第二个计时器的可调用依赖于第一个可调用的执行时间。这意味着如果第一个可调用执行了 100ms;第二个可调用将在 600ms 而不是 500ms 后触发。

这的一个影响是事件循环是非阻塞的——只要我们的代码是非阻塞的。

PHP 中没有并行性,因此所有代码都是严格顺序的。如果我们编写了执行时间较长的代码或需要从本质上阻塞的代码,它将导致整个事件循环也变为阻塞。

使用多个 StreamSelectLoop 实例

在现实世界的 PHP 应用程序中,当我们需要使用 PHP 流、Observables、HTTP 服务器/客户端或 WebSocket 服务器/客户端(以及基本上任何异步代码)进行异步工作时,我们可能需要使用多个事件循环。这意味着应用程序的每个非阻塞部分都需要自己的事件循环。

例如,我们需要使用事件循环来使用IntervalObservable,但我们还需要一个事件循环来读取 PHP 流数据的 WebSocket 服务器。

考虑以下示例,其中我们模拟了类似的场景:

// loop_03.php 
use React\EventLoop\StreamSelectLoop; 

$loop1 = new StreamSelectLoop(); 
$loop1->addPeriodicTimer(1, function() { 
    echo "timer 1\n"; 
}); 

$loop2 = new StreamSelectLoop(); 
$loop2->addTimer(2, function() { 
    echo "timer 2\n"; 
}); 

$loop1->run(); 
$loop2->run(); 

在这个例子中,第二个$loop2永远不会启动。PHP 解释器将只停留在第一个$loop1中,因为它永远不会结束,因为周期性计时器。如果我们按相反的顺序做(首先调用$loop2然后是$loop1),它实际上会工作。第二个循环将只是延迟两秒钟,因为第一个循环只运行一个动作然后结束(没有其他计时器活跃,所以它会自动结束)。

这是我们需要注意的事情。在第七章实现 Socket IPC 和 WebSocket 服务器/客户端中,我们将编写一个同时运行 WebSocket 服务器和 Unix 套接字客户端的应用程序。这意味着它们都需要能够循环读取流中的数据。好事是 WebSocket 服务器将使用来自react/event-loop包的相同事件循环实现。

结果是,在 PHP 中,我们只需要一个事件循环,这可能会成为某些需要与自己的事件循环实现一起工作但又不公开任何我们可以挂钩的库的问题。

然而,这并不一定适用于 RxJS,或者更普遍地说,不适用于解释器工作方式与 PHP 不同的 JavaScript 应用程序。我们将在本书的最后一章更深入地讨论使用 RxJS 和 RxPHP 时的差异。

PHP 中的事件循环互操作性

为了解决这个问题,有人尝试标准化事件循环的实现,以遵循相同的 API。

async-interop/event-loop 包定义了一系列接口,事件循环需要实现这些接口才能真正互换。这意味着我们可以编写一个仅依赖于 async-interop/event-loop 提供的接口的库,最终用户可以决定他们想要使用哪个事件循环实现。

我们可以查看一个我们已知的 StreamSelectLoop 示例,并通过 async-interop/event-loop 提供的接口来使用它。目前,StreamSelectLoop 并没有原生实现这个接口,因此我们需要一个额外的包 wyrihaximus/react-async-interop-loop,它将 react/event-loop 的事件循环实现用 async-interop/event-loop 接口包装。

我们的 composer.json 文件将会非常简单,因为我们只需要一个必需的包:

{ 
    "require": { 
        "wyrihaximus/react-async-interop-loop": "⁰.1.0" 
    } 
} 

wyrihaximus/react-async-interop-loop 包需要作为依赖同时包含 async-interop/event-loopreact/event-loop,所以我们不需要自己包含它们。

然后,我们将编写一个使用 Loop 互操作性接口安排两个动作的最小示例:

// event_interop_01.php 
use Interop\Async\Loop; 
use WyriHaximus\React\AsyncInteropLoop\ReactDriverFactory; 

Loop::setFactory(ReactDriverFactory::createFactory()); 

Loop::delay(1000, function() { 
    echo "second\n"; 
}); 
Loop::delay(500, function() { 
    echo "first\n"; 
}); 

Loop::get()->run(); 

注意,我们所有的操作都是在来自 async-interop/event-loop 包的 Loop 类及其静态方法上进行的。我们已经知道我们一次只能有一个事件循环在运行。这就是为什么 Loop 类上的所有方法都是静态的。

setFactory() 方法告诉 Loop 类如何创建我们的事件循环实例。在我们的例子中,我们使用 react/event-loop,它被包裹在 ReactDriverFactory 中,以遵循 async-interop 接口。

事件循环和 RxPHP 的未来版本

在 RxPHP 2 中,使用事件循环(以及因此需要异步调度的所有操作符)已经显著简化,大多数时候我们甚至不需要担心自己启动事件循环。

注意

RxPHP 2 本来应该基于 async-interop/event-loop 接口。然而,该规范仍然不稳定,所以 RxPHP 团队决定回滚到 RxPHP 1 风格的事件循环。以下段落描述了在 RxPHP 的未来版本(可能是 RxPHP 3)中应该如何使用事件循环。最后,RxPHP 2 基于我们熟悉的 reactphp 库中的 StreamSelectLoop 类。

在未来,RxPHP 将会依赖于 async-interop/event-loop 接口。由于我们不希望自行启动循环,我们可以从 RxPHP 中自动加载一个引导脚本,在脚本执行结束时自动启动循环,使用 PHP 的 register_shutdown_function()。我们将再次更新我们的 composer.json 并添加 autoload 指令:

"autoload": { 
    "files": ["vendor/reactivex/rxphp/src/bootstrap.php"] 
} 

现在我们可以编写任何异步代码:

// rxphp2_01.php 
use Rx\Observable; 

Observable::interval(1000) 
    ->take(5) 
    ->flatMap(function($i) { 
        return \Rx\Observable::of($i + 1); 
    }) 
    ->subscribe(function($value) { 
        echo "$value\n"; 
    }); 

注意,我们既没有创建调度器,也没有启动循环。在 RxPHP 2 中,所有操作符都有默认的调度器预定义,所以我们不需要在 subscribe() 方法中传递它。

如果我们想采用与 RxPHP 1 相似的方法,我们可以硬编码调度器:

use Rx\Scheduler; 
Observable::interval(1000, Scheduler::getAsync()) 
    ->take(5) 
    ... 

然而,在某些情况下,我们可能不想等到脚本末尾的 register_shutdown_function() 开始循环,而是想自己启动它。

让我们看看以下示例:

// rxphp2_02.php 
use Rx\Observable; 

Observable::interval(1000) 
    ->take(3) 
    ->subscribe(function($value) { 
        echo "First: $value\n"; 
    }); 

Observable::interval(1000) 
    ->take(3) 
    ->subscribe(function($value) { 
        echo "Second: $value\n"; 
    }); 

当循环开始时,两个 Observables 都会同时开始发出值,因此输出将如下所示:

$ php rxphp2_02.php
First: 0
Second: 0
First: 1
Second: 1
First: 2
Second: 2

在我们创建第一个 Observable 之后,我们也可以手动启动事件循环:

// rxphp2_03.php 
Observable::interval(1000) 
    ->take(3) 
    ->subscribe(function($value) { 
        echo "First: $value\n"; 
    }); 

Loop::get()->run(); 

Observable::interval(1000) 
    ->take(3) 
    ->subscribe(function($value) { 
        echo "Second: $value\n"; 
    }); 

循环将在打印三个值后结束,然后我们继续处理第二个 Observable。脚本执行结束时,事件循环将自动再次启动。输出如下所示:

$ php rxphp2_03.php
First: 0
First: 1
First: 2
Second: 0
Second: 1
Second: 2

注意

注意,在撰写本书时(2017 年 4 月),async-interop/event-loop 和 RxPHP 2 都处于预发布状态,它们的 API 可能会发生变化。

高阶 Observables

在讨论函数式编程的先决条件时,我们提到了高阶函数。这些函数返回其他函数。在 RxPHP 中,当使用 Observables 时,也应用了非常类似的概念。

高阶 Observable 是发出其他 Observables 的 Observable。为了说明高阶 Observables 与一阶 Observables 的区别,考虑以下简单示例:

// higher_order_01.php 
use Rx\Observable; 
Observable::range(1, 3) 
    ->subscribe(new DebugSubject()); 

这个例子只是打印了三个值并按预期完成。这是我们期望任何一阶 Observables 的行为:

$ php higher_order_01.php 
22:54:05 [] onNext: 1 (integer)
22:54:05 [] onNext: 2 (integer)
22:54:05 [] onNext: 3 (integer)
22:54:05 [] onCompleted

现在,我们可以通过添加 map() 操作符使这个例子更加复杂,该操作符返回另一个 Observable 而不是整数:

// higher_order_02.php 
use Rx\Observable; 

Observable::range(1, 3) 
    ->map(function($value) { 
        return Observable::range(0, $value); 
    }) 
    ->subscribe(new DebugSubject()); 

我们使用 range() 为源 Observable 的每个值创建一个 Observable。在我们的例子中,到达 DebugSubject 实例的 Observables 应该分别发出值 [0][0, 1][0,1,2]

控制台中的输出并不令人满意。DebugSubject 实例打印它接收到的,这是一个 RangeObservable 的实例。

这是正确的行为。我们实际上是从 map() 操作符返回 Observables,而 subscribe() 方法并不关心它传递的值:

$ php higher_order_02.php 
23:29:46 [] onNext: RangeObservable (Rx\Observable\RangeObservable)
23:29:46 [] onNext: RangeObservable (Rx\Observable\RangeObservable)
23:29:46 [] onNext: RangeObservable (Rx\Observable\RangeObservable)
23:29:46 [] onCompleted

注意

每个 onNext 的值是 RangeObservable (Rx\Observable\RangeObservable),因为 DebugSubject 接收一个对象并将其转换为字符串。然后它打印出包括其命名空间在内的类名。

那么,如果我们想扁平化内部 Observables 并重新发出它们的所有值呢?

RxPHP 包含一些操作符,旨在与高阶 Observables 一起使用。特别是最有用的操作符是 mergeAll()concatAll()switchLatest()

为了这个目的,我们可以选择 mergeAll()concatAll()。这两个操作符之间的区别与 merge()concat() 相同。mergeAll() 操作符在收到内部 Observables 时立即订阅它们,并立即重新发出它们的值。另一方面,concatAll() 将按接收到的顺序逐个订阅内部 Observables。

concatAll() 和 mergeAll() 操作符

在这个示例中,我们选择哪一个都无关紧要。RangeObservable 是一个冷 Observable,它使用 ImmediateScheduler,因此所有值总是按正确的顺序发出。

使用 mergeAll() 的实现可能如下所示:

// higher_order_03.php 
Observable::range(1, 3)
    ->map(function($value) {
        return Observable::range(0, $value);
    })
    ->mergeAll()
    ->subscribe(new DebugSubject()); 

现在 Observable::range(1, 3) 发出三个 RangeObservable 实例。mergeAll() 操作符订阅了它们中的每一个,并将它们的所有值重新发出给其观察者,即一个 DebugSubject 实例。

从下面的宝石图中可以明显看出 mergeAll() 的工作原理:

concatAll() 和 mergeAll() 操作符

表示 RxJS 中 mergeAll() 操作符的宝石图(http://reactivex.io/rxjs/class/es6/Observable.js)

表示为顶部水平线的源 Observable 并非直接发出值(线上没有圆圈)。相反,它发出其他 Observable,由对角线表示。

如果我们运行这个示例,我们会得到上面描述的值,即 001012

$ php higher_order_03.php 
00:02:26 [] onNext: 0 (integer)
00:02:26 [] onNext: 0 (integer)
00:02:26 [] onNext: 1 (integer)
00:02:26 [] onNext: 0 (integer)
00:02:26 [] onNext: 1 (integer)
00:02:26 [] onNext: 2 (integer)
00:02:26 [] onCompleted

我们也可以测试如果我们使用异步发出值的 Observable 会发生什么。在这种情况下,我们使用 mergeAll()concatAll() 很重要,所以我们将测试这两种场景。

让我们从 mergeAll() 和一个与上一个示例类似的例子开始。我们将使用 IntervalObservabletake(3) 来发出三个异步发出三个值的 Observable:

// higher_order_04.php 
$loop = new StreamSelectLoop(); 
$scheduler = new EventLoopScheduler($loop); 

Observable::interval(1000, $scheduler)
    ->take(3)
    ->map(function($value) use ($scheduler) {
        return Observable::interval(600, $scheduler)
            ->take(3)
            ->map(function($counter) use ($value) {
                return sprintf('#%d: %d', $value, $counter);
            });
    })
    ->mergeAll()
    ->subscribe(new DebugSubject()); 

$loop->run(); 

内部 Observable 的每个值都被转换成字符串以便于识别。我们可以通过给每个值添加时间戳来描述这个示例中发生的事情:

  • 1000ms:第一个值从外部的 IntervalObservable 发出,通过 map() 操作符转换成了另一个 IntervalObservable。在这个时候,mergeAll() 订阅了这个第一个内部 Observable。

  • 1600ms:内部 IntervalObservable 发出一个初始值(整数 0),该值被转换为字符串并由 DebugSubject 实例打印。

  • 2000ms:第二个内部 Observable 被创建。mergeAll() 操作符也订阅了它。现在它订阅了两个 Observable。

  • 2200ms:第一个内部 IntervalObservable 发出其第二个值 (1)

  • 2600ms:第二个内部 IntervalObservable 发出其第一个值 (0)

  • 2800ms:第一个内部 IntervalObservable 发出其最后一个值 (2)

  • 3000ms:第三个内部 IntervalObservable 被创建。

这会一直持续到所有内部 IntervalObservable 通过 take(3) 操作符发出三个值。

我们可以看到内部 Observable 的值确实是异步发出的,如果我们想消费它们,使用 mergeAll() 操作符非常容易。

完整的控制台输出如下:

$ php higher_order_04.php 
00:43:55 [] onNext: #0: 0 (string)
00:43:55 [] onNext: #0: 1 (string)
00:43:56 [] onNext: #1: 0 (string)
00:43:56 [] onNext: #0: 2 (string)
00:43:56 [] onNext: #1: 1 (string)
00:43:57 [] onNext: #2: 0 (string)
00:43:57 [] onNext: #1: 2 (string)
00:43:57 [] onNext: #2: 1 (string)
00:43:58 [] onNext: #2: 2 (string)
00:43:58 [] onCompleted

使用 concatAll() 的实现完全相同。唯一改变的是我们如何使用这个操作符:

// higher_order_05.php 
... 
    ->map(function($value) use ($scheduler) {
       // ...
    })
    ->concatAll()
    ->subscribe(new DebugSubject()); 
... 

就像concat()运算符一样,concatAll()保持可观察对象的顺序,并且只有在之前的可观察对象完成之后才会订阅下一个可观察对象。控制台中的输出顺序是内部IntervalObservables创建的顺序:

$ php higher_order_05.php 
00:55:30 [] onNext: #0: 0 (string)
00:55:30 [] onNext: #0: 1 (string)
00:55:31 [] onNext: #0: 2 (string)
00:55:32 [] onNext: #1: 0 (string)
00:55:32 [] onNext: #1: 1 (string)
00:55:33 [] onNext: #1: 2 (string)
00:55:34 [] onNext: #2: 0 (string)
00:55:34 [] onNext: #2: 1 (string)
00:55:35 [] onNext: #2: 2 (string)
00:55:35 [] onCompleted

高阶可观察对象的核心原理一开始并不容易理解,所以请随时自行实验。

虽然在 RxPHP 中很难看到concat()concatAll()merge()mergeAll()的实际好处,但这些在 RxJS 中都非常常见。通常,当我们需要按顺序或独立运行多个 HTTP 请求时,使用这些运算符之一非常方便。更多关于这个主题的内容在最后一章中,其中展示了 RxJS 的一些有趣的用例。

switchLatest 运算符

使用concatAll()mergeAll(),我们知道我们将始终接收所有内部可观察对象发射的所有值。在某些用例中,我们可能只关心最新可观察对象的值,而丢弃所有其他可观察对象。这是concatAll()mergeAll()都无法做到的,因为它们总是等待当前可观察对象完成或所有可观察对象完成。

这就是为什么有一个switchLatest()运算符,它始终只订阅最新的可观察对象,并自动取消订阅之前的可观察对象。

下面的弹珠图很好地解释了这一原理:

switchLatest 运算符

弹珠图表示 RxJS 中的 switch()运算符(http://reactivex.io/rxjs/class/es6/Observable.js)

注意

这个运算符在 RxJS 中简单地被称为switch()。还有switchMap()switchMapTo()运算符,目前仅在 RxJS 中可用。

在这个图中,我们可以看到源可观察对象发射了两个可观察对象。第一个内部可观察对象发射了四个值,但其中只有两个("a"和"b")被重新发射。在第三个值发射之前,源可观察对象发射了另一个内部可观察对象,并且当前的可观察对象被取消订阅。然后它继续通过重新发射来自新内部可观察对象的值。

此外,请注意,即使源可观察对象完成,内部可观察对象也会被消费。

那么,这个运算符会如何改变concatAll()mergeAll()示例的输出呢?请看以下内容:

// higher_order_06.php 
... 
    ->map(function($value) use ($scheduler) {
        // ...
    })
    ->switchLatest()
    ->subscribe(new DebugSubject()); 
... 

我们可以肯定,我们不会从所有内部可观察对象中接收所有值,因为每个可观察对象都是在前一个完成之前创建的:

$ php higher_order_06.php 
01:26:24 [] onNext: #0: 0 (string)
01:26:25 [] onNext: #1: 0 (string)
01:26:26 [] onNext: #2: 0 (string)
01:26:27 [] onNext: #2: 1 (string)
01:26:27 [] onNext: #2: 2 (string)
01:26:27 [] onCompleted

每个内部可观察对象只能发射其第一个值,然后它们被取消订阅,除了最后一个可观察对象。由于源可观察对象没有更多的发射,switchLatest()保持对其的订阅。

combineLatest()运算符

concatAll()mergeAll()都会逐个重新发射它们内部可观察对象(或可观察对象)发射的所有值。还有一个具有类似功能的运算符,称为combineLatest()

与前两个不同,combineLatest() 函数接收一个可观察对象数组作为参数,并立即订阅它们中的所有对象。然后,combineLatest() 将每个可观察对象发出的最后一个值内部存储在一个缓冲区中,当所有源可观察对象都至少发出一个值时,它将整个缓冲区作为一个单独的数组发出。然后,在任意源可观察对象发出任何值时,更新后的缓冲区会再次重新发出。

这在下面的宝石图中得到了演示:

combineLatest() 操作符

代表 RxJS 中 combineLatest() 操作符的宝石图(http://reactivex.io/rxjs/class/es6/Observable.js)

注意,当第一个可观察对象发出 a 时,它并没有立即重新发出。只有当第二个可观察对象发出它的第一个值后,combineLatest() 操作符才会重新发出这两个值。这意味着,如果我们有一个包含 N 个可观察对象的数组,combineLatest() 的观察者将始终接收到一个包含 N 个值的数组。

这的一个重要含义是,如果我们源数组中的某个可观察对象由于某种原因没有发出任何值,那么 combineLatest() 也不会发出任何值,因为它需要每个源可观察对象至少有一个值。这可以通过使用 startWith()startWithArray() 操作符来避免,这些操作符在源可观察对象之前添加值发出。

考虑以下示例,我们有一个使用 Observable::just() 创建的只包含单个值的可观察对象。我们想使用 combineLatest() 操作符与一个 IntervalObservable 实例数组结合:

// combine_latest_01.php 

use React\EventLoop\StreamSelectLoop; 
use Rx\Scheduler\EventLoopScheduler; 
use Rx\Observable; 

$loop = new StreamSelectLoop(); 
$scheduler = new EventLoopScheduler($loop); 

$source = Observable::just(42) 
  ->combineLatest([ 
    Observable::interval(175, $scheduler)->take(3), 
    Observable::interval(250, $scheduler)->take(3), 
    Observable::interval(100, $scheduler)->take(5), 
  ]) 
  ->subscribe(new DebugSubject()); 

$loop->run(); 

我们可以肯定输出数组将始终以整数 42 开始。这个数组将在源可观察对象数组发生任何变化时发出。

注意,第二个 IntervalObservable 在 250 毫秒后发出它的第一个值,而第三个 IntervalObservable 仅在 100 毫秒后发出它的第一个值。这意味着我们永远不会收到第三个 IntervalObservable 的第一个值。它永远不会被重新发出,因为 combineLatest() 需要所有可观察对象至少发出一个值,在我们的例子中,它将等待第二个 IntervalObservable,这是最慢的一个。

控制台输出确认了我们的预期行为:

$ php combine_latest_01.php 
09:42:45 [] onNext: [42,0,0,1] (array)
09:42:46 [] onNext: [42,0,0,2] (array)
09:42:46 [] onNext: [42,1,0,2] (array)
09:42:46 [] onNext: [42,1,0,3] (array)
09:42:46 [] onNext: [42,1,1,3] (array)
09:42:46 [] onNext: [42,1,1,4] (array)
09:42:46 [] onNext: [42,2,1,4] (array)
09:42:46 [] onNext: [42,2,2,4] (array)
09:42:46 [] onCompleted

最后两次发出仅因为第二个 IntervalObservable。另外,注意,由于每个源可观察对象的每次发出都会触发 combineLatest() 重新发出其当前值,因此数组中始终只有一个更新的值。

如果我们想确保我们能够捕获所有可观察对象发出的所有值,我们可以使用 startWith() 为每个可观察对象设置默认值:

... 
->combineLatest([ 
  Observable::interval(175, $scheduler)->take(3)->startWith(null), 
  Observable::interval(250, $scheduler)->take(3)->startWith(null), 
  Observable::interval(100, $scheduler)->take(5)->startWith(null), 
]) 
... 

现在输出将从 null 值开始,我们可以确信我们将接收到所有源可观察对象的所有值:

$ php combine_latest_01.php 
09:53:46 [] onNext: [42,null,null,null] (array)
09:53:46 [] onNext: [42,null,null,0] (array)
09:53:46 [] onNext: [42,0,null,0] (array)
09:53:46 [] onNext: [42,0,null,1] (array)
09:53:46 [] onNext: [42,0,0,1] (array)
09:53:46 [] onNext: [42,0,0,2] (array)
... 

这四个操作符属于更高级的 Rx 功能类别。尽管这些操作符在 RxPHP 或 RxJS 中并不常用,但了解它们的存在是非常有用的,因为它们利用了反应式扩展的真正力量。switchMap()combineLatest() 提供的内部逻辑让我们能够避免使用任何状态变量来跟踪我们需要订阅/取消订阅的位置以及需要存储的值。

在下一章中,我们将遇到在单个操作符链中使用的 combineLatest()switchMap()。此外,在本书的最后一章中,当我们讨论与 RxJS 的相似之处时,我们将使用 JavaScript 中 combineLatest() 的一个稍微修改过的版本。concatAll()mergeAll() 操作符在 RxJS 中也很有用,我们可以使用它们做一些在当前的 RxPHP 中无法做到的技巧;但更多内容将在最后一章中介绍。

摘要

本章涵盖了众多新主题。我们将在下一章中使用我们刚刚学到的所有知识,其中我们将使用 Unix 套接字进行进程间通信,以及 WebSocket 服务器来实现一个简单的聊天应用。最重要的是,我们将使用 ProcessObservable 来创建子进程,以及 PHP Streams API 来进行 Unix 套接字通信。我们还将探讨事件循环,包括在 Unix 套接字流和 WebSocket 服务器之间共享相同事件循环实例的使用场景。然后,我们将继续探讨高阶 Observables,用于从多个子进程收集状态,以及 WebSocket 服务器和客户端。PHP Streams API 和高阶 Observables 在本质上可能一开始比较难以理解,所以请随意花时间自己实验。

在下一章中,我们还将介绍 Rx 中的背压概念,这是一种常见的避免通过发出观察者能够处理更多值的策略来过载消费者的方法。

第七章. 实现套接字 IPC 和 WebSocket 服务器/客户端

在上一章中,我们提前看了一下本章将要构建的应用。我们已经知道我们将使用 PHP 流 API 进行进程间通信。我们还将编写 WebSocket 服务器,稍后是一个简单的 WebSocket 客户端。我们还强调了理解异步和非阻塞应用中事件循环工作的重要性,这在本章的服务器和客户端应用中都将适用。

本章也将非常注重源代码,因此我们将将其分为三个更小的部分,涵盖三个不同的应用:

  • 服务器管理应用: 这是我们将运行以测试整个项目的应用。它将启动子进程,并通过 Unix 套接字流(用 PHP 流 API 包装)与它们通信。每个子进程代表一个单独的 WebSocket 服务器,它监听特定的端口。

  • WebSocket 服务器应用: 这是一个 WebSocket 服务器的单个实例,允许同时连接多个客户端,使他们能够聊天。这意味着我们必须实时将每条消息分发给所有客户端。我们还将保留一些最近的消息历史,这些历史将被填充到每个新客户端。这个应用将通过 Unix 套接字与服务器管理器通信,并提供其当前状态(当前连接的客户端数量和聊天历史中的消息数量)。

  • WebSocket 客户端应用: 这是我们的测试客户端,它将连接到 WebSocket 服务器并监听发送到服务器的用户输入。

在我们开始工作于服务器管理应用之前,我们应该讨论一个在 RxJS 环境中出现频率较高,但同时也与本章非常相关的概念。

反压在反应式扩展中

我们通常将 Observables 视为由源 Observable 在一端产生并由观察者在另一端消费的数据流。虽然这仍然是正确的,但我们不知道有 Observables 发出值如此之快,以至于消费者(观察者)无法处理它们的情况。

这可能会导致显著的内存或 CPU 使用,这是我们肯定想避免的。

虽然大多数都不在 RxPHP 中可用,但有两个操作符组适合反压,它们主要与 RxJS 相关:

  • 有损的: 在这个组中,一些值被丢弃并且永远不会到达观察者。例如,这可能是某个时间段内鼠标位置的采样。我们通常对现在的鼠标位置感兴趣;我们不在乎过去的位置,因此这可以完全忽略。

  • 无损的: 在这个组中,值在操作符中堆叠,并且通常以批量的形式发出。我们不想丢失任何数据,因此一个典型的无损操作符的内部实现是一个缓冲区。

正如我们所说的,背压在 RxJS 中比在 RxPHP 中更典型,但让我们看看这两种类型在 RxPHP 中的示例。

有损背压

在前面的章节中,我们使用了 switchLatest() 操作符来处理高阶 Observables。这个操作符会自动订阅源 Observable 发出的最新 Observable,并取消订阅之前的源。实际上,这是一个有损操作符,因为我们知道我们无法保证接收所有值。

实际上,我们通常处理与 RxJS 操作符 throttleTime() 类似的用例。这个操作符接受一个时间段作为参数,它定义了在发出一个值后,它会忽略源 Observable 的所有后续发射。

我们可以查看它的宝石图来清楚地了解它做了什么:

有损背压

这个操作符已经在 RxPHP 中实现,但我们可以仅使用 filter() 来自己实现它,或者更好的方法是创建一个自定义操作符来了解如何内部实现这种和类似的功能。

使用 filter() 操作符实现 throttleTime()

我们可以使用 IntervalObservable 类来模拟一个热源 Observable,它定期发出值,然后我们会过滤掉所有在上一发射后不到一秒到达的值。

以下示例模拟了与 throttleTime() 操作符类似的功能:

// filter_01.php 
$loop = new StreamSelectLoop(); 
$scheduler = new EventLoopScheduler($loop); 
$lastTimestamp = 0; 

Observable::interval(150, $scheduler) 
    ->filter(function() use (&$lastTimestamp) { 
        if ($lastTimestamp + 1 <= microtime(true)) { 
            $lastTimestamp = microtime(true); 
            return true; 
        } else { 
            return false; 
        } 
    }) 
    ->subscribe(new DebugSubject());

$loop->run(); 

注意

从现在起,在这本书中,我们不会包括我们之前使用的类的 use 语句,以使示例尽可能简短。

如果我们运行这个示例,我们会看到它确实做了我们需要的:

$ php filter_01.php 
14:51:01 [] onNext: 0 (integer)
14:51:02 [] onNext: 7 (integer)
14:51:03 [] onNext: 14 (integer)
14:51:04 [] onNext: 21 (integer)
...

如我们所见,IntervalObservable 类会发出不断增加的计数器值,其中大部分都被忽略了。然而,这并不是一个非常系统化的方法。我们必须在变量中保留最后一个时间戳,这是我们通常想避免使用 Rx 来做的。

注意,我们的 filter() 的可调用函数不接受任何参数(当前值),因为这对我们来说并不重要。

因此,让我们将其重新实现为一个独立的 ThrottleTimeOperator 类:

// ThrottleTimeOperator.php 
class ThrottleTimeOperator implements OperatorInterface { 
  private $duration; 
  private $lastTimestamp = 0; 

  public function __construct($duration) { 
    $this->duration = $duration; 
  } 

  public function __invoke($observable, $observer, $sched=null) { 
    $disposable = $observable->filter(function() use ($observer) { 
      $now = microtime(true) * 1000; 
      if ($this->lastTimestamp + $this->duration <= $now) { 
        $this->lastTimestamp = $now; 
        return true; 
      } else { 
        return false; 
      } 
    })->subscribe($observer); 

    return $disposable; 
  } 
} 

正如我们在前面的章节中多次看到的,在实现自定义操作符时,我们需要注意正确传播 onNext 信号,以及 onErroronComplete。我们可以通过重用现有的操作符来委派所有这些责任,这实际上是在 Rx 中实现新操作符的一种推荐方式。这意味着我们的操作符只是设置了一个 filter() 操作符,它会为我们处理所有事情。

使用这个操作符很简单,可以通过 lift() 方法:

// throttle_time_01.php 
$loop = new StreamSelectLoop(); 
$scheduler = new EventLoopScheduler($loop); 
$lastTimestamp = 0; 

Observable::interval(150, $scheduler) 
    ->lift(function()  { 
        return new ThrottleTimeOperator(1000); 
    }) 
    ->subscribe(new DebugSubject()); 

$loop->run(); 

打印到控制台的结果与我们之前看到的代码完全相同,所以我们不需要再次列出它。

因此,这是一个有损操作符。所有没有通过 filter() 的谓词函数的值都将永远丢失。

在 RxJS 5 中,典型的损耗性操作符有 audit()auditTime()throttle()throttleTime()debounce()debounceTime()sample()sampleTime()。在 RxJS 4 中,我们还有 pause() 操作符。

无损背压

无损操作符是指不丢弃任何值的操作符。值只是堆叠起来,并以批量形式发送给观察者。

在 RxPHP 中,我们可以使用 bufferWithCount() 操作符,它接受一个参数,即缓冲区在向观察者发射之前存储的项目数量。可选地,我们还可以指定从上一个缓冲区开始我们想要跳过的项目数量。

水晶球图很好地解释了这一点(此操作符在 RxJS 5 中作为 bufferCount() 提供):

无损背压

如您所见,使用 bufferWithCount() 操作符非常简单。我们将使用之前显示的相同示例,只是切换操作符:

// buffer_with_count_01.php 
$loop = new StreamSelectLoop(); 
$scheduler = new EventLoopScheduler($loop); 
$lastTimestamp = 0; 

Observable::interval(500, $scheduler) 
    ->bufferWithCount(4) 
    ->subscribe(new DebugSubject()); 

$loop->run(); 

我们总是缓冲四个值,所以当源 IntervalObservable 类每 500 毫秒发射一个值时,观察者将每两秒接收一个值:

$ php buffer_with_count_01.php 
15:24:24 [] onNext: [0,1,2,3] (array)
15:24:26 [] onNext: [4,5,6,7] (array)
15:24:28 [] onNext: [8,9,10,11] (array)
15:24:30 [] onNext: [12,13,14,15] (array)

RxJS 5 有五种不同的缓冲操作符变体。

无论是损耗性还是无损操作符都很有用,我们将在实现服务器管理应用程序时稍后使用 throttleTime() 操作符。

实现 ProcessObservable

本章中的应用程序将生成很多子进程,因此将此功能封装到一个自给自足的可观察对象中是有意义的。这个可观察对象将生成一个新的子进程,使用 onNext 发射其输出,并正确处理 onErroronComplete 通知:

// ProcessObservable.php 
class ProcessObservable extends Observable { 
  private $cmd; 
  private $pidFile; 

  public function __construct($cmd, $pidFile = null) { 
    $this->cmd = $cmd; 
    $this->pidFile = $pidFile; 
  } 

  public function subscribe($observer, $scheduler = null) { 
    $process = new Process($this->cmd); 
    $process->start(); 

    $pid = $process->getPid(); 
    if ($this->pidFile) { 
      file_put_contents($this->pidFile, $pid); 
    } 

    $disposable = new CompositeDisposable(); 
    $autoObs = new AutoDetachObserver($observer); 
    $autoObs->setDisposable($disposable); 

    $cancelDisp = $scheduler→schedulePeriodic(function() 
          use ($autoObs, $process, $pid, &$cancelDisp) { 
      if ($process->isRunning()) { 
        $output = $process->getIncrementalOutput(); 
        if ($output) { 
          $autoObs->onNext($output); 
        } 
      } elseif ($process->getExitCode() === 0) { 
        $output = $process->getIncrementalOutput(); 
        if ($output) { 
          $autoObs->onNext($output); 
        } 
        $autoObs->onCompleted(); 
      } else { 
        $e = new Exception($process->getExitCodeText()); 
        $autoObs->onError($e); 
      } 
    }, 0, 200); 

    $disposable->add($cancelDisp); 
    $disposable->add(new CallbackDisposable( 
          function() use ($process) { 

      $process->stop(1, SIGTERM); 
      if ($this->pidFile) { 
        unlink($this->pidFile); 
      } 
    })); 

    return $disposable; 
  } 
} 

这个可观察对象内部使用来自 Symfony3 组件的 Symfony\Component\Process\Process 类,这使得处理子进程变得更容易。

它会定期检查子进程是否有任何可用的输出,并将其发射出来。当进程终止时,我们发送适当的 onErroronComplete 通知。如果我们需要,我们还可以可选地创建一个包含进程 PID 的文件。

注意,我们使用了 AutoDetachObserver 类来包装原始观察者,并将其分配给 $disposable 对象。现在重要的是要知道,当它收到 onErroronComplete 通知时,这个类会自动调用我们传递给它的可丢弃对象的 dispose() 方法。

我们将在第十章 使用 RxPHP 的高级操作符和技术中更详细地解释 AutoDetachObserver 类,使用 RxPHP 的高级操作符和技术

我们可以使用一个小脚本测试这个可观察对象,模拟一个长时间运行的过程:

// sleep.php 
$name = $argv[1]; 
$time = intval($argv[2]); 
$elapsed = 0; 

while ($elapsed < $time) { 
    sleep(1); 
    $elapsed++; 
    printf("$name: $elapsed\n"); 
} 

然后我们使用 ProcessObservable 来生成这个进程并重新发射所有输出:

// process_observable_01.php 
$loop = new React\EventLoop\StreamSelectLoop(); 
$scheduler = new Rx\Scheduler\EventLoopScheduler($loop); 

$pid = tempnam(sys_get_temp_dir(), 'pid_proc1'); 
$obs = new ProcessObservable('php sleep.php proc1 3', $pid); 
$obs->subscribe(new DebugSubject(), $scheduler); 

$loop->run(); 

这将每秒打印一行,然后结束:

$ php process_observable_01.php
11:59:05 [] onNext: proc1: 1
 (string)
11:59:06 [] onNext: proc1: 2
 (string)
11:59:07 [] onNext: proc1: 3
 (string)
11:59:07 [] onCompleted

现在,让我们从本章的主要应用程序开始。

服务器管理应用程序

最后,我们可以开始编写迄今为止最大的应用程序。服务器管理器将是一个 CLI 应用程序,负责启动 WebSocket 服务器,其中每个服务器本身就是一个独立的应用程序,拥有自己的客户端和聊天历史。

一个典型的用例可能是一个 Unix 服务器,它管理着多个游戏服务器的实例。每个服务器都需要隔离。如果其中任何一个崩溃,我们不希望这台机器上的所有游戏服务器也跟着崩溃。同时,我们希望能够从服务器收集一些状态信息,并使用服务器管理器实时监控它们。

我们可以用以下图表描述整个应用程序的结构以及服务器管理器所扮演的角色:

服务器管理器应用程序

在这个图中,我们可以看到右侧的服务器管理器应用程序。它通过 Unix 套接字与单个游戏服务器实例进行通信。这个游戏服务器实例通过 WebSockets 连接了两个客户端。

服务器管理器游戏服务器之间的通信是单向的;游戏服务器将主动将其状态发送给服务器管理器本身。游戏服务器与其所有客户端之间的通信必须是双向的。当用户发送消息时,我们需要将其重新发送给连接到同一游戏服务器的所有其他客户端。

我们将从创建一个基本的类存根开始,该存根通过stdin监听用户输入,并根据这些输入调用一些操作:

// ServerManager.php 
class ServerManagerCommand extends Command { 
  private $scheduler; 
  private $loop; 
  private $unixSocketFile; 
  private $output; 
  private $commands = [ 
    'n' => 'spawnNewServer', 
    'q' => 'quit', 
  ]; 

  protected function configure() { 
    $this->setName('manager'); 
    $this->addArgument('socket_file',InputOption::VALUE_REQUIRED); 
  } 

  protected function execute($input, $output) { 
    $this->output = $output; 
    $this->unixSocketFile = $input->getArgument('socket_file'); 
    @mkdir(dirname($this->unixSocketFile), 0766, true); 

    $loop = new React\EventLoop\StreamSelectLoop(); 
    $this->loop = $loop; 
    $this->scheduler = new EventLoopScheduler($this->loop); 

    $subject = new Subject(); 
    $stdin = $subject->asObservable(); 

    $stdinRes = fopen('php://stdin', 'r'); 
    $loop->addReadStream($stdinRes, function($s) use ($subject) { 
      $str = trim(fgets($s, 1024)); 
      $subject->onNext($str); 
    }); 

    foreach ($this->commands as $pattern => $method) { 
      $stdin 
        ->filter(function($string) use ($pattern) { 
          return $pattern == $string; 
        }) 
        ->subscribeCallback(function($value) use ($method) { 
          $this->$method($value); 
        }); 
    } 

    // ... We'll continue here later 

    $this->loop->run(); 
  } 
} 

$command = new ServerManagerCommand(); 
$application = new Application(); 
$application->add($command); 
$application->setDefaultCommand($command->getName()); 
$application->run(); 

我们从php://stdin创建了一个流并将其添加到事件循环中。这正是我们在上一章讨论 PHP Streams API 时所见到的。为了使添加新命令变得容易,我们创建了一个Subject实例,在用户输入时调用onNext()

我们不是直接订阅Subject实例,而是订阅其asObservable()方法返回的可观察对象。当然,我们也可以直接订阅Subject实例,因为它同时充当可观察对象和观察者。然而,如果有人可以访问Subject实例,那么我们无法保证不会有人错误地调用其onNext()onComplete(),这可能会导致不可预测的行为。因此,隐藏我们内部使用Subject的事实,只通过asObservable()暴露可观察对象是一种良好的实践。

目前我们有两个命令:

  • n:这个命令使用ProcessObservable启动一个新的子进程,并将其可处置项添加到正在运行的进程列表中。我们将使用这些可处置项稍后取消订阅。每个子进程将被分配一个唯一的端口号。这个端口号将由游戏服务器用于启动 WebSocket 服务器。

  • q:这个命令用于退出应用程序。这意味着我们需要调用所有活动进程数组中的dispose(),关闭所有 Unix 套接字连接,然后停止事件循环。

现在我们将实现创建新的子进程和退出应用程序的功能。要退出应用程序,我们需要所有套接字连接的数组($processes私有属性),我们还没有这些。

使用 ProcessObservable 创建新的子进程

创建新的子进程不需要任何特殊操作,因为我们将使用我们之前创建的ProcessObservable类。每个子进程都将分配一个自己的端口号,其中它将运行 WebSocket 服务器:

class ServerManager extends Command { 
  /** @var DisposableInterface[] */ 
  private $processes = []; 

  // ... 

  private function spawnNewServer() { 
    $port = $this->startPort++; 
    $cmd = 'php GameServer.php game-server ' 
      . $this->unixSocketFile . ' ' . $port; 
    $cmd = escapeshellcmd($cmd); 
    $process = new ProcessObservable($cmd); 
    $this->output->writeln('Spawning process on port '.$port); 

    $this->processes[$port] = $process->subscribeCallback( 
      null, 
      function($e) use ($port) { 
        $msg = sprintf('%d: Error "%s"', $port, $e); 
        $this->output->writeln($msg); 
      }, 
      function() use ($port) { 
        $this->output->writeln(sprintf('%d: Ended', $port)); 
      }, $this->scheduler 
    ); 
  } 

  private function quit() { 
    foreach ($this->servers as $server) { 
      $server->close(); 
    } 
    foreach ($this->processes as $process) { 
      $process->dispose(); 
    } 
    $this->loop->stop(); 
  } 
} 

我们启动一个新的子进程,然后订阅它以读取其输出。实际上,我们并不期望收到任何输出;我们这样做只是为了以防子进程崩溃,我们想看看发生了什么。

注意,我们还通过$this->scheduler将单个调度器实例传递给subscribeCallback()。我们需要这样做,因为ProcessObservable会添加自己的周期性计时器来检查子进程的输出。这是我们确实需要确保只使用单个事件循环的情况之一,正如我们在上一章中讨论的那样。

所有可丢弃的对象都将存储在按端口号组织的$processes数组中。保持对所有可丢弃对象的引用很重要,这样我们就可以通过简单地丢弃它们(ProcessObservable将发送一个SIGTERM信号)来温和地结束所有子进程。

游戏服务器应用程序

我们将暂时切换到游戏服务器应用程序。我们只会制作其最基本的部分,即连接到 Unix 套接字服务器并定期从IntervalObservable发送值的那个部分。

我们想这样做,以便能够测试服务器管理器是否正确接收并显示了状态。这是我们使用switchMap()combineLatest()运算符与高阶 Observables 一起工作的部分。

我们现在不会处理 WebSocket 实现 - 这将在以后进行:

// GameServer.php 
class GameServer extends Command { 
  /** @var StreamObservable */ 
  private $streamObservable; 

  protected function configure() { 
    $this->setName('game-server'); 
    $this->addArgument('socket_file',InputOption::VALUE_REQUIRED); 
    $this->addArgument('port', InputOption::VALUE_REQUIRED); 
  } 

  protected function execute($input, $output) { 
    $file = $input->getArgument('socket_file'); 
    $port = $input->getArgument('port'); 

    $client = stream_socket_client("unix://".$file, $errno, $err); 
    stream_set_blocking($client, 0); 

    $loop = new React\EventLoop\StreamSelectLoop(); 
    $this->streamObservable = new StreamObservable($client,$loop); 
    $this->streamObservable->send('init', ['port' => $port]); 
    $this->streamObservable->send('status', 'ready'); 
    $scheduler = new EventLoopScheduler($loop); 

    Observable::interval(500, $scheduler) 
      ->subscribeCallback(function($counter) { 
        $this->streamObservable->send('status', $counter); 
      }); 

    $loop->run(); 
    // WebSocket server will go here... 
  } 
} 

使用stream_socket_client()函数,我们连接到 Unix 套接字服务器。

注意,在连接建立后,我们向服务器管理器发送了两条消息。第一条消息是表示子进程正在以init状态运行,并且它还指示了它使用的端口号(WebSocket 服务器的端口号)。第二条消息是带有字符串readystatus。这是我们将在服务器管理器中显示的内容。然后我们创建IntervalObservable,它通过 Unix 套接字流每 500 毫秒发送一个状态。

我们正在使用一些尚未实现的神秘StreamObservable类。实际上,Unix 套接字流是一个双向通道,因此用 Observable 包装其连接以方便是有意义的。当它接收到数据时,它调用onNext(),当我们关闭连接时,它调用onComplete()

这个 Observable 也会发送数据,所以看起来Subject实例可能更适合这个目的。尽管它通过send()方法发送数据,但实际上它是通过fwrite()直接写入流的。Subjects 是为向观察者发送数据而设计的,这并不是我们的情况。

StreamObservable 类是一个相对简单的 Observable,它将其流添加到事件循环中,并发出它接收到的所有数据:

//  StreamObservable.php 
class StreamObservable extends Observable { 
  protected $stream; 
  protected $subject; 
  protected $loop; 

  public function __construct($stream, LoopInterface $loop) { 
    $this->stream = $stream; 
    $this->loop = $loop; 
    $this->subject = new Subject(); 

    $this->loop->addReadStream($this->stream, function ($stream) { 
      $data = trim(fgets($stream)); 
      $this->subject->onNext($data); 
    }); 
  } 

  public function subscribe($observer, $scheduler = null) { 
    return $this->subject->subscribe($observer); 
  } 

  public function send($type, $data) { 
    $message = ['type' => $type, 'data' => $data]; 
    fwrite($this->stream, json_encode($message) . "\n"); 
  } 

  public function close() { 
    $this->loop->removeReadStream($this->stream); 
    fclose($this->stream); 
    $this->subject->onCompleted(); 
  } 
} 

现在应该很明显 GameServer 类是如何工作的。在我们实现了 WebSocket 服务器之后,我们将使用 StreamObservable 上的 send() 方法向服务器管理器报告其状态。然而,我们不会使用 IntervalObservable 和其递增的计数器,而是发送已连接客户端的数量和聊天历史中的消息数量。

让我们回到服务器管理器,并实现所需的 Unix 套接字服务器,以便在游戏服务器和服务器管理器之间建立连接。

服务器管理器和 Unix 套接字服务器

为了能够使用 stream_socket_client() 连接到套接字服务器,我们首先需要使用 stream_socket_server() 创建服务器。其原理与我们之前在解释使用 stream_socket_server()stream_socket_accept()StreamSelectLoop 的简单 HTTP 服务器示例时看到的是相同的:

class ServerManager extends Command { 
  // ... 
  private $statusSubject; 
  private $servers = []; 

  protected function execute($input, $output) { 
    // ... 
    @unlink($this->unixSocketFile); 
    $address = "unix://" . $this->unixSocketFile; 
    $server = stream_socket_server($address, $errno, $errMsg); 
    stream_set_blocking($server, 0); 

    $this->loop->addReadStream($server, function() use ($server) { 
      $client = stream_socket_accept($server); 
      $server = new GameServerStreamEndpoint($client,$this->loop); 

      $server->onInit()->then(function($port) use ($server) { 
        $msg = sprintf('Sub-process %d initialized', $port); 
        $this->output->writeln($msg); 
        $this->addServer($port, $server); 
      }); 
    }); 

    $this->statusSubject = new Subject(); 
    // ... We'll continue here later 
  } 

  private function addServer($port, $server) { 
    $this->servers[$port] = $server; 
    $this->statusSubject->onNext(null); 
  } 
} 

通过 Unix 套接字接受新连接类似于 TCP 连接。在 GameServer 类中,我们看到了在建立连接后它总是首先发起的状态调用是 "init",以及它的端口号,以便告诉服务器管理器哪个游戏服务器已初始化并准备好开始接收 WebSocket 客户端。我们还提到,我们需要跟踪所有活动连接,以便在我们想要退出应用程序时能够关闭它们。从每个子进程收集状态需要我们能够区分哪个套接字连接属于哪个子进程(以及我们分配给它的端口号)。

这就是为什么,当我们接受一个新的连接时,我们会用具有 onInit() 方法的 GameServerStreamEndpoint 类来包装它,该方法返回 Promise 类的一个实例。当新的连接发送其 init 状态时,这个 Promise 类随后会使用子进程端口号(见 GameServer 类)进行解析。之后,我们最终使用 addServer() 方法将连接添加到连接数组中(以端口号作为键)。

注意,我们保留了一个用于进程的数组($processes 属性)和另一个用于包装在 GameServerStreamEndpoint 中的流连接的数组($servers 属性)。

还要注意,在 addServer() 方法的末尾,我们调用 $statusSubject->onNext(null)。这将触发对子进程状态订阅集合的更新。我们稍后会讨论这个问题。

实现 GameServerStreamEndpoint

这个类将结合我们刚才创建的 StreamObservable、Promises、Deferred 类和 Observables。这样,我们可以完全隐藏其内部,其中我们解码从流中接收到的 JSON 字符串,并按类型过滤消息:

// GameServerStreamEndpoint.php 
class GameServerStreamEndpoint { 
  private $stream; 
  private $initDeferred; 
  private $status; 

  public function __construct($stream, LoopInterface $loop) { 
    $this->stream = new StreamObservable($stream, $loop); 
    $this->initDeferred = new Deferred(); 

    $decodedMessage = $this->stream 
      ->lift(function() { 
        return new JSONDecodeOperator(); 
      }); 

    $unsubscribe = $decodedMessage 
      ->filter(function($message) { 
        return $message['type'] == 'init'; 
      }) 
      ->pluck('data') 
      ->subscribeCallback(function($data) use (&$unsubscribe) { 
        $this->initDeferred->resolve($data['port']); 
        $unsubscribe->dispose(); 
      }); 

    $this->status = $decodedMessage 
      ->filter(function($message) { 
        return $message['type'] == 'status'; 
      }) 
      ->pluck('data') 
      ->multicast(new ReplaySubject(1)); 
    $this->status->connect(); 
  } 

  public function getStatus() { 
    return $this->status; 
  } 

  public function onInit() { 
    return $this->initDeferred->promise(); 
  } 

  public function close() { 
    return $this->stream->close(); 
  } 
} 

我们订阅StreamObservable实例以解码任何传入的消息($decodedMessage变量)。然后,通过filter()操作符,我们只传递特定类型的消息。

如果消息类型是init,我们解析onInit()返回的Promise对象。我们知道不应该有多个init调用,所以我们可以在之后立即取消订阅。

当我们收到status消息时,情况会稍微复杂一些。我们将$decodedMessagemulticast()操作符链式连接。这是一个我们尚未遇到的操作符,我们将在下一章中更详细地探讨它。目前,我们只需要知道这个操作符使用我们提供的Subject实例订阅其源 Observable,在这个例子中是ReplaySubject。然后,它返回一个ConnectableObservable(参见第三章,使用 RxPHP 编写 Reddit 阅读器)。

multicast()操作符的重要之处在于它为其源 Observable 创建单个订阅。我们故意使用ReplaySubject是因为它记得它发出的最后一个值,所以如果我们多次订阅getStatus()返回的 Observable,我们总是会立即接收到最新的值。

multicast()操作符有多种变体,每个变体都有稍微不同的目的,但更多内容将在第八章,在 RxPHP 和 PHP7 pthreads 扩展中进行多播中介绍。

显示子进程的实时状态

为了显示单个GameServerStreamEndpoint的状态,我们可以订阅getStatus()返回的 Observable,实际上它是一个ConnectableObservable

然而,我们的用例并不那么简单。如果我们启动一个新的子进程并想订阅它怎么办?对于N个子进程,我们需要N个订阅。此外,我们的要求是实时监控所有状态,所以这似乎可以使用combineLatest()操作符与一个 Observable 数组(一个发出状态的 Observable 数组)来实现。问题是,我们不知道我们将有多少个 Observable,因为我们将通过启动新的子进程来动态添加它们。

一种可能的解决方案是使用combineLatest()来订阅所有当前状态 Observable,并在创建新的子进程时取消订阅并创建一个新的状态 Observable 数组供combineLatest()操作符使用。这当然是可行的,但有一个更好、更优雅的解决方案,即使用来自第六章,PHP Streams API 和高级 ObservableswitchLatest()操作符和高阶 Observable。

我们将首先在一个单独的示例中演示这个原理,然后将其应用于 ServerManager 类。

结合 switchLatest()combineLatest() 操作符

假设我们每 1000 毫秒添加一个新的服务器,但其中一个现有的服务器每 600 毫秒更新其状态。这意味着我们需要每秒从当前的服务器数组中重新创建一个新的包含 combineLatest() 的可观察对象。

考虑以下示例,我们使用两个 IntervalObservables 来模拟这种情况:

// switch_latest_01.php  
$range = [1]; 
$loop = new StreamSelectLoop(); 
$scheduler = new EventLoopScheduler($loop); 

$newServerTrigger = Observable::interval(1000, $scheduler); 
$statusUpdate = Observable::interval(600, $scheduler)->publish(); 
$statusUpdate->connect(); // Make it hot Observable 

$newServerTrigger 
    ->map(function() use (&$range, $statusUpdate) { 
        $range[] = count($range) + 1; 
        $observables = array_map(function($val) { 
            return Observable::just($val); 
        }, $range); 

        return $statusUpdate 
            ->combineLatest($observables, function() { 
                $values = func_get_args(); 
                array_shift($values); 
                return $values; 
            }); 
    }) 
    ->switchLatest() 
    ->take(8) 
    ->doOnCompleted(function() use ($loop) { 
        $loop->stop(); 
    }) 
    ->subscribe(new DebugSubject()); 

$loop->run(); 

我们不使用服务器数组,而使用一个不断扩展的 $range 变量,并且不使用真实的状态,我们只是用 Observable::just() 包装值。

可观察对象 $statusUpdate$statusUpdate 可观察对象上独立发出,这使得 combineLatest() 操作符有时会在没有任何变化的情况下重新发出相同的值,同时订阅到相同的可观察对象数组。

这个可观察对象链的核心部分显然是 combineLatest()switchLatest()。由于 $newServerTrigger 代表添加一个新的服务器,我们需要为 combineLatest() 提供一个包含我们想要订阅的新可观察对象的数组。然后 switchLatest()combineLatest() 返回的先前可观察对象取消订阅,并订阅新的一个。

你可能会想知道为什么我们使用 func_get_args()array_shift() 来获取传递给可调用的值。操作符 combineLatest() 为每个源可观察对象传递值(N 个源可观察对象产生 N 个函数参数),但我们不知道我们将有多少个源可观察对象。这就是为什么我们将所有参数作为一个数组来获取,然后移除第一个项目。第一个项目是 $statusUpdate 的值,它也被 combineLatest() 作为源可观察对象包含,但对我们来说它没有任何作用,所以我们不会重新发出它。

注意

注意,combineLatest() 的选择器函数是可选的。如果我们不提供它,操作符将只将所有源可观察对象的所有值作为一个数组传递。

控制台中的输出将如下所示:

$ php switch_latest_01.php 
12:18:32 [] onNext: [1,2] (array)
12:18:32 [] onNext: [1,2] (array)
12:18:33 [] onNext: [1,2,3] (array)
12:18:34 [] onNext: [1,2,3] (array)
12:18:34 [] onNext: [1,2,3,4] (array)
12:18:35 [] onNext: [1,2,3,4,5] (array)
12:19:25 [] onNext: [1,2,3,4,5] (array)
12:19:26 [] onNext: [1,2,3,4,5,6] (array)
22:54:16 [] onCompleted

以下是这个示例中事件的按时间戳顺序:

  • 1000 毫秒:$newServerTrigger 可观察对象首次触发,并将第二个项目添加到 $range 数组中。此时 combineLatest() 订阅到了由 Observable::just() 创建的两个可观察对象。由于这两个都是冷可观察对象,combineLatest() 立即重新发出它们的值,因为它已经为每个可观察对象有了值。

  • 1200 毫秒:$statusUpdate 可观察对象再次触发(由于 publish()connect(),它是一个热可观察对象,因此即使在未订阅的情况下也会发出事件)。这使得 combineLatest() 再次触发。

  • 1800 毫秒:$statusUpdate 可观察对象再次触发,这使得 combineLatest() 第三次发出。由于此刻仍然只有两个可观察对象,所以我们得到与之前相同的结果。

  • 2000 ms: $newServerTrigger 可观察对象触发并将在 $range 中添加一个新项目。现在 combineLatest() 操作符订阅了三个可观察对象。

这会一直进行,直到我们总共收集到八个发射(多亏了 take(8) 操作符)。这是一个更高阶可观察对象在实际中应用的真正实用的例子。

现在我们可以使用服务器管理应用程序重新实现它:

class ServerManager extends Command { 
  // ... 
  protected function execute($input, $output) { 
    // ... 
    $this->statusSubject 
      ->map(function() { 
        $observables = array_map(function($server) { 
          /** @var GameServerStreamEndpoint $server */ 
          return $server->getStatus(); 
        }, $this->servers); 

        return Observable::just(true) 
          ->combineLatest($observables, function($array) { 
            $values = func_get_args(); 
            array_shift($values); 
            return $values; 
          }); 
      }) 
      ->switchLatest() 
      ->map(function($statuses) { 
        $updatedStatuses = []; 
        $ports = array_keys($this->servers); 
        foreach ($statuses as $index => $status) { 
          $updatedStatuses[$ports[$index]] = $status; 
        } 
        return $updatedStatuses; 
      }) 
      ->subscribeCallback(function($statuses) use ($output) { 
        $output->write(sprintf("\033\143")); // clean screen 
        foreach ($statuses as $port => $status) { 
          $str = sprintf("%d: %s", $port, $status); 
          $output->writeln($str); 
        } 
      }); 

    // ... 
  } 
} 

这正是相同的操作符链,只是通过为每个状态添加端口号进行了一点点增强。

当我们在 addServer() 方法中添加一个新的服务器时,我们触发 $statusSubject,它重新创建包含状态的观察对象数组。然后,当任何服务器的状态更新时,它直接触发 combineLatest(),因为它们唯一的订阅者是它。

现在也应该明白为什么我们在编写 GameServerStreamEndpoint 时使用了 ReplaySubject。当我们重新订阅已经存在的状态观察对象时,我们希望始终至少有一个值可用,这样 combineLatest() 就不必等待所有源观察对象都发射一个值。由于 ReplaySubject,它们已经发射了最新的值,直接在订阅时发射。

我们可以通过运行 ServerManager.php 脚本来测试这是如何工作的。现在,GameServer 实例将定期使用 IntervalObservable 发射值,因此我们应该已经收到了状态更新。

让我们开始启动 ServerManager.php 应用程序:

$ php ServerManager.php manager ./var/server.sock
Listening on socket ./var/server.sock
Running ...

此命令将 Unix 套接字文件的路径作为参数。它自动将此文件路径传递给所有子进程,这样它们就知道它们应该尝试连接的位置。现在,我们可以按 n 个字符,然后按 Enter 键来生成几个子进程。每个子进程首先发送 ready 状态,然后开始从 IntervalObservable 类发射值。

输出可能看起来像以下内容:

8888: 28
8889: 15
8890: 14
8891: ready

然后,你可以按下 Q 键,然后按 Enter 键优雅地退出应用程序。

注意

注意,我们使用了操作符链 map(callback)->switchLatest()。这种操作符的组合有一个快捷方式 flatMapLatest(callback)。然而,为了使我们的代码更加明确,我们通常会使用更长且更明显的变体。

最后,我们可以实现 WebSocket 服务器和客户端。

实现 WebSocket 服务器

为了实现 WebSocket 服务器,我们将使用一个名为 cboden/ratchet 的库:

$ composer require cboden/ratchet

WebSocket 服务器由一个实现了 MessageComponentInterface 接口并具有四个方法 onOpen()onClose()onError()onMessage() 的类表示。这个类在每个事件上的行为取决于开发者。通常在聊天应用程序中,我们希望将所有活跃的连接保存在客户端数组中,并通过 onMessage() 读取消息,然后将它们重新发送给所有客户端。

我们首先实现所需的方法,然后添加一些自定义的方法:

// ChatServer.php 
use Ratchet\MessageComponentInterface; 
use Ratchet\ConnectionInterface; 

class ChatServer implements MessageComponentInterface { 
  private $connections; 
  private $history = []; 
  private $subject; 

  public function __construct() { 
    $this->subject = new Subject(); 
  } 

  public function onOpen(ConnectionInterface $conn) { 
    $this->connections[] = $conn; 
    foreach (array_slice($this->history, -5, 5) as $msg) { 
      $conn->send($msg); 
    } 
    $this->subject->onNext(null); 
  } 

  public function onMessage(ConnectionInterface $from, $msg) { 
    $this->history[] = $msg; 
    foreach ($this->connections as $conn) { 
      if ($from !== $conn) { 
        $conn->send($msg); 
      } 
    } 
    $this->subject->onNext(null); 
  } 

  public function onClose(ConnectionInterface $conn) { 
    foreach ($this->connections as $index => $client) { 
      if ($conn !== $client) { 
        unset($this->connections[$index]); 
      } 
    } 
    $this->subject->onNext(null); 
  } 

  public function onError(ConnectionInterface $conn, $e) { 
    $this->onClose($conn); 
  } 
} 

没有任何进一步的解释,这个代码的作用应该是显而易见的。只需注意,我们正在使用$subject来表示其状态已更改并需要通过 Unix 套接字发送到服务器管理器。

现在,我们可以添加更多方法。特别是,我们需要getObservable(),我们将订阅以接收当前状态的通知:

class ChatServer implements MessageComponentInterface { 
  // ... 
  public function getObservable() { 
    return $this->subject 
      ->map(function() { 
        return sprintf('clients: %d, messages: %d', 
          $this->getClientsCount(), 
          $this->getChatHistoryCount() 
        ); 
      }); 
  } 

  private function getClientsCount() { 
    return count($this->connections); 
  } 

  private function getChatHistoryCount() { 
    return count($this->history); 
  } 
} 

这个类本身不足以启动 WebSocket 服务器。

WebSocket 连接首先被建立为一个正常的 HTTP 连接,然后升级为 WebSocket 连接。

GameServer类中,我们订阅getObservable()方法返回的 Observable,以便在聊天服务器的状态更改并需要发送到服务器管理器时得到通知。聊天服务器的状态由当前客户端数量和聊天历史中的总消息数表示:

class GameServer extends Command { 
  // ... 
  protected function execute($input, $output) { 
    // ... 
    $webSocketServer = new ChatServer(); 
    $socket = new Reactor($loop); 
    $socket->listen($port, '0.0.0.0'); 
    $server = new IoServer( 
      new HttpServer(new WsServer($webSocketServer)), 
      $socket, 
      $loop 
    ); 

    $webSocketServer->getObservable() 
      ->subscribeCallback(function($status) { 
        $this->streamObservable->send('status', $status); 
      }); 

    $server->run(); 
  } 
} 

当我们已经在GameServer类中时,我们可以看到如何在实践中使用背压。在有多个游戏服务器的情况下,每个服务器每秒都会多次发出值,我们可能想要使用ThrottleTimeOperator通过 Unix 套接字流来限制发射:

Observable::interval(500, $scheduler) 
  ->lift(function() { 
    return new ThrottleTimeOperator(2000); 
  }) 
  ->subscribeCallback(function($counter) { 
    $this->streamObservable->send('status', $counter); 
  }); 

现在,每个GameServer类最多每两秒发送一次状态。在实际应用中,我们显然不会使用IntervalObservable,而是将发射状态留给$webSocketServer->getObservable()。无论如何,背压和ThrottleTimeOperator的使用方式保持不变。

实现 WebSocket 客户端

要实现 WebSocket 客户端,我们将使用另一个名为ratchet/pawl的 PHP 库:

$ composer require ratchet/pawl 0.2.2 

客户端将从php://stdin读取输入并通过 WebSocket 发送到服务器。它还将监视任何传入的消息并将它们打印到控制台:

// GameClient.php 
use function Ratchet\Client\connect; 

class GameClient extends Command { 
  protected function configure() { 
    $this->setName('chat-client'); 
    $this->addArgument('port', InputArgument::REQUIRED); 
    $this->addArgument('address', InputArgument::OPTIONAL, 
      '', '127.0.0.1'); 
  } 

  protected function execute($input, $output) { 
    $port = $input->getArgument('port'); 
    $address = $input->getArgument('address'); 

    $stdin = fopen('php://stdin', 'r'); 
    $loop = new StreamSelectLoop(); 

    connect('ws://' . $address . ':' . $port, [], [], $loop) 
      ->then(function($conn) use ($loop, $stdin, $output) { 
        $loop->addReadStream($stdin,  
          function($stream) use ($conn, $output) { 
            $str = trim(fgets($stream, 1024)); 
            $conn->send($str); 
            $output->writeln("> ${str}"); 
          }); 

          $conn->on('message', function($str) use ($conn,$output){ 
            $output->writeln("< ${str}"); 
          }); 
        }, function ($e) use ($output) { 
            $msg = "Could not connect: {$e->getMessage()}"; 
            $output->writeln($msg); 
        }); 
  } 
} 

WebSocket 客户端是通过connect()函数创建的,其中,作为一个协议,我们使用ws。这个方法返回一个 Promise,当连接建立时,它会被解析为 WebSocket 连接对象,否则会被拒绝。这个函数还需要一个事件循环,我们必须提供我们的单个StreamSelectLoop实例。同一个事件循环用于从fopen()流中读取。

如果我们没有直接提供事件循环,connect()函数会在内部创建自己的实例。这个循环会导致我们在上一章中描述的情况,从php://stdin流中读取的内部循环将永远不会运行。

我们还使用这个连接对象通过on()方法设置事件监听器,并通过send()方法向服务器发送数据。所有发送的消息都以前缀>开头,而所有接收到的消息都以前缀<开头。

现在,我们可以使用这个客户端来测试服务器管理器的实际使用。如果我们运行三个GameClient实例并发送一些示例消息,输出可能如下所示:

$ php GameClient.php chat-client 8890
Hello, World!
> Hello, World!
< Test!

然后,实时监控状态可能看起来像这样:

8888: ready
8889: clients: 1, messages: 0
8890: clients: 1, messages: 2
8891: ready

任何新的 WebSocket 客户端或任何新的消息都会立即更新这个概览。

摘要

本章内容非常注重代码,包含大量基于使用 Unix 套接字和 WebSocket 的示例。我们还利用了本章及上一章学到的很多知识,包括高阶 Observables,使用 switchLatest()combineLatest(),背压以及我们可以使用的算子,使用多个流的事件循环,以及使用 multicast() 算子来在多个观察者之间共享单个订阅。

在下一章中,我们将探讨 Rx 中的多播,并开始使用 pthreads PHP 扩展来利用线程实现真正的并行性,这些线程通常很难实现。

第八章。RxPHP 和 PHP7 pthreads 扩展中的多播

为了利用多个 CPU 和多个核心,我们一直在使用子进程。这当然是一个非常简单且安全的方法来并行运行代码。结合 Unix 套接字,我们可以轻松实现进程间通信。在前一章中,我们将所有这些与 RxPHP 结合起来,以创建完全分离且并行运行的应用程序。

在本章中,我们将探讨一个名为 pthreads 的非常有趣的 PHP7 扩展,它允许使用 POSIX 线程在 PHP 中实现多线程。

尤其是本章将涵盖以下主题:

  • 深入了解 Subject 类及其变体。

  • RxPHP 及其所有衍生品中的多播操作符

  • ConnectableObservableMulticastObservable 的示例

  • 使用单个 Subject 实例与多个源 Observables

  • PHP 中多线程的基础

  • 关于今天 pthreads 扩展的状态、其两个主要版本及其当前实际使用的说明

  • 使用 pthreads 扩展编写几个多线程应用程序,以演示如何使用 ThreadWorkerPoll

在我们并行处理之前,我们应该看看反应式扩展的另一个特性,即多播,它涉及 multicast() 操作符及其衍生品。多播是围绕 Subjects 构建的,所以让我们首先更好地看看我们有哪些不同类型的 Subject 可用。

主题

自从 第二章,使用 RxPHP 进行响应式编程,我们在这本书中一直在使用 Subjects,但 Subject 类有多个不同的变体,用于更具体的用例,其中所有这些都与多播相关。

BehaviorSubject

BehaviorSubject 类扩展了默认的 Subject 类,并允许我们设置一个默认值,该值在订阅时传递给其观察者。考虑以下非常简单的 BehaviorSubject 示例:

// behaviorSubject_01.php  
use Rx\Subject\BehaviorSubject; 

$subject = new BehaviorSubject(42); 
$subject->subscribe(new DebugSubject()); 

DebugSubject 订阅 BehaviorSubject 类时,默认值 42 立即发出。这与使用 startWith() 操作符的功能类似。

输出结果只是一行:

$ php behaviorSubject_01.php
15:11:54 [] onNext: 42 (integer)

ReplaySubject

ReplaySubject 类内部包含一个数组,其中包含它接收到的最后 N 个值,并在订阅时自动将这些值重新发射给每个新的观察者。

在以下示例中,我们订阅了 RangeObservable,它立即将其所有值发射到 ReplaySubject 类。最后三个值始终存储在数组中,当我们稍后使用 DebugSubject 类订阅时,它将立即接收到这三个值:

// replaySubject_01.php 
use Rx\Subject\ReplaySubject; 
$subject = new ReplaySubject(3); 

Observable::range(1, 8) 
    ->subscribe($subject); 

$subject->subscribe(new DebugSubject()); 

输出结果由 ReplaySubject 类接收的最后三个值组成:

$ php replaySubject_01.php 
15:46:30 [] onNext: 6 (integer)
15:46:30 [] onNext: 7 (integer)
15:46:30 [] onNext: 8 (integer)
15:46:30 [] onCompleted

注意,我们还收到了 complete 信号,这是正确的,因为它是由 RangeObservable 发出的。

AsyncSubject

RxPHP 提供的最后一个 Subject 类型称为 AsyncSubject,这可能会有些令人困惑。这个 Subject 唯一做的事情就是只发出它在接收到 complete 信号之前接收到的最后一个值。

我们将在与上一个示例类似的一个例子中演示这个 Subject。我们只需改变操作的顺序,并在订阅源 Observable 之前订阅 DebugSubject 类,以看到它默默地抑制了所有值,除了最后一个:

// asyncSubject_01.php 
use Rx\Subject\AsyncSubject; 
$subject = new AsyncSubject(); 
$subject->subscribe(new DebugSubject()); 

Observable::range(1, 8) 
    ->subscribe($subject); 

输出只有源 RangeObservable 发出的最后一个值:

$ php asyncSubject_01.php 
16:00:46 [] onNext: 8 (integer)
16:00:46 [] onCompleted

现在我们已经知道了一切,可以开始使用多播以及特定的 multicast() 操作符了。

RxPHP 中的多播

在响应式扩展中,多播意味着通过 Subject 类的实例在多个观察者之间共享单个订阅。所有多播操作符在内部都基于通用的 multicast() 操作符,它实现了它们最常用的功能。当然,我们不仅限于只使用 Subject 类,我们还会使用 ReplaySubjectBehaviorSubject

多播在所有 Rx 实现中都很常见,因此了解其内部工作原理通常很有用。

multicast() 操作符和 ConnectableObservable

multicast() 操作符根据我们传递的参数返回 ConnectableObservableMulticastObservable。我们首先看看它是如何与 ConnectableObservable 一起工作的,因为这对我们来说应该非常熟悉。

一个典型的用例可能看起来像以下示例:

// multicast_01.php  
$observable = Rx\Observable::defer(function() { 
        printf("Observable::defer\n"); 
        return Observable::range(1, 3); 
    }) 
    ->multicast(new Subject()); 

$observable->subscribe(new DebugSubject('1')); 
$observable->subscribe(new DebugSubject('2')); 
$observable->connect(); 

我们没有实例化 ConnectableObservable,而是使用了 multicast() 操作符来帮我们完成。

在这个示例中,我们创建了一个单独的源 Observable,并订阅了两个观察者。然后,在调用 connect() 之后,ConnectableObservable 类订阅了由 Observable::defer 静态方法返回的 AnonymousObservable 实例。

如我们所见,multicast() 操作符返回一个 ConnectableObservable 的实例。这个示例的结果如下:

$ php multicast_01.php 
Observable::defer
10:43:42 [1] onNext: 1 (integer)
10:43:42 [2] onNext: 1 (integer)
10:43:42 [1] onNext: 2 (integer)
10:43:42 [2] onNext: 2 (integer)
18:12:16 [1] onNext: 3 (integer)
18:12:16 [2] onNext: 3 (integer)
10:43:42 [1] onCompleted
10:43:42 [2] onCompleted

所有观察者都订阅了我们传递的同一个 Subject 实例。这是一个重要的含义,我们需要意识到。

在不久的将来,我们将查看这个示例的一个略微修改的版本,它将向 multicast() 传递不同的参数。

MulticastObservable

另一个用于多播的 Observable 称为 MulticastObservable。它的用法与 ConnectableObservable 类似,但其内部功能非常不同。考虑以下示例:

// multicastObservable_01.php 
$source = Rx\Observable::defer(function() { 
    printf("Observable::defer\n"); 
    return Observable::range(1, 3); 
}); 

$observable = new MulticastObservable($source, function() { 
    return new Subject(); 
}, function (ConnectableObservable $connectable) { 
    return $connectable->startWith('start'); 
}); 

$observable->subscribe(new DebugSubject('1')); 
$observable->subscribe(new DebugSubject('2')); 

当订阅MulticastObservable时,它会在源 Observable(正如我们在上一个示例中看到的,它返回ConnectableObservable)上内部调用multicast()操作符,并运行第一个可调用函数来创建Subject类的实例。这是与仅使用multicast()相比的第一个主要区别,在后者中,我们始终共享相同的Subject类实例。相比之下,MulticastObservable为每个订阅者创建一个新的Subject

因此,在内部,我们有一个ConnectableObservable的实例。然后它调用第二个可调用函数,并将这个ConnectableObservable作为参数传递,这意味着我们可以进一步链式添加操作符,或者我们甚至可以使用一个完全不同的 Observable(只需记住这个方法必须返回一个 Observable,因为操作符将内部订阅它)。

这个可调用函数通常被称为选择器函数,因为它允许我们选择我们想要订阅的位置。之后,MulticastObservable订阅返回的 Observable,并在ConnectableObservable上调用connect()方法。

在我们的示例中,我们为每个订阅者创建一个Subject类的实例,然后使用ConnectableObservable链式添加startWith(),这使得它在从源发出值之前发出单个值。

输出将如下所示:

$ php multicastObservable_01.php 
12:54:20 [1] onNext: start (string)
Observable::defer
12:54:20 [1] onNext: 1 (integer)
12:54:20 [1] onNext: 2 (integer)
12:54:20 [1] onNext: 3 (integer)
12:54:20 [1] onCompleted
12:54:20 [2] onNext: start (string)
Observable::defer
12:54:20 [2] onNext: 1 (integer)
12:54:20 [2] onNext: 2 (integer)
12:54:20 [2] onNext: 3 (integer)
12:54:20 [2] onCompleted

注意,延迟的 Observable 被调用了两次,这是正确的。每个观察者都有自己的 Subject 和ConnectableObservable实例。我们完全控制我们用于多播的 Subjects,而不是将其留给默认的multicast()行为。

问题是,我们是否使用相同的 Subject 实例,这究竟有什么关系?

Subjects 及其内部状态

我们知道如何使用 Subjects。我们也知道nextcompleteerror信号的作用。那么,如果我们使用单个Subject并多次订阅冷 Observable 会发生什么?考虑以下示例:

// subject_01.php 
$subject = new Subject(); 

$subject->subscribe(new DebugSubject('1')); 
$subject->onNext(1); 
$subject->onNext(2); 
$subject->onNext(3); 
$subject->onCompleted(); 

$subject->subscribe(new DebugSubject('2')); 
$subject->onNext(4); 
$subject->onCompleted(); 

我们将运行这个示例,并讨论Subject实例内部发生的事情。请注意,我们两次订阅了Subject,第一个观察者(由DebugSubject表示)接收前三个值,然后发出complete信号。

然而,第二个观察者会发生什么呢?

$ php subject_01.php 
13:15:00 [1] onNext: 1 (integer)
13:15:00 [1] onNext: 2 (integer)
13:15:00 [1] onNext: 3 (integer)
13:15:00 [1] onCompleted
13:15:00 [2] onCompleted

第二个观察者只接收了complete信号,而没有观察者接收了值4

理解当Subject类接收到complete信号(这意味着它接收到一个complete信号或我们手动调用onCompleted()方法)时内部发生的事情非常重要:

  1. Subject类检查它是否已经被标记为停止。如果是,则方法立即返回。如果没有停止,则它将自己标记为停止。

  2. 完整的信号随后发送给所有观察者。

  3. 观察者数组被清空。

所以现在应该很清楚。前三个值像往常一样发出。然后我们调用了onComplete(),它正好做了我们在这几点中描述的事情。此时,这个Subject实例没有观察者(参见步骤 4)。然后我们用另一个观察者订阅,该观察者被添加到观察者数组中。这个观察者立即收到一个complete信号,因为 Subject 已经停止,并且没有以错误结束。

在这一点上,调用onNext(4)没有任何作用,因为Subject实例已经停止了(参见步骤 1)。

这个原则可能在某些情况下成为问题,例如,当我们故意想要延迟使用Observable::defer静态方法创建可观察对象时,该方法将被多次调用。一旦它发送了complete信号,所有后续的值都将被Subject实例忽略,原因我们在前面已经解释过了。我们将在本章后面提供另一个涉及此问题的示例。

这是我们使用multicast()操作符和ConnectableObservable时需要了解的一个非常重要的原则。

这个是否适用于MulticastObservable取决于我们对其第一次可调用返回的内容。我们可以使用同一个Subject实例,或者根据我们想要达到的目的创建一个新的实例。

注意

如果这一切看起来很混乱,只需记住 Subjects 有一个内部状态。当它们收到completeerror通知时,它们永远不会重新发出任何值。

multicast()操作符和 MulticastObservable

所以让我们回到multicast()操作符,看看MulticastObservable是如何与所有这些相关的。我们说过,multicast()返回ConnectableObservableMulticastObservable,这取决于我们使用的参数。当我们使用multicast()的第二个参数时,这是正确的。

考虑以下示例,其中我们还将选择器函数传递给multicast()操作符:

// multicast_02.php  
use Rx\Observable; 
use Rx\Subject\Subject; 

$subject = new Subject(); 
$source = Observable::range(1, 3) 
    ->multicast($subject, function($connectable) { 
        return $connectable->concat(Observable::just('start')); 
    }) 
    ->concat(Observable::just('concat')); 

$source->subscribe(new DebugSubject()); 
$source->subscribe(new DebugSubject()); 

如果我们使用multicast()的第二个参数,它会在传递给MulticastObservable之前将$subject变量包装在一个可调用函数中。实际上,multicast()在内部实现如下:

function multicast($subject, $selector=null, $scheduler=null){ 
  return $selector ? 
    new MulticastObservable($this, function () use ($subject) { 
      return $subject; 
    }, $selector) : 
    new ConnectableObservable($this, $subject, $scheduler); 
} 

这始终保证了我们使用的是同一个Subject。唯一决定我们将收到哪个可观察对象的是我们是否使用选择器函数。前面的示例还添加了startWith()concat()操作符,以查看这个选择器函数可以产生什么效果。

这个示例的输出受到了我们之前展示的问题的影响:

$ php multicast_02.php
13:41:23 [] onNext: start (string)
13:41:23 [] onNext: 1 (integer)
13:41:23 [] onNext: 2 (integer)
13:41:23 [] onNext: 3 (integer)
13:41:23 [] onNext: concat (string)
13:41:23 [] onCompleted
13:41:23 [] onNext: start (string)
13:41:23 [] onNext: concat (string)
13:41:23 [] onCompleted

第二个订阅者没有收到任何值,尽管我们两次订阅了源可观察对象。

比较可连接可观察对象和 MulticastObservable

为了更清楚地了解这两个用例以及ConnectableObservableMulticastObservable之间的区别,让我们看看这两个图:

比较可连接可观察对象和 MulticastObservable

表示具有两个观察者的可连接可观察对象图

在这个图中,我们有一个包含一个Subject的单个ConnectableObservable,两个观察者都订阅了同一个Subject

另一方面,使用MulticastObservable我们将得到以下结构:

比较 ConnectableObservable 和 MulticastObservable

表示具有两个观察者的MulticastObservable的图

灰色框内的两个ConnectableObservables意味着我们无法控制它们(这些是由前面提到的内部multicast()调用自动创建的)。

从示例中我们可以看出,通过multicast()调用创建的MulticastObservable,我们无法达到图中所示的结果,因为multicast()总是强制我们使用单个Subject实例。当然,我们也可以像本章前面所看到的那样,自己创建MulticastObservable的实例,但还有一个操作符可以用来实现这个目的。

multicastWithSelector()操作符

为了简化创建MulticastObservable实例的过程,我们提供了multicastWithSelector()操作符,它接受两个具有与直接调用MulticastObservable相同目的的可调用参数。

考虑以下示例:

// multicastWithSelector_01.php  
$source = Observable::range(1, 3) 
    ->multicastWithSelector(function() { 
        return new Subject(); 
    }, function(ConnectableObservable $connectable) { 
        return $connectable->concat(Observable::just('concat')); 
    }); 

$source->subscribe(new DebugSubject()); 
$source->subscribe(new DebugSubject()); 

本例说明了我们之前看到的图。我们有两个观察者,每个观察者都有自己的Subject实例。我们还使用了选择器函数,在链的末尾添加了一个concat字符串。

输出结果很容易预测:

$ php multicastWithSelector_01.php 
15:05:56 [] onNext: 1 (integer)
15:05:56 [] onNext: 2 (integer)
15:05:56 [] onNext: 3 (integer)
15:05:56 [] onNext: concat (string)
15:05:56 [] onCompleted
15:05:56 [] onNext: 1 (integer)
15:05:56 [] onNext: 2 (integer)
15:05:56 [] onNext: 3 (integer)
15:05:56 [] onNext: concat (string)
15:05:56 [] onCompleted

这是对 Rx 中的多播和 RxPHP 中的multicast()操作符的介绍。由于还有一些基于multicast()的其他操作符,因此在我们了解multicast()的内部行为后,我们将讨论它们。

publish*()share*()操作符组

内部存在多个使用multicast()操作符的其他操作符,我们可以将它们分为两个基本组:

  • publish*():以“publish”开头的操作符包装multicast()操作符,并使用其中一个Subject类调用它。所有publish*变体都接受一个可选参数,即我们之前提到过的选择器函数。因此,所有这些都可以返回ConnectableObservableMulticastObservable,就像multicast()一样。

  • share*():以“share”开头的操作符在内部使用相同的publish*等效操作符,并将其与refCount()操作符链式连接。所有share*操作符都不允许任何选择器函数。

要理解这两组之间的区别,我们首先需要了解refCount()操作符是什么。

refCount()操作符

我们已经了解了ConnectableObservable的这种非常基本的用法。让我们考虑以下示例,首先看看我们如何手动调用connect()方法,然后切换到refCount()操作符:

// refCount_01.php  
$source = Observable::create(function($observer) { 
    $observer->onNext(1); 
    $observer->onNext(2); 
    $observer->onNext(3); 
}); 
$conn = new Observable\ConnectableObservable($source); 
$conn->subscribe(new DebugSubject('1')); 
$conn->subscribe(new DebugSubject('2')); 
$conn->connect(); 

这很简单。我们有两个订阅了 ConnectableObservable 的观察者,等待调用 connect(),它订阅了源 Observable(在这种情况下是具有自定义订阅函数的 AnonymousObservable)并将所有值同时发送给两个观察者。

注意,我们故意没有使用 RangeObservable,因为我们不想发出 complete 信号:

$ php refCount_01.php 
17:20:41 [1] onNext: 1 (integer)
17:20:41 [2] onNext: 1 (integer)
17:20:41 [1] onNext: 2 (integer)
17:20:41 [2] onNext: 2 (integer)
17:20:41 [1] onNext: 3 (integer)
17:20:41 [2] onNext: 3 (integer)

这很简单,但我们不得不自己调用 connect(),这有时是可以接受的。然而,在其他时候,我们可以将这种逻辑留给 refCount() 操作符。

嗯,实际上它不是一个操作符(它没有被提升到使用 lift() 方法的 Observable 链)。它只是 ConnectableObservable 上的一个方法,返回一个 RefCountObservable 实例。

这个 Observable 内部订阅和取消订阅源 Observable。当第一个观察者订阅时,它也会在 ConnectableObservable 上调用 connect()。然后当另一个观察者订阅时,它什么都不做,因为我们已经订阅了。在取消订阅时,程序正好相反。如果最后一个观察者取消订阅,那么 RefCountObservable 也会取消 ConnectableObservable 的订阅。

这有一些有趣的结果。我们可以使用 refCount() 在至少有一个观察者时自动订阅,正如我们在这个示例中可以看到的那样:

// refCount_02.php  
$source = Rx\Observable::create(function($observer) { 
    $observer->onNext(1); 
    $observer->onNext(2); 
    $observer->onNext(3); 
}); 
$conn = (new Rx\Observable\ConnectableObservable($source)) 
    ->refCount(); 

$conn->subscribe(new DebugSubject('1')); 
$conn->subscribe(new DebugSubject('2')); 

我们再次有两个观察者,但这次我们不是自己调用 connect()。相反,我们使用 refCount() 来为我们调用 connect() 方法。由于我们共享对源的相同订阅,只有第一个观察者会收到值。第二个观察者不会导致对源的另一个订阅(正如我们从前面的解释中可以看到):

$ php refCount_02.php 
17:52:05 [1] onNext: 1 (integer)
17:52:05 [1] onNext: 2 (integer)
17:52:05 [1] onNext: 3 (integer)

然而,如果我们收到第一个观察者的值后取消订阅(这会导致 RefCountObservable 内部的 ConnectableObservable 取消订阅)然后再次使用第二个观察者订阅,它将使源发出所有值,因为我们再次订阅了它:

// refCount_03.php 
// ... 
$sub = $conn->subscribe(new DebugSubject('1')); 
$sub->dispose(); 
$conn->subscribe(new DebugSubject('2')); 

当我们调用 dispose() 时,我们使 RefCountObservable 取消订阅其源,因为没有更多的观察者。

这个示例打印了所有值两次:

$ php refCount_03.php 
17:53:29 [1] onNext: 1 (integer)
17:53:29 [1] onNext: 2 (integer)
17:53:29 [1] onNext: 3 (integer)
17:53:29 [2] onNext: 1 (integer)
17:53:29 [2] onNext: 2 (integer)
17:53:29 [2] onNext: 3 (integer)

当然,我们需要确保我们不会停止 ConnectableObservable 内部的 Subject,正如我们之前讨论的那样。ConnectableObservable 类使用一个 Subject 的单例,所以如果它收到了完成信号,那么取消订阅或订阅的任何变化都不会改变这一点。

publish() 和 share() 操作符

现在我们知道了 multicast()refCount() 操作符的作用,我们最终可以理解 publish()share() 的作用。

使用 publish() 只是一个调用带有 Subject 实例参数的 multicast() 的快捷方式。如果我们重写 multicast() 操作符的第一个示例,它看起来几乎一样:

// publish_01.php  
use Rx\Observable; 
$observable = Observable::defer(function() { 
        printf("Observable::defer\n"); 
        return Observable::range(1, 3); 
    }) 
    ->publish(); 

$observable->subscribe(new DebugSubject('1')); 
$observable->subscribe(new DebugSubject('2')); 
$observable->connect(); 

这个演示的输出与 multicast_01.php 的输出完全相同,所以我们不需要在这里重新打印它。

share()操作符在内部使用publish()->refCount()链,因此我们不再需要调用connect()。然而,输出并不相同。

RangeObservable发送了完整的信号,这标志着ConnectableObservable中的内部Subject已停止,因此第二个观察者除了接收Subject类在订阅点发出的complete信号外,不会接收到任何东西(它不是由源 Observable 发出的):

// share_01.php  
use Rx\Observable; 
$observable = Observable::defer(function() { 
        printf("Observable::defer\n"); 
        return Observable::range(1, 3); 
    }) 
    ->share(); 

$observable->subscribe(new DebugSubject('1')); 
$observable->subscribe(new DebugSubject('2')); 

从输出中,我们可以看到源 Observable 实际上只创建了一次:

$ php share_01.php 
Observable::defer
18:17:12 [1] onNext: 1 (integer)
18:17:12 [1] onNext: 2 (integer)
18:17:12 [1] onNext: 3 (integer)
18:17:12 [1] onCompleted
18:17:12 [2] onCompleted

这两个基本操作符有许多变体,但都是基于相同的原则。

publishValue()shareValue()操作符

这些操作符基于BehaviorSubject而不是仅仅基于Subject。原理完全相同。publishValue()操作符使用BehaviorSubject的实例调用multicast()。然后shareValue()操作符调用publishValue()->refCount()

使用BehaviorSubject允许我们在观察者订阅时向所有观察者发出默认值。

我们可以在之前的示例上测试这个操作符:

// publishValue_01.php  
$source = Observable::defer(function() { 
        printf("Observable::defer\n"); 
        return Observable::range(1, 3); 
    }) 
    ->publishValue('default'); 

$source->subscribe(new DebugSubject()); 
$source->subscribe(new DebugSubject()); 
$source->connect(); 

输出始终以默认字符串开始,因为它作为第一个值由BehaviorSubject发出:

$ php publishValue_01.php 
18:47:17 [] onNext: default (string)
18:47:17 [] onNext: default (string)
Observable::defer
18:47:17 [] onNext: 1 (integer)
18:47:17 [] onNext: 1 (integer)
18:47:17 [] onNext: 2 (integer)
18:47:17 [] onNext: 2 (integer)
18:47:17 [] onNext: 3 (integer)
18:47:17 [] onNext: 3 (integer)
18:47:17 [] onCompleted
18:47:17 [] onCompleted

使用shareValue()与使用share()相同,因此我们不需要在这里包含它。

replay()shareReplay()publishLast()操作符

所有这些操作符与前面的两个操作符具有完全相同的原则,只是基于ReplaySubjectreplay()shareReplay())或AsyncSubjectpublishLast()操作符)。

我们不需要为这些操作符提供示例,因为我们不会看到任何新的内容。

PHP pthreads 扩展

自 2000 年以来,PHP 可以编译为线程安全,这允许任何进程在多个线程中运行多个 PHP 解释器的多个实例(每个 PHP 解释器一个线程)。每个 PHP 解释器都有自己的独立上下文,与其他解释器不共享任何数据(“无共享”哲学)。

这通常用于像 Apache(取决于其模块)这样的 Web 服务器。Apache 创建了多个子进程,每个子进程运行多个线程,每个线程运行多个 PHP 解释器。在线程中而不是在子进程中运行解释器有其优点和缺点。

仅创建线程比创建子进程要快得多,并且消耗的内存也少得多。

一个明显的缺点是隔离。尽管所有 PHP 解释器都在线程中独立运行,但如果其中任何一个发生,例如,"段错误"错误,那么整个进程及其所有线程将立即终止。这甚至包括没有引发任何错误且可能正在处理来自另一个客户端的 HTTP 请求的线程。

这个所谓的服务器 APISAPI)对我们来说并不很有帮助。我们需要能够在线程中运行我们自己的代码片段(“用户空间多线程”)。PHP 扩展 pthreads 是一个面向对象的 API,它正好做到了这一点。它将我们的代码转换成一个新的 PHP 解释器,然后开始执行它。

注意,PHP 的 pthreads 基于 POSIX 线程,这意味着当我们使用 pthread 创建一个线程时,我们实际上是在创建一个真正的线程,而不是在下面进行分叉或创建子进程。

在某些语言中,例如 Python,有看起来像是在并行执行代码的线程,但实际上仍然只有一个单线程的 Python 解释器在从一个“线程”切换到另一个。所以没有真正的并行性。

然而,PHP pthreads有其代价,并且理解至少一点其内部发生的事情是很重要的。

前置条件

在这一章中,我们将使用新的pthreads v3,这意味着我们需要使用 PHP7+。还有一个较老的pthreads v2,它是为 PHP5 设计的。由于这两个版本在内部实现上有重大差异,我们将只使用新的一个。

如我们之前所说,为了使用pthreads扩展,PHP 必须编译时启用线程安全选项。这需要在编译 PHP 时启用,不能在之后启用(如果你只下载 PHP 可执行文件,请确保你下载的是正确的,通常标记为 ZTS)。

安装pthreads的一个通用方法是使用 PECL 工具,它应该在所有平台上都适用:

$ pecl install pthreads

或者,如果你正在运行 OS X,你可以使用一个 homebrew 工具,它也可以为你启用 PHP 配置文件中的它:

$ brew install php70-pthreads

注意

当在独立脚本中运行 PHP 代码时,才能启用当前版本的 pthreads v3。这意味着 pthreads 不能是使用例如php-fpm运行的任何 PHP 应用程序的一部分。换句话说,我们只能在控制台应用程序中使用 pthreads,而不能在 Web 应用程序中使用。

PHP7 中 pthreads 的多线程简介

PHP 中 pthreads 最基础的例子可以是简单地创建多个线程并打印它们的结果。我们将使用sleep()函数来模拟多个长时间运行的任务。记住,在 PHP 中,sleep()函数始终是阻塞的(它会阻塞解释器执行一定的时间):

// threads_01.php  
class MyThread extends Thread { 
    protected $i; 
    public function __construct($i) { 
        $this->i = $i; 
    } 
    public function run() { 
        sleep(rand(1, 5)); 
        printf("%d: done\n", $this->i); 
    } 
} 

$threads = []; 
foreach (range(0, 5) as $i) { 
    $thread = new MyThread($i); 
    $thread->start(); 
    $threads[] = $thread; 
} 

foreach ($threads as $thread) { 
    $thread->join(); 
} 
echo "All done\n"; 

一个任务由一个扩展基本Thread类并实现其run()方法的类来表示。这个run()方法包含在我们调用start()时将在单独的线程中运行的代码。注意,我们需要实现run()方法,而不是start()方法。start()方法是一个用 C 编写的内部方法,它会为我们调用run()

在创建并启动每个线程后,我们使用 join() 方法,它会阻塞当前解释器并等待特定线程完成。如果它已经完成,那么它将继续。通过遍历所有线程并调用 join(),我们实际上等待它们全部完成。

当我们运行这个示例时,我们会得到以下结果(由于我们使用了随机的睡眠间隔,所以你会得到不同的顺序):

$ php threads_01.php 
0: done
2: done
1: done
5: done
3: done
4: done
All done

在本章中,我们不会深入探讨使用 pthreads 扩展。截至 2017 年 4 月,主要原因有三个:

  • 关于 pthreads 的文档非常不足:关于 pthreads 中大多数类和方法的文档包含的信息非常少。最多只有一句话,通常没有任何示例,所以大部分都要靠开发者去猜测它是什么意思。

  • 文档、示例和其他信息来源通常是过时的:新的 pthreads v3 只能与 PHP7 一起使用。然而,官方文档php.net/manual/en/book.pthreads.php只涵盖了 pthreads v2。与此同时,pthreads 的内部结构已经改变,所以你可能会惊讶地发现一些示例根本无法工作。例如,MutexCond 类现在根本不存在。

  • 文档不存在:随着 pthreads v3 一起到来的新类完全没有文档。官方主页 github.com/krakjoe/pthreads 提到了两个版本之间的差异,但没有包含任何关于如何有效使用它们的信息。例如,在 php.net/manual/en/book.pthreads.php 找到的 PHP 文档根本就没有提到 Volatile 类。

这一切意味着使用 pthreads 在目前来说很痛苦,获取任何相关信息都很困难。

关于在保持完全隔离的同时需要共享数据的多个 PHP 上下文也有一些注意事项。由于我们需要意识到这些问题,花些时间解释这对我们意味着什么是很值得的。

从/向线程获取/设置数据

在 PHP 中,所有对象默认都是通过引用传递的。考虑以下示例,我们将一个 stdClass 实例传递给另一个对象,并对其进行修改:

// references_01.php  
$obj = new stdClass(); 
$obj->prop = 'foo'; 

$obj2 = $obj; 
printf("%d\n", $obj === $obj2); 

class TestClass { 
    public $obj; 
    public $objCopy; 

    public function copyObj() { 
        $this->objCopy = $this->obj; 
        $this->objCopy->prop2 = 'bar'; 
    } 
} 

$testObj = new TestClass(); 
$testObj->obj = $obj; 
$testObj->copyObj(); 
printf("%d\n", $obj === $testObj->objCopy); 
print_r($obj); 

我们创建了一个名为 $objstdClass 实例。然后我们将它重新赋值给 $obj2,并使用一个身份运算符(三个等号 ===)比较这两个实例。然后我们将 $obj 传递给 TestClass 的一个实例,在那里我们做了同样的事情,并且还给它添加了一个名为 prop2 的属性。

这个示例的输出结果是我们可能预期的:

$ php7 references_01.php 
1
1
stdClass Object (
 [prop] => foo
 [prop2] => bar
)

所有变量都引用同一个对象。这是我们习惯的,也是我们在 PHP 中一直使用的。

然而,这不能与pthreads一起工作。我们不允许在不同 PHP 上下文中共享对象(内存地址)。这些必须始终是隔离的,这是线程安全执行的基本前提。我们可以通过一个非常简单的例子来测试这一点,紧接着上一个例子:

// threads_02.php  
class MyThread extends Thread { 
    public $obj; 
    public $objCopy; 

    public function run() { 
        $this->objCopy = $this->obj; 
        $this->objCopy->prop2 = 'bar'; 
        printf("%d\n", $this->obj === $this->obj); 
    } 
} 

$obj = new stdClass(); 
$obj->prop = 'foo'; 

$thread = new MyThread($obj); 
$thread->obj = $obj; 
$thread->start(); 
$thread->join(); 

printf("%d\n", $obj === $thread->objCopy); 
print_r($obj); 

在这个例子中,我们使用身份操作符来比较$this->obj与另一个应该引用相同对象的变量。

现在我们来看看运行这个例子会发生什么:

$ php threads_02.php 
0
0
stdClass Object (
 [prop] => foo
)

所有比较都返回false。即使是显而易见的一个,$this->obj === $this->obj也返回false

pthreads中,它必须这样工作,因为 PHP 解释器是隔离的,因此所有来自父上下文和其他上下文的读取和写入操作都需要通过复制数据来执行。然而,有一个例外。来自pthreads扩展的类(包括所有其子类)不会被复制,而是被引用,我们将在后面的例子中看到。

因此,在这个例子中,我们实际上复制了对象多次。每次调用$this->obj都会在当前上下文中创建一个副本,以及到最后的$thread->objCopy语句。

这个原则的后果是我们必须手动收集线程的结果;我们不能仅仅传递一个对象给构造函数,该对象将由线程本身填充结果。

第一个例子的修改版本看起来像这样:

// threads_08.php  
class MyThread extends Thread { 
    protected $i; 
    public $result; 

    public function __construct($i) { 
        $this->i = $i; 
    } 

    public function run() { 
        sleep(rand(1, 5)); 
        printf("%d: done\n", $this->i); 
        $this->result = pow($this->i, 2); 
    } 
} 

$threads = []; 
foreach (range(5, 7) as $i) { 
    $thread = new MyThread($i); 
    $thread->start(); 
    $threads[] = $thread; 
} 

foreach ($threads as $i => $thread) { 
    $thread->join(); 
    printf("%d: %d\n", $i, $thread->result); 
} 
echo "All done\n"; 

这基本上是之前的相同演示;我们只是将每个线程的结果存储在一个公共属性中,我们可以在调用join()之后读取这些结果。

这个例子的输出如下:

$ php threads_08.php 
7: done
5: done
6: done
0: 25
1: 36
2: 49
All done

虽然创建线程很简单,但如果我们有多个线程,就很难跟踪哪些线程正在运行,哪些已经完成。在实际应用中,通常不需要创建许多线程来执行一次。创建这么多线程是不高效的,更重要的是,是不必要的。

使用 Thread、Worker 和 Pool 类

Thread类代表一个单独的解释器上下文和一个单独的任务。当我们想要多次运行相同的任务时,我们需要创建该类多个实例,然后合并所有结果(等待它们完成)。

还有Worker类。与Thread类类似,它代表一个单独的解释器上下文,但它不是执行单个工作,而是可以堆叠工作并依次执行它们。

我们可以取之前的MyThread类,这次我们将所有任务在一个单独的Worker上执行:

// threads_03.php  

class MyThread extends Thread { 
    protected $i; 
    public $result; 

    public function __construct($i) { 
        $this->i = $i; 
    } 

    public function run() { 
        sleep(rand(1, 5)); 
        printf("%d: done\n", $this->i); 
        $this->result = pow($this->i, 2); 
    } 
} 

$worker = new Worker(); 
$threads = []; 
foreach (range(1, 4) as $i) { 
    $thread = new MyThread($i); 
    $worker->stack($thread); 
    $threads[] = $thread; 
} 

$worker->start(); 
echo "Starting worker\n"; 

// Add another task after the worker has started 
$thread = new MyThread(42); 
$worker->stack($thread); 
$threads[] = $thread; 
$worker->shutdown(); 

foreach ($threads as $i => $thread) { 
    printf("%d: %d\n", $i, $thread->result); 
} 
echo "All done\n"; 

由于我们有一个单独的解释器上下文,所有任务都将依次执行。通过调用shutdown(),我们让Worker类等待直到所有堆叠的任务完成。我们也可以在它开始执行后添加任务到工作者:

$ php7 threads_03.php 
Starting worker
5: done
6: done
7: done
42: done
0: 25
1: 36
2: 49
3: 1764
All done

注意任务是一个接一个运行的,而不是并行运行。

我们使用了pthreads提供的默认Worker类,但也可以创建自己的类,该类从Worker扩展。例如,考虑以下类:

class MyWorker extends Worker { 
    public function run() { 
        // ... Initialize this Worker and its context. 
    } 
} 

这个类就像Thread类一样扩展了run()方法。然而,Worker类的run()方法只在初始化 PHP 解释器上下文时被调用一次,并允许我们设置Worker类。

我们当然可以创建多个Worker实例并将任务堆叠在它们上面,但处理哪些工作进程可用以及哪些工作进程正忙将是繁琐的。

因此,pthreadsPool类。它可以包含一定数量的工作进程并将任务分配给它们。我们不需要担心选择正确的工作进程,可以将一切交给Pool类。

现在让我们考虑以下示例,我们将使用三个Worker类的Pool来执行总共六个任务:

// threads_04.php 
class MyWorker extends Worker { 
    public function run() { 
        printf("%s: starting worker\n", date('H:i:s')); 
    } 
} 
class Task extends Threaded { 
    public function run() { 
        sleep(3); 
        printf("%s: done\n", date('H:i:s')); 
    } 
} 
printf("%s: start\n", date('H:i:s')); 
$pool = new Pool(3, MyWorker::class); 

foreach (range(0, 5) as $i) { 
    $pool->submit(new Task()); 
} 

$pool->shutdown(); 
echo "All done\n"; 

每个Task实例都会进行三秒钟的休眠。由于我们使用了三个Worker类,我们可以同时运行三个任务,因此运行这个演示应该正好需要六秒钟。就像我们对Worker类所做的那样,我们调用shutdown(),它会等待所有任务处理完毕然后关闭所有工作进程。这就像对每个任务调用join()一样。

Pool类接受三个参数:同时运行的工作进程数量,它将实例化的Worker类名称(我们显然也可以使用默认的Worker::class),以及传递给Worker类构造函数的参数数组。

这个示例的输出如下:

$ php threads_04.php
22:50:51: start
22:50:51: starting worker
22:50:51: starting worker
22:50:51: starting worker
22:50:54: done
22:50:54: done
22:50:54: done
22:50:57: done
22:50:57: done
22:50:57: done
All done 

从开发者的角度来看,主要的不同之处在于我们正在安排六个任务在三个线程上执行。在我们的第一个 pthreads 示例中,我们也执行了六个任务,但是在六个线程上。

一般的规则是使用尽可能少的线程。创建线程需要一些资源分配(主要是创建一个新的 PHP 解释器),而对于实际进行大量计算的作业,代码会达到一个点,此时创建额外的线程将不再产生任何性能上的好处。对于大多数时间都在等待的任务,如系统调用或通过 HTTP 下载数据,并行运行是非常有效的。我们可以为这些任务创建许多线程,它们将并行运行,实际上不需要任何 CPU 时间。

另一方面,如果我们有需要 CPU 时间的任务,那么在某个时候添加更多的线程将不会有任何效果,因为没有更多的 CPU/核心来运行解释器,所以它需要从一个执行上下文切换到另一个。所有线程将并行运行,但完成所有任务将需要很长时间。这是否有价值取决于我们想要实现什么,但通常使用更少的线程并分批执行任务会更好。

备注

对于计算密集型任务,一个良好的线程数量通常是计算为 (CPU 数量) * (每个 CPU 的核心数)

因此,这就是我们可能想要使用 Pool 类的原因。此外,在先前的例子中我们没有看到的一个重要方面是如何从完成的任务中获取结果。

从线程池中检索结果

从线程中获取处理后的数据最简单的方法是保留它们的引用,然后迭代它们以获取结果。最明显的例子可能如下所示:

// threads_12.php 
class MyThread extends Thread { 
    protected $i; 
    public $result; 
    public function __construct($i) { 
        $this->i = $i; 
    } 
    public function run() { 
        $this->result = pow($this->i, 2); 
    } 
} 

$pool = new Pool(3); 
$threads = []; 
foreach (range(1, 7) as $i) { 
    $thread = new MyThread($i); 
    $pool->submit($thread); 
    $threads[] = $thread; 
} 
$pool->shutdown(); 

$results = []; 
foreach ($threads as $thread) { 
    $results[] = $thread->result; 
} 
print_r($results); 

这非常简单,并且按预期工作。然而,它并不实用。我们使用了 shutdown() 方法等待所有计划中的任务完成,然后从所有线程中收集所有结果。如果我们不想等待所有线程完成,而想在他们准备好时收集结果,这会变得更加复杂。我们可能需要回到类似事件循环的东西,定期检查所有线程的结果。

当然这是可行的,但 pthreads 提供了另一种更优雅的方法来完成这个任务。

因此,Pool 类有一个名为 collect() 的方法。这个方法接受一个可调用参数,该参数在每一个线程上被调用。这个可调用参数必须决定线程是否已经完成。如果线程已经完成,我们可以在可调用内部直接获取其结果并返回 true,这意味着这个线程可以被处理。

不幸的是,有一个很大的但是。在当前的 pthreads v3 中,Pool::collect() 的行为可能已经改变。在大多数示例中,你会看到 collect() 方法被如下使用:

// threads_10.php  
$pool = new Pool(3); 

while (@$i++ < 6) { 
    $pool->submit(new class($i) extends Thread { 
        public $id; 
        private $garbage; 

        public function __construct($id) { 
            $this->id = $id; 
        } 
        public function run() { 
            sleep(1); 
            printf("Hello World from %d\n", $this->id); 
            $this->setGarbage(); 
        } 
        public function setGarbage() { 
            $this->garbage = true; 
        } 
        public function isGarbage(): bool { 
            return $this->garbage; 
        } 
    }); 
} 
while ($pool->collect(function(Collectable $work){ 
    printf("Collecting %d\n", $work->id); 
    return $work->isGarbage(); 
})) continue; 

$pool->shutdown(); 

这个例子使用匿名类(PHP7 的一个特性)来扩展 Thread 以表示单个任务。

尽管这个例子看起来很简单,并且在许多资源(包括 pthreads 作者在 stackoverflow.com 上的答案)中被使用,但它并没有收集所有结果。我们在这里包含这个例子是为了展示它应该如何工作,并且很可能在 pthread 的更新版本中是这样工作的。

PHP7 和 pthreads v3 的输出如下(你可能以不同的顺序得到这些行):

$ php threads_10.php 
Hello World from 1
Collecting 1
Hello World from 2
Collecting 2
Hello World from 3
Collecting 3
Hello World from 4
Hello World from 5
Hello World from 6

如你所见,最后三个线程根本就没有被收集。

这可能不工作的几个可能原因:

  • 在 pthreads 的早期版本中,我们必须扩展 Collectable 类而不是 Thread 类。Collectable 类原本是一个类,但现在它已经变成了一个接口。这个变化在 pthreads 的 README 页面上有记录(github.com/krakjoe/pthreads#php7)。现在,Thread 类自动实现了 Collectable。在大多数资源中,你会发现 Collectable 被用作一个类。

  • Pool::collect() 的官方文档不足。它根本未提及可调用对象需要返回 Boolean 以确定线程是否应该被销毁。此外,该文档是为较旧的 pthreads v2 而编写的,其中 collect() 方法据说返回空值。这并不(或可能从未是)真的,因为它总是用于 while 循环中(参见 stackoverflow.com/questions/28416842/how-does-poolcollect-worksgist.github.com/krakjoe/9384409)。

  • Pool::collect() 的更改在官方的说明书中提到,网址为 github.com/krakjoe/pthreads#php7 。引言:“Pool::collect 机制从 Pool 移至 Worker,以使 Worker 更健壮,Pool 继承更简单。” 这意味着什么仍然是个谜。

  • 在某些示例中,您会看到开发者扩展 Pool 类并使用 while (count($this->work)) 循环。这可能是为了在有工作计划时循环。在 pthreads v3 中,work 属性不存在于 Pool 类中。我们之前提到的官方说明页面列出的破坏性更改已经没有这个更改的记录。

因此,我们最大的问题是缺乏任何可靠的信息。

看起来很绝望,但实际上有一种合理的方式来收集所有结果。我们将利用另一个未记录的类 Volatile 并将其传递给所有线程。正如我们之前在讨论解释器上下文之间共享数据时所说,所有数据都需要复制。相比之下,来自 pthreads 扩展的类(以及所有扩展它们的类)都是直接引用的,我们将利用这一点。

让我们看看使用 Volatile 类从线程中收集结果的这个示例:

// threads_05.php 
class Task extends Thread { 
  public $result; 
  private $i; 

  public function __construct($i, Volatile $results) { 
    $this->i = $i; 
    $this->results = $results; 
  } 
  public function run() { 
    sleep(1); 
    $result = pow($this->i, 2); 
    printf("%s: done %d\n", date('H:i:s'), $result); 

    $this->results->synchronized(function($results,$result){ 
      $results[] = (array)['id' => $this->i,'result' => $result]; 
      $results->notify(); 
    }, $this->results, $result); 
  } 
} 

$pool = new Pool(2); 
$results = new Volatile(); 
foreach (range(0, 3) as $i) { 
  $pool->submit(new Task($i, $results)); 
} 

$results->synchronized(function() use ($results) { 
  while (count($results) != 4) { 
    $results->wait(); 
  } 
}); 

while ($pool->collect()) continue; 
$pool->shutdown(); 
print_r($results); 
echo "All done\n"; 

第一部分看起来非常熟悉。我们创建了一个扩展 Thread 类的类,然后创建了一个 Pool 实例,我们将在此实例中安排四个任务。每个任务在其构造函数中都接受相同的 Volatile 实例。这就是我们将为所有线程追加结果的那个对象。

使用 Volatile 类,我们还使用了三个仅在执行多线程代码且需要在线程之间进行某种同步时才有用的新方法:

  • synchronized(): 此方法在保持该对象访问锁的同时运行可调用对象。在我们的示例中,我们需要使用此方法以确保一次只有一个线程能够追加结果。请注意,pthreads 在底层使用 POSIX 线程,因此运算符 [] 并非原子操作。如果我们没有使用锁,那么多个线程可能会尝试修改结果数组,这会导致完全不可预测的行为。

  • wait():此方法使当前解析上下文等待直到在相同对象上调用notify()(这是一个阻塞操作)。注意,调用wait()将在等待时释放访问锁,然后在通过notify()唤醒后会重新获取。因此,此方法需要在synchronized()内部调用。

  • notify():在调用wait()方法后,此方法唤醒等待的解析上下文。

如果不当使用wait()notify()可能会非常危险。如果包含notify()的线程在第一个线程到达wait()之前调用了此方法,那么第一个上下文将永远卡在wait(),因为没有其他notify()调用可以唤醒它。

因此我们在循环中调用wait(),因为我们知道只有一个线程可以获取锁,因此每个线程将依次追加到$results

所有线程将共享对Volatile的同一引用,因为我们说过,它是一个来自 pthreads 扩展(或其扩展Threaded类)的类,因此它不会在读写尝试时被复制。

当我们运行这个示例时,我们会得到预期的输出:

$ php threads_05.php 
17:21:42: done 0
17:21:42: done 1
17:21:43: done 9
17:21:43: done 4
Volatile Object (
 [0] => Array(
 [id] => 
 [result] => 0
 )
 [1] => Array(
 [id] => 
 [result] => 1
 )
 [2] => Array(
 [id] => 
 [result] => 9
 )
 [3] => Array(
 [id] => 
 [result] => 4
 )
)
All done

注意最后一件事。在追加我们的结果时,我们使用了以下行:

$results[] = (array)['id' => $this->i, 'result' => $result]; 

我们使用类型转换(array),这似乎是多余的。实际上,我们必须这样做,以防止丢失此数组的引用。当将数组设置到Thread类的属性时,它会被自动转换为Volatile对象,除非我们事先将其类型转换为数组。如果不进行类型转换,代表数组的Volatile对象将在上下文关闭时被回收,因此我们需要强制将其类型转换为数组以便复制。

实际上,对于Pool类没有正确收集结果的问题(尽管这个解决方案不如使用synchronized()方法优雅),还有一个解决方案。我们不是使用collect()方法来控制 while 循环运行多长时间,而是可以手动计算已完成的线程数量,类似于以下示例:

// threads_13.php
$pool = new Pool(3);
// populate $pool with 6 tasks...
$remaining = 6;
while ($remaining !== 0) {
  $pool->collect(function(Collectable $work) use (&$remaining) {
    $done = $work->isGarbage();
    if ($done) {
      printf("Collecting %d\n", $work->id);
      $remaining--;
    }
    return $done;
  });
}

现在运行 while 循环直到必要的责任在我们身上,而不是在collect()方法(可能存在 bug)身上。

当我们使用之前显示的相同匿名类实例运行此示例时,我们会正确收集所有结果。

 $ php threads_13.php 
Hello World from 1
Collecting 1
Hello World from 2
Collecting 2
Hello World from 3
Collecting 3
Hello World from 4
Hello World from 5
Hello World from 6
Collecting 6
Collecting 4
Collecting 5 

RxPHP 和 pthreads

一个好问题是所有这些关于 pthreads 与 RxPHP 以及 Rx 一般的关系是什么。

在 PHP 中,我们通常不习惯于处理异步任务,如果我们这样做,实现细节通常对我们隐藏。例如,这是事件循环和 RxPHP 的情况,我们不需要关心 RxPHP 类底层的操作。

在下一章中,我们希望达到相同的状态,其中我们将拥有一个通用目的的 Observable 或一个使用 pthreads 并行运行任务的操作符。由于在 RxPHP 中处理异步代码很容易,因此 pthreads 是一个完美的候选者,它可以添加非常有趣的功能,这些功能可以轻松地在任何地方重用。

摘要

在本章中,我们探讨了两个较大的主题。我们将在下一章中使用这两个主题,其中我们将使用 pthreads 编写多线程应用程序,以及使用 Gearman 编写分布式应用程序。

我们在本章中讨论的两个主题是 RxPHP 中的多播及其所有相关操作符,以及使用 PHP7 的 pthreads v3 扩展来编写多线程 PHP7 应用程序。

在 Rx 中,多播非常有用,可以共享单个连接到源 Observables,而无需重新订阅。这伴随着refCount()操作符,以便更容易地与ConnectableObservables一起工作。

使用 pthreads 扩展在 PHP 中进行多线程编程是可能的。然而,这并不像看起来那么简单,存在多个注意事项,最重要的是文档不足和整体上不直观的方法。在下一章中,我们将仅使用 pthreads 的最基本功能,以避免混淆和与 pthreads 未来更新的最终不一致。下一章的目标是编写一个可扩展的代码质量工具,基于nikic/php-parser项目(github.com/nikic/PHP-Parser),这将允许使用 RxPHP 操作符链添加自定义规则。我们将基于本章所涵盖的内容来构建应用程序。

第九章:使用 pthreads 和 Gearman 进行多线程和分布式计算

在前一章中,我们花费了很多时间与 pthreads 一起工作。然而,我们还没有看到它们在任何实际应用中使用。这正是本章我们要做的,我们将使用 RxPHP 包装 pthreads 以隐藏其内部实现细节,并使线程池在任何 RxPHP 应用程序中易于重用。

除了 pthreads 之外,我们还将查看如何在本地或多个机器上跨多个工作进程分配工作。我们将使用 Gearman 框架及其 PHP 绑定来创建与 pthreads 相同的应用程序,只是我们不会在多个线程中运行它,而是使用多个工作进程(独立进程)。

在本章中,我们将编写一个可扩展的代码质量工具,用于测试 PHP 脚本中的各种样式检查。例如,这可以是不在条件中使用赋值,或者只是变量名遵循某些编码标准。如今,PHP 项目往往变得非常大。如果我们想在一个线程中分析每个文件,这将花费很长时间,因此我们希望在可能的情况下并行运行分析器部分。

尤其是本章将涵盖以下主题:

  • PHP 解析器库的快速介绍以及我们如何使用 RxPHP 操作符包装其解析器

  • 使用我们的自定义操作符包装 pthreads Pool 类,该操作符将接收 Thread 类并在并行中自动运行它们

  • 编写一个 Thread 类,它将在单独的线程中运行 PHP 解析器

  • 介绍 Gearman 框架,并编写一个基本的 PHP 客户端和工作进程。我们还将看到如何仅使用 Gearman 的 CLI 选项来运行客户端和工作进程

  • 在多个 Gearman 工作进程中分配 PHP 解析器任务

  • 比较单进程多线程应用程序与分布式 Gearman 应用程序

我们将快速浏览 PHP 解析器库,因为我们的主要兴趣主要在于 pthreads 和 Gearman 框架。

然而,我们将花费一些时间编写 PHPParserOperator 类,它将结合我们在前几章中学到的许多东西。

PHP 解析器库简介

PHP 解析器是一个库,它接受用 PHP 编写的源代码,通过词法分析器传递它,并创建其相应的语法树。这对于静态代码分析非常有用,我们不仅想要检查自己的代码是否存在语法错误,还想要满足某些质量标准。

在本章中,我们将编写一个应用程序,它接受一个目录,递归地迭代其所有文件和子目录,并将每个 PHP 文件通过 PHP 解析器运行。我们只检查一个特定的模式;这对于这个演示来说已经足够了。

我们希望能够找到任何我们在条件中使用赋值的语句。这可以是以下任何一种示例(这次我们还包括行号以增强清晰度):

// _test_source_code.php 
1\. <?php 
2\. $a = 5; 
3\. if ($a = 1) { 
4\.     var_dump($a); 
5\. } elseif ($b = 2) {} 
6\. while ($c = 3) {} 
7\. for (; $d = 4;) {} 

所有这些都是有效的 PHP 语法,但让我们假设我们想要使我们的代码易于理解。当您的应用程序没有按预期运行,并且您不知道如何找到前面的任何示例时,您可能一开始无法立即判断这是故意的还是只是遗漏了一个等号。也许您想写一个条件语句,如if ($a == 1),但您忘记了一个=

这可以通过静态代码分析器轻松地发现并报告。

因此,让我们首先尝试 PHP 解析器库本身,然后使用 RxPHP 操作符对其进行包装。

使用 PHP 解析器库

在我们开始之前,我们需要安装 PHP 解析器库。像往常一样,我们将使用composer来完成这项工作:

$ composer require nikic/php-parser

最好的用例就是取我们想要分析的源代码,并用解析器处理它:

// php_parser_01.php  
use PhpParser\ParserFactory; 

$syntax = ParserFactory::PREFER_PHP7; 
$parser = (new ParserFactory())->create($syntax); 

$code = file_get_contents('_test_source_code.php'); 
$stmts = $parser->parse($code); 
print_r($stmts); 

此脚本的输出将是一个非常长的嵌套树结构,代表我们传递给解析器的代码:

$ php php_parser_01.php 
...
[2] => PhpParser\Node\Stmt\If_ Object
 (
 [cond] => PhpParser\Node\Expr\Assign Object (
 [var] => PhpParser\Node\Expr\Variable Object (
 [name] => a
 [attributes:protected] => Array (
 [startLine] => 4
 [endLine] => 4
 )
 )
 [expr] => PhpParser\Node\Scalar\LNumber Object (
 [value] => 1
 ...

我们可以看到if语句有一个名为cond的属性,它包含解析后的条件,这是一个Expr\Assign的实例。实际上,我们将要测试的所有语句都有cond属性,因此测试它们是否包含条件中的赋值将相对简单。唯一的例外是for循环,其中条件可能由逗号,分隔的多个表达式组成。

由于语法树是一个嵌套结构,我们需要某种方式来递归地迭代它。幸运的是,库通过NodeTraverser类和注册自定义访问者来支持这一点。访问者是具有多个回调的类,当树遍历器开始/结束处理整个树或进入/离开单个节点时会被调用。

我们将创建一个非常简单的节点访问者,用于检查节点类型,并最终检查cond属性。这是一种方法,我们可以找到条件内的所有赋值,并从源 PHP 脚本中打印出它们各自的行号。

考虑以下代码。这将是我们将要编写的自定义操作符的一部分:

// php_parser_02.php  
use PhpParser\NodeTraverser; 
use PhpParser\ParserFactory; 
use PhpParser\Node; 
use PhpParser\Node\Stmt; 
use PhpParser\Node\Expr; 
use PhpParser\NodeVisitorAbstract; 

class MyNodeVisitor extends NodeVisitorAbstract { 
  public function enterNode(Node $node) { 
    if (($node instanceof Stmt\If_ || 
          $node instanceof Stmt\ElseIf_ || 
          $node instanceof Stmt\While_ 
        ) && $this->isAssign($node->cond)) { 

      echo $node->getLine() . "\n"; 
    } elseif ($node instanceof Stmt\For_) { 
      $conds = array_filter($node->cond, [$this, 'isAssign']); 
      foreach ($conds as $cond) { 
        echo $node->getLine() . "\n"; 
      } 
    } 
  } 

  private function isAssign($cond) { 
    return $cond instanceof Expr\Assign; 
  } 
} 

$syntax = ParserFactory::PREFER_PHP7; 
$parser = (new ParserFactory())->create($syntax); 
$code = file_get_contents('_test_source_code.php'); 

$traverser = new NodeTraverser(); 
$traverser->addVisitor(new MyNodeVisitor()); 
$stmts = $parser->parse($code); 
$traverser->traverse($stmts); 

如您所见,我们使用多个instanceof语句及其相应的cond属性来检查每个节点类型。对于for语句,我们需要检查cond语句的数组,但其余部分类似。

每当我们发现测试的样式检查时,我们只需打印行号,因此前面的示例将打印以下内容:

$ php php_parser_02.php 
3
5
6
7

我们可以看到行号确实与之前提供的源文件匹配。这很好,但当我们想要与 RxPHP 或更有趣的是与 pthreads 一起使用时,并不太有帮助。

实现 PHPParserOperator

如果我们想要处理多个文件,我们只需多次运行解析器。但如果我们想要更好地控制要处理的文件,或者我们想要将预配置的解析器与我们的自定义节点访问者轻松地嵌入到任何 RxPHP 应用程序中,该怎么办呢?

例如,假设我们想以下这种方式使用 PHP 解析器库:

// php_parser_observer_01.php 
Observable::fromArray(['_test_source_code.php']) 
  ->lift(function() { 
    $classes = [AssignmentInConditionNodeVisitor::class]; 
    return new PHPParserOperator($classes); 
  }) 
  ->subscribe(new DebugSubject());   

我们有一个典型的 RxPHP 操作符链,其中我们提升使用了PHPParserOperator。这个类在其构造函数中接受一个数组,该数组将作为节点访问者添加到其内部的NodeTraverser

作为输入,我们使用由源可观察对象发出的原始文件名数组。观察者随后将接收到每个访问者类报告的代码风格违规的数组。

在编写操作符本身之前,我们首先应该看看如何修改前一个示例中的访问者类。由于我们希望能够添加任意数量的自定义节点访问者,它们可以检查他们想要的任何内容,我们需要能够收集它们的所有结果,并通过PHPParserOperator重新发出一个单一值。

编写 AssignmentInConditionNodeVisitor

我们可以从定义一个接口开始,所有我们的节点访问者都必须实现这个接口:

// ObservableNodeVisitorInterface.php 
interface ObservableNodeVisitorInterface { 
    public function asObservable(); 
} 

对于节点访问者有一个要求,就是返回一个可观察对象,它将发出所有代码风格违规:

// AssignmentInConditionNodeVisitor.php 
use PhpParser\NodeVisitorAbstract as Visitor; 
use PhpParser\Node; 
// We're omitting the rest of use statements ... 

class AssignmentInConditionNodeVisitor 
    extends Visitor implements ObservableNodeVisitorInterface { 
  private $subject; 
  private $prettyPrinter; 

  public function __construct() { 
    $this->subject = new Subject(); 
    $this->prettyPrinter = new PrettyPrinter\Standard(); 
  } 
  public function enterNode(Node $node) { 
    // Remains the same as above just instead of echoing the 
    // line numbers we call $this->emitNext(...) method. 
  } 
  public function afterTraverse(array $nodes) { 
    $this->subject->onCompleted(); 
  } 
  public function asObservable() { 
    return $this->subject->asObservable(); 
  } 
  private function isAssign($cond) { 
    return $cond instanceof Expr\Assign; 
  } 
  private function emitNext(Node $node, Expr\Assign $cond) { 
    $this->subject->onNext([ 
      'line' => $node->getLine(), 
      'expr' => $this->prettyPrinter->prettyPrintExpr($cond), 
    ]); 
  } 
} 

这个节点访问者内部使用一个Subject,在emitNext()方法中,它将每个代码风格违规作为一个单一的项目发出。这个项目本身是一个关联数组,它包含行号和导致违规的格式良好的表达式(以便用户明白为什么被报告)。PrettyPrinter类是 PHP 解析器库的一部分。

这个Subject类还需要在我们完成这个语法树时发出一个complete信号。这发生在afterTraverse()方法中。调用complete信号对于让其他操作符正确地使用这个Subject非常重要。

由于我们需要公开这个Subject,我们需要确保没有人可以操纵它,因此我们使用asObservable()操作符将其包装起来。

编写 PHPParserOperator

这个操作符将保留对 PHP 解析器的单一引用,我们将为每个到达这个操作符的文件调用它。这也意味着我们需要为每个文件创建一个新的NodeTraverser类实例,并将每个自定义节点访问者的新实例添加到其中。

从操作符的角度来看,所有节点访问者只是发出风格违规的可观察对象。操作符需要从所有这些对象中收集所有值,然后将这个集合重新发出作为一个单一的项目。

我们将把这个示例分成两个更小的部分。首先,我们将看看如何创建填充了节点访问者的NodeTraverser实例:

// PHPParserOperator.php 
use Rx\ObservableInterface; 
use Rx\ObserverInterface; 
use Rx\SchedulerInterface; 
use Rx\Operator\OperatorInterface; 
use Rx\Observer\CallbackObserver; 
use PhpParser\NodeTraverser; 
use PhpParser\ParserFactory; 

class PHPParserOperator implements OperatorInterface { 
  private $parser; 
  private $traverserClasses; 

  public function __construct($traverserClasses = []) { 
    $syntax = ParserFactory::PREFER_PHP7; 
    $this->parser = (new ParserFactory())->create($syntax); 
    $this->traverserClasses = $traverserClasses; 
  } 

  private function createTraverser() { 
    $traverser = new NodeTraverser(); 
    $visitors = array_map(function($class) use ($traverser) { 
      /** @var ObservableNodeVisitorInterface $visitor */ 
      $visitor = new $class(); 
      $traverser->addVisitor($visitor); 

      return $visitor->asObservable() 
        ->toArray() 
        ->map(function($violations) use ($class) { 
          return [ 
            'violations' => $violations, 
            'class' => $class 
          ]; 
        }); 
      }, $this->traverserClasses); 
    return [$traverser, $visitors]; 
  } 
  // ... 
} 

我们在$traverserClasses属性中保留一个节点访问者类名的数组。当我们想要创建一个新的NodeTraverser时,我们使用array_map()函数迭代这个数组,并实例化每个类。然后我们不仅将其添加到遍历器中,我们还将其从asObservable()方法返回的可观察对象与toArray()map()操作符链式连接。

toArray() 操作符收集源 Observable 发射的所有项目,并在源完成时将它们重新发射为一个单个数组。这就是为什么我们必须确保在 AssignmentInConditionNodeVisitor 类中正确调用 complete 的原因。我们还使用了 map() 来发射带有生成它们的类名的最终违规集合。这不是必需的,但出于实际原因,我们希望能够知道是哪个节点访问者生成了这些结果(或者说,换句话说,这个集合中有什么样式违规)。

createTraverser() 方法返回两个值:NodeTraverser 实例和从每个节点访问者返回的 Observables 数组。

PHPParserOperator 的其余部分是实际订阅发生的地方:

class PHPParserOperator implements OperatorInterface { 
  // ... 
  public function __invoke($observable, $observer, $sched=null) { 
    $onNext = function($filepath) use ($observer) { 
      $code = @file_get_contents($filepath); 
      if (!$code) { /* ... emit error message */ } 

      list($traverser, $visitors) = $this->createTraverser(); 
      (new ForkJoinObservable($visitors)) 
        ->map(function($results) use ($filepath) { 
          // $results = all results from all node visitors. 
          $filtered = array_filter($results, function($result) { 
            return $result['violations']; 
          }); 
          return [ 
            'file' => $filepath, 
            'results' => $filtered, 
          ]; 
        }) 
        ->subscribeCallback(function($result) use ($observer) { 
          $observer->onNext($result); 
        }); 

      $stmts = $this->parser->parse($code); 
      $traverser->traverse($stmts); 
    }; 

    $callbackObserver = new CallbackObserver( 
      $onNext, 
      [$observer, 'onError'], 
      [$observer, 'onCompleted'] 
    ); 
    return $observable->subscribe($callbackObserver, $sched); 
  } 
} 

首先,我们使用 CallbackObserver,它只是传递所有的 errorcomplete 信号。有趣的事情只发生在匿名函数 $onNext 中:

  1. 我们期望每个项目都是一个表示文件路径的字符串。我们使用 file_get_contents() 函数读取文件内容,以获取我们想要分析的源代码。

  2. 然后,我们调用 createTraverser(),它返回一个新的 NodeTraverser 实例以及一个 Observables 数组,我们将从中获取所有样式违规。这些已经像之前看到的那样用 toArray()map() 包装。

  3. 我们创建一个新的 ForkJoinObservable,并将之前调用中的 Observables 数组传递给它。我们在 第五章 中实现了这个 Observable,测试 RxPHP 代码ForkJoinObservable 类订阅到所有其源 Observables,并记住每个源 Observables 发射的最新值。当所有源 Observables 都完成时,它将所有值重新发射为一个单个数组。我们知道所有源都将发射一个值然后完成,这是由于 toArray() 操作符。

  4. 我们对没有发射任何违规的节点访问者不感兴趣,所以我们从 map() 操作符的结果中移除它们。

  5. 最后,我们只是将观察者本身订阅到这个链上。请注意,我们故意没有使用 subscribe($observer),因为这会重新发射包括错误和完成信号在内的一切。我们创建的 Observable 链会在发射其单个值后立即完成,这是由于 ForkJoinObservable,而这正是我们不想要的。看看前一章,我们讨论了共享单个 Subject 实例及其可能产生的不预期结果。同样的原因也适用于这里。

在所有这些之后,我们只是运行 traverse() 方法,该方法分析源代码,并借助我们的具有 Observables 的自定义节点访问者,将发射所有将被收集到 ForkJoinObservable 中的违规。

这是一个相当复杂的操作符,具有复杂的行为。如果我们回到我们展示了如何使用此操作符的示例,我们可以看到所有这些逻辑实际上对我们是隐藏的。

当我们运行之前使用的原始示例时,我们会得到以下结果:

$ php php_parser_observer_01.php 
Array (
 [file] => _test_source_code.php
 [results] => Array (
 [0] => Array (
 [violations] => Array (
 [0] => Array (
 [line] => 3
 [expr] => $a = 1
 )
 [1] => Array (
 [line] => 5
 [expr] => $b = 2
 )
 [2] => Array (
 [line] => 6
 [expr] => $c = 3
 )
 [3] => Array (
 [line] => 7
 [expr] => $d = 4
 )
 )
 [class] => AssignmentInConditionNodeVisitor
 )
 )
)

从此操作符出来的每个项目都是一系列嵌套数组。我们可以看到我们分析的文件名和结果数组,其中每个项目都是由一个节点访问者生成的。由于我们只有一个结果,这里也只有一个数组。每个结果都由节点访问者类名和违规列表标记。每个违规包含行号和发生的确切表达式。

这听起来很好,但是分析一个更大的项目,比如 Symfony3 框架,需要多长时间?目前,Symfony3(不包括第三方依赖项)有超过 3200 个文件。如果处理单个文件只需要 1 毫秒,那么分析整个项目将需要超过 3 秒(实际上,由于文件系统操作如此之多,处理将需要更长的时间)。

因此,这似乎是一个我们可以利用我们在 PHP 中使用 pthreads 的多线程编程知识的绝佳例子。

实现ThreadPoolOperator

我们将编写一个通用操作符,它从其源 Observable 接收由Thread类实例表示的工作。然后,它将它们提交到我们在上一章中看到的Pool类的内部实例。

事实上,这个使用 pthreads 的例子将完全基于我们在上一章中学到的所有内容,所以我们在这里不会回顾它们。

注意

这个例子在某些情况下也将使用 PHP7 语法,因为 pthreads v3 版本仅支持 PHP7。

对于这个操作符,我们内部将使用事件循环。在 RxPHP 中,这意味着我们将使用StreamSelectLoop类,并用Scheduler类包装它。让我们看看ThreadPoolOperator的源代码,然后讨论为什么它被这样实现:

// ThreadPoolOperator.php 
class ThreadPoolOperator implements OperatorInterface { 
  private $pool; 

  public function __construct($num = 4, 
      $workerClass = Worker::class, $workerArgs = []) { 

    $this->pool = new Pool($num, $workerClass, $workerArgs); 
  } 

  public function __invoke($observable, $observer, $sched=null) { 
    $callbackObserver = new CallbackObserver(function($task) { 
        /** @var AbstractRxThread $task */ 
        $this->pool->submit($task); 
      }, 
      [$observer, 'onError'], 
      [$observer, 'onCompleted'] 
    ); 

    $dis1 = $sched->schedulePeriodic(function() use ($observer) { 
      $this->pool->collect(function($task) use ($observer) { 
        /** @var AbstractRxThread $task */ 
        if ($task->isDone()) { 
          $observer->onNext($result); 
          return true; 
        } else { 
          return false; 
        } 
      }); 
    }, 0, 10); 

    $dis2 = $observable->subscribe($callbackObserver); 
    $disposable = new BinaryDisposable($dis1, $dis2); 
    return $disposable; 
  } 
} 

ThreadPoolOperator的构造函数接受与创建的Pool类构造函数相同的参数。有趣的事情发生在__invoke()方法中。

到达此操作符的每个项目都将通过submit()方法发送到线程池。这意味着ThreadPoolOperator只能与由 pthreads 扩展中的Thread类(以及当然所有扩展这个类的类)表示的项目一起工作。

内部,我们使用Scheduler类定期调用一个可调用对象,该对象将检查线程池中已完成并准备好收集的线程。这就是我们在上一章中看到的相同的collect()方法。然而,在这个实现中,我们在可调用对象的每次迭代中只进行一次检查。有一个非常重要的原因我们要这样使用它。我们知道我们可以在一个循环中使用collect()方法,只要还有计划运行的任务。

循环通常看起来像这样:

$remaining = N; 
while ($remaining !== 0) { 
    $pool->collect(function(Thread $work) use (&$remaining) { 
        $done = $work->isDone(); 
        if ($done) { 
            $remaining--; 
        } 
        return $done; 
    }); 
}  

当然,这是正确的。唯一的问题是这个调用是阻塞的。解释器在这个循环中卡住,不允许我们做任何事情。如果我们想使用这样的循环,同时通过StreamSelectLoop(见第六章,PHP Streams API 和高级观察者)从流中读取数据,只要这个循环在运行,我们就无法接收任何东西。如果我们只使用这个while循环,另一个无法工作的例子可能是IntervalObservable,它需要自己安排定时器。这些定时器只有在循环结束时才会被触发。

正因如此,我们定期安排一个 10 毫秒的定时器来运行collect()一次,然后让其他定时器或流进行处理。完成后的线程被保存在Pool类中,直到我们读取并重新发射它们的结果。

这种实现有一个非常重要的行为。由于它并行运行所有任务,并且完全独立于其余的 Observable 链,我们需要注意何时发送complete信号。

考虑以下代码:

Observable::fromArray([1,2,3]) 
    ->map(function($val) { 
        return new MyThread($val); 
    }) 
    ->lift(function() { 
        return ThreadPoolOperator(...); 
    }) 
... 

在这个例子中,ThreadPoolOperator类接收三个MyThread实例,这些实例将被提交给Pool实例,但它也接收一个完成信号。这个完成信号立即传递给它的观察者,在任何一个线程完成并发射任何值之前取消订阅。

同时,ThreadPoolOperator不能自己决定何时发送complete信号。有时当线程池为空且没有任务运行时,我们可能想要发送信号。其他时候,我们可能希望根据 PHP 流活动在任何时候启动线程。

正因如此,我们不自动发送complete信号。

实现PHPParserThread

现在,我们可以看看实际的解析任务是如何实现的。我们已经知道它需要由从pthreads扩展的默认Thread类扩展的类来表示,我们也知道我们将使用 PHP 解析器处理文件,因此我们可以重用PHPParserOperator类。

在我们这样做之前,我们应该为所有的Thread对象定义一些共同的行为:

// AbstractRxThread.php 
abstract class AbstractRxThread extends Thread { 
    private $done = false; 
    protected $result; 

    public function getResult() { 
        return $this->result; 
    } 
    public function isDone() { 
        return $this->done; 
    } 
    protected function markDone() { 
        $this->done = true; 
    } 
} 

我们想要使用ThreadPoolOperator运行的所有任务都需要扩展这个抽象类,该类定义了一些共同的方法。

注意,我们没有为$result属性提供一个设置方法。这是故意的,我们将在查看我们将在此应用程序中使用的PHPParserThread的实现时看到原因:

class PHPParserThread extends AbstractRxThread { 
  private $filenames; 

  public function __construct($filename) { 
    $this->filenames =  
        (array)(is_array($filename) ? $filename : [$filename]); 
    /** @var Volatile result */ 
    $this->result = []; 
  } 

  public function run() { 
    $last = 0; 
    Observable::fromArray($this->filenames) 
      ->lift(function() { 
        $classes = ['AssignmentInConditionNodeVisitor']; 
        return new PHPParserOperator($classes); 
      }) 
      ->subscribeCallback(function ($results) use (&$last) { 
        $this->result[$last++] = (array)[ 
          'file' => $results['file'], 
          'results' => $results['results'], 
        ]; 
      }, null, function() { 
        $this->markDone(); 
      }); 
  } 
} 

如您所见,我们正在使用类型转换,原因与上一章中描述的完全相同。同时请注意,我们正在将输入文件包裹在一个数组中。由于我们希望使这个类可重用,我们将支持传递单个文件或文件数组。我们使用一个空数组初始化 $result 属性,该数组会被 pthreads 自动转换为 Volatile 对象(再次,有关更多信息,请参阅上一章)。因此,我们需要通过自己的 $last 变量跟踪已持久化的项目数量。此外,请注意,我们的结果始终将是一个数组,即使只是处理单个文件也是如此。

在这一点上,我们需要意识到为什么不能为 $result 使用任何设置方法。在前一章中,当我们谈到 Volatile 对象时,我们提到 pthreads 在将数组赋值给任何扩展 Threaded 类的类的属性时,会自动将数组转换为 Volatile。因此,我们不能使用设置器,因为我们无法通过 (array) 强制类型转换到数组。这种自动转换发生在赋值时,因此我们必须强制 AbstractRxThread 中的所有结果都是数组,或者让它自动转换,而这绝对不是我们想要的。

为了更清楚地说明这个问题,让我们考虑以下设置方法:

public function setResult($result) { 
    $this->result = $result; 
} 

赋值发生在我们不想使用 (array) 类型转换强制使用数组的这个函数内部。我们可能想使用一个简单的字符串或整数,例如。

因此,这是我们将在本例中使用的 PHPParserThread 类。实际上还有一个问题。

使用 pthreads 创建新线程意味着我们在内部创建了一个新的 PHP 解释器上下文。这个新上下文所知道的类和函数仅限于 PHP 解释器本身内置的。这个新上下文对 ObservablePHPParserOperator 类一无所知。

正如我们在运行任何 PHP 应用程序时包含 autoload.php Composer 自动加载脚本一样,我们需要为每个新创建的线程做这件事。由于我们不希望在每次使用 PHPParserThread 时都这样做,我们可以使用一个自定义的工作者,它将在其 run() 方法中为我们完成这件事。这个 run() 方法在创建新的解释器上下文时被调用,并允许我们通过例如包含 autoload.php 脚本来初始化它。

实现 PHPParserWorker

为了简化,我们没有在命名空间中定义我们的类,通常只是使用 require_once 关键字包含它们,例如以下示例:

require_once __DIR__ . '/../Chapter 02/DebugSubject.php'; 

因此,我们需要告诉每个工作者内部创建的自动加载器在哪里可以找到这样的类,理想情况下不依赖于 require_once 语句。

我们的工作者将是一个简单的类(基于官方示例,如何在 pthreads 中使用 Composer 的自动加载器,请参阅 github.com/krakjoe/pthreads-autoloading-composer):

// PHPParserWorker.php 
class PHPParserWorker extends \Worker { 
  protected $loader; 

  public function __construct($loader) { 
    $this->loader = $loader; 
  } 

  public function run() { 
    $classLoader = require_once($this->loader); 
    $dir = __DIR__; 
    $classLoader->addClassMap([ 
      'DebugSubject' => $dir . '/../Chapter 02/DebugSubject.php', 
      'ThreadWorkerOperator' => $dir.'/ThreadWorkerOperator.php', 
      'PHPParserThread' => $dir . '/PHPParserThread.php', 
      'PHPParserWorker' => $dir . '/PHPParserWorker.php', 
      'PHPParserOperator' => $dir . '/PHPParserOperator.php', 
    ]); 
  } 

  public function start(int $options = PTHREADS_INHERIT_ALL) { 
    return parent::start(PTHREADS_INHERIT_NONE); 
  } 
} 

这个工作进程使用require_once来注册自动加载器,我们在其中添加了一些类路径。初始化的解析器上下文将由这个工作进程运行的所有Thread实例使用。

最后,我们可以将这些全部放入一个单独的可观察链中。

在多线程应用程序中运行 PHP 解析器

首先,我们将测试我们现在制作的类,处理与之前相同的样本文件,然后递归地处理来自 Symfony3 项目的目录:

// threads_php_parser_01.php 
$loop = new StreamSelectLoop(); 
$scheduler = new EventLoopScheduler($loop); 

Observable::create(function(ObserverInterface $observer) { 
    $observer->onNext('_test_source_code.php'); 
  }) 
  ->map(function($filename) { 
    return new PHPParserThread($filename); 
  }) 
  ->lift(function() { 
    $args = [__DIR__ . '/../vendor/autoload.php']; 
    return new ThreadPoolOperator(2,PHPParserWorker::class,$args); 
  }) 
  ->flatMap(function($result) { 
    return Observable::fromArray((array)$result); 
  }) 
  ->take(1) 
  ->subscribeCallback(function($result) { 
    print_r($result); 
  }, null, null, $scheduler); 

$loop->run(); 

这个例子使用了本章中创建的所有三个用于多线程的类。让我们一步一步看看这个操作符链会发生什么:

  1. 我们有一个单独的源可观察对象,它发射文件名作为其值。请注意,我们故意没有发送完整的信号。

  2. 然后我们使用 map 将所有文件名转换为PHPParserThread类的实例。

  3. ThreadPoolOperator类被喂入它必须运行的任务。

  4. 我们已经提到,即使我们只处理单个文件,ThreadPoolOperator的所有结果都作为数组返回。因此,我们使用flatMap()重新发射其值并展平结果。此外,我们还需要将结果从Volatile类型转换为数组。

  5. 我们故意没有从源发送complete信号。然而,我们知道我们只处理了一个文件,因此我们期望只发射一个项目。所以我们可以使用take(1)为我们发送complete信号,观察者将成功取消订阅,这将停止事件循环。

我们可以运行这个示例,并看到它返回了与仅使用PHPParserOperator的原版完全相同的结果:

$ php threads_php_parser_01.php 
Array (
 [file] => _test_source_code.php
 [results] => Array (
 [0] => Array (
 [violations] => Array (
 ...

注意

尽管本书中的大多数 CLI 应用程序都是基于 Symfony Console 组件,但这次我们甚至不需要它,因为整个应用程序可以写成一个单独的操作符链。

在这个例子中,尽管我们只想处理单个文件,但我们还是启动了两个工作进程。

问题是如果我们尝试并行处理多个文件,会有什么不同。因此,我们将创建一个包含成千上万个 PHP 文件以供测试的 Symfony3 测试项目:

$ composer create-project symfony/framework-standard-edition testdir

以下示例将像上一个示例一样工作。然而,这次我们将创建一个递归迭代器,遍历所有子目录并发射它找到的所有 PHP 文件。我们可以将所有这些写成一个大的操作符链:

// threads_php_parser_02.php 
const MAX_FILES = 500; 
Observable::create(function($observer) use ($loop) { 
    $start = microtime(true); 
    $src = __DIR__ . '/../symfony_template'; 
    $dirIter = new \RecursiveDirectoryIterator($src); 
    $iter = new \RecursiveIteratorIterator($dirIter);  

    while ($iter->valid()) { 
      /** @var SplFileInfo $file */ 
      $file = $iter->current(); 
      if ($file->getExtension() === 'php' && $file->isReadable()){ 
        $observer->onNext($file->getRealPath()); 
      } 
      $iter->next(); 
    }  

    return new CallbackDisposable(function() use ($loop, $start) { 
      echo "duration: ".round(microtime(true) - $start, 2)."s\n"; 
      $loop->stop(); 
    }); 
  }) // End of Observable::create() 
  ->bufferWithCount(20) 
  ->map(function($filenames) { 
    return new PHPParserThread($filenames); 
  }) 
  ->lift(function() { 
    $args = [__DIR__ . '/../vendor/autoload.php']; 
    return new ThreadPoolOperator(4,PHPParserWorker::class,$args); 
  }) 
  ->flatMap(function($result) { 
    return Observable::fromArray((array)$result); 
  }) 
  ->take(MAX_FILES) 
  ->filter(function($result) { 
    return count($result['results']) > 0; 
  }) 
  ->subscribeCallback(function($result) { 
    print_r($result); 
  }, null, null, $scheduler); 

这是我们在本书中编写的最长的操作符链。主要变化是源端发射的我们要分析的文件名。我们有两个不同的迭代器,它们都返回SplFileInfo对象。我们知道我们总共要测试多少个文件,因此我们可以使用take()操作符避免发射冗余值。

在前几章中我们讨论了背压时,提到了bufferWithCount()操作符,它会堆叠值然后在一个数组中重新发射它们。现在这非常有用,因为我们不希望在线程池中为每个文件创建一个任务,而是批量发射它们。

最后,我们也使用了filter()来忽略所有没有违规的结果。当然,我们只对至少有一个违规的文件感兴趣。

这个示例的一个重要部分是它测量了运行整个应用程序所需的时间(从初始订阅到销毁CallbackDisposable)。

如果我们运行这段代码,我们会看到一个类似以下的大列表:

$ php threads_php_parser_02.php
...
Array (
 [file] => ...vendor/symfony/src/Symfony/Bridge/Twig/AppVariable.php
 [results] => Array (
 [0] => Array (
 [violations] => Array (
 [0] => Array (
 [line] => 101
 [expr] => $request = $this->getRequest()
 )
 )
 [class] => AssignmentInConditionNodeVisitor
 )
 )
)
...

报告的行包含以下代码:

if ($request = $this->getRequest()) { 

这确实是我们想要能够报告的代码风格。

现在来谈谈一个重要的问题,运行分析器在多线程中的效果是什么?我们可以用1246线程等设置进行几次重跑。为了得到更相关的结果,我们可以增加处理的文件数量到 1,000,并且禁用xdebug扩展,否则它会显著减慢执行速度。平均下来,时间如下:

1 thread = 5.60s
2 threads = 3.52s
4 threads = 3.08s
6 threads = 4.80s

如我们所见,增加线程的数量开始变得适得其反。这些时间是在 2.5 GHz 英特尔酷睿 i5 上测量的,这是一个双核处理器,带有 SSD 硬盘。对于更多的线程,使用非 SSD 硬盘的结果可能会更好,因为每个线程将不得不花费更多的时间来加载文件内容,这会允许其他线程在此期间执行。

我们几乎达到了只运行一个线程时间的一半,这是一个现实的目标。在一个双核处理器上,以及 RxPHP 和 PHP 本身产生的开销下,这是一个预期的结果。

我们可以查看htop命令的输出,它显示了当前的 CPU 使用情况,以证明两个核心都被充分利用:

在多线程应用程序中运行 PHP 解析器

运行 threads_php_parser_02.php 示例时的当前 CPU 使用率

htop工具显示有四个核心,因为每个核心有两个硬件线程(实际上它只是一个双核处理器)。

只通过利用 pthread 在单个进程中并行运行解析器非常高效。

我们的使用案例可以概括为简单地在一个多个工作者之间分割一个工作。我们并不关心我们将使用什么协议或分布将如何发生。我们甚至不关心哪个工作者将处理特定的批次。我们只需要完成这项工作。

这是对 Gearman 的理想用例。

Gearman 简介

Gearman 是一个在多个进程和机器之间分配工作的框架。由于其功能,它可以作为一个管理者、负载均衡器,或者在不同语言之间没有单点故障的接口。

由于本书是关于 Rx/reactive/异步编程的,我们将快速介绍 Gearman。不用说,Gearman 非常容易设置和使用。

Gearman PHP 扩展是用 C 语言编写的,因此我们需要通过 PECL 或与您的平台相关的包管理器来安装它(有关更多信息,请参阅 gearman.org/download/)。

Gearman 的名字是 "Manager" 一词的字母重组,很好地概括了其用途。Gearman 本身并不执行工作。它只是从客户端接收一个任务(也简单地称为工作)并将其委托给一个可用的工人。

从以下图表中可以很容易地理解任何 Gearman 应用程序的结构:

Gearman 简介

来自官方 Gearman 文档的图表(http://gearman.org/)

每个 Gearman 应用程序都有以下三个主要组件:

  • Gearman 工作服务器:这通常作为一个守护进程运行,接受来自客户端的任务并将它们委托给工人。它用 C 语言编写(最初用 Perl 编写),并且本身不执行任何工作。它还能够将当前任务队列持久化到数据库中,以便在失败时恢复。

  • 客户端:这是任何需要执行某些工作的应用程序,可以是任何语言的。这可能是一个需要发送电子邮件的 Web 应用程序或需要运行几个文件上的静态分析的 CLI 应用程序。客户端本身不执行工作。它向工作服务器发送消息,要么等待工作完成,要么等待工作服务器确认它已被添加到队列中。

  • 工人:这是实际执行工作服务器委托的工作的部分。它也可以用任何语言编写。它包含一个它能执行的功能列表,根据这个列表,工作服务器分配它需要执行的工作。

因此,为了开始使用 Gearman,我们需要在我们的系统上安装并运行工作服务器部分;它通常被称为 gearmangearmand。您可以在 gearman.org/download/ 找到有关如何为您的平台安装和运行 Gearman 的说明。

字符串长度客户端和工人

我们可以创建一个非常简单的应用程序,其中我们将有一个工人,它接受一个字符串并返回其长度。在这种情况下,客户端将仅向工作服务器发送一个字符串,请求返回长度。

我们的客户端将非常简单。它将仅请求一个工作,strlen,然后等待从工作服务器收到响应:

// gearman_client_01.php 
$client = new GearmanClient(); 
$client->addServer('127.0.0.1'); 
$client->setTimeout(3000); 

$length = @$client->doNormal('strlen', 'Hello World!'); 
if (empty($length)) { 
    echo "timeout\n"; 
} else { 
    var_dump(intval($length)); 
} 

我们的客户端连接到单个工作服务器并设置三秒的超时。如果在规定时间内没有收到响应,则继续执行脚本的其他部分。我们使用单个服务器,但可以在不同的机器上运行多个工作服务器,如果其中任何一个崩溃,客户端将继续使用其他服务器。这使得系统具有容错能力。此外,请注意,我们的客户端是阻塞的。

我们正在使用doNormal()方法请求执行一个作业,其中我们需要指定我们想要执行的作业名称以及工作者完成它所需的所有数据。除了doNormal()之外,还有doLow()doHigh()等方法,它们以不同的优先级请求作业。

通常,当我们想要运行一个作业时,我们想知道它的结果。例如,在这种情况下,我们想要等待获取字符串长度。在某些情况下,我们只想安排一个作业,但我们不关心它何时发生以及它的结果是什么。一个典型的用例是 Web 应用程序,其中用户注册,我们想要给他们发送确认电子邮件。我们不希望通过等待电子邮件发送来减慢页面加载速度。

因此,Client类还有一个doBackground()方法(及其更高和更低优先级的变体)。此方法将请求发送到作业服务器,并且只等待确认它已被接收。客户端不关心它何时执行以及结果如何。如果我们参考之前的 Web 应用程序和发送确认电子邮件的用例,现在发送电子邮件或 10 秒后发送都无关紧要。

工作者脚本将等待来自作业服务器的作业,执行它们,并返回结果:

// gearman_worker_01.php 
$worker = new GearmanWorker(); 
$worker->addServer('127.0.0.1'); 
$worker->addFunction('strlen', function(GearmanJob $job) { 
    echo 'new job: ' . $job->workload() 
        . ' (' . $job->workloadSize() . ")\n"; 
    return strlen($job->workload()); 
}); 

while ($worker->work()) { } 

通常,工作者在循环中运行,因此也是阻塞的。我们连接到与客户端相同的作业服务器,并定义一个名为strlen的单个函数。这是客户端在请求作业时指定的相同名称。从可调用返回的值将自动发送回客户端。

现在,我们可以测试这个例子。在运行客户端或工作者之前,我们需要启动 Gearman 作业服务器:

$ gearmand --verbose DEBUG

我们可以使用verbose选项使过程更加健谈。如果没有指定任何其他选项,作业服务器将监听端口4730,这个端口也被 PHP 扩展使用,因此我们不需要进行任何配置。

然后,我们将运行工作者和客户端。哪个先运行都无关紧要。我们的客户端在超时到期前等待三秒钟,因此我们可以先运行它,挂起的作业将被作业服务器排队,直到至少有一个工作者可以执行此作业。

运行客户端和工作者后的控制台输出将如下所示:

$ php gearman_worker_01.php 
new job: Hello World! (12)

实际上,工作者是在循环中运行的,因此处理完这个作业后,它会等待另一个作业:

$ php gearman_client_01.php 
int(12)

客户端只接收响应并结束。

有时很有用的一点是,Gearman CLI 还包含一个可以运行为客户端或工作者的gearman应用程序。在我们的例子中,我们根本不需要编写工作者,只需简单地运行以下命令:

$ gearman -w -f strlen -- wc -c

这个命令创建了一个连接到其默认设置(localhost 端口4730)的工作者。通过-w,我们告诉 Gearman 我们想要启动一个工作者,通过-f strlen,我们定义了它处理的函数。然后,当它收到一个新作业时,它会启动一个新的子进程并运行wc -c,其中它将工作负载作为标准输入传递。因此,这个命令是我们 PHP 工作者的直接替代品。

当然,我们可以在同一台机器上同时运行多个工作者。每个工作者可以处理多个不同的函数。作业服务器负责决定哪个工作者将处理每个作业。

将 PHP 解析器作为 Gearman 工作者运行

我们已经看到了如何在多个线程中运行我们的PHPParserOperator。然而,我们可以通过编写一个内部运行PHPParserOperator的 Gearman 工作者来更容易地在多个进程中运行它,而不是在线程中。

工作者将非常简单。它只需接收它需要加载和分析的文件名,然后返回结果:

// gearman_worker_02.php 

$worker = new GearmanWorker(); 
$worker->addServer('127.0.0.1'); 

$worker->addFunction('phpparser', function(GearmanJob $job) { 
    Observable::just($job->workload()) 
        ->lift(function() { 
            $classes = ['AssignmentInConditionNodeVisitor']; 
            return new PHPParserOperator($classes); 
        }) 
        ->subscribeCallback(function($results) use ($job) { 
            $job->sendComplete(json_encode($results)); 
        }); 
}); 
while ($worker->work()) { } 

主要的区别是我们没有在phpparser函数的可调用对象中使用任何返回语句。由于在 RxPHP 中一切都是异步的,我们需要使用sendComplete(...)方法将结果发送给客户端并标记作业已完成。

当我们运行这个工作者时,它不会向控制台打印任何输出:

$ php gearman_worker_02.php

然后,我们可以立即测试它,而无需编写任何客户端应用程序,只需使用 CLI 命令作为客户端:

$ gearman -f phpparser -s "_test_source_code.php" | json_pp
{
 "results" : [
 {
 "violations" : [
 {
 "expr" : "$a = 1",
 "line" : 3
 },
 ...
 ],
 "class" : "AssignmentInConditionNodeVisitor"
 }
 ],
 "file" : "_test_source_code.php"
}

我们可以看到,控制台输出与我们之前测试PHPParserOperator操作符时看到的是相同的。

-f phpparser告诉gearman我们想要运行哪个函数,通过-s我们可以跳过从标准输入读取,并直接将字符串作为工作负载传递。最后,我们使用了json_pp来美化输出,使其更易于阅读。

当然,我们在这个机器和同一个目录下运行这个示例,所以我们不需要担心正确的文件路径。在实际应用中,我们可能会发送文件内容。

这是对 Gearman 的相当快速的介绍。正如我们所看到的,使用 Gearman 非常简单。实际上,在 PHP 中使用 Gearman 比使用 pthreads 扩展并行运行作业要容易得多。

从我们的角度来看,了解 Gearman 应用程序通常是阻塞的这一点很重要,因此我们在第六章中提到的关于运行多个事件循环的内容,PHP Streams API 和高级观察者,在这里也非常相关。

如果你想了解更多关于 Gearman 的信息,请访问他们的官方文档,其中包含示例gearman.org/manual/

比较 pthread 和 Gearman

使用 pthread 和 Gearman 之间的主要区别显然是我们是运行一个进程的多个线程还是仅仅运行多个进程。

pthreads 的优缺点在上一章和本章都有所涉及。完全分离的 PHP 解释器上下文使得事情稍微有些不直观(例如,使用自动加载器,并且在上下文之间再次共享数据)并且肯定需要比单线程等效方案更多的调试。然而,如果我们愿意投入必要的时间,性能优势是显著的,并且最终运行单个进程总是比管理多个进程要容易。

Gearman 的设计目的是将客户端的工作委托给工作者,并在必要时将结果发送回客户端。它不是一个通用的消息交换框架。正因为这种非常具体的关注点,使用 Gearman 非常简单。有了工作者,我们不必关心是谁、在哪里,有时甚至不必关心工作何时完成。这一切都由作业服务器来决定。

在扩展性方面,线程在这里并不是一个真正的选择。另一方面,使用 Gearman 进行扩展很简单。只需添加更多的工作者,Gearman 就会在它们之间均匀分配负载。

如果我们想要使用更灵活的框架,那么 RabbitMQ 或 ZMQ 将是不错的选择。这些框架设计得易于优化,例如通过禁用确认消息或使用发布/订阅模式,并且总体上比 Gearman 提供更多的灵活性。然而,要正确实现这些框架肯定需要更多的努力。

摘要

本章的目的是通过一个实际例子来展示多线程和分布式计算,这个例子还涉及到 RxPHP。

我们使用了 PHP 解析器库来对 PHP 脚本进行静态代码分析。我们用 RxPHP 操作符包装了解析器,并使用 pthreads 扩展在多个线程中并行运行,以及使用 Gearman 在多个工作者中运行。

我们也看到了如何通过使用ThreadPoolOperator包装来使 RxPHP 中的线程池可重用。

下一章将涵盖那些不适合之前任何一章的主题,并展示一些关于 RxPHP 的有趣和高级用法案例。

第十章. 在 RxPHP 中使用高级操作符和技术

这是最后一章,专门用于解释新的 RxPHP 操作符。有一些主题没有适合前几章,所以我们现在将涵盖它们。我们将多次回顾来自第八章,RxPHP 和 PHP7 pthreads 扩展中的多播的 Observable 多播,在实践示例中,还有四个新的操作符,zip()window()materialize()dematerialize(),它们是修改 Observable 链的更高级技术。

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

  • 与高阶 Observables 一起工作的zip()window()操作符

  • materialize()dematerialize()操作符

  • Observable 链中的错误传播以及如何正确捕获用户定义回调的异常

  • 创建热/冷 Observables 的理论以及取消订阅和完成 Observables 链之间的差异

  • 创建匿名操作符

  • 编写一个递归的DirectoryIteratorObservable,它会在目录及其所有子目录中发出所有文件

  • 基于多播编写DirectoryIteratorObservable的变体

  • 基于 RxPHP 编写 FTP 客户端

  • 在阻塞和同步应用程序中使用 RxPHP

在整本书中,我们已经看到了许多 RxPHP 操作符。它们都以某种方式与值一起工作。

然而,也有一些操作符在原则上与我们在第五章中看到的用于测试 RxPHP 代码的热 Observables 或冷 Observables 的操作符相似,测试 RxPHP 代码

zip()操作符

zip()操作符类似于我们在第五章中实现的ForkJoinObservable。主要区别在于它内部将每个源 Observables 的所有发射存储在单独的队列中,然后在所有源在特定索引处都有值时重新发射它们的值。

通过查看以下示例可以更好地理解这一点:

// zip_01.php 
$obs1 = Observable::range(1, 7); 
$obs2 = Observable::fromArray(['a', 'b']); 
$obs3 = Observable::range(42, 5); 

$obs1->zip([$obs2, $obs3]) 
  ->subscribe(new DebugSubject()); 

我们有三个源 Observables,其中每个都发出不同数量的项目。然后zip()操作符只在所有源在相同索引处都有发射时发出值数组。因此我们知道DebugSubject将只接收两个项目,因为$obs2 Observables 只发出两个项目。

换句话说,zip()操作符不能产生第三次发射,因为它没有为第二个$obs2 Observables 提供第三个值。

此示例的输出如下:

$ php zip_01.php
07:26:16 [] onNext: [1,"a",42] (array)
07:26:16 [] onNext: [2,"b",43] (array)
07:26:16 [] onCompleted

注意,它只包含每个源 Observables 的前两个值。

我们可以看看另一个更复杂的示例,该示例模拟了来自多个源 Observables 的异步发射:

// zip_02.php 
$loop = new StreamSelectLoop(); 
$scheduler = new EventLoopScheduler($loop); 

$obs1 = Observable::interval(1000, $scheduler) 
  ->map(function($i) { return chr(65 + $i); }); 

$obs2 = Observable::interval(500, $scheduler) 
  ->map(function($i) { return $i + 42; }); 

Observable::interval(200, $scheduler) 
  ->zip([$obs1, $obs2]) 
  ->subscribe(new DebugSubject()); 

$loop->run(); 

由于每个源 Observable 以不同的间隔发射,zip() 操作符将不得不根据最慢的一个发射,它每 1000 毫秒发射一次。其余 Observable 的值随后在内部队列中堆叠。

这个例子会打印以下输出:

$ php zip_02.php
08:48:47 [] onNext: [0,"A",42] (array)
08:48:48 [] onNext: [1,"B",43] (array)
08:48:49 [] onNext: [2,"C",44] (array)
08:48:50 [] onNext: [3,"D",45] (array)
...

注意实际上我们没有丢失任何值。所有尚未重新发射的值都被保存在 zip() 操作符内部。

window() 操作符

window() 操作符属于更高级的更高阶 Observable 之一。我们在 第六章 中看到了 switchLatest() 操作符,PHP Streams API 和更高阶 Observable,并且我们知道它会自动订阅其源 Observable 发射的最新 Observable

与之相反的是 window() 操作符,它接受一个所谓的“窗口边界” Observable 作为参数,并根据“窗口边界” Observable 的发射将源发射分成单独的 Observable

一个例子肯定会使这一点更加明显:

// window_01.php 
$source = Observable::range(1, 10)->publish(); 
$windowBoundary = $source->bufferWithCount(3); 

$source->window($windowBoundary) 
  ->doOnNext(function() { 
    echo "emitting new window Observable\n"; 
  }) 
  ->switchLatest() 
  ->subscribe(new CallbackObserver(function($value) { 
    echo "$value\n"; 
  })); 

$source->connect(); 

我们有一个总共发射 10 个项目的源 Observable。由于我们传递给 window()$windowBoundary 变量中的 Observablewindow() 操作符将它们分成三个项目的块。这意味着我们将总共创建四个 Observable,其中前三个发射三个项目(然后完成),最后一个只发射一个项目(然后它也完成)。

为了使这一点更加明显,我们添加了 doOnNext() 操作符,以便每次创建新的 Observable 时都打印一条日志。

然后使用 switchLatest() 操作符始终只订阅 window() 发射的最新 Observable

因此在控制台里看起来我们是将源数据分成了三个项目的块。换句话说,我们将源数据分成了三个时间单位的窗口:

$ php window_01.php
emitting new window Observable
1
2
3
emitting new window Observable
4
5
6
emitting new window Observable
7
8
9
emitting new window Observable
10

你可能想知道这有什么好处。window() 操作符可以可选地接受一个选择函数。这个函数接收当前窗口作为参数,在将其推送到观察者之前。对我们来说,这意味着我们可以在它进一步传递之前将其与更多的操作符链式连接,这可能非常有用。

想象一下我们处于这样一个情况:我们正在接收许多不同类型的消息,但我们希望保证每 100 条消息中只通过每种类型的一条消息。

我们可以通过创建一个包含 500 个项目的源 Observable 来模拟这种情况,其中我们只重复三种不同的字符。然后将其分成每个 100 个项目的窗口,并使用选择函数将新的窗口 Observabledistinct() 操作符链式连接:

// window_02.php 
$chars = []; 
for ($i = 0; $i < 500; $i++) { 
  $chars[] = chr(rand(65, 67)); 
} 
echo 'Source length: ' . count($chars) . "\n"; 

$source = Observable::fromArray($chars)->publish(); 
$windowBoundary = $source->bufferWithCount(100); 

$source->window($windowBoundary, function($observable) { 
    return $observable->distinct(); 
  }) 
  ->doOnNext(function() { 
    echo "emitting new window Observable\n"; 
  }) 
  ->switchLatest() 
  ->subscribe(new CallbackObserver(function($value) { 
    echo "$value\n"; 
  })); 

$source->connect(); 

window() 操作符发射的每个 Observable 都有自己的 distinct() 操作符实例,因此每 100 个项目后,我们又开始重新比较不同的项目。

这个例子会打印以下输出:

$ php window_02.php
Source length: 500
emitting new window Observable
A
C
B
emitting new window Observable
C
A
B
emitting new window Observable
B
C
A
emitting new window Observable
C
B
A
emitting new window Observable
B
C
A
emitting new window Observable

注意,字符的顺序总是会有所不同,因为源是随机生成的。

我们可以看到,尽管每个窗口包含 100 个项目,但这些项目总是通过我们在选择函数中连接到窗口可观察对象的distinct()运算符进行过滤。

注意

window()运算符确实属于更高级且不太常见的运算符之一。在 RxJS 5 中,甚至有更多这个运算符的变体,用于更具体的用例。

materialize()dematerialize()运算符

在第五章,测试 RxPHP 代码,当我们讨论 RxPHP 中的测试时,我们没有使用真实值,而是传递了一些特殊记录的对象,这些对象使用OnNextNotification(或其errorcomplete变体)包装了实际值。我们这样做是因为TestScheduler类,以及我们必须能够唯一地识别每个值,以便比较对象引用而不是它们的值。仅比较值不能保证它们是相同的,因为默认情况下,原始类型(如字符串或整数)不是通过引用传递的。

有两个运算符使用类似的原则。这些是materialize()dematerialize()

第一个操作符会取每个值,将其包装在一个通知对象中,并以典型的onNext信号重新发射。这包括errorcomplete信号。这些信号就像任何其他值一样被包装和重新发射,之后发送一个完整的信号。

这意味着我们可以完全忽略错误信号或像处理常规值一样处理它们。在我们更多地讨论我们可以用所有这些做什么之前,让我们考虑以下示例,我们将看到materialize()运算符实际上做什么:

// materialize_01.php  
Observable::range(1, 3) 
    ->materialize() 
    ->subscribe(new DebugSubject()); 

这是一个仅发出三个值然后发送完整信号的RangeObservablematerialize()运算符将每个信号转换为对象,因此这个例子将在控制台打印以下输出:

$ php materialize_01.php  
20:15:48 [] onNext: OnNext(1) (Rx\Notification\OnNextNotification) 
20:15:48 [] onNext: OnNext(2) (Rx\Notification\OnNextNotification) 
20:15:48 [] onNext: OnNext(3) (Rx\Notification\OnNextNotification) 
20:15:48 [] onNext: OnCompleted() (...\OnCompletedNotification) 
20:15:48 [] onCompleted 

我们可以看到DebugSubject总共接收了五个信号。前三个是源RangeObservable发出的数字。然后是源发出的完整信号,也被包装了,之后是另一个完整信号,但这次是由materialize()运算符本身发出的。

现在我们来看另一个例子,其中我们将发射一个错误:

// materialize_02.php 
Observable::create(function(\Rx\ObserverInterface $observer) { 
        $observer->onNext(1); 
        $observer->onNext(2); 
        $observer->onError(new \Exception("It's broken")); 
        $observer->onNext(4); 
    }) 
    ->materialize() 
    ->subscribe(new DebugSubject()); 

这与上一个例子非常相似,但这次我们在正常发射中强制包含一个错误信号。正如我们所说,materialize()运算符包装错误并调用complete

当我们运行这个例子时,我们会看到包装的值4从未到达DebugSubject,因为它已经因为complete信号而取消订阅:

$ php materialize_02.php 
20:25:59 [] onNext: OnNext(1) (Rx\Notification\OnNextNotification)
20:25:59 [] onNext: OnNext(2) (Rx\Notification\OnNextNotification)
20:25:59 [] onNext: OnError(Exception) (...\OnErrorNotification)
20:25:59 [] onCompleted

所以,当我们实际上不能跳过errorcomplete信号时,所有这些到底有什么用?

虽然materialize()会将信号包裹在通知对象中,但还有一个完全相反的操作符叫做dematerialize()。当然,我们可以独立使用这两个操作符。

使用dematerialize()自定义错误冒泡

想象我们有一个需要发出多个错误的 Observable 链,但它无法决定这些错误中哪些是严重的,需要进一步传播到链的下方,哪些可以安全地忽略。在一个正常的 Observable 链中,第一个错误会导致立即取消订阅。

通过巧妙地使用通知对象和dematerialize()操作符,我们可以让错误在我们想要的时候出现。

在以下示例中,我们生成一系列九个数字。然后,每隔第三个数字被转换为一个错误通知。这些错误并不重要,可以安全地忽略。但是第六个数字是不同的,当它出现时,我们总是想发出一个错误信号。

考虑以下示例,它生成多个错误信号并将它们包裹在通知中:

// materialize_03.php  
Observable::range(1, 9) 
  ->materialize() 
  ->map(function(Notification $notification) { 
    $val = null; 
    $notification->accept(function($next) use (&$val) { 
      $val = $next; 
    }, function() {}, function() use (&$val) { $val = -1; }); 

    if ($val % 3 == 0) { 
      $msg = "It's really broken"; 
      $e = $val==6 ? new LogicException($msg) : new Exception(); 
      return new OnErrorNotification($e); 
    } else { 
      return $notification; 
    } 
  }) 
  ->subscribe(new DebugSubject()); 

这个示例在开始时使用materialize()将所有值转换为通知。然后,在map()操作符内部,我们使用它们的accept()方法解包所有通知,将它们的值传播到适当的可调用对象(就像在 Observable 上调用subscribe()一样)。这样我们就可以看到它的值,并直接返回它,或者最终返回OnErrorNotification

当我们运行这个示例时,我们会得到以下输出:

$ php materialize_03.php 
21:05:42 [] onNext: OnNext(1) (Rx\Notification\OnNextNotification)
21:05:42 [] onNext: OnNext(2) (Rx\Notification\OnNextNotification)
21:05:42 [] onNext: OnError(Exception) (...\OnErrorNotification)
21:05:42 [] onNext: OnNext(4) (Rx\Notification\OnNextNotification)
21:05:42 [] onNext: OnNext(5) (Rx\Notification\OnNextNotification)
21:05:42 [] onNext: OnError(LogicException) 
    (...\OnErrorNotification)
21:05:42 [] onNext: OnNext(7) (Rx\Notification\OnNextNotification)
21:05:42 [] onNext: OnNext(8) (Rx\Notification\OnNextNotification)
21:05:42 [] onNext: OnError(Exception) (...\OnErrorNotification)
21:05:42 [] onNext: OnCompleted() (...\OnCompletedNotification)
21:05:42 [] onCompleted

这就是我们想要得到的结果。实际上,没有任何错误做了任何事情,它们都被作为正常的onNext信号发出。注意,我们用LogicException代替了数字六。现在最后一件事是过滤掉对我们来说不重要的所有错误。这意味着除了单个LogicException之外的所有错误。

我们将在subscribe()调用之前添加filter()dematerialize()操作符。我们必须使用dematerialize()将通知转换为相应的信号。因此,前面的示例将看起来像以下这样:

// materialize_04.php 
// the preceding chain from materialize_03.php 
->filter(function(Notification $notification) { 
  if ($notification instanceof OnErrorNotification) { 
    $e2 = new OnErrorNotification(new LogicException()); 
    return (string)$notification == (string)$e2; 
  } else { 
    return true; 
  } 
}) 
->dematerialize() 
->subscribe(new DebugSubject()) 

如果我们重新运行这个最终示例,我们会得到以下输出:

$ php materialize_04.php 
21:09:33 [] onNext: 1 (integer)
21:09:33 [] onNext: 2 (integer)
21:09:33 [] onNext: 4 (integer)
21:09:33 [] onNext: 5 (integer)
21:09:33 [] onError (LogicException): It's really broken

所有其他错误都被filter()操作符忽略了,唯一被保留并使用dematerialize()解包的是LogicException

这种处理错误的方法显然是一种我们通常不想做的黑客技巧,但了解即使使用 RxPHP 也可以直接做到这一点,而不需要创建自定义观察者或 Observables,这是很好的。

RxPHP 操作符链中的错误处理

你可能想知道为什么我们不能直接从 Observable 发出多个错误,然后使用materialize()来包裹它们。

考虑以下使用Observable::create发出两个错误的示例:

// materialize_05.php  
Observable::create(function($observer) { 
    $observer->onNext(1); 
    $observer->onNext(2); 
    $observer->onError(new Exception()); 
    $observer->onNext(4); 
    $observer->onError(new Exception()); 
    $observer->onNext(6); 
  }) 
  ->materialize() 
  ->subscribe(new DebugSubject()); 

这个例子看起来应该将所有值和错误都包裹在通知中,因为我们把materialize()操作符放在Observable::create之后。让我们看看当我们运行它时会发生什么:

$ php materialize_05.php 
21:14:53 [] onNext: OnNext(1) (Rx\Notification\OnNextNotification)
21:14:53 [] onNext: OnNext(2) (Rx\Notification\OnNextNotification)
21:14:53 [] onNext: OnError(Exception) (...\OnErrorNotification)
21:14:53 [] onCompleted

那么为什么尽管我们使用了 materialize(),我们只能看到直到第一个错误为止的发射?

每次我们使用 lift(),实际上是在创建一个 AnonymousObservable 的新实例。这个 Observable 在订阅时内部创建一个 AutoDetachObserver 实例,然后调用其订阅可调用项。这个 AutoDetachObserver 类在接收到 errorcomplete 信号时会自动调用其内部的可处置对象(从而取消订阅源)。

由于几乎所有的操作符内部都使用 lift(),因此它们也使用 AutoDetachObserver

这包括 Observable::create(),它只是一个创建新的 AnonymousObservable 的静态方法。

因此,这就是为什么 Observable 永远不能发射超过一个 errorcomplete 信号。因为 AutoDetachObserver 类在收到第一个信号时已经取消订阅,所以它们总是会被忽略。

默认错误处理器

我们知道,每个观察者都可以将错误处理器作为一个可选参数,在错误通知时被调用。尽管默认行为与我们可能预期的不同。如果我们指定了错误可调用项,我们可以按自己的意愿处理错误。

例如,考虑以下只指定错误处理器的示例:

// error_01.php 
Observable::range(1, 5) 
  ->filter(function($val) { 
    if ($val === 3) { 
      throw new \Exception("It's broken"); 
    } 
  }) 
  ->subscribe(new CallbackObserver( 
    null, 
    function(\Exception $e){ 
      $msg = $e->getMessage(); 
      echo "Error: ${msg}\n"; 
    }) 
  ); 

当我们运行这个示例时,我们会看到处理器被正确调用:

$ php error_01.php
Error: It's broken

现在如果我们根本不设置任何错误处理器会发生什么?我们可以在以下示例中看到这种情况:

// error_02.php 
Observable::range(1, 5) 
  ->filter(function($val) { 
    if ($val === 3) { 
      throw new \Exception("It's broken"); 
    } 
  }) 
  ->subscribe(new CallbackObserver()); 

我们使用不带任何参数的 CallbackObserver,所以以下输出就是我们将会得到的:

$ php error_02.php
PHP Fatal error:  Uncaught Exception: It's broken in /.../Chapter 10/error_02.php:12
Stack trace:
#0 [internal function]: {closure}(3)
#1 /.../reactivex/rxphp/lib/Rx/Operator/FilterOperator.php(40): call_user_func(Object(Closure), 3)
#2 [internal function]: Rx\Operator\FilterOperator->Rx\Operator\{closure}(3)
...

异常只是被重新抛出。我们没有设置任何错误可调用项,所以这可能是我们没有预料到发生的事情。了解这种行为是好的,因为它意味着如果我们不处理错误通知,它们可能会引起意外的脚本终止。

注意

在 RxJS 5 中,默认的错误处理是相同的;然而,有一个持续的讨论,关于 Rx 应该如何表现。很可能会在 RxJS 的未来版本中改变这种行为。

在操作符内部捕获异常

当在 Observable 或操作符内部调用任何用户定义的函数时,也适用类似的原则。例如,当使用 map() 操作符时,可调用项被封装在一个 try-catch 块中。在可调用项内部抛出的任何异常随后会被发送为错误通知。

这意味着 Observables 和操作符不应该抛出异常,除非发生某些在正常情况下不应该发生的不寻常情况。在用户定义的可调用项内部抛出异常是一个有效用例。

我们可以通过这两个示例测试它们之间的差异。首先,我们将在之前看到的示例中的选择器函数内部抛出一个异常给 zip() 操作符:

// error_03.php 
$obs1 = Observable::range(1, 7); 
$obs2 = Observable::fromArray(['a', 'b']); 
$obs3 = Observable::range(42, 5); 

$obs1->zip([$obs2, $obs3], function($values) { 
    throw new \Exception("It's broken"); 
  }) 
  ->subscribe(new DebugSubject()); 

异常将被捕获并作为错误通知发送:

$ php error_03.php
09:41:05 [] onError (Exception): It's broken

在这个示例中,我们将看到当我们尝试使用普通对象而不是源 Observable 时会发生什么:

// error_04.php 
$obs1 = Observable::range(1, 7); 
$obs2 = Observable::fromArray(['a', 'b']); 
$object = new stdClass(); 

$obs1->zip([$obs2, $object]) 
    ->subscribe(new DebugSubject()); 

注意,在 PHP7 中这种类型的错误是可以捕获的:

$ php error_04.php
PHP Fatal error:  Uncaught Error: Call to undefined method 
    stdClass::subscribe() in /.../lib/Rx/Operator/ZipOperator.php:110
...

异常被留下以终止脚本执行,因为这种情况不应该发生。这很可能意味着我们在代码中有一个错误,无意中想要使用stdClass实例而不是可观察对象。

Observable::create()方法与 Subject 类

除了创建自定义可观察对象外,我们知道我们可以使用Observable::create()静态方法或Subject类的实例来自行发出项目,但到目前为止,我们还没有讨论过我们应该选择哪一个以及为什么。

根据经验法则,通常最好使用Observable::create()。虽然并不总是可能,但它有其优点。

在接下来的几个示例中,让我们假设我们想要与实现以下接口的 API 一起工作。这可以是任何 Facebook/Twitter/WebSocket 或系统 API:

interface RemoteAPI { 
    public function connect($connectionDetails); 
    public function fetch($path, $callback); 
    public function close(); 
} 

热冷可观察对象和 Observable::create()

在最一般的意义上,一个可观察对象只是一个将观察者与值的生产者连接起来的函数。通过生产者,我们理解任何与 RxPHP 无关的值源。例如,这可以是任何实现我们的RemoteAPI接口的类。

我们将看到这与我们从第二章,使用 RxPHP 进行响应式编程中定义的热冷可观察对象很好地工作。冷可观察对象在订阅时创建其生产者(在我们的例子中,连接到远程 API)。这意味着在我们至少有一个观察者之前,我们不想对 API 进行任何远程调用。

因此,内部使用 RemoteAPI 接口的冷可观察对象可能看起来像以下这样:

// observable_create_01.php 
class RemoteServiceAPI implements RemoteAPI { 
  ... 
} 

Observable::create(function(ObserverInterface $observer) { 
  $producer = new RemoteServiceAPI(); 
  $producer->connect('...'); 

  $producer->fetch('whatever', function($result) use ($observer){ 
    $observer->onNext($result); 
  }); 

  return new CallbackDisposable(function() use ($producer) { 
    $producer->close(); 
  }); 
}); 

这满足了我们对冷可观察对象的期望。生产者在我们订阅之前不存在,并且在取消订阅时也会自动关闭连接,因为我们从回调中返回了CallbackDisposable实例到Observable::create()方法。

如果我们想要使用Observable::create()方法创建一个热可观察对象,那么它将是相似的,但这次可观察对象既不负责创建也不负责关闭生产者:

// observable_create_02.php 
$producer = new RemoteServiceAPI(); 
$producer->connect('...'); 

Observable::create(function($observer) use ($producer) { 
  $producer->fetch('whatever', function($result) use ($observer){ 
    $observer->onNext($result); 
  }); 
}); 

// somewhere later... 
$producer->close(); 

生产者是在热可观察对象上独立创建的,订阅/取消订阅它对生产者没有影响。

你可能想知道这一切与比较Observable::create()Subject类有什么关系?

重点是,我们无法简单地用 Subject 来做同样的事情。当然,我们可以在这种情况下使用 Subject,但那时我们就必须自己处理所有订阅和取消订阅的逻辑(包括创建/关闭生产者)。尽管如此,在第八章,在 RxPHP 和 PHP7 pthreads 扩展中的多播中,我们讨论了 Subject 的内部状态,这在这里也非常相关。

作为一条经验法则,每次你使用 Subject 时,都要考虑是否可以用Observable::create()来实现相同的效果。

调用栈长度和 EventLoopScheduler

在开发 PHP 应用程序时,启用 Xdebug 扩展很有用,我们可以用它来调试我们的代码。然而,这会带来性能降低、内存使用增加以及可能嵌套函数调用数量的限制。

最后一个问题与我们特别相关。例如,在 RxPHP 中,当我们创建一个长的操作符链并使用ImmediateScheduler方法时。考虑以下非常长的操作符链:

// stack_length_01.php 
Observable::range(1, 10) 
  ->doOnNext(function($val) { /* do whatever */ }) 
  ->startWithArray([12, 15, 17]) 
  ->skip(1) 
  ->map(function($val) { 
    return $val * 2; 
  }) 
  ->filter(function($val) { 
    return $val % 3 === 0; 
  }) 
  ->doOnNext(function($val) { /* do whatever */ }) 
  ->takeLast(3) 
  ->sum() 
  ->doOnNext(function($val) { /* do whatever */ }) 
  ->subscribe(new CallbackObserver(function() { 
    $backtrace = debug_backtrace(); 
    $len = count($backtrace); 

    foreach ($backtrace as $item) { 
      $args = count($item['args']); 
      $func = $item['function']; 
      if (isset($item['file'])) { 
        $file = substr($item['file'], 
            strrpos($item['file'], '/') + 1); 
        echo "${file}#${item['line']} ${func} ${args} arg/s\n"; 
      } else { 
        echo "${func} ${args} arg/s\n"; 
      } 
    } 
    echo "============\n"; 
    echo "Stack length: ${len}\n"; 
  })); 

在这个例子中,我们链式连接了九个操作符,然后在观察者中打印整个调用栈。我们知道调用栈将从订阅者的onNext处理程序开始,通过onNext()调用向上遍历到RangeObservable,它开始发出值。然后通过subscribe()调用将堆栈返回到底部。

缩短的输出如下:

$ php stack_length_01.php 
{closure} 1 arg/s
CallbackObserver.php#45 call_user_func_array 2 arg/s
AbstractObserver.php#38 next 1 arg/s
AutoDetachObserver.php#53 onNext 1 arg/s
AbstractObserver.php#38 next 1 arg/s
DoOnEachOperator.php#34 onNext 1 arg/s
...
DoOnEachOperator.php#51 onCompleted 0 arg/s
Rx\Operator\{closure} 0 arg/s
CallbackObserver.php#35 call_user_func 1 arg/s
AbstractObserver.php#19 completed 0 arg/s
RangeObservable.php#59 onCompleted 0 arg/s
ImmediateScheduler.php#39 Rx\Observable\{closure} 1 arg/s
...
TakeLastOperator.php#55 subscribe 2 arg/s
Observable.php#740 __invoke 3 arg/s
AnonymousObservable.php#33 Rx\{closure} 2 arg/s
ReduceOperator.php#73 subscribe 2 arg/s
Observable.php#740 __invoke 3 arg/s
AnonymousObservable.php#33 Rx\{closure} 2 arg/s
DoOnEachOperator.php#55 subscribe 2 arg/s
Observable.php#740 __invoke 3 arg/s
AnonymousObservable.php#33 Rx\{closure} 2 arg/s
stack_length_01.php#39 subscribe 1 arg/s
============
Stack length: 103

我们可以看到这个调用栈包含了 103 个嵌套函数调用。这显然很难调试,因此我们可以通过使用EventLoopScheduler而不是defaultImmediateScheduler来缩短其长度。这将使得每个回调到schedule()方法的调用都由EventLoopScheduler作为一个单独的事件来运行。

我们将在Observable::range()调用中直接设置调度器,如下所示:

// stack_length_02.php
$loop = new StreamSelectLoop();
$scheduler = new EventLoopScheduler($loop);
Observable::range(1, 10, $scheduler)
 ...
 ->subscribe(new CallbackObserver(function() {
 ...
 }));
$loop->run();

现在我们运行这个例子时,调用栈将只包含 65 个嵌套调用:

$ php stack_length_02.php 
{closure} 1 arg/s
CallbackObserver.php#45 call_user_func_array 2 arg/s
AbstractObserver.php#38 next 1 arg/s
...
Timers.php#90 call_user_func 2 arg/s
StreamSelectLoop.php#177 tick 0 arg/s
stack_length_02.php#45 run 0 arg/s
============
Stack length: 65

这显然会带来一些成本,因此使用EventLoopScheduler类总是会比使用默认的ImmediateScheduler慢。此外,用schedule()包装的排放是从事件循环而不是从调用schedule()方法的地方调用的。

这可能会使调试变得更加困难,但EventLoopScheduler类在我们不希望阻塞执行线程,同时还想让其他代码被执行(归功于其事件循环)时特别有用。在第六章中,我们详细讨论了事件循环以及为什么不要阻塞执行线程的重要性。在这种情况下,使用EventLoopScheduler是一个非常不错的选择。

注意

使用异步调度器对 RxJS 也很有关系,因为在 JavaScript 环境中调用栈长度是有限的。

取消订阅与完成 Observable

我们知道,当我们有一个观察者时,当源 Observable 完成或当我们手动取消订阅时,我们将停止接收项目。然而,我们还没有讨论为什么我们可能选择其中一个而不是另一个。

基本上有两种停止接收项目的方法:

  • 从源 Observable 取消订阅

  • 使用完成链的操作符(如takeUntil()操作符)

当我们说取消订阅时,通常意味着我们不再希望接收任何项目。这显然并不意味着源可观察对象停止发送项目或发送了完整的通知。我们只是不再对来自源的项目感兴趣。

作为手动取消订阅的重要后果,完整的处理程序永远不会被调用。考虑以下示例,我们在收到几个项目后取消订阅:

// unsubscribe_01.php 
$loop = new StreamSelectLoop(); 
$scheduler = new EventLoopScheduler($loop); 

$subscription = Observable::range(1, 10, $scheduler) 
  ->subscribe(new CallbackObserver( 
    function($val) use (&$subscription) { 
      echo "$val\n"; 
      if ($val === 3) { 
        $subscription->dispose(); 
      } 
    }, 
    null, // no error handler 
    function() { 
      echo "completed!\n"; 
    }) 
  ); 

$loop->run(); 

注意,我们必须使用 EventLoopScheduler 类而不是默认类,因为我们需要在循环中将观察者可调用对象作为单独的事件运行。如果我们使用 ImmediateScheduler 类,那么 $subscription 变量将始终为 null,因为所有可调用对象都会在 subscribe() 调用中调用。换句话说,$subscription 变量将不会被分配。

当我们运行这个演示时,我们可以看到它打印了前三个项目然后结束,并且没有调用完整的处理程序:

$ php unsubscribe_01.php
1
2
3

但如果我们处于一个完整的处理程序很重要且我们总是希望在取消订阅时调用它的情境中呢?在这种情况下,我们可以使用任何发送完整通知的操作符,例如 takeUntil()Subject 类的实例:

// unsubscribe_02.php 
$subject = new Subject(); 

$subscription = Observable::range(1, 10) 
  ->takeUntil($subject) 
  ->subscribe(new CallbackObserver( 
    function($val) use ($subject) { 
      echo "$val\n"; 
      if ($val === 3) { 
        $subject->onNext(null); 
      } 
    }, 
    null, // no error handler 
    function() { 
      echo "completed!\n"; 
    }) 
  ); 

我们使用 $subject 变量来通知 takeUntil() 操作符我们想要完成,然后在传递给 CallbackObserver 类的可调用对象内部手动调用 onNext() 方法。

这样我们确保了,除了取消观察者的订阅外,我们还会调用完整的处理程序,正如我们从控制台输出中可以看到的那样:

$ php unsubscribe_02.php
1
2
3
completed!

我们是想简单地取消订阅还是发送完整的通知,这取决于我们。使用 Subject 类和 takeUntil() 操作符的一个大优点是,我们可以通过使用单个 onNext() 调用来轻松完成多个链。

如果我们只想取消多个链的订阅,那么我们就必须收集并保留它们的所有可处置对象,然后手动在它们上面调用 dispose()

匿名操作符

我们一直在大量使用 lift() 方法在可观察对象链中使用自定义操作符。在 RxPHP v1 中,这也是实现自定义操作符的唯一方式。该方法接受所谓的 操作符工厂 作为参数,这是一个返回我们想要使用的操作符实例的可调用对象。该方法在每次订阅时都会被调用,所以它总共可能只被调用一次。

当使用操作符时,我们利用了 PHP 的魔法 __invoke() 方法,它允许我们像使用函数一样使用任何对象。

让我们考虑一个简单的示例,它展示了 __invoke() 方法:

// func_01.php 
class MyClass { 
    public function __invoke($a, $b) { 
        return $a * $b; 
    } 
} 
$obj = new MyClass(); 
var_dump($obj(3, 4)); 

我们创建了一个 MyClass 的实例,我们像使用常规函数一样使用 $obj(3,4)。如果我们运行这个示例,我们会得到正确的结果:

$ php func_01.php 
int(12)

RxPHP 中的操作符使用相同的原理。实际上,lift() 方法深深嵌套在 Observable 类中,定义如下:

public function lift(callable $operatorFactory) { 
  return new AnonymousObservable( 
      function($observer, $schedule) use ($operatorFactory) { 

    $operator = $operatorFactory(); 
    return $operator($this, $observer, $schedule); 
  }); 
} 

可调用$operatorFactory根本不需要返回一个操作符对象。它可以只返回另一个可调用,该可调用将接受三个参数并执行它想要的任何操作。这在我们需要执行一次性的操作,使其不可重用且不需要编写自定义操作符时非常有用。

例如,我们可以像在其他操作符类中一样对源 Observable 和观察者执行相同的操作:

// anonymous_02.php 
Observable::range(1, 5) 
  ->map(function($val) { 
    return $val * 2; 
  }) 
  ->lift(function() { 
    return function($observable, $observer, $scheduler) { 
      $prevValue = 0; 
      $onNext = function($value) use ($observer, &$prevValue) { 
        $observer->onNext($value * $prevValue); 
        $prevValue = $value; 
      }; 
      $innerObs = new CallbackObserver( 
        $onNext, 
        [$observer, 'onError'], 
        [$observer, 'onCompleted'] 
      ); 

      return $observable->subscribe($innerObs); 
    }; 
  }) 
  ->subscribe(new DebugSubject()); 

注意,我们有一个在上下文中保持的$prevValue变量,我们可以在所有onNext信号的调用中使用它。

编写自定义的 DirectoryIteratorObservable

在上一章中,我们使用了一些DirectoryIterators来递归地获取目录及其所有子目录中的所有文件。在迭代文件时,我们可能不仅想要根据文件名过滤,还想要根据文件大小或访问限制过滤。理想情况下,我们可以有一个自定义的 Observable,它只检查文件名是否匹配某个模式,然后发出SplFileInfo对象,这样我们就可以自己实现过滤逻辑。

为了这个目的,我们将编写我们自己的DirectoryIteratorObservable,它执行所有这些操作,并且还有一些额外的选项。我们可以将实现拆分为两个更小的部分:

// DirectoryIteratorObservable.php 
class DirectoryIteratorObservable extends Observable { 
  private $iter; 
  private $scheduler; 
  private $selector; 
  private $pattern; 

  public function __construct($dir, $pattern = null, 
      $selector = null, $recursive = true, $scheduler = null) { 

    $this->scheduler = $scheduler; 
    $this->pattern = $pattern; 
    if ($recursive) { 
      $dirIter = new RecursiveDirectoryIterator($dir); 
      $iter = new RecursiveIteratorIterator($dirIter); 
    } else { 
      $iter = new DirectoryIterator($dir); 
    } 
    $this->iter = $iter; 

    if ($selector) { 
         $this->selector = $selector; 
    } else { 
      $this->selector = function(SplFileInfo $file) { 
        return $file; 
      }; 
    } 
  } 
  // ... 
} 

我们仍然在内部使用RecursiveIteratorIterator;然而,我们完全控制它,不会让其他开发者随意操作它。例如,有人可能会使用rewind()seek()方法,无意中移动内部指针,更不用说使用三个不同的迭代器遍历目录结构有点过于复杂,且不易重用。

正因如此,我们的 Observable 隐藏了所有这些内容,并且只有几个输入参数。我们绝对希望能够立即设置一个模式来过滤文件。有时我们可能想要递归遍历多个目录,有时则只遍历单个目录,因此我们将为此设置一个单独的参数。我们想要能够修改的最后一种行为是这个操作符将要发出什么。默认情况下,它是SplFileInfo对象,但如果我们设置一个自定义的选择器函数,我们可以发出,例如,仅文件名。

主要逻辑在subscribe()方法中,该方法围绕Scheduler类中的scheduleRecursive()方法构建:

// DirectoryIteratorObservable.php 
class DirectoryIteratorObservable extends Observable { 
  // ... 
  public function subscribe($observer, $scheduler = null) { 
    if ($this->scheduler !== null) { 
      $scheduler = $this->scheduler; 
    } 
    if ($scheduler === null) { 
      $scheduler = new ImmediateScheduler(); 
    } 
    $this->iter->rewind(); 

    return $scheduler->scheduleRecursive( 
        function($reschedule) use ($observer) { 
      /** @var SplFileInfo $current */ 
      $current = $this->iter->current(); 
      $this->iter->next(); 
      if (!$this->pattern || preg_match($this->pattern,$current)){  
        try { 
          $processed = call_user_func($this->selector, $current); 
          $observer->onNext($processed); 
        } catch (\Exception $e) { 
          $observer->onError($e); 
        } 
      } 

      if ($this->iter->valid()) { 
        $reschedule(); 
      } else { 
        $observer->onCompleted(); 
      } 
    }); 
  } 
} 

注意

注意,在 RxPHP v2 中,subscribe()方法不接收Scheduler作为参数。这意味着我们将直接使用Scheduler::getImmediate()静态方法来直接访问Scheduler类。

我们正在遍历迭代器产生的所有值,直到我们到达末尾,在那里我们只发出完整的信号。注意,我们正在包装对选择器函数的调用,所以如果它抛出异常,我们将将其作为error信号发出。

我们可以在与上一章相同的目录结构上测试这个 Observable,就像我们之前做的那样:

// directory_iterator_01.php 
$dir = __DIR__ . '/../symfony_template'; 
(new DirectoryIteratorObservable($dir, '/.+\.php$/')) 
  ->subscribeCallback(function(SplFileInfo $file) { 
    echo "$file\n"; 
  }); 

这将打印一个非常长的文件名列表(当 SplFileInfo 对象被类型转换为字符串时,它们只返回文件名)。

注意,在内部,这个可观察对象的工作方式与例如 RangeObservable 类似。实际上,它不保留观察者数组,而是立即向订阅了它的观察者发出所有值(我们也会使用 rewind() 将迭代器的内部指针移动到开始位置)。后果是显而易见的。

如果我们两次订阅这个可观察对象,它也会将整个可迭代对象循环两次。

DirectoryIteratorSharedObservable

因此,这看起来是一个多播的良好用例。当然,我们可以在每次使用 DirectoryIteratorObservable 时附加 publish() 操作符,但这很容易出错,因为我们很容易忘记使用它。相反,我们可以创建另一个包装 DirectoryIteratorObservable 的可观察对象,并在每次都附加 publish() 操作符:

// DirectoryIteratorSharedObservable.php 
class DirectoryIteratorSharedObservable extends Observable { 
    private $inner; 
    public function __construct() { 
        $args = func_get_args(); 
        // PHP7 array unpacking with "..." 
        $this->inner = (new DirectoryIteratorObservable(...$args)) 
            ->publish(); 
    } 
    public function subscribe($observer, $scheduler = null) { 
        $this->inner->subscribe($observer, $scheduler); 
    } 
    public function connect() { 
        return $this->inner->connect(); 
    } 
    public function refCount() { 
        return $this->inner->refCount(); 
    } 
} 

这个可观察对象只是原始 DirectoryIteratorObservable 的包装,它内部实例化,然后与 publish() 链接。我们故意使用 publish() 而不是 share()share() 操作符也会附加 refCount() 操作符,它根据观察者的数量自动订阅/取消订阅。

这对于需要执行一些异步操作的可观察对象很有用,例如下载数据(我们的 CURLObservable)或并行运行代码(我们的 ThreadPoolOperator)。对于在订阅时立即发出所有值的可观察对象,如 RangeObservable 或我们新的 DirectoryIteratorObservable,它不会像我们预期的那样工作。所有值都会因为 refCount() 操作符内部对 connect() 方法的立即调用而发送给第一个观察者。

现在,我们可以通过订阅多个观察者并调用 connect() 方法来测试这个操作符:

// directory_iterator_shared_01.php 
$src = new DirectoryIteratorSharedObservable('.', '/.+\.php$/'); 
$src->subscribe(new DebugSubject('1')); 
$src->subscribe(new DebugSubject('2')); 
$src->subscribe(new DebugSubject('3')); 
$src->connect(); 

这个演示的输出将是一个文件名列表,每个观察者将一次接收到当前目录中的一个项目:

$ php7 directory_iterator_shared_01.php 
09:52:55 [1] onNext: ./materialize_01.php (SplFileInfo)
09:52:55 [2] onNext: ./materialize_01.php (SplFileInfo)
09:52:55 [3] onNext: ./materialize_01.php (SplFileInfo)
09:52:55 [1] onNext: ./materialize_02.php (SplFileInfo)
09:52:55 [2] onNext: ./materialize_02.php (SplFileInfo)
09:52:55 [3] onNext: ./materialize_02.php (SplFileInfo)
...

我们避免了为每个观察者重新发出相同的目录结构,并使用 public() 操作符从源多播项目,并手动调用 connect() 方法。

使用 RxPHP 的 FTP 客户端

对于这个例子,让我们假设我们正在运行一个 FTP 服务器,我们想在服务器上执行一些操作。PHP 内置了对 FTP 连接的支持,因此我们不需要安装任何额外的库。

我们的目标是能够在使用 RxPHP 的同时,对 FTP 连接执行一些基本操作。当与可观察对象一起工作时,我们大多数时候都是将它们用在操作符链中,但可观察对象也可以用作异步输入或输出。当从异步函数返回一个值时,我们通常会使用 Promise,但与可观察对象一起,同样的原则也适用,我们还可以从链式操作中受益。

注意,PHP 中的所有 FTP 调用都是阻塞的。一些函数有它们的非阻塞变体,例如上传或下载文件的函数,但其他函数,例如更改或列出目录的函数,总是阻塞的。因此,我们将只使用它们的阻塞变体。这样我们可以用可观察对象处理它们的正确和错误状态。这还将是一个很好的例子,我们可以在这里使用多播。

因此,这将是一个如何在同步和阻塞应用程序中使用 RxPHP 的例子。

我们将我们的第一个FTPClient类分成两个更小的部分,看看我们如何在这个用例中实现 RxPHP:

// FTPClient.php 
class FTPClient { 
  private $conn; 
  private $cwd = '/'; 

  public function __construct($host, $username, $pass, $port=21) { 
    $this->conn = ftp_connect($host, $port); 
    if (!$this->conn) { 
      throw new \Exception('Unable to connect to ' . $host); 
    } 
    if (!ftp_login($this->conn, $username, $pass)) { 
      throw new \Exception('Unable to login'); 
    } 
  } 

  public function chdir($dir) { 
    $this->cwd = '/' . $dir; 
    if (!ftp_chdir($this->conn, $dir)) { 
      throw new \Exception('Unable to change current directory'); 
    } 
  } 

  public function listDir() { 
    return Observable::defer(function() { 
      $files = ftp_nlist($this->conn, $this->cwd); 
      return Observable::fromArray($files) 
        ->shareReplay(PHP_INT_MAX); 
    }); 
  } 

  public function close() { 
    ftp_close($this->conn); 
  } 
  // ... 
} 

这些是我们需要的最基本的方法。PHP 中的许多 FTP 函数只是根据它们是否成功返回 true 或 false。我们在构造函数中使用了这个方法,如果这些情况中的任何一个失败,就会抛出异常。

然后是第一个返回一个可观察对象(Observable)的方法。当我们想要获取一个目录中所有文件和目录的列表时,我们会调用listFiles()方法。这个方法从它接收到的文件数组中返回一个可观察对象。正如我们所说的,FTP 在 PHP 中是阻塞的,所以我们不会异步调用ftp_nlist(),需要等待它完成。我们返回一个可观察对象的事实意味着我们可以将这个可观察对象喂给这个FTPClient类中的另一个方法,该方法接受一个可观察对象作为参数。

我们故意使用Observable::defer来推迟实际的网络请求,直到我们订阅它。当我们开始为FTPClient编写测试应用程序时,我们会看到为什么这很重要。

现在,我们可以看看另外三个方法,它们将获取文件大小,从 FTP 服务器下载文件,或将文件上传到服务器:

class FTPClient { 
  // ... 
  public function size(Observable $files) { 
    return Observable::create(function($obs) use ($files) { 
      $files->subscribeCallback(function($filename) use ($obs) { 
        $size = ftp_size($this->conn, $filename); 
        $obs->onNext(['filename' => $filename, 'size' => $size]); 
      }); 
    }); 
  } 

  public function upload(Observable $files, $m = FTP_ASCII) { 
    $subject = new Subject(); 
    $files->subscribeCallback(function($file) use ($subject, $m) { 
      $fp = fopen($file, 'r'); 
      $filename = basename($file); 

      if (ftp_fput($this->conn, $filename, $fp, $m)) { 
        $subject->onNext($filename); 
      } else { 
        $e = new Exception('Unable to upload ' . $filename); 
        $subject->onError($e); 
      } 
    }); 
    return $subject->asObservable(); 
  } 

  public function download(Observable $files, $dir, $m=FTP_ASCII){ 
    $subject = new Subject(); 
    $files->subscribeCallback( 
        function($file) use ($subject, $m, $dir) { 

      $dest = $dir . DIRECTORY_SEPARATOR . $filename; 
      if (ftp_get($this->conn, $dest, $filename, $mode)) { 
        $subject->onNext($filename); 
      } else { 
        $e = new Exception('Unable to download ' . $filename); 
        $subject->onError($e); 
      } 
    }); 
    return $subject->asObservable(); 
  } 
} 

最后两个方法在原则上非常相似。它们都接受一个可观察对象作为参数并订阅它。然后它们创建一个内部 Subject,用于发出成功的上传/下载和错误。然后,使用asObservable()运算符将相同的 Subject 转换为可观察对象并返回。

这种方法的有趣之处在于,我们不需要提前知道我们想要下载/上传哪些文件。换句话说,我们可以用 Subject 的实例调用这些方法,然后继续执行我们的代码。然后,在稍后的某个时候,我们可以开始向这些 Subject 中推送项目,这将导致文件被下载/上传。我们稍后会看到这一点。

我们还实现了size()方法,它接受一个可观察对象作为参数并订阅它。这个方法内部使用Observable::create()实现,原因与listDir()相同。我们想要延迟发出任何值,直到至少有一个订阅。

现在,我们可以在一个简单的演示应用程序中使用这个类,首先连接到 FTP 服务器,列出所有文件和目录,然后尝试将当前目录更改为列表中的最后一个:

// ftp_01.php 
$ftp = new FTPClient('...', 'user', 'password'); 
echo "List content...\n"; 
$ftp->listDir() 
    ->takeLast(1) 
    ->subscribeCallback(function($dir) use ($ftp) { 
        echo "Changing directory to "$dir"...\n"; 
        $ftp->chdir($dir); 
    }); 

我们使用listDir()来获取当前目录的内容,这是此用户的根目录。然后我们只取最后一个项目并尝试进入该目录。在listDir()内部,我们使用了ftp_nlist()函数,它返回所有文件和目录,因此我们如何知道列表中的最后一个项目真的是目录而不是文件?

如果它是一个文件,那么对chdir()的调用将抛出异常。区分文件和目录的一个简单方法是通过检查它们的大小。目录始终具有大小-1,而普通文件具有始终大于或等于0的实际大小:

// ftp_01.php 
// ... 
echo "File sizes...\n"; 
$getFileSizesSubject = new Subject(); 

$fileSizes = $ftp 
  ->size($getFileSizesSubject->asObservable()) 
  ->doOnNext(function($file) { 
    echo "Size of ".$file['filename']." is ".$file['size']."\n"; 
  }) 
  ->filter(function($file) { 
    return $file['size'] != -1; 
  }) 
  ->subscribe(new DebugSubject()); 

$ftp->listDir()->subscribe($getFileSizesSubject);  

这很好地展示了我们之前讨论的内容。我们有一个 Subject 对象,将其作为参数传递给size()方法。这个方法不会订阅它,直到它自己的链有一个观察者,这在最后一行通过DebugSubject实现。

我们仍然没有调用任何ftp_size(),因为$getFileSizesSubject变量中的Subject类还没有发射任何项目。这发生在我们调用listDir()时,它首先调用ftp_nlist()来获取所有文件和目录的列表,然后开始向Subject类发射项目,该类只是简单地取项目并将其重新发射给它的观察者,即size()方法内部的调用者。

由于size()方法基于Observable::create()subscribe()方法,它在我们开始发送项目之前不会进行任何网络调用。这可能在调用它之后的任何时候发生。

这一切可能看起来有点令人困惑,但我们所做的只是将项目在几个 Observables 之间传递。

另一个明显的用例可能是只列出目录中的文件,然后下载所有这些文件。我们有两个需要文件 Observables 作为源的方法。使用size(),它们将检查文件大小(以查看它们是否真的是文件)和download()来下载它们。当然,我们不希望为每个这些方法都进行两次单独的调用,所以我们将使用只包含文件的 Observable 的输出(即$fileSizes变量)作为download()方法的源 Observable。

为了使这个例子稍微复杂一些,我们假设我们想要再次使用文件列表,例如,只打印文件名和它们的大小:

// ftp_01.php 
// ... 
$fileSizes = $ftp 
  ->size($getFileSizesSubject->asObservable()) 
  ->doOnNext(function($file) { 
    echo "Size of ".$file['filename']." is ".$file['size']."\n"; 
  }) 
  ->filter(function($file) { 
    return $file['size'] != -1; 
  }) 
  ->publish(); 

$destDir = './_download'; 
@mkdir($destDir); 

echo "Downloading files ...\n"; 
$filesToDownload = $fileSizes 
  ->map(function($file) { 
    return $file['filename']; 
  }); 

$ftp->download($filesToDownload, $destDir) 
  ->subscribeCallback(function($file) use ($destDir) { 
    echo "$file downloaded"; 
    $fileDest = $destDir . DIRECTORY_SEPARATOR . $file; 
    if (file_exists($fileDest)) { 
      echo " - OK\n"; 
    } else { 
      echo " - failed\n"; 
    } 
  }); 

$fileSizes->subscribeCallback(function($file) { 
  echo $file['filename'] . ' - ' . $file['size'] . "B\n"; 
}); 

$fileSizes->connect(); 
$ftp->listDir()->subscribe($getFileSizesSubject); 
echo "Done\n"; 

$filesToDownload变量中,我们存储了一个预定义的操作符链,它只发射来自$fileSizes的文件名。

如果我们运行这个演示应用程序,我们将得到以下输出(取决于我们连接到的 FTP 服务器):

$ php ftp_01.php
List content...
Changing directory to "web"...
File sizes...
Downloading files ...
Size of . is -1
Size of .. is -1
Size of app is -1
Size of blog is -1
Size of cache is -1
Size of composer.json is 522
composer.json downloaded - OK
composer.json - 522B
Size of composer.lock is 23690
composer.lock downloaded - OK
composer.lock - 23690B
Size of log is -1
Size of src is -1
Size of stats is -1
Size of vendor is -1
Size of www is -1
Done

我们可以看到,基于$fileSizes发射的这两个 Observables 正在共享相同的连接(对于每个项目,doOnNext()操作符只被调用一次)。

我们也可以创建只列出文件或只列出目录的方法。这可以看起来像以下这样:

class FTPClient { 
  // ... 
  public function listFiles() { 
    return $this->size($this->listDir()) 
      ->filter(function($file) { 
        return $file['size'] != -1; 
      }); 
  } 

  public function listDirectories() { 
    return $this->size($this->listDir()) 
      ->filter(function($dir) { 
        return $dir['size'] == -1 
            && $dir['filename'] != '.' 
            && $dir['filename'] != '..'; 
      }) 
      ->map(function($dir) { 
        return $dir['filename']; 
      }); 
  } 
} 

这两者都使用了我们之前解释的延迟订阅的相同原则。

摘要

本章介绍了一些稍微不寻常的例子,这些例子是 RxPHP 可能实现的,但它们并不适合之前的任何章节。这些并不是我们日常使用的东西,但了解这些功能是可能的。

尤其是我们在zip()window()materialize()dematerialize()操作符上进行了操作。我们看到了如何在 Observable 链中传播和处理错误,以及AutoDetachObserver的作用。此外,我们还比较了Observable::create()静态方法和Subject类,以及取消订阅和完成 Observable 链的情况。除此之外,我们还创建了匿名操作符,并编写了DirectoryIteratorObservable类,该类递归地迭代目录结构。最后,我们使用 RxPHP 创建了一个简单的 FTP 客户端,该客户端使用 Observables 进行输入和输出。

在上一章中,我们将讨论除 PHP 之外的语言中 Reactive Extension 的实现。最值得注意的是,我们将查看 RxJS——它是什么,它与 RxPHP 的关系,以及我们可能在 JavaScript 环境中遇到的不同之处。

章节附录。在 RxJS 中重用 RxPHP 技术

在整本书中,我们经常提到某些功能(如操作符或某些 Observables)在 RxPHP 和 RxJS 中的工作方式不同。RxJS 中的一些操作符甚至还没有在 RxPHP 中提供。还有 RxJS 的特性,由于 PHP 解释器的性质,甚至无法在 RxPHP 中实现。

尽管 Reactive Extensions 最初是为.NET 开发的 Rx.NET,但我们已经大量提到了 RxJS。

在本章中,我们将关注当前 RxPHP 和 RxJS 之间的差异。此外,RxJS 的知识在今天是很有用的,因为它的受欢迎程度仍在上升,这得益于像 Angular 2 这样的 JavaScript 框架,这些框架严重依赖于 RxJS。

本章涉及的主题将有些不寻常,因为这些将结合 PHP 和 JavaScript(特别是 ECMAScript 6 - ES6):

  • 我们将了解 RxJS 是什么,以及它在当今 JavaScript 世界中的地位。

  • 我们将编写几个非常简单的 RxJS 演示,介绍 JavaScript 中的同步和异步代码。

  • 我们将讨论 JavaScript 中的异步事件以及我们如何在 RxJS 中从中受益。

  • 我们将了解为什么和如何在 RxJS 和 RxPHP 中高阶 Observables 表现不同。

  • 我们将讨论 RxPHP 目前不可用但在 RxJS 中完全功能的操作符。

我们期望你至少了解 JavaScript 的基础知识,理想情况下还了解新的 ES6 标准(也称为 ES2015)。当然,这不是必需的,因为 RxJS 可以使用纯旧 JavaScript(确切地说,是 ES5.1),但它与 RxJS 及其开发过程非常相关。

此外,ES6 已经被 Node.js 很好地支持,所以我们没有理由不使用它。

如果你对本章中我们将使用的新的 ES6 语法感到困惑,请不要担心。如果你想了解更多关于 ES6 的信息,你可以查看它提供的功能快速总结,链接为github.com/lukehoban/es6features

我们将使用 Node.js 运行时(nodejs.org)运行本章中的所有示例。如果你不熟悉 Node.js,它基本上是一个使用 Chrome 的 V8 JavaScript 引擎的环境,允许我们在控制台中运行 JavaScript 代码。

那么,RxJS 是什么?

简单来说,RxJS 是 JavaScript 中 Reactive Extensions 的实现。

现在,准备好陷入极度困惑吧。

直到 2016 年 12 月,RxJS 有两大主要实现:

  • RxJS 4:这是大多数人熟悉的老版本实现。它的源代码可在github.com/Reactive-Extensions/RxJS找到,并且是用 JavaScript(ES5)编写的。正如我们在本章开头所说,RxPHP 目前主要指的是即将过时的这个老版本的 RxJS 4。

  • RxJS 5:这是更新且完全重写的 RxJS,将取代旧的 RxJS 4。它的源代码可在github.com/ReactiveX/rxjs找到,并且完全使用 TypeScript 2.0 编写。

由于我们提到了另一种名为 TypeScript 的编程语言,我们应该快速了解一下目前实际存在的 JavaScript 版本,以及我们可以在哪里(以及是否)使用它们:

  • ES5.1:这是每个人都可能在某一点上遇到的老式 JavaScript。

  • ES6(也称为ES2015):这是 JavaScript 的新标准。它与 ES5.1 向后兼容,并引入了诸如类、生成器、箭头函数以及let关键字来创建块作用域变量等功能。

  • ES7ES2016):这是 JavaScript 的更新标准,它带来了更多功能,例如async/await关键字,以避免创建回调地狱。

  • TypeScript:这是 ES6 规范的超集,增加了类型检查,并且在最新版本中,还包含了 ES7 的功能,如async/await关键字。

因此,TypeScript 是 RxJS 5 的首选语言,因为它与 ES6 的兼容性以及有助于在编译时预防许多错误的类型检查。

好吧,在谈论编译的时候,我们可能应该提到我们实际上可以在哪里运行这些新潮的语言:

  • ES5.1 被所有当前浏览器包括移动浏览器和 Node.js 支持。

  • ES6 已经可以在两个主要的 JavaScript 引擎中使用:Chrome 的 V8 和 SpiderMonkey(Firefox 所使用)。尽管当前与 ES6 的兼容性相当好(如我们可以在 kangax.github.io/compat-table/es6/ 上看到),但仍然不能仅依靠 ES6 来开发基于浏览器的应用程序。显然,我们还需要支持旧版浏览器和移动设备。因此,任何用 ES6 编写的代码都需要使用编译器(如 babel babeljs.io/)或 traceur github.com/google/traceur-compiler)编译成 ES5。这并不适用于 Node.js,因为在 Node.js v4 已经相当老旧的情况下,我们可以自由地使用 ES6,而且不同 Node.js 版本的渗透率并不是像我们从网络浏览器所习惯的那样成为一个问题(有一个重要的例外,即 ES6 模块导入,我们稍后会提到)。

  • ES7 带来了一些已经在 JavaScript 引擎中本地实现的特性(见 kangax.github.io/compat-table/es2016plus/);然而,这仍然是未来的音乐。我们不会在本章中使用 ES7 特性,以避免将我们的代码从 ES7 编译到 ES6。

  • TypeScript 是由微软及其社区开发的一种相对较新的语言。它不会被任何 JavaScript 引擎原生支持。它使用不同的语法和新关键字,这些关键字与 ES6 或 ES7 都不兼容。这意味着 TypeScript 代码始终需要编译成 ES6,更常见的是编译成 ES5。

另一方面,值得注意的是,TypeScript 是 ES6 的超集。这意味着任何 ES5 或 ES6 代码都是有效的 TypeScript 代码,这使得重用现有的 JavaScript 变得非常容易。

这与其他可以编译成 ES5 的语言形成对比,例如由 Google 制作的 Dart。Dart 完全不兼容 JavaScript,基本上,所有代码都需要重写为 Dart。这可能是 TypeScript 今天如此受欢迎的原因之一,尽管它比 Dart 晚推出。

因此,对于本章,我们将使用 Node.js(理想情况下,v6.9+,但基本上,任何 v4+ 都应该没问题)和 ES6。

JavaScript 模块系统

在讨论当前的 JavaScript 标准时,我们还应该提到今天用于定义 JavaScript 文件之间依赖关系的不同模块系统。

使用 JavaScript 总是令人烦恼,因为没有统一的方式来将代码分割成多个文件,按需加载,甚至打包。

现在我们有了用于 ES6 模块的漂亮的 ES6 语法,让我们考虑以下代码:

import * as lib from 'lib'; 
console.log(lib.square(42)); 

你能告诉我们今天在什么环境中可以原生运行这段代码吗?

这是一个陷阱问题。我们无法在任何地方运行它,因为目前没有任何 JavaScript 引擎支持 ES6 模块,甚至 Node.js 也不支持。

注意

如果你想了解更多关于为什么将 ES6 模块实现到 Node.js 中如此复杂的原因,请阅读 Node.js 开发者之一在hackernoon.com/node-js-tc-39-and-modules-a1118aecf95e 发表的文章。

目前 Node.js 仅支持使用require()函数以 CommonJS 格式(实际上,它并不完全符合 CommonJS 格式;它只是非常接近)加载模块。require()函数仅在 Node.js 中本地可用。如果我们想在浏览器中使用require(),我们需要一个 polyfill 或打包器来将多个通过require()调用的 JavaScript 文件合并成一个单一包。

如果我们真的想现在就使用 ES6 模块定义,这将是我们必须编译代码的另一个原因。请注意,我们实际上可以将 ES6 代码编译成另一种 ES6 代码,只是为了将 ES6 导入转换成当前的模块格式之一,如 UMD、CommonJS、AMD、SystemJS 或全局变量。

这已经被各种打包工具解决,例如 Browserify、webpack、SystemJS-Builder 或 rollup.js。然而,这又增加了一层复杂性。此外,这些工具只是将多个文件打包成一个单一包。如果我们有一个更复杂的应用程序,需要加载第三方库(这些库可以打包成任何格式,包括最基本的 Angular2 或 React 应用程序),我们还需要关注模块加载器。

模块加载器例如有 SystemJS、require.js、require1k、curl.js,以及可能还有更多。

这意味着,当我们今天开始一个 JavaScript 项目时,我们需要提前规划以下四个不同的事情:

  • 我将使用哪种语言?这会影响可用的功能和必须使用的编译器

  • 我将把源代码编译成哪种模块格式?

  • 我将使用哪种打包工具?

  • 我将如何要求我的打包项目(仅仅通过<script>标签包含它,或者我需要一个模块加载器)?

因此,RxJS 4 几乎避免了所有这些问题,因为它是用 ES5 编写的。唯一必要的任务是将其打包成一个可以像使用<script>标签一样轻松加载的单个文件。

使用 RxJS 5,事情变得更加复杂。

RxJS 5 的部署过程

整个 RxJS 5 项目是用 TypeScript 编写的。这意味着它需要编译成 ES5,这样我们就可以在浏览器或 Node.js 中使用它。

流程如下:

  • 整个源代码首先使用 TypeScript 编译器并使用 ES6 模块解析编译成 ES6。

  • 然后使用 Google 开发的closure-compiler-js将 ES6 代码再次编译,生成 ES5 代码。

  • 使用 rollup.js(在 rollup.js 之前,他们使用 Browserify)将 ES5 代码打包成一个单一的 UMD 包。

  • 这个捆绑文件以及每个文件的 ES5 版本、它们的源映射和 .d.ts 文件(TypeScript 声明文件)随后被上传到 npm 仓库。当我们例如在 Node.js 中使用 RxJS 5 时,我们通常会只要求这个单一的 UMD 捆绑。当在浏览器中使用 RxJS 5 时,我们可以通过 <script> 标签包含它,多亏了 UMD 模块格式。

注意

通用模块定义UMD)是一种通用的模块格式,根据加载它的环境,它可以作为 AMD、CommonJS、SystemJS 或全局模块。

如我们所见,在今天的 JavaScript 中开发应用程序可不是闹着玩的。我们将看到它也有一些好处。特别是,基于原型的继承可以简化扩展现有的 Observables,这在 PHP 等语言中是不可能的。

但在那之前,让我们看看如何在 Node.js 中使用 RxJS 5。

Node.js 中 RxJS 5 的快速介绍

我们已经非常熟悉反应式开发,所以这些例子中的任何一个都不应该让我们感到惊讶。

我们将首先通过 npm 安装 RxJS 5(基本上,这是一个类似于 PHP 中的 Composer 的依赖管理工具):

$ npm install rxjs

如我们之前所说,我们将使用 ES6 语法,但因为我们想避免因为 ES6 导入而重新编译我们的代码。这就是为什么我们总是使用 require() 函数来加载依赖。这个例子应该非常简单:

// rxjs_01.js 
const Rx = require('rxjs/Rx'); 

Rx.Observable.range(1, 8) 
    .filter(val => val % 2 == 0) 
    .subscribe(val => console.log('Next:', val)); 

我们在 Rx 常量下通过 rxjs/Rx 加载了 RxJS 5。Node.js 知道在哪里可以找到 rxjs 包(它会在 node_modules 目录中自动查找包)。完整的名称 rxjs/Rx 表示它将从 ./node_modules/rxjs/Rx.js 加载文件。这就像是这个库的入口点。它包含了很多 require() 调用,然后导出我们作为开发者可以使用的所有类。所有这些类都可以通过 Rx 前缀访问(例如,Rx.SubjectRx.TestScheduler)。

我们使用的箭头语法 val => val % 2 == 0 只是一个声明带有返回语句的匿名函数的快捷方式:

function(val) { 
    return val % 2 == 0; 
} 

箭头 => 也使得内部闭包从其父级中获取 this 上下文,但在这里我们不会大量使用它。

要运行这个演示,我们只需要 Node.js 运行时:

$ node rxjs_01.js 
Next: 2
Next: 4
Next: 6
Next: 8

即使是这个非常原始的例子,我们也能看到它与 PHP 的不同。当使用 Composer 时,我们不需要担心依赖的来源,因为它们总是由 Composer 通常生成的 SPL 自动加载器加载。

RxJS 中的异步调用

每次我们想在 PHP 中实现异步代码时,都必须坚持使用事件循环:特别是 StreamSelectLoopEventLoopScheduler,而且别无选择。每个 IntervalObservable 都必须接受一个调度器作为参数(然而,在 RxPHP 2 中,这会自动为我们完成,所以我们通常不需要担心它)。

这是在 RxJS 中,并且在一般情况下,任何与 PHP 完全不同的 JavaScript 环境。

考虑以下示例:

// interval_01.js 
const Rx = require('rxjs/Rx'); 
const Observable = Rx.Observable; 

Observable.interval(1000) 
    .subscribe(val => console.log('#1 Next:', val)); 
Observable.interval(60) 
    .subscribe(val => console.log('#2 Next:', val)); 

注意,我们没有使用任何循环和调度器。实际上,在 RxJS 5 中,操作符作为参数传递调度器并不常见。大多数操作符不需要这样做,因为它们不需要安排任何事情(例如 map()filter() 操作符),通常只有需要与计时器一起工作的操作符才需要(基本上,所有包含“时间”工作的操作符)。

这也意味着我们不需要担心应用程序的不同部分使用不同的事件循环。我们已经在 第六章,PHP Streams API 和高阶可观察对象,以及 第七章,实现套接字 IPC 和 WebSocket 服务器/客户端 中讨论了这个问题,我们看到了,如果置之不理,这可能会导致死锁。

我们可以运行这个演示,并看到不断增长的计数器触发:

$ node interval_01.js 
#2 Next: 0
#1 Next: 0
#2 Next: 1
#2 Next: 2
#1 Next: 1
#2 Next: 3

一个好问题是为什么在 JavaScript 中如此简单,而在 PHP 中却需要如此复杂?

Node.js 和异步事件

Node.js 实际上是一个基于 libuv 库(docs.libuv.org/)的大事件循环。

让我们考虑以下示例,该示例演示了将新的回调函数添加到事件循环中:

// node_01.js  
console.log('Starting application...'); 
var num = 5; 
console.log('num =', num); 

setTimeout(() => { 
    console.log('Inside setTimeout'); 
    num += 1; 
    console.log('num =', num); 
}); 

console.log('After scheduling another callback'); 
console.log('num =', num); 

当我们在 Node.js 中运行应用程序时,它将我们的代码作为单个回调函数开始执行。在我们的代码中某个地方,我们调用了 setTimeout() 函数,该函数接受另一个回调函数作为参数,该回调函数将在一段时间后执行。然而,我们没有提供任何超时参数就调用了 setTimeout()

实际上这并不重要,因为 setTimeout() 将回调函数添加到事件循环中,在所有其他回调函数执行完毕后作为最后一个运行。使用回调函数,我们可以轻松地使 Node.js 异步运行我们的代码。在 Node.js 中,所有系统调用都是异步的,并且以回调函数作为参数,以便非阻塞。

控制台输出如下:

$ node node_01.js 
Starting application...
num = 5
After scheduling another callback
num = 5
Inside setTimeout
num = 6

我们可以看到,在外部回调函数完成后,回调函数确实被调用了。当事件循环中没有更多的回调函数,并且没有挂起的回调函数时,Node.js 就会终止。

在 libuv 的深处,实际上有一个并行运行的线程池,它处理可以并发运行的系统调用。尽管如此,这对我们的代码没有影响,因为 Node.js 总是依次执行回调函数。这与 PHP 形成鲜明对比,在 PHP 中,这些都不存在,唯一安排异步调用的方式是使用自定义事件循环,就像我们使用 StreamSelectLoop 一样。

请记住,从我们的角度来看,Node.js 始终是单线程且严格顺序的。这意味着就像在 PHP 中一样,如果我们编写了阻塞的代码,它将阻塞执行线程。Node.js 永远不会并行执行回调。这当然也适用于浏览器 JavaScript 环境。

如果我们想要并行运行代码,我们可以像在第六章中那样,例如在PHP Streams API 和高级观察者中,生成子进程。

使用 debounceTime()操作符的损失性背压

我们已经从第七章中了解了背压是什么,实现 Socket IPC 和 WebSocket 服务器/客户端。在 RxJS 中,一个典型的用例是debounceTime(),它接受一个值,然后等待指定的超时时间到期后再重新发出它。这在创建自动完成功能时非常有用,我们希望当用户仍在输入字段中输入时推迟发送 AJAX 请求(正如我们在第一章中看到的,响应式编程简介)。

让我们看看它的油管图:

使用 debounceTime()操作符的损失性背压

为了说明debounceTime()的一个实际示例,考虑以下示例:

// debounce_time_01.js  
Observable.interval(100) 
    .concatMap(val => { 
        let obs = Observable.of(val); 
        return val % 5 == 0 ? obs.delay(250) : obs; 
    }) 
    .debounceTime(200) 
    .subscribe(val => console.log(val)); 

这是一个很好的实际示例,它使用 JavaScript 的异步回调来使用debounceTime()。这个示例每 100 毫秒发出一个值,每隔第五个值延迟 250 毫秒。这就是为什么大多数值都被debounceTime()忽略,因为这个操作符要求源至少有 200 毫秒的周期没有发出任何值。

输出如下:

$ node debounce_time_01.js 
4
9
14

对于debounceTime()有一个非常实用的例子,它利用了 JavaScript 的异步回调。

在第一章中,当我们谈论响应式编程时,我们提到的一个常见的被认为是“响应式”的应用是 Excel。我们有许多带有定义它们关系的方程的单元格,任何单元格的任何变化都会传播到整个电子表格。

让我们考虑以下包含三个输入值ABC以及我们在这上面建立的以下方程的电子表格:

使用 debounceTime()操作符的损失性背压

现在,我们如何在 RxJS 中创建类似的东西呢?我们可以将每个单元格表示为带有默认值的BehaviorSubject(我们需要使用 Subjects 以便以后能够更改单元格的值)。然后,每个方程(例如,A + B)将由combineLatest()持有。

前面的电子表格在 RxJS 中可能看起来像这样:

// excel_01.js 
const Rx = require('rxjs/Rx'); 
const Observable = Rx.Observable; 
const BehaviorSubject = Rx.BehaviorSubject; 

let A = new BehaviorSubject(1); 
let B = new BehaviorSubject(2); 
let C = new BehaviorSubject(3); 

let AB = Observable.combineLatest(A, B, (a, b) => a + b) 
    .do(x => console.log('A + B = ' + x)); 

let BC = Observable.combineLatest(B, C, (b, c) => b + c) 
    .do(x => console.log('B + C = ' + x)); 

let ABBC = Observable.combineLatest(AB, BC, (ab, bc) => ab + bc) 
    .do(x => console.log('AB + BC = ' + x)); 

ABBC.subscribe(); 

我们使用 combineLatest() 来通知当每个方程的任何源可观察对象发生变化时。我们还有多个 do() 操作符来记录我们的可观察链中正在发生的事情。

当我们运行这个演示时,我们将看到以下输出:

$ node excel_01.js 
A + B = 3
B + C = 5
AB + BC = 8

这显然是正确的。每个方程都恰好被调用了一次。

现在,让我们假设我们在默认值传播之后将 B 细胞的值更改为 4。这意味着它需要重新计算 ABBCABBC。更新 B 细胞后的期望状态应如下截图所示:

使用 debounceTime() 操作符的损失性背压

将以下两行添加到源文件中:

... 
console.log("Updating B = 4 ..."); 
B.next(4); 

然后,重新运行示例并注意哪些方程被评估了:

$ node excel_01.js 
A + B = 3
B + C = 5
AB + BC = 8
Updating B = 4 ...
A + B = 5
AB + BC = 10
B + C = 7
AB + BC = 12

前三个是正确的。然后,我们将 B 设置为 4,这触发了 A + B 的重新计算,紧接着 AB + BC 等于 10。嗯,这是不正确的,因为我们还没有更新 B + C,它紧接着出现。然后,在更新 BC 之后,AB + BC 再次重新计算,并将正确的值设置为 ABBC

我们可以简单地忽略它,因为最终结果是正确的。然而,如果单元格和方程的数量增加,那么每个多余的更新仍然会导致页面 DOM 的更新。结果,这可能会使页面变得卡顿,用户可能会注意到单元格闪烁。

那我们如何避免这种情况呢?

我们说过,当 debounceTime() 收到一个值时,它会将其存储在内部并开始一个超时。然后,它不会重新发出任何值,直到超时回调被评估,这只会重新发出 debounceTime() 收到的最后一个值。我们可以利用这一点,通过设置 0 超时,这样就不会延迟回调,但只是将其放在 Node.js 事件循环的末尾。

换句话说,当我们使用 debounceTime(0) 时,我们将忽略 debounceTime() 收到的所有值,直到回调的末尾。因此,我们可以用它来计算 AB + BC

let ABBC = Observable.combineLatest(AB, BC, (ab, bc) => ab + bc) 
    .debounceTime(0) 
    .do(x => console.log('AB + BC = ' + x)); 

现在如果我们再次运行代码,我们将看到我们想要的输出:

$ node excel_01.js 
A + B = 3
B + C = 5
Updating B = 4 ...
A + B = 5
B + C = 7
AB + BC = 12

这绝对是一个高级用例,我们不会在日常工作中遇到,但看到我们可以利用 JavaScript 内部机制是很不错的。

注意,在 RxPHP 中不使用事件循环和自定义操作符做这件事是非常困难的,但在 JavaScript 中相对简单。

RxJS 5 和 RxPHP 中的高阶可观察对象

在开发浏览器应用程序时,我们非常经常需要执行 AJAX 调用来异步获取数据。例如,在 Angular2 中,这非常常见,实际上,使用 Angular2 的 HTTP 服务发出的任何 AJAX 请求都会返回一个可观察对象,我们通常将 map() 操作符链式调用以解码 JSON,然后使用 subscribe() 来通知响应已准备好。

我们可以用以下代码来模拟这种情况:

// http_mock_01.js 
const Rx = require('rxjs/Rx'); 
let data = '[{"name": "John"},{"name": "Bob"},{"name": "Dan"}]'; 

Rx.Observable.of(data) 
    .map(response => JSON.parse(response)) 
    .subscribe(value => console.log('Next:', value)); 

变量数据包含一个 JSON 序列化的对象数组,我们将其解码并传递给观察者。输出如下所示:

$ node http_mock_01.js 
Next: [ { name: 'John' }, { name: 'Bob' }, { name: 'Dan' } ]

好吧,它确实有效,但如果我们只想接收具有以字母B开头的name属性的物体呢?目前,我们接收到的整个物体数组作为一个单独的发射。

因此,问题是我们如何解包数组并分别发射每个单独的物体?

在 RxJS 和 RxPHP 中可以以完全相同方式使用的一个选项是使用concatMap()mergeMap()也可以)并返回一个由可迭代对象创建的新 Observables。在 RxJS 中,这可以如下所示:

// http_mock_02.js 
... 
Observable.of(data) 
    .map(response => JSON.parse(response)) 
    .concatMap(array => Observable.from(array)) 
    .filter(object => object.name[0].toLowerCase() == "b") 
    .subscribe(value => console.log('Next:', value)); 

在 RxJS 5 中,Observable.from()接受任何类似数组的对象作为参数,并发出所有项。在 RxPHP 中,我们会使用Observable::fromArray()代替。

现在输出是一个单独的项,因为其余的项被filter()操作符跳过了:

$ node http_mock_02.js
Next: { name: 'Bob' }

在 RxJS 5 中,还有另一种相当巧妙的方法可以达到相同的结果。

我们已经讨论了与高阶 Observables 一起工作的操作符,例如mergeAll()concatAll()。它们订阅一个发出 Observables 的 Observables。由于 RxJS 5 的内部实现,我们可以使用一个小技巧,使用通常只与高阶 Observables 一起工作的操作符来处理数组。

让我们看看如何使用concatAll()在先前的示例中达到与concatMap()相同的结果:

Observable.of(data) 
    .map(data => JSON.parse(data)) 
    .concatAll() 
    .filter(object => object.name[0].toLowerCase() == "b") 
    .subscribe(value => console.log('Next:', value)); 

因此,这显然不应该工作。concatAll()如何订阅一个数组?

答案在于concatAll()以及基本上,所有与高阶 Observables 一起工作的操作符在内部都会订阅源 Observables 发出的项。在 PHP 中,我们预计它们都必须是其他 Observables,但 RxJS 5 并非如此。

RxJS 5 中的一些操作符通过一个名为subscribeToResult()的函数(它在src/util/subscribeToResult.ts中定义)订阅内部 Observables。这个函数对不同类型的项有多个处理程序。当然,有一个处理 Observables 的处理程序,但除此之外,它还知道如何与 Promises 以及 JavaScript 数组一起工作。

当我们之前使用concatAll()时,subscribeToResult()函数只是迭代数组并重新发出所有值。请注意,它只是内部迭代数组。它没有从它创建另一个 Observables。

因此,这只是两个,但有用的,我们在从 RxPHP 切换到 RxJS 5 时可能会遇到的不同点。

适用于 RxJS 5 的特定操作符

正如我们所说,RxJS 5 中还有一些额外的操作符目前 RxPHP 中没有。实际上,它们相当多,但许多在原则上非常相似。我们在第七章,实现 Socket IPC 和 WebSocket 服务器/客户端中提到了一些,例如audit()throttle(),包括所有使用超时或其他 Observables 创建时间窗口的变体。此外,所有从buffer()派生的操作符对我们来说都不那么有趣。

我们将看看这三个运算符,它们服务于一些其他有趣的目的。

expand()运算符

expand()运算符有趣的地方在于它的工作是递归的。它接受一个参数,即需要返回另一个 Observable 的回调。然后,这个回调被应用于返回的 Observable 发出的所有值。只要返回的 Observables 发出值,这个过程就会继续。

考虑以下示例,我们使用expand()递归地将一个值乘以二,直到结果小于 32:

// expand_01.js 
const Rx = require('rxjs/Rx'); 
const Observable = Rx.Observable; 

Observable.of(1) 
    .expand(val => { 
        if (val > 32) { 
            return Observable.empty(); 
        } else { 
            return Observable.of(val * 2); 
        } 
    }) 
    .subscribe(val => console.log(val)); 

我们通过不发出任何值并只返回Observable.empty()(它只发出完成信号)来停止递归。

所有递归调用产生的所有中间值都会由expand()重新发出,所以这个示例的输出将如下所示:

$ node expand_01.js 
1
2
4
8
16
32
64

finally()运算符

如其名所示,这个运算符在errorcomplete信号上都会执行其回调。看到finally()和仅订阅并使用相同的回调来处理错误和完成信号之间的区别是很重要的。

finally()运算符不会将冷 Observables 转换为热。所以,它更类似于do()运算符,而不是subscribe()方法:

// finally_01.js 
const Rx = require('rxjs/Rx'); 
let source = Rx.Observable.create(observer => { 
        observer.next(1); 
        observer.error('error message'); 
        observer.next(3); 
        observer.complete(); 
    }); 

source 
    .finally(() => console.log('#1 Finally callback')) 
    .subscribe( 
        value => console.log('#1 Next:', value), 
        error => console.log('#1 Error:', error), 
        () => console.log('#1 Complete') 
    ); 

source 
    .onErrorResumeNext() 
    .finally(() => console.log('#2 Finally callback')) 
    .subscribe( 
        value => console.log('#2 Next:', value), 
        error => console.log('#2 Error:', error), 
        () => console.log('#2 Complete') 
    ); 

第一次订阅将只收到第一个值,然后是error信号。注意我们使用finally()运算符和subscribe()调用的顺序。运算符finally()先使用,所以它也先收到错误信号。

第二次订阅是类似的。此外,这个示例使用了onErrorResumeNext()来忽略错误信号(即使它不会收到最后一个值,因为它已经退订了)。它只会收到完成信号。再次注意finally()运算符的使用位置。

当我们运行这个示例时,我们会得到以下输出:

$ node finally_01.js 
#1 Next: 1
#1 Error: error message
#1 Finally callback
#2 Next: 1
#2 Complete
#2 Finally callback

尽管两个finally()运算符都在subscribe()之前使用(这是显而易见的,因为这些都是需要在链中某处的运算符),但它们的回调是在subscribe()的错误或完成回调之后执行的。

这是do()运算符的基本区别,也是为什么在某些情况下finally()可能很有用的原因。

withLatestFrom()运算符

在第七章,实现 Socket IPC 和 WebSocket 服务器/客户端,和第八章,在 RxPHP 和 PHP7 pthreads 扩展中的多播中,我们使用了combineLatest()运算符,并提到在 RxJS 5 中还有一个稍微修改过的变体。

combineLatest() 操作符接收多个源可观察对象,并在其中任何一个发出值时,作为数组发出它们的最新的值。然后,有 withLatestFrom() 操作符也接收多个源,但这个操作符只在它的直接前驱在链中发出值(它的源可观察对象)时发出值。

考虑以下具有多个计时器的示例:

// with_latest_from_01.js  
const Rx = require('rxjs/Rx'); 
const Observable = Rx.Observable; 

let source1 = Observable.interval(150); 
let source2 = Observable.interval(250); 

Observable.interval(1000) 
    .withLatestFrom(source1, source2) 
    .subscribe(response => console.log(response)); 

source1source2 每秒都会发出多个值。然而,withLatestFrom() 只在 Observable.interval(1000) 发出值时重新发出它们的值。

这个演示的输出如下:

$ node with_latest_from_01.js  
[ 0, 5, 2 ] 
[ 1, 12, 6 ] 
[ 2, 19, 10 ] 
[ 3, 25, 14 ] 
[ 4, 31, 18 ] 
[ 5, 38, 22 ] 
[ 6, 45, 26 ] 
[ 7, 51, 30 ] 

这个操作符的使用案例与 combineLatest() 的使用案例非常相似。我们只是对重新发出的控制更好,这可能非常有用,例如,实现缓存机制,其中 1 秒的间隔可以控制我们想要刷新缓存的时间。

在谈论缓存的同时,我们将看看这本书中最后一个非常棒的示例。

使用 publishReplay() 和 take() 缓存 HTTP 请求

这个示例是我的最爱。我向那些想要开始使用 RxJS 的人展示这个演示,他们被复杂性压倒了,看不到实际的优势。

在前端开发中一个非常常见的用例是我们需要缓存 AJAX 调用的结果。例如,我们可能有一个我们希望每分钟最多查询一次的服务器。在一分钟内所有的后续调用都不会产生另一个 AJAX 调用,而是只接收缓存的数据。

所有这些都可以通过利用 publishReplay()take() 操作符来完成:

// cache_01.js 
const Rx = require('rxjs/Rx'); 
const Observable = Rx.Observable; 

var counter = 1; 
var updateTrigger = Observable.defer(() => mockDataFetch()) 
    .publishReplay(1, 1000) 
    .refCount(); 

function mockDataFetch() { 
    return Observable.of(counter++).delay(100); 
} 

function mockHttpCache() { 
    return updateTrigger.take(1); 
} 

我们使用 mockDataFetch() 函数创建模拟请求,每次调用时都会增加计数器(这是为了确保我们没有比我们认为的更多地向服务器发出调用)。然后,我们延迟这个可观察对象,以假装它需要一些时间。

每次我们想要从缓存或从新的 AJAX 请求中获取当前数据时,我们都会使用 mockHttpCache() 函数,该函数返回一个可观察对象。

让我们看看我们如何安排几个调用,并确保这确实像我们预期的从控制台输出中工作。之后,我们可以解释为什么它会这样工作:

mockHttpCache().toPromise() 
    .then(val => console.log("Response from 0:", val)); 

setTimeout(() => mockHttpCache().toPromise() 
    .then(val => console.log("Response from 200:", val)) 
, 200); 

setTimeout(() => mockHttpCache().toPromise() 
    .then(val => console.log("Response from 1200:", val)) 
, 1200); 

setTimeout(() => mockHttpCache().toPromise() 
    .then(val => console.log("Response from 1500:", val)) 
, 1500); 

setTimeout(() => mockHttpCache().toPromise() 
    .then(val => console.log("Response from 3500:", val)) 
, 3500); 

我们总共发出五个请求。前两个应该收到相同的响应。接下来的两个将收到另一个响应,最后一个将收到第三个响应。为了说明目的,我们只缓存响应 1 秒。

现在,让我们看看控制台输出:

$ node cache_01.js 
Response from 0: 1
Response from 200: 1
Response from 1200: 2
Response from 1500: 2
Response from 3500: 3

因此,它确实像我们想要的那样工作;但它是如何做到的?

publishReplay(1, 1000) 操作符通过 ReplaySubject 在 1 秒内多播响应(见第八章 Chapter 8` 时,以下情况之一会发生:

  • 我们订阅了一个已经缓存了响应的 ReplaySubject。在这种情况下,在订阅时,它会立即调用 next() 并将此值发送给新的订阅者。由于存在 take(1) 操作符,它传递了值并完成链。然后 ReplaySubject 检查在传递缓存值后订阅者是否停止。多亏了 take(1),它确实停止了,所以 ReplaySubject 不会订阅延迟的 Observable。

  • 我们订阅了 ReplaySubject,但它没有有效的响应缓存,并且还需要订阅触发新 AJAX 请求的延迟 Observable。当请求准备好时,它被传递到链中,take(1) 重新发出它并完成。

因此,这是一种相当简短且巧妙的方法,可以制作出通常需要至少使用一个 setTimeout() 和至少两个状态变量来保持缓存响应及其创建时间的复杂功能。

摘要

这最后一章是专门献给 RxJS 5 的,以展示尽管大多数原则是相同的,但也有一些我们可以利用的差异。

阅读完这一章后,你应该了解 RxJS 4 和 RxJS 5 之间的差异,了解用于开发和部署 RxJS 5 的技术,了解 Node.js 如何处理异步代码,以及 RxJS 5 中已经存在但尚未在 RxPHP 中实现的操作符。

希望你能从 RxJS 和 RxPHP 中汲取精华,并自己用它来编写更快、更易读的代码。

posted @ 2025-09-07 09:15  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报