しばやん雑記

Azure Serverless とメイドさんが大好きなフリーランスのプログラマーのブログ

開発体験を向上させる Azure Functions の Node.js と Python の新しいプログラミングモデルを試した

Azure Functions の特徴として各サービスとのバインディングとトリガーの存在があります。Function の実装ではどのバインディングやトリガーを使っているかという情報を Function Host やスケーリングを担うサービスに伝える必要があり、そのためのメタデータとして function.json が用意されています。

このメタデータによってゼロへのスケールインや、キューの長さやイベントの数による柔軟なオートスケーリングが実現されています。とても重要なファイルなのですが、ミスしやすいポイントでもあります。

C# と Java を使って開発している場合にはコード内のアノテーションから、自動的に必要な function.json メタデータを生成するため意識する必要がないのですが、それ以外の言語では非常に手間なことに手動で定義する必要がありました。リフレクションがあるかどうかで開発体験に大きな違いが発生していたわけです。

理想としてはコード上でバインディングやトリガーを使う宣言をすれば、自動的にメタデータに反映されることなので、そのあたりを大きく改善するために Worker Indexing という機能が追加されました。内部では Project Stein と呼ばれているらしいので、Issue や PR で目にすることもあるかもしれません。

名前の通りですが、各言語ワーカー側がコード内に記述された Function のメタデータをインデックスして、ホスト側に通知するというアプローチです。ファイルではなく言語ワーカーがメタデータをホストに通知するため、C# や Java 以外でも同じようにコードだけで完結することが出来るようになります。

現在はプレビューとして Node.js と Python で Worker Indexing に対応したフレームワーク、プログラミングモデルが試せるようになっているので、それぞれについて実際に動かしておきました。

Node.js v4 (Limited Public Preview)

Node.js の新しいフレームワークは、まだ正式にはパブリックプレビューに達していませんが、少しの修正で誰でも試せるようになっています。GitHub の Wiki でドキュメントとロードマップが公開されています。

前提にもあるように Node.js v18 と TypeScript v4 が必須となっています。v4 が公開された後は Node.js 向けの Azure Functions は TypeScript で書いていくことになるはずです。

実際に Azure Functions プロジェクトを作成して試していくわけですが、まだ v4 向けに作成は出来ないので一旦既存のバージョンで TypeScript 向けに生成し、package.json を書き換えて対応します。具体的には @azure/functions のバージョン変更と func-cli-nodejs-v4 を追加します。

以下に基本となる package.json を載せておきますので、これをベースにするのが簡単です。

{
  "name": "nodejs-v4-test",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "prestart": "npm run build",
    "start": "func start",
    "test": "echo \"No tests yet...\""
  },
  "dependencies": {
    "@azure/functions": "^4.0.0-alpha.4"
  },
  "devDependencies": {
    "@types/node": "18.x",
    "typescript": "^4.0.0",
    "func-cli-nodejs-v4": "4.0.4764"
  },
  "main": "dist/index.js"
}

これまでの package.json に出てこなかった要素として main プロパティがあります。ここに指定したファイルが Azure Functions のエントリポイントとなりますので、必ず存在するファイルのパスを指定します。複数の TypeScript ファイルに分けて書く場合には glob パターンを使った指定も出来ます。

サンプルにもある HttpTrigger を使った基本的な Function 実装は以下の通りシンプルです。TypeScript によって厳密に型指定された状態で書くことが出来るようになっています。

import { app } from "@azure/functions";

app.http('helloWorld1', {
  methods: ['GET', 'POST'],
  handler: async (context, request) => {
    context.log('Http function processed request');

    const name = request.query.get('name') 
      || await request.text() 
      || 'world';

    return { body: `Hello, ${name}!` };
  }
});

基本は全て app というインポートしたオブジェクトに対して、トリガー名のメソッドを呼び出して実際の Function として実装されるラムダ式を登録します。この辺りは Express などと書き方は近いですね。

まだパブリックプレビューにも至っていませんが、Durable Functions 以外は大体のトリガーに対応しています。型情報を持っているので入力補完を使って、ドキュメントを調べずとも書くことが出来ます。

HttpTrigger は簡単すぎるので、試しに Cosmos DB の Change Feed を使ったトリガーを書いてみました。ドキュメントを見ることなく型情報だけで定義しましたが、以下のようなシンプルなコードで実装出来ました。

