Posted 8 months ago

HTTP ACCEPT of mobile devices in Rails based sites

最近在 hoptoad 上看到一些詭異的 log,似乎是網站升到 Rails 3 後才出現的:

ActionView::MissingTemplate: Missing template posts/show with {:formats=>["application/youtube-client", "*/*"], :handlers=>[:rjs, :rxml, :rhtml, :builder, :erb], :locale=>[:"zh-TW", :"zh-TW"]} in view paths

沒錯,怎麼會有 format 是 “application/youtube-client”, “/“,這到底是什麼鬼玩意送出來的?(狀態顯示為才疏學淺)詳細看了一下 HTTP header 資訊發現

HTTP_ACCEPT "*/*, application/youtube-client"
HTTP_USER_AGENT "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; HD2_T8585; Windows Phone 6.5)"
HTTP_UA_OS "Windows CE (Pocket PC) - Version 5.2"

原來是 Windows Phone 這樣的 mobile 裝置送的。

但由於數量不少,於是再繼續看看是不是有其他奇怪的 log 有類似情況的:

  • 居然有 PSP 的,好樣的:
ActionView::MissingTemplate: Missing template posts/show with {:handlers=>[:rjs, :builder, :rhtml, :rxml, :erb], :locale=>[:"zh-TW", :"zh-TW"], :formats=>["*/*;q=0.01"]} in view paths
HTTP_ACCEPT "*/*;q=0.01"
HTTP_USER_AGENT "Mozilla/4.0 (PSP (PlayStation Portable); 2.00)"
  • 華為科技的產品,是分享器嗎?
ActionView::MissingTemplate: Missing template photos/show with {:formats=>["application/vnd.wap.wmlc", "application/vnd.wap.wmlscriptc", "text/vnd.wap.wml", "image/vnd.wap.wbmp", "*/*"], :locale=>[:"zh-TW", :"zh-TW"], :handlers=>[:rjs, :rxml, :rhtml, :builder, :erb]} in view paths
HTTP_ACCEPT "application/vnd.wap.wmlc, application/vnd.wap.wmlscriptc, text/vnd.wap.wml, image/vnd.wap.wbmp, */*"
HTTP_USER_AGENT "Mozilla/5.0 (Linux; U; Android 2.1-update1; zh-tw; MB525 Build/JRDNEM_U3_2.51.0) AppleWebKit/530.17 (KHTML, like Gecko) Version/4.0 Mobile Safari/530.17"
HTTP_VIA "(infoX WAP Gateway), HTTP/1.1, Huawei Technologies"
HTTP_X_UP_BEAR_TYPE "WCDMA"
  • 微軟的行動辦公室?
ActionView::MissingTemplate: Missing template pages/welcome with {:formats=>["text/*"], :handlers=>[:rjs, :rxml, :rhtml, :builder, :erb], :locale=>[:"zh-TW", :"zh-TW"]} in view paths
HTTP_ACCEPT "text/*"
HTTP_USER_AGENT "Microsoft Office Mobile /14.0"
  • 傳說中的魔王 IE6 / Windows XP:
ActionView::MissingTemplate: Missing template posts/show with {:formats=>["image/gif", "image/x-xbitmap", "image/jpeg", "image/pjpeg"], :locale=>[:"zh-TW", :"zh-TW"], :handlers=>[:rjs, :rxml, :rhtml, :builder, :erb]} in view paths
HTTP_ACCEPT "image/gif, image/x-xbitmap, image/jpeg, image/pjpeg"
HTTP_USER_AGENT "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)"
  • 完全沒有 HTTP_USER_AGENT 的資訊,似乎是 POST 動作後導向失敗造成的?
ActionView::MissingTemplate: Missing template digiphoto_posts/show with {:formats=>[:html, :text, :js, :css, :ics, :csv, :xml, :rss, :atom, :yaml, :multipart_form, :url_encoded_form, :json], :locale=>[:"zh-TW", :"zh-TW"], :handlers=>[:rjs, :rxml, :rhtml, :builder, :erb]} in view paths
HTTP_ACCEPT "*/*"
  • 同上,有正確的 format,但是呈現不同的 HTTP_ACCEPT 樣式(多筆 log 資料彙整):
ActionView::MissingTemplate: Missing template digiphoto_posts/show with {:formats=>[:html], :locale=>[:"zh-TW", :"zh-TW"], :handlers=>[:rjs, :rxml, :rhtml, :builder, :erb]} in view paths
HTTP_ACCEPT "application/x-ms-application, image/jpeg, application/xaml+xml, image/gif, image/pjpeg, application/x-ms-xbap, application/msword, application/vnd.ms-excel, application/x-shockwave-flash, */*"
HTTP_ACCEPT "application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5"
HTTP_ACCEPT "image/gif, image/jpeg, image/pjpeg, image/pjpeg, application/x-shockwave-flash, application/vnd.ms-excel, application/vnd.ms-powerpoint, application/msword, */*"
HTTP_ACCEPT "image/gif, image/jpeg, image/pjpeg, image/pjpeg, application/x-ms-application, application/x-ms-xbap, application/vnd.ms-xpsdocument, application/xaml+xml, application/vnd.ms-powerpoint, application/msword, application/x-shockwave-flash, application/vnd.ms-excel, */*"

看到這樣的現象,直覺反應是,事情絕對不是想像中的那麼簡單,必定有惡魔藏在細節裡。

仔細地整理一下,上述的錯誤,所要求的頁面 format 應該都要判斷為 :html 才是正確的,而其中共通的特性是 HTTP_ACCEPT 都不外乎是下列幾種:

"*/*", "*/*;q=0.01", "text/*", "application/youtube-client", "application/vnd.wap.wmlc"

並且似乎也跟 mobile 裝置有關,這就讓我想到有人抱怨 HTC 手機不能順利瀏覽 T 客邦網站這件事。於是乎抓了幾天的 log 來分析,居然有驚奇的發現

https://gist.github.com/9a396c56a73b57d5ad72

行動裝置的 HTTP_ACCEPT 長得都不太一樣,同樣是 Android 系統,卻沒幾個能正確送出 text/html,幾乎都是送 application/xml,太令人訝異了。

那 Rails 3 到底是怎麼利用 HTTP_ACCEPT 資訊,又怎麼判斷 format 的呢?

直接查原始碼可以發現 ActionDispatch::Http::MimeNegotiation 有個 formats 方法,當找不到 paramater[:format] 這個傳遞資訊時,會由 HTTP_ACCEPT 猜測一個適當的 format,最後才預設為 [Mime::HTML]。(Rails 3.0.7)

而這就是問題所在,猜測的邏輯沒有包含到這些狀況,上述那些怪異的 HTTP_ACCEPT,在 Rails 3.0.7 以前(含)並不能正確的處理,於是乎輕則看到這些錯誤 log,重則直接吐 502 給你。那可不妙,我可是要支援 mobile 裝置啊~

幸好,活躍的 Rails 圈有人提出了問題並獲得解答:

但解法目前只有 apply 在 rails master branch,我可是要解燃眉之急呀,於是自己搞了個 patch 來修補 Rails 3.0.7:

