Editar

Passo a passo do código: Aplicativo sem servidor com funções

Azure Event Hubs
Azure Functions

Os modelos sem servidor abstraem o código da infraestrutura de computação subjacente, permitindo que os desenvolvedores se concentrem na lógica de negócios sem uma configuração extensa. O código sem servidor reduz os custos, porque você paga apenas pelos recursos de execução de código e duração.

O modelo controlado por eventos sem servidor se adapta a situações em que um determinado evento dispara uma ação definida. Por exemplo, receber uma mensagem de dispositivo de entrada aciona o armazenamento para uso posterior ou uma atualização de banco de dados dispara algum processamento adicional.

Para ajudá-lo a explorar as tecnologias sem servidor do Azure no Azure, a Microsoft desenvolveu e testou um aplicativo sem servidor que usa o Azure Functions. Este artigo percorre o código da solução Functions sem servidor e descreve decisões de design, detalhes de implementação e alguns dos "gotchas" que você pode encontrar.

Explore a solução

A solução em duas partes descreve um hipotético sistema de entrega por drone. Os drones enviam o estado em utilização para a cloud, que armazena as mensagens para utilização posterior. Um aplicativo Web permite que os usuários recuperem as mensagens para obter o status mais recente dos dispositivos.

Você pode baixar o código para esta solução do GitHub.

Este passo a passo pressupõe familiaridade básica com as seguintes tecnologias:

Não precisa de ser um especialista em Funções ou Hubs de Eventos, mas deve ter um elevado nível de compreensão das funcionalidades. Veja a seguir alguns bons recursos para começar:

Compreender o cenário

Diagram of the functional blocks

A Fabrikam gere uma frota de drones de um serviço de entrega por drones. A aplicação é composta por duas áreas funcionais principais:

  • Ingestão de eventos. Durante o voo, os drones enviam mensagens de estado para um ponto final da cloud. O aplicativo ingere e processa essas mensagens e grava os resultados em um banco de dados back-end (Azure Cosmos DB). Os dispositivos enviam mensagens no formato protobuf (protocol buffer). O protobuf é um formato de serialização de descrição automática eficiente.

    Essas mensagens contêm atualizações parciais. Em intervalos fixos, cada drone envia uma mensagem de “fotograma chave” que contém todos os campos de estado. Entre os fotogramas chave, as mensagens de estado incluem apenas os campos que foram alterados desde a última mensagem. Este comportamento é típico de muitos dispositivos IoT que precisam de conservar largura de banda e energia.

  • Aplicação Web. Uma aplicação Web permite aos utilizadores procurar um dispositivo e consultar o último estado conhecido do dispositivo. Os usuários devem entrar no aplicativo e autenticar com o Microsoft Entra ID. A aplicação permite apenas pedidos de utilizadores com autorização para aceder à aplicação.

Veja a seguir uma captura de ecrã da aplicação Web a mostrar o resultado de uma consulta:

Screenshot of client app

Projetar o aplicativo

A Fabrikam decidiu utilizar as Funções do Azure para implementar a lógica de negócio da aplicação. As Funções do Azure são um exemplo de “Funções como um Serviço” (FaaS). Neste modelo de computação, uma função é um pedaço de código que é implantado na nuvem e executado em um ambiente de hospedagem. Este ambiente de alojamento abstrai completamente os servidores que executam o código.

Por que deve escolher uma abordagem sem servidor?

Uma arquitetura sem servidor com Funções é um exemplo de uma arquitetura condicionada por eventos. O código da função é acionado por algum evento externo à função — neste caso, uma mensagem de um drone ou uma solicitação HTTP de um aplicativo cliente. Com uma aplicação de funções, não precisa de escrever código para o acionador. Apenas escreve o código que é executado em resposta ao acionador. Tal significa que pode concentrar-se na lógica de negócio, ao invés de escrever uma grande quantidade de código para lidar com questões de infraestrutura, como as mensagens.

