Operacje żądań i odpowiedzi w programie ASP.NET Core

Autor: Justin Kotalik

W tym artykule wyjaśniono, jak odczytać treść żądania i zapisać w treści odpowiedzi. Kod dla tych operacji może być wymagany podczas pisania oprogramowania pośredniczącego. Poza pisaniem oprogramowania pośredniczącego kod niestandardowy nie jest zwykle wymagany, ponieważ operacje są obsługiwane przez MVC i Razor pages.

Istnieją dwie abstrakcje dla treści żądania i odpowiedzi: Stream i Pipe. W przypadku odczytywania żądań HttpRequest.Body element to Stream, a HttpRequest.BodyReader element to PipeReader. W przypadku pisania HttpResponse.Body odpowiedzi jest elementem Streami HttpResponse.BodyWriter jest .PipeWriter

Potoki są zalecane za pośrednictwem strumieni. Strumienie można łatwiej używać w przypadku niektórych prostych operacji, ale potoki mają przewagę wydajności i są łatwiejsze do użycia w większości scenariuszy. ASP.NET Core zaczyna używać potoków zamiast strumieni wewnętrznie. Oto kilka przykładów:

  • FormReader
  • TextReader
  • TextWriter
  • HttpResponse.WriteAsync

Strumienie nie są usuwane ze struktury. Strumienie nadal używać na platformie .NET, a wiele typów strumieni nie ma odpowiedników potoków, takich jak FileStreams i ResponseCompression.

Przykłady strumieni

Załóżmy, że celem jest utworzenie oprogramowania pośredniczącego, które odczytuje całą treść żądania jako listę ciągów, dzieląc je na nowe wiersze. Prosta implementacja strumienia może wyglądać podobnie do poniższego przykładu:

Ostrzeżenie

Następujący kod powoduje:

  • Służy do demonstrowania problemów z brakiem potoku w celu odczytania treści żądania.
  • Nie jest przeznaczony do użycia w aplikacjach produkcyjnych.
private async Task<List<string>> GetListOfStringsFromStream(Stream requestBody)
{
    // Build up the request body in a string builder.
    StringBuilder builder = new StringBuilder();

    // Rent a shared buffer to write the request body into.
    byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);

    while (true)
    {
        var bytesRemaining = await requestBody.ReadAsync(buffer, offset: 0, buffer.Length);
        if (bytesRemaining == 0)
        {
            break;
        }

        // Append the encoded string into the string builder.
        var encodedString = Encoding.UTF8.GetString(buffer, 0, bytesRemaining);
        builder.Append(encodedString);
    }

    ArrayPool<byte>.Shared.Return(buffer);

    var entireRequestBody = builder.ToString();

    // Split on \n in the string.
    return new List<string>(entireRequestBody.Split("\n"));
}

Jeśli chcesz zobaczyć komentarze kodu przetłumaczone na języki inne niż angielski, poinformuj nas o tym w tym problemie z dyskusją w usłudze GitHub.

Ten kod działa, ale występują pewne problemy:

  • Przed dołączeniem StringBuilderdo elementu przykład tworzy kolejny ciąg (encodedString), który jest natychmiast odrzucany. Ten proces występuje dla wszystkich bajtów w strumieniu, więc wynikiem jest dodatkowa alokacja pamięci rozmiar całej treści żądania.
  • Przykład odczytuje cały ciąg przed podzieleniem na nowe wiersze. Wydajniejsze jest sprawdzenie nowych wierszy w tablicy bajtów.

Oto przykład, który rozwiązuje niektóre z powyższych problemów:

Ostrzeżenie

Następujący kod powoduje:

  • Służy do demonstrowania rozwiązań niektórych problemów w poprzednim kodzie, ale nie rozwiązuje wszystkich problemów.
  • Nie jest przeznaczony do użycia w aplikacjach produkcyjnych.
