把规则从原 ppt-gen 项目泛化为通用脚手架,覆盖 19 个文件: - ddd/architecture/design-discipline/error-codes/communication-* 等: 领域术语、错误码、消息示例、扩展点、状态流统一为「订单」示范域 - agent-dag/agent.md/agent-concurrency/agent-llm-*:DAG 节点、配置、并发、 LLM 适配从「对话→预览→生成→截图→合成」改为「intake→process→validate→finalize」 - backend-db:Session 实体/状态机/ORM/仓储/checkpoint 对齐新流水线 - 移除幻灯片专属依赖与产物:agent 去 playwright/python-pptx, Dockerfile 去 Chromium 系统库,.gitignore 去 *.pptx - 保留合法通用项:ANSI(PTY 处理)、Playwright(E2E 测试工具)、Gemini CLI(真实 provider) 验证:gemini-cli/openai-api 两变体生成退出 0,生成项目零 PPT 残留, backend/agent 骨架均可 import 运行。
8.3 KiB
架构与设计纪律
六边形 + SOLID + 契约式设计(DbC)+ 高内聚低耦合 + 扩展点机制概述。
整体遵循六边形架构(Ports & Adapters)+ 整洁架构;类/函数级遵循 SOLID 与契约式设计(Design by Contract)。
六边形架构(Ports & Adapters)
- 端口(Port):领域定义的接口(
LLMSession/ImageProvider/Exporter/StorageBackend),表达"领域需要什么能力" - 适配器(Adapter):端口的具体实现(当前 LLM CLI 适配器 / 示范导出器 …),位于最外层,可替换
- 依赖方向恒指向领域;替换外层实现不触动内层
SOLID(类/模块级强制)
| 原则 | 约束 |
|---|---|
| S 单一职责 | 一个类/函数仅一个变更理由 |
| O 开闭 | 扩展靠加实现,不改既有代码(即扩展点机制) |
| L 里氏替换 | 任一端口实现可无差别替换,不破坏调用方契约 |
| I 接口隔离 | 端口按消费者拆分,不强加无关方法 |
| D 依赖倒置 | 依赖抽象(Port),不依赖具体(Adapter) |
契约式设计(Design by Contract)——编码前先定契约
任何函数/类:先定义契约(签名 + 前置条件 + 后置条件 + 不变量),再写测试,再写实现。
- 前置条件(Precondition):调用方须保证的输入约束
- 后置条件(Postcondition):函数承诺的输出与副作用
- 不变量(Invariant):对象生命周期内恒真的状态约束
- 契约落于类型签名 + docstring;关键不变量以断言运行时校验
- 公开接口的契约即其规格,契约稳定性优先于实现自由
核心设计原则(高内聚 / 低耦合)
上述纪律落到日常的可操作规则。所有新功能从设计之初就遵循,不允许"先实现再重构"。
-
单一职责 + 高内聚 每个模块/类/函数只做一件事。相关逻辑聚在一起(如 PTY 机制全在
llm/pty_bridge.py),不相关的拆开。判据是职责而非行数:一个函数做了两类事就该拆;文件超 ~300 行是「该审视是否多职责」的信号,不是硬上限——单一职责的长文件不必为凑行数而拆。 -
依赖方向(低耦合的硬规则) 依赖只能单向流动,禁止反向或跨层依赖(详见
architecture.md)。上层依赖下层,下层绝不反向 import 上层;utils/是谁都可依赖的叶子层,自身不依赖任何业务模块。 -
依赖抽象,不依赖实现(DI) 业务代码依赖接口(Protocol/ABC),不依赖具体类。具体实现在边界处注入。 范例:DAG 节点依赖
LLMSession端口,不知道背后是哪个 CLI 厂商(端口见agent/domain/ports.py)。 实现类必须显式继承 Protocol(class MyImpl(MyProtocol):)并用@override标注每个实现方法——见下「Protocol 实现强制写法」。 -
稳定契约解耦服务 三层之间只通过消息契约通信(WebSocket JSON 协议),不共享内部类型。契约是单一来源(见
communication-protocol.md),任一服务内部重构不影响其他服务。 -
新功能 = 插到扩展点,不改核心 加新能力时,优先实现已有扩展点接口;若没有合适接口,先定义新接口再实现,禁止往核心流程里塞 if/else 分支。
扩展点机制概述(插件接缝)
系统预留以下接口,保证可扩展性。新增同类能力 = 新增一个实现,不改调用方。端口统一声明于 agent/domain/ports.py(六边形架构:端口属领域核心);适配器实现散于对应目录。
下表端口为示范(含一个 LLM 端口 + 三个通用外部能力端口);按项目实际需要增删:
| 扩展点(端口) | 当前适配器 | 适配器位置 | 未来扩展 |
|---|---|---|---|
LLMSession |
当前 CLI 厂商适配器 | agent/llm/ |
其他 CLI / API SDK |
PaymentGateway |
示范支付适配器 / none | agent/providers/ |
接入真实支付网关 |
Exporter |
示范导出器(如 PDF) | agent/export/ |
其他格式 |
StorageBackend |
local_storage(本地 FS) |
agent/storage/ |
S3 / OSS |
每个端口都是 Protocol,新适配器注册到工厂(registry)即生效,调用方按配置选择。具体端口签名与注册规范见 agent.md。
Protocol 实现的强制写法(Python 3.12+)
实现端口时,必须同时满足两条约束,缺一不可:
- 显式继承 Protocol:
class MyImpl(MyProtocol):— 让 mypy 能校验实现完整性,而非依赖鸭子类型隐式对齐 @override标注每个实现方法:来自from typing import override(Python 3.12)— 方法名拼错或接口改签名时,类型检查器立即报错,不等到运行时
from typing import Protocol, override
# ✅ 强制写法
class GeminiCliSession(LLMSession): # 显式继承,非隐式鸭子类型
@override
async def complete(self, prompt: str) -> str:
result = await self._read_until_done()
return result or ""
# ❌ 禁止:隐式对齐,接口改名或拼写错误时静默脱钩
class BadSession:
async def complet(self, prompt: str) -> str: # 拼错了,运行时才 crash
...
违规信号(Claude Code 应直接指出):
- 实现类未继承 Protocol → mypy 无法验证实现完整性
- 实现方法缺少
@override→ 接口改名后实现类静默脱钩,直到运行时才发现
可靠性纪律(Resilience,强制)
核心链路常含易抖动的外部依赖(LLM CLI 进程 / 第三方 API / 外部 HTTP 服务),故跨边界调用必须假定会超时、会瞬时失败、会被重复触发。以下与「命令幂等」(见 communication-protocol.md)同属可靠性约束,从设计之初遵循,不允许"先实现再加兜底"。
-
超时无处不在(Ubiquitous Timeout) 任何跨进程/网络/IO 的等待必须设显式超时,禁止裸
await无界等待。- 外部调用统一用
asyncio.timeout(...)或客户端原生 timeout 包裹;超时即抛领域错误(如EXTERNAL_SERVICE_TIMEOUT),不静默挂起。 - 超时值经
.env注入、不硬编码(见config.md);每类外部依赖一个超时配置项。 - 反例:
await proc.read()无超时 → 进程卡死则会话永久僵死。
- 外部调用统一用
-
重试 + 指数退避 + 抖动(Retry / Backoff + Jitter) 对幂等操作的瞬时失败(网络抖动、第三方 5xx、外部调用偶发失败)才允许重试;非幂等操作禁止盲目重试。
- 重试须有上限(次数 + 总时长双封顶),退避用指数 + 随机抖动,禁固定间隔猛刷。
- 重试逻辑收拢为一处可复用策略(如
utils/retry.py),不在各调用点散写循环。 - 用户输入触发的命令依赖幂等键去重(见
communication-protocol.md),保证重试安全。
-
降级而非整体失败(Graceful Degradation) 单个可选能力失败时,降级到退化态而非让整条主链崩溃。
- 经端口的退化实现承接:某可选外部能力失败/超额 → 降级到
none(跳过该能力)继续主流程,而非中断整笔业务。 - 降级须可观测:记日志 + 必要时以事件告知前端(区别于
error致命错误)。 - 哪些能力可降级、降级到何种退化态,在端口契约里显式声明。
- 经端口的退化实现承接:某可选外部能力失败/超额 → 降级到
-
边界校验,内部信任(Validate at Boundary) 不可信输入(前端命令、上传文件、外部响应)在适配器/路由边界一次性校验(Pydantic / 类型 / 业务规则),校验失败回
INVALID_INPUT;越过边界进入领域后信任已校验,领域内不重复防御性判空。- 校验是边界职责,不是领域职责——领域用前置条件断言不变量,而非兜外部脏数据。
-
资源管理强制(RAII / Context Manager) 持有外部资源(子进程 / pty fd / 浏览器页 / DB 连接 / 文件句柄)必须用 context manager(
with/async with)或在finally确定性释放,禁手动open/close配对裸写。- 目的:连接被取消(WS 断开 → Task cancel)时资源仍确定性回收,杜绝僵尸进程与 fd 泄漏(落地细节见各
*-concurrency.md)。
- 目的:连接被取消(WS 断开 → Task cancel)时资源仍确定性回收,杜绝僵尸进程与 fd 泄漏(落地细节见各