RustのWebアプリ開発に慣れるためRustのactix-webとdieselを使ってMedium.comクローンを作ってみた

RustのWebアプリケーションの開発に慣れるためRustのactix-webとdieselを使ってMedium.comクローンを作ってみました。作り方は、 https://github.com/gothinkster/realworld リポジトリのRustの実装を写経しました。この、Medium.comクローンではAPIは20APIほどあるので、モジュール化されたより実践的なRustのWebアプリケーションの作り方を学べます。 ぜひ、Rustの学習でより実践的なアプリケーションが作りたい方は参考にしてみてください。

Realworldリポジトリとは

RealWorld は、React、Anguler、Node、Djangoなどさまざまなプログラミング言語フレームワークで実装されたMedium.comクローン(Conduitと呼ばれる)です。 リポジトリのREADMEをみると「ほとんどのTodoデモアプリは、フレームワークの機能をざっと紹介するが、実際にアプリケーションを構築するために必要な知識や視点は教えてくれない」という課題感からRealWorldをはじめたようです。

RealWorldでは、さまざまなフロントエンド実装(React、Anguler、Vue.jsなど)とさまざまなバックエンド実装(Node、DjangoRuby on Railsなど)があり、より実践的なディレクトリ構成、CRUD操作、認証、ページネーションなどのサンプルコードを利用できます。

具体的には CodebaseShow でMedium.comクローン(Conduit)の実装コードを検索できるようになっています。

RealWorldでできるようになること

RealWorldのRustのactix-webとdieselを利用した https://github.com/snamiki1212/realworld-v1-rust-actix-web-diesel を写経して、RustでWeb APIを開発しました。その中でどのようなことが学べたか説明します。
参考: 写経したコード https://github.com/nipe0324/learning-rust/tree/main/actix-web-diesel

20APIを実装するのでRust、actix-web、dieselの実装に慣れる

以下のようにAPIのルーティングをみると、20APIを実装します。APIによっては認証が必要なものがあったり、ユーザ、記事、コメント、タグなど複数のリソースに対するCRUD操作やページネーションなどの実装方法についても学べます。
あとは、APIが20本あり数が多いので何度もAPIを実装する中でRustでの実装に慣れることができます。

// src/routes.rs
pub fn api(cfg: &mut ServiceConfig) {
    cfg.service(
        scope("/api")
            .route("/healthcheck", get().to(get_healthcheck))
            .route("/users/login", post().to(signin))
            .route("/users", post().to(signup))
            .route("/user", get().to(get_user))
            .route("/user", put().to(update_user))
            .route("/profiles/{username}", get().to(get_profile))
            .route("/profiles/{username}/follow", post().to(create_follow))
            .route("/profiles/{username}/follow", delete().to(delete_follow))
            .route("/articles", get().to(get_articles))
            .route("/articles", post().to(create_article))
            .route("/articles/feed", get().to(get_articles_feed))
            .route("/articles/{slug}", get().to(get_article_by_slug))
            .route("/articles/{slug}", put().to(update_article))
            .route("/articles/{slug}", delete().to(delete_article))
            .route("/articles/{slug}/comments", get().to(get_article_comments))
            .route(
                "/articles/{slug}/comments",
                post().to(create_article_comment),
            )
            .route(
                "/articles/{slug}/comments/{id}",
                delete().to(delete_article_comment),
            )
            .route("/tags", get().to(get_tags))
            .route("/articles/{slug}/favorite", post().to(create_favorite))
            .route("/articles/{slug}/favorite", delete().to(delete_favorite)),
    );
}

周辺のクレートについても理解を深めることができる

Webアプリケーションを作った方はわかると思いますが、Webアプリケーションを作るにはWebアプリケーションフレームワークやDBライブラリ以外にも周辺のライブラリが必要になってきます。そういった周辺ライブラリについても必要に応じて調べながら学ぶことができます。
今回使ったクレートは以下の通りです。actix-webdiesel以外にもよく使われるserdechrono、エラーハンドリングを楽にするanyhowthiserrorなどさまざまなクレートがあります

# Cargo.toml

[dependencies]
# web framework
actix-web = "4.3.1"
actix-cors = "0.6.4"

# ORM and Query Builder
diesel = { version = "2.0.4", features = ["r2d2", "postgres", "chrono", "uuid", "serde_json"] }

# serialization / deserialization
serde = { version = "1.0.160", features = ["derive"] }
serde_json = "1.0.96"

# encode and decode JWTs
jsonwebtoken = "8.3.0"

# hash and verify password
bcrypt = "0.14.0"

