بناء Microservices باستخدام C# و .NET

بناء Microservices باستخدام C# و .NET

عندما يبدأ الحديث عن Microservices، غالبًا ما يتجه الذهن مباشرة إلى المصطلحات الكبيرة: المرونة، القابلية للتوسع، الاستقلالية، النشر المنفصل، والاعتمادية العالية. لكن الحقيقة التي يكتشفها المطور بعد أول مشروع حقيقي ليست بهذه الرومانسية. Microservices ليست مجرد تقسيم تطبيق كبير إلى أجزاء أصغر، وليست وصفة سحرية تحل كل المشاكل، وليست قرارًا معماريًا يُتخذ لأن الفكرة تبدو “حديثة” أو لأن الجميع يتحدث عنها. Microservices هي أسلوب تفكير كامل في بناء الأنظمة، يبدأ من فهم حدود العمل نفسها، ويمر عبر توزيع المسؤوليات، وينتهي بتصميم يتقبل التغيرات ويمنح الفرق مساحة للعمل دون أن تتصادم كل خطوة مع الأخرى. وعندما تُبنى هذه المعمارية باستخدام C# و .NET، تصبح لديك بيئة ناضجة ومتماسكة تساعدك على تحويل الأفكار إلى خدمات حقيقية قابلة للتطوير، سواء كان المشروع متجرًا إلكترونيًا، منصة تعليمية، نظام حجوزات، أو بنية مؤسسية ضخمة تضم عشرات الخدمات.

الجميل في .NET أنه لا يجبرك على أسلوب واحد، بل يمنحك مجموعة أدوات متوازنة. يمكنك بناء REST APIs بسهولة، واستخدام gRPC عند الحاجة إلى الأداء العالي والاتصال الداخلي السريع، وربط الخدمات مع الرسائل غير المتزامنة عبر RabbitMQ أو Kafka، وحماية النظام بـ ASP.NET Core Identity أو JWT أو OAuth2/OpenID Connect، وتغليف كل خدمة داخل Docker، ثم نشرها وإدارتها على Kubernetes. والأهم من ذلك أن C# نفسها تطورت إلى لغة واضحة وقوية، تسمح بكتابة كود نظيف ومفهوم وقابل للاختبار. هذا مهم جدًا لأن Microservices لا تزداد نجاحًا بالعدد، بل بجودة الحدود بين الخدمات، ووضوح العقود، وانضباط التنفيذ، وصدقك مع نفسك عندما تقرر ما الذي يستحق أن يصبح خدمة مستقلة وما الذي لا يزال أفضل كجزء من وحدة واحدة.

ما هي Microservices فعلًا؟

الفكرة الأساسية بسيطة في ظاهرها: بدلاً من بناء تطبيق ضخم واحد يضم كل شيء، نقسم النظام إلى خدمات صغيرة، وكل خدمة تكون مسؤولة عن جزء محدد جدًا من المجال Domain. خدمة المستخدمين قد تتولى التسجيل وتسجيل الدخول والملفات الشخصية. خدمة الطلبات قد تدير دورة حياة الطلب. خدمة الدفع قد تتعامل مع المعاملات والبوابات المالية. خدمة الإشعارات قد ترسل البريد والرسائل والتنبيهات. وخدمة المخزون قد تتتبع المنتجات والكميات والتحديثات. كل خدمة تُنشر بشكل مستقل، وتملك منطقها الخاص، وقد تملك قاعدة بياناتها الخاصة، وتتكلم مع الخدمات الأخرى عبر واجهات واضحة أو عبر رسائل وأحداث.

لكن وراء هذه الفكرة البسيطة توجد مفاهيم دقيقة جدًا. أولها أن الخدمة ليست مجرد “module” صغير داخل نفس التطبيق؛ هي وحدة مستقلة فعلًا، يمكن تشغيلها، اختبارها، نشرها، ومراقبتها بشكل منفصل. ثانيها أن استقلالية الخدمة لا تعني العزلة الكاملة؛ فهي ما تزال بحاجة إلى التعاون مع بقية الخدمات عبر حدود محسوبة. ثالثها أن نجاح Microservices يعتمد على حسن تعريف الحدود وليس على عدد الخدمات. مشروع فيه خمس خدمات جيدة أفضل بكثير من مشروع فيه خمسون خدمة ضعيفة ومتشابكة.

هنا تظهر قيمة التفكير في Domain-Driven Design. بدل أن تبدأ من الجداول أو من طبقة الـ API، تبدأ من المجال نفسه: ما هي الكيانات المهمة؟ ما هي الأحداث؟ ما هي القواعد؟ ما هي الحدود الطبيعية بين أجزاء النظام؟ عندما تفهم المجال، يصبح تقسيم الخدمات أقل عشوائية وأكثر واقعية. وهذه الواقعية هي التي تمنعك من الوقوع في فخ “تفكيك المونوليث إلى فوضى موزعة”.

لماذا C# و .NET مناسبين جدًا لهذا النمط؟

