いまさら select、といっても SQL ではないのだ

逃避行動してますか?

curl とか wget とかあるのに作りたくなってしまうのが http クライアントですよね。http 経由で色々落ちていますから、拾いたいです。簡単なの (エラー処理? なにそれ美味しいの?) ならボクにだって書けるよ!!

import urlparse
import socket
import sys

url = urlparse.urlparse(sys.argv[1])

sock = socket.socket()
sock.connect((url.hostname, 80))

print "retrieving %s" % sys.argv[1]
file = open(url.path.split('/')[-1], 'wb')
request = 'GET %s HTTP/1.0\nHost: %s\n\n' % (url.path, url.hostname)
sock.send(request)

buf = b''
while True:
  buf += sock.recv(8192)
  pos = buf.find(b'\r\n\r\n')
  if pos > -1:
    buf = buf[pos+4:]
    break

file.write(buf)
while True:
  tmp = sock.recv(8192)
  if tmp == '':
    break
  else:
    file.write(tmp)

file.close()

こんなの作れば

$ time python wget.py http://pypi.python.jp/ipython/ipython-0.10.1.tar.gz
retrieving http://pypi.python.jp/ipython/ipython-0.10.1.tar.gz

real	0m5.167s
user	0m0.116s
sys	0m0.188s
$ tar tfz ipython-0.10.1.tar.gz 
ipython-0.10.1/
ipython-0.10.1/IPython/
ipython-0.10.1/IPython/OutputTrap.py
ipython-0.10.1/IPython/Prompts.py
ipython-0.10.1/IPython/kernel/
ipython-0.10.1/IPython/kernel/parallelfunction.py
ipython-0.10.1/IPython/kernel/__init__.py
(あと省略)

わーい、取れたよー。でも 5 秒ちょっと掛かってますね。沢山一片に取りたいときには

$ time (python wget.py http://pypi.python.jp/ipython/ipython-0.10.1.tar.gz; python wget.py http://pypi.python.org/packages/source/D/Django/Django-1.3.tar.gz)
retrieving http://pypi.python.jp/ipython/ipython-0.10.1.tar.gz
retrieving http://pypi.python.org/packages/source/D/Django/Django-1.3.tar.gz

real	0m15.543s
user	0m0.280s
sys	0m0.392s

まぁこうすればいいわけですが、でもこれ、何か無駄な気がしますよね。1つめの file を取得してから 2 つめのファイルを取得していますが、違うサイトから取ってるなら同時取得すればいいのに。

shell で 2 プロセス走らせるってのは 1 つの解決策だとは思いますが、何だか切ないし。そんなときに使うのが select です。select はファイルデスクリプタがいま読み書きなどできるかどうか kernel に問い合わせる仕組みです。

>>> import select
>>> import socket
>>> sock = socket.socket()
>>> sock.connect(('www.google.com', 80))
>>> sock.fileno()
3
>>> select.select((3,), (3,), (), 1)
([], [3], [])
>>> sock.send('GET / HTTP/1.0\n\n')
16
>>> select.select((3,), (3,), (), 1)
([3], [3], [])
>>> sock.recv(64)
'HTTP/1.0 302 Found\r\nLocation: http://www.google.co.jp/\r\nCache-Co'

select.select() は list の 3-tuple を返してきて、それぞれ

  • 読めるファイルデスクリプタ
  • 書けるファイルデスクリプタ
  • 奇しくなってるファイルディスクリプタ

の番号が詰まっています。上の例だと、1 つめの select.select() では何もしてないのでデータが送れるファイルデスクリプタのみ報告されてますが、GET を送った後の 2 つめの select.select() では読める 3 番が 1 つめの list の中に増えています。つーかこれ読めばいいと http://www.python.jp/doc/2.6/library/select.html

select を使うと多分こんな感じになります。

# coding=utf8
import urlparse
import select
import socket
import sys

# socket.socket.fileno() 毎に色々と準備する
socks = {}
files = {}
bufs = {}
states = {}

# 引数は全て URL のつもりで取得するものとして登録してく
for arg in sys.argv[1:]:
  print "retrieving %s" % arg
  url = urlparse.urlparse(arg)

  # socket 開いて fileno を取って登録
  sock = socket.socket()
  fd = sock.fileno()
  socks[fd] = sock
  sock.connect((url.hostname, 80))

  # 保存先ファイルを開いて登録
  file = open(url.path.split('/')[-1], 'wb')
  files[fd] = file

  # 他のものも登録
  bufs[fd] = b''
  states[fd] = 'header'

  # GET 投げとく
  request = 'GET %s HTTP/1.0\nHost: %s\n\n' % (url.path, url.hostname)
  sock.send(request)

while True:
  # 読めそうな socket を調べる
  rlist = select.select(socks.keys(), (), (), 1)[0]

  # 全ての socket に対して
  for fd in rlist:
    # HEADER が返ってきてるところなら
    if states[fd] == 'header':
      # 溜めて
      bufs[fd] += socks[fd].recv(8192)
      # HEADER が終わってたらコンテンツを保存開始
      pos = bufs[fd].find(b'\r\n\r\n')
      if pos > -1:
        bufs[fd] = bufs[fd][pos+4:]
        states[fd] = 'contents'
        files[fd].write(bufs[fd])

    # 保存し始めてたら
    else:
      tmp = socks[fd].recv(8192)
      # 読めるけど読んで空なら終わりかな
      if tmp == '':
        files[fd].close()
        socks[fd].close()
        del socks[fd]
        del files[fd]
        del bufs[fd]
        del states[fd]

      # 返ってきたものは保存
      else:
        files[fd].write(tmp)

 # 監視する socket が無くなったら終わり
  if socks.keys() == []:
    break
$ time python wget2.py http://pypi.python.jp/ipython/ipython-0.10.1.tar.gz http://pypi.python.jp/Django/Django-1.3.tar.gz
retrieving http://pypi.python.jp/ipython/ipython-0.10.1.tar.gz
retrieving http://pypi.python.jp/Django/Django-1.3.tar.gz

real	0m10.767s
user	0m0.360s
sys	0m0.296s

速くなったよ!!

でも、ファイル毎のダウンロード進捗でも出さないと面白くないですね。取ってきてるファイルも面白くないですね。