Levelwise
فارسی
طراحی کد

الگوهای طراحی (Design Patterns)

الگوی طراحی یک راه حل شناخته‌شده برای یک مشکل تکراری است. در این درس چهار الگوی پرکاربرد در .NET را با یک فروشگاه اینترنتی می‌بینی - Strategy، Decorator، Factory و Mediator. مهم‌تر از اسم الگو، شناختن مشکلی است که حل می‌کند.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۸ دقیقهمثال پرداخت و کاتالوگ فروشگاه اینترنتیکد C# و .NET 10

نویسنده: bezzad

الگو یعنی چه؟

یک برنامه‌نویس با تجربه، مشکل‌ها را می‌شناسد. می‌گوید «این همان مشکل همیشگی است. راه حلش این است.» الگوی طراحی همین تجربه است که یک اسم گرفته است.

اسم مشترک خیلی مفید است. در code review می‌گویی «اینجا یک Decorator بگذار.» هم‌تیمی‌ات در یک ثانیه می‌فهمد منظورت چه ساختاری است.

معروف‌ترین فهرست الگوها در کتاب Design Patterns آمده است. چهار نویسنده آن را در سال ۱۹۹۴ نوشتند و به آن‌ها «گروه چهار نفره» (Gang of Four) می‌گویند. کتاب ۲۳ الگو را در سه دسته آورده است:

دسته سؤال اصلی نمونه در این درس
ساختن (Creational) شیء را چه کسی و چطور بسازد؟ Factory
ساختار (Structural) کلاس‌ها را چطور کنار هم بگذاریم؟ Decorator
رفتار (Behavioral) کلاس‌ها چطور با هم کار کنند؟ Strategy، Mediator
اول مشکل، بعد الگو. الگو را برای نشان دادن دانش خودت استفاده نکن. اگر مشکلی که الگو حل می‌کند در کد تو نیست، الگو فقط پیچیدگی اضافه است.

الگوی Strategy: یک کار، چند روش

فروشگاه ما سه درگاه پرداخت دارد: بانک A، بانک B و کیف پول. کد اول این شکل را دارد:

switch (provider)
{
    case "bank-a": /* get a token, then pay */ break;
    case "bank-b": /* pay in Rials, not Tomans */ break;
    case "wallet": /* charge the customer wallet */ break;
}

تیم محصول می‌گوید چند درگاه دیگر هم در راه است. با این کد چه اتفاقی می‌افتد؟

  1. هر درگاه جدید یعنی تغییر همین کلاس. کد تست‌شده دوباره باز می‌شود.
  2. کلاس مدام بزرگ‌تر می‌شود. هر درگاه یک وابستگی جدید به سازنده اضافه می‌کند.
  3. تغییر یک درگاه، درگاه دیگر را خراب می‌کند. همه در یک فایل هستند.
  4. همین switch در جاهای دیگر تکرار می‌شود. مثلاً در برگشت پول و استعلام وضعیت.

الگوی Strategy می‌گوید: هر روش را در یک کلاس جدا بگذار. همه یک اینترفیس مشترک دارند. استفاده‌کننده فقط اینترفیس را می‌شناسد.

کاربر انتخاب کرد"bank-b"PaymentServiceIPaymentProviderBankAProviderBankBProviderWalletProviderدرگاه جدید؟فقط یک کلاس جدیدسرویس پرداخت عوض نمی‌شود
سرویس پرداخت نمی‌داند بانک B چطور کار می‌کند. فقط می‌داند یک IPaymentProvider دارد.
public interface IPaymentProvider
{
    string Key { get; }
    Task<PaymentResult> PayAsync(Order order, CancellationToken ct);
}

public sealed class BankBProvider(BankBClient client) : IPaymentProvider
{
    public string Key => "bank-b";

    // Bank B works with Rials, so the difference stays inside this class.
    public Task<PaymentResult> PayAsync(Order order, CancellationToken ct) =>
        client.PayAsync(order.Id, order.AmountInTomans * 10, ct);
}

