Edited at
scoutyDay 8

プライベートISUCONのISHOCON2のresponder版を実装してみた


はじめに

最近ちょっと話題になったresponderを勉強するため、scoutyのCTOが趣味でつくっているプライベートISUCONの課題であるISHOCON2のPython実装をresponderで書き換えてみました。


responder

responderはかの有名なrequestsの作者が開発しているWEBアプリケーションフレームワークです。DjangoよりはFlaskのような軽量フレームワークに分類されるかと思いますが、後発だけあってインターフェースなどが整っているようです。

実際の実装などはuvicornstarletteを使っているようです。

ちなみに何も考えずにpip install responderすると起動時に

ModuleNotFoundError: No module named 'starlette.lifespan'

が出て死にます。

https://github.com/kennethreitz/responder/issues/255

にあるように

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='/'
)

これを実行すると、/にリダイレクトされます。実装されているソースはこれでした。


responder.api.API#redirect

    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は以下のようなコードになっています。


responder.models.Request#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で自前でアンエスケープするしかなさそうです。


完成コード

https://github.com/denzow/ISHOCON2/tree/responder-impl/webapp/python_responder

にresponderで移植した完成版がおいてありますので興味があればご覧ください。import文周りが他の実装とくらべてかなりスッキリしました。


パフォーマンス

最後にほかの実装との比較をしておきます。現在ISHOCON2にはPythonの実装としてFlaskSanicのものがありますので今回の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機能など、今回出てこなかった機能をかなり持っていますのでそのような用途であればきっと便利なんだろうと思います。