◀ العودة إلى المدونة
PHP / Symfony

GraphQL مع PHP وSymfony

نشر في 10 Nov 2024· 8 min قراءة
#GraphQL#PHP#API

GraphQL: بديل قوي لـ REST

يتيح GraphQL للعملاء طلب البيانات التي يحتاجونها بالضبط. في مشاريع مثل مشاريع Matalto وManymore، سمحت هذه المرونة بتقليل عدد طلبات API بشكل كبير.

في API من نوع REST التقليدي، لكل مورد عنوان URL خاص به، والخادم هو من يقرر شكل الاستجابة. الشاشة التي تعرض منتجاً وفئته وتقييماته تُطلق غالباً ثلاثة استدعاءات، وكل منها يُرجع حقولاً لا تحتاجها الواجهة. يقلب GraphQL المنطق: ينشر الخادم مخططاً (schema) محدَّد الأنواع يصف كل ما هو متاح، ويرسل العميل استعلاماً يصف بدقة شكل البيانات المنتظرة. عادةً توجد نقطة دخول واحدة تُستدعى بـ POST، وتعكس استجابة JSON بنية الاستعلام تماماً.

في PHP، المرجع هو مكتبة webonyx/graphql-php التي تنفّذ المواصفة. وفي تطبيق Symfony نادراً ما تُستخدم مباشرة: فالحزمة overblog/graphql-bundle تدمجها في إطار العمل (إعداد عبر YAML أو السمات، حاوية الخدمات، الأمان)، كما يستطيع API Platform كشف نقطة دخول GraphQL انطلاقاً من الموارد نفسها التي تخدمها API من نوع REST. يستخدم هذا المقال OverblogGraphQLBundle.

التثبيت مع Symfony

composer require overblog/graphql-bundle
composer require overblog/graphiql-bundle --dev

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

# config/packages/graphql.yaml
overblog_graphql:
    definitions:
        schema:
            query: Query
        mappings:
            types:
                - type: yaml
                  dir: "%kernel.project_dir%/config/graphql/types"
    security:
        query_max_depth: 10
        query_max_complexity: 1000
        enable_introspection: '%kernel.debug%'

القسم security مهم وكثيراً ما يُنسى؛ وسنعود إليه لاحقاً.

تعريف المخطط

المخطط هو العقد بين الخادم وعملائه. كل نوع كائن يصرّح بحقوله وأنواعها، وعند الحاجة بطريقة حلّها. علامة التعجب تعني «غير فارغ»: String! يضمن للعميل أن الحقل سيحمل دائماً قيمة.

# config/graphql/types/Product.types.yaml
Product:
    type: object
    config:
        fields:
            id:
                type: "ID!"
            name:
                type: "String!"
            price:
                type: "Float!"
            category:
                type: "Category"
                resolve: "@=resolver('product_category', [value])"

الحقول البسيطة (id وname وprice) تُقرأ تلقائياً من كائن PHP عبر دوال الوصول (getters) أو الخصائص العامة. أما الحقل category فيُحيل إلى resolver مسمّى، ويمرّر له الكائن الأب (value). بعد ذلك نحتاج إلى نوع جذر Query يحدد نقاط الدخول للقراءة ومعاملاتها:

# config/graphql/types/Query.types.yaml
Query:
    type: object
    config:
        fields:
            product:
                type: "Product"
                args:
                    id:
                        type: "ID!"
                resolve: "@=resolver('products', [args])"
            products:
                type: "[Product!]!"
                args:
                    first:
                        type: "Int"
                        defaultValue: 10
                resolve: "@=resolver('products', [args])"

الـ Resolver

الـ resolver خدمة Symfony عادية: يتلقى معاملات الاستعلام (أو الكائن الأب) ويُرجع البيانات. وككل خدمة، يستفيد من حقن الاعتماديات:

class ProductResolver implements ResolverInterface
{
    public function __construct(
        private ProductRepository $repository,
    ) {}

    public function resolve(Argument $args): Product|array|null
    {
        if (isset($args['id'])) {
            return $this->repository->find($args['id']);
        }
        return $this->repository->findBy([], ['id' => 'ASC'], $args['first'] ?? 10);
    }
}

class ProductCategoryResolver implements ResolverInterface
{
    public function resolve(Product $product): Category
    {
        return $product->getCategory();
    }
}

