第 18 篇
HTTP 请求
用 reqwest 的阻塞客户端访问本机 HTTP 服务,分别处理状态码、JSON、数据校验、响应大小和超时,再用本机测试固定这些行为。
Hex4C59 10 分钟阅读 更新于 2026-10-17
编程·原创·实战教程
前面几章的数据来自命令行和本地文件。现在换一种来源:向一个 HTTP 服务发出请求,读取它返回的 JSON,再转成 Rust 结构体。
本章做一个独立的 lesson-http 小项目。它只请求 127.0.0.1 上的练习服务,不访问外部 API,不需要账号或密钥。完成后,你应该能区分“请求没有成功”“服务器返回 404”“JSON 格式不对”和“数据不符合规则”。
需要先理解 Result、自定义错误、Serde 和 clap。这一章使用阻塞客户端:执行请求时,当前线程等待结果。异步 Rust、同时请求多个服务和 HTTPS 配置留到后续专题。
先看一次请求的经过 #
HTTP(Hypertext Transfer Protocol,超文本传输协议)是一种请求与响应协议。这个练习中的过程是:
- 客户端连接本机练习端口,请求
/lesson.json。 - 服务返回状态码和响应体。
- 客户端先检查状态码,再读取有限大小的响应体。
- Serde 检查 JSON 的结构和字段类型。
- 业务代码检查标题和分钟数,再打印课程信息。
200 表示本次 HTTP 请求成功,404 表示请求的资源没有找到。状态码属于响应信息;收到 200 后,仍可能遇到损坏 JSON 或不合理数据。我们不能只用一次 ? 把这些层次都省掉。
本章希望得到这样的响应体:
lesson-http/fixtures/lesson.json 的完整内容:
{"title":"学习 HTTP 请求","minutes":30}标题不能只有空白,分钟数必须在 1~1440 之间。这是练习的业务规则,不是 HTTP 协议的限制。
准备项目和依赖 #
进入下载包的 rust-course/projects。如果从空目录跟写,建立 lesson-http/src、examples 和 fixtures,再把 lesson-http 加入工作区的 members。
lesson-http/Cargo.toml 的完整内容:
[package]
name = "lesson-http"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
publish = false
[dependencies]
clap = { version = "=4.6.7", features = ["derive"] }
reqwest = { version = "=0.13.5", default-features = false, features = ["blocking"] }
serde = { version = "=1.0.229", features = ["derive"] }
serde_json = "=1.0.154"本章固定 reqwest 0.13.5。blocking 启用阻塞客户端;default-features = false 关闭默认功能,这个练习只用本机明文 HTTP,没有启用 TLS(Transport Layer Security,传输层安全协议)。以后请求 HTTPS 服务时需要重新选择和配置 TLS 功能,不能把本章配置直接当作通用网络客户端。
代码没有开启 reqwest 的 json 功能,因为它会先限制响应体大小,再由 serde_json 解析。projects/Cargo.lock 记录直接及间接依赖选择,首次运行 cargo fetch --locked 需要网络。我们在 rustc 1.99.0 上验证;清单声明的 Rust 1.85 最低版本没有实际运行验证。
客户端的完整实现 #
lesson-http/src/lib.rs 的完整内容:
use std::error::Error;
use std::fmt;
use std::io::{self, Read};
use std::time::Duration;
use serde::Deserialize;
pub const MAX_BODY: u64 = 4096;
#[derive(Debug, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Lesson {
pub title: String,
pub minutes: u32,
}
#[derive(Debug)]
pub enum FetchError {
Http(reqwest::Error),
Io(io::Error),
Json(serde_json::Error),
Status(u16),
TooLarge,
InvalidData,
}
impl fmt::Display for FetchError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Http(_) => write!(f, "HTTP 请求失败"),
Self::Io(_) => write!(f, "读取响应失败"),
Self::Json(_) => write!(f, "响应不是符合格式的 JSON"),
Self::Status(code) => write!(f, "服务器返回 HTTP {code}"),
Self::TooLarge => write!(f, "响应超过 {MAX_BODY} 字节"),
Self::InvalidData => write!(f, "端口、课程标题或分钟数无效"),
}
}
}
impl Error for FetchError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Http(error) => Some(error),
Self::Io(error) => Some(error),
Self::Json(error) => Some(error),
_ => None,
}
}
}
impl From<reqwest::Error> for FetchError {
fn from(error: reqwest::Error) -> Self {
Self::Http(error)
}
}
impl From<io::Error> for FetchError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
impl From<serde_json::Error> for FetchError {
fn from(error: serde_json::Error) -> Self {
Self::Json(error)
}
}
pub fn fetch(port: u16) -> Result<Lesson, FetchError> {
if port == 0 {
return Err(FetchError::InvalidData);
}
let client = reqwest::blocking::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(1))
.timeout(Duration::from_secs(2))
.build()?;
let response = client
.get(format!("http://127.0.0.1:{port}/lesson.json"))
.send()?;
if !response.status().is_success() {
return Err(FetchError::Status(response.status().as_u16()));
}
let mut bytes = Vec::new();
response.take(MAX_BODY + 1).read_to_end(&mut bytes)?;
if bytes.len() as u64 > MAX_BODY {
return Err(FetchError::TooLarge);
}
let lesson: Lesson = serde_json::from_slice(&bytes)?;
if lesson.title.trim().is_empty() || !(1..=1440).contains(&lesson.minutes) {
return Err(FetchError::InvalidData);
}
Ok(lesson)
}Lesson 是期望的响应数据。派生 Deserialize 会生成按字段名和类型构造它的实现,deny_unknown_fields 要求未知字段也报错。JSON 函数调用这份实现,把收到的字节转换成课程对象;字段齐全、类型正确后,业务代码才检查标题和分钟数。这个练习采用严格格式;真实服务是否允许额外字段,需要按它的协议选择。
FetchError 保留了六类失败。请求、响应读取和 JSON 失败有底层原因,source() 会把它们交给调用者;状态码、过大响应和业务校验失败则已经由自己的变体表达。
为什么限制地址和重定向 #
fetch 只接收端口,地址由代码构造为 http://127.0.0.1:{port}/lesson.json。读者不会把服务地址输错成外部网站。no_proxy() 让这个客户端不经过代理;关闭重定向后,302 会作为不成功的状态码返回,而不会继续访问响应里指定的新地址。
这些设置限定了本章练习的连接方式。它们没有实现面向任意用户网址的访问控制,也没有给真实服务增加认证。不要为了请求外部地址,只把 127.0.0.1 换掉就沿用所有假设。
超时与响应大小解决不同问题 #
连接阶段的超时设为 1 秒,阻塞客户端的连接、读写等待超时设为 2 秒。服务连接上却一直不返回响应时,等待也会结束。
这里不能把 2 秒解释成整个 fetch 函数的绝对截止时间:阻塞响应的 Read 调用会为每次读取等待应用超时,缓慢分段返回的内容仍可能延长整个过程。本章没有额外实现贯穿所有步骤的总耗时上限。这两个值适合本机练习,其他场景需要重新确定超时策略。
response 实现了 Read,所以可以用第十二章熟悉的读取接口。take(MAX_BODY + 1) 最多取 4097 字节:若真的读到第 4097 字节,就能判断响应超过 4096 字节;否则读取结束后交给 JSON 解析。
只看 Content-Length 不够,它可能缺失,也不能替代对实际内容的限制。本例直接限制要装入 Vec<u8> 的响应体字节。这个数字没有描述整个 HTTP 库的内存消耗,也不是对所有网络资源的完整限制。
这里读取的是字节,因此使用 serde_json::from_slice。如果内容不是有效 JSON,包括其中的文本编码无效,会返回 JSON 错误。HTTP 响应成功之后,解析和校验仍可能失败。
检查顺序也决定了报告哪种错误:404 会在读取和解析正文之前返回状态码错误;成功状态下若读到第 4097 字节,会先报告大小错误;正文大小合格、JSON 结构正确但分钟数为 0,才会走到业务校验错误。
为程序提供一个端口参数 #
lesson-http/src/main.rs 的完整内容:
use std::error::Error;
use std::process::ExitCode;
use clap::Parser;
#[derive(Parser)]
#[command(version, about = "从本机练习服务器读取课程")]
struct Args {
#[arg(long, value_parser = clap::value_parser!(u16).range(1..))]
port: u16,
}
fn main() -> ExitCode {
let args = Args::parse();
match lesson_http::fetch(args.port) {
Ok(lesson) => {
println!("{}:{} 分钟", lesson.title, lesson.minutes);
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("错误:{error}");
let mut cause = error.source();
while let Some(source) = cause {
eprintln!("原因:{source}");
cause = source.source();
}
ExitCode::FAILURE
}
}
}--port 必须提供,且值在 u16 能表示的正整数范围内。clap 先拒绝端口 0;库函数也检查 0,避免其他调用者绕过 CLI 后传入它。
端口参数合法也不代表那个端口上已有服务。服务没有启动时,请求不能建立连接;服务接受连接后返回什么内容,则由后面的状态、大小、格式和业务检查分别判断。
网络或数据失败以退出码 1 结束,参数错误以退出码 2 结束。成功时只把课程信息写到标准输出,错误和底层原因写到标准错误。
启动只处理一次连接的练习服务 #
配套的 serve 示例是一个本机测试夹具:为了让客户端有确定的输入,固定返回一段响应。它不路由请求,不实现完整 HTTP 服务器,也不适合接受真实用户流量。先把前面的 JSON 保存到 lesson-http/fixtures/lesson.json,再建立下面的文件。
lesson-http/examples/serve.rs 的完整内容:
use std::io::{Read, Write};
use std::net::TcpListener;
use std::time::Duration;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mode = std::env::args()
.nth(1)
.unwrap_or_else(|| String::from("ok"));
let (status, body) = match mode.as_str() {
"ok" => ("200 OK", include_str!("../fixtures/lesson.json").to_owned()),
"missing" => ("404 Not Found", String::from("missing")),
"bad-json" => ("200 OK", String::from("not-json")),
"bad-data" => ("200 OK", String::from(r#"{"title":"Rust","minutes":0}"#)),
"large" => ("200 OK", "x".repeat(5000)),
_ => return Err("模式:ok | missing | bad-json | bad-data | large".into()),
};
let listener = TcpListener::bind("127.0.0.1:0")?;
println!("本机练习端口:{}", listener.local_addr()?.port());
println!("只接收一次连接,响应后退出");
let (mut stream, _) = listener.accept()?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let mut header = Vec::new();
while !header.ends_with(b"\r\n\r\n") {
if header.len() >= 8192 {
return Err("请求头过长".into());
}
let mut byte = [0];
stream.read_exact(&mut byte)?;
header.push(byte[0]);
}
write!(
stream,
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)?;
stream.flush()?;
Ok(())
}127.0.0.1:0 要求系统分配可用端口。这里的 0 用于服务器绑定,与客户端必须使用实际端口不同。服务打印端口后等待一次连接,写完响应就退出。
include_str! 在编译时把练习 JSON 放入示例中,不依赖运行时的工作目录。修改 JSON 后,通过 Cargo 重新运行示例即可重新编译。
读取请求头只是为了等待客户端发完请求。代码限制头部长度,并给这一次连接设置读写超时;它没有实现完整的 HTTP 解析。服务在接受连接前会等待,你可以在自己的终端按 Ctrl+C 结束它。
用两个终端完成一次请求 #
两个终端都进入 rust-course/projects。终端 A 运行:
cargo run -p lesson-http --example serve它会输出类似下面的内容。端口由系统分配,每次可能不同:
本机练习端口:54321
只接收一次连接,响应后退出终端 B 使用 A 实际显示的端口。下面的 54321 只是示例数字,运行时必须替换:
cargo run -p lesson-http -- --port 54321成功输出:
学习 HTTP 请求:30 分钟第一条命令启动一次性服务;第二条发出请求。服务响应后退出,下一次请求之前需要重新启动。这里的“本机”指客户端和服务在同一台机器上,不能把另一个机器显示的端口直接用于本机地址。
把失败条件逐项试出来 #
重新启动服务时,可以指定一种响应模式。每次都先启动 A,再用新的端口运行 B:
| 终端 A 命令的模式 | 响应 | 客户端应报告 |
|---|---|---|
cargo run -p lesson-http --example serve -- missing | HTTP 404 | 服务器返回 HTTP 404 |
cargo run -p lesson-http --example serve -- bad-json | HTTP 200,正文为 not-json | JSON 格式错误,带底层原因 |
cargo run -p lesson-http --example serve -- bad-data | HTTP 200,分钟数为 0 | 数据无效 |
cargo run -p lesson-http --example serve -- large | HTTP 200,正文 5000 字节 | 响应超过 4096 字节 |
404 模式的响应体也不是 JSON,但客户端会先报告状态码,不会把它误归类为 JSON 解析错误。遇到这种输出,先检查请求路径和服务响应,再检查 JSON 格式。
请求一个没有服务监听的端口,会遇到连接错误;具体文本由操作系统和 HTTP 库提供。服务接受请求却迟迟不响应时,会遇到超时。配套测试覆盖了后一种情况,不依赖公开网站是否可用。
让测试只连接自己的本机夹具 #
配套 tests/http.rs 启动临时本机端口,收到一次请求后返回固定响应。每个测试拥有自己的监听器,不使用固定端口,也不请求公网。运行:
cargo test -p lesson-http --test http6 个测试检查正常 JSON、404 与 302、错误字段和业务值、实际响应大小、无响应时的超时,以及 CLI 输出和端口 0 的拒绝。
服务器辅助函数还检查收到的请求行是 GET /lesson.json HTTP/1.1,避免只判断输出而漏掉客户端请求了错误路径。夹具使用线程同时等待请求;这里只要求理解“服务和客户端需要同时运行”,线程的所有权与协调会在并发专题继续讲。
超时测试让服务先读到请求,再通过一个通道等待测试结束。客户端必须因超时返回,测试随后通知服务退出并回收线程。测试没有靠公开网络变慢来碰运气,也没有把“等待了大约两秒”写成性能指标。
练习:兼容服务的旧字段名 #
假设旧服务用 name 表示课程标题,新服务改为 title。让同一个 Lesson 接受任一名称,同时保持未知字段、分钟数和空标题的原有规则。
参考答案:先自己完成再展开
在 Lesson 的 title 字段上增加属性。下面是需要替换原定义的完整结构体;其他代码保持原样:
#[derive(Debug, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Lesson {
#[serde(alias = "name")]
pub title: String,
pub minutes: u32,
}现在,{"name":"Rust","minutes":30} 和 {"title":"Rust","minutes":30} 都可以读取,得到的 Rust 字段仍叫 title。业务检查发生在反序列化之后,所以旧名字并不能绕过分钟数或空标题的限制。
如果同一份 JSON 同时包含 name 和 title,Serde 会将它们视为同一个字段的重复输入并报错;这个属性没有决定“后者覆盖前者”。为兼容添加别名时,要一起检查这种歧义。
配套主版本没有预先加这个别名。修改后,再用本机响应分别测试新名称、旧名称和两个名称同时出现的情况,不要只验证成功的一条路径。
本章之后有一组本机 HTTP 分级练习:先修改响应,再练习失败后继续处理,最后实现顺序读取多个本机服务的客户端。它复用本章的 fetch,不引入异步或公网接口。
接着学什么 #
现在,一个命令已经能发出请求,检查响应,再打印经过校验的数据。可以先关闭答案,用这个结构独立重做一个只读客户端:明确输入地址的范围、成功数据格式,以及每一层可能出现的失败。
待办工具的备份和命令行接口可以继续完善。网络客户端也可以再学异步、并发请求和重试,但每项都需要新的行为约定;例如重试写入请求之前,要先确认重复操作会带来什么结果。
官方延伸阅读 #
外部文档查阅日期:2026-10-13。
一起讨论
选中正文中的文字,可以针对这段内容发起讨论。
正在读取讨论…
在 GitHub 查看与管理讨论 ↗