こんにちは。メドピアのRuby(Rails)化をお手伝いしている@willnetです。
Ruby化のプロジェクトが始まって1年が過ぎました。新しいメンバーも入り、Railsのコード量は日に日に多くなっています。可読性を保ちつつアプリケーションを大きくしていくために、使える知見をチームメンバーに効率よく伝えていくのが大事だと感じる今日このごろです。
普段メドピア内ではコードレビューや社内勉強会などで知識のシェアを行っています。そんなとき、ブログ記事や書籍などのまとまった文章があると「これ読んでおいて」と言うだけで良くなるので楽です。先日form objectを使ったほうがいいですよーという内容でレビューコメントをつけようとしたところ、日本語で詳しくまとまった文章が見当たりませんでした><なければ自分で書くしかありません。そこで今回はRailsにおいて可読性を保つための知見である、form objectについてまとめたいと思います。
form object って何?
form_withのmodelオプション*1にActive Record以外のオブジェクトを渡すデザインパターンです。form_withのmodelオプションに渡すオブジェクト自体もform objectと呼びます。
利点は大きく次の2点です。
- DBを使わないフォームでも、Active Recordを利用した場合と同じお作法を利用できるので可読性が増す
- 他の箇所に分散されがちなロジックをform object内に集めることができ、凝集度を高められる
具体例を見ていきましょう。
具体例
Rails 5.1.0でサービス管理者にフィードバックを返すフォームを作ってみます。フィードバック内容をデータベースに保存したい場合、素直に書くと次のようになるでしょう*2。
class Feedback < ApplicationRecord validates :title, :body, presence: true end class FeedbacksController < ApplicationController def new @feedback = Feedback.new end def create @feedback = Feedback.new(feedback_params) if @feedback.save redirect_to home_path, notice: 'フィードバックを送信しました' else render :new end end private def feedback_params params.require(:feedback).permit(:title, :body) end end
<%= form_with model: @feedback, local: true do |f| %> <% if @feedback.errors.any? %> <% @feedback.errors.full_messages.each do |message| %> <%= message %> <% end %> <% end %> <%= f.label :title %> <%= f.text_field :title %> <%= f.label :body %> <%= f.text_area :body %> <%= f.submit %> <% end %>
ここでフィードバックをデータベースに保存せずに、サービス管理者へメールを送信するようにコードを変更してみましょう。データベースに保存しないので、ApplicationRecordを継承したモデルは使用しません。
そうしたときにこんな感じのコードを書いてしまう人が多いのではないでしょうか。
class FeedbacksController < ApplicationController def new end def create if params[:title].present? && params[:body].present? AdminMailer.feedback(params[:title], params[:body]).deliver_later redirect_to home_path, notice: 'フィードバックを送信しました' else @error_messages = [] @error_messages << 'タイトルを入力してください' if params[:title].blank? @error_messages << '本文を入力してください' if params[:body].blank? render :new end end end
<%= form_with url: feedbacks_path, local: true do %> <% @error_messages && @error_messages.each do |message| %> <%= message %> <% end %> <%= label_tag :title %> <%= text_field_tag :title, params[:title] %> <%= label_tag :body %> <%= text_area_tag :body, params[:body] %> <%= submit_tag %> <% end %>
少し乱雑なコードになりました。まず、コントローラにロジックを記述してしまっています。また、独自のお作法でフォームを作成したため、ビューとコントローラを両方読まないとどのように動くのか、すぐに理解することができなくなりました。例えばビューとコントローラで@error_messages
という変数を利用していますが、どのような形式で情報が格納されているのか、どのように使用したらいいのかはビューとコントローラ両方を読まないと判断できません。
今回の例は項目数が少なく簡単なため、この程度であれば問題ないと判断する方もいるかもしれません。しかし、フォームの入力項目数が増えたり種類(セレクトボックス、チェックボックス、ラジオボタン)が増えたときのことを想像してみてください。コントローラが肥大化して読みづらくなり、ビューにどんなときに何が描画されるのかもわかりづらく、修正するのが大変になりそうですね。
これを解決するためにform objectを利用してみます。
form object を利用した例
form objectとして、ActiveModel::Modelをincludeしたクラスを用意します。 Active Modelは、Active RecordからDBに依存する部分を除いた振る舞いを提供しているライブラリです。これを利用することにより、DBを利用しないフォームでもActive Recordを利用したときと同じような記述をすることができます。
コードを見てみましょう。
class Feedback include ActiveModel::Model attr_accessor :title, :body validates :title, :body, presence: true def save return false if invalid? AdminMailer.feedback(title, body).deliver_later true end end class FeedbacksController < ApplicationController def new @feedback = Feedback.new end def create @feedback = Feedback.new(feedback_params) if @feedback.save redirect_to home_path, notice: 'フィードバックを送信しました' else render :new end end private def feedback_params params.require(:feedback).permit(:title, :body) end end
<%= form_with model: @feedback, local: true do |f| %> <% if @feedback.errors.any? %> <% @feedback.errors.full_messages.each do |message| %> <%= message %> <% end %> <% end %> <%= f.label :title %> <%= f.text_field :title %> <%= f.label :body %> <%= f.text_area :body %> <%= f.submit %> <% end %>
Feedbackクラスを除けば、Active Recordを利用した例と全く同じコードになりました。
このようにform objectを利用すると、Active Recordを利用した場合と同じお作法でビューとコントローラを記述することができて大変便利です。
個人的には、form_withでは必ずmodelオプションを利用する(form_tagを使わずにform_forを使う)という規則をチーム内に作ってしまってもよいのではないかと考えています。
その他の利用例
ここまでの文章を読むと「form objectはActive Recordを使わないフォームでだけ利用する」という理解をしてしまう方が多いのではないでしょうか。しかしform objectはActive Recordを利用する場合にも使えます。
例えば次のようなUserモデルがあるとします。
class User < ApplicationRecord has_secure_password validates :email, presence: true, format: { with: /\A.+@.+\z/ } validates :password, length: { minimum: 6 }, on: :create after_create_commit :send_welcome_mail def send_welcome_mail UserMailer.welcome(self).deliver_later end end
よくある形だと思いますが、passwordのバリデーションやコールバックは、ユーザ作成時以外は必要のないものです。emailのバリデーションもユーザ作成時とメールアドレス変更時だけ必要なものなので、常に必要というわけではありません。
こんな時にform objectを利用してUserモデルに書かれたロジックを外出しすることができます。
class Signup include ActiveModel::Model attr_accessor :email, :password, :password_confirmation validates :email, presence: true, format: { with: /\A.+@.+\z/ } validates :password, presence: true, length: { minimum: 6 }, confirmation: { allow_blank: true } def save return false if invalid? user = User.new(email: email, password: password, password_confirmation: password_confirmation) user.save! UserMailer.welcome(user).deliver_later true end end
class User < ApplicationRecord has_secure_password end
Userモデルがスッキリしましたね。
他にも、複数のActive Recordを一度に保存するようなフォームなどでもform objectを活用することができます。
form objectをもっと活用する
これまでの例では、すべてのform objectにActiveModel::Modelをincludeしてきました。簡単なコード例なのでこれで十分だと思いますが、実際の現場でのコードはもっと複雑になります。そんなときActive Modelだけだと少し面倒に思う部分も出てくるはずです。
例えば先程のユーザ登録用のform objectに、サービス運営側からのダイレクトメールを受け取るか否かのチェックボックスを追加したとしましょう。Railsのビューヘルパーであるcheck_box
メソッドはチェックした場合は文字列の"1"
、チェックしない場合は"0"
がパラメータとして送信されてきます。Active ModelはActive Recordとは異なり、属性の自動的なキャストは行いません*3。booleanとして扱いたい場合、キャストの処理は自分で書く必要があります。
class Signup attr_accessor :email, :password, :password_confirmation, :accept_dm # 略 def accept_dm ActiveRecord::Type::Boolean.new.cast(@accept_dm) end end signup = Signup.new(accept_dm: '1') signup.accept_dm #=> true signup = Signup.new(accept_dm: '0') signup.accept_dm #=> false
独自にキャストしないといけない属性の数が多かったり、種類が豊富だったりしたら面倒ですね。そんなときに使える、属性の型を定義できるgemやform objectのためのgemがいくつか存在するので、form objectをバリバリ使ってみたくなった方は一度目を通しておくと良いと思います。
- dry-rb/dry-types: Flexible type system for Ruby with coercions and constraints
- cgriego/active_attr: What ActiveModel left out
- makandra/active_type: Make any Ruby object quack like ActiveRecord
- trailblazer/reform: Form objects decoupled from models.
まとめ
form objectの利点や使いどころについて一通り解説しました。
form objectという概念自体は4~5年ほど前から広まっているはずなのですが、日本語でのまとまった文章がないことから、日本ではあまり普及していないような気がします。このエントリがform object普及の一助になって、読みやすいRailsのコードが少しでも増えることを願っています。
メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!
■募集ポジションはこちら
https://medpeer.co.jp/recruit/entry/
■開発環境はこちら