اذهب إلى المحتوى

الاجتزاءات والاشتراكات في GraphQL وتنفيذها في تطبيق React


ابراهيم الخضور

سنصل قريبًا إلى نهاية هذا المنهاج، لنختم أفكاره باستعراض بعض التفاصيل الأخرى في GraphQL.

الاجتزاءات

إنّ إعادة عدة استعلامات GraphQL النتيجة نفسها أمر شائع جدًا. فعلى سبيل المثال، فعند تنفيذ الاستعلام الذي يحضر تفاصيل شخص ما كما يلي:

query {
  findPerson(name: "Pekka Mikkola") {
    name
    phone
    address{
      street 
      city
    }
  }
}

والاستعلام الذي يعيد جميع الأشخاص كالتالي:

query {
  allPersons {
    name
    phone
    address{
      street 
      city
    }
  }
}

ستكون النتيجة نفس الأشخاص في كليهما. وعند اختيار الحقول التي ستُعاد، سيحدد كلا الاستعلامين الحقول نفسها.

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

fragment PersonDetails on Person {
  name
  phone 
  address {
    street 
    city
  }
}

يمكننا باستخدام الاجتزاء تنفيذ الاستعلامات بشيفرة مختصرة:

query {
  allPersons {
    ...PersonDetails  }
}

query {
  findPerson(name: "Pekka Mikkola") {
    ...PersonDetails  }
}

لا تُعرّف الاجتزاءات ضمن تخطيط GrapQL، بل ضمن شيفرة العميل. إذ ينبغي التصريح عن الاجتزاء عندما يريد العميل تنفيذ الاستعلام.

وكمبدأ، يمكننا التصريح عن الاجتزاء داخل كل استعلام كالتالي:

const ALL_PERSONS = gql`
  {
    allPersons  {
      ...PersonDetails
    }
  }

  fragment PersonDetails on Person {
    name
    phone 
    address {
      street 
      city
    }
  }
`

لكن من الأفضل أن تُصرح عن الاجتزاء مرة واحدة ثم تخزنه ضمن متغيّر.

const PERSON_DETAILS = gql`
  fragment PersonDetails on Person {
    id
    name
    phone 
    address {
      street 
      city
    }
  }
`

وإن صُرِّح عن الاجتزاء كما سبق، أمكن وضعه ضمن أي استعلام أو طفرة باستخدام إشارة الدولار $ والأقواس المعقوصة:

const ALL_PERSONS = gql`
  {
    allPersons  {
      ...PersonDetails
    }
  }
  ${PERSON_DETAILS}  
`

الاشتراكات

تقدم GraphQL بالإضافة إلى الاستعلامات والطفرات، آلية ثالثة هي الاشتراكات subscriptions. حيث تُمكّن الاشتراكات العملاء من تتبع التحديثات التي قد تطرأ على الخادم.

تختلف الاشتراكات جذريًا عن أية أفكار طرحناها في المنهاج. فحتى الآن، يقتصر التفاعل بين الخادم والمتصفح على تطبيق React يعمل على المتصفح ويرسل طلبات HTTP إلى الخادم. كما تُنفَّذ استعلامات وطفرات GraphQL بالطريقة ذاتها. لكن الوضع سينقلب مع الاشتراكات. فبعد أن يسجل التطبيق اشتراكًا، سيبدأ بالتنصت على التغيرات في الخادم. حيث سيرسل الخادم تنبيهًا إلى كل التطبيقات التي سجلت اشتراكًا في حال حدث أي تغيير.

ولو أردنا التحدث بلغة تقنية سنقول أن البروتوكول HTTP لا يلائم تمامًا الاتصال من الخادم إلى المتصفح، لذا فقد استخدمت Apollo تحت الغطاء مايسمى بمقابس الويب WebSockets للاتصال بين المشتركين والخادم.

الاشتراكات من جانب الخادم

لننجز آلية اشتراك لتلقي تنبيهات عندما يُضاف شخص جديد. لا تغييرات كثيرة على شيفرة الخادم، لكن سيتغير التخطيط قليلًا على النحو التالي:

type Subscription {
  personAdded: Person!
}    

فعندما يُضاف شخص جديد، سترسل جميع تفاصيله إلى جميع المشتركين.

يحتاج الاشتراك personAdded إلى محلل. كما يجب تعديل المحلل addPerson لكي يرسل تنبيهًا إلى المشتركين. ستتغير الشيفرة على النحو التالي:

