Wzorzec asynchronicznego żądania i odpowiedzi

Azure
Azure Logic Apps

Oddzielenie przetwarzania zaplecza od hosta frontonu, kiedy przetwarzanie zaplecza musi być asynchroniczne, a fronton nadal potrzebuje jasnej odpowiedzi.

Kontekst i problem

W nowoczesnym tworzeniu aplikacji normalne jest, aby aplikacje klienckie — często kod działał w przeglądarce internetowej — aby zależeć od zdalnych interfejsów API w celu zapewnienia logiki biznesowej i funkcji tworzenia. Te interfejsy API mogą być bezpośrednio związane z aplikacją lub mogą być usługami udostępnionymi udostępnianymi przez inną firmę. Często te wywołania interfejsu API odbywają się za pośrednictwem protokołu HTTP(S) i są zgodne z semantykami REST.

W większości przypadków interfejsy API dla aplikacji klienckiej są przeznaczone do szybkiego reagowania w kolejności 100 ms lub mniejszej. Wiele czynników może mieć wpływ na opóźnienie odpowiedzi, w tym:

  • Stos hostingu aplikacji.
  • Składniki zabezpieczeń.
  • Względna lokalizacja geograficzna obiektu wywołującego i zaplecza.
  • Infrastruktura sieciowa.
  • Bieżące obciążenie.
  • Rozmiar ładunku żądania.
  • Przetwarzanie długości kolejki.
  • Czas przetwarzania żądania przez zaplecze.

Każdy z tych czynników może dodać opóźnienie do odpowiedzi. Niektóre z nich można ograniczyć przez skalowanie zaplecza w górę. Inne, takie jak infrastruktura sieci, są w dużej mierze poza kontrolą dewelopera aplikacji. Większość interfejsów API może reagować wystarczająco szybko, aby odpowiedzi wróciły do tego samego połączenia. Kod aplikacji może wykonać synchroniczne wywołanie interfejsu API w sposób nieblokjący, dając wygląd przetwarzania asynchronicznego, co jest zalecane w przypadku operacji związanych z operacjami we/wy.

Jednak w niektórych scenariuszach praca wykonywana przez zaplecze może być długotrwała, w kolejności sekund lub może być procesem w tle wykonywanym w minutach lub nawet godzinach. W takim przypadku nie jest możliwe oczekiwanie na ukończenie pracy przed odpowiedzią na żądanie. Taka sytuacja jest potencjalnym problemem dla każdego synchronicznego wzorca żądania-odpowiedzi.

Niektóre architektury rozwiązują ten problem przy użyciu brokera komunikatów, aby oddzielić etapy żądania i odpowiedzi. Ta separacja jest często osiągana przy użyciu wzorca bilansowania obciążenia opartego na kolejce. Takie rozdzielenie umożliwia niezależne skalowanie procesów klienta i interfejsu API zaplecza. Jednak ta separacja wiąże się również z dodatkową złożonością, gdy klient wymaga powiadomienia o powodzeniu, ponieważ ten krok musi stać się asynchroniczny.

Wiele z tych samych zagadnień omówionych dla aplikacji klienckich dotyczy również wywołań interfejsu API REST serwer-serwer w systemach rozproszonych — na przykład w architekturze mikrousług.

Rozwiązanie

