ASP.NET Core のベスト プラクティス

作成者: Mike Rousos

この記事では、ASP.NET Core アプリのパフォーマンスと信頼性を最大化するためのガイドラインについて説明します。

積極的にキャッシュする

キャッシュについては、この記事のいくつかの部分で説明します。 詳細については、「ASP.NET Core でのキャッシュの概要」を参照してください。

ホット コード パスについて

この記事では、''ホット コード パス'' は、頻繁に呼び出され、多くの実行時間が生じるコード パスとして定義されています。 ホット コード パスでは通常、アプリのスケールアウトとパフォーマンスが制限されます。これらについては、この記事のいくつかの部分で説明します。

呼び出しをブロックしないようにする

ASP.NET Core アプリは、多くの要求を同時に処理するように設計する必要があります。 非同期 API では、ブロッキング呼び出しが実行されるのを待たないことによって、小さなスレッド プールでも数千の要求を同時に処理できます。 各スレッドで、時間のかかる同期的なタスクの処理が完了するのを待たずに、別の要求を処理します。

ASP.NET Core アプリの一般的なパフォーマンスに関する問題は、非同期である可能性がある呼び出しがブロックされることです。 多くの同期的なブロッキング呼び出しを行うと、スレッド プールの枯渇や応答時間の増加が起こります。

Task.Wait または Task<TResult>.Result を呼び出して非同期実行をブロックしないでください。 共通のコード パスでロックを取得しないでください。 ASP.NET Core アプリのパフォーマンスは、コードを並列で実行するように設計されている場合に最も高くなります。 Task.Run を呼び出してすぐにそれを待機しないでください。 既に ASP.NET Core で通常のスレッド プールのスレッドを使用してアプリのコードを実行しているため、Task.Run を呼び出しても、スレッド プールに対する無駄なスケジューリングを行うだけです。 スケジュールされたコードによってスレッドがブロックされても、Task.Run でそれを防ぐことはできません。

  • ホット コード パスを非同期にしてください
  • 非同期 API を使用できる場合は、データ アクセス、I/O、および実行時間の長い操作 API を非同期的に呼び出してください
  • 同期 API を非同期にするために、Task.Run を使用しないでください
  • コントローラーや Razor ページ アクションを非同期にしてください。 コール スタック全体を非同期にして、async/await の組み合わせを有効活用する。

PerfView などのプロファイラーを使用すれば、スレッド プールに頻繁に追加されるスレッドを把握できます。 Microsoft-Windows-DotNETRuntime/ThreadPoolWorkerThread/Start イベントは、スレッド プールにスレッドを追加したことを示します。

複数のより小さなページにわたって大きなコレクションを返す

Web ページで大量のデータをすべて一度に読み込むことはできません。 オブジェクトのコレクションを返すときに、パフォーマンスの問題につながる可能性があるかどうかを検討してください。 設計によって次の悪い結果が生じる可能性があるかどうかを判断します。

  • OutOfMemoryException またはメモリ消費量が多い
  • スレッド プールの枯渇 (IAsyncEnumerable<T> の解説を参照)
  • 応答時間が遅い
  • 頻繁なガベージ コレクション

改ページを追加して、前のシナリオを軽減します。 開発者は、ページ サイズとページ インデックス パラメーターを使用して、部分的な結果を返す設計を優先する必要があります。 完全な結果が必要な場合は、サーバー リソースのロックを回避するように、結果のバッチを非同期的に設定するために改ページを使用する必要があります。

返されるレコードのページングと数の制限の詳細については、以下を参照してください。

IEnumerable<T> または IAsyncEnumerable<T> を返す

アクションから IEnumerable<T> が返されると、シリアライザーによって同期コレクションのイテレーションが行われます。 その結果、呼び出しがブロックされ、スレッド プールが枯渇する可能性があります。 同期列挙を回避するには、列挙型を返す前にToListAsync を使用します。

