الأدلة التقنية

كيف تصمم API لا تضطر إلى كسره بعد سنة

مبادئ تصميم API يتطور دون أن يكسر التطبيقات التي تعتمد عليه، وكيف تختار الشكل المناسب لنظامك.

كيف تصمم API لا تضطر إلى كسره بعد سنة

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

وفي مقال HTTP وHTTPS ببساطة شرحنا الطلب والرد والطرق Methods ورموز الحالة Status Codes، فلن نعيد شرحها هنا، وإنما سوف نبني عليها. وسوف نناقش في هذا المقال ما يلي:

  • الأشكال الثلاثة الأشهر لواجهات API: REST وgRPC وGraphQL، وما الذي يختلف بينها فعلاً.
  • REST بشكل صحيح: الموارد Resources والطرق، ورموز الحالة الصحيحة، ورسائل الخطأ بصيغة Problem Details من RFC 9457، والتقسيم إلى صفحات Pagination، والتصفية Filtering، والعمليات الآمنة عند تكرارها Idempotency.
  • الإصدارات Versioning والتوافق مع الإصدارات السابقة Backward Compatibility، وكيف تغلق إصداراً قديماً.
  • OpenAPI لوصف العقد، والفرق بين كتابة العقد أولاً وتوليده من الكود.
  • gRPC وprotobuf وHTTP/2، ومتى تستخدمه.
  • GraphQL، ومتى يفيد، ومشكلة N+1 وجلب بيانات أكثر من اللازم Over-fetching، وملاحظات الأمان.
  • جدول مقارنة، ثم تجربة عملية تعرض نفس العملية عبر REST وgRPC باستدعاءات حقيقية.

ثلاثة أشكال لنفس العملية

لنأخذ مثالاً بسيطاً سوف نمشي عليه في كل المقال: متجر فيه طلبات Orders، ونريد قراءة الطلب ord_002. والصورة التالية تبين كيف يبدو هذا الطلب في الأشكال الثلاثة:

ثلاثة أعمدة: REST باللون البرتقالي فيه متصفح يرسل GET /v1/orders/ord_002 ويستقبل JSON، وgRPC باللون الأزرق فيه سيرفر يرسل POST /shop.v1.OrderService/GetOrder مع content-type application/grpc ويستقبل protobuf عبر HTTP/2، وGraphQL باللون البنفسجي فيه جوال يرسل POST /graphql مع استعلام يحدد الحقول ويستقبل JSON بنفس شكل الاستعلام
نفس العملية في REST وgRPC وGraphQL، والفرق في شكل العقد والرسالة
  • REST: الأساس هو المورد Resource، أي شيء له اسم وعنوان مثل /v1/orders/ord_002، وتتعامل معه بطرق HTTP المعروفة، ومعنى النتيجة في رمز الحالة. وهو أسلوب معماري وليس مواصفة رسمية واحدة، لذلك تجد تطبيقات مختلفة كثيراً تحت نفس الاسم.
  • gRPC: الأساس هو الدالة Method، فأنت تستدعي GetOrder في خدمة OrderService كأنها دالة محلية، والعقد مكتوب في ملف .proto ويولد منه كود الكلاينت والسيرفر.
  • GraphQL: الأساس هو المخطط Schema والاستعلام Query، فهناك نقطة واحدة /graphql، والكلاينت يكتب استعلاماً يحدد فيه الحقول التي يريدها بالضبط، والرد يأتي بنفس شكل الاستعلام.

وقد يتساءل البعض: أيها الأفضل؟ والإجابة أن السؤال الصحيح هو من سوف يستدعي ال API، فواجهة عامة يستدعيها شركاء ومتصفحات تختلف عن اتصال داخلي بين خدمتين في نفس الشبكة، وسوف نصل إلى ذلك في جدول المقارنة بعد أن نفهم كل شكل.

REST بشكل صحيح

الموارد Resources والطرق Methods

أول قاعدة في REST أن المسار اسم Noun والطريقة هي الفعل، فلا تكتب /getOrder?id=2 أو /createOrder أو /orders/2/delete، وإنما GET /v1/orders/2 وPOST /v1/orders وDELETE /v1/orders/2. وتصف Google هذا الأسلوب في AIP-121 Resource-oriented design بأن تبدأ التصميم بالموارد التي يقدمها ال API والعلاقات والتسلسل بينها، فالمجموعة Collection تحتوي موارد من نفس النوع، والمورد قد يحتوي مجموعات فرعية، مثل /v1/customers/7/orders. والصورة التالية تبين الطرق على مورد الطلبات، وأي منها آمن Safe وأي منها متكرر الأثر Idempotent كما يعرفهما RFC 9110:

جدول بستة أسطر: GET /v1/orders للقائمة برمز 200 آمن ومتكرر الأثر، وPOST /v1/orders للإنشاء برمز 201 مع Location ليس آمناً ولا متكرر الأثر، وGET /v1/orders/{id} برمز 200 أو 404 آمن ومتكرر الأثر، وPUT للاستبدال برمز 200 أو 204 متكرر الأثر وليس آمناً، وPATCH للتعديل الجزئي برمز 200 ليس آمناً ولا متكرر الأثر، وDELETE برمز 204 متكرر الأثر وليس آمناً. وفي اليسار مسارات فيها أفعال يجب تجنبها مثل /getOrder?id=2 و/createOrder
المسار للمورد والطريقة للفعل، وخصائص كل طريقة من RFC 9110

في الجدول أعلاه لاحظ التالي:

  • الطريقة الآمنة Safe لا تغير شيئاً في السيرفر، لذلك تستطيع المتصفحات وال Proxies إعادتها وتخزينها مؤقتاً دون خوف، فلا تضع أبداً عملية تغير البيانات خلف GET.
  • متكرر الأثر Idempotent يعني أن أثر تنفيذ الطلب مرتين هو نفس أثر تنفيذه مرة واحدة، فحذف الطلب 2 مرتين يتركه محذوفاً، أما POST مرتين فقد ينشئ طلبين، وهذه هي المشكلة التي سوف نحلها بمفتاح Idempotency-Key.
  • PATCH المعرف في RFC 5789 للتعديل الجزئي ليس متكرر الأثر بالتعريف، وأبسط صيغة لجسمه هي JSON Merge Patch في RFC 7396 حيث ترسل الحقول التي تغيرت فقط وقيمة null تعني الحذف.

وإذا كان نظامك يخدم عدة عملاء كل منهم مستأجر Tenant مستقل، فاحرص على أن يأتي المستأجر من التوكن أو النطاق الفرعي وليس من معامل يرسله الكلاينت بحرية، وقد شرحنا الخيارات في قسم المسار والترويسة والتوكن في مقال تعدد المستأجرين.

رمز الحالة الصحيح لكل حالة

شرحنا معنى رموز الحالة في قسم رموز الحالة في مقال HTTP، والذي يهمنا هنا هو الاختيار بينها في API:

  • 201 Created بعد الإنشاء، مع ترويسة Location فيها عنوان المورد الجديد، و204 No Content بعد الحذف أو التعديل إذا لم يكن هناك جسم للرد.
  • 400 Bad Request لطلب لا يمكن فهمه أو معامل خاطئ، و422 Unprocessable Content لجسم مفهوم الصيغة لكن قيمه غير صالحة، والمهم أن تختار قاعدة واحدة وتلتزم بها في كل المسارات.
  • 401 عندما لا يعرف السيرفر من أنت، و403 عندما يعرفك ولا يسمح لك، و404 للمورد غير الموجود، ويفضل كثيرون إرجاع 404 أيضاً لمورد موجود لا يملكه المستخدم حتى لا يكشفوا وجوده.
  • 409 Conflict عندما يتعارض الطلب مع حالة المورد، و429 Too Many Requests عند تجاوز حد الطلبات.
  • ولا ترجع أبداً 200 وفي الجسم "error": true، والسبب أن كل أداة في الطريق من ال Proxy إلى المراقبة إلى مكتبة الكلاينت تعتمد على رمز الحالة لتعرف أن الطلب فشل.

