16
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rails: あなたがService Objectでやりたいことは、たぶんActiveRecordで1行でできる

Last updated at Posted at 2024-07-07

はじめに

自称、Ruby on Rails フロントエンドエンジニア:joy_cat:のnaofumiです。X @naofumiでは色々勝手なことを書いていますが、最近Qiitaをやろうかなと思っています。

それでは本題です!

Rails歴が長くても、Railsの便利機能が使えていない人は多い

私はフリーランスとして、ここ数年でいろいろなRuby on Rails案件に入ってきました。その中で強く感じるのは、Railsの便利機能をしっかり使いこなしている人が少ないということです。これは一見シニアっぽい人でもそうです。

とても残念なので、使いこなしている人が少ない便利機能に主に焦点を当てて、Railsの素晴らしさをなるべく多くの人に知っていただこうと思っています。

前回のviewのテストの記事もその一つでした。

今回は、ActiveRecordで関連オブジェクトを一気に作る複数の方法を紹介します。

一番多く見かける、ちょっと面倒なやり方

メッセージ(Message)を書き込めるSNSを作っているとする。最初の書き込みのハードルを下げるために、一つのコントローラアクションで、User登録とMessageの書き込みを同時にできるUIを作ったとする。イメージは下記の感じになる。

image.png

この場合、1つのコントローラアクションの中で下記の処理をする必要がある。やることは3つ

  1. 新規Userを作成する (name: "kagami")
  2. 新規Messageを作成する (message: "Hello world!", user: [上記で作成したUser])
  3. 失敗した時のエラー処理を用意する

よくある書き方はこんな感じ

# コントローラ
  def create_with_user
    @user = User.new(user_params)
    @message = Message.new(message_params.merge(user: @user))
    ActiveRecord::Base.transaction do
      @user.save!
      @message.save!
      redirect_to new_with_user_messages_path(@message)
    end
  rescue ActiveRecord::RecordInvalid => e
    @user.errors.add(:base, 'User and Message creation failed')
    render :new_with_user
  end

一応テストも(今回はminitestで書いた)

  test 'create_with_user with valid parameters should create new User and Message' do
    before_user_count = User.count
    before_message_count = Message.count
    
    post create_with_user_messages_path(
      user: { name: 'kagami' },
      message: { text: 'Hello world!' }
    )
    
    assert_equal(before_user_count + 1, User.count)
    assert_equal('kagami', User.last.name)
    assert_equal(before_message_count + 1, Message.count)
    assert_equal('Hello world!', Message.last.text)
  end

  test 'create_with_user with invalid parameters should not change database' do
    before_user_count = User.count
    before_message_count = Message.count
    
    post create_with_user_messages_path(
      user: { name: 'kagami' },
      message: { text: '' }
    )
    
    assert_equal(before_user_count, User.count)
    assert_equal(before_message_count, Message.count)
  end

最初のテストは正常系でUser, Messageのレコードが作成されていることを確認している。二番目のテストは異常系で、transactionが正しく動作していることを確認している(Userはvalidなのだが、Messageがinvalidなのを受けて、transactionがrollbackされ、結果として保存されないことを確認している)。よりしっかり動作確認するなら、コントローラのActiveRecord::Base.transactionを外してみて、テストが落ちることを確認したりすると良い。

ただし慣れている読者ならお気づきだと思うが、このコードのエラー処理はかなり甘い。例えばUser, Message双方がinvalidの場合は、Userのエラーメッセージしか取得できない。もしそこも含めて正しく書こうとするとより複雑なコードになる。私が今回あえて中途半端な処理に留めたのは、実際の現場でそこまでしっかりやっているケースが稀だからである。

加々美の感想

  1. コントローラアクションが複雑になってしまっている
  2. これぐらいならギリギリ我慢できるが、これよりちょっと複雑になると、コードをService Objectに移す人が出てくる
  3. モデル間のリレーションの作り方やバリデーション処理などはビジネスロジックになるので、本来はコントローラの責務ではないと考えられる。DDD視点で言うとService Objectはビジネスロジックを担当しても良いので、その意味でもこれをコントローラに書くよりはService Objectにした方が良いというのは理にかなっている
  4. Rails (ActiveRecord)以外のORMだと、この書き方が仕方ないことも多い。しかしActiveRecordは非常に多機能なので、後述のように、ActiveRecordならもっとうんと簡単に書ける。宝の持ち腐れ感が強く、勿体無い
  5. N数は少ないが、私が見たかぎりおおよそ7割のプロジェクトではこんな書き方をしてしまっている

ActiveRecordの自動保存機能を活かした書き方

Rails Guideに記載の通り、ActiveRecordはリレーションを自動保存する機能がある。これを使うとコントローラアクションは下記のように書ける。

# コントローラ
  def create_with_user
    @user = User.new(user_params)
    @message = @user.messages.build(message_params)
    if @user.save
      redirect_to new_with_user_messages_path(@message)
    else
      render :new_with_user
    end
  end

解説すると、ここでは@userオブジェクトを中心に、メモリの中でオブジェクトのリレーションを構築し、最後に@user.saveを呼び出している。こうするとActiveRecordは自動保存をしてくれて、うまい具合にUser, Message双方を保存してくれる。@messageに対してsaveが呼ぶのではなく、ActiveRecordが自身のリレーションの変更を自動検知・自動保存してくれる機能を使っている。自動保存時にはDBに対する変更は全て1つのtransactionの中で行い、バリデーションエラーも綺麗に処理してくれる。

最初の例のテストもそのまま通るので、UserとMessageが一つのtransactionの中で保存されることが確認できる。また今回自動テストではチェックしていないが、実はUser, Message双方がinvalidだったときに双方のエラーメッセージも正しく表示してくれる。