ASP.NET Core 3.0 以降では、非同期的に列挙する IEnumerable<T> の代わりに IAsyncEnumerable<T> を使用できます。 詳細については、「コントローラー アクションの戻り値の型」を参照してください。

大きなオブジェクトの割り当て最小限に抑える

.NET Core ガベージ コレクターにより、ASP.NET Core アプリのメモリの割り当てと解放が自動的に管理されます。 自動ガベージ コレクションは一般的に、開発者がいつどのようにメモリが解放されるかを心配する必要がないことを意味します。 しかし、参照されていないオブジェクトのクリーンアップには CPU 時間がかかるため、開発者はホット コード パスでのオブジェクトの割り当てを最小限に抑える必要があります。 ガベージ コレクションは、大きなオブジェクト (>= 85,000 バイト) に対しては特にコストがかかります。 大きなオブジェクトは大きなオブジェクト ヒープに格納され、クリーンアップにはフル (第 2 世代) ガベージ コレクションが必要です。 第 0 世代および第 1 世代コレクションとは異なり、第 2 世代コレクションでは、アプリの実行を一時的に中断する必要があります。 大きなオブジェクトの割り当てとその解除を頻繁に行うと、一貫したパフォーマンスが得られなくなる可能性があります。

推奨事項:

  • 頻繁に使用される大きなオブジェクトのキャッシュを検討する。 大きなオブジェクトをキャッシュすると、コストのかかる割り当てを防ぐことができます。
  • ArrayPool<T> を使用して大きな配列を格納することで、バッファーをプールする
  • ホット コード パスに、存続期間の短い大きなオブジェクトを多数割り当てない

上記のようなメモリの問題は、PerfView でガベージ コレクション (GC) の統計を確認し、次のことを調べることで診断できます。

  • ガベージ コレクションの一時停止時間。
  • ガベージ コレクションにかかったプロセッサ時間の割合。
  • 第 0、1、および 2 世代のガベージ コレクションの数。

詳細については、「ガベージ コレクションとパフォーマンス」を参照してください。

データ アクセスと I/O を最適化する

ASP.NET Core アプリでは多くの場合、データ ストアや他のリモート サービスとのやりとりに最も時間がかかります。 良好なパフォーマンスを得るには、データの読み取りと書き込みを効率的に行うことが重要です。

推奨事項:

  • すべてのデータ アクセス API を非同期的に呼び出す
  • 必要以上のデータを取得しない。 現在の HTTP 要求に必要なデータのみを返すクエリを記述する。
  • 少し古いデータが受け入れられる場合は、データベースまたはリモート サービスから取得された頻繁にアクセスされるデータのキャッシュを検討する。 シナリオに応じて、MemoryCache または DistributedCache を使用します。 詳細については、「ASP.NET Core の応答キャッシュ」を参照してください。
  • ネットワーク ラウンド トリップを最小限に抑える。 目標は、複数の呼び出しではなく、1 回の呼び出しで必要なデータを取得することです。
  • 読み取り専用の目的でデータにアクセスする場合は、Entity Framework Core で追跡なしのクエリを使用する。 EF Core では、より効率的に追跡なしのクエリの結果を返すことができます。
  • LINQ クエリをフィルター処理および集計して (.Where.Select、または .Sum ステートメントなどを使用)、データベースによってフィルター処理されるようにする。
  • クエリの実行が非効率になる可能性がある、クライアントの一部のクエリ演算子が EF Core によって解決されることを考慮する。 詳細については、「クライアントの評価に伴うパフォーマンスの問題」を参照してください。
  • "N + 1" SQL クエリが実行される可能性がある、プロジェクション クエリをコレクションに対して使用しない。 詳細については、「相関サブクエリの最適化」を参照してください。

次の方法により、大規模なアプリのパフォーマンスが向上する可能性があります。

