はじめに
こんにちは。KitchHikeエンジニアの小川です。KitchHikeでは主にサーバーサイドを担当しています。
少し前のものですが、「DHHはどのようにRailsのコントローラを書くのか (原文)」というすばらしい記事があります。Railsのコントローラ分割の(DHH流)ベストプラクティスについて解説した記事なのですが、私はこの記事に大変感銘を受け、KitchHikeのルーティング定義にもこのプラクティスを取り入れるようになりました。
本日はこのDHH流ルーティングを取り入れることで得られるメリット、実際の routes.rb
でのルーティング定義のしかたについて紹介したいと思います。
DHH流ルーティングとは?何がうれしいの?
詳しくは元記事を是非とも読んで下さい・・・なのですが、かいつまむと、ここで示されているのはたったひとつの単純明快なルールです。
コントローラはデフォルトのCRUDアクションindex、show、new、edit、create、update、destroyのみを使うべきだということです。その他のアクションはどれも専用の(それ自体はデフォルトのCRUDアクションしか持たない)コントローラの作成につながるのです。 DHHはどのようにRailsのコントローラを書くのか
このルーティングを取り入れるとどのようなメリットがあるのでしょうか?私は大きく2点あると考えています。
1.コントローラのスリム化
重要な責務のコントローラはえてしてアクションが増えがちです。気づけばコントローラの行数が増え、肥大化を招いてしまいます。
コントローラをスリム化するRailsのベストプラクティスとしては、古からよく聞かれる「Skinny Controller, Fat Model」というものがあります。ただ、これはモデル層にビジネスロジックを寄せようというアプローチで、アクション数自体が増えてしまった場合にはあまりマッチするものではありません。
それに対し、DHH流のルーティングのアプローチは、細かくコントローラを分割するというものです。Railsのデフォルトのアクション( index
、 show
、new
、 edit
、 create
、 update
、 destroy
)のみに制限することで、むやみにコントローラ内にアクションが増えることを防いでくれます。結果として、コントローラのスリム化につながります。
KitchHike内でもモデル層に対するアプローチ(たとえばConcernsへの分割など)は継続的に行っていましたが、こうしたコントローラ自体を分割する方法は意識していなかったので非常に新鮮でした。
2. ルーティングのルールが明解になる
コーディングルールは厳格に決まっていても、ルーティングのルールはあまり意識されていないところが実は多いのではないでしょうか?KitchHikeもそのきらいがあり、ルーティング定義は実装者の裁量まかせで、カスタムアクションにも特にルールは設けていませんでした。
カスタムアクション自体は悪いものではありませんが、ルールがないと実装者ごとのばらつきが出がちです。また、仮にルールを厳密に決めたとしても、それが複雑であれば誰もわからなくなってしまうでしょう。
それに対し、DHH流のルーティングのいいところはルールが単純で分かりやすいところです。はっきりしたルールがあれば、実装者によるばらつきは出にくくなりますし、更に開発者間でのコミュニケーションが取りやすくなります。
DHH流ルーティングのケーススタディ
さて、次はDHH流ルーティングを実際にどう取り入れるかをご紹介します。コントローラを分割するタイミングは大体以下の2パターンが多いのではないかと思います。
- リソースにサブアクションを追加する
- リソースをフィルタリングする
以降はこの2つのパターンをそれぞれ見ていきましょう。
ケーススタディその1. リソースのサブアクション編
ある特定のリソースに対して、CRUD以外の追加のアクションを付け加えたい場合です。 たとえば recipe
のリソースに対して、公開/非公開のアクションを付け加えるとしたらどうでしょう?
カスタムアクションを追加するのであれば、コントローラは以下のようになるでしょう。
class RecipesController < ApplicationController def index end # デフォルトのCRUDアクションが続く def publish # 公開処理 end def unpublish # 非公開処理 end end
でもDHH流に書くのであれば、ここはコントローラの分割のポイントです。
class Recipes::PublicationsController < ApplicationController def update # 公開処理 end def destroy # 非公開処理 end end
recipe
リソースの配下に publication
という公開/非公開を担うサブアクションを追加し、 update
に公開処理を、 destroy
に非公開処理をマッピングしてみました。
URLは以下のイメージです。
/recipes/:id/publication
素朴に書いてみる
次はルーティングです。
素朴にルーティング定義を書いてみるとこんな感じになるのではないでしょうか。何だかうまくいきそうです。
resources :recipes do resource :publication, only: [:update, :destroy] end
recipe_publication PATCH /recipes/:recipe_id/publication(.:format) publications#update PUT /recipes/:recipe_id/publication(.:format) publications#update DELETE /recipes/:recipe_id/publication(.:format) publications#destroy recipes GET /recipes(.:format) recipes#index POST /recipes(.:format) recipes#create new_recipe GET /recipes/new(.:format) recipes#new edit_recipe GET /recipes/:id/edit(.:format) recipes#edit recipe GET /recipes/:id(.:format) recipes#show PATCH /recipes/:id(.:format) recipes#update PUT /recipes/:id(.:format) recipes#update DELETE /recipes/:id(.:format) recipes#destroy
ダメでした。コントローラーがネストされず、 publication
そのままになってしまっています。
Railsはデフォルトではネストしたリソースに対して、ネームスペースを使ってくれないのです。
namespace
を使ってみる
では namespace
を使ってみるとどうでしょうか。
namespace :recipes do resource :publication, only: [:update, :destroy] end resources :recipes
ルーティングを見てみると・・・。
recipes_publication PATCH /recipes/publication(.:format) recipes/publications#update PUT /recipes/publication(.:format) recipes/publications#update DELETE /recipes/publication(.:format) recipes/publications#destroy recipes GET /recipes(.:format) recipes#index POST /recipes(.:format) recipes#create new_recipe GET /recipes/new(.:format) recipes#new edit_recipe GET /recipes/:id/edit(.:format) recipes#edit recipe GET /recipes/:id(.:format) recipes#show PATCH /recipes/:id(.:format) recipes#update PUT /recipes/:id(.:format) recipes#update DELETE /recipes/:id(.:format) recipes#destroy
こちらも上手くいきません。 publication
のアクションを行うリソースのidがどこかへ行ってしまいました。 namespace
だからよく考えると当たり前ですよね・・・。
module
を使おう
先の記事のコメント にもありますが、今回のケースだと module
を使うのがよいです。以下のように書いてみましょう。
resources :recipes do resource :publication, only: [:update, :destroy], module: "recipes" end
recipe_publication PATCH /recipes/:recipe_id/publication(.:format) recipes/publications#update PUT /recipes/:recipe_id/publication(.:format) recipes/publications#update DELETE /recipes/:recipe_id/publication(.:format) recipes/publications#destroy recipes GET /recipes(.:format) recipes#index POST /recipes(.:format) recipes#create new_recipe GET /recipes/new(.:format) recipes#new edit_recipe GET /recipes/:id/edit(.:format) recipes#edit recipe GET /recipes/:id(.:format) recipes#show PATCH /recipes/:id(.:format) recipes#update PUT /recipes/:id(.:format) recipes#update DELETE /recipes/:id(.:format) recipes#destroy
理想通りのルーティングになりました!
module
はコントローラーの名前空間を変更したいときに使います。今回はリソース名と同じ名前を module
に記述して、対応するコントローラー名をネストさせています。
ケーススタディその2. リソースのフィルタリング編
次はリソースを特定の条件でフィルタリングするケースを考えてみたいと思います。recipe
を「下書き」という条件でフィルタリングしてみましょう。
コントローラは先ほどと同じく分割して書いてみました。
class Recipes::DraftsController < ApplicationController def index # 下書き一覧 end end
素朴に書いてみる
早速ルーティング定義を書きましょう。こんな感じでどうでしょうか。
resources :recipes do resources :drafts end
recipe_drafts GET /recipes/:recipe_id/drafts(.:format) drafts#index POST /recipes/:recipe_id/drafts(.:format) drafts#create new_recipe_draft GET /recipes/:recipe_id/drafts/new(.:format) drafts#new edit_recipe_draft GET /recipes/:recipe_id/drafts/:id/edit(.:format) drafts#edit recipe_draft GET /recipes/:recipe_id/drafts/:id(.:format) drafts#show PATCH /recipes/:recipe_id/drafts/:id(.:format) drafts#update PUT /recipes/:recipe_id/drafts/:id(.:format) drafts#update DELETE /recipes/:recipe_id/drafts/:id(.:format) drafts#destroy recipes GET /recipes(.:format) recipes#index POST /recipes(.:format) recipes#create new_recipe GET /recipes/new(.:format) recipes#new edit_recipe GET /recipes/:id/edit(.:format) recipes#edit recipe GET /recipes/:id(.:format) recipes#show PATCH /recipes/:id(.:format) recipes#update PUT /recipes/:id(.:format) recipes#update DELETE /recipes/:id(.:format) recipes#destroy
・・・リソースがネストしたルーティングになってしまいました。今回はサブリソースを表現したいわけではないので、これではダメです。
namespace
を使う
また namespace
を使ってみましょう。
namespace :recipes do resources :drafts end resources :recipes
recipes_drafts GET /recipes/drafts(.:format) recipes/drafts#index POST /recipes/drafts(.:format) recipes/drafts#create new_recipes_draft GET /recipes/drafts/new(.:format) recipes/drafts#new edit_recipes_draft GET /recipes/drafts/:id/edit(.:format) recipes/drafts#edit recipes_draft GET /recipes/drafts/:id(.:format) recipes/drafts#show PATCH /recipes/drafts/:id(.:format) recipes/drafts#update PUT /recipes/drafts/:id(.:format) recipes/drafts#update DELETE /recipes/drafts/:id(.:format) recipes/drafts#destroy recipes GET /recipes(.:format) recipes#index POST /recipes(.:format) recipes#create new_recipe GET /recipes/new(.:format) recipes#new edit_recipe GET /recipes/:id/edit(.:format) recipes#edit recipe GET /recipes/:id(.:format) recipes#show PATCH /recipes/:id(.:format) recipes#update PUT /recipes/:id(.:format) recipes#update DELETE /recipes/:id(.:format) recipes#destroy
思ったとおりのルーティングになりました!
が、 routes.rb
がいけてないですね・・・。 recipes
を書く場所が分散してしまっています。できればまとめたいところです。
さらに、上記では namespace ~
を resources :recipes
の下に書くと、 drafts
が :id
と見なされてルーティングエラーとなります。書き順の依存があるのは何だかもやもやします。
そもそもリソース名を変えてみる
ここで提案したいのがそもそも「リソース名を変えてみる」ということです。下書きではない、通常の recipe
リソースを items
で表してみるとどうでしょうか。
namespace :recipes do resources :items resources :drafts end
recipes_items GET /recipes/items(.:format) recipes/items#index POST /recipes/items(.:format) recipes/items#create new_recipes_item GET /recipes/items/new(.:format) recipes/items#new edit_recipes_item GET /recipes/items/:id/edit(.:format) recipes/items#edit recipes_item GET /recipes/items/:id(.:format) recipes/items#show PATCH /recipes/items/:id(.:format) recipes/items#update PUT /recipes/items/:id(.:format) recipes/items#update DELETE /recipes/items/:id(.:format) recipes/items#destroy recipes_drafts GET /recipes/drafts(.:format) recipes/drafts#index POST /recipes/drafts(.:format) recipes/drafts#create new_recipes_draft GET /recipes/drafts/new(.:format) recipes/drafts#new edit_recipes_draft GET /recipes/drafts/:id/edit(.:format) recipes/drafts#edit recipes_draft GET /recipes/drafts/:id(.:format) recipes/drafts#show PATCH /recipes/drafts/:id(.:format) recipes/drafts#update PUT /recipes/drafts/:id(.:format) recipes/drafts#update DELETE /recipes/drafts/:id(.:format) recipes/drafts#destroy
routes.rb
も実際のルーティングもすっきりしたのではないでしょうか?
とはいえ、URLも変わってしまいますし、少々やり過ぎな気もします。先ほどの namespace
案との違いは routes.rb
を綺麗に書けるかどうか、というあくまでコードだけの問題です。
フィルタリングが複雑な場合などはいいかもしれませんが、単純なものであれば namespace
案で充分だと思います。
まとめ
以上DHH流のルーティングのメリットと実際のルーティング定義についてまとめてみました。あらためて整理するとこんな感じでしょうか。
- DHH流のルーティングは以下2つのメリットがある。
- 多数のアクションで肥大化したコントローラのスリム化
- ルーティングのルールが明確なのでコミュニケーションが取りやすい
- リソースのサブアクションを表すには
module
を使うと便利。 - リソースのフィルタリングを表すには
namespace
を使ってサブリソースを表現する方法、そもそもリソース名を変更する方法がある。
コミュニケーションが取りやすくなることが個人的にはとても重要に思います。コミュニケーションが取りやすくなることで、メンバー同士でレビューがしやすくなり、ルーティングの改善の機会に繋がりやすいからです。
DHH流ルーティングを取り入れて、一度チームメンバーの皆さんとわいわい話し合ってみてください。きっと楽しいですよ!
KitchHikeでは一緒にルーティングを考えてくれるRailsエンジニアを絶賛募集中です!