云辇103

Love Life Love Coding Love You

分治的边界:从 SRP 到限界上下文,再到 Rust 中真正可治理的设计

分治并不天然正确。

很多时候,我们一提到“拆分”,就会本能地觉得这是在优化架构、降低复杂度。但事实恰恰相反:分治最大的风险,不是没有拆,而是拆错了边界。一个错误的拆分,会把原本内聚的整体强行打散,结果不是降低复杂度,而是引入更多耦合、更多协调成本、更多隐式依赖,最终让系统变得更难理解、更难修改、更难测试。

所以,真正重要的问题从来不是“要不要分”,而是:

应该按什么边界来分?

我个人目前比较认可,可以从两个层级来理解这个问题:

  1. 代码层级:单一职责原则(SRP)
  2. 系统层级:限界上下文(Bounded Context)

这两个概念看起来属于不同尺度,但它们背后的思想其实非常一致:分,不是为了把东西切碎;分,是为了把复杂度约束在局部。


一、代码层级的边界:SRP 不是“一个类只做一件事”,而是“只有一个变化理由”

在代码层面,我们面对的最大问题是什么?

不是“代码长不长”,也不是“函数优不优雅”,而是变更

一个软件生命周期里最大的成本,往往不是第一次把功能写出来,而是后续不断地响应需求变化、修 bug、扩展逻辑、兼容新场景。维护成本的本质,就是应对变化的成本。

Robert C. Martin 对 SRP 的经典表述是:

A class should have only one reason to change.

也就是:

一个类,应该只有一个变化的理由。

很多人第一次接触 SRP 时,会把它理解成“一个类只做一件事”。这句话不能说错,但太模糊。真正更有操作性的判断方式其实是:

如果两个逻辑会因为不同的人、不同部门、不同规则变化而分别修改,那它们就不该耦合在一起。

举个经典例子。假设我们有一个 Employee 类型,它同时负责:

  • 薪酬计算
  • 数据库存储
  • 报表生成

那么这三个职责其实分别对应三种完全不同的变化来源:

  • 财务规则变了,薪酬计算要改
  • DBA 调整表结构了,持久化逻辑要改
  • HR 改报表格式了,报表生成要改

如果它们被塞进同一个类里,会发生什么?

  • 改薪酬逻辑,可能误伤数据库逻辑
  • 改存储逻辑,可能影响报表
  • 一个类被多个团队从不同方向同时修改
  • 测试范围越来越大,改动影响面越来越不清晰

这就是复杂度滋生的起点。

Rust 风格的 SRP 拆分

在 Rust 里,我们通常不会把所有行为都塞进一个“大对象”里,而是更倾向于把能力拆成清晰的结构体和 trait。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#[derive(Debug, Clone)]
pub struct Employee {
pub id: u64,
pub name: String,
pub base_salary: i64,
pub bonus: i64,
}

pub struct PayrollCalculator;

impl PayrollCalculator {
pub fn calculate_salary(&self, employee: &Employee) -> i64 {
employee.base_salary + employee.bonus
}
}

pub trait EmployeeRepository {
fn save(&self, employee: &Employee) -> Result<(), String>;
}

pub struct SqlEmployeeRepository;

impl EmployeeRepository for SqlEmployeeRepository {
fn save(&self, employee: &Employee) -> Result<(), String> {
println!("save employee {} to database", employee.id);
Ok(())
}
}

pub struct EmployeeReporter;

impl EmployeeReporter {
pub fn generate_report(&self, employee: &Employee) -> String {
format!(
"Employee Report => id: {}, name: {}, base_salary: {}, bonus: {}",
employee.id, employee.name, employee.base_salary, employee.bonus
)
}
}

这里的拆分重点不在于“代码看起来更工整”,而在于:

  • PayrollCalculator 只会因为薪酬规则变化而变化
  • SqlEmployeeRepository 只会因为存储策略变化而变化
  • EmployeeReporter 只会因为报表格式变化而变化

也就是说,SRP 的边界,不是功能名词的边界,而是变化来源的边界。


二、系统层级的边界:限界上下文解决的不是代码问题,而是语义问题

到了系统层面,尤其是大型业务系统里,复杂度的来源就不只是“代码如何组织”了。

这时候更大的问题是:

同一个词,在不同业务里,到底是不是同一个东西?

比如“客户(Customer)”这个词:

  • 在销售团队眼里,客户是潜在购买者
  • 在客服团队眼里,客户是有服务关系的注册用户
  • 在财务团队眼里,客户是有付款行为的法律实体

如果我们强行建立一个“统一 Customer 大模型”,试图让一个模型覆盖所有场景,结果通常会是:

  • 字段越来越多
  • 各种条件分支越来越多
  • 不同团队对模型含义理解不一致
  • 每个场景都觉得这个模型“不太对”

这时候问题已经不是代码优不优雅,而是语义冲突

DDD 里的限界上下文(Bounded Context)就是为了解决这个问题。

限界上下文的核心不是“拆服务”,而是“定义语义边界”

在不同上下文里,可以允许同一个概念拥有不同模型。

比如:

  • SalesCustomer
  • SupportCustomer
  • BillingCustomer

它们名字都带着“Customer”,但语义不同、关注点不同、规则不同,所以本来就不该硬揉成一个模型。

如果用 Rust 表达这个思想,可以很自然地把它们设计成不同类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#[derive(Debug)]
pub struct SalesCustomer {
pub lead_id: u64,
pub name: String,
pub contact: String,
pub intention_level: u8,
}

#[derive(Debug)]
pub struct SupportCustomer {
pub user_id: u64,
pub nickname: String,
pub active_ticket_count: u32,
}

#[derive(Debug)]
pub struct BillingCustomer {
pub legal_entity_id: u64,
pub company_name: String,
pub tax_number: String,
pub paid_amount: i64,
}