هناك عدة أسباب تجعل C# و .NET اختيارًا قويًا لبناء Microservices. أولها أن ASP.NET Core خفيف وسريع ومرن، وهو مناسب جدًا لبناء APIs صغيرة ومركزة. ثانيًا، أدوات الإنتاجية في .NET ممتازة: Dependency Injection مدمجة، Logging منظم، Configuration متقدم، خيارات اختبار قوية، ودعم رائع للتطوير المحلي داخل Docker. ثالثًا، Ecosystem .NET غني بمكتبات قوية للبنية التحتية مثل MassTransit، MediatR، Polly، Serilog، OpenTelemetry، وأدوات قواعد البيانات مثل Entity Framework Core و Dapper. رابعًا، اللغة نفسها تساعدك على بناء شيفرة قابلة للفهم والصيانة، خصوصًا مع ميزات مثل Records، Nullable Reference Types، pattern matching، async/await، وLINQ.

هناك أيضًا ميزة لا يتحدث عنها الناس كثيرًا: الاتساق. في بعض البيئات، عندما تُبنى الخدمات بلغات متعددة بشكل عشوائي، يبدأ الفريق في إنفاق الوقت على اختلاف الأساليب أكثر من إنفاقه على تطوير المنتج. أما في .NET، فهناك درجة من التوحيد تجعل الانتقال بين الخدمات أسهل، وتقلل الكلفة الذهنية على الفريق. هذا لا يعني أن اللغة الواحدة تحل كل شيء، لكنه يعني أن التنظيم يصبح أبسط عندما يكون لديك إطار واضح ومجتمع ناضج.

متى تحتاج Microservices ومتى لا تحتاجها؟

هذه نقطة مهمة جدًا. كثير من المشاريع تبدأ بالمونوليث ثم تسمع الضجيج حول Microservices فتقرر القفز مباشرة إلى البنية الموزعة، ثم تكتشف أن الوقت ضاع في تعقيد لا ضرورة له. Microservices ليست الخيار الأول دائمًا. إذا كان المشروع صغيرًا، والفريق صغيرًا، والمتطلبات ما تزال تتغير بسرعة، فقد يكون المونوليث المنظم Modular Monolith أفضل بكثير. لماذا؟ لأنه أسرع في البناء، أسهل في الاختبار، أقل كلفة في النشر، وأقل تعقيدًا في المراقبة.

تحتاج Microservices عندما يصبح عندك واحد أو أكثر من هذه العوامل: فرق متعددة تعمل بالتوازي، أجزاء من النظام تحتاج إلى التوسع بشكل مستقل، حدود أعمال واضحة جدًا، متطلبات نشر مختلفة، أو ضغوط عالية على بعض الوظائف مقارنة بغيرها. في هذه الحالات، يمكن أن تمنحك Microservices مرونة حقيقية. أما إذا لم تكن هذه الأسباب موجودة، فغالبًا أنت تدفع ثمن التعقيد قبل أن تحصل على الفائدة.

هذه الحقيقة ليست ضد Microservices، بل لصالحها؛ لأنها تمنعك من استخدامها في المكان الخطأ. المعمارية الصحيحة ليست تلك التي تبدو مبهرة في الشرائح التقديمية، بل تلك التي تخدم الواقع العملي للمشروع.

كيف نحدد حدود الخدمات؟

أفضل طريقة هي أن تبدأ من المجال، لا من التقنية. اسأل: ما العمليات التي تتغير معًا؟ ما البيانات التي تنتمي إلى نفس المعنى التجاري؟ ما الذي يمكن أن يملك دورة حياة مستقلة؟ وما الذي يجب أن يبقى قريبًا من بعضه؟ في نظام تجارة إلكترونية مثلًا، خدمة الطلبات قد تكون مستقلة عن خدمة الدفع، لأن الطلب يمر بعدة حالات، بينما الدفع قد يحتاج تكاملًا خارجيًا مع بوابات مالية. خدمة المنتجات قد تختلف تمامًا عن خدمة التوصيات. خدمة المستخدمين مختلفة عن خدمة العناوين. إذا قسمت هذه المجالات بعقلانية، ستصبح الحدود منطقية، وستقل الحاجة إلى الاتصال المفرط بين الخدمات.

هناك خطأ شائع جدًا: أن نقسم الخدمات بحسب “الجداول” أو بحسب “الشاشات” بدلًا من تقسيمها بحسب القدرات التجارية. هذا يؤدي إلى خدمات هشة. الخدمة الجيدة هي تلك التي تملك مسؤولية واضحة، وبيانات مرتبطة بها، وأحداثًا تصدر منها، وتكون أقل اعتمادًا على بقية النظام بقدر الإمكان.

الشكل العام لمشروع Microservices في .NET

في تطبيق Microservices النموذجي، ستجد عادة مجموعة خدمات ASP.NET Core، كل واحدة في مشروع مستقل. قد تجد أيضًا API Gateway أمامها، وخدمة اكتشاف أو تنسيق، وخدمة رسائل، وObservability stack، وربما مركز إعدادات. كل خدمة لديها Controller أو Minimal APIs، طبقة تطبيق Application Layer، طبقة Domain، وطبقة Infrastructure. هذا ليس شرطًا جامدًا، لكنه تنظيم شائع جدًا ومفيد.

مثال هيكل مشروع مقترح

/src
  /BuildingBlocks
  /Services
    /Catalog
      Catalog.Api
      Catalog.Application
      Catalog.Domain
      Catalog.Infrastructure
    /Orders
      Orders.Api
      Orders.Application
      Orders.Domain
      Orders.Infrastructure
    /Payments
      Payments.Api
      Payments.Application
      Payments.Domain
      Payments.Infrastructure
  /Gateway
    ApiGateway
/tests
  Catalog.Tests
  Orders.Tests
  Payments.Tests

