Procedura dettagliata per il codice: Applicazione serverless con Funzioni

Hub eventi di Azure
Funzioni di Azure

I modelli serverless astraggono il codice dall'infrastruttura di calcolo sottostante, consentendo agli sviluppatori di concentrarsi sulla logica di business senza una configurazione completa. Il codice serverless riduce i costi, perché si paga solo per le risorse e la durata di esecuzione del codice.

Il modello serverless basato su eventi si adatta alle situazioni in cui un determinato evento attiva un'azione definita. Ad esempio, la ricezione nel dispositivo di messaggi in ingresso attiva l'archiviazione per uso futuro, oppure un aggiornamento del database attiva un'ulteriore elaborazione.

Per esplorare le tecnologie serverless di Azure in Azure, Microsoft ha sviluppato e testato un'applicazione serverless che usa Funzioni di Azure. Questo articolo illustra il codice per la soluzione Funzioni serverless e descrive le decisioni di progettazione, i dettagli di implementazione e alcuni dei "problemi noti" che potrebbero verificarsi.

Esplorare la soluzione

Questa soluzione in due parti descrive un ipotetico sistema di recapito tramite drone. I droni inviano lo stato in volo al cloud, in cui questi messaggi vengono archiviati per uso futuro. Un'app Web consente agli utenti di recuperare i messaggi per ottenere lo stato più recente dei dispositivi.

È possibile scaricare il codice per questa soluzione da GitHub.

Questa procedura dettagliata presuppone una conoscenza di base delle seguenti tecnologie:

Non è necessario essere esperti di Funzioni o Hub eventi, ma è consigliabile avere una buona familiarità con le relative funzionalità. Ecco alcune risorse utili per iniziare:

Informazioni sullo scenario

Diagram of the functional blocks

Fabrikam gestisce una flotta di droni per un servizio di consegna tramite droni. L'applicazione è costituita da due aree funzionali principali:

  • Inserimento di eventi. Durante il volo, i droni inviano messaggi di stato a un endpoint cloud. L'applicazione inserisce ed elabora questi messaggi e scrive i risultati in un database back-end (Azure Cosmos DB). I dispositivi inviano messaggi in formato Protocol Buffers (Protobuf). Protobuf è un formato di serializzazione efficiente e autoesplicativo.

    Questi messaggi contengono aggiornamenti parziali. A intervalli fissi, ogni drone invia un messaggio di tipo "fotogramma chiave" che contiene tutti i campi di stato. Tra i fotogrammi chiave, i messaggi di stato includono solo i campi che sono cambiati rispetto all'ultimo messaggio. Questo comportamento è tipico di molti dispositivi IoT per cui è necessario conservare larghezza di banda ed energia.

  • App Web. Un'applicazione Web consente agli utenti di cercare un dispositivo ed eseguire query sul relativo ultimo stato noto. Gli utenti devono accedere all'applicazione ed eseguire l'autenticazione con Microsoft Entra ID. L'applicazione consente solo le richieste di utenti autorizzati ad accedere all'app.

Ecco uno screenshot dell'app, che mostra i risultati di una query:

Screenshot of client app

Progettare l'applicazione

Fabrikam ha deciso di usare Funzioni di Azure per implementare la logica di business dell'applicazione. Funzioni di Azure è un esempio di "funzioni come servizio" (FaaS). In questo modello di calcolo, una funzione è un frammento di codice distribuito nel cloud ed eseguito in un ambiente di hosting. Questo ambiente di hosting astrae completamente i server che eseguono il codice.

Perché scegliere un approccio serverless?

Un'architettura serverless con Funzioni è un esempio di architettura basata su eventi. Il codice della funzione viene attivato da un evento esterno alla stessa, in questo caso un messaggio da un drone oppure una richiesta HTTP di un'applicazione client. Con un'app per le funzioni non è necessario scrivere codice per il trigger. Si deve scrivere solo il codice che viene eseguito in risposta al trigger. Questo significa che è possibile concentrarsi sulla logica di business invece di scrivere molto codice per gestire aspetti dell'infrastruttura come la messaggistica.