这三个结构体看起来“重复”,但这种重复并不是坏事。

因为它们表达的是:

  • 销售上下文里的客户
  • 客服上下文里的客户
  • 财务上下文里的客户

限界上下文的价值,不是复用模型,而是避免错误复用模型。

这是很多系统设计里最容易被忽视的一点。


三、真正的分治,不是把 500 行函数拆成 5 个 100 行函数

我过去在游戏业务里接触过一种非常典型的“巨型结算接口”。

这个接口要处理很多事情:

  • 多种游戏模式的结算逻辑
  • 成绩计算
  • 奖励发放
  • 历史荣誉更新
  • 师徒系统加成
  • 任务系统进度推进
  • 活动奖励发放
  • 而且部分奖励必须同步返回给客户端,不能纯异步

如果你把这些逻辑全部塞在一个接口里,代码通常会长成这样:

1
2
3
4
5
6
7
8
9
10
11
pub fn settle(req: SettleRequest) -> Result<SettleResponse, String> {
// 1. 参数解析
// 2. 成绩格式化
// 3. 按模式分支
// 4. 计算奖励
// 5. 更新任务
// 6. 发布事件
// 7. 持久化
// 8. 构建响应
todo!()
}

这种代码的问题,不只是“长”,而是它混杂了多个抽象层级:

  • HTTP / 接口层职责
  • 业务编排职责
  • 业务执行职责
  • 副作用管理职责
  • 响应构建职责

很多人会进一步做一个“表面拆分”:

1
2
3
4
5
6
7
8
pub fn process_game_mode_1(ctx: &SettlementContext) -> Result<SettlementResult, String> {
let r1 = step1(ctx)?;
let r2 = step2(ctx, r1)?;
let r3 = step3(ctx, r2)?;
let r4 = step4(ctx, r3)?;
let r5 = step5(ctx, r4)?;
Ok(r5)
}

这看起来好像变好了,但实际上并没有真正治理复杂度。

因为这些 step1/step2/step3 往往只是把原来的大段代码搬到了不同函数里,而没有改变依赖关系和职责结构。它依然存在几个根本问题:

  • 不能独立理解
  • 不能独立修改
  • 不能独立测试
  • 不能灵活组合

换句话说,这种拆分实现了“分”,但没有实现“治”。


四、什么叫“真正的治理”?

我现在越来越倾向于用下面四个维度来判断一个拆分到底有没有价值:

  1. 可独立理解
  2. 可独立修改
  3. 可独立验证
  4. 可灵活组合

好的架构不是消灭复杂度,因为业务本来就复杂。

好的架构只是做到一件事:

让复杂度被约束在局部,每个局部都能被人稳定地理解、修改、测试和复用。

下面用 Rust 的方式来表达这个思路。


五、用 Rust 实现一个可治理的结算 Pipeline

1. 先定义上下文与结果

我们先把流程的输入和输出抽出来,避免每个处理器都各自操作一堆零散参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
use std::collections::HashMap;

#[derive(Debug, Clone, Copy)]
pub enum GameMode {
Mode1,
Mode2,
}

#[derive(Debug, Clone)]
pub struct Player {
pub id: u64,
pub base_score: i32,
}

#[derive(Debug, Clone)]
pub struct SettleRequest {
pub mode: GameMode,
pub players: Vec<Player>,
}

#[derive(Debug, Clone)]
pub struct SettlementContext {
pub mode: GameMode,
pub players: Vec<Player>,
}

impl SettlementContext {
pub fn from_request(req: SettleRequest) -> Result<Self, String> {
if req.players.is_empty() {
return Err("players cannot be empty".to_string());
}

Ok(Self {
mode: req.mode,
players: req.players,
})
}
}

#[derive(Debug, Clone, Default)]
pub struct Reward {
pub coins: i32,
}

#[derive(Debug, Clone)]
pub struct PlayerReward {
pub player_id: u64,
pub base_reward: Reward,
pub total_reward: Reward,
}

#[derive(Debug, Default)]
pub struct SettlementResult {
pub player_rewards: HashMap<u64, PlayerReward>,
pub events: Vec<String>,
}

#[derive(Debug)]
pub struct SettleResponse {
pub rewards: HashMap<u64, PlayerReward>,
pub events: Vec<String>,
}

impl From<SettlementResult> for SettleResponse {
fn from(result: SettlementResult) -> Self {
Self {
rewards: result.player_rewards,
events: result.events,
}
}
}

这一层的意义在于:

  • SettlementContext 表达流程输入
  • SettlementResult 表达流程累积产物
  • SettleResponse 表达最终对外返回结果

这样,业务处理单元之间共享的是明确的流程模型,而不是散乱参数。


2. 定义 Processor:让每个处理单元只关心一个职责

接下来定义统一的处理器接口。

1
2
3
4
5
6
7
pub trait Processor {
fn process(
&self,
ctx: &SettlementContext,
result: &mut SettlementResult,
) -> Result<(), String>;
}

这个 trait 很关键。它表达的是:

  • 处理器读取上下文
  • 处理器修改结果
  • 处理器只负责自己那一段逻辑

接着我们定义计分职责。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
pub trait ScoreCalculator {
fn calculate(&self, player: &Player, ctx: &SettlementContext) -> i32;
fn convert_to_reward(&self, score: i32) -> Reward;
}

pub struct DefaultScoreCalculator;

impl ScoreCalculator for DefaultScoreCalculator {
fn calculate(&self, player: &Player, _ctx: &SettlementContext) -> i32 {
player.base_score
}

fn convert_to_reward(&self, score: i32) -> Reward {
Reward { coins: score * 10 }
}
}

pub struct ScoreCalculationProcessor<C: ScoreCalculator> {
pub calculator: C,
}

