Skip to content

Commit

Permalink
feat: FastAPI Code with Comment
Browse files Browse the repository at this point in the history
  • Loading branch information
zzsza committed Feb 9, 2024
1 parent e0bcf25 commit 247db83
Show file tree
Hide file tree
Showing 8 changed files with 169 additions and 120 deletions.
143 changes: 78 additions & 65 deletions 02-online-serving(fastapi)/projects/web_single/README.md
Original file line number Diff line number Diff line change
@@ -1,73 +1,86 @@
# Web Single Pattern ML API by FastAPI

Web Single Pattern ML API by FastAPI는 FastAPI를 이용해 만든 웹 서비스로, 단일 모델을 이용해 예측을 수행하는 API를 제공합니다.

## Pre-requisites

- Python >= 3.9
- Poetry >= 1.1.4

## Installation

```bash
poetry install
# FastAPI Web Single Pattern
- 목적 : FastAPI를 사용해 Web Single 패턴을 구현합니다
- 상황 : 데이터 과학자가 model.py을 만들었고(model.joblib이 학습 결과), 그 model을 FastAPI을 사용해 Online Serving을 구현해야 함
- model.py는 추후에 수정될 수 있으므로, model.py를 수정하지 않음(데이터 과학자쪽에서 수정)

# 시작하기 전에
- 여러분들이라면 어떻게 시작할까? 어떻게 설계할까? => 잠깐이라도 생각해보기
- 여러분들의 생각과 제가 말하는 것을 비교 => Diff => 이 Diff가 왜 생겼는가?

# FastAPI 개발
- FastAPI를 개발할 때의 흐름
- 전체 구조를 생각 => 파일, 폴더 구조를 어떻게 할까?
- predict.py, api.py, config.py
- 계층화 아키텍처 : 3 tier, 4 tier layer
- Presentation(API) <-> Application(Service) <-> Database
- API : 외부와 통신. 건물의 문처럼 외부 경로. 클라이언트에서 API 호출. 학습 결과 Return
- schema : FastAPI에서 사용되는 개념
- 자바의 DTO(Data Transfer Object)와 비슷한 개념. 네트워크를 통해 데이터를 주고 받을 때, 어떤 형태로 주고 받을지 정의
- 예측(Request, Response)
- Pydantic의 Basemodel을 사용해서 정의. Request, Response에 맞게 정의. Payload
- Application : 실제 로직. 머신러닝 모델이(딥러닝 모델이) 예측/추론.
- Database : 데이터를 어딘가 저장하고, 데이터를 가지고 오면서 활용
- Config : 프로젝트의 설정 파일(Config)을 저장
- 역순으로 개발

# 구현해야 하는 기능
## TODO Tree 소개
- TODO Tree 확장 프로그램 설치
- [ ] : 해야할 것
- [x] : 완료
- FIXME : FIXME

# 기능 구현
- [x] : FastAPI 서버 만들기
- [x] : POST /predict : 예측을 진행한 후(PredictionRequest), PredictionResponse 반환
- [x] : Response를 저장. CSV, JSON. 데이터베이스에 저장(SQLModel)
- [x] : GET /predict : 데이터베이스에 저장된 모든 PredictionResponse를 반환
- [x] : GET /predict/{id} : id로 필터링해서, 해당 id에 맞는 PredictionResponse를 반환
- [x] : FastAPI가 띄워질 때, Model Load => lifespan
- [x] : DB 객체 만들기
- [x] : Config 설정

# 참고
- 데이터베이스는 SQLite3, 라이브러리는 SQLModel을 사용

# SQLModel
- FastAPI를 만든 사람이 만든 Python ORM(Object Relational Mapping) : 객체 => Database
- 데이터베이스 = 테이블에 데이터를 저장하고 불러올 수 있음
- Session : 데이터베이스의 연결을 관리하는 방식
- 외식. 음식점에 가서 나올 때까지를 하나의 Session으로 표현. Session 안에서 가게 입장, 주문, 식사
- Session 내에서 데이터를 추가, 조회, 수정할 수 있다! => POST / GET / PATCH
- Transaction : 세션 내에 일어나는 모든 활동. 트랜잭션이 완료되면 결과가 데이터베이스에 저장됨

## 코드 예시
```

## Run

```bash
PYTHONPATH=.
poetry run python main.py
```

## Usage

### Predict

