Udostępnij za pośrednictwem


Wzorzec asynchronicznego żądania i odpowiedzi

Oddziel przetwarzanie back-endu od hosta front-end, gdy przetwarzanie back-endu musi być uruchamiane asynchronicznie, ale front-end potrzebuje jasnej odpowiedzi.

Kontekst i problem

We współczesnym tworzeniu aplikacji aplikacje klienckie często korzystają ze zdalnych interfejsów API w celu zapewnienia logiki biznesowej i tworzenia funkcji. Wiele aplikacji uruchamia kod w przeglądarce internetowej, a inne środowiska hostuje również kod klienta. Interfejsy API mogą być powiązane bezpośrednio z aplikacją lub działać jako usługi udostępnione z usługi zewnętrznej. Większość wywołań interfejsu API używa protokołu HTTP lub HTTPS i postępują zgodnie z semantyką REST.

W większości przypadków interfejsy API dla aplikacji klienckiej odpowiadają w ciągu około 100 milisekund (ms) lub mniej. Wiele czynników może mieć wpływ na opóźnienie odpowiedzi:

  • Stos hostingu aplikacji
  • Składniki zabezpieczeń
  • Względna lokalizacja geograficzna rozmówcy i zaplecza systemowego
  • Infrastruktura sieciowa
  • Bieżące obciążenie
  • Rozmiar ładunku żądania
  • Długość kolejki przetwarzania
  • Czas przetwarzania żądania przez zaplecze

Te czynniki mogą powodować opóźnienie w odpowiedzi. Niektóre czynniki można ograniczyć przez skalowanie poziome zaplecza systemu. Inne czynniki, takie jak infrastruktura sieci, są poza kontrolą dewelopera aplikacji. Większość API reaguje wystarczająco szybko, aby odpowiedź wróciła przez to samo połączenie. Kod aplikacji może wykonać synchroniczne wywołanie interfejsu API w sposób nieblokujący, aby zapewnić wygląd przetwarzania asynchronicznego. Zalecamy takie podejście do operacji obciążonych wejściem i wyjściem (we/wy).

W niektórych scenariuszach, back-end wykonuje pracę, która zajmuje kilka sekund, ale jest długotrwała. W innych scenariuszach zaplecze wykonuje długotrwałą pracę w tle przez kilka minut lub przez dłuższy czas. W takich przypadkach nie można poczekać na zakończenie pracy przed wysłaniem odpowiedzi. Taka sytuacja może spowodować problem z synchronicznymi wzorcami odpowiedzi na żądanie.

Niektóre architektury rozwiązują ten problem przy użyciu brokera komunikatów, aby oddzielić etapy żądania i odpowiedzi. Wiele systemów osiąga tę separację za pomocą wzorca bilansowania obciążenia opartego na kolejkach. Ta separacja umożliwia niezależne skalowanie procesów klienta i interfejsu API zaplecza. Wprowadza również dodatkową złożoność, gdy klient wymaga powiadomienia o powodzeniu, ponieważ ten krok musi również stać się asynchroniczny.

Wiele z tych samych zagadnień, które mają zastosowanie do aplikacji klienckich, dotyczy również wywołań interfejsu API REST serwer-serwer w systemach rozproszonych, takich jak w architekturze mikrousług.

Rozwiązanie

Jednym z rozwiązań tego problemu jest użycie sondowania HTTP. Sondowanie działa dobrze w przypadku kodu po stronie klienta, gdy punkty końcowe wywołania zwrotnego są niedostępne lub gdy długotrwałe połączenia dodają zbyt dużą złożoność. Choć wywołania zwrotne są możliwe, dodatkowe biblioteki i usługi, których użycie jest wymagane, mogą zwiększyć złożoność.

W poniższych krokach opisano rozwiązanie:

  • Aplikacja kliencka wykonuje synchroniczne wywołanie API w celu wyzwolenia długotrwałej operacji w tle.

  • Interfejs API reaguje synchronicznie tak szybko, jak to możliwe. Zwraca kod stanu HTTP 202 (Zaakceptowane), aby potwierdzić, że otrzymał żądanie przetwarzania.

    Uwaga / Notatka

    Interfejs API weryfikuje żądanie i akcję do wykonania przed rozpoczęciem długotrwałego procesu. Jeśli żądanie jest nieprawidłowe, odpowiedz natychmiast za pomocą kodu błędu, takiego jak HTTP 400 (nieprawidłowe żądanie).

  • Odpowiedź zawiera odwołanie do lokalizacji wskazujące punkt końcowy, który klient może sondować w celu sprawdzenia wyniku długotrwałej operacji.

  • Interfejs API odciąża przetwarzanie na inny składnik, taki jak kolejka komunikatów.

  • W przypadku pomyślnego wywołania punktu końcowego stanu, zwracany jest kod HTTP 200 (OK). Podczas gdy praca jest w trakcie realizacji, interfejs końcowy zwraca zasób wskazujący ten stan. Po zakończeniu pracy punkt końcowy zwraca zasób informujący o zakończeniu pracy albo przekierowuje się do innego adresu URL zasobu. Jeśli na przykład operacja asynchroniczna tworzy nowy zasób, punkt końcowy stanu przekierowuje do adresu URL tego zasobu.

