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
BaseService
は Reform を使って、次のように定義します。
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.build
と perform
を定義する必要があります。
まず self.build
ですが、実質普通に new
するのとあまり変わりません。それでも定義しているのは、reform
の @model
が何クラスのインスタンスかわかりやすくするためです。
perform
は run
メソッドが実行された時にバリデーション検証に成功したときに呼び出されます。
BaseService を使ったサービス層の実装例
例えば、app/services/article/create.rb
を次のように実装します。
(property
や validates
や save
は Reform の機能なので、そちらを参照してください。)
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に対する操作は自由度が増しました。