hiro99ma blog

rust: 同じシグネイチャーの関数をまとめたい

ちょっとしたローカルで動かすサーバみたいなのを作っている。
そのサーバとアクセスするためのクライアントアプリも作る。
手抜きな JSON-RPC というかエンドポイントが 1つしかない REST というか、そんな感じにしようとしている。
“command” と “params” を持つが “params” は単純な配列ではなく普通に key-value も受け取る。
command をエンドポイントにすれば REST になるんじゃないかな? どうかな?

折り返すだけ

まずは折り返すだけのアプリを作る。
examples を参考にした。 axum は “macros” features がないと dbg!() が使えなかったと思う。

use anyhow::Result;
use axum::{
    Json, Router,
    response::IntoResponse,
    routing::post,
};
use serde_json::Value;

const _HOST: &str = "127.0.0.1:9000";

#[tokio::main]
async fn main() -> Result<()> {
    let app = Router::new()
        .route("/", post(handler));

    let listener = tokio::net::TcpListener::bind(_HOST).await?;
    axum::serve(listener, app).await?;
    Ok(())
}

struct Request {


pub async fn handler(Json(value): Json<Value>) -> impl IntoResponse {
    Json(dbg!(value))
}

実行して curl で適当に JSON を与えるとほぼそのまま返ってくる。

$ curl --json @- http://127.0.0.1:9000/ <<EOS
{"hello": "world"}
EOS
{"hello":"world"}

handler() にデータがやってきて、その戻り値がクライアントに返っていく。

JSONデコード

なのでちょっと書き換えるとデコードして処理できる。

use serde_json::{json, Value};

#[derive(Serialize, Deserialize)]
struct Request {
    command: String,
    params: String,
}

pub async fn handler(Json(value): Json<Value>) -> impl IntoResponse {
    let req: Request = match serde_json::from_value(value) {
        Ok(v) => v,
        Err(e) => { return Json(json!({"error": format!("{e}")})) },
    };
    Json(json!({
        "response": req.command,
        "params": req.params,
    }))
}

それっぽい JSON を流すとやってくれる。

$ curl --json @- http://127.0.0.1:9000/ <<EOS
{"hello": "world"}                         
EOS
{"error":"missing field `command`"}


$ curl --json @- http://127.0.0.1:9000/ <<EOS
{"command": "hello","params": "world"}
EOS
{"params":"world","response":"hello"}

“params” は自由な JSON にもできるだろうか。

#[derive(Serialize, Deserialize)]
struct Request {
    command: String,
    params: serde_json::Value,
}

pub async fn handler(Json(value): Json<Value>) -> impl IntoResponse {
    if let Ok(v) = serde_json::to_string_pretty(&value) {
        println!("{v}");
    }
    let req: Request = match serde_json::from_value(value) {
        Ok(v) => v,
        Err(e) => { return Json(json!({"error": format!("{e}")})) },
    };
    Json(json!({
        "response": req.command,
        "params": req.params,
    }))
}

ちゃんとわかってくれるようだ。

$ curl --json @- http://127.0.0.1:9000/ <<EOS
{"command": "greet", "params": ["hello", "world", 12345]}
EOS
{"params":["hello","world",12345],"response":"greet"}

ちなみに println! の方はこう出力されている。

{
  "command": "greet",
  "params": [
    "hello",
    "world",
    12345
  ]
}

command ごとに関数を分けたい

エンドポイントが増えない代わりに “command” での分岐が増える。
もしかしたらエンドポイントを増やした方が楽だったのだろうか。。。
まあよかろう。

今回やりたいのは “command” と handler を紐づけて match などで分岐するのではなく HashMap などで割り当てるやり方だ。
最初は enum でいろいろ引数を取れるようにして match で分岐していたのだけど、面倒になったのだ。

Gemini に聞いてみたところでは「Commandパターンみたいな感じでできる」だそうだ。
あったね、そういうの。
引数にいろいろな型を与えることができる、ではなく、引数も含めた struct にしてしまって実行は引数なしにする、という感じか。

1つで試す

まずは handler を 1つにして試す。
というだけだったのだが、大改造になってしまった。
handler を保持しておくのに AppState を用意して状態変数として持つようにしている。

Handler が Clone できないとダメということで “dyn-clone” を使った。 ええ、Gemini が言ったとおりにやっただけですよ。

use anyhow::Result;
use axum::{
    extract::State,
    Json, Router,
    response::IntoResponse,
    routing::post,
};
use dyn_clone::DynClone;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

const _HOST: &str = "127.0.0.1:9000";

#[tokio::main]
async fn main() -> Result<()> {
    let app = Router::new()
        .route("/", post(AppState::handler))
        .with_state(AppState::new());

    let listener = tokio::net::TcpListener::bind(_HOST).await?;
    axum::serve(listener, app).await?;
    Ok(())
}

#[derive(Clone)]
struct AppState {
    pub handler: Box<dyn Handler + Send + Sync>,
}

#[derive(Deserialize)]
struct Request {
    command: String,
    params: Value,
}

impl AppState {
    fn new() -> Self {
        Self {
            handler: Box::new(CommandAlice::new()),
        }
    }