Na poniższym diagramie przedstawiono typowy przepływ.

Diagram przedstawiający przepływ żądania i odpowiedzi dla asynchronicznych żądań HTTP.

  1. Klient wysyła żądanie i odbiera odpowiedź HTTP 202.

  2. Klient wysyła żądanie HTTP GET do punktu końcowego stanu. Praca jest w toku, więc to wywołanie zwraca kod HTTP 200.

  3. Praca zostaje ukończona, a punkt końcowy stanu zwraca status HTTP 302 (Found) w celu przekierowania do zasobu.

  4. Klient pobiera zasób pod określonym adresem URL.

Problemy i zagadnienia

Podczas podejmowania decyzji o zaimplementowaniu tego wzorca należy wziąć pod uwagę następujące kwestie:

  • Istnieje wiele sposobów implementacji tego wzorca za pośrednictwem protokołu HTTP, a usługi nadrzędne nie zawsze używają tej samej semantyki. Na przykład większość usług zwraca błąd HTTP 404 (Nie znaleziono) z metody GET, gdy proces zdalny nie jest ukończony, a nie HTTP 202. Zgodnie ze standardową semantyka REST, HTTP 404 jest poprawną odpowiedzią, ponieważ wynik wywołania jeszcze nie istnieje.

  • Odpowiedź HTTP 202 wskazuje, gdzie klient sonduje i jak często. Zawiera następujące nagłówki.

    Nagłówek Opis Notatki
    Location Adres URL, który klient wykorzystuje do sprawdzania statusu odpowiedzi Ten adres URL może być tokenem sygnatury dostępu współdzielonego. Wzorzec klucza valet działa dobrze, gdy ta lokalizacja wymaga kontroli dostępu. Wzorzec ma również zastosowanie, gdy sondowanie odpowiedzi musi zostać przeniesione do innego zaplecza serwerowego.
    Retry-After Szacowanie czasu ukończenia przetwarzania Ten nagłówek uniemożliwia klientom sondowania wysyłania zbyt wielu żądań do zaplecza serwera.

    Podczas projektowania tej odpowiedzi należy wziąć pod uwagę oczekiwane zachowanie klienta. Klient, który kontrolujesz, może dokładnie śledzić te wartości odpowiedzi. Klienci, które zostały stworzone przez innych, w tym klienci skompilowani przy użyciu narzędzi bez kodu lub niskokodowych, takich jak Azure Logic Apps, mogą stosować własną implementację obsługi protokołu HTTP 202.

  • Może być konieczne użycie proxy przetwarzającego w celu dostosowania nagłówków odpowiedzi lub ładunku, w zależności od usług, z których korzystasz.

  • Jeśli punkt końcowy stanu przekierowuje po zakończeniu, HTTP 302 lub HTTP 303 (Zobacz inne) stanowią prawidłowe kody odpowiedzi, w zależności od obsługiwanej semantyki.

  • Gdy serwer przetworzy żądanie, zasób Location określony w nagłówku zwraca kod stanu HTTP, taki jak 200, 201 (utworzony) lub 204 (Brak zawartości).

  • Jeśli podczas przetwarzania wystąpi błąd, zapisz go pod adresem URL zasobu określonym przez nagłówek Location i zwróć kod stanu 4xx z tego zasobu, który odpowiada temu błędowi.

  • Rozwiązania nie implementują tego wzorca w taki sam sposób, a niektóre usługi zawierają dodatkowe lub alternatywne nagłówki. Na przykład Azure Resource Manager używa zmodyfikowanego wariantu tego wzorca. Aby uzyskać więcej informacji, zobacz Resource Manager operacje asynchroniczne.

  • Starsi klienci mogą nie obsługiwać tego wzorca. W takim przypadku może być konieczne umieszczenie serwera proxy przetwarzania za pośrednictwem asynchronicznego interfejsu API w celu ukrycia asynchronicznego przetwarzania z oryginalnego klienta. Na przykład usługa Logic Apps obsługuje ten wzorzec natywnie i można jej używać jako warstwy integracji między asynchronicznym interfejsem API a klientem, który wykonuje synchroniczne wywołania. Aby uzyskać więcej informacji, zobacz Wykonywanie długotrwałych zadań przy użyciu wzorca akcji webhook.

  • W niektórych scenariuszach możesz zapewnić klientom możliwość anulowania długotrwałego żądania. W takim przypadku usługa zaplecza musi obsługiwać jakąś formę instrukcji anulowania.

Kiedy należy używać tego wzorca

Użyj tego wzorca, gdy:

  • Pracujesz z kodem po stronie klienta, takimi jak aplikacje przeglądarkowe, a te ograniczenia utrudniają zapewnienie punktów końcowych wywołań zwrotnych lub powodują, że długotrwałe połączenia dodają zbyt wiele złożoności.

  • Wywołasz usługę, która używa tylko protokołu HTTP, a usługa powrotna nie może wysyłać wywołań zwrotnych z powodu ograniczeń zapory po stronie klienta.

  • Integrujesz się ze starszymi architekturami, które nie obsługują nowoczesnych mechanizmów wywołania zwrotnego, takich jak WebSockets lub webhook.

Ten wzorzec może nie być odpowiedni w następujących przypadkach:

  • Możesz zamiast tego użyć usługi utworzonej na potrzeby powiadomień asynchronicznych, takich jak Azure Event Grid.

  • Odpowiedzi muszą być przesyłane strumieniowo w czasie rzeczywistym do klienta.

  • Klient musi zebrać wiele wyników, a opóźnienie tych wyników jest ważne. Zamiast tego rozważ wzorzec busu usługowego.

  • Dostępne są trwałe połączenia sieciowe po stronie serwera, takie jak WebSockets lub SignalR. Tych połączeń można użyć do powiadamiania osoby dzwoniącej o wyniku.

  • Projekt sieciowy obsługuje otwarte porty do odbierania asynchronicznych wywołań zwrotnych lub webhooków.

Projektowanie obciążenia

Architekt powinien ocenić, w jaki sposób może korzystać ze wzorca asynchronicznego Request-Reply w projektowaniu obciążenia, aby osiągnąć cele i zasady zawarte w filarach Azure Well-Architected Framework.

Filar Jak ten wzorzec obsługuje cele filaru
Efektywność wydajności pomaga wydajnie sprostać wymaganiom dzięki optymalizacjom skalowania, danych i kodu. Zwiększasz czas odpowiedzi i skalowalność, rozdzielając fazy żądania i odpowiedzi dla procesów, które nie wymagają natychmiastowej odpowiedzi. Podejście asynchroniczne zwiększa współbieżność i umożliwia serwerowi zaplanowanie pracy w miarę dostępności pojemności.

- PE:05 Skalowanie i partycjonowanie
- PE:07 Kod i infrastruktura

Podobnie jak w przypadku każdej decyzji projektowej, należy rozważyć kompromisy w stosunku do celów innych filarów, które mogą wprowadzić ten wzorzec.

Przykład

Poniższy kod przedstawia fragmenty aplikacji, która używa Azure Functions do zaimplementowania tego wzorca. To rozwiązanie ma trzy funkcje:

  • Asynchroniczny punkt końcowy interfejsu API
  • Punkt końcowy stanu
  • Funkcja zaplecza, która pobiera kolejkowane elementy robocze i uruchamia je

Diagram struktury asynchronicznego wzorca odpowiedzi żądania w funkcjach.

GitHub logo. Ten przykład jest dostępny w GitHub.

Funkcja AsyncProcessingWorkAcceptor

Funkcja AsyncProcessingWorkAcceptor implementuje punkt końcowy, który akceptuje zadania z aplikacji klienckiej i zapisuje je w kolejce do przetwarzania.

  • Funkcja generuje identyfikator żądania i dodaje go jako metadane do komunikatu kolejki.

  • Odpowiedź HTTP zawiera Location nagłówek wskazujący punkt końcowy stanu. Identyfikator żądania jest wyświetlany w ścieżce adresu URL.

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

Funkcja AsyncProcessingBackgroundWorker

Funkcja AsyncProcessingBackgroundWorker odczytuje operację z kolejki, przetwarza ją na podstawie ładunku komunikatu i zapisuje wynik na koncie magazynu.

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

Funkcja AsyncOperationStatusChecker

Funkcja AsyncOperationStatusChecker implementuje punkt końcowy stanu. Ta funkcja sprawdza stan żądania:

    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
    }

Poniższa CloudBlockBlobExtensions klasa udostępnia metodę rozszerzenia, którą moduł sprawdzania stanu używa do generowania identyfikatora URI sygnatury wspólnego dostępu delegowania użytkownika dla obiektu blob rezultatu.

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

Następne kroki