عنوان:

‫برنامه‌نویسی با الهام از هوش مصنوعی: ساخت کلاینت‌های HTTP تاب‌آور در دات‌نت


نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۳/۱۹ ۰۷:۴۹
آدرس: www.dntips.ir
امروزه، ابزارهای هوش مصنوعی (AI) به عنوان دستیاران قدرتمند برنامه‌نویسی ظهور کرده‌اند و می‌توانند به توسعه‌دهندگان کمک کنند تا وظایف خود را سریع‌تر و با کیفیت بهتری به انجام برسانند. این مقاله به بررسی چگونگی استفاده از تکنیک "برنامه‌نویسی الهام‌گرفته از هوش مصنوعی" یا همان "Vibe Coding" می‌پردازد و نشان می‌دهد چگونه می‌توان با تکیه بر چارچوب چرخه حیات توسعه نرم‌افزار (SDLC)، کلاینت‌های HTTP مقاوم و با قابلیت تحمل خطا (Fault-Tolerant) در محیط .NET Core ساخت.

چارچوب توسعه نرم‌افزار با دستیار هوش مصنوعی
چرخه حیات توسعه نرم‌افزار (Software Development Life Cycle - SDLC) شامل فازهای کلیدی مانند برنامه‌ریزی (Planning)، نیازمندی‌ها (Requirements)، طراحی (Design)، توسعه (Development)، تست (Testing)، استقرار (Deployment) و نگهداری (Maintenance) است. برای استفاده مؤثر از هوش مصنوعی در برنامه‌نویسی، پیروی از یک رویکرد ساختاریافته در این فازها ضروری است. این رویکرد به ما کمک می‌کند تا گام‌های لازم برای رسیدن به نتیجه نهایی را شناسایی کرده و دستورات (Prompts) مورد نیاز برای هوش مصنوعی را به ترتیب منطقی سازمان‌دهی کنیم. در این مقاله، ما به طور خاص بر روی فازهای نیازمندی‌ها، طراحی و توسعه تمرکز خواهیم کرد.

فاز اول: درک نیازمندی‌ها و تعریف داستان کاربری
اولین گام در هر پروژه نرم‌افزاری، درک دقیق نیازمندی‌ها است. تصور کنید در حال توسعه یک پلتفرم تجارت الکترونیک (E-commerce) هستید که باید موجودی کالا را در زمان واقعی از یک سیستم انبار خارجی دریافت و نمایش دهد. مالک محصول (Product Owner) از شما می‌خواهد که: "یکپارچگی جدیدی برای موجودی بلادرنگ با سیستم انبار خارجی موز (Banana Warehouse) ایجاد کنیم. باید قابلیت نمایش بیش از ۱۰۰ موز در هر صفحه را به صورت رندر شده در سمت سرور (Server-Side Rendered) داشته باشیم. همچنین، این قابلیت باید سریع، قابل اعتماد و حتی کمی شوخ‌طبعانه باشد."
در این مرحله، باید یک داستان کاربری (User Story) واضح با معیارهای پذیرش (Acceptance Criteria) مشخص ایجاد کنیم. معیارهای پذیرش باید بر اساس قالب "قاعده‌مدار" (Rule-Oriented Format) و فقط شامل نیازمندی‌های تابعی (Functional Requirements) باشند؛ یعنی بر رفتارها، خروجی‌ها و پاسخ‌های سیستم به اقدامات کاربر یا سیستم تمرکز کنند. نیازمندی‌های غیرتابعی (Non-functional Requirements) مانند زمان پاسخ، زمان در دسترس بودن سیستم یا مقیاس‌پذیری باید حذف شوند. همچنین، تمامی معیارها باید از نظر تابعی قابل تست باشند.

نمونه داستان کاربری:
عنوان: نمایش موجودی بلادرنگ موز از انبار خارجی
به عنوان یک [کاربر یا سیستم مرتبط],
می‌خواهم [آنچه کاربر می‌خواهد انجام دهد],
تا بتوانم [منفعت یا نتیجه حاصل].

معیارهای پذیرش (قالب قاعده‌مدار – فقط تابعی):
  • سیستم باید موجودی موز را از سیستم انبار خارجی دریافت کند.
  • سیستم باید بیش از ۱۰۰ آیتم موجودی موز را در هر صفحه نمایش دهد.
  • سیستم باید محتوای موجودی موز را در سمت سرور رندر کند.
  • سیستم باید وضعیت موجودی را برای هر SKU موز (Stock Keeping Unit - واحد نگهداری موجودی) به درستی نمایش دهد (موجود، ناموجود، نامعلوم).