L'uso di un'architettura serverless offre anche alcuni vantaggi operativi:

  • Non è necessario gestire i server.
  • Le risorse di calcolo vengono allocate dinamicamente in base alle esigenze.
  • vengono addebitati solo i costi per le risorse di calcolo usate per eseguire il codice.
  • Le risorse di calcolo sono scalabili su richiesta in base al traffico.

Architettura

Il diagramma seguente mostra l'architettura di alto livello dell'applicazione:

Diagram showing the high-level architecture of the serverless Functions application.

In un flusso di dati le frecce mostrano i messaggi che vengono inviati da Dispositivi a Hub eventi e attivano l'app per le funzioni. Dall'app, una freccia mostra i messaggi non consegnati a una coda di archiviazione e un'altra freccia mostra la scrittura in Azure Cosmos DB. In un altro flusso di dati, le frecce indicano l'app Web client che ottiene file statici dall'hosting Web statico dell'archiviazione BLOB, tramite una rete CDN. Un'altra freccia mostra la richiesta HTTP del client che passa attraverso Gestione API. Da Gestione API, una freccia mostra l'app per le funzioni che attiva e legge i dati da Azure Cosmos DB. Un'altra freccia mostra l'autenticazione tramite Microsoft Entra ID. Un utente accede anche a Microsoft Entra ID.

Inserimento di eventi:

  1. i messaggi dei droni vengono inseriti da Hub eventi di Azure.
  2. Hub eventi produce un flusso di eventi che contengono i dati dei messaggi.
  3. Questi eventi attivano un'app di Funzioni di Azure per elaborarli.
  4. I risultati vengono archiviati in Azure Cosmos DB.

App Web:

  1. i file statici vengono forniti tramite rete CDN dall'archiviazione BLOB.
  2. Un utente accede all'app Web usando Microsoft Entra ID.
  3. Gestione API di Azure funge da gateway che espone un endpoint API REST.
  4. Le richieste HTTP dal client attivano un'app Funzioni di Azure che legge da Azure Cosmos DB e restituisce il risultato.

Questa applicazione è basata su due architetture di riferimento, che corrispondono ai due blocchi funzionali descritti sopra:

È possibile leggere questi articoli per altre informazioni sull'architettura di alto livello, sui servizi di Azure usati nella soluzione e sugli aspetti da considerare relativi a scalabilità, sicurezza e affidabilità.

Funzione di telemetria dei droni

Per iniziare, verrà esaminata la funzione che elabora i messaggi dei droni provenienti da Hub eventi. La funzione è definita in una classe denominata 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;
        }
    }
    ...
}

Questa classe presenta diverse dipendenze, che vengono inserite nel costruttore tramite inserimento delle dipendenze:

  • Le interfacce ITelemetryProcessor e IStateChangeProcessor definiscono due oggetti helper. Come verrà descritto in seguito, questi oggetti eseguono la maggior parte delle operazioni.

  • TelemetryClient fa parte di Application Insights SDK. Si usa per inviare metriche dell'applicazione personalizzate ad Application Insights.

In seguito verrà illustrato come configurare l'inserimento delle dipendenze. Per il momento, è sufficiente presumere che tali dipendenze esistano.

Configurare il trigger di Hub eventi

La logica della funzione viene implementata con un metodo asincrono denominato RunAsync. Ecco la firma del metodo:

[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
}

Il metodo accetta i parametri seguenti:

  • messages è una matrice di messaggi dell'hub eventi.
  • deadLetterMessages è una coda di archiviazione di Azure, usata per archiviare i messaggi non recapitabili.
  • logging fornisce un'interfaccia di registrazione per la scrittura dei log dell'applicazione. Questi log vengono inviati a Monitoraggio di Azure.

