Rust实战:构建实用的CLI工具HTTPie引言在现代开发中,命令行工具(CLI)因其强大且灵活的特性而广受欢迎。Rust语言凭借其内存安全性和高效性能,正成为构建CLI工具的绝佳选择。在本文中,我们将以构建HTTPie的简化版为例,展示如何使用Rust实现一个功能强大的
在现代开发中,命令行工具(CLI)因其强大且灵活的特性而广受欢迎。Rust 语言凭借其内存安全性和高效性能,正成为构建 CLI 工具的绝佳选择。在本文中,我们将以构建 HTTPie 的简化版为例,展示如何使用 Rust 实现一个功能强大的 CLI 工具,体验从命令行解析到 HTTP 请求处理的完整过程。这不仅是一次实战练习,更是深入理解 Rust 在实际开发中如何发挥优势的机会。
实现 HTTPie 为例,看看用 Rust 怎么做 CLI。HTTPie 是用 Python 开发的,一个类似 cURL 但对用户更加友善的命令行工具,它可以帮助我们更好地诊断 HTTP 服务。
功能分析要做一个 HTTPie 这样的工具,我们先梳理一下要实现哪些主要功能:
~ via 🅒 base
➜ cd Code/rust
~/Code/rust via 🅒 base
➜ cargo new httpie
Created binary (application) `httpie` package
~/Code/rust via 🅒 base
➜ cd httpie
httpie on master [?] via 🦀 1.70.0 via 🅒 base
➜ c
httpie on master [?] via 🦀 1.70.0 via 🅒 base
➜
Cargo.toml
[package]
name = "httpie"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0.71" # 错误处理
clap = { version = "4.3.9", features = ["derive"] }
colored = "2.0.0" # 命令终端多彩显示
jsonxf = "1.1.1" # JSON pretty print 格式化
mime = "0.3.17" # 处理 mime 类型
reqwest = { version = "0.11.18", features = ["json"] } # HTTP 客户端
tokio = { version = "1.29.0", features = ["full"] } # 异步处理库
main.rs
use clap::Parser;
// 定义 HTTPie 的 CLI 的主入口,它包含若干个子命令
// 下面 /// 的注释是文档,clap 会将其作为 CLI 的帮助
/// A naive httpie implementation with Rust, can you imagine how easy it is?
#[derive(Parser, Debug)]
#[clap(version = "1.0", author = "Tyr Chen <tyr@chen.com>")]
struct Opts {
#[clap(subcommand)]
subcmd: SubCommand,
}
// 子命令分别对应不同的 HTTP 方法,目前只支持 get / post
#[derive(Parser, Debug)]
enum SubCommand {
Get(Get),
Post(Post),
// 我们暂且不支持其它 HTTP 方法
}
// get 子命令
/// feed get with an url and we will retrieve the response for you
#[derive(Parser, Debug)]
struct Get {
/// HTTP 请求的 URL
url: String,
}
// post 子命令。需要输入一个 URL,和若干个可选的 key=value,用于提供 json body
/// feed post with an url and optional key=value pairs. We will post the data
/// as JSON, and retrieve the response for you
#[derive(Parser, Debug)]
struct Post {
/// HTTP 请求的 URL
url: String,
/// HTTP 请求的 body
body: Vec<String>,
}
fn main() {
let opts: Opts = Opts::parse();
println!("{:?}", opts);
}
httpie on master [?] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base took 8.7s
➜ cargo build --quiet && target/debug/httpie post httpbin.org/post a=1 b=2
Opts { subcmd: Post(Post { url: "httpbin.org/post", body: ["a=1", "b=2"] }) }
httpie on master [?] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜ cargo build --quiet && target/debug/httpie post a=1 b=2
Opts { subcmd: Post(Post { url: "a=1", body: ["b=2"] }) }
httpie on master [?] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base took 2.9s
➜
ttpie on master [?] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜ echo "# httpie" >> README.md
httpie on master [?] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜ git add .
httpie on master [+] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜ git commit -m "first commit"
[master(根提交) fe158bb] first commit
5 files changed, 1434 insertions(+)
create mode 100644 .gitignore
create mode 100644 Cargo.lock
create mode 100644 Cargo.toml
create mode 100644 README.md
create mode 100644 src/main.rs
httpie on master is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜ git branch -M main
httpie on main is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜ git remote add origin git@github.com:qiaopengjun5162/httpie.git
httpie on main is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜ git push -u origin main
枚举对象中: 8, 完成.
对象计数中: 100% (8/8), 完成.
使用 12 个线程进行压缩
压缩对象中: 100% (5/5), 完成.
写入对象中: 100% (8/8), 10.50 KiB | 5.25 MiB/s, 完成.
总共 8(差异 0),复用 0(差异 0),包复用 0
To github.com:qiaopengjun5162/httpie.git
* [new branch] main -> main
分支 'main' 设置为跟踪 'origin/main'。
httpie on main is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base took 4.3s
➜
httpie on main [!] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜ tree -a -I "target|.git"
.
├── .gitignore
├── Cargo.lock
├── Cargo.toml
├── README.md
└── src
└── main.rs
2 directories, 5 files
httpie on main [!] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜
main.rs
use anyhow::{anyhow, Result};
use clap::Parser;
use colored::Colorize;
use mime::Mime;
use reqwest::{header, Client, Response, Url};
use std::{collections::HashMap, str::FromStr};
use syntect::{
easy::HighlightLines,
highlighting::{Style, ThemeSet},
parsing::SyntaxSet,
util::{as_24_bit_terminal_escaped, LinesWithEndings},
};
// 以下部分用于处理 CLI
// 定义 HTTPie 的 CLI 的主入口,它包含若干个子命令
// 下面 /// 的注释是文档,clap 会将其作为 CLI 的帮助
/// A naive httpie implementation with Rust, can you imagine how easy it is?
#[derive(Parser, Debug)]
#[clap(version = "1.0", author = "Tyr Chen <tyr@chen.com>")]
struct Opts {
#[clap(subcommand)]
subcmd: SubCommand,
}
// 子命令分别对应不同的 HTTP 方法,目前只支持 get / post
#[derive(Parser, Debug)]
enum SubCommand {
Get(Get),
Post(Post),
// 我们暂且不支持其它 HTTP 方法
}
// get 子命令
/// feed get with an url and we will retrieve the response for you
#[derive(Parser, Debug)]
struct Get {
/// HTTP 请求的 URL
#[arg(value_parser=parse_url)]
url: String,
}
// post 子命令。需要输入一个 URL,和若干个可选的 key=value,用于提供 json body
/// feed post with an url and optional key=value pairs. We will post the data
/// as JSON, and retrieve the response for you
#[derive(Parser, Debug)]
struct Post {
/// HTTP 请求的 URL
#[arg(value_parser=parse_url)]
url: String,
/// HTTP 请求的 body
#[arg(value_parser=parse_kv_pair)]
body: Vec<KvPair>,
}
/// 命令行中的 key=value 可以通过 parse_kv_pair 解析成 KvPair 结构
#[derive(Debug, Clone, PartialEq)]
struct KvPair {
k: String,
v: String,
}
/// 当我们实现 FromStr trait 后,可以用 str.parse() 方法将字符串解析成 KvPair
impl FromStr for KvPair {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// 使用 = 进行 split,这会得到一个迭代器
let mut split = s.split("=");
let err = || anyhow!(format!("Failed to parse {}", s));
Ok(Self {
// 从迭代器中取第一个结果作为 key,迭代器返回 Some(T)/None
// 我们将其转换成 Ok(T)/Err(E),然后用 ? 处理错误
k: (split.next().ok_or_else(err)?).to_string(),
// 从迭代器中取第二个结果作为 value
v: (split.next().ok_or_else(err)?).to_string(),
})
}
}
/// 因为我们为 KvPair 实现了 FromStr,这里可以直接 s.parse() 得到 KvPair
fn parse_kv_pair(s: &str) -> Result<KvPair> {
Ok(s.parse()?)
}
fn parse_url(s: &str) -> Result<String> {
// 这里我们仅仅检查一下 URL 是否合法
let _url: Url = s.parse()?;
Ok(s.into())
}
/// 处理 get 子命令
async fn get(client: Client, args: &Get) -> Result<()> {
let resp = client.get(&args.url).send().await?;
Ok(print_resp(resp).await?)
}
/// 处理 post 子命令
async fn post(client: Client, args: &Post) -> Result<()> {
let mut body = HashMap::new();
for pair in args.body.iter() {
body.insert(&pair.k, &pair.v);
}
let resp = client.post(&args.url).json(&body).send().await?;
Ok(print_resp(resp).await?)
}
// 打印服务器版本号 + 状态码
fn print_status(resp: &Response) {
let status = format!("{:?} {}", resp.version(), resp.status()).blue();
println!("{}\n", status);
}
// 打印服务器返回的 HTTP header
fn print_headers(resp: &Response) {
for (name, value) in resp.headers() {
println!("{}: {:?}", name.to_string().green(), value);
}
println!();
}
/// 打印服务器返回的 HTTP body
fn print_body(m: Option<Mime>, body: &str) {
match m {
// 对于 "application/json" 我们 pretty print
Some(v) if v == mime::APPLICATION_JSON => print_syntect(body, "json"),
Some(v) if v == mime::TEXT_HTML => print_syntect(body, "html"),
// 其它 mime type,我们就直接输出
_ => println!("{}", body),
}
}
/// 打印整个响应
async fn print_resp(resp: Response) -> Result<()> {
print_status(&resp);
print_headers(&resp);
let mime = get_content_type(&resp);
let body = resp.text().await?;
print_body(mime, &body);
Ok(())
}
/// 将服务器返回的 content-type 解析成 Mime 类型
fn get_content_type(resp: &Response) -> Option<Mime> {
resp.headers()
.get(header::CONTENT_TYPE)
.map(|v| v.to_str().unwrap().parse().unwrap())
}
/// 程序的入口函数,因为在 HTTP 请求时我们使用了异步处理,所以这里引入 tokio
#[tokio::main]
async fn main() -> Result<()> {
let opts: Opts = Opts::parse();
let mut headers = header::HeaderMap::new();
// 为我们的 http 客户端添加一些缺省的 HTTP 头
headers.insert("X-POWERED-BY", "Rust".parse()?);
headers.insert(header::USER_AGENT, "Rust Httpie".parse()?);
let client = reqwest::Client::builder()
.default_headers(headers)
.build()?;
let result = match opts.subcmd {
SubCommand::Get(ref args) => get(client, args).await?,
SubCommand::Post(ref args) => post(client, args).await?,
};
Ok(result)
}
fn print_syntect(s: &str, ext: &str) {
// 将字符串按照指定语法进行高亮并打印的功能。
// Load these once at the start of your program
let ps = SyntaxSet::load_defaults_newlines();
let ts = ThemeSet::load_defaults();
let syntax = ps.find_syntax_by_extension(ext).unwrap();
let mut h = HighlightLines::new(syntax, &ts.themes["base16-ocean.dark"]);
for line in LinesWithEndings::from(s) {
let ranges_result: Result<Vec<(Style, &str)>, _> = h.highlight_line(line, &ps);
let ranges = ranges_result.unwrap(); // 或者使用 expect() 方法处理错误
let escaped = as_24_bit_terminal_escaped(&ranges[..], true);
print!("{}", escaped);
}
}
// 仅在 cargo test 时才编译
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_url_works() {
assert!(parse_url("abc").is_err());
assert!(parse_url("http://abc.xyz").is_ok());
assert!(parse_url("https://httpbin.org/post").is_ok());
}
#[test]
fn parse_kv_pair_works() {
assert!(parse_kv_pair("a").is_err());
assert_eq!(
parse_kv_pair("a=1").unwrap(),
KvPair {
k: "a".into(),
v: "1".into()
}
);
assert_eq!(
parse_kv_pair("b=").unwrap(),
KvPair {
k: "b".into(),
v: "".into()
}
);
}
}
Cargo.toml
[package]
name = "httpie"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0.71" # 错误处理
clap = { version = "4.3.9", features = ["derive"] } # 命令行解析
colored = "2.0.0" # 命令终端多彩显示
jsonxf = "1.1.1" # JSON pretty print 格式化
mime = "0.3.17" # 处理 mime 类型
reqwest = { version = "0.11.18", features = ["json"] } # HTTP 客户端
tokio = { version = "1.29.0", features = ["full"] } # 异步处理库
syntect = "5.0.0"
httpie on main [!] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base took 24.7s
➜ tokei src/main.rs
===============================================================================
Language Files Lines Code Comments Blanks
===============================================================================
Rust 1 204 155 20 29
|- Markdown 1 16 0 16 0
(Total) 220 155 36 29
===============================================================================
Total 1 204 155 20 29
===============================================================================
httpie on main [!] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜
httpie on main [!] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜ cargo build --quiet
httpie on main [!] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base took 3.5s
➜ target/debug/httpie post https://httpbin.org/post a=1 b
error: invalid value 'b' for '[BODY]...': Failed to parse b
For more information, try '--help'.
httpie on main [!] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜ target/debug/httpie post abc a=1
error: invalid value 'abc' for '<URL>': relative URL without a base
For more information, try '--help'.
httpie on main [!] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜ target/debug/httpie post https://httpbin.org/post a=1 b=2
HTTP/1.1 200 OK
date: "Fri, 30 Jun 2023 02:56:38 GMT"
content-type: "application/json"
content-length: "472"
connection: "keep-alive"
server: "gunicorn/19.9.0"
access-control-allow-origin: "*"
access-control-allow-credentials: "true"
{
"args": {},
"data": "{\"a\":\"1\",\"b\":\"2\"}",
"files": {},
"form": {},
"headers": {
"Accept": "*/*",
"Content-Length": "17",
"Content-Type": "application/json",
"Host": "httpbin.org",
"User-Agent": "Rust Httpie",
"X-Amzn-Trace-Id": "Root=1-649e4444-7a2f12631acc444061bfc41c",
"X-Powered-By": "Rust"
},
"json": {
"a": "1",
"b": "2"
},
"origin": "222.128.44.77",
"url": "https://httpbin.org/post"
}
httpie on main [!] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base took 38.3s
➜
httpie on main [!] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base took 38.3s
➜ cargo test
Compiling httpie v0.1.0 (/Users/qiaopengjun/Code/rust/httpie)
Finished test [unoptimized + debuginfo] target(s) in 1.23s
Running unittests src/main.rs (target/debug/deps/httpie-0758ccd2852d828e)
running 2 tests
test tests::parse_kv_pair_works ... ok
test tests::parse_url_works ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
httpie on main [!] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜
httpie on main [!] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜ cargo build --release
Compiling libc v0.2.147
Compiling autocfg v1.1.0
Compiling proc-macro2 v1.0.63
Compiling unicode-ident v1.0.9
Compiling quote v1.0.29
Compiling cfg-if v1.0.0
Compiling bitflags v1.3.2
Compiling io-lifetimes v1.0.11
Compiling itoa v1.0.6
Compiling rustix v0.37.20
Compiling once_cell v1.18.0
Compiling parking_lot_core v0.9.8
Compiling pin-project-lite v0.2.9
Compiling smallvec v1.10.0
Compiling scopeguard v1.1.0
Compiling futures-core v0.3.28
Compiling bytes v1.4.0
Compiling serde v1.0.164
Compiling core-foundation-sys v0.8.4
Compiling hashbrown v0.12.3
Compiling lock_api v0.4.10
Compiling indexmap v1.9.3
Compiling tokio v1.29.0
Compiling futures-task v0.3.28
Compiling fnv v1.0.7
Compiling tempfile v3.6.0
Compiling slab v0.4.8
Compiling tracing-core v0.1.31
Compiling futures-util v0.3.28
Compiling memchr v2.5.0
Compiling lazy_static v1.4.0
Compiling syn v2.0.22
Compiling tracing v0.1.37
Compiling errno v0.3.1
Compiling signal-hook-registry v1.4.1
Compiling socket2 v0.4.9
Compiling mio v0.8.8
Compiling num_cpus v1.16.0
Compiling core-foundation v0.9.3
Compiling security-framework-sys v2.9.0
Compiling tokio-macros v2.1.0
Compiling parking_lot v0.12.1
Compiling http v0.2.9
Compiling futures-channel v0.3.28
Compiling httparse v1.8.0
Compiling futures-sink v0.3.28
Compiling pkg-config v0.3.27
Compiling fastrand v1.9.0
Compiling pin-utils v0.1.0
Compiling tinyvec_macros v0.1.1
Compiling cc v1.0.79
Compiling native-tls v0.2.11
Compiling tinyvec v1.6.0
Compiling onig_sys v69.8.1
Compiling security-framework v2.9.1
Compiling try-lock v0.2.4
Compiling crc32fast v1.3.2
Compiling percent-encoding v2.3.0
Compiling utf8parse v0.2.1
Compiling serde_json v1.0.99
Compiling ryu v1.0.13
Compiling anstyle-parse v0.2.1
Compiling form_urlencoded v1.2.0
Compiling want v0.3.1
Compiling unicode-normalization v0.1.22
Compiling http-body v0.4.5
Compiling is-terminal v0.4.7
Compiling httpdate v1.0.2
Compiling anstyle-query v1.0.0
Compiling unicode-bidi v0.3.13
Compiling thiserror v1.0.40
Compiling colorchoice v1.0.0
Compiling safemem v0.3.3
Compiling base64 v0.21.2
Compiling anstyle v1.0.1
Compiling tower-service v0.3.2
Compiling adler v1.0.2
Compiling time-core v0.1.1
Compiling anstream v0.3.2
Compiling time v0.3.22
Compiling miniz_oxide v0.7.1
Compiling line-wrap v0.1.1
Compiling idna v0.4.0
Compiling quick-xml v0.28.2
Compiling thiserror-impl v1.0.40
Compiling strsim v0.10.0
Compiling anyhow v1.0.71
Compiling same-file v1.0.6
Compiling heck v0.4.1
Compiling clap_lex v0.5.0
Compiling linked-hash-map v0.5.6
Compiling unicode-width v0.1.10
Compiling yaml-rust v0.4.5
Compiling clap_builder v4.3.9
Compiling clap_derive v4.3.2
Compiling getopts v0.2.21
Compiling walkdir v2.3.3
Compiling tokio-util v0.7.8
Compiling h2 v0.3.20
Compiling tokio-native-tls v0.3.1
Compiling flate2 v1.0.26
Compiling plist v1.4.3
Compiling url v2.4.0
Compiling bincode v1.3.3
Compiling serde_urlencoded v0.7.1
Compiling serde_derive v1.0.164
Compiling atty v0.2.14
Compiling encoding_rs v0.8.32
Compiling log v0.4.19
Compiling mime v0.3.17
Compiling ipnet v2.8.0
Compiling regex-syntax v0.6.29
Compiling hyper v0.14.27
Compiling colored v2.0.0
Compiling clap v4.3.9
Compiling jsonxf v1.1.1
Compiling hyper-tls v0.5.0
Compiling reqwest v0.11.18
Compiling onig v6.4.0
Compiling syntect v5.0.0
Compiling httpie v0.1.0 (/Users/qiaopengjun/Code/rust/httpie)
Finished release [optimized] target(s) in 20.65s
httpie on main [!] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base took 20.7s
➜
httpie on main [!] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base took 20.7s
➜ mcd pub
httpie/pub on main [!] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜ ls
httpie/pub on main [!] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜ cp ../target/release/httpie ./
httpie/pub on main [!?] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜ ls
httpie
httpie/pub on main [!?] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜ ./httpie
A naive httpie implementation with Rust, can you imagine how easy it is?
Usage: httpie <COMMAND>
Commands:
get feed get with an url and we will retrieve the response for you
post feed post with an url and optional key=value pairs. We will post the data as JSON, and retrieve the response for you
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
-V, --version Print version
httpie/pub on main [!?] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜
httpie/pub on main [!?] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base
➜ ./httpie post https://httpbin.org/post greeting=hola name=Tyr
HTTP/1.1 200 OK
date: "Fri, 30 Jun 2023 03:15:49 GMT"
content-type: "application/json"
content-length: "502"
connection: "keep-alive"
server: "gunicorn/19.9.0"
access-control-allow-origin: "*"
access-control-allow-credentials: "true"
{
"args": {},
"data": "{\"greeting\":\"hola\",\"name\":\"Tyr\"}",
"files": {},
"form": {},
"headers": {
"Accept": "*/*",
"Content-Length": "32",
"Content-Type": "application/json",
"Host": "httpbin.org",
"User-Agent": "Rust Httpie",
"X-Amzn-Trace-Id": "Root=1-649e48e3-5fb585884394bb66433bf8a5",
"X-Powered-By": "Rust"
},
"json": {
"greeting": "hola",
"name": "Tyr"
},
"origin": "222.128.44.77",
"url": "https://httpbin.org/post"
}
httpie/pub on main [!?] is 📦 0.1.0 via 🦀 1.70.0 via 🅒 base took 6.6s
➜
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!