Ben Holmes 2021-11-02
得益于其强大的开源社区,Rust 似乎每天都在变得更加强大。Rust 已经成为全栈应用开发的一个可行选择(例如 Rocket 框架就非常有趣)。
但随着应用场景的无限扩展,我们也需要建立无限的数据连接。那么,我们该如何从外部获取数据呢?
一直把数据文件和命令行输入保存在本地机器上并不有趣。让我们学习一下 Rust 的 Reqwest 库如何:
- 发起基本的 GET 和 POST 请求
- 使用请求头(headers)进行身份验证并指定内容类型
- 将 JSON 反序列化为可用的、类型安全的结构体
- 帮助我们构建一个极简的 Spotify 搜索客户端
什么是 Rust?
如果你是 Rust 新手,它是一门对任何编程背景都非常友好的语言。我认为它是 C 语言的现代等价物。但为了使严格的类型系统和内存管理更易于处理,Rust 加入了许多人性化的设计,比如可读性极强的编译期错误提示,以及类似函数式编程中的 match 表达式等特性。
此外,Rust 拥有一个蓬勃发展的贡献者社区,以及大量优秀的教程资源。
什么是 Reqwest?
Reqwest 是一个用于通过 HTTP 协议获取资源的库。它既提供了简化版的 API 来对指定 URL 发起 get 和 post 请求,也提供了功能完整的 Client 模块,可用于设置请求头、Cookie、重定向策略等。
Reqwest 遵循 Rust 的异步协议,使用“future”机制。如果你还不熟悉 Rust 的异步编程,它包含两个关键要素:
- 用于标记异步代码块的关键字(
await关键字)。如果你用过 JavaScript 中的await,它在 Rust 中的工作方式类似。 - 一个能识别这些关键字并高效执行异步程序的运行时环境。Rust 使用
async关键字来标记需要等待响应的函数或代码块。不过,Reqwest 还依赖于 Tokio 运行时,以高效地调度异步事件。
若想深入了解,可以阅读 Rust 官方提供的《异步编程书》(The Async Book)。
使用 Reqwest 构建一个应用
免责声明:本示例基于 2021 年 10 月记录的 Spotify API。他们的 API 和认证协议此后可能已发生变化!但别担心,本示例中的核心知识点仍然适用。
在本项目中,我们将使用 Spotify API 构建一个简单的搜索客户端,以便快速获取歌曲链接并与朋友分享。为了简化流程,我们将跳过官方的完整认证流程。如果你想跟着操作,请前往 Spotify 开发者页面 获取一个可用于测试的 API token(注册账户是免费的)。
发起第一个 GET 请求(Reqwest)
让我们先尝试一些基本的 GET 和 POST 请求。首先,使用 Cargo 创建一个新项目,并在 Cargo.toml 中添加以下依赖项:
[dependencies]
reqwest = { version = "0.11", features = ["json"] } # 启用 JSON 解析支持的 reqwest
futures = "0.3" # 用于 async/await 代码块
tokio = { version = "1.12.0", features = ["full"] } # 用于异步运行时
现在,我们来调用 Spotify 的一个 API 端点:
use reqwest;
// tokio 允许我们在 main 函数上使用 "async"
#[tokio::main]
async fn main() {
// 使用 .await 链式调用将得到查询结果
let result = reqwest::get("https://api.spotify.com/v1/search").await;
}
非常简单!让我们打印看看结果:
println!("{:?}", result);
输出如下:
Ok(Response { url: Url { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("api.spotify.com")), port: None, path: "/v1/search", query: None, fragment: None }, status: 401, headers: {"www-authenticate": "Bearer realm=\"spotify\"", "access-control-allow-origin": "*", "access-control-allow-headers": "Accept, App-Platform, Authorization...
嗯,我们收到的数据比预期多。为什么会这样?这是因为任何请求的初始响应都是服务器返回的原始 Response 对象。
如果你曾使用浏览器的开发者工具查看网络活动,你可能见过其中的一些字段。例如,status 字段对于匹配错误条件非常有用,这也引出了下一节的内容。
根据 StatusCode 进行匹配
你很可能希望根据响应状态码执行不同的逻辑。我们可以对请求的 StatusCode 进行模式匹配:
match response.status() {
reqwest::StatusCode::OK => {
println!("成功!{:?}");
},
reqwest::StatusCode::UNAUTHORIZED => {
println!("需要获取一个新的 token");
},
_ => {
panic!("哎呀!发生了意外情况。");
},
}
当我们后续开始反序列化有效响应时,这种做法尤其有用,因为错误响应的 body 通常与正常响应的格式不同。
解析响应体
如果我们想要获取响应的实际内容(body),就需要再调用一个函数来指定解析方式。我们先尝试使用 .text() 方法将其作为纯文本处理:
#[tokio::main]
async fn main() {
let response = reqwest::get("https://api.spotify.com/v1/search")
.await
// 每个响应都包装在 `Result` 类型中
// 为简化起见,这里直接 unwrap
.unwrap()
.text()
.await;
println!("{:?}", response);
}
输出如下:
Ok("{\n \"error\": {\n \"status\": 401,\n \"message\": \"No token provided\"\n }\n}")
没什么可看的!因为我们没有提供身份验证 token,所以搜索请求返回了 401 错误,这应该会落入我们之前 match 语句中的 reqwest::StatusCode::UNAUTHORIZED 分支。
那么,如何才能获得 200 状态码的响应呢?接下来我们谈谈请求头(headers)。
使用请求头指定内容类型
我们需要在查询中添加几个请求头:
content_type和accept:声明我们希望以 JSON 格式接收响应authorization:传递我们的 Spotify 账户 token
我们不能直接将这些头信息传给 reqwest::get。因此,我们需要使用 Client 模块来链式添加这些头信息。让我们先重构之前的查询,改用 Client:
#[tokio::main]
async fn main() {
let client = reqwest::Client::new();
let response = client
.get("https://api.spotify.com/v1/search")
// 使用 send() 确认发起请求
.send()
.await
// 其余部分保持不变!
.unwrap()
.text()
.await;
println!("{:?}", response);
}
现在有了 client,我们可以添加头信息配置,如下所示:
let response = client
.get("https://api.spotify.com/v1/search")
.header(AUTHORIZATION, "Bearer [AUTH_TOKEN]")
.header(CONTENT_TYPE, "application/json")
.header(ACCEPT, "application/json")
.send()
...
其中 [AUTH_TOKEN] 是你账户的 OAuth token(你可以在这里获取一个)。如果我们现在打印响应,应该会看到……一个不同的错误状态!
不再是 401 授权错误,而是 400 错误(Bad Request)。这是因为我们还没有指定要搜索的内容,所以现在来补充这一点:
let url = format!(
"https://api.spotify.com/v1/search?q={query}&type=track,artist",
// 去听听她最新的专辑,真的很🔥
query = "Little Simz"
);
// 其余部分与之前相同!
let client = reqwest::Client::new();
let response = client
.get(url)
.header(AUTHORIZATION, "Bearer [AUTH_TOKEN]")
.header(CONTENT_TYPE, "application/json")
.header(ACCEPT, "application/json")
.send()
.await
.unwrap();
println!("成功!{:?}", response);
输出如下:
Ok("{\n \"artists\" : {\n \"href\" : \"https://api.spotify.com/v1/search?query=Lil+Simz&type=artist&offset=0&limit=20\",\n \"items\" : [ {\n \"external_urls\"...
反序列化为 JSON
现在我们收到了一大串搜索结果。如何将其转换为有用的数据结构呢?
我们可以使用嵌套的结构体(struct)来建模我们期望接收的数据。具体结构因使用场景而异,但以下是 Spotify 搜索结果所需的模型:
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
struct ExternalUrls {
spotify: String,
}
#[derive(Serialize, Deserialize, Debug)]
struct Artist {
name: String,
external_urls: ExternalUrls,
}
#[derive(Serialize, Deserialize, Debug)]
struct Album {
name: String,
artists: Vec<Artist>,
external_urls: ExternalUrls,
}
#[derive(Serialize, Deserialize, Debug)]
struct Track {
name: String,
href: String,
popularity: u32,
album: Album,
external_urls: ExternalUrls,
}
#[derive(Serialize, Deserialize, Debug)]
struct Items<T> {
items: Vec<T>,
}
#[derive(Serialize, Deserialize, Debug)]
struct APIResponse {
tracks: Items<Track>,
}
注意:截至本文撰写时,Rust 不支持真正的“嵌套 struct”语法,因此我们将每一层拆分为独立命名且可序列化的 struct。
你还会注意到 Serialize 派生宏,它允许 Reqwest 通过 Serde 将原始 API 响应转换为 Rust 友好的类型。请确保将 Serde 添加为项目依赖项:
[dependencies]
...
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
现在我们准备好解析响应了。只有当 API 返回 200 状态时才应尝试解析,否则我们会遇到将错误消息解析为 APIResponse 结构体的问题。
让我们重新引入之前基于状态码的 match 语法:
let response = client
.get(url)
...
match response.status() {
reqwest::StatusCode::OK => {
// 成功时,将 JSON 解析为 APIResponse
match response.json::<APIResponse>().await {
Ok(parsed) => println!("成功!{:?}", parsed),
Err(_) => println!("嗯,响应格式与我们预期的不一致。"),
};
}
reqwest::StatusCode::UNAUTHORIZED => {
println!("需要获取一个新的 token");
}
other => {
panic!("哎呀!发生了意外情况:{:?}", other);
}
}
如果一切顺利,我们应该会得到一个可以使用的漂亮 API 输出。我们还通过在结构体中限定字段名,过滤掉了 API 响应中不感兴趣的键。
如果你习惯于 JavaScript 或 TypeScript,其中对象可能包含意外的键,那么 Rust 的这种严格性应该会让你感到安心!
创建一个 CLI 客户端
现在,让我们把这些数据整理成人类可读的格式。
我们将对歌曲列表进行一个简单的映射处理:
fn print_tracks(tracks: Vec<&Track>) {
for track in tracks {
println!("🔥 {}", track.name);
println!("💿 {}", track.album.name);
println!(
"🕺 {}",
track
.album
.artists
.iter()
.map(|artist| artist.name.to_string())
.collect::<String>()
);
println!("🌎 {}", track.external_urls.spotify);
println!("---------")
}
}
然后在 match 语句中调用这个打印函数:
match response.status() {
reqwest::StatusCode::OK => {
match response.json::<APIResponse>().await {
Ok(parsed) => print_tracks(parsed.tracks.items.iter().collect()),
Err(_) => println!("嗯,响应格式与我们预期的不一致。"),
};
}
...
}
我们还应该接受任意的命令行参数作为搜索关键词:
use std::env;
let args: Vec<String> = env::args().collect();
let search_query = &args[1];
let url = format!(
"https://api.spotify.com/v1/search?q={query}&type=track,artist",
query = search_query
);
...
现在,我们就可以在终端中运行如下命令来获取搜索结果:
cargo run "Little Simz"
输出示例:
Running `target/debug/spotify-search`
🔥 Venom
💿 Venom
🕺 Little Simz
🌎 https://open.spotify.com/track/4WaaWczlVb1UJ24LILsR4C
---------
🔥 Fear No Man
💿 Sometimes I Might Be Introvert
🕺 Little Simz
🌎 https://open.spotify.com/track/6bLkNijhsnr1MWYrO6XnRz
---------
🔥 Venom
💿 GREY Area
🕺 Little Simz
🌎 https://open.spotify.com/track/3A0ITFj6kbb9CggwtPe55f
---------
...
大功告成!
结论
希望本教程展示了 Reqwest 库的强大与简洁。如果你想进一步深入学习,Reqwest 的官方文档提供了许多有价值的示例,包括:
- 设置 Cookie
- 探索其他 HTTP 方法,如 POST 和 PUT
- 提供更多辅助函数,用于判断状态码及其类别
如果你想查看完整项目代码,可以在 GitHub 上找到这个 Spotify 搜索客户端!