رسائل الخطأ بصيغة Problem Details

الحل البسيط أن يكتب كل مطور رسالة الخطأ بالشكل الذي يحبه: مرة {"error": "not found"} ومرة {"message": "...", "code": 17}، والنتيجة أن الكلاينت يكتب كوداً خاصاً لكل مسار. والحل الأنسب هو المعيار RFC 9457 Problem Details for HTTP APIs الصادر في يوليو 2023، والذي حل محل RFC 7807، ويعرف نوع المحتوى application/problem+json وخمسة حقول قياسية:

  • type: رابط URI يعرف نوع المشكلة، مثل https://example.com/problems/order-not-found، والقيمة الافتراضية about:blank عندما لا يضيف النوع معنى أكثر من رمز الحالة.
  • title: ملخص قصير يقرؤه الإنسان لنوع المشكلة.
  • status: رمز حالة HTTP لهذا الحدوث.
  • detail: شرح خاص بهذا الحدوث بالذات.
  • instance: رابط يعرف هذا الحدوث، مثل مسار الطلب.

ويسمح المعيار بحقول إضافية Extensions، مثل قائمة errors بأخطاء كل حقل كما سوف ترى في التجربة العملية. ولا تضع في detail نص الاستثناء Exception أو Stack Trace أو استعلام SQL، لأن هذا ما يبحث عنه المخترق، وقد شرحنا ذلك في خطر أخطاء الإعداد الأمني في مقال أمن واجهات API.

التقسيم إلى صفحات Pagination

لا ترجع أبداً كل السجلات في رد واحد، فالقائمة التي فيها 50 طلباً اليوم فيها 500 ألف بعد سنتين، والطريقة الأولى التي يكتبها الجميع هي الإزاحة Offset: ?limit=20&offset=40، وهي سهلة لكن فيها مشكلتين تبينهما الصورة التالية:

في اليمين لوحة OFFSET: الصفحة 1 فيها ord_009 وord_008 وord_007، ثم يصل طلب جديد ord_010، فتأتي الصفحة 2 بـ LIMIT 3 OFFSET 3 وفيها ord_007 مرة ثانية باللون الأحمر ثم ord_006 وord_005، وعلامة خطأ. وفي اليسار لوحة CURSOR: الصفحة 1 تنتهي بـ next_cursor = ord_007، ويصل نفس الطلب الجديد، والصفحة 2 تبدأ بعد ord_007 بـ ord_006 وord_005 وord_004 دون تكرار، وعلامة صح
الإزاحة تكرر أو تتخطى صفوفاً عندما تتغير البيانات، والمؤشر يكمل من آخر مفتاح

المشكلة الأولى أن البيانات تتغير بين الصفحتين، فإذا أضيف طلب جديد في أول القائمة تنزاح كل الصفوف ويظهر صف مرتين أو يختفي صف، والثانية أن قاعدة البيانات تقرأ كل الصفوف قبل الإزاحة ثم ترميها، فالصفحة رقم 10000 بطيئة. والحل هو المؤشر Cursor ويسمى أيضاً Keyset Pagination: الرد يحمل مؤشراً معتماً Opaque فيه آخر مفتاح رآه الكلاينت، والصفحة التالية تطلب ما بعده، ومع فهرس Index على المفتاح تذهب قاعدة البيانات إليه مباشرة. وهذا ما تفعله Google في AIP-158 بحقلي page_size وpage_token في الطلب وnext_page_token في الرد، وهي نفس الأسماء التي استخدمناها في ملف .proto في التجربة. وفي REST يمكن أن ترسل رابط الصفحة التالية أيضاً في ترويسة Link المعرفة في RFC 8288، وهذا ما يفعله GitHub كما في توثيق التقسيم إلى صفحات في GitHub REST API.

وضع دائماً حداً أعلى لحجم الصفحة، مثل 50 أو 100، وارفض ما فوقه بخطأ واضح، والسبب أن ?limit=1000000 طريقة سهلة لإيقاف السيرفر، وهذا هو الخطر الرابع في قائمة OWASP API Security Top 10.

التصفية Filtering والترتيب Sorting واختيار الحقول

التصفية تكون بمعاملات في الرابط Query Parameters على المجموعة، مثل GET /v1/orders?status=paid&customer=7&sort=-created_at، وفيما يلي القواعد التي توفر عليك مشكلات لاحقة:

  • اسمح فقط بقائمة معروفة من حقول التصفية والترتيب، ولا تمرر اسم الحقل من الرابط إلى استعلام SQL مباشرة، والسبب أنه يفتح باب SQL Injection ويسمح بالترتيب على أعمدة بلا فهرس.
  • إذا كان الترتيب جزءاً من المؤشر فاجعله ثابتاً، أي أضف المعرف كعمود أخير في الترتيب حتى لا يتساوى صفان.
  • اختيار الحقول مثل ?fields=id,total مفيد للجوال، لكن لا تجعله الطريقة الوحيدة لإخفاء حقول حساسة، فالحقل الذي لا يجب أن يراه المستخدم لا يرسل أبداً، وهذا هو الخطر الثالث BOPLA في مقال أمن واجهات API.

إعادة المحاولة الآمنة Idempotency

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

في الأعلى لوحة حمراء بدون مفتاح: الكلاينت يرسل POST /v1/orders، ثم timeout، ثم يعيد نفس الطلب، فينشئ ال API طلبين ord_a1 وord_b2 مدفوعين، ومكتوب دفع مرتين. وفي الأسفل لوحة خضراء مع Idempotency-Key: الطلب الأول بمفتاح 8e03، ثم ضياع الرد 201، ثم نفس الطلب بنفس المفتاح، فيعيد ال API الرد المحفوظ 200 ord_e866fc من جدول key إلى ord_e866fc، وطلب واحد فقط، ونفس المفتاح بجسم مختلف يرد 422
الكلاينت يرسل مفتاحاً فريداً، والسيرفر يحفظ النتيجة ويعيدها لنفس المفتاح

والفكرة أن الكلاينت يولد مفتاحاً عشوائياً لكل عملية، ويرسله في ترويسة Idempotency-Key، ويعيد نفس المفتاح في كل محاولة لنفس العملية، والسيرفر يحفظ المفتاح مع نتيجة التنفيذ الأول، فإذا جاء المفتاح مرة أخرى يرجع النتيجة المحفوظة بدل التنفيذ. وهذه الترويسة موصوفة في مسودة IETF draft-ietf-httpapi-idempotency-key-header، وانتبه إلى أنها مسودة Internet-Draft وصلت إلى الإصدار 07 ولم تصبح RFC، وحالتها اليوم منتهية Expired، لكنها الأساس الذي تبني عليه واجهات كثيرة، وتوصي بأن يكون المفتاح UUID أو قيمة عشوائية مشابهة، وبأن يرد السيرفر بـ 400 إذا كان المفتاح مطلوباً وغاب، وبـ 422 إذا أعيد استخدام المفتاح بجسم مختلف، وبـ 409 إذا وصلت إعادة المحاولة والطلب الأصلي ما زال قيد التنفيذ.

