15-模块二-架构基础功 第15讲-模块实战 - CodeSentinel 核心服务的 Clean Architecture 实现

0 阅读19分钟

模块二-架构基础功 | 第15讲:模块实战 - CodeSentinel 核心服务的 Clean Architecture 实现

开场:把分层从“目录结构”变成“可部署的系统”

当你能在白板上画出分层,却在仓库里找不到对应边界时,架构还停留在讨论阶段。模块二一路讲依赖倒置、一致性、规模估算与重构治理,到本讲要收束为 可运行的交付物:一个完整的 ReviewService 切片,严格按 Clean Architecture + DDD 组织代码,使用 FastAPI 暴露 HTTP,使用 PostgreSQL 持久化权威状态,使用 Redis Streams 发布领域事件(教学替代 Kafka/Outbox),并用 pytest领域单测testcontainers 集成测试(Docker 可用时)。

你会看到四个层次如何各司其职:领域层只谈业务规则与不变式;应用层编排用例并依赖端口协议;基础设施层实现 ORM 与消息;展示层只做 HTTP 映射与错误翻译。依赖注入采用 纯 Python 手动装配(不用框架),让依赖方向一眼可读——这也是架构评审时最容易被追问的点:“你们到底在哪里 new 了具体实现?”

本讲配套仓库目录为 codesentinel-clean-lab/(与本文同级的实验工程)。当你运行 docker compose up --build 后,可打开 http://127.0.0.1:8000/docs 查看自动生成的 Swagger,并手动走通 创建审核 → 启动 → 添加 finding → 完成 → 查询 的链路。模块三开始我们会把 LLM 与 LangChain 以 端口适配器 形式接进来;本讲先把 非 AI 核心路径 做稳,这是平台长期可维护的地基。

把本讲当作 模块二的毕业典礼:你已经能解释 CAP、能画十万 PR 的拓扑、能识别坏味道,现在必须证明你能把这些原则 编译成仓库。评审时最硬的证据不是 PPT,而是 pytest 绿、docker compose 起、Swagger 能点、领域测试不依赖数据库。若你带领团队落地 CodeSentinel,请把本实验当作 脚手架模板:以后新增能力(规则引擎、计费、报表)都先问“它属于哪一层”,再问“端口长什么样”,最后才写实现。这样 AI 辅助生成代码时,提示词里可以明确禁止跨层 import,从流程上降低返工率。


全局视角:四层架构与依赖方向

1. 组件架构图(逻辑视图)

flowchart TB
  subgraph Presentation["展示层 presentation"]
    R["FastAPI Router<br/>api.py"]
    E["HTTP 错误映射<br/>errors.py"]
  end

  subgraph Application["应用层 application"]
    UC["用例<br/>Create/Get/Start/AddFinding/Complete"]
    PT["端口 Protocol<br/>ReviewRepository / EventPublisher"]
  end

  subgraph Domain["领域层 domain"]
    AG["Review 聚合根"]
    EV["领域事件"]
  end

  subgraph Infra["基础设施层 infrastructure"]
    REP["SqlAlchemyReviewRepository"]
    PUB["RedisStreamEventPublisher"]
    ORM["ORM 模型 ReviewModel"]
    DB["db.py engine/session"]
  end

  R --> UC
  UC --> PT
  UC --> AG
  AG --> EV
  REP -.->|implements| PT
  PUB -.->|implements| PT
  REP --> ORM
  REP --> AG
  PUB --> EV

2. 请求处理时序(创建并完成一次审核)

sequenceDiagram
  participant C as Client
  participant F as FastAPI
  participant UC as CreateReviewUseCase
  participant AR as Review(聚合)
  participant RP as SqlAlchemyReviewRepository
  participant PG as PostgreSQL
  participant PB as RedisStreamEventPublisher
  participant RD as Redis

  C->>F: POST /reviews
  F->>UC: execute(CreateReviewCommand)
  UC->>AR: Review.create(...)
  UC->>RP: save(review)
  RP->>PG: INSERT reviews
  UC->>AR: pull_events()
  UC->>PB: publish(events)
  PB->>RD: XADD domain_events
  F-->>C: 201 review_id

3. 编译期依赖图(由外向内单向)

flowchart LR
  presentation --> application
  application --> domain
  infrastructure --> application
  infrastructure --> domain

