Aspire Integration'ları: Hosting ve Client Integration'ları, SQL Server, Redis, RabbitMQ, Keycloak ve Kendi Resource'un
Hosting integration resource'u AppHost'ta modeller, client integration ise service'e client library'yi health check, telemetry ve retry ile birlikte kaydeder; bu varsayılanlar her bağımlılığa uymaz. Yazı Redis düşünce kataloğu 12 saniye bekleten ve unhealthy yapan ayarları düzeltiyor, RabbitMQ ile sipariş olayını consumer span'ıyla aynı trace'te gösteriyor, Keycloak ile back office endpoint'ini koruyor ve dashboard'a kendi komutunu ekliyor; hepsi gerçek çıktılarla.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Aspire 13.6 (Keycloak integration'ı 13.6.5-preview), RabbitMQ.Client 7.2 ve StackExchange.Redis 2.13 ile derlenip denenmiştir; container'lar SQL Server 2025, Redis 8.6, RabbitMQ 4.3 ve Keycloak 26.6.
Önce Tahmin Et
02'de Redis'i dashboard'dan durdurduğumuzda Catalog'un /health'i 503 dönmüştü, kitap listesi ise 11,5 saniye bekleyip 200 dönmüştü. Redis bu service'te yalnız liste cevaplarını saklayan bir output cache. Cache'in olmaması isteği yavaşlatabilir, anlaşılır. Ama neden on saniyeden fazla? Ve neden bir cache yüzünden bütün service "sağlıksız" sayılıyor? Production'da readiness check'i düşen bir service trafikten çıkarılır; yani Redis'in birkaç dakikalık kesintisi kataloğu tamamen kapatabilir. Bu davranışı biz yazmadık. AddRedisOutputCache satırıyla gelen client integration'ın varsayılanları bu. Bu yazıda integration'ların neler eklediğini açıp gerektiğinde nasıl değiştireceğimizi görüyoruz.
Hosting Integration ve Client Integration
Aspire'da bir altyapı parçasının iki ayrı paketi var. Hosting integration AppHost'a eklenir ve resource'u modeller: hangi image, hangi port, hangi parola parameter'ı, hangi health check. Client integration ise o resource'u kullanan service'e eklenir ve client library'yi DI (dependency injection; bağımlılıkları constructor'a dışarıdan veren mekanizma) container'ına kaydeder: connection string'i configuration'dan okur, telemetry'yi açar, health check ekler, gerekiyorsa retry ayarlar. İkisi birbirinden bağımsız. Hosting integration olmadan da client integration kullanabilirsin (connection string'i elle verirsin), client integration olmadan da hosting integration (service kendi library ayarını yapar). Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) şu an dört çift var:
| Resource | Hosting (AppHost) | Client (service) | Kullanan |
|---|---|---|---|
| SQL Server | Aspire.Hosting.SqlServer | Aspire.Microsoft.EntityFrameworkCore.SqlServer | Catalog, migrations |
| Redis | Aspire.Hosting.Redis | Aspire.StackExchange.Redis.OutputCaching | Catalog |
| RabbitMQ | Aspire.Hosting.RabbitMQ | Aspire.RabbitMQ.Client | Ordering, Inventory |
| Keycloak | Aspire.Hosting.Keycloak | Aspire.Keycloak.Authentication | Catalog |
RabbitMQ ve Keycloak bu yazıda geliyor. Paket sürümleri Aspire'ın sürümüyle birlikte gidiyor: hepsi 13.6.5, yalnız Keycloak paketleri 13.6.5-preview.1.26559.7, çünkü Keycloak integration'ı hâlâ preview. Integration paketlerini AppHost SDK'sıyla aynı sürümde tut; aspire update SDK'yı ve bütün Aspire.* paketlerini birlikte güncelliyor.
Client Integration Ne Ekliyor?
Catalog'daki tek satır builder.AddSqlServerDbContext<BookstoreContext>("bookstoredb") aslında dört iş yapıyor: ConnectionStrings:bookstoredb'yi okuyup BookstoreContext'i kaydediyor, Entity Framework Core (EF Core) ve SqlClient span'larını OpenTelemetry'ye bağlıyor, /health'e bir DbContext health check'i ekliyor ve geçici SQL hatalarında sorguyu tekrar deneyen bir execution strategy açıyor. Son maddeyi küçük bir konsol uygulamasıyla gördük:
Console.WriteLine(db.Database.CreateExecutionStrategy().GetType().Name);
SqlServerRetryingExecutionStrategy
Diğerleri de benzer. AddRedisOutputCache bir IConnectionMultiplexer, bir Redis health check'i ve StackExchange.Redis span'ları ekliyor. AddRabbitMQClient tek bir IConnection kaydediyor, başlangıçta bağlantıyı birkaç kez deniyor, RabbitMQ.Client adlı bir health check ve RabbitMQ'nun publish/deliver span'larını ekliyor. Her client integration'ın bir settings class'ı var; bu davranışlar kodla ya da configuration'daki Aspire: section'ı ile kapatılabiliyor: Aspire:Microsoft:EntityFrameworkCore:SqlServer altında DisableRetry, DisableHealthChecks, DisableTracing; Redis ve RabbitMQ'da da DisableHealthChecks ve DisableTracing.
Client integration ne zaman kullanılmamalı? DbContext'in ayarlarını zaten kendin yönetiyorsan (kendi interceptor'ların, kendi retry politikan, birden fazla connection string arasında seçim) AddSqlServerDbContext yerine AddDbContext ile kendi kaydını yap ve ardından builder.EnrichSqlServerDbContext<BookstoreContext>() çağır: health check ve telemetry gelir, DbContext ayarına dokunulmaz. Library'nin Aspire'da client integration'ı yoksa da sorun değil; connection string yine ConnectionStrings__x olarak geliyor, 02'de gördüğümüz connection property'leri de (X_HOST, X_PORT, X_URI) .NET olmayan service'ler için var.
Redis: Cache Kritik Bir Bağımlılık Değil
Önce Tahmin Et'teki deneyi 04'ün koduyla tekrarladık ve Redis'i aspire resource redis stop ile durdurduk:
/health 503 5.580711s
/api/books?page=5 200 11.987081s
/api/books?page=5 200 11.993322s
/api/books/lookup?isbn=9780547928227 200 0.004292s
Cache'lenmeyen /lookup etkilenmiyor; cache'lenen liste her istekte 12 saniye sürüyor ve ikinci istek de hızlanmıyor. Catalog'un log'u sebebi söylüyor:
fail: Microsoft.AspNetCore.OutputCaching.OutputCacheMiddleware[13]
StackExchange.Redis.RedisConnectionException: The message timed out in the backlog attempting to send
because no connection became available (5000ms) - ... command=SETEX, timeout: 5000 ...
StackExchange.Redis bağlantı koptuğunda komutları hemen reddetmiyor, bir backlog'da bağlantının geri gelmesini bekletiyor; varsayılan süre 5 saniye. Output cache middleware'i her istekte önce cache'e bakıyor, sonra cevabı yazıyor: iki komut, iki kez 5 saniye. Middleware hatayı yakalayıp cevabı yine de döndüğü için sonuç 200, ama 12 saniye sonra. /health'teki 5,6 saniye de aynı backlog'da bekleyen bir PING.
Catalog için Redis yalnız bir hızlandırıcı; Redis yokken de veritabanından cevap verebilmeli. Bunu üç yerde söyledik:
// Cache zorunlu değil: Redis yokken de katalog cevap vermeli, hem de hızlı
builder.AddRedisOutputCache("redis",
settings => settings.DisableHealthChecks = true,
options => options.BacklogPolicy = BacklogPolicy.FailFast);
DisableHealthChecks Redis'i Catalog'un health'inden çıkarıyor, BacklogPolicy.FailFast bağlantı yokken komutu beklemeden hata ile bitiriyor. Üçüncüsü AppHost'ta: Catalog artık WaitFor(redis) demiyor, yalnız WithReference(redis). WaitFor'u gerçekten onsuz başlayamayacağın bağımlılıklar için kullan. Aynı deney yeni kodla:
/health 200 0.004646s
/api/books?page=7 200 0.060637s
/api/books?page=7 200 0.027793s
Redis'in kendi durumu dashboard'da redis resource'unun health'inde zaten görünüyor; Catalog'un health'i yalnız Catalog'un çalışıp çalışmadığını söylemeli.
RabbitMQ: Sipariş Olayı ve Consumer Span'ı
Siparişin bir adımını asenkron yapalım: Ordering siparişi Pending olarak kaydediyor ve bir OrderPlaced mesajı yayınlıyor; yeni Bookstore.Inventory worker'ı stoku ayırıp StockReserved ile cevap veriyor; Ordering bu cevabı alınca siparişi Confirmed yapıyor. Mesaj ve queue adları iki service'in ortak sözleşmesi olduğu için küçük bir Bookstore.Contracts projesinde duruyor. Service'lerin nasıl bölüneceği ve olay akışlarının tasarımı .NET ile Mikroservis Uygulaması serisinin konusu; burada Aspire tarafına bakıyoruz. AppHost'a eklenenler:
IResourceBuilder<RabbitMQServerResource> messaging = builder.AddRabbitMQ("messaging")
.WithContainerName("aspire-rabbitmq")
.WithManagementPlugin();
IResourceBuilder<ProjectResource> ordering = builder.AddProject<Projects.Bookstore_Ordering_Api>("ordering")
.WithReference(catalog)
.WithReference(messaging)
.WaitFor(catalog)
.WaitFor(messaging)
.WithHttpHealthCheck("/health");
builder.AddProject<Projects.Bookstore_Inventory>("inventory")
.WithReference(messaging)
.WaitFor(messaging);
WithManagementPlugin() image'ı rabbitmq:4.3-management'a çeviriyor ve resource'a ikinci bir endpoint (management arayüzü) ekliyor. Ordering'e giden değişkenler (aspire describe ordering --format Json):
"ConnectionStrings__messaging": "amqp://guest:<password>@localhost:60012",
"MESSAGING_HOST": "localhost",
"MESSAGING_PASSWORD": null,
"MESSAGING_PORT": "60012",
"MESSAGING_URI": "amqp://guest:<password>@localhost:60012",
"MESSAGING_USERNAME": "guest"
AMQP (Advanced Message Queuing Protocol; RabbitMQ'nun konuştuğu mesajlaşma protokolü) adresi hem connection string hem de 13.0'daki connection property'leri olarak geliyor. Dikkat çeken bir ayrıntı: 13.6'da aspire describe MESSAGING_PASSWORD'ü gizliyor ama parola URI'de (kaynağın kullanıcı adı ve parola dahil tam adresi) ve connection string'te açık duruyor; describe çıktısını paylaşırken bunu unutma. Service tarafında tek satır var: builder.AddRabbitMQClient("messaging");. Inventory'nin consumer'ı:
public sealed class OrderPlacedConsumer(
IConnection connection,
StockLedger ledger,
TimeProvider clock,
ILogger<OrderPlacedConsumer> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await using IChannel channel = await connection.CreateChannelAsync(
new CreateChannelOptions(publisherConfirmationsEnabled: true, publisherConfirmationTrackingEnabled: true),
stoppingToken);
await channel.QueueDeclareAsync(Queues.OrderPlaced, durable: true, exclusive: false, autoDelete: false, cancellationToken: stoppingToken);
await channel.QueueDeclareAsync(Queues.StockReserved, durable: true, exclusive: false, autoDelete: false, cancellationToken: stoppingToken);
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 10, global: false, stoppingToken);
AsyncEventingBasicConsumer consumer = new(channel);
consumer.ReceivedAsync += async (_, delivery) =>
{
OrderPlaced? message = JsonSerializer.Deserialize<OrderPlaced>(delivery.Body.Span, JsonSerializerOptions.Web);
if (message is null)
{
// Okunamayan mesaj queue'ya geri dönmez, dead-letter yoluna gider
await channel.BasicNackAsync(delivery.DeliveryTag, multiple: false, requeue: false);
return;
}
ledger.Reserve(message.Items);
logger.LogInformation("Reserved stock for order {OrderId} ({ItemCount} items)", message.OrderId, message.Items.Count);
StockReserved reply = new(message.OrderId, clock.GetUtcNow());
BasicProperties properties = new() { ContentType = "application/json", DeliveryMode = DeliveryModes.Persistent };
await channel.BasicPublishAsync(exchange: "", routingKey: Queues.StockReserved, mandatory: true,
basicProperties: properties, body: JsonSerializer.SerializeToUtf8Bytes(reply, JsonSerializerOptions.Web));
await channel.BasicAckAsync(delivery.DeliveryTag, multiple: false);
};
await channel.BasicConsumeAsync(Queues.OrderPlaced, autoAck: false, consumer, stoppingToken);
// Host durana kadar channel açık kalsın
await Task.Delay(Timeout.Infinite, stoppingToken);
}
}
Ordering'deki OrderEventPublisher service boyunca tek bir channel kullanıyor ve onu bir SemaphoreSlim ile koruyor, çünkü RabbitMQ channel'ları aynı anda iki thread'den kullanılmamalı. Publisher confirm'leri açık: BasicPublishAsync'in await'i broker mesajı saklayınca dönüyor. Ordering'in StockReserved consumer'ı da yukarıdakinin kısa bir kopyası. RabbitMQ.Client 7'nin API'si baştan sona async; 6.x'teki IModel ve senkron BasicPublish yok.
04'ün sonunda söz verdiğimiz waterfall'a gelelim. Gateway üzerinden iki kalemli bir sipariş verdik ve aspire otel traces çıktısını aynı biçimde çizdik:
0.0 ms 20 ms gateway: POST /api/orders/{**rest}
0.2 ms 20 ms gateway: POST http://localhost:5082/api/orders
0.6 ms 19 ms ordering: POST /api/orders/
1.1 ms 17 ms ordering: Fetch catalog prices
1.8 ms 16 ms ordering: GET http://localhost:5080/api/books/lookup?*
2.1 ms 15 ms catalog: GET /api/books/lookup
4.4 ms 12 ms catalog: SELECT [Books]
17.7 ms 0 ms ordering: publish ordering.order-placed
19.1 ms 2 ms inventory: deliver ordering.order-placed
19.5 ms 0 ms inventory: publish inventory.stock-reserved
20.5 ms 0 ms ordering: deliver inventory.stock-reserved
HTTP isteği ile iki mesajın işlenmesi aynı trace'te. Ordering'in publish span'ı, trace context'ini mesajın header'ına yazıyor; Inventory'nin deliver span'ı oradan okuyup kendini onun altına koyuyor. Bunu biz yazmadık: RabbitMQ.Client 7 bu span'ları kendi ActivitySource'larıyla üretiyor, client integration da o source'ları OpenTelemetry'ye ekliyor. Span'ların attribute'ları OpenTelemetry'nin messaging semantic convention'larını izliyor: messaging.system=rabbitmq, messaging.operation.type=process, messaging.rabbitmq.destination.routing_key=ordering.order-placed, messaging.message.id olarak da sipariş id'si. Log'lar da aynı trace id'yi taşıyor:
19:27:46.355 INFO inventory Reserved stock for order 01a1dae2-e931-7603-913a-72822d1921c2 (2 items)
19:27:46.356 INFO ordering Order 01a1dae2-e931-7603-913a-72822d1921c2 confirmed
Broker durunca ne oluyor? aspire resource messaging stop sonrası Ordering'in /health'i 503 döndü (log'da Health check RabbitMQ.Client with status Unhealthy), yeni sipariş ise 500 ile bitti: AlreadyClosedException: ... CONNECTION_FORCED - broker forced connection closure with reason 'shutdown'. Bu sefer health check'i kapatmadık, çünkü mesaj yayınlayamayan bir Ordering sipariş alamıyor; burada RabbitMQ gerçekten kritik bir bağımlılık. 500'ün bir yan etkisi daha var: sipariş bellekteki store'a yazılmıştı, mesaj ise gitmedi. Sipariş sonsuza kadar Pending kalıyor. Bu, veritabanına yazıp mesaj yayınlamanın klasik "dual write" sorunu; çözümü olan outbox pattern Message Queue'larla Çalışmak serisinde. RabbitMQ'yu yeniden başlattıktan sonra bağlantı ve iki consumer kendiliğinden toparlandı; yeni sipariş bir saniye içinde Confirmed oldu. Bu, RabbitMQ.Client'ın varsayılan automatic recovery davranışı.
Keycloak: Back Office Endpoint'ine Token
Kataloğa kitap eklemek back office'in işi; POST /api/books'u yalnız StoreStaff rolü çağırabilmeli. Token'ları lokalde Keycloak veriyor:
IResourceBuilder<ParameterResource> backofficeSecret = builder.AddParameter("backoffice-client-secret", secret: true);
// Realm import yalnız lokal geliştirme için; publish/deploy'un parçası değil
IResourceBuilder<KeycloakResource> keycloak = builder.AddKeycloak("keycloak", 8080)
.WithContainerName("aspire-keycloak")
.WithRealmImport("./Realms")
.WithEnvironment("BOOKSTORE_BACKOFFICE_SECRET", backofficeSecret);
Port'u sabit verdik (8080): token'ın iss değeri Keycloak'ın adresinden geliyor ve her çalıştırmada değişmemeli. Admin parolası verilmezse Aspire bir tane üretip AppHost'un user secrets'ına Parameters:keycloak-password olarak yazıyor. WithRealmImport, Realms klasöründeki JSON'u container'daki import klasörüne kopyalıyor ve Keycloak açılışta realm'ı oluşturuyor. Realm dosyasında client secret'ı açık yazmadık; Keycloak import sırasında ${...} yer tutucularını environment variable'lardan dolduruyor:
{
"realm": "bookstore",
"roles": { "realm": [ { "name": "StoreStaff" } ] },
"clients": [
{
"clientId": "bookstore-backoffice",
"publicClient": false,
"secret": "${BOOKSTORE_BACKOFFICE_SECRET}",
"serviceAccountsEnabled": true,
"standardFlowEnabled": false,
"protocolMappers": [
{ "name": "catalog-audience", "protocolMapper": "oidc-audience-mapper", ... },
{ "name": "realm-roles", "protocolMapper": "oidc-usermodel-realm-role-mapper", ... }
]
}
],
"users": [
{ "username": "service-account-bookstore-backoffice", "serviceAccountClientId": "bookstore-backoffice", "realmRoles": [ "StoreStaff" ] }
]
}
Secret böylece repoya girmiyor; değer parameter'dan, yani user secrets'tan geliyor. Catalog tarafı:
builder.Services.AddAuthentication()
.AddKeycloakJwtBearer(serviceName: "keycloak", realm: "bookstore", options =>
{
options.Audience = "bookstore-catalog";
// Claim adları Keycloak'ın yazdığı gibi kalsın; yoksa "roles" yeniden adlandırılır ve RequireRole hiçbir şey bulamaz
options.MapInboundClaims = false;
options.TokenValidationParameters.RoleClaimType = "roles";
// Lokalde Keycloak'a http ile gidiliyor; production'da https bir Authority verilir
if (builder.Environment.IsDevelopment())
{
options.RequireHttpsMetadata = false;
}
});
builder.Services.AddAuthorizationBuilder()
.AddPolicy("BackOffice", policy => policy.RequireRole("StoreStaff"));
AddKeycloakJwtBearer, Authority'yi service discovery ile kuruyor: Catalog'a KEYCLOAK_HTTP=http://localhost:8080 ve services__keycloak__http__0 geliyor, metadata da oradan okunuyor. Catalog WaitFor(keycloak) demiyor: metadata ilk korumalı istekte okunuyor ve kitap listesi Keycloak olmadan da çalışmalı. Keycloak container'ı bizde 20–28 saniyede healthy oldu. Denemeler:
# token olmadan
POST /api/books 401
# bookstore-backoffice için client_credentials token'ı; payload'dan bir kısmı
POST .../openid-connect/token 200
"iss": "http://localhost:8080/realms/bookstore",
"aud": "bookstore-catalog",
"azp": "bookstore-backoffice",
"roles": [ "StoreStaff" ]
# token ile
POST /api/books 403
403, ilk denemede MapInboundClaims = false satırı olmadığı için. JWT (JSON Web Token; imzalı ve kendi içinde bilgi taşıyan token biçimi) handler'ı varsayılan olarak bazı claim adlarını .NET'in eski uzun adlarına çeviriyor; roles claim'i http://schemas.microsoft.com/ws/2008/06/identity/claims/role oluyor. Biz RoleClaimType = "roles" dediğimiz için rol bulunamıyor. Satırı ekledikten sonra ikinci sürpriz geldi: kitap veritabanına yazıldı ama cevap 500 oldu. Sebebi Redis bölümündeki kararla ilgili: bu çalıştırmada Redis'i başlatmamıştık ve yeni kitap eklenince listenin cache'ini temizleyen EvictByTagAsync çağrısı, beklediğimiz RedisException yerine onu saran bir AggregateException fırlattı. Catch filtresini düzelttik:
// Başarısız bir eviction isteği bozmamalı; liste zaten 30 sn'de düşüyor
try
{
await cache.EvictByTagAsync(BookListTag, ct);
}
catch (Exception ex) when (ex is RedisException or AggregateException { InnerException: RedisException })
{
loggerFactory.CreateLogger("Bookstore.Api.Books").LogWarning(ex, "Could not evict the book list cache");
}
# token ile
POST /api/books 201 {"isbn":"9780140268867","title":"The Odyssey","author":"Homer","price":280.00,"currency":"TRY"}
# aynı istek tekrar
POST /api/books 409
# yanlış client secret ile token isteği
POST .../openid-connect/token 401
WithRealmImport dokümanda açıkça yalnız lokal geliştirme için; aspire publish ve aspire deploy bunu desteklemiyor. Production'da realm'ı Keycloak'ın Admin API'si, Terraform ya da kendi image'ın kuruyor ve service'ler mevcut bir Keycloak'a bağlanıyor. Authentication ve authorization'ın kendisi (OAuth 2.0 akışları, token ömrü, refresh) bu serinin konusu değil.
Integration Bulmak ve Sürüm Uyumu
Hangi teknolojinin integration'ı var? CLI'dan aranabiliyor:
aspire integration search keycloak
┌──────────────────────────────────────┬─────────────────────────────────────────────────────┬──────────────────────────┐
│ Name │ Package │ Version │
├──────────────────────────────────────┼─────────────────────────────────────────────────────┼──────────────────────────┤
│ keycloak │ Aspire.Hosting.Keycloak │ 13.6.5-preview.1.26559.7 │
│ communitytoolkit-keycloak-extensions │ CommunityToolkit.Aspire.Hosting.Keycloak.Extensions │ 17.0.0-beta.1051 │
└──────────────────────────────────────┴─────────────────────────────────────────────────────┴──────────────────────────┘
aspire integration list o gün 164 hosting integration'ı listeledi; 67'si Community Toolkit'ten. Aspire ekibinin paketleri Aspire.Hosting.* adını taşıyor ve Aspire'la aynı sürüm numarasıyla çıkıyor. Community Toolkit paketleri ise kendi sürüm çizgisinde ve topluluk tarafından bakılıyor; bir paket seçmeden önce son güncellemesine ve hangi Aspire sürümünü hedeflediğine bak. aspire add rabbitmq paketi AppHost projesine ekliyor; biz CPM (Central Package Management; paket sürümlerini tek dosyada toplayan NuGet özelliği) kullandığımız için sürümleri Directory.Packages.props'a yazdık. Preview etiketi önemli: Keycloak'ın API'si bir sonraki minor sürümde değişebilir, production kodu için bunu hesaba kat.
Kendi Resource'un ve Dashboard Komutu
02'de Mailpit'i AddContainer ile eklemiştik; çalışıyordu ama başka bir resource ona WithReference ile bağlanamıyordu, çünkü bir container'ın connection string'i yok. Bir resource'u typed hâle getirmek birkaç satır:
// AddContainer yerine typed bir resource: diğer resource'lar WithReference ile SMTP connection string'i alabilir
public sealed class MailpitResource(string name) : ContainerResource(name), IResourceWithConnectionString
{
internal const string SmtpEndpointName = "smtp";
private EndpointReference? smtpEndpoint;
public EndpointReference SmtpEndpoint => smtpEndpoint ??= new(this, SmtpEndpointName);
public ReferenceExpression ConnectionStringExpression =>
ReferenceExpression.Create($"smtp://{SmtpEndpoint.Property(EndpointProperty.HostAndPort)}");
}
public static IResourceBuilder<MailpitResource> AddMailpit(this IDistributedApplicationBuilder builder, string name) =>
builder.AddResource(new MailpitResource(name))
.WithImage("axllent/mailpit", "v1.33")
.WithImageRegistry("docker.io")
.WithHttpEndpoint(targetPort: 8025, name: "http")
.WithEndpoint(targetPort: 1025, name: MailpitResource.SmtpEndpointName, scheme: "smtp");
AppHost'ta artık builder.AddMailpit("mailpit") yazıyoruz; e-posta gönderecek bir service WithReference(mailpit) ile, değeri ConnectionStringExpression'daki biçimde (smtp://host:port) olan bir ConnectionStrings__mailpit değişkeni alır. Aynı yapıyla kendi ekibinin kullandığı herhangi bir aracı modelleyebilirsin. İkinci genişletme noktası resource komutları. Redis'e dashboard'da ve CLI'da görünen bir "Clear cache" komutu ekledik:
// Dashboard ve CLI komutu: Redis'i boşaltır, yalnız resource healthy iken açık
public static IResourceBuilder<RedisResource> WithClearCacheCommand(this IResourceBuilder<RedisResource> redis) =>
redis.WithCommand(
name: "clear-cache",
displayName: "Clear cache",
executeCommand: async context =>
{
string? connectionString = await redis.Resource.GetConnectionStringAsync(context.CancellationToken);
try
{
await using ConnectionMultiplexer connection = await ConnectionMultiplexer.ConnectAsync($"{connectionString},allowAdmin=true");
foreach (IServer server in connection.GetServers())
{
await server.FlushAllDatabasesAsync();
}
}
catch (RedisConnectionException)
{
return CommandResults.Failure("Redis is not reachable.");
}
return CommandResults.Success();
},
commandOptions: new CommandOptions
{
IconName = "Delete",
UpdateState = context => context.ResourceSnapshot.HealthStatus == HealthStatus.Healthy
? ResourceCommandState.Enabled
: ResourceCommandState.Disabled
});
/api/books?page=6 200 0.322477s
# ikinci istek cache'ten
/api/books?page=6 200 0.003947s
aspire resource redis clear-cache
✅ Command 'clear-cache' executed successfully on resource 'redis'.
# cache boş, cevap yine veritabanından
/api/books?page=6 200 0.052924s
Komut AppHost process'inde çalışıyor; resource'un connection string'ine ve endpoint'lerine erişebiliyor. Dashboard'da resource satırının menüsünde, CLI'da aspire resource <ad> <komut> ile çağrılıyor. Seed verisini yeniden yüklemek, bir test kullanıcısı oluşturmak, bir queue'yu boşaltmak gibi ekipte herkesin elle yaptığı işler için uygun bir yer.
Üretimde Dikkat
Client integration'ların varsayılanları lokal geliştirme ve tipik bir production için seçilmiş, ama senin bağımlılık haritanı bilmiyorlar. Her bağımlılık için iki soru sor: bu olmadan service iş yapabiliyor mu (health check'te olmalı mı), olmadığında ne kadar beklemeli (timeout, backlog, retry)? Bookstore'da cevap Redis için "evet, beklemeden", RabbitMQ için "hayır" oldu.
Health check'leri liveness ve readiness olarak ayır. ServiceDefaults'taki /alive yalnız live etiketli check'leri çalıştırıyor; client integration'ların eklediği check'ler /health'e gidiyor. Kubernetes'te liveness'a bir veritabanı check'i koymak, veritabanı kesintisinde bütün pod'ların yeniden başlatılması demek.
Client secret, admin parolası ve connection string'ler parameter olarak kalsın. WithRealmImport ile gelen yer tutucu yaklaşımı lokalde güzel; production'da secret'ın kaynağı Configuration ve Secret Management serisinde anlatılan secret store.
Preview integration'ları sabit sürüme kilitle ve güncellemeyi bilinçli yap. CPM'de sürüm tek yerde olduğu için bu kolay.
Sık Düşülen Tuzaklar
Client integration'ın retry'ını kendi kodunla çakıştırmak. EF Core client integration'ı retry'lı execution strategy açtığı için kendi başlattığın transaction hata veriyor. Aynı küçük konsol uygulamasında BeginTransactionAsync sonrası bir sorgu:
InvalidOperationException: The configured execution strategy 'SqlServerRetryingExecutionStrategy' does not support
user-initiated transactions. Use the execution strategy returned by 'DbContext.Database.CreateExecutionStrategy()'
to execute all the operations in the transaction as a retriable unit.
Ya işlemi CreateExecutionStrategy().ExecuteAsync(...) içine al ya da DisableRetry ile client integration'ın retry'ını kapatıp kendi politikanı uygula. İkisini birden açık bırakmak (EF retry'ı ve üstüne Polly ile bir retry daha) bir hata anında istek sayısını katlıyor.
Opsiyonel bir bağımlılığa WaitFor ve health check bağlamak. Cache, metric toplayıcı, e-posta sunucusu gibi parçalar düştüğünde service'i de düşürmemeli.
JWT'de rol bulunamadığında yetkiyi suçlamak. 403 görüyorsan önce token'daki claim adlarına, sonra MapInboundClaims ve RoleClaimType'a bak.
Exception tipini varsaymak. Output cache'in Redis store'u eviction'da AggregateException fırlattı; catch filtrelerini gerçek hatayı görerek yaz.
Realm import'u production yolu sanmak. Lokalde her açılışta realm'ı kuruyor; publish çıktısında karşılığı yok.
Küçük Bir Deneme
★☆☆ Catalog'da BacklogPolicy.FailFast satırını kaldır, Redis'i durdur ve liste isteğinin süresini ölç. Sonra DisableHealthChecks'i de kaldırıp /health'e bak. Sayılar bu yazıdakilerle tutuyor mu?
★★☆ Inventory'yi dashboard'dan durdur, üç sipariş ver, RabbitMQ management arayüzünde ordering.order-placed queue'sundaki mesaj sayısına bak. Inventory'yi başlatınca siparişler ne kadar sürede Confirmed oluyor?
★★★ Mailpit'i kullanan küçük bir Notifications worker'ı yaz: OrderPlaced'i dinlesin ve WithReference(mailpit) ile gelen SMTP (e-posta gönderme protokolü) adresine bir onay e-postası göndersin. Mailpit explicit start olduğu için worker'ın davranışı Mailpit başlamadan önce ne oluyor?
Integration'lar, health check'ler ve mesajlaşma ile Bookstore'un parçaları artık birbirine gerçekten bağlı. Bir sonraki yazıda bu bağları test ediyoruz: Aspire.Hosting.Testing ile AppHost'u testte ayağa kaldırıp "kitap ekle, listede gör" ve "sipariş ver, Confirmed olsun" senaryolarını uçtan uca doğruluyoruz; RabbitMQ'nun asenkron cevabını beklerken neden Task.Delay kullanmamamız gerektiğini de göreceğiz.