【.NET Aspire入門】第2回:データベースとキャッシングの統合

はじめに

前回は.NET Aspireの基本概念と開発環境のセットアップについて学びました。今回は、実際のアプリケーション開発で欠かせないデータベースとキャッシングの統合について詳しく解説します。

.NET Aspireは、SQL Server、PostgreSQL、MySQLなどの主要なデータベースと、RedisやGarnetなどのキャッシングシステムをシームレスに統合できます。設定の煩雑さを排除し、開発者が本質的な実装に集中できる環境を提供します。

データベースの統合

SQL Serverの追加

最も基本的なSQL Serverの統合から始めましょう。

// AppHost/Program.cs
var builder = DistributedApplication.CreateBuilder(args);

// SQL Serverインスタンスの追加
var sqlServer = builder.AddSqlServer("sql")
    .WithDataVolume("sqldata"); // データの永続化

// データベースの作成
var catalogDb = sqlServer.AddDatabase("catalogdb");
var ordersDb = sqlServer.AddDatabase("ordersdb");

// APIサービスにデータベースを参照
var catalogApi = builder.AddProject<Projects.CatalogApi>("catalog-api")
    .WithReference(catalogDb);

var ordersApi = builder.AddProject<Projects.OrdersApi>("orders-api")
    .WithReference(ordersDb);

builder.Build().Run();

Entity Framework Coreの設定

APIサービス側でEntity Framework Coreを使用する場合の実装例:

// CatalogApi/Program.cs
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// ServiceDefaultsの追加
builder.AddServiceDefaults();

// SQL Server接続の追加(Aspireが自動的に接続文字列を注入)
builder.AddSqlServerDbContext<CatalogDbContext>("catalogdb");

var app = builder.Build();

app.MapDefaultEndpoints();

// データベースマイグレーションの自動実行
using (var scope = app.Services.CreateScope())
{
    var dbContext = scope.ServiceProvider.GetRequiredService<CatalogDbContext>();
    await dbContext.Database.MigrateAsync();
}

// APIエンドポイントの定義
app.MapGet("/api/products", async (CatalogDbContext db) =>
{
    return await db.Products
        .Include(p => p.Category)
        .ToListAsync();
});

app.MapPost("/api/products", async (Product product, CatalogDbContext db) =>
{
    db.Products.Add(product);
    await db.SaveChangesAsync();
    return Results.Created($"/api/products/{product.Id}", product);
});

app.Run();

// データモデル
public class CatalogDbContext : DbContext
{
    public CatalogDbContext(DbContextOptions<CatalogDbContext> options)
        : base(options) { }

    public DbSet<Product> Products => Set<Product>();
    public DbSet<Category> Categories => Set<Category>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>()
            .HasOne(p => p.Category)
            .WithMany(c => c.Products)
            .HasForeignKey(p => p.CategoryId);

        // シードデータ
        modelBuilder.Entity<Category>().HasData(
            new Category { Id = 1, Name = "Electronics" },
            new Category { Id = 2, Name = "Clothing" },
            new Category { Id = 3, Name = "Books" }
        );
    }
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int CategoryId { get; set; }
    public Category? Category { get; set; }
}

public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public List<Product> Products { get; set; } = new();
}

PostgreSQLの統合

PostgreSQLを使用する場合も、ほぼ同じように実装できます:

// AppHost/Program.cs
var builder = DistributedApplication.CreateBuilder(args);

// PostgreSQLインスタンスの追加
var postgres = builder.AddPostgres("postgres")
    .WithDataVolume("postgresdata")
    .WithPgAdmin(); // pgAdminも同時に起動

// データベースの作成
var inventoryDb = postgres.AddDatabase("inventory");

// マイクロサービスの追加
var inventoryService = builder.AddProject<Projects.InventoryService>("inventory-service")
    .WithReference(inventoryDb);

builder.Build().Run();
// InventoryService/Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();

// PostgreSQL接続の追加
builder.AddNpgsqlDbContext<InventoryDbContext>("inventory");

var app = builder.Build();

