LoginSignup
10
5

More than 1 year has passed since last update.

Rust で API を作成して Rails の便利さを改めて実感した話

Last updated at Posted at 2021-12-23

目次

はじめに

こんにちは DeNA22 卒内定者の takaya です。
この記事はDeNA 21 新卒 ×22 新卒内定者 Advent Calendar 2021 の 24 日目の記事です。

自分は、最近触り始めた Rust(Rocket & diesel)API を作成してみたので、Rails との比較について書いてみようと思います。
よろしければ、ぜひご覧ください。

今回作成した API は、以前自分で作成した Rails API をモチーフにしています。
Rails API についての記事はこちら Rails チュートリアルを完全 SPA 化してみた
レポジトリーはこちら(Git hub) Rails API Repository
Open API Documentはこちら(Swagger hub) rails-tutorial-open-api

想定読者

  • Rails 経験がある方
  • Rust での API 作成に興味がある方
  • Rocketdiesel に興味がある方

今回の記事の目的は、あくまで RailsRust(Rocket & diesel) での API 作成の流れコードの量の違いについて実際のコードを比較しながら感じて貰うことなので、載せているコードに対しての解説は少なめです。

作成した API について

概要

自分がRails で作成した API を、Rust で再現してみようと思って始めたので、local のDocker-composeservicerust環境を追加して開発しています。
データベーズはRailsで使用しているpostgresを併用していて、ORMには、Rust製のdieselWeb フレームワークもまた同様に、Rust製のRocketを使用しています。

今回作成したRust API のレポジトリーはこちら(Git hub) Rails Demo API with Rust

開発環境

  • Docker, Docker-compose
  • Rust:1.56 (nightly)
  • diesel:1.4.8 { features: "postgres,chrono" }
  • rucket:0.5.0-rc.1 { features: "json" }
  • postgres SQL

フォルダー構成

.
├── Cargo.lock
├── Cargo.toml
├── LICENCE
├── README.md
├── Rocket.toml
├── diesel.toml
├── migrations
│   └── 00000000000000_diesel_initial_setup
├── rust-toolchain.toml
├── src
│   ├── controllers
│   │   ├── auth.rs
│   │   ├── microposts.rs
│   │   ├── mod.rs
│   │   └── users.rs
│   ├── helpers
│   │   ├── auth.rs
│   │   ├── microposts.rs
│   │   ├── common.rs
│   │   ├── mod.rs
│   │   └── users.rs
│   ├── lib.rs
│   ├── main.rs
│   ├── models
│   │   ├── forms.rs
│   │   ├── indexes.rs
│   │   ├── mod.rs
│   │   └── tables.rs
│   └── schema.rs
└── target

フォルダー構成は、Railsをモチーフにしています。
modelsは 使用するModelcontrollersrequestを受け付けるためのcontroller 関数helpershelper 関数をそれぞれ定義しています。
${controller}.rsには、対応する${helper}.rsを作成し、その${helper}.rscommmon.rsのみをインポートしています。
lib.rsは、 API 内で、共通して使用しているコードを定義しています。

本題

ではここから、今回の記事のメインである、RailsRust(Rocket & diesel)の比較について書こうと思います。

Rocket,diesel と Rails それぞれの実装に対する比較

まず、実現したい目的に対してのRocket,dieselで必要なコードを記載して、それからRailsでのコードを記載することで、比較していこうと思います。

目的 1. API として使用できるために、Json や Status を設定できるようにする

  • Rocket,diesel での実装

Rocketは、標準では Json型Status型 を自分で設定して返却することができないので、返却用のResponder型を自分で定義する必要がありました。
まず、Json型Status型を保持できるApiResponse struct を作成します。

lib.rs
use rocket::http::{ContentType, Status};
use rocket::serde::json::Value;
  // ControllerからJsonを返すAPI Responseを定義
#[derive(Debug)]
pub struct ApiResponse {
  pub json: Value,
  pub status: Status,
}

次に、ApiResponse struct に、controllerの返り値となるResponder型を継承して、返り値にプロパティーであるstatusjsonの値が、反映されるようにしてようやく準備完了です。

lib.rs
use rocket::response::{Responder, Response};

