Skip to content

哈喽,大家好呀,我是呼噜噜,对于我们程序员来说,curl 这样的工具 是我们经常使用的,常常用于发送 HTTP 请求并从服务器获取响应。无论是用于测试接口、下载文件,还是调试 Web 应用等等

curl有很多替代品,其中以HTTPie最为出名,它是Python 实现的工具,功能很强大,而且输出结果也比 curl 漂亮多了。

我想用rust来重写HTTPie,查了一下目前这方面资料也比较多,Github上已经有了rust版的xh,它的star数目前高达7.3k,本文参考"Rust 编程第一课"上相关的例子,进行一定的迭代与更新,用rust来实现一个类似于 HTTPie 的命令行工具,帮助大家更好地入门rust

HTTPie由于功能非常的完善,我们先从简单的开始实现,本文要实现哪些主要功能:

  1. 因为是cli工具,肯定得有命令行解析,处理子命令和各种参数,验证用户的输入。我这边使用clap库,非常有名的一个库
  2. 我们使用reqwest来发送一个 HTTP 请求,并且能成功获得响应。
  3. 将响应结果更美观输出给用户。我们使用colored库,来美化终端

创建项目

我们先检查一下本地的rust开发环境

java
$cargo --version
rustc 1.88.0 (6b00bc388 2025-06-23)

所以本文基于rust1.88.0,其他版本也行

接着我们创建新项目:

java
cargo new rust_httpie_xh

添加依赖

java

[dependencies]
anyhow = "1.0.99" # 错误处理
clap = { version = "4.5.52", features = ["derive"] } # 命令行
colored = "3.0.0" # 命令终端 颜色
jsonxf = "1.1.1" # json格式化
mime = "0.3.17" # 处理 mime 类型
reqwest = { version = "0.12.24", features = ["json"] } # http 请求
tokio = { version = "1.47.2", features = ["rt", "rt-multi-thread", "macros"] } # 异步
serde_json = "1.0.147" # json 解析

我们主要引入这些库,来帮助我们更有效率地开发,这些库的版本,我也都选的比较新

这个项目的核心库是Clap + Tokio + Reqwest,这也是目前 RustCLI 工具的“御三家”。基本上各种cli小工具都会用得到

引入Anyhow来帮我们简化错误处理,避免再去折腾自定义 Error 类型,更详细的rust错误处理,可以去看呼噜噜之前的文章👉深入浅出Rust错误处理:写出更健壮的Rust代码

最后通过Jsonxf + Colored,来美化终端,输出结果必须要漂亮。黑底白字的JSON 谁看得下去?

命令行定义

既然我们要复刻 HTTPie,那得先把交互的“门面”搭起来

我们这里先支持HTTP 协议中最常用的 4 个子命令GET、POST、PUT、DELETE

下面是命令行最经典的定义:

java
/// cli.rs
use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "httprs_cli",version = "1.0.0", author="xiaoniuhululu.com", about = "httpie-rs mini", long_about = None)] // 设置名称、版本、作者、简介等元数据
pub struct Cli { // 定义主 CLI 结构体,自动生成 help 信息
    #[command(subcommand)]//设置子命令
    pub cmd: Command,
}

#[derive(Subcommand)]
pub enum Command {//定义支持的子命令列表
    Get(RequestArgs),
    Post(RequestArgs),
    Put(RequestArgs),
    Delete(RequestArgs),
}

#[derive(Parser)]
pub struct RequestArgs {
    pub url: String, // 位置参数:URL
    pub items: Vec<String>, // 位置参数:后续所有的 kv 对,收集为一个字符串数组
}

这段代码比较简单,只要把这个结构体定义好,参数解析、错误提示、--help文档生成这些脏活累活,clap已经在底层帮我们全包圆了。

接下来,我们只需要专注于处理业务逻辑即可

参数解析

搭建好cli入口后,我们需要对用户输入的参数进行解析

一个标准的 HTTP 请求,除去 URLMethod,核心其实就剩下“三块骨头”:

  • Headers: 比如 Content-TypeUser-Agent
  • Query Params: 也就是 URL ? 后面的那一串东西
  • Body: 通常是 JSON 数据

现在的难点在于,用户在命令行里敲下一堆键值对(比如 a=1 b:2),程序怎么知道哪个是 Header,哪个是 Body

