概述
之前学习过《陈天·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 接口测试。
在进行实际开发之前,推荐你先了解一下:
本文完整代码:hedon954/httpie
开发思路
HTTP 协议
回顾一下 HTTP 协议的请求体和响应体结构。
请求结构:
响应结构:
命令分析
在本文中,我们就实现 HTTPie cli 官方的这个示例:即允许指定请求方法、携带 headers 和 json 数据发送请求。
我们来拆解一下,这个命令可以分为以下几个部分:
tex
httpie <METHOD> <URL> [headers | params]...
<METHOD>
: 请求方法,本案例中,我们仅支持 GET 和 POST。<URL>
: 请求地址。<HEADERS>
: 请求头,格式为h1:v1
。<PARAMS>
: 请求参数,格式为k1=v1
,最终以 json 结构发送。
效果展示
bash
➜ httpie git:(master) ✗ ./Httpie --help
Usage: Httpie <COMMAND>
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 子命令:
bash
Usage: Httpie post <URL> <BODY>...
Arguments:
<URL> Specify the url you wanna request to
<BODY>... 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<String>
类型的 body 来接收,然后用:
和=
来区分它们。
第 2 步:发送请求
- 使用 reqwest 创建 http client;
- 设置 url;
- 设置 method;
- 设置 headers;
- 设置 params;
- 发送请求;
- 获取响应体。
第 3 步:打印响应
- 打印 http version 和 status,并使用 colored 赋予蓝色;
- 打印 response headers,并使用 colored 赋予绿色;
- 确定 content-type,如果是 json,我们就用 jsonxf 美化 json 串并使用 colored 赋予蓝绿色输出,如果是其他类型,这里我们就输出原文即可。
实战过程
1. 创建项目
bash
cargo new httpie
2. 添加依赖
toml
[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. 完整源码
rust
// 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<KvPair>
}
#[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<Self, Self::Err> {
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<String> {
let _url: Url = s.parse()?;
Ok(s.into())
}
fn parse_kv_pairs(s: &str) -> anyhow::Result<KvPair> {
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<Mime>, 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<Mime> {
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()
开始:
rust
#[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
的定义如下:
rust
#[derive(Parser)]
#[command(version, author, about, long_about = None)]
struct Httpie {
#[command(subcommand)]
methods: Method,
}
#[derive(Parser)]
是一个过程宏(procedural macro),用于自动为结构体实现 clap::Parser
trait。这使得该结构体可以用来解析命令行参数。
在 Httpie
中我们定义了子命令 Method
:
rust
#[derive(Subcommand)]
enum Method {
Get(Get),
Post(Post)
}
#[derive(Subcommand)]
属性宏会自动为枚举派生一些代码,以便它可以作为子命令来解析命令行参数。目前支持 Get
和 Post
两个子命令,它们分别接收 Get
和 Post
参数:
rust
#[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<KvPair>
}
#[derive(Args)]
属性宏表明当前 struct 是命令的参数,其中 Get
仅支持 url
参数,Post
支持 url
和 body
参数。
url
参数我们使用 parse_url
函数来进行解析:
rust
use reqwest::Url;
fn parse_url(s: &str) -> anyhow::Result<String> {
let _url: Url = s.parse()?;
Ok(s.into())
}
这里 reqwest::Url
已经实现了 FromStr
trait,所以这里我们可以直接调用 s.parse()
来解析 url
。
而 body
,因为我们期望 CLI 使用起来像:
bash
httpie url header1:value1 param1=v1
body
就是 header1:value1 param1=v1
,一对 kv 就代表着一个 header 或者 param,用 :
和 =
来区分。因为 kv 对的个数的变长的,所以我们使用 Vec<KvPair>
来接收 body
这个参数,并使用 parse_kv_pairs
来解析 kv 对。
KvPair
是我们自定义的类型:
rust
#[derive(Debug, Clone)]
struct KvPair {
k: String,
v: String,
t: KvPairType,
}
#[derive(Debug,Clone)]
enum KvPairType {
Header,
Param,
}
parse_kv_pairs
的实现如下:
rust
fn parse_kv_pairs(s: &str) -> anyhow::Result<KvPair> {
Ok(s.parse()?)
}
在这里,你可以在 parse_kv_pairs()
函数中,对 s
进行解析并返回 anyhow::Result<KvPair>
。不过,更优雅,更统一的方式是什么呢?就是像 reqwest::Url
一样,为 KvPair
实现 FromStr
trait,这样就可以直接调用 s.parse()
来进行解析了。
rust
impl FromStr for KvPair {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
...
}
}
3.2 发送请求
参数解析完,就到了发送请求的地方了,这里使用 reqwest
crate 就非常方便了,这里就不赘述了,具体可以参考:Rust reqwest 简明教程。
rust
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()
rust
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()
使其在终端以蓝色输出。
rust
fn print_status(resp: &Response) {
let status = format!("{:?} {}", resp.version(), resp.status()).blue();
println!("{}\n", status);
}
print_headers()
中,我们使用 green()
使 header_name 在终端以绿色输出。
rust
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:
rust
fn get_content_type(resp: &Response) -> Option<Mime> {
resp.headers()
.get(header::CONTENT_TYPE)
.map(|v|v.to_str().unwrap().parse().unwrap())
}
在 print_resp()
中,对于 application/json
,我们使用 jsonxf
crate 对进行美化,并使用 cyan()
使其在终端以蓝绿色输出。对于其他类型,我们姑且照原文输出。
rust
fn print_body(mime: Option<Mime>, 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 中进行异步编程和错误处理的一些常见模式。尽管示例代码的错误处理较为简单,但它提供了一个良好的起点,开发者可以在此基础上进行扩展和改进,以适应更复杂的应用场景。