架构与整洁代码(四):架构与编码 Code Review Checklist
引言
软件工程里有一句常被引用的话:好的代码是重构出来的,不是一次写出来的。它提醒我们:初稿几乎必然欠打磨,真正可靠的质量来自持续、有纪律的迭代。Code Review 正是这种迭代中最关键的一环——它把个人习惯拉平到团队标准,把隐性知识显性化,把缺陷拦截在合并之前。
然而,「随便看看」式的评审往往流于表面:有人只看风格,有人只看有没有明显 bug,有人被 diff 的噪声淹没。结果是:架构层面的失误晚到无法廉价修正,设计层面的模糊在代码里被放大成技术债,上线前才发现性能或可观测性缺口。要对抗这种随机性,需要分阶段、可重复的 Checklist:在正确的时机问正确的问题。
本文提供一套按评审阶段组织的清单,建议你按顺序走完四个阶段,而不是在单次 PR 里眉毛胡子一把抓:
- 架构评审:新项目或新模块启动时,先确认分层、边界、读写路径与技术选型是否站得住脚。
- 设计评审:详设与接口冻结阶段,检查聚合、命令查询、事件与模式选型是否与领域一致。
- 代码评审:日常 PR,用 SOLID、函数质量、命名、错误处理与依赖方向守住实现细节。
- 上线前检查:合并发布窗口,补齐性能、并发、可观测性、测试、回滚与文档。
四篇文章如何配合使用
专题入口:侧边栏「架构与整洁代码」或标签聚合页 /tags/architecture-and-clean-code/ 可集中浏览本系列四篇。
本仓库里与「架构 + 编码」相关的文章可以形成一条学习与实践链路:
| 文章 | 角色 |
|---|---|
| 架构与整洁代码(一):Clean Architecture、DDD 与 CQRS——三位一体的架构方法论(41) | 怎么定架构:分层、依赖方向、BC、聚合、CQRS、事件与反模式 |
| 架构与整洁代码(二):复杂业务中的 Clean Code 实践指南(42) | 怎么写:函数、Pipeline、策略、规则引擎等战术 |
| 架构与整洁代码(三):领域驱动设计读书笔记——从概念到架构实践(43) | 怎么建模:战略 / 战术 DDD、通用语言;可与(一)对照阅读 |
| 本文(44) | 查什么:各阶段 Review 要问什么、反例长什么样 |
建议顺序:41 建立地图 → 42 练实现手法 → 43 把领域语言与模型讲透(可与 41 穿插)→ 44 在评审与上线前逐项打勾。四篇互为索引,而不是重复堆砌。
从心理学角度,Checklist 的价值在于降低认知负荷:评审者在疲劳、时间压力或上下文切换时,仍有一个外部脚手架防止遗漏。它并不替代经验与判断力——遇到清单未覆盖的灰区,恰恰说明团队应该把新教训反哺进清单或 ADR。实践中建议:
- 责任人明确:架构项由 Tech Lead / 架构负责人主评;设计项由领域 Owner 主评;PR 项由作者与至少一名熟悉该域的审阅者共担;上线前项可与 SRE / On-call 对齐。
- 粒度分层:巨型 MR 可先要求作者附「自审清单」勾选说明,再在评论里对争议点逐条引用本文章节编号,避免无结构的「感觉不对」。
- 与工具链结合:复杂度、静态检查、依赖图、覆盖率门槛应作为门禁,清单作为人工语义层补充(例如:覆盖率够了但测的是 happy path,仍需人眼过业务不变量)。
四阶段评审流程(Mermaid)
下面是一张简化的流程图,表示从设计期到合并期的顺序关系(实际项目可在各阶段间迭代,但问题域应分开讨论,避免在代码 diff 里硬掰架构决策)。
flowchart LR A[架构评审
设计期] --> B[设计评审
详设期] B --> C[代码评审
PR 期] C --> D[上线前检查
合并期] D --> E[发布 / 观测 / 复盘]
使用建议:
- 架构、设计阶段的结论最好有可追溯记录(ADR、RFC 或设计文档),Code Review 时只核对「实现是否背离结论」。
- PR 评论里若发现架构级问题,应上升到设计讨论,而不是在局部 hack 里「修掉症状」。
- Checklist 是最小充分集的启发工具,团队可按域(支付、搜索、实时链路)扩展专属条目,但不要删掉「依赖方向」「聚合边界」这类高杠杆项。
与「好的代码是重构出来的」的关系:清单并不是鼓吹「一次设计完美」,而是规定在哪些关口必须重构:当架构评审发现分层倒置,应允许推翻局部实现;当代码评审发现函数失控,应要求拆分而不是堆注释。重构被嵌入流程,而不是留到「有空再说」。
一、架构评审阶段 — 设计期
适用时机:立项、新服务、新子域或大规模模块拆分。目标是在写大量代码之前,把分层、边界、一致性、读写特征与技术选型对齐。
1. 分层结构
标准:是否明确定义 Domain / Application / Adapter / Infrastructure(或等价四层)?源代码依赖是否一律指向内层(Domain 为最内),外层通过接口向内依赖?
反例(违反依赖方向):HTTP Handler 直接 import 具体 MySQL 驱动或 ORM 包,绕过应用服务与领域端口。
1 | // BAD: handler depends on concrete DB package |
合规方向:Handler 只依赖应用层用例;持久化通过 Repository 接口在领域或应用边界声明,由 Infra 实现。
1 | // GOOD: handler -> application port -> domain; infra implements port |
评审追问:若团队暂时未引入完整四层,是否至少在包级约定 adapter 不得被 domain import,并在 CI 用 grep / 自定义 linter 守护?
参考:架构与整洁代码(一) §1(分层与依赖规则)。
2. Bounded Context 划分
标准:是否识别 核心域、支撑域、通用域?每个 BC 是否有清晰的 Ubiquitous Language 与对外契约(API / 事件),避免「一个大而全的领域模型」?
反例:订单子域与库存子域共用同一个 Product 结构体,字段含义在两边互相拉扯(价格、可售库存、展示属性混在同一类型上)。
1 | // BAD: one struct serves two contexts with conflicting meanings |
合规 sketch:不同 BC 使用不同模型与防腐层翻译;集成通过 API、消息或显式 ACL。
1 | // GOOD: separate models + explicit mapping at boundary |
评审追问:若两个 BC 必须共享标识符,是共享 ID 还是共享 富模型?前者常见且可接受,后者往往是边界溃缩的信号。
参考:41-架构方法论 §2.1(限界上下文)。
3. 聚合边界
标准:一致性边界是否以聚合为单位设计?是否避免在单个事务中强行修改多个聚合根,除非有显式的领域规则与补偿策略?
反例:一个数据库事务内同时更新 Order 与 Inventory 聚合,绕过领域事件与最终一致性,导致锁竞争与模型腐化。
1 | // BAD: one transaction mutates two aggregates directly |
参考:41-架构方法论 §2.5(聚合)。
4. 读写路径评估
标准:是否量化 读写比、延迟与一致性要求?读路径若存在重 JOIN、宽表、复杂筛选,是否考虑 独立读模型 / 投影,而不是全部堆在写模型上?
反例:在命令路径(下单)同步执行多表 JOIN 报表查询,拖慢写入尾延迟。
合规 sketch:写路径只持久化命令所需最小一致性数据;读路径走物化视图、搜索索引或专用查询服务。
1 | // BAD: command handler does heavy read for side UI |
评审追问:是否测量过 p99 写延迟 与 读 QPS?若读是写的两个数量级以上,独立读模型往往是经济解。
参考:41-架构方法论 §3(CQRS 与读写分离)。
5. 技术选型
标准:存储与中间件是否与 访问模式 匹配(点查、范围扫、全文检索、图关系、流处理)?是否记录选型假设与回退方案?
反例:全文搜索需求用 MySQL LIKE '%keyword%' 扛流量,缺少倒排索引与相关性能力。
评审追问:选型表是否包含 数据量预估、热点键、一致性级别、运维成本?是否评估过 多租户、合规留存、跨地域 对存储的影响?
合规:为每种访问模式写清「主存储 + 缓存 + 索引」的职责划分,避免所有读都打到 OLTP。
6. 过度设计检查(YAGNI)
标准:是否仅为已确认的变更点引入抽象?能否用更简单的模型先交付,再演化?
反例:典型 CRUD 后台强行上 DDD + CQRS + Event Sourcing 全家桶,团队无力维护投影与版本化事件。
评审追问:若去掉 Event Sourcing,业务是否仍成立?若答案是肯定的,则 ES 很可能是 可选优化 而非当前必需。同理,CQRS 是否由观测到的读写不对称驱动,而不是由「流行架构标签」驱动?
合规:从 Transaction Script + 清晰模块边界 起步,在出现明确痛点时再引入战术模式;每引入一层,同步引入 测试与运维 能力。
参考:41-架构方法论 §5.3(反模式与 YAGNI)。
二、设计评审阶段 — 详设期
适用时机:接口评审、领域模型评审、用例与事件清单冻结前。目标是让 战术设计(聚合、Repo、Command/Query、事件)与战略分层一致。
1. 聚合根识别
标准:聚合根是否是外部访问聚合内对象的唯一入口?外部代码是否禁止绕过根直接改内部实体状态?
反例:OrderLine 在包外被直接修改数量,绕过 Order 上的库存与金额不变量。
1 | // BAD: line exported and mutated from outside aggregate |
合规 sketch:通过 Order 的方法修改行项目,并在方法内校验不变量。
1 | // GOOD: changes go through aggregate root |
评审追问:若聚合根方法数量爆炸,是 聚合过大 还是 缺少领域服务?前者考虑拆分聚合与事件协作,后者提取无状态领域服务协调多个根(仍遵守一事务一根的默认)。
反例补充:将 OrderLine 作为独立聚合根对外暴露 CRUD API,导致订单总额不变量无法封闭。
2. 实体 vs 值对象
标准:实体是否有稳定标识且可变(通过受控方法)?值对象是否不可变、按值语义相等(而非仅按指针)?
反例:Money 提供 SetAmount,被多处共享引用后产生意外修改。
1 | // BAD: value object mutable |
1 | // GOOD: new value instead of mutating |
评审追问:Equals 比较是否基于值而非指针?对外暴露的构造函数是否保证 合法组合(例如币种非空、金额非负)?
1 | // GOOD: constructor validates |
3. Repository 接口
标准:Repository 接口是否定义在领域层(或由内层拥有的端口包)?方法名是否表达 业务需要(FindActiveByCustomer)而非表驱动(SelectFromOrdersJoin)?
反例:接口放在 infra 包,领域层 import infra 拉平依赖方向。
1 | // BAD: domain importing infra-defined repository interface |
合规:domain/repository/order.go 定义 OrderRepository,infra 实现。
1 | // GOOD: port owned by domain |
评审追问:接口方法是否泄露 分页实现细节(offset/limit)到领域?读侧复杂筛选是否应归入 Query 侧 而非 Repository 万能方法?
4. Command 设计
标准:命令是否表达 业务意图(如 PlaceOrder、CancelSubscription),而不是贫血 CRUD(CreateOrder 仅映射 HTTP POST)?
反例:UpdateOrder 接收任意字段 map,语义不清、不变量无法集中校验。
1 | // BAD: command is just a data bag |
1 | // GOOD: explicit intent |
参考:42-acc-clean-code 中与「意图命名」相关的章节(配合 §4 Pipeline 组织用例)。
评审追问:命令是否携带 幂等键、版本/乐观锁、操作者身份 等横切要素?失败时是否可映射为明确的业务结果(而非一律 500)?
5. Query 设计
标准:查询是否直接返回 DTO / 读模型,不强行加载完整领域图?是否避免在查询路径上触发写模型副作用?
反例:GetOrderForReport 返回 *Order 聚合并附带懒加载副作用。
1 | // BAD: query returns rich aggregate used for read-only UI |
1 | // GOOD: dedicated read DTO |
评审追问:查询是否 只读、无副作用?是否避免在 Query 路径开事务写审计表(应下沉到命令或异步)?
6. 领域事件
标准:关键业务状态变更是否发布 领域事件?命名是否使用 过去式(OrderPlaced、PaymentCaptured)并携带必要上下文(版本、发生时间)?
反例:事件名为 PlaceOrder,或事件体只有 ID 无版本,消费者无法安全演进。
1 | // BAD: imperative name |
参考:41-架构方法论 §2.7(领域事件与集成)。
7. 模式选型(决策表)
详设阶段可快速对照下表,避免「每个地方都 if-else」或「每个地方都上框架」。
| 场景特征 | 推荐模式 | 参考 |
|---|---|---|
| 多步骤顺序流程 | Pipeline(管道) | 42-acc-clean-code §4 |
| 同一接口多种实现 | 策略模式 | 42-acc-clean-code §6.1 |
| 频繁变化的业务规则 | 规则引擎 / 规则表驱动 | 42-acc-clean-code §7 |
| 跨聚合协作 | 领域事件 + Outbox | 41-架构方法论 §2.7 |
标准:选型是否写清 触发条件、失败语义、测试策略?是否避免把本应稳定的领域规则埋在 JSON 配置里却无人审核?
反例:全系统统一 RuleEngine.Execute(ctx, ruleSetID, facts),但规则集无人版本化与评审,线上等于「可执行的配置漂移」。
合规:规则变更走 PR + 审计 + 影子流量;核心不变量仍保留在代码与单测中,引擎只编排可变的参数化策略。
三、代码评审阶段 — PR 期
适用时机:每次合并请求。本节是清单中最细的部分:把设计约束落到 Go 代码的可观察性质上。
3.1 SOLID 原则
对每一项,用「一句检查问句」+「违规 vs 合规」最小代码对照。
S — 单一职责原则(SRP)
检查:该类型是否只有一个变化理由(一个业务职责)?
1 | // BAD: order service also sends email and parses CSV |
1 | // GOOD: split by responsibility |
O — 开闭原则(OCP)
检查:扩展新行为时,是否无需修改原有稳定代码路径(优先组合、接口、策略)?
1 | // BAD: every new payment method edits the same function |
1 | // GOOD: open for extension via interface |
1 | // GOOD: add new gateway without editing existing orchestration |
L — 里氏替换原则(LSP)
检查:子类型/实现是否可完全替换接口契约而不破坏调用方假设(不缩小前置条件、不放大后置失败)?
1 | // BAD: implementation surprises caller by doing nothing |
1 | // GOOD: explicit test double with honest behavior |
评审追问:若接口允许「可选实现」(例如缓存 MaybeCache),调用方是否到处 if impl != nil?这可能是 ISP 与职责切分不足的信号。
I — 接口隔离原则(ISP)
检查:接口是否小而专注,客户端是否不被迫依赖不需要的方法?
1 | // BAD: fat interface for readers |
1 | // GOOD: segregate by client need |
D — 依赖倒置原则(DIP)
检查:高层模块是否依赖抽象(接口),而非低层具体实现?
1 | // BAD: application service constructs SQL DB |
1 | // GOOD: inject abstraction |
1 | // GOOD: wire in main/infra |
评审追问:New* 构造函数是否把 具体类型 泄漏回 domain?理想情况下,domain 只认识接口,具体类型停留在 cmd/ 或 infra/ 的组装根。
3.2 函数质量
- 函数长度 < 80 行
检查:单函数是否可在一屏内理解?超长函数是否可拆为私有步骤函数或 Pipeline 阶段?
反例:一个 Handle 内顺序完成:鉴权、解析、校验、调用下游、重试、日志、指标、错误映射——应拆为 小函数 或 Pipeline 阶段(参见 42-acc-clean-code §4)。
1 | // GOOD: named steps keep the orchestration readable |
- 圈复杂度 < 10
检查:深层分支是否可表驱动、早返回、策略化?可用gocyclo(或golangci-lint内置规则)在 CI 中强制执行。
1 | # example: analyze cyclomatic complexity (install gocyclo if needed) |
- 嵌套深度 < 3 层
检查:是否用 guard clause 减少if金字塔?
1 | // BAD: deep nesting |
1 | // GOOD: flatten with guards |
- 参数个数 < 5
检查:超过四个参数时,是否使用 Options 结构体或 functional options,或按上下文分组?
1 | // BAD: too many parameters |
1 | // GOOD: options struct |
functional options 补充(适合可选参数多、未来扩展频繁的场景):
1 | type clientOption func(*Client) |
评审追问:context.Context 是否作为 第一个参数 传递 I/O 边界函数,而不是塞进结构体字段隐式携带?
3.3 命名与通用语言
变量 / 函数名反映业务术语
检查:名称是否来自 Ubiquitous Language,而非数据库列名的机械翻译?与团队通用语言一致
检查:同一概念是否只有一个词(CustomervsUser混用要治理)。不用技术术语代替业务术语
检查:是否出现SetStatus(1)这类魔法状态,而不是MarkShipped()?
1 | // BAD: technical + magic number |
3.4 错误处理
- 禁止静默忽略错误
检查:是否存在_ = xxx或空白if err != nil { }?
1 | // BAD |
1 | // GOOD |
- 错误 wrap 携带上下文
检查:跨层返回是否使用%w保留链,并带上业务动作语义?
1 | return fmt.Errorf("place order: %w", err) |
- 区分业务错误与系统错误
检查:调用方能否区分「预期失败」(库存不足)与「应重试 / 告警」的基础设施错误?可用errors.Is/ 自定义哨兵错误类型 /fmt.Errorf包装约定。
1 | var ErrOutOfStock = errors.New("out of stock") |
反例:UserID 在支付域叫 payer_ref,在账户域叫 uid,在日志里叫 operator——评审时应要求统一 词汇表(可放在仓库 docs/glossary.md)。
3.5 依赖方向
domain 包不 import adapter / infra
检查:go list -deps或 IDE 依赖图是否显示内层干净?application 只依赖 domain(及标准库 / 通用类型)
检查:应用服务是否直接引用 HTTP、ORM、消息 SDK?无循环依赖
检查:包之间是否存在 import 环?出现时应拆接口或提取共享内核类型包。
1 | # detect import cycles (Go toolchain) |
反例:domain/order import domain/payment 同时 domain/payment import domain/order,靠 interface{} 或事件总线「糊墙」。
合规:提取 domain/sharedkernel 仅放 ID、金额、时间等最小类型;或把协作上移到 application 编排层。
3.6 DDD 战术模式
- 聚合根方法保护不变量
检查:状态变更是否集中在根上,并在方法内校验规则?
1 | // GOOD: invariant enforced in root method (amounts simplified as int64 cents) |
值对象不可变(无 setter)
检查:值类型字段是否导出写路径?不在聚合外部直接修改内部实体
检查:是否暴露可变内部集合(如[]*Line直接返回引用)?
1 | // BAD: exposes mutable internal slice |
- 一个事务一个聚合
检查:RepositorySave是否在单事务内写入多个根?若必须协作,是否已上升为事件 + 最终一致性设计并文档化?
Saga / 补偿:若业务强要求跨聚合原子性,是否在架构评审阶段明确 Saga、幂等、对账 而非偷偷用长事务?
四、上线前检查 — 合并期
适用时机:发布分支、灰度前、重大重构合并前。与功能完成度无关的「生产就绪」项在此收敛。
1. 性能
标准:关键路径是否有 benchmark(或等价的压测脚本与基线)?是否排查 goroutine / channel 泄漏(长时间运行测试、阻塞 send、未关闭的 worker)?
1 | func BenchmarkPlaceOrder(b *testing.B) { |
评审追问:是否对比过 alloc/op?是否在负载下检查 GC 停顿 与 锁竞争(mutex profile)?异步路径是否避免 无界队列 导致内存膨胀?
泄漏排查 sketch:对长期运行的集成测试使用 runtime.NumGoroutine() 采样,或 go test -race 暴露 data race 与可疑同步。
2. 并发安全
标准:共享可变状态是否由 mutex、channel 编排或 单 goroutine 所有权保护?map 并发读写是否禁止?
1 | // BAD: map + goroutines without synchronization |
1 | // GOOD: protect shared map |
评审追问:RWMutex 的 读锁重入、锁顺序(多把锁)是否文档化?是否避免在锁内调用可能阻塞的外部 I/O?
3. 可观测性
标准:是否具备 metrics(RED/USE)、trace(关键 span)、结构化日志(带 request_id、order_id 等关联字段)?
评审追问:日志是否 可查询(键值字段而非拼接长句)?trace 是否在 跨服务 边界传播 traceparent?关键指标是否有 SLO 与告警阈值(避免「上线了才第一次看监控」)?
1 | // GOOD: structured context in log fields (pseudo API) |
4. 测试覆盖
标准:核心业务规则覆盖率是否 **> 80%**(按团队约定工具统计)?是否有 集成测试 覆盖仓储、消息、外部 HTTP 的 fake / 容器测试?
评审追问:表格驱动测试是否覆盖 边界与错误路径?是否用 黄金文件 或 属性测试(可选)补强复杂规则? flaky 测试是否标记并修复,而不是 t.Skip 永久化?
1 | func TestPlaceOrder_OutOfStock(t *testing.T) { |
5. 回滚方案
标准:是否有 feature flag 或配置开关?数据库迁移是否可回滚或具备向前兼容的双写/双读阶段?
评审追问:配置变更是否 版本化?破坏性 API 是否 并行双版本 一段时间?事件 schema 是否 向后兼容 或采用 双写新字段 策略?
6. 文档更新
标准:架构变更(新 BC、事件契约、SLA)是否同步到 README / ADR / 运维手册?Review 链接是否可追溯到决策记录?
评审追问:On-call 是否知道 如何降级、如何重放消息、如何解读关键告警?新人能否仅凭文档跑起 本地依赖(docker-compose / makefile 目标)?
附录:快速参考卡片
下列 20 条是各阶段「若只能记五条」时的高杠杆提醒;完整项仍以正文为准。
| 阶段 | #1 | #2 | #3 | #4 | #5 |
|---|---|---|---|---|---|
| 架构评审 | 依赖向内 | BC 划分 | 聚合边界 | 读写评估 | YAGNI |
| 设计评审 | 聚合根入口 | 值对象不可变 | Repo 在领域层 | Command 表达意图 | 领域事件 |
| 代码评审 | SRP | 函数 < 80 行 | 业务命名 | 错误 wrap | 依赖方向 |
| 上线前 | Benchmark | 并发安全 | 可观测性 | 测试 > 80% | 回滚方案 |
用法:打印或放进 MR 模板描述区;负责人对勾选结果负责,避免形式主义勾选。
MR 描述区模板示例(可复制)
将下列 Markdown 粘到 Merge Request 正文,作者先自评,审阅者补勾或评论编号。
1 | ## Self review (author) |
按角色的「最小阅读路径」
| 角色 | 建议优先阅读 |
|---|---|
| 作者(提 PR) | 第三节全文 + 附录卡片 |
| 审阅者(同域) | 3.3–3.6 + 第二节与本文冲突点 |
| Tech Lead(新模块) | 第一、二节 + 第四节 |
| SRE / On-call | 第四节 + 事件与迁移说明 |
参考资料
站内文章
- 架构与整洁代码(一):Clean Architecture、DDD 与 CQRS——三位一体的架构方法论
- 架构与整洁代码(二):复杂业务中的 Clean Code 实践指南
- 架构与整洁代码(三):领域驱动设计读书笔记——从概念到架构实践
外部资料
- Robert C. Martin, Clean Architecture: A Craftsman’s Guide to Software Structure and Design
- Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software
- Martin Fowler, CQRS(模式概述与适用边界)
总结
系统化的 Code Review 不是挑剔,而是把重构前移到成本最低的阶段。按 架构 → 设计 → 代码 → 上线前 四段清单推进,并与 42-acc-clean-code、43-acc-ddd-notes、41-架构方法论 交叉引用,团队可以在一致的语言下讨论分层、边界与实现细节。建议把本文的「附录快速参考」嵌入 MR 模板,并在复盘时根据失效案例增补你们自己的第 21 条——最好的 Checklist 永远是活文档。