🌟

PostgreSQLとFastAPIとStreamlitを使ってTodoリストを作ろう(前編)

2023/03/19に公開

本記事の目的

最近、Todoリストの作り方を学んだので、知識を深化させるために言語化する。プログラミング初心者の筆者でも作れたので、プログラミング初心者のモチベーションを上げるためにも、できるだけ分かりやすく書いていく。

PostgreSQLとは

リレーショナルデータベース管理システムの一つ。正直、MySQLやOracle Databaseでも良いが、初心者の自分にとっては、PostgreSQL用のGUI管理ツールのpgAdmin4が使いやすいので、こちらを採用。インストール方法や操作方法については下記の記事を参考にしてほしい。
https://postgresweb.com/introduction-to-postgresql

FastAPIとは

Web APIのフレームワークの一つ。軽量かつ高速が特徴。公式も必要な部分は日本語化されており、困ったら参考にしてもらいたい。
https://fastapi.tiangolo.com/ja/
また、こちらのFastAPI入門書も読みやすいのでオススメ。
https://zenn.dev/sh0nk/books/537bb028709ab9/viewer/f1b6fc

手順0:環境・ディレクトリ構成

初めに環境とディレクトリ構成だけ示しておく。今回はバックエンドだけの記事だが、後編ではフロントエンドについて書いていくので、フロントエンドの構成図も記入しておく。

環境構成
Python:3.9.13
pgAdmin4:3.6.19
VSCode:1.76.2
ディレクトリ構成図
TODOLIST
├ BACKEND/
│ ├ CRUD/
│ │ ├ delete_todo.py
│ │ ├ get_todo.py
│ │ ├ insert_todo.py
│ │ └ update_todo.py
│ ├ DB/
│ │ └ db.py
│ ├ ROUTER/
│ │ ├ delete_todo.py
│ │ ├ get_todo.py
│ │ ├ insert_todo.py
│ │ └ update_todo.py
│ ├ SCHEMA/
│ │ └ schema.py
│ ├ main.py
│ ├ .env
│ └ .gitignore
└ FRONTEND/
  └ front.py

手順1:データベースの作成

まずは、Todoリストを保管するためのデータベースを作る。PostgreSQLのインストール方法については上記の記事を参考にしていただきたい。まず、PostgreSQL内のデータベースを右クリックする。

次に作成→データベースをクリックし、データベース名を登録する。(今回はtestdb)

すると、データベース直下にtestdbというデータベースができるので、これを右クリック→クエリツールをクリック

ここでお気づきの方はいると思うが、今回はUIベースではなく、コードベースでテーブルを作成する。pgAdmin4はUIベースで直感的にテーブルを作成できることが良いところだが、SQLの知識を蓄えたいので、あえてコードベースで作成する。クエリ上には下記のコードを入力。

SQL
CREATE TABLE todolist
(
    deadline date,
    todo character varying,
    priority integer,
    genre character varying,
    completed_flg character varying,
    nowdate date
);

すると、テーブル直下にtestdbというテーブルが生成される。

実際に下記のコードを入力して、テーブルを確認してみよう。

SQL
select * from todolist

これで、Todoリストを保存するテーブルは準備できた。次に、このデータベースにアクセスするコードをVSCode上に書いていく。

手順2-1:事前準備

ここからは必要なライブラリがインストールしてある前提で話す。ライブラリについては「○○ インストール python」と検索すれば大体出てくるので、そちらを参考にしてほしい。

まずは、PostgreSQLのデータベースに接続する。接続するために必要な情報は、データベースがあるサーバー(ホスト)名、データベースにアクセスする際のユーザー名パスワード、そして使いたいデータが入っているデータベース名である。これらに対応する変数をそれぞれ、DB_HOSTDB_USERDB_PASSDB_NAMEとする。これらの情報がどこに記載されているかについて次に説明する。まずは、pgAdmin4を開く。ここでパスワードが求められると思う。このパスワードがDB_PASSである。次にServers直下のPostgreSQLを右クリックする。

そして、プロパティ→接続の順にクリックする。ここで表示されるホスト名/アドレスがDB_HOST、ユーザー名がDB_USERである。最後にデータベース名だが、こちらは手順1で設定したデータベース名(testdb)がDB_NAMEに該当する。
安全性を高めるため、外に漏れるとまずいデータは.envファイルに保管する。下記のコードは一部マスキングしてあるので、各自のデータを入力してほしい。

