はじめに
最近ちょっと話題になったresponderを勉強するため、scoutyのCTOが趣味でつくっているプライベートISUCONの課題であるISHOCON2のPython実装をresponderで書き換えてみました。
responder
responderはかの有名なrequestsの作者が開発しているWEBアプリケーションフレームワークです。DjangoよりはFlaskのような軽量フレームワークに分類されるかと思いますが、後発だけあってインターフェースなどが整っているようです。
実際の実装などはuvicorn
やstarlette
を使っているようです。
ちなみに何も考えずにpip install responder
すると起動時に
ModuleNotFoundError: No module named 'starlette.lifespan'
が出て死にます。
にあるように
pip install starlette==0.8
でstarletteの0.8を明示しないといけないようです。
ISHOCON
@showwinさんがつくっているプライベートのISUCONです。1人で8時間程度で課題をこなせる程度になっているので本家に比べてボリュームは少なめになっています。しかし、スタンダードなWEBアプリケーションに必要な要素を含んでいます。
responderでのあれこれ
ISHOCONをresponderで書き換えていった上で、いくつかresponderのポイントをまとめていきます。
Databaseとの接続
responderはイベントループで動く非同期アプリケーションなので、DBとの接続を初期化するタイミングに注意します。
ここをみると、on_event
を使ってサーバの起動停止時のタイミングで初期化と破棄をするようにするのが良いようです。ちなみに、ドキュメントではcleanup
というイベントでしたが、実際にはshutdown
を指定するようです。
import responder
api = responder.API()
@api.on_event('startup')
async def open_database_connection_pool():
print('startup')
@api.on_event('shutdown')
async def open_database_connection_pool():
print('shutdown')
if __name__ == '__main__':
api.run(address='0.0.0.0', port=8080, debug=True)
これを実行するとこんな感じで起動停止時にメッセージが出ます。
$ python app.py
INFO: Started server process [1460]
INFO: Waiting for application startup.
startup ★
INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
^CINFO: Stopping server process [1460]
INFO: Waiting for application shutdown.
shutdown ★
あとは、ここにこんな感じでaiomysql
のコードを差し込めばよいわけです。
@api.on_event('startup')
async def open_database_connection_pool():
pool = await aiomysql.create_pool(**{
:<DB接続パラメータ>
})
api.mysql = pool
@api.on_event('shutdown')
async def open_database_connection_pool():
api.mysql.close()
await api.mysql.wait_closed()
使うときは以下のようにします。
@api.route('/')
async def index(req, resp):
async with api.mysql.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute('select version() as version')
result = await cur.fetchone()
resp.text = result['version']
これで/
にアクセスすると使っているMySQLのバージョンが表示されます。
テンプレートエンジン
少し前に話題になったSanicとかだとテンプレートエンジンバンドルされてなかったりしましたが、responderはデフォルトでJinja2が入ってるので簡単です。api.template
でそのままJinja2のrender
が呼ばれるので結果をresp.content
に詰めるだけです。
resp.content = api.template(
'index.html',
candidates=candidates,
parties=parties,
sex_ratio=sex_ratio
)
テンプレートファイルの格納先などは、responder.API
のコンストラクタで変更できるようになっています。
api = responder.API(
templates_dir='templates' # 省略時はデフォルトでtemplatesが入るので明示しなくてもよい
)
静的ファイル配信
実際の運用や、チューンしたISUCONの課題でも静的ファイルはnginxなどで返すことにはなりますが、初期実装はたいていアプリケーションサーバがCSSやJSなどの静的ファイルを配信しているので、responderで静的ファイルを配信するようにします。
responderではwhitenoiseという静的ファイル配信用のライブラリが組み込まれています。デフォルトでは/static
というパスでのアクセスを、static
ディレクトリに割り当てて静的ファイルの配信が行われます。これを変更するにはresponder.API
のコンストラクタで変更できるようになっています。
api = responder.API(
:
static_dir='public',
static_route='/public',
)
この設定で、/public/hoge.css
というアクセスは<APP_ROUTE>/public/hoge.css
というファイルを戻すようになります。
リダイレクト
意外とドキュメントみてもよくわからなかったのがリダイレクトの実装方法。
api.redirect(
resp=resp,
location='/'
)
これを実行すると、/
にリダイレクトされます。実装されているソースはこれでした。
def redirect(
self, resp, location, *, set_text=True, status_code=status_codes.HTTP_301
):
"""Redirects a given response to a given location.
:param resp: The Response to mutate.
:param location: The location of the redirect.
:param set_text: If ``True``, sets the Redirect body content automatically.
:param status_code: an `API.status_codes` attribute, or an integer, representing the HTTP status code of the redirect.
"""
# assert resp.status_code.is_300(status_code)
resp.status_code = status_code
if set_text:
resp.text = f"Redirecting to: {location}"
resp.headers.update({"Location": location})
ステータスコードいれて、ヘッダーを更新しているだけなので自分でやってもまぁ同じですね。
GET/POSTでのルーティング
Flaskとかだと
@app.route('/vote', methods=['GET'])
def vote_get(request):
:
@app.route('/vote', methods=['POST'])
def vote_get(request):
:
みたいなことができます。responderの場合、これに相当する機能は多分なさそう?? (少なくともapi.route
では相当する引数を取るようにはできてなかった)
そのため、こんな感じで書くしかなさそうです・・?
@api.route('/vote')
async def vote(req, resp):
if req.method == 'post':
await vote_post(req, resp)
else:
await vote_get(req, resp)
formデータの取得
フォームからPOSTされたデータを取得する場合は、以下のようにreq.media()
を使用します。
async def post_vote(req, resp):
form = await req.media()
await
する必要がある点に注意します。戻されるのはresponder.models.QueryDict
というDictライクなオブジェクトなのでそのまま使うことができます。なお、req.media
は以下のようなコードになっています。
async def media(self, format=None):
"""Renders incoming json/yaml/form data as Python objects. Must be awaited.
:param format: The name of the format being used. Alternatively accepts a custom callable for the format type.
"""
if format is None:
format = "yaml" if "yaml" in self.mimetype or "" else "json"
format = "form" if "form" in self.mimetype or "" else format
if format in self.formats:
return await self.formats[format](self)
else:
return await format(self)
見ればわかるように、memetype
に応じてフォーマッタが切り替わっていますので、form
以外のmimeの場合は異なる形式で戻されるようです。
マルチバイトを含んだURLマッピング
例えば以下のようなroutingの設定があります。
@api.route('/political_parties/{name}')
async def political_parties(req, resp, name):
print(name)
この状態で/political_parties/国民元気党
というURLでアクセスすると以下の結果になります。
%e5%9b%bd%e6%b0%91%e5%85%83%e6%b0%97%e5%85%9a
URLエンコードされた状態でそのまま渡されてしまっています。これをresponder側の設定などでどうにかできないか探したのですが、ちょっとなさそうでしたのでurllib.parse.unquote_plus
で自前でアンエスケープするしかなさそうです。
完成コード
にresponderで移植した完成版がおいてありますので興味があればご覧ください。import文周りが他の実装とくらべてかなりスッキリしました。
パフォーマンス
最後にほかの実装との比較をしておきます。現在ISHOCON2にはPythonの実装としてFlask
とSanic
のものがありますので今回のresponder
を含めた3種で比較します。スコアは、ISHOCON2に同梱されているベンチマークを以下のコマンドで実行した際の結果です。
# ./benchmark --ip app
実装 | スコア |
---|---|
Flask | 2120 |
Sanic | 8068 |
responder | 6542 |
Flaskはやっぱりasyncioを使っているSanic/responderに比べると遅いです。そしてSanicの速さが目立つ結果になりました。今回のresponderのコードはほぼSanic版の移植でしたので、ISUCON用途でresponderを採用することはなさそうです。(この記事の存在意義が薄れる)
感想
スタンダードなWEBアプリケーションを作る上では、responderは必ずしもわかりやすいものではないような気持ちになりました。FlaskやSanicとくらべると少し生のレスポンスやリクエストをいじっている気分です。
しかしresponderは非同期タスクやWebsocketの実装、GraphQL機能など、今回出てこなかった機能をかなり持っていますのでそのような用途であればきっと便利なんだろうと思います。