Jednym z rozwiązań tego problemu jest użycie sondowania HTTP. Sondowanie jest przydatne w kodzie po stronie klienta, ponieważ może być trudne do zapewnienia punktów końcowych wywołań zwrotnych lub używania długotrwałych połączeń. Nawet jeśli wywołania zwrotne są możliwe, dodatkowe biblioteki i usługi, które są wymagane, czasami mogą dodać zbyt dużą złożoność.

  • Aplikacja kliencka wykonuje synchroniczne wywołanie interfejsu API, wyzwalając długotrwałą operację na zapleczu.

  • Interfejs API reaguje synchronicznie tak szybko, jak to możliwe. Zwraca kod stanu HTTP 202 (Zaakceptowane) z potwierdzeniem, że żądanie zostało odebrane do przetworzenia.

    Uwaga

    Interfejs API powinien zweryfikować zarówno żądanie, jak i akcję do wykonania przed rozpoczęciem długotrwałego procesu. Jeśli żądanie jest nieprawidłowe, odpowiedz natychmiast z kodem błędu, takim jak HTTP 400 (nieprawidłowe żądanie).

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

  • Interfejs API odciąża przetwarzanie do innego składnika, takiego jak kolejka komunikatów.

  • Dla każdego pomyślnego wywołania punktu końcowego stanu zwraca błąd HTTP 200. Mimo że praca jest nadal oczekująca, punkt końcowy stanu zwraca zasób, który wskazuje, że praca jest nadal w toku. Po zakończeniu pracy punkt końcowy stanu może zwrócić zasób wskazujący ukończenie lub przekierować 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:

Przepływ żądań i odpowiedzi dla asynchronicznych żądań HTTP

  1. Klient wysyła żądanie i odbiera odpowiedź HTTP 202 (Zaakceptowana).
  2. Klient wysyła żądanie HTTP GET do punktu końcowego stanu. Praca jest nadal oczekująca, więc to wywołanie zwraca błąd HTTP 200.
  3. W pewnym momencie praca jest ukończona, a punkt końcowy stanu zwraca 302 (znaleziono) przekierowanie do zasobu.
  4. Klient pobiera zasób pod określonym adresem URL.

Problemy i kwestie do rozważenia

  • Istnieje wiele możliwych sposobów implementacji tego wzorca za pośrednictwem protokołu HTTP, a nie wszystkich usług nadrzędnych, które mają tę samą semantyka. Na przykład większość usług nie zwróci odpowiedzi HTTP 202 z metody GET po zakończeniu procesu zdalnego. Po czystej semantyce REST powinni zwrócić http 404 (Nie znaleziono). Ta odpowiedź ma sens, jeśli wziąć pod uwagę wynik wywołania nie jest jeszcze obecny.

  • Odpowiedź HTTP 202 powinna wskazywać lokalizację i częstotliwość sondowania odpowiedzi przez klienta. Powinien mieć następujące dodatkowe nagłówki:

    Nagłówek opis Uwagi
    Lokalizacja Adres URL, który klient powinien sondować pod kątem stanu odpowiedzi. Ten adres URL może być tokenem SAS ze wzorcem klucza valet jest odpowiedni, jeśli ta lokalizacja wymaga kontroli dostępu. Wzorzec klucza valet jest również prawidłowy, gdy sondowanie odpowiedzi wymaga odciążenia do innego zaplecza.
    Ponów próbę po Szacowanie czasu ukończenia przetwarzania Ten nagłówek został zaprojektowany tak, aby uniemożliwić klientom sondowanie przed przeciążenie zaplecza ponawianiem prób.

    Oczekiwane zachowanie klienta należy rozważyć podczas projektowania tej odpowiedzi. Chociaż klient pod kontrolą może być kodowany w celu jawnego przestrzegania tych wartości odpowiedzi, klienci, którzy nie są autorami, lub używają podejścia bez użycia lub niskiego kodu (takiego jak Azure Logic Apps), mogą mieć własną obsługę logiki HTTP 202.

  • Może być konieczne użycie serwera proxy przetwarzania lub fasady w celu manipulowania nagłówkami lub ładunkiem odpowiedzi w zależności od używanych usług bazowych.

  • Jeśli punkt końcowy stanu przekierowuje po zakończeniu, HTTP 302 lub HTTP 303 są odpowiednimi kodami zwrotnymi, w zależności od dokładnej obsługiwanej semantyki.

  • Po pomyślnym przetworzeniu zasób określony przez nagłówek Location powinien zwrócić odpowiedni kod odpowiedzi HTTP, taki jak 200 (OK), 201 (Utworzono) lub 204 (Brak zawartości).

  • Jeśli podczas przetwarzania wystąpi błąd, utrąć błąd pod adresem URL zasobu opisanym w nagłówku Lokalizacja i najlepiej zwrócić odpowiedni kod odpowiedzi do klienta z tego zasobu (kod 4xx).

  • Nie wszystkie rozwiązania będą implementować ten wzorzec w taki sam sposób, a niektóre usługi będą zawierać dodatkowe lub alternatywne nagłówki. Na przykład usługa Azure Resource Manager używa zmodyfikowanego wariantu tego wzorca. Aby uzyskać więcej informacji, zobacz Operacje asynchroniczne usługi Azure Resource Manager.

  • Starsi klienci mogą nie obsługiwać tego wzorca. W takim przypadku może być konieczne umieszczenie fasady za pośrednictwem asynchronicznego interfejsu API w celu ukrycia asynchronicznego przetwarzania z oryginalnego klienta. Na przykład usługa Azure Logic Apps obsługuje ten wzorzec natywnie może służyć jako warstwa integracji między asynchronicznym interfejsem API a klientem, który wykonuje wywołania synchroniczne. Zobacz Wykonywanie długotrwałych zadań za pomocą wzorca akcji elementu 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 używać tego wzorca

