صندوقا صادر، نمط واحد
Cloud Tasks للأوامر وPub/Sub للأحداث — نفس الـ outbox المعاملاتي، نفس شكل الترحيل، نفس التسليم عبر HTTP. التفرع الحقيقي الوحيد في الطبقة الوسطى، وفي من يملك التوجيه.
صرت أحكم على المعماريات اللاتزامنية بسؤال واحد: عندما تحتاج خدمة جديدة إلى التفاعل مع شيء ما، Terraform مَن الذي يتغيّر؟ على المنصة التي تعلمت فيها هذا السؤال — نحو دزينة خدمات Go مصغّرة على Cloud Run وتحتها Cloud Spanner — شطر الجوابُ تصميمَ المراسلة كله شطرين نظيفين، وعلّمني ذلك الشطر عن «الأوامر مقابل الأحداث» أكثر مما علّمني أي كتاب.
كل قفزة لاتزامنية في ذلك النظام هي الحركة نفسها: اكتب الرسالة في Spanner داخل معاملة العمل، ودَع ترحيلًا يدفعها إلى خدمة GCP مُدارة، واستقبلها من جديد كطلب HTTP POST موثّق. مقالة الـ Transactional Outbox تغطي أهمية الخطوة الأولى. هذه المقالة عن التفرع في المنتصف: Cloud Tasks للأوامر من نقطة إلى نقطة، وPub/Sub للأحداث المتشعبة — ولماذا الفرق المثير ليس في الإنتاجية ولا الدلالات، بل في الملكية.
النمط المشترك
الخدمة لا تنشر مباشرة أبدًا. إنها تدرج صفًا في أحد جدولي outbox — tasksMessages أو pubsubMessages — في نفس معاملة Spanner التي تحمل بيانات العمل، فلا يمكن لرسالة أن توجد دون بياناتها ولا العكس. ترحيلٌ مخصص وحيد النسخة على Cloud Run لكل جدول يلتقط الصفوف غير المنشورة ويسلّمها للخدمة المُدارة. وعلى طرف الاستقبال، لا يشغّل المستهلكون أي كود لعملاء الطوابير: التسليم دائمًا طلب HTTP POST موقّع بـ OIDC إلى مسار عادي خلف موزّع الحمل.
هذه الخاصية الأخيرة تستحق التوقف. لأن المسارين يسلّمان عبر HTTP عادي، فإن معالج الرسالة مجرد نقطة نهاية — نفس الـ middleware، نفس تمرير سياق المصادقة، نفس التسجيل، نفس قصة الاختبار المحلي كأي مسار متزامن. بنية المراسلة غير مرئية في المكان الذي يسكنه منطق العمل.
التفرع: من يملك التوجيه
في Spanner يبدو نوعا الرسائل متطابقين تقريبًا. العلامة الفارقة حقل واحد، ومكان إقامة الـ Terraform:
| Cloud Tasks — أمر | Pub/Sub — حدث | |
|---|---|---|
| حقل التوجيه | queueId ← نقطة نهاية واحدة مربوطة مسبقًا | topicId ← قناة بث |
| الوجهة تعيش في | Terraform المنتِج — الطابور ومسار الهدف معًا | Terraform كل مستهلك — اشتراكه ومساره الخاصان |
| هل يعرف المنتِج المستقبلين؟ | نعم — بل يبني ترويسات HTTP بنفسه | لا — صفر معرفة بالمشتركين |
| النسخ المسلّمة | معالج واحد بالضبط | نسخة لكل اشتراك |
| ضبط التدفق | لكل طابور: معدل إرسال وسقف تزامن | لا شيء لكل مستهلك — الدفع يتوسع صعودًا |
| إزالة التكرار | معرّف المهمة = معرّف الرسالة؛ الإدراج المكرر يعيد AlreadyExists | لا شيء — على المستهلكين أن يكونوا عديمي التأثر بالتكرار |
| إضافة مستقبِل | تغيير بنية المنتِج | يضيف المستهلك اشتراكًا؛ المنتِج لا يُمَس |
| الدلالة | «استدعِ هذه النقطة لاحقًا، مع إعادة المحاولة» | «حدث هذا؛ تفاعل إن كان يعنيك» |
اقرأ الصف الثالث مرتين — فهو الفرق كله. الأمر شأن المنتِج: يعرف بالضبط من يجب أن يتصرف، فالوجهة تنتمي إلى إعداداته. الحدث شأن المستهلكين: يسمّي المنتِج واقعة (user-account-created) وكل خدمة مهتمة تربط نفسها في بنيتها هي. جعلت السياسة التقنية للمنصة Cloud Tasks الخيارَ الافتراضي لكل عمل لاتزامني، وحجزت Pub/Sub لحفنة أحداث المجال الحقيقية التي يجب أن تتفاعل معها عدة خدمات — فبقي سؤال «هل ينبغي أن يكون هذا حدثًا؟» حوارَ تصميم مقصودًا لا إعدادًا افتراضيًا.
المسار A عمليًا: خدمة تصفّ عملًا لنفسها
أنظف أمثلة الأوامر ذاك الذي يكون فيه المنتِج والمستهلك الخدمة نفسها. حين يرسل شريك خارجي إشعار اكتمال طلب، تدرج خدمة التجارة صفوف العمل وصف المهمة في معاملة واحدة وتعيد 200 فورًا. يلتقط الترحيل الصف، ويضع مهمة في الطابور، وترسلها Cloud Tasks بطلب POST — إلى نقطة نهاية أخرى على الخدمة نفسها — حيث يجري العمل الثقيل: بريد التأكيد عبر بوابة البريد، واقتطاع الدفعة عبر خدمة الفوترة.
طلب الشريك ينتهي عند تثبيت قاعدة البيانات. كل ما هو مكلف يركب المهمة، مع إعادة محاولة عند غير 2xx وتحديد معدل لكل طابور — متانةٌ لعملٍ ما كان له أن يجري داخل طلب HTTP يملكه شخص آخر.
لماذا توجد قاعدة إزالة التكرار: نافذة انهيار الترحيل
جَعْلُ معرّف مهمة Cloud Tasks هو messageId من الـ outbox يغلق هذه الفجوة تحديدًا:
- يقرأ الترحيل الصف وينشئ المهمة في Cloud Tasks.
- ينهار الترحيل قبل كتابة
publishedAtإلى Spanner. - في الدورة التالية ما يزال الصف يبدو غير منشور — فينشئ الترحيل المهمة مرة أخرى.
- ترى Cloud Tasks معرّف المهمة نفسه فترفض بـ
AlreadyExists. - يعامل الترحيل ذلك نجاحًا ويعلّم الصف منشورًا.
من دون ذلك السطر الواحد — تجاهل الترحيل صراحةً لـ AlreadyExists — كان كل انهيار للترحيل قد يضاعف إرسال بريد الطلبات أو اقتطاع المدفوعات. لا يملك Pub/Sub آلية مكافئة، وهذا بالضبط سبب النصف الثاني من السياسة: على مستهلكي Pub/Sub أن يكونوا عديمي التأثر بالتكرار بأنفسهم.
المسار B عمليًا: تشعّب التسجيل
عند تسجيل مستخدم جديد، تدرج خدمة الهوية صفوف المستخدم وصف الحدث في معاملة واحدة، مسمّيةً موضوعًا (topic) فحسب. ينشره الترحيل، وتسلّم اشتراكات الدفع نسخة لكلٍّ: خدمة النقاط تهيّئ حسابًا، وخدمة الإشعارات تنشئ الإعدادات الافتراضية — وخدمة الهوية نفسها مشترِكة، تخصص رمز إحالة في معالج خاص بها.
هذا الاشتراك الأخير هو تفصيلي المفضل في التصميم كله. اشتراك المنتِج في موضوعه هو برهان أن الفصل حقيقي: حتى أعمال المتابعة الخاصة بالمنتِج تركب الحدث لا معاملة التسجيل. يبقى التسجيل كتابة ذرية دنيا، وكل ما بعده — بما في ذلك ما-بعده-داخل-الخدمة-نفسها — تفاعُل. وحين احتاجت خدمة جديدة لاحقًا إلى التفاعل مع التسجيلات، كان التغيير اشتراكًا واحدًا في Terraform تلك الخدمة. خدمة الهوية لم تعلم قط.
ناقل واحد يستبعد البقية
ملاحظة ختامية شكّلت إحساس تشغيل النظام كله: لا gRPC في أي مكان. الـ APIs المتزامنة، وإرسال Cloud Tasks، ودفع Pub/Sub — كل استدعاء بين الخدمات هو HTTP/JSON برموز OIDC وترويسة سياق مصادقة تُمرَّر عبر السلسلة. لا يظهر gRPC إلا داخل SDKs السحابة وفي محاكيات الاختبار المحلية. ناقل واحد يعني حزمة middleware واحدة، وطريقة واحدة لقراءة أي trace، وطريقة واحدة لاستدعاء معالج بـ curl ساعة الأزمة — وهو ما يجعل «المستهلكون مجرد نقاط نهاية» صحيحة في المسارات اللاتزامنية أيضًا.
قاعدة القرار التي أحملها معي: مرّر الأوامر عبر بنية يملكها المنتِج، ومرّر الأحداث عبر بنية يملكها المستهلكون. إن لم تستطع أن تقرر في أي جانب من الجدول تجلس رسالتك، فأنت لم تقرر بعد هل يحق للمنتِج أن يعرف من يستمع — وذاك، لا تقنية الطابور، هو الخيار المعماري الحقيقي.