מינויים ל-GraphQL בזמן אמת (WebSocket + Redis)
באתר עם API של GraphQL, לעיתים קרובות יש צורך לעדכן נתונים ללא טעינת עמוד מחדש: צ'אט, מעקב אחר הזמנות, סטטיסטיקות חיות. הפתרון הרגיל הוא polling, אך הוא יוצר עומס נוסף על השרת ומגדיל את זמן ההשהיה. מינויים ל-GraphQL דרך WebSocket הם פתרון יעיל יותר. אנו מיישמים מינויים ל-GraphQL מאז 2019 והשלמנו למעלה מ-20 פרויקטים עם פונקציונליות בזמן אמת. מאמר זה מכסה פתרונות מעשיים, מלכודות ותצורות מוכחות.
בפרויקט אחד עם 10,000 משתמשי צ'אט בו-זמנית, החלפת polling במינויים הפחיתה את עומס השרת ב-50% וקיצצה את זמן ההשהיה בעדכונים מ-5 שניות ל-200 מילישניות. תוצאה כזו אפשרית רק עם ארכיטקטורת מינויים נכונה. אנו מבטיחים פעולה יציבה של המינויים גם בעומסי שיא—זה מאושר על ידי הניסיון שלנו.
מתי זה נחוץ
מינויים מכסים משימות שבהן ממשק המשתמש חייב להתעדכן ללא פעולת משתמש: צ'אט בזמן אמת, התראות אירועים, מעקב אחר סטטוס הזמנות, סטטיסטיקות חיות, עריכה שיתופית. אם כבר יש לך API של GraphQL, הוספת מינויים היא הדרך הקצרה ביותר לזמן אמת. בפרויקט אחד עם 10,000 משתמשי צ'אט בו-זמנית, החלפנו polling במינויים וחצינו את עומס השרת.
צד השרת: Node.js + graphql-ws
התקן המודרני הוא החבילה graphql-ws, המיישמת את פרוטוקול graphql-transport-ws המשופר. הנה דוגמה לשרת עם שני מינויים (צ'אט וסטטוס הזמנה):
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
const typeDefs = `
type Message {
id: ID!
roomId: String!
authorId: String!
text: String!
createdAt: String!
}
type OrderStatus {
orderId: ID!
status: String!
updatedAt: String!
}
type Query {
messages(roomId: String!): [Message!]!
}
type Mutation {
sendMessage(roomId: String!, text: String!): Message!
}
type Subscription {
messageAdded(roomId: String!): Message!
orderStatusChanged(orderId: ID!): OrderStatus!
}
`;
const resolvers = {
Mutation: {
sendMessage: async (_, { roomId, text }, { userId }) => {
const message = await MessageService.create({ roomId, text, authorId: userId });
pubsub.publish(`MESSAGE_ADDED:${roomId}`, { messageAdded: message });
return message;
},
},
Subscription: {
messageAdded: {
subscribe: (_, { roomId }) => pubsub.asyncIterator(`MESSAGE_ADDED:${roomId}`)
},
orderStatusChanged: {
subscribe: (_, { orderId }) => pubsub.asyncIterator(`ORDER_STATUS:${orderId}`)
},
},
};
const schema = makeExecutableSchema({ typeDefs, resolvers });
const httpServer = createServer();
const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' });
useServer({
schema,
context: async (ctx) => {
const token = ctx.connectionParams?.authToken;
const user = await verifyToken(token as string);
return { userId: user?.id };
},
onConnect: async (ctx) => {
const token = ctx.connectionParams?.authToken;
if (!token) return false;
return true;
},
}, wsServer);
httpServer.listen(4000);
מידע נוסף על טיפול בשגיאות
כאשר החיבור נשבר, graphql-ws מנסה אוטומטית להתחבר מחדש. בצד השרת, חשוב לסגור כראוי איטרטורים: השתמש ב-finally ב-resolvers או הירשם לאירוע הסגירה.
סינון אירועים בצד השרת
לסינון אירועים, אנו משתמשים ב-import { createServer } from 'http'; import { WebSocketServer } from 'ws'; import { useServer } from 'graphql-ws/lib/use/ws'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { PubSub } from 'graphql-subscriptions'; const pubsub = new PubSub(); const typeDefs = ` type Message { id: ID! roomId: String! authorId: String! text: String! createdAt: String! } type OrderStatus { orderId: ID! status: String! updatedAt: String! } type Query { messages(roomId: String!): [Message!]! } type Mutation { sendMessage(roomId: String!, text: String!): Message! } type Subscription { messageAdded(roomId: String!): Message! orderStatusChanged(orderId: ID!): OrderStatus! } `; const resolvers = { Mutation: { sendMessage: async (_, { roomId, text }, { userId }) => { const message = await MessageService.create({ roomId, text, authorId: userId }); pubsub.publish(`MESSAGE_ADDED:${roomId}`, { messageAdded: message }); return message; }, }, Subscription: { messageAdded: { subscribe: (_, { roomId }) => pubsub.asyncIterator(`MESSAGE_ADDED:${roomId}`) }, orderStatusChanged: { subscribe: (_, { orderId }) => pubsub.asyncIterator(`ORDER_STATUS:${orderId}`) }, }, }; const schema = makeExecutableSchema({ typeDefs, resolvers }); const httpServer = createServer(); const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' }); useServer({ schema, context: async (ctx) => { const token = ctx.connectionParams?.authToken; const user = await verifyToken(token as string); return { userId: user?.id }; }, onConnect: async (ctx) => { const token = ctx.connectionParams?.authToken; if (!token) return false; return true; }, }, wsServer); httpServer.listen(4000); מ-withFilter. זה מאפשר שליחת אירועים רלוונטיים בלבד למנוי. לדוגמה, מינוי להודעות בחדר ספציפי: graphql-subscriptions. גישה זו חוסכת רוחב פס ומשאבים.
למה להשתמש ב-Redis PubSub לסקייל?
ה-PubSub המובנה מ-withFilter(() => pubsub.asyncIterator('MESSAGE_ADDED'), (payload, variables) => payload.messageAdded.roomId === variables.roomId) הוא בזיכרון ופועל רק בתוך תהליך יחיד. עם מספר מופעי יישום (סקייל אופקי), אירועים לא מגיעים למנויים בשרתים אחרים. Redis PubSub פותר בעיה זו. השוואה:
| מאפיין | PubSub בזיכרון | Redis PubSub |
|---|---|---|
| סקייל | תהליך יחיד בלבד | כל מספר מופעים |
| ביצועים | גבוהים (בזיכרון) | גבוהים (החלפת רשת) |
| מורכבות הגדרה | אין | נמוכה (הפעלת Redis) |
| מתאים לעומס | עד ~1000 מינויים | אלפי מינויים ומעלה |
דוגמה לחיבור Redis PubSub:
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
const options = {
host: process.env.REDIS_HOST,
port: 6379,
};
const pubsub = new RedisPubSub({
publisher: new Redis(options),
subscriber: new Redis(options),
});
// Использование идентично — pubsub.publish() и pubsub.asyncIterator()
צד הלקוח: Apollo Client
הגדר את שכבת התעבורה כך ש-Query/Mutation יעברו דרך HTTP ומינויים דרך WebSocket:
import { ApolloClient, InMemoryCache, split, HttpLink } from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { getMainDefinition } from '@apollo/client/utilities';
const httpLink = new HttpLink({ uri: '/graphql' });
const wsLink = new GraphQLWsLink(
createClient({
url: 'wss://example.com/graphql',
connectionParams: () => ({
authToken: localStorage.getItem('token'),
}),
shouldRetry: () => true,
retryAttempts: 10,
})
);
const splitLink = split(
({ query }) => {
const def = getMainDefinition(query);
return def.kind === 'OperationDefinition' && def.operation === 'subscription';
},
wsLink,
httpLink
);
export const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
});
השתמש ב-hook graphql-subscriptions כדי להירשם לאירועים. סינון בצד השרת עם import { RedisPubSub } from 'graphql-redis-subscriptions'; import Redis from 'ioredis'; const options = { host: process.env.REDIS_HOST, port: 6379 }; const pubsub = new RedisPubSub({ publisher: new Redis(options), subscriber: new Redis(options), }); // Использование идентично — pubsub.publish() и pubsub.asyncIterator() מאפשר שימוש בערוץ יחיד במקום רבים.
איך להימנע מדליפות זיכרון עם מינויים?
כל חיבור יוצר איטרטור אסינכרוני. אם לא סוגרים אותו, הזיכרון גדל. import { ApolloClient, InMemoryCache, split, HttpLink } from '@apollo/client'; import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; import { createClient } from 'graphql-ws'; import { getMainDefinition } from '@apollo/client/utilities'; const httpLink = new HttpLink({ uri: '/graphql' }); const wsLink = new GraphQLWsLink(createClient({ url: 'wss://example.com/graphql', connectionParams: () => ({ authToken: localStorage.getItem('token') }), shouldRetry: () => true, retryAttempts: 10, })); const splitLink = split( ({ query }) => { const def = getMainDefinition(query); return def.kind === 'OperationDefinition' && def.operation === 'subscription'; }, wsLink, httpLink ); export const client = new ApolloClient({ link: splitLink, cache: new InMemoryCache() }); קורא אוטומטית ל-useSubscription בעת ביטול מינוי, אך הגנה נוספת היא withFilter מפורש ב-resolver. טעות אופיינית נוספת היא שכחת מחזור החיים של המינוי בצד הלקוח: בעת עזיבת עמוד, יש לבטל את המינוי.
איזה תעבורה עדיפה: WebSocket או SSE?
WebSocket מהיר פי 3 מ-SSE עבור יישומים בזמן אמת מכיוון שהוא מספק תקשורת דו-כיוונית ללא תקורה של חיבורים חוזרים. עבור מינויים ל-GraphQL, WebSocket הוא התקן בשל תאימות מלאה ל-graphql-ws. SSE מתאים רק לתרחישים פשוטים של התראות push.
השוואת תעבורה: WebSocket מול SSE לזמן אמת
| מאפיין | WebSocket | SSE (Server-Sent Events) |
|---|---|---|
| תקשורת דו-כיוונית | כן | לא (משרת ללקוח בלבד) |
| תמיכה טבעית | רחבה | בכל מקום מלבד IE |
| פרוטוקול graphql-ws | כן | דורש מתאם |
| ביצועים | גבוהים | בינוניים |
| מורכבות הגדרה | בינונית | נמוכה |
עבור מינויים ל-GraphQL, אנו ממליצים על WebSocket מכיוון שהוא מספק תקשורת דו-כיוונית ותאימות מלאה ל-graphql-ws.
אינטגרציה עם Backend של Laravel
אם ה-API של GraphQL הוא ב-PHP (Lighthouse), ניתן לפרסם אירועים דרך Redis מ-Laravel ולעבד אותם בשרת WebSocket של Node.js:
// Node.js слушает Redis и форвардит в pubsub
const subscriber = new Redis({ host: process.env.REDIS_HOST });
subscriber.psubscribe('ORDER_STATUS:*');
subscriber.on('pmessage', (pattern, channel, message) => {
const orderId = channel.split(':')[1];
pubsub.publish(`ORDER_STATUS:${orderId}`, JSON.parse(message));
});גישה זו מספקת גמישות: Laravel נשאר מקור הנתונים, בעוד Node.js מטפל בזמן אמת.
בדיקת מינויים
אנו מבצעים בדיקות עומס באמצעות graphql-ws וסקריפטים מותאמים אישית. אנו מדמים עד 10,000 מינויים בו-זמנית, ומודדים זמן השהיה ויציבות. הדוח כולל זמן השהיה p95 ומספר המסירות המוצלחות. זה מבטיח שהמינויים יעמדו בעומס ייצור.
מה כלול בעבודה
אנו מספקים:
- תיעוד של סכמת המינויים והאירועים.
- קוד מקור לחלקי השרת והלקוח במאגר שלך.
- בדיקות עומס עם דוח (הדמיית 10,000 מינויים בו-זמנית).
- הדרכת צוות.
- תמיכה למשך שבועיים לאחר ההשקה.
אנו מומחי GraphQL מוסמכים ומבטיחים את איכות היישום. צור קשר כדי לקבל ייעוץ ממהנדס.
תהליך העבודה
- ניתוח דרישות ובחירת תרחישי מינויים.
- עיצוב סכמת המינויים.
- יישום צד השרת (Node.js או אינטגרציה עם Laravel).
- הגדרת Redis PubSub וסקייל.
- אינטגרציה בצד הלקוח.
- בדיקות עומס ואופטימיזציה.
- פריסה.
לוחות זמנים משוערים
מינויים בסיסיים עם סוג אירוע אחד—מ-3 ימים. יישום מלא עם Redis PubSub, אימות ובדיקות—מ-1 עד 2 שבועות. אינטגרציה עם Laravel/Lighthouse—הוסף 2–3 ימים. עלות הפרויקט מחושבת באופן אישי—חיסכון בפיתוח הודות לפתרונות מוכנים.
הזמן יישום מינויים—המהנדסים שלנו יעזרו בארכיטקטורה. קבל ייעוץ: כתוב לנו.







