跳转至

标注写着 int,为什么“很多”还是进来了?

你正在给 AI 学习助手实现一个很朴素的功能:用户填写想学什么、每周能投入多少 小时,系统据此生成学习计划。

浏览器通常会提交这样的 JSON:

{
  "topic": "Python typing",
  "weekly_hours": 6
}

看起来没什么可担心的。topic 是文字,weekly_hours 是数字,计划生成函数只要 把每周时间换算成每天时间就行。

但真实用户不会一直配合我们维持世界和平。某次提交可能是:

{
  "topic": "Python typing",
  "weekly_hours": "很多"
}

人一眼就能看出问题:“很多”是一种态度,不是一个适合参加除法运算的数字。 程序却没有这种生活经验。它不会看着 "很多" 沉思片刻,然后温柔地提醒用户: “我理解你的热情,但还是请给一个整数。”

它只会照着代码继续走,直到某一步实在算不下去。

这时你可能会想:给函数加上 type hints 不就好了吗?

def calculate_daily_minutes(weekly_hours: int) -> int:
    return weekly_hours * 60 // 7

参数明明写着 int。如果传进来的不是整数,Python 总该在门口拦一下吧?

答案可能比想象中更有 Python 风格:它会看一眼注解,礼貌地点点头,然后照常 执行。

这一章就从这个反差开始。我们要弄清四件事:

  1. type hints 到底做什么,又明确不做什么;
  2. 外部 JSON 在哪里、怎样变成核心业务可以信任的数据;
  3. Pydantic 与 typed core 如何分工,而不是彼此替代;
  4. 为什么类型正确、输入有效,程序仍然可能把事情做错。

这不是几个术语的辨析题。边界放错以后,错误数据会深入业务逻辑,函数会反复 校验同一份字典,或者整个项目表面上“类型齐全”,实际上谁也不知道数据究竟从 哪一刻开始可信。

我们先不背定义,直接去看事故现场。

运行环境

本章代码使用 Python 3.12 和 Pydantic v2。可以在虚拟环境中安装:

python -m pip install "pydantic>=2,<3"

不同 Pydantic v2 小版本的错误文字可能略有不同,但本章观察的解析、校验和默认 转换行为一致。

1. 参数写着 int,Python 真的会检查吗?

先运行最小版本:

def calculate_daily_minutes(weekly_hours: int) -> int:
    print("已经进入函数")
    return weekly_hours * 60 // 7


minutes = calculate_daily_minutes(6)
print(minutes)

输出:

已经进入函数
51

每周 6 小时等于 360 分钟,平均到 7 天,每天是 51 分钟。整数除法舍去了一点 余数,但这不是本章的重点。

现在换成浏览器送来的 "很多"

minutes = calculate_daily_minutes("很多")

运行时首先打印:

已经进入函数

随后才抛出 TypeError

这行打印非常关键。它证明 Python 并没有因为参数注解是 int,就在函数入口拒绝 字符串。程序已经进入函数体,甚至已经开始运算了。

具体过程还有一点荒诞感:

"很多" * 3

在 Python 中是合法的,结果是:

'很多很多很多'

所以 "很多" * 60 也不会立即失败。Python 会非常认真地生成一长串“很多”, 然后在字符串参加 // 7 时才发现局面无法收拾。

更值得警惕的是,错误类型不一定会报错。它有时会安静地给出一个错误结果:

def double_hours(hours: int) -> int:
    return hours * 2


print(double_hours("6"))

输出不是 12,也不是异常,而是:

66

程序顺利跑完,终端一片祥和,结果却已经偏离业务含义。相比立即报错,这种 “看起来什么都没发生”的错误往往更麻烦。

到这里,我们可以确认两句话并不等价:

  1. 这个函数声明自己需要一个整数。
  2. Python 在运行时保证传进来的对象一定是整数。

type hints 做的是第一件事,不是第二件事。Python 官方 typing 文档明确说明, Python 运行时不会强制执行函数和变量的类型标注。注解可以被 IDE、类型检查器和 linter 使用,但注解本身不会偷偷在函数门口安插一位检查员。

先记住这一点

type hints 会声明预期,却不会自动改造、检查或拒绝运行时对象。

这不意味着 type hints 没有用。恰恰相反,它很有用,只是它干的是另一份工作。

2. type hints 是路线图,不是入口验货

先看一个没有类型信息的业务函数:

def build_plan(goal):
    daily_minutes = goal.weekly_hours * 60 // 7
    return {
        "topic": goal.topic,
        "daily_minutes": daily_minutes,
    }

