跳转至

加个 CLI,为什么 core 也要改?

上一章结束时,AI 学习助手的核心函数已经有了精确的公开类型:build_plan() 接收 LearningGoal,返回 Plan | PlanFailure。调用方知道该准备什么、会拿回什么、 需要处理几个业务状态。

现在团队收到一个看起来很小的需求:除了 HTTP 接口,再加一个 CLI 入口,让开发者 在终端直接生成学习计划。

你打开代码,发现 build_plan() 的参数类型直接使用了 Web 框架的 Request 对象。 改动 CLI 时,你得让命令行参数伪装成 HTTP 请求——或者修改 core 函数接口。

问题来了:明明只是换了一种输入方式,为什么计划生成规则也要跟着改?

这不是文件太少或太多的问题。两个方案可以有完全相同的文件数量,区别只在 import 箭头指向哪里。本章就从这个反差开始,看看模块边界到底由什么决定。

运行环境与材料说明

本章代码使用 Python 3.12。示例只涉及 package 结构和 import 行为,不依赖外部 框架。你可以在任意 Python 3.12 环境中创建临时 package 复现本章的 import 关系。

Python 的 import 机制在此章中只需要一条核心事实:当 a.py 写了 from b import X,模块 a 就对模块 b 产生了依赖。b 的变化可能影响 a, 反过来则不一定。

1. 观察:增加 CLI 时谁需要改?

先看当前 HTTP-only 时的文件结构和 import 关系:

learning_planner/
├── contracts.py
├── core.py
└── http_adapter.py

各文件的关键片段:

# contracts.py
from dataclasses import dataclass


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


@dataclass(frozen=True)
class Plan:
    topic: str
    daily_minutes: int
# core.py
from learning_planner.contracts import LearningGoal, Plan


def build_plan(goal: LearningGoal) -> Plan:
    daily_minutes = goal.weekly_hours * 60 // 7
    return Plan(topic=goal.topic, daily_minutes=daily_minutes)
# http_adapter.py
from learning_planner.contracts import LearningGoal
from learning_planner.core import build_plan


def handle_http(payload: dict) -> dict:
    goal = LearningGoal(topic=payload["topic"], weekly_hours=payload["weekly_hours"])
    result = build_plan(goal)
    return {"topic": result.topic, "daily_minutes": result.daily_minutes}

import 箭头是:

http_adapter  →  core  →  contracts
     └──────────────────→  contracts

箭头都指向同一方向:adapter 依赖 core,core 依赖 contracts。没有反向。

现在需求来了——增加 CLI adapter。暂停一下,预测:在这个结构下,你需要改动 core.pycontracts.py 吗?

不需要。你只要新增一个文件:

# cli_adapter.py
import sys
from learning_planner.contracts import LearningGoal
from learning_planner.core import build_plan


def main() -> None:
    topic = sys.argv[1]
    weekly_hours = int(sys.argv[2])
    goal = LearningGoal(topic=topic, weekly_hours=weekly_hours)
    result = build_plan(goal)
    print(f"{result.topic}: 每天 {result.daily_minutes} 分钟")

新的 import 图:

http_adapter  →  core  →  contracts
cli_adapter   →  core  →  contracts
     └──────────────────→  contracts

两个 adapter 各管各的输入输出方式,共享同一套业务规则和数据契约。core 没有 感知到外面多了一种入口。

2. 反转箭头:当 core 依赖 adapter

现在看另一种写法。文件数量完全相同,但 core.py 直接使用了 HTTP adapter 的 请求对象:

# http_adapter.py(方案 B)
from dataclasses import dataclass


@dataclass
class HttpRequest:
    body: dict
# core.py(方案 B)
from learning_planner.http_adapter import HttpRequest
from learning_planner.contracts import Plan


def build_plan(request: HttpRequest) -> Plan:
    topic = request.body["topic"]
    weekly_hours = request.body["weekly_hours"]
    daily_minutes = weekly_hours * 60 // 7
    return Plan(topic=topic, daily_minutes=daily_minutes)

import 箭头变成了:

core  →  http_adapter   (反向!)
core  →  contracts

文件数量没变。命名也还算整齐。但箭头方向完全不同。

现在同样的需求来了:增加 CLI adapter。预测一下,你需要改什么?

