コードのチュートリアル:Functions を使用したサーバーレス アプリケーション

Azure Event Hubs
Azure Functions

サーバーレス モデルは、基盤となるコンピューティング インフラストラクチャからコードを抽象化するため、開発者は広範なセットアップを行わずにビジネス ロジックに専念できます。 サーバーレス コードでは、料金が発生するのはコード実行のリソースと期間に対してだけなので、コストが削減されます。

このサーバーレス イベントドリブン モデルが適しているのは、定義済みアクションが特定のイベントによってトリガーされる場合です。 たとえば、受信デバイス メッセージを受信すると、後で使用するためのストレージがトリガーされたり、データベースを更新すると、さらなる処理がトリガーされたりします。

Azure で Azure サーバーレス テクノロジを調べるのに役立つように、Microsoft では Azure Functions を使用するサーバーレス アプリケーションを開発してテストしました。 この記事では、サーバーレス Functions ソリューションのコードについて説明し、設計上の決定、実装の詳細、および発生する可能性のあるいくつかの問題について説明します。

ソリューションを調べる

この 2 部構成のソリューションでは、仮想のドローン配送システムについて説明します。 ドローンによって使用中の状態がクラウドに送信されます。これらのメッセージは、後で使用するために保存されます。 Web アプリを使用すると、ユーザーはデバイスの最新状態を知るためのメッセージを取得できます。

このソリューションのコードは、GitHub からダウンロードできます。

このチュートリアルでは、次のテクノロジに関する基本的な知識を前提としています。

Functions や Event Hubs のエキスパートである必要はありませんが、これらの機能を大まかに理解している必要があります。 使用方法については、以下をご覧ください。

シナリオの理解

機能ブロック図

Fabrikam は、ドローン配送サービス用のドローンを管理しています。 アプリケーションは、次の 2 つの主な機能で構成されています。

  • イベントの取り込み。 ドローンは飛行中に、クラウドのエンドポイントに状態メッセージを送信します。 アプリケーションはこれらのメッセージを取り込んで処理し、結果をバックエンド データベース (Azure Cosmos DB) に書き込みます。 デバイスは、プロトコル バッファー (protobuf) 形式でメッセージを送信します。 protobuf は、効率的で自己記述型のシリアル化形式です。

    これらのメッセージには、部分的な更新が含まれます。 各ドローンは、一定の間隔で、すべての状態フィールドを含む「キー フレーム」メッセージを送信します。 キー フレーム間では、状態メッセージには、最後のメッセージ以降に変更されたフィールドのみが含まれます。 この動作は、帯域幅と電力を節約する必要がある多くの IoT デバイスの典型的な例です。

  • Web アプリ。 Web アプリケーションでは、ユーザーがデバイスを検索し、デバイスの直前の既知の状態をクエリすることができます。 ユーザーは、アプリケーションにサインインし、Microsoft Entra ID で認証する必要があります。 アプリケーションは、アプリにアクセスする権限があるユーザーからの要求のみを許可します。

クエリの結果が表示されている Web アプリのスクリーンショットを、次に示します。

クライアント アプリのスクリーンショット

アプリケーションを設計する

Fabrikam では、Azure Functions を使用して、アプリケーションのビジネス ロジックを実装することにしました。 Azure Functions は「サービスとしての関数」(FaaS) の一例です。 このコンピューティング モデルでは、"関数" は、クラウドにデプロイされホスティング環境で実行される一片のコードです。 このホスティング環境は、コードを実行するサーバーを抽象化します。

サーバーレスのアプローチを選択する理由

Functions を使用したサーバーレス アーキテクチャは、イベント ドリブン アーキテクチャの一例です。 関数コードは、関数の外部にあるいくつかのイベントによってトリガーされます。この例では、ドローンからのメッセージ、またはクライアント アプリケーションからの HTTP 要求です。 関数アプリでは、トリガーのコードを記述する必要はありません。 記述するコードは、トリガーに応答して実行されるコードのみです。 つまり、メッセージングなどのインフラストラクチャの問題を処理するコードを多数記述するのではなく、ビジネス ロジックに焦点を合わせることができます。

また、サーバーレス アーキテクチャの使用にはいくつかの運用上の利点もあります。

  • サーバーを管理する必要はありません。
  • コンピューティング リソースは、必要に応じて動的に割り当てられます。
  • ご自身のコードの実行に使用されたコンピューティング リソースに対してのみ課金されます。
  • コンピューティング リソースはトラフィックに基づいてオンデマンドでスケーリングされます。

