05 数据建模:struct / enum / derive / Option(结合本项目模型)
本章目标
- 你能用 Rust 的类型系统把“业务不变量”表达清楚(避免到处塞字符串和魔法值)。
- 你能读懂并扩展
asset-light的领域模型(Asset / Snapshot / AllocationPlan)。 - 你能理解“稳定编码(code)”与“展示名(display_name)”分离的工程价值。
1. Rust 里怎么“建模”
1.1 struct:描述“一个东西长什么样”
Rust 的 struct 更像是:
- TS:
type Asset = { ... }+ 一些 helper function - Java:POJO + 静态工厂方法 + getter/setter(但 Rust 更倾向不可变/显式修改)
本项目的 Asset 定义在:src/models/asset.rs
它把“资产条目”拆成多个正交维度:
scope: AssetScope:口径(是否纳入再平衡)category: Category:资产大类(风险暴露维度)sub_asset_class: String:子资产类别稳定编码(例如EQ_BROAD)vehicle_type: VehicleType:工具类型(ETF/基金/股票等“持有载体”)
这四个字段非常关键:它们让 UI/分析/DB 都可以围绕同一套“结构化语义”工作。
1.2 enum:描述“一个东西只能是这几种之一”
Rust 的 enum 是“可穷举的联合类型”(Sum Type):
- TS:
type Category = "CASH" | "FI" | ...(但 TS 运行时还是字符串) - Java:
enum Category { ... }
Rust 的强项在于:match 必须覆盖所有分支,使得“漏处理”在编译期就能被抓住。
本项目大量使用 enum:
src/models/category.rs:Categorysrc/models/asset_scope.rs:AssetScopesrc/models/vehicle_type.rs:VehicleType
1.3 derive:把“样板代码”交给编译器生成
你会在模型上看到非常多的 #[derive(...)]:
Debug:方便打印/调试(println!("{:?}", x))Clone:允许显式复制(UI 层经常需要)Copy:轻量值语义复制(常用于小枚举,如Category)PartialEq/Eq/Hash:用于比较、作为 HashMap key 等Serialize/Deserialize:与 serde 结合做序列化(未来做导出/导入会很有用)
例子:Category 是 Copy 的(Clone, Copy),而 Asset 不是(包含 String、Decimal 等堆/大对象)。
2. 本项目的建模策略:稳定编码 vs 展示名
2.1 为什么需要稳定编码
UI 展示名可能会变(文案、翻译、缩写),但数据库里存的数据应该尽量稳定。
因此本项目通常采用:
- DB 存:稳定编码(
CASH/FI/EQ_BROAD/ETF…) - UI 展示:中文名/图标/颜色(
display_name()/icon()/color())
示例:Category 同时提供:
as_str():稳定编码(持久化)from_str():从编码解析(读库/兼容旧数据)display_name():展示名(UI)
对应实现见:src/models/category.rs
2.2 “子资产类别”为什么在 Asset 里是 String
你会注意到:
- 子资产类别标准枚举在:
src/models/asset_sub_category.rs(AssetSubCategory) - 但
Asset.sub_asset_class是String
这是一个典型工程权衡:
用 String 存储稳定编码让系统更“前向兼容”(未来增加子类别不必立刻升级所有数据结构),同时仍然可以用 AssetSubCategory 提供:
- 编码全集(
all()) - 按大类枚举(
all_for_category()) - 从 code 解析(
from_code()) - 校验编码属于哪个大类(
is_valid_for_category())
你可以把它理解为:“DB 里是稳定字符串协议,代码里用 enum 提供字典与校验”。
3. 结合代码:读懂 Asset / Snapshot / Plan
3.1 Asset(资产条目)
文件:src/models/asset.rs
重点:
Asset::new(...)是“构造器/工厂方法”,统一初始化id/created_at/updated_atnotes: Option<String>明确表达“可能没有备注”
典型构造方式(见测试代码):
- 子资产类别:
AssetSubCategory::CashMmf.code().to_string() - 金额:用
rust_decimal_macros::dec!()避免浮点误差
3.2 Snapshot(盘点快照)
文件:src/models/snapshot.rs
关键设计:
SnapshotItem会冗余存储asset_name/scope/category/sub_asset_class/vehicle_type
这样即使未来资产条目被改名/改分类,历史快照仍然能反映当时状态(可审计)。Snapshot::new(items)自动计算total_value
3.3 AllocationPlan(配置方案)
文件:src/models/plan.rs
关键设计:
Allocation.category: Category用 enum 表达“配置只能基于大类”target_percentage: Decimal表达比例,避免 f64 的误差get_target(&Category)提供便利查询(服务层/页面层都会用)
你可以把 Plan 理解成“目标权重向量”,而资产是“当前权重向量”,偏离分析就是两者差异。
对应偏离计算见:src/services/analysis_service.rs
4. 你需要掌握的建模技巧(从易到难)
4.1 用 enum 替代字符串魔法值
优先让业务状态成为 enum:
- 更可读
- 更可 refactor
- 更少“拼错字符串”的隐患
本项目里 DB 仍然需要字符串编码,但代码内部尽量用 enum 表达语义。
4.2 用 Option 表达“可缺失字段”
不要用空字符串 "" 代表“没有值”。
本项目里:
notes: Option<String>AllocationPlan.description: Option<String>
4.3 用 Display / FromStr 把“协议转换”集中管理
一个很实用的工程习惯:
- 解析/格式化逻辑不要散落在 Repository / UI 里
- 集中在模型自身(例如
Category::from_str、Category::as_str)
当前项目主要使用自定义的 from_str/as_str 方法;进阶阶段你可以把它们升级为实现标准 trait:
impl std::fmt::Display for Categoryimpl std::str::FromStr for Category
(Category 已经实现了 Display)
5. 本章练习(强烈建议做)
练习 A:给 VehicleType 实现 Display
当前 VehicleType 有 display_name(),但没有实现 Display。
你可以在 src/models/vehicle_type.rs 里增加:
impl std::fmt::Display for VehicleType { ... }
目标:让你习惯“把展示逻辑集中在类型上”,UI 里直接 {vehicle_type} 更自然。
练习 B:给 Asset 增加一个轻量校验方法 validate()
在 src/models/asset.rs 增加:
- 校验
sub_asset_class是否属于category(用AssetSubCategory::is_valid_for_category)
这会让你理解:
- 业务不变量写在模型里,而不是到处 scattered if
- 校验失败最好用
Result<(), E>表达(为下一章“错误处理”做铺垫)
练习 C(进阶):把 sub_asset_class: String 包装成 newtype
比如:
pub struct SubAssetClassCode(String);
好处:
- 更明确的语义(避免把任意 String 当成 code)
- 更容易集中实现校验/显示
代价:
- 需要更新 DB 读写与序列化/反序列化逻辑
建议你先做 A/B,熟练后再挑战 C。