主にプログラミングに関して。Python, .NET Framework(C#), JavaScript, その他いくらか。
記事にあるサンプルやコードは要検証。使用に際しては責任を負いかねます

Python: Tornadoのノンブロッキングという特徴を手っ取り早く試す

                
 PythonでのWebアプリ作りはGoogleAppEngineから入った。GoogleAppEngineで使うフレームワークが最初はwebappでそれがwebapp2になったが、それが一般的なフレームワークではないので、PythonのWebフレームワークで他のサーバでも使えて開発が続いているのを選ばねばとなった。それでwebapp系に割と似た書き方をするし面白い特徴を持っているTornadoを選んだ。
 Tornadoはノンブロッキングなサーバというのをウリとしている。ノンブロッキングという特徴を利用すれば、時間のかかるIO処理があったらそのデータ処理開始はIOが終わるまで後回しにして、次のリクエストの処理を先にやってしまえる。このあたりを試していなかったので今回試してみる。ちなみにノンブロッキングではなくPython標準のWSGIでも使える。

 リクエスト処理中に起こった時間のかかるIO処理は後回しにして、次のリクエストの処理を始める。これを検証する。
 時間のかかるIO処理として、MongoDBから10MBのデータを取ってくる処理を行うことにする。そのためにMongoDBを立ち上げて、サイズが10MBほどの適当なドキュメントを一件作成しておく。PyMongoを使うとおおよそ下記のように。
v = "a" * 10 ** 7
db.tekitou.insert({"x":v})


 以降のPythonスクリプトではPyMongoではなくTornado用に書かれたMotorを使う。

 上記のドキュメントの取得を、Tornadoの任意のリクエストハンドラの処理にはさむ。
class AsyncHandler(tornado.web.RequestHandler):
@tornado.gen.coroutine
def get(self):
data = yield db.tekitou.find_one()
self.write(data["x"])


 軽い処理ですんなり終えられそうな処理をするリクエストハンドラも用意しておく。
class MainHandler(tornado.web.RequestHandler):
def get(self):
self.write("Hello, world")


 二つのリクエストハンドラを書いた。これらをパスへ割り当てる。今回は軽い処理しかないリクエストハンドラをルート"/"、MongoDBから10MB取ってくる処理を持つリクエストハンドラをパス"/b"へ割り当てておく。
 もしパス"/b"へのリクエストが先にあり、次にパス"/"へのリクエストがあったのに、パス"/"にリクエストしたクライアントが先にレスポンスを得られれば順序が変わっており、ノンブロッキングな特徴が確認できたことになる。

 Pythonのmultiprocessingモジュールを使って、用意したサーバに計四つのプロセスでリクエストを送り、リクエスト順に対するレスポンス順の変化があるかを調べる。リクエストに使ったスクリプトを下につけておく。まずパス"/b"へのリクエストを先に二件発生させ、次にパス"/"へのリクエストを二件発生させている。リクエストごとに固有の整数値を与えておいて、それを判別のためにリクエストクエリに入れておく。
from urllib import request
from multiprocessing import Process
import datetime
import time


url1 = "http://localhost:8888/"
url2 = "http://localhost:8888/b"


def req(url, request_id):
time.sleep(0.01 * request_id)
t = datetime.datetime.now()
print(str(request_id) + ":" + str(t))
f = request.urlopen(url + "?num=" + str(request_id))
print(str(request_id))


if __name__ == '__main__':
p1 = Process(target=req, args=(url2, 1))
p1.start()

p2 = Process(target=req, args=(url2, 2))
p2.start()

p3 = Process(target=req, args=(url1, 3))
p3.start()

p4 = Process(target=req, args=(url1, 4))
p4.start()


 サーバ側は最終的に下記。どのプロセスからのリクエストかをGETのクエリから拾っている。
import tornado.ioloop
import tornado.web
import motor


client = motor.MotorClient()
db = client.tornado_test


class MainHandler(tornado.web.RequestHandler):
def get(self):
print(self.get_argument("num"))
self.write("Hello, world")


class AsyncHandler(tornado.web.RequestHandler):
@tornado.gen.coroutine
def get(self):
print(self.get_argument("num"))
data = yield db.tekitou.find_one()
self.write(data["x"])


application = tornado.web.Application([
(r"/", MainHandler),
(r"/b", AsyncHandler),
])


if __name__ == "__main__":
application.listen(8888)
tornado.ioloop.IOLoop.instance().start()


 スクリプトの用意ができたので走らせる。MongoDBを立ち上げた状態でTornadoサーバを立ち上げ、リクエストスクリプトを実行する。コマンドプロンプトをキャプチャしたものを下に。上がサーバを走らせているプロンプト、下がリクエストを書いたスクリプトを走らせたプロンプトである。
1503291752257.jpg
 サーバを走らせている上側を見るとリクエストIDの1から順番にリクエストを受け取っているのがわかる。次に下を見るとリクエストも確かにその順番で発生していると思われる。しかしレスポンスの順番がかわっており、3、4、1、2の順になっている。
 3と4は重いIOの発生しないリクエストハンドラへのリクエストであった。3と4のリクエストが1と2のリクエストのあとに発生しているのに、レスポンスを受け取ったのは3と4のほうが早い。これはMongoDBへの10MBのIO処理を待っている間に重いIO処理を含まない3と4への処理を先に済ませたからだと考えられる。つまりTornadoのサーバであるノンブロッキングという特徴が見られたということだろう。

 数件のリクエストに対するレスポンスの順番変更があるかを探ることで、Tornadoサーバのノンブロッキングという特徴を試してみた。確かに重いIO処理を行っていると考えられる最中にも別のリクエストを処理し、レスポンスの順番が変わっていた。Tornadoの特徴であるノンブロッキングというところが確認できた。
スポンサーサイト



Python, Tornado: 二つの公式チャットデモ(WebSocket)

                
 Tornadoの公式のコードの中に、demosというディレクトリがあって、そこに二種類のチャット実装デモがある。
https://github.com/tornadoweb/tornado/tree/master/demos

 従来のHTTPで考えると、サーバから能動的にクライアントになにかを投げることはできず、つねにクライアントからのリクエストに応じてレスポンスを返すことになる。その条件でチャットを実装しようとすると、クライアントから定期的にリクエストを送る必要がある。chatというディレクトリに入っているデモでは、JavaScriptで500msごとにタイマーをかけてリクエストを行うことで実装している。ここら辺が旧来の技術で、ロングポールと呼ばれる一つの手法。

 定期的にリクエストを送って、サーバで新しいメッセージがないかを確認してレスポンスを作る。なんか非合理。サーバからクライアントに能動的にデータを送れたら合理的。というわけで新しい技術標準として開発が進められたのがWebSocket。このWebSocketを利用してチャットを実装したのが、デモの中でwebsocketというディレクトリに入っているもの。どう使っているのか気になるのでコードを読んでみる。

 まず予習。そもそもWebSocketがクライアントとサーバの双方向通信を可能にするものだとして、どう使えばいいんだろう。基本的な使いかたから。
 WebSocketはメッセージベースの通信を行う。送受信データの形式はテキスト、あるいはバイナリ。接続の確立、メッセージの送受信、接続の終了を機能としていてけっこうシンプル。
サーバ側(Python, Tornado):https://sites.google.com/site/tornadowebja/documentation/integration-with-other-services/tornado-websocket
class EchoWebSocket(websocket.WebSocketHandler):
def open(self):
print "WebSocket opened"

def on_message(self, message):
self.write_message(u"You said: " + message)

def on_close(self):
print "WebSocket closed"

クライアント側:http://www.html5rocks.com/ja/tutorials/websockets/basics/
var ws = new WebSocket("ws://localhost:8888/websocket");
ws.onopen = function() {
ws.send("Hello, world");
};
ws.onmessage = function (evt) {
alert(evt.data);
};

要は接続とメッセージの送受信をどうするかを規定のメソッドで定義すればいいんだろう。

 デモのコードを読む。まずクライアント側から。websocket/static/chat.jsがそのファイル。そんでもってその中で定義されているオブジェクトupdaterがWebSocketをメンバーに入れて通信処理をしている。showMessage メソッドを定義しておいて、WebSocketのonmessageイベントのときに呼ばれるようにしている。
var updater = {
socket: null,

start: function() {
var url = "ws://" + location.host + "/chatsocket";
updater.socket = new WebSocket(url);
updater.socket.onmessage = function(event) {
updater.showMessage(JSON.parse(event.data));
}
},

showMessage: function(message) {
var existing = $("#m" + message.id);
if (existing.length > 0) return;
var node = $(message.html);
node.hide();
$("#inbox").append(node);
node.slideDown();
}
};


 サーバ側。
class MainHandler(tornado.web.RequestHandler):
def get(self):
self.render("index.html", messages=ChatSocketHandler.cache)

class ChatSocketHandler(tornado.websocket.WebSocketHandler):
waiters = set()
cache = []
cache_size = 200

def allow_draft76(self):
# for iOS 5.0 Safari
return True

def open(self):
ChatSocketHandler.waiters.add(self)

def on_close(self):
ChatSocketHandler.waiters.remove(self)

@classmethod
def update_cache(cls, chat):
cls.cache.append(chat)
if len(cls.cache) > cls.cache_size:
cls.cache = cls.cache[-cls.cache_size:]

@classmethod
def send_updates(cls, chat):
logging.info("sending message to %d waiters", len(cls.waiters))
for waiter in cls.waiters:
try:
waiter.write_message(chat)
except:
logging.error("Error sending message", exc_info=True)

def on_message(self, message):
logging.info("got message %r", message)
parsed = tornado.escape.json_decode(message)
chat = {
"id": str(uuid.uuid4()),
"body": parsed["body"],
}
chat["html"] = tornado.escape.to_basestring(
self.render_string("message.html", message=chat))

ChatSocketHandler.update_cache(chat)
ChatSocketHandler.send_updates(chat)

"/"にリクエストを送ると、これまでのチャットメッセージがテンプレーティングされたHTMLが返ってくる。MainHandlerクラスでそのレスポンスを作っていて、これまでのメッセージはクラスメソッド(インスタンスを作らずクラスから使えるメソッド)に保存されている。テンプレーティングされたHTMLにさきほどのJavaScriptが入っており、読み込まれたらそれ以降でクライアントとサーバのWebSocket通信が確立される。
クライアント サーバ
→ チャットページへのリクエスト
← WebSocketでの接続コードを含むHTMLをレスポンスで返す
→ 読み込まれたJavaScriptからWebSocketでサーバへ接続要請
:サーバが接続要請を受けて接続を確立、以降双方向通信が可能に
 クラスメソッドを通じてこれまでのメッセージ、接続クライアントを保持している。ここらが重要。クライアントからの接続をなんらかの方法で保持しておかないと、メッセージのプッシュができない。HTTPリクエストとは違って、レスポンスを返したら接続を切っていいわけではない。接続保持の方法としてリクエストハンドラのインスタンスを安直にグローバル変数に入れることもできるだろうが、ここではクラスメソッドを使ってクラス変数へ入れている。


 WebSocketのTornadoでの使用方法をのぞいてみた。クライアント側ではWebSocketオブジェクトを作成して、onopen、onmessageイベントを書いておく。Tornadoサーバ側ではtornado.websocket.WebSocketHandlerクラスを継承してopenメソッドやon_messageメソッドを定義しておく、接続インスタンスを保持しておく。このあたりが使い方の根本的な部分だと考えられる。

Python-Tornado: セキュアなWebアプリケーションをもうちょい考える

                
セキュアなアプリケーションを考える
Python-Tornado: XSRF対策

 SSLにすればおおよそ安心かなーと考えながら、本当にそうなのかと色々あさっていたところ、SSLにしていてもBREACH攻撃の脅威は防げないというのを知った。
SSL暗号を無効化する仕組み – BREACH, CRIME, etc
App Engine セキュリティー情報 - BREACH attack について

 以前にXSRF対策でトークンをワンタイムにする必要はないと出典と共に書いたが、この脅威においてはワンタイムトークンを使うことは対策になり得るようだ。でもそもそもgzip圧縮を使わなければそれでこの脅威への対策になるようなので、gzip圧縮は切っておく。そうすればXSRFトークンはワンタイムでなくて大丈夫だろう。Tornadoではgzip圧縮はデフォルトではかかっていない。下記のように書く必要もとくにないがとりあえずgzipは切っておく。
settings = dict(cookie_secret="foooooooooooooooooooooooooooooo",
static_path=os.path.join(os.path.dirname(__file__), "static"),
template_path=os.path.join(os.path.dirname(__file__), "templates"),
xsrf_cookies=True,
gzip=False,
autoescape="xhtml_escape",
debug=True,
)
application = tornado.web.Application([('/(\d*)', View),
('/article/([\w\-]+)', Article)],
**settings)


 あとはXSRF対策としてXSRFトークンをクッキーに埋め込むけど、これってsecure属性を付けなくていいのかなと。XSRFトークンがわかってしまえばXSRFの準備ができるから、取り扱いには気を払うべきかと考えているんだけど、Tornadoではsecure属性の付加オプションがみつからない。secure属性を付けていない場合はクッキーを盗む手段があるし(参考)、DjangoはXSRFクッキーのsecure属性付加オプションを用意している(参考)。

 XSRFクッキーへのsecure属性付加のために少しばかり書き換えを考えてみる。個人的な書き換えなので実際に使うのは望ましくない。編集するファイルの名前はweb.py。おおよそ1000行目あたりにあるXSRFトークンをクッキーにセットする部分だけをちょっと書き換える。やるのはsecure属性付加だけ。変なことして余計な穴を増やしたらまずいし。
def xsrf_token(self):
"""The XSRF-prevention token for the current user/session.

To prevent cross-site request forgery, we set an '_xsrf' cookie
and include the same '_xsrf' value as an argument with all POST
requests. If the two do not match, we reject the form submission
as a potential forgery.

See http://en.wikipedia.org/wiki/Cross-site_request_forgery
"""
if not hasattr(self, "_xsrf_token"):
token = self.get_cookie("_xsrf")
if not token:
token = binascii.b2a_hex(uuid.uuid4().bytes)
expires_days = 30 if self.current_user else None
#self.set_cookie("_xsrf", token, expires_days=expires_days)
if self.application.settings.get("xsrf_cookie_secure"):
self.set_cookie("_xsrf", token, expires_days=expires_days, secure=True)
else:
self.set_cookie("_xsrf", token, expires_days=expires_days)
self._xsrf_token = token
return self._xsrf_token


 書き換えをしたらセッティング項目でxsrf_cookie_secureを追加。
settings = dict(cookie_secret="foooooooooooooooooooooooooooooo",
static_path=os.path.join(os.path.dirname(__file__), "static"),
template_path=os.path.join(os.path.dirname(__file__), "templates"),
xsrf_cookies=True,
xsrf_cookie_secure=True,
gzip=False,
autoescape="xhtml_escape",
debug=True,
)

14042101.png
 上がデフォルトで、下がxsrf_cookie_secureをTrueにした場合。secure属性が付加できた。



追記
 上のコードをTornadoフォーラムにどうかと投げてみた。そうしたらsecure以外にもHttponlyとかexpireとかあって、それを一つずついちいちオプション追加するのクールじゃないと返信があり、どう対処してるかコードサンプルをくれた。どうやらBaseHandlerクラスを宣言するとき、set_cookieメソッドをオーバライドしてるようだ。確かにこっちの書き方はクール。
  def set_cookie(self, *args, **kwargs):
kwargs.setdefault('secure', True)
super(BaseHandler, self).set_cookie(*args, **kwargs)

Python-Tornado: XSRF対策

                
 Python、TornadoでのXSRFについて。用意されているメソッドやらを使うだけでできる。

 まずサーバセッティングでクッキーにXSRF対策のクッキーを埋め込むようにするため、xsrf_cookiesオプションにTrueを入れる。
settings = {
"cookie_secret": "__TODO:_GENERATE_YOUR_OWN_RANDOM_VALUE_HERE__",
"login_url": "/login",
"xsrf_cookies": True,
}
application = tornado.web.Application([
(r"/", MainHandler),
(r"/login", LoginHandler),
], **settings)


 続いてフォームタグに{% module xsrf_form_html() %}を埋め込む(以下までのコードはドキュメントからのコピペ)。そしてRequestHandlerを継承したクラスでself.render([filename])でテンプレートにレンダーを済ませてクライアントに返す。
<form action="/new_message" method="post">
{% module xsrf_form_html() %}
<input type="text" name="message"/>
<input type="submit" value="Post"/>
</form>


 ここまでは公式ドキュメントのわかりやすいところに書いてある。
http://www.tornadoweb.org/en/stable/overview.html#cross-site-request-forgery-protection

 クッキーとhiddenでフォームに埋め込んだ_xsrfの整合を調べるには、RequestHandlerクラスでメソッドが用意されている。self.check_xsrf_cookieメソッドを呼び出せばOK。整合が取れなければエラーを出してくれる。


***
 XSRF対策のトークンをクッキーとformに埋め込む。どちらかをハッシュ化しておくべきという解説をたまに見かける。盗聴や改ざん対策らしい。ハッシュ化がどちらにも効果的には思えないのだが一応。
盗聴対策→もっとベーシックな盗聴対策をするべき。SSL通信を使う。
改ざん対策→クッキーやフォームに仕込むトークンの値は予想のつかないものにしておく。XSRF対策が施されたWebサイトへの攻撃を成功させるためにはトークンの値を知る必要がある。トークンが第三者に知られるというのはXSSや盗聴が考えられるので、まずその対策をしておく。

Python-Tornado: セキュアなセッション管理を考える

                
 前回にTornadoでセキュアなWebサーバを作るにはどうやるかをすごくざっくりと書いた。今回はセッションの管理について、MongoDBを使ってやってみる。いわゆるログインとログアウトというものだ。
 MongoDBはNoSQLのデータベースだがPythonなりJavaScriptなりC#なりを書ければ、かなり学習の敷居の低いデータベースであり、パフォーマンスも優れている。そして各ドキュメント(リレーショナルデータベースでいうところのカラム)にTTL(Time To Live)という有効時間を設定できる。有効時間を過ぎたドキュメントは自動で削除されるので、cronを用意する必要がない。これにセッションIDの管理を任せたら便利そう。


 セッション開始のための認証は通常、IDとパスワードの入力を必要とするが、TornadoではOpenIDを使うクラスが用意されているので、これから書くデモコードではGoogleからOpenIDを取ってくるようにする。デモを簡単に走らせて結果を確認できるようにするために、SSL通信もクッキーへのセキュア属性の付与もここでは行わないが、本来はSSL通信をおこない、クッキーのセキュア属性も付与しておくべきだ。

 ログインとログアウトの機能を考える。
 ログインは認証のための情報(今回はGoogleから拝借するID)と引き換えにセッションIDを発行し、サーバ側ではセッションIDとGoogleから得たIDを紐づけてDBに保存しておく。サーバはクライアントのクッキーにセッションIDをセットし、以降は有効なセッションIDを持っているかで認証済みかどうかを判断する。セッションIDは永続化してはならないのでDBに保存されたセッションIDとGoogleIDを紐づけたドキュメント(レコード)に期限を設ける。クッキーにも有効期限は設定できるがクッキーはクライアントが改変できるものなので、サーバ側で仕組みを実装しておく。
 ログアウトは以下を読んで考えた。
ログアウト機能の目的と実現方法
[セキュリティ] ログアウト時にセッションIDのCookieを破棄する必要はあるか?について
PHPのセッション管理ライブラリがどうなってるか詳しくないので、Python+Tornadoを使う今回の状況において必要なことを書く。ログアウトでサーバの持っているセッションIDが破棄されれば、クライアントがクッキーに持つセッションIDをそれと同時に破棄する必要はない。もし破棄しなかったクライアント側のセッションIDが再び有効になるようなことがあるならそれ脆弱性。セッションIDの生成方法を修正しなければならない。UUIDの使用を考える。

 以上のことを考えてTornadoでのセッション管理を実装してみる。デモなのでセッションの有効期限が60秒間とすごく短くしてある。またTornadoは認証管理のためにRequestHandlerクラスを継承してget_current_userメソッドをオーバライドし、authenticatedデコレータをつけるという方法を用意している。今回は非同期処理が入るのでそれを使えず、自前でlogin_requiredメソッドを用意した。
#!/usr/bin/env python
# -*- coding: utf-8 -*-

import uuid
import datetime

import tornado.auth
import tornado.ioloop
import tornado.web
import tornado.gen
import tornado.httpserver

import pymongo
import motor


############################################# admin

class GoogleLoginHandler(tornado.web.RequestHandler, tornado.auth.GoogleMixin):
@tornado.gen.coroutine
def get(self):
if self.get_argument("openid.mode", None):
user = yield self.get_authenticated_user()
sid = str(uuid.uuid4())
yield db.session.insert({"_id":sid, "user":user["email"], "createdAt":datetime.datetime.utcnow()})
self.set_secure_cookie("sid", sid)
self.redirect(r"/admin")
else:
yield self.authenticate_redirect()

class BaseHandler(tornado.web.RequestHandler):
@tornado.gen.coroutine
def login_required(self):
sid = self.get_secure_cookie("sid")
if not sid:
raise tornado.web.HTTPError(401)
doc = yield db.session.find_one({"_id":sid})
if doc:
raise tornado.gen.Return(doc["user"])
else:
raise tornado.web.HTTPError(401, "session has expired")

class ManageHandler(BaseHandler):
@tornado.gen.coroutine
def get(self):
user = yield self.login_required()
self.write("authenticated: " + user)

class LogoutHandler(tornado.web.RequestHandler):
@tornado.gen.coroutine
def get(self):
sid = self.get_secure_cookie("sid")
result = yield db.session.remove({"_id":sid})
self.write("bye bye")

############################################# initialize and run

def init_db(host, port):
db_name = "foo_db"
c = pymongo.MongoClient(host, port)
db = c[db_name]
db.session.ensure_index("createdAt", expireAfterSeconds = 60)
c.close()

db = motor.MotorClient(host, port)[db_name]
return db

if __name__ == "__main__":
DB_HOST = "localhost"
DB_PORT = 27017
db = init_db(DB_HOST, DB_PORT)

settings = dict(cookie_secret="foooooooooooooooooooooooooooooo",
debug=True,
)

http_server = tornado.httpserver.HTTPServer(
tornado.web.Application([('/login', GoogleLoginHandler),
('/logout', LogoutHandler),
('/admin', ManageHandler),
],
**settings),
)
http_server.listen(8890)
tornado.ioloop.IOLoop.instance().start()


プロフィール

h

Author:h

最新記事
リンク
作ったものなど
月別アーカイブ
カテゴリ
タグリスト

検索フォーム
Amazon