    async fn handler(
        State(state): State<Self>,
        Json(value): Json<Value>,
    ) -> impl IntoResponse {
        let req: Request = match serde_json::from_value(value) {
            Ok(v) => v,
            Err(e) => return Json(json!({"error": format!("{e}")})),
        };
        let resp = match state.handler.execute(req.params) {
            Ok(v) => json!({"response": v}),
            Err(e) => json!({"error": format!("{e}")}),
        };
        Json(resp)
    }
}

pub trait Handler: DynClone {
    fn execute(&self, params: Value) -> Result<Value>;
}

dyn_clone::clone_trait_object!(Handler);

#[derive(Serialize, Deserialize, Clone)]
struct CommandAlice {
    value: i32,
    host: String,
}

impl CommandAlice {
    fn new() -> Self {
        Self {
            value: 0,
            host: "".to_string(),
        }
    }
}

impl Handler for CommandAlice {
    fn execute(&self, params: Value) -> Result<Value> {
        let req: CommandAlice = match serde_json::from_value(params) {
            Ok(v) => v,
            Err(e) => {
                anyhow::bail!(format!("{e}"))
            },
        };
        Ok(json!({
            "value": req.value,
            "name": req.host,
        }))
    }
}

async にする

元の handler()async なので execute() もそうしておこう。
async をつけると Clone がそれではダメなようでエラーになった。

async をつけるのではなく戻り値の型を Box<dyn Future<Output = ()> + Send> などにしましょう、というのが Gemini のお告げだった。
が、これは Clone のときのように “async-trait” を使うとそこまで面倒にならないとのこと。

use anyhow::Result;
use axum::{
    extract::State,
    Json, Router,
    response::IntoResponse,
    routing::post,
};
use async_trait::async_trait;
use dyn_clone::DynClone;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

const _HOST: &str = "127.0.0.1:9000";

#[tokio::main]
async fn main() -> Result<()> {
    let app = Router::new()
        .route("/", post(AppState::handler))
        .with_state(AppState::new());

    let listener = tokio::net::TcpListener::bind(_HOST).await?;
    axum::serve(listener, app).await?;
    Ok(())
}

#[derive(Clone)]
struct AppState {
    pub handler: Box<dyn Handler + Send + Sync>,
}

#[derive(Deserialize)]
struct Request {
    command: String,
    params: Value,
}

impl AppState {
    fn new() -> Self {
        Self {
            handler: Box::new(CommandAlice::new()),
        }
    }

