🏢 معماری چند-مستأجره (Multi-Tenancy) با EF Core (قسمت ۱):
یک دیتابیس برای همه اپلیکیشنهای مدرن SaaS (نرمافزار به عنوان سرویس) یک ویژگی مشترک دارن: چند-مستأجری (Multi-Tenancy).
یعنی یک اپلیکیشن به چندین مشتری (مستأجر یا tenant) سرویس میده، ولی دادههای هر مشتری کاملاً از بقیه ایزوله هست. امروز در قسمت اول از این سری، با این معماری و روش پیادهسازی "یک دیتابیس برای همه" با EF Core آشنا میشیم.
1️⃣ دو رویکرد اصلی برای چند-مستأجری
برای ایزوله کردن دادههای مشتریان، دو راه اصلی وجود داره:
🧠 ایزولهسازی منطقی (Logical Isolation):
یک دیتابیس واحد برای همه مشتریان، که دادهها با یک شناسه مثل TenantId از هم جدا میشن. (موضوع این پست)
🏬 ایزولهسازی فیزیکی (Physical Isolation):
یک دیتابیس کاملاً مجزا برای هر مشتری.
انتخاب بین این دو، به نیاز پروژه بستگی داره. برای صنایعی مثل حوزه سلامت که به ایزولهسازی بالا نیاز دارن، روش دوم الزامیه.
2️⃣ ابزار اصلی ما: EF Core Query Filters 🔍
برای پیادهسازی روش "یک دیتابیس برای همه"، ابزار اصلی ما در EF Core، فیلترهای کوئری سراسری (Global Query Filters) هست. این قابلیت به ما اجازه میده یه شرط WHERE رو به صورت خودکار به تمام کوئریهای یک انتیتی خاص اضافه کنیم. یک بار پیادهسازیش میکنیم و دیگه تقریباً فراموشش میکنیم!
3️⃣ پیادهسازی قدم به قدم
برای این کار به دو چیز نیاز داریم: ۱. راهی برای شناسایی مستأجر فعلی. ۲. راهی برای فیلتر کردن دادهها برای اون مستأجر.
• قدم اول: شناسایی مستأجر با TenantProvider
اول از همه، باید بفهمیم درخواست فعلی برای کدوم مستأجره. ما این منطق رو تو یه کلاس جدا به اسم TenantProvider کپسوله میکنیم. این کلاس، TenantId رو از هدر (X-TenantId) درخواست HTTP میخونه.
public sealed class TenantProvider
{
private const string TenantIdHeaderName = "X-TenantId";
private readonly IHttpContextAccessor _httpContextAccessor;
public TenantProvider(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public string TenantId => _httpContextAccessor
.HttpContext?
.Request
.Headers[TenantIdHeaderName];
}
💡نکته: راههای امنتر دیگری هم برای گرفتن TenantId وجود داره مثل JWT Claim یا API Key.
• قدم دوم: اعمال فیلتر سراسری در DbContext
حالا که میتونیم مستأجر فعلی رو پیدا کنیم، باید به EF Core بگیم که تمام کوئریها رو بر اساس اون فیلتر کنه. بهترین جا برای این کار، متد OnModelCreating در DbContext ماست.
public class OrdersDbContext : DbContext
{
private readonly string _tenantId;
public OrdersDbContext(
DbContextOptions<OrdersDbContext> options,
TenantProvider tenantProvider)
: base(options)
{
// TenantId رو از TenantProvider میگیریم
_tenantId = tenantProvider.TenantId;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// این فیلتر به صورت خودکار به تمام کوئریهای انتیتی Order اضافه میشه
modelBuilder
.Entity<Order>()
.HasQueryFilter(o => o.TenantId == _tenantId);
}
}
حالا هر کوئریای که روی جدول Order زده بشه، EF Core به صورت خودکار شرط WHERE TenantId = 'current_tenant_id' رو بهش اضافه میکنه!
🔖 هشتگها:
#CSharp #ASPNETCore #DotNet #MultiTenancy #EntityFrameworkCore #SoftwareArchitecture #Backend
🏢 معماری چند-مستأجره با EF Core (قسمت ۲):
هر مستأجر، دیتابیس خودش
در قسمت اول، روش ایزولهسازی منطقی (یک دیتابیس برای همه) رو با Query Filters پیادهسازی کردیم. این روش برای خیلی از پروژهها عالیه.
اما گاهی اوقات به ایزولهسازی فیزیکی نیاز داریم؛ یعنی هر مشتری، دیتابیس کاملاً مجزای خودش رو داشته باشه تا دادهها به صورت فیزیکی از هم جدا باشن. امروز با این روش پیشرفته و امن آشنا میشیم.
1️⃣ چالش جدید: مدیریت Connection String داینامیک 🔌
در این سناریو، دیگه Query Filters به کارمون نمیاد، چون با دیتابیسهای مختلفی سر و کار داریم. چالش اصلی ما اینه که به ازای هر درخواست، بتونیم DbContext رو با Connection String مخصوص همون مستأجر پیکربندی کنیم.
2️⃣ پیادهسازی قدم به قدم
قدم اول: ذخیره اطلاعات مستأجرها ⚙️
اول از همه، باید اطلاعات هر مستأجر و Connection String دیتابیسش رو یه جا ذخیره کنیم. برای مثال، در فایل appsettings.json:
"Tenants": [
{ "Id": "tenant-1", "ConnectionString": "Host=tenant1.db;Database=db1" },
{ "Id": "tenant-2", "ConnectionString": "Host=tenant2.db;Database=db2" }
]
قدم دوم: آپدیت کردن TenantProvider 🕵️♂️حالا TenantProvider رو آپدیت میکنیم تا علاوه بر TenantId، بتونه Connection String مربوط به مستأجر فعلی رو هم از تنظیمات بخونه و به ما بده.
public sealed class TenantProvider
{
// ... (Properties for HttpContextAccessor and TenantSettings)
public TenantProvider(
IHttpContextAccessor httpContextAccessor,
IOptions<TenantSettings> tenantsOptions)
{
// ...
_tenantSettings = tenantsOptions.Value;
}
public string TenantId => /* ... gets tenantId from header ... */;
public string GetConnectionString()
{
return _tenantSettings.Tenants
.Single(t => t.Id == TenantId).ConnectionString;
}
}
قدم سوم: جادوی DI (ثبت داینامیک DbContext) ✨این مهمترین و جادوییترین بخش کاره! ما DbContext رو جوری در Program.cs ثبت میکنیم که به ازای هر درخواست، اول TenantProvider رو اجرا کنه، Connection String رو بگیره و بعد DbContext رو با اون پیکربندی کنه.
builder.Services.AddDbContext<OrdersDbContext>((serviceProvider, options) =>
{
// TenantProvider رو از DI میگیریم
var tenantProvider = serviceProvider.GetRequiredService<TenantProvider>();
// Connection String مخصوص مستأجر فعلی رو میگیریم
var connectionString = tenantProvider.GetConnectionString();
// و DbContext رو با اون کانفیگ میکنیم
options.UseSqlServer(connectionString);
});
🔐 نکته امنیتی مهم
قرار دادن Connection Stringها به صورت مستقیم در appsettings.json برای محیط پروداکشن امن نیست. همیشه از ابزارهای مدیریت secret مثل Azure Key Vault یا NET User Secrets. برای محیط توسعه استفاده کنید.
🤔 حرف حساب و تجربه شمابا این دو روش
شما الان جعبه ابزار کاملی برای پیادهسازی هر نوع معماری چند-مستأجره در ASP.NET Core دارید.
شما تو پروژههاتون با کدوم مدل چند-مستأجری کار کردید؟ تک دیتابیس یا چند دیتابیس؟ چالشها و مزایای هر کدوم از نظر شما چیه؟
🔖 هشتگها:
#CSharp #ASPNETCore #DotNet #MultiTenancy #EntityFrameworkCore #SoftwareArchitecture #Backend
Enumها به عنوان رشته در EF Core:
کدی خواناتر برای دیتابیس شما 📜
وقتی از Enumها در Entity Framework Core استفاده میکنید، به صورت پیشفرض به شکل عدد (0, 1, 2) در دیتابیس ذخیره میشن. این کار از نظر پرفورمنس خوبه، ولی وقتی مستقیم به دیتابیس نگاه میکنید، این عددها هیچ معنایی ندارن! 🧐
اما یه راه حل خیلی ساده و تمیز برای افزایش خوانایی و قابلیت نگهداری دیتابیس وجود داره: ذخیره کردن Enumها به صورت رشته.
جادوی HasConversion ✨
با استفاده از متد HasConversion در Fluent API، میتونید به راحتی به EF Core بگید که مقادیر Enum رو به جای عدد، به صورت نام رشتهای اونها ذخیره کنه.
1️⃣ Enum شما:
public enum OrderStatus
{
Pending,
Completed,
Cancelled
}
2️⃣ انتیتی شما:
public class Order
{
public int Id { get; set; }
public OrderStatus Status { get; set; }
}
3️⃣ پیکربندی در DbContext:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.Property(o => o.Status)
.HasConversion<string>(); // تمام جادو اینجاست!
}
حالت پیشفرض (بدون HasConversion): 👎
| Id | Status |
| :-- | :--- |
| 1 | 0 |
| 2 | 1 |
حالت جدید (با HasConversion): 👍
| Id | Status |
| :-- | :--- |
| 1 | "Pending" |
| 2 | "Completed" |
🤔 حرف حساب و تجربه شما
این تغییر کوچیک، دیباگ کردن و کار مستقیم با دیتابیس رو خیلی راحتتر میکنه. با اینکه ذخیرهسازی به صورت عدد کمی بهینهتره، اما در اکثر پروژهها، خوانایی بالاتر Enum به صورت رشته، ارزشش رو داره.
</Link>
🔖 هشتگها:
#EntityFrameworkCore #EFCore #Database
5️⃣ Eager Loading (بارگذاری مشتاقانه) ⏬
بارگذاری مشتاقانه قابلیتی در EF Core است که به شما اجازه میدهد انتیتیهای مرتبط را به همراه انتیتی اصلی خود در یک کوئری دیتابیس واحد بارگذاری کنید.
internal sealed class
VerifyEmail(AppDbContext context)
{
public async Task<bool> Handle(Guid tokenId)
{
EmailVerificationToken? token = await context.EmailVerificationTokens
.Include(e => e.User) // User مرتبط را همزمان لود کن
.FirstOrDefaultAsync(e => e.Id == tokenId);
// ...
}
}
EF Core
یک کوئری SQL واحد تولید میکند که جداول EmailVerificationToken و User را join میکند.
خلاصه 📝
پس، این هم از این! پنج ویژگی EF Core که، صراحتاً، نمیتوانید از ندانستنشان شانه خالی کنید. به یاد داشته باشید، تسلط بر EF Core زمان میبرد، اما این ویژگیها یک پایه محکم برای ساختن فراهم میکنند.
یک توصیه دیگر این است که عمیقاً درک کنید دیتابیس شما چگونه کار میکند. تسلط بر SQL همچنین به شما اجازه میدهد بیشترین ارزش را از EF Core بدست آورید.
🔖 هشتگها:
#EntityFrameworkCore #EFCore #Performance #Database #SQL
متد HasFilter فیلتر SQL را برای رکوردهایی که در ایندکس قرار خواهند گرفت، میپذیرد.
شما همچنین میتوانید یک ایندکس فیلتر شده را با استفاده از SQL ایجاد کنید:
شما میتوانید از طریق مستندات، اطلاعات بیشتری در مورد ایندکسهای فیلتر شده کسب کنید.
ارزشش را دارد که فکر کنید آیا اصلاً به حذف نرم رکوردها نیاز دارید یا خیر.
در سیستمهای سازمانی (enterprise)، شما معمولاً به "حذف" داده فکر نمیکنید. مفاهیم تجاری وجود دارند که شامل حذف داده نمیشوند. چند مثال عبارتند از: لغو یک سفارش، بازپرداخت یک پرداخت، یا باطل کردن یک فاکتور. این عملیاتهای "مخرب" سیستم را به حالت قبلی بازمیگردانند. اما از دیدگاه تجاری، شما واقعاً در حال حذف داده نیستید. 💼
حذف نرم در صورتی مفید است که خطر حذف تصادفی وجود داشته باشد. این قابلیت به شما امکان میدهد رکوردهای حذف نرم شده را به راحتی بازیابی کنید.
در هر صورت، در نظر بگیرید که آیا حذف نرم از دیدگاه تجاری منطقی است یا خیر.
حذف نرم یک شبکه ایمنی ارزشمند برای بازیابی اطلاعات ارائه میدهد و میتواند ردیابی دادههای تاریخی را بهبود بخشد.
با این حال، ارزیابی اینکه آیا این روش واقعاً با نیازمندیهای خاص برنامه شما همخوانی دارد، بسیار مهم است. عواملی مانند اهمیت بازیابی دادههای حذف شده، نیازهای ممیزی (auditing) و مقررات صنعت خود را در نظر بگیرید. ایجاد یک ایندکس فیلتر شده میتواند عملکرد کوئری را در جداول دارای رکوردهای حذف نرم شده بهبود بخشد.
اگر تصمیم گرفتید که حذف نرم برای شما مناسب است، EF Core ابزارهای لازم را برای یک پیادهسازی روان و ساده فراهم میکند.
شما همچنین میتوانید یک ایندکس فیلتر شده را با استفاده از SQL ایجاد کنید:
CREATE INDEX IX_Reviews_IsDeleted
ON bookings.Reviews (IsDeleted)
WHERE IsDeleted = 0;
شما میتوانید از طریق مستندات، اطلاعات بیشتری در مورد ایندکسهای فیلتر شده کسب کنید.
آیا واقعاً به حذف نرم نیاز دارید؟ 🤔
ارزشش را دارد که فکر کنید آیا اصلاً به حذف نرم رکوردها نیاز دارید یا خیر.
در سیستمهای سازمانی (enterprise)، شما معمولاً به "حذف" داده فکر نمیکنید. مفاهیم تجاری وجود دارند که شامل حذف داده نمیشوند. چند مثال عبارتند از: لغو یک سفارش، بازپرداخت یک پرداخت، یا باطل کردن یک فاکتور. این عملیاتهای "مخرب" سیستم را به حالت قبلی بازمیگردانند. اما از دیدگاه تجاری، شما واقعاً در حال حذف داده نیستید. 💼
حذف نرم در صورتی مفید است که خطر حذف تصادفی وجود داشته باشد. این قابلیت به شما امکان میدهد رکوردهای حذف نرم شده را به راحتی بازیابی کنید.
در هر صورت، در نظر بگیرید که آیا حذف نرم از دیدگاه تجاری منطقی است یا خیر.
نکات کلیدی (Takeaway) 📌
حذف نرم یک شبکه ایمنی ارزشمند برای بازیابی اطلاعات ارائه میدهد و میتواند ردیابی دادههای تاریخی را بهبود بخشد.
با این حال، ارزیابی اینکه آیا این روش واقعاً با نیازمندیهای خاص برنامه شما همخوانی دارد، بسیار مهم است. عواملی مانند اهمیت بازیابی دادههای حذف شده، نیازهای ممیزی (auditing) و مقررات صنعت خود را در نظر بگیرید. ایجاد یک ایندکس فیلتر شده میتواند عملکرد کوئری را در جداول دارای رکوردهای حذف نرم شده بهبود بخشد.
اگر تصمیم گرفتید که حذف نرم برای شما مناسب است، EF Core ابزارهای لازم را برای یک پیادهسازی روان و ساده فراهم میکند.
🔖 هشتگها:
#EntityFrameworkCore #EFCore #SoftDelete #Database #DataPersistence #SQL
✅ چرا این روش بهتره؟
🔹 قصد (Intent) واضح است: وقتی LeftJoin را میبینی، دقیقاً میدانی چه اتفاقی میافتد.
🔹 کد کمتر، اجزای کمتر: دیگه خبری از GroupJoin، DefaultIfEmpty یا SelectMany نیست.
🔹 همان نتیجه: همهی محصولات حفظ میشن، حتی اگر بعضی از آنها هیچ Reviewای نداشته باشن.
💡 نکته:
در زمان نگارش این مقاله، C# Query Syntax (ساختار from … select …) هنوز کلیدواژههای مخصوص left join یا right join رو نداره.
پس فعلاً باید از Method Syntax استفاده کنی که در مثال بالا نشون داده شده.
🆕 همچنین جدید در RightJoin
EF Core 10:
•RightJoin
تمام ردیفهای سمت راست را حفظ میکند و فقط ردیفهای منطبق از سمت چپ را نگه میدارد.
EF Core
این متد را به RIGHT JOIN در SQL ترجمه میکند.
این روش زمانی کاربرد دارد که سمت دوم دادهها (Right Side) برای ما بخش «ضروری برای حفظ» باشد.
💡 بهصورت مفهومی:
var query = dbContext.Reviews
.RightJoin(
dbContext.Products,
review => review.ProductId,
product => product.Id,
(review, product) => new
{
ProductId = product.Id,
product.Name,
product.Price,
ReviewId = (int?)review.Id ?? 0,
Rating = (int?)review.Rating ?? 0,
Comment = review.Comment ?? "N/A"
});
🧠 چرا از RightJoin استفاده کنیم؟
وقتی گزارشهایت از جدول Reviews شروع میشن (و میخوای همهشون حفظ بشن)،
در عین حال میخوای Products مرتبط رو هم بیاری اگر وجود داشته باشن.
📊 SQL تولیدشده توسط EF Core:
SELECT
p."Id" AS "ProductId",
p."Name",
p."Price",
COALESCE(r."Id", 0) AS "ReviewId",
COALESCE(r."Rating", 0) AS "Rating",
COALESCE(r."Comment", 'N/A') AS "Comment"
FROM "Reviews" AS r
RIGHT JOIN "Products" AS p ON r."ProductId" = p."Id"
🧩 جمعبندی
به این فکر کن که چقدر left joinها در پروژههایت استفاده میشوند:
• نمایش تمام کاربران حتی اگر تنظیمات پروفایل نداشته باشند
• نمایش همهی محصولات حتی اگر هیچ Reviewای برایشان ثبت نشده باشد
• نمایش همهی سفارشها حتی اگر هنوز اطلاعات ارسال (Shipping Info) نداشته باشند
تقریباً همهجا باهاش سروکار داریم! 🚀
پیش از این، توسعهدهندهها گاهی برای دور زدن پیچیدگی GroupJoin و DefaultIfEmpty، دو Query جداگانه مینوشتند 😩
یا بدتر از آن، از Inner Join استفاده میکردند و دادههایی را از دست میدادند.
اما حالا دیگه هیچ بهانهای نیست ✨
LeftJoin و RightJoin
درست مثل هر متد LINQ دیگهای ساده و خوانا هستند.
⚙️ چند نکته سریع برای نوشتن Queryهای LINQ:
✅ در Projectionها، سمت Nullable را با ?? محافظت کن:
review.Comment ?? "N/A"
✅ Projection
ها را کوچک نگه دار تا دادههای اضافی از دیتابیس نخوانی.
✅ روی ستونهای Join (کلیدهای اتصال) ایندکس بگذار تا Query Plan بهینه شود.
💡 حالا با اضافه شدن LeftJoin و RightJoin،
کدی که مینویسی دقیقاً با مدل ذهنیات منطبق است — واضح، تمیز، و قابل نگهداری.
🔖هشتگها:
#EntityFrameworkCore #DotNet10 #EFCore #LINQ #LeftJoin #RightJoin