private async Task<List<string>> GetListOfStringsFromStreamMoreEfficient(Stream requestBody)
{
    StringBuilder builder = new StringBuilder();
    byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
    List<string> results = new List<string>();

    while (true)
    {
        var bytesRemaining = await requestBody.ReadAsync(buffer, offset: 0, buffer.Length);

        if (bytesRemaining == 0)
        {
            results.Add(builder.ToString());
            break;
        }

        // Instead of adding the entire buffer into the StringBuilder
        // only add the remainder after the last \n in the array.
        var prevIndex = 0;
        int index;
        while (true)
        {
            index = Array.IndexOf(buffer, (byte)'\n', prevIndex);
            if (index == -1)
            {
                break;
            }

            var encodedString = Encoding.UTF8.GetString(buffer, prevIndex, index - prevIndex);

            if (builder.Length > 0)
            {
                // If there was a remainder in the string buffer, include it in the next string.
                results.Add(builder.Append(encodedString).ToString());
                builder.Clear();
            }
            else
            {
                results.Add(encodedString);
            }

            // Skip past last \n
            prevIndex = index + 1;
        }

        var remainingString = Encoding.UTF8.GetString(buffer, prevIndex, bytesRemaining - prevIndex);
        builder.Append(remainingString);
    }

    ArrayPool<byte>.Shared.Return(buffer);

    return results;
}

W powyższym przykładzie:

  • Nie buforuje całej treści żądania w obiekcie StringBuilder , chyba że nie ma żadnych znaków nowego wiersza.
  • Nie wywołuje Split ciągu.

Jednak nadal istnieje kilka problemów:

  • Jeśli znaki nowego wiersza są rozrzedzone, większość treści żądania jest buforowana w ciągu.
  • Kod nadal tworzy ciągi (remainingString) i dodaje je do buforu ciągów, co powoduje dodatkową alokację.

Te problemy można rozwiązać, ale kod staje się coraz bardziej skomplikowany z niewielkimi ulepszeniami. Potoki zapewniają sposób rozwiązywania tych problemów z minimalną złożonością kodu.

Pipelines

W poniższym przykładzie pokazano, jak można obsłużyć ten sam scenariusz przy użyciu elementu PipeReader:

private async Task<List<string>> GetListOfStringFromPipe(PipeReader reader)
{
    List<string> results = new List<string>();

    while (true)
    {
        ReadResult readResult = await reader.ReadAsync();
        var buffer = readResult.Buffer;

        SequencePosition? position = null;

        do
        {
            // Look for a EOL in the buffer
            position = buffer.PositionOf((byte)'\n');

            if (position != null)
            {
                var readOnlySequence = buffer.Slice(0, position.Value);
                AddStringToList(results, in readOnlySequence);

                // Skip the line + the \n character (basically position)
                buffer = buffer.Slice(buffer.GetPosition(1, position.Value));
            }
        }
        while (position != null);


        if (readResult.IsCompleted && buffer.Length > 0)
        {
            AddStringToList(results, in buffer);
        }

        reader.AdvanceTo(buffer.Start, buffer.End);

        // At this point, buffer will be updated to point one byte after the last
        // \n character.
        if (readResult.IsCompleted)
        {
            break;
        }
    }

    return results;
}

private static void AddStringToList(List<string> results, in ReadOnlySequence<byte> readOnlySequence)
{
    // Separate method because Span/ReadOnlySpan cannot be used in async methods
    ReadOnlySpan<byte> span = readOnlySequence.IsSingleSegment ? readOnlySequence.First.Span : readOnlySequence.ToArray().AsSpan();
    results.Add(Encoding.UTF8.GetString(span));
}

W tym przykładzie rozwiązano wiele problemów, które miały implementacje strumieni:

  • Nie ma potrzeby buforu ciągów, ponieważ PipeReader nie są używane bajty.
  • Zakodowane ciągi są bezpośrednio dodawane do listy zwracanych ciągów.
  • ToArray Poza wywołaniem i pamięcią używaną przez ciąg tworzenie ciągów jest wolne od alokacji.

Karty

Właściwości Body, BodyReaderi BodyWriter są dostępne dla HttpRequest i HttpResponse. Po ustawieniu Body innego strumienia nowy zestaw kart automatycznie dostosowuje każdy typ do drugiego. Jeśli ustawisz HttpRequest.Body nowy strumień, HttpRequest.BodyReader zostanie automatycznie ustawiony na nowy PipeReader , który opakowuje HttpRequest.Bodyelement .

StartAsync

HttpResponse.StartAsync służy do wskazywania, że nagłówki są niemodyfikowalne i uruchamiają OnStarting wywołania zwrotne. W przypadku używania Kestrel jako serwera wywołanie StartAsync przed użyciem PipeReader gwarancji, że pamięć zwracana przez GetMemory program należy do KestrelPipe wewnętrznego, a nie zewnętrznego buforu.

Dodatkowe zasoby