BackendEngineering

توسيع واجهات GraphQL مع NestJS: دروس من مشاريع حقيقية

أنماط عملية للحفاظ على أداء واجهات NestJS GraphQL وتنظيمها وقابليتها للتنبؤ مع نمو تعقيد المنتج وحجم الزيارات.

BahrTech Team
٣٠ مارس ٢٠٢٦ · 4 دقيقة قراءة

كان منتج إدارة العيادة يعمل بشكل جيد عند عشرة مستخدمين متزامنين. عند خمسين، بدأت نقطة نهاية قائمة المواعيد تصل إلى 800 ميلي ثانية. عند مائة، بدأت تنتهي بـ timeout. الـ schema بدا معقولاً. الـ resolvers كانت نظيفة. المشكلة كانت أن كل موعد في القائمة كان يطلق استعلام قاعدة بيانات منفصل لجلب الطبيب المعالج، ثم آخر للمريض، ثم آخر لحالة الفاتورة. قائمة من ثلاثين موعداً كانت تعني أكثر من تسعين استعلاماً عند كل تحميل للصفحة.

GraphQL تمنح فرق الواجهة حرية طلب ما تحتاجه بدقة. لكنها أيضاً تجعل من السهل بناء أنظمة يترجم فيها "ما تحتاجه بدقة" إلى شلال من رحلات ذهاب وإياب لقاعدة البيانات. إليك ما نفعله للبقاء أمام ذلك.

صمم الـ schema حول لغة المنتج لا جداول قاعدة البيانات

الـ schema عقد مع فرق الواجهة. إن طابق جداول قاعدة البيانات واحداً لواحد، سيكشف تفاصيل التنفيذ ويقاوم التغيير. إن طابق مفاهيم المنتج — ما يراه المستخدم ويفعله فعلاً — يبقى مفيداً مع تطور الـ backend.

الموعد في منتج عيادة ليس مجرد صف بـ patientId وdoctorId. إنه حدث جدولة مع طبيب مشارك وملف مريض وحالة حالية وفاتورة مرتبطة:

type Appointment {
  id: ID!
  startsAt: DateTime!
  status: AppointmentStatus!
  doctor: Doctor!
  patient: Patient!
  invoice: Invoice
  notes: String
}

عندما يتغير schema قاعدة البيانات — ربما تنتقل بيانات الطبيب لخدمة مختلفة — يبقى نوع GraphQL ثابتاً وتتغير الـ resolver فقط.

استخدم DataLoader لكل علاقة تحلها القوائم

مشكلة N+1 في GraphQL هيكلية لا تتعلق بجودة الكود. عندما تعمل resolver لـ Doctor مرة واحدة لكل Appointment في قائمة، دائماً تحصل على N+1 استعلاماً. DataLoader يحل هذا بتجميع الاستدعاءات التي تصل في نفس دورة حدث event loop.

في NestJS اجعل DataLoaders مرتبطة بنطاق الطلب حتى لا تتسرب القيم المخزنة مؤقتاً بين طلبات مستخدمين مختلفين:

@Injectable({ scope: Scope.REQUEST })
export class PatientLoader {
  loader: DataLoader<string, Patient | null>;

  constructor(private readonly prisma: PrismaService) {
    this.loader = new DataLoader(async (ids: readonly string[]) => {
      const patients = await this.prisma.patient.findMany({
        where: { id: { in: [...ids] } },
      });
      const byId = new Map(patients.map((p) => [p.id, p]));
      return ids.map((id) => byId.get(id) ?? null);
    });
  }
}

@ResolveField(() => Patient)
patient(
  @Parent() appointment: Appointment,
  @Context() ctx: { loaders: { patient: PatientLoader } },
) {
  return ctx.loaders.patient.loader.load(appointment.patientId);
}

مع DataLoader، جلب ثلاثين موعداً مع مرضاهم يكلف استعلامين: واحد للمواعيد، وواحد لجميع المرضى المشار إليهم. بدونه يكلف واحداً وثلاثين.

عندما DataLoader لا يكفي

DataLoader يجمّع حسب المعرف. إن كانت علاقتك تحتاج استعلاماً أعقد — "جلب أحدث فاتورة لكل موعد حيث status = 'unpaid'" — يتطلب التجميع نهجاً مختلفاً قليلاً: تجميع حسب معرفات المواعيد، تشغيل استعلام واحد بـ WHERE appointmentId IN (...) ثم تعيين النتائج.