با تعریف این داستان کاربری، می‌توانیم یک جلسه چت جدید با دستیار هوش مصنوعی آغاز کرده و این داستان را به عنوان بستر (Context) برای ادامه کار تنظیم کنیم.

فاز دوم: طراحی معماری سیستم
پس از تعریف نیازمندی‌ها، نوبت به طراحی معماری می‌رسد. با استفاده از داستان کاربری به عنوان مرجع، می‌توانیم از دستیار هوش مصنوعی برای طراحی نقاط پایانی (Endpoints) REST API، اعمال بهترین شیوه‌های معماری و الگوها، و تولید موارد تست (Test Cases) معنادار برای اعتبارسنجی رفتار سیستم کمک بگیریم.

مثال: تولید موارد تست تابعی:
با استفاده از داستان کاربری موجودی موز، می‌توانیم از دستیار هوش مصنوعی بخواهیم موارد تست تابعی را در قالب "Given/When/Then" (با فرض/هنگامی که/پس از آن) برای سناریوهای مختلف پوشش دهد:
  • موجود (Available)
  • ناموجود (Unavailable)
  • نامعلوم (Unknown)
  • خطاهای جزئی (Partial Errors)
  • صفحه‌بندی (Pagination)
  • SKUs موز گمشده (Missing Banana SKUs)

این رویکرد به ما کمک می‌کند تا معماری سیستم را به گونه‌ای طراحی کنیم که نیازمندی‌ها را به طور کامل پوشش دهد و در عین حال، به مسائل احتمالی مانند ناسازگاری داده‌ها، محدودیت‌های صفحه‌بندی و مدیریت خطاها برای پاسخ‌های جزئی از سیستم‌های خارجی، توجه داشته باشیم.

فاز سوم: توسعه و پیاده‌سازی کلاینت HTTP تاب‌آور
در فاز توسعه، به پیاده‌سازی راه‌حل می‌پردازیم. دستیار هوش مصنوعی می‌تواند بهترین شیوه‌ها را برای واکشی داده‌ها از بک‌اند ASP.NET Core به فرانت‌اند رندر شده در سمت سرور (با استفاده از ReactJS برای بارگذاری ناهم‌زمان محتوا) ارائه دهد. این شیوه‌ها با هدف دستیابی به تأخیر کم (Low Latency) و دسترسی‌پذیری بالا (High Availability) پیشنهاد می‌شوند.

بهترین شیوه‌ها برای واکشی داده‌ها در سمت سرور:
  • استفاده از درخواست‌های دسته‌ای (Batched SKU-based Requests): برای کاهش تعداد فراخوانی‌های شبکه خارجی.
  • به تعویق انداختن بازیابی داده‌های در دسترس بودن (Defer Availability Data Retrieval): تا پس از فاز هیدراسیون (Hydration) React، با استفاده از یک نقطه پایانی /api/availability اختصاصی.
  • حفظ سرعت و مقاومت رندر سمت سرور (SSR): با انتقال بارگذاری داده‌های بلادرنگ به سمت کلاینت.
  • پیاده‌سازی تخریب تدریجی (Graceful Degradation): برای جلوگیری از مسدود شدن رندر صفحه.

کلاینت HTTP مقاوم و با قابلیت تحمل خطا:
برای ساخت یک کلاینت HTTP مقاوم و قابل اعتماد، می‌توان از HttpClientFactory در کنار کتابخانه Polly در دات‌نت استفاده کرد. Polly یک کتابخانه انعطاف‌پذیری (Resilience) و تحمل خطای دات‌نت است که به توسعه‌دهندگان امکان می‌دهد استراتژی‌های مختلفی را برای مدیریت خطاهای موقتی (Transient Errors) و افزایش تاب‌آوری سیستم پیاده‌سازی کنند.

