【初学者向け】FastAPIにNextjsを乗せるには
1. はじめに
個人開発で、GoogleCloudFunctions を使って機械学習系の機能を盛り込みたいというニーズがあり、その分野では比較的日本語のドキュメントが豊富そうな python のフレームワークを使いたいと思い目についたのが FastAPI でした。
よく比較される Django や Flask といったフレームワークもありますが、私個人としては Django より Flask のほうが使いやすく(Django 独自の仕様を学ぶ必要があるため)、FastAPI も Flask に似た性能かつネット上での評判も良いため、今回は FastAPI を選びました。
2. 開発環境のセットアップ
環境構築
fast api
pip install fastapi
uvicorn(ASGI サーバー)
pip install "uvicorn[standard]"
3. バックエンド開発(FastAPI)
環境構築
main.py
from fastapi import FastAPI
import uvicorn
app = FastAPI()
@app.get("/")
async def root():
return {"message": "Hello World"}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="debug")
バックエンドサーバー起動
uvicorn main:app --reload
port8000 に繋いであるので、ブラウザ上で
localhost:8000 に繋げば"message": "Hello World"
が出力されているはずです。
これでセットアップ完了!
DB 構築
基本はこちら[https://qiita.com/abek21/items/7739163085899b257cb8]のデータベースの利用から続きを実装していきます。
必要パッケージ
pip install alembic SQLAlchemy
SQLAlchemy は ORM という SQL をプログラム言語の仕様で扱えるものの一種です。コードの仕様を理解していれば生の SQL を操作するより簡単かもしれません。
マイグレーションを初期状態に
alembic init migrations
DB は Sqlite を使用します sqlite ファイルで DB を扱うことができます
SQLAlchemy を install した際に作成されたalembic.ini
のsqlalchemy.url
を以下のように変更します
sqlalchemy.url = sqlite:///sample.sqlite
main.py と同じ階層にsettings.py
を作成します。DB の接続情報を管理します。
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base #ORMの基底クラス
from sqlalchemy.orm import sessionmaker
SQLALCHEMY_DATABASE_URL = 'sqlite:///sample.sqlite' #sqliteファイルにデータ保存
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} #複数スレッドからのアクセスを許可するか False=許可
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) #自動コミット フラッシュ セッションの紐づけ
Base = declarative_base()
モデル作成
models.py
from sqlalchemy import Column, Integer, String, DateTime
from datetime import datetime
from settings import Base
class TodoModel(Base):
__tablename__ = 'todo'
id = Column(Integer, primary_key=True)
title = Column(String)
created_date = Column(DateTime, default=datetime.utcnow)
先ほど install した ORM の書き方をここで使います
マイグレーションを編集
migrations/env.py
import 文を追加
from settings import Base
from models import *
target_metadata
を編集
target_metadata = Base.metadata
定義したモデルをもとに実際に DB にテーブルを作成するコードを作る
alembic revision --autogenerate -m "create todo table"
migrations/versions/XXXXXXXXXXXX_create_todo_table.py
というマイグレーションが作成される
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('todo',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(), nullable=True),
sa.Column('created_date', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
DB に上記コードを適用
alembic upgrade head
エンドポイント作成(main.py)
from fastapi import FastAPI, Depends
import uvicorn
from schemas import PostTodo
from models import TodoModel
from settings import SessionLocal
from sqlalchemy.orm import Session
app = FastAPI()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@app.get("/")
async def root():
return {"message": "Hello World"}
# データベースからToDo一覧を取得するAPI
@app.get("/todo")
def get_todo(
db: Session = Depends(get_db)
):
# query関数でmodels.pyで定義したモデルを指定し、.all()関数ですべてのレコードを取得
return db.query(TodoModel).all()
# ToDoを作成するAPI
@app.post("/todo")
def post_todo(
todo: PostTodo,
db: Session = Depends(get_db)
):
# 受け取ったtitleからモデルを作成
db_model = TodoModel(title = todo.title)
# データベースに登録(インサート)
db.add(db_model)
# 変更内容を確定
db.commit()
return {"message": "success"}
# ToDoを削除するAPI
@app.delete("/todo/{id}")
def delete_todo(
id: int,
db: Session = Depends(get_db)
):
delete_todo = db.query(TodoModel).filter(TodoModel.id==id).one()
db.delete(delete_todo)
db.commit()
return {"message": "success"}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="debug")
rails で言うところの MVC に近い雰囲気
完了したら
バックエンドサーバーを起動させておいてください
uvicorn main:app --reload
4. フロントエンド開発(Nextjs)
npx next-create-app@latest
->その後は基本全て Enter で OK
*tailwind css が ver4 になったため環境構築でエラーが発生するようになりました
その際は package.json からバージョンをダウングレードして npm install を試みてください
最初に生成された page.tsx を編集する
'use client';
import { useState, useEffect } from 'react';
type Todo = {
id: number;
title: string;
};
export default function Home() {
const [title, setTitle] = useState('');
const [todos, setTodos] = useState<Todo[]>([]);
useEffect(() => {
// ページが読み込まれた時にToDoを取得
handleGetTodos();
}, []);
const handleGetTodos = async () => {
// ToDoを取得する関数
const res = await fetch('http://localhost:8000/todo');
if (!res.ok) {
throw new Error('Network response was not ok');
}
const data = await res.json();
setTodos(data);
};
const handlePostTodo = async () => {
try {
const res = await fetch('http://localhost:8000/todo', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title }),
});
if (!res.ok) {
throw new Error('Network response was not ok');
}
const data = await res.json();
console.log(data);
setTitle('');
handleGetTodos();
return data;
} catch (error) {
console.error('Error:', error);
}
};
const handleDeleteTodo = async (id: number) => {
try {
const res = await fetch(`http://localhost:8000/todo/${id}`, {
method: 'DELETE',
});
if (!res.ok) {
throw new Error('Network response was not ok');
}
// 削除成功後にTodoリストを再取得
handleGetTodos();
} catch (error) {
console.error('Error:', error);
}
};
return (
<main className="flex min-h-screen flex-col items-center p-24">
<h1>ToDoアプリ</h1>
<input
className="text-black"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<button onClick={handlePostTodo}>ToDoを作成</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.title}
<button
className="ml-4 text-red-500"
onClick={() => handleDeleteTodo(todo.id)}
>
削除
</button>
</li>
))}
</ul>
</main>
);
}
これを生成された page.tsx に貼り付けたら
npm run dev
してサーバーを起動してください
以下解説
バックエンドから todo 一覧を取得する(GET)
ページが読み込まれた際に、下記の関数を発動させます。
useEffect(() => {
// ページが読み込まれた時にToDoを取得
handleGetTodos();
}, []); //[]に値をいれるとそれが変更されたタイミングでレンダリングが行われる
const handleGetTodos = async () => {
// ToDoを取得する関数
const res = await fetch('http://localhost:8000/todo');
if (!res.ok) {
throw new Error('Network response was not ok');
}
const data = await res.json();
setTodos(data);
};
const res = await fetch('http://localhost:8000/todo');
main.py
@app.get("/todo")
def get_todo(
db: Session = Depends(get_db)
):
return db.query(TodoModel).all()
先ほど作成したエンドポイント(main.py)において、"/todo"の GET メソッドにアクセスします。
const data = await res.json();
エラーが起きなかった場合 json 形式でデータが返ってきます
setTodos(data);
setTodos は useStatus という React で使われている hooks です。画面の一部を非同期で更新するために使われます。このデータが画面に表示されます。
todo の追加処理(POST)
const handlePostTodo = async () => {
try {
const res = await fetch('http://localhost:8000/todo', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title }),
});
if (!res.ok) {
throw new Error('Network response was not ok');
}
const data = await res.json();
console.log(data);
setTitle('');
handleGetTodos();
return data;
} catch (error) {
console.error('Error:', error);
}
};
上記のコードは先程と違い POSTMethod なので、明示的に POSTMethod を送っているという記述をしなければなりません。詳しくは HTTP リクエストについて調べると良いと思います。ついでに headers にも json 形式のコンテンツをであると記載しておきます。
エラーがなければ json 形式のデータを POST できるというのは先程の一連の流れとほとんど同じです。
setTitle('');
handleGetTodos();
このコードは、送信後の挙動に関係してきます。これがないと input フォームの値が空にならず、todo の値も更新されません。画面上で自分で更新をかければ値も更新されますが、todo を追加する度にいちいちそれをするのは面倒だし、利便性も下がります。
なので、処理が完了した場合、input の値や todo の値を自動的に更新する関数を handlePostTodo の中に持ってきています。
エンドポイント(main.py)
@app.post("/todo")
def post_todo(
todo: PostTodo,
db: Session = Depends(get_db)
):
# 受け取ったtitleからモデルを作成
db_model = TodoModel(title = todo.title)
# データベースに登録(インサート)
db.add(db_model)
# 変更内容を確定
db.commit()
return {"message": "success"}
todo の削除処理(DELETE)
const handleDeleteTodo = async (id: number) => {
try {
const res = await fetch(`http://localhost:8000/todo/${id}`, {
method: 'DELETE',
});
if (!res.ok) {
throw new Error('Network response was not ok');
}
// 削除成功後にTodoリストを再取得
handleGetTodos();
} catch (error) {
console.error('Error:', error);
}
};
フロントエンド側では以下のように li 要素に todo.id という key が配置されています。
{
todos.map((todo) => (
<li key={todo.id}>
{todo.title}
<button
className="ml-4 text-red-500"
onClick={() => handleDeleteTodo(todo.id)}
>
削除
</button>
</li>
));
}
削除ボタンを押す際に関数に todo.id を渡すことで id と同じ todo をバックエンドで削除します。
@app.delete("/todo/{id}")
def delete_todo(
id: int,
db: Session = Depends(get_db)
):
delete_todo = db.query(TodoModel).filter(TodoModel.id==id).one()
db.delete(delete_todo)
db.commit()
return {"message": "success"}
todo/${id}
がバックエンドに渡ってきたらdelete_todo
が実行されます。
delete_todo = db.query(TodoModel).filter(TodoModel.id==id).one()
DB のクエリから TodoModel を選択し、filter で渡ってきた id と同じレコードを 1 件のみ取得します。
そして、delete_todo に代入したのち、db.delete
5. 細かな注意点
フロントエンドとバックエンドで使っているサーバーが異なるため、検証する際はそれぞれのサーバーを起動する必要があります。
CROS(Cross-Origin Resource Sharing)
これはセキュリティに関する設定です。クロスサイトリクエストフォージェリ(CSRF/XSRF)といったサイバー攻撃から Web サイトを守ります。
具体的には異なる URL からのアクセスを制限するものです。オリジンと呼ばれます。
フロントエンドとバックエンドでは別々のサーバーを起動(localhost:8000 と 3000)を起動していますが、バックエンド側でフロントエンドの URL からのアクセスを許可をしなくてはアクセスできないということです。
main.py
# CORSミドルウェアを追加
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"], # フロントエンドのオリジン
allow_credentials=True,
allow_methods=["*"], # すべてのHTTPメソッドを許可
allow_headers=["*"], # すべてのヘッダーを許可
)
6. まとめと感想
Flask と設定次第では Rails のように簡単にマイグレーションができて非常に使いやすかったです。Django では元々ある設定のために新たに学習が必要なのと、拡張がしづらいのが自分にとってネックだったので、覚えることが少なく初学者向けなのではないかと思います。
利用経験があり、初学者向けとされる Rails では API モードにするとエンドポイントがわかりにくい印象があり、個人的には難しかったです。
その点、FastAPI はエンドポイントをわかりやすい形で掲示してくれるのでフロントエンドとバックエンドの連携がしやすいと感じました。
Rails todo アプリのコントローラー
module Api
module V1
class TodosController < ApplicationController
before_action :set_todo, only: [:show, :update, :destroy]
# GET /api/v1/todos
def index
@todos = Todo.all.order(created_at: :desc)
render json: @todos
end
# GET /api/v1/todos/:id
def show
render json: @todo
end
# POST /api/v1/todos
def create
@todo = Todo.new(todo_params)
if @todo.save
render json: @todo, status: :created
else
render json: { errors: @todo.errors.full_messages }, status: :unprocessable_entity
end
end
# PATCH/PUT /api/v1/todos/:id
def update
if @todo.update(todo_params)
render json: @todo
else
render json: { errors: @todo.errors.full_messages }, status: :unprocessable_entity
end
end
# DELETE /api/v1/todos/:id
def destroy
@todo.destroy
head :no_content
end
private
def set_todo
@todo = Todo.find(params[:id])
end
def todo_params
params.require(:todo).permit(:title, :description, :completed)
end
end
end
end
7. 参考リソースとソースコード
公式ドキュメント
FastAPI を用いた API 開発テンプレート
Discussion