// ヘルスチェックの追加
app.MapHealthChecks("/health", new HealthCheckOptions
{
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

// 在庫管理API
app.MapGet("/api/inventory/{productId}", async (int productId, InventoryDbContext db) =>
{
    var inventory = await db.Inventories
        .FirstOrDefaultAsync(i => i.ProductId == productId);
    
    return inventory is not null
        ? Results.Ok(inventory)
        : Results.NotFound();
});

app.MapPut("/api/inventory/{productId}", async (
    int productId, 
    InventoryUpdateRequest request,
    InventoryDbContext db) =>
{
    var inventory = await db.Inventories
        .FirstOrDefaultAsync(i => i.ProductId == productId);
    
    if (inventory is null)
    {
        inventory = new Inventory 
        { 
            ProductId = productId,
            Quantity = request.Quantity,
            LastUpdated = DateTime.UtcNow
        };
        db.Inventories.Add(inventory);
    }
    else
    {
        inventory.Quantity = request.Quantity;
        inventory.LastUpdated = DateTime.UtcNow;
    }
    
    await db.SaveChangesAsync();
    return Results.Ok(inventory);
});

app.Run();

public record InventoryUpdateRequest(int Quantity);

public class Inventory
{
    public int Id { get; set; }
    public int ProductId { get; set; }
    public int Quantity { get; set; }
    public DateTime LastUpdated { get; set; }
}

キャッシングの実装

Redisの統合

Redisを使った分散キャッシングの実装:

// AppHost/Program.cs
var builder = DistributedApplication.CreateBuilder(args);

// Redisの追加
var redis = builder.AddRedis("cache")
    .WithDataVolume("redisdata")
    .WithRedisCommander(); // Redis管理UIも追加

// SQL Serverの追加
var sqlServer = builder.AddSqlServer("sql");
var catalogDb = sqlServer.AddDatabase("catalogdb");

// APIサービスの追加(DBとキャッシュの両方を参照)
var catalogApi = builder.AddProject<Projects.CatalogApi>("catalog-api")
    .WithReference(catalogDb)
    .WithReference(redis);

builder.Build().Run();

キャッシング戦略の実装

// CatalogApi/Services/ProductService.cs
using Microsoft.Extensions.Caching.Distributed;
using System.Text.Json;

public interface IProductService
{
    Task<Product?> GetProductAsync(int id);
    Task<IEnumerable<Product>> GetProductsAsync();
    Task InvalidateCacheAsync(int productId);
}

public class ProductService : IProductService
{
    private readonly CatalogDbContext _dbContext;
    private readonly IDistributedCache _cache;
    private readonly ILogger<ProductService> _logger;
    private const string ProductKeyPrefix = "product:";
    private const string AllProductsKey = "products:all";

    public ProductService(
        CatalogDbContext dbContext,
        IDistributedCache cache,
        ILogger<ProductService> logger)
    {
        _dbContext = dbContext;
        _cache = cache;
        _logger = logger;
    }

    public async Task<Product?> GetProductAsync(int id)
    {
        var cacheKey = $"{ProductKeyPrefix}{id}";
        
        // キャッシュから取得を試みる
        var cachedProduct = await _cache.GetStringAsync(cacheKey);
        if (!string.IsNullOrEmpty(cachedProduct))
        {
            _logger.LogInformation("Product {ProductId} found in cache", id);
            return JsonSerializer.Deserialize<Product>(cachedProduct);
        }

        // データベースから取得
        var product = await _dbContext.Products
            .Include(p => p.Category)
            .FirstOrDefaultAsync(p => p.Id == id);

        if (product != null)
        {
            // キャッシュに保存(有効期限: 5分)
            var options = new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
                SlidingExpiration = TimeSpan.FromMinutes(2)
            };

            await _cache.SetStringAsync(
                cacheKey,
                JsonSerializer.Serialize(product),
                options);

            _logger.LogInformation("Product {ProductId} cached", id);
        }

        return product;
    }

    public async Task<IEnumerable<Product>> GetProductsAsync()
    {
        // 全商品リストのキャッシュ確認
        var cachedProducts = await _cache.GetStringAsync(AllProductsKey);
        if (!string.IsNullOrEmpty(cachedProducts))
        {
            _logger.LogInformation("Products list found in cache");
            return JsonSerializer.Deserialize<List<Product>>(cachedProducts) 
                ?? new List<Product>();
        }

        // データベースから取得
        var products = await _dbContext.Products
            .Include(p => p.Category)
            .ToListAsync();

        // キャッシュに保存(有効期限: 1分)
        var options = new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1)
        };

        await _cache.SetStringAsync(
            AllProductsKey,
            JsonSerializer.Serialize(products),
            options);

        _logger.LogInformation("Products list cached");
        return products;
    }

    public async Task InvalidateCacheAsync(int productId)
    {
        // 個別商品のキャッシュを削除
        await _cache.RemoveAsync($"{ProductKeyPrefix}{productId}");
        
        // 全商品リストのキャッシュも削除
        await _cache.RemoveAsync(AllProductsKey);
        
        _logger.LogInformation("Cache invalidated for product {ProductId}", productId);
    }
}

