こんにちは。AWS CLIが好きな福島です。
はじめに
インフラエンジニアのキャリアがメインの私が社内でPythonを使ったアプリ開発の学習をする機会があったため、先日から学んだことをアウトプットしています。
今回は以下の技術を活用し、TODO管理ができるWebアプリを作ってみたいと思います。
- Python
- プログラミング言語
- Flask
- Webアプリフレームワーク
- Jinja2
- テンプレートエンジン
- Bootstrap
- フロントエンドツールキット
- DynamoDB
- AWSのマネージドなNoSQLサービス
各技術の入門に関するブログも書いているため、ご興味ある方は以下もご確認ください。
完成イメージ
完成イメージは以下の通りです。
設計
まずは、簡単に設計します。
機能
今回は、TODO管理として以下の機能を実装することにします。
- 一覧表示
- 追加
- 更新
- 削除
画面および画面遷移図
画面および遷移図に加え、機能もまとめて書いちゃってます。
URLパス設計
URLのパスは以下の通りにします。
No | パス設計 | メソッド | 備考 |
---|---|---|---|
1 | / | GET | トップページとなります。 TODO一覧を表示するページになります。 |
2 | /todos | GET | 追加/更新するTODOの内容を入力するページになります。 |
3 | /todos/add | POST | TODOを追加し、トップページにリダイレクトします。 |
4 | /todos/update | POST | TODOを更新し、トップページにリダイレクトします。 |
5 | /todos/delete | POST | TODOを削除し、トップページにリダイレクトします。 |
3-5でリダイレクトしているのは、POST
メソッドを送信した後にブラウザの再読み込みにより、フォームの二重送信されるのを防ぐためです。
この仕組みをPost/Redirect/Get
と呼びます。
アーキテクチャ図
アーキテクチャ図は以下の通りです。
- Webサーバの
Werkzeug
は、Flask
に組み込まれているライブラリになります。あくまで開発用途なので、本番ではgnicorn
などを使うのが推奨されています。 - アプリケーションにおける3層アーキテクチャ(プレゼンテーション層,ビジネスロジック層,データアクセス層)と
Flask
のMTVモデル
(Model
,Template
,View
)を組み合わせると上記イメージかなと思います。 Model
がビジネスロジックやデータベースのアクセスを担当し、Template
がHTMLなどのテンプレートを管理、View
がユーザーからのリクエストを受け取り、Template
やModel
をコントロールするイメージです。
アーキテクチャの部分は、以下の記事を参考にさせていただきました。
機能ごとのシーケンス
各機能における処理の詳細は、以下の通りです。 ()で記載しているA-Hは、アーキテクチャ図の記号とリンクしています。
TODO一覧の表示
- ①ユーザーがWebサーバにリクエスト(A)
- ②Webサーバからアプリケーションサーバ(View)にリクエスト(B)
- ③ViewからModelにデータの取得を指示(C)
- ④ModelがデータベースからTODO一覧を取得(D)
- ⑤Modelが処理結果をViewに返す(E)
- ⑥ViewがTemplateからHTMLテンプレートを取得(F)
- ⑦⑤,⑥を基にWebページをレンダリングして、Webサーバにレスポンス(G)
- ⑧Webサーバからユーザーにレスポンス(H)
TODOの追加/更新/削除
TODOの追加/更新/削除処理はほぼ同じ流れになるため、まとめて記載します。
- ①ユーザーがWebサーバにリクエスト(A)
- ②Webサーバからアプリケーションサーバ(View)にリクエスト(B)
- ③ViewからModelにデータの追加/更新/削除を指示(C)
- ④ModelがデータベースにTODOを追加/更新/削除(D)
- ⑤Modelが処理結果をViewに返す(E)
- ⑥ViewがWebサーバにTODO一覧ページへのリダイレクトを指示(G)
- ⑦WebサーバからユーザーにTODO一覧ページへのリダイレクトを指示(H)
- ⑧TODO一覧の表示と同様の処理
TODOに含める情報
TODOには以下の情報を含めることにします。
- タイトル
- 任意の文字列
- 詳細
- 任意の文字列
- ステータス
- In Progress or Pending or Completed
テーブル設計
テーブル名は、TodoTable
にします。
また保存するデータは、TODOに含める情報に加え、アイテムを特定するためキーとして、TodoIDを設定します。
No | キー | 型 | 備考 |
---|---|---|---|
1 | TodoID | str | パーティションキー |
2 | Title | str | - |
3 | Detail | str | - |
4 | TodoStatus | str | - |
ディレクトリ/ファイル構成
今回は、以下の構成にします。
$ tree todo-app todo-app ├── models.py ├── templates │ ├── index.html │ └── todo_form.html └── views.py $
実装
まずは、DynamoDBを作成します。
aws dynamodb create-table \ --table-name todo-table \ --attribute-definitions \ AttributeName=TodoID,AttributeType=S \ --key-schema \ AttributeName=TodoID,KeyType=HASH \ --provisioned-throughput \ ReadCapacityUnits=5,WriteCapacityUnits=5
Flaskをインストールします。
pip install flask
各プログラムファイルを作成します。
models.py
import uuid import boto3 from boto3.dynamodb.types import TypeDeserializer TABLE_NAME = "TodoTable" dynamodb_client = boto3.client("dynamodb") def dynamo_to_python(dynamo_object): deserializer = TypeDeserializer() return {k: deserializer.deserialize(v) for k, v in dynamo_object.items()} def get_item(args): if not args.get("todo_id"): return {} todo_id = args.get("todo_id") item = dynamodb_client.get_item( TableName=TABLE_NAME, Key={"TodoID": {"S": todo_id}} )["Item"] return dynamo_to_python(item) def scan_items(): items = [] response = dynamodb_client.scan(TableName=TABLE_NAME) for dynamo_object in response["Items"]: items.append(dynamo_to_python(dynamo_object)) return items def put_item(form): todo_id = str(uuid.uuid4()) title = form["title"] detail = form["detail"] status = form["status"] dynamodb_client.put_item( TableName=TABLE_NAME, Item={ "TodoID": {"S": todo_id}, "Title": {"S": title}, "Detail": {"S": detail}, "TodoStatus": {"S": status}, }, ) def update_item(form): todo_id = form["todo_id"] title = form["title"] detail = form["detail"] status = form["status"] dynamodb_client.update_item( TableName=TABLE_NAME, Key={"TodoID": {"S": todo_id}}, UpdateExpression="SET Title=:title, Detail=:detail, TodoStatus=:satus", ExpressionAttributeValues={ ":title": {"S": title}, ":detail": {"S": detail}, ":satus": {"S": status}, }, ) def delete_item(todo_id): dynamodb_client.delete_item(TableName=TABLE_NAME, Key={"TodoID": {"S": todo_id}})
views.py
from flask import Flask, redirect, render_template, request, url_for from models import delete_item, get_item, put_item, scan_items, update_item app = Flask(__name__) @app.route("/") def index(): items = scan_items() return render_template("index.html", items=items) @app.route("/todos") def todo_form(): item = get_item(request.args) return render_template("todo_form.html", item=item) @app.route("/todos/add", methods=["POST"]) def add_todo(): put_item(request.form) return redirect(url_for("index")) @app.route("/todos/update", methods=["POST"]) def update_todo(): update_item(request.form) return redirect(url_for("index")) @app.route("/todos/delete", methods=["POST"]) def delete_todo(): delete_item(request.form["todo_id"]) return redirect(url_for("index"))
templates/index.html
<!doctype html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous"> <title>TODO管理</title> </head> <body> <div class="container mt-5"> <h1 class="text-center">TODO管理</h1> <div class="text-end"> <a href="{{ url_for('todo_form') }}" class="btn btn-info mb-3">追加</a> </div> <table class="table table-bordered text-center"> <thead class="table-success"> <tr> <th>タイトル</th> <th>詳細</th> <th>ステータス</th> <th>アクション</th> </tr> </thead> <tbody> {% for item in items %} <tr> <td>{{ item.Title }}</td> <td>{{ item.Detail }}</td> <td>{{ item.TodoStatus }}</td> <td> <a href="{{ url_for('todo_form', todo_id=item.TodoID) }}" class="btn btn-primary">更新</a> <form action="{{ url_for('delete_todo') }}" method="post" class="d-inline"> <input type="hidden" name="todo_id" value="{{ item.TodoID }}"> <button type="submit" class="btn btn-danger">削除</button> </form> </td> </tr> {% endfor %} </tbody> </table> </div> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script> </body> </html>
templates/todo_form.html
<!doctype html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous"> <title>TODO追加/更新</title> </head> <body> <div class="container mt-5"> <h1 class="text-center">TODO追加/更新</h1> <form action="{{ url_for('add_todo') if not item.get('TodoID') else url_for('update_todo') }}" method="post"> <div class="form-group"> <label for="title">タイトル</label> <input type="text" class="form-control" id="title" name="title" value="{{ item.get('Title', '') }}" required> </div> <div class="form-group"> <label for="detail">詳細</label> <textarea class="form-control" id="detail" name="detail" rows="3" required>{{ item.get('Detail', '') }}</textarea> </div> <div class="form-group"> <label for="status">ステータス</label> <select class="form-control" id="status" name="status" required> <option value="Pending" {% if item.get('TodoStatus') == 'Pending' %}selected{% endif %}>Pending</option> <option value="In Progress" {% if item.get('TodoStatus') == 'In Progress' %}selected{% endif %}>In Progress</option> <option value="Completed" {% if item.get('TodoStatus') == 'Completed' %}selected{% endif %}>Completed</option> </select> </div> {% if item.get('TodoID') %} <input type="hidden" name="todo_id" value="{{ item.get('TodoID') }}"> {% endif %} <button type="submit" class="btn btn-primary">保存</button> </form> </div> </body> </html>
動作確認
以下のコマンドでFlask
を起動します。
flask --app views run --debug
- 実行例
$ pwd /blog-sample/todo-app $ $ flask --app views run --debug * Serving Flask app 'views' * Debug mode: on WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. * Running on http://127.0.0.1:5000 Press CTRL+C to quit * Restarting with stat * Debugger is active! * Debugger PIN: 114-794-361
http://127.0.0.0:5000
にアクセスします。
TODO追加の確認
追加ボタンを押下します。
任意の値を入力し、保存ボタンを押下します。
値が保存されていることを確認します。
TODO更新の確認
更新ボタンを押下します。
任意で値を変更し、保存ボタンを押下します。
値が更新されていることを確認します。
TODOの削除
削除ボタンを押下します。
データが削除されることを確認します。
終わりに
今回は、WebアプリとしてTODOアプリを作成してみました。 どなたかのお役に立てれば幸いです。