Azure Functions Node.js Programming Modelでv3→4に移行してみた


# きっかけ

個人開発で以下の Azure Functions を開発・デプロイし、しばらくの間 GitHub で塩漬けしていました。

| 名称                                                                                                    | 値                        |
| ------------------------------------------------------------------------------------------------------- | ------------------------- |
| OS                                                                                                      | Windows                   |
| プラン                                                                                                  | Consumption               |
| リージョン                                                                                              | Japan East                |
| [ランタイム](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-versions)のバージョン    | 4.x                       |
| 関数言語                                                                                                | Node.js(Typescript) v18.x |
| [Azure Functions Node.js Programming Model](https://www.npmjs.com/package/@azure/functions)のバージョン | **3.5.1**                 |

そんな中、2023 年 9 月辺りに、[Dependabot](https://docs.github.com/ja/code-security/dependabot/working-with-dependabot)から、Azure Functions Node.js Programming Model の 3.5.1→4.0.1 アップデートの Pull Request が届いていました。
公式ブログを調べたところ、ちょうどそのタイミングで Azure Functions Node.js Programming Model の v4 が GA されたようでした。

https://techcommunity.microsoft.com/t5/apps-on-azure-blog/azure-functions-node-js-v4-programming-model-is-generally/ba-p/3929217

この公式ブログと[公式リファレンス](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-node-upgrade-v4)を参考にし、Azure Functions Node.js Programming Model の v3→4 に移行した際の Tips を記しておきます。

:::note warn
公式で注意喚起していますが、Azure Functions のランタイムのバージョンと、Azure Functions Node.js Programming Model のバージョンとは互いに別物です。
両方ともメジャーバージョンが 4 ですが、これは偶然…らしいです。
当記事では後者の Azure Functions Node.js Programming Model の方をバージョンアップしたケースとなります。
:::

# v4 の特徴

何と言っても、 **function.json が不要になる**ことです。
v3 以前では、1 つの Azure Functions に複数個デプロイできる関数は、以下のファイルをすべてセットにしていました。

- ソースコード群(C#、Javascript、Python、...)
- function.json

function.json の記述がソースコード化されたため、ソースコードのファイル管理がシンプルになり、特に Durable Functions で、より柔軟にソースコードを記載することが期待できます。

# 対象

[Azure Functions Core Tools](https://github.com/Azure/azure-functions-core-tools)といった何かしらの方法で自動生成した直後である、以下のディレクトリ・ファイル構成を仮定します。

```
FunctionsTSV4
├─ healthcheck
│   ├─ function.json
│   └─ index.ts
├─ ...
：
├─ host.json
├─ local.settings.json
├─ package.json
├─ package-lock.json
└─ tsconfig.json
```

関数が複数個存在する前提ですが、今後の説明のため、GET メソッドの HTTP トリガーであるヘルスチェックもどきの関数`healthcheck`のみ取り扱うことを想定し、その説明上で重要なファイルの中身だけ以下の通り仮定します。

```json:healthcheck/function.json
{
  "bindings": [
    {
      "authLevel": "Function",
      "type": "HttpTrigger",
      "direction": "in",
      "name": "req",
      "methods": ["get", "post"]
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    }
  ],
  "scriptFile": "../dist/healthcheck/index.js"
}
```

```typescript:healthcheck/index.ts
import { Context, HttpRequest } from "@azure/functions";

export default async function (
  context: Context,
  req: HttpRequest
): Promise<void> {
  context.log("HTTP trigger function processed a request.");
  const name = req.query.name || (req.body && req.body.name);
  const responseMessage = name
    ? "Hello, " + name + ". This HTTP triggered function executed successfully."
    : "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response.";

  context.res = {
    status: 200,
    body: responseMessage,
  };
}
```

```json:package.json
{
  "name": "functions-ts-v4",
  "version": "1.0.0",
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "clean": "rimraf dist",
    "prestart": "npm run clean && npm run build",
    "start": "func start"
  },
  "dependencies": {
    "@azure/functions": "^3.5.1"
  },
  "devDependencies": {
    "@types/node": "^18.19.5",
    "azure-functions-core-tools": "^4.0.5455",
    "rimraf": "^5.0.5",
    "typescript": "^5.3.3"
  }
}
```

```json:tsconfig.json
{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es6",
    "outDir": "dist",
    "rootDir": ".",
    "sourceMap": true,
    "strict": false
  }
}
```

# 移行

Azure Functions Node.js Programming Model の v4 で`Context`の型定義が削除されたため、単に以下のコマンドを実行し、Azure Functions Node.js Programming Model の npm パッケージである`@azure/functions`を v3→4 へアップグレードしただけでは NG です。

```bash
npm i @azure/functions@latest
```

実際に上記コマンドの実行のみでは、`npm run build`実行時に以下のコンパイルエラーが発生してしまいます。

```
Module '"@azure/functions"' has no exported member 'Context'.ts(2305)
```

[公式リファレンス](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-node-upgrade-v4)での説明より、v4 でソースコードレベルで破壊的変更が入ったため、上記コマンドの実行の他に、ソースコードの修正も必要です。
具体的にはまず、以下の通りディレクトリ・ファイルを再構成します。

```
FunctionsTSV4
├─ host.json
├─ local.settings.json
├─ package.json
├─ package-lock.json
├─ src
│   └─ functions
│       ├─ healthcheck.ts
│       ├─ ...
：       ：
│       └─ index.ts
└─ tsconfig.json
```

その後、以下の 3 ファイルを修正します。修正のポイントも示しておきます。

```typescript:src/functions/healthcheck.ts
import {
  HttpRequest,
  HttpResponseInit,
  InvocationContext,
} from "@azure/functions";

export default async function (
  request: HttpRequest,
  context: InvocationContext
): Promise<HttpResponseInit> {
  context.log("HTTP trigger function processed a request.");
  const body = await request.text();
  const name =
    request.query.get("name") || (body !== "" && JSON.parse(body).name);
  const responseMessage = name
    ? "Hello, " + name + ". This HTTP triggered function executed successfully."
    : "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response.";

  return {
    status: 200,
    body: responseMessage,
  };
}
```

- v3 での healthcheck/index.ts に対応しています。
- v3 では引数を左からコンテキスト → リクエストの順で定義しましたが、v4 ではリクエスト → コンテキストの順で定義するように修正します。
- v3 でリクエストのボディ・ヘッダー・クエリパラメーターを取得する方法が[Javascript 標準の fetch 関数もどきに変更](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-node-upgrade-v4#httprequest)されたため、それを修正します。
  - リクエストボディの文字列を`request.text()`を実行して Promise を取得し、それが JSON 形式である場合のみ parse しています。
- `@azure/functions`から import する引数のコンテキストの型を、`Context`から`InvocationContext`へと変更されました。そのため、v3 でレスポンスを明示的に返す場合`context.res`に設定するか return するかのいずれかでしたが、v4 では`context.res`に設定できなくなり、必ず return するように修正します。

:::note
上記ソースコードだと`HttpRequest.text`関数を実行してリクエストボディを取得していますが、[`HttpRequest`の型定義](https://github.com/Azure/azure-functions-nodejs-library/blob/v4.x/types/http.d.ts#L76)を見ると、unknown 型の Promise を返す`HttpRequest.json`関数が存在し、一見この関数を実行した方がシンプルのように見えます。
しかし、`HttpRequest.json`関数の内部処理の仕様が、リクエストボディが JSON 形式であろうがなかろうが関係なく問答無用で parse するとなっているため、JSON 形式でないリクエストボディを設定して API を実行すると実行時例外が発生します。
リクエストボディが JSON 形式であることが確約される場合は`HttpRequest.json`関数を採用しても良いですが、確約されない場合は`HttpRequest.text`関数を実行して文字列として取得し、その後 JSON 形式かのバリデーションチェックを挟んで`JSON.parse`関数で parse した方が良いです。
:::

```typescript:src/functions/index.ts
import { app } from "@azure/functions";
import healthcheck from "./healthcheck";
// import ...

app.http("healthcheck", {
  methods: ["GET", "POST"],
  authLevel: "function",
  handler: healthcheck,
});

// 以降、healthcheck以外の関数を同様に定義する

// HTTPトリガーの場合
// app.http("...", {
//   ：
// });

// タイマートリガーの場合
// app.timer("...", {
//   schedule: "cron式",
//   handler: ...,
// });
```

- v3 での healthcheck/function.json に対応しています。
- v3 では HTTP トリガーの設定を bindings > type(direction が `in`) に`httpTrigger`を設定して実現していましたが、v4 では`@azure/functions`から import できる`app`を用いて、`app.http`関数を呼び出すように修正します。
  - 複数個の HTTP トリガーの関数を定義したい場合は、そのまま`app.http`関数を続けて呼び出せば良いです。
  - HTTP トリガー以外でも、例えばタイマートリガーの場合は`app.timer`関数を呼び出せば良いです。詳細は[`app`の型定義](https://github.com/Azure/azure-functions-nodejs-library/blob/v4.x/types/app.d.ts)を参照してください。
- v3 では API のパスが function.json が存在する相対パスにそのまま対応していましたが、v4 では`app.http`関数の第 1 引数に API のパスを設定するように修正します。
- v3 で function.json に設定していた メソッド、認証形式を、それそれ`app.http`関数の methods、authLevel プロパティに設定し、src/functions/healthcheck.ts で export したヘルスチェックもどきの動作を行う async function を handler プロパティに設定するように修正します。

```json:package.json
{
  "name": "functions-ts-v4",
  "version": "1.0.0",
  "main": "dist/src/functions/*.js",
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "clean": "rimraf dist",
    "prestart": "npm run clean && npm run build",
    "start": "func start"
  },
  "dependencies": {
    "@azure/functions": "^4.1.0"
  },
  "devDependencies": {
    "@types/node": "^18.19.5",
    "azure-functions-core-tools": "^4.0.5455",
    "rimraf": "^5.0.5",
    "typescript": "^5.3.3"
  }
}
```

- 関数を構成する.ts ファイルをビルドによってトランスパイルした全.js ファイルが該当するように[GLOB](<https://wikipedia.org/wiki/Glob_(programming)>)を組み、その値を持つ main プロパティを追記します。
  - `npm run build`実行時に、src/functions 配下の各.ts ファイルをトランスパイルした.js ファイルが dist/src/functions 配下にそれぞれ生成するため、main プロパティに`dist/src/functions/*.js`を追記しています。

:::note
tsconfig.json の修正は必要ありません。
:::

なお、上記で src/functions/healthcheck.ts で export した関数を src/functions/index.ts で import し、handler プロパティに設定していましたが、以下の通り import せずに handler プロパティに匿名関数を直接設定することもできます。

```typescript
import {
  app,
  HttpRequest,
  HttpResponseInit,
  InvocationContext,
} from "@azure/functions";

app.http("healthcheck", {
  methods: ["GET", "POST"],
  authLevel: "function",
  // importせず、匿名関数を直接設定
  handler: async function (
    request: HttpRequest,
    context: InvocationContext
  ): Promise<HttpResponseInit> {
    // 省略
    return { status: 200 };
  },
});
```

しかし、その匿名関数に対するユニットテストを実装する場合などを踏まえ、export しておいた方がお行儀が良いです。

# 補足

https://github.com/infhyroyage/FunctionsTSV4/commit/51e39dc1359b479ce2b27ffbb20364a3f2b65690

↑Azure Functions Node.js Programming Model の v3→4 の移行にあたる、サンプルコード修正のコミットです。