Constraintオブジェクトを使ってルーティング設定が担う役割を広げてみよう
こんにちは、「くらそうね」開発チームでバックエンドエンジニアをしている山口拓弥(@yamat47)です。
いきなりですが!
Railsエンジニアのみなさん、config/routes.rb
ってどう書いてますか?
そもそもRailsはリソースベースのルーティング設定をとても簡単に書けますし、少し前に DHH流のルーティング設定が周知(?)されたりもしました。
そのため、ルーティング設定ではあまり複雑なことをしていないアプリがほとんどなのではと想像しています。
「くらそうね」でもこの考えに則り、新たに追加される設定はほぼ全てリソースフルなルーティングになっています。
その上で最近、Constraintオブジェクトを使ったやや複雑なルーティング設定の実装をする機会があったのでその話をしてみようと思います。
満たしたい要件
「くらそうね」には、サービスを利用する際の参考にしていただくために、地域の解体費用の相場を表示しているページがあります。
例えばこちらのページでは、東京都の解体工事費用の相場の情報を表示しています:
こうしたページは都道府県ごとに存在し、また相場情報などはリクエストがある度に動的に計算をしています。
そのため静的なHTMLを返すわけにはいかず、RailsらしいMVCの仕組みを使ってレスポンスを返す必要があります。
また都道府県以外のページは準備をしていないため、こちらが想定していないパスでリクエストがあったときは404 Not Foundを返したいです。
例えば...
/price/tokyo
,/price/aichi
...想定しているパスであり、それぞれ東京都と愛知県の情報を返したい。/price/nihon
,/price/musashi
...想定していないパスであり、404 Not Foundとして扱いたい。
そして都道府県のデータはアプリのデータベース(prefectures
テーブル)で管理されています。
これまでの実装方法
ルーティング設定は素朴にシンプルにしようという考えのもと、パスの妥当性の検証も含めて具体的な処理はコントローラーに任せていました。
# GET /price/:prefecture_slug -> Price::PrefecturesController#show
Rails.application.routes.draw do
namespace :price do
get '/:prefecture_slug', to: 'prefectures#show'
end
end
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オブジェクトを渡す」という方法があり、これを使うと例えば以下のような設定をすることができます。
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オブジェクトを使った制約の指定を駆使して、コントローラーが担っていた責任範囲の一部をルーティング設定に移してみました。
module PrefectureConstraint
module_function
# 想定通りな値がprefecture_slugとして渡ってきたかどうかを判定する。
def matches?(request)
Prefecture.exists?(slug: request.params[:prefecture_slug])
end
end
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ガイド片手にカジュアル面談で語り合いましょう!