只看签名,goal 像一个没有标签的纸箱。调用方不知道里面应该装什么:

  • 普通字典可以吗?
  • 必须是一个带属性的对象吗?
  • weekly_hours 可以是 None 吗?
  • 返回值有哪些字段?
  • 调用失败时又会发生什么?

维护者只能打开函数,沿着 goal.weekly_hoursgoal.topic 反向猜测。IDE 同样很为难。它可以提供一些通用帮助,却无法可靠判断调用方传入的对象是否满足 函数需要。

我们把契约写清楚:

from dataclasses import dataclass


@dataclass(frozen=True)
class LearningGoal:
    topic: str
    weekly_hours: int


@dataclass(frozen=True)
class Plan:
    topic: str
    daily_minutes: int


def build_plan(goal: LearningGoal) -> Plan:
    daily_minutes = goal.weekly_hours * 60 // 7
    return Plan(
        topic=goal.topic,
        daily_minutes=daily_minutes,
    )

现在不必钻进函数体,我们已经能读出一条协作关系:

LearningGoal → build_plan → Plan

对调用方来说,签名说明该准备什么、会拿回什么。若误传 strdictNone,类型检查器可以在代码运行前指出不一致。

对实现方来说,函数体可以依赖 goal.topicstrgoal.weekly_hoursint。如果返回了错误对象,或者把可能为空的值直接参加运算,静态检查工具也 更有机会发现问题。

对维护者和 IDE 来说,补全、跳转、重命名和调用链分析终于有了可靠线索。接手 代码的人不必先进行一场“从属性访问猜对象形状”的考古工作。

这就是 type hints 的核心价值:把原本藏在注释、实现细节和开发者脑海里的约定, 变成机器工具与人都能读取的静态类型契约

可以把它想成一张内部路线图。路线图会告诉团队:

  • 这条路预期接收哪种对象;
  • 会把什么结果交给下一段代码;
  • 哪些调用关系明显走错了方向。

但路线图不会检查刚从外面送来的纸箱。箱子上写着“整数”,里面仍然可能装着 "很多"。要知道里面究竟是什么,必须真的拆箱验货。

浏览器、消息队列、配置文件和第三方 API 都在 Python 静态分析的视野之外。它们 不会因为你的函数签名很工整,就自觉配合类型声明。

于是,第二份工作出现了:谁负责入口验货?

3. 外部 JSON 只是原料,还不是业务对象

浏览器发来 JSON 后,程序最初拿到的通常是一份普通字典:

raw_payload = {
    "topic": "Python typing",
    "weekly_hours": "很多",
}

它有两个看起来很熟悉的键,但“长得像”不等于“已经是”。

这份字典还没有回答:

  • 必需字段是否都存在;
  • 值能否形成声明的类型;
  • 失败应该停在哪一层;
  • 成功后,核心业务能依赖哪些前提。

给变量换一个更自信的名字也没有帮助:

goal = raw_payload

名字从 raw_payload 变成 goal,数据没有因此经历任何成长。就像把文件夹 命名为 final_final_really_final,并不会让内容自动变得最终。

给它补一个注解也不行:

goal: LearningGoal = raw_payload

这行代码没有创建 LearningGoal,也没有检查字典。它只是写下了一条与真实对象 不一致的声明。类型注解不是转换按钮,更不是贴上去就生效的质量认证。

我们需要一个真实发生的动作,把外部数据从“不知道能不能信”变成“核心业务可以 依赖这些前提”:

浏览器 JSON
解析与运行时校验
可信的 typed data
计划生成

外部数据跨过的这个位置,就是运行时数据边界

为什么直到这里才正式给它命名?因为现在这个术语不再是从天而降的定义。我们 已经亲眼看见了它要解决的问题:type hints 能画清内部路线,却不能替外部数据 验货。

4. 让 Pydantic 在入口真正看一眼数据

在本课程的工程里,我们使用 Pydantic v2 建立运行时边界:

from pydantic import BaseModel


class LearningGoalInput(BaseModel):
    topic: str
    weekly_hours: int

这里仍然写了类型标注,但接下来发生的事情与普通函数注解不同。调用 model_validate() 时,Pydantic 会在运行时读取真实输入,执行解析与校验。

先让 "很多" 过来试试:

from pydantic import ValidationError


raw_payload = {
    "topic": "Python typing",
    "weekly_hours": "很多",
}

try:
    validated = LearningGoalInput.model_validate(raw_payload)
