見出し画像

responderで理解するWebサーバー #5 バックグラウンドタスク

2019.7.29 追記
当初非同期処理として公開していましたが、公開後再度内容を精査した結果、非同期ではなくバックグラウンドタスク(マルチスレッド)の内容となっており誤解を生みそうだったので、タイトルを修正しました。
合わせて、非同期として書いていた箇所について一部修正しました。

こんにちわ、Webエンジニアのjuri-tです。

今日はresponderにおけるバックグラウンドタスクについて理解していきたいと思います。

準備

サーバーはいつものように簡単なところからスタートします。

import responder
api = responder.API()

@api.route("/")
async def index(req, resp):
   resp.content = api.template("index.html")

こちらはindex.htmlです。templatesフォルダにあります。

<!Doctype html>
<html lang="ja">

<head>
 <title>Index</title>
 <meta charset="UTF-8"/>
 <link rel="stylesheet" href="/static/application.css">
</head>

<body>
<div class="container">
 <h1>アカウント作成</h1>
 <div class="message">{{ message }}</div>

 <h2>フォーム登録</h2>
 <form action="/user" method="post">
   <div class="form-container">
     <div class="form-group">
       <label for="username">ユーザー名</label>
       <input type="text" id="username" name="username"/>
     </div>
     <div class="form-group">
       <label for="password">パスワード</label>
       <input type="password" id="password" name="password"/>
     </div>
     <button type="submit" name="user" id="registration">ユーザー登録</button>
   </div>
 </form>
</div>
</body>
</html>

こちらはapplication.cssです。cssやjavascriptやimgファイルなどのアセットファイルはstaticフォルダに入れます。(templates同様apiのインスタンス作成時に任意のフォルダを設定できます)

body {
 margin: 0;
 padding: 0;
 font-size: 20px;
 font-family: sans-serif;
 line-height: 1.5;
}

input {
 font-size: 18px;
}

form {
 font-size: 18px;
}

button {
 padding: 10px;
 background-color: #07f;
 color: #fff;
 border: solid 1px #000;
 border-radius: 5px;
 line-height: 1;
 cursor: pointer;
 font-size: 16px;
}

button:hover {
 background-color: #0af;
}

.container {
 margin: auto;
 padding: 20px;
 width: 800px;
}

.message {
 margin: 30px;
 text-align: center;
 font-weight: bold;
}

h1, h2 {
 text-align: center;
}

.form-container {
 width: 400px;
 margin: auto;
 text-align: center;
}

.form-container input {
 margin: 15px;
 width: 200px;
 border: none;
 border-bottom: solid 1px #aaa;
}

.form-container button {
 margin: 10px;
}

今サーバーを起動して/にアクセスすると、index.htmlがサーブされて上記のようなページが表示されているはずです。

もちろん現時点ではサーバー側にパスを用意していないので、404エラーになります。

ちなみにcssはもちろん設定しなくても動作には何の影響もないです。デフォルトのスタイルだと私のテンションがあがらないので書いているだけです。

サーバー側で入力フォームのデータを受け取る

import responder

api = responder.API()

@api.route("/")
async def index(req, resp):
   resp.content = api.template("index.html")

@api.route("/user")
async def user(req, resp):
   if req.method != 'post':
       resp.status_code = 404
       resp.content = api.template('index.html', message='404 Not Found')
       return

   data = await req.media()
   username = data['username']
   password = data['password']

   api.redirect(resp, '/')

/userルートを追加しました。データを受け取るときはreq.mediaで受け取れます。このmediaメソッドは非同期関数で定義されているので、awaitをつけないとデータを受け取ることができません。受け取ることができないというか、Pythonの場合非同期関数をawaitを使わずに呼び出すとエラーになります。

RuntimeWarning: coroutine 'Request.media' was never awaited

遅い処理を呼び出す

ここでデータベースに保存する処理を行うことを考えましょう。サーバー内でメモリ内で行う処理と比べてネットワークを通ってHDDに保存する処理というのはかなり遅いです。サーバーとしては基本的に待つしかないので、よりマシンリソースを効率的に使うために、できれば他のリクエストとか捌きながらデータベースから「保存できたよ〜」って連絡を待ちたいわけです。

