187
141

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.

Ruby on RailsAdvent Calendar 2016

Day 15

サービスクラスについては僕も悪かったと思っているけど、それでもCQSは実現したいんだ

Last updated at Posted at 2016-12-17

このエントリは Ruby on Rails Advent Calendar 15 日目です。(遅くなってすいません)
同時に 14 日目のじょーかーさんのエントリへのアンサーエントリでもあります。

(まあ、じょーかーさんがこの Advent Calendar に登録したときに、タイトルから内容を推察してこれを書くことを決めましたが、実際のところ、あまりアンサーにもカウンターにもなってないし、全然関係ない内容と言えないこともないので、まあサービスクラスについては僕も推奨したことがあるし、僕も反省してるんですよ程度に読んでもらえると幸いです。)

まずはじめにごめんなさい

3 年くらい前に僕は Rails にサービスクラスというものを導入するといいことがあるよと書いたのだけど、それからいくつもの Rails アプリケーションを見たり、実際に自分で開発したりして、うーんって思うことも増えてきたので今の思いをあらためて書いてみます。このエントリを今まで参考にされてきた方々にはこの場を借りて謝罪させていただきます。

Railsでサービスとフォームを導入してみる話 - assertInstanceOf('Engineer', $a_suenami)

ただ基本的な思いは変わってなく、たぶん「サービスと呼んでしまったのがよくなかったな〜」というのが反省の肝です。

僕は単に CQS を実現したいだけだったのかもしれない

僕の Rails 歴もそろそろ 7 年だか 8 年だかをむかえ、業務で利用した期間に限定しても 5 年は超えてると思うので、そろそろ Rails とはこういうものだと自信を持って語っていい程度にはなったと思っている。

その僕の経験上、ActiveRecord を使ってアプリケーションを開発する場合、参照系の機能(検索とか)はパフォーマンス面で悩むことが多く、登録・更新系では機能面(仕様の複雑化、コードの可読性、バグ混入率)の悩みが多くなりがちである。というか、これはおそらく Rails に限った話ではなく、だからこそ CQRS というアーキテクチャパターンが注目されているんだと思うけど、ActiveRecord を使っているとなおさらそう思う。(他のフレームワークや ORM の使用経験がそんなにないのでわからないけど)

そして質的に異なるこの 2 つの悩みは解決方法がまったく異なるので、それらは別々に手を打てるようにできるだけ分離しておきたいし、そうあるべきだよねというのがこのエントリの趣旨である。

Rails における参照系の悩み

参照系に関する悩みについては以下のようなことが理由として挙げられるような気がする。

  • ActiveRecord が生成する SQL が狙ったものにならないことがある
  • UNION や CTE などの一部のクエリは ActiveRecord の機能としては提供されてない
  • USE INDEX 等によるインデックスヒントが与えられない

もちろん、そもそものテーブル設計がイケてないのではないかという意見もあるとは思うのだが、そのイケてない設計に達しやすい原因の一端をになっているのも ActiveRecord ではないかと思っており、なかなか業が深い。

ただ、これらの問題に対しては比較的手を打ちやすいと思うし、最悪の場合は任意の SQL を実行してしまうという裏技もまあできなくはない。なので、悩むことはあるものの、有限時間の悩みで比較的納得感のある解決策にたどり着きやすい。

# インデックスヒント(MySQLの場合)
class Article < ActiveRecord::Base
  def use_index(index_name)
    from("`#{table_name}` USE INDEX (`#{index_name}`)")
  end
end

# 任意の SQL(SQLインジェクションに注意)
# これは本当に最後の手段というか、原則やっちゃダメなのでよい子のみんなは真似しないように!
ActiveRecord::Base.connection.execute("SELECT * FROM `articles` LIMIT 10")

なにより息を吸って吐くように Gemfile に gem を追加してサードパーティのライブラリを利用していく Ruby の文化圏である。SQL で困ったら Memcached なり Redis なり Elasticsearch なり別のミドルウェアの導入を検討するだろうし、それらのクライアントラッパーとなる gem はだいたいのミドルウェアにおいて存在するので、そういった方向での解決を模索するほうがなんとなく Rails っぽさがある(注: 僕の観測範囲に限る)。

Rails における登録・更新系の悩み

登録・更新系での悩みは参照系とは質的に異なる。こちらはデータ整合性に関する問題が圧倒的で、つまり登録・更新時の入力値バリデーションやトランザクション制御の問題に帰結する。

特によく直面する複雑性は 2 つあって、1 つは条件付きバリデーション、もう 1 つが dependent の連鎖による処理のわかりにくさだ。

条件付きバリデーション

Rails では ActiveRecord クラスに宣言的にバリデーションルールを記述できる。

class Article < ActiveRecord::Base
  validates user_id, presence: true
  validates title,
    presence: true,
    length: { maximum: 30 }
  validates content, presence: true
end

素晴らしい!一目見ただけでこのクラスのバリデーションルールがわかる。そして、これは以下のように条件を指定することもできる。

