当サイトは、アフィリエイト広告を利用しています

【Docker × Flask】コンテナでFlask製のREST APIを実装する~実践~

作成日:2024月08月20日
更新日:2024年08月20日

Pythonの軽量フレームワークであるflaskのREST APIをdockerコンテナ環境を使って 開発する。

コンテナ環境でflask製REST APIの開発の基本については 下記記事でまとめているので、FlaskのREST APIがよくわからない人は
先に見てほしい。

当記事は実践編ということで、開発をする際に検討する必要がある

  • プロジェクト構成(モジュールの分け方)
  • エラーハンドリング
  • ロギング
  • レスポンス統一

について深堀りして、実際に開発に使えるREST APIを実装する

作成するREST APIは大規模なものではなくマイクロサービスで使うような
小規模のREST APIを作る。

また開発をするための

  • コンテナ環境構築
  • flaskのビルトインサーバー起動
  • gunicornサーバー起動
  • デバッグ方法

についてもまとめておく。

REST APIの設計

HTTPリクエストを用いてデータベースに対してCRUD操作を行う。
下記のCRUD処理を実装する

  • GET → 全件参照
  • GET(パスパラメータあり) → 条件付き参照
  • POST → 登録
  • PUT → 更新
  • DELETE → 削除

REST APIの技術スタック

開発環境を含めた技術スタックは下記にする。

  • Windows10
  • Docker version 24.0.2(Docker for Windows)
  • VSCode
  • Flask 3.0.2
  • mongo 6.0.13
  • mongo-express 1.0.2
  • gunicorn 21.2.0

細かく解説する

開発環境

Docker

Docker version 24.0.2(Docker for Windows)を使う。

開発はdocker-composeで作成したDockerコンテナ上で行う。

コンテナ上で行うことでライブラリなどのインストールでローカルが汚れない。
またdocker-compose.ymlがあればどこでも同じ開発環境を準備できる。

Docker for Windowsのインストール方法については
下記でまとめています。

VSCode

開発はVSCodeからリモートでコンテナに接続し
コンテナ上で行う。

VSCodeの拡張機能である。

  • Dev Containers(ms-vscode-remote.remote-containers)
  • Docker(ms-azuretools.vscode-docker)

のどちらかを使用すれば、コンテナ内をVSCodeから操作できるので
開発しやすい。

Dev Containersの使い方については下記でまとめています。

REST API

Flask

REST APIはPythonの軽量フレームワークであるflaskで開発する

mongo

データベースはNoSQLのドキュメント指向型のDBで JSONやXMLといったデータ形式で記述されたドキュメントの形でデータを管理できる
MongoDBを使う。

MongoDBに関しては下記記事でまとめています

mongo-express

mongo-expressはNode.jsとExpressを使用して書かれているWebベースのMongoDB管理インターフェース。
データベースやコレクションの表示、追加、削除、ドキュメントの編集などを行うことができる。

実装とは関係ないが、データの確認や編集はmongo-expressを使うと
簡単にできる。

mongo-expressの使い方については下記でまとめています

起動サーバー

gunicorn

REST API自体は外部サーバーであるgunicornで動かす。
Flaskには簡易的な内部サーバーが付属しているが
本番ではgunicornなどの外部サーバーを使うので
gunicornで起動させる。
※起動もデバッグもgunicornの方が早いし、快適。

gunicornについても下記でまとめている

プロジェクト構成

プロジェクト構成は下記のようにする。

プロジェクト構成
.
|-- .env
|-- .gitignore
|-- .vscode
| |-- launch.json
| `-- tasks.json
|-- Dockerfile
|-- app
| |-- __init__.py
| |-- api.py
| |-- config.py
| |-- database.py
| |-- database_model.py
| |-- endpoints.py
| |-- error_handling.py
| |-- exceptions.py
| |-- logger.py
| |-- response_helper.py
| |-- schemas.py
| |-- user_service.py
| `-- wsgi.py
|-- docker-compose.yml
|-- gunicorn_config.py
|-- log
| |-- api.log
| |-- gunicorn_access.log
| `-- gunicorn_error.log
|-- mongo
| |-- db
| `-- init
| `-- init-db.js
|-- requirements.txt
`-- vscode_ex_install.sh

下記から詳しくまとめる。

コンテナ作成

VSCodeでコンテナ開発環境を作成する

.gitignore

REST APIのソースはGitで管理する。
不要なソースは追跡しないようにする。

