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

Railsにおけるサービス層の導入と感触

More than 3 years have passed since last update.

http://qiita.com/kbaba1001/items/e265ad1e40f238931468 で Rails のアーキテクチャを改善する話を書きましたが、理屈っぽい話になっていたので、今回は具体例として実際に私が仕事で実践していることについて書きます。

半年ほどサービス層を作りまくっていますが、最高としか言いようがありません。
Modelとロジックが切り離されているので、本番運用をはじめた後もかなり柔軟に開発を続けられています。
(さすがにERに影響がある変更には手を焼きますが...)

サービス層を作って Fat Model 対策をすることは Rails 開発において有効な設計だと感じています。

Rails にサービス層を導入する最も簡単でパワフルな手段は Trailblazer を使うことです。
しかし、本記事では Reform を使ってサービス層を自作する方法について書きます。
これは今回の仕事のプロジェクト開始当時、 Trailblazer が Rails でのオートロードまわりの機能を作り変えている途中で実用的ではなかったためですが、 Trailblazer の DSL に抵抗があるというケースや小さくサービス層だけ導入するために Reform を使いたいというケースで本記事が参考になれば、と思います。
また、本記事の Reform の使い方は Trailblazer の考え方をベースにしているため、 Trailblazer を理解する足がかりとしても役立つでしょう。

プロジェクトの概要

Webサービスですが、外部サービスとの連携用に一部APIもあります。

  • 開発者: 2名
  • 開発期間: 4か月
  • インフラは AWS 使用
  • Ruby 2.2.3、 Rails 4.2.6

rake stats の結果 (サービス層、テストのあたりちゃんと出てないけど)

+----------------------+-------+-------+---------+---------+-----+-------+
| Name                 | Lines |   LOC | Classes | Methods | M/C | LOC/M |
+----------------------+-------+-------+---------+---------+-----+-------+
| Controllers          |  1805 |  1413 |      67 |     206 |   3 |     4 |
| Helpers              |    39 |    31 |       0 |       7 |   0 |     2 |
| Models               |  1319 |   514 |      44 |      29 |   0 |    15 |
| Mailers              |   217 |   196 |       3 |      11 |   3 |    15 |
| Javascripts          |   130 |    81 |       0 |      26 |   0 |     1 |
| Libraries            |   270 |   222 |       4 |      35 |   8 |     4 |
| Acceptance specs     |   619 |   471 |       0 |       0 |   0 |     0 |
| Controller specs     |    46 |    35 |       0 |       0 |   0 |     0 |
| Feature specs        |   443 |   332 |       0 |       0 |   0 |     0 |
| Interaction specs    |   326 |   279 |       0 |       0 |   0 |     0 |
| Mailer specs         |    21 |    19 |       0 |       0 |   0 |     0 |
| Model specs          |   302 |   246 |       0 |       0 |   0 |     0 |
| Service specs        |  2754 |  2159 |       0 |       0 |   0 |     0 |
+----------------------+-------+-------+---------+---------+-----+-------+
| Total                |  8291 |  5998 |     118 |     314 |   2 |    17 |
+----------------------+-------+-------+---------+---------+-----+-------+
  Code LOC: 2457     Test LOC: 3541     Code to Test Ratio: 1:1.4

開発方針

  • 基本的にモデルにバリデーションを書かない
  • フォームを作るときはフォームオブジェクトをサービス層として作成
  • コントローラにはビジネスロジックを書かない
  • コントローラではセッションの操作やコントローラのレスポンスに関する処理のみを行う

この辺りの考えは Trailblazer と同じです。

サービス層

ディレクトリ・ファイル構成

app/services/
├── user/
│   ├── create.rb
│   └── update.rb
├── article/
│   ├── create.rb
│   └── update.rb
├── base_service.rb

app/services ディレクトリを作成して、その中に base_service.rb を作成します。
app/services 以下のディレクトリ名はモデル名に対応します。そのディレクトリ内のファイル名は機能を説明した名前にします。大体の場合コントローラのアクション名に対応したものがわかりやすいと思います。

