46
47

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 5 years have passed since last update.

Railsでサービスクラスとフォームオブジェクトを使ってサッと良い感じにする

Last updated at Posted at 2017-08-04

サッと解決したいのでGemを使うよ

Rails界隈では定期的にサービスクラスとフォームオブジェクトの話題がきますよね。すぐモデルが肥大化しますからね。

ただ、そうした話題はウォッチしつつも、自分で全部設計するのはちょっとハードルが高いなと思っている方も多いのではないでしょうか。
特にサービスクラスを実装する記事見てると、めっちゃ色々考えてる感あるじゃないですか。大変そう。

そこでTrailblazerですよ!!!

Trailblazerは、Railsなどの上にサービスクラスなどのアーキテクチャを作るGemです。
昔流行った7 Patterns to Refactor Fat ActiveRecord Models(邦訳:肥大化したActiveRecordモデルをリファクタリングする7つの方法)みたいなのを、考えずにササッと作れます。

Railsと同じように、賢い人が作り、進化し、メンテナンスされるアーキテクチャに沿って生きたい気持ちです。

いやウチは新プロジェクトじゃないから…

新しいアーキテクチャを採用するのはプロジェクト開始時でなければ辛いと思われるかもしれませんが、そんなことはありません。
Trailblazerはとても現実的なGemなので、既存のコードと平行で使えます。つまり、少しずつ移行することができるのです。 1

そこで、今回は既存のアプリケーションの一部を書き換えていく形で紹介したいと思います。
題材は有名なRuby on Rails チュートリアルコードです。

本来はもっと難しいコードの方が有り難みが感じられるのですが、難しいコードを読みながら新しいことを覚えるの辛いと思うので、意味のないような簡単なコードで説明します。

やってみよう!

まずは動作確認

元のコードを動かすところから始めましょう。
Ruby on Rails チュートリアルのリポジトリの説明を見ながら元のアプリが正常に動くか確認します。

$ git clone https://bitbucket.org/railstutorial/sample_app_4th_ed.git sample_app
$ cd sample_app
$ bundle install --without production
$ rails db:migrate
$ rails server

アプリが立ち上がったら、 http://localhost:3000/signup にアクセスしてみましょう。
スクリーンショット 2017-07-28 10.39.33.png
動いてますね。

Trailblazerを使う準備をする

早速Trailblazerを使う準備をしましょう。GemfileにTrailblazerを追加します。

gem "trailblazer"
gem "trailblazer-rails"
gem "trailblazer-cells"

Gemfileに追加したGemをインストールします。

$ bundle install

Gemはインストールできましたので、次はディレクトリの作成です。
Trailblazerでは、appの下にconceptsというディレクトリを作成して、そこに使用するコードを入れていきます。
今回はUserというモデル周りを弄るので、次のようなディレクトリ構造にしてください。
スクリーンショット 2017-07-28 10.44.36.png

謎のcontractoperationというディレクトリもありますが、ひとまず作っちゃいましょう。

今回書き換える場所

http://localhost:3000/signup での処理を対象とします。
フォームを表示するのはUsersController#newで、UsersController#createにPOSTするとデータが作られます。
newアクションで表示してcreateアクションでデータを作る流れを、Trailblazerを使って書き換えていきます。

フォームオブジェクトを作る

それでは、フォームオブジェクトから追加していきます。

フォームオブジェクトでは、ユーザからの入力を受け付けてバリデーションを行います。2
フォームオブジェクトを使用する場合、モデルではなくフォームオブジェクトでバリデーションを行います

スクリーンショット 2017-07-28 10.57.39.png

app/concepts/user/contract/create.rbにファイルを作成し、以下のようなフォームオブジェクトを書きます。

app/concepts/user/contract/create.rb
require "reform/form/validation/unique_validator"

module User::Contract
  class Create < Reform::Form
    property :name, validates: { presence: true, length: { maximum: 50 } }
    VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
    property :email, validates: {
        presence: true, length: { maximum: 255 },
        format: { with: VALID_EMAIL_REGEX },
        unique: { case_sensitive: false }
    }
    property :password, validates: { presence: true, length: { minimum: 6 }, allow_nil: true, confirmation: true }
    property :password_confirmation, validates: { presence: true, length: { minimum: 6 }, allow_nil: true }
  end
end

いきなり大量のコードが出現したのでウッ…となりますね。少しずつ見ていきましょう。

まず、Trailblazerでは、フォームオブジェクトはReform::Formというクラスを継承して使います。

module User::Contract
  class Create < Reform::Form
    # 処理を書く
  end
end

クラスの中身には、以下のようなコードが書かれていますね。

property :hoge, validates: {...}

このコードで、フォームオブジェクトにhogeというgetterとsetterを作成して、そこに入った値にバリデーションを行います。
バリデーションの中身は元のモデルからほぼコピペしてるだけなので、詳細は見なくて大丈夫です。

emailuniqueバリデーションだけ少し特殊で、元はuniqueness: {...}と書いてましたが、unique: {...}になってます。
uniquenessはTrailblazerでは動きません。理由を話すと長くなってしまうので、とりあえずunique使ってれば基本問題ないです。詳しくはドキュメントを参照してください。
uniqueを使えるようにするために、一行目のrequireも必要になります。

