PRODمنصة BaaS الأوروبية السياديةافتح لوحة المعلومات →

الهندسة · 10 دقيقة للقراءة

تصميم بوابة API ثنائية المستوى في Rust

Affane Daylami · Fondateur · 6 أغسطس 2026

العودة إلى بلوق

البوابة التي تخدم حركة مرور SDK الخاصة بالمستخدمين وحركة مرور مسؤول المكتب الخلفي لديك لا تحمي كليهما بشكل جيد في نفس الوقت. تعمل بوابة Aurabase (بوابة الهالة، Rust/axum) على حل هذا التوتر في المراحل الأولية: جهازي توجيه متميزين، ومنفذين، ونموذجي مصادقة، وحالة مشتركة واحدة. يصف هذا الدليل نمط مستوى البيانات/مستوى الإدارة هذا كما هو موجود بالفعل في التعليمات البرمجية - المسارات، وترتيب البرامج الوسيطة، وتحديد المعدل، وقاطع الدائرة، والوكيل - وليس نسخة مثالية من الرسم التخطيطي المعماري.

تم إنشاء هذا النص الإنجليزي تلقائيًا من النص الأصلي الفرنسي ولم تتم مراجعته بعد.
تمت ترجمة هذه الصفحة تلقائيًا. النسخة الإنجليزية موثوقة.

الأساسيات

محوران Router على منفذين (8080 مستوى البيانات، 8090 مستوى الإدارة)، تم إنشاؤهما من نفس AppStateالمشترك. يتطلب مستوى البيانات مفتاح API على أي مسار؛ تتطلب إدارة المستوى وحدة تحكم جمهور JWT مخصصة - فالآليتان لا تتداخلان أبدًا. يتم تطبيق تحديد المعدل مرتين: بواسطة IP قبل المصادقة، ثم بواسطة الممثل المصادق عليه بعد ذلك. قاطع الدائرة ليس برنامجًا وسيطًا عالميًا: إنه كائن لكل خدمة (ولكل هدف مخصص لـ PostgREST)، يتم استدعاؤه مباشرة في رمز الوكيل. ويقوم الوكيل النهائي بتغيير النقل اعتمادًا على المسار - أحيانًا وفقًا لطريقة HTTP أو البحث في قاعدة البيانات: طلب/رد NATS لمعظم حركة المرور، وتدفق HTTP المباشر للتخزين، والمتغيرات الثلاثة في الوقت الفعلي وPostgres CRUD المخصص.

#
المشكلة

بوابة واحدة، وجمهورين مختلفين للغاية

تأتي حركة مستوى البيانات من SDK أو تطبيق العميل: حجم الطلبات المجهولة أو الطلبات التي تمت مصادقتها بواسطة مفتاح واجهة برمجة التطبيقات، مع ملف تعريف إساءة الاستخدام بالقرب من أي واجهة برمجة تطبيقات عامة. يأتي مستوى إدارة حركة المرور من الاستوديو - واجهة إدارة المشروع - ويحمل عمليات حساسة: إنشاء المشروع، وتدوير المفاتيح، وقراءة سجلات المستأجر. يشترك الاثنان في هدف مشترك (الوكالة لنفس الخدمات الداخلية: aura-auth، aura-db، aura-storage، وما إلى ذلك) ولكن ليس نفس سطح الخطر.

يتطلب المرور عبر نفس جهاز التوجيه الاختيار بين خيارين سيئين: إما أن يرث Studio CORS حرف البدل الضروري لمجموعة SDK العامة (Access-Control-Allow-Origin: *)، أو يرث SDK قائمة أصول مقيدة مصممة للوحة معلومات داخلية. يقوم كود بوابة الهالة بفرز هذا التوتر من main.rs: اثنان منفصلان Router، كل منهما له CorsLayer الخاص به - حرف البدل المعتمد على جانب مستوى البيانات، تم رفضه وتسجيله كخطأ على جانب مستوى الإدارة.