class Article < ActieRecord::Base
  validates user_id, presence: true
  validates title,
    presence: true,
    length: { maximum: 30 }
    if: :published? # <- NEW
  validates content,
    presence: true,
    unless: :draft? # <- NEW

  def published?
    self.published_at.present?
  end

  def draft?
    !published?
  end
end

ifunless を両方紹介したかったので同じ条件をあえて違う書き方にしたが、どちらも意味は同じで、要するに下書き状態のときはそのバリデーションを適用しないということだ。まあ、これくらいシンプルであればまあわからなくはないし、便利な機能だなと思う。

でもこれはシステムの大規模化とビジネスの複雑化によってだんだんエスカレートする。

class SomeModel < ActiveRecord::Base
  validates :attr1, presence: true, if: :condition1
  validates :attr2, presence: true, unless: :condition2
  validate :validate_something, if: :condition3
  validate :validate_other, if: :condition4

  def validate_something
    # any validation process
  end

  def validate_other
    # any validation process
  end
end

こうなるともうどういうケースでどのバリデーションが有効なのか、ある機能を利用するときにどういう条件を満たせば正常に登録や更新が可能なのかが見ただけではわからなくなる。そして、そのうち、バグを混入してしまうことになり、大切な DB に不正なデータが入ってしまうことになるのである。

dependent の連鎖

もう一つ、よくある問題は dependent の問題だ。要は RDB で外部キーに設定する ON DELETE 句と同じようなもので、ON DELETE CASCADE と同等の設定を Rails だと以下のように書く。

class Article < ActiveRecord::Base
  belongs_to :user, dependent: :destroy
end

これもこれだけ単体で見ると非常に便利に見える。ただ、RDB の外部キーやトリガーでも同様だが、こういった「自動的に関連する処理が実行されてしまう仕組み」は本当に注意して使わないと大量の処理が連動して実行されてしまう。

class User < ActiveRecord::Base
  has_many: :articles
  has_many: :comments
end

class Article < ActiveRecord::Base
  belongs_to: :user, dependent: :destroy
  has_many: :comments
end

class Comment < ActiveRecord::Base
  belongs_to: :user, dependent: :destroy
  belongs_to: :article, dependent: :destroy
end

このような場合、ユーザが退会(user.destroy)すると連動してそのユーザが書いた記事がすべて消え、さらにそれをトリガーとしてその記事へのコメントもすべて消える。この振る舞い自体は正しいだろうし、それを宣言的に書けているという意味ではすばらしいことであるが、以下の 2 点で問題もある。

  • 処理の流れを追いにくい
    • ユーザの退会なのに User クラスを見ただけで何が起こるのかを把握できない
  • パフォーマンス問題への対応をしにくい
    • 例えば数百、数千記事を書いているユーザが退会したら?
    • その中に多くのコメントがついている人気記事があったとしたら?

こういう場合においては宣言的であることは必ずしも嬉しくはなく「ユーザ退会」という処理をきちんと設計し、ある程度は手続き的に記述したくなるものである。

素直に負けを認める、僕が欲しかったのはサービスクラスではなかった

僕が欲しかったのはサービスクラスでなく、単に CQS の C 側を担当してくれるクラスだったんだと思う。サービスなんて呼ぶから「サービスって何?PoEAA のサービスなの?エヴァンスの言ってるサービスなの?」となるけど、コマンドと呼べば(少なくとも)エンジニアにはわかりやすいのではないだろうか。DB に対して SELECT 以外の SQL を実行するすべてはコマンドになるのだから。

コマンドはもちろん「会員登録」とか「画像アップロード」のようにユーザの言葉や DDD でいうユビキタス言語になっているのが理想だけど、今ここで僕が言いたいのは本当に単にエンジニアの目線で DB への書き込みと参照を分離したいというだけなので、このエントリで言及する範囲内ではそれは必須ではない。コマンドがユーザ目線での処理単位になるべきだということについては機会があれば別途エントリを書こうと思う。

つまり僕が欲しかったのはこういうクラスだ。

class ArticlePostCommand
  extend ActiveModel::Model

  attr_reader title, content, user_id

  validates user_id, presence: true

  def initialize(title: nil, content: nil, by: nil)
    @title = title
    @content = content
    @user_id = by
  end

  def run
    return false if invalid?

    Article.create!(
      title: title,
      content: content,
      user_id: user_id
    )
  end
end
class ArticlePublishCommand
  extend ActiveModel::Model

  attr_reader article

  validate :validate_article

  def initialize(article_id: nil)
    @article_id = article_id
  end

  def validate_article
    self.article = Article.find_by(id: @article_id)
    if article.nil?
      self.errors.add(:article_id, :not_exists)
    else
      if article.title.blank?
        self.errors.add(:article_id, :blank_title)
      end
      if article.content.blank?
        self.errors.add(:article_id, :blank_content)
      end
    end
  end

  def run
    return false if invalid?
    article.update(published_at: Time.now)
  end