newアクションのサービスクラスを追加する

フォームオブジェクトはできたので、実際に使うためにサービスクラスも作っていきましょう。
Trailblazerでは、Trailblazer::Operationを継承するクラスがサービスクラスになります。

まず、UsersController#newのアクションで使用するサービスクラスから書いていきましょう。

app/concepts/user/operation/create.rbを作成し、次のようにします。

app/concepts/user/operation/create.rb
class User::Create < Trailblazer::Operation
  class Present < Trailblazer::Operation
    step Model(User, :new) # User.newする
    step Contract::Build(constant: User::Contract::Create) # フォームオブジェクトをビルドする
  end
end

ファイル名がnew.rbではないことに注意してください。今後createアクションのコードもここに追加するので、createするためのサービスクラスとしてcreate.rbという名前で追加します。

内容を見ていきましょう。

class Present < Trailblazer::Operation
  step Model(User, :new) # User.newする
  step Contract::Build(constant: User::Contract::Create) # フォームオブジェクトをビルドする
end

stepが並んでますが、その名の通り、上から順番にstepが実行されていきます。

Model(User, :new)はそのまんまUser.newで、モデルのオブジェクトを新しく作成します。

Contract::Buildでは、先ほど作ったフォームオブジェクト(Reform)のクラスをビルドします。
OperationControllerでフォームオブジェクトを利用できるようにする役割も持っているので、ここでビルドしないとReformのクラスはオブジェクトとして利用することができません。

newアクションからの呼び出し

サービスクラスが追加できたので、Controllerから呼び出してみましょう。

app/controllers/users_controller.rb
def new
  run User::Create::Present
end

これで呼び出せます。簡単ですね。
元々あった、User.newするコードはサービスクラスに移譲されているので消えてます。
スクリーンショット 2017-07-28 14.49.23.png

Viewからフォームオブジェクトを使う

サービスクラスをControllerから実行できたので、サービスクラスの中で実行しているフォームオブジェクトのビルドはできてます。
あとは、ビルドしたフォームオブジェクトをViewで使うだけです。

対象のViewはapp/views/users/new.html.erbですね。
フォームオブジェクトの使用は超簡単で、思考停止して次のように書き換えます。
スクリーンショット 2017-07-28 11.31.48.png

唐突に@formという変数が使えるようになってますが、Controllerでrunメソッドを使用すると、ビルドされたReformのフォームオブジェクトが@formに自動的に代入されます。便利!

newアクションの挙動を確認する

一旦ここまでで http://localhost:3000/signup を表示してみましょう。
Gemなどを追加した関係で名前空間を再読み込みしたいので、一旦Ctrl+Cで起動してるサーバを落とし、再度立ち上げます。

$ rails server

特に前回までと変わりない画面が表示されているはずです。
createアクションを書き換えてないので、submitしても動きません)

createアクションのサービスクラスを書く

本命であるUsersController#createアクションのサービスクラスを書いていきましょう。

元のControllerのコードはこんな感じです。

app/controllers/users_controller.rb
def create
  @user = User.new(user_params)
  if @user.save
    @user.send_activation_email
    flash[:info] = "Please check your email to activate your account."
    redirect_to root_url
  else
    render 'new'
  end
end

よくある感じですね。

それでは、この処理を移譲するサービスクラスを書きましょう。
後で詳しく見ていくので、app/concepts/user/operation/create.rbに一旦次のように追加してみて下さい。

app/concepts/user/operation/create.rb
class User::Create < Trailblazer::Operation
  class Present < Trailblazer::Operation
    step Model(User, :new) # User.newする
    step Contract::Build(constant: User::Contract::Create) # フォームオブジェクトをビルドする
  end

  # --- ここから下を追加 ---
  step Nested(Present)
  step Contract::Validate(key: :user)
  step Contract::Persist()
  step :send_activation_email
  
  def send_activation_email(options)
    user = options["model"]
    UserMailer.account_activation(user).deliver_now
  end
end

眺めてみていかがでしょうか…?
意味が分からない方がほとんどだと思うので、順番に解説していきます。

Nested

まず、step Nested(Present)ですが、これはPresentクラスのstepを呼び出して実行しますよーという意味です。
以下のようなコードがありますよね。

class Present < Trailblazer::Operation
  step Model(User, :new)
  step Contract::Build(constant: User::Contract::Create)
end

step Nested(Present)

これは次のコードと同じです。

class Present < Trailblazer::Operation
  step Model(User, :new)
  step Contract::Build(constant: User::Contract::Create)
end

step Model(User, :new)
step Contract::Build(constant: User::Contract::Create)

Presentはクラス内クラスとして存在しているだけなので、外側のクラスから使いたい場合は明示的に呼び出す必要があるということです。

ところで、このコードでは、UsersController#newでフォームを表示する時と同じように、User.newを行い、その後にフォームオブジェクトを作成しています。
フォームオブジェクトはUsersController#newの表示にも必要ですが、データをPOSTされた時に受ける(そしてバリデーションエラーがあればそれをフォームに表示する)必要があるので、ここでも必要になります。

