LoginSignup
86
94

More than 3 years have passed since last update.

PythonのWebフレームワーク「FastAPI」とTypeScript・OpenAPIで、型つきでWebアプリを作ってみる - 機械学習WebAppのための技術スタック

Last updated at Posted at 2020-05-05

Intro

Pythonで実装した機械学習や画像処理をバックエンドにしたWebアプリをサクッと作るための技術スタックとして、FastAPI+TypeScript+OpenAPIを紹介します。

モチベーション

  • PythonでサクッとWebサーバ(APIサーバ)を立てたい
    • 今まではFlaskを使ってたような用途
  • 「Pythonで」
    • 機械学習・画像処理のサービスなので
  • 「サクッと」
    • バリデーションとか楽したい
  • サーバ、クライアント共に型の保証が欲しい
    • 機械学習や画像処理のアプリはパラメータが多くなりがち・一貫した慣習が無いのでミスしやすい
    • width or w
    • 値の範囲は[0, w] or [0, 1] ?
    • →型アノテーションでカバーしたい
  • やりたいこと
    • API endpoint公開
    • メディアファイルアップロード・ダウンロード
    • Additional: 非同期通信、WebSocket

検討した選択肢

Webフレームワーク

  • (Flask)
  • Sanic
  • Starlette
  • FastAPI

型保証

  • gRPC-web
  • OpenAPI

先に結論

  • FastAPIで簡単に、宣言的にWebAPIが作れる
  • FastAPIでのサーバサイドの定義から、OpenAPIを経由してフロントエンドライブラリを生成できる。
    • 本稿では生成先言語としてTypeScriptを使う。サーバサイドと一致した型定義を利用できる。

FastAPI?

FastAPI logo
https://fastapi.tiangolo.com/

  • starletteベース
  • ASGI
  • 国内では2019年初頭にバズった

Demo

上記モチベーションのうち、

  • サクッとAPIを立てて
  • 型を保証したクライアントが使える

ことのデモ

サンプルRepositoryはこちら: https://github.com/tuttieee/fastapi-typescript-openapi-example

基本的にFastAPIのチュートリアルの通りに進めていくが、↑のために若干改変してある。

  • WebAppを作るところまでカバーするため、frontend (TypeScript)もサンプルに含める
  • Dockerで開発、デプロイする

Dockerで立ち上げる

https://fastapi.tiangolo.com/deployment/ の通り。

  • frontendとのモノレポにするため、1階層掘って /backend に置く。
  • あとでmigrationなどを足すため、もう1階層掘って /backend/app/app とする。/backend/app がdockerコンテナにマウントされる
  • docker-composeで管理する

docker-compose build, docker-compose up のあと、curl localhost:8000 をやってみる

$ curl localhost:8000
{"Hello":"World"}

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/703af24fff8d39e261e1f1ce5ba859f28e806fb2

開発用設定

  • docker-compose.devel.yml
    • Host側ディレクトリをマウント
    • auto-reloadを有効化
    • docker run のcommandに/start-reload.shを指定するとオートリロードが有効になる(利用している 公式Dockerイメージ のオプション)

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/33f6e91bc48c9c6396a9ceaf1e720b88c78a6822

SQLと繋がったAPIを作ってみる

基本的に↓に従って進める。
MySQLを使う部分は独自に追記。

SQLAlchemyインストール

とりあえずDockerfileで直に RUN pip install sqlalchemy

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/75b7d2d7ccb8bc5a142b86bd3800b5e190732628

まずSQLite

  • app/app/__init__.py, app/app/database.py, app/app/models.py を作成
  • 動くか確かめてみる
    • shellに入り、python -i
    • ↓を叩いてみる
from app import models
from app.database import SessionLocal, engine
models.Base.metadata.create_all(bind=engine)
user = models.User(email='test@example.com', hashed_password='')
db = SessionLocal()
db.add(user)
db.commit()
db.close()
db = SessionLocal()
db.query(models.User).all()

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/8f0d85445c33bfddd05466060b11501c2f3748b3

MySQL

backendコンテナに関連ソフトウェアをインストール

RUN apt-get update && apt-get install -y \
    default-mysql-client \ 
