cjreg · 仓颉私有中心仓与多仓体系
官方仓缺的那一半,由它承接
「仓颉生态创新开发挑战赛」技术课题作品,获优秀奖。
仓库 atomgit.com/ystyle/cjreg ,MIT。
官方中心仓很好,但它不解决这三件事
内网或离线环境没有仓。 中心仓在公网。流水线和构建机在网内,断网时得带着依赖跑。
发布不可逆,同步不可控。 版本发上去删不掉、覆盖不了;索引同步快的时候几十秒,慢的话几十分钟到几天。中途失败了只能等,邮件沟通一来一回又是几天。
团队需要自己的仓和预演位。 预发布、内部包、灰度版本,这些不该进公共仓。
cjreg 是这三件事的答案:一个可以自己部署的仓颉包仓库,与官方中心仓协议互通——客户端 cjpm 一行不改,改的只是 cangjie-repo.toml 里的地址。
不是替代官方仓,是补位。官方仓照常用,缺的那一半由私有仓接住。
它长什么样
cjreg(单个静态可执行文件)
├─ HTTP 服务:发布/下载/索引/管理 API
├─ Web 门户 + 后台(cjxt 服务端渲染)
└─ 数据:cjreg.db + blobs/
三条命令跑起来:
cjreg init
cjreg serve
cjreg admin
init 建库、建首个管理员、挂上官方上游;serve 起 HTTP 服务;admin 处理用户和仓库同步。起来之后有门户(包列表、搜索)和管理后台(用户、权限、上游、审计)。
或者一条 compose:
docker run -d -p 8060:8060 \
-v /path/to/data:/data \
ghcr.io/ystyle/cjreg
数据全在挂载目录里(cjreg.db + blobs/ + 配置),备份就是打包这个目录,恢复就是解回去。
一个容易被忽略的点:Web 端也是仓颉
管理界面没用 Vue 也没用 React,用的是 cjxt——一个服务端驱动 UI 的仓颉框架。服务端直接产出 HTML 和增量 patch,浏览器只跑一段很小的运行时。
结果是:写界面不用写一行 JS,也没有 Node、没有 npm、没有前端构建链。 整个 Web 控制台跟服务端一起打成一个静态二进制。
浏览器端当然还是要点 JS 的——cangjie-ui.js 加一个浮层定位库,一共约 100 KB,负责把服务端发来的 patch 应用到 DOM。但那 100 KB 是框架自带的,你写页面时碰不到。
这不是技术炫技,是部署形态的问题:运维拿到的是一个文件,没有 Node 版本、没有 npm 依赖、没有前端构建产物。Dockerfile.build 是多阶段的,评审或使用者不需要本地装任何工具链就能构建出来。
核心能力:四件事
一、多仓:一个实例对接多个仓
官方仓、团队内部仓、某个镜像仓可以同时挂。查包时按优先级找,找到就停——内部包能覆盖同名官方包,cjpm 客户端一行不用改。
上游在管理后台加,存数据库表,不用改配置文件。每个上游带几个开关:
| 字段 | 作用 |
|---|---|
priority |
查找顺序,数字小的优先 |
proxyEnabled |
读路径:允许回源到这个上游 |
asTarget |
写路径:允许把包推到这里 |
cacheTtl |
索引缓存时长,默认 86400 秒 |
读写分离是重点:可以配一个只读的镜像仓,也可以配一个只写的预演仓,互不影响。
二、多仓查找:先查哪个仓
一个包进来,按上游优先级依次查索引,第一个命中的就是它:
resolve(name, org, version):
按 priority 排上游
依次查 index → 命中即返回
都没命中 → 404
切仓就是改 cangjie-repo.toml 里的地址,然后 cjpm clean && cjpm update 刷缓存。客户端不知道背后挂了几个仓。
管理后台里有个解析诊断:给定包名和版本,它按当前配置走一遍,告诉你命中了哪个上游、候选链是什么、卡在哪。
三、发布计划:按拓扑顺序发,六态可视
跨团队发布最烦的是顺序——先发底层库再发上层库,否则解析失败。这套以前靠人肉梳理。
analyze 把待发的一批包分成四类:待发 / 可选 / 已存在 / 冲突。execute 按依赖拓扑顺序推送到目标上游(拓扑排序能力复用 cjdep)。
每个计划项走一个六态状态机:
pending → running → waiting_index
↓ ↓ ↓
completed / failed / skipped
waiting_index 这一态是必须的:版本出现且 sha256 一致才算完成。索引先到的「假完成」会被挡掉。
进度实时推(SSE),中断后计划保持 pending,不会假装在跑。
四、团队与权限:按团队授权,不按人
企业里发版权限一般不给个人,给团队。
cjreg 的模型是:团队 = 权限单元。一个团队关联若干组织或包,成员继承团队那一档权限。三档权限:
| 级别 | 能做什么 |
|---|---|
read |
读取关联的包 |
write |
发布新版本 |
overwrite |
覆盖已存在的同版本(sha 不同的情况) |
有效权限取个人路径和团队路径里较高的那个:
有效权限 = max(个人路径, 团队路径)
个人路径:包是自己首发的 → 按 publisher
团队路径:团队成员 + 团队关联了该组织或包
→ 取命中团队里最高那档
三个细节值得说。
覆盖权在团队手里,不在发布者手里。 同版本不同 sha 想覆盖,需要团队的 overwrite 权限——首发者自己也没有。这条是有意的:覆盖会影响所有用这个版本的人。
新包认领有前提。 命名空间没被任何团队纳管时,有有效发布 token 就能认领,首发者自动成为 publisher。一旦这个组织或包名被团队关联了(纳管),就得有 write 才能发。
组织匹配不做模糊。 org 是空串表示「无组织」,内部关联 ID 是 0;org 非空但没在组织表里登记过,关联 ID 是 -1,不匹配任何团队——这样「关联了无组织」的团队不会越权到任意未登记组织。
这些都在管理后台点鼠标完成:建团队、调权限级别、把团队关联到组织或包。成员在自己的门户里能看到所属团队、权限级别和关联的资源。
五、三级删除与审计:发错了能收回来
包、版本、文件三层分别可删,删之前检查引用。仓库最怕误删——删了别人就构建不过。
谁在什么时候发了什么、删了什么,都写审计日志。内部仓通常给一个团队用,出问题要能回溯。
cjreg admin delete-user --username <u>
对方是团队 owner 时,会先拦下来要求转让——不然团队就成了孤儿。
可见性跟着权限走:退出团队之后,这个团队关联的包就看不到了。不做历史遗留读取是有意的——私有仓的边界应该和当前权限一致。
核心难点:流式发布
最大的问题是内存。
整包驻留内存时,峰值大约是包体的 2–3 倍。默认 256 MB 堆上限下,发一个 100 MB 的包就失败——而企业内部的工具包和资源包,上百兆很常见。
做法是把发布链路全程改成流式:
- 按段解析头,拿到长度与校验
- 边读边算 sha256,不缓存整包
- 写临时文件 → 校验 →
rename原子落盘
下载和回源同理:handler 直接 body(stream),回源边收边落盘。
结果是内存与包体彻底解耦:2 MiB、32 MiB、200 MiB 三档发布,进程 VmHWM 都是 291 MiB。
质量与性能
没有一个数字是估的,全部有脚本可复现。
测试
| 项 | 值 |
|---|---|
| 单元测试 | 1002 个用例,全绿 |
| 行覆盖率 | 91.3%(11241 / 12312 行,90 个源文件) |
| 双仓 e2e | 9 步(发布 6 包 → 拓扑推送 → 公开 API → 上游连通 → 三级删除 → 组织 CRUD) |
| CI | 六步(构建 / 单测 / 覆盖率 / e2e / 文档站 / 容器冒烟) |
1002 个用例不全是纯逻辑单测,还包括进程内 HTTP 夹具(真服务 + 真 client)和页面渲染断言。
性能
单机 loopback 冒烟基准。上游用桩服务隔离——真实中心仓有频率限制且网络会抖动,直接打会让数据完全不可信(实测会引入约 470 ms 的往返与重试)。bench.sh 在测量前会硬校验:只要还有官方上游开着代理就直接终止。
| 维度 | 结果 |
|---|---|
| 索引:回源 → 本地命中 | p50 16.16 ms → 2.74 ms(5.9×),桩计数证明重查轮零回源 |
| 发布吞吐 | 2 MiB 74 MiB/s;32 MiB 278 MiB/s;200 MiB 324 MiB/s |
| 发布内存 | 三档 VmHWM 恒定 291 MiB |
| 并发(索引命中态) | 1/4/16/64 VU → 88 / 294 / 414 / 356 req/s,拐点在 16–64 之间 |
| 启动 → 可服务 | 0.27 s |
| SIGTERM 关闭 | 0.03 s(exit 0) |
口径说明:单机 loopback、页缓存热、所有「回源」都指向本地桩服务。这是服务端自身开销的测量,不是容量规划数字。
踩过的五个坑
这个项目不是从零开始——在此之前我用 Go + Vue3 写过一版 cjrepo(83 commits),验证了「私有仓 + 官方协议互通」这个产品形态。用仓颉重写时,由于有 Go 版做对照,这些坑暴露得比较快:
一、官方索引的 index-version 是字符串,不是数字。
"1" 不是 1。按 Int64 读会抛异常,整行解析失败,索引直接空。
二、cjpm 的 meta-data.json 是 pretty 多行 JSON。
按 NDJSON 逐行解析必然失败。中心仓的索引确实是 NDJSON 格式,但 meta 文件不是。
三、sha256sum 和 dependencies 在嵌套的 index 对象里。
不在顶层,要往下钻一层。
四、bstorm 的几个持久化细节。
重新打开需要 reindex;跨进程的 nextId 要恢复;init 必须 close 落盘才算数。
五、StringReader 读二进制制品会报 UTF-8 错。
下载二进制包时不能当文本读,得改成 readStreamToBytes。
前三个是同一个教训:别照着文档实现,照着真实产物实现。文档写的是「应该是什么」,中心仓里躺的是「实际是什么」。
它不做什么
写清楚边界比列功能有用:
| 不做 | 原因 |
|---|---|
| 不替代官方中心仓 | 定位就是补位,两者并存 |
| 不做公网 SaaS 托管 | 私有仓的价值在私有 |
| 不做包的签名与验签 | 官方仓的信任链,私有仓不越权 |
| 不做依赖漏洞扫描 | 那是另一类工具 |
完整的未实现清单在仓库 docs/finals-submission.md §4.5。
谁会用得上
四种场景:
内网常驻私有仓 —— 流水线和构建机在网内,发布、拉依赖、取 Token 全在本地完成。
预演仓 —— 发布不可逆,先在私有仓预演。管理员直通 overwrite,同版本可以反复发。
多仓体系 —— 一个实例对接多个仓,版本对照告诉你目标仓有没有、要不要发。
CI 与工具链集成 —— 公开只读 API 取包索引与统计,cjpm 客户端无需改造。
如果你也在用仓颉,并且碰到过「内部包没地方放」「断网了构建不了」这类问题,可以试试。
有问题直接在 AtomGit 提 issue,或者到这里说一声也行。
底下是一整套自己写的库
cjreg 站着的不是第三方生态,是我这两年攒下来的七个仓颉库。全部由我一个人写、全部在中心仓可装。
| 库 | 规模 | 为什么需要它 | 包名 |
|---|---|---|---|
| badger-cj | 17271 行 | std 没有嵌入式存储引擎。移植 Go BadgerDB v4:LSM 树、事务、压缩 | badgercj |
| bstorm | 3734 行 | badger-cj 只是 KV,没有「按字段查」。加了二级索引和 @Model/@Index 宏 |
bstorm |
| tachyon | 4410 行 | std 核心库没有 HTTP 服务器(stdx 有,但选了不用,见下) | tachyon |
| tang | 10208 行 | tachyon 是内核,还得有路由、中间件、参数绑定 | tang |
| cjxt | 22997 行 | 想做到「写界面不写 JS」,就得自己做服务端渲染 + 增量补丁 | cjxt |
| gjson | 3649 行 | 发布协议要在不反序列化的情况下取 JSON 字段 | gjson |
| tar-cj | 1682 行 | .cjp 制品是 gzip + tar,发布和回源都要从里面取 README |
ystyle::tar |
依赖是两段链条加三个配件:
badger-cj → bstorm 存储层
tachyon → tang 服务层
cjxt / gjson / tar-cj 页面、协议、制品
合计六万四千多行。写到 cjreg 的时候这些已经齐了,所以这个项目本身只有 31 个生产文件——复杂度被下推到库层了。
版本以 pkg.cangjie-lang.cn 上的为准。
一个刻意的选择:不用 stdx 的 HTTP
仓颉的 stdx 里有 stdx.net.http,但 tang 和 tachyon 都不依赖它。
原因是下游成本:stdx 需要单独配 CANGJIE_STDX_PATH,而且版本要和仓颉版本对齐。用它做底层意味着每个使用者都得先配好 stdx 环境,否则构建不起来。
自己实现 HTTP/1.1,代价是要写解析器、WebSocket 握手(RFC 6455)、SHA-1 和 Base64。收益是:任何仓颉工程 cjpm add tang 就能跑,不用管 stdx。
现在 tachyon 做到了每请求 0 字节分配(实测 112 B 是 std 的 SocketImpl.write 里一个捕获闭包,不在 tachyon 层)。
顺手填的一个坑:测试堆
这七个库撑起 cjreg 的时候,也带来一个额外的麻烦,已经解决了。
bstorm 每个用例建一个内存态 Storm,Badger 打开即预分配 skiplist Segment(约 17 万个原子对象 + 1.3 MB 连续数组)。单测进程累计下来,在默认 256 MB 堆下会随机触发分配失败。
症状很有欺骗性:全量跑间歇失败、单独跑全绿,而且每次挂的用例都不一样。
实测默认堆连跑 3 次全挂,换 8 GB 连跑 2 次全绿,才确认是堆的问题而不是某个用例写错了。
修法是在 cjpm.toml 里把测试进程的堆固定调大。现在 cjpm test 开箱就是稳定的,不用使用者额外配环境。