以前、Google Apps Scriptを使って、スプレッドシート上にtodoアプリを作りました。
タスクを追加して、startボタンを押すと作業時間を計測し、stopボタンを押すと時間の計測を停止して、開始時刻-終了時刻でgoogleカレンダーにタスクの予定を登録するアプリでした。
とても便利に使っていたのですが、スクリプトの入ったスプレッドシートはモバイル端末では実行できない(スプレッドシートアプリでは実行できない)ので、困っていました。
ずっとwebアプリケーションを作ってみたいと思っていたので、この機会に挑戦してみることにしました。
ロードマップ
- Flask × SQLiteで基本的な機能を実装する ← 今回はここ
- FlaskのサーバーとPythonのリストで簡単なアプリを作成し動作を確認する
- データベース (SQLite)を導入する
- Dockerコンテナを立ててサーバーにする(自分の家のネットワークからのみアクセスできる状態)
- todoアプリに実行時間測定の機能を実装する
- タスクの実行時間を測定できるようにする(ストップウォッチ形式)
- 累積実行時間をデータベースに登録し、ブラウザにも表示する
- 開始時刻-終了時刻でgoogleカレンダーにタスクの予定を登録する
- todoアプリにタスクの完了の機能を実装する
- チェックボックスを表示し、チェックを入れたタスクを’Done’ステータスに変更する
- SQLでステータスに応じた表示の絞り込みを実装する
- ラズパイをサーバーにする
- 自分の家の外のネットワークからも接続できるようにする
実行環境の準備 (MacOS)
以前書いたブログ記事をもとに作業していたのですが、実際に動かしてみるとうまくいかないところがあったので、2023/10/7に更新しました。Macでプログラミングをするための初期設定(pyenv + poetry + VSCode)【2023/10/7更新】今度は大丈夫なはず!
pyenvでインストールしたpythonのバージョンを、poetryのプロジェクトに適用させるところで苦労しました。
今回実装したこと
テキスト入力欄にタスク名を入力し、「Add ToDo」ボタンを押すとリストに登録されるようなアプリケーションを作ります。「Delete」を押すとそのタスクが削除されます。
初心者なので、段階的に機能を拡張していくことにしました。
- FlaskのサーバーとPythonのリストで簡単なアプリを作成し動作を確認する
- データベース (SQLite)を導入する
- Dockerコンテナを立ててサーバーにする(自分の家のネットワークからのみアクセスできる状態)
FlaskのサーバーとPythonのリストで簡単なアプリを作成し動作を確認する
ChatGPT先生に聞きつつ進めました。
poetryにflaskをインストールする
まず、poetryプロジェクト「todo_app」を作成し、そこでflaskをインストールしました。poetryの使い方の詳細はMacでプログラミングをするための初期設定(pyenv + poetry + VSCode)【2023/10/7更新】をご覧ください。
$ poetry new todo_app
$ cd todo_app
$ poetry add flask
$ poetry install
コードの用意
次に、コードを準備しました。ディレクトリ構成は次のとおりです。
todo_app/
│
├── app/
│ └── app.py # アプリケーションのメインファイル
│
├── templates/
│ └── index.html # フロントエンドを構成するファイル
│
├── poetry.lock
│
└── pyproject.toml
アプリケーションを実行するときは、
$ poetry run python app.py
を実行します。
すると、次のようなメッセージが表示されます。
* Serving Flask app 'app'
* 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: XXX-XXX-XXX
と表示されるので、http://127.0.0.1:5000 をブラウザで開くと、アプリが表示されます(この構成では、Flaskのdevelopment serverを使っています)。Ctrl + Cでapp.pyを終了すると、ブラウザからはアクセスできなくなります。
app.py
from flask import Flask, render_template, request, redirect, url_for
app = Flask(__name__) # Flaskアプリケーションの初期化
todos = [] # todoリストのデータをリストで保存します(今後データベースに変更します)
# ルート(/)のエンドポイントを定義し、index.htmlテンプレートをレンダリング
@app.route('/')
def index():
return render_template('index.html', todos=todos)
# add_todoエンドポイントを定義し、POSTメソッドで新しいToDoアイテムを追加
@app.route('/add_todo', methods=['POST'])
def add_todo():
todo = request.form.get('todo') # フォームから送信されたtodoパラメータを取得
todos.append(todo)
return redirect(url_for('index'))
# delete_todoエンドポイントを定義し、指定されたIDのToDoアイテムを削除
@app.route('/delete_todo/<int:todo_id>')
def delete_todo(todo_id):
try:
todos.pop(todo_id) # リストから指定されたID(インデックス)の要素を削除
except IndexError:
pass
return redirect(url_for('index'))
if __name__ == "__main__":
app.run(debug=True)
「エンドポイント」とは、WebアプリケーションがHTTPリクエストを受け付けるURLのパスのことです。特定のエンドポイントにリクエストが来たときに、どの関数が実行されるかを定義します。ルート(/)のエンドポイントとは、アプリケーションのトップレベル、つまり基本URL(例:http://example.com/
)にアクセスがあったときのエンドポイントを指します。
以下のコードは、ルート(/)のエンドポイントを定義しています。
@app.route('/')
def index():
return render_template('index.html', todos=todos)
@app.route('/')
というデコレータは、ルート(/)のエンドポイントにアクセスがあったときに、index()
関数を呼び出すことを定義しています。
「テンプレートをレンダリングする」とは、テンプレートファイル(index.html
)を取り、その中の特殊な構文や変数を解釈・置換して、最終的なHTMLコンテンツを生成するプロセスを指します。Flaskではrender_template
関数を使用してテンプレートをレンダリングします。
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ToDo List</title>
</head>
<body>
<h1>ToDo List</h1>
<form action="/add_todo" method="post">
<input type="text" name="todo" id="todo" required>
<input type="submit" value="Add ToDo">
</form>
<ul>
{% for todo in todos %}
<li>
{{ todo }} <a href="{{ url_for('delete_todo', todo_id=loop.index0) }}">Delete</a>
</li>
{% endfor %}
</ul>
</body>
</html>
データがtodosという名前のリストに格納されているため、削除する場合はループ処理で取り出します。
データベース (SQLite)を導入する
今回は、SQLite
とFlask-SQLAlchemy
を使用します。
macOSの場合は、SQLite3がインストール済みです。
Linuxの場合は次のコマンドでSQLite3をインストールします。
$ sudo apt-get update
$ sudo apt-get install sqlite3
poetryプロジェクトにFlask-SQLAlchemyをインストールします。
$ poetry add Flask-SQLAlchemy
$ poetry install
コードをデータベース対応にする
app.pyでデータベースを設定します。
from flask import Flask, render_template, request, redirect, url_for
from flask_sqlalchemy import SQLAlchemy # Flaskで使用するためのSQLAlchemyの拡張ライブラリ
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///todos.db' # 使用するデータベースのURIを設定。ここでは、todos.dbというSQLiteデータベースを使用
db = SQLAlchemy(app) # appを引数にSQLAlchemyインスタンスを作成
# Todoクラスはデータベースのモデルを定義する
# idは主キーで、contentはToDoアイテムのテキストを保存する
class Todo(db.Model):
id = db.Column(db.Integer, primary_key=True)
content = db.Column(db.String(200), nullable=False)
# オブジェクトの内容を人間が読める形式で簡単に確認できるようにする
def __repr__(self):
return self.content
# Todoモデルのすべてのレコードを取得し、index.htmlテンプレートをレンダリング
@app.route('/')
def index():
todos = Todo.query.all()
return render_template('index.html', todos=todos)
# フォームから受け取ったtodoパラメータをTodoモデルのcontentに設定し、データベースに追加
@app.route('/add_todo', methods=['POST'])
def add_todo():
todo_content = request.form.get('todo')
new_todo = Todo(content=todo_content)
db.session.add(new_todo)
db.session.commit()
return redirect(url_for('index'))
# 指定されたtodo_idに対応するTodoモデルのレコードをデータベースから削除
@app.route('/delete_todo/<int:todo_id>')
def delete_todo(todo_id):
todo = Todo.query.get_or_404(todo_id)
db.session.delete(todo)
db.session.commit()
return redirect(url_for('index'))
(-- 以下同様 --)
index.htmlも修正します。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ToDo List</title>
</head>
<body>
<h1>ToDo List</h1>
<form action="/add_todo" method="post">
<input type="text" name="todo" id="todo" required>
<input type="submit" value="Add ToDo">
</form>
<ul>
{% for todo in todos %}
<li>
{{ todo.content }} <a href="{{ url_for('delete_todo', todo_id=todo.id) }}">Delete</a>
</li>
{% endfor %}
</ul>
</body>
</html>
データベースを使わない場合、todos
はPythonリストで、その要素は単なる文字列でしたが、データベースを使う場合、Todo
テーブルは、id
とcontent
という2つのカラムを持っています。
index.html
内で、{% for todo in todos %}
というループを使うと、todos
リスト内の各Todo
エンティティにアクセスできます。そして{{ todo.content }}
や{{ todo.id }}
といった形で、各Todo
エンティティのcontent
属性やid
属性にアクセスできます。
データベースとテーブルの初期化
データベースとテーブルを初期化します。シェルやターミナルから以下のコマンドを実行します。
$ flask shell
Pythonのインタラクティブシェルで以下を実行します。
> from app import db
> db.create_all()
これで、todoタスクがデータベースに保存されるようになりました!
.dbファイルをみてみよう
SQLiteの場合、データは.dbファイルに保存されています。
appディレクトリの中に、instance/todos.dbファイルが作成されているので、sqlite3コマンドで中身をみてみましょう。
$ poetry run sqlite3 ./instance/todos.db
すると、次のようにsqlを受け付けるようになります。
SQLite version 3.39.5 2022-10-14 20:58:05
Enter ".help" for usage hints.
sqlite>
テーブルの名前と、格納されているデータを確認してみましょう。
sqlite> .table
todo
sqlite> SELECT * FROM todo;
1|test
2|test2
先ほどブラウザから登録したタスクが、データベースに保存されていますね。ばっちり!
sqliteから出るには.exitを入力します。
Dockerコンテナを立ててサーバーにする
これまではFlaskのdevelopmentサーバーを利用していましたが、自前のサーバーで動かしたいので、gunicornを使うことにしました。
ラズパイをサーバーとして使いたいですが、ひとまず今回は自分のmac PCをサーバーにして動かすことを目標にします。
poetryプロジェクトで動かしてもいいのですが、今後の拡張性のことを考えて、Dockerコンテナを立てることにしました。
Dockerについては、仕事で使っているので、基本的なことはわかっているという感じです。今後、超基本的なことをまとめた記事を書きたいと思います。
app.pyを修正する
app.runのところを’0.0.0.0’に変更します。こうすることで、外部からアクセスできるようになります。portはdocker run時に-pで指定するポート番号と同じにします。
(-- 同様 --)
if __name__ == "__main__":
app.run(host='0.0.0.0', port=8000)
Dockerをインストールする
今回はmacなのでDocker Desktopを使います。
1. Docker Desktopのインストール:
Docker Desktop for Mac からDocker Desktopをダウンロードします。
ダウンロードした .dmg
ファイルを開いてインストールプロセスを開始します。
ドラッグアンドドロップでDocker Desktopを「アプリケーション」フォルダに移動します。
2. Docker Desktopの起動:
「アプリケーション」フォルダからDocker Desktopを起動します。
初回起動時には、システムのプライバシーセッティングに関連するダイアログが表示されることがあります。必要に応じて許可を与えます。
3. Docker IDの作成とサインイン (オプション):
Docker IDをまだ作成していない場合は、Docker Hubでアカウントを作成します。
Docker DesktopでDocker IDにサインインすると、Docker Hubのプライベートリポジトリにアクセスできるようになります。
4. Dockerの動作確認:
ターミナルを開き、docker version
と docker run hello-world
コマンドを実行してDockerが正しくインストールされ動作していることを確認します。
Dockerfileを作成する
appディレクトリの下に「container_todo」ディレクトリを作成し、そこにDockerfileとイメージのビルドを行うbuild.sh、インストールしたいpyライブラリを記載したrequirements.txtを格納します。
以下にコードを示します。
build.shでdocker buildするまえにappディレクトリがある一つ上の階層にcdしています。DockerfileのCOPYやdocker buildでのイメージの指定のパスに注意してください。
Dockerfile
FROM python:3.9-slim
# ワーキングディレクトリを設定
WORKDIR /app
# todo_appユーザーとグループを作成
RUN groupadd -r todo_app && useradd --no-log-init -r -g todo_app todo_app
# vimのインストール
RUN apt-get update && apt-get install -y vim
# 依存関係のインストール
COPY container_todo/requirements.txt requirements.txt
RUN pip install -r requirements.txt
# アプリケーションのコードをコピー
COPY app/ .
# ユーザーをtodo_appに切り替え
USER todo_app
requirements.txt
gunicorn==20.1.0
Flask-SQLAlchemy==3.0.3
Werkzeug==2.2.2
gunicornとWerkzeug, Flask-SQLAlchemyとSQLAlchemyの間には依存関係があって、エラーが出て苦労しました…
次のサイトを参考にしてバージョンを決定しています。
Flask SQLAlchemyの attributeエラーについて
ImportError: cannot import name ‘url_quote’ from ‘werkzeug.urls’
イメージをビルドする
build.sh
#!/bin/bash
IMAGE_NAME="todo_app"
# ビルドコンテキストを指定してDockerイメージをビルド
# ビルドコンテキストは container_todo の親ディレクトリとする
# -f オプションで Dockerfile の位置を指定
cd ..
docker build -t $IMAGE_NAME -f container_todo/Dockerfile .
if [ $? -eq 0 ]; then
echo "Docker image $IMAGE_NAME built successfully."
else
echo "Failed to build Docker image $IMAGE_NAME."
exit 1
fi
container_todoの中でbash build.shを実行すると、Dockerイメージ「todo_app」がビルドされます。
イメージがビルドされていることを確認するには、
$ docker image ls
を実行すると、いまあるイメージの一覧が表示されます。
docker runしてapp.pyを実行
次に、このイメージを使ってdocker runしたときにgunicornを使ってサーバーからapp.pyを実行するようにしていきましょう。
docker runのコマンドでいろいろ指定したいので、run.shというシェルスクリプトにまとめます。
run.sh
#!/bin/bash
IMAGE_NAME="todo_app"
CONTAINER_NAME="todo_app_container"
# Docker コンテナを起動
docker run -d \
--rm \
--name $CONTAINER_NAME \
-p 8000:8000 \
-v $(pwd)/app:/app \
$IMAGE_NAME\
gunicorn -b 0.0.0.0:8000 app:app
bash run.shを実行すると、コンテナが起動し、サーバー上でapp.pyが起動します。
「SQLiteに保存」「自分のPC上でサーバーを立ててapp.pyを実行する」ことが実現できました!
-dをつけてデタッチモードで起動しているので、run.shを実行するとコマンドラインがかえってきます。コンテナを停止させてapp.pyの実行を終了するには、
docker stop todo_app
を実行します。—rmオプションをつけているので、コンテナの停止とともにコンテナが破棄されます。
-dのかわりに-itとすると、対話型シェルが起動してサーバーの実行状況を確認することができます。
プログラムの中で起きたエラーは表示されないのですが、
docker log todo_app
のように、コンテナ名を指定してdocker logコマンドを実行すると、コンテナ内のログを表示することができます。デバッグの時にご利用ください。
おわりに
まだまだ、求める機能は実装できていませんが、ひとまず動くようになってよかったです。今後、インターネットに公開することも考えると、セキュリティについても勉強しないといけないな…サーバー立てれる夫がいるので、聞きつつ進めていきたいと思います。
これから機能追加できたらブログにまとめようと思っていますので、ご期待ください!
コメント