Ruby
Rails
DCI
Lotus
Trailblazer
RubyDay 24

Rails のアーキテクチャ設計を考える

More than 1 year has passed since last update.

はじめに

ここ一年くらいずっと Rails の何がダメでどうすれば良くなるのかを考えていました。
Rails を使ってそれなりの規模のアプリケーションを作ったことがある人なら、メンテナンスのしづらさを感じたことがあるのではないでしょうか。
メンテナンスの問題は Rails 以外の開発でも発生することですが、実のところメンテナンスしやすいアプリケーションはどうすれば作れるのでしょうか?
この難問に対して私も答えを持っていませんが、考え続けています。
少なくとも、 Rails Way や Rails Tutorial をベースにしたアプリケーション開発は、業務で用いるには簡単すぎるように思います。

「レールに乗る」という言葉がありますが、私は考え方を変えました。
Rails は規模の大きいフレームワークですが、土台に過ぎません。
Rails Way の設計方針は小規模な開発では有効ですが、規模が大きくなると Fat Model や Fat Controller を作り出し、メンテナンス性の低下を引き起こしがちです。
Rails を用いた開発であってもアーキテクチャ設計は状況に応じた選択が必要です。

おそらく上記の御託は、誰しもそれなりに感じていることだと思います。
今日は方法論の話をします。

ポスト Rails

まず参考として、ポスト Rails を狙う gem を紹介します。
アーキテクチャ設計に gem は必須ではありませんが、考え方を学ぶには最適です。

Trailblazer は Rails にサービス層や ViewModel を提供し、新しい規約を組み込みます。
http://trailblazer.to/https://github.com/apotonick/trailblazer を読むだけでも Trailblazer の考え方に触れることはできますが、 電子書籍 が一番詳しく書かれています。(先日100%執筆が完了したようです :tada:)
ドキュメントが長いので誤解しがちですが、 Trailblazer は小規模で柔軟性のある gem です。
私はこの半年間 Trailblazer の考え方をベースにした開発を行ってきました。

Lotus は Rails とは全く別の Ruby 製 Web フレームワークです。
まだバージョンが 0.5.0 なので Rails に比べると色々機能不足ですが、設計の美しさに魅力を感じます。
Rails と比べて Ruby や Rack を素直に使おうとしている感じがします。

Form と Model の分離

さて、本題に入ります。

Rails でよく困ることのひとつは Form と Model が密接に結びついてしまっていることです。
単に 1 つのモデルに対して新規作成または更新処理を行うだけならこれでもいいですが、次のようなケースでは Form と Model を分離したほうが無難です。

  • fields_foraccepts_nested_attributes_for を用いて1つの Form で複数の Model に対して同時に処理を行う
  • 同一の Model に対して複数の画面で新規作成または更新処理を行っており、状況によってバリデーションが異なる (validatesifon オプションを使うケース)
  • 1つのアクション(コントローラのメソッド)でフォームに表示した Model の新規作成または更新処理以外の処理を行う (Model の after_create 等で別の処理を実行するケース)

上記の例はいずれも Fat Model の原因になるだけでなく、何の操作で使うロジックなのか分かりづらいというデメリットがあります。
コンソールやテストでデータを作るときに「Hogeの新規作成」を行おうとして、複数のメソッドを実行する必要があり誤ったデータを作った経験はないでしょうか?
Controller と Model だけにロジックを書いた場合、「Hogeの新規作成」というユーザーの行動をうまく表現できない場合があります。
そこでリソースに対する CRUD を表現する層(サービス層)を別に用意します。

Trailblazer gem はまさにサービス層を提供する gem です。
gem の README は Trailblazer の思想を説明するために長くなっていますが、 この gem が提供する主な機能は Operation です。
他の部分は別の gem に分かれていますし、 Operation も Reform に DSL を付け加えたものです。
もし Trailblazer の DSL や規約が気に入らない場合、 Reform を使ってサービス層を作ると良いと思います。

詳しくは、私が過去に作成したスライドを見てください。
https://speakerdeck.com/kbaba1001/trailblazerwoye-wu-deshi-tutemita

