.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 開発アプローチです。主な利点:

  1. 簡潔性: 最小限のコードで API を構築
  2. パフォーマンス: 高速な起動と低メモリ使用量
  3. 柔軟性: 必要に応じて機能を追加
  4. テスタビリティ: 統合テストが容易

エンハンスド株式会社では、Minimal APIs を活用した高性能な Web API の設計・開発を支援しています。マイクロサービスアーキテクチャや既存システムのモダナイゼーションなど、お客様のニーズに合わせたソリューションを提供いたします。