واجهات GraphQL

الاشتراكات

20 دقيقة الدرس 16 من 35

التحديثات في الوقت الفعلي مع اشتراكات GraphQL

تتيح الاشتراكات الاتصال القائم على الأحداث في الوقت الفعلي بين الخادم والعميل. على عكس الاستعلامات والتحويرات، تحافظ الاشتراكات على اتصال مستمر وتدفع البيانات عند حدوث الأحداث.

ما هي الاشتراكات؟

تسمح الاشتراكات للعملاء بالاستماع إلى التحديثات في الوقت الفعلي من الخادم. إنها مثالية لتطبيقات الدردشة والإشعارات المباشرة ولوحات المعلومات في الوقت الفعلي والتحرير التعاوني.

تعريف مخطط الاشتراك:
type Subscription {
  messageAdded(channelId: ID!): Message
  userStatusChanged(userId: ID!): User
  commentAdded(postId: ID!): Comment
  notificationReceived: Notification
}

type Message {
  id: ID!
  content: String!
  author: User!
  channel: Channel!
  createdAt: DateTime!
}

نمط النشر والاشتراك (PubSub)

تستخدم اشتراكات GraphQL عادةً نمط النشر والاشتراك. ينشر الخادم الأحداث إلى المواضيع، ويتلقى العملاء المشتركون التحديثات.

تطبيق PubSub (Node.js مع Apollo):
import { PubSub } from 'graphql-subscriptions';

const pubsub = new PubSub();

// تحوير ينشر الحدث
const resolvers = {
  Mutation: {
    sendMessage: async (parent, { channelId, content }, { user }) => {
      const message = await Message.create({
        channelId,
        content,
        authorId: user.id
      });

      // نشر الحدث للمشتركين
      pubsub.publish('MESSAGE_ADDED', {
        messageAdded: message,
        channelId
      });

      return message;
    }
  },

  Subscription: {
    messageAdded: {
      subscribe: (parent, { channelId }) => {
        return pubsub.asyncIterator(['MESSAGE_ADDED']);
      },
      resolve: (payload, { channelId }) => {
        // تصفية حسب القناة
        if (payload.channelId === channelId) {
          return payload.messageAdded;
        }
        return null;
      }
    }
  }
};
ملاحظة: PubSub في الذاكرة مناسب للتطوير ولكن ليس للإنتاج. للإنتاج، استخدم Redis PubSub أو أنظمة موزعة مماثلة للتعامل مع عدة مثيلات خادم.

محللات الاشتراكات

محللات الاشتراكات لها وظيفتان: subscribe (إعداد الاشتراك) و resolve (تحويل البيانات المنشورة).

محلل اشتراك متقدم:
const resolvers = {
  Subscription: {
    commentAdded: {
      // إعداد الاشتراك
      subscribe: withFilter(
        () => pubsub.asyncIterator(['COMMENT_ADDED']),
        (payload, variables) => {
          // تصفية: إرسال فقط إذا كان التعليق لهذا المنشور
          return payload.commentAdded.postId === variables.postId;
        }
      ),

      // تحويل الحمولة
      resolve: (payload) => {
        return payload.commentAdded;
      }
    },

    userStatusChanged: {
      subscribe: withFilter(
        () => pubsub.asyncIterator(['USER_STATUS_CHANGED']),
        (payload, variables, context) => {
          // فحص المصادقة
          if (!context.user) return false;

          // إخطار الأصدقاء فقط
          return payload.userId === variables.userId &&
                 context.user.friends.includes(payload.userId);
        }
      )
    }
  }
};

نقل WebSocket

تتطلب الاشتراكات اتصالات WebSocket للاتصال ثنائي الاتجاه. يستخدم Apollo Server بروتوكول graphql-ws.

إعداد Apollo Server مع الاشتراكات:
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { makeExecutableSchema } from '@graphql-tools/schema';
import express from 'express';

const app = express();
const httpServer = createServer(app);

const schema = makeExecutableSchema({
  typeDefs,
  resolvers
});

// خادم WebSocket للاشتراكات
const wsServer = new WebSocketServer({
  server: httpServer,
  path: '/graphql'
});

const serverCleanup = useServer({ schema }, wsServer);

