Header image

クラッソーネの開発者がエンジニアリングに関することもそうでないことも綴っています!

Constraintオブジェクトを使ってルーティング設定が担う役割を広げてみよう

Constraintオブジェクトを使ってルーティング設定が担う役割を広げてみよう

Twitter
山口 拓弥

こんにちは、「くらそうね」開発チームでバックエンドエンジニアをしている山口拓弥(@yamat47)です。

いきなりですが!
Railsエンジニアのみなさん、config/routes.rb ってどう書いてますか?

そもそもRailsはリソースベースのルーティング設定をとても簡単に書けますし、少し前に DHH流のルーティング設定が周知(?)されたりもしました。
そのため、ルーティング設定ではあまり複雑なことをしていないアプリがほとんどなのではと想像しています。

「くらそうね」でもこの考えに則り、新たに追加される設定はほぼ全てリソースフルなルーティングになっています。
その上で最近、Constraintオブジェクトを使ったやや複雑なルーティング設定の実装をする機会があったのでその話をしてみようと思います。

満たしたい要件

「くらそうね」には、サービスを利用する際の参考にしていただくために、地域の解体費用の相場を表示しているページがあります。
例えばこちらのページでは、東京都の解体工事費用の相場の情報を表示しています:

https://www.crassone.jp/price/tokyo

こうしたページは都道府県ごとに存在し、また相場情報などはリクエストがある度に動的に計算をしています。
そのため静的なHTMLを返すわけにはいかず、RailsらしいMVCの仕組みを使ってレスポンスを返す必要があります。

また都道府県以外のページは準備をしていないため、こちらが想定していないパスでリクエストがあったときは404 Not Foundを返したいです。
例えば...

  • /price/tokyo, /price/aichi ...想定しているパスであり、それぞれ東京都と愛知県の情報を返したい。
  • /price/nihon, /price/musashi ...想定していないパスであり、404 Not Foundとして扱いたい。

そして都道府県のデータはアプリのデータベース(prefecturesテーブル)で管理されています。

これまでの実装方法

ルーティング設定は素朴にシンプルにしようという考えのもと、パスの妥当性の検証も含めて具体的な処理はコントローラーに任せていました。

config/routes.rb
# GET /price/:prefecture_slug -> Price::PrefecturesController#show
Rails.application.routes.draw do
  namespace :price do
    get '/:prefecture_slug', to: 'prefectures#show'
  end
end
app/controllers/price/prefectures_controller.rb
module Price
  class PrefecturesController < ApplicationController
    def show
      # もし都道府県のデータが見つからなかったときはActiveRecord::RecordNotFoundが吐かれる。
      # それを捕捉してpublic/404.htmlを返す処理はRailsに任せている。
      @prefecture = Prefecture.find_by!(slug: params[:prefecture_slug])

      # 以降、解体工事の費用の集計をするロジックが続く。
    end
  end
end

これでも満たしたい要件は問題なく満たせていましたが、よりよくできるポイントがいくつかありました。

  • Railsが捕捉する例外が ActiveRecord::RecordNotFound なこと。都道府県リストという広く周知されたものとは異なる値が指定されているのだから、ルーティング設定に沿っていないということで ActionController::RoutingError が吐かれるのが自然な気がする。
  • /price/the-legend-of-zelda のような「絶対違うでしょ...」といったリクエストでも毎回コントローラー層の処理が起動してしまうこと。ApplicationController に様々な処理を書いている場合、無視できないコストになりそう。

調べてみるとRailsのルーティング設定を見直すことで課題を解消できそうだったので、試してみることにしました。

ルーティング設定における制約(Constraint)について

Railsのルーティング設定では、制約(Constraint)を課すことでマッチするための条件を追加することができます。
例えば Railsガイド | Rails のルーティング には次のような例が記載されています。

get 'photos/:id', to: 'photos#show', constraints: { id: /[A-Z]\d{5}/ }

上のルーティングは /photos/A12345 のようなパスにはマッチしますが、/photos/893 にはマッチしません。

この例では正規表現を使ってマッチするための条件を指定していましたが、制約を課す方法はそれ以外にも様々あります。
そのうちの一つに「matches?に応答できるConstraintオブジェクトを渡す」という方法があり、これを使うと例えば以下のような設定をすることができます。

