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」,解释不了「为什么 5):search 要 12 秒」。
0.84.2 实测(同机,min-of-3
| 命令 | 修复前 | 修复后 |
|---|---|---|
version / list / installed(不读索引) | 0.97 / 1.10 / 1.58s | 不变 |
info X --offline | 11.12s | 6.64s |
search X --offline | 12.03s | 7.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)。
这条优化的过程本身就是「先测、再改」的样本 —— 每一步都推翻了上一步的前提:
0.84.3:修一条静默失效的加速路径。 当时有「≥64KB 走系统 md5 命令」这一层 (Windows
certutil -hashfile/ Linuxmd5sum/ macOSmd5 -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,verify19.3s → 13.5s。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。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。结论是不做,理由三条:
收益上限只有 ~80ms(0.231s → 0.292s 的差),且只对纯查询命令有意义;对
install类(秒~分钟级)是 0.1% 量级的噪声。代价与收益不对称:
Client的属性带编译期类型约束 (has RakuPM::Installer $.installer;)、在TWEAK里new出 Installer / Builder / Store / Lock、并由 5 个 role 组合。要真做到「读命令不加载」, 必须去掉类型约束 + 改成惰性访问器 —— 等于拿掉项目里最安全攸关的那个类 (整树事务化安装)的编译期保证。与 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::Tinyish | HTTP::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 → 写锁 lockresolve:在各仓库里查找能提供目标模块的发行版,按约束挑最高版本, 递归展开依赖成一棵树,再做拓扑排序得到「依赖优先」的自底向上顺序。
fetch:本地仓库直接指向目录;生态仓库按索引里的
path拼 URL, 用 HTTP 下载 tar 包并解压到cache/dist/<发行版>/<版本>/。build:包里有
Build.pm(约定class Build的build方法)或 META6 写了"builder"字段(如Distribution::Builder::MakeFromJSON)时, 在发行版根目录起 raku 子进程执行构建,返回真才算成功;build-depends(builder 类通常是生态里的独立包)会在构建前自动解析安装;--no-build可整段跳过。native 包常用这一步编译 C 源生成resources/libraries/*.so。store:源码目录入库(内容指纹 MD5),一次下载可反复部署。 Build 生成的资源此刻随包复制进 store,装完后
%?RESOURCES才取得到。test:在源码目录上跑
t/*.t与t/*.rakutest(-I lib -I inst#<target>/site, 依赖此时已装进 CUR::Installation)。任一用例失败即中止安装,--no-test可跳过。install:交给
CompUnit::Repository::Installation装进target/site/。 它默认:precompile,预编译由 Rakudo 自己管 —— 早期那套「跑raku -M<mod>把模块预编译进平铺 lib/」的做法已删除(既慢又缺版本元数据)。lock:写入精确版本 + 指纹。
世代(原子安装):上面第 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 | 记录数 | 唯一发行版 | 特点 |
|---|---|---|---|---|
zef | https://360.zef.pm | 7971 | 1617 | 当前版本,官方生态 |
rea | …/Raku/REA/main/META.json | 15054 | 2538 | 生态存档,含历史版本与已下架包 |
cpan | …/Perl6-ecosystems/master/cpan1.json | 1916 | — | CPAN 上的 Perl6 模块 |
p6c | …/Perl6-ecosystems/master/p6c1.json | — | — | 旧生态存档 |
有 922 个发行版只在 REA 里有、zef 索引里没有,所以单个索引装不全所有包—— 仓库必须是数组。
配置来源优先级(高 → 低):
$RAKUPM_TARGET/repositories.json(repos add/remove维护,持久化)RAKUPM_ECOSYSTEM环境变量,支持逗号/分号分隔多个,每项可以是 URL 或上表别名默认:
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 之外
——「发布一个包就能往用户机器任意位置写文件」。
不变式(改代码时别绕开):
任何把外来字符串拼进路径的地方,组件必须先过
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 分隔符)。
落盘前再用
RakuPM::Fs.assert-under断言"路径在根之内"。这是第二道防线 ——入口会随代码演进而增多,漏做一处就立刻变成明确报错,而不是一次静默的越界写入。当前需净化的 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 发布到了生态索引(目前没发布,去仓库找必死)。 来源优先级:--from <url>:从指定 git 远端拉源码升级(覆盖一切);当前这份来自某个 git 仓库(开发者自装/自跑)→
git pull该仓库 + 本地重装;否则从默认上游 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.batwrapper; ④ 清掉账本与锁文件里的 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(现代生态) | 8060 | 0 | 一条不缺 —— 现代打包工具都会写 provides |
| rea(老 Perl 6 归档) | 15141 | 138(53 个发行版) | 手写 META.json 的时代产物,.pm 扩展名 + 无 provides |
| cpan | 1916 | 8 | 是真正的 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 | ~/.raku | 69 |
| rakudo 的 site(zef 装的) | <rakudo>/share/perl6/site | 113 |
| raku-pm 的 site | $RAKUPM_TARGET/site | 12 |
实测有 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 也会把候选按系统实际解析
顺序列出,并标出哪一份真的会被执行。
设计说明:为什么不需要单独的「本地包索引」。三层结构各司其职:
installed.json(账本)——已装包的本地索引:名字、全部版本、provides、 指纹、来源目录(自装/git 的包会记录从哪来)。依赖解析查它(O(1)),installed/version展示它;site/本身是 CUR::Installation——Rakudo 用short/+dist/维护 自增索引,use的解析直接用,不需要任何遍历;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 条目里的 .precomp | Rakudo 按需重建 |
| 下载缓存 | cache/dist/ 下的 tar 包与解压目录 | 下次重新下载 |
--all 才追加两类(重建成本是网络/克隆,默认留着):cache/index-*.json
索引缓存、git-cache/ 克隆缓存。
安全边界是这套东西唯一要紧的地方:只删「没有被任何地方引用」的版本, 受保护的有三类 ——
installed.json账本里记的版本(多版本共存时versions里的全部版本);自家
site/(CUR::Installation)里真实装着的版本 —— 账本可能缺记,以磁盘为准;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)。
两道护栏(这是全删命令,护栏比功能重要):
目录必须看起来就是一份 raku-pm 前缀(
store/、site/、installed.json至少有一个),且不是家目录本身、不是文件系统根 —— 否则直接拒绝执行。--target/RAKUPM_TARGET打错一个字符时,这一条能兜住。只删认得的条目名,前缀里别的东西一律不动(会列出来给你看)。 所以即使前缀跟别的文件共处一个目录,也不会误伤。
不去动别人的东西: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::Distribution | META6.json + Zef::Distribution | 发行版元数据 |
RakuPM::Repository (role) | Zef::Repository | 包源抽象 |
RakuPM::Repository::Local | Zef::Repository::LocalCache | 本地仓库 |
RakuPM::Repository::Ecosystem | Zef::Repository::Ecosystems | 远程生态 |
RakuPM::Resolver | find-candidates + find-prereq-candidates | 依赖解析 |
RakuPM::Installer | Zef::Service::InstallRakuDistribution | 安装 |
RakuPM::Client | Zef::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 思路同源,只是简化为"校验"而非"寻址"。