現在地: ホーム Dive Into Python 3

難易度: ♦♦♦♦♢

HTTPウェブサービス

かき乱れた心で眠れば、枕も落ち着かなくなる。
— シャーロット・ブロンテ

 

飛び込む

HTTPの命令だけを用いて遠隔にあるサーバーとデータをやりとりする — HTTPウェブサービスの概念はこの33文字に集約される。サーバーからデータを取得したければ、HTTP GETを使えばいい。サーバーにデータを送信したければHTTP POSTを使えばいい。もっと高度なHTTPウェブサービスのAPIもあって、これらはHTTP PUTHTTP DELETEを利用して、データの作成・修正・削除を行えるようにしてくれる。そう、本当にHTTPの命令だけで全て処理できるのだ。ここにはレジストリも、エンベロープも、ラッパも、トンネリングも必要ない。要するに、HTTPプロトコル内に構築されているこれらの「動詞」(GET, POST, PUT, DELETE) は、データの取得・作成・修正・消去を行うアプリケーションレベルの命令に直接対応するものなのだ。

このやり方の主たる利点はその単純さにあり、またこの単純さゆえに広く使われているのだ。データ — 普通はXMLJSON — は静的に構築して保存することもできるし、サーバーサイドスクリプトを使って動的に生成することもできる。そして、メジャーなプログラミング言語はすべて(もちろんPythonも!)このデータをダウンロードするためのHTTPライブラリを備えている。さらに、この方式だとデバッグも容易だ。というのも、HTTPウェブサービスの個々のリソースにはユニークなアドレスが(URLの形式で)割り振られているため、ウェブブラウザにロードすればすぐに生のデータを見ることができるからだ。

HTTPウェブサービスの例:

Python 3にはHTTPウェブサービスと情報をやりとりするためのライブラリが二つ用意されている。

じゃあ、どちらを使うべきなのかって? 実はどちらでもない。代わりに、httplib2をお勧めする。これはオープンソースなサードパーティ製ライブラリで、http.clientよりも完全にHTTPを実装しているのだが、しかもurllib.requestよりも優れた抽象化を施しているという代物だ。

どうしてhttplib2が正しい選択なのかを理解するには、まずHTTPについて知っておかねばならない。

HTTPの諸機能

HTTPクライエントならば必ず備えるべき五つの機能がある。

キャッシュ

ウェブサービスの種類にかかわらず知っておくべき最も重要なことは、ネットワークアクセスはとても高くつくということだ。とは言っても「お金」がかかるという意味ではなく(帯域はタダではないけどね)、接続を開き、リクエストを送り、そして遠隔サーバーからレスポンスを取得するまでには非常に長い時間がかかるということだ。レイテンシ(リクエストの送信後、その応答としてデータが受信され始めるまでの時間)は、想像よりも大きくなりうるものなのだ。ルーターは処理を誤り、パケットは抜け落ち、さらに中継のプロキシはアタックを受ける — 公衆に開かれたインターネットには一刻も休まる時など無く、そしてあなたがこれに対してできることは多分何もない。

HTTPはキャッシュのことを念頭に置いて設計されている。実際に、あなたと世界との間に横たわって、ネットワークアクセスを最小限にするという仕事のみを行うデバイス(「キャッシュプロキシ」と呼ばれている)なんてものもあるのだ。あなたは気がついていないかもしれないが、あなたの会社やISPはほぼ間違いなくこのキャッシュプロキシを運用している。これができるのも、キャッシュがHTTPプロトコルに組み込まれているからなのだ。

キャッシュがどのように機能するかの具体的な例を挙げよう。仮にブラウザでdiveintomark.orgにアクセスしたとする。このページの背景にはwearehugh.com/m.jpgという画像が置かれている。ここでブラウザがこの画像をダウンロードをすると、サーバーは次のようなHTTPヘッダを付けてくる:

HTTP/1.1 200 OK
Date: Sun, 31 May 2009 17:14:04 GMT
Server: Apache
Last-Modified: Fri, 22 Aug 2008 04:28:16 GMT
ETag: "3075-ddc8d800"
Accept-Ranges: bytes
Content-Length: 12405
Cache-Control: max-age=31536000, public
Expires: Mon, 31 May 2010 17:14:04 GMT
Connection: close
Content-Type: image/jpeg

このCache-ControlExpiresのヘッダは、ブラウザ(それに加えて、あなたとサーバーとの間にあるキャッシュプロキシすべて)に対して「一年間はこの画像をキャッシュしてもいいよ」ということを伝えるものだ。一年間! だからもし、次の年にこの画像へのリンクを含む別のページにアクセスしたとすると、ブラウザはキャッシュにある画像を読み込むので、ネットワークを介したやりとりは全く行われないのだ

しかし待って欲しい、話はもっと良くなる。ブラウザが何かの理由でこの画像をローカルキャッシュから消してしまったとしよう。その原因はディスクスペースが尽きたということかもしれないし、あるいはあなたが自分でキャッシュを削除したのかもしれない。その原因が何であれ、HTTPヘッダは「この画像のデータはパブリックなキャッシュプロキシで保存してもかまわないよ」と述べている(厳密に言えば、ここで重要なのはこのヘッダが述べていないことだ。つまり、Cache-Controlヘッダの中にprivateというキーワードが含まれていないので、このデータはデフォルトでキャッシュできるようになっているのだ)。キャッシュプロキシは膨大な記憶容量を持つように設計されていて、たぶんその容量はあなたのブラウザが用意したものよりもはるかに大きいだろう。

もし、あなたの会社やISPがキャッシュプロキシを運営していたら、そのプロキシにはまだこの画像がキャッシュされているかもしれない。ここで再びdiveintomark.orgにアクセスしたとしよう。すると、まずブラウザはこの画像を探してローカルキャッシュを漁る。しかし、見つけることができないので、今度は遠隔サーバーからダウンロードしようと、ネットワークを通してリクエストを送信するだろう。そこで、もしキャッシュプロキシの方にまだ画像のコピーがあれば、リクエストはそこで止められて、プロキシのキャッシュから画像が返される。つまり、リクエストが遠隔サーバーに到達することはないのだ。現に、このリクエストはあなたの会社のネットワークを離れてさえいない。この仕組みのおかげで、高速なダウンロード(より少ないホップ数での通信)が可能になり、会社側のコストも節約(外部からダウンロードされるデータをより少なく)できるのだ。

HTTPキャッシュは各々が自分の仕事をしっかりこなしている場合にのみうまくいく。つまり、一方ではサーバーが正しいヘッダを返信しなくてはならないし、もう一方ではクライアントがそのヘッダを理解した上で、同じデータを二度リクエストする前にヘッダに従った処理を行わなければならない。その中間に置かれるプロキシは万能薬などではなく、サーバーとクライアントが上手く処理してくれる限りにおいて機能できるだけなのだ。

PythonのHTTPライブラリはキャッシュをサポートしていないが、httplib2はサポートしている。

Last-Modifiedチェック

ひっきりなしに変更されるデータがある一方で、決して変わらないデータもある。その中間には、更新された可能性があったのだが、実際には何も変更されていなかったという類のデータが大量に存在している。CNN.comのフィードは数分おきに更新されるが、私のブログのフィードは数日か数週間は更新されない。後者の場合、私はクライアントに何週間もフィードをキャッシュしてもらいたいとは思わないだろう。そうすると、実際に何かをブログに投稿しても、読者が数週間その記事を目にしないということになりかねないからだ(これも読者の皆さんが「数週間はこのフィードをチェックしないで」としている私のヘッダに従ってくれるおかげだ)。その一方で、一時間ごとにフィード全体をダウンロードして更新をチェックされても困るのだ!

HTTPにはこれを解決する方法も用意されている。最初にデータをリクエストされたときに、サーバーはLast-Modifiedヘッダを付けて返信することができる。これはその名前のとおり、データが更新された日時を表すものだ。diveintomark.orgが参照している背景画像にもLast-Modifiedヘッダが付いている。

HTTP/1.1 200 OK
Date: Sun, 31 May 2009 17:14:04 GMT
Server: Apache
Last-Modified: Fri, 22 Aug 2008 04:28:16 GMT
ETag: "3075-ddc8d800"
Accept-Ranges: bytes
Content-Length: 12405
Cache-Control: max-age=31536000, public
Expires: Mon, 31 May 2010 17:14:04 GMT
Connection: close
Content-Type: image/jpeg

同じデータを二度目(三度目でも四度目でもいいのだが)にリクエストする時、前回サーバーから返された日時を入れたIf-Modified-Sinceヘッダをリクエストに付けて送ることができる。もし、データがその日時以降に変更されていれば、サーバーはIf-Modified-Sinceヘッダを無視し、ステータスコード200と一緒に新しいデータを送り返してくれる。しかし、データがその日時以降に何も変更されていなければ、サーバーはHTTP 304という特別なステータスコードを返す。これは「このデータは前回リクエストされた時から何も変更されていないよ」ということを表すものだ。ちなみに、curlを使えばこれをコマンドライン上でテストすることもできる。