# 上記のコードの続きに追加
# 前述したApiResponseオブジェクトにResponderトレイトを継承
impl<'r, 'o: 'r> Responder<'r, 'o> for ApiResponse {
  fn respond_to(self, _: &Request) -> response::Result<'o> {
    Response::build()
      .status(self.status)
      .header(ContentType::JSON)
      .streamed_body(Cursor::new(self.json.to_string()))
      .ok()
  }
}

あとはcontrollerの返り値にApiResponse struct を設定することで、responseStatusJsonを自由に設定できるようになります。
最後にcontrollerからApiResponse struct を返却する関数を作成して完了です。

controller.rs
use crate:*;
use rocket::serde::json::json;
use rocket::http::Status;

pub fn sample() -> ApiResponse {
  ApiResponse::new(
    Status::Ok,
    json!({
      "name": "sample",
      "age": 20,
    }),
  )
}
  • Rails での実装

ですが、Railsでは上記のような設定は一切不要です。 笑
標準で上記の機能を実装してくれているので、同じことが次のコードのみで可能です。

controller.rb
def sample
  json = { "name" => "sample", "age" => 20 }
  render json: json, status: :ok
  # jbuilderを用いる場合は、下記のコードになる
  render "users/index",
         formats: :json,
         handlers: "jbuilder",
         status: :ok
end

目的 1 に対してのまとめ

今回に関しては、 Rails が初心者向けて言われる理由が顕著に出てる気がします。
やりたいことを自分で設定する必要がなく、ドキュメント通りにするだけでできるのが一番簡単ですね!
Rocket が初心者に優しくない気もしますが。

目的 2. User 作成時に、 Validation からエラーメッセージを取得し、Json で返却する

Validationとは、ユーザー作成や、ログインなどの際、受け取るパラメーターが不正な値の場合、不正なrequestをガードして、エラーメッセージを取得するための仕組みです。
新しいユーザーなどのオブジェクトを作成する際に、不適切なオブジェクトが作成されることを妨げる目的で、設定されます。

  • Rocket,diesel での実装

Rocketでは、各controllerを不正なパラメーターから守るための仕組みとして、Request Guardというものがあります。
ですのでRocketではRequest Guardを用いて、以下の手順でValidationエラーメッセージを取得します。

  • 1. controllerが取得するフォームのModelを作成して、そこにValidationを設定する
  • 2. controller関数の引数に型を設定して、Request Guardを有効にする
  • 3. User追加用のモデル(NewUser)を作成して、データベースに追加する

手順が多いので、一つずつ取り上げていきます。

  • 1. Rocketではまずcontrollerが取得するフォームのModelを作成して、そこにValidationを設定する

今回はUser Formの一例を示します。
User FormValidationは、namepasswordに長さの制限を設けて、またpassword_confirmationpasswordと正しいかどうかを確認しています。

forms.rs
use rocket::form::FromForm;
use serde::Deserialize;

#[derive(Debug, Deserialize, FromForm)]
pub struct UserForm<'r> {
  #[field(validate = len(7..20))]
  pub name: &'r str,
  pub email: &'r str,
  #[field(validate = len(7..20))]
  pub password: &'r str,
  #[field(validate = dbg_eq(self.password))]
  pub password_confirmation: &'r str,
}
  • 2. controller関数の引数にを設定して、Request Guardを有効にする

Rocketでは、controller関数の引数にを設定することで、Request Guardを設定することができます。今回は、Validation Errorを取得してユーザーに表示したかったので、関数の引数にエラーを扱えるContextual型を利用しました。
controller内では、Request Guardを通らずvalueを持たない時に、Validation Errorを扱うためのhandle_user_form_validation関数を実行しています。

controllers/user.rs
use rocket::form::{Contextual, Form};
# controllerメソッドに、UserFormを引数に設定して、型Guardを設定
#[post("/users", data = "<user_form>")]
pub fn create(user_form: Form<Contextual<'_, UserForm>>) -> ApiResponse {
  if user_form.value.as_ref().is_none() {
    return handle_user_form_validation(user_form);
  }
}
helpers/user.rs
pub fn handle_user_form_validation(user_form: Form<Contextual<'_, UserForm>>) -> ApiResponse {
  let err_item = user_form.context.errors().next().unwrap();
  let key: String = err_item.name.as_ref().unwrap().to_string();
  let value: String = err_item.kind.to_string();

  return ApiResponse {
    status: Status::UnprocessableEntity,
    json: json!({"errors":  {key: [value]}}),
  };
}
  • 3. User追加用のモデル(NewUser)を作成して、データベースに追加する処理を行う

