【仓颉语言入门 · 第21课】cjpm 包管理与多文件项目组织:从单文件练习迈向工程化开发
前 20 课的代码都活在一个单独的
src/main.cj里,靠import std.*使用标准库。但真实项目必然要拆文件、分包、复用别人写的库。本课带你掌握cjpm 包管理:一个工程的目录结构、多文件如何协作、如何把自己的代码打成库包、如何通过本地路径引用第三方包,以及cjpm.toml的完整配置项。本文所有代码与报错文案均在仓颉 SDK 1.2.0 下逐行实测编译运行。
目录(系列导航)
整套路线共7 个模块、30 课:
| 模块 | 课次 | 内容 |
|---|---|---|
| 一、环境与入门 | 01~05 | 环境搭建与 Hello World、变量与基本类型、运算符与输入输出、分支、循环 |
| 二、常用类型与数据组织 | 06~10 | 字符串、数组与区间、ArrayList/HashMap/HashSet、可空类型、错误处理 |
| 三、函数与函数式 | 11~14 | 函数、Lambda 与高阶函数、闭包、迭代器与惰性序列 |
| 四、面向对象与类型系统 | 15~20 | struct/class、构造与属性、接口、枚举与 match 模式匹配、泛型、扩展 |
| 五、工程化与标准库 | 21~25 | cjpm 包管理与多文件、文件 IO、JSON 处理、网络编程、单元测试 |
| 六、并发编程 | 26~28 | 线程、Channel 通道与同步原语、并发实战 |
| 七、项目实战 | 29~30 | 命令行小工具、GeoJSON 数据处理实战 |
- 环境搭建与第一个仓颉程序
- 变量与常量:let / var 与基本数据类型
- 运算符与标准输入输出
- 分支结构:if 与 match 表达式
- 循环结构:while / for / Range
- 字符串详解与字符串插值
- 数组 Array 与区间 Range
- 集合框架:ArrayList、HashMap、HashSet
- 可空类型
?与 Option - 错误处理:异常机制与 Result
- 函数定义、参数与返回值
- Lambda 与高阶函数
- 闭包、作用域与函数类型
- 迭代器 Iterator 与 Sequence
- 结构体 struct 与类 class
- 构造函数、属性与方法
- 接口 interface 与实现
- 枚举 enum、代数数据类型与 match 模式匹配
- 泛型编程
- 扩展、类型别名与可见性控制
- cjpm 包管理与多文件项目组织(本文)
- 文件与目录 IO
- JSON 处理(结合 stdx 扩展库)
- 网络编程入门
- 单元测试
- 并发基础:线程的创建与等待
- Channel 通道与同步原语
- 并发实战:多线程任务处理
- 实战一:带文件持久化的命令行小工具
- 实战二:GeoJSON 数据处理程序
一、单文件项目的瓶颈
回顾前 20 课,我们的代码都是这个结构:
myproj/ ├── cjpm.toml └── src/ └── main.cj ← 所有代码塞在这一个文件里cjpm.toml最少需要这几个必填字段(cjpm init会自动生成完整配置):
[package] name = "myproj" version = "1.0.0" cjc-version = "1.2.0" output-type = "executable"随着功能变多,单文件会迅速失控:
- 一个文件几千行,找代码靠搜索;
- 不同功能混在一起,改一处怕动全身;
- 想复用之前写的工具类?只能复制粘贴;
- 团队协作时,多人改同一个文件冲突不断。
工程化的第一步就是拆文件、分包。
二、cjpm 项目的标准目录结构
cjpm是仓颉官方的包管理器兼构建工具(类似 Rust 的 cargo、Go 的 go mod)。一个标准 cjpm 项目的目录结构如下:
myproj/ ├── cjpm.toml # 项目配置文件(必须) ├── src/ # 源码目录(必须) │ ├── main.cj # 程序入口(可执行项目) │ ├── utils.cj # 工具函数 │ └── models.cj # 数据模型 ├── target/ # 编译输出目录(cjpm 自动生成,不用管) └── cjpm.lock # 依赖锁定文件(cjpm 自动生成,不用管)2.1cjpm init:一键创建项目
不用手动建目录,cjpm 提供了脚手架命令:
cjpm init--namemyproj生成的目录结构:
myproj/ ├── cjpm.toml └── src/ └── main.cjcjpm.toml自带完整配置(cjc-version、version、output-type等必填字段已填好),main.cj是一个可运行的骨架:
package myproj main(): Int64 { println("hello world") return 0 }2.2cjpm.toml详解
cjpm.toml是项目的"身份证",控制项目名、版本、依赖、编译选项。先看一个完整的可执行项目配置:
[package] name = "myproj" # 项目名(包名),只能用小写字母/数字/下划线,不能含连字符 version = "1.0.0" # 语义化版本(必填) cjc-version = "1.2.0" # 编译器版本要求(必填) output-type = "executable" # 输出类型:executable(可执行)或 static(静态库,必填) src-dir = "src" # 源码目录,默认 "src" description = "我的第一个仓颉项目" license = "MIT" [dependencies] # 本地路径依赖 # mylib = { path = "../mylib" } # 远程仓库依赖(需要配置 registry) # cangjie-mysql-driver = { version = "0.1.0" }注意:
name、version、cjc-version、output-type是必填字段,缺了cjpm build会报错。cjpm init生成的配置已包含所有必填项。
关键字段说明:
| 字段 | 说明 | 常用值 |
|---|---|---|
name | 项目名,也是默认包名 | 小写字母/数字/下划线,不能含- |
version | 语义化版本 | 1.0.0 |
output-type | 输出类型 | executable(默认)/static |
src-dir | 源码目录 | src(默认) |
[dependencies] | 依赖表 | 见第四节 |
三、多文件协作:把代码拆出去
3.1 同一个包里的多文件
src/目录下所有.cj文件默认属于同一个包(包名 =cjpm.toml里的name)。拆文件不需要任何 import,直接引用即可:
src/models.cj:
package myproj class User { let name: String let age: Int64 init(name: String, age: Int64) { this.name = name this.age = age } func isAdult(): Bool { return this.age >= 18 } }src/utils.cj:
package myproj func formatUser(user: User): String { let status = if (user.isAdult()) { "成年" } else { "未成年" } return "${user.name}(${user.age}岁,${status})" }src/main.cj:
package myproj main(): Int64 { let u = User("小明", 20) println(formatUser(u)) return 0 }运行cjpm run,输出:
小明(20岁,成年)注意:三个文件都写了package myproj,它们共享同一个包作用域,不需要import。
3.2 子目录 = 子包
当文件更多时,需要按功能分包。仓颉的规则是:src/下的子目录自动成为子包,包名 = 项目名 + 子目录路径。
myproj/ ├── cjpm.toml └── src/ ├── main.cj # package myproj ├── models/ │ └── user.cj # package myproj.models └── utils/ └── format.cj # package myproj.utilssrc/models/user.cj:
package myproj.models public class User { public let name: String public let age: Int64 public init(name: String, age: Int64) { this.name = name this.age = age } public func isAdult(): Bool { return this.age >= 18 } }src/utils/format.cj:
package myproj.utils import myproj.models.User public func formatUser(user: User): String { let status = if (user.isAdult()) { "成年" } else { "未成年" } return "${user.name}(${user.age}岁,${status})" }src/main.cj:
package myproj import myproj.models.User import myproj.utils.formatUser main(): Int64 { let u = User("小明", 20) println(formatUser(u)) return 0 }运行结果同上。注意三个要点:
- 子包里的类/函数要加
public——第 20 课讲过,跨包访问默认internal,包外不可见; - import 写完整路径:
import myproj.models.User,不是import models.User; - import 的是符号,不是文件——
import myproj.utils.formatUser导入的是formatUser这个函数,不是format.cj这个文件。
3.3 子包之间的依赖
子包之间也可以互相 import,但会形成依赖链。建议保持单向依赖:main→utils→models,避免循环引用。
四、依赖管理:引用别人的包
4.1 本地路径依赖
最常见的场景:你写了一个通用库mylib,想在另一个项目myproj里用。
目录结构:
workspace/ ├── mylib/ │ ├── cjpm.toml │ └── src/ │ └── lib.cj └── myproj/ ├── cjpm.toml └── src/ └── main.cjmylib/cjpm.toml:
[package] name = "mylib" version = "0.1.0" output-type = "static" # 静态库mylib/src/lib.cj:
package mylib public func greet(name: String): String { return "Hello, ${name}!" }myproj/cjpm.toml:
[package] name = "myproj" [dependencies] mylib = { path = "../mylib" }myproj/src/main.cj:
package myproj import mylib.greet main(): Int64 { println(greet("仓颉")) return 0 }运行cjpm run,输出:
Hello, 仓颉!cjpm 会自动编译mylib,再编译myproj并链接。
4.2 远程仓库依赖
如果包发布到了 cjpm 官方仓库(或私服),可以用版本号引用:
[dependencies] cangjie-mysql-driver = { version = "0.1.0" }首次cjpm build会自动下载并缓存到本地。
4.3 依赖锁定:cjpm.lock
cjpm build后会在项目根目录生成cjpm.lock,记录所有依赖(包括间接依赖)的精确版本。团队开发时应该把cjpm.lock提交到 git,保证所有人用同一套依赖版本。
五、静态库 vs 可执行程序
| 特性 | 可执行程序(executable) | 静态库(static) |
|---|---|---|
output-type | executable(默认) | static |
| 入口 | 必须有main(): Int64 | 不需要main |
| 被依赖 | 不能作为其他项目的依赖 | 可以被其他项目依赖 |
| 输出 | 生成.exe | 生成.a(Linux/macOS)或.lib(Windows) |
什么时候用 static?
- 写通用工具库、SDK 封装、算法库;
- 把一个大项目拆成"核心逻辑库 + 命令行壳"。
什么时候用 executable?
- 写最终给用户运行的程序;
- 写测试用的 demo。
六、实战:把订单系统拆成多文件工程
我们把第 20 课的订单金额工具箱拆成一个规范的 cjpm 工程。
6.1 目录结构
ordersys/ ├── cjpm.toml └── src/ ├── main.cj ├── models/ │ └── order.cj └── utils/ └── money.cj6.2 代码实现
ordersys/cjpm.toml:
[package] name = "ordersys" version = "1.0.0" cjc-version = "1.2.0" output-type = "executable"src/models/order.cj:
package ordersys.models public class Order { public let id: String private var paid: Bool = false private let amountFen: Int64 public init(id: String, amountFen: Int64) { this.id = id this.amountFen = amountFen } public func pay(): Unit { this.paid = true } public func isPaid(): Bool { return this.paid } public func getAmountFen(): Int64 { return this.amountFen } }src/utils/money.cj:
package ordersys.utils public func fenToYuan(fen: Int64): String { let sign = if (fen < 0) { "-" } else { "" } let abs = if (fen < 0) { -fen } else { fen } let yuan = abs / 100 let cents = abs % 100 let centsText = if (cents < 10) { "0${cents}" } else { "${cents}" } return "${sign}${yuan}.${centsText}" } public func yuanText(fen: Int64): String { return fenToYuan(fen) }src/main.cj:
package ordersys import ordersys.models.Order import ordersys.utils.yuanText main(): Int64 { let orders = [ Order("NO.1001", 1250), Order("NO.1002", 800), Order("NO.1003", 99) ] var total = 0 for (o in orders) { o.pay() let status = if (o.isPaid()) { "已支付" } else { "待支付" } println("订单(${o.id}) ${yuanText(o.getAmountFen())} 元 [${status}]") total += o.getAmountFen() } println("实收合计:${yuanText(total)} 元") return 0 }运行cjpm run,输出:
订单(NO.1001) 12.50 元 [已支付] 订单(NO.1002) 8.00 元 [已支付] 订单(NO.1003) 0.99 元 [已支付] 实收合计:21.49 元6.3 设计要点
- 金额用整数分存储,
yuanText()负责展示层格式化——和第 20 课的方案一致; paid是private,外部只能通过pay()改状态,不能绕过;- models 和 utils 单向依赖:
main→models+utils,utils不依赖models; - 每个子包只暴露必要的
public成员,内部实现细节保持默认可见性。
七、常用 cjpm 命令速查
| 命令 | 作用 |
|---|---|
cjpm init --name xxx | 创建新项目 |
cjpm build | 编译项目 |
cjpm run | 编译并运行(可执行项目) |
cjpm clean | 清理编译输出 |
cjpm check | 类型检查,不生成二进制 |
cjpm test | 运行单元测试(第 25 课讲) |
八、常见问题 FAQ
Q1:子目录里的文件一定要写package 项目名.子目录吗?
是的。仓颉按目录推导包名,src/models/下的文件必须写package myproj.models,写package myproj会报包声明冲突(第 464 号错误)。
Q2:我可以把多个类塞在一个文件里吗?
可以,一个.cj文件里可以写任意多个类、函数、扩展。但建议一个文件只放一类功能,方便维护。
Q3:import 写myproj.models.User还是myproj.models?
import 的是符号(类名、函数名),不是包名。import myproj.models.User导入User这个类;如果写import myproj.models,编译器会报"找不到符号"。
Q4:本地路径依赖写相对路径还是绝对路径?
推荐相对路径(如../mylib),因为绝对路径在别人电脑上会失效。相对路径以当前项目的cjpm.toml所在目录为基准。
Q5:静态库能直接运行吗?
不能。output-type = "static"的项目没有main函数,编译产物是.lib/.a,只能被可执行项目链接。
Q6:一个项目能同时是库和可执行程序吗?
可以,但仓颉的做法是拆成两个项目:一个static库项目 + 一个executable壳项目。壳项目依赖库项目,只做参数解析和打印。
九、课后练习
- 用
cjpm init --name calc创建一个新项目,把第 11 课的加、减、乘、除四个函数拆到src/ops.cj,main.cj里调用并打印3 + 5、10 - 2、4 * 6、20 / 4的结果。 - 在
calc项目里新建src/advanced/子目录,写一个power(base: Int64, exp: Int64): Int64函数(幂运算),在main.cj里 import 并计算2^10。 - 创建两个项目:
stringutils(static 库,提供reverse(s: String): String)和myapp(可执行,本地路径依赖stringutils)。在myapp里调用reverse("hello")并打印结果。 - 把第 20 课的订单金额工具箱按本课 6.1 节的结构拆成
ordersys工程,确保能cjpm run出正确结果。 - 挑战:给
stringutils加isPalindrome(s: String): Bool(判断回文),在myapp里测试"level"和"hello";思考为什么stringutils里的函数要加public。
下节预告
工程能拆分了,但程序还活在内存里——重启就丢数据。第 22 课文件与目录 IO将讲解:如何用std.fs读写文本文件、遍历目录、处理路径,以及把本课的订单数据持久化到 JSON 文件,让程序真正"记住"东西。
系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)编写,所有代码均已实际编译运行通过。如遇 SDK 版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。
💬 遇到问题?扫码联系作者
跟着课程练习时,如果在 SDK 安装、环境变量配置、编译报错或调试上卡住,欢迎扫码加作者企业微信直接咨询(请备注"仓颉课程"):
离线环境下图片可能加载不出来,也可以在 CIDE 菜单Help ▸ 联系作者 / Contact中查看同一张二维码(应用内置兜底图,无需联网)。
📥 工具下载
本系列全程使用的仓颉 IDE ——CIDE(免费开源、社区版):
- GitCode 仓库 / 安装包下载:https://gitcode.com/wp_upala/cide
- 打开页面后进入发行版(Releases),两种包任选其一:
- 安装版:下载
CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用; - 免安装版(Portable):下载
CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
- 安装版:下载
- 仓颉 SDK 请前往仓颉编程语言官网下载:https://cangjie-lang.cn