end
class ArticleDeleteCommand
  extend ActiveModel::Model

  attr_reader article

  validate :validate_article

  def initialize(artilce_id: nil)
    @article_id = article_id
  end

  def validate_article
    self.article = Article.find_by(id: @article_id)
    if article.nil?
      self.errors.add(:article_id, :not_exists)
    end
  end

  def run
    return false if invalid?

    # ここでいろいろ関連するデータを消す
    # 場合によっては非同期化や一時無効化などのパフォーマンス対策を検討する

    article.destroy!
  end
end

こうすることによって、記事を公開するときにだけ必要なバリデーションルールは ArticlePublishCommand に隠蔽できたし、記事を削除するときにどういうことが起こるかは ArticleDeleteCommand#run (だけ)を見ればわかるようになった。連動していろいろなデータが消えて DB の負荷が上がるみたいな問題が起こっても Sidekiq とかを使って非同期化するなり、一旦 disabled 的なフラグを立てて(評判のあまりよくない論理削除ってやつだ!)深夜バッチでゆっくり消すなり、対策を立てやすくなる。

ActiveRecord のバリデーションルールは「不変条件」なので、そこのところよろしく頼む

DbC (契約による設計)が好きな人ならわかると思うけど、僕たちがバリデーションとかアサーションとか呼んでるものには「事前条件」「事後条件」「不変条件」がある。まあ読んで字のごとくではあるが、それぞれ、ある手続きを始めるときに満たしていなければならないもの、手続きを終えるときに満たしていなければならないもの、常に満たしていなければならないものである。(ざっくり)

そしてこの分類で言えば ActiveRecord の validatesvalidate で宣言的に定義するルールは不変条件であるべきだと思う。DB のテーブルと 1 対 1 に対応するクラスであるという性質上、これらはテーブルに設定された各種制約とほぼ同等であり、例えば NOT NULL 制約が設定されたカラムと対応する attribute に presence: true が設定されるのは問題ないし、同様にユニーク制約が設定されている場合に uniqueness: true が設定されるのは問題ないのだが、それは誰がどういうコンテキストでデータを更新しても必ず満たされなければならないルール、すなわち不変条件である。

逆に、ある特定のコンテキストでのみ事前条件として適用されるビジネスルールはどこか別のレイヤーに委ねたいし、そうでないと先のような条件付きバリデーションだらけになる。

仮に「ユーザ退会」という操作ひとつとってもユーザ自ら退会する場合と管理画面からオペレータによって強制退会させられる場合で事前条件はきっと異なるし、User#destroy が実行されたときに必ず満たさなければならないルールというのは実は僕たちが考えているよりずっと少ない。

バリデーションには必ずどのコンテキストでそれを適用するかという観点が必要である。
そういう意味で、それを委ねるレイヤーとして ActiveRecord を継承したモデルクラス以外の可能性を模索していきたいし、それを今までサービスと呼んでしまっていたことは反省しているし、本当にごめんなさいという気持ちでいっぱいである。

最近やっていること

上のほうに書いたサンプルコードを見てもらえればわかると思うが、僕がここでコマンドと呼んでるものはクラスとして表現されているものの実際のところ単なる手続きである。コンストラクタ → 各種バリデーションメソッド → 実行(run)という順番で呼ばれることを想定しているし、逆にそれ以外の順番で呼ばれてもまったく意図通りの結果にはならない。(run の先頭で valid? を実行しているのでたぶん違う順番で実行できないけど)

このクラスを使う側からしても

command = ArticlePostCommand.new(title: 'title', content: 'content', by: current_user.id)
if command.run
  @article = command.article
  render
else
  render :new
end

というふうになってしまい、なんとなくコンストラクタを実行してインスタンスを作成するところが煩わしい。

なので、もっとステートレスな関数っぽく呼べるように以下のような親クラスを作ってみたりしている。ちなみにこれ(ここでは UseCase と呼んでいるが発想は同じ)を参考にした(というか、ほぼパクった)感じである。

module Command
  extend ActiveSupport::Concern
  include ActiveModel::Model

  module ClassMethods
    def run(*args)
      new(*args).tap do |command|
        command.run if command.valid?
      end
    end
  end

  def run
    raise NotImplementedError
  end

  def success?
    errors.none?
  end
end
class ArticlePostCommand
  include Command

  attr_reader title, content, user_id

  validates user_id, presence: true

  def initialize(title: nil, content: nil, by: nil)
    @title = title
    @content = content
    @user_id = by
  end

  def run
    Article.create!(
      title: title,
      content: content,
      user_id: user_id
    )
  end
end

こうすることによって、呼び出す側では

command = ArticlePostCommand.run(title: 'title', content: 'content', by: current_user.id)
if command.success?
  @article = command.article
  render
else
  render :new
end

と記述することができ、関数っぽい感じで呼び出すことができ、その戻り値は実行結果を保持するコンテナインスタンスという感じで処理を続けることができるのである。

187
141
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
187
141

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?