规则domain 不依赖任何外层;application 只依赖 domain 与自身 Protocolinfrastructure 实现 application.portspresentation 调用用例并捕获领域异常映射为 HTTP 状态码。

再看一眼依赖箭头的“可维护性含义”:当产品经理说“我们要改审核状态机”,你应能 confidently 打开 domain/review.py;当运维说“数据库要换云厂商”,你应能主要修改 infrastructure 而不碰领域;当老板说“我们要加一个企业微信通知”,你应能新增 NotificationPublisher 端口并在用例里编排,而不是把 webhook 写进聚合根。依赖方向不是教条,而是 把变化频率不同的东西分开,让高频变更(UI、集成)不去扰动低频变更(业务规则)。


核心原理:各层职责与手动依赖注入

1. 领域层:Review 聚合与领域事件

聚合根 Review 负责状态机:pending → in_progress → completed,并提供 fail 分支。所有迁移通过方法触发,非法迁移抛 InvalidTransitionErrorpull_events() 收集 已发生的事实ReviewCreatedReviewStarted 等),供应用层在成功持久化后发布。

为对接 ORM,引入 Review.rehydrate(...)从数据库还原 时不应重复产生创建事件,这与 create() 区分。

2. 应用层:用例与端口

CreateReviewUseCaseGetReviewUseCaseStartReviewUseCaseAddFindingUseCaseCompleteReviewUseCase 只做编排:调用仓储、驱动聚合、拉取事件并发布。依赖的 ReviewRepositoryEventPublisherProtocol 定义,保证 可替换可单测

3. 基础设施层:SQLAlchemy + Redis

ReviewModel 映射表 reviewsfindings 以 JSON 列存储(教学简化)。SqlAlchemyReviewRepositorysave 时 upsert 行并 commitRedisStreamEventPublisher 将事件 JSON 序列化后 XADDcodesentinel:domain_events

生产建议:用 事务性 Outbox 保证“库与消息”一致性;本讲为控制复杂度采用 先 commit 再 publish 的教学路径,并在第12讲讨论风险。

4. 展示层:FastAPI 路由与错误翻译

路由只构造 Command/Query 对象并调用用例;捕获 KeyError → 404,InvalidTransitionError → 409,ValueError → 400。避免在路由里写业务判断。

5. 手动依赖注入:app/main.pycreate_app

create_app() 中依次 make_engineinit_dbmake_session_factory → 构造 repositorypublisher → 构造各 UseCasebuild_router(...)。没有魔法容器,阅读顺序即装配顺序,利于新成员 onboarding。

6. OpenAPI / Swagger

FastAPI 自动生成交互式文档;对外契约以 Pydantic BaseModel 约束字段长度与范围,减少无效请求打到领域层。

7. 测试策略

  • 领域测试tests/test_domain.py):无 IO,毫秒级,验证状态机与事件收集。
  • 集成测试tests/test_integration.py):testcontainers 启动 Postgres+Redis,验证 HTTP 与用例全链路;若本机无 Docker,pytest 会自动 skip(通过 pytestmark 检测 docker.ping())。

补充说明:领域测试 应覆盖所有非法迁移与边界值(消息长度、严重级别枚举),这部分测试是重构时的 安全带;集成测试则验证 端口实现是否正确(SQL 写入、Redis XADD)。两者分工明确,才能避免“全集成测试又慢又不稳定”的陷阱。建议在 CI 里分 job:快速 job 只跑领域与静态检查,慢 job 跑 testcontainers(夜间或合并前)。

8. 与 CodeSentinel 全产品的关系

本实验省略了 LangChain、向量索引与队列 Worker,但它们都应作为 适配器 出现在基础设施层,并通过端口注入应用服务。领域层不应知道“embedding 用哪家模型”。

9. 常见反模式(评审清单)

  • ReviewModel 里写业务规则;
  • 用例直接操作 SQL;
  • 领域事件在聚合外部被“补造”;
  • FastAPI 依赖项里隐藏全局单例导致测试困难。

10. 演进路线(模块三预告)

下一步把 RuleEvaluatorPortLlmReviewPort 加回应用层,并在 Worker 中消费 Redis Stream 做异步重计算;领域事件可触发索引刷新(最终一致)。

