Qiita株式会社 Advent Calendar 2021の14日目の担当は、Qiita株式会社CX向上グループの@kyntkです!
はじめに
タイトルにある変更が対応されたPull Requestはこちらです。
前提
Faradayに2012年に以下のissueが作成されているように、FaradayでContent-Typeがtext/html; charset=utf-8
のようなレスポンスが返ってきても、response.body
の文字コードはASCII-8BIT
になっています。
そのため、response.body.encoding
では実際のbodyの文字コードがわからないので、特定の文字コードに変換したいときには初めにbodyの文字コードを判定しないとEncoding::UndefinedConversionError
が発生する可能性がありました。
Qiitaでは記事内にリンクカードを埋め込む際に、リンク先のタイトル等の情報をHTMLから取得していたのですが、一部文字化けを発生させてしまっていました。
結論
- Faraday Net::HTTP adapter v2.0.0.alpha-2 以降ではContent-Typeに応じた文字コードがセットされるようになった (現状まだアルファリリース)
- HTMLのcharsetに文字コードが設定されていても、Content-Typeに記されていない場合は
ASCII-8BIT
と判定されてしまうので、厳密にチェックが必要な場合は別途文字コードを確認してforce_encodingする必要がある
検証
ここからは動作の検証をしていきますが、文字コードなどについての補足情報を後半に乗せていますので、そちらもご覧ください。
事前準備
複数の文字コードで検証したいので、簡易的にSinatraでサーバーを立ててHTMLを返すようにします。
実際のコードは以下のリポジトリにおいてあります。
検証では、以下の3パターンを用意しました
- HTMLのcharset="utf-8"で、Content-Typeに"utf-8"が含まれている
- HTMLのcharset="shift-jis"で、Content-Typeに"shift-jis"が含まれている
- HTMLのcharset="shift-jis"で、Content-Typeに"shift-jis"が含まれていない
get '/utf8' do
slim :utf8
end
get '/shift-jis' do
headers \
"Content-Type" => 'text/html;charset=shift_jis'
slim :shift_jis
end
get '/shift-jis-no-charset' do
headers \
"Content-Type" => 'text/html'
slim :shift_jis_no_charset
end
Faradayの設定
実装したコードはこちらです。
require 'faraday'
require 'faraday/net_http'
Faraday.default_adapter = :net_http
conn = Faraday.new(url: 'http://localhost:4567') do |f|
f.response(:logger)
end
response = conn.get('/utf8')
注意点としては、Faraday 2.0から、アダプターが本体から切り離されて、別途gem installの必要があることです。
また、v1では default_adapter
が:net_http
でしたが、:test
に変わっています。 (以下のUPGRADING.mdはv2.0.0.alpha-3のものを参照)
そのため、Gemfileに、faraday-net_http
を記載してインストールをすることが必要で、
gem 'faraday', '2.0.0.alpha-3'
+ gem 'faraday-net_http', '2.0.0.alpha-2'
さらにFaradayでリクエストをする前に以下のようにdefault_adapter
の記述が必要になります。
require 'faraday'
+ require 'faraday/net_http'
+ Faraday.default_adapter = :net_http
conn = Faraday.new(url: 'http://localhost:4567') do |f|
Faraday 2へのアップデートに伴う変更はこちらで別途まとめましたので、こちらもご覧ください。
動作検証
上記で作成した3つのエンドポイントに対してリクエストを行い、response.bodyの文字コードをUTF-8に変換します。
response = conn.get('/shift-jis')
puts response.body.encoding
puts response.body.encode('UTF-8')
version1の場合
3パターンとも、response.body.encoding
がASCII-8BIT
となっているため、encodeの処理でEncoding::UndefinedConversionError
が発生してしまいました。
ログは一部加工、抜粋しています。
[DEBUG] request /utf8
I, [2021-12-06T08:04:12.927085 #46311] INFO -- request: GET http://localhost:4567/utf8
I, [2021-12-06T08:04:12.952143 #46311] INFO -- response: content-type: "text/html;charset=utf-8"
[DEBUG] response.body.encoding, ASCII-8BIT
faraday_test.rb:12:in `encode': "\\xE6" from ASCII-8BIT to UTF-8 (Encoding::UndefinedConversionError)
from faraday_test.rb:12:in `<main>'
[DEBUG] request /shift-jis
I, [2021-12-06T08:04:55.299555 #46671] INFO -- request: GET http://localhost:4567/shift-jis
I, [2021-12-06T08:04:55.330637 #46671] INFO -- response: content-type: "text/html;charset=shift_jis"
[DEBUG] response.body.encoding, ASCII-8BIT
faraday_test.rb:17:in `encode': "\\x95" from ASCII-8BIT to UTF-8 (Encoding::UndefinedConversionError)
from faraday_test.rb:17:in `<main>'
[DEBUG] request /shift-jis-no-charaset
I, [2021-12-06T08:05:44.666735 #47028] INFO -- request: GET http://localhost:4567/shift-jis-no-charaset
I, [2021-12-06T08:05:44.690953 #47028] INFO -- response: content-type: "text/html"
[DEBUG] response.body.encoding, ASCII-8BIT
faraday_test.rb:22:in `encode': "\\x95" from ASCII-8BIT to UTF-8 (Encoding::UndefinedConversionError)
from faraday_test.rb:22:in `<main>'
version2の場合
faradayとfaraday-net_httpのバージョンを2にupdateして検証します。
すると、Content-Typeが設定されているときは適切にbodyの文字コードが設定されているため、encodeにも成功しています。
[DEBUG] request /utf8
I, [2021-12-06T08:07:58.076577 #47801] INFO -- request: GET http://localhost:4567/utf8
I, [2021-12-06T08:07:58.105417 #47801] INFO -- response: content-type: "text/html;charset=utf-8"
[DEBUG] response.body.encoding, UTF-8
[DEBUG] response.body
<!DOCTYPE html><html lang="ja"><head><title>charaset utf-8</title><meta charset="utf-8" /></head><body><p>文字コードは UTF-8 です ☺</p></body></html>
[DEBUG] request /shift-jis
I, [2021-12-06T08:07:58.106100 #47801] INFO -- request: GET http://localhost:4567/shift-jis
I, [2021-12-06T08:07:58.113733 #47801] INFO -- response: content-type: "text/html;charset=shift_jis"
[DEBUG] response.body.encoding, Shift_JIS
[DEBUG] response.body
<!DOCTYPE html><html lang="ja"><head><title>charaset Shift_JIS</title><meta charset="shift-jis" /></head><body><p>文字コードは Shift_JIS です </p></body></html>
ただし、設定されていない場合は依然としてEncoding::UndefinedConversionError
が発生してしまいました。
そのため、レスポンスヘッダーのContent-Typeに文字コードが設定されていなくても厳密にHTMLの文字コードを判定したい場合は、body内に含まれるcharsetの記述を参照して、force_encodingをする必要があります。
[DEBUG] request /shift-jis-no-charset
I, [2021-12-06T08:07:58.114818 #47801] INFO -- request: GET http://localhost:4567/shift-jis-no-charset
I, [2021-12-06T08:07:58.120169 #47801] INFO -- response: content-type: "text/html"
[DEBUG] response.body.encoding, ASCII-8BIT
faraday_test.rb:22:in `encode': "\\x95" from ASCII-8BIT to UTF-8 (Encoding::UndefinedConversionError)
from faraday_test.rb:22:in `<main>'
改めて、結論
- Faraday Net::HTTP adapter v2.0.0.alpha-2 以降ではContent-Typeに応じた文字コードがセットされるようになった (現状まだアルファリリース)
- HTMLのcharsetに文字コードが設定されていても、Content-Typeに記されていない場合は
ASCII-8BIT
と判定されてしまうので、厳密にチェックが必要な場合は別途文字コードを確認してforce_encodingする必要がある
補足
検証は以上となりますが、調査をしていく中で文字コードについての背景知識が必要だったので、自分の整理も含めてまとめます。
基本的にはこちらの本を読んだのですが、やはり本を読んで体系的に学ぶのが一番いいと思いました。
文字コードとは
文字を扱うとき、コンピュータはその文字に振られた番号(バイト表現)を処理していますが、この番号や、番号と文字との対応関係のことを文字コードといいます。
このように文字を、対応する番号である文字コードで表現することを符号化といいます。
そして、表現したい文字列の集まりを文字集合といい、その文字の集合に符号を振ったものを符号化文字集合といいます。
単語が多いですが、「ASCII 文字コード表 」などでググってもらえば符号化文字集合のイメージがつくと思います。
UTF-8やShift_JIS、EUC-JPといったものも文字集合の一種です。
ただし、同じ文字であっても文字集合が異なれば符号が異なることがあるので、これを変換する必要があります。
たとえば以下のように、「あ」という文字の符号はUTF-8、Shift_JIS、EUC-JPで異なっています。
# UTF-8
irb> 'あ'.encode('UTF-8').bytes.map { _1.to_s(16).upcase }
=> ["E3", "81", "82"]
# Shift_JIS
irb> 'あ'.encode('Shift_JIS').bytes.map { _1.to_s(16).upcase }
=> ["82", "A0"]
# EUC-JP
irb> 'あ'.encode('EUC-JP').bytes.map { _1.to_s(16).upcase }
=> ["A4", "A2"]
# 同じものもある
irb> 'a'.encode('UTF-8').bytes.map { _1.to_s(16).upcase }
=> ["61"]
irb> 'a'.encode('Shift_JIS').bytes.map { _1.to_s(16).upcase }
=> ["61"]
irb> 'a'.encode('EUC-JP').bytes.map { _1.to_s(16).upcase }
=> ["61"]
これによって、ある符号を異なった文字コードとして解釈してしまうといわゆる文字化けが起きます。
たとえば、Shift_JISで「文字コード」と書かれた文字をUTF-8と解釈してブラウザで表示をしてみると、
このように読めない文字になってしまいました。
外部コードと内部コード
プログラムの中では、外部から入力された文字列を処理することもありますが、この際に入力する文字コードとプログラム内部で使っている文字コードが異なると、これも文字化けを引き起こす要因になります。そのため、外部からの入力の際に、内部で扱っている文字コードへの変換が必要となります。また逆に内部から外部への出力の際にも変換を行う必要があります。
Code Set Independent (CSI) 方式
内部コードは多くのプログラミング言語は1つの文字コードで処理することが多いのですが(UCS方式)、Rubyでは先程の例のように1つのアプリケーション内で複数の文字コードを使えるようになっています。これをCSI方式といいます。Rubyでは文字列ごとに文字コードの情報を持っています。
RubyがなぜCSI方式を採用したのかはこちらが詳しいです。
Content-Type
Content-TypeはHTTPレスポンスヘッダーの一つで、リソースのメディア種別を示しますが、ここでcharset
を使用して文字コードを示すことができます。今回の例ではHTMLを取得したときにその文字コードを判定するために参照しています。
ASCII-8bit
もう一つ補足しておきたいのはFaradayのレスポンスでデフォルトで設定されているASCII-8bit
についてです。
によると、以下のように書かれています。
Encoding::ASCII_8BIT is a special encoding that is usually used for a byte string, not a character string. But as the name insists, its characters in the range of ASCII are considered as ASCII characters. This is useful when you use ASCII-8BIT characters with other ASCII compatible characters.
DeepL翻訳
Encoding::ASCII_8BIT は特殊なエンコーディングで、通常は文字列ではなくバイト文字列に使用されます。しかし、その名が示すように、ASCIIの範囲内にあるその文字はASCII文字とみなされます。これは、ASCII-8BITの文字を他のASCII互換の文字と一緒に使うときに便利です。
というASCII-8bit
は特殊な文字コードのようです。
****## 参考
もっと詳しく知りたい人は、それぞれの文字集合の違いや、文字化けについてなど詳しく說明されているので、ぜひこちらの本を読んでみてください。
終わりに
明日のQiita株式会社 Advent Calendar 2021は、@WakameSun が担当しますのでお楽しみに!