読者です 読者をやめる 読者になる 読者になる

KitchHike Tech Blog

KitchHike Product and Engineering Teams

DHH流のルーティングで得られるメリットと、取り入れる上でのポイント

はじめに

こんにちは。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のデフォルトのアクション( indexshowneweditcreateupdatedestroy )のみに制限することで、むやみにコントローラ内にアクションが増えることを防いでくれます。結果として、コントローラのスリム化につながります。

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エンジニアを絶賛募集中です!

www.wantedly.com