Clojure-反应式编程-全-
Clojure 反应式编程(全)
原文:
zh.annas-archive.org/md5/6145835f49fb455f54fc86ddacf97c6f译者:飞龙
前言
高度并发的应用程序,如用户界面,传统上通过全局变量的突变来管理状态。各种动作通过事件处理程序进行协调,这些处理程序本质上是程序性的。
随着时间的推移,系统的复杂性不断增加。新的功能请求不断涌入,对应用程序进行推理变得越来越困难。
函数式编程通过消除可变状态,允许以声明性和可组合的方式编写应用程序,呈现为构建可靠系统的极其强大的盟友。
这些原则催生了函数式反应式编程和组合事件系统(CES),这些编程范式在构建异步和并发应用程序方面非常有用。它们允许你以函数式风格对可变状态进行建模。
本书致力于这些理念,并介绍了一系列不同的工具和技术,以帮助管理现代系统日益增加的复杂性。
本书涵盖的内容
第一章,什么是反应式编程?,首先通过一个引人入胜的 ClojureScript 编写的反应式应用程序的例子引导你,然后带你穿越反应式编程的历史,在此期间介绍了一些重要的术语,为后续章节定下了基调。
第二章,反应式扩展概述,探讨了这种反应式编程框架的基础。其中介绍了其抽象概念,并讨论了错误处理和背压等重要主题。
第三章,异步编程和网络,引导你构建一个股票市场应用程序。它首先使用一种更传统的方法,然后切换到基于反应式扩展的实现,比较了两种方法之间的权衡。
第四章,core.async 简介,描述了 core.async,这是一个用于 Clojure 异步编程的库。在这里,你将了解通信顺序进程的构建块,以及如何使用 core.async 构建反应式应用程序。
第五章,使用 core.async 创建自己的 CES 框架,开始了构建 CES 框架的雄心勃勃的努力。它利用了前一章获得的知识,并以 core.async 作为框架的基础。
第六章,使用 Reagi 构建简单的 ClojureScript 游戏,展示了反应式框架在游戏开发中产生巨大效果的领域。
第七章, UI 作为函数,转换了方向,展示了如何通过 Om 的视角将函数式编程的原则应用于 Web UI 开发,Om 是 Facebook 的 React 的 ClojureScript 绑定。
第八章, 未来,将未来作为某些类反应式应用的可行替代方案。它探讨了 Clojure 未来的局限性,并提出了一种替代方案:imminent,一个用于 Clojure 的可组合未来库。
第九章, 亚马逊网络服务的响应式 API,描述了一个来自真实项目的案例研究,其中本书介绍的大多数概念都被组合起来与第三方服务进行交互。
附录 A, 图书馆设计代数,介绍了来自范畴论的有助于构建可重用抽象的概念。附录是可选的,不会妨碍前几章的学习。它展示了设计第八章中看到的未来库所使用的原则,未来。
附录 B, 参考文献,提供了书中使用的所有参考文献。
你需要这本书的
本书假设你有一个相当现代的台式机或笔记本电脑,以及一个配置正确的 Clojure 环境与 leiningen(见leiningen.org/)。
安装说明取决于你的平台,可以在 leiningen 网站上找到(见leiningen.org/#install)。
你可以自由选择任何你喜欢的文本编辑器,但流行的选择包括带有 Counterclockwise 插件的 Eclipse(见eclipse.org/downloads/),带有 Cursive 插件的 IntelliJ(见www.jetbrains.com/idea/),Light Table(见lighttable.com/),Emacs 和 Vim。
这本书是为谁而写的
这本书是为目前正在构建或计划构建异步和并发应用程序的 Clojure 开发者而写的,他们还对如何将响应式编程的原则和工具应用于日常工作感兴趣。
需要了解 Clojure 和 leiningen——一个流行的 Clojure 构建工具。
本书还包含几个 ClojureScript 示例,因此熟悉 ClojureScript 和通用 Web 开发将有所帮助。
尽管如此,章节已经仔细编写,只要您具备 Clojure 知识,遵循这些示例只需额外一点努力。
随着本书的进展,它将列出后续章节所需的构建块,因此我的建议是您从第一章,“什么是响应式编程?”,并按顺序阅读后续章节。
一个明显的例外是附录 A,“图书馆设计代数”,它是可选的,可以独立于其他内容阅读——尽管阅读第八章,“未来”,可能提供有用的背景。
规范
在这本书中,您会发现许多不同风格的文本,用于区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名将如下所示:“我们可以通过使用include指令来包含其他上下文。”
代码块应如下设置:
(def numbers (atom []))
(defn adder [key ref old-state new-state]
(print "Current sum is " (reduce + new-state)))
(add-watch numbers :adder adder)
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
(-> (repeat-obs 5)
(rx/subscribe prn-to-repl))
;; 5
;; 5
任何命令行输入或输出都应如下编写:
lein run -m sin-wave.server
新术语和重要词汇将以粗体显示。例如,您在屏幕上、菜单或对话框中看到的单词将以文本中的这种方式显示:“如果这是一个 Web 应用程序,我们的用户将看到一个 Web 服务器错误,例如HTTP 代码 500 - 内部服务器错误。”
注意
警告或重要注意事项将显示在这个框中。
小贴士
小技巧和技巧看起来像这样。
读者反馈
我们欢迎读者提供反馈。请告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正能从中获得最大收益的标题非常重要。
要向我们发送一般反馈,只需发送一封电子邮件到<feedback@packtpub.com>,并在邮件的主题中提及书名。
如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在,您已经成为 Packt 书籍的骄傲所有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您在www.packtpub.com的账户中下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
错误
尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分下的现有勘误列表中。任何现有勘误都可以通过从 www.packtpub.com/support 选择您的标题来查看。
盗版
互联网上版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
如果您发现任何疑似盗版材料,请通过 <copyright@packtpub.com> 联系我们,并提供链接。
我们感谢您在保护我们作者以及为我们带来有价值内容方面的帮助。
问题
如果您在这本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。
第一章:什么是响应式编程?
响应式编程是一个既复杂又广泛的话题。因此,这本书将专注于响应式编程的一个特定公式,称为组合事件系统(CES)。
在介绍响应式编程和 CES 的历史和背景之前,我想从一个实际且希望引人入胜的例子开始:一个在网页上绘制正弦波的动画示例。
正弦波是正弦函数的图形表示。它是一种平滑、重复的振荡,在动画结束时,它将看起来像以下截图:

这个示例将突出 CES:
-
强调我们应该思考我们想要做什么,而不是如何做
-
鼓励构建小的、具体的抽象,这些抽象可以组合在一起
-
生成简洁且易于维护的代码,易于更改
这个程序的核心归结为四行 ClojureScript 代码:
(-> sine-wave
(.take 600)
(.subscribe (fn [{:keys [x y]}]
(fill-rect x y "orange"))))
仅通过查看这段代码,我们无法确切地知道它做什么。然而,请花时间阅读并想象它可能做什么。
首先,我们有一个名为sine-wave的变量,它代表我们将绘制到网页上的 2D 坐标。下一行给出了sine-wave是一种类似集合的抽象的直观感觉:我们使用.take从其中检索 600 个坐标。
最后,我们通过传递一个回调函数来.subscribe到这个“集合”。这个回调函数将为正弦波中的每个项目被调用,最终使用fill-rect函数在给定的x和y坐标处绘制。
目前来说,这需要我们吸收很多信息,因为我们还没有看到其他代码——但这就是这个小练习的目的:尽管我们对这个示例的具体细节一无所知,但我们仍然能够发展出对它可能如何工作的直觉。
让我们看看还需要什么来使这个片段在我们的屏幕上动画化正弦波。
响应式编程的初体验
这个示例是用 ClojureScript 构建的,并使用 HTML 5 Canvas 进行渲染,以及 RxJS(见github.com/Reactive-Extensions/RxJS)——一个 JavaScript 中的响应式编程框架。
在我们开始之前,请记住我们不会深入探讨这些框架的细节——这些将在本书的后面部分进行。这意味着我会要求你们接受相当多的事情,所以如果你没有立即理解事物是如何工作的,请不要担心。这个示例的目的是让我们简单地开始了解响应式编程的世界。
对于这个项目,我们将使用 Chestnut(见github.com/plexus/chestnut)——一个 ClojureScript 的 leiningen 模板,它提供了一个我们可以用作框架的示例工作应用程序。
要创建我们的新项目,请转到命令行并按照以下方式调用 leiningen:
lein new chestnut sin-wave
cd sin-wave
接下来,我们需要修改生成项目中的几个地方。打开sin-wave/resources/index.html并将其更新如下:
<!DOCTYPE html>
<html>
<head>
<link href="css/style.css" rel="stylesheet" type="text/css">
</head>
<body>
<div id="app"></div>
<script src="img/rx.all.js" type="text/javascript"></script>
<script src="img/app.js" type="text/javascript"></script>
<canvas id="myCanvas" width="650" height="200" style="border:1px solid #d3d3d3;">
</body>
</html>
这只是确保我们导入了我们的应用程序代码和 RxJS。我们还没有下载 RxJS,所以让我们现在就做。浏览到github.com/Reactive-Extensions/RxJS/blob/master/dist/rx.all.js并将此文件保存到sin-wave/resources/public。前面的代码片段还添加了一个 HTML 5 Canvas 元素,我们将在此元素上绘制。
现在,打开/src/cljs/sin_wave/core.cljs。我们的应用程序代码将在这里。您可以忽略当前的内容。确保您有一个像以下这样的干净画布:
(ns sin-wave.core)
(defn main [])
最后,回到命令行——在sin-wave文件夹下——启动以下应用程序:
lein run -m sin-wave.server
2015-01-02 19:52:34.116:INFO:oejs.Server:jetty-7.6.13.v20130916
2015-01-02 19:52:34.158:INFO:oejs.AbstractConnector:Started SelectChannelConnector@0.0.0.0:10555
Starting figwheel.
Starting web server on port 10555 .
Compiling ClojureScript.
Figwheel: Starting server at http://localhost:3449
Figwheel: Serving files from '(dev-resources|resources)/public'
一旦之前的命令完成,应用程序将在http://localhost:10555上可用,在那里您将找到一个空白的长方形画布。我们现在可以开始了。
我们使用 Chestnut 模板的主要原因是为了这个示例,它通过 websockets 执行我们的应用程序代码的热重载。这意味着我们可以将浏览器和编辑器并排放置,并且在我们更新代码时,我们将在浏览器中立即看到结果,而无需重新加载页面。
为了验证这是否正常工作,请打开您的网页浏览器的控制台,以便您可以看到页面中脚本的输出。然后按照以下方式将以下内容添加到/src/cljs/sin_wave/core.cljs中:
(.log js/console "hello clojurescript")
您应该已经看到了打印到浏览器控制台的hello clojurescript消息。请确保您的工作环境正常,因为我们将会依赖这个工作流程来交互式地构建我们的应用程序。
同样,确保每次 Chestnut 重新加载我们的文件时清除画布也是个好主意。通过将以下代码片段添加到我们的核心命名空间中,这很简单:
(def canvas (.getElementById js/document "myCanvas"))
(def ctx (.getContext canvas "2d"))
;; Clear canvas before doing anything else
(.clearRect ctx 0 0 (.-width canvas) (.-height canvas))
创建时间
现在我们有了正常的工作环境,我们可以继续我们的动画。指定我们希望多久创建一个新动画帧可能是个好主意。
这实际上意味着将时间的概念添加到我们的应用程序中。您可以自由地尝试不同的值,但让我们从每 10 毫秒创建一个新帧开始:
(def interval js/Rx.Observable.interval)
(def time (interval 10))
由于 RxJS 是一个 JavaScript 库,我们需要使用 ClojureScript 的互操作性来调用其函数。为了方便,我们将 RxJS 的interval函数绑定到一个局部变量。在本书中,当适用时,我们将使用这种方法。
接下来,我们创建一个无限数字流——从 0 开始,每 10 毫秒有一个新元素。让我们确保这是按预期工作的:
(-> time
(.take 5)
(.subscribe (fn [n]
(.log js/console n))))
;; 0
;; 1
;; 2
;; 3
;; 4
小贴士
我在这里非常宽松地使用流这个术语。稍后在本书中将更精确地定义它。
记住时间是无限的,所以我们使用.take来避免无限期地向控制台打印数字。
我们下一步是计算代表正弦波一段的 2D 坐标,我们可以用以下函数来完成:
(defn deg-to-rad [n]
(* (/ Math/PI 180) n))
(defn sine-coord [x]
(let [sin (Math/sin (deg-to-rad x))
y (- 100 (* sin 90))]
{:x x
:y y
:sin sin}))
sine-coord 函数接受我们 2D 画布上的一个 x 点,并根据 x 的正弦值计算 y 点。常数 100 和 90 简单地控制斜率的高度和尖锐程度。例如,尝试计算当 x 为 50 时的正弦坐标:
(.log js/console (str (sine-coord 50)))
;;{:x 50, :y 31.05600011929198, :sin 0.766044443118978}
我们将使用 time 作为 x 值的来源。现在创建正弦波只是将 time 和 sine-coord 结合起来:
(def sine-wave
(.map time sine-coord))
就像 time 一样,sine-wave 是一个无限流。不同的是,我们现在将拥有正弦波的 x 和 y 坐标,如下所示:
(-> sine-wave
(.take 5)
(.subscribe (fn [xysin]
(.log js/console (str xysin)))))
;; {:x 0, :y 100, :sin 0}
;; {:x 1, :y 98.42928342064448, :sin 0.01745240643728351}
;; {:x 2, :y 96.85904529677491, :sin 0.03489949670250097}
;; {:x 3, :y 95.28976393813505, :sin 0.052335956242943835}
;; {:x 4, :y 93.72191736302872, :sin 0.0697564737441253}
这就带我们回到了最初引起我们兴趣的代码片段,以及一个执行实际绘制的函数:
(defn fill-rect [x y colour]
(set! (.-fillStyle ctx) colour)
(.fillRect ctx x y 2 2))
(-> sine-wave
(.take 600)
(.subscribe (fn [{:keys [x y]}]
(fill-rect x y "orange"))))
到目前为止,我们可以再次保存文件,并观察我们刚刚创建的正弦波优雅地出现在屏幕上。
更多颜色
这个例子旨在说明,以非常简单的抽象方式思考,然后在它们之上构建更复杂的抽象,可以使代码更容易维护和修改。
因此,我们现在将更新我们的动画,以不同颜色绘制正弦波。在这种情况下,我们希望当 x 的正弦值为负时绘制红色,否则为蓝色。
我们已经有了通过 sine-wave 流传递的正弦值,所以我们只需要将这个流转换成一个会根据先前标准给出颜色的流:
(def colour (.map sine-wave
(fn [{:keys [sin]}]
(if (< sin 0)
"red"
"blue"))))
下一步是将新的流添加到主绘制循环中——记得注释掉之前的代码,以免同时绘制多个波:
(-> (.zip sine-wave colour #(vector % %2))
(.take 600)
(.subscribe (fn [[{:keys [x y]} colour]]
(fill-rect x y colour))))
保存文件后,我们应该看到一个新正弦波在红色和蓝色之间交替,因为 x 的正弦值在 -1 到 1 之间振荡。
使其变得反应式
虽然到目前为止这很有趣,但我们创建的动画实际上并不是反应式的。当然,它确实对时间本身做出了反应,但这正是动画的本质。正如我们稍后将会看到的,响应式编程之所以被称为响应式编程,是因为程序对外部输入(如鼠标或网络事件)做出反应。
因此,我们将更新动画,让用户控制颜色切换的时间:波将开始为红色,当用户在画布区域内点击时切换到蓝色。进一步的点击将简单地交替在红色和蓝色之间。
我们首先创建无限流——根据 time 的定义——为我们的颜色原语,如下所示:
(def red (.map time (fn [_] "red")))
(def blue (.map time (fn [_] "blue")))
单独来看,red 和 blue 并不有趣,因为它们的值没有变化。我们可以把它们看作是 常数 流。当与另一个无限流结合,该流根据用户输入在它们之间循环时,它们就变得更有趣了:
(def concat js/Rx.Observable.concat)
(def defer js/Rx.Observable.defer)
(def from-event js/Rx.Observable.fromEvent)
(def mouse-click (from-event canvas "click"))
(def cycle-colour
(concat (.takeUntil red mouse-click)
(defer #(concat (.takeUntil blue mouse-click)
cycle-colour))))
这是我们迄今为止最复杂的更新。如果你仔细观察,你也会注意到cycle-colour是一个递归流;也就是说,它是根据自身定义的。
当我们第一次看到这种代码时,我们尝试理解它所做的事情时,我们做出了一个大胆的假设。然而,在快速阅读之后,我们意识到cycle-colour紧密遵循我们可能如何讨论问题的方法:我们将使用红色,直到鼠标点击发生,然后我们将使用蓝色,直到另一个鼠标点击发生。然后,我们开始递归。
我们对动画循环的更改很小:
(-> (.zip sine-wave cycle-colour #(vector % %2))
(.take 600)
(.subscribe (fn [[{:keys [x y]} colour]]
(fill-rect x y colour))))
这本书的目的是帮助你培养出以这种方式建模问题的本能。在每一章之后,越来越多的示例将变得有意义。此外,我们将在 ClojureScript 和 Clojure 中使用多个框架,为你提供广泛的选择工具。
在我们继续之前,我们必须稍微绕个弯,理解我们是如何到达这里的。
练习 1.1
修改之前的示例,以便使用所有彩虹颜色绘制正弦波。绘图循环应如下所示:
(-> (.zip sine-wave rainbow-colours #(vector % %2))
(.take 600)
(.subscribe (fn [[{:keys [x y]} colour]]
(fill-rect x y colour))))
你的任务是实现rainbow-colours流。由于到目前为止的解释非常少,你可能选择在覆盖更多关于 CES 的内容之后再回来做这个练习。
repeat、scan和flatMap函数可能有助于解决这个练习。请务必查阅 RxJs 的 API:github.com/Reactive-Extensions/RxJS/blob/master/doc/libraries/rx.complete.md。
一点历史
在我们谈论响应式编程之前,了解其他相关编程范式如何影响我们开发软件的方式非常重要。这也有助于我们理解响应式编程背后的动机。
除了少数例外,我们大多数人要么自学,要么在学校/大学里被教授了诸如 C 和 Pascal 之类的命令式编程语言,或者诸如 Java 和 C++之类的面向对象语言。
在这两种情况下,命令式编程范式——其中面向对象语言是其中一部分——规定我们编写程序作为一系列修改程序状态的语句。
为了理解这意味着什么,让我们看看一个用伪代码编写的简短程序,该程序计算数字列表的总和和平均值:
numbers := [1, 2, 3, 4, 5, 6]
sum := 0
for each number in numbers
sum := sum + number
end
mean := sum / count(numbers)
小贴士
平均值是列表中数字的平均值,通过将总和除以元素数量得到。
首先,我们创建一个新的整数数组,称为numbers,包含从 1 到 6 的数字,包括 6。然后,我们将sum初始化为零。接下来,我们逐个遍历整数数组,将每个数字的值加到sum上。
最后,我们计算并将列表中数字的平均值赋给局部变量mean。这完成了程序逻辑。
如果执行此程序,它将打印总和为 21,平均值为 3。
尽管这是一个简单的例子,但它突出了其命令式风格:我们设置一个应用程序状态——sum——然后明确告诉计算机如何修改该状态以计算结果。
数据流编程
之前的例子有一个有趣的特性:mean 的值明显依赖于 sum 的内容。
数据流编程使这种关系明确。它将应用程序建模为一个依赖图,数据通过该图从操作到操作流动——并且当值发生变化时,这些变化会传播到其依赖项。
从历史上看,数据流编程由 Lucid 和 BLODI 等定制构建的语言支持,因此,其他通用编程语言被排除在外。
让我们看看这种新的见解将如何影响我们之前的例子。我们知道一旦执行了最后一行,mean 的值就会被赋值,并且除非我们显式地重新赋值变量,否则它不会改变。
然而,让我们想象一下,我们之前使用的伪语言确实支持数据流编程。在这种情况下,将 mean 赋值给一个同时引用 sum 和 count 的表达式,例如 sum / count(numbers),就足以在以下图中创建有向依赖图:

注意,这种关系的直接副作用是创建了一个从 sum 到 numbers 的隐式依赖。这意味着如果 numbers 发生变化,变化会通过图传播,首先更新 sum,然后最终更新 mean。
这就是响应式编程发挥作用的地方。这种范式建立在数据流编程和变化传播的基础上,将这种编程风格带到那些没有原生支持的编程语言中。
对于命令式编程语言,响应式编程可以通过库或语言扩展来提供。本书不涉及这种方法,但如果读者想了解更多关于这个主题的信息,请参考 dc-lib(见 code.google.com/p/dc-lib/)的示例。这是一个通过数据流约束为 C++ 添加响应式编程支持的框架。
面向对象响应式编程
当设计桌面 图形用户界面(GUIs)等交互式应用程序时,我们本质上是在使用面向对象的方法来进行响应式编程。我们将构建一个简单的计算器应用程序来展示这种风格。
小贴士
Clojure 不是一个面向对象的语言,但我们将与 Java API 的部分进行交互,以构建在面向对象范式下开发的用户界面,因此本节的标题是“面向对象响应式编程”。
让我们从命令行创建一个新的 leiningen 项目开始:
lein new calculator
这将在当前文件夹中创建一个名为 calculator 的目录。接下来,使用您最喜欢的文本编辑器打开 project.clj 文件,并添加对 Seesaw 的依赖,Seesaw 是一个用于处理 Java Swing 的 Clojure 库:
(defproject calculator "0.1.0-SNAPSHOT"
:description "FIXME: write description"
:url "http://example.com/FIXME"
:license {:name "Eclipse Public License"
:url "http://www.eclipse.org/legal/epl-v10.html"}
:dependencies [[org.clojure/clojure "1.5.1"]
[seesaw "1.4.4"]])
在撰写本文时,可用的最新 Seesaw 版本是 1.4.4。
接下来,在src/calculator/core.clj文件中,我们将首先导入 Seesaw 库并创建我们将要使用的视觉组件:
(ns calculator.core
(:require [seesaw.core :refer :all]))
(native!)
(def main-frame (frame :title "Calculator" :on-close :exit))
(def field-x (text "1"))
(def field-y (text "2"))
(def result-label (label "Type numbers in the boxes to add them up!"))
Calculator that ends the program when closed. We also create two text input fields, field-x and field-y, as well as a label that will be used to display the results, aptly named result-label.
我们希望标签能够自动更新,一旦用户在任一输入字段中输入一个新的数字。以下代码正是这样做的:
(defn update-sum [e]
(try
(text! result-label
(str "Sum is " (+ (Integer/parseInt (text field-x))
(Integer/parseInt (text field-y)))))
(catch Exception e
(println "Error parsing input."))))
(listen field-x :key-released update-sum)
(listen field-y :key-released update-sum)
第一个函数update-sum是我们的事件处理程序。它将result-label的文本设置为field-x和field-y中值的总和。我们在这里使用 try/catch 作为一个非常基本的错误处理方式,因为按下的键可能不是一个数字。然后我们将事件处理程序添加到两个输入字段的:key-released事件中。
小贴士
在实际应用程序中,我们永远不希望有一个像之前的那样的 catch 块。这被认为是不良风格,catch 块应该执行一些更有用的操作,例如记录异常、触发通知或在可能的情况下恢复应用程序。
我们几乎完成了。我们现在需要做的只是将我们迄今为止创建的组件添加到main-frame中,并最终按以下方式显示它:
(config! main-frame :content
(border-panel
:north (horizontal-panel :items [field-x field-y])
:center result-label
:border 5))
(defn -main [& args]
(-> main-frame pack! show!))
现在我们可以保存文件,并在项目的根目录下通过命令行运行程序:
lein run -m calculator.core
你应该会看到以下截图:

通过在任一或两个文本输入字段中输入一些数字进行实验,并观察标签的值如何自动更改,显示两个数字的和。
恭喜!你刚刚创建了你第一个响应式应用程序!
如前所述,这个应用程序是响应式的,因为结果标签的值会根据用户输入而响应并自动更新。然而,这并不是全部的故事——它缺乏可组合性,需要我们指定我们试图实现的如何,而不是什么。
尽管这种编程风格可能很熟悉,但以这种方式使应用程序响应式并不总是理想的。
根据之前的讨论,我们注意到我们仍然需要相当明确地设置各个组件之间的关系,正如需要编写一个自定义处理程序并将其绑定到两个输入字段所证明的那样。
正如我们将在本书的其余部分看到的那样,处理类似场景有更好的方法。
最广泛使用的响应式程序
上一节中的两个示例对一些读者来说可能很熟悉。如果我们把输入文本字段称为“单元格”,把结果标签的处理程序称为“公式”,我们现在就有了现代电子表格应用程序(如 Microsoft Excel)中使用的术语。
术语响应式编程仅在近年来开始使用,但响应式应用程序的概念并不新鲜。第一个电子表格可以追溯到 1969 年,当时哈佛大学的毕业生 Rene Pardo 和 Remy Landau 创建了LANPAR(用于随机编程数组的语言)[1]。
它是为了解决 Bell Canada 和 AT&T 在当时遇到的问题而发明的:他们的预算表有 2000 个单元格,当修改时,迫使软件重写,耗时从六个月到两年不等。
迄今为止,电子表格仍然是各个领域专业人士的强大且有用的工具。
观察者设计模式
留意细节的读者可能会注意到与观察者设计模式的相似之处。它主要在面向对象的应用程序中使用,作为对象之间通信的方式,而无需知道谁依赖于其变化。
在 Clojure 中,可以使用 watches 实现观察者模式的简单版本:
(def numbers (atom []))
(defn adder [key ref old-state new-state]
(print "Current sum is " (reduce + new-state)))
(add-watch numbers :adder adder)
我们首先创建我们的程序状态,在这种情况下是一个包含空向量的原子。接下来,我们创建一个知道如何求和 numbers 中所有数字的监视函数。最后,我们将监视函数添加到 numbers 原子下的 :adder 键(用于移除监视)。
adder 键符合 add-watch 所需的 API 合同,并接收四个参数。在这个例子中,我们只关心 new-state。
现在,每当更新 numbers 的值时,其监视都会被执行,如下所示:
(swap! numbers conj 1)
;; Current sum is 1
(swap! numbers conj 2)
;; Current sum is 3
(swap! numbers conj 7)
;; Current sum is 10
上面的高亮行表示每次我们更新原子时打印在屏幕上的结果。
虽然有用,但观察者模式仍然需要在设置依赖关系和所需的程序状态上做一些工作,而且难以组合。
话虽如此,这个模式已经被扩展,并成为我们将在本书后面部分探讨的其中一个反应式编程框架的核心,即微软的 Reactive Extensions(Rx)。
函数式反应式编程
就像反应式编程一样,函数式反应式编程(简称 FRP)不幸地成为了一个多义词。
例如,RxJava(见 github.com/ReactiveX/RxJava)、ReactiveCocoa(见 github.com/ReactiveCocoa/ReactiveCocoa)和 Bacon.js(见 baconjs.github.io/)等框架在近年来变得极为流行,并错误地将自己定位为 FRP 库。这导致了围绕术语的混淆。
正如我们将看到的,这些框架并没有实现 FRP,而是受到了它的启发。
为了使用正确的术语以及理解“受 FRP 启发”的含义,我们将简要地看看 FRP 的不同表述。
高阶 FRP
高阶 FRP 指的是 Conal Elliott 和 Paul Hudak 在他们 1997 年的论文 Functional Reactive Animation [2] 中开发的 FRP 的原始研究。这篇论文介绍了 Fran,这是一个嵌入 Haskell 中的特定领域语言,用于创建反应式动画。它后来被作为库以及专门构建的反应式语言实现。
如果你回忆一下我们之前几页创建的计算器示例,我们可以看到这种响应式编程风格要求我们通过直接从输入字段读取和写入来显式地管理状态。作为 Clojure 开发者,我们知道在构建软件时避免状态和可变数据是一个值得记住的好原则。这个原则是函数式编程的核心:
(->> [1 2 3 4 5 6]
(map inc)
(filter even?)
(reduce +))
;; 12
这个简短的程序通过reduce将原始列表中的所有元素加一,过滤掉所有偶数,并将它们相加。
注意我们不需要在计算的每一步显式地管理局部状态。
与命令式编程不同,我们关注我们想要做什么,例如迭代,而不是我们想要如何做,例如使用for循环。这就是为什么实现与我们的程序描述非常接近。这被称为声明式编程。
FRP 将相同的哲学带到了响应式编程中。正如关于该主题的 Haskell 编程语言维基百科明智地指出:
FRP 是关于处理随时间变化的价值,就像它们是常规价值一样。
换句话说,FRP 是一种声明式的方法,用于建模随时间输入做出响应的系统。
这两个语句都触及了时间这一概念。我们将在下一节中探讨这一点,届时我们将介绍 FRP 提供的关键抽象:信号(或行为)和事件。
信号和事件
到目前为止,我们一直在处理程序对用户输入做出反应的想法。这当然只是响应式系统的一小部分,但对于这次讨论来说已经足够了。
用户输入在程序执行过程中多次发生:按键、鼠标拖动和点击只是用户可能与我们系统交互的几个例子。所有这些交互都在一段时间内发生。FRP 认识到时间是响应式程序的一个重要方面,并通过其抽象将其作为一等公民。
信号(也称为行为)和事件都与时间相关。信号表示连续、随时间变化的价值。另一方面,事件代表在特定时间点的离散发生。
例如,时间本身就是一个信号。它连续且无限地变化。另一方面,用户的按键是一个事件,一个离散的发生。
然而,需要注意的是,信号如何变化的语义不必是连续的。想象一下代表你的鼠标指针当前(x,y)坐标的信号。
这个信号被称为离散变化,因为它依赖于用户移动鼠标指针——一个事件,这不是一个连续的动作。
实现挑战
经典 FRP 最定义性的特征可能是对连续时间的使用。
这意味着 FRP 假设信号始终在变化,即使它们的值仍然是相同的,这会导致不必要的重新计算。例如,鼠标位置信号将触发应用程序依赖图的更新——就像我们之前看到的平均程序依赖图——即使鼠标是静止的。
另一个问题在于,经典 FRP 默认是同步的:事件按顺序逐个处理。起初可能无害,但这可能导致延迟,如果某个事件处理时间显著较长,则会使应用程序无响应。
Paul Hudak 和其他人进一步研究了高阶 FRP[7][8],以解决这些问题,但这是以牺牲可表达性为代价的。
FRP 的其他公式旨在克服这些实现挑战。
在本章的其余部分,我将交替使用信号和行为。
一阶 FRP
在这个类别中,最著名的响应式语言是 Elm(见elm-lang.org/),这是一种编译为 JavaScript 的 FRP 语言。它由 Evan Czaplicki 创建,并在他的论文《Elm:用于功能 GUI 的并发 FRP》[3]中提出。
Elm 对高阶 FRP 进行了一些重大改进。
它放弃了连续时间这一概念,完全采用事件驱动。因此,它解决了之前提到的无谓重新计算问题。一阶 FRP 将行为和事件结合到信号中,与高阶 FRP 不同,这些信号是离散的。
此外,一阶 FRP 允许程序员指定何时同步处理事件不是必要的,从而防止不必要的处理延迟。
最后,Elm 是一种严格的编程语言——这意味着函数的参数会被积极评估——这是一个有意识的决策,因为它可以防止在像 Haskell 这样的惰性语言中可能出现的空间和时间泄漏。
小贴士
在像 Fran 这样的 FRP 库中,由于在惰性语言中计算被推迟到绝对最后时刻,内存使用可能会变得难以控制,因此导致空间泄漏。这些较大的计算,由于惰性而随着时间的推移积累,最终执行时可能会造成意外的延迟,导致时间泄漏。
异步数据流
异步数据流通常指的是如Reactive Extensions(Rx)、ReactiveCocoa和Bacon.js等框架。之所以称为异步数据流,是因为它完全消除了同步更新。
这些框架引入了可观察序列的概念[4],有时也称为事件流。
这种 FRP 公式的优点是不局限于函数式语言。因此,即使是像 Java 这样的命令式语言也可以利用这种编程风格。
有人说,这些框架导致了 FRP 术语的混淆。Conal Elliott 在某个时候提出了 CES 这个术语(见twitter.com/conal/status/468875014461468677))。
我已经采用了这个术语(见vimeo.com/100688924),因为我相信它突出了两个重要因素:
-
CES 和 FRP 之间的基本区别:CES 完全是事件驱动的
-
CES 通过组合子高度可组合,并从 FRP 中汲取灵感
CES 是本书的主要内容。
箭头化 FRP
这是我们将要查看的最后一个公式。箭头化的 FRP [5] 在高于阶 FRP 上引入了两个主要区别:它使用信号函数而不是信号,并且建立在 John Hughes 的 Arrow 组合子 [6] 之上。
这主要是一种不同的代码结构方式,可以作为库实现。例如,Elm 通过其 Automaton(见github.com/evancz/automaton)库支持箭头化的 FRP。
提示
本章的第一个草稿将 FRP 的不同公式归类为广泛的连续和离散FRP 类别。多亏了 Evan Czaplicki 出色的演讲控制时间和空间:理解 FRP 的多种公式(见www.youtube.com/watch?v=Agu6jipKfYw),我能够借用这里使用的更具体的类别。这些在讨论 FRP 的不同方法时非常有用。
FRP 的应用
不同的 FRP 公式今天被专业人士和大型组织在几个问题空间中使用。在这本书中,我们将查看几个 CES 如何应用的例子。其中一些是相互关联的,因为大多数现代程序都有几个横切关注点,但我们将突出两个主要领域。
异步编程和网络
GUI 是异步编程的一个很好的例子。一旦你打开一个网页或桌面应用程序,它就简单地坐在那里,空闲,等待用户输入。
这种状态通常被称为事件或主事件循环。它只是简单地等待外部刺激,例如按键、鼠标按钮点击、来自网络的新数据,甚至一个简单的计时器。
每个这些刺激都与一个事件处理器相关联,当这些事件发生时,会调用该事件处理器,因此 GUI 系统的异步特性。
这是我们多年来习惯的编程风格,但随着商业和用户需求的增长,这些应用程序的复杂性也在增加,因此需要更好的抽象来处理应用程序所有组件之间的依赖关系。
另一个处理网络流量周围复杂性的优秀例子是 Netflix,它使用 CES 为后端服务提供反应式 API。
复杂的 GUI 和动画
游戏可能是复杂用户界面的最佳例子,因为它们在用户输入和动画方面有复杂的要求。
我们之前提到的 Elm 语言是构建复杂 GUI(图形用户界面)中最激动人心的努力之一。另一个例子是 Flapjax,它也针对 Web 应用程序,但提供的是一个可以与现有 JavaScript 代码库集成的 JavaScript 库。
摘要
响应式编程的核心在于构建响应式应用程序。我们可以通过多种方式使我们的应用程序变得响应式。其中一些是旧有的理念:数据流编程、电子表格和观察者模式都是例子。但 CES 在近年来尤其受到欢迎。
CES 旨在将函数式编程核心中的声明式问题建模方式引入响应式编程。我们应该关注“什么”,而不是“如何”。
在接下来的章节中,我们将学习如何将 CES 应用到我们自己的程序中。
第二章. 概览响应式扩展
响应式扩展或 Rx 是微软的一个响应式编程库,用于构建复杂的异步程序。它将时间变化的值和事件建模为可观察序列,并通过扩展观察者设计模式来实现。
它的第一个目标平台是.NET,但 Netflix 已经将 Rx 移植到 JVM,命名为 RxJava。微软还开发和维护了一个名为 RxJS 的 JavaScript 版本的 Rx,这是我们用来构建正弦波应用的工具。这两个移植对我们来说效果很好,因为 Clojure 运行在 JVM 上,ClojureScript 在 JavaScript 环境中运行。
如我们在第一章中看到的,什么是响应式编程?,Rx 受到了函数式响应式编程的启发,但使用了不同的术语。在 FRP 中,两个主要的抽象是行为和事件。尽管实现细节不同,可观察序列代表事件。Rx 还通过另一种称为BehaviorSubject的数据类型提供了类似行为的抽象。
在本章中,我们将:
-
探索 Rx 的主要抽象:可观察的
-
了解迭代器和可观察之间的双重性
-
创建和操作可观察序列
观察者模式再探
在第一章中,什么是响应式编程?,我们简要概述了观察者设计模式及其在 Clojure 中使用 watch 的简单实现。这是我们的做法:
(def numbers (atom []))
(defn adder [key ref old-state new-state]
(print "Current sum is " (reduce + new-state)))
(add-watch numbers :adder adder)
在前面的例子中,我们的可观察主题是变量numbers,观察者是adder watch。当可观察的内容发生变化时,它会同步地将变化推送到观察者。
现在,让我们对比一下处理序列的工作方式:
(->> [1 2 3 4 5 6]
(map inc)
(filter even?)
(reduce +))
这次,向量是被观察的主题,处理它的函数可以被视为观察者。然而,这在一个基于拉取的模型中工作。向量不会将任何元素推送到序列中。相反,map和其他函数会向序列请求更多元素。这是一个同步操作。
Rx 使序列以及更多内容表现得像可观察的,这样你仍然可以像对普通序列进行函数组合一样对它们进行映射、过滤和组合。
观察者 – Iterator 的对立面
Clojure 的序列操作符,如 map、filter、reduce 等,支持 Java 的 Iterable。正如其名所示,Iterable 是一个可以迭代的对象。在底层,这是通过从这样的对象中检索 Iterator 引用来支持的。Java 的 Iterator 接口看起来如下:
public interface Iterator<E> {
boolean hasNext();
E next();
void remove();
}
当传入实现此接口的对象时,Clojure 的序列操作符通过使用next方法从它那里拉取数据,同时使用hasNext方法知道何时停止。
小贴士
需要使用remove方法从底层集合中移除其最后一个元素。这种就地修改在多线程环境中显然是不安全的。每当 Clojure 为了互操作性实现此接口时,remove方法简单地抛出UnsupportedOperationException。
另一方面,可观察对象有观察者订阅它。观察者有以下接口:
public interface Observer<T> {
void onCompleted();
void onError(Throwable e);
void onNext(T t);
}
如我们所见,实现此接口的观察者将使用从其订阅的任何可观察对象中可用的下一个值调用其onNext方法。因此,它是一个基于推送的通知模型。
如果我们并排查看接口,这种二重性[4]会变得更加清晰:
Iterator<E> { Observer<T> {
boolean hasNext(); void onCompleted();
E next(); void onError(Throwable e);
void remove(); void onNext(T t);
} }
可观察对象提供了让生产者异步地将项目推送到消费者的能力。一些例子将有助于巩固我们的理解。
创建可观察对象
本章全部关于响应式扩展,所以让我们创建一个名为rx-playground的项目,我们将在我们的探索之旅中使用它。我们将使用 RxClojure(见github.com/ReactiveX/RxClojure),这是一个提供RxJava()(见github.com/ReactiveX/RxJava)Clojure 绑定的库:
$ lein new rx-playground
打开项目文件,并添加对 RxJava 的 Clojure 绑定的依赖项:
(defproject rx-playground "0.1.0-SNAPSHOT"
:description "FIXME: write description"
:url "http://example.com/FIXME"
:license {:name "Eclipse Public License"
:url "http://www.eclipse.org/legal/epl-v10.html"}
:dependencies [[org.clojure/clojure "1.5.1"]
[io.reactivex/rxclojure "1.0.0"]])"]])
现在,在项目的根目录中启动一个 REPL,这样我们就可以开始创建一些可观察对象了:
$ lein repl
我们需要做的第一件事是导入 RxClojure,让我们在 REPL 中输入以下内容来解决这个问题:
(require '[rx.lang.clojure.core :as rx])
(import '(rx Observable))
创建新可观察对象的最简单方法是调用justreturn函数:
(def obs (rx/return 10))
现在,我们可以订阅它:
(rx/subscribe obs
(fn [value]
(prn (str "Got value: " value))))
这将在 REPL 中打印字符串"Got value: 10"。
可观察对象的subscribe函数允许我们为其生命周期中的三个主要事件注册处理程序:新值、错误或通知可观察对象已完成值发射。这分别对应于观察者接口的onNext、onError和onCompleted方法。
在前面的例子中,我们只是订阅了onNext,这就是为什么我们只收到了可观察对象的唯一值,即 10。
单值可观察对象并不特别有趣。让我们创建并交互一个发射多个值的可观察对象:
(-> (rx/seq->o [1 2 3 4 5 6 7 8 9 10])
(rx/subscribe prn))
range operator:
(-> (rx/range 1 10)
(rx/subscribe prn))
当然,这还没有在 Clojure 中使用原始值或序列方面展现出任何优势。
但如果我们需要一个在给定间隔内发射未定义数量整数的可观察对象怎么办?在 Clojure 中将这表示为一个序列变得具有挑战性,但 Rx 使其变得简单:
(import '(java.util.concurrent TimeUnit))
(rx/subscribe (Observable/interval 100 TimeUnit/MILLISECONDS)
prn-to-repl)
小贴士
RxClojure 尚未提供对 RxJava API 的所有绑定。interval方法就是这样一个例子。我们需要使用互操作性并直接在 RxJava 的Observable类上调用该方法。
可观察对象的 interval 方法接受一个数字和一个时间单位作为参数。在这种情况下,我们告诉它每 100 毫秒发出一个整数——从零开始。如果我们在一个连接到 REPL 的编辑器中输入这个命令,然而,会发生两件事:
-
我们将看不到任何输出(取决于你的 REPL;这在 Emacs 中是正确的)
-
我们将有一个无限期发出数字的流氓线程
这两个问题都源于 Observable/interval 是我们使用的第一个不同步发出值的工厂方法。相反,它返回一个可观察对象,将工作推迟到另一个线程。
第一个问题很容易解决。像 prn 这样的函数会打印到动态变量 *out* 绑定的任何内容。当在某些 REPL 环境中(如 Emacs)工作时,这绑定到 REPL 流,这就是为什么我们通常能看到我们打印的所有内容。
然而,由于 Rx 将工作推迟到另一个线程,*out* 已经不再绑定到 REPL 流,所以我们看不到输出。为了解决这个问题,我们需要捕获 *out* 的当前值并在我们的订阅中绑定它。这将在我们实验 Rx 的 REPL 中非常有用。因此,让我们为它创建一个辅助函数:
(def repl-out *out*)
(defn prn-to-repl [& args]
(binding [*out* repl-out]
(apply prn args)))
我们首先创建一个变量 repl-out,它包含当前的 REPL 流。接下来,我们创建一个函数 prn-to-repl,它的工作方式与 prn 类似,但它使用 binding 宏创建一个新的绑定,该绑定对 *out* 在该范围内有效。
这仍然留下了流氓线程的问题。现在正是提到从可观察对象的 subscribe 方法返回的订阅对象的时候。通过保留对该对象的引用,我们可以调用它的 unsubscribe 方法来表示我们不再对那个可观察对象产生的值感兴趣。
将所有这些放在一起,我们的间隔示例可以重写如下:
(def subscription (rx/subscribe (Observable/interval 100 TimeUnit/MILLISECONDS)
prn-to-repl))
(Thread/sleep 1000)
(rx/unsubscribe subscription)
我们创建一个新的间隔可观察对象,并立即订阅它,就像我们之前做的那样。然而,这次,我们将结果订阅分配给一个局部变量。注意,它现在使用我们的辅助函数 prn-to-repl,所以我们将立即看到值被打印到 REPL。
接下来,我们让当前的——REPL——线程休眠一秒钟。这足以让可观察对象从 0 到 9 生成数字。大约在这个时候,REPL 线程会醒来并取消订阅该可观察对象,导致它停止发出值。
自定义可观察对象
Rx 提供了许多其他工厂方法来创建可观察对象(见 github.com/ReactiveX/RxJava/wiki/Creating-Observables),但本书的范围不包括它们的所有内容。
尽管如此,有时,内置的工厂方法都不是你想要的。对于这种情况,Rx 提供了 create 方法。我们可以用它从头开始创建一个自定义的可观察对象。
作为例子,我们将创建我们在这个章节中早期使用的 just 可观察对象的自定义版本:
(defn just-obs [v]
(rx/observable*
(fn [observer]
(rx/on-next observer v)
(rx/on-completed observer))))
(rx/subscribe (just-obs 20) prn)
20 to the REPL.
小贴士
虽然创建自定义可观察对象相对直接,但我们应确保首先使用内置的工厂函数,只有在必要时才创建自己的。
操作可观察对象
现在我们知道了如何创建可观察对象,我们应该看看我们可以用它们做什么有趣的事情。在本节中,我们将了解将可观察对象视为序列的含义。
我们从简单的事情开始。让我们从一个包含所有整数的可观察对象中打印出前五个正偶数的和:
(rx/subscribe (->> (Observable/interval 1 TimeUnit/MICROSECONDS)
(rx/filter even?)
(rx/take 5)
(rx/reduce +))
prn-to-repl)
这开始看起来对我们来说非常熟悉。我们创建了一个间隔,它将以每 1 微秒从零开始发射所有正整数。然后,我们过滤出这个可观察对象中的所有偶数。显然,这是一个太大的列表来处理,所以我们只需从其中取出前五个元素。最后,我们使用+来减少值。结果是 20。
为了强调使用可观察对象编程实际上就像操作序列一样,我们将再举一个例子,我们将结合两个不同的可观察序列。一个包含我喜欢的音乐家的名字,另一个是他们各自乐队的名字:
(defn musicians []
(rx/seq->o ["James Hetfield" "Dave Mustaine" "Kerry King"]))
(defn bands []
(rx/seq->o ["Metallica" "Megadeth" "Slayer"]))
我们希望向 REPL 打印一个格式为Musician name – from: band name的字符串。附加的要求是,乐队名称应该以大写形式打印,以产生冲击力。
我们首先创建另一个包含大写乐队名称的可观察对象:
(defn uppercased-obs []
(rx/map (fn [s] (.toUpperCase s)) (bands)))
虽然这不是严格必要的,但这使代码变得可重用,可以在程序中的几个地方使用,从而避免重复。对原始乐队名称感兴趣的订阅者可以继续订阅bands可观察对象。
拥有这两个可观察对象后,我们可以继续将它们组合起来:
(-> (rx/map vector
(musicians)
(uppercased-obs))
(rx/subscribe (fn [[musician band]]
(prn-to-repl (str musician " - from: " band)))))
再次强调,这个例子应该感觉熟悉。我们追求的解决方案是将两个可观察对象一起 zip 的方法。RxClojure 通过 map 提供zip行为,就像 Clojure 的核心map函数一样。我们用三个参数调用它:要 zip 的两个可观察对象以及一个函数,该函数将使用每个可观察对象中的一个元素调用,并返回适当的表示。在这种情况下,我们只是将它们转换成一个向量。
接下来,在我们的订阅者中,我们简单地解构向量以访问音乐家和乐队名称。我们最终可以将最终结果打印到 REPL:
"James Hetfield - from: METALLICA"
"Dave Mustaine - from: MEGADETH"
"Kerry King - from: SLAYER"
平铺映射及其相关
在上一节中,我们学习了如何使用诸如 map、reduce 和zip之类的操作来转换和组合可观察对象。然而,上述两个可观察对象——音乐家和乐队——完全能够独立产生值。它们不需要任何额外的输入。
在本节中,我们将考察一个不同的场景:我们将学习如何组合可观察量,其中一个可观察量的输出是另一个可观察量的输入。我们之前在第一章中遇到了flatmap,什么是响应式编程?如果你一直在想它的作用是什么,本节将正好解答这个问题。
我们将要做的:给定一个表示所有正整数列表的可观察量,我们将计算该列表中所有偶数的阶乘。由于列表太大,我们将从中取五个元素。最终结果应该是 0、2、4、6 和 8 的阶乘,分别对应。
我们首先需要一个函数来计算数字n的阶乘,以及我们的可观察量:
(defn factorial [n]
(reduce * (range 1 (inc n))))
(defn all-positive-integers []
(Observable/interval 1 TimeUnit/MICROSECONDS))
在本节中,使用某种视觉辅助工具将会有所帮助,所以我们将从一个代表之前可观察量的弹珠图开始:

中间的箭头代表时间,它从左到右流动。这个图表代表一个无限的 Observable 序列,正如其末尾使用省略号所指示的那样。
由于我们现在正在组合所有可观察量,我们将创建一个可观察量,给定一个数字,它将使用之前定义的辅助函数发出其阶乘。我们将使用 Rx 的create方法来完成这个目的:
(defn fact-obs [n]
(rx/observable*
(fn [observer]
(rx/on-next observer (factorial n))
(rx/on-completed observer))))
这与我们在本章早期创建的just-obs可观察量非常相似,不同之处在于它计算其参数的阶乘,并发出结果/阶乘,然后立即结束序列。以下图表说明了它是如何工作的:

我们将数字 5 喂给可观察量,它反过来发出其阶乘,即 120。时间线末尾的垂直线表示序列随后终止。
运行代码确认我们的函数是正确的:
(rx/subscribe (fact-obs 5) prn-to-repl)
;; 120
到目前为止一切顺利。现在,我们需要将两个可观察量结合起来以实现我们的目标。这就是 Rx 中的flatmap发挥作用的地方。我们首先将看到它的实际应用,然后进行解释:
(rx/subscribe (->> (all-positive-integers)
(rx/filter even?)
(rx/flatmap fact-obs)
(rx/take 5))
prn-to-repl)
如果我们运行前面的代码,它将打印出 0、2、4、6 和 8 的阶乘,正如我们所希望的那样:
1
2
24
720
40320
filter all even numbers from all-positive-numbers. This leaves us with the following observable sequence:

与所有正整数类似,这同样是一个无限的可观察量。
然而,我们代码的下一行看起来有点奇怪。我们调用flatmap并给它fact-obs函数。我们已知该函数本身返回另一个可观察量。flatmap将使用它发出的每个值调用fact-obs。fact-obs将反过来为每个数字返回一个单值可观察量。然而,我们的订阅者不知道如何处理可观察量!它只是对阶乘感兴趣!
正因如此,在调用fact-obs以获取一个可观察量之后,flatmap将它们全部扁平化为一个我们可以订阅的单个可观察量。这听起来有点复杂,所以让我们可视化一下这意味着什么:

正如你在前面的图中可以看到的,在整个flatmap执行过程中,我们最终得到一个可观察对象的列表。然而,我们并不关心每个可观察对象,而是关心它们发出的值。因此,Flatmap是完美的工具,因为它将它们——扁平化——组合成图底部所示的可观察对象序列。
你可以将flatmap视为可观察序列的mapcat。
代码的其余部分是直接的。我们只需从这个可观察对象中取出前五个元素并订阅它,就像我们迄今为止所做的那样。
道路上的另一个 flatMap
你可能会想知道如果我们正在flatMap的可观察序列发出多个值会发生什么。那么呢?
在我们开始下一节之前,我们将看到一个最后的例子,以说明在这种情况下flatMap的行为。
这是一个发出其参数两次的可观察对象:
(defn repeat-obs [n]
(rx/seq->o (repeat 2 n)))
使用它是直接的:
(-> (repeat-obs 5)
(rx/subscribe prn-to-repl))
;; 5
;; 5
如前所述,我们现在将这个可观察对象与我们之前创建的all-positive-integers可观察对象结合起来。在继续阅读之前,想想对于前三个正整数,你期望的输出是什么。
代码如下:
(rx/subscribe (->> (all-positive-integers)
(rx/flatmap repeat-obs)
(rx/take 6))
prn-to-repl)
输出如下:
0
0
1
1
2
2
对于一些读者来说,结果可能是不预期的。让我们看看这个例子的水滴图,确保我们理解它是如何工作的:

每次调用repeat-obs时,它都会发出两个值并终止。然后flatmap将它们全部组合成一个单一的可观察对象,使之前的输出更清晰。
关于flatmap——以及本节标题——最后值得一提的一点是,它的“朋友”指的是flatmap的几个名称。
例如,Rx.NET 称其为selectMany。RxJava 和 Scala 称其为flatMap——尽管 RxJava 有一个名为mapMany的别名。Haskell 社区称其为bind。尽管它们有不同的名称,但这些函数的语义是相同的,并且是称为 Monad 的高阶抽象的一部分。我们不需要了解任何关于 Monad 的知识来继续。
需要记住的重要一点是,当你坐在酒吧和朋友讨论组合事件系统时,所有这些名称意味着相同的事情。
错误处理
构建可靠应用程序的一个非常重要的方面是知道当事情出错时应该做什么。认为网络是可靠的、硬件不会失败,或者我们作为开发者不会犯错误是幼稚的。
RxJava 接受这个事实,并提供了一套丰富的组合器来处理失败,其中一些我们将在此处进行考察。
OnError
让我们从创建一个表现不佳的可观察对象开始,该对象总是抛出异常:
(defn exceptional-obs []
(rx/observable*
(fn [observer]
(rx/on-next observer (throw (Exception. "Oops. Something went wrong")))
(rx/on-completed observer))))
现在让我们看看如果我们订阅它会发生什么:
(rx/subscribe (->> (exceptional-obs)
(rx/map inc))
(fn [v] (prn-to-repl "result is " v)))
;; Exception Oops. Something went wrong rx-playground.core/exceptional-obs/fn--1505
exceptional-obs抛出的异常没有被捕获,所以它简单地冒泡到 REPL。如果这是一个 Web 应用程序,我们的用户将看到一个 Web 服务器错误,例如HTTP 代码 500 – 内部服务器错误。这些用户可能不会再使用我们的系统。
理想情况下,我们希望有机会优雅地处理这个异常,可能是一个友好的错误消息,让我们的用户知道我们在乎他们。
如我们在本章前面所见,subscribe函数可以接受最多 3 个函数作为参数:
-
第一个,或者说是
onNext处理器,当可观察对象发出新值时被调用 -
第二个,或者说是
onError,每当可观察对象抛出异常时被调用 -
第三个也是最后一个函数,或者说是
onComplete,当可观察对象完成并且不会发出任何新项目时被调用
对于我们的目的,我们感兴趣的是onError处理器,使用它是直接的:
(rx/subscribe (->> (exceptional-obs)
(rx/map inc))
(fn [v] (prn-to-repl "result is " v))
(fn [e] (prn-to-repl "error is " e)))
;; "error is " #<Exception java.lang.Exception: Oops. Something went wrong>
这次,我们不是抛出异常,而是错误处理器被调用并传递给它。这给了我们向用户显示适当消息的机会。
捕获
onError的使用让我们整体上有了更好的体验,但它并不灵活。
让我们想象一个不同的场景,其中有一个可观察对象从网络中检索数据。如果这个观察者失败,我们希望向用户展示一个缓存值而不是错误消息?
这就是catch组合子发挥作用的地方。它允许我们指定一个函数,当可观察对象抛出异常时调用,这与OnError的作用类似。
然而,与onError不同,catch必须返回一个新的可观察对象,该对象将从异常抛出的那一刻起成为新项目的新来源:
(rx/subscribe (->> (exceptional-obs)
(rx/catch Exception e
(rx/return 10))
(rx/map inc))
(fn [v] (prn-to-repl "result is " v)))
;; "result is " 11
在前面的例子中,我们实际上指定了,每当exceptional-obs抛出时,我们应该返回值10。我们不仅限于单个值。实际上,我们可以使用任何我们喜欢的可观察对象作为新来源:
(rx/subscribe (->> (exceptional-obs)
(rx/catch Exception e
(rx/seq->o (range 5)))
(rx/map inc))
(fn [v] (prn-to-repl "result is " v)))
;; "result is " 1
;; "result is " 2
;; "result is " 3
;; "result is " 4
;; "result is " 5
重试
我们将要检查的最后一个是retry错误处理组合子。当我们知道错误或异常只是暂时性的,我们应该给它另一个尝试的机会,通过重新订阅可观察对象。
首先,我们将创建一个可观察对象,当它第一次被订阅时将失败。然而,下次被订阅时,它将成功并发出一个新项目:
(defn retry-obs []
(let [errored (atom false)]
(rx/observable*
(fn [observer]
(if @errored
(rx/on-next observer 20)
(do (reset! errored true)
(throw (Exception. "Oops. Something went wrong"))))))))
让我们看看如果我们简单地订阅它会发生什么:
(rx/subscribe (retry-obs)
(fn [v] (prn-to-repl "result is " v)))
;; Exception Oops. Something went wrong rx-playground.core/retry-obs/fn--1476
如预期的那样,异常就像我们的第一个例子一样简单地冒泡。然而,我们知道——为了这个例子,这是一个暂时性的失败。让我们看看如果我们使用retry会发生什么变化:
(rx/subscribe (->> (retry-obs)
(.retry))
(fn [v] (prn-to-repl "result is " v)))
;; "result is " 20
现在,我们的代码负责重试可观察对象,并且正如预期的那样,我们得到了正确的输出。
重要的是要注意,retry将无限期地尝试重新订阅,直到成功。这可能不是你想要的,所以 Rx 提供了一个变体,称为retryWith,它允许我们指定一个谓词函数,以控制何时以及是否应该停止重试。
所有这些算子都为我们提供了构建可靠反应式应用程序所需的工具,我们应当始终牢记它们,毫无疑问,它们是我们工具箱中的一大补充。关于这方面的更多信息,请参考 RxJava 的维基页面:github.com/ReactiveX/RxJava/wiki/Error-Handling-Operators。
背压
我们可能还会遇到的问题是,可观察对象生成项目比我们消费得快。在这种情况下出现的问题是如何处理这个不断增长的项的积压。
例如,考虑将两个可观察对象一起压缩。zip操作符(或在 RxClojure 中的map)只有在所有可观察对象都发出一个项目时才会发出新的值。
因此,如果其中一个可观察对象在生成项目方面比其他项目快得多,map将需要缓冲这些项目并等待其他项目,这很可能会引起错误,如下所示:
(defn fast-producing-obs []
(rx/map inc (Observable/interval 1 TimeUnit/MILLISECONDS)))
(defn slow-producing-obs []
(rx/map inc (Observable/interval 500 TimeUnit/MILLISECONDS)))
(rx/subscribe (->> (rx/map vector
(fast-producing-obs)
(slow-producing-obs))
(rx/map (fn [[x y]]
(+ x y)))
(rx/take 10))
prn-to-repl
(fn [e] (prn-to-repl "error is " e)))
;; "error is " #<MissingBackpressureException rx.exceptions.MissingBackpressureException>
如前述代码所示,我们有一个快速生成的可观察对象,其发出项目比较慢的可观察对象快 500 倍。显然,我们无法跟上它,Rx 确实会抛出MissingBackpressureException。
这个异常告诉我们的是,快速生成的可观察对象不支持任何类型的背压——Rx 称之为反应式拉背压——也就是说,消费者不能让它放慢速度。幸运的是,Rx 为我们提供了在这些场景中有用的组合器。
样本
其中一个组合器是sample,它允许我们在给定的时间间隔内采样一个可观察对象,从而限制源可观察对象的输出。让我们将其应用到之前的例子中:
(rx/subscribe (->> (rx/map vector
(.sample (fast-producing-obs) 200
TimeUnit/MILLISECONDS)
(slow-producing-obs))
(rx/map (fn [[x y]]
(+ x y)))
(rx/take 10))
prn-to-repl
(fn [e] (prn-to-repl "error is " e)))
;; 204
;; 404
;; 604
;; 807
;; 1010
;; 1206
;; 1407
;; 1613
;; 1813
;; 2012
唯一的改变是我们先在快速生成的可观察对象上调用sample,然后再调用map。我们将每 200 毫秒采样一次。
通过忽略在这个时间片中发出的所有其他项目,我们已经减轻了最初的问题,即使原始的可观察对象不支持任何形式的背压。
样本组合器只是在这种情况下有用的组合器之一。其他还包括throttleFirst、debounce、buffer和window。然而,这种方法的一个缺点是,很多生成的项目最终都被忽略了。
根据我们构建的应用程序类型,这可能是一个可接受的折衷方案。但如果我们对所有项目都感兴趣呢?
背压策略
如果一个可观察对象不支持背压,但我们仍然对其发出的所有项目感兴趣,我们可以使用 Rx 提供的内置背压组合器之一。
作为例子,我们将查看一个这样的组合器,onBackpressureBuffer:
(rx/subscribe (->> (rx/map vector
(.onBackpressureBuffer (fast-producing-obs))
(slow-producing-obs))
(rx/map (fn [[x y]]
(+ x y)))
(rx/take 10))
prn-to-repl
(fn [e] (prn-to-repl "error is " e)))
;; 2
;; 4
;; 6
;; 8
;; 10
;; 12
;; 14
;; 16
;; 18
;; 20
这个例子与使用sample的例子非常相似,但输出却相当不同。这次我们得到了两个可观察对象发出的所有项目。
onBackpressureBuffer策略实现了一种简单的策略,即缓冲所有由较慢的 Observable 发出的项目,并在消费者准备好时发出它们。在我们的例子中,这每 500 毫秒发生一次。
其他策略包括onBackpressureDrop和onBackpressureBlock。
值得注意的是,响应式拉回压力(Reactive pull backpressure)仍在开发中,要了解最新进展的最佳方式是查看 RxJava 主题的 wiki 页面:github.com/ReactiveX/RxJava/wiki/Backpressure.
摘要
在本章中,我们深入探讨了 RxJava,它是微软从.NET 的响应式扩展移植过来的。我们了解了其主要抽象,即可观察对象(observable),以及它与可迭代对象的关系。
我们还学习了如何以多种方式创建、操作和组合可观察对象。这里展示的例子是为了保持简单而设计的。尽管如此,所有提出的概念在现实应用中都非常有用,并将在我们下一章中派上用场,那时我们将在一个更实际的例子中使用它们。
最后,我们通过查看错误处理和回压(backpressure),这两者都是可靠应用程序的重要特性,应该始终牢记。
第三章。异步编程和网络
几个商业应用程序需要异步地对外部刺激——如网络流量——做出反应。这类软件的一个例子可能是允许我们跟踪股市中公司股价的桌面应用程序。
我们将首先使用更传统的方法来构建这个应用程序。这样做时,我们将:
-
能够识别和理解第一种设计的缺点
-
学习如何使用 RxClojure 处理如滚动平均值这样的有状态计算
-
使用可观察序列以声明性方式重写示例,从而减少我们第一种方法中发现的复杂性
构建股票市场监控应用程序
我们的股票市场程序将包括三个主要组件:
-
一个模拟外部服务的函数,我们可以从中查询当前价格——在现实情况下这很可能是网络调用
-
一个以预定义间隔轮询先前函数的调度器
-
负责更新屏幕的显示函数
我们将首先创建一个新的 Leiningen 项目,我们的应用程序源代码将存储在这里。在命令行中输入以下内容,然后切换到新创建的目录:
lein new stock-market-monitor
cd stock-market-monitor
由于我们将为该应用程序构建一个 GUI,请在project.clj的依赖项部分添加对 Seesaw 的依赖项:
[seesaw "1.4.4"]
接下来,在您最喜欢的编辑器中创建一个src/stock_market_monitor/core.clj文件。让我们创建和配置我们应用程序的 UI 组件:
(ns stock-market-monitor.core
(:require [seesaw.core :refer :all])
(:import (java.util.concurrent ScheduledThreadPoolExecutor
TimeUnit)))
(native!)
(def main-frame (frame :title "Stock price monitor"
:width 200 :height 100
:on-close :exit))
(def price-label (label "Price: -"))
(config! main-frame :content price-label)
如您所见,用户界面相当简单。它由一个标签组成,将显示公司的股价。我们还导入了两个 Java 类,ScheduledThreadPoolExecutor和TimeUnit,我们将在稍后使用它们。
下一步,我们需要的是我们的轮询机制,这样我们就可以在给定的时间表上调用定价服务。我们将通过线程池来实现这一点,以避免阻塞主线程:
小贴士
用户界面 SDK,如 Swing,有主线程或 UI 线程的概念。这是 SDK 用来将 UI 组件渲染到屏幕上的线程。因此,如果我们在这个线程中执行阻塞操作——甚至只是运行缓慢的操作——用户体验将受到严重影响,这就是为什么使用线程池来卸载昂贵的函数调用。
(def pool (atom nil))
(defn init-scheduler [num-threads]
(reset! pool (ScheduledThreadPoolExecutor. num-threads)))
(defn run-every [pool millis f]
(.scheduleWithFixedDelay pool
f
0 millis TimeUnit/MILLISECONDS))
(defn shutdown [pool]
(println "Shutting down scheduler...")
(.shutdown pool))
init-scheduler函数使用给定的线程数创建ScheduledThreadPoolExecutor。这就是我们的周期性函数将运行的线程池。run-every函数在给定的pool中安排函数f以millis指定的间隔运行。最后,shutdown是一个在程序终止时将被调用的函数,它将优雅地关闭线程池。
程序的其余部分将这些部分组合在一起:
(defn share-price [company-code]
(Thread/sleep 200)
(rand-int 1000))
(defn -main [& args]
(show! main-frame)
(.addShutdownHook (Runtime/getRuntime)
(Thread. #(shutdown @pool)))
(init-scheduler 1)
(run-every @pool 500
#(->> (str "Price: " (share-price "XYZ"))
(text! price-label)
invoke-now)))
share-price函数暂停 200 毫秒以模拟网络延迟,并返回一个介于 0 和 1,000 之间的随机整数,代表股票的价格。
我们-main函数的第一行向运行时添加了一个关闭钩子。这允许我们的程序拦截终止——例如在终端窗口中按下Ctrl + C——并给我们机会关闭线程池。
小贴士
ScheduledThreadPoolExecutor池默认创建非守护线程。如果除了程序的主线程外还有任何非守护线程存活,程序就无法终止。这就是为什么关闭钩子是必要的。
接下来,我们使用单个线程初始化调度器,并安排每 500 毫秒执行一个函数。这个函数会请求 XYZ 的当前股价,并更新标签。
小贴士
桌面应用程序需要在 UI 线程中完成所有渲染。然而,我们的周期性函数在单独的线程上运行,并需要更新价格标签。这就是为什么我们使用invoke-now,这是一个 Seesaw 函数,它将主体调度到尽可能快地执行在 UI 线程中。
在项目的根目录中输入以下命令来运行程序:
lein trampoline run -m stock-market-monitor.core
小贴士
Trampolining 告诉 leiningen 不要将我们的程序 JVM 嵌套在其内部,这样我们就可以通过关闭钩子自己处理Ctrl + C的使用。
将会显示一个类似于以下截图的窗口,其上的值将根据之前实现的计划进行更新:

这是一个不错的解决方案。代码相对简单,并且满足我们的原始要求。然而,如果我们从大局来看,我们的程序中存在相当多的噪声。其中大部分代码行都在处理创建和管理线程池,虽然这是必要的,但并不是我们解决问题的核心——这是一个实现细节。
目前我们将保持现状,并添加一个新要求:滚动平均值。
滚动平均值
现在我们可以看到给定公司的最新股价,显示过去,比如说,五次股价的滚动平均值是有意义的。在现实场景中,这将提供一个公司股票市场趋势的客观视图。
让我们扩展我们的程序以适应这个新要求。
首先,我们需要修改我们的命名空间定义:
(ns stock-market-monitor.core
(:require [seesaw.core :refer :all])
(:import (java.util.concurrent ScheduledThreadPoolExecutor
TimeUnit)
(clojure.lang PersistentQueue)))
唯一的改变是一个新的导入语句,用于 Clojure 的PersistentQueue类。我们稍后会使用它。
我们还需要一个新的标签来显示当前的运行平均值:
(def running-avg-label (label "Running average: -"))
(config! main-frame :content
(border-panel
:north price-label
:center running-avg-label
:border 5))
接下来,我们需要一个函数来计算滚动平均值。滚动平均——或移动平均——是统计学中的一个计算,其中你取数据集中一个子集的平均值。这个子集具有固定的大小,并且随着数据的到来而向前移动。通过一个例子,这会变得清楚。
假设你有一个包含从 1 到 10(包括 10)的数字列表。如果我们使用3作为子集大小,滚动平均值如下:
[1 2 3 4 5 6 7 8 9 10] => 2.0
[1 2 3 4 5 6 7 8 9 10] => 3.0
[1 2 3 4 5 6 7 8 9 10] => 4.0
上一段代码中突出显示的部分显示了当前用于计算子集平均值的窗口。
既然我们已经知道了滚动平均值是什么,我们就可以继续在我们的程序中实现它:
(defn roll-buffer [buffer num buffer-size]
(let [buffer (conj buffer num)]
(if (> (count buffer) buffer-size)
(pop buffer)
buffer)))
(defn avg [numbers]
(float (/ (reduce + numbers)
(count numbers))))
(defn make-running-avg [buffer-size]
(let [buffer (atom clojure.lang.PersistentQueue/EMPTY)]
(fn [n]
(swap! buffer roll-buffer n buffer-size)
(avg @buffer))))
(def running-avg (running-avg 5))
roll-buffer函数是一个实用函数,它接受一个队列、一个数字和一个缓冲区大小作为参数。它将那个数字添加到队列中,如果队列超过缓冲区限制,则弹出最旧的元素,从而使其内容滚动。
接下来,我们有一个用于计算数字集合平均值的函数。如果除法不均匀,我们将结果转换为浮点数。
最后,高阶make-running-avg函数返回一个有状态的、单参数函数,它封装了一个空的持久队列。这个队列用于跟踪当前的数据子集。
我们通过调用它并使用缓冲区大小为 5 来创建这个函数的实例,并将其保存到running-avg变量中。每次我们用数字调用这个新函数时,它都会使用roll-buffer函数将其添加到队列中,然后最终返回队列中项目的平均值。
我们编写的用于管理线程池的代码将原样重用,所以我们剩下要做的就是更新我们的周期性函数:
(defn worker []
(let [price (share-price "XYZ")]
(->> (str "Price: " price) (text! price-label))
(->> (str "Running average: " (running-avg price))
(text! running-avg-label))))
(defn -main [& args]
(show! main-frame)
(.addShutdownHook (Runtime/getRuntime)
(Thread. #(shutdown @pool)))
(init-scheduler 1)
(run-every @pool 500
#(invoke-now (worker))))
由于我们的函数不再是单行代码,我们将其抽象到它自己的函数worker中。就像之前一样,它更新价格标签,但我们还扩展了它以使用之前创建的running-avg函数。
我们准备好再次运行程序:
lein trampoline run -m stock-market-monitor.core
你应该看到,就像以下截图所示的一个窗口:

你应该看到,除了显示 XYZ 的当前股价之外,程序还跟踪并刷新价格流的运行平均值。
识别我们当前方法的问题
除了负责构建用户界面的代码行之外,我们的程序大约有 48 行长。
程序的核心在于share-price和avg函数,它们分别负责查询价格服务和计算一列n个数字的平均值。它们只代表六行代码。在这个小程序中有很多附加复杂性。
附加复杂性是由与当前问题无关的代码引起的复杂性。在这个例子中,我们有两种这样的复杂性的来源——在这个讨论中,我们忽略了特定于 UI 的代码:线程池和滚动缓冲区函数。它们给阅读和维护代码的人带来了大量的认知负担。
线程池与我们的问题无关。它只关注如何异步运行任务的语义。滚动缓冲区函数指定了队列的详细实现以及如何使用它来表示该概念。
理想情况下,我们应该能够抽象出这些细节,专注于我们问题的核心;组合事件系统(CES)正允许我们做到这一点。
使用 RxClojure 移除偶然复杂性
在第二章中,我们学习了 RxClojure 这个开源 CES 框架的基本构建块——《反应式扩展概述》。在本节中,我们将利用这些知识来从我们的程序中移除偶然的复杂性。这将使我们能够以清晰、声明性的方式显示价格和滚动平均值。
我们编写的 UI 代码保持不变,但我们需要确保在project.clj文件的依赖关系部分声明了 RxClojure:
[io.reactivex/rxclojure "1.0.0"]
然后,确保我们引入以下库:
(ns stock-market-monitor.core
(:require [rx.lang.clojure.core :as rx]
[seesaw.core :refer :all])
(:import (java.util.concurrent TimeUnit)
(rx Observable)))
这次我们解决问题的方法也有所不同。让我们看看第一个要求:它要求我们显示一家公司在股市中的当前股价。
每次我们查询价格服务时,我们都会得到一个——可能是不同的一—关于相关公司的价格。正如我们在第二章中看到的那样,将其建模为可观察序列是很容易的,所以我们将从这里开始。我们将创建一个函数,为我们提供给定公司的股价可观察对象:
(defn make-price-obs [company-code]
(rx/return (share-price company-code)))
这是一个产生单个值并终止的可观察对象。它等同于以下弹珠图:

第一部分要求之一是我们需要在预定义的时间间隔内查询服务——在这个例子中是每 500 毫秒一次。这暗示了我们之前遇到的一个名为interval的可观察对象。为了获得我们想要的轮询行为,我们需要将 interval 和价格可观察对象结合起来。
你可能还记得,flatmap是完成这项工作的工具:
(rx/flatmap (fn [_] (make-price-obs "XYZ"))
(Observable/interval 500
TimeUnit/MILLISECONDS))
实际上,我们可以简单地订阅这个新的可观察对象并测试它。将你的主函数修改为以下片段并运行程序:
(defn -main [& args]
(show! main-frame)
(let [price-obs (rx/flatmap (fn [_] (make-price-obs "XYZ"))
(Observable/interval 500 TimeUnit/MILLISECONDS))]
(rx/subscribe price-obs
(fn [price]
(text! price-label (str "Price: " price))))))
这非常酷!我们只用几行代码就复制了我们第一个程序的行为。最好的部分是,我们不必担心线程池或调度操作。通过将问题视为可观察序列,以及结合现有和新可观察对象,我们能够声明性地表达我们想要程序执行的操作。
这已经为我们提供了在可维护性和可读性方面的巨大好处。然而,我们仍然缺少程序的一半:滚动平均值。
可观察滚动平均值
可能并不立即明显我们如何将滚动平均值建模为可观察对象。我们需要记住的是,几乎所有我们可以将其视为值序列的东西,我们都可以将其建模为可观察序列。
滚动平均值也没有什么不同。让我们暂时忘记价格是从一个封装在可观察对象中的网络调用中来的。让我们想象我们有一个 Clojure 向量中所有我们关心的值:
(def values (range 10))
我们需要一种方法来以大小为 5 的分区(或缓冲区)处理这些值,这样在每次交互中只丢弃一个值。在 Clojure 中,我们可以使用partition函数来完成这个目的:
(doseq [buffer (partition 5 1 values)]
(prn buffer))
(0 1 2 3 4)
(1 2 3 4 5)
(2 3 4 5 6)
(3 4 5 6 7)
(4 5 6 7 8)
...
partition函数的第二个参数被称为步长,它是开始新分区前应跳过的项目偏移量。在这里,我们将它设置为 1,以便创建我们需要的滑动窗口效果。
那么,接下来的大问题是:我们在处理可观察序列时能否以某种方式利用partition?
事实上,RxJava 有一个名为buffer的转换器,正是为此目的。前面的例子可以重写如下:
(-> (rx/seq->o (vec (range 10)))
(.buffer 5 1)
(rx/subscribe
(fn [price]
(prn (str "Value: " price)))))
小贴士
如前所述,并非所有 RxJava 的 API 都通过 RxClojure 暴露,因此在这里我们需要使用互操作来从可观察序列访问buffer方法。
如前所述,buffer的第二个参数是偏移量,但在 RxJava 文档中被称为skip。如果你在 REPL 中运行它,你会看到以下输出:
"Value: [0, 1, 2, 3, 4]"
"Value: [1, 2, 3, 4, 5]"
"Value: [2, 3, 4, 5, 6]"
"Value: [3, 4, 5, 6, 7]"
"Value: [4, 5, 6, 7, 8]"
...
这正是我们想要的。唯一的区别是,缓冲方法会等待直到它有足够的元素——在这个例子中是五个——然后才继续。
现在,我们可以回到我们的程序,并在主函数中融入这个想法。它看起来是这样的:
(defn -main [& args]
(show! main-frame)
(let [price-obs (-> (rx/flatmap make-price-obs
(Observable/interval 500 TimeUnit/MILLISECONDS))
(.publish))
sliding-buffer-obs (.buffer price-obs 5 1)]
(rx/subscribe price-obs
(fn [price]
(text! price-label (str "Price: " price))))
(rx/subscribe sliding-buffer-obs
(fn [buffer]
(text! running-avg-label (str "Running average: " (avg buffer)))))
(.connect price-obs)))
price-obs, we had created before. The new sliding buffer observable is created using the buffer transformer on price-obs.
然后,我们可以独立订阅每个序列,以便更新价格和滚动平均值标签。运行程序将显示我们之前看到的相同屏幕:

你可能注意到了两个之前没有见过的方法调用:publish 和 connect。
publish方法返回一个可连接的可观察序列。这意味着可观察序列不会开始发出值,直到其connect方法被调用。我们在这里这样做是因为我们想确保所有订阅者都能收到原始可观察序列发出的所有值。
总结来说,我们没有添加太多额外的代码,就以一种简洁、声明性的方式实现了所有要求,这使得代码易于维护和跟踪。我们还将之前的 roll-buffer 函数完全变得不再必要。
程序 CES 版本的完整源代码在此提供供参考:
(ns stock-market-monitor.05frp-price-monitor-rolling-avg
(:require [rx.lang.clojure.core :as rx]
[seesaw.core :refer :all])
(:import (java.util.concurrent TimeUnit)
(rx Observable)))
(native!)
(def main-frame (frame :title "Stock price monitor"
:width 200 :height 100
:on-close :exit))
(def price-label (label "Price: -"))
(def running-avg-label (label "Running average: -"))
(config! main-frame :content
(border-panel
:north price-label
:center running-avg-label
:border 5))
(defn share-price [company-code]
(Thread/sleep 200)
(rand-int 1000))
(defn avg [numbers]
(float (/ (reduce + numbers)
(count numbers))))
(defn make-price-obs [_]
(rx/return (share-price "XYZ")))
(defn -main [& args]
(show! main-frame)
(let [price-obs (-> (rx/flatmap make-price-obs
(Observable/interval 500 TimeUnit/MILLISECONDS))
(.publish))
sliding-buffer-obs (.buffer price-obs 5 1)]
(rx/subscribe price-obs
(fn [price]
(text! price-label (str "Price: " price))))
(rx/subscribe sliding-buffer-obs
(fn [buffer]
(text! running-avg-label (str "Running average: " (avg buffer)))))
(.connect price-obs)))
注意,在这个程序版本中,我们不需要使用关闭钩子。这是因为 RxClojure 创建了守护线程,一旦应用程序退出,这些线程会自动终止。
摘要
在本章中,我们使用我们的股票市场程序模拟了一个现实世界应用。我们以某种传统的方式编写了它,使用了线程池和自定义队列实现。然后我们将其重构为 CES 风格,使用了 RxClojure 的可观察序列。
一旦熟悉了 RxClojure 和 RxJava 的核心概念,生成的程序将更短、更简单、更容易阅读。
在下一章中,我们将介绍 core.async,为实施我们自己的基本 CES 框架做准备。
第四章。核心异步简介
那些程序一次只能做一件事的日子已经一去不复返了。能够同时执行多个任务是大多数现代商业应用的核心。这就是异步编程的用武之地。
异步编程——更普遍地说,并发——是关于利用你的硬件资源比以前做得更多。这意味着在等待结果的同时从网络或数据库连接中获取数据。或者,也许在用户仍然可以操作图形界面时,将 Excel 电子表格读入内存。总的来说,它提高了系统的响应性。
在本章中,我们将探讨不同平台如何处理这种编程风格。更具体地说,我们将:
-
了解核心异步的背景和 API
-
通过以核心异步的抽象实现股票市场应用程序来巩固我们对核心异步的理解
-
理解核心异步如何处理错误处理和背压
-
简短地浏览一下转换器
异步编程和并发
不同的平台有不同的编程模型。例如,JavaScript 应用程序是单线程的,并且有一个事件循环。在发起网络调用时,通常会在稍后阶段注册一个回调,当网络调用成功或出错时会被调用。
与此相反,当我们处于 JVM 上时,我们可以充分利用多线程来实现并发。通过 Clojure 提供的许多并发原语之一(如 futures)来创建新线程非常简单。
然而,异步编程变得繁琐。Clojure futures 没有提供一种原生的方式让我们在稍后阶段通知它们的完成。此外,从尚未完成的 future 中检索值是一个阻塞操作。这可以在以下代码片段中清楚地看到:
(defn do-something-important []
(let [f (future (do (prn "Calculating...")
(Thread/sleep 10000)))]
(prn "Perhaps the future has done its job?")
(prn @f)
(prn "You will only see this in about 10 seconds...")))
(do-something-important)
第二次调用打印时,会取消引用 future,由于它尚未完成,这导致主线程阻塞。这就是为什么只有在 future 运行的线程完成后,你才会看到最后的打印。当然,可以通过为第一个线程创建一个单独的线程来模拟回调,但这个解决方案最多是笨拙的。
在 Clojure 中,GUI 编程是缺乏回调的例外。与 JavaScript 类似,Clojure Swing 应用程序也拥有事件循环,可以响应用户输入并调用监听器(回调)来处理它们。
另一个选项是用一个传递给 future 的自定义回调重写前面的示例:
(defn do-something-important [callback]
(let [f (future (let [answer 42]
(Thread/sleep 10000)
(callback answer)))]
(prn "Perhaps the future has done its job?")
(prn "You should see this almost immediately and then in 10 secs...")
f))
(do-something-important (fn [answer]
(prn "Future is done. Answer is " answer)))
这次输出的顺序应该更有意义。然而,如果我们从这个函数返回 future,我们就无法给它另一个回调。我们失去了在 future 结束时执行操作的能力,又回到了必须取消引用它,从而再次阻塞主线程——这正是我们想要避免的。
小贴士
Java 8 引入了一个新的类,CompletableFuture,它允许注册一个在完成时被调用的回调。如果你有这个选项,你可以使用互操作来让 Clojure 利用这个新类。
如你所意识到的那样,CES(Continuous Event Stream)与异步编程密切相关:我们在上一章中构建的股票市场应用就是一个这样的程序示例。主线程(或 UI 线程)永远不会因为从网络获取数据而阻塞。此外,我们还在订阅时注册了回调。
然而,在许多异步应用程序中,回调并不是最佳选择。过度使用回调可能导致所谓的回调地狱。Clojure 提供了一个更强大、更优雅的解决方案。
在接下来的几节中,我们将探讨core.async,这是一个用于异步编程的 Clojure 库,以及它与响应式编程的关系。
core.async
如果你曾经进行过任何数量的 JavaScript 编程,你可能已经经历过回调地狱。如果你没有,以下代码应该能给你一个很好的概念:
http.get('api/users/find?name=' + name, function(user){
http.get('api/orders?userId=' + user.id, function(orders){
orders.forEach(function(order){
container.append(order);
});
});
});
这种编程风格很容易失控——不是编写更自然、顺序的步骤来实现任务,而是将逻辑分散到多个回调中,增加了开发者的认知负担。
作为对这个问题的回应,JavaScript 社区发布了几个承诺库,旨在解决这个问题。我们可以将承诺视为我们可以传递到和从我们的函数中返回的空盒子。在未来的某个时刻,另一个进程可能会在这个盒子里放入一个值。
例如,前面的片段可以用以下承诺来编写:
http.get('api/users/find?name=' + name)
.then(function(user){
return http.get('api/orders?userId=' + user.id);
})
.then(function(orders){
orders.forEach(function(order){
container.append(order);
});
});
then function is a public function of the promises API. It is definitely a step in the right direction as the code is composable and easier to read.
然而,由于我们倾向于按步骤序列思考,我们希望编写以下内容:
user = http.get('api/users/find?name=' + name);
orders = http.get('api/orders?userId=' + user.id);
orders.forEach(function(order){
container.append(order);
});
尽管代码看起来是同步的,但行为应该与前面的示例没有不同。这正是core.async让我们在 Clojure 和 ClojureScript 中都能做到的。
通信顺序过程
core.async库建立在旧想法之上。它所依赖的基础最初由 Tony Hoare——著名的快速排序算法的发明者——在他的 1978 年论文《通信顺序过程》(Communicating Sequential Processes,简称 CSP)中描述。(见www.cs.ucf.edu/courses/cop4020/sum2009/CSP-hoare.pdf)。CSP 自那时以来已在几种语言中得到扩展和实现,其中最新的是 Google 的Go编程语言。
本书的范围不包括深入探讨这篇开创性论文的细节,因此以下是对主要思想的简化描述。
在 CSP(Communicating Sequential Processes)中,工作是通过两种主要抽象来建模的:通道和进程。CSP 也是消息驱动的,因此它完全解耦了消息的生产者和消费者。将通道视为阻塞队列是有用的。
以下是一个展示这些基本抽象的简单方法:
(import 'java.util.concurrent.ArrayBlockingQueue)
(defn producer [c]
(prn "Taking a nap")
(Thread/sleep 5000)
(prn "Now putting a name in queue...")
(.put c "Leo"))
(defn consumer [c]
(prn "Attempting to take value from queue now...")
(prn (str "Got it. Hello " (.take c) "!")))
(def chan (ArrayBlockingQueue. 10))
(future (consumer chan))
(future (producer chan))
在 REPL 中运行此代码应该会显示类似于以下内容的输出:
"Attempting to take value from queue now..."
"Taking a nap"
;; then 5 seconds later
"Now putting a name in que queue..."
"Got it. Hello Leo!"
为了不阻塞我们的程序,我们使用一个未来在每个自己的线程中启动消费者和生成者。由于消费者首先启动,我们很可能会立即看到其输出。然而,一旦它尝试从通道或队列中获取值,它就会阻塞。它将等待值变得可用,并且只有在生成者完成其小憩后才会继续——显然这是一个非常重要的任务。
现在,让我们将其与使用 core.async 的解决方案进行比较。首先,创建一个新的 leiningen 项目并添加对其的依赖:
[org.clojure/core.async "0.1.278.0-76b25b-alpha"]
现在,在 REPL 或你的核心命名空间中输入以下内容:
(ns core-async-playground.core
(:require [clojure.core.async :refer [go chan <! >! timeout]]))
(defn prn-with-thread-id [s]
(prn (str s " - Thread id: " (.getId (Thread/currentThread)))))
(defn producer [c]
(go (prn-with-thread-id "Taking a nap ")
(<! (timeout 5000))
(prn-with-thread-id "Now putting a name in que queue...")
(>! c "Leo")))
(defn consumer [c]
(go (prn-with-thread-id "Attempting to take value from queue now...")
(prn-with-thread-id (str "Got it. Hello " (<! c) "!"))))
(def c (chan))
(consumer c)
(producer c)
这次我们使用了一个辅助函数,prn-with-thread-id,它将当前线程 ID 附加到输出字符串中。我很快就会解释原因,但除此之外,输出将与之前的一个相同:
"Attempting to take value from queue now... - Thread id: 43"
"Taking a nap - Thread id: 44"
"Now putting a name in que queue... - Thread id: 48"
"Got it. Hello Leo! - Thread id: 48"
从结构上看,这两个解决方案看起来相当相似,但由于我们在这里使用了相当多的新函数,让我们将其分解:
-
chan是一个创建core.async通道的函数。如前所述,它可以被视为一个并发阻塞队列,并且是库中的主要抽象。默认情况下chan创建一个无界通道,但core.async提供了许多更多有用的通道构造函数,其中一些我们将在稍后使用。 -
timeout是另一个这样的通道构造函数。它给我们一个会在返回 nil 给获取进程之前等待给定时间的通道,并在之后立即关闭自己。这是core.async中 Thread/sleep 的等效函数。 -
函数
>!和<!分别用于向通道中放入和从通道中取出值。需要注意的是,它们必须在使用go块的内部使用,正如我们稍后将要解释的。 -
go是一个宏,它接受一个表达式体——形成一个go块——并创建轻量级进程。这就是魔法发生的地方。在go块内部,任何调用>!和<!以等待通道中可用值的操作都会被挂起。挂起是core.async状态机内部使用的特殊类型的阻塞。Huey Petersen 的博客文章深入探讨了这一状态机(见hueypetersen.com/posts/2013/08/02/the-state-machines-of-core-async/)。
Go 块正是我选择在我们的示例中打印线程 ID 的原因。如果我们仔细观察,我们会意识到最后两个语句是在同一个线程中执行的——这并不总是 100%正确,因为并发本质上是非确定性的。这是 core.async 与使用线程/未来的解决方案之间的基本区别。
线程可能很昂贵。在 JVM 上,它们的默认堆栈大小是 512 千字节——可以通过-Xss JVM 启动选项进行配置。在开发高度并发的系统时,创建数千个线程可以迅速耗尽应用程序运行的机器的资源。
core.async承认了这个限制,并为我们提供了轻量级进程。内部,它们确实共享一个线程池,但与为每个 go 块无谓地创建一个线程不同,当 put/take 操作等待一个值变得可用时,线程会被回收和重用。
小贴士
在撰写本文时,core.async使用的线程池默认为可用处理器的数量乘以 2,加 42。因此,具有八个处理器的机器将有一个包含 58 个线程的池。
因此,对于core.async应用程序来说,拥有数万个轻量级进程是很常见的。它们创建起来非常便宜。
由于这是一本关于响应式编程的书,你现在可能心中会有的问题是:我们能否使用core.async构建响应式应用程序?简短的答案是:是的,我们可以!为了证明这一点,我们将重新审视我们的股票市场应用程序,并使用core.async重写它。
使用 core.async 重写股票市场应用
通过使用我们熟悉的例子,我们能够专注于迄今为止讨论的所有方法之间的差异,而不会因为新的、具体的领域规则而分心。
在我们深入实施之前,让我们快速概述一下我们的解决方案应该如何工作。
就像我们之前的实现一样,我们有一个可以查询股票价格的服务。然而,我们的方法的不同之处是一个直接的后果,即core.async通道的工作方式。
在给定的计划中,我们希望将当前价格写入core.async通道。这可能看起来是这样的:

此过程将持续将价格放入out通道。对于每个价格,我们需要做两件事:显示它并显示计算出的滑动窗口。由于我们喜欢我们的函数解耦,我们将使用两个go块,一个用于每个任务:

等一下。我们的方法似乎有些不对劲。一旦我们从输出通道中取出一个价格,它就不再可用,无法被其他 go 块获取,因此,我们的函数最终得到的是第二个值,20。使用这种方法,我们将得到一个大约每隔一个项目计算滑动窗口的滑动窗口,这取决于 go 块之间的交织程度是否一致。
显然,这并不是我们想要的,但它有助于我们更深入地思考问题。core.async的语义阻止我们从通道中读取值超过一次。大多数时候,这种行为都是可以接受的——尤其是如果你把它们看作队列的话。那么我们如何为两个函数提供相同的值呢?
为了解决这个问题,我们将利用core.async提供的另一个通道构造函数,称为broadcast。正如其名所示,broadcast返回一个通道,当写入时,将其值写入作为参数传递给它的通道。实际上,这改变了我们的高级图示,如下所示:

总结来说,我们将尝试将价格写入这个广播通道,然后它将把其值转发到我们将从中操作的两个通道:价格和滑动窗口。
在确立了总体思路之后,我们就可以深入代码了。
实现应用程序代码
我们已经在上一节中创建了一个依赖于core.async的项目,所以我们将基于那个项目进行工作。让我们首先在project.clj文件中添加对 seesaw 的额外依赖项:
:dependencies [[org.clojure/clojure "1.5.1"]
[org.clojure/core.async "0.1.278.0-76b25b-alpha"]
[seesaw "1.4.4"]]
接下来,在src目录中创建一个名为stock_market.clj的文件,并添加以下命名空间声明:
(ns core-async-playground.stock-market
(:require [clojure.core.async
:refer [go chan <! >! timeout go-loop map>] :as async])
(:require [clojure.core.async.lab :refer [broadcast]])
(:use [seesaw.core]))
如果您还没有这样做,现在可能是重启您的 REPL 的好时机。不用担心我们还没有看到的任何函数。在本节中,我们将了解它们。
GUI 代码基本保持不变,所以对于下一个代码片段不需要解释:
(native!)
(def main-frame (frame :title "Stock price monitor"
:width 200 :height 100
:on-close :exit))
(def price-label (label "Price: -"))
(def running-avg-label (label "Running average: -"))
(config! main-frame :content
(border-panel
:north price-label
:center running-avg-label
:border 5))
(defn share-price [company-code]
(Thread/sleep 200)
(rand-int 1000))
(defn avg [numbers]
(float (/ (reduce + numbers)
(count numbers))))
(defn roll-buffer [buffer val buffer-size]
(let [buffer (conj buffer val)]
(if (> (count buffer) buffer-size)
(pop buffer)
buffer)))
(defn make-sliding-buffer [buffer-size]
(let [buffer (atom clojure.lang.PersistentQueue/EMPTY)]
(fn [n]
(swap! buffer roll-buffer n buffer-size))))
(def sliding-buffer (make-sliding-buffer 5))
唯一的不同之处在于,我们现在有一个返回数据窗口的sliding-buffer函数。这与我们的原始应用形成对比,在原始应用中,rolling-avg函数负责创建窗口并计算平均值。这种新的设计更通用,因为它使得这个函数更容易重用。滑动逻辑仍然是相同的。
接下来,我们有使用core.async的主应用程序逻辑:
(defn broadcast-at-interval [msecs task & ports]
(go-loop [out (apply broadcast ports)]
(<! (timeout msecs))
(>! out (task))
(recur out)))
(defn -main [& args]
(show! main-frame)
(let [prices-ch (chan)
sliding-buffer-ch (map> sliding-buffer (chan))]
(broadcast-at-interval 500 #(share-price "XYZ") prices-ch sliding-buffer-ch)
(go-loop []
(when-let [price (<! prices-ch)]
(text! price-label (str "Price: " price))
(recur)))
(go-loop []
(when-let [buffer (<! sliding-buffer-ch)]
(text! running-avg-label (str "Running average: " (avg buffer)))
(recur)))))
让我们一步步地看代码。
第一个函数broadcast-at-interval负责创建广播通道。它接收一个变量数量的参数:描述间隔的毫秒数,表示要执行的任务的函数,以及一个输出通道的序列。这些通道用于创建广播通道,go 循环将向其中写入价格。
接下来,是我们的主函数。let块是其中有趣的部分。正如我们在高级图中讨论的那样,我们需要两个输出通道:一个用于价格,一个用于滑动窗口。它们都在以下内容中创建:
...
(let [prices-ch (chan)
sliding-buffer-ch (map> sliding-buffer (chan))]
...
prices-ch 应该是显而易见的;然而,sliding-buffer-ch 使用了一个我们之前未曾遇到过的函数:map>。这又是 core.async 中另一个有用的通道构造器。它接受两个参数:一个函数和一个目标通道。它返回一个通道,在将值写入目标通道之前,将此函数应用于每个值。一个例子将有助于说明它是如何工作的:
(def c (map> sliding-buffer (chan 10)))
(go (doseq [n (range 10)]
(>! c n)))
(go (doseq [n (range 10)]
(prn (vec (<! c)))))
;; [0]
;; [0 1]
;; [0 1 2]
;; [0 1 2 3]
;; [0 1 2 3 4]
;; [1 2 3 4 5]
;; [2 3 4 5 6]
;; [3 4 5 6 7]
;; [4 5 6 7 8]
;; [5 6 7 8 9]
也就是说,我们将一个价格写入通道,并在另一端得到一个滑动窗口。最后,我们创建了包含副作用的两块 go 块。它们无限循环,从两个通道中获取值并更新用户界面。
你可以通过在终端运行程序来看到它的实际效果:
$ lein run -m core-async-playground.stock-market
错误处理
回到 第二章,我们学习了反应式扩展如何处理错误和异常。它提供了一套丰富的组合器来处理异常情况,并且使用起来非常简单。
尽管与 core.async 一起工作是一种乐趣,但它并没有提供很多异常处理的支持。事实上,如果我们只考虑愉快的路径来编写代码,我们甚至不知道发生了错误!
让我们看看一个例子:
(defn get-data []
(throw (Exception. "Bad things happen!")))
(defn process []
(let [result (chan)]
;; do some processing...
(go (>! result (get-data)))
result))
`get-data` simulates a function that fetches data from the network or an in-memory cache. In this case it simply throws an exception.`process` is a function that depends on `get-data` to do something interesting and puts the result into a channel, which is returned at the end.
让我们看看当我们把它们放在一起会发生什么:
(go (let [result (<! (->> (process "data")
(map> #(* % %))
(map> #(prn %))))]
(prn "result is: " result)))
没有发生任何事情。零,零,什么都没有,什么也没有。
这正是 core.async 中错误处理的问题:默认情况下,我们的异常被 go 块吞没,因为它在单独的线程上运行。我们处于这种状态,实际上并不知道发生了什么。
然而,并非所有都失去了。David Nolen 在他的博客上概述了一种处理此类异步异常的模式。它只需要几行额外的代码。
我们首先定义一个辅助函数和宏——这可能会存在于我们使用 core.async 的任何地方的实用命名空间中:
(defn throw-err [e]
(when (instance? Throwable e) (throw e))
e)
(defmacro <? [ch]
`(throw-err (async/<! ~ch)))
throw-err 函数接收一个值,如果它是一个 Throwable 的子类,它就会被抛出。否则,它只是简单地返回。
宏 <? 实质上是 <! 的直接替代品。事实上,它使用 <! 从通道中获取值,但首先将其传递给 throw-err。
在有了这些实用工具之后,我们需要对我们的 process 函数做一些更改:
(defn process []
(let [result (chan)]
;; do some processing...
(go (>! result (try (get-data)
(catch Exception e
e))))
result))
唯一的改变是我们将 get-data 包裹在一个 try/catch 块中。仔细看看 catch 块:它只是返回异常。
这很重要,因为我们需要确保异常被放入通道中。
接下来,我们更新我们的消费者代码:
(go (try (let [result (<? (->> (process "data")
(map> #(* % %))
(map> #(prn %))))]
(prn "result is: " result))
(catch Exception e
(prn "Oops, an error happened! We better do something about it here!"))))
;; "Oops, an error happened! We better do something about it here!"
这次我们用 <? 代替 <!。这很有意义,因为它会重新抛出通道中发现的任何异常。因此,我们现在可以使用简单的 try/catch 来重新控制我们的异常。
在 第二章 中,我们学习了反应式扩展如何处理错误和异常。它提供了一套丰富的组合器来处理异常情况,并且使用起来非常简单。
core.async 允许协调背压的主要机制是缓冲。core.async 不允许无界缓冲,因为这可能是错误和资源消耗的来源。
相反,我们必须深入思考我们应用程序的独特需求,并选择一个合适的缓冲策略。
固定缓冲区
这是最简单的缓冲形式。它固定为选择的数字n,允许生产者在不需要等待消费者的情况下将项目放入通道:
(def result (chan (buffer 5)))
(go-loop []
(<! (async/timeout 1000))
(when-let [x (<! result)]
(prn "Got value: " x)
(recur)))
(go (doseq [n (range 5)]
(>! result n))
(prn "Done putting values!")
(close! result))
;; "Done putting values!"
;; "Got value: " 0
;; "Got value: " 1
;; "Got value: " 2
;; "Got value: " 3
;; "Got value: " 4
在前面的例子中,我们创建了一个大小为5的缓冲区,并启动了一个go循环来消费其中的值。go循环使用一个timeout通道来延迟其启动。
然后,我们开始另一个 go 块,将数字从 0 到 4 放入结果通道,并在完成后打印到控制台。
到那时,第一个超时将已过期,我们将看到值打印到 REPL。
现在我们来看看如果缓冲区不够大会发生什么:
(def result (chan (buffer 2)))
(go-loop []
(<! (async/timeout 1000))
(when-let [x (<! result)]
(prn "Got value: " x)
(recur)))
(go (doseq [n (range 5)]
(>! result n))
(prn "Done putting values!")
(close! Result))
;; "Got value: " 0
;; "Got value: " 1
;; "Got value: " 2
;; "Done putting values!"
;; "Got value: " 3
;; "Got value: " 4
这次我们的缓冲区大小是2,但其他一切都是相同的。正如你所看到的,go循环完成得晚得多,因为它试图将另一个值放入结果通道,但由于其缓冲区已满而被阻塞/暂停。
与大多数事情一样,这可能没问题,但如果我们不打算阻塞一个快速的生产者,仅仅因为我们不能快速消费其项目,我们必须寻找另一个选项。
丢弃缓冲区
丢弃缓冲区也有一个固定的大小。然而,当它满时,它不会阻塞生产者,而是简单地忽略任何新的项目,如下所示:
(def result (chan (dropping-buffer 2)))
(go-loop []
(<! (async/timeout 1000))
(when-let [x (<! result)]
(prn "Got value: " x)
(recur)))
(go (doseq [n (range 5)]
(>! result n))
(prn "Done putting values!")
(close! result))
;; "Done putting values!"
;; "Got value: " 0
;; "Got value: " 1
如前所述,我们仍然有一个大小为二的缓冲区,但这次生产者迅速结束,从未被阻塞。dropping-buffer简单地忽略了所有超出其限制的项目。
滑动缓冲区
丢弃缓冲区的缺点是,我们可能不会在给定时间处理最新的项目。对于必须处理最新信息的情况,我们可以使用滑动缓冲区:
(def result (chan (sliding-buffer 2)))
(go-loop []
(<! (async/timeout 1000))
(when-let [x (<! result)]
(prn "Got value: " x)
(recur)))
(go (doseq [n (range 5)]
(>! result n))
(prn "Done putting values!")
(close! result))
;; "Done putting values!"
;; "Got value: " 3
;; "Got value: " 4
如前所述,我们只得到两个值,但它们是go循环产生的最新值。
当滑动缓冲区的限制被超过时,core.async会丢弃最旧的项目以腾出空间给最新的项目。我大多数时候都使用这种缓冲策略。
转换器
在我们完成本书的core.async部分之前,不提及 Clojure 1.7 中即将出现的内容以及这对core.async的影响是不明智的。
在撰写本文时,Clojure 的最新版本是1.7.0-alpha5——尽管它是一个 alpha 版本,但包括我在内的大量人已经在生产中使用它。
因此,最终版本可能就在眼前,也许在你阅读这篇文章的时候,1.7 最终版可能已经发布。
在即将发布的版本中,最大的变化是引入了transducers。我们不会在这里介绍它的细节,而是将重点放在使用 Clojure 序列和core.async通道的示例中的高层次含义上。
如果你想了解更多,我推荐 Carin Meier 的绿色鸡蛋和转换器博客文章(gigasquidsoftware.com/blog/2014/09/06/green-eggs-and-transducers/)。这是一个很好的起点。
此外,关于该主题的官方 Clojure 文档网站也是一个有用的资源 (clojure.org/transducers)。
让我们从创建一个新的 leiningen 项目开始:
$ lein new core-async-transducers
现在,打开你的 project.clj 文件,确保你有正确的依赖项:
...
:dependencies [[org.clojure/clojure "1.7.0-alpha5"]
[org.clojure/core.async "0.1.346.0-17112a-alpha"]]
...
接下来,在项目根目录中启动一个 REPL 会话并引入 core.async,我们很快就会用到它:
$ lein repl
user> (require '[clojure.core.async :refer [go chan map< filter< into >! <! go-loop close! pipe]])
我们将从一个熟悉的例子开始:
(->> (range 10)
(map inc) ;; creates a new sequence
(filter even?) ;; creates a new sequence
(prn "result is "))
;; "result is " (2 4 6 8 10)
In the previous example, we ended up with three in total: the one created by range, the one created by map, and finally the one created by filter. Most of the time, this won't really be an issue but for large sequences this means a lot of unnecessary allocation.
从 Clojure 1.7 版本开始,之前的例子可以写成这样:
(def xform
(comp (map inc)
(filter even?))) ;; no intermediate sequence created
(->> (range 10)
(sequence xform)
(prn "result is "))
;; "result is " (2 4 6 8 10)
Clojure 文档将转换器描述为可组合的算法转换。让我们看看为什么是这样。
在新版本中,一系列核心序列组合器,如 map 和 filter,增加了一个额外的参数:如果你不传递一个集合,它将返回一个转换器。
在之前的例子中,(map inc) 返回一个知道如何将函数 inc 应用到序列元素上的转换器。同样,(filter even?) 返回一个最终将过滤序列元素的转换器。它们目前都不做任何事情,它们只是返回函数。
这很有趣,因为转换器是可组合的。我们通过使用简单的函数组合来构建更大、更复杂的转换器:
(def xform
(comp (map inc)
(filter even?)))
一旦我们有了转换器,我们可以以几种不同的方式将其应用于集合。在这个例子中,我们选择了 sequence,因为它将返回一个给定的转换器对输入序列应用后的惰性序列:
(->> (range 10)
(sequence xform)
(prn "result is "))
;; "result is " (2 4 6 8 10)
如前所述,此代码不会创建中间序列;转换器提取当前算法转换的核心,并将其从直接处理序列中抽象出来。
转换器和 core.async
我们现在可能正在问自己:“转换器与 core.async 有什么关系?”
结果表明,一旦我们能够提取这些转换的核心并使用简单的函数组合将它们组合起来,就没有什么可以阻止我们使用转换器与除了序列之外的数据结构!
让我们回顾一下我们第一个例子,使用标准的 core.async 函数:
(def result (chan 10))
(def transformed
(->> result
(map< inc) ;; creates a new channel
(filter< even?) ;; creates a new channel
(into [])))
(go
(prn "result is " (<! transformed)))
(go
(doseq [n (range 10)]
(>! result n))
(close! result))
;; "result is " [2 4 6 8 10]
这段代码现在应该看起来很熟悉了:它是之前展示的仅序列版本的 core.async 等价物。和之前一样,我们这里也有不必要的分配,只是这次我们分配的是通道。
新增对转换器的支持后,core.async 可以利用之前定义的相同转换:
(def result (chan 10))
(def xform
(comp (map inc)
(filter even?))) ;; no intermediate channels created
(def transformed (->> (pipe result (chan 10 xform))
(into [])))
(go
(prn "result is " (<! transformed)))
(go
(doseq [n (range 10)]
(>! result n))
(close! result))
;; "result is " [2 4 6 8 10]
代码基本保持不变,除了我们现在在创建新通道时使用之前定义的相同 xform 转换。需要注意的是,我们不必使用 core.async 组合器——实际上,很多这些组合器已经被弃用,并将在未来版本的 core.async 中被移除。
用于定义 xform 的函数 map 和 filter 与我们之前使用的相同,即它们是核心 Clojure 函数。
这是使用 transducers 的下一个重大优势:通过 transducers 从等式中移除底层数据结构,库如core.async可以重用 Clojure 的核心组合器,以防止不必要的分配和代码重复。
想象其他框架,如 RxClojure,也能利用 transducers,这并不太过分。所有这些框架都能够跨不同数据结构和上下文(序列、通道和 Obervables)使用相同的核心函数。
小贴士
提取计算本质的概念,不考虑其底层数据结构,是一个令人兴奋的话题,在 Haskell 社区中也曾出现过,尽管他们具体处理的是列表。
关于这个主题,有两篇值得提及的论文是 Duncan Coutts、Roman Leshchinskiy 和 Don Stewart 的Stream Fusion [11]以及 Philip Wadler 的Transforming programs to eliminate trees [12]。它们有一些重叠,所以读者可能会觉得这些很有趣。
摘要
到现在为止,我希望我已经证明了你可以使用core.async编写响应式应用。这是一个极其强大且灵活的并发模型,拥有丰富的 API。如果你能以队列的形式设计你的解决方案,那么core.async很可能是你想要使用的工具。
与本书早期仅使用标准 Java API 开发的版本相比,这个股票市场应用的版本更短、更简单——例如,我们不必担心线程池。另一方面,它感觉比在第三章中实现的版本复杂一些,即使用 Reactive Extensions 实现的版本。
这是因为与其它框架相比,core.async在更低的抽象级别上运行。在我们的应用中这一点尤其明显,因为我们不得不担心创建广播通道、go 循环等等——所有这些都可视为附带复杂性,与当前问题无直接关系。
core.async确实为我们构建自己的 CES 抽象提供了一个优秀的基础。这正是我们将要探讨的内容。
第五章。使用 core.async 创建您自己的 CES 框架
在上一章中,提到core.async与其他框架如 RxClojure 或 RxJava 相比,在抽象级别上更低。
这是因为大多数时候我们必须仔细思考我们创建的通道以及要使用的缓冲区类型和大小,是否需要 pub/sub 功能,等等。
然而,并非所有应用程序都需要这种级别的控制。现在我们已经熟悉了core.async的动机和主要抽象,我们可以开始编写一个使用core.async作为底层基础的简单 CES 框架。
通过这样做,我们避免了需要考虑线程池管理,因为框架为我们处理了这一点。
在本章中,我们将涵盖以下主题:
-
使用
core.async作为其底层并发策略构建 CES 框架 -
构建一个使用我们的 CES 框架的应用程序
-
理解到目前为止所提出的不同方法的权衡
一个最小化的 CES 框架
在我们深入细节之前,我们应该在较高层面上定义什么是最小化。
让我们从我们的框架将提供的两个主要抽象开始:行为和事件流。
如果你还记得第一章中的内容,什么是响应式编程?,行为代表连续、随时间变化的值,如时间或鼠标位置行为。另一方面,事件流代表在时间点 T 的离散事件,如按键。
接下来,我们应该考虑我们希望支持哪些类型的操作。行为相当简单,所以至少我们需要:
-
创建新的行为
-
获取行为当前值
-
将行为转换为事件流
事件流有更复杂的逻辑,我们至少应该支持以下操作:
-
将值推送到流中
-
从给定的时间间隔创建流
-
使用
map和filter操作转换流 -
使用
flatmap组合流 -
订阅到流
这是一个小的子集,但足够大,可以展示 CES 框架的整体架构。一旦完成,我们将用它来构建一个简单的示例。
Clojure 还是 ClojureScript?
在这里,我们将转换方向,并给我们的小库添加另一个要求:它应该既能在 Clojure 中运行,也能在 ClojureScript 中运行。起初,这可能会听起来像是一个艰巨的要求。然而,记住core.async——我们框架的基础——既可以在 JVM 上运行,也可以在 JavaScript 中运行。这意味着我们有很多工作要做来让它实现。
然而,这意味着我们需要能够从我们的库中生成两个工件:一个jar文件和一个 JavaScript 文件。幸运的是,有 leiningen 插件可以帮助我们做到这一点,我们将使用其中几个:
-
lein-cljsbuild(见github.com/emezeske/lein-cljsbuild):用于简化 ClojureScript 构建的 Leiningen 插件 -
cljx(见github.com/lynaghk/cljx):一个用于编写可移植 Clojure 代码库的预处理器,即编写一个文件并输出.clj和.cljs文件
您不需要详细了解这些库。我们只使用它们的基本功能,并在遇到时会解释各个部分。
让我们开始创建一个新的 leiningen 项目。我们将我们的框架命名为 respondent——reactive 这个词的许多同义词之一:
$ lein new respondent
我们需要对 project.clj 文件进行一些修改,以包含我们将要使用的依赖项和配置。首先,确保项目依赖项看起来如下所示:
:dependencies [[org.clojure/clojure "1.5.1"]
[org.clojure/core.async "0.1.303.0-886421-alpha"]
[org.clojure/clojurescript "0.0-2202"]]
这里不应该有任何惊喜。仍然在项目文件中,添加必要的插件:
:plugins [[com.keminglabs/cljx "0.3.2"]
[lein-cljsbuild "1.0.3"]]
这些是我们之前提到的插件。它们本身并不做什么,但是需要配置。
对于 cljx,请在项目文件中添加以下内容:
:cljx {:builds [{:source-paths ["src/cljx"]
:output-path "target/classes"
:rules :clj}
{:source-paths ["src/cljx"]
:output-path "target/classes"
:rules :cljs}]}
:hooks [cljx.hooks]
cljx allows us to write code that is portable between Clojure and ClojureScript by placing annotations its preprocessor can understand. We will see later what these annotations look like, but this chunk of configuration tells cljx where to find the annotated files and where to output them once they're processed.
例如,根据前面的规则,如果我们有一个名为 src/cljx/core.cljx 的文件,并且运行预处理器,我们最终会得到 target/classes/core.clj 和 target/classes/core.cljs 输出文件。另一方面,钩子(hooks)只是自动在启动 REPL 会话时运行 cljx 的便捷方式。
配置的下一部分是针对 cljsbuild 的:
:cljsbuild
{:builds [{:source-paths ["target/classes"]
:compiler {:output-to "target/main.js"}}]}
cljsbuild 提供了 leiningen 任务,用于将 Clojuresript 源代码编译成 JavaScript。根据我们之前的 cljx 配置,我们知道 source.cljs 文件将位于 target/classes 目录下,所以我们只是告诉 cljsbuild 编译该目录下的所有 ClojureScript 文件,并将内容输出到 target/main.js。这是项目文件中需要的最后一部分。
好的,请继续删除由 leiningen 模板创建的这些文件,因为我们不会使用它们:
$ rm src/respondent/core.clj
$ rm test/respondent/core_test.clj
然后,在 src/cljx/respondent/ 下创建一个新的 core.cljx 文件,并添加以下命名空间声明:
(ns respondent.core
(:refer-clojure :exclude [filter map deliver])
#+clj
(:import [clojure.lang IDeref])
#+clj
(:require [clojure.core.async :as async
:refer [go go-loop chan <! >! timeout
map> filter> close! mult tap untap]])
#+cljs
(:require [cljs.core.async :as async
:refer [chan <! >! timeout map> filter>
close! mult tap untap]])
#+cljs
(:require-macros [respondent.core :refer [behavior]]
[cljs.core.async.macros :refer [go go-loop]]))
在这里,我们开始看到 cljx 注释。cljx 只是一个文本预处理器,所以当它使用 clj 规则处理文件时——如配置中所示——它将在输出文件中保留由注释 #+clj 前缀的 s- 表达式,同时删除由 #+cljs 前缀的。当使用 cljs 规则时,发生相反的过程。
这是因为宏需要在 JVM 上编译,所以它们必须通过使用 :require-macros 命名空间选项单独包含。不要担心我们之前没有遇到的 core.async 函数;我们将在使用它们构建我们的框架时解释它们。
此外,请注意我们如何排除 Clojure 标准 API 中的函数,因为我们希望使用相同的名称,并且不希望有任何不希望的命名冲突。
本节为我们设置了一个新项目以及我们框架所需的插件和配置。我们准备好开始实现了。
设计公共 API
我们对行为的要求之一是将给定行为转换为事件流的能力。一种常见的方法是在特定间隔采样行为。如果我们以鼠标位置行为为例,通过每x秒采样一次,我们得到一个事件流,该事件流将在离散的时间点发出当前鼠标位置。
这导致以下协议:
(defprotocol IBehavior
(sample [b interval]
"Turns this Behavior into an EventStream from the sampled values at the given interval"))
它有一个名为sample的函数,我们在前面的代码中已经描述过。我们还需要对行为做更多的事情,但现在这已经足够了。
我们下一个主要抽象是EventStream,根据之前看到的需要,导致以下协议:
(defprotocol IEventStream
(map [s f]
"Returns a new stream containing the result of applying f
to the values in s")
(filter [s pred]
"Returns a new stream containing the items from s
for which pred returns true")
(flatmap [s f]
"Takes a function f from values in s to a new EventStream.
Returns an EventStream containing values from all underlying streams combined.")
(deliver [s value]
"Delivers a value to the stream s")
(completed? [s]
"Returns true if this stream has stopped emitting values. False otherwise."))
这为我们提供了一些基本函数来转换和查询事件流。但它确实省略了订阅流的能力。不用担心,我没有忘记它!
虽然,订阅事件流是常见的,但协议本身并没有强制要求这样做,这是因为这种操作最适合它自己的协议:
(defprotocol IObservable
(subscribe [obs f] "Register a callback to be invoked when the underlying source changes.
Returns a token the subscriber can use to cancel the subscription."))
关于订阅,有一个从流中取消订阅的方法是有用的。这可以以几种方式实现,但前面函数的docstring暗示了一种特定的方法:一个可以用来取消订阅流的令牌。这导致我们最后的协议:
(defprotocol IToken
(dispose [tk]
"Called when the subscriber isn't interested in receiving more items"))
实现令牌
令牌类型在整个框架中是最简单的,因为它只有一个具有直接实现的函数:
(deftype Token [ch]
IToken
(dispose [_]
(close! ch)))
它只是关闭它所给的任何通道,停止事件通过订阅流动。
实现事件流
另一方面,事件流实现是我们框架中最复杂的。我们将逐步解决它,在前进的过程中实现和实验。
首先,让我们看看我们的主要构造函数,event-stream:
(defn event-stream
"Creates and returns a new event stream. You can optionally provide an existing
core.async channel as the source for the new stream"
([]
(event-stream (chan)))
([ch]
(let [multiple (mult ch)
completed (atom false)]
(EventStream. ch multiple completed))))
docstring应该足以理解公共 API。然而,可能不清楚所有构造函数参数的含义。从左到右,EventStream的参数是:
-
ch: 这是支持此流的core.async通道。 -
multiple: 这是一种将信息从一条通道广播到许多其他通道的方式。它是我们将要解释的core.async概念之一。 -
completed: 一个布尔标志,表示此事件流是否已完成且不会发出任何新值。
从实现中,你可以看到multiple是从支持流的通道创建的。multiple有点像广播。考虑以下示例:
(def in (chan))
(def multiple (mult in))
(def out-1 (chan))
(tap multiple out-1)
(def out-2 (chan))
(tap multiple out-2)
(go (>! in "Single put!"))
(go (prn "Got from out-1 " (<! out-1)))
(go (prn "Got from out-2 " (<! out-2)))
in, and mult of it called multiple. Then, we create two output channels, out-1 and out-2, which are both followed by a call to tap. This essentially means that whatever values are written to in will be taken by multiple and written to any channels tapped into it as the following output shows:
"Got from out-1 " "Single put!"
"Got from out-2 " "Single put!"
这将使理解EventStream实现更容易。
接下来,让我们看看EventStream的最小实现是什么样的——确保实现位于前面描述的构造函数之前:
(declare event-stream)
(deftype EventStream [channel multiple completed]
IEventStream
(map [_ f]
(let [out (map> f (chan))]
(tap multiple out)
(event-stream out)))
(deliver [_ value]
(if (= value ::complete)
(do (reset! completed true)
(go (>! channel value)
(close! channel)))
(go (>! channel value))))
IObservable
(subscribe [this f]
(let [out (chan)]
(tap multiple out)
(go-loop []
(let [value (<! out)]
(when (and value (not= value ::complete))
(f value)
(recur))))
(Token. out))))
目前,我们选择只实现 IEventStream 协议中的 map 和 deliver 函数。这允许我们将值传递到流中,并转换这些值。
然而,如果我们不能检索到传递的值,这将不会非常有用。这就是为什么我们还实现了 IObservable 协议中的 subscribe 函数。
简而言之,map 需要从输入流中取一个值,对其应用一个函数,并将其发送到新创建的流中。我们通过创建一个连接到当前 multiple 的输出通道来完成这个操作。然后我们使用这个通道来支持新的事件流。
deliver 函数简单地将值放入支持通道。如果值是命名空间关键字 ::complete,我们更新 completed 原子并关闭支持通道。这确保流不会发出任何其他值。
最后,我们有 subscribe 函数。订阅者被通知的方式是通过使用连接到后端 multiple 的输出通道。我们无限循环调用订阅函数,每当发出新值时。
我们通过返回一个令牌来完成,这个令牌会在处置后关闭输出通道,导致 go-loop 停止。
让我们通过在 REPL 中进行几个示例实验来确保所有这些内容都很有意义:
(def es1 (event-stream))
(subscribe es1 #(prn "first event stream emitted: " %))
(deliver es1 10)
;; "first event stream emitted: " 10
(def es2 (map es1 #(* 2 %)))
(subscribe es2 #(prn "second event stream emitted: " %))
(deliver es1 20)
;; "first event stream emitted: " 20
;; "second event stream emitted: " 40
太棒了!我们已经实现了 IEventStream 协议的最小、工作版本!
我们接下来要实现的下一个函数是 filter,它与 map 非常相似:
(filter [_ pred]
(let [out (filter> pred (chan))]
(tap multiple out)
(event-stream out)))
唯一的区别是我们使用 filter> 函数,而 pred 应该是一个布尔函数:
(def es1 (event-stream))
(def es2 (filter es1 even?))
(subscribe es1 #(prn "first event stream emitted: " %))
(subscribe es2 #(prn "second event stream emitted: " %))
(deliver es1 2)
(deliver es1 3)
(deliver es1 4)
;; "first event stream emitted: " 2
;; "second event stream emitted: " 2
;; "first event stream emitted: " 3
;; "first event stream emitted: " 4
;; "second event stream emitted: " 4
正如我们所看到的,es2 只在值是偶数时发出新值。
小贴士
如果你正在逐步跟随示例进行输入,每次我们在任何 deftype 定义中添加新函数时,你都需要重新启动你的 REPL。这是因为 deftype 在评估时会生成和编译一个 Java 类。因此,仅仅重新加载命名空间是不够的。
或者,你可以使用像 tools.namespace(见 github.com/clojure/tools.namespace)这样的工具,它解决了一些这些 REPL 重新加载的限制。
在我们的列表中向下移动,我们现在有 flatmap:
(flatmap [_ f]
(let [es (event-stream)
out (chan)]
(tap multiple out)
(go-loop []
(when-let [a (<! out)]
(let [mb (f a)]
(subscribe mb (fn [b]
(deliver es b)))
(recur))))
es))
我们在调查反应式扩展时已经遇到过这个操作符。这是我们的文档字符串对它的描述:
将函数 f 从 s 中的值映射到新的 EventStream。
返回一个包含所有底层流值的 EventStream。
这意味着 flatmap 需要将所有可能的事件流合并为单个输出事件流。和之前一样,我们向 multiple 流中连接一个新的通道,但然后我们在输出通道上循环,对每个输出值应用 f。
然而,正如我们所看到的,f 本身返回一个新的事件流,所以我们只需订阅它。每当注册在订阅中的函数被调用时,我们将该值传递到输出事件流中,实际上是将所有流合并为一个。
考虑以下示例:
(defn range-es [n]
(let [es (event-stream (chan n))]
(doseq [n (range n)]
(deliver es n))
es))
(def es1 (event-stream))
(def es2 (flatmap es1 range-es))
(subscribe es1 #(prn "first event stream emitted: " %))
(subscribe es2 #(prn "second event stream emitted: " %))
(deliver es1 2)
;; "first event stream emitted: " 2
;; "second event stream emitted: " 0
;; "second event stream emitted: " 1
(deliver es1 3)
;; "first event stream emitted: " 3
;; "second event stream emitted: " 0
;; "second event stream emitted: " 1
;; "second event stream emitted: " 2
我们有一个名为 range-es 的函数,它接收一个数字 n 并返回一个事件流,该流从 0 到 n 发射数字。和之前一样,我们有一个起始流 es1 和通过 flatmap 创建的转换流 es2。
从前面的输出中我们可以看到,由 range-es 创建的流被扁平化为 es2,这使得我们只需订阅一次就能接收所有值。
这就留下了我们从 IEventStream 中需要实现的单一函数:
(completed? [_] @completed)
completed? 简单地返回 completed 原子的当前值。我们现在可以开始实现行为。
实现行为
如果你还记得,IBehavior 协议有一个名为 sample 的单一函数,其文档字符串说明:将此行为转换为从给定间隔的采样值生成的事件流。
为了实现 sample,我们首先将创建一个有用的辅助函数,我们将称之为 from-interval:
(defn from-interval
"Creates and returns a new event stream which emits values at the given
interval.
If no other arguments are given, the values start at 0 and increment by
one at each delivery.
If given seed and succ it emits seed and applies succ to seed to get
the next value. It then applies succ to the previous result and so on."
([msecs]
(from-interval msecs 0 inc))
([msecs seed succ]
(let [es (event-stream)]
(go-loop [timeout-ch (timeout msecs)
value seed]
(when-not (completed? es)
(<! timeout-ch)
(deliver es value)
(recur (timeout msecs) (succ value))))
es)))
在这个阶段,docstring 函数应该足够清晰,但我们希望通过在 REPL 中尝试它来确保我们正确理解其行为:
(def es1 (from-interval 500))
(def es1-token (subscribe es1 #(prn "Got: " %)))
;; "Got: " 0
;; "Got: " 1
;; "Got: " 2
;; "Got: " 3
;; ...
(dispose es1-token)
如预期,es1 以 500 毫秒的间隔从零开始发射整数。默认情况下,它将无限期地发射数字;因此,我们保留调用 subscribe 返回的令牌的引用。
这样我们就可以在完成时将其丢弃,导致 es-1 完成并停止发射项目。
接下来,我们最终可以实现 Behavior 类型:
(deftype Behavior [f]
IBehavior
(sample [_ interval]
(from-interval interval (f) (fn [& args] (f))))
IDeref
(#+clj deref #+cljs -deref [_]
(f)))
(defmacro behavior [& body]
`(Behavior. #(do ~@body)))
通过传递一个函数来创建行为。你可以将这个函数视为一个生成器,负责生成此事件流中的下一个值。
这个生成器函数将在我们(1)deref Behavior 或(2)在 sample 给定的间隔时被调用。
behavior 宏是为了方便而存在的,它允许我们创建一个新的 Behavior,而无需我们自己将主体包装在函数中:
(def time-behavior (behavior (System/nanoTime)))
@time-behavior
;; 201003153977194
@time-behavior
;; 201005133457949
在前面的例子中,我们定义了 time-behavior,它始终包含当前系统时间。然后我们可以通过使用 sample 函数将此行为转换为离散事件的流:
(def time-stream (sample time-behavior 1500))
(def token (subscribe time-stream #(prn "Time is " %)))
;; "Time is " 201668521217402
;; "Time is " 201670030219351
;; ...
(dispose token)
小贴士
总是记得在处理像 sample 和 from-interval 创建的无穷流时保留订阅令牌的引用,否则你可能会遇到不希望的内存泄漏。
恭喜!我们已经使用 core.async 创建了一个工作、最小化的 CES 框架!
然而,我们没有证明它可以用 ClojureScript 实现,这最初是主要要求之一。没关系。我们将通过开发一个简单的 ClojureScript 应用程序来解决它,该应用程序将使用我们新的框架。
为了做到这一点,我们需要将框架部署到我们的本地 Maven 仓库。从项目根目录,输入以下 lein 命令:
$ lein install
Rewriting src/cljx to target/classes (clj) with features #{clj} and 0 transformations.
Rewriting src/cljx to target/classes (cljs) with features #{cljs} and 1 transformations.
Created respondent/target/respondent-0.1.0-SNAPSHOT.jar
Wrote respondent/pom.xml
练习
以下几节有一些练习供你完成。
练习 5.1
将我们当前的 EventStream 实现扩展以包含一个名为 take 的函数。它的工作方式与 Clojure 的核心 take 函数对序列的处理非常相似:它将从底层事件流中取出 n 个项目,之后将停止发出项目。
这里展示了从原始事件流中发出的前五个项目的示例交互:
(def es1 (from-interval 500))
(def take-es (take es1 5))
(subscribe take-es #(prn "Take values: " %))
;; "Take values: " 0
;; "Take values: " 1
;; "Take values: " 2
;; "Take values: " 3
;; "Take values: " 4
提示
在这里保留一些状态可能是有用的。原子可以有所帮助。此外,尝试想出一种方法来处理任何由解决方案要求的未使用的订阅。
练习 5.2
在这个练习中,我们将添加一个名为 zip 的函数,该函数将两个不同事件流发出的项目组合到一个向量中。
与 zip 函数的一个示例交互如下:
(def es1 (from-interval 500))
(def es2 (map (from-interval 500) #(* % 2)))
(def zipped (zip es1 es2))
(def token (subscribe zipped #(prn "Zipped values: " %)))
;; "Zipped values: " [0 0]
;; "Zipped values: " [1 2]
;; "Zipped values: " [2 4]
;; "Zipped values: " [3 6]
;; "Zipped values: " [4 8]
(dispose token)
提示
对于这个练习,我们需要一种方式来知道我们是否有足够的项目可以从两个事件流中发出。最初管理这种内部状态可能很棘手。Clojure 的 ref 类型,特别是 dosync,可能会有所帮助。
一个响应式应用程序
如果我们没有通过在新的应用程序中部署和使用新框架的整个开发生命周期来完善这一章,那么这一章将是不完整的。这正是本节的目的。
我们将要构建的应用程序非常简单。它所做的只是跟踪鼠标的位置,使用我们构建到响应式程序中的反应式原语。
为了达到这个目的,我们将使用由 Mimmo Cosenza 创建的出色的 lein 模板 cljs-start(见 github.com/magomimmo/cljs-start),以帮助开发者开始使用 ClojureScript。
让我们开始吧:
lein new cljs-start respondent-app
接下来,让我们修改项目文件以包含以下依赖项:
[clojure-reactive-programming/respondent "0.1.0-SNAPSHOT"]
[prismatic/dommy "0.1.2"]
第一个依赖项是显而易见的。它只是我们自己的框架。dommy 是一个用于 ClojureScript 的 DOM 操作库。当构建我们的网页时,我们将简要使用它。
接下来,编辑 dev-resources/public/index.html 文件以匹配以下内容:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Example: tracking mouse position</title>
<!--[if lt IE 9]>
<script src="img/html5.js"></script>
<![endif]-->
</head>
<body>
<div id="test">
<h1>Mouse (x,y) coordinates:</h1>
</div>
<div id="mouse-xy">
(0,0)
</div>
<script src="img/respondent_app.js"></script>
</body>
</html>
created a new div element, which will contain the mouse position. It defaults to (0,0).
最后一个拼图是修改 src/cljs/respondent_app/core.cljs 以匹配以下内容:
(ns respondent-app.core
(:require [respondent.core :as r]
[dommy.core :as dommy])
(:use-macros
[dommy.macros :only [sel1]]))
(def mouse-pos-stream (r/event-stream))
(set! (.-onmousemove js/document)
(fn [e]
(r/deliver mouse-pos-stream [(.-pageX e) (.-pageY e)])))
(r/subscribe mouse-pos-stream
(fn [[x y]]
(dommy/set-text! (sel1 :#mouse-xy)
(str "(" x "," y ")"))))
这是我们的主要应用程序逻辑。它创建了一个事件流,我们将从浏览器窗口的 onmousemove 事件中传递当前的鼠标位置到这个事件流。
之后,我们只需简单地订阅它,并使用 dommy 来选择和设置我们之前添加的 div 元素的文本。
我们现在可以使用应用程序了!让我们首先编译 ClojureScript:
$ lein compile
这可能需要几秒钟。如果一切顺利,接下来要做的事情是启动一个 REPL 会话并启动服务器:
$ lein repl
user=> (run)
现在,将你的浏览器指向 http://localhost:3000/ 并拖动鼠标以查看其当前位置。
恭喜你成功开发、部署和使用你自己的 CES 框架!
CES 与 core.async
在这个阶段,你可能想知道何时选择一种方法而不是另一种方法。毕竟,正如本章开头所展示的,我们可以使用 core.async 来完成使用 respondent 所做的一切。
这一切都归结于使用适合当前任务的正确抽象级别。
core.async为我们提供了许多在处理需要相互通信的进程时非常有用的低级原语。core.async的通道作为并发阻塞队列工作,在这些场景中是一个出色的同步机制。
然而,它使得其他解决方案的实现更加困难:例如,通道默认是单次取用的,所以如果我们有多个消费者对放入通道中的值感兴趣,我们必须使用mult和tap等工具自行实现分发。
另一方面,CES 框架在更高的抽象级别上运行,并且默认情况下与多个订阅者一起工作。
此外,core.async依赖于副作用,这可以通过go块内使用>!等函数来看到。例如,RxClojure 这样的框架通过使用纯函数来促进流转换。
这并不是说 CES 框架中没有副作用。当然有。然而,作为库的消费者,这些副作用大部分对我们来说是隐藏的,这让我们认为大部分的代码只是简单的序列转换。
总之,不同的应用领域将或多或少地从这两种方法中受益——有时它们可以从两者中受益。我们应该认真思考我们的应用领域,并分析开发者最有可能设计的解决方案和习惯用法。这将指引我们在开发特定应用时朝向更好的抽象方向。
摘要
在本章中,我们开发了我们自己的 CES 框架。通过开发自己的框架,我们巩固了对 CES 以及如何有效使用core.async的理解。
将core.async用作 CES 框架基础的想法并非是我的。James Reeves(见github.com/weavejester)——路由库Compojure(见github.com/weavejester/compojure)和许多其他有用的 Clojure 库的创造者——也看到了同样的潜力,并着手编写Reagi(见github.com/weavejester/reagi),这是一个建立在core.async之上的 CES 库,其精神与我们本章开发的类似。
他投入了更多的努力,使其成为纯 Clojure 框架的一个更稳健的选择。我们将在下一章中探讨它。
第六章:使用 Reagi 构建简单的 ClojureScript 游戏
在上一章中,我们通过构建自己的框架,将其命名为 respondent,学习了组合事件系统(CES)的工作原理。这使我们深刻理解了此类软件中涉及的主要抽象,并对 core.async、Clojure 的异步编程库以及我们框架的基础有了良好的概述。
Respondent 只是一个玩具框架。我们没有过多关注诸如内存效率和异常处理等横切关注点。这没关系,因为我们使用它作为学习如何使用 core.async 处理和组合事件系统的工具。此外,其设计有意与 Reagi 的设计相似。
在本章中,我们将:
-
了解 Reagi,这是一个建立在
core.async之上的 CES 框架 -
使用 Reagi 构建 ClojureScript 游戏的基础,这将教会我们如何以干净和可维护的方式处理用户输入
-
简要比较 Reagi 与其他 CES 框架,并了解何时使用每个框架
设置项目
你玩过 Asteroids 吗?如果你没有,Asteroids 是一款 1979 年由 Atari 首次发布的街机太空射击游戏。在 Asteroids 中,你是飞船的飞行员,在太空中飞行。在这个过程中,你会被围绕的陨石和外星飞碟所包围,你必须射击并摧毁它们。
在一章中开发整个游戏过于雄心勃勃,会分散我们对本书主题的注意力。我们将限制自己确保屏幕上有一个我们可以飞行的飞船,并且可以向虚空射击太空子弹。到本章结束时,我们将拥有以下截图所示的内容:

要开始,我们将使用与上一章相同的 leiningen 模板 cljs-start(见github.com/magomimmo/cljs-start)创建一个 newClojureScript 项目:
lein new cljs-start reagi-game
接下来,将以下依赖项添加到你的项目文件中:
[org.clojure/clojurescript "0.0-2138"]
[reagi "0.10.0"]
[rm-hull/monet "0.1.12"]
最后一个依赖项是 monet(见github.com/rm-hull/monet),这是一个 ClojureScript 库,你可以用它来处理 HTML 5 Canvas。它是 Canvas API 的高级包装器,使得与之交互变得更加简单。
在我们继续之前,确保我们的设置正常工作可能是个好主意。切换到项目目录,启动 Clojure REPL,然后启动嵌入式 Web 服务器:
cd reagi-game/
lein repl
Compiling ClojureScript.
Compiling "dev-resources/public/js/reagi_game.js" from ("src/cljs" "test/cljs" "dev-resources/tools/repl")...
user=> (run)
2014-06-14 19:21:40.381:INFO:oejs.Server:jetty-7.6.8.v20121106
2014-06-14 19:21:40.403:INFO:oejs.AbstractConnector:Started SelectChannelConnector@0.0.0.0:3000
#<Server org.eclipse.jetty.server.Server@51f6292b>
这将编译 ClojureScript 源文件到 JavaScript 并启动示例 Web 服务器。在你的浏览器中,导航到 http://localhost:3000/。如果你看到以下内容,我们就准备好了:

由于我们将使用 HTML 5 Canvas,我们需要一个实际的画布来渲染。让我们更新我们的 HTML 文档以包含它。它位于 dev-resources/public/index.html 下:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>bREPL Connection</title>
<!--[if lt IE 9]>
<script src="img/html5.js"></script>
<![endif]-->
</head>
<body>
<canvas id="canvas" width="800" height="600"></canvas>
<script src="img/reagi_game.js"></script>
</body>
</html>
我们在我们的文档中添加了一个canvasDOM 元素。所有渲染都将在这个上下文中发生。
游戏实体
我们的游戏将只有两个实体:一个代表飞船,另一个代表子弹。为了更好地组织代码,我们将所有与实体相关的代码放在自己的文件中,即src/cljs/reagi_game/entities.cljs。此文件还将包含一些渲染逻辑,因此我们需要引入monet:
(ns reagi-game.entities
(:require [monet.canvas :as canvas]
[monet.geometry :as geom]))
接下来,我们将添加一些辅助函数以避免过多重复:
(defn shape-x [shape]
(-> shape :pos deref :x))
(defn shape-y [shape]
(-> shape :pos deref :y))
(defn shape-angle [shape]
@(:angle shape))
(defn shape-data [x y angle]
{:pos (atom {:x x :y y})
:angle (atom angle)})
前三个函数只是从我们的形状数据结构中获取数据的一种更简短的方式。shape-data函数创建一个结构。请注意,我们正在使用 Clojure 的引用类型之一atoms来表示形状的位置和角度。
这样,我们就可以安全地将我们的形状数据传递给 monet 的渲染函数,并且仍然能够以一致的方式更新它。
接下来是我们的飞船构造函数。这是与 monet 交互最频繁的地方:
(defn ship-entity [ship]
(canvas/entity {:x (shape-x ship)
:y (shape-y ship)
:angle (shape-angle ship)}
(fn [value]
(-> value
(assoc :x (shape-x ship))
(assoc :y (shape-y ship))
(assoc :angle (shape-angle ship))))
(fn [ctx val]
(-> ctx
canvas/save
(canvas/translate (:x val) (:y val))
(canvas/rotate (:angle val))
(canvas/begin-path)
(canvas/move-to 50 0)
(canvas/line-to 0 -15)
(canvas/line-to 0 15)
(canvas/fill)
canvas/restore))))
这里发生了很多事情,所以让我们分解一下。
canvas/entity是一个 monet 构造函数,并期望你提供三个描述我们的飞船的参数:其初始 x、y 坐标和角度,一个在绘制循环中被调用的更新函数,以及一个负责在每次更新后将形状绘制到屏幕上的绘制函数。
更新函数相当直接:
(fn [value]
(-> value
(assoc :x (shape-x ship))
(assoc :y (shape-y ship))
(assoc :angle (shape-angle ship))))
我们只是将其属性更新为飞船的原子中的当前值。
下一个函数,负责绘制,与 monet 的 API 交互更为频繁:
(fn [ctx val]
(-> ctx
canvas/save
(canvas/translate (:x val) (:y val))
(canvas/rotate (:angle val))
(canvas/begin-path)
(canvas/move-to 50 0)
(canvas/line-to 0 -15)
(canvas/line-to 0 15)
(canvas/fill)
canvas/restore))
我们首先保存当前上下文,以便稍后可以恢复诸如绘图样式和画布定位之类的设置。接下来,我们将画布平移到飞船的 x、y 坐标,并根据其角度旋转。然后我们开始绘制我们的形状,一个三角形,最后通过恢复我们保存的上下文来完成。
下一个函数也创建了一个实体,我们的子弹:
(declare move-forward!)
(defn make-bullet-entity [monet-canvas key shape]
(canvas/entity {:x (shape-x shape)
:y (shape-y shape)
:angle (shape-angle shape)}
(fn [value]
(when (not
(geom/contained?
{:x 0 :y 0
:w (.-width (:canvas monet-canvas))
:h (.-height (:canvas monet-canvas))}
{:x (shape-x shape)
:y (shape-y shape)
:r 5}))
(canvas/remove-entity monet-canvas key))
(move-forward! shape)
(-> value
(assoc :x (shape-x shape))
(assoc :y (shape-y shape))
(assoc :angle (shape-angle shape))))
(fn [ctx val]
(-> ctx
canvas/save
(canvas/translate (:x val) (:y val))
(canvas/rotate (:angle val))
(canvas/fill-style "red")
(canvas/circle {:x 10 :y 0 :r 5})
canvas/restore))))
如前所述,让我们检查update和drawing函数。我们首先从update开始:
(fn [value]
(when (not
(geom/contained?
{:x 0 :y 0
:w (.-width (:canvas monet-canvas))
:h (.-height (:canvas monet-canvas))}
{:x (shape-x shape)
:y (shape-y shape)
:r 5}))
(canvas/remove-entity monet-canvas key))
(move-forward! shape)
(-> value
(assoc :x (shape-x shape))
(assoc :y (shape-y shape))
(assoc :angle (shape-angle shape))))
子弹在其更新函数中包含更多的逻辑。当你从飞船发射它们时,我们可能会创建数百个这样的实体,因此,一旦它们离开可见画布区域,立即将它们销毁是一个好习惯。这是函数做的第一件事:它使用geom/contained?来检查实体是否在画布的尺寸内,不在时将其移除。
然而,与飞船不同的是,子弹不需要用户输入来移动。一旦发射,它们就会自行移动。这就是为什么我们接下来要做的是调用move-forward!。我们还没有实现这个函数,所以我们必须提前声明它。我们将稍后处理它。
一旦子弹的坐标和角度被更新,我们就简单地返回新的实体。
绘制函数比飞船版本简单一些,主要是因为其形状更简单;它只是一个红色的圆圈:
(fn [ctx val]
(-> ctx
canvas/save
(canvas/translate (:x val) (:y val))
(canvas/rotate (:angle val))
(canvas/fill-style "red")
(canvas/circle {:x 10 :y 0 :r 5})
canvas/restore))
现在,我们将继续编写负责更新我们的形状坐标和角度的函数,从 move! 开始:
(def speed 200)
(defn calculate-x [angle]
(* speed (/ (* (Math/cos angle)
Math/PI)
180)))
(defn calculate-y [angle]
(* speed (/ (* (Math/sin angle)
Math/PI)
180)))
(defn move! [shape f]
(let [pos (:pos shape)]
(swap! pos (fn [xy]
(-> xy
(update-in [:x]
#(f % (calculate-x
(shape-angle shape))))
(update-in [:y]
#(f % (calculate-y
(shape-angle shape)))))))))
为了保持简单,船和子弹都使用相同的速度值来计算它们的位置,这里定义为 200。
move! 接受两个参数:形状映射和一个函数 f。这个函数将是 +(加)或 -(减)函数,具体取决于我们是向前还是向后移动。接下来,它使用一些基本的三角学来更新形状的 x,y 坐标。
如果你想知道为什么我们传递加法和减法函数作为参数,这完全是为了避免重复,如以下两个函数所示:
(defn move-forward! [shape]
(move! shape +))
(defn move-backward! [shape]
(move! shape -))
在处理了移动之后,下一步是编写旋转函数:
(defn rotate! [shape f]
(swap! (:angle shape) #(f % (/ (/ Math/PI 3) 20))))
(defn rotate-right! [shape]
(rotate! shape +))
(defn rotate-left! [shape]
(rotate! shape -))
到目前为止,我们已经涵盖了船的移动!但如果我们不能发射子弹,我们的船又有什么用呢?让我们确保我们也覆盖了这一点:
(defn fire! [monet-canvas ship]
(let [entity-key (keyword (gensym "bullet"))
data (shape-data (shape-x ship)
(shape-y ship)
(shape-angle ship))
bullet (make-bullet-entity monet-canvas
entity-key
data)]
(canvas/add-entity monet-canvas entity-key bullet)))
fire! 函数接受两个参数:游戏画布的引用和船。然后它通过调用 make-bullet-entity 创建一个新的子弹并将其添加到画布上。
注意我们如何使用 Clojure 的 gensym 函数为新的实体创建一个唯一的键。我们使用这个键从游戏中删除一个实体。
这完成了 entities 命名空间的代码。
小贴士
在编写卫生宏时,gensym 被广泛使用,因为你可以确信生成的符号不会与使用宏的代码的任何局部绑定冲突。宏超出了本书的范围,但你可能会在以下宏练习系列中找到有用的学习过程,请访问 github.com/leonardoborges/clojure-macros-workshop。
整合所有内容
我们现在准备好组装我们的游戏了。请打开核心命名空间文件 src/cljs/reagi_game/core.cljs,并添加以下内容:
(ns reagi-game.core
(:require [monet.canvas :as canvas]
[reagi.core :as r]
[clojure.set :as set]
[reagi-game.entities :as entities
:refer [move-forward! move-backward! rotate-left! rotate-right! fire!]]))
我们首先从我们的 canvas DOM 元素的引用创建 monet-canvas。然后我们创建我们的船数据,将其放置在画布的中心,并将实体添加到 monet-canvas。最后,我们启动一个绘制循环,它将使用浏览器的本地功能来处理我们的动画——内部它调用 window.requestAnimationFrame(),如果可用,否则回退到 window.setTimemout()。
如果你现在尝试应用程序,这将足以在屏幕中间绘制船,但除了我们没有开始处理用户输入之外,不会发生任何事情。
就用户输入而言,我们关注以下几个动作:
-
船的移动:旋转、前进和后退
-
发射船的炮
-
暂停游戏
为了应对这些动作,我们将定义一些代表相关键的 ASCII 码的常量:
(def UP 38)
(def RIGHT 39)
(def DOWN 40)
(def LEFT 37)
(def FIRE 32) ;; space
(def PAUSE 80) ;; lower-case P
这应该看起来是有意义的,因为我们使用的是传统上用于这些动作的键。
将用户输入建模为事件流
在前面的章节中讨论的一个问题是,如果你可以将事件视为尚未发生的一系列事物,那么你很可能可以将它建模为事件流。在我们的情况下,这个列表由玩家在游戏中按下的键组成,可以像这样可视化:

但是有一个问题。大多数游戏需要同时处理按下的键。
假设你正在向前飞行太空船。你不想停下来旋转它向左,然后再继续向前移动。你想要的是在按下向上键的同时按下左键,让飞船相应地做出反应。
这暗示我们需要能够判断玩家是否正在同时按下多个键。传统上,在 JavaScript 中通过跟踪在类似映射的对象中按下的键来做到这一点,使用标志。类似于以下片段:
var keysPressed = {};
document.addEventListener('keydown', function(e) {
keysPressed[e.keyCode] = true;
}, false);
document.addEventListener('keyup', function(e) {
keysPressed[e.keyCode] = false;
}, false);
然后,在游戏循环的稍后阶段,你会检查是否有多个键被按下:
function gameLoop() {
if (keyPressed[UP] && keyPressed[LEFT]) {
// update ship position
}
// ...
}
虽然这段代码可以工作,但它依赖于修改keysPressed对象,这不是理想的做法。
此外,与前面的设置类似,keysPressed对象对应用程序是全局的,因为它既需要在keyup/keydown事件处理程序中,也需要在游戏循环本身中。
在函数式编程中,我们努力消除或减少全局可变状态的数量,以编写可读性、可维护性高且错误率低的代码。我们将在这里应用这些原则。
如前述 JavaScript 示例所示,我们可以注册回调,以便在keyup或keydown事件发生时得到通知。这很有用,因为我们可以轻松地将它们转换为事件流:
(defn keydown-stream []
(let [out (r/events)]
(set! (.-onkeydown js/document)
#(r/deliver out [::down (.-keyCode %)]))
out))
(defn keyup-stream []
(let [out (r/events)]
(set! (.-onkeyup js/document)
#(r/deliver out [::up (.-keyCode %)]))
out))
keydown-stream和keyup-stream都返回一个新的流,它们在事件发生时将事件传递到该流。每个事件都带有关键字标记,这样我们就可以轻松地识别其类型。
我们希望同时处理这两种类型的事件,因此我们需要一种方法将这两个流合并为一个单一的流。
我们可以通过多种方式组合流,例如使用zip和flatmap等操作符。然而,在这个例子中,我们对merge操作符感兴趣。merge创建一个新的流,它会随着流的到达从两个流中发出值:

这为我们提供了足够的资源来开始创建我们的活动键流。根据我们迄今为止所讨论的内容,我们的流目前看起来如下所示:
(def active-keys-stream
(->> (r/merge (keydown-stream) (keyup-stream))
...
))
为了跟踪当前按下的键,我们将使用 ClojureScript 集合。这样我们就不必担心设置标志为真或假——我们可以简单地执行标准的集合操作,并从数据结构中添加/删除键。
接下来,我们需要一种方法,将按下的键累积到这个集合中,因为合并流会发出新的事件。
在函数式编程中,每当我们要在一系列值上累积或聚合某种类型的数据时,我们使用reduce。
大多数——如果不是所有——CES 框架都有这个内置函数。RxJava 称之为scan。另一方面,Reagi 称之为reduce,这使得它对一般函数式程序员来说很直观。
那是我们将用来完成active-keys-stream实现的函数:
(def active-keys-stream
(->> (r/merge (keydown-stream) (keyup-stream))
(r/reduce (fn [acc [event-type key-code]]
(condp = event-type
::down (conj acc key-code)
::up (disj acc key-code)
acc))
#{})
(r/sample 25)))
r/reduce接受三个参数:一个减少函数、一个可选的初始/种子值,以及要减少的流。
我们的种子值是一个空集,因为最初用户还没有按下任何键。然后,我们的减少函数检查事件类型,根据需要从集合中删除或添加键。
因此,我们得到的是一个类似于以下表示的流:

处理活动键流
我们迄今为止所做的基础工作将确保我们可以轻松地以干净和可维护的方式处理游戏事件。拥有表示游戏键的流背后的主要思想是,现在我们可以像处理正常列表一样对其进行分区。
例如,如果我们对所有按键为UP的事件感兴趣,我们会运行以下代码:
(->> active-keys-stream
(r/filter (partial some #{UP}))
(r/map (fn [_] (.log js/console "Pressed up..."))))
类似地,对于涉及FIRE键的事件,我们可以做以下操作:
(->> active-keys-stream
(r/filter (partial some #{FIRE}))
(r/map (fn [_] (.log js/console "Pressed fire..."))))
这之所以有效,是因为在 Clojure 中,集合可以用作谓词。我们可以在 REPL 中快速验证这一点:
user> (def numbers #{12 13 14})
#'user/numbers
user> (some #{12} numbers)
12
user> (some #{15} numbers)
nil
通过将事件表示为流,我们可以轻松地使用熟悉的序列函数,如map和filter,来操作它们。
然而,编写这样的代码有点重复。前两个例子基本上是在说类似的话:过滤所有匹配给定谓词pred的事件,然后对它们应用f函数。我们可以在一个我们称之为filter-map的函数中抽象这个模式:
(defn filter-map [pred f & args]
(->> active-keys-stream
(r/filter (partial some pred))
(r/map (fn [_] (apply f args)))))
在有了这个辅助函数之后,处理我们的游戏动作变得容易:
(filter-map #{FIRE} fire! monet-canvas ship)
(filter-map #{UP} move-forward! ship)
(filter-map #{DOWN} move-backward! ship)
(filter-map #{RIGHT} rotate-right! ship)
(filter-map #{LEFT} rotate-left! ship)
现在唯一缺少的是在玩家按下PAUSE键时暂停动画。我们遵循与上面相同的逻辑,但略有变化:
(defn pause! [_]
(if @(:updating? monet-canvas)
(canvas/stop-updating monet-canvas)
(canvas/start-updating monet-canvas)))
(->> active-keys-stream
(r/filter (partial some #{PAUSE}))
(r/throttle 100)
(r/map pause!))
Monet 提供了一个标志,告诉我们是否正在更新动画状态。我们使用这个标志作为一个便宜的机制来“暂停”游戏。
注意,active-keys-stream会在事件发生时推送事件,因此,如果用户按住按钮任何时间长度,我们都会为该键获得多个事件。因此,我们可能会在非常短的时间内多次遇到PAUSE键。这会导致游戏疯狂地停止/开始。为了防止这种情况发生,我们限制过滤流的速率,并忽略所有在 100 毫秒窗口内发生的PAUSE事件。
为了确保我们没有遗漏任何东西,这是我们的src/cljs/reagi_game/core.cljs文件应该看起来像的,完整版:
(ns reagi-game.core
(:require [monet.canvas :as canvas]
[reagi.core :as r]
[clojure.set :as set]
[reagi-game.entities :as entities
:refer [move-forward! move-backward! rotate-left! rotate-right! fire!]]))
(def canvas-dom (.getElementById js/document "canvas"))
(def monet-canvas (canvas/init canvas-dom "2d"))
(def ship (entities/shape-data (/ (.-width (:canvas monet-canvas)) 2)
(/ (.-height (:canvas monet-canvas)) 2)
0))
(def ship-entity (entities/ship-entity ship))
(canvas/add-entity monet-canvas :ship-entity ship-entity)
(canvas/draw-loop monet-canvas)
(def UP 38)
(def RIGHT 39)
(def DOWN 40)
(def LEFT 37)
(def FIRE 32) ;; space
(def PAUSE 80) ;; lower-case P
(defn keydown-stream []
(let [out (r/events)]
(set! (.-onkeydown js/document) #(r/deliver out [::down (.-keyCode %)]))
out))
(defn keyup-stream []
(let [out (r/events)]
(set! (.-onkeyup js/document) #(r/deliver out [::up (.-keyCode %)]))
out))
(def active-keys-stream
(->> (r/merge (keydown-stream) (keyup-stream))
(r/reduce (fn [acc [event-type key-code]]
(condp = event-type
::down (conj acc key-code)
::up (disj acc key-code)
acc))
#{})
(r/sample 25)))
(defn filter-map [pred f & args]
(->> active-keys-stream
(r/filter (partial some pred))
(r/map (fn [_] (apply f args)))))
(filter-map #{FIRE} fire! monet-canvas ship)
(filter-map #{UP} move-forward! ship)
(filter-map #{DOWN} move-backward! ship)
(filter-map #{RIGHT} rotate-right! ship)
(filter-map #{LEFT} rotate-left! ship)
(defn pause! [_]
(if @(:updating? monet-canvas)
(canvas/stop-updating monet-canvas)
(canvas/start-updating monet-canvas)))
(->> active-keys-stream
(r/filter (partial some #{PAUSE}))
(r/throttle 100)
(r/map pause!))
这完成了代码,我们现在可以查看结果。
如果你在本章的早期部分仍然运行着服务器,只需退出 REPL,再次启动它,并启动嵌入的 Web 服务器:
lein repl
Compiling ClojureScript.
Compiling "dev-resources/public/js/reagi_game.js" from ("src/cljs" "test/cljs" "dev-resources/tools/repl")...
user=> (run)
2014-06-14 19:21:40.381:INFO:oejs.Server:jetty-7.6.8.v20121106
2014-06-14 19:21:40.403:INFO:oejs.AbstractConnector:Started SelectChannelConnector@0.0.0.0:3000
#<Server org.eclipse.jetty.server.Server@51f6292b>
这将编译我们 ClojureScript 源代码的最新版本到 JavaScript。
或者,你可以让 REPL 保持运行状态,并在另一个终端窗口中简单地要求 cljsbuild 自动编译源代码:
lein cljsbuild auto
Compiling "dev-resources/public/js/reagi_game.js" from ("src/cljs" "test/cljs" "dev-resources/tools/repl")...
Successfully compiled "dev-resources/public/js/reagi_game.js" in 13.23869 seconds.
现在,你可以将你的浏览器指向 http://localhost:3000/ 并在你的宇宙飞船周围飞翔!别忘了射击一些子弹哦!
Reagi 和其他 CES 框架
回到第四章,核心异步简介,我们概述了 core.async 和 CES 之间的主要区别。在这一章中可能出现的另一个问题是:我们如何决定使用哪个 CES 框架?
答案没有以前那么明确,通常取决于正在查看的工具的具体情况。到目前为止,我们已经了解了两种这样的工具:响应式扩展(包括 RxJS、RxJava 和 RxClojure)和 Reagi。
响应式扩展(Rx)是一个更加成熟的框架。它的第一个 .NET 平台版本于 2011 年发布,其中的理念自那时起已经得到了显著的发展。
此外,像 Netflix 这样的知名公司正在生产中大量使用其他平台如 RxJava 的端口。
Rx 的一个缺点是,如果你想在浏览器和服务器上同时使用它,你必须分别使用两个不同的框架,即 RxJS 和 RxJava。虽然它们确实共享相同的 API,但它们是不同的代码库,这可能导致一个端口中已经解决的错误在另一个端口中尚未解决。
对于 Clojure 开发者来说,这也意味着更多地依赖互操作性来与 Rx 的完整 API 进行交互。
另一方面,Reagi 是这个领域的新参与者,但它建立在 core.async 的坚实基础之上。它是完全在 Clojure 中开发的,并通过编译为 Clojure 和 ClojureScript 解决了在浏览器/服务器上的问题。
Reagi 还允许通过 port 和 subscribe 等函数无缝集成 core.async,这些函数允许从事件流中创建通道。
此外,core.async 在 ClojureScript 应用程序中的使用正在变得无处不在,所以你很可能已经将其作为依赖项。这使得 Reagi 在我们需要比 core.async 提供的更高层次抽象时成为一个有吸引力的选择。
摘要
在本章中,我们学习了如何使用我们迄今为止学到的反应式编程技术来编写更干净、更容易维护的代码。为此,我们坚持将异步事件简单地视为列表,并看到这种思维方式如何很容易地被建模为事件流。然后,我们的游戏只需要使用熟悉的序列处理函数对这些流进行操作。
我们还学习了 Reagi 的基础知识,这是一个类似于我们在第四章中创建的 CES 框架,即核心.async 简介,但它功能更丰富且更健壮。
在下一章中,我们将暂时放下 CES,看看基于数据流的传统反应式方法如何有用。
第七章。UI 作为函数
到目前为止,我们已经通过有效地处理和建模数据流来管理异步工作流,从而通过管理复杂性进行了一次旅行。特别是第四章,“core.async 简介”和第五章,“使用 core.async 创建自己的 CES 框架”探讨了提供组合事件系统原语和组合子的库所涉及的内容。我们还构建了一个简单的 ClojureScript 应用程序,该应用程序使用了我们的框架。
你可能已经注意到,到目前为止的示例都没有处理我们准备好向用户展示数据后会发生什么。作为应用程序开发者,我们需要回答的仍然是一个悬而未决的问题。
在本章中,我们将探讨一种在 Web 应用程序中使用 React(见facebook.github.io/react/)处理反应式用户界面的方法,React 是由 Facebook 开发的一个现代 JavaScript 框架,以及:
-
学习 React 如何高效地渲染用户界面
-
了解 Om,它是 React 的 ClojureScript 接口
-
学习 Om 如何利用持久数据结构来提高性能
-
使用 Om 开发两个完全工作的 ClojureScript 应用程序,包括使用
core.async进行组件间通信
复杂 Web UI 的问题
随着单页 Web 应用程序的兴起,能够管理 JavaScript 代码库的增长和复杂性变得至关重要。ClojureScript 也是如此。
为了管理这种复杂性,出现了大量的 JavaScript MVC 框架,例如 AngularJS、Backbone.js、Ember.js 和 KnockoutJS 等。
它们非常不同,但有一些共同的特点:
-
通过提供模型、视图、控制器、模板等来为单页应用程序提供更多结构
-
提供客户端路由
-
双向数据绑定
在本章中,我们将专注于最后一个目标。
如果我们要开发一个中等复杂程度的单页 Web 应用程序,双向数据绑定绝对是至关重要的。以下是它是如何工作的。
假设我们正在开发一个电话簿应用程序。更有可能的是,我们将有一个模型——或者实体、映射等——来表示联系人。联系人模型可能有姓名、电话号码和电子邮件地址等属性。
当然,如果用户无法更新联系信息,那么这个应用程序将不会很有用,因此我们需要一个表单来显示当前的联系详细信息,并允许您更新联系人的信息。
联系模型可能通过 AJAX 请求加载,然后可能使用显式的 DOM 操作代码来显示表单。这看起来可能像以下伪代码:
function editContact(contactId) {
contactService.get(contactId, function(data) {
contactForm.setName(data.name);
contactForm.setPhone(data.phone);
contactForm.setEmail(data.email);
})
}
但是,当用户更新某人的信息时会发生什么?我们需要以某种方式存储它。在点击保存时,以下函数可以解决问题,假设你正在使用 jQuery:
$("save-button").click(function(){
contactService.update(contactForm.serialize(), function(){
flashMessage.set("Contact Updated.")
})
这看似无害的代码带来了一个大问题。这个特定的人的联系模型现在过时了。如果我们还在使用旧的方式开发 Web 应用,即每次更新都重新加载页面,这不会是问题。然而,单页 Web 应用的全部要点是响应性,所以它在客户端保持大量状态,并且保持我们的模型与视图同步非常重要。
这就是双向数据绑定发挥作用的地方。以下是一个 AngularJS 的示例:
// JS
// in the Controller
$scope.contact = {
name: 'Leonardo Borges',
phone '+61 xxx xxx xxx',
email: 'leonardoborges.rj@gmail.com'
}
<!-- HTML -->
<!-- in the View -->
<form>
<input type="text" name="contactName" ng-model="contact.name"/>
<input type="text" name="contactPhone" ng-model="contact.phone"/>
<input type="text" name="contactEmail" ng-model="contact.email"/>
</form>
本章的目标不是 Angular,所以我就不深入细节了。我们只需要从这个例子中知道的是,$scope是我们告诉 Angular 使我们的联系模型对视图可用的方式。在视图中,自定义属性ng-model告诉 Angular 在作用域中查找该属性。这样建立了一种双向关系,当作用域中的模型数据发生变化时,Angular 刷新 UI。同样,如果用户编辑表单,Angular 更新模型,保持一切同步。
然而,这种方法的两个主要问题是:
-
这可能很慢。Angular 及其朋友实现双向数据绑定的方式,大致来说,是通过为视图中的自定义属性和模型属性附加事件处理程序和观察者。对于足够复杂的用户界面,你将开始注意到 UI 渲染速度变慢,从而降低了用户体验。
-
它严重依赖于突变。作为函数式程序员,我们努力将副作用限制到最小。
这种方法及其类似方法带来的缓慢有两方面:首先,AngularJS 及其朋友必须“观察”作用域中每个模型的每个属性以跟踪更新。一旦框架确定模型中的数据已更改,它就会查找依赖于该信息的 UI 部分——例如上面的使用ng-model的片段——然后重新渲染它们。
其次,DOM 是大多数单页 Web 应用中最慢的部分。如果我们稍微思考一下,这些框架正在触发数十或数百个 DOM 事件处理程序以保持数据同步,每个处理程序最终都会更新 DOM 中的节点——或多个节点。
但是,不要只听我的话。我运行了一个简单的基准测试来比较纯计算与定位 DOM 元素并更新其值为计算结果的差异。以下是结果——我使用了 JSPerf 来运行基准测试,这些结果是在 Chrome 37.0.2062.94 上 Mac OS X Mavericks 上获得的(见jsperf.com/purefunctions-vs-dom):
document.getElementsByName("sum")[0].value = 1 + 2
// Operations per second: 2,090,202
1 + 2
// Operations per second: 780,538,120
更新 DOM 比执行简单的计算慢得多。从逻辑上讲,我们希望以尽可能高效的方式完成这项工作。
然而,如果我们不保持数据同步,我们就会回到起点。应该有一种方法可以大幅减少渲染量,同时保留双向数据绑定的便利性。我们能否既吃蛋糕又吃蛋糕?
进入 React.js
正如我们将在本章中看到的,对上一节提出的问题的答案是响亮的肯定,正如你可能已经猜到的,它涉及到 React.js。
但是什么让它变得特殊?
智慧的做法是从 React 不是什么开始。它不是一个 MVC 框架,因此它不是 AngularJS、Backbone.js 等类似框架的替代品。React 专注于 MVC 中的 V,并以一种令人耳目一新的方式来思考用户界面。我们必须稍微偏离一下,以便探索它是如何做到这一点的。
函数式编程的经验教训
作为函数式程序员,我们不需要被不可变性的好处所说服。我们很久以前就接受了这个前提。然而,如果我们不能有效地使用不可变性,它就不会在函数式编程语言中变得普遍。
我们应该感谢投入大量研究纯函数数据结构的巨大努力——首先是 Okasaki 在他的同名书中(见www.amazon.com/Purely-Functional-Structures-Chris-Okasaki/dp/0521663504/ref=sr_1_1?ie=UTF8&qid=1409550695&sr=8-1&keywords=purely+functional+data+structures),然后其他人对其进行了改进。
没有它,我们的程序在空间和运行时复杂度上都会膨胀。
通用思路是,给定一个数据结构,唯一更新它的方式是通过创建一个带有所需增量(delta)的它的副本:
(conj [1 2 3] 4) ;; [1 2 3 4]

在前面的图像中,我们有一个关于conj如何操作的简单看法。在左边,我们有代表我们希望更新的向量的底层数据结构。在右边,我们有新创建的向量,正如我们所看到的,它与之前的向量共享一些结构,同时包含我们的新项目。
小贴士
事实上,底层数据结构是一个树,而表示是为了本书的目的而简化的。我强烈建议读者参考 Okasaki 的书,以了解更多关于纯函数数据结构如何工作的细节。
此外,这些函数被认为是纯函数。也就是说,它将每个输入关联到单个输出,并不做其他任何事情。实际上,这与 React 处理用户界面的方式非常相似。
如果我们将 UI 视为数据结构的视觉表示,它反映了我们应用程序的当前状态,那么我们可以毫不费力地认为 UI 更新是一个简单的函数,其输入是应用程序状态,输出是 DOM 表示。
你会注意到我没有说输出是渲染到 DOM 的——那样会使函数变得不纯,因为渲染显然是一个副作用。这也会使它和替代方案一样慢。
这种 DOM 表示基本上是一个 DOM 节点的树,它模拟了你的 UI 应该看起来是什么样子,没有其他内容。
React 将这种表示称为 虚拟 DOM,粗略地说,React 不是通过监视触发 DOM 重新渲染的应用程序状态的单个部分和片段,而是将你的 UI 转换为一个函数,你可以向它提供整个应用程序状态。
当你向这个函数提供新的更新状态时,React 将该状态渲染到虚拟 DOM。记住,虚拟 DOM 只是一个数据结构,所以渲染非常快。一旦完成,React 会做两件事之一:
-
如果这是第一次渲染,它会将虚拟 DOM 提交到实际 DOM。
-
否则,它会将新的虚拟 DOM 与从应用程序上一次渲染缓存的当前虚拟 DOM 进行比较。然后,它使用一个高效的差异算法来计算更新真实 DOM 所需的最小更改集。最后,它将这个差异提交到 DOM。
不深入 React 的细节,这基本上就是它的实现方式,也是它比其他替代方案更快的原因。从概念上讲,每当应用程序状态发生变化时,React 就会点击“刷新”按钮。
另一个巨大的好处是,通过将你的 UI 视为一个从应用程序状态到虚拟 DOM 的函数,我们恢复了一些在函数式语言中处理不可变数据结构时能够进行的推理。
在接下来的章节中,我们将了解为什么这对我们 Clojure 开发者来说是一个巨大的胜利。
ClojureScript 和 Om
为什么我在 Clojure 书中花了六页的篇幅来谈论 JavaScript 和 React?我保证我不是在浪费你宝贵的时间;我们只是需要一些背景知识来理解接下来要讲的内容。
Om 是由 Cognitect 的多产且杰出的个人 David Nolen 开发的 ClojureScript 接口到 React.js。是的,他还开发了 core.logic、core.match 以及 ClojureScript 编译器。这就是他的多产。但我在这里跑题了。
当 Facebook 发布 React 时,David 立即看到了其潜力,更重要的是,如何利用我们在 Clojure 中编程时能够做出的假设,其中最重要的是数据结构不会改变。
React 提供了几个组件生命周期函数,允许开发者控制各种属性和行为。特别是 shouldComponentUpdate,它用于决定组件是否需要重新渲染。
React 在这里面临着一个巨大的挑战。JavaScript 本质上是可变的,所以在比较虚拟 DOM 树时,要高效地识别哪些节点已更改是非常困难的。React 采用了一些启发式方法来避免 O(n 3 ) 最坏情况性能,并且大多数时候能够在 O(n) 内完成。由于启发式方法并不完美,我们可以选择提供自己的 shouldComponentUpdate 实现,并在渲染组件时利用我们所拥有的知识。
相反,ClojureScript 使用不可变数据结构。因此,Om 提供了最简单和最有效的 shouldComponentUpdate 实现:简单的引用等价性检查。
由于我们总是处理不可变的数据结构,为了知道两棵树是否相同,我们只需要比较它们的根是否相同。如果它们相同,我们就完成了。否则,向下递归并重复此过程。这保证了产生 O(log n) 的运行时间复杂度,并允许 Om 总是从根高效地渲染 UI。
当然,性能并不是 Om 的唯一优点——我们现在将探讨是什么让 Om 应用程序变得出色。
使用 Om 构建简单的联系人应用程序
到目前为止,这一章已经非常注重文本了。是我们动手构建一个简单的 Om 应用程序的时候了。既然我们之前已经讨论了联系人,那么我们就从联系人开始。
React 和 Om 的主要驱动因素是构建高度可重用、自包含组件的能力,因此,即使在简单的 Contacts 应用程序中,我们也将有多个组件协同工作以实现共同目标。
这是我们的用户应该在应用程序中能够做到的:
-
显示当前存储中的联系人列表
-
显示给定联系人的详细信息
-
编辑特定联系人的详细信息
一旦我们完成,它将看起来像以下这样:

联系人应用程序状态
如前所述,Om/React 最终将根据我们的应用程序状态渲染 DOM。我们将使用内存中的数据来简化示例。以下是我们的应用程序状态将看起来像这样:
(def app-state
(atom {:contacts {1 {:id 1
:name "James Hetfield"
:email "james@metallica.com"
:phone "+1 XXX XXX XXX"}
2 {:id 2
:name "Adam Darski"
:email "the.nergal@behemoth.pl"
:phone "+48 XXX XXX XXX"}}
:selected-contact-id []
:editing [false]}))
我们将状态保存在原子中的原因是因为 Om 使用它来重新渲染应用程序,如果我们进行 swap! 或 reset! 操作,例如,如果我们首次渲染应用程序后从服务器加载数据。
国家本身的数据应该大部分是自我解释的。我们有一个包含所有联系人的地图,一个表示当前是否有联系人被选中的键,以及一个表示我们是否正在编辑所选联系人的标志。可能看起来有些奇怪的是,:selected-contact-id 和 :editing keys 都指向一个向量。请稍等片刻;这个原因很快就会变得清晰。
现在我们已经有了我们的应用程序状态草稿,是时候考虑状态如何在我们的应用程序的不同组件中流动了。一图胜千言,所以下面的图展示了我们的数据将通过的高层架构:

在前面的图像中,每个函数都对应一个 Om 组件。至少,它们以一些数据作为它们的初始状态。在这张图中有趣的是,当我们深入到更专业的组件时,它们请求的状态比主组件contacts-app少。例如,contacts-view组件需要所有联系人以及选中联系人的 ID。另一方面,details-panel-view组件只需要当前选中的联系人,以及它是否正在被编辑。这是 Om 中的一种常见模式,我们通常希望避免过度共享应用程序状态。
在对高层架构有一个粗略的了解后,我们就可以开始构建我们的联系人应用程序了。
设置联系人项目
再次强调,我们将使用 Leiningen 模板来帮助我们开始。这次我们将使用om-start(见github.com/magomimmo/om-start-template),也是由 Mimmo Cosenza(见github.com/magomimmo)提供的。在终端中键入以下内容以使用此模板创建基础项目:
lein new om-start contacts
cd contacts
接下来,让我们打开project.clj文件,确保我们有模板拉入的各种不同依赖项的相同版本。这样我们就不必担心不兼容的版本:
...
:dependencies [[org.clojure/clojure "1.6.0"]
[org.clojure/clojurescript "0.0-2277"]
[org.clojure/core.async "0.1.338.0-5c5012-alpha"]
[om "0.7.1"]
[com.facebook/react "0.11.1"]]
...
为了验证新的项目骨架,仍然在终端中,键入以下内容来自动编译你的 ClojureScript 源文件:
lein cljsbuild auto
Compiling ClojureScript.
Compiling "dev-resources/public/js/contacts.js" from ("src/cljs" "dev-resources/tools/repl")...
Successfully compiled "dev-resources/public/js/contacts.js" in 9.563 seconds.
现在,如果我们打开浏览器中的dev-resources/public/index.html文件,我们应该能看到模板默认的“Hello World”页面。
应用程序组件
下一步,我们将打开src/cljs/contacts/core.cljs文件,这是我们的应用程序代码将放置的地方,并确保它看起来像以下内容,以便我们有适当的命名空间声明的一个干净的起点:
(ns contacts.core
(:require [om.core :as om :include-macros true]
[om.dom :as dom :include-macros true]))
(enable-console-print!)
(def app-state
(atom {:contacts {1 {:id 1
:name "James Hetfield"
:email "james@metallica.com"
:phone "+1 XXX XXX XXX"}
2 {:id 2
:name "Adam Darski"
:email "the.nergal@behemoth.pl"
:phone "+48 XXX XXX XXX"}}
:selected-contact-id []
:editing [false]}))
(om/root
contacts-app
app-state
{:target (. js/document (getElementById "app"))})
每个 Om 应用程序都是以om/root函数创建的根组件开始的。它接受一个表示组件的函数——contacts-app——应用程序的初始状态——app-state——以及一个选项映射,其中我们唯一关心的是:target,它告诉 Om 在哪里将根组件挂载到 DOM 上。
在这种情况下,它将挂载到一个 ID 为app的 DOM 元素上。这个元素是由om-start模板给出的,位于dev-resources/public/index.html文件中。
当然,这段代码现在还不能编译,因为我们还没有contacts-app模板。让我们解决这个问题,并在前面的声明上方创建它——我们是自下而上实现组件的:
(defn contacts-app [data owner]
(reify
om/IRender
(render [this]
(let [[selected-id :as selected-id-cursor]
(:selected-contact-id data)]
(dom/div nil
(om/build contacts-view
{:contacts (:contacts data)
:selected-id-cursor selected-id-cursor})
(om/build details-panel-view
{:contact (get-in data [:contacts selected-id])
:editing-cursor (:editing data)}))))))
When describing om/root, we saw that its first argument must be an Om component. The contact-app function creates one by reifying the om/IRender protocol. This protocol contains a single function—render—which gets called when the application state changes.
小贴士
Clojure 使用 reify 动态实现协议或 Java 接口,而不需要创建一个新的类型。你可以在 Clojure 文档的数据类型页面了解更多信息,请访问clojure.org/datatypes。
render函数必须返回一个Om/React组件或 React 知道如何渲染的内容——例如组件的 DOM 表示。contacts-app的参数很简单:data是组件状态,owner是后端的 React 组件。
在源文件向下移动,在render的实现中,我们有以下内容:
(let [[selected-id :as selected-id-cursor]
(:selected-contact-id data)]
...)
如果我们从应用程序状态中回忆起来,:selected-contact-id的值在这个阶段是一个空向量。因此,在这里,我们正在对这个向量进行解构并给它命名。你现在可能想知道为什么我们将向量绑定到一个名为selected-id-cursor的变量上。这是因为在这个组件生命周期的这个点上,selected-id-cursor不再是一个向量,而是一个光标。
Om 光标
一旦om/root创建了我们的根组件,子组件就不再直接访问状态原子了。相反,组件接收一个从应用程序状态创建的光标。
光标是表示原始状态原子中位置的数结构。你可以使用光标来读取、删除、更新或创建一个值,而无需了解原始数据结构。让我们以selected-id-cursor光标为例:

在顶部,我们有原始的应用程序状态,Om 将其转换为光标。当我们从这个状态中请求:selected-contact-id键时,Om 给我们另一个光标,代表数据结构中的那个特定位置。碰巧的是,它的值是一个空向量。
这个光标有趣的地方在于,如果我们使用 Om 的状态转换函数(如om/transact!和om/update!)之一来更新它的值——我们很快就会解释这些函数——它知道如何将更改传播到树的上层,并最终回到应用程序状态原子。
这很重要,因为我们之前简要提到过,让我们的更专业化的组件依赖于其正确操作所需的应用程序状态的具体部分是一种常见的做法。
通过使用光标,我们可以轻松地传播变化,而无需了解应用程序状态的外观,从而避免访问全局状态的需求。
小贴士
你可以将光标想象成 zipper。从概念上讲,它们服务于类似的目的,但具有不同的 API。
填补空白
在contacts-app组件向下移动,我们现在有以下内容:
(dom/div nil
(om/build contacts-view
{:contacts (:contacts data)
:selected-id-cursor selected-id-cursor})
(om/build details-panel-view
{:contact (get-in data [:contacts selected-id])
:editing-cursor (:editing data)}))
dom 命名空间包含 React DOM 类的薄包装。它本质上代表应用程序将呈现的数据结构。接下来,我们看到两个示例,说明我们如何在另一个 Om 组件内部创建 Om 组件。我们使用 om/build 函数来做这件事,并创建了 contacts-view 和 details-panel-view 组件。om/build 函数接受组件函数、组件状态以及可选的选项映射作为参数,这些选项在这个例子中并不重要。
到目前为止,我们已经通过创建子游标开始限制传递给子组件的状态。
根据源代码,我们应该查看的下一个组件是 contacts-view。下面是它的完整代码:
(defn contacts-view [{:keys [contacts selected-id-cursor]} owner]
(reify
om/IRender
(render [_]
(dom/div #js {:style #js {:float "left"
:width "50%"}}
(apply dom/ul nil
(om/build-all contact-summary-view (vals contacts)
{:shared {:selected-id-cursor selected-id-cursor}}))))))
希望现在这个组件的源代码看起来更熟悉一些。和之前一样,我们重新实现 om/IRender 以提供我们组件的 DOM 表示。它由一个单一的 div 元素组成。这次我们将一个表示 HTML 属性的哈希表作为 dom/div 的第二个参数。我们使用了一些内联样式,但理想情况下我们会使用外部样式表。
小贴士
如果你不太熟悉 #js {…} 语法,它只是一个扩展到 (clj->js {…}) 的读取宏,目的是将 ClojureScript 的哈希表转换为 JavaScript 对象。需要注意的是,它不是递归的,正如 #js 的嵌套使用所证明的那样。
dom/div 的第三个参数比我们之前看到的要稍微复杂一些:
(apply dom/ul nil
(om/build-all contact-summary-view (vals contacts)
{:shared {:selected-id-cursor selected-id-cursor}}))
每个联系人将由一个 li(列表项)HTML 节点表示,因此我们首先将结果包装到一个 dom/ul 元素中。然后,我们使用 om/build-all 来构建一个联系人 summary-view 组件的列表。Om 将依次调用 om/build 来处理 vals contacts 中的每个联系人。
最后,我们使用 om/build-all 的第三个参数——选项映射——来演示我们如何在组件之间共享状态而不使用全局状态。我们将在下一个组件 contact-summary-view 中看到它是如何使用的:
(defn contact-summary-view [{:keys [name phone] :as contact} owner]
(reify
om/IRender
(render [_]
(dom/li #js {:onClick #(select-contact! @contact
(om/get-shared owner :selected-id-cursor))}
(dom/span nil name)
(dom/span nil phone)))))
如果我们将应用程序视为一个组件树,我们现在已经到达了其中的一片叶子。这个组件简单地返回一个包含联系人的姓名和电话的 dom/li 节点,并用 dom/span 节点包装。
它还安装了一个处理 dom/li onClick 事件的处理器,我们可以用它来更新状态游标。
我们使用 om/get-shared 来访问我们之前安装的共享状态,并将结果游标传递给 select-contact!。我们还传递了当前联系人,但如果你仔细看,我们必须首先 deref 它:
@contact
原因是 Om 不允许我们在渲染阶段之外操作游标。通过解引用游标,我们得到其最新的底层值。现在 select-contact! 有它需要执行更新的所有内容:
(defn select-contact! [contact selected-id-cursor]
(om/update! selected-id-cursor 0 (:id contact)))
我们简单地使用 om/update! 将索引 0 处的 selected-id-cursor 游标值设置为联系人的 id。如前所述,游标负责传播更改。
小贴士
您可以将 om/update! 视为在原子中使用 clojure.core/reset! 的光标版本。相反,同样适用于 om/transact! 和 clojure.core/swap!。
我们正在以良好的速度前进。是时候看看下一个组件 details-panel-view 了:
(defn details-panel-view [data owner]
(reify
om/IRender
(render [_]
(dom/div #js {:style #js {:float "right"
:width "50%"}}
(om/build contact-details-view data)
(om/build contact-details-form-view data)))))
这个组件现在看起来应该相当熟悉。它所做的只是构建两个其他组件,contact-details-view 和 contact-details-form-view:
(defn contact-details-view [{{:keys [name phone email id] :as contact} :contact
editing :editing-cursor}
owner]
(reify
om/IRender
(render [_]
(dom/div #js {:style #js {:display (if (get editing 0) "none" "")}}
(dom/h2 nil "Contact details")
(if contact
(dom/div nil
(dom/h3 #js {:style #js {:margin-bottom "0px"}} (:name contact))
(dom/span nil (:phone contact)) (dom/br nil)
(dom/span nil (:email contact)) (dom/br nil)
(dom/button #js {:onClick #(om/update! editing 0 true)}
"Edit"))
(dom/span nil "No contact selected"))))))
contact-details-view 组件接收两份数据状态:联系人和编辑标志。如果我们有一个联系人,我们只需渲染该组件。然而,我们使用编辑标志来隐藏它,如果我们正在编辑它。这样我们就可以在下一个组件中显示编辑表单。我们还为编辑按钮安装了一个 onClick 处理器,以便我们可以更新编辑光标。
contact-details-form-view 组件接收相同的参数,但渲染以下表单:
(defn contact-details-form-view [{{:keys [name phone email id] :as contact} :contact
editing :editing-cursor}
owner]
(reify
om/IRender
(render [_]
(dom/div #js {:style #js {:display (if (get editing 0) "" "none")}}
(dom/h2 nil "Contact details")
(if contact
(dom/div nil
(dom/input #js {:type "text"
:value name
:onChange #(update-contact! % contact :name)})
(dom/input #js {:type "text"
:value phone
:onChange #(update-contact! % contact :phone)})
(dom/input #js {:type "text"
:value email
:onChange #(update-contact! % contact :email)})
(dom/button #js {:onClick #(om/update! editing 0 false)}
"Save"))
(dom/div nil "No contact selected"))))))
这个组件负责根据表单更新联系人信息。它是通过调用 update-contact! 并传递 JavaScript 事件、联系人光标和表示要更新的属性的键来实现的:
(defn update-contact! [e contact key]
(om/update! contact key (.. e -target -value)))
如前所述,我们只是使用 om/update! 而不是 om/transact!,因为我们只是用触发事件的表单字段的当前值替换光标属性的值 e。
注意
如果您不熟悉 .. 语法,它只是一个方便的宏,用于 Java 和 JavaScript 的互操作性。前面的例子展开为:
(. (. e -target) -value)
这和其他互操作性操作符在 Clojure 网站的 Java Interop 页面上有描述(见 clojure.org/java_interop)。
就这样。确保您的代码仍在编译——或者如果您还没有开始,请在终端中键入以下内容以启动自动编译:
lein cljsbuild auto
然后,再次在您的浏览器中打开 dev-resources/public/index.html,并尝试我们的联系人应用程序!特别注意,当您编辑联系人属性时,应用程序状态始终处于同步状态。
如果在此阶段有任何问题,请确保 src/cljs/contacts/core.cljs 文件与本书的配套代码匹配。
组件间通信
在我们之前的例子中,我们构建的组件通过应用程序状态相互通信,无论是读取还是交易数据。虽然这种方法可行,但除了非常简单的用例外,它并不总是最佳选择。在本节中,我们将学习使用 core.async 通道执行这种通信的另一种方式。
我们将要构建的应用程序是一个非常简单的虚拟敏捷看板。如果您听说过,它类似于 Trello(见 trello.com/)。如果您没有听说过,不用担心,它本质上是一个任务管理网络应用程序,其中您有代表任务的卡片,并且您可以在例如 待办事项、进行中 和 完成 等列之间移动它们。
到本节结束时,应用程序将看起来如下:

我们将限制自己只实现一个功能:通过拖放卡片在列之间移动。让我们开始吧。
使用 Om 创建敏捷看板
我们已经熟悉了 om-start(见 github.com/magomimmo/om-start-template)的 leiningen 模板,并且没有理由去改变它,所以我们将使用它来创建我们的项目——我将其命名为 om-pm 以代表Om 项目管理:
lein new om-start om-pm
cd om-pm
和之前一样,我们应该确保我们的 project.clj 文件中有正确的依赖项:
:dependencies [[org.clojure/clojure "1.6.0"]
[org.clojure/clojurescript "0.0-2511"]
[org.om/om "0.8.1"]
[org.clojure/core.async "0.1.346.0-17112a-alpha"]
[com.facebook/react "0.12.2"]]
现在确保项目正确编译,以验证我们处于良好状态:
lein cljsbuild auto
Compiling ClojureScript.
Compiling "dev-resources/public/js/om_pm.js" from ("src/cljs" "dev-resources/tools/repl")...
Successfully compiled "dev-resources/public/js/om_pm.js" in 13.101 seconds.
接下来,打开 src/cljs/om_pm/core.cljs 文件,并添加我们将用于构建应用程序的命名空间:
(ns om-pm.core
(:require [om.core :as om :include-macros true]
[om.dom :as dom :include-macros true]
[cljs.core.async :refer [put! chan <!]]
[om-pm.util :refer [set-transfer-data! get-transfer-data! move-card!]])
(:require-macros [cljs.core.async.macros :refer [go go-loop]]))
这次的主要区别是我们正在要求 core.async 函数和宏。我们还没有 om-pm.util 命名空间,但我们会在这个结尾处解决这个问题。
桌板状态
是时候考虑我们的应用程序状态将是什么样子了。在这个应用程序中,我们的主要实体是卡片,它代表一个任务,并具有 id、title 和 description 属性。我们将首先定义几个卡片:
(def cards [{:id 1
:title "Groceries shopping"
:description "Almond milk, mixed nuts, eggs..."}
{:id 2
:title "Expenses"
:description "Submit last client's expense report"}])
这还不是我们的应用程序状态,而是它的一部分。另一个重要的状态部分是跟踪哪些卡片在哪些列上的方式。为了简化问题,我们将只处理三个列:待办事项、进行中和完成。默认情况下,所有卡片都从待办事项开始:
(def app-state
(atom {:cards cards
:columns [{:title "Backlog"
:cards (mapv :id cards)}
{:title "In Progress"
:cards []}
{:title "Done"
:cards []}]}))
这就是我们所需要的所有状态。列具有 :title 和 :cards 属性,其中包含该列中所有卡片的所有 ID。
此外,我们还将有一个辅助函数来使查找卡片更加方便:
(defn card-by-id [id]
(first (filterv #(= id (:id %)) cards)))
小贴士
小心懒序列
你可能已经注意到了在检索卡片 ID 时使用 mapv 而不是 map。这是一个微妙但重要的区别:map 默认是懒的,但 Om 只能创建 maps 和 vectors 的 cursors。使用 mapv 给我们一个 vector,避免了懒加载。
如果我们没有做那件事,Om 会将 ID 列表视为一个普通值,我们就无法进行交易。
组件概述
将 Om 应用程序分割成组件的方法有很多,在本节中,我们将通过遍历每个组件的实现来展示一种方法。
我们将遵循的方法与我们之前的应用程序类似,即从这一点开始,我们将自下而上地展示组件。
在我们看到第一个组件之前,我们应从 Om 的自身 root 组件开始:
(om/root project-view app-state
{:target (. js/document (getElementById "app"))})
这为我们提供了关于下一个组件的线索,project-view:
(defn project-view [app owner]
(reify
om/IInitState
(init-state [_]
{:transfer-chan (chan)})
om/IWillMount
(will-mount [_]
(let [transfer-chan (om/get-state owner :transfer-chan)]
(go-loop []
(let [transfer-data (<! transfer-chan)]
(om/transact! app :columns
#(move-card! % transfer-data))
(recur)))))
om/IRenderState
(render-state [this state]
(dom/div nil
(apply dom/ul nil
(om/build-all column-view (:columns app)
{:shared {:cards (:cards app)}
:init-state state}))))))
生命周期和组件本地状态
之前的组件与我们之前看到的组件相当不同。更具体地说,它实现了两个新的协议:om/IInitState 和 om/IWillMount。此外,我们完全放弃了 om/IRender,转而使用 om/IRenderState。在我们解释这些新协议有什么好处之前,我们需要讨论我们的高级设计。
project-view 组件是应用程序的主要入口点,它将整个应用程序状态作为其第一个参数。正如我们早期的 Contacts 应用程序一样,然后它使用所需的数据实例化剩余的组件。
然而,与 Contacts 示例不同的是,它创建了一个 core.async 通道——transfer-chan——它作为一个消息总线工作。想法是,当我们从一个列拖动卡片并将其拖放到另一个列时,我们的一个组件将把一个传输事件放入这个通道,并让其他人——很可能是 go 块——执行实际的移动操作。
这是在之前显示的组件中取出的以下片段中完成的:
om/IInitState
(init-state [_]
{:transfer-chan (chan)})
这就创建了 Om 所说的组件本地状态。它使用不同的生命周期协议,om/IInitState,它保证只被调用一次。毕竟,我们需要为这个组件提供一个单独的通道。init-state 应该返回一个表示本地状态的映射。
现在我们有了通道,我们需要安装一个 go-loop 来处理发送给它的消息。为此,我们使用不同的协议:
om/IWillMount
(will-mount [_]
(let [transfer-chan (om/get-state owner :transfer-chan)]
(go-loop []
(let [transfer-data (<! transfer-chan)]
(om/transact! app :columns #(move-card! % transfer-data))
(recur)))))
与之前的协议一样,om/IWillMount 在组件生命周期中也会被调用一次。它在即将被挂载到 DOM 中的时候被调用,是安装 go-loop 到我们通道中的完美位置。
小贴士
在 Om 应用程序中创建 core.async 通道时,避免在多次被调用的生命周期函数内部创建它们非常重要。除了非确定性行为之外,这还是内存泄漏的来源。
我们使用 om/get-state 函数从组件本地状态中获取它。一旦我们收到消息,我们就进行状态交易。我们很快就会看到 transfer-data 的样子。
我们通过实现其渲染函数来完成组件:
...
om/IRenderState
(render-state [this state]
(dom/div nil
(apply dom/ul nil
(om/build-all column-view (:columns app)
{:shared {:cards (:cards app)}
:init-state state}))))
...
om/IRenderState 函数与 om/IRender 具有相同的目的,即它应该返回组件应该看起来像的 DOM 表示。然而,它定义了一个不同的函数,render-state,它将组件本地状态作为其第二个参数。这个状态包含我们在 init-state 阶段创建的映射。
剩余组件
接下来,我们将构建多个 column-view 组件,每个列一个。它们接收来自应用程序状态中的卡片列表作为它们的共享状态。我们将使用它来从每个列中存储的 ID 中检索卡片详情。
我们还使用 :init-state 键用我们的通道初始化每个列视图的本地状态,因为所有列都需要对其有一个引用。以下是组件的外观:
(defn column-view [{:keys [title cards]} owner]
(reify
om/IRenderState
(render-state [this {:keys [transfer-chan]}]
(dom/div #js {:style #js {:border "1px solid black"
:float "left"
:height "100%"
:width "320px"
:padding "10px"}
:onDragOver #(.preventDefault %)
:onDrop #(handle-drop % transfer-chan title)}
(dom/h2 nil title)
(apply dom/ul #js {:style #js {:list-style-type "none"
:padding "0px"}}
(om/build-all (partial card-view title)
(mapv card-by-id cards)))))))
到目前为止,代码看起来相当熟悉。我们在示例中使用了内联 CSS 以保持简单,但在实际应用中,我们可能会使用外部样式表。
我们再次实现 render-state 以检索传输通道,该通道将在处理 onDrop JavaScript 事件时使用。当用户将可拖动的 DOM 元素拖放到此组件上时,浏览器将触发此事件。handle-drop 如此处理:
(defn handle-drop [e transfer-chan column-title]
(.preventDefault e)
(let [data {:card-id
(js/parseInt (get-transfer-data! e "cardId"))
:source-column
(get-transfer-data! e "sourceColumn")
:destination-column
column-title}]
(put! transfer-chan data)))
此函数创建传输数据——一个包含键 :card-id、:source-column 和 :destination-column 的映射,这是我们移动卡片在列之间所需的一切。最后,我们将它 put! 到传输通道中。
接下来,我们构建一个或多个 card-view 组件。如前所述,Om 不能从懒序列创建游标,所以我们使用 filterv 给每个 card-view 提供一个包含它们各自卡片的向量。让我们看看它的源代码:
(defn card-view [column {:keys [id title description] :as card} owner]
(reify
om/IRender
(render [this]
(dom/li #js {:style #js {:border "1px solid black"}
:draggable true
:onDragStart (fn [e]
(set-transfer-data! e "cardId" id)
(set-transfer-data! e "sourceColumn" column))}
(dom/span nil title)
(dom/p nil description)))))
由于这个组件不需要任何本地状态,我们回归使用 IRender 协议。此外,我们使其可拖动,并在 onDragStart 事件上安装事件处理器,该事件将在用户开始拖动卡片时触发。
此事件处理器设置传输数据,我们从 handle-drop 中使用。
我们已经忽略了这些组件使用了一些实用函数的事实。没关系,因为我们现在将在新的命名空间中定义它们。
实用函数
在 src/cljs/om_pm/ 下创建一个名为 util.cljs 的新文件,并添加以下命名空间声明:
(ns om-pm.util)
为了保持一致性,我们将从 move-card! 函数开始,自下而上查看函数:
(defn column-idx [title columns]
(first (keep-indexed (fn [idx column]
(when (= title (:title column))
idx))
columns)))
(defn move-card! [columns {:keys [card-id source-column destination-column]}]
(let [from (column-idx source-column columns)
to (column-idx destination-column columns)]
(-> columns
(update-in [from :cards] (fn [cards]
(remove #{card-id} cards)))
(update-in [to :cards] (fn [cards]
(conj cards card-id))))))
move-card! 函数接收我们应用程序状态中列的游标,并简单地移动 card-id 在源和目标之间。你会注意到我们不需要访问 core.async 或 Om 特定函数,这意味着这个函数是纯的,因此很容易测试。
接下来,我们有处理传输数据的函数:
(defn set-transfer-data! [e key value]
(.setData (-> e .-nativeEvent .-dataTransfer)
key value))
(defn get-transfer-data! [e key]
(-> (-> e .-nativeEvent .-dataTransfer)
(.getData key)))
这些函数使用 JavaScript 互操作性来与 HTML 的 DataTransfer(见 developer.mozilla.org/en-US/docs/Web/API/DataTransfer)对象交互。这是浏览器共享与拖放事件相关的数据的方式。
现在,让我们简单地保存文件并确保代码正确编译。我们最终可以在浏览器中打开 dev-resources/public/index.html 并对我们的工作成果进行尝试!
练习
在这个练习中,我们将修改上一节中创建的 om-pm 项目。目标是添加键盘快捷键,以便高级用户可以更高效地操作敏捷看板。
要支持的快捷键:
-
上、下、左和右方向键:这些允许用户在卡片之间导航,突出显示当前卡片 -
n和p键:这些用于将当前卡片移动到下一个(右)或上一个(左)列,分别
这里的关键洞察是创建一个新的core.async通道,该通道将包含按键事件。然后,这些事件将触发之前概述的操作。我们可以使用 Google closure 库来监听事件。只需将以下require添加到应用程序命名空间中:
(:require [goog.events :as events])
然后,使用此函数从 DOM 事件创建通道:
(defn listen [el type]
(let [c (chan)]
(events/listen el type #(put! c %))
c))
基于键盘快捷键移动卡片的具体逻辑可以通过多种方式实现,所以不要忘记将你的解决方案与本书配套代码中提供的答案进行比较。
摘要
在本章中,我们看到了 Om 和 React 处理响应式 Web 界面的不同方法。反过来,这些框架通过应用函数式编程原则,如不可变性和持久数据结构,使得这一过程变得可能且无痛苦。
我们还学会了以 Om 的方式思考,通过将应用程序结构化为一系列函数,这些函数接收状态并输出状态变化的 DOM 表示。
此外,我们还看到通过通过core.async通道来结构化应用程序状态转换,我们可以将展示逻辑与实际执行工作的代码分离,这使得我们的组件更加易于推理。
在下一章中,我们将转向创建响应式应用程序的一个经常被忽视但很有用的工具:Futures。
第八章。未来
向反应式应用迈出的第一步是跳出同步处理。一般来说,应用程序浪费了很多时间等待事情发生。也许我们在等待一个昂贵的计算——比如计算第 1000 个斐波那契数。也许我们在等待某些信息被写入数据库。我们也可以在等待一个网络调用返回,带给我们我们最喜欢的在线商店的最新推荐。
无论我们等待什么,我们都不应该阻塞我们应用程序的客户端。这对于在构建反应式系统时实现我们想要的响应性至关重要。
在处理核心丰富的时代——我的 MacBook Pro 有八个处理器核心——阻塞 API 严重地未充分利用我们可用的资源。
随着我们接近这本书的结尾,适当地退后一步,欣赏到并非所有处理并发、异步计算的类问题都需要像 RxJava 或core.async这样的框架机制。
在本章中,我们将探讨另一个有助于我们开发并发、异步应用的抽象:未来。我们将了解:
-
Clojure 实现 futures 的问题和局限性
-
Clojure 的 futures 的替代方案,提供异步、可组合的语义
-
如何在面临阻塞 IO 的情况下优化并发
Clojure futures
解决这个问题的第一步——即防止一个可能长时间运行的任务阻塞我们的应用程序——是创建新的线程,这些线程执行工作并等待其完成。这样,我们保持应用程序的主线程空闲,以便为更多客户端提供服务。
直接与线程工作,然而,却是繁琐且容易出错的,所以 Clojure 的核心库包括了未来(futures),它们的使用极其简单:
(def f (clojure.core/future
(println "doing some expensive work...")
(Thread/sleep 5000)
(println "done")
10))
(println "You'll see me before the future finishes")
;; doing some expensive work...
;; You'll see me before the future finishes
;; done
clojure.core/future macro with a body simulating an expensive computation. In this example, it simply sleeps for 5 seconds before returning the value 10\. As the output demonstrates, this does not block the main thread, which is free to serve more clients, pick work items from a queue, or what have you.
当然,最有趣的计算,如昂贵的计算,返回我们关心的结果。这就是 Clojure futures 的第一个局限性变得明显的地方。如果我们尝试在完成之前检索未来的结果——通过解引用它——调用线程将阻塞,直到未来返回一个值。尝试运行以下略微修改的先前代码片段:
(def f (clojure.core/future
(println "doing some expensive work...")
(Thread/sleep 5000)
(println "done")
10))
(println "You'll see me before the future finishes")
@f
(println "I could be doing something else. Instead I had to wait")
;; doing some expensive work...
;; You'll see me before the future finishes
;; 5 SECONDS LATER
;; done
;; I could be doing something else. Instead, I had to wait
现在唯一的区别是我们立即尝试在创建未来后立即解引用它。由于未来尚未完成,我们就会在那里等待 5 秒钟,直到它返回其值。只有在这种情况下,我们的程序才被允许继续。
通常,这在构建模块化系统时会导致问题。通常,像前面描述的那样,长时间运行的操作会在特定的模块或函数中启动,然后将其传递给下一个逻辑步骤以进行进一步处理。
Clojure futures 不允许我们在未来完成时安排一个函数执行以进行进一步处理。这是构建反应式系统的一个重要功能。
并行获取数据
为了更好地理解上一节中概述的问题,让我们构建一个更复杂的示例,该示例获取关于我喜欢的电影之一《指环王》的数据。
这个想法是,给定一部电影,我们希望检索其演员,并且对于每个演员,检索他们参与过的电影。我们还希望了解每个演员的更多信息,例如他们的配偶。
此外,我们将匹配每个演员的电影与顶级五部电影列表,以突出显示它们。最后,结果将打印到屏幕上。
从问题陈述中,我们确定了以下两个主要特征,我们需要考虑:
-
其中一些任务需要并行执行
-
它们相互建立依赖关系
为了开始,让我们创建一个新的 leiningen 项目:
lein new clj-futures-playground
接下来,打开 src/clj_futures_playground/core.clj 中的核心命名空间文件,并添加我们将要使用的数据:
(ns clj-futures-playground.core
(:require [clojure.pprint :refer [pprint]]))
(def movie
{:name "Lord of The Rings: The Fellowship of The Ring"
:cast ["Cate Blanchett"
"Elijah Wood"
"Liv Tyler"
"Orlando Bloom"]})
(def actor-movies
[{:name "Cate Blanchett"
:movies ["Lord of The Rings: The Fellowship of The Ring"
"Lord of The Rings: The Return of The King"
"The Curious Case of Benjamin Button"]}
{:name "Elijah Wood"
:movies ["Eternal Sunshine of the Spotless Mind"
"Green Street Hooligans"
"The Hobbit: An Unexpected Journey"]}
{:name "Liv Tyler"
:movies ["Lord of The Rings: The Fellowship of The Ring"
"Lord of The Rings: The Return of The King"
"Armageddon"]}
{:name "Orlando Bloom"
:movies ["Lord of The Rings: The Fellowship of The Ring"
"Lord of The Rings: The Return of The King"
"Pirates of the Caribbean: The Curse of the Black Pearl"]}])
(def actor-spouse
[{:name "Cate Blanchett" :spouse "Andrew Upton"}
{:name "Elijah Wood" :spouse "Unknown"}
{:name "Liv Tyler" :spouse "Royston Langdon"}
{:name "Orlando Bloom" :spouse "Miranda Kerr"}])
(def top-5-movies
["Lord of The Rings: The Fellowship of The Ring"
"The Matrix"
"The Matrix Reloaded"
"Pirates of the Caribbean: The Curse of the Black Pearl"
"Terminator"])
命名空间声明很简单,只需要 pprint 函数,这将帮助我们以易于阅读的格式打印我们的结果。有了所有数据,我们可以创建模拟远程服务的函数,这些服务负责获取相关数据:
(defn cast-by-movie [name]
(future (do (Thread/sleep 5000)
(:cast movie))))
(defn movies-by-actor [name]
(do (Thread/sleep 2000)
(->> actor-movies
(filter #(= name (:name %)))
first)))
(defn spouse-of [name]
(do (Thread/sleep 2000)
(->> actor-spouse
(filter #(= name (:name %)))
first)))
(defn top-5 []
(future (do (Thread/sleep 5000)
top-5-movies)))
每个 service 函数通过给定的时间量暂停当前线程以模拟缓慢的网络。函数 cast-by-movie 和 Top 5 每个都返回一个 future,表示我们希望在另一个线程上获取这些数据。其余的函数简单地返回实际数据。然而,它们也将在一个不同的线程中执行,正如我们很快将看到的。
下一步,我们需要一个函数来聚合所有获取的数据,将配偶与演员匹配,并突出显示 Top 5 列表中的电影。我们将称之为 aggregate-actor-data 函数:
(defn aggregate-actor-data [spouses movies top-5]
(map (fn [{:keys [name spouse]} {:keys [movies]}]
{:name name
:spouse spouse
:movies (map (fn [m]
(if (some #{m} top-5)
(str m " - (top 5)")
m))
movies)})
spouses
movies))
前面的函数相当直接。它只是将配偶和电影组合在一起,构建一个键为 :name、:spouse 和 :movies 的映射。它进一步将 movies 转换为在 top-5 列表中的项后面添加 Top 5 后缀。
最后一个拼图是 -main 函数,它允许我们从命令行运行程序:
(defn -main [& args]
(time (let [cast (cast-by-movie "Lord of The Rings: The Fellowship of The Ring")
movies (pmap movies-by-actor @cast)
spouses (pmap spouse-of @cast)
top-5 (top-5)]
(prn "Fetching data...")
(pprint (aggregate-actor-data spouses movies @top-5))
(shutdown-agents))))
First, we wrap the whole body in a call to time, a simple benchmarking function that comes with Clojure. This is just so we know how long the program took to fetch all data—this information will become relevant later.
然后,我们设置了一系列 let 绑定。第一个,cast,是调用 cast-by-movie 的结果,它返回一个 future。
下一个绑定,movies,使用了一个我们之前没有见过的函数:pmap。
pmap 函数类似于 map,除了函数是并行映射到列表中的项。pmap 函数在幕后使用 futures,这就是为什么 movies-by-actor 不返回 future——它将这个任务留给 pmap 处理。
提示
pmap 函数实际上是为 CPU 密集型操作设计的,但在这里使用是为了使代码简单。面对阻塞 I/O,pmap 不会表现最优。我们将在本章后面更多地讨论阻塞 I/O。
我们通过deref cast绑定来获取演员列表,正如我们在上一节中看到的,这会阻塞当前线程等待异步获取完成。一旦所有结果都准备好了,我们只需调用aggregate-actor-data函数。
最后,我们调用shutdown-agents函数,这将关闭 Clojure 中 futures 背后的线程池。这对于我们的程序正确终止是必要的,否则它会在终端中简单地挂起。
要运行程序,请在终端中(在项目根目录下)输入以下内容:
lein run -m clj-futures-playground.core
"Fetching data..."
({:name "Cate Blanchett",
:spouse "Andrew Upton",
:movies
("Lord of The Rings: The Fellowship of The Ring - (top 5)"
"Lord of The Rings: The Return of The King"
"The Curious Case of Benjamin Button")}
{:name "Elijah Wood",
:spouse "Unknown",
:movies
("Eternal Sunshine of the Spotless Mind"
"Green Street Hooligans"
"The Hobbit: An Unexpected Journey")}
{:name "Liv Tyler",
:spouse "Royston Langdon",
:movies
("Lord of The Rings: The Fellowship of The Ring - (top 5)"
"Lord of The Rings: The Return of The King"
"Armageddon")}
{:name "Orlando Bloom",
:spouse "Miranda Kerr",
:movies
("Lord of The Rings: The Fellowship of The Ring - (top 5)"
"Lord of The Rings: The Return of The King"
"Pirates of the Caribbean: The Curse of the Black Pearl - (top 5)")})
"Elapsed time: 10120.267 msecs"
你会注意到程序需要一段时间才能打印出第一条消息。此外,因为当 futures 被解引用时它们会阻塞,所以程序只有在完全完成《指环王》的演员阵容的获取后才会开始获取前五部电影的列表。
让我们看看为什么是这样的:
(time (let [cast (cast-by-movie "Lord of The Rings: The Fellowship of The Ring")
;; the following line blocks
movies (pmap movies-by-actor @cast)
spouses (pmap spouse-of @cast)
top-5 (top-5)]
cast-by-movie to finish. As stated previously, Clojure futures don't give us a way to run some piece of code when the future finishes—like a callback—forcing us to block too soon.
这防止了top-5——一个完全独立的并行数据获取——在我们检索电影的演员阵容之前运行。
当然,这是一个人为的例子,我们可以在调用top-5之前解决这个特定的烦恼。问题是解决方案并不总是那么清晰,理想情况下我们不应该担心执行顺序。
正如我们将在下一节中看到的,有更好的方法。
Imminent – 一个用于 Clojure 的可组合 futures 库
在过去几个月里,我一直在开发一个开源库,旨在解决 Clojure futures 之前的问题。这项工作的结果是称为imminent(见github.com/leonardoborges/imminent)的库。
基本的区别在于 imminent futures 默认是异步的,并提供了一些组合子,允许我们声明性地编写程序,而无需担心其执行顺序。
展示库如何工作的最佳方式是将之前的电影示例重写进其中。我们将分两步进行。
首先,我们将单独检查即将到来的 API 的各个部分,这些部分将是我们最终解决方案的一部分。然后,我们将把它们全部整合到一个工作应用中。让我们先创建一个新的项目:
lein new imminent-playground
接下来,将 imminent 的依赖项添加到你的project.clj中:
:dependencies [[org.clojure/clojure "1.6.0"]
[com.leonardoborges/imminent "0.1.0"]]
然后,创建一个新的文件,src/imminent_playground/repl.clj,并添加 imminent 的核心命名空间:
(ns imminent-playground.repl
(:require [imminent.core :as Ii]))
(def repl-out *out*)
(defn prn-to-repl [& args]
(binding [*out* repl-out]
(apply prn args)))
Feel free to type this in the REPL as we go along. Otherwise, you can require the namespace file from a running REPL like so:
(require 'imminent-playground.repl)
所有的以下示例都应该在这个文件中。
创建 futures
在 imminent 中创建未来与在 Clojure 中创建未来并没有太大的区别。它就像以下这样简单:
(def age (i/future 31))
;; #<Future@2ea0ca7d: #<Success@3e4dec75: 31>>
然而,看起来非常不同的是返回值。imminent API 中的一个关键决策是将计算的值表示为Success或Failure类型。正如先前的例子所示,Success 封装了计算的成果。Failure,正如你可能猜到的,将封装未来中发生的任何异常:
(def failed-computation (i/future (throw (Exception. "Error"))))
;; #<Future@63cd0d58: #<Failure@2b273f98: #<Exception java.lang.Exception: Error>>>
(def failed-computation-1 (i/failed-future :invalid-data))
;; #<Future@a03588f: #<Failure@61ab196b: :invalid-data>>
正如你所见,你不仅限于异常。我们可以使用failed-future函数创建一个立即完成给定原因的未来,在第二个例子中,这个原因只是一个关键字。
我们可能接下来会问的问题是“我们如何从未来中获取结果?”。与 Clojure 中的未来类似,我们可以按照以下方式解引用它:
@age ;; #<Success@3e4dec75: 31>
(deref @age) ;; 31
(i/dderef age) ;; 31
使用双重解引用的惯用用法很常见,因此 imminent 提供了这样的便利,即dderef,它相当于调用deref两次。
然而,与 Clojure 未来不同,这是一个非阻塞操作,所以如果未来还没有完成,你将得到以下结果:
@(i/future (do (Thread/sleep 500)
"hello"))
;; :imminent.future/unresolved
未来的初始状态是unresolved,除非你绝对确定未来已经完成,否则解引用可能不是处理计算结果的最佳方式。这就是组合子变得有用的地方。
组合子和事件处理器
假设我们想要将年龄未来的值加倍。就像我们对列表做的那样,我们可以简单地映射一个函数到未来上,以完成这个操作:
(def double-age (i/map age #(* % 2)))
;; #<Future@659684cb: #<Success@7ce85f87: 62>>
提示
虽然i/future将主体调度到单独的线程上执行,但值得注意的是,未来的组合子如map、filter等并不会立即创建一个新的线程。相反,它们在原始未来完成之后,将函数调度到线程池中异步执行。
另一种使用未来值的方法是使用on-success事件处理器,它在未来成功时被调用,并带有未来的封装值:
(i/on-success age #(prn-to-repl (str "Age is: " %)))
;; "Age is: 31"
类似地,存在一个on-failure处理器,它对Failure类型做同样的事情。在讨论失败的话题时,imminent 未来理解它们被执行的上下文,如果当前未来产生一个Failure,它将简单地短路计算:
(-> failed-computation
(i/map #(* % 2)))
;; #<Future@7f74297a: #<Failure@2b273f98: #<Exception java.lang.Exception: Error>>>
在前面的例子中,我们不会得到一个新的错误,而是failed-computation中包含的原始异常。传递给map的函数永远不会运行。
决定将未来的结果封装在Success或Failure这样的类型中可能看起来是随意的,但实际上恰恰相反。这两种类型都实现了IReturn协议——以及一些其他协议,它们附带了一系列有用的函数,其中之一就是map函数:
(i/map (i/success "hello")
#(str % " world"))
;; #<Success@714eea92: "hello world">
(i/map (i/failure "error")
#(str % " world"))
;; #<Failure@6d685b65: "error">
我们在这里得到的行为与我们之前的行为相似:将函数映射到失败上只是简单地短路整个计算。然而,如果你确实希望映射到失败上,你可以使用 map 的对应函数map-failure,它的工作方式与 map 类似,但它是其逆操作:
(i/map-failure (i/success "hello")
#(str % " world"))
;; #<Success@779af3f4: "hello">
(i/map-failure (i/failure "Error")
#(str "We failed: " %))
;; #<Failure@52a02597: "We failed: Error">
这与最后提供的事件处理器on-complete配合得很好:
(i/on-complete age
(fn [result]
(i/map result #(prn-to-repl "success: " %))
(i/map-failure result #(prn-to-repl "error: " %))))
;; "success: " 31
与on-success和on-failure不同,on-complete使用结果类型封装调用提供的函数,因此它是处理单个函数中两种情况的一种方便方式。
回到组合子,有时我们需要将函数映射到一个返回未来的未来上:
(defn range-future [n]
(i/const-future (range n)))
(def age-range (i/map age range-future))
;; #<Future@3d24069e: #<Success@82e8e6e: #<Future@2888dbf4: #<Success@312084f6: (0 1 2...)>>>>
range-future函数返回一个成功的期货,它产生一个范围n。const-future函数与failed-future类似,但它立即使用Success类型完成期货。
然而,我们最终得到一个嵌套的期货,这几乎从来不是你想要的。没关系。这正是你将使用另一个组合子flatmap的场景。
你可以把它想象成针对期货的mapcat——它为我们简化了计算过程:
(def age-range (i/flatmap age range-future))
;; #<Future@601c1dfc: #<Success@55f4bcaf: (0 1 2 ...)>>
另一个非常有用的组合子是用来将多个计算汇集到单个函数中使用的——sequence:
(def name (i/future (do (Thread/sleep 500)
"Leo")))
(def genres (i/future (do (Thread/sleep 500)
["Heavy Metal" "Black Metal" "Death Metal" "Rock 'n Roll"])))
(-> (i/sequence [name age genres])
(i/on-success
(fn [[name age genres]]
(prn-to-repl (format "%s is %s years old and enjoys %s"
name
age
(clojure.string/join "," genres))))))
;; "Leo is 31 years old and enjoys Heavy Metal,Black Metal,Death Metal,Rock 'n Roll"
实际上,sequence创建了一个新的期货,它将仅在向量中的所有其他期货都完成或其中任何一个失败时才完成。
这很自然地引出了我们将要查看的最后一个组合子——map-future——我们将用它来代替电影示例中使用的pmap:
(defn calculate-double [n]
(i/const-future (* n 2)))
(-> (i/map-future calculate-double [1 2 3 4])
i/await
i/dderef)
;; [2 4 6 8]
在前面的例子中,calculate-double是一个返回值n翻倍的期货的函数。map-future函数随后将calculate-double映射到列表上,实际上是在并行执行计算。最后,map-future将所有期货序列化,返回一个单一的期货,它提供了所有计算的结果。
因为我们正在执行多个并行计算,并且不知道它们何时会完成,所以我们调用await在期货上,这是一种阻塞当前线程直到其结果准备好的方法。通常,你会使用组合子和事件处理器,但在这个例子中,使用await是可以接受的。
Imminent 的 API 提供了许多更多的组合子,这有助于我们以声明式的方式编写异步程序。本节让我们领略了 API 的强大功能,足以让我们使用 imminent 期货编写电影示例。
再次审视电影示例
仍然在我们的imminent-playground项目中,打开src/imminent_playground/core.clj文件并添加适当的定义:
(ns imminent-playground.core
(:require [clojure.pprint :refer [pprint]]
[imminent.core :as i]))
(def movie ...)
(def actor-movies ...)
(def actor-spouse ...)
(def top-5-movies ...)
The service functions will need small tweaks in this new version:
(defn cast-by-movie [name]
(i/future (do (Thread/sleep 5000)
(:cast movie))))
(defn movies-by-actor [name]
(i/future (do (Thread/sleep 2000)
(->> actor-movies
(filter #(= name (:name %)))
first))))
(defn spouse-of [name]
(i/future (do (Thread/sleep 2000)
(->> actor-spouse
(filter #(= name (:name %)))
first))))
(defn top-5 []
(i/future (do (Thread/sleep 5000)
top-5-movies)))
(defn aggregate-actor-data [spouses movies top-5]
...)
主要区别是它们现在都返回一个 imminent 期货。aggregate-actor-data函数与之前相同。
这带我们来到了-main函数,它被重写为使用 imminent 组合子:
(defn -main [& args]
(time (let [cast (cast-by-movie "Lord of The Rings: The Fellowship of The Ring")
movies (i/flatmap cast #(i/map-future movies-by-actor %))
spouses (i/flatmap cast #(i/map-future spouse-of %))
result (i/sequence [spouses movies (top-5)])]
(prn "Fetching data...")
(pprint (apply aggregate-actor-data
(i/dderef (i/await result)))))))
函数的起始部分与之前的版本非常相似,甚至第一个绑定cast看起来也很熟悉。接下来是movies,它是通过并行获取一个演员的电影得到的。这本身返回一个期货,所以我们通过flatmap在cast期货上,以获得最终结果:
movies (i/flatmap cast #(i/map-future movies-by-actor %))
spouses与movies的工作方式完全相同,这带我们来到了result。这是我们希望将所有异步计算汇集在一起的地方。因此,我们使用sequence组合子:
result (i/sequence [spouses movies (top-5)])
最后,我们决定通过使用await阻塞在result期货上——这样我们就可以打印出最终结果:
(pprint (apply aggregate-actor-data
(i/dderef (i/await result)))
我们以与之前相同的方式运行程序,所以只需在命令行中输入以下内容,在项目的根目录下:
lein run -m imminent-playground.core
"Fetching data..."
({:name "Cate Blanchett",
:spouse "Andrew Upton",
:movies
("Lord of The Rings: The Fellowship of The Ring - (top 5)"
"Lord of The Rings: The Return of The King"
"The Curious Case of Benjamin Button")}
...
"Elapsed time: 7088.398 msecs"
输出结果被裁剪了,因为它与之前完全相同,但有两点不同,值得注意:
-
第一个输出,获取数据...,打印到屏幕上的速度比使用 Clojure futures 的示例快得多
-
获取所有这些所需的总时间更短,仅超过 7 秒
这突出了 imminent futures 和组合器的异步性质。我们唯一需要等待的时间是在程序末尾显式调用await时。
更具体地说,性能提升来自以下代码段:
(let [...
result (i/sequence [spouses movies (top-5)])]
...)
由于之前的所有绑定都不会阻塞当前线程,所以我们永远不需要等待并行启动top-5,从而从总体执行时间中节省了大约 3 秒。我们不需要显式考虑执行顺序——组合器只是做了正确的事情。
最后,还有一个不同之处是我们不再需要像以前那样显式调用shutdown-agents。这是因为 imminent 使用了一种不同类型的线程池:一个ForkJoinPool(参见docs.oracle.com/javase/7/docs/api/java/util/concurrent/ForkJoinPool.html)。
这个池子相对于其他线程池有许多优点——每个都有自己的权衡——其中一个特点是,我们不需要显式关闭它——它创建的所有线程都是守护线程。
当 JVM 关闭时,它会挂起等待所有非守护线程完成。只有在这种情况下,它才会退出。这就是为什么如果我们没有调用shutdown-agents,使用 Clojure futures 会导致 JVM 挂起的原因。
ForkJoinPool 创建的所有线程默认设置为守护线程:当 JVM 尝试关闭时,如果运行的唯一线程是守护线程,它们将被放弃,JVM 优雅地退出。
如map和flatmap之类的组合器以及sequence和map-future函数并不局限于 future。它们遵循许多更基本的原则,这使得它们在多个领域都有用。理解这些原则对于理解本书的内容不是必要的。如果您想了解更多关于这些原则的信息,请参阅附录,《库设计的代数》。
Futures 和阻塞 IO
使用 ForkJoinPool 作为 imminent 的选择是故意的。ForkJoinPool 是在 Java 7 中添加的,非常智能。创建时,你给它一个期望的parallelism级别,默认为可用的处理器数量。
ForkJoinPool 会尝试通过动态缩小和扩大池子来满足所需的并行性。当一个任务提交到这个池子时,如果不需要,它不一定创建一个新的线程。这使得池子能够用更少的实际线程服务大量的任务。
然而,面对阻塞 I/O 时,它不能保证这样的优化,因为它无法知道线程是否正在阻塞等待外部资源。尽管如此,ForkJoinPool 提供了一种机制,允许线程在可能阻塞时通知池。
Imminent 通过实现 ManagedBlocker 接口(见 docs.oracle.com/javase/7/docs/api/java/util/concurrent/ForkJoinPool.ManagedBlocker.html)利用了这一机制,并提供了另一种创建未来的方法,如下所示:
(-> (immi/blocking-future
(Thread/sleep 100)
10)
(immi/await))
;; #<Future@4c8ac77a: #<Success@45525276: 10>>
(-> (immi/blocking-future-call
(fn []
(Thread/sleep 100)
10))
(immi/await))
;; #<Future@37162438: #<Success@5a13697f: 10>>
blocking-future 和 blocking-future-call 与它们的对应物 future 和 future-call 具有相同的语义,但应该在要执行的任务具有阻塞性质(即非 CPU 密集型)时使用。这允许 ForkJoinPool 更好地利用其资源,使其成为一个强大且灵活的解决方案。
摘要
在本章中,我们了解到 Clojure 的未来还有很多需要改进的地方。更具体地说,Clojure 的未来没有提供表达结果之间依赖关系的方法。但这并不意味着我们应该完全摒弃未来。
它们仍然是一个有用的抽象,并且对于异步计算具有正确的语义和丰富的组合器集——例如 Imminent 提供的——它们可以在构建性能和响应性强的反应式应用程序中成为强大的盟友。有时,这已经足够了。
对于需要模拟随时间变化的数据的情况,我们转向受 函数式响应式编程(FRP)和 组合事件系统(CES)启发的更丰富的框架——例如 RxJava——或 通信顺序进程(CSP)——例如 core.async。由于它们提供了更多功能,本书的大部分内容都致力于这些方法。
在下一章中,我们将通过案例研究回顾 FRP/CES。
第九章。亚马逊网络服务的响应式 API
在整本书中,我们学习了许多工具和技术,以帮助我们构建响应式应用程序——使用即将到来的未来、使用 RxClojure/RxJava 的 Observables、使用core.async的通道——甚至使用 Om 和 React 构建响应式用户界面。
在这个过程中,我们还了解了函数式响应式编程和组合事件系统的概念,以及它们之间的区别。
在最后一章中,我们将通过开发一个基于我在澳大利亚悉尼的客户的一个真实世界用例的应用程序,将一些不同的工具和概念结合起来。我们将:
-
描述我们试图解决的自动化基础设施问题
-
简要了解一下亚马逊的一些 AWS 服务
-
使用我们迄今为止学到的概念构建一个 AWS 仪表板
问题
这个客户——从现在起我们将称之为 BubbleCorp——有一个非常普遍且众所周知的大企业问题:一个庞大的单体应用程序。
除了使它们变得缓慢外,由于各个组件不能独立演进,这个应用程序由于其环境限制而使得部署变得极其困难:所有基础设施都必须可用,应用程序才能正常运行。
因此,开发新功能和修复错误仅涉及少数几个由数十名开发者共享的开发环境。这需要在团队之间进行大量的协调,以免他们相互干扰,从而进一步减缓整个生命周期。
解决这个问题的长期方案是将这个大应用程序分解成更小的组件,这些组件可以独立部署和开发,但尽管这个方案听起来很好,但它是一个既费时又漫长的过程。
作为第一步,BubbleCorp 决定他们短期内能改进的最佳方法是让开发者能够独立工作,这意味着能够创建一个新环境。
考虑到基础设施的限制,在单个开发者的机器上运行应用程序是不切实际的。
相反,他们转向了基础设施自动化:他们想要一个工具,只需按一下按钮,就能启动一个全新的环境。
这个新环境将预先配置好适当的应用服务器、数据库实例、DNS 条目以及运行应用程序所需的一切。
这样,开发者只需部署他们的代码并测试他们的更改,无需担心应用程序的设置。
基础设施自动化
亚马逊网络服务(AWS)是目前最成熟和最全面的云计算平台,因此对 BubbleCorp 来说,将其基础设施托管在这里是顺理成章的选择。
如果你之前没有使用过 AWS,不要担心,我们只会关注其服务中的几个:
-
弹性计算云(EC2):一项为用户提供租用虚拟计算机以运行其应用程序的服务。
-
关系数据库服务(RDS):这可以被视为 EC2 的一个特殊版本,提供托管数据库服务。
-
CloudFormation:使用 CloudFormation,用户可以指定多个不同 AWS 资源的基础设施模板,称为堆栈,例如 EC2、AWS 以及许多其他资源,以及它们如何相互交互。一旦编写完成,基础设施模板可以发送到 AWS 执行。
对于BubbleCorp,想法是编写这些基础设施模板,一旦提交,就会产生一个完全新的、隔离的环境,其中包含运行其应用程序所需的所有数据和组件。在任何给定时间,都会有数十个这样的环境在运行,开发者正在对它们进行工作。
尽管这个计划听起来不错,但大公司通常还有一个额外的负担:成本中心。不幸的是,BubbleCorp 不能简单地允许开发者登录 AWS 控制台——在那里我们可以管理 AWS 资源——并且随意启动环境。他们需要一种方法,在众多其他事情中,向环境添加成本中心元数据以处理他们的内部计费流程。
这将带我们到本章剩余部分我们将要关注的应用程序。
AWS 资源仪表板
我的团队和我被分配了一个任务,即构建一个基于 Web 的 AWS 仪表板。这个仪表板将允许开发者使用他们的 BubbleCorp 凭证登录,一旦认证通过,就可以创建新的 CloudFormation 环境以及可视化 CloudFormation 堆栈中每个单独资源的状态。
应用程序本身相当复杂,因此我们将关注其子集:与必要的 AWS 服务接口,以收集有关给定 CloudFormation 堆栈中每个单独资源状态的信息。
完成后,我们的简化仪表板将看起来像这样:

它将显示每个资源的 ID、类型和当前状态。现在这看起来可能不多,但考虑到所有这些信息都来自不同的、独立的网络服务,最终得到不必要的复杂代码的情况是非常容易发生的。
我们将使用 ClojureScript 来完成这项工作,因此我们将使用 AWS SDK 的 JavaScript 版本,其文档可以在aws.amazon.com/sdk-for-node-js/找到。
在我们开始之前,让我们看看我们将与之交互的每个 AWS 服务 API。
小贴士
实际上,我们不会与真实的 AWS 服务交互,而是与从本书的 GitHub 存储库提供的占位符服务器交互。
这样做的理由是为了使跟随本章内容更加容易,因为你不需要创建账户以及生成 API 访问密钥来与 AWS 交互。
此外,创建资源会产生成本,我当然不希望你在月底因为有人不小心让资源运行时间过长而被收取数百美元——相信我,这种情况以前发生过。
CloudFormation
我们将要查看的第一个服务是 CloudFormation。这是有道理的,因为这里找到的 API 将为我们找到给定堆栈中资源的信息提供一个起点。
describeStacks 端点
这个端点是负责列出与特定 AWS 账户关联的所有堆栈。对于给定的堆栈,其响应如下所示:
{"Stacks"
[{"StackId"
"arn:aws:cloudformation:ap-southeast-2:337944750480:stack/DevStack-62031/1",
"StackStatus" "CREATE_IN_PROGRESS",
"StackName" "DevStack-62031",
"Parameters" [{"ParameterKey" "DevDB", "ParameterValue" nil}]}]}
不幸的是,它没有说明哪些资源属于这个堆栈。然而,它确实给了我们堆栈名称,我们可以使用它来在下一个服务中查找资源。
describeStackResources 端点
这个端点接收许多参数,但我们感兴趣的是堆栈名称,一旦提供,就会返回以下内容:
{"StackResources"
[{"PhysicalResourceId" "EC2123",
"ResourceType" "AWS::EC2::Instance"},
{"PhysicalResourceId" "EC2456",
"ResourceType" "AWS::EC2::Instance"}
{"PhysicalResourceId" "EC2789",
"ResourceType" "AWS::EC2::Instance"}
{"PhysicalResourceId" "RDS123",
"ResourceType" "AWS::RDS::DBInstance"}
{"PhysicalResourceId" "RDS456",
"ResourceType" "AWS::RDS::DBInstance"}]}
现在我们似乎有所进展。这个堆栈有几个资源:三个 EC2 实例和两个 RDS 实例——对于仅两次 API 调用来说并不算太坏。
然而,正如我们之前提到的,我们的仪表板需要显示每个资源的状态。有了资源 ID 列表在手,我们需要查看其他可能提供每个资源详细信息的服务的端点。
EC2
我们接下来要查看的服务是针对 EC2 的。正如我们将看到的,不同服务的响应并不像我们希望的那样一致。
describeInstances 端点
这个端点听起来很有希望。根据文档,我们可以给它一个实例 ID 列表,它将返回以下响应:
{"Reservations"
[{"Instances"
[{"InstanceId" "EC2123",
"Tags"
[{"Key" "StackType", "Value" "Dev"}
{"Key" "junkTag", "Value" "should not be included"}
{"Key" "aws:cloudformation:logical-id", "Value" "theDude"}],
"State" {"Name" "running"}}
{"InstanceId" "EC2456",
"Tags"
[{"Key" "StackType", "Value" "Dev"}
{"Key" "junkTag", "Value" "should not be included"}
{"Key" "aws:cloudformation:logical-id", "Value" "theDude"}],
"State" {"Name" "running"}}
{"InstanceId" "EC2789",
"Tags"
[{"Key" "StackType", "Value" "Dev"}
{"Key" "junkTag", "Value" "should not be included"}
{"Key" "aws:cloudformation:logical-id", "Value" "theDude"}],
"State" {"Name" "running"}}]}]}
在这个响应中,我们可以看到State键,它告诉我们特定 EC2 实例的状态。就 EC2 而言,这就是我们所需要的。这让我们剩下 RDS 要处理。
RDS
有些人可能会想,获取 RDS 实例的状态应该和 EC2 一样简单。让我们看看这是否属实。
describeDBInstances 端点
这个端点在目的上与刚才我们查看的类似 EC2 端点相同。然而,它的输入略有不同:它接受单个实例 ID 作为输入,并且截至本文撰写时,不支持过滤器。
这意味着如果我们的堆栈有多个 RDS 实例——比如说,在主/副本设置中——我们需要对每个实例进行多次 API 调用以收集信息。当然,这不是什么大问题,但这是一个需要注意的限制。
一旦给出了特定的数据库实例 ID,这个服务会返回以下代码:
{"DBInstances"
[{"DBInstanceIdentifier" "RDS123", "DBInstanceStatus" "available"}]}
单个实例包含在向量中这一事实暗示了将来将支持过滤。但这还没有发生。
设计解决方案
现在我们已经有了开始设计我们应用程序所需的所有信息。我们需要为每个 CloudFormation 堆栈协调四次不同的 API 调用:
-
describeStacks:用于列出所有可用的堆栈 -
describeStackResources:用于检索堆栈中包含的所有资源的详细信息 -
describeInstances:用于检索堆栈中所有 EC2 实例的详细信息 -
describeDBInstances:用于检索堆栈中所有 DB2 实例的详细信息
接下来,我希望你能暂时退后一步,思考你将如何设计这样的代码。请继续,我会等待。
现在您回来了,让我们看看一种可能的方法。
如果我们回想一下仪表板的外观截图,我们会意识到,对于我们的应用程序来说,只要每个资源都有 ID、类型和状态属性,EC2 和 RDS 资源之间的区别就可以完全忽略。
这意味着无论我们的解决方案可能是什么,它都必须以某种方式提供一种统一的方式来抽象不同的资源类型。
此外,除了需要按顺序调用的describeStacks和describeStackResources之外,describeInstances和describeDBInstances可以并发执行,之后我们需要一种方法来合并结果。
由于一张图片胜过千言万语,以下是我们希望工作流程看起来是这样的图片:

上一张图片突出了我们解决方案的几个关键方面:
-
我们首先通过调用
describeStacks来检索堆栈 -
接下来,对于每个堆栈,我们调用
describeStackResources来检索每个堆栈的资源列表 -
然后,我们按类型拆分列表,最后得到一个包含 EC2 和包含 RDS 资源的列表
-
我们通过并发调用
describeInstances和describeDBInstances,得到两个结果列表,每个资源类型一个 -
由于响应格式不同,我们将每个资源转换为统一的表现形式
-
最后,我们将所有结果合并到一个列表中,以便渲染
这需要吸收很多信息,但正如您很快就会意识到的那样,我们的解决方案并不偏离这个高级描述太远。
我们可以很容易地将这个问题想象成有关几种不同类型的实例的信息通过这个 API 调用图流动——在需要时进行转换——直到我们到达我们想要的信息,以我们想要工作的格式。
事实上,一个很好的方法来建模这个问题是使用我们在本书早期学习到的 Reactive 抽象之一:Observables。
运行 AWS 占位符服务器
在我们开始编写仪表板之前,我们应该确保我们的 AWS 占位符服务器已经正确设置。占位符服务器是一个 Clojure 网络应用程序,它模拟了真实 AWS API 的行为,并且是仪表板将要与之通信的后端。
让我们先进入我们的终端,使用 Git 克隆书籍仓库,然后启动占位符服务器:
$ git clone https://github.com/leonardoborges/ClojureReactiveProgramming
$ cd ClojureReactiveProgramming/code/chapter09/aws-api-stub
$ lein ring server-headless 3001
2014-11-23 17:33:37.766:INFO:oejs.Server:jetty-7.6.8.v20121106
2014-11-23 17:33:37.812:INFO:oejs.AbstractConnector:Started SelectChannelConnector@0.0.0.0:3001
Started server on port 3001
这将启动服务器在端口 3001 上。为了验证它是否按预期工作,请将您的浏览器指向http://localhost:3001/cloudFormation/describeStacks。您应该看到以下 JSON 响应:
{
"Stacks": [
{
"Parameters": [
{
"ParameterKey": "DevDB",
"ParameterValue": null
}
],
"StackStatus": "CREATE_IN_PROGRESS",
"StackId": "arn:aws:cloudformation:ap-southeast-2:337944750480:stack/DevStack-62031/1",
"StackName": "DevStack-62031"
}
]
}
设置仪表板项目
正如我们之前提到的,我们将使用 ClojureScript 开发仪表板,并通过 Om 渲染 UI。此外,由于我们选择了 Observables 作为我们的主要响应式抽象,我们需要 RxJS,这是 Microsoft 响应式扩展的许多实现之一。我们将很快将这些依赖项拉入我们的项目。
让我们使用om-start leiningen 模板创建一个名为aws-dash的新 ClojureScript 项目:
$ lein new om-start aws-dash
这为我们提供了一个起点,但我们应该确保我们的所有版本都匹配。打开新项目根目录中的project.clj文件,并确保依赖项部分看起来如下:
...
:dependencies [[org.clojure/clojure "1.6.0"]
[org.clojure/clojurescript "0.0-2371"]
[org.clojure/core.async "0.1.346.0-17112a-alpha"]
[om "0.5.0"]
[com.facebook/react "0.9.0"]
[cljs-http "0.1.20"]
[com.cognitect/transit-cljs "0.8.192"]]
:plugins [[lein-cljsbuild "1.0.3"]]
...
这是我们第一次看到最后两个依赖项。cljs-http是一个简单的 HTTP 库,我们将用它来向我们的 AWS 模拟服务器发送 AJAX 请求。transit-cljs允许我们做许多事情,例如将 JSON 响应解析为 ClojureScript 数据结构。
小贴士
Transit 本身是一种格式和一系列库,通过这些库,使用不同技术开发的程序可以相互通信。在这种情况下,我们正在使用 Clojurescript 库来解析 JSON,但如果你有兴趣了解更多,我建议阅读 Rich Hickey 在blog.cognitect.com/blog/2014/7/22/transit发布的官方博客文章。
接下来,我们需要 RxJS,作为一个 JavaScript 依赖项,它不能通过 leiningen 获得。没关系。我们将简单地将其下载到应用程序输出目录aws-dash/dev-resources/public/js/:
$ cd aws-dash/dev-resources/public/js/
$ wget https://raw.githubusercontent.com/Reactive-Extensions/RxJS/master/dist/rx.all.js
--2014-11-23 18:00:21-- https://raw.githubusercontent.com/Reactive-Extensions/RxJS/master/dist/rx.all.js
Resolving raw.githubusercontent.com... 103.245.222.133
Connecting to raw.githubusercontent.com|103.245.222.133|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 355622 (347K) [text/plain]
Saving to: 'rx.all.js'
100%[========================>] 355,622 966KB/s in 0.4s
2014-11-23 18:00:24 (966 KB/s) - 'rx.all.js' saved [355622/355622]
接下来,我们需要让我们的应用程序意识到我们对 RxJS 的新依赖。打开aws-dash/dev-resources/public/index.html文件,并添加一个 script 标签来引入 RxJS:
<html>
<body>
<div id="app"></div>
<script src="img/react-0.9.0.js"></script>
<script src="img/rx.all.js"></script>
<script src="img/aws_dash.js"></script>
</body>
</html>
在所有依赖项就绪后,让我们开始自动编译我们的 ClojureScript 源文件,如下所示:
$ cd aws-dash/
$ lein cljsbuild auto
Compiling ClojureScript.
Compiling "dev-resources/public/js/aws_dash.js" from ("src/cljs" "dev-resources/tools/repl")...
Successfully compiled "dev-resources/public/js/aws_dash.js" in 0.981 seconds.
创建 AWS Observables
我们现在可以开始实现我们的解决方案了。如果你还记得反应式扩展章节,RxJava/RxJS/RxClojure提供了几个有用的 Observables。然而,当内置的 Observables 不足以满足需求时,它为我们提供了构建自己的工具。
由于 RxJS 很可能已经为亚马逊的 AWS API 提供了 Observables,我们将首先实现我们自己的原始 Observables。
为了保持整洁,我们将在一个新文件中这样做,位于aws-dash/src/cljs/aws_dash/observables.cljs:
(ns aws-dash.observables
(:require-macros [cljs.core.async.macros :refer [go]])
(:require [cljs-http.client :as http]
[cljs.core.async :refer [<!]]
[cognitect.transit :as t]))
(def r (t/reader :json))
(def aws-endpoint "http://localhost:3001")
(defn aws-uri [path]
(str aws-endpoint path))
命名空间声明需要我们在文件中需要的必要依赖项。注意,这里没有对 RxJS 的显式依赖。由于它是一个我们手动拉入的 JavaScript 依赖项,它通过 JavaScript 互操作性对我们来说是全局可用的。
下一行设置了一个transit读取器用于 JSON,我们将用它来解析模拟服务器响应。
然后,我们定义我们将与之通信的端点以及一个辅助函数来构建正确的 URI。确保变量 aws-endpoint 与上一节中启动的占位符服务器的宿主和端口匹配。
我们即将创建的所有 Observables 都遵循一个共同的结构:它们向占位符服务器发送请求,从响应中提取一些信息,可选地进行转换,然后将转换后的序列中的每个项目发射到新的 Observable 序列中。
为了避免重复,这个模式被以下函数捕获:
(defn observable-seq [uri transform]
(.create js/Rx.Observable
(fn [observer]
(go (let [response (<! (http/get uri {:with-credentials? false}))
data (t/read r (:body response))
transformed (transform data)]
(doseq [x transformed]
(.onNext observer x))
(.onCompleted observer)))
(fn [] (.log js/console "Disposed")))))
让我们分解这个函数:
-
observable-seq接收两个参数:我们将向其发出 GET 请求的后端 URI,以及一个transform函数,该函数接受原始解析的 JSON 响应并返回一个转换项的序列。 -
然后,它调用 RxJS 对象
Rx.Observable的create函数。注意我们如何利用 JavaScript 互操作性:我们通过在前面加上一个点来访问 create 函数,就像在 Java 互操作性中一样。由于Rx.Observable是一个全局对象,我们通过在前面加上 ClojureScript 为我们的程序提供的全局 JavaScript 命名空间js/Rx.Observable来访问它。 -
Observable 的 create 函数接收两个参数。一个是带有观察者的函数,我们可以向其中推送要发布在 Observable 序列中的项目。第二个函数是每当这个 Observable 被处置时调用的函数。这是我们可以执行任何清理所需的函数。在我们的情况下,这个函数只是将调用的事实记录到控制台。
虽然第一个函数是我们感兴趣的:
(fn [observer]
(go (let [response (<! (http/get uri
{:with-credentials?
false}))
data (t/read r (:body response))
transformed (transform data)]
(doseq [x transformed]
(.onNext observer x))
(.onCompleted observer))))
一旦被调用,它就会使用 cljs-http 的 get 函数对提供的 URI 发出请求,该函数返回一个 core.async 通道。这就是为什么整个逻辑都在 go 块中的原因。
接下来,我们使用之前配置的 transit JSON 读取器来解析响应体,并将结果输入到 transform 函数中。记住这个函数,根据我们的设计,它返回一个序列。因此,我们唯一要做的就是依次将每个项目推送到观察者。
一旦完成,我们通过调用 observer 对象的 .onCompleted 函数来指示这个 Observable 序列不会发射任何新的项目。
现在,我们可以使用这个辅助函数继续创建我们的 Observables,从负责检索 CloudFormation 服务的那个开始:
(defn describe-stacks []
(observable-seq (aws-uri "/cloudFormation/describeStacks")
(fn [data]
(map (fn [stack] {:stack-id (stack "StackId")
:stack-name (stack "StackName")})
(data "Stacks")))))
这创建了一个 Observable,它将为每个堆栈发射一个项目,格式如下:
({:stack-id "arn:aws:cloudformation:ap-southeast-2:337944750480:stack/DevStack-62031/1", :stack-name "DevStack-62031"})
现在我们有了堆栈,我们需要一个 Observable 来描述其资源:
(defn describe-stack-resources [stack-name]
(observable-seq (aws-uri "/cloudFormation/describeStackResources")
(fn [data]
(map (fn [resource]
{:resource-id (resource "PhysicalResourceId")
:resource-type (resource "ResourceType")} )
(data "StackResources")))))
它具有类似的目的,并以以下格式发射资源项:
({:resource-id "EC2123", :resource-type "AWS::EC2::Instance"}
{:resource-id "EC2456", :resource-type "AWS::EC2::Instance"}
{:resource-id "EC2789", :resource-type "AWS::EC2::Instance"}
{:resource-id "RDS123", :resource-type "AWS::RDS::DBInstance"}
{:resource-id "RDS456", :resource-type "AWS::RDS::DBInstance"})
由于我们几乎字面地遵循我们的策略,我们还需要两个更多的 observables,每个实例类型一个:
(defn describe-instances [instance-ids]
(observable-seq (aws-uri "/ec2/describeInstances")
(fn [data]
(let [instances (mapcat (fn [reservation]
(reservation "Instances"))
(data "Reservations"))]
(map (fn [instance]
{:instance-id (instance "InstanceId")
:type "EC2"
:status (get-in instance ["State" "Name"])})
instances)))))
(defn describe-db-instances [instance-id]
(observable-seq (aws-uri (str "/rds/describeDBInstances/" instance-id))
(fn [data]
(map (fn [instance]
{:instance-id (instance "DBInstanceIdentifier")
:type "RDS"
:status (instance "DBInstanceStatus")})
(data "DBInstances")))))
其中每个都会以以下格式为 EC2 和 RDS 分别发射资源项:
({:instance-id "EC2123", :type "EC2", :status "running"} ...)
({:instance-id "RDS123", :type "RDS", :status "available"} ...)
结合 AWS Observables
看起来我们现在已经拥有了所有主要部件。剩下要做的就是将我们刚刚创建的更原始、基本的 Observables 组合成更复杂、更有用的 Observables,通过组合它们来聚合我们渲染仪表板所需的所有数据。
我们将首先创建一个函数,它结合了describe-stacks和describe-stack-resourcesObservables:
(defn stack-resources []
(-> (describe-stacks)
(.map #(:stack-name %))
(.flatMap describe-stack-resources)))
从上一个示例开始,我们开始看到如何用 Observable 序列来定义我们的 API 调用是如何带来好处的:几乎是以声明式的方式简单地将这两个 Observable 结合起来。
记住flatMap的作用:由于describe-stack-resources本身返回一个 Observable,我们使用flatMap来扁平化这两个 Observables,就像我们在各种不同的抽象中之前所做的那样。
stack-resourcesObservable 将为所有堆栈发出资源项。根据我们的计划,我们希望在这里分叉处理,以并发检索 EC2 和 RDS 实例数据。
通过遵循这个思路,我们得到了两个更多函数,它们结合并转换了之前的 Observables:
(defn ec2-instance-status [resources]
(-> resources
(.filter #(= (:resource-type %) "AWS::EC2::Instance"))
(.map #(:resource-id %))
(.reduce conj [])
(.flatMap describe-instances)))
(defn rds-instance-status [resources]
(-> resources
(.filter #(= (:resource-type %) "AWS::RDS::DBInstance"))
(.map #(:resource-id %))
(.flatMap describe-db-instances)))
这两个函数都接收一个参数,resources,它是调用stack-resourcesObservable 的结果。这样,我们只需要调用一次。
再次强调,按照我们之前描述的高级想法,以有意义的方式结合 Observables 相当简单。
从resources开始,我们过滤掉我们不感兴趣的类型,检索其 ID,并通过 flatmapping describe-instances和describe-db-instancesObservables 来请求其详细信息。
然而,请注意,由于前面描述的 RDS API 的限制,我们必须多次调用它来检索所有 RDS 实例的信息。
这种看似基本的使用 API 的差异在我们的 EC2 observable 中变成了一个小的转换,它只是将所有 ID 累积到一个向量中,这样我们就可以一次性检索它们。
我们简单的 Reactive API 到 Amazon AWS 现在已经完成,剩下的是创建 UI。
将所有这些整合在一起
现在我们转向构建我们的用户界面。这是一个简单的界面,所以我们直接进入。打开aws-dash/src/cljs/aws_dash/core.cljs并添加以下内容:
(ns aws-dash.core
(:require [aws-dash.observables :as obs]
[om.core :as om :include-macros true]
[om.dom :as dom :include-macros true]))
(enable-console-print!)
(def app-state (atom {:instances []}))
(defn instance-view [{:keys [instance-id type status]} owner]
(reify
om/IRender
(render [this]
(dom/tr nil
(dom/td nil instance-id)
(dom/td nil type)
(dom/td nil status)))))
(defn instances-view [instances owner]
(reify
om/IRender
(render [this]
(apply dom/table #js {:style #js {:border "1px solid black;"}}
(dom/tr nil
(dom/th nil "Id")
(dom/th nil "Type")
(dom/th nil "Status"))
(om/build-all instance-view instances)))))
(om/root
(fn [app owner]
(dom/div nil
(dom/h1 nil "Stack Resource Statuses")
(om/build instances-view (:instances app))))
app-state
{:target (. js/document (getElementById "app"))})
我们的应用状态包含一个单一的关键字,:instances,它最初是一个空向量。正如我们可以从每个 Om 组件中看到的那样,实例将以 HTML 表格中的行形式渲染。
保存文件后,确保通过从 REPL 启动它来运行 Web 服务器:
lein repl
Compiling ClojureScript.
nREPL server started on port 58209 on host 127.0.0.1 - nrepl://127.0.0.1:58209
REPL-y 0.3.5, nREPL 0.2.6
Clojure 1.6.0
Java HotSpot(TM) 64-Bit Server VM 1.8.0_25-b17
Docs: (doc function-name-here)
(find-doc "part-of-name-here")
Source: (source function-name-here)
Javadoc: (javadoc java-object-or-class-here)
Exit: Control+D or (exit) or (quit)
Results: Stored in vars *1, *2, *3, an exception in *e
user=> (run)
2015-02-08 21:02:34.503:INFO:oejs.Server:jetty-7.6.8.v20121106
2015-02-08 21:02:34.545:INFO:oejs.AbstractConnector:Started SelectChannelConnector@0.0.0.0:3000
#<Server org.eclipse.jetty.server.Server@35bc3669>
你现在应该能够将你的浏览器指向http://localhost:3000/,但是,正如你可能已经猜到的,你将看到的是一个空表。
这是因为我们还没有使用我们的 Reactive AWS API。
让我们修复它,并在core.cljs的底部将其全部整合在一起:
(def resources (obs/stack-resources))
(.subscribe (-> (.merge (obs/rds-instance-status resources)
(obs/ec2-instance-status resources))
(.reduce conj []))
#(swap! app-state assoc :instances %))
是的,这就是我们所需要的!我们创建一个 stack-resources 可观察对象,并将其作为参数传递给 rds-instance-status 和 ec2-instance-status,这将并发检索所有实例的状态信息。
接下来,我们通过合并前两个可观察对象并调用 .reduce 方法创建一个新的可观察对象,这将把所有信息累积到一个向量中,这对于渲染来说很方便。
最后,我们简单地订阅这个可观察对象,当它发出结果时,我们简单地更新我们的应用程序状态,让 Om 为我们做所有的渲染。
保存文件并确保 ClojureScript 已成功编译。然后,回到你的浏览器 http://localhost:3000/,你应该能看到所有实例的状态,就像本章开头所展示的那样。
练习
使用我们之前的方法,查看 AWS 资源的新信息的唯一方法是刷新整个页面。修改我们的实现,使其每隔一段时间查询占位符服务——比如说,每 500 毫秒查询一次。
小贴士
RxJS 中的 interval 函数可以帮助解决这个练习。想想你如何结合我们现有的流使用它,通过回顾 flatMap 的工作原理。
摘要
在本章中,我们探讨了反应式应用程序的一个实际用例:为 AWS CloudFormation 堆栈构建仪表板。
我们已经看到,将所有需要的信息视为通过图流动的资源/项目,这与创建可观察对象的方式非常契合。
此外,通过创建只做一件事的原始可观察对象,我们得到了一个很好的声明式方法来将它们组合成更复杂可观察对象,这为我们提供了一种通常在常见技术中找不到的复用程度。
最后,我们将它包装在一个简单的基于 Om 的界面中,以展示在同一个应用程序中使用不同的抽象不会增加复杂性,只要这些抽象被仔细地选择来处理当前的问题。
这使我们到达了希望是一个愉快且富有信息性的旅程的终点,通过不同的反应式编程方式。
这本书远非一个完整的参考手册,它的目的是为你,读者,提供足够的信息,以及你可以今天就能应用的切实工具和示例。
我也希望这本书中包含的参考和练习能够证明它们是有用的,如果你希望扩展你的知识并寻找更多细节的话。
最后,我强烈建议你翻到下一页阅读附录,即《库设计代数》,因为我真心相信,至少它会使你深入思考编程中组合的重要性。
我真诚地希望这本书的阅读体验像写作一样有趣和有教育意义。
感谢阅读。我期待看到你构建的伟大事物。
附录 A. 库设计代数
你可能已经注意到,我们在这本书中遇到的所有的反应式抽象都有一些共同点。首先,它们作为“容器式”抽象工作:
-
期货封装了一个最终会产生单个值的计算。
-
可观察封装了可以在一段时间内以流的形式产生多个值的计算。
-
通道封装了推送到它们的值,并且可以从它们中弹出值,作为一个并发队列,通过这个队列并发进程进行通信。
然后,一旦我们有了这个“容器”,我们就可以以多种方式对其进行操作,这些方式在不同抽象和框架之间非常相似:我们可以使用 filter 过滤它们包含的值,使用 map 对其进行转换,使用 bind/flatMap/selectMany 组合相同类型的抽象,并行执行多个计算,使用 sequence 聚合结果,等等。
因此,尽管抽象及其底层工作原理在本质上不同,但它们仍然感觉属于某种类型的高级抽象。
在这个附录中,我们将探索这些高级抽象是什么,它们之间的关系,以及我们如何在项目中利用它们。
map 的语义
我们将首先查看这些抽象中最常用的一个操作:map。
我们已经长时间使用 map 来转换序列。因此,为了避免为每个新的抽象创建一个新的函数名,库设计者只是简单地在其自己的容器类型上抽象 map 操作。
想象一下,如果我们有 transform-observable、transform-channel、combine-futures 等函数,我们会陷入多么混乱的局面。
幸运的是,情况并非如此。map 的语义已经被充分理解,以至于即使开发者之前没有使用过特定的库,他也会几乎总是假设 map 会将函数应用于库提供的任何抽象中的值(或值)。
让我们看看在这本书中遇到的三个例子。我们将创建一个新的 leiningen 项目,以便在这个附录的内容上进行实验:
$ lein new library-design
接下来,让我们向我们的 project.clj 文件添加一些依赖项:
...
:dependencies [[org.clojure/clojure "1.6.0"]
[com.leonardoborges/imminent "0.1.0"]
[com.netflix.rxjava/rxjava-clojure "0.20.7"]
[org.clojure/core.async "0.1.346.0-17112a-alpha"]
[uncomplicate/fluokitten "0.3.0"]]
...
不要担心最后一个依赖项——我们稍后会处理它。
现在,启动一个 REPL 会话,这样我们就可以跟随操作:
$ lein repl
然后,将以下内容输入到你的 REPL 中:
(require '[imminent.core :as i]
'[rx.lang.clojure.core :as rx]
'[clojure.core.async :as async])
(def repl-out *out*)
(defn prn-to-repl [& args]
(binding [*out* repl-out]
(apply prn args)))
(-> (i/const-future 31)
(i/map #(* % 2))
(i/on-success #(prn-to-repl (str "Value: " %))))
(as-> (rx/return 31) obs
(rx/map #(* % 2) obs)
(rx/subscribe obs #(prn-to-repl (str "Value: " %))))
(def c (chan))
(def mapped-c (async/map< #(* % 2) c))
(async/go (async/>! c 31))
(async/go (prn-to-repl (str "Value: " (async/<! mapped-c))))
"Value: 62"
"Value: 62"
"Value: 62"
这三个例子——分别使用 imminent、RxClojure 和 core.async——看起来非常相似。它们都遵循一个简单的配方:
-
将数字 31 放入它们各自的抽象中。
-
通过在抽象上映射一个函数来将这个数字翻倍。
-
将其结果打印到 REPL。
如预期的那样,它将值 62 输出到屏幕上三次。
看起来map在所有三种情况下都执行相同的抽象步骤:它应用提供的函数,将结果值放入一个全新的容器中,并返回它。我们可以继续进行泛化,但这样我们只是在重新发现已经存在的抽象:函子。
函子
函子是我们将要研究的第一个抽象,它们相当简单:它们定义了一个名为fmap的单一操作。在 Clojure 中,可以使用协议来表示函子,并且它们用于可以映射的容器。这些容器包括但不限于列表、Future、Observables 和通道。
小贴士
本附录标题中的代数指的是抽象代数,这是研究代数结构的数学的一个分支。简单来说,代数结构是在其上定义了一个或多个运算的集合。
例如,考虑半群,这是一种这样的代数结构。它被定义为包含一组元素以及一个将这个集合中的任意两个元素结合起来的运算。因此,正整数集合加上加法运算构成一个半群。
另一个用于研究代数结构的工具被称为范畴论,函子是范畴论的一部分。
我们不会深入探讨所有这些理论背后的细节,因为关于这个主题有大量的书籍[9][10]可供参考。然而,解释这个附录中使用的标题却是必要的。
这是否意味着所有这些抽象都实现了函子协议?不幸的是,并非如此。由于 Clojure 是一种动态语言,它没有内置协议——这些协议是在语言的 1.2 版本中添加的——这些框架倾向于实现自己的map函数版本,而这个版本不属于任何特定的协议。
唯一的例外是即将到来的,它实现了fluokitten库中包含的协议,这是一个提供范畴论概念(如函子)的 Clojure 库。
这是fluokitten中找到的函子协议的简化版本:
(defprotocol Functor
(fmap [fv g]))
如前所述,函子定义了一个单一的操作。fmap将函数g应用于容器Functor``fv内部的任何值。
然而,实现这个协议并不能保证我们实际上已经实现了函子。这是因为,除了实现协议之外,函子还必须遵守一些定律,我们将简要地考察这些定律。
恒等律如下:
(= (fmap a-functor identity)
(identity a-functor))
上述代码就是我们验证这个定律所需的所有内容。它只是简单地说明,在a-functor上映射identity函数与直接将identity函数应用于函子本身是相同的。
组合律如下:
(= (fmap a-functor (comp f g))
(fmap (fmap a-functor g) f))
组合律反过来表明,如果我们组合两个任意函数f和g,将得到的结果函数应用于a-functor,这与先映射g到函子,然后映射f到结果函子是相同的。
无论文本有多少,都无法取代实际示例,因此我们将实现自己的 Functor,我们将其称为 Option。然后我们将重新审视这些定律,以确保我们已经遵守了它们。
选项 Functor
正如托尼·霍尔(Tony Hoare)曾经说过的,空引用是他价值十亿美元的失误(www.infoq.com/presentations/Null-References-The-Billion-Dollar-Mistake-Tony-Hoare)。无论背景如何,你无疑都会遇到可怕的 NullPointerException。这通常发生在我们尝试在 null 对象引用上调用方法时。
由于 Clojure 与其宿主语言 Java 的互操作性,Clojure 接受了空值,但它提供了更好的支持来处理它们。
核心库包含了许多函数,如果传递 nil 值,它们会执行正确操作——这是 Clojure 对 Java 的 null 的版本。例如,一个 nil 序列中有多少个元素?
(count nil) ;; 0
由于对 nil 的有意识设计决策,我们大部分情况下可以不必担心它。对于所有其他情况,Option Functor 可能会有些帮助。
本附录中剩余的示例应在 library-design/src/library_design/ 目录下的 option.clj 文件中。欢迎你在 REPL 中尝试这个示例。
让我们从添加命名空间声明以及我们将要使用的数据开始我们的下一个示例:
(ns library-design.option
(:require [uncomplicate.fluokitten.protocols :as fkp]
[uncomplicate.fluokitten.core :as fkc]
[uncomplicate.fluokitten.jvm :as fkj]
[imminent.core :as I]))
(def pirates [{:name "Jack Sparrow" :born 1700 :died 1740 :ship "Black Pearl"}
{:name "Blackbeard" :born 1680 :died 1750 :ship "Queen Anne's Revenge"}
{:name "Hector Barbossa" :born 1680 :died 1740 :ship nil}])
(defn pirate-by-name [name]
(->> pirates
(filter #(= name (:name %)))
first))
(defn age [{:keys [born died]}]
(- died born))
作为加勒比海盗的粉丝,我认为在这个例子中玩海盗很有趣。假设我们想要计算杰克·斯派洛的年龄。根据我们刚刚覆盖的数据和函数,这是一个简单的任务:
(-> (pirate-by-name "Jack Sparrow")
age) ;; 40
然而,如果我们想知道戴维·琼斯(Davy Jones)的年龄怎么办?实际上我们没有这个海盗的数据,所以如果我们再次运行我们的程序,我们会得到以下结果:
(-> (pirate-by-name "Davy Jones")
age) ;; NullPointerException clojure.lang.Numbers.ops (Numbers.java:961)
就这样。可怕的 NullPointerException。这是因为在我们年龄函数的实现中,我们最终尝试从两个 nil 值中减去,这是不正确的。正如你可能已经猜到的,我们将尝试通过使用 Option Functor 来修复这个问题。
传统上,Option 是作为代数数据类型实现的,更具体地说,是一个具有两种变体的求和类型:Some 和 None。这些变体用于确定值是否存在,而不使用 nils。你可以将 Some 和 None 都视为 Option 的子类型。
在 Clojure 中,我们将使用记录来表示它们:
(defrecord Some [v])
(defrecord None [])
(defn option [v]
(if v
(Some. v)
(None.)))
如我们所见,Some 可以包含单个值,而 None 则不包含任何内容。它只是一个表示内容缺失的标记。我们还创建了一个名为 option 的辅助函数,该函数根据其参数是否为 nil 创建适当的记录。
下一步是将 Functor 协议扩展到两个记录:
(extend-protocol fkp/Functor
Some
(fmap [f g]
(Some. (g (:v f))))
None
(fmap [_ _]
(None.)))
这就是Option Functor 的语义意义变得明显的地方:因为Some包含一个值,它的fmap实现只是将函数g应用到 Functor f内部的值上,该值是Some类型。最后,我们将结果放入一个新的Some记录中。
现在映射一个函数到None意味着什么呢?你可能已经猜到了,这实际上没有太多意义——None记录不包含任何值。我们能做的唯一一件事就是返回另一个None。正如我们很快就会看到的,这给了Option Functor 短路语义。
小贴士
在None的fmap实现中,我们可以返回对this的引用而不是一个新的记录实例。我没有这样做只是为了清楚地表明我们需要返回None的一个实例。
现在我们已经实现了 Functor 协议,我们可以尝试一下:
(->> (option (pirate-by-name "Jack Sparrow"))
(fkc/fmap age)) ;; #library_design.option.Some{:v 40}
(->> (option (pirate-by-name "Davy Jones"))
(fkc/fmap age)) ;; #library_design.option.None{}
第一个例子不应该有任何惊喜。我们将从调用pirate-by-name得到的海盗地图转换成一个选项,然后对它应用年龄函数。
第二个例子更有趣。正如之前所述,我们没有关于 Davy Jones 的数据。然而,对它映射age不再抛出异常,而是返回None。
这可能看起来是一个小小的好处,但关键是Option Functor 使得链式操作变得安全:
(->> (option (pirate-by-name "Jack Sparrow"))
(fkc/fmap age)
(fkc/fmap inc)
(fkc/fmap #(* 2 %))) ;; #library_design.option.Some{:v 82}
(->> (option (pirate-by-name "Davy Jones"))
(fkc/fmap age)
(fkc/fmap inc)
(fkc/fmap #(* 2 %))) ;; #library_design.option.None{}
到目前为止,一些读者可能已经在思考some->宏——在 Clojure 1.5 中引入——以及它如何有效地实现与Option Functor 相同的结果。这种直觉是正确的,如下所示:
(some-> (pirate-by-name "Davy Jones")
age
inc
(* 2)) ;; nil
some->宏会将第一个表达式的结果通过第一个形式传递,如果它不是nil。然后,如果那个表达式的结果不是nil,它会通过下一个形式传递,依此类推。一旦任何表达式评估为nil,some->就会短路并立即返回nil。
话虽如此,Functor 是一个更为通用的概念,因此只要我们在这个概念下工作,我们的代码就不需要随着我们在更高层次的抽象操作而改变:
(->> (i/future (pirate-by-name "Jack Sparrow"))
(fkc/fmap age)
(fkc/fmap inc)
(fkc/fmap #(* 2 %))) ;; #<Future@30518bfc: #<Success@39bd662c: 82>>
在前面的例子中,尽管我们使用的是一个本质上不同的工具——未来(futures),但使用结果的代码并不需要改变。这之所以可能,仅仅是因为 Options 和 futures 都是 Functors,并且实现了 fluokitten 提供的相同协议。我们获得了可组合性和简单性,因为我们可以使用相同的 API 来处理各种不同的抽象。
说到可组合性,这个特性由 Functors 的第二定律保证。让我们看看我们的 Option Functor 是否遵守这个以及第一定律——恒等定律:
;; Identity
(= (fkc/fmap identity (option 1))
(identity (option 1))) ;; true
;; Composition
(= (fkc/fmap (comp identity inc) (option 1))
(fkc/fmap identity (fkc/fmap inc (option 1)))) ;; true
我们就完成了,我们的 Option 函子现在是一个合法的公民。剩下的两个抽象也各自配对了自己的法则。我们不会在本节中介绍这些法则,但我鼓励读者去了解它们(www.leonardoborges.com/writings/2012/11/30/monads-in-small-bites-part-i-functors/)。
计算年龄的平均值
在本节中,我们将探索 Option 函子的不同用法。我们希望,给定一定数量的海盗,计算他们的平均年龄。这很简单:
(defn avg [& xs]
(float (/ (apply + xs) (count xs))))
(let [a (some-> (pirate-by-name "Jack Sparrow") age)
b (some-> (pirate-by-name "Blackbeard") age)
c (some-> (pirate-by-name "Hector Barbossa") age)]
(avg a b c)) ;; 56.666668
注意我们在这里是如何使用 some-> 来保护我们免受 nil 值的影响。现在,如果我们对某个海盗没有信息怎么办?
(let [a (some-> (pirate-by-name "Jack Sparrow") age)
b (some-> (pirate-by-name "Davy Jones") age)
c (some-> (pirate-by-name "Hector Barbossa") age)]
(avg a b c)) ;; NullPointerException clojure.lang.Numbers.ops (Numbers.java:961)
看起来我们又回到了起点!现在更糟,因为如果我们需要一次性使用所有值,而不是通过一系列函数调用传递它们,使用 some-> 就没有帮助了。
当然,并没有失去所有东西。我们只需要在计算平均值之前检查所有值是否都存在:
(let [a (some-> (pirate-by-name "Jack Sparrow") age)
b (some-> (pirate-by-name "Davy Jones") age)
c (some-> (pirate-by-name "Hector Barbossa") age)]
(when (and a b c)
(avg a b c))) ;; nil
虽然这工作得很好,但我们的实现突然必须意识到 a、b 和 c 中的任何一个或所有值都可能是 nil。我们将要查看的下一个抽象,应用函子,解决了这个问题。
应用函子
与函子类似,应用函子是一种容器,并定义了两个操作:
(defprotocol Applicative
(pure [av v])
(fapply [ag av]))
pure 函数是一种将值放入应用函子的通用方式。到目前为止,我们一直使用 option 辅助函数来完成这个目的。我们稍后会用到它。
fapply 函数将展开应用函子 ag 中包含的函数,并将其应用于应用函子 av 中包含的值。
通过示例,这两个函数的目的将变得清晰,但首先,我们需要将我们的 Option 函子提升为应用函子:
(extend-protocol fkp/Applicative
Some
(pure [_ v]
(Some. v))
(fapply [ag av]
(if-let [v (:v av)]
(Some. ((:v ag) v))
(None.)))
None
(pure [_ v]
(Some. v))
(fapply [ag av]
(None.)))
pure 的实现是最简单的。它所做的只是将值 v 包装成 Some 的一个实例。对于 None 的 fapply 实现来说,同样简单。因为没有值,所以我们再次简单地返回 None。
Some 的 fapply 实现确保两个参数都为 :v 关键字提供了值——严格来说,它们都必须是 Some 的实例。如果 :v 不是 nil,它将 ag 中包含的函数应用于 v,最后将结果包装起来。否则,它返回 None。
这应该足以尝试使用应用函子 API 的第一个示例:
(fkc/fapply (option inc) (option 2))
;; #library_design.option.Some{:v 3}
(fkc/fapply (option nil) (option 2))
;; #library_design.option.None{}
现在,我们能够处理包含函数的函子。此外,我们还保留了当任何函子没有值时应发生的行为的语义。
我们现在可以回顾之前提到的年龄平均值示例:
(def age-option (comp (partial fkc/fmap age) option pirate-by-name))
(let [a (age-option "Jack Sparrow")
b (age-option "Blackbeard")
c (age-option "Hector Barbossa")]
(fkc/<*> (option (fkj/curry avg 3))
a b c))
;; #library_design.option.Some{:v 56.666668}
小贴士
由 fluokitten 定义的 vararg 函数 <*> 对其参数执行左结合的 fapply。本质上,它是一个便利函数,使得 (fapply f g h) 等价于 (fapply (fapply f g) h)。
我们首先定义一个辅助函数来避免重复。age-option 函数为我们检索海盗的年龄作为一个选项。
接下来,我们将 avg 函数 Currying 到 3 个参数并将它放入一个选项中。然后,我们使用 <*> 函数将其应用于选项 a、b 和 c。我们得到了相同的结果,但是让 Applicative Functor 为我们处理 nil 值。
小贴士
函数 Currying
Currying 是将多个参数的函数转换为一个更高阶的函数的技术,该函数返回更多的单参数函数,直到所有参数都已提供。
大致来说,Currying 使得以下代码片段等价:
(def curried-1 (fkj/curry + 2))
(def curried-2 (fn [a]
(fn [b]
(+ a b))))
((curried-1 10) 20) ;; 30
((curried-2 10) 20) ;; 30
以这种方式使用 Applicative Functors 是如此常见,以至于这种模式已经被捕获为函数 alift,如下所示:
(defn alift
"Lifts a n-ary function `f` into a applicative context"
[f]
(fn [& as]
{:pre [(seq as)]}
(let [curried (fkj/curry f (count as))]
(apply fkc/<*>
(fkc/fmap curried (first as))
(rest as)))))
alift 函数负责以这种方式提升一个函数,使其可以在无需太多仪式的情况下与 Applicative Functors 一起使用。由于我们能够对 Applicative Functors 做出的假设——例如,它也是一个 Functor——我们可以编写通用的代码,该代码可以在任何 Applicatives 中重用。
在 alift 就位后,我们的年龄平均值示例变成了以下内容:
(let [a (age-option "Jack Sparrow")
b (age-option "Blackbeard")
c (age-option "Hector Barbossa")]
((alift avg) a b c))
;; #library_design.option.Some{:v 56.666668}
我们将 avg 提升为与 Applicative 兼容的版本,使代码看起来非常像简单的函数应用。由于我们不对 let 绑定执行任何有趣的操作,我们可以进一步简化如下:
((alift avg) (age-option "Jack Sparrow")
(age-option "Blackbeard")
(age-option "Hector Barbossa"))
;; #library_design.option.Some{:v 56.666668}
((alift avg) (age-option "Jack Sparrow")
(age-option "Davy Jones")
(age-option "Hector Barbossa"))
;; #library_design.option.None{}
与 Functors 一样,我们可以直接使用代码,并简单地替换底层的抽象,从而再次防止重复:
((alift avg) (i/future (some-> (pirate-by-name "Jack Sparrow") age))
(i/future (some-> (pirate-by-name "Blackbeard") age))
(i/future (some-> (pirate-by-name "Hector Barbossa") age)))
;; #<Future@17b1be96: #<Success@16577601: 56.666668>>
收集年龄统计数据
现在我们已经可以安全地计算多个海盗的平均年龄,也许我们可以进一步计算海盗年龄的中位数和标准差,除了他们的平均年龄。
我们已经有一个计算平均值的函数,所以让我们创建计算数字列表的中位数和标准差的函数:
(defn median [& ns]
(let [ns (sort ns)
cnt (count ns)
mid (bit-shift-right cnt 1)]
(if (odd? cnt)
(nth ns mid)
(/ (+ (nth ns mid) (nth ns (dec mid))) 2))))
(defn std-dev [& samples]
(let [n (count samples)
mean (/ (reduce + samples) n)
intermediate (map #(Math/pow (- %1 mean) 2) samples)]
(Math/sqrt
(/ (reduce + intermediate) n))))
在这些函数就位后,我们可以编写将为我们收集所有统计数据的代码:
(let [a (some-> (pirate-by-name "Jack Sparrow") age)
b (some-> (pirate-by-name "Blackbeard") age)
c (some-> (pirate-by-name "Hector Barbossa") age)
avg (avg a b c)
median (median a b c)
std-dev (std-dev a b c)]
{:avg avg
:median median
:std-dev std-dev})
;; {:avg 56.666668,
;; :median 60,
;; :std-dev 12.472191289246473}
这个实现相当直接。我们首先检索所有感兴趣的年龄并将它们绑定到局部变量 a、b 和 c。然后我们在计算剩余的统计数据时重用这些值。最后,我们将所有结果收集到一个映射中以便于访问。
到现在,读者可能已经知道我们的方向:如果这些值中的任何一个是 nil 会怎样?
(let [a (some-> (pirate-by-name "Jack Sparrow") age)
b (some-> (pirate-by-name "Davy Jones") age)
c (some-> (pirate-by-name "Hector Barbossa") age)
avg (avg a b c)
median (median a b c)
std-dev (std-dev a b c)]
{:avg avg
:median median
:std-dev std-dev})
;; NullPointerException clojure.lang.Numbers.ops (Numbers.java:961)
第二个绑定 b 返回 nil,因为我们没有关于戴维·琼斯的信息。因此,它导致计算失败。像之前一样,我们可以更改我们的实现以保护我们免受此类失败的影响:
(let [a (some-> (pirate-by-name "Jack Sparrow") age)
b (some-> (pirate-by-name "Davy Jones") age)
c (some-> (pirate-by-name "Hector Barbossa") age)
avg (when (and a b c) (avg a b c))
median (when (and a b c) (median a b c))
std-dev (when (and a b c) (std-dev a b c))]
(when (and a b c)
{:avg avg
:median median
:std-dev std-dev}))
;; nil
这次甚至比我们只需要计算平均值时还要糟糕;代码正在检查四个额外的nil值:在调用三个统计函数之前和将统计结果收集到结果映射之前。
我们能做得更好吗?
Monads
我们最后的抽象将解决上一节中提出的问题:如何通过保留我们正在工作的抽象的语义来安全地进行中间计算——在这个例子中,是选项。
现在应该不会感到惊讶,fluokitten 也提供了一个用于 Monads 的协议,简化并如下所示:
(defprotocol Monad
(bind [mv g]))
如果你从类层次结构的角度思考,Monads 将位于底部,继承自 Applicative Functors,而 Applicative Functors 又继承自 Functors。也就是说,如果你正在使用 Monad,你可以假设它也是一个 Applicative 和一个 Functor。
Monads 的 bind 函数将其第二个参数作为函数 g。这个函数接收 mv 中包含的值作为输入,并返回另一个包含其结果的 Monad。这是合同的关键部分:g 必须返回一个 Monad。
原因将在一些例子之后变得更加清晰。但首先,让我们将我们的 Option 抽象提升为 Monad——在这个时候,Option 已经是一个 Applicative Functor 和一个 Functor。
(extend-protocol fkp/Monad
Some
(bind [mv g]
(g (:v mv)))
None
(bind [_ _]
(None.)))
实现相当简单。在 None 版本中,我们实际上无法做任何事情,所以我们就像到目前为止所做的那样,返回一个 None 的实例。
Some 实现从 Monad mv 中提取值,并将其应用于函数 g。注意这次我们不需要将结果包装起来,因为函数 g 已经返回了一个 Monad 实例。
使用 Monad API,我们可以这样计算海盗的年龄总和:
(def opt-ctx (None.))
(fkc/bind (age-option "Jack Sparrow")
(fn [a]
(fkc/bind (age-option "Blackbeard")
(fn [b]
(fkc/bind (age-option "Hector Barbossa")
(fn [c]
(fkc/pure opt-ctx
(+ a b c))))))))
;; #library_design.option.Some{:v 170.0}
首先,我们在最内层的函数中使用了 Applicative 的 pure 函数。记住 pure 的作用是提供一个将值放入 Applicative Functor 中的通用方式。由于 Monads 也是 Applicative,所以我们在这里使用它们。
然而,由于 Clojure 是一种动态类型语言,我们需要通过上下文——容器类型来提示我们希望使用的纯函数。这个上下文简单地是 Some 或 None 的一个实例。它们都有相同的纯实现。
虽然我们得到了正确的结果,但前面的例子离我们想要写的代码还远,因为它有太多的嵌套。它也难以阅读。
幸运的是,fluokitten 提供了一种更好的方式来编写 monadic 代码,称为 do-notation:
(fkc/mdo [a (age-option "Jack Sparrow")
b (age-option "Blackbeard")
c (age-option "Hector Barbossa")]
(fkc/pure opt-ctx (+ a b c)))
;; #library_design.option.Some{:v 170.0}
突然之间,相同的代码变得更加干净和易于阅读,而没有丢失 Option Monad 的任何语义。这是因为 mdo 是一个宏,它扩展为嵌套版本的代码等效,我们可以通过以下方式展开宏来验证:
(require '[clojure.walk :as w])
(w/macroexpand-all '(fkc/mdo [a (age-option "Jack Sparrow")
b (age-option "Blackbeard")
c (age-option "Hector Barbossa")]
(option (+ a b c))))
;; (uncomplicate.fluokitten.core/bind
;; (age-option "Jack Sparrow")
;; (fn*
;; ([a]
;; (uncomplicate.fluokitten.core/bind
;; (age-option "Blackbeard")
;; (fn*
;; ([b]
;; (uncomplicate.fluokitten.core/bind
;; (age-option "Hector Barbossa")
;; (fn* ([c] (fkc/pure opt-ctx (+ a b c)))))))))))
小贴士
在这里停下来,花一点时间来欣赏 Clojure(以及 Lisp 的一般)的力量是很重要的。
像 Haskell 和 Scala 这样的语言,它们大量使用 Functors、Applicative 和 Monads 这样的抽象,也有它们自己的 do-notation 版本。然而,这种支持已经内置到编译器本身中。
以 Haskell 向语言添加 do-notation 为例,一个新版本的编译器被发布,希望使用新功能的开发者必须升级。
相反,在 Clojure 中,由于宏的强大和灵活性,这个新特性可以作为一个库发布。这正是 fluokitten 所做的事情。
现在,我们准备回到我们的原始问题,收集海盗的年龄统计数据。
首先,我们将定义几个辅助函数,将我们的统计函数的结果转换为 Option Monad:
(def avg-opt (comp option avg))
(def median-opt (comp option median))
(def std-dev-opt (comp option std-dev))
在这里,我们利用函数组合来创建现有函数的 monadic 版本。
接下来,我们将使用我们之前学到的 monadic do-notation 重写我们的解决方案:
(fkc/mdo [a (age-option "Jack Sparrow")
b (age-option "Blackbeard")
c (age-option "Hector Barbossa")
avg (avg-opt a b c)
median (median-opt a b c)
std-dev (std-dev-opt a b c)]
(option {:avg avg
:median median
:std-dev std-dev}))
;; #library_design.option.Some{:v {:avg 56.666668,
;; :median 60,
;; :std-dev 12.472191289246473}}
这次我们能够像平时一样编写函数,无需担心中间计算中的任何值是否为空。这种语义正是 Option Monad 的精髓所在,如下所示:
(fkc/mdo [a (age-option "Jack Sparrow")
b (age-option "Blackbeard")
c (age-option "Hector Barbossa")
avg (avg-opt a b c)
median (median-opt a b c)
std-dev (std-dev-opt a b c)]
(fkc/pure opt-ctx {:avg avg
:median median
:std-dev std-dev}))
;; #library_design.option.None{}
为了完整性,我们将使用 futures 来演示 do-notation 对于任何 Monad 的工作方式:
(def avg-fut (comp i/future-call avg))
(def median-fut (comp i/future-call median))
(def std-dev-fut (comp i/future-call std-dev))
(fkc/mdo [a (i/future (some-> (pirate-by-name "Jack Sparrow") age))
b (i/future (some-> (pirate-by-name "Blackbeard") age))
c (i/future (some-> (pirate-by-name "Hector Barbossa") age))
avg (avg-fut a b c)
median (median-fut a b c)
std-dev (std-dev-fut a b c)]
(i/const-future {:avg avg
:median median
:std-dev std-dev}))
;; #<Future@3fd0b0d0: #<Success@1e08486b: {:avg 56.666668,
;; :median 60,
;; :std-dev 12.472191289246473}>>
摘要
本附录带我们简要游览了范畴论的世界。我们学习了其三个抽象:Functors、Applicative Functors 和 Monads。它们是 imminent API 背后的指导原则。
为了深化我们的知识和理解,我们实现了自己的 Option Monad,这是一种常用的抽象,用于安全地处理值的缺失。
我们还看到,使用这些抽象可以使我们对代码做出一些假设,如alift函数所示。还有很多其他函数,我们通常需要为不同的目的反复重写,但如果我们认识到我们的代码符合学到的某个抽象,则可以重用。
最后,我希望这能鼓励读者进一步探索范畴论,因为它无疑会改变你的思维方式。而且,如果我可以如此大胆地说,我希望这也能改变你未来设计库的方式。
附录 B. 参考文献列表
[1] Rene Pardo 和 Remy Landau,《世界上的第一张电子表格》:www.renepardo.com/articles/spreadsheet.pdf
[2] Conal Elliott 和 Paul Hudak,《函数式响应式动画》:conal.net/papers/icfp97/icfp97.pdf
[3] Evan Czaplicki,《Elm:用于功能 GUI 的并发 FRP》:elm-lang.org/papers/concurrent-frp.pdf
[4] Erik Meijer,《Subject/Observer 是 Iterator 的对偶》:csl.stanford.edu/~christos/pldi2010.fit/meijer.duality.pdf
[5] Henrik Nilsson、Antony Courtney 和 John Peterson,《函数式响应式编程,续》:haskell.cs.yale.edu/wp-content/uploads/2011/02/workshop-02.pdf
[6] John Hughes,《将 Monads 推广到 Arrows》:www.cse.chalmers.se/~rjmh/Papers/arrows.pdf
[7] 万占勇,瓦利德·塔哈和保罗·胡达克,实时反应式编程:haskell.cs.yale.edu/wp-content/uploads/2011/02/rt-frp.pdf
[8] 瓦利德·塔哈,万占勇和保罗·胡达克,事件驱动反应式编程:www.cs.yale.edu/homes/zwan/papers/mcu/efrp.pdf
[9] 本杰明·C·皮尔斯,计算机科学家基础范畴论:www.amazon.com/Category-Computer-Scientists-Foundations-Computing-ebook/dp/B00MG7E5WE/ref=sr_1_7?ie=UTF8&qid=1423484917&sr=8-7&keywords=category+theory
[10] 史蒂夫·奥沃迪,范畴论(牛津逻辑指南):www.amazon.com/Category-Theory-Oxford-Logic-Guides/dp/0199237182/ref=sr_1_2?ie=UTF8&qid=1423484917&sr=8-2&keywords=category+theory
[11] 丹肯·科茨,罗马·莱斯钦斯基和唐·斯图尔特,流融合:code.haskell.org/~dons/papers/icfp088-coutts.pdf
[12] 菲利普·瓦德勒,将程序转换为消除树:homepages.inf.ed.ac.uk/wadler/papers/deforest/deforest.ps


浙公网安备 33010602011771号