except ValidationError as exc:
    print(exc)

错误信息会定位到 weekly_hours,大意是:

weekly_hours
  Input should be a valid integer, unable to parse string as an integer

这一次,错误没有继续流入 build_plan(),也没有等到一长串“很多”参加除法时 才被发现。它停在了数据责任最清楚的位置:外部输入进入核心业务之前。

再试合法输入:

raw_payload = {
    "topic": "Python typing",
    "weekly_hours": 6,
}

validated = LearningGoalInput.model_validate(raw_payload)

print(validated.weekly_hours)
print(type(validated.weekly_hours))

输出:

6
<class 'int'>

校验成功后,后续代码可以依赖模型结果中的 weekly_hoursint。这就是边界 带来的变化:核心业务不必每走一步都回头怀疑“它到底是不是数字”。

一个小转折:校验不一定是原样放行或拒绝

如果输入是字符串 "6" 呢?

raw_payload = {
    "topic": "Python typing",
    "weekly_hours": "6",
}

validated = LearningGoalInput.model_validate(raw_payload)

print(validated.weekly_hours)
print(type(validated.weekly_hours))

输出仍然是:

6
<class 'int'>

Pydantic 默认可能执行合理的类型转换,也就是 coercion。"6" 能解析为整数, 所以被转换成 6"很多" 无法解析,因此被拒绝。

这提醒我们,不能把“使用了 Pydantic”简化成“所有字符串一律禁止入内”。 Pydantic 更像按明确规则验货,而不是看到字符串就拉响警报。是否允许转换,取决 于数据来源和业务风险。

Pydantic v2 支持 strict mode,也支持字段约束和额外字段策略,不过这些细节先 留到真正设计 API 输入契约时。本章只需要抓住一件事:

运行时边界必须执行明确的输入策略。模型名称不能代替真实校验,使用 Pydantic 也不能代替我们理解它接受、转换和拒绝什么。

5. 一条完整的数据流长什么样

现在把零散片段接起来。为了看清职责,示例使用:

  • LearningGoalInput 表达外部输入;
  • LearningGoal 表达核心业务接收的数据;
  • Plan 表达业务结果。
from dataclasses import dataclass
from typing import Any

from pydantic import BaseModel


class LearningGoalInput(BaseModel):
    topic: str
    weekly_hours: int


@dataclass(frozen=True)
class LearningGoal:
    topic: str
    weekly_hours: int


@dataclass(frozen=True)
class Plan:
    topic: str
    daily_minutes: int


def to_learning_goal(data: LearningGoalInput) -> LearningGoal:
    return LearningGoal(
        topic=data.topic,
        weekly_hours=data.weekly_hours,
    )


def build_plan(goal: LearningGoal) -> Plan:
    daily_minutes = goal.weekly_hours * 60 // 7
    return Plan(
        topic=goal.topic,
        daily_minutes=daily_minutes,
    )


def handle_payload(payload: dict[str, Any]) -> Plan:
    validated_input = LearningGoalInput.model_validate(payload)
    goal = to_learning_goal(validated_input)
    return build_plan(goal)

先别急着讨论“是不是每个项目都要两套对象”。这段代码的用途是把边界画亮,像 在数据流上打开三盏灯。

第一盏灯:边界之前,诚实承认我们还不知道

handle_payload() 接收 dict[str, Any]。这里的 Any 不是偷懒,而是在忠实 描述事实:外部 JSON 在校验前确实可能包含各种内容。

危险的不是“边界入口出现了未知类型”,而是未知类型一路进入了业务深处,沿途 所有函数都假装自己收到的是可靠数据。

第二盏灯:边界处,真正建立可信前提

validated_input = LearningGoalInput.model_validate(payload)

这一行是整条数据流的分水岭。

在它之前,payload 只是一份外部声明;在它之后,代码拿到的是按模型策略处理过 的结果。失败时,问题停在边界;成功时,后续逻辑可以少背很多心理包袱。

第三盏灯:边界之后,用类型传播信任

goal = to_learning_goal(validated_input)
return build_plan(goal)

转换是显式的,核心函数接收 LearningGoal,返回 Plan。后面的函数和模块通过 type hints 继续表达协作关系,不需要反复猜测字段,也不需要每一层都重新审问同一 份数据。

整条路线可以压缩成:

不可信外部数据
    │  Pydantic 在运行时解析、校验
可信的边界结果
    │  根据项目需要显式转换
typed core
    │  type hints 让内部协作可检查