Użyj tego wzorca dla:

  • Kod po stronie klienta, taki jak aplikacje przeglądarki, gdzie trudno jest zapewnić punkty końcowe wywołania zwrotnego lub użycie długotrwałych połączeń zwiększa zbyt dużą złożoność.

  • Wywołania usługi, w których jest dostępny tylko protokół HTTP, a usługa powrotna nie może uruchamiać wywołań zwrotnych z powodu ograniczeń zapory po stronie klienta.

  • Wywołania usług, które muszą być zintegrowane ze starszymi architekturami, które nie obsługują nowoczesnych technologii wywołania zwrotnego, takich jak webSocket lub elementy webhook.

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

  • Zamiast tego możesz użyć usługi utworzonej na potrzeby powiadomień asynchronicznych, takich jak usługa 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 magistrali usług.
  • Można użyć trwałych połączeń sieciowych po stronie serwera, takich jak WebSockets lub SignalR. Te usługi mogą służyć do powiadamiania obiektu wywołującego o wyniku.
  • Projekt sieci umożliwia otwieranie portów w celu odbierania asynchronicznych wywołań zwrotnych lub elementów webhook.

Projekt obciążenia

Architekt powinien ocenić, w jaki sposób wzorzec asynchronicznego żądania i odpowiedzi może być używany w projekcie obciążenia, aby sprostać celom i zasadom opisanym w filarach platformy Azure Well-Architected Framework. Na przykład:

Filar Jak ten wzorzec obsługuje cele filaru
Wydajność pomaga wydajnie sprostać zapotrzebowaniu dzięki optymalizacjom skalowania, danych, kodu. Oddzielenie faz żądań i odpowiedzi interakcji dla procesów, które nie wymagają natychmiastowych odpowiedzi, zwiększa czas reakcji i skalowalność systemów. W ramach podejścia asynchronicznego można zmaksymalizować współbieżność po stronie serwera i zaplanować ukończenie pracy w miarę możliwoś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ć wszelkie kompromisy w stosunku do celów innych filarów, które mogą zostać wprowadzone przy użyciu tego wzorca.

Przykład

Poniższy kod przedstawia fragmenty aplikacji, która używa usługi Azure Functions do implementowania tego wzorca. W rozwiązaniu istnieją trzy funkcje:

  • Asynchroniczny punkt końcowy interfejsu API.
  • Punkt końcowy stanu.
  • Funkcja zaplecza, która pobiera elementy robocze w kolejce i wykonuje je.

Obraz struktury wzorca odpowiedzi żądania asynchronicznego w funkcjach

Logo usługi GitHub Ten przykład jest dostępny w witrynie GitHub.

AsyncProcessingWorkAcceptor, funkcja

Funkcja AsyncProcessingWorkAcceptor implementuje punkt końcowy, który akceptuje pracę z aplikacji klienckiej i umieszcza go w kolejce do przetwarzania.

  • Funkcja generuje identyfikator żądania i dodaje go jako metadane do komunikatu kolejki.
  • Odpowiedź HTTP zawiera nagłówek lokalizacji wskazujący punkt końcowy stanu. Identyfikator żądania jest częścią ścieżki adresu URL.
