feature image

2024年12月15日 | ブログ記事

Rustでライブラリを作った 続編

この記事は アドベントカレンダー2024 15日目です。

なんですかこれは

以前にRustでライブラリ(traq-bot-http)を作った話をしました。

Rustでライブラリを作った
この記事は新歓ブログリレー2023 [https://trap.jp/tag/welcome-relay-2023]20日目です。 TL; DRリポジトリです。 GitHub - H1rono/traq-bot-http-rs: traQ BOTのHTTPリクエストパーサーtraQ BOTのHTTPリクエストパーサー.Contribute to H1rono/traq-bot-http-rs development by creating an account onGitHub.GitHubH1rono [https://github.com/H1rono/traq-bot-http-rs]そして、こちらがcrates.io [https://crates.io]に公開しているライブラリです。 https://crates.io/crates/traq-bot-http なんですかこれは部内SNSのtraQ [https://trap.

この記事はこのライブラリが進化して色々と遊べるようになった話です。

TL; DR

Handler API (docs, PR #231) を実装しました。

before (full example):

// axum = "0.7.9"
use axum::extract::{Request, State};
use axum::{routing::post, Router};
// http = "1.2.0"
use http::StatusCode;
// traq-bot-http = { version = "0.11.1", features = ["http"] }
use traq_bot_http::{Event, RequestParser};

let parser = RequestParser::new(todo!("provide bot verification token"));
let app = Router::new().route("/", post(handler)).with_state(parser);

async fn handler(State(parser): State<RequestParser>, request: Request) -> StatusCode {
    match parser.parse_request(request).await {
        Ok(Event::MessageCreated(payload)) => {
            print!(
                "{}さんがメッセージを投稿しました。\n内容: {}\n",
                payload.message.user.display_name, payload.message.text
            );
            StatusCode::NO_CONTENT
        }
        Ok(_) => StatusCode::NO_CONTENT,
        Err(err) => {
            eprintln!("ERROR: {err}");
            StatusCode::INTERNAL_SERVER_ERROR
        }
    }
}

after (full example):

use axum::{routing::post_service, Router};
use http::StatusCode;
// tower = "0.5.1"
use tower::service_fn;
// traq-bot-http-http = { version = "0.11.1", features = ["tower"] }
use traq_bot_http::{payloads, RequestParser};

let parser = RequestParser::new(todo!("provide bot verification token"));
let handler = parser
    .into_handler()
    .on_message_created(service_fn(on_message_created));
let app = Router::new().route(
    "/",
    post_service(handler).handle_error(|_| async { StatusCode::INTERNAL_SERVER_ERROR }),
);

async fn on_message_created(
    payload: payloads::MessageCreatedPayload,
) -> Result<(), std::convert::Infallible> {
    print!(
        "{}さんがメッセージを投稿しました。\n内容: {}\n",
        payload.message.user.display_name, payload.message.text
    );
    Ok(())
}

何を作ったんですか

BuilderライクにtraQ BOTイベントのハンドラを構成できるようにしました。

いきなりScalaを書いたなどにも書きましたが、traQ BOT(のHTTPモード)はHTTPサーバーです。traQでのイベントが自分たちのHTTPサーバーにHTTP POSTリクエストで配信されます。ヘッダーX-TRAQ-BOT-EVENTにイベントの種類が入り、その値に応じてリクエストボディのJSONペイロードの構造が変化します。イベントの種類は以下のようなものがあります。

イベント配信を受け取って適切に分配する部分を解決するのがこのライブラリです。

tower::Serviceを受け取ってtower::Serviceを組み立てる形式のAPIです。

use http::{Request, Response};
use tower::Serivce;
use traq_bot_http::{RequestParser, payloads};

fn assert_impl_service<S>(_: &S)
where
    S: Service<Request<String>, Response = Response<String>, Error = traq_bot_http::Error>,
{}

let parser: RequestParser;
let handler = parser
    .into_handler()
    .on_ping(todo!("impl Service<payloads::PingPayload, Response = ()>"))
    .on_message_created(todo!("impl Service<payloads::MessageCreatedPayload, Response = ()>"))
    ...;
// compiles
assert_impl_service(&handler);

with_stateで状態を追加することもできます。

type State = ...;

let state: State;
let handler = parser
    .into_handler()
    .on_left(todo!("impl Service<(State, payloads::LeftPayload), Response = ()>"))
    .on_joined(todo!("impl Service<(State, payloads::JoinedPayload), Response = ()>"))
    .with_state(state);

// compiles
assert_impl_service(&handler);

stateを受け取るのにwith_stateが登録されていない場合にはService traitを実装しません。

let handler = parser
    .into_handler()
    .on_ping(todo!("impl Service<(State, payloads::PingPayload), Response = ()>"))

// does not compile
assert_impl_service(&handler);

何が嬉しいんですか

大きく分けて2点です。

  1. メソッドチェーンで組み立てられる
  2. tower::Serviceをサポートしている

それぞれ詳しく見ていきます。

メソッドチェーン

exampleの通り、イベントハンドラはメソッドチェーンで構成されます。これによって、ユーザーの負担が大きく減りました。

まず、これまではユーザーにmatchを書くことが強制されていました。Handler API導入前のexampleをもう一度見てみましょう。

use traq_bot_http::{Event, RequestParser};

let parser: RequestParser;

match parser.parse_request(request).await {
    Ok(Event::MessageCreated(payload)) => todo!(),
    Ok(_) => todo!(),
    Err(err) => todo!(),
}

このmatchが問題となっていました。ユーザーが欲しいのはOk(Event::MessageCreated(payload))payload部分だけなのに, Ok(_)Err(err)の場合にも対処することを強制してしまっています。let-elseを上手く適用できたらよかったんですが, Ok(_)の場合とErr(err)の場合でとるべき対応は当然異なるためできません。ライブラリ側で巻き取れるmatchをユーザーに負担させているのです。

次に、メソッドチェーンで構成できるようになりました。今度はHandler API導入後のexampleを見てみましょう。

use tower::service_fn;
use traq_bot_http::{payloads, RequestParser};

let parser: RequestParser;
let handler = parser
    .into_handler()
    .on_message_created(service_fn(on_message_created));

async fn on_message_created(
    payload: payloads::MessageCreatedPayload,
) -> Result<(), std::convert::Infallible> {
    todo!()
}

handlerがメソッドチェーンによって組み立てられています。この「メソッドチェーンで組み立てられる」というのが重要です。どういうことかというと、補完機能付きのエディタ(例えばVSCode + rust-analyzer)での体験が良いです。

handler-completion

.を打つだけで次にやるべきことがわかるようになっています。嬉しいですね。

Rust API Guidelinesにもありますが、関数よりメソッドの方が好まれます。(C-METHOD)

Call expressions - The Rust ReferenceまたはUFCSとは関係がありそうですが、ここではあまり関係ありません。一般的には関係あるはずです。

tower::Serviceサポート

適切に構成されたHandlerはtower::Service traitを実装します。

これだけですが強烈です。どういうことかというと、tower周りのエコシステムをフル活用できますaxumはまさにいい例です。以下はaxum::middlewareからの一部引用です。

axum is unique in that it doesn't have its own bespoke middleware system and instead integrates with tower. This means the ecosystem of tower and tower-http middleware all work with axum.

ライブラリ開発側としてこれほど嬉しい存在はありませんね。「いかにライブラリを薄く保ちつつ流行りのライブラリに適応するか」という問題に完璧と言っていい回答を与えることができます。Handler APIはaxumに依存していませんが, Handler APIはaxumに対応していると胸を張って言えます。

use axum::{routing::post_service, Router};
use traq_bot_http::RequestParser;

let parser: RequestParser;
let handler = parser
    .into_handler()
    ...;
let app = Router::new().route(
    "/",
    post_service(handler).handle_error(|_| async { StatusCode::INTERNAL_SERVER_ERROR }),
);

axum::routing::post_serviceがいい働きをしてくれていますね。

pub fn post_service<T, S>(svc: T) -> MethodRouter<S, T::Error>
where
    T: Service<Request> + Clone + Send + 'static,
    T::Response: IntoResponse + 'static,
    T::Future: Send + 'static,
    S: Clone,

使用例

このライブラリ使って私が実際に作ったBOTでは, Handler APIとtowerのエコシステムを活用してDIにも応用しています。

tower::util::BoxCloneServiceを返すようなtraitを定義し、

trait実装側で (Handler APIを利用して) BoxCloneServiceを作成して、

axum::Routerを組み立てる側ではtrait経由でBoxCloneServiceを受け取ります。

このBOTではcargo workspaceを用いて開発を進めているのですが、この一工夫によって理想的な状況を生み出すことに成功しています。

  1. traitを定義している層は抽象的なロジックに集中できます。axumのこともtraq-bot-httpのことも認知しておらず, towerのみに依存しています。
  2. traitを実装する層ではtraQ BOTイベントを受けて何をするか(多分ビジネスロジックというやつ)に集中できます。実際にイベントを受けとる部分は認知せず, axumには依存していません。
  3. 最後のaxum::Routerを組み立てる層では受け取ったBoxCloneServiceの扱いだけに集中できます。traq-bot-httpには依存していません。

Handler APIがなかった時には3番の層がtraq-bot-httpに依存していました。Routerを組み立てるだけのはずがtraQ BOTイベントに関しても考える必要があるという、奇妙な状況が改善されたことになります。

いかがでしたか?

他にもボックス化を使わないように設計した話とか、余計な型境界を取り除くように工夫した話(PR #241など)とかあるんですが、まだまだWIPな部分もあるのでこの辺で終わりにします。よかったらstarつけてください。

GitHub - H1rono/traq-bot-http-rs: traQ BOTのHTTPリクエストパーサー
traQ BOTのHTTPリクエストパーサー. Contribute to H1rono/traq-bot-http-rs development by creating an account on GitHub.

明日の担当は@Takeno_hitoさんと@cp20さんです!

H1rono_K icon
この記事を書いた人
H1rono_K

趣味プログラマー(大学生)

この記事をシェア

このエントリーをはてなブックマークに追加
共有

関連する記事

2023年7月15日
2023 春ハッカソン 06班 stamProlog
H1rono_K icon H1rono_K
2022年9月26日
競プロしかシラン人間が web アプリ QK Judge を作った話
tqk icon tqk
2024年12月11日
Nixで実行環境のライセンス違反を予防する話
comavius icon comavius
2024年8月29日
クロスコンパイルRust
H1rono_K icon H1rono_K
2023年12月25日
オレオレRustプロジェクトテンプレート
H1rono_K icon H1rono_K
2023年9月13日
ブログリレーを支えるリマインダー
H1rono_K icon H1rono_K
記事一覧 タグ一覧 Google アナリティクスについて 特定商取引法に基づく表記