Rust 实战丨HTTPie
概述
之前学习过《陈天·Rust 编程第一课 - 04|get hands dirty:来写个实用的 CLI 小工具》,学的时候迷迷糊糊。后来在系统学习完 Rust 后,重新回过头来看这个实战小案例,基本上都能掌握,并且有了一些新的理解。所以我决定以一个 Rust 初学者的角度,并以最新版本的 Rust(1.7.6)和 clap(4.5.1)来重新实现这个案例,期望能对 Rust 感兴趣的初学者提供一些帮助。
本文将实现的应用叫 HTTPie,HTTPie 是一个用 Python 编写的命令行 HTTP 客户端,其目标是使 CLI 与 web 服务的交互尽可能愉快。它被设计为一个 curl 和 wget 的替代品,提供易于使用的界面和一些用户友好的功能,如 JSON 支持、语法高亮和插件。它对于测试、调试和通常与 HTTP 服务器或 RESTful API 进行交云的开发人员来说非常有用。
HTTPie 的一些关键特性包括:
- JSON 支持:默认情况下,HTTPie 会自动发送 JSON,并且可以轻松地通过命令行发送 JSON 请求体。
- 语法高亮:它会为 HTTP 响应输出提供语法高亮显示,使得结果更加易于阅读。
- 插件:HTTPie 支持插件,允许扩展其核心功能。
- 表单和文件上传:可以很容易地通过表单上传文件。
- 自定义 HTTP 方法和头部:可以发送任何 HTTP 方法的请求,自定义请求头部。
- HTTPS、代理和身份验证支持:支持 HTTPS 请求、使用代理以及多种 HTTP 身份验证机制。
- 流式上传和下载:支持大文件的流式上传和下载。
- 会话支持:可以保存和重用常用的请求和集合。
本文我们将实现其中的 1、2 和 5。我们会支持发送 GET 和 POST 请求,其中 POST 支持设置请求头和 JSON 数据。
在本文中,你可以学习到:
- 如何用 clap 解析命令行参数。
- 如何用 tokio 进行异步编程。
- 如何用 reqwest 发送 HTTP 请求。
- 如何用 colored 在终端输出带颜色的内容。
- 如何用 jsonxf 美化 json 字符串。
- 如何用 anyhow 配合 ? 进行错误传播。
- 如何使用 HTTPie 来进行 HTTP 接口测试。
在进行实际开发之前,推荐你先了解一下:
- Rust reqwest 简明教程
- Rust anyhow 简明教程
- 深入探索 Rust 的 clap 库:命令行解析的艺术
本文完整代码:hedon954/httpie
开发思路
HTTP 协议
回顾一下 HTTP 协议的请求体和响应体结构。
请求结构:
响应结构:
命令分析
在本文中,我们就实现 HTTPie cli 官方的这个示例:即允许指定请求方法、携带 headers 和 json 数据发送请求。
我们来拆解一下,这个命令可以分为以下几个部分:
httpie [headers | params]...
- : 请求方法,本案例中,我们仅支持 GET 和 POST。
- : 请求地址。
- : 请求头,格式为 h1:v1。
- : 请求参数,格式为 k1=v1,最终以 json 结构发送。
效果展示
➜ httpie git:(master) ✗ ./Httpie --help Usage: Httpie Commands: get post help Print this message or the help of the given subcommand(s) Options: -h, --help Print help -V, --version Print version
其中 post 子命令:
Usage: Httpie post ... Arguments: Specify the url you wanna request to ... Set the request body. Examples: headers: header1:value1 params: key1=value1 Options: -h, --help Print help
请求示例:
思路梳理
第 1 步:解析命令行参数
本案例中 httpie 支持 2 个子命令:
- get 支持 url 参数
- post 支持 url、body 参数,因为其中 headers 和 params 是变长的,我们统一用 Vec 类型的 body 来接收,然后用 : 和 = 来区分它们。
第 2 步:发送请求
- 使用 reqwest 创建 http client;
- 设置 url;
- 设置 method;
- 设置 headers;
- 设置 params;
- 发送请求;
- 获取响应体。
第 3 步:打印响应
- 打印 http version 和 status,并使用 colored 赋予蓝色;
- 打印 response headers,并使用 colored 赋予绿色;
- 确定 content-type,如果是 json,我们就用 jsonxf 美化 json 串并使用 colored 赋予蓝绿色输出,如果是其他类型,这里我们就输出原文即可。
实战过程
1. 创建项目
cargo new httpie
2. 添加依赖
[package] name = "httpie" version = "0.1.0" edition = "2021" [dependencies] anyhow = "1.0.80" clap = { version = "4.5.1", features = ["derive"] } colored = "2.1.0" jsonxf = "1.1.1" mime = "0.3.17" reqwest = { version = "0.11.24", features = ["json"] } tokio = { version = "1.36.0", features = ["rt", "rt-multi-thread", "macros"] }
- anyhow: 用于简化异常处理。
- clap: 解析命令行参数。
- colored: 为终端输出内容赋予颜色。
- jsonxf: 美化 json 串。
- mime: 提供了各种 Media Type 的类型封装。
- reqwest: http 客户端。
- tokio: 异步库,本案例种我们使用 reqwest 的异步功能。
3. 完整源码
// src/main.rs 为减小篇幅,省略了单元测试,读者可自行补充。 use std::collections::HashMap; use reqwest::{Client, header, Response}; use std::str::FromStr; use anyhow::anyhow; use clap::{Args, Parser, Subcommand}; use colored::Colorize; use mime::Mime; use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use reqwest::Url; #[derive(Parser)] #[command(version, author, about, long_about = None)] struct Httpie { #[command(subcommand)] methods: Method, } #[derive(Subcommand)] enum Method { Get(Get), Post(Post) } #[derive(Args)] struct Get { #[arg(value_parser = parse_url)] url: String, } #[derive(Args)] struct Post { /// Specify the url you wanna request to. #[arg(value_parser = parse_url)] url: String, /// Set the request body. /// Examples: /// headers: /// header1:value1 /// params: /// key1=value1 #[arg(required = true, value_parser = parse_kv_pairs)] body: Vec } #[derive(Debug, Clone)] struct KvPair { k: String, v: String, t: KvPairType, } #[derive(Debug,Clone)] enum KvPairType { Header, Param, } impl FromStr for KvPair { type Err = anyhow::Error; fn from_str(s: &str) -> Result { let pair_type: KvPairType; let split_char = if s.contains(':') { pair_type = KvPairType::Header; ':' } else { pair_type = KvPairType::Param; '=' }; let mut split = s.split(split_char); let err = || anyhow!(format!("failed to parse pairs {}",s)); Ok(Self { k: (split.next().ok_or_else(err)?).to_string(), v: (split.next().ok_or_else(err)?).to_string(), t: pair_type, }) } } fn parse_url(s: &str) -> anyhow::Result { let _url: Url = s.parse()?; Ok(s.into()) } fn parse_kv_pairs(s: &str) -> anyhow::Result { Ok(s.parse()?) } async fn get(client: Client, args: &Get) -> anyhow::Result { let resp = client.get(&args.url).send().await?; Ok(print_resp(resp).await?) } async fn post(client: Client, args: &Post) -> anyhow::Result { let mut body = HashMap::new(); let mut header_map = HeaderMap::new(); for pair in args.body.iter() { match pair.t { KvPairType::Param => {body.insert(&pair.k, &pair.v);} KvPairType::Header => { if let Ok(name) = HeaderName::from_str(pair.k.as_str()) { if let Ok(value) = HeaderValue::from_str(pair.v.as_str()) { header_map.insert(name,value); } else { println!("Invalid header value for key: {}", pair.v); } } else { println!("Invalid header key: {}", pair.k); } } } } let resp = client.post(&args.url) .headers(header_map) .json(&body).send().await?; Ok(print_resp(resp).await?) } async fn print_resp(resp: Response) -> anyhow::Result { print_status(&resp); print_headers(&resp); let mime = get_content_type(&resp); let body = resp.text().await?; print_body(mime, &body); Ok(()) } fn print_status(resp: &Response) { let status = format!("{:?} {}", resp.version(), resp.status()).blue(); println!("{}\n", status); } fn print_headers(resp: &Response) { for (k,v) in resp.headers() { println!("{}: {:?}", k.to_string().green(), v); } print!("\n"); } fn print_body(mime: Option, resp: &String) { match mime { Some(v) => { if v == mime::APPLICATION_JSON { println!("{}", jsonxf::pretty_print(resp).unwrap().cyan()) } } _ => print!("{}", resp), } } fn get_content_type(resp: &Response) -> Option { resp.headers() .get(header::CONTENT_TYPE) .map(|v|v.to_str().unwrap().parse().unwrap()) } #[tokio::main] async fn main() -> anyhow::Result{ let httpie = Httpie::parse(); let client = Client::new(); let result = match httpie.methods { Method::Get(ref args) => get(client, args).await?, Method::Post(ref args) => post(client, args).await?, }; Ok(result) }
可以看到,即使算上 use 部分,总代码也不过160 行左右,Rust 的 clap 库在 CLI 开发上确实 yyds!
接下来我们来一一拆解这部分的代码,其中关于 clap 的部分我不会过多展开,刚兴趣的读者可以参阅:深入探索 Rust 的 clap 库:命令行解析的艺术。
3.1 命令行解析
我们先从 main() 开始:
#[tokio::main] async fn main() -> anyhow::Result{ let httpie = Httpie::parse(); let client = Client::new(); let result = match httpie.methods { Method::Get(ref args) => get(client, args).await?, Method::Post(ref args) => post(client, args).await?, }; Ok(result) }
我们希望使用 clap 的异步功能,所以使用了 async 关键字,同时加上了 tokio 提供的属性宏 #[tokio::main],用于设置异步环境。为了能够使用 ? 快速传播错误,我们设置返回值为 anyhow::Result,本项目中我们不对错误进行过多处理,所以这种方式可以大大简化我们的错误处理过程。
main() 中我们使用 Httpie::parse() 解析命令行中的参数,使用 Client::new() 创建一个 http client,根据解析到的命令行参数,我们匹配子命令 methods,分别调用 get() 和 post() 来发送 GET 和 POST 请求。
Httpie 的定义如下:
#[derive(Parser)] #[command(version, author, about, long_about = None)] struct Httpie { #[command(subcommand)] methods: Method, }
#[derive(Parser)] 是一个过程宏(procedural macro),用于自动为结构体实现 clap::Parser trait。这使得该结构体可以用来解析命令行参数。
在 Httpie 中我们定义了子命令 Method:
#[derive(Subcommand)] enum Method { Get(Get), Post(Post) }
#[derive(Subcommand)] 属性宏会自动为枚举派生一些代码,以便它可以作为子命令来解析命令行参数。目前支持 Get 和 Post 两个子命令,它们分别接收 Get 和 Post 参数:
#[derive(Args)] struct Get { #[arg(value_parser = parse_url)] url: String, } #[derive(Args)] struct Post { #[arg(value_parser = parse_url)] url: String, #[arg(value_parser = parse_kv_pairs)] body: Vec }
#[derive(Args)] 属性宏表明当前 struct 是命令的参数,其中 Get 仅支持 url 参数,Post 支持 url 和 body 参数。
url 参数我们使用 parse_url 函数来进行解析:
use reqwest::Url; fn parse_url(s: &str) -> anyhow::Result { let _url: Url = s.parse()?; Ok(s.into()) }
这里 reqwest::Url 已经实现了 FromStr trait,所以这里我们可以直接调用 s.parse() 来解析 url。
而 body,因为我们期望 CLI 使用起来像:
httpie url header1:value1 param1=v1
body 就是 header1:value1 param1=v1,一对 kv 就代表着一个 header 或者 param,用 : 和 = 来区分。因为 kv 对的个数的变长的,所以我们使用 Vec 来接收 body 这个参数,并使用 parse_kv_pairs 来解析 kv 对。
KvPair 是我们自定义的类型:
#[derive(Debug, Clone)] struct KvPair { k: String, v: String, t: KvPairType, } #[derive(Debug,Clone)] enum KvPairType { Header, Param, }
parse_kv_pairs 的实现如下:
fn parse_kv_pairs(s: &str) -> anyhow::Result { Ok(s.parse()?) }
在这里,你可以在 parse_kv_pairs() 函数中,对 s 进行解析并返回 anyhow::Result。不过,更优雅,更统一的方式是什么呢?就是像 reqwest::Url 一样,为 KvPair 实现 FromStr trait,这样就可以直接调用 s.parse() 来进行解析了。
impl FromStr for KvPair { type Err = anyhow::Error; fn from_str(s: &str) -> Result { ... } }
3.2 发送请求
参数解析完,就到了发送请求的地方了,这里使用 reqwest crate 就非常方便了,这里就不赘述了,具体可以参考:Rust reqwest 简明教程。
async fn get(client: Client, args: &Get) -> anyhow::Result { ... } async fn post(client: Client, args: &Post) -> anyhow::Result { ... }
3.3 打印响应
响应分为 3 个部分:
- print_status()
- print_headers()
- print_body()
async fn print_resp(resp: Response) -> anyhow::Result { print_status(&resp); print_headers(&resp); let mime = get_content_type(&resp); let body = resp.text().await?; print_body(mime, &body); Ok(()) }
print_status() 比较简单,就是打印 HTTP 版本和响应状态码,然后我们使用 colored crate 的 blue() 使其在终端以蓝色输出。
fn print_status(resp: &Response) { let status = format!("{:?} {}", resp.version(), resp.status()).blue(); println!("{}\n", status); }
print_headers() 中,我们使用 green() 使 header_name 在终端以绿色输出。
fn print_headers(resp: &Response) { for (k,v) in resp.headers() { println!("{}: {:?}", k.to_string().green(), v); } print!("\n"); }
响应体的格式(Media Type)有很多,本案例中我们仅支持 application/json,所以在 print_body() 之前,我们需要先读取 response header 中的 content-type:
fn get_content_type(resp: &Response) -> Option { resp.headers() .get(header::CONTENT_TYPE) .map(|v|v.to_str().unwrap().parse().unwrap()) }
在 print_resp() 中,对于 application/json,我们使用 jsonxf crate 对进行美化,并使用 cyan() 使其在终端以蓝绿色输出。对于其他类型,我们姑且照原文输出。
fn print_body(mime: Option, resp: &String) { match mime { Some(v) => { if v == mime::APPLICATION_JSON { println!("{}", jsonxf::pretty_print(resp).unwrap().cyan()) } } _ => print!("{}", resp), } }
总结
在本文中,我们深入探讨了如何使用 Rust 语言来实现一个类似于 HTTPie 的命令行工具。这个过程包括了对 HTTP 协议的理解、命令行参数的解析、HTTP 客户端的创建和请求发送,以及对响应的处理和展示。通过本文,读者不仅能够获得一个实用的命令行工具,还能够学习到如何使用 Rust 的库来构建实际的应用程序,包括 clap、reqwest、tokio 和 colored 等。此外,文章也说明了在 Rust 中进行异步编程和错误处理的一些常见模式。尽管示例代码的错误处理较为简单,但它提供了一个良好的起点,开发者可以在此基础上进行扩展和改进,以适应更复杂的应用场景。