architecture

架构与深度设计

最小用法见 ../README.md;快速上手见 ./quickstart.md。

本文档由 README 拆分而来,保留全部设计细节;仅位置迁移,未删减。

架构总览

bin/raku-pm.raku          薄壳入口(仅把参数转交给 RakuPM::CLI)
        │
        ▼
RakuPM::CLI               CLI 主体:命令派发 + 各命令实现(原先 bin 里的 900+ 行)
        │                 ※ 单独成模块【只为 precomp】:bin 脚本不享受预编译,
        │                   每次调用都被整体编译(实测 ~0.75s);搬进模块后只剩
        │                   薄壳要编译,启动实测量省 0.6~0.8s(0.79.0)
        │
        ▼
RakuPM::Client            编排器(仿 Zef::Client,只负责协调)
        │
        ├── RakuPM::Repositories    仓库【列表】:配置持久化 + 别名 + 环境变量
        │       │                   (repositories.json,决定挂哪几个索引)
        │       ▼
        ├── RakuPM::Resolver       依赖解析器(递归解析 + 冲突检测 + 拓扑排序)
        │       │                  先按「该版本是否真的提供此模块」筛,再择优
        │       │
        │       ├── RakuPM::Repository (role)   仓库抽象(可插拔)
        │       │       │                    └ 混合 Repository::Matching:
        │       │       │                      「关键词↔发行版」匹配的唯一实现处
        │       │       │                      (search/store/installed/install 共用)
        │       │       ├── Repository::Local        本地目录仓库
        │       │       └── Repository::Ecosystem    远程 JSON 生态(zef/rea/cpan…)
        │       │
        │       ├── RakuPM::Version     语义化版本 + 约束匹配
        │       │
        │       └── RakuPM::Spec        安装规格串解析(Foo:ver<1.2.3> / [email protected])
        │                               零依赖轻量模块:install 与查询命令共用
        │
        ├── RakuPM::Store           包存储(多版本共存 + 内容指纹)
        │
        ├── RakuPM::Installer       安装器(store → CUR::Installation 的 site/)
        │                           + 事务/世代 + bin wrapper
        │   ├── RakuPM::InstallOptions  安装链的选项载体(把 7+ 个布尔开关收成一个对象)
        │   ├── RakuPM::Ledger          账本(installed.json)的唯一读写入口
        │   ├── RakuPM::Fs              递归删除 / 复制 / 遍历 / 体积(单一实现处)
        │   ├── RakuPM::Installer::Generations      generations/ 目录的格式与生命周期
        │   │                           (id 分配 / before / meta / manifest / current / 裁剪)
        │   └── RakuPM::Installer::ShellTemplates   bin wrapper 的三段平台 shell 模板
        │                                (bash / cmd .bat / PowerShell .ps1,纯字符串函数)
        │
        ├── RakuPM::Prefix          安装前缀的【唯一】出处
        │                           默认前缀 / 前缀解析 / 「是不是默认前缀」/
        │                           promote 默认值。零依赖 —— CLI 要在
        │                           require Client 之前就知道前缀(冷启动)
        │
        ├── RakuPM::Platform        平台差异的【唯一】出处(H9)
        │                           系统判别 / 路径分隔符 / 可执行后缀 / MSYS 路径 /
        │                           强杀进程 / 库搜索路径 / md5 命令与输出格式
        │                           一律在调用时读 $*DISTRO,故测试能用
        │                           `my $*DISTRO = Distro.new(:name('linux'))`
        │                           遮蔽,在任意机器上把三个平台的分支真跑一遍
        │
        ├── RakuPM::NativeLib       系统本地库(:from<native>)探测
        │                           逻辑名 → 各系统文件名 + 搜索路径 + 安装命令
        │                           (平台知识全部委托给 RakuPM::Platform)
        │
        ├── RakuPM::FileLock        跨进程文件锁(flock):写命令串行化
        │                           可重入、超时报错、进程被杀自动释放
        │
        ├── RakuPM::Cleaner         缓存回收(clean / gc):判定哪些版本还在被用
        │                           默认只试算,--yes 才真删
        │
        └── RakuPM::Lock            锁文件(精确版本 + 指纹 + native-libs,
                                    保证可复现)

(下面这个不在 Client 树里 —— CLI 直接调用它,与安装目标无关)
RakuPM::Author            生产侧(作者侧):面向【一个发行版目录】
        └── refresh               以磁盘为准重建 META6.json 的 provides

RakuPM::Client 本身再按职责拆成 5 个 role(SelfManager / Tester / Git / Query / Flusher),通过 does 混入同一个类,避免「编排器」膨胀成上千行的上帝类; 拆分后对外行为完全不变。

启动耗时:实测构成与「评估后不做」的项(0.79.1)

启动有多快、为什么快,以及哪些优化被评估后否决 —— 一并记在这里,避免日后重复论证。 同机实测(raku -Ilib -e …,7 轮取最小值):

环节耗时可消除?
raku 解释器启动(raku -e 1 基线)0.148s否
加载第一个 precomp 模块+0.20s(固定成本)否 —— CLI 必须加载
之后每多加载一个模块+0.011s / 个视是否需要而定
RakuPM::CLI 全量(含 10 个依赖)0.379s(+0.231s)—
RakuPM::Client 整条重模块图比 CLI 单独多 ~80ms见下

关键结论:precomp 模块的加载成本是「一次固定成本 + 每模块极小增量」,与模块行数无关。 RakuPM::Installer(1205 行)与 RakuPM::Net(零依赖小模块)单独加载耗时几乎相同 (0.395s vs 0.342s)——「模块大所以慢」的直觉在这里不成立,真正贵的是模块加载机制 本身。这也是 0.79.0 那次重构有效的真正原因:它省的不是「代码多」,而是把 bin 从 不可能被缓存变成可缓存。

慢命令的真正大头:索引加载(0.84.2 实测 + 定点修复)

上面那节解释的是「每个命令都要付的 0.4s」,解释不了「为什么 search 要 12 秒」。 0.84.2 实测(同机,min-of-35):

命令修复前修复后
version / list / installed(不读索引)0.97 / 1.10 / 1.58s不变
info X --offline11.12s6.64s
search X --offline12.03s7.71s

分解(rea 单仓库):refresh 5.86s ≈ 2.4s 解析(白费)+ 2.4s 解析(有效)

  • 0.44s 建索引 + 杂项 —— 而真正的匹配只要 0.36s(零命中与满命中的关键词耗时相同, 可见算法从来不是瓶颈)。瓶颈是每个命令都把 40MB 索引读进来解析 (rea 23.6 + zef 12.6 + cpan 2.6 MB)。

修复:Ecosystem.!read-fetched-at 原先 slurp + from-json 整个索引,只为读一个 _rakupm_t 时间戳;紧接着 !load-rows 又把同一份文件完整解析一遍。改成只扫 头尾各 4KB(时间戳总落在最前或最后几个字节:rea / cpan 在偏移 5,zef 在距末尾 24 字节 —— 两种键序都在用,故头尾都扫),解码用 latin-1(截断多字节字符也不抛错)。

仍然剩下的成本(如实记录,暂不做):剩下的 ~6.6s 里约 4.2s 是「解析 40MB JSON」 本身 —— 这是必要成本,因为 %!by-nv / %!provides 要的是全行。想再降只能改变 缓存形态:refresh 时额外落一份「查询用精简索引」(只留 name / version / auth / description 等搜索与信息需要的字段,预计 40MB → 数 MB)。代价是多一套缓存格式 + 失效 逻辑 + 旧缓存迁移,收益约 3.5s —— 评估后暂不做:复杂度换时间,与「要自行车不要 摩托车」的取舍原则冲突。

又一处:verify 的内容校验(0.84.3 → 0.86.0,三步迭代)

verify 19.3s 里 17.6s 花在逐文件算 MD5(33 个已装条目 / 296 个文件 / 2.1 MB)。 这条优化的过程本身就是「先测、再改」的样本 —— 每一步都推翻了上一步的前提:

  1. 0.84.3:修一条静默失效的加速路径。 当时有「≥64KB 走系统 md5 命令」这一层 (Windows certutil -hashfile / Linux md5sum / macOS md5 -q)。实测它在中文 Windows 上从未生效:certutil 输出是 GBK(MD5 \xb5\xc4 … 哈希:), $proc.out.slurp(:close) 按 UTF-8 解码抛 "Malformed UTF-8"、被外层 try 吞掉 → 返回空 → 回退纯 Raku。于是每个大文件白起一次进程(0.25s)再跑一遍纯 Raku (331KB 实测 2.07s),比纯 Raku 还慢。改成按字节读(:bin)+ latin-1 解码后: 331KB 文件 2.5s → 0.25s,verify 19.3s → 13.5s。

  2. 0.85.0:少算(清单旁车)。 并行被实测否掉(见下),于是改在「内容真的变过」时 才算:每个 store 条目根目录存 .files.json(与 .digest / build.json 同类的 「旁车」,不计入内容指纹、也不会被 Installer 带进安装点),记下每文件的 (size, mtime, md5);size + mtime 未变的文件直接复用上次的 md5。verify 首次 ~13s、 之后 1.8s。取舍明说:「改了内容、又把 size 与 mtime 都保持原样」的改动查不出来; 要无条件全量哈希设 RAKUPM_VERIFY_NOCACHE=1。

  3. 0.86.0:换实现(原生绑定)—— 这才是正解。 前两步都还在「纯 Raku 0.16 MB/s」 这个前提里做文章,而根子上该做的是把 CPU 密集的活交给原生代码:NativeCall 绑 系统 libcrypto 的 MD5(),同一批文件实测 0.246s(比纯 Raku 快 44 倍,实测 RFC 向量与纯 Raku 逐位一致)。verify 强制全量哈希 11.7s → 2.86s(此时剩下的 成本已主要是 ~1s 的 CLI/Client 启动与读文件,哈希不再是瓶颈)。 库名跨平台探测(libcrypto / libcrypto.so.3 / .so.1.1 / libcrypto.dylib …)+ 首次调用用 RFC 向量自校验 + 纯 Raku 回退;「系统命令」那一层随之删掉 —— 有原生 绑定后它完全多余,而它带来的平台特例(三种输出格式、平台命令名)与 GBK 那类坑却是 真实成本。少一条路径 = 少一类只在某个平台上才暴露的 bug。

并行:实测无效,记档以免重试。 把 296 个文件读进内存后纯算 MD5,8 线程只有 1.18x(10.97s → 9.27s,且结果与串行逐位一致)—— 纯 Raku 的哈希计算在 MoarVM 上 不能真并行。(对照:并行解析索引 JSON 有效(4.32s → 3.31s),因为那条含 IO。) 多进程同样不行:每个 raku 子进程启动约 1s,8 个的启动成本就吃掉了全部收益。 结论:CPU 密集的活只有两个真出路 —— 换原生实现,或减少计算量(清单 / 缓存)。

评估后【不做】:拆开 / 惰性化 RakuPM::Client 的重模块图

设想:让读命令(list / installed / which / env / lock / outdated / doctor) 不加载 Installer / Builder / Tester。结论是不做,理由三条:

  1. 收益上限只有 ~80ms(0.231s → 0.292s 的差),且只对纯查询命令有意义;对 install 类(秒~分钟级)是 0.1% 量级的噪声。

  2. 代价与收益不对称:Client 的属性带编译期类型约束 (has RakuPM::Installer $.installer;)、在 TWEAK 里 new 出 Installer / Builder / Store / Lock、并由 5 个 role 组合。要真做到「读命令不加载」, 必须去掉类型约束 + 改成惰性访问器 —— 等于拿掉项目里最安全攸关的那个类 (整树事务化安装)的编译期保证。

  3. 与 0.79.0 有性质差别:那次有架构性收益(bin 根本享受不到 precomp);这次没有 —— Client 本来就是惰性 require(CLI:513)+ 已 precomp,拆它只是把已经便宜的东西 拆得更碎。

顺带排除的一个可疑项:CLI 顶层 use JSON::Fast(全文只为 raku-pm version 读 META6.json 用一次)看着像能省,但 RakuPM::Spec 已经 use JSON::Fast —— 传递引入不可避免,实测边际成本只有 0.020s。

剩下的启动开销 = raku 启动 + 首次模块加载的固定成本,属不可消除项。

HTTP 层:单后端(curl)

网络请求全部走 RakuPM::HTTP 门面,后端实现 RakuPM::HTTP::Backend 这个 role:

后端底层说明
RakuPM::HTTP::TinyishHTTP::Tinyish(系统 curl)唯一后端。TLS / 代理(含 HTTPS 走代理的 CONNECT)最稳,timeout 会变成 curl 的 --max-time

【0.82.0 起单后端】曾同时带一个纯 Raku 的 HTTP::Tiny 兜底后端 + 自动回退顺序 + 可插拔注册表(RAKUPM_HTTP_BACKEND / register-backend),现已整体移除 —— 那份 「插件化」对单机教学场景收益低、维护面大,且 post-form / post-json(上传 / 登录) 本来就是直接调 curl,统一到 curl 一条路径反而更简单。测试若需替身,用构造器 :backend 注入一个实现了 RakuPM::HTTP::Backend 的假后端即可(见 t/http-debug.t)。

写 / 改后端时两个已经踩过的坑,务必避开:

  • 请求头要传给 .get(),不能塞进 .new()。HTTP::Tinyish 的构造选项叫 default-headers,.new(headers => …) 会被静默丢弃 —— 曾经因此 If-None-Match 从未真正发出,索引的 ETag / 304 增量更新(TTL 过期后的条件 GET)完全空转, 每次都全量重下 10~18MB。

  • 超时选项名:Tinyish 是 timeout(不是 max-time)。写错等于没设超时, 链路一停流就无限挂(Windows 与 WSL 表现一致,与平台无关)。

安装流程(五阶段)

解析 resolve → 抓取 fetch → 构建 build → 入存储 store → 测试 test → 激活 install → 写锁 lock
  1. resolve:在各仓库里查找能提供目标模块的发行版,按约束挑最高版本, 递归展开依赖成一棵树,再做拓扑排序得到「依赖优先」的自底向上顺序。

  2. fetch:本地仓库直接指向目录;生态仓库按索引里的 path 拼 URL, 用 HTTP 下载 tar 包并解压到 cache/dist/<发行版>/<版本>/。

  3. build:包里有 Build.pm(约定 class Build 的 build 方法)或 META6 写了 "builder" 字段(如 Distribution::Builder::MakeFromJSON)时, 在发行版根目录起 raku 子进程执行构建,返回真才算成功;build-depends (builder 类通常是生态里的独立包)会在构建前自动解析安装; --no-build 可整段跳过。native 包常用这一步编译 C 源生成 resources/libraries/*.so。

  4. store:源码目录入库(内容指纹 MD5),一次下载可反复部署。 Build 生成的资源此刻随包复制进 store,装完后 %?RESOURCES 才取得到。

  5. test:在源码目录上跑 t/*.t 与 t/*.rakutest(-I lib -I inst#<target>/site, 依赖此时已装进 CUR::Installation)。任一用例失败即中止安装,--no-test 可跳过。

  6. install:交给 CompUnit::Repository::Installation 装进 target/site/。 它默认 :precompile,预编译由 Rakudo 自己管 —— 早期那套「跑 raku -M<mod> 把模块预编译进平铺 lib/」的做法已删除(既慢又缺版本元数据)。

  7. lock:写入精确版本 + 指纹。

  8. 世代(原子安装):上面第 3~7 步整体包在一个事务里 —— 任何一个包失败, target 会被恢复成本次操作前的样子;全部成功才落一个世代,可随时 raku-pm rollback 退回。详见下面《原子安装与世代回滚》。

git 安装走同一条流水线,只是第 1、2 步的「目标包」换成 git clone 出来的目录: resolve 时用 META6.json 里的 depends 当一组根递归解析(见下节), fetch 时目标包跳过下载、直接用 clone 目录,其余步骤逐字相同。

原子安装与世代回滚

一次 install 要么完全生效、要么完全不生效,不留「装了一半」的坏状态:

raku-pm install A          # A 依赖 B → 顺序 [B, A]
  B 装进 CUR ✓
  A 测试失败 ✗
  → 回滚:把 B 也摘掉,target 回到执行前
raku-pm generations     # 列出所有世代
raku-pm rollback        # 退回上一世代
raku-pm rollback 000003 # 退回指定世代

为什么世代这么便宜:CompUnit::Repository::Installation 天然支持多版本并存, 装新版本不会抹掉旧版本。所以「回滚」= 把本次装进去的版本从 CUR 摘掉 + 把 账本 installed.json 恢复成事务前那份,旧版本本来就还在 CUR 里 —— 世代因此 只是一份几 KB 的 JSON,不需要复制几百 MB 的 site/。

布局:

target/
├── site/                    当前生效的 CUR::Installation
├── installed.json           当前账本(世代的 manifest 就是它的快照)
└── generations/
    ├── current              当前世代 id
    ├── 000001/
    │   ├── before.json      事务前的账本快照(回滚的真值来源)
    │   ├── manifest.json    提交后的账本(= 这个世代长什么样)
    │   └── meta.json        时间、本次装了什么
    └── 000002/…

保留最近 10 个世代,当前世代永不裁剪。

边界(诚实声明):

  • build / test 发生在源码目录,不碰 target,本来就是原子的;事务保护的是 「激活进 target CUR + 写账本 + 放 bin」这一段。

  • 回滚到某个世代时,若对应版本的源码已被 raku-pm clean 从 store 清掉, 会警告并跳过(装不回来)。所以别在需要回滚前激进 gc。

  • 同版本 --force 重装被回滚时,CUR 里那份会被摘掉,此时若 store 里还有该 版本会自动装回,不会出现「账本说装了、实际加载不到」。

反向依赖与依赖回收

依赖声明写的是模块名("depends": { "Foo::Bar" => "1.0" }),而账本记的是 发行版名,两者靠 provides 打通,于是得到一张依赖图;反向依赖就是它的反向边。

raku-pm rdepends Foo          # 反查谁依赖了 Foo(也可写它提供的模块名)
raku-pm uninstall Foo         # 还有包依赖它时【拒绝】卸载
raku-pm uninstall Foo --recursive   # 把依赖它的包也一并卸掉
raku-pm uninstall Foo --force       # 强卸(会留下坏掉的依赖,自己负责)
raku-pm autoremove            # 回收不再被依赖的包
raku-pm autoremove --dry      # 只看不动

uninstall 之后会顺带提示「以下包不再被任何已装包依赖」,例如卸掉 Top 之后:

以下 1 个包不再被任何已装包依赖(当初是作为依赖装进来的):
  · Mid 1.0
  raku-pm autoremove        可以把它们一起清掉

为什么不会乱删:账本给每个包记了 reason ——

reason含义会被 autoremove 回收吗
explicit用户点名装的(@order 里最后一个,即本次目标包)永远不回收
dependency作为依赖被拉进来的没人再依赖它时回收

老账本没有 reason 字段时按 explicit 处理——宁可不回收,也不能把用户自己装的东西 悄悄删掉。这是 apt 的 auto/manual 标记同款用意:没有这个区分,「自动回收」就变成「乱删」。

autoremove 会循环到收敛:卸掉 Mid 可能让只被 Mid 依赖的 Leaf 变成新孤儿, 一并處理。

校验:内容完整性 + 安装一致性

raku-pm verify              # 两段都跑
raku-pm verify --content    # 只看内容完整性(旧行为)
raku-pm verify --fix        # 顺便修能修的

① 内容完整性 —— store 里的源码有没有被改动(覆盖 versions[] 全部版本, 不是只查激活版本)。

② 安装一致性 —— 账本 / CUR / store / resources 四者对不对得上。这一段是后来 加的:只做 ① 会给人虚假安全感——GDBM 那次 CUR 已经半残(site/dist/<id> 丢了), 而 store 副本完好无损,① 照样报「全部正常」。

问题含义能自动修吗
账本有、CUR 没有dist 条目损坏那类半截状态否,需重装
账本有、store 没有回滚/重装装不回来否,需重装
CUR 有、账本没记影响 installed / 依赖回收 / 世代回滚✅ 补记账本
声明过的资源文件没了构建产物被删否,需重装
构建后资源仍没生成构建没跑成功(0.44.0 起可查)否,需重装
声明了 builder 却没有构建痕迹旧版 raku-pm 装的,无从确认否,需重装
用 --no-build 装进去的主动跳过了构建,但包声明了 builder否,需重装
构建失败却仍被入库不应发生否,需重装

构建痕迹:build.json 旁车(0.44.0 起)

store 每个条目多一个 build.json,记下构建阶段到底发生了什么:mode (built / skipped-no-build / not-needed / not-recorded)、成功与否、 用的哪个 raku(编译器发行版号,便于排查预编译/ABI 不一致)、以及 声明过却没生成的那些资源。

它补的正是下面这条原本的诚实边界:只声明磁盘上真实存在的资源是 Store 的必然 取舍(声明了却找不到会让 CUR::Installation 装不上),于是「构建没跑成功 → 资源没生成」在 store 的 META6.json 里被静默剔除,事后看那份元数据干干净净, 查无可查。build.json 把「被剔掉的是哪些」原样记下来,verify 才第一次能报出来。

它刻意不计入内容指纹(!fingerprint 里与 .digest 同级跳过):它是「条目 怎么产出的」的记录,不是发行版内容;带时间戳,计入指纹会让每次重算都漂移、 误报「内容已被修改」。

仍然查不出来的一类:资源压根没在任何 META6 里声明过(上游打包就没声明)—— 那种情况连「该有什么」都不知道,不假装能查。

仓库:可配置的数组

Raku 世界不止一个生态索引,而且收录范围差别很大(2026-09 实测):

别名索引 URL记录数唯一发行版特点
zefhttps://360.zef.pm79711617当前版本,官方生态
rea…/Raku/REA/main/META.json150542538生态存档,含历史版本与已下架包
cpan…/Perl6-ecosystems/master/cpan1.json1916—CPAN 上的 Perl6 模块
p6c…/Perl6-ecosystems/master/p6c1.json——旧生态存档

有 922 个发行版只在 REA 里有、zef 索引里没有,所以单个索引装不全所有包—— 仓库必须是数组。

配置来源优先级(高 → 低):

  1. $RAKUPM_TARGET/repositories.json(repos add/remove 维护,持久化)

  2. RAKUPM_ECOSYSTEM 环境变量,支持逗号/分号分隔多个,每项可以是 URL 或上表别名

  3. 默认:zef 索引 + 本地 $RAKUPM_REPO 目录

一个容易踩的坑:RAKUPM_REPO 是调用级设置(相当于"这次在哪个目录找本地包"), 即使配置文件里已经存了 local 的路径,命令行显式指定时也会覆盖它。而 RAKUPM_ECOSYSTEM 是持久配置的兜底,只有配置文件不存在时才生效。 (否则 RAKUPM_REPO=./vendor raku-pm install Foo 会静默用回上次存下的路径, 表现为"明明把包放进去了却找不到"。)

raku -Ilib bin/raku-pm.raku repos                 # 列出已配置的仓库
raku -Ilib bin/raku-pm.raku repos add rea          # 加生态存档(别名)
raku -Ilib bin/raku-pm.raku repos add https://example.com/eco.json   # 或完整 URL
raku -Ilib bin/raku-pm.raku repos remove rea

# 也可以不落盘,临时指定多个索引
RAKUPM_ECOSYSTEM='https://360.zef.pm,rea' raku -Ilib bin/raku-pm.raku install ADT

本地目录仓库(不需要任何索引文件)

一个目录下挂多个「发行版子目录」,每个子目录里放 META6.json + lib/ 即被收录:

~/my-dists/
├── HTTP-Client/{META6.json, lib/...}
└── Web-App/{META6.json, lib/...}
# 给的是目录 → 自动识别为本地仓库(名字默认取目录名,可用 --name= 指定,存绝对路径)
raku -Ilib bin/raku-pm.raku repos add ~/my-dists
# 往里填内容:取源码(只下载不安装),或自己把包目录放/软链进去
raku -Ilib bin/raku-pm.raku fetch HTTP::Client --to=~/my-dists
raku -Ilib bin/raku-pm.raku repos remove ~/my-dists      # 按目录/名字都能删

之后 install / search / 依赖解析都会认这个仓库里的包。

两点要分清:

  • 本地仓库是源码来源,不是已安装的包 —— 已装的在 $RAKUPM_TARGET/site/;

  • 单个正在开发的包不必建仓库,直接 raku-pm install ./my-pkg 即可(会自动装依赖)。 建仓库的价值在于「多个本地包 + 按名安装 + 依赖能被自动解析」。

安装前缀

--target=<路径> 指定安装前缀(一个前缀 = 一份独立的包环境),优先于环境变量 RAKUPM_TARGET,默认 ~/.raku-pm。写在命令前或后都行,两种位置等价:

raku -Ilib bin/raku-pm.raku install Foo --target=./vendor
raku -Ilib bin/raku-pm.raku --target=./vendor install Foo    # 等价

路径里的前导 ~ 会被展开:--target=~/myenv 是家目录下的 myenv, 不是当前目录下一个叫 ~ 的目录。(Rakudo 自己不展开 IO::Path 里的 ~, 这一步是 raku-pm 补上的。)

前缀自洽:<前缀>/bin/ 里的 wrapper 会把前缀显式传给 CLI —— 敲哪个前缀的 wrapper 就在哪个前缀里干活,跟 venv 的 <venv>/bin/python 是同一个语义。 所以「用哪个环境」= 「用哪个 wrapper」/「PATH 里哪个 bin 排在前面」。

要不要写全局 site/bin:装完默认会把 wrapper 也复制进 <rakudo>/share/perl6/site/bin(zef 用的那个目录、天然在 PATH 上),这样装完命令 立刻能用。但这个默认只对默认前缀生效;自定义前缀不写 —— 全局 site/bin 是整机 共享的,局部安装写进去会污染全局环境,多个前缀之间还会互相覆盖:

前缀默认行为想改用
默认 ~/.raku-pm写全局 site/bin(zef 同款:装完即用)--no-promote-bin
自定义 <路径>不写(只动前缀内部)--promote-bin

自定义前缀下装完会明确提示该配什么。要让它生效就是两行:

export RAKULIB="inst#<前缀>/site"     # 模块搜索路径(inst# 前缀不能省)
export PATH="<前缀>/bin:$PATH"        # 命令搜索路径(放最前)

raku-pm env 会按当前前缀直接把这两行打印出来。

为什么 bin 要放 PATH 最前:跨目录是「PATH 顺序」说了算,只有同一个目录内部 才轮到 PATHEXT 比扩展名。所以把 <前缀>/bin 放最前就能赢过 zef 目录里的 <命令名>.exe —— 不必去改别人的文件。

推荐做法:完全不写前缀外的文件(PATH 模式)

上面的默认行为是往全局 site/bin 写一份副本(zef 同款)。如果你更希望 「raku-pm 只在它自己那个目录里留下东西」,把默认前缀也切到 PATH 模式:

# ① 先把前缀自己的 bin 放到 PATH 最前(写进 shell 配置里长期生效)
export RAKULIB="inst#$HOME/.raku-pm/site"
export PATH="$HOME/.raku-pm/bin:$PATH"      # ← 顺序关键,必须在 rakudo site/bin 之前

# ② 以后升级/安装都不再往全局 site/bin 写(self-upgrade 也认这个旗标)
raku-pm self-upgrade --no-promote-bin

# ③ 可选:清掉此前写进全局 site/bin 的那三份副本
#    位置:<rakudo>/share/perl6/site/bin/ 下的 raku-pm、raku-pm.bat、raku-pm.ps1
#    先确认 ① 已生效(`raku-pm env` 列出的 [0] 指向 <前缀>/bin)再删,否则会没有入口。

三个好处:① 前缀外不留任何文件,升级或重装 rakudo 都带不走它; ② 不必改动别的包管理器留下的文件(0.48.0 的「入口体检」改名只是补救手段); ③ 撤销只要改 PATH,不留残渣。代价是自己配一次上面那两行 —— raku-pm env 会按当前前缀直接把这两行打印出来。

多版本 / 多项目共存:一份前缀就是一份独立环境,互不干扰:

raku-pm --target=./projA/.raku-env install Foo    # A 项目自己的环境
raku-pm --target=./projB/.raku-env install Foo    # B 项目的,版本可以不同

自定义前缀默认就是 PATH 模式(不写全局 site/bin),且 <前缀>/bin/ 里的 wrapper 会把前缀显式传给 CLI —— 敲哪个前缀的 wrapper 就在哪个前缀里干活。

⚠ 全局 RAKULIB 对「开发 raku-pm 本身」是个陷阱

上面建议把 RAKULIB 写进 shell 配置 —— 对用包是对的(裸 raku 也能 use Foo)。 但请注意它的副作用:裸 raku 从此也会加载 <前缀>/site 里那份 RakuPM 自己。 如果那份是旧的,你在仓库里跑测试或脚本时就会「以为测的是工作树、其实测的是旧版」—— 症状是旧版的 bug 被当成当前代码的失败报出来,方向完全跑偏 (0.49.3 就是因为这个查了半天;t/ 与 xt/ 里没写 use lib 的文件会这样)。

自保三条:

  • 跑测试只用 raku tools/run-suite.raku:它给每个测试传 -I<工作树>/lib, 并在跑之前自检链首确实是工作树,不是就直接中止(不产生假绿); 除跑测试外它还带三道静态闸门:具名实参检查、文档纪律校验(版本号三处一致 / provides 完整性 / bin / Changes)、个人痕迹检查(会进发布包的内容里不该出现本机 用户名或绝对路径 —— 这类泄漏会「换个写法复发」,故钉成闸门;--docs-only 秒级可跑);

  • 单独跑某个文件用 raku -Ilib t/<x>.t(-I 永远排在 RAKULIB 之前);

  • 想知道「此刻裸 raku 会用哪一份」:raku-pm which RakuPM::Version (链上多份时会把被遮蔽的都列出来);

  • raku-pm 自己启动也会自检:加载链上出现多个不同版本的 RakuPM 时,会在 stderr 打印 ⚠ 加载链上检测到多个 RakuPM 版本,提示实际加载的未必是以为的那份 (0.54.1 起;纯警告、零代价,不影响加载行为)。

清掉前缀里那些过期的 RakuPM(用命令、别手删):

raku-pm installed RakuPM              # 看前缀里有哪些版本,* 是不指定版本时 use 拿到的那个
raku-pm self-upgrade                  # 让前缀里有一份当前的(最高版本会被优先选中)
raku-pm uninstall RakuPM --version=0.36.14   # 确定不要的旧版再逐个摘掉

输出示例:

已配置的仓库(共 3 个,按优先级从上到下):
  [0] zef  远程索引  https://360.zef.pm
  [1] rea  远程索引  https://raw.githubusercontent.com/Raku/REA/main/META.json
  [2] local  本地目录  .

多索引如何协同:解析时把各索引的候选版本合并,再挑满足约束的最高版本, 所以后加的 rea / cpan 不会覆盖 zef 的新版本。repos add 会把新仓库插到最后一个 远程索引之后(local 之前),保持主索引 zef 在最前。找不到包时报错会直接提示 该加哪个索引:

在所有已配置的仓库里都找不到模块 'ADT'。
  已配置的仓库:eco:zef:https://360.zef.pm, local:.
  提示:
    · 用 `raku-pm repos list` 看当前挂了哪些仓库;
    · 用 `raku-pm repos add rea` 加上 Raku 生态存档(收录更全,含历史版本);

某个索引拉不动时怎么办(0.49.5 起统一为「降级 + 告警」,不再整条命令失败): 解析器会把每个仓库都问一遍,所以只要有一个索引不可达,以前就会让整条命令崩 —— 哪怕答案本来就在可达的仓库里(比如本地目录仓库)。现在分两种情形处理,都不阻断:

情形行为
拉取失败,但本地有旧缓存用旧缓存继续,提示「可能已过期,跑 raku-pm repos update 可强制刷新」
拉取失败,且从未缓存过本次把它当空索引继续,提示「结果可能不完整」

两种情形都会打印一行 ⚠,点明是哪个索引、哪个 URL。所以如果你看到 「找不到模块」紧挨着几行索引 ⚠,那就是「不是没有,是我这次看不到」—— 配好网络/代理后重试,或 raku-pm repos remove 掉用不到的索引。 它不会静默降级:宁可多打一行警告,也不让你以为「真的没有这个包」。 (国内网络常见:raw.githubusercontent.com 上的 cpan / rea 连不上, 主索引 360.zef.pm 正常 —— 这时仍能正常安装 zef 索引里的包。)

各索引的字段差异

索引记录大体同构(name / version / provides / depends),但两处关键差异要处理:

  • tar 包地址:360.zef.pm 给的是相对 path(要拼主机),REA 与 cpan 给的是 绝对 source-url。代码里 !tarball-url 优先用 source-url。

  • auth 字段:REA 有些记录顶层没有 auth,身份写在 dist 串里 (ADT:ver<0.5>:auth<github:timo>)。代码里 !auth-of 会回退去解析 dist。

另外索引缓存与解压目录都按仓库名隔离:rea 和 cpan 两个索引都挂在 raw.githubusercontent.com 上(按 host 命名缓存会互相覆盖), 而同一个发行版+版本也可能同时存在于多个索引(按仓库隔离解压结果)。

本地目录布局

$RAKUPM_TARGET/                   默认 ~/.raku-pm
├── cache/                        生态索引 + 下载的 tar 包与解压目录
│   ├── index-360.zef.pm.json
│   └── dist/<发行版>/<版本>/
├── store/                        「下载下来」的包的家
│   └── <发行版名>/<版本>/          多版本共存
│       ├── META.json             raku-pm 自己的规范化元数据
│       ├── META6.json            Raku 标准元数据(CUR::Installation 只认这个)
│       ├── lib/                  源码
│       ├── resources/            资源(native 包的 .so/.dll 构建产物在这里)
│       ├── build.json            构建痕迹旁车(0.44.0 起;不计入内容指纹)
│       └── .digest               内容指纹(MD5,用于防篡改)
├── site/                         ★ 模块搜索路径:CompUnit::Repository::Installation
│   ├── precomp/                  Rakudo 管的预编译产物
│   └── dist/                     按发行版存的元数据(多版本并存)
├── installed.json                raku-pm 自己的已安装清单
└── raku-pm.lock                  锁文件

为什么分 store 和 site 两层(仿 cargo 的 registry/cache + src):

  • store/ 是缓存,一次下载可反复部署,卸载后重装无需再访问仓库;

  • site/ 是运行环境,交给 Raku 官方的 CompUnit::Repository::Installation 管理(zef 装包用的就是它),多版本由它按元数据解析。

早期版本这里是个平铺的 lib/*.rakumod 目录。那样做是错的——平铺目录 没有任何版本元数据,use Foo:ver<1.1.0> 会静默加载实际存在的 2.0.0。 详见下一节。

路径安全不变式:外来字符串进路径前必须净化(0.87.0)

上面每个 <发行版名>/<版本> 都是路径分量。而这两个值来自包自己的 META6.json / 生态索引行——即由发布者控制,属于不可信输入。

修复前 Store 里只做 subst('::','-')(旧私有方法叫 !safe-name,但只处理模块 分隔符、不处理路径结构字符),同一条路径的 $version 连这层都没有。于是 "name": "../../../../../../tmp/PWNED" 会把整个 store 条目写到 target 之外 ——「发布一个包就能往用户机器任意位置写文件」。

不变式(改代码时别绕开):

  1. 任何把外来字符串拼进路径的地方,组件必须先过 RakuPM::Fs.path-component —— 全项目唯一的净化入口。它内部把输入分成两类,处理方式刻意不同 (0.87.4 明确划线,别把两类混起来):

    • 穿越类(/ \、恰为 . / ..、控制字符、结尾点)→ 直接 die。 它们要么能逃出目标目录,要么会让两个不同名字落到同一处。静默把 ../x 改成 x 会让不同发行版撞进同一目录(互相覆盖),比拒绝更危险。

    • Windows 保留类(* ? " < > |)→ 百分号编码 (* → %2A……,% 自身编成 %25 以保证单射)。它们拼不出目标之外的 路径,只是让文件名在 Windows 上非法:旧行为在 Windows 上报 Invalid argument (重试 3 次后回滚,报错完全看不出真因),在其它平台则造出一个名字就是 * 的垃圾文件(还会被 shell 的 glob 展开)。实测受影响的是真实索引里 version 为 占位符 * 的 109 条(? " < > | % 各 0 条)—— 编码让它们 在 Windows 上也能装(端到端验过:装完 use 得到)。 为什么不"替换成 _":那会与真实存在的 _ 撞车,单射性是这里唯一的硬约束。

    • :: 与单个 : 归一到 -(后者在 Windows 上是 ADS 分隔符)。

  2. 落盘前再用 RakuPM::Fs.assert-under 断言"路径在根之内"。这是第二道防线 ——入口会随代码演进而增多,漏做一处就立刻变成明确报错,而不是一次静默的越界写入。

  3. 当前需净化的 7 处:① Store.path-for(name + version);② Client/Tester 的测试日志目录;③ Repository/Ecosystem 的下载缓存(索引行可控,发生在 store 之前); ④ Installer 的临时文件名;⑤ Author 的归档名 / 发布落点;⑥ Client/Git.!fetch-source 的 fetch --to 输出目录;⑦ Client/Git.!git-cache-key 的 git 缓存目录名(0.88.6 补 —— 它是唯一"整条路径直接拼"的:地址可来自 CLI、self-upgrade --from,以及恶意发行版 META6.json 里的 git-deps,后者用户完全无感)。(另有 Author.new 一处冗余 防御:那里的模块名已被正则校验过,留着是为了日后有人放宽那个正则时不至于静默打开 一个穿越。) 加一处新路径 = 加一处净化。

    ★ assert-under 对 .. 穿越天然失效(0.88.6 实测后写明):它比的是字面前缀 ($child.absolute 以 $root.absolute ~ sep 开头),而含 .. 的路径字面上确实"在 下面";再加上 Raku 的 IO::Path 不折叠 ..(.absolute 与 .cleanup 都原样 保留,真正解析 .. 的是内核)—— 于是这类检查看起来完全正常。 结论:净化必须在生成路径时做,事后断言补不上;上面第 2 条只是"防止以后有人绕过 净化直接拼路径"的兜底。⑦ 用栈式规范化实现(.. 上溯一层、已出界则丢弃), 而不是"见到 .. 就 die" —— 远程 URL 里 .. 非法,但本地路径 (self-upgrade --from ../repo)里它完全合法,一刀切会把正常用法打死。

回归守卫:t/path-safety.t(含端到端断言"按旧实现推算的逃逸落点必须不存在", 以及"合法名必须确实装成"的正控——避免安装链没走到拼路径那一步造成假绿; 另含保留字符的编码矩阵与单射性断言,后者专门防"替换成 _"那类非单射写法)。

另注:归档成员名的穿越(tar 里写 ../x)不需要我们防——GNU tar 与 Windows 自带 BSD tar 都拒绝内嵌 ..、剥掉前导 / 与盘符(实测确认)。但那属于"依赖外部 工具的默认行为",故上面的净化针对的是我们自己拼的路径,两者互补。 这个前提由 xt/tar-member-escape.t 钉住(变异验证:给解压命令加上 -P 之后,文件 立刻落到目标目录之外、测试当场变红)—— 正因为它是"外部工具的默认行为",才更需要有 人守着:将来为了让某个包能装而加上 -P,不会有任何别的测试发现。

版本管理

  • 多版本共存:store 里可同时有 JSON/1.0.0 与 JSON/2.0.0;装进 site/ 后两个版本都能被 use 到,靠 :ver<> 精确选。

  • 指定版本:install JSON --version=1.0.0(降级也是这个命令)。

  • 升级:upgrade JSON 升到仓库中最高版本;upgrade 不带包名 = 升级所有 已装包(同 zef upgrade / apt upgrade 的惯例)。

  • 切到指定版本:upgrade JSON --version=1.0.0。

  • 降级的坑(重要):Raku 的 use JSON(不带版本约束)永远加载最高版本, 这是 Rakudo CUR::Installation 的行为,raku-pm 改不了。所以装一个更旧的版本 默认不会让 use 切过去,只会多一份并存 —— 此时命令会明确提示你。 要让旧版真的生效:upgrade JSON --version=1.0.0 --only(先卸掉更高的版本), 或在代码里写 use JSON:ver<1.0.0>。

  • 按版本卸载:uninstall JSON --version=1.0.0 只清掉那一个版本,其余保留; 不给 --version 才是整包卸载(所有版本)。

  • 预发布版的比较(0.87.3 起):按 semver 2.0.0 §11 定序 —— 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0 (数字标识符 < 字母数字标识符;同位数字比数值而不是字典序;正式版 > 预发布; +build 不参与)。这套顺序只有一处实现:RakuPM::Version.key(另有 !pre-key 生成预发布段的比较键),cmp / sort-versions / pick-best 全走它。 别退回「把预发布折成一个布尔」的写法:那样 alpha/beta/rc1 的 key 完全相同、 cmp 一律判 Same,于是 pick-best 给出取决于输入顺序的答案(不报错、不崩, 最难查)。实测生态里的 Hey 就会从正确的 1.0.0-beta.9 退化成 1.0.0-beta.2。

  • 卸载成没成是判定的,不是宣告的(0.87.3 起):uninstall 以「CUR 里真的摘掉了 什么」为准(Installer.uninstall 返回被摘版本的列表)。四种「什么都没卸掉」的 情形 —— 包根本没装 / 指定版本不存在 / 范围没匹配到 / 被依赖而拒绝 —— 一律 报错并以 1 退出,不再打印「已卸载」。脚本靠退出码判断成败,谎报成功比不报更糟。

  • 无参数不猜:uninstall / upgrade 的语义不含「默认对 raku-pm 自己动手」—— 卸载自己请显式写 uninstall RakuPM,避免少打一个参数就误删包管理器。

  • 管理 raku-pm 自身:

    • self-upgrade [--from=<git-url>] [--from-dir=<目录>] [--dry] —— 升级 raku-pm 自身。 它不依赖账本是否已记录(raku-pm 很可能是 zef 装的、不在我们自己的账本里, 而 upgrade 一上来就按账本查 installed-version 会直接 die "尚未安装"), 也不依赖是否把 RakuPM 发布到了生态索引(目前没发布,去仓库找必死)。 来源优先级:

      1. --from <url>:从指定 git 远端拉源码升级(覆盖一切);

      2. 当前这份来自某个 git 仓库(开发者自装/自跑)→ git pull 该仓库 + 本地重装;

      3. 否则从默认上游 https://gitee.com/skyter10086/raku-pm.git 拉取升级。 需要本机已装 Git。--dry 只解析、不真正替换。 升级前会比对版本号:本地已是最新(版本一致且 git 提交号也一致)就跳过下载安装; 版本号相同但 git 提交号不同(上游被强推 / 改写历史,内容已变)则照常重装。 --force 跳过所有比对、强制重装。

    • self-remove [--dry] —— 卸载 raku-pm 自身。比 uninstall RakuPM 多清几样东西: ① 从 CUR::Installation 摘掉发行版;② 删 store/RakuPM/(各版本源码快照, 往往占大头);③ 删 target/bin/ 下的 raku-pm / raku-pm.bat wrapper; ④ 清掉账本与锁文件里的 RakuPM 条目。--dry 只报告会删什么、不真删。

    • 注意:如果 zef 也曾装过 raku-pm,那份不在我们管理范围内 —— 卸完 raku-pm 命令可能仍然存在(会跑 zef 装的那份),要一并清掉请用 zef uninstall RakuPM。

  • 版本共存 ≠ 版本冲突:不指定版本时 use 拿到最高的那个;指定 :ver<> 时由 Rakudo 精确匹配。

多版本与 use Foo:ver<1.2.3>

Raku 支持 use Module:ver<1.2.3> 这种写法,但光有目录里有文件是不够的 —— Raku 得知道每份文件的版本号才能做匹配。

平铺 lib/ 是错的(以及它错得多隐蔽)

如果装包只是把 *.rakumod 平铺复制到 target/lib/,那这个目录里 没有任何版本元数据。Raku 的 CompUnit::Repository::FileSystem 只能从文件 路径推出模块名(lib/Foo/Bar.rakumod → Foo::Bar),推不出版本,于是 :ver<> 约束会被无条件满足:

lib/ 里实际是 2.0.0
> use MultiVer:ver<1.1.0>
我是 2.0.0          ← 不报错,静默给了错的版本

这是最糟的一类失败:不报错,只是给你错的。

正确做法:CompUnit::Repository::Installation

zef 装包用的就是它(site / home / vendor 都是这种仓库)。它按「发行版」 存元数据,同一模块的多个版本可以并存,use Foo:ver<1.1.0> 由 Rakudo 自己解析, 挑不到就明确报错。raku-pm 现在把包装进 $RAKUPM_TARGET/site/:

my $cur  = CompUnit::Repository::Installation.new(prefix => $target.add('site'));
my $dist = Distribution::Path.new($store-dir, :meta-file($store-dir.add('META6.json')));
$cur.install($dist, :force);       # :precompile 默认开,预编译由 Rakudo 管

于是:

$ raku-pm install Concurrent::Stack --version=1.1
$ raku-pm install Concurrent::Stack            # 再装个 1.3

$ raku-pm installed
    Concurrent::Stack 1.1
  * Concurrent::Stack 1.3
(* = 不指定版本时 use 会拿到的那个)

$ raku -I"inst#$HOME/.raku-pm/site" -e 'use Concurrent::Stack:ver<1.1>; ...'
$ raku -I"inst#$HOME/.raku-pm/site" -e 'use Concurrent::Stack:ver<9.9>; ...'
===SORRY!=== Could not find Concurrent::Stack:ver<9.9> in: ...

两个关键细节

1. inst# 前缀不能省。 少写它,Rakudo 会把 site/ 当成普通文件仓库, 多版本元数据就白存了。让 raku-pm env 帮你生成:

$ raku-pm env
# bash / zsh / Git Bash:
export RAKULIB="inst#C:\Users\...\.raku-pm\site"
# PowerShell:
$env:RAKULIB = "inst#C:\Users\...\.raku-pm\site"

2. store 里要多写一份标准 META6.json。 CUR::Installation 只认它,且它的 provides 是「模块名 => 相对路径」,比我们自己的 META.json 多一样东西。 麻烦在于 RakuPM::Distribution.provides 只保存了模块名列表 —— 路径在解析生态 索引时就被丢掉了 —— 所以入库时要扫一遍 lib/ 把路径反推回来 (Foo::Bar → lib/Foo/Bar.rakumod;注意 .rakudoc 是文档不是模块,不计), 并且统一用正斜杠(Windows 的反斜杠会让 Distribution::Path 拼错路径)。 provides 里写了但文件不存在的模块会被跳过 —— 写进去只会让安装失败。

模块源扩展名认三种:.rakumod / .pm6 / .pm。这不是我们自定的口径 —— Rakudo 本身就把 .pm 当 Raku 模块加载(会打一条 deprecated 提示,inst# 仓库下 同样能加载),老 Perl 6 时代 .pm 是主流扩展名。这份清单只有一处定义 (RakuPM::Distribution 里的 @MODULE-EXTS;跨模块经 module-extensions 取), 别再往任何地方内联:它原先在 provides-with-paths 与 !walk-source-files 里各写了 一遍、且都漏了 .pm,于是整个 .pm 时代的包都装不上。

3. provides 缺失时按磁盘反推(0.87.1)。 provides 在 META6 里是可选字段, 而生态里真有一批条目缺它。本机全量索引实测 25117 条里缺 146 条 / 53 个发行版, 分布很有说服力:

索引条目数缺 provides说明
zef(现代生态)80600一条不缺 —— 现代打包工具都会写 provides
rea(老 Perl 6 归档)15141138(53 个发行版)手写 META.json 的时代产物,.pm 扩展名 + 无 provides
cpan19168是真正的 Perl 5 发行版(IO-Compress-*、FindBin-libs…),本就不是 Raku 包

也就是说这批缺 provides 的,恰好就是 .pm 时代的老包 —— 两个成因(缺声明 + 认不出 .pm)指向同一批受害者。缺了 provides、又照直写出一份空的标准 META6,就会得到 幽灵安装:

$ raku-pm install Automata::Cellular  → ✓ 完成(rc=0)
$ raku-pm installed                   → 列得出来
$ raku -e 'use Automata::Cellular'    → Could not find …
$ raku-pm verify                      → ✓ 没有不一致        ← 用户拿不到任何线索

所以入库时走 Distribution.provides-from-disk:有声明就用声明(且只保留磁盘上 真有文件的项);声明为空、或声明的模块一个都对不上磁盘,就以 lib/ 为准反推。 lib/ 是发行版自己的内容,比一份缺失/写坏的元数据字段更可信。反推仍拿不到任何 模块、而 lib/ 里确有文件时,Store.put 会拒绝入库(宁可不装,也不写一个 use 不到东西的空条目)——判定刻意放在落盘之前,所以拒绝时连目录都不会建。

这一对反向操作现在都归 RakuPM::Distribution: provides-with-paths(模块名 → 路径)、scan-provides(扫 lib/ → 模块名)、 provides-from-disk(上面那条兜底)。 它们原先是 Store 的私有实现(且只有单向前者),上移后消费侧(入库写 META6.json)与生产侧(refresh / check)共用一份实现。

已经装进机器里的幽灵条目怎么发现:verify / doctor 的第 ⑥ 段检查 (issue kind = ghost-provides)——「store 的标准 META6.json 一个模块都没声明, 而 lib/ 里明明有模块源」。这类修不了(--fix 只补记账本),提示重装即可 (重装会走上面那条反推)。回归守卫在 t/ghost-provides.t(含真装 + 外部 raku 进程 use 的端到端判据,以及「去掉反推 / 去掉 .pm / 重新内联扩展名清单」三种变异)。

与 zef 共存:同名包两个版本怎么办

zef 和 raku-pm 各有各的安装地盘(zef 装进 ~/.raku 和 rakudo 的 site/, raku-pm 装进 $RAKUPM_TARGET/site/),Raku 的多版本机制让它们天然并存、 互不覆盖 —— 同一模块的两个版本可以同时存在,用 :ver<> 精确挑。

仓库链的顺序(实测):

RAKULIB 里的条目(inst#<target>/site ← raku-pm 的包在这)
  → ~/.raku                (zef home)
  → rakudo 的 site / vendor / core

所以只要你 export 了 RAKULIB(raku-pm env 生成),脚本里:

写法加载谁
use Foo;raku-pm 装的那份(RAKULIB 排最前,链上第一个命中)
use Foo:ver<0.1.0>;zef 装的 0.1.0(raku-pm site 没有这个版本,落到下一站)

不设 RAKULIB 则完全看不到 raku-pm 的包,一切照旧走 zef。

排查用 raku-pm version Foo:它把两个管理器装的版本一起列出来, 并标出「激活」的是哪份(不指定版本时 use Foo 实际加载的那个)。

更直接的工具:raku-pm which(0.48.0)。 上面那句「链上第一个命中」听着简单, 但你机器上通常不止两个仓库 —— 实测这台三层都不止:

仓库路径发行版数(实测)
home~/.raku69
rakudo 的 site(zef 装的)<rakudo>/share/perl6/site113
raku-pm 的 site$RAKUPM_TARGET/site12

实测有 24 个发行版存在于多个仓库(其中 HTTP::Tiny、JSON::Fast、MIME::Base64 连版本都不一样)。同时 PATH 上还可能有 home 优先于 site 的效应:不设 RAKULIB 时 use JSON::Fast 命中的是 home 的 0.20.1,而不是 site 的 0.19。

所以别靠推算:

raku-pm which JSON::Fast     # 这个模块实际从哪加载、版本多少;多份全列出
raku-pm which                # 跨仓库冲突总览(哪些包在多个仓库里、各自什么版本)

which 给【模块名】(如 JSON::Fast)而不是发行版名;不带参数时按仓库链序号 列出全部冲突,序号越小越优先。

一个容易混淆的地方:「包重名」和「命令重名」是两件事。上面讲的是前者 (同一个模块装在多个仓库)。后者是 PATH 上有多份 raku-pm 可执行文件 —— Windows 用 PATHEXT 解析,.EXE 优先于 .BAT,于是 zef 时代留下的 <rakudo>/site/bin/raku-pm.exe 会遮蔽 raku-pm 自己写的 bash/.bat wrapper, 表现是「升级成功了,敲 raku-pm version 还是旧版」。0.48.0 起安装/升级会做 入口体检:算出实际会被执行的那一份,若不是 raku-pm 写的就改名备份为 <名字>.zef-old(可逆),并打印说明。raku-pm env 也会把候选按系统实际解析 顺序列出,并标出哪一份真的会被执行。

设计说明:为什么不需要单独的「本地包索引」。三层结构各司其职:

  1. installed.json(账本)——已装包的本地索引:名字、全部版本、provides、 指纹、来源目录(自装/git 的包会记录从哪来)。依赖解析查它(O(1)), installed / version 展示它;

  2. site/ 本身是 CUR::Installation——Rakudo 用 short/+dist/ 维护 自增索引,use 的解析直接用,不需要任何遍历;

  3. store/——源码缓存,任何已装包都能取回完整源码(重装不用重新下载)。

「每次遍历目录」既不必要也不够用:遍历只能发现「目录里有什么」, 而依赖解析要的是「已装了什么、版本是否满足」——这正是账本的职责。 另有一个显式的 local: 仓库(RAKUPM_REPO 指向的目录,布局 repo/包名/META6.json)用于「一堆本地包当仓库用」的场景,按需扫描。

锁文件

raku-pm.lock 记录精确版本 + 内容指纹:

{
  "version": 1,
  "entries": [
    { "name": "JSON", "version": "2.0.0", "auth": "demo:raku",
      "digest": "d47078e2...", "depends": [] }
  ]
}
  • 普通 install 会自动更新锁;

  • --locked 开启严格模式:锁与解析结果不符则直接报错,适合 CI。

可提交锁文件(0.54.0)

锁文件默认写在当前工作目录的 raku-pm.lock(仿 Cargo.lock),所以可以随项目 提交进版本库,实现「换台机器 / 换时间装出完全一样的版本」:

cd my-project
raku-pm install JSON            # 解析后把精确版本钉进 ./raku-pm.lock
git add -f raku-pm.lock         # 提交(默认被 .gitignore 忽略,需 -f 强制加入)
# 队友克隆后:
raku-pm install --locked        # 严格复用锁里的版本;锁缺失 / 漂移直接报错
  • 锁描述的是项目的依赖闭包,与安装位置(--target)无关:即使把包装进 .raku-env 这种独立前缀,锁仍写在项目根 —— 提交的是锁、不是前缀。

  • --lock-file=<路径> 可把锁改到任意位置(CI 里常钉死到仓库固定路径)。 install / reinstall / self-upgrade / lock 都认这个旗标。

  • raku-pm lock 查看当前锁内容。

  • 注意:默认 .gitignore 忽略了 raku-pm.lock(避免开发残留污染),要提交可复现 锁请用 git add -f,或在你项目的 .gitignore 加 !raku-pm.lock 取消忽略。

并发:跨进程文件锁

一次安装是「读 raku-pm.lock → 解析依赖 → 写 store → 装进 site → 回写锁文件」 这一长串写操作,两个 raku-pm 同时跑会互相踩:

  • 同时往 store 放同一个版本 → 留下半份文件,指纹对不上,之后 verify 必失败;

  • 同时回写 raku-pm.lock → 后写的把先写的整份冲掉,装过的信息凭空消失;

  • 同时 promote bin / 装进 site → wrapper 指向还没写完的路径。

所以所有写命令(install / uninstall / upgrade / fetch / self-upgrade / repos add|remove|update)都会先拿一把跨进程文件锁,别的 raku-pm 只能排队:

$ raku-pm install Foo          # 另一个终端同时装别的包
⏳ 另一个 raku-pm 正在写入 /home/user/.raku-pm,等待它结束…

细节(都在 RakuPM::FileLock):

  • 用 flock,不是「锁文件存在就算锁住」:进程被 kill、Ctrl+C、机器重启时锁由 操作系统回收,不会留下需要手工清理的陈旧锁。锁文件是 目标目录/.raku-pm.run.lock, 里面不存状态,只在同一目录的 .owner 旁路文件里写「谁、在干什么、什么时候开始的」 —— 因为 Windows 的 LockFileEx 是强制锁,持锁期间连读锁文件本身都会被拒。

  • 超时而不是无限等:默认最多等 600 秒(RAKUPM_LOCK_TIMEOUT 可调), 等不到就明确报错并说明是谁在占着,不卡死。

  • 同一进程可重入:install 内部还会调 install / upgrade,靠引用计数管理, 只有最外层 release 才真正 unlock —— 否则嵌套释放会把外层的锁提前交出去。

  • 持锁期间给子进程下「凭证」:包的 Build.pm 里再调 raku-pm install X 是常态, 那种子进程不该等自己父进程手里的锁(会自锁到超时)。做法是持锁时下发一个随机 token(写进锁旁的 .token 文件 + 环境变量 RAKUPM_LOCK_TOKEN),子进程回来时 必须同时满足「盘上 token 一致」且「锁此刻确实被占」才免锁放行 —— 凭证随锁作废,父进程一放锁,晚到的子进程就老实排队;另一个终端手动跑的 raku-pm 根本没有这张凭证,同样排队。 (0.44.0 之前这里用的是「持锁就广播 RAKUPM_NO_LOCK=1」——那等于给所有人 开门:任何继承了该变量的进程都自动拿到免锁权限,连在锁内跑的测试都把锁 当没加,断言恒真。)

  • 逃生舱:--no-lock 或 RAKUPM_NO_LOCK=1 完全跳过锁 —— 并发写会损坏 store 与锁文件,只在确认没有别的 raku-pm 在跑时用。这是用户显式要求的旁路, 与上面的子进程凭证是两回事:它不校验任何东西,设了就生效(所以别写进自动化)。

顺带一个同源问题:生态索引缓存(10MB 上下)以前是 $cache.spurt($text) 直接写, 两个进程同时刷新会交错写出半份 JSON,而受害者是下一次命令(现场已消失,极难排查)。 现在改成写临时文件 + rename 覆盖(同一分区上 rename 是原子的),读者只会看到 旧文件或新文件。

测试执行:串行(0.80.0 起;此前 0.38.0–0.79.x 为并发)

安装时跑 t/ 下的测试,逐文件串行:每个测试文件一个独立的 raku 子进程 (进程隔离,但不是沙箱),跑完一个再跑下一个。zef 生态本身也是串行跑测试,故这是 最贴合生态默认的形态。

$ raku-pm install Foo
  · 运行测试:Foo 0.1.0(3 个文件,串行)
    ✓ 01-basic.rakutest
    ✗ 02-race.rakutest
    | Failed test 'concurrent push' at …/02-race.rakutest line 12
    ↻ 串行复验 1 个失败用例一次(上游 flaky / 环境瞬时问题都可能)
    ✓ 02-race.rakutest(复验通过 —— 首跑的失败判定为假失败,已忽略;首跑日志保留在 02-race.rakutest.log)

为什么从并发退回串行(0.80.0):并发跑测试本身没问题,问题是要让它不误伤安装 就得额外配两道安全网 —— ① 串行预热 precomp(消掉「多进程同时首次编译同一份 lib/*.rakumod」触发的 rakudo 预编译缓存竞态,症状是假的 ===SORRY!===);② 失败用例 串行复验一次。这份复杂度对单机教学场景不划算,且 self-upgrade 早已为此单独强制串行, 故 0.80.0 把并发机制整体移除(--test-jobs 开关随之取消),只保留「失败串行复验」—— 它防的是上游 flaky 用例,与并发无关。

几个要点(0.38.3 起作为【定型默认值】, intentional defaults):

  • ① 串行执行:逐文件跑,进程隔离。不再有并发度旋钮。

  • ② 日志位置默认随安装目标:完整输出落 <目标>/log/<包名>/<版本>/<测试文件>.log(<目标> 默认 ~/.raku-pm), 重装覆盖、无保留份数(按「每发行版每版本每文件」天然隔离)。设 RAKUPM_TEST_LOG_DIR=<某目录> 可把日志根改到全局位置(如 ~/.raku-pm), 便于跨 target 集中查看。

  • ③ 前台只印失败首行(唯一例外:嵌套编译错误 —— ===SORRY!=== 外壳之后的 最内层病因行会印最多 3 行,见 RakuPM::Client::Tester.!failure-lines):完整输出落 上面的日志文件,前台不刷屏、方便事后排查。

  • ④ 超时默认 300s:--test-timeout=N 或 RAKUPM_TEST_TIMEOUT 给每个测试文件 设上限,超时即 kill 子进程并判该文件失败(仍走下面的串行复验)。

  • ⑤ 实时进度(仅终端):跑测试时在 stderr 上原地刷一行 「测试 <文件号>/<文件数> <文件名> <用例完成数>/<用例总数> 用例 pct%」——串行下 同一时刻只跑一个文件,故进度按【当前这个文件】的用例推进(行内数字持续刷新,一个文件里 用例很多也看得到;plan 未出现时只显示已完成用例数)。逐文件「编译 + 执行」可能几分钟 没有别的输出,没有进度会被误认为卡死。非 TTY(管道 / 重定向 / 测试捕获)自动静默, stdout 与日志文件零影响;RAKUPM_TEST_PROGRESS=0 强制关、=1 强制开。

  • 失败自动串行复验一次:上游 flaky 用例(如 stress.rakutest)会偶发失败,复验 通过即视为假失败,不让一次概率性失败阻断安装。复验日志写 <名字>.flake.log, 不覆盖首跑失败日志(保留诊断线索)。

  • 已知平台 bug 软通过(Log::Async 在 Windows 上的 t/12-context.rakutest 等) 与「缺编译工具」提示逻辑保持不变,照常生效。

测试阶段与安装成败的因果(0.79.1 / 修订 0.80.0)

用户按失败提示去 log/ 查因、却发现目录已被回滚清空时(0.78.1 之前会这样),最需要 的就是这层因果。

  • 并发边界:0.80.0 起测试阶段也串行;解析 / 下载 / 构建 / 入 store / 进暂存 / 晋升同样全串行(Client.!install-chain-inner 就是 for @order -> $dist,自底向上)。 唯一跨进程的并发是「两个 raku-pm 同时跑」,靠 FileLock 串行化。

  • 失败串行复验解决什么:过滤上游 flaky 用例(Concurrent::Stack 1.3 的 stress 用例实测 10 次挂 3 次),给用户一次重试余量。复验日志写 <名字>.flake.log,不覆盖首跑日志。

  • 解决不了什么(残余风险,如实记下):

    • 复验也挂 → 判真失败 → 整链回滚(本次已构建的依赖一并丢弃);

    • 上游 flaky 用例若两次都挂,仍然失败(复验只兜一次);

    • 单个测试文件很慢 → 串行下慢用例依次累加墙钟 → 可能撞 --test-timeout。

  • 旋钮:--test-timeout=S / --allow-test-failure / --no-test; 环境变量 RAKUPM_TEST_TIMEOUT。面向用户的完整说明见 MANUAL.md §15。

缓存回收:raku-pm clean

store 只增不减 —— self-upgrade 每升一次就留一份快照,升级留下的旧版本、 卸载后剩下的 store 条目、下载解压的源码包全都堆在那里。实测一台用了一段 时间的机器:store 193M(其中 RakuPM 一个包 30 个版本)、cache/dist 110M, 而真正装着的只有最新那份。

raku-pm clean                 # 试算:列出能回收什么(默认一个字节都不删)
raku-pm clean --yes           # 确认后真删
raku-pm clean --all --yes     # 连索引缓存与 git 克隆缓存一并回收
raku-pm clean --older-than=30 # 只回收 30 天没动过的
raku-pm clean --name=RakuPM   # 只看某一个发行版

回收四类,默认就做:

类别是什么删了会怎样
旧版本快照store 里没被引用的历史版本下次重装要重新下载/入库
入库残骸store/<名>/<版本>/ 里没有 META.json(入库中断)只有清理价值
预编译产物store 条目里的 .precompRakudo 按需重建
下载缓存cache/dist/ 下的 tar 包与解压目录下次重新下载

--all 才追加两类(重建成本是网络/克隆,默认留着):cache/index-*.json 索引缓存、git-cache/ 克隆缓存。

安全边界是这套东西唯一要紧的地方:只删「没有被任何地方引用」的版本, 受保护的有三类 ——

  1. installed.json 账本里记的版本(多版本共存时 versions 里的全部版本);

  2. 自家 site/(CUR::Installation)里真实装着的版本 —— 账本可能缺记,以磁盘为准;

  3. raku-pm.lock 锁定的版本 —— --locked 安装会直接复用它。

site/ 里已装的内容不受影响(它跟 store 是两份拷贝),删掉 store 条目最坏 的结果是下次重装时重新下载,不会让已装好的包失效。删除前还有一道护栏: 只删 $RAKUPM_TARGET 以内的路径。

删文件不可撤销,所以默认是试算;执行时逐项删,某一项被占用(Windows 上 常见)不会中断整轮,最后汇总说明哪几项没删掉。

从零重来:raku-pm flush

clean 是有保护的回收(每条规则都是「什么不能删」)。要「整个推倒重装」时用 flush:它清掉当前前缀里 raku-pm 装过的一切,连它自己的入口一起。

raku-pm flush          # 试算:列出会删什么(默认一个字节都不删)
raku-pm flush --yes    # 真删

清的:前缀内的 store/ site/ git-cache/ cache/ log/ generations/ stage/ bin/ installed.json raku-pm.lock,以及前缀外那几份自装入口 (<rakudo>/share/perl6/site/bin 下的 raku-pm / .bat / .ps1)。

两道护栏(这是全删命令,护栏比功能重要):

  1. 目录必须看起来就是一份 raku-pm 前缀(store/、site/、installed.json 至少有一个),且不是家目录本身、不是文件系统根 —— 否则直接拒绝执行。 --target / RAKUPM_TARGET 打错一个字符时,这一条能兜住。

  2. 只删认得的条目名,前缀里别的东西一律不动(会列出来给你看)。 所以即使前缀跟别的文件共处一个目录,也不会误伤。

不去动别人的东西:zef 装的 RakuPM 不在本前缀内(要清就 zef uninstall RakuPM), <名>.exe.zef-old(zef 入口的唯一备份)也只提示不删。

⚠ 入口会被一起清掉,所以 flush 之后就没有 raku-pm 命令了。装回来:

zef install <raku-pm 仓库路径>                     # zef 装一份,它会写好入口
raku <仓库路径>/bin/raku-pm.raku install .         # 或直接用仓库里的脚本,它自己会写入口

flush 与相邻命令的分工:clean = 能安全回收的;self-remove = 只卸 raku-pm 自己; flush = 全清,为了从零重来。刻意分成三个而不是合成一个 —— 免得有人把 flush 当成 clean 的加强版顺手敲出去。

与 zef 的对应关系

raku-pm 组件zef 对应作用
RakuPM::DistributionMETA6.json + Zef::Distribution发行版元数据
RakuPM::Repository (role)Zef::Repository包源抽象
RakuPM::Repository::LocalZef::Repository::LocalCache本地仓库
RakuPM::Repository::EcosystemZef::Repository::Ecosystems远程生态
RakuPM::Resolverfind-candidates + find-prereq-candidates依赖解析
RakuPM::InstallerZef::Service::InstallRakuDistribution安装
RakuPM::ClientZef::Client编排

核心设计思想

1. 发行版 = 元数据文件(META6.json)

每个包是一个目录,根下有个 META6.json 描述它:

{
  "name": "HTTP-Client",
  "version": "1.2.0",
  "auth": "demo:raku",
  "provides": ["HTTP::Client"],
  "depends": { "JSON": ">= 1.0.0" }
}

关键点:provides(提供哪些模块)和 name(发行版名)是两个概念。 一个发行版 HTTP-Client 可以提供多个模块,模块名 HTTP::Client 用 :: 而非 -。

外部数据的畸形形态:按「能不能无损修复」分三类处理(0.88.1 明确划线)

META6.json 与生态索引行都是外部数据(发布者 / 远端索引生成),写歪是常态。 处理原则不是"一律宽容",也不是"一律报错",而是看这个畸形能不能被无损修复:

畸形处理依据
name / version 类型错(1.0 少了引号)或缺失明确报错(die,含文件 + 字段 + 实际类型 + 怎么改)它们是发行版的身份,会进 store 路径与账本;且没法无损转 —— "version": 1.0 到 Raku 是 Rat,~1.0 得到 "1" 而不是 "1.0",悄悄转会把用户装的东西记成另一个版本
同一信息有多种写法(auth 是数组/null、provides 缺失、depends 写成字符串)归一(同义写法取其一;缺失则按磁盘/默认值兜)这些是同义异构,归一不丢信息;auth 写数组大多是照抄 authors 的格式
数组里混进 null("resources": [null])滤掉既不是有效值也不该报错;不滤会喷 uninitialized 警告,还会留下一个空串资源名

两条纪律:① 索引路径与 META 路径必须共用同一个归一实现 —— 实测 auth 的数组形态 只有 META 那条路处理了,索引那条路会让 Text::Sift4(cpan 索引)带着 Type check failed for return value 裸异常倒下;② 报错信息要给可执行的改法, 但不要回显"改好的字面量"(Rat 会把 1.0 归一成 1,照抄反而改错版本)。

2. 仓库是可插拔的 role

RakuPM::Repository 是一个 role,只要实现这些方法就能成为新的包源:

method find-providers(Str $module --> List)     # 谁能提供这个模块
method all-distributions(--> List)              # 所有包
method available-versions(Str $name --> List)   # 某包的所有版本
method fetch-distribution(Str $name, Str $version)  # 物化元数据
method source-dir-for(Str $name, Str $version)  # 源码目录(本地仓库才有)

这就是 zef 里"新服务后端只需改 config.json 就能接入"的简化版。

注意 source-dir-for:客户端不该猜仓库的内部布局。目录名与发行版名 可以不同(如目录 JSON2/ 里放着发行版 JSON 2.0.0),所以定位源码的职责 必须留在仓库内部。

3. 依赖解析是递归 + 拓扑排序

  • 递归解析:从目标模块出发,DFS 遍历所有依赖。

  • 冲突检测:同一模块被不同版本约束要求时,检查是否兼容。

  • 循环处理:生态数据里真有互相依赖的发行版,所以不再一发现回环就报错, 而是记下来提示一声,拓扑排序排不动的部分按名字补在末尾(绝不静默丢包)。

  • 拓扑排序(Kahn 算法):保证依赖先于被依赖者安装。

  • 约束语义:>=/>/<=/< 是范围;= 与 == 是精确匹配; 多个约束用逗号连接是 AND 关系;* 或空表示任意。

  • 请求串里怎么写(install / search / installed / store 同款): Foo:ver<>=1.0>(≥)、Foo:ver<>1.0>(>)、Foo:ver<<=1.0>(≤)、 Foo:ver<<1.0>(<)、Foo:ver<>=1.0, <2.0>(区间)。 >= 还有等价的后缀写法 Foo:ver<1.0+>(at-least)。 注意串尾那个 > 是 phaser 的闭合括号,不属于约束本身。

4. 脚本语言安装 = 把源码交给 Raku 的仓库机制

对 Raku/Python/Perl 这类脚本语言,"安装"本质上就是把源码放进模块搜索路径。 但有版本区分的那一步不能省:raku-pm 从 store/ 取出某个版本,交给 CompUnit::Repository::Installation 装进 site/,并在 installed.json 记下装过哪些版本(详见「多版本与 use Foo:ver<1.2.3>」一节)。

5. 内容指纹(借鉴 nix 的内容寻址)

每个入 store 的包会计算 MD5 指纹,覆盖 META.json + 全部 lib/ 文件:

  • 内容变 → 指纹变 → 能发现缓存被篡改;

  • verify 命令批量校验,install 前也会自动校验,不通过则拒绝安装。

这与 nix 的 /nix/store/<hash>-name 思路同源,只是简化为"校验"而非"寻址"。

RakuPM v1.0.3

一个教学用的 Raku 包管理器:多索引仓库、依赖解析、跨系统本地库(:from<native>)探测、内容寻址存储、锁文件、单版本激活、单后端 HTTP 客户端(curl)、索引 TTL 与网络重试

Authors

  • skyter10086

License

Apache-2.0

Dependencies

Test Dependencies

Provides

  • RakuPM::Author
  • RakuPM::Builder
  • RakuPM::CLI
  • RakuPM::Cleaner
  • RakuPM::CliCheck
  • RakuPM::CliSpec
  • RakuPM::Client
  • RakuPM::Client::Flusher
  • RakuPM::Client::Git
  • RakuPM::Client::Query
  • RakuPM::Client::SelfManager
  • RakuPM::Client::Tester
  • RakuPM::Distribution
  • RakuPM::FileLock
  • RakuPM::Fs
  • RakuPM::HTTP
  • RakuPM::HTTP::Backend
  • RakuPM::HTTP::Tinyish
  • RakuPM::Help
  • RakuPM::InstallOptions
  • RakuPM::Installer
  • RakuPM::Installer::Generations
  • RakuPM::Installer::ShellTemplates
  • RakuPM::Ledger
  • RakuPM::Lock
  • RakuPM::MD5
  • RakuPM::Message
  • RakuPM::NativeLib
  • RakuPM::Net
  • RakuPM::Platform
  • RakuPM::Prefix
  • RakuPM::Repositories
  • RakuPM::Repository
  • RakuPM::Repository::Ecosystem
  • RakuPM::Repository::Local
  • RakuPM::Repository::Matching
  • RakuPM::Resolver
  • RakuPM::Spec
  • RakuPM::Store
  • RakuPM::UI
  • RakuPM::Version

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.