public sealed class PaymentService(IEnumerable<IPaymentProvider> providers)
{
    private readonly Dictionary<string, IPaymentProvider> _byKey =
        providers.ToDictionary(p => p.Key);

    public Task<PaymentResult> PayAsync(Order order, string key, CancellationToken ct) =>
        _byKey.TryGetValue(key, out var provider)
            ? provider.PayAsync(order, ct)
            : throw new NotSupportedException($"Unknown payment provider: {key}");
}

همه درگاه‌ها در DI ثبت می‌شوند. سرویس پرداخت همه را با هم، به شکل یک لیست، می‌گیرد:

builder.Services.AddScoped<IPaymentProvider, BankAProvider>();
builder.Services.AddScoped<IPaymentProvider, BankBProvider>();
builder.Services.AddScoped<IPaymentProvider, WalletProvider>();
builder.Services.AddScoped<PaymentService>();
  • درگاه جدید: یک کلاس و یک خط ثبت. هیچ کد قبلی عوض نمی‌شود. این همان اصل باز-بسته در درس SOLID است.
  • تست: هر درگاه جدا و فقط با وابستگی خودش تست می‌شود.
  • تفاوت‌ها: تبدیل تومان به ریال فقط داخل کلاس بانک B است.
کی همان switch کافی است؟ وقتی فقط دو یا سه حالت ثابت داری، هر کدام یکی دو خط است و قرار نیست حالت جدیدی بیاید. در این وضعیت، اینترفیس و چند کلاس فقط کد را طولانی‌تر می‌کنند.

الگوی Factory: ساختن را به یک جا بسپار

در Strategy، درگاه را با کلید انتخاب کردیم. ولی اگر ساختن یک شیء کمی پیچیده باشد، یا بخواهیم کد بقیه برنامه اصلاً با DI درگیر نشود، از Factory استفاده می‌کنیم. Factory یک کلاس است که تصمیم می‌گیرد چه شیئی ساخته شود و آن را برمی‌گرداند.

از .NET 8 به بعد، DI خودش ثبت با کلید (Keyed Services) را دارد. یک Factory ساده روی آن:

builder.Services.AddKeyedScoped<IPaymentProvider, BankAProvider>("bank-a");
builder.Services.AddKeyedScoped<IPaymentProvider, BankBProvider>("bank-b");
builder.Services.AddScoped<PaymentProviderFactory>();

public sealed class PaymentProviderFactory(IServiceProvider services)
{
    public IPaymentProvider Get(string key) =>
        services.GetKeyedService<IPaymentProvider>(key)
            ?? throw new NotSupportedException($"Unknown payment provider: {key}");
}
  • بقیه کد فقط با PaymentProviderFactory کار می‌کند. نمی‌داند پشت آن DI است یا یک دیکشنری.
  • کلید از ورودی کاربر می‌آید. پس حالت «کلید ناشناخته» را حتماً با یک خطای روشن مدیریت کن.
نمونه آشنا در .NET: کلاس IHttpClientFactory یک Factory است. ساختن HttpClient و مدیریت اتصال‌های آن را به عهده می‌گیرد. کد تو فقط می‌گوید «یک HttpClient برای این اسم بده».

الگوی Decorator: رفتار اضافه، بدون تغییر کلاس اصلی

کاتالوگ فروشگاه یک IProductRepository دارد که با EF Core کار می‌کند. حالا دو چیز لازم داریم: کش و لاگ. راه ساده این است که هر دو را داخل همان کلاس بنویسیم. ولی نتیجه این است:

  1. هر متد سه کار دارد. دسترسی به داده، کش و لاگ. این خلاف اصل تک‌مسئولیتی است.
  2. منطق کش در همه متدها تکرار می‌شود. تغییر کلید کش یعنی تغییر ده متد.
  3. تست دسترسی به داده بدون کش ممکن نیست.

الگوی Decorator می‌گوید: یک کلاس جدید بساز که همان اینترفیس را دارد و کلاس اصلی را در خودش نگه می‌دارد. کار اضافه را انجام می‌دهد و بعد کار را به کلاس داخلی می‌دهد.

