ASP.NET Core での要求と応答の操作

作成者: Justin Kotalik

この記事では、要求本文からの読み取りと、応答本文への書き込みを行う方法について説明します。 ミドルウェアを作成するときは、これらの操作のコードが必要になることがあります。 操作は MVC と Razor Pages によって処理されるため、ミドルウェアの作成以外では、通常、カスタムコードは必要ありません。

要求と応答の本文には 2 つの抽象化があります: StreamPipe です。 要求の読み取りでは、HttpRequest.BodyStream で、HttpRequest.BodyReaderPipeReader です。 応答の書き込みでは、HttpResponse.BodyStream で、HttpResponse.BodyWriterPipeWriter です。

パイプラインは、ストリームよりも推奨されます。 一部の単純な操作ではストリームの方が使いやすい場合がありますが、パイプラインの方がパフォーマンスに優れていて、ほとんどのシナリオでより簡単に使えます。 ASP.NET Core では、内部的にストリームではなくパイプラインが使われ始めています。 その例は次のとおりです。

  • FormReader
  • TextReader
  • TextWriter
  • HttpResponse.WriteAsync

ストリームがフレームワークから削除されていません。 ストリームは .NET 全体で使われ続けます。また、ストリームの種類の多くにはパイプラインに相当するものがありません (FileStreamsResponseCompression など)。

ストリームの例

要求本文全体を、改行で分割した文字列のリストとして読み取るミドルウェアを作成したいとします。 単純なストリームの実装は、次の例のようになります。

警告

コード例を次に示します。

  • 要求本文の読み取りにパイプが使用されない問題を示します。
  • 実稼働アプリで使用する意図はありません。
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) を作成しています。 このプロセスはストリーム内のすべてのバイトに対して実行されるため、要求本文全体のサイズよりも余分にメモリの割り当てが発生します。
  • 例では、改行で分割する前に文字列全体を読み取っています。 バイト配列内で改行をチェックした方がより効率的です。

これらの問題をいくつか修正した例を次に示します。

警告

コード例を次に示します。

  • 全部の問題が解消されるわけではありませんが、前のコードの一部の問題を解決する策を示します。
  • 実稼働アプリで使用する意図はありません。
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) が作成され、文字列バッファーに追加されているため、余分な割り当てが発生します。

これらの問題は修正可能ですが、わずかな改善でコードがますます複雑になっていきます。 パイプラインには、これらの問題を、コードの複雑さを最小限に抑えて解決する方法が用意されています。

パイプライン

同じシナリオを、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 呼び出しと、文字列で使用されるメモリを除き、文字列作成では割り当てが発生しません。

アダプター

HttpRequestHttpResponse には、BodyBodyReaderBodyWriter プロパティを利用できます。 Body を別のストリームに設定すると、新しいアダプターのセットにより、各種類が別のものに自動的に適応します。 HttpRequest.Body を新しいストリームに設定した場合、HttpRequest.BodyReader は自動的に、HttpRequest.Body をラップする新しい PipeReader に設定されます。

StartAsync

HttpResponse.StartAsync は、ヘッダーが変更不可能であり、また OnStarting コールバックを実行することを示すために使います。 サーバーとして Kestrel を使う場合、PipeReader を使う前に StartAsync を呼び出すことで、GetMemory によって返されるメモリが、外部バッファーではなく Kestrel の内部 Pipe に属するよう保証できます。

その他の技術情報