🌩️

FastAPIバックエンドをリファクタリングしてみた

2023/12/11に公開

FastAPIをリファクタリングしてみた

はじめに

この記事はPanda株式会社アドベントカレンダー2023、11日目の記事です。

Panda株式会社は東京大学松尾研究室・香川高専発のスタートアップで、現在はAR技術とAI技術を駆使しシステム開発に取り込んでいます。

筆者について

Panda株式会社でバックエンド担当している黄です。
ソフトウェア開発・サーバー構築・デプロイメントなどをメインになっています。
ミーティング時のニコニコさに定評があり。

この記事の想定読者

  • FastAPIを用いてバックエンドサーバーの開発をしようとしている人
  • コードの臭い(Code smell)に気にする人
  • 綺麗好きな人

コードの臭い(Code smell)について

コードの臭い(こーどのにおい、英: Code smell)とは、コンピュータプログラミングにおいてプログラムのソースコードに深刻な問題が存在することを示す何らかの兆候のことを言う。 (Wikipedia)

もうちょっと簡単に説明すると、ちゃんと動いているプログラムからの、メンテナンスや新しい機能の追加に害をもたらす不良コード。一見にして一帆風順[1]であっても、一旦大きな設計変更が必要とした場合、深刻な問題が起こられることが多いため、なるべくその症状を見つかる前からリファクタリングを行うのがベターです。

この記事では、現在社内開発しているプロジェクトから発生したコードスメルをFastAPI開発のリファクタリング事例として説明しています。

FastAPIについて

FastAPI は、Pythonの標準である型ヒントに基づいてPython 3.6 以降でAPI を構築するための、モダンで、高速(高パフォーマンス)な、Web フレームワークです。 (FastAPI)

最近人気のPython用Webフレームワーク、Golang並みの速さがでるらしい(すごい)。

コードスメルを探しましょう

恥ずかしながら、筆者の私はFastAPIについて全く知りませんでした。
ドキュメントを従って、需要(データベースのアクセスなど)から愚直に書いている間、このようなプログラムになりました。

main.py
from fastapi import Depends, FastAPI, HTTPException
from fastapi.response import Response
from sqlalchemy.orm import Session

from .connect import crud, models, schemas
from .connect.database import SessionLocal, engine

models.Base.metadata.create_all(bind=engine)
app = FastAPI()

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

@app.get("/get_data")
def get_data(id: str, db: Session=Depends(get_db)):
    db_data = crud.get_data(db, id)
    if db_data is None:
        raise HTTPException(
	    status_code=404
	    detail="Not Found"
	)
    return db_data

@app.post("/post_data")
def post_data(data: schemas.Data, db: Session=Depends(get_db)):
    db_data = crud.get_data(db, id)
    if db_data is None:
        raise HTTPException(
	    status_code=404
	    detail="Not Found"
	)
    return crud.create_data(db, data)

database.py
import configparser

from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

parser = configparser.ConfigParser()
parser.read('./config/database.ini')
config = dict(parser['DEFAULT'])

engine = create_engine(SQLALCHEMY_DATABASE_URL)
SessionLocal = sessionmaker(
    autocommit = False,
    autoflush = False,
    bind=engine
)

Base = declarative_base()

前述の仕様を簡単に説明すると、main.pyからFastAPIのフレームワークに必要の枠組を定義し、その都合でデータベースのセッションを作成してCRUDに利用させる感じなっています。

開発初期は何の問題もありませんでしたが……度重なる修正と新たに追加されたAPIにより、main.pyはどんどん複雑になっていき、可読性もぐっと下がっていきます。
詳しい内容は説明しませんが、数多く違うAPIが交わっていてから各十数行のコードが必要するため、ただ修正したいAPIを探し出すだけでも苦労しました。しかもデータベースのアクセスはSession=Depends(get_db)に依存していたため、問題を起こす仕様になっていました。

問題を探しましょう