you@localhost:~$ curl -I -H "If-Modified-Since: Fri, 22 Aug 2008 04:28:16 GMT" http://wearehugh.com/m.jpg
HTTP/1.1 304 Not Modified
Date: Sun, 31 May 2009 18:04:39 GMT
Server: Apache
Connection: close
ETag: "3075-ddc8d800"
Expires: Mon, 31 May 2010 18:04:39 GMT
Cache-Control: max-age=31536000, public

どうしてこれで状況が改善されるのかって? サーバーが304を返す場合には、データ自体は再び送られてこないからだ。返送されるのはステータスコードだけなのだ。キャッシュされたコピーの有効期限が切れていた場合でも、last-modifiedチェックを使えば、データが変更されていない限り、同じデータを再びダウンロードしなくても済むようになる(さらなるおまけとして、304が返される時もキャッシュに関するヘッダは送られてくる。データが本当に変更されておらず、さらに次のリクエストで304ステータスコードと最新のキャッシュ情報が返されるかもしれないので、正式には「有効期限切れ」とされているデータのコピーもプロキシは保存し続けるものなのだ)。

PythonのHTTPライブラリはlast-modifiedチェックをサポートしていないが、httplib2はサポートしている。

ETagチェック

ETagとはlast-modifiedチェックと同じ役割を果たすものだ。Etagsを使った場合には、サーバーはリクエストされたデータに加えて、ハッシュを納めたETagヘッダを返してくる(このハッシュをどのように生成するかについては完全にサーバーに委ねられている。データが変更されたときにその値が変わりさえすればなんでもいいのだ)。diveintomark.orgから参照されている背景画像にもETagヘッダが含まれている。

HTTP/1.1 200 OK
Date: Sun, 31 May 2009 17:14:04 GMT
Server: Apache
Last-Modified: Fri, 22 Aug 2008 04:28:16 GMT
ETag: "3075-ddc8d800"
Accept-Ranges: bytes
Content-Length: 12405
Cache-Control: max-age=31536000, public
Expires: Mon, 31 May 2010 17:14:04 GMT
Connection: close
Content-Type: image/jpeg

同じデータを二度目にリクエストする時には、Etagのハッシュを入れたIf-None-Matchヘッダを付けて送る。データが変わっていなければ、サーバーは304ステータスコードを送り返してくれるだろう。この場合、last-modifiedチェックの時と同じく、サーバーは304ステータスコードだけを返すので、同じデータが二度送信されることはない。つまり、Etagのハッシュを二度目のリクエストの際に一緒に送ることで「最後にリクエストした時のデータがまだ残っているから、まだハッシュが一致するようなら同じデータを再送信する必要は無いよ」とサーバーに伝えていることになるのだ。

再びcurlを使ってみよう:

you@localhost:~$ curl -I -H "If-None-Match: \"3075-ddc8d800\"" http://wearehugh.com/m.jpg  
HTTP/1.1 304 Not Modified
Date: Sun, 31 May 2009 18:04:39 GMT
Server: Apache
Connection: close
ETag: "3075-ddc8d800"
Expires: Mon, 31 May 2010 18:04:39 GMT
Cache-Control: max-age=31536000, public
  1. ETagは一般に引用符で括られているのだが、実はこの引用符も値の一部だ。だから、If-None-Matchヘッダをサーバーに送り返す時には引用符を付けて返さなくてはならない。

PythonのHTTPライブラリはEtagをサポートしていないが、httplib2はサポートしている。

圧縮

HTTPウェブサービスということになると、ほとんどの場合、回線を通じてテキストベースのデータを行き来させるという話になる。そのデータはXMLかもしれないし、JSONかもしれない。あるいは単なるプレーンテキストかもしれない。形式が何であれ、テキストというのは圧縮効率が良いものだ。XMLの章で例に出したフィードは圧縮していない状態だと3070バイトなのだが、gzipで圧縮すると941バイトになる。元のサイズの30%になってしまうのだ!

HTTPいくつかの圧縮アルゴリズムをサポートしているが、最も一般に用いられている形式はgzipdeflateの二つだ。HTTPを使ってリソースをリクエストする時には、圧縮形式で送るようにサーバーに頼むことができる。あなたがサポートしている圧縮アルゴリズムのリストが入ったAccept-encodingヘッダをリクエストに付け加えればいいのだ。サーバーがそのアルゴリズムのいずれかをサポートしていれば、圧縮したデータを返してくれることだろう(この場合、どのアルゴリズムが使われたかを示すContent-encodingヘッダもついてくる)。後は、送られてきたデータを展開すればいい。

サーバーサイドの開発者向けの重要なメモ: 圧縮したデータと未圧縮のデータが異なるEtagを持つようにすること。でないと、キャッシュプロキシが混乱して、クライアントが扱えないのに圧縮された形式で返してしまいかねないからだ。この微妙な問題の詳細については、Apacheバグ 39727に関する議論を読んでほしい。

PythonのHTTPライブラリは圧縮をサポートしていないが、httplib2はサポートしている。

リダイレクト

クールなURIは変更されないものなのだが、たいていのURIはどうしようもなくクールではない。ウェブサイトは再構成されて、ページは新しいアドレスに移されてしまう。ウェブサービスですら再編されることがあるのだ。例えば、http://example.com/index.xmlで配信されていたフィードは、http://example.com/xml/atom.xmlに移されてしまうかもしれない。あるいは、 組織の拡大や再編の一環として、ドメイン自体が変えられることだってある。例えば、http://www.example.com/index.xmlhttp://server-farm-1.example.com/index.xmlに移転するかもしれない。

リソースをHTTPサーバーにリクエストした場合にはいつも、サーバーはステータスコードも送り返してくる。ステータスコード200が意味するのは「万事異常無し。これがリクエストされたページだ」ということだ。ステータスコード404は「ページが見つかりません」ということを意味する(たぶんウェブブラウジングをしていて404エラーに出くわしたことがあるだろう)。300台のステータスコードは何らかの形のリダイレクトを表している。

HTTPにはリソースが移転したことを知らせる方法がいくつか用意されている。中でも最もよく使われているのはステータスコードの302301を使う方法だ。ステータスコード302一時的なリダイレクトを表す。つまり、「おっと、それは一時的にあっちに移転されてるよ」ということだ(その上で、一時的なアドレスをLocationヘッダに入れて渡してくれる)。一方で、ステータスコード301恒久的なリダイレクトを表す。つまり、「おっと。それはあっちに完全に移転されてるよ」ということだ(その上で、新しいアドレスをLocationに入れて渡してくれる)。ステータスコード302と一緒に新しいアドレスを受け取った場合について、HTTPの仕様は「リクエストしたリソースを取得するには新しいアドレスを使えばいいが、次に同じリソースにアクセスする時には古いアドレスを試すべし」としている。ステータスコード301と一緒に新しいアドレスを受け取った場合には、以後その新しいアドレスを使っていけばいい。

urllib.requestモジュールはHTTPサーバーから適切なステータスコードを受け取った場合に自動でそのリダイレクトをたどってくれるのだが、そのように処理したとは何も言ってくれない。要するに、最終的にリクエストしたデータは取得できるにしても、その処理を支えるライブラリが「ご親切にも」リダイレクトをたどってくれたとは分からないのだ。だから、あなたは古いアドレスに何度も何度もアクセスし続けることになり、その度に新しいアドレスにリダイレクトされて、しかも毎回urllib.requestが「ご丁寧に」リダイレクトをたどってくれる。言い換えれば、これは恒久的なリダイレクトを一時的なリダイレクトと同じように扱っているわけだ。こうすると一回で済むところを二回往復することになるので、これはサーバーにとってもあなたにとっても良くないことなのだ。

httplib2は恒久的なリダイレクトを処理してくれる。恒久的なリダイレクトが生じたことを教えてくれるのみならず、そのリダイレクトをローカルに保存し、リダイレクトされたURLをリクエストの前に自動で書き直してくれるのだ。

HTTPを使ってデータを取得するまずいやり方

HTTPを使ってAtomフィードなどのリソースをダウンロードしたいと考えたとしよう。フィードということなので、一回ダウンロードするだけでは済まず、何回も何回もダウンロードすることになる(ほとんどのフィードリーダーは一時間に一回、更新をチェックする)。まずは、こいつを手早く雑に実装してみて、それからどうやったら改善できるかを考えることにしよう。

