برنامهنویسی با الهام از هوش مصنوعی: ساخت کلاینتهای HTTP تابآور در داتنت
نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۳/۱۹ ۰۷:۴۹
آدرس: www.dntips.ir
| مطالب | ۳۶۹۴ |
| نویسندگان | ۲۷۶ |
| گروههای مطالب | ۱۰۲۴ |
| نقشههای راه | ۱۱۹ |
| دورهها | ۱۴ |
| اشتراکها | ۱۷۹۱۴ |
/api/availability اختصاصی.HttpClientFactory در کنار کتابخانه Polly در داتنت استفاده کرد. Polly یک کتابخانه انعطافپذیری (Resilience) و تحمل خطای داتنت است که به توسعهدهندگان امکان میدهد استراتژیهای مختلفی را برای مدیریت خطاهای موقتی (Transient Errors) و افزایش تابآوری سیستم پیادهسازی کنند.InventoryClient را در C# برای مدیریت درخواستهای HTTP در یک سیستم با بار بالا پیادهسازی کنیم که الزامات زیر را برآورده کند:HttpClientFactory: برای ایجاد نمونههای HttpClient به منظور ارتباط با سیستم خارجی.IDistributedCache و گزینه InMemory برای تست).// Required NuGet Packages (Example for .NET 8)
// <ItemGroup>
// <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.6" />
// <PackageReference Include="Microsoft.Extensions.Caching.Distributed" Version="8.0.6" />
// <PackageReference Include="Microsoft.Extensions.Caching.InMemory" Version="8.0.6" />
// <PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
// <PackageReference Include="Polly.Contrib.Bulkhead" Version="0.2.1-alpha" /> // Consider a more recent and stable Bulkhead library if available or use a custom implementation
// <PackageReference Include="xunit" Version="2.9.0" />
// <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
// <PackageReference Include="WireMock.Net" Version="1.5.47" />
// </ItemGroup>
using Xunit;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.InMemory;
using System.Net.Http;
using Polly;
using Polly.CircuitBreaker;
using Polly.Timeout;
using System.Threading.Tasks;
using System;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Text.Json;
using System.Text;
// --- Test Class ---
public class InventoryClientTests : IDisposable
{
private readonly IWireMockServer _server;
private readonly ServiceProvider _serviceProvider;
private readonly ILogger<InventoryClient> _logger;
public InventoryClientTests()
{
// Setup WireMock server
_server = WireMockServer.Start();
_server
.Given(Request.Create().WithPath("/inventory").UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithBody("{\"bananaId1\":10,\"bananaId2\":5}")
.WithHeader("Content-Type", "application/json"));
_server
.Given(Request.Create().WithPath("/inventory-unhealthy").UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(500)); // Simulate an unhealthy service
// Setup DI container with Polly and Caching
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddConsole());
_logger = services.BuildServiceProvider().GetRequiredService<ILogger<InventoryClient>>();
services.AddDistributedMemoryCache(); // In-memory distributed cache for testing
// Configure Polly policies
var retryPolicy = Policy
.Handle<HttpRequestException>()
.OrResult<HttpResponseMessage>(r => !r.IsSuccessStatusCode)
.WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
onRetry: (exception, timeSpan, retryCount, context) =>
{
_logger.LogWarning($"Retry {retryCount} due to {exception.Exception?.Message ?? exception.Result?.StatusCode.ToString()}. Waiting {timeSpan.TotalSeconds}s.");
});
var circuitBreakerPolicy = Policy
.Handle<HttpRequestException>()
.OrResult<HttpResponseMessage>(r => !r.IsSuccessStatusCode)
.CircuitBreakerAsync(2, TimeSpan.FromSeconds(5),
onBreak: (exception, breakDelay) =>
{
_logger.LogError($"Circuit opened for {breakDelay.TotalSeconds}s due to {exception.Exception?.Message ?? exception.Result?.StatusCode.ToString()}.");
},
onReset: () => _logger.LogInformation("Circuit reset."),
onHalfOpen: () => _logger.LogInformation("Circuit half-opened."));
// Bulkhead policy for capping concurrent requests
// Note: For real-world scenarios, consider a more robust bulkhead implementation or library.
// This is a simple example for demonstration.
var bulkheadPolicy = Policy.BulkheadAsync(10, 2); // 10 concurrent executions, 2 queued
services.AddHttpClient("InventoryClient", client =>
{
client.BaseAddress = new Uri(_server.Url);
client.Timeout = TimeSpan.FromSeconds(3); // Global timeout for HttpClient
})
.AddPolicyHandler(retryPolicy)
.AddPolicyHandler(circuitBreakerPolicy)
.AddPolicyHandler(bulkheadPolicy); // Adding bulkhead policy directly to HttpClient
services.AddSingleton<InventoryClient>();
_serviceProvider = services.BuildServiceProvider();
}
[Fact]
public async Task GetInventoryDataAsync_ReturnsData_WhenExternalServiceIsAvailable()
{
// Arrange
var client = _serviceProvider.GetRequiredService<InventoryClient>();
var bananaSkus = new List<string> { "bananaId1", "bananaId2" };
// Act
var inventoryData = await client.GetInventoryDataAsync(bananaSkus);
// Assert
Assert.NotNull(inventoryData);
Assert.True(inventoryData.ContainsKey("bananaId1"));
Assert.Equal(10, inventoryData["bananaId1"]);
}
[Fact]
public async Task GetInventoryDataAsync_ReturnsCachedData_WhenCircuitBreakerIsOpen()
{
// Arrange
var client = _serviceProvider.GetRequiredService<InventoryClient>();
var distributedCache = _serviceProvider.GetRequiredService<IDistributedCache>();
var bananaSkus = new List<string> { "bananaId1" };
var cachedData = new Dictionary<string, int> { { "bananaId1", 99 } };
var serializedData = JsonSerializer.Serialize(cachedData);
await distributedCache.SetStringAsync("InventoryClient_bananaId1", serializedData, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1)
});
// Simulate service going down to open the circuit
_server.Stop(); // Stop the server to simulate unreachability
// Trigger calls to open the circuit breaker
for (int i = 0; i < 3; i++) // Number of failures to open circuit
{
await Assert.ThrowsAnyAsync<Exception>(() => client.GetInventoryDataAsync(bananaSkus));
}
// Act
var inventoryData = await client.GetInventoryDataAsync(bananaSkus);
// Assert
// The client should return data from cache because the circuit is open
Assert.NotNull(inventoryData);
Assert.True(inventoryData.ContainsKey("bananaId1"));
Assert.Equal(99, inventoryData["bananaId1"]); // Should be from cache
}
public void Dispose()
{
_server.Stop();
_server.Dispose();
_serviceProvider.Dispose();
}
}
// --- InventoryClient Implementation ---
public class InventoryClient
{
private readonly HttpClient _httpClient;
private readonly IDistributedCache _cache;
private readonly ILogger<InventoryClient> _logger;
public InventoryClient(IHttpClientFactory httpClientFactory, IDistributedCache cache, ILogger<InventoryClient> logger)
{
// HttpClientFactory Integration: Ensures efficient management of HttpClient instances.
// It handles connection pooling, DNS changes, and reduces socket exhaustion, leading to better performance and reliability.
_httpClient = httpClientFactory.CreateClient("InventoryClient");
_cache = cache;
_logger = logger;
}
public async Task<Dictionary<string, int>> GetInventoryDataAsync(List<string> bananaSkus)
{
var cacheKey = $"InventoryClient_{string.Join("_", bananaSkus)}";
Dictionary<string, int>? data = null;
try
{
// Circuit Breaker Pattern: If the circuit is open, this call will fail fast,
// preventing cascading failures and allowing the external system to recover.
// Bulkhead Isolation: This implicitly works with the HttpClientFactory policy configuration,
// limiting concurrent calls to the external service, thus protecting internal resources (threads).
var response = await _httpClient.GetAsync("/inventory");
response.EnsureSuccessStatusCode();
var jsonString = await response.Content.ReadAsStringAsync();
data = JsonSerializer.Deserialize<Dictionary<string, int>>(jsonString);
// Distributed Caching: Store fresh data in cache.
// This absorbs load bursts and improves response times for subsequent requests.
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(data), new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) // Cache for 5 minutes
});
_logger.LogInformation($"Successfully fetched data from external service and updated cache for key: {cacheKey}");
}
catch (BrokenCircuitException ex)
{
// Fallback to Cache: When the circuit breaker is open, attempt to serve from cache.
// This provides graceful degradation, allowing the application to function with stale data
// during external service downtime.
_logger.LogWarning($"Circuit breaker is open. Falling back to cache for key: {cacheKey}. Exception: {ex.Message}");
var cachedJson = await _cache.GetStringAsync(cacheKey);
if (!string.IsNullOrEmpty(cachedJson))
{
data = JsonSerializer.Deserialize<Dictionary<string, int>>(cachedJson);
_logger.LogInformation($"Served data from cache for key: {cacheKey}");
}
else
{
// Cache Invalidation: If the circuit is open and cache is empty/stale (due to
// a configured duration after which it gets invalidated), this indicates
// no fresh data is available. This prevents serving indefinitely stale data.
_logger.LogWarning($"Cache empty for key: {cacheKey} while circuit is open. Cannot serve stale data.");
throw; // Or return an empty dictionary, depending on desired behavior
}
}
catch (TimeoutRejectedException ex)
{
_logger.LogError($"Request timed out. Falling back to cache for key: {cacheKey}. Exception: {ex.Message}");
var cachedJson = await _cache.GetStringAsync(cacheKey);
if (!string.IsNullOrEmpty(cachedJson))
{
data = JsonSerializer.Deserialize<Dictionary<string, int>>(cachedJson);
_logger.LogInformation($"Served data from cache for key: {cacheKey}");
}
else
{
_logger.LogWarning($"Cache empty for key: {cacheKey} during timeout. Cannot serve stale data.");
throw;
}
}
catch (Exception ex)
{
_logger.LogError($"An error occurred while fetching inventory data: {ex.Message}");
// General error handling, could also fall back to cache here if desired
throw;
}
return data ?? new Dictionary<string, int>();
}
}HttpClientFactory مسئول ایجاد و مدیریت نمونههای HttpClient است که به بهبود عملکرد و قابلیت اطمینان کمک میکند. Polly برای پیادهسازی الگوهای تابآوری مانند مدارشکن (Circuit Breaker) و بالکهد (Bulkhead) استفاده میشود. مدارشکن از ارسال درخواستهای بیشتر به سرویسهای ناسالم جلوگیری میکند، در حالی که بالکهد تعداد درخواستهای همزمان را محدود میکند تا از اشباع منابع جلوگیری شود. کش توزیعشده (Distributed Cache) برای ذخیرهسازی دادهها و افزایش سرعت پاسخگویی به کار میرود، و در صورت باز بودن مدارشکن، به عنوان منبع پشتیبان عمل میکند. ابطال کش (Cache Invalidation) نیز تضمین میکند که دادههای منقضی شده سرو نشوند. این الگوها همگی به افزایش پایداری و تابآوری سیستم در مواجهه با خطاهای خارجی کمک میکنند.