WebOS Goodies

WebOS の未来を模索する、ゲームプログラマあがりの Web 開発者のブログ。

WebOS Goodies へようこそ! WebOS はインターネットの未来形。あらゆる Web サイトが繋がり、共有し、協力して創り上げる、ひとつの巨大な情報システムです。そこでは、あらゆる情報がネットワーク上に蓄積され、我々はいつでも、どこからでも、多彩なデバイスを使ってそれらにアクセスできます。 WebOS Goodies は、さまざまな情報提供やツール開発を通して、そんな世界の実現に少しでも貢献するべく活動していきます。
Subscribe       

Tornado による非同期 Web サーバーの構築方法

先日、仕事で HTTP リクエストを中継するリバースプロキシのような Web サーバーを作る必要があり、パフォーマンスの要求もけっこう高くなりそうだったので、 Python ベースの非同期 Web サーバーである Tornado を使ってみました。 Tornado はもともと FriendFeed が開発したもので、現在は FriendFeed を買収した Facebook のもと、 Apache Licence 2.0 のオープンソースソフトウェアとして公開されています。

Tornado を使ってみて驚いたのは、 Web サーバーというよりも Web フレームワークとして高いレベルで完成されていること。 Google App Engine の webapp フレームワークライクなリクエスト処理、テンプレートエンジンやテストフレームワークなど、 Web サービスを構築するのに必要となる多くの機能が実装されています。 Python にもともと提供されているライブラリとあわせれば、ほとんど困ることはなさそうです。

非同期 Web サーバーとしては node.js が注目されていますが、個人的には Tornado を推したいですね。やはりサーバーサイドはそれに適した環境で構築すべきだと思います。備忘録もかねて基本的な使い方をご紹介しますので、ぜひ使ってみてください。

Tornado のインストール

Tornado のインストールは pip や easy_install を使うのが簡単ですが、私はプロジェクトで必要なライブラリはできるだけプロジェクトのソースツリー内に収めたい人なので、ここでは手動でビルドします。といっても手順は簡単で、ソースを落としてきて setup.py を実行するだけです。

curl -OL http://github.com/downloads/facebook/tornado/tornado-2.2.1.tar.gz
tar zxvf tornado-2.2.1.tar.gz
cd tornado-2.2.1
python setup.py build
cp -R build/lib/tornado /path/to/your/project

Tornado は Pure Python な Web サーバーなので、上記のようにビルドしたものを PYTHONPATH の通った場所にコピーすれば使えるようになります(ビルドさえ不要かもしれませんが、念のため)。

Hello World してみる

まずはお約束ということで、 Hello World を表示するだけの Web サーバーを作ってみました。

from tornado import ioloop, httpserver, web

class MainHandler(web.RequestHandler):
  def get(self):
    """ GET リクエストの際に呼ばれるメソッド """
    self.set_header('content-type', 'text/plain; charset=UTF-8')
    self.write("Hello World!")

if __name__ == "__main__":
  # URL と RequestHandler 派生クラスとのマッピングを定義
  application = web.Application([
      (r"/", MainHandler)])

  # Web サーバーを作成し、 8080 版ポートで待ち受ける
  server = httpserver.HTTPServer(application)
  server.listen(8080)

  # リクエスト処理の開始
  ioloop.IOLoop.instance().start()

Tornado の Web サーバーは GAE の webapp フレームワークによく似たプログラミングモデルを採用しているので、それに慣れた人なら簡単に理解できるでしょう。

非同期で他のサーバーにリクエストを投げる

冒頭で述べたとおり、 Tornado の特徴はイベントドリブンによる非同期処理が可能な点です。これを利用して、このブログの Atom フィードを取得して返すサーバーを作りました。

from tornado import ioloop, httpserver, web, httpclient

class MainHandler(web.RequestHandler):
  @web.asynchronous
  def get(self):
    req = httpclient.HTTPRequest(url="http://webos-goodies.jp/atom.xml")
    http = httpclient.AsyncHTTPClient()
    http.fetch(req, self.__handle_response)

  def __handle_response(self, response):
    if response.error:
      response.rethrow()
    self.set_header('content-type', 'application/atom+xml; charset=UTF-8')
    self.write(response.body)
    self.finish()

if __name__ == "__main__":
  application = web.Application([(r"/", MainHandler)])
  server = httpserver.HTTPServer(application)
  server.listen(8080)
  ioloop.IOLoop.instance().start()

リクエストの処理を非同期で行う場合、メソッドに @web.asynchronous デコレータをつけます。これにより、 Tornado はメソッドが終了してもリクエストの処理を計測するようになります。

Tornado で HTTP リクエストを送信する際は、 tornado.httplicent という専用のモジュールを利用します。 Python 標準の urllib.urlopen() などを使ってしまうと、処理がブロックしてしまうからです。 まず HTTPRequest を生成してリクエストの内容(URL やリクエストヘッダ・ボディなど)を定義します。そして AsyncHTTPClient を生成し、その fetch() メソッドに HTTPRequest インスタンスと、レスポンスを受け取るためのメソッドを渡します。メソッドにはレスポンスの内容が HTTPResponse インスタンスとして渡されるので、それを利用して処理を行います。

