2018年3月15日木曜日

PythonのTornadoによるコールバック地獄対策

勉強の為に転載しました。
http://kimihiro-n.appspot.com/show/5794791350599680

ジェネレータを使ってコールバック地獄を回避するTornadoの非同期処理がすごい

PythonのWebフレームワークTornadoを使っていて、非同期処理の書き方が素晴らしかったのでシェア。

非同期型Webサーバー

アプリ・フロントエンジニアにとっては非同期処理は無くてはならないくらい重要なものですが、バックエンド側ではあまり馴染みがないかもしれません。
普通のWebサーバーはリクエストを受けてから、レスポンスを返すまで1つの処理フローが途切れず続いています。リクエストを解析して、モデルからデータを取ってきて、表示用にフォーマットしなおして…みたいな処理をプログラム通り順次実行していきます。処理の流れがシンプルで分かりやすいのですが、データベースにアクセスしたり、外部への接続などといったことを行うときは、接続先から結果が帰ってくるまで待つしかありません。これが同期処理と呼ばれるもので、向こうの完了を待って(同期して)次の処理を行っていく形となります。
"""
    同期的な擬似コード例
"""
def get(request):
    params = request.params
    # 時間がかかる処理 
    items = get_items(params)
    # get_itemsが終わるまで次の行に行かない
    return render("hello.html", items)

def get_items(params):
    # DBアクセスなど
    items = hogehoge(params)
    return items
この待ち時間を有効に使うために非同期型のWebサーバーが出てきました。一番有名なのはNode.jsですね。Pythonでは今回のTornadoみたいなフレームワークがあります。この非同期型の一番の特徴は処理の完了を待たないという点です。データベースへのアクセスなど時間の掛かる処理を行うときは、ある処理(A)を呼び出したタイミングで一度リクエスト側の処理を終了させ、処理が完了した段階でAの方からリクエストの続きの処理を呼び出す(いわゆるコールバック)という形式を取っています。待ち時間を有効に使えるのでより多くのトラフィックを捌けるようになるのが特徴です。ただその反面処理の流れがあっちこっちに飛ぶので記述が複雑になりやすい(コールバック地獄)という欠点があります。
"""
   非同期的な擬似コード例
"""
def get(request):
    params = request.params
    # 処理が終わったらcallbackを呼び出す
    async_get_items(params, callback)

# 続きの処理
def callback(result):
    return render("hello.html", result)

def async_get_items(params, callback):
    # 非同期で呼び出される関数
    def async_task():
        items = get_items(params)
        # 処理が終わったらこちらから続きの処理を呼び出す
        callback(items)
   # async_taskを実行する
   # async_execute関数自体は呼び出した瞬間すぐ終わる
   async_execute(async_task)

def get_items(params):
    # DBアクセスなど
    items = hogehoge(params)
    return items
同期的なコードと比べてぱっと見では流れが把握しづらくなってしまいます。今は非同期処理が1回だけなのでこの程度で済んでいますが、あちこちからデータを取りに行ってマッシュアップするような処理を書こうとするとコールバックだらけになってしまって大変見づらいです。
では本題のTornadoではどのように書けるのか、というと以下のようになります。
# 非同期を扱えるようにするためのデコレータ
@gen.coroutine
def get(self):
    params = self.request.params #FIXME:
    # 非同期処理を呼ぶ時にyeildを付ける
    items = yield get_items(params)
    self.render("hello.html", **items)

@gen.coroutine
def get_items(self, params):
    # DBアクセスなど
    items = hogehoge(params)
    # returnではなく Returnメソッドの結果をraiseする
   # Python3.3以降であればただのreturnでよい
    raise gen.Return(items) 
これで非同期処理が書けてしまいます。比べてもらえれば分かると思いますが、同期的なコードにかなり近い形で記述することが可能です。コールバック関数を用意する必要もないので処理の流れを追うのも簡単です。
ポイントとしては@gen.coroutineというデコレータを利用する点ですね。Pythonではデコレータという関数を装飾する機能が備わっていて、関数の前に@(アット)を付けてあげるだけで、対象の関数をラッピングする事が出来ます。今回の場合はgetとget_itemsという関数をそれぞれgen.coroutineという関数でラッピングしていることになります。
このgen.coroutineデコレータの役割は対象の関数をジェネレータへ変換し、ジェネレータが終了するまで非同期で繰り返し呼び出す作業を行ってくれる事です。ざっくり言えばyieldしたところで一旦処理を中断して、非同期処理の結果を待ってくれます。このデコレータのお陰で同期処理のようにシンプルなコードで実現する事ができるようになるわけです。
デコレータとジェネレータ組み合わせて非同期処理をシンプルにするこの発想はすごいと思いました。Tornado面白いです。

リファレンス

-----------------------------------------------------
勉強の為に転載しました。
http://webos-goodies.jp/archives/asynchronous_web_server_using_tornado.html

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 の充実した標準ライブラリも使えるわけですから、鬼に金棒ですね。今後もいろいろ試していきたいと思っています。

ーーー
関連情報:
プログラム言語PytonのTornadoフレームワークは、1秒間にクライアント8千アクセス対応で、Cycloneフレームワークは、秒間にクライアント1万同時アクセス対応となっております。

0 コメント:

コメントを投稿