public static class AsyncProcessingWorkAcceptor
{
    [FunctionName("AsyncProcessingWorkAcceptor")]
    public static async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] CustomerPOCO customer,
        [ServiceBus("outqueue", Connection = "ServiceBusConnectionAppSetting")] IAsyncCollector<ServiceBusMessage> OutMessages,
        ILogger log)
    {
        if (String.IsNullOrEmpty(customer.id) || string.IsNullOrEmpty(customer.customername))
        {
            return new BadRequestResult();
        }

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

        string rqs = $"http://{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);

        await OutMessages.AddAsync(message);

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

AsyncProcessingBackgroundWorker, funkcja

Funkcja AsyncProcessingBackgroundWorker pobiera operację z kolejki, wykonuje pewną pracę na podstawie ładunku komunikatu i zapisuje wynik na koncie magazynu.

public static class AsyncProcessingBackgroundWorker
{
    [FunctionName("AsyncProcessingBackgroundWorker")]
    public static async Task RunAsync(
        [ServiceBusTrigger("outqueue", Connection = "ServiceBusConnectionAppSetting")] BinaryData customer,
        IDictionary<string, object> applicationProperties,
        [Blob("data", FileAccess.ReadWrite, Connection = "StorageConnectionAppSetting")] BlobContainerClient inputContainer,
        ILogger log)
    {
        // Perform an actual action against the blob data source for the async readers to be able to check against.
        // This is where your actual service worker processing will be performed

        var id = applicationProperties["RequestGUID"] as string;

        BlobClient blob = inputContainer.GetBlobClient($"{id}.blobdata");

        // Now write the results to blob storage.
        await blob.UploadAsync(customer);
    }
}

AsyncOperationStatusChecker, funkcja

Funkcja AsyncOperationStatusChecker implementuje punkt końcowy stanu. Ta funkcja najpierw sprawdza, czy żądanie zostało ukończone

  • Jeśli żądanie zostało ukończone, funkcja zwraca klucz-valet-key do odpowiedzi lub przekierowuje wywołanie natychmiast do adresu URL klucza valet..
  • Jeśli żądanie nadal oczekuje, powinniśmy zwrócić kod 200, w tym bieżący stan.
public static class AsyncOperationStatusChecker
{
    [FunctionName("AsyncOperationStatusChecker")]
    public static async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "RequestStatus/{thisGUID}")] HttpRequest req,
        [Blob("data/{thisGuid}.blobdata", FileAccess.Read, Connection = "StorageConnectionAppSetting")] BlockBlobClient inputBlob, string thisGUID,
        ILogger log)
    {

        OnCompleteEnum OnComplete = Enum.Parse<OnCompleteEnum>(req.Query["OnComplete"].FirstOrDefault() ?? "Redirect");
        OnPendingEnum OnPending = Enum.Parse<OnPendingEnum>(req.Query["OnPending"].FirstOrDefault() ?? "OK");

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

        // Check to see if the blob is present
        if (await inputBlob.ExistsAsync())
        {
            // If it's present, depending on the value of the optional "OnComplete" parameter choose what to do.
            return await OnCompleted(OnComplete, inputBlob, thisGUID);
        }
        else
        {
            // If it's NOT present, then we need to back off. Depending on the value of the optional "OnPending" parameter, choose what to do.
            string rqs = $"http://{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 backoff period hits one minute.
                        int backoff = 250;

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

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

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

    private static async Task<IActionResult> OnCompleted(OnCompleteEnum OnComplete, BlockBlobClient inputBlob, string thisGUID)
    {
        switch (OnComplete)
        {
            case OnCompleteEnum.Redirect:
                {
                    // Redirect to the SAS URI to blob storage

                    return new RedirectResult(inputBlob.GenerateSASURI());
                }

            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
}

Następne kroki

Następujące informacje mogą być istotne w przypadku implementowania tego wzorca: