تعدد العملاء
الفهرس
نظرة عامة
شركة برمجيات عندها كذا نظام أعمال (موارد بشرية، محاسبة، مخازن) بيخدموا جهات حكومية وخاصة. القرار: قاعدة كود واحدة وdeployment واحد لكل نظام، والعميل (الـ tenant) بيتحدد من الـ subdomain اللي فاتح منه، والـ backend بيختار قاعدة بياناته من header واحد.
المشكلة — فرع وموقع لكل عميل
كل عميل كان ليه فرع git (نفس كود main بالظبط)، وتطبيق Netlify خاص بيه (branch deploy بـ env vars لكل عميل زي الـ API URL والعنوان واللوجو)، وbackend app خاص بيه على مزوّد الاستضافة. في نظام الموارد البشرية لوحده كان فيه حوالي 50 فرع عميل — يعني حوالي 50 deployment منفصل. أي تعديل تطلبه جهة واحدة كان بيتبني ويتجرب على الديمو، وبعدين يتعمله merge وpush يدوي في كل فرع عميل.
| المعيار | قبل — فرع لكل عميل | بعد — subdomain لكل عميل |
|---|---|---|
| الكود | فرع git لكل عميل (نسخة من main) | قاعدة كود واحدة |
| الـ frontend deployment | تطبيق Netlify لكل عميل (حوالي 50 في الموارد البشرية لوحدها) | deployment واحد لكل نظام على wildcard subdomain |
| الإعدادات | env vars لكل موقع (API URL، العنوان، اللوجو) | API base URL واحد للكل، والعميل بيتعرف من الـ subdomain |
| الـ backend | backend app لكل عميل | backend app واحد بيختار قاعدة البيانات من الـ header |
| إصلاح لكل العملاء | حوالي 50 merge وpush يدوي | deployment واحد |
| التكلفة | build minutes فوق الـ free tier + backend app لكل عميل | build minutes جوه الـ free tier + backend app واحد |
القرار
درست المشكلة، واقترحت إن الـ tenant يتحدد من الـ subdomain. عرضت الفكرة في اجتماع، وحللنا كل نقطة فيها مع المدير، واتوافق عليها مباشرة.
صممت الطريقة وبنيت جانب الـ frontend. الـ backend (.NET، مسؤولية فريق الـ backend) بيحتفظ بقاعدة بيانات لكل tenant وبيختار القاعدة حسب الـ header.
مسار الطلب
تحديد الـ tenant — getTenantId
في نسخ Nuxt الحالية الـ hostname بييجي من useRequestURL().hostname — على السيرفر من الطلب نفسه (الـ host header)، وعلى الكلاينت من المتصفح. (النسخة القديمة الـ SPA كانت بتقراه من window.location.hostname.)
const BASE_DOMAIN = 'example.com'
const RESERVED = ['www', 'demo']
export const getTenantId = (hostname) => {
if (!hostname || hostname === 'localhost') return null
if (isIpAddress(hostname)) return null
if (isTunnelHost(hostname) || isPlatformDefaultHost(hostname)) return null
const suffix = '.' + BASE_DOMAIN
if (!hostname.endsWith(suffix)) return null
const label = hostname.slice(0, -suffix.length)
if (label.includes('.') || RESERVED.includes(label)) return null
return label
}| الـ host | النتيجة | ليه |
|---|---|---|
| acme.example.com | acme | subdomain عميل — بيتبعت header |
| www.example.com | null | www مش عميل |
| demo.example.com | null | الديمو مستثنى — بيروح للقاعدة الافتراضية |
| localhost | null | تطوير محلي — القاعدة الافتراضية |
| 192.168.1.10 | null | عنوان IP مش subdomain عميل |
| dev tunnel host (زي ngrok) | null | مش subdomain عميل |
| الدومين الافتراضي لمنصة الاستضافة | null | شكله زي subdomain عميل، بس مش عميل |
الـ Interceptor
الأمثلة دي مبسّطة للتوضيح، مش كود الإنتاج.
// composables/useTenantId.js
export const useTenantId = () => getTenantId(useRequestURL().hostname)
// plugins/api.js
export default defineNuxtPlugin(() => {
const tenantId = useTenantId()
const api = $fetch.create({
baseURL: '/api',
onRequest({ options }) {
if (tenantId) options.headers.set('tenantId', tenantId)
}
})
return { provide: { api } }
})الـ plugin بيتنفذ مرة لكل request على السيرفر، ومرة واحدة في المتصفح — فنفس الـ tenant بيتحسب في الحالتين من غير أي كود خاص بكل بيئة. والطلبات اللي رايحة لسيرفر الهوية/تسجيل الدخول المنفصل مابتاخدش الـ header.
إيه اللي بيختلف من عميل للتاني
البيانات بس. الكود واحد والـ build واحد. حتى الوضع (خاص/عام) مش إعداد للـ tenant — بيتحدد من رد الـ profile بعد تسجيل الدخول، والآلية دي متوثقة في نظام الأوضاع.
العزل
| الطبقة | الآلية | النتيجة |
|---|---|---|
| قاعدة البيانات | قاعدة لكل tenant، والـ backend بيختارها من الـ header | بيانات كل عميل منفصلة |
| الـ session | auth cookie من نوع host-only — من غير ما نحدد cookie domain | كل subdomain ليه session مستقلة، والكوكي مابتتبعتش لأي subdomain تاني |
النتيجة إن session كل subdomain معزولة أوتوماتيك، من غير أي كود إضافي. (النسخة القديمة الـ SPA كانت بتخزن التوكن في localStorage، وده برضه مربوط بالـ origin، فالعزل كان متحقق هناك كمان.)
البدائل
مفيش معماريات تانية اتقارنت رسمياً — الفكرة اتعرضت، واتحللت نقطة نقطة، واتوافق عليها.
| البديل | المشكلة |
|---|---|
| نكمّل على فرع + موقع + backend app لكل عميل | الـ drift والـ merges اليدوية بيزيدوا مع كل عميل جديد، والتكلفة بتكبر معاهم |
ما تغيّر أثناء البناء
| التغيير | البداية | اللي ظهر | التعديل |
|---|---|---|---|
| اسم الـ header | tenant_id | بعض الـ proxies بتشيل الـ headers اللي فيها underscore (nginx بيعمل كده افتراضياً). | الاسم بقى tenantId — مايعتمدش على إعداد proxy معين. |
| الديمو | الديمو subdomain زي أي عميل، فكان بيبعت header. | الديمو مش عميل — هو النسخة اللي بتتجرب عليها التعديلات. | الديمو اتستثنى ومابيبعتش header. |
| التطوير المحلي | localhost وعناوين الـ IP مالهاش subdomain عميل. | محتاجين التطوير المحلي يشتغل من غير tenant. | الدالة بترجع null، فالتطوير المحلي بيكلّم القاعدة الافتراضية. |
| الـ dev tunnel hosts | host زي ngrok كان بيتقري كأنه tenant. | تسجيل الدخول نجح، بس الـ profile رجع فاضي. | الـ tunnel hosts اتستثنت. |
| الدومين الافتراضي لمنصة الاستضافة | الدومين الافتراضي اللي منصة الاستضافة بتديه للموقع (من الشكل site-name.platform-domain) ماكانش مستثنى. | شكله زي subdomain عميل بالظبط، فأي deploy لسه على الدومين ده أول جزء منه بيتقري كأنه tenant. | الدومينات الافتراضية للمنصة اتستثنت زي الـ tunnel hosts. |
النتائج والحالة
| النظام | الحالة |
|---|---|
| الموارد البشرية | اتنقل وشغّال في الإنتاج، ونسخة Nuxt الجديدة مبنية بنفس النمط |
| المحاسبة | اتنقل وشغّال في الإنتاج |
| المخازن | بيتنقل لنفس النمط دلوقتي |