وأشهر تطبيق لها هو Stripe، وبحسب توثيق Idempotent requests فإن كل طلبات POST تقبل المفتاح، ويحفظ Stripe رمز الحالة وجسم الرد للطلب الأول سواء نجح أو فشل، حتى رد 500، ويمكن حذف المفاتيح بعد 24 ساعة على الأقل، ويقارن معاملات الطلب الجديد بالأصلي ويرجع خطأ إذا اختلفت، ولا معنى لإرسال المفتاح مع GET وDELETE لأنها متكررة الأثر أصلاً.

⚠️
في التجربة العملية نحفظ المفاتيح في الذاكرة لتبسيط المثال، أما في الإنتاج فاحفظ المفتاح مع النتيجة في قاعدة البيانات داخل نفس المعاملة Transaction التي تنشئ الطلب، والسبب أن السيرفر قد يتوقف بين الإنشاء وحفظ المفتاح، فيعود الطلب المكرر وينشئ نسخة ثانية.

الإصدارات Versioning والتوافق مع الإصدارات السابقة

القاعدة الأولى أن أفضل إصدار جديد هو الذي لا تحتاجه، فأغلب التغييرات يمكن أن تكون إضافية Additive لا تكسر أحداً: حقل جديد اختياري في الرد، أو مسار جديد، أو معامل اختياري جديد. ويعدد توثيق GitHub في صفحة Breaking changes التغييرات الكاسرة التي تحتاج إصداراً جديداً: حذف عملية كاملة، وحذف أو إعادة تسمية معامل أو حقل في الرد، وإضافة معامل إجباري جديد، وجعل معامل اختياري إجبارياً، وتغيير نوع معامل أو حقل، وحذف قيم من Enum، وإضافة قاعدة تحقق Validation جديدة على معامل موجود، وتغيير متطلبات المصادقة Authentication أو الصلاحيات Authorization. ويحتاج الكلاينت من جهته أن يكون متسامحاً، أي أن يتجاهل الحقول التي لا يعرفها بدل أن يفشل، وإلا أصبح حتى الحقل الإضافي كاسراً.

وعندما تحتاج إصداراً جديداً فعلاً فهناك ثلاث طرق شائعة تبينها الصورة التالية:

ثلاث بطاقات: URL path فيها GET /v1/orders/ord_002 وGET /v2/orders/ord_002 من Google AIP-185، وHeader فيها X-GitHub-Api-Version: 2026-03-10 من GitHub REST API، وPinned per account فيها Stripe-Version: 2026-09-30.endive من Stripe API. وفي الأسفل قائمة متى تحتاج إصداراً جديداً: إضافة حقل اختياري أو مسار جديد لا تحتاج، وحذف حقل أو تغيير اسمه أو نوعه ومعامل جديد إجباري وتغيير المصادقة تحتاج. وخط زمني: v2 released ثم ترويسة Deprecation من RFC 9745 ثم ترويسة Sunset من RFC 8594 ثم 410 Gone
طرق الإصدارات الثلاث، وما يكسر التوافق، وكيف تغلق الإصدار القديم
  • الإصدار الرئيسي في المسار: /v1/orders ثم /v2/orders، وهو الأوضح في السجلات Logs والتوثيق، وتطلب Google في AIP-185 أن يكون الإصدار رئيسياً فقط مثل v1 وليس v1.4.2، وأن تعمل الإصدارات المختلفة معاً في نفس الوقت أثناء الانتقال، وأن يكون للإصدار القديم فترة إيقاف معلنة قبل إغلاقه.
  • الإصدار في ترويسة: GitHub يستخدم X-GitHub-Api-Version بإصدارات بالتاريخ، وبحسب توثيق إصدارات GitHub REST API فإن الطلب بدون الترويسة يستخدم الإصدار 2022-11-28، والإصدار يبقى مدعوماً 24 شهراً بعد صدور إصدار أحدث، وبعد انتهاء الدعم يرجع 410 Gone.
  • إصدار مثبت لكل حساب: في Stripe يستخدم الحساب إصداره الافتراضي ما لم ترسل ترويسة Stripe-Version، وبحسب توثيق Versioning في Stripe تصدر منذ 2024-09-30.acacia إصدارات شهرية بلا تغييرات كاسرة، وإصدار رئيسي مرتين في السنة يبدأ بالتغييرات الكاسرة.

وإغلاق الإصدار القديم له الآن ترويستان قياسيتان: ترويسة Deprecation في RFC 9745 الصادر في مارس 2025 لتعلن تاريخ إيقاف الدعم، وترويسة Sunset في RFC 8594 لتعلن تاريخ توقف المورد عن الاستجابة، ويشترط RFC 9745 ألا يكون تاريخ Sunset قبل تاريخ Deprecation. وبينهما أهم شيء عملي: سجلات تريك من ما زال يستدعي الإصدار القديم، حتى تتواصل معه قبل الإغلاق بدل أن تكتشفه من شكواه بعده.

OpenAPI: العقد مكتوب

بحسب مواصفة OpenAPI 3.2.0 المنشورة في 19 سبتمبر 2025 فإن OpenAPI وصف قياسي لا يرتبط بلغة برمجة لواجهات HTTP، يسمح للإنسان والحاسوب بمعرفة قدرات الخدمة دون الوصول إلى الكود أو توثيق إضافي أو مراقبة حركة الشبكة. وفي الممارسة هو ملف YAML أو JSON فيه المسارات والمعاملات وأشكال الطلب والرد ورموز الحالة والمصادقة، ومنه تولد صفحات التوثيق ومكتبات الكلاينت بأدوات مثل OpenAPI Generator، ويستخدمه الفحص الأمني الديناميكي واختبارات العقد Contract Tests التي شرحناها في مقال أنواع الاختبارات.

وهناك طريقتان للحصول على الملف: توليده من الكود Code-first كما تفعل أطر مثل FastAPI، أو كتابته أولاً Contract-first ثم كتابة الكود ليطابقه. والتوليد من الكود أسرع في البداية، لكنه يصف ما كتبه الكود وليس ما قصده المصمم، وسوف ترى في التجربة العملية أن الملف المولد يقول إن ترويسة Idempotency-Key اختيارية مع أن الكود يرفض الطلب بدونها، ولا يذكر ردود الخطأ بصيغة application/problem+json إطلاقاً. لذلك إذا كان ال API عاماً أو يستهلكه فريق آخر فاكتب العقد أولاً وراجعه كما تراجع الكود، أو على الأقل راجع الملف المولد وأكمله قبل أن تنشره.

gRPC: دوال عن بعد فوق HTTP/2

بحسب مقدمة gRPC فإن الكلاينت في gRPC يستدعي دالة على سيرفر في جهاز آخر كأنها كائن محلي، والعقد يكتب بلغة Protocol Buffers في ملف .proto، ثم يولد المترجم protoc مع إضافة gRPC كود الكلاينت والسيرفر بلغات مختلفة، فيمكن أن يكون السيرفر بـ Java والكلاينت بـ Python أو Go. ويعمل gRPC فوق HTTP/2، والتفاصيل في وصف بروتوكول gRPC over HTTP2: الطريقة دائماً POST، والمسار بالشكل /Service/Method، ونوع المحتوى application/grpc، وكل رسالة مسبوقة ببايت للضغط Compressed-Flag وأربعة بايتات لطول الرسالة، وحالة النتيجة في ترويسة grpc-status التي تأتي في الترويسات الختامية Trailers. والصورة التالية تبين البايتات الحقيقية لاستدعاء واحد من التجربة العملية:

في اليمين CLIENT إلى SERVER: إطار HEADERS فيه :method POST و:path /shop.v1.OrderService/GetOrder وcontent-type: application/grpc وte: trailers، ثم إطار DATA فيه بايت 00 للعلم flag، وأربعة بايتات 00 00 00 09 للطول 9، ثم 0a 07 6f 72 64 5f 30 30 32 وهي رسالة protobuf للحقل 1 بقيمة ord_002. وفي اليسار SERVER إلى CLIENT: HEADERS فيها :status 200، وDATA فيها 31 بايتاً للطلب، وTRAILERS فيها grpc-status: 0 أو grpc-status: 5 مع :status 200 في الحالتين. وفي الأسفل نفس الطلب ord_002 بحجم 78 بايتاً في REST JSON و31 بايتاً في protobuf
استدعاء gRPC واحد: ترويسات HTTP/2 ثم رسالة protobuf بطولها ثم الحالة في الترويسات الختامية

وفيما يلي أهم ما يميز gRPC:

  • أربعة أنواع من الاستدعاء، كما في المفاهيم الأساسية في gRPC: طلب ورد واحد Unary، وتدفق من السيرفر Server Streaming، وتدفق من الكلاينت Client Streaming، وتدفق في الاتجاهين Bidirectional Streaming، وهذا أصعب كثيراً في REST.
  • المهلة Deadline جزء من الاستدعاء: الكلاينت يحدد إلى متى ينتظر، وإذا انتهت ينتهي الاستدعاء بـ DEADLINE_EXCEEDED، ويستطيع السيرفر أن يعرف الوقت الباقي.
  • رموز حالة خاصة به: مثل NOT_FOUND وALREADY_EXISTS وUNAVAILABLE، والقائمة في صفحة رموز الحالة، ولاحظ في الصورة أن رد HTTP يبقى 200 حتى عند الخطأ، فأي أداة مراقبة تقرأ رمز HTTP فقط لن ترى الأخطاء.
  • تطور العقد له قواعد: في دليل proto3 إضافة حقل جديد آمنة، وحذف حقل آمن بشرط أن تحجز رقمه واسمه بـ reserved حتى لا يعيد أحد استخدامه، أما تغيير رقم حقل موجود فغير آمن، والسبب أن الرسالة على الشبكة تحمل أرقام الحقول وليس أسماءها، فإعادة استخدام رقم قديم بمعنى جديد تجعل الكلاينت القديم يقرأ بيانات خاطئة، والأرقام من 1 إلى 15 تأخذ بايتاً واحداً لذلك تعطى للحقول الأكثر استخداماً.

ومتى تستخدمه؟ gRPC مناسب للاتصال الداخلي بين الخدمات Service-to-service، وعندما تحتاج تدفقاً Streaming، وعندما تكتب الخدمات بلغات مختلفة وتريد عقداً صارماً وكوداً مولداً. وهو غير مناسب لواجهة عامة يستدعيها المتصفح مباشرة، والسبب أن المتصفح لا يستطيع التحكم في إطارات HTTP/2 بالشكل الذي يحتاجه gRPC، لذلك يحتاج gRPC-Web إلى Proxy مثل Envoy يترجم بين المتصفح والسيرفر، ولا يناسب أيضاً شريكاً يريد أن يجرب ال API بأداة curl في دقيقة.

GraphQL: الكلاينت يحدد الشكل

بحسب مواصفة GraphQL إصدار سبتمبر 2025 فإن GraphQL أنشئ عام 2012 وبدأ تطويره كمعيار مفتوح عام 2015. وفكرته أن السيرفر ينشر مخططاً Schema فيه الأنواع والعلاقات، والكلاينت يكتب استعلاماً فيه الحقول التي يريدها فقط، فيحصل عليها في طلب واحد بنفس شكل الاستعلام. وهذا يحل مشكلتين في REST: جلب بيانات أكثر من اللازم Over-fetching عندما يرجع المسار حقولاً كثيرة لا تحتاجها شاشة الجوال، وجلب بيانات أقل من اللازم Under-fetching عندما تحتاج الشاشة ثلاثة طلبات REST متتالية لتجمع الطلب والعميل والمنتجات.

لكن هذه المرونة تنقل التعقيد إلى السيرفر، وأشهر مشكلة فيه هي N+1 التي يشرحها دليل الأداء في GraphQL: كل حقل يحله Resolver مستقل، فاستعلام يطلب الطلبات ومع كل طلب عميله يرسل استعلاماً للطلبات ثم استعلاماً لكل عميل، والصورة التالية تبين ذلك والحل:

استعلام GraphQL يطلب orders(first: 3) مع id وtotal وcustomer { name }. في اليمين لوحة N+1 حمراء: استعلام SELECT من orders ثم ثلاثة استعلامات من customers لكل id 7 و9 و12، ومكتوب مع 100 طلب تصبح 101 استعلام. وفي اليسار لوحة BATCHING خضراء: DataLoader يجمع المفاتيح، فيصبح استعلام orders واستعلام customers WHERE id IN (7, 9, 12) فقط. وفي الأسفل ضوابط: حد للعمق Depth، وحد للتكلفة Cost، وإيقاف Introspection في الإنتاج، وصلاحيات لكل حقل
كل Resolver يسأل وحده فتتضاعف الاستعلامات، والتجميع Batching يعيدها إلى استعلامين

والحل المعروف هو التجميع Batching بمكتبة مثل DataLoader التي تجمع المفاتيح المطلوبة خلال الطلب ثم تسأل قاعدة البيانات مرة واحدة. وهناك ثمن آخر: التخزين المؤقت Caching في HTTP أصعب لأن أغلب الاستعلامات تذهب بـ POST إلى نفس المسار، والمراقبة أصعب لأن كل الطلبات على مسار واحد.

ومن جهة الأمان فإن الكلاينت هو الذي يكتب الاستعلام، فيستطيع أن يكتب استعلاماً متداخلاً بعمق كبير أو يرسل مئات العمليات في طلب واحد، لذلك يوصي OWASP GraphQL Cheat Sheet بحد للعمق وحد للكمية وتحليل تكلفة الاستعلام ومهلة زمنية، وبإيقاف الاستكشاف Introspection في الإنتاج وعدم إرجاع Stack Traces، وبالتحقق من الصلاحيات على كل حقل وكل علاقة وليس على نقطة الدخول فقط. وهذه نفس مخاطر ال API العادية لكن بأبواب أكثر، وقد شرحنا أصلها في مقال أمن واجهات API مع OWASP API Security Top 10، وفي مقال ما هي OWASP تجد بقية مشاريعها.

ومتى يفيد GraphQL؟ عندما تخدم عدة واجهات بحاجات مختلفة جداً من نفس البيانات، مثل تطبيق جوال وموقع ولوحة إدارة، أو عندما تجمع بيانات من عدة خدمات خلف نقطة واحدة. أما لواجهة صغيرة بشاشات معروفة، أو لاتصال بين خدمتين، فإن REST أو gRPC أبسط في التشغيل والمراقبة والتأمين.

المقارنة في جدول واحد

