العودة إلى المدونة

بناء أنظمة متعددة المستأجرين قابلة للتوسع باستخدام Next.js وPrisma

Muhammad Umar
مدة القراءة بالدقائق: 2

مشكلة التفكير في عميل واحد

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

تتناول هذه المقالة القرارات المعمارية التي اتخذتها أثناء بناء نظام خلفي لنقاط البيع متعدد المستأجرين يخدم مواقع تجزئة تابعة لمؤسسات متعددة.

النهج: عزل المستأجرين على مستوى الصف

هناك ثلاثة أنماط شائعة:

  1. قواعد بيانات منفصلة — عزل فعلي بتكلفة تشغيل مرتفعة.
  2. مخططات منفصلة — عزل جيد مع تعقيد في ترحيلات قاعدة البيانات.
  3. مخطط مشترك مع معرّف المستأجر — بداية أسهل تتطلب التزامًا صارمًا بتصفية الاستعلامات.

يوفر العزل على مستوى الصف لمعظم منتجات SaaS المبكرة توازنًا مناسبًا بين البساطة والعزل.

// Every table that holds tenant data includes a tenantId
model Product {
  id       String @id @default(cuid())
  tenantId String
  name     String
  price    Decimal

  tenant   Tenant @relation(fields: [tenantId], references: [id])

  @@index([tenantId])
}

فرض عزل المستأجرين

يكمن الخطر في تسرب البيانات عرضًا إذا أغفل أحد الاستعلامات التصفية باستخدام tenantId. عالجت ذلك بنمط المستودع:

// src/lib/db/repositories/product.repository.ts
export function createProductRepository(tenantId: string) {
  return {
    findAll: () =>
      prisma.product.findMany({
        where: { tenantId },
        orderBy: { createdAt: "desc" },
      }),

    findById: (id: string) =>
      prisma.product.findFirst({
        where: { id, tenantId }, // tenantId always included
      }),
  };
}

تمر كل عملية على قاعدة البيانات عبر المستودع الذي يتلقى سياق المستأجر من الجلسة دائمًا.

تحديد المستأجر في Next.js App Router

باستخدام App Router، أستخرج المستأجر من الجلسة في التخطيط الجذري وأمرره عبر سياق React:

// app/[tenantSlug]/layout.tsx
export default async function TenantLayout({ params, children }) {
  const tenant = await getTenantBySlug(params.tenantSlug);
  if (!tenant) notFound();

  return (
    <TenantProvider tenant={tenant}>
      {children}
    </TenantProvider>
  );
}

تقرأ Server Actions المستأجر من الجلسة بدل مدخلات المستخدم، للحماية من هجمات الوصول المباشر غير المصرح به إلى الكائنات (IDOR).

الدروس المستفادة

  • لا تثق بمعرّف المستأجر القادم من العميل. استمد السياق دائمًا من الجلسة التي جرى التحقق من هوية صاحبها.
  • أضف فهارس لأعمدة tenantId. يتراجع أداء الاستعلامات سريعًا دونها.
  • خطط لترحيل البيانات مبكرًا. تحتاج إضافة tenantId للجداول القائمة دون توقف إلى تعبئة مدروسة للبيانات القديمة.
  • استخدم الحذف المنطقي بدل النهائي. تتطلب بيانات المستأجر تدقيقًا، وتكون الطوابع الزمنية في deletedAt أكثر أمانًا.

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

العودة إلى المدونة