用 Rust 的类型系统,搭一个诊断平台[RustTraining]

用 Rust 的类型系统,搭一个诊断平台

很多人第一次接触 Rust,会把注意力放在所有权、生命周期、Resulttrait 这些语言特性上。
但如果只停在这里,其实只学到了 Rust 的“语法层”。

Rust 真正厉害的地方,是你可以把很多业务约束直接编码进类型系统里,让一部分错误根本写不出来。

这篇文章想讨论一个非常实用的问题:

如果我们要写一个服务器诊断平台,怎么用 Rust 的类型系统,把认证、会话状态、命令响应、数据校验、寄存器宽度这些规则都放进编译期?

这正是微软那本 Type-Driven Correctness in Rust 在第 10 章做的事情。它把前面几章分散的模式组合起来,搭出了一个完整的诊断工作流:

  1. 认证拿到能力令牌
  2. 打开会话并进入激活状态
  3. 发送类型安全的命令
  4. 用一次性 token 做审计
  5. 返回带单位的结果
  6. 校验 FRU 边界数据
  7. 读取带 phantom type 的寄存器

看起来像很多技巧,但它们放在一起,目标其实只有一个:

让错误的使用方式在编译阶段就被排除。


先看目标:我们想防住哪些错误?

假设我们在做一个 IPMI 风格的诊断模块,最常见的问题大概有这些:

  • 没认证就去激活会话
  • 会话还没准备好就发命令
  • 温度和转速都用 f64,结果混着传
  • 收到的原始 FRU 数据还没校验就开始解析
  • 本该读 16 位寄存器,却当成 32 位来读
  • 一次性的审计 token 被重复使用

如果这些约束全靠注释、文档、或者 if 判断来维护,系统一大就会开始漏。

所以更好的办法是:把正确流程写进类型里。


1. 用 typed command 约束“请求决定响应”

很多协议代码会写成这样:

1
fn send_command(cmd: &[u8]) -> io::Result<Vec<u8>>

这种设计的问题是,命令和响应之间没有类型关联。
调用者必须“自己记住”某个命令返回的到底是温度、风扇转速,还是电压。

我们换一种方式,把命令建模成 trait:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::io;

#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Celsius(pub f64);

#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Rpm(pub f64);

pub trait IpmiCmd {
type Response;

fn net_fn(&self) -> u8;
fn cmd_byte(&self) -> u8;
fn payload(&self) -> Vec<u8>;
fn parse_response(&self, raw: &[u8]) -> io::Result<Self::Response>;
}

这里最关键的是这句:

1
type Response;

它把“这个命令对应什么返回值”直接绑定到了类型层。

比如读取温度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pub struct ReadTemp {
pub sensor_id: u8,
}

impl IpmiCmd for ReadTemp {
type Response = Celsius;

fn net_fn(&self) -> u8 { 0x04 }
fn cmd_byte(&self) -> u8 { 0x2D }
fn payload(&self) -> Vec<u8> { vec![self.sensor_id] }

fn parse_response(&self, raw: &[u8]) -> io::Result<Celsius> {
if raw.is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "empty response"));
}
Ok(Celsius(raw[0] as f64))
}
}

再比如读取风扇转速:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pub struct ReadFanSpeed {
pub fan_id: u8,
}

impl IpmiCmd for ReadFanSpeed {
type Response = Rpm;

fn net_fn(&self) -> u8 { 0x04 }
fn cmd_byte(&self) -> u8 { 0x2D }
fn payload(&self) -> Vec<u8> { vec![self.fan_id] }

fn parse_response(&self, raw: &[u8]) -> io::Result<Rpm> {
if raw.len() < 2 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "need 2 bytes"));
}
Ok(Rpm(u16::from_le_bytes([raw[0], raw[1]]) as f64))
}
}

这样设计之后,调用方拿到的类型是编译器推出来的,而不是靠人脑记忆。


2. 用 capability token 表达“你有没有资格做这件事”

很多系统喜欢这么写权限控制:

1
fn activate_session(is_admin: bool) -> Result<(), Error>

问题是 bool 太弱了。
它没有任何上下文,也不能证明这个权限是怎么来的。

我们可以把“管理员权限”变成一个能力令牌

1
2
3
4
5
6
7
8
9
10
11
pub struct AdminToken {
_private: (),
}

