使用Ruby on Rails从数据库中的yaml-serialized字段获取大小数

pup*_*eno 12 serialization ruby-on-rails bigdecimal

使用Ruby on Rails我有几个序列化的字段(主要是数组或散列).其中一些包含BigDecimals.非常重要的是,那些大小数仍然是小数字,但Rails将它们变成浮点数.我怎么BigDecimal回来?

在研究这个问题时,我发现在没有Rails的情况下,在纯Ruby中序列化一个大小数,按预期工作:

BigDecimal.new("42.42").to_yaml
 => "--- !ruby/object:BigDecimal 18:0.4242E2\n...\n"
Run Code Online (Sandbox Code Playgroud)

但在Rails控制台中它不会:

BigDecimal.new("42.42").to_yaml
 => "--- 42.42\n"
Run Code Online (Sandbox Code Playgroud)

这个数字是大小数的字符串表示,所以它是正确的.但是当我读回它时,它被读作浮点数,所以即使我将其转换为BigDecimal(我不想做的事情,因为它容易出错),我可能会失去精度,这是不可接受的我的应用.

activesupport-3.2.11/lib/active_support/core_ext/big_decimal/conversions.rb找到了在BigDecimal中覆盖以下方法的罪魁祸首:

YAML_TAG = 'tag:yaml.org,2002:float'
YAML_MAPPING = { 'Infinity' => '.Inf', '-Infinity' => '-.Inf', 'NaN' => '.NaN' }

# This emits the number without any scientific notation.
# This is better than self.to_f.to_s since it doesn't lose precision.
#
# Note that reconstituting YAML floats to native floats may lose precision.
def to_yaml(opts = {})
  return super if defined?(YAML::ENGINE) && !YAML::ENGINE.syck?

  YAML.quick_emit(nil, opts) do |out|
    string = to_s
    out.scalar(YAML_TAG, YAML_MAPPING[string] || string, :plain)
  end
end
Run Code Online (Sandbox Code Playgroud)

他们为什么要那样做?更重要的是,我该如何解决它?

DMK*_*MKE 16

您提到的ActiveSupport核心扩展代码已在主分支中"已经"修复(提交大约一年,并且撤消了与Rails 2.1.0一样旧的实现),但由于Rails 3.2只获得安全更新,您的应用可能是坚持旧的实施.

我想你有三个选择:

  1. 将您的Rails应用程序移植到Rails 4.
  2. Backport Psych的BigDecimal#to_yaml实现(猴子修补猴子补丁).
  3. 切换到Syck作为YAML引擎.

每个选项都有其自身的缺点:

如果你有时间的话,移植到Rails 4在我看来是最好的选择(自v4.0.0.beta1起,上面提到的提交在Rails中可用).由于尚未发布,您必须使用测试版.我并不怀疑会有任何重大变化,尽管一些GSoC的想法看起来好像仍然可以进入4.0版本......

猴子修补 ActiveSupport猴子补丁应该相当简单.虽然我没有找到最初的实现BigDecimal#to_yaml,但是一个有点相关的问题导致了这个提交.我想我会留给你(或其他StackOverflow用户)如何向后移植该特定方法.

作为quick'n'dirty解决方法,您可以简单地使用Syck作为YAML引擎.在同一个问题中,用户斜率 发布了这段代码(您可以将其放在初始化文件中):

YAML::ENGINE.yamler = 'syck'

class BigDecimal
  def to_yaml(opts={})
    YAML::quick_emit(object_id, opts) do |out|
      out.scalar("tag:induktiv.at,2007:BigDecimal", self.to_s)
    end
  end
end

YAML.add_domain_type("induktiv.at,2007", "BigDecimal") do |type, val|
  BigDecimal.new(val)
end
Run Code Online (Sandbox Code Playgroud)

这里的主要缺点(除了Syck在Ruby 2.0.0上不可用)之外,你无法在Rails上下文中读取正常的 BigDecimal转储,并且每个想要读取你的YAML转储的人都需要相同类型的加载器:

BigDecimal.new('43.21').to_yaml
#=> "--- !induktiv.at,2007/BigDecimal 43.21\n"
Run Code Online (Sandbox Code Playgroud)

(更改标签"tag:ruby/object:BigDecimal"也无济于事,因为它会产生!ruby/object/BigDecimal......)


更新 - 迄今为止我学到的东西

  1. 奇怪的行为似乎可以追溯到Rails 1.2的时代(你可能也会说2007年2月),根据这篇博客文章.

  2. config/application.rb以这种方式修改没有帮助:

    require File.expand_path('../boot', __FILE__)
    
    # (a)
    
    %w[yaml psych bigdecimal].each {|lib| require lib }
    class BigDecimal
      # backup old method definitions
      @@old_to_yaml = instance_method :to_yaml
      @@old_to_s    = instance_method :to_s
    end
    
    require 'rails/all'
    
    # (b)
    
    class BigDecimal
      # restore the old behavior
      define_method :to_yaml do |opts={}|
        @@old_to_yaml.bind(self).(opts)
      end
      define_method :to_s do |format='E'|
        @@old_to_s.bind(self).(format)
      end
    end
    
    # (c)
    
    Run Code Online (Sandbox Code Playgroud)

    在不同的点(这里是a,bc),a BigDecimal.new("42.21").to_yaml产生了一些有趣的输出:

    # (a) => "--- !ruby/object:BigDecimal 18:0.4221E2\n...\n"
    # (b) => "--- 42.21\n...\n"
    # (c) => "--- 0.4221E2\n...\n"
    
    Run Code Online (Sandbox Code Playgroud)

    其中一个是默认的行为,b是由核心的ActiveSupport扩展引起的,ç应该是相同的结果作为一个.也许我错过了一些东西......

  3. 在仔细阅读你的问题时,我有了这个想法:为什么不用其他格式序列化,比如JSON?将另一列添加到数据库并随时间迁移,如下所示:

    class Person < ActiveRecord::Base
      # the old serialized field
      serialize :preferences
    
      # the new one. once fully migrated, drop old preferences column
      # rename this to preferences and remove the getter/setter methods below
      serialize :pref_migration, JSON
    
      def preferences
        if pref_migration.blank?
          pref_migration = super
          save! # maybe don't use bang here
        end
        pref_migration
      end
    
      def preferences=(*data)
        pref_migration = *data
      end
    end
    
    Run Code Online (Sandbox Code Playgroud)