L'attributo EventHubTrigger del parametro messages configura il trigger. Le proprietà dell'attributo specificano un nome di hub eventi, una stringa di connessione e un gruppo di consumer. Un gruppo di consumer è una visualizzazione isolata del flusso di eventi di Hub eventi. Questa astrazione consente a più consumer dello stesso hub eventi.

Si notino i segni di percentuale (%) in alcune proprietà dell'attributo. Indicano che la proprietà specifica il nome di un'impostazione dell'app, che acquisisce il valore effettivo in fase di esecuzione. In caso contrario, senza segni di percentuale, la proprietà fornisce il valore letterale.

La proprietà Connection rappresenta un'eccezione. Infatti, specifica sempre il nome di un'impostazione dell'app e mai un valore letterale, quindi il segno di percentuale non è necessario. Il motivo di questa distinzione è che una stringa di connessione è segreta e non deve mai essere archiviata nel codice sorgente.

Anche se le altre due proprietà (nome dell'hub eventi e gruppo di consumer) non sono dati sensibili come le stringhe di connessione, è comunque preferibile inserirle nelle impostazioni dell'app invece di definirle come hardcoded. In questo modo, possono essere aggiornate senza ricompilare l'app.

Per altre informazioni sulla configurazione di questo trigger, vedere Binding di Hub eventi per Funzioni di Azure.

Logica di elaborazione dei messaggi

Ecco l'implementazione del metodo RawTelemetryFunction.RunAsync che elabora un batch di messaggi:

[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 });
        }
    }
}

Quando la funzione viene richiamata, il parametro messages contiene una matrice di messaggi provenienti dall'hub eventi. L'elaborazione di messaggi in batch offre in genere prestazioni più elevate rispetto alla lettura di un messaggio alla volta. Tuttavia, è necessario assicurarsi che la funzione sia resiliente e che gestisca normalmente errori ed eccezioni. In caso contrario, se la funzione genera un'eccezione non gestita nel mezzo di un batch, i messaggi rimanenti potrebbero andare persi. Questa considerazione viene descritta in maggior dettaglio nella sezione Gestione degli errori.

Ma se si ignora la gestione delle eccezioni, la logica di elaborazione per ogni messaggio è semplice:

  1. Chiamare ITelemetryProcessor.Deserialize per deserializzare il messaggio che contiene una modifica dello stato di un dispositivo.
  2. Chiamare IStateChangeProcessor.UpdateState per elaborare la modifica dello stato.

Questi metodi vengono descritti in maggior dettaglio di seguito, a partire dal metodo Deserialize.

Metodo Deserialize

Il metodo TelemetryProcess.Deserialize accetta una matrice di byte che contiene il payload del messaggio. Deserializza questo payload e restituisce un oggetto DeviceState, che rappresenta lo stato di un drone. Lo stato può rappresentare un aggiornamento parziale, contenente solo il delta dell'ultimo stato noto. Pertanto, il metodo deve gestire i campi null nel payload deserializzato.

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;
    }
}

Questo metodo usa un'altra interfaccia helper, ITelemetrySerializer<T>, per deserializzare il messaggio non elaborato. I risultati vengono quindi trasformati in un modello POCO più facile da gestire. Questo modello consente di isolare la logica di elaborazione dai dettagli di implementazione della serializzazione. L'interfaccia ITelemetrySerializer<T> è definita come libreria condivisa, che viene anche usata dal simulatore di dispositivi per generare eventi di dispositivi simulati e inviarli ad Hub eventi.

using System;

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

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

Metodo UpdateState

Il metodo StateChangeProcessor.UpdateState applica le modifiche dello stato. L'ultimo stato noto per ogni drone viene archiviato come documento JSON in Azure Cosmos DB. Poiché i droni inviano aggiornamenti parziali, l'applicazione non può semplicemente sovrascrivere il documento quando riceve un aggiornamento. Deve invece recuperare lo stato precedente, unire i campi e quindi eseguire un'operazione upsert.

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);
    }
}

