14章 HTTPウェブサービス

飛び込む

HTTP の命令だけを用いて遠隔にあるサーバーとデータをやりとりする

— HTTPウェブサービスの概念はこの33文字に集約される。

  • サーバーからデータを取得したければ、 http GET を使えばいい。
  • サーバーにデータを送信したければhttp POST を使えばいい。

もっと高度なhttpウェブサービスのapiもあって、 これらは

  • http PUT
  • http DELETE を利用して、 データの作成・修正・削除を行えるようにしてくれる。

httpの命令だけで全て処理できる。

ここにはレジストリも、エンベロープも、ラッパも、トンネリングも必要ない。 要するに、httpプロトコル内に構築されているこれらの「動詞」(GET, POST, PUT, DELETE) は、 データの取得・作成・修正・消去を行うアプリケーションレベルの命令に直接対応するもの。

このやり方の主たる利点は、 その単純さにあり、またこの単純さゆえに広く使われている。

データ — 普通はxmlかjson — は静的に構築して保存することもできるし、 サーバーサイドスクリプトを使って動的に生成することもできる。 そして、メジャーなプログラミング言語はすべて(Pythonも) このデータをダウンロードするためのhttpライブラリを備えている。

さらに、この方式だとデバッグも容易。 というのも、httpウェブサービスの個々のリソースにはユニークなアドレスが(urlの形式で)割り振られているため、 ウェブブラウザにロードすればすぐに生のデータを見ることができる。

httpウェブサービスの例:

  • Google Data apiを使えば、Blogger やYouTube といった様々なGoogle のサービスとやりとりすることができる。
  • Flickr Servicesを使えばFlickrに写真をアップロードしたり、ダウンロードしたりすることができる。
  • Twitter apiを使えば、Twitterに投稿することができる。

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

  • http.client は、httpプロトコルのrfc 2616を実装した低級ライブラリ。
  • urllib.request は、http.client 上に構築された抽象化レイヤ。 これはhttpサーバーとftpサーバーの両方にアクセスするための標準apiを提供してくれるもので、httpリダイレクトを自動でたどることもできれば、いくつかの一般的なhttp認証方式も扱える。

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

HTTPの諸機能

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

キャッシュ

ウェブサービスの種類にかかわらず知っておくべき最も重要なことは、ネットワークアクセスはとても高くつくということ。

接続を開き、リクエストを送り、そして遠隔サーバーからレスポンスを取得するまでには非常に長い時間がかかる。 レイテンシ(リクエストの送信後、その応答としてデータが受信され始めるまでの時間) は、 想像よりも大きくなる。 ルーターは処理を誤り、パケットは抜け落ち、さらに中継のプロキシはアタックを受ける

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 にアクセスしたとする。

すると、

  1. まずブラウザはこの画像を探してローカルキャッシュを漁る。
  2. しかし、見つけることができないので、今度は遠隔サーバーからダウンロードしようと、ネットワークを通してリクエストを送信するだろう。
  3. そこで、もしキャッシュプロキシの方にまだ画像のコピーがあれば、リクエストはそこで止められて、プロキシのキャッシュから画像が返される。

つまり、リクエストが遠隔サーバーに到達することはない。 現に、このリクエストはあなたの会社のネットワークを離れてさえいない。 この仕組みのおかげで、高速なダウンロード(より少ないホップ数での通信)が可能になり、 会社側のコストも節約(外部からダウンロードされるデータをより少なく)できる。

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を使えばこれをコマンドライン上でテストすることもできる。

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

同じデータを2度目にリクエストする時には、 Etagのハッシュを入れた If-None-Matchヘッダを付けて送る。 データが変わっていなければ、サーバーは 304ステータスコードを送り返してくれる。 この場合、last-modifiedチェックの時と同じく、 サーバーは304ステータスコードだけを返すので、 同じデータが2度送信されることはない。

つまり、Etagのハッシュを2度目のリクエストの際に一緒に送ることで 「最後にリクエストした時のデータがまだ残っているから、まだハッシュが一致するようなら同じデータを再送信する必要は無いよ」とサーバーに伝えていることになる。

curlを使うと:

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

ETagは一般に引用符で括られているのだが、実はこの引用符も値の一部。 だから、If-None-Matchヘッダをサーバーに送り返す時には引用符を付けて返さなくてはならない。

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

圧縮

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

httpはいくつかの圧縮アルゴリズムをサポートしているが、 最も一般に用いられている形式は gzipdeflate の2つ。 httpを使ってリソースをリクエストする時には、圧縮形式で送るようにサーバーに頼むことができる。

サポートしている圧縮アルゴリズムのリストが入った Accept-encodingヘッダをリクエストに付け加えればいい。 サーバーがそのアルゴリズムのいずれかをサポートしていれば、 圧縮したデータを返してくれる(この場合、どのアルゴリズムが使われたかを示す Content-encodingヘッダもついてくる)。 後は、送られてきたデータを展開すればいい。

圧縮したデータと未圧縮のデータが異なるEtagを持つようにすること。 でないと、キャッシュプロキシが混乱して、 クライアントが扱えないのに圧縮された形式で返してしまいかねない。 この微妙な問題の詳細については、Apacheバグ 39727に関する議論に書いてある。

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

リダイレクト

ウェブサイトは再構成されて、 ページは新しいアドレスに移されてしまう。 ウェブサービスですら再編されることがある。

例えば、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フィードなどのリソースをダウンロードしたいと考えたとする。 フィードということなので、一回ダウンロードするだけでは済まず、 何回も何回もダウンロードすることになる (ほとんどのフィードリーダーは一時間に一回、更新をチェックする)。

まずは、こいつを手早く雑に実装してみて、それからどうやったら改善できるかを考える。

In [1]:
import urllib.request
urllib.request
Out[1]:
<module 'urllib.request' from 'C:\\Miniconda3\\lib\\urllib\\request.py'>
In [2]:
a_url = 'http://cartman0.hatenablog.com/feed'

