Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

[Rails 5] ActiveSupport::NumberHelperとActionView::Helpers::NumberHelperの数値フォーマットメソッド

こんにちは、hachi8833です。
少々空いていましたが、久しぶりのActiveSupport探訪シリーズではNumberHelperを見てみます。

ActiveSupport::NumberHelperクラス

ActiveSupport::NumberHelperには以下の数値フォーマット変換メソッドがあります。

#number_to_currency
数値を通貨形式に変換。単位のほかにロケールで指定もできる。
例: number_to_currency(1234567890.506, locale: :fr)
# => "1 234 567 890,51 €"
#number_to_delimited
数値を桁区切り表示に変換。ロケール指定や正規表現も使える。
例: number_to_delimited(12345678, delimiter: '.')
# => "12.345.678"
#number_to_human
数値をmillionなどの単位に丸めて表示。有効桁数なども指定できる。
例: number_to_human(1234567, precision: 4, significant: false)
# => "1.2346 Million"
#number_to_human_size
#number_to_humanと似ているが、ファイルサイズの単位表示に向いている。有効桁数なども指定できる。
例: number_to_human_size(1234567, precision: 2)
# => "1.2 MB"
#number_to_percentage
数値をパーセント単位で表示。有効桁数なども指定できる。
例: number_to_percentage('98')
# => "98.000%"
#number_to_phone
数値を電話番号に変換。国番号や内線番号、桁区切りも指定できる。有効桁数指定や正規表現も使える。
例: number_to_phone(1235551234, country_code: 1, extension: 1343, delimiter: '.')
# => "+1.123.555.1234 x 1343"
#number_to_rounded
数値を丸める。有効桁数やロケールも指定できる。
例: number_to_rounded(389.32314, precision: 4, significant: true)
# => "389.3"

ActionView::Helpers::NumberHelperとどう違うのか?

ActionViewにもActionView::Helpers::NumberHelperというヘルパークラスがあります。実は先にこっちを知ってから「あれ?同じものがActiveSupportにあったような?」と気づいたのでした。

ActionView::Helpers::NumberHelperの数値フォーマット変換メソッドですが、何だかActiveSupportのと微妙に違っています。

以下については両者共通のようです。

#number_to_currency
数値を通貨形式に変換。
#number_to_human
数値をmillionなどの単位に丸めて表示。
#number_to_human_size
#number_to_humanと似ているが、ファイルサイズの単位表示に向いている。
#number_to_percentage
数値をパーセント単位で表示。
#number_to_phone
数値を電話番号に変換。

こちらには#number_to_delimited#number_to_roundedはなく、代わりに以下があります。

#number_with_delimiter
数値を桁区切り表示に変換。ロケールや正規表現も使える。
例: number_with_delimiter(98765432.98, delimiter: " ", separator: ",")
# => 98 765 432,98
#number_with_precision
数値を丸める。有効桁数やロケールも指定できる。
例: number_with_precision(13, precision: 5, significant: true)
# => 13.000

ActiveSupport::NumberHelperActionView::Helpers::NumberHelperのコード

こうなってくると、両者のコードは実は共通ではないかという予測が立ちます。

active_support/number_helper.rb

まずはactive_support/number_helper.rbから。コメントは取っ払ってあります。

# active_support/number_helper.rb
module ActiveSupport
  module NumberHelper
    extend ActiveSupport::Autoload

    eager_autoload do
      autoload :NumberConverter
      autoload :RoundingHelper
      autoload :NumberToRoundedConverter
      autoload :NumberToDelimitedConverter
      autoload :NumberToHumanConverter
      autoload :NumberToHumanSizeConverter
      autoload :NumberToPhoneConverter
      autoload :NumberToCurrencyConverter
      autoload :NumberToPercentageConverter
    end

    extend self

    def number_to_phone(number, options = {})
      NumberToPhoneConverter.convert(number, options)
    end

    def number_to_currency(number, options = {})
      NumberToCurrencyConverter.convert(number, options)
    end

    def number_to_percentage(number, options = {})
      NumberToPercentageConverter.convert(number, options)
    end

    def number_to_delimited(number, options = {})
      NumberToDelimitedConverter.convert(number, options)
    end

    def number_to_rounded(number, options = {})
      NumberToRoundedConverter.convert(number, options)
    end

    def number_to_human_size(number, options = {})
      NumberToHumanSizeConverter.convert(number, options)
    end

    def number_to_human(number, options = {})
      NumberToHumanConverter.convert(number, options)
    end
  end
end

内容はというと、NumberToPhoneConverterなどにそのまま投げているシンプルなつくりです。投げる相手は activesupport/lib/active_support/number_helper/の下にあります。

せっかくなのでその中からnumber_to_currency_converter.rbを開いてみました。

# active_support/number_helper/number_to_currency_converter.rb
require "active_support/core_ext/numeric/inquiry"

module ActiveSupport
  module NumberHelper
    class NumberToCurrencyConverter < NumberConverter # :nodoc:
      self.namespace = :currency

      def convert
        number = self.number.to_s.strip
        format = options[:format]

        if number.to_f.negative?
          format = options[:negative_format]
          number = absolute_value(number)
        end

        rounded_number = NumberToRoundedConverter.convert(number, options)
        format.gsub("%n".freeze, rounded_number).gsub("%u".freeze, options[:unit])
      end

      private

        def absolute_value(number)
          number.respond_to?(:abs) ? number.abs : number.sub(/\A-/, "")
        end

        def options
          @options ||= begin
            defaults = default_format_options.merge(i18n_opts)
            # フォーマットオプションが渡された場合はマイナス表示をオーバーライド
            defaults[:negative_format] = "-#{opts[:format]}" if opts[:format]
            defaults.merge!(opts)
          end
        end

        def i18n_opts
          # 固有のマイナス記号がない場合は国際的なマイナス記号にする
          i18n = i18n_format_options
          i18n[:negative_format] ||= "-#{i18n[:format]}" if i18n[:format]
          i18n
        end
    end
  end

