10
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

AxumでGoのチュートリアル「Writing Web Application」をやってみた

Last updated at Posted at 2021-12-21

フューチャーアドベントカレンダーの22日目の記事です

はじめに

私はプログラミング初心者ですが、ITエンジニアリングスキルを高めて手に職をつけたい!という気持ちがあります。

そこで、まずは社会で比較的需要の多い(のか?)Webバックエンド開発の基礎を習得したいと思いました。Webバックエンドフレームワークは星の数ほどあれど、それらに共通した要素は多いはずで、どれか一つにある程度習熟してしまえば、新たなフレームワークを学ぶのは容易になるはずです。

と考えると何でもいいような気がしたので、自分の好きな言語であるRustのWebフレームワークAxumを選びました。ただ、Axumはかなり新しいフレームワークで、チュートリアルなどが見当たらないので、Goのnet/httpのチュートリアルWriting Web ApplicationAxumで実装してみました。実装は以下にあります。

Axumとは

Axumは非常にスリムなフレームワークです。AxumはGitHubのREADMEに書かれているようにErgonomicsModularityにフォーカスしています。

Modularity

Axumは色々なクレートがレゴブロックのように組み合わされています。
例えば、非同期ランタイムはtokio、サーバーはhyperが使われています。
また、AxumServiceという「Requestを受け取りResponseを返すもの」を抽象化したトレイト(Javaでいうインターフェイスのようなもの)を土台としています。Servicetowerで定義されています。
AxumServiceのルーティングに特化しています。後述するハンドラはServiceに変換できます。

Ergonomics

Ergonomicsは人間工学という意味だそうです。使いやすさを重視しているということでしょう。
以下のような特徴があるようです。

マクロフリーなルーティング

Webフレームワークの中にはアノテーション(マクロ)でルーティングするWebフレームワークも多いですが、Axumは現在のところ、その方式は採用していません(Axum v0.4時点)。
マクロは非常に高い表現力ゆえに、何をしているのか分かりづらいという側面があるので、なるべく使用を避けようとしているのでしょう。

宣言的なリクエストとレスポンスのパース

net/httpのハンドラは、要求されたインターフェイスを実装した構造体に対して、操作を施し、リクエストからデータを抽出したりレスポンスを返したりしたりする方式を採用していますが、Axumではハンドラはリクエストは0個以上のFromRequestを実装したもの(Extractorと呼ばれます)で、レスポンスはIntoResponseを実装したものです。

言葉だと分かりづらいので具体例で比較してみます。

net/httpのハンドラの定義の一例
func handler(w http.ResponseWriter, r *http.Request) { ... }
Axumのハンドラの定義の一例
async fn hander(uri: Uri, method: Method, headers: HeaderMap, body: Bytes) -> (StatusCode, HeaderMap, &'static str) { ... }

net/httpではリクエストとレスポンスを処理したければ脳死でhttp.ResoponseWriter*http.Requestを使いますが、Axumの場合、リクエストやレスポンスにおいて具体的に欲しい物、返したい物を引数、返り値に書いていきます。
ハンドラの引数や戻り値で使うためにはFromRequestIntoResponseが実装されていなければなりませんが、よく使われるものには、FromRequestIntoResponseの実装が用意されています。

Gowiki

Writing Web ApplicationはGowikiという超簡易的なWikiを作るチュートリアルです。
簡単のため、Wikiのページはファイルで管理されています。
APIとしては

  1. ページの参照
  2. 編集(新規作成)
  3. 保存機能

が提供されています。

同じ機能を実装したAxumとnet/httpのコードを見比べたときの所感

net/httpはGoが標準で提供しているパッケージで、機能は厳選されています。これを素で使うことは少ないでしょうが、渋川さんの記事によるとginechonet/httpのラッパーみたいなので、実質かなり広く使われているフレームワークと言っていいでしょう。Axumはサービスのルーティングに特化したかなりスリムなフレームワークです。もしかしたら、net/httpと同じようにAxumをベースにしたさらにリッチなフレームワークが登場するかもしれません。

ルーティング

まず、実装を見比べてみます。

go
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/ から引用

rust
#[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のコードはスタンダードな作りに見えます。AxumhyperServerを再エクスポートしていてaxum::Server::bind(&"127.0.0.1:3000".parse().unwrap()).serve(app.into_make_service()).~~というのは実際はhyperの関数を呼び出しています。
hyperserve関数はサービスを引数に要求するのでapp(ルーター)のinto_make_service()関数を呼び出してサービスに変換しています。

ミドルウェア

こちらも実装を示します。

go
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/ から引用

rust
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/httpAxumもハンドラに機能を付加するミドルウェアという仕組みを持っています。net/httpはミドルウェアはhandlerFuncを返す関数を引数にとり、handerFuncを返すという非常に分かりやすい作りになっています。
サンプルコードを見て気になったのは引数の関数のシグネチャに戻り値が書かれていないことです。これは推論されるということなのでしょうか。。

Axumtowerのミドルウェアの仕組みをシームレスに使えるようになっています。towerにおけるミドルウェアとは生成時にServiceを受け取るServiceです。ハンドラはServiceに変換できます。なので、ハンドラをミドルウェアでレイヤリングすることができます。

「~はServiceです。」は「~はServiceトレイトを実装したなにかです。」という意味です。

もともと存在するミドルウェアを使うのは簡単なのですが、ミドルウェアを自作するためにはRustの非同期に関する知識とtowerServiceに関する知識が必要になります。

例としてTimeoutミドルウェアの実装を示します。

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]FuturePollなど、Rustの非同期を勉強したことがないと、意味が分からないと思います。私はRustの非同期を少し勉強したことはありますが(全然理解できてない😭)、それでもミドルウェアをすらすら書ける自信がありません🤪

とはいえ、独自のミドルウェアを実装したいときに、必ずしもこのような実装をしなければいけないわけではなく、AxumにはExtractorをミドルウェアに変換するextractor_middlewareという関数が存在します。私の実装ではこれを用いました。常識が全く分かりませんが、汎用性を考えないミドルウェアはextractor_middlewareを使うのが定石なのかもしれません。

正常系以外のレスポンスの返し方

サーバーは常に正常のレスポンスを返すわけではなく、INTERNAL_SERVER_ERRORBAD_REQUESTUNAUTHORIZEDなどのエラー(400、500番台)やリダイレクト(300番台)を返すこともあります。
これの実現方法ですがnet/httpではhttp.Error(...)http.Redirect(...)等を呼ぶだけでした。

Axumの場合はResultを使います。Resultは列挙型です。以下のような定義になっていて、OK<T>Err<E>の直和です。Axumでは、正常ならOk<T>を返し、エラーやリダイレクトならErr<T>を返すように実装します。(と思っていますが、違うかもしれません。違かったらごめんなさい🙇‍♂️)

Resultの定義
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さんです!

10
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?