Dela via


Asynkront mönster för begäran-svar

Frikoppla backend-bearbetning från en frontendvärd när backend-bearbetning måste köras asynkront, men frontenden behöver ett klart svar.

Kontext och problem

I modern programutveckling förlitar sig klientprogram ofta på fjärranslutna API:er för att tillhandahålla affärslogik och skapa funktioner. Många program kör kod i en webbläsare och andra miljöer är också värdar för klientkod. API:erna kan relatera direkt till programmet eller fungera som delade tjänster från en extern tjänst. De flesta API-anrop använder HTTP eller HTTPS och följer REST-semantik.

I de flesta fall svarar API:er för ett klientprogram på cirka 100 millisekunder (ms) eller mindre. Många faktorer kan påverka svarsfördröjningen:

  • Programmets värdstack
  • Säkerhetskomponenter
  • Den relativa geografiska platsen för anroparen och backend-systemet
  • Nätverksinfrastruktur
  • Aktuell belastning
  • Storleken på begäransdatabördan
  • Behandlingskölängd
  • Tiden för serverdelen att bearbeta begäran

Dessa faktorer kan öka svarstiden. Du kan minimera vissa faktorer genom att skala ut serverdelen. Andra faktorer, till exempel nätverksinfrastruktur, ligger utanför programutvecklarens kontroll. De flesta API:er svarar tillräckligt snabbt för att svaret ska returneras via samma anslutning. Programkod kan göra ett synkront API-anrop på ett icke-blockerande sätt för att ge utseendet av asynkron bearbetning. Vi rekommenderar den här metoden för in- och utdata (I/O)-bundna åtgärder.

I vissa scenarier utför serverdelen långvarigt arbete och tar några sekunder. I andra scenarier utför serverdelen tidskrävande bakgrundsarbete i minuter eller under längre perioder. I dessa fall kan du inte vänta tills arbetet har slutförts innan du skickar ett svar. Den här situationen kan skapa ett problem för synkrona mönster för begäran-svar.

Vissa arkitekturer löser det här problemet med hjälp av en meddelandekö för att separera stegen för begäran och svar. Många system uppnår denna separation genom kö-baserade mönstret för belastningsutjämning. Med den här separationen kan klientprocessen och serverdels-API:et skalas oberoende av varandra. Det ger också extra komplexitet när klienten behöver ett meddelande om lyckad åtgärd eftersom det steget också måste bli asynkront.

Många av de överväganden som gäller för klientprogram gäller även för REST API-anrop från server till server i distribuerade system, till exempel i en arkitektur för mikrotjänster.

Lösning

En lösning på det här problemet är att använda HTTP-avsökning. Pollning fungerar bra för kod på klientsidan när återuppringningsslutpunkter inte är tillgängliga eller när långlivade anslutningar lägger till för mycket komplexitet. Även om återanrop är möjliga kan de extra bibliotek och tjänster som de behöver öka komplexiteten.

Följande steg beskriver lösningen:

  • Klientprogrammet gör ett synkront anrop till API:et för att utlösa en tidskrävande åtgärd på serverdelen.

  • API:et svarar synkront så snabbt som möjligt. Den returnerar en HTTP 202-statuskod (godkänd) för att bekräfta att den tog emot begäran om bearbetning.

    Anmärkning

    API:et verifierar begäran och den åtgärd som ska utföras innan den startar den tidskrävande processen. Om begäran inte är giltig svarar du omedelbart med en felkod som HTTP 400 (felaktig begäran).

  • Svaret innehåller en platsreferens som pekar på en slutpunkt som klienten kan avsöka för att kontrollera resultatet av den långvariga åtgärden.

  • API:et avlastar bearbetningen till en annan komponent, till exempel en meddelandekö.

  • För ett lyckat anrop till statusslutpunkten returnerar slutpunkten HTTP 200 (OK). Medan arbetet pågår returnerar slutpunkten en resurs som anger det tillståndet. När arbetet är klart returnerar slutpunkten en resurs som anger slutförande eller omdirigeringar till en annan resurs-URL. Om den asynkrona åtgärden till exempel skapar en ny resurs omdirigeras statusslutpunkten till URL:en för resursen.

Följande diagram visar ett typiskt flöde.

Diagram som visar begärande- och svarsflödet för asynkrona HTTP-begäranden.

  1. Klienten skickar en begäran och tar emot ett HTTP 202-svar.

  2. Klienten skickar en HTTP GET-begäran till statusslutpunkten. Arbetet väntar, så det här anropet returnerar HTTP 200.

  3. Arbetet slutförs och statusslutpunkten returnerar HTTP 302 (hittades) för att omdirigera till resursen.

  4. Klienten hämtar resursen på den angivna URL:en.

Problem och överväganden