https://gist.github.com/33c10952fe3cadaec3c4

直接抓回來放在 config/initializers 後,重新啟動 application 就行了。

這個 patch 同時解決了兩件事:

  • 針對特殊的 HTTP_ACCEPT,如 “/”, “/;q=0.01”, “text/*” 都能正確判斷 format 是 [Mime::JS] 或 [Mime::HTML]。
  • 針對行動裝置送的 application/xxx 這類型 HTTP_ACCEPT 也能返回正確的 Mime::SET。

如此一來就能好好面對行動裝置的挑戰了。

Posted 1 year ago

Ruby 1.9.2 在 Rails 上的 Encoding 問題

如果在 Ruby 1.9.2 開發 Rails 程式,最頭痛的問題應該就是編碼(Encoding)錯誤的問題。除了要考慮資料庫本身的編碼問題,所有的字串 IO 都會經過編碼處理,Ruby 1.9.2 提供了廣域設定與實體方法設定的方式,但該在什麼時機設定呢?

基本上 Yahuda Kats 已經寫了篇蠻詳盡的說明(Ruby 1.9 Encodings: A Primer and Solution for Rails),我這裡所要提的重點是,如果你的資料庫(如 MySQL)設定是用 UTF-8 做為儲存編碼,而 Web Server 所傳送的文字編碼是 ASCII-8BIT 的話(可以從 server log 得知),可能會遇到

“\xE4” from ASCII-8BIT to UTF-8

這樣的錯誤訊息,基本上可以用 Ruby 1.9.2 的廣域編碼設定來解決問題,只要在 $RAILS_ROOT/config/environment.rb 的檔案開頭加上這幾行設定即可:

if defined? Encoding
Encoding.default_internal = 'UTF-8'
Encoding.default_external = 'ASCII-8BIT'
end

這樣就可以正常地從 Web Form 裡面輸入中文,並以 UTF-8 字串儲存到資料庫中。如果 Web Server 並不是傳送 ASCII-8BIT 的話,那就要將 default_external 改成適當的編碼。

PS. 我是在 Ruby-1.9.2-head + Rails-3.0.0.beta4 + mysql-2.8.1 gem 的環境下測試,但 Ruby-1.9.2-preview3 之後應該都是同樣的解法。

PS2. Yahuda Kats 的這篇 Encodings, Unabridged 將目前 Ruby 1.9 處理 Encoding 的來由述說的很清楚,值得仔細研究。

PS3. 感謝 ihower 提醒 rails ticket #4336 有另外用 before_filter 對 params 做 force_encoding 的解法,這樣就方便看 log 做 debug。

Posted 1 year ago

研究 Rails 3 的 Action::Dispatch

當開發 Rails 3 應用程式時,了解新的 call stack 設計對除錯和開發新功能的幫助會很大:

call stack of Rails 2 and 3

首先 Action::Dispatch 成為整個 call stack 的主角,Jeremy McAnally 歸納出幾個特點:

  • Request handling and parameter parsing
  • Sessions, Rails’ flash, and cookie storage
  • File uploads
  • Routing, URL matching, and rescuing errors
  • HTTP conditional GETs
  • Client response and HTTP status code

如果熟悉 Rails 3 的初始化程序,應該可以了解 rack server 在接收到一個 HTTP request 後,要怎麼誘發 Rails::Application 的 .call 實體方法:

Rails::Server.new.start
Rack::Handler::Mongrel.run(wrapped_app)
Mongrel::HttpServer.new.run
Mongrel::HttpServer.new.process_client
Rack::Handler::Mongrel.new.process
Rack::Chunked.new.call(env)
Rack::ContentLength.new.call(env)
Rails::Rack::LogTailer.new.call(env)
YourApp::Application.call(env)
Rails::Application.call(env)
Rails::Application.app.call(env)
Rails::Application.middleware.build(routes).call(env) # @app.call(env)

Rails 3 為了符合 Rack 的設計理念(能回應 call 方法的物件),於是將 routes 和 middlewares 設計成串接式(cascade)型態的物件,並在執行 initializers 時實體化成 rails app stack 物件。其中有三個 initializer 跟 Action::Dispatch 最相關:

## actionpack/lib/action_dispatch/railtie.rb
initializer "action_dispatch.prepare_dispatcher" do |app|
  require 'rails/dispatcher'
  ActionDispatch::Callbacks.to_prepare { app.routes_reloader.reload_if_changed }
end
## railties/lib/rails/engine.rb
initializer :add_routing_paths do |app|
  paths.config.routes.to_a.each do |route|
    app.routes_reloader.paths.unshift(route) if File.exists?(route)
  end
end
## railties/lib/rails/application/finisher.rb
initializer :build_middleware_stack do
  app
end

其中 action_dispatch.prepare_dispatcher 會設定一個 ActionDispatch 的 callback 內容。而 add_routing_paths 會用 Rails::Application.routes_reloader 方法實體化一個 Rails::Application::RoutesReloader 物件,並將所有 Rails::Engine 的 config/routes.rb 加入到 @path 陣列。當執行 build_middleware_stack 時,會實體化 ActionDispatch::Routing::RouteSet 物件,連同各式剛實體化好的 middleware 物件一起放入 ActionDispatch::MiddlewareStack 陣列物件中,最後轉換成串接式型態的物件:

## railties/lib/rails/application.rb
class Rails::Application
  def app
    @app ||= middleware.build(routes)
  end
  def routes
    @routes ||= ActionDispatch::Routing::RouteSet.new
  end
end
## railties/lib/rails/engine.rb
class Rails::Engine
  delegate :middleware, :paths, :root, :to => :config
end
## railties/lib/rails/application/configuration.rb
class Rails::Application::Configuration
  def middleware
    @middleware ||= default_middleware_stack
  end
  def default_middleware_stack
    ActionDispatch::MiddlewareStack.new.tap do |middleware|
      ......
      middleware.use('::ActionDispatch::Callbacks', lambda { !cache_classes })
      ......
    end
  end
end
## actionpack/lib/action_dispatch/middleware/stack.rb
class ActionDispatch::MiddlewareStack
  def build(app = nil, &blk)
    app ||= blk
    raise "MiddlewareStack#build requires an app" unless app
    active.reverse.inject(app) { |a, e| e.build(a) }
  end
  def use(*args, &block)
    middleware = Middleware.new(*args, &block)
    push(middleware)
  end
end

仔細地看 Rails::Application.app 物件真正的樣貌,就是把一個擁有 @app 類別變數的 middleware 物件當成另一個擁有 @app 類別變數的 middleware 物件的初始化參數,一個個串接下去形成一個大型的 rack app stack 物件。這 rack app stack 物件也會被當成參數成為 rack server stack 的串接式物件,以便呼應 rack server 傳來的 call 方法:

#> s1 = ::ActiveRecord::QueryCache.new(@routes)
#> s9 = ::ActionDispatch::Callbacks.new(s8, !cache_classes)
#> ……
#> @app = ::ActionDispatch::Static.new(s13, Rails.public_path)
#> Rails::Application.app.call(env) = @app.call(env)
@app = #<ActionDispatch::Static:0x2ff9fa4
  @app=#<Rack::Lock:0x2ffa1ac
  @app=#<Rack::Runtime:0x2ffa2b0
  @app=#<Rails::Rack::Logger:0x2ffaa1c
  @app=#<ActionDispatch::ShowExceptions:0x2ffaabc
  @app=#<ActionDispatch::Callbacks:0x2ffac60
  @app=#<ActionDispatch::Cookies:0x2ffae40
  @app=#<ActiveRecord::SessionStore:0x2ffaf1c
  @app=#<ActionDispatch::Flash:0x2ffb0ac
  @app=#<ActionDispatch::ParamsParser:0x2ffb50c
  @app=#<Rack::MethodOverride:0x2ffb64c
  @app=#<ActionDispatch::Head:0x2ffb944
  @app=#<ActiveRecord::ConnectionAdapters::ConnectionManagement:0x300e2d8 
  @app=#<ActiveRecord::QueryCache:0x300e33c
  @app=#<ActionDispatch::Routing::RouteSet:0x151c588»»»»»»»>

這裡要特別注意 ActionDispatch::Callbacks 這個 middleware 物件,當它初始化時會執行名為 :prepare 的 callback(先前的 initializer 已設定好內容)。這個 callback 執行時會用 Rails::Application.routes_reloader 方法來實體化一個 Rails::Application::RoutesReloader 物件,並動用 reload! 方法。這時會得到 ActionDispatch::Routing::RouteSet 實體物件,接著利用 clear! 方法取得一個新的 Rack::Mount::RouteSet 實體物件 @set,依序載入所有 Rails::Engine 的 config/routes.rb,最後執行 finalizer! 方法,將 @set 凍結住避免變動:

## railties/lib/rails/application.rb
class Rails::Application
  def routes_reloader
    @routes_reloader ||= RoutesReloader.new
  end
end
## actionpack/lib/action_dispatch/middleware/callback.rb
class ActionDispatch::Callbacks
  define_callbacks :prepare, :scope => :name
  def self.to_prepare(*args, &block)
    if args.first.is_a?(Symbol) && block_given?
      define_method :"__#{args.first}", &block
      set_callback(:prepare, :"__#{args.first}")
    else
      set_callback(:prepare, *args, &block)
    end
  end
  def initialize(app, prepare_each_request = false)
    @app, @prepare_each_request = app, prepare_each_request
    run_callbacks(:prepare)
  end
end
## railties/lib/rails/application/routes_reloader.rb
class Rails::Application::RoutesReloader
  def reload!
    routes = Rails::Application.routes
    routes.disable_clear_and_finalize = true
    routes.clear!
    paths.each { |path| load(path) }
    ActiveSupport.on_load(:action_controller) { routes.finalize! }
    nil
  ensure
    routes.disable_clear_and_finalize = false
  end
end
## actionpack/lib/action_dispatch/routing/route_set.rb
class ActionDispatch::Routing::RouteSet
  def clear!
    # Clear the controller cache so we may discover new ones
    @controller_constraints = nil
    @finalized = false
    routes.clear
    named_routes.clear
    @set = ::Rack::Mount::RouteSet.new(:parameters_key => PARAMETERS_KEY)
  end
  def finalize!
    return if @finalized
    @finalized = true
    @set.add_route(NotFound)
    @set.freeze
  end
end

當 routes_reloader 載入 config/routes.rb 時,會誘發 Rails::Application.routes 執行 draw 實體方法。這個 draw 方法會把 routes 自己(ActionDispatch::Routing::RouteSet 實體物件)當成參數實體化一個 ActionDispatch::Routing::Mapper 物件 mapper,並成為 mapper 的 @set 變數,而 mapper 會負責解譯 block 中定義的 Rails routes mapping DSL 內容。

例如當定義一個 match(‘/’, :to => “your_home#index”) 時,首先會把傳入 match 方法的參數轉換成 ActionDispatch::Routing::Mapper::Mapping 實體物件,再轉換成參數陣列 mapping。接著 mapper 的 @set 變數(routes)會以 add_route 方法把 mapping 參數實體化成 ActionDispatch::Routing::Route 物件 route。由於 routes 本身也有一個 @set 變數是個 Rack::Mount::RouteSet 實體物件,同樣會以 add_route 方法將 route 加入這個 @set 變數,以便串接 rack mount 的 router 辨識功能:

## actionpack/lib/action_dispatch/routing/route_set.rb
class ActionDispatch::Routing::RouteSet
  def draw(&block)
    clear! unless @disable_clear_and_finalize
    mapper = Mapper.new(self)
    if block.arity == 1
      mapper.instance_exec(DeprecatedMapper.new(self), &block)
    else
      mapper.instance_exec(&block)
    end
    finalize! unless @disable_clear_and_finalize
    nil
  end
  # @set = ::Rack::Mount::RouteSet.new
  def add_route(app, conditions = {}, requirements = {}, defaults = {}, name = nil, anchor = true)
    route = Route.new(app, conditions, requirements, defaults, name, anchor)
    @set.add_route(*route)
    named_routes[name] = route if name
    routes << route
    route
  end
end
class ActionDispatch::Routing::Dispatcher
  def initialize(options={})
    @defaults = options[:defaults]
    @glob_param = options.delete(:glob)
  end
end
## actionpack/lib/action_dispatch/routing/mapper.rb
class ActionDispatch::Routing::Mapper
  def initialize(set)
    @set = set
  end
  # @set = ::ActionDispatch::Routing::RouteSet.new
  def match(*args)
    mapping = Mapping.new(@set, @scope, args).to_route
    @set.add_route(*mapping)
    self
  end
end
class ActionDispatch::Routing::Mapper::Mapping
  def to_route
    [ app, conditions, requirements, defaults, @options[:as], @options[:anchor] ]
  end
  def app
    Constraints.new(
      to.respond_to?(:call) ? to : Routing::RouteSet::Dispatcher.new(:defaults => defaults),
      blocks
    )
  end
end

產生 mapping 陣列時會實體化一個 Routing::RouteSet::Dispatcher 物件 app,負責拆解 params,執行特定 controller 的 action 內容,屬於仲介 route 到 controller 的主要 app stack 物件。這時 initializer 才算完成整個 rails app stack 的準備工作。

當一個 HTTP request 從 rack server stack 傳遞到 rails app stack 後,會先經過 middlewares 一層層地處理,直到 ActionDispatch::Routing::RouteSet 實體物件傳遞給 Rack::Mount::RouteSet 實體物件,才會辨識 http request 該由哪個 ActionDispatch::Routing::Route 實體物件來執行,並經由 ActionDispatch::Routing::RouteSet::Dispatcher 實體物件誘發特定 controller 的 action 內容:

Rails::Application.middleware.build(routes).call(env) # @app.call(env)
ActionDispatch::Static.new.call(env) # @app.call(env)
…… # cascade @app.call(env) of middlewares
ActionDispatch::Routing::RouteSet.new.call(env) # @app.call(env)
Rack::Mount::RouteSet.new.call(env) # @set.call(env)
ActionDispatch::Routing::Route.new.call(env) # route.app.call(env)
ActionDispatch::Routing::RouteSet::Dispatcher.new.call(env) # @app.call
YourHomeController.action(params[:action]).call(env)
YourHomeController.middleware_stack.build(default_stack_proc).call(env)
#> default_stack_proc = Proc.new { new.dispatch(name, klass.new(env)) }
YourHomeController.new.dispatch(params[:action], request)
#> request = ActionDispatch::Request.new(env)
YourHomeController.new.send(params[:action])
YourHomeController.new.to_a

從程式碼中可以發覺 ActionDispatch::Routing::RouteSet::Dispatcher 實體物件利用 controller.action(params[:action]) 方法來取得任一個 controller 的 action dispatch stack 串接式物件(ActionDispatch::MiddlewareStack 物件),這個 stack 預設是一個 Proc 物件,能夠執行 controller 的某個 action:

## railties/lib/rails/application.rb
class Rails::Application
  def call(env)
    env["action_dispatch.parameter_filter"] = config.filter_parameters
    app.call(env)
  end
end
## actionpack/lib/action_dispatch/routing/route_set.rb
class ActionDispatch::Routing::RouteSet
  def call(env)
    finalize!
    @set.call(env)
  end
end
## rack-mount/lib/rack/mount/recognition/route_set.rb
class Rack::Mount::RouteSet
  def call(env)
    raise 'route set not finalized' unless @recognition_graph
    env[PATH_INFO] = Utils.normalize_path(env[PATH_INFO])
    request = nil
    req = @request_class.new(env)
    recognize(req) do |route, matches, params|
      # TODO: We only want to unescape params from uri related methods
      params.each { |k, v| params[k] = Utils.unescape_uri(v) if v.is_a?(String) }
      if route.prefix?
        env[Prefix::KEY] = matches[:path_info].to_s
      end
      env[@parameters_key] = params
      result = route.app.call(env)
      return result unless result[1][X_CASCADE] == PASS
    end
    request || [404, {'Content-Type' => 'text/html', 'X-Cascade' => 'pass'}, ['Not Found']]
  end
end
## actionpack/lib/action_dispatch/routing/route_set.rb
class ActionDispatch::Routing::RouteSet::Dispatcher
  def call(env)
    params = env[PARAMETERS_KEY]
    prepare_params!(params)
    # Just raise undefined constant errors if a controller was specified as default.
    unless controller = controller(params, @defaults.key?(:controller))
      return [404, {'X-Cascade' => 'pass'}, []]
    end
    controller.action(params[:action]).call(env)
  end
end
## actionpack/action_controller/metal.rb
class ActionController::Metal
  class_attribute :middleware_stack
  self.middleware_stack = ActionDispatch::MiddlewareStack.new
  def self.inherited(base)
    self.middleware_stack = base.middleware_stack.dup
    super
  end
  def self.action(name, klass = ActionDispatch::Request)
    middleware_stack.build do |env|
      new.dispatch(name, klass.new(env))
    end
  end
  def dispatch(name, request)
    @_request = request
    @_env = request.env
    @_env['action_controller.instance'] = self
    process(name)
    to_a
  end
  def to_a
    response ? response.to_a : [status, headers, response_body]
  end
end
## actionpack/abstract_controller/base.rb
class AbstractController::Base
  def process(action, *args)
    @_action_name = action_name = action.to_s
    unless action_name = method_for_action(action_name)
      raise ActionNotFound, "The action '#{action}' could not be found"
    end
    @_response_body = nil
    process_action(action_name, *args)
  end
  def process_action(method_name, *args)
    send_action(method_name, *args)
  end
  alias send_action send
end

由於 controller 的 action 不會再傳遞 HTTP request 到其他物件,而是直接回傳標準的 rack response 陣列 [status, headers, response_body],成了所謂的 rack endpoint。最後再由 rack server 把 HTTP response 送回到使用者個瀏覽器中解讀。

以上就是 Action::Dispatch 整個 call stack 大略的樣貌。如果想要在這中間做些處理,則是安插個 middleware 進去,如 José Valim 就寫了個 devise 提供 rack 風格的 authentication 功能。

Posted 1 year ago

研究 Rails 3 的 boot process 和 initialization process

徹底了解 Rails 3 的 boot/initialization 流程對於控制 Rails 應用程式是很重要的一個課題,當我們下了 rails server 命令,究竟發生了什麼事,BundlerRack 又扮演了什麼角色,讓我們來瞧個仔細。

說明:環境變數 GEM_PATH 是 rubygems 的安裝路徑,ROOT_PATH 是應用程式根目錄,而本篇文章使用 RAILTIES_PATH 代表 railties 這個 gem 的根目錄,依此類推。引述段落則是依序指出重點檔案或指令。

在 Rails 3 應用程式根目錄下,執行 rails server 指令,第一步便是透過 boot.rb 誘發 bundler 將所有相關 rubygems 的 lib 都加到 $LOAD_PATH:

GEM_PATH/bin/rails server
RAILTIES_PATH/bin/rails
ROOT_PATH/script/rails
#> also define ENV_PATH, BOOT_PATH, APP_PATH
ROOT_PATH/config/boot.rb
#> add required gems’s lib into $LOAD_PATH

bundler 預設用 Definition.from_gemfile 解讀 ROOT_PATH/Gemfile,再以 Bundler.setup 方法呼叫 Bundle::Runtime 設定 rubygems;或是先執行 bundle lock 產生 ROOT_PATH/.bundle/environment 檔案(由 Definition.from_lock 解讀 ROOT_PATH/Gemfile.lock,再由 Bundle::Runtime 生成),當被 require 時會執行新的 Bundler.setup 方法設定 rubygems。

而 ROOT_PATH/script/rails 最後所 require 的 commands.rb 便會依序載入 rails.rb,設定基礎 Rails 環境,加載 rails/application.rb 及各式 Railtie,掛載透過 bundler 設定的 rubygems,啟動 Rails::Server 通知 Rack::Server 跑 Rails::Application:

RAILTIES_PATH/lib/rails/commands.rb
RAILTIES_PATH/lib/rails/commands/server.rb
rails_server = Rails::Server.new #=> also set ENV[“RAILS_ENV”]
ROOT_PATH/config/application.rb
RAILTIES_PATH/lib/rails/all.rb
RAILTIES_PATH/lib/rails.rb
ACTIVESUPPORT_PATH/lib/active_support.rb
……
RAILTIES_PATH/lib/rails/application.rb
……
Bundler.require :default, Rails.env
……
rails_server.start

這時 Rack::Server.start 會呼叫 .app 實體方法,先讓 Rack::Builder 以 #parse_file 類別方法解讀 config.ru,產生 Rakc::Builder 實體物件。此 Rack::Builder 物件會載入 environment.rb 來初始化 YourApp::Application,用 .run 實體方法把 YourApp::Application 這個龐大的 Rack application可回應 call 方法傳入 env 參數並回傳 [status, header, body] 的 Ruby 實體物件/Proc 物件)也加入 Rack::Builder@ins 陣列,並回傳成 Rack::Server@app 陣列。最後 Rack::Server 透過 .wrapped_app 實體方法把 Rails::Server 的 middlewares 包裹成 @app 陣列,並實體化為 rack application,一起丟給 Rack::Handler 處理:

rails_server.start
……
wrapped_app = rails_server.build_app(@app)
#> @app = Rack::Builder.parse_file(‘config.ru’) = YourApp::Application
##> ROOT_PATH/config.ru
##> ROOT_PATH/config/environment.rb
##> YourApp::Application.initialize!
##> run YourApp::Application
#> rails_server.middleware[‘development’]
rack_app = wrapped_app = #<Rails::Rack::LogTailer:0x2f92228 @app=YourApp::Application, @cursor=612423, @file=#<File:log/development.log>, @last_checked=1270047942.86726>
……
rails_server.server.run(wrapped_app)
#> Rack::Server.new.server.run(wrapped_app)
#> Rack::Handler.default(options).run(wrapped_app)
rails_server = #<Rails::Server:0x2dafa14 @app=YourApp::Application, @_server=Rack::Handler::Mongrel, @options={:server=>nil, :AccessLog=>[], :pid=>”tmp/pids/server.pid”, :Host=>”0.0.0.0”, :debugger=>false, :daemonize=>false, :environment=>”development”, :config=>”config.ru”, :Port=>3000}>
……
Rack::Handler::Mongrel.run(rack_app)
#> http_server = Mongrel::HttpServer.new
#> handler = Rack::Handler::Mongrel.new(rack_app)
#> handler = Rack::Chunked.new(Rack::ContentLength.new(rack_app))
#> http_server.register(‘/’, handler)
……
Rack::Handler::Mongrel.process

如果跑 mongrel server 則 Rack::Handler.default 會以 Rack::Handler::Mongrel.run 方法跑一個 Mongrel::HttpServer 實體物件,並把每個 rack application(獨立的 Rack::Handler::Mongrel 實體物件)都註冊起來。

當 mongrel server 收到 http request 後,會送給 Rack::Handler::Mongrel 的 .process 實體方法,呼叫 rack application 的 .call 實體方法送出 http response。因為 Rails 3 每個 controller action、metal 都被設計成 rack application,所以創造出無限可能。

另外要提的是 Phusion Passenger 2.2.9 之後也採用類似的方法跑 Rails 3 application(主要是 require 的順序不一樣):

PhusionPassenger::Railz::ApplicationSpawner#preload_application
ROOT_PATH/config/environment.rb
ROOT_PATH/config/application.rb
ROOT_PATH/config/boot.rb
….
YourApp::Application.initialize!
run YourApp::Application

以上是 rails boot process 的概要,接著來看 rails initialization process 的部份。當 Rack Server 在解讀 config.ru 時,會產生一個這樣的 rack application 物件:

rack_app = Rack::Builder.new {
  require ::File.expand_path('../config/environment',  __FILE__)
  run YourApp::Application
}.to_app

而 Rails::Application 初始化等於是展開 config.ru 這個 block 內容,最終會產生一個描述完整的 Rack::Builder 實體物件:

rack_app = Rack::Builder.new {
  use ActionDispatch::Static
  use Rack::Lock
  use Rack::Runtime
  use Rails::Rack::Logger
  use ActionDispatch::ShowExceptions
  use ActionDispatch::RemoteIp
  use Rack::Sendfile
  use ActionDispatch::Callbacks
  use ActionDispatch::Cookies
  use ActionDispatch::Session::CookieStore
  use ActionDispatch::Flash
  use ActionDispatch::ParamsParser
  use Rack::MethodOverride
  use ActionDispatch::Head
  use ActiveRecord::ConnectionAdapters::ConnectionManagement
  use ActiveRecord::QueryCache
  run YourApp::Application.routes
}.to_app

一切都從類別宣告開始。從 ROOT_PATH/config/environment.rb 把 ROOT_PATH/config/application.rb 給 require 後的宣告開始:

YourApp::Application < Rails::Application < Rails::Engine < Rails::Railtie

這 YourApp::Application 的繼承宣告主要做了幾件事,定義 called_from 變數,引入 Rails::Application::Configurable 模組,定義 #config 類別方法用來建立 Rails::Application::Configuration 實體物件,排 subclasses 順序,把自己定義成 Singleton,最後把自己實體化:

## snippet of Rails::Application
def inherited(base)
  raise "You cannot have more than one Rails::Application" if Rails.application
  super
  Rails.application = base.instance
end

## snippet of Rails::Engine
def inherited(base)
  unless base.abstract_railtie?
    base.called_from = begin
      # Remove the line number from backtraces making sure we don't leave anything behind
      call_stack = caller.map { |p| p.split(':')[0..-2].join(':') }
      File.dirname(call_stack.detect { |p| p !~ %r[railties[\w\-\.]*/lib/rails|rack[\w\-\.]*/lib/rack] })
    end
  end
  super
