第7章 7.3 Monorepo:pnpm workspace

🎯 开场:为什么你的代码越来越难管?

上一章我们搞定了代码风格问题,ESLint 和 Prettier 让团队代码整整齐齐。但新问题来了——

你的公司有三个项目:
- web-admin:后台管理系统
- mobile-api:移动端接口
- shared-utils:大家都要用的工具函数

现在 shared-utils 改了一个函数,三个项目都得手动复制粘贴。版本一乱,整个人都麻了。

「说白了:多个项目之间共享代码,就像一栋楼的多个住户共用一个快递柜——得有个统一的管理系统,不然乱成一锅粥。」

这章你能解决什么?

学完本文,你将理解:
- Monorepo 是什么、为什么大型项目都在用
- 用 Python 模拟 workspace 的核心思想
- 多个「包」之间如何共享代码、保持版本一致


🧱 基础:Monorepo 到底是什么?

1. 从「拆快递」理解 Monorepo

想象你家门口有个快递柜(\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\n\nMonorepo),里面分了很多格子(workspace)。

快递柜(Monorepo)
├── 格子1:生鲜(web-admin)
├── 格子2:书籍(mobile-api)
└── 格子3:日用品(shared-utils)

每个格子都是独立的,但都在同一个柜子里。你想找东西,直接开对应格子就行,不用满院子翻。

类比到代码:
- web-adminmobile-apishared-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 个核心点:

  1. Monorepo 是一种「把所有项目放一个仓库」的架构,方便共享代码和统一管理
  2. Python 用 sys.path + __init__.py 可以模拟 workspace 的模块导入
  3. 多个包之间通过「上一级目录」的方式互相引用,实现代码共享

延伸学习资源:

资源 推荐理由
Python 官方文档 - 模块 最权威的模块系统讲解
《Architecture Patterns with Python》 讲大型 Python 项目如何组织
pnpm 官方文档 - workspace 想用原生工具可以看看

互动钩子:

你在公司用过 Monorepo 或者类似的多项目管理方案吗?遇到过什么坑?评论区聊聊,老粉优先回复!


下章预告:

代码写完了,怎么让它们跑在网上而不是本地?下一章我们来聊部署——Vercel、Netlify、Nginx,总有一款适合你。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。