この時点で、User Formにはエラーがないことが保証されているので、NewUserを通して、新しいユーザーを作成するヘルパー関数(create_user)をしています。
ユーザー作成はdieselを通して行われて、create_user内で実行しています。
ユーザー作成の処理の際に、データベースを通してemailがユニークであるかを確認し、同じemailが存在する場合はエラーを返すという流れです。

controllers/user.rs
#[post("/users", data = "<user_form>")]
pub fn create(user_form: Form<Contextual<'_, UserForm>>) -> ApiResponse {
# User Form のエラーを予め処理する
# User Form の値を利用して、userを作成する
  let result = create_user(&conn, user_form.value.as_ref().unwrap());

# データベースのエラーを受け取ってresponseに変換する
  let error_response = handle_diesel_error(&result);

  if error_response.is_some() {
    return error_response.unwrap();
  }
}
helpers/user.rs
// Post /users
impl NewUser {
  fn create(name: &str, email: String, password_digest: String) -> NewUser {
    NewUser {
      name: Some(String::from(name)),
      email: Some(email),
      created_at: Utc::now().naive_utc(),
      updated_at: Utc::now().naive_utc(),
      password_digest: Some(password_digest),
      admin: false,
      activation_digest: None,
      activated: Some(true),
      activated_at: None,
      reset_digest: None,
      reset_sent_at: None,
    }
  }
}

// ユーザーを作成するためのhelper関数
pub fn create_user<'a>(conn: &PgConnection, userform: &UserForm) -> QueryResult<User> {
  use schema::users;
  let password_hash = hash(userform.password, DEFAULT_COST).expect("Error hashing password");
# user作成用のobject new_userを作成する
  let new_user = NewUser::create(
    userform.name,
    userform.email.to_lowercase(),
    String::from(password_digest),
  );

# dieselを通して、データベースのusers tableにNewUserを追加する
  diesel::insert_into(users::table)
    .values(&new_user)
    .get_result::<User>(conn)
}
models/tables.rs
#[derive(Debug, Queryable, Insertable)]
#[table_name = "users"]
pub struct NewUser {
  pub name: Option<String>,
  pub email: Option<String>,
  pub created_at: NaiveDateTime,
  pub updated_at: NaiveDateTime,
  pub password_digest: Option<String>,
  pub admin: bool,
  pub activation_digest: Option<String>,
  pub activated: Option<bool>,
  pub activated_at: Option<NaiveDateTime>,
  pub reset_digest: Option<String>,
  pub reset_sent_at: Option<NaiveDateTime>,
}
  • Rails での実装

ではここで、RailsValidation Errorを表示するやり方と比較してみます。
Railsでは以下の手順で同様の処理を実装できます。

  • 1. ModelValidationを設定する
  • 2. フォームからparamsを安全に受け取るために、strong parametersを設定する
  • 3. Modelのインスタンスを作成して、データベースに追加する処理を行う

手順として多く見えますが、実際のコード量は Rust の時とは大きく違います。

  • 1. ModelValidationを設定する
models/user.rb
class User < ApplicationRecord

  #gem bcryptを追加したら利用でき、Userにpassword_digestを作成してくれる
  has_secure_password
  # User Modelに設定するValidationを定義
  validates(:name, presence: true, length: { maximum: 50 })
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates(:email, presence: true, length: { maximum: 255 }, format: { with: VALID_EMAIL_REGEX }, uniqueness: { case_sensitive: false })
  validates(:password, presence: true, length: { minimum: 6 }, allow_nil: true)
end
  • 2. フォームからparamsを安全に受け取るために、strong parametersを設定する

安全に Params を受け取るためのstrong parametersを設定する必要があります。
このパラメーターはユーザー作成時以外使用しないので、privateメソッドで定義します。

private
# 安全にParamsを受け取るためのstrong parametersを設定する
def user_params
  params.require(:user).permit(:name, :email, :password, :password_confirmation)
end
  • 3. Modelのインスタンスを作成して、データベースに追加する処理を行う

最後にユーザー作成を行うのですが、その際にModel Validation によるエラーメッセージをrails では自動的に得ることができます。
rocketではエラーメッセージを取得するために、生じたErrorに対応するメッセージを自分で作成する必要がありました。
ですが、railsではエラーメッセージerrorsとして作成してくれているので、後はjsonで返却するだけで実装できます。

controllers/users.rb
# POST /users
def create
  @user = User.new(user_params)
  if @user.save
    @user.send_activation_email
    payload = { user_id: @user.id }
    token = encode_token(payload)
    render json: { user: @user, token: token, gravator_url: gravator_for(@user) }, status: :created, location: api_user_url(@user)
  else
    # saveに失敗した際に、@userにerrorsが追加される
    render json: { errors: @user.errors }, status: :bad_request
  end
end

# 2のstrong parameterを定義
def user_params
  params.require(:user).permit(:name, :email, :password, :password_confirmation)
end

目的 2 に対してのまとめ

Rust(Rocket & diesel) では Error型を受け取って、自分でエラーメッセージに変換する必要があったので、railsValidationから自動的にエラーメッセージを作成してくれるところは、かなり便利だなと思いました。
何より、すぐにerrors変数として使用できる点が良いですね!

目的 3. Request Header の Token を用いて、ユーザー認証を行う

最後は User 認証についてです。
Railsでは User 認証に JsonWebTokenを用いているので、RocketJsonWebTokenを用いた認証についても比較してみます。

  • Rocket,diesel での実装

Rocket,diesel での User 認証の処理は以下の流れで行います。

  • 1. LoginUserを取得するRequest Guardを作成する
  • 2. controllerRequest Guardを設定する

では一つずつ取り上げて行きます。

  • 1. LoginUserを取得するためのRequest Guardを作成する

Rocketでは、Requestに対して特定の処理を追加してRequest Guardを作成するためのtraitFromRequestがあります。
ですので、FromRequestを継承してLoginUserというRequest Guardを作成します。

lib.rs
// User認証を行うRequestGuard
use jsonwebtoken::{decode, DecodingKey, Validation};
use models::tables::User as LoginUser;

#[rocket::async_trait]
impl<'r> FromRequest<'r> for LoginUser {
    type Error = UserAuthError;

    async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
        // Request Headerから JWTを取得
        let token: &str = match request.headers().get_one("Authorization") {
            Some(token) => token.split_whitespace().collect::<Vec<&str>>()[1],
            None => return Outcome::Failure((Status::Unauthorized, UserAuthError::NotFoundToken)),
        };

        #[derive(Debug, Deserialize)]
        struct DecodedToken {
            user_id: i64,
        }

        // tokenからuser_idにdecode
        let decoded_token = decode::<DecodedToken>(
            &token,
            &DecodingKey::from_secret("s3cr3t".as_ref()),
            &Validation {
                validate_exp: false,
                ..Default::default()
            },
        );

        if let Err(_) = decoded_token {
            return Outcome::Failure((Status::Unauthorized, UserAuthError::InvalidToken));
        }

        let conn = establish_connection();

        // decode_tokenを用いて、Login Userを取得する
        let current_user =
            helpers::users::get_user_by_id(&conn, decoded_token.unwrap().claims.user_id);

        match current_user {
            Ok(user) => Outcome::Success(user),
            Err(_) => Outcome::Failure((Status::Unauthorized, UserAuthError::NotFoundUser)),
        }
    }
}
helpers/users.rs
pub fn get_user_by_id(conn: &PgConnection, user_id: i64) -> QueryResult<User> {
  use schema::users::dsl::*;

  let result = users
    .filter(id.eq(user_id))
    .filter(activated.eq(true))
    .first::<User>(conn);

  result
}
  • 2. controllerRequest Guardを設定します

controllerの引数にResult<LoginUser,UserAuthError>を設定することで、Request Guardを有効化することができます。
最後にkeyのエラーハンドリングを行うことで、LoginUserの値を扱う事ができるようになります。

controllers/auth.rs
use helpers::common::handle_auth_error;

