10
6

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.

サービスクラス(Trailblazerも含めて)での実装がときめかない

Posted at

はじめに

私のボスが「Trailblazerを使い、Railsのモデルの肥大化問題からサヨナラする」の記事をSlackで共有してきました。それを読んだエンジニア歴が浅い新人がプロジェクトに導入すべきかどうか悩んでいます。

私は直感的に思いました。「ダメだ。私たちにはまだ早い! いきなりTrailblazerでサービスクラスなんか書いていたら、WEBアプリケーションの設計の美しさに気づくことなく一生を終えてしまう。」

なお、ここでいうサービスクラスというのは、「俺が悪かった。素直に間違いを認めるから、もうサービスクラスとか作るのは止めてくれ」の記事にあるような、すべてのビジネスロジックを手続き型プログラミングのように書く実装を意味しています。そして、私にとっては、流行りのTrailblazerのOperationでさえも、そのようなサービスクラスをきれいに整えただけ1のように見えて、ときめきを感じないのです。

Trailblazerについての人気記事のコードを見てみる

先ほどの記事のようなサービスクラスのコードは、日々Qiitaを読んでいるような皆さんにとってはうんざりでしょう。

一方、そのサービスクラスを進化させることによってFat Model問題を解決しようとしているTrailblazerのコードについてはどのような印象をお持ちでしょうか? 

Operationのサンプルコード

はじめにで出てきたTrailblazerについての人気記事「Trailblazerを使い、Railsのモデルの肥大化問題からサヨナラする」で書かれているコードを引用しながら見ていきましょう。

コントローラー

まず、コントローラーは以下のようになっています。

def create
  # ユーザー認証機能をDeviseで作った場合、ユーザーはcurrent_userで取得できる
  run Post::Create, params[:post], current_user: current_user do
    # バリデーションに成功したら(全Stepが成功したら)こちらに入る
    redirect_to action: :index and return
  end

  # バリデーションに失敗したら(いずれかのStepが失敗したら)こちらにくる
  render :new
end

注目のサービスクラス

そして、こちらがサービスクラス(Operation)のコードです。コメント部分はすべて私が書きました。注目すべきは、フォームのバリデーションなども含めたすべてのビジネスロジックが、まるで手続き型プログラミングのように書かれていることです。そして、それらのステップの記述にはstepと呼ばれるDSLが使われています。

app/concepts/post/operation/create.rb
# Operationというのはサービスクラスのこと
class Post::Create < Trailblazer::Operation
  # DSLを使う
  extend Contract::DSL
  
  # Postモデル用のフォームオブジェクト(Reformと呼ばれる)を記述する。
  # Reformではモデル単位ではなく、フォーム単位でバリデーションを行う。
  contract do
    model "post"

    property :title, validates: { presence: true, length: { maximum: 100 } }
    property :body, validates: { presence: true, length: { maximum: 2000 } }
  end
  
  # ここのDSLが特徴的。ビジネスロジックをステップごとに記述する。 
  step Model(Post, :new)     # Postモデルを生成
  step :assign_current_user! # post.user = current_user と同等
  step Contract::Build()     # フォームオブジェクト(Reform)をビルド
  step Contract::Validate()  # フォームオブジェクト(Reform)をバリデート
  step Contract::Persist()   # フォームオブジェクト(Reform)を使ってモデルを保存
  step :notify_author!       # ユーザーへの通知
  
  def assign_current_user!(options)
    post = options["model"]
    post.user = options["current_user"]
  end

  def notify_author!(options)
    # ユーザーへの通知処理
  end
end

その他のコード

フォームオブジェクトであるContractのコードは、元記事をご覧ください。フォームやモデルのバリデーションだけでなく、Strong Parametersの処理もやってくれます。

感想: これが主流になると悲しい...

TrailblazerのOperationでのサービスクラスは、とてもすっきり書かれていて、何をやっているのかがわかりやすいです。プログラマーじゃなくても概要を理解できるレベルだと思います。

