در این مطلب به پیاده سازی یک پروژه با  قابلیت Multi-Tenant می‌پردازیم. در یکی از پروژه‌هایی که انجام دادیم، نیاز بر آن بود که سیستم به صورت Multi-Tenant پیاده سازی شود که بتوان سیستم را برای چندین مشتری اجرا کرد. بسیاری از پروژه‌ها را میتوان برای چندین مشتری اجرا کرد اما مشکلی که وجود دارد باید برای همه مشتری ها سرور و دیتابیس و ... جدا ایجاد کرد. Multi-Tenant این مشکل را برطرف می‌کند و شما می‌توانید یک پروژه را بر روی یک دیتابیس اجرا نمایید و هر مشتری دیتای مربوط به خود را بتواند ویرایش و تماشا کند. در این نوع پروژه‌ها مهم ترین موضوعی که باید بررسی و پیاده سازی شود آن است که هر مشتری فقط بتواند دیتای مربوط به خود را مشاهده کند و به دیتای سایر مشتری‌ها دسترسی نداشته باشد.

در این پروژه ما از یکی از ساختارهای Multi-tenancy استفاده کردیم. نگه داشتن Tenant در جدول‌‎ها. چندین نوع پیاده سازی برای Multi-tenancy  وجود دارد. در بعضی پروژه‌ها دیتابیس هر مشتری جدا می‌شود و یا بر اساس schema جداول هر مشتری در یک دیتابیس جدا می‌شود و یا آیدی مربوط به هر مشتری در جداولی که مهم است و فیلتر بر روی آنها اعمال میشود نگه داری میشود که ما از این روش استفاده کردیم.

برای این کار ابتدا کوئری فیلترهای مربوط به EF Core را در کلاس DbContext پیاده سازی کردیم. در همان ابتدای کار به یک مشکل برخوردیم. ما یک سری کوئری فیلتر قبلی داشتیم و فیلترهای جدید روی EF Core کوئری های قبلی رو پاک می‌کردند و برای همین یک متدی پیاده سازی کردیم که مشکل را برطرف کنیم و بتوانیم کوئری فیلترهای قبلی را با کوئری فیلتر جدید And کنیم. EF Core به طور پیش‌فرض از چندین HasQueryFilter روی یک entity پشتیبانی نمی‌کند. این محدودیت مانع از ترکیب فیلترهای soft delete با فیلترهای tenant-specific می‌شد برای همین متد زیر را اضافه کردیم:

public static void AddQueryFilter<T>(this EntityTypeBuilder<T> entityTypeBuilder, Expression<Func<T, bool>> expression) where T : class
{
    ParameterExpression parameterExpression = Expression.Parameter(entityTypeBuilder.Metadata.ClrType);
    Expression expression2 = ReplacingExpressionVisitor.Replace(expression.Parameters.Single(), parameterExpression, expression.Body);
    LambdaExpression queryFilter = entityTypeBuilder.Metadata.GetQueryFilter();
    
    if (queryFilter != null)
    {
        expression2 = Expression.AndAlso(
            ReplacingExpressionVisitor.Replace(queryFilter.Parameters.Single(), parameterExpression, queryFilter.Body), 
            expression2);
    }

    LambdaExpression filter = Expression.Lambda(expression2, parameterExpression);
    entityTypeBuilder.HasQueryFilter(filter);
}

این اکستنشن متد، کوئری فیلترهای قبلی را با کوئری فیلتر جدید And می‌کند و به صورت زیر از آن استفاده کردیم.

modelBuilder.Entity<Organization>()
    .AddQueryFilter(e => _currentTenant.Id == null || e.TenantId == _currentTenant.Id);

modelBuilder.Entity<Employee>()
    .AddQueryFilter(e => _currentTenant.Id == null || e.Organization.TenantId == _currentTenant.Id);

در این قسمت مشخص کرده ایم که اگر Tenant فعلی نال نبود و مقدار داشت، فیلتر مربوط به Tenant باید اعمال شود. 

قسمت جالب پروژه که خیلی ازش خوشم اومد اینجاست. 

ICurrentTenant: اومدیم یه اینترفیس تعریف کردیم که این قابلیت رو بهمون بده هرکجا از سیستم که خواستیم TenantId فعلی رو بهمون بده و یا اینکه اون رو عوض کنه(برای بحث ادمین). کلاس CurrentTenant یه متغییر داره از نوع AsyncLocal که اطلاعات مربوط به Tenant رو نگه میداره ( در مورد AsyncLocal حتما بخونید خیلی بهتون کمک میکنه). بعدش یه پراپرتی به اسم Id که مقدار Tenant فعلی رو برمیگردونه و یه متد به اسم Change. قشنگی کار اینجاست. این متد یه آیدی میگیره و کارش اینه که مقدار Tenant رو عوض کنه. اول میاد مقدار فعلی Tenant رو داخل یه متغییری نگه می‌داره و بعدش Tenant جدید رو توی AsyncLocal نگه می‌داره. اگه دقت کنید خروجی این متد IDisposable هست و میتونه توی Using قرار بگیره. بعدش اومدیم یه اکشن به کلاس DisposeAction پاس دادیم و گفتیم وقتی Dispose فراخوانی شد، مقدار قبلی Tenant رو که نگه داشته بودیم رو برگردون به داخل AsyncLocal. 