pub fn auto_login(key: Result<LoginUser, UserAuthError>) -> ApiResponse {
  // keyからuserを取得する
  let login_user = match key {
    Ok(user) => user,
    Err(err) => return handle_auth_error(err),
  };

  // login_userを用いた処理を行う
}
helpers/common.rs
// Result<LoginUser, UserAuthError>専用のerror Handler
// Error message用のApiResponseを作成する
pub fn handle_auth_error(error: UserAuthError) -> ApiResponse {
  println!("{:?}", error);
  match error {
    UserAuthError::NotFoundToken => ApiResponse::new(
      Status::Unauthorized,
      json!({
        "error": "Token is not found",
        "message": "Please login"
      }),
    ),
    UserAuthError::NotFoundUser => ApiResponse::new(
      Status::Unauthorized,
      json!({
        "error": "User is not found",
        "message": "Your account is not found"
      }),
    ),
    UserAuthError::InvalidToken => ApiResponse::new(
      Status::Unauthorized,
      json!({
        "error": "Token is invalid",
        "message": "Your token is invalid. \n Please login again."
      }),
    ),
  }
}
  • Rails での実装

Rails での User 認証の処理は以下の流れで行います。

  • 1. LoginUserを取得するための関数(authorized)を定義する
  • 2. controllerbefore_actionauthorizedを実行する

では具体的な実装について挙げます。

  • 1. LoginUserを取得するための関数(authorized)を定義する

まずは、 request header から token を取得して、 decoded_token を作成します。
その後に、decoded_tokenuser_id からUserが見つかれば Login User として global variable に設定、なければエラーを表示させる仕組みです。

controllers/application_controller.rb
# request header から token を取得
  def auth_header
    #{Authorization: 'Bearer <token>'}
    request.headers['Authorization']
  end

# 取得した token から decode_token を作成する
  def decoded_token
    if auth_header
      token = auth_header.split(' ')[1]
      #Header: {Authorization: 'Bearer <token>'}
      begin
        JWT.decode(token, '${key}', true, algorothm: 'HS256')
      rescue JWT::DecodeError
        nil
      end
    end
  end

# decode_token の user_id を用いて、User を取得する
  def logged_in_user
    if decoded_token
      user_id = decoded_token[0]['user_id']
      @login_user = User.find(user_id)
    end
  end

#login_userがなければ、errorを出す
  def authorized
    render json: { message: 'Please log in'}, status: :unauthorized unless !!logged_in_user
  end
  • 2. controllerbefore_actionauthorizedを実行する

railscontrollerには、before_actionというメソッドがあります。
これはある関数を実行する前に指定した関数を実行できるというもので、loginが必要な関数のbefore_actionauthorizedを設定すれば @login_user が変数として使用できます。

controllers/auth.rb
before_action :authorized

# get '/auto_login',
def auto_login
    @gravator_url = gravator_for(@login_user)
    @current_microposts = @login_user.microposts.with_attached_image
    render "users/auto_login", formats: :json, handlers: "jbuilder"
end

目的 3 に対してのまとめ

エラーハンドリングを自分で実装する必要があるので、 Rocket の方がコード量は多かったですけど、他と比べると差はまだ少なかったかなと個人的に感じました。
Railsbefore_action の使い勝手の良さはもちろんですが、 RocketRequest GuardValidation の時よりも相性が良く、書きやすかったです。
ruby でも rust でも、JsonWebToken を簡単に扱えるパッケージを作成してくれている点が非常に大きかったですね。

Rocket & diesel(Rust) で開発していて困ったこと

  • エラーを検索しても、解決策がなかなか見つからない

Rocketdiesel の利用者が少ないので、エラーメッセージを検索してもcrate ドキュメントしか出てきません。解決策を見つけるには、ドキュメントGithubdiscussionを読みこむしかないので初心者泣かせでした。

  • Rustコンパイルが最初のうちは全く通らない

RustC言語 と同じくらい型が厳しいので、最初のうちは全くコンパイルが通りませんでした。言語の自体のハードルの高さを実感しましたね。
Typescript みたいに一旦 any で握りつぶすみたいなことも難しいので、最初のうちは開発しててイライラしました。 笑

最後に

いつか競プロに挑戦してみたいなと思っていて、そのために勉強を始めた言語が Rust でした。なので今回の API 作成は、より Rust を理解できるきっかけになったので個人的に大満足です。
ただ僕自身は Rust を言語として凄い気に入ってるんですが、RustAPI 作成においてRailsTypescript & Express よりも生産性が高くなることは難しい気がしますね。
フレームワークとしての力の差を大きく感じましたし、静的型付けで開発したいなら Typescript 、さらにパフォーマンスも追求するなら Go を選ぶことがほとんどだと思います。
どの言語も適材適所なんだなと改めて実感できました。
今まで長々と書いた記事を最後まで読んで頂きありがとうございます。

10
5
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
5