LWP::Simple::get() のハマりポイント

昔から動いているサイトがアクセスしている外部サイトのAPIが今夏に新バージョンになるということで、対応作業をしていました。私達のサーバサイド言語はPerlです。

新旧API、どちらもJSONを返すのですが、構造が若干異なっているという感じ。各アイテムのキー名にそれほど違いはなかったので、テンプレート側(デザイナー側)の負担を最小限にするために、新APIで叩いて返ってきたJSONの構造を少々変更して、テンプレート側には旧APIで叩いて今まで渡していたようなデータ構造をそのまま模してしまうことにしました。

この部分はJSON::XSでJSONテキストをハッシュリファレンスにしたものをひたすら組み換えるだけなので、APIのパターンが複数あるものの、それぞれ含めても一日二日あればできる範囲です。

コードを書いて手元の開発環境で動かしてみました。旧APIでは表示ができる。それを新APIにアクセスするようにして、データ組み換えコードを通るようにしてみたら文字化けが起こりました。組み換え部分では、枝葉のデータの文字コードは一切いじっていないのにです。

文字化けのパターンを見たら、UTF−8へのバイト列への変換を多重で行ってしまうパターンでした。ここまでわかると、根本的な原因は分からなくても対処法はわかります。

ざっくりと概要を抜き出すと、旧APIでは

JSON::XS->new->decode(...)

となっていた部分、新APIでは

 

JSON::XS->new->utf8->decode(...)

としたら文字化けしなくなりました。

対症療法ではこれでいいのですが、原因がわからないと気持ちが悪いです。調査してみました。

旧APIのコードは古かったので、リファクタリングをしたり新APIへのアクセスモジュールを書いて疎結合的に対応したかったのですが、納期が限られていたので新APIへの対応コードは旧APIへの対応コードをベースに書くことにしました。お仕事的な都合ですね。

外部サイトのAPIにアクセスしてJSONを取ってくる部分は、以下の様なコードとなっていました。

my $json = LWP::Simple::get($url);

その後のコードは文字コードにタッチしていないので、いろいろ調べた結果、名前の通りシンプルなことをしていないここが何かしているだろうという話になりました。

LWP::Simpleのgetのコードを見てみます。手元の最新版6.00でのお話です。

sub get ($)
{
 my $response = $ua->get(shift);
 return $response->decoded_content if $response->is_success;
 return undef;
}

$uaはLWP::UserAgentオブジェクトです。よって$responseはHTTP::Responseオブジェクト。

素直な感想としては $response->content じゃなくて $response->decoded_content なんだというところ。以前deflateされたコンテンツを扱う際に使ったことがある程度の知識でした。

HTTP::Responseのソースコードを読んでもdecoded_contentが無かったので、HTTP::ResponseのスーパークラスであるHTTP::Messageを読んでみたらdecoded_contentがありました。長いので抜粋しながら解説します。

