Rust 代码挑战系列 1 - 使用 axum 编写 http 服务器

这是一个学习 Rust 的小系列文章,通过完成来自 shuttle 平台所举办的 2023 Christmas Code Hunt 里的每个小挑战,来学习 rust web 框架的使用。

你可以选择任意的 web 框架来完成挑战,笔者选用的是之前文章中提及的 axum 框架实现 http 服务,版本选用目前最新的 0.7,rust 使用的是 1.74.0 版本。

在每个任务中,主要记录了笔者的实现思路和代码改进过程,而非直接给出最终结果,旨在更多的了解 rust 和 axum 的使用。 为了精简内容,笔者不会对问题做过多描述,想要了解完整描述可自行查看对应问题的原页面。

在开始前,若对 axum 不熟悉,建议先阅读其文档,以便对该框架有一些基本认识。

本篇内涉及了 -1、1、4 这三部分的挑战内容。

Part -1

-1 是一个热身部分,用来搭建一下开发环境并实现一个 hello world 请求的处理。

Task 1

# 输入输出示例
curl -I -X GET http://localhost:8000/

HTTP/1.1 200 OK
...

我们先来自行初始化一个项目:

# 新建项目
cargo new cch2023

添加依赖,编辑 Cargo.toml

[dependencies]
axum = "0.7.2"
tokio = { version = "1.35.1", features = ["full"] }

更新 src/main.rs

use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
    let router = Router::new().route("/", get(demo));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:8000")
        .await
        .unwrap();

    axum::serve(listener, router).await.unwrap();
}

async fn demo() -> &'static str {
    "hi"
}

使用 cargo run 运行程序,然后发一个请求测试一下(无法使用 curl 的话,可以在 vscode 里安装 Thunder Client 插件发送请求):

curl http://localhost:8000
# hi%

Task 2

# 输入输出示例
curl -I -X GET http://localhost:8000/-1/error

HTTP/1.1 500 Internal Server Error
...

axum 里返回错误码很简单,直接返回对应的状态码即可:

use axum::{http::StatusCode};

async fn main() {
    let router = Router::new()
        .route("/-1/error", get(mock_error));
    // ...
}

async fn mock_error() -> StatusCode {
    StatusCode::INTERNAL_SERVER_ERROR
}

那么 -1 部分的热身任务就完成了。

Part 1

正式开始挑战啦。

Task 1

寒冷的北极大陆上,圣诞老人用来派发礼物的包包管理系统出现了点故障,我们需要帮助他进行校准。

实现一个 GET 接口 /1/<num1>/<num2>,返回 (num1 XOR num2) POW 3 的结果,其中 XOR 是异或运算,POW 是指数运算。

# 输入输出示例
curl http://localhost:8000/1/4/8

1728

axumRouter 文档里可以看到所有支持的路径写法,我们将使用 /:num1/:num2

use axum::{extract::Path};

async fn main() {
    let router = Router::new()
        .route("/1/:num1/:num2", get(d1));
    // ...
}

async fn d1(path: Path<(i32, i32)>) -> String {
    (path.0 .0 ^ path.0 .1).pow(3).to_string()
}

上述中使用 Path extractor 来提取路径中的参数,然后进行数学运算,返回字符串内容即可。

Task 2

包包系统校准好了,现在需要再校准下雪橇管理系统。现在 <num> 不只有两个,将这些 <num> 和上述一样进行异或运算,然后进行指数运算。

# 输入输出示例 1
curl http://localhost:8000/1/10

1000

## 输入输出示例 2
curl http://localhost:8000/1/4/5/8/10

27

