前言
上期结尾我说下一期聊工程化实践。这期就来真的。
我先坦白一下第八期写完时的项目状态。
src/main.rs,497 行。UI 描述、事件处理、文件读写、配置保存、HTTP 请求、错误处理,全部塞在一个文件里。unwrap 出现了 11 次,expect 出现了 6 次。调试靠 println!("到这里了") 和 println!("content length: {}", content.len())。
功能跑通了。但如果有人让我把这个项目交接给他维护,我会心虚。
这期就是解决这个心虚。不讲 API,讲三件事:怎么拆模块、怎么处理错误、怎么加日志。做完之后,同样的功能,项目的可维护性会从"只能自己改"变成"别人也能接手"。
1. 模块拆分:不是随便拆,是按"谁会变"拆
1.1 拆分前长什么样
原来的 main.rs 大概是这样的结构:
rust
// main.rs --- 497 行
script_mod!(mod json_viewer {
// ===== 设计变量 (40 行) =====
color_primary: ...,
font_size_title: ...,
// ===== 数据结构 (80 行) =====
json_root: Option<JsonNode>,
all_nodes: Vec<JsonNode>,
expanded_nodes: HashSet<String>,
// ...
// ===== UI 描述 (120 行) =====
main_window := Window { ... },
// ===== 事件处理 (150 行) =====
#[event] fn handle_open_file(...) { ... }
#[event] fn handle_tree_click(...) { ... }
// ...
// ===== 文件读写 (50 行) =====
fn load_file(...) { ... }
fn save_config(...) { ... }
// ===== HTTP 请求 (40 行) =====
fn check_update(...) { ... }
// ===== 工具函数 (17 行) =====
fn get_modified_time(...) { ... }
});
所有东西混在一起。想改配置文件格式?要在 500 行里找"config 相关的代码在哪一段"。加一个新按钮?要去 120 行的 UI 描述里找到底在哪个嵌套层级。
1.2 怎么拆
拆模块不是平均分,是按"什么会一起变"来拆。标准就是一句话:改一个需求时,只动一个文件。
我的拆法是:
text
src/
├── main.rs (30 行) --- 入口,只做 script_mod! 宏调用和初始化
├── ui.rs (180 行) --- UI 描述 + 事件处理
├── model.rs (150 行) --- 数据结构和业务逻辑(解析、展开折叠、搜索)
├── config.rs (80 行) --- 配置的读写和 AppConfig 结构
├── api.rs (60 行) --- HTTP 请求相关
├── theme.rs (40 行) --- 设计变量(颜色、字号、间距)
└── error.rs (30 行) --- 统一错误类型
每个文件的职责:
main.rs:只保留script_mod!宏调用和#[run]初始化。清楚到"打开这个文件就知道应用怎么启动"。ui.rs:Makepad 的 UI 描述 DSL 和所有#[event]函数。改界面只动这个文件。model.rs:JsonNode数据结构、JSON 解析、展开折叠逻辑、搜索匹配。不依赖 Makepad,纯 Rust 逻辑。config.rs:AppConfig结构体、load()、save()。改配置格式只动这里。api.rs:HTTP 请求的封装。换 HTTP 库只动这里。theme.rs:所有颜色、字号、间距常量。调主题只动这里。error.rs:AppError枚举和Display实现。加新错误类型只动这里。
1.3 model.rs 为什么独立出来
有一个很重要的细节:model.rs 不依赖 Makepad。
这意味着它的代码可以在 Makepad 的事件循环之外测试。你可以直接用 cargo test 跑 JSON 解析、搜索匹配、展开折叠逻辑,不需要启动窗口。
rust
// model.rs --- 不依赖 Makepad,可单独测试
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_search_nodes() {
let node = JsonNode::parse(r#"{"name": "test", "value": 42}"#).unwrap();
let results = search_nodes(&node, "test");
assert_eq!(results.len(), 2); // key 和 value 各匹配一次
}
}
这种"把纯逻辑抽出来"的思路,在任何一个 UI 框架里都适用。UI 层只做展示和事件分发,数据结构和业务逻辑独立。
2. 错误处理:从 panic 到 AppError
2.1 原来的样子
原来项目里错误处理的写法:
rust
// ❌ 随处可见的 unwrap
let content = std::fs::read_to_string(path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
let config = AppConfig::load().expect("配置加载失败");
崩了就是崩了。用户看到的是 Rust 的 panic 信息和一个白屏。你甚至不知道是哪一行崩的。
2.2 自定义 AppError
我做了一个最小的错误类型:
rust
// error.rs
use std::fmt;
#[derive(Debug)]
pub enum AppError {
FileRead { path: String, reason: String },
FileWrite { path: String, reason: String },
JsonParse { reason: String },
HttpRequest { url: String, reason: String },
ConfigLoad { reason: String },
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::FileRead { path, reason } => {
write!(f, "文件读取失败: {} ({})", path, reason)
}
AppError::FileWrite { path, reason } => {
write!(f, "文件写入失败: {} ({})", path, reason)
}
AppError::JsonParse { reason } => {
write!(f, "JSON 解析失败: {}", reason)
}
AppError::HttpRequest { url, reason } => {
write!(f, "网络请求失败: {} ({})", url, reason)
}
AppError::ConfigLoad { reason } => {
write!(f, "配置加载失败: {}", reason)
}
}
}
}
每个错误变体都带了足够的上下文。FileRead 有路径和原因,HttpRequest 有 URL 和原因。出问题的时候不用猜。
2.3 在应用层统一处理
Makepad 里不能直接 propagate error(没有 ? 到顶层),所以在每个 #[event] 函数里调用一个统一的错误展示方法:
rust
fn show_error(&mut self, cx: &mut Cx, error: &AppError) {
self.status_message = error.to_string();
self.status_is_error = true;
log::error!("{}", error);
self.ui.redraw(cx);
}
fn load_file(&mut self, cx: &mut Cx, path: &str) {
match std::fs::read_to_string(path) {
Ok(content) => {
self.file_content = content;
self.status_message = format!("已加载: {}", path);
}
Err(e) => {
self.show_error(cx, &AppError::FileRead {
path: path.to_string(),
reason: e.to_string(),
});
}
}
}
用户看到的是"文件读取失败: /home/me/data.json (No such file or directory)",而不是 Rust 堆栈。
3. 日志:从 println 到结构化日志
3.1 println 为什么不够
println! 有几个致命问题:
- 没有级别区分。开发时想看的调试信息和生产环境该打的错误混在一起
- 没有时间戳。你不知道这个事件是什么时候发生的
- 输出到 stdout,不是 stderr,重定向的时候会丢
3.2 换 env_logger
最简单的方案:
toml
[dependencies]
log = "0.4"
env_logger = "0.11"
在 main.rs 入口初始化:
rust
#[run]
fn main(&mut self, cx: &mut Cx) -> Result<(), String> {
env_logger::init();
log::info!("应用启动");
self.config = AppConfig::load();
log::info!("配置加载完成: {} 个最近文件", self.config.recent_files.len());
// ...
}
在代码里替换所有 println:
rust
// ❌ 之前
println!("打开文件: {}", path);
println!("解析失败!");
// ✅ 之后
log::debug!("打开文件: {}", path);
log::error!("JSON 解析失败: {}", e);
然后通过环境变量控制级别:
bash
# 开发时
RUST_LOG=debug cargo run
# 生产发布
RUST_LOG=error ./my-app
3.3 日志该打什么
一个经验法则:
error!--- 用户能感知的失败(文件打不开、请求失败、配置损坏)warn!--- 不应该发生但程序能自己恢复的情况(配置文件格式不对但用了默认值)info!--- 重要的状态变化(应用启动、文件成功加载、文件保存)debug!--- 开发调试信息(函数调用参数、中间计算结果)
别到处打 info!,也别只在关键路径打 error!。一个健康的日志文件,info 和 debug 是主体,error 和 warn 很少出现。
4. 还有一些顺手该做的事
4.1 Cargo.toml 整理
原来我的依赖是东加一个西加一个,版本号有的写了有的没写。整理后:
toml
[package]
name = "json-viewer"
version = "0.1.0"
edition = "2021"
description = "一个简单的 JSON 查看器"
repository = "https://github.com/xxx/json-viewer"
license = "MIT"
[dependencies]
makepad-widgets = { git = "https://github.com/makepad/makepad", branch = "dev" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["blocking"] }
dirs = "5"
log = "0.4"
env_logger = "0.11"
每个依赖版本明确,写了 description 和 license。这不是形式主义------当别人 clone 你的项目时,cargo build 能一次成功,不会因为没写 version 导致 resolve 失败。
4.2 加一个 .gitignore
text
/target
*.log
*.swp
.DS_Store
三行,但省掉很多尴尬。你不会想在 commit history 里看到 target 目录下的编译产物。
4.3 README 补一段"怎么跑"
markdown
## 运行
git clone https://github.com/xxx/json-viewer
cd json-viewer
cargo run --release
## 调试
RUST_LOG=debug cargo run
别人(包括三个月后的你自己)clone 下来,看一眼 README 就能跑起来。连这都没有的项目,再好的代码也打折扣。
5. 拆分前后的真实对比
我记录了一下拆分前后的实际数据:
| 维度 | 拆分前 | 拆分后 |
|---|---|---|
| 单文件最大行数 | 497 | 180 |
| unwrap/expect 数量 | 17 | 0 |
| 调试输出方式 | println! | env_logger |
| 加一个新功能平均耗时 | ~30 分钟 | ~10 分钟 |
| 你愿意给别人看吗 | 不太敢 | 还行 |
数字可能因人而异,但方向是确定的:工程化不是额外的工作量,是把后面省出来的时间提前花了。
这 17 个 unwrap 改掉之后,我至少避免了三次 release 构建时的 panic。每次 panic 不致命------毕竟只是个 JSON 查看器------但每次都会让用户觉得"这东西不稳定"。
6. 什么时候开始做工程化
很多人会问"什么时候该开始拆模块、加错误处理"。
我的回答:写到第三个功能模块的时候。
第一个功能(比如打开文件),先让它在 main.rs 里跑通。第二个功能(比如树形展示)加上去,main.rs 到了 200 行,还能忍。第三个功能(比如搜索)加上去,main.rs 到了 350 行------这时候停下来,趁你还记得每段代码是干什么的,立刻拆。
别等项目"做完了"再重构。那时候你已经忘了三个月前 handle_tree_click 里为什么有个奇怪的 if depth > 20 判断。而且堆了 2000 行的 main.rs 再拆,光理解原有代码就要半天。
趁代码还在你脑子里的时候拆。这个时机过了就不回来了。
总结
这一期不教新功能,只做三件让项目能维护的事:
把 500 行的 main.rs 拆成 7 个文件,每个不超过 200 行。改需求时不用在全项目里大海捞针。
把 17 个 unwrap 和 expect 换成一个 AppError 类型。用户看到的是中文提示,不是你调试时写的 panic 信息。
把 println 换成 env_logger。开发时 debug 级别看细节,生产时只打 error,不用重新编译。
这三件事做完,你的项目从"只能自己改"变成了"可以交接"的状态。到了这一步,你写的就不再是一个 demo,而是一个工程。
下一期是系列收官篇:打包、发布与跨平台交付。Windows、macOS、Linux 的构建体验、发布流程和当前生态限制,完整走一遍。