memorandums

日々の生活で問題解決したこと、知ってよかったことなどを自分が思い出すために記録しています。

WEBrickでHTTPリクエストにマルチバイト文字列が入っていた場合の挙動について

(注意)本エントリーは受講生向けの説明です。

RubyでのWebアプリ開発の演習で以下のコードがありました。簡単にしています。

HTMLフォームに入力された文字列をRubyで受け取って表示するという基本的なプログラムです。

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8"/>
</head>
<body>
  <form action="/aaa">
    <input type="submit" name="btn" value="決定"/>
  </form>
</body>
</html>
require 'webrick'
config = {
    :Port => 8080,
    :DocumentRoot => '.'
}
server = WEBrick::HTTPServer.new( config )
server.mount_proc( "/aaa" ) do |req, res|
  res.body = <<EOF
<html>
<head>
  <meta charset="utf-8"/>
</head>
<body>
  ボタンのラベルは #{req.query["btn"]}"
</body>
</html>
EOF
end
trap(:INT) do
    server.shutdown
end
server.start

で、このコードを実行すると以下のエラーが表示されます。rubyの8行目はres.bodyに代入しているところです。

ERROR Encoding::CompatibilityError: incompatible character encodings: UTF-8 and ASCII-8BIT
s.rb:8:in `block in

'

ここで何が起きているか、です。

html側ではutf-8を指定しています。submitボタンを押すと、ボタンのラベルである「決定」のutf-8でのバイト列がクエリ文字列として付加されて、rubyに渡されます。

決定ボタンを押下したとき、Chromeのアドレスバーには確かに「http://localhost:8080/aaa?btn=決定」とありました。ちなみに、このアドレスをテキストエディタなどに貼り付けると「http://localhost:8080/aaa?btn=%E6%B1%BA%E5%AE%9A」と表示されます。

これは何か?以下が参考になります。

qiita.com

HTTPでやり取りするときのデータに使える文字は予め規定されているんですね。日本語などのマルチバイト文字列はそのままでは送れません。そこで、マルチバイトの文字コードをASCII文字を使って表現するように変換してから送る必要があるんですね。これをURLエンコードっていって、その逆はデコードになります。

例えば、上記のようにUTF-8で「決定」の文字コードは「E6 B1 BA E5 AE 9A」となります。なのでこれを変換するとバイトの切れ目に%をいれて、%E6%B1%BA%E5%AE%9Aという風になるっていうわけなんですね。ここまではWebブラウザがしてくれる仕事になります。

で、このデータをサーバであるrubyで受け取るとWEBrickでクエリー文字列を処理してくれます。つまり、「%E6%B1%BA%E5%AE%9A」を「E6 B1 BA E5 AE 9A」に戻してくれるはずということです。

実際にソースを読んでみましょう。

WEBrickのソースはググったらすぐに見つかります。

github.com

探すとparse_queryというメソッドで処理しているようで、今回はGETメソッドなので、赤線部が実行されているはずです。

f:id:ke_takahashi:20190530125822p:plain

HTTPUtilsモジュール内のparse_queryってのがあるようですね。追います。ほんと、読みやすいコードです。①で区切り文字&で分割して、②でキーと値に分割し、③でキーと値のそれぞれをunescape_formしています。

f:id:ke_takahashi:20190530130216p:plain

unescape_formの中ではさらに以下の_unescapeメソッドを呼んでいます。str.bはこちらによるとASCII文字化するメソッドです。ちなみに、「%E6%B1%BA%E5%AE%9A」はすべてASCII文字のため変化はありません。

続いて、gsubは置換メソッドですね。regexは引数で与えられます。追跡すると「/%([0-9a-fA-F]{2})/」とありました。つまり%始まりで16進数の文字が2つ連続したらマッチするという正規表現になりますね。gsub!なので破壊的命令になりますのでstrの内容が置き換わります。gsubには後方にブロックが与えられていますので、マッチするたびにこのブロックが実行される感じですね。hexは文字列を16進数とみなして数値に変換する、chrは数値をASCII文字に変換するメソッドになります。

したがって、例えば、先頭の「%E6」がマッチしたとすると$1はE6になり、hexを取ると230(10進数)で、それをASCII文字にすると¥xE6になるっていう感じですね。イディオムなんでしょうね。。。%E6は3バイトの文字列ですので、それをバイナリの1バイトに変換する感じです。勉強になります。ちなみに「%」はどこ行った。。。て思っていろいろと実験してみたのですがよくわかりませんでした。マッチしたなら$1の値がE6ではなく%E6になっていいと思うのですが。。。都合よくできています。メタ文字かと思いましたがそうでもない。。。時間があるときにまと調べます。

def _unescape(str, regex)
  str = str.b
  str.gsub!(regex) {$1.hex.chr}
  # encoding of %-unescaped string is unknown
  str
end

という具合に、とりあえずマルチバイトを含んだクエリー文字列がHTTPを通過して無事にサーバー側のrubyでASCII文字として受け取ります(ここで元の文字コードがわかれば変換するときにその文字コードのStringとして復活することができるんでしょうけど。。。そういう仕組みはないようです)。

で、問題になるのが、上記のrubyコードのうち以下の行になります。ruby自体はマジックコメント指定していないのでデフォルトのutf-8扱いになります。つまり「ボタンのラベルは」はutf-8の文字列になるかと思います。しかし。。。#{req.query["btn"]}の内容は「決定」でありますが、上記の変換の結果、ASCIIコードのStringになってしまいます。

ボタンのラベルは #{req.query["btn"]}"

ここでさらに実験。utf-8のStringとASCIIのStringの結合をしようとすると。。。

"決定".force_encoding("ASCII") + "決定".force_encoding("utf-8")

Encoding::CompatibilityError (incompatible character encodings: US-ASCII and UTF-8) というエラーが表示されて実行がストップします。

つまり、req.queryで得られる文字列にutf-8等のマルチバイト文字列が含まれている場合に、force_encoding("utf-8")をつけまくらなければならなくなる。。。という話になります。プロの方のコードもこんな感じに作るんですかねぇ。。。

こんなアホなことがあるのか。。。と思い、ぐぐってみたのですが、どうもそれしか解法がないようで。。。いや。。。たぶん私の理解不足だけなんだと思うのですが。。。暗黙の型変換はしない主義から来ているのでしょうか。

ちなみに、utf-8の文字列と結合せずに、req.queryをそのままクライアント側に返すのであればエラーは生じません。すべてASCII文字だから、ASCII文字同士の連結に不都合はないからですね。。。わかるような気がします。

require 'webrick'
config = {
    :Port => 8080,
    :DocumentRoot => '.'
}
server = WEBrick::HTTPServer.new( config )
server.mount_proc( "/aaa" ) do |req, res|
  res.body = <<EOF
<html>
<head>
  <meta charset="utf-8"/>
</head>
<body>
#{req.query["btn"]}
</body>
</html>
EOF
end
trap(:INT) do
    server.shutdown
end
server.start

とりあえずやったことのメモでした。これをどう説明するか。。。説明する必要があるのかないのか。。。迷うところです。でも、もし受講生がエンジニアとして働くようになったら必ず出会う問題ですね。文字コードっていうやつは。

こまた。

何かいい解法、もしくは教授法をご存知の方がいらっしゃいましたら教えてください。