コード ベースをコミットする前に、前のハイパフォーマンスの方法の影響を測定することをお勧めします。 コンパイル済みクエリの複雑さが増すと、パフォーマンスの向上が正当化されない場合があります。

クエリの問題は、Application Insights またはプロファイリング ツールを使用してデータへのアクセスにかかった時間を確認することで検出できます。 ほとんどのデータベースでは、頻繁に実行されるクエリに関する統計も使用できます。

HttpClientFactory で HTTP 接続をプールする

HttpClient には IDisposable インターフェイスが実装されていますが、再利用するように設計されています。 HttpClient インスタンスが閉じられている場合、短期間 TIME_WAIT 状態でソケットが開いたままになります。 HttpClient オブジェクトを作成して破棄するコード パスが頻繁に使用されると、アプリで使用可能なソケットが使い果たされる可能性があります。 HttpClientFactory は、この問題の解決策として ASP.NET Core 2.1 で導入されたものです。 HTTP 接続のプーリングを処理して、パフォーマンスと信頼性を最適化します。 詳細については、「HttpClientFactory を使用して回復力の高い HTTP 要求を実装する」を参照してください。

推奨事項:

共通のコード パスを高速に保つ

すべてのコードを高速にする必要があります。 頻繁に呼び出されるコード パスは、最適化に最も重要なものです。 これには以下が含まれます。

  • アプリの要求処理パイプラインのミドルウェア コンポーネント (特にミドルウェア) は、パイプラインの早い段階で実行されます。 これらのコンポーネントは、パフォーマンスに大きな影響を与えます。
  • すべての要求に対して、または要求ごとに複数回実行されるコード。 たとえば、カスタム ログ、承認ハンドラー、一時的なサービスの初期化などです。

推奨事項:

HTTP 要求の外部で実行時間の長いタスクを完了する

ASP.NET Core アプリへのほとんどの要求は、必要なサービスを呼び出し、HTTP 応答を返すコントローラーまたはページ モデルで処理できます。 実行時間の長いタスクを伴う一部の要求では、要求と応答のプロセス全体を非同期にすることをお勧めします。

推奨事項:

  • 実行時間の長いタスクが、通常の HTTP 要求処理の一環として完了するまで待たない
  • 実行時間の長い要求をバックグラウンド サービスで、または Azure 関数を使用してアウト プロセスで処理することを検討する。 作業をアウト プロセスで完了することは、CPU を集中的に使用するタスクで特に役立ちます。
  • クライアントと非同期通信を行うために、SignalR などのリアルタイム通信オプションを使用する

クライアント資産を縮小する

複雑なフロントエンドを備えた ASP.NET Core アプリでは、多くの JavaScript、CSS、またはイメージ ファイルが提供されることがよくあります。 初期読み込み要求のパフォーマンスは、次のようにして向上させることができます。

  • バンドル。複数のファイルを 1 つにまとめます。
  • 縮小。空白とコメントを削除することでファイルのサイズを小さくします。

推奨事項:

  • バンドルと縮小のガイドラインを使用する。ここでは、互換性のあるツールについて説明されており、ASP.NET Core の environment タグを使用して DevelopmentProduction の両方を処理する方法が示されています。
  • 複雑なクライアント資産管理では、Webpack などの他のサードパーティ製ツールを検討する

応答を圧縮する

通常、応答のサイズを小さくすると、多くの場合、アプリの応答性が大幅に向上します。 ペイロード サイズを小さくする 1 つの方法は、アプリの応答を圧縮することです。 詳細については、「応答圧縮」を参照してください。

最新の ASP.NET Core リリースを使用する

ASP.NET Core は新しくリリースされるたびにパフォーマンスが向上しています。 .NET Core および ASP.NET Core の最適化は、一般的に新しいバージョンは古いバージョンよりもパフォーマンスが高いことを意味します。 たとえば、.NET Core 2.1 ではコンパイル済みの正規表現のサポートが追加され、Span<T> の利点が得られました。 ASP.NET Core 2.2 では HTTP/2 のサポートが追加されました。 ASP.NET Core 3.0 では多くの機能強化が追加されており、メモリ使用量が減り、スループットが向上しています。 パフォーマンスを優先する場合は、現在のバージョンの ASP.NET Core へのアップグレードを検討してください。

