はじめに

Rails アプリケーションは,通常 / を URL root として動作します. この記事は,これを /myapp に変更したときの顛末です. Rails のバージョンは 3.2.6 です. 結論となる対処法は簡単なのですが, 事情は思ったより複雑のようです.Rails は, 正しい作法がすぐ変わるので混乱しますね. 結論だけ知りたい人は, 結局おすすめは の節だけ読めばいいです.

まずは公式のガイドから

Ruby on Rails Guides: Configuring Rails Applications によると,以下の2つが目的に関係しそうです.

  • Rails.application.config.relative_url_root
  • Rails.application.config.assets.prefix

relative_url_root は,環境変数 RAILS_RELATIVE_URL_ROOT によってデフォルト値が設定されるので,

% RAILS_RELATIVE_URL_ROOT='/myapp' rails server -p 3000

などとすると,反映されるように思えます.しかし,rake で確認してみると,

% RAILS_RELATIVE_URL_ROOT='/myapp' rake routes

    new_user_session GET    /users/sign_in(.:format)         devise/sessions#new
        user_session POST   /users/sign_in(.:format)         devise/sessions#create
destroy_user_session DELETE /users/sign_out(.:format)        devise/sessions#destroy
    content_material GET    /materials/:id/content(.:format) materials#content
           materials GET    /materials(.:format)             materials#index
                     POST   /materials(.:format)             materials#create
        new_material GET    /materials/new(.:format)         materials#new
       edit_material GET    /materials/:id/edit(.:format)    materials#edit
            material GET    /materials/:id(.:format)         materials#show
                     PUT    /materials/:id(.:format)         materials#update
                     DELETE /materials/:id(.:format)         materials#destroy
                root        /                                materials#index

何だかうまくいってない. ググってみると,最近の Rails では, Rails.application.config.relative_url_root を陽に参照するように config/routes.rb を書換えないとダメみたいです. そこで, config/application.rb に scope の記述を追加:

Myapp::Application.routes.draw do
  scope (Rails.application.config.relative_url_root || '/') do
    # ...
  end
end

これで,

% RAILS_RELATIVE_URL_ROOT='/myapp' rake routes

    new_user_session GET    /myapp/users/sign_in(.:format)         devise/sessions#new
        user_session POST   /myapp/users/sign_in(.:format)         devise/sessions#create
destroy_user_session DELETE /myapp/users/sign_out(.:format)        devise/sessions#destroy
    content_material GET    /myapp/materials/:id/content(.:format) materials#content
           materials GET    /myapp/materials(.:format)             materials#index
                     POST   /myapp/materials(.:format)             materials#create
        new_material GET    /myapp/materials/new(.:format)         materials#new
       edit_material GET    /myapp/materials/:id/edit(.:format)    materials#edit
            material GET    /myapp/materials/:id(.:format)         materials#show
                     PUT    /myapp/materials/:id(.:format)         materials#update
                     DELETE /myapp/materials/:id(.:format)         materials#destroy
                root        /myapp(.:format)                       materials#index

おお,うまくいった!! と思いきや. Rails.application.config.assets.prefix は連動しない様子. assets 関係は,別のサーバに置いたりするからでしょうか.サーバを1つで済ませたいので, config/application.rb に assets の場所を記述します.

config.assets.prefix = File.join((config.relative_url_root || '/'), 'assets')

今度こそうまくいった!!

Devise がおかしい

でも,認証用の Devise プラグインがうまくいかないです.具体的には,未認証時の redirect 先である new_user_session_path がおかしい. 先の rake routes の結果からすると /myapp/users/sign_in に redirect されそうなのに,実際には, /myapp/myapp/users/sign_in に飛ばされてしまって 404 が出ます. ただ,URL 直叩きで /myapp/users/sign_in にアクセスすれば OK です.つまり,redirect 先の決定部分だけがおかしい.

Rack の 認証フレームワーク Warden からたどることしばし.認証失敗時の redirect 先を決めているのは, lib/devise/failure_app.rbscope_path だと分かりました. 以下抜粋.

 1: def scope_path
 2:   opts  = {}
 3:   route = :"new_#{scope}_session_path"
 4:   opts[:format] = request_format unless skip_format?
 5: 
 6:   config = Rails.application.config
 7:   opts[:script_name] = (config.relative_url_root if config.respond_to?(:relative_url_root))
 8: 
 9:   context = send(Devise.available_router_name)
10: 
11:   if context.respond_to?(route)
12:     context.send(route, opts)
13:   elsif respond_to?(:root_path)
14:     root_path(opts)
15:   else
16:     "/"
17:   end
18: end

7行目で config.relative_url_rootSCRIPT_NAME に突込んでるのが原因のようです. ここをコメントアウトすると動きます.これで問題解決としてもよかったのですが, 何かよく意味が分からないので,もう少し調べてみました.

7行目の動作って,大きなお世話じゃないの??… と思ったけど.調べると結構面倒な問題みたいで, Devise に関係なく,Rails 本家の github でもいくつか議論があった様子です. 以下が事情をよく説明しているように思えます.

config.action_controller.relative_url_root doesn't work in Rails 3.1 · Issue #4308 · rails/rails

SCRIPT_NAME vs RAILS_RELATIVE_URL_ROOT

僕の理解では,こういう問題があるようです.

  1. Rack の枠組では, SCRIPT_NAME が Rails でいうところの relative_url_root と同じ意味を持つ. しかし,両者は,連携しているようでしていない.
  2. Rack サーバの WebRick は,両者の関係を意識しないが, thin とかは, --prefix /myapp によって relative_url_root を設定する他,リクエスト毎の SCRIPT_NAMEPATH_INFO を設定する(ような気がする).
  3. assets 関係は,登場後日が浅い上にアプリケーションのルーティング (config/routes.rb) の外にあるので, 更に半端な連携状態になっている.

