2017年12月19日火曜日

HTTP/1.1 Transfer-Encoding: chunked

Web Scraping 用のプログラムを組んでいて、HTTP レベルの Socket プログラムを自前で組むか、Client レベルの処理は言語環境のライブラリーに任せて HTML を解析・処理する程度のものを組むかという、二択問題によく悩む。これまでの経験上、後者の方が敷居が低く取りかかるのに容易であるというメリットがある。一方、一旦何らかの壁にぶち当たると、ブラックボックスのない前者の環境で試行錯誤を進めた方が、心理的にもまさしく“急がば回れ”の典型のような結果となり、トータルで楽になる。

そんなわけで、今回取り組んでいる物件についても、スタートアップは Java の URLConnection で一通り組み上げてしまって、それで問題なく全体的に動くものが出来上がっていたので、そのまま運用していたものだった。ゼロ状態からのスタートとなるプロジェクトでは、まずはどんな形であれ、全体としてちゃんと動くものへと到達できるかどうかが不明な状態なので、「まずは全体としてちゃんと動くもの」まで辿り着くことが何よりも重要である。そのためには、Client レベルのものは Java のコアライブラリーに任せて、Web Scraping にターゲットを絞った方が確実にプロジェクトを成功まで推進することができる。

それでそのまま動くようになったままで運用し続けていて、プログラムには手を入れていなかったのだが、今度、Lanterna を使って GUI 化したりして、使い勝手が向上したので、さらに前から欲しいと考えていた機能を追加したりした。最終的には GUI 化したらしたで CUI の時は気にならなかった処理速度が気になったので、ネットワークからのデータの読み込みをマルチスレッド化するところまで行き着いた。

このネットワーク読み込み処理のマルチスレッド化で、ある問題が表面化したのだった。

どうやら Java の URLConnection は、スレッドセーフではないようなのだ。とはいっても、このプロジェクトに関して言えば、Cookie がスレッド間で共有されていることが問題の核心であり、それを URLConnection 自体がスレッドセーフではないという話に含めていいのかどうかはわからない。ともかく、Cookie が上書きされてしまうのが原因で、サーバー側が付与する JSESSIONID に一貫性がなくなり、問題が発生することとなった。

URLConnection による Client 機能は Cookie なども宜しく処理してくれるので便利だが、一旦、Cookie を自前で操作したいとなると、逆に厄介だ。また、Apache のライブラリーを使えば、スレッドセーフに設計されているらしいが、こちらについては、Android の Java では Apache のネットワークライブラリーは非推奨であるという理由で、依存したくないので見送った。

そこで結局は HTTP レベルにフォールバックして Socket プログラムを自前で組むという方針にすることにした。

そもそも、Client レベルのものを言語環境のライブラリーに任せる場合の大きなメリットは

  1. Cookie を自前で処理する必要がなくなる
  2. HTTPS 対応を自前で行う必要がなくなる
  3. Transfer-Encoding: chunked を自前で処理する必要がなくなる

ではないかと思う。これまで、Cookie については面倒なだけで、必要に迫られた場合は、自前で HTTP レベルで処理する方策を採ってきた。HTTPS は調べてみたら SSLSocket を使えば Socket とほぼ同様に使えるので、意外と敷居は高くなかった。ところが chunked についてはお手上げで、自分が可能なことならば HTTP クライアントを自前で組むことを避ける主な理由はこれだった。

chunked の場合、HTTP/1.0 にして逃げることもできるので、無理に立ち向かう必要はない。ところで今回、HTTP/2.0 も使われ出したことだし、さすがに HTTP/1.0 に逃げるという消極的な方策はあまり気持の良いものでもないように感じた。それで今回は最後の壁である chunked へと立ち向かうことにした。

――で、chunked 対応な HTTP クライアントプログラムを自前で組んでみたのだが、なぜかセオリー通りに動作しない。何をやっても chunk のサイズ計算が合わないのである。レスポンスが Shift_JIS とかいう最悪の糞エンコーディングのせいだからかな? と思って色々試してみるも打破できない。行単位で処理する BufferedReader ではなく、 char 単位で処理する InputStreamReader で扱うようにしても改善せず。最後には byte 単位で処理する InputStream で直接扱っても改善しない。なぜかどうやっても、chunk サイズで指定された通りのサイズを読み込んでも、読み込まれるデータが chunk サイズよりも小さくなるのである。byte 単位なので、Shift_JIS による影響は完全に断てている。なのにこれは何なのか? 手は尽したのに、これではお手上げだ。

それでふと思い付いて chunk サイズをわざと +1000 してみたら、どうなるか試してみた。これが決定打だった。なんと chunk サイズを多くしても、全く読み込まれるデータが変わっていないのである。データが同じ場所でブツ切れている。

chunk サイズの問題ではないことに最後に行き着いて、それがどうやら、InputStream からのデータの読取に起因することだということが判明した。chunk サイズに相当する byte 配列を一つ用意して、それを inputStream.read(buffer) で一発で読み取ろうとしていたが、その処理でブツ切れになってしまっていたのだ。byte 配列を 1 バイト分だけ用意して、chunk サイズ分ループで回して 1 バイトずつ読み取ったら、完全に問題はクリアされた。

Java 以前には Perl がメインだったこともあって、ファイル IO 関係は一番感覚的に不慣れさが残っていたジャンルである。Perl の場合はデータはバイナリーモード(Java の byte に相当)か、文字モード(Java の char に相当)か程度で、一文字か文字列かという区別はなく、もちろん、バッファどうのということを気にすることもなく、とてもシンプルで苦労がなかった。そのため、Java を使うようになってからも、ファイル IO 関係は半ば「おまじない」的にコードを記述する感覚で、なぜ InputStream → InputStreamReader → BufferedReader と多段階になっているのか、不便な感じがする程度で深く検証することを避けていた。そのため、このようにデータの読み込みが半端な状況に陥いるなどというアイデアは最後になるまで想定できなかったのである。こういう経験で反対に、Perl の場合も、ファイル IO に関しては、while(<>) という記述をすることの由来がわかった。ところが Perl は大体が言語処理系側が宜くやってくれるので、記述上は配列に一気に読み込んでも、このようなブツ切れになってしまう羽目にはならなかったわけだが。Perl と比較すると、たまに C 言語の不便さが断片的に残っているのが Java なのかもしれない。それで Kotlin なんて話が出てくるのだろう。

0 件のコメント:

コメントを投稿