12

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

研究这个问题,我发现在没有 Rails 的普通 Ruby 中序列化一个大小数可以按预期工作:

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

但在 Rails 控制台中它不会:

BigDecimal.new("42.42").to_yaml
 => "--- 42.42\n"

这个数字是大十进制的字符串表示,所以没关系。但是当我读回它时,它被读取为浮点数,所以即使我将它转换为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

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

4

3 回答 3

17

您提到的 ActiveSupport 核心扩展代码“已经”在 master 分支中修复(提交大约有一年的时间,并且撤消了与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 用户)如何反向移植该特定方法。

作为快速的解决方法,您可以简单地使用 Syck 作为 YAML 引擎。在同一个问题中,用户rampion 发布了这段代码(您可以将其放在初始化文件中):

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

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

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

(将标签更改为"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)
    

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

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

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

  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
    
于 2013-04-18T21:35:24.923 回答
2

如果您使用的是 Rails 4.0 或更高版本(但低于 4.2),您可以通过删除方法来解决它BigDecimal#encode_with

您可以使用以下方式存档undef_method

require 'bigdecimal'
require 'active_support/core_ext/big_decimal'

class BigDecimal
  undef_method :encode_with
end

我将此代码放在初始化程序中,现在它可以工作了。在 Rails 4.2 中,Rails 猴子补丁的这种“还原”不是必需的,因为此提交删除了猴子补丁。

于 2015-02-02T21:08:28.723 回答
1

对于 rails 3.2,以下工作:

# config/initializers/backport_yaml_bigdecimal.rb

require "bigdecimal"
require "active_support/core_ext/big_decimal"

class BigDecimal
  remove_method :encode_with
  remove_method :to_yaml
end

如果没有这个补丁,在 rails 3.2 控制台中:

irb> "0.3".to_d.to_yaml
=> "--- 0.3\n...\n"

有了这个补丁:

irb> "0.3".to_d.to_yaml
=> "--- !ruby/object:BigDecimal 18:0.3E0\n...\n"

您可能希望将其包装在带有文档和弃用警告的版本测试中,例如:

# BigDecimals should be correctly tagged and encoded in YAML as ruby objects
# instead of being cast to/from floating point representation which may lose
# precision.
#
# This is already upstream in Rails 4.2, so this is a backport for now.
#
# See http://stackoverflow.com/questions/16031850/getting-big-decimals-back-from-a-yaml-serialized-field-in-the-database-with-ruby
#
# Without this patch:
#
#   irb> "0.3".to_d.to_yaml
#   => "--- 0.3\n...\n"
#
# With this patch:
#
#   irb> "0.3".to_d.to_yaml
#   => "--- !ruby/object:BigDecimal 18:0.3E0\n...\n"
#
if Gem::Version.new(Rails.version) < Gem::Version.new("4.2")
  require "bigdecimal"
  require "active_support/core_ext/big_decimal"

  class BigDecimal
    # Rails 4.0.0 removed #to_yaml
    # https://github.com/rails/rails/commit/d8ed247c7f11b1ca4756134e145d2ec3bfeb8eaf
    if Gem::Version.new(Rails.version) < Gem::Version.new("4")
      remove_method :to_yaml
    else
      ActiveSupport::Deprecation.warn "Hey, you can remove this part of the backport!"
    end

    # Rails 4.2.0 removed #encode_with
    # https://github.com/rails/rails/commit/98ea19925d6db642731741c3b91bd085fac92241
    remove_method :encode_with
  end
else
  ActiveSupport::Deprecation.warn "Hey, you can remove this backport!"
end
于 2015-05-05T03:49:35.670 回答