rails 脚本里调用helper方法

Posted by wxianfeng Sun, 01 Feb 2009 14:21:00 GMT

环境:ubuntu 10.10 + ruby 1.8.7 + rails 2.3.5

有这样一个需求,以前写script都是一般调用Model里的方法,这次不同,调用的是helper方法,需要批量的更新数据库中的一个字段,于是写了个script,然后用script/runner执行,需要调用一个helper方法,出现下面了这个错误:

NoMethodError: undefined method `url_for' for nil:NilClass

最后发现原因是 helper 方法里有 link_to 导致的,那怎么办?也不好调试,runner输出的玩意也跟踪不到错误的地方,只是抛出异常,于是通过我不断的debug到rails源码里,查出到底谁是nil了,最终找到了禍手,并且现在可以很方便的在script里用helper任何方法了,下面我把过程重现一遍.

建好demo project:

rails scaffold -d mysql
cd scaffold
rake db:create
ruby script/generate scaffold user name:string password:string birthday_at:datetime
rake db:migrate
rake rails:freeze:gems  # 把rails打包进去 ,为了方便查看rails源码和debug
ruby script/server
create 一条user数据

users_helper.rb 添加如下代码:

  def link_to_user
   # link_to "user_first","/users/show/#{User.first.id}"  这个不会报错,why? 后面会提到
    link_to "user_first",:controller => "users",:action=>"show",:id=>User.first
  end

script 代码: RAILSROOT/script/tools/callhelper.rb:

# at RAILS_ROOT run : ruby script/runner script/tools/call_helper.rb
p ApplicationController.helpers.link_to_user

调用 runner执行报错:

NoMethodError: undefined method `url_for' for nil:NilClass

debug 跟进去发现是link_to 的问题:

linkto 源码:(/usr/local/system/projects/scaffold/vendor/rails/actionpack/lib/actionview/helpers/url_helper.rb)

def link_to(*args, &block)

    <span class="r">if</span> block_given?
      options      = args.first || {}
      html_options = args.second
      concat(link_to(capture(&amp;block), options, html_options).html_safe!)
    <span class="r">else</span>
      name         = args.first
      options      = args.second || {}
      html_options = args.third

      url = url_for(options)

      <span class="r">if</span> html_options
        html_options = html_options.stringify_keys
        href = html_options[<span class="s"><span class="dl">'</span><span class="k">href</span><span class="dl">'</span></span>]
        convert_options_to_javascript!(html_options, url)
        tag_options = tag_options(html_options)
      <span class="r">else</span>
        tag_options = <span class="pc">nil</span>
      <span class="r">end</span>

      href_attr = <span class="s"><span class="dl">&quot;</span><span class="k">href=</span><span class="ch">\&quot;</span><span class="il"><span class="idl">#{</span>url<span class="idl">}</span></span><span class="ch">\&quot;</span><span class="dl">&quot;</span></span> <span class="r">unless</span> href
      <span class="s"><span class="dl">&quot;</span><span class="k">&lt;a </span><span class="il"><span class="idl">#{</span>href_attr<span class="idl">}</span></span><span class="il"><span class="idl">#{</span>tag_options<span class="idl">}</span></span><span class="k">&gt;</span><span class="il"><span class="idl">#{</span>name || url<span class="idl">}</span></span><span class="k">&lt;/a&gt;</span><span class="dl">&quot;</span></span>.html_safe!
    <span class="r">end</span>

end

里面又调用到了 urlfor 方法,urlfor 源码:

def url_for(options = {})

    options ||= {}
    url = <span class="r">case</span> options
    <span class="r">when</span> <span class="co">String</span>
      escape = <span class="pc">true</span>
      options
    <span class="r">when</span> <span class="co">Hash</span>
      options = { <span class="sy">:only_path</span> =&gt; options[<span class="sy">:host</span>].nil? }.update(options.symbolize_keys)
      escape  = options.key?(<span class="sy">:escape</span>) ? options.delete(<span class="sy">:escape</span>) : <span class="pc">true</span>
      <span class="iv">@controller</span>.send(<span class="sy">:url_for</span>, options)
    <span class="r">when</span> <span class="sy">:back</span>
      escape = <span class="pc">false</span>
      <span class="iv">@controller</span>.request.env[<span class="s"><span class="dl">&quot;</span><span class="k">HTTP_REFERER</span><span class="dl">&quot;</span></span>] || <span class="s"><span class="dl">'</span><span class="k">javascript:history.back()</span><span class="dl">'</span></span>
    <span class="r">else</span>
      escape = <span class="pc">false</span>
      polymorphic_path(options)
    <span class="r">end</span>

    escape ? escape_once(url) : url
  <span class="r">end</span></pre></div>

执行的是 Hash 那一块,好,就在这设断点,最后发现了真正的问题 原来是 @controller.send(:urlfor, options) 这里报错的,@controller 为nil,之前为什么 linkto 后面给string不报错,url_for源码已经告诉你了