const server = new ApolloServer({
  schema,
  plugins: [
    ApolloServerPluginDrainHttpServer({ httpServer }),
    {
      async serverWillStart() {
        return {
          async drainServer() {
            await serverCleanup.dispose();
          }
        };
      }
    }
  ]
});

await server.start();
app.use('/graphql', express.json(), expressMiddleware(server));

httpServer.listen(4000);

تصفية الاشتراكات

تسمح وظيفة withFilter بتصفية المشتركين الذين يتلقون أي أحداث.

أمثلة التصفية:
import { withFilter } from 'graphql-subscriptions';

const resolvers = {
  Subscription: {
    // تصفية حسب معرف المستخدم
    notificationReceived: {
      subscribe: withFilter(
        () => pubsub.asyncIterator(['NOTIFICATION']),
        (payload, variables) => {
          return payload.userId === variables.userId;
        }
      )
    },

    // تصفية حسب معايير متعددة
    orderUpdated: {
      subscribe: withFilter(
        () => pubsub.asyncIterator(['ORDER_UPDATED']),
        (payload, variables, context) => {
          const { orderId, status } = variables;
          const order = payload.orderUpdated;

          // تصفية حسب معرف الطلب
          if (orderId && order.id !== orderId) return false;

          // تصفية حسب الحالة
          if (status && order.status !== status) return false;

          // المصادقة: يجب أن يمتلك المستخدم الطلب
          return order.userId === context.user.id;
        }
      )
    }
  }
};

دورة حياة الاشتراك

فهم دورة حياة الاشتراك يساعد في تصحيح الأخطاء وتحسين الميزات في الوقت الفعلي.

خطافات دورة الحياة:
const resolvers = {
  Subscription: {
    messageAdded: {
      subscribe: async (parent, args, context) => {
        // 1. إنشاء الاتصال
        console.log('المشترك متصل:', context.user.id);

        // 2. التحقق من الأذونات
        const channel = await Channel.findById(args.channelId);
        if (!channel.hasAccess(context.user)) {
          throw new Error('الوصول مرفوض');
        }

        // 3. إعداد المكرر
        const iterator = pubsub.asyncIterator(['MESSAGE_ADDED']);

        // 4. التنظيف عند قطع الاتصال
        const cleanup = () => {
          console.log('المشترك قطع الاتصال:', context.user.id);
          // تنظيف الموارد
        };

        iterator.return = async () => {
          cleanup();
          return { value: undefined, done: true };
        };

        return iterator;
      }
    }
  }
};
نصيحة: راقب الاشتراكات النشطة ونفذ التنظيف لمنع تسرب الذاكرة. فكر في الحد من عدد الاشتراكات المتزامنة لكل مستخدم.

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

خطاف اشتراك React:
import { useSubscription, gql } from '@apollo/client';

const MESSAGE_SUBSCRIPTION = gql`
  subscription MessageAdded($channelId: ID!) {
    messageAdded(channelId: $channelId) {
      id
      content
      author {
        id
        name
      }
      createdAt
    }
  }
`;

function ChatRoom({ channelId }) {
  const { data, loading, error } = useSubscription(
    MESSAGE_SUBSCRIPTION,
    { variables: { channelId } }
  );

  if (loading) return <p>جارٍ الاتصال...</p>;
  if (error) return <p>خطأ: {error.message}</p>;

  return (
    <div>
      {data && (
        <div className="new-message">
          <strong>{data.messageAdded.author.name}:</strong>
          {data.messageAdded.content}
        </div>
      )}
    </div>
  );
}
تحذير: تستهلك الاشتراكات موارد الخادم. نفذ تحديد المعدل والمصادقة، وفكر في استخدام Redis PubSub للتوسع الأفقي عبر عدة مثيلات خادم.
تمرين:
  1. نفذ اشتراكًا لتحديثات التعليقات المباشرة على منشور مدونة
  2. أضف تصفية لإرسال التعليقات فقط للمستخدمين الذين لديهم وصول إلى المنشور
  3. أنشئ تحويرًا ينشر حدث التعليق
  4. قم ببناء مكون React يشترك ويعرض التعليقات الجديدة في الوقت الفعلي
  5. أضف المصادقة إلى سياق الاشتراك