例外を最小限に抑える

例外はまれである必要があります。 例外のスローとキャッチには、他のコード フロー パターンよりも時間がかかります。 そのため、通常のプログラム フローを制御するために例外を使用しないでください。

推奨事項:

  • 例外のスローまたはキャッチを通常のプログラム フローの手段として、特にホット コード パスでは使用しない
  • 例外の原因となる条件を検出して処理するためアプリにロジックを含める
  • 通常とは異なる、または予期しない状態では、例外をスローまたはキャッチする

Application Insights などのアプリケーション診断ツールは、パフォーマンスに影響する可能性があるアプリの一般的な例外を特定するのに役立つ場合があります。

HttpRequest/HttpResponse 本文での同期読み取りや書き込みを回避する

ASP.NET Core の I/O はすべて非同期です。 サーバーには Stream インターフェイスが実装されており、同期と非同期の両方のオーバーロードがあります。 スレッド プールのスレッドのブロックを回避するために、非同期のものを使用することをお勧めします。 スレッドをブロックすると、スレッド プールが枯渇する可能性があります。

してはいけないこと: 次の例では ReadToEnd を使用しています。 現在のスレッドで結果を待機するのをブロックしています。 これは、非同期での同期の一例です。

public class BadStreamReaderController : Controller
{
    [HttpGet("/contoso")]
    public ActionResult<ContosoData> Get()
    {
        var json = new StreamReader(Request.Body).ReadToEnd();

        return JsonSerializer.Deserialize<ContosoData>(json);
    }
}

前のコードでは、Get で HTTP 要求本文全体を同期的にメモリに読み込みます。 クライアントのアップロードが遅い場合、アプリは非同期で同期を行います。 Kestrel では同期読み取りはサポートされていないので、アプリでは非同期で同期を行います。

すべきこと: 次の例では ReadToEndAsync を使用しており、読み取り中にスレッドをブロックしません。

public class GoodStreamReaderController : Controller
{
    [HttpGet("/contoso")]
    public async Task<ActionResult<ContosoData>> Get()
    {
        var json = await new StreamReader(Request.Body).ReadToEndAsync();

        return JsonSerializer.Deserialize<ContosoData>(json);
    }

}

前のコードでは、HTTP 要求本文全体を同期的にメモリに読み込みます。

警告

要求が大きい場合、HTTP 要求本文全体をメモリに読み込むと、メモリ不足 (OOM) 状態になる可能性があります。 OOM により、サービス拒否となる可能性があります。 詳細については、この記事の「大きな要求本文または応答本文をメモリに読み込むのを避ける」を参照してください。

すべきこと: 以下は、バッファーされていない要求本文を使用する完全な非同期の例です。

public class GoodStreamReaderController : Controller
{
    [HttpGet("/contoso")]
    public async Task<ActionResult<ContosoData>> Get()
    {
        return await JsonSerializer.DeserializeAsync<ContosoData>(Request.Body);
    }
}

前のコードでは、要求本文を C# オブジェクトに非同期的に逆シリアル化します。

Request.Form よりも ReadFormAsync を優先する

HttpContext.Request.Form ではなく HttpContext.Request.ReadFormAsync を使用します。 HttpContext.Request.Form は、次の状態でのみ、安全に読み取れます。

  • フォームが ReadFormAsync の呼び出しで読み取られた。
  • キャッシュされたフォーム値が、HttpContext.Request.Form を使用して読み取られている

してはいけないこと: 次の例では HttpContext.Request.Form を使用しています。 HttpContext.Request.Form では非同期で同期が使用されており、スレッド プールが枯渇する可能性があります。

