1 进化史与里程碑
年份 | 思想 / 架构 | 主要诉求 | 引用人物 |
---|---|---|---|
1978 | MVC(Smalltalk-80) | 将 UI-状态-动作解耦 | Trygve Reenskaug |
1990s | 3-Tier / N-Layer(Web 盛行) | Presentation / Business / Data 隔离 | 企业应用模式 |
2005 | Hexagonal Architecture(Ports & Adapters) | UI、DB 都只是"外设";靠 Port 接口 隔离 | Alistair Cockburn |
2008 | Onion Architecture | 把 Hexagon 画成同心圆:依赖只能向内 | Jeffrey Palermo |
2012 | Clean Architecture | 汇总 Onion + Hexagon + DDD,用更明确的四层语义推广 | Robert C. Martin ("Uncle Bob") |
2017 | 《Clean Architecture》一书 | 理论体系化、案例丰富 | Uncle Bob |
这些演进围绕同一个痛点:业务代码的寿命远长于任何技术细节
。
只要让"框架/数据库/协议"留在外圈,核心业务就能:
- 不被技术更新拖着走。
- 可以在内存里飞快地做单元测试。
- 被不同界面(Web、CLI、Scheduler...)复用。
2 Clean Architecture 的四层剖面
- Entities(Enterprise Business Rules)
- 领域对象、值对象、纯算法
- Use Cases(Application Business Rules)
- 一次业务场景的「剧本」
- 定义 Port trait 任意调用外部资源
- Interface Adapters
- Controller / Presenter
- Repository / Gateway 实现这些 Port
- DTO ↔ Entity 的 Mapper
- Frameworks & Drivers
- Web 框架、ORM、消息队列、第三方 SDK
- Composition Root:启动 + 依赖注入
依赖只能内向;内层永远不知道外层存在
3 为什么要用 Clean Architecture
- 可测试 内层代码零 IO;Mock Port 就能跑毫秒级单测。
- 可演化 Web→gRPC、MySQL→Mongo 只改最外圈。
- 可复用 同一套 Use-Case 能同时服务 App、CLI、Batch Job。
- 可维护 业务规则与技术细节分仓,阅读、重构心智负担小。
4 一个典型的目录布局(语言无关)
bash
src/
├─ core/ ← 不依赖任何框架
│ ├─ domain/ ← Entities + Domain Services
│ │ ├─ entities/
│ │ └─ services/
│ └─ usecase/ ← Interactors + Port traits
│ ├─ interactor/
│ └─ port/
├─ adapters/ ← 实现 Port,做格式转换
│ ├─ persistence/
│ ├─ notification/
│ └─ web/
├─ frameworks/ ← 具体框架 glue code
│ └─ actix/, diesel/, ...
└─ main.rs / App.kt / Program.cs ← Composition Root
核心判别法:
core/ 不准 import 任何外部库(除了官方标准库)。 其它层可以反向依赖 core。
5 闹钟需求:一步步落到代码
下面举一个例子:
用户可以创建若干闹钟,指定时间与提示文本;到点后系统提醒。
5.1 领域建模(core/domain)
领域建模使用 DDD(# Domain-driven-design)进行建模.该文章只介绍整洁架构,对于 DDD的讲解请移步:Domain-driven-design
rust
// entities/alarm.rs
pub struct AlarmId(Uuid);
pub struct AlarmTime(NaiveTime); // HH:mm:ss,省掉日期
pub struct Alarm {
id: AlarmId,
time: AlarmTime,
message: String,
enabled: bool,
}
impl Alarm {
pub fn trigger_msg(&self) -> String { self.message.clone() }
pub fn is_due(&self, now: NaiveTime) -> bool { self.enabled && now >= self.time }
}
// services/next_tick.rs
pub struct NextTick;
impl NextTick {
pub fn millis_until(alarm: &Alarm, now: NaiveTime) -> u64 { ... }
}
纯净:只依赖 chrono 之类标准日期库,无 IO。
5.2 Use Case + Port(core/usecase)
rust
// port/alarm_repo.rs ------ 访问持久层
pub trait AlarmRepository {
fn save(&self, alarm: Alarm) -> anyhow::Result<()>;
fn find_due(&self, now: NaiveTime) -> anyhow::Result<Vec<Alarm>>;
}
// port/notifier.rs ------ 发送提醒
pub trait Notifier {
fn notify(&self, msg: &str) -> anyhow::Result<()>;
}
// interactor/create_alarm.rs
pub struct CreateAlarm<'a, R: AlarmRepository> { repo: &'a R }
impl<'a, R: AlarmRepository> CreateAlarm<'a, R> {
pub fn exec(&self, time: AlarmTime, msg: String) -> anyhow::Result<()> {
let alarm = Alarm { id: AlarmId::new(), time, message: msg, enabled: true };
self.repo.save(alarm)
}
}
// interactor/ring_due_alarms.rs
pub struct RingDueAlarms<'a, R: AlarmRepository, N: Notifier> {
repo: &'a R,
notifier: &'a N,
}
impl<'a, R, N> RingDueAlarms<'a, R, N>
where
R: AlarmRepository,
N: Notifier,
{
pub fn exec(&self, now: NaiveTime) -> anyhow::Result<()> {
for alarm in self.repo.find_due(now)? {
self.notifier.notify(&alarm.trigger_msg())?;
}
Ok(())
}
}
要点:
这里的 trait (AlarmRepository, Notifier) 就是 Port 即IO,但是IO的具体实现不在这里; 用例不知道数据库、HTTP、推送 SDK 长啥样。
5.3 Adapter 层(adapters/...)
rust
// adapters/persistence/sqlite_repo.rs
pub struct SqliteRepo { conn: Connection }
impl AlarmRepository for SqliteRepo { /* SQL 读写 */ }
// adapters/notification/desktop_notifier.rs
pub struct DesktopNotifier;
impl Notifier for DesktopNotifier {
fn notify(&self, msg: &str) { /* 调 OS API 弹窗 */ }
}
外部IO的具体实现
5.4 框架 & 组合根(main.rs)
rust
fn main() -> anyhow::Result<()> {
// === 组装 ===
let repo = SqliteRepo::new("alarms.db")?;
let notifier = DesktopNotifier::new();
let create_uc = CreateAlarm { repo: &repo };
let ring_uc = RingDueAlarms { repo: &repo, notifier: ¬ifier };
// === CLI 入口示例 ===
if let Some(("add", cmd)) = cli().subcommand() {
create_uc.exec(parse_time(cmd), cmd.value_of("msg").unwrap().into())?;
} else { // 简易轮询定时器
loop {
ring_uc.exec(Local::now().time())?;
thread::sleep(Duration::from_secs(30));
}
}
}
main 里把 Port 绑到 Adapter,实现依赖注入;核心业务仍然 0 framework import。
6 常见疑问:那些接口究竟谁来实现?
- Port trait(访问 IO)的实现在 Adapter / Framework 层,_绝不能_回到 Use Case。
- 业务多态 trait(如 PricingStrategy)如果出现在 Domain,可以在 Domain 里给出若干实现,也可以让 Use Case / Adapter 扩展------只要它不需要外部资源即可。
- "用例自己要不要实现接口?"
- 用例实现 输入端口(例如 MVC Controller 调用 execute())的场景可以有;
- 但它绝不实现 输出端口(Repository、Gateway),否则又把 IO 拉回内层。
口诀:
"接口属于调用方,具体类属于被调用方的外层。"
7 总结
- MVC / N-Layer 解决了 UI 与 DB 的早期耦合,但没挡住"框架侵入"。
- Hexagonal 用 Port 把 UI、DB 看成"六边形的插口";Onion 再次强调依赖朝内。
- Clean Architecture 把名字与职责说得更白:Entities、Use Cases、Adapters、Frameworks。
目录、trait、struct 的划分只为一件事:
让你今天写下的业务规则,十年后还能在任何新技术栈上跑起来。
Pomelo_刘金 拿闹钟示例演练一遍,你就可以把相同套路复制到电商、IM、金融交易或任何需要长期维护的系统里。祝编码整洁,业务长青。