【.NET Aspire入門】第2回:データベースとキャッシングの統合
.NETAspireデータベースRedisSQL ServerPostgreSQL
はじめに
前回は.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でのデータベースとキャッシングの統合について学びました。重要なポイント:
- シンプルな統合: AddSqlServer、AddPostgres、AddRedisなどの直感的なAPI
- 自動的な接続管理: 接続文字列の自動注入
- 開発環境の簡素化: Docker Composeの複雑な設定が不要
- 本番環境対応: データボリュームによる永続化
- 観測可能性: メトリクスとヘルスチェックの組み込み
次回は、メッセージングとイベント駆動アーキテクチャの実装について解説します。
次回予告:「第3回:メッセージングとイベント駆動アーキテクチャ」では、RabbitMQ、Azure Service Bus、Apache Kafkaなどのメッセージングシステムを.NET Aspireで活用する方法を詳しく解説します。