pets
를 파이썬 클래스 Pet
으로 맵핑하여 아래와 같은 동작을 수행할 수 있음pet = Pet()
이라고 가정하고,pet.type
-> SQL pets 테이블의 type 컬럼에 접근 가능pet.owner
-> SQL pets 테이블의 owner 컬럼에 접근 가능Django-ORM
, SQLAlchemy
, TortoiseORM
, Peewee
가 있음Django-ORM
: 장고 프레임워크에 내장된 ORM으로 FastAPI 개발시 사용할 일이 없음SQLAlchemy
: 파이썬의 가장 표준적인 ORMPeewee
: 가볍고 간단한 기능을 제공하는 ORM으로 sqlite / mysql / postgresql / cockroachdb 만 지원함Tortoise ORM
: 등장한지 얼마 안된 ORM으로 비동기 처리에 적합SQLAlchemy
튜토리얼 한국어 번역 문서 (비공식) : 링크create_engine
from sqlalchemy import create_engine
SQLALCHEMY_DATABASE_URL = "mysql://user:password@postgresserver/db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, pool_size=15, max_overflow=0, encoding='utf8', convert_unicode=True
)
create_enigne
객체에 각종 설정 값을 인자로 넣어 초기화하여 이를 수행한다.connect_args={"check_same_thread": False}
옵션을 켜줘야함 sessionmaker
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
SQLALCHEMY_DATABASE_URL = "mysql://user:password@postgresserver/db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, pool_size=15, max_overflow=0, encoding='utf8', convert_unicode=True
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
create_eninge
을 초기화하여 DB 연결에 대한 설정을 마쳤으면, 실질적인 통신을 할 세션에 대한 정의를 해야한다. sessionmaker
라는 객체를 초기화하여 이를 수행한다.
SessionLocal
로 이름을 지정한 이유는 추후에 사용할 Session
과 분리하기 위함
여기서 선언한 SessionLocal
은 그 자체가 데이터베이스 세션이 아니다.
이는 설정값을 넘겨받아 선언된 새로운 클래스일 뿐이다.
이렇게 설정값을 넘겨받아 새로 선언된 SessionLocal
클래스의 인스턴스 객체가 생성되면 그 인스턴스 객체가 실질적인 데이터베이스 세션 역할을 수행한다.
(* sessionmaker의 docstring을 읽어보면 자세히 이해가능)
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True)
hashed_password = Column(String)
is_active = Column(Boolean, default=True)
items = relationship("Item", back_populates="owner")
class Item(Base):
__tablename__ = "items"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, index=True)
description = Column(String, index=True)
owner_id = Column(Integer, ForeignKey("users.id"))
owner = relationship("User", back_populates="items")
Base
를 상속받는다.Base Class
에 대해 설정을 해두면 여러가지 반복된 코드를 줄이거나, 모델 클래스 정의의 일부분을 자동화 할 수 있다.import re
from datetime import datetime
from sqlalchemy import Column, DateTime
from sqlalchemy.ext.declarative import as_declarative, declared_attr
from sqlalchemy.ext.declarative import declarative_base
@as_declarative()
class Base:
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
__name__: str
# CamelCase의 클래스 이름으로부터 snake_case의 테이블 네임 자동 생성
@declared_attr
def __tablename__(cls) -> str:
return re.sub(r'(?<!^)(?=[A-Z])', '_', cls.__name__).lower()
created_at
과 updated_at
이라는 컬럼을 자동으로 생성해주고,orm_mode
설정Config
서브 클래스를 선언하여 각종 설정값을 수정할 수 있다.ORM의 객체
이지, dict
와 같은 파이썬의 내장 자료형이 아니다.json
타입으로 API를 통해 반환해주기 위해서는 별도의 처리가 핋요하다.orm_mode = True
설정이다. .
을 찍어 객체의 속성에 접근할 수 있다.dict
자료형에서 id = data.get('id')
이렇게 접근해야 하는것을 단순히 data.id
이렇게 접근할 수 있는 것이다.lazy loading
을 기본값으로 한다. orm_mode = True
로 설정해둔 Pydantic 모델을 response_model
로 설정하지 않았을 경우, 관계 테이블의 데이터는 표시되지 않는다. (어찌보면 당연)def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
get_db
는 호출될 때마다 db라는 변수를 생성해내는데, 이 db는 SessionLocal클래스의 인스턴스인 것이다.close()
된다.from sqlalchemy.orm import Session
과 SessionLocal
은 어떻게 다른가?create_engine
)을 바인딩해 정의한 sessionmaker
로 SessionLocal
을 만들었다. \SessionLocal
을 호출해 db connection 객체를 매 커넥션마다 만들어내는 get_db
함수를 정의했다. \get_db
함수는 db 커넥션을 필요로 하는 엔드포인트 함수에 dependency로 주입되어 db connection에 대한 request가 있을 때 호출되어 request가 종료되면 close 된다.SessionLocal
이 아닌 Session
으로 하게 된다.\SessionLocal
로 지정해두어도 엔드포인트는 문제없이 작동한다.\Session
으로 지정해둠으로써 Session
에서 제공하는 메서드에 대해 자동완성 기능을 제공 받을 수 있다. \sessionmaker
로 초기화되는 SessionLocal
은 create_engine
을 바인딩해서 Session
을 랩핑한 클래스인데, \SessionLocal
이 생성하는 세션은 sqlalchemy의 Session
객체 이다. 다만 sessionmaker
함수로 한번 감싸져있기 때문에 IDE가 이것이 Session
임을 인식 못할 뿐이다.user = await db.query(User).first()
<- 이렇게 쓸 수 없다는 뜻from fastapi import Request, Response, FastAPI
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
SQLALCHEMY_DATABASE_URL = "mysql://user:password@postgresserver/db"
app = FastAPI()
engine = create_engine(
SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, pool_size=15, max_overflow=0, encoding='utf8', convert_unicode=True
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@app.middleware("http")
async def db_session_middleware(request: Request, call_next):
response = Response("Internal server error", status_code=500)
try:
request.state.db = SessionLocal()
response = await call_next(request)
finally:
request.state.db.close()
return response
# Dependency
def get_db(request: Request):
return request.state.db
하나의 DB Session으로 2개의 커넥션을 담당하는 엔드포인트 함수가 있었음
프론트 페이지 구현 때문에 Create 함수의 반환값으로 성공/실패 여부가 아닌, Create 작업이 반영된 DB를 Read 하는 것으로 구성함
Create와 Read 모두 하나의 Session으로 처리하는 꼴로 구성됨
개발 당시 원하던 형태는 Create의 결과가 Read에 반영되어 Read의 결과 값이 반환되도록 하는 거였지만, Create 하기 전의 결과만 계속 반환되는 문제 발생
create 함수가 commit 되기 전에 get 함수가 실행되는 것인지(너무 빨라서) 중간에 wait을 주거나 하지 않으면 계속 오류 발생
찾아보니 격리 수준에 따라 create 함수가 db connection을 어느 수준까지 다뤘느냐에 따라 get 함수의 결과가 달라질 수 있다는 내용 발견
create 함수가 커밋 되던 말던 수정사항이 생기면 무조건 읽어올 수 있도록 db의 격리수준을 READ UNCOMMITED
로 변경
문제 해결 되었지만 READ UNCOMMITED
격리수준은 매우 불안정한 격리 수준으로 가급적 사용해서는 안되는 것
create 엔드포인트 함수
는 create에 대해서만 책임지고, 반영된 수정사항이 필요했으면 프론트에서 리다이렉트 하는게 더 좋지 않았을까?!APIRouter
클래스를 통해 복잡한 API 엔드포인트를 정리할 수 있다. app = FastAPI()
이런식으로 정의하는데, router = APIRouter()
이런식으로 똑같이 쓸 수 있는 미니 FastAPI 객체 인 것이다.APIRouter()
클래스에 인자를 넣어 전역 dependency 를 주입할 수 있다.from fastapi import APIRouter, Depends, HTTPException, Header
async def get_token_header(x_token: str = Header(...)):
if x_token != "fake-super-secret-token":
raise HTTPException(status_code=400, detail="X-Token header invalid")
router = APIRouter(
prefix="/items",
tags=["items"],
dependencies=[Depends(get_token_header)],
responses={404: {"description": "Not found"}},
)
@router.get("")
async def create_item(item_id: str):
...
@router.get("")
async def read_items():
...
@router.get("/{item_id}")
async def read_item(item_id: str):
...
이렇게 하면 각 엔드포인트 함수에 get_token_header
라는 의존성을 주입하지 않더라도 전역적으로 의존성을 주입할 수 있다.
위의 예시의 경우 모든 엔드포인트에서 헤더의 토큰 여부를 체크하게 된다.
전역 Dependency 를 지정하였어도, 각 엔드포인트 함수별로의 Dependency도 추가로 지정할 수 있다.
! 개별 함수에 Dependency를 지정하는 것처럼 tags와 response를 개별로 한번 더 지정할 수 있다.
from fastapi import BackgroundTasks, FastAPI
app = FastAPI()
def write_notification(email: str, message=""):
with open("log.txt", mode="w") as email_file:
content = f"notification for {email}: {message}"
email_file.write(content)
@app.post("/send-notification/{email}")
async def send_notification(email: str, background_tasks: BackgroundTasks):
background_tasks.add_task(write_notification, email, message="some notification")
return {"message": "Notification sent in the background"}
from typing import Optional
from fastapi import BackgroundTasks, Depends, FastAPI
app = FastAPI()
def write_log(message: str):
with open("log.txt", mode="a") as log:
log.write(message)
def get_query(background_tasks: BackgroundTasks, q: Optional[str] = None):
if q:
message = f"found query: {q}\n"
background_tasks.add_task(write_log, message)
return q
@app.post("/send-notification/{email}")
async def send_notification(
email: str, background_tasks: BackgroundTasks, q: str = Depends(get_query)
):
message = f"message to {email}\n"
background_tasks.add_task(write_log, message)
return {"message": "Message sent"}
위의 예시나, 아래의 Background 클래스의 코드 예시에서 볼 수 있듯이 FastAPI 내장 백그라운드 작업 모듈은 굉장히 제한적인 기능만 제공한다.
대안으로 사용할 수 있는 2개 패키지가 있다.
항목 | FastAPI 내장 모듈 | APScheduler | Celery |
---|---|---|---|
워커 | 쓰레드 | 쓰레드 | 프로세스 |
스케쥴링 가능 | X | O | O |
사용 난이도 | 쉬움 | 덜쉬움 | 꽤나 복잡 |
대용량 처리 능력 | 낮음 | 덜낮음 | 꽤나 높음 |
GUI 관리 툴 | X | X | O |
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
/static
폴더에 404.html
405.html
파일들을 저장해두면 알아서 서빙한다.
from .main import app
client = TestClient(app)
def test_read_main():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"msg": "Hello World"}
conftest.py
라는 파일을 정의해둠으로써 테스트 함수에 여러 인자를 넘겨줄 수 있다. conftest.py
파일은 pytest
를 위한 비품(fixture) 집합소라고 생각 하면 된다.# confest.py
import random
import pytest
@pytest.fixture(scope="module")
def random_endpoint() -> int:
return random.randint(1,100)
confest.py
를 위와 같이 정의해두고# test.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
# Check if endpoint is incorrect
def test_get_series_contents_bad_request(random_endpoint: int):
response = client.get(f"/v1/series/{random_endpoint}")
assert response.status_code == 404
test.py
(테스트 파일)에서 테스트할 함수에 인자로 confest.py
의 인자로 정의해둔 함수를 넣어주면 된다.confest.py
의 함수를 test.py
로 import 하지 않아도 된다.로그인 토큰에 접목시킨다면 이렇게 할 수 있을 것이다. (FastAPI 공식 Docs의 템플릿 프로젝트 출처)
confest.py
test_items.py
confest.py
에서 정의한 superuser_token_header
함수가 test_items.py
에서 테스트 될때 작동하여 토큰을 발급받고 이를 헤더에 주입한다.