mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-30 01:46:01 +00:00
docs(hexagonal): rewrite the guide as a normative spec
- HEXAGONAL_ARCHITECTURE_zh.md becomes the spec: Cockburn/AWS-sourced standard structure (domain seven-piece layout, file naming rules), the four-transformation conversion chain with fixed owners and method names, commands/events design with upgrade triggers, an enforced rule table, and generic read/write sequence + class diagrams - FEEDBACK_DESIGN_zh.md is rewritten as the reference-implementation walkthrough of that spec (commands, exceptions split, _apply mapping, composition root, updated test map and pitfalls) - add the definition and dispatch diagrams under docs/assets
This commit is contained in:
parent
8e07bd0159
commit
72a0b2171e
@ -1,10 +1,8 @@
|
||||
# 用户反馈(Feedback)模块设计
|
||||
|
||||
> 面向想读懂或二次开发这个模块的人。读完你将能回答:一次点踩从浏览器到数据库经过哪些层、每层为什么只能做那些事、想加个字段该改哪几个文件。
|
||||
> [六边形设计规范](HEXAGONAL_ARCHITECTURE_zh.md) 的**参考实现走读**。本文假定你已读过规范:术语(端口、适配器、聚合、command、防腐层)与通用规则不再重复解释,只讲三件事——feedback 特有的产品决定、每条规范规则落在哪个文件、以及 feedback 特有的陷阱。读完你将能回答:一次点踩从浏览器到数据库经过哪些层、每层为什么只能做那些事、想加个字段该改哪几个文件。
|
||||
>
|
||||
> 本文假定你已读过 [HEXAGONAL_ARCHITECTURE_zh.md](HEXAGONAL_ARCHITECTURE_zh.md)——那里讲六边形的规则,这里讲规则在这个模块上的具体样子。术语(端口、适配器、聚合、防腐层)不再重复解释。
|
||||
>
|
||||
> 姊妹文档:[SCHEDULE_DESIGN_zh.md](SCHEDULE_DESIGN_zh.md)——定时任务模块,同样的结构但复杂度高一个量级(**尚在 `rayhpeng/hexagonal-scheduling-slice` 分支,未合并到本分支**)。
|
||||
> 姊妹文档:[SCHEDULE_DESIGN_zh.md](SCHEDULE_DESIGN_zh.md)——定时任务模块,同样的结构但复杂度高一个量级。
|
||||
|
||||
---
|
||||
|
||||
@ -27,50 +25,29 @@ PUT /api/threads/{tid}/runs/{rid}/feedback 设置当前评价(幂等)
|
||||
DELETE /api/threads/{tid}/runs/{rid}/feedback 撤回
|
||||
```
|
||||
|
||||
## 2. 当前状态:已完整迁移
|
||||
评价在业务上**与 run 绑定**。早期版本曾有一个可选的 `message_id`(把评价收窄到 run 内某一条消息),从未被任何读写方使用,属于冗余设计,已连同存储列一起移除(迁移 `0011_feedback_drop_message_id`)。
|
||||
|
||||
这是仓库第一个走完六边形切片的模块,也是后续模块的样板。内圈、外圈、组合根、契约测试都已到位:
|
||||
## 2. 内圈全景
|
||||
|
||||
```
|
||||
packages/harness/deerflow/domain/feedback/ 内圈(harness)
|
||||
backend/app/adapters/feedback/ 从适配器(app)
|
||||
backend/app/gateway/routers/feedback.py 主适配器(app)
|
||||
backend/app/gateway/deps.py::langgraph_runtime 组合根(app)
|
||||
domain/feedback/ 对应规范 §2 的 domain 七件套
|
||||
├── __init__.py 上下文公开 API:聚合 + 命令 + 错误 + 服务(端口刻意不导出)
|
||||
├── model.py Feedback(聚合根)· 2 个白名单常量 → 七件套 model/
|
||||
├── exceptions.py 5 个领域错误,一个基类 FeedbackError → 七件套 exceptions/
|
||||
├── commands.py RateRun · RetractRunRating → 七件套 commands/
|
||||
├── ports.py FeedbackRepository · RunLookup → 七件套 ports/
|
||||
└── service.py FeedbackService(写方法即 handler) → 七件套 command_handlers/
|
||||
```
|
||||
|
||||
与 schedule 模块不同,这里**没有新旧并存**——旧的 `FeedbackRepository`(散在 `persistence/` 下的那一版)已经删除,没有兼容层,没有双写。
|
||||
`model.py` 是**单文件**而非目录,因为这里只有一个聚合、没有值对象、没有状态机——对比 schedule 的 `model/` 包。模块规模决定形态,不必强行对称。events 缺席:feedback 的业务事实目前没有任何跨上下文订阅方(规范 §3.2 的触发条件未到)。
|
||||
|
||||
已知的收尾待办见总纲 §7,其中优先级最高的一项是本文 §11 的第一个陷阱。
|
||||
`__init__.py` 的导出策略值得注意:**它导出聚合、命令、错误和服务,但不导出端口**。端口是给适配器和测试用的契约,不是日常调用点的符号,所以要写 `from deerflow.domain.feedback.ports import FeedbackRepository`——多打几个字,换来"谁在实现契约"这件事在 import 里就看得见。
|
||||
|
||||
## 3. 内圈全景
|
||||
## 3. `Feedback`:规则本体
|
||||
|
||||
```
|
||||
domain/feedback/
|
||||
├── __init__.py 上下文的公开 API:领域对象 + 服务(端口刻意不在这里导出)
|
||||
├── model.py Feedback(聚合根)· 5 个领域错误 · 2 个白名单常量
|
||||
├── ports.py 2 个 Protocol:FeedbackRepository · RunLookup
|
||||
└── service.py FeedbackService —— 用例编排(input port)
|
||||
```
|
||||
一个 frozen dataclass。
|
||||
|
||||
`model.py` 是**单文件**而非目录,因为这里只有一个聚合、没有值对象、没有状态机——对比 schedule 的 `model/` 包(两个聚合 + 两个状态机 + 值对象)。模块规模决定形态,不必强行对称。
|
||||
|
||||
三层纪律:
|
||||
|
||||
| 文件 | 职责 | 纪律 |
|
||||
|---|---|---|
|
||||
| `model.py` | 业务事实与不变量 | 零依赖(仅标准库);不知道存储和 HTTP 存在;不变量在构造期校验 |
|
||||
| `ports.py` | 领域声明的接口 | 技术中立——签名里不出现 SQL、表名、HTTP 状态码 |
|
||||
| `service.py` | 用例编排 | 内圈唯一调用 output port 的地方;`user_id` 显式传参;自身不含业务规则 |
|
||||
|
||||
`__init__.py` 的导出策略值得注意:**它导出领域对象和服务,但不导出端口**。端口是给适配器和测试用的契约,不是日常调用点的符号,所以要写 `from deerflow.domain.feedback.ports import FeedbackRepository`——多打几个字,换来"谁在实现契约"这件事在 import 里就看得见。
|
||||
|
||||
CI 的 `tests/test_harness_domain_purity.py` 会 AST 扫描整个 `domain/`,禁止 import sqlalchemy / fastapi / pydantic / app / harness 基础设施模块。
|
||||
|
||||
## 4. `Feedback`:规则本体
|
||||
|
||||
`model.py`,93 行,一个 frozen dataclass 加五个错误类。
|
||||
|
||||
### 4.1 字段与身份
|
||||
### 3.1 字段与身份
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
@ -80,19 +57,16 @@ class Feedback:
|
||||
thread_id: str # ├ 业务身份三元组
|
||||
rating: int # │ (thread_id, run_id, user_id)
|
||||
user_id: str | None = None# ┘
|
||||
message_id: str | None = None
|
||||
comment: str | None = None
|
||||
tags: tuple[str, ...] = ()
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
```
|
||||
|
||||
**两套身份,别混。** `feedback_id` 是行的代理主键;决定"是不是同一条评价"的是 `(thread_id, run_id, user_id)`。upsert 按业务身份查找、更新时**保留原有的 `feedback_id`**——`SqlFeedbackRepository.save` 和 `InMemoryFeedbackRepository.save` 都显式实现了这一点,契约测试 `test_save_updates_keeping_identity` 逐字段盯着它。前端如果拿 `feedback_id` 做 key,重新评价后 key 不变。
|
||||
**两套身份,别混。** `feedback_id` 是行的代理主键;决定"是不是同一条评价"的是 `(thread_id, run_id, user_id)`。upsert 按业务身份查找、更新时**保留原有的 `feedback_id`**——这条契约由适配器的 `_apply` 设计承载(代理主键不在其中,见 §7.1),契约测试 `test_save_updates_keeping_identity` 逐字段盯着它。前端如果拿 `feedback_id` 做 key,重新评价后 key 不变。
|
||||
|
||||
`user_id` 可以是 `None`——那是免鉴权模式(`database.backend` 无认证部署)。所有读方法的 `user_id` 参数因此都是 `str | None`,`None` 表示不按所有者过滤。
|
||||
`user_id` 可以是 `None`——那是免鉴权模式(`database.backend` 无认证部署)。读方法的 `None` 表示不按所有者过滤;**删除路径的 `None` 语义不同**,见 §5.2。
|
||||
|
||||
`message_id` 把评价收窄到 run 里的某一条消息而不是整个 run,可选。
|
||||
|
||||
### 4.2 创建即一致
|
||||
### 3.2 创建即一致
|
||||
|
||||
```python
|
||||
def __post_init__(self):
|
||||
@ -103,11 +77,13 @@ def __post_init__(self):
|
||||
raise InvalidTagError(...)
|
||||
```
|
||||
|
||||
校验在 `__post_init__`,不在工厂里,所以**绕不过去**:`Feedback.create(...)` 和 `Feedback(...)` 直接构造走同一条路(`test_direct_construction_also_validates` 盯着这条)。适配器从数据库读出来重建对象时也会过这一遍——一行手工改坏的数据在读取时就会炸,而不是流到前端。
|
||||
校验在 `__post_init__`,不在工厂里,所以**绕不过去**:`Feedback.create(...)` 和直接构造走同一条路(`test_direct_construction_also_validates` 盯着这条)。适配器从数据库读出来重建对象时也会过这一遍——一行手工改坏的数据在读取时就会炸,而不是流到前端。
|
||||
|
||||
推论一条:**非法输入永远在任何 IO 之前报错**。`FeedbackService.rate_run` 先构造聚合再查 run(见 §6.2),所以一个非法 rating 配一个不存在的 run,报的是 `InvalidRatingError` 而不是 `RunNotFoundError`。
|
||||
推论一条:**非法输入永远在任何 IO 之前报错**。handler 先构造聚合再查 run(见 §6.2),所以一个非法 rating 配一个不存在的 run,报的是 `InvalidRatingError` 而不是 `RunNotFoundError`。
|
||||
|
||||
### 4.3 tags 是语言中立的 slug
|
||||
`Feedback.create` 的参数是散装关键字参数——这是规范 §2.1 变形 ② 的规定形态(工厂参数 = 聚合字段减代理主键,永不收 command);feedback 的六个参数彼此独立、无可聚类的领域概念,所以没有值对象。
|
||||
|
||||
### 3.3 tags 是语言中立的 slug
|
||||
|
||||
```python
|
||||
VALID_FEEDBACK_TAGS = frozenset({
|
||||
@ -115,28 +91,35 @@ VALID_FEEDBACK_TAGS = frozenset({
|
||||
})
|
||||
```
|
||||
|
||||
白名单在领域里,翻译在前端。存储和分析只见 slug,所以中文界面和英文界面提交的"回答不对"能聚合到一起。
|
||||
白名单在领域里,翻译在前端。存储和分析只见 slug,所以中文界面和英文界面提交的"回答不对"能聚合到一起。加标签见 §10.2。
|
||||
|
||||
加标签要动这个 frozenset,见 §10.2。
|
||||
### 3.4 时间戳的真相源
|
||||
|
||||
### 4.4 `frozen=True` 意味着什么
|
||||
`created_at` 是**记账戳**,由聚合构造时刻确定(`default_factory` 读时钟),upsert 更新时随新聚合刷新——语义是"当前这份看法是什么时候给出的"。它**不参与任何业务规则**(没有"超过 N 天的评价失效"这类逻辑),所以允许 `default_factory` 读时钟;一旦时间成为**规则输入**,必须改为显式 `now=` 传参(规范 §4 的时钟规则管的是后者,schedule 的 `next_after(now)` 是对照实例)。存储侧:`_apply` 把聚合的 `created_at` 写入行,读回经 `_tz_aware` 补时区——聚合构造时刻是唯一真相源,适配器不打自己的时间戳。
|
||||
|
||||
聚合不可变。想改评价就构造一个新的(`dataclasses.replace` 或 `Feedback.create`),没有 setter,没有"半更新"的中间态。`test_frozen` 盯着这条。
|
||||
### 3.5 `frozen=True` 意味着什么
|
||||
|
||||
代价是仓储的 `save` 只能是整体替换语义,不能是字段级 patch——这正是端口签名 `save(feedback: Feedback)` 的原因。
|
||||
聚合不可变。想改评价就构造一个新的,没有 setter,没有"半更新"的中间态(`test_frozen` 盯着)。代价是仓储的 `save` 只能是整体替换语义——这正是端口签名 `save(feedback)` 的原因。
|
||||
|
||||
### 4.5 一处与 schedule 的分歧:这里读了时钟
|
||||
## 4. Commands:写用例的具名载体
|
||||
|
||||
`created_at` 的默认值是 `datetime.now(UTC)`——**领域在构造期读了系统时钟**。schedule 模块刻意避免这件事(`now` 一律由调用方显式传参,领域从不读时钟,测试天然确定)。
|
||||
`commands.py` 是规范 §3.1"写用例一律 command 化"的落地:
|
||||
|
||||
两个切片在这一点上不一致,如实记录:feedback 的时间只是一个记账戳,不参与任何规则判断(没有"超过 N 天的评价失效"这类逻辑),所以读时钟的代价只是测试里断言时间需要一点宽容(`test_created_at_is_tz_aware` 只断言"带时区"而非具体值)。schedule 的时间是**规则输入**(下次触发、租约过期),那里必须显式传参。
|
||||
| Command | Handler | 请求模型 |
|
||||
|---|---|---|
|
||||
| `RateRun` | `FeedbackService.rate_run` | `RateRunRequest` |
|
||||
| `RetractRunRating` | `FeedbackService.retract_run_rating` | 无 body,端点内直接构造 |
|
||||
|
||||
如果哪天 feedback 出现依赖时间的规则,应当同时把 `created_at` 改成显式传参。
|
||||
命名链三种拼写互为变体(规范 §2.1),grep 任何一个就能找到用例全部三层。
|
||||
|
||||
**command 是哑数据**:不做业务校验——rating 合法性归聚合 `__post_init__`,结构校验归 router 的 api model。所以"非法 rating 先于未知 run 报错"的归因顺序由 handler 的构造顺序拥有,`test_command_is_dumb_data` 钉这条。
|
||||
|
||||
**查询刻意不 command 化**:`latest_per_run_in_thread` / `latest_for_runs` 收普通参数——command 表达改变状态的意图,包装读操作是纯样板。
|
||||
|
||||
**身份不进请求模型**:`RateRunRequest` 上没有也不许有 `user_id` 字段,它由服务端解析后经 `to_command(thread_id, run_id, user_id)` 注入(规范 §2.1 变形 ①)。
|
||||
|
||||
## 5. 端口:领域对外的两个依赖
|
||||
|
||||
`ports.py` 声明领域需要外界做什么,由外圈实现。
|
||||
|
||||
| 端口 | 回答什么问题 | 方法数 |
|
||||
|---|---|---|
|
||||
| `FeedbackRepository` | 评价存在哪、怎么按所有者过滤、并发 upsert 冲突怎么表达 | 4 |
|
||||
@ -144,48 +127,45 @@ VALID_FEEDBACK_TAGS = frozenset({
|
||||
|
||||
### 5.1 两条约定,比签名更重要
|
||||
|
||||
**① 所有者过滤是参数,不是异常。** 每个读方法都收 `user_id: str | None`:非 `None` 时限制到该用户的条目,`None` 表示不过滤(免鉴权模式)。别人的评价表现为"查不到",而不是抛权限错误。
|
||||
**① 读路径的所有者过滤是参数,不是异常。** 读方法收 `user_id: str | None`:非 `None` 时限制到该用户的条目,`None` 表示不过滤(免鉴权模式)。别人的评价表现为"查不到",而不是抛权限错误。
|
||||
|
||||
**② 并发 upsert 的败者必须翻译成领域错误。** 两个并发请求可以都没查到、都去插入,输的那个撞上唯一约束。适配器负责把 `IntegrityError` 翻译成 `DuplicateFeedbackError`,router 再映射成 HTTP 409 让客户端重试。旧代码在这里泄漏了驱动异常,结果是 500。
|
||||
|
||||
### 5.2 两个刻意的缺席
|
||||
### 5.2 删除路径的 `None` 是等值匹配,不是"不过滤"
|
||||
|
||||
**没有 `find_one` / `get_by_id`。** 读路径只有批量方法,因为唯一的消费者是消息列表回显——它要的是"这一页所有 run 的当前评价",不是"某一条评价"。加一个单条读方法就要有人来回答"谁在用它"。
|
||||
`remove_for_run` 与读方法**刻意不对称**:所有权是**等值匹配**——非 `None` 只删该用户的条目,`None` 只匹配 NULL 所有者的条目(免鉴权模式写下的),**不是**"无视所有者删除"。删除绝不能跨所有者,端口 docstring 与契约用例 `test_remove_for_run_none_user_matches_only_null_owner`(两实现各跑一遍)共同钉死这条语义。
|
||||
|
||||
**`RunLookup` 里没有 `user_id`。** 它只回答纯事实"run 属于哪个 thread",不掺授权。授权是两环链条,见 §6.2。
|
||||
### 5.3 两个刻意的缺席
|
||||
|
||||
### 5.3 为什么 `RunLookup` 只有一个方法
|
||||
**没有 `find_one` / `get_by_id`。** 读路径只有批量方法,因为唯一的消费者是消息列表回显——它要的是"这一页所有 run 的当前评价"。加单条读方法就要有人回答"谁在用它"。
|
||||
|
||||
feedback 需要知道的关于 run 的全部事情,就是"它属于哪个 thread"。而 run 上下文的仓储(`RunStore`)有 26 个方法。
|
||||
|
||||
依赖那一个方法而不是那 26 个,是接口隔离原则的教科书用法,收益是具体的:`domain/feedback/` 整个包里没有一行提到 `RunStore`、`runtime`、`persistence`。run 上下文换存储实现,feedback 领域一个字不用改。
|
||||
|
||||
代价是外圈要写一层防腐层,见 §7.2。
|
||||
**`RunLookup` 里没有 `user_id`。** 它只回答纯事实"run 属于哪个 thread",不掺授权——授权是两环链条,见 §6.2。feedback 需要知道的关于 run 的全部事情就是这一问,而 run 上下文的仓储有 26 个方法;依赖一个方法而不是 26 个,收益是 `domain/feedback/` 整个包里没有一行提到 `RunStore`。代价是外圈要写一层防腐层,见 §7.2。
|
||||
|
||||
## 6. 应用服务:用例编排
|
||||
|
||||
`service.py`,75 行。构造函数收两个端口,之后只见 Protocol 类型。
|
||||
构造函数收两个端口,之后只见 Protocol 类型。
|
||||
|
||||
### 6.1 用例清单
|
||||
|
||||
| 方法 | 支撑 | 领域错误 |
|
||||
|---|---|---|
|
||||
| `rate_run(...)` | `PUT /feedback` | `InvalidRatingError` / `InvalidTagError` / `RunNotFoundError` / `DuplicateFeedbackError` |
|
||||
| `retract_run_rating(...)` | `DELETE /feedback` | — |
|
||||
| `latest_per_run_in_thread(...)` | 消息列表全量路径 | — |
|
||||
| `latest_for_runs(...)` | 消息列表分页路径 | — |
|
||||
| 方法 | 输入 | 支撑 | 领域错误 |
|
||||
|---|---|---|---|
|
||||
| `rate_run(cmd)` | `RateRun` | `PUT /feedback` | `InvalidRatingError` / `InvalidTagError` / `RunNotFoundError` / `DuplicateFeedbackError` |
|
||||
| `retract_run_rating(cmd)` | `RetractRunRating` | `DELETE /feedback` | — |
|
||||
| `latest_per_run_in_thread(...)` | 普通参数 | 消息列表全量路径 | — |
|
||||
| `latest_for_runs(...)` | 普通参数 | 消息列表分页路径 | — |
|
||||
|
||||
服务自身**不含业务规则**:rating 和 tags 的合法性归聚合,所有者过滤归仓储,它只负责"按什么顺序调用谁"。
|
||||
服务自身**不含业务规则**:rating 和 tags 的合法性归聚合,所有者匹配归仓储,它只负责"按什么顺序调用谁"。
|
||||
|
||||
### 6.2 `rate_run`:顺序是设计,不是随意
|
||||
|
||||
```python
|
||||
feedback = Feedback.create(...) # ① 先构造:规则校验在此
|
||||
await self._require_run(thread_id, run_id) # ② 再查 run:引用完整性
|
||||
return await self._repository.save(feedback) # ③ 最后落库
|
||||
feedback = Feedback.create( # ① 先构造:规则校验在此
|
||||
run_id=cmd.run_id, thread_id=cmd.thread_id, rating=cmd.rating, ...)
|
||||
await self._require_run(cmd.thread_id, cmd.run_id) # ② 再查 run:引用完整性
|
||||
return await self._repository.save(feedback) # ③ 最后落库
|
||||
```
|
||||
|
||||
**① 在 ② 之前**,所以错误归因不受 IO 结果影响——非法 rating 配不存在的 run,报 `InvalidRatingError`。`test_invalid_rating_rejected_before_run_lookup` 就是钉这个顺序的。
|
||||
**① 在 ② 之前**,所以错误归因不受 IO 结果影响。`test_invalid_rating_rejected_before_run_lookup` 钉这个顺序。
|
||||
|
||||
**② 是引用完整性,不是授权。** 授权是两环链条:
|
||||
|
||||
@ -195,38 +175,32 @@ service 的 _require_run -> run 属于该 thread
|
||||
两者合起来 -> 你拥有这个 run
|
||||
```
|
||||
|
||||
这解释了为什么 `RunLookup.thread_of` 签名里没有 `user_id`:第一环已经证明了 thread 归属,第二环只需回答纯事实。少了任何一环都不成立——单独的 `_require_run` 拦不住"用自己的 thread 配别人的 run"以外的攻击面,单独的 `owner_check` 拦不住跨 thread 的 run id。
|
||||
这解释了为什么 `RunLookup.thread_of` 签名里没有 `user_id`:第一环已经证明了 thread 归属,第二环只需回答纯事实。少了任何一环都不成立。
|
||||
|
||||
### 6.3 读路径为什么有两个方法
|
||||
|
||||
`latest_per_run_in_thread` 拉整个 thread,`latest_for_runs` 只拉指定的一批 run。后者是给消息列表分页端点用的——一页只需要这一页那几个 run 的角标,拉整个 thread 在长会话里是浪费。
|
||||
|
||||
两者都是**批量**方法,返回 `dict[run_id, Feedback]`,一次查询覆盖一页。前端按钮的高亮状态由此而来,没有独立的读端点。
|
||||
`latest_per_run_in_thread` 拉整个 thread,`latest_for_runs` 只拉指定的一批 run——后者给消息列表分页端点用,一页只需要这一页那几个 run 的角标。两者都是**批量**方法,返回 `dict[run_id, Feedback]`,一次查询覆盖一页(防 N+1,规范 §5.2 读链路的实例)。
|
||||
|
||||
### 6.4 `retract` 为什么不查 run
|
||||
|
||||
写路径校验引用完整性,删路径不校验,这个不对称是有意的:删一条不存在的评价本来就返回 `False`(router 转成 404),多一次 `RunLookup` 调用不会改变任何结果,只是多一次 IO。
|
||||
写路径校验引用完整性,删路径不校验,这个不对称是有意的:删一条不存在的评价本来就返回 `False`(router 转成 404),多一次 `RunLookup` 调用不改变任何结果,只是多一次 IO。
|
||||
|
||||
## 7. 适配器:两种形态
|
||||
## 7. 适配器与组合根
|
||||
|
||||
两个从适配器住在 `app/adapters/feedback/`,一个端口一个文件。它们性质不同——判据和命名惯例见总纲 §5,这里讲各自的实现要点。
|
||||
两个从适配器住在 `app/adapters/feedback/`,一个端口一个文件;两形态判据见规范 §2。
|
||||
|
||||
### 7.1 `feedback_repository.py`:自有持久化
|
||||
|
||||
`SqlFeedbackRepository` 只做两种翻译:
|
||||
|
||||
1. **领域对象 ↔ ORM 行**:`_to_domain` / `_to_row` 两个静态方法。`_to_row` 显式列出每个字段而不是 `**asdict()`——新加的列在被刻意映射之前保持私有。
|
||||
1. **领域对象 ↔ ORM 行**:`_to_domain(row)` / `_apply(row, feedback)`(规范 §2.1 变形 ③r/③w)。`_apply` 就地全字段赋值,**一份字段清单同时服务 insert 与 update**——新字段不可能"插入有值、更新静默丢失";代理主键 `feedback_id` 刻意不在其中(insert 构造行时定死,upsert 因此天然保留既有身份)。显式列字段而非 `**asdict()`:新列在被刻意映射前不进出数据库。
|
||||
2. **技术异常 → 领域错误**:`IntegrityError → DuplicateFeedbackError`。
|
||||
|
||||
外加一件跨数据库的脏活:`_tz_aware()`。SQLite 读回来的 `datetime` 丢了 tzinfo,而存进去的一律是 UTC,所以读路径统一补回 `UTC`。这是 `_to_domain` 存在的第二个理由——**读取归一化只发生在这一个地方**。`tests/test_persistence_timezone.py` 盯着它。
|
||||
外加一件跨数据库的脏活:`_tz_aware()`。SQLite 读回来的 `datetime` 丢了 tzinfo,而存进去的一律是 UTC,读路径统一补回——**读取归一化只发生在 `_to_domain` 这一个地方**,`tests/test_persistence_timezone.py` 盯着它。
|
||||
|
||||
session 生命周期 = 一个端口方法(每方法一个短事务)。没有跨方法的事务,也没有 unit-of-work——feedback 的用例都是单聚合操作,不需要。
|
||||
|
||||
一处继承的微妙之处,这个类的 docstring 自己写着:
|
||||
|
||||
> Explicit inheritance is a readability aid only: a missing method would still instantiate fine (Protocol bodies are inherited), so the contract test suite must cover every port method.
|
||||
|
||||
`class SqlFeedbackRepository(FeedbackRepository)` 里的显式继承只是可读性提示。Protocol 的方法体是 `...`,继承过来就是"返回 `None` 的空实现",所以**少写或拼错一个方法不会报错,只会静默返回 `None`**。这不是理论风险,见 §11.2。
|
||||
类 docstring 自带一条继承警示(规范 §4 也有):显式继承 Protocol 只是可读性提示,拼错方法名不会报错、只会静默返回 `None`——契约套件必须覆盖每个端口方法并断言返回值,实证见 §11.1。
|
||||
|
||||
### 7.2 `run_lookup.py`:防腐层
|
||||
|
||||
@ -238,41 +212,25 @@ async def thread_of(self, run_id: str) -> str | None:
|
||||
return run.get("thread_id") if run else None
|
||||
```
|
||||
|
||||
把 26 个方法的 `RunStore` 收窄成 1 个问题,顺带把 `RunStore.get()` 返回的 `dict[str, Any]` 挡在领域外面。
|
||||
|
||||
`RunStore` 的类型注解走 `TYPE_CHECKING`,运行时零 import 开销——这个模块被组合根延迟 import,没必要在导入时拉起 `deerflow.runtime` 包。
|
||||
|
||||
文件里的 `TODO(hexagonal)` 写的是**触发条件**而不是抱怨:
|
||||
|
||||
```
|
||||
TODO(hexagonal): this depends on ``RunStore``, an infrastructure
|
||||
component, rather than on a contract published by the run context --
|
||||
that context has not been through a hexagonal slice yet. When it
|
||||
publishes one (a DTO, not its aggregate and not its repository),
|
||||
replace the body of this class. The ``RunLookup`` port does not move.
|
||||
```
|
||||
|
||||
最后一句是这层设计买到的东西:上游重构时改动范围是一个文件,端口和领域一行不动。
|
||||
|
||||
**为什么这是正常形态而不是欠债**:feedback 是下游(Customer),run 是上游(Supplier)。上游没有发布正式契约之前,下游为它写一层防腐层是 DDD 的标准答案。真正需要警惕的是这层**没有被测试**,见 §11.1。
|
||||
把 26 个方法的 `RunStore` 收窄成 1 个问题,顺带把 `RunStore.get()` 返回的 `dict[str, Any]` 挡在领域外面。文件里的 `TODO(hexagonal)` 写的是触发条件(run 上下文发布 DTO 契约时替换类体,端口不动)——防腐层是上下游关系的正常形态,不是欠债。真正需要警惕的是这层**没有对真实 `RunStore` 的契约测试**,见 §11.2。
|
||||
|
||||
### 7.3 组合根
|
||||
|
||||
`app/gateway/deps.py::langgraph_runtime` 是适配器唯一的实例化点:
|
||||
`app/composition.py::build_domain_services()` 是适配器唯一的实例化点——纯函数,收已建好的基础设施、返回装配结果,由 `deps.py::langgraph_runtime` 在启动时调用:
|
||||
|
||||
```python
|
||||
app.state.feedback_service = FeedbackService(
|
||||
repository=SqlFeedbackRepository(sf),
|
||||
runs=RunStoreRunLookup(app.state.run_store),
|
||||
return DomainServices(
|
||||
feedback=FeedbackService(
|
||||
repository=SqlFeedbackRepository(session_factory),
|
||||
runs=RunStoreRunLookup(run_store),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
`sf is None`(memory 后端)时 `feedback_service = None`,router 的依赖返回 503。这条规则目前只有一行注释在守——总纲 §7 待办 2 就是把这段装配抽成可单测的纯函数。
|
||||
`session_factory is None`(memory 后端)时服务为 `None`,router 的依赖返回 503——装配抽成纯函数正是为了让这条规则可被 `tests/test_composition.py` 断言,而不是靠 lifespan 里的一行注释。
|
||||
|
||||
## 8. 一次点踩的旅程
|
||||
|
||||
以 `PUT /api/threads/{tid}/runs/{rid}/feedback` 为主线,每一步都可以打开对应文件核对:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 浏览器
|
||||
@ -286,25 +244,18 @@ sequenceDiagram
|
||||
U->>R: PUT .../feedback (rating=-1)
|
||||
R->>R: @require_permission 校验 thread 归属
|
||||
R->>R: get_current_user 解析用户
|
||||
R->>S: rate_run(tid, rid, rating=-1, ..., user_id)
|
||||
R->>R: body.to_command(tid, rid, user_id) — 构造 RateRun
|
||||
R->>S: rate_run(RateRun)
|
||||
S->>A: Feedback.create(...) — 构造期校验 rating/tags
|
||||
S->>L: (经 RunLookup) thread_of(rid) — 引用完整性
|
||||
S->>I: (经 FeedbackRepository) save(feedback)
|
||||
I->>DB: 短事务:按业务身份查 → 更新或插入 → commit
|
||||
I->>DB: 短事务:按业务身份查 → _apply 到既有行或新行 → commit
|
||||
I-->>S: Feedback 领域对象
|
||||
S-->>R: Feedback
|
||||
R-->>U: 200(领域错误在此译为 400/404/409)
|
||||
R-->>U: 200 FeedbackResponse.from_domain(领域错误在此译为 400/404/409)
|
||||
```
|
||||
|
||||
逐层职责:
|
||||
|
||||
1. **Router**(`app/gateway/routers/feedback.py`)只做协议转换:解析请求与当前用户 → 调 service → 把领域错误映射为 HTTP 码(`InvalidRatingError→400`、`InvalidTagError→400`、`RunNotFoundError→404`、`DuplicateFeedbackError→409`)。不含任何业务判断。
|
||||
2. **Service**(`service.py`)编排用例,顺序见 §6.2。
|
||||
3. **聚合**(`model.py`)持有全部规则。回答"Feedback 的合法状态是什么",读这一个文件即可。
|
||||
4. **Adapters**(`app/adapters/feedback/`)只翻译不编排,见 §7。
|
||||
5. **组合根**(`deps.py`)决定谁实现哪个端口,见 §7.3。
|
||||
|
||||
**回显不走独立端点**:消息列表接口(`thread_runs.py`)经 service 的 `latest_per_run_in_thread` / `latest_for_runs` 批量取每个 run 的当前评价,嵌进消息数据返回——前端按钮高亮由此而来,与主流 Chat 产品一致。
|
||||
逐层职责:Router 只做协议转换(构造 command、渲染白名单响应、映射错误码);Service 编排(§6.2 的顺序);聚合持有全部规则;适配器只翻译不编排;组合根决定谁实现哪个端口。**回显不走独立端点**:消息列表接口经 `latest_per_run_in_thread` / `latest_for_runs` 批量取每个 run 的当前评价嵌进消息数据——前端按钮高亮由此而来。
|
||||
|
||||
## 9. 测试分层
|
||||
|
||||
@ -313,17 +264,15 @@ sequenceDiagram
|
||||
| 测试文件 | 层 | 规模 | 红了说明 |
|
||||
|---|---|---|---|
|
||||
| `test_feedback_domain.py` | 聚合 | 9 | 业务规则错 |
|
||||
| `test_feedback_service.py` | 用例编排(双 fake 端口) | 9 | 编排顺序或错误映射错 |
|
||||
| `test_feedback.py::FeedbackRepositoryContract` | 端口契约 × 2 实现 | 11 × 2 | 存储实现与端口语义漂移 |
|
||||
| `test_feedback_service.py` | 用例编排(双 fake 端口) | 10 | 编排顺序或错误映射错 |
|
||||
| `test_feedback.py::FeedbackRepositoryContract` | 端口契约 × 2 实现 | 12 × 2 | 存储实现与端口语义漂移 |
|
||||
| `test_composition.py` | 组合根 | 2 | 装配规则错(memory → None) |
|
||||
| `test_persistence_timezone.py` | 适配器细节 | 1 | SQLite 时区归一化坏了 |
|
||||
| `test_owner_isolation.py` | 跨用户隔离 | 2 | 所有者过滤坏了 |
|
||||
| `test_thread_messages_feedback.py` | 回显集成 | — | 消息列表拼装坏了 |
|
||||
|
||||
**契约测试的形状**值得学:`FeedbackRepositoryContract` 是一个不带 `Test` 前缀的基类(pytest 不会直接收集它),`TestSqlFeedbackRepository` 和 `TestInMemoryFeedbackRepository` 各自继承它并提供 `repo` fixture。一套语义用例,两套实现各跑一遍。
|
||||
**契约测试的形状**值得学:`FeedbackRepositoryContract` 是一个不带 `Test` 前缀的基类(pytest 不直接收集),`TestSqlFeedbackRepository` 和 `TestInMemoryFeedbackRepository` 各自继承并提供 `repo` fixture——一套语义用例,两套实现各跑一遍。**零 IO 的 fake 住在 `tests/feedback_fakes.py`**,不在测试文件里——契约套件和服务测试都能当普通模块 import。
|
||||
|
||||
**零 IO 的 fake 住在 `tests/feedback_fakes.py`**,不在测试文件里——这样契约套件和服务测试都能当普通模块 import,不依赖 tests 目录恰好在 `sys.path` 上。
|
||||
|
||||
`test_feedback_service.py` 是总纲 §2「唯一检验」的活样本:整个用例在两个 dict fake 上端到端跑通,零 IO、零 HTTP。
|
||||
`test_feedback_service.py` 是规范 §1「唯一检验」的活样本:整个用例在两个 dict fake 上端到端跑通,零 IO、零 HTTP。
|
||||
|
||||
## 10. 二次开发指引
|
||||
|
||||
@ -333,16 +282,17 @@ sequenceDiagram
|
||||
|
||||
1. `model.py` — `Feedback` 加字段;如果有合法值约束,在 `__post_init__` 里加校验
|
||||
2. `test_feedback_domain.py` — 先写红的测试(TDD 是这个仓库的硬要求)
|
||||
3. ORM 层 — `deerflow/persistence/feedback/model.py` 加列,并在 `migrations/versions/` 加一个 alembic revision(**每个 ORM 变更都必须有 revision**,见 backend/AGENTS.md)
|
||||
4. `feedback_repository.py` — `_to_domain` / `_to_row` 各加一行(这是"显式列字段"的代价,也是它的价值)
|
||||
5. `tests/feedback_fakes.py` — fake 通常不用改(它存整个聚合)
|
||||
6. `routers/feedback.py` — 要不要进请求/响应模型?**默认不进**,除非前端真的需要
|
||||
3. `commands.py` — 用户要能提交它才加(`RateRun` 加字段,router 的 `to_command` 跟着传);服务端派生的字段不进 command
|
||||
4. ORM 层 — `deerflow/persistence/feedback/model.py` 加列,并在 `migrations/versions/` 加一个 alembic revision(**每个 ORM 变更都必须有 revision**,见 backend/AGENTS.md)
|
||||
5. `feedback_repository.py` — `_to_domain` / `_apply` 各加一行(`_apply` 的一份清单覆盖 insert 与 update,不存在第三处要同步的写路径)
|
||||
6. `tests/feedback_fakes.py` — fake 通常不用改(它存整个聚合)
|
||||
7. `routers/feedback.py` — 要不要进请求/响应模型?**默认不进**,除非前端真的需要
|
||||
|
||||
端口签名不用动——`save(feedback)` 传的是整个聚合。
|
||||
|
||||
### 10.2 加一个原因标签
|
||||
|
||||
改 `model.py` 的 `VALID_FEEDBACK_TAGS`,加 slug;前端加翻译。不要在前端硬编码后端没有的 slug——`InvalidTagError` 会拦下来(这是有意的:白名单在领域侧才能保证分析口径统一)。
|
||||
改 `model.py` 的 `VALID_FEEDBACK_TAGS`,加 slug;前端加翻译。不要在前端硬编码后端没有的 slug——`InvalidTagError` 会拦下来(有意的:白名单在领域侧才能保证分析口径统一)。
|
||||
|
||||
### 10.3 加一个用例
|
||||
|
||||
@ -350,10 +300,10 @@ sequenceDiagram
|
||||
|
||||
1. 端口够用吗?`latest_per_run_in_thread` 可能已经够。够就不要加端口。
|
||||
2. 不够则先在 `ports.py` 加方法并写清语义(docstring 是契约测试的依据)
|
||||
3. `FeedbackRepositoryContract` 加用例——**两套实现都必须跑通**
|
||||
3. `FeedbackRepositoryContract` 加用例——**两套实现都必须跑通,并断言返回值**
|
||||
4. 两个实现各自实现
|
||||
5. `service.py` 加编排方法,`test_feedback_service.py` 用 fake 测
|
||||
6. router 加端点,只做协议转换
|
||||
5. 写用例则在 `commands.py` 加 command(命名链三拼写对齐);`service.py` 加方法,`test_feedback_service.py` 用 fake 测
|
||||
6. router 加端点,只做协议转换(有 body 则请求模型加 `to_command`)
|
||||
|
||||
### 10.4 换存储
|
||||
|
||||
@ -361,58 +311,53 @@ sequenceDiagram
|
||||
|
||||
### 10.5 不要做的事
|
||||
|
||||
- **不要在 router 里写业务判断。** rating 合法性、tags 白名单、引用完整性都有归属,router 只翻译协议。
|
||||
- **不要让端口签名出现 SQL / 表名 / HTTP 状态码。** 纯度测试拦 import,但拦不住命名——`save_row` 这种名字要靠 review。
|
||||
- **不要在领域里 import 适配器。** 想不到理由要这么做,但如果你觉得需要,说明用例编排放错层了。
|
||||
- **不要给 `RunLookup` 加方法来"顺便"拿 run 的别的信息。** 那是 run 上下文的数据,需要更多就说明该等它发布契约,或者你的用例应该住在别的上下文里。
|
||||
- **不要用 `**asdict()` 简化 `_to_row`。** 显式字段列表是一道闸门:新列在被刻意映射之前不会悄悄进出数据库。
|
||||
规范 §4 的规则清单全部适用;feedback 语境下最常犯的四条:
|
||||
|
||||
- **不要在 router 里写业务判断。** rating 合法性、tags 白名单、引用完整性都有归属。
|
||||
- **不要给 `RunLookup` 加方法来"顺便"拿 run 的别的信息。** 需要更多就说明该等 run 上下文发布契约,或你的用例应该住在别的上下文。
|
||||
- **不要用 `**asdict()` 简化 `_apply` 或 command 解包。** 显式字段列表是闸门。
|
||||
- **不要让身份字段出现在请求模型上。** `user_id` 只能经 `to_command` 参数注入。
|
||||
|
||||
## 11. 常见陷阱速查
|
||||
|
||||
### 11.1 `RunLookup` 没有契约测试(已知缺口,最高优先级)
|
||||
### 11.1 显式继承 Protocol 会把拼错的方法名变成静默 `None`
|
||||
|
||||
`FeedbackRepository` 有双实现契约套件,`RunLookup` 没有——`RunStoreRunLookup` 只在 `tests/feedback_fakes.py` 的 fake 层面被覆盖,**从未对着真实的 `RunStore` 跑过**。
|
||||
真实发生过:`latest_per_run_in_thread` 一度被写成 `latest_per_run_i_thread`(少一个 `n`)。因为适配器显式继承 Protocol,方法体 `...` 被继承,真方法退化成"返回 `None` 的空实现"——**没有 `AttributeError`**。抓到它的是契约套件里断言返回值的用例;`isinstance(repo, FeedbackRepository)` 抓不到(`runtime_checkable` 只查方法名存在,继承使名字总是存在)。所以"契约套件覆盖每个端口方法并断言返回值"不是可选项。
|
||||
|
||||
失效路径很具体:`RunStore.get()` 返回 `dict[str, Any]`,适配器靠 `run.get("thread_id")` 取值。一旦那个 dict 的键改名,`thread_of` 对所有 run 返回 `None`、所有点踩变成 404,而测试全绿。
|
||||
### 11.2 `RunLookup` 没有对真实 `RunStore` 的契约测试(已知缺口,最高优先级)
|
||||
|
||||
防腐层的价值是把不受控的外部形状挡在门外,门本身没被测过,挡不挡得住是运气。
|
||||
|
||||
### 11.2 显式继承 Protocol 会把拼错的方法名变成静默 `None`
|
||||
|
||||
真实发生过:`latest_per_run_in_thread` 一度被写成 `latest_per_run_i_thread`(少一个 `n`)。因为 `SqlFeedbackRepository` 显式继承 `FeedbackRepository`,Protocol 的方法体是 `...`,真方法退化成"返回 `None` 的空实现"——**没有 `AttributeError`**,只是所有调用返回 `None`。
|
||||
|
||||
抓到它的是契约套件里那些断言返回值的用例(`assert await repo.latest_per_run_in_thread(...) == {}` 得到 `None`)。
|
||||
|
||||
**注意 `test_satisfies_port` 抓不到这类错误**:`isinstance(repo, FeedbackRepository)` 对 `runtime_checkable` Protocol 只检查方法名存在,而继承使得名字总是存在。所以那条断言证明的是"没有忘记声明",不是"实现是对的"——**契约套件必须覆盖每个端口方法并断言返回值**,这不是可选项。
|
||||
`RunStoreRunLookup` 只在 fake 层面被覆盖。失效路径很具体:`RunStore.get()` 返回 `dict[str, Any]`,适配器靠 `run.get("thread_id")` 取值——键改名则 `thread_of` 对所有 run 返回 `None`、所有点踩变 404,而测试全绿。防腐层的价值是把不受控的外部形状挡在门外,门本身没被测过,挡不挡得住是运气。
|
||||
|
||||
### 11.3 upsert 更新时不要换 `feedback_id`
|
||||
|
||||
按业务身份三元组查到已有行时,保留原 `feedback_id`。前端可能用它做列表 key,换掉会导致重新评价后组件重挂载。
|
||||
按业务身份三元组查到已有行时保留原 `feedback_id`——`_apply` 不含代理主键正是为此。前端可能用它做列表 key,换掉会导致组件重挂载。
|
||||
|
||||
### 11.4 SQLite 读回的时间没有 tzinfo
|
||||
|
||||
一律经 `_to_domain` 的 `_tz_aware()` 补 UTC。绕过 `_to_domain` 自己组装 `Feedback` 就会漏掉这一步。
|
||||
|
||||
### 11.5 免鉴权模式下 `user_id` 是 `None`
|
||||
### 11.5 `user_id=None` 在读和删两侧语义不同
|
||||
|
||||
它是合法值,不是"忘记传"。所有读方法的 `user_id: str | None` 里,`None` 的语义是"不按所有者过滤",不是"过滤 user_id 为 null 的行"。写路径的 `remove_for_run` 是例外——它用 `user_id` 做等值匹配,所以 `None` 只删 `user_id IS NULL` 的行。
|
||||
读路径 `None` = 不按所有者过滤;删除路径 `None` = **只匹配 NULL 所有者的条目**(等值匹配,见 §5.2)。混淆两者的后果是免鉴权与鉴权混合部署时删错行——契约用例已钉死正确语义。
|
||||
|
||||
### 11.6 并发 upsert 的 409 不是 bug
|
||||
|
||||
两个并发请求都没查到、都插入,输的那个撞唯一约束 → `DuplicateFeedbackError` → HTTP 409。客户端重试即可。旧代码这里泄漏 `IntegrityError`,返回的是 500。
|
||||
两个并发请求都没查到、都插入,输的那个撞唯一约束 → `DuplicateFeedbackError` → HTTP 409,客户端重试即可。旧代码这里泄漏 `IntegrityError`,返回的是 500。
|
||||
|
||||
## 12. 代码索引
|
||||
|
||||
| 关注点 | 文件 |
|
||||
|---|---|
|
||||
| 聚合、不变量、领域错误 | `packages/harness/deerflow/domain/feedback/model.py` |
|
||||
| 聚合、不变量、白名单常量 | `packages/harness/deerflow/domain/feedback/model.py` |
|
||||
| 领域错误(一族一基类) | `packages/harness/deerflow/domain/feedback/exceptions.py` |
|
||||
| 命令(写用例的输入载体) | `packages/harness/deerflow/domain/feedback/commands.py` |
|
||||
| 端口契约(语义写在 docstring 里) | `packages/harness/deerflow/domain/feedback/ports.py` |
|
||||
| 用例编排 | `packages/harness/deerflow/domain/feedback/service.py` |
|
||||
| 用例编排(写方法即 command handler) | `packages/harness/deerflow/domain/feedback/service.py` |
|
||||
| 上下文公开 API | `packages/harness/deerflow/domain/feedback/__init__.py` |
|
||||
| SQL 适配器 | `backend/app/adapters/feedback/feedback_repository.py` |
|
||||
| SQL 适配器(`_to_domain` / `_apply`) | `backend/app/adapters/feedback/feedback_repository.py` |
|
||||
| 防腐层适配器 | `backend/app/adapters/feedback/run_lookup.py` |
|
||||
| HTTP 入口 + 错误映射 | `backend/app/gateway/routers/feedback.py` |
|
||||
| 组合根 | `backend/app/gateway/deps.py::langgraph_runtime` |
|
||||
| ORM 行 + 迁移 | `packages/harness/deerflow/persistence/feedback/model.py`、`persistence/migrations/versions/` |
|
||||
| HTTP 入口 + api model + 错误映射 | `backend/app/gateway/routers/feedback.py` |
|
||||
| 组合根 | `backend/app/composition.py::build_domain_services` |
|
||||
| ORM 行 + 迁移 | `packages/harness/deerflow/persistence/feedback/model.py`、`migrations/versions/0011_feedback_drop_message_id.py` |
|
||||
| 零 IO fake | `backend/tests/feedback_fakes.py` |
|
||||
| 测试分层 | 见 §9 |
|
||||
|
||||
@ -1,207 +1,294 @@
|
||||
# DeerFlow 后端架构:六边形设计
|
||||
# DeerFlow 后端架构:六边形设计规范
|
||||
|
||||
> 面向想理解 DeerFlow 后端架构的学习者。读完你将能回答:任何一段后端代码,为什么在它所在的位置。
|
||||
> 本文是**规范**:新业务模块必须按此结构落地,代码与规范冲突时以规范为准。全文只讲标准形态,不绑定具体模块——规则落到真实代码上的样子,见参考实现的模块文档:[FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md) 与 [SCHEDULE_DESIGN_zh.md](SCHEDULE_DESIGN_zh.md)。编码规约见 `backend/AGENTS.md`。
|
||||
>
|
||||
> **本文只讲规则**,不讲某个模块的具体样子。想看规则落到真实代码上、想动手改一个模块,读对应的模块文档:
|
||||
>
|
||||
> - [FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md) —— 用户反馈模块,首个完成的切片,最简单的样板
|
||||
> - [SCHEDULE_DESIGN_zh.md](SCHEDULE_DESIGN_zh.md) —— 定时任务模块,两个聚合 + 两个状态机 + 四道并发防线(**尚在 `rayhpeng/hexagonal-scheduling-slice` 分支,未合并到本分支**)
|
||||
>
|
||||
> 编码规约见 `backend/AGENTS.md`。
|
||||
> 引用标记 [C] / [G] / [B] / [P] 的出处见 §7。
|
||||
|
||||
---
|
||||
|
||||
## 1. 两个正交的边界
|
||||
## 1. 六边形架构是什么
|
||||
|
||||
DeerFlow 后端由两个互相垂直的边界切分,理解它们是理解一切的前提。
|
||||
真名 **Ports & Adapters**(Cockburn, 2005 [C])。"六边形"只是画图的偶然。它的意图,原文一句话说尽:
|
||||
|
||||
**边界一:harness / app —— 回答"哪些代码可复用发布"。**
|
||||
> "Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases." [C]
|
||||
>
|
||||
> (允许应用被用户、程序、自动化测试或批处理脚本**平等地**驱动,并且能在与最终运行期设备和数据库**隔离**的条件下开发与测试。)
|
||||
|
||||
- `packages/harness/deerflow/`(import 前缀 `deerflow.*`):可独立发布的 agent 框架包——agent 循环、工具、沙箱、MCP、技能、配置;
|
||||
- `app/`(import 前缀 `app.*`):不发布的应用层——FastAPI Gateway 与 IM 渠道集成。
|
||||

|
||||
|
||||
依赖单向:app 可以 import deerflow,deerflow 永不 import app(CI 中 `tests/test_harness_boundary.py` 执法)。
|
||||
| 图中概念 | 定义 | 规范落点 |
|
||||
|---|---|---|
|
||||
| Domain Model · Aggregates | 聚合:一致性边界,不变量在构造期成立 | `domain/<ctx>/model` |
|
||||
| Domain Model · Value Objects | 值对象:无身份、按值比较;解析校验过的领域概念 | 同上 |
|
||||
| Domain Model · Domain Errors | 领域自己的失败词汇——入口拿它们映射协议码,而不是让领域认识 409 | `exceptions.py` |
|
||||
| Application Service(input port) | 用例编排的入口面,自身不含业务规则 | `service.py` |
|
||||
| Ports | **领域自己声明、自己拥有**的技术中立接口,画在六边形的边上——它是六边形的一部分 | `ports.py` |
|
||||
| Driving / Primary Adapters | 驱动应用的入口。Cockburn 点名 http 应用、批处理、**自动化测试**皆是 driver——"测试与真实入口平等"是定义的一部分 | 入口 + 时钟 + 回调 + service 测试 |
|
||||
| Driven / Secondary Adapters | 实现端口的外部集成:自有持久化 与 防腐层 两形态(§2) | `app/adapters/<ctx>/` |
|
||||
| External Service / Other Context | 对一个六边形而言,**同进程的邻居上下文也是外部世界**——这是防腐层存在的原因 | 防腐层的对接对象 |
|
||||
|
||||
**边界二:内圈 / 外圈 —— 回答"业务规则与技术细节谁依赖谁"。**
|
||||
**全图的题眼是两种箭头**:实线 "calls" 是调用方向(入口调进来,业务也经端口调出去);虚线 "implements" 是依赖方向(适配器实现领域声明的接口)。调用可以双向穿越边界,**依赖永远指向内**——两者解耦即"依赖反转"的准确含义([B]:"the domain is the core of the application and doesn't depend on any other module")。
|
||||
|
||||
这是六边形架构引入的维度:**业务核心(内圈)不依赖任何基础设施(外圈);基础设施实现业务声明的接口**。两个边界叠加后的切分规则一句话:
|
||||
**唯一检验**:业务逻辑能否在没有 HTTP、没有数据库的测试里(全部换成 fake)完整运行——参考实现的 service 测试套件就是这个检验的常态化:整个用例在零 IO 的 dict fake 上端到端跑通。
|
||||
|
||||
> **domain(模型 + 端口 + 应用服务)归 harness;adapters + 入口 + 组合根归 app。**
|
||||
## 2. 标准结构
|
||||
|
||||
历史上仓库只有边界一,于是持久化实现以"框架能力"的名义沉进了 harness,业务规则以"应用胶水"的名义散进了 router——两个维度被一条边界承担。六边形重构补上的就是第二个维度。
|
||||
本仓库先有一条自己的边界:**harness(`deerflow.*`,可发布框架包)/ app(`app.*`,不发布应用层)**,依赖单向 app → harness。六边形叠加其上的切分规则一句话:
|
||||
|
||||
## 2. 六边形架构究竟是什么
|
||||
> **domain(模型 + 命令 + 端口 + 应用服务)归 harness;adapters + 入口 + 组合根归 app。**
|
||||
|
||||
真名是 **Ports & Adapters**(Cockburn, 2005)。"六边形"只是画图的偶然,六条边没有任何含义。它的全部内容是一个观念转变加一条规则:
|
||||
结构直接采用 AWS Prescriptive Guidance 的三文件夹划分——"entrypoints (primary adapters), domain (domain and interfaces), and adapters (secondary adapters)" [B]——每个业务模块的规定形态:
|
||||
|
||||
> **应用只有"内外"之分,没有"上下"之分。** 数据库和浏览器没有本质区别——它们都是站在边界外、想与业务核心对话的外部世界。传统分层图把数据库画成业务的"地基",这是错觉;六边形把它掰起来,与 UI 摆在同一圈上,降格为业务的一个"插件"。
|
||||
```
|
||||
packages/harness/deerflow/domain/<ctx>/ # 内圈(对应 AWS domain/ 七件套 [B])
|
||||
├── model.py 或 model/ # 聚合·值对象(model/:"entities, value objects, and domain services")
|
||||
├── exceptions.py # 领域错误(exceptions/:"the known errors defined within the domain";
|
||||
│ # 与 model 平级——AWS 树中 exceptions/ 是七件套的独立成员)
|
||||
├── commands.py # 命令(commands/:"command objects that define the information
|
||||
│ # required to perform an operation on the domain";见 §3.1)
|
||||
├── ports.py # 端口(ports/:"abstractions through which the domain communicates
|
||||
│ # with databases, APIs, or other external components")
|
||||
│ # ★ ports 是 domain 的子目录,不是与之平级的第三层——抽成独立层
|
||||
│ # 会让领域依赖外部包才能声明自己的需求,恰好破坏依赖倒置
|
||||
├── service.py # 应用服务(command_handlers/:"methods or classes that run commands
|
||||
│ # on the domain";写方法即 handler,命名依据见 §3.1)
|
||||
└── (events.py) # 领域事件——按 §3.2 的触发条件引入,默认不建
|
||||
|
||||
**唯一规则**:依赖箭头永远从外指向内。核心声明接口(port),外部世界各自带转换头(adapter)来插。调用可以由内向外发起(业务调仓储),但依赖方向始终向内(仓储实现的是业务声明的接口)——调用方向与依赖方向解耦,这就是"依赖反转"的准确含义。
|
||||
backend/app/adapters/<ctx>/ # 从适配器(AWS adapters/ [B])
|
||||
├── <端口名snake_case>.py # 一个端口一个文件;两种形态见下
|
||||
backend/app/gateway/routers/<ctx>/ # 入口(AWS entrypoints/ [B];目录名沿用既有 routers,
|
||||
│ # 等多数模块六边形化后一次性更名)
|
||||
├── router.py # 协议转换 + 领域错误→HTTP 单表映射
|
||||
└── models.py # api model:"defines the interface the primary adapter requires to
|
||||
# communicate with clients" [B] —— 主适配器自己的模型,不是领域
|
||||
# 对象的视图;入向 to_command、出向 from_domain,见 §2.1
|
||||
backend/app/composition.py # 组合根:适配器唯一实例化点(AWS 未定义此概念,
|
||||
# 本仓库自有——纯函数,装配规则可被单测断言)
|
||||
```
|
||||
|
||||
**唯一检验**:业务逻辑能否在没有 HTTP、没有数据库的测试里(全部换成 fake)完整运行。能,就实现了;不能,无论目录叫什么名字都没实现。DeerFlow 的 `tests/test_feedback_service.py` 就是这个检验的活样本:整个 feedback 用例在零 IO 的 dict fake 上端到端跑通。
|
||||
**文件命名规则**:
|
||||
|
||||
- **单复数**:装同类多件的文件用复数(`ports.py` / `commands.py` / `exceptions.py` / `events.py`);装单一整体概念或单个类的用单数(`model` = the domain model 这个整体,AWS 目录树与 cosmicpython 同为单数;`service.py` 里恰好一个 Application Service 类)。名从形态,不照搬 Django 的 `models.py`——那是 ORM 语境。
|
||||
- **错误类名保留 PEP 8 的 `Error` 后缀**(如 `XxxNotFoundError`),文件名叫 `exceptions.py` 对齐 AWS 概念名——两者不冲突,`requests/exceptions.py` 里放 `HTTPError` 正是先例;`Exception` 后缀是 Java/C# 惯例,与标准库和本仓库全部现存错误类相悖,禁止引入。
|
||||
- 一个上下文一个错误基类(`<Ctx>Error`),入口的单表映射可按基类兜底。
|
||||
|
||||
**从适配器的两种形态**,判据两问——表归本上下文所有吗?自己写 SQL 吗:
|
||||
|
||||
| | 两问皆是:自有持久化 | 两问皆否:防腐层(ACL) |
|
||||
|---|---|---|
|
||||
| docstring 首行标记 | `Secondary adapter (owned persistence)` | `Secondary adapter (anti-corruption layer)` |
|
||||
| 类名前缀 | `Sql`(我自己写 SQL) | 被包装者的名字(读作"我借道那个组件") |
|
||||
| 职责 | 领域对象 ↔ ORM 行显式互译;技术异常 → 领域错误 | 把上游宽接口收窄成本上下文的一个问题 |
|
||||
|
||||
技术维度不进文件名(换存储时 `sql_` 前缀就得改名,而端口没变);防腐层的 TODO 写**触发条件**而非抱怨——上游发布契约(一个 DTO,不是它的聚合或仓储)时替换类体,端口不动。**跨上下文只能拿 DTO**,永远不依赖别人的聚合或仓储。
|
||||
|
||||
**ORM 行的归属**是一条有意规则而非欠债:表定义随共享的 engine/alembic migrations 基建统一居住在 harness `persistence/` 下,**适配器是它唯一的读写方**;迁出的触发条件是共享基建的所属模块自身六边形化。
|
||||
|
||||
### 2.1 转换链:数据的四次变形
|
||||
|
||||
**线上格式(wire)**:HTTP body 中实际传输的原始 JSON——转换链的最外端形态,类型系统之外的世界(没有 tuple 只有 array、没有 datetime 只有字符串,客户端想塞什么字段都能塞)。wire 上的字节就是 API 契约本身。
|
||||
|
||||
一个写请求穿过六边形时,数据恰好变形四次(① ② ③w ④);读回程经 ③r 重建。每次变形有**唯一的 owner 和规定的方法名**——这是全文档最机械、也最值得机械执行的部分:
|
||||
|
||||
| # | 变形 | Owner | 方法 | 规则 |
|
||||
|---|---|---|---|---|
|
||||
| ① | wire → Command | 请求模型 | `to_command(path_params..., user_id)` | body 归模型字段;路径参数与**服务端解析的身份**经参数注入——**身份字段禁止出现在请求模型上**;wire 类型 → 领域类型(如 `list` → `tuple`)在此完成;无 body 的用例在端点内直接构造 command,不造空请求模型 |
|
||||
| ② | Command → 聚合 | handler(service 写方法) | 显式逐字段调聚合工厂 `Aggregate.create(...)` | 禁止 `**asdict(cmd)`——显式字段列表是闸门;禁止聚合与 command 互相 import(两者只在 service 相遇);**工厂参数 = 聚合字段(减去工厂生成的代理主键)**:标量传标量、多个参数共同表达一个领域概念时聚成值对象传入,永不收 command——聚合的构造面向多个来源(handler、适配器重建、测试),不绑定任何用例的输入形态 |
|
||||
| ③w | 聚合 → ORM 行 | 自有持久化适配器 | `_apply(row, aggregate)`(就地) | **一份显式字段清单服务 insert 与 update 两条写路径**——新字段不可能"插入有值、更新静默丢失";代理主键不进 `_apply`(在 insert 构造行时定死,upsert 保留既有身份归端口契约)。例外:CAS 型写方法(§4)分字段所有权,**刻意不走**整聚合映射 |
|
||||
| ③r | ORM 行 → 聚合 | 自有持久化适配器 | `_to_domain(row)` | 显式字段列表;读取归一化(如补时区)只发生在此处;重建也过 `__post_init__`(坏行读取即爆);技术异常在适配器译成领域错误 |
|
||||
| ④ | 聚合 → wire | 响应模型 | `from_domain(aggregate)` classmethod | 白名单渲染,服务端字段(租约、身份内部形态…)不进 wire;禁止模块级散转换函数 |
|
||||
|
||||
**命名链**:一个写用例一个名字,三种拼写互为变体——command `PascalCase` 祈使动词短语(无 `Command` 后缀,模块路径已是语境)↔ handler 方法 `snake_case` ↔ 请求模型 `<Command名>Request`。grep 任何一个拼写就能找到用例的全部三层。
|
||||
|
||||
静态结构(类图,通用角色):
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph app["app · 官方宿主(六边形外圈)"]
|
||||
direction TB
|
||||
PA["primary adapters<br/>gateway/routers · channels · scheduler"]
|
||||
SA["secondary adapters<br/>app/adapters/feedback"]
|
||||
CR["deps.py · 组合根"]
|
||||
classDiagram
|
||||
class UseCaseRequest {
|
||||
<<api model>>
|
||||
+仅 body 字段
|
||||
+to_command(path_params, user_id) Command
|
||||
}
|
||||
class ResourceResponse {
|
||||
<<api model · 白名单>>
|
||||
+from_domain(aggregate)$
|
||||
}
|
||||
class Command {
|
||||
<<frozen · 哑数据>>
|
||||
+路径参数·user_id·payload
|
||||
}
|
||||
class ApplicationService {
|
||||
<<input port>>
|
||||
+use_case(cmd) Aggregate
|
||||
+query(params) 批量
|
||||
}
|
||||
class Aggregate {
|
||||
<<frozen>>
|
||||
+create()$
|
||||
+__post_init__() 校验
|
||||
}
|
||||
class OutputPort {
|
||||
<<Protocol>>
|
||||
+save(aggregate)
|
||||
}
|
||||
class SqlAdapter {
|
||||
<<owned persistence>>
|
||||
-_apply(row, aggregate)
|
||||
-_to_domain(row)
|
||||
}
|
||||
class OrmRow
|
||||
|
||||
UseCaseRequest ..> Command : ① to_command
|
||||
ApplicationService ..> Command : 消费
|
||||
ApplicationService ..> Aggregate : ② create
|
||||
ApplicationService --> OutputPort : 调用
|
||||
SqlAdapter ..|> OutputPort : 实现
|
||||
SqlAdapter ..> OrmRow : ③w _apply / ③r _to_domain
|
||||
ResourceResponse ..> Aggregate : ④ from_domain
|
||||
note for Command "Command 与 Aggregate 互不 import\n唯一汇点是 Service"
|
||||
```
|
||||
|
||||
## 3. Commands 与 Events
|
||||
|
||||
AWS 的 domain 七件套里有 `commands/` 与 `events/` [B]。commands 是本仓库的标准配备;events 采用轻量形态 + 明确的升格触发条件——不是省略,是设计决策,何时升格写在这里。
|
||||
|
||||
### 3.1 Commands:写用例一律 command 化,查询不
|
||||
|
||||
标准形态是每个用例一个 command 对象 + 每 command 一个 handler——[G] 指出这是单一职责与开闭原则的应用方式。本仓库的规定:
|
||||
|
||||
- **写用例一律 command 化**:每个改变状态的用例对应 `commands.py` 里的一个 frozen dataclass,handler 即 service 方法;**查询保持普通参数**——command 表达改变状态的意图,包装读操作是纯样板(CQRS 的最浅形态:读写异形)。
|
||||
- **command 是哑数据**:不做业务校验——值规则归聚合 `__post_init__`,结构校验归入口的 api model,所以错误归因顺序(构造聚合先于任何 IO)仍由 handler 的构造顺序拥有。
|
||||
- **构造点在 api model**:`body.to_command(...)`(§2.1 变形 ①)——wire 形状拥有"翻译成领域词汇"这半边。
|
||||
- **部分更新的提示**:update 类用例(`None` = 未提供)command 化时需要 `Unset` sentinel 表达三态,勿把 `None` 的两种含义混进一个字段。
|
||||
- **文件为何叫 `service.py` 而非 `command_handlers.py`**:"Application Service" 是六边形/DDD 的一级正统术语,AWS 的 `command_handlers/` 是它的一种实现风格(函数式 handler + 注册表分发),不是概念本身的名字——名字跟着选定的形态走:本仓库的形态是"handler 即 service 方法",且该类还持有不收 command 的查询方法,叫 `command_handlers.py` 会错报一半内容(cosmicpython 同样先叫 `services.py`,到引入 message bus 那章才改名 `handlers.py`)。**演进分界**:当 §3.2 的 events 升格、组合根引入统一 dispatcher 时,写路径的自然形态变为独立 handler 函数(command handler 与 event handler 同居 `handlers.py`,按类型分发),`Service` 类在写侧消解、读侧留成 `queries.py`——在那之前,一个 Service 类聚合读写用例是更少样板、类型可追踪的形态。它的贫血风险由既有规则守住:"service 自身不含业务规则"(§4)。
|
||||
|
||||
### 3.2 Events:业务事实默认走入站适配器,第二订阅方出现时升格为事件
|
||||
|
||||
标准形态是领域行为完成后发出事件("Define events that the domain objects emit after they complete a behavior" [B]),供其他模块订阅。本仓库的对应设计:
|
||||
|
||||
- **轻量形态**:一个业务事实只有一个消费者时,用**点对点回调 + 入站适配器**表达——入站适配器负责过滤(与本上下文无关的事件压根产生不出领域 DTO,service 因此不需要守卫子句)与翻译(运行时类型 → 领域词汇),再调用用例。组合根只装这一个监听者,领域不产生事件对象。
|
||||
- **升格触发条件**:同一业务事实出现**第二个订阅方**。判例:要给某个已有回调链的事实加通知——在监听者里加分支(入站适配器开始编排)或让 service 调通知端口(本上下文被迫认识通知领域)都是错误答案——正确答案是此刻引入事件。
|
||||
- **升格路径**:
|
||||
1. 事件类型放 `domain/<ctx>/events.py`(frozen dataclass,领域词汇),由**应用服务**在用例完成处发出——聚合保持纯函数式(返回新状态),不自带事件收集器;
|
||||
2. 组合根装配一个进程内同步 dispatcher(`dict[事件类型, list[订阅者]]` 即可起步),替换单一 hook;订阅者住各自上下文的入站适配器;
|
||||
3. 既有入站适配器的过滤与翻译职责不变——它翻译出的领域 DTO 驱动用例,用例完成后发领域事件;跨进程投递(AWS 语境的 "routed to other microservices")是第三阶段,触发条件是真的拆了服务。
|
||||
|
||||
## 4. 规则清单
|
||||
|
||||
每条附执法手段;无机械执法的靠 review,标 ⚠。
|
||||
|
||||
| 规则 | 执法 |
|
||||
|---|---|
|
||||
| harness 永不 import `app.*` | `tests/test_harness_boundary.py` |
|
||||
| `domain/` 永不 import sqlalchemy / fastapi / pydantic / app / harness 基础设施 | `tests/test_harness_domain_purity.py`(AST) |
|
||||
| 不变量在 `__post_init__` 校验,工厂与直接构造走同一条路("创建即一致",绕不过去) | 域测试(直接构造也校验的用例模式) |
|
||||
| `now` 与运营阈值显式传入(`now=` 参数、policy 值对象注入),领域不读时钟与配置 | 纯度测试拦 import;⚠ `datetime.now` 靠 review |
|
||||
| 端口签名技术中立:不出现 SQL、表名、HTTP 状态码、`Mapping[str, Any]`、运行时类型 | ⚠ review;`Mapping` 出现即领域在处理传输/存储格式 |
|
||||
| 事务边界在端口方法内部(`async with session_factory()`);session 不进路由签名 | ⚠ review;per-request session 会破坏冲突翻译点与非 HTTP 入口 |
|
||||
| 技术异常在适配器译成领域错误,翻译点唯一;防腐层只许端口契约声明的异常逃逸 | 契约测试 + service 测试 |
|
||||
| 领域错误 → HTTP 码单表映射;未分类错误落 500,不默认 4xx | router 测试 |
|
||||
| 转换链四次变形的 owner 与方法名固定(§2.1),身份字段禁止出现在请求模型上 | ⚠ review |
|
||||
| 契约测试覆盖每个端口方法**并断言返回值**——`isinstance(repo, Port)` 抓不到拼错的方法名(Protocol 继承使名字总是存在) | 各上下文契约套件 × 双实现 |
|
||||
| CAS 不得表达成 `save(aggregate)`——读改写会重新引入 CAS 要关的竞态 | 契约测试 |
|
||||
| 响应模型是白名单不是 dump;服务端字段不进 wire | api model 显式字段 |
|
||||
| 越权一律表现为"不存在"(None / False / 404),不抛权限错误 | 契约测试 + owner isolation 测试 |
|
||||
|
||||
测试分层与架构分层一一对应:域测试红 = 规则错;service 测试红 = 编排错;契约测试红 = 存储实现错。契约测试全绿**不代表**可以多实例并发——原子性归实现不归契约,由真数据库的 race 测试单独负责。
|
||||
|
||||
## 5. 调用关系:读与写的标准链路
|
||||
|
||||
### 5.1 写链路
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant R as Router + API Model
|
||||
participant S as Application Service
|
||||
participant A as Aggregate
|
||||
participant P as 防腐层<br/>(经 output port)
|
||||
participant AD as SQL Adapter<br/>(经 output port)
|
||||
participant DB as DB
|
||||
|
||||
C->>R: PUT /resource {body}
|
||||
R->>R: ① body.to_command(path, user_id)
|
||||
Note over R: 身份服务端解析,禁止进 body
|
||||
R->>S: use_case(cmd)
|
||||
S->>A: ② Aggregate.create(...)
|
||||
Note over S,A: 构造期校验——规则先于任何 IO
|
||||
S->>P: 引用完整性 / 前置事实
|
||||
S->>AD: save(aggregate)
|
||||
AD->>DB: ③w _apply(row) → 短事务
|
||||
DB-->>AD: row
|
||||
AD-->>S: ③r _to_domain → Aggregate
|
||||
S-->>R: Aggregate
|
||||
R-->>C: ④ 200 Response.from_domain(...)
|
||||
Note over R: 白名单渲染
|
||||
|
||||
alt 领域错误
|
||||
S--)R: DomainError
|
||||
R-->>C: 单表映射 → 4xx(未分类落 500)
|
||||
end
|
||||
subgraph harness["deerflow-harness(内圈 + 运行时)"]
|
||||
direction TB
|
||||
SV["domain/*/service.py<br/>(input ports)"]
|
||||
PO["domain/*/ports.py<br/>(output ports)"]
|
||||
DM["domain/*/model.py"]
|
||||
RT["runtime/(agent loop)"]
|
||||
end
|
||||
DB[("SQLite / Postgres")]
|
||||
PA -->|调用| SV
|
||||
SV --> PO
|
||||
SV --> DM
|
||||
SA -->|实现| PO
|
||||
CR -.->|启动时装配注入| SA
|
||||
SA --> DB
|
||||
style DM fill:#fff3cc
|
||||
style PO fill:#e2f7e2
|
||||
style SV fill:#d8ecff
|
||||
```
|
||||
|
||||
**与 AWS 官方指南的对应。** DeerFlow 的分层直接采用 AWS Prescriptive Guidance《Building hexagonal architectures on AWS》的划分:应用分成三个文件夹——入口(主适配器)、领域(领域**与接口**)、适配器(从适配器):
|
||||
要点各归其位:编排顺序本身是设计——先构造聚合(零 IO 校验先行),错误归因不受 IO 结果影响;事务收在端口方法内部,路由不知道 session 存在;技术异常在适配器译成领域错误后才穿出边界。
|
||||
|
||||
| AWS 概念 | AWS 建议的位置 | DeerFlow 对应 |
|
||||
|---|---|---|
|
||||
| 入口 / primary adapters | `entrypoints/` | `app/gateway/routers/`、`app/channels/`、scheduler |
|
||||
| 领域 + 接口 | `domain/`(`ports/` 是它的**子目录**) | `packages/harness/deerflow/domain/<模块>/`,`ports.py` 在包内 |
|
||||
| 从适配器 / secondary adapters | `adapters/` | `app/adapters/<上下文>/` |
|
||||
### 5.2 读链路
|
||||
|
||||
**端口属于领域,不是独立一层**——AWS 明确把 `ports/` 放在 `domain/` 之下,描述为"领域借以与数据库、API 或其他外部组件通信的抽象"。把端口抽成与领域平级的第三层是常见误读:那会让领域反过来依赖一个外部包才能声明自己的需求,恰好破坏依赖倒置。
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant R as Router
|
||||
participant S as Application Service
|
||||
participant AD as SQL Adapter<br/>(经 output port)
|
||||
participant DB as DB
|
||||
|
||||
DeerFlow 的两处偏离,都是有意的:`entrypoints/` 沿用仓库既有的 `app/gateway/routers/`(改名收益不抵影响面);**组合根 AWS 未定义**——那份指南没有这个概念,DeerFlow 的组合根(`app/gateway/deps.py::langgraph_runtime`)是自己的工程判断,实例见 [FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md) §7.3。
|
||||
|
||||
## 3. 内圈的三个构件
|
||||
|
||||
每个已迁移的业务模块在 `packages/harness/deerflow/domain/<模块>/` 下由三个文件组成,纪律各不同:
|
||||
|
||||
| 文件 | 职责 | 纪律 |
|
||||
|---|---|---|
|
||||
| `model.py` | **聚合**:业务事实与不变量 | 零依赖(仅标准库);不知道存储和 HTTP 的存在;不变量在构造期校验("创建即一致") |
|
||||
| `ports.py` | **端口**:领域声明的接口(`typing.Protocol`) | 技术中立——签名里不允许出现 SQL、表名、文件路径等词汇 |
|
||||
| `service.py` | **应用服务**:用例编排 | 内圈里唯一调用 output port 的构件;`user_id` 显式传参;自身不含业务规则 |
|
||||
|
||||
一个关键理解:"service 调用 port"是合法的——**port 是 domain 自己声明、自己拥有的接口**,调用自己的抽象不构成对外圈的依赖。运行时注入的实现来自外圈(`app/adapters/`),但 service 只见 Protocol 类型。
|
||||
|
||||
方向要分清:**output port 是"注入进来"**(组合根把 SQL adapter 塞进 service 构造函数),**input port 是"暴露出去"**(service 本身就是 input port 实现体,router 从 `app.state` 拿到后调用)。
|
||||
|
||||
三个文件不是硬性文件数,是三种纪律:模块简单时 `model.py` 是单文件(feedback),复杂到有多个聚合和值对象时它是一个包(schedule 的 `model/`)。想看这三层在真实代码里长什么样、以及一个请求怎么穿过它们,读 [FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md) §3–§8。
|
||||
|
||||
## 4. 从适配器的两种形态
|
||||
|
||||
同一个上下文的从适配器看起来同级,性质可能完全不同。以 feedback 的两个为例——`SqlFeedbackRepository` 自己写 SQL,`RunStoreRunLookup` 一行 SQL 也没有。分辨靠两个问题:
|
||||
|
||||
1. **这些表归我这个上下文所有吗?**
|
||||
2. **我自己写 SQL、知道表结构吗?**
|
||||
|
||||
| | 两问皆是 | 两问皆否 |
|
||||
|---|---|---|
|
||||
| 名称 | **自有持久化** | **防腐层**(ACL) |
|
||||
| 例子 | `SqlFeedbackRepository` | `RunStoreRunLookup` |
|
||||
| 文件 | `feedback_repository.py` | `run_lookup.py` |
|
||||
| 触达 | `feedback` 表(feedback 上下文自有) | `runs` 表(run 上下文所有) |
|
||||
| 手段 | 自己的 ORM 查询与短事务 | 调用 run 上下文已有的 `RunStore` |
|
||||
| 类名前缀 | `Sql` | 被包装者的名字(`RunStore`) |
|
||||
| docstring 首行标记 | `Secondary adapter (owned persistence)` | `Secondary adapter (anti-corruption layer)` |
|
||||
|
||||
**命名惯例**:文件名取端口名的 snake_case,性质与技术**不进文件名**——分别由类名前缀和 docstring 首行的固定标记承载。`Sql` 前缀读作"我自己写 SQL",`RunStore` / `ThreadStore` 这类前缀读作"我借道那个组件"。
|
||||
|
||||
想找出全部防腐层,grep 标记即可:
|
||||
|
||||
```bash
|
||||
grep -rl "anti-corruption layer" app/adapters/
|
||||
C->>R: GET /...(一页数据)
|
||||
R->>S: query(scope_id, keys, user_id)
|
||||
Note over R,S: 查询不 command 化——普通参数
|
||||
S->>AD: 批量读
|
||||
AD->>DB: 单次查询覆盖整页(防 N+1)
|
||||
DB-->>AD: rows
|
||||
AD-->>S: dict[key, Aggregate]
|
||||
Note over AD: _to_domain 逐行重建——<br/>坏行读取即爆(单行)或跳过并记日志(列表)
|
||||
S-->>R: 领域对象批量
|
||||
R-->>C: from_domain 白名单渲染,嵌入宿主响应
|
||||
Note over R: 越权数据表现为"查不到",不是权限错误
|
||||
```
|
||||
|
||||
**为什么不用 `sql_` / `acl_` 文件名前缀**(这个方案被评估过并否决):
|
||||
读侧三条规则在此汇齐:查询收普通参数(§3.1);批量方法按页返回、不设 N+1 形态的单条端点;所有者过滤是仓储参数而非异常。
|
||||
|
||||
1. 前缀是**实现属性**而非身份属性。文件的身份是"实现哪个端口";换存储时 `sql_` 就得改名,但端口没变、import 路径理应不动——技术选择泄漏到结构上,正是六边形要避免的。
|
||||
2. 冗余:`from app.adapters.feedback.sql_feedback_repository import SqlFeedbackRepository` 里 "sql" 出现两次。
|
||||
3. `acl` 在本仓库会被读成 access control list(`app/gateway/authz.py` 就在隔壁)。而且 ACL 不是一种技术,是一种**关系**——`RunStoreRunLookup` 里的 `RunStore` 已经把关系说清楚了。
|
||||
### 5.3 多驱动源
|
||||
|
||||
**前缀真正有价值的时机**:同一端口在生产代码里有多个实现并存。目前 `FeedbackRepository` 只有一个生产实现(内存版只存在于 `tests/feedback_fakes.py`)。到那天再引入 `sql_` / `memory_`,届时改名有正当理由。
|
||||
写链路的 driver 不只 HTTP:时钟入口(poller)与运行时回调入口(inbound adapter)走完全相同的 `Service → Ports → Adapters` 路径,只是 ① 的形态不同——时钟入口没有 wire 形状,直接构造调用;回调入口先过滤再翻译成领域 DTO(§3.2)。真实系统中三种驱动源共存的宏观图与派发闭环,见 [SCHEDULE_DESIGN_zh.md](SCHEDULE_DESIGN_zh.md)。
|
||||
|
||||
**防腐层不是欠债,是上下游关系的正常形态。** feedback 是下游(Customer),run 是上游(Supplier);上游还没有发布正式契约之前,下游为它写一层防腐层正是 DDD 给的标准答案。但要在代码里写清楚它何时会变——**TODO 应描述触发条件,而不是抱怨现状**:
|
||||
**本节的结论**:调用双向穿越边界(入口调进来、业务调出去、回调再调进来),依赖从不——六边形内的代码 import 不到链路右侧的任何东西,由 §4 的 AST 测试执法而非自觉。
|
||||
|
||||
`app/adapters/feedback/run_lookup.py` 里的实际写法(代码注释统一用英文,与仓库其余部分一致):
|
||||
## 6. 现状与待办
|
||||
|
||||
```python
|
||||
TODO(hexagonal): this depends on ``RunStore``, an infrastructure
|
||||
component, rather than on a contract published by the run context --
|
||||
that context has not been through a hexagonal slice yet. When it
|
||||
publishes one (a DTO, not its aggregate and not its repository),
|
||||
replace the body of this class. The ``RunLookup`` port does not move.
|
||||
```
|
||||
| 模块 | 状态 |
|
||||
|---|---|
|
||||
| **Feedback** | ✅ 参考实现(`domain/feedback/` + `app/adapters/feedback/`) |
|
||||
| **Scheduling** | ✅ 参考实现(`domain/schedule/` + `app/adapters/schedule/`);旧代码已停用待删 |
|
||||
| Run / ThreadMeta / RunEvent / Channel 等 | 旧模式,待迁移——阅读时勿以其为模板,新模块一律照 §2 |
|
||||
|
||||
最后一句是这层设计买到的东西:上游重构时,改动范围是一个文件,端口与领域一行不动。
|
||||
待办,按优先级:
|
||||
|
||||
## 5. 规则如何被守住
|
||||
| # | 待办 |
|
||||
|---|---|
|
||||
| 1 | 补防腐层对真实上游组件的契约测试(防腐层的门本身没被测过) |
|
||||
| 2 | 删除 schedule 旧代码(`app/scheduler/service.py`、`gateway/routers/scheduled_tasks.py`、旧 `sql.py` 仓储部分) |
|
||||
| 3 | feedback 依赖注入对齐 `Annotated[Service, Depends(...)]` 别名形态 |
|
||||
| 4 | schedule 写用例按 §3.1 command 化(含部分更新的 `Unset` sentinel) |
|
||||
|
||||
规则不自动执法就会退化,DeerFlow 用三层机械手段守住边界:
|
||||
## 7. 引用出处
|
||||
|
||||
| 手段 | 位置 | 守住什么 |
|
||||
|---|---|---|
|
||||
| AST 边界测试 | `tests/test_harness_boundary.py` | harness 永不 import `app.*` |
|
||||
| AST 纯度测试 | `tests/test_harness_domain_purity.py` | `domain/` 永不 import sqlalchemy / fastapi / pydantic / app / harness 基础设施模块——"内圈零依赖"的机器可验证形式 |
|
||||
| 契约测试 | `tests/test_feedback.py` | `FeedbackRepositoryContract` 一套用例,由 `TestSqlFeedbackRepository` 与 `TestInMemoryFeedbackRepository` 各跑一遍——port 语义与实现不漂移;这也是"全 fake 可运行"检验的常态化 |
|
||||
引用 AWS / Cockburn 须给页面级出处;JS 渲染页(`welcome.html` 等)不作引用锚点。
|
||||
|
||||
测试分层与架构分层一一对应,失败定位因此清晰:域测试红 = 业务规则错;service 测试红 = 编排错;契约测试红 = 存储实现错。具体模块的测试分层表见各切片文档(如 [FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md) §9)。
|
||||
|
||||
**契约套件必须断言返回值,不能只断言"端口被满足"。** 一个显式继承 `Protocol` 的适配器,拼错方法名不会抛 `AttributeError`——Protocol 的方法体是 `...`,真方法退化成"返回 `None` 的空实现",而 `isinstance(repo, SomePort)` 依然为真(继承使得方法名总是存在)。这不是理论风险,feedback 切片真实踩过,见 [FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md) §11.2。
|
||||
|
||||
## 6. 现状地图:哪些已迁移、哪些还没有
|
||||
|
||||
六边形是渐进迁移,当前状态:
|
||||
|
||||
| 模块 | 状态 | 代码位置 | 模块文档 |
|
||||
|---|---|---|---|
|
||||
| **Feedback** | ✅ 已迁移(首个垂直切片,后续模块的样板) | `domain/feedback/` + `app/adapters/feedback/` | [FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md) |
|
||||
| **Scheduling** | 🚧 内圈完成、外圈进行中(另一分支) | `domain/schedule/` + `app/adapters/schedule/` | [SCHEDULE_DESIGN_zh.md](SCHEDULE_DESIGN_zh.md) |
|
||||
| Run / ThreadMeta / RunEvent | 旧模式(有抽象基类 + 双实现,待领域化) | `runtime/*/store/`、`persistence/` | — |
|
||||
| Channel / 配置写路径等 | 旧模式(待迁移) | `persistence/*/sql.py`、`gateway/routers/` | — |
|
||||
|
||||
阅读旧模式代码时请注意:它们代表**迁移前的形态**(仓储返回裸 dict、领域规则散落、哨兵式用户解析),不要以它们为新代码的模板——新模块一律复制 Feedback 的形状(上述纯度测试会拦截倒退)。
|
||||
|
||||
**跨模块的收尾待办**,按优先级:
|
||||
|
||||
| # | 待办 | 为什么 |
|
||||
|---|---|---|
|
||||
| 1 | 补 `RunStoreRunLookup` 对真实 `RunStore` 的契约测试 | 唯一有实质风险的一项,见 [FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md) §11.1 |
|
||||
| 2 | 装配抽成纯函数 `app/composition.py::build_domain_services()` | 组合根现在与启动流程混在一个函数里,无法单测;"memory 后端 → 503"这条规则目前只有一行注释在守 |
|
||||
| 3 | 依赖注入改用 `Annotated[FeedbackService, Depends(get_feedback_service)]` 别名 | 测试可用 `dependency_overrides` 替换 service,不必再直接摸 `app.state` |
|
||||
| 4 | 端口 `thread_of` 是否收窄成 `belongs_to_thread(run_id, thread_id) -> bool` | 判断内聚进端口,并与 schedule 的 `ThreadLookup.exists_for_user` 对称(未决) |
|
||||
|
||||
第 3 项注入的仍是**应用级单例 service**,不是 per-request session——事务边界留在端口方法内部,路由不该知道 session 的存在。
|
||||
|
||||
## 7. 术语表
|
||||
|
||||
| 术语 | 含义 | DeerFlow 对应 |
|
||||
|---|---|---|
|
||||
| Domain(领域核心) | 纯业务规则,零基础设施依赖 | `domain/feedback/model.py` |
|
||||
| Port(端口) | 领域声明的技术中立接口,**位于 domain 包内** | `FeedbackRepository`、`RunLookup`(Protocol) |
|
||||
| Input port | 用例接口,被入口调用 | `FeedbackService.rate_run(...)` |
|
||||
| Output port | 领域对外依赖,被基础设施实现 | `FeedbackRepository.save(feedback)` |
|
||||
| Primary adapter(入口) | 外部协议 → 用例调用;AWS 称 `entrypoints/` | `gateway/routers/`、`channels/`、scheduler |
|
||||
| Secondary adapter · 自有持久化 | 实现 output port,自己拥有表并写 SQL | `SqlFeedbackRepository` |
|
||||
| Secondary adapter · 防腐层 | 实现 output port,把别的上下文的宽接口收窄 | `RunStoreRunLookup` |
|
||||
| 组合根 | 适配器唯一的实例化地 | `app/gateway/deps.py::langgraph_runtime`(计划抽出 `build_domain_services()`) |
|
||||
| 聚合 | 一致性边界,不变量构造期成立 | `Feedback` |
|
||||
| 契约测试 | 多实现共用一套语义用例 | `FeedbackRepositoryContract` |
|
||||
|
||||
## 8. 延伸阅读
|
||||
|
||||
- 想基于此架构做扩展(加字段、加用例、换存储、加入口)→ 二次开发指南;
|
||||
- 想了解写代码时的判定规则(什么放哪、什么禁止)→ `backend/AGENTS.md` 的分层规约章节;
|
||||
- AWS Prescriptive Guidance,《Building hexagonal architectures on AWS》——
|
||||
[Best practices](https://docs.aws.amazon.com/prescriptive-guidance/latest/hexagonal-architectures/best-practices.html)
|
||||
给出本文 §2 采用的三文件夹结构,
|
||||
[Structure a Python project in hexagonal architecture using AWS Lambda](https://docs.aws.amazon.com/prescriptive-guidance/latest/patterns/structure-a-python-project-in-hexagonal-architecture-using-aws-lambda.html)
|
||||
是同一结构的 Python 落地示例;
|
||||
- Cockburn 的 Ports & Adapters 原文(2005),"六边形"这个名字的出处。
|
||||
- **[C]** Cockburn, *Hexagonal Architecture*(2005)——Intent 原句与 driver 类别清单:
|
||||
<https://alistair.cockburn.us/hexagonal-architecture/>
|
||||
- **[G]** AWS Prescriptive Guidance, *Building hexagonal architectures on AWS* — Introduction:
|
||||
<https://docs.aws.amazon.com/prescriptive-guidance/latest/hexagonal-architectures/introduction.html>
|
||||
- **[B]** 同指南 *Best practices*——三文件夹结构与 domain 七件套定义:
|
||||
<https://docs.aws.amazon.com/prescriptive-guidance/latest/hexagonal-architectures/best-practices.html>
|
||||
- **[P]** *Structure a Python project in hexagonal architecture using AWS Lambda*——同一结构的 Python 落地示例:
|
||||
<https://docs.aws.amazon.com/prescriptive-guidance/latest/patterns/structure-a-python-project-in-hexagonal-architecture-using-aws-lambda.html>
|
||||
|
||||
@ -7,7 +7,7 @@ This directory contains detailed documentation for the DeerFlow backend.
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [ARCHITECTURE.md](ARCHITECTURE.md) | System architecture overview |
|
||||
| [HEXAGONAL_ARCHITECTURE_zh.md](HEXAGONAL_ARCHITECTURE_zh.md) | 六边形(Ports & Adapters)分层总纲:规则、AWS 官方结构对应、两类从适配器、边界如何被测试守住 |
|
||||
| [HEXAGONAL_ARCHITECTURE_zh.md](HEXAGONAL_ARCHITECTURE_zh.md) | 六边形(Ports & Adapters)分层规范:标准结构(AWS 三文件夹 + domain 七件套)、Commands/Events 设计、规则清单与执法、调用关系 |
|
||||
| [FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md) | 用户反馈模块设计:首个完成的六边形切片,聚合/端口/适配器逐层走读与二次开发指引 |
|
||||
| [API.md](API.md) | Complete API reference |
|
||||
| [AUTH_DESIGN.md](AUTH_DESIGN.md) | User authentication, CSRF, platform-trust (IM / Internal Auth), and per-user isolation |
|
||||
|
||||
BIN
backend/docs/assets/hexagonal_architecture.png
Normal file
BIN
backend/docs/assets/hexagonal_architecture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
backend/docs/assets/hexagonal_dispatch_relation.png
Normal file
BIN
backend/docs/assets/hexagonal_dispatch_relation.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 987 KiB |
Loading…
x
Reference in New Issue
Block a user