Architecture

次の図は、アプリケーションのアーキテクチャの概略を示しています。

サーバーレス Functions アプリケーションの高レベル アーキテクチャを示している図。

あるデータ フローでは、矢印は、デバイスから Event Hubs に送信されるメッセージと、関数アプリのトリガーを示しています。 アプリからは、配信不能メッセージをストレージ キューに送信する 1 つの矢印と、Azure Cosmos DB への書き込みを示すもう 1 つの矢印が出ています。 別のデータフローでは、矢印は、CDN を介して BLOB ストレージの静的 Web ホスティングから静的ファイルを取得するクライアント Web アプリを示しています。 もう 1 つの矢印は、API Management を通過するクライアント HTTP 要求を示しています。 API Management からの 1 つの矢印は、関数アプリのトリガーと Azure Cosmos DB からのデータの読み取りを示しています。 別の矢印は、Microsoft Entra ID による認証を示しています。 ユーザーは Microsoft Entra ID にもサインインします。

イベントの取り込み:

  1. ドローンのメッセージは、Azure Event Hubs によって取り込まれます。
  2. Event Hubs は、メッセージ データを含むイベントのストリームを生成します。
  3. これらのイベントは、それらを処理するために Azure Functions アプリをトリガーします。
  4. 結果は Azure Cosmos DB に格納されます。

Web アプリ:

  1. 静的ファイルは、BLOB ストレージから CDN によって提供されます。
  2. ユーザーは Microsoft Entra ID を使用して Web アプリにサインインします。
  3. Azure API Management は、REST API エンドポイントを公開するゲートウェイとして機能します。
  4. クライアント トリガーからの HTTP 要求は、Azure Cosmos DB から読み取って結果を返す、Azure Functions アプリをトリガーします。

このアプリケーションは、上記で説明した 2 つの機能ブロックに相当する、2 つの参照アーキテクチャに基づいています。

アーキテクチャの概要、このソリューションで使用されている Azure サービス、拡張性、セキュリティ、および信頼性に関する考慮事項については、これらの記事を参照してください。

ドローン テレメトリ関数

まず、Event Hubs からのドローンのメッセージを処理する関数を見てみましょう。 この関数は RawTelemetryFunction という名前のクラス内に定義されています。

namespace DroneTelemetryFunctionApp
{
    public class RawTelemetryFunction
    {
        private readonly ITelemetryProcessor telemetryProcessor;
        private readonly IStateChangeProcessor stateChangeProcessor;
        private readonly TelemetryClient telemetryClient;

        public RawTelemetryFunction(ITelemetryProcessor telemetryProcessor, IStateChangeProcessor stateChangeProcessor, TelemetryClient telemetryClient)
        {
            this.telemetryProcessor = telemetryProcessor;
            this.stateChangeProcessor = stateChangeProcessor;
            this.telemetryClient = telemetryClient;
        }
    }
    ...
}

このクラスにはいくつかの依存関係があり、それらは依存関係の挿入を使用してコンストラクターに挿入されています。

  • ITelemetryProcessorIStateChangeProcessor のインターフェイスは、2 つのヘルパー オブジェクトを定義します。 ご覧のとおり、これらのオブジェクトがほとんどの作業を行います。

  • TelemetryClient は Application Insights SDK (クラシック API) の一部です。 これは、Application Insights にカスタムのアプリケーション メトリックを送信するために使用されます。

後で、依存関係の挿入を構成する方法を説明します。 ここでは、単にこれらの依存関係が存在すると仮定します。

Event Hubs トリガーを構成する

この関数のロジックは、RunAsync という名前の非同期のメソッドとして実装されています。 このメソッド シグネチャを次に示します。

[FunctionName("RawTelemetryFunction")]
[StorageAccount("DeadLetterStorage")]
public async Task RunAsync(
    [EventHubTrigger("%EventHubName%", Connection = "EventHubConnection", ConsumerGroup ="%EventHubConsumerGroup%")]EventData[] messages,
    [Queue("deadletterqueue")] IAsyncCollector<DeadLetterMessage> deadLetterMessages,
    ILogger logger)
{
    // implementation goes here
}

