14.1 这节课解决什么问题
任何"联网应用"(本课程实战:调用 LLM API、读 SSE 流)都离不开两件事:
javascript
① 发 HTTP 请求并拿响应 → reqwest(Rust 事实标准的 HTTP 客户端,tokio 原生)
② 把 JSON 转成类型 → serde + serde_json(Rust 事实标准的序列化框架)
Rust 没有反射、没有动态类型,JSON↔类型映射完全靠为每个类型手写/派生 的 Serialize/Deserialize 实现完成------这就是为什么第 6-8 课反复铺垫 derive 与 trait。本课把这些线收束成一套日常模板:
arduino
JSON 文本 ──serde_json::from_str──▶ 我的结构体 (反序列化,类型安全地消费 API)
我的结构体 ──serde_json::to_string──▶ JSON 文本 (序列化,构造请求体)
reqwest:get/post/json 一把梭,错误、超时、重试都带上
14.2 serde:类型 ↔ 数据格式的"转换器"
toml
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
14.2.1 最小往返:结构体 ↔ JSON
rust
use serde::{Deserialize, Serialize};
// derive 两个 trait:序列化 + 反序列化
#[derive(Debug, Serialize, Deserialize)]
struct User {
id: u32,
name: String,
email: String,
vip: bool,
}
fn main() -> Result<(), serde_json::Error> {
// ① Rust → JSON 文本
let u = User {
id: 7,
name: String::from("小灵"),
email: String::from("xl@example.com"),
vip: true,
};
let json = serde_json::to_string_pretty(&u)?;
println!("{json}");
// ② JSON 文本 → Rust(字段对不上会在反序列化时报错)
let raw = r#"{"id":7,"name":"小灵","email":"xl@example.com","vip":true}"#;
let back: User = serde_json::from_str(raw)?;
println!("解析回: {} ({})", back.name, back.email);
Ok(())
}
💡 心智:serde 把"结构体的字段名"当 JSON 键名一一对应。字段缺失/类型不匹配 →
from_str返回Err(不会静默吞字段),这正是类型安全的一环。想知道"JSON 到底是啥样"?to_string_pretty打出来看。
14.2.2 应对"对不上的现实":rename / default / skip / flatten
真实 API 的 JSON 跟你的 Rust 命名经常不一致,全靠属性宏对齐:
rust
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct LlmResponse {
#[serde(rename = "id")] // JSON 键叫 id
request_id: String,
#[serde(rename_all = "camelCase")] // 嵌套对象里也全改成驼峰
usage: Usage,
#[serde(default)] // 后端可能不给该字段 → 用 Default
model: String,
#[serde(skip_serializing_if = "Option::is_none")] // None 时序列化里省略
error: Option<LlmError>,
#[serde(flatten)] // 把"多余字段"收进口袋(前后端演进友好)
extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Serialize, Deserialize)]
struct Usage {
prompt_tokens: u32,
completion_tokens: u32,
}
#[derive(Debug, Serialize, Deserialize)]
struct LlmError {
message: String,
}
常用属性速查:
| 属性 | 作用 | 场景 |
|---|---|---|
#[serde(rename = "键")] |
字段↔JSON 键改名 | 蛇形字段对驼峰接口 |
#[serde(rename_all = "camelCase")] |
整块改名规则 | API 全驼峰时最省事 |
#[serde(default)] |
缺字段用 Default | 后端版本演进、可选字段 |
#[serde(skip_serializing_if = "...")] |
满足条件不输出 | None/空 Vec 不写进 JSON |
#[serde(flatten)] |
平铺嵌入/收额外键 | 前向兼容、"未知字段袋" |
#[serde(with = "...")] |
自定义编解码模块 | 时间戳、特殊格式 |
14.2.3 处理"枚举 + 时间戳"两个高频难点的标准姿势
rust
use serde::{Deserialize, Serialize};
// 枚举:字符串型 JSON(如 "pending"/"done")映射到 enum ------ rename_all + 普通变体即可
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Status {
InProgress, // JSON: "in_progress"
Done, // JSON: "done"
}
// 秒级时间戳 → 人类可读:标准库没有自带,实战常用一个工具模块(第 17 课给完整版)
fn main() -> Result<(), serde_json::Error> {
let s: Status = serde_json::from_str(r#""done""#)?;
println!("{s:?}");
// 动态 JSON(不确定结构时):serde_json::Value 兜底
let v: serde_json::Value = serde_json::from_str(r#"{"a":1,"b":[true,null]}"#)?;
println!("a = {}", v["a"]); // 1
println!("b[1] is null: {}", v["b"][1].is_null());
Ok(())
}
💡 工程经验:请求/响应模型永远定义成强类型 struct (宁可多写几个字段定义),
serde_json::Value只做"动态中间态/未知字段袋"。类型错误在编译期/反序列化时暴露,别让Value一路裸奔到业务逻辑(13 课精神同款:能编译期确定的别拖到运行期)。
14.3 reqwest:async HTTP 客户端
toml
[dependencies]
reqwest = { version = "0.12", features = ["json", "rustls-tls"] } # 默认 native-tls,可换 rustls
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
14.3.1 GET + JSON 一把梭
rust
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Todo {
userId: u32,
id: u32,
title: String,
completed: bool,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 简单请求:静态方法一把梭(每次新建连接,简单场景够用)
let todo: Todo = reqwest::get("https://jsonplaceholder.typicode.com/todos/1")
.await?
.json::<Todo>() // 响应体直接反序列化成 Todo
.await?;
println!("{todo:?}");
// 多字段场景用 Builder:Header/Query/超时一条链
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10)) // 全局超时
.user_agent("voice-clone-course/0.1")
.build()?;
let resp = client
.get("https://httpbin.org/json")
.query(&[("format", "pretty"), ("limit", "10")]) // 拼 ?query
.send()
.await?;
println!("状态码: {}", resp.status());
let body = resp.text().await?; // 拿原始文本(诊断时爱用)
println!("前 200 字符: {}", &body[..body.len().min(200)]);
Ok(())
}
14.3.2 POST JSON 请求体(对接 LLM API 的标准动作)
rust
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
struct ChatRequest {
model: String,
messages: Vec<ChatMessage>,
max_tokens: u32,
}
#[derive(Serialize)]
struct ChatMessage {
role: String, // "system" / "user" / "assistant"
content: String,
}
#[derive(Deserialize)]
struct ChatResponse {
choices: Vec<Choice>,
}
#[derive(Deserialize)]
struct Choice {
message: ChatMessage,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
let payload = ChatRequest {
model: String::from("gpt-4o-mini"),
messages: vec![
ChatMessage { role: "system".into(), content: "你是课程助教".into() },
ChatMessage { role: "user".into(), content: "解释一下所有权".into() },
],
max_tokens: 200,
};
let resp = client
.post("https://api.openai.com/v1/chat/completions")
// .bearer_auth(api_key) // 或用 Header::AUTHORIZATION 手动塞
.json(&payload) // 自动序列化 + Content-Type: application/json
.send()
.await?;
if !resp.status().is_success() {
let text = resp.text().await?;
// LLM API 常返回结构化错误,实战会专门反序列化成 ApiError
return Err(format!("请求失败 {}: {text}", resp.status()).into());
}
let chat: ChatResponse = resp.json().await?;
if let Some(choice) = chat.choices.first() {
println!("回答: {}", choice.message.content);
}
Ok(())
}
14.3.3 错误处理:超时、重试与状态码检查
rust
use anyhow::{anyhow, Context, Result};
async fn fetch_with_retry(client: &reqwest::Client, url: &str, retries: u32) -> Result<String> {
for attempt in 0..=retries {
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
client.get(url).send(),
)
.await;
match result {
Ok(Ok(resp)) => {
if resp.status().is_success() {
return resp.text().await.context("读取响应体");
}
// 5xx 可重试,4xx 是客户端错误别重试
if !resp.status().is_server_error() {
return Err(anyhow!("HTTP {}", resp.status()));
}
}
Ok(Err(e)) => return Err(anyhow!("请求错误: {e}")),
Err(_) => eprintln!("第 {attempt} 次尝试超时"),
}
tokio::time::sleep(std::time::Duration::from_millis(200 * (attempt + 1))).await;
}
Err(anyhow!("重试 {retries} 次仍失败"))
}
💡 铁律:超时永远要设 (Client 级 + 单次 timeout 双层),外部 API 没有不超时的道理。对 5xx 做退避重试、对 4xx 直接抛错------这是所有 HTTP 客户端的通行做法,17 课给 LLM 桥接层时会封装成中间件。
14.4 实战融合:离线可练(无网络也能跑)
14.4.1 离线也能练 serde:本地 JSON 文件
rust
use serde::{Deserialize, Serialize};
use std::fs;
#[derive(Debug, Serialize, Deserialize)]
struct ChatHistory {
title: String,
messages: Vec<HistoryMessage>,
}
#[derive(Debug, Serialize, Deserialize)]
struct HistoryMessage {
role: String,
content: String,
created_at: u64,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// 随便写一份"mock LLM 会话记录"存本地再读
let sample = r#"{
"title": "Rust 学习",
"messages": [
{"role": "user", "content": "什么是所有权", "created_at": 1735000000},
{"role": "assistant", "content": "一句话:每个值只有一个所有者", "created_at": 1735000030}
]
}"#;
fs::write("history.json", sample)?;
let raw = fs::read_to_string("history.json")?;
let hist: ChatHistory = serde_json::from_str(&raw)?;
println!("会话「{}」共 {} 条", hist.title, hist.messages.len());
// 再序列化回文件(round-trip 自检)
fs::write("history_roundtrip.json", serde_json::to_string_pretty(&hist)?)?;
Ok(())
}
💡 没有外网时的完整替代:用
std::io::Read/fs读本地 mock JSON → serde 解析 → 后续所有"消费模型"逻辑都能练。等有网了把"读文件"换成client.get(url)即可无缝切换------模型层与 IO 层解耦,这也是 17 课架构的核心思想。
14.4.2 有网时完整串联
用 https://httpbin.org/json(公开测试端点)或自建 python3 -m http.server + 静态 JSON 都行。优先本地可控端点,CI/离线都能跑。最小演练:
bash
# 终端 1:起一个随时可用的本地 JSON 服务(Python)
mkdir -p mock && echo '{"greeting":"hello","list":[1,2,3]}' > mock/data.json
cd mock && python3 -m http.server 8000
rust
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 终端 2:请求本地服务
let body = reqwest::get("http://127.0.0.1:8000/data.json")
.await?
.text()
.await?;
println!("{body}");
Ok(())
}
14.5 读报错专项(本课高频三类)
| 现象 | 原因 | 修法 |
|---|---|---|
reqwest::Error 网络层失败 |
连接不上/超时/DNS | 确认地址、设 timeout、看 is_timeout()/is_connect() |
serde_json::Error: missing field ... at line .. column .. |
JSON 缺字段/类型不匹配 | 打印原始 JSON 对比模型,补 #[serde(default)] 或修正字段名 |
cannot find type / derive 报错 |
忘加 features = ["derive"] |
serde = { version = "1", features = ["derive"] } |
14.6 📝 动手练习
参考实现放 code/14-http-json/(写作时同步给出)。
- serde 基础往返 :定义
struct Profile { nickname: String, level: u8, tags: Vec<String>, joined: Option<String> },来回转换并断言相等;故意删掉 JSON 里一个字段观察missing field报错。 - 属性对齐 :写一个"字段是 snake_case、JSON 是 camelCase"的接口模型,用
#[serde(rename_all = "camelCase")]+ 部分#[serde(rename)]完成双向转换。 - default + flatten :设计
#[serde(default)]兼容"新老后端响应",用flatten收集未知字段到serde_json::Map,打印出两个"不认识的键"。 - 离线历史文件 :复刻 14.4.1 的 ChatHistory round-trip;改成"messages 字段缺失也能解析出空 Vec"(hint:
#[serde(default)])。 - reqwest GET 本地 :起本地 python http.server,用 reqwest 拉
/data.json并json::<Value>()后按路径取值打印。 - POST + 错误分支 :对着本地起一个故意返回 500 的端点 (python 脚本),验证你的"4xx/5xx 分流 + 重试退避"逻辑真的走了预期分支(超时用
timeout包一层)。 - 综合(重点) :用
ChatRequest/ChatResponse模型(14.3.2),mock 一个"流式还是非流式都返回固定 JSON"的本地端点,完整跑通client → 序列化 → POST → 反序列化 → 打印,全程无外网依赖。
验收门禁 :不查资料能写出------一个带 rename_all、default、skip_serializing_if 的 Deserialize 模型;reqwest 的 GET/POST + .json() 最小调用;为什么网络请求一定要设超时、为什么 5xx 重试 4xx 不重试。
✅ 本节小结
- serde :
#[derive(Serialize, Deserialize)]把类型↔JSON 打通;rename/rename_all/default/skip_serializing_if/flatten应对现实差异;枚举用rename_all映射字符串; - 模型纪律 :强类型 struct 优先,
serde_json::Value只当动态袋; - reqwest :Client 可复用(连接池)、
.query()/.header()/.json()链式构造;.json::<T>()一步反序列化;永远设超时; - 错误策略:超时双层防护、5xx 退避重试、4xx 直接抛、结构化错误模型化;
- 离线可练:把 IO 层与模型层解耦,本地 mock 文件即可完成大部分训练,切换真实 URL 只需换一行;
- 认知:reqwest + serde 是所有"调用外部 HTTP/JSON 服务"的统一套路------LLM API、气象、支付、SSE(下节)全都一个样。
下一课预告 :第 15 课《SSE 流式读取与增量解析》------LLM 回答为什么是一段段蹦出来的?SSE(Server-Sent Events)协议怎么解析、data: 行如何流式增量解析、如何边收边把 delta 拼起来。这一课把 13 课的 IO 编排 + 14 课的 serde 模型合体,直接服务实战篇的流式问答。