```bash
curl -X POST "http://0.0.0.0:8000/predict" -H "Content-Type: application/json" -d '{"features": [5.1, 3.5, 1.4, 0.2]}'

{"id":3,"result":0}
SQLModel.metadata.create_all(engine) : SQLModel로 정의된 모델(테이블)을 데이터베이스에 생성
- 처음에 init할 때 테이블을 생성!
```

### Get all predictions

```bash
curl "http://0.0.0.0:8000/predict"

[{"id":1,"result":0},{"id":2,"result":0},{"id":3,"result":0}]
```

### Get a prediction

```bash
curl "http://0.0.0.0:8000/predict/1"
{"id":1,"result":0}
with Session(engine) as session:
...
# 테이블에 어떤 데이터를 추가하고 싶은 경우
result = ''
session.add(result) # 새로운 객체를 세션에 추가. 아직 DB엔 저장되지 않았음
session.commit() # 세션의 변경 사항을 DB에 저장
session.refresh(result) # 세션에 있는 객체를 업데이트
# 테이블에서 id를 기준으로 가져오고 싶다
session.get(DB Model, id)
# 테이블에서 쿼리를 하고 싶다면
session.query(DB Model).all() # 모든 값을 가져오겠다
```

## Build

```bash
docker build -t web_single_example .
```
# SQLite3
- 가볍게 사용할 수 있는 데이터베이스. 프러덕션 용도가 아닌 학습용

## Project Structure

```bash
.
├── .dockerignore # 도커 이미지 빌드 시 제외할 파일 목록
├── .gitignore # git에서 제외할 파일 목록
├── Dockerfile # 도커 이미지 빌드 설정 파일
├── README.md # 프로젝트 설명 파일
├── __init__.py
├── api.py # API 엔드포인트 정의 파일
├── config.py # Config 정의 파일
├── database.py # 데이터베이스 연결 파일
├── db.sqlite3 # SQLite3 데이터베이스 파일
├── dependencies.py # 앱 의존성 관련 로직 파일
├── main.py # 앱 실행 파일
├── model.joblib # 학습된 모델 파일
├── model.py # 모델 관련 로직 파일
├── poetry.lock # Poetry 라이브러리 버전 관리 파일
└── pyproject.toml # Poetry 프로젝트 설정 파일
```
# 현업에서 더 고려해야 하는 부분
- Dev, Prod 구분에 따라 어떻게 구현할 것인가?
- Data Input / Output 고려
- Database => Cloud Database(AWS Aurora, GCP Cloud SQL)
- API 서버 모니터링
- API 부하 테스트
- Test Code
55 changes: 23 additions & 32 deletions 02-online-serving(fastapi)/projects/web_single/api.py
Original file line number Diff line number Diff line change
@@ -1,43 +1,44 @@
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from sqlmodel import Session

from database import PredictionResult, engine
from schemas import PredictionRequest, PredictionResponse
from dependencies import get_model
from database import PredictionResult, engine
from sqlmodel import Session, select

router = APIRouter()


class PredictionRequest(BaseModel):
features: list


class PredictionResponse(BaseModel):
id: int
result: int


# FastAPI 경로
@router.post("/predict")
def predict(request: PredictionRequest) -> PredictionResponse:
# 모델 추론
model = get_model()
# 모델 load
model = get_model()

# 예측 : 여러분들의 코드 상황에 따라 구현
prediction = int(model.predict([request.features])[0])

# 결과를 데이터베이스에 저장

# 예측한 결과를 DB에 저장
# 데이터베이스 객체를 생성. 그 때 prediction을 사용
prediction_result = PredictionResult(result=prediction)
with Session(engine) as session:
session.add(prediction_result)
session.commit()
session.refresh(prediction_result)

# 응답
return PredictionResponse(id=prediction_result.id, result=prediction)

# return PredictionResponse

@router.get("/predict")
def get_predictions() -> list[PredictionResponse]:
with Session(engine) as session:
statement = select(PredictionResult)
prediction_results = session.exec(statement).all()
# prediction_results = session.query(PredictionResult).all()
return [
PredictionResponse(id=prediction_result.id, result=prediction_result.result)
for prediction_result in prediction_results
]

@router.get("/predict/{id}")
def get_prediction(id: int) -> PredictionResponse:
# 데이터베이스에서 결과를 가져옴
def get_preidction(id: int) -> PredictionResponse:
with Session(engine) as session:
prediction_result = session.get(PredictionResult, id)
if not prediction_result:
Expand All @@ -48,13 +49,3 @@ def get_prediction(id: int) -> PredictionResponse:
id=prediction_result.id, result=prediction_result.result
)