impl<C: ScoreCalculator> Processor for ScoreCalculationProcessor<C> {
fn process(
&self,
ctx: &SettlementContext,
result: &mut SettlementResult,
) -> Result<(), String> {
for player in &ctx.players {
let score = self.calculator.calculate(player, ctx);
let reward = self.calculator.convert_to_reward(score);

result.player_rewards.insert(
player.id,
PlayerReward {
player_id: player.id,
base_reward: reward.clone(),
total_reward: reward,
},
);
}

Ok(())
}
}

这段代码体现的是认知治理

当你阅读 ScoreCalculationProcessor 时,不需要理解:

  • HTTP 是怎么进来的
  • 数据库怎么存
  • 活动系统怎么发奖
  • 师徒系统怎么计算

你只需要理解一件事:

输入玩家分数,输出基础奖励。

这就是“可独立理解”。


3. 再把师徒加成拆成独立单元

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
pub trait MasterApprenticeService {
fn bonus_rate(&self, player_id: u64) -> f32;
}

pub struct DefaultMasterApprenticeService;

impl MasterApprenticeService for DefaultMasterApprenticeService {
fn bonus_rate(&self, player_id: u64) -> f32 {
if player_id % 2 == 0 { 0.2 } else { 0.0 }
}
}

pub struct MasterApprenticeProcessor<S: MasterApprenticeService> {
pub service: S,
}

impl<S: MasterApprenticeService> Processor for MasterApprenticeProcessor<S> {
fn process(
&self,
_ctx: &SettlementContext,
result: &mut SettlementResult,
) -> Result<(), String> {
for reward in result.player_rewards.values_mut() {
let rate = self.service.bonus_rate(reward.player_id);
let bonus = (reward.base_reward.coins as f32 * rate) as i32;
reward.total_reward.coins += bonus;
}

Ok(())
}
}

这段代码体现的是演化治理

如果未来产品说:

  • 师徒加成从 20% 改成 15%
  • 某些模式不参与师徒加成
  • 某类玩家有特殊规则

那么你修改的边界非常明确:

  • MasterApprenticeService
  • 或改 MasterApprenticeProcessor

而不需要在一大段结算总流程里艰难定位一段逻辑。

这就是“可独立修改”。


4. Pipeline 负责组合,而不是负责业务细节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pub struct SettlementPipeline {
processors: Vec<Box<dyn Processor>>,
}

impl SettlementPipeline {
pub fn new(processors: Vec<Box<dyn Processor>>) -> Self {
Self { processors }
}

pub fn execute(&self, ctx: &SettlementContext) -> Result<SettleResponse, String> {
let mut result = SettlementResult::default();

for processor in &self.processors {
processor.process(ctx, &mut result)?;
}

Ok(result.into())
}
}

SettlementPipeline 的职责非常纯粹:

  • 维护处理顺序
  • 驱动流程执行
  • 聚合最终结果

它并不关心每个处理器内部细节。

这种设计带来的好处是,流程控制和业务实现分离了。编排归编排,规则归规则。

这就是“组合治理”的基础。


5. Factory 决定不同模式如何装配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pub struct SettlementPipelineFactory;

impl SettlementPipelineFactory {
pub fn create_mode1_pipeline(&self) -> SettlementPipeline {
SettlementPipeline::new(vec![
Box::new(ScoreCalculationProcessor {
calculator: DefaultScoreCalculator,
}),
Box::new(MasterApprenticeProcessor {
service: DefaultMasterApprenticeService,
}),
])
}

pub fn create_mode2_pipeline(&self) -> SettlementPipeline {
SettlementPipeline::new(vec![
Box::new(ScoreCalculationProcessor {
calculator: DefaultScoreCalculator,
}),
])
}
}

这里体现的是很关键的一点:

新增模式,不一定意味着修改原有主流程;更理想的方式是新增一种组合。

比如:

  • Mode1 有基础计分 + 师徒加成
  • Mode2 只有基础计分
  • Mode3 可以是基础计分 + 活动奖励 + 排行榜结算

你做的是装配不同 pipeline,而不是不断在主流程里堆 if else


6. 最后让 API 只负责入口职责

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pub struct Api {
pipeline_factory: SettlementPipelineFactory,
}

impl Api {
pub fn new() -> Self {
Self {
pipeline_factory: SettlementPipelineFactory,
}
}

pub fn settle(&self, req: SettleRequest) -> Result<SettleResponse, String> {
let ctx = SettlementContext::from_request(req)?;

let pipeline = match ctx.mode {
GameMode::Mode1 => self.pipeline_factory.create_mode1_pipeline(),
GameMode::Mode2 => self.pipeline_factory.create_mode2_pipeline(),
};

pipeline.execute(&ctx)
}
}

这一层就非常清晰了:

  • 请求进来
  • 转成上下文
  • 根据模式选择流程
  • 执行流程
  • 返回结果

此时 Api::settle 已经不是一个“巨石方法”,而是一个真正的入口编排器。


六、为什么这种拆分才算“真正的分治”?

因为它不只是把代码切开了,而是让每一部分都具备独立治理能力。

1. 可独立理解

你看 ScoreCalculationProcessor,知道它只负责算基础奖励。

你看 MasterApprenticeProcessor,知道它只负责师徒加成。

你不需要读完整个结算流程,才能理解局部逻辑。

2. 可独立修改

改师徒系统,不需要冒险碰计分逻辑。

改活动奖励,不需要去翻数据库落库逻辑。

每种变化都有明确落点。

3. 可独立验证

测试也会变得非常自然。

比如测试计分逻辑时,只关心输入分数和输出奖励:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#[cfg(test)]
mod tests {
use super::*;

#[test]
fn score_processor_should_convert_score_to_reward() {
let ctx = SettlementContext {
mode: GameMode::Mode1,
players: vec![Player { id: 1, base_score: 10 }],
};

let processor = ScoreCalculationProcessor {
calculator: DefaultScoreCalculator,
};

let mut result = SettlementResult::default();
processor.process(&ctx, &mut result).unwrap();

let reward = result.player_rewards.get(&1).unwrap();
assert_eq!(reward.base_reward.coins, 100);
assert_eq!(reward.total_reward.coins, 100);
}

#[test]
fn master_apprentice_processor_should_apply_bonus() {
let mut result = SettlementResult::default();
result.player_rewards.insert(
2,
PlayerReward {
player_id: 2,
base_reward: Reward { coins: 100 },
total_reward: Reward { coins: 100 },
},
);

let processor = MasterApprenticeProcessor {
service: DefaultMasterApprenticeService,
};

let ctx = SettlementContext {
mode: GameMode::Mode1,
players: vec![],
};

processor.process(&ctx, &mut result).unwrap();

let reward = result.player_rewards.get(&2).unwrap();
assert_eq!(reward.total_reward.coins, 120);
}
}

你会发现,这种测试的特点是:

  • 输入很小
  • 依赖很少
  • 断言很精确
  • 执行很快

这就是“可独立验证”。

4. 可灵活组合

新增一个模式时,你大概率只需要:

  • 新增一个 Processor
  • 或新增一个新的 Pipeline 装配方案

而不是冲进那个几百行主函数里再追加一个大分支。

这就是“可灵活组合”。


七、SRP 和限界上下文,本质上解决的是同一个问题

看到这里其实会发现,SRP 和限界上下文虽然一个偏代码、一个偏系统,但它们都在回答同一个根本问题:

边界该怎么画,才能让复杂度停留在局部,而不向全局扩散?

它们的区别只是作用层级不同。

SRP 关注的是:

  • 代码里的变化理由
  • 模块职责是否混杂
  • 修改边界是否清晰

限界上下文关注的是:

  • 业务语义是否一致
  • 模型是否被错误共享
  • 团队协作边界是否清晰

所以可以这样理解:

  • 在代码层级,SRP 是按“变化理由”划边界
  • 在系统层级,Bounded Context 是按“语义一致性”划边界

两者本质上都是在做一件事:

把复杂系统切成高内聚、低耦合、局部可治理的单元。


八、结语:真正好的拆分,不是更碎,而是更稳

我现在越来越觉得,架构设计里最重要的能力,不是“会不会拆”,而是“知不知道什么不该拆”。

因为错误的拆分,往往比不拆更糟。

  • 把本应内聚的逻辑拆散,会带来高耦合
  • 把强语义关联的概念硬拆开,会带来额外通信成本
  • 把本该独立演化的职责揉在一起,会让每次变更都像拆雷

所以,真正有价值的分治,不是为了让代码看起来更模块化,也不是为了迎合某种架构风格,而是为了让复杂度在局部被稳定控制住。

如果一个拆分最终没有带来这些收益:

  • 更容易理解
  • 更容易修改
  • 更容易测试
  • 更容易组合

那它很可能只是“看起来分了”,其实并没有真正治理复杂度。

而我理解中的“真正的分治”,恰恰就在这里:

不是把大问题切碎,而是把每一块都切成可以独立治理的单元。

img

📖 阶段一:索引 (Indexing) —— “把书买回来,做好标签放进图书馆”