2019.7.29 追記
上の処理は非同期処理(async/await)の話でした。ここで説明しているバックグラウンドタスクは、後述のようにマルチスレッドで実行する処理となっており、一般的に言われる非同期処理とは意味が異なります。公式ドキュメントにおいてもバックグラウンドタスクについては、下記のような記述となっており、非同期であることとは関係がないです。responderの非同期処理で出てくる説明にはバックグラウンドタスクを非同期処理として説明している記事も散見されますが誤りです。遅い処理の完了を待つことなく処理できるという意味合いにおいては正しいです。
Here, you can spawn off a background thread to run any function, out-of-request:

それを簡単に書けるのがresponderのバックグラウンドタスクになります。

@api.background.task
def save(*, username, password):
   # ここでDBに保存するとか遅い処理をする
   time.sleep(10)
   print(username)

@api.route("/user")
async def user(req, resp):
   if req.method != 'post':
       resp.status_code = 404
       resp.content = api.template('index.html', message='404 Not Found')
       return
   
   data = await req.media()
   username = data['username']
   password = data['password']

   save(username=username, password=password)

   api.redirect(resp, '/')

saveという関数を追加して呼び出すように変更しました。このsaveが遅い処理を行う関数の代わりです。

試しにこれを実行してみてください。Webページ上では、ボタン押したら10秒も待つことなくすぐにレスポンスが返ってきたと思います。一方、usernameが表示されたのは10秒ぐらいしてからだったと思います。つまり、ユーザーにはレスポンスをさっさと返して、遅い処理はそのあと実行できているわけです。これはユーザー体験が大事なWebアプリでは重要な機能です。(ボタン押してクルクルずっとしてたら嫌ですよね?)

responderでは@api.background.taskでデコレートした関数を呼び出すだけで、簡単にバックグラウンドタスクとして実行できます。(これ簡単ですよね?)

Flaskとかの場合はジョブキューの仕組みが必要になるので、Redisを入れてCeleryを入れてみたいなことをする必要があります。(すれば良いんですけどね)

2019.7.29 追記
ここも同様に非同期な処理と記載しましたが、誤りでした。また、FlaskではCeleryを入れてもできますが、responder同様、ThreadPoolExecutorでマルチスレッドで処理することも可能です。

api.background.taskの実体はThreadPoolExecutor

このバックグラウンドタスクはPythonのThreadPoolExecutorで実装されています。worker数は、CPUの数を取得して自動で設定しています。現在は、この設定を自分で変更することはできないようです。

これを読むと、worker数を指定しなければプロセッサの5倍が設定されるらしいので、I/Oバウンドな処理だと自前でThreadPoolExecutor使うほうがよりパフォーマンス出るかもしれませんね。ここは想像です。

JSON形式でデータを受け取る

APIサーバーとしての役割だと、JSON形式で受け取ることも多々あると思います。responderだとさっきと同様req.mediaのまま使えます。

@api.route("/user")
async def user(req, resp):
   if req.method != 'post':
       resp.status_code = 404
       resp.content = api.template('index.html', message='404 Not Found')
       return

   data = await req.media()
   username = data['username']
   password = data['password']

   save(username=username, password=password)

   if req.accepts('application/json'):
       resp.media = {'message': 'ok'}
   else:
       api.redirect(resp, '/')

やっていることは同じです。Acceptヘッダーでjsonを受け取れるときは、JSONを使ってレスポンスしている以外は同じコードです。試してみましょう。

<head>
 <title>Index</title>
 <meta charset="UTF-8"/>
 <script type="text/javascript" src="/static/application.js"></script>
 <link rel="stylesheet" href="/static/application.css">
</head>

index.htmlにapplication.jsを呼び出すように追加します。application.jsは以下のように、fetchを使って/userにPOSTします。

(function () {
 window.addEventListener('DOMContentLoaded', function () {
   const reg = document.getElementById('registration');
   reg.addEventListener('click', (e) => {
     e.preventDefault();
     const username = document.getElementById("username").value;
     const password = document.getElementById("password").value;
     fetch('/user', {
       method: 'post',
       headers: {
         "Content-Type": "application/json",
         "Accept": "application/json"
       },
       body: JSON.stringify({
         username: username,
         password: password
       })
     }).then(resp => {
       if(resp.status < 300) {
         console.log(resp);
       } else {
         console.error(resp);
       }
     }).catch(err => {
       console.error(err);
     })
   });
 })
})();