استراتژی‌های تاب‌آوری با Polly:
  • تلاش مجدد (Retry): برای خطاهای موقتی که ممکن است با تکرار عملیات برطرف شوند.
  • مدارشکن (Circuit Breaker): برای جلوگیری از اضافه بار سیستم هنگامی که سرویس خارجی غیرقابل دسترس می‌شود. این الگو از ارسال درخواست‌های بیشتر به یک سرویس ناسالم جلوگیری می‌کند.
  • مهلت زمانی (Timeout): برای کنترل فراخوانی‌های طولانی‌مدت و جلوگیری از آویزان شدن برنامه.
  • ایزولاسیون بالک‌هد (Bulkhead Isolation): برای محدود کردن تعداد درخواست‌های هم‌زمان (Concurrent Requests) و جلوگیری از اتمام رشته‌ها (Thread Exhaustion) و ایزولاسیون خطاها.
  • کش کوتاه‌مدت (Short-lived Caching): برای جذب بارهای ناگهانی (Load Bursts) و در عین حال حفظ تازگی داده‌ها.

با در نظر گرفتن این بهترین شیوه‌ها، می‌توانیم یک کلاس InventoryClient را در C# برای مدیریت درخواست‌های HTTP در یک سیستم با بار بالا پیاده‌سازی کنیم که الزامات زیر را برآورده کند:
  • یکپارچگی با HttpClientFactory: برای ایجاد نمونه‌های HttpClient به منظور ارتباط با سیستم خارجی.
  • کش توزیع‌شده (Distributed Caching): برای ذخیره و بازیابی کارآمد داده‌ها (با استفاده از IDistributedCache و گزینه InMemory برای تست).
  • الگوی مدارشکن (Circuit Breaker Pattern): با استفاده از Polly برای نظارت بر در دسترس بودن سیستم خارجی. هنگامی که سیستم خارجی غیرقابل پاسخ می‌شود، مدار باید باز شود تا از فراخوانی‌های بیشتر جلوگیری شود.
  • بازگشت به کش (Fallback to Cache): هنگامی که مدارشکن باز است، کلاینت باید پاسخ‌ها را از کش توزیع‌شده ارائه دهد و از برقراری تماس با سیستم خارجی تا زمان سالم شدن مجدد آن خودداری کند.
  • ابطال کش (Cache Invalidation): اگر سیستم خارجی برای مدت زمان پیکربندی شده‌ای غیرقابل پاسخ بماند، داده‌های کش شده باطل شوند تا از ارائه پاسخ‌های منقضی شده جلوگیری شود.
  • ایزولاسیون بالک‌هد (Bulkhead Isolation): با استفاده از Polly برای محدود کردن تعداد درخواست‌های هم‌زمان و ایزولاسیون خطاها.
  • استفاده از WireMock.Net: برای شبیه‌سازی (Mock) سیستم خارجی در تست‌ها.

نمونه کد (مثال):
// 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) نیز تضمین می‌کند که داده‌های منقضی شده سرو نشوند. این الگوها همگی به افزایش پایداری و تاب‌آوری سیستم در مواجهه با خطاهای خارجی کمک می‌کنند.


خودآزمایی
کدام یک از الگوهای زیر در صورت باز بودن مدارشکن، به کلاینت HTTP اجازه می‌دهد تا داده‌ها را از یک منبع جایگزین سرو کند؟
الف) Retry
ب) Bulkhead Isolation
ج) Fallback to Cache
د) Timeout


نتیجه‌گیری
در این مسیر، ما نشان دادیم که چگونه می‌توان یک نیازمندی محصول مبهم را به یک داستان کاربری واضح و قابل تست تبدیل کرد، بهترین شیوه‌ها را برای ارتباطات HTTP در محیط‌های با بار بالا بررسی کرد، و از هوش مصنوعی به عنوان یک همکار کدنویسی مؤثر استفاده کرد. با همسویی با فازهای SDLC و اعمال الگوهای تاب‌آوری معماری، ما نه تنها کد، بلکه یک راه‌حل مقیاس‌پذیر و قابل اعتماد را توسعه دادیم.
چه در حال نوشتن کد در دفتر باشید و چه در حال حل مشکلات در محیط تولید، به یاد داشته باشید که:
  • با یک داستان کاربری خوب آغاز کنید.
  • هوش مصنوعی را با دستورات هوشمندانه راهنمایی کنید.
  • شیوه‌های مهندسی اثبات شده را به کار ببرید.
  • از تکرار و بهبود تا رسیدن به نتیجه مطلوب نترسید.


پاسخ خودآزمایی
ج) Fallback to Cache


مشاهده مطلب اصلی