Existem também algumas vantagens operacionais na utilização de uma arquitetura sem servidor:

  • Não é preciso gerir os servidores.
  • Os recursos de computação são atribuídos dinamicamente, conforme necessário.
  • São cobrados apenas os recursos de computação utilizados para executar o código.
  • Os recursos de computação são dimensionados a pedido com base no tráfego.

Arquitetura

O diagrama seguinte mostra a arquitetura de alto nível da aplicação:

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

Em um fluxo de dados, as setas mostram mensagens indo de Dispositivos para Hubs de Eventos e acionando o aplicativo de função. No aplicativo, uma seta mostra mensagens de letra morta indo para uma fila de armazenamento e outra seta mostra a gravação no Azure Cosmos DB. Em outro fluxo de dados, as setas mostram o aplicativo Web cliente obtendo arquivos estáticos da hospedagem web estática de armazenamento de Blob, por meio de uma CDN. Outra seta mostra a solicitação HTTP do cliente passando pelo Gerenciamento de API. No Gerenciamento de API, uma seta mostra o aplicativo de função acionando e lendo dados do Azure Cosmos DB. Outra seta mostra a autenticação através do Microsoft Entra ID. Um usuário também entra no Microsoft Entra ID.

Ingestão de eventos:

  1. As mensagens dos drones são ingeridas pelos Hubs de Eventos do Azure.
  2. Os Hubs de Eventos produzem um fluxo de eventos que contém os dados das mensagens.
  3. Estes eventos acionam uma aplicação de Funções do Azure para as processar.
  4. Os resultados são armazenados no Azure Cosmos DB.

Aplicação Web:

  1. Os ficheiros estáticos são servidos pela CDN a partir do Armazenamento de blobs.
  2. Um usuário entra no aplicativo Web usando a ID do Microsoft Entra.
  3. A Gestão de API do Azure funciona como um gateway que expõe um ponto de final da API REST.
  4. As solicitações HTTP do cliente acionam um aplicativo do Azure Functions que lê do Azure Cosmos DB e retorna o resultado.

Esta aplicação baseia-se em duas arquiteturas de referência, correspondentes aos dois blocos funcionais, descritos acima:

Pode ler estes artigos para saber mais sobre a arquitetura de alto nível, os serviços do Azure utilizados na solução e as considerações sobre escalabilidade, segurança e fiabilidade.

Função de telemetria dos drones

Vamos começar por examinar a função que processa as mensagens dos drones nos Hubs de Eventos. A função é definida numa classe chamada 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;
        }
    }
    ...
}

Esta classe tem várias dependências, as quais são injetadas no construtor com a injeção de dependências:

  • As interfaces ITelemetryProcessor e IStateChangeProcessor definem dois objetos do programa auxiliar. Como vamos ver, estes objetos fazem a maior parte do trabalho.

  • A TelemetryClient faz parte do SDK do Application Insights, é utilizada para enviar métricas de aplicações personalizadas para o Application Insights.

Mais tarde, veremos como configurar a injeção de dependências. Por enquanto, vamos assumir que estas dependências existem.

Configurar o acionador dos Hubs de Eventos

A lógica na função é implementada como um método assíncrono chamado RunAsync. Esta é a assinatura do método:

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

O método recebe os seguintes parâmetros:

  • messages é uma matriz das mensagens do hub de eventos.
  • deadLetterMessages é uma Fila de Armazenamento do Microsoft Azure, utilizada para armazenar mensagens não entregues.
  • logging fornece uma interface de registo, para escrever registos de aplicações. Estes registos são enviados para o Azure Monitor.

O atributo EventHubTrigger no parâmetro messages configura o acionador. As propriedades do atributo especificam um nome do hub de eventos, uma cadeia de ligação e um grupo de consumidores (Um grupo de consumidores é uma exibição isolada do fluxo de eventos dos Hubs de Eventos. Essa abstração permite vários consumidores do mesmo hub de eventos.)

Repare nos sinais de percentagem (%) em algumas das propriedades dos atributos. Estes indicam que a propriedade especifica o nome de uma definição de aplicação e o valor real é retirado dessa definição de aplicação no tempo de execução. Caso contrário, sem os símbolos de percentagem, a propriedade fornece o valor literal.