>>> import urllib.request
>>> a_url = 'http://diveintopython3.org/examples/feed.xml'
>>> data = urllib.request.urlopen(a_url).read()  
>>> type(data)                                   
<class 'bytes'>
>>> print(data)
<?xml version='1.0' encoding='utf-8'?>
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'>
  <title>dive into mark</title>
  <subtitle>currently between addictions</subtitle>
  <id>tag:diveintomark.org,2001-07-29:/</id>
  <updated>2009-03-27T21:56:07Z</updated>
  <link rel='alternate' type='text/html' href='http://diveintomark.org/'/>
  …
  1. どんなものであれHTTPを使ってダウンロードするのは、Pythonでは驚くほど簡単だ。実際に、たった一行でできてしまう。urllib.requestモジュールには便利なurlopen()という関数が用意されていて、これはダウンロードしたいページのアドレスを引数にとり、ファイルに似たオブジェクトを返すものなのだが、このオブジェクトをread()するだけでページの内容を全て取得することができるのだ。これ以上簡単にしようがないだろう。
  2. urlopen().read()メソッドは常に文字列ではなくbytesオブジェクトを返す。思い出してほしいのだが、 バイト列はバイト列であって、文字列はそれを抽象化したものだった。HTTPは抽象化されたものを扱わないので、リソースをリクエストした時には、バイト列の形で受け取ることになる。それを文字列として扱いたいなら、文字コードを定めて明示的に文字列に変換しなくてはならない。

それで、この方法のどこがまずいのだろう? テストや開発の際に一回だけ使うお手軽なコードとしては、これで何も悪くない。私もよくこれを使っている。フィードの中身を取得しようとしていて、それでフィードの中身が手に入っているわけだし、この方法でどのウェブページも取得できる。しかし、定期的にアクセスされるようなウェブサービス(e.g.このフィードを一時間に一回リクエストする場合)の観点からすると、これは効率が悪いというだけではなく、無礼な方法でもあるのだ。

何が回線を通っているのか?

これがなぜ非効率で無礼なのかを理解するために、PythonのHTTPライブラリのデバッグ機能をオンにして何が「回線を通じて」(i.e. ネットワークを介して)送られているのかを見てみよう。

>>> from http.client import HTTPConnection
>>> HTTPConnection.debuglevel = 1                                       
>>> from urllib.request import urlopen
>>> response = urlopen('http://diveintopython3.org/examples/feed.xml')  
send: b'GET /examples/feed.xml HTTP/1.1                                 
Host: diveintopython3.org                                               
Accept-Encoding: identity                                               
User-Agent: Python-urllib/3.1'                                          
Connection: close
reply: 'HTTP/1.1 200 OK'
…further debugging information omitted…
  1. この章の最初で述べたように、urllib.requesthttp.clientという他のPythonの標準ライブラリに依存している。本来ならhttp.clientに直接触れる必要は無いのだが(urllib.requestモジュールが自動でインポートしてくれる)、ここではurllib.requestHTTPサーバーに接続する際に使っているHTTPConnectionクラスのデバッグフラグをオンに切り替えるためにインポートしている。
  2. デバッグフラグが立っているので、HTTPのリクエストとレスポンスに関する情報がリアルタイムで出力される。ご覧のとおり、このAtomフィードをリクエストする際に、urllib.requestモジュールは5行のコードを送っている。
  3. 最初の行はあなたが使っているHTTPの動詞と、リソースのパス(からドメイン名を引いたもの)を明示している。
  4. 二行目はリクエストしているフィードのあるドメイン名を示している。
  5. 三行目はクライアントがサポートしている圧縮アルゴリズムを指定している。先ほど述べたように、標準ではurllib.requestは圧縮をサポートしていない
  6. 四行目はリクエストを行っているライブラリの名前を示している。標準ではPython-urllibとバージョン番号が記される。urllib.requesthttplib2ではこのユーザーエージェントを変更することができ、そのためには単にUser-Agentヘッダをリクエストに加えるだけでいい(こうするとデフォルトの値が置き換えられる)。

では、サーバーが何を送り返してきたのかを見てみよう。

# 前の例から続く
>>> print(response.headers.as_string())        
Date: Sun, 31 May 2009 19:23:06 GMT            
Server: Apache
Last-Modified: Sun, 31 May 2009 06:39:55 GMT   
ETag: "bfe-93d9c4c0"                           
Accept-Ranges: bytes
Content-Length: 3070                           
Cache-Control: max-age=86400                   
Expires: Mon, 01 Jun 2009 19:23:06 GMT
Vary: Accept-Encoding
Connection: close
Content-Type: application/xml
>>> data = response.read()                     
>>> len(data)
3070
  1. urllib.request.urlopen()関数から返されたresponseにはサーバーが返したHTTPヘッダが全て入っている。加えて、このオブジェクトには実際のデータをダウンロードするためのメソッドも入っている。これについてはすぐに触れる。
  2. サーバーはいつあなたのリクエストを処理したのかを教えてくれる。
  3. このレスポンスにはLast-Modifiedヘッダが含まれている。
  4. さらにはETagヘッダもこのレスポンスに入っている。
  5. このデータは3070バイトだ。ここに何が欠けているかに注意してほしい。つまり、これにはContent-encodingヘッダが抜けているのだ。リクエストでは圧縮していないデータしか受け取れないと明示したので(Accept-encoding: identity)、当然のことながら、このレスポンスには圧縮されていないデータが入っている。
  6. このレスポンスにはキャッシュヘッダが含まれていて、これは「24時間(86400秒)までならキャッシュしてもいいよ」と述べている。
  7. そして最後に、response.read()を呼び出すことで実際のデータをダウンロードしている。len()関数の戻り値を見れば分かるように、ここでは一度に3070バイトをダウンロードしている。

お分かりだと思うが、この時点で既にこのコードは非効率的だ。このコードは圧縮されていないデータをリクエストしている(そしてその通り受け取っている)のだ。私はこのサーバーがgzip圧縮をサポートしていることを事実として知っているのだが、HTTPの圧縮機能を利用するにはそのように指定しておかなければならないのだ。今回はそう指定しなかったので、データは圧縮されなかった。だから、941バイトで済むところを、3070バイトもダウンロードすることになってしまったというわけだ。なんて悪い子だ。ビスケットはおあずけだな。

けどちょっと待って、これはもっと悪くなるんだ! このコードがどれだけ非効率かを見るために、もう一度同じフィードをリクエストしてみよう。

# 前の例から続く
>>> response2 = urlopen('http://diveintopython3.org/examples/feed.xml')
send: b'GET /examples/feed.xml HTTP/1.1
Host: diveintopython3.org
Accept-Encoding: identity
User-Agent: Python-urllib/3.1'
Connection: close
reply: 'HTTP/1.1 200 OK'
…further debugging information omitted…

何かこのリクエストのおかしなところに気がついただろうか? これは何も変わっていないのだ! 内容は最初のリクエストと全く同じで、If-Modified-Sinceヘッダの影も形もなければ、If-None-Matchヘッダもない。キャッシュのヘッダを気にかけた様子も全く無く、しかも依然として圧縮を利用していないのだ。

それで、同じことを二度やって何が起こると思う? 同じレスポンスを受けとるんだ。それも二度ね。

# 前の例から続く
>>> print(response2.headers.as_string())     
Date: Mon, 01 Jun 2009 03:58:00 GMT
Server: Apache
Last-Modified: Sun, 31 May 2009 22:51:11 GMT
ETag: "bfe-255ef5c0"
Accept-Ranges: bytes
Content-Length: 3070
Cache-Control: max-age=86400
Expires: Tue, 02 Jun 2009 03:58:00 GMT
Vary: Accept-Encoding
Connection: close
Content-Type: application/xml
>>> data2 = response2.read()
>>> len(data2)                               
3070
>>> data2 == data                            
True
  1. サーバーはなおも「スマートな」ヘッダの配列を送ってくれている。つまり、キャッシュのためのCache-ControlExpires、更新チェックを可能にするLast-ModifiedETagだ。しかも、Vary: Accept-Encodingヘッダは、このサーバーは要求さえあればデータの圧縮も扱えるということをほのめかしてさえいる。しかし、ここであなたはそのように要求しなかったのだ。
  2. またしても、データを取得するのに丸々3070バイトもダウンロードしている……
  3. ……先ほどダウンロードしたのと寸分違わぬ3070バイトだ。

HTTPは、これよりもっと上手く処理できるように設計されている。urllibは、まるで私がスペイン語を話すようにHTTPを話している — 困ったときには十分役立つが、会話をするには足りない。そして、HTTPとは会話なのだ。そろそろ、流暢にHTTPで話せるライブラリにアップグレードするとしようか。

httplib2の紹介