前述したmainクラスは、大よそ二つの問題があります:

  1. データベースのアクセスはget_db()、またSession=Depends(get_db)を経由しないとできない
  2. そのためAPIのエントリポイントはmain.pyと一緒にしなければならないため、mainクラスが巨大化

この問題を解消するために、APIを複数のクラスに分けて、それぞれget_db()を継承、または定義する方法が一番簡単ですが、それでもデータベースをアクセスする上のフレキシブルが足りなく、将来他の問題をもたらすことが予想できます。

そのため、一番最初に解決すべきことはデータベースのアクセス方式だと、筆者は天啓[2]を授かりました。

リファクタリングをしましょう

問題がわかったら話しが早い。

真先にリファクタリングすべきのはデータベースアクセスの仕組みです。

database.py
import warnings
import configparser

from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

parser = configparser.ConfigParser()
parser.read('./config/database.ini')
config = dict(parser['DEFAULT'])

engine = create_engine(SQLALCHEMY_DATABASE_URL)
SessionLocal = sessionmaker(
    autocommit = False,
    autoflush = False,
    bind=engine
)

Base = declarative_base()

@contextmanager
def SessionManager():
    db = SessionLocal()
    try:
        yield db
    except:
        warnings.warn("DB operation failed")
        db.rollback()
        raise
    finally:
        db.close()

コンテキストマネージャーを用いて、with文の実行時にランタイムコンテキストを定義しました。その結果、一々Sessionを作成しなくてもdatabase.pyをインポートする限り、SessionManagerを介してデータベースにアクセスすることができました。

データベースのアクセスを改善したところ、いよいよmain.pyのリファクタリングに着手します。これは前のセクションと言った方法で元のAPIを複数のクラスに分けることにより、mainクラスのサイズを減らせます。

main.py
# (...前略...)
from api import Data

@app.get("/get_data")
def get_data(id: str):
    return Data.get(id)

@app.post("/post_data")
def post_data(data: schemas.Data):
    return Data.post(data)

api/Data.py
from fastapi import HTTPException
import data_crud, database, schemas

def get(id: str):
    with database.SessionManager() as db:
        db_data, error = data_crud.read(db, id)
	if error != None:
	    raise HTTPException(
	        status_code=422,
		detail=error
	    )
    return db_data

def post(data: schemas.Data):
    with database.SessionManager() as db:
        db_data, _ = data_crud.read(db, id)
        if db_data != None:
            raise HTTPException(
                status_code=422,
                detail="Record already exist"
            )
        error = data_crud.create(db, data)
        if error != None:
            raise HTTPException(
                status_code=422,
                detail=error
            )
    return {"message": "create success"}

おわりに

今回は「FastAPIのリファクタリング」というテーマでPanda株式会社 Advent Calendar 2023 11日目を執筆させていただきました。

この記事で紹介したリファクタリングの方法はあくまで一例であり、他にもたくさん修正できるポイントがあります。また、誰でもプロジェクトの最初から完璧なコードを書くことはできないので、コードスメルが少ないコードでもリファクタリングする必要があります。そのため、綺麗なコードを維持するために、小まめなリファクタリングが必要不可欠であると筆者は考えています。

リファクタリングは一見してかなり時間をかかるし手間も労力も重いと見られることが多いかもしれませんが、大体のプロジェクトは長期に渡ってたくさんの仕様変更が発生することが多く、もし最初から綺麗なコードを維持できると将来のためにも一つ悪いことでもないでしょう。

アドベントカレンダーも半分くらい経ちました、もしよろしければ、明日はリファクタリングの要になるユニットテストの記事を掲載するつもりなので、興味がある人はそちらも見てくれると嬉しいです!

脚注
  1. (いっぽうふんじゅん) 順風に帆をあげる。物事が何事もなく順調にいくこと。順風満帆。 ↩︎

  2. (てんけい) 天の神が真理を人間に示すこと。天の啓示。いつも勝手に思いのままに告げられる、アヒルのおもちゃ使うと頻度が上がることがある。 ↩︎

Panda株式会社

Discussion