تجربتي مع gRPC-Web في تطبيقات React
الموضوعات: gRPC · React · الواجهات
تشرح معظم أمثلة gRPC-Web كيفية إرسال طلب unary وعرض الاستجابة في مكوّن React. عند بناء تطبيق فعلي، تظهر أسئلة إضافية عن الإلغاء وانقطاع الاتصال والمهل وطريقة عرض الأخطاء.
في التطبيق الحقيقي يغيّر المستخدم المرشّح بسرعة، ويغادر الصفحة قبل وصول الرد، وتنقطع الشبكة في منتصف stream، ويعود الخادم بخطأ لا يصلح أن نعرضه كما هو. هنا تبدأ القرارات التي لم أجدها مجتمعة في الوثائق.
هذه ليست وصفة لمكتبة بعينها. هي الطريقة التي أرتّب بها طبقة الاتصال حتى لا تنتشر تفاصيل gRPC في كل مكوّن.
مراجعة إعدادات الخادم الوسيط
لا يدعم المتصفح الاتصال عبر gRPC بصيغته الأصلية مباشرة. يوجد وسيط يحوّل gRPC-Web إلى الاتصال الذي تفهمه الخدمة الخلفية. لذلك، عندما يتوقف الطلب عند pending، لا أنظر إلى React والخادم فقط. أنظر أيضًا إلى إعدادات الـ proxy: المهلة، وحجم الرسالة، والترويسات المسموح بها، وطريقة إغلاق الاتصال.
هذه نقطة مهمة خصوصًا مع البث. gRPC-Web لا يمنح المتصفح كل أشكال البث المتاحة بين خدمتين خلفيتين. إذا كان التصميم يحتاج إلى بث ثنائي الاتجاه، فيجب حسم ذلك مبكرًا بدل اكتشافه بعد بناء الواجهة.
بالنسبة إليّ، مخطط الاتصال لا يكتمل إلا إذا أجاب عن ثلاثة أسئلة:
- من يضع المهلة؟
- من يستطيع إلغاء الطلب؟
- أين يظهر الخطأ عندما يفشل الاتصال بين الـ proxy والخدمة؟
إذا لم تكن الإجابات واضحة، فالمشكلة ستظهر لاحقًا داخل واجهة تبدو كأنها تحمّل إلى ما لا نهاية.
لا أستدعي العميل المولّد من المكوّن
أستخدم العميل المولّد من ملفات proto، مع عزله عن مكوّنات الواجهة. أنشئ طبقة صغيرة بينه وبين React. تستقبل هذه الطبقة أنواعًا مفهومة داخل التطبيق، وتحوّل الرد إلى الشكل الذي تحتاجه الشاشة.
type Report = {
id: string;
state: "ready" | "running" | "failed";
};
export async function loadReport(id: string): Promise<Report> {
const request = new GetReportRequest();
request.setId(id);
const response = await reportClient.getReport(request);
return {
id: response.getId(),
state: mapReportState(response.getState()),
};
}قد تبدو هذه الطبقة غير ضرورية في البداية. بعد أول تغيير في proto، يتضح السبب: تعدّل التحويل في مكان واحد بدل تعديل getters والحقول الاختيارية داخل عشرات المكوّنات.
وهنا أضيف بيانات الاتصال أيضًا: رمز المصادقة، واللغة، ومعرّف الطلب، والمهلة. المكوّن يطلب تقريرًا؛ لا يحتاج إلى معرفة الترويسات التي يتوقعها الـ proxy.
إلغاء الطلبات التي لم تعد مطلوبة
افتح شاشة بحث، اكتب حرفًا، ثم حرفًا آخر، ثم انتقل إلى صفحة ثانية. من دون إلغاء ستبقى الطلبات القديمة تعمل، وقد يصل أبطؤ رد بعد أحدث رد ويستبدل البيانات الصحيحة.
إذا أتاح العميل إلغاء الطلب، أمرّر دالة الإلغاء من طبقة الاتصال إلى الـ hook وأستدعيها في دالة التنظيف. وأحتفظ أيضًا بمعرّف للطلب الحالي؛ لأن رسالة الإلغاء نفسها قد تتأخر.
useEffect(() => {
const call = searchReports(query, {
onData: setReports,
onError: setError,
});
return () => call.cancel();
}, [query]);تجاهل الرد القديم داخل React يمنع تحديث الشاشة، لكنه لا يوقف العمل في الشبكة والخدمة. لهذا أفضّل الإلغاء الحقيقي متى كان متاحًا، ثم أستخدم فحص معرّف الطلب كحاجز إضافي.
لكل طلب نهاية معروفة
من دون مهلة للطلب، قد تبقى الشاشة في حالة تحميل إلى أجل غير محدد. أضع قيمة افتراضية في طبقة الاتصال، ثم أغيّرها فقط للعمليات التي تحتاج فعلًا إلى وقت أطول.
const deadline = new Date(Date.now() + 10_000);
const response = await client.getReport(request, { deadline });هذه القيمة مثال وليست رقمًا مناسبًا لكل خدمة. الاستعلام السريع ليس مثل تصدير تقرير كبير. المهم أن تكون المهلة قرارًا مكتوبًا، لا سلوكًا تركناه للمكتبة أو للـ proxy.
وعندما تنتهي، يجب أن يعرف المستخدم ما حدث. DEADLINE_EXCEEDED لا ينبغي أن يبقى اسمًا تقنيًا في الشاشة؛ يتحول إلى رسالة قصيرة مع زر إعادة المحاولة إذا كانت العملية آمنة.
توحيد معالجة الأخطاء
أخطاء gRPC دقيقة، وهذا مفيد للمطور. لكنها ليست نصوص واجهة. أنشئ محوّلًا مركزيًا يعيد نوعًا يفهمه التطبيق:
type AppError =
| { kind: "offline"; retryable: true }
| { kind: "unauthorized"; retryable: false }
| { kind: "invalid-input"; retryable: false }
| { kind: "unknown"; retryable: false };أتعامل عادةً مع UNAVAILABLE وDEADLINE_EXCEEDED كحالتين يمكن إعادة المحاولة فيهما. أما UNAUTHENTICATED وPERMISSION_DENIED فلهما مسار مصادقة أو صلاحيات واضح. وINVALID_ARGUMENT غالبًا يعني أن الواجهة أرسلت شيئًا لم تتفق عليه مع الخدمة.
الفائدة ليست فقط اتساق الرسائل. يصبح لدينا مكان واحد يقرر ما يُسجّل، وما يمكن تكراره، ومتى نوقف المحاولة.
استئناف استقبال البيانات بعد انقطاع الاتصال
server streaming ليس WebSocket باسم آخر. قد ينقطع الاتصال بسبب مشكلة في الشبكة أو انتهاء مهلة في الخادم الوسيط. وإذا أعدت الاتصال من دون معرفة آخر عنصر استلمته، فقد تفقد بيانات أو تعرضها مرتين.
أفضل أن يملك الطلب resume token أو معرّفًا يمكن الاستئناف بعده:
const stream = client.watchEvents({
resumeFrom: lastSeenId,
});
stream.on("data", enqueue);
stream.on("end", reconnect);لا أستدعي setState مع كل رسالة تصل. أجمع الرسائل في buffer صغير، ثم أفرغها على دفعات. البث قد يكون سريعًا؛ الشاشة لا تحتاج إلى إعادة الرسم بالمعدل نفسه.
أعرض كذلك حالة الاتصال بوضوح. أثناء محاولة قصيرة يمكن إبقاء آخر بيانات موثوقة مع إشارة إلى أنها لا تتحدث الآن. وبعد نفاد عدد المحاولات أتوقف وأعطي المستخدم قرار إعادة الاتصال. مؤشر تحميل دائم لا يخبره بأي شيء.
لا أكرر عمليات الكتابة عشوائيًا
إعادة محاولة طلب قراءة غالبًا آمنة. إعادة إرسال أمر غيّر البيانات قد تنفذ العملية مرتين. لذلك لا تكفي معرفة رمز الخطأ؛ يجب أن تعرف طبقة الاتصال إن كانت الطريقة idempotent.
أستخدم backoff محدودًا مع قدر بسيط من jitter للقراءات. عمليات الكتابة تحتاج إلى مفتاح يمنع التكرار أو إلى قرار صريح من المستخدم. والبث يحتاج إلى نقطة استئناف، لا إلى فتح اتصال جديد ونسيان السابق.
هذه السياسة مركزية. لو تركتها لكل شاشة، فستصبح لدينا عشر طرق مختلفة للتعامل مع العطل نفسه.
ما الذي أسجله؟
أسجل اسم الطريقة، والمدة، ورمز الحالة، وعدد المحاولات، ومعرّف الطلب. لا أسجل الرسالة كاملة بصورة افتراضية. في أنظمة الأمن والاتصالات قد تحمل الرسالة بيانات لا ينبغي أن تصل إلى السجلات، كما أن الرسائل الكبيرة تجعل البحث في السجل أصعب.
الهدف أن أستطيع الإجابة سريعًا: هل فشل الطلب قبل الـ proxy، أم داخله، أم في الخدمة؟ لا أحتاج إلى نسخة من بيانات المستخدم حتى أعرف ذلك.
يسهّل هذا التنظيم استخدام gRPC-Web داخل التطبيق. تتعامل المكوّنات مع طلبات ونتائج واضحة، وتبقى تفاصيل المهلة والإلغاء وتحويل البيانات وتسجيل الأخطاء في طبقة واحدة. بذلك نستفيد من الأنواع المولّدة من دون تكرار تفاصيل الاتصال في كل شاشة.