pub fn authenticate(user: &str, pass: &str) -> Result<AdminToken, &'static str> {
if user == "admin" && pass == "secret" {
Ok(AdminToken { _private: () })
} else {
Err("authentication failed")
}
}

AdminToken 不能随便构造,只能通过 authenticate() 拿到。
这就意味着后续 API 可以直接要求它:

1
2
3
fn do_sensitive_thing(_admin: &AdminToken) {
// ...
}

如果手里没有 AdminToken,这段代码连调用资格都没有。


3. 用 typestate 限制“什么状态下能做什么事”

协议会话天然就是状态机。
比如一个 IPMI 会话至少会经历:

  • Idle
  • Active

如果你把所有操作都挂在一个 Session 上,然后运行时判断当前状态,代码很容易写出非法调用顺序。

更好的做法是:把状态放进类型参数。

1
2
3
4
5
6
7
8
9
use std::marker::PhantomData;

pub struct Idle;
pub struct Active;

pub struct Session<State> {
host: String,
_state: PhantomData<State>,
}

连接之后先得到 Session<Idle>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
impl Session<Idle> {
pub fn connect(host: &str) -> Self {
Session {
host: host.to_string(),
_state: PhantomData,
}
}

pub fn activate(self, _admin: &AdminToken) -> Result<Session<Active>, String> {
println!("Session activated on {}", self.host);
Ok(Session {
host: self.host,
_state: PhantomData,
})
}
}

真正执行命令的方法,只存在于 Session<Active> 上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
impl Session<Active> {
pub fn execute<C: IpmiCmd>(&mut self, cmd: &C) -> io::Result<C::Response> {
let raw = self.raw_send(cmd.net_fn(), cmd.cmd_byte(), &cmd.payload())?;
cmd.parse_response(&raw)
}

fn raw_send(&self, _nf: u8, _cmd: u8, _data: &[u8]) -> io::Result<Vec<u8>> {
Ok(vec![42, 0x1E])
}

pub fn close(self) {
println!("Session closed");
}
}

这意味着下面这种错误在编译期就会被挡住:

1
2
let mut session = Session::<Idle>::connect("192.168.1.100");
// session.execute(&ReadTemp { sensor_id: 0 }); // 编译不过

因为 execute() 根本不属于 Session<Idle>


4. 用 newtype 表达“单位不同就是不同类型”

很多监控/诊断系统里最危险的一类 bug,不是崩溃,而是数值语义混淆

比如:

  • 温度是摄氏度
  • 风扇是转速 RPM
  • 电压是 Volt

如果全都用 f64,编译器根本分不清。

我们用简单的 newtype 包起来:

1
2
3
4
5
6
7
8
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Celsius(pub f64);

#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Rpm(pub f64);

#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Volts(pub f64);

这看起来只是多包了一层,但编译器会立刻帮你防住类型串线:

1
2
3
let temp: Celsius = session.execute(&ReadTemp { sensor_id: 0 })?;
// let wrong: Volts = session.execute(&ReadTemp { sensor_id: 0 })?;
// error: expected Volts, found Celsius

这就是“让单位进入类型系统”的价值。


5. 用单次消费类型保证审计 token 不能复用

有些资源天生就应该“一次性使用”。
比如一个审计事件 token,要求每次诊断只写入一次,不允许复用。

Rust 的 move 语义特别适合表达这种约束:

1
2
3
4
5
6
7
8
9
10
11
12
13
pub struct AuditToken {
run_id: u64,
}

impl AuditToken {
pub fn issue(run_id: u64) -> Self {
AuditToken { run_id }
}

pub fn log(self, message: &str) {
println!("[AUDIT run_id={}] {}", self.run_id, message);
}
}

注意 log(self, ...) 会消费掉 self
所以一旦调用完成,这个 token 就不能再用了:

1
2
3
let audit = AuditToken::issue(1001);
audit.log("diagnostic report written");
// audit.log("write again"); // 编译不过,value moved

这种“只能发生一次”的约束,不需要额外 runtime flag,所有权系统天然就能表达。


6. 用 validated boundary 把“不可信输入”拦在边界

FRU、网络包、磁盘块、用户输入,这些外部数据都不应该直接进业务逻辑。
更好的模式是:

原始数据先校验,校验通过后才变成领域对象。

