【AI开发之Rust】第 14 课:网络请求与 JSON —— reqwest + serde

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/(写作时同步给出)。

  1. serde 基础往返 :定义 struct Profile { nickname: String, level: u8, tags: Vec<String>, joined: Option<String> },来回转换并断言相等;故意删掉 JSON 里一个字段观察 missing field 报错。
  2. 属性对齐 :写一个"字段是 snake_case、JSON 是 camelCase"的接口模型,用 #[serde(rename_all = "camelCase")] + 部分 #[serde(rename)] 完成双向转换。
  3. default + flatten :设计 #[serde(default)] 兼容"新老后端响应",用 flatten 收集未知字段到 serde_json::Map,打印出两个"不认识的键"。
  4. 离线历史文件 :复刻 14.4.1 的 ChatHistory round-trip;改成"messages 字段缺失也能解析出空 Vec"(hint:#[serde(default)])。
  5. reqwest GET 本地 :起本地 python http.server,用 reqwest 拉 /data.jsonjson::<Value>() 后按路径取值打印。
  6. POST + 错误分支 :对着本地起一个故意返回 500 的端点 (python 脚本),验证你的"4xx/5xx 分流 + 重试退避"逻辑真的走了预期分支(超时用 timeout 包一层)。
  7. 综合(重点) :用 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 模型合体,直接服务实战篇的流式问答。

相关推荐
恋喵大鲤鱼2 小时前
Rust 格式化输出占位符详解
rust
孙启超2 小时前
【AI开发之Rust】第 13 课:async/await 与 tokio 异步运行时
开发语言·后端·rust
小道士写程序2 小时前
Rust + Axum + MySQL + SQLx
rust
Jay_2717 小时前
C语言和Java区别
编程语言
mikuyyds19 小时前
geo-toolbox 插件算法解析:RUSLE 与 MUSLE 的工程化落地
算法·rust·gis
qq_4523962320 小时前
第十四篇:《系统编程实战:用 Rust 编写高性能 HTTP 服务器》
rust
Amos_Web1 天前
Rspack 源码解析(十一):资产 Hook 与增量构建
前端·rust·源码阅读
孙启超1 天前
【AI开发之Rust】第 12 课:并发模型与同步原语
开发语言·后端·rust