A propriedade Connection é uma exceção. Esta propriedade especifica sempre um nome de definição de aplicação, nunca um valor literal, pelo que o símbolo de percentagem não é necessário. O motivo para essa distinção é que uma cadeia de ligação é secreta e nunca deve ser verificada no código fonte.

Embora as outras duas propriedades (nome do hub de eventos e grupo de consumidores) não sejam dados confidenciais como uma cadeia de ligação, continua a ser melhor colocá-las nas definições de aplicação, em vez de no hard-coding. Desta forma, podem ser atualizadas sem recompilar a aplicação.

Para obter mais informações acerca da configuração deste acionador, veja Enlaces dos Hubs de Eventos do Azure para as Funções do Azure.

Lógica de processamento das mensagens

Veja a seguir a implementação do método RawTelemetryFunction.RunAsync que processa um lote de mensagens:

[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 a função é invocada, o parâmetro messages contém uma matriz de mensagens do hub de eventos. O processamento de mensagens em lotes produzirá, em geral, um melhor desempenho do que a leitura de uma mensagem de cada vez. No entanto, tem de confirmar que a função é resiliente e que processa as falhas e as exceções corretamente. Caso contrário, se a função emitir uma exceção não processada no meio de um lote, poderá perder as restantes mensagens. Esta consideração é abordada mais detalhadamente na seção Processamento de erros.

Porém, se ignorar o processamento de exceções, a lógica de processamento para cada mensagem é simples:

  1. Chame ITelemetryProcessor.Deserialize para anular a serialização da mensagem que contém uma alteração de estado do dispositivo.
  2. Chame IStateChangeProcessor.UpdateState para processar a alteração de estado.

Vamos examinar estes dois métodos em maior detalhe, a começar pelo método Deserialize.

Método de anulação da serialização

O método TelemetryProcess.Deserialize recorre a uma matriz de bytes que contém o payload das mensagens. O método anula a serialização deste payload e devolve um objeto DeviceState, que representa o estado de um drone. O estado pode representar uma atualização parcial, que contém apenas o delta do último estado conhecido. Assim, o método precisa de processar os campos null no payload da serialização anulada.

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

Este método utiliza outra interface de programa auxiliar, ITelemetrySerializer<T>, para anular a serialização da mensagem não processada. Os resultados são, em seguida, transformados num modelo POCO, mais fácil trabalhar. Este design ajuda a isolar a lógica de processamento dos detalhes de implementação da serialização. A interface ITelemetrySerializer<T> é definida numa biblioteca partilhada, que também é utilizada pelo simulador de dispositivos para gerar eventos de dispositivos simulados e enviá-los para os Hubs de Eventos.

using System;

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

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

Método UpdateState

O método StateChangeProcessor.UpdateState aplica as alterações de estado. O último estado conhecido para cada drone é armazenado como um documento JSON no Azure Cosmos DB. Uma vez que os drones enviam atualizações parciais, a aplicação não pode simplesmente substituir o documento quando recebe uma atualização. Em vez disso, precisa de obter o estado anterior, unir os campos e, em seguida, realizar uma operação de 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);
    }
}

Esse código usa a IDocumentClient interface para buscar um documento do Azure Cosmos DB. Se o documento existir, os valores do novo estado serão unidos no documento existente. Caso contrário, será criado um novo documento. Ambos os casos são processados pelo método UpsertDocumentAsync.

Esse código é otimizado para o caso onde o documento já existe e pode ser unido. Na primeira mensagem de telemetria de um determinado drone, o método ReadDocumentAsync vai gerar uma exceção, porque não existe nenhum documento para esse drone. Após a primeira mensagem, o documento vai estar disponível.

Observe que essa classe usa a injeção de dependência para injetar o para o IDocumentClient Azure Cosmos DB e um IOptions<T> com definições de configuração. Veremos como configurar a injeção de dependências mais tarde.

Nota

O Azure Functions dá suporte a uma associação de saída para o Azure Cosmos DB. Essa associação permite que o aplicativo de função escreva documentos no Azure Cosmos DB sem qualquer código. No entanto, o enlace de saída não funcionará neste cenário específico, devido à lógica de upsert personalizada necessária.

