
设想如下场景:同事离职,走之前把他负责的项目“交接”给了我。
说是交接,其实就是在工位上指着屏幕跟我讲了40分钟:“这个config是配置,那个utils是工具函数,scripts下面有几个脚本但其中两个没用了别管它,tests……嗯测试我后来没怎么写……”
我当时就想,如果项目结构本身就能说话,何必费这口舌?
后来我接手第二个、第三个项目,发现每个项目目录长得都不一样。有的把所有代码扔一个文件夹,有的层层嵌套七八层,有的README写了一堆但关键信息一个没有。每次接手新项目,都得靠前任口头“翻译”。
痛定思痛,我整理了一套Python项目结构模板,自己用了大半年,团队里推了一阵子。不敢说是最佳实践,但至少交接的时候不用再口头解释了——打开项目,目录结构就是文档。
my-project/
├── README.md # 项目入口,必读
├── pyproject.toml # 依赖管理 + 构建配置
├── Makefile # 常用命令快捷入口
├── .env.example # 环境变量模板
├── .gitignore
├── .github/
│ └── workflows/
│ └── ci.yml # CI配置
├── docs/
│ └── architecture.md # 架构设计文档
├── scripts/
│ ├── setup.sh # 一键初始化
│ └── seed_data.py # 测试数据填充
├── tests/
│ ├── conftest.py # 公共fixture
│ ├── unit/
│ │ └── test_service.py
│ └── integration/
│ └── test_api.py
├── src/
│ └── my_project/
│ ├── __init__.py
│ ├── __main__.py # python -m my_project 入口
│ ├── config.py # 配置加载
│ ├── models/ # 数据模型
│ ├── services/ # 业务逻辑
│ ├── api/ # 对外接口
│ └── utils/ # 工具函数
└── docker/
├── Dockerfile
└── docker-compose.yml别急着说“太复杂了”。我一个个讲,你看看这些文件是不是都有必要。
src/ 布局?这个可能是争议最大的点。很多人习惯了这样:
my-project/
├── my_project/
│ ├── __init__.py
│ └── ...
├── tests/
└── pyproject.toml项目根目录直接放包,看起来简单。但有个坑:你本地开发的时候,Python会从项目根目录找模块,这意味着你跑的是源码而不是安装后的包。本地跑得好好的,一打包发布就挂——因为安装路径、依赖关系可能都不一样。
把包放在 src/ 下面:
src/
└── my_project/
├── __init__.py
└── ...必须先 pip install -e . 安装才能用,本地开发环境和真实安装环境保持一致。这个做法Python官方文档和PyPA都推荐,叫 src layout。
一开始我也觉得多一层目录麻烦,但踩过一次“本地能跑装不上”的坑之后,就再也没回过头。
pyproject.toml 而不是 requirements.txt以前项目里总有这些东西:
requirements.txt
requirements-dev.txt
setup.py
setup.cfg
MANIFEST.in哪个是干啥的?新来的人一脸懵。
pyproject.toml 一个文件搞定,PEP 518/621标准,Python社区已经定了:
[project]
name = "my-project"
version = "0.1.0"
description = "项目描述"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.100.0",
"sqlalchemy>=2.0",
"pydantic>=2.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"pytest-cov",
"ruff",
"mypy",
]
[project.scripts]
my-project = "my_project.__main__:main"依赖、元数据、入口点,全在这一个文件里。不需要再维护 setup.py 和 requirements.txt 两套东西。
开发环境安装:
pip install -e".[dev]"一行搞定,开发和运行依赖都装上。
Makefile — 团队新人的救命稻草新同事入职第一天, clone完代码问你:“怎么跑?怎么测?怎么格式化?”
以前我得口头说:先装依赖,然后跑pytest,flake8检查代码风格……现在我只说一句:make。
.PHONY: install test lint format run clean
install:
pip install -e ".[dev]"
test:
pytest tests/ -v --cov=my_project
lint:
ruff check src/ tests/
mypy src/
format:
ruff format src/ tests/
run:
python -m my_project
clean:
rm -rf build/ dist/ *.egg-info .pytest_cache .mypy_cache
find . -type d -name __pycache__ -exec rm -rf {} +make test 跑测试,make lint 检查代码,make run 启动项目。不用记命令,不用翻文档,make 一下就能看到所有可用命令。
Makefile的本质很简单:把团队约定变成可执行文件,而不是口头约定。
config.py — 别再把配置散落在各处你有没有见过这种代码?
# service.py
DB_HOST = "192.168.1.100" # 硬编码
API_KEY = "sk-xxxxx" # 直接写源码里
TIMEOUT = int(os.environ.get("TIMEOUT", "30")) # 到处都是环境变量读取配置散落在十几个文件里,改一个数据库地址要翻遍整个项目。更别提密钥直接写在代码里这种操作——我见过不止一次推到Git上去了。
用一个统一的配置模块:
# src/my_project/config.py
frompydantic_settingsimportBaseSettings
fromfunctoolsimportlru_cache
classSettings(BaseSettings):
# 应用配置
app_name: str = "my-project"
debug: bool = False
# 数据库
db_host: str = "localhost"
db_port: int = 5432
db_name: str = "mydb"
db_user: str = ""
db_password: str = ""
# 外部服务
api_key: str = ""
api_timeout: int = 30
model_config = {"env_file": ".env"}
@lru_cache
defget_settings() ->Settings:
returnSettings()所有配置集中管理,类型有校验,默认值有文档,环境变量自动读取。哪里需要配置就 from my_project.config import get_settings,不用到处 os.environ.get。
.env.example 是给新人看的模板:
# .env.example — 复制为 .env 后填写实际值
DB_HOST=localhost
DB_PORT=5432
DB_NAME=mydb
DB_USER=your_user
DB_PASSWORD=your_password
API_KEY=your_api_key
API_TIMEOUT=30新人clone完项目,复制 .env.example 为 .env,填上自己的配置,就能跑起来。不用再问你“数据库地址是什么”“API密钥找谁要”。
models/、services/、api/ — 分层不是装腔作势很多人写Python喜欢把所有逻辑塞一个文件,甚至一个函数从头写到尾。几百行的函数,数据库查询、业务判断、响应格式化全搅在一起。改一处怕牵连其他,最后谁都不敢动。
拆成三层:
src/my_project/
├── models/ # 数据长什么样
│ ├── __init__.py
│ ├── user.py # 用户模型
│ └── order.py # 订单模型
├── services/ # 业务怎么干
│ ├── __init__.py
│ ├── user_service.py
│ └── order_service.py
├── api/ # 外部怎么调
│ ├── __init__.py
│ └── routes.py各层干的事不一样:
举个具体的例子:
# models/user.py — 数据模型
fromsqlalchemyimportColumn, String, Boolean
frommy_project.models.baseimportBase
classUser(Base):
__tablename__ = "users"
id = Column(String, primary_key=True)
name = Column(String)
email = Column(String, unique=True)
is_active = Column(Boolean, default=True)# services/user_service.py — 业务逻辑
frommy_project.models.userimportUser
frommy_project.configimportget_settings
classUserService:
def__init__(self, db_session):
self.db = db_session
defget_user(self, user_id: str) ->User|None:
returnself.db.query(User).filter_by(id=user_id).first()
defdeactivate_user(self, user_id: str) ->bool:
user = self.get_user(user_id)
ifnotuser:
returnFalse
user.is_active = False
self.db.commit()
returnTrue# api/routes.py — 接口层
fromfastapiimportAPIRouter, Depends
frommy_project.services.user_serviceimportUserService
router = APIRouter()
@router.get("/users/{user_id}")
defget_user(user_id: str, service: UserService = Depends()):
user = service.get_user(user_id)
ifnotuser:
return {"status": "not_found"}
return {"id": user.id, "name": user.name}这样分层的好处:改接口不影响业务逻辑,换数据库不影响接口,写单元测试可以绕过HTTP直接测service。每一层只关心自己的事,改动的范围才可控。
__main__.py — 统一入口之前见过各种启动方式:有人 python app.py,有人 python run.py,有人 python -m some.module.that.is.deep.nested。每个项目不一样,每次都得问。
用 __main__.py:
# src/my_project/__main__.py
frommy_project.configimportget_settings
frommy_project.apiimportcreate_app
defmain():
settings = get_settings()
app = create_app(settings)
app.run(host="0.0.0.0", port=8000)
if__name__ == "__main__":
main()启动方式统一:
python -m my_project不用记入口文件叫啥,不用翻README找启动命令。python -m 包名,就这一种。
tests/ — 分清单元测试和集成测试很多项目的 tests/ 目录就是一个大杂烩,单元测试和集成测试混在一起。跑起来有的快有的慢,只想跑快的还得分着挑。
分开来:
tests/
├── conftest.py # 公共fixture
├── unit/ # 不依赖外部,秒级完成
│ └── test_user_service.py
└── integration/ # 需要数据库/外部服务
└── test_api.py# tests/conftest.py
importpytest
frommy_project.services.user_serviceimportUserService
@pytest.fixture
defuser_service():
"""单元测试用的mock service"""
returnUserService(db_session=MockSession())日常开发跑单元测试,速度快:
pytest tests/unit/ -vCI或者上线前跑全量:
pytest tests/ -v这比一锅炖的体验好太多了。
scripts/ — 那些只跑一次的脚本也得有个家每个项目总有几个“偶尔用一下”的脚本:初始化数据库、填充测试数据、迁移旧数据、清理临时文件。以前这些脚本要么散落在项目根目录,要么在某个人的电脑上,要么压根找不到。
给它们一个固定位置:
scripts/
├── setup.sh # 一键初始化开发环境
└── seed_data.py # 填充测试数据setup.sh 是新人入职的第一条命令:
#!/bin/bash
set-e
echo">>> 创建虚拟环境"
python -m venv .venv
source .venv/bin/activate
echo">>> 安装依赖"
pip install -e".[dev]"
echo">>> 配置环境变量"
cp .env.example .env
echo">>> 初始化数据库"
python -m my_project.db init
echo">>> 完成!运行 make run 启动项目"这样不管谁来,clone完跑一个 bash scripts/setup.sh 就能开始干活。
docs/ — 不是让你写长篇大论很多项目要么没文档,要么文档是过时的。问题出在“文档”这个词太吓人了,好像得写个几十页的规范似的。
我 docs/ 里就放一个文件:architecture.md。
内容不超过一页,写三件事就够了:
# 项目架构
## 简介
内部订单管理系统,提供REST API给前端调用。
## 目录说明
- `src/my_project/models/` — 数据模型(ORM + Schema)
- `src/my_project/services/` — 业务逻辑
- `src/my_project/api/` — HTTP接口
- `tests/unit/` — 单元测试(不需要数据库)
- `tests/integration/` — 集成测试(需要数据库)
## 关键决策
- 使用FastAPI:需要异步支持 + 自动生成API文档
- src布局:避免本地开发和安装环境不一致
- Pydantic Settings:统一配置管理,类型安全够用了。以后有人问“这个项目为什么这样组织”,指这个文件就行。
其实项目结构这件事,最怕的不是“不知道怎么组织”,而是“每个人都按自己的习惯来”。
模板的意义从来不是“这个结构最牛”,而是“大家用同一套”。当所有项目长得差不多,你打开任何一个都能在10秒内找到想要的东西,这才是效率。
代码能自己说话,就不用人替它解释。
“无他,惟手熟尔”!有需要的用起来!
本文分享自 Nicholas与Pypi 微信公众号,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文参与 腾讯云自媒体同步曝光计划 ,欢迎热爱写作的你一起参与!