Raku Recipes

on

1. 在真实环境中使用 Raku

在解决任何问题之前,你需要准备好你的环境来编辑、测试和运行你的 Raku 程序。本章将对你将面临的问题提出解决方案。Raku 可以作为第一语言使用(事实上,我鼓励你这样看待它),但你也可以直接使用其他语言的概念。

比方说,你喜欢烹饪,你决定创建一个应用程序,其中有你创建的食谱,以及其他来自内容提供商和广大公众的食谱。人们将能够看到、上传、评论和评价菜谱。后台将用 Raku 编写,因为这将使你能够利用它的所有功能。

你将需要执行一系列与处理、处理、渲染有关的不同任务,并对这些文件进行各种操作。

但在你做这些之前,你需要准备好你的工具。在本章中,你将立即开始做这些工作。

1.1. 食谱 1-1. 准备好你的工具

1.1.1. 问题

你需要在 Raku 中创建一个程序、模块或脚本。

1.1.2. 解决办法

安装 Rakudo Raku 和 Comma IDE 并开始使用它们。

1.1.3. 它是如何工作的

Raku 是一种编译语言,它使用一个虚拟机来运行由编译器创建的字节码汇编。这类似于 Java 创建 .class.jar 文件然后在 Java 虚拟机中运行的方式,或者 C# 创建在 CLR 中运行的程序集的方式。

那么,Raku 编译器实际上是一个不同程序的堆栈。

  • 下层被运行字节码汇编的虚拟机所占据。Raku 并不致力于单一的虚拟机,事实上目前有三个虚拟机可用。MoarVM、JavaScript 虚拟机和 JVM。一般来说,MoarVM 是参考虚拟机,除非另有说明,否则本书将使用 MoarVM。

  • 下一层由 NQP(Not Quite Perl 的缩写,意思是 Not Quite Perl,也可能是完全不同的语言)占据,NQP 是一种简化的语言,它通过翻译成 Java 或 MoarVM 字节码来生成字节码。

  • 最上层是由编译器占据的。解释器将解析 Raku 代码,将生成字节码的工作留给 NQP。使用中间语言意味着 Raku 解释器可以用 Raku 编写。只要一个程序能够通过 roast 套件中的所有测试,它就被认为是一个 Raku 解释器。Raku 没有选择像 Perl(姊妹语言)这样的参考实现,也没有选择像 ECMAScript 这样的规范,而是选择了一个参考测试套件,这让实现者在创建解释器/编译器时有了更大的灵活性。然而,到今天为止,有一个单一的实现,它被称为 Rakudo Raku 或简单的 Rakudo。

在这种情况下安装 Raku,假设你安装的是这个完整的栈,至少有一个虚拟机,通常是 MoarVM。

Raku 是开源的,你可以简单地克隆这三个仓库,然后创建一个 "Raku"。

你自己的 Raku 版本可以通过篡改编译器选项来实现。Raku 社区鼓励你这样做,如果你想使用 Raku 进行黑客和学习的话。然而,社区的建议是用以下两种方式之一来安装 Raku。

1.1.4. 使用 rakudobrew 版本管理器

这将让你得到完整的堆栈,如果需要的话编译 MoarVM,并创建一个 perl6 可执行文件,在正确配置后,你可以从命令行运行。这也会下载 zef 模块管理器,你可以在之后安装。下载 rakudobrew,并按照其仓库中的说明进行操作(https://github.com/tadzik/rakudobrew)。确保包括安装任何不存在的先决条件,并使其从命令行中可用。

这些命令将下载版本管理器,选择已下载的版本,并构建 zef,以及使其对你可用。

rakudobrew build moar-2019.11
rakudobrew global moar-2019.11
rakudobrew build zef

你可以运行这些命令:

raku –version
zef –version

你将获得这样的东西,显示已经安装的内容和它所包含的版本堆栈。

This is Rakudo version 2019.11 built on MoarVM version 2019.11 implementing
Perl 6.d. and v0.8.2

输出显示了 Rakudo 和 MoarVM 的版本;一般来说,它们是匹配的(如果你使用 rakudobrew),当然你也可以将它们混搭起来。一般来说,我们的建议是使用最后的版本。实现位指的是正在使用的语言规范的版本。第一个生产就绪的版本是 6.c,写这篇文章时开发的是 6.d,6.e 目前正在开发中。

注意 rakudo/moarvm/raku 的版本遵循年.月.版本计划,每年都会产生几个版本。你可以通过关注 @raku_news twitter 账户来了解最新的版本(以及其他 raku 相关的新闻)。

如果你开发模块或者需要对照几个版本检查你的代码,这可能是最好的选择。

1.1.5. 使用 Rakudo Star

乐道星是一个发行版,也就是一个包,不仅包括乐,还包括 zef,文档,以及最基本的模块。这是一个包含电池的下载,你可以直接用来启动自己的程序。包是为三个最广泛使用的操作系统准备的。Linux、Windows 和 OSX。你可以从 https://raku.org/downloads 下载它们。

Rakudo Star 版本(通常)是在相应的 Rakudo 版本之后不久制作的,并且具有相同的编号系统。它们经过了稳定性测试,并包含了对所包含软件包的修复。它们更容易安装,并且受到 Rakudo 开发者的高度鼓励。如果你将它们用于本书和你的开发环境,就不会出错。

1.1.6. 源码控制工具

在专业或自制软件开发中,使用源码控制不再是可有可无的事情。使用 Git 与任何一个在线托管网站,GitLab 和 GitHub,每一步都很方便。从现在开始,我们将假设你的程序是在仓库中创建的。此外,你可能希望安装 GitHub 或 GitLab CLI 以利用基本 Git 工具的扩展。

因此,作为开始,我们将在我们最喜欢的 Git 托管环境中创建一个仓库,GitHub、GitLab 或 BitBucket。暂时来说,GitHub 不允许你在下拉菜单中选择 Raku 作为语言来创建一个合适的 .gitignore,所以就留空,我们以后再做。创建仓库后,将其克隆到本地。或者,你也可以创建一个空的文件夹,然后用 git init 初始化它。

1.1.7. Comma,Raku 综合发展环境

Raku 选择的 IDE 叫 Comma。Edument 是一家瑞典-捷克公司,它是基于 IntelliJ IDEA 创建的。它分为社区版和 Comma 完整版,社区版是免费的,Comma 完整版的许可证可以从公司购买。社区版包含了大量开发、运行和调试 Raku 程序的工具。

我们将使用 Comma 社区版来解决第一个问题。在我们创建的菜谱相关的创业项目中,我们需要统计菜谱的总数,这些菜谱分布在所有不同的目录中。

食谱将以树状结构,就像图 1-1 所示。

图 1-1.菜谱的初始子目录结构 食谱库的初始子目录结构

无论结构如何,程序都能通过递归下降到文件中并计数来确定有多少个菜谱。这些文件是 Markdown 格式的,有一定的结构,但我们暂时不打算考虑这个问题。我们只是计算文件的数量—​这是一项相对简单的工作。

然而,项目可以而且确实在发展,Comma 明白它将在一个仓库中工作,并且知道仓库中文件的状态。因此,你要做的第一件事是从现有源中选择文件 ➤ 新建 ➤ 项目。你将得到一个像图 1-2 中所示的对话框。

图 1-2. 项目列表,提示你选择一个项目。已经被逗号控制的,有蝴蝶图标

在灰色的文件夹中,我们会看到 perl6-recipes-apress,这是克隆的仓库所在。选择那个文件夹来托管项目,让 Comma 来管理它。你会得到一个像图 1-3 一样的弹出窗口,在这里你会被提示选择项目名称,默认情况下,项目名称是目录名(完全保持这种方式是合理的)。通过点击 Next,你就可以选择 SDK 了。可以暂时跳过这一步。通过选择文件 ➤ 项目结构,可以随时设置这两者。

图 1-3.选择一个项目名称。选择项目名称,以后再选择 SDK

你需要选择 SDK,或软件开发包。用 IntelliJ IDEA 的话来说,这就相当于在 IDE 中选择你要使用的 Raku 编译器。这在你实际运行某些程序之前是没有必要的,但你现在可以这样做。通过点击 New,你将能够从已经安装的 Raku 版本中进行选择,如图 1-4 所示。

图 1-4. 选择你要在项目中使用的 SDK,或 Perl 的版本 该组合将显示你通过 rakudobrew 或使用任何其他方法安装的所有版本。在这种情况下,选择当前版本的 Raku。

在你实际创建脚本之前,你可以做一个额外的配置。Comma 会创建一系列文件来跟踪你的本地仓库配置,包括诸如你之前所做的两个选择。它们被保存到一系列 XML 文件中,在一个叫做 .idea 的目录下,以及版本库根目录下的 .iml 文件。可能最好也把它添加到版本库中。由于 Comma 知道你在哪里工作,所以会提示你将它们添加到版本库中,如图 1-5 所示。

图 1-5.添加 Comma 配置的提示 Comma 提示将 Comma 配置文件添加到源码控制体系中去

这样就会向 Git 添加一系列文件。在控制台写下 git status,你会得到如图 1-6 所示的答案。

图 1-6. git 状态显示所有添加到 Git 的 Comma 配置文件

.idea/workspace.xml 文件包含了工作空间的布局,也就是 Comma IDE 的布局,打开的文件等等。将它保存在本地,不放在版本库中可能是有意义的。出于同样的原因,通过编写以下内容来保持它脱离你的本地状态

echo workspace.xml > .gitignore

其中,同时会创建 workpace.xml 文件。在创建文件之前,通过提交和推送所有的更改来收尾。

选择文件 ➤ 新建将打开图 1-7 所示的对话框。选择 Raku 脚本 (Perl 6 脚本),因为你要创建一个简单的脚本。该菜单允许你创建各种东西,从未确定的文件,到测试文件,再到 Raku 模块(在本书后面会做)。

图 1-7. 从 Comma IDE 中创建一个 Raku 脚本

你创建的文件,其名称会被提示输入,它将具有 Raku 脚本的基本结构,包括磅礴行,如图 1-8 所示。

图 1-8. 逗号创建的脚本的锅炉模板(我们添加了 use v6;)。

脚本的模板包括一个 sub MAIN。我们将添加 use v6;,只有当你也准备好了可用的 Perl 解释器时才需要,因为用它们会产生一个错误。我还添加了 $dir = '.' 参数,它将保存你要处理的食谱库的最上面的目录。

典型的 "shebang" 句子如下:

#!/usr/bin/env raku

这一行使用一个系统实用程序来寻找当前环境中的 raku 二进制文件;这包括与 rakudobrew 一起安装的,也包括与 Rakudo Star 一起安装的,它允许你使用任何一个。

注意这在 Windows 中不起作用,你必须从命令行中运行 raku,而在类似 linux 的环境中,如 linux 子系统或 msys,它确实可以工作。

你需要在下一行添加 use v6;,这将防止 perl5 解释器出错,并将产生一个错误,表明你应该切换到 Raku 来运行它。

你可以像这样填写程序的其余部分。

use v6;
sub MAIN( $dir = '.' ) {
    say tree( $dir )».List.flat.elems;
}
sub tree( $dir ) {
    my @files = gather for dir($dir) -> $f {
        if ( $f.IO.f ) {
            take $f
        } else {
            take tree($f);
} }
    return @files;
}

这个程序的要点是在子程序树中,它递归下降到所有子目录,并创建一个包含所有文件的数组。这个数组被转换为一个列表,列表被扁平化,并计算元素的数量。

我们可以直接从 Comma 中提交这个文件,如图 1-9 所示。

图 1-9. 点击右上角的云端区域,从 Comma 中签入一个变化。

集成开发环境会提示提交消息,你会得到一个像图 1-10 所示的系统消息。

图 1-10.Comma 报告提交完成,并显示提交消息。逗号报告提交完成,并显示提交消息。

如果你点击 Commit 按钮,你可以选择同时提交和推送。如果你愿意,也可以从命令行推送。

在这之后,我们需要运行脚本。逗号让你在工作目录上打开一个控制台,点击底部栏的终端。如果你添加了运行配置,你会得到所有的好东西,包括可以调试它。默认情况下,你没有创建任何运行/调试配置,因为 Comma 不会假设你将创建一个特定类型的项目。你需要点击添加配置,你会得到一个像图 1-11 所示的对话框。

图 1-11.运行/调试配置对话框 运行/调试配置对话框,选择了 Perl 6。

图 1-11 中选择的 Perl 6,指的是一个 Raku 脚本。你会得到那个对话框 当你点击 + 时,选择你要使用的脚本类型。当你选择后,会打开一个对话框,你需要选择脚本的路径,以及给它一个名字,比如 Count Recipes。由于脚本需要食谱所在的最上层目录,所以将食谱添加为脚本参数。这个名字就会出现,而不是之前的添加配置名称。

你可以点击脚本右边的绿色播放符号来运行它。运行脚本的结果将出现在控制台上,在一个窗口中标有你给它的名字。

这样,你就成功地从 Comma lDE 中运行了你的第一个脚本。一般来说,Comma 已经准备好运行和调试你所有的 Raku 代码,从简单的脚本到复杂的并发模块,它都是特别准备好的,所以我们强烈建议你使用 Comma lDE。所以我们强烈建议你在所有的 Raku 项目中使用 Comma。

1.2. 食谱 1-2. 将其他语言的概念运用到 Raku 中

1.2.1. 问题

由于 Raku 不是你的第一语言,你知道如何用另一种编程语言做大多数事情。因此,你想用 Raku 来进行编程。

1.2.2. 解决办法

Raku 是一种多范式语言;它是函数式、并发式和面向对象式的。如果你知道任何一种语言遵循这些范式中的一种或任何一种,你可以通过简单的 Google 语法或使用语言文档将你所知道的应用于 Raku。

也有一些特定的文档页面将 Raku 与其他语言进行比较。请参考它们以获得特定的例子以及函数名和结构的直接翻译。

1.2.3. 它是如何工作的

官方的 Raku 文档在 https://docs.raku.org,包含了一套针对以下语言的迁移指南。

  • Perl。有几页专门介绍了从 Perl 迁移到 Raku 的过程。它们是完全不同的语言,但其中相当大的一部分内容是关于 社区对该语言非常熟练,所以这六页内容涵盖了运算符、函数以及 Perl 用于正则表达式的大部分特定语法,例如。

  • Node.js/JavaScript

  • Ruby

  • Python

  • Haskell

第一个 Raku 编译器是由 Audrey Tang 用 Haskell 编写的,所以这些语言比你想象的更接近。 搜索"如何在 Raku 中进行 X 操作"将带你到文档、StackOverflow 答案或在线教程中的正确位置。在某些情况下,Raku 给某些命令或数据结构起了特定的名字。表 1-1 提供了 Raku 名称与其他语言名称的简短翻译指南。

Raku Name

其它语言

Range

Python, Go: 一个函数, 而不数据结构. Ruby: range. Haskell: range

Seq

Ruby: range (非惰性). haskell: seq (非惰性). Clojure: lazy-seq

traits

Elixir: @behaviour

roles

在几乎所有语言中都叫 traits. Kotlin: interfaces. Javascript: traits 和 mixins. Ruby: mixins

sink context

空上下文

phasers

有些语言,比如 Python,在程序或模块加载的某个特定阶段,使用 init.py 这样的特定文件来运行代码。Perl 也有类似的 BEGIN, END 等块。Kotlin 在类中有 init 块。

multi-dispatch

在大多数语言中都叫做多重分派

proto

elixir: protocols

subset

一般来说,这对应于精炼类型的概念,例如在 TypeScript 或 liquid haskell 中。 Ada:子类型或约束类型

一般来说,Raku 融合了许多现代编程语言中的范式、数据结构和构造;很难找到一种语言拥有如此丰富的范式、数据结构和构造。如果你懂得任何语言,尤其是 Haskell 或 Scala 等函数式语言,那么通过表 1-1 或搜索引擎,将很容易把你所知道的数据和控制结构映射到 Raku 中。

当然,有许多控制和数据结构是 Raku 所特有的。例如,结点和语法。我强烈鼓励你查看 Apress 和同一作者的《Perl 6 快速语法参考书》,以获得对它们的认可。

当书籍和参考资料失败时,你将需要一个帮助。我们接下来会讲到这个问题。

1.3. 食谱 1-3. 参与到社区中来

1.3.1. 问题

你需要帮助了解乐库的更多细节,或者你想认识其他正在使用乐库的人。

1.3.2. 解决办法

你可以关注几个 Twitter 账户,以及一些 StackOverflow 标签和几个 IRC 频道。

1.3.3. 它是如何工作的

一个社区对于一种编程语言或技术的健康发展至关重要。在这里,你可以了解新语言的细微差别,了解新的发展和模式。

Raku 社区广泛使用 IRC,也就是互联网中继聊天,它是 Slack 的前身。它是 Slack 的前身,不同的主机分为不同的频道,每个频道都使用 # 作为前缀。Freenode 中的主频道是 #raku,用这个 URL 表示:irc://irc.freenode.net/#raku。当点击或输入该网址时,浏览器会要求你打开一个 IRC 客户端,其中有很多。我比较喜欢 weechat,一个控制台客户端,但也有面向控制台以及更多面向窗口的客户端,适用于任何操作系统。你也可以使用预装的网络客户端,从这个网址:https://webchat.freenode.net/#raku

除了在那里闲逛的人,#raku 上还有一系列机器人,它们会帮助你评估 Raku 表达式,以及浏览代码及其历史。

其他可能有用的频道如下。 - #raku-dev 是核心开发者的聚集地。 - #whateverable 是一个你可以咨询友好的机器人 Camelia 的频道,它将为你评估你的 Raku 表达式。你也可以在其他任何一个频道中这样做。如果你打算大量使用它,最好是把它移到一边去,这样你就不会打扰到那里的对话流程。

有几个邮件列表你也可以订阅;这些对寻求建议很有用。参见 https://raku.org/archive/lists/。perl6 用户列表可能是你应该订阅的一个。它没有太多的流量,而且你可以帮助其他用户并得到他们的帮助。

Raku 的 "extraofficial"官方 Twitter 账号是 @raku_news,其中有 Liz Matijsen 策划的每周新闻和其他与 Raku 相关的活动、教程和小知识。还有其他 Perl6er 在 Twitter 上有不同程度的活跃度,以及与 Raku 相关的推文。一个有趣的,即使不是 Raku 独有的,也是 @perlwchallenge,这是一个每周一次的挑战,可以使用 Perl 5 或 Raku 解决。它指向博客文章,介绍如何用两种语言解决挑战。这是一种非常有趣的学习新事物的方式,并通过解决这些问题来挑战自己。

最后,StackOverflow 每周都会在 [raku] 标签中收到一些 Raku 相关的问题。成为社区的一员,意味着不仅要查看它对你的问题的有用答案,还要对有用的问题和答案进行投票,并时常查看它,以防你能帮助到别人。

在你的国家或附近也很有可能有一个 Perl(和 Raku)会议。每年在美国、亚洲和欧洲都会有一些重要的 Perl 会议,它们的特色是 Perl 和 Raku 的讲座、开发者和用户。没有什么比面对面的交流更重要了,而且你总能学到关于这门语言和它的库的新东西。

1.4. 食谱 1-4. 安装一些外部的、有用的模块

1.4.1. 问题

你需要创建一个程序,你需要一个没有包含在核心库中的库或模块,或者是 Rakudo Star 发行版的一部分。

1.4.2. 解决办法

使用 zef 来安装你需要的模块,搜索它们,并自动安装它们的所有依赖关系。

1.4.3. 它是如何工作的

Raku 以标准库的形式包含了一些 "电池"。这些库包括所有的基本类和类型,以及用于处理本地库(NativeCall)和 Rakudo 特定模块和类的附加模块。

Rakudo Star 发行版在其捆绑包中包含了更多的库—​例如一些处理 JSON 的库、一些测试库和脚本、HTML 模板以及 HTTP 库和实用程序。对于基本的应用来说,这已经足够了,还有一个附加的价值,那就是它们已经针对它们所捆绑的 Raku 版本进行了广泛的测试。

在 Raku 生态系统中还有更多的库,到 2019 年底大约有 2000 个。你可以在 https://modules.raku.org 访问它们,在那里,它们按标签和名称组织。

在你的情况下,你想处理库中的食谱,以提取它们的标题。它们是用 Markdown 编写的。你可以通过简单地使用 Zef(Raku 模块管理器)搜索在其名称或描述中包含 Markdown 的模块。

zef search Markdown

这将返回一个实现 Markdown 的模块列表,或者以某种方式做一些与之相关的事情。从(缩短的)描述来看,Text::Markdown 似乎就是我们要找的东西。你可以用下面的方法安装它。

zef install Text::Markdown

这也将安装所有需要的依赖关系,如果有的话。所有的模块都使用 Pod6(Raku 标记语言)来描述它们的工作。你可以查看他们发布的 API 库。在某些情况下,会有额外的教程。例如,这个模块在 Raku Advent Calendar 的一篇文章中就有介绍,网址是:https://perl6advent.wordpress.com/2017/12/03/letterops-with-perl6/

然后,你可以使用该模块来编写这个程序,它将采取一个文件,并照顾它的主头。

use Text::Markdown;
sub MAIN( $file ) {
    if $file.IO.e {
        my $md = parse-markdown-from-file($file);
        say "Recipe title: ", $_.text
                for $md.document.items
                .grep( Text::Markdown::Heading )
                .grep( { $_.level == 1 } );
} }

第一行(在 shebang 之后,从现在开始我们将不再介绍它)包括将 Text::Markdown 导入到当前程序中的句子,并使其所有导出的函数可用。其中一个函数是 parse-markdown-from-file,它接收一个文件并将其转换为一个复杂的对象,一个 Text::Markdown::* 对象的数组。你可以只打印配方的名称,方法是取文件中的所有项目($md.document.items),只过滤那些是标题(* ~~ Text::Markdown::Heading)的项目,然后再过滤那些有 alevelequalto1({ $_.level == 1 }) 的标题。

虽然这个模块对这个特殊的问题很有用,但还有其他几个模块你可能有兴趣下载。这些模块具有很高的河道得分,这意味着它们被生态系统中的其他模块非常频繁地使用,而且深度不一。它们为 Raku 添加了有趣的功能,因此,熟悉它们是个好主意。

  • Template::Mustache:一个用于将数据结构渲染成模板的模块。

  • URI.Mustache: 一个用于将数据结构渲染成模板的模块。一个用于处理通用资源标识符的模块。

  • Cro.Cro::Mustache: 一个用于将数据结构渲染成模板的模块。一个用于创建分布式应用的模块,包括微服务和许多其他模块。

  • File::Temp:一个以独立于操作系统的方式创建时态文件的模块。

一般来说,Raku 生态系统将包含你需要完成你的应用程序的试用和成熟的模块。只需使用 zef 和/或 modules.raku.org 来搜索它,然后 zef 将其安装到你的编码环境中。

1.5. 食谱 1-5. 检测操作系统环境,并据此改变程序行为

1.5.1. 问题

你的程序最终可能会在一个未知的操作系统中运行。你需要它知道它的环境。

1.5.2. 解决办法

使用 $*DISTRO$*SPEC 和其他动态变量来确定操作系统的特定特性,并编写特定的代码。

同时避免直接引用操作系统的路径;使用 IO::Path 以一种与操作系统无关的方式访问这些路径。

1.5.3. 如何工作

在你的菜谱网站中,你需要为一个目录中的所有文件创建一个简单的目录,以便检查它们。这可以通过使用目录和 globs 来解决,但你可以选择更直接的方法,在操作系统中发出相应的调用。这个程序将为你完成这个任务。

class Recipes {
    has $.folder;
    has $!is-win = $*DISTRO.is-win;
    multi method show( $self where .is-win: ) {
        shell "dir {$self.folder}";
}

    multi method show( $self: ) {
         shell "ls {$self.folder}";
} }
Recipes.new(folder => "recipes").show

在 Raku 中,有很多方法可以做任何事情,而习惯性的方法并不是你会经常听到的。使用从 Raku 程序中发出的操作系统专用命令,就像使用 dir 子程序或 IO::Path 的子方法一样习惯。在这种情况下,我们还使用了两个习惯性的乐库结构:通过冒号将调用者包含在签名中。

multi method show( $self: ) {

并利用签名中的 where 来确定我们要调用哪个版本的方法。

总而言之,这个 recipe 做了以下工作:它在建立对象时就初始化 Recipes 类的 is-win 属性,并接收在 $*DISTRO 上调用 is-win 的结果作为默认值。多表示,如果在对象本身($self)上调用的 is-win 为 True,将调用方法的第一个版本(使用 dir)。如果失败,则会进入第二个版本,它使用 ls,一个在 Linux 和 OSX 中工作的命令。作用于对象本身的多重调度是一个有趣的模式,可以应用在 Raku 中,而且是它特有的模式。

这个变量是一个动态范围变量,它由符号 $ 后面的 twigil * 表示。这些全大写变量是由编译器初始化的,所以它们的值取决于一系列的启发式(和编译时的设置变量),这些变量检测脚本运行的操作系统。

默认情况下:

say $*DISTRO

会返回类似 debian(9.stretch) 的东西。这是调用变量上的 gist 方法的结果。然而,这个变量包含了更多关于操作系统的信息。

say $*DISTRO.perl
# OUTPUT: «Distro.new(release => "9", is-win => Bool::False, path-sep => ":",
#     name => "debian", auth  => "https://www.debian.org/", version =>
      v9.stretch,
# signature => Blob, desc => "Debian GNU/Linux 9 (stretch)")␤»

最有趣的信息是 shell 中使用的路径分隔符,在 Debian 中是 :,以及发布号,这在调整代码以适应特定操作系统的不同版本时可能会有用。

另一个变量,$*SPEC,是专门与文件的指定方式有关的,它包含了一个将被用来处理文件的类。Raku 中有四个类:IO::Spec::CygwinIO::Spec::QNXIO::Spec::UnixIO::Spec::Win32。由于这些都是 IO::Path 类内部使用的,所以最好使用它们(而不是直接使用文件名)来构建路径,这样才能保证在任何操作系统中都能使用。事实上,在该类的上一版本中并没有考虑到这一点,所以我们将这样修改。

class Recipes {
    has $.folder;
    has $.folder-path = IO::Path.new( $!folder );;
    has $!is-win = $*DISTRO.is-win;
    multi method show( $self where .is-win: ) {
        shell "dir {$self.folder-path}";
}
    multi method show( $self: ) {
         shell "ls {$self.folder-path}";
} }
Recipes.new(folder => "recipes/desserts").show

只要路径中有一个斜线,我们就可能需要适应特定的操作系统。IO::Path 就能帮你做到这一点。以前我们是直接使用名称,现在我们使用一个 IO::Path 对象,当我们把它插入到命令中列出文件时,它将串联成一个符合操作系统的路径。由于这个路径是不会改变的,所以会自动用对象创建。

2. 输入和输出

大多数程序需要与文件系统和网络交互,以获取数据并产生结果。输入/输出例程和类,或简称I/O,将这些功能分组。在本章中,我们包括几个配方,它们将帮助你以不同的方式处理不同种类的文件。

2.1. 食谱 2-1. 读取作为参数处理的文件

2.1.1. 问题

你需要处理一系列的文件,但你事先并不知道你要处理哪些文件,所以最好是脚本能处理你提供的作为参数的文件。

2.1.2. 解决办法

动态变量 $*ARGFILES 是一个伪文件的别名,其中包括所有在命令行中提供的文件。在你的脚本中使用它。

2.1.3. 它是如何工作的

你需要计算你的菜谱网站的权重,也就是那里的总句数。或者,你需要按目录来计算这个数字。无论哪种方式,都是你需要对一组文件进行的操作,它会对文件的内容进行统一处理。因此,让我们创建一个脚本,打印句子的数量(用句号隔开,或者双行分隔符,就像标题一样)。

事实上,只需一行代码就可以完成。

say "Sentences → ", $*ARGFILES.lines.join("\n") .split( / [ '.' | \v**2 ] / ).elems;

$ARGFILES 的行为是一个单一的文件句柄,其中的文件已经打开。你不需要担心依次打开或关闭每一个文件句柄;只要你用一个文件列表来调用脚本,Raku 就会整理它们,并使它们在这个单一变量下可用。你可以对这个文件进行不同的操作,比如逐行读取。这允许你使用任何你想要的东西来整理行,例如,回车。split 使用的正则表达式会用一个句号('.')或两个垂直的空格(\v * 2)来分割结果的字符串。

例如,你可以这样运行这个脚本。

perl6 Chapter-2/count-sentences.p6 recipes/desserts/*.md

而且它会返回5作为目前版本库中的版本。

注意: 我也鼓励你从 Comma 运行这个脚本,至少要习惯它。在这种情况下,它是一个单一的文件,没有太多的调试,但多行脚本和更复杂的模块将真正受益于 Comma 提供的工具和提示。如果你想这样做,你必须包含一个配置,并附上脚本名称和工作目录(例如仓库的顶部)。唯一需要考虑的是,Comma 不理解 globs。所以,作为脚本参数,你将不得不充实文件的名称,这样:recipes/desserts/buckwheat-pudding.md recipes/desserts/guacustard.md

其实,这相当于这样做。

say "Sentences → ", $*ARGFILES.slurp.split( / [ '.' | \v**2 ] / ).elems;

文件集被简单地吞噬,也就是说,整个吞噬成一个单一的字符串,包括回车。通过行分离,然后使用你喜欢的任何方式连接,可以让你有更多的灵活性;例如,你可能想消除作为标题的行。上面的分割表达式可能会产生空的"句子"(例如,如果句号后面有两个垂直空格),此外,我们还想消除标题(以#开头)。这个版本可以解决这个问题。

say "Sentences → ", $*ARGFILES.lines.grep( /^<:L>/ ) .join("\n").split( / [ '.' | \v**2 ] / ).elems;

通过使用 grep 只选择那些以字母 (<:L>) 开头的行,我们将删除以哈希标记开头的标题、空行 (前提是没有以空格开头的行和后面跟着任何其他字母的行,我们将注意不使用这些字母) 和标题。剩下的就是以字母开头的行了。但是,可能有一些情况下,在一个章节或文件的结尾处有空行,我们可能忘了在那里加一个句号来结束一个句子。我们会将其也标记为句子的结尾,这也就解释了分割中的双垂直空格。最后,我们计算创建的数组的元素数量,这将表示句子的数量。

该脚本在处理单个文件而不是多个文件时的效果是一样的。如果你想单独查找和处理文件,当然可以这样做。在接下来的配方中,我们将看到如何异步处理它们。

2.2. 食谱 2-2. 异步读取和处理文件

2.2.1. 问题

你需要在不阻止程序的情况下读取未知大小的文件。

2.2.2. 解决办法

Raku 包含了许多异步操作的设施,你既可以使用异步输入/输出,也可以使用 tap 进行事件驱动的操作。你既可以使用异步输入/输出,也可以使用 tap 进行事件驱动操作。

2.2.3. 它是如何工作的

异步编程是一种强大的,即使不完全是流行的,也是一种处理任务的方式,其持续时间是事先不知道的。同步程序启动任务,而程序的其他部分则等待该任务的完成。当需要及时给出响应时,这种行为是相当不方便的,比如在网络上。

异步编程从 JavaScript 开始流行起来。JavaScript 最初是为 Web 界面设计的,它的工作原理是按顺序处理事件,但不会阻塞 UI。当基于服务器的版本 Node 被创建时,这种行为扩展到所有类型的事件。解释器运行一个事件循环,一些任务创建事件和回调,当事件被激活时就会被调用。

这在输入/输出中是相当方便的。不需要同步等待整个文件被读取,而是启动一个读取任务,当该任务完成后,调用一个回调函数。读取工作在后台进行,而程序的其他部分则由自己处理其他事情,或者创建其他事件,这些事件将被依次处理。

假设你需要检查菜谱数据库中的所有文件,然后对它们进行一些操作,比如像我们在上一章所做的那样提取它们的标题。这些文件可能有不同的大小,或者在文件系统中的不同深度,这意味着如果其中一个文件的时间比平常长,所有的操作都会被延迟。

使用这个脚本。

use Text::Markdown;
sub MAIN( $dir = '.' ) {
    my @promises = do for tree( $dir ).List.flat -> $f {
        start extract-titles( $f )
    }
    my @results = await @promises;
    say "Recipes ⇒\n\t", @results.map( *.chomp).join: "\t";
}

sub tree( $dir ) {
    my @files = gather for dir($dir) -> $f {
        if ( $f.IO.f ) {
            take $f
        } else {
            take tree($f);
        }
    }
    return @files;
}

sub extract-titles ( $f ) {
    my @titles;
    if $f.IO.e {
        my $md = parse-markdown($f[0].slurp);
        @titles = $md.document.items
            .grep( Text::Markdown::Heading )
            .grep( { $_.level == 1 } );
    }
    @titles;
}

树形例程,也就是我们之前使用的那个,以递归方式在目录上运行,以收集所有文件,这应该不会花费太多时间,尽管我们也可以异步运行这部分,甚至并行运行。这应该不会花很长时间,尽管我们也可以异步运行这部分,甚至是并行运行。我们将以异步方式做的是打开,然后处理文件的内容。

程序的主要部分运行在文件列表上,并为每个文件创建一个承诺。start 命令正是这样做的。它从作为参数的块中创建一个承诺。在 Raku 中,for 循环会返回每一次迭代的最后一句话的结果列表。这里的单句 start 将返回一个承诺;我们将这个承诺数组分配给一个变量,有效地称为 @promises

在每个承诺中,都有一个普通的 vanilla 代码块,用来检查文件的存在(谁知道呢,它可能在从树例程出来的路上消失了,安全起见总是好的)。块不能使用 return 关键字,但它们会返回最后一条语句产生的任何内容。所以那个承诺,完成后会被保留,会返回那个值。

但是,这个值暂时没有分配给任何东西。它存储在承诺中,但在承诺真正被保留之前,你不会知道它的价值。await 语句正是这样做的:它等到其参数中的所有承诺都被履行,并返回它们的值;@results 将包含这些值,然后在下一步中以同步的方式打印出来。

这样读取文件的速度会比同步读取文件的速度略快到明显。增加的速度将取决于文件的大小和每个文件需要处理的数量。但是脚本中还有一部分不是异步的。我们不妨使用供应品一路异步。

supply 是一个可以异步填充和选取的对象序列,总是按照填充的顺序来。你向一个 supply 发射来填充它,并从它那里敲击来使用它的值。这些敲击的好处是它们可以异步发生,而且一个 supply 可以有任意多的 tap。看看这个脚本。

sub MAIN( $dir = '.' ) {
    my $supply = supply tree-emit( $dir );
    my @titles = gather {
        $supply.tap( -> $f { take $f.IO.lines.head } )
    };
    say "Recipes ⇒\n", @titles.join("\n");
}

sub tree-emit( $dir ) {
    for dir($dir) -> $f {
        if ( $f.IO.f ) {
            emit $f
        } else {
            tree-emit($f);
        }
    }
}

它递归地深入到目录中,每找到一个文件就发出一个文件名。由于树状发射例程是在供应中调用的,所以供应将收集所有的文件名,而用 $supply.tap 调用的 tap 将获得每个文件的第一行,并将其收集在数组中。但供应块内的代码不会被运行,直到它被真正的 tap。把 supply 不看作是缓冲区,而是看作是一个任务列表,在绝对需要之前不会运行。如果我们对脚本进行这样的改动。

sub MAIN( $dir = '.' ) {
    my $supply = supply tree-emit( $dir );
    say "Now let's rock";
    my @titles = gather {
        $supply.tap( -> $f { take $f.IO.slurp.lines.head } )
    };
    say "Recipes ⇒\n", @titles.join("\n");
}

结果将按此顺序打印。

Now let's rock
Let's emit recipes/main/rice/tuna-risotto.md
(and the rest of the files)

这种操作风格也被称为反应式编程;抽头依次对供应中的每个对象做出反应。这是一种高效的I/O工作方式,因为它将减少循环产生的开销。许多现代语言 - 从 Node.js 到 Dart 再到最新 3.x 版本的 Python - 都使用这种反应式操作来处理从简单的文件I/O到服务于 Web 页面和其他服务的一切。

2.3. 食谱 2-3. 连接外部实用程序和文件的输入和输出

2.3.1. 问题

你需要运行一个外部程序并处理它的输出,或者反过来说,你需要向一个外部程序提供文本以处理它。

2.3.2. 解决方法

Proc::Async 类提供了各种与外部交互式命令行程序接口的设施。

2.3.3. 如何工作

一旦奠定了异步工作的基础,你就可以用它来做各种事情。你可以与文件和其他程序进行交互,这些程序以未知的时间间隔发出文本。想一想系统日志或其他类型的程序,它们会不时地附加到文件中。例如,这个脚本会对整个系统中运行的进程进行快照,并将它们追加到一个文件中。

watch "ps -e | tail --lines=+2 >> /tmp/ps.log"

文件会是这样的。

28485 ?       00:00:03 kworker/u8:0
28821 ?       00:00:19 gimp-2.8
28829 ?       00:00:01 script-fu
30976 ?       00:00:00 docker
31001 ?       00:00:00 containerd-shim
31029 pts/0   00:00:00 sh
31189 pts/4   00:00:02 zsh

这是一个基本结构,包括每行的最后两个元素,命令运行的时间和实际语句的缩写。

这个程序将监控这个文件并对其采取行动。

my $proc = Proc::Async.new: 'tail', '-f', '/tmp/ps.log';
$proc.stdout.tap(-> $v {
    $v ~~ m:g/([\d+] ** 3 % ':') \s+ (\S+)/;
    with $/ {
        for $_.list -> $match {
            my $command = $match[1];
            my $time = $match[0];
            given $command {
                when .contains("sh") {
                    say "Running shell $command for $time"
                }
                when none( "watch", "ps", "tail" ) { say "Seen $command" }
            }
        }
    }
});

say "Listening to /tmp/ps.log";
await $proc.start;
say "Finished";

虽然有点长,但其结构很简单。首先,创建异步连接,然后设置监视器,最后运行实际的进程,也是异步的。

正如解决方案中所指出的,Proc::Async 是驱动所有这些交互的类;它的文档在 https://docs.raku.org/type/Proc::Async。我们在第一行中使用它,创建一个要运行 tail -f /tmp/ps.log 的进程。我们需要以这种方式分割它,以保护它不被 shell 逃逸。继续到脚本的最后一部分,我们通过启动进程创建一个承诺,并使用 await 等待承诺的完成。实际上,这永远不会发生,因为 tail -f 会一直运行,直到它被打断。

但是异步处理是在中间完成的。就像我们在前面的配方中所做的那样,我们使用 taps。Proc::Async 会从每个进程句柄中创建供应:标准输入或 stdin,标准输出或 stdout,以及标准错误。我们只对 stdout 感兴趣,所以这就是我们敲击的供应。我们通过一个正则表达式来运行每一行。([dd+] ** 3 % ':' 部分是三组一到多个数字的集合。这就是它的运行时间。第二部分只是命令的开头。圆括号捕捉内容,只有当有实际捕捉到的内容时,我们才运行循环(用 $/)。$_ 将等于 $/(因为用 topicalizes,使得 $_ 等于其表达式)。

正如你可以想象的那样,把这个连接到另一个程序的输入上,也会以同样的方式工作。例如,你可以运行一个程序,并将其输出连接到另一个程序的输入。例如,要计算资源库中的食谱(是 Markdown 文件)的数量,我们使用这个。

my $find-proc = Proc::Async.new: 'find', @*ARGS[0] // "recipes", "-name", "*.md";
my $wc-proc = Proc::Async.new: 'wc';

$wc-proc.bind-stdin: $find-proc.stdout;
$wc-proc.stdout.tap( { $_.print } );

my $wc = $wc-proc.start;
my $find = $find-proc.start;
await $wc, $find;
say "✓ Finished"

这个程序使用两个异步进程。我们将从 find 进程中读取信息,它使用UNIX命令行实用程序来查找文件系统中的文件。它把起始目录作为第一个参数,并从那里开始深入,直到检查完所有的子目录。我们将使用一个命令行参数,但默认情况下,它将使用 recipes 作为顶级目录。

第二个过程是另一个计算字数的命令行工具。它产生的行是这样的。

6 6 209

它显示的是字数(6)、行数(6)和总字符数(209)。由于第一个程序每行会产生一个文件,所以前两个数字是一样的。

下一行使用 bind-stdin 来连接 wc 工具的输入和 find 工具的输出,就像你做了以下的操作(在 UNIX/Linux 中)。

find recipes -name "*.md" -print | wc

你可能知道,管道符号|连接左边的输出和右边的输入。Raku 会通过编程来完成,而且效率很高。一旦这样做了,你就可以点击 wc 进程的供给,它将打印输出。

由于我们现在有两个进程,你必须等待,直到两个承诺都实现,你在程序的下一行到最后一行做。

这些管道可以随你的需要而复杂化;例如,你可以将输出绑定到几个输入,并创建连接不同的、现成的实用程序的胶水脚本。

这些脚本也可以在 MacOS 和 Windows 的 Linux 子系统中工作,以及在 Windows 的不同 bash 命令行中工作。然而,在这种情况下,我们使用 PowerShell 命令。在任何情况下,Raku 将使用任何可用的操作系统设施来运行你启动的命令。

2.4. 食谱 2-4. 读取和处理二进制文件

2.4.1. 问题

你需要处理二进制文件,如图像或视频。

2.4.2. 解决办法

用任何文件读取命令都可以读取二进制文件。然而,它的内容需要存储在被称为 blobs 的特殊数据结构中,根据格式的不同,也会有 Raku 模块可以处理它们;例如,图像或声音文件的模块。

2.4.3. 它是如何工作的

假设你在你的菜谱网站上存储了一系列的图片,你需要在提供这些图片之前检查它们的大小,以便缩小它们的尺寸,使其适应特定的屏幕,或者其他什么。无论如何,你需要知道图片的宽度和高度。

这是存储在文件中的两块数据,它们是文件头的一部分。它们是文件头的一部分。被称为 EXIF 读取器的专业工具能够收集这些数据,以及与相机设置相关的所有其他数据,甚至在某些情况下收集GPS数据。让我们保持简单,只用 Raku 获得宽度和高度。这个程序就可以做到这一点。

my Blob $image = slurp( @*ARGS[0] // "../recipes-images/rice.jpg", :bin);
# From here https://stackoverflow.com/a/43313299/891440 by user6096479
my $index = 0;
until $image[$index] == 255 and $image[$index+1] == any( 0xC0, 0xC2 ) {
    $index++;
    last if $index > $image.elems;
}

if ( $index < $image.elems ) {
   say "Height ", $image[$index+5]*256 + $image[$index+6];
   say "Width ", $image[$index+7]*256 + $image[$index+8];
} else {
    die "JPG metadata not found, damaged file or wrong file format";
}

尽管它有很多数字,但它归结为这些步骤:获取数据(二进制形式),寻找一个标记(表示数据开始的文件块),然后获取数据并打印出来。让我们把这个过程分解一下。

  • 首先,我们用一个 blob 来存储文件的二进制内容;slurp 会返回一个 blob,如果它的选项是 :bin,就像二进制一样。一个 blob 基本上是一个字节列表(实际上是 uint8,用8位表示的无符号整数)。until 循环探索直到找到该段的标记:一个价值为 FF 的十六进制字节,或 255,然后是另一个价值为 C0C2 的十六进制字节。

  • 当找到该标记时,高度存储在第五和第六个字节的两个字节中,我们将第一个字节乘以256转换成十进制。宽度存储在接下来的两个字节中,我们以完全相同的方式将其转换为十进制。

  • 如果在命令行中发出一个已经损坏或具有任何其他格式的文件,这些标记将不会被发现。循环将结束,脚本将以错误信息退出。 如果你需要从这些图像中获取更多的信息,或者只是将它们以自相同的形式存储在其他地方,那么 blobs 就是最好的方式。

2.5. 食谱 2-5. 观看文件的变化

2.5.1. 问题

你需要检查一个文件或目录是否有任何变化。

2.5.2. 解决办法

使用 IO::Notification.watch-path,它将返回一个你可以点击检查或以其他方式行动的供应,它将具有 IO::Notification::Change 对象的形状。

2.5.3. 它是如何工作的

你已经看到了异步代码在 Raku 中的工作方式。一般来说,它观察一系列事件,并在事件发生时运行一些代码。

在本章之前的配方中,其他代码都会生成事件。然而,系统本身也会一直在低水平上生成事件,我们只要接入这个流,就可以对它们进行处理。我说的是 tap 吗?好吧,我们在 Raku 里有 tap,不是吗?所以,你可以直接写一个程序,挖掘相应文件或目录中的变化所产生的事件流(或供应)。

例如,假设我们需要检查是否有新的配方被添加到文件系统中的配方集合中,或者是否对它们做了什么。我们可以在添加了新的菜谱之后,运行一些检查,或者在没有包含西兰花的菜谱被删除的时候发出警报(如果包含西兰花的话,完全是合理的)。让我们用这个脚本来实现。

my $dir = @*ARGS[0] // 'recipes';
my $dir-watch-supply= IO::Notification.watch-path($dir);

$dir-watch-supply.tap: -> $change {
    given $change.event {
        when FileChanged { say "{$change.path} has changed"}
        when FileRenamed {
            say "{$change.path} has been renamed, deleted or created"
        }
    }
};
await Promise.in(30).then: { say "Finished watch"; };

正如配方的解决方案中所指出的,我们使用方便的 IO::Notification Raku 类来检查配方目录。这个类包括一个单一的方法, watch-path,它以一个代表要监视的路径的字符串作为参数。这个方法会产生一个供应,我们可以有效地对其进行敲击。

敲击将产生变化事件,这些变化事件是 IO::Notification::Change 类的对象,它有两个属性:事件的类型,即是否被 FileChanged(例如,改变了大小)或 FileRenamed(包括创建或删除,以及实际的重命名),以及路径。这就是为什么我们要检查 $change.event 的值,根据类型来调整涉及路径的消息。

然而,供应只是创建了一个事件流,当其中一个事件产生时,一个 tap 就会异步运行。不过,我们需要一些东西来等待事件的发生。我们需要一个事件循环,允许脚本继续执行,直到满足某个条件。这就是最后一条语句的作用。它创建了一个承诺,在30秒后保留;实际上,这是一个等待循环,它将存在30秒,之后脚本将结束。

注意: 很有可能 tap 产生的消息会被保存在一个缓冲区中,并在程序结束时全部打印出来。这也是为什么这类事件循环必须优雅地退出的原因,这样所有的缓冲区都会被刷新,事件和它们产生的消息也不会丢失。

这样做的问题是,事件循环不可能永远进行下去。它将观察30秒;然后,它将不得不再次重新启动以捕捉新的变化。这最终可能会变得很烦人;一个手表应该一直在观察。下一个脚本将做到这一点。

my $dir = @*ARGS[0] // 'recipes';
my $dir-watch-supply= $dir.IO.watch;
my $ctrl-c = Promise.new;
$dir-watch-supply.tap: -> $change {
    given $change.event {
        when FileChanged { say "{$change.path} has changed"}
        when FileRenamed {
            say "{$change.path} has been renamed, deleted or created"
        }
    }
};
signal(SIGINT).tap( { say "Exiting"; $ctrl-c.keep } );
await $ctrl-c;

这个脚本为文件监视使用了另一种形式,从字符串中创建了一个 IO::Path,并将监视放在它上面。然而,这并不是主要的变化。接下来创建了一个新的、实际上是空的许诺;我们称它为 $ctrl-c,因为这就是它要做的事情。检查 tap 是完全一样的,但它在这之后就发生了变化。

首先,我们设置了一个关于 SIGINT 信号供应的 tap。SIGINT 是按 Ctrl+C 时调用的系统信号。我们可以捕获该信号并对其采取行动,在这种情况下,我们将打印一条消息,表明我们正在优雅地退出应用程序,然后我们遵守承诺,到目前为止还没有实现。由于程序将一直等待,直到该承诺被遵守,这就是下一条语句所做的事情,它将有效地退出,冲洗输出缓冲区,一般做正确的事情退出程序。

3. 数据科学和数据分析

脚本(和其他)语言是很好的资源,可以将数据从一种格式获取到另一种格式,或者对已经被捣毁的数据进行操作。数据科学指的是数据收集、数据混杂,并执行操作以产生结果;数据分析不太面向数学,指的是对数据进行简单的聚合或执行单个操作。

一些编程范式,如函数式编程,用于处理这类任务。由于 Raku 的多范式性质及其广泛的原生函数集,它独特地适用于这类任务,我们将在本章中看到。

3.1. 食谱 3-1. 从多个文件中提取唯一的电子邮件地址/用户名

3.1.1. 问题

对于一组存在于三个文件中的电子邮件地址,需要确定哪些邮件地址在三个文件中重复,反之,哪些邮件只在其中一个文件中唯一出现。

3.1.2. 解决方法

使用集合运算来确定所有文件之间的交集,或两个文件之间的差集。

3.1.3. 它是如何工作的

决定在的菜谱网站上建立一个每月新菜谱的通讯。系统以文件的形式收集新的地址,一行一个地址。不幸的是,只是临时使用文件,而不是一个适当的通讯应用程序,可以很容易地管理这一切。办公室里的每个人都会保存他们在前几周收到的电子邮件地址。随着新的登录驱动器的出现,你最终会收到许多不同的电子邮件。这些人往往对不同版本的通讯感兴趣(只发甜点,只发素食,等等)。你决定创建一个核心的电子邮件地址列表,以接收所有的新闻邮件。你可以通过所有不同的文件创建者来确定哪些人注册了所有不同的版本。

这些文件将被这样列出。

one@ema.il
another@electron.ic
yetan@oth.er

…​等等。一行一个。这个脚本可以帮你做到这一点。

say [∩] do for dir( @*ARGS[0] // "emails", test => /txt$/ ) -> $f {
    $f.lines;
}

这基本上是一行的工作,所以让我们把它分开。本质上,这个脚本只是从一个目录中读取名字以 .txt 结尾的文件,将它们按行划分,创建一个 list-of-lists,并对这个 list-of-lists 应用一个还原运算符。这个还原运算符依次抽取每一个成员,计算交集,并将结果与列表中的下一个列表相交。

让我们从下到上,从右到左来分解这个表达式。

  1. $f.lines 会创建一个邮件地址的列表,这些邮件地址被放在不同的行上。slurp 方法将读取一整个文件。其结果将被返回。

  2. $f 将包含一个 IO::Path,它将被用作循环变量。

  3. 循环将在 dir 返回的列表上运行;这个命令将检查命令行中作为第一个参数传递的目录(@*ARGS[0]`),如果没有定义(//),则检查 "emails"。目录中可能还有其他文件,所以我们只得到那些名字以txt结尾的文件(/txt$/)。这是一个正则表达式,原则上你可以使用任何表达式来过滤文件。事实上,test可以是任何类型的测试,包括文件权限或类型测试。我们将在一个以该名称命名的目录上使用它,该目录包括几个文件 --email-(1,2,3).txt-- 这就是为什么我们使用该模式进行过滤。

  4. 我们在 for 前面使用 do,它将 for 放在一个创建列表的上下文中。这将用每次迭代的结果创建一个列表,在我们的例子中,这也是一个列表(每个文件中的电子邮件)。

  5. 在前面,列表换算超运算符 [],应用到交集运算符 [∩];这些之所以被称为 hyper,是因为它们需要一个底层运算符来工作,结合它们的语义。在这种情况下,它结合了交集运算符的语义和 [] 超运算符的应用在序列到列表的语义。Raku 支持许多 Unicode 数学运算符及其隐含的语义,但要确定如何在特定的编辑器或操作系统中键入它们并不容易。每一个运算符都有一个 ASCII 码(读:容易输入)对应的符号,通常是一些用括号包围的符号。在这种情况下,它是 (&) (记忆法则:& 表示和;交集选择那些在一个集合和另一个集合中的元素)。还原是一种常见的列表操作,对列表中的前两个项目依次应用一个运算符,然后对第三个元素进行运算,以此类推。所以在本例中,对于三个文件,它要做的是 (@list[0] ∩ @list[1]) ∩ @list[2]。使用换算超运算符意味着我们不需要事先知道一个列表中有多少项。

这最终导致了一个非常紧凑的脚本,可以从命令行运行。

raku -e 'say [∩] do .slurp.lines for dir( @*ARGS[0] // "emails", test => /txt$/ );'

我们将循环体移到 for 前面,并取消循环占位符变量,从而进一步减少循环体。我们要做一个简单的改变,将出现在一个文件中的邮件提取出来。

raku -e 'say [(-)] do .slurp.lines for dir( @*ARGS[0] // "emails", test => /txt$/ );'

(-)(ASCII 等价物)或 是差集运算符。Raku 的一个优点是它能够在集合上使用这些类型的运算符。集合不仅适用于数学计算,它们也有很好的商业用途,如这两个例子所示。

3.2. 食谱 3-2. 创建一个加权随机数生成器

3.2.1. 问题

我们需要创造一个作弊的轮盘赌或装模作样的骰子,以产生比别人更大的概率的中奖号码。

3.2.2. 解决方法

使用 Mixes。这个数据结构是一个带有权重的集合,集合中元素的权重可以用来"加载"结果,相对于其他元素。

3.2.3. 它是如何工作的

混合料是不同元素的集合,每一个元素都有一个权重分配给它。例如,我们想通过投掷一个模子来生成新的食谱,模子中的元素和原料一样多,但我们也想考虑到我们对一些原料的偏好。比如我们可以加载洋葱,卸载大蒜,用这个小程序来创建一个我们要使用的食材列表。

my $ingredients = ( rice => 1, chickpeas => 1,
                    onion => 2, tomatoes => 1,
                    garlic => 0.5, pasta => 1,
                    chestnut => 0.25, bellpeppers => 1).Mix;

for ^10 {
    say "New recipe ⇒ ", $ingredients.roll( 5 ).unique.join(", ");
}

Mixs 本质上是具有实值的哈希或关联数组,这就是为什么我们通过创建那种哈希并对其调用 .Mix 来声明它们。

这个配方没有更多的内容。Mixs 正是为这种事情而创建的。我们是把洋葱装上,把大蒜和栗子卸下(如果不是当季的栗子很贵,也不怎么好)。我们实际上是把那个装好的骰子掷了五次,它将以(相对)概率产生我们需要的食材。那次掷骰子会创建一个五种元素的列表,我们提取其中独特的成分(这会使一些配方变得更短)。最后,我们打印整个事情,结果是这样的。

New recipe ⇒ chickpeas, onion
New recipe ⇒ pasta, bellpeppers, tomatoes, onion
New recipe ⇒ onion, rice, pasta
New recipe ⇒ bellpeppers, onion
New recipe ⇒ bellpeppers, onion, chickpeas, pasta, garlic
New recipe ⇒ onion, chestnut, tomatoes, garlic
New recipe ⇒ tomatoes, onion, rice
New recipe ⇒ tomatoes, onion, garlic, pasta
New recipe ⇒ onion, rice, tomatoes
New recipe ⇒ onion, pasta, rice, bellpeppers

洋葱多,这应该是意料之中的事,栗子不多。这显然是随机的,有些时候它要从出现两次的食谱中剔除,但装模作样的地方还是很明显的。

3.3. 食谱 3-3. 用电子表格工作、过滤、排序和转换数据

3.3.1. 问题

我们需要访问 Excel 电子表格中包含的数据。

3.3.2. 解决办法

生态系统中有一个工作模块 Parser::FreeXL::Native,可以直接读取电子表格。如果电子表格已经以基于文本的 CSV 格式保存,则可以直接或通过 Text::CSV 模块进行读取和解析。

3.3.3. 它是如何工作的

企业将结构化数据保存在电子表格中是很正常的,Excel 格式很广泛,可以由微软 Office 产品以及 LibreOffice 等开源应用和 Google Suite 等在线应用制作和读取。这些电子表格中的数据是以行和列的形式分布的,所以很容易输入,也很容易在上面制作图表或应用操作。

你已经收到了一个电子表格,里面有你将要使用的食材的卡路里数据。

请注意,事实上,像 FDa 这样的政府组织一直在制作含有有用营养信息的电子表格。然而,我们将在这里使用的电子表格是专门为此目的而创建的。

我们将使用的电子表格如图3-1所示。它将有三栏,分别是成分的名称(我们将用它作为键)、显示卡路里的单位和卡路里。

Ingredient Unit    Calories
Rice       100g    130
Chickpeas  100g    364
Lentils    100g    116
Egg        Unit    78
Apple      Unit    52
Beer       ⅓ liter 216
Tuna       100g    130

图3-1. 成分数据库样本

你需要读取这些信息,包含在一个 calories.xls 文件中,并计算出一道美味的金枪鱼烩饭的热量。这个脚本可以做到这一点。

use Parser::FreeXL::Native;

my %ingredients = %( Rice => g => 350,
                     Tuna =>  g => 400 ,
                     Cheese => g => 200 );

my Parser::FreeXL::Native $xl-er .= new;

$xl-er.open("data/calories.xls");
$xl-er.select_sheet(0);

my $total-calories = 0;
for 1..^$xl-er.sheet_dimensions[0] -> $r {
    my $ingredient = $xl-er.get_cell($r,0).value;
    if %ingredients{$ingredient} {
       my ($q, $unit )= extract-measure($xl-er.get_cell($r,1).value);
       if %ingredients{$ingredient}.key eq $unit  {
	   $total-calories += $xl-er.get_cell($r,2).value
	                   * %ingredients{$ingredient}.value / $q;
       }
   }
}

say "Total calories ⇒ $total-calories";

sub extract-measure( $str ) {
    $str ~~ /^^ $<q> = ( <:N>* ) \s* $<unit>=(\w+)/;
    my $value = val( ~$<q> ) // unival( $<q> );
    return ($value,~$<unit>)
}

为了运行这个程序,你需要安装它所使用的模块,通过以下方式。

zef install Parser::FreeXL::Native

这是一个本地模块。这意味着它为一个编译的共享库提供了一个 Raku 前台,在这种情况下,它被称为 FreeXL。这个库可以通过通常的安装命令(在 Ubuntu 的情况下)安装在 Linux 或 Windows 的 Linux 子系统中。

sudo apt install libfreexl-dev

你应该按照通常的程序在 MacOS 或 Windows 中安装它。

这是 Raku 的一个很好的功能;NativeCall 接口提供了一个简单的方法来包装本地库,这样你就可以通过一个自然的 Raku 接口在你的程序中利用它们。

这个脚本有两个不同的部分:第一个部分读取电子表格中的数值,第二个部分将使用这些数值来计算菜品中的卡路里,其成分、用于衡量它们的单位和数量都包含在 %ingredients 变量中。这个变量用一对来表示数量,这对变量的键将是单位(本例中是克),值将是单位数。在本例中,350个大米,400个金枪鱼,200个奶酪。这对你来说可能有点多,但我的家人喜欢吃奶酪。

第一部分从电子表格中读取数值:它读取文件,选择其中唯一的一张表(索引=0),然后开始运行行。它从第二行开始(索引=1),因为第0行只包含页眉。

第二部分是一个循环,在电子表格的行上运行,其索引将进入 $r 变量。

regex,也就是从第二列(索引1)中提取计量单位和数量的正则表达式(有时复数写成 regexen)有点棘手,这就是为什么我们把它们放在一个单独的 extract-measures 例程中。但我们需要知道卡路里是如何计算的,这是一种方便的确定方式。正则表达式的名声很不好,但是一旦你了解了它们的要领,它们对于那种任务—​从有一点结构的文本中获取数据—​是非常好的。

让我们试着理解一下正则表达式。对于这些成分,它说的是100克这样的东西,这意味着我们测量每100克的卡路里(这是通常的方式)。但我们需要将其分解为度量单位(100)和单位(g)。首先,我们通过 ^^ 将 regex 固定在字符串的开头。紧接着,一个数字,如果存在,将表示度量单位。我们通过 (<:N>*) 来表达,星号意味着,在某些情况下,比如当它简单地表示 Unit 时,它将不存在。括号用于 捕获结果。请注意,我们在这里使用的是 <:N>,而不是更熟悉的 \d: Unicode 属性。在我们的测量集合中,我们有一个 ⅓(对于啤酒来说,这接近于半品脱,这也是我们在西班牙测量啤酒的方式之一,un tercio),这将不匹配 \d,所以我们使用一个字符类描述来覆盖它。然后,单位将被一些空格隔开(或不隔开)(因此又是 *),并将是一个或多个"字"(\w)字符的集合。

对于我们感兴趣的三个成分,该 regex 将得到100这个数字进入 $<q> 变量,g 进入 $<unit> 变量。使用角括号的变量可以在正则表达式内定义和分配,也可以在正则表达式外使用,就像我们在定义的例程中做的那样。然而,$<q> 需要额外的处理,再次感谢 el tercio。通常将字符串转换为数字的例程不能直接处理它们,它们只能处理数字字面,如 3、2.3e7,或 ASCII 版本的分数 1/3。事实上,这个脚本中的任何成分都不会发生这种情况,但这并不意味着我们不应该考虑到这种情况。

注意: 我们本可以使用这种形状,比如 1/3,来表达我们表格中的小数.然而,这将在正则表达式中产生一系列其他问题。所以,我们还是不要这样做了。

如果我们在一个只包含 "⅓" 的字符串上使用 val,就会返回失败。但失败是 nils 的伪装,所以我们利用这个事实给 $value 赋值。如果用 val 转换失败,将对其应用 unival,返回数值,这就是我们返回的内容。

该例程将返回两个值的列表:用于测量卡路里的数量和单位。第一个可以是一个空字符串。

一旦数据从电子表格中提取出来,我们就需要添加它。循环的行是这样做的:首先我们获取原料的名称,这是电子表格中的第一列。只有当该原料在我们的菜品中时,我们才会继续进行;只有这样,我们才会使用正则表达式来提取计量和单位。然后,如果单位与我们的食材列表中的单位相同,我们就根据食材的重量(在本例中)进行操作,计算卡路里的数量。

这里的结果将是高达1231卡路里。不过不用担心,因为这是为四个人准备的食谱。如果你想的话,你甚至可以在奶酪上大做文章,只是会增加一些卡路里。

正如解决方案中所评论的那样,我们也可以使用另一种模块读取 CSV 文件。具有相同信息的 CSV 文件是一个文本文件,看起来像这样。

Ingredient;Unit;Calories
Rice;100g;130
Chickpeas;100g;364
Lentils;100g;116
Egg;Unit;78
Apple;Unit;52
Beer;⅓ liter;216
Tuna;100g;130
Cheese;100g;128

分号作为分隔符,每行有一行。这个脚本将读取并打印文件的内容。

use Text::CSV;
say csv(in => "data/calories.csv",  sep => ';', headers => "auto" );

你需要先安装 Text::CSV,但正如你所看到的,这只是一条语句。除了文件名之外,我们还指定了要使用的分隔符(如果不是默认的逗号的话),并且通过自动标题选项,我们让它自动为每一行创建一个哈希,使用标题作为键。例如,第一行将变成。

{Calories => 130, Ingredient => Rice, Unit => 100g}

CSV 是一种格式,与 JSON 和其他数据序列化方法一样,在数据科学中被广泛使用。我们将在下一个配方中再来讨论它。

3.4. 食谱 3-4. 应用一系列的变换并从中提取数据

3.4.1. 问题

你有数据存储在一个数组列表中,你想对该数据应用一个或几个变换,包括处理或过滤,然后提取一个数量。例如,假设想计算一组菜品的总卡路里数,然后排除那些卡路里数超过1000的菜品。

3.4.2. 解决方法

这种操作称为 map/reduce。在 Raku 中,有几个 map 操作符,包括 map 本身,和 reduce 操作符,可以使用超运算符从二元(infix)操作符建立。此外,feed 操作符允许创建一个单一的操作链,可以很容易地被视觉识别。

3.4.3. 它是如何工作的

映射/还原是一种功能操作,即首先将一个列表中的元素映射到另一个列表中(通过任何形式的操作和/或过滤),然后对结果进行操作,给出一个单一的、还原的结果。

也就是说,我们最初有这样的东西。

a1,a2,...,an ==> b1,b2,...,bn ==> c1,c2,...,cm

然后,在不同的 map 阶段后,通过这样的方式换算。

((c1 op c2) op c3).... op cm) ==> X

在 Raku-map 和 grep 中,主要有两个函数做 map 部分。我们之前已经遇到过它们。由于它们会产生另一个列表,所以可以简单地将它们作为一个方法应用到前一个操作的结果上,从而进行链式连接。这在视觉上可能会让人感到困惑,所以 Raku 也使用 feed 操作符 =⇒(也叫火箭)作为这种(链式)map 操作的语法糖。

例如,我们要处理带有卡路里数据的 CSV 文件,并产生一个只包含非奶制品成分的单一 map。然后我们稍后会参考该 map 来计算菜品中的卡路里。你可以这样做。

use Text::CSV;

my %calories = csv(in => "data/calories.csv",  sep => ';', headers => "auto", key => "Ingredient" );

%calories.keys
    ==> map( { %calories{$_}<Ingredient>:delete } )
    ==> grep( { %calories{$_}<Dairy> eq 'No'} )
    ==> my @non-dairy-ingredients;

%calories.keys
    ==> map( { %calories{$_}<Dairy>:delete } );

say %calories{ @non-dairy-ingredients}.map: { parse-measure( $_<Unit> ) };

sub parse-measure ( $description ) {
    $description ~~ / $<unit>=(<:N>*) \s* $<measure>=(\S+) /;
    my $unit = $<unit> // 1;
    return ($unit,$<measure>);
}

在这个例子中,读取 CSV 文件的方式是,我们没有一个哈希数组,而是有一个哈希数列,列用 key 表示。 在这个脚本中,我们有两个 map 链。第一个 map 链工作在卡路里表的键上,卡路里表是一个数组,所以它最终会返回一个数组。它将首先删除 Ingredient 键,我们已经知道它是哈希的键。它其实并不影响输出,但会改变卡路里表的方面。然后用 grep 只选择那些非奶制品的成分。在下一步,我们也从卡路里表中删除 Dairy 键,因为我们知道那些被选中的是非奶制品。

最终,我们产生一个卡路里表中使用的单位和度量单位的列表。我们稍稍改变了之前使用的 regex,它只能够捕捉数字。由于我们用 ⅓ 升来衡量啤酒,所以我们需要一些具有 Unicode 属性 "N" 的东西也来捕捉它。我们稍后将在下一个脚本中使用这个子程序。

这个脚本的目的是作为一个热身,并作为下一个脚本的介绍,它实际上解决了这个问题。它将从文件中读取菜谱成分明细,添加每道菜的热量,只过滤那些热量低于1600卡路里的菜(即每人400卡路里,这是合理的),并添加该数量。这个程序会做到这一切。

use Text::CSV;

csv(in => "data/calories.csv",  sep => ';', headers => "auto", key => "Ingredient" ).pairs
    ==> map( {
       $_.value<Ingredient>:delete;
       $_.value<parsed-measures> = parse-measure( $_.value<Unit> );
       $_ } )
    ==> my %calories;

my @recipes;
for dir("data/recipes/", test => /\.csv$/) -> $r {
    my %data = csv(in => $r.path, headers => "auto", key => "Ingredient").pairs
      ==> map( { $_.value<Ingredient>:delete; $_; } );
    push @recipes: %data;

}

say qq:to/END/;
Your non-caloric recipes add up to
{[+] (@recipes ==> map( { get-calories( $_ ) } ) ==> grep( * < 1600 ) )}
calories
END

sub parse-measure ( $description ) {
    $description ~~ / $<unit>=(<:N>*) \s* $<measure>=(\S+) /;
    my $unit = +$<unit>??+$<unit>!!1;
    return ($unit,~$<measure>);
}

sub get-calories( %recipe ) {
    my $total-calories = 0;
    for %recipe.keys -> $i {
        if %recipe{$i}<Unit> eq %calories{$i}<parsed-measures>[1] {
            $total-calories +=
            %calories{$i}<Calories> * %recipe{$i}<Quantity> / %calories{$i}<parsed-measures>[0]
        }
    }
    $total-calories;
}

这是迄今为止最长的脚本,即使我们去掉解析子,也是如此。但它在概念上很简单:它读取卡路里表并将其放入一个名为 %calories 的关联数组中,读取食谱并将其放入一个名为 @recipes 的数组中,然后用一句话将食谱映射到它们的卡路里,选择那些少于1600卡路里的食谱,并将它们统计成一个单一的数字。

计算卡路里的子也和我们之前见过的子非常相似,所以我们只关注脚本中段的 map/reduce 操作。先说 map 部分。@recipes ==> map( { get-calories( $_ ) } ) 将包含配方成分(和数量)的关联数组映射(或转换)为一个数字列表。这个数字列表将在下一阶段通过 grep 进行过滤:==> grep( * < 1600 ) )。只有几个食谱,我们现在对其名称不感兴趣,有这个数量。这些食谱的卡路里将在 reduce 阶段加入,也就是图链开头的 [+]。这是用大括号包裹起来的,以便在插入输出字符串之前对其进行评估,它使用了 heredocs 语法,以避免一点混乱。qq:to/END/ 是一个引号结构,双 q 保证里面的任何表达式都会被评估,END 表示将在字符串的最后发布的标记。

一般来说,使用 map/reduce 可以为你省去很多嵌套循环,可以让你以函数的方式处理数据。如果你的数据很多,甚至可以使用 hyperrace 进行并行化。所以用这种方式来思考数据流是一个双赢的命题。

3.5. 食谱 3-5. 创建一个随机数据生成器

3.5.1. 问题

为了测试和其他目的,我们需要一个随机数据生成器来生成合适的数据结构。

3.5.2. 解决办法

使用 pick,它是特定于类的,并适用于许多不同的数据结构。

3.5.3. 它是如何工作的

在处理有标签的数据时,有时需要创建可以作为建议的组合,例如用于测试算法或仅仅作为最终结果。这种数据需要具有某种结构;例如,遵循某种正则表达式的字符串或一组项目,每一个项目都具有某种质量。

例如,你可能需要为你的菜谱随机生成菜品。大多数菜品都会有一个主料(比如说,米饭)和一个额外的副料(比如说,鹰嘴豆)。什么?鹰嘴豆和米饭是一道美味的地中海菜肴,和加勒比海各地的豆子和米饭一样。在餐桌上的所有食材中,我们可以将它们混合搭配,生成新的菜肴。让我们把食材表改成这样。

Ingredient;Unit;Calories;Dairy;Vegan;Main;Side
Rice;100g;130;No;Yes;Yes;Yes
Chickpeas;100g;364;No;Yes;Yes;Yes
...

我们在保存数据的 CSV 中添加了两列: 主列和副列表示该原料是否可以作为主原料使用,或者添加到主原料中,以生成一个完整的菜品。

现在你要做的是生成一个有主料和副料的菜。这个脚本可以帮助你完成这个任务。

use Raku::Recipes;

my %calories-table = calories-table;

my @main-course = %calories-table.keys.grep: { %calories-table{$_}<Main> eq 'Yes' };
my @side-dish = %calories-table.keys.grep: { %calories-table{$_}<Side> eq 'Yes' };

say "Your recipe ⇒ ", @main-course.pick, " with ", @side-dish.pick, " on the side";

我们创建了 Raku::Recipes,用于我们在几个食谱中不断使用的所有实用例程;在这种情况下,我们将使用 calories-table(我们之前已经使用过几次),这个例程读取 CSV,解析每个成分的措施描述,并将所有内容放入哈希中。

注意:这个模块在本书的 Github 网站和 apress 网站上都有,用 Git 下载后(git pull JJ/raku-recipes-apress),或者从 apress 提供的 Url 下载后,改到下载的目录下,写上 zef install .

我们只用食材的名称:作为主菜的就放到 @main-course,副菜就放到 @side-dish。我们在表的键上使用一个过滤器,只选择那些相应字段(主菜或副菜)标记为 Yes 的键(食材)。

随机生成,之后就直接了当了:我们在两个数组上使用 pick,从每个数组中随机选择一个成分。这样就会打印出这样的结果。

Your recipe ⇒ Pasta with Chickpeas on the side

这都是好的,但面食在这两个阵列中。一盘面食配上面条,就有点不伦不类了。接下来我们就尽量避免这种情况吧。

given (@main-course X @side-dish).grep( { @_[0] ne @_[1] } ).pick {
    say "Your recipe → @_[0] with ", lc( @_[1] ), " on the side"
}

这两个数组的定义方式完全相同。然而,我们使用 grep 过滤器只得到具有不同配料的配料对。@main-course X @side-dish 将创建一个构成菜品的配料对的列表,grep检查第一种和第二种配料是否不同,所以得到的列表将只有合适的配料对。通过使用 given,我们将这对食材放入话题变量 @_ 中。最后,我们在生成的(保证的)配料对数组中进行挑选;我们使用 lc 将第二个配料对小写,以避免在句子中间出现大写字母。

我们使用 given 是因为它是一个 topicalizing 语句,也就是说,它把它的参数放到一个合适的主题变量中,$_、%@,取决于它的类型。一般来说,given 的使用方式将与 switch 在其他语言中的使用方式相同:它将对主题进行检查,并在有匹配的情况下运行不同的代码块。然而,在这种情况下,它将简单地运行该代码块,而不进行任何进一步的检查。另外,这个主题变量是一个数组,所以这个区块会有 @_ 定义了两个成分。这个变量是用来直接打印菜品的。

此外,你还可以使用本章前面学到的加载模子技术。可以为此使用一个额外的带有偏好的列,你必须将配料存储在 Mix 中,而不是数组中,并使用 roll。原理是完全一样的。有了这些不同的选择,可以看到"有许多方法可以做"的原则在行动,这一点贯穿了 Raku 中的每一个设计决策。

3.6. 食谱 3-6. 处理大的、结构化的文件

3.6.1. 问题

大文件中包含的信息需要有效处理,可能会有一些内存限制。

3.6.2. 解决办法

你可以在文件句柄上使用 .line,这将会从文件的行中创建一个惰性序列,或者在文件不容易分成行的情况下,使用 .Supply 对其进行分块读取。

3.6.3. 它是如何工作的

目前的电脑有很好的内存。但自然规律是,你的电脑需要处理的文件大小总是会增长到两倍的大小 的可用内存。因此,尽管在大多数情况下,在内存中吞吐一整个文件就可以了,但在某些情况下,这可能太慢了,或者在可用内存的情况下根本不可能。例如,开源的电子表格 LibreOffice Calc 在试图读取一个几百兆的文件时就会窒息。Raku 能够跟上它吗?剧透一下:是的,它会。

我们先来谈谈惰性的概念。惰性的数据结构简单来说就是一个只有在请求时才会计算其元素。一个使用函数生成的惰性序列,只有当它被请求时,才会重构它的元素号 n。同时,它将处于空白状态,但更重要的是,它不会在内存中,吞噬空间,计算它所需的资源将可供操作系统的其他部分使用。

在 Raku 中负责输入/输出的 IO::Handle 对象相当强大,其中,它们可以变成惰性的数据结构,只在需要时才返回文本文件的行。

例如,你需要将美国农业部公布的每个产品的营养素数据库加载为一个179MB的CSV文件。因此,原则上,你可以使用 Text::CSV 来处理它。但是,这将会吞噬掉 179MB 以上的数据,而且,更糟糕的是,你需要很长时间才能在控制台中看到结果。

(在这种情况下,你可以使用一个面向行的 API,但你已经在另一个食谱中看到了如何使用它,所以让我们尝试一个不同的方法,没有依赖性。) 让我们这样使用 IO::Handle.line

.say for "/home/jmerelo/Documentos/Nutrients.csv".IO.lines.grep: {
    my @data = $_.split('","');
    $_ if @data[2] eq "Protein" and @data[4] > 70 and @data[5] ~~ /^g/
}

这个脚本将只打印那些蛋白质含量超过70克的产品(一个任意选择的数字)。这个脚本将立即开始打印控制台中的行,就像这样。

"45332602","203","Protein","LCCS","70.59","g"
"45333759","203","Protein","LCCS","77.42","g"
"45333760","203","Protein","LCCS","72.73","g"
...

在我的台式电脑上,处理整个文件的时间不会很长-28秒。更重要的是,进程监视器显示,程序使用的内存从来没有超过大约 100MB 的常驻内存,大大小于文件大小。

立即获得这些结果的好处是,例如,我们可以创建一个供应,并将它们发射到该供应。一个 tap 将拾取这些行,例如,异步地查找产品代码(它在另一个文件中被交叉引用)。

如果文件是以其他方式组织的,例如,作为文本或 JSON 文件,也可以使用供应。IO::Handle.Supply 将从中读取特定大小的块,然后以字符串的形式发出。反正文本文件是可以按行处理的,而且,如前面的配方所示,这种分块处理是一种最好留给二进制文件的技术。反正知道有不止一种方法就好。

4. 数学

编程语言是数学的后代,但它们有不同的能力将数学语言和表达式翻译成代码。通过使用它们的 Unicode 字形来实现运算符,你会发现 Raku 代码比其他语言更接近数学。根据其函数性质,Raku 函数也可以作为纯函数(应用,用数学术语来说)工作,因此在 Raku 代码中可以更清晰地看到数据流。

在本章中,我们将通过几个处理数学对象的配方,合理地应用数学运算。

4.1. 食谱 4-1. 生成数学序列并从中提取随机元素

4.1.1. 问题

一个数学序列有一个初始值和一个生成器,生成器从之前的值计算出序列中的下一个项。你需要一种直接的方法来处理这些潜在的无限数据结构,并从中提取任意元素。

4.1.2. 解决方法

将 Seq 与序列运算符一起使用。此外,你还可以使用生态系统中 Math::Sequences 模块中的现成序列。

4.1.3. 如何工作

Raku 包括内置的(或核心)数据结构 Seq。Seq 数据结构用来表示一个懒惰的序列,它可以表示无限的序列,并计算每个项的需求。它还更进一步,因为它可以从第一项中推断出序列的其余部分,尤其是简单的几何或算术进展。

你可能听说过关于国际象棋发明者的故事。国王要求他为这样一个伟大的游戏报出价格。他想用麦粒来支付,在棋盘上的第一个格子里放一粒,而在每一个连续的格子里放双倍数量的麦粒。"成交。"国王回答。不过,如果他手头有 Raku,他可以打出下面的内容,马上就能意识到这个数字有多大。

say [+] (1,2,4...*)[^64]

(1,2,4…​*) 是实际的 Seq。我们需要至少打出前三个元素,这样 Raku 就能知道足以将其标记为一个算术进阶,然后可以计算其余的元素。…​ 是序列运算符,这是一个智能运算符,能够生成任何类型元素的序列,包括这些无限序列。方括号取一个片断,从第1个元素到第64个元素(排除),最后我们用超前和来求和一切。结果,正如预期的那样,是 18446744073709551615。顺便说一下,你也可以在 Raku 中用以下方法计算。

say 2⁶⁴ -1;

注: 国王被逗乐了或者不被逗乐了,这取决于你问谁,要么笑得很开心,要么剪了那哥们儿的脖子上的头发。

序列也可以从应用于前项的操作中递归计算。例如,后面的那个,将序列中前面的两个元素结合起来,并取模9。

sub digits( $_1, $_2 ) {
    return $_1, $_2, { ($^a ~ $^b) % 9 } ... *;
}

for 1..5 X 1..5 -> @_ {
    say digits( | @_ )[^10];
}

$^a$^b 是占位变量,将按字母顺序取上一个和下一个变量的值。它们所在的块将计算前两个变量中的第n个元素。我们没有使用单一的数字对开始,而是创建了一个返回 Seq 的 sub。该子在接下来创建的循环中被调用。

它创建了一个由25对数字组成的数组,并将它们扁平化后交给子。|将从一个数组中创建两个参数。我们最终会打印每个序列的前十个元素,得到类似这样的结果。

(1 1 2 3 5 8 4 3 7 1)
(1 2 3 5 8 4 3 7 1 8)
(1 3 4 7 2 0 2 2 4 6)

在很多情况下,使用 Seq 可以省去你在构建复杂的循环或递归函数时的大量工作,而且它在 Raku 中的语法将更容易理解。

4.2. 食谱 4-2. 编程一个分而治之的算法

4.2.1. 问题

你需要解决一个数学问题,把它分成可以解决的小问题。

4.2.2. 解题方法

使用递归,使基本情况,也就是最小的情况得到解决,你可以从那里建立起来。

4.2.3. 它是如何工作的

分而治之是一种在许多问题领域中使用的技术,它将困难的问题转化为可以相对容易解决的问题。一个经典的例子是排序。对一个长的列表进行排序是通过使用枢轴来解决的。它包括将所有小于一定大小的元素放在一个列表中,而将大于一定大小的元素放在另一个列表中。因此,你把排序问题分成了两个问题,即对那两个较小的列表进行排序的问题。这种算法叫做 quicksort,它在时间和内存上都非常高效。

同样类型的问题在烹饪中也会出现。我们如何才能在不超过一定卡路里的情况下,烹饪出蛋白质含量最高的一餐?或者烹饪出一份纤维含量最高,又不超过一定量的蛋白质?

注意你可能已经注意到,这是一个背包问题的例子。

让我们利用卡路里表,找到一个与表中用量相同的,能优化蛋白质含量的食材组合。这次我们不需要关注量,也不需要关注如何创造一餐好饭的规则,就能做到这一点。这个程序会做到这一点。

use Raku::Recipes;
# We're using this code from Raku::Recipes:
sub calories-table( $dir = "." ) is export {
    csv(in => "$dir/data/calories.csv",  sep => ';', headers => "auto",
    key => "Ingredient" ).pairs
    ==> map( {
       $_.value<Ingredient>:delete;
       $_.value<parsed-measures> = parse-measure( $_.value<Unit> );
       $_ } );
}

my %calories-table = calories-table;
multi sub recipes( -1, $ ) { return [] };
multi sub recipes( $index,
                   $weight  where
                   %calories-table{@products[$index]}<Calories> > $weight ) {
                       return recipes( $index - 1, $weight );
}

multi sub recipes( $index, $weight ) {
    my $lhs = proteins(recipes( $index - 1, $weight ));
    my @recipes = recipes( $index - 1,
                           $weight -  %calories-table{
                           @products[$index]}<Calories> );

    my $rhs = %calories-table{@products[$index]}<Protein> +  proteins( @recipes );
    if $rhs > $lhs {
        return @recipes.append: @products[$index];
    } else {
        return @recipes;
    }
}

my $max-calories = 1000;
my @products = %calories-table.keys;
my @ingredients = recipes( @products.elems -1 , $max-calories );
say @ingredients, " with ", proteins( @ingredients ), "g protein";

sub proteins( @items ) {
    return [+] %calories-table{@items}.map: *<Protein>;
}

这个程序使用了我们之前使用过的帮助模块来加载配料表,它在前两行就完成了。我们还是把例程 calories-table 包含在内,以供参考。你可能还记得,这是在上一章的配方中使用的代码,它对数据集应用了一系列的变换。此外,这个例程还使用了 parse-measure,也在那一章中讨论过。

这是一个分而治之的算法,所以我们必须从最大的问题开始,解决较小的问题。这就是我们在最后几行所做的事情,它设置了算法,建立了卡路里数和产品数组(简单来说就是卡路里表的键,其中包含产品的名称),并调用了食谱子程序。

multi sub recipes( -1, $ ) { return [] };
multi sub recipes( $index,
                   $weight  where
                   %calories-table{@products[$index]}<Calories> > $weight ) {
    return recipes( $index - 1, $weight );
}

multi sub recipes( $index, $weight ) {
    my $lhs = proteins(recipes( $index - 1, $weight ));
    my @recipes = recipes( $index - 1,
                           $weight -  %calories-table{
                           @products[$index]}<Calories> );
    my $rhs = %calories-table{@products[$index]}<Protein> +  proteins( @recipes );
    if $rhs > $lhs {
        return @recipes.append: @products[$index];
    } else {
        return @recipes;
    }
}

my $max-calories = 1000;
my @products = %calories-table.keys;
my @ingredients = recipes( @products.elems -1 , $max-calories );

这个子程序是所有乐趣所在。我们用 Raku 的 multi 来处理我们的不同选择。

  • 如果 index 变成负数,我们就没有产品了。它只是返回一个空的成分数组。这将是基本情况。另外,其实权重是什么并不重要,所以我们用虚变量$来表示权重。

  • 用一个非负的索引,但是当数组中那个位置的产品的卡路里比我们想要的多时,我们就跳过一个,往下走一步,"消除"这个产品(简单的跳过)。在这种情况下不会出现这种情况,除非我们把卡路里削减到400卡路里,而不把辣条剔除。

  • 接下来的多是真正的主力军。我们比较两件事:不使用当前产品的食谱中的蛋白质,以及不使用当前产品计算出的最佳蛋白质的产品。这就进入了 @recipes 数组。如果这个产品中的蛋白质含量比没有这个产品的好,我们就选择当前产品,将其追加到列表中。如果没有,我们就简单地返回没有它的产品列表。

你可以玩弄总的卡路里数,得到不同的高蛋白组合。你会发现,每次运行它,你都会获得一组不同的成分。类似这样的。

[Kale Tomato Olive Oil Kidney beans Lentils Chicken breast Rice] with 53.6g protein [Lentils Egg Tuna] with 43.9g protein

这有几个原因。首先,这种分而治之的方法是一种贪婪的算法:产品在数组中的排序顺序会有影响,因为当循环算法到达产品时,会根据当前的卡路里数来放弃或添加。其次,这个数组只是一个哈希表中的键列表,为什么是随机的呢?哈希表中的元素列表是以随机顺序返回的,为了防御拒绝服务攻击,也保证了这种方式的发生。

我们需要多次运行该算法,以获得热量含量最大的组合。鸡肉、扁豆和豆子炖肉,有人愿意吗?

4.3. 食谱 4-3. 使用矩阵

4.3.1. 问题

从图像处理到机器学习,矩阵在各种问题上的应用最多。处理事先已知尺寸的数据结构相当方便。

4.3.2. 解决方法

Raku 对二维矩阵的数据支持有限,但有一些运算符。使用生态系统模块 Math::Matrix 来处理矩阵。

4.3.3. 它是如何工作的

Math::Matrix 是一个生态系统模块,所以你需要先下载它。它在 GitHub 仓库里有很好的文档,https://github.com/pierre-vigier/Perl6-Math-Matrix。它使你能够使用矩阵,也就是简单的二维数组或表格。

当你想对一系列的数量进行计算时,矩阵运算是很好的,它们可以用在我们的菜谱计算环境中。例如,我们可能有一张表,上面有不同配方中不同原料的数量,还有一张表,上面有几个用户实际消耗的一道菜的百分比。假设我们想计算不同食材被消耗的数量,这样我们就可以,计算出卡路里或蛋白质的数量。

下表列出了三种不同食材—​大米、鹰嘴豆和西红柿在三种不同食谱中的用量,以克为单位。

Recipe 1

Recipe 2

Recipe 3

Rice

150

50

50

Chickpeas

50

150

50

Tomatoes

100

150

100

现在我们有三个不同的人,因为挑食或者干脆吃饱了,只吃了三个食谱中的一部分。

Person 1

Person 2

Person 3

Recipe 1

0.5

0.8

0.3

Recipe 2

0.9

1

1

Recipe 3

0.2

0.8

0.7

我们需要知道每个人消耗了多少克的每种原料。这个程序将计算出:

use Math::Matrix;

# Recipe x ingredient
# Columns = Recipes
# Rows = Amount of rice, chickpeas and tomato
my $food-matrix = Math::Matrix.new( [[ 150, 50, 50 ],
				                     [ 50, 150, 50 ],
				                     [ 100, 150, 100 ]] );

# Columns = People
# Rows = percentage of dish eaten
my $person-recipes = Math::Matrix.new( [[ 0.5, 0.8, 0.3 ],
				                        [ 0.9, 1, 1 ],
				                        [ 0.2, 0.8, 0.7 ]] );

say $food-matrix dot $person-recipes;

这将打印以下内容:

130 210 130 170 230 200 205 310 250

与数学中的许多问题一样,一旦你有了正确的表示方法,就只是选择正确的运算符的问题。在这种情况下,点积(恰当地称为点)将行乘以等量列,然后将结果相加。良多人1消耗了130克大米,而人3消耗了310克鹰嘴豆。这几乎是1000卡路里的热量。

该模块包含了大量的运算,包括分解和大多数矩阵运算,你可以用于任何这一点是必不可少的,比如神经网络。而且它使用了预期的运算符。

use Math::Matrix;
my $first = Math::Matrix.new( [[1,2],[3,4]] );
my $second = $first * 2;
say $second + $first;

第二个数组是第一个数组的两倍;也就是说,每个元素都要乘以二。然后,我们将第二个数组与第一个数组相加,得出总和,在每一种情况下都使用通常的算术运算符。这种表现力,以及用新的操作(在这种情况下是矩阵)来重载各种运算符的能力,是使 Raku 有用和强大的两点。

4.4. 食谱 4-4. 计算 Mandelbrot 集

4.4.1. 问题

为了好玩,你需要计算 Mandelbrot 集。

4.4.2. 解决办法

Mandelbrot 集是一组数字,当数字被迭代时,函数不会改变(即它们保持绝对值的边界)。当你将迭代次数(通常称为逸出时间)后的值映射为颜色时,它们会产生视觉上令人惊叹的图形。基本上,你可以在 Raku 中使用复杂的数字来对这些集合进行编程。Julia 集和 Fatou 集是两个为特定函数定义的互补集,通常是二次多项式。它们由复数平面元素组成,其序列值由一定的数字限定。

4.4.3. 它是如何工作的

对于 Mandelbrot 集和 Julia 集,就是要创建一个递归定义的序列,并在一定次数的迭代后计算其值。我们将计算出 Mandelbrot 集,并将 Julia 集作为一个练习。

这个脚本将计算 Mandelbrot 集合的一部分,并使用填充方块将其打印到控制台。

use Array::Shaped::Console;

sub mandelbrot( Complex $c --> Seq ) {
    0, *²+$c ... *.abs > 2;
}

my $min-x = -40;
my $max-x = 40;
my $min-y = -60;
my $max-y = 20;
my $scale = 1/40;
my $limit = 100;
my @mandelbrot[$max-y - $min-y + 1; $max-x - $min-x + 1];
for $min-y..$max-y X $min-x..$max-x -> ( $re, $im ) {
    my $mandel-seq := mandelbrot( Complex.new( $re*$scale, $im*$scale) );
    @mandelbrot[$re-$min-y;$im-$min-x] = $mandel-seq[$limit].defined??
          ∞ !! $mandel-seq.elems;
}
say printed(@mandelbrot);

看上去有点长,但事实上,它的要点只有不到半打的行文。首先,我们来看一下 Mandelbrot 序列本身。

0, *²+$c ... *.abs > 2;

为了确定某个复数 $c 是否属于 Mandelbrot 集合,我们从0开始计算一个序列,并通过平方计算之后的每一个数字,然后将 $c 相加。如果序列中的数值没有达到无穷大,也就是说,如果序列永远持续下去,那么这个数将属于 Mandelbrot 集合。我们从启发式的角度也知道,如果在某一点上,序列中的数的绝对值大于2,那么它最终将走向无穷大,从而不属于 Mandelbrot 集。所以,如果 $c 属于 Mandelbrot 集,这个序列将是一个无限(但懒惰)序列,如果不属于 Mandelbrot 集,它将是有限的。

我们用数字创建一个(粗)网格。它的边界是x轴的-40,40和y轴的-60,20,我们选择了这两个范围,这样就能真正显示出熟悉的 Mandelbrot 集的画面。我们用100作为极限:如果在100次迭代内,它还没有停止,那很可能它永远不会停止(当然,我们可能是错的,但如果你的资源有限,而且没有办法证明关于每一个复数的定理,那就是你需要做的)。另外,我们把那个网格缩小,以便更好地观察集合,我们用1/40作为这个比例。网格实际上会从x轴的-1,1,y轴的-0.5到1.5。这个序列是为每一个数字生成的,然后我们检查序列中的元素100(第一个0之后)会发生什么。它存在,所以它属于 Mandelbrot 集合。让我们给它分配一个 ,因为它将走向无穷大。如果它不存在,让我们记下逃逸时间,这将是我们所表示的。

我们将其存储在一个异形数组中,这是 Raku 的一个精巧的功能。它们是数组,而不是一维(本质上是向量),而是不同维度的数组。由于我们正在计算一个网格的元素,并为每一个元素获取一个值,所以我们将它们存储在一个二维数组中,并根据我们将在每个维度中的点的数量调整维度。我们使用分号 ; 来分隔每个维度的索引: @mandelbrot[$re-$min-y;$im-$min-x]。它能记住自己的形状这一事实将在后面使用。

这将被处理到打印例程,它属于 Array::Shaped::Console 模块。这个例程使用符号来表示值,并根据数组的形状和可用值的范围自动调整自己。它最终会打印出类似这样的结果。

□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□▢■▢□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□▢■▢□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▢■▢▢□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▣▢□▢▤■▤▢□▢▣▢□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▣▢▣■▣▢▣▢□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▥■■■■■▥▢□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□▣▤■■■■■▤▣□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▣■■■■■▣▢□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□▢□□□□□▢▢▢▢▢▢■■■■■▢▢▢▢▢▢□□□□□▢□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□▢▢▢▢□□□□□▣▢▢▣▤▥■■■▥▤▣▢▢▣□□□□□▢▢▢▢□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▢□▢▢▢▥▦▥■■■■■■■■■▥▦▥▢▢▢□▢▢□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▢▢▢▢▢▣■■■■■■■■■■■■■▣▢▢▢▢▢▢□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□▣▢▤▦▣■■■■■■■■■■■■■■■▣▦▤▢▣□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▢■■■■■■■■■■■■■■■■■■■■■■■▢▢□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▤■■■■■■■■■■■■■■■■■■■■■▤▢□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▢▣■■■■■■■■■■■■■■■■■■■▣▢▢□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□□▤▥■■■■■■■■■■■■■■■■■■■▥▤□□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□▢▢▢▢■■■■■■■■■■■■■■■■■■■■■▢▢▢▢□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□▢▢▢▢▣■■■■■■■■■■■■■■■■■■■■■▣▢▢▢▢□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▤■■■■■■■■■■■■■■■■■■■■■■■▤▢□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▤■■■■■■■■■■■■■■■■■■■■■▤▢□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▢■■■■■■■■■■■■■■■■■■■■■▢▢□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▦■■■■■■■■■■■■■■■■■■■■■▦▢□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▣■■■■■■■■■■■■■■■■■■■▣▢□□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▣■■■■■■■■■■■■■■■■■■■▣▢□□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▣■■■■■■■■■■■■■■■■■▣▢□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▣■■■■■■■■■■■■■■■▣▢□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▢▧■■■■■■■■■■■■■▧▢▢□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▢▢▢▢▧■■■■■■■■■▧▢▢▢▢▢□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□□▢▢▢▢▢▣▣▤▥▧■■■▧▥▤▣▣▢▢▢▢▢□□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□▤▢▢■■▣▥■■■■■■■■■■■■■■■▥▣■■▢▢▤□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□▢▣□□▣■▢▣■■■■■■■■■■■■■■■■■■■■■■■▣▢■▣□□▣▢□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□▢▤▢▢▢■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▢▢▢▤▢□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□▢▤■▨▢■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▢▨■▤▢□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□▢▢▦■▤■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▤■▦▢▢□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□▢▢▢▢□□□▢▢▣▧■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▧▣▢▢□□□▢▢▢▢□□□□□□□□□□□□□
□□□□□□□□□□□□□□▤▣▢▧▤▢▥■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▥▢▤▧▢▣▤□□□□□□□□□□□□□□
□□□□□□□□□□□□□▢▦▧▥▦■▢▧■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▧▢■▦▥▧▦▢□□□□□□□□□□□□□
□□□□□□□□□□□□▣▣▢▦■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▦▢▣▣□□□□□□□□□□□□
□□□□□□□□□□□□□□▢▢■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▢▢□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□▨▤■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▤▨□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□▢▢▣■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▣▢▢□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□▢▤■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▤▢□□□□□□□□□□□□□□□
□□□□□□□□□□□□□▢▨▢▣■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▣▢▨▢□□□□□□□□□□□□□
□□□□□□□□□□□□□▣▤■▤■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▤■▤▣□□□□□□□□□□□□□
□□□□□□□□□□□□□▢▢■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▢▢□□□□□□□□□□□□□
□□□□□□□□□□□□□□▢■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▢□□□□□□□□□□□□□□
□□□□□□□□□□□□□▢■▧■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▧■▢□□□□□□□□□□□□□
□□□□□□▢▢□□▢□□▢▣■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▣▢□□▢□□▢▢□□□□□□
□□□□□▢▥▢▢■▣▢▢▢■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▢▢▢▣■▢▢▥▢□□□□□
□□□□□▢▢▣▦▤■▧■▢▨■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▨▢■▧■▤▦▣▢▢□□□□□
□□□□□□▢▣■■■■■■▧■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▧■■■■■■▣▢□□□□□□
□□□□▣▢▣■■■■■■■▩■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▩■■■■■■■▣▢▣□□□□
□□□□▤▣▣■■■■■■■▨■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▨■■■■■■■▣▣▤□□□□
▢▢▢▢▣■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▣▢▢▢▢
□▢▣▤▣■■■■■■■■■▨■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▨■■■■■■■■■▣▤▣▢□
□▣▢□▢▣▢■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▢▣▢□▢▣□
□▢□□□▢▢▨■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▨▢▢□□□▢□
▢□□□□□□▢■▣▥▧▣▢■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▢▣▧▥▣■▢□□□□□□▢
▥□□□□□▢▣▢▢▤▢▢▢▣■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▣▢▢▢▤▢▢▣▢□□□□□▥
□□□□□□▢▢□□□□□▢▥■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▥▢□□□□□▢▢□□□□□□
□□□□□□□□□□□□□▩▤■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▤▩□□□□□□□□□□□□□
□□□□□□□□□□□□□□▧▦■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▦▧□□□□□□□□□□□□□□
□□□□□□□□□□□□□□▢▧■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▧▢□□□□□□□□□□□□□□
□□□□□□□□□□□□□▢▣■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▣▢□□□□□□□□□□□□□
□□□□□□□□□□□□□□▥▣▢■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▢▣▥□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□▢▢■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▢▢□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□▢▢■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▢▢□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□▢▢■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▢▢□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□▢▢▤■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▤▢▢□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□▢▥■■■■▨■■■■■■■■■■■■■■■■■■▢■■■■■■■■■■■■■■■■■■▨■■■■▥▢□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□▢▥■■■▢▤■■■■■■■■■■■■■■■■■▢■■■■■■■■■■■■■■■■■▤▢■■■▥▢□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□▢■▥■▣▢▢▤■■■■■■■■■■■■■■■□□□■■■■■■■■■■■■■■■▤▢▢▣■▥■▢□□□□□□□□□□□□□□□□
□□□□□□□□□□□□▢□▢▢▢▣▣▣▢□▢▥▢▣■■■■■■■■■■■▩□□□□□▩■■■■■■■■■■■▣▢▥▢□▢▣▣▣▢▢▢□▢□□□□□□□□□□□□
□□□□□□□□□□□□□▣□□▢▣□□□□□□□▢■■▣▤■■■■■▢▣□□□□□□□▣▢■■■■■▤▣■■▢□□□□□□□▣▢□□▣□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□▢□□□□□□□□▥■■▢▢▢■▤▢▣▢□□□□□□□□□▢▣▢▤■▢▢▢■■▥□□□□□□□□▢□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□▢▢▢□□□▢▣□□□□□□□□□□□□□□□▣▢□□□▢▢▢□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□▢▢▢▢□□□□□□□□□□□□□□□□□□□□□□□□□▢▢▢▢□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□
□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□

这张图有着大家熟悉的、心形的 Mandelbrot 集合。在这种情况下,黑色的方块将是属于它的元素,白色的方块显示了那些逃逸时间很短的元素,小于10。

无论如何,这表明使用 Raku 进行序列计算是多么容易,即使是复杂的数字。计算的关键部分是在单行中定义的序列。其余的部分主要用于视觉上的渲染。使用更适合数学的数据结构,比如异形数组,也让程序员的生活更轻松。

4.5. 食谱 4-5. 充分利用整数的无限精确性

4.5.1. 问题

你需要检查整个整数集的一些属性,这意味着要在无限集上工作,并且要处理可能具有无限精度的数字。

4.5.2. 解决方法

只需在 Raku 中使用普通的 Ints,默认情况下,Ints 具有任意精度。你也可以使用无限序列或它们的组合,因此你可以对无限序列应用任何类型的操作,并且只在需要时才产生结果。

4.5.3. 它是如何工作的

让我们从计算相邻质数开始;这些质数被两个分开。它们也被称为孪生质数,它们合在一起被称为孪生质数对。事实证明,孪生质数对的数量是无限的,这就是为什么我们需要无限的精度来计算它们。随着我们位数的增加,显然要花很长的时间来处理它们。但是由于 Raku 可以使用惰性序列,我们可以使用这个简短的脚本来计算连续数。

my Int @primes = (1,2,3…∞).grep: *.is-prime;

my $prev = 0;
my @contiguous = lazy gather {
    for @primes -> $prime {
	take [$prime, $prev] if ($prime - $prev) == 2;
	$prev=$prime;
    }
}

say @contiguous[300..310];

其中最重要的部分是在第一行中定义一个(可能的)所有质数的列表。这个序列将包含一个所有可能的质数的生成器,并将根据需求计算它们。这就是我们在下一个循环中要做的事情。同样,这其中最重要的部分是我们需要懒惰地处理懒惰序列。gather 语句会在循环内拾取所有由 take 发送的数据,但使其变得懒惰,会使产生的数据结构 @contiguous 变得懒惰,从而不会在停止之前超过(无限)for 循环。如果我们要计算第300个到310个连续的质数,就不会走到无穷大再回来,而是在计算完第310对连续的质数后才停止。对了,是 [17791 17789]。另外,在我的笔记本上,这需要大约两秒钟的时间。计算第3000对,也就是 [300499 300497] 和第10000对,也就是 [1260991 1260989],需要很长时间(在这种情况下,大约5分钟)。我们可以继续使用任何序列,不需要指定大整数什么的,只要我们准备好等待就可以了。但很明显,这些数字与这里和那里使用的其他整数是差不多的,唯一的好处是不需要为它们使用一些特殊的数据结构。

让我们试着用真正的大数来工作。我们只需要在其他地方开始序列。例如,在这里。

my Int @primes = (264...∞).grep: *.is-prime;

在这种情况下,第一个质数是 18446744073709551629。如果我们想知道前三对,前一个程序将打印如下。

([18446744073709552423 18446744073709552421]
[18446744073709554151 18446744073709554149]
[18446744073709558603 18446744073709558601])

这发生在不到 1/20 秒的时间里。显然,有很多连续的质数。这些数确实有很多位数,显示出我们需要的任意精度。

没有那么多的和睦数,也就是那些被除数(不包括它本身和一)加起来后,产生第二个数的数对。在这种情况下,它涉及到轻松地索引一个数字的除数列表,以便它们可以被相加并与其他数字进行比较。这是 Perl 每周挑战中的一个挑战,Laurent Rosenfeld 提出了这个解决方案(稍作修改,因为最初它只返回第一对)。

sub sum-divisors (Int $num) {
    my @divisors = grep { $num %% $_ }, 2..($num / 2).Int;
    return [+] 1, | @divisors;
}

for 2..Inf -> $i {
    my $sum_div = sum-divisors $i;
    if $sum_div > $i and $i == sum-divisors $sum_div {
        say "$i and $sum_div are amicable numbers";
    }
}

同样,它使用了一个懒惰,所以可以处理整个整数集;但是,需要使用 Control+C 来停止它,因为它一找到这些数字就会继续打印友好的数字。另外,它并没有存储 sum-divisors 的结果,所以当 $sum_div 再次达到 $i 的值时,就会重新计算一遍。让我们做两个小改动来处理这些问题。

use experimental :cached;

sub sum-divisors (Int $num) is cached {
    my @divisors = grep { $num %% $_ }, 2..($num / 2).Int;
    return [+] 1, | @divisors;
}

my @amicable = lazy gather {
    for 2..Inf -> $i {
	my $sum_div = sum-divisors $i;
	take [$i, $sum_div] if $sum_div > $i and $i == sum-divisors $sum_div;
    }
}

say @amicable[^3];

(还在实验中)缓存功能存储了一个带有 is cached 特征的例程的结果。有了这个功能,我们就会有一个数字的除数的值,如果以前见过,就会有这个值,这样可以节省不少时间。然后我们将循环的结果分配给一个懒惰序列,这样我们就可以根据需求计算第n个元素。我们直接获得了三对经典已知的和睦数,在六秒钟内,我们计算出了前四对。

([220 284] [1184 1210] [2620 2924] [5020 5564])

5. 配置和执行程序

到目前为止,我们一直在与小型脚本和模块合作,一般来说,这些脚本和模块已经具备了完成工作所需的一切。然而,大多数真正的程序需要用户提供一些信息才能正常运行,即使它们使用默认值工作。这些信息将以环境信息、命令行标志或某种标准格式的配置文件的形式出现。在本章中,我们将看到这些在 Raku 中是如何工作的。

5.1. 食谱 5-1. 使用 JSON/YAML/.ini 文件配置程序

5.1.1. 问题

你需要运行一个程序,在设计程序的时候,一系列的值是不知道的,或者说对于不同的实例来说,这些值根本就是不同的。

5.1.2. 解决办法

现在,JSON 可能是最广泛使用的配置格式,以及数据结构的序列化。你可以使用生态系统中的模块 JSON::Fast(可从 https://modules.raku.org/dist/JSON::Fast:cpan:TIMOTIMO 获取,一样平常使用 zef),将存储在 JSON 格式的数据转换为相应的 Raku 数据结构。

YAML、.ini 以及 TOML 等格式也比较流行,它们都可以被生态系统中的模块解析。选择你觉得最舒服的格式或者最流行的格式。

5.1.3. 它是如何工作的

程序有许多不同的方法来接收变量的值,如文件名、端口值、或任何其他它们可能需要的字符串或数字。在 MAIN 子上使用位置参数和命名参数是一种方法 (我们将在下一步看到如何做), 但配置文件的优点是可读、可编辑, 而且可以放在源控制文件下 (如果它们包含敏感信息, 则可以加密)。无论如何,留下一些值让用户决定是有意义的,因此配置文件在这种事情上很方便。

让我们重写一个我们以前用过的程序,就是计算某个卡路里的最大蛋白质量的程序。我们将使用三个配置项:存放卡路里计数的文件,最大卡路里计数,以及重复次数,以得到n的最佳值,由于结果将取决于乘积矩阵中乘积的顺序,因此使用多次迭代将帮助我们获得一个更好的值。

下面是 JSON 配置文件。

{
    "calories" : 1000,
    "repetitions" : 3,
    "dir" : "."
}

我们对变量使用不形象的名称,并将其存储在 JSON 哈希表中,变量为键值对。程序如下。

use Raku::Recipes;
use JSON::Fast;

my %conf = from-json(  slurp(@*ARGS[0] // "config.json" ) );
%calories-table = calories-table( %conf<dir> );
@products = %calories-table.keys;

my $max-calories = %conf<calories>;

my @results = gather for ^%conf<repetitions> {
    @products = @products.pick(*);
    my @ingredients = optimal-ingredients( @products.end , $max-calories );
    my $proteins = proteins( @ingredients );
    say @ingredients, " with $proteins g protein";
    take @ingredients => $proteins;
}
say "Best ", @results.Hash.maxpairs;

首先,我们将用于最大化蛋白质最优成分的例程重新命名,并将其放在 Raku::Recipes 模块中,该模块包含了其他不同的子例程,我们从以前的食谱中重用。这个例程将在同一个 %calories-table 变量中使用卡路里表;这个和 @products 将是具有模块范围的变量,但它们在这个主程序中得到的值。这只是附带的,这个配方的主旨是使用 JSON 配置。

从这个意义上说,主要的操作是在 %conf 变量中。该变量将是从一个文件中读取的哈希值,该文件将作为命令行中的第一个参数(@*ARGS[0])被接收,或者在自已同一目录中具有默认值 config.json。该哈希值用于加载卡路里表(使用 %conf<dir> 中包含的目录),以获得允许的最大卡路里(在 %conf<calories> 中)和我们要洗牌产品数组的次数,以获得具有最佳蛋白质含量的新产品组合。

洗牌是通过 @products = @products.pick(*) 来完成的。pick 将从数组中随机返回一个元素,而使用 Whatever(*) 将从数组中挑选出尽可能多的元素。实际上,这将对数组进行洗牌,由于 optimal-ingredients 使用了该变量,我们将其赋值回同一个变量。

每重复一次就会生成一个[配料数组]、[蛋白质含量]形式的对。我们使用 gathertake 从循环中生成这个结果。这很方便地安排使用 maxpairs,它将打印第二个元素具有最大值的对。

我们从示例代码仓库的主目录中运行这个配方,像这样。

raku -Ilib Chapter-5/max-proteins-with-conf.p6 Chapter-5/config.json

我们就会得到与此类似的结果。

[Chicken breast Kale Rice Chickpeas Kidney beans Cheese] with 77.7 g protein
[Potatoes Chorizo Beer Pasta Chicken breast] with 64.9 g protein
[Chicken breast Potatoes Cheese Chorizo Tomato Sardines] with 109.3 g protein Best (Chicken breast Potatoes Cheese Chorizo Tomato Sardines => 109.3)

为了让 maxpairs 正确工作,我们需要将它变成一个哈希;最佳行显示了一个成分-蛋白质对,看起来相当不错,蛋白质含量为100克。不过我不建议将辣味鱼和沙丁鱼混合在一起,所以你可能要运行几次(或者重新配置另一个重复次数),这样你最终会得到一些可口的东西。

5.1.4. 使用 INI 文件进行配置

INI 格式最初是在 Windows 中使用的,但现在在任何地方都可以找到,它比较简单,在许多情况下,当你需要的只是几个变量/值对时,就会用到它。它也被分为若干部分,其名称用方括号包围。

[food]
calories = 500
[algorithm]
repetitions =  5
[meta] dir = .

一个可靠的处理模块是 Config::INI,可以在 https://github.com/tadzik/perl6-Config-INI 中找到,你可以用通常的方法安装它。前面的程序可以改编成这种方式使用。

use Raku::Recipes;
use Config::INI;

my %conf = Config::INI::parse_file( @*ARGS[0].IO.e ?? @*ARGS[0] !! "config.ini" );
say %conf;
%calories-table = calories-table( %conf<meta><dir> );
@products = %calories-table.keys;
my $max-calories = %conf<food><calories>;

my @results = gather for ^%conf<algorithm><repetitions> {
    @products = @products.pick(*);
    my @ingredients = optimal-ingredients( @products.elems -1 , $max-calories );
    my $proteins = proteins( @ingredients );
    say @ingredients, " with $proteins g protein";
    take @ingredients => $proteins;
}
say "Best ", @results.Hash.maxpairs;

这个模块直接用 Config::INI::parse_file 读取文件,现在的哈希对变量进行了分层组织,第一个键是部分,第二个键是变量本身的名称。除此之外,除了重复次数和卡路里数有变化,我们减少了卡路里数外,其他的结果都没有明显的变化。

5.1.5. 使用 YAML 文件进行配置

YAML 最近在云配置中的使用比较流行,但它的生命力很长,因此在很多语言中都有很好的支持。这其中就包括 Raku,它有一个名为 YAMLish 的库,经常更新,支持度很高。

我们将尝试解决另一个问题,类似于背包问题,但更简单:我们将尝试使用两种材料创建一个具有一定卡路里的食谱。这些食材必须包括一个副料和一个主料,我们可以另外施加限制,比如让它们成为素食或非奶制品。在进行工作之前,代码会对配置进行检查,如果配置不正确或者不符合限制条件,就会发出一个错误。

下面是一个 YAML 的配置文件的例子。

---
main: Cod
side: Potatoes
calories: 500

在 YAML 中,三个破折号表示一个文档的开头,其余的是键值对。YAML 允许任何类型的数据结构的序列化,但对于这个问题,这将是足够的。指定一个主菜和一个配菜,以及你想要的菜的卡路里量。为了简化,我们就把它平均分摊到副菜和菜中。

我们需要对这个配置文件进行处理,这样,如果出现问题,就可以告知用户我们所期望的是什么,出了什么问题。这也将确保错误不会进一步传播到库中,产生一个用户无法解释的更隐晦的错误。我们需要检查任何可能出错的地方,并产生一个异常,可以引导用户修复任何错误。

请注意,我们将使用自定义异常,这将在第8章中更广泛地介绍。异常,而且是类型化的异常,可以被设计到应用程序中,并通过给它们一个参数来唤起它们,使其自定义异常以适应特定的情况。

这个程序必须读取 YAML 文件,然后进行一系列的检查,如果缺少了一些必要的东西,或者仅仅是有一些错误,就会发出异常(并结束程序)。当一切检查完毕后,它将生成配方。

use YAMLish;
use Raku::Recipes::Roly;
use X::Raku::Recipes;

my $conf = slurp( @*ARGS[0] // "Chapter-5/recipe.yaml" );
my $recipes = Raku::Recipes::Roly.new;

my %conf = load-yaml( $conf );
%conf<calories> //= 500;

constant @conf-keys = <main side calories>;

die "There are unknown keys in the configuration file"
        if %conf.keys ⊖ @conf-keys ≠ ∅;

my @recipe;
for <main side> -> $part {
    without %conf{$part} { X::Raku::Recipes::MissingPart.new( :$part ).throw() };
    given %conf{$part} {
        when %conf{$part} ∉ $recipes.products {
            X::Raku::Recipes::ProductMissing.new( :product(%conf{$part}) ).throw()
        }
        when not $recipes.check-type( %conf{$part}, $part.tc ) {
            X::Raku::Recipes::WrongType.new( :desired-type( $part )).throw() ;
        }
    }
    my %this-product = $recipes.calories-table{%conf{$part}};
    my $portion = %conf<calories>/( 2 * %this-product<Calories>);
    @recipe.push: $portion *  %this-product<parsed-measures>[0] ~ " " ~
            %this-product<parsed-measures>[1] ~ " of " ~  %conf{$part}.lc;
}

say "Use ", @recipe.join(" and ");

这个程序看起来比实际时间要长,只是因为正在执行的所有检查。但这是必不可少的,在生产环境中更是如此,因为在生产环境中,配置必须恰到好处。

程序的前言包括了我们已经谈过的模块。X::Raku::Recipes 是一个定义了所有异常的文件,所有的文件都会使用这个文件作为它们的命名空间。

之后,我们读取配置文件(无论是从命令行还是从默认值),并为卡路里的数量分配一个合理的默认值,以防缺失。我们初始化(双关语)角色,它也会读取卡路里表,并将配置加载到一个 Raku 哈希中。那个变量应该只有三个键,而且那里的每个值都必须是正确的。从而进行多项检查。

  • 是否只有我们理解的键?如果有任何其他的键,程序就会死掉,并且会通知用户。

  • 是否包括菜的两个部分?如果缺少任何一个,就会抛出一个 MissingPart 异常。在这种情况下,我们可能希望将土豆作为一个合理的默认值。然而,在西班牙,默认的食物只是面包,所以在没有合理的默认值的情况下,如果缺少了面包,我们就抛出一个异常。我们会得到类似 Object does not seem to be side 这样的结果,如果我们使用 side: Cod.

  • 我们是否对所提到的成分有所了解?如果表中缺少它,抛出一个 ProductMissing 异常。

  • 那个食材真的是那种菜吗?例如,我们是否要求猕猴桃配土豆?如果它们不匹配,应该抛出另一个异常。

当所有这些检查都通过后,就只需将该食材用来测量卡路里的措施除以它需要填充的卡路里数量(本例中为250)。我们将其阐述为一个字符串,包括将配料的小写字母,而配料总是大写字母。最终,这可能会打印出如下的内容。

Use 236.111111 g of cod and 304.878049 g of potatoes

这是一块好鳕鱼和一个中等大小的土豆。似乎是合理的。

对配置的正确处理总是应该与对可能的错误的精确处理相配合。我们将在第8章学到更多的知识。

5.2. 食谱 5-2. 用标志和参数配置命令行命令

5.2.1. 问题

你需要从命令行中调用不同值的脚本。

5.2.2. 解决办法

使用 MAIN 子程序来决定如何调用程序。可以定义多个实例,允许更高效的调用。此外,它是自动记录的,会自动生成一个 -h-?,并对每一个参数及其值进行解释。MAIN 是一个普通的子程序,所以它也会为你执行类型检查,并将它们从命令行中的字符串转换为程序所需的适当格式。

5.2.3. 它是如何工作的

此时,我们在 CSV 文件中已经有了一张不错的食材表,我们可能需要一些工具从命令行中查阅。比如,我们有多少种素食食材?有多少素食配菜?如果有一个命令行工具能把这些作为标志,并给我们一个配料清单,那就非常有用了。我们可以用它来获得一份清单,然后在网上查到一个菜谱。我们已经在 CSV 表格中以这种形式列出了食材。

Ingredient;Unit;Calories;Dairy;Vegan;Main;Side;Protein;Dessert
Rice;100g;130;No;Yes;Yes;Yes;2.7;No
Chickpeas;100g;364;No;Yes;Yes;Yes;7;No

所以我们可以通过五个不同的特点来筛选。乳制品,素食,主食,副食和甜点。这样一来,我们的命令行程序一共有五个标志。这个小程序就可以做到这一点:

use Raku::Recipes::Classy;

sub MAIN( Bool :$Dairy, Bool :$Vegan, Bool :$Main, Bool :$Side, Bool :$Dessert ) {
    my %ingredients = Raku::Recipes::Classy.new().calories-table;
    my @flags;
    for <Dairy Vegan Main Side Dessert> -> $f {
        @flags.push($f) with ::{"\$$f"};
    }
    my @filtered = %ingredients.keys.grep: -> $i {
        my @checks =  @flags.map: -> $k {
            %ingredients{$i}{$k} eq ::{"\$$k"}
        }
        so @checks.all;
    }
    say @filtered;
}

首先,我们使用一个面向对象的版本的卡路里表(现在包括了更多的东西),并将其加载到我们的程序中;我们将把它包含在 %ingredients 变量中。

让我们看看我们如何使用命令行标志。对于我们要检查的每一个,在专门命名的 Main 子例程的签名中都会有一个命名变量。因此,我们将为我们定义的五个过滤器中的每一个都有一个变量,并且我们强制它们为 Bool。标志的存在会将变量设置为 True;我们也可以通过使用 --/,如 --/Dairy,将标志设置为 False。例如,不属于副原料的主原料将用以下方式列出。

raku Chapter-5/filter-ingredients.p6 --Main --/Side

如果依次检查每一个变量会很麻烦,所以我们使用一个很好的 Raku 技巧来访问一个名字在变量中的变量的值: ::{"\$$f"}。这就建立了一个变量的名称,其中会有美元(/$),然后是它的标识符,也就是变量 $f 的值。如果这个变量存在,我们就把它和过滤器一起添加到数组中。这个循环和声明其实可以缩短为这样。

my @flags = <Dairy Vegan Main Side Dessert>.grep: { defined ::{"\$$_"} };

这样就有效地只过滤了那些被定义的变量。然后,我们通过对其键值运行 grep 来再次过滤成分列表:@checks 变量将包含一个将该成分的值与所需值进行比较的结果列表。这最终将是一个 [True False True] 形式的列表。但是我们需要该成分满足所有定义的条件,所以我们在其中创建一个 juncton: @checks. all。 Junction 对于比较来说是非常有用的;通过一个操作符,我们可以对一个列表中的所有元素进行操作(也可能同时使用自动线程),但在这种情况下,我们要做的是通过 so 将该值转换为一个 Bool 值返回。只有当列表中的所有元素都为真时,这个值才会为 True;@filtered 将包含所有条件为真的所有元素。例如,上一条命令的结果如下:

[Tomato Kale Potatoes]

这些只是那些不能同时作为主菜的配菜。其他的,比如鹰嘴豆,可以是主料(炖鹰嘴豆或沙拉),也可以是配菜,所以它们不会被列入这个列表。一旦明确了这些命令的作用,我们还可以缩短它们,消除任何中间变量。

say %ingredients.keys.grep: -> $i {
        so all @flags.map:  { %ingredients{$i}{$_} eq ::{"\$$_"} };
}

留下的程序,总之不到10行。

注意,这也会返回一个列表而不是数组,但这个细节不是那么重要。

作为一个附加值,使用 MAIN 会自动添加一个 -h 标志,所以当你运行这个:

raku -Ilib Chapter-5/filter-ingredients.p6 -h

你得到的是:

Usage:
  Chapter-5/filter-ingredients.p6 [--Dairy] [--Vegan] [--Main] [--Side]
[--Dessert]

如果你在没有过滤的情况下运行会怎样?它将返回所有的成分。但它仍然会运行同样的代码;它的设计方式是,在没有过滤器的情况下,它可以做到这一点,但它仍然需要运行大量的代码来实现一些可以很容易实现的东西。为了解决这个问题,我们可以简单的定义多个 MAIN,使用 multi

use Raku::Recipes::Classy;

multi sub MAIN() {
    say Raku::Recipes::Classy.new.products;
}

multi sub MAIN( Bool :$Dairy, Bool :$Vegan, Bool :$Main, Bool :$Side, Bool :$Dessert ) {
    say Raku::Recipes::Classy.new.filter-ingredients( :$Dairy, :$Vegan, :$Main, :$Side, :$Dessert );
}

我们还在 Raku::Recipes::Classy 类中添加了 filter-ingredients,包含的代码与之前相同。新版本使用了多重分派机制。Raku 会简单地调用签名匹配的方法或例程。在 MAIN 例程的情况下,它将根据使用的标志来调用一个或另一个。没有标志 ?

它将调用第一个,它只是调用返回产品或成分列表的方法。有标志吗?它将调用第二个方法。在这种情况下,这将会稍微快一些,但从概念上来说,它将更容易地显示意图。

如果我们想使用额外的过滤器,例如,按蛋白质的最小数量或最大卡路里数量,会发生什么?让我们使用这个多日程机制来添加一个新的 MAIN,将按最小蛋白质含量进行过滤。这是一个整数,所以我们将其添加到新的 MAIN 的签名中。

# This is added to the previous example
multi sub MAIN(Bool :$Dairy, Bool :$Vegan, Bool :$Main,
           Bool :$Side, Bool :$Dessert,
           Int :$min-proteins) {
    my $rr = Raku::Recipes::Classy.new;
    my @filtered = $rr.filter-ingredients( :$Dairy, :$Vegan, :$Main, :$Side, :$Dessert );
    my %ingredients = $rr.calories-table;
    say @filtered.grep: { %ingredients{$_}<Protein> > $min-proteins };
}

实际上,我们唯一添加的是一个新的语句,它是按照最小的蛋白质量来过滤成分的,那是最后一个收尾括号之前的语句。使用已经知道的 grep,我们将过滤后的原料列表(在 @filtered 中),检查蛋白质的数量是否高于要求的数量。这样就会从原来的列表中,按特征过滤掉那些蛋白质含量较少的成分,产生类似这样的结果。

raku -Ilib Chapter-5/filter-ingredients-proteins.p6 --min-proteins=5 --Vegan
(Chickpeas Lentils Kidney beans)

这个结果说明,鹰嘴豆、扁豆、芸豆不仅好吃,而且营养丰富。在家里,我们每周至少要吃两次以这三种食材为主的菜。

同样的方法,我们用一个配置文件来获取生成菜谱的数据。

在本章中,我们可以在命令行中进行。事实上,我们可以在参数中添加类型,这使得在参数进入程序之前就能更容易地捕捉到参数中的错误,从而使一切变得更快。这次让我们重复一下使用命令行用 YAML 读取配置文件的口诀。

use Raku::Recipes::Roly;
my $recipes = Raku::Recipes::Roly.new;

subset Main of Str where {
    $_ ∈ $recipes.products && $recipes.check-type($_, "Main" )
};

subset Side of Str where {
    $_ ∈ $recipes.products && $recipes.check-type($_, "Side" )
};

sub MAIN( Int :$calories = 500,
            Main :$main!,
            Side :$side! ) {
    my @recipe;
    for <main side> -> $part {
        my $this-value = ::{"\$$part"};
        my %this-product = $recipes.calories-table{$this-value};
        my $portion = $calories/( 2 * %this-product<Calories>);
        @recipe.push: $portion *  %this-product<parsed-measures>[0] ~ " " ~
                %this-product<parsed-measures>[1] ~ " of " ~ $this-value.lc;
    }

    say "Use ", @recipe.join(" and ");
}

执行程序的部分,最后八行,实质上是一样的。不同的是设置。我们不需要处理异常,因为 Raku 本身就会通过几种机制为我们处理。 - 我们创建了两个子集,Main 和 Side,只允许输入正确类型的值,并且包含在产品表中可用的产品集合中。除了这些值以外的任何值都会产生一个调用错误。 - 我们将两个变量 $main$side 作为强制性变量。如果没有使用它们,程序将以相应的错误死掉。 - 如果添加了额外的键,我们不会产生错误,但用法字符串澄清了什么是强制性的,什么是可选的。

Usage:
  Chapter-5/generate-recipe-cli.p6 --main=<Main> --side=<Side>
[--calories=<Int>]

为了获得其名称在另一个变量 $part 中的变量的值,我们使用与之前相同的技巧,查阅符号表:::{"$/$part"};

如果这个变量在运行时缺失了 mainside,它将产生用法字符串。如果类型不正确,比如这里,它将产生同样的信息。

raku -Ilib Chapter-5/generate-recipe-cli.p6 -Ilib --main=Sardines --side="Green kiwi"

注意: 如果你想传递一个带有空格的产品作为参数,你必须使用引号。

这并不理想,因为并不清楚为什么这个参数没有通过。然而,你可以用更多的文档来补充简洁的信息。如果事实证明这对你的用例来说是不够的,只需转到使用配置文件的配方。

5.3. 食谱 5-3. 在程序中使用 Shell 环境变量

5.3.1. 问题

在云环境中,最好的做法是使用环境变量将所有信息传递给程序。它们在整个程序中也是可用的,所以在需要时可以读取它们。

5.3.2. 解决办法

所有的环境变量都可以通过 %*ENV 默认动态变量来读取,并以 key 作为变量名。

5.3.3. 如何工作

最佳实践告诉你,一些信息应该被保存在环境变量中。这些变量是从 shell 中定义的,shell 让每个运行的程序都能使用。根据我们使用的 shell (或操作系统),我们会使用类似这样的变量。

export CALORIE_TABLE_FILE=../data/calories.csv

在=周围没有空格是很重要的;另外,传统上,它们的名称使用 ALL CAPS。在这种情况下,我们将告诉我们正在运行的程序,或者任何程序,在这个问题上,该文件将被放置在哪里。每个程序都会收到一份所有环境变量及其值的副本。每种语言都使用不同的调用或数据结构来使它们可用。在 Raku 的情况下,它是 %*ENV 变量。% 符号表示它是一个关联变量,twigil * 表示它是一个动态变量。动态变量的值类似于全局变量,但不是用全局范围定义,而是用调用者的范围定义。例程中的动态变量可以由调用者设置,也可以由调用者在子内部更改时有不同的值。

这个概念被延续到了自动变量上,当程序被实例化时,这些变量会得到一个值,比如这个程序,只是该变量的变化不会被输出到程序之外。它们只能在程序的其他地方使用。使用 run 启动的程序就会得到这个(可能是修改过的)实验的副本。

我们将在 Raku/Recipes/Roly.pm6 文件中这样使用变量的内容。

method new( $dir = "." ) {
    my $calorie-table-file = %*ENV<CALORIE_TABLE_FILE> // "$dir/data/
    calories.csv";
    my %calories-table = csv(in => $calorie-table-file,
                             sep => ';',
                             headers => "auto",
                             key => "Ingredient" ).pairs
    ==> map( {
       $_.value<Ingredient>:delete;
       $_.value<parsed-measures> = parse-measure( $_.value<Unit> );
       $_ } );

for %calories-table.values -> %ingredient {
    for %ingredient.keys -> $k {
        given  %ingredient{$k} {
            when "Yes" { %ingredient{$k} = True }
            when "No"  { %ingredient{$k} = False };
        }
    }
};
@products = %calories-table.keys;
self.bless( :%calories-table, :@products );
}

这个方法创建对象,对应的是第三章和第四章的配方。变化在前几行。我们检查那个环境变量是否被定义了,如果定义了,就设置我们要读到的文件名。如果没有定义,则使用实例化变量时定义的目录。实际上,这个值覆盖了我们在创建 Raku::Recipes::Roly 实例时用于目录的任何值。

有一点很好,那就是你不需要改变任何其他文件,改变的只是内部的实现。之前的食谱中使用的脚本仍然会以完全相同的方式工作。

我们会经常使用这种方法,主要是在将我们的应用程序部署到云端和定义 API 密钥时,例如。或者任何我们想要的时候,真的。

5.4. 食谱 5-4. 为应用程序创建一个 Docker 容器,以便轻松地分发它

5.4.1. 问题

你需要一种在任何云提供商中轻松部署应用程序的方法。

5.4.2. 解决办法

基于任何已发布的 Raku 映像,创建一个自足的 Docker 映像,可以在安装 Docker 的任何地方使用。

5.4.3. 它是如何工作的

Docker 容器是部署云应用的最佳方式,但也可以作为方便容器,在安装 Docker 客户端和服务器的任何地方部署命令,现在几乎无处不在。

Docker 可以创建映像,这些映像是与文件系统一起的应用程序,可以被修改并用于存储数据或结果,或者直接扔掉。这些 Docker 镜像最好的比喻就是下一个版本的可执行文件:你可以直接下载并运行它们,而不用担心安装任何东西,比如它们的依赖关系或它们所使用的语言。因此,你可以用它们来包装一个应用程序,以便在任何地方使用它。

我们将使用一个 Docker 容器来包装我们刚刚创建的脚本,这个脚本可以按类型过滤成分,也可以过滤最小量的蛋白质。假设你需要在其他任何地方得到你的命令行实用程序,它可以创建一个成分列表,并通过特征和蛋白质进行过滤。通常这将是云,但 Docker 容器可以在任何地方使用,事实上也是如此。

创建这些镜像的方法有很多,你可以简单地用基础镜像启动一个容器,安装所有需要的东西,然后推送镜像。你也可以把它上传到 Docker Hub 上,让大家都能使用。

但如果你使用 Dockerfile 会更好,它是一个配方,描述了如何将所有东西放入容器中,并包括运行它所需要做的事情。下面是你将使用的内容。

FROM jjmerelo/alpine-raku:latest
LABEL version="0.0.2" maintainer="JJ Merelo <jjmerelo@GMail.com>"

ADD META6.json Chapter-5/filter-ingredients-proteins.p6 ./
RUN mkdir lib && mkdir data
ADD lib/ lib
ADD data/calories.csv data

RUN apk update && apk upgrade && zef install . \
    && chmod +x filter-ingredients-proteins.p6
ENTRYPOINT ["./filter-ingredients-proteins.p6"]

它依赖于一个已经包含 Raku、jjmerelo/alpine-perl6 的基础映像。你可以使用几乎任何一个,只要它的执行路径中有 raku 和 zef 作为可执行文件。我维护着那个镜像,并在它被生产出来后立即将其升级到最新的 Raku 版本(在写文章的时候是 2020.01)。如果因为任何原因不方便,你可以使用 Docker Hub 搜索其他的。这个镜像基于 Alpine Linux,这是一个极简主义的发行版,在 Docker 世界之外鲜为人知,但在那里相当流行。这个镜像的主要意图是创建一个结构紧凑、下载时间短的镜像。

Dockerfile 的命令都是大写的;接下来的 label,是给镜像添加元数据,可以用 Docker image 或者其他实用工具来检查。虽然不是很需要,但是很方便。

其余的真的非常简短。它创建了目录,这样就能在预期的地方找到文件。zef install . 将安装 Raku::Recipes 模块,以便它们在任何地方都可以使用,apk update 和 upgrade 是用于升级操作系统的 Alpine 命令,因为自从镜像创建以来,它可能已经改变了。

最后一个命令建立了一个入口点:如果 Docker 镜像被自己调用,这就是要运行的内容,这也是添加标志的地方。我们使用我们想要封装的程序,很明显,在运行之前,把它放在方括号里,表示标志将被附加到它上面。

我们要用 buildah 来构建这个镜像,buildah 是红帽的一个实用工具,可以构建 Docker 镜像。

buildah bud -f Dockerfile -t jjmerelo/raku-recipes:latest

或者也可以这样,如果你使用标准的 Docker 安装。

docker build -t jjmerelo/raku-recipes:latest .

然后,我们就可以运行它了。Docker 镜像可以用几个实用程序来运行,当然包括这里使用的 Docker 客户端本身。我偏爱 podman,因为它是 OCI(Open Container Initiative)标准的实现。它不需要守护进程运行,而且速度略快。

podman run  -it --rm sh jjmerelo/raku-recipes:latest

它将打印整个成分列表,或者只打印

podman run  -it --rm sh jjmerelo/raku-recipes:latest --Vegan

只用于素食成分。这个标志将被传递给 ENTRYPOINT 定义的命令,它将以直接从命令行运行程序的方式运行。

使用 Docker 镜像是一种极其方便的方式,可以创建特定模块的测试容器,或者将 Web 服务运送到云端。我们稍后再来讨论这个问题。

5.5. 食谱 5-5. 使用 etcd 进行高级/分布式配置

5.5.1. 问题

你需要在云部署中获取配置值或任何其他相关信息。

5.5.2. 解决方案

使用 etcd,它是众多分布式键值存储中的一种。它允许我们设置将要在本地使用的值,并通过查询本地的联合 etcd 守护进程,在任何云实例中检索,它将与所有其他守护进程联系。如果需要的话,这可以安全地完成。这样一来,任何节点都可以发布只有在部署后才知道的本地值,如 IP、端口,或者仅仅是他们希望分布在分布式应用的所有部分和副本上的信息。

5.5.3. 它是如何工作的

只要你在单个节点中部署应用,使用命令行选项或环境变量是可以的。如果你想在云中的一个或几个节点中部署应用,设置环境变量可能会很麻烦,而且使用部署脚本来做也会带来一系列的问题,主要是任何东西都会被一劳永逸地设置。

使用分布式配置服务,比如 etcd,是一种简单、高效、安全的方式,可以将信息分布在部署的所有实例中。为了做到这一点,需要在每一个要部署的节点中安装 etcd,这些节点之间都要相互意识到。解释如何做到这一点超出了本书的范围,但你可以在网站 etcd.io 上查看安装选项。按照那里的说明安装 etcd 服务器和命令行客户端 etcdctl,这将在本食谱中使用。

我们将使用与之前使用环境变量相同的方式来使用 etcdctl:设置我们感兴趣的值(例如文件名)。我们可以使用下面的脚本来实现。

my $can-haz-etcdctl = shell "etcdctl --version", :out;

my $output = $can-haz-etcdctl.out.slurp;
die "Can't find etcdctl" unless $output ~~ /"etcdctl version"/;

my $version = ($output ~~ / "API version: " (\d+) /);
my $setter = $version[0] ~~ /2/ ?? "set" !! "put";

sub MAIN( $key, $value ) {
    my $output = shell "etcdctl $setter $key $value", :out;
    my $set-value = $output.out.slurp.trim;
    if $value eq $set-value {
        say "🔑 $key has been set to $value";
    } else {
        die "Couldn't set $key to $value";
    }
}

这个脚本主要是对 etcdctl 命令行的一个包装,但是它有一个附加值,就是你不需要记住确切的语法,如果 etcdctl 没有安装,它就会失败,如果我们没有在命令行中使用键值对,它就会抱怨,因为我们在 MAIN 子的签名中使用了键值对。如果没有安装 etcdctl,它就会失败,如果我们没有在命令行中使用键值对,它就会抱怨,因为我们在 MAIN 例程的签名中使用了键值对。如果我们做这样的事情:

raku etc-set.p6 hey

它将写出以下内容:

Usage:
  etc-set.p6 <key> <value>

这个程序的关键功能是通过使用 shell 来实现的,这一点我们已经在专门介绍系统交互的章节中看到了。这个程序将按原样运行命令行参数,不试图检查或清理它们,这意味着如果它们只是包含一个 && 或一个 ;,就有可能运行其他程序。在这种情况下,这并不是问题,因为所产生的命令行将以与给它们赋值的用户相同的权限运行。然而,用户最好使用同样的方法在互联网暴露的后台运行程序。

使用 shell 例程并捕获输出,我们可以做几件事。

  • 确定 etcdctl 是否在工作,是否在路径中可用。

  • 由于我们捕获了这个命令的输出,我们用它来检查 API 的版本。当从版本2换到版本3时,值设置命令从 set 改为 put。版本2在一些发行库中仍然可以使用,所以我们需要照顾到这一点。

  • 我们再用它来捕捉设置键的输出,etcdctl 如果正确地完成了设置,就会返回所设置的值,所以我们要检查它是否被正确设置了。由于 etcdctl 返回的值是带回车的,所以我们对它进行修剪,也就是说,我们把周围的空格都去掉,这样我们就只剩下值了。

然后我们可以用这个脚本来运行。

raku etc-set.p6 filename data/calories.csv

如果一切正常,这将打印出以下内容(应该是这样,只要我们正确安装了 etcd)。

🔑 filename has been set to data/calories.csv

然后,我们需要从我们的脚本中获取值。

my $can-haz-etcdctl = shell "etcdctl --version", :out;

my $output = $can-haz-etcdctl.out.slurp;
die "Can't find etcdctl" unless $output ~~ /"etcdctl version"/;

for @*ARGS -> $key {
    my $output = shell "etcdctl get $key", :out;
    my $value = $output.out.slurp.trim;
    say "🔑 $key -> $value";
}

在(必须的)检查了我们有一个 etcdctl 的工作副本之后,我们对命令行中设置的每一个键启动 etcdctl,捕获输出,然后打印一行键值对。

我们可以运行它并获得结果。

raku etc-get.p6 innie foo filename
🔑 foo -> bar
🔑 filename -> data/calories.csv

shell 命令是相当强大和灵活的,我们可以用它来连接任何可以从命令行运行的程序或 API,而大多数程序和 API 都可以。在许多情况下,这将是其他更有表现力的 API 的替代品,比如 gRPC,它在 Raku 中的支持现在还不是很完善。另外,NativeCall 接口可以用来将一个共享库绑定到 Raku,或者 REST API,如果有的话。然而,所有这些都需要更多的编程努力,所以在大多数情况下,从 Raku 中调用这些程序的 CLI 就足够了。

6. 自动化系统任务

为了在同一页面上,我们将使用一个基于 Debian 的 Docker 镜像来测试本章的配方。在大多数情况下,所有的 Linux 系统的工作原理都是完全一样的,但是如果你还没有使用一个系统,你可以为你的操作系统使用一个 Docker 安装,并从该镜像中检查配方。可以使用以下方式下载该镜像。

docker pull jjmerelo/raku-recipes:Chapter-6

你可以用以下方法运行它:

docker -it –rm --entrypoint bash jjmerelo/raku-recipes:Chapter-6

这将会给你一个 bash 提示,从这个提示中可以得到 Raku(和其他应用程序)。

6.1. 食谱 6-1. 检查某些事件的日志

6.1.1. 问题

你需要检查系统日志,或任何其他类型的日志,检查某些事件,如反复尝试登录或任何类型的错误。

6.1.2. 解决方法

你可以对一些系统文件设置监视,并过滤你想知道的事件,你已经在第2章看到了如何做。你还可以使用生态系统中的 Sys::Lastlog 模块来识别最后登录的人(我们将在本章后面学习)。我们将使用第2章中的食谱 2-5 "观察文件的变化"和一组正则表达式(也许是用户定义的)组合来定义我们感兴趣的事件,然后将它们记录下来或打印到控制台。另外,我们还可以使用 Syslog::Parse,这是本书作者发布的一个模块,它将系统日志转换为一个供给,并包含一个解析 syslog 条目的语法。

6.1.3. 它是如何工作的

Linux 系统日志中的每个条目都是这样的。

Feb 17 12:06:45 penny org.gtk.vfs.Daemon[5244]: ** (process:8299): WARNING
**: send_done_cb: No such interface 'org.gtk.vfs.Enumerator' on object at
path /org/gtk/vfs/client/enumerator/2 (g-dbus-error-quark, 19)

首先是发生某事的日期和时间。接下来是机器名称、引起输入的进程和方括号内的进程号。

在某些情况下,还有其他信息。

Feb 17 12:06:45 penny dbus[1450]: [system] Successfully activated service 'org.freedesktop.hostname1'

方括号内的名称是指这是一条系统信息信息。而 ** 则表示有比较严重的事情发生。syslog 格式在 RFC 5424 中是规范化的,它包括了整个远程日志的协议,所以可以从任何应用程序中读取和产生。

标准化的格式是一个很好的格式。我们可以对其进行处理。然后我们可以寻找我们感兴趣的东西。比如说,我们只看警告。

say "/var/log/syslog".IO.lines.grep: /"-WARNING **"/;

不过,这也太多了吧。很多台词,而且都是同时出现的。这就违背了警告的全部目的,那就是,嗯,警告你可能发生了一些不正常的事情。此外,这些实际上是 gnome 的警告;其他应用程序产生的警告可能会被忽略。例如,这些是 Docker 警告。

Feb 19 09:17:36 penny dockerd[2408]: time="2020-02-
19T09:17:36.865094510+01:00" level=warning msg="failed to rename /var/lib/
docker/tmp for background deletion: %!s(<nil>). Deleting synchronously"

它们是小写的,在某些情况下,它们只是说<警告>。让我们试着考虑到所有这些情况。这段代码将做到这一点,并告诉你是谁发出的消息。

my @warnings = "/var/log/syslog".IO.lines.grep: /warn/;
for @warnings -> $w {
    my ($metadata, $message) = $w.split( ": ", 2 );
    say "→ ", $metadata.split(/\s+/)[*-1],
        " has produced this message\n\t$message\n\n";
}

它会产生像这样的行:

→ gnome-session[5475] has produced this message
             Window manager warning: Window 0x4800022 (win0) sets an MWM hint indicating it isn't resizable, but sets min size 1 x 1 and max size 2147483647 x 2147483647; this doesn't make much sense.

至少我们知道是谁干的。不过,剩下的信息还是让我们摸不着头脑,尽管我们可以用正则表达式来提取。或者更好的是,我们可以使用 Syslog::Parse 中包含的语法来理解这一切。

use Syslog::Grammar;
use Syslog::Grammar::Actions;

"/var/log/syslog".IO.lines
    ==> map( { Syslog::Grammar.parse( $_,
                                      actions => Syslog::Grammar::Actions.
                                      new ).made; } )
    ==> grep( { $_<message> ~~ m:i/warn/ }  )
    ==> my @lines;

for @lines -> %w {
    say "⇒ %w<actor> has warned about\n\t%w<message>\n\tby $%w<hour>";
}

Syslog::Parse 中的语法可以理解行中的不同组件:有元数据和一个由冒号和空格分隔的消息(可能是空的)。但这些元数据是有结构的,比如日期或产生它的应用程序。我们可以使用该应用程序应用特定的过滤器来解读消息,或者只通过消息内容来过滤,而不是使用整行。

注意可能会有一个叫做 awarnia 的应用,会在警告过滤器中触发假阳性。

在语法解析的同时,语法动作会将解析后的内容转换成可用的东西,或者在事情发生时采取行动。在这种情况下,我们使用了默认的 Syslog::Grammar::Action,它创建了一个哈希,使用我们感兴趣的信息作为键。例如,它可以产生一个数据结构,比如这个。

{
:actor("firefox.desktop"),
:day(19),
:hostname("penny"),
:hour("12:29:14"),
:message("... long message with warning ..."), :month("Feb"),
:pid(Any),
:user("∅")
}

虽然解析只会产生一个 Match 对象,其中包含了匹配中获得的原始字符串,但观察一下,例如,如果在消息中没有检测到用户,就会显示一个空集。脚本本身会产生一组结构化的警告,比如这个。

⇒ firefox.desktop has warned about
    message repeated 2 times: [ [Child 11536, MediaDecoderStateMachine #1] WARNING: (some stuff)]
    by 12:29:14

许多其他的过滤器是可能的,我们可以以许多不同的方式显示预处理的数据。我们将在下一个配方中深入研究这个问题。

6.2. 食谱 6-2. 在控制台上交互式检查日志

6.2.1. 问题

你希望在控制台中对某些日志进行警告,或许还可以对它们进行过滤。

6.2.2. 解决办法

生态系统模块如 Term::TablePrint 允许你在控制台上静态地检查数据表;你可以在调用它之前生成数据,并通过异步更新添加交互性。

6.2.3. 它是如何工作的

一般来说,TUI(Terminal User Interface,终端用户界面)就是我们所说的面向整个控制台的应用界面,至少要设置成能与控制台全方位合作的界面。这样的界面并不是把东西打印到控制台,然后在上面消失。

虽然面向命令行的界面速度很快,会被存储在 CLI 历史记录中,并且可以重新编辑和使用,但有时你需要浏览一组项目,并根据上下文选择一个。比如说,你需要在检查系统中所有日志的同时,检查某个日志。而这一切都需要一个控制台,而不是面向行的用户界面。Term::TablePrint 在下面的脚本中救了我们一命。

use Term::Choose :choose;
use Term::TablePrint;
use Libarchive::Filter;

my @files = dir( "/var/log", test => { "/var/log/$_".IO.f } );

while @files {
    my $file = choose( @files.map( *.Str ),
      :prompt("Choose file or 'q' to exit") );
    last unless $file;
    my $i;
    my $content;
    if $file ~~ /\.gz$/ {
        $content = archive-decode($file, filter=>'gzip');
    } else {
       $content = $file.IO;
    }
    my @lined-file= $content.lines.map: { [ ++$i, $_ ] };
    print-table([ ['⇒',$file], |@lined-file ]);
};

再一次,这显示了 Raku 如何在没有大量指令的情况下做很多事情,但在这种情况下,这是由于我们使用了上述模块以及另外两个模块-- Term::Choose (由同一个作者 Matthäus Kiem 编写)和 Libarchive::Filter。

这个脚本是一个循环,它呈现了 /var/log 目录的内容(过滤后只包括文件,也就是不包括目录),并帮助我们通过游标键移动来选择一个文件。我们通过选择例程来实现。见图6-1。只有在有文件的情况下才会进入 while 循环(应该有),它将一直运行到用户按q键退出。当这种情况发生时, $file 变成空值,最后一条退出循环的命令被激活。

图6-1. 在所有的日志中选择一个文件

这个例程在 $file 变量中返回一个文件名,为了在屏幕上逐行显示文件,我们需要检查它是压缩文件还是简单文件。为了在屏幕上逐行显示文件,我们需要检查它是压缩文件还是简单文件。前者的类型会通过档案解码过滤器(来自 Libarchive::Filter),然后转换成一个有两列的表格 - 行数和行内容。如图 6-2 所示,在顶部添加一个带箭头的页眉和文件名。

图6-2. 以表格形式显示一个压缩文件

你可以用上下箭头逐行浏览,包括分页,完成后用 q 退出。这只需通过打印-表格顺序(来自 Term::TablePrint)来实现;q 是退出当前屏幕的默认键。

脚本的要点是来自三个不同模块的三个函数,都存在于生态系统中。将它们以不同的方式组合起来,你可以用一个简单的、面向屏幕的界面创建强大的系统管理脚本。

6.3. 食谱 6-3. 检查 Git 提交的模式和元数据,或将它们存储起来

6.3.1. 问题

源码控制是 Git 的代名词,有时你需要通过日志来衡量生产力,记录他们工作的问题,或者简单地绘制工作图表。访问文本日志很容易,但你需要解析它以获得可行的数据。

6.3.2. 解决办法

生态系统中有许多模块可以与 Git 合作。我们将检查它们,并使用最适合每个任务的模块。例如,Git::Log 可能是处理提交的最佳模块。

6.3.3. 如何工作

比如你想知道,你在书中的实例库中的工作效率。你每天提交了多少次?

Git::Log 可以帮你解决这个问题。

use Git::Log;

git-log()<>
    ==> map( { DateTime.new( $_<AuthorDate> ).Date } )
    ==> classify( { $_ } )
    ==> map( { $_.key ~ ", " ~ $_.value.elems } )
    ==> sort()
    ==> my @dates;
say @dates.join( "\n" );

这是一个很好的例子,使用 feed 操作符(我更喜欢叫它火箭,因为它把结果发射到下一个处理阶段)来创建一个操作管道,在尾端,得到你想要的东西。

让我们看看不同的阶段。

  1. git-log() 进行了检查当前目录的 Git 日志的调用,默认情况下(这也是为什么应该从仓库的顶部目录调用的原因,如 raku Chapter-6/commits-per-day.p6)。这将返回一个标量,我们通过 <> 对其进行去容器化,从而得到它里面的哈希数组。经过这个阶段,我们就有了一个哈希数组,每一个哈希数组都包含了一个提交的信息,包括日期,也就是我们感兴趣的日期。

  2. 我们通过将 UTC 格式的时间转换为 DateTime 对象,将哈希数组映射为日期,然后进入 Date 部分。之后,我们就会得到一个 20xx-yy-zz 形式的对象数组。

  3. 我们通过简单地使用对象的字符串形式将数组对象分类成对。这些对子将以日期作为键,以相同日期的副本作为值的数组。我们需要更进一步,因为我们对每天的提交次数感兴趣。

  4. 我们将这些对映射成一个字符串,这个字符串的键(日期),与值之间用逗号隔开,逗号是该日期在日志中出现的次数。它是无序的,因为哈希是有保证的。

  5. 我们对结果进行排序,使较早的日期先出现。然后我们将它们发送到一个数组中。

我们可以对这个数组做任何我们想做的事情,但是我们选择把它打印成一个单一的字符串,每一个元素都用回车(\n)隔开。就是这样。我们可以用稍微不同的方法来确定每个作者的提交次数 (本例中没有,因为这里只有一个作者),甚至可以检查每个月或每周一天的生产率。比如说,我们来检查一下。

注意,它只是引起了我的好奇心。

我们需要修改前面脚本中的几项内容,以便按一周中的某一天来检查生产率。

my @dow = <Nope Mon Tue Wed Thu Fri Sat Sun>;
git-log()<>
    ==> map( { DateTime.new( $_<AuthorDate> ).day-of-week } )
    ==> classify( { $_ } )
    ==> sort()
    ==> map( { @dow[$_.key] ~ ", " ~ $_.value.elems } )
    ==> my @dates;
say @dates.join( "\n" );

由于一周的日子是从1开始编号的,所以我们在 @dow 数组的0索引中插入一个虚元素,这样我们在使用它们时就不用对它进行操作了。排序和最后一个映射的位置已经被颠倒了(反正我们在前面的例子中也是这样做的,也没有什么关系),所以我们是按照天数来分类的,而不是按照名字的字母顺序来分类的(这也没有什么关系)。我们没有直接使用键来生成数组,而是将其作为周名的索引。这就是结果:

Mon, 15
Tue, 15
Wed, 20
Thu, 36
Fri, 15
Sat, 25
Sun, 39

有去我的周末…​

6.4. 食谱 6-4. 清理你的 Docker 镜像商店

6.4.1. 问题

当使用 Docker 容器时,你最终会有许多未完成的构建,你只用过一次的镜像,以及其他你根本不需要的镜像。

6.4.2. 解决办法

使用 Docker::API(生态系统中的一个模块)来执行此任务和其他 Docker 相关任务。

6.4.3. 它是如何工作的

首先,你需要从生态系统中下载此模块。按照说明安装它,包括额外的外部库。注意,这个配方不需要从 Docker 容器内运行。 安装模块的提示说明包含在本书仓库中的 Chapter-6/README.md 中。在每个章节目录中,都有一个特定章节的 META6.json 将让你使用 zef install -deps-only 安装所需的模块。

如果你已经使用了一段时间的 Docker,你会有很多你可能不需要的镜像。有一系列的脚本定期运行(例如从 cron 作业中)为你做清理工作是很方便的。你显然可以使用 Raku 来清理 Docker 镜像。

考虑所谓的 dangling images,这是构建图像的部分结果的图像或图层,并不是任何已标记的图像的一部分。这意味着再往后的构建失败了。你仍然可以使用这些图像,事实上,有些东西可能已经建立在它们上面。例如,如果你创建了一个通过ID调用这样一个图像的容器,你可能会使用它们。

让我们使用这个 Raku 脚本来摆脱任何挂起的镜像。

use Docker::API;
my Docker::API $docker-api .= new;
my @images = $docker-api.images( dangling => True )[];

for @images -> %i {
    say "Trying to delete %i<Id>";
    $docker-api.image-remove( name => %i<Id> );
    CATCH {
        default {
            if .message ~~ /"being used"/ {
                say "Image %i<Id> not deleted, since it's being used";
            }
        }
    }
}

和往常一样,这是一个非常短的脚本,它能很好地完成工作。它基本上是三行代码,加上定义、消息和赋值。

前两行创建了一个 $docker-api 对象,它是 Docker API 的面向对象接口。它不能做任何 Docker 命令行不能做的事情,但它可以方便地处理图像,获取它们的信息,或者删除它们,这就是我们将要做的事情。

我们将只获取悬空的镜像:$docker-api.images( dangling ⇒ True )[]。所有 API 命令都可以包含一个过滤器,这将使它只返回那些具有特定属性的镜像。@images 数组将只有这些图片,以及所有的属性,尤其是ID,这是我们用来删除它们的句柄。

在这些镜像的循环里面,我们发出相应的命令,通过ID来删除这些图片。但是,有时候会失败,而且会出现异常。这就是为什么我们有 CATCH 块的原因:如果命令失败了,模块会捕捉到这个异常,并打印一个有用的信息,继续进行下一个删除(而不是以错误的方式停止程序)。

在我的系统中,它打印的内容是这样的。

Trying to delete sha256:d20dfd5d508913ce0516b16f16f05cef5a9eac541372284db080f634d2e77b94
Image sha256:d20dfd5d508913ce0516b16f16f05cef5a9eac541372284db080f634d2e77b94 not deleted, since it's being used
Trying to delete sha256:47306096984d543f8c6fcfbedabbfb49debc9eb81fbd8605fe7a1ca517a0d593
Image sha256:47306096984d543f8c6fcfbedabbfb49debc9eb81fbd8605fe7a1ca517a0d593 not deleted, since it's being used
Trying to delete sha256:ab6c614ae59e2a640662179ad1e311f0c542f8404b9cb92935cc2e4f31cc9eae
Trying to delete sha256:22c0389a61f83e2f1d9a4ad16b7d2f9790dfd0458defbc2b687e8acfa75c590b
Trying to delete sha256:bf9a0c98b8a3dd48a313642dd014759dfe36625e0b2a2c0b35ed3c8d095b6e6c

这表明,大部分的悬空图像都被删除了;但是,有些图像无法删除,我们已经被告知了。

事实上,Docker::API 是一段不错的代码,但由于我们也知道如何与命令行程序交互,所以我们可以直接用命令行工作。这个 API 简化了交互,给 Docker 提供了一个类似于 Raku 的接口,这样我们就不用担心命令行语法及其标志的复杂性,我们也可以处理来自我们程序的错误。

6.5. 食谱 6-5. 处理最后一个登录你系统的人

6.5.1. 问题

作为系统管理员,你需要知道最后连接到系统的人,以防止入侵或检查系统的使用情况。

6.5.2. 解决方法

使用 Sys::Lastlog,生态系统中的一个模块。它是如何工作的

作为一个 Linux 系统的系统管理员,你可以随时发出 lastlog 命令,它将告诉你用户表中的每个用户最后一次登录的时间。然而,你可能无法访问控制台,或者可能希望有程序访问权,这样它就会被记录下来,它就会发出警报,或者其他什么。

Sys::Lastlog 来救你了。这是一个基于 UNIX 系统(包括 OSX)的接口,可以帮助你处理日志。

首先,你必须做一些事情让它在 Docker 容器内工作,因为用户实际上不会在容器内登录。让我们用下面的方法来运行它。

docker run -itu root -v 'pwd':/home/raku --entrypoint sh jjmerelo/raku-recipes:Chapter6

这样,你就可以用超级用户的权限(就是 -itu rootu root 部分)来运行。你需要为一个用户创建一个密码,这样你就可以用它来登录。例如:

passwd rakurecipes

(在这个容器中定义的用户是 raku,你可能不想为这个用户设置密码。另外,你也可以使用一些其他的空闲用户,比如 uucp 或 www-data。) 你可以给它任何你想要的密码并重复它。然后你可以从命令行登录。

login rakurecipes 然后做任何事情或注销,这并不重要。

如果你使用的是像 Ubuntu 这样的 UNIX 系统,你不需要这样做,但是如果你使用的是 Cygwin 或者 Windows Linux 子系统,你可能需要做类似的事情。

例如,让我们尝试获取一个登录用户的列表以及他们登录的时间。这个脚本可以做到这一点。

use Sys::Lastlog;
say .user.username, ", ", .entry.timestamp
    for Sys::Lastlog.new().list.grep: *.entry.time > 0;

其实这只是一行代码,但我们先来研究一下循环。Sys::Lastlog 是一个面向对象的接口,所以我们需要创建一个对象来使用它。一旦我们有了这个对象,我们就通过 .list 方法获得系统中最后一次登录的列表。这些元素中的大部分将是从未登录过的用户(例如 daemon 或 backup 等系统用户),所以我们需要只过滤那些至少登录过一次的用户。通过使用 grep,我们只得到列表中最后一次登录时间大于0的元素。*.entry 将是一个类型为 Sys::Lastlog::Entry 的对象,它代表 lastlog 文件中的每一个元素。其中一个元素是时间,如果该用户从未登录过,则时间为0。

在循环里面,我们隐式地使用隐式变量 $_;即 .user

username 相当于 $.user.username,而 $.user 将是一个类型为 Sys::Lastlog::UserEntry 的对象,它将包含该条目所涉及的用户信息,从系统用户表中收集。.entry.timestamp 将以标准格式打印最后的登录时间。在我们的容器中,我们可能会看到这样的内容。

rakurecipes, 2020-02-15T17:19:12Z

而在我自己的系统中。

jmerelo, 2019-05-20T21:43:46Z

一般来说,在 Raku 生态系统中寻找能够完成常规系统管理任务的脚本是个好主意。如果这样的脚本不存在,你可以创建自己的脚本。你将在下一章中看到如何做到这一点。

7. 模块

编程涉及到创建抽象层,编程语言提供了创建类和函数的互锁和可堆叠层的方法,因此程序员可以用更高层次的构造来思考,并尽可能快地完成工作。这些模块应用可以在 Raku 中以许多不同的方式实现。一旦实现了这些模块,我们鼓励你将你的模块发布到不断增长的(规模和功能)Raku 生态系统中,这样其他人就可以从你所做的事情中受益。

7.1. 食谱 7-1. 在 Raku 中设计类、角色和模块

7.1.1. 问题

你需要将一组功能打包到一个类或角色中,以便它们可以轻松地在其他程序中使用。

7.1.2. 解决办法

Raku 包含了一个非常广泛的对象模型,其中包括类和角色,它们是数据和功能集,你可以将它们组合到其他角色或类中。

7.1.3. 它是如何工作的

Raku 提供了许多不同的方式来进行这种包装。除了现成的类、包和角色之外,还有一个元对象协议,让你有能力创建新的包装功能。

让我们从简单的东西开始:模块。例如,在前面的章节中,你使用了 Raku::Recipes,这个模块包含了一组之前在其他食谱中使用的例程。下面是它的样子(为了简洁起见,实际的例程代码已经被抑制了)。

use Text::CSV;
# Utility functions for the Raku Recipes book
unit module Raku::Recipes;
our %calories-table is export;
our @products is export;
# Parses measure to return an array with components
sub parse-measure ( $description ) is export {...}
# Returns the table of calories in the CSV file
sub calories-table( $dir = "." ) is export {...}

multi sub optimal-ingredients( -1, $ )  is export  { return [] };

multi sub optimal-ingredients( $index,
                               $weight where %calories-table{@products[$index]}<Calories> > $weight )  is export  {...}

multi sub optimal-ingredients( $index, $weight )  is export  {...}
multi proteins( [] ) is export { 0 }
multi proteins( @items )  is export  {...}

模块的名称是在顶层单元模块 Raku::Recipes 上声明的(几乎),就在第一行之后,它声明了你要使用的模块。使用关键字将所有从安装的模块导出的函数导入到当前的作用域。这正是 is export 声明的内容:这些方法和两个变量是可以导出的。就方法而言,它们实际上只有在用于调用它们的对象的上下文中才是可导出的。它们是通过使用 traits 来实现的,traits 是在编译时附加在对象(如例程)上的属性。在本例中,语句确认导出属性将被附加到我们要导出的例程上。

这意味着,它们将在导入的地方显示出来,就像它们在同一个作用域中被声明一样。在两个导出变量的情况下,它们是用 our 声明的,说明它们可以从导入它们的地方被访问。如果需要的话,可以通过使用它们的全称来实现,比如 @Raku::Recipes::products

我们的关键字表示它们可以被导出,但如果它们不能被导出,则被用作本地范围的变量。在这种情况下,所有的例程和变量都会被导出。如果我们想保留一些私有的东西,我们干脆不把这个特性添加到对象中,而是用 my 来声明。

请注意,实际上,use 做了两件事:它使所有具有包作用域的对象(也就是用 our 声明的对象)可以访问,并将它们导入到当前的命名空间中,使它们可以访问,而不必使用它们的完全限定名。我们仍然能够访问在其 FQN-Raku::Recipes::<%calories-table> 下声明为 our 的两个变量—​例如,即使它们没有被导出。

unit 关键字是用来简化模块(或类)的声明的,它只是说声明紧随其后,接下来将是包中包含的任何东西(可以是一个模块、一个角色或一个类)。如果不用 unit,我们就要用大括号把模块代码和声明包起来,就像这样。

module Raku::Recipes {
 # Code and declarations would go here...
}

这个关键字只是为我们节省了一点打字的时间,并且通过避免括号(和缩进)来清理代码。

模块名称的组织方式是通过命名空间,在这种情况下,声明这个模块会自动声明一个 Raku 命名空间(它将用于存储该命名空间中所有模块和变量的名称)。导入模块也会声明一个带有模块名称的命名空间。习惯上也是将包存储在文件中,其文件名为包中的最后一个名字(最后一个双冒号后面的那个),并存储在反映命名空间其他部分的目录层次中,以及 lib 顶级目录以下的所有内容。在这种情况下,文件将被称为 lib/Raku/Recipes.pm6(自 2019 年底以来,也可以使用 lib/Raku/Recipes.rakumod)。

这个包的主要问题是,我们有几个包宽的变量,必须从外部初始化,而且也要从例程中包范围和使用。这是一种方法,但是有很多方法,所以后来我们把同样的功能重新打包在一个类里,我们把这个类叫做 Rakudo::Recipes::Classy。还有就是方法和属性的定义,其中和上次一样,占用一行以上的代码都用省略号代替了(你可以在本书的代码库中查阅)。

use Text::CSV;
use Raku::Recipes;

# Utility functions for the Raku Recipes book
unit class Raku::Recipes::Classy;

has %.calories-table;
has @.products;

method new( $dir = "." ) {...}
multi method optimal-ingredients( -1, $ ) is export { return [] };
multi method optimal-ingredients( $index,
                                  $weight where %!calories-table{@!products[$index]}<Calories> > $weight ) {...}
multi method optimal-ingredients( $index, $weight )  {...}
multi method proteins( [] ) { 0 }
multi method proteins( @items )   {...}
method products() { return @!products };
method calories-table() { return %!calories-table };
method filter-ingredients( Bool :$Dairy, Bool :$Vegan, Bool :$Main, Bool :$Side, Bool :$Dessert ) {...}

这个类提供的功能是一样的,只是由于在 Raku::Recipes 中已经定义了一个函数,并且没有使用对象属性,我们只需要从 Raku::Recipes 中导入即可。我们还需要添加几个实用方法,product 和 calories-table,它们将返回该实例的这些属性值。

包的定义方式与我们用模块而不是类的方式相同:我们使用单元来创建一个范围。例程现在是方法,包范围内的变量现在是对象属性。然而,我们需要一种方法来实例化它们。在 Raku 中,任何方法都可以返回特定类的对象。有一个默认的新构造函数,它将属性作为命名参数。在这种情况下,我们需要从一个目录位置实例化对象,并且不会事先知道这些值。然后我们声明自己的对象构建器,它将执行同样的功能。

然后我们就可以像之前那样创建这个类的对象。

say Raku::Recipes::Classy.new.products;

这将在飞行中创建一个对象,从默认位置或环境变量中定义的数据文件中读取数据文件并打印出来。

这个类可以通过子类来扩展,Raku 允许新类的对象继承接口以及属性。但是现代设计更倾向于组成而不是继承。这意味着通过组合几个现成的对象来创建新的类。Raku 也可以使用角色来实现这一点。角色是可组合的类;它们有属性和方法,可以一起创建一个类。如果角色使用了存根方法(没有实现的方法,当它们被组成时就会强制重新实现),那么它们就定义了接口,但也提供了自主的有意义的功能。存根方法可以与更多的代码或角色组合,创建一个完整的类。

例如,我们可以创建一个包含配方中的成分的角色。就目前而言,配料将是一个未确定的对象数组。我们唯一需要的功能,除了计算配料的数量,就是一个 gist 方法,将配料打印成一个用换行符隔开的带项目符号的列表。

#| Role that describes generic recipe ingredients
unit role Raku::Recipes::Ingredients;

has @.ingredients;
method how-many { return @!ingredients.elems }
method gist {
    return @!ingredients.map( "* " ~ * ~ "\n").join;
}

这个角色可以用来做几件事。例如,它可以用来创建一个购物清单(这将增加检查哪些项目已经购买的功能),但我们将用它来创建一个包括食谱名称和描述的食谱。

use Raku::Recipes::Ingredients;

#| A class with a fragment of a recipe: description + ingredients
unit class Raku::Recipes::Recipe does Raku::Recipes::Ingredients;

has Str $.title;
has Str $.description;

method gist {
    return "#$!title\n\n$!description\n\n## Ingredients\n "
        ~ self.Raku::Recipes::Ingredients::gist;
}

Raku::Recipes::Amates 的属性和方法被整合到这个类中。一个类的实例会有 how-many 方法,如果需要的话,它会有 gist 方法。然而,我们需要以其他方式打印食谱,以模拟 Markdown(mockdown?类中声明的属性直接使用,我们也可以用 @!ingredients 来做,它本身就是一个属性。然而,我们为什么要扔掉一个完美的、在角色中声明的要旨方法呢?我们把它纳入到这个方法中,用它的完全限定名明确地调用它,其中包括它被声明的角色的名称。

一般来说,角色不应该是可实例化的。然而,事实上,Raku 允许你对它们进行实例化(在一种叫做 punning 的机制中),甚至可以对它们进行子类化。

role A { has $.foo = "Foo" };
role B is A { has $.bar = "Bar" };
say A.new; # OUTPUT: A.new(foo => "Foo")
say B.new; # OUTPUT: B.new(bar => "Bar", foo => "Foo")
role C does B { has $.baz = "Baz"};
say C.new; # OUTPUT: C.new(baz => "Baz", bar => "Bar", foo => "Foo")

因此,虽然类不能被组成,但角色可以被子类化、实例化(在一个叫做 punning 的过程中)和组成,所以在许多情况下,最好围绕角色而不是类来创建你的应用程序。

我们正是要这样做—​让我们重构 Classy 类,以便将大部分功能剥离到一个角色上,我们将把它称为 Raku::Recipes::Roly。它包含在这里。

# Utility functions for the Raku Recipes book
unit role Raku::Recipes::Roly;

has %.calories-table;
has @.products;

method new( $dir = "." ) {...}
method products() { return @!products };
method calories-table() { return %!calories-table };

现在我们以前的 Raku::Recipes::Classy 类会是这样的。

unit class Raku::Recipes::Classy does Raku::Recipes::Roly;

# "method new" and attributes used to be here...
multi method optimal-ingredients( -1, $ ) is export { return [] };

# Everything below is the same, except for the products and calories-table methods....

新的 Raku::Recipes::Classy 确实,也就是说,包括或由 Raku::Recipes::Roly 组成。这实际上是对旧方法的一个插件替换:其他任何东西都不需要修改,我们将能够像以前一样完全使用那个类,只是我们现在多了一个角色,如果我们愿意,我们可以把它作为一个独立的对象使用,或者把它组合到其他类中。

7.2. 食谱 7-2. 记录你的模块

7.2.1. 问题

你需要解释你的模块的使用方法,这样任何想在他们的程序中包含它的人都知道该怎么做。你还需要让它可以被搜索到,并包含一些说明性的例子来说明如何使用它。

7.2.2. 解决办法

Raku 中的注释是智能的,这意味着它们与程序的其他部分一起编译。在正常注释中的一些格式标记(使用哈希标记,#)将被解释为为方法或它周围的数据添加文档。此外,Raku 使用自己的标记语言,称为 Pod6,它可以与代码一起添加结构化文本,也可以在一个独立的文件中使用。

7.2.3. 它是如何工作的

记录你的工作是很重要的,如果你要向世界发布你的模块,或者只是打算以后再来做,那么记录你的工作是必不可少的。模块需要被记录在案,以便客户了解它们做了什么。每一个方法和函数也需要被记录下来,这样才能清楚地解释它们的行为。

Raku 代码可以通过哈希标记进行注释。这样的注释会延伸到行末,不会产生任何形式的代码或行为变化。

# this would be a comment

我们也已经看到,当用 -h 调用命令行时,MAIN 例程会自动生成文档并打印出来。此外,我们将以接下来所示的方式记录之前创建的角色,使用一种特殊的语法向 Raku 表明,当询问例程或变量的信息时,打算打印注释。

#| Utility functions for the Raku Recipes book
unit class Raku::Recipes::Classy does Raku::Recipes::Roly;

#| Compute optimal ingredients, knapsack style
multi method optimal-ingredients( -1, $ )  is export  { return [] };
# ...
#| Adds up the amount of protein for the items in the argument.
multi method proteins( @items )   { ... }
# ...
#| Filters ingredients by type
method filter-ingredients( Bool :$Dairy,
                           Bool :$Vegan, Bool :$Main, Bool :$Side, Bool
                           :$Dessert ) {...}

同样,这段代码也是书库中代码的一部分,这里只展示了相关部分。不过,其基本思想是主动注释的概念。这些在哈希标记后面使用管道符号(|)的注释被称为声明者块,它们与紧随其后声明的方法、包或例程直接相关。第一个将被附加到类上,其余的将被附加到它们下面的相应例程上。它们之所以是活动的,是因为它们可以作为元数据的一部分,使用 WHY 方法进行访问。

use Raku::Recipes::Classy;
say Raku::Recipes::Classy.WHY;
say Raku::Recipes::Classy.^lookup('filter-ingredients').WHY;

这将打印以下内容:

Utility functions for the Raku Recipes book
Filters ingredients by type

每一行都将是其中一条指令的结果。让我们更仔细地看看这段代码。首先你会注意到 WHY 方法中使用的全大写。这并不是异想天开,它对应的是 HOW 函数使用全大写的命名惯例。

好吧,也许这有点异想天开,包括使用 HOW 和 WHY 的约定。但你能从一个以彩虹蝴蝶为吉祥物的语言中期待什么呢?

HOW 方法回答的是关于元对象本身的问题,而不是其中的数据。它代表着高阶工作,也可能只是一个重名,所以它的名字符合这个 HOW 中包含的 WHAT 和 WHY。而 HOW 是功能或元对象协议(MOP)的通用名称。在 Raku 中,你不仅可以通过使用这个 MOP 来创建具有某种行为的类型对象(如可组合性),而且你还可以查看现有对象(包括类型对象)的底层,并查看它们是什么,它们的作用,或者,在这种情况下,它们的文档是什么。

WHY 访问 Raku::Recipes::Classy 类型对象的文档,它通过调用类型对象本身的方法来实现。

这也是对下一条语句的介绍,下一条语句是调用 why,但使用的方式不同。我们先来看看为什么要这样做,归根结底是没有其他方法可以访问类中的方法对象。例程可以很容易地访问,因为只要在其前面加上 &,就可以访问 HOW 的高阶工作原理。例如,你可以很容易地访问一个模块的 HOW 和其中声明的例程。我们在 Raku::Recipes 模块中使用了这些声明块。

#| Utility functions for the Raku Recipes book
unit module Raku::Recipes;
# ...

#| Parses measure to return an array with components
sub parse-measure ( $description ) is export { ... }

可以通过以下方式进入:

use Raku::Recipes;
say Raku::Recipes.WHY;
say &parse-measure.HOW;
say &parse-measure.WHY;

这将打印以下内容:

Utility functions for the Raku Recipes book
Perl6::Metamodel::ClassHOW.new
Parses measure to return an array with components

在例程名称前使用安括号来描述容器本身,使用它,你可以访问它的 HOW(虽然如图所示,它只持有私有变量)或它的 WHY,即带有例程文档的元数据。然而,这个对象允许对它的内容进行一点反省。例如,你可以发现它是一个什么样的对象。

say &parse-measure.HOW.name(&parse-measure);
say &parse-measure.^name;

这两句话打印的内容完全一样-- "Sub"。事实上,这是相同的代码。第二个版本在方法名前面加上了有趣的小圆点,并且取消了对象名的重复。这只是第一部分的语法糖。简而言之,方法名前面的逗号是用它左边的对象作为参数调用该名称的 HOW 方法。

这算是对我们第一个例子中下一句话的绕口令。

say Raku::Recipes::Classy.^lookup('filter-ingredients').WHY;

你已经明白这个语法了。我们是用类的名称和我们要查询的方法的名称来调用 HOW 类的查找方法。我们需要这样做,因为没有简单的语法来引用类中方法的名称,或者说,引用对象的名称。首先,有歧义:如果我们在前面拍打 &,我们真正的意思是什么?通过这种有点啰嗦的语法,我们获得了对该特定类的方法对象的引用,然后我们可以在其上调用 why 或其他任何东西。

这段额外的代码:

say Raku::Recipes::Classy.^lookup('filter-ingredients').^name;
say Raku::Recipes::Classy.new.^lookup('filter-ingredients').HOW;

将返回以下内容:

Method
Perl6::Metamodel::ClassHOW.new

我们拥有的是一个 Method,而它的 HOW 就是类的元模型,和上一个例子一样。

注意 你可能会好奇为什么子和方法共用同一个 HOW,也就是类的 HOW,ClassHOW.这是因为子和方法都是类,简单的说,在 raku 核心中 HOW 的数量是有限的,但这不在本章的讨论范围内。

你可能已经注意到了(如果你没有注意到,本章的审稿人可能注意到了),我们没有提到多模块的声明块。这是因为我们需要一些关于 how 如何工作的信息才能到达它。但我们现在已经有了这些信息,所以让我们潜入。如果我们查找多方法的名称会发生什么?

say Raku::Recipes::Classy.new.^lookup('optimal-ingredients').raku;

这返回:

proto method optimal-ingredients (::T : |) {*}

这是我们在该名称下声明的所有三个方法的原型。 lookup 只会返回它被调用的名称下的第一个方法。我们实际上还没有声明这个方法;试图访问它的 WHY 将返回一个空字符串。我们如何在网上为它添加文档呢?只要把 proto 记录下来就可以了。

#| Uses the knapsack algorithms to compute the ingredients that maximize protein content.
proto method optimal-ingredients (Int,$) {*}

然后使用与其他方法相同的机制来打印它。

say Raku::Recipes::Classy.new.^lookup('optimal-ingredients').WHY;

这将打印前一个块的内容。

其余的定界符块在不同的 multis 中会发生什么?我们可以删除它们吗?我们应该删除吗?不,因为文档是好的。还有,请继续阅读。

使用程序中的定界符块并不是检查文档的唯一方法。如果你要花额外的精力去实际检查里面的内容,你就不会花那么多心思了。文档是很重要的,它是代码不可分割的一部分,这也是为什么它可以直接从命令行中显示出来。

raku --doc=Text lib/Raku/Recipes.pm6

这将显示以下内容:

module Raku::Recipes

Utility functions for the Raku Recipes book
sub parse-measure(
        $description,
)

Parses measure to return an array with components

让我们把它分解一下。我们从命令行调用 Raku,使用 --doc=Text 作为参数和我们需要打印文档的模块名称。这个标志,--doc,是一个很聪明的标志:它检查是否存在一个叫做 Pod::To::XXX 的模块,其中 XXX 是传递给它的参数,在这个例子中是 Text。然后它使用一个特定的函数来传递模块。该函数提取所有的活动文档,并以所示的方式打印出来:下一行是 documented unit 和 document。在这个模块中,模块本身以及 parse-measure 子都被记录下来。名称以及签名(如何调用)被显示出来,后面是声明者块的文本。这是一种快速且无依赖性的记录代码和显示文档的方式。

但事实上,我们调用 Raku 表明我们实际上是在编译文本,只是我们不是运行文本,而是通过指示的过滤器来渲染文本。编译就是编译,如果你需要用一个外部的、依赖的模块来渲染成文本的东西,你将不得不写这样的东西。

raku --doc=Text -Ilib lib/Raku/Recipes/Classy.pm6

附加的标志 -Ilib 将告诉 Raku 在哪里寻找 Raku::Recipes::Classy 使用的模块,即目录 lib。关于 Pod::To::Text 的好处是,它并不关心获取 multis 的声明块的复杂性。它将渲染每一个 multi 的实例和它的签名。

文档不需要减少到一行,尤其是不需要减少到一个长行,这会让大多数编辑器难以处理。有办法可以包含几行,甚至每个方法包含几条文档。我们将在 Raku::Recipes::Roly 类中进行尝试。

# ... (some more code above and below suppressed for brevity) ...
#|[
Creates a new calorie table, reading it from the directory indicated,
the current directory by default. The file will be in a subdirectory data,
and will be called calories.csv
]
method new( $dir = "." ) { ... }
#| Basic getter for products
method products () { return @!products };
#= → Returns an array with the existing products.

对于第一条注释,我们使用多行注释。这些注释可以使用任何类型的大括号,只要它们是成对的。在这种情况下,我们使用方括号,但也可以使用大括号或括号,只要它们是成对的。在第二种情况下,我们使用了页脚注释的语法;这些注释被附加到它们所跟随的例程上,就像 #| 引导它一样。当渲染这个块的文档时,它们将互相跟随,这样。

method new(
    $dir = ".",
)

这将创建一个新的卡路里表,从指定的目录中读取,默认为当前目录。该文件将在子目录 data 中,并将被称为 calories.csv。

method products()
Basic getter for products
→ Returns an array with the existing products.

但我们可以做得更好,在模块中加入整块的格式化文档。到目前为止,我们看到的是每个子方法和包的文档,但我们无法记录类属性或 multis。我们需要一种方法来记录整个模块。解决方案就是 POD6,它是一种语言,其实也就是乐界所说的俚语或辫子。它是一个更大的语言(Raku)中的一个迷你语言,用来写文档。我们会把它添加到使用它的 Raku::Recipes::Roly 上面。

=begin pod

=head1 NAME

Raku::Recipes::Roly - Example of a Role, which includes also utility
functions for Raku Recipes book (by Apress)
=head1 SYNOPSIS

=begin code
use Raku::Recipes::Roly;

my $rrr = Raku::Recipes::Roly.new; # "Puns" role with the default data dir

say $rrr.calories-table; # Prints the loaded calorie table
say $rrr.products;       # Prints the products that form the set of
ingredients
=end code

=head1 DESCRIPTION
    This is a simple data-loading role that can be combined into classes that will deal with tables of ingredients, every one with tabular data.

=head1 CAVEATS

The file needs to be called C<calories.csv> and be placed in a C<data/> subdirectory.

=end pod

#| Basic file-loading role
unit role Raku::Recipes::Roly:ver<0.0.2>;

当我们在这时,我们也修改了上面那行,现在包括一个版本。Raku 允许发行版(包括几个包)和包(比如这个角色)的版本。这使得它很容易有几个版本的模块同时存在,但从我们的角度来看,它只是简单的元数据,帮助我们(和用户)知道我们正在处理哪个版本的功能。

Pod6 头在上面,它以 =begin pod 开始,以 =end pod 结束。

注意 当你开始使用像这样更高级的语法时,你一定要使用 Comma Ide.到目前为止,正常的语法在普通的编辑器中已经被 raku 模式(或者,有些人仍然被称为 perl 6 模式)覆盖得比较好。但是当你开始混合这些俚语的时候,最好使用特定的 Ide 来处理。

POD6 块(当你读到这里的时候,它可能已经改了名字,因为它仍然带有数字 6 的色彩)使用这种语法来标记开始和结束。一个文档中可能会有几个 POD 块,在这种情况下,只有一个。

在一个 pod 块中,=begin=end 仍然会标记不同类型块的限制。例如,我们用来说明几个例子的代码块。

其他标记会在覆盖一整行的时候使用 =:例如,=head1 用于标题。此外,单词或句子级别的标记会使用字母和角括号,比如我们在 C<calories.csv> 中添加的代码标记。

需要注意的是,完整的语法可以参考 《Perl 6 快速语法参考》等书籍,当然还有 https://docs.raku.org 的文档。

在这个 POD 块中,我们添加了描述一个包的常用部分:名称、关于如何使用它的 SYNOPSIS、DESCRIPTION 等等。我们没有添加每个方法的部分,而是重用了我们之前添加的智能注释。

请注意,这些部分是用大写的,这表明了 perl 的传统,最初的文档标记,叫做 pod,使用了这种约定。

你可以再次在模块上使用 raku -doc=Text 现在它将以文本形式呈现 POD 块,然后是附加到所有不同角色元素的定义块。

这样,你就涵盖了你的包中所需要的所有文档。在你的所有包中始终使用文档,并测试一切都有文档。而说到测试,我们将在下一个配方中看到如何测试模块。

7.3. 食谱 7-3. 测试你的模块

7.3.1. 问题

你需要检查一个模块、类或角色的代码是否符合预期。

7.3.2. 解决办法

Raku 包含了一个名为 Test 的断言模块,它可以用来编写关于函数结果的断言,以及规划和管理测试的附加函数。使用该模块的脚本可以直接运行,也可以使用发布管理器 zef 进行处理。

7.3.3. 它是如何工作的

Raku 中包含的测试模块有一系列结构相同的函数。它们获得一个或多个参数,并根据获得的输出测试预期的输出。最后一个参数是一个描述测试本身的消息,帮助开发者理解什么地方失败了。这里是 Raku::Recipes::Roly 的测试。

use Test; # -*- mode: perl6 -*-
use Raku::Recipes::Roly;

my $rr = Raku::Recipes::Roly.new( "." );

my @products = $rr.products;
my %calories-table = $rr.calories-table;

subtest {
    is( %calories-table{@products[0]}<Dairy>, True|False, "Values processed" );
    cmp-ok( @products.elems, ">", 1, "Many elements in the table" );
},  "Products";

subtest {
    ok( %calories-table<Rice>, "Rice is there" );
    is( %calories-table<Rice><parsed-measures>[1], "g", "Measure for rice is OK" );
}, "Calories table";

done-testing;

另外,当我们在写模块的时候,我们可以创建一个测试,只检查它是否可以正确加载,也就是语法上是否正确。

use-ok("Raku::Recipes::Roly");

我们跳过这些,直接去查看角色的功能。它有三个 --newcalories-tableproduct

所有的测试都包括一个设置阶段;在这个阶段,我们创建我们要测试或需要测试的对象。在这种情况下,我们实例化(通过 punning)一个该角色的对象,然后实例化两个变量,这两个变量是调用我们将测试的另外两个方法的结果。然后三个方法都被调用,但结果是否正确呢?

我们将四个测试分为两个不同的子测试:一个是针对产品的测试,另一个是针对 calorie-table 的测试。如果其中一个测试失败,子测试就会失败;这只是一种方便的表达方式,即一个功能块在该功能块的所有测试都是绿色的情况下才算完成。子测试使用一个代码块和一个描述该块的消息。

我们使用三种不同的断言。ok 断言是最简单的:如果被测试的对象是 trueish,它就会通过;如果是 falseish 或未定义,它就会失败。还有一个等价的,但是相反的测试,叫做 nok。下一个,是,用 eq 操作符来比较预期和结果。另一个使用的 cmp-ok,也是一个比较,但它需要三个参数,中间一个是我们将使用的比较操作符,我们将把它作为一个字符串传递。

所有的测试都可以用 zef test 来运行。(最后一个是当前目录); zef 很聪明,它加载所有在 lib/ 路径中找到的模块,以便测试 t/ 目录下的所有测试。我们也可以用独立的方式运行,但我们需要指定测试模块所在的路径。

raku -Ilib t/01-recipes-role.t

测试脚本通常采用 .t 扩展名,它们位于 t/ 目录下。这将为前面的代码(以及本章开始时定义的角色)打印以下内容。

    ok 1 - Values processed
    ok 2 - Many elements in the table
    1..2
ok 1 - Products
    ok 1 - Rice is there
    ok 2 - Measure for rice is OK
    1..2
ok 2 - Calories table
1..2

说明这两个子测试是正确的,其中的每个测试也是正确的。

正如所指出的那样,文档是很重要的,所以我们至少需要测试,对于每一个类,都有一个 POD 块来描述它的作用。然而,文档并不是以任何变量导出的。如果我们想检查 POD 块,我们必须显式地导出它们。让我们对 Raku::Recipes::Roly 类这样做。

our $pod is export = $=pod[0];

这是一个模块或包的范围变量,在角色声明之前声明。

注意角色不能有我们的作用域变量。在类的情况下,我们可以把它包含在类中,作为一个具有类作用域的类变量,并通过它的全称来使用它而不需要作用域。

一旦我们导入角色,这个变量就会被插入到我们的命名空间中,所以这将测试这个文档的存在。

subtest "There's documentation for the module", {
    ok $pod, "There's documentation";
};

以同样的方式,我们可以测试声明者块的存在,在这种情况下,对于 Raku::Recipes::Classy。

subtest "Test declarator blocks", {
    ok Raku::Recipes::Classy.WHY, "Class described";
    ok Raku::Recipes::Classy.new.^lookup('filter-ingredients').WHY,
    "Declarator blocks retrieved";
}

我们以前面关于文档的配方中描述的方式访问声明者块。这样我们就可以确保通过实现漂移来维护描述。除了存在,我们还可以用其他的断言来检查某些词是否在里面,但测试文档的基本机制是存在的。文档被理解为代码,未经测试的代码是坏的。因此,未经测试的文档也是坏的。那么我们就在我们的发行版中全部测试一下吧。

7.4. 食谱 7-4. 将你的模块作为一个开源模块发布

7.4.1. 问题

你解决了一个原创性的问题,或者用一种新的方式解决了一个已知的问题,并且希望将你的解决方案用开源许可证发布出去,这样它就可以随时供大家使用,包括你自己在不同环境下的应用。

7.4.2. 解决方案

Raku 生态系统有两种发布模块的方式,可以通过 zef 下载模块—​一种叫做 CPAN,它存储了你的发布版本的 tar 文件;另一种叫做 p6c,它直接从 GitHub、GitLab 或 BitBucket 仓库下载代码。我们将重点讨论第二种。

除了决定在哪里发布之外,我们还需要包含额外的元数据,这些元数据包含在我们已经看到的 META6.json 文件中。我们将提供一份清单,列出发布发行版所需的所有字段。

7.4.3. 它是如何工作的

首先你需要决定的是你要为模块使用的许可证。一般来说,开源是免费的,所以你需要选择一个能明确用户可以对代码做什么的许可证。所有的许可证都有一个规定,即原作者要得到承认,但有些许可证对用户可以用代码做什么的限制比其他许可证更严格。

传统上,包括 Raku 在内的 Perl 世界使用所谓的 Artistic License;2.0 版本是为 Raku 创建的。你需要用一些法律术语来理解这个许可证和 MIT 许可证之间的差异。我只想说,这是一个相当宽松的许可证,而且无论如何,你的著作权都会得到尊重。

你可能在创建 repo 时就选择了一个许可证,所以你可以坚持使用这个许可证。改变它只需要在一个叫 LICENSE 的文件中改变许可证的大量文本;每个模块的 POD 块中都应该包含一个关于许可证的说明,这很方便。你也需要把它作为元数据放在 META6.json 文件中。

这就是本章描述的发行版所使用的示例文件。

{
    "authors" : [
    "JJ Merelo"
    ],
    "auth": "github:JJ",
    "description" : "Raku Recipes, modules and examples",
    "license" : "Artistic-2.0",
    "name" : "Raku::Recipes",
    "perl" : "6.d",
    "provides" : {
        "Raku::Recipes": "lib/Raku/Recipes.pm6",
        "Raku::Recipes::Classy": "lib/Raku/Recipes/Classy.pm6",
        "Raku::Recipes::Roly": "lib/Raku/Recipes/Roly.pm6"
    },
    "tags" : [
    "book","Apress", "Raku", "examples", "recipes", "cooking"
    ],
    "depends" : [ "Text::Markdown",
                  "Text::CSV" ],
    "source-url" : "https://github.com/JJ/raku-recipes-apress",
    "version" : "0.0.2"
}

并非所有的模块都是必须的,但大多数模块在发布时都很方便,可以在 modules.raku.org 网站上阅读。它列出了所有可用的模块(在写这篇文章的时候大约有 3000 个)。

  • 作者。参与模块开发的所有人员的数组;你可以使用真名或网名。

  • auth 字段略有不同:你可以列出屏幕名,也就是版本库中使用的名字或 CPAN 中使用的名字。这个 将使模块描述链接到开发它的人(或集体)的简介。发行版是由名称和作者来称呼的;你可以 fork 或者干脆从头开始使用相同名称的发行版,如果它们碰巧做了完全相同的事情。Zef 将能够区分它们,你将能够使用相应的语法在本地使用它们。

  • 描述字段是对模块功能的简短描述。尽量优化这个描述,以便于搜索。

  • 许可证字段将包括你正在使用的许可证的标准化名称。正在遵循的标准叫做 SPDX 或软件包数据交换标准,描述在 https://spdx.dev。这个事实上的标准提供了一系列字符串来描述所有可用的开源许可证;Artistic-2.0 将是我们在这里谈到的许可证的 SPDX 名称。

  • 名称字段将有包含所有模块的发行版的名称。在本例中,Raku::Recipes(这也是模块的名称)。如果只有一个模块,模块的名称将放在这里;如果有几个模块,至少有一个模块的名称与发行版相同,这很方便。这个名字将是你的类所使用的命名空间;其他不同级别的类(更高或更低)将使用这个命名空间。在本例中,我们有 Raku::Recipes::Classy 和 Raku::Recipes::Roly。这通常比将它们作为兄弟姐妹更可取,例如在 Raku::Recipes-Classy 中,但你有绝对的自由来命名和放置你的模块,无论你在你的文件夹层次结构中的任何位置。同样,在一个发行版中拥有不同的命名空间也是不方便的。如果其中一个类自然而然地似乎需要另一个命名空间,那么用它创建一个不同的发行版可能是合理的。唯一的例外是例外。Raku::Recipes 的例外将使用 X::Raku::Recipes 命名空间(但稍后我们在即将到来的章节中处理例外时,会有更多的介绍)。

(可能很快就会过时)perl 或 raku 键描述了发行版所能使用的最低版本。目前,只有 6.c(第一个生产版本)和 6.d(截至 2020 年的当前版本)可用。未来的版本将使用不同的字母;另外,这个字段将只接受主要版本。每月发布的版本将不得不在不同的层次上处理。自 2019 年 10 月更名以来,raku 是首选密钥。

提供键需要一个哈希,使用发行版中包含的模块名称作为键,并作为值,在文件系统中使用相对路径可以找到它们。传统上,如果遵循了文件名和目录布局的惯例,会有或多或少的从名字到文件夹的直接映射。提供了这个哈希,说明有不止一种方法,但还是有的。

标签是一个自由形式的数组,以使分布更容易找到。你想用多少标签就用多少标签。

下一个关键,dependence,是一个包含上游依赖的数组。这些发行版需要安装才能运行,我们在模块中使用的就是这些发行版。

source-url 表示可以找到这个发行版的网站的 URL。这将是用来下载它的 URL 或 URI,所以确保可以在那里找到它。它可以是一个带有 tar 文件的 URL,而不是 GitHub repo URL;这样,你就可以同时发布多个版本的发行版。

  • 下一个关键中表示的版本是相当重要的。Zef 在决定是否安装发行版时,会考虑版本。如果生态系统(或 CPAN)上可用的版本高于本地安装的版本,它将被下载并安装。否则,它将拒绝安装,即使代码已经进化,而这个特定的元数据没有改变。这就是为什么每次有任何性质的变化,从增加或过时的功能到修复 bug,都应该改变这些版本。即使内部重构已经发生,而没有任何对外的 API 变化,也是如此。这个版本字符串同样是自由形式的,但大多数遵循语义版本惯例:第一个数字将是主要的,第二个是次要的,第三个是补丁级别。增加它们以反映不同性质的变化。

META6.json 文件是让你的模块发布的必经步骤。测试,如本章所示,也是必须的。你应该测试你的模块的所有功能,如果需要的话,包括文档。如果标准的 Test 模块不够用,你可以尝试使用其他模块,通过文档本身,从输出到连接进行测试(不需要求助于导出$=pod 变量)。 此外,还可以考虑这些建议,虽然它们不是强制性的。

  • README.md 文件应该包含一些文档,包括安装说明(除了使用 zef,如果需要的话;例如,附加的依赖关系)和其他任何你想添加的东西。

  • 添加一个 CONTRIBUTING.md 文件是很方便的,它可以帮助其他人报告 bug 和补丁,并正确创建一个 pull 请求。

在推送和拉取请求完成后,设置一些自动测试。这将省去你手动检查每一个贡献的麻烦(或者在你发布后发现错误)。有很多方法可以做到这一点,从 GitHub Actions 通过 GitLab 管道到 Travis 或 CircleCI。如果你想在 Windows 中测试所有的东西,AppVeyorCI 提供了很好的设施。你的代码将在多个操作系统中使用,所以你应该在所有平台上进行测试。

一旦完成,你需要告诉世界,这个发行版已经准备好被下载了。让我们暂时抛开 CPAN 选项,专注于其他。由于源代码将直接从版本库中下载,所以不需要额外的打包。

注意 你可以,也应该使用 Git 标签将该版本中的最后一次提交标记为与 META6.json 文件中的版本相同的版本。这样一来,生态系统的发布和 Github(或其他)的发布将齐头并进。

然而,有两个步骤是必要的。

  1. 通过在 GitHub 或 GitLab 中点击 META6.json 文件的 URL,然后点击 Raw 按钮来拾取它。这将在屏幕上显示文件的内容。进入地址栏,将其复制到剪贴板。这个 URL 会是这样的:https://raw.githubusercontent.com/JJ/p6-math-constants/master/META6.json

  2. 转到 https://github.com/Raku/ecosystem,编辑其主目录上名为 META.list 的文件。把这一行添加到你认为应该去的地方。最后一行是一个不错的选择。这将会创建一个版本库的 fork,然后从这个版本库中创建一个 pull request,写一些类似添加 My::Distro 到生态系统的内容。这其实并不重要,但让它具有描述性将有助于维护者(比如我自己)评估你是否知道你在做什么。META6.json 文件会进行一些测试;如果它因为某些原因而失败(一些 JSON 语法错误,或者你的许可证的不符合 SPDX 描述),你需要回到你提交的 repo 并修复它。然后你需要在拉取请求中再次修改文件以重新启动测试。简单的添加空格就可以解决这个问题。

通常情况下,它会很快地准备好接受请求,但 zef 要花上几个小时才能赶上。当它完成后,任何人都可以写 zef 安装 Your::Published::Module 并在自己的程序中使用它。

7.5. 食谱 7-5. 使用多个调度来加快应用速度

7.5.1. 问题

根据一个例程得到的参数类型,甚至是它的值,使用不同的代码可能会很方便。这样做可以避免慢速的 if,允许类型安全的代码调度,并使结果应用程序的优化更容易。

7.5.2. 解决方案

在你的类/角色方法或模块例程中通过 multi 使用多重调度。

7.5.3. 它是如何工作的

多重调度是一种机制,语言根据代码的调用方式运行不同的代码。在 Raku 中,不同的例程调用惯例被称为签名,你可以根据签名声明同一个函数的不同版本,这些函数将被调用。

假设我们想计算一定量的食物中的卡路里。我们可以有一个"100g 大米"形式的字符串,或者将数量和产品分开,如 "100g"和"大米",或者有三个独立的信息。我们可以用一个子程序来处理,这样。

sub gimme-calories( $first, $second?, $third?) {
    my ($product, $unit, $how-much);
    if $third {
        ($how-much, $unit, $product) = ($first, $second, $third);
    } elsif $second {
        ($how-much, $unit, $product) = (|parse-measure( $first ), $second);
    } else { my @parts = $first.split: /\s+/;
        ($how-much, $unit, $product) = (|parse-measure( @parts[0] ),
        @parts[1])
    }

    if %calories-table{$product}<parsed-measures>[1] eq $unit {
        return %calories-table{$product}<Calories> * $how-much / %calories-table{$product}<parsed-measures>[0];
    } else {
        fail;
    }
}

第二和第三位置词是可选的,用一个 ? 后缀表示(如 $second?),我们用它们作为卫士来检查我们得到的信息。我们要做的是尝试从这三种可能性中提取相同的信息。我们使用变量重构来同时将三个变量分配给另外三个变量。为了将一个数组扁平化为另一个数组,我们使用滑移操作符 |。在 if 的最后,我们在三个不同的变量中得到了值,我们从中计算出卡路里。中间有一些决策需要一些时间。

众多的 if 和 eles 使得这段代码有点令人费解,而且方法的签名也不是太有信息量,有一些非信息量的名字,可以采取不同的类型。没有对值进行类型检查。它可以工作,但我们可以做得更好。让我们尝试使用 multis。

multi sub how-many-calories( Str $description ){
    return samewith( | $description.split(/\s+/))
}

multi sub how-many-calories( Str $quantities, Str $product ) {
    my ($how-much, $unit ) = parse-measure( $quantities );
    return samewith( $how-much, $unit, $product )
}

multi sub how-many-calories( Int $how-much, Str $unit, Str $product ) {
    if %calories-table{$product}<parsed-measures>[1] eq $unit {
        return %calories-table{$product}<Calories> * $how-much / %calories-table{$product}<parsed-measures>[0];
    } else {
        die "Die $how-much $unit $product";
    }
}

每一个组合都有一个 multi。这些实际上是类型安全的,它们使用一种级联,互相调用到级联的最低部分,这个级联有一个签名,一个 Int,还有两个字符串。其中,我们可以在这里使用默认值,使用更严格的约束,但有趣的是多重调度。我们对函数的使用也是如此。它调用同名的多,但我们给它的签名使它比调用它的名字更快,因为它只需要检查其余同名签名的函数。

代码路径很清晰:每个签名都对应一个入口点,而且它们的选择方式很简单。从软件设计的角度来看,这个是个赢家。但在速度方面,它到底有多快呢?

让我们用这个小基准来检查一下速度。

my @measures = 1000.rand.Int xx 10000;
my @food = <Rice Tuna Lentils>;
my @final = @measures.map( {$_ ~ "g "})  X~ @food;

my $start = now;

for @final {
    my $calories = how-many-calories($_) ;
}
say now - $start;

start = now;
for @final {
    my $calories = gimme-calories($_);
}
say now - $start;

$start = now;
for @measures X @food {
    my $calories = how-many-calories( @_[0].Int, "g", @_[1]) ;
}
say now - $start;

$start = now;
for @measures X @food {
    my $calories = gimme-calories( @_[0].Int, "g", @_[1] );
}
say now - $start;

它要么调用单弦形式,要么调用分弦形式,这样做了 30000 次。在我的系统中,返回的结果是这样的。

3.0829807
3.13236452
0.9351156
1.1699724

所以字符串形式比多元素形式快 1% 左右。这并不是什么值得称道的事情,但三元素形式的速度要快 20% 左右。总而言之,速度上的差异显然取决于你的代码的实际调用组合,但如果你把更好的设计与速度上的差异混合在一起,使用多元素显然是个好主意。

然而,多设计本身并不需要总是更快。例如,我们需要解析我们所使用的数量和单位来计算我们每一种食材的卡路里数量。这些都有两种(或三种)不同的表达方式:其中一种是前面有数量,其他的是简单的描述,比如"单位"。我们是用这个代码来解析的。

sub parse-measure ( $description ) is export {
    $description ~~ / $<unit>=(<:N>*) \s* $<measure>=(\S+) /;
    my $unit = +$<unit> ?? +$<unit> !! 1;
    return ($unit, ~$<measure>);
}

正则表达式变得有点复杂,因为它必须考虑到所有不同的情况。我们还必须找出前面有无数量(我们将其赋给 $<unit> 变量),并在这种情况下赋一个默认值。但显然有两种不同的选择,我们可以在不同的多例程中加快处理它们。就像这样。

multi sub unit-measure ( $description where /^<:N>/ ) is export {
    $description ~~ / $<unit>=(<:N>+) \s* $<measure>=(\S+) /;
    return ( +$<unit>, ~$<measure> );
}
multi sub unit-measure ( $description where /^<alpha>/ ) is export {
    $description ~~ / $<measure>=(\S+) /;
    return ( 1, ~$<measure> );
}

我们需要将它们声明为 multi,以明确它们是同一个例程的两个版本,在调用方式上有所不同。在这种情况下,通过它们被调用的变量的内容。Multis 需要在签名上有所不同,而且签名应该是不相干的。如果它们不是,那么第一个涵盖它被调用的参数的那个将被触发。

在这种情况下,它们是互斥的。要么字符串以数字开头,要么以字母开头。但是当它是一个数字时,我们知道它是如何工作的:至少会有一个类似于数字的字符,然后是空格(或者不是),后面是一个单词。这将涵盖"100g"和"1⁄3 升"。另一方面,如果是以字母开头的,那就干脆就是一个单词了,所以重构词法就大大简化了,我们只需要把它分配到一个变量中,然后通过前缀~来串联。另外,默认值更容易创建和描述,我们也避免了 ifs,这正是我们想要的。

只要你的代码遵循反模式,检查参数的类型或值,然后使用它们调用不同的代码块,这就是调用多调度,通过缩短每个例程的行数来创建更干净的代码。

虽然先验应该如此,但这样真的能加快速度吗?让我们用这个脚本来了解一下。

use Raku::Recipes;

my @measures = 1000.rand.Int xx 10000;

my @units = <g l liter tablespoon>;

my @strings  = @measures X~ @units;
my @things = <Unit Clove Pinch>.pick xx 10000;

@strings.append: @things;

my $time = now;

for @strings {
    my $result = parse-measure( $_ );
}

say Duration.new(now - $time);

$time = now;

for @strings {
    my $result = unit-measure( $_ );
}

say Duration.new(now - $time);

在生成一系列两种可用类型的字符串后,使用两个可用的例程对它们进行检查。在我的系统中,非多路版本大约需要一秒钟,多路版本大约需要三秒钟,给不给。

是什么原因呢?事实上,multi 版使用了两个正则表达式,而 non-multi 版只使用了一个正则表达式。虽然先验调度本身会更快,但你应该始终确保你调度的东西确实能让它更快。所以需要进行一些重构。

这是一个关于始终做我们在上一个配方中所做的事情的教训:测试。还是有更好的架构和更干净的代码的优势,但是,哇,三倍的速度会很慢。事实上,它们的工作原理并不完全一样,因为 parse-measure 没有考虑到单位可以是 Unicode 数字。我们也可以这样重构 multis,让它们使用单一的正则表达式。

multi sub unit-measure ( $description
                         where  /$<unit>=(<:N>+) \s* $<measure>=(\S+) /) is export {
    my $value = +val( ~$<unit>  ) // unival( ~$<unit> );
    return ( $value, ~$<measure> );
}
multi sub unit-measure ( $description ) is export {
    return ( 1, $description.trim );
}

但这只能减少一点点的差异,multis 需要 2.5 秒左右,non-mult 大概在 1.1 秒左右。

显然,在软件的关键部分,这不是你想要的差异;在许多地方,速度是必不可少的。然而,在这个例子中,我们并没有使用签名来区分 multis,而是使用约束。就其本身而言,约束会增加一些开销,但可能涉及到额外的开销。

底线是,总是测试。架构决策并不总是与速度的提升相匹配,所以根据特定代码路径的关键性,你可能会想要选择单调度而不是多调度。

8. 处理错误

错误得到了不好的名声,只是因为他们使它看起来像什么是错的,这是你的错。不应该是这样的。错误通常是应用程序和与之交互的人—​程序员或最终用户—​之间的误解。

那么我们的责任就是确定误解是什么,并在一定程度上找出应用程序所期望的东西。设计错误(为了简洁起见,我们称它们为错误,当我们指的是误解时)和它们所传递的信息是让应用程序或模块恰到好处的一个重要部分。在这一章中,我们正是关注这个问题。

8.1. 食谱 8-1. 设计一个异常层次结构

8.1.1. 问题

防御性的代码设计方法要求对异常进行优雅和类型安全的处理。每一段代码都必须有自己的异常集,因为系统提供的异常不足以完全解释为什么会发生意外事件。

8.1.2. 解决方案

加上你设计的类层次结构,Raku 应用程序常规地添加了一个 X:: 类集,并传递精确而有意义的消息。这些错误类是 Exception 类的子类,但它们可以被定制,至少可以通过消息来定制。在你的模块或库中使用它们与传统的异常一起使用。

8.1.3. 它是如何工作的

我们已经在第六章中使用了一组异常来描述配置文件可能出现的问题。但是,我们没有展示代码,所以在这里。

use Raku::Recipes::Roly;

class X::Raku::Recipes::WrongType:api<1> is Exception {
    has $.desired-type is required;
    has $.product;
    has $.actual-types;
    submethod BUILD(:$!desired-type,
                    :$!product = "Object") {
        my $rrr = Raku::Recipes::Roly.new();
        if $!product ne "Object" {
            $!actual-types = $rrr.calories-table{$!product}<types>
        }
    }

    multi method message( X::Raku::Recipes::WrongType $x where $x.product eq "Object": ) {
        return "The product if not of the required type $!desired-type";
    }
    multi method message() {
        return "$!product is not of the required type
                    «$!desired-type», only types $!actual-types";
    }
}

class X::Raku::Recipes::WrongUnit:api<1> is Exception {
    has $!desired-unit is required;
    has $!unit;

    submethod BUILD(:$!desired-unit,
                    :$!unit) {}
    method message() {
        return "$!unit does not match the unit type, should be $!desired-unit";
    }
}

这两个异常在同一个文件中: X/Raku/Recipes.pm6。它们所传达的信息不同,它们的命名也是为了清楚地传达这一信息。WrongType 明确表示这不是代码所期望的那种东西,而 WrongUnit 则表示计量单位不正确。所有这些都发生在食谱的上下文中,所以很清楚我们在说什么。如果我们抛出一个异常,比如第一个异常,使用默认值,也就是 Object,如下所示。

The product is not of the required type Main  in block <unit> at
/home/jmerelo/progs/perl6/raku-recipes-apress/t/00-exceptions.t line 9

然后,该消息指出了抛出异常的位置。<unit> 表示这是测试文件第9行声明的一个块。在该上下文中,我们可能没有更多关于所需对象的信息,这就是为什么我们使用默认值。

然而,我们使用多个调度来根据我们所拥有的信息抛出不同的信息。如果我们知道产品,我们可以找出产品有哪些类型。在这种情况下,异常将看起来像这样。

Apple is not of the required type «Main», only types Dessert Vegan

这些类型是从我们在异常 BUILD 阶段创建的 Raku::Recipes::Roly 对象中获得的,也可以作为对象的属性。

我们正在使用的多重调度有一个有趣的方面:它是根据对象本身的值来完成的。关于 Raku 签名的一个有趣的地方是,你可以使用调用者作为该签名的一部分,除此之外,这允许你约束值并根据它进行调度。

除此以外,这两个异常有相同的结构:它们是 Exception 的子类,是 Raku 提供的这类东西的基类。你可以顺着它,不提供其他任何东西,让异常的类型通过它的名字强行沉淀下来。你也可以重写消息方法来提供特定的消息。你还可以提供属性,允许对消息进行某种个性化处理。

好吧,这是错误的类型,但什么才是正确的类型呢?对于 WrongType$!wired-type 将提供该信息,它还将指示所提供产品的类型。一般来说,任何类型的异常都应该明确上下文,并为程序员以及用户提供一条出路,用户可能需要告知开发团队出了什么问题,或者尝试用不同的输入或操作序列来克服错误。

我们还注意到哪个产品缺失了,哪个部分是需要的。在每一种情况下,这些属性都是必需的,因此,除非提供信息,否则不能实例化对象。在这三种情况下,BUILD 子方法提供了一种方法来将 new 中提供的参数绑定到属性上。在第五章,我们学习了如何使用它们。

# Check out the whole program in Chapter 5 when %conf{$part} ∉ $recipes.products {
    X::Raku::Recipes::ProductMissing.new( :product(%conf{$part}) ).throw()
}

然而,我们可以做得更好。在大多数情况下,我们处理的是缺少的东西。我们只需要指定缺少的东西的类型和缺少的东西的名称。我们可以分拆一个层次结构来处理这个问题,或者是一个可组成的层次结构。接下来我们就来做这个事情。首先,角色。

role X::Raku::Recipes::Missing:api<1> is Exception {
    has $!part is required;
    has $!name is required;
    submethod BUILD( :$!part, :$!name ) {}
    method message() {
        return "the $!part $!name seems to be missing. Please provide it";
    }
    method gist(X::Raku::Recipes::Missing:D: ) {
        self.message()
    }
}

首先,请注意我们在声明类的时候指定了 api 属性。Raku 允许类和模块的版本化(也允许作者)。这块元数据主要可以用来在你试图使用一个已经被取代的类时发出警告。一个说明"这个类没有正确的 API 版本"的错误比"找不到类"的信息量大得多。由于我们已经重构了这个模块,我们给了它一个 API 编号,以说明我们将使用这个 API 版本。没有 API 版本也等同于 API 0 版本。

所有这些异常都是面向用户的,并不是真正针对其他程序员的。这也是为什么我们重载了 gist 的原因;这样做的主要作用是避免打印异常抛出的行的信息。gist 方法是当你在一个对象上调用 say 的时候,所以当异常被抛出并打印出来的时候,才会被调用。

每当你想重用属性的时候,最好使用角色而不是类。也就是组成而不是子类(或继承),因为 Raku 中的类属性是私有的。子类将无法访问类的属性,除非你声明它们是公开的,而你可能不想这么做。然而,组成的属性仍然是私有的,你仍然可以访问它们。这些属性都将是强制性的。而当没有更多具体的类的时候,我们可以直接把这个角色作为一个异常,通过惩罚来抛出。不过,上面的类可以通过这样组成这个角色来重新制定。

class X::Raku::Recipes::Missing::Part does X::Raku::Recipes::Missing {
    submethod BUILD( :$!part="part of meal", :$!name) {}
}

class X::Raku::Recipes::Missing::File does X::Raku::Recipes::Missing {
    submethod BUILD($!part = "file", :$!name) {}
}

class X::Raku::Recipes::Missing::Product does X::Raku::Recipes::Missing {
    submethod BUILD($!part = "product", :$!name) {}
}

创建一个表现型的基类角色确实简化了其余层次结构的创建。新的类将组成基类,但它们要做的就是给一个属性分配一个默认值,在这种情况下是 $!part.message() 方法将从基础角色中重用,我们将能够在程序中直接使用它们。

以前,我们在应用程序中只使用异常。由于我们添加了一个新的异常,抱怨文件丢失,我们可以重写 Raku::Recipes::Roly 角色的一部分来包含它。

method new( $dir = "." ) {
    my $calorie-table-file = %*ENV<CALORIE_TABLE_FILE> // "$dir/data/calories.csv";
    X::Raku::Recipes::Missing::File.new(:name($calorie-table-file)).throw
            unless $calorie-table-file.IO.e;
    # Rest remains the same as in the previous chapters
}

以前,如果找不到文件,下一条语句就会失败。现在我们检查它是否存在,如果不存在,就会抛出一个异常,说找不到它。

添加一个异常类层次结构会给你的应用程序增加价值:它允许程序员立即将异常归零,并有选择地捕获和处理它们。它允许他们忽略非关键的异常(或者简单地发出警告),并在应用程序的上下文中,为那些不能被忽略的异常提供有意义的消息,特别是当默认消息在上下文中无法理解时。接下来我们就来看看如何做到这一点。

8.2. 食谱 8-2. 向用户传递有意义的错误信息

8.2.1. 问题

使用你的代码的开发人员应该能够了解由于描述性异常或错误抛出而导致的失败或不能按预期工作时发生了什么。

8.2.2. 解决办法

提供一个全面的异常层次结构,使用代码的回溯来进一步帮助用户,并在信息中清楚地表达你在代码中期待的东西和你得到的东西。

8.2.3. 它是如何工作的

如果不勾选,一个异常就会炸毁你的程序。它将在用户可能并不完全清楚的情况下这样做,因为它不需要任何关于问题所在的信息。然而,异常携带了大量的信息,你可以利用它们来为最终用户—​正在运行程序的人或者我们班上那个程序在她面前失灵的客户提供尽可能多的信息。我们将在下一节中看到如何捕捉这些错误。这个配方将相当实用,并将提供一系列的成分,使你提供的例外情况从错误命名的肩膀上摘下一点坏名声。

即使你没有设计过类层次结构,你也可以使用标准库中的一个异常。它们真的有上百个,但它们主要是为了涵盖代码解释和执行过程中发生的异常而设计的。有几个你可以用在自己的代码中。X::TypeCheck (https://docs.raku.org/type/X::TypeCheck) 和 X::Obsolete (https://docs.raku.org/type/X::Obsolete)。 你可能也会发现 X::NYI,也就是"尚未实现"的意思,很有用。

例如,在本章中,我们创建了一个新版本的 X::Raku::Recipes 类,包括一个新版本的 X::Raku::Recipes::WrongType。我们可以这样重新编写它的代码。

class X::Raku::Recipes::WrongType:api<0> {
    submethod BUILD() {
        X::Obsolete.new(old => "X::Raku::Recipes::WrongType:api<0>",
            replacement => "X::Raku::Recipes::WrongType:api<1> in Raku::Recipes",
            when => "using Raku::Recipes"
        ).throw;
    }
}

当你尝试使用它时,它将打印以下内容:

Unsupported use of X::Raku::Recipes::WrongType:api<0>; using Raku::Recipes
please use X::Raku::Recipes::WrongType:api<1> in
Raku::Recipes
  in submethod BUILD at /home/jmerelo/progs/perl6/raku-recipes-apress/
  lib/X/Raku/Chapter5/Recipes.pm6 (X::Raku::Chapter5::Recipes) line 6
  in block <unit> at /home/jmerelo/progs/perl6/raku-recipes-apress/t/00-
  exceptions-chapter-5.t line 4

这条信息内容相当丰富。它把皮毛放在了前面。使用新版本的 API 1,而不是这个旧版本。然后它显示了所谓的回溯,也就是一切发生的地方。底部是在 X::Raku::Chapter5::Recipes 文件的第6行,我们从测试脚本的第4行调用了这个文件。回溯其实是一个可以从 Exception 基类中使用的对象,它包含了一个帧的列表,这些帧是显示异常冒泡经过的栈帧部分的对象。通过 gist 方法完成的渲染异常的默认方式是将这些帧从下到上排列出来

我们已经看到了如何通过完全消除背框来覆盖这种行为。你也可以做完全相反的事情:把它打印得更漂亮,使信息突出。我们接下来会这么做。

use Raku::Recipes;
use Colorizable;

class X::Raku::Recipes::Obsolete is Exception {
    has $!old-stuff is required;
    has $!new-stuff is required;

    submethod BUILD( :$!old-stuff, :$!new-stuff) {}
    method message() {
        return "You seem to be using $!old-stuff, which is deprecated. Please switch to $!new-stuff";
    }
    multi method gist(X::Raku::Recipes::Obsolete:D: ) {
        my @nice-bts = self.backtrace.list.grep( ! *.is-setting() );
        @nice-bts.shift;

        my $output = ("Hey! " but Colorizable).colorize: :mo(bold);
        $output ~= ( self.message but Colorizable).colorize: :mo(underline);
        $output ~= "\nThis happened on ⇒\n";

        for @nice-bts -> $bt {
            $output ~= (("\t» Line " ~ $bt.line()) but Colorizable).colorize: blue;
            my $subname = ($bt.subname eq "<unit>") ?? "an anonymous routine"
                                                    !! $bt.subname;
            $output ~= " in " ~ ($subname but Colorizable).colorize: cyan;
        }
        return $output;
    }
}

我们已经创建了我们自己的,蓬松的,Obsolete 异常的版本,我们通过重写 gist 方法来实现。请记住,当你想说一个对象时,这个方法就会被调用,而当一个 Exception 被抛出时,它就会被大声说出来(除非它被捕获,我们接下来会做)。作为一个口诀,它就是 "口诀中的异常"(但它是为每个对象定义的)。总之,这个新的异常子类为 exception,这意味着它自动得到一个回溯。异常中有一些无趣的部分,我们把它们去掉。我们的做法是消除那些属于设置的部分,也就是说基本上如果有一部分是发生在系统库里面的,我们就不想知道。在这种情况下,我们消除了异常本身内部发生的部分回溯,因为我们使用的是基类的 throw。

我们是用 shift 来消除第一帧。在这个特殊的情况下,这并不有趣,因为我们感兴趣的是你在哪里试图使用过时的类。而这些是唯一剩下的东西。

对于这些,我们使用 Colorizable,这是一个由 Luis Uceta 在2020年3月发布的模块,它是一种很好的创建彩色信息的方法。为了给一个字符串着色,首先你必须混入 Colorizable 角色,就像这样: ("Hey! " but Colorizable)。一旦完成,你就可以在字符串上调用 Colorize,然后给它赋予模式(如粗体或下划线)和颜色(我们稍后会用到)。因此,我们打印一条友好的信息,然后为每一条回溯打印一行,解释这是它发生的地方。我们还把 <unit> 变成"匿名例程"。这就是我们在这个例子中调用它的地方。在程序中,它将显示例程的名称和文件中它发生的行。图8-1显示了一个将被打印的例子。

图8-1. 友好的错误信息,解释了回溯和信息的内容

循环线比看起来更啰嗦,但本质上它们所做的是提取那些信息位,比如 $bt.line$bt.subname,以那种特殊的方式打印出来。

这一节的主要内容,反正不是什么技术性的东西。设计错误信息很难,但它是你对外的窗口。设计得好的消息会让你在项目中的问题形态上节省技术支持。一个设计良好的异常信息,只有让用户确定发生了什么,在哪里发生的,如果可能的话,在哪里修复它的信息,是一个防御性的编程,将在未来为你节省很多麻烦。所以要努力创建和传递最好的异常信息。

8.3. 食谱 8-3. 捕捉和处理程序中的错误

8.3.1. 问题

不能简单地不检查错误。如果可能的话,你应该抓住它们,并提供一个解决方法,或者干脆就继续下去。

8.3.2. 解决办法

Raku 使用块作用域的 catch 语句来捕获错误。使用它们以及 Raku 控制结构,可以为异常提供解决方案,而这些解决方案通常取决于抛出的异常类型。

8.3.3. 它是如何工作的

如果没有办法克服例外,例外就不会那么有用。术语中谈到了抛出或引发异常,所以把处理异常的方法叫做捕获异常是很公平的。由于我们有 class-y 的异常,捕捉它们的块将不得不以不同的方式处理每个类。

让我们尝试在一个看似简单但底层复杂的脚本中使用这个方法。它通过给主盘和副盘分配一定量的食物来生成一个食谱,并返回这顿饭有多少卡路里。主菜和副菜将在命令行输入。但是如果出现错误怎么办呢?我们这样处理吧。

use Raku::Recipes::Calorie-Computer;
use X::Raku::Recipes;
use X::Raku::Recipes::Missing;

my $rrr = Raku::Recipes::Calorie-Computer.new();
my $main = @*ARGS[0] // "Chickpeas";
my $side = @*ARGS[1] // "Rice";
my $calories = $rrr.calories-for( main => $main => 200,
                                  side => $side => 250 );

say "Calories for a main dish of $main and side of $side are $calories";

CATCH {
    default {
        given .message {
            when /Main/ || /$main/ { $main = "Chickpeas" }
            when /Side/ || /$side/ { $side = "Rice" }
        }
        $calories = $rrr.calories-for( main => $main => 200,
                side => $side => 250 );
        .resume;
    }
}

首先你需要注意的是,这段代码广泛使用了 Raku::Recipes::Calorie-Computer 的方法,这个类混在 Roly 角色中,包括从食材列表中计算热量的方法。在使用的两个模块中定义了例外,主要使用的方法是 calories-for

这个方法会接受两个命名参数 - mainside,每个参数都是一个 Pair,它的 key 是原料的名称,它的值是我们要用它的量,不管用什么度量。我们设定主菜200克,副菜250克,这几乎是我做饭时的做法。

这个功能的复杂性在这个脚本中看不出来。如果提到的产品不存在(比如说你想要一个主菜鱼尾和一个副食米饭),或者如果你分别用一些不是主菜或副食的东西作为主菜或副食,它就会产生异常情况

(我想要苹果加金枪鱼)。我们可以用这个做几件事(包括在调用那个方法之前处理它们),但为什么要这样做呢?它已经处理了所有可能的异常。我们需要做的就是捕获异常以处理它们。

这就是为什么我们要使用 try-catch 组合,或者可以说,try 的主服务,边上还有一个 catchtry 块包含了任何发生在其中的异常,并创建了一个范围来捕获异常。异常可能会让某个变量处于不好的状态,所以 CATCH 块,也就是前面标有 CATCH 关键字的块(全大写,记住),分析出了什么问题,并去修复它,如果这也是可能的话。这些 CATCH 块是在正常流程之外的,它们会捕获发生在同一范围内的任何异常。这就是为什么我们在最后设置它们的原因。

如果某些部分缺失,我们会用一个合理的默认值来代替它。鹰嘴豆配米饭,有人知道吗?(它们和一点番茄、大蒜、也许还有一点洋葱,当然还有橄榄油一起吃,非常美味)。CATCH 块类似于给定块:它把异常作为一个局部变量,然后我们可以使用 when/default 来处理它。最后一个关键字是一个全局性的关键字:当其他一切都失败时,它将发射。在这种情况下,它是唯一的一个,它体现了我们如何在最通用的情况下创建这种类型的块。

我们需要知道是主菜还是副菜产生了错误,以便为其中之一或另一个提供默认值。消息(它将是一个 X::Raku::Recipes::WrongType 或一个 X::Raku::Recipes::Missing::Product)将包含产品或部件的名称。为什么会是这样呢?因为我们在前面的配方中承诺,会产生有意义的错误。我们将对错误进行解析,以确定如何修复它。我们会添加一些提示产生错误的值的东西,并解释如何处理它,并在可能的情况下从中恢复。

一旦变量被分配,我们知道 $main$side 都有一个有效的值(提供或默认)。所以我们再次计算卡路里,这次没有任何问题,并调用 .resume 在抛出异常的地方恢复执行。这将产生类似下面的结果。

Calories for a main dish of Tuna and side of Rice are 585

嗯,金枪鱼和米饭。我希望配菜是锯木屑,但我会满足于此。

不过,我们可以做得更好。如果有另一个未知的错误呢?它将包含在 try 块中,但由于 catch 块不会处理它,所以它对 $calories 没有价值,也不会打印出任何合理的内容。另外,你可能想为不同种类的错误提供不同的默认值。或者干脆举手示意"我做不到"。

这里是,只包括代码的主要部分。

{
    $calories = $rrr.calories-for( main => $main => 200,
                                   side => $side => 250 );
    CATCH {
       when X::Raku::Recipes::Missing::Product {
            given .message {
                when /$main/ { $main = "Pasta" }
                when /$side/ { $side = "Potatoes" }
            }
            $calories = $rrr.calories-for( main => $main => 200,
                                           side => $side => 250 );
        }
        when X::Raku::Recipes::WrongType {
            given .desired-type {
                when "Main" { $main = "Chickpeas" }
                when "Side" { $side = "Rice" }
            }
            $calories = $rrr.calories-for( main => $main => 200,
                                           side => $side => 250 );
        }
    }
}

如果问题出在产品上(我们用意大利面作为主食,土豆作为副食),或者出在产品的类型上(主食或副食),就会使用不同的默认值。CATCH 块作为给定块,局部变量是例外。when 子句将智能匹配异常,并将其保留在 topic 变量中。我们会像之前一样,在给定子句中对 topic 变量进行充值。如果异常是一个不同的异常,我们不知道该怎么处理它,所以可能最好只把它传播给用户,用户会看到程序停止(并希望采取行动)。

匹配异常,在 WrongType 的情况下,使用 desired-type 属性来完成。使用这个而不是消息更具体,因为你需要知道类型的具体属性,但在消息内容可能比属性(或 API)更经常变化的意义上,它不那么脆弱。总之,总有很多方法可以做到。

然而,重复计算卡路里量的代码并不聪明。让我们试着重新表述一遍,避免这种重复。这里只显示 catch 块。

CATCH {
       when X::Raku::Recipes::Missing::Product {
            given .message {
                when /$main/ { $main = "Pasta" }
                when /$side/ { $side = "Potatoes" }
            }
            proceed;
        }
       when X::Raku::Recipes::WrongType {
            given .message {
                when /Main/ { $main = "Chickpeas" }
                when /Side/ { $side = "Rice" }
            }
            proceed;
        }
       when none(X::Raku::Recipes::Missing::Product,
                 X::Raku::Recipes::WrongType) {
           die "There's something wrong with ingredients, I can't generate that";
        }
       default {
            $calories = $rrr.calories-for( main => $main => 200,
                                           side => $side => 250 );
        }
}

这里的主要变化是使用了 proceed,也就是 given 说 "先别走,还有很多"的方式。given 中的 when 子句可能会匹配好几次;proceed 是一种说 "好的,我很好,但可能还有其他子句也会匹配"的方式。在这种情况下不会是这样的:它是一个 Missing::Product,一个 WrongType,或者都不是。我们在第三个子句中进行处理,这将再次使脚本死掉。但它的要点是在默认子句中:它总是会匹配,并且总是在匹配之后运行,一旦我们有了 $main$side 变量的值,就会计算卡路里。这样就避免了可怕的代码重复,程序流程也更加清晰。

最后,这表明了一个好的错误层次结构设计,加上信息量大的信息,如何让你更容易设计出可靠的程序,几乎可以应对用户会扔给他们的所有东西。

8.4. 食谱 8-4. 在 Comma IDE 中调试你的应用程序

8.4.1. 问题

你的程序失败了,而且很难确定发生了什么。

8.4.2. 解决办法

Comma IDE 有一个集成的调试器,你可以用它来设置断点和检查变量值。

8.4.3. 它是如何工作的

Comma 是一个集成的开发环境,拥有所有需要的东西,可以看到你的模块和应用程序中发生了什么。在本书中,我经常使用 Community 版本,以查看正在发生的事情,或者在某件事情失败时进行调查。

你在第1章中学习了如何选择一个脚本来运行,所以现在你将使用同样的选择来调试一个程序。这可以是你在那里使用的程序,也可以是本章中的一个程序,或者是你想要的任何程序。点击"播放"图标旁边的小虫子就可以进入调试模式,如图8-2所示。

图8-2.Comma IDE 中的 Debug 图标(小虫)。

然而,默认情况下,它启动了一个后台调试器,允许你停止它并检查断点。如果它完成得太快,你将无法检查所有的断点。你需要插入一个断点,通过点击行号和你想停止的行的文本之间的文字。见图8-3。

图8-3.在第16行插入断点 在第16行中插入断点,只要在该行数字和窗口之间的那一总列中单击即可

断点将显示为一条紫色的线,以及一个红色的圆盘。程序现在会在到达断点时停止,将线的颜色改为蓝色(在我使用的主题中;在其他主题中会是另一种浅色)。一旦程序暂停,你可以通过点击底部面板中的调试器选项卡(这是默认的布局,它可能在你屏幕的其他地方)来访问其余的好东西。你会看到类似图8-4的东西。

在这个块中有三个词法变量,但有趣的是 topical 变量:$_,这表明它包含一个类型为 X::Raku::Recipes::WrongType 的异常。我们可以点击"播放"符号,它将显示属性的值。显示 $!product 是 Apple(这是错误的),$!wired-type 是 Side。苹果是一种甜点(虽然苹果酱是一种不错的配菜),这就是为什么提出异常的原因。

你可以点击其余的调用框架,这些框架会对应之前被调用的内容,例如,可以看到 calories-for 是如何被调用的,以及它收到的值。

但是我们很高兴,这个值是我们要找的,我们可以继续执行。我们可以一步一步的走,也可以点击任何一行,运行到那一行为止。我们将点击"默认"块,看看那里发生了什么。或者你也可以点击 F8,一步步走过去。你可能不想"踏入",因为那里的事情会很快变得很毛躁。当你进入 Rakudo 自己的代码时,如果你想进入一个块,你可能会使用"步入"。

如果你想设置固定的断点,就这样做,然后点击左侧的图标,有点像侧边的"弹出"按钮(如果你是十岁以下的孩子,可能不会明白这个参考)。这样就会恢复并运行到下一个断点。我们可以浏览到框架中包含所有的词汇变量,$main$side,以及其他许多变量,如图8-5所示。

图8-5.检查"全局"变量

那个被标记为 <unit> 的框架显示了我们所有的"全局"变量,或者说在最外层的范围内实际上是全局的。我们可以看到一些在其他章节中提到的变量,比如我们为了方便而创建的 $=pod$pod,以及 $main$side$rrr。只要呆在那个框架里,踩着"过来",你就会准确地运行那一行,而不是其他。

当有些东西不符合你的要求时,这个调试器将帮助你指出并点击你的程序的内部工作方式。它比 "say $this" 和 "say @that" 这里和那里更胜一筹,对吧?

8.5. 食谱 8-5. 用漂亮的错误让它们大方地失败,从而调试 Grammar

8.5.1. 问题

当一个 Grammar 失败时,很难钻研到解析到底失败在哪里。

8.5.2. 解决方法

使用 Grammar::Tracer 来确定 Grammar 在做什么,在哪里停止。

8.5.3. 它是如何工作的

语法要么失败了,却没有任何提示,要么你需要使用 Grammar::Tracer,并且很啰嗦地发现一些需要触发的规则没有被触发。幸运的是,我们可以安装 Grammar::ErrorReporting,它不仅可以报告错误,还可以让你以程序化的方式处理这些错误。

语法是你不知道你需要的最强大的东西,也是 Raku 最突出的功能之一,暂时没有其他语言共享。语法是一种程序化的方式来表达一组文本中的结构,它们给你提供了一种方法来轻松提取你感兴趣的结构部分。正如例程之于类,方法之于语法。语法是正则表达式的分层集合,这些正则表达式相互调用,以建立一个复杂的数据结构,忠实地表达你要分析的文本的结构。

请注意,我们用一整章的时间来介绍 Grammar,在本书的后面。

我们将创建一个简单的语法来分析一个用 markdown 写的菜谱中的成分列表。我们可以从这个开始。

unit grammar Raku::Recipes::Grammar::Ingredients;

token TOP { <row> }
token row { "*" | "-" | "✅" \h+ <ingredient> }
token ingredient { <quantity> \h* <unit>? }
token quantity { <:N>+ }
token unit     { "g" | "tbsp" | "clove" | "tbsps" | "cloves" }

其结构与类的结构类似。我们把它作为单位,和类一样,以避免缩进。Grammar 由 rule、token 或 regex 组成。token 和 rule 是 regex,但有一点不同:token 是棘轮式的,也就是不回溯。rule 是指 whitespace 很重要的 token。这意味着,默认情况下,token 和 regex 可以使用空白作为装饰,并使其更加清晰。棘轮使 grammar 中的 token 速度更快,这就是为什么默认使用 token 的原因。当然,如果你愿意的话,你仍然可以使用 rule 或 regex。

Grammar 有一个 TOP token,在这种情况下,它只是委托给下一个 token,rowrow 有一个丁字符号(通过|,是三个选项之一),加上一个成分的描述,其中包括一个数量和一个单位。在包含数量后面的成分之前,我们只用这个,因为我们已经卡住了。当试图解析 * 2 tbsps 时,应该是完全 OK 的,它匹配了 *,然后就停止了。

Grammar::Tracer 来救场了。我们把它插入到 grammar 文件的顶部。在不做任何其他改动的情况下,它将把图8-6所示的结果打印到控制台。

图 8-6. Grammar::Tracer 的工作

匹配还可以,但无缘无故停在第一个匹配上。这就不好了。但这说明,某种程度上,token 中的第一个 * 确实符合 grammar。嗯,我们回到文档中去。

我们回到文档中去。去那里看看总是好的,因为你可能无法正确记住正则表达式的工作原理。而 | 恰好是"最长备选"。嗯,不是我们要找的东西。Grammar 匹配 *"✅" \h+ <ingredient>,所以它只是对第一个选项满意,然后退出。我们需要使用 || 来代替。替换。另外,|| 的优先级比 whitespace 高,所以我们需要把这三个东西分组,而不需要捕捉,因为我们对丁字符并不感兴趣。所以用这个代码。

token row { ["*" || "-" || "✅"] \h+ <ingredient> }

这样就可以了。如果我们保留 Grammar::Tracer,我们将看到图8-7中的结果。

图 8-7. 匹配整个句子

图 8-7 显示,它到了第一条规则,尝试匹配成分,它由一个数量(那里匹配了 2)和一个单位(tbsps,汤匙)组成。然后,它回溯表示它匹配了层次结构中较高的 token,我们就可以得到好的结果了。这个程序会产生 "We need to use 2 tbsps of whatever"。

use Raku::Recipes::Grammar::Ingredients;

my $row = Raku::Recipes::Grammar::Ingredients.parse("* 2 tbsps");
say "We need to use $row<row><ingredient><quantity> $row<row><ingredient><unit> of whatever"

结果匹配的结构与 Grammar::Tracer 打印的结构相同,它是一组嵌套的哈希,最上面的哈希使用最上面的 token 的名称作为键,从那里向下,到其余的 token。每一个关卡都会有很多键,因为里面提到了 token。

不过,事情还是会出错的。让我们试着解析 *2 tbsps,同时留下 Grammar::Tracer。见图8-8。

图8-8. 现在解析失败

图8-8显示,它失败了,而且是在第一个 token 中就失败了。好吧,这有帮助,但不是真的。让我们试着找出到底发生了什么。调试不会帮助我们,因为它只会显示 Match 变量 $/ 的值,而这个值是空的,因为,匹配失败了。同理,"say $this or $that" 也是如此。但你的工具箱里有一个强大的工具:重构。

事实上,那个失败的规则有三个不同的部分。我们可能对所有的部分都不感兴趣,但目前我们需要知道它失败的地方,所以我们来分解一下。

token row { <dingbat> <whitespace> <ingredient> }
token dingbat { ["*" || "-" || "✅"] }
token whitespace { \h+ }

一行现在是三个不同的 token;Tracer 将帮助我们确切地知道它失败的地方。见图8-9。

啊哈!原来是那个偷偷摸摸的空格(或没有空格)导致了失败。让我们解决这个问题,它就会工作。下面的代码。

use Raku::Recipes::Grammar::ErrorReporting;

my $measure = Raku::Recipes::Grammar::ErrorReporting.parse("* 2 tbsp");
say ~$measure<row><ingredient><quantity>, " of ",
        ~$measure<row><ingredient><unit>;

会产生这个:

2 of tbsp

不过,我们还是对空白处不感兴趣,而且,对丁字符也不感兴趣。结果的匹配将包括它们。通过在 rule 的名称前使用点,我们将使用它,但不存储它。

token row {  <.dingbat> <.whitespace> <ingredient> }

这将产生相同的结果,但噪音较小。

请记住,并不是所有标有 FAIL 的东西最终都会产生匹配失败;语法可能正在探索一个替代分支中的一个分支在那里失败,但随后在其他分支中成功并最终返回匹配。但通过这些,我们看到 Raku 不仅提供了强大的 Grammar,而且在生态系统中还提供了强大的工具,帮助你在某些东西不能以它应该的方式工作时使用和调试它们。这一点,以及一点编程手艺,将帮助你创建伟大的文本解析和分析工具,但我们将在本书的后面讨论这个问题。

9. 客户端 Web 和 API

网络是信息的无限来源,它就在那里供你挖掘。很多服务可以直接使用,在其他情况下,你必须做一些按摩,然而在其他情况下,你需要适当的授权。在所有这些情况下,Raku 都会帮助你。

9.1. 食谱 9-1. 查询 GeoIP 数据库

9.1.1. 问题

在你的日志文件中,你有一堆互联网地址,你需要知道它们来自哪里,以检查哪些国家对你的内容感兴趣。

9.1.2. 解决方法

使用 Raku 生态系统中的一个模块 GeoIP2 来查询 MaxMind 数据库,这个数据库包含了关于 IP 组的地理位置信息。

9.1.3. 它是如何工作的

你手里有一整套的文件,里面有你的菜谱网络服务器的日志,你想知道这些 IP 都来自哪里。这样一来,你就可以创造更多关于地方美食的内容。或者你只是出于好奇心想知道。专有的解决方案会给你提供这些信息,但同时也会让你的用户失去一些隐私,所以最好是自己开发。

幸运的是,有一家叫 MaxMind 的公司,生产了一系列的数据库。这些数据库是开放的格式,你可以用它们来查询 IP 的情况。你可以通过 GeoIP2 下载模块访问它们。

然而,这些数据库是专有的。有几种方法可以获得其中的一个数据库。

  • 你的公司可能会对其中的一个数据库非常感兴趣,它就会帮你买一个。

  • 有一些用几种语言编写的开源工具,比如 Perl,可以生成这种格式的数据,而且它们是开源的。见 https://github.com/maxmind/MaxMind-DB-Writer-perl。 你可能想创建自己的、有限的、数据库,比如使用你们公司 VPN 内的 IP(如果是跨国的)。

  • 最后,你可以免费下载 GeoLite 数据库。这些数据库的精度有限,但我们只对国家和大陆感兴趣,所以这就可以了。 我们将创建一个简单的脚本,检查你用来连接互联网的机器的 IP,并告诉你你来自哪个国家和大陆。

use GeoIP2;
my $ip = qx{curl -s ifconfig.me};
my $geo = GeoIP2.new( path => 'Chapter-9/GeoLite2-Country.mmdb' );
my $location = $geo.locate( ip => $ip );

say "The IP is in $location<country><names><en>, $location<continent><names><en>";

由于在本地,我们只能知道你的系统连接到的本地 IP(或 IP),通常是由路由器发出的本地 IP,所以我们通过 curl 使用 ifconfig.me 互联网服务来获取我们自己的 IP。我们需要安装并访问 curl,而 -s 将使它返回 IP,而不在屏幕上打印任何其他内容(s=无声)。qx 引号构造是运行外部程序的另一种方式,类似于我们在第1章和第2章中使用 shellrun 的做法。

然后我们用数据库文件的路径来初始化数据库对象(之前我已经注册下载了,你也可以在 https://dev.maxmind.com/geoip/geoip2/geolite2/ 进行注册)。我们调用 locate 方法来获取位置,它将以数据结构的形式返回,其中包括多种格式的国家和大陆名称,以及一些地理编码信息。我们只需要访问国家和大洲的英文名称(存储在 <names> 键中)(存储在代表语言编码的 <en> 键中)。这将为我打印以下内容。

IP 在西班牙,欧洲

说的对!

如果你想解析日志,你需要使用到目前为止看到的任何一个文本处理配方来处理这个日志。

9.2. 食谱 9-2. 从网站下载和提取信息

9.2.1. 问题

你需要得到一些网站上包含的信息。

9.2.2. 解决办法

使用外部 CLI 工具或使用可用的库之一下载页面,如 HTTP::UserAgent、Cro::HTTP 或 LWP::Simple。正如它们的名字一样,LWP::Simple 更简单,HTTP::UserAgent 能让你更灵活地创建特定的头文件,而 Cro::HTTP 是设计和维护最好的。你可能不会需要 Cro::HTTP 的所有功能。一旦内容被下载,使用正则表达式来捕获你感兴趣的信息,或者,如果这不容易或不可能,在 HTML 文档对象模型解析器中捕获信息,如 DOM::Tiny。

9.2.3. 它是如何工作的

网络上有很多信息。如果有一种方法来获取和处理它…​ 但现在有了!从一开始,搜刮就是一种收集半结构化信息和数据的方法。从古至今,搜刮是一种收集信息和数据的方法,这些信息和数据是半结构化的,并公布在网络上。然而,搜刮仍是一门玄妙的艺术,有许多不同程度的自由度:从信息如何结构化,到更深奥的挑战,如节流(当有来自IP的重复请求时,使请求响应速度变慢)或服务条款(如果你从一个网站下载信息,你可能会被禁止,即使你实际上没有在其他地方发布这些信息)。

此外,还有一些技术上的挑战。从本质上讲,当你在搜刮时,你需要下载用 HTML 编写的页面,然后解析该 HTML 以获得你所需要的信息。在许多情况下(例如,如果该信息有一个明确的前缀,或以某种方式结构),解析是直接的。在其他一些情况下,你需要将整个页面解析到 DOM,然后寻找挂在某个分支上的特定树叶。

注意,在另外一些情况下,你可能会很幸运,从一个数据 div 或用户访问的 JSON 文件中获得信息。

配方也是如此。有无数的网站每天都会发布食谱,或者是食谱被摆放在那里,通常是以一种有一定结构的格式。

注意 不幸的是,一种叫做 hRecipes 的微格式用于在 HTML 中包含菜谱已经不再流行,我怀疑它是否曾经流行过。

然而,出于种种原因,我们最好还是坚持使用像 Wikipedia 这样的开放许可网站。Wikibooks 在 https://en.wikibooks.org/wiki/Category:Recipes 中包含了大量的菜谱,这些菜谱几乎遵循了相同的格式。这个脚本将下载其中一个食谱并提取其中的成分。

use HTTP::UserAgent;

my $URL = @*ARGS[0] // "https://en.wikibooks.org/wiki/Cookbook:Apple_Pie_I";
my $recipr = HTTP::UserAgent.new;
my $response = $recipr.get($URL);

die $response.status-line unless $response.is-success;
my $ingredients = ( $response.content.split(/"<h2>"/))[1];
my @ingredients = ($ingredients ~~ m:g/"\/Cookbook:"(\w+)/);
say @ingredients.map( ~*[0] ).unique;

在使用 HTTP::UserAgent 模块之前,你需要下载它,它是生态系统中经常更新的模块。

注意和这个快速发展的生态系统中的其他模块一样,你使用的 raku 版本在安装时可能会有一些怪癖,如果你不想活在边缘,可以使用 rakudo Star 发行版,其中包括许多其他有用的模块。

脚本将采取一个食谱的 URL,或者使用苹果派的 URL,因为有什么比苹果派更好,对吗?它将实例化一个版本的 HTTP 用户代理,并将在下一句中获取 URL。如果出现错误,它会用一条消息保释出来。

注意为了进入正题,我们将跳过检查和对错误的优雅处理。例如,我们不会检查 Url,使它有效地具有正确的模式.这留给读者.提示:一个简单的 regex 就可以了。

如果一切正常,我们就可以继续提取信息了。我们知道,会有一个 H2 段,叫做成分,这确实是第一个 H2 段。所以理所当然的,我们使用简单的 <h2> 进行拆分,取第二个,数组中的索引1。

请注意,这就是维基百科的一个好处:干净的标记.如果用其他一点标记和 CSS 类来标记的话,可能会更难分割部分。

在这一节中,我们意识到,每一种食材都有一个链接到描述使用该食材的不同食谱的部分。它们都遵循相同的模式:https://en.wikibooks.org/wiki/Cookbook: +ingredient (而且它总是一组类似于单词的字符)。因此,我们使用的模式是 m:g/"/Cookbook:"(\w+)/);。:g 将匹配所有的菜谱,而括号将只捕获单词,因此我们在一个数组中得到一组 Match 对象。我们需要提取被匹配的精确字符串:.map( ~*[0] ) 将得到 Match 中的第一个匹配对象,并将其字符串化。我们从这个数组中提取出唯一的元素,以防其中任何元素重复。这将会打印出类似这样的结果。

(Flour Margarine Lard Salt Water Apple Lemon_Juice Butter Sugar Cornstarch Cinnamon Nutmeg Milk)

注 是的,好像有一个栏目专门介绍带水的食谱,嗯,好喝! "这汤真好喝!"你的秘方是什么?" "水"

请注意,由两个独立的单词组成的成分,比如 Lemon Juice,用下划线隔开;下划线也与 \w 匹配,所以这里没有问题。

这样做还算成功,但我们还是需要有点技巧,通过从具有一定形状的 URL 中提取信息,来获取我们想要的信息。

提示 搜刮是一门玄妙的艺术,你需要时刻保持手艺。你也可以从 apress 出版的《用 Python 进行网站抓取》和《数据科学的实用网络抓取》这两本优秀的书中学习抓取的艺术和技巧,虽然语言不同,但技术和方法论并没有太大的区别。

让我们试着使用 DOM。虽然一般来说,这将会让你得到更精确的数据,但应该考虑到现代的 DOM,一般来说是动态的,所以你不能从源头得到它们(你需要一个无头的浏览器,目前,Raku 还没有)。另外,你需要的 DOM 结构是静态的,而很多网站会经常性地改变这个结构。让我们坚持使用这个同样的 Wikibooks 源码,它在这方面更干净和可刮擦。

use WWW;
use DOM::Tiny;

my $URL = @*ARGS[0] // "https://en.wikibooks.org/wiki/Cookbook:Apple_Pie_I";
my $content = get($URL);

die "That $URL didn't work" unless $content;

my @all-lis = DOM::Tiny.parse( $content.split(/"<h2>"/)[1]).find('li').map: ~*;
my @my-lis = @all-lis.grep( /title..Cookbook ":"/)
        .map( { DOM::Tiny.parse( $_ ) } );

say @my-lis.map( "→ " ~ *.all-text).unique.join("\n");

我们已经换了一个不同的带有 HTTP 命令的库,简单而贴切地命名为 WWW,它给我们提供了一个简单的从网上下载的 get 命令(它也可以在飞行中解析 JSON)。DOM::Tiny 将提供 DOM 解析能力。

虽然 HTML 是一种文档结构描述语言,但并没有什么好的方法为它提供一个自上而下的结构。例如,在结构中不能显示分段的划分(可以通过使用 section 标签来显示,但是这里没有使用)。这就是为什么,要提取成分部分,我们仍然要做与上一版本中基于标签的分割一样的工作。这样一来,我们就可以确保我们解析的内容真的包含了我们感兴趣的内容。这个页面的 HTML 相当干净,这意味着在任何地方都看不到语义类属性。如果包含我们列表项的 ul 标签有一个 class='ingredients' 标签,那就简单多了。尽管如此,从源码来看,我们看到每个成分都在 <li> 标签中,所以 find("li") 将在一个 Raku Seq(即你可以迭代的项目序列)中得到所有的成分。关于序列的所有信息,请访问 https://docs.raku.org/type/Seq 或从《Perl 6 快速语法参考》第 5 章了解。我们需要做额外的检查,所以我们通过 ~* 将它们串联起来,将它们渲染成 HTML。

我们需要做的是下一个 grep:如果没有链接到配料页面,那就不是一个配料。在苹果派页面上,它们都是这样的,但我们要保持安全边际,实际检查一下这个。可能有一些额外的说明,我不知道,使用一些特殊的平底锅或预热烤箱(你总是需要预热烤箱)。我们再次解析它们,只是因为这是摆脱标记并获得所有文本的最简单的方法,接下来我们使用 .all-text,另一个 DOM::Tiny 方法来做。

结果将如下。

→ 8 oz (225 g) plain flour → 4 oz (110 g) margarine → 2 oz (55 g) lard
→ pinch of salt
→ 2 tablespoons cold water
→ 1 lb (500 g) apples, sliced
→ 2 tbsp lemon juice
→ 1 oz (28 g) salted butter
→ 2½ c sugar, and additional for sprinkling
→ ¼ c flour
→ 2½ tbsp cornstarch
→ cinnamon
→ nutmeg
→ 1 oz (28 g) milk
→ sugar

同样,我们使用独特的,因为肉桂是重复的(也许有点被高估了),因为它用在馅饼的两个部分。这样一来,我们就把所有的原料都集中在一起了,我们可以对它们进行额外的处理(比如使用语法,这一点我们后面会讲到)。

如果完全没有办法的话,你就会废掉。如果你能使用一个API,事情就会简单很多。我们接下来会讲到这一点。

9.3. 食谱 9-3. 使用 Web API 从网站获取信息

9.3.1. 问题

你需要从一个通过 REST API 提供数据的网站下载信息。

9.3.2. 解决办法

在 Raku 中使用 Web 客户端,如 WWW 或 Cro::HTTP,或者如果生态系统中存在 API 的特定模块。

9.3.3. 它是如何工作的

食谱和食物,在网络上一般都是蓬勃发展的领域。

注意在冠状病毒大流行的时候更是如此,当时世界上很大一部分人都被关在家里,有更多的时间。

有一个星座的网站,每天都可以查询,但这也创造了一个完整的服务行业,为这些网站提供内容,以及从撰写食谱到检查各种食材信息的附加值。还有 Yummly,它提供了一个按次付费的 API,还有很多其他的服务。我们还是选择 Edamam 吧。如果你以开发者身份注册,它确实提供了一个免费层,限制在5个请求/分钟。因此,请为这个配方做 https://developer.edamam.com/edamam-recipe-api, 并获得一个应用ID和一个API密钥。

use Cro::HTTP::Client;
use URI::Encode;

my $appID = %*ENV{'EDAMAM_APP_ID'};
my $api-key = %*ENV{'EDAMAM_API_KEY'};
my $api-req = "\&app_id=$appID\&app_key=$api-key";
my $ingredient = @*ARGS[0] // "water";

my $cro = Cro::HTTP::Client.new(base-uri => "https://api.edamam.com/" );
my $response = await $cro.get( "search?q="
                               ~ uri_encode($ingredient) ~ $api-req);
my %data = await $response.body;
say %data<hits>.map( *<recipe><label> ).join: "\n";

Cro 是一个了不起的作品,它是一个分布式应用的框架,它包含了各种不同协议的好东西,当然也包括我们这里使用的这个 HTTP 客户端。与我们之前一直使用的客户端不同,它是异步的。我们以前也用过异步,但在这种情况下,它是完全合适的。你并不知道一个请求的响应什么时候会到达,让程序的其他部分一直挂在那里等待会导致性能低下。在你将做一个单一请求并串行处理的情况下,也许可以(就像我们在前面的配方中所做的那样)直接启动请求并等待结果;事实上,我们在这里就是这样做的。但以后会有。

第一块语句将设置必要的变量,像往常一样从命令行中获取查询字符串,如果它存在的话。你需要从Edam账户中复制并粘贴它们来定义环境变量。如果这些变量没有有效的值,它将无法工作。由于所有的请求都会精确地使用这两个值,所以我们设置了 $api-req 变量,以便以后重复使用。

Cro::HTTP::Client 设置了一个带有基本 URL 的客户端。如果协议版本允许的话,它会重用连接,这也是与我们之前使用的其他两个模块的区别。从产量和功能上看,Cro 比其他同类型的模块要领先很多。

因为它是异步的,一个请求会返回一个承诺。我们在这个承诺上等待,才能得到响应。但响应本身也是一个承诺:它是一个连接,我们需要再次在它上等待,才能得到响应的主体。这就解释了两个等待的顺序。API 使用 GET 来访问搜索函数,它使用 q 作为查询字符串的参数。我们建立一个 URL,在这种情况下,这很方便(也是给方法一个哈希的替代方法),因为 API 会明确地要求按这个顺序提供认证参数(否则你无法确定哈希的密钥将如何排序)。

Cro::HTTP::Client 甚至会为你解码响应的主体,并给你一个 Raku 数据结构。默认情况下,Edamam API 会返回10个点击,免费层最多返回100个。我们将满足于第一页,事实上,这个特殊的查询字符串会返回几千次点击。"点击率"是结果数据结构返回的键之一。但我们只是对返回的配方感兴趣。数据结构将是一个哈希,它将把 hit 存储为 hits 键下的一个数组。其中,每个命中都会有一个配方键,而这个配方会有一个标签键,这将是它的描述。所以最后一条语句在这些命中上运行一个映射,并提取这些配方的名称。它将像这样。

Summer water
Pineapple Coconut Water
Water Toast
Water Kefir from 'Mastering Fermentation'
Grapefruit Sparkling Water
Coconut-Water Gelatin
Cucumber-Orange Water Recipe
Tomato Water Pasta
Rose Water Marshmallows recipes
Rose Water Syrup

嗯,夏天的水。迫不及待的想做这个。我可以在当地超市买到脱水水吗?

一般来说,大多数 API 将使用 REST,你将能够使用 Cro::HTTP::Client 来处理它。认证将以不同的方式进行,在大多数情况下,将它们作为元数据添加到请求中。处理 API 的本质就在那里。把你的参数放在一起,构建请求(包括头元数据,如果你需要的话),然后发射它,解码响应。

在一些有限的情况下,你会有一个为特定 API 定制的 Raku 模块。API::Discord 将处理该对话系统,还有 Twitter 的社交网络,Glot.io 的 GlotIO,甚至还有一个 Wikidata 的薄包装器,叫做 Wikidata::API,是由你们自己发布的。

维基数据是维基百科中较少有人涉足的部分,专门用于,嗯,数据。它存储数据和数据项之间的关系。它包括用什么成分来做什么配方。作为 WikiRealm 的其余部分,它是众包的,所以你的里程数可能会有所不同。好在它有一个无需认证的 API,基于一种名为 SPARQL 的查询语言。例如,这个查询将返回所有包含大蒜的食谱。

SELECT ?recipe ?recipeLabel
WHERE
{
  ?recipe wdt:P31?/wdt:P279* wd:Q219239;
            wdt:P527 wd:Q21546392.
  SERVICE wikibase:label { bd:serviceParam wikibase:language "en", "fr". }
}
ORDER BY UCASE(STR(?recipeLabel))

前缀 wdt 是用来表示关系,wd 是表示数据。我们拥有的两个固定的数据是 Q219239,它是,食谱(查看它的 URI https://www.wikidata.org/wiki/Q219239, 或者直接在 www.wikidata.org 上搜索"食谱")。这两个关系是一个实例或一个子类。carbonara 酱的配方是配方的一个实例。P527 的意思是"是由"组成的,Q21546392 是大蒜。查看 Q 前缀表示实体,P 表示它们之间的关系。所以本质上我们是在说"给我所有看起来是配方的东西,并且包括大蒜"。扰流器。在写这篇文章的时候,只有两个。我们需要这个脚本来获取它们。

use Wikidata::API;
my $query = "Chapter-9/ingredients.sparql".IO.slurp;
my $recipes-with-garlic= query($query);
say "Recipes with garlic:\n",
        $recipes-with-garlic<results><bindings>
            .map: { utf8y( $_<recipeLabel><value>) };

sub utf8y ( $str ) {
    Buf.new( $str.ords ).decode("utf8")
}

其实也没什么好说的。读取 SPARQL 查询,发起查询,显示结果。结果是在一个相对复杂的数据结构中,但唯一有趣的部分是 recipeLabel 键,正如你之前看到的,它是由查询创建的。其余的都是模板(results 将是存储结果的键,bindings 将显示绑定到 results 的不同变量,包括 recipeLabel)。

我们必须创建一个小的子例程,utf8y,来处理结果,因为使用的 JSON 模块不能。该例程将一个字符串分解为其字符,重新构造,并返回以 utf8 编码的字符串。这将打印出以下内容。

有大蒜的食谱

(Anchoïade ratatouille)

这第一个字就是造成那个套路存在的原因。这是仅有的两个似乎使用大蒜的食谱。当然,他们必须是法国人。

如果你懂 SQL 或其他查询语言,SPARQL 并不难学,它真的可以帮助你处理很多平凡的事情。在乐乐降临日历的这篇文章中,圣诞老人用它来检查男孩和女孩们在信中要求的东西是否真的是一个对象 https://perl6advent.wordpress.com/2017/12/03/letterops-with-perl6/。

总的来说,API 会帮助你丰富你的应用,使用 Raku 可以轻松地与它们合作。

9.4. 食谱 9-4. 通过查询互联网服务来检查IP和地址

9.4.1. 问题

你有一个IP,你需要检查它是否开启或是否有服务。

9.4.2. 解决方法

你可以使用 Net::IP 来操作地址,或者使用 Net::IP::Parse 来检查地址,还可以使用 IP::Random 来生成随机的 IP 地址。Sys::IP 会给你本地 IP 地址。你也可以使用 whois 来映射域名到名字。在许多情况下,会有一个 Raku 模块可用来检查某个服务,在其他情况下,你将不得不使用本章讨论的一个配方来创建你自己的 API 查询服务。

9.4.3. 它是如何工作的

使用IP地址工作涉及到查询系统服务,以及使用 TCP 等协议进行调用,并检查返回的内容。例如,很多时候我们需要检查一个服务是否在运行,如果没有运行,就要做一些事情,比如记录一个故障事件,或者采取其他措施,比如发送邮件。一般来说,做系统调用,而且是以一种与系统无关的方式进行,并不容易。

服务一般会被映射到"端口"上,端口是系统内的地址,通常有一个约定俗成的编号。这些编号是公布的,你通常会确保如果你需要运行一些东西,你会避开它们,创建你自己的。

总之,把这些放在一起意味着,如果你想检查某些服务是否在你的系统上运行,你需要检查该端口中是否有任何东西响应某个协议,通常是 TCP。你可以用这个简单的脚本来完成

use Sys::IP;
use Services::PortMapping;
use CheckSocket;

my $this-ip = Sys::IP.new.get_default_ip();

for <www-http ssh> -> $service {
    if check-socket(%TCPPorts{$service},$this-ip) {
        say "Your service $service is running in port %TCPPorts{$service}";
    } else {
        say "Apparently, your service $service is not running";
    }
}

脚本很简单,因为有三个模块,把所有复杂的东西都藏起来了。Sys::IP 找到本地系统拥有的 IP 或IP,可以用来检查的 IP(当然,总有 127.0.0.1)。Services::PortMapping 导出四个哈希,将标准服务名映射到端口,反之亦然。最后,CheckSocket 检查是否有一个 TCP 服务在某个地址的端口中运行。

所以我们按顺序进行:首先通过 get-default_ip 找到本地IP,然后通过在它们上面运行 check-socket 来检查两个常见的服务,ssh 和 http(根据"服务名称和传输协议端口号注册表",它们的标准名称是 http、www 或 www-http)。如果它们正在运行或没有运行,将打印不同的信息。在我的例子中,它将打印以下信息。

Your service www-http is running in port 80
Your service ssh is running in port 22

这就是我发现我一直安装并运行 Apache httpd 的原因。

Raku 是一门通用语言,它和下一门语言一样,足以胜任系统级任务。生态系统中的模块确实不如其他语言(如 Perl 或 Python)那么多,但在生态系统的 Net:: 命名空间中,对 DNS 或 BGP 等协议有广泛的支持。不幸的是,其他协议,如 ICMP,都没有。然而,Raku 生态系统每周都会增加几个模块,所以当你读到这篇文章的时候,这可能不是真的。

10. 文本处理

脚本语言是制作脚本的好帮手,这些脚本可以对文本进行处理,提取信息,以某种格式呈现,或者以其他有用的方式进行操作。在本章中,我们将看到如何提取信息、识别文件之间的差异以及将静态页面渲染为 HTML。我们将看到许多基本的 Raku 技术,以及被介绍到生态系统中的有用模块。

10.1. 食谱 10-1. Scrape Markdown 文档

10.1.1. 问题

你需要从 markdown 文档中提取信息,例如只提取标题,或者通过位置或内容识别某些信息。

10.1.2. 解决方案

正则表达式是一种强大的特定领域的方法,可以从半结构化文本中提取信息,例如 markdown 文本,这总是一个选项。如果信息在结构中,你可以使用生态系统中的 Text::Markdown 模块。

10.1.3. 它是如何工作的

Scraping 是从带有某种标记的文本中提取信息的过程,无论是在网络上还是在具有已知格式的文档上,如 PDF 或文字处理文档。Scraping 用于处理遗留的文档,从网络上的信息中创建 API,或者准备向公众开放的数据。

我们在本书的食谱中使用了 markdown。在任何配方中,你需要提取的东西之一,就是它的成分。比方说,你需要确定某个食谱的成分,这样你就可以把它们添加到你的购物清单中,或者计算其中的卡路里量(就像我们在第二章中做的那样)。

例如,这可能是一个制作胡萝卜卷的食谱的标记。

# Carrot wraps

A healthy way to start a meal, or to munch between them.

## Ingredients
* 200g carrots
* 200g cottage cheese or cheese spread
* 4 wheat tortillas

## Preparation
    1.    Cut the carrots in long sticks or slices
    2.    Spread cheese over tortillas, cut them in half
    3.    Put carrot sticks on tortillas, wrap them around
    4.    Add fresh parsley, mint or coriander to taste.

标题和章节都有明确的标示。首先是一级标题(用 # 表示),然后我们把原料作为二级标题,再把准备工作作为另一个二级标题。我们和以前一样,只对成分及其措施感兴趣。由于这些标记语言只标记段落,而不是段落集,所以没有任何东西能包裹住 ingredients。我们必须找到另一种策略来获取它们。但请注意,它们是文档结构中唯一的列表项。准备工作使用的是编号项。所以,这可能是提取它们的一个好方法。我们在这里这样做。

use Text::Markdown;

sub MAIN( $filename = "recipes/appetizers/carrot-wraps.md") {
    my $md = parse-markdown-from-file($filename);
    my @ingredients = $md.document.items
            .grep( Text::Markdown::List )
            .grep( !*.numbered );
    for @ingredients[0].items -> $i {
        say "Ingredient → {(~$i).trim}";
    };
}

请注意,这里提到的 markdown 文件将和其他代码一起被包含在本书的 github 仓库中。

我们将使用生态系统中的一个模块,Text::Markdown。它并不完美,但对于像本篇这样的简单文档,它的工作做得相当好。Text::Markdown::Discount 是另一种选择,它使用一个 C 库来做解析。如果需要的话,这两个模块还可以从数据结构中生成 markdown。

总之,parse-markdown-from-file 直接从文件中创建一个数据结构;这个数据结构将是一个 Text::Markdown::Document 对象,有一系列的项目。Text::Markdown 对每一种可能的标记都有不同的类型;它们被称为 Text::Markdown::Whatever。例如,一个文本段落将是 Text::Markdown::Paragraph,并且,实际上,Text::Markdown::List 将是一个列表。然而,编号或常规列表没有特定的类型,那是通过对象中的一个属性来区分的。

我们需要做的是提取文档对象中的项目,文档对象将是一个数组,过滤所有的列表(将有一个常规列表用于成分,一个编号的用于说明),然后只取那些编号的列表。

会有一个,那将是数组中的第一个元素。同样,列表数据结构中的项目将包含所有不同的列表项目,我们在循环中有效地列出这些项目。不过,我们需要做一些额外的处理。每一个项目都将是一个 Text::Markdown 数据结构,我们需要对其进行字符串化处理;即使如此,文本也将包含回车和可能的其他空白,我们对其进行修剪。最终,结果将是这样的。

Ingredient → 250g carrots Ingredient → 200g cottage cheese or cheese spread Ingredient → 4 wheat tortillas

这些成分可以通过提取措施和实际使用的成分进一步加工。这将留待以后我们处理迷你语言时再讨论。

但是,请注意,对于那些注定要被自动处理的文档,总是赋予一个固定的结构是很重要的。在本例中,列表的类型是一个显著的特征。如果我们也使用一个编号列表,我们就需要进行额外的处理,例如,将文档按章节分割(就像我们在上一章处理网页时所做的那样),然后提取第二部分的项目。然而,在这种情况下,我们对信息的呈现方式没有任何形式的控制。在 markdown 文档的情况下,我们通常会这样做。一些简单的准则就足以使带有一点结构的文本像任何一种(序列化的)数据结构一样规则。

注意 你需要在文本本身中进行结构化。例如,第二项有一个"或",这将使它难以自动处理。当我们以这种方式处理成分时,我们就需要一种方法来有原则地、但又不笨拙地表达这些替代方案。

10.2. 食谱 10-2. 生成一组静态网页

10.2.1. 问题

你需要为一个静态网站生成一些页面,以廉价(安全)的方式发布。

10.2.2. 解决办法

使用静态网站生成器从 markdown 文档生成 HTML 页面,或者使用模板和脚本推出自己的网站。最简单的方法是从你已有的 markdown 文档中生成 HTML。例如,Markit 会解析 markdown 并将其转换为 HTML。

除了这些系统,还有三个其他的系统—​Uzu、Pekyll 和 BreakDancer。后者可能已经过时了,Uzu 最近更新了。但是,Markit 也许可以代替它们中的任何一个,从它的 markdown 源中生成一组页面,这就是我们要找的东西。

此外,我们还可以使用模板系统,比如 Template::Classic,用程序中的值来填充 boilerplate。这就是我们将在这里使用的。

10.2.3. 它是如何工作的

首先我们能想到的是使用一个工具,直接使用 markdown 中的源码,然后用 HTML 生成一个静态网站。最接近的是 Pekyll,但我真的不能建议你使用它,因为它的文档不是最新的(或完整的),而且它已经三年没有更新了。不过,它可能完全没问题,因为 Raku 是一种(大部分)稳定的语言。但我们还是试试另一条路吧。

Uzu 最近一直在更新,而且它的使用频率很高。但是,它使用 HTML 而不是 markdown 源码来生成网站。这意味着我们需要退一步,想办法直接生成 HTML。

Markit 就是解决方案。事实上,如果你想要的只是一个普通的 markdown-to-HTML 处理器,这个处理器是相当不错的,而且会做得很好,虽然很朴素。我们将用它来为我们所有的食谱生成 HTML文件(这些不是真正的页面,因为它们没有完整的 HTML 头/体结构,尽管它们可以使用)。

use Raku::Recipes;
use Markit;

my $md = Markdown.new;

for recipes() -> $recipe {
    my $html-path-name = ~$recipe;
    $html-path-name ~~ s/\.md/\.html/;
    $html-path-name ~~ s/recipes/build/;
    my $html-path = IO::Path.new($html-path-name);
    my $html-dir = $html-path.dirname.IO;
    $html-dir.mkdir unless $html-dir.d;
    say $html-dir;
    spurt $html-path-name,  $md.markdown( $recipe.slurp );
}

我们将使用 Raku::Recipes 中的食谱例程,它对应于我们在第一章中创建的计数文件食谱。我们已经将它整合到我们的 Raku Recipes 实用程序模块中。它将返回一个列表,其中包含默认的 recipes 文件夹中的所有文件的路径(或另一个绝对或相对的路径,在那里可以存储 markdown recipes)。该循环将在这些文件上运行。它分两步生成 HTML 文件的路径:它改变扩展名,然后将目录从原来的(包括食谱)改为最后的,即 build。然后我们需要创建目录,如果它不存在,因为否则文件的创建将失败。我们创建一个 IO::Path 对象,检查它是否存在,除非它存在,否则我们就将该目录 mkdir。

spurt 例程将直接把从 markdown 生成 HTML 的结果写到文件中,只需一步。例如,这将是 carrot-wraps.html 文件的内容。

<h1>Carrot wraps</h1>
<p>A healthy way to start a meal, or to munch between them.</p>
<h2>Ingredients</h2>
<ul>
<li>250g carrots</li>
<li>200g cottage cheese or cheese spread</li>
<li>4 wheat tortillas</li>
</ul>
<h2>Preparation</h2>
<ol>
<li>Cut the carrots in long sticks or slices</li>
<li>Spread cheese over tortillas, cut them in half</li>
<li>Put carrot sticks on tortillas, wrap them around</li>
<li>Add fresh parsley, mint or coriander to taste.</li>
</ol>

然而,一个 HTML 文件并不意味着一个网站,甚至不是一个页面,所以我们需要将整个文档结构包裹在我们生成的片段中。为了做到这一点,我们应该避免只在 HTML 字符串周围游走,这对于网站设计师来说是很难编辑的。最好的选择是使用模板,我们将选择 Chloe Kekoa 最近发布的 Template::Classic。它非常简单,只有一个函数 template,可以创建一个例程,将变量的值渲染到模板中。如果你想学习用 Raku 的方式(或者至少是 Raku 的一种方式)来做事,它也是一段非常优秀的代码,它很好地利用了许多 Raku 独有的特性,包括语法。

很显然,模板是要模板的,所以我们需要用这种方式来创建页面的基本骨架。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Test page</title>
<link rel='stylesheet' id='style-css'  href='raku-recipes.css' type='text/css' media='all' />
</head>
<body>
<!-- This is a Template::Classic template -->
<% take $content %>
</body>
</html>

这个模板的关键部分就在这里: <% take $content %>。在 Template::Classic 中,<% %> 运行 Raku 代码,它将返回变量 $content 中的任何内容,并将其精确地包含在 HTML 文件的那部分。我们将需要使用该变量来生成文件。我们在这个程序中就是这样做的。

use Raku::Recipes;
use Markit;
use Template::Classic;

my $template-name="templates/recipe.html";
my $template-file = "resources/$template-name".IO.e
                ??"resources/$template-name".IO.slurp
                !!%?RESOURCES{$template-name}.slurp;

my $md = Markdown.new;
my &generate-page := template :($content), $template-file;

for recipes() -> $recipe {
    my $html-fragment = recipe($md,$recipe);
    my @page = generate-page( $html-fragment );
    spurt-with-dir($recipe, @page.eager.join );
}

generate-page 例程与前面的配方完全一样。它将从 markdown 生成 HTML 片段;spurt-with-dir 将像前一个配方一样,写入一个文件并创建目录(如果存在)。为了简洁起见,它们已经被省略了。剩下的就直接进入正题了。

首先我们从文件系统中获取模板。这个模板会像大多数模块使用的资源一样,在资源/目录下。传统上,这个目录的所有内容都会在 META6.json 中声明,随后安装到一个不可更改的地方。你不需要去找这个地方在哪里。%*RESOURCES 动态哈希将为每个资源提供一个条目。我们事先并不知道我们是在同一个目录下使用这个模板,还是在安装了 Raku::Recipes 模块之后才使用,所以第一条语句就解决了这个问题。

和我们之前做的一样,我们生成一个 markdown 渲染器对象,但现在我们另外创建一个 templater 函数。我们使用模板,即 Template::Classic 导出的唯一例程来完成。它接收一个签名和一个模板作为参数,并返回一个将模板应用到其参数的函数。我们使用绑定 := 而不是赋值;这是模块文档告诉我们的方式,而且这是有意义的,因为绑定创建了一种别名。我们所做的只是简单的说,该函数将指向调用模板的结果,而不是一个副本。这可能也会给它带来一点速度上的优势。

那么,这就是一个高阶函数。一个返回函数,接受一个模板,并返回一个应用该模板的函数。这就是我们所说的 templater。templater 是一个函数,它将把这个模板应用到提交给它的变量上,把它们的值嵌入到它定义的地方。

这是一种我们通常在函数式语言中找到的模式,比如 Haskell 和 Scala。但是 Raku 也是一种函数式语言,函数是一级对象(和类型或语法一样)。我们可以在任何可以使用其他类型数据的地方使用它们。这也是我们选择这个模块作为配方的原因之一;即使它很新,但它确实突出了 Raku 的能力和特性。想一想如何以一种面向对象的方式来完成这个任务:我们将创建一个模板对象,将模板作为一个属性,然后我们将调用该对象的一个方法来应用模板(并可能改变状态的对象)。该方法必须使用一个通用签名(例如,哈希)来接收变量。这就是函数式编程的全面扩展:创建的 templater 是一个无状态函数—​它接收经过类型检查的变量,并返回填好的内容。

不过,请注意,有很多方法可以做到,如果你对对象,或者对纯程序接口很满意,raku 也会为你提供使用它的工具。

签名只是一个例程使用的参数组合。它是一种声明它接受什么样的参数的方式,不仅可以用来在调用函数时对参数进行类型检查,还可以用来检查函数本身,函数的类型包括签名和返回类型。签名机制在大多数现代语言中都可以使用。Raku,此外,还包括签名作为一级对象。万一你想说,嘿,创建这个函数,我想以这种精确的方式被调用,你可以使用像这样的签名文: ($content)

请注意,raku 中的签名机制是相当广泛的,包括运行时检查、子签名、命名和位置参数、slurpies 等。你可以在 《Perl 6 快速语法参考》书中找到所有关于它的内容,作者是同一个人。

基线是,我们要把 templater 作为一个函数来调用。有了这个签名,我们告诉这个签名我们将如何调用它,使用哪些参数,它的类型,以何种顺序调用,等等。

就像我们在前面的配方中所做的那样,我们生成 HTML 片段,将这个 templater 应用于它,并生成一个 Seq。在模板中,$html-fragment 的内容将成为 $content,并返回结果。它返回的是一个序列,每个元素都是结果的一个片段,你可以分别处理,甚至可以用它们创建一个供应。使用 Seq 可以使它更加灵活,但在这种情况下,我们只是对整个事情感兴趣。由于它是一个惰性的序列,我们让它变得急切,这样它的所有组件都会被活用,然后我们简单地将它加入到一个单一的字符串中。然后这个字符串会被写入文件系统,保留原始路径。

结果(一旦你把CSS文件放在正确的地方)会像图10-1一样。

图10-1. 通过 templater 渲染的食谱

不过,我们还是可以做得更好一些。例如,标题是通用的,每一页都是一样的。如果我们能在那里使用食谱的标题会更好。另外,如果能有一个索引页,里面有每个页面的链接,那就更好了。现在我们还没有这样的功能。但我们可以利用 Template::Classic,以及本章的第一个配方来实现这种效果。有两种模板解决方案:无代码和另一种,有代码。像 Mustache 这样的无代码解决方案,创建了自己对模板语言的适应性,从而可以用隐式的方式处理某些数据结构。例如,你可以创建一种方式来表达如何呈现一个数组或哈希。另一方面,Template::Classic 允许你包含所有种类的 Raku 代码。我们将在索引模板中使用它,如下所示。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
<title>Raku Recipes: index</title>
<link rel='stylesheet' id='style-css' href='raku-recipes.css' type='text/css'
      media='all' />
</head>
<body>
<!-- This is a Template::Classic template -->
<h1>Recipes: index</h1>
<ul>
    <% for %links.kv -> $file, $title { %>
    <li><a href="<%= $file %>"><%= $title %></a></li>
    <% } %>
</ul>
</body>
</html>

这里的主要区别是 HTML 文档正文中的 for 循环。<% %> 包括 Raku 语句,<%= %> 片段只是包括变量的值。所以在这里,我们是把一个以文件路径为键、以标题为值的哈希,并从每一个文件中创建一个列表项。但这个模板必须从程序本身使用,就像这样。

use Raku::Recipes;
use Markit;
use Template::Classic;
use Text::Markdown;

my $md = Markdown.new;
my &generate-page := template :($title,$content),
                        template-file( "templates/recipe-with-title.html" );

my %links;
for recipes() -> $recipe {
    my $this-md = parse-markdown-from-file($recipe.path);
    my $html-fragment = recipe($md,$recipe);
    my $title = $this-md.document.items[0].text;
    note "Can't find title for $recipe" unless $title;
    my @page = generate-page( $title, $html-fragment );
    my $path = spurt-with-dir($recipe, @page.eager.join );
    $path .= subst( "build/", '' )
    %links{$path} = $title;
}

my &generate-index:= template :( %links ),
                        template-file( "templates/recipes-index.html" );
spurt("build/index.html", generate-index( %links ).eager.join);

在这个程序中,为了清晰起见,再次取消了例程。前一个脚本的第一行已经被转换为模板文件例程,找到文件的存储位置并加载它。

这个程序与第一个版本非常相似。第一个变化是页面模板现在包含了食谱的标题,我们使用 Text::Markdown: my $title = $this-md.document.items[0].text 来提取。

请注意,尽管是 markdown 解析器,但我们不能使用 Markit,因为它没有提供实际获取文档解析版本的接口。因此,我们对 .md 文档进行了两次解析。

你还会发现,spurt-with-dir 现在会返回用于 HTML 文件的路径名。我们将需要它来创建链接哈希。使用 subst,我们去掉了使用的构建目录的前缀,并将其存储在哈希中。

索引生成语句由几句话组成:一个是为这个模板构建 templater(和之前看到的一样),另一个是把它保存到标准位置,build/index.html。其结果将如图10-2所示。

图10-2.菜谱网页索引 食谱网页索引

其余页面的生成将如图10-2所示,只是它们的名称将在 HTML 文件的标题标签中。

使用这些简单的模板和一个小的 Raku 脚本,静态网站的生成是快速和相当灵活的,因为你可以自定义脚本(和模板的脚本部分)来创建你喜欢的东西。如果你需要的是更完整的东西,比如生成一个博客,每个新文件都会再生不同的文件,从索引到 RSS 订阅,可能你需要的是 Uzu。Uzu 是高度可编程的,可配置的,而且速度也相当快。不过,对于一个由一组网页和一个索引组成的简单网站来说,只需要两个模板和一个脚本就可以了。

10.3. 食谱 10-3. 创建一个词典并在上面进行快速搜索

10.3.1. 问题

有一组单词及其定义,你需要通过单词或内容进行快速搜索。

10.3.2. 解决方案

使用 Data::StaticTable,一个具有快速索引功能的内存数据库。

10.3.3. 它是如何工作的

当你知道键,并且需要访问对应的值时,哈希值是很好的。然而,做逆向搜索,也就是找到与值相对应的键,就不是那么容易了,尤其是当你需要在几列上扩展搜索时。当然,数据库是很好的选择,但如果可能的话,数据库的速度并不比在内存中做快,除了你的脚本外,你还需要额外的工具。使用类似于数据库的东西,但在内存中,正是我们要找的。

就像我们需要以特定的方式安排数据将其放入数据库一样,我们需要安排数据将其输入到静态表中。我们接下来会做这个工作。

use Data::StaticTable;
use Raku::Recipes::Texts;


my %recipes = Raku::Recipes::Texts.new().recipes;

my @recipes-table;
for %recipes.kv -> $title, %content {
    @recipes-table.append: [ $title,
                             %content<description>,
                             %content<ingredients>.join ];
}

my $recipes = Data::StaticTable.new(
        <Name Description Ingredients>,
        ( @recipes-table)
        );

my $recipe-query =  Data::StaticTable::Query.new($recipes); # Query object

$recipe-query.add-index( "Ingredients" );

my Data::StaticTable::Position @rice = $recipe-query.grep(rx/rice/,
        'Ingredients',n => True);
say $recipes.take( @rice ).display;

首先要观察的是,我们正在使用一个新的模块。Raku::Recipes::Texts. 这个模块使用我们在之前的食谱中使用的例程来创建一个单一的对象:一个包含(暂时是一部分)食谱文本-标题、描述和成分的哈希。哈希将使用食谱标题作为键,对于每个食谱,其值将包含两个附加键。描述和配料

请注意,当我们开始使用配方的其他部分时,我们会丰富这个模块。目前,它只是包含了新的方法和一个对属性配方的访问器,其中包含了刮取的数据。

我们在 %recipes hash 中得到从 markdown recipes 中抓取的文本,然后我们在上面运行一个循环,在每次迭代中提取标题(这是hash中使用的键)和值(另一个hash)。由于 Data::StaticTable 取一个数组,所以每一次迭代,我们都会增加一行,包含三列:食谱名称、描述和成分。

Data::StaticTable 需要列名和数据。我们用它创建 $recipes。它将包含我们的数据,并将用于搜索。但我们需要一个额外的 Query 对象来实现这一点。Data::StaticTable::Query 对象需要一个 Data::StaticTable 对象。此外,根据文档,你还可以添加一个索引,这将使搜索更快。

Data::StaticTable 查询使用已知的方法 grep;它们不是只取一个表达式,而是同时取一个表达式和你要搜索的列。我们想搜索含有大米的食谱,这就是我们在这个查询中要做的。

my Data::StaticTable::Position @rice =
        $recipe-query.grep(rx/rice/, 'Ingredients'):n;

它返回一个 Data::StaticTable::Position 类型的对象。这些对象就像索引一样,只是它们可能包含附加信息。我们可以像使用定位一样使用它们来获取包含该术语的表行。我们使用显示方法,使结果类似于数据库查询。

Name   Description  Ingredients
............   .................................  .........................
["Tuna risotto"]   ["A relatively simple version of this rich, creamy dish of Italian origin."]   ["500g tuna\n\n 250g rice\n\n 1⁄2 onion\n\n 250g cheese (parmegiano reggiano or granapadano, or manchego)\n\n 1 tbsp extra virgin olive oil\n\n 4 cloves garlic\n\n"]
(This might look better on a bigger screen.)

你可能会发现在查询结果后面有一个 :n。冒号后缀的字词被称为副词;它们相当于设置为 True 的标志(如果冒号后面是感叹号,也就是 bang)。为静态表查询定义的 grep 方法需要几个命名的参数,其中一个是 :$n 在这种情况下,它将返回行号而不是内容,或者其他格式。但是你可能已经注意到我说的是命名参数,而不是副词。这是因为子程序和方法中的命名参数也可以作为副词应用。该调用相当于下面的内容。

my Data::StaticTable::Position @rice =
        $recipe-query.grep(rx/rice/, 'Ingredients', :n );

或:

my Data::StaticTable::Position @rice =
        $recipe-query.grep(rx/rice/, 'Ingredients', n => True);

这里使用 :n 的方式比较特殊,但在乐乐中完全允许。请记住,总是有不止一种方式。

10.4. 食谱 10-4. 计算纯文本文档的差异

10.4.1. 问题

你需要确定一个文档的两个版本之间是否有任何变化,并确定这些变化是什么。

10.4.2. 解决办法

如果文档是 Git(或其他源码控制)仓库中同一文档的版本,如果你知道这两个版本所在的提交,你可以直接用 Git 计算这个差异。如果不知道,生态系统中的不同模块会给你这个差异。Algorithm::Diff 是最老牌的,File::Compare 功能更全,Text::Diff::Sift4 是最新的,可能也是最快的。

10.4.3. 它是如何工作的

假设你需要检查传入配方的新版本,并确定它们到底有什么不同,这样你就可以创建一个单页,在点击时呈现两个版本。你正在比较文本文件,你需要知道哪些行是不同的。

我们将创建一个我们之前使用的食谱的新版本,一个金枪鱼烩饭。新版本将使用金枪鱼罐头和人造黄油代替橄榄油。为了测试 Raku 的这些功能,我们来测试一下。

我们先来试试 File::Compare。

constant $prefix = "recipes/main/rice/";
say "Different"
    if files_are_different( "$prefix/tuna-risotto.md",
                                     "$prefix/tuna-risotto-low-cost.md");

这个只会告诉我们文件是否不同,所以如果我们想快速检查,或者我们使用的是非文本文件,它可能是好的。然而,它在这里并不符合我们的目的。让我们用这个脚本试试 Text::Diff::Sift4。

constant $prefix = "recipes/main/rice/";
say sift4( "$prefix/tuna-risotto.md".IO.slurp,
               "$prefix/tuna-risotto-low-cost.md".IO.slurp, 100, 400);

这个模块只有一个函数,而且它的编程速度很快。然而,这只是返回43作为两个"字符串"中不同字符的数量(这是在我们啜饮整个文件进行检查时)。同样,它不仅可以作为快速检查是否存在差异,而且可以检查差异有多大。我们在这里寻找更具体的东西。

让我们尝试下一个建议的模块,Algorithm::Diff。

use Algorithm::Diff;

constant $prefix = "recipes/main/rice/";
for sdiff( "$prefix/tuna-risotto.md".IO.lines, "$prefix/tuna-risotto-low-cost.md".IO.lines).rotor(3)
    -> ($mode, $deleted, $added ) {
    say qq:to/EO/ unless $mode eq 'u';
# $mode
    ← $deleted
    → $added
EO

};

这正是我们想要的。输出将是这样的:

#c
    ← * 500g tuna
    → * 500g canned tuna

#c
    ← * 250g cheese (parmegiano reggiano or granapadano, or manchego)
    → * 250g whatever cheese is in your fridge

#c
    ← * Extra virgin olive oil
    → * 2 tablespoons olive oil

#+
    ←
    → * 1 tablespoon butter or margarine.

c表示 "改变","+" 表示新的行。这个模块的 API 非常简单:sdiff 比较两个文件的行数(这就是为什么我们要读取文件并提取它们),然后输出变化。我们需要把它们分成三组(因此有了 .rotor(3))来理解这一切。第一个是变化的类型,第二个和第三个是第一和第二个文件中的行。我们使用签名来解构数组中的每一次迭代,在循环内部使用的三个变量中,有三个由 rotor 产生的元素:$mode$deletedadded

我们使用 heredoc 格式(你在第3章中看到的)以一种漂亮的格式来呈现。unless 语句会过滤掉用 u 标记的行,即"未更改",以便只显示更改。

它也足够快,而且它可能适合大文件。所以这就解决了这个问题。

11. 微服务

网站的世界让位于网络服务的世界,这最终催生了微服务的世界,微服务是一种小型的反应式应用,具有 REST API,使用 JSON 或其他序列化语言响应 HTTP 命令。自从引入 Cro 微框架后,与微服务相关的大部分需求都被覆盖了。在本章中,我们将看到如何以几种不同的方式使用这个微框架。

11.1. 食谱 11-1. 创建一个微服务问题

你需要创建一个微服务,作为你提供的服务的 API。

11.1.1. 解决方案

使用 Cro - Raku 首屈一指的微服务框架,在你的业务逻辑上创建一个层。它可以同时工作,并且可以使用 Raku习 语轻松编程。

11.1.2. 它是如何工作的

现在的应用架构是由不同的微服务组成的复杂网状结构,这些微服务被服务总线绑在一起并部署到云端。这种复杂性使得创建能够有效服务于许多不同前端的后端变得更加容易。他们通过创建小型服务来实现这一目标—​事实上,这些服务小到可以独立于应用程序的其他部分进行设计、实现、部署和测试,只要尊重接口即可。这使得整个应用程序更加可靠,并通过独立扩展组成应用程序的微服务来保持服务时间的统一。这也使得应用对云资源的使用效率更高,因为云资源通常是按使用付费的。

不管怎么说,微服务的整个要点是提供一个可替代的、独立于语言的、可以从不同前端消费的 API,所以它通常是围绕现有的类或模块设计的。从架构的角度来看,将业务逻辑和API分开总是很重要的,这样它们就可以独立开发和测试。

这个 API 是围绕 HTTP 动词 - GET、POST、PUT 和 DELETE - 和状态码建立的,200 表示 "一切正常",当出现某种客户端错误时,返回 4xx。然后,API 是围绕路由创建的,路由的功能相当于对象或模块。这些路由与 URI 一起工作,对象的生命周期重复使用同一个 URI。

在本例中,我们将为我们的成分数据库创建一个微服务。我们将使用 /Ingredient 路由,URI 将把原料的(大写)名称添加到这个片段中。我们的第一个微服务将返回我们所拥有的关于一个原料的信息。

use Cro::HTTP::Server;
use Cro::HTTP::Router;
use Raku::Recipes::Roly;

my $rrr = Raku::Recipes::Roly.new();
my $recipes = route {
    get -> "Ingredient", Str $ingredient {
        content 'application/json', $rrr.calories-table{$ingredient};
    }
}
my Cro::Service $μservice = Cro::HTTP::Server.new(
    :host('localhost'),
    :port(31415),
    application => $recipes
);
$μservice.start;

react whenever signal(SIGINT) {
    $μservice.stop;
    exit;
}

我们使用两个 Cro 模块,Cro::HTTP::Server 和 Cro::HTTP::Router。第一个包含多线程服务器,另一个用于创建路由。Cro 是一个模块化的框架,它有不同的下载模块来实现不同的功能。这些模块可以通过发出 zef install Cro::HTTP 来安装。

提示记住,这些代码都在书的 repo 里,每一章都有自己的 META6.json,主要是你进入目录后,只要写 zef install -deps-only . 就可以安装所有具体的模块。

路由命令用于创建我们的应用程序将拥有的所有路由,首先是命令(本例中是 get),然后是一个 Raku 块,我们为其提供位置参数,其序列创建路由。在本例中,这两个参数是 Ingredient,然后是将用于食材名称的变量 $ingredient。这两个参数一起构成了用于检索原料数据的 URI。例如,一个 URI 可以是 /Ingredient/Rice/Ingredient/Olive+Oil。由于 URI 需要用 MIME 编码,所以空格会被转换为 +。如果你决定使用浏览器来测试,浏览器会帮你做这件事。

请注意,"试水"是可以的,但不是测试微服务的正确方式。你需要一个适当的集成测试,这就是我们将在本章下一个到最后一个配方中看到的。

同一模块还提供了内容顺序,取两个参数。MIME 类型和实际内容。这个顺序是很聪明的,因为只要它知道如何转换数据结构,它就能将数据结构转换为足以满足 MIME 类型的东西。在这种情况下,它只是 JSON,所以它知道如何做。我们返回描述每一个成分的哈希值,结果就像图11-1所示,从浏览器上看。

图11-1.从浏览器访问微服务

这部分定义了路由,但我们需要定义微服务,设置它要使用的地址(这将决定它要监听的接口),它要使用的端口(我喜欢 pi 端口,31415),以及要被服务的应用程序的名称。很明显,我们把这个变量称为 $μservice,使用希腊字母 mu。

最后,我们需要启动微服务,并设置一种方法来实际停止它。它将一直运行,直到进程收到停止信号,也就是大多数操作系统中的 Ctrl+C 或命令行中的 kill 命令。我们可以从命令行启动它,如果那个端口是空闲的(为什么不空闲呢),我们就可以使用它。事实上,我们可以使用 Cro 创建一个小型客户端,就像我们在第9章所做的那样。

use Cro::HTTP::Client;
my $ingredient = @*ARGS[0] // "water";
my $cro = Cro::HTTP::Client.new( base-uri => "http://localhost:31415/Ingredient/" );
my $response = await $cro.get( $ingredient );
say await $response.body;

由于我们使用的是标准接口,我们不需要包含我们要提取信息的类。我们只需要知道如何构建访问资源的 URL。这将把 JSON 表示打印到控制台,因为它只是对微服务的访问通道。但只要我们不需要担心微服务的实现,我们也可以用不同的语言创建一个客户端。比如说 Python。

import requests
import sys

if len(sys.argv) > 1:
    ingredient = sys.argv[1]
else:
    ingredient = "Rice"

with requests.get('http://localhost:31415/Ingredient/'+ingredient) as r:
    if (r.status_code == 200):
        print(r.text)

这里的主要区别是,我们不对产生的 JSON 进行解码。另外,我们检查状态码是否正确,但本质上是一样的。在一天结束的时候,REST API 是访问任何一种微服务的标准方式。 我们可以使用另一种访问内容的方式,使用 web 下载器,如 curl(或 wget)。

curl http://localhost:31415/Type/Vegan
["Olive Oil","Green kiwi","Sundried tomatoes","Apple","Orange","Kale","Kidney beans","Lentils","Rice","Tomato","Potatoes","Cashews","Chickpeas","Beer"]

但事情可能会出错,而且会出错,第8章给你上了一课(或两课)如何处理这个问题。当我们试图访问一个不存在的成分时,我们至少需要以某种合理的方式做出反应。处理错误的最好方法是完全避免它,我们可以使用乐的签名来做到这一点(再次)。这只是路径定义,脚本的其他部分和之前一样。

my $recipes = route {
    get -> "Ingredient", Str $ingredient where $rrr.is-ingredient($ingredient) {
        content 'application/json', $rrr.calories-table{$ingredient};
    }

    get -> "Ingredient",
           Str $ingredient where !$rrr.is-ingredient($ingredient) {
        not-found;
    }
}

我们已经说过,Cro 路由得到的区块不同,调用约定也就相当于区块签名。我们可以在签名中进行类型检查:$ingredient 是一个真实的,所以它是可以的。它得到一个块,它用我们掌握的关于它的数据来回答。但是,我们可以抓住它不也在签名中的事实:当它不是成分时,第二个路由会开火,它只是使用 not-found 命令,这将使 Raku 生成正确的响应。如图11-2所示,这是从 Postman API 测试应用中看到的。

图11-2. 从 Postman 应用中看到的微服务404响应,在图像的底部

这种机制是相当灵活的,它也能更清晰地显示出应用所遵循的执行路径。但即便如此,把所有的路径都归纳在一个地方也不方便。另外,我们希望我们的微服务,只要包含一个高效的 Web 服务器,就可以兼任我们在上一章生成的静态网页的服务器。我们还将增加一些路由—​例如,一个返回所有具有特征的食材的路由,例如属于素食菜肴。这就是结果。再一次,只是显示了路由。

sub static-routes {
    route {
        get -> *@path {
            static 'build/', @path, :indexes<index.html index.htm>;
        }
    }
}

sub type-routes {
    route {
        get -> Str $type where $type ∈ @food-types {
            my %ingredients-table = $rrr.calories-table;
            my @result = %ingredients-table.keys.grep: {
                %ingredients-table{$_}{$type}
            };
            content 'application/json', @result;
        }
        get -> Str $type where $type ∉ @food-types {
            not-found;
        }
    }
}

sub ingredient-routes {
    route {
        get -> Str $ingredient where $rrr.is-ingredient($ingredient) {
            content 'application/json', $rrr.calories-table{$ingredient};
        }
        get -> Str $ingredient where !$rrr.is-ingredient($ingredient) {
            not-found;
        }
    }
}

my $recipes = route {
    include "content" => static-routes,
    "Type"            => type-routes,
    "Ingredient"      => ingredient-routes;
}

从程序结构和总体架构的角度来看,这里有几个改进。路由已经被划分为块,然后被包含在一组单一的路由中,将由微服务提供服务。

我们定义了一个新的静态路由,它将负责静态内容。静态命令将返回文件,在同一个命令中,我们还定义了该索引。

htm 或 index.html 将是默认的索引。这些文件是在 build 子目录中创建的,所以那是用来构建路由的。URL 将被碎片化为一个 @path,这个路径将被重构为一个文件路径,并被返回。

我们创建的路由是为了响应某种类型的成分,它的模式与我们之前使用的相同。如果该类型确实存在(@food-types 是从 Raku::Recipes 导入的),它将返回一个配料列表,如果不存在,则返回 404。另一个路由块,ingredient-routes,和上一个版本一样,除了一个小细节:路由本身。

这种表达路由的方式可以将逻辑和它要挂到的路由解耦,这让设计者在修复路由的时候更加灵活。那么路由名称在哪里定义呢?我们在一个 include 语句中定义:它是一个哈希,以路由 URI 片段为键,以它要被路由到的子为值。因此, Ingredient 将会被路由到与之前相同的代码,只是现在这个路径没有被嵌入到代码中,而是完全独立的。

在这些例子中,我们只使用了 GET 路由,因为它们足够简单,而且不会改变内容。最初,我们并不打算让我们的一套配方从任何地方改变,而是从文件中改变。然而在更一般的环境下,可以从 Cro 使用 PUT 和 POST 来创建新的资源,也可以使用 DELETE 来删除它们。只需将路径中的 get 语句改为 post、put 或 delete 即可。这是一个简短的"茶水间" web 服务的例子,它介绍了茶水间中的食材,显示了存储的食材,还可以删除其中的一种。

sub keep-routes is export {
    route {
        put -> Str $ingredient where $rrr.is-ingredient($ingredient) {
            $pantry ∪= $ingredient;
            say $pantry;
            content "application/json", $pantry.list;
        }

        get ->  {
            content "application/json", $pantry.list;
        }

        delete -> Str $ingredient where $rrr.is-ingredient($ingredient) {
            if $ingredient ∈ $pantry {
                $pantry ∖= $ingredient;
            }
            content "application/json", $pantry.list;
        }
    }
}

请求的内容是以完全相同的方式检索的。它将作为一个参数被处理到路由中,我们可以用之前的方式为它添加签名检查。 $pantry 变量已经被定义为一个集合,我们对它使用集合操作,以便添加一个新的成分,或者使用\集差操作符删除它。在每一种情况下,请求都会返回 pantry 的当前状态,将其转换为 list,因为 content 只接受可以实际转换为 json 的数据结构。我们也可以使用 curl 进行请求。

% curl -X PUT http://localhost:31415/pantry/Rice
[{"Rice":true}]
% curl -X PUT http://localhost:31415/pantry/Tuna
[{"Tuna":true},{"Rice":true}]
% curl http://localhost:31415/pantry
[{"Tuna":true},{"Rice":true}]
% curl -X DELETE http://localhost:31415/pantry/Tuna
[{"Rice":true}]

当一个集合被转换为一个列表时,它被转换为一个对的列表("Element" ⇒ True),这就是为什么在这里这样显示。如果我们想只显示元素,我们可以简单地只提取对的键。

这个配方给你提示了 Cro 的可能性。我们将在接下来的配方中看到更多的可能性。但在这些配方之前,你可能想跳转到本章的最后一个配方,关于测试这个非常微服务。因为测试很重要。

11.2. 食谱 11-2. 使用 Web 套接字连接到客户端

11.2.1. 问题

你想在你的网站上创建一个交互式服务,例如,通过提供一个 websocket 接口,创建一个机器人。

11.2.2. 解决办法

Cro 是一个通用的网络计算框架。它可以路由你的 websocket 调用,并创建一个你可以反应的供应。一个特定的模块,Cro::WebSocket,是用来处理 websocket 的,从客户端或从服务器端。我们将更多地关注服务器端。

11.2.3. 它是如何工作的

Websockets 是一种相对较新的技术,可以用来使网站更加动态和响应。由于它们打开了一个永久的连接,而不是普通的 vanilla HTTP 使用的无状态连接,它们可以用来实现迭代服务,如聊天或小机器人。

我们要创建一个迭代服务,一个小小的卡路里计算机,它可以获取一种食材的数量,并返回其中的卡路里数量。例如,用户可能会输入一个250克的苹果,会得到该量的苹果的卡路里。

我们将为此创建一个 websocket 服务器。我们将使用 cro 命令行工具为实现生成一个存根。这个工具是独立于 Cro 所拥有的其他模块,从 Raku 生态系统中安装的,一旦完成,就可以从命令行中使用它。将自己放置在你想要模块所在的目录中后,写下类似下面的内容。

cro stub http calories calories ':!secure :websocket'

我们将在一个名为 calories 的子目录中生成一个名为 calories 的 HTTP 服务,该服务将使用 HTTP(而不是HTTPS,也就是 !security,意思是"不安全"),并且它将成为一个 websocket 服务器(因此,副词形状的标志)。所有选项都用单引号写。

这将生成一大堆文件,包括一个 Dockerfile 和一个 META6.json 文件,以及一个启动服务器的脚本,并包含一个名为 Routes 的模块,其中将包含服务。我们将按原样使用服务器,我们将在 Routes 模块上下功夫,该模块还包括一个用于响应请求和服务结果的锅炉板。这将是 lib 子目录下的一个模块。它还将生成一个像这样的 Cro 配置文件。

---
name: calories
env:  []
entrypoint: service.p6
links:  []
endpoints:
-
id: http
    port-env: CALORIES_PORT
    name: HTTP
    host-env: CALORIES_HOST
    protocol: http
id: calories
cro: 1
...

这里的要点是,它使用了一系列环境变量,专门与这个服务一起定义。这是微服务的标准最佳实践。它还为这个服务定义了 Cro 版本(1)(在 "cro" 键中)和一个 ID,也是卡路里。原则上,我们不需要进一步担心这个文件,尽管我们可能要改变环境变量的名称或入口点的名称。这个文件也会被 Cro 命令行用来启动服务。

总之,这就是命令行要为我们做的工作范围。我们还得编写路由。首先,我们要负责解析将通过套接字向我们发送的"命令"。在上一章中,我们解析了一部分配方文件的配料行。然而,我们在实际处理完整的配料描述本身时却停了下来。我们现在就需要这样做,这样我们就可以制作一个新的语法来处理这个问题。

让我们先重用我们能重用的东西。语法已经由元对象协议创建,与 Raku 中的其他类型对象一样。它们在很多方面都类似于类。它们也类似于角色吗?嗯,是的,它们是。所以,让我们把两个标记分拆成它们自己的角色,可以说是一个 grammarole,这样我们就可以重复使用它们。

unit role Raku::Recipes::Grammar::Measures;
token quantity { <:N>+ }
token unit     { "g" | "tbsp" | "clove" | "tbsps" | "cloves" }

好吧,去过,做过。这看起来像一个语法,行为也像一个语法,但实际上是定义为一个角色。作为一个角色,我们可以像这样把它混入 grammar 中。

use Raku::Recipes::Roly;
use Raku::Recipes::Grammar::Measures;

my @products;
BEGIN {
    @products = Raku::Recipes::Roly.new.products;
}

unit grammar Raku::Recipes::Grammar::Measured-Ingredients does Raku::Recipes::Grammar::Measures;
token TOP      { <quantity> [\h* <unit> \h+ <ingredient> | \h+ <ingredient>] }
token ingredient {:i @products }

在这个新的语法中,除了组成一个角色之外,还有几个有趣的地方。我们使用 BEGIN 块来初始化一个变量,这个变量将在里面使用。我们需要知道类中哪些产品是可用的,才能正确地进行解析。BEGIN 就是所谓的相位器,也就是保证在编译过程的某个阶段运行的块。这将在编译时运行,并且只运行一次,给 @products 分配一个值,这个值将被烘焙到存储的预编译二进制中。

但是,这两个 token 也很有意思。一个使用这个变量,实际上相当于 "product1" | "product2"…​…​以此类推,对数组中的每一个产品都是如此。数组可以在 regex 中进行插值(token 只是 regex,记住),达到这个效果。我们还在开头加了一个副词,表示它将不区分大小写。我们不在乎人们是写 pasta 还是 Pasta 或者 PaStA。它仍然会被选中。

TOP token 还使用了交替:要么我们有1个鸡蛋这样的东西(数量+原料),要么我们有100克苹果这样的东西(数量+单位+原料)。这个交替就解决了这个问题,能够同时匹配这两种情况。

注意: 我们在 Raku::Recipes 模块中也用稍微不同的方式解决了这个问题,使用了正则表达式和多个schedule。记住,在 Raku 中做事总是不止一种方式。

让我们进入 websocket 服务器本身。首先,我们需要建立一个客户端,否则我们将没有任何东西来检查它。一种选择是 websocat (https://github.com/vi/websocat/releases),但是,为了再次说明这种服务器与各种客户端的互操作性,我们将使用以下脚本。它改编自 ws 的例子,用 JavaScript 编写,使用 deno 运行时运行。

import {
  connectWebSocket,
  isWebSocketCloseEvent,
  isWebSocketPingEvent,
  isWebSocketPongEvent,
} from "https://deno.land/std/ws/mod.ts";
import { encode } from "https://deno.land/std/encoding/utf8.ts";
import { BufReader } from "https://deno.land/std/io/bufio.ts";
import { TextProtoReader } from "https://deno.land/std/textproto/mod.ts";
import { blue, green, red, yellow } from "https://deno.land/std/fmt/colors.ts";
const endpoint = Deno.args[0] || "ws://127.0.0.1:31415/calories";
/** simple websocket cli */
try {
  const sock = await connectWebSocket(endpoint);
  console.log(green("«Calories» webservice connected! (type 'close' to quit)"));
  const messages = async (): Promise<void> => {
    for await (const msg of sock) {
      if (typeof msg === "string") {
        console.log(yellow(`< ${msg}`));
      } else if (isWebSocketCloseEvent(msg)) {
        console.log(red(`closed: code=${msg.code}, reason=${msg.reason}`));
      }
    }
  };
  const cli = async (): Promise<void> => {
    const tpr = new TextProtoReader(new BufReader(Deno.stdin));
    while (true) {
      await Deno.stdout.write(encode("> "));
      const line = await tpr.readLine();
      if (line === null || line === "close") {
        break;
      } else {
        await sock.send(line);
      }
    }
  };
  await Promise.race([messages(), cli()]).catch(console.error);
  if (!sock.isClosed) {
    await sock.close(1000).catch(console.error);
  }
} catch (err) {
  console.error(red(`Could not connect to WebSocket: '${err}'`));
}
Deno.exit(0);

这个脚本会出现一个提示,会通过 websocket 发送你输入的内容,如果你写的是 close 或空行,就会关闭连接。默认情况下,它使用的是 websocket 的 URL,即 ws://127.0.0.1:31415/calories(但你可以在命令行中使用一个参数来更改)。Web 套接字使用 ws(或 wss,对于安全的)协议,地址、端口和片段与 HTTP URL 差不多。我们要等到我们的 Webservice 设置好后再运行它。

下面是为 calories websocket 定义的路由,由之前生成的模板扩展而来。

use Cro::HTTP::Router;
use Cro::HTTP::Router::WebSocket;
use Raku::Recipes::Grammar::Measured-Ingredients;
use Raku::Recipes::Roly;
my $rrr = Raku::Recipes::Roly.new;

sub routes() is export {
    route {
        my $chat = Supplier.new;
        get -> 'calories' {
            web-socket -> $incoming {
                supply {
                    whenever $incoming -> $message {
                        $chat.emit(await $message.body-text);
                    }
                    whenever $chat -> $text {
                        # Compute calories here
                        my $item =
                                Raku::Recipes::Grammar::Measured-
                                Ingredients
                                .parse( $text );
                        my $calories = $rrr.calories( ~$item<ingredient>, +$item<quantity>);
                        emit "Calories: for $text ⇒ $calories";
                    }
                }
            }
        }
    }
}

虽然这里有一定的大括号超载,但其要点在最后的非闭括号行。我们得到文本,使用语法对其进行解析,然后从解析对象中提取成分和数量。

注意 我们可以,事实上也应该,验证单位是否相同。这个脚本会把 100tbsp 的意大利面,33g的鸡蛋,以及类似的东西传递给我们。我们可以尝试在语法层面,或者在这个语法层面来捕捉这些错误,但是我们暂时对正确的字符串被正确解析的事实感到满意。

这些都被输入到计算卡路里的例程中,然后返回一个带有结果的字符串。服务的名称,或者说通往它的路径,是作为一个参数给出的,以获取,这就是为什么我们使用上面的 /calories URL来访问这个服务。

从大的方面来看,它比这更复杂一些,涉及到几个耗材。但从本质上讲,从 Raku 的角度来看,websocket 服务器路由是一个供应品,其排放将到达客户端。我们发出结果字符串,该字符串将通过 websocket 被客户端接收。但实际发生的情况是,套接字通过 websocket 接收到一个传入的消息,该 websocket 解析消息,并通过我们为 websocket 创建的一个特定的供应,重新发出正文,这就允许对传入的请求进行异步处理。实际的处理是由第二个供应的接收端完成的,我们仍然像在模板代码中那样调用 $chat。总而言之,比 REST API 的处理方式略微复杂一些,但本质上和其他语言的处理方式类似,只是使用了 Raku 标准的数据结构和功能。

我们没有改变服务器脚本,所以我们直接从那里运行它。Comma IDE 包含了一个运行 Web 服务的特殊设施;我们将在这个场合使用它。我们创建一个执行配置,如图11-3所示。

图11-3. 从 Comma IDE 运行 websocket

注意,我们在图11-3中定义了我们要运行的两个环境变量。我们将使用 localhost 和常用的端口来运行。Raku 参数显示了我们需要包含的库的路径,Raku::Recipes,在 lib 处,还有路由,在所示路径中。

现在我们可以启动我们的客户端,并输入请求,得到的答案如图11-4所示。

图11-4. 从 deno 客户端消耗 websocket

我们使用命令行。

deno  run --allow-net ws-client.ts

deno 需要明确允许使用网络,因为它运行在类似浏览器的沙盒中。正如你所看到的,它返回计算出的卡路里,以不同的颜色显示,并再次打开提示。关闭一词将有效地关闭连接。

对于 websockets,你也可以使用浏览器开发者控制台中的纯 JavaScript,通常按 Shift+Ctrl+C 就可以进入。在 Firefox 中的结果如图11-5所示。

图11-5. 从 Firefox 开发者控制台使用 websocket 进行工作

我们打开连接,然后设置一个事件,每次收到消息都会触发。我们记录消息所附带的数据,然后手动向套接字发送消息,在控制台中得到响应。

一般来说,这些 websocket 服务会作为一个小部件在网站内实现,或者作为一个微服务来为其他微服务服务。无论如何,这个配方已经向你展示了如何从头开始创建一个 websocket 微服务,如何从 Comma IDE 中运行它,以及如何使用不同的客户端服务来验证它的工作。

11.3. 食谱 11-3. 为 Telegram 这样的消息应用程序创建一个迷你机器人

11.3.1. 问题

Telegram 和 Slack 等消息应用程序提供了另一种用户界面,即对话式界面,可以用来回答简单的询问,存储信息,甚至创建小游戏。这些请求可以在任何时候到达,对它们的响应必须是即时的,不会阻塞程序,而且还要尽可能快。我们需要一个敏捷的程序来快速响应这些请求。

11.3.2. 解决方案

有了我们在食谱 11-2 中创建的微服务,我们可以再创建一个层,通过这种方式挖掘它来服务信息。如果需要,我们也可以直接使用类中的业务逻辑。无论如何,Telegram 和 Slack 都有一个开源的 API。Slack 在 Raku 生态系统中的覆盖率没有那么高,只有一个模块可用于发送消息。至少有两个 Telegram 模块我们可以使用,所以我们将用其中一个模块创建一个 Telegram 机器人。

11.3.3. 它是如何工作的

我们将创建一个 Telegram 机器人,它可以计算卡路里的数量,给定一种原料的数量和单位。这意味着的第一件事是解析字符串,以便我们获得原料的单位、数量和名称。但我们之前已经做过了,对吗?对,虽然它允许错误的单位。让我们给这个配方来个转折。毕竟,我们对机器人的标准还挺高的,比其他类型的服务要高,对吧?

实现这个修复的方法有很多,例如,我们可以简单地从语法中发出一个语法错误,拒绝解析错误的字符串。然而,这说起来容易做起来难。在语法的构造方式中,当我们到达成分时,我们已经解析了单位和数量。在那一刻,我们可以尝试将原料与单位匹配,如果没有匹配,就拒绝解析,但错误不会在原料本身,而是在单位,所以我们需要回溯。我们将不得不重新组织整个语法,并通过回溯使整个过程变得更慢。

小贴士:顺便说一下,各个层次的语法在同一家出版社出版的一本书《Parsing with Perl 6 Regexes and Grammars》(Moritz Lenz) 中都有很棒的解释,如果你对它们很好奇,或者想更广泛地使用它们,不妨一试。

幸运的是,我们可以使用 Raku 机制在不同的层次上捕捉到这个错误,那就是通过一个动作。语法会给你一个抽象的语法树(AST),而附加在语法上的动作则会把这个 AST 编译成一个你可以随时使用的对象。到目前为止,我们一直在提取 AST 的部分内容来获取成分,或者其他什么。现在我们将一举解决两个问题:得到一个不需要知道解析结构的解析对象,以及检查是否在某个层次上有什么不正确的地方。我们将为这两步过程创建这个动作。

use Raku::Recipes::Roly;
use X::Raku::Recipes;
my $rrr = Raku::Recipes::Roly.new();
unit module Raku::Recipes::Grammar::Actions;

class Measured-Ingredients {
    method TOP($/) {
        my $unit = $/<unit>.made // "Unit";
        my $ingredient = $/<ingredient>.made;
        if ( $rrr.check-unit( $ingredient, $unit ) ) {
            make $ingredient =>  $unit => $/<quantity>.made;
        } else {
            X::Raku::Recipes::WrongUnit.new( desired-unit => 'Other',
                    unit => $unit ).throw;
        }
    }
    method ingredient($/) {
        make tc ~$/;
    }
    method quantity($/) {
        make +val( ~$/  ) // unival( ~$/ )
    }
    method unit($/){
        make ~$/;
    }
}

一个 grammar action,在一个层面上,只是一个类。该类的实例将被嵌入到相应的语法中并产生结果。它的命名需要以某种方式与它们所服务的语法耦合,这就是为什么在这种情况下,它被称为Raku::Recipes::Grammar::Actions::Measured-Ingredients。通过将这个类放在一个模块中,我们已经将所有我们将在这里创建的动作放到了同一个 Raku::Recipes::Grammar::Actions 命名空间中,而且我们也可以通过使用一个单一的使用子句将它们一起加载。

类本身为语法中的每一个 token 都有方法;这些方法获得匹配对象 $/ 作为参数,在该点进行匹配。方法获取该对象并生成另一个对象,该对象在该点被附加到抽象语法树上。make 命令在该点将其参数附加到 $/ 上;make 相当于 $/.make。如果我们在单元标记处将某物附加到 $/,那么该结果将在该标记之上的任何地方可用,即 $/<unit>.made

记住,$/ 是一个匹配对象。我们需要把它强制转换成其他的东西,这就是为什么我们在 unit 中把匹配对象转换为字符串,而在 ingredient 中,我们使用 tc(标题大小写)命令转换并大写它(记住 ingredients 总是大写)。对于测量,我们做的事情和之前解析 CSV 时做的事情类似:根据 val 工作(ASCII数字)与否(对于 ⅔ 这样的东西),我们使用不同的方法将其转换为数字。

但是这个动作的关键是在 TOP 方法中;这是我们检查单位是否正确的地方,使用 Raku::Recipes::Roly 中适当调用的 check-unit 方法。如果它是正确的,我们返回一个对,其键是原料,其值是另一个对—​单位和数量。当没有单位时,我们也很方便地使用了 Unit,例如,在字符串 "3 Apple" 中。

注意,只要我们挑剔,我们也可以尝试在语法中哄骗复数。然而,我们需要在原始数据中添加更多的信息,以避免像3汤匙橄榄油或300克金枪鱼(tunae?)这样的东西表现为假阳性。如果需要的话,我们会去做,但目前我们对这类错误要宽容一些。

我们可以这样使用这个动作。

my $item = Raku::Recipes::Grammar::Measured-Ingredients.parse("2 egg",
        actions => Raku::Recipes::Grammar::Actions::Measured-Ingredients.new);

它将产生一个这样的对象。Egg ⇒ Unit ⇒ 2。这是相当方便的,我们可以将它用于我们的 Telegram 机器人。

如果你还没有使用它,Telegram 是一个消息应用程序,最初是由一个俄罗斯程序员创建的,它已经变得非常流行,这要归功于 的安全功能,灵活性,以及创建机器人的能力,这是其他商业聊天应用程序,如 WhatsApp 缺乏的。与 WhatsApp 不同的是,它还可以在手机关机的情况下工作,而且它有一个不错的、开源的、适用于所有平台的桌面应用。创建一个机器人是相当简单的,通过与一个叫 BotFather 的机器人对话来完成(还能怎么做?图11-6显示了我们如何创建这个。

图11-6. 通过与 BotFather 对话创建 RakuRecipesBot。

这个对话会给你一个密钥,我在这里把它模糊了。这是一个长长的字符串,将是你启动机器人微服务的 token。

机器人的工作方式与微服务类似:它收到一条消息,并对其进行回复。它需要解析该消息以寻找特定的命令,或者理解该消息以给出答案。在这种情况下,我们会让我们的机器人以成分的措施来回应卡路里含量,比如我们在 websocket 服务器(或 websocker)中使用的那些。所以本质上,它将会攻克一个消息供应,并使用 API 来响应它们。这就是我们在这个程序中所做的事情。

use Telegram;
use Raku::Recipes::Grammar::Measured-Ingredients;
use Raku::Recipes::Grammar::Actions;
use Raku::Recipes::Roly;

my $bot = Telegram::Bot.new(%*ENV<RAKU_RECIPES_BOT_TOKEN>);
my $rrr = Raku::Recipes::Roly.new;

$bot.start(1);

react {
    whenever $bot.messagesTap -> $msg {
        say $msg.raku;
        my $item =  Raku::Recipes::Grammar::Measured-Ingredients.parse(
                $msg.text,
                actions =>
                    Raku::Recipes::Grammar::Actions::Measured-Ingredients
                            .new).made;

        if $item {
            my $calories = $rrr.calories( $item );
            $bot.sendMessage($msg.chat.id,
            "{$item.value.value} {$item.value.key} of {$item.key} has $calories calories");
            say "{ $msg.sender.username }: { $msg.text } in { $msg.chat.id } → $item";
        } else {
            say "There's something wrong with the input string; can't compute calories";
            $bot.sendMessage( $msg.chat.id,
                    "Sorry, can't compute '{$msg.text}'");
        }
    }
    whenever signal(SIGINT) {
        $bot.stop;
        exit;
    }
}

我们通过创建一个使用我们获得的 API token 的对象来启动程序。像往常一样,API token 和各种安全信息必须从环境变量中读取。我们需要创建一个 Roly 对象来计算卡路里,然后我们启动机器人,告诉它每秒钟进行一次轮询。我们可以随意改变这个时间间隔。

当一个事件发生时,反应块将被运行。当收到消息并放入 $msg 变量时,它会通过 action和 .made 方法解析成一个对象。如果没有创建对象,就说明出了问题,就会激活 else 子句。我们发送一条消息说,我们不能用它工作;$msg.text 返回消息中的实际文本。

小贴士:永远记得要对你的消息有所帮助,包括解释误解的起源。

然而,如果我们有一个解析过的成分对象,我们可以从中计算出卡路里。我们可以直接把它交给 Roly 中的 calorie 方法,它有一个新的 multi,直接取这种东西,一个 Pair,其值也是一个 Pair。

multi method calories( Pair $ingredient ) {
    return self.calories( $ingredient.key, $ingredient.value.value );
}

既然我们已经知道这个单位没问题,我们就不需要给出这些信息来计算卡路里。它将返回一个数字,我们将把它作为消息发送,回应发出命令的特定用户;sendMessage 将聊天 ID 作为参数,以确保消息路由正确。在这两种情况下,我们也会向控制台打印一些信息来检查是否一切正常。这些信息不会被用户接收。

脚本从控制台或其他任何地方运行。它需要运行,它才能响应消息。有些人使用 el cheapo Raspberry Pis 在家里有一个机器人农场,一直在运行,但你也可以使用免费层级的云服务让它永久运行,例如在一个容器内运行。它的工作原理将如图11-7所示。

图11-7. 获取不同菜肴的热量

原则上,一个机器人可以做的事情是无限的。比如说,你可以添加认证功能。所有的配置都是通过 BotFather 来完成的。你也可以做繁重的任务,在这种情况下,你要增加并发性,以便在后台做其他任务。

Telegram 可以做的事情之一是响应命令。这些都是前面带 / 的"斜线"命令,通过在消息框中键入来发出。这些命令没有什么特别的,除了你可以用 BotFather 定义它们,当你输入斜杠时,UI 会列出它们。无论如何你都要对它们进行解析。然而,这很方便,因为它给你提供了一个简单的方法来解释机器人是什么以及它是如何工作的。让我们用三个命令 --about/products/calories --在这个脚本的第二个版本中工作(只包括反应块,因为它是唯一改变的东西)。

whenever $bot.messagesTap -> $msg {
        $msg.text ~~ /\/$<command> = (\w+) \h* $<args> = (.*)/;
        say "$msg $<command>";
        given $<command> {
            when "calories" {
                gimme-calories($msg,$<args>)
            }
            when "products" {
                $bot.sendMessage($msg.chat.id, @products.join("-"));
            }
            when "about" {
                $bot.sendMessage($msg.chat.id, q:to/EOM/);
To query, use /calories Quantity [Unit] Ingredient or /products for the list of products
EOM
            }
        }
}

我们需要对命令进行解析,我们使用正则表达式进行解析。一个命令永远是一个由字符组成的字符串,然后它可能有也可能没有一些更多的文本。如果有更多的文本,它将被捕获在 $<args> 中。

根据命令的不同,我们会发回不同的信息。前面计算卡路里的代码已经被分拆成自己的例程,叫做 gimme-calories。对于 about 命令将返回的消息,我们使用所谓的 here-to 语法,我们在第三章中第一次使用了这种语法。结果如图11-8所示。

图11-8. 使用斜杠命令与机器人对话

当我在这时,我给了机器人一个有趣的脸。它现在看起来更友好了 而且效率很高。

通过 Telegram 提供服务可以是一种很好的、方便的方式来提升你的业务,也可以做各种家庭自动化任务,甚至可以作为只基于网络的服务的补充。有了足够的后端,你就可以为它们提供服务,鉴于你可以使用 Cro 创建并发后端,如果需要的话,你可以将其与共享同一云实例的 websockers 或 web 服务以及数据服务一起嵌入。你将在接下来的章节中了解更多关于这些选项的信息。

11.4. 食谱 11-4. 测试你的微服务

11.4.1. 问题

如果没有经过测试,它就坏了。这也适用于微服务。归根结底,它们是另一种类型的函数,你需要检查其结果。因此,必须对它们的每一个方面进行测试,就像测试其他模块中的其他函数一样。

11.4.2. 解决办法

Cro 包含一个测试框架,叫做 Cro::HTTP::Test。它可以很好地完成这项工作。

此图将以黑白打印

11.4.3. 它是如何工作的

请注意,如果你是刚从第一个配方(食谱 11-1)中获得这个配方,那是极好的选择。如果不是,那个配方包含了我们要测试的微服务,所以你可能想先把它做完。

首先,你需要和本章的第一个配方一起进行。API 和它的测试需要同时开发,在本书中只是为了处理每个配方中的一个概念而将其分开。

理论上,路由只是简单的函数,所以应该有一种方法可以简单地通过调用它们并观察结果来测试它们。然而,实际上它们比这更复杂一些,因为它们是通过 HTTP 请求调用并返回 HTTP 响应的。那么理论上,我们可以直接启动服务,并为它们创建一系列的测试,黑盒式的。我们之前使用过的 Postman 也可以进行编程,有了它,我们可以用几种不同的语言创建一个黑盒测试套件。

我们对白盒测试比较感兴趣,因为我们可能想知道哪些代码路径是被占用的,而且不需要实际启动服务器、占用端口等。所以我们将使用一个本地的测试库。Cro::HTTP::Test。和其他 API 测试库的工作方式一样,它只需要访问需要测试的路由。在内部,它产生请求和响应,但不实际运行服务器。下面是一个使用它的简单测试。

use Cro::HTTP::Test;

require "ingredients-microservice-v3.p6" <&static-routes>;
test-service static-routes, {
    test get('/'),
            status => 200,
            content-type => 'text/html',
            body => /recipes/;
}
done-testing;

在加入测试库后,我们需要 "require" 实现微服务的程序,并提供其正确的路径。与 use 不同的是,require 不会自动导入符号,所以我们需要在编译时声明它们。我们暂时只测试静态路由,所以我们需要导入这个。像我们一样将路由块声明为独立的例程,在这里也有好处。Cro::HTTP::Test 通过调用路由块来测试路由。如果直接把它们写进一个单独的变量中,我们就无法做到这一点。或者,我们可以吗?事实上,我们可以,因为在第二个版本中,我们将路由分配给的变量实际上是一个子程序。

但是,等等。require 是做什么的?还有,如果我不记得真正导出它,它怎么能导入例程呢?好吧,我们是有点作弊了。我们必须这样修改之前的程序(为了可读性,部分不变的部分被抑制)。

sub static-routes is export { ... }
sub type-routes is export { ... }
sub ingredient-routes is export { ... }
# Route block definition here
if ( $*PROGRAM eq $?FILE ) {...} # Fire up service

所以我们必须明确声明例程是可以导出的,这样才能导入测试。但更重要的是,require 会编译并运行它所加载的内容。

请注意,当你通过使用加载一个模块时也会发生同样的事情;定义之间的任何代码都将被运行,以及在该阶段运行的块—​例如,BEGIN 块。这是每一次加载的副作用,而且在这种情况下我们并不真正需要,因为它将启动服务页面的事件循环,并阻止在脚本中运行任何其他内容。

所以,像其他语言一样,我们检查文件是直接运行还是作为模块加载。$*PROGRAM$?FILE 是系统定义的变量,取当前脚本使用的路径的值,以及当前文件的句柄。如果它们是一样的,那么,我们实际上是在同一个文件中运行。在字符串上下文中,$?FILE 会自动转换为字符串。如果我们正在测试,$*PROGRAM 将是测试程序的名称,而 $?FILE 将不会改变。在这种情况下,它们将是不同的,它将不会运行。

那么,回到测试本身。该模块引入了 test-service 命令,它的参数是路由例程的名称,或者,等等,routingine,以及有效启动测试的块。在它里面,我们需要为每一个测试提供一个测试命令,而这个命令首先使用 HTTP 命令和路由。由于该路由没有附加到任何 URL 片段,所以这里使用的路径将从根目录开始,/

在这种情况下,我们测试的是路由的静态部分,即应该返回静态页面的部分;/ 应该返回索引。在响应中,我们检查状态码是否正确(200),类型是否正确(text/html),以及正文中的内容是否正确。它应该包括 regex/recipes/,它包含在 css 文件的名称中,raku-recipes.css

程序打印的结果如下。

ok 1 - Status is acceptable
    ok 2 - Content type is acceptable
    ok 3 - Body is acceptable
    1..3
ok 1 - GET /
1..1

测试是保证软件质量的过程,好的测试不仅仅是告诉你一切正常,而是指出一些可以修复的怪癖。在这个案例中,虽然我们通过修改脚本展现了乐乐的灵活性,但还有更多的方法,其中之一就是简单的为路由创建一个模块。这第一次测试就产生了重构,这是好事。

这是我们为路由创建的模块。

use Cro::HTTP::Router;
use Raku::Recipes::Roly;
use Raku::Recipes;

unit module My::Routes;

our $rrr = Raku::Recipes::Roly.new();
my Set $pantry;

sub static-routes is export {
    route {
        get -> *@path {
            static 'build/', @path, :indexes<index.html>;
        }
    }

}

sub type-routes is export {
    route {
        get -> Str $type where $type ∈ @food-types {
            my %ingredients-table = $rrr.calories-table;
            my @result =  %ingredients-table.keys.grep: {
                %ingredients-table{$_}{$type} };
            content 'application/json', @result;
        }
    }
}

sub ingredient-routes is export {
    route {
        get -> Str $ingredient where $rrr.is-ingredient($ingredient) {
            content 'application/json', $rrr.calories-table{$ingredient};
        }
    }
}

sub keep-routes is export {
    route {
        put -> Str $ingredient where $rrr.is-ingredient($ingredient) {
            $pantry ∪= $ingredient;
            say $pantry;
            content "application/json", $pantry.list;
        }

        get ->  {
            content "application/json", $pantry.list;
        }

        delete -> Str $ingredient where $rrr.is-ingredient($ingredient) {
            if $ingredient ∈ $pantry {
                $pantry ∖= $ingredient;
            }
            content "application/json", $pantry.list;
        }
    }
}

事实上,我们取消了 not-found 命令,这些命令是为了说明问题,因为如果没有找到该名称的路由,就会返回同样的内容。这是重构的一部分,当然,我们要对它进行测试。我们将其命名为 My::Routes,并将其放入 Chapter-11 文件夹的 lib/ 子文件夹中。

这就是新的测试脚本。

use Cro::HTTP::Test;
use My::Routes;

test-service static-routes, {
    test get('/'),
            status => 200,
            content-type => 'text/html',
            body => /recipes/;
    test get('/index.html'),
            status => 200,
            content-type => 'text/html',
            body => /"Recipes: index"/;
    test get("/foo"),
        status => 404;
}

test-service type-routes, {

        test get("Dessert"),
            status => 200,
            json => /Apple/;

    test get("foo"),
            status => 404;
}

test-service ingredient-routes, {
    test get("Apple"),
            status => 200,
            content-type => "application/json",
            body => *<Vegan> == True;
    test get("Olive%20Oil"),
            status => 200,
            content-type => "application/json",
            body => *<Vegan> == True;
    test get("Fishtails"),
        status => 404;
}

done-testing;

我们为每一个 routingine 都有一个测试块,在每一个测试块中,我们都会测试一些应该有的东西,但也会测试一些不应该有的东西。在微服务中,当事情没有问题的时候,总是要返回正确的状态,但当事情没有问题的时候,也要返回正确的状态。测试命令使用了一个位置参数,也就是我们要测试的路由。由于这些路由是独立的,所以它们都将以/为起点,就像我们在这个配方的上一次迭代中测试的静态路由那样。其余的命名参数将检查状态和返回的 MIME 类型,然后对 body 进行检查。由于得到 JSON 响应是很常见的,所以可以像这样同时测试是该类型和 body。

test get("Dessert"),
    status => 200,
    json => /Apple/;

成功的测试和我们之前看到的类似。它们会检查状态、类型(在两个动态路由的情况下,类型将是 application/json)和一些应该在 body 中的东西。在 JSON 响应的情况下,它将作为默认变量可用,因此我们可以对它运行其他检查,例如,如果它们是素食主义者;这就是我们为苹果做的事情。

404 测试就简单多了:你只需要检查它们是否返回该代码。状态码在头部,所以没有必要检查正文。这也是一种防御性编程。"未找到"应该总是以这种明确的方式返回,而不是通过200个成功响应的正文中的错误代码。

这些白盒测试覆盖了所有可能的代码路径。它们可能不会覆盖所有可能的数据路径,尽管在这种情况下,没有可能的角案例需要担心。一个问题可能是如何处理带有两个单词的成分(因此,一个空格),但只需将空格的 URI 编码为 %20 就可以了。

test get("Olive%20Oil"),
        status => 200,
        content-type => "application/json",
        body => *<Vegan> == True;

通过该模块,你可以轻松地将这些测试插入到持续集成服务中,并确保你将部署的所有软件的质量。

11.5. 食谱 11-5. 应对 Web Hooks

11.5.1. 问题

网络钩子简单的说就是监听网络上其他地方发生的事件的程序,它们正在监听这些事件,当这些事件发生时对它们做出反应。当一些内部事件发生时,外部程序如源代码管理站点或持续集成站点会激活一个钩子,并期望从这些事件中得到响应。与消息应用一样,我们需要以这样的方式来响应每一个事件,这样做不会阻塞应用,同时也要及时有效地响应。

11.5.2. 解决方案

本质上,一个响应 web 钩子的应用就是一个 web 服务,只不过它有一些特定的格式,需要遵循特定的准则进行响应。同样,用 Cro 制作的 web 服务也可以这样工作。由于我们需要实时的,或者至少是足够快的响应,一个好的解决方案将是创建并发的程序,在独立的线程中响应钩子。这样一来,对进一步传入的钩子的响应就不会被阻断,而且可以利用计算能力来提供对事件的快速响应。

11.5.3. 它是如何工作的

本质上,我们需要的是一个通过一些 API 请求触发的动作。例如,由于我们有一个从文件中重建网站的脚本,如果它所依赖的任何文件发生变化,我们可以触发重建。我们需要创建一个路由来分析该请求,并对其进行充分的响应。

重要的是,这些 web 钩子是异步工作的。这些操作可能需要很长的时间,所以它们不能挂起服务器并阻止它响应任何其他请求,所以我们将创建一个任务,等待信号开始重建网站。

并发是一个很好的概念:它允许你用并行的方式工作,但水平很高。包含这些设施的语言并不多。Go 是其中之一,还有 Scala 和 Erlang。Raku 的并发设施与我们在 Go 中看到的设施类似:它是 Hoare 的并发顺序进程的实现,不同的进程通过通道进行状态通信,没有直接通信或(理想情况下)访问共享内存。

在上一章,我们使用模板生成了网站。在这一章中,整个站点生成例程已经使用 Raku::Recipes::Texts 类中的一个方法纳入,我们只需从这个程序中调用它。这将通过频道中的消息触发构建。

use Raku::Recipes::Texts;

my $builder = Channel.new;

my $p = start {
    react {
        whenever $builder {
            say "Building…";
            my $recipes-text = Raku::Recipes::Texts.new();
            $recipes-text.generate-site()
        }
    }
}

await (^3).map: -> $r {
    start {
        sleep $r;
        $builder.send($r);
    }
}

$builder.close;
await $p;

这个程序在很大程度上受到了文档中例子的启发,试图说明并发操作是如何工作的。

首先,我们有通道;我们调用 $builder 的通道将被用来触发构建。实际的消息其实并不重要,我们将利用消息的存在来触发构建。我们启动一个线程来做这件事,这个线程包括一个反应块,每当 $builder 通道收到消息时,这个线程就会"唤醒"并构建网站。

与文件系统的任何交互可能都必须伴随着锁定,以避免竞赛条件。在这种情况下,两个线程可能要同时修改一个文件。我们稍后再来讨论这个问题。目前,我们的重点是基本的并发机制。

当然,我们需要生成这些消息。我们创建一个循环,启动三个线程,每一个线程都以越来越多的秒数发出一条消息(感谢睡眠延迟)。由于 await 在循环的前面,所以直到三个承诺都完成,也就是三秒后才会结束。然后,程序流将进入下一条指令,关闭通道,这条指令将传播到看该通道启动的线程,并将其也关闭,实现该承诺。我们再一次等待最后一条指令中的那个承诺,这样程序最终会退出。

基本的想法是,对消息的响应不会像在异步系统中那样,占用单个顺序程序,甚至等待事件循环的到来。如果其中一个线程还没有完成,线程会并行唤醒。它们还需要避免同时在同一个文件系统片段上工作。

但是一旦我们有了概念上的脚手架,我们就可以在真正的路线中设置这个,比如这个。

use Cro::HTTP::Router;
use Raku::Recipes::Texts;

my $lock = Lock::Async.new;
my $builder = Channel.new;
my $p = start {
    react {
            whenever $builder {
                say "Waiting for lock…";
                $lock.protect: {
                    say "Rebuilding";
                    my $recipes-text = Raku::Recipes::Texts.new();
                    $recipes-text.generate-site();
                    say "Rebuilt";
                };

            }
    }
}

unit module My::Rebuild;

sub rebuild-route is export {
    route {
        get -> {
            $builder.send(True);
            content 'application/json', my %result = %( :building("Started") );
        }
    }

}

我们增加的是一个锁。一个锁可以确保不会有两个线程同时运行那段代码,所以在它的监视下不会发生竞赛条件。我们使用的是异步锁,它会等到被释放后才会工作。所以这个 routingine 将设置锁、通道和响应重建命令的线程。

注意,我错过了在这里使用锁、枪托和枪管的机会,好吧,没关系。

这些命令将从路由发出,也就是下面的。网络钩子将会是类似 http://localhost:31415/rebuild 的东西,并且会发送一条消息,任何消息,给这个频道—​比如说可以是 "Be kind, rebuild"。然后频道会立即返回一条消息,表示它正在重建。重建将发生在幕后,服务器将立即准备好响应其他请求。

整个微服务,整合了所有的路由,如图所示。

use Cro::HTTP::Server;
use Cro::HTTP::Router;
use My::Routes;
use My::Rebuild;


my $recipes = route {
    include "content"    => static-routes,
            "rebuild"    => rebuild-route,
            "Type"       => type-routes,
            "Ingredient" => ingredient-routes;

}

if ( $*PROGRAM eq $?FILE ) {
    my Cro::Service $μservice = Cro::HTTP::Server.new(
            :host('localhost'), :port(31415), application => $recipes
            );

    say "Starting service";
    $μservice.start;

    react whenever signal(SIGINT) {
        $μservice.stop;
        exit;
    }
}

这包括了所有的分拆路由,以及这个路由,在一个单一的展示了这个 web 钩子如何与微服务的其他部分无缝集成。我们在运行服务器的控制台中的日志将显示这样的内容。

Starting service
Waiting for lock...
Rebuilding
Rebuilt
Waiting for lock...
Rebuilding
Rebuilt
Waiting for lock...
Rebuilding
Rebuilt

通过这种方式,我们在微服务架构中集成了一个 web 钩子。这个 web 钩子可以集成到一个工作流中,例如,每次从 Git 服务器接收到推送时都可以调用它。一旦代码被拉取,就可以调用这个钩子来重建服务器。例如,这可能是在这样的情况下,你有这个带有文本的食谱网站,但也有其他前端的服务(甚至是web本身),或者你可能想有一个微服务,为一系列 web 钩子服务。最基本的一点是,web 钩子一般都是执行重任的 fire-and-forget 事件,这也是为什么如果可能的话,它们必须并发完成,并且必须学会通过使用锁来与自己良好的合作。

12. 使用数据源

大多数应用程序都使用某种永久性的存储,从一个非常简单的,也许是非结构化的系统,如文本文件,到一个更复杂的,但也更有效的媒体,如数据库,无论是老式的关系型数据库,还是新的数据库,可以与任何数据结构一起工作。这些数据库提供了自己的 API,但是有了抽象层,让你可以专注于你的业务逻辑,而不用担心具体的数据访问语言,这很方便。Raku 提供了与各种数据库接口的不同方式,同时也有一个非常好的对象关系管理器 RED。你将在本章学习如何使用它们。

12.1. 食谱 12-1. 使用关系型数据库

12.1.1. 问题

你需要永久地存储和访问数据,所以你要选择一个关系型数据库,如 MariaDB、PostgreSQL 或 SQLite3。

12.1.2. 解决方案

使用通用的 DBIish 接口来访问关系型、基于 SQL 的数据库。

12.1.3. 它是如何工作的

DBIish 是生态系统中的一个模块,之所以这样命名,是因为它与 Perl DBI(DataBase Interface)模块具有相同的功能。这个模块的主要思想是为所有的数据库管理员提供一个抽象层,这样 Raku 代码就可以独立于最终用于管理数据的系统来创建。

到目前为止,我们已经将所有的数据存储在一个(不可改变的)CSV 文件中,我们将把我们的数据处理升级为数据存储。这将给我们带来访问的并发性,更快的速度,在进行搜索、插入和大量更新时,更加灵活。现代的数据存储也可以基于云端,减少了在企业内部维护的麻烦(以及成本)。

数据管理服务大致有两种,大致分为使用 SQL 的传统的和 NoSQL 的。SQL 的是描述和查询数据的,也叫关系数据库管理器(RDBM)。NoSQL 最初的意思是指 NoSQL,但现在更广泛的意思不仅是指 SQL,还包括 PostgreSQL 等更多的传统服务。NoSQL 一般指的是像 Redis 这样的键值存储或 MongoDB 这样的文档存储。我们将在本章中看到它们的一点。

12.1.4. 创建数据库和表

让我们从关系型数据库开始。它们都使用SQL来对你要包含在数据库中的数据进行建模。所以在前面,我们需要对我们以前包含在CSV表中的数据进行描述。事实上,数据库将信息存储在表中,而这些表的行中有不同的项目,所以每一个 CSV 列都会成为一个表列。这个 SQL 将定义该表。

create table recipedata (
  name varchar,
  unit varchar,
  calories float,
  protein float,
  main BOOLEAN,
  side BOOLEAN,
  vegan BOOLEAN,
  dairy BOOLEAN,
  dessert BOOLEAN
);

Varchars 相当于字符串,但存储的 chars 数量是可变的,卡路里和蛋白质一般会是浮点数,而其他特性是布尔数。

我们在这里会使用 sqlite3,因为它只是简单的存储在一个文件中,所以设置起来就简单多了,而且还可以进行测试。在生产中,可能用 MariaDB 或者 PostgreSQL 比较好,不过只要并发读写量不多,或者容量大,需要数据库复制和负载均衡,用 sqlite 也完全可以。不过一般情况下,只需要改变驱动或命令行接口,这和语言是正交的,这里的语言是 Raku,所以我们还是坚持使用。显然,你需要在你的平台中使用首选的方法安装 sqlite3,或者从其网站 https://sqlite.org/index.html。

在 sqlite3 中,数据库就是一个简单的文件,你只需要把这个作为命令行界面的输入,就可以在这个数据库中创建这个表。

sqlite3 test.sqlite3 < recipedata.sql

这样一来,表和数据库就创建好了,但现在我们要把当前的数据库迁移到这里。其实,我们可以直接将 CSV 文件导入到 sqlite3 中。

sqlite> .separator ;
sqlite>.import ../data/calories-chapter12.csv recipedata sqlite> select name, unit, calories, protein from recipedata; Rice;100g;130.0;2.7
Chickpeas;100g;364.0;7.0
Lentils;100g;116.0;7.4
Egg;Unit;78.0;13.0
Apple;Unit;52.0;0.3
Beer;1⁄3 liter;216.0;0.3
Tuna;100g;130.0;23.5
Cheese;100g;128.0;25.4
Chorizo;100g;455.0;24.0
Potatoes;100g;82.0;2.0
Tomato;100g;24.0;0.9
Olive Oil;1 tablespoon;119.0;0.0
Pasta;100g;131.0;6.6
Chicken breast;100g;101.0;32.0
Kidney beans;100g;127.0;8.7
Kale;100g;28.0;1.9
Sardines;100g;208.0;25.0
Orange;Unit;65.0;1.0
Green kiwi;Unit;42.0;0.8
Beef;100g;217.0;26.1
Cashews;100g;553.0;18.0
Sundried tomatoes;100g;213.0;5.0
Cod;85g;90.0;19.0
Skyr drink;100g;54.0;7.4

我们需要以与 CSV 文件中出现的完全相同的方式排列列名。在这种情况下,为了清晰起见,我们用另一种方式对它们进行了分组,所以我们要么改变定义,要么改变 CSV 文件,这就是为什么我们创建了这个以正确顺序排列列名的替代 CSV 文件。

12.1.5. 依赖关系的反转和单一源

我们需要我们的菜谱应用与数据存储一起工作。而我们最初的版本,在 Raku::Recipes::Roly 类中烘焙,使用了数据存储(在CSV文件中)和业务逻辑(比如检查关于数据的事情,或者计算卡路里)之间的紧密耦合,我们需要让业务逻辑完全独立于我们存储和操作数据的地方。我们需要遵循控制权倒置的原则:不要让业务逻辑控制数据访问逻辑,而是让数据访问逻辑控制业务逻辑。这样做有很多好处,其中最主要的是能够轻松实现单一的真实源。数据访问层将能够控制数据的访问方式,无论我们的业务逻辑的哪一个副本,它们都将使用相同的、一致的数据。

通过分拆数据访问,我们可以用一个单一的数据接口来工作,让别人来决定在哪里存储我们的数据以及如何处理数据。只要它尊重这个接口,我们就可以用它来工作。

所以我们设计一个角色来定义这个接口。我们将这个角色称为 Dator,就像数据访问器一样。

unit role Raku::Recipes::Dator;

method get-ingredient( Str $ingredient ) {...}
method get-ingredients() {...}
method search-ingredients( %search-criteria ) {...}
method insert-ingredient( Str $ingredient, %data ) {...}
method delete-ingredient( Str $ingredient) {...}

这个角色具有 CR(U)D 接口的基本功能。我们创建(用 insert)、读取(通过 get-ingredient 来获取单个原料,通过 get-ingredients 来获取所有原料),不提供更新的可能性,因为有些原料不可能突然就得到卡路里或者突然就变得不奶了,然后删除。在这种情况下,我们不使用省略号作为隐藏代码的方式:它是一个真正的省略号,这使得这些方法成为存根。任何混合这个角色的类都需要实现这些方法,否则它将无法实例化。

注意这些方法中的一些方法可以抛出一个异常,但是,如果我们认为支持这些方法是不合理的。

最初我们用 CSV 读取这些数据。那么我们就创建一个类,实现这个逻辑,并且能够通过 CSV 来读取。

use Text::CSV;
use Raku::Recipes::Dator;
use Raku::Recipes;
use X::Raku::Recipes::Missing;

unit class Raku::Recipes::CSVDator does Raku::Recipes::Dator;

has %.ingredients;
method new( $dir = "." ) {
    # Suppressed implementation for brevity. It can be found in the book
    GitHub repository.
}

method get-ingredient( Str $ingredient ) {
    return %!ingredients{$ingredient}
            // X::Raku::Recipes::Missing::Product.new( name => $ingredient ).throw
}

method get-ingredients() {
    return %!ingredients;
}

method search-ingredients( %search-criteria ) {
    %!ingredients.keys.grep:
            { self!check(  %!ingredients{$_},%search-criteria) };
}

method !check( %ingredient-data, %search-criteria) {
    my @criteria = do for %search-criteria.keys {
        %search-criteria{$_} == %ingredient-data{$_}
    }
    return all @criteria;
}

method insert-ingredient( Str $ingredient, %data ) {
    die "Ingredients are immutable in this class";
}

method delete-ingredient( Str $ingredient) {
    die "Ingredients are immutable in this class";
}

就实现而言,这些都是比较简单的。由于我们是用哈希把它们保存在内存中,所以我们不能允许这个类的客户端改变它们。我们保留了单一真源原则,因为这个真源将是原始文件。我们使用了一个名为 !check 的私有方法来检查一个成分是否符合标准,但除此之外,它只是简单地处理存储的数据,用它自己的属性重现我们在 Raku::Recipes::Roly 中已经做的事情。请注意使用异常(在第八章中有所涉及)来传递缺少一个原料的信息。

例如,我们可以将其用于命令行脚本,以 JSON 格式打印我们所拥有的某个原料的数据。

use Raku::Recipes::CSVDator;
use X::Raku::Recipes::Missing;
use JSON::Fast;

sub MAIN( $ingredient ) {
    my $dator = Raku::Recipes::CSVDator.new;
    say to-json( $dator.get-ingredient( tc($ingredient) ));
    CATCH {
        when X::Raku::Recipes::Missing {
            "We don't have info on $ingredient".say
        }
        default { say "Some error has happened"}
    }
}

实施起来很简单。当然,我们需要照顾到可能缺少的成分。使用 MAIN 会拒绝没有参数的使用,并使用参数(我们用 tc 大写)来检查数据存储。

有趣的是,除了我们要使用的数据存储的声明,其余的完全与具体的实现脱钩。所以我们现在就尝试用 SQLite 来做一个实际的实现。

12.1.6. 关系型数据存储的实现

好了,让我们回到实际实现一个可以与 SQLite 一起工作的 dator。在这种情况下,单一的真相来源是数据存储,所以我们将能够插入和删除。每个使用这个类的对象都会看到相同版本的数据。下面是实现的全部内容。

use DBIish;
use Raku::Recipes::Dator;
use Raku::Recipes;
use X::Raku::Recipes::Missing;

#| Basic calorie table handling role
unit class Raku::Recipes::SQLator does Raku::Recipes::Dator does Associative;

has $!dbh;
has @!columns;

method new( $file = "Chapter-12/ingredients.sqlite3" ) {
    my $dbh = DBIish.connect("SQLite", :database($file));
    # This is SQLITE3 specific
    my $sth = $dbh.prepare("PRAGMA table_info('recipedata');");
    $sth.execute;
    my @table-data = $sth.allrows();
    my @columns = @table-data.map: *[1].tc;
    self.bless( :$dbh, :@columns );
}

submethod BUILD( :$!dbh, :@!columns ) {}

#| Retrieves a single ingredient by name
method get-ingredient( Str $ingredient ) {
    my $sth = self!run-statement( q:to/GET/, $ingredient );
SELECT * FROM recipedata where name = ?;
GET
    with $sth.allrows()[0] { return self!hashify($_) }
    else { return []};
}

multi method AT-KEY( Str $ingredient ) {
    return self.get-ingredient( $ingredient );
}

method !hashify( @row is copy ) {
    my %hash;
    for @!columns -> $c {
        %hash{$c} = shift @row
    }
    %hash<name>:delete;
    return %hash;
}

method !run-statement( $stmt, *@args ) {
    my $sth = $!dbh.prepare($stmt);
    $sth.execute(@args);
    return $sth;
}

method get-ingredients {
    my $sth = $!dbh.prepare(q:to/GET/);
SELECT * FROM recipedata;
GET
    $sth.execute;
    my %rows;
    for $sth.allrows() -> @row {
        my $name = @row[0];
        my %this-hash = self!hashify(@row);
        %rows{$name} = %this-hash;
    }
    return %rows;
}

method search-ingredients( %search-criteria ) {
    my @clauses = do for %search-criteria.kv -> $k,$v {
        lc($k) ~ " = '" ~ $v ~ "'";
    }
    my $query = "SELECT name FROM recipedata WHERE " ~ @clauses.join( "AND ");
    $query ~~ s:g/<|w>True<|w>/Yes/;
    $query ~~ s:g/<|w>False<|w>/No/;
    my $sth = $!dbh.prepare($query);
    $sth.execute;
    return $sth.allrows().map: *[0];
}

method insert-ingredient( Str $ingredient, %data ) {
    my $stmt = "INSERT INTO recipedata (" ~ @!columns.join(", ")
        ~ ") VALUES (" ~ ("?" xx @!columns.elems ).join(", ") ~ ")";
    my @values = $ingredient;
    for @!columns[1..*] -> $c {
        with %data{$c} { @values.push: %data{$c} }
        else { X::Raku::Recipes::Missing::Column.new( :name($c) ).throw }
    }
    my $sth = $!dbh.prepare($stmt);
    $sth.execute( |@values);
}

method delete-ingredient( Str $ingredient) {
    my $sth = $!dbh.prepare(q:to/DELETE/);
delete FROM recipedata where name = ?
DELETE
    $sth.execute( $ingredient);
}

可能是我们目前看到的最长的配方了。我们声明一个类,它混合了 Dator,但也混合了 Associative 的作用。我们将得到的结果将作为一个关联数组工作,至少在某种意义上是这样。从语法上来说,这是很方便的,事实上,我们可以在基础角色中引入它。然而,这是一个额外的角色客户端,类可能需要,也可能不需要,所以我们干脆把实现留给那些需要的人。我们在类中会有两个属性:一个是数据库句柄,我们将用它来访问类,另一个是列,我们在几个地方都会需要列,也是为了查错。

这两个属性的值是在新方法中分配的,新方法将上面已经创建的数据库文件作为默认值,并包含从 CSV 文件导入的值。这一部分是 SQLite 特有的,要做一些修改。主要是我们在使用 DBIish 连接的时候,选择那个特定的驱动来使用,但我们也使用一个 PRAGMA 来获取列的名称。这个 PRAGMA 返回的是表的信息。使用 bless,我们实际上调用 BUILD,在这里我们将变量与属性绑定。

大多数方法的做法如下:它们准备一条 SQL 语句,然后绑定值并执行它。我们创建了一个私有的方法,称为 run-statement,它按顺序进行,但它并不总是有效,所以在这些情况下,我们只需在方法中直接发出 SQL 语句。使用准备好的语句总是比直接创建 SQL 语句并运行它等更安全。

大多数方法都会调用 hashify,将结果以哈希的形式返回,不包括 name 列,name 列将作为哈希的键。

为了使其工作为关联性,即使用 {}<> 来访问其中一个成分,我们定义了 AT-KEY 方法。它实际上调用了 get-ingredient,但允许我们以另一种方式访问它。

我们还添加了 X::Raku::Recipes::Missing::Column 异常,当我们试图创建新行时,列缺失时将会发出异常。

总的来说,DBIish 是极简主义的:它提供了一些方法来发出 SQL 命令并按摩结果。它做了它的工作,并没有真正的阻碍。大部分的复杂性来自于创建SQL语句和将结果转换为我们所期望的东西。

12.1.7. 在实践中使用它

我们现在需要使用它。使用这种结构的目的是为了能够和其他数据访问类互换使用。所以我们把之前的命令行命令改成 JSON 化一个原料上的数据,这样就可以互换使用它们中的任何一个。就是这样的。

use Raku::Recipes::CSVDator;
use Raku::Recipes::SQLator;
use X::Raku::Recipes::Missing;
use JSON::Fast;

sub MAIN( $ingredient, $data-source = "Chapter-12/ingredients.sqlite3" ) {
    my $dator;
    if ( $data-source ~~ /\.sqlite3$/ ) {
        $dator = Raku::Recipes::SQLator.new( $data-source );
    } else {
        $dator = Raku::Recipes::CSVDator.new;
    }
    say to-json( $dator.get-ingredient( tc($ingredient) ));
    CATCH {
        when X::Raku::Recipes::Missing {
            "We don't have info on $ingredient".say
        }
        default { say "Some error has happened" }
    }
}

无论我们在命令行中给出第二个以 sqlite3 结尾的参数,还是使用其他的东西,都会使用不同的 dator。CSVDator 使用的是目录,data/calories.csv 会在它的默认位置,所以我们这样使用就可以了。

其余的都是一样的:我们可以使用同样的 API 来获取我们所拥有的关于食材的数据。我们也可以将这类对象注入到需要访问数据的高阶对象中。甚至如果数据存储完全不同,例如 Redis 也没有关系。我们将在下一个配方中与之合作。

12.2. 食谱 12-2. 使用 Redis 的接口

12.2.1. 问题

Redis 是一个快速的内存数据存储,具有很高的吞吐量,这使得它对缓存、队列、当然还有存储很有用,只要它不是永久性的。你需要在一个需要非常快速检索数据的应用中使用它。

12.2.2. 解决办法

Redis 对于一个每秒点击率不高的应用来说,可能是矫枉过正了,但从 Raku 来看如何使用它还是很有用的。Cro 有一个插件,允许你在 Redis 中存储会话信息,但我们将使用一个简单的 Redis 包装库来与之合作。

12.2.3. 它是如何工作的

Redis 是一种内存中的键值存储,由于它的速度快,从简单的缓存到消息队列,它已经被应用在各种事物中。它使用起来非常方便,如果使用得当,它可以比磁盘绑定的数据存储有显著的速度提升。它也是开源的,你可以在你的操作系统上使用任何一个选项安装它,https://redis.io/download, 包括把它下载为一个 Docker 容器。

请注意,作为这里使用的任何其他东西,它包含在本书的"官方" Docker 容器中,网址是 docker.pkg.github.com/jj/perl6-recipes-apress/rakurecipes:last

一般来说,Redis 中要存储的是键值对,如图所示。然而,Redis 允许几种值,包括哈希。这些哈希值可以同时设置,也可以单独设置。Redis 没有使用标准语言来处理存储的数据,而是使用自己的语言,它是由(一般是大写)命令组成的,比如 HMSET 和它们后面的一系列参数。每一个实例都可以访问实例中的每一个项目,这就是为什么键一般使用命名空间,命名空间是键的冒号分隔的前缀。我们将使用 recipes:在这里,把我们创建的东西和其他任何数据分开。

作为一个数据存储,我们将再次混合使用 Raku::Recipes::Dator 角色,以及 Associative。下面是最终的类,叫做:

Raku::Recipes::Redisator:
use Redis;
use Raku::Recipes::Dator;
use Raku::Recipes;
use X::Raku::Recipes::Missing;

#| Basic calorie table handling role
unit class Raku::Recipes::Redisator does Raku::Recipes::Dator does Associative;

#| Contains the table of calories
has $!redis;

method new( $url = "127.0.0.1:6379" ) {
    my $redis = Redis.new($url, :decode_response);
    self.bless( :$redis );
}

submethod BUILD( :$!redis ) {}

#| Retrieves a single ingredient by name
method get-ingredient( Str $ingredient ) {
    $!redis.hgetall( "recipes:$ingredient" );
}

#| To make it work as Associative.
multi method AT-KEY( Str $ingredient ) {
    return self.get-ingredient( $ingredient );
}

#| Retrieves all ingredients in a hash keyed by name
method get-ingredients {
    my @keys = $!redis.keys("recipes:*");
    my %rows;
    for @keys.first<> -> $k {
        $k ~~ /<?after "recipes:">$<key>=(.+)/;
        %rows{~$<key>} = $!redis.hgetall("recipes:" ~ $<key>)
    }
    return %rows;
}

#| Search ingredients by key values
method search-ingredients( %search-criteria ) {
    my %ingredients = self.get-ingredients;
    %ingredients.keys.grep:
            { search-table(  %ingredients{$_},%search-criteria) };
}

method insert-ingredient( Str $ingredient, %data ) {
    $!redis.hmset("recipes:$ingredient", |%data);
}

method delete-ingredient( Str $ingredient) {
   $!redis.del("recipes:$ingredient")
}

我们使用 Redis 模块来访问 Redis;这个模块负责向 Redis 发出命令,包括大部分但不是全部 Redis 命令。正如你所看到的,这比我们用于 SQLite3 的模块要简单得多。连接是直接的,有一个本地 IP 和一个标志,表明我们希望以字符串而不是 blobs 的形式获取结果。大多数命令的行为都是一样的:它们向 Redis 发出相应的 h* 命令并返回结果。hmset 命令创建了一个键值集,可以同时设置多个哈希键。我们总是添加命名空间(或消除它)。del 命令按键删除,hgetall 则返回哈希中所有与某个键的值相匹配的键。

我们使用 keys 命令进行搜索,keys 查找整个键空间,只返回那些与模式相匹配的键,在本例中就是那些有 recipes:前缀的键。我们再用它来搜索-ingredients。Redis 有一个命令,scan(本例中还有 hscan),但在 Redis Raku 驱动中没有实现。没有什么大不了的,我们只需要通过对每个键发出搜索请求来检索所有的成分,然后使用 Raku 命令进行搜索。

如果我们在内存中还没有数据,或者像对 CSV 和 SQLite3 那样以某种方式提供数据,那就不是很有用。这就是内存数据库的问题,你需要填充它们。让我们使用同一个模块来创建一个填充它的脚本。

use Redis;
use Raku::Recipes::Redisator;
use Raku::Recipes::SQLator;

my %data = Raku::Recipes::SQLator.new.get-ingredients;
my $redisr = Raku::Recipes::Redisator.new;

for %data.kv -> $ingredient, %data {
    $redisr.insert-ingredient($ingredient,%data);
}
say $redisr.get-ingredients;

很简单:它使用一个数据访问器获取数据,并使用另一个数据访问器插入数据。最后,它打印整个数据集,以检查它是否与初始数据存储中的数据有效相同,在本例中我们选择的是 SQLite3。如果我们使用 CSVDator,它的工作方式也是一样的。

12.2.4. 重构高级类

一旦我们对所有的数据存储有了足够的支持,我们就可以重构高级类,并创建一个新的 Raku::Recipes::Roly,它将与数据访问解耦,其对象将接受数据访问对象的注入。这就是重构后的角色,我们称之为 Raku::Recipes::Base。

use Raku::Recipes;

unit role Raku::Recipes::Base;

has $!dator;

submethod BUILD( :$!dator ) {}

method products () { return $!dator.get-ingredients.keys };

method calories-table() { return $!dator.get-ingredients };

proto method is-ingredient( | ) {*}

multi method is-ingredient( Str $product where $product ∈ self.products --> True)  {}
multi method is-ingredient( Str $product where $product ∉ self.products --> False) {}

method check-type( Str $ingredient where $ingredient ∈ self.products,
                   Str $type where $type ∈ @food-types --> Bool ) {
    return so $!dator.get-ingredient($ingredient){$type} eq "Yes" | True;
}

method check-unit( Str $ingredient where $ingredient ∈ self.products,
                   Str $unit where $unit ∈ @unit-types --> Bool ) {
    return $!dator.get-ingredient($ingredient)<parsed-measures>[1] eq $unit;
}

这在外部也是同样的作用,任何访问都必须通过 $dator 来完成,它负责所有的数据流量。但是这样使用它可以保证无论我们需要使用什么样的数据访问,我们都可以使用它的上层接口来工作。我们可以使用该角色编写这个与数据源无关的脚本(把它打点到一个类上),来检查某个成分是否属于某种特定类型。

use Raku::Recipes::CSVDator;
use Raku::Recipes::SQLator;
use Raku::Recipes::Redisator;
use Raku::Recipes::Base;
use X::Raku::Recipes::Missing;
use JSON::Fast;

sub MAIN( $ingredient, $type, $data-source = "Chapter-12/ingredients.sqlite3" ) {
    my $dator;
    if ( $data-source ~~ /\.sqlite3$/ ) {
        $dator = Raku::Recipes::SQLator.new( $data-source );
    } elsif ($data-source ~~ /\d+\:\d+/) {
        $dator = Raku::Recipes::Redisator.new( $data-source );
    } else {
        $dator = Raku::Recipes::CSVDator.new
    }
    my $checker = Raku::Recipes::Base.new( :$dator );
    say "$ingredient is ",
            $checker.check-type( $ingredient, $type ) ?? "" !! "not ", "of type $type";
}

脚本的大部分内容是检查数据源并选择一个 dator。如果它有一个 sqlite3 扩展,我们就用 SQLator;如果它包括一个冒号,周围有数字,可能是一个 IP 加端口,所以会实例化 Redisator,如果都不是,试用真实的 CSVator 最好。用这个命令行运行它。

raku Chapter-12/check-ingredient.p6 Rice Vegan 127.0.0.1:6379

Will return this:

Rice is of type Vegan

这和我们使用默认的,或者 SQLite 数据存储的路径完全一样。$checker 已经被注入到数据存储中,倒置了依赖关系,并且完全解耦了我们的业务逻辑(在这种情况下,它只是检查例程)和数据存储。我们有了一个单一的真源,我们可以继续在上面添加更多的抽象层,选择最方便的数据存储,以完全独立于实现的方式来测试我们的业务逻辑。

但有一个问题是,有了这些新的数据访问层,数据表示与数据结构脱钩了,我们用手工来匹配它们。一种更高层次的方式是可能的,我们将在下一个配方中使用它。

12.3. 食谱 12-3. 使用 ORM 进行高级数据描述和访问

12.3.1. 问题

你需要处理数据,而不必处理复杂的编写 SQL 句子,或者以这样的方式使代码和数据结构以更接近的方式反映数据。

12.3.2. 解决办法

使用 RED,Raku 对象关系管理器,它为创建数据访问程序提供了一个 Raku 惯用接口。

12.3.3. 它是如何工作的

"看,妈,不用 SQL" 是对象关系管理器的座右铭,它为关系数据库中存储的数据提供了一个面向对象的接口。数据被描述为原生类,访问数据的 API 反映了一种更自然的处理数据的方法,而不用担心 SQL 语法的复杂性。因此,对象是使用自然的对象实例语法创建的,对象之间的关系只是类的属性。

到目前为止,我们的菜谱应用中的主要类是菜谱列表。然而,这里的自然对象显然是一个单一的原料和我们所拥有的关于它的数据。让我们以此为基础来创建我们的 Red 类,如下所示。

use Red:api<2>;

model Raku::Recipes::IngRedient is rw is table<Ingredient> {

    has Str  $.name      is id;
    has Str  $.unit      is column{ :!nullable };
    has Int  $.calories  is column{ :!nullable };
    has Num  $.protein   is column{ :!nullable };
    has Bool $.dairy     is column{ :!nullable };
    has Bool $.vegan     is column{ :!nullable };
    has Bool $.main      is column{ :!nullable };
    has Bool $.side      is column{ :!nullable };
    has Bool $.dessert   is column{ :!nullable };
}

你可能观察到的第一件事是,ORMs 意味着没有 SQL,但也很少或没有代码。这是对对象的高级描述,与简单地将其声明为一个类有一些区别。然而,差异是关键。

首先,我们使用的是第二个版本的 API,因为 Red 到目前为止有两个版本的 API。我们已经在关于异常的一章中看到了如何使用这个副词,所以我们不再深入探讨。类被声明为一个模型。这是 Red 定义的语法,用于声明将存储在数据库中的类。Raku 有一个可编程的元对象协议,Red 发行版的作者 Fernando Correa de Oliveira 在这里做的是一个使用它创建新类型对象的极好例子。在 Red 中,模型是存储的类,但它们的行为方式是在元模型中精确编程的。

Red 通常从模型本身的名称中导出存储中的表的名称。但在这种情况下,名称中包含冒号,所以我们最好给它一个带有 "is table" 特征的实际名称。我们将简单地称它为 ingredient。默认情况下,它是一个内存中的数据库,只要我们离开程序,它就会消失。

例如,每个模型都需要有一个 ID,要么是一个名为 $.id 的字段,要么是一个得到 "is id" 特质的列,表示它是唯一的,将被用来检索整行。

其余的列得到了正确的类型,所以我们可以在分配它们的时候对它们进行类型检查,这是我们到目前为止还无法做到的。它们是不可空的,因为它们都需要有一个确定的值;卡路里是一个整数,因为它们通常是四舍五入的,而蛋白质是一个浮点数,因为它们通常是一个较小的量,以克的分数作为可能的值。

我们已经可以使用这个类了;让我们用它从我们已有的数据中填充一个(不同的)数据库,只检索素食成分。

use Red;
use Raku::Recipes::IngRedient;
use Raku::Recipes::SQLator;

my $*RED-DB = database "SQLite";
Raku::Recipes::IngRedient.^create-table;

my %data = Raku::Recipes::SQLator.new.get-ingredients;

for %data.kv -> $ingredient, %data {
    my %red-data;
    %red-data<name> = $ingredient;
    for %data.kv -> $key, $value is rw {
        given $value {
            when "Yes" { $value = True }
            when "No"  { $value = False }
        }
        %red-data{lc $key } = $value;
    }
    Raku::Recipes::IngRedient.^create: |%red-data;
}

say "Vegan ingredients →",
    Raku::Recipes::IngRedient.^all.grep( { .vegan } ).map( { .name } )

我们在这里要做的是使用现有的一个数据源,在这里是 SQLator,所有的成分都存储在一个数据库中,然后用它作为原点来填充这个数据库,它将以同样的格式。

Red 使用 $*RED-DB 动态变量来建立将要使用的数据库驱动,由于 SQLite 更容易设置和测试,而且它已经安装好了,所以会再次使用 SQLite。

我们提到 Red 使用了元对象协议;当我们使用 HOW 方法,前面加个小括号,在存储中物理创建表时,就可以清楚地看到这一点。Raku::Recipes::IngRedient.^create-table 就可以做到这一点,它默认在内存中创建一个表。这样会更快,当然每次启动程序时都需要把它填满。

循环将运行所有存储在主 SQLite 数据库中的元素。我们从它们中创建一个哈希值;因为名称是哈希值的关键,所以我们把它也包含在那里。我们需要将 Yes 和 No 转换为其布尔值,因为如果我们原始使用它们,它们将被存储为 "True",因为任何非空字符串都是 true。

为了搜索数据库中的素食成分,我们使用 ^all 获取所有元素的句柄。同样,一个方法要访问模型的 HOW(高阶工作),它将返回一个包含所有个体的懒惰Seq。我们不需要发明一个新的方法来搜索:grep 就可以很好的完成,我们过滤只保留素元素(用 :vegan,只有当对象的素属性为真时才会为真)。最后,我们只映射到成分的名称,得到如下。

Vegan ingredients →(Cashews Lentils Kidney beans Green kiwi Orange Olive Oil Apple Beer Tomato Chickpeas Potatoes Rice Kale Sundried tomatoes)

使用 Red,我们又可以重构我们之前工作过的关系数据库绑定的 dators。然而,除了产生更干净的代码之外,在这种情况下,它并不会给我们带来什么好处。当使用大系统,有复杂关系的时候,ORM 才真正显示出它的价值,因为它可以很容易地表达这些关系,并从存储中检索出整组相关的对象,而不需要写复杂的 JOIN 或其他类型的 SQL 查询。对于我们来说,Red 可能是你需要了解和使用的十大 Raku 模块之一,所以我们鼓励你去尝试它。

12.4. 食谱 12-4. 使用 MongoDB

12.4.1. 问题

一些半结构化的数据最好存储在 NoSQL 数据库中,因为它可以高效地处理这类数据。MongoDB 可能是最流行的一种,也是现有工具覆盖较好的一种,所以我们来试试。

12.4.2. 解决方案

在生态系统中使用 MongoDB 驱动。

12.4.3. 它是如何工作的

MongoDB 作为一个文档存储已经变得非常流行,它甚至成为了被称为 MEAN 栈的一部分。Mongo、ExpressJS、AngularJS 和 Node。这并不意味着必须使用这些工具:大多数语言都有库,Raku 也不例外。

当我们将 MongoDB 用于半结构化文档时,即由多个键值对组成的文档,每一个键值对中都可以有任何数量的文本,MongoDB 就会表现出色。由于它并不强求特定的模式,你可以在那里存储任何类型的信息,这就是为什么我们要在这些食谱中使用它。食谱已经被充实了,并且有成分,也可以有任何数量的成分。

在任何情况下,它们都将包括一个描述、一个标题和一个成分列表(可以是空的)。我们将使用这个脚本把我们已经拥有的列表存储在 MongoDB 数据库中。

use MongoDB::Client;
use MongoDB::Database;
use BSON::Document;
use Raku::Recipes::Texts;

my $recipes-text = Raku::Recipes::Texts.new();

my MongoDB::Client $client .= new(:uri('mongodb://'));
my MongoDB::Database $database = $client.database('recipes');

my @documents;
for $recipes-text.recipes.kv -> $title, %data {
    %data<title> = $title;
    if %data<ingredients>.elems > 1 {
        for %data<ingredients>.kv -> $k, $v {
            %data{"ingredient-list-$k"} = $v.trim;
        }
    }
    %data<ingredients>:delete;
    @documents.append: BSON::Document.new((|%data)),
}

say "Inserting docs";

my BSON::Document $req .= new: (
    insert => 'recipes',
    documents => @documents
);

my BSON::Document $doc = $database.run-command($req);

if $doc<ok> {
    say "Docs inserted";
}

在运行这个之前,我们必须有一个 MongoDB 的安装,使用你喜欢的方法来安装它。它在连接中使用默认设置:mongodb://URI,将连接,没有用户名或密码,到数据库。切记不要在生产环境中这样做。

Mongo 创建了不同的数据库,每个数据库都有文档的集合。每一个文档都是一个数据结构,只要我们把文档看成类似于关系型数据库中的行,一个集合就是一个表。在插入一组文档之前,我们需要选择这两者,我们把它们都称为 recipes,因为,为什么不呢。

Mongo 使用了一种叫做 BSON 的格式;这种格式是一种"二进制 JSON",包括 JSON 以及处理图像等二进制数据的方法。既然 Mongo 使用的是这种格式,那么我们就需要使用生态系统中的 BSON 发行版,将其他数据结构转换为这种格式。

这种格式有某些特殊性。例如,它不接受嵌套的数据结构。这就是为什么我们将成分列表转换为一组键,类型为 ingredient-list-<number>。这样我们就把这个列表"扁平化"了,我们将得到一个 BSON 文档,里面有标题、描述和一堆 ingredient-list-n 键。我们只有在有实际列表的情况下才会创建它,否则它将是空的。

MongoDB 中的命令也是通过同样的格式执行的。一个 BSON 文档,键名为 insert,值为我们将要创建的集合的名称。另一个键,document,将指向我们将要插入的文档列表,也就是我们之前创建的 BSON 文档数组。

基本上就是这样了。Mongo 会返回另一个 BSON 文档,键是 ok,如果一切正常的话。这将插入所有文档,我们可以使用客户端或命令行来处理它们,如图12-1所示。

图12-1. 查询数据库中的菜谱集合

例如,我们可以使用正则表达式,如图12-2所示。

图12-2.使用正则表达式查询 使用正则表达式进行查询

查询中列出了我们将用于搜索 "标题"的关键和正则表达式,只需包含金枪鱼一词即可。

db.recipes.find( { "title" : { $regex: /Tuna/ } } )

use MongoDB::Client;
use MongoDB::Database;
use BSON::Document;

my MongoDB::Client $client .= new(:uri('mongodb://'));
my MongoDB::Database $database = $client.database('recipes');
my MongoDB::Collection $recipes = $database.collection('recipes');

my $regex = BSON::Regex.new( regex => @*ARGS[0]) ;
my MongoDB::Cursor $cursor =
        $recipes.find( criteria => ["title" => '$regex' => $regex ,],
                       projection => [ "title" => 1,] );

while $cursor.fetch -> BSON::Document $d {
    say $d<title>;
}

MongoDB 发行版中包含了与集合打交道的类;由于搜索是在集合中进行的,所以我们在这里将使用这个类。前面的三条语句是一个级联,最终会创建一个代表该集合的对象。

我们将使用正则表达式进行搜索;我们不能直接使用它,所以我们需要创建一个 BSON::Regex 文档。由于所有的查询都会成为 BSON 文档,所以我们最好提前做。regex 中会包含我们将使用的单词作为参数,例如 Tuna。

查找命令只需要一个标准,也就是一个命名的参数,但值也是一对。key 就是要进行搜索的字段,value 就是标准。如果是一个清脆的值,就不需要再做什么了,但是我们可以使用搜索运算符,比如正则表达式。在这种情况下,它也会是一个 Pair,有操作符和我们之前定义的正则表达式。这个搜索会返回与之匹配的文档(BSON 格式)。但是我们可以再进一步,定义一个投影,建立我们感兴趣的字段,在这种情况下,只有标题。我们将标题与1配对,表示一个真实的值。默认情况下,所有其他字段都将被删除。

这将返回一个 MongoDB::Cursor;我们需要从该游标中获取每一个项目来找到结果,我们在下面的 while 循环中进行。返回的文档是关联的,所以我们可以直接打印它的标题,就像打印一个哈希一样。这将打印。

Tuna risotto
Tuna risotto

这是因为我们数据库里有两个版本的金枪鱼烩饭配方,其中一个是低成本的。不过没必要在标题中说。

你可以在这个菜谱的基础上创建,例如,可以创建一个 dator,它可以作为一个数据访问层,也可以创建一个完整的菜谱数据库,便于访问和搜索。无论哪种情况,根据你的数据和业务案例需求,你都可以从 Raku 生态系统中选择适合你需求的发行版,并在 Raku 功能和数据存储功能之间提供一个良好的桥梁。

12.5. 食谱 12-5. 从 Wikidata 中提取信息

12.5.1. 问题

维基数据是维基百科中一个鲜为人知的角落,它存储了关于数据片段的事实以及它们之间的关系。你需要查询 Wikidata 来获取关于特定食物成分或配方的信息。

12.5.2. 解决方案

在第9章中,我们使用了 Wikidata::API,这是一个围绕 Wikidata 查询服务的包装器。在这里,我们将更多地关注于用 SPARQL 语言创建查询,以便下载我们正在寻找的信息。

12.5.3. 如何工作

如果我们可以拥有,在我们的指尖,一个名副其实的信息宝库,并且很容易挖掘?你能想象吗?不需要检查和存储每一条信息,在那里,但简单的查询,当你需要它。好吧,这就是维基百科,以及它的数据部门 Wikidata。我们在过去已经遇到过它,也使用过它的 API,现在我们要真正地把它用起来。

例如,我们需要关于我们已经拥有的成分的额外数据;我们至少需要有它们的描述。例如,我们可能希望将这些信息存储在我们的数据库中,以配合我们已经收集到的信息。或者我们可能想拥有自动收集的关于过敏原的信息。它是否含有坚果?是否含有麸质?诸如此类的事情。

目前,我们还是以描述为主。维基数据上的每个项目都有一个,所以如果它在那里,就会有一个与之对应的描述。

请注意,Wikidata 中的所有信息要么是从单个数据源收集的(比如,uSDa 食物表),要么是志愿者插入的。 让我们尝试创建一个脚本,用这个脚本找到我们数据库中已有的成分描述。

use Wikidata::API;
use Raku::Recipes::SQLator;

my $dator = Raku::Recipes::SQLator.new;

for $dator.get-ingredients.keys -> $ingredient is copy {

    $ingredient = lc $ingredient;
    my $query = qq:to/END/;
SELECT distinct ?item ?itemLabel ?itemDescription WHERE\{
  ?item ?label "$ingredient"\@en.
  ?item wdt:P31?/wdt:P279* wd:Q25403900.
  ?article schema:about ?item .
  ?article schema:inLanguage "en" .
  ?article schema:isPartOf <https://en.wikipedia.org/>.
  SERVICE wikibase:label \{ bd:serviceParam wikibase:language "en". \}
\} END
}

sub utf8y ( $str ) {
    Buf.new( $str.ords ).decode("utf8")
}

这个脚本运行数据库中的原料名称,并创建一个 SPARQL 查询来查找其描述。在对名称进行小写后,也就是 Wikidata 中通常的存储方式,我们做一个复杂的查询,包括两个部分。

?item ?label "$ingredient"\@en.

这个查询可以找到名字(或标签)(英文为 @en)是 $ingredient 变量内容的项目。请注意,我们是在这个字符串中插值变量。我们需要小心地转义其他所有可能被解释为代码的内容,比如大括号和 en 前的 at 符号。

有很多东西的名字是米。所以我们必须添加以下内容。项目 wdt:P31?/wdt:P279*wd:Q25403900

my $result = query($query);
if $result<results><bindings> -> @r {
say "$ingredient ⇒\n\t", @r.first<itemDescription><value>; }

以便只返回食品成分。为了查找数据 ID 和关系,你可以直接使用 wikidata.org 的搜索槽,或者使用 Wikidata 的交互式查询服务,这是一个非常有用的弹出窗口,为你提供搜索选项。

当然要注意的是,对这些例子进行最细微的调整,使之适合自己,也是可以的,因为这一直是程序员的前进方向。

我们表达为 wdt:P31?/wdt:P279* 的关系是指"是一个实例或一个子类",星号意味着这种关系可能在任何地方,所以不需要是直接的关系,比如"大米是一种食品"。它还将包括"大米是谷类,谷类是食品"。综合起来,这样就只能返回主食—​大米,而不能返回明尼苏达州的大米。最后一个,wd:Q25403900,同样是"食品成分",wd 前缀表示这是一个 "wikidata",而不是 wikidata 关系(如 wdt 会表示)。

注意,我很惊讶这个完全虚构的城市居然真的存在。如果爱达荷州的马铃薯也是一个真实存在的城市,我也不会感到惊讶。

并非数据库中的所有项目都会在 Wikidata 中拥有一个条目,只是一个简单的条目。

事实上,其中很少有这样做的。所以我们检查查询返回的内容,只打印其中的描述。查询总是要返回一个列表,我们坚持使用第一个列表;即使有好几个项目,比如说,大米,其中任何一个项目对我们来说都是足够好的。包含描述的键的名称将是 itemDescription,这是我们在 SPARQL 查询中使用的术语。结果包含更多的数据,例如 wikidata 项的 URI。

结果将是这样的。

cheese ⇒
        generic term for a diverse group of milk-based food products
cod ⇒
        fish
egg ⇒
        animal egg eaten as food
rice ⇒
        cereal grain and seed of different Oryza and Zizania species
olive oil ⇒
        liquid fat extracted by pressing olives
chorizo ⇒
        pork sausage originating from Italy and typical of the Iberian Peninsula, spread to Latin America of raw minced pork, seasoned with spices and dried
orange ⇒
        citrus fruit of the orange tree
kale ⇒
        form of cabbage with green or purple leaves
apple ⇒
        fruit of the apple tree
beef ⇒
        meat from cattle

这些查询需要一段时间,所以最好在建立数据库的时候就使用它们,或者将它们存储在 Redis 等缓存中,这样在需要的时候就会很方便。由于是开放许可的数据,你可以用任何你喜欢的方式使用它。

13. 创建桌面应用

当你的应用程序是针对非技术性的受众时,你需要设计出让用户可以移动的应用程序,并且只需要一个键盘,也许还有鼠标就可以完成他们的工作。有很多方法可以实现这一点,从使用一个简单的控制台 UI 到一个更复杂的,如果可能的话,基于窗口的应用程序。这些功能在 Raku 中默认是不可用的,所以在大多数情况下,你需要使用生态系统中的发行版,这些发行版可能会使用本地库,所以本章也会重点介绍它们。

13.1. 食谱 13-1. 使用全控制台 UI

13.1.1. 问题

创建一个能够自适应控制台的前端,这样它就可以在任何地方运行,而不必担心本地窗口系统的复杂性。

13.1.2. 解决办法

我们可以使用 Terminal::Choose,它更简单,但我们在第6章中已经这样做了。或者我们可以使用 Terminal::Print,这是一个 CUI(控制台用户界面)的模块,有一个直接的 API。然而,Termbox 是最近发布的,它有一个简单的 API,我们将用它来创建一个 CUI(控制台用户界面)模块。我们将使用它来创建一个完整的控制台应用程序。

13.1.3. 它是如何工作的

一般来说,用户界面比命令行界面更适合终端用户。对于面向用户的应用,甚至对于那些不需要大量复制的应用,比如游戏或小程序,控制台的优势在于它们始终存在,非常轻巧,而且相对简单。控制台由一系列可以被单个字符占据的单元格组成,这些单元格有一个背景和前景色(以及一个位置)。

这些用户界面会产生事件;一般是按键点击事件。我们已经知道如何在 Raku 中处理事件流,所以一个 UI 应用程序将包括绘制初始屏幕和处理事件,对它们作出反应,并可能移动东西。

例如,让我们创建一个选择一系列食材的应用程序。然后,我们可以尝试确定使用它们的菜肴中的卡路里(就像我们在第11章中做的那样),或者通过 Wikidata 查找使用它们的食谱(按照第12章中的食谱)。这将只是一个选择这些成分的屏幕。这可以通过以下程序来完成。

use Termbox :ALL;
use Raku::Recipes::SQLator;

my %data = Raku::Recipes::SQLator.new.get-ingredients;
my Set $selected;

if tb-init() -> $ret {
    note "tb-init() failed with error code $ret";
    exit 1;
}

END tb-shutdown;

my $row = 0;
my $ingredient-index = 0;
my @ingredients = %data.keys.sort;
my $max-len = @ingredients.map: { .codes };
my $split = @ingredients.elems / 2;
print-string("Select with ENTER, move with space or cursors",1,1,
        TB_WHITE, TB_BLACK);
for @ingredients -> $k {
    my ($this-column,$this-row )  = ingredient-to-coords( $row );
    uncheck-mark( $row );
    print-string( $k , $this-column + 5, $this-row, TB_BLACK, TB_WHITE );
    $row++;
}
draw-cursor($ingredient-index);
tb-present;

my $events = Supplier.new;
start {
    while tb-poll-event( my $ev = Termbox::Event.new ) { $events.emit: $ev }
}

react whenever $events.Supply -> $ev {
    given $ev.type {
        when TB_EVENT_KEY {
            given $ev.key {
                when TB_KEY_SPACE | TB_KEY_ARROW_DOWN {
                    undraw-cursor($ingredient-index);
                    $ingredient-index =
                            ($ingredient-index+1) % @ingredients.elems;
                    my ( $this_column, $this_row ) = ingredient-to-coords
                            ($ingredient-index);
                    draw-cursor( $ingredient-index );
                    tb-present;
                }
                when TB_KEY_ARROW_UP {
                    undraw-cursor($ingredient-index);
                    if $ingredient-index {
                        $ingredient-index--
                    } else {
                        $ingredient-index = @ingredients.elems - 1;
                    }
                    my ( $this_column, $this_row ) = ingredient-to-coords
                            ($ingredient-index);
                    draw-cursor( $ingredient-index );
                    tb-present;
                }
                when TB_KEY_ENTER {
                    if @ingredients[$ingredient-index] ∈ $selected {
                        uncheck-mark($ingredient-index);
                        $selected ⊖= @ingredients[$ingredient-index];
                    } else {
                        check-mark($ingredient-index);
                        $selected ∪= @ingredients[$ingredient-index];
                    }
                    tb-present;
                }
                when TB_KEY_ESC {
                    print-string("Selected " ~
                            $selected.map( *.key ).join("-" ),
                            1,2,
                            TB_BLUE, TB_YELLOW);
                    tb-present;
                    sleep(5);
                    done
                }

            }
        }
    }
}

subset RowOrColumn of Int where * >= 1;

sub uncheck-mark( $ingredient-index ) {
    my ($this-column,$this-row )  = ingredient-to-coords( $ingredient-index );
    print-string( "[ ]", $this-column + 1 , $this-row, TB_BLACK, TB_BLUE );
}

sub check-mark( $ingredient-index ) {
    my ($this-column,$this-row )  = ingredient-to-coords( $ingredient-index );
    print-string( "[X]", $this-column + 1 , $this-row, TB_BLACK, TB_BLUE );
}

sub draw-cursor( $ingredient-index ) {
    my ($cursor_c, $cursor_r) = ingredient-to-coords( $ingredient-index);
    tb-change-cell( $cursor_c, $cursor_r, ">".ord, TB_YELLOW, TB_RED );

}

sub undraw-cursor( $ingredient-index ) {
    my ($cursor_c, $cursor_r) = ingredient-to-coords( $ingredient-index);
    tb-change-cell( $cursor_c, $cursor_r, " ".ord, 0, 0 );

}

sub print-string( Str $str, RowOrColumn $column,
                  RowOrColumn $row,
                  $fgcolor,
                  $bgcolor  ) {
    for $str.encode.list -> $c  {
        state $x;
        tb-change-cell( $column + $x++,
                $row,
                $c,
                $bgcolor, $fgcolor );
    }
}

sub ingredient-to-coords( UInt $ingredient-index) {
    return 1 + ($ingredient-index / $split).Int * ($max-len + 5),
            (3 + $ingredient-index % $split).Int;
}

这个程序使用了 Termbox,正如已经指出的那样。Termbox 发行版是围绕着一个同名的库的 NativeCall 包装器(查看下一章如何创建这些包装器)。这个库可以从 https://github.com/nsf/termbox 下载,它有一个免费的许可证。它有许多流行语言的绑定,当然包括这个。当你用 zef 安装 Termbox 时,你不需要安装任何额外的包。它将在飞行中为你构建它所需要的东西。

这个程序相当长,因为每一件小事都需要写代码。但它有几个块。

  1. 初始化不同的数据结构和控制台 UI。tb-init 命令将初始化它,并创建一个我们可以在上面画画的空白画布。

  2. 绘制初始画面。

  3. 设置一个事件流。termbox 事件流会被轮询,每轮询一次,就会向流中发出另一个事件。当用户需要时,这个事件流将被关闭。

  4. 一个区块设置了不同的关键事件发生时的处理方式,每一个事件都必须单独处理。

  5. 一个 END 相位器,在程序退出时(比如说因为程序用完了指令)会发射,它负责在程序结束时关闭画布。

在低层次上,这个 CUI/TUI(控制台或终端用户界面)的主力是 tb-change-cell。它需要五个参数:列和行,按这个顺序,一个字符代码,然后是前景和背景颜色。这些参数也被 Termbox 预定义为 Raku 常量。

由于打印字符串这个简单的行为涉及到一些代码,我们将我们自己的 print-string 子程序包裹在其中。它所需要的参数基本相同,但行和列将是初始参数,而且它需要一个完整的字符串。在其中,我们通过 encode.list 来提取字符代码。第一条命令将一个字符串转换为一个 blob,第二条命令将 blob 转换为一个字符列表。每个字符我们都会递增列号。我们使用了一个状态变量 $x,这样它的值在迭代块的一次调用到下一次调用时都会被保存。

另一个重要的变量是 ingredient-index。光标将被放置在 ingredient-index 所指示的值中,它只是 ingredients 数组中的一个位置,是一个使用 SQLator 提取的按字母顺序排列的成分列表。其他所有的位置都围绕着它。从成分-索引中,我们计算出游标、复选框和成分的行和列。ingredient-index 将是一个全局变量,尽管为了照顾到何时何地改变它,大多数例程不会改变它。如果我们需要勾选或取消勾选一个复选框,或者绘制或取消绘制光标,这是我们需要知道的唯一值。图 13-1显示了 UI 在屏幕上的样子。

图13-1. 选择原料的 CUI。奶酪已经被标记,光标在 Chorizo 的前面。

构件到坐标的例程可以将构件索引(或初始绘图中的行)转换为坐标,将构件整齐地放置在两行中。它考虑到了原料名称的最大长度,这个长度存储在全局变量 $max-len 中。

包装还包括类型检查。我们定义了一个 RowOrColumn 子集,以注意数值必须超过1。Termbox 是一个非常实用的库,但它的操作很脆,任何错误都会简单地使程序退出,因为它是在 C 代码中发生的,并没有传播到 Raku 中产生一个适当的异常。我们需要在代码中捕捉这些错误,以避免用户的挫败感。

反应块会对按键事件做出响应。我们首先过滤按键事件,然后区分不同的按键。空格键和向下键将以同样的方式表现。我们使用一个 junction(|)。所以 TB_KEY_SPACE | TB_KEY_ARROW_DOWN 将在其中任何一个键被按下时触发。这样就会通过取消绘制然后再绘制的方式一步步移动光标。在每次改变之后,我们需要调用 TB-Present 来更新 UI。这个和箭头向上会绕过食材指数,如果超过食材数量,则回到0,如果低于0,则上升到食材数量的最终指数,回车键会切换勾选,最后,Escape 会打印所选食材,然后,等待5秒后,退出。此界面如图13-2所示。

图13-2. 打印所选原料

sleep 后的命令,done,会关闭 supply,然后将程序结束,触发 END 相位器,关闭画布。

这个程序做到了该做的事情,没有太多花哨的东西。当你必须连接到云系统,或者通过有限的带宽连接到系统时(这种情况还是有的),它相当方便。对于系统管理任务来说,控制台用户界面仍然比基于窗口的用户界面更受欢迎。事实上,这种界面也可以在浏览器内部的控制台中运行。然而,在某些情况下,你会希望创建一个成熟的基于窗口的应用程序。接下来我们将看到如何做到这一点。

13.2. 食谱 13-2. 创建一个使用系统 Windows 的应用程序

13.2.1. 问题

你需要创建一个使用鼠标和窗口的桌面应用程序,并且可以移植到不同的操作系统。

13.2.2. 解决办法

GTK3 是一个可移植的库,在整个开源世界中,它被用来创建桌面界面;GTK::Simple 是该库的 Raku 绑定,你可以用它来构建界面。它的社区维护和更新频率很高,就像它的底层库一样,所以它可以为你的桌面应用程序提供一个稳定的基础。

13.2.3. 它是如何工作的

设计图形用户界面不仅仅是把按钮和其他小部件放在一个矩形表面上,然后在事情发生时做出反应。GUI 需要关注用户(也就是"用户界面"中的"用户"),让他们尽可能容易地知道什么是可用的,什么可以做,什么不能做。

我甚至不会假装我对这个主题有所了解,但 GUI 的艺术是一个非常有趣的话题,你应该了解情况(或者在你的团队中有一些专家)。我将尽量尊重简单的原则(比如告知用户可以做什么),并通过 Raku 展示如何在 GTK3 中实现。

GTK3 是一个很流行的 GUI 库,已经是 3.xx 后期的版本,它最初是作为 GIMP 图像处理程序的基础而开发的(事实上,GTK 的意思是 Gimp ToolKit)。最近,它已经蔓延到了各种多平台桌面应用中,包括 Gnome。很多应用程序,包括我正在使用的 LibreOffice 都使用它。它的原始语言是 C 语言,所以它需要一些脚手架才能在 Raku 等语言中使用。在安装我们将要使用的发行版 Gtk::Simple 之前,我们必须使用 link:https:// github.com/raku-community-modules/gtk-simple[https:// github.com/raku-community-modules/gtk-simple] (在 Ubuntu 或 Debian 的情况下,sudo apt install libgtk-3-dev)中的说明安装该库的开发版。在 Windows 的情况下,共享库是随着 Raku 模块的安装而下载和安装的。

GTK3 的基本概念是盒子。你把 widget 水平堆放在 Hboxes 中,垂直堆放在 Vboxes 中,而这些 widget 又相互嵌入,直到最终的窗口矩形被创建。例如,如果你想在另一个 widget 上创建两个 widget,你需要将这两个 widget 嵌入一个水平框中,然后这个水平框和新的 widget 一起插入一个垂直框中。

和前面的食谱一样,一个 UI 应用将基本上由两部分组成:设计 UI 的布局和对其中的事件创建反应。我们将创建一个应用程序,列出三种类型的食材—​主食、副食或甜点—​以及素食和乳制品按钮,让我们可以切换该类型的食材。

use GTK::Simple;
use GTK::Simple::App;
use GTK::Simple::RadioButton;
use Raku::Recipes::SQLator;

my $app = GTK::Simple::App.new( title => "Select ingredients" );

my $dator = Raku::Recipes::SQLator.new();
my @all-radio;

my @panels = do for <Main Side Dessert> {
    create-type-panel( $dator, $_)
};

for @all-radio -> $b {
    $b.toggled.tap: &grayout-same-name;
}

$app.set-content(
            GTK::Simple::VBox.new(
                create-type-buttons( @panels ),
                GTK::Simple::HBox.new( |@panels )
            )
        );

$app.border-width = 15;
$app.run;

END {
    say "Selected ingredients →";
    say @all-radio.grep(  *.status ).map( *.label ).join(" | ");
}

sub create-type-buttons( @panels ) {
    my $button-set = GTK::Simple::HBox.new(
            my $vegan = GTK::Simple::Button.new(label => "Vegan"),
            my $dairy = GTK::Simple::Button.new(label => "Non-Dairy"),
            my $exit = GTK::Simple::Button.new(label => "Exit"),
            );
    $vegan.clicked.tap: { toggle-buttons( $_, "Vegan" )};
    $dairy.clicked.tap: { toggle-buttons( $_, "Dairy" )};
    $exit.clicked.tap({ $app.exit; });
    return $button-set;
}

sub create-radio-buttons ( $dator, @labels is copy ) {
    my $label = shift @labels;
    my $first-radio-button =
            GTK::Simple::RadioButton.new(:$label )
            but $dator.get-ingredient($label);
    my @radio-buttons = ( $first-radio-button ) ;
    while @labels {
        $label = shift @labels;
        my $this-radio-button =
                GTK::Simple::RadioButton.new(:$label)
                but $dator.get-ingredient($label);
        @radio-buttons.append: $this-radio-button;
        $this-radio-button.add( $first-radio-button );
    }
    @all-radio.append: |@radio-buttons;
    @radio-buttons;
}

sub create-button-set( $dator, $title, @labels ) {
    my $label = GTK::Simple::TextView.new;
    $label.text = "→ $title";
    my @radio-buttons = create-radio-buttons( $dator, @labels );
    GTK::Simple::VBox.new( $label, |@radio-buttons);
}

sub create-type-panel( Raku::Recipes::Dator $dator,
                       $type where $type ∈ <Main Side Dessert> ) {
    my @ingredients = $dator.search-ingredients( { $type => "Yes" });
    create-button-set( $dator, $type, @ingredients );
}

sub toggle-buttons( $button, $type ) {
    state $clicked = False;
    if $clicked {
        $button.label = "Non-$type";
        for @all-radio -> $b {
            if $b.Hash{$type} eq "Yes" {
                $b.sensitive = False;
            } else {
                $b.sensitive = True;
            }
        }
        $clicked = False;
    } else {
        $button.label = $type;
        for @all-radio -> $b {
            $b.sensitive = $b.Hash{$type} eq "No";
        }
        $clicked = True;
    }

}

sub grayout-same-name( $b ) {
    state $toggled = False;
    if $toggled {
        for @all-radio -> $other {
            if $b !=== $other and $b.label eq $other.label {
                $other.sensitive = False;
            }
        }
        $toggled = False;
    } else {
        for @all-radio -> $other {
            if $b.WHICH ne $other.WHICH and $b.label eq $other.label {
                $other.sensitive = True;
            }
        }
        $toggled = True;
    }
}

让我们看看这个程序的不同部分是如何工作的。

13.2.4. 创建布局

窗口有如下内容。

  • 最上面一排有三个按钮: 素食和乳制品按钮,以及 …​ 作为退出按钮。我们把这些类型的按钮叫做"类型按钮"。

  • 一个有三列的面板,每一列都有一个标签表示它们的类型。它们包含一个成分列表,这些成分的工作方式就像单选按钮一样,你只能选择其中的一个。这些就是类型面板。

布局是用这个命令来构架的。

$app.set-content(
            GTK::Simple::VBox.new(
                create-type-buttons( @panels ),
                GTK::Simple::HBox.new( |@panels )
            )
        );
$app.border-width = 15;

所以它是一个垂直的盒子,上面是类型按钮,下面是水平的盒子,下面是类型面板。图13-3显示了这个样子。

图13-3. 初始应用程序窗口

每一个组件的长度都是自动设置的,尽管我们给整个窗口周围15个像素。

我们还需要为每个单选按钮提供一些信息。一个按钮小组件只需要包含显示它所需的信息。但是我们需要检查一个按钮是否包含素食或乳制品成分的信息,我们需要给按钮添加一个有效载荷。我们使用 mixins 来完成这个工作。

my $first-radio-button =
        GTK::Simple::RadioButton.new(:$label )
        but $dator.get-ingredient($label);

$first-radio-button (和其他按钮)变量包含了一个 GTK RadioButton,但也包含了我们对成分的数据。$first-radio-button 是一个混合体。当我们在 "first" 对象上调用方法时,它将表现为这样。然而,如果我们把这个对象(只是在其他对象更有意义的上下文中使用它)转换为混合类的类型,在本例中是Hash,我们将能够访问混合类的那部分。$first-radio-button 将表现为一个 RadioButton,但 $first-radio-button.Hash 将包含该部分的信息。这是一种非常聪明的方式,可以将有效载荷添加到变量中,而不需要用另一个哈希值或数组或其他什么东西来交叉引用它们。而且我们可以在创建时或之后混合有效载荷。你以后会发现这个相当有用。

所有的窗口都包含在一个 App 变量中。我们通过 set-content 设置这个变量的内容,然后使用 $app.run 启动事件循环。$app.exit 将退出应用程序,我们将其绑定到 Exit 按钮上。

我们会更详细地看到动作是如何工作的。每一个 widget 都会创建一个点击流,用 Raku 的术语来说就是一个你可以点击的供应。点击是对该点击流的订阅;每次收到事件时,点击块都会被执行。我们在第二章中广泛使用了它们。我们需要显式地设置这些轻敲,以便每次点击按钮时都能做一些事情。默认情况下,小组件的状态会发生变化,但我们可能需要对它做一些其他的事情。例如,如果我们点击素食者,我们需要取消选择所有非素食者的成分,但我们也需要切换它的状态来显示它们的位置。在 GUI 中,显示状态总是很重要的,而且应该清楚地说明。如果我们点击素食,图13-4说明了窗口中会显示什么。

图13-4. 选择素食选项

通过将那些不符合所选标准的选项灰化,我们展示了哪些可以做,哪些不可以做。toggle-buttons 例程就可以解决这个问题。但是为了切换这些按钮,我们需要知道它们是什么样的类型。这就是上面提到的 mixin 的用武之地。

for @all-radio -> $b {
    $b.sensitive = $b.Hash{$type} eq "No";
}

我们把所有的按钮都存储在一个全局变量中,@all-radio,以便能够做这种事情。$b.Hash<Vegan> 会告诉我们这个按钮是否指的是素食成分,同理,乳制品也是如此。

这些单选按钮也有自己的 tap,但这些按钮只会将其他同名按钮灰化。

for @all-radio -> $other {
    if $b.WHICH ne $other.WHICH and $b.label eq $other.label {
        $other.sensitive = False;
    }
}

每当我们切换一个单选按钮时,我们就会检查是否有其他的单选按钮与之同名。首先我们检查它们是否不同:. WHICH 是每个对象的唯一 ID,如果是同一个按钮,它将是相同的。然后,我们需要检查标签是否完全相同;在这种情况下,我们通过将敏感属性切换为 False(如果相反,则为 True)将其灰色化。这样一来,如果我们在任何一列中选择了大米,其他两列就会被灰化,如图13-5所示。

图13-5. 在另一列中选择大米后,大米被灰化了

实际上,你如何处理所选的成分已经超出了这个配方的范围,但无论如何,我们使用 END 块打印它们。再一次,将所有按钮放在一个变量中是很方便的:我们简单地过滤那些状态为 True 的按钮,意思是点击,然后使用 .map( *.label) 提取标签。当我们退出时,类似这样的内容将被打印出来(到控制台)。

Selected ingredients → Lentils | Rice | Apple

我们可以用它做一些不同的事情,交互式地显示信息。文本是在 TextView 小组件中显示的,我们这里用它来显示列标题。然而,这将遵循同样的原则:设置小组件,将其存储在某个地方,以便你可以使用它,当你需要时,在点击中改变小组件的状态。

在此基础上还可以创建更复杂的应用程序:最近更新的生态系统中有一系列 Gnome::* 发行版,你可以用它们创建带有绘图面板的应用程序,甚至是小型编辑器。Gnome::Gtk3 是一个 fork,经常更新,并且有一个非常自由的 Apache 许可证。

一般来说,如果你需要创建一个成熟的、窗口化的、多平台的应用程序,在 Raku 中有很多不同的方法。

13.3. 食谱 13-3. 创建一个小游戏

13.3.1. 问题

你需要制作一个动画或桌面小游戏。

13.3.2. 解决方案

使用 SDL2::Raw 和 SDL2,SDL2 是围绕 SDL2 库的低级面向对象的封装器,SDL2 库是一个用于创建视频游戏的多平台、优化框架。

13.3.3. 它是如何工作的

游戏可能看起来很复杂,留给电子游戏开发者去创造。

但归根结底,游戏是一种叙事性的媒体,可以用来传递信息,鼓励行为,或者仅仅是用动画的方式,当然也是娱乐的方式来展示一些东西。

在一个游戏中,你必须考虑到很多东西,但作为一个从 Pong 上切入游戏的人,基本上是两行和拇指指甲厚的像素,我可以告诉你,最重要的是叙事和机制,而不是图形方面。

SDL,即 Simple DirectMedia Layer,是一个多平台的库,可以帮助你完成这些事情。它包含多个基元,用于绘制形状、点、从窗口获取点击流,以及创建游戏时需要的任何东西。事实上,许多专业游戏都是使用 SDL 创建的。它有助于与许多语言的绑定,包括 Raku。无论如何,请按照 http://www.libsdl.org/download-2.0.php 的说明下载该库的开发版本,如果你使用的是 Ubuntu,则只需从命令行发出这个命令即可。

sudo apt install libsdl2-dev

所以让我们用它来做我们的程序。我们的游戏将被称为 DIVCO,它的目的是展示感染如何在平地传播。如果这些方块是连续的(甚至是一个角一个角的),那么它们有 0.5 的可能性会互相传染。如果一个健康的方块旁边有两个生病的方块,感染就会一直发生。我们可以在任何时候感染任何一个方块,每一秒钟我们都会检查是否有新的感染发生。用户可以感染任意数量的初始方块,只需点击一个方块即可。

我们需要定义方块,我们将使用一个独立的类来完成。

use SDL2::Raw;

enum unit-state <HEALTHY INFECTED>;
constant OPAQUE = 255;

constant @infected = (255,0,0,OPAQUE);
constant @healthy = (0,255,0,OPAQUE);
constant GRID_X = 25;
constant GRID_Y = 25;

unit class My::Unit;

has $!renderer;
has $!x;
has $!y;
has unit-state $.state = HEALTHY;
has $!rect;

submethod BUILD( :$!renderer, :$!x, :$!y ) {}

submethod TWEAK {
    $!rect = SDL_Rect.new:
        x => $!x*GRID_X,
        y => $!y*GRID_Y,
        w => GRID_X,
        h => GRID_Y;
}

submethod flip() {
    $!state = $!state == HEALTHY ?? INFECTED !! HEALTHY;
    self.render;
}

method render {
    if $!state == HEALTHY {
        $!renderer.draw-color(|@healthy);
    } else {
        $!renderer.draw-color(|@infected);
    }
    $!renderer.fill-rect($!rect);
}

每一个方块都有网格中的坐标,它出生时传递的渲染器对象,状态,以及一个定义为 SDL_Rect 的矩形。既然它的位置不会改变,我们也可以在这里生成基元,当我们需要时再使用它。唯一的公共属性是状态,这将防止任何客户端在对象的初始化后改变其余的属性。

Unit 可以从感染到健康再到健康,它使用 SDL2::Raw 基元把自己画到网格上。我们使用 draw-color 将它们绘制成不同的颜色—​绿色代表健康,红色代表感染。我们使用 fill-rect 基元来绘制精确的形状。正如你所看到的,它是通过三个步骤完成的:我们设置颜色,我们创建形状,我们填充形状。这具有很大的灵活性(例如,我们可以使用纹理),但当你需要绘制一个简单的矩形时,它肯定很啰嗦。它并不是真正的渲染,而是在一个"阴影画布"中,只有当我们需要它时才会呈现。

好了,这将是我们的基本单元,但我们需要在主程序中定义游戏的其他部分。在这里,它是。

use SDL2::Raw;
use lib <lib Chapter-13/lib>;
use SDL2;
use My::Unit;


LEAVE SDL_Quit;

my $occupied =  @*ARGS[0] // 0.5;

my int ($w, $h) = 800, 600;
my $window = init-window( $w, $h );
LEAVE $window.destroy;

my $renderer = SDL2::Renderer.new( $window, :flags(ACCELERATED) );
SDL_ClearError;

my @grid[$w/GRID_X;$h/GRID_Y];
say "Generating grid...";
for ^@grid.shape[0] -> $x {
    for ^@grid.shape[1] -> $y {
        if ( 1.rand < $occupied ) {
            @grid[$x;$y] = My::Unit.new( :$renderer, :$x, :$y );
            @grid[$x;$y].render;
        }
    }
}
sdl-loop($renderer);

#-------------------- routines -----------------------------------------

#| Init window
sub init-window( int $w, int $h ) {
    die "couldn't initialize SDL2: { SDL_GetError }" if SDL_Init(VIDEO) != 0;
    SDL2::Window.new(
            :title("DIVCO"),
            :width($w),
            :height($h),
            :flags(SHOWN)
            );
}

#| Rendering loop
sub sdl-loop ( $renderer ) {
    my SDL_Event $event .= new;
    loop {
        state $last-update = now;
        while SDL_PollEvent($event) {
            handle-event( $renderer, SDL_CastEvent($event) );
        }
        if now - $last-update  > 1 {
            infection-loop($renderer);
            $last-update = now;
        }
    }
}

#| Handle events
proto sub handle-event( | ) {*}

multi sub handle-event( $, SDL2::Raw::SDL_MouseButtonEvent $mouse ) {
    my ( $grid-x, $grid-y ) = gridify( $mouse.x, $mouse.y );
    given $mouse {
        when (*.type == MOUSEBUTTONUP ) {
            with @grid[$grid-x; $grid-y] {
                .flip;
            }
        }
    }
}

sub gridify ( $x, $y) {
    return ($x / GRID_X).Int, ($y/GRID_Y).Int;
}

multi sub handle-event( $, SDL2::Raw::SDL_KeyboardEvent $key ) {
    given $key {
        when (*.type == KEYDOWN )
        {
            if $key.sym == 27 {
                exit;
            }
        }
    }
}

multi sub handle-event( $, $event ) {
    given $event {
        when ( *.type == QUIT )
        {
            exit;
        }
    }
}

sub infection-loop( $renderer ) {
    say "Infection loop…";
    for ^@grid.shape[0] -> $x {
        for ^@grid.shape[1] -> $y {
            with @grid[$x; $y] {
                if .state == HEALTHY {
                    my $prob=0;
                    for max($x - 1, 0) .. min($x + 1, @grid.shape[0] - 1) ->
                    $xx {
                        for max($y - 1, 0) .. min($y + 1,
                                @grid.shape[1] - 1) ->
                        $yy {
                            if @grid[$xx;$yy] && @grid[$xx;$yy].state ==
                            INFECTED {
                                $prob += 0.5
                            }
                        }
                    }
                    if 1.rand < $prob {
                        @grid[$x;$y].flip;
                    }
                }
            }
        }
    }
    $renderer.present;
}

同样,有点啰嗦,这也是GUI应用程序的惯例。它是这样继续构建应用程序的。

  1. 它创建了游戏所需的低级基元—​窗口(800 x 600)和渲染器,渲染器是处理阴影画布并将其绘制在屏幕上的对象。事实上,这些都使用了两个 SDL2 高级类。其中一个是用来创建渲染器对象的,因为它是用来在屏幕上绘画的,所以会传递给很多其他方法。SDL2 并不太完整,在其他大多数情况下,我们将使用 SDL2::Raw 程序等价物。

  2. 然后它创建平地网格。概率默认为0.5(50%的方格将被占用),但可以通过命令行更改,使用0到1之间的数字,密度越高,感染的概率越高。每个单位或方块,都会得到一个渲染器的副本,它需要定义矩形并绘制到阴影画布上。单元被存储在一个形状数组中。异形数组是n维数组,可以记住每个维度的大小。它们足以代表一个网格。作为一个额外的优势,在它们上面运行循环是相当容易的,这要归功于 .shape 方法,它返回一个包含每个维度中元素数量的数组。

  3. 然后进入主循环。这个循环永远运行,或者直到按下正确的键,它做两件事—​它检查点击流中的任何东西并进行处理,它每隔一秒就运行一次感染循环。这个感染循环会移动,一个方块一个方块地检查它的邻居,并根据规定的概率将它们从健康状态翻转到感染状态。这个概率在这行中是硬编码的,如果 @grid[$xx;$yy] && @grid[$xx;$yy].state == INFECTED { $prob += 0.5 },但要你使用不同的概率,看看它们如何影响感染概率。

  4. LEAVE 块将以一种有序的方式从 SDL 引擎中退出。这将在程序关闭前被调用。它如何工作的一个例子显示在 https://youtu.be/zzw9XSOX5-Q, 以及图13-6中。屏幕上没有显示任何说明,但点击某个地方感觉很自然,并鼓励用户探索游戏。也没有显示分数:它想传递的是如何让亲近感更快地传播。

图13-6. 初始屏幕,感染前

主循环是排他性的,也就是说,它阻止了其他事件循环的发生,包括异步循环。这在 Raku 中并不是最好的工作方式,但我们仍然可以通过其他方式来模拟异步事件。有些语言使用事件循环集成器,但在我们的情况下,我们必须使用现有的手段。循环订阅点击流,在 $event 变量中捕获点击。我们创建一个 multi 来处理这个事件,这样我们就可以解除程序的混乱,以不同的方式处理不同类型的点击。通用的 multi 将处理 quit 事件,基本上是当我们关闭窗口的时候。例如,我们可以在这种情况发生时打印一条关闭消息。下一个是处理键盘事件,但它只是监听值为27的键,也就是 Escape 键,而且它也会在该键被按下时做出反应,而不是在我们停止按该键时。SDL 中的点击流可以捕获各种事件,这样我们就可以对使用按键、鼠标、甚至操纵杆等输入设备的不同手势做出反应。

最后,接下来的多起来处理鼠标事件,但我们只对鼠标被点击时感兴趣。事实上,只有当我们停止点击它的时候,我们才会感兴趣。MOUSEBUTTONUP. 每当我们点击一次,就会捕捉到两个事件,DOWN 和 UP。我们可以选择其中一个来应对。当发生这种情况时,我们要做的是将光标的x和y位置转换为网格(使用 gridify 子例程),这样我们就可以直接使用它们作为数组索引。有了这些计算结果,我们将感染相应的方块,这将是简单的占据 @grid 数组中相应位置的方块。感染会自动扩散,直到出现类似图13-7的情况,感染无法再扩散。

图13-7. 感染的最后阶段,初始密度为0.4,有两个种子方块。

我们可以试验不同的密度,看看感染会如何进行。在密度等于0.5(这是默认值)的情况下,很有可能从一次传染开始,除了可能是几个孤立的方块外,其他的方块都会传播。只要将密度降低到0.4,就已经有了不相干的岛屿,单个传染就不会扩散到其他区块,这说明社会疏远是阻止传播的一种方法。

归根结底,编程游戏机制涉及到两种类型的动作:一种是周期性的动作(移动、新对象的出现什么的),另一种是反应性的动作(对按键做出反应,让敌方对象对你的动作做出反应)。在 Raku 和使用 SDL 中,这就是一个结合基本事件循环的问题,并对点击流中的事件做出不同的反应。这些可以创造性地与评分方法、不同的游戏玩法相结合,甚至可以创建客户端-服务器组合,让人们一起工作。

不过在目前的状态下,它的可玩性和已经传达的信息,只用了几十行 Raku。要有创意,创造自己的游戏,哪怕是只给你的朋友和家人。

14. 与其它语言的库和代码对接

世界上充满了可以在你的应用程序中重复使用的(开源)代码。如果已经用 Raku 写好了就好了…​…​。不用担心! Raku 被设计为嵌入其他语言,从使用 C 或 C++ 调用惯例的调用库到其他语言的完整代码,如 Perl,Python,甚至 JavaScript 的子集。在本章中,这将是主要的主题:与各种其他语言编写的代码一起工作。你可以将其扩展到更高的层次,甚至扩展到可嵌入的、用 C 语言编写的语言,比如 Perl。

14.1. 食谱 14-1. 嵌入 Perl 程序

14.1.1. 问题

要么你真的很喜欢 Perl 的 regexes,要么你有一些 Perl 中的毛茸茸的代码需要从你的 Raku 程序中访问,要么你只是有一些遗留的代码,你最好不要去碰它,因为它可以工作,但你仍然需要扩展它的功能。在任何情况下,都有一些 Perl 代码需要和 Raku 一起运行。

14.1.2. 解决办法

使用 Inline::Perl,或者在某些有限的情况下,使用 Butterfly Project 的 p5* 模块,这些模块使用与 Perl 中相同名称的例程扩展 Raku,这样你就可以一直使用本地的 Raku 代码。

14.1.3. 它是如何工作的

尽管有相反的报道,但 Perl 是一门流行的语言,截至2020年,它的工资在当前语言中是最高的。这也意味着,它有一个非常丰富的生态系统,包括在成千上万的 CPAN(综合 Perl 档案网络)模块上,和一个巨大的安装基础,必须在生产中维护。在任何情况下,你可能需要使用 Perl 代码将其包含在你的应用程序中。

注意:你可能知道 Raku 最初叫 Perl 6。事实上,它与 Perl 完全不兼容,所以才改名。然而,这个 Inline::Perl 模块维护得很好,所以它们之间仍然不是天壤之别。事实上,它们共享一个大的社区,许多人是在 Perl(和 Raku)活动中了解到 Raku 后才来到 Raku 的。

此外,Perl 已经稳定了很久,API 不变,这意味着10年前甚至15年前发布的库(或 Perl 语中的模块)现在完全可以使用。此外,Perl 是一门流行的语言,在一段时间内,它可能是三大语言之一,这意味着 CPAN 中有一个库可以满足所有的需求。有了 Inline::Perl5,你可以在 Raku 中使用这些库。

首先,你需要安装发行版,为了做到这一点,你必须准备一个特殊的、共享的、版本的 Perl 解释器。使用 perlbrew,Perl 版本管理器,你可以这样做。

perlbrew --notest install stable -Duseshrplib

许多操作系统打包的 Perl 都带有 -Dusershrplib -fPIC(如 Ubuntu 或 Debian 的 Perl),在这种情况下,就不需要这样做了。

这将在不进行测试的情况下安装最新版本的 Perl,在写这篇文章的时候是 5.30.2。

由于 Raku 以前被称为 Perl 6,Perl 仍然停留在 5.xx 版本,每年年中都会产生偶数的版本,以赶上每年的 Perl (以及现在的 Raku) 大会。当你读到这篇文章的时候,稳定版至少是 5.32,但最后一个稳定版将用这个命令激活。

perlbrew use 5.30.3

这对于 Inline::Perl5 以及我们将在这里使用的模块的安装是必要的。一旦安装完成,请使用 zef 以通常的方式安装 Inline::Perl5;或者,如果安装失败,请按照 https://github.com/niner/Inline-Perl5/ 的说明进行安装。

我们将尝试找到一个允许我们使用食谱的模块;它将帮助我们导入食谱,并在必要时将它们转换为 HTML。而这个模块可能是 MealMaster,https://metacpan.org/pod/MealMaster,这个模块允许导入食谱,格式叫做 MealMaster。你可以在网页上找到无数的菜谱,它直接来自90年代初:http://www.ffts.com/recipes.htm。很显然,这个格式是由一个叫做 "Now you’re cooking" 的桌面程序使用的。

它看起来有点像这样。

MMMMM----- Recipe via Meal-Master (tm) v7.07
      Title: Freezer Log
 Categories: Appetizers
Servings: 6
    1/2 lb Sharp Cheddar cheese
      8 sl Cooked bacon
    1/2 ts Worcestershire sauce
      1 ts Dry mustard
      2 ts Mayonnaise
  Combine in food processor - 1/2 lb. sharp yellow cheese, 8 slices bacon,
  1/2 tsp. Worcestershire sauce, 1 tsp. dry mustard, 2 tsp. mayonnaise.
  Blend until bacon is minced. Form into a log shaped like party rye bread.
  Wrap in plastic wrap or foil and freeze til firm. To serve, slice, put on
  rye bread,
MMMMM

这不是一个很难解析的格式,用空格来表示不同的部分,用五个 M 来标记每个菜谱的开头和结尾,还有固定的元数据,比如 Servings 或 Title。不过,你还是需要解析这种格式,MealMaster 模块从2005年就开始做了。在 Raku 程序中使用它之前,我们需要安装它,使它可以被 Perl(在 Raku 内或不在)解释器使用。我们用一个 cpanfile 来表达这些要求。

requires 'MealMaster';
requires "Text::Markdown";

这种格式最近被扩展为向我们系统展示需求的最佳方式。然后我们可以通过以下方式安装这些需求。

cpanm --installdeps .

这是 cpanm(或 CPAN minus),一个安装需求的命令行工具。做完这些,我们就可以运行这个程序了。它以 MealMaster 格式从文件中导入食谱,导入它们以实例化一个 Raku::Recipes::Recipe 类型的对象,然后以 HTML 格式导出它们,同样使用一个来自 Perl 的模块,叫做 Text::Markdown。

use Inline::Perl5;
use MealMaster:from<Perl5>;
use Text::Markdown:from<Perl5>;
use Raku::Recipes::Recipe;

my $parser = MealMaster.new();
my @recipes = $parser.parse("Chapter-14/appetizer.mmf");
my $md = Text::Markdown.new();

for @recipes -> $r {
    my $description = "Categories " ~ $r.categories().join( " - ");
    my $title;
    if $r.title ~~ Str {
        $title = $r.title
    } else {
        $title = $r.title.decode
    }
    my $recipe = Raku::Recipes::Recipe.new(
        :$title,
        :$description,
        ingredients => $r.ingredients().map: {.product }
    );
    say $md.markdown( $recipe.gist );
}

要使用 Perl 模块,这个子句完全一样,只是我们在结尾处添加了副词 :from<Perl5>,以说明它们需要通过 Perl5-Raku 桥来安装和实例化。我们还使用了来自 Perl 5 的 Text::Markdown。即使在 Raku 宇宙中有几个 markdown 的发行版,也没有一个像这个2010年发布的发行版那么成熟,它是基于最初的 markdown 实现,是用 Perl 编写的。

这些符号被安装到程序中,可以使用原生的Raku命令来运行。例如,我们实例化一个菜谱阅读器和一个标记解析器。MealMaster.parse 将创建一个对象数组,每一个对象都是一个 MealMaster 菜谱,或者是一个 MealMaster::Recipe 类型的对象。我们提取该对象的属性来实例化我们的对象。在从 Perl 到 Raku 的转换过程中,有些东西可能会有一些改变。例如,如果标题是简单的 ASCII 字符,它将返回一个 Str 字符,但如果它以某种方式包含了其他符号,如 o 或 é,则返回一个 Blob 字符(我们应该也要对 ingredients 做这样的处理,因为它们也有同样的问题,但现在还不那么明显,我们就让它过去吧)。这就是为什么我们将 $r.title 的结果智能匹配为 Str,如果是 Blob,则解码为这样一种类型的对象。

由于 MealMaster 格式中没有描述的地方,我们从格式中列出的类别中创建一个。最后,我们创建一个食材数组。MealMaster 会用自己的格式 MealMaster.Ingredient 来返回每一个,我们从中只获得它使用的产品。它还包括了度量衡和数量,但由于它们不是我们正在使用的格式,所以我们只关注产品。

最后,我们将产品的要旨创建成 HTML,正如我们之前所看到的,这个要旨已经是 markdown 格式的,所以直接将其转换为 HTML,生成这样的东西。

<h1>Zucchini Fritters #2</h1>
<p>Categories: Appetizers</p>
<h2>Ingredients</h2>
<ul>
<li>Milk</li>
<li>Egg, lightly beaten</li>
<li>All-purpose flour</li>
<li>Baking powder</li>
<li>1-ounce package ranch-style dip mix</li>
<li>(8 ounces) shredded zucchini</li>
<li>Vegetable oil</li>
</ul>

我们可以使用第11章中的配方,并将它们发布到网站上,或者以其他方式进行处理,但问题是,我们可以在 CPAN 模块的宝库中挖掘任何我们可以想象的东西,并导入和按摩数据,连接到深奥的数据存储,或者其他任何在 Raku 生态系统中不那么容易获得的东西。

如前所述,另一种选择是使用 Raku 原生代码来创建尽可能接近原始代码的代码。在这方面,Liz Mattijsen 的"蝴蝶计划"会有所帮助。你可以在 https://modules.raku.org/dist/P5built-ins:cpan:ELIZABETH 找到相关信息。主要的想法是将所有的 Perl 内建插件移植到 Raku 上,这样你就可以简单地将旧的 Perl 代码转录到它的 Raku 等价物上,而不需要花费太多的精力。

我们将尝试用一个名为 File::Chown 的 Perl 模块来实现这个目标,你可以在 https://metacpan.org/pod/File::chown。 之所以选择这个模块,是因为它主要是在基本的 Perl 上增加了一些附加值,而且你没有额外的依赖性需要移植。本质上,它包裹了 Perl 自己的 chown 函数,并给出了一个程序化的接口,可以使用用户和组的名称、ID来改变一组文件的用户和组,或者干脆用一个文件的用户和组来实现。

这就是已经创建的模块,名为 Sys::Chown。

unit module Sys::Chown;
use P5getpwnam;
use P5getgrnam;
use UNIX::Privileges :USER;
use File::Stat <stat>;

subset Valid-User of Str:D where userinfo($_).uid.defined;
subset Valid-Group of Str:D where groupinfo($_).gid.defined;

proto sub chown(|) {*}
multi sub chown ( @files,
                  Valid-User $user,
                  Valid-Group $group = getgrgid(userinfo($user).gid)[0] )
    is export {
        my @result = do for @files -> $f {
            UNIX::Privileges::chown($user, $group, $f);
        }
    so all @result;
}

multi sub chown ( @files,
                  UInt $uid,
                  UInt $gid = userinfo(getpwuid($uid)[0]).gid )
    is export {
        my @result = do for @files -> $f {
            UNIX::Privileges::chown(getpwuid($uid)[0], getgrgid($gid)[0], $f);
        }
    so all @result;
}

multi sub chown (@files, IO::Path $file where .e ) {
    my $stat = stat($file.path);
    chown( @files, $stat.uid, $stat.gid);
}

我们使用了 Butterfly 项目中的两个模块:P5getpwnam 和 P5getgrnam。另外两个模块是用来模拟没有被移植的函数。UNIX::Privileges 用于低级的、单文件的 chown,File::Stat 用于统计,从文件中提取信息。

我们还定义了两个子集,Valid-User 和 Valid-Group,这样调用这些函数就比较(类型-)安全。在原来的版本中(你可以在它的版本库中查看,网址是 https://github.com/perlancar/perl-File-chown/blob/master/lib/File/chown.pm), 调用和单 chown 子如果不工作,就会以错误的方式保释出来。然而,我们可以在签名级别捕捉这些错误,所以我们这样做。

此外,我们利用了多子,而不是使用单次调用。原来是依靠嵌套的 if 来找出使用的是哪种调用模式。如果是对哈希的引用,那么我们就想用文件来工作,如果没有,那么,就是用户和组。其余的元素就是我们想要工作的文件。我们将在一个多中创建三个不同的签名来处理每一个签名。

第一个使用名称和组作为字符串,但它会检查它们是否存在。默认情况下,组将是与用户对应的第一个组,它通常与用户的名字相同。我们混合 UNIX::Privileges 的 user info,获取一个包含用户信息的数据结构,包括组,然后我们调用 P5getgrnam 的 getgrid,从获取的网格中获取组的名称。低级的(来自 UNIX::Privileges)chown 使用的是用户和组名。

最后,使用文件的采用 File::Stat 的 stat 来查找文件的 uid 和 gid 信息。它使用一个 IO::Path,只要在结尾加上 .IO,就可以很容易地从一个字符串中创建。它必须是一个有效的文件,因为它会被检查是否存在。有了这个,你可以从脚本中以管理员身份使用这个库来改变文件权限,因为它们是有特权的。但整个观点是,你可以通过使用 Butterfly Project 的模块,以及一些额外的模块,轻松地将 Perl 的 File::chown 移植到一个新的库中。通过重用 API,将更容易地将其他 Perl 库原生地移植到 Raku 中;例如,File::Create::Layout https://metacpan.org/release/File-Create-Layout, 这是另一个依赖于 File::chown 的 Perl 库。

14.2. 食谱 14-2. 运行外部程序并捕获输出

14.2.1. 问题

你有一个没有 API 的程序,但你可以从命令行运行它,你想把它嵌入到你自己的应用程序中,从而捕获它的输出。

14.2.2. 解决方法

在第2章中,我们看到了如何连接外部程序的输入和输出;在第1章中,我们还使用了其外部 CLI 中的 etcd,并运行了外部程序。在这里,我们将学习如何使用 Proc::Async (https://docs.raku.org/type/Proc::Async) 异步连接到外部运行的程序,捕获其输出,并在需要时为其提供输入。

14.2.3. 如何工作

许多语言和实用程序都使用交互式 REPL 或 CLI,有一组简化的命令。这个 REPL 从标准输入中接收输入,并产生输出到标准输出,或产生错误到标准错误输出。当你与它们交互式工作时,是通过键盘和控制台。然而,大多数操作系统的一个好处是,那些是打开流的句柄。默认情况下,那些流是连接到控制台和键盘的。然而,你可以重新连接这些连接,并使用另一个程序来提供它们或处理输出,并用它做一些事情。

例如,Linux 有一个优秀的命令行计算器,叫做 bc。你输入一个表达式,然后得到输出。不加修饰。注意 你可以在 https://www.gnu.org/software/bc/bc.html 上下载它。

不过,如果能有一些花边就更好了。至少保留以前计算的输出,或者能够自动引用最后一行的结果。我们将使用 @xx 格式,@1 表示第一次计算的结果,以此类推。没有数字的 @ 表示最后一个可用的结果。另外,增加一个提示符。我们将在下一个程序中使用。

sub term:<bcp> { prompt(" " x 6 ~ "← ") }
class Bc {
    has $!bc;

    submethod BUILD( :$!bc ) {}

    method send( Str $str ) {
        $!bc.print($str.trim ~ "\n");
    }
    method get-next( @outputs ) {
        my $next = bcp;
        $next.trim;
        if ! $next {
            $!bc.close-stdin;
        }
        if $next ~~ /"@" $<output> = (\d*) / {
            my $index = $<output> ne "" ?? $<output> - 1 !! @outputs.elems - 1;
            my $result = @outputs[$index] // 0;
            $next ~~ s/"@"\d*/$result/;
        }
        self.send($next);
    }
}

my $bc = Proc::Async.new: :w, 'bc', '-l’;
my $this-bc = Bc.new( :$bc );
my @outputs;

$bc.stdout.tap: -> $res {
    @outputs.append: $res.trim;
    say "[ {@outputs.elems} ] → ", $res;
    $this-bc.get-next(@outputs);
}

$bc.stderr.tap: {
    say 'Error in input ', $_;
    $this-bc.get-next($@outputs);
}

my $next = bcp;
my $promise = $bc.start;
$this-bc.send($next);
await $promise;

该程序使用 Proc::Async 的实例来运行 bc,然后将流连接到外部程序。Proc::Async 重新连接输入和输出,不仅如此,它还把它们作为你可以敲打的供给。当然,这是异步的,正如这个类的名字一样。这是一个标准类,所以不需要使用外部依赖。

`$bc 对象是外部程序的一个表示,我们将用它来工作。首先,在声明全局变量后,我们将保留每次操作的输出,我们设置两个 tap,用于标准输出和标准错误。第一个将存储结果,呈现一个包括索引的提示,并提示下一个输入。我们将把 Bc 类包裹起来,以封装对象,而不是带着它到处调用子程序。

从这个 tap 和下一个 tap 中调用 $this-bcget-next 方法,它包含了这个程序的大部分附加值。它使用一个术语 bcp 来获取输入。

这是我们第一次面对这种事情。术语是定义符号的一种方式,它们定义了一组可以在任何地方使用的字符。它本质上是一个可以使用任何符号的例程。我们并不是通过使用 ASCII 字符来挖掘它的真实价值,所以我们用其他方式来称呼它。

sub term:<⏎> { prompt(" " x 6 ~ "← ") }

我们就可以只用符号来引用它。

my $next = ⏎;

不管名字是什么,它都会呈现六个空格,然后呈现一个"输入"箭头,并等待输入。这样一来,至少只要我们的操作不超过十次,输入和输出箭头就可以对齐。用户输入的内容将作为变量的值返回,并在 tap 中进一步处理。

然后,get-next 方法继续查找字符串中是否有 at 符号。使用一个 regex,它将其存储在 $<output> 变量中。它通过减去1(因为第一个输出是数组中的第零个元素)来转换为索引,如果该变量不包含任何内容,则将数组中最后一个元素的值赋给它。也就是说,如果元素里有一个裸 @。然后,在发送字符串给 bc 评估之前,我们用我们存储的值替换它。

为了把它发送给 bc,我们在 $bc 对象上使用 print。虽然我们需要敲击输出,但我们只需要通过"打印"来提供输入,或者在实用程序的标准输入中放入一些值。子 bc-send 就能做到这一点。它还修剪了两边多余的空格(例如,为了避免它有两个返回值),并且只在最后放一个,这是 bc 所期望的格式。

第二个点是在标准错误输出上。它打印出错误信息,并打印出提示,以便再次输入 repl 循环。该环节的结果如图14-1所示。

图14-1. Raku 使用 bc 增值的环节。

我们可以想到这个包装器的许多其他用途。例如,缓存结果,这样我们就不需要在bc中计算它们,而只需要立即返回它们。无论如何,Raku 对命令行交互式程序的控制程度允许我们将这些程序作为外部API使用,即使它们没有作为外部库使用。

然而,如果有一个外部库,Raku 提供了一种在我们的程序中使用它的方法。接下来我们将看到如何做到这一点。

14.3. 食谱 14-3. 用 NativeCall 封装用C写的外部库

14.3.1. 问题

有几个C库可以很好地集成到你的业务逻辑中,或者是真正的高性能模块,你可以用它们来加速你的程序。但是,它们都是用C语言编写的。

14.3.2. 解决方案

选择一个成熟完善的库,用 NativeCall 包住它。在你坐下来写之前,一定要检查一下生态系统中是不是已经有了这样的东西。

你不需要把每一个功能和数据结构都包起来,只需要选择那些对你的商业案例有用的。

14.3.3. 它是如何工作的

C 语言是一门成熟的语言,它有很多库,这些库经历了很多周期的调试和发布,可能是它们最擅长的。DRY(Don’t Reinvent Yourself )原则在这里是适用的,所以你不希望在 Raku 中重写库,而是希望能够从中使用现有的库。NativeCall 是一种安全的方式,由 Raku 提供,可以绕过现有的 C(和 C++)库,并将它们纳入到生态系统中。

大多数语言都有一个绑定外部库的机制,这样它们就可以在语言中原生使用。大多数现代语言也允许你使用共享库的二进制 API(在 Linux 中是 .so,在 Osx 中是 .dynlib,在 Windows 中是 .dll),通过将C数据结构和函数绑定到语言的数据结构和例程中。

在 Raku 中,有两点是需要考虑的。第一,本地数据类型,第二,如何声明一个例程,以便在调用时,共享库里面的代码被执行。

举个例子,我们将创建一个与 libcmark 的绑定,libcmark 是一个解释 CommonMark 的C库,是一种系统化的标记。该库包括解析和将 markdown 转换为不同格式的函数。在我们的例子中,我们只对从 markdown 字符串生成 HTML 的函数感兴趣。

在许多情况下,这个库已经被你的发行版作为 libxxx-dev 包发布了。在其他情况下,你必须从头开始构建它。在这种情况下,你需要从 https://github.com/commonmark/cmark 下载它,并按照说明进行安装。共享库将被称为类似 libcmark.so.0.29.0 的东西,它将被放在 /usr/local/lib 中。确保它最终以 /usr/lib/libcmark.so 的形式存在,这样我们的模块就能正确找到它。

我们将绑定一个名为 cmark_markdown_to_html 的例程。这个例程在原始库中是这样定义的。

char * cmark_markdown_to_html(const char *text, size_t len, int options)

这是模块的代码:

use NativeCall;
unit module cmark::Simple;

constant CMARK_OPT_DEFAULT is export = 0;
constant CMARK_OPT_SOURCEPOS  is export = 2;
constant CMARK_OPT_HARDBREAKS  is export = 4;
constant CMARK_OPT_SAFE  is export = 8;
constant CMARK_OPT_UNSAFE is export = 131072;
constant CMARK_OPT_NOBREAKS = 16;
constant CMARK_OPT_NORMALIZE = 256;
constant CMARK_OPT_VALIDATE_UTF8 = 512;
constant CMARK_OPT_SMART = 1024;
sub cmark_markdown_to_html(Str $text,
                           int32 $len, int32 $options
        --> Str )
    is native("cmark") is export {*};

sub commonmark-to-html( Str $text ) is export {
    cmark_markdown_to_html( $text, $text.encode.elems, CMARK_OPT_DEFAULT);
}

注:这是在生态系统中以 cmark::Simple 的形式发布的。几天前,同一库的另一个绑定 Cmark 也发布到了生态系统中。你可以在 https://github.com/khalidelboray/raku-cmark 查看它的实现差异,以及更完整的绑定。

NativeCall 是一个 Rakudo 库,而不是 Raku 库。这意味着它是 Rakudo 实现 Raku 的一部分,但不是规范的一部分。在实践中,这意味着它需要被显式地使用,就像 Test 一样,例如,它也是默认的 Rakudo(因此也是 Raku)发行版的一部分。

回头看看上面的 cmark_markdown_to_html 原文。我们首先要做的是将声明中的类型转换为等价的 Raku 类型。char * 只是一个 Str 类型。事实上,如果使用 CArray[uint8],或者是8位无符号整数的本机类型数组,会更加准确。但是,char * 是如此的普遍,而且通常都会被转换为 Str,所以这些即使是非本机类型,也可以直接使用。size_t 类型是可以用32位来表示的整数,我们在声明中就使用了这个类型。而最后一个参数,是用于选项的,它的值可以达到 2^17(对于 CMARK_OPT_UNSAFE),所以我们需要32位来表示。如果这里使用了一些C结构,我们还需要声明它,但在这种情况下不需要。

我们将这个函数定义为 native,指向共享库的名称。被称为 cmark,它将寻找 libcmark.so。前缀和后缀是隐式的。我们还将其导出,以备有人想直接使用它。当这个子被调用时,将运行的是库中同名的C函数,这就是为什么我们用一个存根 {*} 来表示它的代码。

我们会想让这个函数更简单直接一些,所以我们在它周围包裹了另一个函数。它选择了 cmark_markdown_to_html 的默认选项,并通过调用 .encode.elems 来找到字符串的长度。C例程需要精确的字节数,而不是字符的长度。.encode 将把 Str 变成一个 Blob,其元素数量与编码所需的字节数相同。所以 .elems 会返回大小并正确处理,如是。

commonmark-to-html('þor')

þor 中的 "þ" 在 UTF8 中是两个字节长。这将正确地返回 <p>þor</p>,正如我们所期望的那样。

最后,这个例子说明了如何在 Raku 中轻松创建C库的绑定。确定你要调用的入口点或例程,将调用签名转换为 Raku 中的等价物,声明将使用的数据结构,主要是 structs,并将它们声明为 native。注意,不需要重铸所有的例程或所有的数据结构,只需要重铸你感兴趣的那些。

在很多情况下,这些外部C库是为了提高速度和性能而使用的。而没有其他地方比图形库更重要。我们接下来将对其中的一个库进行研究。

14.4. 食谱 14-4. 与图形处理库合作

14.4.1. 问题

图形处理是一个广泛的领域,从基本的二维图片处理到由许多不同点组成的三维结构的创建和运动,这些结构为了移动,必须经过一系列涉及矩阵和矢量的数学运算。这是一个对性能要求很高的领域,基本上,你需要在一秒钟的时间内执行运动的一个片段,才能使它看起来流畅。这就是为什么像C这样的低级语言被经常使用的原因。它们能够在广泛的硬件范围内执行。但你需要在你的一个乐库程序中具备这种能力。

14.4.2. 解决办法

外面有很好的图形处理库,为了速度,大多数都是用C语言编写的。你不需要完整的功能集,只需要几个例程,甚至是单个例程。为了将它们的功能整合到你的程序中,最好的方法是使用 Raku 生态系统模块,如果它可用的话。大多数可能会链接相应的C库,你就可以直接使用它了。然而,如果不是这样的话,你将需要使用 NativeCall 来链接、声明和使用你熟悉的或仅仅是为了速度而选择的任何库的例程。

14.4.3. 它是如何工作的

低级图形处理单元主要涉及到对矢量和点的变换。如果你想连贯地移动一个对象,你必须对对象的每一个点应用同样的变换,考虑到每一个点的位置和方向。

最终,大多数图形库已经转换为矢量的四组件表示,通常称为 vec4。前三个分量是三维坐标,第四个分量,通常用w表示,对于矢量来说是1,对于点来说是0。

矢量的变换是通过对其应用矩阵来实现的,将矩阵乘以矢量将得到另一个矢量,即变换后的矢量。几乎所有的变换都可以通过这种方式进行,但大多数变换都是旋转、平移和缩放这三种方式的组合。一般来说,一个图形库将拥有所有这些操作,以及创建执行这些类型操作或组合这些操作的矩阵的简单方法。

如果我们试图在 Raku 中找到一个执行这些操作的库,我们会发现没有办法做到这一点(在 kazmath 发布到生态系统之前)。有很多 C 库,比如 CGLM,或者我们要使用的 kazmath(你可以从它的仓库 https://github.com/Kazade/kazmath 获取)。这个库经过了广泛的测试,包含了对这些图形基元的优化操作,并且有一个相对简单的 API,包含了一些简单的数据结构。因此,我们将向你展示如何围绕这些类型的程序包装 Raku API。完整的代码在 https://github.com/JJ/raku-kazmath; 我在这里逐一展示,以便更容易解释创建这个包装器的过程。

我们首先要考虑的是数据结构。基本上,我们将需要其中的两个,vec4 和 mat4。第一个是比较直接的。

class vec4 is repr('CStruct') is export {
    has num32 $.x;
    has num32 $.y;
    has num32 $.z;
    has num32 $.w;
    method new( Num() $x = 0, Num() $y=0, Num() $z=0, Num() $w = 1 ) {
        self.bless( :$x, :$y, :$z, :$w )
    }
    submethod BUILD ( :$!x, :$!y, :$!z, :$!w) { }
}

要与 C 代码互换的数据结构需要一些特定的表示方式。我们用 repr('CStruct') 特质来表示,它需要在内部用和 C 结构一样的方式来表示。这样一来,C 代码的部分就可以直接接收到它。

另外,数据本身的表示方式也需要是原生的。原来的数据结构是这样的。

typedef struct kmVec4
{
        kmScalar x;
        kmScalar y;
        kmScalar z;
        kmScalar w;
} kmVec4;

每一个元素都是一个 kmScalar,其实在其他地方定义为一个 double。问题是,一个双数在不同的平台上可以有不同的含义。在 Raku 中,我们有两种不同的表示方式:num32 和 num64。一般来说,num32 等同于 float,num64 等同于 double,但在这种情况下,经过多次测试,最终 num32 是正确的使用方式。

一般来说,选择正确的表示方式是一个试错的过程,因为每一种C数据类型都有几种可能。最终,通过错误和分段故障,你会确定一个哪种最适合你。然而,这些原生数据结构不能作为属性使用。我们将使用最接近它的 Raku 类型,在这种情况下,Num.在构造函数中,我们提供了自动转换(通过签名中的 Num()),对于任何其他类型,这种转换是可能的(如 Ints 或 Rats)。 除此之外,这个数据结构也是一个 Raku 类,这就是为什么我们可以提供一个默认的构造函数和一个子方法 BUILD 来将变量绑定到值,包括它们的默认值。

然后我们可以这样创建这些结构。

our $vec4-x = vec4.new( 1, 0, 0, 1);

这将是一个朝向 X 方向的向量。这一点可以通过第一个位置的1和最后一个位置的1显示出来,这表明它是一个方向向量,而不是一个点。每一个维度都需要是一个浮点数,事实上,字数将被转换为 Raku,而不是本地的数据结构。然而,我们可以提供一个 Raku 等价物,在本例中是1,与我们正在寻找的等价物。另外,我们也可以使用数字文字来表示相同的内容,指数符号如下。

our $vec4-y = vec4.new( 0e0, 1, 0e0, 1);

如果我们使用0,无论如何都会被转换为 Num,这只是让它变得明确。

我们为这个结构体使用的名称是任意的,我们只是给它一个名称,但重要的是结构体本身。我们只是给它一个名字,但重要的是结构本身。原来的叫 kmVec4,我们叫它 vec4。

现在我们准备定义一个处理这些事情的函数;例如,一个可以将其放大的函数。

sub kmVec4Scale(vec4 $pOut, vec4 $pIn, num32 $s)
        returns vec4 is native('kazmath') is export {*}

这个函数相当于C语言中的这个函数。

kmVec4* kmVec4Scale(kmVec4* pOut, const kmVec4* pIn, const kmScalar s);

它的名字是一样的,尽管我们可以重新定义它,如果它与命名空间中的任何其他符号发生冲突。然后检查一下等效的签名。kmScalar 类型被映射到 num32,kmVec4 被映射到 vec4。Raku 中没有指针,所以我们只需使用相应的数据结构,但 Raku 不会为我们分配内存。我们需要提前分配任何需要的内存。为了显示它,我们将它映射到 C 中的同名函数,我们说 is native('kazmath')。Raku 会搜索共享库文件,/usr/lib/libkazmath.so,我们应该已经提前安装了。

我们可以按如下方式使用它。

my vec4 $out .= new;
my vec4 $in .= new(1.Num, 0.Num, 0.Num, 1.Num);
my vec4 $result = kmVec4Scale($out, $in, 2.Num);

如果我们发出一个 say $result,它将打印以下内容。

kazmath::vec4.new(x => 1.4142135381698608e0, y => 0e0, z => 0e0, w => 1.4142135381698608e0)

矢量已经被缩放到两倍大小,然后进行了归一化处理。两个分量,$x$w,已经被归一化为二的平方根,所以向量保持正确的长度。

请注意,实际上,w 表示这是一个向量,但这些函数并没有考虑到这一点。

让我们再往上走一点,到 mat4 数据结构,它是用来变换向量的。

class mat4 is repr('CStruct') is export {
    HAS num32 @.mat[16] is CArray;
    submethod BUILD( :@mat = 0.Num xx 16) {
        for ^16 {
            @!mat[$_] = @mat[$_];
        }
    }
    method gist() {
        my @arr;
        my $index = 0;
        for ^16 {
            my $index = ($_ % 4 ) * 4 + ($_ div 4 );
            @arr.append: @!mat[$index];
        }
        return (@arr.rotor(4).map: "|" ~ *.join(" ") ~ "|").join("\n")
    }
}

这也是C语言中的一个结构,它是这样定义的。

typedef struct kmMat4 {
        kmScalar mat[16];
} kmMat4;

也就是说,它是一个有16个元素的数组,每一个元素都是一个 kmScalar,因此是一个 float。

我们在 Raku 中反映这个问题的方式相对复杂。让我们从简单的部分开始。@.mat[16] 清楚地表明这是一个有16个元素的位置关系。前面的 num32 表示每个元素都将是 num32 的类型,或者C领域的 kmScalar。

但是我们就需要用一个特质来表示这个需要表现为一个 CArray。

顾名思义,这些都是 C 语言中可以原生使用的数组,我们不能直接声明它,因为 CArrays 不能被约束在一个大小上,事实上,它们更相当于一个指针到元素,而不是一个数组。所以我们需要声明一个大小的数组,然后在它上面贴上 CArray 标签。

然而,这还不够。之前,结构的不同元素使用了一个简单的 "has as" 声明。然而,这使用了大写的 HAS。HAS 是 has 的嵌入式等价物;当有一个像这样复杂的数据结构时,Raku(以及其他任何语言)会创建一个指向嵌入式数组的指针,并把它藏在方便的地方。我们在这里告诉它的是 "不要这样做"。当你把这个数据结构传递给任何东西时,数组必须就在那里,它必须和它一起去。每当你在结构内有复杂的数据结构时,你就需要使用这个,主要是用数组。

我们也给这个类提供了一种简单的构建方式,这不是随意的,而是一种要求。C语言中的数据结构需要初始化为某种东西,所以我们把它初始化为0,这样当建立一个 mat 时,我们就已经可以使用它了。

gist 方法很简单方便,首先它显示了这些16个分量的向量转换成数组的方式,以列优先的方式,元素从上到下,然后从右到左。这只是一种简单的表示方式,这样我们在制作数组的时候就可以打印出来。

我们来看看制作这样一个数组的方法。例如,我们要创建一个在x轴上旋转的矩阵。通过这个函数就可以实现。

sub kmMat4RotationX(mat4 $mat, num32 $radians)
    returns mat4 is native('kazmath') is export {*}

kazmath 中的函数总是以同样的方式运行。它们得到一个元素的指针,然后返回指针传递的元素中的变换值,并作为这个变换的结果。很明显,这个原始函数。

kmMat4* kmMat4RotationX(kmMat4* pOut, const kmScalar radians);

用指针来表示这两者,而我们旋转它的弧度是一个 kmScalar。

我们现在可以这样使用。

my mat4 $one-mat .= new;
my mat4 $result1 = kmMat4RotationX($one-mat, pi/2);
say $result1;
my vec4 $out .= new;
my vec4 $in  .= new(0.Num, 1.Num, 0.Num, 1.Num);
my vec4 $result = kmVec4Transform( $out, $in, $result1);
say $result;

这将导致以下结果:

|1 0 0 0|
|0 -4.371138828673793e-08 -1 0|
|0 1 -4.371138828673793e-08 0|
|0 0 0 1|
kazmath::vec4.new(x => 0e0, y => -4.371138828673793e-08, z => 1e0, w => 1e0)

旋转矩阵将只包含中间的四个元素加上对角线,这些元素将被乘以矢量的坐标值。结果将是旋转和归一化的向量,稍微旋转。现在看的是z,y的值是一个四舍五入的误差,但不要紧。我们使用 kmVec4Transform 函数,它接收一个输出向量,一个输入向量,然后是我们将使用的数组作为变换。它是这样定义的。

sub kmVec4Transform(vec4 $pOut, vec4 $pV,
                    mat4 $pM)
        returns vec4 is native('kazmath') is export {*}

和其他函数一样,它被定义为 "is export",因为它实际上在 kazmath 模块中。结果将与 $pOut 的值相同,因为模块中的所有函数都是这样定义的。

我们可以用 kmVec4Transform( $out, $kazmath::vec4-y, $result1) 来实现同样的结果,因为它是一个默认定义的向量,而且它指向y的方向。

一般来说,开源都是建立在巨人的肩膀上的,改造、改编或包装现有的代码总比自己写好,尤其是像这个C语言库这样快速、严密、经过测试的代码,你还是需要花些功夫把数据结构铸造成乐乐的。一旦完成了这些,转换 API 中的函数就很直接了。记住,你只需要"导出"那些你感兴趣的函数,一旦C代码被调用,它内部调用的所有函数都不需要有 Raku 镜像。

这个图形代码又可以融入到游戏中(就像你在上一章看到的那些),或者用来加快数学模块的速度(就像第4章中使用的那些)。但无论如何,它扩大了 Raku 重用其他语言中的遗产或代码的可能性。

15. 加快处理速度

现代计算机已经远远不止一个处理器,在那里指令一条一条的运行,多核多线程可以帮助你把东西做得更快,通过简单的程序同时做几件事情。但是你需要一种语言,给你提供一个很好的抽象层,才能真正加快编程速度。Raku 是在21世纪设计的,它包含了一系列的功能,以利用硬件的并行处理能力。我们将在本章中研究几种方法。

15.1. 食谱 15-1. 使用带 Hyper/Race 的数据并行

15.1.1. 问题

你需要以最快的速度处理海量数据。

15.1.2. 解决办法

数据并行是一种技术,通过这种技术,可以同时处理串行数据结构的各个部分。Raku 通过 hyperrace 方法使用显式线程,或者使用 junction 使用隐式自动线程。hyperrace 命令可以让你更精细地控制数据结构的划分和处理方式。

15.1.3. 它是如何工作的

现代台式电脑拥有很大的功率,多核架构是常态,即使是低端的笔记本电脑也是如此。使用显式并发,我们可以在没有大量开销的情况下使用这些能力,但如果你想做的是在几个核心或处理器线程中同时处理几个大数据结构的碎片,还是显得有些矫枉过正。

这就是所谓的数据并行:不是一个一个地处理串行数据,而是同时处理该结构的几个项目。Raku 主要通过两种机制来实现数据并行:hyper/race 和 junction。我们暂且不说后者,重点说说前者。

hyperrace 这两个命令可以像串行数据结构(哈希和数组)的方法一样使用,也可以作为 for 等循环结构的前缀。主要的区别在于处理的顺序,因此也就是输出的顺序。另一方面,hyper 保证结果将以与原始数据结构相同的顺序显示。

除此之外,它们的工作方式是一样的。它们分批收集数据结构的元素,提交给不同的线程,并根据需要多次收集结果。在 hyper 的情况下,结果是有序的。这显然涉及到一些开销,这意味着默认情况下不应该使用数据并行。你需要使用大数据结构,所以我们将重新审视我们在第三章中处理的140MB营养素文件,为这个新版本重新安排一下代码。

# Grab Nutrients.csv from https://data.nal.usda.gov/dataset/usda-branded-food-products-database/resource/c929dc84-1516-4ac7-bbb8-c0c191ca8cec
my @nutrients = "/home/jmerelo/Documentos/Nutrients.csv".IO.lines;
my $time = now;
my @selected = @nutrients.grep: {
    my @data = $_.split('","');
    @data[2] eq "Protein" and @data[3] eq "LCCS" and @data[4] > 70 and
    @data[5] ~~ /^g/;
};
say now - $time;
say @selected.join: "\n";

和前一个案例一样,这个代码基本上是选择其推导模式使用 LCCS 的营养素(通过代码),并且每克(可能是毫克,但这并不重要)含有超过70个单位的蛋白质。如果你回到第三章,你会注意到几个变化。首先,我们分离了所有的输入和输出操作。文件在开始时被读取,结果在最后被打印。中间的循环是唯一有时间限制的事情;它在数据结构上进行 greps(过滤),产生一个新的结构。在我的台式电脑上,处理整个数据结构需要32秒左右,总共需要38秒左右。

由于这需要很长的时间,让我们尝试简单地通过添加 race 来使用并行化。这样默认情况下,会以64个批次划分,使用4个线程。我们将改变这一行。

my @selected = @nutrients.race.grep: {

循环的时间会减少到30秒。好吧,不是很大,到是根据情况,差不多是在同一个范围内。这证明了没有自动数据并行这回事,你真的要考虑到硬件的问题。由于我的电脑有8个核心,每个核心有两个物理线程,我们把它提升到11个,看看有没有增加(我们给其他程序留一点汁液)。

my @selected = @nutrients.race( :11degree ).grep: {

其实,并没有实现大的增加。不过,每批程序的大小64还是很关键的。所以我们要改变一下。

my @selected = @nutrients.race( :batch(@nutrients/4), :4degree ).grep: {

既然我们事先知道了数组的大小,那么我们就简单的除以我们要使用的线程数,然后使用这么多线程。这样就得到了,哒哒,8.71,所以我们已经把初始时间缩短了4倍左右。我们可以或许把它调大一点吗?让我们用总大小除以6的批处理量和6个线程,在代码中把4改成6。我们是不是有什么收获呢?没有,最多9秒左右。4似乎是一个甜蜜点,因为用3也能达到十秒。

到最后,这能给我们买到什么吗?如果你回过头来看看最初的版本,将从文件中读取操作、处理、输出穿插在一起,其时间与最快的数据并行版本的并行度为4,这是为什么呢?简单来说,从磁盘读取是最慢的操作,I/O和语言所利用的处理器之间有一定程度的并行性。输出操作也是如此:输出到控制台是串行操作,这就阻碍了并行性。

这意味着数据并行可以给你带来性能的提升,只要所有的操作都能以流水线的方式进行,把数据作为输入,产生更多的数据作为输出,没有副作用。所有的数据都需要在内存中,不需要进行任何输入输出操作,而且,你需要自己调整并行度和批次大小,以达到最大的速度提升。但最重要的是对每一个数据都要做大量的处理,这个问题我们以后再谈。

这个配方只做了几个对比,而且我们已经看到了,它几乎已经到了受益于 race/hyper 的边缘。无论如何,主要的一点是,这些命令有自动并行的潜力,但你需要考虑你的实际生产机器和你的问题,看看它们是否能帮助你。当你这样做的时候,微调并行度和批次会给你带来最大的性能提升。

最终的版本会是这样的。

my $degree = @*ARGS[0] // 4;
my $time = now;
my @selected = @nutrients
    .race( :batch(@nutrients/$degree),:degree($degree)  )
    .grep: { /...

你可以根据机器和操作系统来调整并行度,默认使用对我来说效果最好的并行度(对你来说可能不是这样)。

15.2. 食谱 15-2. 使用异步输入/输出

15.2.1. 问题

你需要创建能够立即响应输入的程序,而不会在输入或输出操作发生时挂起。

15.2.2. 解决办法

通过通道和线程将数据并行与异步输入或输出结合起来,或者仅仅依靠 Raku 固有的事件循环来快速高效地处理输入。

15.2.3. 它是如何工作的

在第11章中,我们设置了一个 web 钩子,通过使用多个线程工作在重负载上,例如建立一个网站,立即响应变化消息。我们可以更普遍地应用这种技术来使 I/O 绑定的操作变得更快。例如,网络操作是出了名的慢。请求一个外部 API 需要时间,在等待响应时阻塞程序的其他部分会花费很长时间。并行发出请求会使整个操作变得和最慢的那个一样慢,这时我们就可以收集所有的操作。

但是,你需要小心。试图并行化我们在第11章中使用的 Wikidata 查询示例将导致失败。在像这样的开放 API 的情况下,基本上同时发出两个请求是不礼貌的,并行请求将返回以下内容。

There’s an error in the API request: Error response 429: Rate limit exceeded.

然后,让我们尝试一个不同的例子。你可能还记得在第9章中使用 Edamam API。单个 API 请求大约需要三秒钟。为什么不把这些请求异步地汇集起来,这样我们就可以异步地发出尽可能多的请求,然后在结果到达时进行处理呢?

首先要考虑的是"尽可能多"的部分。大多数 API 都有一个限制,Edamam 也不例外。在免费层中,它允许,每分钟5个请求(如果你超过这个数量,你会收到一封邮件,如果你超过了,它将返回429状态)。所以,这个池子最多只能是五个,然后我们就得等一段时间。在等待请求上限补充的时候,任何可能的速度提升都会失去。无论如何,尽快获得早期的结果有其价值,这就是我们在这里做的事情。

use Cro::HTTP::Client;
use URI::Encode;
use Raku::Recipes::SQLator;

my $appID = %*ENV{'EDAMAM_APP_ID'};
my $api-key = %*ENV{'EDAMAM_API_KEY'};
my $api-req = "\&app_id=$appID\&app_key=$api-key";

my $dator = Raku::Recipes::SQLator.new;
my $cro = Cro::HTTP::Client.new(base-uri => "https://api.edamam.com/");

my @responses = do for $dator.get-ingredients.keys[^5] -> $ingredient {
    $cro.get("search?q=" ~ uri_encode(lc($ingredient)) ~ $api-req) ;
}

for await @responses -> $response {
    my %data = await $response.body;
    say "⇒Ingredient %data<q>\n\t→", %data<hits>.map(*<recipe><label>).join:
            "\n\t→";

}

脚本的大部分内容与之前的版本相同。归根结底,就是提出请求和获取响应。

主要的区别是在请求循环中。通过使用 do for 结构,返回循环中最后一条指令的结果。$cro.get 返回的是一个承诺;事实上,我们之前所做的是在这个承诺上等待获得结果。我们现在要做的,不是单独等待每个响应,而是创建一个响应承诺数组。

承诺需要被遵守,我们无论如何都需要等待响应来获得它们。但是,我们不是停止程序流来等待第一个承诺,然后再进行第二个承诺,而是用 await @responses 来等待所有五个承诺。在这种形式下,当每一个承诺都被满足后, await 将返回所有承诺的结果。这将和最慢的响应一样,五个请求都会在差不多的时间内发射。现在,处理响应的方式是一样的,除了我们循环处理包含在变量 $response 中的承诺结果。

这将会打印出类似这样的结果。

⇒Ingredient chicken breast →Sous Vide Chicken Breast Recipe →Roasted Chicken Breast →Chicken Breast with Salsa

就是说,在 Edamam 数据库中,第一行中的每一种食材都有一些配方,但重要的是,这些配方有多长?但重要的是,这需要多长时间?在我的系统中大约需要5秒钟,这比单次请求所需的时间勉强多一点,而这个程序实际上做得更多,因为它从数据库中获取信息,并打印更多行的输出。

这里的关键是 Cro::HTTP::Request 是异步工作的,很好地集成到异步程序中。你可以简单地通过包裹一个同步请求的承诺来获得同样的结果(比如从 LWP::Simple 获取)。然而,使用 Cro,代码更加清晰和简单。

异步编程允许你通过在一个快速的系列中汇集尽可能多的请求来利用带宽。这在面向用户的应用程序中是必不可少的,并将帮助你创建响应式程序。Raku 很好地集成了各种设施,提供了承诺的工业标准数据结构。

15.3. 食谱 15-3. 使用通道和线程使你的程序并发执行

15.3.1. 问题

单线程程序运行时间较长。

15.3.2. 解决方法

重塑你的问题,使之成为一个任务序列,这些任务可以交换消息,或者当它们收到一个带有有效载荷的消息时开始工作,该消息表明任务将是什么。

15.3.3. 它是如何工作的

并发编程是让不同的进程同时工作并协调获得结果的艺术。当然,问题在于协调的部分。一般来说,你需要将数据传递给进程和/或从进程中获取数据,而你这样做的方式基本上是问题的一部分。处理这个问题的方法有很多,其中最糟糕的一种是使用共享的一部分内存(比如说,通过全局变量),让每个人都在里面读,甚至更糟糕的是,在里面写。如果有一种方法可以锁定该共享内存,使一次只有一个进程在读/写,就会变得简单一些,但仍然存在让每个进程管理自己的锁并按时释放它们的问题。

另一种管理方式是通过一种叫做 Communicating Sequential Processes(CSP)的方法,由伟大的计算机科学家 C.A.R.Hoare 提出。CSP 的基本思想很简单。在使用通信总线交换信息的进程之间,不会有共享内存。这种通信总线通常被称为通道,正如我们在第11章中所看到的那样,它在 Raku(以及其他语言如 Go 和 Julia)中被实现为一种基本的数据结构。

Raku 中的进程将使用通道来接收它们需要工作的数据结构,以及在需要时与其他通道进行结果通信。具体来说,我们可以使用它以半自动的方式将数据分配给任务。

让我们在接下来的程序中实现这个功能,它取了一个大文件,其中包含了 MealMaster 格式的食谱,这个文件是由几个在 http://ffts.com/recipes.htm 上找到的食谱处理而成的。这些食谱中的一些是从80年代的 Usenet 上获取的。长话短说,这些菜谱似乎没有版权,我把它们合并成一个文件,转换成UTF8编码。每一个菜谱,我们都会这样处理:

  • 提取标题、成分和类别

  • 扫描食材,并将其链接到我们为其创建的页面上(就像我们在第11章中做过的那样)

  • 在 markdown 中转储结果,然后将其转换为 HTML

  • 使用模板创建一个网页(与我们在第10章中的做法相同)

我们将创建这个脚本来说明并发和通信到通道的主要原理。

警告:虽然这个程序在大多数时间都是并行运行的,但它可以得到很大的改进,我们将在下一个配方中看到。下一个配方将扩展并发程序中使用的方法论,并将解释如何调试它们以获得最大的好处。

use Inline::Perl5;
use MealMaster:from<Perl5>;
use Raku::Recipes::Recipe;
use Raku::Recipes::SQLator;
use URI::Encode;
use Template::Classic;
use cmark::Simple;

my $threads = @*ARGS[0] // 4;

my Channel $queue .= new;

my $parser = MealMaster.new();
my @recipes = $parser.parse("Chapter-15/allrecip.mmf");

my %ingredients = Raku::Recipes::SQLator.new.get-ingredients;
my @known = %ingredients.keys.map: *.lc;

my &generate-page = template :($title,$content),
        template-file( "templates/recipe-with-title.html" );

my atomicint $serial = 1;

my @promises = do for ^$threads {
    start react whenever $queue -> $recipe is copy {
        $recipe.ingredients = process-ingredients( $recipe );
        "/tmp/recipe-$serial.html".IO.spurt(generate-page($recipe.title,
                commonmark-to-html($recipe.gist)).eager.join);
        say "Writing /tmp/recipe-$serial.html";
        $serial⚛++;
    }
}

for @recipes -> $r {
    my $description = "Categories: " ~ $r.categories().join( " - ");
    my $title;
    if $r.title ~~ Str {
        $title = $r.title
    } else {
        $title = $r.title.decode
    }
    my $recipe = Raku::Recipes::Recipe.new(
        :$title,
        :$description,
        ingredients => $r.ingredients().map: {.product }
            );
    $queue.send: $recipe;
}

$queue.close;

await @promises;

sub template-file( $template-file-name ) {
    "resources/$template-file-name".IO.e
            ??"resources/$template-file-name".IO.slurp
            !!%?RESOURCES{$template-file-name}.slurp;
}

sub process-ingredients( $recipe ) {
    my @real-ingredients = $recipe.ingredients.grep: /^^\w+/;
    gather for @real-ingredients -> $i is copy {
        $i = $i ~~ Blob ?? $i.encode !! $i;
        if $i ~~ m:i/ <|w> $<ingredient> = (@known) <|w>/ {
            my $ing = ~$<ingredient>;
            my $subst = "[$ing](/ingredient/" ~ uri_encode($ing.lc) ~ ")";
            $i ~~ s:i!<|w> $ing <|w> ! $subst !;
        }
        take $i;
    }
}

这个程序有点长,但除了对通道的新颖使用外,大部分都是结合了其他配方中看到的技术。程序的关键部分之一是需要生成一个单一的序列号,这样我们就可以给每一个我们创建的 HTML 文件一个唯一的名字。只要我们不使用随机的文件名,无论如何我们都会需要这个。例如,如果我们使用标题,我们仍然需要检查它是否是唯一的,并且我们不会用新的文件滥竽充数。所以我们将使用一个独特的 Raku 功能,原子数。atomicint 数据类型提供了一系列在线程下安全的操作。我们只需要对它进行增量,但原子数不是普通的 ints,所以我们还可以这样对它进行原子增量:$serial⚛++。这虽然是共享内存,但是保证了在线程下的工作,所以我们就可以了。

我们还使用了一个通道,简单的说就是 $queue,因为它是一个任务队列,会接收哪些操作的数据。

程序包括两个主要部分:一部分从文件中提取食谱,并将它们转换为 Raku::Recipes::Recipe 数据结构。这个数据结构被发送,用 $queue.send:$recipe; 发送到队列中。队列可以处理 Raku 中的任何类型的数据结构,不需要对它们进行序列化/反序列化。它只是在工作。

执行任务的线程确实是在这个发射发生之前创建的,只是因为它们是反应式的代码,在需要的时候会被唤醒。而当 $queue 中接收到消息时,这种需要就会出现。我们首先在我们将工作的线程数量上建立一个循环。

my @promises = do for ^$threads { start ... }

这是用 do 语句前缀建立的,它收集了循环中每次迭代的结果。这就是其中最后一条语句的结果。在这种情况下,它是一个启动语句,它返回一个承诺;然后 @promises 将包含为每个线程创建的承诺,默认为四个(对于中端笔记本电脑和台式机来说是一个合理的数字)。

承诺实际上是反应式代码:react whenever $queue → $recipe 将在队列中收到东西时唤醒。每一个承诺都会以同样的方式行事,它们会在队列可用时立即尝试从队列中读取。它们是否在不同的线程上运行将取决于有多少个可用的线程,但从高级别的角度来看,并且在合适的情况下,每个承诺都将在不同的线程中实现。

在队列中,操作并不是非常繁重,但它包括一系列的依赖性传递。首先,我们过滤掉 "---- xxx ----- " 形式的"成分",这些成分实际上是配方中解析不好的部分。然后我们对这些剩余的成分进行检查,使用正则表达式来查找我们要链接的已知成分。这个 regex <|w> $<ingredient> = (@known) <|w> 不仅会尝试查找该菜谱中的配料,只返回那些由单词边界标记 <|w> 说明的整词的配料(这样我们只链接大米(rice),而不链接甘草(licorice)),而且还会存储配料的名称,我们将用它来创建链接,并将其存储在 $subst 变量中。

如果有任何这些成分,我们还需要替换它们,我们在这里这样做。

$i ~~ s:i!<|w> $ing <|w> ! $subst !

s 是替代操作符,工作在 $i 上,它已经被声明为"原样"副本,所以我们可以改变它的值。:i 副词表示它将不区分大小写,因为配方有各种大写,从全大写到首字母大写。这个 s 操作符可以使用任何类型的引号。我们选择用 !!! 而不是 ///,只是为了让 regex 更加明显。这样就会增加 markdown 式的链接。

这些成分在一个收集/获取循环中;$i 被"获取",根据我们找到的成分,进行收集、处理或不处理。

其余的循环则是将这些原料重新分配到配方中。这个 Raku::Recipes::Recipe 有将整个配方打印成 markdown 的机制,所以我们利用这一点,在接下来的一句话中,对配方做一系列的事情。

  • $recipe.gist 会将菜谱(包括新铸成的 markdown 链接)渲染成 markdown。

  • 结果通过我们在上一章创建的 cmark::Simple 模块的 commonmark-to-html 转换为 HTML。

  • 这要经过 generate-page,一个 Template::Simple 子程序,它将把生成的 HTML 片段和食谱的标题一起放到一个页面中。这个例程会生成一个惰性的数组,我们"急切地"将其呈现出来,然后连接成一个单一的字符串。

  • 该字符串通过 spurt 写入一个文件,在本例中,该文件将在 temporal 文件夹中。

序列是原子增量的,在它被唤醒之前,那段代码会进入睡眠状态,时间确实很短,可能在另一个线程中。那读取原始文件需要几分钟,生成4000多个文件,默认的线程数,一次运行需要十分钟左右。

这对于一个10MB的文件来说,确实是一个不错的标志。如果每一个的处理量大一点,而且不是那么I/O绑定的话,可能会更好。如果能从文件中混入惰性读取,那就更好了。增加更多的线程并不是真正的改进。为什么会这样,这是留给下一个配方的任务。

15.4. 食谱 15-4. 使用 Comma IDE 监控并发

15.4.1. 问题

调试并发程序是特别困难的。除了 grokking 程序做了什么,你还需要知道它是否同时发生,也就是说,并发是否真的发生了,发生了多少。

15.4.2. 解决方法

Raku 的 IDE,Comma,包括可视化并发事件和任务的设施。为了使用它,你必须在你的代码中添加一个特定的日志库,称为 Log::Timeline。Comma 检查这些日志,以便在 IDE 中可视化地显示事件和任务。

15.4.3. 它是如何工作的

在并行程序中,你必须考虑的最重要的事情之一是通信和计算之间的平衡。通信(往返于通道)与顺序版本会产生一定的开销。如果你在不同线程中进行的同步计算量能够克服这个开销,那就是赢了。如果没有,你就输了。所以,你需要把尽可能多的计算放在线程中,但同时也要验证,这些线程是否有效地以并行的方式运行,是否存在一个处理器空闲而其他处理器做所有工作的空隙。

执行这个任务的主要工具是 Comma,我们已经讨论过多次的 IDE。Comma 本身并不能监控并发执行,它需要在程序中做一些小的修改才能做到这一点。我们需要在 Log::Timeline 模块中添加混合的模块。这就是我们要用来监控前面程序的那个模块。

unit module Recipr::Log::Timeline;
use Log::Timeline;
class Processing does Log::Timeline::Task['Recipr', 'Backend', 'Processing'] is export { }

我们将把所有的 logger 类放在一个模块中,叫做 Recipr::Log::Timeline。有两种类型的角色可以混合在一起。Log::Timeline::Task 和 Log::Timeline::Event。目前,我们就用前者。它们用来记录它们的 last name 所表示的内容,要么是扩展任务,要么是单个事件。任务记录器的主要目的是环绕我们需要监控的代码,以便检查它的运行时间和程度。事件记录器是传统的记录器:可以将任何种类的数据记录成通用格式。两者都会在监控器中呈现。

Log::Timeline::Task 是一个参数化的角色。它的设计是为了让参数作为一种实例化。不同的要记录的任务会有不同的三个参数组合,这三个参数分别对应模块名称、任务类别和任务名称。任务将按照类别进行分组,然后按照名称进行分组。我们将使用同一个类来记录所有将做同一任务的线程。这个类本身是空的,因为我们不需要任何额外的处理。我们也把它从模块中导出,这样从外面就可以看到它。

在我们的程序中,我们不需要做太多的事情来添加这个。你只需要用 Recipr::Log::Timeline::Processing.log: → { }. 包住你想要监控的任务的代码。 就是说:

my @promises = do for ^$threads {
    start react whenever $queue -> $recipe is copy {
        Recipr::Log::Timeline::Processing.log: -> {
            #...same code here
        }
    }
}

通常情况下, 所有在一个线程中完成的工作都会被这样包装. 但是你可以用这种方式来封装任何你想要表示的代码;每个片段都会在代码监视器中得到一个表示。

我们将对代码进行一个额外的改变。我们使用的是一个原子变量,它保证了它不会被两个线程同时修改。但是,这并不能保证它不会被两个线程同时使用,因为它是在写完文件后才被修改的,而写一个文件可能需要几分之一秒的时间。在第一个文件之后,这种情况极不可能发生,但在最开始的时候,当线程同时启动的时候,可能会造成竞赛条件。所以最好使用一个局部变量,而不是全局变量,并把这个变量放到启动任务的消息中。我们把它改成这样。

$queue.send: ($i++, $recipe);

这需要对接收代码进行修改。

start react whenever $queue -> ($serial, $recipe) is copy {

这将把接收到的列表分解成序列号和配方对象。代码的其余部分将只取消原子增量操作符,保持文件名不变。

那我们就尝试着用监控来让它工作吧。我们需要点击 buggy 旁边的图标,看起来像…​…​真的,我不知道它看起来像什么。两条绿色的线和蓝色、黄色的点和线。猜测它有点像时间线。总之,我们需要这样运行。我们会看到像图15-1这样的东西。不用说,这并不是我们要找的东西。

至少,我们看到它是并行的。问题是,并行在经过了几秒钟的比较长的停顿之后,线程根本没有并行运行,就开始了。第二,只有两个线程。

图15-1. 程序的第一次迭代,用 Comma 监控。并行监控图标在红色方块"停止"图标的左边。

一旦它们开始运行,两个线程并行运行正常,如两个线程中交替创建的带子所示。

那么问题到底出在哪里呢?序列部分,也就是给通道供电的循环,是有问题的。整个程序并不是以并行的方式运行的,因为线程在启动几秒钟后才开始执行任务,这时调度器才会想办法让它们进来。这绝对不理想,这也解释了为什么使用更多的线程对整体性能没有帮助,正如我们之前看到的那样。

我们能做什么呢?我们可以让供给通道的代码也变得并发,通过启动一个运行它的线程。我们通过使用这段代码来添加。

await start for @recipes -> $r { # Rest of the loop is the same.

也就是说,start for 启动一个线程,而 await 只有在全部完成后才会进行。

监视器将显示这种截然不同的全景图,如图 15-2 所示。

图 15-2. 在 6 个不同的线程中运行 Recipr

我们换成 4 个线程。我们可以通过编辑配置中的命令行参数来改变 Comma 中的命令行。结果如图 15-3 所示。

图 15-3. 用 4 个线程运行

这里有几件事我们可以欣赏。首先,在每次运行中都会出现相当明显的差距(当你知道比例时,就是1/5秒)。第二,任务最初非常快的事实。如果我们将鼠标悬停在条纹上,我们会看到它们需要0.06秒左右。后来,本应花费差不多时间的事情,却花费了长达半秒的时间。结果和最初的版本一样,四个线程大约需要十分钟。增加更多的线程并不是真正的提升,至少在我的笔记本上是这样。

问题还是在于通信和计算之间的平衡。有一个单线程在预处理配方和创建对象。这个线程一直在运行。我们要监控它,检查它是否在其他线程处理时有效地运行。我们在程序的总线程数中也增加了一个线程,所以剩下的空间不大。

我们再添加一个任务监视器。

class Emitting does Log::Timeline::Task['Recipr', 'Backend', 'Emitting']
                 is export { }

然后我们把 loop 迭代包在里面:

await start for @recipes -> $r {
    Recipr::Log::Timeline::Emitting.log: -> {
        # same code as before.
    }
}

这将导致图 15-4。

图 15-4. 监视发射线程

那额外的监测让我们看到,排放需要的时间非常少,有很多排放不能实时处理。还有一个小的差距,问题是处理每一个文件都需要几十分之一秒的时间。我们可以试着深入研究一下,看看这个时间在哪里。我们再加一个显示器。由于我们要在同一组中有两个监视器,我们将尝试把它们分成两组,这样重命名:

unit module Recipr::Log::Timeline;
use Log::Timeline;

class Processing does Log::Timeline::Task['Recipr', 'Processing', 'Processing']
                is export { }
class Emitting does Log::Timeline::Task['Recipr', 'Backend', 'Emitting']
                is export { }
class Saving does Log::Timeline::Task['Recipr', 'Processing', 'Saving']
                is export { }

所以凡是进入处理线程的东西都会以 Processing 作为名称,而 Emitting 则是后台类的单任务记录器。结果如图15-5所示。

图15-5. 任务内的监控

"保存"的小条纹表明,它并没有花很长时间:是任务的其他部分花了这么长时间。顺便说一下,我们还可以看到,发射器发送消息的速度比处理消息的速度快得多;无论如何,这是很自然的,但处理每条消息似乎还是要花很长时间。

我们现在需要做的是对这些较慢的部分进行剖析。这不属于并行处理的范畴,但本章是关于性能的。让我们试着把这件事做到极致。我不打算用另一张显示器的截图来烦扰你,显示循环的不同部分的表现,但在代码片段而不是整个循环上工作会发现几个问题。那个在成分上的循环有效地耗费了很长的时间,在某些情况下,是单个的迭代耗费了几百分之一秒的时间。我们可以通过从循环中取出 URL 的创建来加快一点速度。你失去了一些东西(它们将以与最初相同的大写字母出现),但你节省了一些速度。

我们需要让它更快一些。也许是出了名的慢的 regexes 的缘故。让我们用 subst 来代替。

my @promises = do for ^$threads {
    start react whenever $queue -> $recipe is copy {
        Recipr::Log::Timeline::Processing.log: -> {
            my @real-ingredients = $recipe.ingredients.grep( /^^\w+/)
            .map( {  $_ ~~ Blob ?? $_.decode !! $_ } );
            $recipe.ingredients = @real-ingredients;
            my $recipe-md = $recipe.gist;
            for @known -> $k {
                $recipe-md .= subst( /:i <|w> $k <|w>/, %urls-for-known{$k} )
            }
            Recipr::Log::Timeline::Saving.log: -> {
                "/tmp/recipe-$serial.html".IO.spurt(generate-page($recipe.title,
                        commonmark-to-html($recipe-md)).eager.join);
                say "Writing /tmp/recipe-$serial.html";
            }
            $serial⚛++;
        }
    }
}

我们已经取消了对成分的循环,用一个过滤成分的 map 来代替。该映射会对原始文件中发现的 blobs 进行解码(主要来自于使用一些非英文字母,如 é)。事实上,我们可以取消使用临时变量 @real-ingredients

我们不需要对每个成分进行替换,另外,由于它只替换了第一个找到的单词,所以可能容易出错,而是循环所有产品并进行替换。我们在 regex 里面使用副词 :i 来使搜索不区分大小写。我们现在在渲染中使用的不是使用 gist 转换配方,而是这个新的标记文档,其中包含所有的替换。

这是否带来了真正的改进?不出所料(或不出所料),确实如此。它消除了一个循环,并引入了另一个循环,其结果可能会更快,因为它将在所有的实例上替换一个产品名称,而且会快得多。最后一个版本的程序需要5分钟左右。然而,主要的提升可能来自于这样一个事实:我们只检查一次正则表达式,而不是在结果为正匹配的情况下检查两次。

无论如何,这显示了应该使用的方法论来调试和获得并发程序的最大性能。首先,你必须找出是什么拖慢了它的速度。你的程序可能已经够快了,也可能大部分时间都花在一个很小的部分。那个慢的部分也需要用某种方式来并行。然后,你必须最大限度地提高并发性:把程序中尽可能多的串行部分放在自己的独立线程中,通过检查性能来尽量消除瓶颈,最终优化每线程性能,使每个部分都尽可能快。始终保持线程数量的灵活性,并测试运行几个数字,直到你得到适合你的特定平台的最佳组合。这将取决于你拥有的物理线程数量、正在运行的其他程序和服务以及输入/输出性能。最大化线程数量可能不是最好的主意;使用能给你带来最大提升的数字。

一定要考虑到下一个配方中讨论的最佳实践。

15.5. 食谱 15-5. 创建强大的并发程序

15.5.1. 问题

你需要创建一个尽可能快的程序,给定你的规格、输入和输出。

15.5.2. 解决办法

嗯,这里没有一个解决方案。并发性能取决于很多东西,而且没有什么灵丹妙药。在计算和通信之间取得平衡,找到正确的数据和控制结构,是一个系统测试和深入了解你的程序所运行的硬件和软件的问题。由于缺乏一个放之四海而皆准的方法,你所拥有的只是一系列的最佳实践,你可以使用 Raku 工具(如 Comma 并行监视器和日志)来逐步改进你的程序。

15.5.3. 它是如何工作的

并行编程是一门艺术,它能让许多CPU一起用大量的通信做很多工作,而一开始可能只需要一个顺序程序。让我们回到之前的程序,尝试一个顺序版本。这里展示了整个程序,虽然大部分代码是重复使用的。

use Inline::Perl5;

use MealMaster:from<Perl5>;

use Raku::Recipes::Recipe;
use Raku::Recipes::SQLator;

use Template::Classic;
use cmark::Simple;

my $threads = @*ARGS[0] // 3;

my $parser = MealMaster.new();
my @recipes = $parser.parse("Chapter-15/allrecip.mmf");

my %ingredients = Raku::Recipes::SQLator.new.get-ingredients;
my @known = %ingredients.keys.map: *.lc;

my &generate-page = template :($title,$content),
        template-file( "templates/recipe-with-title.html" );

my %urls-for-known = | @known.map: { $_ => "[$_](/ingredient/$_)"};

@recipes.kv.rotor(2).map( { process-recipe(@_[0], @_[1]) } );

# Subs
sub template-file( $template-file-name ) {
    "resources/$template-file-name".IO.e
            ??"resources/$template-file-name".IO.slurp
            !!%?RESOURCES{$template-file-name}.slurp;
}

sub process-recipe( $serial, $recipe ) {
    my $description = "Categories: " ~ $recipe.categories().join(" - ");
    my $title;
    if $recipe.title ~~ Str {
        $title = $recipe.title
    } else {
        $title = $recipe.title.decode
    }
    my $rrecipe = Raku::Recipes::Recipe.new(
            :$title,
            :$description,
            ingredients => $recipe.ingredients().map: { .product }
            );
    my @real-ingredients = $rrecipe.ingredients.grep(/^^\w+/)
            .map({ $_ ~~ Blob ?? $_.decode !! $_ });
    $rrecipe.ingredients = @real-ingredients;
    my $recipe-md = $rrecipe.gist;
    for @known -> $k {
        $recipe-md .= subst(/:i <|w> $k <|w>/, %urls-for-known{$k})
    }
    "/tmp/recipe-$serial.html".IO.spurt(generate-page($rrecipe.title,
            commonmark-to-html($recipe-md)).eager.join);
    say "Writing /tmp/recipe-$serial.html";

}

这个程序做了两个比较小的改动。

  • 每个菜谱都是在一个函数中处理的,这个函数接收封装的 Perl 5 MealMaster::Recipe 对象,进行所有的过滤,并最终保存到文件中。

  • 它使用一个单一的映射,而不是线程和通道,来处理大约10000个菜谱的数组。这一行 @recipes.kv.rotor(2).map( { process-recipe(@[0], @[1]) } ); 生成一个索引元素序列,通过 .rotor 将其分块成 (index,recipe) 数组。我们将像之前那样使用索引来创建文件名。

这里没有真正的并行性,只有高效的数据流。一般来说,将代码布局好,这样你就可以将其作为一个 map 来应用,这比使用循环的类似方法要快。我们现在有一个单一的 map,而不是两个循环。在同一台笔记本电脑上运行这个需要大约三分钟,而运行并行版本需要近五分钟。是什么原因呢?

嗯,通信就是这样。最新版本的程序效率比较高,但它无法克服每一个配方都要发送一条消息,总共约有10000条消息。而且每条消息都有自己的开销。虽然我们可以同时处理消息,但所增加的开销比将整个程序并行化所获得的还要多。

这并不意味着之前的程序就无效了。它有它的用处:当你不想让单个处理器过载,或者当你有效地想通过这种方式利用低负载处理器时。每一种并行化技术都有自己的定位。此外,通过改进单线程代码,我们也为这个,更高性能的版本铺平了道路。

我们还能做得更好吗?不,我们做不到。我们可以使用 hyper,或者 race,但结果会在同一个范围内。这里的问题是,每个配方所做的计算量并不多。这里的第二个启示是,为了达到良好的计算和通信平衡,你需要有相当数量的单项计算开始。大的数据结构和少量的计算量并不是一个好的组合,尽管你可能会想出一个并行处理的方案(例如,用手工批量计算数据结构的块)。在一个足够小的数据结构上进行相当数量的计算,更像是那种可以进行自动(通过 hyper/race)或手动(通过 channel/start)计算的问题。

所以如果问题本身不适合求解,我们可以随时改变问题。与其创建一个单一的文件,然后一次性以非常小的块数处理它,不如让我们将文件分成许多更小的文件,并尝试对这些文件进行并行处理。这个 Raku 脚本将把我们原来使用的大文件分割成包含400个食谱的小块(除了最后一个)。

use Inline::Perl5;
use MealMaster:from<Perl5>;

my $input-file-name = @*ARGS[0] // "allrecip.mmf";

my $all-recipes = $input-file-name.IO.slurp;

my @recipes = $all-recipes.split( /^^ ["-----" | "MMMMM"] \s+ /);
my $index = 1;

for @recipes.rotor(400) -> @chunk {
    my $all-recipes = '';
    for @chunk -> $r {
        my $this-mm;
        if $r ~~ /^"-"/ {
            $this-mm = "$r\n-----\n";
        } else {
            $this-mm = "$r\nMMMMM\n";
        }

        "/tmp/temp.mmf".IO.spurt($this-mm);
        if MealMaster.parse("/tmp/temp.mmf") {
            $all-recipes = "$all-recipes$this-mm\n";
        } else {
            say $all-recipes;
            die $this-mm;
        }
    }
    "/tmp/all-recipes-$index.mmf".IO.spurt( $all-recipes );
    $index++;
}

它还在用 Raku 处理格式方面取得了小小的进展。事实上,它唯一能做的就是将整个文件以菜谱的形式分割开来,进行检查,然后再将它们连接在一起,形成一个400个菜谱的文件。

为了做到这一点,我们再回头看看格式。有点诡异的是,食谱包含在一个格式内,它的结尾要么是五个破折号,要么是五个 M,在第一种情况下,它会有一堆破折号,后面是一个语句,在第二种情况下,有五个

M 之后是破折号和相同的语句。因为我们要么通过这个正则表达式 /^^ ["-----" | "MMMM"] \s+ / 来分割,它匹配行首的五个破折号或五个 M 和一个或多个空格,这个精确的信息就会丢失。然而,M 和破折号是需要匹配的,所以我们把它们放回去,写到一个文件中(因为 MealMaster 模块只从文件中读取,所以这是个很好的候选拉请求),以便通过解析来检查,如果没问题(原来的要没问题,但拆分可能不行),就把它加到一个字符串中。最后,整个字符串被写入一个文件,索引会自增。我们最终有21个文件,其中20个文件包含400个菜谱,其中一个文件包含任何剩下的菜谱。所以现在我们有21个文件。我们可以并行处理它们吗?

嗯,恰好我们不能。因为底层代码是用 Perl 写的,所以它不是重入式的。任何从线程中调用的代码都必须是重入式的,否则你可能会发现竞赛条件,将来自两个不同线程的代码混合在一起。试图在一个线程中通过 MealMaster 处理一个文件会导致失败。所以我们必须使用 Raku 来处理它们。我们没有语法来解释这个问题(目前)。让我们尝试尽可能地处理它。这是一个例子:

      Title: Vegetable Casserole Supreme
 Categories: Casseroles, Vegetables
      Yield: 6 servings

      4 ea POTATOES, SLICED                    1 lb GROUND BEEF
      3 ea CARROTS, SLICED DIAGONALLY          1 cn MUSHROOM SOUP      *
      1 cn PEAS

  *       USE CREAM OF MUSHROOM OR TRY CHICKEN SOUP OR ?
  *----------------------------------------------------------------------*
  LAYER ALL VEGETABLES AND BEEF IN A 2 QUART CASSEROLE DISH. POUR THE SOUP
  (DILUTED WITH 1 CAN OF WATER) OVER THE TOP OF THE DISH.
  BAKE AT 350 DEG F. UNTIL ALL LAYERS ARE DONE.

有三个部分,用双回车分隔。对它们进行处理已经超出了本书的范围,所以我们只需要将标题转换为标题,在类别和产量中贴上二级标题,然后将整个内容转换为 HTML。这个程序就可以做到。

use Inline::Perl5;

use MealMaster:from<Perl5>;

use Raku::Recipes::Recipe;
use Raku::Recipes::SQLator;
use Recipr::Log::Timeline;

use URI::Encode;
use Template::Classic;
use cmark::Simple;

my $threads = @*ARGS[0] // 3;
my Channel $queue .= new;

my $parser = MealMaster.new();
my @recipes = $parser.parse("Chapter-15/allrecip.mmf");

my %ingredients = Raku::Recipes::SQLator.new.get-ingredients;
my @known = %ingredients.keys.map: *.lc;

my &generate-page = template :($title,$content),
        template-file( "templates/recipe-with-title.html" );

my %urls-for-known = | @known.map: { $_ => "[$_](/ingredient/$_)"};

my @promises = do for ^$threads {
    start react whenever $queue -> $recipe-file  {
        Recipr::Log::Timeline::Processing.log: -> {
            $recipe-file.path ~~ /$<serial> = (\d+)/;
            my $serial = +$<serial>;
            my @all-lines = $recipe-file.lines;
            my $recipes = @all-lines
                    .grep({ !$_.starts-with("MMMMM") })
                    .grep({ !$_.starts-with("-----") })
                    .join("\n");
            $recipes ~~ s:g/\h+ "Title:" /# /;
            for <Categories Yield> -> $c {
                $recipes ~~ s:g/\h+$c/## $c/;
            }
            process-recipes($serial, $recipes);
        }
    }
}

await start for dir("/tmp", test => /"all-recipes"/ ) -> $r-file {
    $queue.send: $r-file;
}

$queue.close;
await @promises;

# Subs
sub template-file( $template-file-name ) {
    "resources/$template-file-name".IO.e
            ??"resources/$template-file-name".IO.slurp
            !!%?RESOURCES{$template-file-name}.slurp;
}
sub process-recipes( $serial, $recipe ) {
    "/tmp/recipes-$serial.html".IO.spurt(generate-page("Recipes $serial",
            commonmark-to-html($recipe)).eager.join);
    say "Writing /tmp/recipes-$serial.html";

}

本质上,我们正在做的是已经表明的事情。检测以标记符开始的行并消除它们,为标题和类别添加一些标记,并将其转换为HTML。我们使用 start-with 作为搜索术语,我们使用正则表达式,包括 \h,水平空间,因此回车不会被转换,因此结果是正确的标记。

如果我们用一个线程来做这件事,大约需要50秒。如果使用两个线程,则会下降到27秒。更多的线程会给你带来边际收益。主要的问题是,处理每个文件都需要几秒钟的时间;除非一个线程把另一个线程的大量负载减掉,否则它可能会被卡住处理一些东西。更多的线程会增加从第一个完成的线程到最后一个线程之间的时间变化。我们用一个例子来说明。我们有四个任务,分别需要3秒、2秒、1秒和7秒。按顺序,我们需要15秒。两个线程至少需要7秒,但前提是我们很幸运,我们先运行那个较长的任务。额外的线程将永远需要7秒。虽然这种情况下数量不一样,但原理是一样的。对于那些耗时较长的任务,简单地在线程之间进行无优先级的划分,其收益会越来越少。见图15-6。

到最后,重要的是原始性能,最后这个版本(诚然,它做的事情略有不同)只需要初始版本的一小部分时间。但它显示了在设计一个强大的并发应用程序时的许多困境。即使你设法有一个非常好的通信/计算比例,你仍然需要在不同任务之间有一个很好的工作量平衡,你需要保持每个任务的总体工作量很低,因为它的最大值将是整个程序需要等待所有任务结束的时间。而在这一切之上,考虑到你的系统中有多少物理线程是很重要的。如果物理线程较少,每个线程在等待处理器可用时都会变慢。你需要用越来越多的线程来运行你的程序,当没有明显的收益时就停止。一般来说,操作系统会给自己保留两到三个线程。在所示的情况下(也是在我的笔记本上),从顺序线程到两个线程会有很大的提升。其他任何东西都将相当于最小的收益。

监控工具—​比如一个简单的命令行定时和更复杂的 Comma 监控器以及它的日志库—​将帮助你设计出你所需要的强大的并发程序。

16. 创建微型语言

Grammar 是 Raku 的一个独特的功能;它们是处理带有结构的文本的一种强大的方式,而且它们可以用来创建迷你语言。你可以将这些迷你语言用于许多不同的目的,从配置到实际的编程语言。就像正则表达式一样,迷你语言是那种你会希望你以前就知道的东西,因为它们会因为其表达能力而省去很多麻烦。接下来我们将深入研究它们。

16.1. 食谱 16-1. 使用能展现其可能性的迷你语言

16.1.1. 问题

许多问题域都会受益于拥有一种能更好地描述它们的迷你语言,而不是使用 JSON 等带有强制 Grammar 的语言。一般来说,迷你语言是表达事物的自然方式。它们更接近于自然(文本)描述,其结构使它们更容易理解。解释迷你语言给我们提供了它的结构,这种结构可以转换为其他(迷你)语言或以不同的方式处理。

16.1.2. 解决办法

使用 Grammar 来解析一种(迷你)语言,包括扮演与其他语言共同的角色。

16.1.3. 它是如何工作的

注意:在深入研究 Grammar 之前,你可能需要查看由同一作者和出版商撰写的《Perl 6 快速语法参考》的第 15 章,以获得有关 Grammar 功能和可能性的更广泛参考。

语言的结构是分层的,从最底层的字形(如单词或数字)到表达式,最后到"程序"或"文档"。解析包括创建这些规则,从上到下,检查文档是否正确,并在其中识别我们需要识别的部分。每一个部分都会得到一个标记,不是作为赞赏,而仅仅是作为理解文档说的是什么以及我们需要从文档中得到什么的一种方式。并不是每个符号都需要这样的标记,一个例子就是大多数正常语言中的小括号。没有 "小括号"的标记,但我们理解,无论它们里面的什么标记都会被归为一类,而外面的标记则优先考虑。

我们在第 8 章和第 11 章处理了 Grammar 。在这一章中,我们将尝试更进一步,从底层设计一个 Grammar ,用特定的结构来解释用 Markdown 写的配方。这个配方应该是这样的。

# Tuna risotto

这道源自意大利的浓郁奶油菜的版本相对简单。

## Ingredients (for 4 persons)
* 320g tuna (canned)
* 250g rice
* ½ onion
* 250g cheese (whatever is in your fridge)
* 2 tablespoons olive oil
* 4 cloves garlic
* 1 spoon butter (or margarine).
* ⅓ liter wine (or beer).

## Preparation (60m)

1. Slightly fry tuna with its own oil it until it browns a bit. You can do
   this while you start doing the rest. Save a bit of oil for the rice.
1. Stir-fry garlic until golden-colored, chopped if you so like, retire if
   you don't like the color.
2. Add finely-chopped onion, and stir-fly until transparent.
3. Add rice and stir-fry until grains become transparent in the tips.
4. Add wine or beer and stir until it's absorbed by grains.
5. Repeat several times: add fish broth, stir, until water is evaporated,
   until rice is soft but a bit chewy.
6. Add tuna, butter, grated cheese, and turn heating off, removing until
   creamy.
7. Let it rest for 5 minutes before serving.

这个菜谱有三个部分,加上一个标题,标题使用了 Markdown 标题的通用惯例:描述,这是自由格式的文本,一组配料(标题中表示将有多少人食用),以及制作说明。

食材需要具体解析:它们包含一个量、一个度量衡、食材本身和可能的选项。例如,从 320 克金枪鱼(罐头)中,我们需要了解它正好是 320 克金枪鱼,我们可以用金枪鱼罐头来做。我们之前已经对食材进行了部分加工,但止步于做全(包括选项)。我们在这里做一下。

最后,准备工作包括括号里的时间,它包含了一个有序的指令列表。它们需要按照顺序进行处理,但前面的数字要遵循 Markdown 惯例。它们需要的是一个数字,而不是均匀的顺序,这就是为什么前面的两个项目有数字一。我们可能要说明这些事情可以同时进行。第一个词很重要,因为它通常表示一种活动。我们可能也想抓住这一点,这样我们就可以突出它。

我们对 Grammar 并不陌生。我们在整本书中一直在使用它们,主要是为了解析食谱的部分内容。我们还使用了外部库来处理迷你语言,比如我们在第 14 章和第 15 章中使用的 MealMaster 应用程序中的语言。然而,我们处理的并不是整个"文档"或"程序",只是帮助我们理解一些(也许是复杂的)字符串的小 Grammar 。此外,到目前为止,我们还没有想出一种处理整个配方的方法,用 Markdown 的一个子集或任何其他方式来描述。既然它是基于 Markdown 的,而且它是用来处理菜谱的,我们就叫它 RecipeMark 吧。

让我们尝试创建一个处理这种格式的 Grammar。请记住,一种语言不需要是一套结构化的操作,按顺序执行。它不需要是一种图灵完备的语言,我们可以用它创建复杂的程序。迷你语言可以是一个小的操作集,也可以是一个简单或复杂的配置,或者仅仅是一种结构化文档的方式,以便能够自动处理。我们的菜谱语言将是其中的一种。

让我们确定一下我们可以重用之前的东西。这个 Grammar ole 在之前已经定义好了。

use Raku::Recipes;

unit role Raku::Recipes::Grammar::Measures;

token quantity { <:N>+ }
token unit     { @unit-types }

它定义了成分、数量和单位的最小部分。它把单位限制在我们感兴趣的选项上,限制在我们的"语言"上。我们需要这样做,因为如果我们不知道单位,我们就不能计算任何关于它的东西—​它的重量、卡路里或其他东西。一种语言通常会限制在某些位置使用的"关键"词,我们在这里也会这样做(或者会继续这样做,因为这是重复使用的)。下面是 Raku::Recipes 中的定义。

our @unit-types is export = <g tbsp clove tbsps cloves liters liter
    ltablespoons Unit tablespoon spoons cloves clove spoon cup cups>;

语言不会理解任何其他单位(如"一撮"或"尝尝")。通过限制这些词,我们也固定了单位:要么我们有这些词之一,要么我们将简单地使用单位。如果我们在数字后面找到一个不是其中之一的词,我们就知道我们直接指的是一个产品。

再往上走一步,将是诠释整行描述成分的 grammarole。

use Raku::Recipes::CSVDator;
use Raku::Recipes::Grammar::Measures;

my @products;
BEGIN {
    @products = Raku::Recipes::CSVDator.new.products.map:
        {$_.ends-with("s")?? $_ !! ( $_, $_ ~ "s").Slip }
}

unit role Raku::Recipes::Grammarole::Measured-Ingredients does
    Raku::Recipes::Grammar::Measures;
token ingredient-description {
    <measured-ingredient> \h* <options>?
}

token measured-ingredient {
    [ <quantity> \h* <unit> \h+ <product> || <quantity> \h+ <product>]
}

token options {
    '(' ~ ')' $<content> = .+?
}

token product {:i @products }

先说说最后这一行。我们将能够创建只包括这些产品的成分。这些产品是我们存储在其他地方的产品。事实上,我们使用其中一个 token 从数据源中提取这些信息。这个 token 是必不可少的,它将我们数据存储中的任何成分定义为语言中的一个关键词。同时,它对于锚定成分的其他成分也是必不可少的。通过对照成分描述检查一个词(或者,在某些情况下,两个词),我们能够理解,不管左边的是什么,都是一种测量,而右边的可能是附加信息。如果没有这种有限的词语选择,就很难理解这个特殊的指令。一门语言,除此之外,就是 Grammar 和语义。数据存储为这种 Grammar 提供了语义,也使得扩展语言非常容易,只需在数据存储中增加更多的行即可。

我们不会对大写字母进行编码。我们创建产品列表,使其考虑到复数(虽然这会产生一些奇怪的错误,如 "Kales" 和 "Tunas",我们无法解释美丽的英语语言的所有怪癖)。复数词将和单数词一样成为 RecipeMark 的一部分。同样,这是限制性的,因为我们将能够用我们所知道的成分来编写食谱。但这也符合语言的设计方式:它们有一个 Grammar 和语义。我们将使用的限制性计量单位的语义是我们所知道的,并且可以转化为权重。产品的语义将是我们赋予它们的语义—​它们可以在什么样的盘子里,以及我们拥有的关于它们的量化数据。

因为这是一个角色,所以我们没有指定的 top 规则:只是我们觉得最顶级的那个。所以我们现在从上往下看。我们有的是一个 ingredient-description,它将是一个 ingredient 加上一个可选的部分,我们称之为 options。这个可选部分会在括号里。

'(' ~ ')' $<content> = .+?

tilde 是嵌套内容的标记。这条规则不仅会检查小括号内是否有一些东西(定义在后面),如果有的话,它还会以一种优雅的方式失败,指出最后的小括号不见了。我们需要捕获小括号内的所有东西,所以我们用 .+? 对任何可以去那里的东西进行非贪婪的描述。它一般会是字母和空格,但也可以有更多—​一个表示某事不错的表情符号。我们还需要把它分配给一个特定的标记,$<content>,以表明那是我们感兴趣的部分,而不是括号。我们已经在其他地方看到了测量成分的部分,所以这只是一个小小的提醒—​两个选项,有或没有单位。在前面的 Grammar 中,数量被定义为任何数字(原则上,13⁄4 都是有效的)。单位将用水平空格分隔或不分隔(这意味着我们将接受像 1cup 这样的东西,但我们可以接受它,或者在以后完善 Grammar,使它只接受"粘性"单位),然后是产品。

大家知道,我们可以直接使用 grammaroles,只要指定我们要使用的规则就可以了。让我们来转一转,检查一下它是否符合我们想要的一切。

use Raku::Recipes::Grammarole::Measured-Ingredients;

grammar Tester does Raku::Recipes::Grammarole::Measured-Ingredients {}
say Tester.subparse("1⁄2 onion",
        rule => "ingredient-description")<measured-ingredient>;
say Tester.subparse("1⁄3 liter wine (or beer)", rule => "ingredient-description");

Grammaroles 并不是完全的 grammar ,所以我们需要把它们编织在一个测试 grammar 中来检查。此外,这个 grammar 不包括 top,所以我们需要指定 grammar 的切入点并使用 subparse。很明显,我们选择了 "TOP" rule(在这种情况下,它是一个 token;Grammar 的每个组件默认都被称为 rule)。这和预期的一样,打印出以下结果。

⌈½ onion⌋
 quantity => ⌈½⌋
 product => ⌈onion⌋
⌈⅓ liter wine (or beer)⌋
 measured-ingredient => ⌈⅓ liter wine⌋
  quantity => ⌈⅓⌋
  unit => ⌈liter⌋
  product => ⌈wine⌋
 options => ⌈(or beer)⌋
  content => ⌈or beer⌋

在第一种情况下,没有单位或产品,在第二种情况下,它正确地解析了数量和可选部分的内容。 这里有一个小问题:如果我们使用"鸡蛋"作为产品,那将有效地作为一个标记返回。那么我们就必须检查是否有这个词,或者其等价的单数词。我们可以将 token 改为。

token measured-ingredient {
    [ <quantity> \h* <unit> \h+ <product> || <quantity> \h+ <product>] s?
}

末尾有一个可选的 "s":

say Tester.subparse("3 eggs (free run)",
        rule => "ingredient-description");

它将正确地返回:

⌈3 eggs (free run)⌋
 measured-ingredient => ⌈3 eggs⌋
  quantity => ⌈3⌋
  product => ⌈egg⌋
 options => ⌈(free run)⌋
  content => ⌈free run⌋

我们可以直接在我们的数据存储中查找产品。不幸的是,"lentilss" 也会通过测试,尽管我们会从中提取正确的产品。好吧,你赚了一些,你失去了一些,但是当有两个选项时,有一些假阳性(在你的问题域中可能在 grammar 上不正确的词,但你可以正确地解释)总比假阴性(语义上不正确的词,你必须进一步处理才能理解实际发生的事情)要好。

与 "sticky" 单位的情况一样,需要对英语语言有更深的理解。在这种情况下,关于单词是单数还是复数的元数据("鸡蛋"与"小扁豆"),以及每个单词的差异化规则。这是可行的,但留给读者去练习。

16.1.4. 完成 RecipeMark

一旦(可能是)最复杂的部分解决了,我们就可以向上建立其余的 grammar 。Grammar 从来都不是简单的,在编写这个 grammar 的过程中,依靠 grammar 调试工具,比如我们在第 8 章中看到的那些工具,是个不错的建议。理想的情况是,我们会把 grammar 中任何可以作为角色重用的部分因素剔除,然后建立起我们的方式,直到完整的 grammar 。我们可能要对什么是被认为是"正确的 "RecipeMark",什么是不正确的 RecipeMark 添加一些限制。

如果我们将 grammar 与通常的语言编译或解释模块进行比较,它们会将 token 化(在适当命名的 token 中),然后解析整个结构,使 token 按照正确的顺序放置,并由正确的分隔符分开。 grammar 中的每个 token 实际上都会同时进行这两个阶段的工作,所以这两个阶段之间没有明显的区别。然而,我们通常会从最简单的元素到最复杂的元素来设计(和测试) grammar ,这将理解整个文档的结构。

下面是 RecipeMark 的 grammar 。

use Raku::Recipes::Grammarole::Measured-Ingredients;

unit grammar Raku::Recipes::Grammar::RecipeMark does Raku::Recipes::Grammarole::Measured-Ingredients;

use Raku::Recipes::Grammarole::Measured-Ingredients;

token TOP {
    "#" \h+ <title>
    <.separation>
    <description>
    <.separation>
    "##" \h+ Ingredients \h+ "(for" \h+ $<persons> = \d+ \h+ person s? ")"
    <.separation>
    <ingredient-list>
    <.separation>
    "##" \h+ Preparation \h+ "(" $<time> = \d+ "m)"
    <.separation>
    <instruction-list>
}

token separation { \v ** 2 }
token title { <words>+ % \h }
token description { [<sentence> | <sentence>+ % \s+] }
token ingredient-list { <itemized-ingredient>+ % \v }
token itemized-ingredient { ["*"|"-"] \h+ <ingredient-description>}
token instruction-list { <numbered-instruction>+  % \v }
token numbered-instruction { <numbering> \h+ <instruction> }
token instruction { <action-verb> \h <sentence>}
token numbering { \d+ )> "." }
token action-verb { <.words>  }
token sentence { <.words>+ % [[","|";"|":"]? \s+] "."}
token words { <[\w \- \']>+ }

它的结构是这样的,最简单的东西在最下面,比较复杂的东西,包括整个文档,都在最上面。我们简单的看一下头部,包括我们讨论过的 grammarole,然后到最下面,最简单的东西,单词。

什么是词,什么不是词,似乎直观的很清楚。但是,我们必须把单词和标点符号分开,可能还要考虑到破折号分离的单词或者包括撇号的单词。这就是为什么类似单词的字符(包括 _ 和数字)与这两个符号一起被扔在一起,以创建我们要使用的单词。比如说,这就不包括 RecipeMark 的表情符号。我们都想把茄子表情符号作为一种成分,但这要等到描述下一代 RecipeMark 的 RFC 123(征求意见)。

单词组成句子,中间有标点符号(分号、冒号和逗号),并以句号结束。这也排除了一些标点符号(例如,em-dash),但同样,这是由选择的,并不会真正影响食谱的描述。我们也在设计一种迷你语言,并且有一定的自由度来施加一些限制(例如,不使用中间带点的缩写词)。此外,我们在句子中使用 <.words>(以及 <action-verb>)。我们并不是对每一个单独的词感兴趣,而是对它们将被用来构建更复杂的结构感兴趣。因此,这些文档的碎片将被匹配,一旦它们被包含在更高级别的结构中,就会被扔掉。

一个指令将是一个动作动词和一个句子。实际上,一个动作动词是任何第一个词,但我们希望鼓励人们使用动作动词(如搅拌、煎炸和煮沸)作为指令的第一个词。我们甚至可以对它们进行限制,我们现在没有这样做,但将来我们可以通过简单地修改规则来实现。而且一个指令只会包括一句话;理由是如果有两句话,那么,它们就是两个指令,所以再创建一个指令即可。所有的指令都需要在编号后加上句号。将捕获标记 )> 直接放在数字后面,我们只需抛弃这部分,保留数字本身即可。

很明显,指令列表是一组由垂直空格分隔的指令。一个重复指示符(如 +)后面跟着 % 和一个原子的表达式,如 % \v 的意思是"被 \v 分隔"。这样一来,我们就搞定了最后一部分,即指令集。 每个成分的描述都是在 grammarole 中创建的。在这里,我们以类似的方式,进行分项(使用 *-,就像在 Markdown 中一样)并创建一个列表。最后我们需要定义标题,这里的词实际上很重要,还有分隔,它的定义主要是为了避免在主规则中出现许多常量。这里复制一下。

token TOP {
    "#" \h+ <title>
    <.separation>
    <description>
    <.separation>
    "##" \h+ Ingredients \h+ "(for" \h+ $<persons> = \d+ \h+ person s? ")"
    <.separation>
    <ingredient-list>
    <.separation>
    "##" \h+ Preparation \h+ "(" $<time> = \d+ "m)"
    <.separation>
    <instruction-list>
}

这个 TOP 规则尽职尽责地展示了我们描述的 RecipeMark 文档的整体结构。每一节,或者说标题和节的内容,都被几个垂直分隔符严格分开,我们还引入了几个额外的标记—​菜谱设计的人数以及需要烹饪的时间(分钟)。同样,这也是严格的,描述每一个部分的词语都必须精确地按照这种方式来写。请注意,隐含地(作为成分的一部分),这个 grammar 使用数据存储来检查什么构成成分,并验证该成分的正确计量。

有了这个,再加上一个简单的程序,比如这个程序。

use Raku::Recipes::Grammar::RecipeMark;
use Grammar::Tracer;

my $rm = Raku::Recipes::Grammar::RecipeMark.new;

for <tuna-risotto tuna-risotto-low-cost> -> $fn {
    my $text = "recipes/main/rice/$fn.md".IO.slurp;
    say $rm.parse( $text );
}

我们的 grammar 正确的配方将被解析成这样的数据结构:

title => ⌈Tuna risotto⌋
 words => ⌈Tuna⌋
 words => ⌈risotto⌋
description => ⌈A relatively simple version of this rich, creamy dish of Italian origin.⌋
 sentence => ⌈A relatively simple version of this rich, creamy dish of Italian origin.⌋
persons => ⌈4⌋
ingredient-list => ⌈* 320g tuna (canned)
# Ingredients here
 itemized-ingredient => ⌈* 320g tuna (canned)⌋
  ingredient-description => ⌈320g tuna (canned)⌋
   measured-ingredient => ⌈320g tuna⌋
    quantity => ⌈320⌋
    unit => ⌈g⌋
    product => ⌈tuna⌋
   options => ⌈(canned)⌋ content => ⌈canned⌋
# Lots more ingredients
time => ⌈60⌋
instruction-list => ⌈1. Slightly-fry tuna with its own oil it until it browns a bit, you can do this while you start doing the rest, save a bit of oil for the rice. # and the rest
7. Rest for 5 minutes before serving.⌋
numbered-instruction => ⌈1. Slightly-fry tuna with its own oil it until it browns a bit, you can do this while you start doing the rest, save a bit of oil for the rice.⌋
 numbering => ⌈1⌋
 instruction => ⌈Slightly-fry tuna with its own oil it until it browns a bit, you can do this while you start doing the rest, save a bit of oil for the rice.⌋
  action-verb => ⌈Slightly-fry⌋
  sentence => ⌈tuna with its own oil it until it browns a bit, you can do this while you start doing the rest, save a bit of oil for the rice.⌋
# Rest of instructions

如果有 Grammar 错误,它将简单地返回 Nil。

检查 Grammar (并提取文档的部分内容)将带你走完一部分路。但你需要实际处理迷你程序。我们将在下一个配方中进行处理。

16.2. 食谱 16-2. 创建和处理用迷你语言编写的迷你程序的配方

16.2.1. 问题

一旦你创建了一种确定文本是否符合语言标准的方法,你就需要对它采取行动。例如,生成一个包含必要数据的数据结构,以便进行其他处理。

16.2.2. 解决办法

使用 Grammar 操作,可能用角色结构化,来收集数据,并将其提供给 Raku 类型或一般数据结构。

16.2.3. 它是如何工作的

使用 Grammar 进行解析时,会返回一个 Match 对象,其中包括每一个被匹配的嵌套的 token。Match 对象将包含 TOP 标记所匹配的内容。实际上,这就是一棵解析树。但是,这个解析树即使包含了我们需要的所有信息,也可能不是按照我们需要的方式来包含的。有些信息可能隐藏在两三个标记的深处,从浏览结果的人来看,可能并不明显,所有的信息都在哪里。此外,解析树包含 Match 对象。我们可能需要其中的一部分以另一种格式,从数字到复杂的类。

Raku 提供了一种机制来解决这个问题。 Grammar 动作嵌入到 Grammar ,检查解析树中的每一步,并以我们需要的格式生成数据。同样,我们在第 11 章中使用了它们。我们在这里要做的是使用它们来生成一个复杂的 RecipeMark 数据结构,这将更容易使用。

如果我们看一下配方本身,它有几个部分。其中三部分可以是元数据:标题、人数和准备时间。其余的则是信息或内容:描述和配料和说明书的列表。对于初学者来说,我们可以简单地将食谱结构成一个哈希,有六个键,三个键是元数据,三个键是内容,名称明显。我们可以对食材和说明书列表使用不同的数据结构。成分列表可以是一个哈希:每个成分都会是不同的产品,所以我们可以使用产品作为键。指令列表是一个数组,但我们需要考虑到数字。在某些情况下,数字可能是重复的,所以我们可能要使用一个数组的对。每个对也会有一个对作为值,它将使用"动作动词"作为键。成分也将使用一个哈希作为值,它将以 "选项"、"单位"和"数量"作为键。

这个数据结构将是我们可以使用的 "处理过的"配方。我们将使用这个动作来处理它。

unit class Raku::Recipes::Grammar::RecipeMark::Actions;

method TOP($/) { make {
    title => ~$/<title>,
    description => ~$/<description>,
    persons => +$/<persons>,
    ingredient-list => $/<ingredient-list>.made,
    preparation-minutes => + $/<time>,
    instruction-list => $/<instruction-list>.made
}}

method ingredient-list( $/ ) {
    make gather for $/.hash<itemized-ingredient> ->
    $ingredient {
        take $ingredient.made
    }
}

method itemized-ingredient($/) { make $/<ingredient-description>.made }

method ingredient-description($/) {
    my %ingredient = $/<measured-ingredient>.made;
    %ingredient{%ingredient.keys[0]}{'options'} = $/<options> if $/<options>;
    make %ingredient;
}

method measured-ingredient($/) {
    make $/<product>.made => { unit => $/<unit>.made // "Unit",
                               quantity => $/<quantity>.made
                             }
}

method product($/) { make tc ~$/; }

method quantity($/) { make +val( ~$/  ) // unival( ~$/ ) }
method options($/){ make ~$/; }
method unit($/){ make ~$/; }

method instruction-list( $/ ) {
    my @instructions = gather for $/.hash<numbered-instruction> -> $instruction {
        take $instruction.made
    }
    make @instructions;
}

method numbered-instruction($/) {
    make $/<numbering>.made => $/<instruction>.made;
}

method numbering($/) { make +$/; }
method instruction($/) { make $/<action-verb>.made => $/<sentence>.made }
method action-verb($/) { make ~$/; }
method sentence($/) { make ~$/; }

我们从下往上看。首先提醒一下这个工作原理:$/ 是一个 Match 对象。每个方法都会收到一个同名 token 产生的 Match 对象。通过使用 make,我们在相应的抽象 Grammar 树上附加了一个数据结构,所以生成的数据结构会搭载在 Match 上。我们可以使用 .made 方法进行访问,所以 make $/<action-verb>.made 将从 action-verb token(在 RecipeMark Grammar 中)中检索出我们使用 action-verb 方法生成的数据结构(如下右图)。然后我们将把它附加到对象 $/ 上。

大多数简单的或低级的标记(可以说是解析树的"叶子")只是简单地将匹配转换为字符串(通过使用字符串上下文化器)或数字(通过数字上下文化器 +)。在某些情况下,我们需要额外的处理。例如,数量,需要一个不同的转换过程,这取决于它是否是一个 Unicode 数字。在一些情况下—​数字-指令、指令、测量-成分—​我们创建对,正如我们之前所指出的那样。

处理 token 列表就比较复杂了。Match 是一个默认返回简单事物列表的对象;访问 token 列表的另一种方式是使用 .hash 方法,以我们要访问的 token 名称为键。我们在 ingredient-list 和 C 中也做了类似的事情:我们在提取的 token 列表上运行,只需将相应的数据结构添加到我们要返回的数组中。

TOP 规则对应的方法是负责生成要返回的数据结构。此外,还有一些只在那里提取的标记,就是元数据对应的标记。我们直接从 token 匹配中创建标题、描述、人员和准备分钟,而食材和说明书列表将从他们所附加的 token 中用 .made 获得。这就创建了我们最终可以使用的数据结构,其中包含了处理过的菜谱。

我们可以在下面的脚本中使用它。

use Raku::Recipes::Grammar::RecipeMark;
use Raku::Recipes::Grammar::RecipeMark::Actions;

my $rm = Raku::Recipes::Grammar::RecipeMark.new;
my $action = Raku::Recipes::Grammar::RecipeMark::Actions.new;
for <main/rice/tuna-risotto
    main/rice/tuna-risotto-low-cost
    appetizers/carrot-wraps>
-> $fn {
    my $text = "recipes/$fn.md".IO.slurp;
    say $rm.parse( $text, actions => $action ).made;
}

同样,我们对文件进行吐槽和解析,指出我们将使用的动作。我们只实例化它一次,并把它传递给每一条解析语句。解析的结果仍将是一个 Match;但在该 Match 对象上调用 .made 将返回处理结果的数据结构。例如,对于胡萝卜包来说,它将是这样的。

{description => A healthy way to start a meal, or to munch between them., ingredient-list => ({Carrots => {quantity => 250, unit => g}} {Cottage cheese => {options => ⌈(or cheese spread)⌋
 content => ⌈or cheese spread⌋, quantity => 200, unit => g}} {Wheat tortillas => {quantity => 4, unit => Unit}}), instruction-list => [1 => Cut => the carrots in long sticks or slices. 2 => Spread => cheese over tortillas, cut them in half. 3 => Put => carrot sticks on tortillas, wrap
 them around. 4 => Add => fresh parsley, mint or coriander to taste.],
 persons => 4, preparation-minutes => 20, title => Carrot wraps}

在这里你可以看到不同的键:描述、标题、准备分钟…​…​。总的来说,这提供了一个已经有用的格式,并且容易处理,我们的食谱用 RecipeMark 写的版本。

我们可以将其转储为 JSON 格式,将其序列化,并将其提供给任何语言。我们可以使用这些数据或元数据来生成报告,我们将在下一个配方中进行。

16.3. 食谱 16-3. 处理配方和生成报告

16.3.1. 问题

你需要处理一组使用食谱迷你语言编写的食谱,提取最常见的成分,或在一组食谱中总结卡路里,或者仅仅是查看文本并决定它是否是一个正确的 RecipeMark 文档。如果可能的话,所有这些功能都应该在一个命令行工具中实现。

当文档出现错误时,你还需要生成一份报告;一个有用的错误信息将帮助用户确定错误所在并加以纠正。

16.3.2. 解决方案

Grammar 给你提供了一个抽象的 Grammar 树,你可以在这个树上工作,提取解决方案;一旦你有了这个树,你就可以把你感兴趣的部分归零。或者你可以使用 Grammar 动作,相当于程序编译,创建小型编译,在这种情况下,编译成一个单一的卡路里或成分的计数。无论如何,当文档不符合 Grammar 时,你需要超越返回 Nil。我们应该收集用户不理解的反馈,至少要遵循第 8 章的精神,对错误要有帮助和意义。除此之外,我们还需要捕捉一些更高层次的错误,比如某个成分的重复。

为了解决这些问题,我们将创建一个命令行工具,它的不同选项可以让你做不同的事情:从简单的检查,到生成新的文档或对文档中提取的数据进行操作。

16.3.3. 它是如何工作的

一个迷你语言一般会包含一个命令行程序,它可以进行不同的操作,比如检查 grammar ,转换为其他格式,或者进行其他类型的处理。如果 grammar 没问题,脚本就会执行任务,但如果失败了,没有错误信息,它就干脆不做了。它必须尽最大努力确定错误,给出理解不当的提示。

Grammar ,原则上,从一开始就不具备这种功能。但是,归根结底, Grammar 是 Raku 程序,所以我们可以把可检测到的 grammar 错误当作异常,命令行实用程序可以捕捉到这些异常,或者干脆冒给用户。

这就需要围绕语言建立一个全新的类层次结构,所以我们重构要做的第一件事就是创建一个全新的命名空间,叫做 RecipeMark,在这里我们插入所有专门处理这种新语言的类。我们要做的第一件事就是把现有的模块移到一个叫 RecipeMark 的新命名空间中。由于我们的第一项工作是向客户报告错误,我们将重新设计 grammar (现在将称为 RecipeMark::Grammar),使其能够处理错误。我们需要将 Grammar::PrettyErrors 混合进来。这就是结果。

use Raku::Recipes::Grammarole::Measured-Ingredients;
use Grammar::PrettyErrors;

unit grammar RecipeMark::Grammar
        does Raku::Recipes::Grammarole::Measured-Ingredients
        does Grammar::PrettyErrors;

token TOP {
    "#" \h+ <title>
    <.separation>
    <description>
    <.separation>
    "##" \h+ Ingredients \h+ "(for" \h+ $<persons> = \d+ \h+ person s? ")"
    <.separation>
    <ingredient-list>
    <.separation>
    "##" \h+ Preparation \h+ "(" $<time> = \d+ "m)"
    <.separation>
    <instruction-list>
}

token separation { <ws> ** 2 }
# No change in the rest , except...
token ws { <!ww> \v }

正如你所看到的,主要的改变是重写了代表空白的 ws 标记,使它能与垂直的空格一起使用,并改变了分离标记,使它包括两个这样的标记。Grammar::PrettyErrors 通过环绕这个标记来工作。它试图找出最后一个工作的地方,并在错误中显示出来。它并不完美,但至少我们知道该从哪里开始寻找。试图插入一个不存在的成分(比如食人鱼而不是金枪鱼)会导致一个像图 16-1 所示的错误。

图 16-1. 由于 Grammar ::PrettyError 产生的 RecipeMark 错误

在前面的分离中显示了错误,它包括行号,甚至更好的是,可以用另一种方式捕捉和处理的错误类型。

其他类型的错误可能很难在解析阶段被捕捉到,比如说,存在两种成分的同一种产品。

新的,错误检测的, Grammar ,将被称为 RecipeMark::Grammar,这里是。

use Raku::Recipes::Grammarole::Measured-Ingredients;
use Grammar::PrettyErrors;
use X::RecipeMark;

unit grammar RecipeMark::Grammar
        does Raku::Recipes::Grammarole::Measured-Ingredients
        does Grammar::PrettyErrors;

# token TOP, separation, title, description do not change.

token ingredient-list {
    :my $*INGREDIENTS = ∅;
    <itemized-ingredient>+ % \v
}

token itemized-ingredient {
    ["*"|"-"] \h+ <ingredient-description>
    {
        my $product = tc ~$/<ingredient-description><measured-ingredient> <product>;
        if $product ∉ $*INGREDIENTS {
            $*INGREDIENTS ∪= $product;
        } else {
            X::RecipeMark::RepeatedIngredient.new( :match($/), :name($product) ).throw;
        }
    }
}

token instruction-list {
    :my UInt $*LAST = 0;
    <numbered-instruction>+  % \v
}

#Instruction, numbered-instruction do not change
token numbering {
    \d+ )> "." {
        if +$/ < $*LAST {
            X::RecipeMark::OutOfOrder.new( :match($/),
            :number(+$/),
            :last($*LAST) ).throw;
        } else {
            $*LAST = +$/;
        }
    }
}
# The rest is the same as above

我们做了一些改动,使 grammar 可以生成两种不同的异常,一种是针对我们可以检测到的每一种错误。这些异常是这样定义的。

use Grammar::Message;

unit module X::RecipeMark;

role Base is Exception {
    has Match $.match;
    submethod BUILD( :$!match ) {}
}

class OutOfOrder does Base {
    has $.number;
    has $.last;

    submethod BUILD( :$!match, :$!number, :$!last ) {}

    multi method message () {
        pretty-message( "Found instruction number $!number while waiting for number > $!last", $!match );
    }
}

class RepeatedIngredient does Base {
    has $.name;

    submethod BUILD( :$!match, :$!name ) {}

    multi method message () {
        pretty-message("Ingredient $!name appears twice", $!match );
    }
}

我们定义一个 Base 类,主要是为了方便,因为其他两个类需要一个 Match 属性才能工作。X::RecipeMark::OutOfOrder 需要被插入的编号,X::RecipeMark::RepeatedIngredient 需要被重复的原料名称。在这两种情况下,都会创建一个包含匹配和消息的漂亮消息。这个例程来自于发布的模块 Grammar::Message,它使用提供的匹配来创建一个列表,其中包含被匹配的文档和一个指针,指向我们最后知道一切都很顺利的地方。这不会是精确地发生错误的地方,但它将是我们知道处理顺利的最后一个位置。

效果类似于 Grammar::PrettyErrors 所做的事情(事实上,代码是从那个模块中重用的,自由软件是美好的,你不觉得吗?)。但这个例程的好处是,如果我们想把异常消息传送给最终用户,我们可以简单地用它来生成异常消息。

不过,我们需要的是在抛出异常时提供那个 Match 对象。在原 grammar 上所做的修改正是朝着这个方向发展的。Regexen、tokens 和规则只需在它们周围加上大括号,就可以执行代码。这是重构 Grammar 的关键之一。

另一个关键是动态变量,那些在标号和标识符之间带双标*的变量。从第 1 章的配方开始,我们就一直在使用它们,在第 1 章中,我们使用 $*DISTRO 来确定我们是否在 Windows 中工作。然而,到目前为止,我们所使用的所有变量都是由 Rakudo 解释器创建的内在变量,或者在其中一个模块中使用,比如 Red。此外,这是唯一一次我们真正定义了这样的变量,在整个 Red ORM 中使用。这表明,它们通过乐道程序被广泛使用,通常在那里人们会使用一个全局变量。然而,与全局变量不同的是,它们不能在整个程序中被看到(和改变)。它们只在它们被定义的作用域和从该作用域调用的任何东西中可见。如果我们在一个例程中定义了它们,它们可以在所有从那里调用的例程中被看到和改变。

Token 实际上是例程(它们实际上是方法,而不是独立的子),它们"调用"其中提到的 token。因此,如果我们在一个 token 中定义了一个动态变量,那么那里提到的所有 token 也会看到那里定义的动态变量。

Token 实际上是正则表达式,所以我们需要一种特殊的 grammar 来定义它们的变量(包括动态变量)。副词 :my 将用于此。当我们写出以下内容时。

token ingredient-list {
    :my $*INGREDIENTS = ∅;
    <itemized-ingredient>+ % \v
}

我们正在定义一个名为 $*INGREDIENTS 的动态变量,该变量将在 itemized-ingredient token 以及从那里调用的任何其他 token 中出现。我们给它分配了一个空的集合;这个变量将包含到目前为止所使用的成分集。如果 itemized-ingredient 是一个例程,我们会使用一个状态变量来存储这个状态。然而,词法范围变量是我们可以在 token/regex/rule 中声明的唯一类型的变量,所以我们使用该功能在调用 itemized-ingredients 的 token 中创建一个动态变量。

这个变量将持有一个集合,其中包含所有提到的成分。itemized-ingredient 中插入的代码如下。

{
    my $product = tc ~$/<ingredient-description><measured-ingredient> <product>;
    if $product ∉ $*INGREDIENTS {
        $*INGREDIENTS ∪= $product;
    } else {
        X::RecipeMark::RepeatedIngredient.new( :match($/), :name($product) ).throw;
    }
}

它将产品名称规范化(通过使用标题大小写 - tc),如果产品名称不存在,则将其插入到集合中,如果存在,则使用 $/ 中存储的匹配状态和产品名称引发一个异常。这个异常对用户很有帮助,因为它会产生一个异常,说 "Ingredient Tuna 出现两次",然后会指向发生这种情况的行。

在指令的情况下,我们使用动态变量来保存最后使用的指令号。

{
    if +$/ < $*LAST {
        X::RecipeMark::OutOfOrder.new( :match($/),
        :number(+$/),
        :last($*LAST) ).throw;
    } else {
        $*LAST = +$/;
    }
}

Contextualizing to number 将 Match 转换为一个数字。我们将它与最后存储的数字进行比较,如果数字要下降,我们会引发一个异常(允许重复的数字)。

由此产生的 Grammar 将把之前的相同文档声明为正确。然而,如果它们不正确,它将尝试显示某种错误(一旦它们被使用 RecipeMark::Grammar.parse 调用)。例如,当一个产品被重复时产生的错误如图 16-2 所示。

图 16-2. 当原料被重复时产生的错误,在本例中是金枪鱼(它被写成了米饭)。

该错误的"尾巴"提供了关于它产生的位置的太多信息。它可能是有用的(例如,它可能会揭示它发生的源行),但我们可能会选择在以后抑制它。

这种额外的处理会带来一些开销:每次调用 token 时都会调用的代码,这就导致了最终用户的一定延迟。不过,只要我们让用户对 RecipeMark 语言以及与它的交互更加满意,这种应用付出的代价就很小。

16.3.4. 处理 RecipeMark 文档

一旦我们知道一切都正确,我们必须生成文档的内部表示。我们已经定义了一组动作,我们只要将其重命名为 RecipeMark::Actions 就可以大部分重用。此外,我们还需要修改成分描述方法中的这条指令。

%ingredient{%ingredient.keys[0]}{'options'} = ~$/<options><content> if $/<options>;

以前只是说 $<options>,没有上下文符 ~。这对于生成一个对象是没有问题的,但是 $/<options> 是一个 Match 对象,这将使得这不是一个纯粹的哈希,而是一个包含整个 Match 对象的哈希。让我们把它字符串化(同时去掉括号),这样这个操作就会生成可序列化的纯哈希对象,而不会有其他对象嵌入其中。用这个动作进行解析会生成一个数据结构,但是我们需要重用这个数据结构来生成一个 RecipeMark 对象,我们可以在这个对象上构建所有我们需要的处理选项。下面是这个类的定义。

use RecipeMark::Grammar;
use RecipeMark::Grammar::Actions;
use JSON::Fast;

unit class RecipeMark;

has Str $.title;
has Str $.description;
has UInt $.persons;
has UInt $.preparation-minutes;
has %.ingredient-list;
has @.instruction-list;

method new( $file where .IO.e) {
    my %temp = RecipeMark::Grammar.parse(
            $file.IO.slurp,
            actions => RecipeMark::Grammar::Actions.new
            ).made;
    self.bless(| %temp );
}

method to-json() {
    return to-json self.Hash ;
}

method Hash() {
    return { title => $!title,
             description => $!description,
             persons => $!persons,
             preparation-minutes => $!preparation-minutes,
             ingredient-list => %!ingredient-list,
             instruction-list => @!instruction-list
    }
}

method product-list() {
    return %!ingredient-list.keys;
}

这个类的每一个对象都会有六个属性,这些属性构成了返回的哈希的键,这就是为什么新方法会解析文件的内容,并且只是祝福所产生的哈希 self.bless( |%temp )。这将哈希扁平化,将其转换为一组命名的参数,直接传递给这个对象的 BUILD 子方法(隐式)。我们可以通过 .Hash 方法取回对象,或者使用 to-json 将其转换为 JSON。产品列表只是 %!ingredient-list hash 中的键列表,但客户端不需要关心实现的问题。我们只需要添加一个 product-list 方法,将其返回即可。

这样一来,创建处理代码就很容易了。这将是 RecipeMark 命令行的第一个版本,暂时来说,它检查并产生一个购物清单。

use lib <lib ../lib>;
use RecipeMark;

multi sub MAIN( "check", $file where .IO.e ) {
    say RecipeMark.new( $file ).to-json;
}

multi sub MAIN( "shopping-list", $file where .IO.e ) {
    say "# Shopping list\n\n",
            RecipeMark.new( $file )
            .product-list
            .sort
            .map( {"* [ ] $_."})
            .join: "\n";
}

由于我们使用 MAIN sub 来处理不同的选项,所以如果在没有选项或使用 -help 标志的情况下使用,这还有一个额外的好处,就是可以自由使用信息。在这两种情况下,它都会打印以下内容。

Usage:
  recipemark check <file>
  recipemark shopping-list <file>

检查命令行选项将以 JSON 格式打印生成的结构,同时也会产生这里所指出的 Grammar 错误,因为我们没有捕获输出。如果有购物清单选项也会产生同样的错误。如果能够创建对象,它将通过方法提取产品-列表,按字母顺序排序,将其映射到一行 Markdown 代码的待办事项中,最后将所有项目连接起来打印购物列表。

% Chapter-16/recipemark shopping-list recipes/main/rice/tuna-risotto-low-cost.md
# Shopping list
* [ ] Butter.
* [ ] Cheese.
* [ ] Garlic.
* [ ] Olive oil.
* [ ] Onion.
* [ ] Rice.
* [ ] Tuna.
* [ ] Wine.

你可以将其导出到 TODO-list 应用程序中,并将其带到你的超市、杂货店或友好的邻里杂货店。

RecipeMark 对象将是我们需要做的额外处理的基础,例如,计算卡路里(这将与在 Raku::Recipes 类层次结构中做这种任务的对象和/或例程挂钩)。一旦我们在一个对象中获得了所有的信息,我们就可以通过 API 公开功能,或者,如果我们认为值得的话,我们可以为它添加新的方法。我们也可以将这个数据结构存储在数据存储中,如第 12 章所示。例如,我们可以很容易地添加一个方法来检查一个菜谱是否是素食主义者,并通过命令行 API 将其公开。这将是添加到 RecipeMark 中的方法。

method vegan() {
    my $data = Raku::Recipes::CSVDator.new;
    return so all self.product-list.map:
            { $data.get-ingredient($_)<Vegan> };
}

其实很简单。我们使用数据存储来检索我们所知道的所有成分(记住,食谱使用我们在数据库中的产品名称作为关键字)。我们逐一检查它们是否是素食主义者,只有当所有的成分都是素食主义者时,我们才返回 true。暴露该 API 的 multi 会是这样的。

multi sub MAIN( "vegan", $file where .IO.e ) {
    my $recipemark = RecipeMark.new( $file );
    say $recipemark.title,
            $recipemark.vegan ??
            color("green") ~ " is vegan " ~ color("reset")!!
            " is " ~ color("red") ~ "not" ~ color("reset") ~ " vegan ";
}

这一点是比较直接的。两种不同配方上的结果如图 16-3 所示。

在下一个配方中,我们将看到用不同的方法将其处理成另一个 Markdown 文档,甚至是 HTML。

然而,这三个配方显示了 Raku 及其不同的设施—​从 Grammar 到动态范围变量,再到角色和强大的正则表达式引擎和异常处理设施—​是如何被利用来创建一个结构良好的 DSL 语言处理应用程序的,它是可扩展的,并且可以通过它的 API 轻松使用。我们不仅有一个语言处理程序,我们还有一个可嵌入的解释器,它可以成为一个更大的应用程序的一部分;这可以用许多不同种类的语言来完成。

注意 Grammar (及其定义和处理语言的能力),就像正则表达式一样,是非常强大的东西,一旦你通过了第一条,公认的陡峭的学习曲线,你会发现无限的应用。在本书中,你已经了解了几个用例,在你学习了如何在这些食谱中使用 Grammar 之后,你可能会想到一些不那么明显(而更有用)的用例。

16.4. 食谱 16-4. 将 Grammar 转换为一个完整的配方处理应用程序,生成 HTML 或其他外部格式

16.4.1. 问题

需要使用自定义的亮点将每个关键词的配方转换为另一种格式。例如,我们想要创建网页,而不仅仅是简单的 Markdown 处理,甚至要生成一个新的 Markdown 文档,其中包括关键字的特定标记。

16.4.2. 解决方案

按照前面的配方进行处理,并将该生成的数据结构作为中间格式或数据在模板中使用。

16.4.3. 它是如何工作的

其实,主要决定的是在模板中放什么,用什么模板引擎来处理配方。数据已经被提取到一个可管理的数据结构中,所以我们只需要对它进行分片处理,给文档的每一个部分都做一个合理的标记,这样如果转换成其他格式,就能突出它在文档中的作用。

无论如何,让我们来盘点一下我们所拥有的东西。一个 RecipeMark 包括标题、描述、人数和制作时间。它还包括一个成分哈希和一个说明数组。理想情况下,模板应该能够和它们一起工作;当然,更理想的情况是它可以直接取 RecipeMark 对象。

我们已经对 Template::Classic 有了不错的体验。现在我们就用它来工作吧。首先,我们来创建模板。

# <%= $recipemark.title %>

<%= $recipemark.description %>

## Ingredients (for <%= $recipemark.persons %> persons)

<%
use URI::Encode;
my %ingredients = $recipemark.ingredient-list;
for %ingredients.kv -> $product, %data {
    take "* %data<quantity> %data<unit> "
        ~ "[ {lc $product} ](/Ingredient/" ~ uri_encode($product) ~ ")"
                    ~ (" %data<options>" if %data<options> ) ~ "\n";
} %>

## Preparation (<%= $recipemark.preparation-minutes %>m)
<%
for $recipemark.instruction-list[0][] -> $instruction {
    take $instruction.key ~ ". " ~ "*" ~ $instruction.value.key ~ "* "
            ~ $instruction.value.value ~ "\n";
} %>

小编提醒一下 Template::Classic 的工作原理:它在模板中嵌入标记,标记将被替换成它的值。在这种情况下,它需要一个单一的变量:,它是 $recipemark,是 RecipeMark 类的一个实例。标题、描述、人员和准备时间是直接使用对象的公共属性提取的,这也是为什么它们在 <%= %> 标记中,直接插入里面的值。

我们需要几个循环来获取成分。我们需要在原料的键值上做一个循环,在这里我们将列出原料数据,并插入一个 Markdown 链接到我们描述每个原料的(假定)网站。这里我们需要包含 URI::Encode 模块,以便能够使用 uri_encode。模板以序列元素的形式返回片段,我们使用 take 将它们 "返回 "到渲染的模板中。

我们需要使用相当奇怪的 Grammar 来获得指令列表。

$recipemark.instruction-list[0][]

指令列表被存储为一个单元素的数组,主要是由于它是如何用 Grammar 动作提取的。我们只需通过使用 [] 对这第一个元素进行解密,就能回馈给我们一个数组,我们在这个数组上执行循环。在这个循环中,我们将突出第一个动作动词。

我们将在 RecipeMark CLI 中添加另一个 main multi 来处理这个子命令,我们将简单地称之为 md。

multi sub MAIN("md", $file where .IO.e ) {
    my $template-name="templates/recipemark.md";
    my $template-file = "resources/$template-name".IO.e
            ?? "resources/$template-name".IO.slurp
            !! %?RESOURCES{$template-name}.slurp;
    my &generate-page := template :($recipemark), $template-file;
    my $recipemark = RecipeMark.new( $file );
    say generate-page( $recipemark).eager.join
}

就像我们在前面的章节中所做的那样,我们加载模板文件,并从中创建一个模板例程,之后使用它将结果打印到标准输出。

在描述胡萝卜包的文档上使用它的结果如下。

➜ raku-recipes-apress git:(master) ✗ Chapter-16/recipemark md recipes/ appetizers/carrot-wraps.md
# Carrot wraps

A healthy way to start a meal, or to munch between them.

## Ingredients (for 4 persons)

* 250 g [ carrots ](/Ingredient/Carrots)
* 200 g [ cottage cheese ](/Ingredient/Cottage%20cheese) (or cheese spread)
* 4 Unit [ wheat tortillas ](/Ingredient/Wheat%20tortillas)

## Preparation (4m)

1. *Cut* the carrots in long sticks or slices.
2. *Spread* cheese over tortillas, cut them in half.
3. *Put* carrot sticks on tortillas, wrap them around.
4. *Add* fresh parsley, mint or coriander to taste.

这很接近原稿,但显示了文档是如何被理解和解析的。Markdown 可以直接转换为 HTML,但我们可能要重新使用之前使用的模板,或者添加格式化或渲染说明。

一个 HTML 模板就可以直接翻译成这个模板。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
<title>Recipe: <%= $recipemark.title %></title>
<link rel='stylesheet' id='style-css' href='raku-recipes.css' type='text/css'
      media='all' />
</head>
<body>
<!-- This is a Template::Classic template -->
<h1><% $recipemark.title %></h1>
<p><% $recipemark.description %></p>

<h2>Ingredients (for <%= $recipemark.persons %> persons)</h2>

<ul>
<%
use URI::Encode;
my %ingredients = $recipemark.ingredient-list;
for %ingredients.kv -> $product, %data {
take "<li> %data<quantity> %data<unit> "
    ~ "<a href='/Ingredient/" ~ uri_encode($product) ~ "'>"
    ~ lc $product ~ "</a>"
    ~ (" %data<options>" if %data<options> ) ~ "</li>\n";
} %>
</ul>

<h2> Preparation (<%= $recipemark.preparation-minutes %>m) </h2>

<ul><% for $recipemark.instruction-list[0][] -> $instruction {
    take "<li>" ~ $instruction.key ~ ". " ~ "<strong>" ~
    $instruction.value.key ~
    "</strong> "
    ~ $instruction.value.value ~ "</li>\n";
} %>
</ul>

</body>
</html>

我们使用了 HTML 标记,试图得出正确的选择。我们在指令列表中使用 <ul> 而不是 <ol>,因为项目编号可能会重复。其余的地方插入元数据,标题放在页面标题和页眉的位置,其余的地方都差不多。实现这个的 MAIN 是比照的,和 Markdown 的 MAIN 差不多。

我们是否可以再进一步,尝试用 MealMaster 的格式来生成菜谱?这个格式在这个页面上有说明:http://ffts.com/mmformat.txt。让我们试着把它浓缩成一个模板。我们可以使用 MealMaster Perl 模块来检查它是否被正确定义。下面是我们将要使用的模板。

---------- Recipe via Meal-Master (tm) v8.05
      Title: <%= $recipemark.title %>
 Categories: <%= $categories %>
      Yield: <%= $recipemark.persons %> servings
<%    my %units =  tbsp => "ts",
                    Unit => "ea",
                    spoons => "sp",
                    cloves => "ea",
                    cup => "cu",
                    liter => "l" ;
      my %ingredients = $recipemark.ingredient-list;
      for %ingredients.kv -> $product, %data {
          my $quantity = %data<quantity> ~~ Rat
                         ?? %data<quantity>.Num
                         !! %data<quantity>;
          my $unit = %data<unit>.chars > 2
                     ?? %units{%data<unit>}
                     !! %data<unit>;
          take $quantity.fmt("%7s") ~ " " ~ $unit.fmt("%2s") ~ " "
          ~ lc $product ~ "\n"
} %>

<%for $recipemark.instruction-list[0][] -> $instruction {
        take "  " ~ $instruction.key ~ ". " ~ "*" ~ $instruction.value.key
        ~ "*"
              ~ " " ~ $instruction.value.value ~ "\n";
}
%>
-----

必须适应配方的僵化、固定格式。主要由于使用 COBOL 及其简单的面向行的处理格式,用行上的位置来表示不同类型的数据,以及特定的定界符,这是非常通常的。在本例中,定界符在文件的两端。我们使用元数据来填充固定的字段。我们将需要一个新的容器,称为 $categories,来创建这些,因为它不是 RecipeMark 属性的一部分。

但主要的转换是由食材遭受的。成分的数量需要在前七个字符中以十进制格式显示,这就是为什么我们将分数转换为数字。然后,它们被垫在行的前七个字符中打印。"承认 "的措施需要容纳两个字符,而且数量也是有限的(和我们在 RecipeMark 中的做法一样)。我们尽可能地容纳这些。例如,没有等量的丁香,所以我们就放 "ea"(如每个)。剩下的一行将是原料,我们跳过选项。这也需要包含在 40 列中,所以我们只需跳过选项,以避免该行内容过满。通过使用 fmt("%xs"),我们将字符串扩展(或者,可能是收缩)为指定的格式,因此数量正好在第 7 列结束,单位在第 9 列结束。

我们需要再处理一下这个模板的文件名。下面是对应的 MAIN.MIN 的文件名。

multi sub MAIN("mmf", $file where .IO.e ) {
    my $template-file= template-file "templates/recipemark.mmf";
    my &generate-page := template :($categories, $recipemark),
    $template-file;
    my $categories = $file.split("/")[1..*-2].join(", ");
    my $recipemark = RecipeMark.new( $file );
    say generate-page( $categories, $recipemark).eager.join
}

最主要的是,正如这里所指出的,我们需要一些东西来填充类别。这种元数据是包含在文件名中的:包含的目录也是元数据。它表示是用米饭做的,是主菜还是开胃菜等等。模板声明稍作修改,加入了这个变量,这个变量是拆分文件名得到的,去掉了第一个元素("菜谱")和最后一个元素(文件名)。生成的文件如下。

---------- Recipe via Meal-Master (tm) v8.05
      Title: Tuna risotto
 Categories: main, rice
      Yield: 4 servings
    125  g cheese
    0.5 ea onion
      ...
  1. *Chop* tuna to small chunks, and stir-fry it until it browns a bit;
     you can do this while you start doing the rest.
  1. *Stir-fry* garlic until golden-colored, chopped if you so like, retire
     if you don't like the color.
 ...
-----

根据 MealMaster 模块的说法,这种格式是正确的;而且它是正确的,因为食谱所需的数据是由 RecipeMark API 语义上暴露出来的。

RecipeMark CLI 最终演变成了一个命令,有六个子命令,能够进行转换和检查,甚至生成报告。这种灵活性源于 Raku Grammar 和我们创建的关于它们的业务逻辑层,包括一个封装解析结果的类、抽象 Grammar 树、语义和一个方便的 API。这种强大的功能可以通过许多不同的方式来利用,但 Raku 的表达能力使其工作起来相当容易。

这个 Grammar 几乎是从头开始写的,但很多 Grammar 都会包含常见的语言模式。你不需要每次都重新发明它们。我们将在下一个食谱中看到如何重复使用它们。

16.5. 食谱 16-5. 重用常见语言模式

16.5.1. 问题

许多功能被许多(迷你)语言所共享:从表达式到常见模式,如电子邮件地址和 URL。重写这些常见表达式的标记可能会花费很多时间。

16.5.2. 解决方案

使用 Grammar::Common,由已故的 Jeff Goff 发起,最近作为 Raku 社区模块的一部分进行维护,它有大量的通用模式,你可以在你的迷你语言中重复使用。

16.5.3. 它是如何工作的

在设计 Raku 语言时的一个见解是将 Grammar 作为第一类类型纳入其中。就像模块和类产生一个代码的生态系统一样, Grammar 产生一个生态系统,嗯, Grammar 作为代码。一个生态系统会让你有可能站在巨人的肩膀上,运用 DRY 原则--"不要重复自己"。而现在我们也可以对 Grammar 做同样的事情。

通过 Jeff Goff 的这个模块,我们当然可以站在巨人的肩膀上。他看到了 Grammar 的可能性,它可以创建一个通用模式的基础,这些模式可以很容易地在我们的程序中重复使用,比如使用不同格式的通用数学表达式。他还意识到创建这个基础的最好方法是通过 Grammar oles,所以他的 Grammar::Common 是一个角色的集合,用于常见的程序表达式…​…​但也包括文本。

只要我们进入(半)自然语言处理,我们就需要反复解析文本。一个句子就是一个句子,一个词就是一个词。句子中的第一个词必须大写,而句子的停顿方式只有这么几种:问号、感叹号或句号。这个包含在 Grammar::Common 中的 Grammar 可以帮助我们解决这个问题。

unit role Grammar::Common::Text;

token sentence { <first-word> <.separators> <sub-sentence> <.stop>}
token stop { "." | "?" | "!" }
token sub-sentence { <words>* % <.separators> }
token separators { [","|";"|":" ]? \s+ }
token first-word { <:Lu> <[\w \- \' \.]>* }
token words { <[\w \- \']>+ }

我们使用 Unicode 字符类 <:Lu>,它代表"大写字母"。一个句子将是一个大写的第一个字,加上一个分隔符,加上一个子句(没有句号或第一个字的句子),再加上一个字符来完成句子。虽然这个近似值并不能抓住所有可能的单词和句子(例如,引用和括号将不起作用),但它是一个良好的开端,可以在 Grammar 中被覆盖。但重要的是,它提供了一组可以直接重用的构件。

我们可以用它来重构 RecipeMark Grammar 。下面是结果(省略了没有变化的部分)。

use Raku::Recipes::Grammarole::Measured-Ingredients;
use Grammar::PrettyErrors;
use Grammar::Common::Text;
use X::RecipeMark;

unit grammar RecipeMark::Grammar
        does Raku::Recipes::Grammarole::Measured-Ingredients
        does Grammar::PrettyErrors
        does Grammar::Common::Text;

# No change
token instruction { <action-verb> \h <sub-sentence> <.stop>}
# No change, except for elimination of <words> and <sentence>

只有一个变化:我们在这个 token 中使用了来自 Grammar::Common::Text 的 <sub-sentence>,并删除了 <sentence> 以及 <words>,它们现在在那个通用 Grammar 中。好在我们不需要改变任何动作,因为它们有相同的名称,因此产生相同的 token。当使用任何其他 Grammar 时,我们将需要到这一点。

同样的方式,建议检查生态系统中是否有实现你所需要的部分功能的模块,建议对 Grammar 也这样做。它总能为你节省一些工作。

17. 有趣的单行程序

Raku 是一种具有功能特性的表达式语言。这使得在管道的一端捕捉用户和系统输入,并从另一端喷出处理过的数据或任何类型的动作变得相对容易。像Perl 和 Raku 挑战赛这样的比赛也强调短程序,这就产生了一大堆简单的脚本,尽管有时在其他语言中难以辨认,但它们的功能却很强大。不过,Raku 追求的是表现力,所以用 Raku 理解它们和创造它们要容易得多。

这些单行本也采用了非常有创意的编程模式。我们在本章的所有配方中都使用它们作为原料。

17.1. 食谱 17-1. 用单行代码写一个猜谜游戏

17.1.1. 问题

作为一个挑战,你需要写一个游戏,为玩家提供一定的回合数来猜测一个数字,并且每回合都会给他们提示。

17.1.2. 解决方法

Raku 使用 ; 作为语句分隔符,所以你可以放任意多的语句。

在单行上。此外,你还可以使用一些技术来缩短程序,并尽可能地减少字符数:使用单字符变量(或不使用符号变量),在第一次使用它们时定义它们(或使用隐式变量,不需要定义),使用运算符代替控制结构( ??!! 代替 if),而且,一般来说,为了长度而牺牲可读性。

17.1.3. 它是如何工作的

这个猜谜游戏会随机生成一个小数字,玩家尝试通过猜测来确定这个数字。当玩家猜中一个数字时,游戏会提示真正的答案是大数还是小数,从而让玩家缩小数字的猜测范围。当猜到正确的数字时,游戏就结束了。下面是一个非单行程序。

my $number = 6.rand.Int;
my $prompt = "*";
say $number;
while ( my $guess = prompt("$prompt Your guess>") ) ne "" {
    if $guess == $number { last }
    elsif $guess < $number { $prompt = "<" }
    else { $prompt = ">" }
}

数字生成后,我们用部分提示来说明猜测的数字是比输入的数字大还是小。然后我们运行一个循环,如果猜测正确则退出(使用 last),如果不正确则更改提示。这个循环会一直运行,或者直到我们使用一个空字符串来回应它。用来写消息和收集所写内容的命令,实际上就是提示符。

让我们试着利用我们所拥有的一切,将这个程序缩短为一行代码。

my \n = (1..6).pick; ($_ > n ?? ">" !! "<").print while ( $_ = prompt("Your guess> ") ) != n

注释 好吧,所以在本书中,由于本书的页边距,它不会装进一行。但是你可以在图17-1中看到它在 Raku 中的一行代码中。

这一行有93个字符(其中有些是空格),它有两条语句;我们需要一条来声明一个变量。我们使用默认变量 $_ 来避免声明一个变量,以及无符号变量来避免输入 $,这是随机数生成的一个较短的版本(可能更快),但本质上是一样的。一字型变量甚至不需要有自己的文件—​你可以直接在命令行中使用 -e 运行它们,如图17-1所示。

图 17-1. 使用 raku -e 运行单行程序。

注意,脚本需要在引号内。由于我们已经使用了双引号,我们将在整个脚本周围使用单引号。

如果你需要在Windows中这样做,有很多选择。

  1. 最新的 Windows 版本包含一个 Ubuntu 子系统;你可以使用 shell 来安装和运行 Raku,就像在 Linux 中那样。另一个选择是 CygWin,它可以帮助你安装许多 Linux 程序。最后,GitHub 客户端包括一个 shell,它是 CygWin 的一个版本,如果你想这样做,你可以在那里安装其他 Linux 命令行实用程序并运行它们。

  2. 这些最后的版本还包括 PowerShell,它具有与 Linux 命令行相同的引用惯例。使用 PowerShell 以同样的方式运行它。

  3. 最后,你可以简单地在外面使用双引号,如果你使用的是旧版本的 Windows,并且不能(或不愿意)安装 Linux 命令行工具,则可以将里面的双引号用"/"转义。

请记住,Raku 在使用引号方面是相当自由的,所以如果你在脚本内部使用单引号,你可以用其他引号代替,或者干脆转义。

raku -e 'my \n = (1..6).pick; ($_ > n ?? ⌈>⌋ !! ⌈<⌋).print while ( $_ = prompt(⌈ Your guess> ⌋) ) != n'

我们能不能把这个再缩短一点?好吧,我们至少可以做一条逻辑线,让它也能发挥作用。

{ $^b > $^a
        ?? &?BLOCK($^a, prompt("> "))
        !! $^b < $^a ?? &?BLOCK($^a, prompt("< ")) !! "✓".say
}((1..6).pick,0)

这是153个字符,再一次,去掉空格会使它更短,但无论如何会比以前长。但这是一个单一的逻辑行。它创建了一个块,并递归地调用它。这个块有两个参数,第一个参数是要猜的数字,第二个参数是猜测。如果猜测的数字比较大,它就会在提示中显示一个"大于"的符号,否则就会显示一个"小于"的符号。它用 ??!! 进行三方比较,如果不小于或大于,就一定是解,所以它打印一个复选标记。

这里的诀窍是递归调用这个块:没有定义词法变量,要猜的数字以 $^a(一个隐式变量,只要参数的顺序也是变量名的字母顺序,就可以随意调用)的形式传递,猜的数字以 $^b 的形式传递。没有赋值,只有参数的绑定,这就使得这个纯粹的功能。

真正的神奇之处在于对隐式变量 &?BLOCK 的使用。Raku 有很多方法来进行反省(这可能不一定是件好事,但在这里很方便)。例如,代码块有一种自称方式:这个 &?BLOCK 并不关心它在哪个块中。它永远是这个块,你可以用它来递归,就像我们在这里做的那样。另一种可能使用更多行的选择是用一个简短的名字来调用例程,并以名字来引用它。这同样可以增加一个逻辑新行,因为子声明和代码可能被认为是驻留在不同的行上。

所以,你自己选吧。逻辑上或物理上更短。无论哪种情况,Raku 都有办法让你做到。

17.2. 食谱 17-2. 使用单行计算序列中的第n个元素

17.2.1. 问题

你需要计算一个递归定义的序列的第n个元素,给定它的第一个元素和一个给定前面元素的通项来计算n。

17.2.2. 解答

这个很简单,因为序列可以在 Raku 中以"自然"的方式定义。序列中的位置可以从命令行中读取。然而,单行道需要进行测试和评估,所以我们将提出几种解决方案来实现这一点。

17.2.3. 它是如何工作的

Raku 中的序列使用了一个名为 Seq 的类,它可以容纳懒惰定义的和可能是无限的序列,但它的主要操作方式是使用 …​(省略号)。(省略号)运算符。这个运算符可以:

  • 根据它的第一项生成算术或几何级数。

  • 根据它的首项定义一个序列,以及一个从前面的项计算出来的一般项。

让我们从一个简单的开始。计算一个数的阶乘 也就是这个数和前面所有数的乘积。例如,4的阶乘是 4x3x2x1=24。

say  [*] 1..( @*ARGS[0]
                // %*ENV<NUMBER>
                // die "Use $*PROGRAM <num> or NUMBER=<num> $*PROGRAM" );

虽然为了便于阅读,这个解决方案显示为几行,但从逻辑上讲,它是一行。脚本的大部分内容是检查输入:它使用命令行或环境变量,如果它们都不存在,它就会以一条使用信息结束。// 是定义或操作符。如果有的话,它使用左边,如果没有的话,则使用右边。所以我们可以通过输入以下内容来运行。

./factorial.p6 225

或者在 Linux 或 OSX 命令行中写入以下内容:

NUMBER=225 ./factorial.p6

如果没有任何参数可用,它将产生这样的消息:

Use /home/jmerelo/progs/perl6/raku-recipes-apress/Chapter-17/factorial.p6
<num> or NUMBER=<num> /home/jmerelo/progs/perl6/raku-recipes-apress/
Chapter-17/factorial.p6

程序的路径取自 $*PROGRAM 动态变量。这是在一个"die"消息中产生一个立即退出(而不是一个额外的错误),这是典型的单行 hack。

无论这个数字是如何得到的,它都会被用来生成一个范围,并使用 * 操作符上的 reduce 元操作符将其所有元素相乘。结果是一个快速的程序,当然还有其他方法。比如这个。

sub MAIN( Int $number = %*ENV<NUMBER>) { say  [*] 1..$number }

这与之前的做法正好相反。从物理上看是一行(甚至比上一行还短),但从逻辑上看至少是三行。我们使用签名的默认值机制来使用环境变量(如果有的话)。在这种情况下,没有值的错误将由子 main 本身产生,它可能会减少信息量,尽管它使程序在逻辑上更简单。

Type check failed in binding to parameter '$number'; expected Int but got
Any (Any)

这并不是一个真正的使用信息,而且它显然忽略了你可以使用环境变量的事实。另一方面,如果我们使用 -help 运行它,我们会得到一个有用的使用信息,如下所示。

Usage:
  factorial-v2.p6 [<number>]

这表明了参数的名称和它是可选的事实(方括号内)。甚至还有第三种选择。

sub MAIN( Int $number =%*ENV<NUMBER> ) { my $c = 1; say (1,* * $c++...∞) [$number-1] }

我们使用一个辅助变量来考虑操作所处位置的索引,$c 将包含该索引,并将其与前一个值相乘,生成一个序列,其中 $c 元素是阶乘。但我们这样做实际上是在生成一个无限的(但懒惰的)序列。我们不需要这样做,所以让我们尝试一个简单的循环,如下。

sub MAIN( Int $n =%*ENV<NUMBER> ) { my $ფ = 1; $ფ *= $_ for 1..^$n; $ფ.say;}

我们使用的是格鲁吉亚字母 phar,因为它听起来像 factorial,也像一个戴着眼镜的小鼻子。我们对程序进行了高尔夫化处理,以占用最少的字符数,事实上,如果我们给环境变量起另一个名字,可以让它更短。但这里还有一个问题。在我们试过的所有程序中,哪一个是最快的?如果我们用命令行上的时间对10000的阶乘如何计算进行计时,结果会是这样的。

time raku ./factorial-v4.p6 10000
0,80s user 0,06s system 106% cpu 0,806 total

事实上,这正好是我机器上最快的。前一个是最慢的,0.95秒。这是一个使用最简单的数据结构,以及 for 循环,反正是优化了不少。它还是使用了一个范围,但这并不慢,事实上第一个版本是第二快的;因为 sub MAIN 增加了一点开销,所以这个版本可能,事实上是相当快的。使用 hyperfine 命令工具对这四个版本进行基准测试的结果如图17-2所示。

图17-2.用 hyperfine 命令工具对这四个版本进行基准测试的结果。四个版本的基准测试,最后一个版本比第一个版本快了2%。

不过,做事要快,会让你的速度不快。所以我们需要测试,我们也需要测试这些独角戏。在我们最不愿意看到的时候,一切都充满了乐趣和欢声笑语,直到那个单行本崩溃。

幸运的是,我们可以在 Raku 中进行白盒测试;也就是说,我们可以用与其他函数或模块相同的方式在 Raku 中编写的脚本上运行测试。我们使用 Test::Script 模块来实现这一功能,该模块是由你们最近向生态系统发布的。让我们测试这四个模块,并检查它们是否有效地工作。

use Test::Script;
use lib <.>;

for <factorial factorial-v2 factorial-v3 factorial-v4> -> $f {
    my $filename = "Chapter-17/$f.p6";
    output-is($filename, "3628800\n",
       "Output well computed for 10",
       args => [10]);
}

Test::Script 提供了一系列函数,其中一个是 output-is。它验证脚本的输出,也就是函数的第二个参数是否正确。脚本的参数通过名为 argumentargs 给出,作为一个数组。在本例中,我们将尝试计算10的阶乘,也就是指定的数量。一切都好吗?嗯,不是的。上一个脚本是错误的。这里是正确的版本。

sub MAIN( Int $n =%*ENV<NUMBER> ) { my $ფ = 1; $ფ *= $_ for 1..$n; $ფ.say;}

我不会让你在这个版本和上个版本之间来回走动:$n 前面的 ^ 是排除了计算中要考虑的最后一个元素,结果比预期少了一个零。测试发现了这个错误,现在已经修复了。你明白了吗?测试很重要。

注意,我重新运行了基准,看看这个错误是否对总的性能有影响,并没有。

我们可以用一条线计算更复杂的序列。例如,我们可以计算尼伯利亚对数基数e的近似值;这是对阶乘的倒数相加,直到一个数。也就是说,我们计算出一系列数的阶乘,直到一个点,我们取它们的倒数,然后把它们相加。例如,如果这个数是3,那么三个阶乘就是1,2,6,它们的反数是1,1⁄2,1⁄6,加起来就是12⁄3。数字越大,精度越大。这个脚本就可以做到。

say [+] (1,| [\*] (1...∞))[^@*ARGS[0]].map: 1/*

其基础是所有因子的无限连续。[*] 是一个累加换算运算符。它把每一个元素乘以之前所有元素的结果, 但它没有把结果扔掉, 而是用所有元素创建一个系列. 它没有使用 0,因为 0 是一种特殊的情况,阶乘是 1,但它会破坏这个系列,所以我们把它放在前面,并使用滑移运算符 | 来整理整个系列。

这样的结果将是所有阶乘的无限序列。但我们只需要先用n来计算e的近似值,所以我们使用命令行中给出的参数将其切到我们想要的地方。然而,为了得到e,我们需要对该序列进行反演。对于1000个元素,我们将得到 2.718281828459045,对于更大的值,它几乎停留在那里。我们还能再往前走吗?嗯,20! 已经是一个相当大的数字了,而 1/20! 是非常小的。它超出了系统中表示浮点数字的能力,为什么要把这些周期都浪费掉呢?我们就把不能再改进的序列剪掉吧。

say [+] (1,| [\*] (1...^max(@*ARGS[0],20))).map: 1.0/*

我们不定义一个无限的序列,从理论的角度来看是可以的,但不是很实用,我们在参数处切割数字序列,如果它小于20或者干脆就是20。和上面一样,但是无论如何我们都会更快的得到结果,因为 20 项就是我们要计算的全部内容。但是,如果我们得到的只是蹩脚的精度,而我们可以用一个简单得多的公式来实现,那么我们为什么要这样做呢?我们需要提高精度 而且,Raku,在这个意义上,又是有帮助的。

say [+] (1,| [\*] (1..@*ARGS[0])).map: { FatRat.new(1,$_) }

我们取消了数值的上限,我们不需要了。但这里的关键是使用 FatRat,或者任意精度的大有理数来计算每一个新项。当 Rats(或有理数)超过一定大小时,就会变成 Nums(或浮点数),从而受到精度限制,而 FatRats 则不会。对于 FatRats,我们可以一直计算项,直到累为止。对于1000个项,将如下:

2.718281828459045235360287471352662497757247093699959574966967627724076630
[...41 lines here...]
2010249505518816948032210025154264946398128736776589276881635983125

当你需要的时候,Raku 会给你提供精度,但是只有 Ints 和 FatRats 这两个数据结构具有无限的精度,所以如果你需要到数字的第十六位,你需要在你的单行本(或其他任何程序)中包含其中一个。

17.3. 食谱 17-3. 使用单行代码重复执行系统管理任务

17.3.1. 问题

你需要不时地检查日志,或者创建一个警报,或者监控一些变量,如果可能的话,只需要一行代码。你需要以既定的频率重复这些检查。

17.3.2. 解决办法

使用 supply 来创建周期性任务;如果你想把它们放在一行中,这些任务应该很短。如果你需要使用外部模块来执行系统任务,你可以在命令行中使用 -M 来提供它。

17.3.3. 如何工作

系统交互并不容易,我们专门用了整整一章的篇幅来介绍,第二章。一般来说,你需要使用一个外部库来正确解析日志,或者使用正确的命令与操作系统 API 交互,这通常会在一个外部模块中。

例如,比如说你需要知道文件系统的演变,并确定它是如何被填充的。你可以使用不同的命令,Linux 和 OSX 中的 df 和 du,Windows 中可能还有其他种类的命令。如果你不想处理所有不同的情况,你可以使用一个模块,比如 FileSystem::Capacity,它使用这些工具来返回目录和卷的容量。 现在你需要定期做一些事情。同样,你可以使用操作系统的服务来做这件事。然而,这高度依赖于操作系统,并且需要额外的功能。如果可能的话,我们就用一个脚本来实现同样的功能。

在 Raku 中,有几种方法可以实现这一壮举。可以对承诺进行定时,并且你可以在每履行一个承诺的情况下,推出另一个承诺。然而,最简单的实现方式是使用 supply。一个 supply 创建一个数据流;一个实时 supply 会永远产生值,或者直到它被停止。有许多方法可以创建这类 supply,但我们将使用 .interval,它每给定一个参数的秒数就会生成一个递增的整数流。Supply.interval(2) 将产生每两秒递增的数字。我们并不关心数字,而是关心每两秒处理一个东西,这就是我们在这个单行程序中要做的事情。

react whenever Supply.interval(@*ARGS[0]) {
    with volumes-info()</> {
        say "Free M ", (.<free>/2**20).Int,
        "- Used ", .<used%>
    }
}

同样是167个字符,间距什么的都有。这一行确实很长,但它是一个逻辑句,包裹在几个控制结构中。外部的那个 react,每当收到一个消息,就会激活。它所反应的消息包含在一个when子句中,该子句检查(周期性)供应何时发出什么。我们并不真正关心数字(这将在 $_ 变量中),但我们关心我们的东西:volumes-info 将返回一个以所有卷为键的哈希和以它们为哈希的信息,</> 将是根卷(在 Linux 中;这是操作系统特有的,在 Windows 中不起作用)。我们可以改变斜线的方向(从 /\)来检测操作系统,但这会增加长度,如果你把它安装到不同的操作系统上,可能更容易改变它。

使用 with 有助于缩短脚本,因为它把它的值放在隐式变量中。.<free> 将从哈希中返回该值,同理,.<used%> 也是如此。我们将值转换为兆字节,因为默认情况下它是以字节为单位的(至少在 Ubuntu 中是这样)。

我们还使用参数来指定间隔,并通过 @*ARGS 来获取。然后我们可以这样运行它:

raku -M FileSystem::Capacity::VolumesInfo -e 'react whenever Supply.interval(@*ARGS[0]) { with volumes-info()</> { say "FreeM ", (.<free> / 2** 20).Int, "- Used ", .<used%> } }' 15
FreeM 193112- Used 57%
...

我真的很想把这段代码放在一行上,所以我把它缩减了很多。这里是正常字体,分几行:

raku -M FileSystem::Capacity::VolumesInfo
    -e 'react whenever Supply.interval(@*ARGS[0]) {
        with volumes-info()</> {
            say "FreeM ", (.<free> / 2 ** 20).Int, "- Used ", .<used%>
        }
    }' 15

FileSystem::Capacity::VolumesInfo 模块包含 volumes-info,是 FileSystem::Capacity 发行版的一部分。在命令行中使用 -M 相当于我们放在脚本前面的使用语句。通过将它卸载到命令行中,我们在程序中保存了一条语句,使其保持为单行。

为了使它有用,你可能会想在启动时运行它,并将它的输出重定向到系统日志目录下的某个日志文件,例如 /var/log。但这是额外的事情。这里的单行脚本将完美地解决你的问题。

18. 术语

Raku 将许多新的概念引入编程领域,其中一些概念有自己的词汇来描述它们。本词汇表包括了一系列在本书(和 Raku 文档)中常用的词汇,以及在这里首次引入的其他词汇。

18.1. Dator

数据访问器的简称,据我所知在本书中也有介绍。它是一个数据访问类,或者是同一个类的实例化对象,或者是描述这些类必须采用的(可能是抽象的)接口的角色。Dators 被注入到其他类中,以一种独立于实际使用的数据存储的方式访问数据存储。

18.2. Distro/Distribution

一组功能相关的 Raku 模块、类和语法,它们一起被发布到生态系统中或在一个动作中安装。相当于经典的"库"概念,除了发行版可能包括二进制脚本和其他工件,如文档。

18.3. Grammarole

同时在本书中也介绍了,它是一个定义部分语法的角色,它可以在语法中混用,虽然它本身可以,也可以通过双关语来作为语法使用。

18.4. Hyper/Race

自动线程运算符,用于将应用到的数据结构分成不同的批次(批次大小由程序员控制),然后提交给不同的线程进行计算,从而将顺序操作转化为并发操作。虽然 hyper 会按照与原始数据结构相同的顺序返回结果,但 race 可能不会尊重这个顺序。

18.5. Punning

创建一个新的角色实例,就像它是一个完整的类一样。

18.6. Rakuish

神话般的品质,会让一个模式或语句比其他模式或语句更足以解决 Raku 中的问题。一般来说,没有这样的事情,因为在 Raku 中,有不止一种方法可以做,而且 Raku 是一种以开发者为中心的语言。然而,不同的编程模式会带来更快的代码或更干净的外观。选择一种而不是另一种,不是语言说了算,而是真的取决于你。这也指的是利用 Raku 的特性(特别是那些区别于其他语言的特性)来简洁地表达自己和解决问题。TMTOWTDI 也适用于术语的定义。

18.7. Rocket Operator

在本书和 《Perl 6 快速语法参考》中都有使用。这是一个 "feed" 操作符,它将一个阶段的结果"发射"到下一个阶段。它是 =⇒ 操作符,它从左边接收一个 list、sequence 或数组,从右边接收一个映射/过滤操作,结果是一个数组,而这个数组又可以反过来被送入下一个阶段。例如,^3 =⇒ map( *2) 将计算0到2的平方。

18.8. Routingine

本书介绍的是路由例程,也就是 Cro 微服务中具有例程形式的路由块。

18.9. Token

一个标记,作为 grammar 的一部分使用,其表达式与方法的表达式类似,它使用正则表达式的语言来提取字符串的一部分。 与声明的 regex 的主要区别是 token 不回溯(因此速度更快),并且忽略空白;规则不忽略空白。token、regexes 和 rule 在 grammar 中都有使用,尽管在本书中我们几乎只使用 token。

18.10. Websocker

这里介绍一下,它是一个 websocket 服务器。换句话说,就是一个通过 websocket 响应消息的应用程序。