public class CurrentTenant : ICurrentTenant
{
    private readonly AsyncLocal<CurrentTenantInfo> _currentScope = new AsyncLocal<CurrentTenantInfo>();

    public int? Id => _currentScope.Value?.Id;

    public IDisposable Change(int? id)
    {
        var parentScope = _currentScope.Value;

        _currentScope.Value = new CurrentTenantInfo(id);

        return new DisposeAction(() =>
        {
            _currentScope.Value = parentScope;
        });
    }
}

با استفاده از این کار هرکجا که بخوایم Tenant عوض کنیم و یا در نظر نگیریم میتونیم به صورت زیر عمل کنیم:

public async Task<List<Organization>> GetAllOrganizationsAsync()
{
    using (currentTenant.Change(null))
    {
        return await GetOrganizationsAsync();
    }
}

و یا اینکه Tenant رو عوض کنیم:

using (currentTenant.Change(5))
{
    var employees = await context.Employees.ToListAsync();
}

در نهایت هر سیستمی که از Multi-Tenancy استفاده کنه باید به نحوی مشخص کنه که این ریکوئستی که الان ارسال شده، مربوط به کدام Tenantهستش. برای این پروژه ما از یک Middleware استفاده کردیم که مقدار Tenant رو از هدر میخونیم و توی ICurrentTenant نگه میداریم.

public class TenantMiddleware
{
    private readonly RequestDelegate _next;

    public TenantMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context, ICurrentTenant currentTenant)
    {
        int tenantId = ExtractTenantId(context.Request);

        using (currentTenant.Change(tenantId))
        {
            await _next(context);
        }
    }

    private static int ExtractTenantId(HttpRequest request)
    {
        if (request.Headers.TryGetValue("X-Tenant-Id", out var tenantId))
            return Convert.ToInt32(tenantId);

        throw new ArgumentException("X-Tenant-Id Not found");
    }
}

کدهای این مطلب و داکیومنت فنی آن را میتوانید از گیت هاب دانلود کنید.

🛡️ ملاحظات امنیتی

  1. 🔒 ایزولاسیون مستأجر: داده‌ها به صورت خودکار بر اساس context مستأجر فیلتر می‌شوند
  2. اعتبارسنجی هدر: شناسه مستأجر از هدرهای درخواست استخراج می‌شود
  3. 🔍 فیلتر کوئری: همه کوئری‌های دیتابیس به صورت خودکار محدود به مستأجر فعلی می‌شوند
  4. 🗑️ Soft Delete: رکوردهای حذف شده به صورت خودکار فیلتر می‌شوند
  5. 🎯 مدیریت Context: دفع صحیح context مستأجر از نشت داده جلوگیری می‌کند

🎯 مزایای کلیدی

  1. 🔄 ایزولاسیون خودکار داده: نیازی به فیلتر دستی نیست
  2. 🗄️ پایگاه داده واحد: مقرون به صرفه و آسان برای نگهداری
  3. 🧵 Thread-Safe: مدیریت صحیح context async
  4. 🔧 انعطاف: امکان دور زدن فیلترها در صورت نیاز
  5. عملکرد: فیلتر کردن کارآمد در سطح دیتابیس
  6. 📈 مقیاس‌پذیری: آسان برای اضافه کردن مستأجران و موجودیت‌های جدید

🚀 راه‌اندازی توسعه

  1. 🗄️ پایگاه داده: SQL Server با connection string ارائه شده
  2. 🔄 Migration: اجرای dotnet ef database update
  3. 📋 هدرها: شامل کردن X-Tenant-Id در همه درخواست‌های API
  4. 🚨 Middleware: اطمینان از ثبت TenantMiddleware در ابتدای pipeline

🎯 نتیجه‌گیری

این پیاده‌سازی Multi-Tenant راه‌حلی قوی، امن و مقیاس‌پذیر برای مدیریت داده‌های چند مستأجری با ایزولاسیون خودکار و فیلتر کردن ارائه می‌دهد. استفاده از Entity Framework Core Query Filters امکان فیلتر کردن خودکار در سطح دیتابیس را فراهم می‌کند که منجر به عملکرد بالا و امنیت بهتر می‌شود.

⚠️ نکات مهم:

  • 🚨 TenantMiddleware اجباری است و باید در ابتدای pipeline ثبت شود
  • 🗑️ Context Management با DisposeAction برای جلوگیری از memory leak ضروری است
  • 🔍 Query Filters به صورت خودکار همه کوئری‌ها را فیلتر می‌کنند
  • 🛡️ Security از طریق ایزولاسیون کامل داده‌ها تضمین می‌شود

این معماری امکان توسعه و نگهداری آسان سیستم‌های چند مستأجری را فراهم می‌کند و در عین حال امنیت و عملکرد بالایی را تضمین می‌کند.

Powered by Froala Editor

نظرات