monorepo 与调试
工程化的最后一课,收两个尾。其一,项目做大了会「裂变」——对外一个应用,底下还养着工具库、算法库、若干脚本,仓库该怎么组织才不乱?其二,代码一多难免出岔子,全靠 print 排查既慢又伤士气,得学会让程序停在出问题的那一…
工程化的最后一课,收两个尾。其一,项目做大了会「裂变」——对外一个应用,底下还养着工具库、算法库、若干脚本,仓库该怎么组织才不乱?其二,代码一多难免出岔子,全靠 print 排查既慢又伤士气,得学会让程序停在出问题的那一行,当场审问。前半章用 uv 的 workspace 把一群项目装进一个仓库统一供养;后半章拆开断点调试的原理,再把它接进 monorepo 工程。本章配了两个可以直接动手的示例:insight-lab/(monorepo 工程)与 debug-demo/(调试原理演示)。
1. 一个仓库装下一窝项目
1.1 monorepo 长什么样
先看现象:一个数据项目做着做着,往往会长出好几样东西——一个对外的应用、一个画图的库、一个算指标的库。默认做法是每样开一个仓库,这叫 multirepo(多仓库);monorepo(单仓库)反其道而行:只要这些项目由同一拨人维护、彼此紧密配合,就全部放进同一个代码仓库。
本章示例工程 insight-lab 的布局:
insight-lab/
├── apps/
│ └── report-cli/ # 应用:终端周报
├── packages/
│ ├── statskit/ # 库:统计计算
│ └── vizkit/ # 库:终端图表
├── pyproject.toml # workspace 根配置
└── README.md
1.2 和多仓库比一比
| 对比维度 | 多仓库(multirepo) | 单仓库(monorepo) |
|---|---|---|
| 代码复用 | 先发成 pip 包,别人才用得上 | 直接引源码,改完立刻生效 |
| 跨项目改动 | 多仓分别提 PR,版本号对齐全靠人肉 | 一次提交,所有关联改动齐步走 |
| 工具与规范 | 每仓自己配一套 | 全仓统一,一处配置全局受益 |
| 大范围重构 | 牵扯多仓,风险高回报低 | 整仓都在工具链眼皮底下,放心动手 |
| 持续集成 | 每仓一条流水线 | 一条流水线,还能只测受影响的成员 |
| 适合谁 | 团队独立、发版节奏互不相同 | 紧密协作的应用群、SDK 家族、工具集 |
经验之谈:项目之间「血缘」越近,monorepo 越划算;真到了各养各的团队、各排各的发版,再拆仓不迟。
2. 用 uv workspace 搭骨架
uv workspace 官方文档:https://docs.astral.sh/uv/concepts/projects/workspaces/#using-workspaces 以下操作都假定 uv 已装好(第 23 章的内容)。
2.1 根项目先立起来
mkdir insight-lab && cd insight-lab
uv init
uv init 生成的是一个普通单项目骨架,接下来在根目录 pyproject.toml 里补一段声明,把它升级成工作区:
[tool.uv.workspace]
members = [
"packages/*",
"apps/*",
]
members 就是「户口本」:列在上面的子项目,才算这个工作区的成员。
2.2 members 的通配写法
members 支持写 glob 模式,以后新增子目录不必每次回来登记:
| 模式 | 圈定范围 |
|---|---|
packages/* |
packages/ 下的直接子目录 |
packages/** |
packages/ 下所有层级子目录(含嵌套) |
libs/* |
libs/ 下的直接子目录 |
apps/* |
apps/ 下的直接子目录 |
目录也可以并列多个,各管一摊:
[tool.uv.workspace]
members = [
"packages/*",
"apps/*",
"libs/*",
]
2.3 创建成员包
uv init --lib packages/statskit # 库:按 src 布局生成,自带构建配置
uv init --lib packages/vizkit
uv init apps/report-cli # 应用:只管自己跑,普通初始化即可
--lib 与不加的区别在第 22 章讲过:库要被别人 import、要能构建发布,得有 src 布局和构建后端这些行头;应用不对外发布,就不需要。
2.4 sources:把成员连起来
工作区里的成员也要互相依赖。本工程里,vizkit 要用 statskit 的归一化函数,report-cli 则两个都直接用:
# packages/vizkit/pyproject.toml
dependencies = [
"rich>=13.7.0",
"statskit",
]
[tool.uv.sources]
statskit = { workspace = true }
# apps/report-cli/pyproject.toml
dependencies = [
"statskit",
"vizkit",
]
[tool.uv.sources]
statskit = { workspace = true }
vizkit = { workspace = true }
[tool.uv.sources] 是 workspace 机制的心脏。光在 dependencies 里写名字,uv 默认去 PyPI 找包;而 sources 里那行 workspace = true 等于告诉 uv:这个名字别去下载,去工作区里找同名成员,直接用它的源码。于是你改 statskit 的代码,vizkit 立刻用上新版,中间不存在「重新安装」这个动作。
两个细节:
- 某个包名在 PyPI 和工作区里同时存在时,工作区优先;
- 确实想用 PyPI 上的同名包,显式写
statskit = { workspace = false }把链接关掉。
2.5 一条命令装齐
uv sync --all-packages
这条命令替你干了四件事:
- 把所有成员的
pyproject.toml读齐; - 解析整棵依赖树——成员之间的依赖也算在内;
- 在根目录生成唯一的
uv.lock; - 在根目录创建唯一的
.venv,所有依赖装进这一个环境。
和第 17 章 venv 的「一个项目一个环境」对照:workspace 是整仓一个环境,成员包以可编辑方式装进去,import 兄弟包像 import 第三方库一样自然(第 15 章讲的 import 搜索路径在背后生效)。顺带的规矩:uv.lock 要提交进 git,.venv 不要。
2.6 跑起来
uv run --package report-cli python apps/report-cli/main.py
--package 指定以哪个成员的身份运行。跑通后会打印统计摘要和两张终端柱状图:
本周 7 天,日均 158.1 元
最高 245.5,最低 96.5
每日销售额
┏━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ 条目 ┃ 数值 ┃ 分布 ┃
┡━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 周一 │ 128.0 │ █████████████ │
│ 周二 │ 96.5 │ █████████ │
│ …… │ …… │ …… │
└──────┴───────┴──────────────────────────┘
3. workspace 日常速查
3.1 Makefile 收纳长命令
uv run --package report-cli python apps/report-cli/main.py 这种命令敲两遍就腻了,交给 Makefile:
.PHONY: report build
report:
uv run --package report-cli python apps/report-cli/main.py
build:
uv build --package statskit
uv build --package vizkit
以后 make report 一句话的事。
3.2 加第三方依赖
# 给某个成员包加(写进它自己的 pyproject.toml)
uv add --package vizkit <包名>
# 给根项目加开发依赖
uv add --dev debugpy
3.3 构建
uv build # 全部成员一起构建
uv build --package statskit # 只构建某一个
产物落在各成员的 dist/ 目录,拿去发布走的就是第 22 章的流程。
3.4 看依赖网:uv tree
uv tree --package report-cli # 看某个成员的依赖树
uv tree # 看全部成员的
输出示例:
report-cli v0.1.0
├── statskit v0.1.0 (workspace)
└── vizkit v0.1.0 (workspace)
├── rich v13.9.4
└── statskit v0.1.0 (workspace)
自家成员带 (workspace) 标记,内部依赖与外部依赖一眼分明。
4. 引用成员包的三种姿势
[tool.uv.sources] 里除了 workspace,还支持另外两种来源。
4.1 源码链接(workspace 模式)
[tool.uv.sources]
statskit = { workspace = true }
日常开发的首选:本地源码直连,改完即生效,不需要 pip install -e .,也不需要重新 add。
4.2 路径引用
[tool.uv.sources]
statskit = { path = "../statskit" }
引用工作区之外的本地包——比如某个库还没搬进这个仓库,先临时挂上用用。相对路径、绝对路径都可以。
4.3 Git 引用
[tool.uv.sources]
statskit = { git = "https://github.com/your-org/statskit.git", rev = "v0.1.0" }
引用某个 Git 仓库的指定版本(rev 可以是 tag、分支或 commit),适合依赖「还没发到 PyPI、但远端仓库已经有了」的包。
到这里,「组织」的问题就绪:仓库有了、环境统一了、成员互相认识了。可工程一跑起来难免出岔子——数据不对、图表怪异、某行代码的结果离谱。print 调试法在小脚本里凑合,在多包工程里会把人逼疯。下半场换主题:断点调试。
5. 断点凭什么能让程序说停就停
5.1 解释器留的后门:sys.settrace
先认识被调试的目标,debug-demo/price.py,一个折后价计算:
# price.py:一笔订单的折后价计算
def final_price(original: float, discount: int) -> float:
"""discount 为折扣百分比,例如 75 表示七五折。"""
rate = discount / 100
saved = original * rate
return original - saved
order = final_price(280.0, 75)
print(f"应付金额:{order:.2f}")
再看 debug-demo/mini_tracer.py——一个二十来行的「最小调试器」:
# mini_tracer.py:一个手写的「最小调试器」
import os
import sys
# 定位同目录下的目标脚本并读入源码
here = os.path.dirname(os.path.abspath(__file__))
target = os.path.join(here, "price.py")
source = open(target, encoding="utf-8").read()
def tracer(frame, event, arg):
"""每发生一次调试事件就触发:event 说什么事发生了,frame 是当时的栈帧。"""
print(f"事件 {event}|{frame.f_code.co_name}() 第 {frame.f_lineno} 行")
return tracer
sys.settrace(tracer)
exec(source, {"__name__": "__main__"})
运行 python mini_tracer.py,解释器的执行轨迹被一行行「直播」出来:
事件 call|<module>() 第 1 行
事件 line|<module>() 第 1 行
事件 line|<module>() 第 4 行
事件 line|<module>() 第 11 行
事件 call|final_price() 第 4 行
事件 line|final_price() 第 6 行
事件 line|final_price() 第 7 行
事件 line|final_price() 第 8 行
事件 return|final_price() 第 8 行
事件 line|<module>() 第 12 行
应付金额:70.00
事件 return|<module>() 第 12 行
解读一下:
sys.settrace(tracer)注册追踪函数后,解释器每往前执行一步都会回调它,把「正在发生什么」实时递出来;event是事件类型:call(进入某个函数)、line(即将执行某行)、return(函数返回)……frame是当时的栈帧:第几行(f_lineno)、在哪个函数(f_code.co_name)、局部变量有哪些,全在里面;- tracer 把自己 return 回去,意思是「这个帧后续的行事件也继续通知我」。
调试器的全部秘密就这层窗户纸:断点,无非是「事件到了指定文件的指定行,就暂停」;单步,无非是「每走一行都停,等你发令」;变量面板,无非是暂停那一刻去 frame 里把局部变量读出来。debugpy 这类工业级工具,是在同一套机制之上把体验和工程化做到了极致。
5.2 一场调试里的四个角色
原理清楚后,再看你按下 F5 那一刻,台上一共有谁:
┌──────────────┐ DAP(JSON 消息) ┌──────────────┐ sys.settrace ┌──────────────┐
│ 编辑器调试界面 │ ◄──────────────────► │ debugpy 适配器 │ ◄──────────────► │ 目标程序 │
│ 断点·单步·变量 │ │ (翻译官) │ 每步执行回调 │ (Python 进程)│
└──────────────┘ └──────────────┘ └──────────────┘
- 编辑器调试界面:红点断点、变量面板、单步按钮——你直接面对的那一层;
- debugpy(调试适配器):两头都懂的角色,对编辑器说 DAP,对 Python 用 settrace;
- DAP(Debug Adapter Protocol):编辑器与适配器之间的 JSON 消息协议。有了这层通用语言,VS Code 不必懂 Python,debugpy 也不必懂 VS Code——换一个编辑器,适配器照样能对接;
- 目标程序:真正执行代码的 Python 进程。
6. 单文件调试:launch 模式
调试单个文件最省事的方式是 launch(发射)模式:拉起程序、装上调试器、建立连接,全由编辑器一口气完成。以 debug-demo 为例,在 VS Code 里建 .vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"name": "调试当前打开的文件", // 配置名,调试面板下拉框里显示
"type": "debugpy", // 用哪个调试适配器:Python 自然是 debugpy
"request": "launch", // 启动模式:由编辑器负责拉起程序
"program": "${file}", // 调试谁:当前编辑器里打开的文件
"console": "integratedTerminal" // 程序输出走 VS Code 内置终端
}
]
}
用法三步:在行号左边点一下,打下红色断点;按 F5 选中这个配置;程序跑到断点行自动停住。停住之后可以单步(F10 逐行、F11 进入函数)、看变量面板、看调用栈。debug-demo/ 里已经放好这份配置,可以直接体验。
7. 工程调试:attach 模式
launch 模式有个软肋:程序得由编辑器拉起。而 monorepo 里的应用必须靠 uv run 启动——依赖装在 workspace 的共享环境里,编辑器直接 launch 找不到那套环境。解法换个思路:程序照旧由 uv 启动,只是让它「挂起等连接」,编辑器再主动贴上去。这就是 attach(附加)模式。
7.1 第一步:装上调试器
uv add --dev debugpy
装进根项目的开发依赖组([dependency-groups])——整个工作区共享一个 .venv,装一次处处可用。
7.2 第二步:让应用「挂起待命」
在 insight-lab/Makefile 里加一个 debug 目标(记得把它补进 .PHONY):
debug:
uv run --package report-cli python -m debugpy --listen 5678 --wait-for-client apps/report-cli/main.py
两个参数是关键:
--listen 5678:在本机 5678 端口开一个调试服务,等人来连;--wait-for-client:客户端没连上之前,一行业务代码都不执行——保证断点从第一行就生效。
7.3 第三步:编辑器侧的附加配置
insight-lab/.vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"name": "附加到 make debug",
"type": "debugpy",
"request": "attach", // 附加模式:连到一个已经在跑的进程
"connect": {
"host": "localhost",
"port": 5678 // 与 --listen 的端口保持一致
}
}
]
}
7.4 两步启动
- 终端里
make debug:应用启动但原地挂起,等待连接; - VS Code 里按 F5 选「附加到 make debug」:连上之后程序开跑,命中断点即停。
连不上先查两处:launch.json 里的端口和 --listen 是否一致;终端里程序是否真的停在 debugpy 的等待提示上。
attach 模式的妙处在于:不管程序怎么起的——uv、容器、还是远程服务器——只要它能开调试端口,编辑器就能贴上去。工程越大,这招越值钱。
8. 作业
习题一:复刻一个自己的 monorepo
照本章流程从零搭一遍(别直接抄 insight-lab,换个业务场景,比如「待办应用 + 日程库 + 提醒库」),要求:
- 根配置的
members用上 glob 通配; - 至少两个库包 + 一个应用,且库之间存在依赖(A 依赖 B);
uv sync --all-packages一键装齐,uv run --package跑通,uv tree能看到(workspace)标记;- 把两个库包
uv build构建出来,发布到阿里云制品仓库(第 22 章私有发布的做法)。
习题二:把断点调试接进 monorepo
在习题一的工程(或本章 insight-lab)上完成:
- 先跑一遍
debug-demo/mini_tracer.py,把 call / line / return 的事件流亲眼看过; - 给应用加上 debugpy 监听目标,用 Makefile 收纳;
- 编辑器 attach 上去,在库包内部的函数里打断点,验证两件事:跨包的断点一样能停;暂停时变量面板能看到库包里的局部变量。
下一章:25 · Jupyter 交互环境——至此,从写代码到建工程、到打包发布、再到排障调试,「工程交付」这条线完整收官。接下来换挡进入数据实践:从交互式的 Jupyter 笔记本开始,看看数据探索为什么离不开它。