Processamento de erros

Conforme mencionado anteriormente, a aplicação de funções RawTelemetryFunction processa um lote de mensagens num ciclo. Tal significa que a função precisa de processar corretamente todas as exceções e continuar a processar o restante lote. Caso contrário, as mensagens podem ser removidas.

Se for encontrada uma exceção ao processar uma mensagem, a função colocará a mensagem numa fila de mensagens não entregues:

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

A fila de mensagens não entregues é definida com um enlace de saída para uma fila de armazenamento:

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

Aqui, o atributo Queue especifica o enlace de saída e o atributo StorageAccount especifica o nome de uma definição de aplicação que contém a cadeia de ligação da conta de armazenamento.

Dica de implantação: no modelo do Gerenciador de Recursos que cria a conta de armazenamento, você pode preencher automaticamente uma configuração de aplicativo com a cadeia de conexão. O truque é usar a função listKeys .

Esta é a secção do modelo que cria a conta de armazenamento para a fila:

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

Esta é a seção do modelo que cria a aplicação de funções.


    {
        "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)]"
                    },
                    ...

Esta define uma definição de aplicação denominada DeadLetterStorage, cujo valor é preenchido com a função listKeys. É importante tornar o recurso da aplicação de funções dependente do recurso da conta de armazenamento (veja o elemento dependsOn). Tal garante que a conta de armazenamento é criada primeiro e que a cadeia de ligação está disponível.

Configurar a injeção de dependências

O código a seguir configura a injeção de dependências para a função 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);
            });
        }
    }
}

As Funções do Azure escritas para .NET podem utilizar a arquitetura de injeção de dependências do ASP.NET Core. A ideia geral é que declare um método de arranque para a sua assemblagem. O método utiliza uma interface IFunctionsHostBuilder, que é utilizada para declarar as dependências para a injeção de dependências. Pode fazê-lo ao chamar o método Add* no objeto Services. Quando adiciona uma dependência, especifica a sua duração:

  • Os objetos transitórios são criados sempre que são pedidos.
  • Os objetos com âmbito são criados uma vez por cada execução de função.
  • Os objetos singleton são reutilizados entre execuções de função, dentro da duração do anfitrião da função.

Neste exemplo, os objetos TelemetryProcessor e StateChangeProcessor são declarados como transitórios, o que é adequado para os serviços sem estado simples. A classe DocumentClient, por outro lado, deve ser singleton para um melhor desempenho. Para obter mais informações, veja Sugestões de desempenho para o Azure Cosmos DB e .NET.

Se consultar novamente o código da RawTelemetryFunction, poderá ver que existe outra dependência que não aparece no código de configuração da injeção de dependências, isto é, a classe TelemetryClient que é utilizada para registar as métricas da aplicação. O runtime das Funções regista automaticamente esta classe no contentor de injeção de dependências, pelo que não tem de o registar explicitamente.

Para obter mais informações sobre a injeção de dependências nas Funções do Azure, veja os seguintes artigos:

Transmitir definições de configuração na injeção de dependências

Por vezes, um objeto tem de ser inicializado com alguns valores de configuração. Por norma, estas definições devem vir das definições de aplicação ou (no caso de segredos) do Azure Key Vault.

Existem dois exemplos nesta aplicação. Primeiro, a DocumentClient classe usa um ponto de extremidade e uma chave do serviço Azure Cosmos DB. Para este objeto, a aplicação regista um operador lambda que será invocado pelo contentor de injeção de dependências. Este operador lambda utiliza a interface IConfiguration para ler os valores de configuração:

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

O segundo exemplo é a classe StateChangeProcessor. Para este objeto, utilizamos uma abordagem denominada padrão de opções. Eis como funciona:

  1. Defina uma classe T que contém as definições de configuração. Nesse caso, o nome do banco de dados do Azure Cosmos DB e o nome da coleção.

    public class StateChangeProcessorOptions
    {
        public string COSMOSDB_DATABASE_NAME { get; set; }
        public string COSMOSDB_DATABASE_COL { get; set; }
    }
    
  2. Adicione a classe T como uma classe de opções para a injeção de dependências.

    builder.Services.AddOptions<StateChangeProcessorOptions>()
        .Configure<IConfiguration>((configSection, configuration) =>
        {
            configuration.Bind(configSection);
        });
    
  3. No construtor da classe a ser configurada, inclua um parâmetro IOptions<T>.

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