const { PubSub } = require('apollo-server')const pubsub = new PubSub()
  Mutation: {
    addPerson: async (root, args, context) => {
      const person = new Person({ ...args })
      const currentUser = context.currentUser

      if (!currentUser) {
        throw new AuthenticationError("not authenticated")
      }

      try {
        await person.save()
        currentUser.friends = currentUser.friends.concat(person)
        await currentUser.save()
      } catch (error) {
        throw new UserInputError(error.message, {
          invalidArgs: args,
        })
      }

      pubsub.publish('PERSON_ADDED', { personAdded: person })
      return person
    },  
  },
  Subscription: {
      personAdded: {
          subscribe: () => pubsub.asyncIterator(['PERSON_ADDED'])
      },
  },

يحدث الاتصال في الاشتراكات باستخدام مبدأ نشر- اشتراك publish-subscribe عن طريق كائن يستخدم الواجهة PubSub. حيث ينشر إضافة شخص جديد تنبيهًا عن العملية تصل إلى جميع المشتركين وذلك بالاستفادة من التابع publish العائد للواجهة PubSub.

يسجل محلل الاشتراك personAdded جميع المشتركين وذلك بإعادته كائن مكرّر iterator object مناسب.

لنجري التعديلات التالية على الشيفرة التي تُشغِّل الخادم:

// ...

server.listen().then(({ url, subscriptionsUrl }) => {
    console.log(`Server ready at ${url}`)
    console.log(`Subscriptions ready at ${subscriptionsUrl}
`)})

سنجد أن الخادم يتنصت على الاشتراكات على العنوان ws://localhost:4000/graphql

Server ready at http://localhost:4000/
Subscriptions ready at ws://localhost:4000/graphql

لا نحتاج لأية تعديلات أخرى على الخادم.

يمكن اختبار الاشتراكات باستخدام أرضية عمل GraphQL كالتالي:

gpg_subscription_01.png

عندما تضغط على زر التشغيل play فوق الاشتراك، ستنتظر أرضية العمل التنبيهات التي قد تصل إلى الاشتراك.

يمكن إيجاد شيفرة الواجهة الخلفية ضمن الفرع part8-6 في المستودع الخاص بالتطبيق على Github.

الاشتراكات من جانب العميل

لكي نصبح قادرين على استخدام الاشتراكات في تطبيق React، لا بدّ من إجراء بعض التغييرات وخاصة على أوامر التهيئة. ينبغي تعديل أوامر التهيئة الموجودة في الملف "index.js" كالتالي:

import { 
  ApolloClient, ApolloProvider, HttpLink, InMemoryCache, 
  split} from '@apollo/client'
import { setContext } from 'apollo-link-context'

import { getMainDefinition } from '@apollo/client/utilities'import { WebSocketLink } from '@apollo/client/link/ws'
const authLink = setContext((_, { headers }) => {
  const token = localStorage.getItem('phonenumbers-user-token')
  return {
    headers: {
      ...headers,
      authorization: token ? `bearer ${token}` : null,
    }
  }
})

const httpLink = new HttpLink({
  uri: 'http://localhost:4000',
})

const wsLink = new WebSocketLink({
    uri: `ws://localhost:4000/graphql`,
    options: {
        reconnect: true
    }})const splitLink = split(
    ({ query }) => {
        const definition = getMainDefinition(query)
        return (
            definition.kind === 'OperationDefinition' &&
            definition.operation === 'subscription'
        );
    },
    wsLink,
    authLink.concat(httpLink),)
const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: splitLink})

ReactDOM.render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>, 
  document.getElementById('root')
)

ولكي تعمل الشيفرة لابدّ من تثبيت بعض الاعتماديات:

npm install @apollo/client subscriptions-transport-ws

إنّ السبب الكامن خلف إجراء التعديلات على أوامر التهيئة، هو ضرورة امتلاك التطبيق اتصال HTTP بالإضافة إلى مقبس ويب إلى خادم GraphQL.

const wsLink = new WebSocketLink({
  uri: `ws://localhost:4000/graphql`,
  options: { reconnect: true }
})

const httpLink = createHttpLink({
  uri: 'http://localhost:4000',
})

تُنفَّذ الاشتراكات باستخدام دالة الخطاف useSubscription.

لنعدل الشيفرة كالتالي:

export const PERSON_ADDED = gql`
subscription {
personAdded {
...PersonDetails
}  
}  
${PERSON_DETAILS}`
import {
  useQuery, useMutation, useSubscription, useApolloClient} from '@apollo/client'

const App = () => {
  // ...

  useSubscription(PERSON_ADDED, {
    onSubscriptionData: ({ subscriptionData }) => {
      console.log(subscriptionData)
    }
  })

  // ...
}

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