Questo codice usa l'interfaccia IDocumentClient per recuperare un documento da Azure Cosmos DB. Se il documento esiste, i valori del nuovo stato vengono uniti al suo interno. Altrimenti, viene creato un nuovo documento. Entrambi i casi vengono gestiti dal metodo UpsertDocumentAsync.

Questo codice è ottimizzato per il caso in cui il documento esista già e possa essere unito. Nel primo messaggio di telemetria inviato da uno specifico drone, il metodo ReadDocumentAsync genererà un'eccezione, perché non sono disponibili documenti per quel drone. Dopo il primo messaggio, il documento sarà disponibile.

Si noti che questa classe usa l'inserimento delle dipendenze per inserire IDocumentClient per Azure Cosmos DB e un oggetto IOptions<T> con le impostazioni di configurazione. Più avanti verrà descritto come configurare l'inserimento delle dipendenze.

Nota

Funzioni di Azure supporta un'associazione di output per Azure Cosmos DB. Questa associazione consente all'app per le funzioni di scrivere documenti in Azure Cosmos DB senza codice. Tuttavia, il binding di output non funziona in questo specifico scenario, a causa della logica di upsert personalizzata necessaria.

Gestione errori

Come accennato in precedenza, l'app per le funzioni RawTelemetryFunction elabora un batch di messaggi in un ciclo. Quindi, la funzione deve gestire normalmente le eventuali eccezioni e continuare a elaborare il resto del batch. In caso contrario, è possibile che i messaggi vadano persi.

Se viene riscontrata un'eccezione durante l'elaborazione di un messaggio, la funzione lo inserisce in una coda di messaggi non recapitabili:

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

La coda di messaggi non recapitabili è definita usando un binding di output con una coda di archiviazione:

[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)

Qui l'attributo Queue specifica il binding di output e l'attributo StorageAccount specifica il nome di un'impostazione dell'app che contiene la stringa di connessione per l'account di archiviazione.

Suggerimento per la distribuzione: nel modello di Resource Manager che crea l'account di archiviazione, è possibile popolare automaticamente un'impostazione dell'app con la stringa di connessione. Il trucco consiste nell'usare la funzione listKeys.

Ecco la sezione del modello che crea l'account di archiviazione per la coda:

    {
        "name": "[variables('droneTelemetryDeadLetterStorageQueueAccountName')]",
        "type": "Microsoft.Storage/storageAccounts",
        "location": "[resourceGroup().location]",
        "apiVersion": "2017-10-01",
        "sku": {
            "name": "[parameters('storageAccountType')]"
        },

Ecco la sezione del modello che crea l'app per le funzioni.


    {
        "apiVersion": "2015-08-01",
        "type": "Microsoft.Web/sites",
        "name": "[variables('droneTelemetryFunctionAppName')]",
        "location": "[resourceGroup().location]",
        "tags": {
            "displayName": "Drone Telemetry Function App"
        },
        "kind": "functionapp",
        "dependsOn": [
            "[resourceId('Microsoft.Web/serverfarms', variables('hostingPlanName'))]",
            ...
        ],
        "properties": {
            "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('hostingPlanName'))]",
            "siteConfig": {
                "appSettings": [
                    {
                        "name": "DeadLetterStorage",
                        "value": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('droneTelemetryDeadLetterStorageQueueAccountName'), ';AccountKey=', listKeys(variables('droneTelemetryDeadLetterStorageQueueAccountId'),'2015-05-01-preview').key1)]"
                    },
                    ...

Viene definita un'impostazione dell'app denominata DeadLetterStorage il cui valore viene popolato con la funzione listKeys. È importante impostare la risorsa app per le funzioni in modo che dipenda dalla risorsa account di archiviazione (vedere l'elemento dependsOn). In questo modo ci si assicura che venga prima creato l'account di archiviazione e che la stringa di connessione sia disponibile.

Configurazione dell'inserimento delle dipendenze

Il codice seguente imposta l'inserimento delle dipendenze per la funzione 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<IDocumentClient>(ctx => {
                var config = ctx.GetService<IConfiguration>();
                var cosmosDBEndpoint = config.GetValue<string>("CosmosDBEndpoint");
                var cosmosDBKey = config.GetValue<string>("CosmosDBKey");
                return new DocumentClient(new Uri(cosmosDBEndpoint), cosmosDBKey);
            });
        }
    }
}

