HTTPプログラムの書き方とテストするためのサーバー

何となくの知識しか持っていないHTTPの動作を理解するために、Gaucherfc.httpモジュールを使わずHTTPクライアント用の低レベルライブラリを書いてみることにした。rfc.httpモジュールを使わないのは持続的接続(Keep Alive)の動作を知りたいからという理由が大きい。
プログラムを書いていくと、どうも持続的接続とリダイレクトの扱いが面倒な感じがする。これらがなければ、ソケットを開く、データを送信する、データを受信する、ソケットを閉じる、という動作で完結するので、書く手間はあってもプログラムはそれほど入り組まないと思えるのだけど、持続的接続とリダイレクトがからむと途端に面倒になる感じがする。「使いやすいAPIデザイン」に

あまり典型的じゃない望み

  • インクリメンタルな処理
  • パーシステント接続

使いにくくする もしくは使えなくする

とあってこれは一つの見識だと思うけど、そこに当てはまらない、APIを汚くするかもしれない処理を扱いたい場合どうすれば良いのだろう。
リダイレクトを自動で処理させようとすると、リダイレクト前のヘッダを捨てずに取っておきたい場合があったり、メソッドがPOSTだった場合リダイレクト後はGETに置き換えたり(この動作はRFC的には間違っているけど)する必要がある。こういう動作が必要になるのは例でいうと

  1. (HTTPSで暗号化した状態で)POSTでパスワードを送る。
  2. サーバは
    • セッションIDをクッキーで送ってくる。
    • リダイレクトさせるためにステータスコード302 Foundを返す(本当はこの場合リダイレクトさせるなら303 See Otherを使うべきだけど)。

このとき、リダイレクト前のヘッダ内容(クッキー)は捨てずに取っておく必要があるし、リダイレクト後のURIも覚えておく必要がある。またリダイレクト先にPOSTしてしまうとパスワードを暗号化されていない状態で(もしかしたら関係ないサーバーに)送ることになる。
さらに持続的接続を扱うと話がややこしくなる。ソケットを開いたり使いまわしたりをどう扱うものなのかよく判らない。特にリダイレクトする場合、リダイレクト前のソケットはどう扱うものなのか。

テストの書き方

これはどんなプログラムを書く場合にも言えることだけど、テストを書くのが難しい。テストを書くのは、作りたいプログラム自身を書くのと同じかそれ以上に難しいと思う。
プログラムの書き方については『プログラム書法』から様々なプログラミング言語本から色々なところに書かれているから多少は判る気がするけど、テストの書き方はそれに比べると全然判らない。「まずテストを書いて」というところで止まってしまう。

テスト用のHTTPサーバー

HTTPクライアントプログラムのテストをしようすると、比較的単純な動作をするHTTPサーバーが欲しくなる。それで「最小限の動作をするHTTPサーバを書くことにする」→「テストが必要なくらいには複雑になる」→「そのためのテストがいる」という結果になる気がする。
ゼロから書くとかなり大変そうなのでWEBrickで書いてみたけど、それでもテストが必要なくらいには複雑な気がする(WEBrickの使い方)。

http://localhost:8080/reflect/

で送ったデータをそのままレスポンスの本体にして返してくるし、

http://localhost:8080/content-length/
http://localhost:8080/chunked/
http://localhost:8080/closing/

でレスポンス本体の長さの表し方を変える。

http://localhost:8080/reflect/201/

などでステータスコードだけ変えることもできる。あと

X-Header-……

というヘッダーを送ると、X-Header-を取ったものをヘッダーに付け加えて送り返してくる。など。
これでもテスト用の機能としてまだかなり足りてないかも。

require 'webrick'

# リクエストの生データを保存するための処理
module WEBrick
  class HTTPRequest
    alias :old_initialize :initialize
    def initialize(*args)
      @request_data = ""
      old_initialize(*args)
    end
    alias :old_read_data :_read_data
    def _read_data(*args)
      x = old_read_data(*args)
      # STDOUT.puts x  # for debug
      @request_data << x
      x
    end
    def read_data(io, size)
      _read_data(io, :read, size)
    end

    def get_request_data
      body  # リクエスト本体はbodyが呼ばれるまで読み込まれない
      @request_data
    end

    # 強制的にソケットを閉じ異常動作を発生させる
    def close_socket
      @socket.close
    end
  end
end

def start_test_server(config={})
  default_config ={:BindAddress => '127.0.0.1', :Port => 8080}

  server = WEBrick::HTTPServer.new(default_config.merge config)

  server.mount_proc('/reflect') do |req, res|
    make_status(req, res)
    res.body << req.get_request_data
  end

  server.mount_proc('/content-length') do |req, res|
    make_status(req, res)
    make_body(req, res)
  end

  server.mount_proc('/chunked') do |req, res|
    make_status(req, res)
    make_body(req, res)
    res.chunked = true
  end

  server.mount_proc('/closing') do |req, res|
    make_status(req, res)
    make_body(req, res)
    res.chunked = false
    res.content_length = nil
    res.keep_alive = false
  end

  server.mount_proc('/found') do |req, res|
    res.status = 302
    res["location"]= File.join('/reflect', req.path_info)
  end

  server.mount_proc('/looping') do |req, res|
    res.status = 302
    res["location"]= File.join('/looping-2', req.path_info)
  end

  server.mount_proc('/looping-2') do |req, res|
    res.status = 302
    res["location"]= File.join('/looping', req.path_info)
  end

  server.mount_proc('/endless') do |req, res|
    res.status = 302
    res["location"]= File.join('/endless', rand(10000000).to_s)
  end

  # 返信せずソケットを閉じる。異常な動作
  server.mount_proc('/error') do |req, res|
    req.close_socket
  end

  server.mount_proc('/exit') do |req, res|
    res.status = 200
    server.shutdown
  end

  trap(:INT){server.shutdown}
  server.start
end

def make_status(req, res)
  if %r|/(\d\d\d)| =~ req.path_info
    res.status = $~[1].to_i
  end
  req.each{|key, val|
    if /^X-Header-([^:]*)/i =~ key
      res[$~[1]] = val
    end
  }
end

def make_body(req, res)
  if req.body
    res.body << req.body
  elsif %r|/\d\d\d/(.*)| =~ req.path_info
    res.body << $~[1]
  else
    res.body << req.path_info
  end
end

start_test_server()