    async fn handler(
        State(state): State<Self>,
        Json(value): Json<Value>,
    ) -> impl IntoResponse {
        let req: Request = match serde_json::from_value(value) {
            Ok(v) => v,
            Err(e) => return Json(json!({"error": format!("{e}")})),
        };
        let resp = match state.handler.execute(req.params).await {
            Ok(v) => json!({"response": v}),
            Err(e) => json!({"error": format!("{e}")}),
        };
        Json(resp)
    }
}

#[async_trait]
pub trait Handler: DynClone + Send + Sync {
    async fn execute(&self, params: Value) -> Result<Value>;
}

dyn_clone::clone_trait_object!(Handler);

#[derive(Serialize, Deserialize, Clone)]
struct CommandAlice {
    value: i32,
    host: String,
}

impl CommandAlice {
    fn new() -> Self {
        Self {
            value: 0,
            host: "".to_string(),
        }
    }
}

#[async_trait]
impl Handler for CommandAlice {
    async fn execute(&self, params: Value) -> Result<Value> {
        let req: CommandAlice = match serde_json::from_value(params) {
            Ok(v) => v,
            Err(e) => {
                anyhow::bail!(format!("{e}"))
            },
        };
        Ok(json!({
            "value": req.value,
            "name": req.host,
        }))
    }
}

2つにする

あとは HashMap にして追加するだけだ。
CommandBob を追加した。コマンド名を key に追加していった。
コードが長い。GitHub などに上げればよかった。。。

use anyhow::Result;
use axum::{
    extract::State,
    Json, Router,
    response::IntoResponse,
    routing::post,
};
use async_trait::async_trait;
use dyn_clone::DynClone;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;

const _HOST: &str = "127.0.0.1:9000";

#[tokio::main]
async fn main() -> Result<()> {
    let app = Router::new()
        .route("/", post(AppState::handler))
        .with_state(AppState::new());

    let listener = tokio::net::TcpListener::bind(_HOST).await?;
    axum::serve(listener, app).await?;
    Ok(())
}

#[derive(Clone)]
struct AppState {
    pub handler: HashMap<&'static str, Box<dyn Handler + Send + Sync>>,
}

#[derive(Deserialize)]
struct Request {
    command: String,
    params: Value,
}

impl AppState {
    fn new() -> Self {
        let mut map: HashMap<&'static str, Box<dyn Handler + Send + Sync>> = HashMap::new();
        map.insert("alice", Box::new(CommandAlice::new()));
        map.insert("bob", Box::new(CommandBob::new()));
        Self {
            handler: map,
        }
    }

    async fn handler(
        State(state): State<Self>,
        Json(value): Json<Value>,
    ) -> impl IntoResponse {
        let req: Request = match serde_json::from_value(value) {
            Ok(v) => v,
            Err(e) => return Json(json!({"error": format!("{e}")})),
        };
        let resp = match state.handler[&req.command.as_str()].execute(req.params).await {
            Ok(v) => json!({"response": v}),
            Err(e) => json!({"error": format!("{e}")}),
        };
        Json(resp)
    }
}

#[async_trait]
pub trait Handler: DynClone + Send + Sync {
    async fn execute(&self, params: Value) -> Result<Value>;
}

dyn_clone::clone_trait_object!(Handler);

#[derive(Serialize, Deserialize, Clone)]
struct CommandAlice {
    value: i32,
    host: String,
}

impl CommandAlice {
    fn new() -> Self {
        Self {
            value: 0,
            host: "".to_string(),
        }
    }
}

#[async_trait]
impl Handler for CommandAlice {
    async fn execute(&self, params: Value) -> Result<Value> {
        let req: Self = match serde_json::from_value(params) {
            Ok(v) => v,
            Err(e) => {
                anyhow::bail!(format!("{e}"))
            },
        };
        Ok(json!({
            "value": req.value,
            "name": req.host,
        }))
    }
}

#[derive(Serialize, Deserialize, Clone)]
struct CommandBob {
    rate: f64,
    place: String,
    game: String,
}

impl CommandBob {
    fn new() -> Self {
        Self {
            rate: f64::default(),
            place: String::default(),
            game: String::default(),
        }
    }
}

#[async_trait]
impl Handler for CommandBob {
    async fn execute(&self, params: Value) -> Result<Value> {
        let req: Self = match serde_json::from_value(params) {
            Ok(v) => v,
            Err(e) => {
                anyhow::bail!(format!("{e}"))
            },
        };
        Ok(json!({
            "rate": req.rate,
            "game_place": format!("{}-{}", req.place, req.game),
        }))
    }
}

実行するとコマンドで呼び分けされている。

$ curl --json @- http://127.0.0.1:9000/ <<EOS
{"command": "alice", "params": {"value": 32, "host": "local"}}
EOS
{"response":{"name":"local","value":32}}


$ curl --json @- http://127.0.0.1:9000/ <<EOS
{"command": "bob", "params": {"rate": 0.75, "place": "Japan", "game": "hanafuda"}}
EOS
{"response":{"game_place":"Japan-hanafuda","rate":0.75}}

REST

最後に、素直に REST っぽくエンドポイントごとに呼び出しを分ける。
“command” がその分いらなくなる。
AppState や “params” もいらないのだけど、面倒なので残した。

トレイトや async で面倒だった箇所が全部なくなるのでかなりすっきりする。
すっきりするが負けた気分だ。

use anyhow::Result;
use axum::{Json, Router, extract::State, response::IntoResponse, routing::post};
use serde::Deserialize;
use serde_json::{Value, json};

const _HOST: &str = "127.0.0.1:9000";

#[tokio::main]
async fn main() -> Result<()> {
    let app = Router::new()
        .route("/alice", post(CommandAlice::handler))
        .route("/bob", post(CommandBob::handler))
        .with_state(AppState {});

    let listener = tokio::net::TcpListener::bind(_HOST).await?;
    axum::serve(listener, app).await?;
    Ok(())
}

#[derive(Clone)]
struct AppState;

#[derive(Deserialize)]
struct Request {
    params: Value,
}

#[derive(Deserialize)]
struct CommandAlice {
    value: i32,
    host: String,
}

impl CommandAlice {
    async fn handler(
        State(_state): State<AppState>,
        Json(value): Json<Value>,
    ) -> impl IntoResponse {
        let resp = match Self::execute(value).await {
            Ok(v) => json!({"response": v}),
            Err(e) => json!({"error": format!("{e}")}),
        };
        Json(resp)
    }