Lotus の場合、Form やバリデーションに関するロジックはコントローラのアクションクラスに書きます。
(参考: http://lotusrb.org/guides/actions/parameters/ )
Lotus のコントローラは Rails とは異なりアクションごとにファイルが分かれています。
そのため Trailblazer の Operation にコントローラのアクションがくっついているような形になります。
Trailblazer の Operation の場合、HTTPリクエストに関わるロジック(Sessionの操作やリダイレクト先の指定など)はコントローラに書く必要があるため、ロジックが分離してしまいますが、 Lotus の場合一緒に扱うことができます。
一方で常に Rack server として扱う必要があるので、サービス層を Test Factory として使うケースでは余分な処理があることになります。

View のロジック

View に表示するテキストの整形などの理由により、 View だけで必要となるロジックがあります。
View に直接ロジックを書くとテストしづらくなるため、Helper にロジックを書きます。
Helper はデフォルトではすべてのファイルが読み込まれてしまいますが、 config/application.rb

config.action_controller.include_all_helpers = false

を設定することで特定の Helper だけを View で使用できます。

とはいえ、 Helper は単に View からアクセスできるメソッドを定義できるだけなので少々不便です。
これに対し、次の2つのアプローチがあります。

  1. Model の Decorator か Object#extend で View 用のロジックを使えるようにする
  2. Template (Partial) 毎にロジックを記述するクラスを定義する

Model の Decorator か Object#extend で View 用のロジックを使えるようにする

Model のインスタンスを View に渡す前に Decorator クラスでラップして、 View 用のインスタンスを作る方法です。
この場合、View用のメソッドが Model に結びつくので Helper より扱いやすい場合があります。
Decorator クラスは initialize で Model インスタンスを受け取るクラスを定義するだけでも十分ですが、 gem としては DraperActiveDecorator があります。
Draper は dacorate メソッドを呼ぶなどの方法で明示的にデコレートする必要がありますが、ActiveDecorator は render するときに自動的にデコレートします。
私の好みとしては Draper の方が Association のデコレートがしっかりしていたり、Model名以外の decorator を使用することもできるので好きです。

Object#extend を使う場合も Decorator を使ったときと得られるものは似ています。
module に View 用のメソッドを定義してコントローラでモデルのインスタンスに対して追加します。

module UserViewLogic
  def full_name
    # last_name と first_name は User モデルのメソッド
    "#{last_name} #{first_name}"
  end
end

class UsersController < ApplicationController
  def show
    @user = User.find(params[:id])
    @user.extend(UserViewLogic)
    # p @user.full_name #=> "馬場 一樹"
  end
end

Template (Partial) 毎にロジックを記述するクラスを定義する

Trailblazer (正確には Cells または Apotomo) や
Lotus では Template (Partial) 毎にロジックを定義する仕組みがあります。

View が複雑になってくると、View の一部だけを扱いたいケースがあります。
例えば、ブログの記事のページで記事の内容、コメントの投稿、検索フォーム、過去の記事の一覧を表示するような場合です。
このときコンポーネント毎に partial を分ければ template は分割できますが、インスタンス変数や Helper メソッドはコントローラのアクション単位でしか扱えません。

Cells はロジック付き partial のような機能を提供します。
Cells のディレクトリ構成は次のようになります。

app
├── concepts
│   ├── comment
│   │   ├── cell.rb
│   │   ├── views
│   │   │   ├── show.haml

app/concepts/comment/views/show.haml が partial のようなもので、 app/concepts/comment/cell.rb がそのためのロジックです。
内容はそれぞれ次のようになります。

# app/concepts/comment/cell.rb
class Comment::Cell < Cell::Concept
  property :body
  property :author

  def show
    render
  end

private
  def author_link
    link_to "#{author.email}", author
  end
end
-# app/concepts/comment/views/show.haml
%h3 New Comment
  = body

  -# Comment::Cell の private メソッドも呼び出せます
  = author_link

Cell は通常の View から concept メソッド (または cell メソッド) を使うことで render します。

-# app/views/comments/show.html.haml
= concept("comment/cell", @comment)

app/concepts/comment/cell.rb の単体テストで View コンポーネントのテストを書くことができるので、E2E テストで担保する範囲を減らすことができます。

Lotus の View も Cell 同様 Ruby のクラスファイルと erb などの Template ファイルが1セットになっています。
参考: http://lotusrb.org/guides/views/overview/

Model とは何か

ここまでの内容で、 Form と Model の分離と View のロジックの定義方法について話しました。
これにより Model から Validation と View のためのロジックが消えました。
Model の CRUD をサービス層に移すことで callback もほぼなくなるはずです。

ここでは基本に返って Model とは何かについて考えます。

Lotus::Model では Model は Repositories と Entities の2つに分かれています。
Repositories には SQL クエリの発行などデータベース操作に関するロジックを、Entities にはそれ以外のロジックを書きます。

Trailblazer では、Model はデータベースの ORM としての役割に注力して、 association と scope に関することのみ行うことになっています。
これは Lotus::Model の Repositories にあたります。
Entities にあたる部分はありませんが、次の3つのどれかを選択することになると思います。

  1. Operation クラス中にすべてのロジックを書く
  2. View のロジックを分離した時と同様に decorator か Object#extend で分離したファイルにロジックを定義する
  3. Model にメソッドを定義してロジックを書く

「1」で十分なケースが多いですが、テストを書きづらい場合があるので、そのときは「2」か「3」を選びます。
「2」に関して、 Trailblazer では Twin と呼ばれる decorator を使うことができます。
「3」に関して、 Trailblazer では非推奨ですが、変更頻度や利用頻度が低い Model 等では単純さを優先してもよいと思います。

The DCI Architecture でもデータモデルは時間が経っても比較的安定しているという経験則と照らしあわせて、

We must separate simple, stable data models from dynamic behavioral models.

と述べています。
DCI では Scala の Trait を用いた例でデータクラスにロール(ロジック)を割り当てていますが、これを Ruby で行うのが「2」の方法になります。

おまけ: Test Factory

Factory Girlfabrication のような gem は便利ですが、サービス層を作成しておくと、サービス層を Test Factory として使うことができます。

describe Comment::Update do
  it "updates" do
    comment = Comment::Create.(comment: {body: "Operation rules!"}).model # this is a factory.

    Comment::Update.(id: comment.id, comment: {body: "FTW!"})
    expect(comment.body).to eq("FTW!")
  end
end

(http://trailblazer.to/gems/operation/ の "Testing With Operations" より)

上記の例の場合、Comment::Create.(comment: {body: "Operation rules!"}).modelComment.create(body: "Operation rules!") と同じように見えます。
しかし、ここで重要なことは Comment.create により 「Comment データを 1 件作る」ことではなく、ユーザーがアプリケーション上で「コメントを投稿する操作」を行った後であるという状況を表現できていることです。
実際 Comment::Create が内部で単に Comment.create を実行しているだけなら同じことですが、例えば投稿時にコメント数をカウントアップしたり通知処理を行ったりする場合、 Comment::Create を用いることで再現できます。

同じようなことを Factory Girl の callback や trait で実装することがあるわけですが、そこに間違いが含まれる危険を考えればアプリケーション上のロジックがそのまま使えるほうが良いことは明らかです。

ただ、上記の場合 comment: {body: "Operation rules!"} をデフォルト値としてデータを作成してくれる機能がほしくなります。
これは専用の class を用意するだけで十分かと思います。

# spec/factories/comment_factory.rb または test/factories/comment_factory.rb
class CommentFactory
  def self.create(params = {})
    default_params = {comment: {body: "Operation rules!"}}

    Comment::Create.(default_params.merge(params)).model
  end
end

# テスト中での利用
describe Comment::Update do
  it "updates" do
    comment = CommentFactory.create
    # ...

細かいロジックはサービス層にあるので Factory の役割は Test を書くのが楽になるようにデフォルトのパラメータを用意することです。
デフォルトのパラメータを複数用意したいケースでは CommentFactory のメソッドを工夫すると良いと思います。

また、 Factory を作ることのメリットはもうひとつあります。
それはサービス層が未実装である場合に、テストデータを用意する部分のインタフェースを統一することです。
サービス層をテストデータの生成に利用する方法は、事前にサービス層が作られていることが前提となります。
しかし、システム開発では Create や Update よりも先に Read の機能を作成するケースが多々あります。
その場合は Factory を仮実装しておいて、 Create や Update のサービス層ができたらそちらを使うように置き換えます。

class CommentFactory
  def self.create(params = {})
    default_params = {comment: {body: "Operation rules!"}}

    # TODO サービス層ができたら置き換えること
    Comment.create(default_params.merge(params)[:comment])
  end
end

おまけ: エラー処理

エラー処理を rescue_from で行うと何かと辛いので gafferambulance のような gem を使うのが手軽だと思います。

参考