明确的业务结果

Pydantic 和 type hints 不是两个争夺同一岗位的候选人。一个负责入口验货,一个 负责内部路线。

  • 只有 type hints,外部错误数据仍可能直接进入核心业务;
  • 只有 Pydantic,内部函数仍可能靠裸字典和口头约定协作;
  • 两者配合,数据怎样变可信、可信数据怎样继续传播,才都看得见。

至于边界模型和业务对象是否复用,没有永恒答案。小项目可以在职责清楚的前提下 复用;当传输格式、校验工具和业务对象需要独立变化时,再显式转换。真正重要的 不是对象数量,而是转换位置能找到、选择理由说得清。

6. 校验很有用,但不要在每个路口都重新验票

知道运行时校验有用后,很容易出现一种热情过度的设计:

def build_plan(raw_goal: dict[str, object]) -> Plan:
    goal = LearningGoalInput.model_validate(raw_goal)
    ...


def calculate_schedule(raw_goal: dict[str, object]) -> list[int]:
    goal = LearningGoalInput.model_validate(raw_goal)
    ...


def format_plan(raw_goal: dict[str, object]) -> str:
    goal = LearningGoalInput.model_validate(raw_goal)
    ...

每个函数都亲自校验一次,看上去层层把关,安全感很足。可数据明明已经通过入口, 现在每走十步又被要求重新验明身份。检查次数增加了,边界反而消失了。

这种写法会带来三个问题。

第一,内部函数不再表达自己真正需要什么。它们都接收宽泛字典,再自行恢复数据 形状。调用者只能靠阅读实现猜契约。

第二,输入策略散落在业务流程里。字段规则变化后,开发者必须寻找所有校验点, 确认它们有没有悄悄长出不同版本。

第三,核心业务开始依赖外部传输格式和边界工具。原本只需要计算学习计划的函数, 现在还要知道 Pydantic 和原始字典。

更稳妥的原则是:

  1. 数据从不可信区域进入可信区域时,执行运行时校验;
  2. 校验成功后,通过明确类型传播已经建立的前提;
  3. 数据再次跨越新的系统或信任边界时,再考虑新的校验。

浏览器 JSON 进入服务是边界;另一个服务发来的消息进入消费者也是边界。一个 普通内部函数调用另一个内部函数,通常不是新的边界。

所以“集中校验”不是宣誓整个系统一生只校验一次,而是让校验跟随真实边界, 不要跟随函数数量。

7. 把它放回熟悉的 ETL 世界

如果你做过 ETL pipeline,这条关系其实并不陌生。

上游声称某列是整数,并不代表真实数据里不会出现空字符串、特殊符号、旧格式或 一段令人困惑的自由文本。下游 transformation 写着“这里按整数处理”,也不会 自动把脏数据清干净。

通常需要先经历:

raw record
    → schema / cleaning
    → normalized record
    → trusted transformation pipeline

在 typed Python 工程里,可以暂时对应成:

外部 JSON
    → Pydantic runtime boundary
    → validated typed data
    → business transformation

Pydantic 边界类似 raw record 进入可信处理区域前的解析与校验。type hints 则 类似可信 pipeline 各阶段之间明确的输入输出 schema。它们让内部协作可检查, 却不会自动清理刚到达系统的原始数据。

这个类比很好用,但别让它承担超出能力范围的工作。Pydantic model 不是完整数据 质量平台,type checker 也不会分析生产数据分布。ETL 中的跨记录一致性、去重、 质量监控和异常隔离,往往不属于一个字段模型。

类比只需要帮我们记住这一句:

原始数据进入可信处理区域时,要在运行时建立前提;可信区域内部仍要用明确 契约继续传递数据。

8. 两道检查都通过,程序还是可能算错

现在我们已经有了整齐的类型,也有了运行时边界。是不是可以宣布胜利了?

先看这段代码:

def build_plan(goal: LearningGoal) -> Plan:
    daily_minutes = goal.weekly_hours * 60 // 5
    return Plan(
        topic=goal.topic,
        daily_minutes=daily_minutes,
    )

参数是 LearningGoal,返回值是 Plan。类型检查没有明显问题。

输入:

{
  "topic": "Python typing",
  "weekly_hours": 6
}

也能通过 Pydantic 校验。

程序运行后得到每天 72 分钟。类型很正确,输入很健康,答案却错了。需求是把 每周时间平均到 7 天,代码却除以了 5。正确结果应该是 51