我们这里借用HTTPie的规则,沿用它的语法约定,通过分隔符来区分:

  • Header**:**(例如 Content-Type:json
  • Query**==** (例如 search==rust
  • JSON String**=** (例如 name=xiaoniu,值会被处理为字符串)
  • JSON Value**:=** (例如 age:=18is_admin:=true,值会被当作原始 JSON 类型,支持布尔值、数字甚至嵌套结构)

我们这里定义KV枚举来统一承载这些类型:

java
/// kv.rs
use anyhow::{anyhow, Result};
use std::str::FromStr;

/// 定义核心数据模型,kv键值对
#[derive(Debug, Clone)]
pub enum Kv {
    Header(String, String), // 对应 header:value
    Query(String, String),  // 对应 query==value
    Json(String, serde_json::Value), // 对应 json=value 或 json:=value
}
/// 实现 FromStr trait
/// 解析键值对字符串,将其转换为强类型的 Kv 枚举
impl FromStr for Kv {
    type Err = anyhow::Error;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        // 优先级很重要:先判断复杂的,再判断简单的

        if let Some((k, v)) = s.split_once(":=") {// 处理 JSON 原生类型 (key:=value)
            Ok(Kv::Json(k.into(), serde_json::from_str(v)?))
        } else if let Some((k, v)) = s.split_once("==") { // 处理 Query 参数 (key==value)
            Ok(Kv::Query(k.into(), v.into()))
        } else if let Some((k, v)) = s.split_once(':') { // 处理 Header (key:value)
            Ok(Kv::Header(k.into(), v.into()))
        } else if let Some((k, v)) = s.split_once('=') { // 处理普通 JSON 字符串 (key=value)
            Ok(Kv::Json(k.into(), serde_json::Value::String(v.into())))
        } else {
            Err(anyhow!("invalid argument: {}, expected format like key=value", s))
        }
    }
}

这里我们利用 Rust 强大的 FromStr trait,把解析逻辑封装在结构体内部,这样可以让我们的代码看起来干净又舒服。

另外需要注意这里,判断的优先级不能乱。比如 := 必须在 : 之前判断,否则 age:=18 可能会被误判为 Header

使用 split_once 既高效又语义清晰,比写正则匹配要舒服得多

请求构建

我们现在已经把用户输入的各种参数Headers、Query、Body都统一解析成了 KV 枚举,接下来把这些零散的参数组装成真正的 HTTP 请求了

request.rs 的实现:

java
/// request.rs
use reqwest::{Client, RequestBuilder};
use serde_json::{Map, Value};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; // 显式引入 header 相关类型

use crate::kv::Kv;

pub fn build_request(
    client: &Client,
    method: reqwest::Method,
    url: &str,
    items: Vec<Kv>,
) -> anyhow::Result<RequestBuilder> {
    let mut headers = HeaderMap::new();
    let mut query = Vec::new();
    let mut json = Map::new();

    for item in items {
        match item {
            Kv::Header(k, v) => {
                //这里需要显式指定解析的目标类型HeaderName,HeaderValue;因为rust编译器的类型推导能力有限
                headers.insert(k.parse::<HeaderName>()?, v.parse::<HeaderValue>()?);
            }
            Kv::Query(k, v) => query.push((k, v)),
            Kv::Json(k, v) => {
                json.insert(k, v);
            }
        }
    }

    // 初始化请求构建器
    let mut req = client.request(method, url).headers(headers);

    if !query.is_empty() {
        req = req.query(&query);
    }

    // 如果有 JSON 字段,序列化为 Body
    if !json.is_empty() {
        req = req.json(&Value::Object(json));
    }

    Ok(req)
}

这步工作看似简单,其实是个“分拣”的过程。我们需要把混在一起的 KV 列表,分门别类地塞进三个不同的“桶”里:HeaderMapQuery 数组和 JSON 对象

注意下headers.insert(k.parse::<HeaderName>()?, v.parse::<HeaderValue>()?)这段代码,这里rust编译器的类型无法自动推导出来,需要显式指定解析的目标类型HeaderNameHeaderValue

PythonJS 里,Header 可能就是个字符串字典,但在 Rust 里不行,HeaderNameHeaderValue 都有严格的校验规则,它能确保你传进去的 HTTP 头是合规的,否则直接抛错,把问题拦截在发送请求之前

我们还使用了 serde_json::Map 来收集 JSON 字段。这样无论用户输入了多少个 key=value,最后都会被合并成一个完整的 JSON 对象,再通过 req.json()一次性序列化。

响应打印

当请求发出去了,服务器回包了。

如果直接把服务器返回的原始字符串拍在终端上,那一坨压缩后的 JSON 能把人看瞎。既然是对标 HTTPie,我们的输出必须得带颜色、带格式化,看起来得赏心悦目

我们先来了解一下http的响应报文,响应报文包括四部分,分别为响应首行(Status Line)响应头(Headers)空行(Blank Line)响应体(Response Body),如下图所示

这里我们主要关注三个部分的处理:状态行、Header、Body,打印并美化响应:

java
/// response.rs
use colored::Colorize;
use mime::Mime;
use reqwest::Response;

pub async fn render(resp: Response) -> anyhow::Result<()> {
    //打印状态行:协议版本 + 状态码
    println!(
        "{} {}\n",
        format!("{:?}", resp.version()).blue(),
        resp.status().to_string().blue()
    );
    //打印 Header,为了便于区分,把 Key 染成绿色
    for (k, v) in resp.headers() {
        println!("{}: {:?}", k.to_string().green(), v);
    }
    // Header 和 Body 之间空一行,符合 HTTP 协议视觉习惯
    println!();

    //处理 Body
    let mime = resp
        .headers()
        .get(reqwest::header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.parse::<Mime>().ok());

    // 获取原始 Body 文本
    let body = resp.text().await?;

    // 如果是 JSON,则进行格式化输出
    if let Some(m) = mime {
        if m.type_() == mime::APPLICATION && m.subtype() == mime::JSON {
            if let Ok(pretty) = jsonxf::pretty_print(&body) {
                println!("{}", pretty.cyan());
                return Ok(());
            }
        }
    }

    // 如果不是 JSON 或者格式化失败,就原样打印
    println!("{}", body);
    
    Ok(())
}

美化响应报文,我用了 colored 库给不同区域上了色。状态码用蓝色,HeaderKey 用绿色,JSON Body 用青色

这里我们还用了mime::Mime来精准识别HTTP 头里的 Content-Type,而不是把它当成普通字符串处理,避免了很多麻烦

main.rs

前面我们完成了 cli、kv、request、response这些"零件",现在我就通过 main.rs 把它们组装起来

拧紧螺丝,点火启动

话不多说,直接看最终的组装代码:

java
/// main.rs
mod cli;
mod kv;
mod request;
mod response;

use clap::Parser;
use reqwest::{Client, Method};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // 解析命令行参数
    // 如果参数格式不对,clap 会在这里直接打印 help 信息并退出,不用我们操心
    let cli = cli::Cli::parse();
    // 初始化 HTTP 客户端
    let client = Client::new();

    // 匹配子命令,确定 HTTP 方法和参数
    let (method, args) = match cli.cmd {
        cli::Command::Get(a) => (Method::GET, a),
        cli::Command::Post(a) => (Method::POST, a),
        cli::Command::Put(a) => (Method::PUT, a),
        cli::Command::Delete(a) => (Method::DELETE, a),
    };

    // 解析 kv 参数
    let items = args
        .items
        .iter()
        .map(|s| s.parse())
        .collect::<Result<Vec<_>, _>>()?;
    // 构建请求
    let req = request::build_request(&client, method, &args.url, items)?;
    // 发送请求
    let resp = req.send().await?;
    // 渲染响应
    response::render(resp).await
}

