[Ruby] Hash 内の文字列をすべて force_encoding する

# 初めに

ログ内容の収められた Hash をログ管理システムへ送信するために `JSON.generate` しようとしたところ、以下のようなエラーが発生した。

```
"\xE3" from ASCII-8BIT to UTF-8 (Encoding::UndefinedConversionError)
/usr/local/lib/ruby/2.7.0/json/common.rb:224:in `generate'
(以下略)
```

今回はこれの原因を簡単に調査し、その対応方法の1つとして Hash に含まれるすべての String のエンコードを UTF-8 へと変更する。

(あくまで簡単な調査で、ASCII-8BIT という文字エンコーディングについて、Ruby, module JSON, class StandardError のソースまでは追わない。)

# 原因調査

## "\xE3" from ASCII-8BIT to UTF-8
一目には、Hash に ASCII-8BIT エンコードの文字列が含まれていることが原因だと推測できる。
まず、単純に ASCII-8BIT 文字列を UTF-8 へ変換する際にエラーが発生するかを実際に確認する。

```ruby
str = String.new('a', encoding: Encoding::ASCII_8BIT)
p str.encoding
# => #<Encoding:ASCII-8BIT>
p str.encode(Encoding::UTF_8)
# => "a"

str2 = String.new('あ', encoding: Encoding::ASCII_8BIT)
p str2.encoding
# => #<Encoding:ASCII-8BIT>
p str2.encode(Encoding::UTF_8)
# => `encode': "\\xE3" from ASCII-8BIT to UTF-8 (Encoding::UndefinedConversionError)
```

後者において再現した。
また、UTF-8 文字コード表などを参照すると e3 から始まる領域にはひらがな、かたかな、漢字の一部等が収録されているため、問題のエラーも日本語が混ざった ASCII-8BIT 文字列を `JSON.generate` 内で UTF-8 へ変換しようとして発生していると推定する。

## ASCII-8BIT の発生源

カスタム例外をキャッチした際に、これの詳細をログ管理システムへ json として送信する。
今回問題が発生している処理はここである。

エラー詳細の json へ何が含まれているかは、元々のエラーを起こした処理に依るのだが、今回のケースではカスタム例外の `full_message` が含まれているようであった。
この状況を再現する。

```ruby
class MyError < StandardError
  def initialize(message)
    super(message)
  end
end

err = MyError.new('問題発生')

p err.message.encoding
# #<Encoding:UTF-8>
p err.full_message.encoding
# #<Encoding:ASCII-8BIT>
```

`full_message` の方だけが `ASCII-8BIT` になってしまった。
また、以下の内容を試した。

```ruby
begin
  str = String.new('あ', encoding: Encoding::ASCII_8BIT)
  str.encode(Encoding::UTF_8)
rescue => e
  p e.message.encoding
  # #<Encoding:ASCII-8BIT>
  p e.full_message.encoding
  # #<Encoding:ASCII-8BIT>
end
```

この結果から、むしろカスタム例外でこちらが文字列を与えた `message` が UTF-8 になってしまっていて、通常は ASCII-8BIT であるようだ。

まぁ、ASCII_8BIT がどこから発生するのかを確認したかっただけなので、`Exception#full_message` は ASCII_8BIT を返すものなのだと納得する。

また、Exception#full_message の内容が ASCII 文字だけなら `encode` で問題も発生しないので、カスタム例外で `message` にマルチバイト文字を入れてしまい、`full_message` に混入することが問題のようだ。

# 対応
今回の問題にだけ対処するなら、
- `message` にマルチバイト文字を与えない。
- `full_message` を `froce_encoding` してから Hash に収める。

このどちらかで良いが、エラー発生後のログ記載に関わる共通処理なのでどこから同じような現象が降ってくるかわからない。
Hash に含まれる文字列すべてを UTF-8 へと事前に置き換えることにした。

## Hash 内の文字列をすべて force_encoding する
`encode` は現時点でのエンコーディングが正しいという前提で処理をかけるため、問題となっているエラーを自分で引き起こしてしまう。
なので `froce_encoding` を行う。

Hash 内も Hash や Array でネストしている可能性を考えて再帰的に処理を行う。

```ruby
require 'json'

def recursive_encode(obj)
  case obj
  when String
    obj.force_encoding(Encoding::UTF_8)
  when Array
    obj.map do |it|
      recursive_encode(it)
    end
  when Hash
    obj.reduce({}) do |memo, it|
      memo[it[0]] = recursive_encode(it[1])
      memo
    end
  else
    # String ではないなら手を出さない
    obj
  end
end

# テスト
hash = {
  string: String.new('あ', encoding: Encoding::ASCII_8BIT),
  array: [String.new('い', encoding: Encoding::ASCII_8BIT)],
  hash: {key: String.new('う', encoding: Encoding::ASCII_8BIT)},
  nest: {
    string: String.new('え', encoding: Encoding::ASCII_8BIT),
    array: [String.new('お', encoding: Encoding::ASCII_8BIT)],
    hash: {key: String.new('か', encoding: Encoding::ASCII_8BIT)},
  }
}

recursive_encode(hash)

p JSON.generate(hash)
```

無事に json へ変換が出来た。

# 以上
`JSON.generate` が失敗する理由の調査と、対処のための Hash 内の String に対するエンコーディングの実装を行った。

初めに で断った通りソースコードを正確にあたっていないため、JSON.generate へ何らかのオプションを与えたら一発で解決してしまう、などの可能性はあるが今回はこのような方法を取った。