لكي يشير الاسمان products وproduct_category المستخدمان في المخطط إلى هذه الأصناف، صرّح بهما كأسماء مستعارة، مثلاً بتنفيذ AliasedInterface ودالتها الساكنة getAliases(). لاحظ أيضاً أن هذه الأمثلة تستخدم التسميات التاريخية للحزمة: في الإصدارات الحديثة أُعيدت تسمية ResolverInterface ودالة التعبير resolver() إلى QueryInterface وquery(). راجع توثيق الإصدار المثبَّت. وللكتابة، المبدأ نفسه مع نوع جذر Mutation وأصناف تنفّذ MutationInterface.

ضع دائماً حداً أقصى لعدد العناصر المُرجَعة (هنا عبر first)، وانتقل إلى الترقيم بالمؤشر (نموذج «Connection» من Relay الذي تدعمه الحزمة) بمجرد أن تطول القوائم.

استعلام GraphQL

query {
  products(first: 10) {
    id
    name
    price
    category {
      name
    }
  }
}

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

query ProductDetail($id: ID!) {
  product(id: $id) {
    id
    name
    price
    category {
      name
    }
  }
}

على مستوى HTTP، يُرسل الاستعلام ومتغيراته بصيغة JSON في جسم طلب POST (العنوان الدقيق يتوقف على إعداد مسارات الحزمة):

curl -X POST https://api.example.com/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query": "query ProductDetail($id: ID!) { product(id: $id) { name price } }", "variables": {"id": "42"}}'

فخ N+1

لمرونة GraphQL كلفة خفية. في الاستعلام products(first: 10) أعلاه، يُستدعى resolver الفئة مرة لكل منتج: استعلام SQL للقائمة، ثم عشرة استعلامات للفئات إن كانت العلاقة تُحمَّل بشكل كسول. ومع القوائم المتداخلة، يتضاعف العدد بسرعة. يوجد حلّان متكاملان:

  • تحميل العلاقات الأكثر طلباً مباشرة في الـ repository، عبر ربط (join) وaddSelect.
  • استخدام نمط DataLoader (عبر overblog/dataloader-bundle): تُجمع المعرّفات المطلوبة أثناء التنفيذ، ثم تُحمَّل في استعلام مجمَّع واحد.

راقب عدد استعلامات SQL لكل عملية GraphQL في محلّل Symfony: إنه المؤشر الأكثر موثوقية.

تأمين API من نوع GraphQL

نقطة دخول تقبل استعلامات عشوائية يجب أن تحمي نفسها من الإساءة. يستطيع عميل خبيث إرسال استعلام عميق جداً أو واسع جداً يستنزف الخادم. الإعدادان query_max_depth وquery_max_complexity يرفضان مثل هذه الاستعلامات قبل تنفيذها. أما الاستبطان (introspection)، الذي يتيح لأي شخص تنزيل المخطط كاملاً، فهو هنا محصور في وضع التصحيح. وأخيراً، تُعلَن الصلاحيات حقلاً بحقل:

# Type excerpt: field restricted to administrators
Product:
    type: object
    config:
        fields:
            purchasePrice:
                type: "Float"
                access: "@=hasRole('ROLE_ADMIN')"

فرق آخر مع REST: يُرجع طلب GraphQL عادةً رمز الحالة HTTP 200 حتى عند وقوع خطأ، إذ تُدرج الأخطاء تحت المفتاح errors في الاستجابة. لذا يجب أن يحلّل نظام المراقبة لديك محتوى الاستجابات، لا رموز HTTP وحدها.

المزايا مقارنة بـ REST

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

لتطوير المخطط دون ترقيم إصدارات الـ API، أضف حقولاً بدلاً من تعديلها، وعلِّم القديمة منها بـ deprecationReason قبل حذفها حين لا يعود أي عميل يستخدمها.

متى تبقى على REST

GraphQL ليس بديلاً شاملاً. التخزين المؤقت HTTP القياسي (CDN، الوكيل العكسي) لا يعمل جيداً مع طلبات POST موجَّهة كلها إلى العنوان نفسه. ورفع الملفات يتطلب امتداداً للمواصفة. ولـ API عامة وبسيطة يستهلكها أطراف خارجيون، تبقى API من نوع REST موثَّقة بـ OpenAPI مألوفة أكثر. يتألق GraphQL أساساً حين يكون لعدة عملاء (ويب، جوّال) احتياجات مختلفة من نموذج بيانات غني وشديد الترابط.

باختصار: عرِّف مخططاً واضحاً، وضع حداً للقوائم، وتتبّع مشكلة N+1 منذ الاستعلامات الأولى، وقيِّد العمق والتعقيد، وعطِّل الاستبطان وGraphiQL في الإنتاج.