#
الخطوة 1

جهازي توجيه أكسوم، أحدهما AppState مشترك

لا يعد الفصل عملية نشر منفصلة: تعمل الخطتان في نفس العملية، على نفس AppState (تجمعات Postgres، عميل NATS، ذاكرات Moka المؤقتة، قواطع الدائرة). يختلف بناء Router فقط، عبر وظيفتين مخصصتين يتم استدعاء كل منهما مرة واحدة عند بدء التشغيل ويتم تقديمهما بواسطة وظيفتين منفصلتين TcpListener.

gateway/server.rsrust
// منفذين، وجهازي توجيه، وAppState واحد

let data_addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
let mgmt_addr: SocketAddr = format!("{}:{}", config.host, config.management_port).parse()?;

let data_listener = TcpListener::bind(data_addr).await?;
let mgmt_listener = TcpListener::bind(mgmt_addr).await?;

// نفس الحالة، رسمان بيانيان مختلفان للطريق
let data_app = build_data_plane_router(state.clone(), data_cors);
let mgmt_app = build_management_plane_router(state);

tokio::join!(
    axum::serve(data_listener, data_app),
    axum::serve(mgmt_listener, mgmt_app),
);

يبدأ الرسمان البيانيان للمسار من نفس القاعدة، service_routes(): يتم تثبيت نفس معالجات الوكيل (db_proxy, storage_proxy, functions_proxy…) على كلا الخطتين، مع مسارات إضافية خاصة بكل منهما. تؤدي إعادة استخدام نفس المعالجات إلى تجنب التنفيذ المزدوج للوكيل؛ يؤدي التباعد فقط في البرامج الوسيطة إلى تجنب تكرار منطق الأعمال للحصول على حدود أمنية. إذا كانت الواجهة الخلفية الخاصة بك مُصممة كمساحة عمل Cargo متعددة الخدمات، فاطلع على دليل بنية مساحة عمل Cargo — البوابة هي مجرد صندوق واحد من بين صناديق أخرى في هذا القسم.

#
الخطوة 2

تختلف المصادقة عند الدخول

على مستوى البيانات، يكون مفتاح API إلزاميًا على أي مسار، باستثناء عدد قليل من المسارات العامة حقًا (/health, JWKS، نقاط نهاية التسجيل). يتم نقله كرأس apikey أو X-API-Key - أو، لمسارات تدفق WebSocket وSSE فقط، كمعلمة ?apikey=. يحظر الكود صراحةً هذا الوضع الأخير لمفتاح service_role: يتسرب مفتاح URL في سجلات الوصول، وتتبعات OTel، ورأس المُحيل. يظل JWT اختياريًا على جانب مستوى البيانات: بدونه، يظل المتصل anon؛ معه يصبح authenticated.

على مستوى الإدارة، مفتاح API غير موجود: يتم قبول وحدة تحكم JWT فقط، والتي يجب أن يكون جمهورها بالضبط aurabase-control. لا يتم تنفيذ الدور بواسطة الرمز المميز نفسه - يتم إعادة حسابه عند كل طلب من عضوية المستخدم في المؤسسة التي تمتلك المشروع، الموروثة عبر علاقة المشروع → المؤسسة.

الرمز المميز مطلوبمفتاح API (apikey / X-API-Key)، دائمًاوحدة تحكم JWT (التفويض: الحامل)، دائمًا
ارتفاع الدورJWT اختياري: حالًا → مصادق عليهRBAC الموروثة من المؤسسة (المالك/المسؤول/المطور/المشاهد)
أدخل سلسلة الاستعلاميُسمح به في WS/SSE فقط، ولا يُسمح به مطلقًا في Service_roleلا ينطبق
الجمهور المتوقعالمشروع المستهدف (UUID للمسار)ثابت "التحكم aurabase"
كورسحرف البدل * مسموح بهتم رفض Wildcard، أصول الاستوديو فقط
#
الخطوة 3