import { app } from "@azure/functions";

app.cosmosDB("ChangeFeed", {
  connectionStringSetting: "CosmosConnection",
  databaseName: "my-database",
  collectionName: "my-item",
  handler: async (context, documents: MyItem[]) => {
    for (const document of documents) {
      context.log(`${document.id} - ${document.name}`)
    }
  }
});

class MyItem {
  id: string
  name: string
}

少し迷った点としては handler に入ってくる unknown 型だけでした。結局は明示的に必要な型を指定すれば、上手いことマッピングしてくれたので問題ありませんでした。

実際に Cosmos DB と接続して Change Feed の動作確認をしましたが、全く問題なく動作しました。

Worker Indexing の影響があるのか分かりませんが、体感としては Function Host が立ち上がるまでに通常より時間がかかった気がします。GA の頃には諸々改善されていると思いますが、少し注意しておきたいです。

まだ Azure 上で動かすことは出来ませんが、Azure Functions Host の v4.14.0 でサポートされるらしいので、プラットフォームアップデート後は実際に Azure 上にデプロイして動かすことが出来るはずです。

Python v2 (Public Preview)

Python の新しいプログラミングモデルはパブリックプレビューとなっているので、最新の Azure Functions Core Tools と Visual Studio Code でのテンプレート周りと Azure 上での実行がサポートされています。

Node.js の場合とは異なり、特に Python 自体のバージョンに対する制約は存在しないようなので、既存のコードを v2 プログラミングモデルにアップデートするのが楽になりそうです。

Python の v2 プログラミングモデルではデコレーターをベースとしているので、使い勝手としては C# や Java でのアノテーションに近いです。この辺りは言語機能やトレンドに合わせてきた感がありますね。新しいプロジェクトを v2 ベースで作る方法は既にドキュメントが用意されているので、こちらを参照してください。

早速サンプルにある HttpTrigger の実装を確認しておきました。デコレーター部分は完全に C# や Java のアノテーションに読み替えが可能ですね。

import azure.functions as func

app = func.FunctionApp()

@app.function_name(name="HttpTrigger1")
@app.route(route="hello") # HTTP Trigger
def test_function(req: func.HttpRequest) -> func.HttpResponse:
    return func.HttpResponse("HttpTrigger1 function processed a request!!!")

注意点としては Node.js の時とあまり変わりませんが Worker Indexing が必要なので、今のところは有効化の設定を明示的に指定する必要があります。重要な制約としてはエントリポイントが function_app.py というファイル名固定という点です。

ファイルが固定だからと言って複数の Function 実装をまとめろというわけではなく、Blueprint という仕組みによって別ファイルで定義された Function をエントリポイント側で登録可能です。Blueprint を使うことでファイルの分離やコンポーネントの共通化が行えます。

例によって HttpTrigger だけでは簡単すぎるので、Python でも Cosmos DB の Change Feed を使ったトリガーを書いてみました。こちらも入力補完だけで実装しました。

import logging
import azure.functions as func

app = func.FunctionApp()

@app.function_name(name="ChangeFeed")
@app.cosmos_db_trigger(arg_name="documents", connection_string_setting="CosmosConnection", database_name="my-database", collection_name="my-item")
def change_feed(documents: func.DocumentList):
    logging.info(f"{documents[0]['id']} - {documents[0]['name']}")

Function の実装自体は非常にシンプルですね。設定周りはデコレーターとして分離されているので、実際の処理と混ざらないためコードの見通しが良いです

受け取る型が Python では DocumentList など専用で用意されているのに注意ですね。Event Grid など向けにも専用の型が存在していたので、自分で定義する必要が無く便利です。

その他のバインディングとトリガー向けにはサンプルが公式ドキュメントが用意されているので、こちらを確認すると様々なサービスを利用するパターンに簡単に対応できるはずです。

実際に作成したコードを実行してみると、Cosmos DB 側のドキュメントを変更したタイミングでトリガーが無事に実行されたことが確認出来ました。

最終的な結果は全くこれまでと変わらないのですが、ここに至るまでの開発体験がこれまでの Node.js と Python と比べて非常に向上していることが分かります。

ハッカソンなどでも面倒でも function.json は必要だと言っていた部分なので、これが無くなると劇的に開発効率が上がることは間違いありません。GA を楽しみにしています。