概述
之前学习过《陈天·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 协议的请求体和响应体结构。
请求结构:
http request structure
响应结构:
http response structure
命令分析
在本文中,我们就实现 HTTPie cli 官方的这个示例 :即允许指定请求方法、携带
headers 和 json 数据发送请求。
HTTPie 官方示例
我们来拆解一下,这个命令可以分为以下几个部分:
1 httpie <METHOD> <URL> [headers | params]...
<METHOD>
: 请求方法,本案例中,我们仅支持 GET 和
POST。
<URL>
: 请求地址。
<HEADERS>
: 请求头,格式为
h1:v1
。
<PARAMS>
: 请求参数,格式为
k1=v1
,最终以 json 结构发送。
效果展示
1 2 3 4 5 6 7 8 9 10 11 ➜ 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 子命令:
1 2 3 4 5 6 7 8 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
请求示例:
httpie response demo
思路梳理
httpie 开发思路梳理
第 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. 创建项目
2. 添加依赖
1 2 3 4 5 6 7 8 9 10 11 12 13 [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. 完整源码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 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 { #[arg(value_parser = parse_url)] url: String , #[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()
开始:
1 2 3 4 5 6 7 8 9 10 #[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
的定义如下:
1 2 3 4 5 6 #[derive(Parser)] #[command(version, author, about, long_about = None)] struct Httpie { #[command(subcommand)] methods: Method, }
#[derive(Parser)]
是一个过程宏(procedural
macro),用于自动为结构体实现 clap::Parser
trait。这使得该结构体可以用来解析命令行参数。
在 Httpie
中我们定义了子命令 Method
:
1 2 3 4 5 #[derive(Subcommand)] enum Method { Get (Get), Post (Post) }
#[derive(Subcommand)]
属性宏会自动为枚举派生一些代码,以便它可以作为子命令来解析命令行参数。目前支持
Get
和 Post
两个子命令,它们分别接收
Get
和 Post
参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 #[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
函数来进行解析:
1 2 3 4 5 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 使用起来像:
1 httpie url header1:value1 param1=v1
body
就是 header1:value1 param1=v1
,一对 kv
就代表着一个 header 或者 param,用 :
和 =
来区分。因为 kv 对的个数的变长的,所以我们使用
Vec<KvPair>
来接收 body
这个参数,并使用
parse_kv_pairs
来解析 kv 对。
KvPair
是我们自定义的类型:
1 2 3 4 5 6 7 8 9 10 11 12 #[derive(Debug, Clone)] struct KvPair { k: String , v: String , t: KvPairType, }#[derive(Debug,Clone)] enum KvPairType { Header, Param, }
parse_kv_pairs
的实现如下:
1 2 3 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()
来进行解析了。
1 2 3 4 5 6 impl FromStr for KvPair { type Err = anyhow::Error; fn from_str (s: &str ) -> Result <Self , Self ::Err > { ... } }
3.2 发送请求
参数解析完,就到了发送请求的地方了,这里使用 reqwest
crate 就非常方便了,这里就不赘述了,具体可以参考:Rust reqwest
简明教程 。
1 2 async fn get (client: Client, args: &Get) -> anyhow::Result <()> { ... }async fn post (client: Client, args: &Post) -> anyhow::Result <()> { ... }
3.3 打印响应
httpie response demo
响应分为 3 个部分:
print_status()
print_headers()
print_body()
1 2 3 4 5 6 7 8 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()
使其在终端以蓝色 输出。
1 2 3 4 fn print_status (resp: &Response) { let status = format! ("{:?} {}" , resp.version (), resp.status ()).blue (); println! ("{}\n" , status); }
print_headers()
中,我们使用 green()
使
header_name 在终端以绿色 输出。
1 2 3 4 5 6 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:
1 2 3 4 5 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()
使其在终端以蓝绿色 输出。对于其他类型,我们姑且照原文输出。
1 2 3 4 5 6 7 8 9 10 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
中进行异步编程和错误处理的一些常见模式。尽管示例代码的错误处理较为简单,但它提供了一个良好的起点,开发者可以在此基础上进行扩展和改进,以适应更复杂的应用场景。