しかしですよ! MVCモデルのWEBアプリケーションフレームワークの集大成であり、WEBアプリケーション設計におけるノウハウの塊であるRuby on Railsのクールな機能がほとんど使われていないじゃないですか? 私たちが好きなのはオブジェクト指向ですよ、デザインパターンですよ、そして、オブジェクト同士がメッセージを送り合うコールバックを使う設計ですよ! 手続き型プログラミングみたいに書いてんじゃないよ(涙)

さらに、テストケースを書くならまだしも、一番大切なメインのコードを書くのに独自言語(DSL)を使うのですか! Ruby on Rails作者のDavid Heinemeier Hansson氏は美しいコードを書けるからRubyを選んだと言っています。その美しいコードを書けるRubyを捨ててまでやるのですか(涙)

Rails標準の機能を使って書いてみる

Trailblazerとの比較のために、同じことをRails標準の機能を使って書いてみます。

コントローラー

まずはコントローラーです。Railsの標準で書いた場合、コントローラーのコード量はTrailblazerと比べて少し増えてしまいます。ただ、これから述べるようにモデルをしっかりと書けば、コントローラーが肥大化することはありません。

def create
  form = NewPostForm.new(post_params)
  # ユーザー認証機能をDeviseで作った場合、ユーザーはcurrent_userで取得できる
  form.user = current_user 
  
  # ここでバリデーション、モデルの保存、ユーザーへの通知が行われる
  if form.save
    redirect_to action: :index and return
  end

  render :new
end

def post_params
  params.require(:post).permit(:title, :body)
end

注目のモデル

そして、注目のモデルです。Railsではコールバックの仕組みが積極的に使われます。ここでは、そのコールバックと、それをバリデーションの目的に特化したその名もバリデーションという仕組みを見ていきます。

コールバック

まずはコールバックです。

普通に書く場合

app/models/post.rb
class Post < ApplicationRecord
  after_save :notify_author
  # ...
  def notify_author
    # ユーザーへの通知処理
  end
end

モデルの保存後にafter_saveで定義したメソッドがコールバックされます。ここでは、投稿(Post)が完了したら、ユーザーにメールなどで通知を行うようになっています。

つまり、ユーザーモデルを保存するだけで、必要なメソッドが必ず実行されるわけです。サービスクラスに書いた場合、コードをコピペして新しいサービスクラスを作ったときに、必要な処理もうっかり消してしまうことがあります。モデルに書いておくとそんな心配もありません。

Fat Model対策をする場合

モジュール

もしモデルの肥大化が気になる場合は、Railsのモジュールの仕組みを使って、モデルを分割することができます。

app/models/post.rb
class Post < ApplicationRecord
  include PostNotificator # モデルにはこれを書くだけ
  # ...
end
app/models/concerns/post_notificator.rb
# 機能をモジュールとして、別のファイルで定義する
module PostNotificator
  extend ActiveSupport::Concern
  
  included do
    after_save :notify_author
  end

  def notify_author
    # ユーザーへの通知処理
  end
end

元のコードでは、モデルを保存した後の処理はユーザーへの通知だけでしたが、他にも、たとえば「モデルを保存した後にKVSの値も更新する」などの処理も、モデルにinclude KVSUpdaterと記述して、KVSUpdaterモジュールを定義すれば、Postモデルにすっきりと機能を追加することができます。

app/models/post.rb
class Post < ApplicationRecord
  include PostNotificator
  include RedisUpdater # モデルに機能を追加
  # ...
end
app/models/concerns/kvs_updater.rb
module KVSUpdater
  extend ActiveSupport::Concern
  
  included do
    after_save :update_kvs
  end

  def update_kvs
    # ここにKVSを更新するコード
  end
end
デリゲート

デリゲートの仕組みを使って、特定の処理を別クラスに移譲させることもできます。

app/models/post.rb
class Post < ApplicationRecord
  # notify_authorメソッドの処理を移譲する
  delegate :notify_author, to: :notify
  def notify
    PostNotificator.new(self).notify
  end
  # ...