CLI 没有 HttpRequest。你面临两个选择:

  1. 在 CLI 里凭空构造一个 HttpRequest,假装命令行参数是 HTTP body;
  2. 修改 core.py 的参数类型,让它不再依赖 HttpRequest

无论哪个选择,core 都要受到 adapter 变化的影响。第一种是把 Web 概念强加给 CLI;第二种是承认 core 一开始就不该依赖这个对象。

这就是"依赖方向"的后果:方案 A 中 adapter 的变化不会扩散到 core;方案 B 中 adapter 的变化会沿着反向箭头传播进 core。

两个方案的差异不是文件多少,而是谁依赖谁

3. 从现象中提取三个判断维度

回过头看,方案 A 和方案 B 的对比已经暗示了三条可检查的线索。现在正式命名它们。

职责内聚:同一变化原因是否集中在同一模块?

方案 A 中,"怎样从 HTTP 读取输入"集中在 http_adapter.py,"怎样生成计划" 集中在 core.py。改 Web 框架只动 adapter,改计划规则只动 core。

方案 B 中,core 同时承担"理解 HTTP 请求结构"和"生成计划"两个变化原因。换 框架时 core 也要改。

检查问题:这个模块会因为几种不同原因被修改?

公开接口:跨模块协作暴露了什么?

方案 A 中,core 公开的函数签名是 build_plan(goal: LearningGoal) -> Plan。 调用方只需要提供一个 LearningGoal,不需要知道 core 内部怎样计算。

方案 B 中,core 公开接口要求传入 HttpRequest。这意味着 core 把 Web 协议 的细节暴露给了所有调用方——包括未来根本不走 HTTP 的 CLI。

检查问题:跨模块的协作面上,暴露的是业务数据契约,还是某个特定技术的内部结构?

依赖方向:稳定的规则是否反向依赖易变的 adapter?

学习计划的生成规则比 Web 框架选型更稳定。方案 A 让易变的 adapter 依赖稳定的 core;方案 B 让稳定的 core 反向依赖易变的 adapter。

检查问题:import 箭头是从易变指向稳定,还是从稳定指向易变?

这三个问题不要求你知道任何架构模式的名字。你只需要看 import、看函数签名、 想一想"如果某处变了,谁会跟着变"。

4. 边界的两个反例

掌握了三项判断后,另一些问题也能识别了。

反例一:common.py 汇总一切

有人觉得"共享的东西都放一起"很方便:

# common.py
from dataclasses import dataclass


@dataclass
class LearningGoal:
    topic: str
    weekly_hours: int


@dataclass
class Plan:
    topic: str
    daily_minutes: int


@dataclass
class HttpRequest:
    body: dict


def parse_body(request: HttpRequest) -> LearningGoal:
    return LearningGoal(
        topic=request.body["topic"],
        weekly_hours=request.body["weekly_hours"],
    )


def format_plan(plan: Plan) -> str:
    return f"{plan.topic}: {plan.daily_minutes}min/day"

三个模块都导入 common,同时 common 里也导入了三个模块各自的内部 helper。

结果:

  • 职责内聚?不。common.py 同时包含数据契约、HTTP 解析和输出格式化,三种 完全不同的变化原因共存。
  • 依赖方向?混乱。每个模块都依赖 commoncommon 又依赖每个模块的内部, 形成事实上的循环。

Python 遇到循环导入时可能抛出 ImportErrorAttributeError,具体取决于 导入顺序和模块加载时机。但即使用延迟导入或其他技巧绕过了报错,真实的耦合 并没有消失——它只是被语法技巧掩盖了。

反例二:六层空壳

另一个极端:为当前只有一个 adapter 和一个业务函数的项目,预建六个层级:

learning_planner/
├── adapters/
│   └── inbound/
│       └── http/
│           └── handler.py
├── application/
│   └── services/
│       └── plan_service.py    # 只转发给 domain
├── domain/
│   └── services/
│       └── plan_domain_service.py  # 只转发给 model
├── infrastructure/
│   └── ...
└── ...

plan_service.py 的全部内容:

from learning_planner.domain.services.plan_domain_service import build_plan_domain


def build_plan_service(topic: str, weekly_hours: int):
    return build_plan_domain(topic, weekly_hours)