どんなものであれhttpを使ってダウンロードするのは、Pythonでは簡単。 実際に、たった一行でできてしまう。

urllib.requestモジュールには便利な urlopen() という関数が用意されていて、 これはダウンロードしたいページのアドレスを引数にとり、 ファイルに似たオブジェクトを返すもの。 そして、このオブジェクトを read()するだけでページの内容を全て取得することができる。

In [3]:
data = urllib.request.urlopen(a_url).read()

urlopen().read()メソッドは常に文字列ではなく、 bytesオブジェクトを返す。 バイト列はバイト列であって、文字列はそれを抽象化したものだった。

httpは抽象化されたものを扱わないので、リソースをリクエストした時には、 バイト列の形で受け取ることになる。 それを文字列として扱いたいなら、文字コードを定めて明示的に文字列に変換しなくてはならない。

In [4]:
type(data)
Out[4]:
bytes
> print(data)

b'<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ja">\n  <title>\xe3\x81\xaf\xe3\x81\x97\xe3\x81\x8f\xe3\x82\x8c\xe3\x82\xa8\xe3\x83\xb3\xe3\x82\xb8\xe3\x83\x8b\xe3\x82\xa2\xe3\x82\x82\xe3\x81\xa9\xe3\x81\x8d\xe3\x81\xae\xe3\x83\xa1\xe3\x83\xa2</title>\n  \n  <subtitle>\xe6\x83\x85\xe5\xa0\xb1\xe3\x83\xbbWeb\xe7\xb3\xbb\xe6\x8a\x80\xe8\xa1\x93\xe3\x81\xae\xe5\x8b\x89\xe5\xbc\xb7\xe3\x83\xa1\xe3\x83\xa2\xe3\x83\xbb\xe5\x82\x99\xe5\xbf\x98\xe9\x8c\xb2\xe3\x81\xa7\xe3\x81\x99\xe3\x80\x82</subtitle>\n  \n  <link href="http://cartman0.hatenablog.com/"/>\n  <updated>2016-04-06T20:18:44+09:00</updated>\n  <author>\n    <name>cartman0</name>\n  </author>\n  <generator uri="http://blog.hatena.ne.jp/" version="8c91984a041e93c276225325bff2ee2b">Hatena::Blog</generator>\n  <id>hatenablog://blog/12921228815722929243</id>\n\n  \n    \n    \n    <entry>\n        <title>Dive Into Python3 13\xe7\xab\xa0\xe3\x83\xa1\xe3\x83\xa2\xef\xbc\x88Python\xe3\x82\xaa\xe3\x83\x96\xe3\x82\xb8\xe3\x82\xa7\xe3\x82\xaf\xe3\x83\x88\xe3\x82\x92\xe3\x82\xb7\xe3\x83\xaa\xe3\x82\xa2\xe3\x83\xa9\xe3\x82\xa4\xe3\x82\xba\xef\xbc\x89</title>\n
....

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

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

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

In [5]:
from http.client import HTTPConnection
HTTPConnection
Out[5]:
http.client.HTTPConnection

urllib.request は、http.client という他のPythonの標準ライブラリに依存している。 本来ならhttp.clientに直接触れる必要は無いのだが(urllib.requestモジュールが自動でインポートしてくれる)、 ここではurllib.request がhttpサーバーに接続する際に使っているHTTPConnectionクラスのデバッグフラグをオンに切り替えるためにインポートしている。

In [6]:
HTTPConnection.debuglevel = 1
In [7]:
from urllib.request import urlopen

デバッグフラグが立っているので、httpのリクエストとレスポンスに関する情報がリアルタイムで出力される。 ご覧のとおり、このAtomフィードをリクエストする際に、 urllib.requestモジュールは 5行のコードを送っている。

In [8]:
response = urlopen('https://raw.githubusercontent.com/chungy/Dive-Into-Python-3/master/examples/feed.xml')
send: b'GET /chungy/Dive-Into-Python-3/master/examples/feed.xml HTTP/1.1\r\nAccept-Encoding: identity\r\nConnection: close\r\nUser-Agent: Python-urllib/3.5\r\nHost: raw.githubusercontent.com\r\n\r\n'
reply: 'HTTP/1.1 200 OK\r\n'
header: Content-Security-Policy header: X-XSS-Protection header: X-Frame-Options header: X-Content-Type-Options header: Strict-Transport-Security header: ETag header: Content-Type header: Cache-Control header: X-GitHub-Request-Id header: Content-Length header: Accept-Ranges header: Date header: Via header: Connection header: X-Served-By header: X-Cache header: X-Cache-Hits header: Vary header: Access-Control-Allow-Origin header: X-Fastly-Request-ID header: Expires header: Source-Age 

最初の行(send:):

send: b'GET /chungy/Dive-Into-Python-3/master/examples/feed.xml HTTP/1.1

使っているhttpの動詞と、リソースのパス(からドメイン名を引いたもの)を明示している。

2行目(Host):

Host: raw.githubusercontent.com

リクエストしているフィードのあるドメイン名を示している。

3行目(Accept-Encoding:):

Accept-Encoding: identity

クライアントがサポートしている圧縮アルゴリズムを指定している。 標準では urllib.request は圧縮をサポートしていない。

4行目(User-Agent:):

User-Agent: Python-urllib/3.5

リクエストを行っているライブラリの名前を示している。 標準ではPython-urllib とバージョン番号が記される。 urllib.requesthttplib2 ではこのユーザーエージェントを変更することができ、 そのためには単にUser-Agentヘッダをリクエストに加えるだけでいい(こうするとデフォルトの値が置き換えられる)。

サーバーが何を送り返してきたのかを見てみる。

urllib.request.urlopen() 関数から返された response にはサーバーが返したhttpヘッダが全て入っている。 加えて、このオブジェクトには実際のデータをダウンロードするためのメソッドも入っている。

