Help us understand the problem. What is going on with this article?

Railsで大規模アプリケーションを正しく設計するために避けるべき3つの機能

More than 3 years have passed since last update.

この記事はCrowdWorks Advent Calendar 2016 の24日目の記事です。CrowdWorksのエンジニアが毎日なにかを書きます。

昨日の記事は @suzan2go による「ラーメン屋で考えるRailsのデータモデリング」でした。

はじめに

CrowdWorksは、2011年の創業以来、約5年間の開発を続けてきました。サービスの立ち上げ期においては、サービスの継続性・変更容易性を高めることよりも、サービスを成長させ、存続に繋がるフェースまで素早く立ち上げることが最重要な観点です。
一方で、サービス提供も5年が経過し、多くの方にご利用頂く「社会インフラ」に一歩ずつ近づいてきています。そういった環境の変化もあり、「日々改善し続ける」「日々変更し続ける」ことに重要視する観点が移り変わってきました。そのような価値観の変遷に取り組む過程で考えている「大規模で複雑な業務要件を担うRailsアプリケーションとの付き合い方」の一面を紹介します。

SQLアンチパターン: Polymorphic を使わない

Railsでは簡単に Polymorphic Association を作れてしまいますが、 SQLアンチパターン 6章 にも書かれているとおり使うべきではありません。使うべきでない理由をRailsの例を使って書いてみます。

BlogEntryPressReleaseという2つのモデルに対して「複数のコメントを投稿できる」という機能を考えた時、以下のようなデータ構造を作ったとしましょう。

class BlogEntry
  has_many :comments, as: :commentable
end

class PressRelease
  has_many :comments, as: :commentable
end

class Comment
  # commentable_type, commentable_id カラムを持っている
  belongs_to :commentable, polymorphic: true
end

image

最初の段階では問題無いかもしれませんが、追加の要件として、

  • PressRelease に対するコメントは、IR担当者の確認後に公開したい
  • BlogEntry に対するコメントは、自動スパムフィルタを入れたい
  • ...

といった内容が出てきた時に、 Comment モデル内に分岐が増え続けていくことになってしまいます。

class Comment
  belongs_to :commentable, polymorphic: true

  before_validates :set_published

  # IR担当者が確認後に公開するときに使うメソッド
  def publish!
    update_attributes(published: true)
  end

  private

  def set_published
    case commentable_type
    when BlogEntry.to_s
      self.published = spam_check # BlogEntry の場合はスパム判定結果に応じて公開する
    when PressRelease.to_s
      self.published = false if new_record? # PressRelease の場合は最初は非公開にする
    end
  end

  def spam_check
    # なにかする
  end
end

... サンプルコードを書いてみるだけでも疲れてきましたね。。

また、違う観点で考えてみても、

  • BlogEntryのコメントは、BlogEntryに対する関心はあるが、PressReleaseに対する関心は無い
  • PressReleaseのコメントは、PressReleaseに対する関心はあるが、BlogEntryに対する関心は無い

という、「関心の範囲」が異なるはずです。関心の範囲が異なるものは、別のモデルにするべきです。
似て非なるものは、同じような構造だとしても別モデルに分離しましょう。

Concern(頭痛の種)を避ける

肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)にも

“app/concerns ディレクトリを使っているようなアプリケーションって、だいたい後から頭痛の種になる(=concerning)んだよね”

と書かれていたりしますが、本当に頭痛の種ですね。一切使用禁止にしたい気持ちになっている今日このごろです。例を見てみましょう。

先程のBlogEntryPressReleaseのコメントについて、引き続き考えてみます。各々のコメントを別のモデルに分けたけれど、同じような処理をまとめるためにConcernを使った...という状況を考えてみましょう。

class BlogEntry::Comment
  include Commentable
  belongs_to :blog_entry
end

class PressRelease::Comment
  include Commentable
  belongs_to :press_release
end