الترتيب الحقيقي للبرمجيات الوسيطة (وسبب أهميتها)

يقوم axum بتكديس البرامج الوسيطة باستدعاءات .layer() المتعاقبة - والقاعدة التي تحكم ترتيب التنفيذ مثيرة للدهشة من الناحية العملية: آخر .layer() الذي تم وضعه يصبح الطبقة الخارجية، وبالتالي أول طبقة يتم اجتيازها بواسطة طلب وارد، وآخر من يرى الاستجابة تترك. وبالتالي فإن القراءة الخطية للملف تعطي الترتيب العكسي لترتيب التنفيذ الفعلي.

gateway/router.rsrust
// مكتوب على النحو التالي (المستخرج الفعلي، ترتيب الملف):

service_routes(...).merge(data_plane_extra)
    .layer(metrics_auth_middleware)      // (1) المركز الأول ← الأعمق
    .layer(rate_limit_actor_middleware)  // (2)
    .layer(data_plane_auth_middleware)   // (3)
    .layer(rate_limit_middleware)        // (4)
    .layer(request_id_middleware)        // (5)
    .layer(AccessLogLayer)               // (6)
    .layer(prometheus_layer)             // (7)
    .layer(TraceLayer)                   // (8)
    .layer(RequestBodyLimitLayer)        // (9)
    .layer(security_headers_middleware)  // (10)
    .layer(cors)                         // (11) وضعت في المركز الأخير → الأبعد

// وبالتالي فإن الطلب الوارد يمر عبر (11) → (1)، وليس أبدًا (1) → (11).
تأثير ملموس لهذا الأمر

تقوم البرمجيات الوسيطة request_id بتعيين رأس X-Request-Id فقط في الاستجابة، وليس على الطلب الوارد أبدًا. نظرًا لأنه تم وضع AccessLogLayer بعده في الملف - وبالتالي فهو خارجي أكثر، وبالتالي تم اجتيازه من قبل - فإن التقاطه للحقل request_id يقرأ الرأس كما أرسله العميل، وليس المعرف الذي تم إنشاؤه لاحقًا في السلسلة. إذا لم يقدم المتصل أي X-Request-Id، فسيغادر سطر سجل الوصول بحقل فارغ، بينما تحمل الاستجابة التي تم إرجاعها معرف UUID تم إنشاؤه حديثًا. ليس عيبًا مخفيًا — تذكير بأن الترتيب الذي تمت به كتابة السلسلة .layer() لا يضمن أي شيء يتعلق بالترتيب المنطقي الذي ننسبه إليها.

#
الخطوة 4

تحديد المعدل: IP أولاً، ثم الممثل

يتم تطبيق تحديد المعدل في تمريرتين مختلفتين، في وقتين مختلفين في السلسلة. يعمل الأول قبل المصادقة والحدود بواسطة عنوان IP - وهو مرشح عام مضاد للفيضانات، نشط حتى على الطرق العامة: بدونه، يمكن للتدفق غير المصادق أن يطرق نقطة نهاية باهظة الثمن، مثل تجميع السجلات، دون تشغيل فحص JWT على الإطلاق. يتم تشغيل الخيار الثاني بعد المصادقة والقيود من قبل الممثل - مفتاح واجهة برمجة التطبيقات أو المستخدم - باستخدام المطالبات التي أدخلتها المصادقة للتو: هذه هي حصة المنتج الحقيقية، وهي الحصة التي يتم احتسابها للفواتير والخطط.

يعتمد التنفيذ على صندوق governor (مجموعة الرمز المميز) للحساب المحلي، مع نافذة Lua Redis النصية المنزلقة للتوزيع بين مثيلات البوابة، واحتياطي محلي (ذاكرة التخزين المؤقت Moka) إذا كان Redis غير متاح. الإعدادات الافتراضية للمستودع: 100 طلب في الثانية، دفعة من 1000.