Tänk på följande när du bestämmer hur du ska implementera det här mönstret:

  • Det finns flera sätt att implementera det här mönstret via HTTP, och överordnade tjänster använder inte alltid samma semantik. De flesta tjänster returnerar till exempel HTTP 404 (hittades inte) från en GET-metod när en fjärrprocess inte är klar i stället för HTTP 202. Enligt standard-REST-semantik är HTTP 404 rätt svar eftersom resultatet av anropet inte finns ännu.

  • Ett HTTP 202-svar anger var klienten avsöker och hur ofta. Den innehåller följande rubriker.

    Header Beskrivning Notes
    Location En URL som klienten söker efter svarsstatus Den här URL:en kan vara en signaturtoken för delad åtkomst. Valet Key-mönstret fungerar bra när den här platsen behöver åtkomstkontroll. Mönstret gäller även när svarssökningen måste flyttas till en annan serverdel.
    Retry-After En uppskattning av när bearbetningen slutförs Denna header förhindrar att frågande klienter skickar för många begäranden till back end.

    Överväg förväntat klientbeteende när du utformar det här svaret. En klient som du styr kan följa dessa svarsvärden exakt. Klienter som andra skapar, inklusive klienter som skapats med hjälp av verktyg utan kod eller låg kod som Azure Logic Apps, kan använda sin egen hantering för HTTP 202.

  • Du kan behöva använda en bearbetningsproxy för att justera svarshuvudena eller nyttolasten, beroende på de underliggande tjänster som du använder.

  • Om statusslutpunkten omdirigeras efter slutförandet är antingen HTTP 302 eller HTTP 303 (se Övrigt) giltiga returkoder, beroende på den semantik som du stöder.

  • När servern har bearbetat begäran returnerar resursen Location som rubriken anger en HTTP-statuskod som 200, 201 (skapad) eller 204 (inget innehåll).

  • Om ett fel inträffar under bearbetningen bevarar du felet på resurs-URL:en som Location huvudet anger och returnerar en 4xx-statuskod från resursen som matchar felet.

  • Lösningar implementerar inte alla det här mönstret på samma sätt, och vissa tjänster innehåller extra eller alternativa rubriker. Till exempel använder Azure Resource Manager en modifierad variant av det här mönstret. Mer information finns i Resource Manager asynkrona åtgärder.

  • Äldre klienter kanske inte stöder det här mönstret. I så fall kan du behöva placera en bearbetningsproxy över det asynkrona API:et för att dölja den asynkrona bearbetningen från den ursprungliga klienten. Logic Apps stöder till exempel det här mönstret internt och du kan använda det som ett integreringslager mellan ett asynkront API och en klient som gör synkrona anrop. Mer information finns i Utföra långvariga uppgifter med webhook-åtgärdsmönstret.

  • I vissa scenarier kanske du vill tillhandahålla ett sätt för klienter att avbryta en tidskrävande begäran. I så fall måste serverdelstjänsten ha stöd för någon form av avbokningsinstruktion.

När du ska använda det här mönstret

Använd det här mönstret i sådana här scenarier:

  • Du arbetar med kod på klientsidan, till exempel webbläsarprogram, och dessa begränsningar gör det svårt att tillhandahålla återanropsslutpunkter, eller att långvariga anslutningar lägger till för mycket komplexitet.

  • Du anropar en tjänst som endast använder HTTP-protokollet och returtjänsten kan inte skicka återanrop på grund av brandväggsbegränsningar på klientsidan.

  • Du integrerar med äldre arkitekturer som inte stöder moderna återanropsmekanismer som WebSockets eller webhooks.

Det här mönstret kanske inte är lämpligt när:

  • Du kan använda en tjänst som skapats för asynkrona meddelanden i stället, till exempel Azure Event Grid.

  • Svar måste strömmas i realtid till klienten.

  • Klienten måste samla in många resultat och svarstiden för dessa resultat är viktig. Överväg ett Service Bus-mönster i stället.

  • Beständiga nätverksanslutningar på serversidan som WebSockets eller SignalR är tillgängliga. Du kan använda dessa anslutningar för att meddela anroparen om resultatet.

  • Nätverksdesignen stöder öppna portar för att ta emot asynkrona återanrop eller webhooks.

Design av arbetsbelastning

En arkitekt bör utvärdera hur de kan använda det asynkrona Request-Reply mönstret i arbetsbelastningens design för att uppfylla de mål och principer som beskrivs i Azure Well-Architected Framework-pelarna.

Grundpelare Så här stöder det här mönstret pelarmål
Prestandaeffektivitet hjälper din arbetsbelastning effektivt uppfylla kraven genom optimering av skalning, data och kod. Du förbättrar svarstiden och skalbarheten genom att avkoda begärande- och svarsfaserna för processer som inte kräver ett omedelbart svar. En asynkron metod ökar samtidigheten och låter servern schemalägga arbete när kapaciteten blir tillgänglig.

- PE:05 Skalning och partitionering
- PE:07 Kod och infrastruktur

Som med alla designbeslut bör du överväga kompromisser mot målen för de andra pelarna som det här mönstret kan införa.

Exempel

Följande kod visar utdrag från ett program som använder Azure Functions för att implementera det här mönstret. Den här lösningen har tre funktioner:

  • Den asynkrona API-slutpunkten
  • Statusslutpunkten
  • En serverdelsfunktion som tar köade arbetsobjekt och kör dem

Diagram över strukturen för mönstret Asynkront svar för begäran i Functions.

GitHub logo. Det här exemplet finns på GitHub.

Funktionen AsyncProcessingWorkAcceptor

Funktionen AsyncProcessingWorkAcceptor implementerar en slutpunkt som accepterar arbete från ett klientprogram och köar det för bearbetning.

  • Funktionen genererar ett begärande-ID och lägger till det som metadata i kömeddelandet.

  • HTTP-svaret innehåller en Location rubrik som pekar på en statusslutpunkt. Begärande-ID:t visas i URL-sökvägen.

    public class AsyncProcessingWorkAcceptor(ServiceBusClient _serviceBusClient)
    {
        [Function("AsyncProcessingWorkAcceptor")]
        public async Task<IActionResult> RunAsync([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] HttpRequest req, [FromBody] CustomerPOCO customer)
        {
            if (string.IsNullOrEmpty(customer.id) || string.IsNullOrEmpty(customer.customername))
            {
                return new BadRequestResult();
            }

            var reqid = Guid.NewGuid().ToString();

            string scheme = Environment.GetEnvironmentVariable("AZURE_FUNCTIONS_ENVIRONMENT") == "Development" ? "http" : "https";
            var rqs = $"{scheme}://{Environment.GetEnvironmentVariable("WEBSITE_HOSTNAME")}/api/RequestStatus/{reqid}";

            var messagePayload = JsonConvert.SerializeObject(customer);
            var message = new ServiceBusMessage(messagePayload);
            message.ApplicationProperties.Add("RequestGUID", reqid);
            message.ApplicationProperties.Add("RequestSubmittedAt", DateTime.Now);
            message.ApplicationProperties.Add("RequestStatusURL", rqs);
            var sender = _serviceBusClient.CreateSender("outqueue");

            await sender.SendMessageAsync(message);
            return new AcceptedResult(rqs, $"Request Accepted for Processing{Environment.NewLine}ProxyStatus: {rqs}");
        }
    }

Funktionen AsyncProcessingBackgroundWorker

Funktionen AsyncProcessingBackgroundWorker läser åtgärden från kön, bearbetar den baserat på meddelandets nyttolast och skriver resultatet till ett lagringskonto.

    public class AsyncProcessingBackgroundWorker(BlobContainerClient _blobContainerClient)
    {
        [Function(nameof(AsyncProcessingBackgroundWorker))]
        public async Task Run([ServiceBusTrigger("outqueue", Connection = "ServiceBusConnection")] ServiceBusReceivedMessage message)
        {
            var requestGuid = message.ApplicationProperties["RequestGUID"].ToString();
            string blobName = $"{requestGuid}.blobdata";

            await _blobContainerClient.CreateIfNotExistsAsync();

            var blobClient = _blobContainerClient.GetBlobClient(blobName);
            using (MemoryStream memoryStream = new MemoryStream())
            using (StreamWriter writer = new StreamWriter(memoryStream))
            {
                writer.Write(message.Body.ToString());
                writer.Flush();
                memoryStream.Position = 0;

                await blobClient.UploadAsync(memoryStream, overwrite: true);
            }
        }
    }

Funktionen AsyncOperationStatusChecker

Funktionen AsyncOperationStatusChecker implementerar statusslutpunkten. Den här funktionen kontrollerar statusen för begäran:

    public class AsyncOperationStatusChecker(ILogger<AsyncOperationStatusChecker> _logger)
    {  
        [Function("AsyncOperationStatusChecker")]
        public async Task<IActionResult> Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "RequestStatus/{thisGUID}")] HttpRequest req,
             [BlobInput("data/{thisGUID}.blobdata", Connection = "DataStorage")] BlockBlobClient inputBlob, string thisGUID)
        {
            OnCompleteEnum OnComplete = Enum.Parse<OnCompleteEnum>(req.Query["OnComplete"].FirstOrDefault() ?? "Redirect");
            OnPendingEnum OnPending = Enum.Parse<OnPendingEnum>(req.Query["OnPending"].FirstOrDefault() ?? "OK");

            _logger.LogInformation($"C# HTTP trigger function processed a request for status on {thisGUID} - OnComplete {OnComplete} - OnPending {OnPending}");

            // Check whether the blob exists.
            if (await inputBlob.ExistsAsync())
            {
                // If the blob exists, the function uses the OnComplete parameter to determine the next action.
                return await OnCompleted(OnComplete, inputBlob, thisGUID);
            }
            else
            {
                // If the blob doesn't exist, the function uses the OnPending parameter to determine the next action.
                string scheme = Environment.GetEnvironmentVariable("AZURE_FUNCTIONS_ENVIRONMENT") == "Development" ? "http" : "https";
                string rqs = $"{scheme}://{Environment.GetEnvironmentVariable("WEBSITE_HOSTNAME")}/api/RequestStatus/{thisGUID}";

                switch (OnPending)
                {
                    case OnPendingEnum.OK:
                        {
                            // Return an HTTP 200 status code.
                            return new OkObjectResult(new { status = "In progress", Location = rqs });
                        }

                    case OnPendingEnum.Synchronous:
                        {
                            // Back off and retry. Time out if the back-off period reaches one minute.
                            int backoff = 250;

                            while (!await inputBlob.ExistsAsync() && backoff < 64000)
                            {
                                _logger.LogInformation($"Synchronous mode {thisGUID}.blob - retrying in {backoff} ms");
                                backoff = backoff * 2;
                                await Task.Delay(backoff);
                            }

                            if (await inputBlob.ExistsAsync())
                            {
                                _logger.LogInformation($"Synchronous Redirect mode {thisGUID}.blob - completed after {backoff} ms");
                                return await OnCompleted(OnComplete, inputBlob, thisGUID);
                            }
                            else
                            {
                                _logger.LogInformation($"Synchronous mode {thisGUID}.blob - NOT FOUND after timeout {backoff} ms");
                                return new NotFoundResult();
                            }
                        }

                    default:
                        {
                            throw new InvalidOperationException($"Unexpected value: {OnPending}");
                        }
                }
            }
        }
        private async Task<IActionResult> OnCompleted(OnCompleteEnum OnComplete, BlockBlobClient inputBlob, string thisGUID)
        {
            switch (OnComplete)
            {
                case OnCompleteEnum.Redirect:

                    {
                        // The typical way to generate a shared access signature token in code requires the storage account key.
                        // If you need to use a managed identity to control access to your storage accounts in code, which is a recommended best practice, you should do so when possible.
                        // In this scenario, you don't have a storage account key, so you need to find another way to generate the shared access signatures.
                        // To generate shared access signatures, use a user delegation shared access signature. This approach lets you sign the shared access signature by using Microsoft Entra ID credentials instead of the storage account key.

                        BlobServiceClient blobServiceClient = inputBlob.GetParentBlobContainerClient().GetParentBlobServiceClient();
                        var userDelegationKey = await blobServiceClient.GetUserDelegationKeyAsync(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(7));
                        // Redirect the shared access signature uniform resource identifier (URI) to blob storage.
                        return new RedirectResult(inputBlob.GenerateSASURI(userDelegationKey));
                    }

                case OnCompleteEnum.Stream:
                    {
                        // Download the file and return it directly to the caller.
                        // For larger files, use a stream to minimize RAM usage.
                        return new OkObjectResult(await inputBlob.DownloadContentAsync());
                    }

                default:
                    {
                        throw new InvalidOperationException($"Unexpected value: {OnComplete}");
                    }
            }
        }
    }

    public enum OnCompleteEnum
    {

        Redirect,
        Stream
    }

    public enum OnPendingEnum
    {

        OK,
        Synchronous
    }

Följande CloudBlockBlobExtensions klass tillhandahåller en tilläggsmetod som statuskontrollanvändaren använder för att generera en URI för en delegat med delad åtkomstsignatur till resultatbloben.

    public static class CloudBlockBlobExtensions
    {
        public static string GenerateSASURI(this BlockBlobClient inputBlob, UserDelegationKey userDelegationKey)
        {
            BlobServiceClient blobServiceClient = inputBlob.GetParentBlobContainerClient().GetParentBlobServiceClient();

            BlobSasBuilder blobSasBuilder = new BlobSasBuilder()
            {
                BlobContainerName = inputBlob.BlobContainerName,
                BlobName = inputBlob.Name,
                Resource = "b",
                StartsOn = DateTimeOffset.UtcNow,
                ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(10)
            };
            blobSasBuilder.SetPermissions(BlobSasPermissions.Read);

            var blobUriBuilder = new BlobUriBuilder(inputBlob.Uri)
            {
                Sas = blobSasBuilder.ToSasQueryParameters(userDelegationKey, blobServiceClient.AccountName)
            };

            // Generate the shared access signature on the blob, which sets the constraints directly on the signature.
            Uri sasUri = blobUriBuilder.ToUri();

            // Return the URI string for the container, including the shared access signature token.
            return sasUri.ToString();
        }
    }

Nästa steg