Lean Baseball

No Engineering, No Baseball.

RESTful APIをシュッと作る技術 - PythonとFastAPIでバックエンドを5時間ちょいで作ってみた

久々に開発ネタです.

大晦日ハッカソン2019 #大晦日ハッカソンで,

  • 野球のデータをシュッと見るためのDashboardを作る(理由は後ほど).
  • そんなDashboardのBackend APIをシュッと開発する.

を目標に立て現在進行系でやってるのですが,

Backendを昨日(12/30)の18:00から着手して(実質作業時間)約5時間ちょいで完成させてしまいました.

本年最後のブログは,

  • FastAPIでバックエンドを5時間ちょいで作った話.
  • それもカウボーイコーディングじゃなくてちゃんときれいに作ったよ, クリーンアーキテクチャ意識して.

っていう記録を備忘録的に残したいと思います.

なお, コードは公開しませんが必要なスニペットは載せますので参考にしてもらえると嬉しいです.

おしながき

前提条件

一応前提条件書いときます.

  • ローカル環境(Macbook Pro + Docker Desktop)の環境で動かしてます, クラウドサービス等には上げていない.*1
  • データは事前にマイグレーションしてDocker上のMySQLで取得できる状態*2
  • デモなので, ユーザー認証は作ってません*3
  • 新規プロダクトのデモかつゼロからやってるのでテストおよびValidatorは未実装です.*4
  • 参照しかしないアプリなため, post/put/deleteに当たるAPIは未実装です(そもそも仕様にないので作らないつもり.).

ちなみにデータはSean Lahmanというメジャーリーグのデータベースを使っています, DB化は(ちょっと古いですが)過去記事が参考になると思います.

shinyorke.hatenablog.com

github.com

これらの前提条件のもと, 5時間で完成させました.

作ったモノ

野球のデータを取得するAPIで,

  • 選手ID(player_id)から選手Profileを取得
  • 同じくplayer_idから打撃成績(年度の降順)で取得

というAPIを作りました.

スクショで魅せるとこんなかんじ(サンプルはShohei Ohtaniさんです*5).

起動後, http://0.0.0.0:8000/docs と打つとAPIのドキュメントが登場.

f:id:shinyorke:20191231164345j:plain
APIドキュメント. これはFastAPIの標準機能.

このドキュメントはそのままSwaggerチックなモノになっててこの場から直接実行することもできます, 強い.

f:id:shinyorke:20191231161753j:plain
docstringの内容をこのまま出してくれてます, いい感じ.

実際にAPIを叩いてプロフィール出してみました, ちゃんとできてる.

f:id:shinyorke:20191231164145j:plain
選手プロフィール

打撃成績も今年の分までしっかり出てくれました.

f:id:shinyorke:20191231164129j:plain
打撃成績の検索結果

以下の順でコードベースで追いかけていきます.

  • Docker(Docker Compose)
  • Application(Router/Controller)
  • Schema/Model/Database

そもそもFastAPI #とは

一言で言うとPython製の非同期前提なWeb APIをいい感じに作るためのFrameworkです.

fastapi.tiangolo.com

あくまで自分の解釈ですが,

  • バックエンドが欲しい時にシュッと作れるWeb APIのFramework
  • チャットなどで非同期処理前提のサーバがほしい時に重宝する「ASGI(Asynchronous Server Gateway Interface) 」に対応した今どきなFrameworkでもある
  • 開発してる感覚的には, Flaskそのもの

といったモノです.

Hello worldを見ると一目瞭然かもしれません.

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
def read_root():
    return {"Hello": "World"}


@app.get("/items/{item_id}")
def read_item(item_id: int, q: str = None):
    return {"item_id": item_id, "q": q}

デコレータで囲う感覚もそうですが, 他も似てます.

???「だったらFlaskでいいのでは?」

と思われるかもですが,

  • 年末年始休みで時間あるので普段使ってないものにしたかった.*6
  • 当初はresponderを想定していたが, 若干方言ある・依存ライブラリ多いのでちょっと...
  • 自社(JX通信社)で使ってるFastAPIを同僚にオススメされたのでやってこ!ってなった.*7