هذا التقسيم يساعدك على عزل المنطق عن التفاصيل. فـ Domain يحتوي الكيانات والقواعد، وApplication يحتوي use cases والخدمات التطبيقية، وInfrastructure يحتوي قواعد البيانات والتكاملات، وApi يحتوي نقاط الدخول. بهذه الطريقة، لا تختلط تفاصيل HTTP مع منطق العمل، ولا تختلط الاستعلامات مع القرارات التجارية.

بناء خدمة بسيطة باستخدام ASP.NET Core

لنبدأ بخدمة كتالوج بسيطة. هذه الخدمة قد تكون مسؤولة عن المنتجات والفئات وبعض خصائص العرض. هدفنا هنا ليس بناء متجر كامل، بل توضيح كيف تبدو الخدمة المستقلة في .NET.

نموذج المجال

namespace Catalog.Domain.Products;

public class Product
{
    public Guid Id { get; private set; }
    public string Name { get; private set; }
    public string Description { get; private set; }
    public decimal Price { get; private set; }
    public bool IsActive { get; private set; }

    private Product() { }

    public Product(string name, string description, decimal price)
    {
        if (string.IsNullOrWhiteSpace(name))
            throw new ArgumentException("Product name is required.");

        if (price < 0)
            throw new ArgumentException("Price cannot be negative.");

        Id = Guid.NewGuid();
        Name = name;
        Description = description;
        Price = price;
        IsActive = true;
    }

    public void Deactivate()
    {
        IsActive = false;
    }
}

هذا الكود يوضح مبدأ بسيطًا لكنه مهم: الكيان نفسه يملك قواعده. لا نسمح بإنشاء منتج باسم فارغ أو سعر سالب. هذه القواعد ليست في Controller، وليست في قاعدة البيانات فقط، بل داخل المجال نفسه.

DTO لإنشاء منتج

namespace Catalog.Application.Products;

public record CreateProductRequest(string Name, string Description, decimal Price);

Service/Application use case

using Catalog.Domain.Products;

namespace Catalog.Application.Products;

public interface IProductService
{
    Task<Guid> CreateAsync(CreateProductRequest request, CancellationToken cancellationToken);
    Task<Product?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
}

Implementation

using Catalog.Domain.Products;
using Microsoft.EntityFrameworkCore;

namespace Catalog.Application.Products;

public class ProductService : IProductService
{
    private readonly CatalogDbContext _dbContext;

    public ProductService(CatalogDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<Guid> CreateAsync(CreateProductRequest request, CancellationToken cancellationToken)
    {
        var product = new Product(request.Name, request.Description, request.Price);

        _dbContext.Products.Add(product);
        await _dbContext.SaveChangesAsync(cancellationToken);

        return product.Id;
    }

    public async Task<Product?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
    {
        return await _dbContext.Products.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
    }
}

DbContext

using Catalog.Domain.Products;
using Microsoft.EntityFrameworkCore;

namespace Catalog.Application.Products;

public class CatalogDbContext : DbContext
{
    public CatalogDbContext(DbContextOptions<CatalogDbContext> options)
        : base(options)
    {
    }

    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>(builder =>
        {
            builder.HasKey(x => x.Id);
            builder.Property(x => x.Name).IsRequired().HasMaxLength(200);
            builder.Property(x => x.Description).HasMaxLength(2000);
            builder.Property(x => x.Price).HasColumnType("decimal(18,2)");
        });
    }
}

API Controller

using Microsoft.AspNetCore.Mvc;

namespace Catalog.Api.Controllers;

[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;

    public ProductsController(IProductService productService)
    {
        _productService = productService;
    }

    [HttpPost]
    public async Task<IActionResult> Create(CreateProductRequest request, CancellationToken cancellationToken)
    {
        var id = await _productService.CreateAsync(request, cancellationToken);
        return CreatedAtAction(nameof(GetById), new { id }, new { id });
    }

    [HttpGet("{id:guid}")]
    public async Task<IActionResult> GetById(Guid id, CancellationToken cancellationToken)
    {
        var product = await _productService.GetByIdAsync(id, cancellationToken);
        if (product is null)
            return NotFound();

        return Ok(product);
    }
}

هذا النموذج البسيط يوضح كيف يمكن لخدمة واحدة أن تكون واضحة ومستقلة وقابلة للتطوير. وهي ما تزال صغيرة، لكن بنيتها تمنحك مساحة للنمو دون فوضى.

قاعدة البيانات لكل خدمة: لماذا هذا مهم؟

أحد المبادئ الأكثر شيوعًا في Microservices هو أن لكل خدمة قاعدة بياناتها الخاصة، أو على الأقل مخزنها المنطقي الخاص بها. هذا لا يعني أنك ممنوع من استخدام نفس خادم SQL Server أو PostgreSQL، لكن يعني أن الخدمة لا ينبغي أن تعتمد على جداول خدمة أخرى مباشرة. لماذا؟ لأن مشاركة قاعدة البيانات تخلق اقترانًا قويًا جدًا بين الخدمات، وتحول الاستقلالية إلى وهم. عندما يغير فريق خدمة أخرى حقلًا أو بنية جدول، قد تنكسر خدمتك أنت. وعندما تحتاج إلى نشر قاعدة البيانات، تصبح التغييرات المشتركة أصعب وأخطر.

