Azure FunctionsでBluePrint対応を行う
この記事はAzure Functions(Python版)をリファクタリングしてBluePrint対応するための実装メモです。
Azure FunctionsはちょっとしたPythonAPIを動かすのにちょうど良いFaaS(Function as a Service)です。
しかし毎回APIを作るために func init
していたのでは、無尽蔵にリポジトリが増えて管理コストが上がってしまいます。
今回はBluePrintを使って疎結合な形で複数API対応に向けたリファクタリングを行っていきます。
過去に作成したAzure Functionsについては下記の通り。
- 基本編
- Azure Functions編
今回はAzure Functions編のため、作業ディレクトリは下記になります。
~/devel/sandbox_azure_functions
1. 現状確認
1.1. 最初にGREENであることを確認する
リファクタリングを行うに当たって、まずはテストが全て通ることを確認します。
- ユニットテスト+カバレッジの確認を実行します実行コマンド
hatch run cov
出力結果cmd [1] | coverage run -m pytest tests =========================== test session starts ============================ platform darwin -- Python 3.11.6, pytest-7.4.4, pluggy-1.3.0 rootdir: ~/devel/sandbox_azure_functions configfile: pyproject.toml collected 4 items tests/test_function_app.py .... [100%] ============================ 4 passed in 0.09s ============================= cmd [2] | - coverage combine Combined data file .coverage.M1.local.56110.XVxUrLCx cmd [3] | coverage report -m Name Stmts Miss Branch BrPart Cover Missing ------------------------------------------------------------- function_app.py 18 0 6 0 100% ------------------------------------------------------------- TOTAL 18 0 6 0 100%
- ユニットテストが成功し、カバレッジも100%になっていることが確認できました
- Lintチェックを実行します実行コマンド
hatch run style:check
出力結果cmd [1] | pflake8 . cmd [2] | black --check --diff . All done! ✨ 🍰 ✨ 7 files would be left unchanged. cmd [3] | isort --check-only --diff . Skipped 4 files
- ここでも問題ないことが確認できました
- インテグレーションテストのために、別のターミナルを開いてAzure Functionsを起動します(すでに起動している場合、
func start
は不要)実行コマンドfunc start
出力結果Found Python version 3.11.6 (python3). Azure Functions Core Tools Core Tools Version: 4.0.5455 Commit hash: N/A (64-bit) Function Runtime Version: 4.27.5.21554 [2024-01-04T15:30:51.211Z] Customer packages not in sys path. This should never happen! [2024-01-04T15:30:51.762Z] Worker process started and initialized. Functions: MyHttpTrigger: http://localhost:7071/api/MyHttpTrigger For detailed output, run func with --verbose flag. [2024-01-04T15:30:56.711Z] Host lock lease acquired by instance ID '0000000000000000000000007DB202B9'.
- 元のターミナルに戻ってインテグレーションテストを実行します実行コマンド
hatch run integration:all
出力結果機能: メッセージAPI # features/message_api.feature:3 シナリオ: HappyPath # features/message_api.feature:5 前提 ポート"7071"でサーバーが起動していること # features/steps/steps.py:8 0.001s もし "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する # features/steps/steps.py:32 0.011s ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている # features/steps/steps.py:43 0.000s """ {"message": "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."} """ シナリオ: リクエストパラメータにnameを含んでいるとき、レスポンスにnameの値が含まれている # features/message_api.feature:13 前提 ポート"7071"でサーバーが起動していること # features/steps/steps.py:8 0.000s もし リクエストパラメータに下記を設定する # features/steps/steps.py:27 0.000s | key | value | | name | HOGE | かつ "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する # features/steps/steps.py:32 0.005s ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている # features/steps/steps.py:43 0.000s """ {"message": "Hello, HOGE. This HTTP triggered function executed successfully."} """ シナリオ: リクエストボディにJSON形式でnameを含んでいるとき、レスポンスにnameの値が含まれている # features/message_api.feature:24 前提 ポート"7071"でサーバーが起動していること # features/steps/steps.py:8 0.000s もし Content-Typeに"application/json"を、リクエストボディに下記を設定する # features/steps/steps.py:19 0.000s """ {"name": "FUGA"} """ かつ "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する # features/steps/steps.py:32 0.004s ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている # features/steps/steps.py:43 0.000s """ {"message": "Hello, FUGA. This HTTP triggered function executed successfully."} """ シナリオ: リクエストボディに不完全なJSON形式を含んでいるとき、リクエストボディは無視される # features/message_api.feature:36 前提 ポート"7071"でサーバーが起動していること # features/steps/steps.py:8 0.000s もし Content-Typeに"application/json"を、リクエストボディに下記を設定する # features/steps/steps.py:19 0.000s """ {"name": "FUGA" """ かつ "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する # features/steps/steps.py:32 0.004s ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている # features/steps/steps.py:43 0.000s """ {"message": "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."} """ 1 feature passed, 0 failed, 0 skipped 4 scenarios passed, 0 failed, 0 skipped 15 steps passed, 0 failed, 0 skipped, 0 undefined Took 0m0.026s
- こちらも問題なく完了したことが確認できました
- 次の作業に進むため、
func start
したAzure FunctionsをCtrl+c
で終了しておきます
ここまでの確認を通して、現在地がGREENであることの確認ができました。
今回はユニットテスト・インテグレーションテストと2段階のテストがあるので、これをガイドにしてリファクタリングを進めます。
2. HTTP Trigger FunctionをリファクタリングしてBluePrint化する
2.1. MyHttpTriggerのユニットテストを更新する
まずは functon_app.py
の内容をBluePrintオブジェクトに変更していきます。
その前段としてユニットテストを更新し、RED工程になることを確認します。
- まず新しいユニットテスト用のフォルダを作成します実行コマンド
mkdir -p tests/apis touch tests/apis/__init__.py
- 次に既存の
functon_app.py
用のテストであるtests/test_function_app.py
を、名前を変えながら移動します実行コマンドmv tests/test_function_app.py tests/apis/test_http_blueprint.py
- この時点ではまだテストが通ることを確認します実行コマンド
hatch run test
出力結果============================ test session starts =========================== platform darwin -- Python 3.11.7, pytest-7.4.4, pluggy-1.3.0 rootdir: ~/devel/sandbox_azure_functions configfile: pyproject.toml collected 4 items tests/apis/test_http_blueprint.py .... [100%] ============================= 4 passed in 0.07s ============================
- エディタで
tests/apis/test_http_blueprint.py
を開いて下記のように更新します。tests/apis/test_http_blueprint.pyimport json from unittest import TestCase import azure.functions as func - from function_app import MyHttpTrigger + from apis.http_blueprint import MyHttpTrigger class TestFunctionApp(TestCase): def test_without_params(self): req = func.HttpRequest(
- この状態でテストを実行します。実行コマンド
hatch run test
出力結果============================ test session starts =========================== platform darwin -- Python 3.11.7, pytest-7.4.4, pluggy-1.3.0 rootdir: ~/devel/sandbox_azure_functions configfile: pyproject.toml collected 0 items / 1 error ================================== ERRORS ================================== ____________ ERROR collecting tests/apis/test_http_blueprint.py ____________ ImportError while importing test module '~/devel/sandbox_azure_functions/tests/apis/test_http_blueprint.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: /opt/homebrew/Cellar/python@3.11/3.11.7/Frameworks/Python.framework/Versions/3.11/lib/python3.11/importlib/__init__.py:126: in import_module return _bootstrap._gcd_import(name[level:], package, level) tests/apis/test_http_blueprint.py:7: in <module> from apis.http_blueprint import MyHttpTrigger E ModuleNotFoundError: No module named 'apis' ========================== short test summary info ========================= ERROR tests/apis/test_http_blueprint.py !!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!! ============================= 1 error in 0.13s =============================
これで、関数 MyHttpTrigger
のユニットテストが更新できました。
2.2. MyHttpTriggerのプロダクトコードを更新する
次にMyHttpTriggerのプロダクトコードをBluePrint化していきます。
-
function_app.py
をapis/http_blueprint.py
に移動します実行コマンドmkdir apis touch apis/__init__.py mv function_app.py apis/http_blueprint.py
-
apis/http_blueprint.py
を開いて下記のように修正しますapis/http_blueprint.py〜〜〜〜〜(前略)〜〜〜〜〜 import azure.functions as func - app = func.FunctionApp() + bp = func.Blueprint() - @app.route(route="MyHttpTrigger", auth_level=func.AuthLevel.ANONYMOUS) + @bp.route(route="MyHttpTrigger", auth_level=func.AuthLevel.ANONYMOUS) def MyHttpTrigger(req: func.HttpRequest) -> func.HttpResponse: 〜〜〜〜〜(後略)〜〜〜〜〜
- 再度ユニットテストを実行します実行コマンド
hatch run test
出力結果============================ test session starts =========================== platform darwin -- Python 3.11.7, pytest-7.4.4, pluggy-1.3.0 rootdir: ~/devel/sandbox_azure_functions configfile: pyproject.toml collected 4 items tests/apis/test_http_blueprint.py .... [100%] ============================= 4 passed in 0.07s ============================
これで関数MyHttpTriggerのBluePrint化が完了しました。
2.3. BluePrintを使う新たなfunction_app.pyを作成する
先ほどのBluePrint化した関数MyHttpTriggerをAzure Functionsから使うために、新しい function_app.py
を作成します。
まずはユニットテストを作成し、その後プロダクトコードを実装していきます。
- ファイル
tests/test_function_app.py
を新たに作成し、下記の内容を記述しますtests/test_function_app.pyimport sys from unittest import TestCase from unittest.mock import patch import azure.functions as func class TestFunctionApp(TestCase): @patch("apis.http_blueprint.bp") def test_happy_path(self, mock_message_bp): if "function_app" in sys.modules: del sys.modules["function_app"] with patch.object( func.FunctionApp, "register_functions" ) as mock_register_functions: import function_app # noqa: E117, F401 mock_register_functions.assert_any_call(mock_message_bp)
- この状態でテストを実行します。実行コマンド
hatch run test
出力結果============================ test session starts =========================== platform darwin -- Python 3.11.7, pytest-7.4.4, pluggy-1.3.0 rootdir: ~/devel/sandbox_azure_functions configfile: pyproject.toml collected 5 items tests/test_function_app.py F [ 20%] tests/apis/test_http_blueprint.py .... [100%] ================================= FAILURES ================================= ______________________ TestFunctionApp.test_happy_path _____________________ self = <tests.test_function_app.TestFunctionApp testMethod=test_happy_path> mock_message_bp = <MagicMock name='bp' id='4319282960'> @patch("apis.http_blueprint.bp") def test_happy_path(self, mock_message_bp): if "function_app" in sys.modules: del sys.modules["function_app"] with patch.object( func.FunctionApp, "register_functions" ) as mock_register_functions: > import function_app # noqa: E117, F401 E ModuleNotFoundError: No module named 'function_app' tests/test_function_app.py:17: ModuleNotFoundError ========================================= short test summary info ========================================= FAILED tests/test_function_app.py::TestFunctionApp::test_happy_path - ModuleNotFoundError: No module named 'function_app' ======================================= 1 failed, 4 passed in 0.09s =======================================
-
function_app.py
が無いことで、テストが落ちることを確認できました
-
- 新たにファイル
function_app.py
を作成し、下記を記述しますfunction_app.pyimport azure.functions as func from apis.http_blueprint import bp app = func.FunctionApp() app.register_functions(bp)
- 再度テストを実行します実行コマンド
hatch run test
出力結果============================ test session starts =========================== platform darwin -- Python 3.11.7, pytest-7.4.4, pluggy-1.3.0 rootdir: ~/devel/sandbox_azure_functions configfile: pyproject.toml collected 5 items tests/test_function_app.py . [ 20%] tests/apis/test_http_blueprint.py .... [100%] ============================= 5 passed in 0.08s ============================
ここまでで実装は完了です。
2.4. デグレが起きてないか確認する
ここまでの変更でデグレしてないか、インテグレーションテストを使って確認していきます。
今回はリファクタリングなので、インテグレーションテストは変更せずともそのまま通るはずです。
- 新しいターミナルを開いて、インテグレーションテスト用のAzure Functionsを立ち上げます実行コマンド
func start
出力結果Found Python version 3.11.6 (python3). Azure Functions Core Tools Core Tools Version: 4.0.5455 Commit hash: N/A (64-bit) Function Runtime Version: 4.27.5.21554 [2024-01-04T15:30:51.211Z] Customer packages not in sys path. This should never happen! [2024-01-04T15:30:51.762Z] Worker process started and initialized. Functions: MyHttpTrigger: http://localhost:7071/api/MyHttpTrigger For detailed output, run func with --verbose flag. [2024-01-04T15:30:56.711Z] Host lock lease acquired by instance ID '0000000000000000000000007DB202B9'.
- 元のターミナルに戻ってインテグレーションテストを実行します実行コマンド
hatch run integration:all
出力結果機能: メッセージAPI # features/message_api.feature:3 シナリオ: HappyPath # features/message_api.feature:5 前提 ポート"7071"でサーバーが起動していること # features/steps/steps.py:8 0.001s もし "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する # features/steps/steps.py:32 0.011s ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている # features/steps/steps.py:43 0.000s """ {"message": "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."} """ シナリオ: リクエストパラメータにnameを含んでいるとき、レスポンスにnameの値が含まれている # features/message_api.feature:13 前提 ポート"7071"でサーバーが起動していること # features/steps/steps.py:8 0.000s もし リクエストパラメータに下記を設定する # features/steps/steps.py:27 0.000s | key | value | | name | HOGE | かつ "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する # features/steps/steps.py:32 0.005s ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている # features/steps/steps.py:43 0.000s """ {"message": "Hello, HOGE. This HTTP triggered function executed successfully."} """ シナリオ: リクエストボディにJSON形式でnameを含んでいるとき、レスポンスにnameの値が含まれている # features/message_api.feature:24 前提 ポート"7071"でサーバーが起動していること # features/steps/steps.py:8 0.000s もし Content-Typeに"application/json"を、リクエストボディに下記を設定する # features/steps/steps.py:19 0.000s """ {"name": "FUGA"} """ かつ "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する # features/steps/steps.py:32 0.004s ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている # features/steps/steps.py:43 0.000s """ {"message": "Hello, FUGA. This HTTP triggered function executed successfully."} """ シナリオ: リクエストボディに不完全なJSON形式を含んでいるとき、リクエストボディは無視される # features/message_api.feature:36 前提 ポート"7071"でサーバーが起動していること # features/steps/steps.py:8 0.000s もし Content-Typeに"application/json"を、リクエストボディに下記を設定する # features/steps/steps.py:19 0.000s """ {"name": "FUGA" """ かつ "POST"メソッドで"/api/MyHttpTrigger"にリクエストを実行する # features/steps/steps.py:32 0.004s ならば ステータスコードが"200 (OK)"で、レスポンスボディが下記の形になっている # features/steps/steps.py:43 0.000s """ {"message": "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."} """ 1 feature passed, 0 failed, 0 skipped 4 scenarios passed, 0 failed, 0 skipped 15 steps passed, 0 failed, 0 skipped, 0 undefined Took 0m0.026s
無事に全件成功しました。
これで、デグレを起こさずにリファクタリングが完了しました。
他にも念のため、Lintチェックやカバレッジのチェックも行っておきましょう。
- 改めてカバレッジを確認します実行コマンド
hatch run cov
出力結果cmd [1] | coverage run -m pytest tests ============================ test session starts =========================== platform darwin -- Python 3.11.7, pytest-7.4.4, pluggy-1.3.0 rootdir: ~/devel/sandbox_azure_functions configfile: pyproject.toml collected 5 items tests/test_function_app.py . [ 20%] tests/apis/test_http_blueprint.py .... [100%] ============================= 5 passed in 0.08s ============================ cmd [2] | - coverage combine Combined data file .coverage.M1.local.36141.XvoahQVx cmd [3] | coverage report -m Name Stmts Miss Branch BrPart Cover Missing -------------------------------------------------------------------- apis/__init__.py 0 0 0 0 100% apis/http_blueprint.py 17 0 6 0 100% function_app.py 4 0 0 0 100% -------------------------------------------------------------------- TOTAL 21 0 6 0 100%
- カバレッジがリファクタリング前と同様の100%になっていることが確認できました
- Lintチェックを実行します実行コマンド
hatch run style:check
出力結果cmd [1] | pflake8 . cmd [2] | black --check --diff . All done! ✨ 🍰 ✨ 10 files would be left unchanged. cmd [3] | isort --check-only --diff . Skipped 4 files
- ここでも問題ないことが確認できました
2.5. ここまでの内容を記録する
ここまで作業した内容をGitリポジトリに記憶します。
git add .
git commit -m "BluePrint対応"
[main 6d62a4e] BluePrint対応
6 files changed, 144 insertions(+), 117 deletions(-)
create mode 100644 apis/__init__.py
create mode 100644 apis/http_blueprint.py
create mode 100644 tests/apis/__init__.py
create mode 100644 tests/apis/test_http_blueprint.py
まとめ
今回はテストを活かしながらBluePrintに対応するリファクタリングを行いました。
機能追加を行う場合も、今回と同様にテストやカバレッジを確認した後に作業し、完了の確認のためにテストを使うことで、より安全に実装を進めることができます。
また今回の実装で、API自体の疎結合化を進めることができました。
これにより、2つ目のAPIを実装する際に、既存のコードやテストに対する変更を最小限に抑えることができます。
実装する場合は今回と同様にBluePrintでAPIを実装してfunction_appでimportしてregister_functions()をする形になりますが、その際メインのビジネスロジックと、そのテストである apis/http_blueprint.py
と tests/apis/test_http_blueprint.py
は触る必要がなくなり、デグレのリスクを下げることができます。
次回はVitestでフロントエンドを書いていきます。
次回の記事はこちら:
Discussion