المعيارRESTgRPCGraphQL
وحدة التصميمالمورد Resource والمسارالخدمة والدالة Methodالمخطط Schema والحقل
العقدOpenAPI، اختياريملف .proto، إجباريالمخطط، إجباري
الصيغة على الشبكةJSON نصي غالباًprotobuf ثنائي BinaryJSON
النقلHTTP/1.1 أو HTTP/2 أو HTTP/3HTTP/2HTTP، غالباً POST على مسار واحد
المتصفح مباشرةنعمعبر gRPC-Web وProxyنعم
التدفق Streamingمحدودأربعة أنواع مدمجةعبر Subscriptions
التخزين المؤقت في HTTPسهل مع GETغير متاح عملياًصعب
الأخطاءرموز HTTP مع Problem Detailsgrpc-status في Trailersغالباً 200 مع قائمة errors
نقاط القوةبسيط ومعروف، يجرب بـ curl، أدوات ضخمةعقد صارم، كود مولد، سريع، تدفقالكلاينت يحدد الحقول، طلب واحد لشاشة كاملة
نقاط الضعفلا يوجد معيار واحد، Over-fetchingصعب في المتصفح وفي التجربة اليدويةN+1، التخزين المؤقت، سطح هجوم أوسع
الأنسب لهالواجهات العامة وأغلب التطبيقاتالاتصال الداخلي بين الخدماتعدة واجهات بحاجات مختلفة من نفس البيانات

ومكان ال API في المعمارية مهم أيضاً: في تطبيق SPA تكون الواجهة في المتصفح وال API هو نقطة الدخول إلى مستوى التطبيق، وأي API داخلي بين الخدمات يجب ألا يكون مكشوفاً للإنترنت، وقد شرحنا الفصل بين المستويات Tiers في مقال المعمارية متعددة الطبقات.

تجربة عملية: نفس العملية عبر REST وgRPC

الآن سوف نبني خدمة صغيرة بلغة Python تعرض نفس مخزن الطلبات عبر REST بإطار FastAPI وعبر gRPC، في Container واحد، ثم نستدعيها بأداة curl وأداة grpcurl، ونرى رسائل الخطأ والتقسيم إلى صفحات ومفتاح Idempotency-Key وملف OpenAPI والبايتات الفعلية على الشبكة. والخدمة تستمع على 127.0.0.1 فقط، وgRPC هنا بدون TLS لتبسيط التجربة، أما في الإنتاج فاستخدم TLS دائماً.

الملفات

العقد في الملف orders.proto:

syntax = "proto3";

package shop.v1;

service OrderService {
  rpc GetOrder(GetOrderRequest) returns (Order);
  rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse);
}

message Order {
  string id = 1;
  string customer = 2;
  int64 total_halalas = 3;
  string status = 4;
}

message GetOrderRequest {
  string id = 1;
}

message ListOrdersRequest {
  int32 page_size = 1;
  string page_token = 2;
}

message ListOrdersResponse {
  repeated Order orders = 1;
  string next_page_token = 2;
}

ونسخ المكتبات في requirements.txt:

fastapi==0.142.2
uvicorn==0.54.0
grpcio==1.84.0
grpcio-tools==1.84.0
grpcio-reflection==1.84.0

وملف Dockerfile الذي يولد كود gRPC من العقد أثناء بناء ال Image:

FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY orders.proto .
RUN python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. orders.proto
COPY app.py .
CMD ["python", "app.py"]

والتطبيق في الملف app.py:

import base64, hashlib, json, threading, uuid
from concurrent import futures

import grpc
import uvicorn
from fastapi import FastAPI, Header, Request, Response
from fastapi.responses import JSONResponse
from grpc_reflection.v1alpha import reflection
from pydantic import BaseModel, Field

import orders_pb2, orders_pb2_grpc

# one in-memory store shared by both interfaces
ORDERS = {f"ord_{i:03d}": {"id": f"ord_{i:03d}", "customer": f"customer-{i}",
                          "total_halalas": 11500 * i, "status": "paid"} for i in range(1, 6)}
IDEMPOTENCY = {}  # key -> (request hash, order id)


def page(size, token):
    # keyset pagination: the cursor carries the last id the client saw, not a row offset
    after = base64.urlsafe_b64decode(token).decode() if token else ""
    ids = [i for i in sorted(ORDERS) if i > after]
    chunk = ids[:size]
    nxt = base64.urlsafe_b64encode(chunk[-1].encode()).decode() if len(ids) > size else ""
    return [ORDERS[i] for i in chunk], nxt


# ---------- REST ----------
api = FastAPI(title="Shop API", version="1.0.0")


def problem(status, title, detail, type_="about:blank", **extra):
    body = {"type": type_, "title": title, "status": status, "detail": detail, **extra}
    return JSONResponse(body, status_code=status, media_type="application/problem+json")


class NewOrder(BaseModel):
    customer: str = Field(min_length=1)
    total_halalas: int = Field(gt=0)


@api.get("/v1/orders")
def list_orders(request: Request, response: Response, limit: int = 2, cursor: str = ""):
    if not 1 <= limit <= 50:
        return problem(400, "Invalid limit", "limit must be between 1 and 50", instance=str(request.url.path))
    items, nxt = page(limit, cursor)
    if nxt:
        response.headers["Link"] = f'</v1/orders?limit={limit}&cursor={nxt}>; rel="next"'
    return {"items": items, "next_cursor": nxt or None}


@api.get("/v1/orders/{order_id}")
def get_order(order_id: str, request: Request):
    if order_id not in ORDERS:
        return problem(404, "Order not found", f"No order with id {order_id}",
                       type_="https://example.com/problems/order-not-found", instance=str(request.url.path))
    return ORDERS[order_id]


@api.post("/v1/orders", status_code=201)
def create_order(order: NewOrder, request: Request, response: Response,
                 idempotency_key: str | None = Header(default=None)):
    if not idempotency_key:
        return problem(400, "Idempotency-Key required", "Send a unique Idempotency-Key header with every POST")
    digest = hashlib.sha256(order.model_dump_json().encode()).hexdigest()
    if idempotency_key in IDEMPOTENCY:
        old_digest, order_id = IDEMPOTENCY[idempotency_key]
        if old_digest != digest:
            return problem(422, "Idempotency-Key reused", "This key was already used with a different body")
        response.status_code = 200
        response.headers["Location"] = f"/v1/orders/{order_id}"
        return ORDERS[order_id]
    order_id = f"ord_{uuid.uuid4().hex[:6]}"
    ORDERS[order_id] = {"id": order_id, **order.model_dump(), "status": "pending"}
    IDEMPOTENCY[idempotency_key] = (digest, order_id)
    response.headers["Location"] = f"/v1/orders/{order_id}"
    return ORDERS[order_id]


# ---------- gRPC ----------
class OrderService(orders_pb2_grpc.OrderServiceServicer):
    def GetOrder(self, request, context):
        if request.id not in ORDERS:
            context.abort(grpc.StatusCode.NOT_FOUND, f"No order with id {request.id}")
        return orders_pb2.Order(**ORDERS[request.id])

    def ListOrders(self, request, context):
        items, nxt = page(request.page_size or 2, request.page_token)
        return orders_pb2.ListOrdersResponse(orders=[orders_pb2.Order(**o) for o in items], next_page_token=nxt)


def serve_grpc():
    server = grpc.server(futures.ThreadPoolExecutor(max_workers=4))
    orders_pb2_grpc.add_OrderServiceServicer_to_server(OrderService(), server)
    names = (orders_pb2.DESCRIPTOR.services_by_name["OrderService"].full_name, reflection.SERVICE_NAME)
    reflection.enable_server_reflection(names, server)
    server.add_insecure_port("0.0.0.0:50051")
    server.start()
    return server