ちなみに(うっすらですが)自社でもFastAPIを(自分とは別のチームで)使っており, サンプルめいたものはTechブログでもちょっと紹介されています.

tech.jxpress.net

Docker(Docker Compose)

まずDockerで動かしたモノの設定...の前にざっくりなプロジェクト構成です.

.
├── Dockerfile
├── Pipfile
├── Pipfile.lock
├── app.py
├── sabr_analytics
│   ├── __init__.py
│   └── router.py
├── docker-compose-local.yml
├── lahman
│   ├── __init__.py
│   ├── crud.py
│   ├── models.py
│   ├── router.py
│   └── schemas.py
└── service
    ├── __init__.py
    └── database.py

Applicationとしてのエントリーポイントはapp.pyでして, Local Debug時は,

$ python app.py

もしくは, ASGIサーバーのuvicornを使って,

$ uvicorn app:api --reload

これで動きます.

Docker使ってのデバッグですが,

$ docker-compose -f docker-compose-local.yml up -d

これで済むようにしています.*8

そんなdocker-compose-local.ymlの内容はこちら(networksは事前に作ってます).

version: "3.0"
services:
  api:
    build: .
    image: shiyorke/yakiu/api
    container_name: yakiu_api
    env_file: .env
    ports:
      - "8000:8000"

networks:
  default:
    external:
      name: baseball_network

そしてDockerfileはこれ.

pipenvで必要なモノを入れて動かしておしまいです.

FROM python:3.8

LABEL  maintainer "shinyorke"
RUN set -ex \
    && apt-get update -y --fix-missing \
    && apt-get install -y -q --no-install-recommends \
    curl \
    file \
    && apt-get purge -y --auto-remove

# install
RUN pip install pipenv
ADD Pipfile Pipfile.lock /
RUN pipenv install --system

# add to application
ADD app.py /
ADD service service
ADD sabr_analytics sabr_analytics
ADD lahman lahman

EXPOSE 8000
CMD ["uvicorn", "app:api", "--host", "0.0.0.0", "--port", "8000"]

ちなみにPipfileはこちら.