这个main函数的逻辑非常直白,就解析命令行 -> 解析 kv 参数 -> 构建请求 -> 发送请求 -> 渲染响应

代码写完了,是骡子是马得拉出来溜溜~

在项目根目录下执行:

java
#========GET=========
# get基础访问
cargo run get https://httpbin.org/get

# 带查询参数 (Result: ?a=1&b=2)
cargo run get https://httpbin.org/get a==1 name==xiaoniuhululu

# 带请求头 (Result: Headers 中包含 X-Api-Key 和 User-Agent)
cargo run get https://httpbin.org/get X-Api-Key:abc-123 User-Agent:HttpRs/1.0

# 混合:参数 + 请求头
cargo run get https://httpbin.org/get search==rust lang==cn Authorization:BearerToken

#========POST=========
# 纯字符串字段 (Result: {"greeting": "wx", "name": "xiaoniuhululu"})
cargo run post https://httpbin.org/post greeting=wx name=xiaoniuhululu

# 混合类型:字符串 + 数字 + 布尔值 (注意 := 的用法)
cargo run post https://httpbin.org/post name=xiaoniuhululu id:=100 is_admin:=true

# 测试覆盖:带浮点数和 null
cargo run post https://httpbin.org/post score:=99.5 parent:=null

#========PUT=========
# PUT
cargo run put https://httpbin.org/put name=jack age:=18

