项目声称能生成学习计划——你从哪里开始验证?¶
跨章复习检查点¶
在进入正文之前,暂停回忆四件事——不看资料:
- 静态类型契约和运行时数据边界分别在什么时机、什么位置约束数据? (P01:type hints 在开发期由检查器追踪调用关系;运行时边界在系统入口处拦截 不可信外部数据。二者不能互相替代。)
- 一个公开函数的返回类型如果是
Plan | PlanFailure,调用方能从中获得什么? (P02:调用方能用模式匹配或类型检查区分成功与失败分支,不需要依赖隐含约定 或捕获异常。) - adapter 和 core 之间的依赖方向应该是什么? (P03:adapter 依赖 core 和 contracts,core 不 import adapter;新入口只需 新增 adapter,不需要改 core。)
- README 应该概括什么、链接什么、不出现什么? (P06:概括只有 README 负责的事实,链接由其他文件维护的详细事实,不出现 当前尚不存在的能力。)
如果四条中有两条以上只能说出大致方向但回忆不出具体依据,建议回到对应章节 浏览一下小结部分。本章需要把这些局部关系当作审查工具同时使用。
你面前是一个候选项目仓库。它声称做的事情只有一件:
浏览器提交学习目标,服务端调用
build_plan生成学习计划。
仓库给了你 contracts、core、adapter、Git 内容和 README。你要回答的问题不是 "文件存不存在"——而是从这些材料的公开行为中,能找到多少证据支持它真的能 完成那件事。
这是工程审查的起点:不是检查目录是否齐全,是追踪一条公开操作从外部输入到 最终结果的每一步,看证据链在哪里断裂。
1. 从公开操作出发:你要找什么证据¶
假设你刚加入这个项目,同事只告诉你一句话:"用户从浏览器提交学习目标,系统 返回一个学习计划。"现在你要核实这句话。
暂停想一下:你会沿途检查哪几类东西?
不是"contracts.py 存在""core.py 存在"这种清单。而是围绕同一个操作 回答一组递进问题:
- 输入边界——外部数据以什么形式进入系统?谁负责确认它是可信的?
- 核心结果——进入可信状态后,系统做了什么计算?结果的类型和语义是什么?
- 失败语义——如果数据不合法、计算不成功,调用方看到的是什么?
- 交付事实——代码能跑吗?仓库里有什么?新人怎么知道从哪里开始?
这四类问题不是分别检查四个文件,而是沿同一条数据流追踪它从入口到出口
经历的每一层。当某一层的证据和上一层矛盾——比如签名声称接收 LearningGoal
对象,但实际传入的是原始字典——证据链就断了。
接下来我们按这四类逐一核对候选项目的材料。
2. 核对类型与运行时边界¶
先看候选项目的核心函数签名:
# core.py
from contracts import LearningGoal, Plan
def build_plan(goal: LearningGoal) -> Plan:
weekly_hours = goal.weekly_hours
daily_minutes = (weekly_hours * 60) // 7
return Plan(topic=goal.topic, daily_minutes=daily_minutes)
签名很清楚:build_plan 接收一个 LearningGoal,返回一个 Plan。调用方
看到这个签名,会合理预期传入一个已经通过验证的 LearningGoal 实例。
现在看 adapter 怎么调用它:
# http_adapter.py
from core import build_plan
def handle_request(request):
try:
data = request.json()
result = build_plan(data) # data 是 dict,不是 LearningGoal
return {"status": "ok", "plan": result}
except Exception:
return None
暂停预测:这段代码有什么问题?
至少三个:
第一,注解不会转换字典。 build_plan(goal: LearningGoal) 的类型标注告诉
开发工具"这里应该传 LearningGoal",但 Python 运行时不会自动把 dict 变成
LearningGoal。data 是字典,传进去就是字典——goal.topic 会变成
dict.__getattr__ 调用,抛出 AttributeError。
第二,adapter 没有运行时边界。 在 P01 和 P04 中我们建立了一个判断: 不可信外部数据进入系统时,应该在入口处经过运行时验证,变成可信的 typed 对象后 再传入核心逻辑。这里完全跳过了那一步——外部字典直接灌入 core。
第三,None 抹平了所有失败类别。 输入格式错误?返回 None。计算逻辑
抛异常?返回 None。网络问题?返回 None。调用方只能看到"没有结果"——
无法区分是自己的请求不合法、是业务计算失败、还是系统内部出了问题。
P04 中我们区分了三类失败:边界拒绝(无效输入)、业务失败(输入合法但不满足
业务规则)和实现缺陷(代码 bug)。这里的 except Exception: return None
把三类全部混成一个无语义的空值。
到这里,仅仅追踪"输入 → 核心 → 返回"这一小段数据流,证据链已经断了两处: 外部数据没有经过运行时边界,失败语义被完全抹平。
3. 核对模块依赖方向¶
类型和边界的问题确认了。现在看模块关系。P03 建立的判断是:adapter 依赖 core 和 contracts,core 本身不应该依赖任何具体入口框架。
看候选项目的另一个版本(有些候选项目在重构后出现了这种结构):
# core.py(问题版本)
from fastapi import Request
from contracts import Plan
def build_plan(request: Request) -> Plan:
data = request.json()
topic = data["topic"]
weekly_hours = data["weekly_hours"]
daily_minutes = (weekly_hours * 60) // 7
return Plan(topic=topic, daily_minutes=daily_minutes)
暂停预测:如果现在要增加一个 CLI 入口——python -m app.cli --topic Python
--weekly-hours 6——你需要改哪些文件?
build_plan 的签名要求传入 Request 对象。CLI 不是 HTTP 请求——它没有
Request。如果你要从命令行调用 build_plan,你只有两个选择:
选择一:伪造一个 Request 对象。 在 CLI 中 from fastapi import Request,
构造一个假的 request,塞入参数,传给 build_plan。这意味着 CLI adapter
被迫依赖 FastAPI——一个和命令行完全无关的 HTTP 框架。
选择二:修改 core。 把 build_plan 的签名从 Request 改成业务契约
(LearningGoal),让 core 不再依赖 HTTP 框架对象。
选择二才是正确方向——但它揭示了一个问题:当前的 core 和 adapter 的职责 倒置了。本该由 adapter 负责的"从传输格式提取数据"的工作,被放进了 core。 本该只关心业务逻辑的 core,却需要知道数据是从 HTTP request 里来的。
用 P03 的 import 箭头来画:
当前(倒置):
http_adapter → core → fastapi.Request
修正后:
http_adapter → core → contracts
cli_adapter → core → contracts
修正后,core 只依赖 contracts 中定义的业务类型。任何新 adapter——HTTP、CLI、
测试脚本——都只需要实现"从自己的输入格式构造 LearningGoal",然后调用
build_plan(goal)。core 完全不需要改。
这就是 P03 说的"加个 CLI,为什么 core 也要改"的原理。如果核对依赖方向时 发现 core import 了具体框架对象,那 adapter 变化就会穿透到本不该变的核心 模块。
4. 核对 Git 与 README¶
代码层面的证据链审查完了。现在看仓库交付层面。
候选项目的 git status 输出:
$ git status
On branch main
Changes not staged for commit:
modified: .env
Untracked files:
.gitignore
$ git ls-files
.env
__pycache__/core.cpython-312.pyc
.venv/pyvenv.cfg
contracts.py
core.py
http_adapter.py
README.md
pyproject.toml
暂停想一下:从 P05 的判断框架看,这里有什么问题?
至少两处:
.venv/ 和 __pycache__/ 被跟踪了。 按 P05 的分类,虚拟环境是机器
相关的本地状态,字节码缓存是自动生成的派生物——都不应该进入 Git。它们的存在
说明项目初始化时没有配置 .gitignore(果然,.gitignore 还在 untracked
状态)。
.env 被跟踪且已修改。 .env 包含本地配置甚至可能有 secret。它已经
进入了 Git 历史,现在的 .gitignore(即使提交了)也不会自动移除它的跟踪
状态——P05 第三节专门解释过这个问题。
再看 pyproject.toml 的依赖声明:
锁文件呢?git ls-files 里没有 uv.lock 或任何锁文件。P05 讨论过:锁文件
保证不同时间、不同机器安装出相同的依赖版本。没有锁文件,协作者在不同时间
uv sync 可能得到不同的 Pydantic 小版本。
现在看 README:
# Learning Planner
基于 FastAPI 的智能学习规划 API。
## 快速开始
docker compose up -d
curl http://localhost:8000/plans -d '{"topic":"Python"}'
暂停判断:根据 P06 的框架,这份 README 哪些内容有问题?
README 声称这是"基于 FastAPI 的 API",但当前仓库只有 contracts、core 和
http_adapter——没有 FastAPI 应用实例,没有 main.py。README 给了
docker compose up -d,但仓库里没有 Dockerfile,没有 docker-compose.yml。
新人按照 README 操作,每一步都会失败。
P06 的判断:这些内容属于虚构——描述了当前不存在的能力,而不是导航读者到 真实入口。README 应该承认当前状态("库模块,无独立服务入口"),并指向真实 存在的代码入口。
综合第二、三、四节的发现:
| 证据层 | 发现的问题 | 破坏了什么 |
|---|---|---|
| 类型与边界 | 外部字典直接传入 core,无运行时验证 | 输入可信前提 |
| 类型与边界 | except Exception: return None |
失败语义可区分性 |
| 模块依赖 | core 依赖 HTTP 框架对象 | 新入口需要修改 core |
| Git 内容 | .venv/、__pycache__/、.env 被跟踪 |
仓库交付边界 |
| Git 内容 | 无锁文件 | 依赖一致性 |
| README | 声称 Docker 和 FastAPI 可用 | 首次进入者的启动任务 |
六处断裂——每一处都可以用前六章建立的判断框架精确定位。
5. 最小化还是过度:两种修正方案¶
假设你要修正上面所有问题。团队提出了两个方案:
方案 A: 修正当前结构——
learning_planner/
├── contracts.py # LearningGoal, Plan, PlanFailure
├── core.py # build_plan(goal: LearningGoal) -> Plan | PlanFailure
├── http_adapter.py # 从 HTTP 请求构造 LearningGoal,调用 core
├── pyproject.toml
├── uv.lock
├── .gitignore
├── .env.example
└── README.md
三类职责清晰:contracts 定义业务类型,core 实现业务逻辑,adapter 负责外部 格式转换。每个文件有明确的变化原因。
方案 B: 引入"完整分层架构"——
learning_planner/
├── domain/
│ ├── entities/
│ │ └── learning_goal.py
│ ├── value_objects/
│ │ └── plan.py
│ └── services/
│ └── plan_service.py
├── application/
│ └── use_cases/
│ └── create_plan.py
├── infrastructure/
│ ├── repositories/
│ │ └── plan_repository.py
│ └── adapters/
│ └── http_adapter.py
├── ports/
│ ├── input/
│ │ └── plan_port.py
│ └── output/
│ └── plan_store_port.py
├── pyproject.toml
├── uv.lock
├── .gitignore
├── .env.example
└── README.md
暂停判断:在当前阶段——项目只支持"输入学习目标 → 返回学习计划"这一个 操作——哪个方案更合理?
看方案 B 里的那些文件:plan_repository.py——当前没有数据库,repository
只能是空壳。plan_store_port.py——当前没有持久化需求,port 只能转发。
use_cases/create_plan.py——当前只有一个操作,use case 只是把调用转一道手。
六个目录中大部分文件的当前实现是转发——它们不承担独立职责,只是把调用 从一处搬到另一处。它们的存在不是因为当前有证据需要这些层,而是因为"将来 可能需要"。
但 P03 建立的判断是:模块边界由当前职责决定,不由预期的未来需求决定。 一个没有独立变化原因的文件——比如只转发调用的 use case——不构成真实的模块 边界,它只是一层间接。
那"可扩展"呢?来看两个具体场景:
场景一:增加 CLI adapter。 方案 A 只需新增一个 cli_adapter.py,让它
构造 LearningGoal 并调用 core.build_plan。方案 B 需要你理解六层结构,
在 ports/input 创建 CLI port,在 infrastructure/adapters 添加 CLI
adapter,然后穿过 use case 到达 domain service。做的事情一样,路径多了三层。
场景二:增加数据库持久化。 到那时,确实需要 repository 和可能的 port 抽象。但那是"已知下一步变化确实发生"之后的事——在变化发生时增加对应层次, 远比现在预建空壳再维护它们更经济。
总结这个判断:
- 最小化不是"文件最少",而是当前每个文件都有直接的职责证据。
- 可扩展不是"预建所有将来可能的层",而是公开接口和依赖方向允许将来 有依据地增量添加。
- 方案 A 满足最小化(三个文件各有独立职责),也满足可扩展(core 不依赖 具体 adapter,新 adapter 只需 import core 和 contracts)。
- 方案 B 在当前阶段引入了没有独立职责的空层——那不是扩展性,是虚假结构。
边界与常见误区¶
本章把前六章的关系组织为一次围绕公开操作的联合审查。以下是它不意味着 的东西:
-
不是通用检查清单。本章的审查路径是围绕"浏览器提交目标→生成计划"这 一个操作展开的。不同项目的公开操作不同,证据链的具体内容也不同。方法可以 迁移,清单不能照搬。
-
不意味着所有六层必须同时完美。在真实项目中,你可能先修复最致命的断裂 (比如缺少运行时边界导致数据静默损坏),再处理次要问题(比如 README 不够 完整)。优先级取决于"哪个断裂破坏了当前最重要的可观察行为"。
-
不是架构评分表。这里不存在"三层 80 分、六层 100 分"的评判。层数不是 质量指标——有职责证据的层是必要的,只转发的层是多余的。
-
不替代运行测试。联合审查是设计层面的一致性检查,不能证明代码运行正确。 测试属于后续课程(W01-L02 及更后面),不在本课完成条件内。
-
不预设 W01-L02 的目录细节。本章只说明"core 不依赖具体 adapter"这个 方向允许增加 FastAPI 路由,但具体的路由组织、依赖注入和中间件配置是后续 课程的事。
本章小结¶
工程基线审查不是确认文件清单,而是围绕一个公开操作建立证据链——从外部输入 到核心结果到交付事实,每一层的证据必须与上下层一致。
证据链断裂的典型模式:
- 类型与边界断裂——签名声称接收 typed 对象,实际传入原始字典;运行时
没有验证步骤;失败语义被
None或空异常捕获抹平。 - 模块依赖倒置——core 依赖具体框架对象,导致新入口被迫修改不该改的模块。
- 交付事实不一致——Git 跟踪了不该跟踪的内容,缺少锁文件;README 声称 了不存在的能力。
"最小但可扩展"的判断标准:
- 最小——当前每个模块都有直接的职责证据,没有只转发的空层。
- 可扩展——公开接口和依赖方向允许新入口只通过新增 adapter 接入,不需要修改 core 或预建当前无证据的层。
这个判断方法在新入口出现时可以直接迁移——下一步加 CLI 也好、加 FastAPI 路由 也好,核对方式是一样的:新入口需要什么类型?依赖谁?失败怎么暴露?Git 和 README 需要同步什么?
练习¶
练习一:CLI adapter 综合迁移¶
项目决定新增 CLI 入口,用户可以执行:
CLI 应复用已有的 build_plan 逻辑,输出学习计划到终端。
当前修正后的项目结构为方案 A:
learning_planner/
├── contracts.py # LearningGoal, Plan, PlanFailure
├── core.py # build_plan(goal: LearningGoal) -> Plan | PlanFailure
├── http_adapter.py # 从 HTTP 请求构造 LearningGoal,调用 core
├── pyproject.toml
├── uv.lock
├── .gitignore
├── .env.example
└── README.md
请回答以下问题:
- CLI adapter 的输入数据从哪里来?它在哪里建立运行时边界(确保数据可信)?
- CLI adapter 应该 import 什么?core 需要改动吗?
- 如果用户输入了无效的
--weekly-hours(比如负数或非整数),CLI 应该如何 处理?与 HTTP adapter 的处理有什么区别,有什么共同点? - 新增 CLI 后,Git 仓库需要新增或修改哪些文件?
.gitignore需要变吗? - README 需要更新什么内容?哪些用"概括"方式写,哪些用"链接"方式写?
练习二:课程回忆——无资料复述¶
不看任何前文或参考资料,尝试回答:
- 静态类型契约和运行时数据边界的核心区别是什么?各自在什么时候、什么位置 起作用?
- 一个函数签名返回
Plan | PlanFailure对调用方意味着什么?和返回None有什么根本区别? - 如果 core 导入了
fastapi.Request,这说明什么问题?加新入口时会怎样? .gitignore能不能让一个已经被 Git 跟踪的文件停止被跟踪?为什么?- README 中"链接到来源"比"复制来源内容"的优势是什么?举一个具体例子。
练习解析¶
练习一解析¶
1. CLI 的输入数据和运行时边界:
CLI 的输入来自命令行参数(--topic Python --weekly-hours 6),通常用
argparse 或类似库解析为字符串和数值。运行时边界应在 CLI adapter 内部建立:
从解析后的参数构造 LearningGoal 实例。如果使用 Pydantic 的 LearningGoal,
构造时就会触发验证——weekly_hours 必须是正整数,topic 必须是非空字符串。
关键是:边界建立在 adapter 内部、core 之前——和 HTTP adapter 的位置一样。
core 始终接收已验证的 LearningGoal 对象,不需要知道数据是从 HTTP body
还是命令行参数来的。
2. CLI adapter 的 import 和 core 的变化:
# cli_adapter.py
from contracts import LearningGoal, Plan, PlanFailure
from core import build_plan
def main():
# 解析命令行参数...
goal = LearningGoal(topic=topic, weekly_hours=weekly_hours)
result = build_plan(goal)
# 根据 result 类型输出...
CLI adapter 导入 contracts 和 core——和 HTTP adapter 的 import 结构完全
一样。core 不需要任何改动,因为 build_plan 的签名是 (LearningGoal)
-> Plan | PlanFailure,和入口来源无关。
这正是"core 不依赖具体 adapter"带来的可扩展性:新入口不触碰 core。
3. 无效输入的处理:
如果 --weekly-hours 是负数或非整数:
- 共同点:都在 adapter 层建立运行时边界;都使用
LearningGoal构造来 触发验证;验证失败后都不调用build_plan。 - 区别:HTTP adapter 返回 HTTP 状态码(如 422)和 JSON 错误信息给客户端; CLI adapter 向终端打印可读的错误提示(如"错误:weekly-hours 必须是正整数") 并退出(非零退出码)。
失败语义的本质相同(拒绝无效输入、向调用方暴露错误原因),但表达方式 随入口格式变化。这也是 adapter 的职责:翻译外部格式,包括翻译错误格式。
4. Git 仓库的变化:
新增文件:
- cli_adapter.py(或 cli.py)——新代码,跟踪。
- pyproject.toml 中可能新增 [project.scripts] 入口点声明——已跟踪文件
的修改,自然包含在下次提交中。
.gitignore 不需要变——CLI adapter 是源码,属于项目事实。新增源码文件
不影响 ignore 规则;ignore 规则只需要在新增本地状态或生成物类别时调整。
uv.lock 如果未引入新依赖则不变。如果 CLI 需要新增依赖(比如 click),
则 pyproject.toml 和 uv.lock 都需要更新并提交。
5. README 更新:
需要更新的内容:
- 当前范围:"现在支持 HTTP 入口和 CLI 入口"——用概括方式,因为 这是只有 README 负责声明的信息。
- CLI 使用方式:
python -m app.cli --topic Python --weekly-hours 6—— 用概括方式,因为这条命令相对稳定,且 README 是告诉读者"怎么开始"的 地方。 - 模块结构概览:新增一行"cli_adapter.py — CLI 入口适配"——用概括 方式(目录级别)。
- 依赖变化:如果新增了 CLI 库,仍然是"依赖见 pyproject.toml"——用 链接方式,不在 README 里列版本号。
不需要在 README 里写 CLI 的所有参数说明——那属于 --help 自己负责的事实。
练习二解析¶
1. 静态契约 vs. 运行时边界:
静态类型契约在开发期、代码中起作用——类型检查器(如 mypy)读取标注, 验证调用关系是否匹配。它不在运行时执行任何检查。
运行时数据边界在程序运行时、系统入口处起作用——当外部数据(HTTP 请求体、命令行参数、文件内容)进入系统时,运行时验证(如 Pydantic 模型构造) 实际检查数据的类型和约束。
两者解决不同问题:静态契约防止开发者错误调用已有代码;运行时边界防止不可信 外部数据进入核心逻辑。
2. Plan | PlanFailure vs. None:
返回 Plan | PlanFailure 告诉调用方:这个函数有两种有意义的结果——成功
(Plan)和业务失败(PlanFailure)。调用方必须处理两个分支,类型检查器
会在只处理一种时发出警告。PlanFailure 还可以携带失败原因(如
"weekly_hours 超出合理范围")。
返回 None 只告诉调用方"可能没有结果"——不知道为什么没有。是输入不合法?
业务规则不满足?还是代码出了 bug?调用方无法区分,也无法给用户有意义的
反馈。
根本区别:Plan | PlanFailure 保留了失败语义;None 抹平了所有失败
类别。
3. core 导入 fastapi.Request 的问题:
这说明核心业务模块依赖了具体的传输框架。后果是:任何不使用 FastAPI 的入口 (CLI、测试脚本、消息队列消费者)都被迫依赖 FastAPI,或者被迫修改 core 去掉这个依赖。这违反了"adapter 依赖 core,core 不依赖 adapter"的方向—— 职责和依赖都倒置了。
加新入口时,要么伪造 HTTP 对象(荒谬),要么重构 core(本不该改)。
4. .gitignore 不能停止跟踪已有文件:
不能。.gitignore 只对尚未被跟踪(untracked)的文件路径生效——它告诉
Git"不要开始跟踪这些路径"。已经在 Git 索引中的文件(tracked)不受
.gitignore 影响,修改仍然会出现在 git diff 中。
要停止跟踪一个已有文件,需要 git rm --cached <file> 将它从索引中移除,
然后提交这个变更。之后 .gitignore 才会对该路径生效。
5. 链接 vs. 复制的优势:
链接面向来源的存在性,复制面向某时刻的具体内容。来源内容变化时, 链接不需要更新(它指向的地方自己会更新),复制必须同步修改。
具体例子:README 写"依赖见 pyproject.toml"(链接)vs. 写
"pydantic==2.9.0"(复制)。当 Pydantic 升级到 2.10 时,前者不用改
README——读者去看 pyproject.toml 就能看到最新版本。后者如果忘了改,
读者看到的就是过时信息。
参考资料¶
- Python Documentation: typing — Support for type hints——Python 官方类型标注文档
- Pydantic Documentation: Models——Pydantic 模型验证机制
- Git Documentation: gitignore——.gitignore 的匹配规则和作用范围
- Python Documentation: __main__ — Top-level code environment——Python 模块作为脚本运行