In [9]:
print(response.headers.as_string()) 
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'
X-XSS-Protection: 1; mode=block
X-Frame-Options: deny
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=31536000
ETag: "bcdda81dde068c9e23eb55a81b23ed86278fdb59"
Content-Type: text/plain; charset=utf-8
Cache-Control: max-age=300
X-GitHub-Request-Id: 2BF9481F:5B27:4344A02:57066E88
Content-Length: 3070
Accept-Ranges: bytes
Date: Thu, 07 Apr 2016 14:28:25 GMT
Via: 1.1 varnish
Connection: close
X-Served-By: cache-nrt6127-NRT
X-Cache: MISS
X-Cache-Hits: 0
Vary: Authorization,Accept-Encoding
Access-Control-Allow-Origin: *
X-Fastly-Request-ID: 4c0366f07b2d933e51b417fbf90838536521137e
Expires: Thu, 07 Apr 2016 14:33:25 GMT
Source-Age: 0


サーバーはいつリクエストを処理したのかを教えてくれる。

Date: Wed, 06 Apr 2016 16:07:03 GMT

feedによっては、レスポンスに以下のように、Last-Modifiedヘッダが含まれる場合もある。

Last-Modified: Sun, 31 May 2009 06:39:55 GMT

さらには ETagヘッダもこのレスポンスに入っている。

ETag: "bcdda81dde068c9e23eb55a81b23ed86278fdb59"

このデータは3070バイト。 ここに何が欠けているかに注意してほしい。 つまり、これには Content-encodingヘッダが抜けている。 リクエストでは圧縮していないデータしか受け取れないと明示したので(Accept-encoding: identity)、当然のことながら、このレスポンスには圧縮されていないデータが入っている。

Content-Length: 3070

このレスポンスにはキャッシュヘッダが含まれていて、これは「300秒までならキャッシュしてもいいよ」と述べている。

Cache-Control: max-age=300

最後に、response.read() を呼び出すことで実際のデータをダウンロードしている。 len()関数の戻り値を見れば分かるように、ここでは一度に3070バイトをダウンロードしている。

In [10]:
data = response.read()
data
Out[10]:
b"<?xml version='1.0' encoding='utf-8'?>\r\n<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'>\r\n  <title>dive into mark</title>\r\n  <subtitle>currently between addictions</subtitle>\r\n  <id>tag:diveintomark.org,2001-07-29:/</id>\r\n  <updated>2009-03-27T21:56:07Z</updated>\r\n  <link rel='alternate' type='text/html' href='http://diveintomark.org/'/>\r\n  <entry>\r\n    <author>\r\n      <name>Mark</name>\r\n      <uri>http://diveintomark.org/</uri>\r\n    </author>\r\n    <title>Dive into history, 2009 edition</title>\r\n    <link rel='alternate' type='text/html'\r\n      href='http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition'/>\r\n    <id>tag:diveintomark.org,2009-03-27:/archives/20090327172042</id>\r\n    <updated>2009-03-27T21:56:07Z</updated>\r\n    <published>2009-03-27T17:20:42Z</published>\r\n    <category scheme='http://diveintomark.org' term='diveintopython'/>\r\n    <category scheme='http://diveintomark.org' term='docbook'/>\r\n    <category scheme='http://diveintomark.org' term='html'/>\r\n    <summary type='html'>Putting an entire chapter on one page sounds\r\n      bloated, but consider this &amp;mdash; my longest chapter so far\r\n      would be 75 printed pages, and it loads in under 5 seconds&amp;hellip;\r\n      On dialup.</summary>\r\n  </entry>\r\n  <entry>\r\n    <author>\r\n      <name>Mark</name>\r\n      <uri>http://diveintomark.org/</uri>\r\n    </author>\r\n    <title>Accessibility is a harsh mistress</title>\r\n    <link rel='alternate' type='text/html'\r\n      href='http://diveintomark.org/archives/2009/03/21/accessibility-is-a-harsh-mistress'/>\r\n    <id>tag:diveintomark.org,2009-03-21:/archives/20090321200928</id>\r\n    <updated>2009-03-22T01:05:37Z</updated>\r\n    <published>2009-03-21T20:09:28Z</published>\r\n    <category scheme='http://diveintomark.org' term='accessibility'/>\r\n    <summary type='html'>The accessibility orthodoxy does not permit people to\r\n      question the value of features that are rarely useful and rarely used.</summary>\r\n  </entry>\r\n  <entry>\r\n    <author>\r\n      <name>Mark</name>\r\n    </author>\r\n    <title>A gentle introduction to video encoding, part 1: container formats</title>\r\n    <link rel='alternate' type='text/html'\r\n      href='http://diveintomark.org/archives/2008/12/18/give-part-1-container-formats'/>\r\n    <id>tag:diveintomark.org,2008-12-18:/archives/20081218155422</id>\r\n    <updated>2009-01-11T19:39:22Z</updated>\r\n    <published>2008-12-18T15:54:22Z</published>\r\n    <category scheme='http://diveintomark.org' term='asf'/>\r\n    <category scheme='http://diveintomark.org' term='avi'/>\r\n    <category scheme='http://diveintomark.org' term='encoding'/>\r\n    <category scheme='http://diveintomark.org' term='flv'/>\r\n    <category scheme='http://diveintomark.org' term='GIVE'/>\r\n    <category scheme='http://diveintomark.org' term='mp4'/>\r\n    <category scheme='http://diveintomark.org' term='ogg'/>\r\n    <category scheme='http://diveintomark.org' term='video'/>\r\n    <summary type='html'>These notes will eventually become part of a\r\n      tech talk on video encoding.</summary>\r\n  </entry>\r\n</feed>\r\n"
In [11]:
len(data)
Out[11]:
3070

この時点で既にこのコードは非効率的。 このコードは圧縮されていないデータをリクエストしている(そしてその通り受け取っている)のだ。 httpの圧縮機能を利用するにはgzip のようなものを指定しておかなければならない。 今回はそう指定しなかったので、データは圧縮されなかった。 だから、941バイトで済むところを、3070バイトもダウンロードすることになってしまった。