وجود قاعدة بيانات منفصلة لكل خدمة يمنحك استقلالًا أكبر. قد تستخدم خدمة الكتالوج PostgreSQL، وخدمة الطلبات SQL Server، وخدمة الإشعارات Redis أو MongoDB، بحسب الحاجة. هذا لا يعني أن عليك اختيار تقنيات مختلفة فقط لإظهار التنوع؛ بل لأن كل خدمة يمكن أن تستخدم التخزين الأنسب لطبيعة عملها. ومع ذلك، لا تبدأ بتعدد قواعد البيانات لمجرد أنه يبدو “احترافيًا”. ابدأ ببساطة، ثم وسّع حين تظهر الحاجة الفعلية.

الاتصال بين الخدمات: REST أم gRPC أم الرسائل؟

هنا يبدأ قلب المعمارية الموزعة في الظهور. الخدمات تحتاج إلى التحدث مع بعضها، لكن كيف؟ يوجد أكثر من أسلوب.

REST مناسب جدًا عندما تريد واجهات واضحة وبسيطة، أو عندما يكون الاتصال خارجيًا مع أطراف أخرى. gRPC ممتاز للاتصالات الداخلية السريعة بين الخدمات، خاصة عندما تحتاج أداءً أعلى وعقودًا واضحة. الرسائل Message-based communication مهمة جدًا عندما تريد فصلًا أكبر بين الخدمة المُرسلة والخدمة المستقبلة، أو عندما تكون العملية غير متزامنة، أو عندما تريد تحمل الفشل وإعادة المحاولة بشكل أفضل.

في كثير من الأنظمة الجادة، لا تختار أسلوبًا واحدًا فقط. قد تستخدم REST للقراءة أو للواجهات الخارجية، وRabbitMQ أو Kafka للأحداث الداخلية، وgRPC لبعض الاتصالات منخفضة الكمون بين الخدمات الحساسة.

مثال gRPC بسيط في .NET

syntax = "proto3";

option csharp_namespace = "Catalog.Grpc";

package catalog;

service ProductService {
  rpc GetProduct (ProductRequest) returns (ProductReply);
}

message ProductRequest {
  string id = 1;
}

message ProductReply {
  string id = 1;
  string name = 2;
  string description = 3;
  double price = 4;
}

مثال استخدام HttpClient بين خدمتين

public class OrdersCatalogClient
{
    private readonly HttpClient _httpClient;

    public OrdersCatalogClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<ProductDto?> GetProductAsync(Guid id, CancellationToken cancellationToken)
    {
        var response = await _httpClient.GetAsync($"/api/products/{id}", cancellationToken);

        if (!response.IsSuccessStatusCode)
            return null;

        return await response.Content.ReadFromJsonAsync<ProductDto>(cancellationToken: cancellationToken);
    }
}

تسجيل العميل

builder.Services.AddHttpClient<OrdersCatalogClient>(client =>
{
    client.BaseAddress = new Uri("http://catalog-api");
});

الاتصال المباشر بهذه الطريقة سهل، لكنه قد يصبح نقطة ضعف إن كان مكثفًا جدًا. لهذا السبب، من الحكمة أن تفكر أيضًا في الأساليب غير المتزامنة.

الأحداث والرسائل: عندما يكون الفصل أفضل من الاتصال المباشر

في الأنظمة الموزعة، من الأفضل غالبًا ألا تنتظر الخدمة الأخرى لإتمام كل شيء. عندما يحدث أمر مهم، مثل إنشاء طلب أو إتمام دفع، يمكن للخدمة أن تنشر حدثًا Event، وتقوم الخدمات الأخرى بالاستجابة له بشكل مستقل. هذا يجعل النظام أكثر مرونة وأقل ترابطًا.

مثال على حدث

public record OrderCreatedEvent(Guid OrderId, Guid CustomerId, decimal TotalAmount);

نشر الحدث باستخدام واجهة مبسطة

public interface IEventBus
{
    Task PublishAsync<T>(T @event, CancellationToken cancellationToken);
}

معالج حدث

public class OrderCreatedHandler
{
    private readonly ILogger<OrderCreatedHandler> _logger;

    public OrderCreatedHandler(ILogger<OrderCreatedHandler> logger)
    {
        _logger = logger;
    }

    public Task HandleAsync(OrderCreatedEvent @event, CancellationToken cancellationToken)
    {
        _logger.LogInformation("Order created: {OrderId}, Total: {TotalAmount}", @event.OrderId, @event.TotalAmount);
        return Task.CompletedTask;
    }
}

في الأنظمة الواقعية، قد تستخدم RabbitMQ أو Kafka أو Azure Service Bus أو غيرها. وغالبًا ستحتاج إلى Outbox Pattern حتى لا تضيع الأحداث عند حدوث فشل بين حفظ البيانات ونشر الرسالة. هذا النمط مهم جدًا لأنه يعالج واحدة من أكثر المشاكل إزعاجًا في الأنظمة الموزعة: كيف تضمن أن التغيير في قاعدة البيانات ونشر الحدث يحدثان بشكل موثوق؟

Outbox Pattern: حل مهم لمشكلة موثوقية الرسائل

تخيل أن خدمة الطلبات حفظت الطلب في قاعدة البيانات، ثم فشلت قبل أن تنشر الحدث. الآن النظام في حالة غير متسقة: البيانات موجودة، لكن بقية الخدمات لا تعرف بذلك. Outbox Pattern يحل هذه المشكلة عبر حفظ الحدث ضمن نفس المعاملة Transaction التي تحفظ البيانات، ثم توجد عملية خلفية تقرأ الأحداث غير المرسلة وتبثها لاحقًا.