比如一个简化版 FRU:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pub struct ValidFru {
pub board_serial: String,
pub product_name: String,
}

impl ValidFru {
pub fn parse(raw: &[u8]) -> Result<Self, &'static str> {
if raw.len() < 8 {
return Err("FRU too short");
}

if raw[0] != 0x01 {
return Err("bad FRU version");
}

Ok(ValidFru {
board_serial: "SN12345".to_string(),
product_name: "ServerX".to_string(),
})
}
}

调用方式也很清楚:

1
2
let raw_fru = vec![0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0xFD];
let fru = ValidFru::parse(&raw_fru)?;

重点不在于这个 demo 校验有多复杂,而在于:
后续代码拿到的是 ValidFru,不是随便一段 &[u8]

也就是说,“校验过没有”不再靠注释,而是靠类型表达。


7. 用 phantom type 表达“运行时没有,但语义上很重要”的信息

有些信息不会体现在结构体字段里,但对 API 正确性至关重要。
寄存器宽度就是一个很典型的例子。

我们可以这样建模:

1
2
3
4
5
6
7
8
9
10
11
12
pub struct Width16;

pub struct Reg<W> {
offset: u16,
_w: PhantomData<W>,
}

impl Reg<Width16> {
pub fn read(&self) -> u16 {
0x8086
}
}

Width16 不占运行时空间,但它告诉编译器:
这个寄存器就是 16 位的,所以 read() 的返回值应该是 u16

把它放进设备模型里:

1
2
3
4
5
6
7
8
9
10
11
12
13
pub struct PcieDev {
pub vendor_id: Reg<Width16>,
pub device_id: Reg<Width16>,
}

impl PcieDev {
pub fn new() -> Self {
PcieDev {
vendor_id: Reg { offset: 0x00, _w: PhantomData },
device_id: Reg { offset: 0x02, _w: PhantomData },
}
}
}

这样读取时就不会“忘了这个寄存器本来多宽”。


8. 把这些模式拼起来:一个完整的诊断流程

前面每个点单独看都不算复杂,真正有意思的是它们组合起来之后会变成什么样。

下面是一个简化后的完整流程:

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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
use std::io;
use std::marker::PhantomData;

#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Celsius(pub f64);

#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Rpm(pub f64);

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

pub struct ReadTemp {
pub sensor_id: u8,
}

impl IpmiCmd for ReadTemp {
type Response = Celsius;

fn net_fn(&self) -> u8 { 0x04 }
fn cmd_byte(&self) -> u8 { 0x2D }
fn payload(&self) -> Vec<u8> { vec![self.sensor_id] }

fn parse_response(&self, raw: &[u8]) -> io::Result<Celsius> {
if raw.is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "empty"));
}
Ok(Celsius(raw[0] as f64))
}
}

pub struct ReadFanSpeed {
pub fan_id: u8,
}

impl IpmiCmd for ReadFanSpeed {
type Response = Rpm;

fn net_fn(&self) -> u8 { 0x04 }
fn cmd_byte(&self) -> u8 { 0x2D }
fn payload(&self) -> Vec<u8> { vec![self.fan_id] }

fn parse_response(&self, raw: &[u8]) -> io::Result<Rpm> {
if raw.len() < 2 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "need 2 bytes"));
}
Ok(Rpm(u16::from_le_bytes([raw[0], raw[1]]) as f64))
}
}

pub struct AdminToken {
_private: (),
}

pub fn authenticate(user: &str, pass: &str) -> Result<AdminToken, &'static str> {
if user == "admin" && pass == "secret" {
Ok(AdminToken { _private: () })
} else {
Err("authentication failed")
}
}

pub struct Idle;
pub struct Active;

pub struct Session<State> {
host: String,
_state: PhantomData<State>,
}

impl Session<Idle> {
pub fn connect(host: &str) -> Self {
Session {
host: host.to_string(),
_state: PhantomData,
}
}

pub fn activate(self, _admin: &AdminToken) -> Result<Session<Active>, String> {
Ok(Session {
host: self.host,
_state: PhantomData,
})
}
}

impl Session<Active> {
pub fn execute<C: IpmiCmd>(&mut self, cmd: &C) -> io::Result<C::Response> {
let raw = self.raw_send(cmd.net_fn(), cmd.cmd_byte(), &cmd.payload())?;
cmd.parse_response(&raw)
}

fn raw_send(&self, _nf: u8, _cmd: u8, _data: &[u8]) -> io::Result<Vec<u8>> {
Ok(vec![42, 0x1E])
}
}