这个阶段是离线准备的,就像考试前你需要先整理好复习资料。

  1. 文档加载:你要查资料,首先得有书。这一步就是把你公司里的 PDF、Word、TXT、网页等各种格式的文档全部导进系统里。(进货买书
  2. 文档分块:一整本书太厚了,大模型一口气读不完(受限于上下文窗口)。所以要把长文档切成一小段一小段的(Chunk)。比如按段落切,每段 500 字。(把书撕成一页页的“知识卡片”
  3. 向量嵌入:计算机看不懂汉字,只懂数字。所以要用一个特殊的模型(Embedding 模型),把每一张“知识卡片”变成一串数学坐标(向量)。意思相近的句子,坐标就离得近。(给每张卡片打上“数学条形码”,按语义分类
    • 图中的箭头向下:这些打了条形码的卡片,最终都被存进了下一层楼的向量数据库中,随时备查。

🔍 阶段二:检索 (Retrieval) —— “遇到考试题,去图书馆找相关的书页”

这个阶段是实时发生的,用户开始提问了。

  1. 问题输入:用户问了一个问题,比如:“公司的报销流程是什么?”(看到考试卷上的题目
  2. 向量嵌入:为了能在图书馆里找到答案,必须把用户的“问题”也变成同样的数学坐标(向量)。(把考试题目也翻译成“数学条形码”
  3. 相似匹配 向量数据库:拿着问题的条形码,去向量数据库里扫一圈,计算数学距离(比如余弦相似度)。距离越近,说明卡片上的内容跟问题越相关。(图书管理员拿着你的题目,去书架上比对,找出最相关的卡片

✍️ 阶段三:生成 (Generation) —— “照着找出来的资料,组织语言写出答案”

资料找到了,现在要让大模型开始写作业了。

  1. top_k:数据库里可能匹配出成百上千相关的段落,但我们只要最相关的前几个(K一般设为3或5)。(挑出最最相关的 3 张知识卡片放在桌上
  2. 提示词 Prompt:这是最关键的“拼接”环节。系统会在后台把“用户的原始问题”和“找出来的 top_k 知识卡片”缝合在一起,形成一段固定格式的话。
    • (画外音提示词类似于:“LLM你好,请根据以下参考资料【卡片1、卡片2、卡片3】,回答用户的问题【报销流程是什么?】”)
  3. LLM:大语言模型(像 ChatGPT)接收到了上面拼好的 Prompt。这时候,它不仅知道问题是什么,而且手边就有了正确答案的参考资料。(学生看着考卷题目,同时照着桌上的复习卡片开始作答
  4. 答案输出:LLM 消化了参考资料的内容,用自然人类语言润色后输出给用户。完美避免了幻觉!(交出满分答卷

🧠 一句话记忆口诀:

为了加深记忆,你可以用这句顺口溜记住整张图:

“切碎资料变向量(索引),问题比对找前K(检索),拼进Prompt喂模型(生成)。”

alt text

一、类型状态模式(Typestate Pattern)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use std::marker::PhantomData;

struct Disconnected;
struct Connected;

struct Socket<State> {
fd: i32,
_state: PhantomData<State>,
}

impl Socket<Disconnected> {
fn connect(self, addr: &str) -> Socket<Connected> {
// ... connect logic ...
Socket { fd: self.fd, _state: PhantomData }
}
}

impl Socket<Connected> {
fn send(&mut self, data: &[u8]) { /* ... */ }
fn disconnect(self) -> Socket<Disconnected> {
Socket { fd: self.fd, _state: PhantomData }
}
}

// Socket<Disconnected> has no send() method — compile error if you try

这段代码展示了 Rust 中一种非常高级且强大的设计模式,叫做 Typestate Pattern(类型状态模式)

它的核心意思是:利用 Rust 的编译检查,确保你永远不会在错误的状态下调用错误的函数。

1.1 直白解释

在普通的编程语言中(比如 C++ 或 Java),你可能会写这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Socket {
is_connected: bool,
fd: i32,
}

impl Socket {
fn send(&self, data: &[u8]) {
if !self.is_connected {
panic!("还没连接呢!不能发数据!"); // 这种错误只有在程序运行时才会发现
}
// ...
}
}

但在 Rust 代码里,如果你试图在断开连接时调用 send(),程序根本编译不通过。 编译器会直接告诉你:”对不起,Socket<Disconnected> 这个类型没有 send 方法”。

1.2 实现原理

1.2.1 定义不同的状态(标签)

1
2
struct Disconnected; // 状态 A:断开
struct Connected; // 状态 B:连接

这两个结构体没有任何字段,它们只是”标签”。

1.2.2 让 Socket 携带状态

1
2
3
4
struct Socket<State> {
fd: i32,
_state: PhantomData<State>, // 告诉编译器:这个 Socket 现在处于 State 状态
}

PhantomData 就像一个占位符,它不占内存,只在编译阶段起作用,标记当前的 State 是什么。

1.2.3 为不同状态实现不同的方法

这是最关键的地方:方法是分家写的。

  • 只有 Socket<Disconnected> 才有 connect 方法:

    1
    2
    3
    impl Socket<Disconnected> {
    fn connect(self, ...) -> Socket<Connected> { ... }
    }

    注意:connect 会消耗掉原来的自己(self),返回一个全新的类型 Socket<Connected>

  • 只有 Socket<Connected> 才有 senddisconnect 方法:

    1
    2
    3
    4
    impl Socket<Connected> {
    fn send(&mut self, data: &[u8]) { ... }
    fn disconnect(self) -> Socket<Disconnected> { ... }
    }

1.3 代码示例对比

如果你这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let socket = Socket { fd: 1, _state: PhantomData }; // 默认是 Disconnected

// ❌ 编译报错!
// 编译器会说:Socket<Disconnected> 没定义 send 方法
// 你在写代码阶段就能发现错误,而不是等程序跑起来崩溃
socket.send(b"hello");

// ✅ 必须先 connect
let mut connected_socket = socket.connect("127.0.0.1");

// ✅ 现在可以 send 了,因为类型变成了 Socket<Connected>
connected_socket.send(b"hello");

// ❌ 再次报错!
// 一旦调用 disconnect,变量会被消耗,不能再调用 send
let closed_socket = connected_socket.disconnect();
connected_socket.send(b"bye"); // 编译报错:Value used after move

1.4 优势总结

  1. 状态安全:你不可能忘记检查连接状态,因为编译器替你检查了。
  2. 零成本抽象:这些 PhantomData 和泛型状态只存在于编译期,编译出来的机器码里没有任何多余的 if-else 判断,性能极高。
  3. API 指引:开发者在使用你的库时,编辑器只弹出会展示当前状态下可用的方法。如果你没连接,编辑器压根不会提示你 send 方法。

一句话总结:它把”逻辑错误”变成了”语法错误”。


二、通过关联类型确保协议正确性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::io;

trait IpmiCmd {
type Response;
fn parse_response(&self, raw: &[u8]) -> io::Result<Self::Response>;
}

// Simplified for illustration — see ch02 for the full trait with
// net_fn(), cmd_byte(), payload(), and parse_response().

struct ReadTemp { sensor_id: u8 }
impl IpmiCmd for ReadTemp {
type Response = Celsius;
fn parse_response(&self, raw: &[u8]) -> io::Result<Celsius> {
Ok(Celsius(raw[0] as i8 as f64))
}
}


fn execute<C: IpmiCmd>(cmd: &C, raw: &[u8]) -> io::Result<C::Response> {
cmd.parse_response(raw)
}
// ReadTemp always returns Celsius — can't accidentally get Rpm

这段代码展示了 Rust 中一个非常核心的设计哲学:通过类型系统确保协议正确性(Protocol Correctness),即所谓的”使非法状态不可表达“(Make invalid interactions unrepresentable)。

2.1 核心目标:绑定”请求”与”响应”

在底层协议(如 IPMI、NVMe)中,你发送一个特定的命令(Request),硬件会返回一段二进制数据。传统做法通常是手动解析这些字节,但这容易出错:你可能会不小心用解析”转速”的代码去解析”温度”数据。

这段代码通过 Rust 的 关联类型(Associated Types) 在编译阶段解决了这个问题。

2.2 代码逐段解析

2.2.1 定义 Trait(协议骨架)

1
2
3
4
trait IpmiCmd {
type Response; // 关联类型:每个命令必须定义它对应的返回类型
fn parse_response(&self, raw: &[u8]) -> io::Result<Self::Response>;
}
  • type Response; 是关键。它告诉编译器:任何实现了 IpmiCmd 的类型,都必须明确指定它返回什么类型的数据。

2.2.2 具体命令实现

1
2
3
4
5
6
7
8
9
10
struct ReadTemp { sensor_id: u8 }

impl IpmiCmd for ReadTemp {
type Response = Celsius; // 绑定:ReadTemp 命令只能返回 Celsius 类型

fn parse_response(&self, raw: &[u8]) -> io::Result<Celsius> {
// 将原始字节解析为 Celsius 结构体
Ok(Celsius(raw[0] as i8 as f64))
}
}
  • 这里将 ReadTemp 结构体与 Celsius(摄氏度)类型硬绑定。
  • 如果你尝试在 parse_response 里返回一个 Rpm(转速)类型,编译器会直接报错。

2.2.3 泛型执行函数

1
2
3
fn execute<C: IpmiCmd>(cmd: &C, raw: &[u8]) -> io::Result<C::Response> {
cmd.parse_response(raw)
}
  • 这是一个通用的执行函数。它的返回类型是 C::Response
  • 这意味着:如果你传入的是 ReadTemp,函数返回值的类型在编译时就被确定为 Celsius

2.3 为什么说这实现了”协议正确性”?

“ReadTemp always returns Celsius — can’t accidentally get Rpm”
(ReadTemp 总是返回 Celsius —— 不会意外地得到 Rpm)

  • 传统方式的风险: 在 C 语言或动态语言中,你可能写出 data = execute(READ_TEMP_CMD); rpm = parse_as_rpm(data); 这样的代码。这在逻辑上是错的,但编译器不会阻止你。
  • Rust 的安全性: 在这段代码的设计下,如果你写 let res: Rpm = execute(&read_temp_cmd, raw);代码根本无法通过编译。因为 ReadTempResponse 类型是 Celsius,它与 Rpm 类型不匹配。

2.4 硬件层面的实际应用

例子中提到的 IPMI, Redfish, NVMe Admin commands 都是复杂的硬件管理协议。

  • 这些协议有成百上千个命令。
  • 每个命令对应的二进制响应格式都不同。
  • 使用这种模式,可以为每个命令编写一个结构体,并绑定其特有的响应结构体。这样开发者在调用 API 时,类型系统会自动引导他们写出正确的解析逻辑,极大地减少了底层驱动开发中的 Bug。

2.5 总结

这段代码演示了如何利用 Rust 的 TraitAssociated Types 来建模复杂的交互协议。它将”运行时的协议逻辑错误”转换成了”编译时的类型错误”,从而保证了程序的绝对健壮性。

核心思想:把”逻辑错误”变成”语法错误”。

概览图
在 Rust 中实现”依赖倒置”(Dependency Inversion)非常自然。Rust 使用 Trait 来定义契约,并利用 泛型(Generics)Trait 对象(Trait Objects) 来注入依赖。

一、依赖倒置与订单服务

1.1 定义数据和契约

首先定义业务对象和它需要的操作接口。

1
2
3
4
5
6
7
8
9
10
#[derive(Debug, Clone)]
pub struct Order {
pub id: u64,
pub price: f64,
}

// 1. 定义 "契约" (What) —— 相当于 Go 的 Interface
pub trait OrderRepository: Send + Sync {
fn save(&self, order: &Order) -> Result<(), String>;
}

1.2 实现业务逻辑

在 Rust 中,我们通常使用泛型来实现这种注入。这在编译时就会确定类型,性能极高。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 2. "不变" 的业务逻辑
pub struct OrderService<R: OrderRepository> {
repo: R, // 依赖于契约 R
}

impl<R: OrderRepository> OrderService<R> {
// 构造函数,注入具体的实现
pub fn new(repo: R) -> Self {
Self { repo }
}

pub fn create_order(&self, order: Order) -> Result<(), String> {
// 1. 核心业务逻辑 (100% 纯粹)
if order.price < 0.0 {
return Err("价格错误".to_string());
}

// 2. 调用 "契约",不关心底层是 MySQL, PostgreSQL 还是内存
self.repo.save(&order)
}
}

1.3 单元测试

有了抽象,测试业务逻辑就变得非常简单,不需要启动任何数据库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;

// 3. 定义一个 Mock 实现
struct MockRepo {
saved_orders: Mutex<Vec<Order>>,
}

impl OrderRepository for MockRepo {
fn save(&self, order: &Order) -> Result<(), String> {
let mut orders = self.saved_orders.lock().unwrap();
orders.push(order.clone());
Ok(())
}
}

#[test]
fn test_create_order_logic() {
// 准备 Mock 环境
let mock_repo = MockRepo { saved_orders: Mutex::new(vec![]) };
let service = OrderService::new(mock_repo);

// 测试业务逻辑:价格错误
let bad_order = Order { id: 1, price: -10.0 };
assert!(service.create_order(bad_order).is_err());

// 测试业务逻辑:成功保存
let good_order = Order { id: 2, price: 100.0 };
assert!(service.create_order(good_order).is_ok());
}
}

1.4 生产环境的真实实现

在生产代码中,你才去实现真正的数据库逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct SqliteRepository {
// db: SqlitePool,
}

impl OrderRepository for SqliteRepository {
fn save(&self, order: &Order) -> Result<(), String> {
println!("正在将订单 {} 写入 SQLite 数据库...", order.id);
// 这里写真正的 SQL 逻辑
Ok(())
}
}

fn main() {
// 在程序入口处(组合根)完成注入
let repo = SqliteRepository {};
let service = OrderService::new(repo);

let _ = service.create_order(Order { id: 101, price: 50.0 });
}

二、装饰器模式:缓存封装

我们延续之前的例子,展示如何在不改动 OrderService 的情况下,像”套娃”一样把 Redis 功能套上去。

2.1 定义核心契约

1
2
3
4
5
6
7
8
9
use async_trait::async_trait;

#[derive(Debug, Clone)]
pub struct Order { pub id: u64, pub price: f64 }

#[async_trait]
pub trait OrderRepository: Send + Sync {
async fn save(&self, order: &Order) -> Result<(), String>;
}

2.2 数据库实现

1
2
3
4
5
6
7
8
9
pub struct SqliteRepo;

#[async_trait]
impl OrderRepository for SqliteRepo {
async fn save(&self, order: &Order) -> Result<(), String> {
println!(" [DB层] 正在将订单 {} 存入 Sqlite...", order.id);
Ok(())
}
}

2.3 缓存装饰器

重点: 它也实现 OrderRepository,但它内部持有另一个实现了该 Trait 的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pub struct CachedOrderRepo<T: OrderRepository> {
inner: T, // "下一层"是谁?我不关心,只要它实现了 OrderRepository
redis_addr: String, // 模拟 Redis 连接
}

impl<T: OrderRepository> CachedOrderRepo<T> {
pub fn new(inner: T, addr: &str) -> Self {
Self { inner, redis_addr: addr.to_string() }
}
}

#[async_trait]
impl<T: OrderRepository> OrderRepository for CachedOrderRepo<T> {
async fn save(&self, order: &Order) -> Result<(), String> {
// 1. 调用下一层(底层的数据库写入)
self.inner.save(order).await?;

// 2. 增加"副作用":写入缓存
println!(" [缓存层] 正在将订单 {} 写入 Redis ({})", order.id, self.redis_addr);

Ok(())
}
}

2.4 业务逻辑层——保持不变

注意: 这里的代码和加缓存之前完全一模一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pub struct OrderService<R: OrderRepository> {
repo: R,
}

impl<R: OrderRepository> OrderService<R> {
pub fn new(repo: R) -> Self { Self { repo } }

pub async fn create_order(&self, order: Order) -> Result<(), String> {
// 核心业务校验
if order.price < 0.0 { return Err("价格错误".to_string()); }

// 直接调用契约
self.repo.save(&order).await
}
}

2.5 程序入口处组装

这是见证奇迹的时刻:你通过改变对象的组合方式,改变了系统的行为,而没有修改业务逻辑代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#[tokio::main]
async fn main() {
// --- 方案 A: 原始版本 ---
// let repo = SqliteRepo;
// let service = OrderService::new(repo);

// --- 方案 B: 带缓存的版本 ---
let sqlite_repo = SqliteRepo;
// 像套娃一样,把 sqlite 塞进 cached repo
let cached_repo = CachedOrderRepo::new(sqlite_repo, "redis://127.0.0.1");
// 把装好的 cached_repo 塞进 service
let service = OrderService::new(cached_repo);

// 调用
let my_order = Order { id: 888, price: 99.0 };
service.create_order(my_order).await.unwrap();
}

2.6 这种写法的优势

  1. 零成本抽象 (Zero-cost Abstraction):
    如果你使用泛型,Rust 编译器在编译时会进行”单态化”(Monomorphization)。它会直接生成一套专门调用 Sqlite 后接 Redis 的机器码。没有运行时的虚函数表查找开销,性能等同于你手写的硬编码代码。

  2. 职责单一 (SRP):

    • OrderService: 只管业务逻辑(价格对不对)。
    • SqliteRepo: 只管 SQL 怎么写。
    • CachedOrderRepo: 只管 Redis 怎么存。
      如果你下周要加 Kafka,你只需要再写一个 KafkaOrderRepo 并在 main 函数里再套一层。
  3. 极度好写测试:

    • 你想测 OrderService?给它塞一个 MockRepo(不连 DB,不连 Redis)。
    • 你想测 CachedOrderRepo 的缓存逻辑?给它塞一个 MockRepo 作为 inner
  4. 符合开闭原则 (OCP):
    软件实体应对扩展开放,对修改关闭。你通过增加新类实现了新功能,而不是通过修改旧代码实现新功能。

三、重试机制

这就是装饰器模式最迷人的地方。我们可以非常轻松地再加一层”重试”逻辑,而无需触碰现有的 OrderServiceSqliteRepoCachedOrderRepo 的任何代码。

3.1 RetryOrderRepo 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
pub struct RetryOrderRepo<T: OrderRepository> {
inner: T,
max_attempts: usize,
}

impl<T: OrderRepository> RetryOrderRepo<T> {
pub fn new(inner: T, max_attempts: usize) -> Self {
Self { inner, max_attempts }
}
}

#[async_trait]
impl<T: OrderRepository> OrderRepository for RetryOrderRepo<T> {
async fn save(&self, order: &Order) -> Result<(), String> {
let mut last_error = String::new();

for attempt in 1..=self.max_attempts {
println!(" [重试层] 正在进行第 {} 次尝试...", attempt);

match self.inner.save(order).await {
Ok(()) => return Ok(()), // 成功了,直接返回
Err(e) => {
last_error = e;
println!(" [重试层] 第 {} 次尝试失败: {}", attempt, last_error);
}
}
}

Err(format!("在 {} 次重试后仍然失败: {}", self.max_attempts, last_error))
}
}

3.2 套娃组装

现在的组装顺序是:
Service -> Retry (重试) -> Cache (缓存) -> Sqlite (数据库)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#[tokio::main]
async fn main() {
// 1. 最底层:数据库
let sqlite_repo = SqliteRepo;

// 2. 中间层:加个缓存
let cached_repo = CachedOrderRepo::new(sqlite_repo, "redis://127.0.0.1");

// 3. 最外层包装:加个重试(比如最多尝试 3 次)
let retry_repo = RetryOrderRepo::new(cached_repo, 3);

// 4. 注入业务层
let service = OrderService::new(retry_repo);

// 运行
let my_order = Order { id: 999, price: 150.0 };
if let Err(e) = service.create_order(my_order).await {
println!("最终执行失败: {}", e);
} else {
println!("最终执行成功!");
}
}

3.3 套娃顺序的讲究

  1. Retry 包裹 Cache (Retry(Cache(DB))):

    • 语义:如果”写DB+写缓存”整体失败了,就重试整个过程。
    • 适用场景:你希望保证缓存和数据库最终一致。
  2. Cache 包裹 Retry (Cache(Retry(DB))):

    • 语义:重试只针对 DB 写入。只有 DB 真的写成功了,才去写一次缓存。
    • 适用场景:你认为缓存写入通常不会失败,只有 DB 写入需要重试。
  3. Logging 包裹一切 (Log(Retry(Cache(DB)))):

    • 语义:记录最外层的请求和最终结果。

四、防腐层与短信服务

这个例子展示了架构设计中极其重要的 “防腐层”(Anti-Corruption Layer, ACL) 概念。

4.1 定义短信契约与业务层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
use async_trait::async_trait;

// --- 1. 定义契约 (What) ---
#[async_trait]
pub trait SmsService: Send + Sync {
async fn send(&self, phone: &str, code: &str) -> Result<(), String>;
}

// --- 2. 业务逻辑只依赖契约 ---
pub struct UserService<S: SmsService> {
sms: S, // 也可以用 Box<dyn SmsService> 支持运行时切换
}

impl<S: SmsService> UserService<S> {
pub fn new(sms: S) -> Self { Self { sms } }

pub async fn register(&self, phone: &str) -> Result<(), String> {
println!("[业务逻辑] 开始注册用户: {}", phone);
let code = "123456"; // 模拟生成验证码

// 调用契约,不关心背后是哪家云厂商
self.sms.send(phone, code).await?;

println!("[业务逻辑] 注册成功!");
Ok(())
}
}

4.2 多种发送方式实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 实现 A:腾讯云 (生产环境)
pub struct TencentSms { api_key: String }
#[async_trait]
impl SmsService for TencentSms {
async fn send(&self, phone: &str, code: &str) -> Result<(), String> {
println!(" [腾讯云] 调用 SDK 发送短信至 {},验证码:{}", phone, code);
Ok(())
}
}

// 实现 B:阿里云 (备用环境)
pub struct AliyunSms { secret_id: String }
#[async_trait]
impl SmsService for AliyunSms {
async fn send(&self, phone: &str, code: &str) -> Result<(), String> {
println!(" [阿里云] 调用 SDK 发送短信至 {},验证码:{}", phone, code);
Ok(())
}
}

// 实现 C:日志短信 (本地开发/单元测试)
pub struct LogSms;
#[async_trait]
impl SmsService for LogSms {
async fn send(&self, phone: &str, code: &str) -> Result<(), String> {
// 不花钱,不连网,只打日志
println!(" [本地模拟] 模拟发送短信至 {},验证码:{}", phone, code);
Ok(())
}
}

4.3 不同场景的组装

1
2
3
4
5
6
7
8
9
10
11
12
#[tokio::main]
async fn main() {
// 场景 1:本地开发
// 只需要传入 LogSms,不需要配置任何云账号,不需要联网
let dev_service = UserService::new(LogSms);
dev_service.register("13800138000").await.unwrap();

// 场景 2:线上生产(使用腾讯云)
let prod_sms = TencentSms { api_key: "TX_123".to_string() };
let prod_service = UserService::new(prod_sms);
prod_service.register("13911112222").await.unwrap();
}

4.4 深度价值

4.4.1 彻底解决”测试地狱”

如果没有这个 Trait,你写单元测试时必须处理腾讯云 SDK 的初始化、网络连接等。
有了 Trait,你在测试文件里写一个 struct TestSms,在 send 方法里直接 return Ok(())你的测试运行速度会从秒级降至毫秒级,且不消耗任何短信资费。

4.4.2 实现防腐层 (Anti-Corruption)

腾讯云 SDK 的 API 设计可能很古怪。如果你直接在 UserService 里用,你的业务逻辑就被腾讯的 API 风格”污染”了。

  • Trait 就像一堵墙:墙内是纯粹的业务语言(phone, code),墙外是肮脏的第三方 SDK 细节。
  • 更换供应商:换阿里云时,你只需要写一个新的 AliyunSms 实现类,业务代码一行都不用动。

4.5 运行时容灾切换

你可以利用 Rust 的 dyn SmsService 实现一个”智能切换器”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pub struct FailoverSms {
primary: TencentSms,
secondary: AliyunSms,
}

#[async_trait]
impl SmsService for FailoverSms {
async fn send(&self, phone: &str, code: &str) -> Result<(), String> {
// 先尝试腾讯云
match self.primary.send(phone, code).await {
Ok(_) => Ok(()),
Err(_) => {
// 如果腾讯云挂了,自动切换到阿里云
println!("!!! 腾讯云异常,正在切换到阿里云备用通道...");
self.secondary.send(phone, code).await
}
}
}
}

这种强大的容灾能力,对于业务层 UserService 来说是完全透明的! 业务层只管调 send,它根本不知道背后发生了惊心动魄的线路切换。

0%