🦁

Azure FunctionsでBluePrint対応を行う

2024/01/09に公開

この記事はAzure Functions(Python版)をリファクタリングしてBluePrint対応するための実装メモです。

Azure FunctionsはちょっとしたPythonAPIを動かすのにちょうど良いFaaS(Function as a Service)です。
しかし毎回APIを作るために func init していたのでは、無尽蔵にリポジトリが増えて管理コストが上がってしまいます。
今回はBluePrintを使って疎結合な形で複数API対応に向けたリファクタリングを行っていきます。

過去に作成したAzure Functionsについては下記の通り。

  1. 基本編
    1. AzureStaticWebAppをReact+AzureFunctions(Python)で作って、ローカルで動かしてみる
  2. Azure Functions編
    1. Azure Functions(Python版)の試験をpytestで行う
    2. hatchでAzure Functions(Python版)のLintチェックをしながらリファクタリングする
    3. behaveを使ってAzure Functions(Python版)でBDD(振る舞い駆動開発)を始める

今回はAzure Functions編のため、作業ディレクトリは下記になります。

~/devel/sandbox_azure_functions

1. 現状確認

1.1. 最初にGREENであることを確認する

リファクタリングを行うに当たって、まずはテストが全て通ることを確認します。

  1. ユニットテスト+カバレッジの確認を実行します
    実行コマンド
    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%になっていることが確認できました
  2. 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
    
    • ここでも問題ないことが確認できました
  3. インテグレーションテストのために、別のターミナルを開いて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'.
    
  4. 元のターミナルに戻ってインテグレーションテストを実行します
    実行コマンド
    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
    
    • こちらも問題なく完了したことが確認できました
  5. 次の作業に進むため、func start したAzure Functionsを Ctrl+c で終了しておきます

ここまでの確認を通して、現在地がGREENであることの確認ができました。

今回はユニットテスト・インテグレーションテストと2段階のテストがあるので、これをガイドにしてリファクタリングを進めます。

2. HTTP Trigger FunctionをリファクタリングしてBluePrint化する

2.1. MyHttpTriggerのユニットテストを更新する

まずは functon_app.py の内容をBluePrintオブジェクトに変更していきます。
その前段としてユニットテストを更新し、RED工程になることを確認します。

  1. まず新しいユニットテスト用のフォルダを作成します
    実行コマンド
    mkdir -p tests/apis
    touch tests/apis/__init__.py
    
  2. 次に既存の functon_app.py 用のテストである tests/test_function_app.py を、名前を変えながら移動します
    実行コマンド
    mv tests/test_function_app.py tests/apis/test_http_blueprint.py
    
  3. この時点ではまだテストが通ることを確認します
    実行コマンド
    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 ============================
    
  4. エディタで tests/apis/test_http_blueprint.py を開いて下記のように更新します。
    tests/apis/test_http_blueprint.py
      import 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(
    
  5. この状態でテストを実行します。
    実行コマンド
    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化していきます。

  1. function_app.pyapis/http_blueprint.py に移動します
    実行コマンド
    mkdir apis
    touch apis/__init__.py
    mv function_app.py apis/http_blueprint.py
    
  2. 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:
    
    〜〜〜〜〜(後略)〜〜〜〜〜
    
    
  3. 再度ユニットテストを実行します
    実行コマンド
    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 を作成します。

まずはユニットテストを作成し、その後プロダクトコードを実装していきます。

  1. ファイル tests/test_function_app.py を新たに作成し、下記の内容を記述します
    tests/test_function_app.py
    import 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)
    
    
  2. この状態でテストを実行します。
    実行コマンド
    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 が無いことで、テストが落ちることを確認できました
  3. 新たにファイル function_app.py を作成し、下記を記述します
    function_app.py
    import azure.functions as func
    
    from apis.http_blueprint import bp
    
    app = func.FunctionApp()
    
    app.register_functions(bp)
    
    
  4. 再度テストを実行します
    実行コマンド
    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. デグレが起きてないか確認する

ここまでの変更でデグレしてないか、インテグレーションテストを使って確認していきます。

今回はリファクタリングなので、インテグレーションテストは変更せずともそのまま通るはずです。

  1. 新しいターミナルを開いて、インテグレーションテスト用の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'.
    
  2. 元のターミナルに戻ってインテグレーションテストを実行します
    実行コマンド
    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チェックやカバレッジのチェックも行っておきましょう。

  1. 改めてカバレッジを確認します
    実行コマンド
    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%になっていることが確認できました
  2. 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.pytests/apis/test_http_blueprint.py は触る必要がなくなり、デグレのリスクを下げることができます。

次回はVitestでフロントエンドを書いていきます。

次回の記事はこちら:

参考

Discussion