#
الخطوة 5

قاطع الدائرة ليس طبقة، بل هو كائن لكل هدف

على عكس بقية السلسلة، لا يظهر قاطع الدائرة في أي .layer(). يحمل AppState مثيل CircuitBreaker واحد لكل خدمة (المصادقة، db، الوقت الفعلي، التخزين، الوظائف، الإشعارات، ai، الموفر، التحكم)، ورمز الوكيل نفسه - وليس جهاز التوجيه - هو الذي يستدعي try_acquire_probe() قبل محاولة الطلب، ثم record_success() أو record_failure() اعتمادًا على النتيجة.

حالة PostgREST مميزة: المشاريع في الهيكل المخصص (Postgres وPostgREST الخاصان بالمشروع) لا تحتوي على مجال خطأ مشترك - كل عملية PostgREST هي هدفها الخاص. وبالتالي، تحتفظ البوابة بجدول قواطع الدائرة المفهرسة حسب الهدف الذي تم حله، ويتم ملؤه سريعًا وتطهيره كل 60 ثانية عن طريق عملية مسح تزيل الإدخالات غير النشطة: بدون هذا التطهير، سيضيف كل مشروع مخصص جديد إدخالاً لا يختفي أبدًا.

gateway/circuit_breaker.rsrust
let Some(probe) = circuit_breaker.try_acquire_probe() else {
    return Err(ServiceUnavailable);
};

// …محاولات (محاولات) استعلام NATS، مع إعادة المحاولة المحدودة…

match resultat {
    Ok(Ok(_))     => match probe.take() { Some(p) => p.record_success(), _ => {} },
    Ok(Err(_))    => match probe.take() { Some(p) => p.record_failure(), _ => {} },
    Err(_timeout) => {} // المسبار غير مستهلك → تم إرجاعه بواسطة Drop
}

يتم إرجاع رمز التحقيق كـ Drop إذا لم يتم استهلاكه بشكل صريح مطلقًا - وهو مفيد عندما تنتهي مهلة جميع محاولات الطلب دون الوصول إلى الفرع الذي كان سيحرره. ولا يتم تشغيل إعادة التشغيل التلقائي إلا في حالة وجود دليل صارم على عدم التسليم من جانب NATS (NoResponders): لا تثبت مهلة البوابة البسيطة أي شيء عن التسليم الحقيقي للطلب، ويمكن أن تؤدي إعادة تشغيله إلى تنفيذه مرتين.

#
الخطوة 6

الرابط الأخير: NATS أو HTTP المباشر، وليس عشوائيًا أبدًا

الوكيل النهائي لا يتحدث بروتوكولًا واحدًا بشكل عكسي، ولا يتم تحديد الاختيار عن طريق المسار: يمكن أن يعتمد على طريقة HTTP، أو حتى على البحث في قاعدة البيانات. بالنسبة لمعظم حركة المرور (المصادقة، والوظائف، والإشعارات، والتحكم، وغالبية قاعدة البيانات)، تقوم البوابة بإجراء تسلسل لطلب HTTP في مظروف NATS وإرساله كطلب/رد إلى موضوع مخصص للخدمة - رحلة ذهابًا وإيابًا بدون مصافحة TCP، موثقة في التعليمات البرمجية باعتبارها أسرع بكثير من وكيل HTTP الكلاسيكي لحركة المرور من نوع RPC.

التخزين، والمتغيرات الثلاثة للوقت الفعلي (WebSocket، وSSE، وREST للبث/القنوات/التواجد) و- بشكل مشروط - تخرج طلبات Postgres CRUD من هذا المسار وتنتقل عبر عميل HTTP مباشر ومجمع. لقد اتخذ التخزين هذا الاختيار بشكل صريح: تشفير نص ثنائي في مظروف NATS يتطلب إجراء تسلسل له، وتحميله بالكامل في الذاكرة من كلا الطرفين، والبقاء تحت الحد الأقصى لحجم رسالة NATS - وهي تكلفة حقيقية للكائنات الكبيرة. WebSocket وSSE ببساطة لا يتسامحان مع دلالات الطلب/الرد: ترقية البروتوكول والتدفق الذي يظل مفتوحًا ليس لهما مكافئ NATS.