module Commentable
  extend ActiveSupport::Concern

  included do
    after_create :notify_to_admin
  end

  private

  def notify_to_admin
    # サービス管理者あてにコメントが投稿されたことを通知する処理を書く
  end
end

image

Concernを使うことで、実装そのものは別ファイルに切り出して共通化することができましたが、2つのコメントモデルの責務は増えてしまいました。「コメント投稿時に管理者あてに通知されること」のテストをどこに記述するかも悩ましい問題です。Concernをincludeしたモデルで網羅的にテストを書いていると冗長なテストが増えていきますし、Concernのモジュールを単体でテストするのも一工夫が必要で、積極的に選びたい感じではありません。

# (a) include先のモデルでテストする
describe BlogEntry::Comment do
  context 'notify_to_admin' do
    # ...
  end
end

# (b) テスト用のクラスを作成し、そこにConcernを混ぜ込んでテストする。
describe Commentable do
  let(:klass) do
    Class.new do
      # Commentable に必要ないろいろなメソッドたちを定義する
    end.tap do |kls|
      kls.include(Commentable)
    end
  end

  context 'notify_to_admin' do
    # ...
  end
end

最近、私がコードレビュー等でConcernを使っているところ見かけた時には、「Concernを使って責務を混ぜ込むのではなく、独立したクラスを作って責務を移譲しよう」ということを言うようにしています。
例えば、以下のような感じでしょうか。

class BlogEntry::Comment
  belongs_to :blog_entry
  after_create -> { CommentCreateNotificationToAdmin.perform(self) }
end

class PressRelease::Comment
  belongs_to :press_release
  after_create -> { CommentCreateNotificationToAdmin.perform(self) }
end

class CommentCreateNotificationToAdmin
  def self.perform(comment)
    # サービス管理者あてにコメントが投稿されたことを通知する処理を書く
  end
end

Concernを使わずに、素のRubyオブジェクトを作って処理を移譲することで、モデルの責務を整理し、テストも書きやすい実装になっていくでしょう。

まとめると、

  • Concern を使うとテストが書きにくくなるから (ちょっとトリッキーなテストになりがち)
  • 単一責務の原則に反するから (そのクラスの役割が増えすぎる)

というような理由で、Concernを使うべきでない、と考えるようになりました。

Callbackに使われない

RailsではCallbackが豊富な機能を持っており、便利に使うことができます。が、便利だからという理由で使いすぎると、見通し悪く責務の分離も曖昧になり...という悪循環になりかねません。
他のフレームワークではCallbackを使わずに複雑な処理を実現しているのです。Railsでも「Callbackに使われる」のではなくて、別の手段を見つければ良いのです。そうしないと、Callback地獄から抜け出せません。1

先程のBlogEntry::Commentについて、引き続き考えてみます。各々のコメントが作成・更新・削除された時の処理が増えていった...という状況を考えてみましょう。

class BlogEntry::Comment
  belongs_to :blog_entry

  # ユーザ自身による作成・更新の場合は管理者向けに通知
  after_save -> { CommentUpdateNotificationToAdmin.perform(self) }, if: -> { by_user? }

  # ユーザのコメント投稿数を更新する
  after_save -> {
    # 最初の公開時にだけインクリメントする (公開→非公開→公開、と変化したときにカウントしない)
    if by_user? && first_published?
      User.increment_counter(:blog_etnry_comment, user.id)
    end
  }

  # ブログエントリの購読者にコメント投稿されたことを通知する
  after_save -> {
    self.blog_entry.watchers.each do |watcher|
      BlogEntryCommentNotifier.create(blog_entry, watcher, comment)
    end
  }
end

このように、たくさんのコールバックが増えてしまった...という状況は案外あるのではないでしょうか。
この例ではうまく表現できていませんが、callback同士が依存し、実行順とデータの状態によってエラーが発生する...なんていう状況になってしまうと、デバッグの難易度もあがり、サービスを安定して開発し続けるのが難しくなってしまいます。