app/services/user/create.rb などがサービス層の個々の実装となります。
これらは base_service.rb 中の BaseService クラスのサブクラスとして実装します。

サービス層のオートロード

上記のディレクトリ構成でオートロードできるように config/initializers/services.rb を次のように定義します。

require_dependency Rails.root.join('app/services/base_service')

Dir.glob("app/services/**/*.rb") do |f|
  require_dependency Rails.root.join(f)
end

BaseService

BaseServiceReform を使って、次のように定義します。

class BaseService < Reform::Form
  class ParametersInvalid < StandardError; end

  # NOTE `new(Model.new)` 等のようにしてインスタンスを生成して返すようにしてください
  def self.build
    raise NotImplementedError
  end

  def run(params = {})
    if validate(params)
      perform

      yield @model if block_given?
      return true
    end

    false
  end

  def run!(params = {}, &block)
    unless run(params, &block)
      raise ParametersInvalid, I18n.t('errors.messages.record_invalid', errors: errors.full_messages.join(', '))
    end
  end

  private

  def perform
    raise NotImplementedError
  end
end

サブクラスでは self.buildperform を定義する必要があります。

まず self.build ですが、実質普通に new するのとあまり変わりません。それでも定義しているのは、reform@model が何クラスのインスタンスかわかりやすくするためです。

performrun メソッドが実行された時にバリデーション検証に成功したときに呼び出されます。

BaseService を使ったサービス層の実装例

例えば、app/services/article/create.rb を次のように実装します。
(propertyvalidatessaveReform の機能なので、そちらを参照してください。)

class Article::Create < BaseService
  property :title

  validates :title, presence: true

  def self.build
    new(Article.new)
  end

  def perform
    save
  end
end

次に、これをコントローラとビューで使う例を実装します。

class ArticlesController < ApplicationController
  def new
    @article_create = Article::Create.build
  end

  def create
    @article_create = Article::Create.build
    @article_create.run(params[:article_create]) do |article|
      # バリデーションに成功した時の処理
      redirect_to article_path(article) and return
    end    

    # バリデーションに失敗した時の処理
    render :new
  end
end
-# app/views/articles/new.html.haml
%h1 記事の作成

= form_for @article_create, url: article_path do |f|
  = f.text_field :title
  = f.submit

まず、 new アクションで Model の代わりにサービス層のインスタンス変数をビューに渡します。
Reform の力により form_for を使うことができます。(simple_form などのgemも使えます。)

create メソッドが少し見慣れない形になっていると思います。 BaseService の実装を見ればわかる通り、 run メソッドはバリデーションに成功した場合、 perform を実行してブロックを実行します。
そのため、ブロック中には正常系の処理を書きます(return を忘れないように)。

ブロック内で return を書いておけば、run メソッドの後の行はバリデーションに失敗した時のみ呼ばれることになります。
そのため、ここでは異常系の処理を書きます。

この構成により、サービス層のインタフェースが統一されます。

サービス層を作るメリット

サービス層を用意することで、モデルからバリデーションを含めたロジックを切り離すことができます。

モデルにバリデーションを書かないというTrailblazer の考え方に初めて触れた時、私は
DBに間違ったデータが入るのではないかと不安になりました。
(実際、気をつけなればそうなる危険はあります)
しかし、ユーザーが画面から入力する部分に関してサービス層でバリデーションを書いておけば、問題ありませんでした。
コンソールやスクリプトなどで開発者が誤って不正なデータを作成してしまうケースがありますが、
むしろModelにバリデーションがあることで素直にデータを投入できないことのデメリットの方が大きいように感じます。

開発中はビジネス要件を満たすことを主眼においた設計をするのが精一杯だと思いますが、
運用がはじまると管理画面からの操作や想定外の処理をコンソールで行う必要が発生します。
今まで、そのようなケースでは Model のつくりが邪魔をしてうまく作業できないことがありました。
サービス層を導入することで、Modelに対する操作は自由度が増しました。

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
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  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
ユーザーは見つかりませんでした