O sistema de injeção de dependências vai preencher automaticamente a classe de opções com os valores de configuração e transmiti-la ao construtor.

Existem diversas vantagens nesta abordagem:

  • Desassociar a classe da origem dos valores de configuração.
  • Configurar facilmente as diferentes origens de configuração, como variáveis de ambiente ou ficheiros de configuração JSON.
  • Simplificar o teste de unidades.
  • Utilizar uma classe de opções com tipos de dados inflexíveis, que é menos propensa a erros do que apenas a transmissão de valores escalares.

Função GetStatus

A outra aplicação de Funções nesta solução implementa uma API REST simples para obter o último estado conhecido de um drone. Esta função é definida numa classe chamada GetStatusFunction. Veja a seguir o código completo da função:

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

Esta função utiliza um acionador HTTP para processar um pedido HTTP GET. A função usa uma associação de entrada do Azure Cosmos DB para buscar o documento solicitado. Uma consideração a ter em conta é que este enlace será executado antes da lógica de autorização ser realizada dentro da função. Se um utilizador não autorizado solicitar um documento, o enlace da função ainda poderá obter o documento. Em seguida, o código de autorização devolverá um erro 401, para que o utilizador não veja o documento. A decisão de considerar este comportamento aceitável ou não pode depender dos seus requisitos. Por exemplo, esta abordagem pode tornar mais difícil auditar o acesso a dados confidenciais.

Autenticação e autorização

O aplicativo Web usa a ID do Microsoft Entra para autenticar usuários. Uma vez que a aplicação é uma aplicação de página única (SPA) em execução no browser, o fluxo de concessão implícita é apropriado:

  1. O aplicativo Web redireciona o usuário para o provedor de identidade (neste caso, Microsoft Entra ID).
  2. O utilizador introduz as credenciais.
  3. O fornecedor de identidade redireciona de volta para a aplicação Web com um token de acesso.
  4. A aplicação Web envia um pedido para a API Web e inclui o token de acesso no Cabeçalho de autorização.

Implicit flow diagram

Uma aplicação de Função pode ser configurada para autenticar os utilizadores com o código zero. Para obter mais informações, veja Autenticação e autorização no Serviço de Aplicações do Azure.

Por outro lado, a autorização geralmente requer alguma lógica de negócio. O Microsoft Entra ID oferece suporte à autenticação baseada em declarações. Neste modelo, a identidade de um utilizador é representada como um conjunto de afirmações provenientes do fornecedor de identidade. Uma afirmação pode ser qualquer informação sobre o utilizador, como o nome ou o endereço de e-mail.

O token de acesso contém um subconjunto de afirmações do utilizador. Entre estas estão as funções de aplicação que são atribuídas ao utilizador.

O parâmetro principal da função é um objeto ClaimsPrincipal que contém as afirmações do token de acesso. Cada afirmação é um par de chaves/valores do tipo de afirmação e valor de afirmação. A aplicação utiliza-os para autorizar o pedido.

O seguinte método de extensão testa se um objeto ClaimsPrincipal contém um conjunto de funções. Este devolve false caso alguma das funções especificadas esteja em falta. Se esse método devolver o valor falso, a função devolverá HTTP 401 (Não Autorizado).

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

Para obter mais informações sobre a autenticação e a autorização nesta aplicação, veja a secção Considerações de segurança da arquitetura de referência.

Próximos passos

Depois de ter uma ideia de como essa solução de referência funciona, aprenda as práticas recomendadas e recomendações para soluções semelhantes.

O Azure Functions é apenas uma opção de computação do Azure. Para obter ajuda com a escolha de uma tecnologia de computação, consulte Escolher um serviço de computação do Azure para seu aplicativo.