そうならないために、一連の処理を見通しよく記述することが大切でしょう。全ての場面で使える「銀の弾丸」は無いので、状況に応じて適切な手段を考えていく必要があります。PofEAAの言うところの「サービス層 (Service Layer)」は、その一つの手段です。まずはServiceの入れ物を作ってCallbackの処理を移動させてみましょう。

class BlogEntries::CommentsController
  before_action :set_blog_entry

  def create
    BlogEntries::CommentCreationService.new(user: current_user, blog_entry: @blog_entry, comment_params: comment_params).perform!
    head :created
  rescue ActiveRecord::RecordInvalid => e
    render json: { ... }, status: :unprocessable_entity
  end

  private

  def set_blog_entry
    @blog_entry = BlogEntry.find(params[:blog_entry_id])
  end

  def comment_params
    params.require(...).permit(...)
  end
end

# app/services/blog_entries/comment_creation_service.rb
class BlogEntries::CommentCreationService
  def initialize(user:, blog_entry:, comment_params:)
    @user = user
    @blog_entry = blog_entry
    @comment_params = comment_params
  end

  def perform!
    create_comment
    notify_to_admin
    increment_user_counter
    notify_to_blog_entry_watchers
  end

  private

  attr_reader :user, :blog_entry, :comment_params

  def create_comment
    comment = blog_entry.comments.build(comment_params)
    comment.user_id = user.id
    comment.save!
  end

  def notify_to_admin
    CommentUpdateNotificationToAdmin.perform(self)
  end

  def increment_user_counter
    if comment.published?
      User.increment_counter(:blog_etnry_comment, user.id)
    end
  end

  def notify_to_blog_entry_watchers
    blog_entry.watchers.each do |watcher|
      BlogEntryCommentNotifier.create(blog_entry, watcher, comment)
    end
  end
end

BlogEntry::Comment のCallbackで、隣(BlogEntry)の隣(Watcher)の関連まで辿っていたのが無くなったり、処理順依存のあるCallback処理が明示的に順序が指定できるようになったり、と、一定のメリットはあるでしょう。しかし、オブジェクト指向設計に即しているかというと、まだまだ...ですね。オブジェクト指向プログラミングを実践できないまま積み重なっていくと、Service層のひとつひとつのクラスが肥大化していき、「俺が悪かった。素直に間違いを認めるから、もうサービスクラスとか作るのは止めてくれ by joker1007」というような状況になってしまいかねない気もします。この部分については、「オブジェクト指向を実践しよう」という掛け声だけでは足りず、幾つかのルールを明示するなど、継続して考えていかないといけない部分だと思っています。

まだ結論には至っていませんが、今考えている2つのアプローチについて簡単に紹介します。巨大で複雑なRailsアプリケーションをどのように設計・実装していくべきか、議論できると嬉しいです。

Service Layer の構造化

REST的な画面設計・UI/UX設計にできるならば、各Controllerでの処理はシンプルなものになるでしょう。現実にはそんなことはなく、1回のHTTPリクエストで複数のコンテキストに跨る処理を実行する必要が出てくることもあると思います。

例えば、

  1. 外部の決済APIを呼び出して決済処理を実行する
  2. 決済成功の情報を元に、自社サービスに反映する
    • (決済成功後に使えるようになる機能を有効化したり、領収書を作ったり、etc...)

というような場合。それぞれは独立した処理・トランザクションになり、興味の対象範囲は異なりますが、1回のHTTPリクエストで処理実行したい...ということはあるでしょう。Serviceの興味の範囲を適切に狭めつつ、Controllerの責務も小さく保つために、その間を取り持つ「ServiceHandler」のような層を作ると良いのではないだろうか、というアイディアを持っています。

