最近よく考えてたんだけど、ASP.NET Coreのパフォーマンスチューニングの話になると、みんな結構細かいところに行きがちだよね。LINQをforループに変えるとか、文字列の結合方法とか、メモリプーリングがどうとか。そういうミクロな最適化も、まあ、無駄じゃないんだけど、正直、それでアプリが劇的に速くなることって稀じゃない?
本当に効いてくるのって、そういう小手先のテクニックじゃなくて、もっと根本的な…そう、アーキテクチャの選択だと思うんだ。開発の初期段階で決めた構造が、後々のスケーラビリティにものすごく影響する。だから今日は、構文の微調整とかじゃなくて、本当にパフォーマンスを左右する「構造」の話をしてみようかなって。
クリーンアーキテクチャが逆に性能を落とす?っていう話
クリーンアーキテクチャ、みんな好きだよね。関心事が分離されてて、テストもしやすいし、保守性も高い。でもね、これ、パフォーマンスを全く考えずに教科書通りに作ると、結構なオーバーヘッドを生むことがあるんだ。
例えば、よくある`Controller → Service → Repository → Database`っていう階層構造。それぞれの層で独自のモデルを持ってて、層をまたぐたびにデータのマッピングが発生する。こういうアプリで、ただ顧客データを1件取ってくるだけのシンプルなAPIの応答時間を測ってみると、全体で120ミリ秒くらいかかってたりする。で、そのうちデータベースのクエリ自体は15ミリ秒だったりして。じゃあ残りの100ミリ秒以上はどこに消えたの?って話になる。
プロファイラで中身を覗いてみると、だいたいこんな感じに時間が分散してる。
- オブジェクトのマッピング(層間のモデル変換)に35ミリ秒くらい。
- DIコンテナによる依存関係の解決に28ミリ秒くらい(特に依存が深くなってる場合)。
- あと、複数の層で重複してるバリデーションとかビジネスロジックに42ミリ秒くらい。
これを見ると、データベースがボトルネックじゃないのは明らかだよね。じゃあ、クリーンアーキテクチャやめればいいのかっていうと、それも違う。もっと戦略的に、構造を少し見直すだけで、劇的に改善できることが多いんだ。
じゃあ、どうやって構造を最適化するの?
いくつか方法はあるんだけど、代表的なのはこんな感じ。
- 一部のモデルを共有する: 隣接するレイヤー間で、全部マッピングするんじゃなくて、一部のモデルは共有しちゃう。これでマッピングのコストを減らせる。
- 依存関係をフラットにする: サービスの依存関係が深くなりすぎないように、構成を見直す。ネストが深いと、インスタンス生成のオーバーヘッドが馬鹿にならないからね。
- CQRSを導入する: これが個人的には一番効果的だと思う。要は、データの参照(Query)と更新(Command)のパスを完全に分ける考え方。参照系の処理って、複雑なビジネスロジックやバリデーションがいらないことが多いでしょ?そういう場合は、余計なサービス層とかをバイパスして、直接データベースから必要なデータだけをDTO(Data Transfer Object)に詰めて返す。
特にCQRSは効くよ。さっきの120ミリ秒の例で言うと、こういうアーキテクチャの見直しをするだけで、応答時間が45ミリ秒くらいまで、つまり6割から7割くらい削減できたりする。アルゴリズムを変えたり、トリッキーなコードを書いたりしなくてもね。
コードで示すと、違いは一目瞭然。
// 変更前:典型的なサービスクラス。複数の依存とマッピングがある。
public async Task GetCustomerAsync(int id)
{
var customerEntity = await _customerRepository.GetByIdAsync(id);
// ここでEntityからビジネスモデルへのマッピングが発生
var customerModel = _mapper.Map(customerEntity);
await _validationService.ValidateCustomerAsync(customerModel);
// ここでビジネスモデルからDTOへのマッピングが発生
return _mapper.Map(customerModel);
}
// 変更後:CQRSのクエリハンドラ。めちゃくちゃシンプル。
public async Task Handle(GetCustomerQuery query)
{
// DBから直接レスポンス用のDTOにプロジェクションする
return await _dbContext.Customers
.Where(c => c.Id == query.CustomerId)
.Select(c => new CustomerResponseDto {
Id = c.Id,
Name = c.Name,
// 他のプロパティ
})
.FirstOrDefaultAsync();
}
後のコード、すっきりしてるでしょ。余計な層がないから、速いのは当たり前なんだよね。
サービスのライフタイム管理、これ、隠れたパフォーマンスキラーだから
ASP.NET Coreのパフォーマンスで見過ごされがちなのが、サービスのライフタイム。`Singleton`、`Scoped`、`Transient`のどれを選ぶか。これ、メモリ使用量とスループットにめちゃくちゃ影響する。
ざっくり言うと、
- Singleton: アプリケーション全体でインスタンスは一個だけ。ずっと使い回される。
- Scoped: HTTPリクエストごとにインスタンスが一個作られる。そのリクエストの中では使い回される。
- Transient: 毎回、DIコンテナから要求されるたびに新しいインスタンスが作られる。
何も考えずに全部`Scoped`とか`Transient`にしてると、リクエストが多いときにインスタンスの生成と破棄(ガベージコレクション)のコストが積み重なって、パフォーマンスが頭打ちになる。特に`Transient`は要注意。
ステートレスなサービス、つまり状態を持たないサービスなら、積極的に`Singleton`にできないか検討すべき。例えば、設定情報を読むだけのサービスとか、マッピング定義とか、ステートレスなユーティリティとかね。Microsoftの公式ドキュメントでも推奨されてるけど、意外と現場では意識されてないことが多い気がする。
実際に負荷テストツール、例えば`BenchmarkDotNet`とかで測ってみると、その差は歴然とする。どのサービスを`Singleton`にできるか注意深く見直すだけで、スループットが6割とか7割向上するケースも全然珍しくない。マジで。
じゃあ、どう使い分けるのがいいの?
個人的な指針はこんな感じかな。
- 基本は`Scoped`: DBコンテキスト(`DbContext`)に依存するサービスとか、リクエスト単位で状態を管理したいものは、だいたいこれ。
- 積極的に`Singleton`: 状態を持たないサービス。キャッシュサービス、ロガー、設定管理、`IHttpClientFactory`とか。ただし、`Scoped`なサービスに依存するものは`Singleton`にできないから注意。
- `Transient`は慎重に: 利用するたびに必ずユニークな状態が必要な、本当に特殊なケースだけ。例えば、一時的な計算結果を保持するビルダーオブジェクトとかかな。ほとんどのケースでは不要なはず。
// 最適化されたサービス登録のパターン例
// ↓↓↓ ステートレスなものはSingletonにできないか検討する
services.AddSingleton();
services.AddSingleton(GetConfiguredMapper());
services.AddSingleton(CreateLogger());
// ↓↓↓ DB接続やリクエスト単位のものはScopedが基本
services.AddScoped();
services.AddScoped();
// ↓↓↓ Transientは本当に必要な時だけ
services.AddTransient();
この辺の整理整頓をするだけで、アプリの見通しも良くなるし、パフォーマンスも上がるし、一石二鳥だと思う。
知ってた?ミドルウェアの順番もパフォーマンスに効くんだよ
これも地味だけど効果的なやつ。`Program.cs`(昔の`Startup.cs`)に書くミドルウェアの順番。あれ、実はすごく大事。
例えば、認証ミドルウェア (`UseAuthentication`) の置き場所。これをパイプラインのかなり早い段階に置いちゃうと、すべてのリクエスト、そう、画像やCSSファイルへのリクエスト、ヘルスチェックのエンドポイントへのリクエストまで、全部認証の処理が走っちゃう。これ、無駄だよね。
// 変更前:認証がすべてのリクエストで実行される
app.UseHttpsRedirection();
app.UseAuthentication(); // ←静的ファイルのリクエストにもこれが走る
app.UseAuthorization();
app.UseStaticFiles();
app.UseRouting();
// ...
静的ファイルへのアクセスとか、認証が不要なエンドポイントは、認証ミドルウェアよりも前に処理を完了させるべき。順番を入れ替えるだけ。
// 変更後:認証が不要なリクエストは先に処理する
app.UseHttpsRedirection();
app.UseStaticFiles(); // ←認証の前に静的ファイルを返す
app.UseRouting();
// ヘルスチェックなど、認証不要なエンドポイントを先に定義
app.MapHealthChecks("/health");
// この後に認証・認可ミドルウェアを置く
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(...);
たったこれだけのことで、特にトラフィックの多いサイトだと、サーバーのCPU負荷が目に見えて下がる。コスト削減にも直結するから、やらない手はないよね。
Minimal APIはただの省略記法じゃない
.NET 6で登場したMinimal API。最初は「コントローラー書くのが楽になるシンタックスシュガーでしょ?」くらいに思ってたんだけど、いやいや、全然違う。これもパフォーマンスに効くアーキテクチャの選択なんだ。
なんで速いかっていうと、従来のコントローラーベースのAPIが持っていた、いろんな「おもてなし」機能をバイパスするから。例えば、コントローラーの探索、アクションメソッドの選択、フィルターの実行パイプライン…こういうのを全部すっ飛ばせる。シンプルなAPIにとっては、この辺の仕組みはただのオーバーヘッドでしかないからね。
じゃあ、どっちを使えばいいの?っていう話になると思うから、簡単な比較表を作ってみた。あくまで個人的な見解だけど。
結論としては、使い分けが大事ってこと。例えば、アプリケーションの大部分は従来のコントローラーで作っておいて、特にアクセスが集中するエンドポイント(例えば、商品一覧とか)だけをMinimal APIで実装する、みたいなハイブリッドなアプローチがいいんじゃないかな。
反例とよくある誤解
ここまで最適化の話ばっかりしてきたけど、じゃあ「常に最速のアーキテクチャが正義なのか?」っていうと、そうじゃない。ここが大事なところで。
- 誤解1: クリーンアーキテクチャは悪である。
そんなことはない。システムの寿命が長くて、ビジネスロジックが複雑で、保守性が何よりも重要なプロジェクトなら、レイヤー間のマッピングのオーバーヘッドは許容すべきコスト。むしろ、CQRSを無理やり導入してコードが複雑になる方が問題。 - 誤解2: すべてのサービスはSingletonにすべきである。
これもダメ。状態を持ってしまっているサービスを無理やりSingletonにすると、ユーザー間でデータが混線したり、とんでもないバグの原因になる。スレッドセーフじゃないものをSingletonにするのは自殺行為。 - 誤解3: コントローラーはもう古い。全部Minimal APIにすべき。
さっきも言ったけど、これも違う。認証、バリデーション、ロギングみたいな横断的な関心事をフィルターで綺麗に処理したいなら、コントローラーの仕組みはすごくよくできてる。Minimal APIで同じことをやろうとすると、結構ごちゃごちゃしがち。
要するに、銀の弾丸はないってこと。パフォーマンスは大事だけど、それは保守性、開発速度、コードの可読性といった、他の要素とのトレードオフなんだよね。自分のプロジェクトの特性を理解して、どこにコストを払うか決めるのが、アーキテクトの仕事なんだと思う。
結局、コードの数行をいじるより、全体の設計図を見直す方が、よっぽど大きなインパクトがある。そして、その設計図に唯一絶対の正解はない。…うん、それが一番言いたかったことかな。
皆さんのプロジェクトでは、どのアーキテクチャのボトルネックに一番悩まされていますか?クリーンアーキテクチャのレイヤーマッピング?それともサービスのライフタイム管理?もしよかったら、コメントで教えてください。