i18nオプションのうち、マイナス記号の処理はここで行われています。そしてそれ以外のi18nオプションの処理は、同じディレクトリのnumber_converter.rbで行っていました。以下は該当箇所のみピックアップしています。

# active_support/number_helper/number_converter.rb
  ...
      private
        ...
        def i18n_format_options
          locale = opts[:locale]
          options = I18n.translate(:'number.format', locale: locale, default: {}).dup

          if namespace
            options.merge!(I18n.translate(:"number.#{namespace}.format", locale: locale, default: {}))
          end

          options
        end

        def translate_number_value_with_default(key, i18n_options = {})
          I18n.translate(key, { default: default_value(key), scope: :number }.merge!(i18n_options))
        end

        def translate_in_locale(key, i18n_options = {})
          translate_number_value_with_default(key, { locale: options[:locale] }.merge(i18n_options))
        end

        def default_value(key)
          key.split(".").reduce(DEFAULTS) { |defaults, k| defaults[k.to_sym] }
        end

        def valid_float?
          Float(number)
        rescue ArgumentError, TypeError
          false
        end
    end
...

最終的に、ActiveSupportがインクルードしているI18n gemにi18n処理を投げています。ロケールなどの処理もi18nで行われています。長い旅路ですね。

action_view/helpers/number_helper.rb

続いてaction_view/helpers/number_helper.rbです。

# action_view/helpers/number_helper.rb
require "active_support/core_ext/hash/keys"
require "active_support/core_ext/string/output_safety"
require "active_support/number_helper"

module ActionView
  module Helpers #:nodoc:
    module NumberHelper
      class InvalidNumberError < StandardError
        attr_accessor :number
        def initialize(number)
          @number = number
        end
      end

      def number_to_phone(number, options = {})
        return unless number
        options = options.symbolize_keys

        parse_float(number, true) if options.delete(:raise)
        ERB::Util.html_escape(ActiveSupport::NumberHelper.number_to_phone(number, options))
      end

      def number_to_currency(number, options = {})
        delegate_number_helper_method(:number_to_currency, number, options)
      end

      def number_to_percentage(number, options = {})
        delegate_number_helper_method(:number_to_percentage, number, options)
      end

      def number_with_delimiter(number, options = {})
        delegate_number_helper_method(:number_to_delimited, number, options)
      end

      def number_with_precision(number, options = {})
        delegate_number_helper_method(:number_to_rounded, number, options)
      end

      def number_to_human_size(number, options = {})
        delegate_number_helper_method(:number_to_human_size, number, options)
      end

      def number_to_human(number, options = {})
        delegate_number_helper_method(:number_to_human, number, options)
      end

      private

        def delegate_number_helper_method(method, number, options)
          return unless number
          options = escape_unsafe_options(options.symbolize_keys)

          wrap_with_output_safety_handling(number, options.delete(:raise)) {
            ActiveSupport::NumberHelper.public_send(method, number, options)
          }
        end

        def escape_unsafe_options(options)
          options[:format]          = ERB::Util.html_escape(options[:format]) if options[:format]
          options[:negative_format] = ERB::Util.html_escape(options[:negative_format]) if options[:negative_format]
          options[:separator]       = ERB::Util.html_escape(options[:separator]) if options[:separator]
          options[:delimiter]       = ERB::Util.html_escape(options[:delimiter]) if options[:delimiter]
          options[:unit]            = ERB::Util.html_escape(options[:unit]) if options[:unit] && !options[:unit].html_safe?
          options[:units]           = escape_units(options[:units]) if options[:units] && Hash === options[:units]
          options
        end

        def escape_units(units)
          Hash[units.map do |k, v|
            [k, ERB::Util.html_escape(v)]
          end]
        end

        def wrap_with_output_safety_handling(number, raise_on_invalid, &block)
          valid_float = valid_float?(number)
          raise InvalidNumberError, number if raise_on_invalid && !valid_float

          formatted_number = yield

          if valid_float || number.html_safe?
            formatted_number.html_safe
          else
            formatted_number
          end
        end

        def valid_float?(number)
          !parse_float(number, false).nil?
        end

        def parse_float(number, raise_error)
          Float(number)
        rescue ArgumentError, TypeError
          raise InvalidNumberError, number if raise_error
        end
    end
  end
end

予想どおり、require "active_support/number_helper"でActiveSupport::NumberHelperを読み込んでいます。

delegate_number_helper_methodというprivateメソッドがあり、ここで以下の処理が追加されています。

  • 数値チェック: return unless number
  • ビュー向けにエスケープ: #escape_unsafe_options
  • ビュー向けに単位をエスケープ: #escape_units
  • #html_safeにしてエスケープの繰り返しを避ける: #wrap_with_output_safety_handling
  • floatが有効かどうかチェック: #parse_float

#html_safe「については以下の記事をご覧ください。

Rails: ビューのHTMLエスケープは#link_toなどのヘルパーメソッドで解除されることがある

まとめ

  • ActiveSupport::NumberHelperは、WebやHTMLに限定されない汎用性の高い変換を行います。I18nライブラリによるi18n対応もここで行っています。
  • ActionView::Helpers::NumberHelperは上のコードを基にしながら、ビュー向けに適切なエスケープ処理を追加しています。

したがって、RailsのビューではActionViewの数値ヘルパーを使うべきです。お間違いのないよう。

関連記事

[Rails5] Active Support Core Extensionsのマルチバイト系メソッド: String#mb_charsとis_utf8?


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。