كيان OutboxMessage

public class OutboxMessage
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string Type { get; set; } = string.Empty;
    public string Payload { get; set; } = string.Empty;
    public DateTime OccurredOnUtc { get; set; } = DateTime.UtcNow;
    public DateTime? ProcessedOnUtc { get; set; }
    public string? Error { get; set; }
}

عند حفظ الطلب

using System.Text.Json;

public async Task CreateOrderAsync(CreateOrderRequest request, CancellationToken cancellationToken)
{
    var order = new Order(request.CustomerId, request.TotalAmount);

    _dbContext.Orders.Add(order);

    var domainEvent = new OrderCreatedEvent(order.Id, order.CustomerId, order.TotalAmount);

    _dbContext.OutboxMessages.Add(new OutboxMessage
    {
        Type = nameof(OrderCreatedEvent),
        Payload = JsonSerializer.Serialize(domainEvent)
    });

    await _dbContext.SaveChangesAsync(cancellationToken);
}

معالج خلفي للـ Outbox

using System.Text.Json;
using Microsoft.EntityFrameworkCore;

public class OutboxProcessor : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<OutboxProcessor> _logger;

    public OutboxProcessor(IServiceScopeFactory scopeFactory, ILogger<OutboxProcessor> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope = _scopeFactory.CreateScope();
            var dbContext = scope.ServiceProvider.GetRequiredService<OrdersDbContext>();
            var messages = await dbContext.OutboxMessages
                .Where(x => x.ProcessedOnUtc == null)
                .OrderBy(x => x.OccurredOnUtc)
                .Take(20)
                .ToListAsync(stoppingToken);

            foreach (var message in messages)
            {
                try
                {
                    // publish to broker
                    message.ProcessedOnUtc = DateTime.UtcNow;
                }
                catch (Exception ex)
                {
                    message.Error = ex.Message;
                    _logger.LogError(ex, "Failed to process outbox message {MessageId}", message.Id);
                }
            }

            await dbContext.SaveChangesAsync(stoppingToken);
            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
        }
    }
}

هذا النمط ليس مجرد حيلة تقنية، بل أحد الأسس المهمة جدًا في الأنظمة الموزعة الجادة.

معالجة الأعطال: الفشل سيحدث، فاستعد له

في Microservices، الفشل ليس استثناءً نادرًا؛ هو جزء من الواقع. خدمة قد تكون بطيئة، أو شبكة قد تتأخر، أو قاعدة بيانات قد تكون مشغولة، أو رسالة قد تضيع مؤقتًا، أو خدمة خارجية قد تتوقف. لهذا السبب، يجب أن يكون النظام مصممًا لتحمل الأعطال لا لإنكارها.

أحد أهم الأدوات هنا هو Polly، الذي يسمح لك بتطبيق Retry وCircuit Breaker وTimeout وFallback بطريقة منظمة. مثال على Retry بسيط:

using Polly;

var retryPolicy = Policy
    .Handle<HttpRequestException>()
    .WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));

يمكنك أيضًا استخدام Circuit Breaker لمنع الضغط المستمر على خدمة معطلة:

var circuitBreakerPolicy = Policy
    .Handle<HttpRequestException>()
    .CircuitBreakerAsync(5, TimeSpan.FromSeconds(30));

هذه السياسات الصغيرة قد تمنع مشاكل كبيرة جدًا في الإنتاج. لكنها يجب أن تُستخدم بحكمة، لأن الإفراط في إعادة المحاولة قد يزيد الضغط بدل أن يخففه.

الأمان في Microservices

الأمان في الأنظمة الموزعة يجب أن يكون واضحًا ومنظمًا. عادة ستحتاج إلى Authentication للمستخدم أو النظام الذي يرسل الطلب، وAuthorization لتحديد ما يسمح له فعله، وربما إضافة Identity Provider خارجي مثل Azure AD أو Keycloak أو Duende IdentityServer بحسب البنية. غالبًا ستستخدم JWT Access Tokens في الطلبات بين الواجهات والخدمات، وربما service-to-service authentication للاتصالات الداخلية.

مثال حماية Endpoint باستخدام JWT

builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer("Bearer", options =>
    {
        options.Authority = "https://identity.example.com";
        options.Audience = "catalog-api";
    });

builder.Services.AddAuthorization();

في Controller

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    [Authorize]
    [HttpGet]
    public IActionResult GetAll()
    {
        return Ok(new { Message = "Protected endpoint" });
    }
}

لا تجعل الأمان فكرة لاحقة. في النظام الموزع، الخطأ في الأمان لا يبقى محصورًا في جزء واحد؛ قد ينتشر عبر عدة خدمات. لذلك، اتساق التوثيق والتفويض أمر أساسي.

Observability: كيف ترى ما يحدث فعلًا؟

من دون Observability، تكون الخدمات الموزعة مثل غرفة مليئة بالأبواب المغلقة. قد يكون كل شيء يعمل، أو قد يكون جزء صغير يتعطل في مكان لا تراه. لهذا تحتاج إلى Logging، Metrics، Tracing. هذه الثلاثية تساعدك على فهم ما يحدث في الإنتاج.

Logging

logger.LogInformation("Order {OrderId} processed successfully", orderId);
logger.LogWarning("Inventory is low for product {ProductId}", productId);
logger.LogError(ex, "Payment processing failed for order {OrderId}", orderId);