public class BadReadController : Controller
{
    [HttpPost("/form-body")]
    public IActionResult Post()
    {
        var form =  HttpContext.Request.Form;

        Process(form["id"], form["name"]);

        return Accepted();
    }

すべきこと: 次の例では HttpContext.Request.ReadFormAsync を使用して、フォーム本体を非同期的に読み取っています。

public class GoodReadController : Controller
{
    [HttpPost("/form-body")]
    public async Task<IActionResult> Post()
    {
       var form = await HttpContext.Request.ReadFormAsync();

        Process(form["id"], form["name"]);

        return Accepted();
    }

大きな要求本文または応答本文をメモリに読み込むのを避ける

.NET では、85,000 バイト以上のすべてのオブジェクト割り当ては、ラージ オブジェクト ヒープ (LOH) に配置されます。 大きなオブジェクトは次の 2 つの場合にコストがかかります。

  • 新しく割り当てられた大きなオブジェクトのメモリをクリアする必要があるため、割り当てコストが高い。 CLR では、新しく割り当てられたすべてのオブジェクトのメモリがクリアされることが保証されます。
  • LOH がヒープの残りの部分と一緒に収集される。 LOH には、フル ガベージ コレクションまたは Gen2 コレクションが必要です。

このブログ記事で、問題について簡潔に説明されています。

大きなオブジェクトが割り当てられると、Gen 2 オブジェクトとしてマークされます。 小さなオブジェクトの場合は Gen 0 ではありません。 その結果、LOH でメモリを使い切ると、GC によって LOH だけでなく、マネージド ヒープ全体がクリーンアップされます。 そのため、LOH を含む Gen 0、Gen 1、Gen 2 がクリーンアップされます。 これはフル ガベージ コレクションと呼ばれ、最も時間のかかるガベージ コレクションです。 多くのアプリケーションで、これを受け入れることができます。 しかし、平均的な Web 要求 (ソケットからの読み取り、圧縮解除、JSON のデコードなど) を処理するために必要な大きなメモリ バッファーが少ない、ハイパフォーマンスの Web サーバーではあり得ません。

大きな要求または応答本文を単一の byte[] または string に格納すると:

  • LOH の領域をすぐに使い切る可能性がある。
  • フル GC が実行されるため、アプリのパフォーマンスの問題が発生する可能性がある。

同期データ処理 API の操作

同期読み取りと書き込みのみをサポートするシリアライザー/逆シリアライザー (たとえば、Json.NET) を使用する場合:

  • シリアライザー/逆シリアライザーに渡す前に、データを非同期的にメモリにバッファーします。

警告

要求が大きい場合は、メモリ不足 (OOM) 状態になる可能性があります。 OOM により、サービス拒否となる可能性があります。 詳細については、この記事の「大きな要求本文または応答本文をメモリに読み込むのを避ける」を参照してください。

ASP.NET Core 3.0 では、既定で、JSON シリアル化に System.Text.Json が使用されます。 System.Text.Json:

  • 非同期で JSON の読み取りと書き込みを行います。
  • UTF-8 テキスト用に最適化されています。
  • 通常、Newtonsoft.Json よりパフォーマンスが向上します。

フィールドに IHttpContextAccessor.HttpContext を格納しない

IHttpContextAccessor.HttpContext では、要求スレッドからのアクセス時にアクティブな要求の HttpContext が返されます。 IHttpContextAccessor.HttpContext をフィールドや変数に格納することはできません

してはいけないこと: 次の例では、HttpContext をフィールドに格納してから、後で使用を試します。

public class MyBadType
{
    private readonly HttpContext _context;
    public MyBadType(IHttpContextAccessor accessor)
    {
        _context = accessor.HttpContext;
    }