// Program.cs での登録
builder.Services.AddScoped<IProductService, ProductService>();

// APIエンドポイントでの使用
app.MapGet("/api/products/{id}", async (int id, IProductService productService) =>
{
    var product = await productService.GetProductAsync(id);
    return product is not null
        ? Results.Ok(product)
        : Results.NotFound();
});

app.MapPut("/api/products/{id}", async (
    int id, 
    Product product, 
    CatalogDbContext db,
    IProductService productService) =>
{
    var existingProduct = await db.Products.FindAsync(id);
    if (existingProduct is null)
        return Results.NotFound();

    existingProduct.Name = product.Name;
    existingProduct.Price = product.Price;
    existingProduct.CategoryId = product.CategoryId;

    await db.SaveChangesAsync();
    
    // キャッシュの無効化
    await productService.InvalidateCacheAsync(id);
    
    return Results.NoContent();
});

Garnetの使用(Microsoftの新しい高性能キャッシュ)

Garnetは、Microsoftが開発した高性能なキャッシュサーバーです:

// AppHost/Program.cs
var builder = DistributedApplication.CreateBuilder(args);

// Garnetの追加(Redisプロトコル互換)
var garnet = builder.AddGarnet("garnet-cache")
    .WithDataVolume("garnetdata");

// 使用方法はRedisと同じ
var api = builder.AddProject<Projects.Api>("api")
    .WithReference(garnet);

builder.Build().Run();

高度なパターン

Read-Through/Write-Through キャッシュパターン

public interface ICacheAside<T> where T : class
{
    Task<T?> GetAsync(string key, Func<Task<T?>> factory);
    Task SetAsync(string key, T value, TimeSpan? expiration = null);
    Task RemoveAsync(string key);
}

public class RedisCacheAside<T> : ICacheAside<T> where T : class
{
    private readonly IDistributedCache _cache;
    private readonly ILogger<RedisCacheAside<T>> _logger;
    private readonly JsonSerializerOptions _jsonOptions;

    public RedisCacheAside(
        IDistributedCache cache,
        ILogger<RedisCacheAside<T>> logger)
    {
        _cache = cache;
        _logger = logger;
        _jsonOptions = new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase
        };
    }

    public async Task<T?> GetAsync(string key, Func<Task<T?>> factory)
    {
        // キャッシュから取得
        var cached = await _cache.GetStringAsync(key);
        if (!string.IsNullOrEmpty(cached))
        {
            _logger.LogDebug("Cache hit for key: {Key}", key);
            return JsonSerializer.Deserialize<T>(cached, _jsonOptions);
        }

        _logger.LogDebug("Cache miss for key: {Key}", key);
        
        // ファクトリーメソッドでデータを取得
        var data = await factory();
        if (data != null)
        {
            await SetAsync(key, data);
        }

        return data;
    }

    public async Task SetAsync(string key, T value, TimeSpan? expiration = null)
    {
        var options = new DistributedCacheEntryOptions();
        if (expiration.HasValue)
        {
            options.AbsoluteExpirationRelativeToNow = expiration.Value;
        }

        var json = JsonSerializer.Serialize(value, _jsonOptions);
        await _cache.SetStringAsync(key, json, options);
        
        _logger.LogDebug("Cached value for key: {Key}", key);
    }

    public async Task RemoveAsync(string key)
    {
        await _cache.RemoveAsync(key);
        _logger.LogDebug("Removed cache for key: {Key}", key);
    }
}