کنترلر محصولLoggingProductRepositoryلاگ و زمان هر درخواستCachedProductRepositoryاگر در کش بود، همین‌جا جواب بدهEfProductRepositoryفقط کار با دیتابیسهر لایه همان اینترفیس را داردو لایه داخلی را صدا می‌زند
مثل عروسک‌های تو در تو. استفاده‌کننده نمی‌داند چند لایه وجود دارد.
public sealed class CachedProductRepository(
    IProductRepository inner, HybridCache cache) : IProductRepository
{
    public async Task<Product?> GetByIdAsync(int id, CancellationToken ct) =>
        await cache.GetOrCreateAsync(
            $"product:{id}",
            async token => await inner.GetByIdAsync(id, token),
            cancellationToken: ct);
}

کلاس EfProductRepository حالا فقط با دیتابیس کار می‌کند. کلاس کش فقط کش را می‌شناسد. لاگ هم یک Decorator جدا است.

ثبت در DI با کتابخانه Scrutor ساده است:

builder.Services.AddHybridCache();
builder.Services.AddScoped<IProductRepository, EfProductRepository>();
builder.Services.Decorate<IProductRepository, CachedProductRepository>();
builder.Services.Decorate<IProductRepository, LoggingProductRepository>();

در Scrutor، آخرین Decorate بیرونی‌ترین لایه است. پس درخواست اول به لاگ می‌رسد، بعد کش، بعد EF Core.

ترتیب لایه‌ها مهم است

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

