أساسيات CompletableFuture
أساسيات CompletableFuture
قدّمت Java 8 كلاس CompletableFuture<T> كتوسعة للـ Future<T> القديم، لكنّه يمنحك ما افتقده الأصل تمامًا: القدرة على تسجيل استدعاءات راجعة (callbacks)، وتسلسل التحويلات، وبناء مسارات معالجة غير متزامنة كاملة — كل ذلك دون حجب خيط الانتظار للنتيجة.
في هذا الدرس نركّز على أسلوبين يشكّلان عمود فقرة كل مسار معالجة: supplyAsync لبدء عمل غير متزامن ينتج قيمة، وthenApply لتحويل تلك القيمة عند وصولها.
لماذا CompletableFuture وليس Future العادي؟
مع Future العادي، الطريقة الوحيدة للحصول على النتيجة هي استدعاء get()، الذي يحجب الخيط الحالي حتى تنتهي العملية الحسابية. هذا يجعل بناء المسارات مستحيلاً ويُلغي فائدة عدم التزامن.
يحلّ CompletableFuture هذه المشكلة بالسماح لك بإرفاق استدعاء راجع يُنفَّذ تلقائيًا بمجرد توفّر القيمة، محرّرًا الخيط لأداء أعمال أخرى في هذه الأثناء.
supplyAsync — بدء عملية حسابية غير متزامنة
يُرسل CompletableFuture.supplyAsync(Supplier<T>) lambda إلى مجموعة خيوط ويُعيد فورًا CompletableFuture<T>. يواصل الخيط الاستدعائي عمله؛ تعمل lambda في الخلفية.
supplyAsync المجموعة المشتركة للـ JVM وهي ForkJoinPool.commonPool(). هذه المجموعة مشتركة بين التطبيق بأكمله، لذا في كود الإنتاج الذي يجري استدعاءات I/O (شبكة، قرص) عليك تمرير executor خاص بك كوسيط ثانٍ لتجنّب حرمان المهام المرتبطة بالمعالج.
thenApply — تحويل النتيجة
يُسجّل thenApply(Function<T, U>) تحويلًا سيعمل على الخيط نفسه الذي أكمل الـ future (أو الخيط الاستدعائي إن كانت قد اكتملت بالفعل). ويُعيد CompletableFuture<U> جديدًا يحمل القيمة المُحوَّلة.
كل استدعاء لـ thenApply يُعيد CompletableFuture جديدًا. يبقى الأصلي دون تغيير. هذا الأسلوب القائم على مسار غير قابل للتعديل يجعل كل خطوة قابلة للتركيب والاختبار باستقلالية.
CompletableFuture<String> ← thenApply(String::length) ← CompletableFuture<Integer>. إذا استنتج IDE الأنواع لك، تستطيع اكتشاف أخطاء المنطق (مثل تطبيق دالة String على Integer) في وقت الترجمة.
مثال كامل من البداية إلى النهاية
فيما يلي مسار معالجة صغير لكنه واقعي: جلب معرّف مستخدم من خدمة بطيئة، ثم البحث عن بريده الإلكتروني، ثم تنسيق رسالة ترحيب — كل ذلك دون حجب أي خيط باستثناء استدعاء get() الأخير في النهاية.
thenApplyAsync — إسناد التحويل لخيط منفصل
ينفّذ thenApply استدعاءه على الخيط الذي أكمل المرحلة السابقة. إن كان التحويل نفسه مكلفًا، استخدم thenApplyAsync لإحالته إلى خيط من المجموعة بدلًا من ذلك.
Thread.sleep() أو استعلامات JDBC أو أي عملية حجب أخرى داخل استدعاء thenApply يُشغل خيط المجموعة طوال فترة الانتظار. استخدم thenCompose (يُغطّى في الدرس القادم) للخطوات التي تُعيد هي نفسها CompletableFuture، ومنح عمليات I/O دائمًا executor خاصًا بها.
استرداد النتائج: join مقابل get
يحجب كلا الأسلوبين get() وjoin() حتى تتوفّر النتيجة. الفرق في معالجة الاستثناءات: يرمي get() استثناءات محتملة (InterruptedException، ExecutionException)، بينما يلفّ join() كل شيء في CompletionException غير محتملة. داخل سلاسل lambda يكون join() أكثر عملية؛ في كود التطبيق حيث تريد معالجة الانقطاع صراحةً، فضّل get().
الخلاصة
يُرسل supplyAsync العمل إلى مجموعة خيوط ويُعيد مؤشرًا حيًا للنتيجة المستقبلية. يُسلسل thenApply تحويلًا نقيًا على ذلك المؤشر دون حجب. تسلسل عدة استدعاءات thenApply ينتج مسارًا مقروءًا وآمن الأنواع حيث كل خطوة دالة صغيرة ومحدّدة الهدف. مرّر دائمًا executor مسمى للمراحل المرتبطة بـ I/O، ولا تحجب أبدًا داخل الاستدعاء الراجع. الدرس القادم يوسّع هذه الأنماط بـ thenCompose وthenCombine ومعالجة الأخطاء.