セッション管理

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();

// Redisを使ったセッション管理
builder.AddRedisDistributedCache("cache");

builder.Services.AddSession(options =>
{
    options.IdleTimeout = TimeSpan.FromMinutes(30);
    options.Cookie.HttpOnly = true;
    options.Cookie.IsEssential = true;
});

var app = builder.Build();

app.UseSession();

// セッションの使用例
app.MapPost("/api/cart/add", async (
    CartItem item,
    ISession session,
    HttpContext context) =>
{
    var cartJson = session.GetString("cart");
    var cart = string.IsNullOrEmpty(cartJson)
        ? new List<CartItem>()
        : JsonSerializer.Deserialize<List<CartItem>>(cartJson);

    cart!.Add(item);
    
    session.SetString("cart", JsonSerializer.Serialize(cart));
    await session.CommitAsync();
    
    return Results.Ok(cart);
});

app.MapGet("/api/cart", (ISession session) =>
{
    var cartJson = session.GetString("cart");
    if (string.IsNullOrEmpty(cartJson))
        return Results.Ok(new List<CartItem>());

    var cart = JsonSerializer.Deserialize<List<CartItem>>(cartJson);
    return Results.Ok(cart);
});

public record CartItem(string ProductId, string Name, decimal Price, int Quantity);

パフォーマンスモニタリング

カスタムメトリクスの実装

public class CacheMetrics
{
    private readonly IMetrics _metrics;
    private readonly Counter<long> _cacheHits;
    private readonly Counter<long> _cacheMisses;
    private readonly Histogram<double> _cacheOperationDuration;

    public CacheMetrics(IMeterFactory meterFactory)
    {
        var meter = meterFactory.Create("ProductService.Cache");
        
        _cacheHits = meter.CreateCounter<long>("cache_hits", 
            description: "Number of cache hits");
        
        _cacheMisses = meter.CreateCounter<long>("cache_misses", 
            description: "Number of cache misses");
        
        _cacheOperationDuration = meter.CreateHistogram<double>(
            "cache_operation_duration",
            unit: "ms",
            description: "Duration of cache operations");
    }

    public void RecordHit() => _cacheHits.Add(1);
    public void RecordMiss() => _cacheMisses.Add(1);
    
    public void RecordOperationDuration(double durationMs) => 
        _cacheOperationDuration.Record(durationMs);
}

トラブルシューティング

接続エラーの対処

// リトライポリシーの実装
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.ConfigurationOptions = new StackExchangeRedis.ConfigurationOptions
    {
        EndPoints = { builder.Configuration.GetConnectionString("cache") ?? "localhost" },
        ConnectRetry = 3,
        ReconnectRetryPolicy = new LinearRetry(TimeSpan.FromSeconds(5)),
        ConnectTimeout = 5000,
        SyncTimeout = 5000,
        AbortOnConnectFail = false
    };
});

ヘルスチェックの追加

builder.Services.AddHealthChecks()
    .AddSqlServer(
        name: "catalog-db",
        connectionString: builder.Configuration.GetConnectionString("catalogdb") 
            ?? throw new InvalidOperationException("Connection string not found"))
    .AddRedis(
        name: "cache",
        connectionString: builder.Configuration.GetConnectionString("cache") 
            ?? "localhost:6379");

まとめ

今回は、.NET Aspireでのデータベースとキャッシングの統合について学びました。重要なポイント:

  1. シンプルな統合: AddSqlServer、AddPostgres、AddRedisなどの直感的なAPI
  2. 自動的な接続管理: 接続文字列の自動注入
  3. 開発環境の簡素化: Docker Composeの複雑な設定が不要
  4. 本番環境対応: データボリュームによる永続化
  5. 観測可能性: メトリクスとヘルスチェックの組み込み

次回は、メッセージングとイベント駆動アーキテクチャの実装について解説します。


次回予告:「第3回:メッセージングとイベント駆動アーキテクチャ」では、RabbitMQ、Azure Service Bus、Apache Kafkaなどのメッセージングシステムを.NET Aspireで活用する方法を詳しく解説します。

技術的な課題をお持ちですか専門チームがサポートします

記事でご紹介した技術や実装について、
より詳細なご相談やプロジェクトのサポートを承ります。