11. 事务边界

每个用例当前对应一次 save() 中的 commit。更复杂场景可引入 Unit of Work 模式,将多仓储同学务包裹。

12. 多租户扩展点

表结构可增加 tenant_id 并在路由从 JWT 注入;本讲为聚焦分层省略。

13. API 版本化

可在路由前缀加 /v1;Pydantic 模型独立版本,避免破坏旧客户端。

14. 安全

生产应加认证中间件、审计日志与速率限制;本讲 healthz 仅用于编排探活。

15. 可观测性

建议在 publishsave 周围加结构化日志与 metrics(本讲代码保持精简)。

16. 为什么不用 DI 框架

教学项目手动装配足够;团队若扩大,可逐步引入轻量容器,但 不要以牺牲显式依赖图为代价

17. 与 DDD 聚合边界的对齐

Review 为聚合根,Finding 为聚合内实体;对外只通过聚合根修改。

18. 失败与补偿

fail() 路径可用于外部 Git 不可达等业务失败;报告上下文可订阅 ReviewFailed 事件。

19. 代码生成治理

要求 AI 生成代码时必须遵守包结构与 import 方向,否则用架构测试(import-linter)拦截。

20. 小结

本讲把 架构原则 落实为 文件路径 + 构造顺序 + 测试。能跑通 compose 与 pytest,即模块二毕业最低标准。

21. build_router 为何传入用例实例而不是在路由里 Depends 注入全局?

教学项目里,我们把用例作为参数传入 build_router,是为了 显式展示装配关系,并避免 FastAPI Depends 与全局状态混用导致测试困难。生产中可以封装 get_use_cases(),但务必保证每个请求拿到的是 线程安全 的仓储与会话作用域;SQLAlchemy Session 通常应 每请求一会话,本实验为简化在仓储内部创建短生命周期会话,下一步演进可改为 Depends(get_session)

22. JSON 列存 findings 的取舍

教学实现用 JSON 列表减少表数量,便于快速跑通;当 findings 需要独立查询、索引与权限控制时,应拆 findings 表并建立外键。架构评审要问:查询模式 而不是 ORM 方便。CodeSentinel 若要对 finding 做全文检索,迟早要外置索引或拆表。

23. 领域异常 vs HTTP 异常

领域层抛 ValueError/InvalidTransitionError 是合理的;映射到 HTTP 是展示层职责。不要把 HTTPException 引入领域层,否则 Clean Architecture 依赖方向被破坏,复用领域逻辑到 CLI/Worker 时会被迫依赖 Web 框架。

24. 事件序列化与版本

RedisStreamEventPublisher 以 JSON 写入;生产应为事件加 schema_version 字段,并为消费方提供 向后兼容 策略(忽略未知字段、双写过渡期)。

25. 幂等与重复提交

POST /reviews 若客户端重试,可能创建多条审核记录;若业务需要幂等,应在网关或应用层引入 Idempotency-Key(第12讲已铺垫)。本讲聚焦分层,不把幂等键塞进聚合,以免冲淡教学目标。

26. 与模块二第12、13讲的连接

第12讲强调异步索引与权威读路径;本讲 ReviewCompleted 事件写入 Redis Stream,可作为 IndexWorker 的输入。第13讲强调队列削峰;下一步可把 POST /reviews 从“同步落库”扩展为“落库 + 投递任务队列”,API 更快返回。

27. 团队分工:谁写哪一层?

建议:领域 + 应用 由熟悉业务的工程师主导;基础设施适配器 可由平台同学提供模板;展示层 由熟悉 Web 的同学维护。AI 生成代码时最容易把三层揉进路由,本讲工程就是反例教材。

28. 代码审查要点(Checklist)

导入方向是否正确?用例是否只做编排?聚合是否唯一入口?事件是否在聚合内创建?仓储是否泄露 SQL 给应用层?路由是否翻译异常?若任一项为否,应打回。

29. 性能与 N+1

当前读写路径简单,无 N+1;当 findings 拆表后,要用 selectinload 或分页查询避免放大。性能优化应 测量后 进行,而不是预先污染领域模型。

30. 模块二学习成果自测

你能不看讲义画出依赖图吗?能解释 rehydrate 吗?能说明为何 publisher 在基础设施层吗?能口述把 LLM 接进来的端口位置吗?若都能,模块二目标达成。


