كيفية هيكلة الواجهة الخلفية لتطبيق SaaS متعدد المستأجرين
دليل عملي لهندسة تطبيقات SaaS متعددة المستأجرين — من استراتيجيات عزل قواعد البيانات إلى المصادقة، الفوترة، ومؤشرات الميزات.

في اللحظة التي تقرر فيها أن تطبيقك سيخدم أكثر من مؤسسة واحدة، تتشعب كل القرارات المعمارية. تصميم قاعدة البيانات، المصادقة، التفويض، الفوترة، وحتى التسجيل — كل ذلك يحمل الآن السؤال: لأي مستأجر؟
لقد قمت بنشر واجهات خلفية متعددة المستأجرين عبر مجالات مختلفة جدًا. الأنماط التي تصمد في بيئة الإنتاج نادرًا ما تكون هي الأنماط التي تبدو أنظف على السبورة البيضاء. يغطي هذا المنشور القرارات التي تهم حقًا، والمقايضات التي لا يحذرك أحد بشأنها، وأنماط التنفيذ الملموسة التي يمكنك الاستفادة منها.
استراتيجيات عزل قواعد البيانات
هذا هو القرار الأول والأكثر أهمية. إذا أخطأت فيه، فستواجه عملية ترحيل تمس كل استعلام في قاعدة بياناتك.
هناك ثلاثة أساليب رئيسية:
| الاستراتيجية | العزل | التعقيد | التكلفة | الأفضل لـ |
|---|---|---|---|---|
| قاعدة بيانات مشتركة، مخطط مشترك | منخفض | منخفض | الأدنى | تطبيقات SaaS في المراحل المبكرة، أقل من 100 مستأجر |
| قاعدة بيانات مشتركة، مخطط لكل مستأجر | متوسط | متوسط | متوسط | النطاق المتوسط، الصناعات المنظمة |
| قاعدة بيانات لكل مستأجر | عالٍ | عالٍ | الأعلى | عملاء الشركات، الامتثال الصارم |
قاعدة بيانات مشتركة، مخطط مشترك
تعيش بيانات كل مستأجر في نفس الجداول. يمكنك تمييز الصفوف بواسطة عمود tenant_id. هذا هو المكان الذي يجب أن تبدأ منه معظم منتجات SaaS.
الميزة هي البساطة. مسار ترحيل واحد، مجمع اتصالات واحد، استعلامات مباشرة. الجانب السلبي هو أن فقدان شرط WHERE tenant_id = ? يعني تسربًا للبيانات. لا توجد شبكة أمان على مستوى البنية التحتية — العزل يتم بالكامل في كود تطبيقك.
يعمل هذا النهج حتى تبدأ استعلامات مستأجر كبير في تدهور الأداء للجميع، أو حتى يسأل فريق الامتثال لعميل مؤسسي عن كيفية فصل بياناتهم ماديًا عن المنافسين. في تلك المرحلة، تنتقل إلى مستوى أعلى.
قاعدة بيانات مشتركة، مخطط لكل مستأجر
يحصل كل مستأجر على مخطط PostgreSQL خاص به (أو ما يعادله) ضمن مثيل قاعدة بيانات مشتركة. الجداول متطابقة عبر المخططات، ولكنها مفصولة ماديًا. يمكنك تبديل مسار البحث عند وقت الاتصال.
يمنحك هذا عزلًا حقيقيًا دون الحاجة إلى إدارة عشرات من مثيلات قواعد البيانات. عمليات الترحيل أكثر تعقيدًا — حيث تقوم بتشغيلها N مرة — ولكن أدوات مثل node-pg-migrate أو دعم Prisma للمخططات المتعددة تتعامل مع هذا بشكل معقول.
المشكلة: يصبح تجميع الاتصالات معقدًا. إذا كنت تستخدم PgBouncer، فأنت بحاجة إلى أن تكون حذرًا بشأن كيفية تفاعل تبديل المخطط مع الاتصالات المجمعة. لقد رأيت هذا يسبب أخطاء دقيقة حيث يقرأ المستأجر A مخطط المستأجر B للحظة لأن اتصالًا تم إرجاعه إلى المجمع في منتصف المعاملة.
قاعدة بيانات لكل مستأجر
أقصى درجات العزل. لكل مستأجر مثيل قاعدة بيانات مخصص (أو على الأقل قاعدة بيانات منطقية مخصصة). تقوم بتوجيه الاتصالات ديناميكيًا بناءً على سياق المستأجر.
هذا مكلف وثقيل من الناحية التشغيلية، لكن بعض العملاء سيدفعون ثمنه. غالبًا ما تتطلبه خدمات الرعاية الصحية والخدمات المالية. الفائدة الرئيسية بخلاف العزل هي أنه يمكنك توسيع نطاق المستأجرين ونسخهم احتياطيًا واستعادتهم بشكل مستقل. لا يمكن لجيران مزعجين أن يؤثروا سلبًا على مثيلك المشترك.
في الممارسة العملية، تدير معظم الفرق نظامًا هجينًا: قاعدة بيانات مشتركة للأغلبية، ومثيلات مخصصة لحسابات الشركات التي تدفع مقابلها.
المصادقة وتحديد المستأجر
يحتاج كل طلب وارد إلى الإجابة على سؤالين: من هو هذا المستخدم؟ و إلى أي مستأجر ينتمي؟
هناك ثلاث استراتيجيات شائعة لتحديد سياق المستأجر:
مبني على النطاق الفرعي (Subdomain-based): acme.yourapp.com يرتبط بالمستأجر Acme. نظيف، بديهي، ويعمل بشكل رائع لمنتجات B2B حيث يحصل كل عميل على مساحة عمل خاصة به. تستخرج معرف المستأجر (tenant slug) من رأس Host.
مبني على مطالبة JWT (JWT claim-based): يتم تضمين معرف المستأجر في رمز الوصول عند تسجيل الدخول. لا حاجة لتوجيه على مستوى البنية التحتية. يعمل بشكل جيد لتطبيقات الجوال وتطبيقات الصفحة الواحدة (SPAs) حيث يكون توجيه النطاق الفرعي غير عملي.
مبني على الرأس (Header-based): يرسل العميل رأس X-Tenant-ID. بسيط ولكنه خطير إذا لم يتم التحقق منه مقابل أذونات المستخدم المصادق عليه. استخدم هذا فقط للمكالمات الداخلية بين الخدمات.
في معظم الأنظمة، أجمع بين تحديد النطاق الفرعي لتدفق تسجيل الدخول الأولي مع مطالبات JWT لمكالمات API اللاحقة. إليك كيف تبدو البرمجيات الوسيطة (middleware):
import { Request, Response, NextFunction } from "express";
import { verifyToken } from "../lib/auth";
import { getTenantBySlug } from "../services/tenant";
export async function tenantMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
// 1. Try JWT claim first (authenticated requests)
const token = req.headers.authorization?.replace("Bearer ", "");
if (token) {
const payload = verifyToken(token);
if (payload?.tenantId) {
req.tenantId = payload.tenantId;
return next();
}
}
// 2. Fall back to subdomain resolution
const host = req.hostname;
const slug = host.split(".")[0];
if (slug === "www" || slug === "app") {
return res.status(400).json({ error: "Tenant not identified" });
}
const tenant = await getTenantBySlug(slug);
if (!tenant || !tenant.isActive) {
return res.status(404).json({ error: "Tenant not found" });
}
req.tenantId = tenant.id;
next();
}
قم بتركيب هذا قبل معالجات المسار الخاصة بك، وستتمكن كل دالة تالية من الوصول إلى req.tenantId. لن يضطر بقية تطبيقك أبدًا إلى التفكير في كيف يعمل تحديد المستأجر — بل يقرأ المعرف فقط من سياق الطلب.
تحديد نطاق كل استعلام قاعدة بيانات
بمجرد أن يكون لديك سياق المستأجر في كل طلب، تحتاج إلى ضمان تطبيقه على كل استعلام. فقدان النطاق هو خرق للبيانات. هذا ليس شيئًا تتركه لانضباط المطورين.
أفضل نمط وجدته هو طبقة مستودع تفرض النطاق تلقائيًا:
import { db } from "../lib/database";
export function scopedQuery(tenantId: string) {
return {
async findMany<T>(table: string, where: Record<string, unknown> = {}): Promise<T[]> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.select("*");
},
async findOne<T>(table: string, where: Record<string, unknown>): Promise<T | null> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.first();
},
async insert<T>(table: string, data: Record<string, unknown>): Promise<T> {
return db(table)
.insert({ ...data, tenant_id: tenantId })
.returning("*")
.then((rows) => rows[0]);
},
async update(
table: string,
where: Record<string, unknown>,
data: Record<string, unknown>
): Promise<number> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.update(data);
},
async remove(table: string, where: Record<string, unknown>): Promise<number> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.del();
},
};
}
في معالجات المسار الخاصة بك، لا تقوم أبدًا بإنشاء استعلامات خام. أنت دائمًا تمر عبر الواجهة المحددة النطاق:
app.get("/api/projects", async (req, res) => {
const query = scopedQuery(req.tenantId);
const projects = await query.findMany("projects", { archived: false });
res.json(projects);
});
إذا كنت تستخدم ORM مثل Prisma، يمكنك تحقيق نفس الشيء باستخدام برمجيات وسيطة (middleware) تقوم بحقن مرشح tenant_id في كل استدعاء findMany و findFirst و update و delete. الهدف هو جعل المسار غير الآمن أصعب من المسار الآمن.
التحكم في الوصول المستند إلى الأدوار داخل المستأجرين
يقدم تعدد المستأجرين نموذج تفويض من طبقتين. أولاً: هل ينتمي هذا المستخدم إلى هذا المستأجر؟ ثانيًا: ماذا يمكنه أن يفعل داخله؟
أبقي هذا الأمر بسيطًا باستخدام جدول tenant_memberships:
CREATE TABLE tenant_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
role VARCHAR(50) NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, tenant_id)
);
الأدوار عادة ما تكون owner (مالك)، admin (مسؤول)، member (عضو)، وأحيانًا viewer (عارض) أو دور خاص بالمجال. تجنب بناء مصفوفة أذونات كاملة ما لم يكن منتجك يحتاجها حقًا — معظم تطبيقات SaaS من نوع B2B تعمل بشكل جيد مع 3-4 أدوار.
يتم فحص التفويض بعد تحديد المستأجر:
export function requireRole(...roles: string[]) {
return async (req: Request, res: Response, next: NextFunction) => {
const membership = await db("tenant_memberships")
.where({ user_id: req.userId, tenant_id: req.tenantId })
.first();
if (!membership || !roles.includes(membership.role)) {
return res.status(403).json({ error: "Insufficient permissions" });
}
req.userRole = membership.role;
next();
};
}
// Usage
app.delete("/api/projects/:id", requireRole("owner", "admin"), async (req, res) => {
// only owners and admins reach this handler
});
أحد خيارات التصميم التي توفر عليك الصداع لاحقًا: اسمح دائمًا للمستخدم بالانتماء إلى عدة مستأجرين. حتى لو كان منتجك يعمل بمساحة عمل واحدة اليوم، ففي اللحظة التي تضيف فيها حسابات وكالات، أو بائعين بعلامة بيضاء، أو وضع استشاري، ستحتاج إلى ذلك.
مؤشرات الميزات لكل مستأجر
ليس كل مستأجر يجب أن يرى كل ميزة. أحيانًا تقوم بتقييد الميزات حسب مستوى الخطة. أحيانًا تقوم بتنفيذ طرح تدريجي. وأحيانًا يدفع عميل مؤسسي مقابل قدرة مخصصة.
أستخدم جدول tenant_features بسيطًا بدلاً من استخدام خدمة مؤشرات الميزات من طرف ثالث. بالنسبة لمعظم منتجات SaaS، هذا أكثر من كافٍ:
// Cache-backed feature flag check
const featureCache = new Map<string, Set<string>>();
export async function hasFeature(tenantId: string, feature: string): Promise<boolean> {
if (!featureCache.has(tenantId)) {
const rows = await db("tenant_features")
.where({ tenant_id: tenantId, enabled: true })
.select("feature_key");
featureCache.set(tenantId, new Set(rows.map((r) => r.feature_key)));
}
return featureCache.get(tenantId)!.has(feature);
}
// Invalidate on plan change or manual toggle
export function invalidateFeatureCache(tenantId: string) {
featureCache.delete(tenantId);
}
في معالجات المسار:
app.post("/api/analytics/export", async (req, res) => {
if (!(await hasFeature(req.tenantId, "analytics_export"))) {
return res.status(403).json({
error: "This feature is not available on your current plan",
upgrade: true,
});
}
// proceed with export
});
عندما يقوم المستأجر بترقية خطته، تقوم بتبديل صفوف الميزات ذات الصلة وإبطال ذاكرة التخزين المؤقت. الأمر مباشر، قابل للتدقيق، ولا توجد تبعيات خارجية.
دمج الفوترة
Stripe هو المعيار هنا لسبب وجيه. الربط طبيعي: عميل Stripe واحد لكل مستأجر، اشتراك واحد لكل مستأجر، مستويات الخطط ترتبط بالمنتجات والأسعار.
القاعدة التصميمية الحاسمة: قم بتخزين معرف عميل Stripe في سجل المستأجر، وليس في سجل المستخدم. الفوترة تخص المؤسسة، وليس الشخص الذي أدخل بطاقة الائتمان.
يبدو تدفق الويب هوك (webhook) كالتالي:
checkout.session.completed— إرفاق معرف عميل Stripe بالمستأجر، وتفعيل اشتراكه.invoice.paid— تمديد فترة الفوترة، وتحديثtenant.paidUntil.invoice.payment_failed— وضع علامة على المستأجر كمتأخر عن الدفع، وإرسال رسائل تذكير بالدفع.customer.subscription.updated— مزامنة تغييرات مستوى الخطة، وتحديث مؤشرات الميزات وفقًا لذلك.customer.subscription.deleted— الرجوع إلى الخطة المجانية أو التعليق.
المفتاح هو خاصية التكرارية (idempotency). يمكن لـ Stripe أن يرسل نفس الويب هوك عدة مرات وسيفعل ذلك. يجب أن يكون كل معالج آمنًا للتشغيل مرتين بنفس حمولة الحدث.
عزل البيانات والأمان
بالإضافة إلى تحديد نطاق الاستعلامات، هناك بعض الأمور غير الواضحة التي يجب عليك إتقانها مبكرًا:
أمان على مستوى الصفوف (Row-Level Security - RLS): إذا كنت تستخدم PostgreSQL، فقم بتمكين RLS كخط دفاع ثانٍ. حتى لو كان كود تطبيقك يحتوي على خطأ، فإن قاعدة البيانات نفسها سترفض إرجاع الصفوف التي لا تتطابق مع سياق المستأجر الحالي. قم بتعيين app.current_tenant عند وقت الاتصال واكتب سياسات بناءً عليه.
سياق التسجيل (Logging context): يجب أن يتضمن كل سطر سجل tenantId. عندما يحدث خطأ في الساعة 3 صباحًا، تحتاج إلى معرفة المستأجر المتأثر دون البحث في تتبعات الطلبات. التسجيل المنظم مع حقل المستأجر المضمن في سياق المسجل يجعل هذا تلقائيًا.
النسخ الاحتياطي وحذف البيانات: سيتغير المستأجرون، وسيطالب البعض بحقوق اللائحة العامة لحماية البيانات (GDPR) أو حقوق حذف البيانات. إذا كانت جميع بياناتك في جداول مشتركة، فإن تطهير مستأجر واحد يعني عبارات DELETE مصممة بعناية عبر كل جدول. مع مخطط لكل مستأجر أو قاعدة بيانات لكل مستأجر، يكون الأمر DROP SCHEMA أو DROP DATABASE. خطط لهذا من اليوم الأول.
تحديد المعدل لكل مستأجر (Rate limiting per tenant): محدد المعدل العالمي لا يكفي. لا ينبغي أن يستهلك استيراد دفعة لمستأجر واحد من ميزانية معدل مستأجر آخر. حدد نطاق محدد المعدل الخاص بك بواسطة tenantId، وليس فقط بواسطة عنوان IP أو المستخدم.
التشفير في وضع السكون (Encryption at rest): للصناعات الحساسة، قم بتشفير بيانات المستأجر في وضع السكون باستخدام مفاتيح لكل مستأجر. يضيف هذا تعقيدًا لإدارة المفاتيح، ولكنه يعني أن إلغاء وصول المستأجر بسيط مثل تدمير مفتاحه. لا تبقى أي بيانات قابلة للقراءة على القرص بعد إزالة المستأجر.
أفكار أخيرة
تعدد المستأجرين ليس مكتبة تقوم بتثبيتها. إنه مجموعة من القرارات المعمارية التي تمس كل طبقة من طبقات نظامك، من قاعدة البيانات إلى شبكة توصيل المحتوى (CDN). الخبر السار هو أن الأنماط مفهومة جيدًا. الجزء الصعب هو تطبيقها بالمستوى الصحيح من العزل لسوقك ومرحلتك المحددة.
ابدأ بكل شيء مشترك وطبقة تحديد نطاق منضبطة. أضف حدود العزل عندما يطلبها عملاؤك. لا تبالغ في الهندسة لتلبية متطلبات الامتثال التي لا تملكها بعد، ولكن تأكد من أن بنيتك يمكن أن تتطور نحو عزل أكثر صرامة دون إعادة كتابة.
لقد قمت ببناء أنظمة متعددة المستأجرين من منصات المطاعم إلى تطبيقات SaaS الطبية — كل منها بمتطلبات العزل والتوسع الخاصة بها. الأنماط في هذا المنشور هي تلك التي صمدت عبر جميعها.
مشاريع ذات صلة
RestoHub
المطاعم تتوقف عن خسارة 30% لصالح Uber Eats — تحصل على نظام طلبات وقوائم وموقع إلكتروني وبرنامج ولاء خاص بها في منصة واحدة. تجربة طلب كاملة بمستوى Uber Eats، لكن المطعم يحتفظ بكل قرش.
TakeCare
ممرض واحد يراقب الآن 250 مريضًا عن بُعد — ليحل محل المكالمات الهاتفية والزيارات المنزلية اليدوية في أكبر مستشفيات كيبيك. يعمل حاليًا في مستشفى Jewish General وCHUM ومعهد Douglas للصحة النفسية.