if __name__ == "__main__":
    grpc_server = serve_grpc()
    uvicorn.run(api, host="0.0.0.0", port=8000, log_level="warning")

في الإعداد أعلاه لاحظ التالي:

  • المخزن ORDERS واحد، وكل من مسارات REST ودوال gRPC تقرأ منه، فالفرق الوحيد هو العقد والنقل.
  • الدالة page تقسم بالمؤشر: المؤشر هو آخر معرف رآه الكلاينت مشفراً بـ base64 حتى يبقى معتماً، والصفحة التالية تبدأ بعده.
  • الدالة problem ترجع كل الأخطاء بصيغة RFC 9457 ونوع المحتوى application/problem+json.
  • create_order يرفض الطلب بدون Idempotency-Key، ويحفظ هاش الجسم مع المفتاح حتى يكتشف إعادة استخدام المفتاح بجسم مختلف.
  • تفعيل الاستكشاف Reflection في gRPC يسمح لأداة grpcurl بمعرفة الخدمات دون ملف .proto، وهو مفيد في التجربة لكنه يكشف العقد كاملاً، فقرر بوعي هل تفعله في الإنتاج كما يشرح دليل Reflection.

وملف compose.yaml:

name: lab-api

services:
  app:
    build: .
    image: lab-api-app:1
    container_name: lab-api-app
    ports:
      - "127.0.0.1:21580:8000"    # REST
      - "127.0.0.1:21581:50051"   # gRPC
    networks: [net]

networks:
  net:
    name: lab-api-net

التشغيل والقراءة عبر REST

docker compose up -d --build
docker compose ps
NAME          IMAGE           COMMAND           SERVICE   CREATED         STATUS         PORTS
lab-api-app   lab-api-app:1   "python app.py"   app       6 seconds ago   Up 3 seconds   127.0.0.1:21580->8000/tcp, 127.0.0.1:21581->50051/tcp

الآن سوف نطلب الصفحة الأولى من الطلبات ونطبع الترويسات، ثم نتبع المؤشر إلى الصفحتين التاليتين:

curl -s -i "http://127.0.0.1:21580/v1/orders?limit=2"
curl -s "http://127.0.0.1:21580/v1/orders?limit=2&cursor=b3JkXzAwMg=="
curl -s "http://127.0.0.1:21580/v1/orders?limit=2&cursor=b3JkXzAwNA=="

والمخرج سوف يكون كما يلي (حذفنا ترويستي date وserver من كل رد في هذا القسم):

HTTP/1.1 200 OK
content-length: 198
content-type: application/json
link: </v1/orders?limit=2&cursor=b3JkXzAwMg==>; rel="next"

{"items":[{"id":"ord_001","customer":"customer-1","total_halalas":11500,"status":"paid"},{"id":"ord_002","customer":"customer-2","total_halalas":23000,"status":"paid"}],"next_cursor":"b3JkXzAwMg=="}

{"items":[{"id":"ord_003","customer":"customer-3","total_halalas":34500,"status":"paid"},{"id":"ord_004","customer":"customer-4","total_halalas":46000,"status":"paid"}],"next_cursor":"b3JkXzAwNA=="}
{"items":[{"id":"ord_005","customer":"customer-5","total_halalas":57500,"status":"paid"}],"next_cursor":null}

لاحظ أن رابط الصفحة التالية موجود في الجسم next_cursor وفي ترويسة link معاً، وأن الصفحة الأخيرة ترجع null فيعرف الكلاينت أن القائمة انتهت. والمؤشر b3JkXzAwMg== هو ord_002 بترميز base64، والكلاينت لا يجب أن يبني عليه أي افتراض، لأنك قد تغير محتواه لاحقاً.

ثم طلب واحد، وطلب غير موجود، وحجم صفحة خارج الحد:

curl -s http://127.0.0.1:21580/v1/orders/ord_002
curl -s -i http://127.0.0.1:21580/v1/orders/ord_999
curl -s -i "http://127.0.0.1:21580/v1/orders?limit=500"
{"id":"ord_002","customer":"customer-2","total_halalas":23000,"status":"paid"}

HTTP/1.1 404 Not Found
content-length: 162
content-type: application/problem+json

{"type":"https://example.com/problems/order-not-found","title":"Order not found","status":404,"detail":"No order with id ord_999","instance":"/v1/orders/ord_999"}

HTTP/1.1 400 Bad Request
content-length: 125
content-type: application/problem+json

{"type":"about:blank","title":"Invalid limit","status":400,"detail":"limit must be between 1 and 50","instance":"/v1/orders"}

الإنشاء مع Idempotency-Key

الآن سوف نرسل نفس طلب الإنشاء مرتين بنفس المفتاح كأن الرد الأول ضاع، ثم نفس المفتاح بجسم مختلف، ثم طلباً بدون مفتاح:

U=http://127.0.0.1:21580/v1/orders; H='content-type: application/json'
curl -s -i -X POST $U -H "$H" -H 'Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324' -d '{"customer":"customer-9","total_halalas":11500}'
curl -s -i -X POST $U -H "$H" -H 'Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324' -d '{"customer":"customer-9","total_halalas":11500}'
curl -s -i -X POST $U -H "$H" -H 'Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324' -d '{"customer":"customer-9","total_halalas":99900}'
curl -s -X POST $U -H "$H" -d '{"customer":"customer-9","total_halalas":11500}'
HTTP/1.1 201 Created
content-length: 84
content-type: application/json
location: /v1/orders/ord_e866fc

{"id":"ord_e866fc","customer":"customer-9","total_halalas":11500,"status":"pending"}

HTTP/1.1 200 OK
content-length: 84
content-type: application/json
location: /v1/orders/ord_e866fc

{"id":"ord_e866fc","customer":"customer-9","total_halalas":11500,"status":"pending"}

HTTP/1.1 422 Unprocessable Content
content-length: 127
content-type: application/problem+json

{"type":"about:blank","title":"Idempotency-Key reused","status":422,"detail":"This key was already used with a different body"}

{"type":"about:blank","title":"Idempotency-Key required","status":400,"detail":"Send a unique Idempotency-Key header with every POST"}

في المخرج أعلاه لاحظ التالي:

  • الطلب الأول أنشأ ord_e866fc برمز 201 وترويسة location، والثاني بنفس المفتاح رجع نفس الطلب ولم ينشئ طلباً جديداً.
  • اخترنا أن يرجع التكرار 200 حتى يميزه الكلاينت عن الإنشاء، أما Stripe فيعيد نفس الرد الأول حرفياً، وكلا الخيارين صحيح إذا وثقته.
  • نفس المفتاح بمبلغ مختلف رجع 422 كما توصي مسودة IETF، والسبب أن هذا غالباً خطأ في الكلاينت أعاد استخدام مفتاح قديم لعملية جديدة.

نكسر العقد: خطأ التحقق بصيغة الإطار

الآن سوف نرسل جسماً قيمه غير صالحة: اسم عميل فارغ ومبلغ سالب:

curl -s -i -X POST http://127.0.0.1:21580/v1/orders -H 'content-type: application/json' -H 'Idempotency-Key: 1f0c2b9e-0d7a-4c8e-9a51-3b2f4e6d7a10' -d '{"customer":"","total_halalas":-5}'
HTTP/1.1 422 Unprocessable Content
content-length: 268
content-type: application/json

