كثير من التطبيقات التي تستعمل Dio تنتهي بنفس الـ interceptors الأربعة (المعترِضات التي تمرّ بها الطلبات والردود). واحد يعيد محاولة الطلبات الفاشلة. واحد يضيف الـ access token ويجدّده عند 401. واحد يسجّل (log). ثم يضيف أحدهم رابعًا ليمنع نفس طلب GET من الانطلاق ثلاث مرات حين تُبنى ثلاثة widgets معًا.
تُكتب هذه عادةً باليد وعلى عجل، وتميل إلى حمل نفس الأخطاء. تأخير إعادة المحاولة خطّي ويساوي صفرًا في المحاولة الأولى. إعادة المحاولة تعيد إرسال POST، فقد ينفّذ الخادم نفس الكتابة مرتين. خمسة طلبات تستقبل 401 في نفس اللحظة فيجدّد التطبيق الـ token خمس مرات. والمسجِّل يطبع ترويسة Authorization في الـ console.
dio_essentials حزمة (package) بلغة Dart خالصة فيها هذه الـ interceptors الأربعة، مكتوبة لتفادي هذه الأخطاء. اعتماديتها الوحيدة هي dio، وتعمل على VM وFlutter والويب.
ماذا تقدّم
DedupInterceptor
يدمج الطلبات المتطابقة الجارية في نفس الوقت في نداء شبكة واحد. المفتاح هو الـ method، والـ URI (دون اعتبار ترتيب معاملات الاستعلام)، وترويسات مختارة، وبصمة (hash) للـ body، ويمكنك توفير دالة بناء المفتاح الخاصة بك.
وفيه cache اختياري قصير العمر لطلبات GET وHEAD بمدة TTL قابلة للضبط. عمليات الكتابة لا تُخزَّن ولا يُعاد ردّها أبدًا، وأي كتابة تمسح الـ cache. إلغاء أحد المنتظرين لا يلغي الآخرين: النداء المشترك يُلغى فقط عندما يلغي كل المنتظرين. وكل منتظر يحصل على نسخته الخاصة من الرد، فلا يغيّر أحدهم بيانات الآخر.
RetryInterceptor
يستعمل exponential backoff حقيقيًا مع full jitter: random(0, min(cap, base * 2^n)). تضبط أنت القاعدة والحد الأقصى وعدد المحاولات. ويحترم ترويسة Retry-After بصيغة الثواني وبصيغة التاريخ (HTTP-date).
يعيد محاولة GET وHEAD وOPTIONS وPUT وDELETE فقط. أما POST وPATCH فلا تُعاد إلا إذا حمل الطلب ترويسة Idempotency-Key أو طلب ذلك صراحة. يعيد المحاولة عند أخطاء الاتصال وانتهاء المهلة (timeout) والحالات 408 و429 و502 و503 و504، ويمكن تغيير ذلك بشرط مخصّص. ولا يعيد أبدًا طلبًا ملغى، ولا طلبًا جسمه Stream أو FormData. والساعة ودالة الانتظار ومصدر العشوائية قابلة للحقن من أجل اختبارات حتمية.
AuthRefreshInterceptor
يضيف الـ token من مزوّد غير متزامن (async). عند 401 ينفّذ تجديدًا واحدًا بالضبط بينما تنتظره الطلبات المتزامنة، ثم يعيد إرسال كل واحد منها مرة واحدة بالـ token الجديد. وإن فشل التجديد، يفشل كل المنتظرين بـ SessionExpiredException، ويُستدعى onSessionExpired مرة واحدة.
يدعم مسارات مستثناة مثل '/auth/*'، وتجديدًا استباقيًا اختياريًا عندما يقترب exp في الـ JWT من هامش محدد. ولا يمكن أن يدخل في حلقة: أي طلب يُرسل داخل دالة التجديد يتجاوز الـ interceptor، حتى على نفس كائن Dio. وعندما يصل 401 قديم بعد انتهاء التجديد، يقارن الـ tokens ويعيد الإرسال دون تجديد ثانٍ.
RedactingLogInterceptor
مسجِّل مختصر: سجل واحد لكل حدث، مع مخرج (sink) قابل للاستبدال. يحجب الترويسات ومعاملات الاستعلام وحقول النماذج ومفاتيح JSON المتداخلة (authorization وcookie وset-cookie وpassword وtoken وaccess وrefresh وsecret وapi_key وغيرها). والمطابقة لا تفرّق بين حالة الأحرف ولا الفواصل.
يستبدل أي نص له شكل JWT ببصمة مثل jwt#a1b2c3، ويقصّ الأجسام الكبيرة، وينبّه إلى ردود HTML من نقاط نهاية JSON. وسجلات الأخطاء تُنظَّف كذلك، ومعها نص الاستثناء. والحجب يتم على البيانات المهيكلة قبل التنسيق، لا على كل سطر مطبوع، فلا يفلت token انقسم على سطرين.
وهناك أيضًا JwtClaims.decodeUnverified، التي تقرأ claims الـ JWT مثل exp وsub كتلميحات في جهة العميل. وهي لا تتحقق من التوقيع.
طريقة الاستعمال
أضِفها من GitHub:
dependencies: dio_essentials: git: https://github.com/AbdeldjalilChougui/dio_essentials ثم ابنِ العميل. الترتيب مهم، ومثال الـ README يعرض الترتيب الموصى به:
import 'package:dio/dio.dart'; import 'package:dio_essentials/dio_essentials.dart'; Dio buildClient(TokenStore store, void Function() goToLogin) { final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com')); dio.interceptors.addAll([ // 1. Dedup first: everything after it sees one request per merged group. DedupInterceptor(dio: dio, cacheTtl: const Duration(seconds: 3)), // 2. The logger next, so it also logs the 401s and 503s that the // interceptors below recover from. RedactingLogInterceptor(sink: print, maxBodyChars: 1500), // 3. Auth. AuthRefreshInterceptor( dio: dio, tokenProvider: store.readAccess, refreshToken: () async { // Requests made in here bypass the interceptor automatically. final res = await dio.post<Map<String, dynamic>>( '/auth/refresh', data: {'refresh': await store.readRefresh()}, ); final access = res.data!['access'] as String; await store.writeAccess(access); return access; }, onSessionExpired: goToLogin, skipPaths: const ['/auth/*'], proactiveLeeway: const Duration(seconds: 30), ), // 4. Retry. RetryInterceptor( dio: dio, maxAttempts: 4, baseDelay: const Duration(milliseconds: 300), maxDelay: const Duration(seconds: 10), ), ]); return dio; } TokenStore صنف من عندك فيه readAccess وreadRefresh وwriteAccess.
وطلب POST يصير قابلًا لإعادة المحاولة بأمان عندما تعطيه مفتاح idempotency:
await dio.post<void>( '/payments', data: {'amount': 1200}, options: Options(headers: {'Idempotency-Key': paymentId}), ); مفاتيح التحكم لكل طلب تمرّ عبر Options(extra: {...}): المفتاح DedupInterceptor.extraKey لفرض الدمج أو تخطّيه، وRetryInterceptor.retryExtraKey للسماح بإعادة المحاولة أو منعها، وAuthRefreshInterceptor.skipExtraKey لطلب بلا token وبلا تجديد.
لماذا هذا الترتيب
Dio يشغّل المعالجات بترتيب إضافة الـ interceptors. وعندما يتعافى interceptor من خطأ، لا ترى معالجات الأخطاء التي بعده ذلك الخطأ.
-
DedupInterceptorيأتي أولًا. النداء المشترك يدخل السلسلة من جديد، فأي interceptor قبله سيعمل مرتين للطالب الأول. - وضع
RedactingLogInterceptorقبل auth وretry يسجّل كل محاولة، ومنها الإخفاقات التي تم التعافي منها. والثمن أن سطر الطلب لا يُظهر الترويسات المضافة لاحقًا مثلAuthorization. وإن وضعته في الآخر ترى الترويسات النهائية، لكن النتائج النهائية فقط. -
AuthRefreshInterceptorوRetryInterceptorيعيدان الإرسال عبر نفس كائنDio، فيمرّ الطلب المُعاد بالسلسلة كلها من جديد. مرّر لهما الـDioالمرتبطين به. وإعادة الإرسال عبرDio()جديد تُضيّع الـ interceptors.
والملف example/main.dart جولة قابلة للتشغيل دون اتصال: dart run example/main.dart.
ما يجب معرفته
- الدمج ينسخ البيانات الشبيهة بـ JSON فقط. الرد المدموج يُنسخ نسخًا عميقًا للـ maps والقوائم والنصوص والأرقام والبايتات. أما الكائنات المخصّصة التي ينتجها transformer مخصّص فتبقى مشتركة. والطلبات التي جسمها
FormDataأوStream، والطلبات التي تنتظرResponseType.stream، لا تُدمج أبدًا. - إعادة المحاولة و
FormData. أجسامFormDataلا تُعاد أبدًا، حتى لو أمكن نسخ كل جزء فيها. - دالة التجديد يجب أن تحفظ الـ token الجديد، حتى يُرجعه
tokenProvider. والـ401على طلب لم يحمل token يمرّ دون تجديد. - قاعدة التجاوز تعتمد على قيمة في
Zone. العمل الذي يخرج من الـ zone، مثل طلب ينطلق من مستمع stream أُنشئ قبل ذلك، يجب تعليمه بـskipExtraKeyأو إدراجه فيskipPaths. - الحجب "أفضل جهد" للقيم التي لا يتعرّف عليها. السر الذي مفتاحه ليس ضمن الأسماء المضبوطة، وليس JWT ولا بيانات اعتماد بصيغة auth-scheme، ولم يُحجب في موضع آخر من نفس الطلب، سيُطبع. أضِف أسماءك عبر
redactKeysأوredactKeyFragments. والقيم الأقصر من 4 أحرف تُحجب حيث تظهر في البنية، لكن لا يُبحث عنها في النص الحر. -
JwtClaimsلا تتحقق من التواقيع أبدًا، وهذا مقصود. استعملها لتلميحات مثل "جدّد قريبًا"، لا كمصدر ثقة.
لماذا بنيتها
في الـ README قسم عنوانه "Migrating from ad-hoc interceptors"، وهو في الواقع قائمة أخطاء. التأخير المحسوب بـ base * (factor * attempt) خطّي، ويساوي صفرًا في أول إعادة. إعادة المحاولة عند أخطاء الاتصال "لكل الـ methods" تعيد إرسال POST أيضًا. الـ QueuedInterceptor يصفّ كل الأخطاء خلف تجديد واحد، فتتأخر أخطاء لا علاقة لها. دالة تجديد تستدعي نفس الـ Dio قد تُحدث deadlock. cache للدمج مفتاحه الـ method والمسار فقط يعيد ردّ POST. ومسجِّل يحجب سطرًا بسطر يفلت منه token انقسم على سطرين.
لا شيء من هذا يظهر في اختبار المسار السليم. يظهر تحت الضغط، أو مع اتصال ضعيف، أو حين تنتهي صلاحية token وخمسة طلبات في الطريق. أردت أن تُكتب هذه الـ interceptors الأربعة مرة واحدة، مع معالجة كل حالة من هذه، بدل إعادة كتابتها في كل مشروع.
الكود
الكود، وجدول الخيارات لكل طلب، والمثال، والاختبارات موجودة على GitHub: https://github.com/AbdeldjalilChougui/dio_essentials. الرخصة MIT.
نُشر في الأصل على abdeldjalilchougui.biz.