    public void CheckAdmin()
    {
        if (!_context.User.IsInRole("admin"))
        {
            throw new UnauthorizedAccessException("The current user isn't an admin");
        }
    }
}

前のコードでは、コンストラクターで null または正しくない HttpContext が頻繁に取り込まれています。

すべきこと: 以下の例では次のようにします。

  • IHttpContextAccessor をフィールドに格納する。
  • 正しい時刻に HttpContext フィールドを使用し、null を確認する。
public class MyGoodType
{
    private readonly IHttpContextAccessor _accessor;
    public MyGoodType(IHttpContextAccessor accessor)
    {
        _accessor = accessor;
    }

    public void CheckAdmin()
    {
        var context = _accessor.HttpContext;
        if (context != null && !context.User.IsInRole("admin"))
        {
            throw new UnauthorizedAccessException("The current user isn't an admin");
        }
    }
}

複数のスレッドから HttpContext にアクセスしない

HttpContext はスレッド セーフではありません。 複数のスレッドから並列で HttpContext にアクセスすると、ハング、クラッシュ、データ破損などの予期しない動作が発生するおそれがあります。

してはいけないこと: 次の例では、3 つの並列要求を行い、送信 HTTP 要求の前と後に受信要求パスをログに記録します。 要求パスは、並列で複数のスレッドからアクセスされる可能性があります。

public class AsyncBadSearchController : Controller
{       
    [HttpGet("/search")]
    public async Task<SearchResults> Get(string query)
    {
        var query1 = SearchAsync(SearchEngine.Google, query);
        var query2 = SearchAsync(SearchEngine.Bing, query);
        var query3 = SearchAsync(SearchEngine.DuckDuckGo, query);

        await Task.WhenAll(query1, query2, query3);

        var results1 = await query1;
        var results2 = await query2;
        var results3 = await query3;

        return SearchResults.Combine(results1, results2, results3);
    }       

    private async Task<SearchResults> SearchAsync(SearchEngine engine, string query)
    {
        var searchResults = _searchService.Empty();
        try
        {
            _logger.LogInformation("Starting search query from {path}.", 
                                    HttpContext.Request.Path);
            searchResults = _searchService.Search(engine, query);
            _logger.LogInformation("Finishing search query from {path}.", 
                                    HttpContext.Request.Path);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed query from {path}", 
                             HttpContext.Request.Path);
        }

        return await searchResults;
    }

すべきこと: 次の例では、3 つの並列要求を行う前に、受信要求からすべてのデータをコピーします。

public class AsyncGoodSearchController : Controller
{       
    [HttpGet("/search")]
    public async Task<SearchResults> Get(string query)
    {
        string path = HttpContext.Request.Path;
        var query1 = SearchAsync(SearchEngine.Google, query,
                                 path);
        var query2 = SearchAsync(SearchEngine.Bing, query, path);
        var query3 = SearchAsync(SearchEngine.DuckDuckGo, query, path);

        await Task.WhenAll(query1, query2, query3);

        var results1 = await query1;
        var results2 = await query2;
        var results3 = await query3;

        return SearchResults.Combine(results1, results2, results3);
    }

    private async Task<SearchResults> SearchAsync(SearchEngine engine, string query,
                                                  string path)
    {
        var searchResults = _searchService.Empty();
        try
        {
            _logger.LogInformation("Starting search query from {path}.",
                                   path);
            searchResults = await _searchService.SearchAsync(engine, query);
            _logger.LogInformation("Finishing search query from {path}.", path);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed query from {path}", path);
        }

        return await searchResults;
    }

要求の完了後に HttpContext を使用しない

HttpContext は、ASP.NET Core パイプラインにアクティブな HTTP 要求がある場合にのみ有効です。 ASP.NET Core パイプライン全体は、すべての要求を実行するデリゲートの非同期チェーンです。 このチェーンから返される Task が完了すると、HttpContext がリサイクルされます。

してはいけないこと: 次の例では async void を使用しており、最初の await に到達すると HTTP 要求が完了します。

