はじめに
ASP.NET Core .NET 6.0 で Minimal API がリリースされ、2年が経過しました。Minimal API はMVCスタイルの構造ではなく、PythonやNode.jsなどのスクリプト言語のWebフレームワークのようなスタイルで実装を可能とする新しいスタイルです。リリース以来アップデートを重ね続けており、もはや本番プロジェクトでの採用や、中・大規模プロジェクトでの採用にも支障はありません。
現在の Web プロジェクトのテンプレートは Minimal API を使用するのが既定となっており、実際に Web プロジェクトを作ってみると、構成用のファイルを除く実装は Program.cs ひとつだけで、恐ろしくシンプルな実装が書かれています。
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
しかし、この Minimal API形式での実装は小さなサンプルアプリを作る程度なら良いが、中・大規模開発には向いていない、という意見や本番用ではない、という誤解があるようです。
Qiita, Zenn で Minimal API に関する投稿を探してざっと見てみましたが、みなさん Program.cs に実装していました。サンプルコードだから Program.cs に実装しているのだとは思うのですが、実際のプロジェクトで採用する場合は Program.cs に全て実装するのは非現実的です。ではどうすればいいのでしょうか。
Microsoft の Principal Software Engineer である Tess Ferrandez 氏が Minimal API を採用する場合の構成方法についてブログを投稿しました。Program.cs に全て実装しないようにエンドポイントを分割するための方法が書かれています。
Organizing ASP.NET Core Minimal APIs
Tess 氏が翻訳をして投稿することを快く許可してくれましたので、以下に翻訳を掲載します。是非参考にしてください。
Organizing ASP.NET Core Minimal API
「実際に運用する」アプリには Minimal API は使用できないという声を(SNSや直接コメントで)よく耳にします。
その理由は、私たちが目にするほとんどのサンプルは非常に単純であり、大規模なアプリケーションの場合でも納得できる方法でコードを構成する方法を示していないからだと思います。
実を言うと、多くの「実際に運用する」アプリもそれほど複雑ではありませんが、それは別の話です。
私は多くの大規模な組織の会社と協力しながら、ワークロードを Azure に移行するのを支援してきましたが、過去 2 年ほどの間、ASP.NET Core の Minimal API が導入されて以来、すべてのプロジェクトで Minimal API を採用しており、非常に満足しています。それ以前は、Python(FastAPIとFlask)でも似たような Minimal API を使用していました。
しかし、ASP.NET における問題は、サンプルはすべて program.cs の中にいくつかのエンドポイントを実装する(次のような)形式で完結しており、これは大規模なアプリケーションでコードを構成する方法ではないため、コードをより保守しやすくするためのいくつかの簡単な手順を簡単に書こうと思いました。
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
...
app.MapGet("/todoitems", async (TodoDb db) =>
await db.Todos.ToListAsync());
app.MapGet("/todoitems/complete", async (TodoDb db) =>
await db.Todos.Where(t => t.IsComplete).ToListAsync());
app.MapGet("/todoitems/{id}", async (int id, TodoDb db) =>
await db.Todos.FindAsync(id)
is Todo todo
? Results.Ok(todo)
: Results.NotFound());
app.Run();
以降では Microsoft Learn のチュートリアル: ASP.NET Core を使用して最小 API を作成するを例として使用しており、以下のヒントのいくつかはその投稿からのものですが、私が取り組んでいるプロジェクトで役立つと思われるバリエーションをいくつか追加しました。
拡張メソッドを使用してエンドポイントを構成する
エンドポイントを構成する簡単な解決策は、拡張メソッドを使用することです。
Endpoints というフォルダ、またはクリーン アーキテクチャを行う場合は TodoItems フォルダに TodoItemsEndpoints.cs というファイルを作成し、次のコードを追加します。
public static class TodoItemsEndpoints
{
public static void RegisterTodoItemsEndpoints(this WebApplication app)
{
app.MapGet("/todoitems", async (TodoDb db) =>
await db.Todos.ToListAsync());
app.MapGet("/todoitems/complete", async (TodoDb db) =>
await db.Todos.Where(t => t.IsComplete).ToListAsync());
app.MapGet("/todoitems/{id}", async (int id, TodoDb db) =>
await db.Todos.FindAsync(id)
is Todo todo
? Results.Ok(todo)
: Results.NotFound());
...
}
}
これにより、Program.cs ファイルが即座に非常にクリーンになり、すべてのエンドポイントを個別のファイルに分離し、アプリケーションにとって意味のある方法で構成できます。
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
...
+ app.RegisterTodoItemsEndpoints();
app.Run();
program.cs から呼び出さずにエンドポイント登録を作成できる nuget パッケージ (Carter など) がありますが、私は個人的にそれらの必要性を感じたことはありません。
Results の代わりに TypedResults を使用する
Results の代わりに TypedResults を使用すると、コードをさらにクリーンにすることができます。
Resultsを使用する場合は、通常、Produces属性を追加して、有効なレスポンスタイプをSwaggerなどに指定します。
app.MapGet("/todoitems/{id}", async (int id, TodoDb db) =>
await db.Todos.FindAsync(id)
is Todo todo
? Results.Ok(todo)
: Results.NotFound())
.Produces<Todo>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
TypedResults では、戻り値の型が推測されるため、.Produces() 属性をスキップできます。これによって混乱が少なくなり、コンパイル時に間違いがないかどうかを確認できるようになり、単体テストもより単純になることを意味します。
app.MapGet("/todoitems/{id}", async Task<Results<Ok<Todo>, NotFound>> (int id, TodoDb db) =>
await db.Todos.FindAsync(id)
is Todo todo
? TypedResults.Ok(todo)
: TypedResults.NotFound());
エンドポイントの登録からファンクションを分離する
エンドポイントの登録を別のファイルに分割したとしても、依然として非常に乱雑に見え、全然テストがしにくいです。 ラムダを別のメソッドに移動することで、これらの問題の両方を解決できます。
app.MapGet("/todoitems/{id}", async Task<Results<Ok<Todo>, NotFound>> (int id, TodoDb db) =>
await db.Todos.FindAsync(id)
is Todo todo
? TypedResults.Ok(todo)
: TypedResults.NotFound());
app.MapGet("/todoitems/{id}", GetTodoById);
static async Task<Results<Ok<Todo>, NotFound>> GetTodoById(int id, TodoDb db) =>
await db.Todos.FindAsync(id)
is Todo todo
? TypedResults.Ok(todo)
: TypedResults.NotFound();
TodoItemsEndpoints の登録部分は次のようになります。
app.MapGet("/todoitems", GetAllTodos);
app.MapGet("/todoitems/complete", GetCompleteTodos);
app.MapGet("/todoitems/{id}", GetTodoById);
app.MapPost("/todoitems/", CreateTodo);
app.MapPut("/todoitems/{id}", UpdateTodoById);
app.MapDelete("/todoitems/{id}", DeleteTodo);
この後には登録から外したばかりのメソッドが続きます。また、メソッドをテストしたい場合は、API を経由せずにメソッドを直接呼び出すことができます。
エンドポイントのグループ化
繰り返しを避けるためにエンドポイントをグループ化することで、これをさらにクリーンアップできます。
var todoItems = app.MapGroup("/todoitems");
todoItems.MapGet("/", GetAllTodos);
todoItems.MapGet("/complete", GetCompleteTodos);
todoItems.MapGet("/{id}", GetTodoById);
todoItems.MapPost("/", CreateTodo);
todoItems.MapPut("/{id}", UpdateTodoById);
todoItems.MapDelete("/{id}", DeleteTodo);
これにより、「/todoitems」 を 6 回記述する必要がなくなりますが、それだけではなく、さらに必要に応じて他の属性をグループ全体に適用することもできます。
グループ内のすべてのエンドポイントに承認を要求したり、タグや CORS、レート制限を一度に追加したりできます。
var todoItems = app.MapGroup("/todoitems")
.RequireAuthorization()
.WithTags("Todo Items");
もちろん、必要に応じて、個々のエンドポイントに承認とタグを追加することもできます。
まとめ
ここ数年、私は Python と .NET の間で時間を過ごしてきましたが、コードのシンプルさとミニマリズムをますます評価するようになりました。 定型文や乱雑さは少ないほど良いため、Minimal API が私にとって非常に適しており、これまでのところ、プロジェクトのサイズに関係なく、私にとって問題となっている制限は見つかりませんでした。 同意するか反対するか、またはその他のヒントや経験があるかどうかにかかわらず、twitter (X) @tessferrandez でご意見をお待ちしています。