它不做任何事,只是把调用原样转发。

这违反了什么?职责内聚要求模块有自己的变化原因。一个"只转发"的层没有独立 职责;它的存在不是因为当前业务需要隔离,而是因为某个模板说"应该有这一层"。

文件数量不等于模块边界清晰。边界清晰的标志是:每个模块有自己的变化原因, 公开接口暴露的是业务契约而不是内部细节,import 箭头从易变指向稳定。

5. 综合验证:CLI 迁移

回到贯穿故事。现在你已经知道三项判断,让我们把 CLI adapter 的设计做完整。

需求:CLI 从命令行参数读取 topicweekly_hours,调用 build_plan, 把结果打印到终端。

允许的 import 箭头:

cli_adapter   →  contracts   (读取 LearningGoal 定义)
cli_adapter   →  core        (调用 build_plan)
core          →  contracts   (使用 LearningGoal 和 Plan)
http_adapter  →  contracts   (读取 LearningGoal 定义)
http_adapter  →  core        (调用 build_plan)

不允许的箭头:

core          →  cli_adapter    ✗ (core 反向依赖 adapter)
core          →  http_adapter   ✗ (同上)
cli_adapter   →  http_adapter   ✗ (adapter 之间不应耦合)

验证三项判断:

  1. 职责内聚:CLI adapter 只负责"从命令行读取输入、向终端输出结果"这一种 变化原因。HTTP adapter 只负责"从 HTTP 请求读取、向 HTTP 响应输出"。core 只负责计划生成规则。改命令行参数格式不影响 core,改计划算法不影响 adapter。

  2. 公开接口:core 暴露的协作面是 build_plan(goal: LearningGoal) -> Plan。 这是业务数据契约,不是某个框架的技术对象。两个 adapter 都能直接使用。

  3. 依赖方向:adapter(易变)依赖 core 和 contracts(稳定)。core 不反向 依赖任何 adapter。新增第三个 adapter 时,core 无需修改。

这就是模块边界的工作方式:不是数文件、不是看命名,而是检查职责是否集中、 接口是否暴露正确的东西、箭头是否指向稳定的方向。

本章小结

回到开头的问题:"加个 CLI,为什么 core 也要改?"

答案是:如果 core 的 import 箭头反向依赖了 adapter 的技术对象,那么 adapter 的变化就会沿着箭头传播进 core。文件数量相同、命名整齐,都不能阻止这种扩散。

模块边界由三个维度决定:

  1. 职责内聚——同一变化原因是否集中在同一模块;
  2. 公开接口——跨模块协作面暴露的是业务契约还是内部细节;
  3. 依赖方向——import 箭头是否从易变指向稳定。

循环共享(common.py 汇总一切)违反职责内聚和依赖方向。过度拆分(为小职责 预建空层)违反职责内聚——没有独立变化原因的模块不构成有意义的边界。

如果只带走一句话:

不要数文件,要追箭头。边界清晰的标志是变化不扩散,不是目录看起来整齐。

练习

练习一:画出 import 箭头

以下是一个四文件项目的 import 关系:

# file: api.py
from project.service import process_order

# file: service.py
from project.api import ApiRequest
from project.models import Order

# file: models.py
# 无 import

# file: utils.py
from project.models import Order
from project.service import process_order

请回答:

  1. 画出所有 import 箭头。
  2. service.py 依赖 api.py——这符合"从易变指向稳定"吗?
  3. 如果需要增加一个消息队列入口,service.py 需要修改吗?为什么?

练习二:诊断模块方案

某团队将代码组织如下:

# adapters/http.py
from core.planner import build_plan
from contracts.goal import LearningGoal

# core/planner.py
from contracts.goal import LearningGoal, Plan

# contracts/goal.py
# 只有 dataclass 定义

另一个团队的组织:

# adapters/http.py
from core.planner import build_plan

# core/planner.py
from adapters.http import HttpPayload
from contracts.goal import Plan

# contracts/goal.py
# 只有 Plan 定义

请用三项判断(职责内聚、公开接口、依赖方向)分别评价两个方案。哪个方案在 增加 CLI adapter 时 core 不需要修改?

练习三:识别循环与过度拆分

方案 X:

# shared.py
from handlers.http_handler import get_default_headers
from handlers.cli_handler import get_default_args
from core.planner import DEFAULT_HOURS