[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true

[dev-packages]

[packages]
fastapi = "*"
uvicorn = "*"
sqlalchemy = "*"
cryptography = "*"
pymysql = "*"
sabr = "*"

[requires]
python_version = "3.7"

メインで使ってるパッケージが少なくて気持ち的に楽です.

Application(Router/Controller)

Application本体です.

まずすべてのエントリーポイントとなってるapp.pyの実装.

import uvicorn

from fastapi import FastAPI

from lahman import router as api_lahman
from sabr_analytics import router as api_analytics

api = FastAPI()


@api.get("/")
async def dashboard():
    return {
        'playerIds': ['Ichiro', 'Altuve', 'Trout', 'Shohei'],
        'api_batting': 'lahman',
        'api_analytics': 'analytics'
    }


api.include_router(
    api_lahman.router,
    prefix="/lahman",
    tags=["lahman"],
)


api.include_router(
    api_analytics.router,
    prefix="/analytics",
    tags=["analytics"],
)

if __name__ == "__main__":
    uvicorn.run(api, host="0.0.0.0", port=8000)

APIのエントリーポイント(URL)は少なめですが, 拡張性を考慮して最初からRoterでモジュールを刺す形で開発をはじめました.

これは開発当初からこの仕掛を作りたくて公式ドキュメントを探してましたがアッサリ見つかりました.

Bigger Applications - Multiple Files - FastAPI

これは明確な理由があって,

  • 普通の野球成績データはlahmanパッケージに留める
  • 今後開発する(昨日今日では作ってない)独自のモデルで分析した結果は別のパッケージとして明確に切り分けたい

という方針を最初から決めており, それによりサブドメインも分かれるのがわかりきってたので最初からrouter前提にしました.

ちなみに構成的には,

.
├── app.py  # メインのアプリ. ここのrouterに刺したものだけ動く
├── sabr_analytics  # 独自分析モデルのアプリ, これから開発
│   ├── __init__.py
│   └── router.py
├── lahman  # 野球の成績データAPI, 今回メインで開発
│   ├── __init__.py
│   ├── crud.py
│   ├── models.py
│   ├── router.py
│   └── schemas.py
└── service  # DBコネクション, 外部API接続などパッケージ横断で共通して使うもの
    ├── __init__.py
    └── database.py

と明確に分けて開発してます.

この先メインで解説するlahmanパッケージのrouter定義(lahman/router.py)はこんな感じです.

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List

from lahman import schemas
from lahman.crud import Database
from service.database import session

router = APIRouter()


@router.get("/")
async def index():
    return {
        'apis': [
            '/player',
            '/batting'
        ]
    }


@router.get("/player/{player_id}", response_model=schemas.Player)
async def get_player(player_id: str, db: Session = Depends(session)):
    """
    Player Profile
    :param player_id: lahman playerID
    :param db: DB Session
    :return: Player Profile
    :rtype: JSON
    :exception: HTTPException(404)
    """
    player = Database.get_player(db, player_id)
    if player:
        return player
    raise HTTPException(status_code=404, detail=f"player not found: {player_id}")


@router.get("/batting/{player_id}", response_model=List[schemas.BattingTotal])
async def get_batting(player_id: str, db: Session = Depends(session)):
    """
    Batting Stats
    :param player_id: lahman playerID
    :param db: DB Session
    :return: Batting Stats
    :rtype: JSON
    """
    batting_stats = Database.get_batting(db, player_id)
    return batting_stats

めちゃくちゃシンプルですね.

Schema/Model/Database

データモデルの定義などがどうなってるか解説です.

こちらも公式ドキュメントを参考にアレンジしました.

SQL (Relational) Databases - FastAPI

lahmanパッケージのrouter定義(lahman/router.py)より.

関数のこちらに着目.

@router.get("/player/{player_id}", response_model=schemas.Player)
async def get_player(player_id: str, db: Session = Depends(session)):
    """
    Player Profile
    :param player_id: lahman playerID
    :param db: DB Session
    :return: Player Profile
    :rtype: JSON
    :exception: HTTPException(404)
    """
    player = Database.get_player(db, player_id)
    if player:
        return player
    raise HTTPException(status_code=404, detail=f"player not found: {player_id}")

response_model=schemas.Player

ここは名前の通り戻り値を決めています, 具体的にはpydanticというライブラリでいい感じに定義しています.

Django REST Framework(DRF)で言う所のserializerに当たる箇所.

※ソースコードは「lahman/schema.py」

from datetime import datetime

import pydantic
from pydantic import BaseModel
from sabr.stats import Stats


class BattingBase(BaseModel):
    playerID: str
    yearID: int
    G: int = None
    G_batting: int = None
    AB: int = None
    R: int = None
    H: int = None
    DOUBLE: int = None
    TRIPLE: int = None
    HR: int = None
    SINGLE: int = None
    RBI: int = None
    SB: int = None
    CS: int = None
    BB: int = None
    SO: int = None
    IBB: int = None
    HBP: int = None
    SH: int = None
    SF: int = None
    GIDP: int = None
    G_old: int = None
    BA: float = None
    OBP: float = None
    SLG: float = None
    OPS: float = None
    BABIP: float = None

    @pydantic.validator('SINGLE', pre=True, always=True)
    def single(cls, v, *, values, **kwargs):
        """
        Single Hits
        :param v: Default Value
        :param values: Row Values
        :param kwargs: Option
        :return: SINGLE
        """
        if values['AB'] > 0:
            return values['H'] - (values['HR'] + values['TRIPLE'] + values['DOUBLE'])
        return 0.0

    @pydantic.validator('BA', pre=True, always=True)
    def ba(cls, v, *, values, **kwargs):
        """
        Batting Average
        :param v: Default Value
        :param values: Row Values
        :param kwargs: Option
        :return: BA
        """
        if values['AB'] > 0:
            return Stats.avg(h=values['H'], ab=values['AB'])
        return 0.0

    @pydantic.validator('OBP', pre=True, always=True)
    def obp(cls, v, *, values, **kwargs):
        """
        On Base Per
        :param v: Default Value
        :param values: Row Values
        :param kwargs: Option
        :return: OBP
        """
        return Stats.obp(h=values['H'], bb=values['BB'], hbp=values['HBP'], sf=values['SF'], ab=values['AB'])

    @pydantic.validator('SLG', pre=True, always=True)
    def slg(cls, v, *, values, **kwargs):
        """
        Slugging
        :param v: Default Value
        :param values: Row Values
        :param kwargs: Option
        :return: OBP
        """
        if values['AB'] > 0:
            single = values['H'] - (values['HR'] + values['TRIPLE'] + values['DOUBLE'])
            tb = Stats.tb(single=single, _2b=values['DOUBLE'], _3b=values['TRIPLE'], hr=values['HR'])
            return Stats.slg(tb=tb, ab=values['AB'])
        return 0.0

    @pydantic.validator('OPS', always=True)
    def ops(cls, v, *, values, **kwargs):
        """
        On Base Plus Slugging
        :param v: Default Value
        :param values: Row Values
        :param kwargs: Option
        :return: OPS
        """
        return values['OBP'] + values['SLG']

    @pydantic.validator('BABIP', always=True)
    def babip(cls, v, *, values, **kwargs):
        """
        BABIP
        :param v: Default Value
        :param values: Row Values
        :param kwargs: Option
        :return: BABIP
        """
        return Stats.babip(h=values['H'], hr=values['HR'], ab=values['AB'], sf=values['SF'], so=values['SO'])


class Batting(BattingBase):
    teamID: str
    lgID: str
    stint: int

    class Config:
        orm_mode = True


class BattingTotal(BattingBase):
    class Config:
        orm_mode = True


class PlayerBase(BaseModel):
    playerID: str
    birthYear: int
    birthMonth: int
    birthDay: int
    nameFirst: str
    nameLast: str
    bats: str
    throws: str


class Player(PlayerBase):
    playerID: str
    birthCountry: str
    birthState: str
    birthCity: str
    nameGiven: str
    weight: int
    height: float
    debut: datetime
    finalGame: datetime
    retroID: str
    bbrefID: str

    class Config:
        orm_mode = True

validatorデコレータでモデルに無い値(例えば打率(BA)など)を計算してるのが若干気持ち悪いですがこれが正しい使い方っぽいです苦笑.

もちろんDBからデータを探してSchemaにマッピングするのですが, これはSQLAlchemyを使っています(Python使いならおなじみ*9ですね).

Databaseクラスには検索のための関数を生やしており,

from sqlalchemy.orm import Session
from sqlalchemy import desc
from lahman import models


class Database:

    @staticmethod
    def get_player(db: Session, player_id: str):
        """
        Get Player
        :param db: SQLAlchemy Session
        :param player_id: lahman db playerID
        :return: Player Profile
        """
        return db.query(models.People).filter(models.People.playerID == player_id).first()

    @staticmethod
    def get_batting(db: Session, player_id: str):
        """
        Get Batting Stats
        :param db: SQLAlchemy Session
        :param player_id: lahman db playerID
        :return: Batting Stats(Years Desc)
        """
        return db.query(models.BattingTotal).order_by(desc(models.BattingTotal.yearID)) \
            .filter(models.BattingTotal.playerID == player_id).all()

モデルはいつもどおりSQLAlchemyでいい感じに定義です.

from sqlalchemy import Column, DateTime, Float, Integer, String, text

from service.database import Base


class People(Base):
    __tablename__ = u'People'

    playerID = Column(String(10), primary_key=True)
    birthYear = Column(Integer)
    birthMonth = Column(Integer)
    birthDay = Column(Integer)
    birthCountry = Column(String(50))
    birthState = Column(String(2))
    birthCity = Column(String(50))
    deathYear = Column(Integer)
    deathMonth = Column(Integer)
    deathDay = Column(Integer)
    deathCountry = Column(String(50))
    deathState = Column(String(2))
    deathCity = Column(String(50))
    nameFirst = Column(String(50))
    nameLast = Column(String(50))
    nameGiven = Column(String(255))
    weight = Column(Integer)
    height = Column(Float(asdecimal=True))
    bats = Column(String(1))
    throws = Column(String(1))
    debut = Column(DateTime)
    finalGame = Column(DateTime)
    retroID = Column(String(9))
    bbrefID = Column(String(9))


class BattingTotal(Base):
    __tablename__ = u'BattingTotal'

    playerID = Column(String(9), primary_key=True, nullable=False, server_default=text("''"))
    yearID = Column(Integer, primary_key=True, nullable=False, server_default=text("'0'"))
    G = Column(Integer)
    G_batting = Column(Integer)
    AB = Column(Integer)
    R = Column(Integer)
    H = Column(Integer)
    DOUBLE = Column(u'2B', Integer)
    TRIPLE = Column(u'3B', Integer)
    HR = Column(Integer)
    RBI = Column(Integer)
    SB = Column(Integer)
    CS = Column(Integer)
    BB = Column(Integer)
    SO = Column(Integer)
    IBB = Column(Integer)
    HBP = Column(Integer)
    SH = Column(Integer)
    SF = Column(Integer)
    GIDP = Column(Integer)
    G_old = Column(Integer)

データベースの関数はserviceパッケージにまとめています.

なぜならConnectionはパッケージ関係なく使うからです(こなみ).

import os

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

params = {
    'user': os.getenv('DB_USER'),
    'password': os.getenv('DB_PASSWORD'),
    'host': os.getenv('DB_HOST'),
    'database': os.getenv('DATABASE')
}

DATABASE_URL = "mysql+pymysql://{user}:{password}@{host}:3306/{database}".format(**params)

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

Base = declarative_base()


def session():
    """
    Get Database Session
    :return:
    """
    db = None
    try:
        db = SessionLocal()
        yield db
    finally:
        db.close()

公式のSQL使う時解説だと, 上記の「def session()」に当たる箇所をapp.py上で定義してましたが, 依存する箇所が気持ち悪かったのでそこは流石にDatabaseに寄せました.

実はクリーンアーキテクチャなのだ

という細かい構成・こだわりを元にプロトタイプをシュッとつくりました.

実はこの設計, クリーンアーキテクチャを志向してまして.

Clean Architecture 達人に学ぶソフトウェアの構造と設計

Clean Architecture 達人に学ぶソフトウェアの構造と設計

  • 作者:Robert C.Martin
  • 出版社/メーカー: KADOKAWA
  • 発売日: 2018/07/27
  • メディア: 単行本

  • 依存性を一貫して, 「(DB・インターフェース層) -> (ビジネスモデル層)」の一方通行にする.
  • DBや(今回登場しなかったが)外部APIなどはservice層にまとめ, 他のビジネスモデル層と明確に切り離し.
  • ビジネスモデル層(今回はlahmanとかsabr_analyticsパッケージ)は各々が独立し, 必要なモノをserviceからもらう.
  • routerやapp.pyはDB何使おうが, ビジネスモデルをどう組もうが気にさせない.

を徹底しました.

が, もっといい設計あるよって人はぜひコメントとかもらえると嬉しいです.

これを何に使うか

デブサミ2020の発表時のデモ...のプロトタイプとして使います.*10

event.shoeisha.jp

自分の発表は「残席わずか」らしいので, みんな来てね.

良いお年を🎍&これから大晦日ハッカソン2019 #大晦日ハッカソンに復帰してフロントエンド作ります.

*1:とはいえDocker化済ませているのでやろうと思えば半日かな.

*2:実はここに一番時間掛かってるかもしれない.

*3:これは後学の為にちゃんと作りたい. FastAPI使いこなしたい.

*4:これは方針として正しいと思っています, 公開する前に作れるようにキレイに書いたので(真顔).

*5:来年の二刀流復活はマジで期待したい.

*6:Djangoは飽きるほど触ってる, Flaskもさんざん触ってるのと, 現在の開発状況とか考えるとリスクが多そうなので新規プロダクトで使う気になりませんでした.

*7:有志メンバーで昨日もくもく会をしてたときにこの話題で盛り上がりました.その後帰宅してから開発スタート.

*8:といいつつ, これはフロントエンド開発時の構成で実際はPyCharmでガッツリやってます.

*9:好き嫌いは分かれると思いますが笑

*10:そのままバックエンドのAPIとして使えるとは思ってますが, 本命を別に作りたい&あくまでデモの仕様を決めるためのプロトタイプなのでアッサリと捨てる可能性すらありますFastAPIのコードは.