BACKEND/.env
DB_USER="****"
DB_PASS="****"
DB_HOST="****"
DB_NAME="testdb"

筆者はgitlab上で開発を行うので、.gitignoreファイルに.envを書き、gitlab上に表示されないようにする。

BACKEND/.gitignore
.env

.gitignoreに入れられたファイルはVSCode上のファイル名が薄くなっているので、これが確認出来たら隠しファイルになっている。これで事前準備が終わった。

手順2-2:データベースとのリンク

まずコードを記す。その後のコードの説明をする。

DB/db.py
import os
from posixpath import dirname
from dotenv import load_dotenv
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from os.path import join,dirname

dotenv_path=join(dirname(__file__),'../.env')
load_dotenv(dotenv_path)

DB_USER=os.environ.get("DB_USER")
DB_PASS=os.environ.get("DB_PASS")
DB_HOST=os.environ.get("DB_HOST")
DB_NAME=os.environ.get("DB_NAME")

SQLALCHEMY_DATABASE_URL='postgresql+psycopg2://{}:{}@{}/{}'.format(
    DB_USER,
    DB_PASS,
    DB_HOST,
    DB_NAME
)

engine=create_engine(SQLALCHEMY_DATABASE_URL)

Sessionlocal = sessionmaker(autocommit=False,autoflush=True,bind=engine)
Base=declarative_base()

def get_db():
    db=Sessionlocal()
    try:
        yield db
    finally:
        db.close()

  • join(dirname(file),'../.env')で何をやっているか
    dirname(__file__)は現在のディレクトリ名を返す。(今回だと~TODOLIST/BACKEND/DB)
    ということでjoin(dirname(__file__),'../.env')は~TODOLIST/BACKEND/DB/../.envを表す。..はひとつ前のフォルダに移動なので~TODOLIST/BACKEND/.envといったパスをdotenv_pathに代入している。


  • load_dotenv(dotenv_path)で何をやっているか
    load_dotenv()は.envファイルが見つかるまで、親ディレクトリを遡り、.envファイルを実行する。なので今回の様に.envファイルの正確なパスを指定しなくてもよい。正直、load_dotenv()でもルートディレクトリ(起点となるディレクトリ。今回の場合はTODOLISTファルダ。)内に.envファイルがあれば読み取ってくれるが、.envのパスを理解しておくため、ガチガチに記入した。また、個人的にはload_dotenv(dotenv_path)を記入する場所も大事に感じた。次に書くos.environ.get()で環境変数にアクセスするが、別ファイルの同じ変数名のものが存在するとき、最後に読み取られた変数のものを参照する。なので、os.environ.get()でアクセスする環境変数は、可能ならばos.environ.get()直前に定義しておくことで、間違いなくアクセスできる。


  • os.environ.get("DB_????")で何をやっているか
    os.environ.get()は()内のキー(環境変数)をルートディレクトリ上で調べ、値を文字列で返す。例えばos.environ.get("DB_NAME")は.env内のDB_NAMEを参照し、代入されている"testdb"を返す。


  • 'postgresql+psycopg2://{}:{}@{}/{}'で何をやっているか
    PostgreSQLに接続するためのおまじないだと思ってほしい。{}内には下記の順で情報を入れる。今回はformat関数を使って、後ろに書いている。
postgresql+psycopg2://{ユーザー名}:{パスワード}@{ホスト名}:{ポート番号}/{データベース名}


  • sessionmaker(autocommit=False,autoflush=TRUE,bind=engine)で何をやっているか
    autoflushをTRUEにすることで、データベースへの変更処理が次回データベースを呼んだタイミングで変更内容が反映される。変更処理後にdb.commit()などで呼び出さないと、Swagger UI上では変更が確認されるのに、大元のdb上では変わっていないということになってしまう。


  • def get_db()で何をやっているか
    Sessionlocal()で前の文で定義したデータべース情報をdbに代入。try+yield/finally文で書くことで、データベースが開く・開かない関係なく、最後はデータベースをクローズしてくれる。これは後々使うので関数化しておく。

手順3:データの呼び起こし

手順2でデータベースに接続することができたので、そこに保存されているテーブル(データ)を呼んでみましょう。まずは、コード。

