目次
はじめに
こんにちは 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 作成に興味がある方
- Rocket や diesel に興味がある方
今回の記事の目的は、あくまで Rails と Rust(Rocket & diesel) での API 作成の流れやコードの量の違いについて実際のコードを比較しながら感じて貰うことなので、載せているコードに対しての解説は少なめです。
作成した API について
概要
自分がRails で作成した API を、Rust で再現してみようと思って始めたので、local のDocker-compose
の service
にrust環境を追加して開発しています。
データベーズはRailsで使用しているpostgresを併用していて、ORMには、Rust製のdiesel、Web フレームワークもまた同様に、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は 使用するModel、controllersはrequestを受け付けるためのcontroller 関数、helpersはhelper 関数をそれぞれ定義しています。
各${controller}.rs
には、対応する${helper}.rs
を作成し、その${helper}.rs
とcommmon.rs
のみをインポートしています。
lib.rs
は、 API 内で、共通して使用しているコードを定義しています。
本題
ではここから、今回の記事のメインである、Railsと**Rust(Rocket & diesel)**の比較について書こうと思います。
Rocket,diesel と Rails それぞれの実装に対する比較
まず、実現したい目的に対してのRocket,dieselで必要なコードを記載して、それからRailsでのコードを記載することで、比較していこうと思います。
目的 1. API として使用できるために、Json や Status を設定できるようにする
- Rocket,diesel での実装
Rocketは、標準では Json型
と Status型
を自分で設定して返却することができないので、返却用のResponder型
を自分で定義する必要がありました。
まず、Json型
とStatus型
を保持できるApiResponse
struct を作成します。
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型
を継承して、返り値にプロパティーであるstatusとjsonの値が、反映されるようにしてようやく準備完了です。
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 を設定することで、response のStatus
やJson
を自由に設定できるようになります。
最後にcontroller
からApiResponse
struct を返却する関数を作成して完了です。
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では上記のような設定は一切不要です。 笑
標準で上記の機能を実装してくれているので、同じことが次のコードのみで可能です。
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のエラーメッセージを取得します。
-
-
controller
が取得するフォームのModelを作成して、そこにValidationを設定する
-
-
-
controller
関数の引数に型を設定して、Request Guardを有効にする
-
-
-
User
追加用のモデル(NewUser
)を作成して、データベースに追加する
-
手順が多いので、一つずつ取り上げていきます。
-
-
Rocket
ではまずcontroller
が取得するフォームのModelを作成して、そこにValidationを設定する
-
今回はUser Form
の一例を示します。
User Form
のValidationは、name、passwordに長さの制限を設けて、またpassword_confirmationがpasswordと正しいかどうかを確認しています。
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,
}
-
-
controller
関数の引数に型を設定して、Request Guardを有効にする
-
Rocket
では、controller
関数の引数に型を設定することで、Request Guardを設定することができます。今回は、Validation Errorを取得してユーザーに表示したかったので、関数の引数にエラーを扱えるContextual
型を利用しました。
controller
内では、Request Guardを通らずvalue
を持たない時に、Validation Errorを扱うためのhandle_user_form_validation
関数を実行しています。
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);
}
}
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]}}),
};
}
-
-
User
追加用のモデル(NewUser
)を作成して、データベースに追加する処理を行う
-
この時点で、User Form
にはエラーがないことが保証されているので、NewUser
を通して、新しいユーザーを作成するヘルパー関数(create_user
)をしています。
ユーザー作成はdieselを通して行われて、create_user
内で実行しています。
ユーザー作成の処理の際に、データベースを通してemailがユニークであるかを確認し、同じemailが存在する場合はエラーを返すという流れです。
#[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();
}
}
// 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)
}
#[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 での実装
ではここで、RailsでValidation Errorを表示するやり方と比較してみます。
Railsでは以下の手順で同様の処理を実装できます。
-
- ModelにValidationを設定する
-
- フォームからparamsを安全に受け取るために、
strong parameters
を設定する
- フォームからparamsを安全に受け取るために、
-
- Modelのインスタンスを作成して、データベースに追加する処理を行う
手順として多く見えますが、実際のコード量は Rust の時とは大きく違います。
-
- ModelにValidationを設定する
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
-
- フォームからparamsを安全に受け取るために、
strong parameters
を設定する
- フォームからparamsを安全に受け取るために、
安全に Params を受け取るためのstrong parameters
を設定する必要があります。
このパラメーターはユーザー作成時以外使用しないので、private
メソッドで定義します。
private
# 安全にParamsを受け取るためのstrong parametersを設定する
def user_params
params.require(:user).permit(:name, :email, :password, :password_confirmation)
end
-
- Modelのインスタンスを作成して、データベースに追加する処理を行う
最後にユーザー作成を行うのですが、その際にModel Validation によるエラーメッセージをrails では自動的に得ることができます。
rocketではエラーメッセージを取得するために、生じたError
に対応するメッセージを自分で作成する必要がありました。
ですが、railsではエラーメッセージをerrors
として作成してくれているので、後はjson
で返却するだけで実装できます。
# 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型
を受け取って、自分でエラーメッセージに変換する必要があったので、railsのValidationから自動的にエラーメッセージを作成してくれるところは、かなり便利だなと思いました。
何より、すぐにerrors
変数として使用できる点が良いですね!
目的 3. Request Header の Token を用いて、ユーザー認証を行う
最後は User 認証についてです。
Railsでは User 認証に JsonWebToken
を用いているので、RocketでJsonWebToken
を用いた認証についても比較してみます。
- Rocket,diesel での実装
Rocket,diesel での User 認証の処理は以下の流れで行います。
-
-
LoginUser
を取得するRequest Guardを作成する
-
-
-
controller
にRequest Guardを設定する
-
では一つずつ取り上げて行きます。
-
-
LoginUser
を取得するためのRequest Guardを作成する
-
Rocketでは、Request
に対して特定の処理を追加してRequest Guardを作成するためのtrait、FromRequest
があります。
ですので、FromRequest
を継承してLoginUser
というRequest Guardを作成します。
// 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)),
}
}
}
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
}
-
-
controller
にRequest Guardを設定します
-
controller
の引数にResult<LoginUser,UserAuthError>
を設定することで、Request Guardを有効化することができます。
最後にkey
のエラーハンドリングを行うことで、LoginUser
の値を扱う事ができるようになります。
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を用いた処理を行う
}
// 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 認証の処理は以下の流れで行います。
-
-
LoginUser
を取得するための関数(authorized
)を定義する
-
-
-
controller
のbefore_action
でauthorized
を実行する
-
では具体的な実装について挙げます。
-
-
LoginUser
を取得するための関数(authorized
)を定義する
-
まずは、 request header から token を取得して、 decoded_token を作成します。
その後に、decoded_token の user_id からUser
が見つかれば Login User
として global variable
に設定、なければエラーを表示させる仕組みです。
# 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
-
-
controller
のbefore_action
でauthorized
を実行する
-
railsのcontroller
には、before_action
というメソッドがあります。
これはある関数を実行する前に指定した関数を実行できるというもので、loginが必要な関数のbefore_action
にauthorized
を設定すれば @login_user
が変数として使用できます。
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 の方がコード量は多かったですけど、他と比べると差はまだ少なかったかなと個人的に感じました。
Rails の before_action の使い勝手の良さはもちろんですが、 Rocket の Request Guard が Validation の時よりも相性が良く、書きやすかったです。
ruby でも rust でも、JsonWebToken を簡単に扱えるパッケージを作成してくれている点が非常に大きかったですね。
Rocket & diesel(Rust) で開発していて困ったこと
- エラーを検索しても、解決策がなかなか見つからない
Rocket や diesel の利用者が少ないので、エラーメッセージを検索してもcrate ドキュメントしか出てきません。解決策を見つけるには、ドキュメントやGithubのdiscussionを読みこむしかないので初心者泣かせでした。
- Rust のコンパイルが最初のうちは全く通らない
Rust は C言語 と同じくらい型が厳しいので、最初のうちは全くコンパイルが通りませんでした。言語の自体のハードルの高さを実感しましたね。
Typescript みたいに一旦 any
で握りつぶすみたいなことも難しいので、最初のうちは開発しててイライラしました。 笑
最後に
いつか競プロに挑戦してみたいなと思っていて、そのために勉強を始めた言語が Rust でした。なので今回の API 作成は、より Rust を理解できるきっかけになったので個人的に大満足です。
ただ僕自身は Rust を言語として凄い気に入ってるんですが、Rust が API 作成においてRails や Typescript & Express よりも生産性が高くなることは難しい気がしますね。
フレームワークとしての力の差を大きく感じましたし、静的型付けで開発したいなら Typescript 、さらにパフォーマンスも追求するなら Go を選ぶことがほとんどだと思います。
どの言語も適材適所なんだなと改めて実感できました。
今まで長々と書いた記事を最後まで読んで頂きありがとうございます。