# deta and time libray
chrono = { version = "0.4.24", features = ["serde"] }

# generate and parse UUIDs
uuid = { version = "1.3.2", features = ["serde", "v4"] }

# Flexible concrete Error type build on std::error::Error
anyhow = "1.0.71"

# derive(Error)
thiserror = "1.0.40"

# lightweight logging facade
log = "0.4.17"
env_logger = "0.10.0"

# futures and streams featuring
futures = "0.3.28"

# dotenv
dotenvy = "0.15.7"

# Convert strings into any case
convert_case = "0.6.0"

認証エラーやDBエラーをHTTPレスポンスに変換する方法がわかる

https://github.com/snamiki1212/realworld-v1-rust-actix-web-diesel の実装においては、認証エラーやDBエラーをAppErrorに変換し、AppError上にactix_web::error::ResponseErrorトレイトを定義することで実現していました。

まず、アプリケーションエラーをAppErrorとして定義します。

// src/error.rs

// アプリケーションエラー
pub enum AppError {
    // 401
    #[error("Unauthorized: {}", _0)]
    Unauthorized(JsonValue),

    // 403
    #[error("Forbidden: {}", _0)]
    Forbidden(JsonValue),

    // 404
    #[error("Not Found: {}", _0)]
    NotFound(JsonValue),

    // 422
    #[error("Unprocessable Entity: {}", _0)]
    UnprocessableEntity(JsonValue),

    // 500
    #[error("Internal Server Error")]
    InternalServerError,
}

そして、次に、JWTやDBのエラーが発生した場合にAppErrorに変換するためにFromトレイトを実装します。

// src/error.rs

// JwtErrorをAppErrorに変換する
impl From<JwtError> for AppError {
    fn from(err: JwtError) -> Self {
        match err.kind() {
            JwtErrorKind::InvalidToken => AppError::Unauthorized(json!({
                "error": "Token is invalid"
            })),
            JwtErrorKind::InvalidIssuer => AppError::Unauthorized(json!({
                "error": "Issuer is invalid",
            })),
            _ => AppError::Unauthorized(json!({
                "error": "An issue was found with the token provided",
            })),
        }
    }
}

// DieselErrorをAppErrorに変換する
impl From<DieselError> for AppError {
    fn from(err: DieselError) -> Self {
        match err {
            DieselError::DatabaseError(kind, info) => {
                if let DatabaseErrorKind::UniqueViolation = kind {
                    let message = info.details().unwrap_or_else(|| info.message()).to_string();
                    AppError::UnprocessableEntity(json!({ "error": message }))
                } else {
                    AppError::InternalServerError
                }
            }
            DieselError::NotFound => {
                AppError::NotFound(json!({ "error": "requested record was not found" }))
            }
            _ => AppError::InternalServerError,
        }
    }
}

そして、最後にAppErroractix-webのエラーレスポンスに変換します。

// src/error.rs

// AppErrorからactix-webのエラーレスポンスに変換する
impl actix_web::error::ResponseError for AppError {
    fn error_response(&self) -> HttpResponse {
        match self {
            AppError::Unauthorized(ref msg) => HttpResponse::Unauthorized().json(msg),
            AppError::Forbidden(ref msg) => HttpResponse::Forbidden().json(msg),
            AppError::NotFound(ref msg) => HttpResponse::NotFound().json(msg),
            AppError::UnprocessableEntity(ref msg) => HttpResponse::UnprocessableEntity().json(msg),
            AppError::InternalServerError => {
                HttpResponse::InternalServerError().json("Internal Server Error")
            }
        }
    }

    fn status_code(&self) -> StatusCode {
        match *self {
            AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
            AppError::Forbidden(_) => StatusCode::FORBIDDEN,
            AppError::NotFound(_) => StatusCode::NOT_FOUND,
            AppError::UnprocessableEntity(_) => StatusCode::UNPROCESSABLE_ENTITY,
            AppError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

RealWorldで学ぶうえでの注意点

RealWorldのMedium.comクローンから学べることはたくさんあります。しかし、学ぶ上でも注意することはあります。Medium.comクローンはあくまでデモアプリ、かつ、各言語やフレームワークで実装をしていの人は別々の人なので、クラス設計やフレームワークの使い方や実装方法が必ずしも正しいとは限りません。そこは注意しつつ開発が必要です。困ったらクレートのREADMEやチュートリアルなどを確認しにいくのがよいと思います。

ぜひ、新しい言語や新しいフレームワークなどを学ぶ際には、チュートリアルのあとにRealWorldのクローンを作って手に馴染ませてみてください!!

参考サイト