  • async void の使用は、ASP.NET Core アプリでは常に不適切な方法です。
  • コード例では、HTTP 要求の完了後に HttpResponse にアクセスしています。
  • 遅延アクセスにより、プロセスがクラッシュします。
public class AsyncBadVoidController : Controller
{
    [HttpGet("/async")]
    public async void Get()
    {
        await Task.Delay(1000);

        // The following line will crash the process because of writing after the 
        // response has completed on a background thread. Notice async void Get()

        await Response.WriteAsync("Hello World");
    }
}

すべきこと: 次の例では Task がフレームワークに返されます。そのため、HTTP 要求はアクションが完了するまで完了しません。

public class AsyncGoodTaskController : Controller
{
    [HttpGet("/async")]
    public async Task Get()
    {
        await Task.Delay(1000);

        await Response.WriteAsync("Hello World");
    }
}

バックグラウンド スレッドで HttpContext を取り込まない

してはいけないこと: 次の例では、クロージャで Controller プロパティから HttpContext を取り込んでいます。 これは、作業項目が次のようになる可能性があるため、不適切な方法です。

  • 要求スコープの外部で実行される。
  • 不適切な HttpContext の読み取りが試される。
[HttpGet("/fire-and-forget-1")]
public IActionResult BadFireAndForget()
{
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        var path = HttpContext.Request.Path;
        Log(path);
    });

    return Accepted();
}

すべきこと: 以下の例では次のようにします。

  • 要求中にバックグラウンド タスクで必要なデータをコピーする。
  • コントローラーからは何も参照しない。
[HttpGet("/fire-and-forget-3")]
public IActionResult GoodFireAndForget()
{
    string path = HttpContext.Request.Path;
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        Log(path);
    });

    return Accepted();
}

バックグラウンド タスクは、ホステッド サービスとして実装する必要があります。 詳細については、「Background tasks with hosted services」(ホストされるタスクを使用するバックグラウンド タスク) を参照してください。

バックグラウンド スレッドのコントローラーに挿入されたサービスを取り込まない

してはいけないこと: 次の例は、Controller アクション パラメーターから DbContext を取得するクロージャを示しています。 これは不適切な方法です。 作業項目は要求スコープの外部で実行できます。 ContosoDbContext のスコープは要求に設定されるため、ObjectDisposedException が生成されます。

[HttpGet("/fire-and-forget-1")]
public IActionResult FireAndForget1([FromServices]ContosoDbContext context)
{
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        context.Contoso.Add(new Contoso());
        await context.SaveChangesAsync();
    });

    return Accepted();
}

すべきこと: 以下の例では次のようにします。

  • バックグラウンド作業項目にスコープを作成するために、IServiceScopeFactory を挿入する。 IServiceScopeFactory はシングルトンです。
  • バックグラウンド スレッドに新しい依存関係挿入スコープを作成する。
  • コントローラーからは何も参照しない。
  • 受信要求から ContosoDbContext を取り込まない。
[HttpGet("/fire-and-forget-3")]
public IActionResult FireAndForget3([FromServices]IServiceScopeFactory 
                                    serviceScopeFactory)
{
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        await using (var scope = serviceScopeFactory.CreateAsyncScope())
        {
            var context = scope.ServiceProvider.GetRequiredService<ContosoDbContext>();

            context.Contoso.Add(new Contoso());

            await context.SaveChangesAsync();                                        
        }
    });

    return Accepted();
}

以下の強調表示されたコードでは、次のようにします。

  • バックグラウンド操作の有効期間のスコープを作成し、そこからサービスを解決する。
  • 正しいスコープから ContosoDbContext を使用する。