代码实战:实验工程结构与关键文件说明

以下路径均相对于课程目录下的 codesentinel-clean-lab/

codesentinel-clean-lab/
├── docker-compose.yml
├── Dockerfile
├── requirements.txt
├── README.md
├── app/
│   ├── main.py                 # FastAPI 工厂 + 手动 DI
│   ├── domain/
│   │   ├── review.py           # 聚合根 + 状态机
│   │   └── events.py           # 领域事件
│   ├── application/
│   │   ├── ports.py            # Protocol 端口
│   │   └── use_cases.py        # 用例实现
│   ├── infrastructure/
│   │   ├── db.py               # Engine / Session 工厂
│   │   ├── orm.py              # SQLAlchemy 模型
│   │   ├── repositories.py     # 仓储实现
│   │   └── redis_events.py     # Redis Stream 发布
│   └── presentation/
│       ├── api.py              # 路由组装函数 build_router
│       └── errors.py           # HTTPException 工厂
└── tests/
    ├── test_domain.py          # 领域单测(无需 Docker)
    └── test_integration.py     # testcontainers 集成测试

运行与验证

cd codesentinel-clean-lab
docker compose up --build

另开终端:

curl -s -X POST http://127.0.0.1:8000/reviews -H "Content-Type: application/json" \
  -d "{\"repo\":\"demo/app\",\"pr_number\":1}"
# 记下 review_id 后继续:
curl -s -X POST http://127.0.0.1:8000/reviews/<review_id>/start -i
curl -s -X POST http://127.0.0.1:8000/reviews/<review_id>/findings \
  -H "Content-Type: application/json" \
  -d "{\"severity\":\"high\",\"message\":\"示例问题\"}"
curl -s -X POST http://127.0.0.1:8000/reviews/<review_id>/complete -i
curl -s http://127.0.0.1:8000/reviews/<review_id>

核心代码阅读顺序建议

  1. app/domain/review.py:状态机与 pull_events
  2. app/application/use_cases.py:每个用例的三步走(取聚合 → 操作 → 保存 → 发事件)。
  3. app/infrastructure/repositories.py:ORM 与聚合互转。
  4. app/presentation/api.py:HTTP 与用例的边界。
  5. app/main.py:依赖如何被 new 出来。

pytest

pip install -r requirements.txt
pytest -q

无 Docker 时:3 passed, 2 skipped 为预期;有 Docker 时应 5 passed

逐文件讲解(建议对照 IDE 阅读)

  • app/domain/events.py:事件是事实,字段尽量不可变;后续可统一基类做 event_type 路由。
  • app/infrastructure/redis_events.py_event_to_dict 用类名做类型标签,教学足够;生产可改为显式 event_type 字符串,避免类重命名破坏消费方。
  • app/infrastructure/db.pypool_pre_ping 避免断连;K8s 环境下还要合理设置 pool_size
  • Dockerfile:单阶段镜像简单直接;生产改用多阶段减小攻击面与体积。
  • docker-compose.yml:Postgres healthcheck 避免 API 过早启动导致连接失败。

常见启动失败排查

若 API 连接数据库失败,优先看 DATABASE_URL 是否在容器网络内指向 db 服务主机名,而不是 127.0.0.1。若在宿主机运行 uvicorn 而数据库在 compose 内,应使用映射端口与 localhost

与 Swagger 联调的心理模型

Swagger 是 契约文档 也是 冒烟工具;正式环境应对外收敛暴露面,或加认证后开放。开发阶段用它验证状态码映射是否与设计一致(404/409/400)。

手把手走读:一次 POST 请求如何在层间穿梭(叙事版)

假设客户端发起 POST /reviews,FastAPI 先把 JSON 校验为 CreateReviewBody,这一步属于 展示层的输入清洗,它不应包含业务规则,只做 形状与范围 约束。随后路由调用 CreateReviewUseCase.execute,应用层创建 Review.create:这里发生的是 领域事实 的诞生——新的 review_id、初始 pending 状态、以及 ReviewCreated 事件进入聚合内部队列。接着 repository.save 把聚合映射为 ReviewModel 行写入 PostgreSQL:注意 ORM 模型不知道状态机,它只存当前快照;状态机的合法性已经在聚合方法里保证过。save 成功后,用例调用 pull_events 拿到事件列表并交给 publisher.publish:从这一刻起,其他上下文(报表、索引、通知)可以订阅流并各自处理,形成 解耦扩展点。整个链路里,应用层没有 if request.json 之类的 Web 细节,领域层没有 redis.xadd 之类的 IO 细节——这就是 Clean Architecture 在代码阅读时的“舒适感”来源。若你在走读时看到用例里直接拼 SQL,几乎可以断定分层已经破功。