那么如何解决,修改call_helper.rb:

# at RAILS_ROOT run : ruby script/runner script/tools/call_helper.rb
include ActionController::UrlWriter
p ApplicationController.helpers.link_to_user

ok,这下就可以了.

ActionController::UrlWriter 中url_for 源码;

def url_for(options)

  options = <span class="pc">self</span>.class.default_url_options.merge(options)

  url = <span class="s"><span class="dl">'</span><span class="dl">'</span></span>

  <span class="r">unless</span> options.delete(<span class="sy">:only_path</span>)
    url &lt;&lt; (options.delete(<span class="sy">:protocol</span>) || <span class="s"><span class="dl">'</span><span class="k">http</span><span class="dl">'</span></span>)
    url &lt;&lt; <span class="s"><span class="dl">'</span><span class="k">://</span><span class="dl">'</span></span> <span class="r">unless</span> url.match(<span class="s"><span class="dl">&quot;</span><span class="k">://</span><span class="dl">&quot;</span></span>)

    raise <span class="s"><span class="dl">&quot;</span><span class="k">Missing host to link to! Please provide :host parameter or set default_url_options[:host]</span><span class="dl">&quot;</span></span> <span class="r">unless</span> options[<span class="sy">:host</span>]

    url &lt;&lt; options.delete(<span class="sy">:host</span>)
    url &lt;&lt; <span class="s"><span class="dl">&quot;</span><span class="k">:</span><span class="il"><span class="idl">#{</span>options.delete(<span class="sy">:port</span>)<span class="idl">}</span></span><span class="dl">&quot;</span></span> <span class="r">if</span> options.key?(<span class="sy">:port</span>)
  <span class="r">else</span>
    <span class="c"># Delete the unused options to prevent their appearance in the query string.</span>
    [<span class="sy">:protocol</span>, <span class="sy">:host</span>, <span class="sy">:port</span>, <span class="sy">:skip_relative_url_root</span>].each { |k| options.delete(k) }
  <span class="r">end</span>
  trailing_slash = options.delete(<span class="sy">:trailing_slash</span>) <span class="r">if</span> options.key?(<span class="sy">:trailing_slash</span>)
  url &lt;&lt; <span class="co">ActionController</span>::<span class="co">Base</span>.relative_url_root.to_s <span class="r">unless</span> options[<span class="sy">:skip_relative_url_root</span>]
  anchor = <span class="s"><span class="dl">&quot;</span><span class="k">#</span><span class="il"><span class="idl">#{</span><span class="co">CGI</span>.escape options.delete(<span class="sy">:anchor</span>).to_param.to_s<span class="idl">}</span></span><span class="dl">&quot;</span></span> <span class="r">if</span> options[<span class="sy">:anchor</span>]
  generated = <span class="co">Routing</span>::<span class="co">Routes</span>.generate(options, {})
  url &lt;&lt; (trailing_slash ? generated.sub(<span class="rx"><span class="dl">/</span><span class="ch">\?</span><span class="k">|</span><span class="ch">\z</span><span class="dl">/</span></span>) { <span class="s"><span class="dl">&quot;</span><span class="k">/</span><span class="dl">&quot;</span></span> + <span class="gv">$&amp;</span> } : generated)
  url &lt;&lt; anchor <span class="r">if</span> anchor

  url
<span class="r">end</span>

end

发现这个 urlfor 已经和 actonview 下的不一样了,完全重写了,所以 当你include ActionController::UrlWriter 后,调用的是这个 urlfor 方法,而不是之前的那个 urlfor 了........这两个方法 源码 还有待进一步研究,之前nil问题解决了.

期间遇到的问题:

1,在rails console 里可以直接调用 helper 方法,为什么在用runner执行的script不可以找到helper变量?

console 中helper:

wxianfeng@ubuntu:/usr/local/system/projects/scaffold$ ruby script/console 
Loading development environment (Rails 2.3.5)
ruby-1.8.7-p302 > helper
 => #<ActionView::Base:0xb71dc038 @assigns_added=nil, @_first_render=nil, @assigns={}, @view_paths=[], @helpers=#<ActionView::Base::ProxyModule:0xb71dbf98>, @controller=nil, @_current_render=nil> 

那我们就来看看 runner 和 console 到底有什么区别:

runner源码:

#!/usr/bin/env ruby
require File.expand_path('../../config/boot',  __FILE__)
require 'commands/runner

console源码:

#!/usr/bin/env ruby
require File.expand_path('../../config/boot',  __FILE__)
require 'commands/console'

commands/runner 源码:(/usr/local/system/projects/scaffold/vendor/rails/railties/lib/commands/runner.rb)

require 'optparse'

options = { :environment => (ENV['RAILS_ENV'] || "development").dup }
code_or_file = nil

ARGV.clone.options do |opts|
  script_name = File.basename($0)
  opts.banner = "Usage: #{$0} [options] ('Some.ruby(code)' or a filename)"

  opts.separator ""

  opts.on("-e", "--environment=name", String,
          "Specifies the environment for the runner to operate under (test/development/production).",
          "Default: development") { |v| options[:environment] = v }

  opts.separator ""

  opts.on("-h", "--help",
          "Show this help message.") { $stderr.puts opts; exit }

  if RUBY_PLATFORM !~ /mswin/
    opts.separator ""
    opts.separator "You can also use runner as a shebang line for your scripts like this:"
    opts.separator "-------------------------------------------------------------"
    opts.separator "#!/usr/bin/env #{File.expand_path($0)}"
    opts.separator ""
    opts.separator "Product.find(:all).each { |p| p.price *= 2 ; p.save! }"
    opts.separator "-------------------------------------------------------------"
  end

  opts.order! { |o| code_or_file ||= o } rescue retry
end

ARGV.delete(code_or_file)

ENV["RAILS_ENV"] = options[:environment]
RAILS_ENV.replace(options[:environment]) if defined?(RAILS_ENV)

require RAILS_ROOT + '/config/environment'

begin
  if code_or_file.nil?
    $stderr.puts "Run '#{$0} -h' for help."
    exit 1
  elsif File.exist?(code_or_file)
    eval(File.read(code_or_file), nil, code_or_file)
  else
    eval(code_or_file)
  end
ensure
  if defined? Rails
    Rails.logger.flush if Rails.logger.respond_to?(:flush)
  end
end

commands/console.rb 源码 (/usr/local/system/projects/scaffold/vendor/rails/railties/lib/commands/console.rb)

irb = RUBY_PLATFORM =~ /(:?mswin|mingw)/ ? 'irb.bat' : 'irb'

require 'optparse'

options = { :sandbox => false, :irb => irb }
OptionParser.new do |opt|
  opt.banner = "Usage: console [environment] [options]"
  opt.on('-s', '--sandbox', 'Rollback database modifications on exit.') { |v| options[:sandbox] = v }
  opt.on("--irb=[#{irb}]", 'Invoke a different irb.') { |v| options[:irb] = v }
  opt.on("--debugger", 'Enable ruby-debugging for the console.') { |v| options[:debugger] = v }
  opt.parse!(ARGV)
end

libs =  " -r irb/completion"
libs << %( -r "#{RAILS_ROOT}/config/environment")
libs << " -r console_app"
libs << " -r console_sandbox" if options[:sandbox]
libs << " -r console_with_helpers"

if options[:debugger]
  begin
    require 'ruby-debug'
    libs << " -r ruby-debug"
    puts "=> Debugger enabled"
  rescue Exception
    puts "You need to install ruby-debug to run the console in debugging mode. With gems, use 'gem install ruby-debug'"
    exit
  end
end

ENV['RAILS_ENV'] = case ARGV.first
  when "p"; "production"
  when "d"; "development"
  when "t"; "test"
  else
    ARGV.first || ENV['RAILS_ENV'] || 'development'
end

if options[:sandbox]
  puts "Loading #{ENV['RAILS_ENV']} environment in sandbox (Rails #{Rails.version})"
  puts "Any modifications you make will be rolled back on exit"
else
  puts "Loading #{ENV['RAILS_ENV']} environment (Rails #{Rails.version})"
end
exec "#{options[:irb]} #{libs} --simple-prompt"

可以发现 ,原来 console 里 加载了 consolewithhelpers ,另外 发现 其实 rails console就是 irb 只不过 加载了 一些lib

看看 consolewithhelpers 源码:(/usr/local/system/projects/scaffold/vendor/rails/railties/lib/consolewithhelpers.rb)

def helper
  @helper ||= ApplicationController.helpers
end

@controller = ApplicationController.new

so,看明白了把,改变原来的 script:

# at RAILS_ROOT run : ruby script/runner script/tools/call_helper.rb
include ActionController::UrlWriter
require 'console_with_helpers'
p helper.link_to_user

2,那我直接用ruby调用一个script可以吗,不用runner来执行,可以.

#!/usr/bin/env ruby
#
# author : wang.fl1429@gmail.com
# run at RAILS_ROOT :  ruby script/tools/call_helper_script.rb
require File.expand_path('../../../config/boot',  __FILE__)
# ENV['RAILS_ENV'] ||= 'production'
require RAILS_ROOT + '/config/environment'
include ActionController::UrlWriter
p ApplicationController.helpers.link_to_user

3,为什么一般长久大批量执行用 runner ,而不是直接用 ruby 执行

看上面rails runner.rb的源码 也没发现为什么 ,只是用了eval 来执行ruby代码, 改天 benchmark 一把看看 , 瞧瞧eval 的好处!,不用 rails runner 我们同样可以达到runner的效果:

ruby eval(File.read("/....../call_helper.rb"))

demo project download: here

SEE: http://kpumuk.info/ruby-on-rails/memo-6-using-named-routes-and-url_for-outside-the-controller-in-ruby-on-rails/