config/routes.rb
module SundayConstraint
  module_function

  # Constraintオブジェクトはmatches?メソッドが定義されていることを期待する。
  # このメソッドがtruthyな値を返すときに制約を満たしたとみなされる。
  #
  # 引数にはコントローラーなどで参照できるいわゆるrequestオブジェクトが渡されるため、
  # 例えばIPアドレスやリファラーといったリクエストの情報をもとにした制約を課すこともできる。
  # もちろんこの例のように全然関係ない条件を指定することもできる。
  def matches?(request)
    Date.current.sunday?
  end
end

Rails.application.routes.draw do
  # SundayConstraint.matches?がtruthyなときにだけ当てはまるルーティング設定。
  # ワイルドカードセグメントが指定されているため、当てはまったときはパスが丸々params[:path]に入っている。
  get '*path', to: 'enjoy_sunday#index', constraints: SundayConstraint
end

Constraintオブジェクトを使うことでコントローラーの責任の一部をルーティング設定に移してみた

ようやくこの記事の本題に入ります。
Constraintオブジェクトを使った制約の指定を駆使して、コントローラーが担っていた責任範囲の一部をルーティング設定に移してみました。

app/constraints/prefecture_constraint.rb
module PrefectureConstraint
  module_function

  # 想定通りな値がprefecture_slugとして渡ってきたかどうかを判定する。
  def matches?(request)
    Prefecture.exists?(slug: request.params[:prefecture_slug])
  end
end
config/routes.rb
Rails.application.routes.draw do
  namespace :price do
    # constraintsを指定することで、想定していないパスへのリクエストがこの設定に当てはまらないようにする。
    # その結果ActionController::RoutingErrorが発生し、
    # コントローラーを経由することなく404 Not Foundを返すことができる。
    get '/:prefecture_slug', to: 'prefectures#show', constraints: PrefectureConstraint
  end
end

このように変更したことで、ルーティング設定とコントローラーとの責任範囲を次のように明確にすることができました。
ルーティング設定が担う役割が広がったことで、肥大化しやすいコントローラーの責務の一部を他に任せることができました。

  • ルーティング設定:リクエストが想定通りな形かどうかを判別する。
  • コントローラー:想定通りなリクエストに対してレスポンスを作って返す。

責任範囲を明確にすることは自動テストで担保する事柄を明確にすることにもつながります。
RSpecでいうところのrouting spec, request spec, system specのそれぞれで何を検証していくのか、整理しやすい状態にすることができました。

色々と蛇足な話

上で挙げた実装例ですが、せっかくコントローラーを経由しないで判別しているにも関わらずConstraintオブジェクトが毎回データベースにアクセスするのはもったいないです。
そこで実際にリリースしたコードでは、想定している値のリストを事前にCSVに吐き出し、それを使って想定通りのリクエストかどうかを判定しています。
「Constraintオブジェクトの紹介」という本題からはそれるため、実装例ではシンプルさを優先して実際の形とは変えていました。

アプリが肥大化するに従って、ScaffoldしたばかりのようなRailsの最低限の構成では管理しきれないことが増えてきます。
Fat Modelの倒し方 などに表されるようにModelやControllerの複雑さに対する対処法は様々議論されていますが、ルーティングの複雑さと戦う記事はあまり見つけられていません。
もし皆さまおすすめの記事や書籍などあればぜひ教えてください!

おわりに

今回はRailsのルーティング設定というちょっと地味なテーマで記事を書いてみました。
こうしたほぼ静的なページはWordpressなどのCMSで管理しているチームも多いと思いますが、少しでもRailsエンジニアの皆さまの参考になれば嬉しいです。

クラッソーネでは、ConstraintオブジェクトのようなRailsのちょっとマイナーな機能が好きなエンジニアを大募集中です。
少しでも興味がある方、ぜひ一緒にRailsガイド片手にカジュアル面談で語り合いましょう!

https://www.crassone.co.jp/recruit/engineer/


山口 拓弥
クラッソーネで Ruby on Rails を使ってサービス開発をしています。週末はアメフトのコーチをしたり選手もしたり、たまに審判もしたりしてます。エビ中もすき。
記事一覧