初めに
ログ内容の収められた 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 へ変換する際にエラーが発生するかを実際に確認する。
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
が含まれているようであった。
この状況を再現する。
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
になってしまった。
また、以下の内容を試した。
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 でネストしている可能性を考えて再帰的に処理を行う。
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 へ何らかのオプションを与えたら一発で解決してしまう、などの可能性はあるが今回はこのような方法を取った。