どうだ!って言うぐらいに簡単になる。しかもScaffoldのコントローラとほぼそっくりで、見慣れた形である。非常に取り扱いやすくなっている。

加々美の感想

上の「ActiveRecordの自動保存機能を活かした書き方」は、ActiveRecordの自動保存機能を使う一方で、レコードを作る各メソッド(User.newおよび@user.messages.build)にパラメータを渡すのはコントローラの責務としている。

これが一番RailsのMVCの責務分離の考え方に沿っていると私は感じている。その意味ではバランスが取れたやり方ではないかと思う。

  • HTTP paramsを受け取り、それを加工してActiveRecordクラスに渡すのは、コントローラの責務
  • モデル(ActiveRecord)のオブジェクトやレコードの作成、およびそれがビジネスロジックに沿っていることを保証するのは、モデルの責務

accepts_nested_attributes_forを使った書き方

一方でActiveRecordには accepts_nested_attributes_forという強力なメソッドがある。

こっちはviewとcontrollerとmodelが密結合になるのは覚悟の上で、特定のパターンにマッチしたものならば、複雑なフォームでもとても簡単に作れる仕組みである。

使い方は下記の通りになる。

# コントローラ
  def create_with_user
    @user = User.new(user_params.merge(messages_attributes: [message_params]))
    if @user.save
      redirect_to new_with_user_messages_path(@user.messages.last)
    else
      render :new_with_user
    end
  end
# Userモデル
...
  accepts_nested_attributes_for :messages
...

非常に多機能なので、使い方の詳細は公式ドキュメントをぜひ確認していただきたい。大きなポイントだけ下に解説する。

  1. accepts_nested_attributes_forクラスメソッドにより、Userクラスの中にUser#messages_attributes=(args)というメソッドが生成される。これが accepts_nested_attributes_for の処理の本体である
  2. 今回のケースでは、User#messages_attributes=(args)は新たに作るべき各MessageレコードのパラメータをArrayにまとめた引数([message_params])を受け取る。そして実質、前の自動保存の例の@user.messages.build(message_params)と同じ処理を実行する
  3. 前の自動保存の例と同じように、@user.saveとすると@user.messagesも同じtransactionの中で自動保存される

加々美の感想

accepts_nested_attributes_forは、viewのFormHelperfields_forと組み合わせると、非常に少ない労力で複雑なネスト化されたフォームを作ることができる。

ただしそのためにモデル、ビューとコントローラの疎結合が犠牲になり、密結合になる。やりすぎと言われても仕方がないところはある。

今回はその極一部だけの機能を使っただけなので、普通に自動保存を使った前の例と労力がほとんど変わらないし、却って分かりにくくなった感が拭えない。でもうまく使うと非常に強力なことには変わりはない。仕組みぐらいは理解して、必要になったら使うという姿勢で損はない。

なお、User#messages_attributes=(args)にparamsをうまく流し込む発想は意外と使い所があるので、覚えておいて損はないと思う。User.new(name: "kagami", messages_attributes: [message_params])は実際には下記と等価であると覚えておくと良い。だからhogehoge=(args)というメソッドをUserに用意すれば、User.new(name: "kagami", messages_attributes: [message_params], hogehoge: "hugahuga")って書き方もできる。hogehogeをデータベースに保存する必要はない一方でバリデーション時に呼び出せるので、例えば利用規約への同意を確認するがDBに保存しないときなどに使う。

User.new(name: "kagami", messages_attributes: [message_params])
# 上記は下記とほぼ等価
user = User.new
user.name = "kagami"
user.messages_attributes = [message_params]

そしてRailsにおけるService Objectの考え方について

私はRailsにおいて、Service Objectが必要なケースはかなり稀だと考えている。あったとしても、今回のように複数レコードを作る程度の複雑さではService Objectを使う必要は感じない。もっとうんと複雑な時に使う可能性は否定しないが、ActiveRecordで複数レコードを作る時ですらService Objectに頼らなければならないのなら、この先もっと複雑な処理に対応できるかが心配になる。この程度ぐらいはもっとサクッとさっぱり書けるようになっておきたい。(これができない人の書いたService Objectはどうせ汚いんじゃないのかという意味で)

少なくとも海外においては、RailsにService Objectは不要という考え方が主流になってきたように思う。やるとしてもデータベースと結びつかない、ただのRubyオブジェクト:PORO (Plain old Ruby object)を使う話の方をよく聞くので、これも近日中に紹介したい。

ActiveRecordの自動保存とDDD

別に機会を設けて話したいと思うが、ActiveRecordの自動保存とDDDのAggregateの考え方は非常に親和性が高いと私は思っている。DDD Aggregateでは、複数のクラスのうちの一つ(Aggregate root)が主導権を握って、他方のクラスのバリデーションから保存までをトリガーする。今回はUserがAggregate rootだったと考えるととてもよく似ていることを感じてもらえると思う。

Railsの自動保存機能は他のORMにほとんど見られない特徴だと私は認識しているが、考え方として特殊なのではないことを理解していただきたい。他のORMを使った場合はカスタムで書いて、FactoryクラスとかService Objectにコードをしまっておくけど、RailsだとActiveRecordにその機能が最初からあるだけの話。

最後に

自分も含めて言うが、Railsは非常に多機能なフレームワークである。他のフレームワークではなかなか見られない機能もある。エンジニアとして何年も経験を積んでも、まだまだ学べるところがたくさん残っている。だからシニアエンジニアであっても、Railsの知識の抜け漏れが多いと考えて間違いない。

日頃から積極的に公式ドキュメントを読み返すなどして、Railsの知識を常に自分の中でアップデートするのは絶対にやった方が良いと私は思っている。誕生から20年のフレームワーク、その奥深さは計り知れない。

16
15
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
16
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?