مثال زنده: ترتیب Decorator ها
درخواست‌های لاگ‌شده: ۰کوئری‌های دیتابیس: ۰
    • لاگ بیرون، کش داخل. همه درخواست‌ها لاگ می‌شوند. زمانی که کاربر واقعاً می‌بیند را اندازه می‌گیری.
    • کش بیرون، لاگ داخل. فقط درخواست‌هایی لاگ می‌شوند که از کش رد شده‌اند. برای دیدن بار روی دیتابیس مفید است.
    • قاعده کلی. اول تصمیم بگیر هر لایه چه چیزی را باید ببیند. بعد ترتیب را بچین.
    نمونه آشنا در .NET: کلاس DelegatingHandler برای HttpClient همین ایده را دارد. هر Handler کار خودش را انجام می‌دهد و درخواست را به Handler داخلی می‌دهد. Middleware های ASP.NET Core هم زنجیره‌ای شبیه همین هستند.

    الگوی Mediator: فرستنده، گیرنده را نشناسد

    کنترلر سفارش‌ها کم‌کم شش یا هفت سرویس در سازنده گرفته است. هر action فقط به دو تا از آن‌ها نیاز دارد، ولی برای ساختن کنترلر همه ساخته می‌شوند.

    الگوی Mediator یک میانجی وسط می‌گذارد. کنترلر فقط یک پیام می‌فرستد: «سفارش را ثبت کن». میانجی پیام را به کلاسی می‌دهد که مسئول همان کار است. به این کلاس Handler می‌گوییم.

    قبل: کنترلر همه را می‌شناسدOrdersControllerسفارشمشتریانبارپرداختتخفیفاعلانشش وابستگی، حتی برای یک کار سادهبعد: کنترلر فقط یک پیام می‌فرستدOrdersControllerSend(PlaceOrder)IMediatorPlaceOrderHandlerPayOrderHandlerCancelOrderHandlerهر کلاس فقط وابستگی‌های کار خودش را دارد
    public sealed record PlaceOrder(Guid CartId) : IRequest<Guid>;
    
    app.MapPost("/orders", async (PlaceOrder command, IMediator mediator, CancellationToken ct) =>
        TypedResults.Ok(await mediator.Send(command, ct)));
    • سود: هر Handler کوچک است و فقط وابستگی‌های خودش را دارد. کارهای مشترک، مثل اعتبارسنجی و لاگ، یک بار دور همه Handler ها نوشته می‌شوند.
    • هزینه: یک لایه غیرمستقیم اضافه می‌شود. با «رفتن به تعریف» در IDE به Handler نمی‌رسی. وابستگی‌ها هم پشت IMediator پنهان می‌شوند.
    شلوغی را پنهان نکن. اگر کنترلر هفت وابستگی دارد، شاید مشکل طراحی است. شاید کنترلر باید به چند کنترلر کوچک‌تر تقسیم شود. Mediator این مشکل را حل نمی‌کند، فقط آن را پنهان می‌کند. جزئیات کامل‌تر، کتابخانه MediatR و لایسنس آن در درس CQRS و Mediator آمده است.

    چند الگوی دیگر که هر روز می‌بینی

    الگو مشکل نمونه در فروشگاه یا .NET
    Adapter یک کتابخانه بیرونی اینترفیس دیگری دارد. کلاسی که SDK بانک را به IPaymentProvider ما تبدیل می‌کند.
    Observer چند بخش باید از یک اتفاق باخبر شوند. رویداد «سفارش ثبت شد» و گیرنده‌هایش. درس DDD را ببین.
    Builder ساختن یک شیء چند قدم و تنظیم دارد. ساختن برنامه با WebApplication و متد CreateBuilder.
    Singleton فقط یک نمونه از یک کلاس لازم است. در .NET به جای کلاس static، با ثبت Singleton در DI.

    اشتباه‌های رایج

    اشتباه چرا بد است؟ راه درست
    الگو برای مشکلی که وجود ندارد کد سنگین و سخت‌فهم می‌شود، بدون سود. اول مشکل را ببین، بعد الگو را انتخاب کن.
    Strategy برای دو حالت ثابت چند کلاس و اینترفیس، به جای یک if ساده. تا وقتی حالت‌ها زیاد نشده‌اند، همان if یا switch.
    کش و لاگ داخل کلاس اصلی چند مسئولیت در یک کلاس و تکرار در همه متدها. هر کار اضافه در یک Decorator جدا.
    ترتیب Decorator ها بدون فکر لاگ یا retry چیزی را می‌بیند یا نمی‌بیند که انتظار نداری. برای هر لایه بپرس: چه درخواستی باید به من برسد؟
    Handler هایی که همدیگر را با Send صدا می‌زنند جریان کد گم می‌شود و دنبال کردنش سخت است. منطق مشترک را در یک کلاس عادی بگذار، نه در Handler دیگر.
    ساختن Singleton با کلاس static تست سخت می‌شود و وابستگی پنهان است. ثبت با طول عمر Singleton در DI.

    چه وقت الگو؟

    مناسب

    • یک تغییر از یک نوع، بارها تکرار شده است.
    • کلاس به خاطر چند کار مختلف بزرگ شده است.
    • تیم اسم الگو را می‌شناسد و با آن راحت‌تر حرف می‌زند.
    • خود .NET همان الگو را دارد و می‌توانی از آن استفاده کنی.

    نامناسب

    • «شاید روزی لازم شود.»
    • فقط یک پیاده‌سازی وجود دارد و قرار نیست بیشتر شود.
    • هم‌تیمی‌ها برای فهمیدن یک کار ساده باید پنج فایل را باز کنند.
    • الگو فقط برای این است که کد «حرفه‌ای» به نظر برسد.

    خلاصه در شش خط

    1. الگوی طراحی، اسم یک راه حل شناخته‌شده برای یک مشکل تکراری است.
    2. با Strategy، هر روش در یک کلاس جدا است و روش جدید کد قبلی را عوض نمی‌کند.
    3. با Factory، تصمیم «کدام شیء ساخته شود» یک جا جمع می‌شود.
    4. با Decorator، کار اضافه مثل کش و لاگ دور کلاس اصلی پیچیده می‌شود. ترتیب لایه‌ها مهم است.
    5. با Mediator، فرستنده گیرنده را نمی‌شناسد. ولی این الگو جای طراحی خوب را نمی‌گیرد.
    6. اول مشکل را ببین. اگر مشکل نیست، الگو هم لازم نیست.