再走读 POST /reviews/{id}/complete:应用层必须先 get 聚合,再调用 complete(),再 save,再 publish。这个顺序保证 只有成功持久化的状态迁移才会对外发布事件(在教学实现里仍要注意进程崩溃窗口,生产用 Outbox 收紧)。若有人提议“先 publish 再 save 让下游更快”,你要用一致性语言解释:下游会看到过期的完成信号,进而错误更新索引或通知用户 假完成。这类争论在架构评审里非常常见,本实验代码就是讨论锚点。

手动 DI 与可测试性:为什么要避免在模块顶层创建全局单例

create_app() 里集中 new 的好处是:集成测试可以构造 假的 EventPublisher内存数据库 并传入 build_router,而不需要 hack 全局变量。全局单例看似省事,但会让并行测试互相污染,也会让“局部替换依赖”变得不可能。对 CodeSentinel 这种长期项目,可测试的装配 比省两行代码重要得多。

从本实验迁移到单体模块化单体(modular monolith)

若团队暂不拆分微服务,本结构依然成立:未来把 infrastructure 包替换为独立部署的客户端即可。关键是 边界清晰,而不是 部署形态。很多团队误以为“单体就可以随便跨层调用”,结果在单体内复制了微服务的一切缺点却没有隔离收益。

代码量与架构:教学项目刻意短小,但边界不打折

你可能会觉得 JSON findings、Redis Stream 很“玩具”,这是有意为之:把噪声降到最低,让你只看到 依赖方向。当你加 LLM、加向量库、加队列时,代码量会膨胀,但只要坚持 新增适配器而不是新增上帝类,膨胀是线性的而不是指数级的。


生产环境实战:从实验到真平台的差距清单

  1. Outbox / CDC:替换“先库后发”的丢消息风险。

  2. 鉴权与多租户:JWT、mTLS、配额。

  3. 迁移工具:引入 Alembic 管理 schema 版本。

  4. 秘密管理:数据库与 Redis 密码来自密钥管理系统。

  5. 水平扩展:API 无状态;会话不存内存;连接池上限与 pgbouncer。

  6. 事件演进:事件 schema 版本号与兼容消费方。

  7. 灰度发布:按租户或仓库维度金丝雀。

  8. 备份恢复:PITR + 定期演练。

  9. SLO 与告警:API 延迟、错误率、队列深度。

  10. 供应链:镜像扫描、依赖漏洞门禁。

  11. 配置分层:开发/预发/生产配置分离,禁止把密钥写进镜像层。

  12. 零停机迁移:数据库变更与代码发布协调,必要时双写。

  13. 审计:谁创建了哪条审核、谁完成了审核,写入只追加审计表。

  14. 限流防刷:公开 API 必须配网关限流与 WAF。

  15. 容灾:多可用区部署与 Redis/DB 主从切换 Runbook。

  16. 成本:Redis Stream 无限增长需修剪或归档策略。

  17. 合规:代码片段是否出境、模型调用日志保留周期。

  18. 客户隔离:Row-Level Security 或分库分表策略预研。

  19. 内部开发者平台:模板仓库一键生成符合分层的微服务骨架。

  20. 持续架构评审:每季度对照 ADR 检查是否偏离依赖规则。


本讲小结:模块二收官思维导图

mindmap
  root((模块二实战))
    Domain
      Review聚合
      状态机
      领域事件
    Application
      用例编排
      Protocol端口
    Infrastructure
      SQLAlchemy
      Redis Stream
    Presentation
      FastAPI
      错误映射
    质量
      领域单测
      testcontainers
    运行
      docker compose
      Swagger

延伸阅读:从实验仓库到你的业务仓库

