SafeBufferのエンコーディングをMessagePackのラウンドトリップで保持する
ActiveSupport::MessagePack が ActiveSupport::SafeBuffer をシリアライズする際に ASCII-8BIT へ変換していた問題を修正し、UTF-8エンコーディングをラウンドトリップ越しに保持するようになりました。
背景
キャッシュされた安全な文字列を扱うコードで、UTF-8文字列の連結が壊れるという問題が存在していました。ActiveSupport::SafeBuffer はHTMLエスケープ済みの安全な文字列を保持するクラスですが、ActiveSupport::MessagePack を通じてシリアライズ・デシリアライズを行うと、元のエンコーディングが ASCII-8BIT に変換されてしまっていました。その結果、復元された SafeBuffer に対してUTF-8文字列を連結しようとすると Encoding::CompatibilityError が発生するケースがありました。
具体的には、"Mäßig" のようなUTF-8文字を含む SafeBuffer をダンプ・ロードすると、戻ってきたオブジェクトのエンコーディングが ASCII-8BIT になり、"prefix " + loaded のような連結操作が失敗していました。
技術的な変更
シリアライズ処理の中核は activesupport/lib/active_support/message_pack/extensions.rb にあり、MessagePackの型登録とそのコーデックを定義しています。今回の変更では、SafeBuffer の packer/unpacker をカスタムメソッドに置き換え、recursive: true フラグを追加しました。
変更前:
registry.register_type 18, ActiveSupport::SafeBuffer,
packer: :to_s,
unpacker: :new
変更後:
registry.register_type 18, ActiveSupport::SafeBuffer,
packer: method(:write_safe_buffer),
unpacker: method(:read_safe_buffer),
recursive: true
変更前の実装では packer: :to_s でバイト列を生のextensionバイトとして書き出していたため、エンコーディング情報が失われていました。変更後は以下の2つのヘルパーメソッドを介してシリアライズします。
def write_safe_buffer(buffer, packer)
packer.write(buffer.to_str)
end
def read_safe_buffer(unpacker)
ActiveSupport::SafeBuffer.new(unpacker.read)
end
packer.write(buffer.to_str) は生のバイト列ではなくMessagePackのネストされた文字列ペイロードとして書き出します。MessagePackの文字列型はUTF-8エンコーディングを仕様として保持するため、ロード時に unpacker.read で取り出した文字列はUTF-8エンコーディングを維持しています。その文字列を ActiveSupport::SafeBuffer.new でラップすることで、html_safe? の状態とUTF-8エンコーディングの両方が復元されます。
また、activesupport/test/message_pack/shared_serializer_tests.rb に回帰テストが追加され、ラウンドトリップ後のオブジェクトについて次の4点が検証されます。
-
SafeBufferのインスタンスであること -
html_safe?がtrueであること - エンコーディングが
Encoding::UTF_8であること - UTF-8文字列との連結が例外なく行えること
設計判断
生のextensionバイトからネストされた文字列ペイロードへの移行 が採用された点が重要です。MessagePackのextension型でバイト列を直接扱う場合、エンコーディングはバイト列に内包されないため、デシリアライズ時に元のエンコーディングを復元する手段がありません。一方、MessagePackの文字列型はUTF-8を規格上の文字エンコーディングとして扱うため、エンコーディング情報を別途持たせることなくラウンドトリップを保証できます。
recursive: true の追加も見逃せない点です。このフラグにより、packerとunpackerがMessagePackの型システムを再帰的に利用する形になります。HashWithIndifferentAccess の登録でも同様のパターン(recursive: true とカスタムpacker/unpacker)が使われており、今回の変更はその設計に倣ったものです。
まとめ
この変更は、シリアライズの実装詳細(生バイト列vs.文字列ペイロード)の選択がエンコーディング保持という動作保証に直結することを示しています。MessagePackの文字列型を活用してエンコーディング情報を暗黙に保持させるアプローチにより、変更量を最小限に抑えながら、キャッシュを経由したUTF-8文字列操作の信頼性が向上しました。