{"detail":[{"type":"string_too_short","loc":["body","customer"],"msg":"String should have at least 1 character","input":"","ctx":{"min_length":1}},{"type":"greater_than","loc":["body","total_halalas"],"msg":"Input should be greater than 0","input":-5,"ctx":{"gt":0}}]}

لاحظ أن هذا الخطأ لم يمر عبر الدالة problem، لأن FastAPI يتحقق من الجسم قبل أن يصل إلى الكود ويرجع شكله الخاص {"detail": [...]} بنوع application/json، فأصبح لدينا شكلان مختلفان للأخطاء في نفس ال API، وهو الخطأ الشائع الذي يقع فيه كثيرون، لأن كل إطار له شكله الافتراضي. والحل أن تلتقط أخطاء التحقق وتحولها إلى نفس الصيغة، لذلك أضفنا قبل قسم gRPC في app.py ما يلي:

from fastapi.exceptions import RequestValidationError


@api.exception_handler(RequestValidationError)
def validation_problem(request: Request, exc: RequestValidationError):
    errors = [{"field": ".".join(map(str, e["loc"][1:])), "message": e["msg"]} for e in exc.errors()]
    return problem(422, "Validation failed", "The request body has invalid fields",
                   type_="https://example.com/problems/validation", errors=errors)

ثم أعدنا البناء بـ docker compose up -d --build وأرسلنا نفس الطلب:

HTTP/1.1 422 Unprocessable Content
content-length: 291
content-type: application/problem+json

{"type":"https://example.com/problems/validation","title":"Validation failed","status":422,"detail":"The request body has invalid fields","errors":[{"field":"customer","message":"String should have at least 1 character"},{"field":"total_halalas","message":"Input should be greater than 0"}]}

والآن كل أخطاء ال API بنفس الصيغة، والحقل الإضافي errors يحمل خطأ كل حقل كما يسمح RFC 9457. ولاحظ أن المخزن في الذاكرة بدأ من جديد بعد إعادة التشغيل، وهذا سبب آخر لحفظ المفاتيح في قاعدة البيانات في الإنتاج.

ماذا يقول ملف OpenAPI المولد؟

FastAPI يولد الملف تلقائياً على المسار /openapi.json، والأمر التالي يطبع النسخة والمسارات ومعاملات الإنشاء ومخطط الجسم:

curl -s http://127.0.0.1:21580/openapi.json | python -c "import json,sys; d=json.load(sys.stdin); print(d['openapi'], d['info']); print(list(d['paths'])); print(json.dumps(d['paths']['/v1/orders']['post']['parameters'], indent=2)); print(json.dumps(d['components']['schemas']['NewOrder'], indent=2))"
3.1.0 {'title': 'Shop API', 'version': '1.0.0'}
['/v1/orders', '/v1/orders/{order_id}']
[
  {
    "name": "idempotency-key",
    "in": "header",
    "required": false,
    "schema": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Idempotency-Key"
    }
  }
]
{
  "properties": {
    "customer": {
      "type": "string",
      "minLength": 1,
      "title": "Customer"
    },
    "total_halalas": {
      "type": "integer",
      "exclusiveMinimum": 0.0,
      "title": "Total Halalas"
    }
  },
  "type": "object",
  "required": [
    "customer",
    "total_halalas"
  ],
  "title": "NewOrder"
}

لاحظ ثلاثة أمور: الملف بنسخة OpenAPI 3.1.0 وليس 3.2.0 الأحدث، لأن الإطار هو الذي يقرر، وترويسة idempotency-key مكتوبة "required": false مع أن الكود يرفض الطلب بدونها، ولا يوجد في الملف أي ذكر لردود application/problem+json. فالملف المولد يصف التوقيع البرمجي للدالة، وليس العقد الذي قصدناه، وهذا ما ذكرناه في قسم OpenAPI.

نفس العملية عبر gRPC

الآن سوف نستدعي نفس الخدمة عبر gRPC بأداة grpcurl من Container على نفس الشبكة:

G="docker run --rm --name lab-api-grpcurl --network lab-api-net fullstorydev/grpcurl:v1.9.3"
$G -plaintext lab-api-app:50051 list
$G -plaintext lab-api-app:50051 describe shop.v1.OrderService
$G -plaintext -d '{"id":"ord_002"}' lab-api-app:50051 shop.v1.OrderService/GetOrder
$G -plaintext -d '{"id":"ord_999"}' lab-api-app:50051 shop.v1.OrderService/GetOrder; echo "exit=$?"
$G -plaintext -d '{"page_size":2}' lab-api-app:50051 shop.v1.OrderService/ListOrders
grpc.reflection.v1alpha.ServerReflection
shop.v1.OrderService
shop.v1.OrderService is a service:
service OrderService {
  rpc GetOrder ( .shop.v1.GetOrderRequest ) returns ( .shop.v1.Order );
  rpc ListOrders ( .shop.v1.ListOrdersRequest ) returns ( .shop.v1.ListOrdersResponse );
}
{
  "id": "ord_002",
  "customer": "customer-2",
  "total_halalas": "23000",
  "status": "paid"
}
ERROR:
  Code: NotFound
  Message: No order with id ord_999
exit=69
{
  "orders": [
    {
      "id": "ord_001",
      "customer": "customer-1",
      "total_halalas": "11500",
      "status": "paid"
    },
    {
      "id": "ord_002",
      "customer": "customer-2",
      "total_halalas": "23000",
      "status": "paid"
    }
  ],
  "next_page_token": "b3JkXzAwMg=="
}

في المخرج أعلاه لاحظ التالي:

  • نفس البيانات ونفس المؤشر b3JkXzAwMg== الذي رأيناه في REST، لأن الدالة page مشتركة.
  • الحقل total_halalas ظهر كنص "23000" وليس كرقم، والسبب أن grpcurl يحول protobuf إلى JSON بقواعد ProtoJSON التي تكتب قيم int64 كنص عشري، لأن JavaScript لا تستطيع تمثيل كل أرقام 64 بت بدقة.
  • الخطأ جاء برمز gRPC NotFound وليس برمز HTTP 404.

البايتات على الشبكة

حتى نرى ما تقوله صورة إطارات gRPC، سوف نكتب رسالة protobuf يدوياً ونرسلها بأداة curl عبر HTTP/2. والرسالة هي بايت الضغط 00 ثم الطول 00 00 00 09 ثم تسعة بايتات: 0a وهو الحقل رقم 1 من نوع نص، و07 طول النص، ثم ord_002:

docker run --rm --name lab-api-curl --network lab-api-net --entrypoint sh curlimages/curl:8.22.0 -c "printf '\000\000\000\000\011\012\007ord_002' > /tmp/req; od -An -tx1 /tmp/req; curl -s -v --http2-prior-knowledge -H 'content-type: application/grpc' -H 'te: trailers' --data-binary @/tmp/req -o /tmp/resp http://lab-api-app:50051/shop.v1.OrderService/GetOrder 2>&1 | grep -E '^[<>] ' | grep -v -i 'user-agent\|^> accept'; od -An -tx1 /tmp/resp"
 00 00 00 00 09 0a 07 6f 72 64 5f 30 30 32
> POST /shop.v1.OrderService/GetOrder HTTP/2
> Host: lab-api-app:50051
> content-type: application/grpc
> te: trailers
> Content-Length: 14
>
< HTTP/2 200
< content-type: application/grpc
< grpc-accept-encoding: identity, deflate, gzip
<
< grpc-status: 0
< grpc-message:
 00 00 00 00 1f 0a 07 6f 72 64 5f 30 30 32 12 0a
 63 75 73 74 6f 6d 65 72 2d 32 18 d8 b3 01 22 04
 70 61 69 64