[HttpGet("/fire-and-forget-3")]
public IActionResult FireAndForget3([FromServices]IServiceScopeFactory 
                                    serviceScopeFactory)
{
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        await using (var scope = serviceScopeFactory.CreateAsyncScope())
        {
            var context = scope.ServiceProvider.GetRequiredService<ContosoDbContext>();

            context.Contoso.Add(new Contoso());

            await context.SaveChangesAsync();                                        
        }
    });

    return Accepted();
}

応答本文の開始後に状態コードやヘッダーを変更しない

ASP.NET Core では、HTTP 応答本文をバッファーしません。 応答が初めて書き込まれた場合は、次のようになります。

  • ヘッダーが、本文のそのチャンクと共にクライアントに送信される。
  • 応答ヘッダーを変更できなくなる。

してはいけないこと: 次のコードでは、応答が既に開始された後に応答ヘッダーの追加を試みます。

app.Use(async (context, next) =>
{
    await next();

    context.Response.Headers["test"] = "test value";
});

前のコードでは、next() が応答に書き込まれた場合、context.Response.Headers["test"] = "test value"; で例外がスローされます。

すべきこと: 次の例では、ヘッダーを変更する前に HTTP 応答が開始されたかどうかを確認します。

app.Use(async (context, next) =>
{
    await next();

    if (!context.Response.HasStarted)
    {
        context.Response.Headers["test"] = "test value";
    }
});

すべきこと: 次の例では HttpResponse.OnStarting を使用して、応答ヘッダーがクライアントにフラッシュされる前にヘッダーを設定します。

応答が開始されていないかどうかを確認することで、応答ヘッダーが書き込まれる直前に呼び出されるコールバックを登録できます。 応答が開始されていないかどうかを確認すると、次のようになります。

  • ヘッダーを適時に追加またはオーバーライドする機能が提供される。
  • パイプライン内の次のミドルウェアを認識する必要がない。
app.Use(async (context, next) =>
{
    context.Response.OnStarting(() =>
    {
        context.Response.Headers["someheader"] = "somevalue";
        return Task.CompletedTask;
    });

    await next();
});

応答本文への書き込みを既に開始している場合は next() を呼び出さない

コンポーネントは、応答を処理および操作できる場合にのみ、呼び出す必要があります。

IIS でインプロセス ホスティングを使用する

インプロセス ホスティング モデルを使用する場合、ASP.NET Core アプリはその IIS ワーカー プロセスと同じプロセスで実行されます。 インプロセス ホスティングでは、要求がループバック アダプターを介してプロキシ化されないので、アウトプロセス ホスティングよりもパフォーマンスが向上します。 ループバック アダプターは、同じマシンに送信ネットワーク トラフィックを戻すネットワーク インターフェイスです。 IIS では Windows プロセス アクティブ化サービス (WAS) を使用してプロセス管理が処理されます。

ASP.NET Core 3.0 以降では、プロジェクトの既定値はインプロセス ホスティング モデルとなります。

詳細については、「IIS を使用した Windows での ASP.NET Core のホスト」を参照してください

HttpRequest.ContentLength が null でないと想定しない

Content-Length ヘッダーが受信されない場合、HttpRequest.ContentLength は null になります。 この場合の null は、要求本文の長さが不明であることを意味します。長さが 0 であることを意味するものではありません。 null との比較は (== を除き) すべて false を返すので、たとえば Request.ContentLength > 1024 という比較は、要求本文のサイズが 1024 を超える場合に false を返す可能性があります。 これを知らないと、アプリのセキュリティ ホールにつながるおそれがあります。 大きすぎる要求を防いでいると (そうではないのに) 考えてしまうかもしれません。

詳細については、こちらの StackOverflow の回答を参照してください。

信頼性の高い Web アプリ パターン

.NET 用の信頼性の高い Web アプリ パターンYouTube のビデオ記事を参照して、ゼロからでも既存のアプリをリファクタリングしても、信頼性が高く、パフォーマンスが高く、テスト可能で、コスト効率が高く、スケーラブルな ASP.NET Core アプリを作成する方法に関するガイダンスを参照してください。