type checker 不知道一周为什么要按 7 天计算。Pydantic 也不知道学习计划如何 分配才符合产品规则。它们都完成了自己的工作,只是业务公式写错了。

这时,三种“正确”终于分开了:

判断层次 它真正回答的问题 本例中的证据
静态类型正确 代码是否按声明的类型协作 参数是 LearningGoal,返回 Plan
运行时输入有效 外部数据是否满足入口策略 weekly_hours 能成功形成整数
业务行为正确 实现是否符合真实需求 每周分钟数是否按 7 天分配

这三位同事彼此合作,但不替对方上班。

  • 类型检查通过,不代表外部输入已经校验;
  • 输入校验通过,不代表业务规则已经满足;
  • 一个业务案例跑通,也不代表所有调用关系都符合类型契约。

后续课程会用测试检查具体业务行为。本章只需要建立一个健康的警觉:看到“有类型” “有 Pydantic”“程序能跑”,都不要急着把它翻译成“程序已经正确”。

9. 四个很有诱惑力的误区

误区一:参数有注解,错误对象就进不来

def build_plan(goal: LearningGoal) -> Plan:
    ...

这条签名能帮助静态工具发现不合理调用,但 Python 运行时仍可收到其他对象。 如果数据来自 JSON、配置、消息或第三方服务,就要找到那一行真正读取运行时值、 并决定接受、转换或拒绝的代码。

如果找遍调用链只看到注解,没有看到实际校验,那么门牌写得再漂亮,入口也没有 验货。

误区二:用了 Pydantic,内部函数就不需要类型

Pydantic 解决的是外部数据怎样进入可信区域。边界之后,多个函数、模块和返回 结果仍然需要协作。

def build_plan(goal: dict) -> dict:
    ...

这样的接口仍然让调用方猜字段、元素和返回形状。边界模型不会自动替内部接口补 上契约。Pydantic 负责把数据送进来,type hints 负责让后面的路不至于重新变成 没有路标的空地。

误区三:每层都重新创建模型才最安全

重复校验经常不是“防线严密”,而是“不知道防线究竟在哪里”。它增加转换和耦合, 还可能让各层逐渐形成不同输入策略。

真正该问的是:数据刚刚跨越新的信任边界了吗?如果没有,就优先使用明确类型 传播已经建立的前提。

误区四:校验通过就说明业务一定正确

Pydantic 可以确认 weekly_hours 是整数,却不知道 0 小时是否符合产品规则, 也不知道计划算法应该除以 5 还是 7。

字段形状、业务状态和算法结果是不同层次。模型创建成功值得高兴,但还没到开 庆功会的时候。

10. 阅读数据流时,沿路问这七个问题

以后阅读 typed Python 项目,可以暂时忘掉“它用了哪些流行工具”,沿着数据走 一遍:

  1. 数据最初来自哪里?浏览器、文件、环境变量、消息队列还是另一个服务?
  2. 程序最初拿到的真实对象是什么?字符串、字典、字节还是第三方对象?
  3. 哪一行代码真正执行了解析和运行时校验?
  4. 校验成功后,后续代码可以依赖哪些前提?
  5. 这些前提如何通过参数、返回值、属性和集合元素类型继续传播?
  6. 数据是否在内部又退化成形状不明的字典或 Any
  7. 眼前的证据证明的是类型关系、输入有效,还是业务行为?

这七个问题比“项目有没有使用 Pydantic”更有判断力。工具名称不能替代数据流。 同样使用 Pydantic,一个项目可能在入口建立清楚边界,另一个项目可能让每个函数 都重新检查同一份字典。

别数模型,也别数注解。先找数据从哪来、在哪变可信、之后怎样继续走。

本章小结

回到最初那份输入:

{
  "topic": "Python typing",
  "weekly_hours": "很多"
}

参数标成 int,不会让 Python 自动拒绝 "很多"。type hints 的职责是声明 静态类型契约,让调用方、实现方、IDE 和类型检查器能够检查程序内部的协作关系。

外部 JSON 在运行时才到达系统,因此需要一个真实执行的边界机制。Pydantic v2 的 model_validate() 可以解析和校验输入:成功时产生符合模型结果的数据, 失败时用 ValidationError 把问题留在边界。默认模式还可能执行类型转换,所以 我们必须理解模型实际接受、转换和拒绝什么。

边界之后,数据通过明确业务类型进入 typed core。内部函数通常不必层层重复 校验,除非数据再次跨越新的系统或信任边界。