このメソッドは次のパラメーターを受け取ります。

  • messages は、イベント ハブのメッセージの配列として表されます。
  • deadLetterMessages は、配信不能メッセージの格納に使用される Azure Storage キューです。
  • logging は、アプリケーション ログを書き込むためのログ記録のインターフェイスを提供します。 これらのログは Azure Monitor に送信されます。

messages パラメーターの EventHubTrigger 属性は、トリガーを構成します。 属性のプロパティは、イベント ハブ名、接続文字列、およびコンシューマー グループを指定します。 ( コンシューマー グループは、Event Hubs イベント ストリームの分離ビューです。この抽象化により、同じイベント ハブの複数のコンシューマーが使用できます。)

いくつかの属性プロパティのパーセント記号 (%) に注意してください。 これらは、プロパティがアプリ設定の名前を指定し、実際の値が実行時にそのアプリ設定から取得されることを示します。 そうではなく、パーセント記号がない場合、プロパティはリテラル値を指定します。

Connection プロパティは例外です。 このプロパティは常にアプリ設定名を指定し、リテラル値を指定することはないため、パーセント記号は必要ありません。 このような区別があるのは、接続文字列がシークレットであり、決してソース コードにチェックインするべきではないためです。

他の 2 つのプロパティ (イベント ハブ名とコンシューマー グループ) は接続文字列などの機密データではありませんが、それでも、ハード コーディングするのではなく、アプリ設定に入れることをお勧めします。 これにより、アプリを再コンパイルしなくても更新することができます。

このトリガーの構成の詳細については、「Azure Functions における Azure Event Hubs のバインド」をご覧ください。

メッセージ処理ロジック

メッセージをバッチで処理する RawTelemetryFunction.RunAsync メソッドの実装を、次に示します。

[FunctionName("RawTelemetryFunction")]
[StorageAccount("DeadLetterStorage")]
public async Task RunAsync(
    [EventHubTrigger("%EventHubName%", Connection = "EventHubConnection", ConsumerGroup ="%EventHubConsumerGroup%")]EventData[] messages,
    [Queue("deadletterqueue")] IAsyncCollector<DeadLetterMessage> deadLetterMessages,
    ILogger logger)
{
    telemetryClient.GetMetric("EventHubMessageBatchSize").TrackValue(messages.Length);

    foreach (var message in messages)
    {
        DeviceState deviceState = null;

        try
        {
            deviceState = telemetryProcessor.Deserialize(message.Body.Array, logger);

            try
            {
                await stateChangeProcessor.UpdateState(deviceState, logger);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Error updating status document", deviceState);
                await deadLetterMessages.AddAsync(new DeadLetterMessage { Exception = ex, EventData = message, DeviceState = deviceState });
            }
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Error deserializing message", message.SystemProperties.PartitionKey, message.SystemProperties.SequenceNumber);
            await deadLetterMessages.AddAsync(new DeadLetterMessage { Exception = ex, EventData = message });
        }
    }
}

この関数が呼び出されるとき、messages パラメーターにはイベント ハブからのメッセージの配列が含まれています。 メッセージをバッチで処理することで、通常は、1 つのメッセージを一度に読み取るよりもパフォーマンスが向上します。 ただし、関数に回復力があり、エラーと例外を適切に処理することを確認する必要があります。 そうではなく、関数がバッチの途中で未処理の例外をスローする場合は、残りのメッセージが失われる可能性があります。 この考慮事項については、「エラー処理」のセクションで詳しく説明します。

ただし、例外処理を無視すれば、各メッセージの処理ロジックは単純です。

  1. ITelemetryProcessor.Deserialize を呼び出して、デバイスの状態変更が含まれているメッセージを逆シリアル化します。
  2. IStateChangeProcessor.UpdateState を呼び出して、状態変更を処理します。

これらの 2 つのメソッドの詳細について、Deserialize メソッドから見てみましょう。

メソッドを逆シリアル化する

TelemetryProcess.Deserialize メソッドは、メッセージ ペイロードを含むバイト配列を取得します。 これは、このペイロードを逆シリアル化し、DeviceState オブジェクトを返して、ドローンの状態を表します。 この状態は、直前の既知の状態からの差分だけを含む、部分的な更新を表すことがあります。 そのため、このメソッドは、逆シリアル化されたペイロードの null フィールドを処理する必要があります。

