第7章 7.3 Monorepo:pnpm workspace
🎯 开场:为什么你的代码越来越难管?
上一章我们搞定了代码风格问题,ESLint 和 Prettier 让团队代码整整齐齐。但新问题来了——
你的公司有三个项目:
- web-admin:后台管理系统
- mobile-api:移动端接口
- shared-utils:大家都要用的工具函数
现在 shared-utils 改了一个函数,三个项目都得手动复制粘贴。版本一乱,整个人都麻了。
「说白了:多个项目之间共享代码,就像一栋楼的多个住户共用一个快递柜——得有个统一的管理系统,不然乱成一锅粥。」
这章你能解决什么?
学完本文,你将理解:
- Monorepo 是什么、为什么大型项目都在用
- 用 Python 模拟 workspace 的核心思想
- 多个「包」之间如何共享代码、保持版本一致
🧱 基础:Monorepo 到底是什么?
1. 从「拆快递」理解 Monorepo
想象你家门口有个快递柜(\n\n
\n\n
\n\nMonorepo),里面分了很多格子(workspace)。
快递柜(Monorepo)
├── 格子1:生鲜(web-admin)
├── 格子2:书籍(mobile-api)
└── 格子3:日用品(shared-utils)
每个格子都是独立的,但都在同一个柜子里。你想找东西,直接开对应格子就行,不用满院子翻。
类比到代码:
- web-admin、mobile-api、shared-utils 是三个独立项目
- 它们在同一个代码仓库里
- 共享的代码放在 shared-utils,其他项目直接引用
2. Monorepo 的核心概念
| 概念 | 生活类比 | 代码解释 |
|---|---|---|
| Workspace | 快递柜格子 | 独立的项目/包 |
| 共享包 | 公共储物箱 | shared-utils 这种公用代码 |
| 版本锁定 | 快递单号 | 所有项目用统一的依赖版本 |
| 统一构建 | 物业集中管理 | 一次命令构建所有项目 |
3. pnpm workspace 是什么?
pnpm 是一个 Node.js 的包管理器,workspace 是它管理多项目的方式。但我们今天用 Python 来实现同样的思想——毕竟,Monorepo 是一种架构理念,不局限于某一种语言。
4. 用 Python 模拟 Monorepo 结构
先建一个这样的目录结构:
my-monorepo/ # 根目录,相当于仓库
├── packages/ # 放所有「包」的房间
│ ├── shared_utils/ # 共享工具包
│ │ └── __init__.py
│ └── calculator/ # 计算器包
│ └── __init__.py
└── apps/ # 放应用程序
└── main.py # 主程序入口
创建这个结构:
import os
# 模拟 Monorepo 的目录结构
base = "my-monorepo"
os.makedirs(f"{base}/packages/shared_utils", exist_ok=True)
os.makedirs(f"{base}/packages/calculator", exist_ok=True)
os.makedirs(f"{base}/apps", exist_ok=True)
print("目录结构创建完成!")
print("实际文件夹结构:")
for root, dirs, files in os.walk(base):
level = root.replace(base, '').count(os.sep)
indent = ' ' * 2 * level
print(f"{indent}{os.path.basename(root)}/")
subindent = ' ' * 2 * (level + 1)
for file in files:
print(f"{subindent}{file}")
运行结果:
目录结构创建完成!
实际文件夹结构:
my-monorepo/
packages/
shared_utils/
__init__.py
calculator/
__init__.py
apps/
main.py
解释:我们用 Python 的 os.makedirs 模拟了 Monorepo 的目录结构。注意看,packages 目录下有两个独立的「包」,但它们都属于同一个父目录——这就是 Monorepo 的核心。
🔥 实战:三个递进项目
项目 1:共享工具包(5 分钟)
目标:创建一个所有人都能用的「工具箱」,其他项目直接导入。
packages/shared_utils/format.py:
def format_currency(amount):
"""把数字转成人民币格式"""
return f"¥{amount:,.2f}"
def format_percent(value):
"""把小数转成百分比"""
return f"{value * 100:.1f}%"
if __name__ == "__main__":
# 测试一下
print(format_currency(1234567.89))
print(format_percent(0.1567))
运行结果:
¥1,234,567.89
15.7%
apps/main.py:
import sys
sys.path.insert(0, '..') # 把上级目录加入搜索路径
from packages.shared_utils.format import format_currency, format_percent
# 模拟从数据库读取的订单金额
order_amount = 998.5
discount = 0.2
final_price = order_amount * (1 - discount)
print(f"原价:{format_currency(order_amount)}")
print(f"折扣:{format_percent(discount)}")
print(f"实付:{format_currency(final_price)}")
运行结果:
原价:¥998.50
折扣:20.0%
实付:¥798.80
一句话解释:sys.path.insert(0, '..') 就像告诉 Python「去上级目录找 shared_utils」,这样不同文件夹的代码就能互相调用了。
项目 2:计算器包(15 分钟)
目标:做一个计算器包,能做加减乘除,然后让主程序调用它。
packages/calculator/operations.py:
def add(a, b):
return a + b
def subtract(a, b):
return a - b
def multiply(a, b):
return a * b
def divide(a, b):
if b == 0:
raise ValueError("除数不能为0!")
return a / b
def calculate(expression):
"""解析简单表达式,如 '2 + 3' 或 '10 * 5'"""
parts = expression.split()
if len(parts) != 3:
raise ValueError("表达式格式错误,需要:数字 运算符 数字")
left, op, right = parts
left, right = float(left), float(right)
operations = {
'+': add,
'-': subtract,
'*': multiply,
'/': divide
}
if op not in operations:
raise ValueError(f"不支持的运算符:{op}")
return operations[op](left, right)
packages/calculator/__init__.py:
from .operations import add, subtract, multiply, divide, calculate
__all__ = ['add', 'subtract', 'multiply', 'divide', 'calculate']
apps/calculator_app.py:
import sys
sys.path.insert(0, '..')
from packages.calculator import calculate
from packages.shared_utils.format import format_currency
# 从配置文件读取的账单数据
bills = [
"100 + 50", # 账单1
"200 - 30", # 账单2
"50 * 3", # 账单3
"100 / 4", # 账单4
]
print("=== 账单计算器 ===\n")
for i, bill in enumerate(bills, 1):
try:
result = calculate(bill)
print(f"账单{i}: {bill} = {result}")
except Exception as e:
print(f"账单{i}计算失败: {e}")
print(f"\n总费用:{format_currency(sum(calculate(b) for b in bills))}")
运行结果:
=== 账单计算器 ===
账单1: 100 + 50 = 150.0
账单2: 200 - 30 = 170.0
账单3: 50 * 3 = 150.0
账单4: 100 / 4 = 25.0
总费用:¥495.00
一句话解释:我们在 __init__.py 里用 __all__ 导出函数,这就像给包写了个「说明书」,告诉别人可以用哪些功能。
项目 3:个人财务小助手(15 分钟)
目标:综合运用,做一个能读取账单、计算汇总、输出报表的小工具。
data/bills.json(模拟数据文件):
[
{"type": "income", "desc": "工资", "amount": 15000},
{"type": "expense", "desc": "房租", "amount": 3500},
{"type": "expense", "desc": "吃饭", "amount": 2500},
{"type": "expense", "desc": "交通", "amount": 300},
{"type": "income", "desc": "兼职", "amount": 2000},
{"type": "expense", "desc": "购物", "amount": 1500}
]
packages/calculator/statistics.py:
from typing import List, Dict
def sum_by_type(bills: List[Dict], target_type: str) -> float:
"""按类型求和"""
return sum(b['amount'] for b in bills if b['type'] == target_type)
def calc_balance(bills: List[Dict]) -> float:
"""计算结余"""
income = sum_by_type(bills, 'income')
expense = sum_by_type(bills, 'expense')
return income - expense
def top_expenses(bills: List[Dict], n: int = 3) -> List[Dict]:
"""找出最大的n笔支出"""
expenses = [b for b in bills if b['type'] == 'expense']
return sorted(expenses, key=lambda x: x['amount'], reverse=True)[:n]
apps/finance_report.py:
import sys
import json
sys.path.insert(0, '..')
from packages.calculator.statistics import sum_by_type, calc_balance, top_expenses
from packages.shared_utils.format import format_currency
# 读取账单数据
with open('data/bills.json', 'r', encoding='utf-8') as f:
bills = json.load(f)
# 生成报表
print("=" * 30)
print(" 个人财务月报")
print("=" * 30)
income = sum_by_type(bills, 'income')
expense = sum_by_type(bills, 'expense')
balance = calc_balance(bills)
print(f"\n收入:{format_currency(income)}")
print(f"支出:{format_currency(expense)}")
print(f"结余:{format_currency(balance)}")
print("\n--- Top 3 支出 ---")
for i, item in enumerate(top_expenses(bills), 1):
print(f"{i}. {item['desc']}: {format_currency(item['amount'])}")
print("\n--- 支出明细 ---")
for bill in bills:
if bill['type'] == 'expense':
icon = "💸" if bill['amount'] > 1000 else "📦"
print(f"{icon} {bill['desc']}: {format_currency(bill['amount'])}")
运行结果:
==============================
个人财务月报
==============================
收入:¥17,000.00
支出:¥7,800.00
结余:¥9,200.00
--- Top 3 支出 ---
1. 房租: ¥3,500.00
2. 吃饭: ¥2,500.00
3. 购物: ¥1,500.00
--- 支出明细 ---
💸 房租: ¥3,500.00
📦 吃饭: ¥2,500.00
📦 交通: ¥300.00
💸 购物: ¥1,500.00
一句话解释:这个项目把「数据读取→计算→格式化输出」串了起来,展示了 Monorepo 架构下多个包协作的方式。
💪 进阶:常见坑 + 小技巧
坑 1:循环导入(Import Cycle)
# ❌ 错误示例
# a.py 导入 b.py,b.py 又导入 a.py,会报错
# a.py
# from b import func_b
# def func_a(): pass
# b.py
# from a import func_a # 这里会炸
# ✅ 正确做法:把共享的放到第三个文件
# shared.py - 放两个模块都要用的东西
# a.py - from shared import something; from b import func_b
# b.py - from shared import something; def func_b(): pass
坑 2:相对导入写错路径
# ❌ 错误示例
# 假设在 apps/main.py 里,想导入 packages
# import packages.shared_utils # 找不到!
# ✅ 正确做法:加 sys.path
import sys
sys.path.insert(0, '..') # 往上一级找
from packages.shared_utils import format_currency
坑 3:__all__ 漏写想暴露的函数
# ❌ 错误示例:__all__ 里没写新函数,外部用不了
def new_func():
pass
__all__ = ['old_func'] # new_func 暴露不了!
# ✅ 正确做法
__all__ = ['old_func', 'new_func']
坑 4:路径依赖硬编码
# ❌ 错误示例:换个位置就挂了
data = open('/Users/apple/workspace/data/bills.json')
# ✅ 正确做法:用相对路径或配置文件
import os
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
data_path = os.path.join(base_dir, 'data', 'bills.json')
坑 5:忘了 __init__.py 文件
重要:Python 3.3 之前,每个包目录必须有
__init__.py才能被当作模块导入。
# ✅ 懒人写法:空文件也行
# packages/shared_utils/__init__.py
# (留空即可)
调试技巧:善用 __file__ 定位
import os
print(f"当前文件:{__file__}")
print(f"当前目录:{os.path.dirname(__file__)}")
print(f"项目根目录:{os.path.dirname(os.path.dirname(__file__))}")
这个技巧在调试路径问题时特别有用,相当于代码里的「GPS定位」。
✏️ 练习题
练习 1(2 分钟):换个数字试试
# 修改 format_currency 的输入,输出你的生日年份(当作金额)
# 例如:1995 -> "¥1,995.00"
- 预期输出:
¥1,995.00 - 提示:直接把数字传给
format_currency()就行
练习 2(3 分钟):加个判断
在 calc_balance 函数里,加一个判断:如果 balance < 0,返回 "负债";否则返回 "盈余"。
- 输入:
balance = -500 - 预期输出:
"负债" - 提示:用一个简单的 if 判断即可
练习 3(5 分钟):处理新数据
新建一个 data/expenses.json,包含你自己的3笔消费记录,然后用 top_expenses() 找出最大的一笔。
- 输入:
[{"type": "expense", "desc": "电影票", "amount": 50}, ...] - 预期输出:金额最大的那笔消费
练习 4(10 分钟):串起来
把「计算器」和「统计」功能结合起来:读入一个表达式列表(如 ["100 + 50", "200 - 80"]),计算每个结果,然后统计这些结果的总和。
- 输入:
["10 + 5", "20 * 2", "100 / 4"] - 预期输出:
sum = 70.0
练习 5(5 分钟):报错分析
以下代码报错 ModuleNotFoundError: No module named 'packages',请分析原因并修复:
import sys
# 假设这个文件在 apps/calculator_app.py
from packages.calculator import calculate # 这行报错
print(calculate("2 + 3"))
- 预期输出:
5.0 - 提示:检查 sys.path 配置
作业:做一个「团队代码库浏览器」
需求:模拟一个团队的多人协作场景,有以下包和功能:
目录结构:
team-workspace/
├── packages/
│ ├── user_manager/ # 用户管理包
│ │ └── __init__.py
│ └── project_tracker/ # 项目追踪包
│ └── __init__.py
└── apps/
└── dashboard.py # 主程序
功能点:
1. user_manager:管理团队成员(添加、查询)
2. project_tracker:管理项目状态(新建、标记完成)
3. dashboard:汇总显示团队成员和项目状态
加分项:
- 用 format_currency 或自定义的格式化函数美化输出
- 添加「项目进度百分比」统计
验收标准:
- 能跑起来
- 输出包含至少 3 个成员和 3 个项目
- 代码有注释说明每个函数干嘛
提交方式:评论区贴完整代码,或发 GitHub 链接
📚 总结 + 资源
本文学到的 3 个核心点:
- Monorepo 是一种「把所有项目放一个仓库」的架构,方便共享代码和统一管理
- Python 用
sys.path+__init__.py可以模拟 workspace 的模块导入 - 多个包之间通过「上一级目录」的方式互相引用,实现代码共享
延伸学习资源:
| 资源 | 推荐理由 |
|---|---|
| Python 官方文档 - 模块 | 最权威的模块系统讲解 |
| 《Architecture Patterns with Python》 | 讲大型 Python 项目如何组织 |
| pnpm 官方文档 - workspace | 想用原生工具可以看看 |
互动钩子:
你在公司用过 Monorepo 或者类似的多项目管理方案吗?遇到过什么坑?评论区聊聊,老粉优先回复!
下章预告:
代码写完了,怎么让它们跑在网上而不是本地?下一章我们来聊部署——Vercel、Netlify、Nginx,总有一款适合你。

评论(0)