ぶちのブログ

競プロとCTFが趣味なWebエンジニアのアウトプットの場

Refinementを使って、安全にActiveSupportにモンキーパッチを当てて、Hash#to_jsonの結果を書き換える

環境

Ruby 2.6.0, Rails 5.2.2

やりたかったこと

Rails上で

{today: Date.today}.to_json

とすると

{"today":"2019-02-20"}

となるが、ハッシュの要素が増えたときでも、strftime('%Y年%m月%d日')みたいなのをできる限り書かないで(DRYに保ったままで)

{"today":"2019年02月20日","yesterday":"2019年02月19日"}

としたい。が、副作用が怖いので、単純なオーバーライドはしたくない。Refinementで済ませたいが、調べても方法が分からなかったので、自己解決。

まずは単純にオーバーライドして方針を確かめる

ActiveSupportのメソッドを読み進めていくと、active_support/core_ext/object/json.rbで定義されているHash#as_jsonメソッド内で

def as_json(options = nil)
  subset = if options
     :
     :
  else
    self
  end

  Hash[subset.map { |k, v| [k.to_s, options ? v.as_json(options.dup) : v.as_json] }]
end

のように、valueに対してas_jsonメソッドを呼んでいる。valueにDateオブジェクトが入った時に、active_support/core_ext/object/json.rbでDateオブジェクトに定義されている以下のメソッドが実行される。

class Date
  def as_json(options = nil)
    if ActiveSupport::JSON::Encoding.use_standard_json_time_format
      strftime("%Y-%m-%d")
    else
      strftime("%Y/%m/%d")
    end
  end
end

strftimeの引数がハードコーディングされている。悲しい。 ひとまず、オープンクラスを使って、

class Date
  def as_json(options = nil) 
    strftime("%Y年%m月%d日")
  end
end

としてやれば、やりたいことはとりあえず達成される。 しかし、別のクラスでは別の形式を使いたいときに困ることが予想される上、やはりActiveSupportのモンキーパッチは危険な気がするため、できる限り避けたいですよね。

思考停止でRefinementを使ってみる(これは罠で失敗しました)

module M
  refine Date do
    def as_json(options = nil) 
      strftime("%Y年%m月%d日")
    end
  end
end

として、使いたいメソッド内でusing Mとしても、うまく行かない。 Refinementはusingと同じレキシカルスコープにしか効力を発さないので、直接Date#as_jsonを呼び出しているところでusingを呼ばない限りはrefineできない。 あくまでも、今回のような場合はHash#to_jsonをrefineすることしかできない。(勉強になりました)

Refinementを複数使ってみる

Date#as_jsonを直接呼び出しているHash#as_jsonをrefineする。Hash#as_jsonを直接呼び出しているActiveSupport::JSON::Encoding::JSONGemEncoderをrefineする……と繰り返していけばいいのではないかと考えた。これについてはコードを見ていただいた方が早いと思います。

module M1
  refine Date do
    def as_json(options = nil)
      :
      :
    end
  end
end

module M2
  refine Hash do
    using M1
    def as_json(options = nil)
      :
      :
    end
  end
end

module M3
  refine ActiveSupport::JSON::Encoding::JSONGemEncoder do
    using M2
    def encode(value)
      stringify jsonify value.as_json(options.dup)
    end
  end
end

module M4
  refine ActiveSupport::JSON.singleton_class do
    using M3
    def encode(value, options = nil)
      ActiveSupport::JSON::Encoding.json_encoder.new(options).encode(value)
    end
  end
end

module M5
  refine Hash do
    using M4
    def to_json(options = nil)
      :
      :
    end
  end
end

ActiveSupport::JSONのクラスメソッドをrefineする際には、ActiveSupport::JSONの特異クラスをrefineすることに気をつければ、面倒ではありますが、それほど難しいことはしていません。(ソースコードを読むときは、pryのshow-methodを使うととても便利です。今回初めて知りました)

結論

5時間くらいかかってしまったが、これでうまくいった。が、保守性は下がっている気もするので、マージするかは要検討中です。