这个任务里,路径的参数个数是不固定的,axum 则为我们提供了 wildcards 写法:/assets/*path,来看看怎么使用,:

async fn main() {
    let router = Router::new()
        // 该路径会包含前面的 /1/:num1/:num2,故此处将其替换,路径冲突会报错
        .route("/1/:nums", get(d1));
    // ...
}

async fn d1(Path(nums): Path<String>) -> String {
    let nums = nums
        .split('/')
        .filter(|x| !x.is_empty())
        .map(|x| i32::from_str_radix(x, 10).unwrap())
        .collect::<Vec<_>>();

    let mut res = 0;
    for num in nums {
        res = res ^ num;
    }
    res = res.pow(3);
    res.to_string()
}

上述中当我们使用了通配符后,便需要自行解析路径参数,将参数都转为数字然后进行计算返回。

小知识 - Turbofish

上述代码的 xx.collect::<Vec<_>>() 使用到了一种叫做 turbofish 的写法 ::<T>,用来协助编译器进行类型推断。我们看下面的例子,为何这里 rust 不能自动推断出目标类型是个 Vec<&str>,而是编译报错 “type annotations needed”:

fn col_demo(data: &'static str) {
  let items = data.split('/').collect(); // compiler error
}

写成下面的某一种形式就正常了:

let items = data.split('/').collect::<Vec<_>>();
let items: Vec<_> = data.split('/').collect();

通过查看 collect 的函数签名可知其返回值是一个实现了 FromIterator trait 的类型,而从 FromIterator 的文档可知有很多类型都实现了这个 trait,比如 VecVecDeque,因此如果不手动标注具体类型,编译器无法知道我们需要的是哪个类型:

let items = data.split('/').collect::<Vec<_>>(); // ok
let items = data.split('/').collect::<VecDeque<_>>(); // ok

其中 Vec 元素的类型使用了 _ 表示让编译器自动推断,不需要手动指定,是因为此处的类型编译器是可以自动推断的。

回到 Task 2 中,我们的代码可以进一步改进,将其中的 for 循环使用函数风格替换掉,可以一条语句写完整个逻辑了:

async fn d1(Path(nums): Path<String>) -> String {
    nums.split('/')
        .filter(|x| !x.is_empty())
        .map(|x| i32::from_str_radix(x, 10).unwrap())
        .fold(0, |acc, cur| acc ^ cur)
        .pow(3)
        .to_string()
}

这一部分的问题解决让我们了解到 rust 的 iterator 的多种工具函数,它可以几乎代替 for 循环的写法,具体可以查阅 iterator 文档查看所有的函数。

Part 4

怎么从 Part 1 突然就到 Part 4 了呢?因为 shuttle 的挑战是基于日期的,而非递增序号哦。

在柔和的北极光的照耀下,圣诞老人正在训练他的驯鹿团队,一只顽皮的小精灵不小心把绿茶打翻洒在了计算机上,驯鹿们的数据错乱了,现在我们需要帮助恢复这些数据。

Task 1

计算出某一组驯鹿组合的综合力量值:

# 输入输出示例
curl -X POST http://localhost:8000/4/strength \
  -H 'Content-Type: application/json' \
  -d '[
    { "name": "Dasher", "strength": 5 },
    { "name": "Dancer", "strength": 6 },
    { "name": "Prancer", "strength": 4 },
    { "name": "Vixen", "strength": 7 }
  ]'

22

该任务里涉及到了 json 格式的请求体的处理,新增一个路由:

Router::new().route("/4/strength", post(d4_1));

实现路由函数:

use axum::Json;
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct Reindeer {
    name: String,
    strength: i32,
}

async fn d4_1(Json(body): Json<Vec<Reindeer>>) -> String {
    // 使用 iterator 函数式编程写法
    body.iter().map(|x| x.strength).sum::<i32>().to_string()
}

上述中用到了 serde 包处理序列化,我们需要安装它并开启 derive 特性:

cargo add serde --features derive

小提问

在上述的 Reindeer struct 定义中,能否将 name 定义为 &'static str 而非 String 呢?

不行,原因可从 Json<T> 的 trait 实现看出,T 需要满足 DeserializeOwned,该 trait 要求在反序列化时不会借用到外部数据,使用 &'static str 则会借用到数据引用。

Task 2

现在每个驯鹿有了更多的属性,我们需要返回一份统计数据:

# 输入输出示例
curl -X POST http://localhost:8000/4/contest \
  -H 'Content-Type: application/json' \
  -d '[
    {
      "name": "Dasher",
      "strength": 5,
      "speed": 50.4,
      "height": 80,
      "antler_width": 36,
      "snow_magic_power": 9001,
      "favorite_food": "hay",
      "cAnD13s_3ATeN-yesT3rdAy": 2
    },
    {
      "name": "Dancer",
      "strength": 6,
      "speed": 48.2,
      "height": 65,
      "antler_width": 37,
      "snow_magic_power": 4004,
      "favorite_food": "grass",
      "cAnD13s_3ATeN-yesT3rdAy": 5
    }
  ]'

{
  "fastest": "Speeding past the finish line with a strength of 5 is Dasher",
  "tallest": "Dasher is standing tall with his 36 cm wide antlers",
  "magician": "Dasher could blast you away with a snow magic power of 9001",
  "consumer": "Dancer ate lots of candies, but also some grass"
}

这个任务里,我们要根据响应格式来返回内容,其中在计算 "consumer" 里谁吃的糖果最多时用到的是 "cAnD13s_3ATeN-yesT3rdAy" 字段,这个字段名需要额外处理一下。

新增一个路由:

Router::new().route("/4/contest", post(d4_2));

实现路由函数:

#[derive(Debug, Deserialize)]
struct DetailedReindeer {
    name: String,
    strength: i32,
    speed: f32,
    height: i32,
    antler_width: i32,
    snow_magic_power: i32,
    favorite_food: String,
    // 使用 rename 来“修正”奇怪的命名。更多属性可查阅文档 https://serde.rs/attributes.html
    #[serde(rename(deserialize = "cAnD13s_3ATeN-yesT3rdAy"))]
    candy: i32,
}

#[derive(Debug, Serialize)]
struct ReindeerResponse {
    fastest: String,
    tallest: String,
    magician: String,
    consumer: String,
}

async fn d4_2(Json(body): Json<Vec<DetailedReindeer>>) -> Json<ReindeerResponse> {
    let fastest = body.iter().max_by(|a, b| a.speed.total_cmp(&b.speed));
    let tallest = body.iter().max_by_key(|a| a.height);
    let magician = body.iter().max_by_key(|a| a.snow_magic_power);
    let consumer = body.iter().max_by_key(|a| a.candy);

    let res = ReindeerResponse {
        fastest: format!(
            "Speeding past the finish line with a strength of {} is {}",
            fastest.unwrap().strength,
            fastest.unwrap().name
        ),
        tallest: format!(
            "{} is standing tall with his {} cm wide antlers",
            tallest.unwrap().name,
            tallest.unwrap().antler_width
        ),
        magician: format!(
            "{} could blast you away with a snow magic power of {}",
            magician.unwrap().name,
            magician.unwrap().snow_magic_power
        ),
        consumer: format!(
            "{} ate lots of candies, but also some {}",
            consumer.unwrap().name,
            consumer.unwrap().favorite_food
        ),
    };
    return Json::from(res);
}

在查找最大值时,我们使用了 iterator.max_by_key(),其中查找 fastest 使用的是 iterator.max_by(),因为 max_by_key 要求返回的元素内容满足 Ord trait,rust 中的 integer 类型都实现了该 trait,但是 float 类型(f32、f64)并未实现,似乎是因为在 IEEE 754 二进制浮点数运算标准里,浮点数还包括了 -0/+0、Infinity、NaN 等一些特殊值,而 integer 并没有,感兴趣可与在此帖子内了解更多。

小结

本节完,若要有所收获,还需自己动起手来先自己尝试实现哦。