وبنفس الطريقة مع ord_999:

< HTTP/2 200
< content-type: application/grpc
< grpc-status: 5
< grpc-message: No order with id ord_999
< 

في المخرج أعلاه لاحظ التالي:

  • الطلب POST على المسار /shop.v1.OrderService/GetOrder بنوع application/grpc، تماماً كما يصفه بروتوكول gRPC over HTTP2، وجسمه 14 بايتاً فقط.
  • الرد يبدأ بـ 00 00 00 00 1f، أي بدون ضغط وطول 31 بايتاً، ثم الحقول بأرقامها: 0a للحقل 1 (id)، و12 للحقل 2 (customer)، و18 للحقل 3 (total_halalas) وقيمته d8 b3 01 هي 23000 بترميز Varint، و22 للحقل 4 (status)، ولا توجد أسماء حقول على الشبكة إطلاقاً، ولهذا تغيير رقم الحقل يكسر العقد وتغيير اسمه لا يكسر الرسالة الثنائية.
  • grpc-status جاء بعد الجسم في الترويسات الختامية، وفي حالة الخطأ جاء grpc-status: 5 وهو NOT_FOUND مع رد HTTP بحالة 200.
  • نفس الطلب في REST كان 78 بايتاً من JSON (content-length: 78 في رد GET /v1/orders/ord_002)، وفي protobuf 31 بايتاً، وهذا فرق حقيقي في الحجم لكنه ليس السبب الأول لاختيار gRPC كما ذكرنا.

التنظيف

docker compose down
docker image rm lab-api-app:1 fullstorydev/grpcurl:v1.9.3 curlimages/curl:8.22.0

أخطاء شائعة

  • أفعال في المسارات وGET يغير البيانات: يكسر التخزين المؤقت ويجعل أي زاحف Crawler يحذف بياناتك.
  • 200 مع خطأ في الجسم: المراقبة لا ترى الفشل، ومكتبات الكلاينت لا تعيد المحاولة.
  • شكلان للأخطاء: شكلك وشكل الإطار الافتراضي كما رأينا في التجربة، والحل صيغة RFC 9457 في كل مكان.
  • قوائم بلا حد أعلى أو تقسيم بالإزاحة على جداول كبيرة تتغير.
  • POST دون Idempotency-Key في عمليات الدفع والطلبات، ومفاتيح محفوظة خارج معاملة قاعدة البيانات.
  • تغيير كاسر بلا إصدار: حذف حقل أو جعل معامل إجبارياً «لأن لا أحد يستخدمه»، ثم تكتشف من يستخدمه.
  • إعادة استخدام رقم حقل في protobuf بدل حجزه بـ reserved.
  • GraphQL بلا حدود للعمق والتكلفة، مع Introspection مفتوح في الإنتاج.
  • ملف OpenAPI مولد لا يراجعه أحد، فيصف شيئاً غير ما يفعله ال API.

قائمة تحقق قبل نشر API

  1. هل المسارات أسماء موارد، والطرق تتبع معانيها في RFC 9110؟
  2. هل كل نجاح وكل فشل له رمز حالة صحيح، و201 مع Location بعد الإنشاء؟
  3. هل كل الأخطاء بصيغة application/problem+json، بما فيها أخطاء التحقق التي يرجعها الإطار؟
  4. هل كل قائمة مقسمة إلى صفحات بمؤشر وحد أعلى للحجم؟
  5. هل حقول التصفية والترتيب من قائمة مسموحة فقط؟
  6. هل عمليات POST الحساسة تقبل Idempotency-Key، والمفاتيح محفوظة مع النتيجة في نفس المعاملة؟
  7. هل تعرف أي تغيير كاسر، ولديك طريقة إصدارات وسياسة إغلاق بترويستي Deprecation وSunset؟
  8. هل ملف OpenAPI أو .proto أو المخطط مراجع ومحفوظ في Git ومطابق للسلوك؟
  9. هل راجعت ال API مقابل OWASP API Security Top 10، وخصوصاً الصلاحيات على كل كائن وكل حقل؟

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • REST للموارد وgRPC للدوال وGraphQL للمخطط، والاختيار يبدأ من سؤال من سوف يستدعي ال API: الواجهات العامة غالباً REST، والاتصال الداخلي بين الخدمات يناسبه gRPC، وGraphQL يفيد عندما تخدم واجهات كثيرة بحاجات مختلفة.
  • REST الصحيح: أسماء موارد، وطرق بمعانيها في RFC 9110، ورموز حالة صادقة، وأخطاء بصيغة RFC 9457، وصفحات بمؤشر وحد أعلى، وتصفية من قائمة مسموحة.
  • POST ليس متكرر الأثر، ومفتاح Idempotency-Key من مسودة IETF وكما يطبقه Stripe يجعل إعادة المحاولة آمنة.
  • أغلب التغييرات يمكن أن تكون إضافية بلا إصدار جديد، وعند الحاجة إصدار رئيسي في المسار أو ترويسة، ثم Deprecation من RFC 9745 وSunset من RFC 8594 قبل الإغلاق.
  • OpenAPI 3.2.0 يصف العقد، والملف المولد من الكود يحتاج مراجعة كما رأينا في التجربة.
  • gRPC يرسل protobuf بأرقام الحقول فوق HTTP/2 والحالة في الترويسات الختامية، وGraphQL يحتاج تجميعاً ضد N+1 وحدوداً للعمق والتكلفة وصلاحيات لكل حقل.

وأقرب المقالات إلى هذا الموضوع على الموقع:

دليلHTTP وHTTPS ببساطة: ماذا يحدث بين الرابط والصفحةماذا يحدث بين كتابة الرابط وظهور الصفحة، وكيف تقرأ الطلب والرد ورموز مثل 502 و504، ولماذا يكلف الاتصال الجديد أكثر.دليلأمن ال API بالأمثلة مع OWASP API Security Top 10أشهر عشرة مخاطر في واجهات API كما ترتبها OWASP، ولكل خطر مثال لكود ضعيف وكيف تصلحه وتختبره.دليلمدخل إلى تصميم الأنظمة: من سيرفر واحد إلى ملايين المستخدمينكيف يكبر متجر إلكتروني من سيرفر واحد إلى نظام يخدم ملايين المستخدمين، وما الثمن الذي تدفعه في كل خطوة.دليلالفرق بين Tiers وLayers: متى يكون نظامك ثلاثي الطبقات فعلاً؟ماذا يعني أن يكون نظامك ثلاثي الطبقات 3-tier فعلاً، والفرق بين الطبقة داخل الكود والمستوى في الشبكة، وما تطلبه الضوابط بالنص.دليلتعدد المستأجرين Multi-Tenancy في SaaS للمبتدئينكيف يخدم تطبيق SaaS واحد مئات العملاء دون أن يرى أحدهم بيانات غيره، وأي نموذج عزل يناسب تطبيقك.دليلما هي OWASP؟ مشاريعها ووثائقها ومتى تستخدم كلاً منهامن يدير OWASP ولماذا كل ما تنشره مجاني، وأي مورد من مواردها تفتح في كل مرحلة من بناء تطبيقك.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجربة العملية على Docker Engine 29.8.0 وDocker Compose 5.5.1 مع Python 3.13.16 وFastAPI 0.142.2 وuvicorn 0.54.0 وgrpcio 1.84.0 وprotobuf 7.36.2، وgrpcurl 1.9.3 وcurl 8.22.0.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.