UTF-8で動くRailsがShift_JISな外部システムと通信する方法

こんにちは。

メディアサービス開発部Webアプリケーション開発課のフサギコ(髙﨑)です。部署名が変わりました。

Ruby on Railsによるバックエンドの実装運用と、AWSによるサービスインフラの設計構築を中心とした、いわゆるテックリードのような立ち位置で働いています。

本記事では、UTF-8環境下で動くRailsがShift_JISな外部APIと通信する方法についてお話しします。

前提知識 文字コード

文字コードとは、文字をコンピュータ上で扱うにあたって、文字の字形とビット列の対応付けの方法を定める規格です。

「コンピュータ上で表現するべき文字と、文字ごとに一意な番号の組の集合」を定める符号化文字集合と、「符号化文字集合に定めた文字をビット列に変換する方法」を定める文字符号化方式の2段階に大きく分けられます。1

例えば、私の本名である髙﨑のうち、1文字目の

  • Unicode文字集合における番号: 9AD9
    • UTF-8で符号化したときのビット列: 0xE9AB99
    • 私の手元のlinuxにおいてUTF-16で符号化したときのビット列: 0xFEFF9AD92
    • UTF-16LEで符号化したときのビット列: 0xD99A
    • UTF-16BEで符号化したときのビット列: 0x9AD9
  • JIS X 0208における番号: 単独としては割当なし、ただしJIS包摂基準の145によってであると見なされる3
  • JIS X 0213における番号: 単独としては割当なし、ただしJIS包摂基準の145によってであると見なされる

2文字目の

  • Unicode文字集合における番号: FA11
    • UTF-8で符号化したときのビット列: 0xEFA891
    • 私の手元のlinuxにおいてUTF-16で符号化したときのビット列: 0xFEFFFA11
    • UTF-16LEで符号化したときのビット列: 0x11FA
    • UTF-16BEで符号化したときのビット列: 0xFA11
  • JIS X 0208における番号: 割当なし
  • JIS X 0213における番号: 1面47区82点
    • EUC-JIS-2004で符号化したときのビット列: 0xCFF2

となっています。 これらは、

irb(main):001:0> char = '﨑'
=> "﨑"
irb(main):002:0> char.encode(Encoding::UTF_8).unpack('H*').map(&:upcase)
=> ["EFA891"]
irb(main):003:0> char.encode(Encoding::UTF_16).unpack('H*').map(&:upcase)
=> ["FEFFFA11"]
irb(main):004:0> char.encode(Encoding::SHIFT_JIS).unpack('H*').map(&:upcase)
(irb):4:in `encode': U+FA11 from UTF-8 to Shift_JIS (Encoding::UndefinedConversionError)
        from (irb):4:in `<main>'                                                                                     
        from /home/fusagiko/.rbenv/versions/3.1.0/lib/ruby/gems/3.1.0/gems/irb-1.4.1/exe/irb:11:in `<top (required)>'
        from /home/fusagiko/.rbenv/versions/3.1.0/bin/irb:25:in `load'                                               
        from /home/fusagiko/.rbenv/versions/3.1.0/bin/irb:25:in `<main>'                                             
irb(main):005:0> char.encode(Encoding::EUC_JISX0213).unpack('H*').map(&:upcase)
=> ["CFF2"]
irb(main):006:0>

のようにして確かめられます。

逆方向は

irb(main):006:0> ["CFF2"].pack('H*').force_encoding(Encoding::EUC_JISX0213).encode(Encoding::UTF_8)
=> "﨑"
irb(main):007:0>

で確認できます。

UTF-8環境下で動くRailsがShift_JISな外部システムと通信するには

前提知識がだいぶ長くなってしまいましたが、本文に入ります。

今どきShift_JISな外部APIと通信するなんて事態ありうるのか、と思われる方もいるかもしれませんが、あります。

具体的な例は挙げませんが、長年インターネットを支え続けている尊敬すべきレガシーシステムには、今もShift_JISであることがAPI仕様書に明記されている場合があります。

Shift_JISな外部APIに対してPOSTリクエストする

べた書きとしては

これは単に下記のようにRubyのbundled gemであるNet::HTTPを使えば下記のように実現できます。

require 'net/http'

uri = URI.parse('http://localhost:1234/')
body = "sample例示".encode(Encoding::SHIFT_JIS)

Net::HTTP.post(uri, body, {'Content-Type' => 'text/plain; charset="Shift_JIS"'})
# => #<Net::HTTPNoContent 204 No Content readbody=true>

あるいは、RubyからHTTPリクエストする際に広く使われているFaraday gemで下記のようにしてもよいでしょう。

require 'faraday'

url = 'http://localhost:1234/'
body = "sample例示".encode(Encoding::SHIFT_JIS)

Faraday.post do |request|
  request.url url
  request.headers['Content-Type'] = 'text/plain; charset="Shift_JIS"'
  request.body = body
end

Content-Typeヘッダは当然ですがapplication/xmlなど、bodyに合わせた正しいものに変更してください。

本当にShift_JISで送信されているかについては、下記のようにnetcatで受信したものをnkfコマンドで変換して確認できます。

$ nc -l -p 1234
※ 別のコンソールから上記コードでリクエストを送信する

POST / HTTP/1.1
Content-Type: text/plain; charset="Shift_JIS"
Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept: */*
User-Agent: Ruby
Host: localhost:1234
Content-Length: 10

sample■■ ※ターミナルの文字コードはUTF-8で動作しているのでここが文字化けする
$

$ nc -l -p 1234 | nkf -S -w
※ 別のコンソールから上記コードでリクエストを送信する
※ コンソールで「HTTP/1.1 204 [Enter] [Enter]」と入力する

POST / HTTP/1.1
User-Agent: Faraday v1.10.0
Content-Type: text/plain; charset="Shift_JIS"
Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept: */*
Host: localhost:1234
Content-Length: 10

sample例示
$

Railsにおいては

上記のようなコードを実行するClientクラスを作り、Railsからはそれを使うのが良いのではないでしょうか。

class HogeClient
  API_URL = 'http://example.com/hoge_api'.freeze

  # Ruby2.7で導入された引数を全て転送する記法 
  # https://docs.ruby-lang.org/ja/latest/doc/news=2f2_7_0.html#:~:text=%E8%BB%A2%E9%80%81%E3%81%99%E3%82%8B%E8%A8%98%E6%B3%95%E3%80%8C-,(...),-%E3%80%8D%E3%81%8C%E5%B0%8E%E5%85%A5%E3%81%95
  def self.call(...) 
    new(...).call
  end

  def initialize(body)
    @body = body
  end

  def call
    response = request!
    parsed_response = parse_response(response)
    build_result(parsed_response)
  end

  def request!
    Faraday.post do |request|
      request.url API_URL
      request.headers['Content-Type'] = 'text/plain; charset="Shift_JIS"'
      request.body = @body.encode(Encoding::SHIFT_JIS)
    end
  end

  def parse_response(response)
    # レスポンスを解釈する
  end

  def build_result(parsed_response)
    # Clientの利用元で使いたい感じに解釈済みレスポンスを加工する
  end
end

このようにすれば、テストにおいてもallow(HogeClient).to receive(:call).and_return(mocked_result) などとモックしてテストできます。

Shift_JISで使用できない文字が混ざっていないかをバリデーションする

さて、前節でShift_JISな外部APIに対してPOSTリクエストするClinetクラスを作成しました。……といっても「前提知識 文字コード」の節でお話した通り、UTF-8とShift_JISでは取り扱える文字に違いがあります。

Shift_JISで取り扱えない文字を送信する前に検知できるように、事前にバリデーションする必要があるでしょう。

そのために、指定した文字コードで扱えない文字が混ざっているかをバリデーションするEachValidatorを作成します。

class CharSetValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    return if value.nil? || value.to_s.empty?

    value.to_s.encode(options[:encoding])
  rescue Encoding::UndefinedConversionError
    record.errors.add(attribute, :unavailable_character)
  end
end

上記のコードからわかる通り、Stringクラスのencodeメソッドは変換できない文字を含む場合にEncoding::UndefinedConversionErrorをraiseします。 これをrescueしてあげればレコードのerrorsにバリデーションエラーを追加できます。

EachValidatorを実装したからにはspecを書かねばなりません。 このときに便利だったのが僕の本名で、冒頭に書いた通り髙﨑はどちらもJIS X 0208の第一水準漢字、第二水準漢字には含まれず、2文字目のはJIS X 0213の第三水準漢字に含まれています。

また、Windowsでは以前から髙も﨑も入力できましたが、これはMicrosoftによるShift_JIS実装であるWindows-31J(CP932とも)の独自拡張4であって、JIS X 0208の規格には準拠していません。

そのため、Windows上ではシフトJISと表記されながらも実際にはWindows-31Jであったりするなど、そのシフトJISはどのシフトJISなのか、すなわち第二水準漢字までしか許されない真のShift_JIS5なのか、Windows-31Jなのか、はたまたJIS X 0213にまで対応するShift_JIS-20046なのかを言及された場所に応じて注意深く確認する必要があります。

注意しなければならない点として、Rubyが扱える文字エンコードを表すEncodingモジュールにはEncoding::SHIFT_JISとEncoding::SJISがそれぞれ定義されていますが、Encoding::SJISはEncoding::Windows_31Jの別名となっています。したがって、真のShift_JISを表すのはEncoding::SHIFT_JISのみです。

また、はJIS包摂基準の145によってであると見なすとされていますが、StringクラスのencodeメソッドはEncoding::UndefinedConversionErrorをraiseします。これは変換、逆変換した際に正しくへ戻せないためにこうしているようです。

あとはこのCharSetValidatorを使って実際にバリデーションすればいいわけですが、前節で作成したClientクラスにActiveModel::Modelをincludeしてその場でバリデーションするか、別途リクエストを表現するActiveModel::Modelをincludeしたクラスを作成するかはプロダクトの実装方針に合わせればよいでしょう。 もちろん、ActiveRecordモデルでも使えます。

validates :body, char_set: {encoding: Encoding::SHIFT_JIS}

Shift_JISな外部システムからPOSTリクエストを受ける

接続する外部システムによっては、POSTリクエストによるコールバックを受ける場合もあります。

文字コードとしてShift_JISが指定されるような歴史の長いシステムから来るPOSTによるコールバックの場合、リクエストペイロードはjsonではなくapplication/x-www-form-urlencodedなFormDataや、XMLなどが多いでしょう。 私が実装したのは前者のapplication/x-www-form-urlencodedなFormDataの場合でした。

そのような場合に使えるのが、ActionController::ParameterEncodingモジュールで実装されているparam_encodingメソッドです。

下記のようにして使います。

class HogeController < ActionController::Base
  param_encoding :create, :shift_jis_japanese_param, Encoding::SHIFT_JIS
end

class FugaController < ActionController::API
  include ActionController::ParameterEncoding
  setup_param_encode
  
  param_encoding :create, :shift_jis_japanese_param, Encoding::SHIFT_JIS
end

ActionController::Baseではデフォルトでincludeされていますが、ActionController::APIではそうではないため、includeしたうえでsetup_param_encodeメソッドを呼ばねばなりません。

ともあれこうすることで、param_encodingメソッドで指定したパラメータのEncodingがShift_JISになるため、これをStringクラスのencodeメソッドを使ってEncoding::UTF_8に変換すれば、あとはいつも通り扱えるようになります。

RequestSpecでは、

  subject do
    post hoge_path, params: params
  end

  let :params do
    {
      shift_jis_japanese_param: "あかさたな",
    }.transform_values { _1&.encode(Encoding::SHIFT_JIS) }
  end

などとすればテストできます。この状態でコントローラ側のparam_encodingメソッドをコメントアウトしてみると、Invalid request parameters: Invalid encoding for parameterといったようなエラーでテストが失敗するのを確認できるはずです。

おまけ: ユーザのブラウザにShift_JISでPOSTさせる

場合によっては、RailsではHTMLとしてレンダリングし、ユーザのブラウザからShift_JISでPOSTして遷移させる場面もあるでしょう。

そのような場合には、formタグのaccept-charset属性を使います。 これにshift_jisと指定すれば、formのsubmitボタンを押したとき、自動的にShift_JISへ変換してPOSTしてくれるようです7

まとめ

本記事では、UTF-8環境下で動くRailsがShift_JISな外部システムと通信する方法についてお話ししました。

私たちが日本語を話し、日本語で文章を書く以上、こういった文字コードと無関係ではいられない場面もときにはあります。 全世界的にUnicodeになりつつある昨今、文字コードの深遠を覗き込むのはなかなか稀ですが、それでも覗き込まねばならなくなった場合に参考にしていただければ幸いです。

本記事の内容は僕が実装した当時にruby-jp Slackでご相談させていただいた内容に基づくものが含まれています。 当時相談に乗っていただいた皆様に感謝申し上げます。

ブックウォーカーでは物理・電子・Web連載問わず漫画や本が好き、あるいはこういったバリデータやテストをしっかり書いて、より安心してサービスに注力できる状態を作り上げることに興味があるWebアプリケーションエンジニアを募集しています。

興味がありましたらぜひ、採用情報ページからご応募ください。

www.bookwalker.co.jp


  1. Unicodeでは更に細かく分けられ、またJISやISOでは文字符号化方式を符号化文字集合に付随するものとして扱ったりしていますが、本記事ではそこまでは扱いません

  2. ただし先頭のFEFFはエンディアンを判別するためのものであって、それを取り除けばUTF-16LEとUTF-16BEのどちらかと同じになる。

  3. JIS X 0208と0213規格票の包摂関連項目(青空文庫)

  4. その独自拡張は元をたどるとIBMとNECの独自拡張に概ね由来しているのでMicrosoftのせいというわけでもない……

  5. 余談ですが、健康診断に行くと本名の苗字が二文字とも**と表示されたりして、(この病院の電子カルテは真のShift_JISなんだな)というのがわかったりして面白いです

  6. ちなみにJIS X 0213の文字集合を扱える符号化方式のうちRubyが対応しているのはEUC-JIS-2004のみであって、Shift_JIS-2004には対応していません。もっとも、世界的にUnicodeになりつつある昨今、JIS X 0213の文字集合に対応した文字符号化方式を扱う機会が稀すぎますが……

  7. あまりにも簡単すぎて、その裏のブラウザ開発者の方々の苦労がしのばれます……