さらに、以前紹介したように同じコードでyaml形式のbodyも受け取れます

ファイルアップロードを使ってデータを送信する

さて、次は、ファイルアップロードのパターンです。

<!Doctype html>
<html lang="ja">
<head>
 <title>Index</title>
 <meta charset="UTF-8"/>
 <script type="text/javascript" src="/static/application.js"></script>
 <link rel="stylesheet" href="/static/application.css">
</head>

<body>
<div class="container">
 <h1>アカウント作成</h1>
 <div class="message">{{ message }}</div>
 <h2>フォーム登録</h2>
 <form action="/user" method="post">
   <div class="form-container">
     <div class="form-group">
       <label for="username">ユーザー名</label>
       <input type="text" id="username" name="username"/>
     </div>
     <div class="form-group">
       <label for="password">パスワード</label>
       <input type="password" id="password" name="password"/>
     </div>
     <button type="submit" name="user" id="registration">ユーザー登録</button>
   </div>
 </form>

 <hr />

 <h2>ファイルアップロード</h2>
 <form action="/upload" method="post" enctype="multipart/form-data">
   <div class="file-uploader">
     <label for="filename-label">ファイルを選択</label>
     <input type="file" id="filename-label" name="uploaded-file" accept="video/*"/>
     <button type="submit" name="save">アップロード</button>
   </div>
 </form>
</div>
</body>
</html>

<hr />以外にファイルアップロードを追加しました。cssには以下の設定を追記します。

.file-uploader {
 width: 300px;
 margin: auto;
 margin-top: 50px;
 border: solid 1px #033;
 border-radius: 3px;
 text-align: center;
 display: flex;
 justify-content: center;
 align-items: center;
 background-color: #07f;
}

.file-uploader input[type="file"] {
 display: none;
}

.file-uploader label, .file-uploader button {
 width: 150px;
 cursor: pointer;
 padding: 10px;
 border: none;
 line-height: 1em;
 text-align: center;
}

.file-uploader:hover {
 background-color: #0af;
}

.file-uploader label {
 color: #000;
 background-color: #fff;
}

.file-uploader label:hover {
 background-color: #ddd;
}

hr {
 margin: 30px;
}

ファイルを選択しても、選択したファイルは見えないので、その場合はJavaScript使ってごにょごにょやるしかなさそうです。めんどくさかったので、そこはやってないです。上の設定をしていれば、画面は下のようになっているはずです。

サーバーでファイルを受け取れるようにさっきとは別のルーティングを追加します。POST /uploadすると受け取れるようにします。multipart/form-dataを受け取るにはreq.mediaのformatにfilesを指定します。

@api.route("/upload")
async def upload(req, resp):
   if req.method != 'post':
       resp.status_code = 404
       resp.content = api.template('index.html', message="404 Not Found")
       return

   data = await req.media(format="files")
   resp.content = api.template('index.html', message="Completed!")

このとき、dataオブジェクトには、クライアント側で設定したファイル情報が格納されます。

{ 
  "uploaded-file": {
    "filename": ...,
    "content-type": "text/csv",
    "content": b'...'
   },
   "save": b''
}

最初に格納されるkeyはformに指定した名前です。contentはbytes型で取得されるので、strに変更するときは data.get('uploaded-file').get('content').decode('utf-8')みたいにやれば良いです。文字コードはちゃんと判定しないと、このままだとsjisとかは開けないのでご注意を

さっきのフォームの場合だと、データベースが遅いと言ってもよほど変なことしていない限り許容範囲のレスポンス速度になっているかもしれません。

しかし、ファイルアップロードの場合は、ファイルサイズが大きくなるとサーバー側でファイルを読み込んで何かやるとなると、ちょっと処理時間が大きくなることが予想されます。そのときに、responderだと簡単にジョブキューのようなタスクが書けるので非常に良いです。

というわけで、responderの特長の一つであるバックグラウンドタスクについて書いてきました。当初書こうとしていた非同期処理については、改めて書きたいと思います。それでは、今回はこのへんで〜


サポートありがとうございます。頂いたご支援は美味しいものを食べに行きます。