Metrics

يمكنك قياس عدد الطلبات، زمن الاستجابة، نسبة الفشل، وعدد رسائل الـ queue. هذه الأرقام ليست للتزيين، بل لتفسير صحة النظام.

Distributed Tracing

عندما ينتقل الطلب من API Gateway إلى خدمة الطلبات ثم إلى خدمة الدفع ثم إلى خدمة الإشعارات، يجب أن يكون لديك trace id يربط هذه الرحلة كلها. أدوات مثل OpenTelemetry وJaeger أو Tempo تساعدك على ذلك. بدون تتبع موزع، ستجد صعوبة في فهم أين تأخر الطلب وأين فشل.

API Gateway: بوابة واحدة بدلًا من الفوضى

عندما يكون لديك عدة خدمات، قد لا ترغب في أن يعرف العميل الخارجي كل خدمة على حدة. هنا تأتي فكرة API Gateway. البوابة تستقبل الطلبات وتوجهها إلى الخدمات المناسبة، وقد تضيف cross-cutting concerns مثل authentication، rate limiting، caching، request aggregation، والتحكم في المسارات.

في .NET يمكنك استخدام YARP أو Ocelot بحسب احتياجك. الفكرة أن العميل يتحدث إلى نقطة دخول واحدة بدلًا من التحدث إلى عشر خدمات. هذا يبسط الواجهة الخارجية كثيرًا، لكن يجب ألا يتحول الـ Gateway إلى طبقة منطق أعمال ثقيلة. وظيفته التنظيم والتوجيه، لا احتواء كل قواعد النظام.

إدارة الإعدادات والسرية

الخدمات في Microservices تحتاج إلى إعدادات كثيرة: سلاسل اتصال، مفاتيح تكامل، عناوين الخدمات الأخرى، إعدادات الرسائل، إعدادات البريد، وغيرها. المشكلة أن وضع هذه القيم مباشرة داخل الشيفرة أو داخل ملفات مكشوفة ليس آمنًا. الأفضل استخدام Secrets Management في التطوير، وEnvironment Variables في الإنتاج، وربط ذلك مع Key Vault أو أي مدير أسرار مناسب.

مثال قراءة إعدادات من البيئة

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
var rabbitMqHost = builder.Configuration["Messaging:Host"];

appsettings.json

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=OrdersDb;Trusted_Connection=True;TrustServerCertificate=True"
  },
  "Messaging": {
    "Host": "localhost",
    "Port": 5672
  }
}

احرص على ألا تدخل الأسرار الحقيقية إلى المستودع البرمجي. هذه قاعدة لا ينبغي التهاون فيها.

Docker: تغليف الخدمة بشكل موحد

من الصعب الحديث عن Microservices بدون Docker. الحاويات تجعل كل خدمة قابلة للتشغيل بنفس البيئة تقريبًا أينما ذهبت. هذا يقلل مشكلة “يعمل عندي ولا يعمل عندك”، ويجعل النشر المحلي والاختبار والإنتاج أكثر اتساقًا.

Dockerfile لخدمة ASP.NET Core

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["Catalog.Api/Catalog.Api.csproj", "Catalog.Api/"]
COPY ["Catalog.Application/Catalog.Application.csproj", "Catalog.Application/"]
COPY ["Catalog.Domain/Catalog.Domain.csproj", "Catalog.Domain/"]
COPY ["Catalog.Infrastructure/Catalog.Infrastructure.csproj", "Catalog.Infrastructure/"]
RUN dotnet restore "Catalog.Api/Catalog.Api.csproj"
COPY . .
WORKDIR "/src/Catalog.Api"
RUN dotnet publish -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "Catalog.Api.dll"]

تشغيل الحاوية

docker build -t catalog-api .
docker run -p 8080:8080 catalog-api

Docker ليس فقط للنشر؛ هو أيضًا أداة مهمة للتطوير المحلي، خصوصًا عندما تحتاج تشغيل قاعدة بيانات ورسالة broker وعدة خدمات معًا.

Docker Compose للتطوير المحلي

في بيئة Microservices، تشغيل كل شيء يدويًا مرهق جدًا. Docker Compose يحل جزءًا كبيرًا من هذه المشكلة.

version: "3.9"
services:
  catalog-api:
    build:
      context: .
      dockerfile: Catalog.Api/Dockerfile
    ports:
      - "8081:8080"
    depends_on:
      - catalog-db

  catalog-db:
    image: postgres:16
    environment:
      POSTGRES_USER: catalog
      POSTGRES_PASSWORD: catalog123
      POSTGRES_DB: catalogdb
    ports:
      - "5432:5432"

بهذه الطريقة تستطيع تشغيل الخدمة وقاعدتها في خطوة واحدة تقريبًا. وهذا يوفر وقتًا ويجعل الاختبار المحلي أكثر واقعية.

Kubernetes: عندما يكبر النظام

عندما ينتقل النظام من مجرد خدمات إلى منظومة إنتاجية كبيرة، يصبح Kubernetes خيارًا شائعًا لإدارة النشر، التوسع، الاكتشاف، وإعادة التشغيل التلقائي. Kubernetes ليس ضرورة لكل مشروع، لكنه مفيد جدًا عندما تحتاج تحكمًا كبيرًا في عدد النسخ، وسلوك التحديثات، والموارد، والمراقبة.

Deployment بسيط