最後に finish() メソッドを呼び出すことで、 @web.asynchronous デコレータによって継続状態になっていたリクエストの処理を終了させます。

2012/06/07 追記

tornado.gen を使うと、非同期処理がもっと簡単に書けるそうです。上の処理だと、たぶんこんな感じでしょうか(テストしていないので、動かなかったらごめんなさい)。

from tornado import ioloop, httpserver, web, httpclient, gen

class MainHandler(web.RequestHandler):
  @web.asynchronous
  @gen.engine
  def get(self):
    req = httpclient.HTTPRequest(url="http://webos-goodies.jp/atom.xml")
    http = httpclient.AsyncHTTPClient()
    response = yield gen.Task(http_client.fetch, req)
    if response.error:
      response.rethrow()
    self.set_header('content-type', 'application/atom+xml; charset=UTF-8')
    self.write(response.body)
    self.finish()

if __name__ == "__main__":
  application = web.Application([(r"/", MainHandler)])
  server = httpserver.HTTPServer(application)
  server.listen(8080)
  ioloop.IOLoop.instance().start()

これは凄い。非同期処理にありがちなコールバックの嵐を見事に回避できますね。 @Masahito さんに教えていただきました。ありがとうございます!

その他の機能を使う

Tornado には、基本的な Web サーバーを実装する際に必要となるモジュールが多数実装されています。そのすべてを紹介することはできませんが、私が実際に試してみた機能をいくつか取り上げてみようと思います。

SSL のサポート

tornado.httpserver はけっこう本格的な Web サーバーとしての機能を持っていて、 SSL もサポートしています。 HTTPServer を作成する際に、 ssl_options 引数に証明書と秘密鍵のファイルを指定するだけです。

server = httpserver.HTTPServer(application, ssl_options = {
  'certfile': '/path/to/ssl.cer',
  'keyfile': '/path/to/ssl.key'
})

バーチャルホスト

SSL に加えて、バーチャルホストで複数のドメインをサポートすることも可能です。 tornado.web.Application の add_handlers() メソッドでバーチャルホストが追加できます。引数はバーチャルホストの FQDN と、 RequestHandler のマッピングです。

class MainHandler(web.RequestHandler):
  # ...

class SubHandler(web.RequestHandler):
  # ...

if __name__ == "__main__":
  application = web.Application([(r"/", MainHandler)])
  application.add_handlers('sub.example.com', [(r'/', SubHandler)])
  server = httpserver.HTTPServer(application)
  server.listen(8080)
  ioloop.IOLoop.instance().start()

上記では、 sub.example.com へのアクセスのみ SubHandler にルーティングされ、それ以外はすべて MainHandler に回されます。 Application コンストラクタに指定したマッピングがデフォルトホストとなるわけですね。

起動オプションの処理

tornado.options モジュールを利用すると、起動時のオプションを簡単に処理できます。以下は --port オプションで待ち受けポート番号を指定できるようにする例です。

# -*- coding: utf-8 -*-

from tornado import options

options.define('port', type=int, default='8080', help=u'ポート番号の指定')

#...

if __name__ == "__main__":
  options.parse_command_line()
  application = web.Application([(r"/", MainHandler)])
  server = httpserver.HTTPServer(application)
  server.listen(options.options.port)
  ioloop.IOLoop.instance().start()

define() で指定できるオプションを定義しておき、 parse_command_line() を実行することで指定されたコマンドラインを解析します。オプションに指定された値は、 tornado.options.options.<define()の第一引数> で取得できます。

また、 options.parse_command_line() を実行することで、以下のオプションも自動的に追加されます。

オプション説明
--helpオプションの説明を表示
--logging=levelログレベルの指定(debug, info, warning, error, none)
--log_to_stderrログを標準エラー出力に出力する
--log_file_prefix=prefixログのファイル名を指定する
--log_file_max_size=sizeログファイルのサイズがsizeを超えるとローテートする
--log_file_num_backups=numログファイルをnum世代まで残す

見てのとおり、 parse_command_line() を実行するだけでログローテートまでやってくれます。 Tornado を使うときは、必ず最初に parse_command_line() を実行しておくのがよいでしょう。

マルチプロセス

マルチプロセスでの動作にも簡単に対応できます。 HTTPServer の listen() メソッドの代わりに、 bind() と start() を呼ぶだけです。

if __name__ == "__main__":
  application = web.Application([(r"/", MainHandler)])
  server = httpserver.HTTPServer(application)
  server.bind(8080)
  server.start(0)
  ioloop.IOLoop.instance().start()

start() の引数は起動するプロセス数で、 0 を指定するとプロセッサの数に合わせて適切なプロセスを起動してくれます。また、この方法で起動したプロセスは、異常終了した際に自動的に再起動されるそうです。フレームワーク添付の Web サーバーでここまでやってくれるのは凄いですね。

これらのほかにも、非同期の MySQL クライアントである tornado.database モジュールや、 WebSocket サーバー実装の tornado.websocket など、面白そうなモジュールがいろいろあります。さらに Python の充実した標準ライブラリも使えるわけですから、鬼に金棒ですね。今後もいろいろ試していきたいと思っています。

関連記事

この記事にコメントする

Recommendations
Books
「Closure Library」の入門書です。
詳しくはこちらの記事をどうぞ!
Categories
Recent Articles