httplib2を使うには、まずこれをインストールする必要がある。それには、code.google.com/p/httplib2/に行って最新のバージョンをダウンロードすればいい。httplib2はPython 2.xにもPython 3.xにも対応しているのだが、必ずPython 3用のバージョンを選んでほしい。httplib2-python3-0.5.0.zipみたいな名前のものがそれだ。

アーカイブを解凍して端末を開き、新しく作成されたhttplib2というディレクトリに入る。Windowsでは、スタートメニューからファイル名を指定して実行を選んでからcmd.exeと打ち込み、ENTERを押せばいい。

c:\Users\pilgrim\Downloads> dir
 Volume in drive C has no label.
 Volume Serial Number is DED5-B4F8

 Directory of c:\Users\pilgrim\Downloads

07/28/2009  12:36 PM    <DIR>          .
07/28/2009  12:36 PM    <DIR>          ..
07/28/2009  12:36 PM    <DIR>          httplib2-python3-0.5.0
07/28/2009  12:33 PM            18,997 httplib2-python3-0.5.0.zip
               1 File(s)         18,997 bytes
               3 Dir(s)  61,496,684,544 bytes free

c:\Users\pilgrim\Downloads> cd httplib2-python3-0.5.0
c:\Users\pilgrim\Downloads\httplib2-python3-0.5.0> c:\python31\python.exe setup.py install
running install
running build
running build_py
running install_lib
creating c:\python31\Lib\site-packages\httplib2
copying build\lib\httplib2\iri2uri.py -> c:\python31\Lib\site-packages\httplib2
copying build\lib\httplib2\__init__.py -> c:\python31\Lib\site-packages\httplib2
byte-compiling c:\python31\Lib\site-packages\httplib2\iri2uri.py to iri2uri.pyc
byte-compiling c:\python31\Lib\site-packages\httplib2\__init__.py to __init__.pyc
running install_egg_info
Writing c:\python31\Lib\site-packages\httplib2-python3_0.5.0-py3.1.egg-info

Mac OS Xなら/Applications/Utilities/にあるTerminal.appというアプリケーションを起動すればいい。LinuxならTerminalを起動する。こちらは大抵の場合、AccessoriesSystem以下に置かれている、Applicationsメニューにある。

you@localhost:~/Desktop$ unzip httplib2-python3-0.5.0.zip
Archive:  httplib2-python3-0.5.0.zip
  inflating: httplib2-python3-0.5.0/README
  inflating: httplib2-python3-0.5.0/setup.py
  inflating: httplib2-python3-0.5.0/PKG-INFO
  inflating: httplib2-python3-0.5.0/httplib2/__init__.py
  inflating: httplib2-python3-0.5.0/httplib2/iri2uri.py
you@localhost:~/Desktop$ cd httplib2-python3-0.5.0/
you@localhost:~/Desktop/httplib2-python3-0.5.0$ sudo python3 setup.py install
running install
running build
running build_py
creating build
creating build/lib.linux-x86_64-3.1
creating build/lib.linux-x86_64-3.1/httplib2
copying httplib2/iri2uri.py -> build/lib.linux-x86_64-3.1/httplib2
copying httplib2/__init__.py -> build/lib.linux-x86_64-3.1/httplib2
running install_lib
creating /usr/local/lib/python3.1/dist-packages/httplib2
copying build/lib.linux-x86_64-3.1/httplib2/iri2uri.py -> /usr/local/lib/python3.1/dist-packages/httplib2
copying build/lib.linux-x86_64-3.1/httplib2/__init__.py -> /usr/local/lib/python3.1/dist-packages/httplib2
byte-compiling /usr/local/lib/python3.1/dist-packages/httplib2/iri2uri.py to iri2uri.pyc
byte-compiling /usr/local/lib/python3.1/dist-packages/httplib2/__init__.py to __init__.pyc
running install_egg_info
Writing /usr/local/lib/python3.1/dist-packages/httplib2-python3_0.5.0.egg-info

httplib2を使うには、httplib2.Httpクラスのインスタンスを作成する。

>>> import httplib2
>>> h = httplib2.Http('.cache')                                                    
>>> response, content = h.request('http://diveintopython3.org/examples/feed.xml')  
>>> response.status                                                                
200
>>> content[:52]                                                                   
b"<?xml version='1.0' encoding='utf-8'?>\r\n<feed xmlns="
>>> len(content)
3070
  1. httplib2の主要なインターフェイスはHttpオブジェクトだ。理由は次の節で説明するが、Httpオブジェクトを作る時は常にディレクトリ名を渡さなければならない。この時、そのディレクトリはまだ存在しないものであっても構わない。必要に応じてhttplib2が作成してくれるからだ。
  2. Httpオブジェクトができたら、データを取得するのは簡単で、request()メソッドに欲しいデータのアドレスを渡して呼び出すだけでいい。そうすれば、そのURLに対するHTTP GETリクエストが送信される(POSTなどの他のHTTPリクエストを送信する方法についてはこの章の後半で説明する)。
  3. request()メソッドは二つの値を返す。一つ目がhttplib2.Responseオブジェクトで、これにはサーバーが送り返してきたHTTPヘッダが全て入っている。例えば、status200という値は、リクエストが成功したことを示している。
  4. content変数にはHTTPサーバーから送り返された実際のデータが入っている。このデータは文字列ではなくbytesオブジェクトの形式で返されるので、文字コードを定めて、自分で変換しなくてはならない。

一つのhttplib2.Httpオブジェクトだけで足りているかもしれないが、このオブジェクトを複数作成するのが適当な場合もある。しかし、そうは言っても、なぜ二つ以上必要なのかをしっかり理解している場合にしか、そうしてはならない。例えば、「二つの異なるURLからデータをリクエストする必要があるから」というのでは十分ではない。Httpオブジェクトを再利用してrequest()メソッドを二度呼び出せばよいからだ。

補足: httplib2はなぜ文字列の代わりにバイト列を返すのか?

バイト列。文字列。なんて厄介なんだろう。こんなものは「単純に」httplib2が処理してくれればいいのに、と思ったかもしれない。しかし、これは現実には厄介な問題なのだ。その原因は、文字コードを決定する規則がリクエストされるリソースの種類によってまちまちだということにある。では、どうやってhttplib2はリクエストされているリソースの種類を識別するのだろうか? たいていの場合、リソースの種類はContent-Type HTTPヘッダに記されている。しかし、これはHTTPのオプション機能なので、すべてのHTTPサーバーがこのヘッダを返してくれるわけではない。仮に、HTTPレスポンスにこのヘッダが含まれていなかったとすると、あとはクライエント側でリソースの種類を推測するしかない(この作業は一般に「content sniffing」と呼ばれているが、こいつはどうやっても完璧にはならない)。

リクエストしているリソースの種類が分かっているなら(このケースだとXMLドキュメント)、返されたbytesオブジェクトをそのままxml.etree.ElementTree.parse()関数に渡すこともできるかもしれない。しかし、これができるのは、この例のようにXMLドキュメントが文字コードに関する情報を含んでいる場合だけだ。そして、これもオプション機能なので、あらゆるXMLドキュメントが文字コードを明示しているわけではない。XMLドキュメントに文字コードの種類が示されていない場合には、クライアントはドキュメントを運んできたHTTPレスポンスの方(i.e. Content-Type HTTPヘッダ)を見ることになっている。ここにはcharset変数が含まれているかもしれないからだ。

話はもっと悪くなる。ここで、文字コードに関する情報は、XMLドキュメント自体とContent-Type HTTPヘッダの二ヶ所に存在しうることになった。では、両方に文字コードの情報が入っていたら、どちらが優先されるのだろうか? RFC 3023(誓ってもいいが、これは私がでっち上げたものじゃない)によると、Content-Type HTTPヘッダに含まれているメディアタイプがapplication/xmlapplication/xml-dtdapplication/xml-external-parsed-entityのいずれかであるか、あるいはapplication/xmlのサブタイプ(例えば、application/atom+xmlapplication/rss+xml。ここには、さらにapplication/rdf+xmlも含まれる)ならば、文字コードは

  1. Content-Type HTTPヘッダのcharset変数に入っている文字コードか、
  2. ドキュメント内のXML宣言に入っているencoding属性の文字コードか、
  3. UTF-8になる。

