nipeblog

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

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

Realworldリポジトリとは

RealWorld は、React、Anguler、Node、Django などさまざまなプログラミング言語やフレームワークで実装されたMedium.comクローンのプロジェクトのことです。

リポジトリのREADMEをみると「ほとんどのTodoデモアプリは、フレームワークの機能をざっと紹介するが、実際にアプリケーションを構築するために必要な知識や視点は教えてくれない」という課題感からRealWorldをはじめたようです。

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

CodebaseShow のページから色々な実装のコードを検索できます。

CodebaseShowページ

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

RealWorldのRustのactix-webとdieselを利用した snamiki1212/realworld-v1-rust-actix-web-diesel を写経して、RustでWeb APIを開発しました。その中で次のようなことを学べました

  • 約20のAPI実装をすることで、Rust, actix-web, dieselに慣れる
  • よく使うクレートについて理解を深めることができる
  • 認証エラーやDBエラーをHTTPレスポンスに変換する方法がわかる

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

約20のAPIを実装します。

APIによっては認証が必要なものがあったり、ユーザ、記事、コメント、タグなど複数のリソースに対するCRUD操作やページネーションなどの実装方法についても学べます。

何度もAPIを実装する中でRustでの実装に慣れることができます。

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アプリケーションフレームワークやDBライブラリ以外にも周辺のライブラリが必要になってきます。

そういった周辺ライブラリ(Rustの場合「クレート」と呼ぶ)についても学ぶことができます。

今回使ったクレートは以下の通りで、よく使われる serde や chrono、エラーハンドリングを楽にする anyhow や thiserror などについて学べます。

[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レスポンスに変換する方法がわかる

認証エラーやDBエラーを AppError に変換し、AppError 上に actix_web::error::ResponseErrorトレイトを定義することで実現していました。

// アプリケーションエラーの定義
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トレイトを実装します。

// Fromトレイトの実装

// 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 のエラーレスポンスに変換します。

// 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のクローンを作って手に馴染ませてみてください!!

参考サイト