.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は、クラウドネイティブアプリケーション開発を大幅に簡素化します。本記事で紹介した実装パターンを活用することで、以下のメリットが得られます:

  1. 開発生産性の向上: ローカル開発環境の簡素化
  2. 運用性の向上: 統合されたテレメトリとオブザーバビリティ
  3. 信頼性の向上: レジリエンスパターンの組み込み
  4. セキュリティの強化: 認証・認可の統合

エンハンスド株式会社では、.NET Aspireを活用したクラウドネイティブアプリケーション開発の支援を行っています。お気軽にご相談ください。


#DotNetAspire #CloudNative #Microservices #DotNet8 #Azure #Kubernetes #Docker #DevOps #オブザーバビリティ #分散システム

執筆者: エンハンスド株式会社 技術チーム
公開日: 2024年12月23日