第 18 章 HTTP 客户端与 API 调用(reqwest + tokio)
本章目标
- 在 Rust 侧用
reqwest发 HTTP,并对前端暴露受控的网络能力。 - 了解
tauri-plugin-http与直接用reqwest的差别。 - 打好 JSON 请求/响应、超时、重试、取消、代理、TLS 证书六大基本功。
- 设计 CloudTone 的网络层抽象。
一、两条网络路线
- 前端直接用
fetch:受 WebView CSP + capability 管控,允许发送请求到白名单域名。 - Rust 后端用
reqwest:不受 CSP 限制,功能更强(代理、自定义 TLS、httpbin 低阶控制)。
推荐做法:第三方 API、爬虫、需要鉴权 token 的请求 —— Rust 侧做。简单的静态资源、CDN 拉图 —— 前端直连更省事。
二、Rust 侧 reqwest
# Cargo.toml
reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
tokio = { version = "1", features = ["full"] }
基本 GET / POST
use reqwest::Client;
pub async fn search_songs(keyword: &str) -> anyhow::Result<Vec<Song>> {
let client = Client::new();
let res: ApiResp<Vec<Song>> = client
.get("https://api.example.com/search")
.query(&[("q", keyword)])
.header("User-Agent", "CloudTone/0.1")
.timeout(std::time::Duration::from_secs(10))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(res.data)
}
#[derive(serde::Deserialize)]
struct ApiResp<T> { code: i32, data: T }
复用 Client
Client 内部有连接池。应该复用,不要每次 new。放 AppState:
pub struct AppState {
pub http: reqwest::Client,
// ...
}
impl AppState {
pub fn new(/*..*/) -> Self {
let http = reqwest::Client::builder()
.user_agent(concat!("CloudTone/", env!("CARGO_PKG_VERSION")))
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap();
Self { http, /* .. */ }
}
}
POST JSON
let res = state.http.post(url)
.json(&RequestBody { foo: "bar" })
.send().await?
.error_for_status()?;
流式下载
use futures_util::StreamExt;
use tokio::io::AsyncWriteExt;
let mut resp = state.http.get(url).send().await?.error_for_status()?;
let total = resp.content_length().unwrap_or(0);
let mut file = tokio::fs::File::create(path).await?;
let mut downloaded: u64 = 0;
while let Some(chunk) = resp.chunk().await? {
file.write_all(&chunk).await?;
downloaded += chunk.len() as u64;
channel.send(DownloadEvent::Progress { transferred: downloaded, total })?;
}
取消
reqwest 本身不直接支持取消;用 tokio::select! + tokio::sync::oneshot:
let (cancel_tx, mut cancel_rx) = tokio::sync::oneshot::channel::<()>();
tokio::select! {
_ = &mut cancel_rx => {
tracing::info!("cancelled");
}
res = do_request() => {
// 处理结果
}
}
把 cancel_tx 存到 AppState 里,另一个 command 调用时 .send(())。
三、tauri-plugin-http
这个插件给前端一个「受 capability 控制的 fetch」:
pnpm tauri add http
{
"permissions": [
{
"identifier": "http:default",
"allow": [{ "url": "https://api.example.com/**" }]
}
]
}
import { fetch } from "@tauri-apps/plugin-http";
const res = await fetch("https://api.example.com/songs");
这比直接浏览器 fetch 的好处:
- 绕过 CORS(真正到 Rust 层发请求)。
- capability 统一管控目标域名。
- 可以设置超时、代理。
四、为 CloudTone 设计网络层
// src-tauri/src/core/providers/netease.rs
use crate::core::providers::MusicProvider;
pub struct NeteaseProvider {
http: reqwest::Client,
base: String,
}
#[async_trait::async_trait]
impl MusicProvider for NeteaseProvider {
fn name(&self) -> &'static str { "netease" }
async fn search(&self, kw: &str) -> anyhow::Result<Vec<Song>> {
let url = format!("{}/search?keywords={}", self.base, urlencoding::encode(kw));
Ok(self.http.get(url).send().await?.error_for_status()?.json().await?)
}
async fn stream_url(&self, song_id: &str) -> anyhow::Result<String> {
// 一般拿到临时签名 URL
// ...
Ok("https://...".into())
}
}
Provider 抽象 + 注册表:
pub struct ProviderRegistry {
pub inner: dashmap::DashMap<String, Box<dyn MusicProvider + Send + Sync>>,
}
impl ProviderRegistry {
pub fn get(&self, name: &str) -> Option<&dyn MusicProvider> {
self.inner.get(name).map(|r| r.value().as_ref())
}
}
第 38 章我们会实际写两个 provider(网易 + 可插拔空壳)。本书不讨论具体商用 API 的使用合规问题——请使用公开可用、符合你所在地区法律的 API。
五、错误处理
统一把 reqwest::Error 映射到 AppError::Network(String)。避免把 reqwest 的内部类型泄露到前端。
#[derive(Debug, thiserror::Error)]
pub enum NetworkError {
#[error("timeout")]
Timeout,
#[error("non-2xx: {0}")]
Status(u16),
#[error("decode: {0}")]
Decode(String),
#[error("other: {0}")]
Other(String),
}
impl From<reqwest::Error> for NetworkError {
fn from(e: reqwest::Error) -> Self {
if e.is_timeout() { Self::Timeout }
else if let Some(s) = e.status() { Self::Status(s.as_u16()) }
else if e.is_decode() { Self::Decode(e.to_string()) }
else { Self::Other(e.to_string()) }
}
}
六、TLS / 代理 / 证书
rustls-tls是纯 Rust 实现,强烈推荐(macOS/Windows/Linux 统一行为)。native-tls依赖系统库,有时老系统上闹别扭。- 代理:
Client::builder().proxy(reqwest::Proxy::all("http://127.0.0.1:7890")?)。 - 自签证书:
Client::builder().add_root_certificate(cert)。
七、限流与重试
简单的指数退避:
use tokio::time::{sleep, Duration};
async fn get_with_retry(client: &Client, url: &str) -> anyhow::Result<String> {
let mut delay = 500;
for attempt in 1..=4 {
match client.get(url).send().await.and_then(|r| r.error_for_status()) {
Ok(r) => return Ok(r.text().await?),
Err(e) if attempt == 4 => return Err(e.into()),
Err(_) => { sleep(Duration::from_millis(delay)).await; delay *= 2; }
}
}
unreachable!()
}
批量请求限流用 tokio::sync::Semaphore:
let sem = Arc::new(Semaphore::new(4));
for url in urls {
let p = sem.clone().acquire_owned().await.unwrap();
tokio::spawn(async move {
let _p = p;
fetch(url).await
});
}
常见陷阱
1. WebView
fetch遇到 CORS有些 API 不允许浏览器直连。走 Rust。
2.
reqwest在 macOS release build 卡死偶见 native-tls 和 macOS 15 兼容问题。换
rustls-tls99% 解决。
3.
json()解析大响应内存爆用
.chunk()流式处理,或者serde_json::from_reader接.bytes_stream()。
4. 响应编码不是 UTF-8
reqwest::Response::text_with_charset(encoding)手动指定。
本章小结
reqwest+rustls-tls是 Tauri 后端网络的黄金组合。- 前端能用
tauri-plugin-http就用,绕 CORS,统一管控。 - Provider 抽象让第三方音源可插拔。
动手时刻
在 hello 项目里:
-
写一个
fetch_ip命令,请求https://api.ipify.org?format=json,返回 IP。 - 前端按钮触发,显示 IP。
-
给 capability 里加
http:default和 scope。
下一章,SQLite + sqlx。