CRUD/get_todo.py
from sqlalchemy.orm import Session
from sqlalchemy.sql import text

def get_todolist(db:Session):
    sql=text('select * from todolist order by "deadline" asc;')
    result=db.execute(sql).all()
    return result

  • db.execute(sql).all()で何をやっているか
    手順2で指定したデータベース(db)にSQL文を実行(execute(sql))し、全データを返す(all())ことを行う。SQL文はtext文の()内を指す。

手順4:出力データの型定義

Pythonの良いところでもあり、悪いところでもあるのが、「動的に変数の型を変える」ことです。VBAを使ったことがある方は経験していると思うが、通常、変数を用いる際は先にその変数の型を定義する必要がある。

VBA
Dim i as integer
i=2

Pythonだと型を定義せず使える。しかも、次に違う型のデータが入ったとしても、その型に動的に変わる。

Python
i=2
print(type(i))
#<class 'int'>
i="test"
print(type(i))
#<class 'str'>

しかし、これだと気づかないうちに変数の型が変わってしまい、後のコードで引っかかる+どこから間違えているか分からなくなる。なので、何のデータであれ、型定義は大事。ということで、定義していく。

SCHEMA/schema.py
from pydantic import BaseModel
import datetime

class class_schema(BaseModel):
    #期日
    deadline:datetime.date
    #やること
    todo:str
    #優先度
    priority:int
    #カテゴリ
    genre:str
    #完了フラグ
    completed_flag:bool
    #作成日
    nowdate:datetime.date
    class Config:
        orm_mode = True

  • class Config~で何をやっているか
    ここで10時間は取られた。別パソコンで実装しているときは、この文が無くても動いたので油断していた。FastAPIに渡すデータは本来、dict形式でないといけないが、SQLAlchemyで呼ぶデータはORM形式(データベースとオブジェクト指向プログラミング言語を変換する型)なので、orm_modeをTrueにしないと下記の様なエラーが出て、止まる。逆に、何故この文が無くても、別のパソコンでは動いたのか。ここはもう少し検証する。
value is not a valid dict (type=type_error.dict)

手順5:パスオペレーションの定義

FastAPIではREST API形式でデータのやり取りができる。REST API形式とは、ざっくり言えば、「パス(/以降の文)でデータをやり取りする方法」です。HTTP通信みたいにデータをやり取りしましょうねということ。手順3ではデータのやり取り部分を作成したので、これをパスでやり取りできるようにする。まずはコード。

ROUTER/get_todo.py
from fastapi import APIRouter,Depends
from sqlalchemy.orm import Session
from DB import db
from CRUD import get_todo
from SCHEMA.schema import class_schema

router=APIRouter()

@router.get("/router",response_model=list[class_schema],tags=['todo'],description='get_todo')
def get(db:Session=Depends(db.get_db)):
    result=get_todo.get_todolist(db=db)
    return result

  • @router.get~で何をやっているか
    @~はデコレータ文といい、第一引数にパスを指定することで、そのパスが呼ばれたら、def以降の関数を行うようになる。以上が、パスパラメータ関数のプログラミングの一通りの流れである。ほとんどコピペで済むので説明は省くが、残りのCRUD処理のコードだけ記す。

CRUD/delete_todo.py
from sqlalchemy.orm import Session
from sqlalchemy.sql import text
import datetime

def delete_todolist(deadline:datetime.date,todo:str,priority:int,genre:str,completed_flag:bool,nowdate:datetime.date,db:Session):
    sql=text('delete from todolist where "deadline"=%s and "todo"=%s and \n\
             "priority"=%s and "genre"=%s and "completed_flag"=%s and "nowdate"=%s;'\
             %(repr(deadline),repr(todo),repr(priority),repr(genre),repr(completed_flag),repr(nowdate)))
    db.execute(sql)
    db.commit()

    sql2=text('select * from todolist order by "deadline" asc')
    result=db.execute(sql2).all()

    return result
CRUD/insert_todo.py
from sqlalchemy.orm import Session
from sqlalchemy.sql import text
import datetime