اجعل الـ Mutations رفيعة — ادفع المنطق للخدمات

يجب أن تفعل resolver الـ mutation أربعة أشياء: التحقق من المدخلات، التفويض، استدعاء خدمة تطبيقية، وإرجاع النتيجة. منطق العمل لا ينتمي للـ resolvers.

@Mutation(() => AppointmentPayload)
async createAppointment(
  @Args("input") input: CreateAppointmentInput,
  @CurrentTenant() tenant: Tenant,
  @CurrentUser() user: User,
) {
  await this.authz.assertCanCreate(user, "Appointment");
  return this.appointmentService.create({ tenantId: tenant.id, input });
}

هذا النمط يهم أكثر مما يبدو. عندما يحتاج نفس المسار لاحقاً للعمل من مهمة cron أو webhook أو CLI للإدارة، الخدمة قابلة للاستخدام بالفعل. الـ resolver هي فقط طبقة نقل.

أضف حدود تعقيد وعمق الاستعلام قبل وصول العملاء الخارجيين

نقطة نهاية GraphQL غير مقيدة يمكن أن تُسأل عن بيانات متداخلة بعمق تسبب حملاً هندسياً أسياً على قاعدة البيانات. هذا أقل أهمية للمنتجات الداخلية ذات الواجهات المتحكم بها، لكنه مهم فوراً بمجرد وجود عملاء خارجيين أو API عام.

GraphQLModule.forRoot<ApolloDriverConfig>({
  driver: ApolloDriver,
  validationRules: [
    depthLimit(7),
    createComplexityLimitRule(1000, {
      onCost: (cost) => logger.debug({ cost }, "query complexity"),
    }),
  ],
}),

حدد الحدود بناءً على تعقيد العمليات الفعلية. حد منخفض جداً يكسر استعلامات مشروعة؛ حد مرتفع جداً عديم الفائدة. ابدأ متسامحاً وشدد بمجرد توفر بيانات تكلفة العمليات الفعلية.

اجعل العمليات المكلفة مرئية

متوسط زمن الاستجابة هو أقل المقاييس فائدة في API تعتمد GraphQL. استعلام رخيص واحد واستعلام مكلف يظهران معاً في المتوسط.

سجل توقيت كل resolver واسم العملية والحقول المحددة وعدد استعلامات قاعدة البيانات لكل طلب:

{
  "operation": "ListAppointments",
  "durationMs": 87,
  "dbQueryCount": 3,
  "tenantId": "t_abc123",
  "selectedFields": ["id", "startsAt", "doctor", "patient"]
}

هذا يخبرك فوراً عندما يُحل حقل جديد بطريقة تضيف حملاً على قاعدة البيانات. بدون تسجيل على مستوى الحقل، تكتشف الـ resolvers المكلفة عندما تؤثر على المستخدمين، لا عندما تصل في Pull Request.

تنظيم الوحدات: وحدة NestJS واحدة لكل مجال منتج

AppModule واحد يستورد كل شيء يعمل حتى يتجاوز الـ schema بضع مئات من الأنواع. بعد ذلك تصبح أخطاء الحقن الدائري وأوقات بدء التشغيل الطويلة مؤلمة.

جمّع الـ resolvers والخدمات والـ loaders حسب مجال المنتج — مواعيد، مرضى، فوترة، مصادقة — كل منها في وحدة NestJS خاصة مع استيرادات صريحة. البنية التحتية المشتركة (Prisma، auth، تخزين مؤقت) تعيش في وحدات موفر منفصلة تُستورد حيث تحتاج.

هذا يعكس كيفية تنظيم المنتج فعلياً، مما يسهل تسليم وحدة لمهندس جديد أو تقسيم الخدمات لاحقاً إن تطلب الحجم ذلك.

NestJS GraphQL يتوسع جيداً عندما تعكس الـ schema مفاهيم المنتج، والـ loaders تزيل أنماط الوصول N+1، والـ mutations رفيعة، والعمليات المكلفة مرئية قبل أن تفاجئك في الإنتاج.

لطبقة البيانات التي تقع تحت هذا الـ API، راجع SaaS متعدد المستأجرين مع PostgreSQL وPrisma.

الوسوم
BackendEngineering