フューチャーアドベントカレンダーの22日目の記事です
はじめに
私はプログラミング初心者ですが、ITエンジニアリングスキルを高めて手に職をつけたい!という気持ちがあります。
そこで、まずは社会で比較的需要の多い(のか?)Webバックエンド開発の基礎を習得したいと思いました。Webバックエンドフレームワークは星の数ほどあれど、それらに共通した要素は多いはずで、どれか一つにある程度習熟してしまえば、新たなフレームワークを学ぶのは容易になるはずです。
と考えると何でもいいような気がしたので、自分の好きな言語であるRustのWebフレームワークAxum
を選びました。ただ、Axum
はかなり新しいフレームワークで、チュートリアルなどが見当たらないので、Goのnet/http
のチュートリアルWriting Web ApplicationをAxum
で実装してみました。実装は以下にあります。
Axumとは
Axum
は非常にスリムなフレームワークです。Axum
はGitHubのREADMEに書かれているようにErgonomicsとModularityにフォーカスしています。
Modularity
Axum
は色々なクレートがレゴブロックのように組み合わされています。
例えば、非同期ランタイムはtokio
、サーバーはhyper
が使われています。
また、Axum
はService
という「Requestを受け取りResponseを返すもの」を抽象化したトレイト(Javaでいうインターフェイスのようなもの)を土台としています。Service
はtower
で定義されています。
Axum
はService
のルーティングに特化しています。後述するハンドラはService
に変換できます。
Ergonomics
Ergonomicsは人間工学という意味だそうです。使いやすさを重視しているということでしょう。
以下のような特徴があるようです。
マクロフリーなルーティング
Webフレームワークの中にはアノテーション(マクロ)でルーティングするWebフレームワークも多いですが、Axum
は現在のところ、その方式は採用していません(Axum v0.4時点)。
マクロは非常に高い表現力ゆえに、何をしているのか分かりづらいという側面があるので、なるべく使用を避けようとしているのでしょう。
宣言的なリクエストとレスポンスのパース
net/http
のハンドラは、要求されたインターフェイスを実装した構造体に対して、操作を施し、リクエストからデータを抽出したりレスポンスを返したりしたりする方式を採用していますが、Axumではハンドラはリクエストは0個以上のFromRequest
を実装したもの(Extractor
と呼ばれます)で、レスポンスはIntoResponse
を実装したものです。
言葉だと分かりづらいので具体例で比較してみます。
func handler(w http.ResponseWriter, r *http.Request) { ... }
async fn hander(uri: Uri, method: Method, headers: HeaderMap, body: Bytes) -> (StatusCode, HeaderMap, &'static str) { ... }
net/http
ではリクエストとレスポンスを処理したければ脳死でhttp.ResoponseWriter
と*http.Request
を使いますが、Axum
の場合、リクエストやレスポンスにおいて具体的に欲しい物、返したい物を引数、返り値に書いていきます。
ハンドラの引数や戻り値で使うためにはFromRequest
やIntoResponse
が実装されていなければなりませんが、よく使われるものには、FromRequest
やIntoResponse
の実装が用意されています。
Gowiki
Writing Web ApplicationはGowikiという超簡易的なWikiを作るチュートリアルです。
簡単のため、Wikiのページはファイルで管理されています。
APIとしては
- ページの参照
- 編集(新規作成)
- 保存機能
が提供されています。
同じ機能を実装したAxumとnet/httpのコードを見比べたときの所感
net/http
はGoが標準で提供しているパッケージで、機能は厳選されています。これを素で使うことは少ないでしょうが、渋川さんの記事によるとgin
もecho
もnet/http
のラッパーみたいなので、実質かなり広く使われているフレームワークと言っていいでしょう。Axum
はサービスのルーティングに特化したかなりスリムなフレームワークです。もしかしたら、net/http
と同じようにAxum
をベースにしたさらにリッチなフレームワークが登場するかもしれません。
ルーティング
まず、実装を見比べてみます。
func main() {
http.HandleFunc("/view/", makeHandler(viewHandler))
http.HandleFunc("/edit/", makeHandler(editHandler))
http.HandleFunc("/save/", makeHandler(saveHandler))
log.Fatal(http.ListenAndServe(":8080", nil))
}
https://golang.org/doc/articles/wiki/ から引用
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/view/:title", get(view))
.route("/edit/:title", get(edit))
.route("/save/:title", post(save))
.layer(extractor_middleware::<ValidTitle>());
axum::Server::bind(&"127.0.0.1:8080".parse().unwrap())
.serve(app.into_make_service())
.await
.unwrap();
}
net/http
でまず気になったのが、ぱっと見ハンドラがルータに紐付けられているように見えないことです。
この記事が非常に参考になりましたが、DefaultServeMux
というグローバル変数が存在していてhandlerFunc
が呼ばれるとDefaultServeMux
に紐付けられるそうです。
そして、http.ListenAndServe
の第2引数にnil
が渡されるとサーバーにDefaultServeMux
が渡されるという作りになっています。
知ってしまえばなんてことはないですが、私は最初にコードを見た時に少し戸惑いました。
Axum
のコードはスタンダードな作りに見えます。Axum
はhyper
のServer
を再エクスポートしていてaxum::Server::bind(&"127.0.0.1:3000".parse().unwrap()).serve(app.into_make_service()).~~
というのは実際はhyper
の関数を呼び出しています。
hyper
のserve
関数はサービスを引数に要求するのでapp(ルーター)のinto_make_service()
関数を呼び出してサービスに変換しています。
ミドルウェア
こちらも実装を示します。
var validPath = regexp.MustCompile("^/(edit|save|view)/([a-zA-Z0-9]+)$")
func makeHandler(fn func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
m := validPath.FindStringSubmatch(r.URL.Path)
if m == nil {
http.NotFound(w, r)
return
}
fn(w, r, m[2])
}
}
https://golang.org/doc/articles/wiki/ から引用
pub struct ValidTitle;
#[async_trait]
impl<B> FromRequest<B> for ValidTitle
where
B: Send,
{
type Rejection = (StatusCode, HeaderMap, String);
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
let re = Regex::new(r"^/[a-zA-Z0-9]+$").unwrap();
let path = req.uri().path();
let title = path
.trim_start_matches('/')
.trim_start_matches(|c| c != '/');
if !re.is_match(title) {
return Err((
StatusCode::NOT_FOUND,
HeaderMap::new(),
"invalid Page Title".to_string(),
));
}
Ok(Self)
}
}
/*
let app = Router::new()
.route("/view/:title", get(view))
.route("/edit/:title", get(edit))
.route("/save/:title", post(save))
.layer(extractor_middleware::<ValidTitle>()); // 1. ここで使用。
*/
チュートリアルではURLのpathのバリデーションをするミドルウェアを実装する章がありました。
net/http
もAxum
もハンドラに機能を付加するミドルウェアという仕組みを持っています。net/http
はミドルウェアはhandlerFunc
を返す関数を引数にとり、handerFunc
を返すという非常に分かりやすい作りになっています。
サンプルコードを見て気になったのは引数の関数のシグネチャに戻り値が書かれていないことです。これは推論されるということなのでしょうか。。
Axum
はtower
のミドルウェアの仕組みをシームレスに使えるようになっています。tower
におけるミドルウェアとは生成時にService
を受け取るService
です。ハンドラはService
に変換できます。なので、ハンドラをミドルウェアでレイヤリングすることができます。
「~はServiceです。」は「~はServiceトレイトを実装したなにかです。」という意味です。
もともと存在するミドルウェアを使うのは簡単なのですが、ミドルウェアを自作するためにはRustの非同期に関する知識とtower
のService
に関する知識が必要になります。
例としてTimeoutミドルウェアの実装を示します。
use pin_project::pin_project;
use std::time::Duration;
use std::{
fmt,
future::Future,
pin::Pin,
task::{Context, Poll},
};
use tokio::time::Sleep;
use tower::Service;
#[derive(Debug, Clone)]
struct Timeout<S> {
inner: S,
timeout: Duration,
}
impl<S> Timeout<S> {
fn new(inner: S, timeout: Duration) -> Self {
Timeout { inner, timeout }
}
}
impl<S, Request> Service<Request> for Timeout<S>
where
S: Service<Request>,
S::Error: Into<BoxError>,
{
type Response = S::Response;
type Error = BoxError;
type Future = ResponseFuture<S::Future>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx).map_err(Into::into)
}
fn call(&mut self, request: Request) -> Self::Future {
let response_future = self.inner.call(request);
let sleep = tokio::time::sleep(self.timeout);
ResponseFuture {
response_future,
sleep,
}
}
}
#[pin_project]
struct ResponseFuture<F> {
#[pin]
response_future: F,
#[pin]
sleep: Sleep,
}
impl<F, Response, Error> Future for ResponseFuture<F>
where
F: Future<Output = Result<Response, Error>>,
Error: Into<BoxError>,
{
type Output = Result<Response, BoxError>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.project();
match this.response_future.poll(cx) {
Poll::Ready(result) => {
let result = result.map_err(Into::into);
return Poll::Ready(result);
}
Poll::Pending => {}
}
match this.sleep.poll(cx) {
Poll::Ready(()) => {
let error = Box::new(TimeoutError(()));
return Poll::Ready(Err(error));
}
Poll::Pending => {}
}
Poll::Pending
}
}
#[derive(Debug, Default)]
struct TimeoutError(());
impl fmt::Display for TimeoutError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad("request timed out")
}
}
impl std::error::Error for TimeoutError {}
type BoxError = Box<dyn std::error::Error + Send + Sync>;
https://github.com/tower-rs/tower/blob/master/guides/building-a-middleware-from-scratch.md から引用
。。。🙄
#[pin]
、Future
、Poll
など、Rustの非同期を勉強したことがないと、意味が分からないと思います。私はRustの非同期を少し勉強したことはありますが(全然理解できてない😭)、それでもミドルウェアをすらすら書ける自信がありません🤪
とはいえ、独自のミドルウェアを実装したいときに、必ずしもこのような実装をしなければいけないわけではなく、Axum
にはExtractor
をミドルウェアに変換するextractor_middleware
という関数が存在します。私の実装ではこれを用いました。常識が全く分かりませんが、汎用性を考えないミドルウェアはextractor_middleware
を使うのが定石なのかもしれません。
正常系以外のレスポンスの返し方
サーバーは常に正常のレスポンスを返すわけではなく、INTERNAL_SERVER_ERROR
、BAD_REQUEST
、UNAUTHORIZED
などのエラー(400、500番台)やリダイレクト(300番台)を返すこともあります。
これの実現方法ですがnet/http
ではhttp.Error(...)
やhttp.Redirect(...)
等を呼ぶだけでした。
Axumの場合はResult
を使います。Result
は列挙型です。以下のような定義になっていて、OK<T>
とErr<E>
の直和です。Axum
では、正常ならOk<T>
を返し、エラーやリダイレクトならErr<T>
を返すように実装します。(と思っていますが、違うかもしれません。違かったらごめんなさい🙇♂️)
pub enum Result<T, E> {
Ok(T),
Err(E),
}
ただ、Axum
でエラーとリダイレクトを実装したときに少し困ったことがありました。
Wikiのページを返すview関数では、ページが見つからなければリダイレクト、ページが見つかればそのページを返します。
Goは以下のような実装でした。
func viewHandler(w http.ResponseWriter, r *http.Request, title string) {
p, err := loadPage(title)
if err != nil {
http.Redirect(w, r, "/edit/"+title, http.StatusFound)
return
}
renderTemplate(w, "view", p)
}
https://golang.org/doc/articles/wiki/ から引用
チュートリアルなので、renderTemplate
(内部でfunc (t *Template) Execute(wr io.Writer, data interface{}) error
を呼んでいる)のエラーハンドリングは省かれてましたが、renderTemplateはエラーを発生しうる箇所です。つまり、この関数は正常系、リダイレクト、サーバーエラーの3種類のレスポンスを返す可能性があります。
Rustでは、私は最初以下のように書きました。
async fn view(Path(title): Path<String>) -> Result<impl IntoResponse, impl IntoResponse> {
let page = match load_page(&title) {
Ok(page) => page,
Err(_) => return Err(Redirect::found(format!("/edit/{}", title))), // 1. Err<Redirect>を返す。
};
let mut context = Context::new();
context.insert("title", &page.title);
context.insert("body", &String::from_utf8(page.body).unwrap());
match TEMPLATES.render("view.html", &context) {
Ok(html) => Ok(Html(html)),
Err(err) => Err(handle_error(err)), // 2.Err<(StatusCode, HeaderMap, String)>を返す。
}
}
fn handle_error(err: impl std::error::Error) -> (StatusCode, HeaderMap, String) {
(
StatusCode::INTERNAL_SERVER_ERROR,
HeaderMap::new(),
format!("Something went wrong: {}", err),
)
}
しかし、これはコンパイルエラーになります。なぜなら1と2で返す型が違うからです。従って、私は以下のように書いてエラーで返す型を一致させました。
async fn view(Path(title): Path<String>) -> Result<impl IntoResponse, impl IntoResponse> {
let page = match load_page(&title) {
Ok(page) => page,
Err(_) => return Err(handle_redirect(&title)), // 1.Err<(StatusCode, HeaderMap, String)>を返す。
};
let mut context = Context::new();
context.insert("title", &page.title);
context.insert("body", &String::from_utf8(page.body).unwrap());
match TEMPLATES.render("view.html", &context) {
Ok(html) => Ok(Html(html)),
Err(err) => Err(handle_error(err)), // 2.Err<(StatusCode, HeaderMap, String)>を返す。
}
}
fn handle_error(err: impl std::error::Error) -> (StatusCode, HeaderMap, String) {
(
StatusCode::INTERNAL_SERVER_ERROR,
HeaderMap::new(),
format!("Something went wrong: {}", err),
)
}
fn handle_redirect(title: &str) -> (StatusCode, HeaderMap, String) {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::LOCATION,
HeaderValue::from_str(&format!("/edit/{}", title)).unwrap(),
);
(StatusCode::TEMPORARY_REDIRECT, headers, "".to_string())
}
しかし、リダイレクトを返す関数があるのに、手動でヘッダーにLocation
をセットするというのも変な感じです。おそらくいいやり方はあるんでしょうが、私には分かりませんでした。
おわりに
本記事ではAxum
でGoのチュートリアルWriting Web Applicationを実装してみました。私はGoもRustも素人で不正確な情報が多いと思うので事前に謝罪申し上げます🤥
Axum
はまだ情報が少ないため、「こういうときにどう書くの?」という疑問を解消しづらい面はありましたが、フレームワーク自体はとても使いやすかったです。機能は少ないですが、それは意図的なものです。Goでnet/http
をベースとしたフレームワークがたくさん作られているように、Axum
をベースとしたフレームワークがこれから作られていったり、関連するクレートがさらに充実していけば、開発体験はより快適になると思います。
ただ、Rustは言語自体の習得難易度が高く、使用者も多いとは言えないため、例えばGoの代わりにRustでWebバックエンド開発をやろうとすると、おそらく開発者集めに苦労すると思います。なので、企業がWebバックエンド開発でそれでもRustを採用するという場面は限られていそうで、ニッチなユースケースで使われるに留まるんだろうなあという気はしています。私は特別な理由が無い限り、Rustでコーディングしたいので悲しいです😢
明日は@RuyPKGさんです!