def insert_todolist(deadline:datetime.date,todo:str,priority:str,genre:str,db:Session):
    completed_flag=False
    nowdate=datetime.datetime.today().strftime("%Y-%m-%d")
    sql=text('insert into todolist values (%s,%s,%s,%s,%s,%s);'\
             %(repr(deadline),repr(todo),repr(priority),repr(genre),repr(completed_flag),repr(nowdate)))
    db.execute(sql)
    db.commit()

    sql2=text('select * from todolist order by "deadline" asc;')
    result=db.execute(sql2).all()

    return result
CRUD/update_todo.py
from sqlalchemy.orm import Session
from sqlalchemy.sql import text
import datetime

def update_todolist(deadline:datetime.date,todo:str,priority:int,genre:str,nowdate:datetime.date,db:Session):
    sql=text('update todolist set "completed_flag"=True where "deadline"=%s and "todo"=%s and \n\
             "priority"=%s and "genre"=%s and "nowdate"=%s;'\
             %(repr(deadline),repr(todo),repr(priority),repr(genre),repr(nowdate)))
    db.execute(sql)
    db.commit()

    sql2=text('select * from todolist order by "deadline" asc')
    result=db.execute(sql2).all()

    return result
ROUTER/delete_todo.py
from fastapi import APIRouter,Depends
from sqlalchemy.orm import Session
from DB import db
from CRUD import delete_todo
from SCHEMA.schema import class_schema

router=APIRouter()

@router.delete("/router",response_model=list[class_schema],tags=['todo'],description='delete_todo')
def delete(deadline:str,todo:str,priority:int,genre:str,completed_flag:bool,nowdate:str,db:Session=Depends(db.get_db)):
    result=delete_todo.delete_todolist(deadline=deadline,todo=todo,priority=priority,genre=genre,completed_flag=completed_flag,nowdate=nowdate,db=db)
    return result
ROUTER/insert_todo.py
from fastapi import APIRouter,Depends
from sqlalchemy.orm import Session
from DB import db
from CRUD import insert_todo
from SCHEMA.schema import class_schema

router=APIRouter()

@router.post("/router",response_model=list[class_schema],tags=['todo'],description='insert_todo')
def insert(deadline:str,todo:str,priority:str,genre:str,db:Session=Depends(db.get_db)):
    result=insert_todo.insert_todolist(deadline=deadline,todo=todo,priority=priority,genre=genre,db=db)
    return result
ROUTER/update_todo.py
from fastapi import APIRouter,Depends
from sqlalchemy.orm import Session
from DB import db
from CRUD import update_todo
from SCHEMA.schema import class_schema

router=APIRouter()

@router.patch("/router",response_model=list[class_schema],tags=['todo'],description='update_todo')
def update(deadline:str,todo:str,priority:int,genre:str,nowdate:str,db:Session=Depends(db.get_db)):
    result=update_todo.update_todolist(deadline=deadline,todo=todo,priority=priority,genre=genre,nowdate=nowdate,db=db)
    return result

最後にFastAPIのUIであるSwagger UIにこれらの機能を実装する。

手順6:Swagger UIへの実装

DB→CRUD→SCHEMA→ROUTERと9割は完了しており、mainには実装コードだけを入力し、最後にコマンドでFastAPIを起動する。

BACKEND/main.py
from fastapi import FastAPI
from ROUTER import get_todo,insert_todo,delete_todo,update_todo

app=FastAPI()

app.include_router(router=get_todo.router)
app.include_router(router=insert_todo.router)
app.include_router(router=delete_todo.router)
app.include_router(router=update_todo.router)
cmd
uvicorn main:app --reload

  • app.include_router~で何をやっているか
    手順5で作成したrouterをSwagger UI上に表示させるために、FastAPIオブジェクトに追記している。


  • uvicorn main:app --reloadで何をやっているか
    uvicorn(ユーブイアイコーン)はWebサーバとWebアプリケーションが通信するためのインターフェースである。このコードでは、main.py内のappを呼んでいることを示す。--reloadを最後に付けることで、main.pyがあるディレクトリで変更が生じた際に、再度appを呼んでくれる。このコードを実行することで、http://127.0.0.1:8000 が生成されると思う。これに/docsを付けて(http://127.0.0.1:8000/docs) にアクセスすると、Swagger UIにアクセスできる。

これでバックエンドの作成は終了。Swagger UIを用いれば、フロントエンドを作らなくても、最低限のTodoリスト操作は可能だと思う。余力がある方は、後編のstreamlitを用いたフロントエンド編もお付き合いください。

Discussion