WebSockets والتطبيقات الفورية
الوقت الفعلي مع اشتراكات GraphQL
مقدمة في اشتراكات GraphQL
توفر اشتراكات GraphQL طريقة لدفع التحديثات في الوقت الفعلي من الخادم إلى العملاء عندما تحدث أحداث معينة. على عكس الاستعلامات (طلب البيانات) والطفرات (تعديل البيانات)، تحافظ الاشتراكات على اتصال نشط وتدفق التحديثات.
ما هي اشتراكات GraphQL؟
الاشتراكات هي ميزة GraphQL تسمح بـ:
- تحديثات الوقت الفعلي: دفع البيانات إلى العملاء عندما تحدث الأحداث
- موجهة بالأحداث: يشترك العملاء في أحداث معينة
- قائمة على WebSocket: يستخدم بروتوكول WebSocket للاتصال ثنائي الاتجاه
- آمن من النوع: دعم كامل لنظام نوع GraphQL
مفهوم أساسي: تكمل الاشتراكات ثلاثية GraphQL: الاستعلامات (قراءة)، الطفرات (كتابة)، الاشتراكات (تحديثات الوقت الفعلي).
لماذا نستخدم اشتراكات GraphQL؟
الفوائد على REST WebSockets التقليدية:
- بيانات وقت فعلي آمنة من النوع مع مخطط GraphQL
- طلب البيانات التي تحتاجها بالضبط (لا جلب زائد)
- التصفية والوسيطات المدمجة
- سطح API موحد (الاستعلامات والطفرات والاشتراكات في نقطة نهاية واحدة)
- أدوات قوية ودعم العميل (Apollo، Relay، URQL)
مخطط اشتراك GraphQL
حدد الاشتراكات في مخطط GraphQL الخاص بك:
// schema.graphql
type Message {
id: ID!
content: String!
author: User!
createdAt: String!
}
type User {
id: ID!
username: String!
avatar: String
}
type Query {
messages: [Message!]!
}
type Mutation {
sendMessage(content: String!): Message!
}
type Subscription {
# الاشتراك في الرسائل الجديدة في غرفة
messageAdded(roomId: ID!): Message!
# الاشتراك في مؤشرات الكتابة
userTyping(roomId: ID!): User!
# الاشتراك في تغييرات حالة المستخدم
userStatusChanged(userId: ID!): UserStatus!
}
enum UserStatus {
ONLINE
OFFLINE
AWAY
}
إعداد خادم Apollo
قم بإعداد خادم Apollo مع دعم الاشتراك:
// تثبيت التبعيات
// npm install apollo-server-express graphql subscriptions-transport-ws
const { ApolloServer, gql } = require('apollo-server-express');
const { PubSub } = require('graphql-subscriptions');
const { createServer } = require('http');
const express = require('express');
const { ApolloServerPluginDrainHttpServer } = require('apollo-server-core');
const { makeExecutableSchema } = require('@graphql-tools/schema');
const { WebSocketServer } = require('ws');
const { useServer } = require('graphql-ws/lib/use/ws');
const app = express();
const httpServer = createServer(app);
// مثيل PubSub للنشر/الاشتراك في الأحداث
const pubsub = new PubSub();
// مخطط GraphQL
const typeDefs = gql`
type Message {
id: ID!
content: String!
author: String!
timestamp: String!
}
type Query {
messages: [Message!]!
}
type Mutation {
sendMessage(content: String!, author: String!): Message!
}
type Subscription {
messageAdded: Message!
}
`;
// المحللون
const resolvers = {
Query: {
messages: () => messages
},
Mutation: {
sendMessage: (_, { content, author }) => {
const message = {
id: String(Date.now()),
content,
author,
timestamp: new Date().toISOString()
};
messages.push(message);
// نشر حدث للمشتركين
pubsub.publish('MESSAGE_ADDED', {
messageAdded: message
});
return message;
}
},
Subscription: {
messageAdded: {
subscribe: () => pubsub.asyncIterator(['MESSAGE_ADDED'])
}
}
};
// إنشاء المخطط
const schema = makeExecutableSchema({ typeDefs, resolvers });
// خادم WebSocket للاشتراكات
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql'
});
const serverCleanup = useServer({ schema }, wsServer);
// خادم Apollo
const server = new ApolloServer({
schema,
plugins: [
ApolloServerPluginDrainHttpServer({ httpServer }),
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
}
};
}
}
]
});
await server.start();
server.applyMiddleware({ app });
httpServer.listen(4000, () => {
console.log(`الخادم جاهز على http://localhost:4000${server.graphqlPath}`);
console.log(`الاشتراكات جاهزة على ws://localhost:4000${server.graphqlPath}`);
});
مهم: تتطلب الاشتراكات كلاً من خادم HTTP (للاستعلامات/الطفرات) وخادم WebSocket (للاشتراكات) يعملان على نفس المنفذ.
محللات الاشتراك
تستخدم محللات الاشتراك مكررات غير متزامنة:
const resolvers = {
Subscription: {
messageAdded: {
// اشتراك بسيط
subscribe: () => pubsub.asyncIterator(['MESSAGE_ADDED'])
},
// اشتراك مع التصفية
messageAddedInRoom: {
subscribe: (_, { roomId }) => {
return pubsub.asyncIterator([`MESSAGE_ADDED_${roomId}`]);
}
},
// اشتراك مع تصفية مخصصة
messageAddedForUser: {
subscribe: () => pubsub.asyncIterator(['MESSAGE_ADDED']),
resolve: (payload, args, context) => {
// تصفية الرسائل بناءً على أذونات المستخدم
if (payload.messageAdded.private &&
payload.messageAdded.recipientId !== context.userId) {
return null; // لا ترسل لهذا المشترك
}
return payload.messageAdded;
}
}
}
};
// نشر الأحداث
const sendMessage = async (roomId, message) => {
await db.messages.create(message);
// النشر إلى قناة خاصة بالغرفة
pubsub.publish(`MESSAGE_ADDED_${roomId}`, {
messageAddedInRoom: message
});
};
PubSub لنشر الأحداث
يربط نمط PubSub الطفرات بالاشتراكات:
const { PubSub } = require('graphql-subscriptions');
const pubsub = new PubSub();
// أسماء الأحداث كثوابت
const EVENTS = {
MESSAGE_ADDED: 'MESSAGE_ADDED',
USER_TYPING: 'USER_TYPING',
USER_STATUS_CHANGED: 'USER_STATUS_CHANGED'
};
const resolvers = {
Mutation: {
sendMessage: async (_, { roomId, content }, { userId }) => {
const message = await db.messages.create({
roomId,
content,
authorId: userId
});
// النشر لجميع المشتركين
await pubsub.publish(EVENTS.MESSAGE_ADDED, {
messageAdded: message,
roomId // يمكن استخدامه للتصفية
});
return message;
},
setTyping: async (_, { roomId, isTyping }, { userId }) => {
await pubsub.publish(EVENTS.USER_TYPING, {
userTyping: {
userId,
roomId,
isTyping
}
});
return { success: true };
}
},
Subscription: {
messageAdded: {
subscribe: (_, { roomId }) => {
return pubsub.asyncIterator([EVENTS.MESSAGE_ADDED]);
},
resolve: (payload, { roomId }) => {
// التصفية حسب الغرفة
if (payload.roomId === roomId) {
return payload.messageAdded;
}
return null;
}
},
userTyping: {
subscribe: (_, { roomId }) => {
return pubsub.asyncIterator([EVENTS.USER_TYPING]);
},
resolve: (payload, { roomId }) => {
if (payload.userTyping.roomId === roomId) {
return payload.userTyping;
}
return null;
}
}
}
};
أفضل ممارسة: استخدم ثوابت لأسماء الأحداث لتجنب الأخطاء المطبعية وتسهيل إعادة البناء.
تصفية الاشتراكات
تصفية المشتركين الذين يتلقون الأحداث:
// الطريقة 1: التصفية القائمة على القناة (الأكثر كفاءة)
const resolvers = {
Subscription: {
messageAdded: {
subscribe: (_, { roomId }) => {
// لكل غرفة قناتها الخاصة
return pubsub.asyncIterator([`ROOM_${roomId}_MESSAGES`]);
}
}
},
Mutation: {
sendMessage: async (_, { roomId, content }) => {
const message = await saveMessage(roomId, content);
// النشر إلى قناة خاصة بالغرفة
pubsub.publish(`ROOM_${roomId}_MESSAGES`, {
messageAdded: message
});
return message;
}
}
};
// الطريقة 2: التصفية القائمة على المحلل (أكثر مرونة)
const resolvers = {
Subscription: {
messageAdded: {
subscribe: () => pubsub.asyncIterator(['ALL_MESSAGES']),
resolve: (payload, args, context) => {
const { messageAdded } = payload;
const { roomId } = args;
const { user } = context;
// إرسال فقط إذا كان المستخدم في الغرفة
if (messageAdded.roomId !== roomId) return null;
// إرسال فقط إذا كان المستخدم لديه إذن
if (!user.hasAccessToRoom(roomId)) return null;
return messageAdded;
}
}
}
};
// الطريقة 3: مساعد withFilter (يوفره Apollo)
const { withFilter } = require('graphql-subscriptions');
const resolvers = {
Subscription: {
messageAdded: {
subscribe: withFilter(
() => pubsub.asyncIterator(['MESSAGE_ADDED']),
(payload, variables, context) => {
// إرجاع true للإرسال، false للتخطي
return payload.messageAdded.roomId === variables.roomId &&
context.user.hasAccessToRoom(variables.roomId);
}
)
}
}
};
معالجة الاشتراك من جانب العميل
الاتصال والاشتراك من عميل React:
// تثبيت التبعيات
// npm install @apollo/client graphql subscriptions-transport-ws
import { ApolloClient, InMemoryCache, HttpLink, split } from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities';
import { createClient } from 'graphql-ws';
// اتصال HTTP للاستعلامات والطفرات
const httpLink = new HttpLink({
uri: 'http://localhost:4000/graphql'
});
// اتصال WebSocket للاشتراكات
const wsLink = new GraphQLWsLink(createClient({
url: 'ws://localhost:4000/graphql'
}));
// تقسيم حركة المرور بين HTTP و WebSocket
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
httpLink
);
// عميل Apollo
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache()
});
استخدام الاشتراكات في React
import { useSubscription, gql, useMutation } from '@apollo/client';
const MESSAGE_ADDED = gql`
subscription MessageAdded($roomId: ID!) {
messageAdded(roomId: $roomId) {
id
content
author {
id
username
avatar
}
createdAt
}
}
`;
const SEND_MESSAGE = gql`
mutation SendMessage($roomId: ID!, $content: String!) {
sendMessage(roomId: $roomId, content: $content) {
id
content
author {
id
username
}
createdAt
}
}
`;
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
// الاشتراك في الرسائل الجديدة
const { data, loading, error } = useSubscription(MESSAGE_ADDED, {
variables: { roomId },
onSubscriptionData: ({ subscriptionData }) => {
const newMessage = subscriptionData.data.messageAdded;
setMessages(prev => [...prev, newMessage]);
}
});
// طفرة لإرسال الرسائل
const [sendMessage] = useMutation(SEND_MESSAGE);
const handleSend = async (content) => {
await sendMessage({
variables: { roomId, content }
});
};
return (
<div>
{messages.map(msg => (
<div key={msg.id}>
<strong>{msg.author.username}:</strong> {msg.content}
</div>
))}
<MessageInput onSend={handleSend} />
</div>
);
}
تمرين:
- قم بإعداد خادم Apollo مع اشتراكات GraphQL
- أنشئ مخططاً مع:
- استعلام لجلب البيانات الأولية
- طفرة لإنشاء المنشورات
- اشتراك لاستقبال المنشورات الجديدة
- نفذ التصفية بحيث يتلقى المستخدمون فقط المنشورات من المستخدمين المتابَعين
- أنشئ عميل React مع عميل Apollo
- اشترك في التحديثات في الوقت الفعلي واعرضها في واجهة المستخدم
- اختبر مع نوافذ متصفح متعددة للتحقق من المزامنة في الوقت الفعلي
ملخص
توفر اشتراكات GraphQL اتصالاً في الوقت الفعلي آمناً من النوع:
- اتصال ثنائي الاتجاه قائم على WebSocket
- آمن من النوع مع دعم مخطط GraphQL الكامل
- طلب البيانات التي تحتاجها بالضبط (لا جلب زائد)
- تصفية مدمجة مع الوسيطات والمحللات
- API موحد مع الاستعلامات والطفرات
- دعم عميل قوي مع Apollo و Relay و URQL