public class TelemetryProcessor : ITelemetryProcessor
{
    private readonly ITelemetrySerializer<DroneState> serializer;

    public TelemetryProcessor(ITelemetrySerializer<DroneState> serializer)
    {
        this.serializer = serializer;
    }

    public DeviceState Deserialize(byte[] payload, ILogger log)
    {
        DroneState restored = serializer.Deserialize(payload);

        log.LogInformation("Deserialize message for device ID {DeviceId}", restored.DeviceId);

        var deviceState = new DeviceState();
        deviceState.DeviceId = restored.DeviceId;

        if (restored.Battery != null)
        {
            deviceState.Battery = restored.Battery;
        }
        if (restored.FlightMode != null)
        {
            deviceState.FlightMode = (int)restored.FlightMode;
        }
        if (restored.Position != null)
        {
            deviceState.Latitude = restored.Position.Value.Latitude;
            deviceState.Longitude = restored.Position.Value.Longitude;
            deviceState.Altitude = restored.Position.Value.Altitude;
        }
        if (restored.Health != null)
        {
            deviceState.AccelerometerOK = restored.Health.Value.AccelerometerOK;
            deviceState.GyrometerOK = restored.Health.Value.GyrometerOK;
            deviceState.MagnetometerOK = restored.Health.Value.MagnetometerOK;
        }
        return deviceState;
    }
}

このメソッドは、別のヘルパー インターフェイス、ITelemetrySerializer<T> を使用して、未加工のメッセージを逆シリアル化します。 その後、結果が、より簡単に操作できる POCO モデルに変換されます。 この設計は、シリアル化の実装の詳細から処理ロジックを分離するのに役立ちます。 ITelemetrySerializer<T> インターフェイスは共有ライブラリ内に定義されます。これは、シミュレートされたデバイス イベントを生成して Event Hubs に送信するために、デバイス シミュレーターでも使用されます。

using System;

namespace Serverless.Serialization
{
    public interface ITelemetrySerializer<T>
    {
        T Deserialize(byte[] message);

        ArraySegment<byte> Serialize(T message);
    }
}

UpdateState メソッド

StateChangeProcessor.UpdateState メソッドは状態変更に適用されます。 各ドローンの直前の既知の状態は、Azure Cosmos DB に JSON ドキュメントとして格納されます。 ドローンは部分的な更新を送信するため、更新を取得するときに、アプリケーションが単純にドキュメントを上書きすることはできません。 代わりに、前の状態をフェッチし、フィールドをマージしてから、アップサート操作を実行する必要があります。

public class StateChangeProcessor : IStateChangeProcessor
{
    private IDocumentClient client;
    private readonly string cosmosDBDatabase;
    private readonly string cosmosDBCollection;

    public StateChangeProcessor(IDocumentClient client, IOptions<StateChangeProcessorOptions> options)
    {
        this.client = client;
        this.cosmosDBDatabase = options.Value.COSMOSDB_DATABASE_NAME;
        this.cosmosDBCollection = options.Value.COSMOSDB_DATABASE_COL;
    }