最后,类型与校验都不能证明业务算法正确。静态类型正确、运行时输入有效和业务 行为正确,需要各自的证据。

如果只带走一句话,可以带走这句:

在入口验货,在内部画清路线,最后再确认货物真的送到了正确目的地。

练习

练习一:三份工作分别由谁负责

下面四项主要属于静态类型契约、运行时数据边界,还是业务正确性?

  1. IDE 提示调用方把 str 传给参数类型为 LearningGoal 的函数。
  2. model_validate() 拒绝无法解析为整数的 "很多"
  3. build_plan() 把每周分钟数错误地除以 5。
  4. 核心函数返回 Plan,调用方却把结果当作 str 使用。

练习二:先下注,再运行

先别运行。预测下面三次调用分别会发生什么:

class LearningGoalInput(BaseModel):
    topic: str
    weekly_hours: int


def double_hours(hours: int) -> int:
    return hours * 2


double_hours("6")
LearningGoalInput.model_validate(
    {"topic": "Python", "weekly_hours": "6"}
)
LearningGoalInput.model_validate(
    {"topic": "Python", "weekly_hours": "很多"}
)

尤其注意:不是每个错误类型都会用异常提醒你。

练习三:把验货点放回入口

某程序的数据流如下:

HTTP JSON
→ route handler
→ build_plan
→ calculate_schedule
→ format_response

现在三个内部函数都接收 dict[str, object],并各自调用 Pydantic 校验。请判断:

  1. 运行时边界更适合放在哪里?
  2. 边界之后的函数参数应表达什么?
  3. 为什么不建议三个函数各自重新校验同一份字典?

练习四:换一个入口,规则还成立吗?

系统以后不只接收浏览器请求,还会从消息队列收到:

{
  "topic": "Python typing",
  "weekly_hours": 6
}

消息由另一个服务发送。请判断:

  1. 消息队列消费者是否仍需要运行时边界?
  2. HTTP 和消息使用相同字段,是否意味着只能有一个入口函数?
  3. 两个入口怎样复用同一个 typed core?

练习解析

练习一解析

  1. 属于静态类型契约。IDE 根据参数声明检查调用关系。
  2. 属于运行时数据边界。真实值已经到达程序,Pydantic 正在解析和校验它。
  3. 属于业务正确性。类型和输入都可以正确,算法仍可能违反需求。
  4. 属于静态类型契约。返回值声明让工具发现调用方的使用方式与接口不一致。

判断时不要只看工具名称。先问:检查发生在源码分析阶段还是数据到达之后?它在 检查调用关系、输入内容,还是业务结果?

练习二解析

double_hours("6") 会正常进入函数。Python 不会因为 hours: int 自动拒绝 字符串。字符串乘以 2 合法,所以结果是 "66"

这正是它值得被记住的原因。错误类型不一定轰轰烈烈地报错,也可能端端正正地 返回一个错误答案。

第二次调用在 Pydantic 默认模式下会成功。字符串 "6" 可以解析为整数,模型 中的 weekly_hours 最终是整数 6

第三次调用会产生 ValidationError"很多" 无法解析为整数,问题停在模型 边界,不会继续进入业务计算。

练习三解析

边界应靠近 HTTP JSON 进入 Python 业务系统的位置。route handler 或专门的输入 adapter 收到字典后,先建立 Pydantic 模型,再把验证后的数据或显式转换后的业务 对象交给 build_plan

边界之后,函数参数应表达它们真正需要的 typed data,例如 LearningGoalPlan 或具体集合,而不是继续传递形状不明的字典。

三个函数重复校验会让外部传输格式和 Pydantic 依赖扩散到核心流程,还会增加 重复转换与策略不一致风险。既然数据已经在入口建立可信前提,内部应通过明确 类型传播它。

练习四解析

消息队列消费者仍需要运行时边界。消息来自进程或系统之外,即使发送方声称遵循 相同 schema,接收方拿到的仍是运行时外部数据。旧消息、版本差异、错误生产者或 手工注入都可能破坏约定。

HTTP 和消息使用相同字段,不表示必须共享同一个入口函数。HTTP adapter 与消息 consumer 的读取方式和失败处理不同,可以分别负责各自协议。

两个入口在各自边界完成解析与校验,再转换为同一个 LearningGoal,共同调用:

def build_plan(goal: LearningGoal) -> Plan:
    ...

这样复用的是稳定的业务契约与核心规则。我们既没有让消息消费者依赖 HTTP 对象, 也没有复制两份计划算法。

参考资料