# handlers/http_handler.py
from shared import LearningGoal

# handlers/cli_handler.py
from shared import LearningGoal

# core/planner.py
from shared import LearningGoal, Plan

方案 Y:

# layer1/entry.py      → 只调用 layer2
# layer2/dispatch.py   → 只调用 layer3
# layer3/validate.py   → 只调用 layer4
# layer4/transform.py  → 只调用 layer5
# layer5/execute.py    → 只调用 layer6
# layer6/planner.py    → 实际执行 build_plan

每层只有一行转发代码。

请指出:方案 X 违反了三项判断中的哪些?方案 Y 又违反了哪项?

练习四:为新 adapter 设计依赖

需求:为 AI 学习助手增加一个定时任务 adapter,每天凌晨自动为所有用户生成 学习计划。它从数据库读取用户列表,逐个调用 build_plan

请回答:

  1. 这个 adapter 应该 import 哪些模块?
  2. core.py 是否需要知道"调用来自定时任务"?
  3. 画出包含 HTTP、CLI 和定时任务三个 adapter 的 import 箭头图。
  4. 不看正文,复述判断模块边界是否清晰的三个问题。

练习解析

练习一解析

import 箭头:

api       →  service
service   →  api        (反向!)
service   →  models
utils     →  models
utils     →  service

service.py 依赖 api.py 是反向的。service 是业务逻辑,api 是外部入口。 稳定的业务逻辑不应该依赖易变的接入层。这里 service 使用了 ApiRequest, 意味着如果 API 协议变化,service 也要跟着修改。

增加消息队列入口时,service.py 需要修改。因为它的参数类型绑定了 ApiRequest,消息队列无法直接提供这个对象。解决方向是让 service 接收业务 数据契约(如 Order),而不是 api 层的技术对象。

练习二解析

第一个团队:

  • 职责内聚:adapter 负责接入,core 负责规则,contracts 负责数据定义。各司其职。
  • 公开接口:core 暴露的是 build_plan(LearningGoal) -> Plan,业务契约。
  • 依赖方向:adapter → core → contracts。从易变指向稳定。

第二个团队:

  • 职责内聚:core 同时承担"理解 HTTP 载荷"和"生成计划"两种变化原因。
  • 公开接口:core 的函数签名要求传入 HttpPayload,暴露了 Web 技术细节。
  • 依赖方向:core → adapters。稳定的规则反向依赖易变的接入层。

增加 CLI adapter 时,第一个方案的 core 不需要修改。第二个方案必须修改 core, 因为 CLI 无法提供 HttpPayload

练习三解析

方案 X 违反了职责内聚依赖方向

  • shared.py 汇总了数据契约、HTTP 默认头和 CLI 默认参数三种不同职责。
  • shared.py 导入了 handlers 和 core 的内部对象,而 handlers 和 core 又 反过来导入 shared,形成循环依赖。任何一处修改都可能触发多模块变化。

方案 Y 违反了职责内聚

  • 中间四层(layer2-layer5)没有独立的变化原因,只做转发。它们的存在不是因为 业务需要隔离不同职责,而是因为"模板说要有这么多层"。
  • 没有独立职责的模块不构成有意义的边界,只增加了调用链长度和阅读成本。

练习四解析

定时任务 adapter 应该 import:

  • contracts:获取 LearningGoalPlan 的定义;
  • core:调用 build_plan

core.py 不需要知道调用来自定时任务。它只关心接收到的 LearningGoal 是否 符合契约。调用来源是 HTTP、CLI 还是 cron job,属于 adapter 的职责。

三个 adapter 的 import 箭头图:

http_adapter    →  core  →  contracts
cli_adapter     →  core  →  contracts
cron_adapter    →  core  →  contracts
      └────────────────────→  contracts

所有 adapter 都依赖 core 和 contracts;core 只依赖 contracts;没有任何箭头 从 core 或 contracts 指向 adapter。

三个判断问题:

  1. 这个模块会因为几种不同原因被修改?(职责内聚)
  2. 跨模块协作面上暴露的是业务契约还是特定技术的内部结构?(公开接口)
  3. import 箭头是从易变指向稳定,还是从稳定指向易变?(依赖方向)

参考资料