end

## snippet of Rails::Railtie
def inherited(base)
  unless base.abstract_railtie?
    base.send(:include, self::Configurable)
    subclasses << base
  end
end

每種 Railtie(Application, Engine, Plugin 都是一種 Railtie 型態)的 #config 類別方法都是在管理 Rails::Railtie::Configuration(子)物件。如呼叫 Rails::Application.config 時,會以 called_from 路徑中第一個有 config.ru 的路徑做為 root 參數建立 Rails::Application::Configuration 實體物件。

## snippet of Rails::Application::Configurable
def config
  @config ||= Application::Configuration.new(self.class.find_root_with_flag("config.ru", Dir.pwd))
end

## snippet of Rails::Application::Configuration
def initialize(*)
  super
  @allow_concurrency   = false
  @filter_parameters   = []
  @dependency_loading  = true
  @serve_static_assets = true
  @time_zone           = "UTC"
  @consider_all_requests_local = true
  @session_store = :cookie_store
  @session_options = {}
end

## snippet of Rails::Engine::Configuration
def initialize(root=nil)
  super
  @root = root
end

## snippet of Rails::Railtie::Configuration
def initialize
  @@options ||= {}
end

基本上各 Railtie 利用 #initializer 類別方法定義了許多初始化時要執行的 initializer block,當 YourApp::Application.initialize! 執行時,會透過 #method_missing 類別方法得到 YourApp::Application 實體物件,透過 railties.all 實體方法取得所有 Railtie 實體物件,接著取得各 initializers 實體物件,再依序執行這些 initializer block,完成整個應用程式初始化動作:

## snippet of Rails::Application
def self.method_missing(*args, &block)
  instance.send(*args, &block)
end

def initialize!
  run_initializers(self)
  self
end

def initializers
  initializers = Bootstrap.initializers_for(self)
  railties.all { |r| initializers += r.initializers }
  initializers += super
  initializers += Finisher.initializers_for(self)
  initializers
end

## Rails::Initializable
def run_initializers(*args)
  return if instance_variable_defined?(:@ran)
  initializers.each do |initializer|
    initializer.run(*args)
  end
  @ran = true
end

def self.initializer(name, opts = {}, &blk)
  raise ArgumentError, "A block must be passed when defining an initializer" unless blk
  opts[:after] ||= initializers.last.name unless initializers.empty? || initializers.find { |i| i.name == opts[:before] }
  initializers << Initializer.new(name, nil, opts, &blk)
end

而 initializers 實體物件群組執行順序是 Rails::Application::Bootstrap -> Rails::Railtie.subclasses -> Rails::Engine.subclasses -> Rails::Plugin.all -> Rails::Engine -> Rails::Application::Finisher。雖然 initializer 執行順序預設是依照宣告順序,也可用 :before, :after 參數排序,但 Railtie、Engine、Plugin 各群組內依名稱字母排序的特性,實際撰寫 Engine 或 Plugin 時還是要多留心一下:

Rails::Application::Bootstrap
ActiveSupport::Railtie
I18n::Railtie
ActionDispatch::Railtie
ActionView::Railtie
ActionController::Railtie
ActiveRecord::Railtie
ActionMailer::Railtie
ActiveResource::Railtie
Rails::TestUnitRailtie
AllKindsOfEnginesOrderedByAlphabet (Rails::Engine)
AllKindsOfPluginsOrderedByAlphabet (Rails::Plugin)
YourApp::Application (Rails::Engine)
Rails::Application::Finisher

其中 Rails::Engine 有個名為 :add_routing_paths 的 initializer 會處理各 engine 的 routes.rb 載入;另 Rails::Application::Finisher 中有個名為 :build_middleware_stack 的 initializer 會呼叫 Rails::Application 的 .app 實體方法將所有 default_middleware、Railtie 的 middlewares 或自定的 middlewares 都丟到一個 ActionDispatch::MiddlewareStack 陣列中,並建立這些 middlewares 實體物件,這都是值得留意的基本 initializer。當所有 initializers 都執行完畢,一個 Rails 3 的 rack application(@app)便建立完成,達到初始化的最終目的:

## Rails::Application
def app
  @app ||= middleware.build(routes)
end

def call(env)
  env["action_dispatch.parameter_filter"] = config.filter_parameters
  app.call(env)
