ASP.NET Core의 요청 및 응답 작업

작성자 Justin Kotalik

이 문서에서는 요청 본문을 읽고 응답 본문을 쓰는 방법을 설명합니다. 미들웨어를 작성할 때 이러한 작업에 대한 코드가 필요할 수 있습니다. 미들웨어 작성 외에, MVC 및 Razor Pages에서 작업이 처리되기 때문에 사용자 지정 코드는 일반적으로 필요하지 않습니다.

요청 및 응답 본문에 대한 두 가지 추상(StreamPipe)이 있습니다. 요청 읽기의 경우 HttpRequest.BodyStream이고 HttpRequest.BodyReaderPipeReader입니다. 응답 작성의 경우 HttpResponse.BodyStream이고 HttpResponse.BodyWriterPipeWriter입니다.

스트림보다 Pipelines를 사용하는 것이 좋습니다. 일부 간단한 작업에서는 스트림이 더 편리할 수도 있지만, 파이프라인은 성능상의 장점이 있고 대부분의 시나리오에서 더 편리합니다. ASP.NET Core는 내부적으로 스트림 대신 파이프라인을 사용하기 시작했습니다. 예를 들면 다음과 같습니다.

  • FormReader
  • TextReader
  • TextWriter
  • HttpResponse.WriteAsync

스트림이 프레임워크에서 제거되지 않습니다. 스트림은 .NET 전체에서 계속 사용되며, FileStreamsResponseCompression과 같이 상응하는 파이프 항목이 없는 스트림 형식도 많습니다.

스트림 예제

새 줄에서 분할하여 전체 요청 본문을 문자열 목록으로 읽는 미들웨어를 만들려 한다고 가정해 보겠습니다. 단순 스트림 구현은 다음 예제와 같이 표시될 수 있습니다.

Warning

코드는 다음과 같습니다.

  • 파이프를 사용하지 않고 요청 본문을 읽을 때 발생하는 문제를 보여 주는 데 사용됩니다.
  • 프로덕션 앱에서 사용하기 위한 것이 아닙니다.
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"));
}

영어 이외의 언어로 번역된 코드 주석을 보려면 이 GitHub 토론 이슈에서 알려주세요.

이 코드는 실행되지만 다음과 같은 몇 가지 문제가 있습니다.

  • 예제에서는 StringBuilder에 추가하기 전에 즉시 제거되는 또 다른 문자열(encodedString)이 생성됩니다. 이 프로세스는 스트림의 모든 바이트에 대해 발생하므로 결과는 전체 요청 본문의 크기만큼 추가 메모리가 할당됩니다.
  • 예제에서는 새 줄에서 분할하기 전에 전체 문자열을 읽습니다. 바이트 배열에서 새 줄을 확인하는 것이 더 효율적입니다.

앞의 문제 중 일부를 해결한 예제는 다음과 같습니다.

Warning

코드는 다음과 같습니다.

  • 위 코드의 모든 문제를 해결하는 것이 아니라 일부 문제에 대한 해결을 보여 주는 데 사용됩니다.
  • 프로덕션 앱에서 사용하기 위한 것이 아닙니다.
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;
}

앞의 예제:

  • 줄 바꿈 문자가 없는 경우 StringBuilder에 전체 요청 본문을 버퍼링하지 않습니다.
  • 문자열에서 Split를 호출하지 않습니다.

그러나 여전히 다음과 같은 몇 가지 문제가 있습니다.

  • 줄 바꿈 문자가 스파스인 경우, 대부분의 요청 본문이 문자열에 버퍼링됩니다.
  • 코드는 문자열(remainingString)을 계속 만들어 문자열 버퍼에 추가하므로 추가 할당이 발생합니다.

이러한 문제는 해결할 수 있지만, 코드가 별다른 개선 사항 없이 점점 더 복잡해집니다. 파이프라인은 코드 복잡성을 최소화하여 이러한 문제를 해결하는 방법을 제공합니다.

Pipelines

다음 예제에서는 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));
}

이 예제에서는 스트림 구현에 포함된 많은 문제를 수정합니다.

  • PipeReader에서 사용되지 않은 바이트를 처리하므로 문자열 버퍼가 필요하지 않습니다.
  • 인코드된 문자열은 반환된 문자열 목록에 직접 추가됩니다.
  • 문자열에서 사용하는 메모리 외에, 할당 없이 문자열이 생성됩니다(ToArray 호출 제외).

어댑터

Body, BodyReaderBodyWriter 속성은 HttpRequestHttpResponse에 대해 사용할 수 있습니다. Body를 다른 스트림에 설정하면 새 어댑터 세트가 각 형식을 다른 형식에 맞게 자동으로 조정합니다. HttpRequest.Body를 새 스트림으로 설정하는 경우 HttpRequest.BodyReaderHttpRequest.Body를 래핑하는 새 PipeReader로 자동 설정됩니다.

StartAsync

HttpResponse.StartAsync는 헤더를 수정할 수 없음을 나타내며 OnStarting 콜백을 실행하는 데 사용됩니다. Kestrel을 서버로 사용하는 경우 PipeReader를 사용하기 전에 StartAsync를 호출하면 GetMemory에서 반환된 메모리가 외부 버퍼가 아닌 Kestrel의 내부 Pipe에 속하게 됩니다.

추가 리소스