概述§

作为 Perl 语言家族成员,Raku 中的程序在顶层往往更倾向于解释执行的末端。在本教程中,“解释执行”程序意味着源代码(即人类可读的文本,例如 say 'hello world';)由 Raku 程序立即处理成可由计算机执行的代码,任何中间阶段都存储在内存中。

相反,编译程序是指将人类可读的源代码首先处理成机器可执行代码,并将此代码的某种形式存储在“磁盘”上。为了执行程序,机器可读版本被加载到内存中,然后由计算机运行。

编译和解释形式都有各自的优点。简而言之,解释型程序可以快速“编写”出来,源代码也可以快速更改。编译型程序可能很复杂,需要花费大量时间预处理成机器可读代码,但运行起来对用户来说要快得多,用户只看到加载和运行时间,而不会看到编译时间。

Raku 同时拥有这两种范式。在**顶层**,Raku 程序是解释执行的,但分离到模块中的代码将被编译,预处理后的版本将在需要时加载。在实践中,由社区编写的模块只需要在用户“安装”时预编译一次,例如通过 zef 等模块管理器。然后,开发人员可以在自己的程序中使用 use 来使用它们。这样做的效果是使 Raku 顶层程序快速运行。

Perl 家族语言的一大优势是能够将由有能力的程序员编写的整个模块生态系统集成到一个小程序中。这种优势被广泛复制,现在已成为所有语言的规范。Raku 将集成提升到一个新的高度,使 Raku 程序能够相对容易地将用其他语言编写的系统库集成到 Raku 程序中,请参见 原生调用

Perl 和其他语言的经验表明,模块的分布式特性会带来一些实际上的困难。

  • 一个流行的模块可能会经历多次迭代,因为 API 会不断改进,但不能保证向后兼容性。因此,如果程序依赖于某个特定的函数或返回值,那么就必须有一种方法来指定 版本

  • 一个模块可能由 Bob 编写,他是一位非常有能力的程序员,后来离开了,模块无人维护,于是 Alice 接手。这意味着同一个模块,同一个名称,同一个通用 API 可能在野外有两个版本。或者,两个最初合作开发模块的开发人员(例如 Alice 和 Bob)后来分道扬镳。因此,有时需要有一种方法来定义模块的**授权**。

  • 一个模块可能会随着时间的推移而得到增强,维护者会同时维护两个版本,但 API 不同。因此,可能需要定义所需的**API**。

  • 在开发新程序时,开发人员可能希望同时安装 Alice 和 Bob 编写的模块。因此,不可能只安装一个具有单个名称的模块版本。

Raku 支持所有这些可能性,允许存在、安装和本地使用多个版本、多个授权和多个 API。类和模块如何使用特定属性访问的解释请参见 其他地方。本教程介绍了 Raku 如何处理这些可能性。

介绍§

在考虑 Raku 框架之前,让我们看看像 PerlPython 这样的语言如何处理模块安装和加载。

ACME::Foo::Bar -> ACME/Foo/Bar.pm
os.path -> os/path.py

在这些语言中,模块名称与文件系统路径之间存在一对一的关系。我们只需将双冒号或句点替换为斜杠,并添加 .pm.py

请注意,这些是相对路径。PythonPerl 都使用包含路径列表来完成这些路径。在 Perl 中,它们在全局 @INC 数组中可用。

@INC

/usr/lib/perl5/site_perl/5.22.1/x86_64-linux-thread-multi
/usr/lib/perl5/site_perl/5.22.1/
/usr/lib/perl5/vendor_perl/5.22.1/x86_64-linux-thread-multi
/usr/lib/perl5/vendor_perl/5.22.1/
/usr/lib/perl5/5.22.1/x86_64-linux-thread-multi
/usr/lib/perl5/5.22.1/

每个包含目录都会检查它是否包含从模块名称确定的相对路径。如果匹配,则加载该文件。

当然,这只是一个简化的版本。这两种语言都支持缓存模块的编译版本。因此,Perl 不会直接查找 .pm 文件,而是首先查找 .pmc 文件。而 Python 首先查找 .pyc 文件。

在这两种情况下,模块安装主要意味着将文件复制到由相同简单映射确定的位置。该系统易于解释、易于理解、简单且健壮。

为什么要改变?§

为什么 Raku 需要另一个框架?原因是这些语言缺少一些功能,即

  • Unicode 模块名称

  • 不同作者发布的同名模块

  • 安装了多个版本的模块

26 个拉丁字母集对于几乎所有现代语言来说都过于限制,包括英语,因为英语中许多常用词都有变音符号。

如果模块名称与文件系统路径之间存在 1:1 关系,那么一旦尝试在多个平台和文件系统上支持 Unicode,就会陷入困境。

然后是多个作者之间共享模块名称。这在实践中可能有效,也可能无效。我可以想象使用它来发布一个包含某些修复的模块,直到原始作者在“官方”版本中包含该修复。

最后是多个版本。通常,需要特定版本模块的人会使用 local::lib 或容器或一些自己开发的解决方法。它们都有自己的缺点。如果应用程序能够简单地说,嘿,我需要老式的、可靠的 2.9 版本,或者可能是该分支的错误修复版本,那么这些都不需要。

如果你对继续使用简单的名称映射解决方案有任何希望,你可能在版本控制要求上放弃了。因为,当你寻找 2.9 或更高版本时,如何找到模块的 3.2 版本?

流行的想法包括在 JSON 文件中收集有关已安装模块的信息,但当这些文件变得非常慢时,文本文件被替换为将元数据放入 SQLite 数据库中。但是,这些想法很容易被引入另一个要求而推翻:发行包。

Linux 发行版的软件包大多只是包含一些文件和一些元数据的存档。理想情况下,安装此类软件包的过程仅仅意味着解压缩文件并更新中央软件包数据库。卸载意味着删除以这种方式安装的文件,并再次更新软件包数据库。在安装和卸载时更改现有文件会使打包人员的生活变得更加困难,因此我们真的想避免这种情况。此外,安装文件的名称可能不依赖于之前安装的内容。我们必须在打包时知道这些文件的名称将是什么。

长名称§

Foo::Bar:auth<cpan:nine>:ver<0.3>:api<1>

让我们摆脱这种困境的第一步是定义一个长名称。Raku 中的完整模块名称由短名称、作者、版本和 API 组成

同时,你安装的通常不是单个模块,而是一个可能包含一个或多个模块的发行版。发行版名称的工作方式与模块名称相同。实际上,发行版通常会以其主模块命名。发行版的一个重要属性是它们是不可变的。Foo:auth<cpan:nine>:ver<0.3>:api<1> 将始终是完全相同代码的名称。

$*REPO§

PerlPython 中,你处理指向文件系统目录的包含路径。在 Raku 中,我们称这些目录为“仓库”,每个仓库都由一个执行 CompUnit::Repository 角色的对象管理。没有 @INC 数组,而是 $*REPO 变量。它包含一个仓库对象。该对象具有一个 next-repo 属性,该属性可能包含另一个仓库。换句话说:仓库被管理为一个链表。与传统数组的重要区别在于,当遍历列表时,每个对象都有权决定是否将请求传递给 next-repo。Raku 设置了一组标准的仓库,“core”、“vendor”和“site”。此外,还有一个用于当前用户的“home”仓库。

仓库必须实现 need 方法。Raku 代码中的 userequire 语句基本上被转换为对 $*REPOneed 方法的调用。该方法可能会将请求委托给 next-repo。

role CompUnit::Repository {
    has CompUnit::Repository $.next-repo is rw;
 
    method need(CompUnit::DependencySpecification $spec,
                CompUnit::PrecompilationRepository $precomp,
                CompUnit::Store :@precomp-stores
                --> CompUnit:D
                )
        { ... }
    method loaded(
                --> Iterable
                )
        { ... }
 
    method id--> Str )
        { ... }
}

仓库§

Rakudo 附带了几个可用于仓库的类。最重要的类是 CompUnit::Repository::FileSystemCompUnit::Repository::Installation。FileSystem 仓库旨在用于模块开发,实际上在查找模块时就像 Perl 一样工作。它不支持版本或 auth,并且只是将短名称映射到文件系统路径。

安装库是真正智能的地方。当请求模块时,通常可以通过其确切的长名称来请求,或者说类似“给我一个匹配此过滤器的模块”的内容。此类过滤器通过 CompUnit::DependencySpecification 对象提供,该对象具有以下字段:

  • 短名称,

  • 身份验证匹配器,

  • 版本匹配器和

  • API 匹配器。

在查看候选者时,安装库会将模块的长名称与这个 DependencySpecification 智能匹配,或者更确切地说,将各个字段与各个匹配器智能匹配。因此,匹配器可以是某个具体的值、版本范围,甚至正则表达式(尽管任意正则表达式,例如 .*,不会产生有用的结果,但类似 3.20.1+ 的表达式只会找到高于 3.20.1 的候选者)。

加载所有已安装发行版的元数据将非常缓慢。当前的 Raku 框架实现使用文件系统作为一种数据库。但是,另一个实现可能会使用其他策略。以下描述展示了一种实现的工作方式,并在此处包含以说明正在发生的事情。

我们不仅存储发行版的文件,还创建索引以加快查找速度。其中一个索引以已安装模块的短名称命名的目录形式出现。但是,当今大多数常用的文件系统无法处理 Unicode 名称,因此我们不能直接使用模块名称。这就是臭名昭著的 SHA-1 哈希进入游戏的地方。目录名称是 UTF-8 编码的模块短名称的 ASCII 编码 SHA-1 哈希。

在这些目录中,我们找到每个发行版中包含具有匹配短名称的模块的一个文件。这些文件再次包含发行版的 ID 以及构成长名称的其他字段:身份验证、版本和 API。因此,通过读取这些文件,我们通常会得到一个简短的身份验证-版本-API 三元组列表,我们可以将其与我们的 DependencySpecification 进行匹配。我们最终得到获胜发行版的 ID,我们使用它来查找存储在 JSON 编码文件中的元数据。此元数据包含 sources/ 目录中包含请求的模块代码的文件的名称。这就是我们可以加载的内容。

查找源文件名称再次有点棘手,因为仍然存在 Unicode 问题,此外,相同的文件相对名称可能被不同的已安装发行版使用(想想版本)。因此,至少目前,我们使用长名称的 SHA-1 哈希。

资源§

%?RESOURCES
%?RESOURCES<libraries/p5helper>
%?RESOURCES<icons/foo.png>
%?RESOURCES<schema.sql>

Foo
|___ lib
|     |____ Foo.rakumod
|
|___ resources
      |___ schema.sql
      |
      |___ libraries
            |____ p5helper
            |        |___
            |___ icons
                     |___ foo.png

不仅以这种方式存储和查找源文件。发行版还可以包含任意资源文件。这些可以是图像、语言文件或在安装时编译的共享库。可以通过模块内的 %?RESOURCES 哈希访问它们。

只要您坚持发行版的标准布局约定,即使在开发过程中不安装任何内容,这也适用。

这种架构的一个很好的结果是,创建专用库非常容易。

依赖项§

幸运的是,预编译至少在大多数情况下运行良好。但它也带来了自己的挑战。加载单个模块很容易。当模块具有依赖项,而这些依赖项又具有自己的依赖项时,乐趣就开始了。

Raku 中加载预编译文件时,我们还需要加载其所有依赖项的预编译文件。而且这些依赖项 **必须** 是预编译的,我们不能从源文件加载它们。更糟糕的是,依赖项的预编译文件 **必须** 与我们最初用于预编译模块的完全相同的文件。

最重要的是,预编译文件仅适用于用于编译的精确 Raku 二进制文件。

如果不存在一个额外的要求,所有这些仍然是可以管理的:作为用户,您希望您刚刚安装的模块的新版本实际被使用,不是吗?

换句话说:如果您升级了预编译模块的依赖项,我们必须检测到这一点,并使用新的依赖项再次预编译模块。

预编译存储§

现在请记住,虽然我们有一个标准的库链,但用户可以通过命令行上的 -I 或代码中的“use lib”来添加额外的库。

这些库可能包含预编译模块的依赖项。

我们解决这个谜题的第一个方案是,每个仓库都有自己的预编译存储库,用于存储预编译文件。我们只从链中第一个仓库的预编译存储库加载预编译文件,因为这是唯一一个直接或间接访问所有候选者的仓库。

如果这个仓库是文件系统仓库,我们会在 .precomp 目录中创建一个预编译存储库。

虽然这是安全的选择,但它会导致每次使用新仓库时,我们都将从没有访问预编译文件开始。

相反,我们将在首次加载时预编译使用的模块。

鸣谢§

本教程基于 niner演讲