الحالة الأكثر إثارة للاهتمام هي /v1/db/*، التي يقرر معالجها بنفسه عند كل طلب: تنتقل مسارات الإدارة (المخطط والسياسات وSQL الأولية) دائمًا في NATS إلى aura-db، وينتقل PUT دائمًا إلى NATS (يرجع PostgREST 405 عند الاستبدال الكامل)، وينتقل مشروع MongoDB دائمًا إلى NATS - ولا يذهب إلا CRUD في مشروع Postgres مع مثيل PostgREST المخصص الذي تم حله إلى Direct HTTP. إذا لم يتم حل هذا المثيل المخصص، تستجيب البوابة 503 بدلاً من الرجوع إلى PostgREST مشترك: يُفترض أنه تم إغلاق الفشل، وليس احتياطيًا صامتًا متدهورًا. تنطبق رؤوس الأمان (CSP الصارم، لا توجد بيانات اعتماد CORS) بشكل موحد على جميع هذه المسارات، الموضوعة في نهاية السلسلة، قبل أن تغادر الاستجابة البوابة.

#
الخطوة 7

ميزانية المهلة لكل مسار، وليست المهلة الشاملة

تطبق البوابة TimeoutLayer لكل مجموعة من المسارات بدلاً من المهلة العامة - وهو خيار مرتبط بنفس آليات التراص مثل ترتيب البرامج الوسيطة. يحتاج مسار وظائف Edge إلى ميزانية أطول بكثير من الباقي (يمكن تشغيل الوظيفة بشكل قانوني لعدة دقائق): الافتراضي للمستودع هو 30 ثانية لغالبية المسارات، مقارنة بـ 380 ثانية لـ /v1/functions/*.

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

#
لنتذكر

أعد إنتاج هذا النمط في مكان آخر: القائمة المرجعية

  1. منفصل عن طريق PLAN (سطح التعرض)، وليس عن طريق الخدمة: يجب ألا تصل حزمة SDK العامة المخترقة أبدًا إلى قائمة أصل CORS بلوحة تحكم المشرف الخاصة بك.
  2. احتفظ بحالة مشتركة واحدة بدلاً من عمليتي نشر منفصلتين - تكرار منطق الأعمال يكلف أكثر من تكرار جهاز التوجيه.
  3. تحقق من ترتيب البرامج الوسيطة الفعلي عن طريق تتبعه من آخر .layer()، وليس من القراءة الخطية للملف أبدًا.
  4. حد منفصل للمعدل لكل IP (قبل المصادقة) عن الحصة لكل ممثل (بعد) - وإلا فإن التدفق غير المصادق يفرض تحققًا مكلفًا بدون حدود.
  5. ضع القاطع في أقرب مكان ممكن من مكالمة الشبكة الفعلية، في الوكيل - وحجمه حسب الهدف عندما لا تتم مشاركة المجال الخطأ.
  6. قم بإعادة تشغيل الطلب فقط عند إثبات عدم التسليم، ولا يتم ذلك مطلقًا عند انتهاء مهلة بسيطة.
  7. امنح كل مجموعة مسارات ميزانية المهلة الخاصة بها التي تم تعيينها قبل دمج أجهزة التوجيه - وليس TimeoutLayer عموميًا من شأنه أن يحل محل الميزانية الأطول.

هل أنت جاهز للنشر؟

الواجهة الخلفية الخاصة بك في خمس دقائق.

لا حاجة لبطاقة ائتمان · 500 ميجابايت مجانًا · 50000 MAU