他方で、Content-Type HTTPヘッダで与えられるメディアタイプがtext/xmltext/xml-external-parsed-entity、あるいはtext/*+xmlという形式のサブタイプなら、ドキュメント中のXML宣言にあるencoding属性は全く無視されてしまい、文字コードは、

  1. Content-Type HTTPヘッダに入っているcharset変数で与えられる文字コードか、
  2. us-asciiになる。

そして以上のことはXMLドキュメントだけに当てはまる話だ。HTMLドキュメントについては、ウェブブラウザがcontent sniffingのための複雑怪奇な規則[PDF]を作り上げてしまっていて、私たちはいまだ完全に解明できていない

パッチ歓迎

httplib2はキャッシュをどのように扱うのか

少し前の節で、「httplib2.Httpオブジェクトを作る時はいつもディレクトリ名を渡すように」と言ったのを覚えているだろうか? その理由は実はキャッシュにある。

# 前の例から続く
>>> response2, content2 = h.request('http://diveintopython3.org/examples/feed.xml')  
>>> response2.status                                                                 
200
>>> content2[:52]                                                                    
b"<?xml version='1.0' encoding='utf-8'?>\r\n<feed xmlns="
>>> len(content2)
3070
  1. ここにそんなに驚くようなことはないはずだ。戻り値を新しい二つの変数に代入していることを除けば、これは先ほどと全く同じだ。
  2. HTTPstatusは同じく200で、前から何も変わっていない。
  3. ダウンロードした内容にも変化はない。

それで……だから何なの? では、Pythonの対話シェルを一度閉じて、新しいセッションで再起動してほしい。そうしたら、ご説明しよう。

# 前の例の続きではない!
# いまの対話シェルを終了して、
# 新しいシェルを立ち上げて欲しい
>>> import httplib2
>>> httplib2.debuglevel = 1                                                        
>>> h = httplib2.Http('.cache')                                                    
>>> response, content = h.request('http://diveintopython3.org/examples/feed.xml')  
>>> len(content)                                                                   
3070
>>> response.status                                                                
200
>>> response.fromcache                                                             
True
  1. デバッグフラグをオンにして、何が回線を通っているのかを見てみよう。この行はhttp.clientでデバッグをオンにするのと同じ役割を果たすもので、httplib2がサーバーに送信したデータ全部と、返送されてきた情報の中の主要なものを出力してくれるようになる。
  2. 前と同じディレクトリ名を渡して、httplib2.Httpオブジェクトを作成する。
  3. 前回と同様に、同じURLをリクエストする。しかし何も起こっていないようだ。もっと正確に言えば、何もサーバーに送られていなければ、サーバーから返ってきてもいない。ここではネットワークを通したやりとりが全く行われていないのだ。
  4. しかし、現実に何らかのデータを「受信」してはいる — 実のところ、 すべてのデータを受け取っているのだ。
  5. 加えて、「リクエスト」が成功したことを示すHTTPステータスコードも「受信」している。
  6. 問題があるのはこの部分だ。この「レスポンス」はhttplib2のローカルキャッシュから生成されたものなのだ。httplib2.Httpオブジェクトを作るときにディレクトリ名を渡したが — そのディレクトリはhttplib2がこれまでに行った処理全てをキャッシュしているのだ。

httplib2のデバッグをオンにしたいなら、モジュールレベルの定数(httplib2.debuglevel)を設定してから、新しくhttplib2.Httpオブジェクトを作る必要がある。デバッグをオフにしたいなら、同じモジュールレベルの定数を変更して、また新しくhttplib2.Httpオブジェクトを作ればいい。

前回、このURLのデータをリクエストした時、そのリクエストは成功していた(status: 200)。これに対するレスポンスにはフィードのデータだけでなく、キャッシュのヘッダも一組入っていて、「このリソースは24時間までならキャッシュしてもいいよ」(Cache-Control: max-age=86400の部分。86400は24時間を秒に直したもの)と伝え回っていた。httplib2はこのキャッシュのヘッダの内容を理解した上で、それに従って.cacheディレクトリ(これはHttpオブジェクトを作成したときに渡したものだ)に前回のレスポンスを保存しておいたのだ。そして、そのキャッシュの期限がまだ切れてなかったので、二度目にこのURLのデータをリクエストした時、httplib2はネットワークにあたることなく単純にキャッシュしておいた内容を返したというわけだ。

「単純に」とは言ったが、当然ながらこの単純さの背後にはいくつもの複雑な処理が隠れている。httplib2デフォルトでHTTPキャッシュを自動的に処理してくれる。もし、何らかの理由でレスポンスがキャッシュから生成されたものなのかを知る必要があるなら、response.fromcacheをチェックすればいい。そういう場合でなければ、これは何の問題もなく上手く動いてくれる。

さて、データのキャッシュを持ってはいるが、そのキャッシュを無視して遠隔サーバーに再リクエストしたいと思ったとしよう。ブラウザはユーザーから特に要求があればこういう処理をする。例えば、F5を押すと現在見ているページが更新されるが、Ctrl+F5を押せばキャッシュを無視して遠隔サーバーにリクエストが行われる。ここで「単にキャッシュからデータを削除して、もう一度リクエストすればいいんじゃない?」と考えた人もいるだろう。もちろんそうすることもできるが、あなたと遠隔サーバー以外にもこの処理に関わっているものが存在しているかもしれないということを思い出してほしい。例えば、中間にあるプロキシサーバーはどうだろうか? これについては完全にあなたの手の外にあるが、ここにまだデータがキャッシュされているかもしれない。その場合、(プロキシサーバーにとっては)キャッシュはまだ有効なので、特に何も気にとめることなくキャッシュの方を返してくることだろう。

ローカルキャッシュの方に手を加えて「これでうまくいきますように」と願うのではなく、HTTPの機能を使ってリクエストが確実に遠隔サーバーに届くようにすべきだ。

# 前の例から続く
>>> response2, content2 = h.request('http://diveintopython3.org/examples/feed.xml',
...     headers={'cache-control':'no-cache'})  
connect: (diveintopython3.org, 80)             
send: b'GET /examples/feed.xml HTTP/1.1
Host: diveintopython3.org
user-agent: Python-httplib2/$Rev: 259 $
accept-encoding: deflate, gzip
cache-control: no-cache'
reply: 'HTTP/1.1 200 OK'
…further debugging information omitted…
>>> response2.status
200
>>> response2.fromcache                        
False
>>> print(dict(response2.items()))             
{'status': '200',
 'content-length': '3070',
 'content-location': 'http://diveintopython3.org/examples/feed.xml',
 'accept-ranges': 'bytes',
 'expires': 'Wed, 03 Jun 2009 00:40:26 GMT',
 'vary': 'Accept-Encoding',
 'server': 'Apache',
 'last-modified': 'Sun, 31 May 2009 22:51:11 GMT',
 'connection': 'close',
 '-content-encoding': 'gzip',
 'etag': '"bfe-255ef5c0"',
 'cache-control': 'max-age=86400',
 'date': 'Tue, 02 Jun 2009 00:40:26 GMT',
 'content-type': 'application/xml'}
  1. httplib2を使えば、どのリクエストにも任意のHTTPヘッダを加えることができる。全てのキャッシュ(つまり、ローカルディスクにあるキャッシュだけではなく、あなたと遠隔サーバーの間にあるキャッシュプロキシ全て)を無視するには、no-cacheヘッダをheaders辞書に加えればいい。
  2. httplib2がネットワークを通じたリクエストを開始している。httplib2双方向 — つまり、レスポンスの受信とリクエストの送信の両方においてキャッシュのヘッダを理解し、それに従ってくれるのだ。ここでは、no-cacheヘッダが追加されたことをちゃんと認識している。だからこそ、ローカルキャッシュを全て無視したのであり、その結果としてネットワークを介したデータのリクエストを行わざるを得なくなったのだ。
  3. このレスポンスはローカルキャッシュから生成されたものではない。このことはリクエスト送信に関するデバッグ情報が出力されているのを見れば明らかなのだが、これを手続的に確認できるのは良いことだ。
  4. リクエストが成功したので、遠隔サーバーからフィード全体を再びダウンロードすることができた。当然ながら、サーバーはフィードのデータと一緒にHTTPヘッダも全て送り返してくれている。この中にはキャッシュのヘッダも入っていて、httplib2はこれを使ってローカルキャッシュを更新する。次にこのフィードがリクエストされた時に、ネットワークを通じたアクセスを避けられるかもしれないからだ。HTTPキャッシュに関わるどの部分も、キャッシュの利用を最大にし、ネットワークを介したアクセスを最小にするように設計されている。今回はキャッシュを無視したが、遠隔サーバーはあなたが今回のリクエストの結果を次回のリクエストに備えてキャッシュしてくれていることを賞賛してくれるだろう。

httplib2はどのようにLast-ModifiedヘッダやETagヘッダを扱うのか

Cache-ControlExpiresの二つのキャッシュヘッダfreshness indicator と呼ばれる。この二つのヘッダは「このキャッシュの期限が切れるまでは、ネットワークを介したアクセスを行う必要はまったくない」と断言するものだ。前の章で見たのはまさしくこの機能で、このヘッダがあれば、httplib21バイトたりともネットワークを通してやりとりすることなく、そのままキャッシュのデータを返すのだ(もちろん、明示的にキャッシュを無視した場合は別だが)。

しかし、データが更新された可能性があったのだが、リクエストを送信してみたら実際には更新されていなかった、という場合はどうだろう。HTTPはこういう時のためにLast-ModifiedEtagというヘッダを用意している。これらのヘッダは validator と呼ばれるもので、ローカルキャッシュの有効期限が既に切れている場合には、クライアントは次のリクエストにこのvalidatorを追加することで、データが実際に変更されたかどうかを確認することができる。データが変更されていなければ、サーバーはデータを返送せずに304ステータスコードをだけを送り返してくる。だから、一回だけはネットワークを通したやりとりが行われるのだが、はるかに少ないバイト数をダウンロードするだけで済むのだ。

>>> import httplib2
>>> httplib2.debuglevel = 1
>>> h = httplib2.Http('.cache')
>>> response, content = h.request('http://diveintopython3.org/')  
connect: (diveintopython3.org, 80)
send: b'GET / HTTP/1.1
Host: diveintopython3.org
accept-encoding: deflate, gzip
user-agent: Python-httplib2/$Rev: 259 $'
reply: 'HTTP/1.1 200 OK'
>>> print(dict(response.items()))                                 
{'-content-encoding': 'gzip',
 'accept-ranges': 'bytes',
 'connection': 'close',
 'content-length': '6657',
 'content-location': 'http://diveintopython3.org/',
 'content-type': 'text/html',
 'date': 'Tue, 02 Jun 2009 03:26:54 GMT',
 'etag': '"7f806d-1a01-9fb97900"',
 'last-modified': 'Tue, 02 Jun 2009 02:51:48 GMT',
 'server': 'Apache',
 'status': '200',
 'vary': 'Accept-Encoding,User-Agent'}
>>> len(content)                                                  
6657
  1. フィードの代わりに、今回はサイトのホームページ(HTMLドキュメント)をダウンロードする。このページをリクエストするのは今回が始めてなので、httplib2がやるべき仕事は少ない。実際、最小限のヘッダだけを付けてリクエストを送信している。
  2. 返ってきたレスポンスにはHTTPヘッダがいくつも入っている……が、キャッシュに関する情報は含まれていない。しかし、ここにはETagヘッダとLast-Modifiedヘッダが二つとも入っている。
  3. 私がこの例を作成した時には、このページは6657バイトだった。たぶんそれから変わっているとは思うが、別にご心配なく。
# 前の例から続く
>>> response, content = h.request('http://diveintopython3.org/')  
connect: (diveintopython3.org, 80)
send: b'GET / HTTP/1.1
Host: diveintopython3.org
if-none-match: "7f806d-1a01-9fb97900"                             
if-modified-since: Tue, 02 Jun 2009 02:51:48 GMT                  
accept-encoding: deflate, gzip
user-agent: Python-httplib2/$Rev: 259 $'
reply: 'HTTP/1.1 304 Not Modified'                                
>>> response.fromcache                                            
True
>>> response.status                                               
200
>>> response.dict['status']                                       
'304'
>>> len(content)                                                  
6657
  1. 同じページを、同じHttpオブジェクト(と同じローカルキャッシュ)を使って再びリクエストしてみよう。
  2. httplib2If-None-MatchヘッダにETagのvalidatorを付けてサーバーに送っている。
  3. httplib2は同様にLast-ModifiedのvalidatorをIf-Modified-Sinceヘッダに入れてサーバーに送信している。
  4. サーバーはこれらのvalidatorとリクエストされたページを見て、そのページは前回のリクエストから何も変更されていないと判断する。だから、サーバーはデータを入れることなく304ステータスコードだけを送り返すのだ。
  5. クライエントの方に戻ると、httplib2304ステータスコードを認識し、キャッシュからページの内容をロードしている。
  6. ここはちょっと分かりにくいかもしれない。ここには実際に二つのステータスコードがあるのだ — すなわち、304(今回サーバーから返されたもの。これが返されたからhttplib2はキャッシュの方を参照したのだ)と、200前回サーバーから返されたもの。ページのデータと一緒にhttplib2のキャッシュに保存されていた)だ。response.statusはキャッシュのステータスコードを返す。
  7. サーバーから返された本当のステータスコードが欲しいなら、response.dictを参照すればいい。これはサーバーから返された実際のヘッダを収めた辞書だ。
  8. だが、どうであれcontent変数にはちゃんとデータが入っている。一般論としては、なぜレスポンスがキャッシュから生成されたのかを知る必要はないだろう(もしかしたらキャッシュから生成されたということすら気にかけないかもしれないが、それでも全くかまわない。httplib2は賢いので、こちらがおろそかでもきちんと処理してくれる)。request()の処理が完了するころには、httplib2は既にキャッシュをアップデートして、データを返してくれているのだ。

http2libはどのように圧縮を扱うのか

HTTP数種類の圧縮形式をサポートしているが、中でも最もよく使われているのはgzipとdeflateの二つだ。httplib2はこの両方をサポートしている。

>>> response, content = h.request('http://diveintopython3.org/')
connect: (diveintopython3.org, 80)
send: b'GET / HTTP/1.1
Host: diveintopython3.org
accept-encoding: deflate, gzip                          
user-agent: Python-httplib2/$Rev: 259 $'
reply: 'HTTP/1.1 200 OK'
>>> print(dict(response.items()))
{'-content-encoding': 'gzip',                           
 'accept-ranges': 'bytes',
 'connection': 'close',
 'content-length': '6657',
 'content-location': 'http://diveintopython3.org/',
 'content-type': 'text/html',
 'date': 'Tue, 02 Jun 2009 03:26:54 GMT',
 'etag': '"7f806d-1a01-9fb97900"',
 'last-modified': 'Tue, 02 Jun 2009 02:51:48 GMT',
 'server': 'Apache',
 'status': '304',
 'vary': 'Accept-Encoding,User-Agent'}
  1. httplib2が送信するリクエストには、必ずAccept-Encodingというヘッダが付けられている。このヘッダによってdeflategzipのどちらかなら扱えるということをサーバーに伝えているのだ。
  2. ここでは、サーバーはgzip形式で圧縮されたデータを返している。request()の処理が完了するころには、httplib2は既にデータを展開し、content変数に入れ終わっているのだ。返送されたデータが圧縮されたものだったかどうかを知りたいなら、response['-content-encoding']をチェックすればいい。そうでなければ、何も気にする必要はない。

httplib2はどのようにリダイレクトを扱うのか

HTTP二種類のリダイレクトを定義していた。つまり、一時的なものと恒久的なものだ。このうち、一時的なリダイレクトについては、そのリダイレクトをたどるということ(これはhttplib2が自動でやってくれる)以外に、特に何か処理を行う必要はなかった。

>>> import httplib2
>>> httplib2.debuglevel = 1
>>> h = httplib2.Http('.cache')
>>> response, content = h.request('http://diveintopython3.org/examples/feed-302.xml')  
connect: (diveintopython3.org, 80)
send: b'GET /examples/feed-302.xml HTTP/1.1                                            
Host: diveintopython3.org
accept-encoding: deflate, gzip
user-agent: Python-httplib2/$Rev: 259 $'
reply: 'HTTP/1.1 302 Found'                                                            
send: b'GET /examples/feed.xml HTTP/1.1                                                
Host: diveintopython3.org
accept-encoding: deflate, gzip
user-agent: Python-httplib2/$Rev: 259 $'
reply: 'HTTP/1.1 200 OK'
  1. このURLにはフィードが入っていない。正しいアドレスに一時的にリダイレクトするようサーバーを設定しておいた。
  2. ここでリクエストが行われている。
  3. それに対するレスポンスは302 Foundだ。ここには示されていないが、このレスポンスには正しいURLを示すLocationヘッダが入っている。
  4. httplib2はすぐに方向を変えて、Locationヘッダで与えられたhttp://diveintopython3.org/examples/feed.xmlというURLに新たなリクエストを送信し、リダイレクトを「たどって」くれる。

リダイレクトを「たどる」ということについては、この例以上のものはなにもない。httplib2が要求されたURLにリクエストを送信し、サーバーが「違う違う。ここじゃなくてあちらを参照してよ」という内容のレスポンスを返す。そして、httplib2はその新しいURLに向けて別のリクエストを送信する。

# 前の例から続く
>>> response                                                          
{'status': '200',
 'content-length': '3070',
 'content-location': 'http://diveintopython3.org/examples/feed.xml',  
 'accept-ranges': 'bytes',
 'expires': 'Thu, 04 Jun 2009 02:21:41 GMT',
 'vary': 'Accept-Encoding',
 'server': 'Apache',
 'last-modified': 'Wed, 03 Jun 2009 02:20:15 GMT',
 'connection': 'close',
 '-content-encoding': 'gzip',                                         
 'etag': '"bfe-4cbbf5c0"',
 'cache-control': 'max-age=86400',                                    
 'date': 'Wed, 03 Jun 2009 02:21:41 GMT',
 'content-type': 'application/xml'}
  1. request()メソッドを一度呼び出して取得したresponseには最後のURLから返されたレスポンスが入っている。
  2. httplib2response辞書にcontent-locationというキーワードを付けて、最終的にたどり着いたURLを追加してくれる。このヘッダはサーバーから送られてきたものではなく、httplib2固有のものだ。
  3. ちなみに、このフィードは圧縮されている
  4. さらに、キャッシュすることもできる(この点は重要だ。これについてはすぐ後で説明する)。

取得したresponseの中には最後のURLに関する情報は含まれているのだが、では、その中間にあったURL、つまりこの最後のURLに至るまでに経由したURLの情報が欲しい時はどうすればいいのだろうか? これについてもhttplib2を使えば調べることができる。

# 前の例から続く
>>> response.previous                                                     
{'status': '302',
 'content-length': '228',
 'content-location': 'http://diveintopython3.org/examples/feed-302.xml',
 'expires': 'Thu, 04 Jun 2009 02:21:41 GMT',
 'server': 'Apache',
 'connection': 'close',
 'location': 'http://diveintopython3.org/examples/feed.xml',
 'cache-control': 'max-age=86400',
 'date': 'Wed, 03 Jun 2009 02:21:41 GMT',
 'content-type': 'text/html; charset=iso-8859-1'}
>>> type(response)                                                        
<class 'httplib2.Response'>
>>> type(response.previous)
<class 'httplib2.Response'>
>>> response.previous.previous                                            
>>>
  1. response.previous属性を調べれば、httplib2が現在のレスポンスオブジェクトにたどり着く直前に経由したレスポンスオブジェクトを参照することができる。
  2. responseresponse.previousのどちらもhttplib2.Responseオブジェクトだ。
  3. 従って、response.previous.previousというように調べることで、リダイレクトの道筋をどんどん遡っていけることになる(これが必要になるのは次のような状況だ。つまり、あるURLが二番目のURLにリダイレクトし、さらにそこから三番目のURLにリダイレクトされる。本当にこういうこともあるんだよ!)。ここでは、既にリダイレクトの起点までたどり着いていたので、この属性の値はNoneになる。

同じURLをもう一度リクエストしたらどうなるだろうか?

# 前の例から続く
>>> response2, content2 = h.request('http://diveintopython3.org/examples/feed-302.xml')  
connect: (diveintopython3.org, 80)
send: b'GET /examples/feed-302.xml HTTP/1.1                                              
Host: diveintopython3.org
accept-encoding: deflate, gzip
user-agent: Python-httplib2/$Rev: 259 $'
reply: 'HTTP/1.1 302 Found'                                                              
>>> content2 == content                                                                  
True
  1. 同じURLに、同じhttplib2.Httpオブジェクトだ(したがってキャッシュも同じだ)。
  2. 302レスポンスはキャッシュされなかったので、httplib2は別のリクエストを同じURLに送信している。
  3. またしても、サーバーは302を返している。しかし、ここに何が欠けているかに注意してほしい。ここでは最終的なURLhttp://diveintopython3.org/examples/feed.xmlに対する二回目のリクエストが送られていないのだ。つまり、先ほどのレスポンスはキャッシュされていて(前の例で見たCache-Controlを思い出して欲しい)、さらにhttplib2302 Foundを受け取ると、新しくリクエストを送信するのに先立ってまずキャッシュをチェックしたということだ。キャッシュにはまだ新しいhttp://diveintopython3.org/examples/feed.xmlのコピーがあったので、再びリクエストする必要が無かったのだ。
  4. request()メソッドが処理を完了するころには、フィードのデータは既にキャッシュから読み出され、返されている。もちろん、これは前回受け取ったのと同じデータだ。

要するに、一時的なリダイレクトについては特に何かをする必要はないということだ。httplib2は自動でリダイレクトをたどってくれるし、しかも、あるURLが別のURLにリダイレクトしているという事実は、httplib2が圧縮やキャッシュやETagsなどのHTTPの諸機能を扱う上で何の妨げにもならないのだ。

恒久的なリダイレクトも同じく簡単だ。

# 前の例から続く
>>> response, content = h.request('http://diveintopython3.org/examples/feed-301.xml')  
connect: (diveintopython3.org, 80)
send: b'GET /examples/feed-301.xml HTTP/1.1
Host: diveintopython3.org
accept-encoding: deflate, gzip
user-agent: Python-httplib2/$Rev: 259 $'
reply: 'HTTP/1.1 301 Moved Permanently'                                                
>>> response.fromcache                                                                 
True
  1. 前と同じく、このURLは実際には存在していない。そこで、http://diveintopython3.org/examples/feed.xmlに向けた恒久的なリダイレクトを送信するようにサーバーを設定しておいた。
  2. ほら、ステータスコード301が返ってきた。だが、またここで何が欠けているかに注意してほしい。リダイレクト先のURLに対するリクエストが送信されていないのだ。なぜか? その答えは「既にローカルにキャッシュされているから」だ。
  3. httplib2はリダイレクトを「たどって」、そのままキャッシュに行き着いたのだ。

だけど待って! 話はまだあるんだ!

# 前の例から続く
>>> response2, content2 = h.request('http://diveintopython3.org/examples/feed-301.xml')  
>>> response2.fromcache                                                                  
True
>>> content2 == content                                                                  
True
  1. ここに一時的なリダイレクトと恒久的なリダイレクトの違いがある。一度httplib2が恒久的なリダイレクトをたどると、そのURLに対するリクエストはそれから先、すべて自動でリダイレクト先のURLに書きかえられ、元のURLにネットワークを介してリクエストが送られることはないのだ。デバッグがまだオンになっていることを思い出して欲しい。それなのに、ネットワークを通じたやりとりは全く出力されていないのだ。
  2. そう、このレスポンスはキャッシュから取得したものだ。
  3. フィード全体を(キャッシュから)取得できている。

HTTP。こいつはちゃんと動くのだ。

HTTP GETの先へ

HTTPウェブサービスはGETリクエストだけに限られない。何か新しいものを作ろうと思ったとしたらどうだろうか? 議論が行われている掲示板にコメントを書き込む時であれ、ブログを更新する時であれ、またTwitterIdenti.caといったマイクロブログにあなたのステータスを投稿する場合であっても、たいていの場合、そこではHTTP POSTが使われている。

TwitterもIdenti.caも、140字以内であなたのステータスを投稿し、更新できるようにしてくれるシンプルなHTTPベースのAPIを公開している。ステータスを更新するためのIdenti.caのAPIドキュメントを見てみよう。

Identi.ca REST APIメソッド: ステータス/更新
Updates the authenticating user’s status. Requires the status parameter specified below. Request must be a POST.

URL
https://identi.ca/api/statuses/update.format
Formats
xml, json, rss, atom
HTTP Method(s)
POST
Requires Authentication
true
Parameters
status. Required. The text of your status update. URL-encode as necessary.

これはどのように動くのだろう? 新しいメッセージをIdenti.caに投稿するには、HTTP POSTリクエストをhttp://identi.ca/api/statuses/update.formatに送信しなければならない(formatの部分はURLの一部ではない。ここには、リクエストに対して、サーバーにどんなデータ形式でレスポンスを返信してほしいのかを入れる。XMLでレスポンスを返してほしければ、https://identi.ca/api/statuses/update.xmlにリクエストを送信する)。また、リクエストにはstatusという変数を含める必要があり、この変数にステータスを更新するメッセージが入ることになる。さらに、リクエストは認証を通らなくてはならない。

認証だって? もちろん。Identi.caでステータスを更新するには、あなたが誰であるかを証明しなくてはならない。Identi.caはwikiではないのだ。だから、あなただけがあなたのステータスを更新することができる。Identi.caはSSLを介したHTTP Basic認証a.k.a. RFC 2617)を利用して、セキュアで扱いやすい認証を提供している。httplib2SSLHTTP Basic認証もサポートしているので、この部分は簡単に済ますことができる。

POSTリクエストとGETリクエストとの違いは、POSTリクエストにはペイロードが入っているということにある。このペイロードとはサーバーに送信したいデータのことだ。ここで、APIメソッドはデータの一部としてstatus要求しているが、これはURLエンコードされている必要がある。このURLエンコードとは非常にシンプルな符号化形式で、キーと値のペアからなる集合(i.e. 辞書)を引数にとり、それを文字列に変換するというものだ。

>>> from urllib.parse import urlencode              
>>> data = {'status': 'Test update from Python 3'}  
>>> urlencode(data)                                 
'status=Test+update+from+Python+3'
  1. Pythonには辞書をURLエンコードするユーティリティ関数が用意されている。すなわち、urllib.parse.urlencode()だ。
  2. Identi.ca APIが要求しているのはこの種の辞書だ。ここにはstatusというキーが一つだけ入っていて、それに対応する値は一回分のステータス更新のメッセージになっている。
  3. URLエンコードされた文字列はこんな感じになる。これがHTTP POSTリクエストの際に、「回線を通じて」Identi.ca APIサーバーに送信されるペイロードなのだ。

>>> from urllib.parse import urlencode
>>> import httplib2
>>> httplib2.debuglevel = 1
>>> h = httplib2.Http('.cache')
>>> data = {'status': 'Test update from Python 3'}
>>> h.add_credentials('diveintomark', 'MY_SECRET_PASSWORD', 'identi.ca')    
>>> resp, content = h.request('https://identi.ca/api/statuses/update.xml',
...     'POST',                                                             
...     urlencode(data),                                                    
...     headers={'Content-Type': 'application/x-www-form-urlencoded'})      
  1. 次のようにしてhttplib2は認証を扱う。まず、add_credentials()メソッドを用いてユーザー名とパスワードを記憶する。それから、httplib2がリクエストを出すと、サーバーは401 Unauthorizedステータスコードを返し、さらにどの認証方式をサポートしているかのリストを(WWW-Authenticateヘッダに入れて)返送してくれる。httplib2は自動でAuthorizationヘッダを組み立てて、このURLに再びリクエストを送信してくれる。
  2. 二番目の変数はHTTPリクエストの種類だ。ここではPOSTになる。
  3. 三番目の変数はサーバーに送信するペイロードだ。ここではステータスメッセージの入った辞書をURLエンコードで変換して送信する。
  4. 最後に、ペイロードがURLエンコードで符号化されたものだということをサーバーに伝えなくてはならない。

add_credentials()メソッドの三番目の変数は、その認証が通用するドメインを表す。この部分については必ず明記しておくこと! このドメインを空白のままにしておくと、後で別の認証を必要とするサイトに対してhttplib2.Httpオブジェクトを再利用した時に、httplib2が元のサイト用のユーザー名とパスワードをその別のサイトに漏らしてしまいかねないからだ。

以下のものが回線を通じて送信された:

# 前の例から続く
send: b'POST /api/statuses/update.xml HTTP/1.1
Host: identi.ca
Accept-Encoding: identity
Content-Length: 32
content-type: application/x-www-form-urlencoded
user-agent: Python-httplib2/$Rev: 259 $

status=Test+update+from+Python+3'
reply: 'HTTP/1.1 401 Unauthorized'                        
send: b'POST /api/statuses/update.xml HTTP/1.1            
Host: identi.ca
Accept-Encoding: identity
Content-Length: 32
content-type: application/x-www-form-urlencoded
authorization: Basic SECRET_HASH_CONSTRUCTED_BY_HTTPLIB2  
user-agent: Python-httplib2/$Rev: 259 $

status=Test+update+from+Python+3'
reply: 'HTTP/1.1 200 OK'                                  
  1. 最初のリクエストに対して、サーバーは401 Unauthorizedステータスコードを返している。httplib2はサーバーから明示的に要求されない限り認証ヘッダを送信しないからだ。そして、サーバーはこのようにステータスコードを返すことで認証を要求するのだ。
  2. httplib2はすぐに向き直って、同じURLに二度目のリクエストを送信する。
  3. 今回は、add_credentials()メソッドを用いて追加したユーザー名とパスワードをリクエストに含めている。
  4. うまくいった!

リクエストは成功したが、そのあとサーバーは一体何を返してくるのだろう? これは完全にそのウェブサービスのAPIしだいだ。ある種のプロトコル(例えばAtom Publishing Protocol)は201 Createdステータスコードに加えて、新しく作られたリソースの場所をLocationヘッダに入れて返してくれる。Identi.caは200 OKと、新しく作られたリソースに関する情報の入ったXMLドキュメントを返す。

# 前の例から続く
>>> print(content.decode('utf-8'))                             
<?xml version="1.0" encoding="UTF-8"?>
<status>
 <text>Test update from Python 3</text>                        
 <truncated>false</truncated>
 <created_at>Wed Jun 10 03:53:46 +0000 2009</created_at>
 <in_reply_to_status_id></in_reply_to_status_id>
 <source>api</source>
 <id>5131472</id>                                              
 <in_reply_to_user_id></in_reply_to_user_id>
 <in_reply_to_screen_name></in_reply_to_screen_name>
 <favorited>false</favorited>
 <user>
  <id>3212</id>
  <name>Mark Pilgrim</name>
  <screen_name>diveintomark</screen_name>
  <location>27502, US</location>
  <description>tech writer, husband, father</description>
  <profile_image_url>http://avatar.identi.ca/3212-48-20081216000626.png</profile_image_url>
  <url>http://diveintomark.org/</url>
  <protected>false</protected>
  <followers_count>329</followers_count>
  <profile_background_color></profile_background_color>
  <profile_text_color></profile_text_color>
  <profile_link_color></profile_link_color>
  <profile_sidebar_fill_color></profile_sidebar_fill_color>
  <profile_sidebar_border_color></profile_sidebar_border_color>
  <friends_count>2</friends_count>
  <created_at>Wed Jul 02 22:03:58 +0000 2008</created_at>
  <favourites_count>30768</favourites_count>
  <utc_offset>0</utc_offset>
  <time_zone>UTC</time_zone>
  <profile_background_image_url></profile_background_image_url>
  <profile_background_tile>false</profile_background_tile>
  <statuses_count>122</statuses_count>
  <following>false</following>
  <notifications>false</notifications>
</user>
</status>
  1. 思い出して欲しいのだが、httplib2が返すデータは常にバイト列であって、文字列ではなかった。これを文字列に直すには、適切な文字コードを使ってデコードしなくてはならない。Identi.caのAPIは常に結果をUTF-8で返してくれるので、この部分にさしたる問題はない。
  2. ここに先ほど投稿したステータスメッセージの本文がある。
  3. ここにあるのは、この新しいステータスメッセージを表すユニークな識別子だ。Identi.caはこれをウェブ上でメッセージを見るためのURLを生成するのに使う。

ほら、ちゃんとできている。

Identi.caに投稿されたステータスメッセージを示すスクリーンショット

HTTP POSTの先へ

HTTPGETPOSTだけには留まらない。確かに、この二つは(特にウェブブラウザにおいて)最もよく使われているリクエストだが、ウェブサービスのAPIGETPOST以上のものを扱うことができるし、httplib2の方もそれに対応する準備ができている。

# 前の例から続く
>>> from xml.etree import ElementTree as etree
>>> tree = etree.fromstring(content)                                          
>>> status_id = tree.findtext('id')                                           
>>> status_id
'5131472'
>>> url = 'https://identi.ca/api/statuses/destroy/{0}.xml'.format(status_id)  
>>> resp, deleted_content = h.request(url, 'DELETE')                          
  1. サーバーはXMLを返してきたんだったよね? XMLをパースする方法はもう知ってるはずだ。
  2. findtext()メソッドは与えられた表現に最初に合致するものを探しだし、そこからそのテキストの内容を抽出する。ここでは単に<id>要素を探しているだけだ。
  3. 先ほど投稿したステータスメッセージを削除するために、<id>要素のテキストの内容に基づいてURLを構築する。
  4. メッセージを削除するには、単純にこのURLHTTP DELETEリクエストを送ればいい。

以下のものが回線を通じて送信された:

send: b'DELETE /api/statuses/destroy/5131472.xml HTTP/1.1      
Host: identi.ca
Accept-Encoding: identity
user-agent: Python-httplib2/$Rev: 259 $

'
reply: 'HTTP/1.1 401 Unauthorized'                             
send: b'DELETE /api/statuses/destroy/5131472.xml HTTP/1.1      
Host: identi.ca
Accept-Encoding: identity
authorization: Basic SECRET_HASH_CONSTRUCTED_BY_HTTPLIB2       
user-agent: Python-httplib2/$Rev: 259 $

'
reply: 'HTTP/1.1 200 OK'                                       
>>> resp.status
200
  1. 「このメッセージを削除してくれ」
  2. 「ごめんなさい、残念だけどそれはできないんだ」
  3. 「認証されていませんだって ふーむ。このメッセージを削除してくれ、頼むから……
  4. ……ほら、私のユーザー名とパスワードだ」
  5. 「お安いご用だ!」

そしてかくのごとく、メッセージはふっと消え去りぬ。

screenshot showing deleted message on Identi.ca

もっと知りたい人のために

httplib2

HTTPキャッシュ:

RFCs:

© 2001– Mark Pilgrim
© Fukada, Fujimoto(日本語版)