end

接著就是搬椅子等待 Rack server 呼叫 Rails::Application 的 .call 實體方法囉。

更新:內文已更新為 rails-3.0.0.beta2 的版本為主。

Posted 1 year ago

研究 Rails 3 的 Rails::Engine 和 Rails::Railtie

當我們在撰寫 Rails 3 的 Plugin/Engine 時,會被要求養成好的習慣,如你要覆寫 ActiveRecord 等底層模組/類別時,請先 require 它和你的覆寫模組(注意順序),再把覆寫模組給 include 進去:

## in lib/my_gem.rb
require "active_record"
require "my_gem/active_record_sti_fix" 
ActiveRecord::Base.class_eval { include MyGem::ActiveRecordStiFix }

對於常寫 Rails plugin 的人,會想直接更動 Rails 3 原本的 Life Cycle 來滿足需求,這時只要引入一個 Rails::Railtie 子類別便可變更啟動程序,這是因為 ActionMailer, ActionController, ActionDispatch, ActionView, ActiveModel, ActiveRecord, ActiveResourceActiveSupport 等都擁有一個繼承 Rails::Railtie 的類別,Rails 3 啟動程序主要工作就是正確誘發這些 railtie(請注意 require 的順序):

## in lib/my_gem.rb
module MyGem
  # Make sure your Gem loads the railtie.rb file if Rails is loaded first.
  require 'my_gem/railtie' if defined?(Rails::Railtie)
end

## in lib/my_gem/railtie.rb
require 'my_gem'
require 'rails'

module MyGem
  class Railtie < ::Rails::Railtie
    # This creates a config.my_gem in the user's Application
config.my_gem = MyGem # configuration shared by all railties and the application config.generators.test_framework :rspec config.middlewares.use MyGem::MyMiddleware config.to_prepare do MyGem.config_setup! end # rails 3 notification system require "my_gem/railties/log_subscriber" log_subscriber MyGem::Railties::LogSubscriber.new rake_tasks do load "my_gem/setup.rake" end generators do require "../../generators/another_generator.rb" end initializer :setup_my_gem, :before => :load_environment_config do |app| app.config.locale = 'zh-TW' app.config.time_zone = 'taipei' end end end

關於以上程式內容,設定 config.my_gem 作為這個 rubygem 特有的設定值辨識名,config.* 可以設定所有 railtie 都讀得到的設定值,可定義 Rails::LogSubscriber 來處理 log 記錄,可用 rake_tasks 方法載入 rake tasks,用 generators 方法指定 generators 的位置,以及用 initializer 方法定義一些常量、變數等等(可以不需要在 config/initializers 下建設定檔),最後這些方法都會在執行 YourApp::Application < Rails::Application 時被誘發。

而平常寫的 Rails 3 Engine(小型嵌入式 rails 應用程式,會自動載入 app, config, lib 等目錄)分為兩種。一種是 Engines in gems,需要額外載入 Rails::Engine 類別(在 Rails.root/config/environment.rb 一開始就 require 進來或在 Gemfile 定義 gem ‘my_engine’),才會把這個 rubygem 啟動成為 rails engine,這是因為 Rails::Engine 本身也是 Rails::Railtie 的子類別(多了預設的 initializers 和特定的 methods),因此擁有類似的載入和設定方式:

## lib/my_engine.rb
module MyEngine
  # Make sure your Gem loads the railtie.rb file if Rails is loaded first.
  require 'my_engine/engine' if defined?(Rails::Railtie)
end

## lib/my_engine/engine.rb
require 'my_engine'
require 'rails'

module MyEngine
  class Engine < ::Rails::Engine
    config.my_engine = MyEngine

    # Add a load path for this specific Engine
    config.load_paths << File.expand_path("../../a_path", __FILE__)
    config.eager_load_paths << File.expand_path("../../b_path", __FILE__)
    config.load_once_paths << File.expand_path("../../c_path", __FILE__)

    # Add/specify the load path for this specific Engine
    paths.app.controllers << "lib/controllers"
    paths.app.metals = "lib/metal"
  end
end

另一種是 Engines in plugins,不需額外載入 Rails::Plugin 類別(這是 Rails::Engine 的子類別),必須依循預設規則,只要把 engine 程式放在 vender/plugins 下,Rails 3 就自動幫你處裡好,如同以往也會自動載入 PLUGIN_ROOT/init.rb,唯一要注意的是 Rails::Plugin 是最終類別,且同一個 engine 中不能同時載入另一個 Rails::Engine。

最後來看 Rails::Application,它本身就是個巨大的 Rails::Engine,以 Singleton 方式存在,主要工作是協調各 Railtie 的啟動程序,如 initialization, configuration, routes, middleware 和 metal(定義在 Rails::Application 目錄下 ),讓彼此和睦相處。

註:以上主要編修自 josevalim 的 gist 文件,並以 rails-3.0.0.beta2 這個版本為主,詳細的 method 用法請參考 Rails 原始碼

Posted 1 year ago

實用的 Rails Module 方法

研究 Rails 3 的時候看到 The Rails Module (in Rails 3) 這篇文章覺得很有趣,於是利用 looksee 把所有的 Rails module public method 都翻出來:

[Rails]
  application        cache          initialize!   logger       public_path=
  application=       configuration  initialized=  logger=      root        
  backtrace_cleaner  env            initialized?  public_path  version

雖然大部分在 Rails 2.3.5 都已經支援,也不難查到,但還是得看過 source code,了解的夠深入,才能運用的恰到好處:

Rails.root.join("config", "database.yml")
Rails.env.development?
Rails.application.routes.recognize_path("rails/info/properties")

純粹做個記錄。

Posted 1 year ago

jquery_corpus 幫你的 rails 3 application 自動安裝 jquery plugins

Rails 3 提供開發人員可改用 jquery-ujs,但並沒有將方法整合的很完整、方便,只好自己利用 generator 來管理 jquery 的 plugin,整理一下就變成 jquery_corpus 了。使用例子如下:

rails generate jquery:ui 1.8rc3
rails generate jquery:colorbox

由於機制上是透過 generator 來處理 jquery plugin 的 fetch、unzip、file copy、file create、file delete 等工作,因此使用者可以看到每個步驟的 log,並決定版本衝突時是否更動,增加了使用上的彈性。

因為一個 generator 對應一個 jquery plugin,必要時開發者只需要寫個 generator 和 spec 便可將新的 jquery plugin 納入管理,而目前 generator 只寫了上面兩個,有待日後的努力慢慢補足。

Posted 1 year ago

撰寫與發表 rubygem — 以 Rails 3/Thor 的 generator 為例

開發 Rails 應用程式時,常會使用 generate scaffold 的指令來建立基本的 MVC 架構,安裝 rubygem 時也會有 generate spec 這類的指令要執行,這些都是利用 generator 來達到設定上的自動化,Rails 3 改寫了這部份的實作方法,有必要重新熟悉一下。

基本上 Rails 3 的 generator 是繼承自 thorThor::Group 物件,並整合了 Rails 2.3 原本 generator 和 template 的方法成為 Rails::Generators::Actions。實作方面,我們只需要在 lib/generators 下建立一個 _generator.rb 檔,宣告一個繼承 Rails::Generators::Base(或 Rails::Generators::NamedBase、Thor::Group)的 Class,並定義要執行的 tasks (public instance method) 就完成了。

使用 bundler 管理 Rails 應用程式所需的 rubygem 非常好用,而用 jeweler 產生 rubygem 也十分簡單,以下便介紹如何撰寫一個 generator 來抓取 jquery-ujs 及其使用方法,並說明打包 rubygem 的流程。

首先安裝 jeweler 並建立一個新的 rubygem,命名為 generator_demo,採用 rspec 來做為 test framework,預計打包到 rubygems.org:

gem install jeweler
jeweler —rspec —gemcutter generator_demo
cd generator_demo
git log

jeweler 會用它的 generator 幫你建立 generator_demo 這個目錄及相關檔案,並自動使用 git 做版本控管。接著我們需要一個名為 jquery_ujs_generator.rb 的檔案。Rails 3 本身提供建立 generator template 的 generate 指令,使用 rails generate generator jquery_ujs 會自動在 Rails 3 應用程式的 lib 下建立 generator 所需的檔案。但這裡我們必須手動建立:

mkdir -p lib/generators/jquery_ujs
mkdir -p lib/generators/jquery_ujs/templates
touch lib/generators/jquery_ujs/jquery_ujs_generator.rb
touch lib/generators/jquery_ujs/USAGE

編輯 jquery_ujs_generator.rb 填入以下內容:

require 'rails/generators/base'

class JqueryUjsGenerator < Rails::Generators::Base
  def self.source_root
    @source_root ||= File.expand_path('../templates', __FILE__)
  end

  def fetch_rails_jquery_ujs_file
    url = 'http://github.com/rails/jquery-ujs/raw/master/src/rails.js'
    get url, 'public/javascripts/rails.js'
  end
end

這裡我們定義一個 JqeuryUjsGenerator 類別,每個 instance method 將會依序直接被 invoke,而 method 的命名建議使用直述式的(類似 rpsec 的 it 敘述的命名)。其中 fetch_rails_jquery_ujs_file 使用了 thor 的 get 方法取得遠端的檔案,並存放於 public/javascript 底下。而 source_root 是定義本地端範本檔案所在的位置,因為我們直接從網路抓取檔案,所以用不到。這樣最簡單的 generator 便完成,其他常用的 method 請查閱 thor 的 rdoc 說明。

一般建議要養成撰寫測試的習慣,先將 spec/generator_demo_spec.rb 的 fail 敘述刪掉,再建立 generator 對應的 spec 檔案:

mkdir -p spec/generators
touch spec/generators/jquery_ujs_generator_spec.rb

編輯 jquery_ujs_generator_spec.rb,填入以下內容:

require 'spec_helper'
require 'generators/jquery_ujs/jquery_ujs_generator'

describe JqueryUjsGenerator do
  before do
    @generator = JqueryUjsGenerator.new([], {}, {})
  end

  it "fetch rails jquery-ujs file" do
    url = 'http://github.com/rails/jquery-ujs/raw/master/src/rails.js'
    @generator.should_receive(:get).with(url, 'public/javascripts/rails.js').and_return(true)
    @generator.fetch_rails_jquery_ujs_file
  end
end

如果使用 rspec 1.x 的話,直接在 spec/spec_helper.rb 加入:

require 'rails/generators'

便可檢測 spec 是否通過:

spec spec

但這裡打算直接使用 rspec 2,請先安裝好:

gem install rspec -v=2.0.0.beta.3

需要修改 spec/spec_helper.rb 成為:

$LOAD_PATH.unshift(File.dirname(__FILE__))
$LOAD_PATH.unshift(File.expand_path('../../lib', __FILE__))
require 'rubygems'
require 'rspec'
require 'rspec/autorun'

require 'rails/generators'
require 'generator_demo'

Rspec.configure do |config|
  config.mock_framework = :rspec
end

改用新的指令進行檢測:

rspec spec

最後準備打包。由於 jeweler 提供不少相關 rake task 可配合使用,包含自動產生 generator_demo.gemspec 的指令,這個 .gemspec 檔案是用來定義將程式打包成 rubygem 所需的 Gem::Specification,透過撰寫 jeweler task 的方式來簡化流程,因此只要將相關資訊填入 Rakefile 即可:

begin
  require 'jeweler'
  Jeweler::Tasks.new do |gem|
    gem.name = "generator_demo"
    gem.summary = %Q{A rails generator demo}
    gem.description = %Q{tell you creating generator is quit simple}
    gem.email = "tsechingho@gmail.com"
    gem.homepage = "http://github.com/tsechingho/generator_demo"
    gem.authors = ["Tse-Ching Ho"]
    gem.add_development_dependency "rspec", ">= 2.0.0.beta.3"
  end
  Jeweler::GemcutterTasks.new
rescue LoadError
  puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
end

begin
  require 'rspec/core/rake_task'
  Rspec::Core::RakeTask.new(:spec)
  Rspec::Core::RakeTask.new(:rcov) do |spec|
    spec.rcov = true
  end
  task :spec => :check_dependencies