    public async Task<ResourceResponse<Document>> UpdateState(DeviceState source, ILogger log)
    {
        log.LogInformation("Processing change message for device ID {DeviceId}", source.DeviceId);

        DeviceState target = null;

        try
        {
            var response = await client.ReadDocumentAsync(UriFactory.CreateDocumentUri(cosmosDBDatabase, cosmosDBCollection, source.DeviceId),
                                                            new RequestOptions { PartitionKey = new PartitionKey(source.DeviceId) });

            target = (DeviceState)(dynamic)response.Resource;

            // Merge properties
            target.Battery = source.Battery ?? target.Battery;
            target.FlightMode = source.FlightMode ?? target.FlightMode;
            target.Latitude = source.Latitude ?? target.Latitude;
            target.Longitude = source.Longitude ?? target.Longitude;
            target.Altitude = source.Altitude ?? target.Altitude;
            target.AccelerometerOK = source.AccelerometerOK ?? target.AccelerometerOK;
            target.GyrometerOK = source.GyrometerOK ?? target.GyrometerOK;
            target.MagnetometerOK = source.MagnetometerOK ?? target.MagnetometerOK;
        }
        catch (DocumentClientException ex)
        {
            if (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
            {
                target = source;
            }
        }

        var collectionLink = UriFactory.CreateDocumentCollectionUri(cosmosDBDatabase, cosmosDBCollection);
        return await client.UpsertDocumentAsync(collectionLink, target);
    }
}

このコードでは、IDocumentClient インターフェイスを使用して、Azure Cosmos DB からドキュメントをフェッチします。 ドキュメントが存在する場合は、新しい状態値が既存のドキュメントにマージされます。 そうでない場合は、新しいドキュメントが作成されます。 どちらの場合も、UpsertDocumentAsync メソッドによって処理されます。

このコードは、ドキュメントが既に存在していてマージできる場合に最適です。 指定されたドローンからの最初のテレメトリ メッセージでは、そのドローンのドキュメントがないため、ReadDocumentAsync メソッドは例外をスローします。 最初のメッセージの後、ドキュメントが使用可能になります。

このクラスは、依存関係の挿入を使用して、Azure Cosmos DB 用の IDocumentClient と構成設定を含む IOptions<T> を挿入することに注意してください。 依存関係の挿入を設定する方法については、後で説明します。

注意

Azure Functions では、Azure Cosmos DB 用の出力バインディングがサポートされています。 このバインディングにより、関数アプリでは、コードを記述せずに Azure Cosmos DB でドキュメントを記述できます。 ただし、カスタムのアップサート ロジックが必要なため、出力バインディングはこの特定のシナリオでは機能しません。

エラー処理

前述のように、RawTelemetryFunction 関数アプリはループ内のメッセージをバッチで処理します。 つまり、関数は、例外を適切に処理して、バッチの残りの部分の処理を継続する必要があります。 そうでない場合は、メッセージが欠落する可能性があります。

メッセージを処理しているときに例外が発生した場合、関数はメッセージを配信不能キューに入れます。

catch (Exception ex)
{
    logger.LogError(ex, "Error deserializing message", message.SystemProperties.PartitionKey, message.SystemProperties.SequenceNumber);
    await deadLetterMessages.AddAsync(new DeadLetterMessage { Exception = ex, EventData = message });
}

配信不能キューは、ストレージ キューへの出力バインディングを使用して定義されます。

[FunctionName("RawTelemetryFunction")]
[StorageAccount("DeadLetterStorage")]  // App setting that holds the connection string
public async Task RunAsync(
    [EventHubTrigger("%EventHubName%", Connection = "EventHubConnection", ConsumerGroup ="%EventHubConsumerGroup%")]EventData[] messages,
    [Queue("deadletterqueue")] IAsyncCollector<DeadLetterMessage> deadLetterMessages,  // output binding
    ILogger logger)

ここで、Queue 属性は出力バインディングを指定し、StorageAccount 属性は、ストレージ アカウントの接続文字列を保持するアプリ設定の名前を指定します。

依存関係の挿入の設定

次のコードでは、RawTelemetryFunction 関数の依存関係の挿入を設定します。

[assembly: FunctionsStartup(typeof(DroneTelemetryFunctionApp.Startup))]

namespace DroneTelemetryFunctionApp
{
    public class Startup : FunctionsStartup
    {
        public override void Configure(IFunctionsHostBuilder builder)
        {
            builder.Services.AddOptions<StateChangeProcessorOptions>()
                .Configure<IConfiguration>((configSection, configuration) =>
                {
                    configuration.Bind(configSection);
                });

            builder.Services.AddTransient<ITelemetrySerializer<DroneState>, TelemetrySerializer<DroneState>>();
            builder.Services.AddTransient<ITelemetryProcessor, TelemetryProcessor>();
            builder.Services.AddTransient<IStateChangeProcessor, StateChangeProcessor>();

            builder.Services.AddSingleton<CosmosClient>(ctx => {
                var config = ctx.GetService<IConfiguration>();
                var cosmosDBEndpoint = config.GetValue<string>("CosmosDBEndpoint");
                return new CosmosClient(
                    accountEndpoint: cosmosDBEndpoint,
                    new DefaultAzureCredential());
            });
        }
    }
}

.NET 用に作成された Azure Functions では、ASP.NET Core の依存関係の挿入フレームワークを使用できます。 基本的な考え方としては、アセンブリの起動メソッドを宣言します。 このメソッドは、DI の依存関係を宣言するために使用される IFunctionsHostBuilder インターフェイスを取得します。 これは、Services オブジェクトで Add* メソッドを呼び出すことによって行います。 依存関係を追加するときは、その有効期間を指定します。

