以前の記事でBottleを使ったAPIを作成しました。
ここではもうちょっと進んだ例を書いていきたいと思います。
ここでのアプリケーションは、事前に指定されたファイルシステム上のあるディレクトリの中で、ファイルやサブディレクトリを作成する、というものです。
書ききれなかったその他は別記事で。
アプリケーションの仕様
仕様というほどでもありませんが、期待動作を決めておきましょう。
要はリクエストを解析し、ファイルを操作するPython標準モジュールの機能(os.listdir()
やos.remove()
、open()
など)を呼び出すことで実現します。
- GET: アイテム(ファイルまたはディレクトリ)の参照
- クエリパラメータで種類(ファイルまたはディレクトリ)を指定できる
- ID(名前)を指定しない場合は一覧表示する
- POST: アイテムの新規作成
- クエリパラメータで種類を指定できる
- ファイルの場合はポストされた内容(文字列)を書き込む
- PUT: アイテムの変更
- 指定されたIDがファイルの場合は内容を上書き
- ディレクトリの場合はなにもしない
- DELETE: アイテムの削除
- 指定されたIDのアイテムを削除する
Bottleの良さを活かす意味で、できるだけPython標準のモジュールのみで実装したいと思います。
また、これは個人的な事情ですが、Linux (Python 2.7)でもWindows (Python 3.6)でも動くようにしたいと思います。ここではGitを介して転送しています。
検証した環境は以下の通りです。
- Windows:
- Windows 10 (x64)
- Python 3.6.1 (Anaconda 4.4.0)
- Linux:
- Ubuntu (Server) 16.04 (Xenial)
- Python 2.7.5
サーバの変更
Bottleのオプションとして、HTTPリクエストを処理するサーバ部分を変更することができます。
# もちろん別途インストールする必要があります。
デフォルトで読み込まれるもの(wsgiref: WSGI参照実装)は開発用途向けなのだそうで、確かにもっさりしているかも。まあPythonに速度を求められないよなあと思いながら、実用向けと言われているものを試してみます。
公式サイトはWSGIに対応した実装なら何でもいけるぜ!と言っていますが、ソースを見ると確かに結構な数が列挙されています(下記)。シンプルさと性能で、ここではWaitressを使うことにしました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | server_names = { 'cgi': CGIServer, 'flup': FlupFCGIServer, 'wsgiref': WSGIRefServer, 'waitress': WaitressServer, 'cherrypy': CherryPyServer, 'cheroot': CherootServer, 'paste': PasteServer, 'fapws3': FapwsServer, 'tornado': TornadoServer, 'gae': AppEngineServer, 'twisted': TwistedServer, 'diesel': DieselServer, 'meinheld': MeinheldServer, 'gunicorn': GunicornServer, 'eventlet': EventletServer, 'gevent': GeventServer, 'rocket': RocketServer, 'bjoern': BjoernServer, 'aiohttp': AiohttpServer, 'uvloop': AiohttpUVLoopServer, 'auto': AutoServer, } |
公式の説明によると、WaitressはPythonスクリプトとして動くのではなく、その下のCPython(C言語で実装されたPythonのインタプリタ)を直接たたいているので高速に動作するのだそうです。
ともあれ、簡単にpipでインストールしておきましょう。下記はUbuntuのaptを使った例です。
1 2 3 4 5 | # Python2, pipがインストールされていない場合は追加 sudo apt install python-minimal sudo apt install python-pip # ここからWaitressのインストール pip install waitress |
APIの実装
やっと本題です。
ファイル構成は次のようにしました。前記事ではフラットにおいていましたが、のちのちモジュールを増やすことを想定してサブディレクトリに配置しました。Python3では必須ではありませんが、Python2向けに__init__.py
ファイルも置いておきます。
1 2 3 4 5 6 7 | (project root) ├─app.py └─libs ├─__init__.py ├─bottle.py ├─routes.py └─utils.py |
以下にそれぞれの詳細を記載しておきます。
サーバの起動設定: app.py
app.py
では各モジュールのインポートと、エントリポイントを書いておきます。
ここでは、全てのセグメントからアクセスを受け付け、スクリプト実行時の引数(sys.argv
)でリッスンポートとサーバを指定できるようにしています。
1 2 3 4 5 6 7 8 9 10 11 12 | from libs import bottle from libs import routes import sys if __name__ == "__main__": if len(sys.argv) == 1: bottle.run(host="0.0.0.0") elif len(sys.argv) == 2: bottle.run(host="0.0.0.0", port=sys.argv[1]) else: bottle.run(host="0.0.0.0", port=sys.argv[1], server=sys.argv[2]) |
ルーティング: routes.py
routes.py
にルーティングを定義しています。
基本的には、クライアントからのリクエストに応じて、別ファイルに記載したutils
モジュールを呼んでいます。ロジックに必要なモジュールへの依存性は極力utils
モジュールに押し込め、ここでのインポートを最低限にします。
ロジックを実装するutils
モジュール内では一部の例外をハンドルしてInternal Server Error (500)を返し、utils
モジュールでハンドルしなかった例外が上がってくる場合はBad request (400)で応答しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | from libs.bottle import route, response, abort, request from libs.utils import get_items, create_item, modify_item import traceback @route('/api/item', method=['GET', 'POST']) @route('/api/item/', method=['GET', 'POST']) def handle_dir(): try: # call utils if request.method == 'GET': status, result = get_items("", request.query.itemtype) else: status, result = create_item(request.body, request.query.itemtype) # build response response.headers['Content-Type'] = 'application/json' if status: return result else: abort(500) except: # bad request abort(400) @route('/api/item/<item_id>', method=['GET', 'PUT', 'DELETE']) def handle_item(item_id): try: # call utils if request.method == 'GET': status, result = get_items(item_id) else: status, result = modify_item(item_id, request.method, request.body) # build response response.headers['Content-Type'] = 'application/json' if status: return result else: abort(500) except: # bad request abort(400) |
ロジック: utils.py
utils.py
ではルーティングから呼ばれる機能を実装しています。
GETリクエストには、ハードコードされたディレクトリ(base_dir
)にあるアイテム(ディレクトリやファイル)を取得してリストオブジェクトを作成し、JSON文字列として返します。
POSTやPUTリクエストに対しては、ID(item_id
)を使ってアイテムを作成・変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | import os import json import platform import datetime import traceback import uuid # set base location if platform.uname()[0] == "Windows": base_dir = "C:\\data" else: base_dir = "/home/{}/data".format(os.environ['USER']) if not os.path.isdir(base_dir): raise Exception("base_dir not found: {}".format(base_dir)) def _get_contents(item_type="all"): path_list = [os.path.join(base_dir, x) for x in os.listdir(base_dir)] def _get_file_content(file_name): with open(os.path.join(base_dir, file_name), 'r') as f: return json.load(f) result = [] for item in path_list: # convert time stamp to string _ts = os.path.getctime(item) date_str = datetime.datetime.fromtimestamp(_ts).isoformat() if os.path.isdir(item): if item_type == "file": continue # build directory info entity = {"id": os.path.basename(item), "item_type": "dir", "content": "(N/A)", "path": item, "size": -1, "date": date_str} else: if item_type == "dir": continue # build file info file_id = os.path.basename(item) entity = {"id": file_id, "item_type": "file", "content": json.dumps(_get_file_content(file_id)), "path": item, "size": os.path.getsize(item), "date": date_str} result.append(entity) return result def get_items(item_id="", item_type="all"): try: content_list = _get_contents(item_type) if item_id != "": # the case specified item requested for content in content_list: if content["id"] == item_id: return (True, json.dumps(content, indent=4)) # throw exception if no content matched raise Exception("specified item was not found") else: return (True, json.dumps(content_list, indent=4)) except: print(traceback.format_exc()) return (False, json.dumps({"message": "failed to get item(s)"})) def _edit_item(item_path, item_type, mode, payload): if mode == "DELETE": if os.path.isfile(item_path): os.remove(item_path) elif os.path.isdir(item_path): os.rmdir(item_path) else: raise Exception("specified path was not found") else: if item_type == "file": # post file or put file data = json.load(payload) with open(item_path, 'w') as f: json.dump(data, f, indent=4) else: if os.path.isdir(item_path): # do nothing if "put" directory and it's exists pass else: raise Exception("specified path was not found") def create_item(payload, item_type): try: # generate new id item_id = "{}".format(uuid.uuid4()) # create new item on filesystem if item_type == "dir" or item_type == "": os.mkdir(os.path.join(base_dir, item_id)) else: _edit_item(os.path.join(base_dir, item_id), "file", "POST", payload) return (True, json.dumps({"id": item_id, "itemtype": item_type})) except: print(traceback.format_exc()) return (False, json.dumps({"message": "failed to create item"})) def modify_item(item_id, mode, payload): try: content_list = _get_contents() for content in content_list: if content["id"] == item_id: _edit_item(content["path"], content["item_type"], mode, payload) response = json.dumps( {"id": item_id, "item_type": content["item_type"]}, indent=4) return (True, response) raise Exception("specified item was not found") except: print(traceback.format_exc()) return (False, json.dumps({"message": "failed to modify item"})) |
スクリプトは見たままですが、以下に詳細を解説しておきます。APIという本旨からは脱線するので、読み飛ばしても大丈夫です。
LinuxとWindowsの判別
LinuxとWindowsではパスの体系が違うので、platform
モジュールを使って判別します。
platform.uname()
が返すリスト(厳密にはnamed tuple)の最初の要素がOSの違いを格納しているので、それを使って操作対象にするディレクトリ(base_dir
)を切り替えています。
1 2 3 4 5 | # set base location if platform.uname()[0] == "Windows": base_dir = "C:\\data" else: base_dir = "/home/{}/data".format(os.environ['USER']) |
大抵のLinuxでは環境変数$USER
にユーザ名が格納されているので、これをos.environ()
で参照してホームディレクトリのパスを取得しています。
また、条件式は次のような書き方でも同じことです。
1 2 3 | # set base location if "Windows" in platform.uname(): ... |
IDの作成
ここではID(item_id
)をUUID(uuid
モジュール)を使って作成しています。
アイテムの実体はファイルシステム上のディレクトリまたはファイルなので、重複しない文字列が必要です。
ここではuuid.uuid4()
メソッドが返す値を文字列(string型)にフォーマットしていますが、例えば時刻を使っても(まず無いと思いますが作成タイミングが重複しなければ)一意な文字列を作れます。
1 2 3 | # generate new id from datetime import datetime as dt dt.now().strptime(dtstr, '%Y%m%d-%H%M%S-%f') |
Python2対応
私の場合、コーディングとデバッグはPython3環境(Windows)で、実行はPython2環境(Linux)で実施しています。
そこで、Python3でデバッグしたものがPython2でも通るようにする必要があります。
3to2
を使うなどやり方はいろいろあるのですが、ここではそう複雑なことをしていないので、書き方に注意するくらいで基本的には同じコードを使うことにします。
Python2からPython3では、print
文が関数へ変更されていますが、Python3の書き方をしておけばPython2(ここでは2.7)でも通ります。また、Python2ではサブディレクトリに配置したモジュールを認識させるため__init__.py
を作成しておく(空でok)必要があります。__init__.py
は置いておくだけならPython3でも動作に影響しないので、こちらはPython2に合わせています。
アプリケーションの実行
WindowsでもLinuxでも同じコマンドで実行できます。下記はLinuxでの実行例です。
なお、操作対象となるディレクトリを忘れずに作成しておきます。Linuxの場合はパーミッションに注意が必要ですが、ディレクトリの所有者と同じユーザで実行すればまず大丈夫でしょう。
1 2 3 4 | [user@localhost:(project root)/]$ python app.py 8080 waitress Bottle v0.13-dev server starting up (using WaitressServer())... Listening on http://0.0.0.0:8080/ Hit Ctrl-C to quit. |
実際にリクエストを投げてみましょう。
POST(アイテム作成)メソッドのパラーメータitemtype
でアイテムの種類を指定すると、同期的に指定された種類のものを作成したあと、新規に付与したIDを応答します。
1 2 3 4 5 6 7 8 | (POST request) http://(server address):8080/api/item?itemtype=file (response) { "id": "35f88fe7-59b8-4c69-9568-315dde1e7e77", "item_type": "file" } |
GETリクエストでディレクトリにあるファイルとディレクトリの情報を取得できます。
長いので省略。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | (GET request) http://(server address):8080/api/item?itemtype=all (response) [ { "content": "...", "item_type": "file", "date": "2016-11-01T12:27:50.780118", "path": "/home/user/data/a9b252b3-f7f6-4038-9701-7e2c2623e8c5", "id": "a9b252b3-f7f6-4038-9701-7e2c2623e8c5", "size": 172 }, { "content": "...", "item_type": "file", "date": "2016-11-01T12:40:31.131910", "path": "/home/user/data/85036b19-a501-4702-b83d-7fac37861f13", "id": "85036b19-a501-4702-b83d-7fac37861f13", "size": 172 }, ..., { "content": "(N/A)", "item_type": "dir", "date": "2016-11-01T12:27:50.868119", "path": "/home/user/data/fce39d8a-d349-41c3-b2b9-a491eb6194e2", "id": "fce39d8a-d349-41c3-b2b9-a491eb6194e2", "size": -1 } ] |
POSTリクエストでは、対象がファイルのIDを指定した場合、リクエストのボディにセットされたJSONデータで上書きします。
また、試しに存在しないIDを指定した場合はBad request (400)を応答していることがわかります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | # 存在するアイテム名を指定 (PUT request) http://(server address):8080/api/item/85036b19-a501-4702-b83d-7fac37861f13 (response) - [xx/xx/2017 xx:xx:xx] "PUT /api/item/85036b19-a501-4702-b83d-7fac37861f13 HTTP/1.1" 200 78 { "item_type": "file", "id": "85036b19-a501-4702-b83d-7fac37861f13" } # 存在しないアイテム名を指定 (PUT request) http://(server address):8080/api/item/id-not-defined (response) - [xx/xx/2017 xx:xx:xx] "PUT /api/item/id-not-defined HTTP/1.1" 400 741 |
その他の話題は続きで。