URL(リンク)の生成時と,対応するリソースの配信時(サーブ)で異なる情報を使っていることが問題のようです.

Devise は,そのへんの微妙な関係を修復するために世話を焼いていたようです. 本来なら Rack アプリケーションとしての Rails は, SCRIPT_NAME を信じて動けば問題ない筈で, RAILS_RELATIVE_URL_ROOT はなくてもいいように思うのですが, 過去の経緯からなのか,半端な状態になっているんだと思います.

Rack に正しく SCRIPT_NAME を付けさせるには,

Rack に対応したアプリケーションを起動する際に map で囲めばいいようです. 単純な Rack アプリケーションで確認してみましょう hello-rack.rb:

require 'pp'

class HelloRack
  def call(env)
    pp env
    [
      200,
      { 'Content-Type' => 'text/plain' },
      ['Hello Rack']
    ]
  end
end

config.ru:

require ::File.expand_path('.',  'hello-rack.rb')
map "/myapp" do
  run HelloRack.new
end

実行して

% rackup

localhost:9292 にアクセスすると,

{
 "SCRIPT_NAME"=>"/myapp",  # diff
 "PATH_INFO"=>"/hello",  # diff
 "QUERY_STRING"=>"",
 "REQUEST_URI"=>"http://localhost:9292/myapp/hello",
 "SERVER_NAME"=>"localhost",
 "SERVER_PORT"=>"9292",
 "SERVER_SOFTWARE"=>"WEBrick/1.3.1 (Ruby/1.9.3/2012-02-16)",
 "HTTP_HOST"=>"localhost:9292",
 "REQUEST_PATH"=>"/myapp/hello"
}

一方, config.rumap がない場合は,

{
 "SCRIPT_NAME"=>"",  # diff
 "PATH_INFO"=>"/myapp/hello",  # diff
 "QUERY_STRING"=>"",
 "REQUEST_URI"=>"http://localhost:9292/myapp/hello",
 "SERVER_NAME"=>"localhost",
 "SERVER_PORT"=>"9292",
 "SERVER_SOFTWARE"=>"WEBrick/1.3.1 (Ruby/1.9.3/2012-02-16)",
 "HTTP_HOST"=>"localhost:9292",
 "HTTP_VERSION"=>"HTTP/1.1",
 "REQUEST_PATH"=>"/myapp/hello"
}

SCRIPT_NAMEPATH_INFO の関係が map によって変化しています. Rack アプリケーションは, SCRIPT_NAME を加味して URL を生成しつつ, PATH_INFO を見てルーティングすればいいようです.

結局おすすめは

で,結局 Stack Overflow に頼るのが一番です. ruby on rails - What is the replacement for ActionController::Base.relative_url_root? - Stack Overflow つまり,以下の2つを両方実行すればいいです.

  1. config.ru の run を map で包む

    map ActionController::Base.config.relative_url_root || "/" do
      run Myapp::Application
    end
    

    これによって,毎回のリクエストに SCRIPT_NAME が設定され, これを利用してアプリケーションのルーティング (routes) 関連がうまく振舞えるようになります.

  2. 環境変数 RAILS_RELATIVE_URL_ROOT を設定してサーバを起動する

    % RAILS_RELATIVE_URL_ROOT='/myapp' rails server -p 3000
    

    これによって,Devise が SCRIPT_NAMERAILS_RELATIVE_URL_ROOT で上書きしても値が同じになるので, 未認証時の redirect がうまく動作します.

おまけ

この2つを実行すると,おまけで assets 関連もうまくサーブされるようになります. つまり, config.assets.prefix の設定は不要です. 上記2つの一方だけでは,assets は,うまくサーブされません.どうも,現状は,こうなっているようです:

  1. RAILS_RELATIVE_URL_ROOT をセットすると,asset 関連のURL生成部が /myapp を prefix として付けるようになる. しかし,この設定は,サーブ側には働かない.
  2. サーブ側を動作させるためには, config.assets.prefix で陽に指定するか, Rack 側の設定で map を書いて, SCRIPT_NAMEPATH_INFO を正しく与えるようにする.

この問題は,おそらく,次期バージョンで解決しそうです. Merge pull request #5296 from dlitz/relative_url_root_from_script_name · 9beb0b6 · rails/rails つまり,assets のために RAILS_RELATIVE_URL_ROOT を設定する必要がなくなりそうです.

一方, routes に関してはどうでしょうか.routes は, RAILS_RELATIVE_URL_ROOT を URL生成時に使わないようです. これは,assets とちぐはぐに見えますね. /myapp をURL生成時に付けさせるためには,以下2つのいずれかが必要です.

  1. config/routes.rb 中に scope hoge... の記述をする.
  2. Rack 側の設定で map を書いて, SCRIPT_NAMEPATH_INFO を正しく与えるようにする.

後者でURL生成がうまくいく理由がよく分かりませんが,おそらくURL生成にも SCRIPT_NAME を見てるんでしょうね.

両者は,若干意味が違います. 前者は,routes が /myapp まで意識してルーティングしているのに対して, 後者は,Rack が /myapp を意識していて, routes は PATH_INFO を見て / をトップとしてルーティングしています. なので,上記2つともやると,routes が GET / は知らんといって怒ります.

ルーティング関連は,よく分からないことだらですね.Rails バージョンアップの際には,またハマると思います. その時にこの経験が役に立つかもしれません.