&& apt-get clean \ 
&& rm -rf /var/lib/apt/lists/*

RUN pip install sqlalchemy PyMySQL

サンプル:

MySQLコンテナ追加

  • 公式イメージ を利用。基本的に、READMEの通りにdocker-compose.ymlに設定を書く
  • Credentialsを.envに追記、docker-compose.ymlで環境変数として渡す

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/22d760d963394f340622ea402fdcf72f02cd240f

Alembic

  • Dockerfilepip install alembic追記(いずれrequirements.txt化?) -> docker-compose build
  • database.pyを更新
    • database URLをMySQL用に更新
    • create_engine をMySQL用に更新(不要な引数を除去)
    • naming conventionを追記

Tips: starletteのクラスがそのまま使える

その後shellに入り、以下の作業をする

初期化

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/979f4b3982b3ce3e7631661af3171000d6ea0381

設定

Ref: https://alembic.sqlalchemy.org/en/latest/autogenerate.html

  • alembic.ini 編集
    • sqlalchemy.url を削除(database.pyから取るようにするので)
  • migrations/env.py 編集
    • ↓を追記
from app.models import Base
from app.database import SQLALCHEMY_DATABASE_URL

config.set_main_option("sqlalchemy.url", SQLALCHEMY_DATABASE_URL)
target_metadata = Base.metadata
Migration生成
  • models.py の定義をMySQL用にしておく(String に長さを指定する)
  • alembic revision --autogenerate -m "Add users and items tables"

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/4ceae799f6f6b83ffc71cfec5ce13a669e137757

Migration実行
  • alembic upgrade head
試してみる
  • python -i で以下を実行
from app import models
from app.database import SessionLocal
user = models.User(email='test@example.com', hashed_password='')
db = SessionLocal()
db.add(user)
db.commit()
db.close()

db = SessionLocal()
db.query(models.User).all()

API作成

Pydanticモデル作成

See https://fastapi.tiangolo.com/tutorial/sql-databases/#create-initial-pydantic-models-schemas
app/schemas.py 作成

CRUD utils作成

See https://fastapi.tiangolo.com/tutorial/sql-databases/#crud-utils
app/crud.py 作成

メインAPI作成

See https://fastapi.tiangolo.com/tutorial/sql-databases/#main-fastapi-app
app/main.py 更新

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/1e7d140c1d7929c15f7815648deb14f831807ddd

動作確認

cURL例:

  • curl localhost:8000/users/
  • curl -X POST -d '{"email": "testtest@example.com", "password": "test"}' localhost:8000/users/

OpenAPIから
http://localhost:8000/docs

MISC

from fastapi.routing import APIRoute

...

def use_route_names_as_operation_ids(app: FastAPI) -> None:
  """
  Simplify operation IDs so that generated API clients have simpler function
  names.

  Should be called only after all routes have been added.
  """
  for route in app.routes:
      if isinstance(route, APIRoute):
          route.operation_id = route.name

use_route_names_as_operation_ids(app)

Frontend

Create React App

$ yarn create react-app frontend --template typescript
$ cd frontend
$ yarn start

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/21b1f0f926132fdbf9c1dbef967ef4286fa27279

(とりあえず)APIアクセス

  • CORS設定
    • backend/app/app/main.py に以下を追記
# TODO: This is for development. Remove it for production.
origins = [
    "<http://localhost:3000",>
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)
  • yarn add axios
  • frontend/src/App.tsx で雑にfetchしてみる
import React, { useState, useEffect } from 'react';
import './App.css';
import { DefaultApi, Configuration, User } from './api-client';

const config = new Configuration({ basePath: 'http://localhost:8000' }); // TODO: This is for dev
export const apiClient = new DefaultApi(config);

const App: React.FC = () => {
  const [users, setUsers] = useState<User[]>([]);

  useEffect(() => {
    apiClient.readUsers().then((response) => {
      setUsers(response.data);
    })
  })

  return (
    <div className="App">
      <ul>
        {users.map(user =>
          <li key={user.id}>{user.email}</li>
        )}
      </ul>
    </div>
  );
}

export default App;

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/79fc0bb26c6ffa7e6e43473f0ef497dd1c2f53ff

現コードベースの問題点

  • 手動で型定義しなければならない
    • 自分で interface User とか書く

OpenAPIによるクライアント自動生成

Intro

OpenAPI: 旧Swagger
FastAPIがOpenAPIのJSON定義を生成してくれる: http://localhost:8000/openapi.json
このJSONを食って様々な言語のクライアントを生成してくれるOpenAPI Generator

DockerでOpenAPI Generatorを動かす

See https://openapi-generator.tech/docs/installation.html#docker

- docker-compose.openapi-generator.yml を作成

version: "3.7"

services:
  openapi-generator:
    image: openapitools/openapi-generator-cli
    volumes:
      - ./frontend:/frontend
    working_dir: /frontend
    command:
      - generate
      - -g
      - typescript-axios
      - -i
      - <http://backend/openapi.json>
      - -o
      - /frontend/src/api-client
      - --additional-properties=supportsES6=true,modelPropertyNaming=original
      # modelPropertyNaming=original is necessary though camelCase is preferred
      # See <https://github.com/OpenAPITools/openapi-generator/issues/2976>
  • Makefile にコマンドを追記
oapi/gen:
  docker-compose -f docker-compose.yml -f docker-compose.openapi-generator.yml up openapi-generator \
  && docker-compose -f docker-compose.yml -f docker-compose.openapi-generator.yml rm -f openapi-generator

Frontendを自動生成コードを使うよう書き換える

frontend/src/App.tsx

import React, { useState, useEffect } from 'react';
import './App.css';
import { DefaultApi, Configuration, User } from './api-client';

const config = new Configuration({ basePath: '<http://localhost:8000'> }); // TODO: This is for dev
export const apiClient = new DefaultApi(config);

const App: React.FC = () => {
  const [users, setUsers] = useState<User[]>([]);

  useEffect(() => {
    apiClient.readUsers().then((response) => {
      setUsers(response.data);
    })
  })

  return (
    <div className="App">
      <ul>
        {users.map(user =>
          <li>{user.email}</li>
        )}
      </ul>
    </div>
  );
}

export default App;

サンプル: https://github.com/tuttieee/fastapi-typescript-openapi-example/commit/1d38242a63c9a5477fa1cde506f36f68a19060a7

その他

このDemoで扱わなかったこと

実際のプロジェクトでは、Unittest, CI, Linterなどは適宜採用

FastAPIのその他の利点、なぜ他の技術と比べてFastAPIを採用したか

  • Python
    • 画像扱ったり、機械学習的なことをしたり→Pillowやnumpyは欲しい
  • 非同期通信(WebSocket)のサポート
    • ASGIベース
    • Flaskでやろうとするとgeventとかハックが必要
  • 普通のHTTP
    • メディアファイルのアップロードなどはmultipart/formdataでやりたいし、ダウンロードは直接ファイルを落としたい
    • gRPC-webだとこの辺が微妙
    • https://github.com/grpc/grpc-web/issues/517
  • オールインワン
    • 「サクッと」に通じる
    • ドキュメントが充実、production-readyなdockerイメージも公式が提供
    • 例えば、FlaskやSanicとかでも、プラグインを入れればOpenAPIを使えるなど
86
94
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
86
94