secwind's blog

在Rails中同时使用符号和字符串形式的key操作hash

我们可能有时会遇到这样的问题:当访问一个 hash 时,如果其中的元素使用了字符串形式的 key,而我们使用符号 key进行调用,就会无法取到值。

  hash = { 'type' => 'mobile' }
  hash['type'] => "mobile"
  # 用符号key会返回 nil
  hash[:type] => nil

这种问题会出现在诸如API升级等场景中,比如转化 xml 为 hash 时。设想当你需要把一个 xml 串转化成 hash。

  hash = Hash.from_xml("<errors><column>name</column><error>can't be blank</error></errors>")
  hash['errors'] #=> {"column"=>"name", "error"=>"can't be blank"}
  hash[:errors] #=> nil

当然,rails 给我们提供好了现成的 symbolize_keys / deep_symbolize_keys 方法来把字符串形式的 key 转化为符号形式。

  hash.deep_symbolize_keys! #=> {:errors=>{:column=>"name", :error=>"can't be blank"}}

这方案似乎不错,但是转换后字符串形式的 key 就无法使用了。设想如果你在升级一个类的接口方法,而这个方法返回一个以字符串为 key 的哈希,此时你想改为返回符号形式的 key,如果你使用了 symbolize_keys 进行转化,那么所有涉及调用了该方法的地方都需要进行同步改动!

有没有鱼与熊掌可兼得的解决方案呢?答案当然是有!还记得我们之前讲到过的 ActiveRecord::Store 吗,包裹在 store 中的数据是同时支持两种形式的 key 来进行访问的。它是怎么做到的呢?我们分析下 Store 类的源代码,可以发现下面这么一段:

   def dump(obj)
     @coder.dump self.class.as_indifferent_hash(obj)
   end
   def load(yaml)
     self.class.as_indifferent_hash(@coder.load(yaml || ''))
   end
   def self.as_indifferent_hash(obj)
     case obj
     when ActiveSupport::HashWithIndifferentAccess
       obj
     when Hash
       obj.with_indifferent_access
     else
       ActiveSupport::HashWithIndifferentAccess.new
     end
   end

不难发现,被 Store 序列/反序列化的数据都是通过 as_indifferent_hash 方法进行的,而 as_indifferent_hash 方法实际上只是对传入的对象类型进行了判断,返回包装过的数据。通过其中的分支判断我们可以发现,当对象为 ActiveSupport::HashWithIndifferentAccess 类型时,会不经任何处理直接返回。那么也就是说,ActiveSupport::HashWithIndifferentAccess 这个对象可以同时支持字符串和符号两种形式的 key。

通过查看Rails的API文档,我们可以发现其实这货就是 Hash 类的一个子类。它将 Hash 类中很多会因为 key 类型不同而导致问题的方法都进行了重写。

设想这样一个场景,你有一个购物车的模型,其中可能以 Hash 形式保存商品数量,类似如下形式:

  cart = {quantity: 3}

购物车接受一个更新数量的参数,对商品数量进行更新。不过当参数为字符串形式时,会发生问题:

  # 假设我们的 params = {'quantity': 5},那么我们会得到下面的结果……
  cart.update(params)  #=> {:quantity=>3, "quantity"=>5}

你需要手动将参数先 symbolize_keys 才能避免遇到类似问题。HashWithIndifferentAccess 类重写了这些会产生类似问题的方法:

  cart = ActiveSupport::HashWithIndifferentAccess.new(quantity: 3)
  cart.update('quantity' => 5)  #=> {"quantity"=>5}
  cart[:quantity]  #=> 5

这也正是Rails在 actionpack 中所实现的 params 可以通过任意形式 key 来访问的奥秘所在。

# /actionpack/lib/action_dispatch/http/parameters.rb 行 47
def normalize_encode_params(params)
  case params
  when Hash
    if params.has_key?(:tempfile)
      UploadedFile.new(params)
    else
      params.each_with_object({}) do |(key, val), new_hash|
        new_hash[key] = if val.is_a?(Array)
                          val.map! { |el| normalize_encode_params(el) }
                        else
                          normalize_encode_params(val)
                        end
      end.with_indifferent_access
    end
  else
    params
  end
end

# /actionpack/lib/action_dispatch/http/request.rb 行 298
# 重写了 rack 的 get 方法,以实现对两种形式 key 的支持
def GET
  @env["action_dispatch.request.query_parameters"] ||= Utils.deep_munge(normalize_encode_params(super || {}))
rescue Rack::Utils::ParameterTypeError, Rack::Utils::InvalidParameterError => e
  raise ActionController::BadRequest.new(:query, e)
end

之所以想贴出这段代码是因为,通过阅读这段代码,你会发现另外一个有用的方法:with_indifferent_access。

Rails将该方法以猴子补丁的形式打入了 Hash 类中,在这个方法的帮助下,你可以很轻易的将任意普通的 Hash 类的实例转化为 HashWithIndifferentAccess 对象。

  hash = {quantity: 2}
  hash['quantity']  #=> nil
  hash = {quantity: 2}.with_indifferent_access
  hash['quantity']  #=> 2