  • 一時オブジェクトは、要求されるたびに作成されます。
  • スコープ付きオブジェクトは、関数の実行ごとに 1 回作成されます。
  • シングルトン オブジェクトは、関数ホストの有効期間内の、関数の実行全体で再利用されます。

この例では、TelemetryProcessor オブジェクトと StateChangeProcessor オブジェクトは一時として宣言されています。 これは、軽量なステートレス サービスに適しています。 その一方で、DocumentClient クラスは、最適なパフォーマンスのためにはシングルトンである必要があります。 詳細については、「Azure Cosmos DB と .NET のパフォーマンスに関するヒント」をご覧ください。

RawTelemetryFunction のコードに戻って確認すると、DI セットアップ コードに存在しないもう 1 つの依存関係、つまり、アプリケーション メトリックのログ記録に使用される TelemetryClient クラスがあることがわかります。 Functions ランタイムはこのクラスを自動的に DI コンテナーに登録するため、明示的に登録する必要はありません。

Azure Functions の DI の詳細については、次の記事をご覧ください。

DI での構成設定の引き渡し

オブジェクトによっては、いくつかの構成値で初期化する必要がある場合があります。 一般に、これらの設定は、アプリ設定から、または Azure Key Vault から (シークレットの場合) 取得されます。

このアプリケーションの 2 つの例を次に示します。 最初の例では、DocumentClient クラスが Azure Cosmos DB サービスのエンドポイントとキーを取得します。 このオブジェクトの場合、アプリケーションは、DI コンテナーによって呼び出されるラムダを登録します。 このラムダは、IConfiguration インターフェイスを使用して構成値を読み取ります。

builder.Services.AddSingleton<CosmosClient>(ctx => {
    var config = ctx.GetService<IConfiguration>();
    var cosmosDBEndpoint = config.GetValue<string>("CosmosDBEndpoint");
    return new CosmosClient(
                    accountEndpoint: cosmosDBEndpoint,
                    new DefaultAzureCredential());
});

2 番目の例は StateChangeProcessor クラスです。 このオブジェクトの場合は、オプションのパターンというアプローチを使用します。 しくみは次のとおりです。

  1. 構成設定が含まれる T クラスを定義します。 この例では、Azure Cosmos DB データベース名とコレクション名です。

    public class StateChangeProcessorOptions
    {
        public string COSMOSDB_DATABASE_NAME { get; set; }
        public string COSMOSDB_DATABASE_COL { get; set; }
    }
    
  2. DI のオプション クラスとしてクラス T を追加します。

    builder.Services.AddOptions<StateChangeProcessorOptions>()
        .Configure<IConfiguration>((configSection, configuration) =>
        {
            configuration.Bind(configSection);
        });
    
  3. 構成しているクラスのコンストラクターに、IOptions<T> パラメーターを含めます。

    public StateChangeProcessor(IDocumentClient client, IOptions<StateChangeProcessorOptions> options)
    

DI システムは、構成値を含むオプション クラスを自動的に読み込んで、これをコンストラクターに渡します。

このアプローチにはいくつかの利点があります。

  • 構成値のソースからクラスを分離します。
  • 環境変数や JSON 構成ファイルなどの異なる構成ソースを簡単に設定します。
  • 単体テストを簡略化します。
  • 厳密に型指定されたオプション クラスを使用します。これにより、スカラー値を渡すだけの場合よりもエラーが少なくなります。

GetStatus 関数

このソリューションのその他の関数アプリは、ドローンの直前の既知の状態を取得するための単純な REST API を実装します。 この関数は GetStatusFunction という名前のクラス内に定義されています。 この関数の完全なコードを次に示します。

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using System.Security.Claims;
using System.Threading.Tasks;

namespace DroneStatusFunctionApp
{
    public static class GetStatusFunction
    {
        public const string GetDeviceStatusRoleName = "GetStatus";

