Passo a passo do código: aplicativo sem servidor com o Functions

Hubs de eventos do Azure
Funções do Azure

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 extensiva. O código sem servidor reduz os custos, pois você paga apenas pelos recursos de execução de código e pela duração.

O modelo controlado por eventos sem servidor se ajusta 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 aciona algum processamento adicional.

Para ajudar você 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 explica o código para a solução do Functions sem servidor e descreve as decisões de design, os detalhes de implementação e algumas das "armadilhas" que você pode encontrar.

Explorar a solução

A solução de duas partes descreve um sistema hipotético de entrega por drones. Os drones enviam o status de voo para a nuvem, que armazena essas mensagens para uso 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 é necessário ser especialista no Functions ou nos Hubs de Eventos, mas você deve ter um entendimento avançado dos recursos deles. Veja alguns bons recursos para começar:

Compreender o cenário

Diagram of the functional blocks

A Fabrikam gerencia uma frota de drones para um serviço de entrega por drone. O aplicativo é composto por duas áreas funcionais principais:

  • Ingestão de eventos. Durante o voo, os drones enviam mensagens de status ao ponto de extremidade da nuvem. O aplicativo ingere e processa essas mensagens e grava os resultados em um banco de dados de back-end (Azure Cosmos DB). Os dispositivos enviam mensagens no formato de buffer de protocolo (protobuf). O protobuf é um formato de serialização eficiente e autodescritivo.

    Essas mensagens contêm atualizações parciais. Em um intervalo fixo, cada drone envia uma mensagem de "quadro principal" que contém todos os campos de status. Entre os quadros principais, as mensagens de status somente incluem os campos que foram alterados desde a última mensagem. Esse comportamento é típico de vários dispositivos IoT que precisam conservar largura de banda e energia.

  • Aplicativo Web. Um aplicativo Web permite que os usuários pesquisem um dispositivo e consultem o último status conhecido dele. Os usuários devem entrar no aplicativo e se autenticar com o Microsoft Entra ID. O aplicativo só permite solicitações de usuários autorizados a acessá-lo.

Veja uma captura de tela do aplicativo Web mostrando o resultado de uma consulta:

Screenshot of client app

Criar o aplicativo

A Fabrikam decidiu usar o Azure Functions para implementar a lógica de negócios do aplicativo. O Azure Functions é um exemplo de FaaS ("Funções como Serviço"). Nesse modelo de computação, uma função é um trecho de código implantado na nuvem e executado em um ambiente de hospedagem. Esse ambiente de hospedagem abstrai completamente os servidores que executam o código.

Por que escolher uma abordagem sem servidor?

Uma arquitetura sem servidor com o Functions é um exemplo de arquitetura orientada a eventos. O código da função é disparado por algum evento externo à função – neste caso, uma mensagem de um drone ou uma solicitação HTTP de um aplicativo cliente. Com um aplicativo de funções, não é necessário gravar código para o gatilho. Você só grava o código executado em resposta ao gatilho. Isso significa que é possível se concentrar na lógica de negócios em vez de gravar uma grande quantidade de código para lidar com preocupações de infraestrutura, como mensagens.

Também há algumas vantagens operacionais de usar uma arquitetura sem servidor:

  • não é necessário gerenciar servidores.
  • Os recursos de computação são alocados dinamicamente conforme a necessidade.
  • Você recebe uma cobrança apenas pelos recursos de computação usados para execução do seu código.
  • Os recursos de computação são dimensionados sob demanda com base no tráfego.

Arquitetura

O diagrama a seguir mostra a arquitetura de alto nível do aplicativo:

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

Em um fluxo de dados, as setas mostram mensagens que passam de Dispositivos para Hubs de Eventos e disparam o aplicativo de funções. No aplicativo, uma seta mostra mensagens mortas que vão 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 do cliente obtendo arquivos estáticos da hospedagem da Web estática do armazenamento de blobs, por meio de um CDN. Outra seta mostra a solicitação HTTP do cliente que passa pelo Gerenciamento de API. No Gerenciamento de API, uma seta mostra o aplicativo de funções que dispara e lê dados do Azure Cosmos DB. Outra seta mostra a autenticação por meio do Microsoft Entra ID. Um Usuário também entra no Microsoft Entra ID.