console_person_details_print_02.png

سيرسل الشخص تنبيهًا إلى العميل عندما يُضاف شخص جديد، ثم تستدعى الدالة المعرّفة في الصفة onSubscriptionData وتُزوّد بتفاصيل الشخص الجديد على أساس معاملات.

لنوسِّع حلنا بحيث يضاف الشخص الجديد إلى الذاكرة المؤقتة لمكتبة Apollo عندما تُستقبل تفاصيله.

لكن لابدّ من الانتباه إلى عدم إضافة الشخص الجديد إلى الذاكرة المؤقته مرتين عندما يُنشئه المستخدم:

const App = () => {
  // ...

  const updateCacheWith = (addedPerson) => {
    const includedIn = (set, object) => 
      set.map(p => p.id).includes(object.id)  

    const dataInStore = client.readQuery({ query: ALL_PERSONS })
    if (!includedIn(dataInStore.allPersons, addedPerson)) {
      client.writeQuery({
        query: ALL_PERSONS,
        data: { allPersons : dataInStore.allPersons.concat(addedPerson) }
      })
    }   
  }

  useSubscription(PERSON_ADDED, {
    onSubscriptionData: ({ subscriptionData }) => {
      const addedPerson = subscriptionData.data.personAdded
      notify(`${addedPerson.name} added`)
      updateCacheWith(addedPerson)
    }
  })

  // ...
}

يمكن استخدام الدالة updateCacheWith ضمن الكائن PersonForm لتحديث الذاكرة المؤقتة:

const PersonForm = ({ setError, updateCacheWith }) => {  // ...

  const [ createPerson ] = useMutation(CREATE_PERSON, {
    onError: (error) => {
      setError(error.graphQLErrors[0].message)
    },
    update: (store, response) => {
      updateCacheWith(response.data.addPerson)
    }
  })

  // ..
} 

يمكن إيجاد شيفرة العميل بشكلها الكامل ضمن الفرع part8-9 في المستودع المخصص للتطبيق على Github.

مسألة n+1

لنضف شيئًا ما إلى الواجهة الخلفية. سنعدل التخطيط بحيث يمتلك النوع Person الحقل friendOf الذي يدل على أي قائمة أصدقاء يتواجد الشخص:

type Person {
  name: String!
  phone: String
  address: Address!
  friendOf: [User!]!
  id: ID!
}

يجب أن يدعم التطبيق الاستعلام التالي:

query {
  findPerson(name: "Leevi Hellas") {
    friendOf{
      username
    }
  }
}

وبما أنّ الحقل friendOf ليس حقلًا من حقول الكائن Person ضمن قاعدة البيانات، لابدّ من إنشاء محلل للعملية يحل هذا الموضوع. لننشئ أولًا محللًا يعيد قائمة فارغة:

Person: {
  address: (root) => {
    return { 
      street: root.street,
      city: root.city
    }
  },
  friendOf: (root) => {
      // يعيد قائمة فارغة
      return [
      ]
  }},

يمثّل المعامل root الكائن الخاص بالشخص الذي تُنشأ من أجله قائمة أصدقاء، وبالتالي سنبحث ضمن كل الكائنات من النوع User على تلك التي تمتلك فيها root معرّفًا فريدًا id كالتالي root._id في قائمة الأصدقاء الخاصة به:

  Person: {
    // ...
    friendOf: async (root) => {
      const friends = await User.find({
        friends: {
          $in: [root._id]
        } 
      })

      return friends
    }
  },

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

query {
  allPersons {
    name
    friendOf {
      username
    }
  }
}

لكن هناك مشكلة واحدة في حلّنا، فهي تُرسل عددًا غير منطقي من الاستعلامات إلى قاعدة البيانات. فلو سجّلنا كل استعلام، وكان لدينا 5 أشخاص، سنرى التالي:

Person.find
User.find
User.find
User.find
User.find
User.find

فعلى الرغم من أننا نفّذنا استعلامًا واحدًا رئيسيًا، فسيُنفِّذ كل شخص ضمن قاعدة البيانات استعلامًا آخر من خلال المحلل الخاص به، وهذا ما يعرف بمشكلة n+1 الشهيرة والتي تظهر بين الفينة والأخرى بشكل مختلف، وقد تتسلل إلى شيفرة المطورين دون الانتباه إليها.

يعتمد حل هذه المشكلة على الحالة التي أدت لظهورها. وعادة ما تتطلب استخدام استعلامات مشتركة بدلًا من عدة استعلامات منفصلة.

إنّ الحل الأسهل لحالتنا هي تخزين الأشخاص الذين تتواجد قائمة أصدقائهم ضمن كل كائن من النوع Person:

const schema = new mongoose.Schema({
  name: {
    type: String,
    required: true,
    unique: true,
    minlength: 5
  },
  phone: {
    type: String,
    minlength: 5
  },
  street: {
    type: String,
    required: true,
    minlength: 5
  },  
  city: {
    type: String,
    required: true,
    minlength: 5
  },
  friendOf: [
      {
          type: mongoose.Schema.Types.ObjectId,
          ref: 'User'
      }
  ],
})

وبعدها يمكننا أن ننفذ استعلامًا مشتركًا أو أن نملأ الحقول friendOf للأشخاص عندما نحضر الكائنات Person:

Query: {
  allPersons: (root, args) => {    
    console.log('Person.find')
    if (!args.phone) {
      return Person.find({}).populate('friendOf')
    }
 return Person.find({ phone: { $exists: args.phone === 'YES' } })
      .populate('friendOf')
  },
  // ...
}

لن نحتاج بعد التغييرات التي أجريناها إلى محلل مستقل للحقل friendOf.

لن يسبب الاستعلام allPersons مشكلة n+1 إن أحضرنا اسم الشخص ورقم هاتفه فقط:

query {
  allPersons {
    name
    phone
  }
}

لو عدّلنا الاستعلام لتنفيذ استعلام مشترك لأنه يسبب أحيانا مشكلة n+1، فسيتطلب تنفيذه جهدًا أكبر، وخاصةً عندما لا نحتاج المعلومات عن الأشخاص المعنيين. يمكن استمثال الاستعلام باستخدام المعامل الرابع لدوال المحللات. إذ يستخدم هذا المعامل للتحقق من الاستعلام بحد ذاته، وبالتالي يمكن تنفيذ الاستعلام المشترك في بعض الحالات فقط عندما نتنبأ بحدوث مشكلة n+1. لكن لا تستعجل بالقفز إلى هذه المرحلة قبل أن تتأكد بأنك تحتاج ذلك فعلًا.

وكما يقول دونالد كنوث:

اقتباس

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

تقدم المكتبة DataLoader التي طورتها Facebook حلًا جيدًا لمشكلة n+1 بالإضافة إلى مسائل أخرى. يمكن إيجاد معلومات أكثر عن استخدامها مع Apollo من خلال الانترنت وننصح بالمقالة graphql server data loader caching batching والمقالة batching graphq queries with dataloader.

خاتمة

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

لقد أصبحت GraphQL تقنية قديمة نوعًا ما، إذ بدأت Facebbok باستخدامها منذ عام 2012، وقد خضعت بالفعل لاختبارات صعبة. تزايد الاهتمام بهذه المكتبة شيئًا فشيئًا منذ أن نشرتها FaceBook عام 2015، وقد تهدد سيطرة REST في المستقبل القريب. إنه تلاشي متوقع، لكنه لن يحصل قريبًا. وبالتالي تعلم GrapQL أمر يستحق المحاولة بكل تأكيد.

تمارين

1 الاشتراكات من جهة الخادم

أضف الاشتراك bookAdded إلى الواجهة الخلفية بحيث يعيد تفاصيل كل الكتب الجديدة للمشترك.

2 الاشتراكات من جهة العميل: القسم الأول

ابدأ باستخدام الاشتراكات من جهة العميل. أضف الاشتراك bookAdded، وأبلغ المستخدم عندما تُضاف كتب جديدة. يمكنك أن تنبه المستخدم بطرق عدة، مثل استعمال الدالة window.alert على سبيل المثال.

3 الاشتراكات من ناحية العميل: القسم الثاني

ابق على واجهة العرض محدَّثةً بعد تنبيه المستخدم عن الكتب الجديدة. يمكنك التحقق من صحة عملك بفتح التطبيق في نافذتين مختلفتين من المتصفح وإضافة كتابة جديد في إحداهما. يجب أن تُحدِّث واجهة العرض في كلتا النافذتين الأخرى عند إضافة الكتاب الجديد.

مشكلة n+1

جد حلًا لمشكلة n+1 في الاستعلام التالي بأي طريقة تريد:

query {
  allAuthors {
    name 
    bookCount
  }
}

وهكذا نكون وصلنا إلى آخر تمرين في هذا القسم وحان الوقت لرفع إجاباتك إلى GitHub. لا تنس تحديد التمارين التي أنجزت حلها ضمن منظومة تسليم التمارين.

ترجمة -وبتصرف- للفصل Fragments and Subscriptions من سلسلة Deep Dive Into Modern Web Development


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...