apiVersion: apps/v1
kind: Deployment
metadata:
  name: catalog-api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: catalog-api
  template:
    metadata:
      labels:
        app: catalog-api
    spec:
      containers:
        - name: catalog-api
          image: catalog-api:latest
          ports:
            - containerPort: 8080

Service

apiVersion: v1
kind: Service
metadata:
  name: catalog-api
spec:
  selector:
    app: catalog-api
  ports:
    - port: 80
      targetPort: 8080

Kubernetes يضيف طبقة تشغيلية قوية، لكنه يضيف أيضًا تعقيدًا. لذلك لا تجلبه إلا عندما تكون الحاجة حقيقية.

CQRS: فصل القراءة عن الكتابة

في كثير من الأنظمة، تختلف احتياجات القراءة عن الكتابة. القراءة قد تحتاج أداءً عاليًا واستعلامات مخصصة، بينما الكتابة تحتاج قواعد عمل صارمة وتأكيدات دقيقة. هنا يأتي نمط CQRS: Command Query Responsibility Segregation. الفكرة هي فصل منطق الأوامر عن منطق الاستعلامات.

Command

public record CreateOrderCommand(Guid CustomerId, decimal TotalAmount);

Query

public record GetOrderDetailsQuery(Guid OrderId);

Handler مبسط

public class CreateOrderHandler
{
    private readonly OrdersDbContext _dbContext;

    public CreateOrderHandler(OrdersDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<Guid> HandleAsync(CreateOrderCommand command, CancellationToken cancellationToken)
    {
        var order = new Order(command.CustomerId, command.TotalAmount);

        _dbContext.Orders.Add(order);
        await _dbContext.SaveChangesAsync(cancellationToken);

        return order.Id;
    }
}

CQRS لا يعني دائمًا تعقيدًا ضخمًا أو Event Sourcing بالضرورة. أحيانًا يكفي فصل منطقي بسيط بين الأوامر والاستعلامات لتحصل على وضوح أفضل.

الاختبارات في Microservices

الاختبار هنا يصبح أكثر أهمية، لأن أي خطأ قد لا يبقى في خدمة واحدة. لديك اختبارات وحدة لطبقة Domain، واختبارات تكامل لقاعدة البيانات، واختبارات API، واختبارات عقد Contract Tests بين الخدمات، وربما اختبارات End-to-End.

اختبار بسيط للمجال

using Xunit;

public class ProductTests
{
    [Fact]
    public void Product_Should_NotAllowNegativePrice()
    {
        Assert.Throws<ArgumentException>(() => new Product("Phone", "Description", -10));
    }
}

اختبار API

using System.Net;
using Xunit;

public class ProductsApiTests
{
    [Fact]
    public async Task GetProduct_ShouldReturnSuccess()
    {
        // pseudo example
        HttpResponseMessage response = new HttpResponseMessage(HttpStatusCode.OK);

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }
}

الاختبارات في الأنظمة الموزعة قد تكون أغلى من اختبارات المونوليث، لكنها في المقابل تحميك من أعطال صامتة ومؤلمة جدًا.

الأداء والتوسع

ميزة Microservices الحقيقية تظهر عندما تحتاج إلى توسع مستقل. قد تكون خدمة البحث تحت ضغط كبير بينما خدمة المستخدمين هادئة. عندها يمكنك زيادة نسخ خدمة البحث فقط. قد تكون خدمة الإشعارات تحتاج إلى معالجة خلفية كثيفة، بينما بقية النظام لا تحتاج توسعًا كبيرًا. هذا النوع من التحكم لا يقدمه المونوليث بسهولة.

لكن التوسع ليس مجانيًا. كل خدمة جديدة تعني تعقيدًا إضافيًا في النشر، المراقبة، logging، الأمن، وإدارة الإعدادات. لذلك ابحث دائمًا عن التوازن. لا توسع لمجرد أنه ممكن، ولا تحافظ على المونوليث لمجرد أنه أبسط. اختر ما يناسب واقعك الحالي مع هامش نمو معقول.

أخطاء شائعة في بناء Microservices

هناك أخطاء تتكرر كثيرًا. أولها تقسيم النظام إلى خدمات كثيرة جدًا منذ البداية، فتتحول البنية إلى شبكة من الاتصالات غير الضرورية. ثانيها مشاركة قاعدة بيانات واحدة بين كل الخدمات، فتفقد الاستقلالية الحقيقية. ثالثها الاعتماد المفرط على الاتصالات المباشرة synchronous calls، مما يجعل النظام هشًا أمام الأعطال. رابعها تجاهل Observability، ثم الوقوف حائرًا في الإنتاج عندما يحدث فشل غير مفهوم. خامسها كتابة المنطق التجاري داخل الـ API Layer بدلًا من عزله في الطبقات المناسبة. سادسها عدم بناء سياسات Retry وTimeout وFallback وCircuit Breaker. سابعها إهمال الأمن بين الخدمات وفي حافة النظام. وثامنها بناء كل شيء بتقنيات معقدة قبل أن يتأكد الفريق من الحاجة إليها فعلًا.

الدرس الأهم هنا أن Microservices تطلب انضباطًا أكثر من الطلبات التقليدية. كل قرار صغير قد يترك أثرًا كبيرًا عند التوسع.

كيف تبني خدمة Orders بشكل عملي؟

لنجمع الأفكار في مثال واقعي نسبيًا. خدمة Orders قد تستقبل الطلب، تحفظه في قاعدة بياناتها، تنشر حدث OrderCreated، ثم تنتظر أن تتفاعل بقية الخدمات. خدمة Payments قد تستمع للحدث وتبدأ معالجة الدفع. خدمة Inventory قد تخصم المخزون. خدمة Notifications قد ترسل رسالة تأكيد. لا يوجد اعتماد مباشر بين كل هذه الخدمات في أفضل سيناريو، بل يوجد تنسيق عبر الأحداث.

نموذج Order

public class Order
{
    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public decimal TotalAmount { get; private set; }
    public string Status { get; private set; }