建议你 fork codesentinel-clean-lab 后做三次小改动以巩固:改动 A 增加 GET /reviews?repo= 查询列表(练习仓储协议扩展);改动 BReviewFailed 增加 HTTP 路径(练习事件与 API 对齐);改动 CRedisStreamEventPublisher 替换为内存 ListPublisher 用于快速测试(练习端口替换)。三次改动都应保持 domain 目录无 FastAPI/SQLAlchemy import。完成后再让 AI 生成一版“合并式大文件实现”,用 import-linter 或简单脚本扫描违规 import,体验 架构门禁 的价值。


思考题

  1. 若把 RedisStreamEventPublisher.publish 挪到 session.commit() 之前,可能出现什么一致性问题?如何改进?

  2. Review.rehydrateReview.create 的分工如何避免“重复创建事件”?

  3. 如果下一步要接入 LangChain,你会新增哪些端口与适配器,领域层需要改吗?

  4. SqlAlchemyReviewRepository 每次 save 新建会话的代价是什么?在高 QPS 下你会如何调整会话边界?

  5. 如果把 findings 从 JSON 列拆成独立表,聚合根 Review 的加载策略应如何设计以避免性能退化?


下一讲预告(模块三建议路径)

进入 AI 编排与工具链治理:把 LLM 调用封进 LlmReviewPort、把提示词与版本治理外置,引入 LangChain Runnable观测(token、延迟、成本),并让 CodeSentinel 的 Worker 在队列上 安全、幂等、可限流 地消费审核任务——那将是“智能”真正接入平台内核的一讲。你会看到:本讲的 ReviewCompleted 事件如何自然衔接索引刷新;本讲的端口如何扩展出 ToolCalling 而不污染领域;以及如何把第13讲的队列 Worker 与本讲的用例编排 拼成一条端到端链路。模块二的“基础功”到此告一段落,接下来是让平台 聪明且可控


附录:模块二学习路线回顾(与 CodeSentinel 贯穿项目对齐)

把模块二想象为 同一条故事线的四个镜头:第12讲解决分布式下的 真相与视图;第13讲解决规模化下的 吞吐与成本;第14讲解决 AI 产码下的 结构与可读性;第15讲解决工程落地时的 分层与依赖。四讲合起来回答一个问题:CodeSentinel 作为平台,是否能在人、机、数据、合规四方约束下持续演进。若你在复盘时发现团队只做了第13讲的扩容却忽略第12讲一致性,你会在索引与详情之间反复踩坑;若只做了第15讲分层却忽略第14讲重构文化,你会很快在聚合根外又长出一层“脚本式服务”。建议把四讲的思维导图打印在团队看板旁,当作 架构宪法的速查表

附录 B:推荐的课后作业(任选一题深入)

作业 1:为 reviews 增加 updated_atversion 字段,实现乐观锁,防止并发完成审核覆盖写入。作业 2:实现 InMemoryReviewRepositoryListEventPublisher,让 tests/ 在无任何外部依赖下跑通 API 层测试。作业 3:写一个简单的 import-linter 或脚本,校验 app/domain 不 import fastapisqlalchemyredis。完成作业后,把结论写成一页 ADR,记录你遇到的权衡点。这样模块二不仅“听过”,而且“做过”,后续接入 LangChain 时你会明显更自信。

附录 C:与团队 Code Review 的对接话术

当你要拒绝一个跨层 PR 时,可以用三句话:第一,这条规则属于领域不变式还是集成细节?第二,如果明天我们换成 gRPC,这段代码要改多少?第三,能否用领域单测覆盖?如果答不上来,就应要求作者重构后再合并。话术的目的不是压人,而是把讨论从情绪拉回到 边界与可验证性。CodeSentinel 作为审核产品,更应先在内部实践高质量评审文化,否则对外售卖“治理”很难令人信服。

附录 D:交付检查表(建议你打印)

分层目录是否成立?领域是否零外部框架依赖?用例是否只依赖端口?仓储是否可替换?事件是否由聚合创建?HTTP 错误是否只在展示层?docker compose up 是否可启动?pytest 是否至少绿三单测?Swagger 是否可完成主链路?若任一项为否,先补功课再进入模块三,否则 AI 工具链会把技术债放大到难以收拾。把检查表当作门禁,而不是形式主义,才能真正从模块二毕业,进入更高噪声的智能化阶段。