pub struct AuditToken {
run_id: u64,
}

impl AuditToken {
pub fn issue(run_id: u64) -> Self {
AuditToken { run_id }
}

pub fn log(self, message: &str) {
println!("[AUDIT run_id={}] {}", self.run_id, message);
}
}

pub struct ValidFru {
pub board_serial: String,
pub product_name: String,
}

impl ValidFru {
pub fn parse(raw: &[u8]) -> Result<Self, &'static str> {
if raw.len() < 8 {
return Err("FRU too short");
}
if raw[0] != 0x01 {
return Err("bad FRU version");
}

Ok(ValidFru {
board_serial: "SN12345".to_string(),
product_name: "ServerX".to_string(),
})
}
}

pub struct Width16;

pub struct Reg<W> {
offset: u16,
_w: PhantomData<W>,
}

impl Reg<Width16> {
pub fn read(&self) -> u16 {
0x8086
}
}

pub struct PcieDev {
pub vendor_id: Reg<Width16>,
pub device_id: Reg<Width16>,
}

impl PcieDev {
pub fn new() -> Self {
PcieDev {
vendor_id: Reg { offset: 0x00, _w: PhantomData },
device_id: Reg { offset: 0x02, _w: PhantomData },
}
}
}

fn full_diagnostic() -> Result<(), String> {
let admin = authenticate("admin", "secret").map_err(|e| e.to_string())?;

let session = Session::<Idle>::connect("192.168.1.100");
let mut session = session.activate(&admin)?;

let temp: Celsius = session.execute(&ReadTemp { sensor_id: 0 })
.map_err(|e| e.to_string())?;

let fan: Rpm = session.execute(&ReadFanSpeed { fan_id: 1 })
.map_err(|e| e.to_string())?;

let pcie = PcieDev::new();
let vendor_id = pcie.vendor_id.read();

let raw_fru = vec![0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0xFD];
let fru = ValidFru::parse(&raw_fru).map_err(|e| e.to_string())?;

let audit = AuditToken::issue(1001);

let report = format!(
"Server: {} (SN: {}), VID: 0x{:04X}, Temp: {:?}, Fan: {:?}",
fru.product_name,
fru.board_serial,
vendor_id,
temp,
fan,
);

audit.log(&report);

Ok(())
}

这段代码最有意思的地方,不是“它能跑”,而是它在编译期已经帮我们排除了很多非法情况。


9. 编译器到底替我们证明了什么?

把上面的设计拆开看,编译器至少能帮我们兜住这几类错误:

错误类型 怎么被防住 对应模式
未认证就激活会话 activate() 必须拿 &AdminToken capability token
会话状态不对就发命令 execute() 只存在于 Session<Active> typestate
命令返回值写错类型 IpmiCmd::Response 绑定响应类型 typed command
温度/转速/电压混用 CelsiusRpmVolts 是不同类型 dimensional type
寄存器宽度读错 Reg<Width16> 只能读出 u16 phantom type
未校验原始数据直接使用 parse() 才能得到 ValidFru validated boundary
审计 token 被复用 log(self, ...) 消费所有权 single-use type

这也是这类设计最打动人的地方:

不是“多写了几层抽象”,而是把一堆原本只能靠测试和经验发现的问题,前移到了编译期。


10. 这类设计的成本和收益

这种写法当然不是零成本的。
你需要多定义一些类型,多思考状态边界,也要接受一开始 API 看起来会比“直接传 u8Vec<u8>”更啰嗦。

但它带来的收益非常适合基础设施和协议类系统:

  • API 更难被误用
  • 重构时更有信心
  • 单元测试压力下降
  • 文档和代码更一致
  • 很多 bug 根本进入不了运行时

如果你的系统有这些特点,这种设计会特别值:

  • 协议有明确状态流转
  • 外部输入不可信
  • 数值单位很多
  • 权限控制严格
  • 某些资源只能单次使用
  • 错误代价很高

结语

很多文章会把 Rust 的优势概括成“内存安全”。
但对于工程系统来说,更实用的一层是:

Rust 可以让你把业务规则、协议约束、单位语义和权限边界,一起编码进类型系统。