        [FunctionName("GetStatusFunction")]
        public static IActionResult Run(
            [HttpTrigger(AuthorizationLevel.Function, "get", Route = null)]HttpRequest req,
            [CosmosDB(
                databaseName: "%COSMOSDB_DATABASE_NAME%",
                collectionName: "%COSMOSDB_DATABASE_COL%",
                ConnectionStringSetting = "COSMOSDB_CONNECTION_STRING",
                Id = "{Query.deviceId}",
                PartitionKey = "{Query.deviceId}")] dynamic deviceStatus,
            ClaimsPrincipal principal,
            ILogger log)
        {
            log.LogInformation("Processing GetStatus request.");

            if (!principal.IsAuthorizedByRoles(new[] { GetDeviceStatusRoleName }, log))
            {
                return new UnauthorizedResult();
            }

            string deviceId = req.Query["deviceId"];
            if (deviceId == null)
            {
                return new BadRequestObjectResult("Missing DeviceId");
            }

            if (deviceStatus == null)
            {
                return new NotFoundResult();
            }
            else
            {
                return new OkObjectResult(deviceStatus);
            }
        }
    }
}

この関数は、HTTP トリガーを使用して HTTP GET 要求を処理します。 この関数は、Azure Cosmos DB 入力バインディングを使用して、要求されたドキュメントをフェッチします。 1 つの考慮事項は、このバインディングは承認ロジックが関数内で実行される前に実行されることです。 承認されていないユーザーがドキュメントを要求した場合、この関数バインドは引き続きドキュメントをフェッチします。 その場合、承認コードは 401 を返すため、ユーザーにドキュメントは表示されません。 この動作を許容できるかどうかは、要件によって異なります。 たとえば、このアプローチでは、機密データのデータ アクセスの監査が難しくなる可能性があります。

認証と権限承認

Web アプリは、Microsoft Entra ID を使用してユーザーを認証します。 アプリはブラウザーで実行されているシングルページ アプリケーション (SPA) であるため、 認証コード フロー が適切です。

  1. Web アプリは、ユーザーを ID プロバイダー (この例では、Microsoft Entra ID) にリダイレクトします。
  2. ユーザーは自分の資格情報を入力します。
  3. ID プロバイダーは、後でアクセス トークンと交換できる承認コードを使用して Web アプリにリダイレクトします。
  4. Web アプリは Web API に要求を送信し、Authorization ヘッダーにリソースのアクセス トークンを含めます。

承認フローの図

関数アプリケーションは、コードなしでユーザーを認証するように構成できます。 詳細については、「 Azure App Service での認証および承認」を参照してください。

その一方で、承認には、通常は何らかのビジネス ロジックが必要です。 Microsoft Entra ID は、要求ベースの認証がサポートされています。 このモデルでは、ユーザーの ID は、ID プロバイダーから送信される要求のセットとして表されます。 要求は、ユーザーの名前やメール アドレスなど、ユーザーに関する情報の一部である場合があります。

アクセス トークンには、ユーザーの要求のサブセットが含まれています。 これらの例として、ユーザーが割り当てられているアプリケーション ロールが挙げられます。

関数の principal パラメーターは、アクセス トークンからの要求が含まれる ClaimsPrincipal オブジェクトです。 各要求は、要求の種類と要求の値のキーと値のペアです。 アプリケーションはこれらを使用して要求を承認します。

次の拡張メソッドは、ClaimsPrincipal オブジェクトにロールのセットが含まれるかどうかをテストします。 指定されたロールのいずれかが不足している場合は、false が返されます。 このメソッドが false を返す場合、関数は HTTP 401 (未承認) を返します。

namespace DroneStatusFunctionApp
{
    public static class ClaimsPrincipalAuthorizationExtensions
    {
        public static bool IsAuthorizedByRoles(
            this ClaimsPrincipal principal,
            string[] roles,
            ILogger log)
        {
            var principalRoles = new HashSet<string>(principal.Claims.Where(kvp => kvp.Type == "roles").Select(kvp => kvp.Value));
            var missingRoles = roles.Where(r => !principalRoles.Contains(r)).ToArray();
            if (missingRoles.Length > 0)
            {
                log.LogWarning("The principal does not have the required {roles}", string.Join(", ", missingRoles));
                return false;
            }

            return true;
        }
    }
}

このアプリケーションでの認証と承認の詳細については、参照アーキテクチャのセキュリティに関する考慮事項に関するページをご覧ください。

次のステップ

この参照ソリューションのしくみを理解したら、同様のソリューションのベスト プラクティスと推奨事項について学習します。

Azure Functions は、Azure のコンピューティング オプションの 1 つにすぎません。 コンピューティング テクノロジの選択については、「アプリケーションの Azure コンピューティング サービスを選択する」を参照してください。