    async fn execute(value: Value) -> Result<Value> {
        let req: Request = serde_json::from_value(value)?;
        let req: Self = serde_json::from_value(req.params)?;
        Ok(json!({
            "value": req.value,
            "name": req.host,
        }))
    }
}

#[derive(Deserialize)]
struct CommandBob {
    rate: f64,
    place: String,
    game: String,
}

impl CommandBob {
    async fn handler(
        State(_state): State<AppState>,
        Json(value): Json<Value>,
    ) -> impl IntoResponse {
        let resp = match Self::execute(value).await {
            Ok(v) => json!({"response": v}),
            Err(e) => json!({"error": format!("{e}")}),
        };
        Json(resp)
    }

    async fn execute(value: Value) -> Result<Value> {
        let req: Request = serde_json::from_value(value)?;
        let req: Self = serde_json::from_value(req.params)?;
        Ok(json!({
            "rate": req.rate,
            "game_place": format!("{}-{}", req.place, req.game),
        }))
    }
}

実行。
特にいうことはない。

$ curl --json @- http://127.0.0.1:9000/alice <<EOS
{"params": {"value": 32, "host": "local"}}
EOS
{"response":{"name":"local","value":32}}


$ curl --json @- http://127.0.0.1:9000/bob <<EOS
{"params": {"rate": 0.75, "place": "Japan", "game": "hanafuda"}}
EOS
{"response":{"game_place":"Japan-hanafuda","rate":0.75}}

おまけ

この内容は別の作業でやっていて、そのときは Copilot に丸投げして変更してもらった。
そのときは “dyn-clone” や “async-trait” が使われておらず、 いきなり Pin<> やら Send + Sync やらが出てきて思考停止して使う羽目になった。
理解しようと思ってこの記事を書き始めたのだが、まさかクレートの導入で楽にできそうだとは思いもよらなかった。
動いているものを書き換える気力もないので、そこは悩むところだ。

“dyn-clone” や “async-trait” のリポジトリを見ると Star がそこそこ付いているので 大丈夫かなと思って使っているが、ダミーのアカウントを大量に作って Star を付ける なんてことは悪い奴らなら普通にやりそうなので安心もできないだろう。

と心配ばかりしていても自分で実装できるわけじゃないし、 できる人だって一から作りたいとは思うまい。
そういういろんなバランスで最近のソフトウェアは成り立っているのだなあ。

writer: hiro99ma
tags: Rust言語

 
About me
About me
comment
Comment Form
🏠
Top page
GitHub
GitHub
Twitter
X/Twitter
My page
Homepage