Le funzioni di Azure scritte per .NET possono usare il framework di inserimento delle dipendenze ASP.NET Core. L'idea di base è che si dichiara un metodo di avvio per l'assembly. Il metodo accetta un'interfaccia IFunctionsHostBuilder, che viene usata per dichiarare le dipendenze per l'inserimento. A questo scopo, chiamare il metodo Add* sull'oggetto Services. Quando si aggiunge una dipendenza, se ne specifica la durata:

  • Gli oggetti temporanei vengono creati ogni volta che sono necessari.
  • Gli oggetti con ambito vengono creati una volta per ogni esecuzione della funzione.
  • Gli oggetti singleton vengono riutilizzati tra varie esecuzioni della funzione, entro la durata dell'host della funzione.

In questo esempio gli oggetti TelemetryProcessor e StateChangeProcessor sono dichiarati come temporanei. Questa scelta è appropriata per i servizi leggeri senza stato. La classe DocumentClient, d'altra parte, deve essere singleton per prestazioni ottimali. Per altre informazioni, vedere Suggerimenti sulle prestazioni per Azure Cosmos DB e .NET.

Facendo riferimento al codice di RawTelemetryFunction, è possibile rilevare un'altra dipendenza non presente nel codice di configurazione dell'inserimento delle dipendenze, ossia la classe TelemetryClient usata per registrare le metriche dell'applicazione. Il runtime di Funzioni registra automaticamente questa classe nel contenitore di inserimento delle dipendenze, quindi non è necessario registrarla esplicitamente.

Per altre informazioni sull'inserimento delle dipendenze in Funzioni di Azure, vedere gli articoli seguenti:

Passaggio delle impostazioni di configurazione nell'inserimento delle dipendenze

A volte è necessario inizializzare un oggetto con alcuni valori di configurazione. In generale, queste impostazioni dovrebbero provenire dalle impostazioni dell'app oppure, nel caso di segreti, da Azure Key Vault.

Esistono due esempi in questa applicazione. Prima di tutto, la DocumentClient classe accetta un endpoint di servizio e una chiave di Azure Cosmos DB. Per questo oggetto, l'applicazione registra un'espressione lambda che verrà richiamata dal contenitore di inserimento delle dipendenze. L'espressione lambda usa l'interfaccia IConfiguration per leggere i valori di configurazione:

builder.Services.AddSingleton<IDocumentClient>(ctx => {
    var config = ctx.GetService<IConfiguration>();
    var cosmosDBEndpoint = config.GetValue<string>("CosmosDBEndpoint");
    var cosmosDBKey = config.GetValue<string>("CosmosDBKey");
    return new DocumentClient(new Uri(cosmosDBEndpoint), cosmosDBKey);
});

Il secondo esempio è la classe StateChangeProcessor. Per questo oggetto si usa il cosiddetto approccio del modello di opzioni. Ecco come funziona:

  1. Definire una classe T che contiene le impostazioni di configurazione. In questo caso, il nome del database e il nome della raccolta di Azure Cosmos DB.

    public class StateChangeProcessorOptions
    {
        public string COSMOSDB_DATABASE_NAME { get; set; }
        public string COSMOSDB_DATABASE_COL { get; set; }
    }
    
  2. Aggiungere la classe T come classe di opzioni per l'inserimento delle dipendenze.

    builder.Services.AddOptions<StateChangeProcessorOptions>()
        .Configure<IConfiguration>((configSection, configuration) =>
        {
            configuration.Bind(configSection);
        });
    
  3. Nel costruttore della classe da configurare includere un parametro IOptions<T>.

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

Il sistema di inserimento delle dipendenze popolerà automaticamente la classe di opzioni con i valori di configurazione e la passa al costruttore.

Questo approccio presenta diversi vantaggi:

  • La classe viene separata dall'origine dei valori di configurazione.
  • Le varie origini di configurazione possono essere configurate facilmente, ad esempio le variabili di ambiente o i file di configurazione JSON.
  • Gli unit test sono semplificati.
  • Viene usata una classe di opzioni fortemente tipizzata, che è meno soggetta a errori rispetto al semplice passaggio di valori scalari.

Funzione GetStatus

L'altra app di Funzioni di questa soluzione implementa una semplice API REST per ottenere l'ultimo stato noto di un drone. Questa funzione è definita in una classe denominata GetStatusFunction. Ecco il codice completo per la funzione:

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);
            }
        }
    }
}

Questa funzione usa un trigger HTTP per elaborare una richiesta HTTP GET. La funzione usa un'associazione di input di Azure Cosmos DB per recuperare il documento richiesto. Un aspetto da considerare è che questo binding verrà eseguito prima che la logica di autorizzazione venga eseguita all'interno della funzione. Se un utente non autorizzato richiede un documento, il binding della funzione lo recupererà comunque. Quindi il codice di autorizzazione restituirà un errore 401, in modo che l'utente non veda il documento. Se questo comportamento è accettabile o meno dipende da specifici requisiti. Ad esempio, con questo approccio può risultare più difficile controllare l'accesso ai dati sensibili.

Autenticazione e autorizzazione

L'app Web usa Microsoft Entra ID per autenticare gli utenti. Poiché si tratta di un'applicazione a pagina singola eseguita nel browser, il flusso di concessione implicita è appropriato:

  1. L'app Web reindirizza l'utente al provider di identità (in questo caso, MICROSOFT Entra ID).
  2. L'utente immette le sue credenziali.
  3. Il provider di identità lo reindirizza all'app Web con un token di accesso.
  4. L'app Web invia una richiesta all'API Web e include il token di accesso nell'intestazione dell'autorizzazione.

Implicit flow diagram

Un'applicazione di Funzioni può essere configurata per autenticare gli utenti senza codice. Per altre informazioni, vedere Autenticazione e autorizzazione nel servizio app di Azure.

L'autorizzazione, al contrario, richiede in genere una logica di business. Microsoft Entra ID supporta l'autenticazione basata sulle attestazioni. In questo modello l'identità di un utente è rappresentata da un set di attestazioni provenienti dal provider di identità. Un'attestazione può essere un'informazione sull'utente, ad esempio il nome o l'indirizzo di posta elettronica.

Il token di accesso contiene un sottoinsieme di attestazioni dell'utente, che includono i ruoli applicazione a cui l'utente è assegnato.

Il parametro principal della funzione è un oggetto ClaimsPrincipal che contiene le attestazioni del token di accesso. Ogni attestazione è una coppia chiave-valore di tipo attestazione e valore attestazione, che l'applicazione usa per autorizzare la richiesta.

Il metodo di estensione seguente verifica se un oggetto ClaimsPrincipal contiene o meno un set di ruoli. Se uno dei ruoli specificati risulta mancante, restituisce false. Se questo metodo restituisce False, la funzione restituisce HTTP 401 (non autorizzato).

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;
        }
    }
}

Per altre informazioni sull'autenticazione e l'autorizzazione in questa applicazione, vedere la sezione Considerazioni sulla sicurezza dell'architettura di riferimento.

Passaggi successivi

Dopo aver appreso il funzionamento di questa soluzione di riferimento, è possibile apprendere le procedure consigliate e le raccomandazioni per soluzioni simili.

Funzioni di Azure è solo un'opzione di calcolo di Azure. Per informazioni sulla scelta di una tecnologia di calcolo, vedere Scegliere un servizio di calcolo di Azure per l'applicazione.