    private Order() { }

    public Order(Guid customerId, decimal totalAmount)
    {
        if (totalAmount <= 0)
            throw new ArgumentException("Total amount must be positive.");

        Id = Guid.NewGuid();
        CustomerId = customerId;
        TotalAmount = totalAmount;
        Status = "Pending";
    }

    public void MarkAsPaid()
    {
        Status = "Paid";
    }
}

خدمة إنشاء طلب

public class OrderService
{
    private readonly OrdersDbContext _dbContext;

    public OrderService(OrdersDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<Guid> CreateAsync(Guid customerId, decimal totalAmount, CancellationToken cancellationToken)
    {
        var order = new Order(customerId, totalAmount);
        _dbContext.Orders.Add(order);

        _dbContext.OutboxMessages.Add(new OutboxMessage
        {
            Type = "OrderCreatedEvent",
            Payload = JsonSerializer.Serialize(new OrderCreatedEvent(order.Id, order.CustomerId, order.TotalAmount))
        });

        await _dbContext.SaveChangesAsync(cancellationToken);

        return order.Id;
    }
}

بهذا الأسلوب، تكون الخدمة مسؤولة عن جزء واضح من الرحلة، وتترك بقية التفاعل لتدفق الأحداث. هذا أفضل بكثير من استدعاء خدمة الدفع مباشرة من داخل عملية حفظ الطلب إذا كان الهدف هو المرونة والتوسع.

تجربة التطوير المحلية

عندما يعمل الفريق على Microservices، يصبح التشغيل المحلي من أكثر التحديات إزعاجًا. لذلك من المهم أن تبني بيئة محلية مريحة: Docker Compose، ملفات إعدادات منفصلة، بيانات اختبارية، broker محلي، وربما script واحد يشغل النظام كله. كلما كان تشغيل النظام المحلي أقرب إلى الواقع، قلّت المفاجآت عند النشر.

وقد يكون من المفيد أيضًا إنشاء مشروع Building Blocks يحتوي على utilities مشتركة مثل:

  • نتائج موحدة للـ API

  • middleware للأخطاء

  • logging configuration

  • tracing setup

  • authentication helpers

  • base abstractions

لكن احذر من تحويل هذا المشروع المشترك إلى “مستودع سحري” يشارك كل شيء. المشاركة الزائدة تقود إلى اقتران غير مرغوب فيه.

ما الذي يجعل Microservices ناجحة فعلًا؟

النجاح في Microservices ليس في عدد الخدمات، بل في جودة الحدود بينها، ووضوح عقودها، وقدرتها على العمل المستقل، ومرونة النشر، ونضج المراقبة، والانضباط التشغيلي. في النهاية، أنت لا تبني مجموعة APIs فقط؛ أنت تبني نظامًا موزعًا يجب أن يبقى مفهومًا بعد سنة أو سنتين أو خمس. وهذه نقطة حاسمة: ما يبدو بسيطًا اليوم قد يصبح غامضًا جدًا غدًا إذا لم تضع الأساس الصحيح.

النهج الأفضل غالبًا هو البدء ببنية بسيطة ومنظمة، ثم استخراج الخدمات تدريجيًا عندما يظهر السبب الحقيقي. هذا أفضل من القفز المباشر إلى نظام معقد لا يزال في مرحلة غير مستقرة. أما إذا كان المشروع كبيرًا من البداية، أو إذا كانت الفرق متعددة، أو إذا كانت مجالات العمل منفصلة بوضوح، فـ Microservices مع C# و .NET يمكن أن تكون خيارًا ممتازًا ومطمئنًا جدًا.

خاتمة

بناء Microservices باستخدام C# و .NET ليس مجرد تبني تقنية حديثة، بل هو دخول في أسلوب هندسي يحتاج وعيًا وهدوءًا وتدرجًا. القوة الحقيقية هنا لا تأتي من الأسماء الكبيرة، بل من التفاصيل الصغيرة: الحدود الصحيحة، العقود الواضحة، التعامل الحكيم مع البيانات، الرسائل غير المتزامنة، تحمل الأعطال، الأمان، المراقبة، والاختبارات. C# و .NET يقدمان بيئة ممتازة لكل هذا، لأنهما يجمعان بين الإنتاجية والقوة والنضج والمرونة. وعندما تُستخدم هذه الأدوات بعقلية صحيحة، يصبح بإمكانك بناء نظام لا يعمل فقط، بل يعيش ويتطور ويقاوم التغيرات بدل أن ينهار أمامها.

#بناء Microservices باستخدام C# و .NET #ASP.NET Core #Microservices Architecture #.NET #REST API #gRPC #RabbitMQ #Kafka #Docker #Kubernetes #Entity Framework Core #CQRS #DDD #Event-Driven Architecture #API Gateway #JWT Authentication

اشترك في نشرتنا البريدية

12k+

المشتركون

أسبوعيًا

التكرار

مجاني

دائمًا