Ingestão de eventos:

  1. as mensagens de drone são ingeridas pelos Hubs de Eventos do Azure.
  2. Os Hubs de Eventos produzem um fluxo de eventos que contém os dados da mensagem.
  3. Esses eventos disparam um aplicativo do Azure Functions para processá-los.
  4. Os resultados são armazenados no Azure Cosmos DB.

Aplicativo Web:

  1. os arquivos estáticos são exibidos pela CDN no armazenamento de blobs.
  2. Um usuário se conecta ao aplicativo Web utilizando o Microsoft Entra ID.
  3. O Gerenciamento de API do Azure atua como um gateway que expõe o ponto de extremidade da API REST.
  4. As solicitações HTTP do cliente disparam um aplicativo do Azure Functions que faz a leitura do Azure Cosmos DB e retorna o resultado.

Esse aplicativo é baseado em duas arquiteturas de referência, que correspondem aos dois blocos funcionais descritos acima:

Leia esses artigos para saber mais sobre a arquitetura de alto nível, os serviços do Azure usados na solução e as considerações de escalabilidade, segurança e confiabilidade.

Função de telemetria do drone

Vamos começar examinando a função que processa as mensagens do drone nos Hubs de Eventos. A função é definida em uma 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;
        }
    }
    ...
}

Essa classe tem várias dependências que são injetadas no construtor usando a injeção de dependência:

  • As interfaces ITelemetryProcessor e IStateChangeProcessor definem dois objetos auxiliares. Como veremos, esses objetos fazem a maior parte do trabalho.

  • O TelemetryClient faz parte do SDK do Application Insights. É usado para enviar métricas de aplicativo personalizadas para o Application Insights.

Mais adiante, veremos como configurar a injeção de dependência. Por enquanto, apenas suponha que essas dependências existem.

Configurar o gatilho dos Hubs de Eventos

A lógica na função é implementada como um método assíncrono chamado RunAsync. Veja 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 usa os seguintes parâmetros:

  • messages é uma matriz de mensagens do hub de eventos.
  • deadLetterMessages é uma Fila do Armazenamento do Azure, usada para armazenar mensagens mortas.
  • logging fornece uma interface de registro para gravar logs de aplicativo. Esses logs são enviados para o Azure Monitor.

O atributo EventHubTrigger no parâmetro messages configura o gatilho. As propriedades do atributo especificam um nome do hub de eventos, uma cadeia de conexã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.

Observe os sinais de percentagem (%) em algumas das propriedades de atributo. Eles indicam que a propriedade especifica o nome de uma configuração de aplicativo e o valor real é obtido dela no tempo de execução. Caso contrário, sem os sinais de porcentagem, a propriedade retornará o valor literal.

A propriedade Connection é uma exceção. Essa propriedade sempre especifica um nome de configuração de aplicativo, nunca um valor literal, portanto, o sinal de percentagem não é necessário. O motivo para essa distinção é que uma cadeia de conexão é secreta e nunca deve ser inserida no código-fonte.

Embora as duas outras propriedades (nome do hub de eventos e grupo de consumidores) não sejam dados confidenciais como uma cadeia de conexão, ainda é melhor colocá-las nas configurações do aplicativo em vez de fazer hard-coding. Dessa forma, elas podem ser atualizadas sem recompilar o aplicativo.

Para saber mais sobre a configuração desse gatilho, confira Associações de Hubs de Eventos do Azure para o Azure Functions.

Lógica de processamento de mensagens

Aqui está 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. Geralmente, o processamento de mensagens em lotes gerará um desempenho melhor do que ler uma mensagem de cada vez. No entanto, é necessário verificar se a função é resiliente e trata bem as falhas e exceções. Caso contrário, se a função gerar uma exceção sem tratamento no meio de um lote, talvez você perca as mensagens restantes. Essa consideração é discutida em mais detalhes na seção Tratamento de erro.

No entanto, se você ignorar o tratamento da exceção, a lógica de processamento de cada mensagem será simples:

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

Vamos examinar esses dois métodos mais detalhadamente, começando pelo método Deserialize.

Desserializar o método

