LoginSignup
1
0

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

Posted at

初めに

ログ内容の収められた 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_messagefroce_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 へ何らかのオプションを与えたら一発で解決してしまう、などの可能性はあるが今回はこのような方法を取った。

1
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0