واجهات GraphQL
أنماط الترقيم في GraphQL
تنفيذ الترقيم في GraphQL
الترقيم ضروري للتعامل مع مجموعات البيانات الكبيرة بكفاءة. يدعم GraphQL استراتيجيات ترقيم متعددة، من الترقيم البسيط القائم على الإزاحة إلى الترقيم الأكثر تطوراً القائم على المؤشر بأسلوب Relay.
الترقيم القائم على الإزاحة
أبسط نهج للترقيم باستخدام الحد والإزاحة:
type Query {
posts(limit: Int = 10, offset: Int = 0): PostConnection!
}
type PostConnection {
posts: [Post!]!
total: Int!
hasMore: Boolean!
}
type Post {
id: ID!
title: String!
content: String!
createdAt: DateTime!
}
محلل ترقيم الإزاحة
const resolvers = {
Query: {
posts: async (_, { limit = 10, offset = 0 }) => {
// التحقق من معاملات الترقيم
if (limit < 1 || limit > 100) {
throw new Error('Limit must be between 1 and 100');
}
if (offset < 0) {
throw new Error('Offset cannot be negative');
}
// جلب المنشورات بالحد والإزاحة
const posts = await Post.find()
.sort({ createdAt: -1 })
.skip(offset)
.limit(limit);
// الحصول على العدد الإجمالي لمعلومات الترقيم
const total = await Post.countDocuments();
return {
posts,
total,
hasMore: offset + limit < total
};
}
}
};
// مثال على الاستعلام:
// query {
// posts(limit: 20, offset: 40) {
// posts {
// id
// title
// }
// total
// hasMore
// }
// }
ملاحظة: ترقيم الإزاحة بسيط ولكن لديه مشاكل في الأداء مع الإزاحات الكبيرة. استخدم الترقيم القائم على المؤشر للحصول على أداء أفضل مع مجموعات البيانات الكبيرة.
الترقيم القائم على المؤشر
ترقيم أكثر كفاءة باستخدام المؤشرات بدلاً من الإزاحات الرقمية:
type Query {
posts(
first: Int
after: String
last: Int
before: String
): PostConnection!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Post {
id: ID!
title: String!
content: String!
createdAt: DateTime!
}
تنفيذ ترقيم المؤشر
const { Buffer } = require('buffer');
// ترميز/فك ترميز المؤشر
function encodeCursor(value) {
return Buffer.from(value.toString()).toString('base64');
}
function decodeCursor(cursor) {
return Buffer.from(cursor, 'base64').toString('utf-8');
}
const resolvers = {
Query: {
posts: async (_, { first, after, last, before }) => {
// التحقق من الوسائط
if (first && last) {
throw new Error('Cannot use first and last together');
}
if (first && first < 1) {
throw new Error('first must be positive');
}
if (last && last < 1) {
throw new Error('last must be positive');
}
// بناء الاستعلام
let query = {};
if (after) {
const afterDate = new Date(decodeCursor(after));
query.createdAt = { $lt: afterDate };
}
if (before) {
const beforeDate = new Date(decodeCursor(before));
query.createdAt = { ...query.createdAt, $gt: beforeDate };
}
// تحديد الحد وترتيب الفرز
const limit = first || last || 10;
const sortOrder = last ? 1 : -1;
// جلب المنشورات
let posts = await Post.find(query)
.sort({ createdAt: sortOrder })
.limit(limit + 1); // جلب واحد إضافي للتحقق من المزيد من الصفحات
// التحقق من المزيد من الصفحات
const hasMore = posts.length > limit;
if (hasMore) {
posts = posts.slice(0, limit);
}
// عكس الترتيب إذا كان جلب الأخير
if (last) {
posts = posts.reverse();
}
// إنشاء الحواف
const edges = posts.map(post => ({
node: post,
cursor: encodeCursor(post.createdAt.toISOString())
}));
// الحصول على العدد الإجمالي
const totalCount = await Post.countDocuments();
return {
edges,
pageInfo: {
hasNextPage: first ? hasMore : false,
hasPreviousPage: last ? hasMore : false,
startCursor: edges.length > 0 ? edges[0].cursor : null,
endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null
},
totalCount
};
}
}
};
نصيحة: قم بترميز المؤشرات كسلاسل base64 لإخفاء تفاصيل التنفيذ. يمكن أن تستند المؤشرات إلى المعرفات أو الطوابع الزمنية أو القيم المركبة.
نمط اتصال بأسلوب Relay
اتباع مواصفات Relay للحصول على ترقيم متسق عبر واجهة برمجة التطبيقات الخاصة بك:
// مثال على استعلام Relay
query GetPosts {
posts(first: 10, after: "Y3JlYXRlZEF0OjIwMjYtMDItMTU=") {
edges {
cursor
node {
id
title
author {
name
}
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}
// جلب الصفحة التالية
query GetNextPage {
posts(first: 10, after: "Y3JlYXRlZEF0OjIwMjYtMDItMDU=") {
edges {
cursor
node {
id
title
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
// جلب الصفحة السابقة
query GetPreviousPage {
posts(last: 10, before: "Y3JlYXRlZEF0OjIwMjYtMDItMTU=") {
edges {
cursor
node {
id
title
}
}
pageInfo {
hasPreviousPage
startCursor
}
}
}
مساعد ترقيم قابل لإعادة الاستخدام
// utils/pagination.js
class PaginationHelper {
static encodeCursor(value) {
return Buffer.from(String(value)).toString('base64');
}
static decodeCursor(cursor) {
return Buffer.from(cursor, 'base64').toString('utf-8');
}
static async paginate(model, args, options = {}) {
const {
first,
after,
last,
before,
sortField = 'createdAt',
sortOrder = -1
} = { ...args, ...options };
// بناء الاستعلام
const query = { ...options.where };
if (after) {
const afterValue = this.decodeCursor(after);
query[sortField] = { $lt: afterValue };
}
if (before) {
const beforeValue = this.decodeCursor(before);
query[sortField] = {
...query[sortField],
$gt: beforeValue
};
}
// تحديد الحد
const limit = first || last || 10;
const sort = last ? -sortOrder : sortOrder;
// جلب المستندات
let docs = await model
.find(query)
.sort({ [sortField]: sort })
.limit(limit + 1);
// التحقق من المزيد من الصفحات
const hasMore = docs.length > limit;
if (hasMore) {
docs = docs.slice(0, limit);
}
if (last) {
docs = docs.reverse();
}
// إنشاء الحواف
const edges = docs.map(doc => ({
node: doc,
cursor: this.encodeCursor(doc[sortField])
}));
// الحصول على العدد الإجمالي
const totalCount = await model.countDocuments(options.where || {});
return {
edges,
pageInfo: {
hasNextPage: first ? hasMore : false,
hasPreviousPage: last ? hasMore : false,
startCursor: edges[0]?.cursor || null,
endCursor: edges[edges.length - 1]?.cursor || null
},
totalCount
};
}
}
// الاستخدام في المحلل
const resolvers = {
Query: {
posts: async (_, args) => {
return await PaginationHelper.paginate(Post, args);
},
userPosts: async (_, { userId, ...args }) => {
return await PaginationHelper.paginate(Post, args, {
where: { userId }
});
}
}
};
module.exports = PaginationHelper;
تحذير: احرص دائمًا على تحديد حجم الصفحة الأقصى لمنع مشاكل الأداء. الحد الشائع هو 100 عنصر لكل صفحة.
الترقيم ثنائي الاتجاه
دعم الترقيم الأمامي والخلفي:
const resolvers = {
Query: {
posts: async (_, { first, after, last, before }) => {
// الترقيم الأمامي
if (first) {
const query = after
? { createdAt: { $lt: decodeCursor(after) } }
: {};
const posts = await Post.find(query)
.sort({ createdAt: -1 })
.limit(first + 1);
const hasNextPage = posts.length > first;
const nodes = hasNextPage ? posts.slice(0, first) : posts;
return {
edges: nodes.map(post => ({
node: post,
cursor: encodeCursor(post.createdAt)
})),
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: nodes[0] ? encodeCursor(nodes[0].createdAt) : null,
endCursor: nodes[nodes.length - 1]
? encodeCursor(nodes[nodes.length - 1].createdAt)
: null
},
totalCount: await Post.countDocuments()
};
}
// الترقيم الخلفي
if (last) {
const query = before
? { createdAt: { $gt: decodeCursor(before) } }
: {};
const posts = await Post.find(query)
.sort({ createdAt: 1 })
.limit(last + 1);
const hasPreviousPage = posts.length > last;
const nodes = hasPreviousPage ? posts.slice(0, last) : posts;
nodes.reverse();
return {
edges: nodes.map(post => ({
node: post,
cursor: encodeCursor(post.createdAt)
})),
pageInfo: {
hasNextPage: !!before,
hasPreviousPage,
startCursor: nodes[0] ? encodeCursor(nodes[0].createdAt) : null,
endCursor: nodes[nodes.length - 1]
? encodeCursor(nodes[nodes.length - 1].createdAt)
: null
},
totalCount: await Post.countDocuments()
};
}
throw new Error('Must provide either first or last');
}
}
};
تمرين:
- نفذ ترقيم قائم على الإزاحة لاستعلام
usersمع التحقق من حجم الصفحة - أنشئ نظام ترقيم قائم على المؤشر باستخدام معرفات المنشورات بدلاً من الطوابع الزمنية
- ابنِ مساعد ترقيم قابل لإعادة الاستخدام يعمل مع أي نموذج Mongoose
- نفذ ترقيم ثنائي الاتجاه يدعم كلاً من
first/afterوlast/before - أضف إمكانيات التصفية إلى الاستعلامات المرقمة (على سبيل المثال، التصفية حسب الفئة أثناء ترقيم المنشورات)