sub decoded_content
{
 my($self, %opt) = @_;
 my $content_ref;
 my $content_ref_iscopy;

 eval {
 $content_ref = $self->content_ref;
 die "Can't decode ref content" if ref($content_ref) ne "SCALAR";

 if (my $h = $self->header("Content-Encoding")) {

想像通り、Content-Encodingのハンドリングを最初に始めています。

 

さらに読み進めていきます。

        if ($self->content_is_text || (my $is_xml = $self->content_is_xml)) {

Content-Encoding を見るところが終わったら、次はこんな条件節が出てきました。Content-Encoding以外のハンドリングをしているとは知らなかった。

 

HTTP::Messageは何も親クラスに持っていないし、content_is_textも見つからないなと思っていたのでこれって抽象クラスなのかなと思っていた(実際HTTP::Messageはベースクラスとして使われることを想定しています)のですが、AUTOLOADでハンドリングしていて、HTTP::Headersに処理を委譲しているようです。というわけでHTTP::Headersを見てみました。

sub content_is_text {
 my $self = shift;
 return $self->content_type =~ m,^text/,;
}

確かに明朗快活。

実際に新旧APIのヘッダどうなのと思ってwget -S で見てみたら、図星でした。

  • 旧API → Content-Type: text/html;charset=UTF-8
  • 新API → Content-Type: application/json;charset=UTF-8

というか旧APIはtext/htmlでJSONを返してきていたことが驚き。さすがAPI黎明期(?)に生まれたAPIだけある。

新APIになって、この $self->content_is_text にひっからなくなって挙動が変わったんだなーということが分かります。

HTTP::Message の decoded_content に戻ると、以下の様な条件節があります。

 if ($self->content_is_text || (my $is_xml = $self->content_is_xml)) {
 my $charset = lc(
 $opt{charset} ||
 $self->content_type_charset ||
 $opt{default_charset} ||
 $self->content_charset ||
 "ISO-8859-1"
 );

$self->content_type_charset は Content-Type ヘッダの中から charset= を探し出すもののようでした。これでtext/* の Content-Type であれば UTF-8 が拾われます。その後こうしています。

 else {
 require Encode;
 eval {
 $content_ref = \Encode::decode($charset, $$content_ref,
 ($opt{charset_strict} ? Encode::FB_CROAK() : 0) | Encod
e::LEAVE_SRC());
 };

Content-Type が text/*;charset=UTF-8 の場合は、ここでPerlの内部文字列にアップグレードされます。

旧APIのレスポンスヘッダはContent-Type: text/html;charset=UTF-8 だったのでここで知らずとアップグレードされていました。

新APIのレスポンスヘッダは、ここの条件節にはひっかからなかったので、ここでは処理されること無くバイト列で処理されることになります。

当初の部分に話を戻しますが、my $json = LWP::Simple::get($url) したものを JSON::XS->new->decode($json) していましたが、旧APIのほうはこれで内部文字列が渡るので内部文字列を持ったデータ構造となっており、テンプレートに渡す都合でこのJSONテキストからデコードしたハッシュリファレンスの構造をData::Recursive::EncodeでUTF-8のバイト列で構成されたものにしていました。

新APIだと、JSON::XS->new->decode($json) に渡される $json が前述の LWP::Simple の暗黙のお世話機能で内部文字列ではなくなってしまったので、Data::Recursive::Encode でUTF-8のバイト列を内部文字とみなしてさらにUTF-8のバイト列にしようとして文字化けを起こしていたのでした。この文字化けの起こり方は予想の範囲内でした。

解決策の一つとしては、新しいAPIでは JSON::XS->new->decode($json) ではなく JSON::XS->new->utf8->decode($json) とすることです。

そもそも、外部サイトの旧APIがJSONなのにContent-Typeとしてtext/htmlを返してくること、そしてLWP::Simple::get($url) が内部で HTTP::Response#content ではなく HTTP::Response#decoded_content を呼んでしまって余計な処理が入って、それを旧APIの処理プログラムを書いた人が受け取った文字列通りにData::Recursive::Encode->encode してしまったとか、色々なな部分に罠があったのでした。

JSONやHTTPに関わる部分は以下のようなことを守っていればハマりを回避できると思います。

  • LWP::Simple::get() は使わずにLWP::UserAgent#get を使ってHTTP::Response#contentを使ってレスポンスボディを取得する。HTTP::Response#is_success などでエラーハンドリングも書けるから。HTTP::Response#decoded_content を使いたい場合も、LWP::Simple::get() で暗黙で使うのではなく、明示的に使ったほうがよさそう
  • JSON::XS を使う際は、基本的にPerlの内部文字列としてdecodeすることを意識して JSON::XS->new->decode(…) ではなく JSON::XS->new->utf8->decode(…) と書く。このあたりはJSON関連モジュールでインターフェースは似たようなものの、挙動が微妙に違ったりするので、他のモジュールを使っている場合は注意する必要があるし、JSON::XSも意識して使う必要がありそう

まぁ、JSON APIの作成者が Content-Type: text/html でJSONを返すのはちょっと反則ですよね。Content-Type に text/json というものは無いっぽいので、application/json で返してきてくれると各種JSON処理モジュールや周辺関連でハマらず済むのかもしれません。逆にハマったら、JSON APIのContent-Typeに注目して、自分が使用しているHTTPクライアントモジュールを疑ってみるのもひとつの解決策だと思いました。

私は既存の旧APIへのアクセスモジュールを読みながら新APIへの対応を書いていてハマったという話で、新API自体が返す Content-Type は application/json となっていたので、API開発側のAPI設計も良い方向に向かっているのかもしれません。そんな新旧APIの差異でハマるとは思いもよりませんでしたけど、LWP::Simple::get() からの内部処理やハマリポイントなどが分かってちょっと勉強になりました。

Read More