validateとpersist

step Contract::Validate(key: :user)
step Contract::Persist()

POSTで送信されてきたデータに対して、フォームオブジェクトで書いたバリデーションを行うのがContract::Validateです。
引数のkeyは、paramsの中のどのkeyがバリデーションの対象になるかを指定します。
バリデーションを行うタイミングで、paramsのデータとフォームオブジェクトをマッピングしているので、このような記述になります。

Persistは、フォームオブジェクトが持っている(バリデーションが通った)データをUser.newしたモデルに送り、saveします。

バリデーションが通らないとどうなるの?

ここで不思議なのは、下記のような通常のRailsにあるはずのバリデーションの結果によるifがないことですね。

def create
  @user = User.new(user_params)
  if @user.save
    # 成功した場合の処理
  else
    # 失敗した場合の処理
  end
end

TrailblazerのOperationでは、stepは上から順番に実行されるので分岐どこいったという感じがしますが、実はstepの実行が失敗すると、それ以上stepが進まなくなるのです。
この話をすると長くなってしまうので詳しくは書きませんが、Operationでは成功のラインと失敗のラインがあり、この2つで遷移を管理します。

image.png
http://trailblazer.to/gems/operation/2.0/index.html#flow-control から引用)

この図だと、成功している限りは右のstepを順番にこなし、失敗すると左のラインに移動して処理を終えます。

この仕組みが実に秀逸で、例えば保存処理をして良いユーザであるかチェックするとか、そういうチェック処理もこの2つのラインで管理できます。
誰が書いてもそれなりに見やすく書ける感じが好きです。

メール送信

サービスクラスでメール送信している部分について見ていきます。
元のcreateアクションでは、@user.saveが通った場合、メールを送ることになってました。

def create
  @user = User.new(user_params)
  if @user.save
    @user.send_activation_email # ←これ
    # ...
  else
    # ...
  end
end

このsend_activation_emailはモデルにありますが、他から呼び出されていないようなので、実装も呼び出しも以下のようにOperationに書いています。

step :send_activation_email

def send_activation_email(options)
  user = options["model"]
  UserMailer.account_activation(user).deliver_now
end

Operationstepで呼び出せるメソッドは、必ず引数を取る必要があります。この中にmodelがあるので、ここからstep Contract::Persist()で保存された後のUserモデルを取得できます。

createアクションを修正 する

さて、Operationは完成したのでUsersController#createから呼び出したいと思います。次のように書き換えましょう。

app/controllers/users_controller.rb
def create
  run User::Create do |_|
    flash[:info] = "Please check your email to activate your account."
    redirect_to root_url and return
  end
  render 'new'
end

newアクションの時とは違い、runメソッドがブロックを持っていますね。
runメソッドのブロックの中はstepが全て成功した場合に実行されます。この性質を利用して、次のように成功した場合と失敗した場合を分けます。

run User::Create do |_|
  # 成功した場合の処理
  return # returnするので、成功したらここでメソッドを抜ける
end
# 失敗した時の処理

全ての処理がOperationの中の実装で書ければ良いのですが、現実的にはControllerで実行しないと動かない処理はありますので、このようにブロックの内外に記述します。
Operationに入れないものとしては、redirect_toなどですね。redirect_toを使う時はControllerのオブジェクトがselfになることを期待していますが、Operationで実行するとselfが変わってしまいます。3

不要なコードのを削除

最後に、不要になったコードを消しましょう。

まず、Userモデル(app/models/user.rb)でメールを送っていた部分は消しましょう。
スクリーンショット 2017-07-28 14.48.49.png

また、ControllerのStrongParametersの処理は、フォームオブジェクトで生やしているプロパティしかそもそもモデルに入らないので、不要になります。
スクリーンショット 2017-07-28 14.48.30.png
…ただ、今回は別のアクション(UsersController#update)でも使用してるので、上のようにメソッドごと消しちゃうとアレな感じになります。ご注意ください。

動かす

http://localhost:3000/signup を試してみましょう!
(メールの設定がテストモードになってるので、メールは送られません)

今回行なった修正はgithubに置いときましたので、動かねーという方はそちらも参照して頂ければと思います。
ちなみにテストまでは面倒見てませんので動かないかもしれません。

終わりに

全てのクラスが役割ごとにスッキリまとまって良い感じになりましたね。
あまり考えずにサッとサービスクラスとフォームオブジェクト作れるの最高すぎます。

Trailblazerは他にも様々なアーキテクチャを使えますので、そちらもまた紹介できればと思います!

  1. 実際、弊社では半分ぐらいがTrailblazerを使って書かれてますが、半分はそのままのRailsになってます。

  2. フォームオブジェクトでは複数のモデルをまとめて処理するなど、もっと高度なことができるのですが、例が簡単すぎて他に書くことがありません :cry:
    なので、次のようにUserモデル(app/models/user.rb)からバリデーションを消しまして…

  3. runは引数に自由に渡せるので、Controllerのオブジェクトを渡したりすればOperationの中で書ける気がしなくもないですが、そういった書き方をしてる人は見たことないです。

46
47
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
46
47

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?