このコードがどれだけ非効率かを見るために、 もう一度同じフィードをリクエストしてみる。

In [12]:
response2 = urlopen('https://raw.githubusercontent.com/chungy/Dive-Into-Python-3/master/examples/feed.xml')
send: b'GET /chungy/Dive-Into-Python-3/master/examples/feed.xml HTTP/1.1\r\nAccept-Encoding: identity\r\nConnection: close\r\nUser-Agent: Python-urllib/3.5\r\nHost: raw.githubusercontent.com\r\n\r\n'
reply: 'HTTP/1.1 200 OK\r\n'
header: Content-Security-Policy header: X-XSS-Protection header: X-Frame-Options header: X-Content-Type-Options header: Strict-Transport-Security header: ETag header: Content-Type header: Cache-Control header: X-GitHub-Request-Id header: Content-Length header: Accept-Ranges header: Date header: Via header: Connection header: X-Served-By header: X-Cache header: X-Cache-Hits header: Vary header: Access-Control-Allow-Origin header: X-Fastly-Request-ID header: Expires header: Source-Age 

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

キャッシュのためのCache-Control とExpires、更新チェックを可能にするLast-ModifiedETagだ。 しかも、 Vary: Accept-Encodingヘッダは、このサーバーは要求さえあればデータの圧縮も扱えるということをほのめかしてさえいる。しかし、ここでそのように要求しなかった。

In [13]:
print(response2.headers.as_string())
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'
X-XSS-Protection: 1; mode=block
X-Frame-Options: deny
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=31536000
ETag: "bcdda81dde068c9e23eb55a81b23ed86278fdb59"
Content-Type: text/plain; charset=utf-8
Cache-Control: max-age=300
X-GitHub-Request-Id: 2BF9481F:5B27:4344A02:57066E88
Content-Length: 3070
Accept-Ranges: bytes
Date: Thu, 07 Apr 2016 14:28:25 GMT
Via: 1.1 varnish
Connection: close
X-Served-By: cache-nrt6121-NRT
X-Cache: HIT
X-Cache-Hits: 1
Vary: Authorization,Accept-Encoding
Access-Control-Allow-Origin: *
X-Fastly-Request-ID: d86b916fd1178aa526be4ea549c97474be0f5f25
Expires: Thu, 07 Apr 2016 14:33:25 GMT
Source-Age: 1


データを取得するのに丸々3070バイトもダウンロードしている

In [14]:
data2 = response2.read()
In [15]:
len(data2)
Out[15]:
3070

先ほどダウンロードしたのと寸分違わぬ3070バイト。

In [16]:
data2 == data
Out[16]:
True

httpは、これよりもっと上手く処理できるように設計されている。

httplib2の紹介

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

pip からインストールできる。

pip install httplib2
In [17]:
import httplib2
httplib2
Out[17]:
<module 'httplib2' from 'C:\\Miniconda3\\lib\\site-packages\\httplib2\\__init__.py'>

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

httplib2の主要なインターフェイスはHttpオブジェクト。 Httpオブジェクトを作る時は常にディレクトリ名を渡さなければならない。 この時、そのディレクトリはまだ存在しないものであっても構わない。 必要に応じてhttplib2が作成してくれる。

In [18]:
h = httplib2.Http('.cache')

Httpオブジェクトができたら、データを取得するのは簡単で、 request()メソッドに欲しいデータのアドレスを渡して呼び出すだけでいい。 そうすれば、そのurlに対するhttp GETリクエストが送信される。

In [19]:
(response, content) = h.request('https://raw.githubusercontent.com/chungy/Dive-Into-Python-3/master/examples/feed.xml') 

request()メソッドは二つの値を返す。

一つ目がhttplib2.Responseオブジェクトで、これにはサーバーが送り返してきたhttpヘッダが全て入っている。 例えば、statusの200という値は、リクエストが成功したことを示している。

In [20]:
response.status
Out[20]:
200

content変数にはhttpサーバーから送り返された実際のデータが入っている。 このデータは文字列ではなくbytesオブジェクトの形式で返されるので、 文字コードを定めて、自分で変換しなくてはならない。

In [21]:
content[:52]
Out[21]:
b"<?xml version='1.0' encoding='utf-8'?>\r\n<feed xmlns="
In [22]:
len(content)
Out[22]:
3070

