.NET Minimal APIs 実践ガイド:軽量で高速な Web API 開発
.NET Minimal APIs 実践ガイド:軽量で高速な Web API 開発
はじめに
.NET 6 で導入された Minimal APIs は、最小限のコードで Web API を構築できる新しいアプローチです。従来の Controller ベースの API と比較して、起動時間の短縮、メモリ使用量の削減、そしてコードの簡潔性を実現します。本記事では、実践的な Minimal APIs の活用方法を解説します。
Minimal APIs の基本
1. シンプルな API の作成
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// サービスの登録
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// 開発環境での Swagger UI
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// エンドポイントの定義
app.MapGet("/", () => "Hello World!");
app.MapGet("/api/users/{id:int}", async (int id, IUserService userService) =>
{
var user = await userService.GetUserByIdAsync(id);
return user is not null
? Results.Ok(user)
: Results.NotFound($"User with ID {id} not found");
})
.WithName("GetUser")
.WithOpenApi()
.Produces<User>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
app.Run();
2. 依存性注入(DI)の活用
// Services/UserService.cs
public interface IUserService
{
Task<User?> GetUserByIdAsync(int id);
Task<IEnumerable<User>> GetAllUsersAsync();
Task<User> CreateUserAsync(CreateUserDto dto);
Task<User?> UpdateUserAsync(int id, UpdateUserDto dto);
Task<bool> DeleteUserAsync(int id);
}
public class UserService : IUserService
{
private readonly ApplicationDbContext _context;
private readonly ILogger<UserService> _logger;
public UserService(ApplicationDbContext context, ILogger<UserService> logger)
{
_context = context;
_logger = logger;
}
public async Task<User?> GetUserByIdAsync(int id)
{
_logger.LogInformation("Getting user with ID: {UserId}", id);
return await _context.Users
.Include(u => u.Profile)
.FirstOrDefaultAsync(u => u.Id == id);
}
public async Task<User> CreateUserAsync(CreateUserDto dto)
{
var user = new User
{
Name = dto.Name,
Email = dto.Email,
CreatedAt = DateTime.UtcNow
};
_context.Users.Add(user);
await _context.SaveChangesAsync();
_logger.LogInformation("Created new user with ID: {UserId}", user.Id);
return user;
}
}
// Program.cs でのサービス登録
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<IUserService, UserService>();
高度な機能の実装
1. エンドポイントグループとバージョニング
// エンドポイントの整理
public static class UserEndpoints
{
public static void MapUserEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/users")
.WithTags("Users")
.RequireAuthorization();
group.MapGet("/", GetAllUsers)
.WithName("GetAllUsers")
.WithOpenApi();
group.MapGet("/{id:int}", GetUserById)
.WithName("GetUserById")
.WithOpenApi();
group.MapPost("/", CreateUser)
.WithName("CreateUser")
.WithOpenApi()
.RequireAuthorization("AdminOnly");
group.MapPut("/{id:int}", UpdateUser)
.WithName("UpdateUser")
.WithOpenApi();
group.MapDelete("/{id:int}", DeleteUser)
.WithName("DeleteUser")
.WithOpenApi()
.RequireAuthorization("AdminOnly");
}
private static async Task<IResult> GetAllUsers(
IUserService userService,
[AsParameters] PaginationQuery query)
{
var users = await userService.GetAllUsersAsync(query);
return Results.Ok(users);
}
private static async Task<IResult> GetUserById(
int id,
IUserService userService)
{
var user = await userService.GetUserByIdAsync(id);
return user is not null
? Results.Ok(user)
: Results.NotFound();
}
private static async Task<IResult> CreateUser(
CreateUserDto dto,
IUserService userService,
IValidator<CreateUserDto> validator)
{
var validationResult = await validator.ValidateAsync(dto);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(validationResult.ToDictionary());
}
var user = await userService.CreateUserAsync(dto);
return Results.Created($"/api/users/{user.Id}", user);
}
}
// Program.cs での使用
app.MapUserEndpoints();
// API バージョニング
var v1 = app.MapGroup("/api/v1");
v1.MapUserEndpoints();
var v2 = app.MapGroup("/api/v2")
.RequireAuthorization();
v2.MapUserEndpoints();
2. 認証と認可
// JWT 認証の設定
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]))
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireRole("Admin"));
options.AddPolicy("UserOwner", policy =>
policy.RequireAssertion(context =>
{
var userIdClaim = context.User.FindFirst("UserId")?.Value;
var routeUserId = context.Resource as string;
return userIdClaim == routeUserId;
}));
});
// 認証エンドポイント
app.MapPost("/api/auth/login", async (
LoginDto dto,
IAuthService authService,
IValidator<LoginDto> validator) =>
{
var validationResult = await validator.ValidateAsync(dto);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(validationResult.ToDictionary());
}
var result = await authService.AuthenticateAsync(dto.Email, dto.Password);
return result.IsSuccess
? Results.Ok(new
{
Token = result.Token,
RefreshToken = result.RefreshToken,
ExpiresIn = result.ExpiresIn
})
: Results.Unauthorized();
})
.AllowAnonymous()
.WithName("Login")
.WithOpenApi();
3. ミドルウェアとフィルター
// カスタムミドルウェア
public class RequestLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestLoggingMiddleware> _logger;
public RequestLoggingMiddleware(
RequestDelegate next,
ILogger<RequestLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var requestId = Guid.NewGuid().ToString();
context.Items["RequestId"] = requestId;
_logger.LogInformation(
"Request {RequestId} {Method} {Path} started",
requestId,
context.Request.Method,
context.Request.Path);
var sw = Stopwatch.StartNew();
try
{
await _next(context);
}
finally
{
sw.Stop();
_logger.LogInformation(
"Request {RequestId} completed in {ElapsedMilliseconds}ms with status {StatusCode}",
requestId,
sw.ElapsedMilliseconds,
context.Response.StatusCode);
}
}
}
// エンドポイントフィルター
public class ValidationFilter<T> : IEndpointFilter where T : class
{
private readonly IValidator<T> _validator;
public ValidationFilter(IValidator<T> validator)
{
_validator = validator;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var model = context.Arguments
.OfType<T>()
.FirstOrDefault();
if (model is null)
{
return Results.BadRequest("Invalid request model");
}
var validationResult = await _validator.ValidateAsync(model);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(validationResult.ToDictionary());
}
return await next(context);
}
}
// フィルターの適用
app.MapPost("/api/products", CreateProduct)
.AddEndpointFilter<ValidationFilter<CreateProductDto>>();
パフォーマンス最適化
1. レスポンスキャッシング
// メモリキャッシュの設定
builder.Services.AddMemoryCache();
builder.Services.AddResponseCaching();
// Redis キャッシュの設定
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "MinimalAPI";
});
// キャッシュを使用したエンドポイント
app.MapGet("/api/products", async (
IProductService productService,
IMemoryCache cache,
[AsParameters] ProductQuery query) =>
{
var cacheKey = $"products_{query.Category}_{query.Page}_{query.PageSize}";
if (!cache.TryGetValue<ProductListResponse>(cacheKey, out var cachedProducts))
{
cachedProducts = await productService.GetProductsAsync(query);
var cacheOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromMinutes(5))
.SetAbsoluteExpiration(TimeSpan.FromMinutes(30));
cache.Set(cacheKey, cachedProducts, cacheOptions);
}
return Results.Ok(cachedProducts);
})
.CacheOutput(policyName: "ProductsCache");
// Output キャッシングの設定
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("ProductsCache", builder =>
builder.Expire(TimeSpan.FromMinutes(5))
.SetVaryByQuery("category", "page", "pageSize")
.Tag("products"));
});
2. 非同期ストリーミング
// 大量データのストリーミング
app.MapGet("/api/export/users", async (
IUserService userService,
HttpContext context) =>
{
context.Response.ContentType = "text/csv";
context.Response.Headers.Add(
"Content-Disposition",
"attachment; filename=\"users.csv\"");
await using var writer = new StreamWriter(context.Response.Body);
await writer.WriteLineAsync("Id,Name,Email,CreatedAt");
await foreach (var user in userService.GetUsersAsyncEnumerable())
{
await writer.WriteLineAsync(
$"{user.Id},{user.Name},{user.Email},{user.CreatedAt:yyyy-MM-dd}");
await writer.FlushAsync();
}
})
.RequireAuthorization("AdminOnly");
// Service での実装
public async IAsyncEnumerable<User> GetUsersAsyncEnumerable()
{
const int batchSize = 100;
int offset = 0;
while (true)
{
var users = await _context.Users
.OrderBy(u => u.Id)
.Skip(offset)
.Take(batchSize)
.AsNoTracking()
.ToListAsync();
if (!users.Any())
{
yield break;
}
foreach (var user in users)
{
yield return user;
}
offset += batchSize;
}
}
テストとドキュメント
1. 統合テスト
// IntegrationTests/UserEndpointsTests.cs
public class UserEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public UserEndpointsTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// テスト用の DB に置き換え
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>));
if (descriptor != null)
services.Remove(descriptor);
services.AddDbContext<ApplicationDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
});
});
_client = _factory.CreateClient();
}
[Fact]
public async Task GetUser_ReturnsOk_WhenUserExists()
{
// Arrange
var userId = 1;
// Act
var response = await _client.GetAsync($"/api/users/{userId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var user = await response.Content.ReadFromJsonAsync<User>();
user.Should().NotBeNull();
user!.Id.Should().Be(userId);
}
[Fact]
public async Task CreateUser_ReturnsCreated_WithValidData()
{
// Arrange
var createDto = new CreateUserDto
{
Name = "Test User",
Email = "test@example.com"
};
// Act
var response = await _client.PostAsJsonAsync("/api/users", createDto);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
response.Headers.Location.Should().NotBeNull();
var user = await response.Content.ReadFromJsonAsync<User>();
user.Should().NotBeNull();
user!.Name.Should().Be(createDto.Name);
user.Email.Should().Be(createDto.Email);
}
}
2. OpenAPI ドキュメントのカスタマイズ
// OpenAPI の詳細設定
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "Minimal API Sample",
Version = "v1",
Description = "A sample API using .NET Minimal APIs",
Contact = new OpenApiContact
{
Name = "Enhanced Inc.",
Email = "support@enhanced.co.jp",
Url = new Uri("https://enhanced.co.jp")
}
});
// セキュリティ定義
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Description = "JWT Authorization header using the Bearer scheme",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.Http,
Scheme = "bearer"
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
new string[] { }
}
});
// XML コメントの読み込み
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
options.IncludeXmlComments(xmlPath);
});
// エンドポイントでの詳細な API ドキュメント
app.MapPost("/api/orders", CreateOrder)
.WithName("CreateOrder")
.WithSummary("Create a new order")
.WithDescription("Creates a new order in the system with the provided details")
.WithOpenApi(operation =>
{
operation.Parameters[0].Description = "Order creation details";
operation.RequestBody.Required = true;
return operation;
})
.Produces<Order>(StatusCodes.Status201Created)
.Produces<ValidationProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized);
Real-time 機能の実装
SignalR との統合
// SignalR Hub
public class NotificationHub : Hub
{
private readonly ILogger<NotificationHub> _logger;
public NotificationHub(ILogger<NotificationHub> logger)
{
_logger = logger;
}
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier;
await Groups.AddToGroupAsync(Context.ConnectionId, $"user-{userId}");
_logger.LogInformation("User {UserId} connected", userId);
await base.OnConnectedAsync();
}
public async Task SendMessage(string message)
{
await Clients.Others.SendAsync("ReceiveMessage", Context.UserIdentifier, message);
}
}
// Program.cs での設定
builder.Services.AddSignalR();
app.MapHub<NotificationHub>("/hubs/notifications");
// REST API から SignalR への通知
app.MapPost("/api/notifications", async (
NotificationDto dto,
IHubContext<NotificationHub> hubContext) =>
{
await hubContext.Clients
.Group($"user-{dto.UserId}")
.SendAsync("ReceiveNotification", dto);
return Results.Ok();
})
.RequireAuthorization();
デプロイと運用
Docker コンテナ化
# Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["MinimalApi.csproj", "."]
RUN dotnet restore "MinimalApi.csproj"
COPY . .
WORKDIR "/src/."
RUN dotnet build "MinimalApi.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "MinimalApi.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MinimalApi.dll"]
ヘルスチェックとメトリクス
// ヘルスチェックの設定
builder.Services.AddHealthChecks()
.AddSqlServer(
builder.Configuration.GetConnectionString("DefaultConnection"),
name: "database")
.AddRedis(
builder.Configuration.GetConnectionString("Redis"),
name: "cache")
.AddUrlGroup(
new Uri("https://api.external.com/health"),
name: "external-api");
// メトリクスの設定
builder.Services.AddOpenTelemetry()
.WithMetrics(builder =>
{
builder.AddPrometheusExporter();
builder.AddMeter("Microsoft.AspNetCore.Hosting");
builder.AddMeter("Microsoft.AspNetCore.Http");
});
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
app.MapPrometheusScrapingEndpoint();
まとめ
.NET Minimal APIs は、シンプルさとパフォーマンスを両立した現代的な Web API 開発アプローチです。主な利点:
- 簡潔性: 最小限のコードで API を構築
- パフォーマンス: 高速な起動と低メモリ使用量
- 柔軟性: 必要に応じて機能を追加
- テスタビリティ: 統合テストが容易
エンハンスド株式会社では、Minimal APIs を活用した高性能な Web API の設計・開発を支援しています。マイクロサービスアーキテクチャや既存システムのモダナイゼーションなど、お客様のニーズに合わせたソリューションを提供いたします。