O método TelemetryProcess.Deserialize usa uma matriz de bytes que contém o conteúdo da mensagem. Ele desserializa esse conteúdo e retorna um objeto DeviceState, que representa o estado de um drone. O estado pode representar uma atualização parcial contendo somente o delta do último estado conhecido. Portanto, o método precisa tratar os campos null no conteúdo desserializado.

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

Esse método usa outra interface auxiliar, ITelemetrySerializer<T>, para desserializar a mensagem bruta. Em seguida, os resultados são transformados em um modelo POCO mais fácil de trabalhar. Esse design ajuda a isolar a lógica de processamento dos detalhes de implementação da serialização. A interface ITelemetrySerializer<T> é definida em uma biblioteca compartilhada, que também é usada pelo simulador do dispositivo para gerar eventos de dispositivo simulado e enviá-los aos 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 de cada drone é armazenado como um documento JSON no Azure Cosmos DB. Como os drones enviam atualizações parciais, o aplicativo não pode simplesmente substituir o documento quando recebe uma atualização. Em vez disso, ele precisa buscar o estado anterior, mesclar os campos e, em seguida, realizar uma operação 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);
    }
}

Este código utiliza a interface IDocumentClient para buscar um documento no Azure Cosmos DB. Se o documento existir, os novos valores de estado serão mesclados com o documento existente. Caso contrário, um novo documento será criado. Ambos os casos são tratados pelo método UpsertDocumentAsync.

Esse código é otimizado para o caso em que o documento já existe e pode ser mesclado. Na primeira mensagem de telemetria de um determinado drone, o método ReadDocumentAsync gerará uma exceção, porque não há documento para o drone em questão. Após a primeira mensagem, o documento ficará disponível.

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

Observação

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ções grave documentos no Azure Cosmos DB sem nenhum código. No entanto, a associação de saída não funcionará neste cenário específico por causa da lógica upsert personalizada necessária.

Tratamento de erros

Como mencionado anteriormente, o aplicativo de funções RawTelemetryFunction processa um lote de mensagens em um loop. Isso significa que a função precisa tratar qualquer exceção bem e continuar processando o restante do lote. Caso contrário, as mensagens podem ser removidas.

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

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 mortas é definida usando uma associação 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 a associação de saída e o atributo StorageAccount especifica o nome de uma configuração de aplicativo que contém a cadeia de conexão da conta de armazenamento.

Dica de implantação: no modelo do Resource Manager que cria a conta de armazenamento, é possível preencher automaticamente uma configuração de aplicativo com a cadeia de conexão. O truque é usar a função listKeys.

Veja a seçã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')]"
        },

Veja a seção do modelo que cria o aplicativo 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)]"
                    },
                    ...

Isso define uma configuração de aplicativo chamada DeadLetterStorage, cujo valor é preenchido usando a função listKeys. É importante fazer com que o recurso do aplicativo de funções dependa do recurso da conta de armazenamento (veja o elemento dependsOn). Isso garante que a conta de armazenamento seja criada primeiro e que a cadeia de conexão fique disponível.

Configurar uma injeção de dependência

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

O Azure Functions gravado para .NET pode usar a estrutura de injeção de dependência do ASP.NET Core. A ideia básica é declarar um método de inicialização para o assembly. O método usa uma interface IFunctionsHostBuilder, utilizada para declarar as dependências para injeção. Faça isso chamando o método Add* no objeto Services. Ao adicionar uma dependência, especifique seu tempo de vida:

  • Objetos transitórios são criados toda vez que são solicitados.
  • Objetos de escopo são criados uma vez por execução de função.
  • Objetos singleton são reutilizados em todas as execuções de função, dentro do tempo de vida do host da função.

Neste exemplo, os objetos TelemetryProcessor e StateChangeProcessor são declarados como transitórios. Isso é adequado para serviços leves, sem estado. Por outro lado, a classe DocumentClient deve ser um singleton para ter um melhor desempenho. Para mais informações, confira Dicas de desempenho para o Azure Cosmos DB e .NET.

Se você consultar novamente o código de RawTelemetryFunction, verá outra dependência que não é exibida no código de configuração da injeção de dependência, a classe TelemetryClient usada para registrar as métricas do aplicativo. O runtime do Functions registra essa classe automaticamente no contêiner de injeção de dependência para que você não precise registrá-la explicitamente.

Para saber mais sobre a injeção de dependência no Azure Functions, confira os seguintes artigos:

Passar definições de configuração na injeção de dependência

Às vezes, um objeto deve ser iniciado com alguns valores de configuração. Em geral, essas configurações devem vir das configurações do aplicativo (no caso de segredos) ou do Azure Key Vault.

Há dois exemplos neste aplicativo. Primeiro, a classe DocumentClient utiliza um ponto de extremidade do serviço Azure Cosmos DB e uma chave. Para este objeto, o aplicativo registra um lambda que será invocado pelo contêiner de injeção de dependência. Esse lambda usa 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 esse objeto, usamos uma abordagem chamada o padrão de opções. Veja como funciona:

  1. Defina uma classe T que contenha 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 injeção de dependência.

    builder.Services.AddOptions<StateChangeProcessorOptions>()
        .Configure<IConfiguration>((configSection, configuration) =>
        {
            configuration.Bind(configSection);
        });
    
  3. No construtor da classe que está sendo configurado, inclua um parâmetro IOptions<T>.

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

O sistema de injeção de dependência preencherá automaticamente a classe de opções com os valores da configuração e os passará para o construtor.

Há várias desvantagens nessa abordagem:

  • Desacoplar a classe da origem dos valores de configuração.
  • Definir facilmente diferentes origens de configuração, como variáveis de ambiente ou arquivos de configuração JSON.
  • Simplificar o teste de unidade.
  • Usar uma classe de opções fortemente tipada, que é menos suscetível a erros do que simplesmente passar valores escalares.

Função GetStatus

O outro aplicativo do Functions nesta solução implementa uma API REST simples para obter o último status conhecido do drone. Essa função é definida em uma classe chamada GetStatusFunction. Veja 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);
            }
        }
    }
}

Essa função usa um gatilho HTTP para processar uma solicitação HTTP GET. A função utiliza uma associação de entrada do Azure Cosmos DB para buscar o documento solicitado. Uma consideração é que essa associação será executada antes da realização da lógica de autorização dentro da função. Se um usuário não autorizado solicitar um documento, a associação da função ainda buscará o documento. Em seguida, o código de autorização retornará um 401, para que o usuário não veja o documento. Seus requisitos definirão se esse comportamento é aceitável. Por exemplo, essa abordagem pode dificultar a auditoria de acesso a dados confidenciais.

Autenticação e autorização

O aplicativo Web que será utilizado usa o Microsoft Entra ID para autenticar os usuários. Como é um SPA (aplicativo de página única) em execução no navegador, o fluxo de concessão implícita é apropriado:

  1. O aplicativo Web redireciona o usuário para o provedor de identidade (nesse caso, o Microsoft Entra ID).
  2. O usuário insere suas credenciais.
  3. O provedor de identidade redireciona de volta para o aplicativo Web com um token de acesso.
  4. O aplicativo Web envia uma solicitação à API Web e inclui o token de acesso no cabeçalho de autorização.

Implicit flow diagram

Um aplicativo de Função pode ser configurado para autenticar usuários sem código. Para saber mais, confira Autenticação e autorização no Serviço de Aplicativo do Azure.

Por outro lado, a autorização geralmente precisa de lógica de negócios. O Microsoft Entra ID dá suporte à autenticação baseada em declarações. Neste modelo, a identidade do usuário é representada como um conjunto de declarações provenientes do provedor de identidade. Uma declaração pode ser qualquer informação sobre o usuário, como o nome ou o endereço de email.

O token de acesso contém um subconjunto de declarações do usuário. Entre elas, estão todas as funções de aplicativo atribuídas ao usuário.

O parâmetro principal da função é um objeto ClaimsPrincipal que contém as declarações do token de acesso. Cada declaração é um par chave-valor do tipo e do valor da declaração. O aplicativo as usa para autorizar a solicitação.

O método de extensão a seguir testa se o objeto ClaimsPrincipal contém um conjunto de funções. Ele retornará false se alguma das funções especificadas estiver ausente. Se esse método retornar false, a função retornará 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 saber mais sobre a autenticação e a autorização no aplicativo, confira a seção Considerações de segurança da arquitetura de referência.

Próximas etapas

Depois de ter uma ideia de como essa solução de referência funciona, conheça as práticas recomendadas e as 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, confira Escolher um serviço de computação do Azure para seu aplicativo.