rescue LoadError
  task :spec do
    abort "Rspec 2 is not available. In order to run specs, you must: [sudo] gem install Rspec -v=2.0.0.beta.3"
  end
end

第一段是建立 rubygem 的 jeweler task 設定,第二段則是替換 jeweler 原本的 spec task 改使用 rspec 2 的 spec task 寫法。接著便可在 local 端將這個 rubygem 裝起來:

rake -T
rake spec
rake version:write
rake gemspec
(rake build)
rake install
git add .
git commit -m ‘release 0.0.0; add jquery-ujs generator.’

先跑完 spec 後,建立 jeweler 需要的 VERSION 檔案來描述 rubygem 的版號,再產生 gemspec,安裝後可用 gem search generator_demo 找到 0.0.0 的版本,記得將更動納入版本控管。

在 Rails 3 應用程式的 Gemfile 中加入:

gem 'generator_demo'

便能直接使用這個 generator 了:

rails generate jquery_ujs

後續可將程式碼放到 github.com 上。先設定好 github 的 ssh token 並在 github 上建立好同名的 repository(這裡是 generator_demo),依照 github 的指示設定 git config(jeweler 已經預設好同名的 origin remote),之後便可 push 到 github 上去了:

git push origin master

再將產生的 rubygem 放到 rubygems.org 上供人使用。先註冊好 rubygems.org 的帳號,通過測試、增加 rubygem 的版號、納入版本控管並包裝後,便可 push 出去:

rake spec
rake version:bump:minor
rake gemspec
rake install
git add .
git commit -m ‘release 0.1.0’
git push origin master
gem push pkg/generator_demo-0.1.0.gem

以上便是一整套撰寫與發表 rubygem 的流程,以及 Rails 3/Thor generator 的簡易例子,希望說明夠簡潔易懂。

Posted 1 year ago

用 Bundler 管理 Rails 2.3.5 應用程式所需的 gems 套件

Rails 3 beta 釋放出來一個月了,需要開始為 Rails 2.3.5 的應用程式做轉換準備,首先第一步便是先把 gems 的管理交給 bundler 來處理。

步驟很簡單,首先把 bundler (>=0.9.5) 裝起來:

gem install bundler

再來修改 Rails 2 應用程式內的 config/boot.rb 檔案,在 Rails.boot! 這行之前加入下面的 patch 來將 bundler 包裹到 Rails 2 的啟動程序:

# add this to the bottom of config/boot.rb, before the line `Rails.boot!`
class Rails::Boot
  def run
    load_initializer
    extend_environment
    Rails::Initializer.run(:set_load_path)
  end

  def extend_environment
    Rails::Initializer.class_eval do
      old_load = instance_method(:load_environment)
      define_method(:load_environment) do
        Bundler.require :default, Rails.env
        old_load.bind(self).call
      end
    end
  end
end

接著,建立新的設定檔 config/preinitializer.rb 來套用 bundler 設定:

# this code goes in config/preinitializer.rb, which you should create if it doesn't exist
begin
  # Require the preresolved locked set of gems.
  require File.expand_path('../../.bundle/environment', __FILE__)
rescue LoadError
  # Fallback on doing the resolve at runtime.
  require "rubygems"
  require "bundler"
  if Gem::Version.new(Bundler::VERSION) <= Gem::Version.new("0.9.5")
    raise RuntimeError, "Bundler incompatible.\n" +
      "Your bundler version is incompatible with Rails 2.3 and an unlocked bundle.\n" +
      "Run `gem install bundler` to upgrade or `bundle lock` to lock."
  else
    Bundler.setup
  end
end

然後建立 bundler 環境和 Gemfile 檔案:

bundle init

這個指令會自動幫你在 Rails 2 應用程式根目錄下加入 .bundle 目錄和 Gemfile 設定檔:

# A sample Gemfile
source :gemcutter
#
# gem "rails"

請記得將 .bundle 這個隱藏目錄加入 .gitignore 裡面:

.bundle

最後,將原本在 config/environment.rb 上的 config.gem 設定都搬到 Gemfile 內,例如:

source :gemcutter

gem 'rails', '~> 2.3.5', :require => nil
gem 'sqlite3-ruby', :require => 'sqlite3'
gem 'mongrel'

group :development do
  gem 'rails-footnotes', '>= 3.6.3'
end

group :test do
  gem 'rspec-rails'
end

group :cucumber do
  gem 'database_cleaner', '>= 0.5.0', :require => nil
  gem 'cucumber-rails', '>= 0.3.0', :require => nil
  gem 'webrat', '>= 0.7.0', :require => nil
  gem 'rspec-rails', '>= 1.3.2', :require => nil
end

再來檢查設定有沒有成功:

bundle check
bundle install
bundle show
script/server

一切無誤,大功告成。


番外篇:
有時候還是會遇上些小問題,我在使用 preferences 這個 gem 時,就會遇到 uninitialized constant Preferences::InstanceMethods::Preference 的錯誤,解決方法是乖乖的把 config.gem ’preferences’ 加回 config/environment.rb 去。

Posted 1 year ago

Authlogic Bundle - 打包 Rails 使用者認證與授權功能

以現今的技術來說,要開發一套網路應用程式,幾乎都需要建立使用者認證(authentication)與授權(authorization)機制,加上身為華人的我們,中文界面(i18n)更是必要的功能,像 Rails 這種以敏捷開發為特色的網頁程式語言框架(web framework),更是擁有眾多好用且多樣的開放源碼(Open Source)套件可供選擇。

隨著開發者使用的套件越來越多樣,要啟動一個新專案開發一套新的應用程式,變得不斷在重複弄相關必需套件的設定與修改,然後才能專心於新專案功能的開發。Rails 2.3 引入了 template 的功能,讓開發者能將熟稔的套件組合拼湊成一個模板,自動化地套用在新應用程式中,加上內建的 engines 機制,讓程式 MVC 部份可以切割出來放在 Plugin 中,強化了程式模組化的能力,根據這兩樣功能,打造一個程式開發包變成輕鬆且有意義。

以目前主流的 Rails 開放源碼套件來說,使用者認證以 Resful_authentication , ClearanceAuthlogic 這三個套件最受歡迎,並且常會配合使用 Open ID Authentication 作為單一認證的方式。而使用者授權套件則更為多樣,從簡單的 Role Requirement 到功能齊全的 Rails Authorization PluginDeclarative Authorization 都有。

於是根據個人的喜好,將 Authlogic, Open ID Authentication, Declarative Authorization 打包成 Authlogic Bundle,並支援 Rails-i18nGit 版本控制系統,以開放源碼的形式給喜愛 Rails 的開發者使用,使用方式十分簡單,只要一行指令:

$ rails your-app-name -m http://github.com/tsechingho/authlogic_bundle/raw/master/templates/remote.rb

便可以輕鬆地開始撰寫專案程式了。修改方式也很容易,利用 engines 的特色,只要將 Plugin 中的程式檔案拷貝到相對應路徑便可以改寫程式行為與界面。如果有任何建議,隨時歡迎指教。