image

  • Controller
    • ユーザからのリクエストをハンドリングする
    • ServiceHandlerを1つだけ呼び出す
  • ServiceHandler
    • Controllerから呼ばれる
    • ServiceHandlerは一つまたは複数のServiceを呼び出す
    • 他のServiceHandlerを呼んではいけない
  • Service
    • ServiceHandlerから呼ばれる
    • 他のServiceを呼んではいけない
    • トランザクションの境界を担う
    • ActiveRecord::Baseを継承したモデルを操作する

こういったルール(パターン)を明示することで、アプリケーションで統一された設計が進んでいくのではないか、と考えています。

イベント駆動を取り入れる

イベントメッセージを publish / subscribe する構造を取り入れることが出来れば、ロジックの依存関係を分離できるようになるでしょう。雑なサンプルコードですが、以下のようなイメージでしょうか。

class BlogEntries::CommentCreationService
  def initialize(user:, blog_entry:, comment_params:)
    @user = user
    @blog_entry = blog_entry
    @comment_params = comment_params
  end

  def perform!
    create_comment
    publish_event
  end

  private

  attr_reader :user, :blog_entry, :comment_params

  def create_comment
    comment = blog_entry.comments.build(comment_params)
    comment.user_id = user.id
    comment.save!
  end

  def publish_event
    EventPublisher.blog_entry_comment_created(user: user, blog_entry: blog_entry, comment: comment)
  end
end

module BlogEntries::CommentCreationEvent
  class NotificationToAdminSubscriber
    def self.perform(user:, blog_entry:, comment:)
      # 管理者向けの通知処理
    end
  end

  class UserSummaryUpdateSubscriber
    def self.perform(user:, blog_entry:, comment:)
      # ユーザのコメント投稿数を更新する
    end
  end

  class WatcherNotificationSubscriber
    def self.perform(user:, blog_entry:, comment:)
      # ブログエントリの購読者にコメント投稿されたことを通知する
    end
  end
end

このようなアーキテクチャを取り入れることで、ロジック間の依存関係を排除し、トランザクション的にも分離することができます。さらに言えば、Subscribeするアプリケーションは場合によっては Ruby on Rails 以外の言語・フレームワークを選択できるようになり、サービス全体のスケーラビリティ確保にも繋がると考えています。

まだ本格的に取り組み始めたばかりではありますが、実際に導入してみてどうなったか、後々お知らせできればと思っています。

まとめ

この記事では、クラウドワークスで考えている「大規模で複雑な業務要件を担うRailsアプリケーションとの付き合い方」の一部をご紹介しました。最近、Railsを使ったアプリケーション設計・実装について考える中で思っていることは、

  • Railsは複雑な機能を簡単に使えるように、便利な道具(Polymorphic、Concern、Callback、...)を用意してくれた
  • 規模が小さい/シンプルなアプリケーションであれば、その便利な道具を使うことによって得られるメリットは凄く大きい
  • けれども、規模が大きく/複雑なアプリケーションになった時には、「難しいことを簡単に実現する道具」の内部まで知る必要が出てきて辛くなる

というような事です。サービス立ち上げ時のRailsのメリットは十二分に理解できる一方で、その考えだけでは、大規模・複雑なアプリケーションを維持し続けることは難しいと感じています。そこに「銀の弾丸」は無く、いろいろなアプローチを考え続ける必要があると思っていて、その一端をこの記事ではご紹介しました。

クラウドワークスでは、サービスのアーキテクチャを考えながら、一緒に手を動かしてくれる仲間を募集しています。この記事や、アドベントカレンダーの他の記事を読んで興味が湧いたら、ぜひお話させてください。

気になるエンジニアと寿司ランチという企画もあるので、気になる記事を書いていたエンジニア指名もお待ちしています(時間帯は個別調整可能なので夜でもどうぞ)。


この記事はCrowdWorks Advent Calendar 2016 の24日目の記事でした。明日はクラウドワークス副社長の@shuzonaritaによる「非エンジニア副社長がゼロから歩む テクノロジー企業経営への道」の予定です。

akiray03
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away