#========DELETE=========
# 常见用法:通过 URL Query 删除
cargo run delete https://httpbin.org/delete id==1

# 特殊用法:部分 API 要求在 DELETE Body 中带数据
cargo run delete https://httpbin.org/delete ids:='[10, 20]'

上述测试用例均使用了 httpbin.org,它是一个开源的 HTTP 测试服务,它会把接收到的请求内容(Headers, Body, Args)原样返回给你

单元测试

我们这里再补充一些单元测试,来测试边界情况、将测试用例,作为活的文档,方便后续重构优化:

java
/// kv.rs

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_header() {
        let kv = "Authorization:token".parse().unwrap();
        matches!(kv, Kv::Header(_, _));
    }

    #[test]
    fn parse_query() {
        let kv: Kv = "page==1".parse().unwrap();
        assert!(matches!(kv, Kv::Query(k, v) if k == "page" && v == "1"));
    }

    #[test]
    fn parse_json_string() {
        let kv: Kv = "name=jack".parse().unwrap();
        assert!(matches!(kv, Kv::Json(k, v) if k == "name" && v == "jack"));
    }

    #[test]
    fn parse_json_raw() {
        let kv: Kv = "age:=18".parse().unwrap();
        // 检查 json 的数字类型
        assert!(matches!(kv, Kv::Json(k, v) if k == "age" && v == 18));
    }

    #[test]
    fn parse_json_obj() {
        // 测试复杂 JSON 对象
        let kv: Kv = r#"user:={"name":"rose"}"#.parse().unwrap();
        if let Kv::Json(k, v) = kv {
            assert_eq!(k, "user");
            assert_eq!(v["name"], "rose");
        } else {
            panic!("expected json obj");
        }
    }

    #[test]
    fn invalid_kv() {
        let result = "xxx".parse::<Kv>();
        assert!(result.is_err());
    }
}

///request.rs
#[cfg(test)]
mod tests {
    use super::*;
    use reqwest::{Client, Method};

    #[test]
    fn build_post_request() {
        let client = Client::new();
        let items = vec![
            Kv::Json("name".into(), "jack".into()),
            Kv::Header("X-Test".into(), "1".into()),
        ];

        let req = build_request(&client, Method::POST, "http://example.com", items).unwrap();
        let req = req.build().unwrap();

        assert_eq!(req.method(), Method::POST);
        assert!(req.headers().contains_key("x-test"));
    }

    #[test]
    fn build_delete_with_body() {
        let client = Client::new();
        let items = vec![Kv::Json("id".into(), 1.into())];

        let req = build_request(&client, Method::DELETE, "http://example.com", items).unwrap();
        let req = req.build().unwrap();

        assert_eq!(req.method(), Method::DELETE);
    }
}

Rust 支持条件编译,这里 #[cfg(test)] 表明整个 mod tests 都只在 cargo test 时才编译

我们只需运行 cargo test,就能一次性跑完这些单元测试

最后我们无论发布给别人用或还是自用,追求极致的性能,更小的体积,更快的运行速度的话,可以使用cargo build --release来打个包

大小仅3.4M

本文到这里就结束啦,这是一个非常适合新手练手的项目,复刻了 Python 著名的 HTTP 客户端工具 httpie。代码清爽简洁,不得不感谢Rust 强大的生态系统


关注我的公众号【小牛呼噜噜】,获取更多干货,下期我们再见!

扫码到公众号中,回复关键字:rust_httpie_xh,即可获取本文完整代码示例和练习项目

作者:小牛呼噜噜

本文到这里就结束啦,感谢阅读,关注同名公众号:小牛呼噜噜,防失联+获取更多技术干货