当サイトは、アフィリエイト広告を利用しています
Pythonの軽量フレームワークであるflaskのREST APIをdockerコンテナ環境を使って 開発する。
コンテナ環境でflask製REST APIの開発の基本については
下記記事でまとめているので、FlaskのREST APIがよくわからない人は
先に見てほしい。
当記事は実践編ということで、開発をする際に検討する必要がある
について深堀りして、実際に開発に使えるREST APIを実装する
作成するREST APIは大規模なものではなくマイクロサービスで使うような
小規模のREST APIを作る。
また開発をするための
についてもまとめておく。
HTTPリクエストを用いてデータベースに対してCRUD操作を行う。
下記のCRUD処理を実装する
開発環境を含めた技術スタックは下記にする。
細かく解説する
Docker version 24.0.2(Docker for Windows)を使う。
開発はdocker-composeで作成したDockerコンテナ上で行う。
コンテナ上で行うことでライブラリなどのインストールでローカルが汚れない。
またdocker-compose.ymlがあればどこでも同じ開発環境を準備できる。
Docker for Windowsのインストール方法については
下記でまとめています。
開発はVSCodeからリモートでコンテナに接続し
コンテナ上で行う。
VSCodeの拡張機能である。
のどちらかを使用すれば、コンテナ内をVSCodeから操作できるので
開発しやすい。
Dev Containersの使い方については下記でまとめています。
REST APIはPythonの軽量フレームワークであるflaskで開発する
データベースはNoSQLのドキュメント指向型のDBで
JSONやXMLといったデータ形式で記述されたドキュメントの形でデータを管理できる
MongoDBを使う。
MongoDBに関しては下記記事でまとめています
mongo-expressはNode.jsとExpressを使用して書かれているWebベースのMongoDB管理インターフェース。
データベースやコレクションの表示、追加、削除、ドキュメントの編集などを行うことができる。
実装とは関係ないが、データの確認や編集はmongo-expressを使うと
簡単にできる。
mongo-expressの使い方については下記でまとめています
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でコンテナ開発環境を作成する
REST APIのソースはGitで管理する。
不要なソースは追跡しないようにする。
**/mongo/****/__pycache__/****/log/**
# 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接続用URLMONGO_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でコンテナ作成時にコンテナの環境変数に設定する値を定義する。
それぞれの値はコメントの通り。
を設定する。
コンテナに環境変数を設定する方法について詳しくは下記でまとめている。
REST APIの実装やサーバー起動に必要なライブラリを記載する
Flask==3.0.2pymongo==4.8.0pydantic==2.8.2gunicorn==21.2.0
Dockerfileで読み込ませてインストールする
REST APIの実装に必要な依存関係をインストールしたDockerイメージを作成する
FROM python:3.12# workspaceディレクトリ作成、移動WORKDIR /workspace# プロジェクトディレクトリにコピーCOPY requirements.txt /workspace# 必要モジュールのインストールRUN pip install --upgrade pipRUN pip install -r requirements.txt
Dockerイメージはpython:3.12をベースにする。
必要なライブラリ等はrequirements.txtを読み込んでインストールする。
docker-composeを使って
の3つのコンテナを作成する。
version: "3"services:flask-api:container_name: "flask-api"build:context: .dockerfile: Dockerfileports:- "5000:5000"volumes:# バインドマウント- .:/workspace# 環境変数読み込みenv_file:- .envtty: truenetworks:flaskmongo_network:ipv4_address: 172.25.2.2# MongoDBコンテナmongodb:container_name: "mongodb"restart: alwaysimage: mongo:6.0.13ports:- "27017:27017"volumes:- ./mongo/init:/docker-entrypoint-initdb.d # 初期化スクリプト- mongoDataStore:/data/db # MongoDBのデータファイル# - ./mongo/db:/data/dbenv_file:- .envnetworks:flaskmongo_network:ipv4_address: 172.25.2.3# mongo_expressコンテナmongo_express:container_name: "mongo_express"image: mongo-express:1.0.2restart: alwaysports:- "8081:8081"env_file:- .envdepends_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: alwaysimage: mongo:6.0.13ports:- "27017:27017"volumes:- ./mongo/init:/docker-entrypoint-initdb.d # 初期化スクリプト- mongoDataStore:/data/db # MongoDBのデータファイル# - ./mongo/db:/data/dbenv_file:- .envnetworks: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: alwaysimage: mongo:6.0.13ports:- "27017:27017"volumes:- ./mongo/init:/docker-entrypoint-initdb.d # 初期化スクリプト- mongoDataStore:/data/db # MongoDBのデータファイル# - ./mongo/db:/data/dbenv_file:- .envnetworks:flaskmongo_network:ipv4_address: 172.25.2.3~~省略~~# 名前付きボリュームvolumes:mongoDataStore:name: mongoDataStore
dockerのボリュームに関しては下記でまとめている
バインドマウントにしたい場合はコメントアウトしている「./mongo/db:/data/db」
を活性化して「mongoDataStore:/data/db」をコメントアウトする。
MongoDB用のフォルダ
MongoDBのデータの保存場所。 バインドマウントしない場合は未使用。
// 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内に
を作り、その中に
を作り、ドキュメントを1件挿入している
※MongoDBはデータベースに少なくも1つのコレクションをがないとデータベースとして認識しないので注意
#!/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[@]}"; docode --install-extension $extensiondone
開発に必要なVSCodeの拡張機能をスクリプトを使って一括でインストールする。
VSCodeの拡張機能「Dev Containers」を使用している場合は
devcontainer.jsonに書けばコンテナ作成時に自動でインストールしてくれるが
拡張機能「Docker」を使っている場合は自動ではインストールしてくれないので
スクリプトで一括でインストールする
詳しくは下記記事参照
FlaskでREST APIを実装していく。
REST APIは下記の方針で実装する。
それぞれのモジュールが特定の役割を果たすようにする。
こうすることでコードの理解とメンテナンスが容易になる
最低限のもの以外はFlask依存のライブラリを使用しないようにした。
こうすることでFaskAPIなどの別のフレームワークへも比較的容易に移行することができる。
※FastAPIでもそのうち作りたいので...
flaskアプリケーションの初期化処理を行う役割を持たせる。
create_app関数を定義し、アプリケーションファクトリパターンで
アプリケーションの作成を行う。
from flask import Flaskimport loggingfrom app.database import Databasefrom app.endpoints import configure_endpointsfrom app.error_handling import init_error_handlingfrom app.logger import logger_initdef 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アプリケーションをWSGIサーバー(またはflaskのビルトインサーバー)で起動するための
エントリーポイントとして機能すること。
from app.api import create_appapi = 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アプリケーションインスタンス)を
渡すようにコンテナの環境変数で設定している
※詳しくはサーバー起動のところでまとめる
Pythonの標準ロギング機能を使ってロギング設定を辞書形式で定義し、
アプリケーション全体で統一されたロギングを行うための設定をまとめて管理する役割がある。
import loggingimport 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の標準ロギング機能については下記で詳しくまとめています
アプリケーションの設定を一元管理し、環境変数等からMongoDB接続情報などを取得してアプリケーション内で
利用できるようにする役割を持つ。
import osclass Config:# コンテナ環境変数よりmongodb接続情報取得MONGO_URI = os.environ.get('MONGO_URI')
今回はコンテナの環境変数からmongodb接続情報取得しているのみだが
他に外部ファイルで設定しているもの(APIキー,ファイルパス等)があった場合はここで読み込み一元管理する
アプリケーション全体で共有されるデータベース接続の役割を持たせ
データベース接続を一元管理する。
from flask import Flaskfrom pymongo import MongoClientimport loggingfrom app.config import Configlogger = logging.getLogger('apiLogger')class Database:# db接続クライアントclient = None@classmethoddef init_db(cls):# mogogoDBサーバーに接続try:cls.client = MongoClient(Config.MONGO_URI)except Exception as e:# logginglogger.error(f'An error occurred while inithializing the database: {e}')raise
アプリケーション全体で使用するMongoDBの接続を管理するため、シングルトンパターンでクラスを作成する。
他のモジュールやクラスはこのデータベース接続(client)を使用してデータベース操作を行う。
init_dbはFlaskアプリケーションの初期化時に呼び出され、MongoDBへの接続を確立する。
Flaskアプリケーションでのエラーハンドリングを統一的に行うための設定と処理をまとめて管理する役割。
from flask import Flask, jsonify, make_responseimport loggingfrom pydantic import ValidationError, BaseModelfrom typing import Typefrom pymongo.errors import PyMongoErrorfrom app.exceptions import CustomExceptionfrom app.response_helper import ResponseHelperlogger = 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 eexcept Exception as e:logger.error(f"An unexpected error occurred: {str(e)}")raisereturn 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)
少し細かく解説する。
# バリデーションチェック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を使って
のバリデーションチェックを行う関数。
引数としては
を受け取り、バリデーションチェックを行う。
バリデーションエラーが発生しない場合は
データモデルクラスオブジェクトを辞書型にして返却する。
※データはバリデーションチェック時( model(**data))にデータモデルクラスオブジェクトに自動で変換される
バリデーションエラーが発生した場合は
エラーオブジェクトにコンテキスト情報として、リクエストかレスポンスか
の情報を付与して再スローする。
※ValidationErrorの情報だけではリクエストかレスポンスのどちらで発生したのか
判断できないため
pydanticによるバリデーションを行う方法については下記でもまとめています
ValidationErrorのエラーハンドラー関数。
アプリケーション内でValidationErrorが発生した(発生させた)場合は
すべてこの関数で処理されるようにする。
# バリデーションエラーハンドラー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()メソッドでこの関数を登録する必要がある。
※後述する。
CustomExceptionのエラーハンドラー関数。
アプリケーション内でCustomExceptionを発生させた場合は
すべてこの関数で処理されるようにする。
# カスタムエクセプションハンドラー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()メソッドでこの関数を登録する必要がある。
PyMongoErrorのエラーハンドラー関数。
アプリケーション内でPyMongoErrorを発生させた場合は
すべてこの関数で処理されるようにする。
# 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()メソッドでこの関数を登録する必要がある。
エラーハンドラー関数を例外と紐づけて登録する関数。
アプリケーション初期処理で実行する。
# エラーハンドリング初期化関数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)
エラーハンドラー関数と例外をセットで登録する。
これでエラーハンドラー関数がセットで登録した例外が発生したときに
実行されるようになる。
db操作関数のデコレーター関数。
# 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 eexcept Exception as e:logger.error(f"An unexpected error occurred: {str(e)}")raisereturn wrapper
db操作関数に「@pymongo_error_wrapper」をつけることで
db操作関数を引数として受けて、try~exeptで囲んで実行させることができる。
※database_model.pyでいちいちtry~exceptが囲むのがめんどうなので作成
カスタム例外クラスを定義する
class CustomException(Exception):status_code: interror_message: strdetail: strdef __init__(self, error_message: str):self.error_message = error_messageclass CustomBadRequestException(CustomException):status_code = 400 # override fieldclass CustomNotFoundException(CustomException):status_code = 404 # override fieldclass CustomInternalServerErrorException(CustomException):status_code = 500 # override field
例外の種類ごとにカスタム例外クラスを継承したサブクラスを定義する。
業務ロジックの例外はすべてカスタム例外クラスで処理するようにした。
ルートの設定とエンドポイントの定義をするルーターとしての役割と
ルーティングとビジネスロジックの橋渡しを行うコントローラーとしての役割を持たせた。
route.pyとcontroller.pyに分けることも考えたが
小規模なREST APIでは冗長になると判断し、統合した。
import loggingfrom flask import Flask, jsonify, make_response, requestfrom pydantic import BaseModel, ValidationErrorfrom app.schemas import Usersfrom app.user_service import UserServicefrom app.error_handling import validation_checklogger = logging.getLogger('apiLogger')# アクセスログ出力用デコレーターdef access_log(func):def log_wrapper(*args, **kwargs):http_method = request.methodroute = request.pathlogger.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_responsereturn log_wrapperclass Endpoints:def __init__(self, app: Flask):self.app = appself.user_service = UserService()self.configure_routes()def configure_routes(self):# 全件取得@self.app.route('/', methods=['GET'], endpoint='get_users')@access_logdef 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_logdef 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_logdef 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_logdef 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_logdef 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)
細かく解説する。
# アクセスログ出力用デコレーターdef access_log(func):def log_wrapper(*args, **kwargs):http_method = request.methodroute = request.pathlogger.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_responsereturn log_wrapper
デコレーター関数
各エンドポイントにリクエストが来た際、処理の前後でログを出力する。
各エンドポイントにデコレーター(@access_log)をつけることで実行される。
response, make_response = func(*args, **kwargs)で戻り値を二つとっているのは
エンドポイントの戻りでmake_response関数を使うと辞書型でなくなってしまい。ログにレスポンスを綺麗に
だせないため。
Flaskアプリケーションのルートの設定とエンドポイントの定義を行う役割を持たせる。
ビジネスロジックやレスポンスの生成はサービス層(user_service.py)に任せることでエンドポイントは
結果をシンプルに返すようにする
こうした方がコードの可読性が高くなる
class Endpoints:def __init__(self, app: Flask):self.app = appself.user_service = UserService()self.configure_routes()def configure_routes(self):# 全件取得@self.app.route('/', methods=['GET'], endpoint='get_users')@access_logdef 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_logdef 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_logdef 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_logdef 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_logdef 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関数を使ってレスポンスデータとステータスコードを指定して返却する。
Endpointsクラスのインスタンスを作成して、
Flask アプリケーション (app) に対してエンドポイントを設定するファクトリ関数
# エンドポイントの初期化処理を行うファクトリ関数def configure_endpoints(app: Flask):Endpoints(app)
アプリケーション初期処理に、この関数を呼び出すだけで、
Flask アプリケーションに必要なエンドポイントがすべて設定される
バリデーションを行うため
pydanticのBaseModelクラスを継承したデータモデルを定義する
from pydantic import BaseModel, Field, field_validatorfrom typing import Optional, List# データモデル定義class Users(BaseModel):user_id: strname: 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]] = []
定義した
のデータモデルクラスを使ってリクエストとレスポンスのバリデーションを実施する。
pydanticを使ったバリデーションについては下記でまとめています
ビジネスロジックを処理する役割を持たせる
ここでは
を行う。
from app.database_model import DatabaseModelfrom app.response_helper import ResponseHelperfrom app.error_handling import validation_checkfrom app.schemas import Users, UsersListfrom app.exceptions import CustomNotFoundExceptionclass 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})
細かく解説する。
class UserService:def __init__(self):self.model = DatabaseModel('SAMPLE_DB') # データベース名を指定してインスタンス化
データベース名を指定してインスタンス作成を行う。 データベースごとにサービスとデータモデルのインスタンスが作られる。
作成したデータベースのインスタンスに対して
コレクション名等を指定して操作を行う。
※処理によって必要な引数は異なるがコレクション名は必須。
例として指定参照の関数をあげる。
# 指定取得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では異常系の分岐を書く必要がなく、正常系のみとなっている。
例として指定参照の関数をあげる。
# 指定取得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で行うことも考えたがエンドポイントはなるべく
ルートの設定とエンドポイントの定義を役割として、シンプルにしたかったのでこちらで行うようにした。
レスポンスの統一をする役割を持つクラスを定義する。
user_service.pyの返却値とエラーハンドラー関数の返却値を
このクラスのオブジェクトにすることでレスポンスを統一させる。
from pydantic import BaseModel# データモデルとして定義class Response(BaseModel):status_code: intmessage: str | None = Nonedata: dict | None = Nonedetail: str | None = Noneclass ResponseHelper:@staticmethoddef 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()@staticmethoddef 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)ができるようにした。
DatabaseModelクラスは、MongoDBデータベースの操作を抽象化し、
共通のデータベース操作を簡単に行えるようにする役割を持たせる。
from app.database import Databasefrom app.error_handling import pymongo_error_wrapperclass DatabaseModel:# dbごとにインスタンスを作成def __init__(self, db_name):self.db = Database.client[db_name]self.db_name = db_name# 全件取得@pymongo_error_wrapperdef find_documents(self, collection_name, query={}, projection={'_id': 0}):collection = self.db[collection_name]return list(collection.find(query, projection))# 指定取得@pymongo_error_wrapperdef 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_wrapperdef insert_document(self, collection_name, document):collection = self.db[collection_name]return collection.insert_one(document)# 削除@pymongo_error_wrapperdef delete_document(self, collection_name, document_id):collection = self.db[collection_name]return collection.delete_one({"user_id": document_id})# 更新@pymongo_error_wrapperdef 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を書かなくていい!
このクラスは指定されたデータべースに対して下記のような操作を行う。
# 全件取得@pymongo_error_wrapperdef find_documents(self, collection_name, query={}, projection={'_id': 0}):collection = self.db[collection_name]return list(collection.find(query, projection))
今回はfind_documentsを全件取得用として使うが
引数(queryとprojection)を指定して呼び出せば、柔軟なデータ取得ができる。
例えば下記のような使い方ができる
# 年齢が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 のように使われる
# 指定取得@pymongo_error_wrapperdef 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_wrapperdef insert_document(self, collection_name, document):collection = self.db[collection_name]return collection.insert_one(document)
# 削除@pymongo_error_wrapperdef delete_document(self, collection_name, document_id):collection = self.db[collection_name]return collection.delete_one({"user_id": document_id})
# 更新@pymongo_error_wrapperdef 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})
MongoDBのクエリに関しては下記で詳しく載せてます
実装したREST APIをサーバで動かす方法をまとめる。
の両方で動かしてみる。 ※個人的にはgunicornのが早いのでおすすめ。
gunicorn wsgi:api --config gunicorn_config.py
で起動できる。
起動時にWSGIサーバー(またはflaskのビルトインサーバー)で起動するためのエントリーポイント
であるwsgiモジュールのapi(flaskアプリケーションインスタンス)を指定する。
また起動時に設定は「gunicorn_config.py」を読み込ませる
# ワーカープロセスの数workers = 1# バインドするホストとポートbind = '0.0.0.0:5000'# リロードオプションを有効にするreload = True# ディレクトリの変更chdir = '/workspace/app'# access logaccesslog = '/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 logerrorlog = '/workspace/log/gunicorn_error.log'loglevel = 'info'
.envで環境変数として
# Flaskがどのアプリケーションを実行するか指定(pyファイル:インスタンス)FLASK_APP=app.wsgi:api# Flaskがどの環境で動作するか指定FLASK_ENV=development#デバッグモードの指定FLASK_DEBUG=true
を設定しているため
flask run --host=0.0.0.0
で起動できる。
環境変数がない場合は
flask --app app.wsgi:api --debug run --host=0.0.0.0
のように起動時にオプションを指定する必要がある。
コンテナで起動する場合は「--host=0.0.0.0」は必須
いちいちcommandうつのが面倒なのでタスク登録しておく
{"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も作成する。
{"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を少し構造などを考え
なるべくシンプルでわかりやすいコードになるよう作ってみた。
開発環境構築から実装、サーバー起動、デバッグの方法をまとめたので
一通りは動くものが作れると思うので参考になればありがたいです。