この記事は アドベントカレンダー2024 15日目です。
なんですかこれは
以前にRustでライブラリ(traq-bot-http
)を作った話をしました。
この記事はこのライブラリが進化して色々と遊べるようになった話です。
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ペイロードの構造が変化します。イベントの種類は以下のようなものがあります。
PING
,JOINED
,LEFT
MESSAGE_CREATED
,MESSAGE_UPDATED
,DIRECT_MESSAGE_CREATED
- etc
イベント配信を受け取って適切に分配する部分を解決するのがこのライブラリです。
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点です。
- メソッドチェーンで組み立てられる
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)での体験が良いです。
.
を打つだけで次にやるべきことがわかるようになっています。嬉しいですね。
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 oftower
andtower-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を用いて開発を進めているのですが、この一工夫によって理想的な状況を生み出すことに成功しています。
- traitを定義している層は抽象的なロジックに集中できます。
axum
のこともtraq-bot-http
のことも認知しておらず,tower
のみに依存しています。 - traitを実装する層ではtraQ BOTイベントを受けて何をするか(多分ビジネスロジックというやつ)に集中できます。実際にイベントを受けとる部分は認知せず,
axum
には依存していません。 - 最後の
axum::Router
を組み立てる層では受け取ったBoxCloneService
の扱いだけに集中できます。traq-bot-http
には依存していません。
Handler APIがなかった時には3番の層がtraq-bot-http
に依存していました。Routerを組み立てるだけのはずがtraQ BOTイベントに関しても考える必要があるという、奇妙な状況が改善されたことになります。
いかがでしたか?
他にもボックス化を使わないように設計した話とか、余計な型境界を取り除くように工夫した話(PR #241など)とかあるんですが、まだまだWIPな部分もあるのでこの辺で終わりにします。よかったらstarつけてください。
明日の担当は@Takeno_hitoさんと@cp20さんです!