@router.get("/predict")
def get_predictions() -> list[PredictionResponse]:
# 데이터베이스에서 결과를 가져옴
with Session(engine) as session:
prediction_results = session.query(PredictionResult).all()
return [
PredictionResponse(id=prediction_result.id, result=prediction_result.result)
for prediction_result in prediction_results
]
4 changes: 2 additions & 2 deletions 02-online-serving(fastapi)/projects/web_single/config.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from pydantic import Field
from pydantic_settings import BaseSettings


class Config(BaseSettings):
db_url: str = Field(default="sqlite:///./db.sqlite3", env="DB_URL")
model_path: str = Field(default="model.joblib", env="MODEL_PATH")
app_env: str = Field(default="local", env="APP_ENV")


config = Config()
config = Config()
15 changes: 6 additions & 9 deletions 02-online-serving(fastapi)/projects/web_single/database.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import datetime
from typing import Optional

from sqlmodel import Field, SQLModel, create_engine

from sqlmodel import SQLModel, Field, create_engine
from config import config


class PredictionResult(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
class PredictionResult(SQLModel,table=True):
id: int = Field(default=None, primary_key=True)
result: int
created_at: Optional[str] = Field(default_factory=datetime.datetime.now)

created_at: str = Field(default_factory=datetime.datetime.now)
# default_factory : default를 설정. 동적으로 값을 지정.

engine = create_engine(config.db_url)
engine = create_engine(config.db_url)
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
model = None


def load_model(model_path: str):
import joblib

global model
model = joblib.load(model_path)


def get_model():
global model
return model
return model
17 changes: 9 additions & 8 deletions 02-online-serving(fastapi)/projects/web_single/main.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,34 @@
from contextlib import asynccontextmanager

from fastapi import FastAPI
from contextlib import asynccontextmanager
from loguru import logger
from sqlmodel import SQLModel

from api import router
from config import config
from database import engine
from dependencies import load_model

from api import router

@asynccontextmanager
async def lifespan(app: FastAPI):
# 데이터베이스 테이블 생성
logger.info("Creating database tables")
logger.info("Createing database table")
SQLModel.metadata.create_all(engine)

# 모델 로드
logger.info("Loading model")
load_model(config.model_path)

# model.py에 존재. 역할을 분리해야 할 수도 있음 => 새로운 파일을 만들고, 거기서 load_model 구현
yield


app = FastAPI(lifespan=lifespan)
app.include_router(router)

@app.get("/")
def root():
return "Hello World!"


if __name__ == "__main__":
import uvicorn

uvicorn.run(app, host="0.0.0.0", port=8000)
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
40 changes: 40 additions & 0 deletions 02-online-serving(fastapi)/projects/web_single/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
annotated-types==0.6.0
anyio==3.7.1
certifi==2023.11.17
click==8.1.7
dnspython==2.4.2
email-validator==2.1.0.post1
exceptiongroup==1.2.0
fastapi==0.105.0
h11==0.14.0
httpcore==1.0.2
httptools==0.6.1
httpx==0.25.2
idna==3.6
itsdangerous==2.1.2
Jinja2==3.1.2
joblib==1.3.2
loguru==0.7.2
MarkupSafe==2.1.3
numpy==1.26.2
orjson==3.9.10
pydantic==2.5.2
pydantic-extra-types==2.2.0
pydantic-settings==2.1.0
pydantic_core==2.14.5
python-dotenv==1.0.0
python-multipart==0.0.6
PyYAML==6.0.1
scikit-learn==1.3.2
scipy==1.11.4
sniffio==1.3.0
SQLAlchemy==2.0.23
sqlmodel==0.0.14
starlette==0.27.0
threadpoolctl==3.2.0
typing_extensions==4.9.0
ujson==5.9.0
uvicorn==0.24.0.post1
uvloop==0.19.0
watchfiles==0.21.0
websockets==12.0
9 changes: 9 additions & 0 deletions 02-online-serving(fastapi)/projects/web_single/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from pydantic import BaseModel

class PredictionRequest(BaseModel):
features: list
# input 데이터를 여러분들의 상황에 맞게 작성

class PredictionResponse(BaseModel):
id: int
result: int

0 comments on commit 247db83

Please sign in to comment.