.gitignore
**/mongo/**
**/__pycache__/**
**/log/**
  • mongodbのデータは除外する
  • __pycache__も不要なので除外
  • logも追跡はしないので除外

.env

.env
# flask-REST APIコンテナ用
# Flaskがどのアプリケーションを実行するか指定(pyファイル:インスタンス)
FLASK_APP=app.wsgi:api
# Flaskがどの環境で動作するか指定
FLASK_ENV=development
#デバッグモードの指定
FLASK_DEBUG=true
# MongoDBコンテナ用
# MongoDBの管理者のユーザー名
MONGO_INITDB_ROOT_USERNAME=admin
# MongoDBの管理者のパスワード
MONGO_INITDB_ROOT_PASSWORD=admin123
# 初期DBの作成
MONGO_INITDB_DATABASE=mongodb
# MongoDB接続用URL
MONGO_URI=mongodb://admin:admin123@172.25.2.3:27017/
# Mongo Expressコンテナ用
# Mongo Expressが接続するMongoDBのサーバーを指定(MongoDBのサービス名)
ME_CONFIG_MONGODB_SERVER=mongodb
# MongoDBの管理者(rootユーザー)のユーザー名(Mongo ExpressがMongoDBに接続する際に使用)
ME_CONFIG_MONGODB_ADMINUSERNAME=admin
# MongoDBの管理者(rootユーザー)のパスワード(Mongo ExpressがMongoDBに接続する際に使用)
ME_CONFIG_MONGODB_ADMINPASSWORD=admin123
# Mongo Expressへの基本認証のユーザー名
ME_CONFIG_BASICAUTH_USERNAME=admin
# Mongo Expressへの基本認証のパスワード
ME_CONFIG_BASICAUTH_PASSWORD=password

docker-composeでコンテナ作成時にコンテナの環境変数に設定する値を定義する。
それぞれの値はコメントの通り。

  • Flaskのビルトインサーバー起動するための環境変数
  • MongoDB接続用の環境変数
  • Mongo Express接続用の環境変数

を設定する。

コンテナに環境変数を設定する方法について詳しくは下記でまとめている。

requirements.txt

REST APIの実装やサーバー起動に必要なライブラリを記載する

requirements.txt
Flask==3.0.2
pymongo==4.8.0
pydantic==2.8.2
gunicorn==21.2.0

Dockerfileで読み込ませてインストールする

Dockerfile

REST APIの実装に必要な依存関係をインストールしたDockerイメージを作成する

dockerfile
FROM python:3.12
# workspaceディレクトリ作成、移動
WORKDIR /workspace
# プロジェクトディレクトリにコピー
COPY requirements.txt /workspace
# 必要モジュールのインストール
RUN pip install --upgrade pip
RUN pip install -r requirements.txt

Dockerイメージはpython:3.12をベースにする。
必要なライブラリ等はrequirements.txtを読み込んでインストールする。

docker-compose.yml

コンテナ作成

docker-composeを使って

  • flask-api
  • mongodb
  • mongo_express

の3つのコンテナを作成する。

docker-compose.yml
version: "3"
services:
flask-api:
container_name: "flask-api"
build:
context: .
dockerfile: Dockerfile
ports:
- "5000:5000"
volumes:
# バインドマウント
- .:/workspace
# 環境変数読み込み
env_file:
- .env
tty: true
networks:
flaskmongo_network:
ipv4_address: 172.25.2.2
# MongoDBコンテナ
mongodb:
container_name: "mongodb"
restart: always
image: mongo:6.0.13
ports:
- "27017:27017"
volumes:
- ./mongo/init:/docker-entrypoint-initdb.d # 初期化スクリプト
- mongoDataStore:/data/db # MongoDBのデータファイル
# - ./mongo/db:/data/db
env_file:
- .env
networks:
flaskmongo_network:
ipv4_address: 172.25.2.3
# mongo_expressコンテナ
mongo_express:
container_name: "mongo_express"
image: mongo-express:1.0.2
restart: always
ports:
- "8081:8081"
env_file:
- .env
depends_on:
mongodb:
condition: service_started # mongoコンテナが起動してから起動させる
networks:
flaskmongo_network:
ipv4_address: 172.25.2.4
# ネットワーク設定
networks:
# ネットワーク名(ファイル内での使う名称)
flaskmongo_network:
# ネットワークドライバーを指定
driver: bridge
#ネットワークの名前を指定(dockerネットワークとしての名称)
name: flaskmongo_network
# IPアドレス管理(IPAM)の設定
ipam:
# IPAMドライバーを指定
driver: default
# ネットワークのサブネット設定
config:
- subnet: 172.25.2.0/24
# 名前付きボリューム
volumes:
mongoDataStore:
name: mongoDataStore

ネットワーク作成

flask-apiコンテナからmongodbコンテナに通信する必要があるので
Dockerのネットワークを作成し、固定IPを割り振る

ネットワーク作成部分
# MongoDBコンテナ
mongodb:
container_name: "mongodb"
restart: always
image: mongo:6.0.13
ports:
- "27017:27017"
volumes:
- ./mongo/init:/docker-entrypoint-initdb.d # 初期化スクリプト
- mongoDataStore:/data/db # MongoDBのデータファイル
# - ./mongo/db:/data/db
env_file:
- .env
networks:
flaskmongo_network:
ipv4_address: 172.25.2.3
~~省略~~
# ネットワーク設定
networks:
# ネットワーク名(ファイル内での使う名称)
flaskmongo_network:
# ネットワークドライバーを指定
driver: bridge
#ネットワークの名前を指定(dockerネットワークとしての名称)
name: flaskmongo_network
# IPアドレス管理(IPAM)の設定
ipam:
# IPAMドライバーを指定
driver: default
# ネットワークのサブネット設定
config:
- subnet: 172.25.2.0/24

ネットワークで固定IPを割り振る方法について詳しくは下記参照

名前付きボリューム作成

MongoDBのデータはdockerの名前付きボリュームで管理する

名前付きボリューム作成部分
# MongoDBコンテナ
mongodb:
container_name: "mongodb"
restart: always
image: mongo:6.0.13
ports:
- "27017:27017"
volumes:
- ./mongo/init:/docker-entrypoint-initdb.d # 初期化スクリプト
- mongoDataStore:/data/db # MongoDBのデータファイル
# - ./mongo/db:/data/db
env_file:
- .env
networks:
flaskmongo_network:
ipv4_address: 172.25.2.3
~~省略~~
# 名前付きボリューム
volumes:
mongoDataStore:
name: mongoDataStore

dockerのボリュームに関しては下記でまとめている

バインドマウントにしたい場合はコメントアウトしている「./mongo/db:/data/db」
を活性化して「mongoDataStore:/data/db」をコメントアウトする。

mongo

MongoDB用のフォルダ

mongo/db

MongoDBのデータの保存場所。 バインドマウントしない場合は未使用。

mongo/init/init-db.js

init-db.js
// init-db.js
// 管理者ユーザーでログイン
db = db.getSiblingDB("admin");
db.auth("admin", "admin123");
// mongotable データベースに接続する
db = db.getSiblingDB("SAMPLE_DB");
// コレクションを作成する
db.createCollection("USERS_COLLECTION");
// サンプルドキュメントを挿入する
db.USERS_COLLECTION.insertOne({
user_id: "id",
name: "Name",
age: 0,
});
print("Initialization completed.");

DBに初期データを入れるためのスクリプト※javascript コンテナ起動時に実行される。

MongoDB内に

  • SAMPLE_DBデータベース

を作り、その中に

  • USERS_COLLECTIONコレクション

を作り、ドキュメントを1件挿入している
※MongoDBはデータベースに少なくも1つのコレクションをがないとデータベースとして認識しないので注意

vscode_ex_install.sh

vscode_ex_install.sh
#!/bin/bash
# 拡張機能のIDリスト
extensions=(
"ms-python.python"
"ms-python.vscode-pylance"
"ms-python.debugpy"
"mhutchie.git-graph"
"ms-vscode-remote.vscode-remote-extensionpack"
"ms-azuretools.vscode-docker"
)
# 各拡張機能をインストール
for extension in "${extensions[@]}"; do
code --install-extension $extension
done

開発に必要なVSCodeの拡張機能をスクリプトを使って一括でインストールする。
VSCodeの拡張機能「Dev Containers」を使用している場合は
devcontainer.jsonに書けばコンテナ作成時に自動でインストールしてくれるが
拡張機能「Docker」を使っている場合は自動ではインストールしてくれないので
スクリプトで一括でインストールする

詳しくは下記記事参照

REST API実装

FlaskでREST APIを実装していく。

実装方針

REST APIは下記の方針で実装する。

責務の分離

それぞれのモジュールが特定の役割を果たすようにする。
こうすることでコードの理解とメンテナンスが容易になる

Flask依存のライブラリを使わない

最低限のもの以外はFlask依存のライブラリを使用しないようにした。
こうすることでFaskAPIなどの別のフレームワークへも比較的容易に移行することができる。
※FastAPIでもそのうち作りたいので...

app/api.py

flaskアプリケーションの初期化処理を行う役割を持たせる。
create_app関数を定義し、アプリケーションファクトリパターンで
アプリケーションの作成を行う。

app/api.py
from flask import Flask
import logging
from app.database import Database
from app.endpoints import configure_endpoints
from app.error_handling import init_error_handling
from app.logger import logger_init
def create_app() -> Flask:
# ロガーの設定
logger_init()
# ロガー取得
logger = logging.getLogger('apiLogger')
logger.info('api starting')
# アプリケーションインスタンスの作成
app = Flask(__name__)
# 日本語文字化け対応
app.json.ensure_ascii = False
# データベース接続
Database.init_db()
# エラーハンドラ関数登録
init_error_handling(app)
# ルーティング設定
configure_endpoints(app)
return app

アプリケーション起動時に下記の設定を行う。

ロギングの設定

logger_init()でロギングの設定を行い、loggerを使ってアプリケーションの起動時にログメッセージを記録する

アプリケーションインスタンスの作成

アプリケーションインスタンスを作成し、Flaskの標準的な機能を利用できるようにする

日本語文字化けの防止

app.json.ensure_ascii = Falseを設定することで、日本語の文字化けを防ぎ、
APIレスポンスで日本語を適切に扱えるようにする

データベースの接続の初期化

Database.init_db()でデータベース接続の初期設定を行う
これにより、アプリケーションがデータベースにアクセスできるようになる。

エラーハンドリングの設定

エラーハンドリングを設定し、アプリケーション全体のエラーハンドリングを一元管理する。

ルーティングの設定

APIのエンドポイントを設定する。
アプリケーションの各エンドポイントが適切に機能するようになる。

アプリケーションファクトリパターンとは?

アプリケーションファクトリパターンは WebアプリケーションフレームワークであるFlaskにおいて推奨される設計パターンで
アプリケーションのインスタンス化と設定を行う関数(またはクラス)を使用して、
アプリケーションの作成と設定をカプセル化する

下記のような特徴がある

  • 関数によるアプリケーションの作成 → 関数内でアプリケーションの設定、拡張機能の初期化、ルーティングの設定を行う
  • 動的な設定 → 異なる設定(開発、本番、テストなど)に対応するため、環境変数から設定を読み込む、または設定ファイルなどから動的な設定の適用が容易になる
  • Flaskの拡張機能の統合 → Flaskの拡張機能(例えば、データベースクライアントやセッション管理など)を初期化する

アプリケーションファクトリパターンを使用することで
Flaskアプリケーションの管理がより簡単になり複数の環境での設定管理もより効率的に行うことができる。
また、異なる環境での設定の切り替えや、テストがしやすくなる。

app/wsgi.py

役割としてはFlaskアプリケーションをWSGIサーバー(またはflaskのビルトインサーバー)で起動するための
エントリーポイントとして機能すること。

app/wsgi.py
from app.api import create_app
api = create_app()

WSGI(Web Server Gateway Interface)は、PythonのWebアプリケーションとWebサーバーとの間のインターフェースを定義する仕様のこと。
WSGIサーバー(Gunicorn)を起動時にflaskアプリケーションインスタンスを渡す必要があるため
このファイルを経由して渡す。
WSGIサーバー起動時に「app/wsgi.py」のapi(flaskアプリケーションインスタンス)を指定する必要がある。

flaskのビルトインサーバー起動時も「app/wsgi.py」のapi(flaskアプリケーションインスタンス)を
渡すようにコンテナの環境変数で設定している ※詳しくはサーバー起動のところでまとめる

app/logger.py

Pythonの標準ロギング機能を使ってロギング設定を辞書形式で定義し、
アプリケーション全体で統一されたロギングを行うための設定をまとめて管理する役割がある。

app/logger.py
import logging
import logging.config
# ロギングの設定を辞書形式で定義
logging_config = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'exampleFormatter': {
'format': '%(asctime)s - %(levelname)s - %(message)s',
'datefmt': '%Y-%m-%d %H:%M:%S'
}
},
'handlers': {
'consoleHandler': {
'class': 'logging.StreamHandler',
'level': 'DEBUG',
'formatter': 'exampleFormatter',
'stream': 'ext://sys.stdout'
},
'fileHandler': {
'class': 'logging.FileHandler',
'level': 'INFO',
'formatter': 'exampleFormatter',
'filename': '/workspace/log/api.log',
'encoding': 'utf-8'
}
},
'loggers': {
'': { # ルートロガーの設定
'level': 'DEBUG',
'handlers': ['consoleHandler', 'fileHandler']
},
'apiLogger': { # 特定のロガー設定
'level': 'DEBUG',
'handlers': ['consoleHandler', 'fileHandler'],
'propagate': False
}
}
}
# ロギングの設定を適用
def logger_init():
logging.config.dictConfig(logging_config)

ロギング初期設定をアプリケーション起動時に行うことで
どのモジュールからでもロガー(apiLogger)が使用できるようになる。

ログは/workspace/log/api.logに保存する
※fileHandlerで指定

Pythonの標準ロギング機能については下記で詳しくまとめています

app/config.py

アプリケーションの設定を一元管理し、環境変数等からMongoDB接続情報などを取得してアプリケーション内で
利用できるようにする役割を持つ。

app/config.py
import os
class Config:
# コンテナ環境変数よりmongodb接続情報取得
MONGO_URI = os.environ.get('MONGO_URI')

今回はコンテナの環境変数からmongodb接続情報取得しているのみだが
他に外部ファイルで設定しているもの(APIキー,ファイルパス等)があった場合はここで読み込み一元管理する

app/database.py

アプリケーション全体で共有されるデータベース接続の役割を持たせ
データベース接続を一元管理する。

app/database.py
from flask import Flask
from pymongo import MongoClient
import logging
from app.config import Config
logger = logging.getLogger('apiLogger')
class Database:
# db接続クライアント
client = None
@classmethod
def init_db(cls):
# mogogoDBサーバーに接続
try:
cls.client = MongoClient(Config.MONGO_URI)
except Exception as e:
# logging
logger.error(f'An error occurred while inithializing the database: {e}')
raise

アプリケーション全体で使用するMongoDBの接続を管理するため、シングルトンパターンでクラスを作成する。
他のモジュールやクラスはこのデータベース接続(client)を使用してデータベース操作を行う。

init_dbはFlaskアプリケーションの初期化時に呼び出され、MongoDBへの接続を確立する。

app/error_handling.py

Flaskアプリケーションでのエラーハンドリングを統一的に行うための設定と処理をまとめて管理する役割。

app/error_handling.py
from flask import Flask, jsonify, make_response
import logging
from pydantic import ValidationError, BaseModel
from typing import Type
from pymongo.errors import PyMongoError
from app.exceptions import CustomException
from app.response_helper import ResponseHelper
logger = logging.getLogger('apiLogger')
# バリデーションチェック
def validation_check(data: dict, model: Type[BaseModel],target: str):
# レスポンスのバリデーションチェック
try:
validated_data = model(**data) # 引数で渡されたモデルを使用してバリデーション
return validated_data.model_dump()
except ValidationError as e:
if target == 'request':
e.context = 'request' # エラーオブジェクトにコンテキスト情報を追加
else:
e.context = 'response' # エラーオブジェクトにコンテキスト情報を追加
raise e # eを再スロー
except Exception as e:
logger.error(f"An unexpected error occurred: {str(e)}")
raise
# バリデーションエラーハンドラー
def handle_validation_error(error: ValidationError):
# カスタム属性 `context` でリクエストかレスポンスのエラーかを区別
if hasattr(error, 'context') and error.context == 'request':
logger.error(f'Request validation error:\n{str(error.errors())} ', exc_info=True)
response = ResponseHelper.error(status_code=422, message="Request validation error", detail=str(error.errors()))
return make_response(jsonify(response), 422)
elif hasattr(error, 'context') and error.context == 'response':
logger.error(f'Response validation error:\n{str(error.errors())} ', exc_info=True)
response = ResponseHelper.error(status_code=422, message="Response validation error", detail=str(error.errors()))
return make_response(jsonify(response), 422)
# カスタムエクセプションハンドラー
def handle_custom_exception(error: CustomException):
logger.error(
f'HTTP status code: {error.status_code}\n '
f'Error message: {error.error_message}\n ',
exc_info=True
)
response = ResponseHelper.error(status_code=error.status_code, message=error.error_message)
return make_response(jsonify(response), error.status_code)
# PyMongoエラーハンドラー
def handle_pymongo_error(error: PyMongoError):
logger.error(
f'HTTP status code: {500}\n'
'Error message: Internal Server Error\n'
f'detail: {error.detail}',
exc_info=True
)
response = ResponseHelper.error(status_code=500, detail=error.detail, message='Internal Server Error')
return make_response(jsonify(response), 500)
# dbアクセスエラー用ラッパー
def pymongo_error_wrapper(func):
def wrapper(self, collection_name, *args, **kwargs):
try:
return func(self, collection_name, *args, **kwargs)
except PyMongoError as e:
e.detail = f"Failed to find documents in database '{self.db_name}', collection '{collection_name}'"
raise e
except Exception as e:
logger.error(f"An unexpected error occurred: {str(e)}")
raise
return wrapper
# エラーハンドリング初期化関数
def init_error_handling(app: Flask):
app.register_error_handler(ValidationError, handle_validation_error)
app.register_error_handler(CustomException, handle_custom_exception)
app.register_error_handler(PyMongoError, handle_pymongo_error)

少し細かく解説する。

バリデーションチェック関数

validation_check
# バリデーションチェック
def validation_check(data: dict, model: Type[BaseModel],target: str):
# レスポンスのバリデーションチェック
try:
validated_data = model(**data) # 引数で渡されたモデルを使用してバリデーション
return validated_data.model_dump() # データモデルクラスオブジェクトから辞書形式に変換
except ValidationError as e:
if target == 'request':
e.context = 'request' # エラーオブジェクトにコンテキスト情報を追加
else:
e.context = 'response' # エラーオブジェクトにコンテキスト情報を追加
raise e # eを再スロー
except Exception as e:
logger.error(f"An unexpected error occurred: {str(e)}")
raise

pydanticを使って

  • リクエスト
  • レスポンス

のバリデーションチェックを行う関数。

引数としては

  • data (dict): バリデーション対象のデータを辞書形式で受け取る
  • model (Type[BaseModel]): バリデーションを行うためのPydanticデータモデルクラスを指定する
  • target (str): この引数により、バリデーションがリクエストに対するものかレスポンスに対するものかを判定する

を受け取り、バリデーションチェックを行う。

バリデーションエラーが発生しない場合は
データモデルクラスオブジェクトを辞書型にして返却する。
※データはバリデーションチェック時( model(**data))にデータモデルクラスオブジェクトに自動で変換される

バリデーションエラーが発生した場合は
エラーオブジェクトにコンテキスト情報として、リクエストかレスポンスか
の情報を付与して再スローする。
※ValidationErrorの情報だけではリクエストかレスポンスのどちらで発生したのか
判断できないため

pydanticによるバリデーションを行う方法については下記でもまとめています

handle_validation_error

ValidationErrorのエラーハンドラー関数。
アプリケーション内でValidationErrorが発生した(発生させた)場合は
すべてこの関数で処理されるようにする。

handle_validation_error
# バリデーションエラーハンドラー
def handle_validation_error(error: ValidationError):
# カスタム属性 `context` でリクエストかレスポンスのエラーかを区別
if hasattr(error, 'context') and error.context == 'request':
logger.error(f'Request validation error:\n{str(error.errors())} ', exc_info=True)
response = ResponseHelper.error(status_code=422, message="Request validation error", detail=str(error.errors()))
return make_response(jsonify(response), 422)
elif hasattr(error, 'context') and error.context == 'response':
logger.error(f'Response validation error:\n{str(error.errors())} ', exc_info=True)
response = ResponseHelper.error(status_code=422, message="Response validation error", detail=str(error.errors()))
return make_response(jsonify(response), 422)

validation_check関数で再スローしたValidationErrorを受け取りここで処理する。

ここでは定義しているだけなので、
実際にアプリケーション内で発生したValidationErrorをこの関数で処理させるためには register_error_handler()メソッドでこの関数を登録する必要がある。
※後述する。

handle_custom_exception

CustomExceptionのエラーハンドラー関数。
アプリケーション内でCustomExceptionを発生させた場合は
すべてこの関数で処理されるようにする。

handle_custom_exception
# カスタムエクセプションハンドラー
def handle_custom_exception(error: CustomException):
logger.error(
f'HTTP status code: {error.status_code}\n '
f'Error message: {error.error_message}\n ',
exc_info=True
)
response = ResponseHelper.error(status_code=error.status_code, message=error.error_message)
return make_response(jsonify(response), error.status_code)

handle_validation_errorと同様にregister_error_handler()メソッドでこの関数を登録する必要がある。

handle_pymongo_error

PyMongoErrorのエラーハンドラー関数。
アプリケーション内でPyMongoErrorを発生させた場合は
すべてこの関数で処理されるようにする。

handle_pymongo_error
# PyMongoエラーハンドラー
def handle_pymongo_error(error: PyMongoError):
logger.error(
f'HTTP status code: {500}\n'
'Error message: Internal Server Error\n'
f'detail: {error.detail}',
exc_info=True
)
response = ResponseHelper.error(status_code=500, detail=error.detail, message='Internal Server Error')
return make_response(jsonify(response), 500)

handle_validation_errorと同様にregister_error_handler()メソッドでこの関数を登録する必要がある。

init_error_handling

エラーハンドラー関数を例外と紐づけて登録する関数。
アプリケーション初期処理で実行する。

init_error_handling
# エラーハンドリング初期化関数
def init_error_handling(app: Flask):
app.register_error_handler(ValidationError, handle_validation_error)
app.register_error_handler(CustomException, handle_custom_exception)
app.register_error_handler(PyMongoError, handle_pymongo_error)

エラーハンドラー関数と例外をセットで登録する。
これでエラーハンドラー関数がセットで登録した例外が発生したときに
実行されるようになる。

pymongo_error_wrapper

db操作関数のデコレーター関数。

pymongo_error_wrapper
# dbアクセスエラー用ラッパー
def pymongo_error_wrapper(func):
def wrapper(self, collection_name, *args, **kwargs):
try:
return func(self, collection_name, *args, **kwargs)
except PyMongoError as e:
e.detail = f"Failed to find documents in database '{self.db_name}', collection '{collection_name}'"
raise e
except Exception as e:
logger.error(f"An unexpected error occurred: {str(e)}")
raise
return wrapper

db操作関数に「@pymongo_error_wrapper」をつけることで db操作関数を引数として受けて、try~exeptで囲んで実行させることができる。
※database_model.pyでいちいちtry~exceptが囲むのがめんどうなので作成

app/exceptions.py

カスタム例外クラスを定義する

app/exceptions.py
class CustomException(Exception):
status_code: int
error_message: str
detail: str
def __init__(self, error_message: str):
self.error_message = error_message
class CustomBadRequestException(CustomException):
status_code = 400 # override field
class CustomNotFoundException(CustomException):
status_code = 404 # override field
class CustomInternalServerErrorException(CustomException):
status_code = 500 # override field

例外の種類ごとにカスタム例外クラスを継承したサブクラスを定義する。
業務ロジックの例外はすべてカスタム例外クラスで処理するようにした。

app/endpoints.py

ルートの設定とエンドポイントの定義をするルーターとしての役割と
ルーティングとビジネスロジックの橋渡しを行うコントローラーとしての役割を持たせた。

route.pyとcontroller.pyに分けることも考えたが
小規模なREST APIでは冗長になると判断し、統合した。

app/endpoints.py
import logging
from flask import Flask, jsonify, make_response, request
from pydantic import BaseModel, ValidationError
from app.schemas import Users
from app.user_service import UserService
from app.error_handling import validation_check
logger = logging.getLogger('apiLogger')
# アクセスログ出力用デコレーター
def access_log(func):
def log_wrapper(*args, **kwargs):
http_method = request.method
route = request.path
logger.info(f'Received {http_method} request for "{route}"')
# 関数を実行してレスポンスを取得
response, make_response = func(*args, **kwargs)
# レスポンスの詳細を辞書形式でログに記録
logger.info(f'Sending {http_method} response for \"{route}\" result: {response}"')
return make_response
return log_wrapper
class Endpoints:
def __init__(self, app: Flask):
self.app = app
self.user_service = UserService()
self.configure_routes()
def configure_routes(self):
# 全件取得
@self.app.route('/', methods=['GET'], endpoint='get_users')
@access_log
def get_users():
response = self.user_service.list_users()
return response, make_response(jsonify(response), 200)
# 指定取得
@self.app.route('/<user_id>', methods=['GET'], endpoint='get_user_by_id')
@access_log
def get_user_by_id(user_id):
response = self.user_service.get_user_by_id(user_id)
return response, make_response(jsonify(response), 200)
# 登録
@self.app.route('/register', methods=['POST'], endpoint='register_user')
@access_log
def register_user():
# リクエストボディをdictで取得
request_data = request.get_json()
# リクエストバリデーション実行
validated_data = validation_check(data=request_data, model=Users, target='request')
response = self.user_service.register_user(validated_data)
print(response)
return response, make_response(jsonify(response), 201)
# 更新
@self.app.route('/<user_id>', methods=['PUT'], endpoint='update_user')
@access_log
def update_user(user_id):
# リクエストボディをdictで取得
update_data = request.get_json()
# リクエストバリデーション実行
validated_data = validation_check(data=update_data, model=Users, target='request')
response = self.user_service.update_user(user_id, validated_data)
return response, make_response(jsonify(response), 200)
# 削除
@self.app.route('/<user_id>', methods=['DELETE'], endpoint='delete_user')
@access_log
def delete_user(user_id):
response = self.user_service.delete_user(user_id)
return response, make_response(jsonify(response), 200)
# エンドポイントの初期化処理を行うファクトリ関数
def configure_endpoints(app: Flask):
Endpoints(app)

細かく解説する。

access_log

access_log
# アクセスログ出力用デコレーター
def access_log(func):
def log_wrapper(*args, **kwargs):
http_method = request.method
route = request.path
logger.info(f'Received {http_method} request for "{route}"')
# 関数を実行してレスポンスを取得
response, make_response = func(*args, **kwargs)
# レスポンスの詳細を辞書形式でログに記録
logger.info(f'Sending {http_method} response for \"{route}\" result: {response}"')
return make_response
return log_wrapper

デコレーター関数
各エンドポイントにリクエストが来た際、処理の前後でログを出力する。
各エンドポイントにデコレーター(@access_log)をつけることで実行される。

response, make_response = func(*args, **kwargs)で戻り値を二つとっているのは
エンドポイントの戻りでmake_response関数を使うと辞書型でなくなってしまい。ログにレスポンスを綺麗に
だせないため。

Endpoints

Flaskアプリケーションのルートの設定とエンドポイントの定義を行う役割を持たせる。
ビジネスロジックやレスポンスの生成はサービス層(user_service.py)に任せることでエンドポイントは
結果をシンプルに返すようにする

こうした方がコードの可読性が高くなる

Endpoints
class Endpoints:
def __init__(self, app: Flask):
self.app = app
self.user_service = UserService()
self.configure_routes()
def configure_routes(self):
# 全件取得
@self.app.route('/', methods=['GET'], endpoint='get_users')
@access_log
def get_users():
response = self.user_service.list_users()
return response, make_response(jsonify(response), 200)
# 指定取得
@self.app.route('/<user_id>', methods=['GET'], endpoint='get_user_by_id')
@access_log
def get_user_by_id(user_id):
response = self.user_service.get_user_by_id(user_id)
return response, make_response(jsonify(response), 200)
# 登録
@self.app.route('/register', methods=['POST'], endpoint='register_user')
@access_log
def register_user():
# リクエストボディをdictで取得
request_data = request.get_json()
# リクエストバリデーション実行
validated_data = validation_check(data=request_data, model=Users, target='request')
response = self.user_service.register_user(validated_data)
print(response)
return response, make_response(jsonify(response), 201)
# 更新
@self.app.route('/<user_id>', methods=['PUT'], endpoint='update_user')
@access_log
def update_user(user_id):
# リクエストボディをdictで取得
update_data = request.get_json()
# リクエストバリデーション実行
validated_data = validation_check(data=update_data, model=Users, target='request')
response = self.user_service.update_user(user_id, validated_data)
return response, make_response(jsonify(response), 200)
# 削除
@self.app.route('/<user_id>', methods=['DELETE'], endpoint='delete_user')
@access_log
def delete_user(user_id):
response = self.user_service.delete_user(user_id)
return response, make_response(jsonify(response), 200)

各エンドポイントには@access_logをつけてログ出力を行うようにする。
@access_logをつけるとエンドポイントの名前がすべてlog_wrapperになり、エンドポイント衝突で
エラーになるので、endpoint引数で明示的にエンドポイントの名前を指定している。

リクエストボディのバリデーションチェック(登録処理、更新処理)はendpointsで実施する。
ValidationErrorが発生した場合はエラーハンドラー関数に処理が移る。

またreponseはmake_response関数を使ってレスポンスデータとステータスコードを指定して返却する。

configure_endpoints

Endpointsクラスのインスタンスを作成して、
Flask アプリケーション (app) に対してエンドポイントを設定するファクトリ関数

configure_endpoints
# エンドポイントの初期化処理を行うファクトリ関数
def configure_endpoints(app: Flask):
Endpoints(app)

アプリケーション初期処理に、この関数を呼び出すだけで、
Flask アプリケーションに必要なエンドポイントがすべて設定される

app/schemas.py

バリデーションを行うため
pydanticのBaseModelクラスを継承したデータモデルを定義する

app/schemas.py
from pydantic import BaseModel, Field, field_validator
from typing import Optional, List
# データモデル定義
class Users(BaseModel):
user_id: str
name: str = Field(..., min_length=1, max_length=50)
age: Optional[int] = Field(None, ge=0, le=120)
# カスタムバリデーション
@field_validator('name')
def name_must_be_capitalized(cls, v):
if not v.istitle():
raise ValueError('名前は大文字で始める必要があります')
return v
# データモデルリストの定義
class UsersList(BaseModel):
users: Optional[List[Users]] = []

定義した

  • Users
  • UsersList

のデータモデルクラスを使ってリクエストとレスポンスのバリデーションを実施する。

pydanticを使ったバリデーションについては下記でまとめています

app/user_service.py

ビジネスロジックを処理する役割を持たせる

ここでは

  • データベースモデルのインスタンス作成
  • インスタンスを使ったデータの操作(CRUD処理)
  • ロジックエラーの判定
  • レスポンスの作成(統一)

を行う。

app/user_service.py
from app.database_model import DatabaseModel
from app.response_helper import ResponseHelper
from app.error_handling import validation_check
from app.schemas import Users, UsersList
from app.exceptions import CustomNotFoundException
class UserService:
def __init__(self):
self.model = DatabaseModel('SAMPLE_DB') # データベース名を指定してインスタンス化
# 全件取得
def list_users(self):
user_list = self.model.find_documents('USERS_COLLECTION')
# レスポンスバリデーション
# データモデルリストのバリデーション
validated_data = validation_check(data={"users": user_list}, model=UsersList, target='response')
return ResponseHelper.success(status_code=200, data=validated_data)
# 指定取得
def get_user_by_id(self, user_id):
user_detail = self.model.find_document_by_id('USERS_COLLECTION', user_id)
if not user_detail:
raise CustomNotFoundException(error_message="ユーザーが見つかりません")
# レスポンスバリデーション
validated_data = validation_check(data=user_detail, model=Users, target='response')
return ResponseHelper.success(status_code=200, data=validated_data)
# 登録
def register_user(self, user_data):
self.model.insert_document('USERS_COLLECTION', user_data)
# `_id` フィールドを削除
user_data.pop('_id', None)
# レスポンスバリデーション
validated_data = validation_check(data=user_data, model=Users, target='response')
return ResponseHelper.success(status_code=201,message=f"ユーザー登録が成功しました。user_id: {user_data['user_id']}", data=validated_data)
# 更新
def update_user(self, user_id, update_data):
result = self.model.update_document('USERS_COLLECTION', user_id, update_data)
if result.matched_count == 0:
raise CustomNotFoundException(error_message="ユーザーが見つかりません")
return ResponseHelper.success(status_code=200,message=f"ユーザー情報が更新されました。user_id: {user_id}", data={'update_cnt':result.matched_count})
# 削除
def delete_user(self, user_id):
result = self.model.delete_document('USERS_COLLECTION', user_id)
if result.deleted_count == 0:
raise CustomNotFoundException(error_message="ユーザーが見つかりません")
return ResponseHelper.success(status_code=200,message=f"ユーザーが削除されました。user_id: {user_id}", data={'delete_cnt':result.deleted_count})

細かく解説する。

データベースモデルのインスタンス作成

init
class UserService:
def __init__(self):
self.model = DatabaseModel('SAMPLE_DB') # データベース名を指定してインスタンス化

データベース名を指定してインスタンス作成を行う。 データベースごとにサービスとデータモデルのインスタンスが作られる。

インスタンスを使ったデータの操作(CRUD処理)

作成したデータベースのインスタンスに対して
コレクション名等を指定して操作を行う。
※処理によって必要な引数は異なるがコレクション名は必須。

ロジックエラーの判定

例として指定参照の関数をあげる。

get_user_by_id
# 指定取得
def get_user_by_id(self, user_id):
user_detail = self.model.find_document_by_id('USERS_COLLECTION', user_id)
if not user_detail:
raise CustomNotFoundException(error_message="ユーザーが見つかりません")
# レスポンスバリデーション
validated_data = validation_check(data=user_detail, model=Users, target='response')
return ResponseHelper.success(status_code=200, data=validated_data)

指定したデータがなかった場合はロジックエラーとしてCustomNotFoundExceptionを発生させる。
その後はエラーハンドラー関数として登録したhandle_custom_exception関数で処理される。

validation_checkでエラーになった場合は同様にhandle_validation_error関数で処理される

これによりuser_service.pyからendpoints.pyには正常系の値しか返らなくなる。
※エラーをすべてuser_service.pyで処理するため

そのためendpoints.pyでは異常系の分岐を書く必要がなく、正常系のみとなっている。

レスポンスの作成(統一)

例として指定参照の関数をあげる。

get_user_by_id
# 指定取得
def get_user_by_id(self, user_id):
user_detail = self.model.find_document_by_id('USERS_COLLECTION', user_id)
if not user_detail:
raise CustomNotFoundException(error_message="ユーザーが見つかりません")
# レスポンスバリデーション
validated_data = validation_check(data=user_detail, model=Users, target='response')
return ResponseHelper.success(status_code=200, data=validated_data)

ResponseHelperクラスを使ってレスポンスの形を統一している。

レスポンスの統一はendpointsで行うことも考えたがエンドポイントはなるべく
ルートの設定とエンドポイントの定義を役割として、シンプルにしたかったのでこちらで行うようにした。

app/response_helper.py

レスポンスの統一をする役割を持つクラスを定義する。
user_service.pyの返却値とエラーハンドラー関数の返却値を
このクラスのオブジェクトにすることでレスポンスを統一させる。

app/response_helper.py
from pydantic import BaseModel
# データモデルとして定義
class Response(BaseModel):
status_code: int
message: str | None = None
data: dict | None = None
detail: str | None = None
class ResponseHelper:
@staticmethod
def success(status_code:int, message="Operation successful", data=None, detail=None):
return Response(status_code=status_code, message=message, data=data, detail=detail).model_dump()
@staticmethod
def error(status_code:int, message="An error occurred", data=None, detail=None):
return Response(status_code=status_code, message=message, data=data, detail=detail).model_dump()

ResponseHelperクラスのメソッドは入力データに基づいてレスポンスを生成するだけで、
クラスの状態や属性にアクセスしない。
そのため、メソッドを@staticmethodにして、インスタンス化なしに直接呼び出せるようにしている。

またResponseクラスをpydanticのBaseModelを継承してつくりことで
簡単に辞書型へのシリアライズ(model_dump)ができるようにした。

app/database_model.py

DatabaseModelクラスは、MongoDBデータベースの操作を抽象化し、
共通のデータベース操作を簡単に行えるようにする役割を持たせる。

app/database_model.py
from app.database import Database
from app.error_handling import pymongo_error_wrapper
class DatabaseModel:
# dbごとにインスタンスを作成
def __init__(self, db_name):
self.db = Database.client[db_name]
self.db_name = db_name
# 全件取得
@pymongo_error_wrapper
def find_documents(self, collection_name, query={}, projection={'_id': 0}):
collection = self.db[collection_name]
return list(collection.find(query, projection))
# 指定取得
@pymongo_error_wrapper
def find_document_by_id(self, collection_name, document_id, projection={'_id': 0}):
collection = self.db[collection_name]
return collection.find_one({"user_id": document_id}, projection)
# 登録
@pymongo_error_wrapper
def insert_document(self, collection_name, document):
collection = self.db[collection_name]
return collection.insert_one(document)
# 削除
@pymongo_error_wrapper
def delete_document(self, collection_name, document_id):
collection = self.db[collection_name]
return collection.delete_one({"user_id": document_id})
# 更新
@pymongo_error_wrapper
def update_document(self, collection_name, document_id, update_data):
collection = self.db[collection_name]
return collection.update_one(
{"user_id": document_id},
{"$set": update_data}
)

pymongo_error_wrapperデコレーターを適用することで
MongoDB操作中に発生するエラーはエラーハンドラー関数のhandle_pymongo_errorで処理させる
※try~exceptを書かなくていい!

このクラスは指定されたデータべースに対して下記のような操作を行う。

全件取得

find_documents
# 全件取得
@pymongo_error_wrapper
def find_documents(self, collection_name, query={}, projection={'_id': 0}):
collection = self.db[collection_name]
return list(collection.find(query, projection))
  • collection_name:コレクション名
  • query:ドキュメントを指定するためのフィルタ条件
    • 全件取得なのでデフォルト値として{}を指定
  • projection:取得したドキュメントの中で、どのフィールドを含めるか、または除外するかを指定する
    • _id フィールドはMongoDBで自動生成されるユニークな識別子だがシリアライズ失敗するため除外する。

今回はfind_documentsを全件取得用として使うが
引数(queryとprojection)を指定して呼び出せば、柔軟なデータ取得ができる。

例えば下記のような使い方ができる

find_documents
# 年齢が30歳以上のユーザーの `name` と `email` フィールドを取得
documents = database_model.find_documents(
collection_name="users",
query={"age": {"$gte": 30}}, # 年齢が30以上の条件
projection={"name": 1, "email": 1, "_id": 0}
)

1と0はMongoDB のクエリにおいてboolean のように使われる

指定取得

find_document_by_id
# 指定取得
@pymongo_error_wrapper
def find_document_by_id(self, collection_name, document_id, projection={'_id': 0}):
collection = self.db[collection_name]
return collection.find_one({"user_id": document_id}, projection)
  • collection_name:コレクション名
  • document_id:user_idとして条件に使う
  • projection:取得したドキュメントの中で、どのフィールドを含めるか、または除外するかを指定する
    • _id フィールドはMongoDBで自動生成されるユニークな識別子だがシリアライズ失敗するため除外する。

登録

insert_document
# 登録
@pymongo_error_wrapper
def insert_document(self, collection_name, document):
collection = self.db[collection_name]
return collection.insert_one(document)
  • collection_name:コレクション名
  • document:登録するデータ

削除

delete_document
# 削除
@pymongo_error_wrapper
def delete_document(self, collection_name, document_id):
collection = self.db[collection_name]
return collection.delete_one({"user_id": document_id})
  • collection_name:コレクション名
  • document_id:user_idとして条件に使う

更新

update_document
# 更新
@pymongo_error_wrapper
def update_document(self, collection_name, document_id, update_data):
collection = self.db[collection_name]
return collection.update_one(
{"user_id": document_id},
{"$set": update_data}
)
  • collection_name:コレクション名
  • document_id:user_idとして条件に使う
  • update_data:更新データ

MongoDBのクエリに関しては下記で詳しく載せてます

サーバー起動

実装したREST APIをサーバで動かす方法をまとめる。

  • gunicornサーバー
  • flaskのビルトインサーバー

の両方で動かしてみる。 ※個人的にはgunicornのが早いのでおすすめ。

gunicornで起動

gunicorn
gunicorn wsgi:api --config gunicorn_config.py

で起動できる。

起動時にWSGIサーバー(またはflaskのビルトインサーバー)で起動するためのエントリーポイント
であるwsgiモジュールのapi(flaskアプリケーションインスタンス)を指定する。

また起動時に設定は「gunicorn_config.py」を読み込ませる

gunicorn_config.py
# ワーカープロセスの数
workers = 1
# バインドするホストとポート
bind = '0.0.0.0:5000'
# リロードオプションを有効にする
reload = True
# ディレクトリの変更
chdir = '/workspace/app'
# access log
accesslog = '/workspace/log/gunicorn_access.log'
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
# gunicorn log
errorlog = '/workspace/log/gunicorn_error.log'
loglevel = 'info'
  • アクセスログは、クライアントからのリクエストに関する情報を記録する
  • エラーログは、アプリケーションやサーバーで発生したエラーや重要なメッセージを記録する

flaskのビルトインサーバー起動

.envで環境変数として

.evn
# Flaskがどのアプリケーションを実行するか指定(pyファイル:インスタンス)
FLASK_APP=app.wsgi:api
# Flaskがどの環境で動作するか指定
FLASK_ENV=development
#デバッグモードの指定
FLASK_DEBUG=true

を設定しているため

flaskサーバー
flask run --host=0.0.0.0

で起動できる。

環境変数がない場合は

flaskサーバー
flask --app app.wsgi:api --debug run --host=0.0.0.0

のように起動時にオプションを指定する必要がある。
コンテナで起動する場合は「--host=0.0.0.0」は必須

VSCodeのタスク登録する

いちいちcommandうつのが面倒なのでタスク登録しておく

.vscode/tasks.json
{
"version": "2.0.0",
"tasks": [
{
"label": "Flask Run",
"type": "shell",
"command": "flask run --host=0.0.0.0",
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"reveal": "always"
},
"problemMatcher": []
},
{
"label": "Run Gunicorn",
"type": "shell",
"command": "gunicorn",
"args": [
"wsgi:api",
"--config",
"gunicorn_config.py"
],
"group": {
"kind": "build",
"isDefault": true
}
}
]
}

これで「F1」でコマンドパレットを開いて、「タスクの実行」で選択すれば
すぐ起動できる

デバッグ

VSCodedでデバッグするためのlaunch.jsonも作成する。

.vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "Flask debugger",
"type": "debugpy",
"request": "launch",
"module": "flask",
"args": [
"run",
"--reload",
"--host=0.0.0.0"
],
"jinja": true
},
{
"name": "Gunicorn debugger",
"type": "debugpy",
"request": "launch",
"module": "gunicorn",
"args": [
"--config",
"gunicorn_config.py",
"wsgi:api"
],
"jinja": true
}
]
}

gunicornとflaskのビルトインサーバーでデバッグできるようになる

下記で詳しくまめています

まとめ

実践編なのでPythonのFlaskを使ってREST APIを少し構造などを考え
なるべくシンプルでわかりやすいコードになるよう作ってみた。

開発環境構築から実装、サーバー起動、デバッグの方法をまとめたので
一通りは動くものが作れると思うので参考になればありがたいです。

新着記事

目次
タグ別一覧
top