補足: 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/xml
  • application/xml-dtd
  • application/xml-external-parsed-entity のいずれかであるか、 あるいはapplication/xmlのサブタイプ(例えば、application/atom+xmlやapplication/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はキャッシュをどのように扱うのか

先ほどの指定したフォルダ(.cache)に、キャッシュが保存される。

In [23]:
(response2, content2) = h.request('https://raw.githubusercontent.com/chungy/Dive-Into-Python-3/master/examples/feed.xml') 

httpのstatusは同じく200で、前から何も変わっていない。

In [24]:
response2.status
Out[24]:
200

ダウンロードした内容にも変化はない。

In [25]:
content2[:52]
Out[25]:
b"<?xml version='1.0' encoding='utf-8'?>\r\n<feed xmlns="
In [26]:
len(content2)
Out[26]:
3070
In [27]:
import httplib2

この行はhttp.clientでデバッグをオンにするのと同じ役割を果たすもので、 httplib2 がサーバーに送信したデータ全部と、返送されてきた情報の中の主要なものを出力してくれるようになる。

In [28]:
httplib2.debuglevel = 1

前と同じディレクトリ名を渡して、httplib2.Httpオブジェクトを作成する。

In [29]:
h = httplib2.Http('.cache')

前回と同様に、同じurlをリクエストする。しかし何も起こっていないようだ。 もっと正確に言えば、何もサーバーに送られていなければ、 サーバーから返ってきてもいない。 ここではネットワークを通したやりとりが全く行われていない。

In [30]:
(response, content) = h.request('https://raw.githubusercontent.com/chungy/Dive-Into-Python-3/master/examples/feed.xml') 

しかし、現実に何らかのデータを「受信」してはいる — 実のところ、 すべてのデータを受け取っている。

In [31]:
len(content)
Out[31]:
3070

「リクエスト」が成功したことを示すhttpステータスコードも「受信」している。

In [32]:
response.status
Out[32]:
200

問題があるのはこの部分。 この「レスポンス」は httplib2 のローカルキャッシュから生成されたもの。 httplib2.Httpオブジェクトを作るときにディレクトリ名を渡したが — そのディレクトリは httplib2 がこれまでに行った処理全てをキャッシュしている。

In [33]:
response.fromcache
Out[33]:
True

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

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

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

データのキャッシュを持ってはいるが、 そのキャッシュを無視して遠隔サーバーに再リクエストしたいと思ったとする。 ブラウザはユーザーから特に要求があればこういう処理をする。 例えば、F5 を押すと現在見ているページが更新されるが、 Ctrl+F5 を押せばキャッシュを無視して遠隔サーバーにリクエストが行われる。 ここで「単にキャッシュからデータを削除して、もう一度リクエストする」こともできる。

もちろんそうすることもできるが、あなたと遠隔サーバー以外にもこの処理に関わっているものが存在しているかもしれないということを思い出してほしい。 例えば、中間にあるプロキシサーバーはどうだろうか? これについては完全にあなたの手の外にあるが、 ここにまだデータがキャッシュされているかもしれない。 その場合、(プロキシサーバーにとっては)キャッシュはまだ有効なので、特に何も気にとめることなくキャッシュの方を返してくることになる。

httpの機能を使ってリクエストが確実に遠隔サーバーに届くようにすべき。

httplib2 を使えば、どのリクエストにも任意のhttpヘッダを加えることができる。 全てのキャッシュ(つまり、ローカルディスクにあるキャッシュだけではなく、 あなたと遠隔サーバーの間にあるキャッシュプロキシ全て)を無視するには、 no-cacheヘッダをheaders辞書に加えればいい。

In [34]:
(response2, content2) = h.request('https://raw.githubusercontent.com/chungy/Dive-Into-Python-3/master/examples/feed.xml',
                                 headers={'cache-control':'no-cache'})
send: b'GET /chungy/Dive-Into-Python-3/master/examples/feed.xml HTTP/1.1\r\nHost: raw.githubusercontent.com\r\ncache-control: no-cache\r\nuser-agent: Python-httplib2/0.9.2 (gzip)\r\naccept-encoding: gzip, deflate\r\n\r\n'
reply: 'HTTP/1.1 200 OK\r\n'
header: Content-Security-Policy header: X-XSS-Protection header: X-Frame-Options header: X-Content-Type-Options header: Strict-Transport-Security header: ETag header: Content-Type header: Cache-Control header: X-GitHub-Request-Id header: Content-Encoding header: Content-Length header: Accept-Ranges header: Date header: Via header: Connection header: X-Served-By header: X-Cache header: X-Cache-Hits header: Vary header: Access-Control-Allow-Origin header: X-Fastly-Request-ID header: Expires header: Source-Age 

httplib2 がネットワークを通じたリクエストを開始している。 httplib2 は双方向 — つまり、レスポンスの受信とリクエストの送信の両方においてキャッシュのヘッダを理解し、それに従ってくれる。 ここでは、no-cacheヘッダが追加されたことをちゃんと認識している。 だからこそ、ローカルキャッシュを全て無視したのであり、 その結果としてネットワークを介したデータのリクエストを行わざるを得なくなった。

In [35]:
response2.status
Out[35]:
200

このレスポンスはローカルキャッシュから生成されたものではない。 このことはリクエスト送信に関するデバッグ情報が出力されているのを見れば明らかなのだが、これを手続的に確認できる。

In [36]:
response2.fromcache
Out[36]:
False

リクエストが成功したので、 遠隔サーバーからフィード全体を再びダウンロードすることができた。 当然ながら、サーバーはフィードのデータと一緒にhttpヘッダも全て送り返してくれている。 この中にはキャッシュのヘッダも入っていて、 httplib2 はこれを使ってローカルキャッシュを更新する。 次にこのフィードがリクエストされた時に、 ネットワークを通じたアクセスを避けられるかもしれないから。

httpキャッシュに関わるどの部分も、 キャッシュの利用を最大にし、 ネットワークを介したアクセスを最小にするように設計されている。 今回はキャッシュを無視したが、今回のリクエストの結果を次回のリクエストに備えてキャッシュした。

In [37]:
print(dict(response2.items()))
{'x-fastly-request-id': 'db1381a2918cc0dbeb9df46d9c0ea934be532da3', 'connection': 'keep-alive', 'x-xss-protection': '1; mode=block', 'status': '200', 'strict-transport-security': 'max-age=31536000', 'source-age': '1', 'cache-control': 'max-age=300', 'content-type': 'text/plain; charset=utf-8', 'x-cache': 'HIT', 'date': 'Thu, 07 Apr 2016 14:28:27 GMT', 'x-frame-options': 'deny', 'content-length': '3070', 'expires': 'Thu, 07 Apr 2016 14:33:27 GMT', '-content-encoding': 'gzip', 'access-control-allow-origin': '*', 'accept-ranges': 'bytes', 'x-served-by': 'cache-nrt6134-NRT', 'x-github-request-id': '2BF9481F:5B28:51F11CF:57066E8A', 'x-cache-hits': '1', 'via': '1.1 varnish', 'etag': '"bcdda81dde068c9e23eb55a81b23ed86278fdb59"', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'", 'vary': 'Authorization,Accept-Encoding', 'content-location': 'https://raw.githubusercontent.com/chungy/Dive-Into-Python-3/master/examples/feed.xml', 'x-content-type-options': 'nosniff'}

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

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

しかし、データが更新された可能性があったのだが、 リクエストを送信してみたら実際には更新されていなかった、 という場合はどうだろう。

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

In [38]:
import httplib2
httplib2.debuglevel = 1
h = httplib2.Http('.cache')

フィードの代わりに、今回はサイトのホームページ(htmlドキュメント)をダウンロードする。 このページをリクエストするのは今回が始めてなので、httplib2 がやるべき仕事は少ない。 実際、最小限のヘッダだけを付けてリクエストを送信している。

In [39]:
(response, content) = h.request('https://raw.githubusercontent.com/diveintomark/diveintopython3/master/diveintopython3.org')
send: b'GET /diveintomark/diveintopython3/master/diveintopython3.org HTTP/1.1\r\nHost: raw.githubusercontent.com\r\nuser-agent: Python-httplib2/0.9.2 (gzip)\r\nif-none-match: "0dc933db83d0bb51bef9121df2e4a3ebd296212a"\r\naccept-encoding: gzip, deflate\r\n\r\n'
reply: 'HTTP/1.1 304 Not Modified\r\n'
header: Date header: Via header: Cache-Control header: ETag header: Connection header: X-Served-By header: X-Cache header: X-Cache-Hits header: Vary header: Access-Control-Allow-Origin header: X-Fastly-Request-ID header: Expires header: Source-Age 

返ってきたレスポンスにはhttpヘッダがいくつも入っている……が、 キャッシュに関する情報は含まれていない。 しかし、ここにはETagヘッダLast-Modifiedヘッダ が 2つとも入っている。

In [40]:
print(dict(response.items()))
{'x-fastly-request-id': 'f151127d948911939963b02df0b713193db27b34', 'source-age': '0', '-varied-accept-encoding': 'gzip, deflate', 'x-xss-protection': '1; mode=block', 'status': '304', 'strict-transport-security': 'max-age=31536000', 'connection': 'keep-alive', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'", 'content-type': 'text/plain; charset=utf-8', 'x-cache': 'MISS', 'date': 'Thu, 07 Apr 2016 14:28:28 GMT', 'x-frame-options': 'deny', 'via': '1.1 varnish', 'expires': 'Thu, 07 Apr 2016 14:33:28 GMT', '-content-encoding': 'gzip', 'access-control-allow-origin': '*', 'accept-ranges': 'bytes', 'x-served-by': 'cache-nrt6134-NRT', 'x-github-request-id': '2BF94815:2081:27AC3EC:5705DF47', 'x-cache-hits': '0', 'content-length': '1971', 'etag': '"0dc933db83d0bb51bef9121df2e4a3ebd296212a"', 'cache-control': 'max-age=300', 'vary': 'Authorization,Accept-Encoding', 'content-location': 'https://raw.githubusercontent.com/diveintomark/diveintopython3/master/diveintopython3.org', 'x-content-type-options': 'nosniff'}
In [41]:
len(content)
Out[41]:
1971

同じページを、同じHttpオブジェクト(と同じローカルキャッシュ)を使って再びリクエストしてみる。

In [42]:
(response, content) = h.request('https://raw.githubusercontent.com/diveintomark/diveintopython3/master/diveintopython3.org')

クライエントの方に戻ると、httplib2は304ステータスコードを認識し、キャッシュからページの内容をロードしている。

In [43]:
response.fromcache
Out[43]:
True

ここには実際に2つのステータスコードがある — すなわち、304(今回サーバーから返されたもの。これが返されたからhttplib2 はキャッシュの方を参照したのだ)と、 200(前回サーバーから返されたもの。 ページのデータと一緒にhttplib2のキャッシュに保存されていた)だ。 response.status はキャッシュのステータスコードを返す。

In [44]:
response.status # キャッシュのステータスコード
Out[44]:
200

サーバーから返された本当のステータスコードが欲しいなら、 response.dict を参照すればいい。 これはサーバーから返された実際のヘッダを収めた辞書になる。

In [45]:
response.dict['status']
Out[45]:
'200'

どうであれcontent変数にはちゃんとデータが入っている。 一般論としては、なぜレスポンスがキャッシュから生成されたのかを知る必要はないだろう(もしかしたらキャッシュから生成されたということすら気にかけないかもしれないが、 それでも全くかまわない。 httplib2 は賢いので、こちらがおろそかでもきちんと処理してくれる)。 request() の処理が完了するころには、 httplib2 は既にキャッシュをアップデートして、データを返してくれている。

In [46]:
len(content)
Out[46]:
1971

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

httpは数種類の圧縮形式をサポートしているが、 中でも最もよく使われているのは

  • gzip
  • deflate

の2つ。 httplib2 はこの両方をサポートしている。

In [47]:
response, content = h.request('https://raw.githubusercontent.com/diveintomark/diveintopython3/master/diveintopython3.org')

httplib2が送信するリクエストには、 必ず Accept-Encoding というヘッダが付けられている。 このヘッダによって deflategzip のどちらかなら扱えるということをサーバーに伝えている。

accept-encoding: gzip, deflate

サーバーは、gzip形式で圧縮されたデータを返している。 request() の処理が完了するころには、 httplib2 は既にデータを展開し、 content変数に入れ終わっているのだ。 返送されたデータが圧縮されたものだったかどうかを知りたいなら、 response['-content-encoding'] をチェックすればいい。 そうでなければ、何も気にする必要はない。

In [48]:
print(dict(response.items()))
{'x-fastly-request-id': 'f151127d948911939963b02df0b713193db27b34', 'source-age': '0', '-varied-accept-encoding': 'gzip, deflate', 'x-served-by': 'cache-nrt6134-NRT', 'status': '200', 'strict-transport-security': 'max-age=31536000', 'connection': 'keep-alive', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'", 'content-type': 'text/plain; charset=utf-8', 'x-cache': 'MISS', 'date': 'Thu, 07 Apr 2016 14:28:28 GMT', 'x-frame-options': 'deny', 'content-length': '1971', 'expires': 'Thu, 07 Apr 2016 14:33:28 GMT', '-content-encoding': 'gzip', 'access-control-allow-origin': '*', 'accept-ranges': 'bytes', 'x-xss-protection': '1; mode=block', 'x-github-request-id': '2BF94815:2081:27AC3EC:5705DF47', 'x-cache-hits': '0', 'via': '1.1 varnish', 'etag': '"0dc933db83d0bb51bef9121df2e4a3ebd296212a"', 'cache-control': 'max-age=300', 'vary': 'Authorization,Accept-Encoding', 'content-location': 'https://raw.githubusercontent.com/diveintomark/diveintopython3/master/diveintopython3.org', 'x-content-type-options': 'nosniff'}

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

httpは2種類のリダイレクトを定義していた。 つまり、

  • 一時的なもの
  • 恒久的なもの

このうち、一時的なリダイレクトについては、 そのリダイレクトをたどるということ (これはhttplib2が自動でやってくれる)以外に、 特に何か処理を行う必要はなかった。

In [49]:
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にはフィードが入っていない。正しいアドレスに一時的にリダイレクトするようサーバーを設定しておいた。 ② ここでリクエストが行われている。 ③ それに対するレスポンスは302 Foundだ。ここには示されていないが、このレスポンスには正しいurlを示すLocationヘッダが入っている。 ④ httplib2はすぐに方向を変えて、Locationヘッダで与えられたhttp://diveintopython3.org/examples/feed.xml (http://diveintopython3-ja.rdy.jp/http-web-services.html)というurl に新たなリクエストを送信し、リダイレクトを「たどって」くれる。

取得した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                                            ③
>>>
  • response.previous属性を調べれば、httplib2が現在のレスポンスオブジェクトにたどり着く直前に経由したレスポンスオブジェクトを参照することができる。
  • ② responseもresponse.previousのどちらもhttplib2.Responseオブジェクトだ。

  • ③ 従って、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

① 同じurlに、同じhttplib2.Httpオブジェクトだ(したがってキャッシュも同じだ)。 ② 302レスポンスはキャッシュされなかったので、httplib2は別のリクエストを同じurlに送信している。 ③ またしても、サーバーは302を返している。しかし、ここに何が欠けているかに注意してほしい。ここでは最終的なurlの http://diveintopython3.org/examples/feed.xml に対する二回目のリクエストが送られていないのだ。つまり、先ほどのレスポンスはキャッシュされていて(前の例で見たCache-Controlを思い出して欲しい)、さらにhttplib2は302 Foundを受け取ると、新しくリクエストを送信するのに先立ってまずキャッシュをチェックしたということだ。キャッシュにはまだ新しいhttp://diveintopython3.org/examples/feed.xml のコピーがあったので、再びリクエストする必要が無かったのだ。 ④ 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

① 前と同じく、このurlは実際には存在していない。 そこで、http://diveintopython3.org/examples/feed.xml に向けた恒久的なリダイレクトを送信するようにサーバーを設定しておいた。 ② ほら、ステータスコード301が返ってきた。だが、またここで何が欠けているかに注意してほしい。リダイレクト先のurlに対するリクエストが送信されていないのだ。なぜか? その答えは「既にローカルにキャッシュされているから」だ。 ③ httplib2はリダイレクトを「たどって」、そのままキャッシュに行き着いたのだ。

ここに一時的なリダイレクトと恒久的なリダイレクトの違いがある。 一度httplib2が恒久的なリダイレクトをたどると、 そのurlに対するリクエストはそれから先、 すべて自動でリダイレクト先のurlに書きかえられ、 元のurlにネットワークを介してリクエストが送られることはない。 デバッグがまだオンになっていることを思い出して欲しい。

それなのに、ネットワークを通じたやりとりは全く出力されていない。

response2, content2 = h.request('http://diveintopython3.org/examples/feed-301.xml')  ①
>>> response2.fromcache                                                                  ②
True
>>> content2 == content                                                                  ③
True
  • ② そう、このレスポンスはキャッシュから取得したものだ。
  • ③ フィード全体を(キャッシュから)取得できている。

Github apiでGet してみる

自分のユーザー情報を取得する。 APIを使うに認証が必要。

import httplib2
from base64 import b64encode
httplib2.debuglevel = 1

h = httplib2.Http('.cache')
user = ''
password = ''
auth = b64encode(bytes(user + ':' + password, 'utf-8')).decode('utf-8')
endpoint = 'https://api.github.com'
resp, content = h.request(endpoint+'/user', 
                          'GET',
                         headers = {'Authorization': 'Basic ' + auth}
                         )

result:

send: b'GET /user HTTP/1.1\r\nHost: api.github.com\r\n
accept-encoding: gzip, deflate\r\n
if-none-match: "0dc6462cfbaf8e49dfcf4f679ac9d268"\r\n
if-modified-since: Fri, 25 Mar 2016 16:04:44 GMT\r\n
user-agent: Python-httplib2/0.9.2 (gzip)\r\n
authorization: Basic XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\r\n\r\n'
reply: 'HTTP/1.1 200 \r\n'
...
>resp.status

200
> content

b'{"login":"XXXX",
"id":XXXXX,
"avatar_url":"https://avatars.githubusercontent.com/u/10578703?v=3",
"gravatar_id":"",
"url":"https://api.github.com/users/Cartman0",
"html_url":"https://github.com/Cartman0",
"followers_url":"https://api.github.com/users/Cartman0/followers",
"following_url":"https://api.github.com/users/Cartman0/following{/other_user}",
...
}

HTTP GETの先へ

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)を利用して、セキュアで扱いやすい認証を提供している。

httplib2 はsslもhttp Basic認証もサポートしているので、 この部分は簡単に済ますことができる。

POSTリクエストとGETリクエストとの違いは、

  • POSTリクエストには ペイロード が入っているということにある。

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

Python には辞書をurlエンコードするユーティリティ関数が用意されている。 すなわち、urllib.parse.urlencode() だ。

In [50]:
from urllib.parse import urlencode 
urlencode
Out[50]:
<function urllib.parse.urlencode>

Identi.ca api が要求しているのはこの種の辞書。 ここには status というキーが一つだけ入っていて、 それに対応する値は一回分のステータス更新のメッセージになっている。

In [51]:
data = {'status': 'Test update from Python 3'}

urlエンコードされた文字列はこんな感じになる。 これが http POSTリクエストの際に、 「回線を通じて」Identi.ca apiサーバーに送信されるペイロードになる。

In [52]:
urlencode(data)
Out[52]:
'status=Test+update+from+Python+3'
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. 2番目の変数はhttpリクエストの種類。ここではPOSTになる。

  3. 3番目の変数はサーバーに送信するペイロード。ここではステータスメッセージの入った辞書をurlエンコードで変換して送信する。

  4. 最後に、ペイロードがurlエンコードで符号化されたものだということをサーバーに伝えなくてはならない。

add_credentials()メソッドの3番目の変数は、 その認証が通用するドメインを表す。 この部分については必ず明記しておくこと!

このドメインを空白のままにしておくと、 後で別の認証を必要とするサイトに対してhttplib2.Httpオブジェクトを再利用した時に、httplib2が元のサイト用のユーザー名とパスワードをその別のサイトに漏らしてしまいかねないため。

Github(Gist) API

https://developer.github.com/v3/gists/#create-a-gist

Create a gist

POST /gists Input

Name Type Description
files object Required. Files that make up this gist.
description string A description of the gist.
public boolean Indicates whether the gist is public. Default: false

The keys in the files object are the string filename, and the value is another object with a key of content, and a value of the file contents. For example:

{
  "description": "the description for this gist",
  "public": true,
  "files": {
    "file1.txt": {
      "content": "String file contents"
    }
  }
}

Note: Don't name your files "gistfile" with a numerical suffix. This is the format of the automatic naming scheme that Gist uses internally.

import httplib2
from base64 import b64encode
from urllib.parse import urlencode
import json

httplib2.debuglevel = 1

h = httplib2.Http('.cache')

user = ''
password = ''
auth = b64encode(bytes(user + ':' + password, 'utf-8')).decode('utf-8')
endpoint = 'https://api.github.com'

data = {
  "description": "Github API Sample",
  "public": True,
  "files": {
    "file1.md": {
      "content": "# Create a Gist with Github API"
    }
  }
}

resp, content = h.request(endpoint+'/gists', 
                          'POST',
                          json.dumps(data),
                          headers = {'Authorization': 'Basic ' + auth,
                                     'Content-Type': 'application/json'
                                    })

result:

send: b'POST /gists HTTP/1.1\r\nHost: api.github.com\r\nContent-Length: 123\r\n
accept-encoding: gzip, deflate\r\n
content-type: application/json\r\n
user-agent: Python-httplib2/0.9.2 (gzip)\r\n
authorization: Basic XXXXXXXXXXXXXXXXXXXXXX\r\n\r\n'
send: b'{"files": {"file1.md": {"content": "# Create a Gist with Github API"}}, "public": true, "description": "Github API Sample"}'
reply: 'HTTP/1.1 201 Created\r\n'
header: Server header: Date header: Content-Type header: Content-Length header: Status header: X-RateLimit-Limit header: X-RateLimit-Remaining header: X-RateLimit-Reset header: Cache-Control header: Vary header: ETag header: Location header: X-GitHub-Media-Type header: Access-Control-Expose-Headers header: Access-Control-Allow-Origin header: Content-Security-Policy header: Strict-Transport-Security header: X-Content-Type-Options header: X-Frame-Options header: X-XSS-Protection header: Vary header: X-Served-By header: X-GitHub-Request-Id 
`
> resp.status

201

Gist id を取得。

> content_data = json.loads(content.decode('utf-8'))
> content_data['id']

'1426de5bcd87485909ba2f0a66da6481'

以下のようにgistリポジトリが作られる。

create a gist

HTTP POSTの先へ

httpは GETPOST だけには留まらない。 この2つは(特にウェブブラウザにおいて)最もよく使われているリクエストだが、 ウェブサービスのapiは GETPOST 以上のものを扱うことができるし、 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. メッセージを削除するには、単純にこのurlにhttp 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

Gist api でGist を削除してみる

Gist api でのGistの削除は以下。

https://developer.github.com/v3/gists/#delete-a-gist

Delete a gist

DELETE /gists/:id

Response

Status: 204 No Content
X-RateLimit-Limit: 5000
X-RateLimit-Remaining: 4999
import httplib2
from base64 import b64encode

httplib2.debuglevel = 1

h = httplib2.Http('.cache')

user = ''
password = ''
auth = b64encode(bytes(user + ':' + password, 'utf-8')).decode('utf-8')
endpoint = 'https://api.github.com'

gist_id = '1426de5bcd87485909ba2f0a66da6481'

resp, content = h.request(endpoint + '/gists/' + gist_id, 
                          'DELETE',
                          headers = {'Authorization': 'Basic ' + auth}
                         )

result:

send: b'DELETE /gists/1426de5bcd87485909ba2f0a66da6481 HTTP/1.1\r\n
Host: api.github.com\r\n
accept-encoding: gzip, deflate\r\n
user-agent: Python-httplib2/0.9.2 (gzip)\r\n
authorization: Basic XXXXXXXXXXXXXXXXXXXXXXXXXXXXX\r\n\r\n'
reply: 'HTTP/1.1 204 No Content\r\n'
header: Server header: Date header: Status header: X-RateLimit-Limit header: X-RateLimit-Remaining header: X-RateLimit-Reset header: X-GitHub-Media-Type header: Access-Control-Expose-Headers header: Access-Control-Allow-Origin header: Content-Security-Policy header: Strict-Transport-Security header: X-Content-Type-Options header: X-Frame-Options header: X-XSS-Protection header: Vary header: X-Served-By header: X-GitHub-Request-Id
> resp.status

204
> content

b''

これで任意のgistを削除できる。

参考リンク