end

バリデーション

コールバックに続いて、バリデーションです。バリデーションの使い方は、カラムのフォーマット合っているかというようなシンプルものだけではありません。外部APIを呼び出して、その結果によってはモデルを保存しないようなこともバリデーションクラスに書けばすっきりします。

app/models/post.rb
class Post < ApplicationRecord
  # モデルの保存前(バリデート時)にコールバックされる
  validates_with SubscriptionValidator
  # ...
end
app/validators/subscription.rb
class SubsctiptionValidator < ActiveModel::Validator
  def validate(record)
    # このユーザーが課金しているかどうかを外部APIを使って調べるなどの複雑な処理
  end
end

その他のコード

フォームオブジェクトであるTrailblazerのReformは、モデル単位ではなくフォーム単位でバリデーションを行うのですが、Rails標準のActiveModelでも同じようなことができます。

ただ、フォームオブジェクトに関してはReformの方が便利で使いやすいと思います。簡潔に書けるだけでなく、Strong Parametersの処理までやってくれます。元記事のContract(Reform)をご覧ください。

本記事の趣旨からは外れますが、ActiveModelを使ったフォームオブジェクトの実装も書いておきます。

app/forms/new_post_form.rb
class NewPostForm
  include ActiveModel::Model
  include ActiveModel::Attributes # Rails5.1以前ではActiveModelAttributesのgemを使う

  # モデルにあるアトリビュート
  def self.attributes
    [:name, :body, :user]
  end

  attr_accessor *self.attributes

  # モデルにないアトリビュート
  attribute :privacy_policy, :boolean, default: false

  # バリデーション
  validates :privacy_policy, acceptance: true

  def to_model
    Post.new(to_hash)
  end

  def save
    return false unless valid?
    to_model.save
  end

  def to_hash
    self.class.attributes.inject({}) do |hash, key|
      hash.merge({ key => self.send(key) })
    end
  end
end

上記の実装はとても大変な感じがしますね。ActiveModelをTrailblazerのReform並に便利にするには、たくさんハックする必要があります。以下のURLを参考にすれば、self.attributesto_hashのあたりが各フォーム間で共通化できると思います。
ActiveModel attributes - Stack Overflow

感想: やっぱりRailsはクール!

ActiveModelのコードを除き、上記のモデルの書き方はやっぱりクールです。必要なコールバックを定義して、そこをトリガーにして動かすのです。

コントローラーの主な処理は、渡されてきたパラメーターでモデルを作成/更新/破棄することだと思います。これに加えて、モデルを扱う前後でいくつかの処理が必要となります。Railsのモデル(ActiveRecord)では、その前後の処理をコールバックを使うことでロジックを分けて書けるようになっているのです。

コールバックの種類はこちらを参照してください。
Active Record コールバック - Rails ガイド

サービスクラスを作った方がよいケースもある?

ここまで書いておいてアレですが、サービスクラスを作った方がいいケースがあることは認めます。代表的なものはバッチ処理でしょう。

ただ、よく(コントローラーの1つのアクションで)複数モデルを扱う場合もサービスクラスが向いているんじゃないかという意見も聞きますが、複数モデル間でリレーションが張られていればやはりモデルだけで済みます。accepts_nested_attributes_forを定義すれば、1つのモデルを更新するコードを書くだけで他の関連付けられたモデルも更新されます。もちろん、その際、それぞれのモデルのコールバックも呼ばれます。

まとめ

特別なケースを除けば、サービスクラスを作るとおもしろくなくなるという私見から、私はTrailbrazerはあまり使いたくないなぁ...と思います。ただ、時代の流れには逆らえませんし、もしかすると食わず嫌いなだけかもしれませんので、今後使う機会があれば、ぜひ一度がっつりとやってみたいとも思っています。

  1. もちろん、ソースコードをきれいに整えて可読性、凝縮性を上げることが何より大切なのは理解しています。

10
6
2

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
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?