.NET Aspire によるクラウドネイティブアプリケーション開発完全ガイド
.NET Aspire によるクラウドネイティブアプリケーション開発完全ガイド
はじめに
.NET Aspireは、クラウドネイティブアプリケーションの開発を簡素化する新しいスタックです。分散アプリケーションの構築、実行、デプロイを統合的にサポートし、開発者体験を大幅に向上させます。本記事では、実践的な開発手法を解説します。
.NET Aspireの主要機能
1. オーケストレーション
// Program.cs - App Host
var builder = DistributedApplication.CreateBuilder(args);
// Add Redis cache
var cache = builder.AddRedis("cache")
.WithRedisCommander();
// Add PostgreSQL with pgAdmin
var postgres = builder.AddPostgres("postgres")
.WithPgAdmin()
.AddDatabase("catalogdb");
// Add RabbitMQ for messaging
var messaging = builder.AddRabbitMQ("messaging")
.WithManagementPlugin();
// Add services
var catalogApi = builder.AddProject<Projects.CatalogService>("catalog-api")
.WithReference(postgres)
.WithReference(cache);
var basketApi = builder.AddProject<Projects.BasketService>("basket-api")
.WithReference(cache)
.WithReference(messaging);
var orderingApi = builder.AddProject<Projects.OrderingService>("ordering-api")
.WithReference(postgres)
.WithReference(messaging);
// Add frontend
builder.AddProject<Projects.WebFrontEnd>("webfrontend")
.WithReference(catalogApi)
.WithReference(basketApi)
.WithReference(orderingApi);
builder.Build().Run();
2. サービスディスカバリー
// CatalogService - Program.cs
var builder = WebApplication.CreateBuilder(args);
// Add service defaults & Aspire components
builder.AddServiceDefaults();
builder.AddNpgsqlDbContext<CatalogContext>("catalogdb");
builder.Services.AddStackExchangeRedisCache("cache");
// Add services
builder.Services.AddScoped<ICatalogRepository, CatalogRepository>();
builder.Services.AddScoped<ICatalogService, CatalogService>();
// Add OpenAPI
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure middleware
app.MapDefaultEndpoints();
// Catalog API endpoints
app.MapGet("/api/catalog/items", async (
ICatalogService catalogService,
[AsParameters] PaginationRequest pagination) =>
{
var items = await catalogService.GetItemsAsync(
pagination.PageIndex,
pagination.PageSize
);
return Results.Ok(items);
})
.WithName("GetCatalogItems")
.WithOpenApi()
.RequireAuthorization();
app.MapGet("/api/catalog/items/{id:int}", async (
int id,
ICatalogService catalogService) =>
{
var item = await catalogService.GetItemByIdAsync(id);
return item is not null
? Results.Ok(item)
: Results.NotFound();
})
.WithName("GetCatalogItemById")
.WithOpenApi();
app.MapPost("/api/catalog/items", async (
CreateCatalogItemRequest request,
ICatalogService catalogService,
IValidator<CreateCatalogItemRequest> validator) =>
{
var validationResult = await validator.ValidateAsync(request);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(validationResult.ToDictionary());
}
var item = await catalogService.CreateItemAsync(request);
return Results.Created(
$"/api/catalog/items/{item.Id}",
item
);
})
.WithName("CreateCatalogItem")
.WithOpenApi()
.RequireAuthorization("Admin");
app.Run();
実践的なマイクロサービス実装
1. ドメイン駆動設計(DDD)の適用
// Domain Layer - Entities
namespace Ordering.Domain.AggregatesModel.OrderAggregate;
public class Order : Entity, IAggregateRoot
{
private DateTime _orderDate;
private int? _buyerId;
private int _orderStatusId;
private string _description;
private bool _isDraft;
private readonly List<OrderItem> _orderItems;
public IReadOnlyCollection<OrderItem> OrderItems => _orderItems;
public OrderStatus OrderStatus { get; private set; }
public Address ShippingAddress { get; private set; }
public decimal Total => _orderItems.Sum(o => o.Units * o.UnitPrice);
protected Order()
{
_orderItems = new List<OrderItem>();
_isDraft = false;
}
public Order(
int? buyerId,
Address shippingAddress,
string description) : this()
{
_buyerId = buyerId;
ShippingAddress = shippingAddress ?? throw new ArgumentNullException(nameof(shippingAddress));
_description = description;
_orderDate = DateTime.UtcNow;
_orderStatusId = OrderStatus.Submitted.Id;
}
public static Order NewDraft()
{
var order = new Order();
order._isDraft = true;
return order;
}
public void AddOrderItem(
int productId,
string productName,
decimal unitPrice,
decimal discount,
string pictureUrl,
int units = 1)
{
var existingOrderItem = _orderItems
.SingleOrDefault(o => o.ProductId == productId);
if (existingOrderItem != null)
{
existingOrderItem.AddUnits(units);
}
else
{
var orderItem = new OrderItem(
productId,
productName,
unitPrice,
discount,
pictureUrl,
units
);
_orderItems.Add(orderItem);
}
}
public void SetCancelledStatus()
{
if (_orderStatusId == OrderStatus.Paid.Id ||
_orderStatusId == OrderStatus.Shipped.Id)
{
StatusChangeException(OrderStatus.Cancelled);
}
_orderStatusId = OrderStatus.Cancelled.Id;
_description = $"Order was cancelled.";
AddDomainEvent(new OrderCancelledDomainEvent(this));
}
public void SetPaidStatus()
{
if (_orderStatusId != OrderStatus.AwaitingValidation.Id)
{
StatusChangeException(OrderStatus.Paid);
}
_orderStatusId = OrderStatus.Paid.Id;
_description = "Payment confirmed";
AddDomainEvent(new OrderPaidDomainEvent(this));
}
private void StatusChangeException(OrderStatus orderStatusToChange)
{
throw new OrderingDomainException(
$"Is not possible to change the order status from {OrderStatus.Name} to {orderStatusToChange.Name}."
);
}
}
// Domain Events
public class OrderStartedDomainEvent : INotification
{
public int? BuyerId { get; }
public Order Order { get; }
public OrderStartedDomainEvent(Order order, int? buyerId)
{
Order = order;
BuyerId = buyerId;
}
}
// Domain Event Handlers
public class OrderStartedDomainEventHandler : INotificationHandler<OrderStartedDomainEvent>
{
private readonly IBuyerRepository _buyerRepository;
private readonly ILogger<OrderStartedDomainEventHandler> _logger;
public OrderStartedDomainEventHandler(
IBuyerRepository buyerRepository,
ILogger<OrderStartedDomainEventHandler> logger)
{
_buyerRepository = buyerRepository;
_logger = logger;
}
public async Task Handle(
OrderStartedDomainEvent domainEvent,
CancellationToken cancellationToken)
{
var buyer = await _buyerRepository.FindAsync(domainEvent.BuyerId.Value);
if (buyer == null)
{
buyer = new Buyer(domainEvent.BuyerId.Value);
_buyerRepository.Add(buyer);
}
_logger.LogInformation(
"Buyer {BuyerId} associated with order {OrderId}",
domainEvent.BuyerId,
domainEvent.Order.Id
);
}
}
2. イベント駆動アーキテクチャ
// Integration Events
namespace EventBus.Messages.Events;
public record OrderSubmittedIntegrationEvent : IntegrationEvent
{
public int OrderId { get; init; }
public string BuyerId { get; init; }
public decimal Total { get; init; }
public List<OrderItemDto> Items { get; init; }
}
// Event Bus Implementation
public interface IEventBus
{
Task PublishAsync<T>(T @event) where T : IntegrationEvent;
Task SubscribeAsync<T, TH>()
where T : IntegrationEvent
where TH : IIntegrationEventHandler<T>;
}
// RabbitMQ Implementation
public class RabbitMQEventBus : IEventBus
{
private readonly IRabbitMQPersistentConnection _persistentConnection;
private readonly ILogger<RabbitMQEventBus> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly string _exchangeName = "event_bus";
public async Task PublishAsync<T>(T @event) where T : IntegrationEvent
{
if (!_persistentConnection.IsConnected)
{
_persistentConnection.TryConnect();
}
var policy = Policy.Handle<BrokerUnreachableException>()
.Or<SocketException>()
.WaitAndRetryAsync(5, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
using var channel = _persistentConnection.CreateModel();
var eventName = @event.GetType().Name;
channel.ExchangeDeclare(
exchange: _exchangeName,
type: ExchangeType.Direct
);
var body = JsonSerializer.SerializeToUtf8Bytes(
@event,
@event.GetType(),
new JsonSerializerOptions
{
WriteIndented = false
}
);
await policy.ExecuteAsync(async () =>
{
var properties = channel.CreateBasicProperties();
properties.DeliveryMode = 2; // persistent
properties.Headers = new Dictionary<string, object>
{
["event-id"] = @event.Id.ToString(),
["event-type"] = eventName,
["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};
_logger.LogInformation(
"Publishing event {EventId} of type {EventType}",
@event.Id,
eventName
);
channel.BasicPublish(
exchange: _exchangeName,
routingKey: eventName,
mandatory: true,
basicProperties: properties,
body: body
);
});
}
public async Task SubscribeAsync<T, TH>()
where T : IntegrationEvent
where TH : IIntegrationEventHandler<T>
{
var eventName = typeof(T).Name;
await Task.Run(() =>
{
DoInternalSubscription(eventName);
_subsManager.AddSubscription<T, TH>();
StartBasicConsume<T>();
});
}
}
// Integration Event Handler
public class OrderSubmittedIntegrationEventHandler
: IIntegrationEventHandler<OrderSubmittedIntegrationEvent>
{
private readonly IBasketService _basketService;
private readonly IEmailService _emailService;
private readonly ILogger<OrderSubmittedIntegrationEventHandler> _logger;
public OrderSubmittedIntegrationEventHandler(
IBasketService basketService,
IEmailService emailService,
ILogger<OrderSubmittedIntegrationEventHandler> logger)
{
_basketService = basketService;
_emailService = emailService;
_logger = logger;
}
public async Task Handle(OrderSubmittedIntegrationEvent @event)
{
_logger.LogInformation(
"Handling integration event {EventId} - Order {OrderId} submitted",
@event.Id,
@event.OrderId
);
// Clear basket
await _basketService.DeleteBasketAsync(@event.BuyerId);
// Send confirmation email
await _emailService.SendOrderConfirmationAsync(
@event.BuyerId,
@event.OrderId,
@event.Total
);
}
}
3. レジリエンスパターンの実装
// Polly Configuration
public static class HttpClientBuilderExtensions
{
public static IHttpClientBuilder AddCustomResilience(this IHttpClientBuilder builder)
{
return builder
.AddStandardResilienceHandler(options =>
{
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(30);
options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(60);
options.CircuitBreaker.FailureRatio = 0.5;
options.CircuitBreaker.MinimumThroughput = 10;
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(10);
options.Retry.MaxRetryAttempts = 3;
options.Retry.BackoffType = DelayBackoffType.Exponential;
options.Retry.UseJitter = true;
options.Retry.DelayGenerator = args =>
{
var delay = args.AttemptNumber switch
{
0 => TimeSpan.Zero,
1 => TimeSpan.FromSeconds(1),
2 => TimeSpan.FromSeconds(3),
_ => TimeSpan.FromSeconds(5)
};
return new ValueTask<TimeSpan?>(delay);
};
})
.AddStandardHedgingHandler(options =>
{
options.MaxHedgedAttempts = 2;
options.Delay = TimeSpan.FromMilliseconds(500);
});
}
}
// Service Registration
builder.Services.AddHttpClient<ICatalogService, CatalogService>(client =>
{
client.BaseAddress = new Uri("https://localhost:5001");
})
.AddCustomResilience()
.AddHttpMessageHandler<AuthenticationDelegatingHandler>();
// Circuit Breaker with Fallback
public class ResilientCatalogService : ICatalogService
{
private readonly HttpClient _httpClient;
private readonly IMemoryCache _cache;
private readonly ILogger<ResilientCatalogService> _logger;
private readonly ResiliencePipeline _resiliencePipeline;
public ResilientCatalogService(
HttpClient httpClient,
IMemoryCache cache,
ILogger<ResilientCatalogService> logger)
{
_httpClient = httpClient;
_cache = cache;
_logger = logger;
_resiliencePipeline = new ResiliencePipelineBuilder()
.AddCircuitBreaker(new CircuitBreakerStrategyOptions
{
FailureRatio = 0.5,
SamplingDuration = TimeSpan.FromSeconds(10),
MinimumThroughput = 10,
BreakDuration = TimeSpan.FromSeconds(30),
OnOpened = args =>
{
_logger.LogWarning(
"Circuit breaker opened for catalog service"
);
return default;
}
})
.AddFallback(new FallbackStrategyOptions<IEnumerable<CatalogItem>>
{
FallbackAction = async args =>
{
_logger.LogWarning(
"Using cached catalog data due to service failure"
);
var cachedItems = await _cache.GetOrCreateAsync(
"catalog-items-fallback",
async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
return await LoadCachedCatalogItems();
}
);
return Outcome.FromResult(cachedItems);
}
})
.Build();
}
public async Task<IEnumerable<CatalogItem>> GetItemsAsync(
int pageIndex,
int pageSize)
{
return await _resiliencePipeline.ExecuteAsync(
async ct => await FetchCatalogItemsAsync(pageIndex, pageSize, ct)
);
}
}
テレメトリとオブザーバビリティ
1. OpenTelemetry統合
// Telemetry Configuration
public static class TelemetryExtensions
{
public static IHostApplicationBuilder ConfigureTelemetry(
this IHostApplicationBuilder builder)
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddService(
serviceName: builder.Environment.ApplicationName,
serviceVersion: Assembly.GetExecutingAssembly()
.GetName().Version?.ToString() ?? "1.0.0",
serviceInstanceId: Environment.MachineName)
.AddAttributes(new Dictionary<string, object>
{
["environment"] = builder.Environment.EnvironmentName,
["team"] = "platform"
}))
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddMeter("Microsoft.AspNetCore.Hosting")
.AddMeter("Microsoft.AspNetCore.Http")
.AddMeter("OrderingService.Metrics")
.AddPrometheusExporter())
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(options =>
{
options.Filter = (httpContext) =>
{
return !httpContext.Request.Path.StartsWithSegments("/health");
};
options.RecordException = true;
})
.AddHttpClientInstrumentation(options =>
{
options.RecordException = true;
})
.AddEntityFrameworkCoreInstrumentation(options =>
{
options.SetDbStatementForText = true;
options.SetDbStatementForStoredProcedure = true;
})
.AddSource("MassTransit")
.AddOtlpExporter());
// Add custom metrics
builder.Services.AddSingleton<OrderingMetrics>();
return builder;
}
}
// Custom Metrics
public class OrderingMetrics
{
private readonly Counter<long> _ordersCreated;
private readonly Histogram<double> _orderProcessingDuration;
private readonly ObservableGauge<long> _pendingOrders;
public OrderingMetrics(IMeterFactory meterFactory)
{
var meter = meterFactory.Create("OrderingService.Metrics");
_ordersCreated = meter.CreateCounter<long>(
"orders.created",
unit: "{orders}",
description: "Total number of orders created"
);
_orderProcessingDuration = meter.CreateHistogram<double>(
"order.processing.duration",
unit: "ms",
description: "Order processing duration"
);
_pendingOrders = meter.CreateObservableGauge(
"orders.pending",
() => GetPendingOrderCount(),
unit: "{orders}",
description: "Current number of pending orders"
);
}
public void RecordOrderCreated(string customerId, decimal amount)
{
_ordersCreated.Add(1,
new TagList
{
{ "customer.id", customerId },
{ "order.amount.range", GetAmountRange(amount) }
});
}
public void RecordOrderProcessingTime(double milliseconds, string status)
{
_orderProcessingDuration.Record(milliseconds,
new TagList { { "status", status } });
}
private static string GetAmountRange(decimal amount) => amount switch
{
< 100 => "small",
< 1000 => "medium",
_ => "large"
};
}
2. 分散トレーシング
// Custom Activity Source for detailed tracing
public class OrderingService
{
private static readonly ActivitySource ActivitySource =
new("OrderingService", "1.0.0");
private readonly IOrderRepository _orderRepository;
private readonly IEventBus _eventBus;
private readonly OrderingMetrics _metrics;
public async Task<OrderDto> CreateOrderAsync(CreateOrderCommand command)
{
using var activity = ActivitySource.StartActivity(
"CreateOrder",
ActivityKind.Internal);
activity?.SetTag("order.buyer_id", command.BuyerId);
activity?.SetTag("order.items.count", command.Items.Count);
try
{
// Validate command
using (var validationActivity = ActivitySource.StartActivity(
"ValidateOrder",
ActivityKind.Internal))
{
await ValidateOrderCommand(command);
}
// Create order aggregate
var order = new Order(
command.BuyerId,
new Address(
command.Street,
command.City,
command.State,
command.Country,
command.ZipCode
),
command.CardTypeId,
command.CardNumber,
command.CardSecurityNumber,
command.CardHolderName,
command.CardExpiration
);
foreach (var item in command.Items)
{
order.AddOrderItem(
item.ProductId,
item.ProductName,
item.UnitPrice,
item.Discount,
item.PictureUrl,
item.Units
);
}
// Save to database
using (var dbActivity = ActivitySource.StartActivity(
"SaveOrder",
ActivityKind.Client))
{
_orderRepository.Add(order);
await _orderRepository.UnitOfWork.SaveEntitiesAsync();
dbActivity?.SetTag("order.id", order.Id);
}
// Publish integration event
using (var eventActivity = ActivitySource.StartActivity(
"PublishOrderSubmittedEvent",
ActivityKind.Producer))
{
var integrationEvent = new OrderSubmittedIntegrationEvent(
order.Id,
order.BuyerId.ToString(),
order.Total,
order.OrderItems.Select(oi => new OrderItemDto
{
ProductId = oi.ProductId,
ProductName = oi.ProductName,
Units = oi.Units,
UnitPrice = oi.UnitPrice
}).ToList()
);
await _eventBus.PublishAsync(integrationEvent);
eventActivity?.SetTag("event.id", integrationEvent.Id);
}
// Record metrics
_metrics.RecordOrderCreated(command.BuyerId, order.Total);
activity?.SetStatus(ActivityStatusCode.Ok);
return OrderDto.FromOrder(order);
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.RecordException(ex);
throw;
}
}
}
デプロイメントとCI/CD
1. Aspire Manifest生成とデプロイ
# .github/workflows/deploy-aspire.yml
name: Deploy Aspire Application
on:
push:
branches: [ main ]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Install Aspire workload
run: dotnet workload install aspire
- name: Build solution
run: dotnet build --configuration Release
- name: Run tests
run: dotnet test --configuration Release --logger trx --collect:"XPlat Code Coverage"
- name: Generate Aspire manifest
run: |
dotnet run --project src/ShopAppHost/ShopAppHost.csproj \
--publisher manifest \
--output-path aspire-manifest.json
- name: Login to Azure
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Deploy to Azure Container Apps
run: |
az containerapp env create \
--name ${{ vars.ENVIRONMENT_NAME }} \
--resource-group ${{ vars.RESOURCE_GROUP }} \
--location ${{ vars.LOCATION }}
azd deploy --from-aspire-manifest aspire-manifest.json
2. Infrastructure as Code (Bicep)
// main.bicep - Aspire Infrastructure
@description('The location for all resources')
param location string = resourceGroup().location
@description('Environment name')
param environmentName string
@description('Container Registry name')
param containerRegistryName string
// Container Apps Environment
resource containerAppEnvironment 'Microsoft.App/managedEnvironments@2023-05-01' = {
name: 'cae-${environmentName}'
location: location
properties: {
daprAIConnectionString: applicationInsights.properties.ConnectionString
appLogsConfiguration: {
destination: 'log-analytics'
logAnalyticsConfiguration: {
customerId: logAnalyticsWorkspace.properties.customerId
sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey
}
}
zoneRedundant: true
}
}
// Log Analytics Workspace
resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' = {
name: 'log-${environmentName}'
location: location
properties: {
sku: {
name: 'PerGB2018'
}
retentionInDays: 30
}
}
// Application Insights
resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = {
name: 'ai-${environmentName}'
location: location
kind: 'web'
properties: {
Application_Type: 'web'
WorkspaceResourceId: logAnalyticsWorkspace.id
}
}
// Container Registry
resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-01-01-preview' = {
name: containerRegistryName
location: location
sku: {
name: 'Standard'
}
properties: {
adminUserEnabled: true
}
}
// Service Bus for messaging
resource serviceBusNamespace 'Microsoft.ServiceBus/namespaces@2022-10-01-preview' = {
name: 'sb-${environmentName}'
location: location
sku: {
name: 'Standard'
}
}
// Redis Cache
resource redisCache 'Microsoft.Cache/redis@2023-08-01' = {
name: 'redis-${environmentName}'
location: location
properties: {
sku: {
name: 'Standard'
family: 'C'
capacity: 1
}
enableNonSslPort: false
minimumTlsVersion: '1.2'
}
}
// PostgreSQL Flexible Server
resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2023-03-01-preview' = {
name: 'psql-${environmentName}'
location: location
sku: {
name: 'Standard_B2s'
tier: 'Burstable'
}
properties: {
version: '15'
administratorLogin: 'aspireuser'
administratorLoginPassword: 'P@ssw0rd123!'
storage: {
storageSizeGB: 32
}
backup: {
backupRetentionDays: 7
geoRedundantBackup: 'Disabled'
}
}
}
// Outputs for application configuration
output containerAppEnvironmentId string = containerAppEnvironment.id
output containerRegistryLoginServer string = containerRegistry.properties.loginServer
output applicationInsightsConnectionString string = applicationInsights.properties.ConnectionString
output serviceBusConnectionString string = serviceBusNamespace.listKeys().primaryConnectionString
output redisConnectionString string = '${redisCache.properties.hostName}:6380,password=${redisCache.listKeys().primaryKey},ssl=True,abortConnect=False'
パフォーマンス最適化
1. 非同期処理とバックグラウンドサービス
// Background Service for Order Processing
public class OrderProcessingService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<OrderProcessingService> _logger;
private readonly Channel<OrderProcessingItem> _queue;
private readonly OrderingMetrics _metrics;
public OrderProcessingService(
IServiceProvider serviceProvider,
ILogger<OrderProcessingService> logger,
OrderingMetrics metrics)
{
_serviceProvider = serviceProvider;
_logger = logger;
_metrics = metrics;
// Create unbounded channel for order processing
_queue = Channel.CreateUnbounded<OrderProcessingItem>(
new UnboundedChannelOptions
{
SingleReader = false,
SingleWriter = false
});
}
public async ValueTask EnqueueOrderAsync(OrderProcessingItem item)
{
if (item == null)
{
throw new ArgumentNullException(nameof(item));
}
await _queue.Writer.WriteAsync(item);
_logger.LogInformation(
"Order {OrderId} queued for processing",
item.OrderId
);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Run(async () =>
{
await ProcessOrdersAsync(stoppingToken);
}, stoppingToken);
}
private async Task ProcessOrdersAsync(CancellationToken cancellationToken)
{
const int maxConcurrency = 5;
using var semaphore = new SemaphoreSlim(maxConcurrency);
await foreach (var item in _queue.Reader.ReadAllAsync(cancellationToken))
{
// Don't await - process concurrently
_ = ProcessOrderWithSemaphoreAsync(item, semaphore, cancellationToken);
}
}
private async Task ProcessOrderWithSemaphoreAsync(
OrderProcessingItem item,
SemaphoreSlim semaphore,
CancellationToken cancellationToken)
{
await semaphore.WaitAsync(cancellationToken);
try
{
await ProcessOrderAsync(item, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error processing order {OrderId}",
item.OrderId
);
}
finally
{
semaphore.Release();
}
}
private async Task ProcessOrderAsync(
OrderProcessingItem item,
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
using var scope = _serviceProvider.CreateScope();
var orderService = scope.ServiceProvider
.GetRequiredService<IOrderService>();
try
{
_logger.LogInformation(
"Starting processing of order {OrderId}",
item.OrderId
);
// Process order steps
await orderService.ValidateOrderAsync(item.OrderId, cancellationToken);
await orderService.ReserveInventoryAsync(item.OrderId, cancellationToken);
await orderService.ProcessPaymentAsync(item.OrderId, cancellationToken);
await orderService.ConfirmOrderAsync(item.OrderId, cancellationToken);
_metrics.RecordOrderProcessingTime(
stopwatch.ElapsedMilliseconds,
"success"
);
_logger.LogInformation(
"Order {OrderId} processed successfully in {ElapsedMs}ms",
item.OrderId,
stopwatch.ElapsedMilliseconds
);
}
catch (Exception ex)
{
_metrics.RecordOrderProcessingTime(
stopwatch.ElapsedMilliseconds,
"failure"
);
await orderService.HandleOrderFailureAsync(
item.OrderId,
ex.Message,
cancellationToken
);
throw;
}
}
}
public record OrderProcessingItem(
int OrderId,
DateTime SubmittedAt,
int Priority = 0
);
2. キャッシング戦略
// Distributed Cache with Redis
public class CachedCatalogService : ICatalogService
{
private readonly ICatalogService _innerService;
private readonly IDistributedCache _cache;
private readonly ILogger<CachedCatalogService> _logger;
private readonly TimeSpan _cacheExpiration = TimeSpan.FromMinutes(5);
public CachedCatalogService(
ICatalogService innerService,
IDistributedCache cache,
ILogger<CachedCatalogService> logger)
{
_innerService = innerService;
_cache = cache;
_logger = logger;
}
public async Task<PagedResult<CatalogItem>> GetItemsAsync(
int pageIndex,
int pageSize,
int? brandId = null,
int? typeId = null)
{
var cacheKey = $"catalog:items:{pageIndex}:{pageSize}:{brandId}:{typeId}";
// Try to get from cache
var cachedData = await _cache.GetAsync(cacheKey);
if (cachedData != null)
{
_logger.LogDebug("Cache hit for key {CacheKey}", cacheKey);
return JsonSerializer.Deserialize<PagedResult<CatalogItem>>(cachedData)!;
}
// Get from service
var result = await _innerService.GetItemsAsync(
pageIndex,
pageSize,
brandId,
typeId
);
// Cache the result
var serialized = JsonSerializer.SerializeToUtf8Bytes(result);
await _cache.SetAsync(
cacheKey,
serialized,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = _cacheExpiration,
SlidingExpiration = TimeSpan.FromMinutes(2)
}
);
_logger.LogDebug(
"Cached catalog items for key {CacheKey}",
cacheKey
);
return result;
}
public async Task InvalidateCacheAsync()
{
// Use Redis pattern matching to clear all catalog cache entries
var server = _cache as IServer;
if (server != null)
{
await foreach (var key in server.KeysAsync(pattern: "catalog:*"))
{
await _cache.RemoveAsync(key);
}
}
_logger.LogInformation("Catalog cache invalidated");
}
}
// Cache-aside pattern with stampede protection
public class SmartCacheService
{
private readonly IDistributedCache _cache;
private readonly IMemoryCache _memoryCache;
private readonly SemaphoreSlim _cacheLock;
public SmartCacheService(
IDistributedCache distributedCache,
IMemoryCache memoryCache)
{
_cache = distributedCache;
_memoryCache = memoryCache;
_cacheLock = new SemaphoreSlim(1, 1);
}
public async Task<T?> GetOrCreateAsync<T>(
string key,
Func<Task<T>> factory,
TimeSpan? expiration = null) where T : class
{
// L1 Cache - Memory
if (_memoryCache.TryGetValue(key, out T? cachedValue))
{
return cachedValue;
}
// L2 Cache - Redis
var distributedValue = await _cache.GetStringAsync(key);
if (!string.IsNullOrEmpty(distributedValue))
{
var value = JsonSerializer.Deserialize<T>(distributedValue);
// Populate L1 cache
_memoryCache.Set(key, value, TimeSpan.FromMinutes(1));
return value;
}
// Prevent cache stampede
await _cacheLock.WaitAsync();
try
{
// Double-check after acquiring lock
distributedValue = await _cache.GetStringAsync(key);
if (!string.IsNullOrEmpty(distributedValue))
{
return JsonSerializer.Deserialize<T>(distributedValue);
}
// Create value
var newValue = await factory();
if (newValue != null)
{
var serialized = JsonSerializer.Serialize(newValue);
// Set in both caches
await _cache.SetStringAsync(
key,
serialized,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = expiration ?? TimeSpan.FromMinutes(10)
}
);
_memoryCache.Set(key, newValue, TimeSpan.FromMinutes(1));
}
return newValue;
}
finally
{
_cacheLock.Release();
}
}
}
セキュリティ実装
1. 認証・認可
// JWT Authentication Configuration
public static class AuthenticationExtensions
{
public static IServiceCollection AddCustomAuthentication(
this IServiceCollection services,
IConfiguration configuration)
{
var identityUrl = configuration["IdentityUrl"];
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = identityUrl;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = false,
ValidateIssuer = true,
ValidIssuer = identityUrl,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) &&
path.StartsWithSegments("/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
services.AddAuthorization(options =>
{
options.AddPolicy("ApiScope", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "api");
});
options.AddPolicy("AdminOnly", policy =>
{
policy.RequireRole("Admin");
});
options.AddPolicy("CustomerOnly", policy =>
{
policy.RequireRole("Customer");
});
});
return services;
}
}
// Resource-based authorization
public class OrderAuthorizationHandler :
AuthorizationHandler<OrderOperationRequirement, Order>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
OrderOperationRequirement requirement,
Order resource)
{
if (context.User.IsInRole("Admin"))
{
context.Succeed(requirement);
return Task.CompletedTask;
}
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (requirement.Name == "ViewOrder" || requirement.Name == "CancelOrder")
{
if (resource.BuyerId == userId)
{
context.Succeed(requirement);
}
}
return Task.CompletedTask;
}
}
public class OrderOperationRequirement : IAuthorizationRequirement
{
public string Name { get; }
public OrderOperationRequirement(string name)
{
Name = name;
}
}
まとめ
.NET Aspireは、クラウドネイティブアプリケーション開発を大幅に簡素化します。本記事で紹介した実装パターンを活用することで、以下のメリットが得られます:
- 開発生産性の向上: ローカル開発環境の簡素化
- 運用性の向上: 統合されたテレメトリとオブザーバビリティ
- 信頼性の向上: レジリエンスパターンの組み込み
- セキュリティの強化: 認証・認可の統合
エンハンスド株式会社では、.NET Aspireを活用したクラウドネイティブアプリケーション開発の支援を行っています。お気軽にご相談ください。
#DotNetAspire #CloudNative #Microservices #DotNet8 #Azure #Kubernetes #Docker #DevOps #オブザーバビリティ #分散システム
執筆者: エンハンスド株式会社 技術チーム
公開日: 2024年12月23日