اذهب إلى المحتوى

رضوى العربي

الأعضاء
  • المساهمات

    114
  • تاريخ الانضمام

  • تاريخ آخر زيارة

10 متابعين

آخر الزوار

لوحة آخر الزوار معطلة ولن تظهر للأعضاء

إنجازات رضوى العربي

عضو نشيط

عضو نشيط (3/3)

76

السمعة بالموقع

  1. سنتناول خلال هذا المقال برنامجًا معقدًا بعض الشئ عما رأيناه مسبقًا، فقد كانت غالبية الأمثلة التي تعرَّضنا إليها مجرد أمثلة بسيطة هدفها توضيح تقنية برمجية أو اثنتين على الأكثر؛ أما الآن، فقد ان الوقت لتوظيف كل تلك الأفكار والتقنيات معًا ضمن برنامجٍ واحدٍ حقيقي. سنناقش أولًا البرنامج وتصميمه بصورةٍ مُبسطة، وبينما نفعل ذلك، سنتحدث سريعًا عن بعض خاصيات جافا التي لم نتمكَّن من الحديث عنها أثناء المقالات السابقة؛ إذ تتضمَّن تلك الخاصيات أمورًا يُمكِن تطبيقها على جميع البرامج وليس فقط على برامج واجهات المُستخدم الرسومية GUI. سنتحدث تحديدًا عن برنامج عرض مجموعة ماندلبرو Mandelbrot، والذي يَسمَح للمُستخدِم باكتشاف تلك المجموعة الشهيرة، إذ سنبدأ أولًا بشرح ما يعنيه ذلك. تتوفَّر نسخةٌ أقوى من هذا البرنامج؛ ونسخةٌ أخرى منه مكتوبةٌ بلغة JavaScript، وقابلةٌ للتشغيل بمتصفحات الإنترنت. مجموعة ماندلبرو Mandelbrot مجموعة ماندلبرو هي مجموعةٌ من النقاط الواقعة على سطح مستوى xy، التي تُحسَب مواضعها بواسطة عمليةٍ حسابية؛ وكل ما تحتاج إلى معرفته لكي تتمكَّن من تشغيل البرنامج هو أن تَعرِف إمكانية استخدام هذه المجموعة لصناعة مجموعةٍ من الصور الرائعة. سننتقل الآن إلى التفاصيل الحسابية: لنفترض لدينا نقطة (a,b)، وكان الإحداثي الأفقي والرأسي لتلك النقطة مكونين من أعدادٍ حقيقية، عندها يُمكِننا إذًا تطبيق العمليات التالية عليها: Let x = a Let y = b Repeat: Let newX = x*x - y*y + a Let newY = 2*x*y + b Let x = newX Let y = newY تتغير إحداثيات النقطة (x,y) أثناء تنفيذ حلقة التكرار loop بالأعلى، ويَنقلنا ذلك إلى السؤال التالي: هل تزداد قيم إحداثيات النقطة (x,y) دون أي قيد أم أنها تقتصر على منطقة نهائية ضمن المستوى؟ إذا كانت (x,y) تذهب إلى اللانهاية (أي تزداد بدون قيد)، فإن نقطة البداية (a,b) لا تنتمي إلى مجموعة ماندلبرو؛ أما إذا كانت النقطة (x,y) مقتصرةً على منطقة نهائية، فإن نقطة البداية (a,b) تنتمي إلى المجموعة. من المعروف أنه لو أصبح 'x2 + y2' أكبر من '4' ضمن أي لحظة، فإن النقطة (x,y) تذهب إلى اللانهاية، وبالتالي لو أصبح 'x2 + y2' أكبر من '4' بالحلقة المُعرَّفة بالأعلى، فيُمكِننا أن نُنهِي الحلقة، ونستنتج أن النقطة (a,b) ليست ضمن مجموعة ماندلبرو بلا شك. في المقابل، بالنسبة لنقطة (a,b) تنتمي إلى تلك المجموعة، فإن الحلقة لن تنتهي أبدًا. إذا شغَّلنا ذلك على حاسوب، فإننا بالطبع لا نريد أن نحصل على حلقة لا نهائية تَعمَل إلى الأبد، ولذلك سنضع حدًا أقصى على عدد مرات تنفيذ الحلقة، وسيُمثِّل maxIterations ذلك الحد. ألقِ نظرةً إلى ما يلي: x = a; y = b; count = 0; while ( x*x + y*y < 4.1 ) { count++; if (count > maxIterations) break; double newX = x*x - y*y + a; double newY = 2*x*y + b; x = newY; y = newY; } بعد انتهاء الحلقة، وإذا كانت قيمة count أقل من أو تُساوِي maxIterations؛ فعندها يُمكِننا أن نستنتج أن النقطة (a,b) لا تنتمي إلى مجموعة ماندلبرو؛ أما إذا كانت قيمة count أكبر من maxIterations، فقد تنتمي النقطة (a,b) إلى المجموعة أو لا؛ وفي العموم كلما كانت قيمة maxIterations أكبر، كلما زادت احتمالية انتماء النقطة (a,b) إلى المجموعة. سنُنشِئ صورةً باستخدام العملية الحسابية السابقة على النحو التالي: سنَستخدِم شبكةً مستطيلةً من البكسلات لتمثيل مستطيلٍ واقعٍ على سطح المستوى، بحيث يتوافق كل بكسل مع إحداثيات قيمها حقيقية (a,b)، إذ تُشير قيم الإحداثيات إلى مركز البكسل. سنُشغِّل حلقة التكرار بالأعلى لكل بكسل؛ فإذا تعدَّت قيمة count قيمة الحد الأقصى maxIterations، فسنُلّون البكسل باللون الأسود، مما يعني أن تلك النقطة قد تنتمي إلى مجموعة ماندلبرو؛ أما إذا لم يتعداه، فسيعتمد لون البكسل على قيمة count بعد انتهاء الحلقة، مما يَعنِي أننا سنَستخدِم ألوانًا مختلفةً للقيم المختلفة من count. كلما ازدادت قيمة count، كانت النقطة أقرب إلى مجموعة ماندلبرو؛ وبالتالي تُعطِي الألوان بعض المعلومات عن النقاط الواقعة خارج المجموعة وعن شكل المجموعة، ولكن من المهم أن تدرك أن تلك الألوان عشوائية وأن النقاط الملونة لا تنتمي إلى المجموعة. تَعرِض الصورة التالية لقطة شاشة من برنامج عرض مجموعة ماندلبرو، الذي يَستخدِم نفس تلك العملية الحسابية؛ إذ تُمثِّل المنطقة السوداء مجموعة ماندلبرو، باستثناء أن بعض النقاط السوداء قد لا تكون ضمن المجموعة فعليًا. إذا شغَّلت البرنامج فبإمكانك تكبير الصورة حول أي منطقة صغيرة ضمن سطح المستوى، وذلك بنقر زر الفأرة على الصورة مع السحب؛ إذ يؤدي ذلك إلى رسم "صندوق تكبير" مستطيل الشكل حول جزء الصورة ذاك كما هو مُوضَّح بالأعلى. وعندما تُحرِّر زر الفأرة، سيزداد حجم جزء الصورة الموجود داخل صندوق التكبير لكي يملأ الشاشة بالكامل؛ أما إذا نقرت على أي نقطة ضمن الصورة، فسيزداد حجم الصورة أيضًا عند النقطة التي نقرت عليها بمعدل تكبير يساوي الضعف. انقر على "Shift" أو اِستخدِم زر الفأرة الأيمن لكي تُصغرّ حجم الصورة. تُعدّ النقاط الموجودة عند الحدود الفاصلة لمجموعة ماندلبرو هي النقاط الأكثر تشويقًا، وفي الحقيقة، يُعَد ذلك الفاصل معقدًا تعقيدًا لا نهائيًا؛ فإذا كبَّرت الصورة إلى حدٍ بعيد، فلن يمنعك البرنامج من فعل ذلك، ولكنك ستتعدّى إمكانيات نوع البيانات double، وستبدأ بكسلات الصورة في الظهور وستصبح الصورة بلا معنى. يُمكِنك استخدام قائمة "MaxIterations" لزيادة الحد الأقصى لعدد مرات تكرار الحلقة، وتذكّر أن البكسلات السوداء قد تقع أو لا ضمن المجموعة؛ فإذا أزدت قيمة MaxIterations، فقد تجد أن بعض المناطق السوداء قد أصبحت ملونةً هي الأخرى. تُحدِّد قائمة "Palette" مجموعة الألوان المُستخدَمة، ويؤدي استخدام لوحات ألوان مختلفة إلى إنتاج صورٍ مختلفة، ولكنها مُجرّد مجموعة ألوان عشوائية فقط. وتحدِّد قائمة "PaletteLength" عدد الألوان المختلفة المُستخدَمة؛ فإذا اِستخدَمت الإعدادات الافتراضية، فإن كل قيمة ممكنة للمتغير count يُقابلها قيمة لونٍ مختلفة. قد تحصل أحيانًا على صور أفضل بكثير باستخدام عددٍ مختلف من الألوان؛ فإذا كان عدد الألوان الموجودة بلوحة الألوان المُستخدَمة أقل من قيمة maxIterations، فسيتكرَّر نفس اللون لكي يشمل جميع القيم المحتملة للمتغير count؛ أما إذا كان عددها أكبر من قيمة maxIterations، فسيُستخدَم فقط جزءٌ من الألوان المُتاحة. لذلك، إذا كانت غالبية بكسلات الصورة من خارج المجموعة مُكوَّنة من درجات مختلفة من لون واحد تقريبًا، فقللّ عدد ألوان لوحة الألوان؛ إذ يؤدي ذلك إلى اختلاف الألوان بصورةٍ أسرع مع تغيُّر قيمة count؛ أما إذا وجدتها مُكوَّنةً من ألوان عشوائية تمامًا بدون أي انسيابية بين الألوان، زِد عدد ألوان لوحة الألون. يحتوي البرنامج على قائمة "File" يُمكِن استخدامها لحفظ الصورة مثل ملف صورة بامتداد PNG؛ ويُمكِنك أيضًا أن تحفظ ملف "param" لحفظ إعدادات البرنامج التي أنتجت الصورة الحالية؛ وتستطيع لاحقًا قراءة هذا الملف إلى البرنامج باستخدام الأمر "Open". يعود اسم مجموعة ماندلبرو إلى Benoit Mandelbrot، وهو أول من لاحظ ذلك التعقيد المذهل لتلك المجموعة، إذ من الرائع الحصول على هذا التعقيد والجمال من تلك الخوارزمية البسيطة. تصميم تطبيق مجموعة ماندلبرو نجد معظم الأصناف بلغة جافا مُعرَّفةً ضمن حزم packages؛ فعلى الرغم من أننا استخدمنا بعض الحزم القياسية، مثل javafx.scene.control و java.io بكثرة، إلا أن معظم الأمثلة التي تعرَّضنا إليها كانت تَستخدِم الحزمة الافتراضية، ويَعنِي ذلك أننا لم نُصرِّح بانتمائها إلى أي حزمةٍ مسماة. وفي المقابل، عند إنجاز أي برمجة جدية، فمن الأفضل دومًا أن نُنشِئ حزمةً لاحتواء الأصناف المُستخدَمة بالبرنامج. تُوصِي مؤسسة Oracle بأن تكون أسماء الحزم مبنيةً على اسم نطاق domain name الإنترنت للمؤسسة المُنتِجة للحزمة؛ فبالنسبة لحاسوب المؤلف، فإن اسم النطاق الخاص به هو eck.hws.edu، ولا يُفترَض لأي حاسوب آخر بالعالم عمومًا أن يكون له نفس الاسم؛ ووفقًا لمؤسسة Oracle، ينبغي أن يكون اسم الحزمة في تلك الحالة هو edu.hws.eck، أي سيكون ترتيب عناصر اسم النطاق معكوسًا. علاوةً على ذلك، ينبغي أن تُسمَى الحزم الفرعية ضمن تلك الحزمة بأسماءٍ، مثل edu.hws.eck.mdbfx، وهو في الواقع الاسم الذي اختاره المؤلف لتطبيق عرض مجموعة ماندلبرو. ويَضمَن ذلك ألا يَستخدِم أي شخصٍ آخر -شرط أن يتبِع نفس نمط التسمية- نفس اسم الحزمة، وبناءً على ذلك، يُمكِن استخدام ذلك الاسم الفريد لتحديد هوية التطبيق. ناقشنا باختصار طريقة استخدام الحزم بمقال بيئات البرمجة programming environment في جافا، وكذلك أثناء شرح بعض الأمثلة البرمجية بمقال أمثلة برمجية على الشبكات في جافا: إطار عمل لتطوير الألعاب عبر الشبكة. باختصار، يُمثِل التالي كل ما ينبغي أن تعرفه بخصوص تطبيق عرض مجموعة ماندلبرو، فالبرنامج مُعرَّفٌ ضمن 7 ملفات شيفرة مصدرية تجدها بالمجلد edu/hws/eck/mdbfx الموجود داخل مجلد source بالموقع؛ أي أن الملفات موجودة بمجلدٍ اسمه mdbfx، والموجود بدوره بمجلدٍ اسمه eck، المتواجد بمجلد hws، ضمن مجلد edu. لا بُدّ أن تتبِع المجلدات اسم الحزمة بتلك الطريقة. يحتوي نفس ذلك المجلد على ملفٍ اسمه strings.properties المُستخدَم بالبرنامج والذي سنناقشه بالأسفل. ويحتوي مجلد examples على ملفات الموارد التي تَستخدِمها قائمة "Examples". ونظرًا لاعتماد البرنامج على مكتبة JavaFX، فينبغي أن تتأكّد من توفُّر المكتبة عند تصريف البرنامج أو تشغيله كما ناقشنا بمقال بيئات البرمجة (programming environment) في جافا المشار إليه في الأعلى؛ وإذا كنت تَستخدِم بيئة تطوير متكاملة Integrated Development Environment مثل Eclipse، فكل ما عليك فعله هو إضافة مجلد "edu" إلى المشروع، مع ضبطه لكي يَستخدِم مكتبة JavaFX؛ وإذا أردت استخدام سطر الأوامر، فينبغي أن يشير مجلد العمل working directory إلى المجلد المُتضمِّن لمجلد edu؛ وإذا لم تكن تَستخدِم إصدارًا قديمًا من JDK والذي تكون فيه مكتبة JavaFX مبنيةً مسبقًا، فستحتاج إلى إضافة خيارات مكتبة JavaFX إلى أمري javac و java. إذا كنت قد عرَّفت الأمرين jfxc و jfx المكافئين للأمرين javac و java مع خيارات مكتبة JavaFX، فيُمكِنك ببساطة أن تُصرِّف الشيفرة المصدرية باستخدام الأمر التالي: jfxc edu/hws/eck/mdbfx/*.java أو الأمر التالي إذا كنت تَستخدِم نظام التشغيل Windows: jfxc edu\hws\eck\mdbfx\*.java ستَجِد صنف التطبيق الرئيسي مُعرَّفًا بالصنف Main. اِستخدِم الأمر التالي لتشغيل البرنامج: jfx edu.hws.eck.mdbfx.Main يجب أن يُنفِّذ هذا الأمر بالمجلد المُتضمِّن لمجلد edu؛ وإذا كان إصدار JDK المُستخدِم يتضمَّن مكتبة JavaFX مُسبَقًا، فيُمكِنك ببساطة أن تَستخدِم الأمرين javac و java بدلًا من jfxc و jfx. يتضمَّن الملف MandelbrotCanvas.java غالبية العمليات المطلوبة لحساب صور مجموعة ماندلبرو وعرضها؛ إذ يُعدّ الصنف MandelbrotCanvas صنفًا فرعيًا من الصنف Canvas، ويُمكِنه حساب صور مجموعة ماندلبرو وعرضها كما ناقشنا بالأعلى. تعتمد الصورة الناتجة على النطاق الظاهر من قيم x و y، وعلى الحد الأقصى لعدد مرات تكرار الخوارزمية، وعلى لوحة الألوان المُستخدَمة لتلوين البكسلات خارج المجموعة. وتأتي جميع تلك المُدْخَلات من مكان آخر ضمن البرنامج، ويقتصر دور الصنف MandelbrotCanvas على حساب الصورة وعرضها بناءً على قيم المْدخَلات المُعطاة له. بالإضافة إلى تلوين بكسلات الصورة، يَستخدِم الصنف MandelbrotCanvas مصفوفةً ثنائية الأبعاد لتخزين قيمة count لكل بكسلٍ في الصورة؛ إذ تُحدِّد قيمة count لبكسل معين مع لوحة الألوان المُستخدَمة، اللون المستعمل لتلوين ذلك البكسل كما ناقشنا بالأعلى. وفي حالة تغيير لوحة الألوان المُستخدَمة، سيَستخدِم البرنامج قيمة المتغير count لكل بكسل لإعادة ضبط الألوان، دون أن يعيد حساب مجموعة ماندلبرو مرةً أخرى. في المقابل، إذا تغير نطاق قيم x و y، أو إذا تغير حجم النافذة، فسيضطّر البرنامج لإعادة حساب قيم count لجميع البكسلات. قد تستغرِق عملية حساب تلك القيم وقتًا طويلًا، ولأنه من غير المُفترَض أن نُعطِّل block واجهة المُستخدِم أثناء إجراء تلك الحسابات، سيُجرِي البرنامج تلك العمليات بخيطٍ عاملٍ worker thread منفصل كما ناقشنا بمقال البرمجة باستخدام الخيوط threads في جافا، إذ يَستخدِم البرنامج تحديدًأ خيطًا عاملًا وحيدًا لكل معالج. عند بدء عملية حساب تلك القيم، ستكون الصورة شفافة، أي أنه يُمكِنك رؤية الخلفية الرمادية للنافذة. تُقسَّم العملية إجمالًا إلى مجموعةٍ من المهام tasks، وتتكوَّن كل مهمة من عملية حساب صف واحد من الصورة. وبعد انتهاء أي مهمة، تُطبِّق الألوان الناتجة على البكسلات الموجودة بالصف الخاص بتلك المهمة. نظرًا لأنه من الممكن تعديل الحاوية فقط من خلال خيط تطبيق JavaFX، فستَستدعِي كل مهمة التابع Platform.runLater()‎ لإجراء التغييرات المطلوبة، وهذا يُمكِّن المُستخدِم من الاستمرار باستخدام القوائم وحتى الفأرة أثناء عملية حساب الصورة. يحتوي الملف MandelbrotPane.java على كامل محتوى نافذة تطبيق عرض مجموعة ماندلبرو، إذ يُعدّ الصنف MandelbrotPane صنفًا فرعيًا من الصنف BorderPane. يحتوي منتصف كائن الحاوية المنتمي للصنف BorderPane على كائنٍ من النوع MandelbrotCanvas. وفي الحقيقة، يَستخدِم البرنامج حاويةً ثانيةً شفافةً فوق الحاوية المتضمِّنة للصورة؛ فعندما يَرسِم المُستخدِم "صندوق تكبير" باستخدام الفأرة، فإن ذلك الصندوق يُرسَم فعليًا بالحاوية العلوية لكي لا يشوه الصورة (ألقِ نظرةً على مقال أمثلة عن رسوميات فاخرة باستعمال جافا). في المقابل، تحتوي المنطقة السفلية من الحاوية على عنوان من النوع Label، يَعمَل مثل شريطٍ لعرض حالة البرنامج، إذ يُستخدَم لعرض بعض المعلومات التي قد تَهِم المُستخدِم. أخيرًا، يحتوي البرنامج على شريط قوائم أعلى الحاوية. يُعرِّف الصنف Menus.java -المُشتَق من الصنف MenuBar- شريط القوائم الخاص بالتطبيق. (ألقِ نظرةً على مقال بناء تطبيقات كاملة باستعمال مكتبة جافا إف إكس JavaFX لمزيدٍ من المعلومات عن القوائم وعناصر القوائم، كما يُعرِّف الصنف Menus مجموعةً من التوابع والأصناف الفرعية المتداخلة nested subclasses لتمثيل جميع عناصر القائمة، وكذلك الأوامر التي تُمثِّلها تلك العناصر؛ إذ تشتمل تلك الأوامر على أوامرٍ مُتعلِّقة بمعالجة الملفات وتَستخدِم التقنيات التي تَعرَّضنا لها بمقالات مدخل إلى التعامل مع الملفات في جافا ومقدمة مختصرة للغة XML واستعمالها في تطبيقات جافا وأمثلة عن رسوميات فاخرة باستعمال جافا المشار إليه بالأعلى. تحتوي القوائم "MaxIterations" و "Palette" و"PaletteLength" على مجموعةٍ من الكائنات المنتمية إلى النوع RadioMenuItems. يُعرِّف البرنامج صنفًا متداخلًا داخل الصنف Menus لتمثيل كل مجموعة؛ فعلى سبيل المثال، يُعرِّف الصنف PaletteManager عناصر قائمة "Palette" على هيئة متغيرات نسخة instance variables، ويُسجِّل معالج حدث لكل عنصر، كما يُعرِّف القليل من البرامج المفيدة لمعالجة القائمة. تتشابه الأصناف الخاصة بالقوائم الثلاثة، جتى أنه من الأفضل تعريفها على أنها أصنافٌ فرعيةٌ مشتقةٌ من صنفٍ أكثر عمومية. ويحتوي البرنامج أيضًا على قائمة "Examples" التي تتضمَّن الإعدادات الخاصة ببعض العينات لقطعٍ من مجموعة ماندلبرو. يُنفِّذ الصنف MandelbrotPane كثيرًا من العمل الذي يتطلّبه البرنامج؛ فهو يُهيئ معالجات لأحداث الفأرة MousePressed و MouseDragged و MouseReleased بالحاوية العلوية، ليُمكِّن المُستخدِم من تكبير الصورة وتصغيرها؛ كما يُهيئ معالجًا للحدث MouseMoved، الذي يُحدِّث شريط الحالة ويجعله يَعرِض إحداثيات النقطة المقابلة للمكان الحالي لمؤشر الفأرة على الصورة. يُولَّد الحدث MouseMoved عندما يُحرِّك المُستخدِم مؤشر الفأرة دون أن يضغط باستمرار على زرها. ويُستخدم كذلك الحدث MouseExited لإعادة ضبط شريط الحالة إلى كلمة "Idle" عندما يقع مؤشر الفأرة خارج الحاوية. بالإضافة إلى ما سبق، يُنفِّذ البرنامج أوامر قوائم أخرى كثيرة باستدعاء توابع مُعرَّفةٍ بالصنف MandebrotPane. ويحتوي الصنف Menus على متغير نسخة اسمه owner، يشير إلى الحاوية -من النوع MandelbrotPane- المُتضمِّنة لشريط القوائم، وبالتالي يُمكِنه استخدام ذلك المتغير لاستدعاء أي توابع مُعرَّفة بالصنف MandelbrotPane؛ إذ يضبُط التابع setLimits()‎ مثلًا، نطاق قيم x و y الظاهرة بالصورة؛ كما أن هناك توابعٌ أخرى لضبط كُلٍ من لوحة الألوان المُستخدَمة وعدد الألوان الموجودة بلوحة الألوان، والحد الأقصى لعدد مرات تكرار الخوارزمية. بمجرد تغيُّر أي من تلك الخاصيات، لا بُدّ من تعديل الصورة المعروضة لمجموعة ماندلبرو؛ فعلى سبيل المثال، عندما تتغير لوحة الألوان المُستخدَمة أو عدد الألوان الموجودة باللوحة، يَحسِب الصنف MandelbrotPane لوحةً جديدةً من الألوان ويَستدعِي تابعًا مُعرَّفًا بالصنف MandelbrotCanvas ليُبلِّغه بأن عليه استخدام تلك اللوحة الجديدة. وفي المقابل، عندما يتغير الحد الأقصى لعدد مرات تكرار الخوارزمية، تكون إعادة حساب الصورة بالكامل ضرورية، ولهذا يَستدعِي الصنف MandelbrotPane التابع startJob()‎ المُعرَّف بالصنف MandelbrotCanvas ليُبلِّغه بأن عليه أن يبدأ وظيفةً جديدة، ويتولى الصنف MandelbrotCanvas كل العمل اللازم لتهيئة تلك الوظيفة وإدارتها. يُمرَّر كائن الصنف MandelbrotPane المُستخدَم بالبرنامج مثل معاملٍ إلى باني الصنف Menus، ويُخزِّن بدوره كائن الصنف Menus المُمثِّل للقوائم نسخةً من ذلك الكائن بهيئة متغير نسخة، اسمه owner. في الواقع، يُعالِج الصنفان MandelbrotPane و MandelbrotCanvas غالبية أوامر القوائم، ولكي يتمكَّن الكائن المُمثِّل للقوائم من تنفيذ تلك الأوامر، فإنه يحتاج إلى مرجع reference إلى كائن الصنف MandelbrotPane. وبالمثل من الصنف MandelbrotCanvas، يُعرِّف كائن الصنف MandelbrotPane التابع getDisplay()‎ الذي يعيد مرجعًا إلى الحاوية التي يحتويها، وبالتالي يستطيع الكائن المُمثِّل للقوائم الحصول على مرجعٍ إلى الحاوية باستدعاء owner.getDisplay()‎. كنا نضع شيفرة البرنامج بالكامل بالأمثلة السابقة من هذه السلسلة بملفٍ واحدٍ كبير، وبالتالي كانت جميع الكائنات متاحةً لكل أجزاء الشيفرة مباشرةً. وفي المقابل، عند تقسيم البرنامج إلى مجموعة من الملفات، لا يكون الوصول إلى الكائنات الضرورية بهذه السهولة. تُعدّ الأصناف MandelbrotPane و MandelbrotCanvas و Menus أكثر الأصناف أهميةً بتطبيق عرض مجموعة ماندلبرو؛ إذ يُعرِّف الصنف Main.java الصنف الفرعي المُشتَق من الصنف Application، والذي ينبغي تشغيله عند تنفيذ البرنامج؛ ويضع تابعه start()‎ كائنًا من النوع MandelbrotPane داخل المرحلة stage الرئيسية للبرنامج. يحتوي البرنامج على ثلاثة أصناف أخرى، إذ يُعرِّف الصنفان SetImageSizeDialog.java و SetLimitsDialog.java صناديق نوافذ مخصَّصة، والتي لن نناقشها هنا أكثر من ذلك؛ أما الصنف الأخير، فهو I18n، والذي سنناقشه بالأسفل. أظهرت هذه المناقشة القصيرة لتصميم تطبيق عرض مجموعة ماندلبرو أنه يَستخدِم تشكيلةً واسعةً من التقنيات التي تعرَّضنا لها مُسبقًا خلال مقالات هذه السلسلة، وسنفحص بالجزء المُتبقِي من هذا المقال القليل من الخاصيات الجديدة المُستخدَمة ضمن البرنامج. الأحداث ومستمعي الأحداث والارتباطات تعاملنا مع الأحداث events ومستمعي الأحداث بكثرة، وكذلك مع ارتباط binding الخاصيات القابلة للمراقبة observable ببعض الأمثلة، وسيكون من الرائع لو رأينا طريقة استخدام تلك التقنيات ضمن تطبيق عرض مجموعة ماندلبرو، إذ سنَستخدِم مجموعةً من الأصناف. لنبدأ الآن من الحقيقة التالية: لا يَعرِف الصنف MandelbrotCanvas أي شيء عن الصنف Menus مع أن شريط القوائم يحتوي على عناصر يبدو وكأنها تَعرِف ما يحدث بصنف الحاوية MandelbrotCanvas. بالتحديد، تُعطَّل بعض عناصر القائمة عندما تكون عملية حساب الصورة قيد التنفيذ. بما أن الحاوية لا تستدعِي أي توابع أو تَستخدِم أيًا من متغيرات صنف القوائم Menus، فكيف تمكَّنت القوائم من معرفة ما إذا كانت هناك عمليةً حسابيةً قيد التنفيذ بالحاوية؟ الإجابة بالطبع هي من خلال استخدام الأحداث أو على نحوٍ أكثر دقة، وذلك من خلال استخدام الارتباط (ألقِ نظرةً على مقال البواني وتهيئة الكائنات Object Initialization في جافا). يحتوي الصنف MandelbrotCanvas على خاصية قابلة للمراقبة من النوع boolean اسمها working، إذ تحتوي تلك الخاصية على القيمة true عندما يكون هناك عملية معالجة قيد التنفيذ. وينبغي أن تكون عناصر القائمة مُعطَّلةً عندما تكون قيمة تلك الخاصية مساويةً للقيمة true، وهو ما يُمكِننا إجراؤه بسطر شيفرةٍ واحد يربُط خاصية عنصر قائمة disableProperty بخاصية الحاوية workingProperty. على سبيل المثال، يُمكِننا تطبيق ذلك على عنصر القائمة "saveImage" بكتابة ما يَلي داخل باني الصنف Menus: saveImage.disableProperty().bind(owner.getDisplay().workingProperty()); إذ يشير owner هنا إلى كائن الصنف MandelbrotPane؛ بينما تشير القيمة المُعادة من التابع owner.getDisplay()‎ إلى كائن الصنف MandelbrotCanvas الموجود به. وبالمثل، يُعيد عنصر القائمة "Restore Previous Limits" ضبط نطاق قيم x و y الظاهرة إلى قيمها السابقة قبل آخر تحديث؛ إذ يَستخدِم الصنف Menus متغير النسخة previousLimits من النوع double[]‎ ليتذكر نطاق القيم السابق، ولكن السؤال هو: كيف يحصل على تلك المعلومات؟ عندما يُكبّر المُستخدِم الصورة أو يُصغرّها، يحدث ذلك التغيير بالصنف MandelbrotPane؛ بالتالي لا بُدّ إذًا من وجود طريقة تُمكِّن القوائم من ملاحظة ذلك التغيير، ويكْمُن الحل طبعًا في استخدام خاصيةٍ قابلةٍ للمراقبة، وتكون تلك الخاصية من النوع ObjectProperty<double[]>‎ في هذه الحالة. يُضيف باني الصنف Menus مستمع حدث من النوع ChangeListener إلى الخاصية limitsProperty المُعرَّفة بالصنف MandelbrotPane على النحو التالي: owner.limitsProperty().addListener( (o,oldVal,newVal) -> { // خزِّن القيمة القديمة للمتغير‫ limitsProperty لاستخدامها بالأمر "Restore Previous Limits" previousLimits = oldVal; undoChangeOfLimits.setDisable( previousLimits == null ); }); نظرًا لأننا نَستخدِم الأحداث هنا للتواصل، فإن الصنفين MandelbrotCanvas و MandelbrotPane خفيفا الترابط loosely coupled مع الصنف Menus. في الحقيقة، يُمكِننا استخدامهما دون أي تعديل ببرامج أخرى لا تحتوي على نفس الصنف Menus من الأساس؛ وبدلًا من استخدام الأحداث والارتباط، كان من الممكن جعل صنفي الحاوية والعرض يَستدعِيان توابعًا، مثل limitsChanged()‎ و computationStarted()‎ مُعرَّفين بالصنف Menus. يكون الترابط بين الأصناف في تلك الحالة قويًا strong coupling، وبالتالي سيضطّر أي مبرمج يرغب باستخدام الصنف MandelbrotCanvas إلى استخدام الصنف Menus أيضًا، أو إلى تعديل الصنف MandelbrotCanvas كي لا يُشير إلى الصنف Menus. لا يُمكِننا طبعًا معالجة جميع المشاكل باستخدام الأحداث، كما أنه ليس من الضروري أن يكون أي ترابط قوي شيئًا سيئًا، إذ يشير الصنف MandelbrotPane مثلًا إلى الصنف MandelbrotCanvas مباشرةً ولا يُمكِننا استخدامه بدونه، ولكن بما أن الغرض من كائن الصنف MandelbrotPane هو حمل كائنٍ آخر من الصنف MandelbrotCanvas، فلا يُمثِّل هذا الترابط مشكلةً هنا. وفي المقابل، يُمكِننا استخدام الصنف MandelbrotCanvas على نحوٍ مستقل عن الصنف MandelbrotPane. يُوظِّف الصنف MandelbrotPane الأحداث لغرضٍ آخر؛ إذ تقع حاوية الصورة والحاوية الشفافة ضمن الكائن displayHolder من النوع StackPane، وعندما يُغيّر المُستخدِم حجم النافذة، سيتغير حجم الكائن displayHolder ليتناسب مع الحجم الجديد، وينبغي عندها أن يُضبَط حجم الحاوية لكي يتناسب مع حجم العرض الجديد؛ أي لا بُدّ من بدء عملية المعالجة لحساب صورةٍ جديدة. ولذلك، يُهيئ الصنف MandelbrotPane مستمعين إلى الخاصيات height و width المُعرَّفة بالكائن displayHolder؛ وذلك حتى يتمكَّن من الاستجابة للتغييرات بالحجم. ولكن، عندما يُغيّر المُستخدِم حجم النافذة ديناميكيًا، قد يتغير حجم displayHolder عدة مرات بكل ثانية. ونظرًا لأن بدء عملية حساب صورة جديدة يتطلَّب كثيرًا من الوقت، فإننا بالتأكيد لا نرغب في فعل ذلك عدة مراتٍ بالثانية الواحدة. في الواقع، سيبدأ البرنامج عملية حساب صورة جديدة فقط بعد مرور حوالي ثلث ثانية من توقُّف التغيير بحجم النافذة؛ وبالتالي إذا جرَّبت تغيير حجم نافذة البرنامج، فستلاحظ أن الحاوية لا تُغيِّر حجمها تلقائيًا بتُغيُّر حجم النافذة، ويَعرِض البرنامج نفس الصورة طالما كان الحجم هو نفسه. تُوضِح الشيفرة التالية طريقة فعل ذلك. يُهيئ البرنامج مستمعي أحداث تغيُّر الحجم displayHolder، ليستدعوا التابع startDelayedJob()‎ على النحو التالي: displayHolder.widthProperty().addListener( e -> startDelayedJob(300,true) ); displayHolder.heightProperty().addListener( e -> startDelayedJob(300,true) ); إذ يُمثِّل المعامل الأول للتابع startDelayedJob()‎ الزمن بوحدة الميلي ثانية، الذي لا بُدّ من انتظاره قبل إعادة ضبط حجم الحاوية وبدء عملية حساب جديدة؛ بينما يشير المعامل الثاني إلى أن ضبط حجم الحاوية ليس ضروريًا قبل بدء عملية المعالجة. يَستخدِم البرنامج كائنًا من النوع Timer المُعرَّف بحزمة java.util لكي يتمكَّن من تأجيل تنفيذ العملية؛ إذ يُمكِننا ببساطة أن نُمرِّر كائنًا من النوع TimerTask يُمثِّل مهمةً مؤجَلّةً إلى كائنٍ من النوع Timer، وذلك لكي تُنفَّذ المهمة بعد زمنٍ معين؛ كما يُمكِننا أيضًا إلغاء المهمة إذا لم يكن ذلك الزمن قد مرّ بعد، إذ يَستخدمِ البرنامج التابع startDelayedJob()‎ لإضافة مهمة تغيير حجم الحاوية إلى المؤقت، لتُنفَّذ بعد 300 ميلي ثانية. وفي حالة استدعاء التابع startDelayedJob()‎ مرةً أخرى قبل مرور 300 ميلي ثانية، فستُلغَى المهمة السابقة تلقائيًا وتُضاف المهمة الجديدة بدلًا منها إلى المؤقت. وبذلك، تكون مُحصلة ما سبق هو عدم تنفيذ أي مهمة إلى أن تَمرّ 300 ميلي ثانية دون أي استدعاء جديدٍ للتابع ()startDelayedJob. يتيح البرنامج خيار ضبط الصورة لتكون ثابتة الحجم؛ إذ لا ينبغي تلك الحالة أن يتغير حجم displayHolder نهائيًا. وبناءً على ذلك، قد تكون الصورة صغيرةً، ولا تتمكَّن من ملئ النافذة بالكامل، وستَظهَر عندها أجزاءٌ من الخلفية الرمادية الموجودة وراءها. في المقابل، قد تكون الصورة كبيرةً جدًا على النافذة، وفي تلك الحالة، ينبغي أن تظهر أشرطة تمرير يُمكِن اِستخدامها للمرور عبر كامل الصورة؛ ويُنفِّذ البرنامج ذلك باستخدَام الصنف ScrollPane الذي يُمثِّل حاويةً تحتوي على مكوِّن واجهة معين، ويُوفِّر أشرطة تمرير إذا اقتضت الضرورة؛ أما عندما يكون حجم الصورة ثابتًا، فسيُحذَف displayHolder من الصنف MandelbrotPane، ويُوضَع بكائن الصنف ScrollPane المُمثِّل للحاوية، ثم يُوضَع كائن الحاوية ذلك بمنتصف كائن الصنف MandelbrotPane. المزيد عن واجهات المستخدم الرسومية أخيرًا، سنذكر هنا بعض التفاصيل المُتعلّقة ببرمجة واجهات المُستخدِم الرسومية التي لم نتمكَّن من عرضها بالمقالات السابقة. ذكرنا من قبل أن ملفات الموارد تُمثِّل جزءًا من البرنامج ولكنها لا تتضمَّن أي شيفرة، وقد رأينا على سبيل المثال في مقال التعرف على بعض أصناف مكتبة جافا إف إكس JavaFX البسيطة مدى سهولة اِستخدَام الصور مثل ملفات موارد مع الصنف Image؛ ولكن يُمكِننا عمومًا استخدام أي نوعٍ من البيانات مثل ملفات موارد. يحتوي تطبيق عرض مجموعة ماندلبرو مثلًا، على قائمة "Examples"؛ وعندما يختار المُستخدِم أمرًا من تلك القائمة، تُحمَّل الإعدادات الخاصة بعرضٍ معيَّن لمجموعة ماندلبرو إلى البرنامج؛ وتكون هذه الإعدادات مُخزَّنةً في الواقع بهيئة ملفات موارد بصيغة XML. كيف يَصِل البرنامج إلى تلك الملفات؟ لسوء الحظ، ليس الأمر بنفس سهولة استخدام صورة على أنها ملف مورد لإنشاء كائنٍ من النوع Image. تُخزَّن الموارد بملفات تقع إلى جانب ملفات الأصناف المُصرَّفة الخاصة بالبرنامج؛ إذ يُحدِّد بلغة جافا كائنٌ من النوع ClassLoader -ويُعرَف باسم مُحمِّل أصناف class loader- مكان ملفات الأصناف ويُحمِّلها للبرنامج عند الحاجة. يملُك مُحمِّل الأصناف قائمةً بالمسارات التي ينبغي عليه البحث فيها عن ملفات الأصناف؛ إذ تُعرَف تلك القائمة باسم مسارات الأصناف class path، والتي تتضمَّن موضع تخزين أصناف جافا القياسية، كما تتضمَّن المجلد الحالي. إذا كان البرنامج مُخزَّنًا داخل ملف jar، فسيكون ذلك الملف أيضًا ضمن مسارات الأصناف. وبالإضافة إلى ملفات الأصناف، تستطيع كائنات الصنف ClassLoader العثور على ملفات الموارد الواقعة بمسارات الأصناف أو بمجلداتٍ فرعية داخل مسارات الأصناف. علينا أولًا أن نحصل على كائنٍ من النوع ClassLoader لكي نتمكَّن من استخدام ملفات الموارد؛ إذ يمكننا باستخدام ذلك الكائن أن نُحدِّد موضع ملف موردٍ معين. في العموم، يحتوي أي كائن على تابع النسخة getClass()‎، الذي يعيد كائنًا يُمثِّل الصنف الذي ينتمي إليه الكائن؛ ويحتوي الكائن المُمثِّل للصنف بدوره على التابع getClassLoader()‎، الذي يعيد الكائن -من النوع ClassLoader- الذي حمَّل الصنف المَعنِي. وبالتالي، يُمكِننا كتابة ما يلي بأي تابع نسخة لأي كائن لكي نحصل على مُحمِّل الأصناف الذي نريده. ClassLoader classLoader = getClass().getClassLoader(); يُمكِننا بدلًا من ذلك استخدام ClassName.class، إذ يشير ClassName إلى اسم الصنف الذي نريده، لكي نحصل على مرجع reference إلى الكائن المُمثِّل لذلك الصنف. على سبيل المثال، كان بإمكاننا استخدام Menus.class.getClassLoader()‎ بتطبيق عرض مجموعة ماندلبرو لكي نسترجع مُحمِّل الأصناف. بمجرد حصولنا على مُحمِّل الأصناف، يُمكِننا اِستخدَامه للعثور على أي ملف مورد؛ إذ يعيد مُحمِّل الأصناف مُحدِّد الموارد الموحَّد URL الخاص بالملف، وهو ما يُمكِّننا من قراءة البيانات الموجودة بالملف. وكما هو الحال مع ملفات الصور، يحتاج مُحمِّل الأصناف إلى مسار الملف لكي يتمكَّن من إيجاده؛ إذ يتضمَّن المسار اسم الملف، بالإضافة إلى أي مجلدات ينبغي التنقُل عبرها للوصول إلى الملف. تقع ملفات الموارد المُتضمِّنة للأمثلة بالنسبة لتطبيق عرض مجموعة ماندلبرو داخل سلسلة المجلدات edu/hws/eck/mdbfx/examples، وبالتالي يكون مسار أحد تلك الملفات "settings1.mdb" هو edu/hws/eck/mdbfx/examples/settings1.mdb. يُعيد الأمر التالي مُحدِّد الموارد المُوحد الخاص بذلك الملف: URL resourceURL = classLoader.getResource("edu/hws/eck/mdbfx/examples/settings1.mdb"); والآن، بعد أن حصلنا على مُحدِّد الموارد الموحد الخاص بالملف، يُمكِننا ببساطة استخدام كائن مجرى من النوع InputStream لفتح الملف وقراءة بياناته على النحو التالي: InputStream stream = resourceURL.openStream(); وهذا بالضبط هو ما يفعله تطبيق عرض مجموعة ماندلبرو لكي يُنفِّذ قائمة "Examples". إذًا، باستخدامنا لمجرى دخل، يُمكِننا قراءة أي نوعٍ من البيانات الموجودة بملف مورد، وأن نفعل بها أي شيء نريده، ولكن تذكّر أن جافا تُوفِّر أساليبًا أفضل لتحميل بعض أنواع البيانات، مثل الصور إلى البرنامج. سنناقش الآن موضوعًا آخر عن استخدام المُسرِّعات accelerators لعناصر القائمة؛ إذ أن المُسرِّع ببساطة هو مفتاح، أو عدة مفاتيح بلوحة المفاتيح يُمكِن اِستخدَامها لاستدعاء عنصر قائمة معين بدلًا من اختيار عنصر القائمة عن طريق الفأرة. يشيع استخدام المُسرِّع "Control-S" مثلًا لحفظ ملف؛ كما يُستخدَم الصنف KeyCombination المُعرَّف بحزمة javafx.scene.input لتمثيل اتحاد مجموعةٍ من المفاتيح المُمكِن اِستخدَامها مثل مُسرِّع؛ ويُمكِننا إنشاء كائنٍ من هذا الصنف من سلسلةٍ نصية، مثل "ctrl+S". ينبغي أن تحتوي السلسلة النصية على مجموعة عناصرٍ تفصل بينها إشارة الجمع، بحيث يُمثِّل كل عنصر منها -باستثناء الأخير- مفتاح مُعدِّل، مثل "ctrl"، أو "alt"، أو "meta"، أو "shift"، أو "shortcut"؛ ويُمكِن كتابة تلك المفاتيح باستخدام حروفٍ كبيرة أو صغيرة. يُمثِّل المفتاح "shortcut" استثناءً، إذ يُكافئ المفتاح "meta" بنظام التشغيل Mac؛ بينما يُكافئ المفتاح "ctrl" بأنظمة Linux و Windows، وبالتالي نحصل على المُعدِّل المناسب لأوامر القائمة بحسب النظام المُشغَّل عليه البرنامج. في المقابل، لا بُدّ أن يكون العنصر الأخير بالسلسلة النصية المُمثِّلة لاتحاد مجموعة المفاتيح من نوع التعداد KeyCode؛ إذ يُمثِّل ذلك النوع في العموم حرفًا أبجديًا مكتوبًا بالحالة الكبيرة، ويُمثِّل مفتاح ذلك الحرف، ولكنه قد يكون أيضًا مفتاح دالة مثل "F9" (لا يَعمَل جميعها بالمناسبة). على سبيل المثال، تُمثِّل السلسلة النصية "ctrl+shift+N" الضغط باستمرار على مفتاحي "control" و "shift" مع الضغط على مفتاح "N"؛ بينما تُمثِّل السلسلة النصية "shortcut+S" الضغط باستمرار على المُعدِّل المناسب للحاسوب الذي يَعمَل عليه البرنامج مع الضغط على مفتاح "S". نستطيع تمرير تلك السلاسل النصية إلى التابع الساكن KeyCombination.valueOf()‎ لنُنشِئ منها كائنًا من النوع KeyCombination، وذلك لنَستخدِمه لتهيئة مُسرِّعٍ لأي عنصرٍ من عناصر القائمة، إذ تضيف الشيفرة التالية مثلًا مُسرِّعًا لعنصر القائمة "Save Image" الموجود بتطبيق عرض مجموعة ماندلبرو: saveImage.setAccelerator( KeyCombination.valueOf("shortcut+shift+S") ); يُمكِننا استخدام المُسرِّعات مع أي نوع من أنواع عناصر القائمة، بما في ذلك RadioMenuItem و CheckMenuItem. وفي جميع الحالات، عندما ينقر المُستخدِم على مجموعة المفاتيح المُمثِّلة لمُسرِّع معين، ينبغي أن يكون لذلك نفس تأثير النقر بالفأرة على عنصر القائمة المقابل. يكون المُسرِّع الخاص بعنصر قائمة معين مكتوبًا عادةً إلى جانب نص عنصر القائمة؛ وبالنسبة لتطبيق عرض مجموعة ماندلبرو، تملُك جميع الأوامر الموجودة بقائمتي "File" و "Control" مُسرِّعات خاصة بها. التدويل Internationalization سنناقش خلال ما هو متبقي من هذا المقال موضوعين يُمكِن تطبيقهما على جميع البرامج وليس على برامج واجهات المُستخدِم الرسومية فقط؛ إذ لا نُطبِّقهما عادةً بالبرامج الصغيرة، ولكنهما مهمان جدًا للتطبيقات الضخمة. يُقصَد بالتدويل كتابة البرنامج كتابةً يَسهُل معها تهيئته ليَعمَل بمختلف أنحاء العالم. تُستخدَم كلمة "I18n" عادةً للإشارة إلى التدويل، إذ يمثّل "18" عدد الأحرف بين الحرف الأول "I" والحرف الأخير "n" من كلمة "Internationalization". في المقابل، يُطلَق على مهمة تهيئة البرنامج ليَعمَل بمنطقة معينة اسم التوطين localization؛ في حين يُطلَق اسم المحليات locales على تلك المناطق. تختلف المحليات عن بعضها بجوانب كثيرة، مثل نوع العملة المُستخدَمة، والصيغة المُستخدَمة لكتابة الأعداد والتواريخ، ولكن الاختلاف الأبرز والأكثر وضوحًا هو اللغة. سنناقش هنا طريقة كتابة البرامج لنتمكَّن من ترجمتها إلى لغاتٍ أخرى بسهولة. تتمحور الفكرة الأساسية في عدم كتابة السلاسل النصية التي تَظهَر للمُستخدِم ضمن الشيفرة المصدرية للبرنامج؛ لأننا لو فعلنا ذلك، فسنحتاج إلى مترجم قادر على البحث داخل الشيفرة بأكملها، ويَستبدِل كل سلسلةٍ نصية بترجمتها، وسنضطّر بعدها إلى إعادة تصريف البرنامج. في المقابل، إذا أردنا كتابة برنامج يدعم خاصية التدويل، فلا بُدّ أن نُخزِّن كل السلاسل النصية معًا ضمن ملفٍ واحد أو أكثر على نحوٍ منفصلٍ تمامًا عن ملفات الشيفرة المكتوبة بلغة جافا، وبالتالي نستطيع أن نعثر عليهم بسهولة ونترجمهم. ونظرًا لأننا لم نُعدِّل ملفات الشيفرة، فإننا لا نحتاج حتى إلى إعادة تصريف البرنامج. والآن لكي نُنفِّذ تلك الفكرة، علينا أن نُخزِّن السلاسل النصية داخل ملف خاصيات properties file واحدٍ أو أكثر؛ وهو ملفٌ بسيط يحتوي على قائمة أزواج مفتاح / قيمة، ولأن الغرض منها هنا هو الترجمة، فستُشير القيم إلى السلاسل النصية المعروضة للمُستخدِم، وهي ببساطة السلاسل النصية التي ينبغي أن نترجمها؛ أما المفاتيح فهي أيضًا سلاسلٌ نصية، ولكن لا حاجة لترجمتها، لأنها لن تَظهَر أبدًا للمُستخدِم. نظرًا لأننا لن نُعدِّل المفاتيح، يُمكِننا استخدامها ضمن الشيفرة المصدرية. وينبغي عمومًا أن يُقابِل كلُّ سلسلةٍ نصيةٍ مفتاحًا فريدًا يُعرِّف هويتها، وبناءً على ذلك، يستطيع البرنامج استخدام المفاتيح للعثور على ما يقابلها من سلاسل نصية من ملف الخاصيات؛ أي يحتاج البرنامج إلى معرفة المفاتيح فقط، بينما يرى المُستخدِم القيم المقابلة لتلك المفاتيح. وعند ترجمة ملف الخاصيات، سيتمكَّن المُستخدِم من رؤية قيمٍ مختلفة لنفس المفاتيح. تُكتَب أزواج مفتاح / قيمة بملفات الخاصيات على النحو التالي: key.string=value string لا ينبغي أن تحتوي السلسلة النصية المُمثِّلة للمفتاح قبل إشارة التساوي على أيّ فراغات، إذ تُستخدَم عادةً النقاط للفصل بين الكلمات المُكوِّنة لها. في المقابل، بإمكان السلسلة النصية المُمثِّلة للقيمة أن تحتوي على مسافات أو أيّ محارف أخرى. إذا انتهى السطر بمحرف "\"، ستستمر القيمة إلى السطر التالي، وتُهمَل الفراغات الموجودة ببداية ذلك السطر في تلك الحالة. لسوء الحظ، يمكن أن يحتوي ملف الخاصيات على محارف من مجموعة محارف ASCII فقط؛ إذ تدعم تلك المجموعة الأحرف الأبجدية الإنجليزية فقط. ومع ذلك، بإمكان القيمة أن تتضمَّن محارف UNICODE عشوائية، مما يَعنِي ضرورة تشفير المحارف من خارج مجموعة محارف ASCII. يتضمَّن JDK البرنامج native2ascii، الذي يَستطيع تحويل الملفات التي تَستخدِم محارف من خارج مجموعة محارف ASCII إلى ملف خاصيات بصيغةٍ مناسبة. لنفترض أننا نريد عرض سلسلة نصية للمُستخدِم، مثل اسم أمر ضمن قائمة ببرنامج معين. عندها، سيحتوي إذًا ملف الخاصيات على زوج مفتاح/قيمة على النحو التالي: menu.saveimage=Save PNG Image... إذ أن "Save PNG Image…‎" هي السلسلة النصية التي ينبغي أن تَظهَر بالقائمة. والآن، يستطيع البرنامج استخدام المفتاح "menu.saveimage" للعثور على قيمة المفتاح، ومن ثُمَّ يَستخدِمها مثل نص عنصر القائمة. تُجرَى عملية العثور تلك بواسطة الصنف ResourceBundle، إذ يمكنه استرجاع ملفات الخاصيات واستخدامها. قد تحتوي السلسلة النصية المعروضة للمُستخدِم -في بعض الأحيان- على سلاسل نصية فرعية لا يُمكِن تحديدها قبل تشغيل البرنامج، مثل اسم ملف معين؛ فقد يرغب البرنامج بإبلاغ المُستخدِم مثلًابالرسالة التالية "Sorry, the file, filename, cannot be loaded"، علمًا أن filename هو اسم ملفٍ اختاره المُستخدِم أثناء تشغيل البرنامج. لمعالجة تلك الحالة، بإمكان القيم -بملفات الخاصيات- أن تتضمَّن عنصرًا زائفًا placeholder؛ إذ يُستبدَل ذلك العنصر بسلسلةٍ نصيةٍ يُحدِّدها البرنامج بعد تشغيله، ويُكْتَب ذلك العنصر الزائف على النحو التالي "{0}"، أو "{1}"، أو "{2}". بالنسبة لمثال خطأ الملف، قد يحتوي ملف الخاصيات على القيمة التالية: error.cantLoad=Sorry, the file, {0}, cannot be loaded يسترجِع البرنامج القيمة المقابلة للمفتاح error.cantLoad، ثم يستبدل اسم الملف الفعلي بالعنصر الزائف "{0}". قد يختلف ترتيب الكلمات عند ترجمة السلسلة النصية، ولكن نظرًا لأننا نَستخدِم عنصرًا زائفًا لتمثيل اسم الملف، سيتمكَّن المترجم من وضع اسم الملف بالمكان النحوي الصحيح بالنسبة للغة المُستخدَمة. في الواقع، لا يُعالِج الصنف ResourceBundle عملية الاستبدال تلك، وإنما يتولَّى الصنف MessageFormat تلك المهمة. يَستخدِم تطبيق عرض مجموعة ماندلبرو ملف خاصيات، اسمه strings.properties؛ إذ لا بُدّ أن ينتهي اسم أي ملف خاصيات بكلمة ".properties". يقرأ البرنامج أي سلسلة نصية تراها عند تشغيل التطبيق من ذلك الملف. بَرمَج الكاتب الصنف I18n.java لقراءة قيم المفاتيح، ويحتوي ذلك الصنف على التابع الساكن static التالي: public static tr( String key, Object... args ) يُعالِج التابع السابق العملية بالكامل؛ إذ يَستقبِل التابع المعامل key الذي يُمثِّل المفتاح الذي ينبغي أن يبحث عنه التابع ضمن ملف الخاصيات strings.properties؛ بينما تُمثِل المعاملات الإضافية القيم التي ينبغي أن تحلّ محل العناصر المزيفة -إن وجدت- بالقيمة المقابلة للمفتاح. تذكّر أن التصريح عن المعامل باستخدام "Object…‎"، وهذا يَعنِي احتمالية تمرير أيّ عددٍ من المعاملات الفعلية بعد المعامل key. ألقِ نظرةً على مقال تعرف على المصفوفات (Arrays) في جافا. تشمل الاستخدامات النموذجية ما يلي: String saveImageCommandText = I18n.tr( "menu.saveimage" ); String errMess = I18n.tr( "error.cantLoad" , selectedFile.getName() ); في الواقع، سترى استدعاءاتٍ كثيرةً مشابهة ضمن شيفرة تطبيق عرض مجموعة ماندلبرو؛ إذ كُتِبَ الصنف I18n بطريقة عامة لتتمكَّن من استخدامه بأي برنامجٍ آخر، وكل ما تحتاج إليه هو توفير ملف خاصيات على أنه ملف مورد، وأن تُخصِّص اسمه بالملف I18n.java، وأخيرًا أن تضع الصنف ضمن الحزمة الخاصة بك. يُمكِننا أيضًا استخدام أكثر من ملف خاصيات ضمن نفس البرنامج. فعلى سبيل المثال، قد نُضمِّن نسخةً فرنسية وأخرى يابانية من ملف الخاصيات إلى جانب النسخة الإنجليزية، فإذا كان اسم الملف بالنسخة الإنجليزية هو strings.properties، فينبغي أن تكون أسماء الملفات بالنسختين الفرنسية واليابانية strings_fr.properties و strings_ja.properties؛ إذ تملُك كل لغة ترميزًا مكوَّنًا من حرفين، مثل "fr" و "ja"، ويُستخدَم هذا الترميز باسم ملف الخاصيات الخاص بتلك اللغة. بدايةً، يَستخدِم البرنامج الاسم البسيط لملف الخاصيات "strings"؛ فإذا كان البرنامج مُشغَّلًا بنظام جافا وكانت اللغة المُفضلة هي الفرنسية، فسيحاول البرنامج أن يُحمِّل ملف خاصيات باسم "strings_fr.properties"؛ وإذا فشل، فسيحاول أن يُحمِّل ملف خاصيات باسم "strings.properties". يَعنِي ذلك، أن البرنامج سيستَخدِم ملف الخاصيات الخاص باللغة الفرنسية في الموضع الفرنسي، وسيستخدِم ملف الخاصيات الخاص باللغة اليابانية في موضع اللغة اليابانية، وأخيرًا، سيَستخدِم ملف الخاصيات الافتراضي في الحالات الأخرى. الإعدادات المفضلة تَسمَح غالبية البرامج للمُستخدِم بضبط إعداداته المفضلة؛ إذ تُمثِّل تلك الإعدادات جزءًا من حالة البرنامج، التي ينبغي أن يتذكرها عند تشغيله مرةً أخرى، وليتمكَّن من ذلك، عليه أن يُخزِّنها بملفٍ ضمن المجلد الرئيسي للمُستخدِم، كما أن عليه أن يتمكَّن من تحديد موقعها بعد ذلك. ينبغي إذًا تسمية الملف بطريقة تُجنِّبنا أيّ تعارضٍ مع أسماء الملفات المُستخدَمة بواسطة البرامج الأخرى. هناك مشكلةٌ أخرى، وهي أننا بتلك الطريقة سنملأ المجلد الرئيسي للمُستخدِم بملفاتٍ لا ينبغي أن يُعرَف بوجودها أساسًا. تتعامل جافا مع تلك المشاكل بتوفير طريقةٍ قياسية لمعالجة الإعدادات المفضلة؛ إذ تُعرِّف جافا الصنف Preferences ضمن حزمة java.util.prefs، وهذا الصنف هو كلُّ ما تحتاج إليه. يحتوي ملف تطبيق عرض مجموعة ماندلبرو Main.java على مثالٍ لاستخدام الصنف Preferences. يضبُط المُستخدِم عادةً إعداداته المفضلة بمعظم البرامج عبر صندوق نافذة مُخصَّص، ولكن لا يحتوي تطبيق ماندلبرو على إعدادات يتناسب معها هذا النوع من المعالجة. وبدلًا من ذلك، يُخزِّن البرنامج بعضًا من جوانب حالة البرنامج أوتوماتيكيًا على أنها إعداداتٌ مفضلة؛ وبكل مرة يُشغِّل المُستخدِم بها البرنامج، فإنه يقرأ تلك الإعدادات إن وُجدت؛ وبكل مرة يُغلِق المُستخدِم بها البرنامج، فإنه يُخزِّن تلك الإعدادات. تؤدي الحاجة إلى حفظ الإعدادات المفضلة إلى مشكلةٍ شيقةٍ نوعًا ما. ينتهي البرنامج بمجرد غلق النافذة، ولكننا نحتاج إلى طريقةٍ لحفظ الإعدادات عند حدوث ذلك، ويَكْمُن الحل عادةً في استخدام الأحداث: يُسجِّل التابع start()‎ المُعرَّف بالصنف Main مستمعًا إلى حدث غلق النافذة، وعند وقوع ذلك الحدث، يُعالِجه معالج الحدث بحفظ الإعدادات المفضلة. تُخزَّن الإعدادات المفضلة ببرامج جافا بصيغةٍ تعتمد على المنصة المُستخدَمة، وبمكانٍ يعتمد أيضًا على تلك المنصة، ولكن بصفتك مبرمج جافا، فلا حاجة للقلق بشأن ذلك، إذ يعرف نظام التفضيلات بجافا تمامًا مكان تخزين البيانات، ولكن ما يزال هناك مشكلة فصل الإعدادات المفضلة لبرنامج معين عن إعدادات بقية برامج جافا التي قد تكون مُشغَّلة على نفس الحاسوب. تحلّ جافا تلك المشكلة بنفس الطريقة التي حلّت بها مشكلة تسمية الحزم؛ إذ تُعرَّف ببساطة الإعدادات المفضلة لبرنامج معين من خلال اسم الحزمة الخاصة بالبرنامج مع اختلافٍ بسيط بالترميز. على سبيل المثال، تطبيق عرض مجموعة ماندلبرو مُعرَّف بحزمة edu.hws.eck.mdbfx، وإعداداته مُعرَّفة عبر السلسلة النصية "‎/edu/hws/eck/mdbfx". حلَّ المحرف "/" محلَّ النقاط مع إضافة "/" إلى البداية. تُخزَّن الإعدادات المفضلة لأي برنامج داخل عقدة node، ويُمكِن استرجاع العقدة المقابلة للسلسلة النصية المُعرِّفة لإعدادات برنامج معين على النحو التالي: Preferences root = Preferences.userRoot(); Preferences node = root.node(pathName); يشير المعامل pathname إلى السلسلة النصية المُعرِّفة للعقدة، مثل "‎/edu/hws/eck/mdbfx". تتكوَّن العقدة نفسها من قائمةٍ بسيطة من أزواج مفتاح / قيمة، ويتكوَّن كلٌ من المفتاح والقيمة من سلاسل نصية. في الحقيقة، يستطيع البرنامج أن يُخزِّن أي سلاسل نصية ضمن تلك العقد، فهي تُمثِّل فقط طريقةً للاحتفاظ بالبيانات من عملية تشغيل برنامج لأخرى، ولكن يُحدِّد المفتاح عمومًأ هوية عنصرٍ معيّنٍ من الإعدادات المفضلة، وتكون قيمته المقابلة هي القيمة المفضلة. إذا كان prefnode كائنًا من النوع Preferences، فإنه يحتوي على التابع prefnode.get(key)‎ لاسترجاع القيمة المرتبطة بمفتاح معين، والتابع prefnode.put(key,value)‎ لضبط القيمة الخاصة بمفتاح معين. يَستخدِم البرنامج "Main.java" الإعدادات المفضلة لتخزين شكل نافذة البرنامج وموضعها، إذ يَسمَح ذلك بالإبقاء على حجم النافذة وشكلها بين عمليات التشغيل المتتالية لنفس البرنامج. وعندما يُشغِّل المُستخدِم البرنامج، سيجد النافذة بنفس المكان الذي تركها فيه آخر مرة شغَّل خلالها البرنامج. يُخزِّن البرنامج أيضًا اسم المجلد الذي كان يحتوي على آخر ملف فتحه المُستخدِم أو حفظه، وهذا في الواقع أمر مهم؛ لأن السلوك الافتراضي لصندوق نافذة فتح ملف هو عرض مجلد العمل الحالي، وهو نادرًا ما سيكون المكان الذي يرغب المُستخدِم بحفظ ملفات البرنامج به. بفضل خاصية الإعدادات المفضلة، سيتمكَّن المُستخدِم من الانتقال إلى المجلد الصحيح عند أول تعاملٍ له مع البرنامج، وبعد ذلك سيجد المُستخدِم نفسه بالمجلد الصحيح أوتوماتيكيًا عند استخدامه للبرنامج مرةً أخرى. تستطيع الإطلاع على الشيفرة المصدرية بالملف Main.java لمزيدٍ من التفاصيل. يُمكِننا بالتأكيد قول المزيد عن لغة جافا وعن البرمجة عمومًأ، ولكن هذه السلسلة لا تمثِّل سوى مقدمةً إلى البرمجة باستخدام لغة جافا، وقد حان الوقت لانتهاء رحلتنا. التي نتمنى أنها كانت رحلةً مُبهِجةً لك وأنك قد استفدت منها ببناء أساسٍ قوي تستطيع استخدامه قاعدةً لاكتشافاتك التالية. ترجمة -بتصرّف- للقسم Section 5: Finishing Touches من فصل Chapter 13: GUI Programming Continued من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: النوافذ وصناديق النافذة في جافا الخاصيات والارتباطات في جافا كيفية كتابة برامج صحيحة باستخدام لغة جافا مقدمة إلى صحة البرامج ومتانتها في جافا
  2. تتكوَّن جميع برامج واجهات المُستخدِم الرسومية GUI التي تعرَّضنا إليها حتى الآن من نافذةٍ واحدة، ولكن غالبية البرامج تتكوَّن في الواقع من عدة نوافذ. ولذلك سنناقش في هذا المقال طريقة إدارة التطبيقات متعددة النوافذ؛ كما سنتناول صناديق النافذة dialog boxes، وهي نوافذٌ صغيرة تظهر وتختفي، وتُستخدَم عادةًً للحصول على مُدْخَلٍ من المُستخدِم. بالإضافة إلى ذلك، سنتعرَّض للصنف WebView، وهو أداة تحكُّم بمكتبة JavaFX، وتدعم كثيرًا وظائف متصفح الإنترنت. صناديق النافذة Dialog Boxes أيّ صندوق نافذة هو ببساطة نافذة تعتمد على نافذةٍ أخرى تَعمَل بمثابة أبٍ أو مالكٍ لصندوق النافذة؛ ويَعنِي ذلك، أنه لو أغلقنا النافذة المالكة، فسيُغلَق صندوق النافذة أوتوماتيكيًا. قد يكون صندوق النافذة شرطيًا modal أو غير شرطي modeless؛ فعند فتح صندوق نافذة شرطي، ستُعطَّل النافذة المالكة له، ولا يستطيع المُستخدِم التفاعل معها حتى يُغلِق صندوق النافذة. هناك أيضًا صناديق نافذة شرطية على مستوى التطبيق application modal؛ أي أنها تُعطِّل التطبيق بالكامل وليس فقط النافذة المالكة لها، وتُعدّ غالبية صناديق النافذة بمكتبة JavaFX من هذا النوع. تظهر صناديق النافذة الشرطية عادةً أثناء تنفيذ البرنامج، وذلك لكي تطلُب من المُستخدِم مُدْخَلًا معينًا، أو فقط لعرض رسالةٍ للمُستخدِم أحيانًا. في المقابل، لا تُعطِّل صناديق النافذة غير الشرطية تفاعل المُستخدِم مع نوافذها المالكة، ولكنها ما تزال تُغلَق أوتوماتيكيًا عند غلق النافذة المالكة. يُستخدَم هذا النوع عادةً لإظهار عرضٍ view مختلفٍ للبيانات الموجودة بالنافذة المالكة، أو لعرض أدوات تحكُّم إضافية تؤثر على النافذة المالكة. يُمكِننا ضبط مرحلةٍ من الصنف Stage لتَعمَل مثل صندوق نافذة، ولكن غالبية صناديق النافذة ببرامج JavaFX هي كائناتٌ مُنتمية إلى الصنف Dialog المُعرَّف بحزمة javafx.scene.control، أو أي من أصنافه الفرعية subclasses. فإذا كان dlg كائن صندوق نافذة من النوع Dialog، فسيتضمَّن توابع النسخ instance methods التالية لعرض صندوق النافذة: dlg.show()‎ و dlg.showAndWait()‎. إذا استخدمنا التابع dlg.showAndWait()‎ لعرض الصندوق، فسيكون صندوق النافذة شرطيًا، حتى على مستوى التطبيق. لا يعود التابع showAndWait()‎ إلى أن يُغلَق صندوق النافذة، وتكون بالتالي قيمة مُدْخَل المُستخدِم متاحةً بعد استدعاء showAndWait()‎ مباشرةً. في المقابل، إذا استخدمنا التابع dlg.show()‎ لعرض الصندوق، فسيكون غير شرطي، إذ يعود التابع show()‎ فورًا، ويستطيع المُستخدِم بالتالي التعامل مع النافذة والصندوق، والتبديل بينهما. وفي الواقع، يتشابه استخدام صناديق النافذة غير الشرطية مع البرمجة على التوازي parallel programming نوعًا ما؛ فهناك شيئان قيد التنفيذ بنفس الوقت. سنُركّز هنا على صناديق النافذة الشرطية فقط. يُعدّ الصنف Dialog<T>‎ نوعًا ذا معاملاتٍ غير مُحدّدة النوع parameterized، إذ يُمثِّل معامل النوع type parameter نوع القيمة التي سيعيدها التابع showAndWait()‎، والتي تكون تحديدًا من النوع Optional<T>‎؛ ويَعنِي ذلك أنها قيمةٌ من النوع T، ولكنها قد تكون موجودةً أو غير موجودة. يتضمَّن الصنف Optional -المُعرَّف بحزمة java.util- التابع isPresent()‎، والذي يُعيد قيمةً من النوع boolean، التي تشير إلى ما إذا كانت القيمة موجودةً أم لا، كما يتضمَّن التابع get()‎ الذي يعيد تلك القيمة إذا كانت موجودة. إذا لم تكن القيمة موجودةً، فسيؤدي استدعاء التابع get()‎ إلى حدوث استثناءٍ exception؛ ويَعنِي ذلك أنه إذا أردنا استخدام القيمة المعادة من التابع showAndWait()‎، فعلينا أولًا استدعاء التابع isPresent()‎ لنتأكّد من أن التابع قد أعاد قيمةً فعلًا. يحتوي أي صندوق نافذة عادةً على زرٍ واحدٍ أو أكثر لغلق الصندوق على الأقل، وتكون أسماء تلك الأزرار غالبًا هي: "OK" أو "Cancel" أو "Yes" أو "No". كما يُستخدَم نوع التعداد ButtonType لتمثيل الأزرار الأكثر شيوعًا، ويتضمَّن القيم ButtonType.OK و ButtonType.CANCEL و ButtonType.YES و ButtonType.NO؛ إذ يُعدّ النوع ButtonType القيمة المعادة الأكثر شيوعًا من صناديق النافذة المُمثَلة بالصنف Dialog، وذلك للإشارة إلى الزر الذي نقر عليه المُستخدِم لغلق الصندوق، ويكون صندوق النافذة من النوع Dialog<ButtonType>‎ في تلك الحالة. يُعدّ Alert صنفًا فرعيًا من الصنف Dialog<ButtonType>‎، إذ يُسهِّل إنشاء صناديق النافذة التقليدية التي تَعرِض رسالةً نصيةً للمُستخدِم مصحوبةً بزرٍ واحدٍ أو اثنين، وتَعمَل بمثابة تنبيهٍ للمُستخدِم. كنا قد استخدمنا هذا الصنف فعليًا في مقال مدخل إلى التعامل مع الملفات في جافا لكي نَعرِض بعض رسائل الخطأ للمُستخدِم، ولكن دون أن نتعرَّض لطريقة عمله. يُمكِننا إنشاء كائنٍ من النوع Alert على النحو التالي: Alert alert = new Alert( alertType, message ); ينتمي المعامل الأول إلى نوع التعداد Alert.AlertType الذي يُمكِن لقيمته أن تكون واحدةً مما يلي: Alert.AlertType.INFORMATION. Alert.AlertType.WARNING. Alert.AlertType.ERROR. Alert.AlertType.CONFIRMATION. وعند تخصيص أي من القيم الثلاثة الأولى، سيحتوي الصندوق المعروض على زر "OK" وحيد ولا يفعل أكثر من مجرد عرض رسالةٍ نصيةٍ للمُستخدِم، وفي تلك الحالة، لا حاجة لفحص أو استرجاع القيمة المعادة من التابع alert.showAndWait()‎. عند تمرير القيمة الأخيرة، سيتضمَّن الصندوق زر "OK" و زر "Cancel"، ويُستخدَم عادةً لسؤال المُستخدِم عما إذا كان يريد الاستمرار بتنفيذ عملية يُحتمَل أن تكون خطيرةً، مثل حذف ملف؛ إذ يكون من الضروري فحص القيمة المعادة من التابع في تلك الحالة، وهذا هو ما تفعله الشيفرة التالية: Alert confirm = new Alert( Alert.AlertType.CONFIRMATION, "Do you really want to delete " + file.getName() ); Optional<ButtonType> response = confirm.showAndWait(); if ( response.isPresent() && response.get() == ButtonType.OK ) { file.delete(); } إضافةً إلى الأزرار، قد يحتوي صندوق النافذة على مساحةٍ للمحتوى وعنوانٍ رئيسي يَظهَر أعلى تلك المساحة، ورسمةٍ تَظهَر إلى جانب العنوان الرئيسي إن وجد، أو إلى جانب المحتوى؛ وبالتأكيد عنوان يَظهَر بشريط عنوان صندوق النافذة. تكون الرسمة عادةً أيقونةً صغيرةً؛ فبالنسبة لصناديق النافذة من النوع Alert، تُوضَع الرسالة بمساحة المحتوى، وتُضبَط الخاصيات الأخرى أوتوماتيكيًا بما يتناسب مع نوع التنبيه المُستخدَم، ويُمكِن مع ذلك تعديلها باستدعاء التوابع التالية المُعرَّفة بالصنف Dialog قبل عرض التنبيه: alert.setTitle( windowTitle ); alert.setGraphic( node ); alert.setHeaderText( headerText ); يُمكِن لأي من تلك القيم المُمرَّرة أن تكون قيمةً فارغةً null، كما يُمكِننا ضبط المحتوى إلى أي عقدة مبيان مشهد عشوائية لتحلّ محل الرسالة النصية، وذلك باستدعاء التابع التالي: alert.getDialogPane().setContent( node ); ولكننا نُطبِّق ذلك عادةً على صندوق نافذة عادي من النوع Dialog، لا على تنبيهٍ من النوع Alert. تُوضِّح تنبيهات التأكيد بالصورة التالية المكونات المختلفة الموجودة بأي صندوق نافذة، إذ يُمكِّننا ملاحِظة أن العنوان الرئيسي الخاص بالصندوق الموجود على يمين الصورة فارغ؛ وإذا أردت عَرض نصٍ متعدد الأسطر ضمن تنبيه، فلا بُدّ من إضافة محرف السطر الجديد ("‎\n") إلى النص: تُوفِّرمكتبة JavaFX الصنف الفرعي TextInputDialog المُشتَق من الصنف Dialog<String>‎ لتمثيل صناديق النافذة التي تقرأ مُدْخَلًا من المُستخدِم. ويعيد التابع showAndWait()‎ في تلك الحالة قيمةً من النوع Optional<String>‎، كما يحتوي صندوق النافذة المُستخدِم لذلك الصنف على حقلٍ نصي من النوع TextField، مما يُمكِّن المُستخدِم من إدخال سطر نصي؛ كذلك، يحتوي على زر "OK" و زر "Cancel". يَستقبِل الباني معاملًا من النوع String، والذي يُمثِّل المحتوى المبدئي للحقل النصي؛ فإذا أردنا أن نطرح سؤالًا على المُستخدِم أو أن نَعرِض رسالةً معينةً عليه، فيُمكِننا وضعها بالعنوان الرئيسي للصندوق. يعيد صندوق النافذة محتويات الحقل النصي إن وُجدت، والتي قد تكون مجرد سلسلةٍ نصيةٍ فارغة، وإذا نقر المُستخدِم على زر "Cancel" أو أغلق صندوق النافذة ببساطة، فستكون القيمة المعادة غير موجودة. ألقِ نظرةً على الشيفرة التالية: TextInputDialog getNameDialog = new TextInputBox("Fred"); getNameDialog.setHeaderText("Please enter your name."); Optional<String> response = getNameDialog.showAndWait(); if (response.isPresent() && response.get().trim().length() > 0) { name = response.get().trim(); } else { Alert error = new Alert( Alert.AlertType.ERROR, "Anonymous users are not allowed!" ); error.showAndWait(); System.exit(1): } إلى جانب الصنفين Alert و TextInputDialog، يُعرِّف الصنف SimpleDialogs.java -كتبه المؤلف- التوابع الساكنة static التالية التي يُمكِن استخدامها لعرض أكثر أنواع صناديق النافذة شيوعًا: SimpleDialogs.message(text)‎: يعرِض صندوق نافذة يحتوي على رسالة نصية وزر"OK"، ولا يًعيد أي قيمة. لاحِظ أنك لا تحتاج إلى كتابة محرف السطر الجديد ضمن الرسالة إذا أردتها أن تكون متعددة الأسطر؛ لأن ذلك يحدث تلقائيًا مع الرسائل الطويلة. يَستقبِل التابع أيضًا معاملًا ثانيًا اختياريًا لتخصيص عنوان صندوق النافذة. SimpleDialogs.prompt(text)‎: يعرِض صندوق نافذة يحتوي على رسالة نصية وحقل إدخال نصي مع زر "OK" و زر "Cancel"، ويُعيد قيمةً من النوع String تُمثِّل محتويات الحقل النصي إذا كان المُستخدِم قد نقر على "OK"؛ أو على القيمة الفارغة null إذا كان المُستخدِم قد أغلق الصندوق. يَستقبِل التابع أيضًا معاملين اختياريين آخرين لتخصيص عنوان صندوق النافذة، والمحتوى المبدئي للحقل النصي على الترتيب. SimpleDialogs.confirm(text)‎: يَعرِض صندوق نافذة يحتوي على رسالة نصية مع زر "Yes" و زر "No" و زر "Cancel"، ويُعيد قيمةً من النوع String، والتي لا بُدّ أن تكون واحدةً من القيم التالية "yes" أو "no" أو "cancel". يَستقبِل التابع معاملًا ثانيًا اختياريًا لتخصيص عنوان صندوق النافذة مثل التابعين السابقين. يحتوي الصنف SimpleDialogs على بعض الخيارات الأخرى، مثل صندوق نافذة بسيط لاختيار لون، والتي يُمكِنك الإطلاع عليها بقراءة شيفرة الملف SimpleDialogs.java؛ كما يَسمَح أيضًا البرنامج TestDialogs.java للمُستخدِم بتجربة صناديق النافذة المختلفة المُعرَّفة بالصنف SimpleDialogs. الصنفان WebView و WebEngine سنناقش ببقية هذا المقال برنامج متصفح إنترنت مُتعدّد النوافذ، إذ تبدو كتابة متصفح إنترنت عمليةً معقدة، وهي كذلك فعلًا، ولكن مكتبة JavaFX تُسهِّل ذلك كثيرًا بتنفيذ غالبية العمل المطلوب ضمن مجموعةٍ من الأصناف القياسية؛ إذ يُمثِّل الصنف WebView المُعرَّف بحزمة javafx.scene.control أداة تحكُّم يُمكِنها تحميل صفحة إنترنت وعرضها، كما تستطيع تلك الأداة معالجة معظم صفحات الإنترنت جيدًا بما في ذلك تنفيذ شيفرة JavaScript، إذ تُستخدَم لغة البرمجة جافاسكربت JavaScript لبرمجة صفحات إنترنت ديناميكية، ولا علاقة لها بلغة Java). إضافةً لما سبق، يُمثِّل الصنف WebView العرض view ضمن نمط نموذج-عرض-مُتحكَّم Model-View-Controller الذي ناقشناه بمقال أمثلة عن رسوميات فاخرة باستعمال جافا؛ إذ يُنفَّذ غالبية العمل المطلوب لتحميل صفحات الإنترنت وإدارتها من خلال كائنٍ من النوع WebEngine، والذي يُمثِّل جزءًا من المُتحكِّم controller؛ أما النموذج، فهو هيكل بياني data structure يتضمَّن محتويات صفحة الإنترنت. ويُنشِئ الصنف WebEngine النموذج عند تحميل الصفحة، ثم يَعرِض الصنف WebView محتوياتها. يجب أن نَعرِض كائن الصنف WebView ضمن نافذة؛ إذ يُمثِّل الصنف الفرعي BrowserWindow.java -المُشتَق من صنف النافذة القياسي Stage- نافذة متصفح إنترنت كاملة، وهو يحتوي على كائنٍ ينتمي إلى الصنف WebView، بالإضافة إلى شريط قوائم وبعض أدوات التحكُّم الأخرى، مثل صندوق إدخال نصي يُمكِّن المُستخدِم من كتابة محدّد موارد مُوحد URL لصفحة إنترنت معينة، وزر "Load" ينقر عليه المُستخدِم لتحميل صفحة الإنترنت من محدّد الموارد الموحد إلى كائن الصنف WebView. بالإضافة إلى ذلك، يُمكِن لباني الصنف BrowserWindow أن يَستقبِل معاملًا إضافيًا لتخصيص محدِّد موارد موحد مبدئي يُحمَّل تلقائيًا عند فتح النافذة. يتضمَّن كل كائن من النوع WebView كائنًا آخرًا من النوع WebEngine، والذي يُمكِننا استرجاعه باستدعاء التابع webEngine = webview.getEngine()‎. يمكننا الآن تحميل load صفحة إنترنت باستدعاء ما يلي: webEngine.load( urlString ); إذ أن urlString سلسلةٌ نصيةٌ تُمثِّل محدِّد موارد موحد -ألقِ نظرةً على مقال تواصل تطبيقات جافا عبر الشبكة-. ويجب أن تبدأ تلك السلسلة ببروتوكول، مثل "http:‎" أو "https:‎"، ولهذا يضيف البرنامج كلمة "http://‎" إلى مقدمة السلسلة النصية المُتضمِّنة لمُحدّد الموارد الموحد، إذا لم تكن تحتوي على بروتوكول فعليًا. إضافةً لما سبق، يُحمِّل البرنامج صفحة إنترنت جديدة أوتوماتيكيًا، وذلك إذا نقر المُستخدِم على رابط ضمن الصفحة المعروضة حاليًا. تُحمَّل صفحات الإنترنت بصورةٍ غير متزامنة asynchronous؛ أي أن التابع webEngine.load()‎ يعود فورًا، في حين تُحمَّل صفحة الإنترنت ضمن خيط thread مُشغَّلٍ بالخلفية. وعند اكتمال عملية التحميل، تُعرَض صفحة الإنترنت داخل كائن الصنف WebView؛ أما في حالة فشل التحميل لسببٍ ما، فلا يحدث أي تنبيه تلقائي، ولكن ما يزال بإمكاننا الحصول على بعض المعلومات بإضافة مستمعي أحداث إلى الخاصيتين location و title -من النوع String- القابلتين للمراقبة observable، والمُعرَّفتين بالصنف WebEngine؛ إذ تُمثِّل الخاصية location محدد الموارد الموحد لصفحة الإنترنت المعروضة حاليًا، أو التي يُجرَى تحميلها؛ بينما تُمثِّل الخاصية title عنوان صفحة الإنترنت الحالية، والتي تَظهَر بشريط عنوان النافذة التي تَعرِض صفحة الإنترنت. على سبيل المثال، يستمع الصنف BrowserWindow إلى الخاصية title ويَضبُط عنوان النافذة ليتوافق مع محتوياتها كما يلي: webEngine.titleProperty().addListener( (o,oldVal,newVal) -> { if (newVal == null) setTitle("Untitled " + owner.getNextUntitledCount()); else setTitle(newVal); }); يستمع البرنامج إلى الخاصية location أيضًا، ويَعرِض قيمتها داخل عنوان من النوع Label أسفل النافذة؛ في حين سنناقش owner لاحقًا بالأسفل. يُمكِننا أيضًا أن نضيف مستمعًا إلى خاصية webEngine.getLoadWorker().stateProperty()‎ لمراقبة تقدّم تحميل الصفحة. ألقِ نظرةً على شيفرة الصنف BrowserWindow.java لترى مثالًا على ذلك. ذكرنا بالأعلى أن كائن الصنف WebView (مع كائن الصنف WebEngine الخاص به) قادرٌ على تشغيل شيفرة JavaScript المُضمَّنة بصفحات الإنترنت؛ ولكن هذا ليس دقيقًا تمامًا، إذ تحتوي JavaScript على بعض البرامج الفرعية subroutines المسؤولة عن إظهار صناديق نوافذ بسيطة؛ فعلى سبيل المثال، يَعرِض صندوق النافذة من النوع "alert" رسالةً نصيةً للمُستخدِم؛ بينما يطرح صندوق النافذة من النوع "prompt" سؤالًا على المُستخدِم ويتلقى ردَّه النصي عليها؛ أما صندوق النافذة من النوع "confirm"، فيَعرِض رسالةً للمُستخدِم مع زر "OK" و زر "Cancel"، ويتلقى قيمةً مُعادةً من النوع boolean تشير إلى ما إذا كان المُستخدِم قد أغلق الصندوق بالنقر على زر "OK". في الواقع، يتجاهل الصنف WebEngine طلبات JavaScript لعرض تلك الصناديق افتراضيًا، ولكن يُمكِننا إضافة مستمعي أحداث للاستجابة إلى تلك الطلبات، إذ يَستخدِم الصنف BrowserWindow صناديق نافذة من تلك المُعرَّفة بالصنف SimpleDialogs للرد على تلك الأحداث؛ فعندما تحاول JavaScript أن تَعرِض صندوق نافذة تنبيهي مثلًا، فسيُولِّد كائن الصنف WebEngine حدثًا من النوع AlertEvent، يتضمَّن الرسالة التي تريد JavaScript أن تَعرِضها، وسيستجيب الصنف BrowserWindow باستدعاء SimpleDialogs.message()‎ لكي يَعرِض الرسالة للمُستخدِم. ألقِ نظرةً على الشيفرة التالية: webEngine.setOnAlert( evt -> SimpleDialogs.message(evt.getData(), "Alert from web page") ); تختلف معالجة صناديق النافذة من النوعين prompt و confirm بعض الشيء؛ لكونهما يعيدان قيمةً، وتبين الشيفرة التالية الطريقة التي اتبعها البرنامج لمعالجتهما: webEngine.setPromptHandler( promptData -> SimpleDialogs.prompt( promptData.getMessage(), "Query from web page", promptData.getDefaultValue() ) ); webEngine.setConfirmHandler( str -> SimpleDialogs.confirm(str, "Confirmation Needed").equals("yes") ); لم نناقش بعد شريط القوائم الذي يدعمه الصنف BrowserWindow؛ إذ يحتوي ذلك الشريط على قائمةٍ واحدةٍ اسمها "Window"، وتحتوي بدورها على مجموعةٍ من الأوامر لأغراضٍ معينة، مثل فتح نافذة متصفح جديدة أو غلْق النافذة الحالية، كما تحتوي على قائمةٍ مكوّنة من نوافذ المتصفح المفتوحة حاليًا، ويستطيع المُستخدِم أن يختار أيًا منها ليُحضره إلى مقدمة الشاشة؛ ولكي تفهم طريقة تنفيذ ذلك، عليك أولًا أن تفهم طريقة استخدام الصنف BrowserWindow ضمن برنامجٍ متُعدّد النوافذ. إدارة عدة نوافذ لا يُعدّ الصنف BrowserWindow تطبيقًا، أي لا يُمكِن تشغيله على أنه برنامجٌ بحد ذاته، وإنما يُمثِّل نافذةً واحدةً ضمن برنامجٍ متعدد النوافذ. ستجِد النسخة القابلة للتشغيل من هذا الصنف بالملف WebBrowser.java. يمتد الصنف WebBrowser من الصنف Application مثل أي برنامجٍ مُصمَّم بمكتبة JavaFX، كما يعتمد على الصنفين BrowserWindow.java و SimpleDialogs.java؛ ولذلك ستحتاج إلى الأصناف الثلاثة لكي تتمكَّن من تشغيل البرنامج. يحتوي أي صنفٍ ينتمي للنوع Application على التابع start()‎ الذي يستدعيه النظام عند بدء تشغيل التطبيق، إذ يستقبل هذا التابع معاملًا من النوع Stage يُخصِّص النافذة الرئيسية للبرنامج، ولكن ليس من الضروري للبرنامج أن يَستخدِم تلك النافذة فعليًا؛ إذ يتجاهل التابع start()‎ المُعرَّف بالصنف WebBrowser النافذة الرئيسية المُخصَّصة، ويُنشِئ بدلًا منها نافذةً من النوع BrowserWindow، لتكون هي أول ما يَظهَر عند تشغيل البرنامج، وقد ضُبطَت تلك النافذة لتُحمِّل الصفحة الرئيسية لصفحة الإنترنت التي تحتوي على النسخة المُتاحة عبر الإنترنت من هذا الكتاب. يُمثِّل ما سبق كل ما ينبغي أن يفعله الصنف WebBrowser.java لتنفيذ البرنامج باستثناء القائمة "Window" التي تحتوي على قائمةٍ بكل النوافذ المفتوحة. ونظرًا لعدم كون تلك القائمة جزءًا من بيانات أي نافذة على حدة، فقد كان من الضروري الاحتفاظ بها بمكانٍ آخر، مثل كائن الصنف WebBrowser، ويمكن بدلًا من ذلك تخزين قائمة النوافذ مثل متغير عضو ساكن static بالصنف BrowserWindow، مما يَعنِي تشاركُه بين جميع النسخ المنشأة من ذلك الصنف؛ إذ يُعرِّف الصنف WebBrowser تابعه newBrowserWindow()‎ بغرض فتح نافذةٍ جديدة؛ بينما يحتوي الصنف BrowserWindow على متغير النسخة owner للإشارة إلى كائن الصنف WebBrowser الذي فتح النافذة. وبالتالي إذا أراد البرنامج فتح نافذة جديدة، فإنه يفعل ذلك باستدعاء التابع owner.newBrowserWindow(url)‎، إذ يشير المعامل url إلى مُحدِّد الموارد الموحد الخاص بصفحة الإنترنت المطلوب تحميلها بالنافذة الجديدة. وفي المقابل، قد يحتوي المعامل على القيمة الفارغة null بهدف فتح نافذة متصفحٍ فارغة. يتحدََد حجم أي نافذة بمكتبة JavaFX وفقًا لحجم المشهد -من النوع Scene- المعروض داخلها افتراضيًا، كما تظهر النافذة بمنتصف الشاشة افتراضيًا؛ ويُمكِننا بدلًا من ذلك ضبط حجم النافذة ومكانها قبل فتحها. بالنسبة للبرامج متعددة النوافذ، لا يُحبَّذ عرض جميع النوافذ بنفس المكان بالضبط، كما يبدو أن الحجم الافتراضي لكائنات الصنف BrowserWindow صغيرٌ جدًا بمعظم شاشات الحاسوب؛ ولذلك يَضبُط التطبيق WebBrowser موضع جميع النوافذ التي يفتحها ليَبعُد مكان كل نافذةٍ مسافةً قصيرةً عن مكان النافذة التي فتحها التطبيق بالمرة السابقة؛ كما يضبُط التطبيق حجم النافذة بما يتناسب مع حجم الشاشة. يحتوي الصنف Screen المُعرَّف بحزمة javafx.stage على التابع الساكن Screen.getPrimary()‎ الذي يعيد كائنًا يتضمَّن معلوماتٍ عن الشاشة الرئيسية للحاسوب؛ كما ويحتوي ذلك الكائن بدوره على كل من التابعين Screen.getPrimary().getVisualBounds()‎، الذي يُعيد كائنًا من النوع Rectangle2D ويُمثِّل المساحة القابلة للاستخدام من الشاشة الرئيسية. يَستدعِي تابع البرنامج start()‎ ذلك التابع لكي يَحسِب كُلًا من حجم أول نافذة ومكانها على النحو التالي: public void start(Stage stage) { // (stage is not used) openWindows = new ArrayList<BrowserWindow>(); // List of open windows. screenRect = Screen.getPrimary().getVisualBounds(); // 1 locationX = screenRect.getMinX() + 30; locationY = screenRect.getMinY() + 20; // 2 windowHeight = screenRect.getHeight() - 160; windowWidth = screenRect.getWidth() - 130; if (windowWidth > windowHeight*1.6) windowWidth = windowHeight*1.6; // 3 newBrowserWindow("http://math.hws.edu/javanotes/index.html"); } // end start() حيث أن: [1]: يشير (locationX,locationY) إلى مكان الركن الأيسر العلوي للنافذة التي ستُفتَح بالمرة القادمة، وتتحرك النافذة الأولى إلى الأسفل قليلًا من الركن الأيسر العلوي للجوانب المرئية للشاشة الرئيسية. [2]: تعني أن حجم النافذة يعتمد على طول وعرض جوانب الشاشة المرئية بما يَسمَح ببعض المسافات الإضافية حتى يكون من الممكن وضع النوافذ فوق بعضها، مع إزاحة كل واحدة عن سابقتها قليلًا. قيِّد العرض ليكون على الأكثر 1.6 مرة من الطول لأسباب جمالية. [3]: تعني افتح النافذة الأولى لتَعرِض الصفحة الأمامية للكتاب. عندما يَفتح التابع newBrowserWindow()‎ نافذةً جديدةً، سيعتمد حجمها ومكانها على قيم المتغيرات windowWidth و windowHeight و locationX و locationY؛ ولهذا ينبغي أن نُعدِّل قيم المتغيرين locationX و locationY لكي تَظهَر النافذة التالية بمكانٍ مختلف؛ وعلينا أيضًا أن نضيف النافذة الجديدة إلى قائمة النوافذ المفتوحة؛ كما ينبغي أن نتأكَّد من حذف النافذة من القائمة عند غلقها. لحسن الحظ، تُولِّد أي نافذة حدثًا عند غلقها، وبالتالي يُمكِننا أن نُضيف مستمعًا إلى ذلك الحدث، ليَحذِف معالج الحدث النافذة من قائمة النوافذ المفتوحة. ألقِ نظرةً على شيفرة التابع newBrowserWindow()‎: void newBrowserWindow(String url) { BrowserWindow window = new BrowserWindow(this,url); openWindows.add(window); // Add new window to open window list. window.setOnHidden( e -> { // 1 openWindows.remove( window ); System.out.println("Number of open windows is " + openWindows.size()); if (openWindows.size() == 0) { // 2 System.out.println("Program ends because all windows are closed"); } }); if (url == null) { window.setTitle("Untitled " + getNextUntitledCount()); } window.setX(locationX); // set location and size of the window window.setY(locationY); window.setWidth(windowWidth); window.setHeight(windowHeight); window.show(); locationX += 30; // set up location for NEXT window locationY += 20; if (locationX + windowWidth + 10 > screenRect.getMaxX()) { // 3 locationX = screenRect.getMinX() + 30; } if (locationY + windowHeight + 10 > screenRect.getMaxY()) { // 4 locationY = screenRect.getMinY() + 20; } } وتعني كل من: [1]: يُستدعَى عند غلق النافذة، وذلك ليحذف النافذة من قائمة النوافذ المفتوحة. [2]: ينتهي البرنامج أوتوماتيكيًا عند غلق جميع النوافذ. [3]: إذا كانت النافذة ستمتد إلى ما بعد طرف الشاشة الأيمن، فأعِد ضبط المتغير locationX إلى قيمته الأصلية. [4]: إذا كانت النافذة ستمتد إلى ما بعد طرف الشاشة السفلي، فأعِد ضبط المتغير locationY إلى قيمته الأصلية. يتضمَّن الصنف WebBrowser التابع getOpenWindowList()‎، والذي يعيد قائمةً بكل النوافذ المفتوحة؛ إذ يستدعِي كائن الصنف BrowserWindow ذلك التابع أثناء إنشائه للقائمة "Window". وفي الواقع، لا يحدث ذلك بأفضل كفاءةٍ ممكنة، ويُعاد بناء القائمة بكل مرة تُعرَض خلالها؛ إذ تُولِّد القائمة حدثًا عندما ينقُر المُستخدِم على اسم القائمة، وأيضًا قبل ظهورها مباشرةً. يُسجِّل الصنف BrowserWindow مستمعًا إلى ذلك الحدث، ويسترجِع معالج هذا الحدث قائمة النوافذ المفتوحة باستدعاء التابع owner.getOpenWindowList()‎، ويَستخدِمها لإعادة بناء القائمة قبل أن يُظهِرها على الشاشة. ألقِ نظرةً على شيفرة التابع المُعرَّف بالصنف BrowserWindow: private void populateWindowMenu() { ArrayList<BrowserWindow> windows = owner.getOpenWindowList(); while (windowMenu.getItems().size() > 4) { // 1 windowMenu.getItems().remove(windowMenu.getItems().size() - 1); } if (windows.size() > 1) { // 2 MenuItem item = new MenuItem("Close All and Exit"); item.setOnAction( e -> Platform.exit() ); windowMenu.getItems().add(item); windowMenu.getItems().add( new SeparatorMenuItem() ); } for (BrowserWindow window : windows) { String title = window.getTitle(); // Menu item text is the window title. if (title.length() > 60) { // 3 title = title.substring(0,57) + ". . ."; } MenuItem item = new MenuItem(title); final BrowserWindow win = window; // (for use in a lambda expression) // 4 item.setOnAction( e -> win.requestFocus() ); windowMenu.getItems().add(item); if (window == this) { // 5 item.setDisable(true); } } } حيث تعني كل من: [1]: تتكون القائمة من 4 عناصر دائمة. اِحذِف العناصر الأخرى المقابلة للنوافذ المفتوحة التي تُركت من آخر مرة عُرِضَت خلالها القائمة. [2]: أضف الأمر "Close All" فقط إذا لم تكن تلك هي النافذة الوحيدة. [3]: لا تَستخدِم نصوصًا طويلةً جدًا لعناصر القائمة. [4]: سيُحضِر معالج الحدث لعنصر القائمة ذاك النافذة المقابلة إلى المقدمة باستدعاء التابع requestFocus()‎ الخاص بها. [5]: نظرًا لأن النافذة موجودة بالمقدمة فعليًا، فعطِّل العنصر المقابل لتلك النافذة. كما ترى، ليس من الصعب إدارة تطبيق متعدد النوافذ، كما أنه من السهل كتابة متصفح إنترنت بوظائف معقولة نوعًا ما باستخدام مكتبة JavaFX. كان هذا مثالًا جيدًا على استخدام أصناف موجودة مثل قاعدة نبني عليها أصنافًا أخرى. رأينا أيضًا أمثلةً جديدةً جيدةً للتعامل مع الأحداث، وبذلك نكون قد وصلنا تقريبًا إلى نهاية هذه السلسلة. سنناقش في المقال الأخير بعض الأشياء المتعلقة ببرمجة واجهات المُستخدِم الرسومية. ترجمة -بتصرّف- للقسم Section 4: Mostly Windows and Dialogs من فصل Chapter 13: GUI Programming Continued من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: مكونات الواجهة المركبة ونمط MVC في جافا واجهة المستخدم الحديثة في جافا مدخل إلى التعامل مع الملفات في جافا
  3. تتضمَّن واجهة تطوير التطبيقات JavaFX تعقيداتٍ أكبر بكثير مما درسناه إلى الآن، ولكن كل هذا التعقيد يعمل لصالح المبرمج عمومًا؛ فهو على الأغلب يكون مخفيًا بالاستخدامات الأكثر شيوعًا لمكتبة JavaFX، أي أنك لست مضطّرًا إلى معرفة تفاصيل أدوات التحكُّم الأكثر تعقيدًا لكي تتمكَّن من استخدامها بفعالية بغالبية البرامج. تُعرِّف مكتبة JavaFX مجموعةً من الأصناف التي تُمثِّل مكونات أكثر تعقيدًا بكثير من تلك التي رأيناها، ولكن حتى أكثر تلك المكونات تعقيدًا ليس صعب الاستخدام في غالبية الأحوال. سنناقش خلال هذا المقال بعض مكونات الواجهة التي تدعم عرض القوائم والجداول ومعالجتها؛ ولكي تتمكَّن من استخدام تلك المكونات المعقدة بفعالية، عليك أن تتعلم القليل عن نمط "نموذج-عرض-متحكِّم Model-View-Controller"، والذي يُعد أساس لكثيرٍ من مكونات واجهة المُستخدِم الرسومية. سنناقش هذا النمط لاحقًا ضمن هذا المقال. تُوفِّر مكتبة JavaFX عددًا من أدوات التحكُّم التي لن نتعرَّض لها بهذا الكتاب -على الرغم من أن بعضها مفيدٌ نوعًا ما وقد ترغب بالإطلاع عليه-، مثل TabbedPane و SplitPane و Tree و ProgressBar، وكذلك بعض أدوات التحكُّم لقراءة أنواع خاصة من المُدْخَلات، مثل ColorPicker و DatePicker و PasswordField و Spinner. سنبدأ هذا المقال بمثالٍ قصير على كتابة أداة تحكُّم مُخصَّصة، وهو أمرٌ ستحتاج إليه إذا لم تَجِد المُكوِّن الذي تريده ضمن التشكيلة الضخمة من مكونات الواجهة المُعرَّفة مُسبقًا بمكتبة JavaFX، أو من الممكن أن تجده فعلًا ولكنه معقدٌ جدًا بالموازنة مع متطلبات برنامجك، وقد ترغب مثلًا بشيء أبسط. مكون واجهة مخصص بسيط ستَجِد عادةً كل ما تحتاجه لإنشاء واجهة مُستخدِم رسومية بأصناف المكونات القياسية الموجودة بمكتبة JavaFX، ومع ذلك قد ترغب أحيانًا بشيءٍ مختلفٍ بعض الشيء. في تلك الحالة، قد تفكر بكتابة مُكوِّنك الخاص بالاعتماد على واحدة من المكونات التي تُوفِّرها المكتبة، أو بالاعتماد على الصنف البسيط Control الذي يَعمَل صنفًا أساسيًا base class لجميع أدوات التحكُّم. لنفترض مثلًا أننا نريد أداة تحكُّم تمثِّل "ساعة إيقاف"؛ فعندما ينقُر المُستخدِم على الساعة، ينبغي أن تُشغِّل الوقت؛ وعندما ينقر المُستخدِم عليها مرةً أخرى، ينبغي أن تَعرِض الزمن المُنقضِي منذ النقرة الأولى. يُمكِننا استخدام الصنف Label لعرض النص، ولكننا نريده أن يكون قادرًا على الاستجابة إلى حدث النقر على الفأرة، ويُمكِننا تحقيق ذلك بتعريف صنف مُكوِّن واجهة، وليكن اسمه هو StopWatchLabel صنفًا فرعيًا subclass مُشتقًا من الصنف Label، إذ سيَستمِع كائن الصنف StopWatchLabel لحدث نقر الفأرة عليه، ويُغيّر النص المعروض إلى "Timing…‎" عندما ينقر المُستخدِم عليه لأول مرة، كما سيتذكّر توقيت نقر المُستخدِم عليه. وعندما ينقر المُستخدِم عليه مرةً أخرى، سيفحص التوقيت مرةً أخرى، وسيحسِب الزمن المُنقضِي ويعرضه. في الواقع، لسنا في حاجة بالضرورة لتعريف صنفٍ فرعي جديد، إذ يمكننا استخدام عنوانٍ عادي بالبرنامج، وتهيئة مُستمعٍ ليَستجيب لحدث النقر على العنوان، ونَسمَح للبرنامج بإنجاز العمل اللازم للاحتفاظ بالزمن وتعديل النص المعروض بالعنوان. ومع ذلك، فبكتابة صنف جديد، سنتمكَّن من إعادة استخدامه بمشروعات أخرى، كما أن كل الشيفرة المُتعلقة بساعة الإيقاف مُجمعّةٌ معًا بمكانٍ واحد. يكون ذلك أكثر أهميةً عند التعامل مع المكونات الأكثر تعقيدًا. ليست كتابة الصنف StopWatchLabel بهذه الصعوبة، إذ سنحتاج إلى متغير نسخة instance variable لتخزين توقيت بدء تشغيل ساعة الإيقاف، وسنُهيئ تابعًا لمعالجة حدث النقر على ساعة الإيقاف. ينبغي أن يُحدِّد ذلك التابع ما إذا كانت ساعة الإيقاف مُشغَّلةً أم مُتوقفِة؛ ولهذا سنحتاج إلى متغير نسخة من النوع boolean، وليكن اسمه هو running للاحتفاظ بهذا الجانب من حالة المكوّن؛ كما سنَستخدِم التابع System.currentTimeMillis()‎ للحصول على التوقيت الحالي بوحدة الميلي ثانية مثل قيمةٍ من النوع long. عند بدء تشغيل ساعة الإيقاف، سنخزِّن التوقيت الحالي بمتغير نسخة اسمه startTime؛ وعند إيقافها، سنَستخدِم التوقيت الحالي لحساب الزمن المنقضِي الذي ظلت خلاله ساعة الإيقاف قيد التشغيل. ألقِ نظرةً على شيفرة الصنف StopWatch: import javafx.scene.control.Label; // 1 public class StopWatchLabel extends Label { private long startTime; // Start time of timer. // (Time is measured in milliseconds.) private boolean running; // True when the timer is running. // 2 public StopWatchLabel() { super(" Click to start timer. "); setOnMousePressed( e -> setRunning( !running ) ); } // 3 public boolean isRunning() { return running; } // 4 public void setRunning( boolean running ) { if (this.running == running) return; this.running = running; if (running == true) { // Record the time and start the timer. startTime = System.currentTimeMillis(); setText("Timing...."); } else { // 5 long endTime = System.currentTimeMillis(); double seconds = (endTime - startTime) / 1000.0; setText( String.format("Time: %1.3f seconds", seconds) ); } } } // end StopWatchLabel حيث تشير كل من: [1] إلى مكوِّن واجهة مُخصَّص يمثِّل ساعة إيقاف بسيطة، فعندما ينقر المُستخدِم عليه، سيبدأ المؤقت بالعمل؛ وعندما ينقر المُستخدِم عليه مجددًا، يَعرِض الزمن بين النقرتين. يؤدي النقر لمرة ثالثة إلى بدء المؤقت من جديد، وهكذا. وبينما يكون المؤقت قيد التشغيل، يَعرِض العنوان الرسالة النصية "Timing….‎" فقط. [2] إلى أنه يضبط الباني النص المبدئي للعنوان إلى "Click to start timer"، ويُهيئ معالجًا لحدث النقر على الفأرة لكي يتمكَّن العنوان من الاستجابة إلى نقرات الفأرة. [3] يُشير إلى ما إذا المؤقت قيد التشغيل حاليًا. [4] أن المؤقت يُضبط ليَعمَل أو ليتوقف، ويُعدِّل النص المعروض بالعنوان، إذ ينبغي أن يُستدعى هذا التابع ضمن خيط تطبيق مكتبة JavaFX. يُحدِّد المعامل running ما إذا كان ينبغي أن يكون المؤقت قيد التشغيل؛ وإذا كانت قيمة المعامل تساوي حالته الحالية، لا يحدث أي شيء. [5] أنه قد أوقِف المؤقت، واحسب الزمن المنقضِي منذ لحظة بدء المؤقت واعرضه. نظرًا لأن الصنف StopWatchLabel هو صنفٌ فرعيٌ من الصنف Label، يُمكِننا تطبيق أيٍّ مما يُمكِننا فعله بكائنات الصنف Label على كائنات هذا الصنف؛ إذ يُمكِننا مثلًا إضافته إلى حاوية، أو أن نضبُط نوع الخط المُستخدَم، أو لونه، أو حجمه الأقصى، أو المُفضَّل، أو أن نضبُط تنسيق CSS الخاص به؛ كما يُمكِننا أيضًا أن نضبُط النص المعروض بداخله، مع أن ذلك يتعارض مع وظيفة ساعة الإيقاف. لاحِظ أن الصنف StopWatchLabel.java ليس تطبيقًا، ولا يمكن تشغيله بمفرده. يَستخدِم البرنامج القصير TestStopWatch.java كائنًا من ذلك الصنف، ويَضبُط مجموعةً من خاصياته لتحسين مظهره. نمط MVC يُعدّ تقسيم المسؤوليات والمهام واحدًا من أهم مبادئ التصميم كائني التوجه object-oriented design؛ إذ ينبغي أن يكون لكل كائن دورًا وحيدًا محدّدًا بوضوح ومُقيدًّا بمسؤولية معينة؛ ويُعدّ نمط نموذج-عرض-مُتحكِّم Model-View-Controller -أو اختصارًا MVC- تطبيقًا جيدًا لهذا المبدأ على تصميم واجهات المُستخدِم الرسومية، إذ يشير كُل من النموذج والعرض والمُتحكِّم، إلى واحدةٍ من المسؤوليات الثلاث الضرورية لتصميم واجهات المُستخدِم الرسومية. إذا طبقنا نمط MVC على مكون، فسيتكوَّن النموذج من البيانات المُمثِّلة للحالة الحالية للمكوِّن؛ أما العرض فسيكون ببساطةٍ هو التمثيل المرئي للمكون على الشاشة؛ بينما سيشير المُتحكِّم إلى ذلك الجزء من المكون المسؤول عن فعل ما هو ضروري نتيجةً للأحداث الصادرة عن أفعال المُستخدِم، أو عن مصادر أخرى مثل المؤقتات. يُمكِن تلخيص فكرة ذلك النمط في إسناد مسؤولية كلٍ من النموذج والعرض والمُتحكِّم إلى كائناتٍ مختلفة. من السهل فهم دور العرض view بنمط MVC، وهو يُمثَّل عادةً باستخدام كائن المكوِّن ذاته، وتتلخص مسؤوليته في رسم المكون على الشاشة؛ ولكي يتمكَّن من إنجاز ذلك، فإنه يعتمد على النموذج، وذلك لاحتواءه على حالة المكوِّن الحالية التي تؤثر بلا شك على طريقة عرض المكوِّن على الشاشة. نظرًا لأن بيانات النموذج مُخزَّنةٌ بكائنٍ منفصل طبقًا لما ينص عليه نمط MVC، فينبغي للكائن المُمثِّل للمكوِّن الاحتفاظ بمرجع reference إلى الكائن المُمثِّل للنموذج. وعندما يتغير ذلك الكائن، تكون إعادة رسم العرض ضروريةً في العادة، وذلك لتعكس الحالة الجديدة، وبالتالي يحتاج المكوِّن إلى طريقةٍ لتحديد توقيت حدوث مثل تلك التغييرات؛ وهو ما يُمكِن تحقيقه بالاستعانة بالأحداث events ومستمعي الأحداث. يُهيَأ كائن النموذج، فيُولِّد أحداثًا عند تغيُّر البيانات، ويُسجِّل كائن العرض نفسه مستمعًا لتلك الأحداث؛ وعندما يتغير النموذج، يقع حدث، ويُبلَّغ العرض بوقوعه؛ وبالتالي يكون بإمكانه الاستجابة بتحديث محتويات المكوِّن على الشاشة. عند استخدام نمط MVC مع مكونات مكتبة JavaFX، لا يكون المُتحكِّم مُنفصلًا بوضوح عن كلٍ من العرض والنموذج، إذ تُوزَّع وظيفته عادةً بين عدة كائنات. في العموم، قد يتضمَّن المُتحكِّم مستمعي أحداث الفأرة ولوحة المفاتيح المسؤولين عن الاستجابة لما يفعله المُستخدِم بالعرض؛ كما قد يتضمَّن مستمعي بعض الأحداث الأخرى عالية المستوى، مثل تلك الناتجة عن زر أو مزلاج، والتي تؤثر على حالة المكوِّن. ويَستجيب المُتحكِّم عادةً على الأحداث بإجراء تعديلات على النموذج، مما يؤدي إلى تعديل العرض مباشرةً استجابةً لتلك التغييرات التي أُجريَت على النموذج. تَستخدِم مكتبة JavaFX نمط MVC بأماكن كثيرة حتى لو لم تكن تَستخدِم مصطلحات "النموذج" و"العرض"؛ وتُعدّ الخاصيات القابلة للمراقبة observale -ألقِ نظرةً على مقال الخاصيات والارتباطات في جافا- أسلوبًا لتنفيذ فكرة النموذج المنفصل عن العرض، على الرغم من أن النموذج قد يكون موزَّعًا على كائناتٍ مختلفة كثيرة عند استخدام الخاصيات. في الواقع، ستلاحِظ وضوح دور كُلٍ من النموذج والعرض أكثر بأداتي القائمة والجدول اللتين سنناقشهما فيما يلي. صنفا القائمة ListView والجدول ComboBox يُمثِّل الصنف ListView قائمةً من العناصر التي يستطيع المُستخدِم أن يختار من بينها، كما بإمكانه أن يُعدِّل العناصر الموجودة بالقائمة. يَسمَح البرنامج SillyStamper.java للمُستخدِم باختيار أيقونة (صورة صغيرة) من قائمة أيقونات مُمثَّلةٍ بكائنٍ من النوع ListView؛ بحيث يختار المُستخدِم الأيقونة التي يرغب بها بالنقر عليها، ثم يُمكِنه أن يَطبَعها داخل الحاوية من خلال النقر على الحاوية. وفي المقابل، يُضيف النقر مع الضغط على زر Shift نسخةً أكبر من الصورة إلى الحاوية (الأيقونات المُستخدَمة بهذا البرنامج مأخوذة من مشروع سطح المكتب KDE). تَعرِض الصورة التالية نافذة البرنامج بعد أن طَبعَ المُستخدِم مجموعةً من الأيقونات فعليًا داخل مساحة الرسم، واختارَ أيقونة "star" من القائمة: ستَجِد الصنف ListView مُعرَّفًا بحزمة javafx.scene.control؛ وهو في الحقيقة صنفٌ ذو معاملات غير محددة النوع parameterized، إذ يشير معامل النوع إلى نوع الكائن المعروض بالقائمة، ويُعدّ النوع ListView<String>‎ هو الأكثر شيوعًا؛ ولكننا استخدمنا بهذا البرنامج النوع ListView<ImageView>‎، إذ تستطيع كائنات النوع ListView أن عرض السلاسل النصية من النوع String والعقد من النوع Node مباشرةً؛ وعند استخدامه مع كائنات من أنواع أخرى، فإنه يَعرِض التمثيل النصي للكائن الذي يعيده التابع toString()‎ افتراضيًا، والذي لا يكون مفيدًا في غالب الأحيان. تُخزَّن عناصر القائمة من النوع ListView<T>‎ بكائنٍ من النوع ObservableList<T>‎، إذ تُعدّ قائمة العناصر جزءًا من النموذج الخاص بالمكون، ويُمكِننا استخدام التابع listView.getItems()‎ لاسترجاع عناصر القائمة؛ وعند إضافة العناصر إلى تلك القائمة أو حذفها منها، تُحدَّث القائمة تلقائيًا لتَعكِس ذلك التغيير. يُعرِّف البرنامج SillyStamper القائمة باستخدام المُعدِّل static؛ مما يَعنِي أنه من غير الممكن تعديل القائمة بعد إنشائها، وبالتالي لا يستطيع المُستخدِم أن يُعدِّل القائمة. يقرأ البرنامج صور الأيقونات من ملفات موراد، ويحيط كل صورةٍ منها ضمن كائنٍ من النوع ImageView -ألقِ نظرةً على مقال مكونات التحكم البسيطة في واجهة المستخدم في مكتبة جافا إف إكس JavaFX-، ويُضيفه إلى قائمة العناصر بكائن الصنف ListView. تَعرِض الشيفرة التالية التابع المسؤول عن إنشاء القائمة، والذي يستدعِيه التابع start()‎ الخاص بهذا البرنامج: private ListView<ImageView> createIconList() { String[] iconNames = new String[] { // ‫أسماء ملفات الموارد بالمجلد stamper_icons "icon5.png", "icon7.png", "icon8.png", "icon9.png", "icon10.png", "icon11.png", "icon24.png", "icon25.png", "icon26.png", "icon31.png", "icon33.png", "icon34.png" }; iconImages = new Image[iconNames.length]; // لرسم الأيقونات ListView<ImageView> list = new ListView<>(); list.setPrefWidth(80); list.setPrefHeight(100); for (int i = 0; i < iconNames.length; i++) { Image icon = new Image("stamper_icons/" + iconNames[i]); iconImages[i] = icon; list.getItems().add( new ImageView(icon) ); } list.getSelectionModel().select(0); // اختر العنصر الأول بالقائمة return list; } يبدو أن الحجم المُفضَّل الافتراضي لأي قائمة هو 200 في 400 بغض النظر عن مكوناتها. ويضبُط التابع السابق العرض والطول المُفضَّلين للقائمة؛ فقائمة الأيقونات تحتاج عرضًا أصغر بكثير، كما أن الطول المُفضَّل الافتراضي يؤدي إلى زيادة طول الحاوية بقدرٍ أكبر مما هو مرغوب به، ولذلك يَضبُط التابع الطول المُفضَّل إلى قيمةٍ أصغر، مع أنها ستمتد ضمن هذا البرنامج لتملأ المساحة المُتاحة. يبدو استخدام التابع "للنموذج المُختار" ضمن القائمة مثيرًا بعض الشيء؛ ويُقصَد بذلك جزء النموذج الذي يحتوي على قائمة العناصر التي اختارها المُستخدِم من القائمة، إذ يستطيع المُستخدِم أن يختار عنصرًا واحدًا فقط على الأكثر افتراضيًا، وهو السلوك المناسب لهذا البرنامج؛ ولكن يُمكِننا عمومًا ضبطه ليسمح باختيار عدة عناصر بنفس الوقت، وذلك باستدعاء ما يلي: list.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); في حالة تطبيق وضع الاختيار الأحادي الافتراضي، يُلغَى اختيار العنصر المُختار حاليًا -إن وجد-، إذا اختار المُستخدِم عنصًرا آخرًا من القائمة؛ إذ يستطيع المُستخدِم اختيار عنصرٍ بالنقر عليه؛ كما يستطيع البرنامج ضبط الاختيار إلى عنصرٍ معينٍ باستدعاء التابع list.getSelectionModel().select(index)‎، إذ يشير المعامل index إلى فهرس العنصر المطلوب اختياره. يبدأ هنا ترقيم العناصر من الصفر، وفي حالة كان index يُساوِي -1، فسيُلغَى الاختيار من جميع العناصر. يُسلًط عرض القائمة الضوء على العنصر المُختار حاليًا، كما يستمع إلى التغييرات الحادثة بالنموذج المختار؛ فعندما ينقر المُستخدِم على عنصر، سيُعدِّل مستمع أحداث الفأرة (يُمثِل جزءًا من النموذج) النموذج المختار، ويُبلَّغ العرض بحدوث ذلك التغيير؛ وبناءً على ذلك، يُحدِّث العرض مظهره ليَعكِس حقيقة اختيار عنصرٍ آخر. يستطيع البرنامج استرجاع العنصر الواقع عليه الاختيار حاليًا باستدعاء التابع التالي: list.getSelectionModel().getSelectedIndex() يَستخدِم البرنامج SillyStamper.java التابع السابق عندما ينقر المُستخدِم على الحاوية؛ ولكي يُحدِّد أي أيقونةٍ ينبغي عليه أن يطبعها بالصورة. لاحِظ أن القائمة بالبرنامج SillyStamper.java غير قابلةٍ للتعديل، لكن يحتوي البرنامج التوضيحي الثاني EditListDemo.java على قائمتين بإمكان المُستخدِم تعديلهما: الأولى قائمة سلاسلٍ نصية، والأخرى قائمة أعداد؛ إذ يستطيع المُستخدِم أن يبدأ بتعديل عنصرٍ معينٍ ضمن القائمة بالنقر المزدوج عليه، أو بالنقر عليه مرةً واحدةً إذا كان قيد الاختيار أساسًا؛ كما يُمكِنه إنهاء عملية التعديل من خلال الضغط على مفتاح "return" أو مفتاح "escape" لإلغاء عملية التعديل. ويؤدي اختيار عنصرٍ آخر ضمن القائمة إلى إلغاء عملية التعديل. هناك بعض الأشياء التي ينبغي فعلها إذا أردنا السماح للمُستخدِم بتعديل عناصر قائمةٍ من النوع ListView. أولًا، ينبغي أن نجعل القائمة قابلةً للتعديل باستدعاء التعليمة التالية: list.setEditable(true); ولكن هذا ليس كافيًا، إذ ينبغي أن تكون كل خلية ضمن القائمة قابلةً للتعديل أيضًا؛ ويُقصَد بالخلية هنا تلك المساحة الموجودة بالقائمة، والتي يُعرَض من خلالها عنصرٌ وحيد. في العموم، كل خلية هي كائنٌ مسؤولٌ عن عرض العنصر، وبإمكانه أيضًا تعديله وفق ما يفعله المُستخدِم، ولكن لاحِظ أن تلك الخلايا لا تكون قابلةً للتعديل بالوضع الافتراضي. تَستخدِم القوائم من النوع ListView مصنع factory خلايا لإنشاء الكائنات التي تُمثِّل تلك الخلايا. ففي الواقع، يُعَد مصنع الخلايا كائنًا آخر، ووظيفته هي إنشاء الخلايا؛ ولكي نَحصل على نوعٍ مختلفٍ من الخلايا، ينبغي أن نُوفِّر للقائمة مصنع خلايا مختلف. يتبِع ذلك ما يُعرَف باسم نمط المصنع factory pattern؛ فمن خلال استخدام كائن مصنع لإنشاء الخلايا، يُمكِننا أن نُخصِّص الخلايا بسهولة دون تغيير الشيفرة المصدرية للصنف ListView، فكل ما نحتاج إليه هو مصنع خلايا جديد. في الواقع، ليس من السهل كتابة مصانع الخلايا، ولكن تُوفِّر مكتبة JavaFX لحسن الحظ مجموعةً من مصانع الخلايا القياسية. فإذا كان listView من النوع ListView<String>‎، فيُمكِننا أن نُهيِئ مصنع خلايا بإمكانه إنشاء خلايا قابلةٍ للتعديل باستدعاء ما يلي: listView.setCellFactory( TextFieldListCell.forListView() ); يُعيد التابع TextFieldListCell.forListView()‎ مصنعًا بإمكانه إنشاء خلايا تَعرِض سلاسلًا نصيةً وتُعدِّلها؛ إذ تَستخدِم الخلية عنوانًا من النوع Label أثناء عرض السلسلة النصية، وتَستخدِم حقلًا نصيًا من النوع TextField أثناء تعديل العنصر. هذا هو كل ما ينبغي أن تعرفه لكي تتمكَّن من إنشاء قائمة سلاسل نصية قابلةٍ للتعديل. وعلاوةً على ذلك، تتوفَّر أنواع عناصر أخرى يتناسب معها أيضًا عرض العنصر، مثل سلسلةٍ نصية واستخدام حقلٍ نصي من النوع TextField أثناء تعديل العنصر، مثل الأعداد والقيم المكونة من محرف واحد والتواريخ والأزمنة. ومع ذلك، إذا لم يكن العنصر سلسلةً نصيةً، فلا بُدّ من وجود طريقةٍ ما للتحويل بينه وبين تمثيله النصي. تُسهِّل مكتبة JavaFX لحسن الحظ تحقيق ذلك بالحالات الشائعة إلى حدٍ كبير، فهي تُوفِّر مُحوِّلات قياسية لجميع الأنواع المذكورة بالأعلى. على سبيل المثال، إذا كان intList قائمةً قابلةً للتعديل من النوع ListType<Integer>‎، فيُمكِننا أن نُهيِئ له مصنع خلايا مناسب باستخدام التعليمة التالية: intView.setCellFactory( TextFieldListCell.forListView( new IntegerStringConverter() ) ); إذ أن المعامل المُمرَّر للتابع forListView هو كائنٌ يُحوِّل بين الأعداد الصحيحة وتمثيلاتها النصية. ونظرًا لأن ذلك المُحوِّل القياسي لا يُعالِج المُدْخَلات غير الصالحة بطريقةٍ جيدة، فقد اِستخدَمنا بالبرنامج التوضيحي EditListDemo.java مُحوِّلًا مُخصَّصًا آخرًا -كتبه المؤلف- لمصنع الخلايا المُستخدَم بقائمة الأعداد الصحيحة ضمن البرنامج. ألقِ نظرةً على الشيفرة التالية: StringConverter<Integer> myConverter = new StringConverter<Integer>() { // 1 public Integer fromString(String s) { // حوِّل سلسلةً نصيةً إلى عدد صحيح if (s == null || s.trim().length() == 0) return 0; try { return Integer.parseInt(s); } catch (NumberFormatException e) { return null; } } public String toString(Integer n) { // حوِّل عددًا صحيحًا إلى سلسلة نصية if (n == null) return "Bad Value"; return n.toString(); } }; listView.setCellFactory( TextFieldListCell.forListView( myConverter ) ); إذ تشير [1] إلى أن مُحوِّل السلاسل النصية المُخصَّص يحوّل قيمةً نصيةً مُدخَلةً غير صالحة إلى القيمة الفارغة null بدلًا من الفشل، ويَعرِض تلك القيمة الفارغة null على هيئة "Bad Value"؛ بينما يَعرِض السلسلة النصية الفارغة على هيئة صفر. يُعرِّف الصنف StringConverter تابعين فقط، هما toString()‎ و fromString()‎. ستَجِد محولات السلاسل النصية القياسية مُعرَّفةً ضمن حزمة javafx.util.converters؛ كما ستَجِد الصنف TextFieldListCell مُعرَّفًا ضمن حزمة javafx.scene.control.cell؛ كما تتوفَّر أيضًا أصنافٌ أخرى مشابهة من أجل الخلايا المُستخدَمة مع الجداول. إلى جانب القوائم، يُنفِّذ البرنامج التوضيحي بعض الأشياء الأخرى الشيّقة المُتعلّقة بالأزرار والعناوين باستخدامه لبعض الخاصيات القابلة للمراقبة observable المُعرَّفة بنموذج القائمة -كما ناقشنا بمقال الخاصيات والارتباطات في جافا-؛ إذ يحتوي البرنامج مثلًا على عناوين تَعرِض العنصر المُختار ورقمه، وقد نفَّذ البرنامج ذلك بربط خاصية text الموجودة بالعنوان مع خاصيةٍ مُعرَّفةٍ بالنموذج المُختار الموجود بالقائمة. ألقِ نظرةً على الشيفرة التالية: Label selectedIndexLabel = new Label(); selectedIndexLabel.textProperty().bind( listView.getSelectionModel() .selectedIndexProperty() .asString("Selected Index: %d") ); Label selectedNumberLabel = new Label(); selectedNumberLabel.textProperty().bind( listView.getSelectionModel() .selectedItemProperty() .asString("SelectedItem: %s") ); لا بُدّ أن يكون الزر المسؤول عن حذف عنصر القائمة المختار حاليًا مُفعَّلًا فقط في حالة وجود عنصرٍ مُختارٍ فعلًا، إذ يُنفِّذ البرنامج ذلك بربط خاصية الزر disable على النحو التالي: deleteNumberButton.disableProperty().bind( listView.getSelectionModel() .selectedIndexProperty() .isEqualTo(-1) ); وبالتالي، تُمثِّل العناوين والأزرار بدائلًا لنفس النموذج المختار الذي تعتمد عليه القائمة، إذ يُعدّ ذلك واحدًا من أهم خاصيات نمط MVC، ألا وهو: قد تتواجد عدة عروض views لنفس الكائن المُمثِّل لنموذجٍ معين. تستمع العروض للتغييرات الحادثة بالنموذج؛ وفي حالة حدوث تعديل، تُبلَّغ العروض بالتغيير الحادث، وتُحدِّث نفسها لتعكِس الحالة الجديدة للنموذج. وبالإضافة إلى ما سبق، يحتوي البرنامج على الزر "Add" المسؤول عن إضافة عنصرٍ جديدٍ إلى القائمة، إذ يَستخدِم ذلك الزر جزءًا آخرًا من نموذج كائن الصنف ListView المُمثِّل للقائمة؛ وذلك بإضافة العنصر إلى كائنٍ قابلٍ للمراقبة من النوع ObservableList يَحمِل جميع عناصر القائمة. ونظرًا لأن كائن الصنف ListView يستمع إلى التغييرات الواقعة بتلك القائمة القابلة للمراقبة، فإنه يُبلَّغ بحدوث ذلك التغيير، وبالتالي يُمكِنه أن يُحدِّث نفسه ليعرِض العنصر الجديد ضمن القائمة. وبخلاف إضافة العنصر إلى القائمة القابلة للمراقبة، فلا حاجة لفعل أي شيءٍ آخر لإظهار العنصر على الشاشة. والآن، سنناقش أداة تحكُّم أخرى مُمثَّلةٍ بالصنف ComboBox، إذ تشبه تلك الأداة أداة التحكُّم التي يُمثِّلها الصنف ListView إلى حدٍ كبير، بل هي أساسًا نفس أداة ListView، ولكنها تُظهِر العنصر المُختار فقط؛ فعندما ينقر المُستخدِم على تلك الأداة، ستَظهَر قائمةٌ بجميع العناصر المتاحة، ويستطيع المُستخدِم أن يختار أي عنصرٍ منها. في الواقع، تَستخدِم أداة التحكُّم ComboBox كائنًا من الصنف ListView لعرض القائمة التي تظهر عند نَقْر المُستخدِم على الأداة. ولقد رأينا تلك الأداة مُستخدَمةً فعلًا بهيئة قائمة ببعض الأمثلة السابقة، مثل البرنامج GUIDemo.java بمقال واجهة المستخدم الحديثة في جافا. بالمثل من الصنف ListView، إذ يُعدّ الصنف ComboBox نوعًا ذا معاملات غير محدَّدة النوع، ويُعدّ نوع العنصر String هو الأكثر اِستخدامًا معها، على الرغم من دعمها لأنواع عناصر أخرى (باستخدام مصانع الخلايا ومحوّلات السلاسل النصية). يُمكِننا إنشاء أداة تحكُّم من النوع ComboBox وإدارتها بنفس طريقة إنشاء وإدارة أداة التحكُّم ListView. ألقِ نظرةً على الشيفرة التالية على سبيل المثال: ComboBox<String> flavors = new ComboBox<>(); flavors.getItems().addAll("Vanilla", "Chocolate", "Strawberry", "Pistachio"); flavors.getSelectionModel().select(0); يُمكِننا ضبط تلك الأداة لتُصبِح قابلةً للتعديل، ولسنا بحاجةٍ إلى مصنع خلايا مُخصَّص لذلك الغرض طالما كانت العناصر المُستخدَمة من النوع String، وتكون الأداة في هذه الحالة أشبه بتركيبةٍ غريبة تجمع بين الحقل النصي والقائمة؛ إذ تَستخدِم حقلًا نصيًا لعرض العنصر المُختار بدلًا من استخدام عنوان. إلى جانب ذلك، يمكن للمُستخدِم تعديل قيمة الحقل النصي، وستُصبِح القيمة المُعدَّلة هي القيمة المختارة، ولكن لاحظ أن القيمة الأصلية للعنصر المُعدَّل لا تُحذَّف من القائمة، وإنما يُضاف العنصر الجديد فقط، كما أن العنصر الجديد لا يُصبِح جزءًا دائمًا من القائمة. يؤدي استدعاء التابع flavors.setEditable(true)‎ في المثال السابق مثلًا، إلى السماح للمُستخدِم بكتابة "Rum Raisin,‎" أو أي شيء آخر على أنها نكهةٌ مُفضَّلة، ولكنه لا يحلّ محل العنصر "Vanilla"، أو "Chocolate"، أو "Strawberry"، أو "Pistachio" الموجودين بالقائمة. بخلاف كائنات الصنف ListView، تُولِّد كائنات الصنف ComboBox حدثًا من النوع ActionEvent عندما يختار المُستخدِم عنصرًا جديدًا سواءٌ فَعَلَ ذلك باختيار العنصر من القائمة، أو بكتابة العنصر على أنه قيمةٌ جديدةٌ بالصندوق القابل للتعديل، ثم الضغط على "return". الصنف TableView بالمثل من أداة التحكُّم بالقائمة المُمثَّلة بالصنف ListView، تَعرِض أداة تحكُّم "الجدول" المُمثَّلة بالصنف TableView تجميعةً من العناصر للمُستخدِم، ولكنها أكثر تعقيدًا، إذ تُرتَّب عناصر الجدول ضمن شبكةٍ من الصفوف والأعمدة، ويُمثِّل كل موضعٍ بالشبكة "خليةً" ضمن الجدول. يحتوي كل عمودٍ هنا على متتاليةٍ من العناصر، ويملُك رأسًا يقع أعلى العمود ويحتوي على اسمه. وفي العموم، يتشابه العمل مع عمودٍ واحد ضمن كائن الصنف TableView مع العمل مع كائن الصنف ListView من جوانب كثيرة. يُعدّ الصنف TableView<T>‎ نوعًا ذا معاملات غير مُحدَّدة النوع، إذ يحتوي كائن معامل النوع T على جميع البيانات المتعلقة بصف واحد ضمن الجدول، ويُمكِنه أن يتضمَّن بيانات إضافية أيضًا؛ فيمكن للجدول أن يَكون "عرضًا view " لبعض البيانات المُتاحة فقط. ينتمي نموذج البيانات الخاص بجدول من النوع TableView<T>‎ إلى الصنف ObservableList<T>‎، ويُمكِننا استرجاعه باستدعاء التابع table.getItems()‎، كما يُمكِننا أيضًا إضافة الصفوف إلى الجدول وحذفها منه بإضافة العناصر وحذفها من تلك القائمة. لكي نُعرِّف جدولًا: لا يكون تحديد الصنف المُمثِّل لصفوف الجدول كافيًا، فعلينا أيضًا أن نحدِّد نوع البيانات التي ننوي تخزينها بكل عمود ضمن الجدول؛ لذلك سنَستخدِم كائنًا من النوع TableColumn<T,S>‎ لوصف كل عمود ضمن الجدول، إذ يشير معامل النوع الأول T إلى نفس نوع معامل النوع الخاص بالجدول، بينما يشير معامل النوع الثاني S إلى نوع العناصر التي ننوي تخزينها بخلايا ذلك العمود. يشير النوع TableColumn<T,S>‎ إلى كون العمود يَعرِض عناصرًا من النوع S مشتقةً من صفوفٍ من النوع T. لا تحتوي الكائنات المُمثِّلة للعمود على العناصر المعروضة بالعمود، فهم موجودون بكائنات النوع T التي تُمثِّل الصفوف، ومع ذلك تحتاج كائنات الأعمدة إلى طريقةٍ لاسترجاع العنصر المعروض بالعمود من الكائن المُمثِّل للصف؛ إذ يُمكِننا إجراء ذلك بتخصيص ما يُعرَف باسم "مصنع قيم الخلايا"، فيُمكِننا مثلًا أن نكتب مصنعًا لتطبيق أي دالةٍ function على كائنٍ مُمثِّل لصف، ولكن الصنف PropertyValueFactory يُعدّ هنا الخيار الأكثر شيوعًا، والذي يسترجِع ببساطة قيمة إحدى خاصيات كائن الصف. والآن لنفحص مثالًا. يَعرِض البرنامج التوضيحي SimpleTableDemo.java جدولًا غير قابلٍ للتعديل، ويحتوي الجدول على أسماء الولايات الخمسين الموجودة بالولايات المتحدة الأمريكية مع عواصمها وتعدادها السكاني. ألقِ نظرةً على الصورة التالية: يَستخدِم البرنامج كائناتٍ تنتمي إلى الصنف StateData المُعرَّف على أنه صنفٌ متداخلٌ ساكنٌ عام لحَمْل بيانات كل صف. لا بُدّ أن يكون الصنف عامًا لكي نتمكَّن من اِستخدَامه مع الصنف PropertyValueFactory، ولكن ليس من الضروري أن يكون متداخلًا أو ساكنًا. سنُعرِّف قيم بيانات كل صف على أنها خاصيات ضمن ذلك الصنف، أي سيكون هنالك تابع جَلْب getter لكل قيمةٍ منها. في الحقيقة، يُعدّ تعريف الخاصيات باستخدام توابع جلب كافيًا لاستخدامها مثل قيمٍ بجدولٍ غير قابل للتعديل، كما سنحتاج إلى شيء مختلف بالنسبة لأعمدة الجداول القابلة للتعديل كما سنرى لاحقًا. ألقِ نظرةً على تعريف الصنف: public static class StateData { private String state; private String capital; private int population; public String getState() { return state; } public String getCapital() { return capital; } public int getPopulation() { return population; } public StateData(String s, String c, int p) { state = s; capital = c; population = p; } } سنُنشِئ الجدول المسؤول عن عرض بيانات الولايات على النحو التالي: TableView<StateData> table = new TableView<>(); بعد ذلك سنضيف إلى نموذج بيانات الجدول عنصرًا لكل ولاية، والذي يُمكِننا استرجاعه باستدعاء التابع table.getItems()‎؛ ثم سنُنشِئ الكائنات المُمثِّلة للأعمدة ونُهيئها ونضيفها إلى نموذج أعمدة الجدول، والذي يُمكِننا استرجاعه باستدعاء التابع table.getColumns()‎. ألقِ نظرةً على الشيفرة التالية: TableColumn<StateData, String> stateCol = new TableColumn<>("State"); stateCol.setCellValueFactory( new PropertyValueFactory<StateData, String>("state") ); table.getColumns().add(stateCol); TableColumn<StateData, String> capitalCol = new TableColumn<>("Capital City"); capitalCol.setCellValueFactory( new PropertyValueFactory<StateData, String>("capital") ); table.getColumns().add(capitalCol); TableColumn<StateData, Integer> populationCol = new TableColumn<>("Population"); populationCol.setCellValueFactory( new PropertyValueFactory<StateData, Integer>("population") ); table.getColumns().add(populationCol); يُمثِّل المعامل المُمرَّر لباني الصنف TableColumn النص المعروض برأس العمود. وبالنسبة لمصانع قيم الخلايا، يحتاج أي مصنعٍ منها إلى قراءة قيمة الخلية من كائن صفٍ ينتمي إلى النوع StateData؛ أما بالنسبة للعمود الأول، فنوع البيانات هو String، وبالتالي ينبغي أن يَستقبِل المصنع مُدْخَلًا من النوع StateDate ويُخرِج قيمة خاصية من النوع String. بالتحديد، الخرج هو قيمة الخاصية state المُعرَّفة ضمن كائن الصنف StateData. بالتالي، يُمكِننا كتابة الباني على النحو التالي: new PropertyValueFactory<StateData, String>("state") يُنشِئ الاستدعاء السابق مصنع قيم خلايا يَحصُل على القيمة التي سيَعرِضها بالخلية باستدعاء obj.getState()‎، إذ أن obj هو الكائن المُمثِّل لصف الجدول المُتضمِّن للخلية. وقد خصَّصنا العمودين الآخرين بنفس الطريقة. هذا هو كل ما تحتاج إلى معرفته لكي تتمكَّن من إنشاء جدولٍ لا يستطيع المُستخدِم أن يُعدِّل محتويات خلاياه. يمكن للمُستخدِم افتراضيًا تعديل طول عَرْض العمود من خلال سحب الفاصل الموجود بين رأسي أي عمودين؛ كما بإمكانه أن ينقر على رأس أي عمود لكي يُرتِّب صفوف الجدول ترتيبًا تصاعديًا أو تنازليًا وفقًا لقيم ذلك العمود؛ وبإمكاننا مع ذلك تعطيل هاتين الخاصيتين بضبط بعض الخاصيات المُعرَّفة بكائن الصنف TableColumn -وهو ما سنفعله بالمثال التالي-؛ كما يستطيع المُستخدِم أيضًا إعادة ترتيب الأعمدة بسحب رأس العمود إلى اليمين أو اليسار. يتضمَّن البرنامج التوضيحي ScatterPlotTableDemo.java مثالًا على جدول قابل للتعديل، إذ يُمثِّل كل صفٍ ضمن الجدول نقطةً على سطح المستوى، ويحتوي العمودين على الإحداثي الأفقي والرأسي للنقاط. يَعرِض البرنامج تلك النقاط ضمن مخطط انتشار بياني scatter plot داخل حاوية، إذ يَرسِم تقاطعًا صغيرًا عند موضع كل نقطة. وتُوضِّح الصورة التالية لقطة شاشة من البرنامج أثناء تعديل الإحداثي الأفقي لإحدى النقاط: سنحتاج إلى نوع بيانات لتمثيل صفوف الجدول، والذي قد يكون صنفًا بسيطًا يحتوي على خاصيتين x و y لتمثيل إحداثيات النقطة؛ ولكن نظرًا لأننا نريد عمودًا قابلًا للتعديل، فلا نستطيع استخدام خاصياتٍ بسيطة مُعرَّفة بتوابع جَلْب وضبط، وإنما لا بُدّ أن تكون الخاصيات قابلةً للمراقبة. بالتحديد، لا بُدّ أن يَتبِّع الصنف نمط مكتبة JavaFX للخاصيات القابلة للمراقبة والتي تنص على مايلي: ينبغي أن تُخزَّن قيم الخاصيتين x و y بكائنات خاصيات قابلة للمراقبة، كما ينبغي أن يتضمَّن الكائن المُمثِّل للنقطة، وليكن اسمه pt، توابع النسخ pt.xProperty()‎ و pt.yProperty()‎؛ إذ تعيد تلك التوابع كائنات الخاصيات القابلة للمراقبة، لكي تُستخدَم بضبط قيم الخاصيات واسترجاعها. وبما أن تلك الخاصيات تُخزِّن قيمًا من النوع double، فإن تلك الكائنات ستكون من النوع DoubleProperty. يُمكِننا تعريف صنف البيانات للجدول على النحو التالي: public static class Point { private DoubleProperty x, y; public Point(double xVal, double yVal) { x = new SimpleDoubleProperty(this,"x",xVal); y = new SimpleDoubleProperty(this,"y",yVal); } public DoubleProperty xProperty() { return x; } public DoubleProperty yProperty() { return y; } } يُعَد الصنف DoubleProperty صنفًا مجرَّدًا abstract؛ أما الصنف SimpleDoubleProperty، فهو صنفٌ فرعيٌ حقيقي concrete يتطلَّب بانيه constructor كُلًا من الكائن المُتضمِّن للخاصية واسم الخاصية والقيمة المبدئية لتلك الخاصية؛ وفي المقابل، يُوفِّر الصنف أوتوماتيكيًا مستمعي أحداث التغيير وانعدام الصلاحية invalidation الخاصين بتلك الخاصية. بعد تعريفنا للصنف Point، يُمكِننا إنشاء الجدول وإضافة بعض النقاط العشوائية إليه على النحو التالي: table = new TableView<Point>(); points = table.getItems(); for (int i = 0; i < 5; i++) { // أضف خمس نقاط عشوائية إلى الجدول points.add( new Point(5*Math.random(), 5*Math.random()) ); } عند إضافة نقطة إلى الجدول أو حذف نقطة منه، فلا بُدّ من إعادة رسم الحاوية، ولذلك سنضيف مستمعًا إلى القائمة points، التي تَعمَل مثل نموذج بيانات للجدول: points.addListener( (Observable e) -> redrawDisplay() ); لاحِظ تصريحنا عن كون المعامل e بتعبير لامدا السابق lambda expression من النوع Observable؛ وذلك لأن القائمة القابلة للمراقبة تتضمَّن نسختين من التابع addListener()‎، وكلاهما يَستقبِل مُعاملًا واحدًا بتعبير لامدا. وبالتالي يُمكِّن التصريح عن نوع e المُصرِّف من معرفة النسخة التي نريد استدعاءها، فنحن نضيف مستمعًا من النوع InvalidationListener لا من النوع ListChangeListener. وبذلك نكون قد ضبطنا الحاوية لكي تُعيد رسم نفسها بمجرد إضافة نقطةٍ إلى الجدول أو حذف نقطةٍ منه، ولكننا لم نضبطها بعد لتفعل ذلك عند تعديل إحدى النقاط الموجودة بالجدول؛ لأن ذلك لا يُمثِّل تغييرًا ببنية القائمة، وإنما يُمثِّل تغييرًا ضمن إحدى الكائنات الموجودة بالقائمة. لكي نتمكَّن من الإستجابة لتلك التغييرات أيضًا، يُمكِننا مثلًا إضافة مستمعين إلى الخاصيتين القابلتين للمراقبة المُعرَّفتين بكل كائنٍ من النوع Point. في الواقع، هذا هو ما يفعله الجدول أساسًا لكي يستجيب إلى التغييرات الحادثة بأي نقطة ضمن الجدول، ولكننا لن نتبِع هذا الأسلوب؛ إذ سنضبُط البرنامج بدلًا من ذلك ليستمع إلى نوعٍ آخر من الأحداث، التي ستمكِّنه أيضًا من معالجة تعديلات خلايا الجدول. يتضمَّن كل جدول خاصيةً قابلةً للمراقبة اسمها editingCell، والتي تحتوي على الخلية التي يُجرَى تعديلها حاليًا أو القيمة الفارغة null، إذا لم تكن هناك أي خليةٍ قيد التعديل. عندما تتغير قيمة تلك الخاصية إلى القيمة الفارغة null، يَعنِي ذلك أن هناك عملية تعديل لخليةٍ ما ضمن الجدول قد اكتملت، وبالتالي سنضبُط الحاوية لكي تعيد رسم نفسها بعد كل عملية تعديل من خلال تسجيل مستمعٍ إلى حدث التغيير بالخاصية editingCell على النحو التالي: table.editingCellProperty().addListener( (o,oldVal,newVal) -> { if (newVal == null) { redrawDisplay(); } }); والآن، لكي نُنهِي تعريف الجدول، ينبغي أن نُعرِّف العواميد؛ إذ سنحتاج إلى مصنع قيم خلايا لكل عمود، شرط أن يُنشَأ باستخدام مصنع قيم خاصيات. يتبِّع ذلك نفس النمط الذي اِستخدَمناه بالمثال السابق، ونظرًا لأن العمود هنا قابلٌ للتعديل، فسنحتاج إلى مصنع خلايا أيضًا كما فعلنا تمامًا بمثال القوائم القابلة للتعديل بالأعلى. يُمكِننا إذًا إنشاء مصنع خلايا باستخدام التعليمة التالية: TextFieldTableCell.forTableColumn(myConverter) إذ أن المعامل myConverter من النوع StringConverter<Double>‎؛ وسيكون من الأفضل بهذا البرنامج لو منعنا المُستخدِم من تغيير حجم الأعمدة أو تغيير ترتيبها. تتضمَّن الشيفرة التالية كل ما هو مطلوب لضبط إحدى العواميد: TableColumn<Point, Double> xColumn = new TableColumn<>("X Coord"); xColumn.setCellValueFactory( new PropertyValueFactory<Point, Double>("x") ); xColumn.setCellFactory( TextFieldTableCell.forTableColumn(myConverter) ); xColumn.setSortable(false); xColumn.setResizable(false); xColumn.setPrefWidth(100); // الحجم الافتراضي صغير للغاية table.getColumns().add(xColumn); بقي لنا الآن ضبط الجدول ليكون قابلًا للتعديل، وذلك باستدعاء التابع table.setEditable(true)‎. ربما ترى أننا قد اضطررنا لفعل كثيرٍ من الأشياء لمجرد إنشاء جدول خصوصًا إذا كان قابلًا للتعديل؛ ولكن الجداول أكثر تعقيدًا من ذلك بكثير، والشيفرة التي تتطلَّبها مكتبة JavaFX لتهيئة جدول أقل بكثير مما يتطلَّبه تنفيذ جدولٍ من الصفر مباشرةً. بالمناسبة، عليك أن تنتبه للطريقة التي استخدمنا بها نمط MVC ضمن هذا البرنامج، إذ يُعَد مخطط الانتشار البياني عرضًا view بديلًا لنفس نموذج البيانات المعروض بالجدول؛ كما تُستخدَم البيانات من النموذج عند إعادة رسم الحاوية، ويَحدُث ذلك استجابةً للأحداث النابعة عن أي تعديلات بالنموذج. قد يُفاجئك ذلك، ولكننا لا نحتاج إلى إضافة ما هو أكثر من ذلك لكي نضمَّن استمرار عرض مخطط الانتشار البياني لنفس البيانات على نحو صحيح. سيكون أيضًا من الأفضل لو ألقيت نظرةً على شيفرة البرنامج ScatterPlotTableDemo.java، وستجدها موثقةً جيدًا. بالإضافة إلى فَحْص الصنف TableView؛ كما يُمكِنك كذلك إلقاء نظرةٍ على الطريقة التي اِستخدَمنا بها التحويلات transforms لرسم مخطط الانتشار البياني. ترجمة -بتصرّف- للقسم Section 3: Complex Components and MVC من فصل Chapter 13: GUI Programming Continued من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: أمثلة عن رسوميات فاخرة باستعمال جافا التخطيط الأساسي لواجهة المستخدم في مكتبة جافا إف إكس JavaFX
  4. لقد رأينا أمثلةً كثيرةً على طريقة استخدام كائن سياق رسومي من النوع GraphicsContext للرسم ضمن حاوية، ولكنه يمتلك في الحقيقة ميزات أخرى كثيرة إلى جانب تلك التي تناولناها من قبل. سنناقش خلال هذا القسم رسوم الحاوية، وسنبدأ بفحص تابعين مفيدين لإدارة حالة كائن سياق رسومي. يمتلك كائنٌ g من النوع GraphicsContext خاصيات متعددة، مثل لون المِلء وعرض الخط، وتؤثر تلك الخاصيات على جميع ما يرسمه الكائن. من المهم أن تتذكر أن أي حاوية تملك كائن سياق رسومي وحيد، وأن أي تغيير يُجرَى على إحدى خاصيات ذلك الكائن، سيُطبَّق على جميع رسومه المُستقبلية إلى أن تتغير قيمة خاصياته مرةً أخرى؛ أي أن تأثير أي تغيير على خاصياته يتجاوز حدود البرنامج الفرعي subroutine الذي نُفَّذت خلاله. ومع ذلك، يحتاج المبرمجون عادةً إلى تغيير قيمة بعض الخاصيات تغييرًا مؤقتًا، بحيث تُعاد إلى قيمها السابقة بعد انتهائهم؛ ولهذا، تتضمَّن واجهة برمجة التطبيقات الخاصة بالرسوميات التابعين g.save()‎ و g.restore()‎ لتنفيذ ذلك بسهولة. يُخزِّن التابع g.save()‎ عند تنفيذه حالة كائن السياق الرسومي، والتي تتضمَّن جميع الخاصيات التي تؤثر على الرسوم تقريبًا. في الواقع، يَملُك كائن السياق الرسومي مكدسًا stack.) للحالات -ألقِ نظرةً على مقال المكدس Stack والرتل Queue وأنواع البيانات المجردة ADT-، بحيث يُخزِّن التابع g.save()‎ حالة الكائن الحالية بالمكدس؛ وفي المقابل، يَسحَب التابع g.restore()‎ عند استدعائه الحالة الموجودة أعلى المكدس، ويَضبُط قيم جميع خاصيات الكائن لتتوافق مع القيم المخزَّنة بالحالة المسحوبة. نظرًا لاستخدام كائن السياق الرسومي مكدس حالات، فمن الممكن استدعاء التابع save()‎ عدة مرات قبل استدعاء التابع restore()‎، ولكن لا بُدّ أن يُقابِل كل استدعاءٍ للتابع save()‎ استدعاءً للتابع ()restore. ومع ذلك، لا يؤدي استدعاء restore()‎ بدون استدعاء سابق مقابل للتابع save()‎ إلى حدوث خطأ؛ بل يحدث فقط تجاهُلٌ لتلك الاستدعاءات الإضافية. يُعدّ استدعاء التابع save()‎ ببداية البرنامج الفرعي والتابع restore()‎ بنهايته، الطريقة الأسهل عمومًا لضمان عدم تجاوز التغييرات المُجراة على كائن السياق الرسومي ضمن برنامج فرعي معين ما يليه من استدعاءات. تبرز أهمية حفظ حالة السياق الرسومي واستعادتها لاحقًا أثناء التعامل مع التحويلات transforms، والتي سنناقشها لاحقًا ضمن هذا القسم. رسم حواف الأشكال بطريقة فاخرة لقد رأينا طريقة رسم حواف الخطوط والمنحنيات وحتى خطوط المحارف النصية، وكذلك طريقة ضبط كُلٍ من لون وحجم الخط المُستخدَم لرسم تلك الحواف strokes. في الواقع، تتوفَّر خاصيات أخرى تؤثر على طريقة رسم الحواف؛ فيُمكِننا مثلًا أن نرسمها بخطوط مُنقطّة أو متقطعة، كما يُمكننا أن نتحكَّم بمظهر نهاية الحواف، وبمظهر نقطة التلاقي بين خيطين أو منحنين، مثل تقابُل جانبي مستطيل بإحدى أركانه. يحتوي كائن السياق الرسومي على خاصيات للتحكُّم بجميع تلك الخاصيات، ولكن من الضروري استخدام خطوطٍ عريضة بما يكفي حتى نتمكَّن من ملاحظة تاثير خاصيات، مثل تلك التي تتحكَّم بمظهر نهاية الحواف ونقط التلاقي. تَعرِض الصورة التالية بعض الخيارات المتاحة: تُبيّن نهايتي القطع المستقيمة الثلاثة على يسار الصورة الأنماط الثلاثة المحتملة للخط "cap.‎"، كما ستَجِد الحافة باللون الأسود؛ أما الخط الهندسي، فهو بلون أصفر داخل الحافة. عند استخدام النمط BUTT، تُقطَّع نهايتي الخط الهندسي؛ أما عند استخدام النمط الدائري ROUND، يُضاف قرصٌ بكل نهاية قطره يساوي عرض الخط؛ بينما عند استخدام النمط المربع SQUARE، يُضاف مربعٌ بدلًا من القرص. وما تحصل عليه عند استخدام النمط الدائري أو المربع هو نفس ما تحصل عليه عند رسم حافة بقلم رأسه دائري أو مربع على الترتيب. إذا كان g كائن سياق رسومي، فسيضبُط التابع g.setLineCap(cap)‎ نمط الخط cap المُستخدَم لرسَم الحواف، إذ يَستقبِل التابع معاملًا من نوع التعداد StrokeLineCap المُعرَّف بحزمة javafx.scene.shape، والتي قيمه المحتملة هي StrokeLineCap.BUTT و StrokeLineCap.ROUND والقيمة الافتراضية StrokeLineCap.SQUARE. تَعمَل نقط التلاقي بنفس الطريقة، إذ يَضبُط التابع g.setLineJoin(join)‎ مظهر النقطة التي يلتقي عندها خيطان أو منحنيان، ويَستقبِل التابع في تلك الحالة كائنًا من النوع StrokeLineJoin، وقيمه المحتملة هي القيمة الافتراضية StrokeLineJoin.MITER و StrokeLineJoin.ROUND و StrokeLineJoin.BEVEL؛ إذ تعرِض الصورة السابقة هذه الأنماط الثلاثة بالمنتصف. عند اِستخدام النمط MITER، تمتد القطعتان المستقيمتان لتكوين نقطة حادة؛ أما عند اِستخدَام النمطين الآخرين، فسيُقطّع الركن. يكون التقطيع بالنسبة للنمط BEVEL باستخدام قطعة مستقيمة؛ أما بالنسبة للنمط ROUND، فسيكون التقطيع باستخدام قوس أو دائرة. تبدو نقط التلاقي الدائرية أفضل إذا رسمت منحنيًا كبيرًا بهيئة سلسلةٍ من القطع المستقيمة القصيرة. يُمكِننا استخدام التابع g.setLineDashes()‎ لتطبيق نمط تقطيع يُظهِر الحواف على نحوٍ مُنقّط أو متقطِّع، إذ تُمثِّل معاملات هذا التابع أطوال القطع والمسافات الفاصلة بينها: g.setLineDashes( dash1, gap1, dash2, gap2, . . . ); لاحِظ أن معاملات التابع هي من النوع double، ويُمكِن تمريرها أيضًا مثل مصفوفةٍ من النوع double[]‎. وإذا رسمنا حافةً معينةً بعد اختيار أيٍّ من أنماط التقطيع، فسيتكوّن الشكل من خطٍ أو منحنًى طوله يُساوِي dash1، متبوعًا بمسافةٍ فارغة طولها gap1، والتي يتبعها خط أو منحنى طوله dash2، وهكذا، وسيُعاد تكرار نفس نمط الخطوط والفراغات بما يكفي لرسم طول الحافة بالكامل. على سبيل المثال، يرسِم الاستدعاء g.setLineDashes(5,5)‎ الحافة مثل متتاليةٍ من القطع القصيرة التي يبلُغ طول كل منها 5 ويَفصِل بينها مسافةً فارغةً طولها يُساوِي 5؛ بينما يَرسِم الاستدعاء g.setLineDashes(10,2)‎ متتاليةً من القطع المستقيمة الطويلة، بحيث يَفصِل بينها مسافات قصيرة. يُمكِن تخصيص نمطٍ مُكوَّن من قطعٍ مستقيمة ونقط باستدعاء التابع g.setLineDashes(10,2,2,2)‎، ويتكوَّن النمط المتقطع الافتراضي من خطٍ بدون أي نقاط أو فواصل. يَسمح البرنامج التوضيحي StrokeDemo.java للمُستخدِم برسم خطوط ومستطيلات باستخدام تشكيلةٍ مختلفة من أنماط الخطوط. ألقِ نظرةً على الشيفرة المصدرية لمزيدٍ من التفاصيل. تلوين فاخر لقد أصبح بإمكاننا رسم حوافٍ فاخرة الآن. ربما لاحظت أن كل عمليات الرسم كانت مقيدةً بلونٍ واحدٍ فقط، ولكن يُمكِننا في الواقع تجاوز ذلك باستخدام الصنف Paint؛ إذ تُستخدَم كائنات هذا الصنف لإسناد لونٍ لكل بكسل نمرُ عليه أثناء الرسم. وفي الواقع، يُعدّ الصنف Paint صنفًا مجرّدًا abstract، وهو مُعرَّف بحزمة javafx.scene.paint. يُعدّ الصنف Color واحدًا فقط من ضمن الأصناف الفرعية الحقيقية المشتقة من الصنف Paint، أي يُمكِننا أن نُمرِّر أي كائن من النوع Paint مثل معاملٍ للتابعين g.setFill()‎ و g.setStroke()‎. وعندما يكون الكائن المُمرَّر من النوع Color، سيُطبَّق نفس اللون على جميع البكسلات التي تَمُرّ عبرها عملية الرسم، ولكن هنالك بالطبع أنواع أخرى يَعتمِد فيها اللون المُطبَّق على بكسلٍ معين على إحداثياته. تُوفِّر مكتبة JavaFX عدة أصناف بهذه الخاصية، مثل ImagePattern، ونوعين آخرين للتلوين المُتدرِج؛ فبالنسبة للصنف الأول ImagePattern، يُستخرَج لون البكسل من صورة مكررة -إن اقتضت الضرورة- مثل ورق حائط حتى تُغطِي سطح المستوى xy بالكامل؛ أما بالنسبة للتلوين المُتدرِج، فيتغير اللون المُطبَّق على البكسلات تدريجيًا من لونٍ لآخر بينما ننتقل من نقطة لأخرى. تُوفِّر جافا صنفين من هذا النوع، هما LinearGradient و RadialGradient. سيكون من المفيد لو اطلعنا على بعض الأمثلة، إذ تعرض الرسمة التالية مضلعًا ملونًا بأسلوبين مختلفين؛ ويَستخدِم المضلع الموجود على اليسار الصنف LinearGradient، بينما يَستخدِم المضلع الموجود على اليمين الصنف ImagePattern. لاحِظ أن اللون قد اُستخدَم هنا لملء المضلع ذاته فقط، أما حواف المُضلع فقد رُسمَت بلونٍ أسود عادي. ومع ذلك، يُمكِننا استخدام كائنات الصنف Paint لرسم حواف الأشكال وملئها أيضًا. ستَجِد شيفرة رسم المضلعين بالبرنامج PaintDemo.java، إذ يُمكِّنك هذا البرنامج من الاختيار بين عدة أنماط تلوين مختلفة، إلى جانب التحكًّم ببعض من خاصيات تلك الأنماط. إذا اخترنا استخدام الصنف ImagePattern، فسنحتاج إلى صورة أولًا. لقد تعرَّضنا للصنف Image بالقسم الفرعي 6.2.3، وتعلمنا طريقة إنشاء كائن من النوع Image من ملف مورد. وبفرض أن pict هو كائنٌ يُمثِّل صورةً من النوع Image، يُمكِننا إنشاء كائنٍ من النوع ImagePattern باستخدام باني الكائن على النحو التالي: patternPaint = new ImagePattern( pict, x, y, width, height, proportional ); تُمثِّل المعاملات x و y و width و height قيمًا من النوع double، تتحكَّم بكُلٍ من حجم الصورة وموضعها بالحاوية؛ إذ تُوضَع نسخةٌ واحدةٌ من الصورة بالحاوية، بحيث يقع ركنها الأيسر العلوي بنقطة الإحداثيات (x,y)، وتمتد وفقًا للطول والعرض المُخصَّصين. بعد ذلك، تتكرر الصورة أفقيًا ورأسيًا عدة مرات بما يكفي لملء الحاوية بالكامل، ولكنك ترى فقط الجزء الظاهر عبر الشكل المطلوب تطبيق نمط التلوين عليه. يُمثِّل المعامل الأخير للباني proportional قيمةً من النوع boolean، وتُخصِّص طريقة تفسير المعاملات الأخرى x و y و width و height؛ فإذا كانت قيمة proportional تُساوِي false، فسيُقاس كُلٌ من width و height باستخدام نظام الإحداثيات المعتاد؛ أما إذا كانت قيمته تساوي true، فسيُقاسان باستخدام مضاعفاتٍ من حجم الشكل المطلوب تطبيق نمط التلوين عليه، وستكون (x,y) مساويةً (0,0) في الركن الأيسر العلوي للشكل (بتعبير أدق، المستطيل المُتضمِّن للشكل). انظر ما يلي على سبيل المثال: patternPaint = new ImagePattern( pict, 0, 0, 1, 1, true ); يُنشِيء هذا الباني كائنًا من النوع ImagePattern، بحيث تُغطِي نسخةٌ واحدةٌ من الصورة الشكل بالكامل. وإذا طبقنا نمط التلوين ذاك على عدة أشكال بأحجام مختلفة، فستمتد الصورة بما يتناسب مع كل شكل. وفي المقابل، إذا أردنا أن يحتوي الشكل على أربع نسخ من الصورة أفقيًا ونسختين رأسيًا، فيُمكِننا استخدام ما يلي: patternPaint = new ImagePattern( pict, 0, 0, 0.25, 0.5, true ); في المقابل، يُحدَّد نمط التلوين المُتدرِج الخطي من خلال تخصيص قطعةٍ مستقيمة ولون عدة نقاط على طول تلك القطعة؛ إذ يُطلَق على تلك النقاط وألوانها اسم وقفات الألوان color stops، وتُضاف الألوان بينها، بحيث يُسنَد لونٌ معينٌ لكل نقطة على الخط، كما تمتد الألوان عموديًا على القطعة المستقيمة لتنتج شريطًا ملونًا لا نهائي. ينبغي أيضًا أن نُخصِّص ما يحدث خارج ذلك الشريط، وهو ما يُمكِننا فعله بتخصيص ما يُعرَف باسم "أسلوب التكرار cycle method" بمكتبة JavaFX؛ إذ تشتمل قيمه المحتملة على مايلي: الثابت CycleMethod.REPEAT، الذي يُكرِّر الشريط الملون بما يكفي لتغطية سطح المستوى بالكامل. الثابت CycleMethod.MIRROR الذي يكرِّر أيضًا الشريط الملون، ولكنه يَعكِس كل تكرارٍ منه لتتوافق الألوان الموجودة على أطراف كل تكرار مع بعضها. الثابت CycleMethod.NO_REPEAT، الذي يَمِدّ اللون الموجود على كل طرف لا نهائيًا. تَعرِض الصورة التالية ثلاثة أنماط تلوين مُتدرِج تستخدِم جميعها نفس خاصيات القطعة المستقيمة ووقفات الألوان، إذ تَستخدِم تلك الموجودة على يسار الصورة أسلوب التكرار MIRROR؛ بينما تَستخدِم الموجودة بمنتصف الصورة أسلوب التكرار REPEAT؛ في حين تَستخدِم تلك الموجودة على اليمين أسلوب التكرار NO_REPEAT. رسمنا القطعة المستقيمة ووضعنا علامات على مواضع وقفات الألوان على طول تلك القطعة، كما هو موضح في الشكل التالي: يُنشِئ الباني التالي نمط تلوين متدرج: linearGradient = new LinearGradient( x1,y1, x2,y2, proportional, cycleMethod, stop1, stop2, . . . ); تُمثِّل المعاملات الأربعة الأولى قيمًا من النوع double، إذ تُخصِّص نقطتي البداية والنهاية للقطعة المستقيمة (x1,y1) و (x2,y2)؛ أما المعامل الخامس proportional، فهو من النوع boolean؛ فإذا كانت قيمته تساوي false، فستُفسَّر نقطتي البداية والنهاية باستخدام نظام الإحداثيات المعتاد؛ أما إذا كانت قيمته تساوي true، فإنها تُفسَّر باستخدام نظام إحداثيات تقع نقطته (0,0) في الركن الأيسر العلوي للشكل المطلوب تطبيق نمط التلوين عليه، بينما تقع نقطته (1,1) في الركن الأيمن السفلي لنفس الشكل. يُمثِّل المعامل السادس cycleMethod إحدى الثوابت CycleMethod.REPEAT و CycleMethod.MIRROR و CycleMethod.NO_REPEAT؛ بينما تشير المعاملات المتبقية إلى وقفات الألوان، ويُمثَل كل وقفةٍ منها كائنًا من النوع Stop. يستقبل باني الصنف Stop معاملين من النوع double و Color؛ إذ يُخصِّص المعامل الأول مكان الوقفة على طول القطعة المستقيمة، وتكون قيمته نسبةً من المسافة بين نقطتي البداية والنهاية. في العموم، لا بُدّ أن يكون مكان الوقفة الأولى عند 0 ومكان الوقفة الأخيرة عند 1؛ كما لا بُدّ أن تكون قيمة مكان كل وقفة أكبر من قيمة مكان الوقفة التي تَسبِقها، إذ يُمكِننا مثلًا إنشاء نمط التلوين المُتدرِج المُستخدَم لتلوين الشكل الموجود على يسار الصورة السابقة على النحو التالي: grad = new LinearGradient( 120,120, 200,180, false, CycleMethod.MIRROR, new Stop( 0, Color.color(1, 0.3, 0.3) ), new Stop( 0.5, Color.color(0.3, 0.3, 1) ), new Stop( 1, Color.color(1, 1, 0.3) ) ); بالنسبة لنمط التلوين المُتدرِج الخطي، سيكون اللون ثابتًا على طول عدة خطوط معينة؛ أما بالنسبة لنمط التلوين الدائري، فسيكون اللون ثابتًا بالنسبة لدوائر معينة؛ إذ تُخصَّص وقفات الألوان على طول نصف قطر دائرة بأبسط حالات التلوين المتدرج الدائري، ويكون اللون ثابتًا للدوائر التي تتشارك نفس المركز. وبالمثل في نمط التلوين الخطي، إذ يُحدِّد أسلوب التكرار اللون المُستخدَم خارج الدائرة. في الحقيقة، الأمر أكثر تعقيدًا من ذلك، فقد يتضمن نمط التلوين الدائري أيضًا نقطةً محورية، والتي تكون هي نفسها مركز الدائرة في الحالة الأبسط، ولكنها في العموم قد تكون أي نقطة أخرى داخل الدائرة. تحدِّد وقفة اللون بالموضع 0 اللون المُستخدَم عند النقطة المحورية، بينما تُحدِّد وقفة اللون بالموضع 1 اللون المُستخدَم على طول الدائرة، وتَستخدِم جميع الرسوم بالصورة التالية نفس نمط التلوين المُتدرِّج، ولكنها تختلف بمكان النقطة المحورية. ستلاحظ وجود علامتين بالرسوم الموجودة بالصف الأول من الصورة، إذ تُمثِّل العلامتان الدائرة والنقطة المحورية. لاحِظ أن اللون المُستخدَم عند النقطة المحورية هو الأحمر، بينما اللون المُستخدَم على طول الدائرة هو الأصفر: يَستقبِل باني الكائن كثيرًا من المعاملات: radialGradient = new RadialGradient( focalAngle,focalDistance, centerX,centerY,radius, proportional, cycleMethod, stop1, stop2, . . . ); يُخصِّص المعاملان الأول والثاني موضع النقطة المحورية، إذ يشير focalDistance إلى المسافة التي تبعدها النقطة المحورية عن مركز الدائرة، وتُخصَّص على أنها نسبةٌ من نصف القطر، أي لا بُدّ أن تكون أصغر من 1؛ بينما يشير focalAngle إلى الاتجاه الذي تتحرك إليه النقطة المحورية بعيدًا عن المركز. في الحالة الأبسط من نمط التلوين المتدرج الدائري، تكون المسافة مساويةً للصفر ولا يكون للاتجاه أي معنًى. تُخصِّص المعاملات الثلاثة التالية مركز الدائرة وطول نصف قطرها؛ في حين تعمل المعاملات المتبقية على نحوٍ مشابه للمعاملات المُستخدَمة بنمط التلوين المتدرج الخطي. التحويلات Transforms تشير النقطة (0,0) تبعًا لنظام الإحداثيات القياسي الخاص بمكون الحاوية إلى ركنها الأيسر العلوي؛ بينما تشير النقطة (x,y) إلى تلك النقطة التي تبعد مسافة طولها x بكسل من الجانب الأيسر للحاوية ومسافة y بكسل من جانبها العلوي، ومع ذلك ليس هناك أي تقييد بخصوص ضرورة استخدام نظام الاحداثيات ذاك، ففي الحقيقة، يُمكِننا أن نضبُط كائن السياق الرسومي لكي يَستخدِم أنظمة إحداثيات أخرى تعتمد على وحدات طول مختلفة، بل ومحاور إحداثيات مختلفة. والهدف من ذلك هو اختيار نظام الإحداثيات الذي يتناسب أكثر مع ما نرغب برسمه. على سبيل المثال، إذا كنا نرسم مخططات معمارية، يُمكِننا عندها استخدام إحداثيات يُمثِّل طول كل وحدةٍ منها القيمة الفعلية لواحد قدم. يُطلَق اسم "التحويلات transforms" على التغييرات الحادثة بنظام الإحداثيات. وهناك ثلاثة أنواع بسيطة من التحويلات يمكن توضيحها في الآتي: أولًا، يُعدِّل الانتقال translate موضع نقطة الأصل (0,0). ثانيًا، يُعدِّل التحجيم scale المقياس المُستخدَم أي وحدة المسافة. ثالثًا، يُطبِّق الدوران rotation دورانًا حول نقطة. تتوفَّر تحويلات أخرى أقل شيوعًا، مثل الإمالة shear التي تُميِل الصورة بعض الشيء. تعرِض الرسمة التوضيحية التالية نفس الصورة بعد إجراء تحويلات مختلفة عليها: لاحظِ أن كل محتويات الصورة بما في ذلك النصوص، قد تأثرت بالتحويلات المُجراة. يُمكِننا أن نُجرِي تحويلًا أكثر تعقيدًا بدمج مجموعةٍ من تلك التحويلات الثلاثة البسيطة، إذ يمكننا مثلًا، تطبيق دورانٍ متبوعٍ بتحجيم، ثم بانتقال، ثم بدورانٍ مرةً أخرى. عندما نُطبِّق عدة تحويلات بالتتابع سيتراكم تأثيرها، ولهذا يكون من الصعب عادةً فهم تأثير التحويلات المعقدة فهمًا كاملًا. تُعدّ التحويلات عمومًا موضوعًا ضخمًا يُمكِن تغطيته بدورة عن الرسوم الحاسوبية computer graphics، ولكننا نناقش هنا فقط بعض الحالات البسيطة لكي نحظى بفكرةٍ عما يُمكِن لتلك التحويلات أن تفعله. يُعدّ التحويل الحالي خاصيةً مُعرَّفةً بكائن السياق الرسومي؛ فهو يُمثِّل جزءًا من الحالة التي يُخزِّنها التابع save()‎ ويستعيدها التابع restore()‎. من المهم جدًا اِستخدَام التابعين save()‎ و restore()‎ عند التعامل مع التحويلات؛ لكي نمنع تأثير التحويلات التي يجريها برنامجٌ فرعيٌ معين على ما يتبعه من استدعاءات لبرامج فرعية أخرى. وبالمثل من بقية الخاصيات الأخرى المُعرَّفة بكائن السياق الرسومي، يمتد تأثير التحويلات على الأشياء المرسومة لاحقًا بعد تطبيق التحويلات على كائن السياق الرسومي. لنفترض أن g كائن سياق رسومي من النوع GraphicsContext، عندها يُمكِننا أن نُطبِّق انتقالًا على g باستدعاء g.translate(x,y)‎؛ إذ تمثِّل x و y قيمًا من النوع double، ويتلخص تأثيرها حسابيًا في إضافة (x,y) على الإحداثيات بعمليات الرسم التالية. فإذا استخدمنا مثلًا النقطة (0,0) بعد تطبيق هذا الانتقال، فإننا فعليًا نشير إلى النقطة التي إحداثياتها تساوي (x,y) وفقَا لنظام الإحداثيات القياسي، ويَعنِي ذلك أن جميع أزواج الإحداثيات قد تحركت بمقدارٍ معين. ألقِ نظرةً على التعليمتين التاليتين: g.translate(x,y); g.strokeLine( 0, 0, 100, 200 ); ترسم التعليمتان السابقتان نفس الخط الذي ترسمه التعليمة التالية: g.strokeLine( x, y, 100+x, 200+y ); تؤدي النسخة الثانية من الشيفرة نفس عملية الانتقال ولكن يدويًا، وبدلًا من محاولة التفكير بعملية الانتقال باستخدام أنظمة الإحداثيات، قد يكون من الأسهل لنا لو فكرنا بما يَحدُث للأشكال التي ستُرسَم لاحقًا، فمثلًا، بعد استدعاء التابع g.translate(x,y)‎، ستتحرك جميع الكائنات التي نرسمها بمسافة x من الوحدات أفقيًا وبمسافة y من الوحدات رأسيًا. وفي مثال آخر، قد يُفضِّل البعض أن تُمثِّل النقطة (0,0) منتصف مكون الحاوية بدلًا من ركنها الأيسر العلوي، ويُمكِننا ذلك باستدعاء الأمر التالي قبل رسم أي شيء: g.translate( canvas.getWidth()/2, canvas.getHeight()/2 ); يُمكِننا أن نُطبِّق تحجيمًا على g باستدعاء التابع g.scale(sx,sy)‎، إذ تشير المعاملات إلى معامل التحجيم بالاتجاهين x و y. وبعد تنفيذ هذا الأمر، ستُضرَّب إحداثيات x بمقدار يُساوِي sx؛ في حين تُضرَّب إحداثيات y بمقدار يُساوِي sy، ويكون تأثير ذلك على الأشياء المرسومة هو بجعلها أكبر أو أصغر. بالتحديد، تؤدي معاملات التحجيم التي تتجاوز قيمتها العدد 1 إلى تكبير حجم الأشكال؛ في حين تؤدي معاملات التحجيم التي قيمتها أقل من 1 إلى تصغير حجمها. وتُستخدَم عادةً نفس قيمة معامل التحجيم للمحورين، ويُطلَق عليه في تلك الحالة اسم "التحجيم المنتظم uniform scaling". تُعدّ النقطة (0,0) مركز التحجيم، أي النقطة التي لا تتأثر بعملية التحجيم؛ بينما تنتقل النقاط الأخرى باتجاه أو بعكس اتجاه النقطة (0,0)، وإذا لم يكن الشكل موجودًا بالنقطة (0,0)، فلا يقتصر تأثير التحجيم على تغيير حجم الشكل، وإنما نقله أيضًا بعيدًا عن النقطة (0,0) لمعاملات التحجيم الأكبر من 1 وقريبًا من النقطة (0,0) لمعاملات التحجيم الأقل من 1. يُمكِننا أيضًا أن نَستخدِم معاملات تحجيم سالبة، والتي يَنتُج عنها حدوث انعكاس، إذ تنعكس الأشكال أفقيًا مثلًا حول الخط x=0، وذلك بعد استدعاء التابع g.scale(-1,1)‎. يُعدّ الدوران هو النوع الثالث من التحويلات البسيطة، إذ يتسبَّب استدعاء التابع g.rotate(r)‎ بدوران جميع الأشكال التي نرسمها لاحقًا بزاوية r حول النقطة (0,0). تُقاس الزوايا بوحدة الدرجات، وتُمثِّل الزوايا الموجبة دورانًا باتجاه عقارب الساعة؛ بينما تُمثِل الزوايا السالبة دورانًا بعكس اتجاه عقارب الساعة، إلا لو كنا قد طبقنا مُسبقًا معامل تحجيم سالب، إذ يؤدي إلى عَكْس الإتجاه. لا تُعدّ الإمالة shearing عملية تحويل بسيطة، وذلك لأنه من الممكن تنفيذها (مع بعض الصعوبة) عبر متتالية من عمليات الدوران والتحجيم؛ إذ يتمثَّل تأثير الإمالة الأفقية بنقل الخطوط الأفقية إلى اليسار أو إلى اليمين بمقدار يتناسب مع المسافة من المحور الأفقي x، فتنتقل النقطة (x,y) إلى النقطة (x+a*y,y)، لأن a هو مقدار الإمالة ذاك. لا تتضمّن مكتبة JavaFX تابعًا لتطبيق عملية الإمالة، ولكنها تملُك طريقةً لتطبيق عملية تحويل عشوائي. إذا كان لديك معرفةٌ بالجبر الخطي linear algebra، فلربما تعرف أن المصفوفات تُستخدَم لتمثيل التحويلات، وأنه من الممكن تخصيص أي عملية تحويل بتحديد الأعداد الموجودة بالمصفوفة مباشرةً. تَستخدِم مكتبة JavaFX كائنات من النوع Affine لتمثيل التحويلات، ويُطبِّق التابع g.transform(t)‎ تحويلًا t من النوع Affine على كائن السياق الرسومي. لن نتطرّق للرياضيات هنا، ولكن تُجرِي الشيفرة التالية عملية إمالة أفقية بمقدار يساوي a: g.transform( new Affine(1, a, 0, 0, 1, 0) ); قد نحتاج في بعض الأحيان إلى تطبيق عدة تحويلات للحصول على التأثير المطلوب. لنفترض مثلًا أننا نريد أن نعرض كلمة "hello world" بعد إمالتها بزاوية 30 درجة، بحيث تقع نقطتها الأصلية بالنقطة (x,y). في الواقع، لن تتمكَّن الشيفرة التالية من تنفيذ ذلك: g.rotate(-30); g.fillText("hello world", x, y); يكمُن سبب المشكلة في أن عملية الدوران تُطبَّق على كُلٍ من النقطة (x,y) والنص، وبالتالي لا تظِلّ النقطة الأصلية بالموضع (x,y) بعد تطبيق عملية الدوران؛ فكل ما نحتاج إليه هو أن نُنشِئ سلسلةً نصيةً مدارةً بنقطة أصلية واقعة عند (0,0)، ثم يُمكِننا أن ننقلها مسافة (x,y)، وهو ما سيؤدي إلى تحريك النقطة الأصلية من (0,0) إلى (x,y). تُنفِّذ الشيفرة التالية ذلك: g.translate(x,y); g.rotate(-30); g.fillText("hello world", 0, 0); ما ينبغي ملاحظته هنا هو الترتيب الذي تُطبَّق به عمليات التحويل؛ إذ تُطبَّق عملية الانتقال على جميع الأشياء التي تلي الأمر translate، والتي هي ببساطة بعض الشيفرة المسؤولة عن رسم سلسلة نصية مدارة بنقطة أصلية واقعة عند (0,0). بعد ذلك، تنتقل تلك السلسلة النصية المدارة بمسافة قدرها (x,y). يَعنِي ذلك أن السلسلة النصية تُدار أولًا ثم تُنقَل. يُمكِننا أن نُلخِص ذلك في أن عمليات الانتقال تُطبَّق بترتيبٍ معاكس لترتيب ظهور أوامر الانتقال بالشيفرة. بإمكان البرنامج التوضيحي TransformDemo.java تطبيق تشكيلةٍ مختلفة من التحويلات على صورة، كما يُمكِّن المُستخدِم من التحكُّم بمقدار كُلٍ من عمليات التحجيم والإمالة الأفقية والدوران والانتقال. قد يساعدك تشغيل البرنامج على فهم التحويلات أكثر، كما يُمكِّنك من فحص الشيفرة المصدرية للبرنامج لترى طريقة تنفيذ تلك العمليات. تُطبَّق التحويلات ضمن هذا البرنامج وفقًا للترتيب التالي: تحجيم، ثم إمالة، ثم دوران، ثم إنتقال. وإذا فحصت الشيفرة، سترى أن التوابع المسؤولة عن عمليات التحويل مُستدعاة بالترتيب المعاكس: انتقال، ثم دوران، ثم إمالة، ثم تحجيم. هنالك أيضًا عملية انتقال إضافية لتحريك نقطة الأصل إلى مركز الحاوية لكي يصبح مركز عمليات التحجيم والدوران والإمالة هو منتصف الحاوية. الحاويات المكدسة Stacked يُعدّ برنامج الرسم البسيط ToolPaint.java المثال الأخير الذي سنتعرض له بهذا القسم. كنا قد تعرضنا لبرامج رسم في جزئيات سابقة من هذه السلسلة، ولكنها كانت مقتصرةً على رسم المنحنيات فقط، ولكن سيُمكِّن البرنامج الجديد المُستخدِم من اختيار أداةٍ للرسم، إضافةً إلى أداة رسم المنحنيات؛ إذ سيتضمَّن البرنامج أدوات لرسم خمسة أنواع من الأشكال موضحة في الآتي: خطوط مستقيمة. مستطيلات. أشكال بيضاوية . مستطيلات ملونة. أشكال بيضاوية ملونة. عندما يَرسِم المُستخدِم شكلًا بأي من أدوات الرسم الخمسة، فعليه سحَب الفأرة ورسم الشكل من النقطة التي بدأت عندها عملية السحب إلى الموضع الحالي للفأرة. وبكل مرة تتحرك خلالها الفأرة، يُحذَف الشكل السابق ويُرسَم شكلٌ جديد. بالنسبة لأداة رسم الخط مثلًا، يكون التأثير هو رؤية خطٍ مُمتدٍ من نقطة بدء عملية السحب إلى الموضع الحالي لمؤشر الفأرة، ويتحرك هذا الخط مع حركة الفأرة. سيكون من الأفضل لو شغّلت البرنامج وجرَّبت هذا التأثير بنفسك. تَكْمُن صعوبة برمجة ذلك في أن بعض أجزاء الرسمة تُغطَّى وتُكشَف باستمرار، بينما يُحرِّك المُستخدِم مؤشر الفأرة؛ وعندما يُغطَّى جزءٌ من الرسمة الحالية ثم يُكشَف عنه مرةً أخرى، فلا بُدّ أن تَظلّ الرسمة ظاهرة. ويَعنِي ذلك أن البرنامج لا يستطيع أن يرسم الشكل على نفس الحاوية المُتضمِّنة للرسمة؛ لأن ذلك سيَمحِي ما كان موجودًا بذلك الجزء من الرسمة. يتلخص الحل بمكتبة JavaFX باستخدام مكوني حاوية واحدًا فوق الآخر؛ بحيث يحتوي مكون الحاوية السفلي على الرسمة الفعلية؛ بينما يُستخدَم مكون الحاوية العلوي لتنفيذ أدوات رسم الأشكال. ينبغي أن تكون الحاوية العلوية شفافة، أي أن تُملأ بلونٍ درجة شفافيته تُساوِي صفر. تُرسَم الأشكال بالحاوية العلوية بينما يسَحب المُستخدِم مؤشر الفأرة بعد اختياره لأداة رسم معينة، وبالتالي لا تتأثر الحاوية السفلية مع إمكانية اختفاء بعض أجزائها خلف الشكل المرسوم بالحاوية العلوية. في كل مرة يتحرك خلالها مؤشر الفأرة، ستُحذَف مكونات الحاوية العلوية وسيُعاد رسم الشكل الجديد بها؛ وعندما يُحرِر المُستخدِم زر الفأرة بنهاية عملية السحب، ستُحذَف مكونات الحاوية العلوية وسيُرسَم الشكل هذه المرة بالحاوية السفلية ليُصبِح جزءًا من الرسمة الفعلية، وبالتالي تُصبِح الحاوية العلوية شفافةً مجددًا بينما تُصبِح مكونات الحاوية السفلية مرئيةً بالكامل. يُمكِننا حذف محتويات حاوية لتصبح بعدها شفافةً تمامًا باستدعاء التعليمة التالية: g.clearRect( 0, 0, canvas.getWidth(), canvas.getHeight() ); إذ أن g هو كائن السياق الرسومي من النوع GraphicsContext المقابل للحاوية، وتكون الحاويات شفافةً بالكامل بمجرد إنشائها. يُمكننا استخدام كائنٍ من النوع StackPane لوضع حاويةٍ فوق حاوية أخرى، إذ تُرتِّب كائنات الصنف StackPane عُقدها nodes الأبناء بعضها فوق بعض بنفس ترتيب إضافتها إليها؛ وعليه، يُمكِننا إنشاء الحاويتين المُستخدمتين بهذا البرنامج على النحو التالي: canvas = new Canvas(width,height); // حاوية الرسم الرئيسية canvasGraphics = canvas.getGraphicsContext2D(); canvasGraphics.setFill(backgroundColor); canvasGraphics.fillRect(0,0,width,height); overlay = new Canvas(width,height); // الحاوية الشفافة العلوية overlayGraphics = overlay.getGraphicsContext2D(); overlay.setOnMousePressed( e -> mousePressed(e) ); overlay.setOnMouseDragged( e -> mouseDragged(e) ); overlay.setOnMouseReleased( e -> mouseReleased(e) ); StackPane canvasHolder = new StackPane(canvas,overlay); ملاحظة: أُضيفت معالجات أحداث الفأرة إلى الحاوية العلوية لا السفلية، لأنها تُغطِي الحاوية السفلية؛ فعندما يَنقُر المُستخدِم على الرسمة الموجودة بالحاوية السفلية، فإنه فعليًا ينقر على الحاوية العلوية. بالمناسبة، لا تَستخدِم أداة رسم المنحنيات الحاوية العلوية تحديدًا، وإنما تُرسَم المنحنيات مباشرةً بالحاوية السفلية. ونظرًا لأن المُستخدِم لا يستطيع حذف أي جزء من المنحنى بعد رسمه، لا حاجة لوضع نسخةٍ مؤقتةٍ منه بالحاوية العلوية. العمليات على البكسلات يتضمَّن البرنامج ToolPaint.java أداتين إضافيتين، هما "الحذف Erase" و "التلطيخ Smudge"، إذ تَعمَل كلتاهما بالحاوية السفلية مباشرةً. تَعمَل أداة الحذف على النحو التالي: بينما يَسحَب المُستخدِم مؤشر الفأرة بعد اختيار تلك الأداة، يُملأ مربعٌ صغير حول موضع المؤشر باللون الأسود لكي يحذف جزء الرسمة الموجود بهذا الموضع. وفي المقابل، تَعمَل أداة التلطيخ على النحو التالي: يؤدي السَحْب بعد اختيار تلك الأداة إلى تلطيخ اللون أسفل الأداة، تمامًا كما لو سحبت إصبعك عبر لون مبلل. تُبيِّن الصورة التالية لقطة شاشة من البرنامج بعد سَحْب أداة التلطيخ حول مركز مستطيل أحمر: لا تتضمَّن مكتبة JavaFX برنامجًا فرعيًا مبنيًا مُسبقًا لإجراء تلطيخ على صورة، فهو أمرٌ يتطلّب معالجةً مباشرةً لألوان البكسلات كُلٌ على حدى. تتلخص الفكرة الأساسية لتلك الأداة فيما يلي: يَستخدِم البرنامج ثلاث مصفوفات ثنائية البعد بحجم 9 x‏ 9، واحدةٌ لكل مُكوِّن لون؛ أي واحدةٌ تَحمِل المكون الأحمر؛ وواحدة للمكون الأخضر؛ والأخيرة للمكون الأزرق. عندما ينقر المُستخدِم على الفأرة أثناء استخدام أداة التلطيخ، تُنسَخ مكونات لون البكسلات الموجودة داخل المربع المحيط بمؤشر الفأرة من الصورة إلى المصفوفات الثلاثة، وعندما يُحرِّك المُستخدِم الفأرة، تمتزج بعضًا من ألوانها بألوان البكسلات الموجودة بالمكان الجديد لمؤشر الفأرة. وبنفس الوقت، يُنسَخ بعضٌ من ألوان الصورة إلى المصفوفات، أي أن المصفوفات تُسقِط بعضًا من الألوان التي تحملها وتلتقط بعض الألوان من المكان الجديد. إذا فكرت بالأمر قليلًا، سترى أن ذلك يشبه تمامًا ما يحدث عندما تُمرِّر إصبعك بدهانٍ حديث. ينبغي إذًا أن نتمكَّن من قراءة ألوان البكسلات الموجودة بالصورة لكي نُنفِّذ تلك العملية، كما علينا أن نكون قادرين على كتابة ألوان جديدة بتلك البكسلات. في الواقع، من السهل كتابة الألوان بالبكسلات باستخدام الصنف PixelWriter؛ فإذا كان g كائن سياق رسومي من النوع GraphicsContext، وكان هذا الكائن مُرتبطًا بمكون حاوية، عندها يُمكِننا إنشاء كائنٍ من النوع PixelWriter لتلك الحاوية باستدعاء ما يلي: PixelWriter pixWriter = g.getPixelWriter(); ولكي نتمكَّن من ضبط لون البكسل الموجود بالنقطة (x,y) من الحاوية، يُمكِننا استدعاء التابع التالي: pixWriter.setColor( x, y, color ); إذ أن color هو كائنٌ من النوع Color، وتمثِّل x و y إحداثيات بكسل، وهم ليسوا عُرضَةً لأي عملية تحويل قد تُطبَّق على كائن السياق الرسومي. لو كانت مكتبة JavaFX توفِّر طريقةً سهلةً لقراءة ألوان البكسلات من الحاوية، فسنكون قد انتهينا فعلًا، ولكن لسوء الحظ، ليس الأمر بهذه البساطة؛ إذ يبدو أن عمليات الرسم لا تُطبَّق على الحاوية على الفور، وإنما تُخزَّن مجموعةٌ من عمليات الرسم، ثم تُرسَل إلى عتاد جهاز الرسوم دفعةً واحدة، وذلك لرفع الكفاءة. بالتحديد، تُرسَل مجموعة العمليات فقط عندما تُصبِح عملية إعادة رسم الحاوية على الشاشة ضرورية. ويَعنِي ذلك، أننا لو قرأنا لون بكسل من الحاوية، فإننا لا نَضمَن أن تكون القيمة التي نحصل عليها متضمّنةً لكل أوامر الرسم التي طبقناها على الحاوية. وبناءً على ذلك، إذا أردنا أن نقرأ ألوان البكسلات، فسنضطّر لفعل شيءٍ ما لضمان اكتمال جميع عمليات الرسم، مثل أخذ لقطة شاشة للحاوية. يُمكِننا أن نأخذ لقطة شاشة لأي عقدة بمبيان المشهد، وتعيد تلك العملية قيمةً من النوع WritableImage تحتوي على صورة للعقدة بعد تطبيق جميع العمليات قيد الانتظار. تلتقط التعليمة التالية صورةً لعقدة بأكملها: WritableImage nodePic = node.snapshot(null,null); يحتوي كائن الصنف WritableImage على صورةٍ للعقدة كما هي ظاهرةٌ على الشاشة، ويُمكِننا أن نَستخدِمه مثل أي كائن صورة عادي ينتمي إلى الصنف Image. يَستقبِل التابع من التعليمة السابقة معاملات من النوع SnapshotParameter و WriteableImage؛ فإذا مررنا صورةً غير فارغة قابلة للكتابة للمعامل الثاني، فسيَستخدِمها التابع مثل صورة طالما كانت كبيرةً كفاية لتَحمِل صورة العقدة، وربما يكون ذلك أكثر كفاءةً من إنشاء صورةٍ جديدة قابلة للكتابة. بالنسبة للمعامل الأول، يُمكِن اِستخدَامه للتحكُّم بالصورة الناتجة على نحوٍ أكبر، إذ يمكنه تحديدًا الطلب بأن تكون لقطة الشاشة مقتصرةً على مستطيلٍ معين من العقدة فقط. والآن، لكي نُنفِّذ أداة التلطيخ، ينبغي أن نقرأ البكسلات من مستطيلٍ صغيرٍ بالحاوية، إذ تبلغ مساحة ذلك المستطيل 9*9؛ ولإجراء ذلك بكفاءة، سنُمرِّر كائنًا من النوع SnapshotParameter لكي يُخصِّص رغبتنا بلقطة شاشة لذلك المستطيل لا الحاوية بالكامل، وبذلك نكون قد حصلنا على ذلك المستطيل ضمن كائن صورة من النوع WritableImage. وبعد ذلك، سنَستخدِم كائنًا من النوع PixelReader لقراءة ألوان البكسلات من الكائن الذي حصلنا عليه. في الواقع، يتضمَّن تنفيذ ذلك كثيرًا من التفاصيل، ولذلك سنعرض فقط طريقة التنفيذ. يُنشِئ البرنامج كائنًا واحدًا من كل نوعٍ من الأنواع التالية WritableImage و PixelReader و SnapshotParameter لاستخدامها مع جميع لقطات الشاشة، وقد تقع بعض البكسلات خارج الحاوية لبعض لقطات الشاشة، وهو ما يُعقدّ الأمور قليلًا. ألقِ نظرةً على الشيفرة التالية: pixels = new WritableImage(9,9); // a 9-by-9 writable image pixelReader = pixels.getPixelReader(); // a PixelReader for the writable image snapshotParams = new SnapshotParameters(); عندما ينقر المُستخدِم على زر الفأرة، ينبغي أن تُؤخذ لقطة شاشة مربعة لجزء الحاوية المحيط بمكان مؤشر الفأرة الحالي (startX,startY)، ثم تُنسخ بيانات الألوان من تلك اللقطة إلى مصفوفات مكونات الألوان smudgeRed و smudgeGreen و smudgeBlue. ألقِ نظرةً على الشيفرة التالية: snapshotParams.setViewport( new Rectangle2D(startX - 4, startY - 4, 9, 9) ); // 1 canvas.snapshot(snapshotParams, pixels); int h = (int)canvas.getHeight(); int w = (int)canvas.getWidth(); for (int j = 0; j < 9; j++) { // صفٌ في لقطة الشاشة int r = startY + j - 4; // الصف المقابل بالحاوية for (int i = 0; i < 9; i++) { // عمودٌ في لقطة الشاشة int c = startX + i - 4; // العمود المقابل بالحاوية if (r < 0 || r >= h || c < 0 || c >= w) { // 2 smudgeRed[j][i] = -1; } else { Color color = pixelReader.getColor(i, j); // pixelReader gets color from the snapshot smudgeRed[j][i] = color.getRed(); smudgeGreen[j][i] = color.getGreen(); smudgeBlue[j][i] = color.getBlue(); } } } حيث أن: [1] يُمثِّل viewport المستطيل الموجود بالحاوية والذي سيُضمَّن بلقطة الشاشة. [2] تعني أن النقطة (c,r) تقع خارج الحاوية، كما تشير قيمة -1 بالمصفوفة smudgeRed إلى أن البكسل كان خارج الحاوية. والآن، علينا مزج اللون الموجود بمصفوفات مكونات الألوان بمربع البكسلات المحيط بالنقطة (x,y)؛ ولكي نُنفِّذ ذلك، سنأخذ لقطة شاشة مربعة جديدة لمربع البكسلات المحيط بالنقطة (x,y)؛ وبمجرد حصولنا على تلك اللقطة، يُمكِننا إجراء الحسابات الضرورية لعملية المزج، ونكتب بعدها اللون الجديد الناتج إلى الحاوية باستخدام كائن الصنف PixelWriter المسؤول عن الكتابة بالحاوية. ألقِ نظرةً على الشيفرة التالية: snapshotParams.setViewport( new Rectangle2D(x - 4, y - 4, 9, 9) ); canvas.snapshot(snapshotParams, pixels); for (int j = 0; j < 9; j++) { // صف بلقطة الشاشة int c = x - 4 + j; // الصف المقابل بالحاوية for (int i = 0; i < 9; i++) { // عمود بلقطة الشاشة int r = y - 4 + i; // العمود المقابل بالحاوية if ( r >= 0 && r < h && c >= 0 && c < w && smudgeRed[i][j] != -1) { // اِسترجِع لون البكسل الحالي من لقطة الشاشة Color oldColor = pixelReader.getColor(j,i); // 1 double newRed = (oldColor.getRed()*0.8 + smudgeRed[i][j]*0.2); double newGreen = (oldColor.getGreen()*0.8 + smudgeGreen[i][j]*0.2); double newBlue = (oldColor.getBlue()*0.8 + smudgeBlue[i][j]*0.2); // اكتب لون البكسل الجديد إلى الحاوية pixelWriter.setColor( c, r, Color.color(newRed,newGreen,newBlue) ); // امزج جزء من اللون الموجود بالحاوية إلى المصفوفات smudgeRed[i][j] = oldColor.getRed()*0.2 + smudgeRed[i][j]*0.8; smudgeGreen[i][j] = oldColor.getGreen()*0.2 + smudgeGreen[i][j]*0.8; smudgeBlue[i][j] = oldColor.getBlue()*0.2 + smudgeBlue[i][j]*0.8; } } } إذ تعني [1]: احصل على لون جديد للبكسل عن طريق دمج اللون الحالي مع مكونات اللون المُخزَّنة بالمصفوفات. في الواقع، هذه عمليةٌ معقدةٌ نوعًا ما، ولكنها أوضحت طريقة التعامل مع البكسلات إلى حدٍ ما. عليك أن تتذكر أنه من الممكن كتابة ألوان البكسلات إلى الحاوية باستخدام كائنٍ ينتمي إلى الصنف PixelWriter؛ ولكن ينبغي لقراءة البكسلات منها، أن نأخذ لقطة شاشة للحاوية، ثم نَستخدِم كائنًا من الصنف PixelReader لقراءة ألوان البكسلات من كائن النوع WritableImage الذي يحتوي على لقطة الشاشة. عمليات الدخل والخرج للصور يحتوي البرنامج التوضيحي ToolPaint.java على قائمة "File" تحتوي على أمرٍ لتحميل صورةٍ من ملفٍ إلى حاوية؛ وأمرٍ آخر لحفظ صورة من حاوية إلى ملف. بالنسبة لأمر تحميل الصورة، سنحتاج أولًا إلى تحميل الصورة إلى كائنٍ من النوع Image. كنا قد اطلعنا بمقال التعرف على بعض أصناف مكتبة جافا إف إكس JavaFX البسيطة على طريقة تحميل صورةٍ من ملف مورد، وكيفية رسمها ضمن حاوية. وبنفس الطريقة تقريبًا، يُمكِن تحميل صورة من ملف على النحو التالي: Image imageFromFile = new Image( fileURL ); يُمثِّل المعامل سلسلةً نصيةً تُخصِّص موقع الملف بهيئة محدّد موارد موّحد URL، وهو ببساطة مسار الملف مسبوقٌ بكلمة "file:‎"؛ فإذا كان imageFile كائنًأ من النوع File يحتوي على مسار الملف، فيُمكِننا ببساطة كتابة ما يلي: Image imageFromFile = new Image( "file:" + imageFile ); نَستخدِم عادةً كائن نافذة اختيار ملف من النوع FileChooser لنسمَح للمُستخدِم باختيار ملف -ألقِ نظرةً على مقال مدخل إلى التعامل مع الملفات في جافا-، وعندها سيكون imageFile هو الملف المختار الذي تُعيده تلك النافذة. قد يختار المُستخدِم ملفًا لا يُمكِن قراءته، أو لا يحتوي على صورة، وهنا لا يُبلِّغ باني كائن الصنف Image عن استثناءٍ في تلك الحالة، وإنما يَضبُط متغيرًا مُعرَّفًا بالكائن لكي يُشير إلى وجود خطأ؛ لذلك علينا فحص ذلك المتغير باستخدام الدالة imageFromFile.isError()‎ التي تعيد قيمةً من النوع boolean، وفي حالة وجود خطأ فعلًا، يُمكِننا أن نَستعيد الاستثناء المُتسبِّب بالخطأ باستدعاء الدالة imageFromFile.getException()‎. بمجرد حصولنا على الصورة وتأكُّدنا من عدم وجود خطأ، يُمكِننا أن نرسمها بالحاوية باستخدام كائن السياق الرسومي الخاص بالحاوية. يَضبُط الأمر التالي حجم الصورة، بحيث تملأ الحاوية كاملةً: g.drawImage( imageFromFile, 0, 0, canvas.getWidth(), canvas.getHeight() ); سنضع جميع ما سبق ضمن تابعٍ، اسمه doOpenImage()‎، وسيَستدعيه البرنامج ToolPaint لتحميل الصورة المُخزَّنة بالملف الذي اختاره المُستخدِم. ألقِ نظرةً على تعريف التابع: private void doOpenImage() { FileChooser fileDialog = new FileChooser(); fileDialog.setInitialFileName(""); fileDialog.setInitialDirectory( new File( System.getProperty("user.home") ) ); fileDialog.setTitle("Select Image File to Load"); File selectedFile = fileDialog.showOpenDialog(window); if ( selectedFile == null ) return; // لم يختر المُستخدِم أي ملف Image image = new Image("file:" + selectedFile); if (image.isError()) { Alert errorAlert = new Alert(Alert.AlertType.ERROR, "Sorry, an error occurred while\ntrying to load the file:\n" + image.getException().getMessage()); errorAlert.showAndWait(); return; } canvasGraphics.drawImage(image,0,0,canvas.getWidth(),canvas.getHeight()); } والآن، لكي نستخرِج الصورة من حاويةٍ إلى ملف، ينبغي أن نحصل أولًا على الصورة من الحاوية، وذلك بأخذ لقطة شاشة للحاوية بالكامل، وهو ما سنفعله كما يلي: Image image = canvas.snapshot(null,null); لسوء الحظ، لا تتضمَّن مكتبة JavaFX -بإصدارها الحالي على الأقل- خاصية حفظ الصور إلى ملفات؛ ولهذا سنعتمد على الصنف BufferedImage من حزمة java.awt.image بأداة تطوير واجهات المُستخدِمة الرسومية AWT القديمة، إذ يُمثِّل ذلك الصنف صورةً مُخزّنةً بذاكرة الحاسوب، مثل الصنف Image بمكتبة JavaFX. في الواقع، يُمكِننا بسهولة تحويل كائن من النوع Image إلى النوع BufferedImage باستخدام تابعٍ ساكن static مُعرَّف بالصنف SwingFXUtils من حزمة javafx.embed.swing على النحو التالي: BufferedImage bufferedImage = SwingFXUtils.fromFXImage(canvasImage,null); يُمكِننا أن نُمرِّر كائنًا من النوع BufferedImage مثل معاملٍ ثانٍ للتابع ليَحمِل الصورة، والذي من الممكن أن يأخذ القيمة null. بمجرد حصولنا على كائن الصنف BufferedImage، يُمكِننا استخدام التابع الساكن التالي المُعرَّف بالصنف ImageIO من حزمة javax.imageio لكتابة الصورة إلى ملف: ImageIO.write( bufferedImage, format, file ); يُمثِّل المعامل الثاني للتابع السابق سلسلةً نصيةً من النوع String، إذ تخصِّص تلك السلسلة صيغة الملف الذي ستُحفَظ إليه الصورة. يُمكِن حفظ الصور عمومًا بعدة صيغ بما في ذلك "PNG" و "JPEG" و "GIF"، ويستخدم البرنامج ToolPaint صيغة "PNG" دائمًا. يُمثِل المعامل الثالث كائنًا من النوع File، ويُخصِّص بيانات الملف المطلوب حفظه، إذ يُبلِّغ التابع ImageIO.write()‎ عن استثناءٍ، إذا لم يتمكَّن من حفظ الملف؛ وإذا لم يتعرف التابع على الصيغة، فإنه يَفشَل أيضًا، ولكنه لا يُبلِّغ عن استثناء. يُمكِننا الآن أن نُضمِّن كل شيء معًا داخل التابع doSaveImage()‎ بالبرنامج ToolPaint. ألقِ نظرةً على تعريف التابع: private void doSaveImage() { FileChooser fileDialog = new FileChooser(); fileDialog.setInitialFileName("imagefile.png"); fileDialog.setInitialDirectory( new File( System.getProperty("user.home") ) ); fileDialog.setTitle("Select File to Save. Name MUST end with .png!"); File selectedFile = fileDialog.showSaveDialog(window); if ( selectedFile == null ) return; // لم يختر المُستخدِم أي ملف try { Image canvasImage = canvas.snapshot(null,null); BufferedImage image = SwingFXUtils.fromFXImage(canvasImage,null); String filename = selectedFile.getName().toLowerCase(); if ( ! filename.endsWith(".png")) { throw new Exception("The file name must end with \".png\"."); } boolean hasFormat = ImageIO.write(image,"PNG",selectedFile); if ( ! hasFormat ) { // لا ينبغي أن يحدث ذلك نهائيًا throw new Exception( "PNG format not available."); } } catch (Exception e) { Alert errorAlert = new Alert(Alert.AlertType.ERROR, "Sorry, an error occurred while\ntrying to save the image:\n" + e.getMessage()); errorAlert.showAndWait(); } } ترجمة -بتصرّف- للقسم Section 2: Fancier Graphics من فصل Chapter 13: GUI Programming Continued من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: الخاصيات والارتباطات في جافا مقدمة إلى برمجة واجهات المستخدم الرسومية (GUI) في جافا
  5. تعتمد برمجة واجهات المُستخدِم الرسومية GUI على اِستخدَام الأحداث events استخدامًا كبيرًا، متضمنةً الأحداث منخفضة المستوى، مثل أحداث لوحة المفاتيح والفأرة، والأحداث عالية المستوى، مثل تلك الناتجة عن اختيار قائمة أو ضبط قيمة مزلاج. تَنتُج أحداث المزلاج -كما رأينا في في مقال التخطيط الأساسي لواجهة المستخدم في مكتبة جافا إف إكس JavaFX - من خاصية قابلة للمراقبة observable مُعرَّفة بذلك المزلاج، وبالتالي إذا أردنا الإستجابة إلى تغيُّر قيمة مزلاج sldr، فعلينا أن نُسجِّل مُستمِعًا listener بالخاصية valueProperty المُعرَّفة به على النحو التالي: sldr.valueProperty().addListener( . . . ); عندما تتغير قيمة المزلاج، تُولِّد الخاصية valueProperty حدثًا، مما يَسمَح لشيفرة معالجة الأحداث بالاستجابة إلى ذلك التغيير. في الواقع، قيمة valueProperty للمزلاج هي كائنٌ object من النوع DoubleProperty، إذ تُغلِّف كائنات هذا النوع قيمةً من النوع double، وتحتوي على التابعين get()‎ و set()‎ لاسترجاع تلك القيمة وتعديلها. علاوةً على ذلك، تُعدّ تلك القيمة قيمةً قابلةً للمراقبة، مما يَعنِي أنها تُولِّد أحداثًا عندما تتغيَّر، كما أنها تُعَد أيضًا خاصيةً قابلةً للارتباط bindable؛ إذ يستطيع هذا النوع من الخاصيات أن يرتبط بخاصياتٍ أخرى من نفس النوع، وتُجبَر الخاصيات المُرتبطِة بتلك الطريقة على أن يكون لها نفس القيمة. تَستخدِم واجهة تطوير تطبيقات مكتبة JavaFX الخاصيات القابلة للارتباط بكثرة، وسنتعلّم بهذا المقال كيفية استخدامها والدور الذي تلعبه. الأصناف التي سنناقشها بهذا المقال مُعرَّفةٌ بحزمة javafx.beans وحزمها الفرعية، ومع ذلك لن نضطّر لاستيراد import تلك الأصناف من الحزم الموجودة بها إلى برامج JavaFX؛ لأننا غالبًا ما نعتمد على كائناتٍ موجودة بالفعل. القيم القابلة للمراقبة Observable تُعدّ الكثير من متغيرات النسخ instance variables المُعرَّفة بكائنات مكتبة JavaFX، قيمًا قابلةً للمراقبة، أي أنها تُولِّد أحداثًا، حتى أن أغلبها قابلٌ للارتباط أيضًا؛ إذ يُعدّ كلٌ من عرض حاوية canvas وارتفاعها مثلًا، قيمًا قابلةً للمراقبة من النوع DoubleProperty؛ كما يُعدّ النص الموجود بحقل نصي أو عنوان label قيمةً قابلةً للمراقبة من النوع StringProperty؛ إلى جانب أن قائمة أبناء حاوية من النوع Pane تُعَد قيمةً قابلةً للمراقبة من النوع ObservableList<Node>‎. ويتكَّون مربع الاختيار checkbox من خاصية قابلة للمراقبة من النوع BooleanProperty، والتي تشير إلى اختيار المربع؛ وفي مثال آخر، نجد لون النص المعروض بعنوان، فهو أيضًا قيمةٌ قابلة للمراقبة من النوع ObjectProperty<Paint>‎. تُولِّد القيم القابلة للمراقبة نوعين من الأحداث هما: الحدث الأول، وهو حدث تغيُّر القيمة، وفي تلك الحالة لا بُدّ أن يُنفِّذ implement معالج الحدث واجهة نوع الدالة ChangeListener<T>‎ ذات المعاملات غير مُحدّدة النوع parameterized، والتي تحتوي على التابع changed(target,oldValue,newValue)‎؛ إذ يُمثِّل المعامل الأول الكائن القابل للمراقبة الذي تغيَّرت قيمته، ويحتوي المعامل الثاني على القيمة السابقة؛ في حين يحتوي المعامل الثالث على القيمة الجديدة. على سبيل المثال، إذا أدرنا عرض القيمة الحالية لمزلاج مثل نص عنوان، فعندها ينبغي أن نُغيّر النص عندما تتغير قيمة المزلاج، بحيث تتوافق قيمتهما دائمًا. ولهذا، علينا أن نُسجِّل مُستمعًا لحدث تغيُّر القيمة لكي يَضبُط نص العنوان ليتوافق مع قيمة المزلاج، على النحو التالي: slider.valueProperty().addListener( (t,oldVal,newVal) -> label.setText("Slider Value: " + newVal) ); سنرى لاحقًا أن هناك طرائقٌ أفضل لإنجاز نفس المهمة. تُولِّد القيم القابلة للمراقبة نوعًا آخرًا من الأحداث، هو حدث انعدام الصلاحية invalidation؛ إذ يُولَّد ذلك الحدث عندما تُصبِح القيمة الحالية غير صالحةٍ لسببٍ ما. لا يستطيع أي كائنٍ عمومًا قراءة القيمة غير الصالحة لكائنٍ آخر، فعند محاولته قراءتها، سيُعاد حسابها لتُصبِح قيمةً صالحةً مرةً أخرى. بمعنًى آخر، يُشيِر حدث انعدام الصلاحية إلى ضرورة إعادة حساب القيمة، ولكنها لا تُحسَب فعليًا إلا عند الحاجة؛ ويُطلَق على ذلك اسم "التحصيل المُرجَأ lazy evaluation" للقيمة، أي أن عملية إعادة حساب القيمة تُؤجَّل إلى حين الحاجة إلى استخدام القيمة الجديدة لسببٍ ما. يتميز هذا النوع من التحصيل بالكفاءة بالموازنة مع إعادة حساب القيمة بمجرد انعدام صلاحيتها. لنفترض مثلًا، أن عشرات الأشياء قد حدثت، وأدت لجعل قيمةٍ ما غير صالحة مع استخدام التحصيل المُرجَأ، وبالتالي سيُعاد حساب القيمة مرةً واحدةً فقط عند الحاجة إليها بدلًا من أن يُعاد تحصيلها عشرات المرات. والأهم من ذلك هو أن الكائن القابل للمراقبة يُولِّد حدث انعدام صلاحية وحيد لا عشرة أحداث، ويُجنبِّنا ذلك العديد من الاستدعاءات غير الضرورية لمستمعي ذلك الحدث. بالنسبة لمكتبة JavaFX، يُفضَّل عادةً تهيئة مُستمِع إلى حدث انعدام الصلاحية بدلًا من حدث تغيُّر القيمة. يُعرِّف مستمعو الأحداث من النوع InvalidationListener تابعًا وحيدًا، هو التابع invalidated(obv)‎، إذ يُشير معامل ذلك التابع إلى الكائن القابل للمراقبة الذي أصبحت قيمته غير صالحة. ألقِ نظرةً على الشيفرة التالية على سبيل المثال، مع الملاحظة أن sayHello هو مربع اختيار: sayHello.selectedProperty().addListener( e -> { if (sayHello.isSelected()) label.settext("Hello"); else label.setText("Goodbye"); }); تُسجِّل الشيفرة بالمثال السابق مستمع حدث من النوع InvalidationListener. ويُمكِننا تنفيذ نفس الأمر باستدعاء sayHello.setOnAction()‎، ولكن هنالك اختلاف: يُستدعَى مُستمعِو الأحداث من النوع ActionListener، فقط إذا كانت حالة مربع الاختيار قد تغيَّرت بواسطة المُستخدِم؛ بينما يُستدعَى مُستمعِي أحداث انعدام الصلاحية متى تغيّرت القيمة، بما في ذلك التغييرات المُجراة نتيجة لاستدعاء sayHello.setSelected()‎. لاحِظ أننا كنا قد اِستخدَمنا نفس التابع addListener()‎ لتسجيل مستمعي الأحداث من النوع ChangeListener، إذ يَستطيع المُصرِّف compiler أن يُفرِّق بينهما حتى لو مرَّرنا المُستمِع بهيئة تعبير لامدا lambda expression؛ لأن تعبير لامدا لحدث من النوع ChangeListener يَستقبِل ثلاثة معاملات؛ في حين أن تعبير لامدا لحدث من النوع InvalidationListener، يَستقبِل معاملًا واحدًا. يَطرَح ذلك السؤال التالي: ماذا سيحدث لو كانت قيمة الخاصية selectedProperty بمربع الاختيار sayHello قد أصبحت غير صالحةٍ فقط دون أن تتغير؟ هل سيعيد الاستدعاء sayHello.isSelected()‎ القيمة الحالية غير الصالحة أم القيمة الجديدة؟ في الحقيقة، سيؤدي استدعاء sayHello.isSelected()‎ إلى تحصيل القيمة الجديدة وإعادتها. لا يُمكِن عمومًا قراءة القيم غير الصالحة نهائيًا؛ إذ تؤدي أي محاولة لقراءتها إلى إعادة تحصيلها ما يَنتُج عنه حساب القيمة الجديدة. بالنسبة لمكتبة JavaFX، يُمكِن الوصول إلى الخاصيات القابلة للمراقبة ضمن كائنٍ ما باستدعاء توابع نسخ تنتهي أسماؤها بكلمة "Property"، إذ يُستخدَم الاستدعاء slider.valueProperty()‎ مثلًا، للوصول إلى خاصية القيمة بمزلاج؛ بينما يُستخدَم الاستدعاء label.textProperty()‎ للوصول إلى خاصية النص بعنوان. ليست جميع القيم القابلة للقراءة بكائنات مكتبة JavaFx خاصيات؛ في حين تُعدّ الخاصيات القابلة للمراقبة قابلةً للارتباط أيضًا -كما سنرى بالمقال التالي-؛ كما لا تُعدّ القيم البسيطة القابلة للمراقبة قابلة للارتباط، مع أنها ما تزال تُولِّد حدثي تغيُّر القيمة وانعدام الصلاحية، بل ويُمكِن تسجيل مستمعي أحداث للاستجابة إلى التغيُّرات التي تحدث بقيمتها. الخاصيات القابلة للارتباط Bindable يمكن لمعظم الخاصيات القابلة للمراقبة بمكتبة JavaFX الارتباط بخاصيةٍ أخرى من نفس النوع، وتًستثنى من ذلك الخاصيات القابلة للقراءة فقط read-only؛ فلا يُمكِن ربطها بخاصية أخرى، على الرغم من إمكانية ربط خاصيةٍ بخاصية قابلة للقراءة فقط. لنفترض مثالًا بسيطًا بأننا نريد لنص عنوان معين أن يكون دائمًا هو نفس قيمة النص الموجود بحقلٍ نصي معين. يُمكِننا تنفيذ ذلك ببساطة بربط خاصية textProperty المُعرَّفة بالعنوان مع خاصية textProperty المُعرَّفة بالحقل النصي، على النحو التالي: Label message = new Label("Never Seen"); TextField input = new TextField("Type Here!"); message.textProperty().bind( input.textProperty() ); يُجبِر التابع bind()‎ قيمة message.textProperty()‎ بأن تكون هي نفسها قيمة input.textProperty()‎. وبمجرد تنفيذ التابع bind()‎، يُنسَخ النص الموجود بالحقل النصي إلى العنوان؛ لكي لا يرى المُستخدِم نص العنوان المبدئي (وهو "Never Seen" بالمثال السابق). عند حدوث أي تغيير على النص الموجود بالحقل النصي أثناء تشغيل البرنامج، يُطبَّق ذلك التغيير على العنوان تلقائيًا، سواءً كان ذلك التغيير نتيجةً لكتابة المُستخدِم بالحقل النصي، أو نتيجةً لاستدعائنا للتابع input.setText()‎ برمجيًا. لاحِظ أن مفهوم الارتباط مُنفَّذ داخليًا باستخدام الأحداث ومستمعي الأحداث، فالهدف منه هو ألا نضطّر لتهيئة مستمعي الأحداث يدويًا، وإنما نَستدعِي فقط التابع bind()‎ لتهيئة كل شيء أوتوماتيكيًا. عندما نَستخدِم bind()‎ لربط خاصية بخاصيةٍ أخرى، لا يُمكِننا عندها تعديل قيمة الخاصية المُرتَبطِة تعديلًا مباشرًا، ولذلك يؤدي أي استدعاء للتابع message.setText()‎ بالمثال السابق إلى حدوث استثناء exception. وبالطبع، بإمكان أي خاصيةٍ الارتباط بخاصيةٍ واحدةٍ أخرى فقط بأي لحظة. يُمكِننا استخدام التابع unbind()‎ الذي لا يَستقبِل أي معاملات، وذلك لإلغاء ارتباطٍ معين على النحو التالي: message.textProperty().unbind(); يحتوي البرنامج التوضيحي BoundPropertyDemo.java على أمثلةٍ متعددة عن الخاصيات المرتبطة. ستَجِد كما في المثال السابق، بأن خاصية النص الموجودة بعنوان مرتبطةً بخاصية النص الموجودة بحقلٍ نصي، بحيث تؤدي الكتابة في الحقل النصي إلى تعديل نص العنوان. تعرِض الصورة التالية لقطة شاشة من البرنامج: يُمثِّل العنوان الموجود على يمين أسفل النافذة مثالًا آخرًا عن الارتباط، إذ يَعرِض العنوان قيمة المزلاج، وسيتغير نص العنوان بمجرد أن يَضبُط المُستخدِم قيمة المزلاج. كما ناقشنا بالأعلى، يُمكِننا تنفيذ ذلك بتسجيل مستمعٍ إلى خاصية المزلاج valueProperty، ولكنها مُنفَّذةٌ بهذا المثال باستخدام الارتباط. ومع ذلك توجد مشكلة، إذ أن خاصية textProprety الموجودة بالعنوان من النوع StringProperty، بينما خاصية valueProperty الموجودة بالمزلاج من النوع DoubleProperty، ولذلك لا يُمكِن ربطهما مباشرةً؛ لأن الإرتباط يَعمَل بنجاح فقط إذا كانت كلتاهما من نفس النوع. لحسن الحظ، يحتوي النوع DoubleProperty على التابع asString()‎ الذي يُحوِّل الخاصية إلى النوع string، أي لو كان slider مزلاجٌ من النوع Slider، فسيُمثِّل مايلي خاصيةً من النوع string تحتوي على التمثيل النصي لقيمة المزلاج العددية التي هي بالأساس من النوع double: slider.valueProperty().asString() وبالتالي يُمكِننا الآن أن نربط خاصية textProprety الموجودة بالعنوان بتلك الخاصية النصية، كما يمكن للتابع asString()‎ أن يَستقبِل صيغة سلسلة تنسيق format string، مثل تلك التي يَستخدِمها التابع System.out.printf، لتنسيق قيمة المزلاج العددية. وقد اِستخدَمنا المتغير sliderVal لتمثيل العنوان بهذا البرنامج، وربطنا خاصيته النصية بكتابة ما يلي: sliderVal.textProperty().bind( slider.valueProperty().asString("Slider Value: %1.2f") ); إذا شئنا الدقة، يُعدّ التعبير slider.valueProperty().asString()‎ من النوع StringBinding وليس StringProperty، ولكننا سنتجاهل التمييز بينهما هنا لأنه ليس مهمًا. تحتوي الكائنات المُمثِّلة لخاصيات Property على توابع كثيرة للتحويل بين الأنواع بالإضافة إلى توابع لإجراء بعض العمليات الأخرى، إذ يُعرِّف النوع DoubleProperty التابع lessThan(number)‎، الذي يعيد خاصيةً من النوع boolean قيمتها تساوي true عندما تكون قيمة النوعDoubleProperty أصغر من عددٍ معين. فمثلًا، إذا كان btn من النوع Button، فإنه يحتوي على خاصية btn.disableProperty()‎ من النوع BooleanProperty للإشارة إلى ما إذا كان الزر مُعطَّلًا أم لا؛ فإذا أردنا أن نُعطِّل الزر عندما تصبح قيمة مزلاج معين أقل من 20، يُمكِننا ربط خاصية تعطيل الزر على النحو التالي: btn.disableProperty().bind( slider.valueProperty().lessThan(20) ); تتوفَّر توابع أخرى مشابهة، مثل greaterThan()‎ و lessThanOrEqual()‎ و isNotEqualTo()‎ وغيرها، كما تتوفَّر توابع أخرى لإجراء بعض العمليات الحسابية. على سبيل المثال: slider.valueProperty().multiply(2) يُمثِّل التعبير السابق خاصيةً من النوع double قيمتها تساوي ضعف قيمة المزلاج. يُمكِننا استخدام الصنف When المُعرَّف بحزمة javafx.beans.binding لكي نُطبِّق شيئًا مثل المعامل الثلاثي ":?" على خاصيات من النوع boolean، ولكن بقواعد صيغة مختلفة بعض الشيء -ألقِ نظرةً على مقال التعبيرات expressions في جافا-. على سبيل المثال، إذا كان boolProp يُمثِّل خاصيةً من النوع boolean، وكان trueVal و falseVal متغيرين يحتويان على أي قيم بشرط أن تكون من نفس النوع، فإن: new When(boolProp).then(trueVal).otherwise(falseVal) يُمثِّل خاصيةً من نفس نوع المتغيرين trueVal و falseVal، وتكون قيمتها مساويةً لقيمة trueVal إذا كان boolProp يحتوي على القيمة true، أو القيمة falseVal إذا كان boolProp يحتوي على القيمة false. كنا قد اِستخدَمنا بمثال سابق مستمعًا لضبط نص عنوان إلى كلمة "Hello" أو "Goodbye"، وذلك بناءً على اختيار مربع الاختيار sayHello أم لا. تُوضِّح الشيفرة التالية طريقة فعل نفس الشيء، ولكن باستخدام ارتباط الخاصيات: label.textProperty().bind( new When(sayHello.selectedProperty()).then("Hello").otherwise("Goodbye") ); يُمثِل المعامل المُمرَّر بالتعليمة When(sayHello.selectedProperty())‎ خاصيةً من النوع boolean. نظرًا لأن كُلًا من "Hello" و "Goodbye" قيمٌ من النوع String، فستكون الخاصية الناتجة من التعبير بالكامل سلسلةً نصيةً من النوع String، وهو ما يتوافق مع نوع الخاصية label.textProperty()‎. بنفس الأسلوب، يَستخدِم البرنامج BoundPropertyDemo.java مربع اختيار للتحكُّم بلون الخلفية الخاص بعنوان كبير باستخدام الارتباط مع كائنٍ من النوع When، ويُمكِنك الاطلاع على الشيفرة كاملة لمزيدٍ من التفاصيل. يُمثِّل البرنامج التوضيحي CanvasResizeDemo.java تطبيقًا مباشرًا ومفيدًا على ارتباط الخاصيات؛ إذ يَعرِض هذا البرنامج 50 قرصًا أحمرًا صغيرًا داخل نافذة، وتتحرك الأقراص ضمن حاويةٍ من النوع Canvas تملأ النافذة، وتتصادم مع بعضها بعضًا، كما تتصادم بحواف الحاوية. كنا قد ضبطنا حجم النافذة في الأمثلة السابقة التي اِستخدَمنا بها حاوية، ليكون غير قابل للتعديل؛ وذلك لأن حجم الحاوية لا يتغير أوتوماتيكيًا، ومع ذلك، بإمكان البرامج أن تُعدِّل حجم الحاوية من خلال ضبط الخاصيات الممثلة لعرض الحاوية وارتفاعها، وذلك باستدعاء canvas.setWidth(w)‎ و canvas.setHeight(h)‎. هناك حلٌ آخر لضبط حجم الحاوية، إذ يُمكِننا ربط هاتين الخاصيتين بمصدرٍ مناسب؛ لأنهما خاصيتان قابلتان للارتباط من النوع DoubleProperty. استخدمنا بهذا البرنامج كائنًا من النوع Pane ليحيط بالحاوية، ويعمل مثل جذرٍ لمبيان المشهد scene graph، ويملأ النافذة. وعندما يُعدِّل المُستخدِم حجم النافذة، يتغير حجم الكائن تلقائيًا ليتوافق مع ما اختاره المُستخدِم. إذا أردنا لحجم الحاوية أن يتغير تلقائيًا هو الآخر ليتماشى مع حجم الكائن، فعلينا أن نربط خاصية عرض الحاوية بخاصية عرض الكائن؛ وبالمثل خاصية ارتفاع الحاوية بخاصية ارتفاع الكائن. تُوضِح الشيفرة التالية طريقة فعل ذلك: canvas.widthProperty().bind( root.widthProperty() ); canvas.heightProperty().bind( root.heightProperty() ); إذا شغَّلت البرنامج وزدت حجم النافذة، فسترى أن حجم الحاوية قد ازداد أيضًا؛ إذ ستتمكَّن الأقراص الحمراء المتصادمة من الانتشار بحيز أكبر؛ وبالمثل، إذا قلصت حجمها، ستتقيد الأقراص ضمن حيِّز أصغر. يعيد البرنامج رسم الحاوية باستمرار، ولهذا لسنا مضطّرين لفعل أي شيء إضافي لإعادة رسمها عند زيادة حجمها. قد نحتاج ببرامج أخرى إلى إعادة رسم محتويات الحاوية عند تغيُّر حجمها، وهو ما يُمكِننا تنفيذه بإضافة مستمعي أحداث إلى خاصيات عرض الحاوية وارتفاعها، لكي يتمكَّنوا من إعادة رسم الحاوية استجابةً للتغيُّرات الواقعة بعرضها أو ارتفاعها. الارتباط ثنائي الاتجاه يُستخدَم التابع bind()‎ لإنشاء ارتباطاتٍ أحادية الاتجاه؛ بمعنى أنها تَعمَل من اتجاهٍ واحدٍ فقط، وهو ما قد لا يكون مناسبًا في بعض الأحيان. لنفترض مثلًا أنه لدينا مربعي اختيار cb1 و cb2 من النوع CheckBox، ونريدهما أن يكونا متطابقين دائمًا. في الواقع، لا يُمكِننا تحقيق ذلك باستخدام الارتباطات أحادية الاتجاه؛ لأننا لو كتبنا ما يلي مثلًا: cb2.selectedProperty().bind( cb1.selectedProperty() ); فستصبح حالة مربع الاختيار cb2 مطابقةً لحالة مربع الاختيار cb1، ولكن لن يؤثر تغيير حالة cb2 على حالة cb1، وإنما يؤدي إلى حدوث استثناء؛ لأنه لا يَصِح تعديل خاصيةٍ قد رُبِطَت فعليًا باستخدام bind()‎. يَعنِي ذلك، أنه لو نقر المُستخدِم على مربع الاختيار cb2، فسيحدث استثناء، وذلك لأن مربع الاختيار سيحاول أن يُغيّر حالته. يَكْمُن الحل في ما يُعرَف باسم الارتباط ثنائي الاتجاه bidirectional binding؛ فعند ارتباط خاصيتين ارتباطًا ثنائي الاتجاه، عندها يُمكِن لقيمة أيٍّ منهما أن تتغير، وعندها تتغيّر قيمة الأخرى أوتوماتيكيًا إلى نفس القيمة. يُستخدَم التابع bindBidirectional()‎ لإنشاء هذا النوع من الارتباط، فيُمكِننا مثلًا كتابة ما يَلِي لربط مربعي الاختيار: cb2.selectedProperty().bindBidirectional( cb1.selectedProperty() ); والآن، يَستطيع المُستخدِم أن ينقر على أي مربعٍ منهما، وستتغير حالة المربع الآخر تلقائيًا. قد لا يكون ذلك مفيدًا لمربعي اختيار، ولكن أهميته تبرز في حالات أخرى، مثل مزامنة حالة مربع اختيار من النوع CheckBox معروض بالنافذة مع حالة عنصر مربع اختيار ضمن قائمةٍ من النوع CheckMenuItem، إذ سيتمكَّن المُستخدِم من اِستخدَام أحد عنصري الواجهة لإتمام نفس الأمر. في الواقع، يشيع استخدام مُكوِّنات واجهة لها نفس الغرض ضمن القوائم وأشرطة الأدوات باتباع نفس الاسلوب. يُطبِّق البرنامج التوضيحي BoundPropertyDemo.java أمرًا مشابهًا مع أزرار انتقاء من النوع RadioButton وعناصر أزرار انتقاء ضمن قائمة من النوع RadioMenuItem، إذ يَسمَح البرنامج للمُستخدِم بالتحكُّم بلون عنوان معين، إما باستخدام قائمة "Color"، أو مجموعةٍ من أزرار الانتقاء؛ إذ يرتبط كل زر انتقاء ارتباطًا ثنائي الاتجاه مع عنصر زر انتقاء مكافئ معروض بالقائمة. تُوضِّح الشيفرة التالية طريقة تنفيذ ذلك بالتفصيل: Menu colorMenu = new Menu("Color"); Color[] colors = { Color.BLACK, Color.RED, Color.GREEN, Color.BLUE }; String[] colorNames = { "Black", "Red", "Green", "Blue" }; ToggleGroup colorGroup = new ToggleGroup(); for (int i = 0; i < colors.length; i++) { // أنشِئ عنصر قائمة وزر انتقاء مقابل RadioButton button = new RadioButton(colorNames[i]); RadioMenuItem menuItem = new RadioMenuItem(colorNames[i]); button.selectedProperty().bindBidirectional( menuItem.selectedProperty() ); menuItem.setToggleGroup(colorGroup); // 1 menuItem.setUserData(colors[i]); right.getChildren().add(button); // أضف زرًا إلى الحاوية colorMenu.getItems().add(menuItem); // أضف عنصر قائمة إلى القائمة if (i == 0) menuItem.setSelected(true); } colorGroup.selectedToggleProperty().addListener( e -> { // 2 Toggle t = colorGroup.getSelectedToggle(); if (t != null) { // 3 Color c = (Color)t.getUserData(); message.setTextFill(c); } }); حيث أن: [1] تعني: لاحِظ استخدام UserData لتخزين الكائن المُمثِّل للون عنصر القائمة لحاجتنا إليه لاحقًا. [2] تعني: استمع إلى التغييرات الواقعة بالخاصية selectedToggleProperty المُعرَّفة بالصنف ToggleGroup؛ لكي تتمكَّن من ضبط لون العنوان بما يتوافق مع عنصر القائمة الواقع عليه الاختيار. [3] بمعنى: يُمثِّل t العنصر المُختار من النوع RadioMenuItem، إذ يمكنك اِسترجاع اللون من UserData واستخدامه لضبط لون النص. ربما تُصبِح قيمة selectedToggleProperty فارغةً بلحظة معينة، لأن أحدهما وقع عليه الاختيار والآخر غير مختار. تُضاف عناصر القائمة إلى كائنٍ من النوع ToggleGroup -ألقِ نظرةً على مقال مكونات التحكم البسيطة في واجهة المستخدم في مكتبة جافا إف إكس JavaFX-، بينما لا تُضاف أزرار الانتقاء إليه. والآن، إذا نقر المُستخدِم على إحدى أزرار الانتقاء التي لم تكن قيد الاختيار، فستتغير حالة عنصر القائمة المكافئ إلى وضع الاختيار، ولكن قبل أن يحدث ذلك، سيُغير كائن النوع ToggleGroup حالة عنصر القائمة المختار حاليًا إلى وضع عدم الاختيار، وهو ما يؤدي إلى تغيير حالة زر الانتقاء المربوط بعنصر القائمة ذاك إلى وضع عدم الاختيار أيضًا، وبالنهاية، ستتغير حالة كلٍ من زري الانتقاء وعنصري القائمة. سيكون من الأفضل لو فحصت طريقة استخدام البرنامج لخاصية userData المُعرَّفة بأزرار الانتقاء، على الرغم من أنها لا تتعلَّق بالارتباط ثنائي الاتجاه. تَحمِل كل عقدة node بمبيان المشهد بيانات المُستخدِم ضمن خاصية اسمها userData من النوع Object. لا يَستخدِم النظام تلك الخاصية المُعرَّفة بكل عقدة، ولذلك تُعدّ مكانًا مناسبًا لتخزين بعض البيانات المتعلقة بالعقدة، ويُسمَح لتلك البيانات بأن تكون من أي نوع. بالنسبة لهذا البرنامج، ستتكوَّن بيانات المُستخدِم بالعقدة المُمثِلة لعنصر زر انتقاء بالقائمة من قيمةٍ من النوع Color، وسيُستخدَم هذا اللون عند اختيار عنصر القائمة ذاك. نتمنى أن تكون الأمثلة التي تعرَّضنا لها بهذا المقال قد أقنعتك بفعالية ارتباط الخاصيات ودورها في تنفيذ العمليات بين الكائنات ببرامج مكتبة JavaFX. قد يبدو هذا الأسلوب من البرمجة غريبًا بالبداية، ولكنه أسهل وأكثر وضوحًا من التعامل مع الأحداث ومستمعي الأحداث مباشرةً. ترجمة -بتصرّف- للقسم Section 1: Properties and Bindings من فصل Chapter 13: GUI Programming Continued من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: أمثلة برمجية على الشبكات في جافا: إطار عمل لتطوير الألعاب عبر الشبكة مدخل إلى أساسيات البرمجة بلغة Java: ما هي البرمجة؟ البواني وتهيئة الكائنات Object Initialization في جافا
  6. سنناقش بهذا المقال مجموعةً من البرامج التي تَستخدِم الشبكات والخيوط. تتشارك تلك التطبيقات بمشكلة توفير الاتصال الشبكي بين مجموعةٍ من البرامج المُشغَّلة على حواسيبٍ مختلفة. تُعدّ الألعاب ثنائية اللاعبين أو متعددة اللاعبين عبر الشبكة واحدةً من الأمثلة النموذجية على هذا النوع من التطبيقات، ولكن تضطّر تطبيقاتٌ أخرى أكثر جدية لمواجهة نفس المشكلة أيضًا. سندرس بالقسم الأول من هذا المقال إطار عملٍ framework يُمكِن اِستخدامه ببرامج مختلفة تندرج تحت هذا النوع، ثم سنناقش بالجزء المتبقي من هذا المقال ثلاثة تطبيقات مبنيةً على هذا الإطار. في الواقع، قد تكون تلك التطبيقات هي الأكثر تعقيدًا بهذه السلسلة، ولذلك فهمها ليس ضروريًا لفهم أساسيات الشبكات. في الواقع، يعود الفضل لهذا المقال إلى الطالبين Alexander Kittelberger و Kieran Koehnlein؛ فقد أرادا أن يَكتُبا برنامج لعبة بوكر عبر الشبكة مشروعًا نهائيًا بالصف الذي كان الكاتب يُدرسّه. لقد ساعدهما الكاتب بالجزء المُتعلّق بالشبكات بكتابة إطار عملٍ بسيط يدعم الاتصال بين اللاعبين. يتعرَّض التطبيق إلى الكثير من الأفكار الهامة، ولذلك تقررت إضافة إصدارٍ أعم وأكثر تطورًا من إطار العمل إلى السلسلة. يمثّل المثال الأخير بهذا المقال برنامج لعبة بوكر عبر الشبكة. إطار عمل Netgame تشترك جميع الألعاب المختلفة عبر الشبكة بشيءٍ واحد فيما يتعلّق بالشبكات على الأقل؛ حيث لا بُدّ من وجود طريقةٍ لإيصال الأفعال التي يفعلها لاعبٌ معينٌ إلى اللاعبين الآخرين عبر الشبكة. لذلك، سيكون من الأفضل لو أتحنا تلك الإمكانيات بقاعدةٍ مشتركة قابلةٍ لإعادة الاستخدام بواسطة الكثير من الألعاب المختلفة. على سبيل المثال، تحتوي حزمة netgame.common، التي طورها الكاتب على الكثير من الأصناف. لم نتعامل كثيرًا مع الحزم packages بهذه السلسلة بخلاف استخدام الأصناف المبنية مسبقًا built-in. كنا قد شرحنا ماهية الحزم بمقال بيئات البرمجة programming environment في جافا، ولكننا لم نَستخدِم بجميع الأمثلة البرمجية حتى الآن سوى الحزمة الافتراضية default package. تُستخدَم الحزم عمليًا بجميع المشروعات البرمجية بهدف تقسيم الشيفرة إلى مجموعةٍ من الأصناف المرتبطة، ولذلك كان من البديهي تعريف إطار العمل القابل لإعادة الاستخدام بحزمةٍ يُمكِن إضافتها مثل مجموعة متكاملة unit إلى أي مشروع. تُسهِّل بيئات التطوير المتكاملة Integrated development environments مثل Eclipse استخدام الحزم packages: فمن أجل استخدام حزمة netgame ضمن مشروع باِستخدَام إحدى بيئات التطوير المتكاملة، كل ما عليك فعله هو نَسْخ مجلد netgame بالكامل إلى المشروع؛ ونظرًا لاستخدام حزمة netgame مكتبة JavaFX، ينبغي ضبط المشروع ببيئة Eclipse ليدعم استخدام مكتبة JavaFX. إذا كنت تَعمَل بسطر الأوامر، ينبغي أن يحتوي مجلد العمل working directory على المجلد netgame بهيئة مجلدٍ فرعي. إذا لم تكن تَستخدِم إصدارًا قديمًا من JDK يتضمَّن مكتبة JavaFX مسبقًا، فينبغى أن تُضيف خيار JavaFX إلى أوامر javac و java. لنفترض أننا عرَّفنا الأمرين jfxc و jfx ليكافئا الأمرين javac و java بعد إضافة خيارات مكتبة JavaFX إليهما. والآن، إذا أردنا أن نُصرِّف كل ملفات جافا المُعرَّفة بحزمة netgame.common مثلًا، يُمكِننا استخدام الأمر التالي في نظامي Mac OS و Linux: jfxc netgame/common/*.java أما بالنسبة لنظام Windows، يُمكِننا استخدام الشرطة المائلة للخلف بدلًا من الشرطة المائلة للأمام كما يلي: jfxc netgame\common\*.java بالنسبة لإصدارات JDK التي تتضمَّن بالفعل مكتبة JavaFX، يمكن استخدام javac وليس jfxc. ستحتاج إلى أوامرٍ مشابهة لتتمكَّن من تصريف الشيفرة المصدرية لبعض الأمثلة ضمن هذا المقال، وستجدها معرَّفةً ضمن حزمٍ فرعية subpackages أخرى من حزمة netgame. لتتمكَّن من تشغيل البرنامج main المُعَّرف بحزمةٍ معينة، ينبغي أن تكون بمجلدٍ يتضمَّن تلك الحزمة بهيئة مجلدٍ فرعي، كما ينبغي استخدام الاسم الكامل للصنف الذي تريد تشغيله. إذا أردت مثلًا تشغيل الصنف ChatRoomWindow -سيُناقش لاحقًا ضمن هذا المقال- المُعرَّف بحزمة netgame.chat، شغِّل الأمر التالي: jfx netgame.chat.ChatRoomWindow تُعدّ التطبيقات التي سنناقشها ضمن هذا المقال أمثلةً على البرمجة الموزَّعة؛ فهي تتضمَّن عدة حواسيب تتواصل عبر الشبكة. تَستخدِم تلك التطبيقات كما هو الحال في مثال قسم الحوسبة الموزعة من مقال المقال السابق خادمًا server مركزيًا أو برنامجًا رئيسيًا master مُوصَّلًا بمجموعةٍ من العملاء clients؛ حيث تمر جميع الرسائل عبر الخادم، أي لا يستطيع عميلٌ معينٌ إرسال رسالةٍ إلى عميلٍ آخر مباشرةً. سنشير إلى الخادم ضمن هذا المقال باسم "الموزّع hub" بمعنى موزع الاتصالات. هناك عدة أشياء ينبغي أن تفهمها جيدًا: لا بُدّ أن يكون الموزع مُشغَّلًا قبل بدء تشغيل أيٍّ من العملاء. يتصل العملاء بالموزع ليتمكَّنوا من إرسال رسائلهم إليه، ويُعالِج الموزع جميع رسائل العملاء واحدةً تلو الأخرى بنفس ترتيب استقبالها، وبناءً على تلك المعالجة، يُمكِنه أن يرسل عدة رسائلٍ إلى عميلٍ واحدٍ أو أكثر. يَملُك كل عميل مُعرِّف هوية ID خاصٍ به، ويُمثِل ذلك إطار عمل framework عام يُمكِن اِستخدامه بمختلف أنواع التطبيقات، بحيث يُعرِّف كل تطبيق ما يخصُّه من رسائلٍ ومعالجات. لنتعمَّق الآن بتفاصيل البرنامج. كانت الرسائل في قسم الحوسبة الموزعة من المقال السابق تُرسَل جيئةً وذهابًا بين الخادم والعميل وفقًا لمتتاليةٍ مُحدَّدةٍ ومُعرَّفةٍ مُسبقًا، حيث كان الاتصال بين الخادم والعميل بمثابة اتصالٍ بين خيطٍ واحدٍ مُشغَّلٍ ببرنامج الخادم وخيطٍ آخرٍ مُشغّلٍ ببرنامج العميل. بالنسبة لإطار عمل netgame، نريد أن نجعل الاتصال غير متزامن asynchronous؛ بمعنى أننا لا نريد انتظار وصول الرسائل بناءً على متتاليةٍ متوقَّعة مسبقًا، ولجعل هذا ممكنًا، يَستخدِم العميل وفقًا لإطار عمل netgame خيطين للاتصال: الأول لإرسال الرسائل إلى الموزع، والآخر لاستقبال الرسائل منه. وبالمثل، يَستخدِم الموزع وفقًا لإطار عمل netgame خيطين للاتصال مع كل عميل. يكون الموزع عمومًا متصلًا مع عدة عملاء، ويستطيع استقبال الرسائل من أي عميل بأي وقت. ينبغي أن يُعالِج الموزع الرسائل بطريقةٍ ما، ويَستخدِم لإجراء تلك المعالجة خيط اتصالٍ واحد فقط لمعالجة جميع الرسائل. عندما يَستقبِل ذلك الخيط رسالةً معينةً من عميلٍ ما، فإنه يُدْخِلها إلى رتلٍ queue يحتوي على الرسائل المُستقبَلة؛ حيث يتوفَّر رتل واحد فقط تُخزِّن به رسائل جميع العملاء. في المقابل، يُنفِّذ خيط معالجة الرسائل حلقة تكرار loop يقرأ خلالها رسالةً واحدةً من الرتل ويُعالجها، ثم يقرأ رسالةً أخرى ويُعالجها وهكذا. لاحِظ أن الرتل هو كائن من النوع LinkedBlockingQueue. يتضمَّن الموزع خيطًا آخرًا غير مُوضَّحٍ بالصورة السابقة؛ حيث يُنشِئ ذلك الخيط مقبسًا من النوع ServerSocket، ويَستخدِمه للاستماع لطلبات الاتصال الآتية من العملاء. يُسلّم الموزع كل طلب اتصالٍ يَستقبِله إلى كائنٍ آخر من النوع المُتداخِل ConnectionToClient؛ حيث يُعالِج ذلك الكائن الاتصال مع العميل. يَملُك كل عميلٍ متصلٍ رقم مُعرِّف هويةٍ خاصٍ به، حيث تُسنَد مُعرِّفات الهوية 1 و2 و3 .. إلى العملاء عند اتصالهم؛ ونظرًا لأن بإمكانهم غلق الاتصال، قد لا تكون أرقام مُعرِّفات الهوية الخاصة بالعملاء المتصلين متتاليةً ضمن لحظةٍ معينة. يُستخدَم متغيرٌ من النوع TreeMap<Integer,ConnectionToClient>‎ لربط أرقام مُعرِّفات الهوية الخاصة بالعملاء المتصلين مع الكائنات المسؤولة عن معالجة اتصالاتهم. تمثِّل الرسائل المُرسَلة والمُستقبَلة كائنات، ولذلك اِستخدَمنا مجاري دْخَل وخَرْج I/O streams من النوع ObjectInputStream و ObjectOutputStream لقراءة الكائنات وكتابتها. (انظر قسم إدخال وإخراج الكائنات المسلسلة من مقال قنوات الدخل والخرج وعمليتي القراءة والكتابة في جافا). لقد غلَّفنا مجرى الخرج الخاص بالمقبس باستخدام الصنف ObjectOutputStream لنَسمَح بنقل الكائنات عبر المقبس؛ في حين غلَّفنا مجرى الدخل الخاص بالمقبس باستخدام الصنف ObjectInputStream لنَسمَح باستقبال الكائنات. ملاحظة: لا بُدّ أن تُنفِّذ الكائنات المُستخدَمة مع هذا النوع من المجاري الواجهة java.io.Serializable. ستَجِد الصنف Hub مُعرَّفًا بالملف Hub.java في حزمة netgame.common. يجب تخصيص رقم المنفذ الذي سيستمع إليه مقبس الخادم بهيئة معامل يُمرَّر إلى الباني constructor. يُعرِّف الصنف Hub التابع التالي: protected void messageReceived(int playerID, Object message) عندما تصِل رسالةٌ من عميلٍ معينٍ إلى مقدمة رتل الرسائل، يقرأها خيط معالجة الرسائل من الرتل، ويَستدعِي ذلك التابع. بتلك اللحظة فقط، تبدأ المعالجة الفعلية لرسالة العميل. يُمثِّل المعامل الأول playerID رقم مُعرِّف الهوية الخاص بالعميل المُرسِل للرسالة؛ بينما يُمثِّل المعامل الثاني الرسالة نفسها. يُمرِّر التابع نسخةً من تلك الرسالة إلى جميع العملاء المتصلين، ويُمثِّل ذلك المعالجة الافتراضية التي يُجريها الموزع على الرسائل المُستقبَلة؛ ولكي يُنفِّذ ذلك، يُغلِّف الموزع أولًا كُلًا من رقم مُعرِّف الهوية playerID ومحتويات الرسالة message بكائنٍ من النوع ForwardedMessage (مُعرَّفٌ بالملف ForwardedMessage.java بحزمة netgame.common). قد تكون المعالجة الافتراضية مناسبة تمامًا لاحتياجات تطبيقٍ بسيطٍ، مثل برنامج غرفة المحادثة الذي سنناقشه لاحقًا، ولكن سنضطّر إلى تعريف صنفٍ فرعي subclass من الصنف Hub بالنسبة لغالبية التطبيقات، وإعادة تعريف التابع messageReceived()‎ به لإجراء معالجةٍ أكثر تعقيدًا. في الواقع، يحتوي الصنف Hub على عدة توابعٍ أخرى، والتي قد تحتاج إلى إعادة تعريفها أيضًا. نستعرِض بعضًا منها فيما يلي: protected void playerConnected(int playerID)‎: يُستدعَى ذلك التابع في كل مرةٍ يتصل خلالها لاعبٌ بالموزع؛ حيث يُمثِّل المعامل playerID رقم مُعرِّف الهوية الخاص باللاعب الجديد. لا يفعَل هذا التابع شيئًا بالصنف Hub. (لقد أرسَلَ الموزع رسالةً من النوع StatusMessage بالفعل إلى كل عميل ليخبره بوجود لاعبٍ جديد؛ أما الغرض من التابع playerConnected()‎ فهو إجراء أي عملياتٍ أخرى إضافية قد ترغب الأصناف الفرعية المُشتقَة من الصنف Hub بتنفيذها). من الممكن الوصول إلى قائمة مُعرِّفات الهوية لجميع اللاعبين المتصلين حاليًا باستدعاء التابع getPlayerList. protected void playerDisconnected(int playerID)‎: يُستدعَى ذلك التابع في كل مرةٍ يُغلِق خلالها لاعبٌ معينٌ اتصاله مع الموزع (بعد أن يُرسِل الموزع رسالةً من النوع StatusMessage إلى العملاء). يُمثِّل المعامل رقم مُعرِّف الهوية الخاص باللاعب الذي أغلق الاتصال. لا يفعَل هذا التابع شيئًا بالصنف Hub. يُعرِّف الصنف Hub بعض التوابع العامة public المفيدة منها: sendToAll(message)‎: يُرسِل ذلك التابع الرسالة message المُمرَّرة لكل عميلٍ متصل بالموزع حاليًا. لا بُدّ أن تكون الرسالة كائنًا غير فارغ يُنفِّذ الواجهة Serializable. sendToOne(recipientID,message)‎: يُرسِل ذلك التابع الرسالة message المُمرَّرة إلى مُستخدِمٍ واحدٍ فقط؛ حيث يُمثِّل المعامل الأول recipientID رقم مُعرِّف الهوية الخاص بالعميل الذي ينبغي أن يَستقبِل تلك الرسالة، ويُعيد التابع قيمة من النوع boolean تُساوِي false في حالة عدم وجود عميلٍ مقابلٍ لقيمة recipientID. shutDownServerSocket()‎: يُغلِق هذا التابع مقبس الخادم الخاص بالموزع؛ لكي لا يتمكَّن أي عميلٍ آخر من الاتصال. يُمكِننا اِستخدَامه بالألعاب الثنائية (لاعبين فقط) بعد أن يتصِل اللاعب الثاني مثلًا. setAutoreset(autoreset)‎: يضبُط قيمة خاصية autoreset؛ فإذا كانت قيمتها تساوي true، يُعَاد ضبط المجاري -من النوع ObjectOutputStreams- المُستخدَمة لنقل الرسائل إلى العملاء أوتوماتيكيًا قبل نقل تلك الرسائل. القيمة الافتراضية لتلك الخاصية هي false. يكون لإعادة ضبط مجرًى من النوع ObjectOutputStream معنًى إذا كان هناك كائنٌ قد كُتِبَ بالمجرى بالفعل، ثم عُدّل، ثم كُتِبَ إلى المجرى مرةً أخرى. إذا لم نُعِد ضبط المجرى قبل كتابة الكائن المُعدَّل به، تُرسَل القيمة القديمة غير المُعدَّلة إلى المجرى بدلًا من القيمة الجديدة. في تلك الحالة، يُفضَّل اِستخدَام كائناتٍ ثابتة غير قابلة للتعديل immutable مع الاتصال، ولا تكون عندها إعادة الضبط ضرورية. ينبغي أن تقرأ الشيفرة المصدرية للملف Hub.java للإطلاع على طريقة تنفيذ كل ما سبق وللمزيد من المعلومات في العموم. مع قليلٍ من الجهد والدراسة، ستكون قادرًا على فهم كل شيء موجود ضمن ذلك الملف، ومع ذلك تحتاج فقط إلى فهم الواجهة العامة public والمحمية protected بالصنف Hub والأصناف الأخرى المُعرَّفة بإطار عمل netgame حتى تتمكَّن من كتابة بعض التطبيقات المبنية عليها. لننتقل الآن إلى جانب العميل، ستَجِد تعريف الصنف المُمثِل للعميل بالملف Client.java بحزمة netgame.common؛ حيث يحتوي الصنف على باني كائن constructor يَستقبِل كُلًا من اسم أو عنوان بروتوكول الإنترنت، ورقم المنفذ الخاصين بالموزع الذي سيتصل به العميل. يُسبِّب هذا الباني تعطيلًا إلى أن يُنشَأ الاتصال. الصنف Client هو صنفٌ مجرَّد abstract؛ أي لا بُدّ لأي تطبيق netgame أن يُعرِّف صنفًا فرعيًا مُشتقًا من الصنف Client، وأن يُوفِّر تعريفًا للتابع المُجرّد abstract التالي: abstract protected void messageReceived(Object message); يُستدعَى التابع السابق بكل مرةٍ يَستقبِل خلالها العميل رسالةً من الموزع. قد يُعيد الصنف الفرعي المُشتق تعريف override التوابع المحمية الآتية: playerConnected. playerDisconnected. serverShutdown. connectionClosedByError. انظر الشيفرة المصدرية لمزيدٍ من المعلومات. علاوةً على ذلك، يحتوي الصنف Client على متغير نسخة instance variable، اسمه connectedPlayerIDs من نوع المصفوفة int[]‎. تُمثِّل تلك المصفوفة قائمة أرقام مُعرِّفات الهوية الخاصة بجميع العملاء المتصلين حاليًا بالموزع. يُعرِّف الصنف Client مجموعةً من التوابع العامة، وسنستعرِض بعضًا من أهمها فيما يلي: send(message)‎: ينقل هذا التابع رسالةً إلى الموزع. قد يكون المعامل message أي كائنٍ غير فارغ بشرط أن يُنفِّذ الواجهة Serializable. getID()‎: يسترجع هذا التابع رقم مُعرِّف الهوية الذي أسنده الموزع إلى العميل. disconnect()‎: يغلِق اتصال العميل مع الموزع. لاحظ أن العميل لن يتمكَّن من إرسال أي رسائلٍ أخرى بعد غلق الاتصال. إذا حاول العميل فعل ذلك، سيُبلِّغ التابع send()‎ عن استثناءٍ من النوع IllegalStateException. صُممّ الصنفان Hub و Client عمومًا ليُوفِّرا إطار عملٍ عام يُمكِن استخدامه أساسًا لكثيرٍ من الألعاب الشبكية المختلفة والبرامج المُوزَّعة أيضًا. جميع التفاصيل منخفضة المستوى المتعلقة بالاتصالات الشبكية والخيوط المتعدّدة مخفيةٌ ضمن الأقسام الخاصة private المُعرَّفة بتلك الأصناف، وتتمكَّن بذلك التطبيقات المبنية على تلك الأصناف من العمل وفقًا لمصطلحاتٍ عالية المستوى مثل اللاعبين والرسائل. صُممت تلك الأصناف على عدة مراحل بناءً على الخبرة المُكتسبَة من مجموعةٍ من التطبيقات الحقيقية، ولهذا يُفضَّل إلقاء نظرةٍ على الشيفرة المصدرية لرؤية طريقة استخدام الصنفين Hub و Client للخيوط والمقابس ومجاري الدْخَل والخرج. سنمرّ سريعًا بالجزء المُتبقِي من هذا المقال على ثلاثة تطبيقات مبنيةٍ على إطار عمل netgame وبدون مناقشة التفاصيل. يُمكِنك الإطلاع على الشيفرة المصدرية للتطبيقات الثلاثة كاملةً بحزمة netgame. تطبيق غرفة محادثة بسيط تطبيقنا الشبكي الأول هو "غرفة محادثة" تُمكِّن المُستخدمين من الاتصال بخادمٍ معين، ومن ثم إرسال الرسائل؛ حيث يستطيع المُستخدمون المتواجدون بنفس الغرفة رؤية تلك الرسائل. يتشابه هذا التطبيق مع البرنامج GUIChat من قسم برنامج محادثة عبر الشبكة غير متزامن من مقال الخيوط Threads والشبكات في جافا باستثناء أنه من الممكن لأي عددٍ من المُستخدِمين التواجد بالمحادثة. لا يُعدّ هذا التطبيق لعبة، ولكنه يظهر الوظائف الأساسية التي يُوفِّرها إطار عمل netgame. يتكوَّن تطبيق غرفة المحادثة من برنامجين: الأول هو ChatRoomServer.java، وهو في الواقع برنامجٌ بسيطٌ للغاية، فكل ما يفعله هو إنشاء موزع Hub يَستمِع إلى طلبات الاتصال القادمة من عملاء netgame: public static void main(String[] args) { try { new Hub(PORT); } catch (IOException e) { System.out.println("Can't create listening socket. Shutting down."); } } يُعرَّف رقم المنفذ PORT على أنه ثابتٌ بالبرنامج، ويمكن أن يكون أي رقمٍ عشوائي شرط أن يَستخدِم الخادم والعملاء نفس الرقم. يَستخدِم البرنامج ChatRoom الصنف Hub نفسه لا صنفًا فرعيًا منه. أما البرنامج الثاني من تطبيق غرفة المحادثة فهو البرنامج ChatRoomWindow.java، الذي ينبغي أن يُشغِّله المُستخدمون الذين يريدون المشاركة بغرفة المحادثة. يجب أن يعرِف المُستخدِم اسم أو عنوان بروتوكول الانترنت الخاص بالحاسوب الذي يَعمَل عليه الموزع. (لأغراض اختبار البرنامج، يُمكِنك تشغيل برنامج العميل بنفس الحاسوب الذي يَعمَل عليه الموزع باستخدام localhost اسمًا لحاسوب الموزع). يَعرِض ChatRoomWindow عند تشغيله صندوق نافذة ليطلب من المُستخدِم إدخال تلك المعلومات، ثم يَفتَح نافذةً تُمثِّل واجهة المُستخدِم لغرفة المحادثة؛ حيث تحتوي تلك النافذة على مساحةٍ نصيةٍ كبيرة لعرض الرسائل التي يُرسِلها المُستخدمون إلى الغرفة؛ كما تحتوي على صندوق إدخال نصي حيث يَستطيع المُستخدِم إدخال الرسائل. عندما يُدْخِل المُستخدِم رسالة، تظهر الرسالة بالمساحة النصية الموجودة بنافذة كل مُستخدِم مُتصِّلٍ بالموزع، ولذلك يرى المستخدمين جميع الرسائل المُرسَلة بواسطة أي مُستخدِم. لنفحص الآن طريقة برمجة ذلك. لابُدّ أن تُعرِّف تطبيقات netgame صنفًا فرعيًا مشتقًا من الصنف المجرّد Client. بالنسبة لتطبيق غرفة المحادثة، عرَّفنا العملاء بواسطة الصنف المتداخل ChatClient داخل البرنامج ChatRoomWindow. يُعرِّف البرنامج متغير النسخة connection من النوع ChatClient، والذي يُمثِّل اتصال البرنامج مع الموزع. عندما يُدْخِل المُستخدِم رسالة، تُرسَل الرسالة إلى الموزع باستدعاء التابع التالي: connection.send(message); عندما يَستقبِل الموزع رسالة، فإنه يُغلِّفها ضمن كائنٍ من النوع ForwardedMessage مع إضافة رقم مُعرِّف الهوية الخاص بالعميل المُرسِل للرسالة. بعد ذلك، يُرسِل الموزع نسخةً من هذا الكائن إلى كل عميلٍ متصلٍ بالموزع، بما في ذلك العميل الذي أرسل الرسالة. من الجهة الأخرى، عندما يَستقبِل العميل رسالةً من الموزع، يُستدعَى التابع messageReceived()‎ المُعرَّف بالكائن المنتمي إلى الصنف ChatClient؛ ويُعيد الصنف ChatClient تعريف ذلك التابع لكي يجعله يضيف الرسالة إلى المساحة النصية ببرنامج ChatClientWindow. اختصارًا لما سبق، تُرسَل أي رسالةٍ يُدْخِلها أي مُستخدِم إلى الموزع بينما يُرسِل الموزع نُسخًا من أي رسالةٍ يَستقبِلها إلى كل عميل، ولذلك يتسلَّم جميع العملاء نفس مجرى الرسائل من الموزع. بالإضافة إلى ما سبق، يُنبَّه كل عميلٍ عندما يَتصِل لاعبٌ جديدٌ بالموزع أو عندما يُغلِق لاعبٌ معينٌ اتصاله مع الموزع، وكذلك عندما يَفقِد هو نفسه اتصاله مع الموزع. يعيد البرنامج ChatClient تعريف التوابع التي تُستدعَى عند وقوع تلك الأحداث ليتمكَّن من إضافة رسائلٍ مناسبة إلى المساحة النصية. نستعرِض فيما يلي تعريف صنف العميل الخاص بتطبيق غرفة المحادثة: // 1 private class ChatClient extends Client { // 2 ChatClient(String host) throws IOException { super(host, PORT); } //3 protected void messageReceived(Object message) { if (message instanceof ForwardedMessage) { // لا يوجد أنواع رسائلٍ أخرى متوقعة ForwardedMessage bm = (ForwardedMessage)message; addToTranscript("#" + bm.senderID + " SAYS: " + bm.message); } } // 4 protected void connectionClosedByError(String message) { addToTranscript( "Sorry, communication has shut down due to an error:\n " + message ); Platform.runLater( () -> { sendButton.setDisable(true); messageInput.setEditable(false); messageInput.setDisable(true); messageInput.setText(""); }); connected = false; connection = null; } // 5 protected void playerConnected(int newPlayerID) { addToTranscript( "Someone new has joined the chat room, with ID number " + newPlayerID ); } // 6 protected void playerDisconnected(int departingPlayerID) { addToTranscript( "The person with ID number " + departingPlayerID + " has left the chat room"); } } // end nested class ChatClient حيث أن: [1] يتصِل برنامج ChatClient مع الموزع ويُستخدَم لإرسال الرسائل واستقبالها من وإلى الموزع. تكون الرسائل المُستقبَلة من الموزع من النوع ForwardedMessage وتحتوي على رقم مُعرِّف الهوية الخاص بالمُرسِل وعلى السلسلة النصية التي أرسلها المُستخدِم. [2] يُنشِئ اتصالًا مع خادم غرفة المحادثة بالحاسوب المُخصَّص. [3] يُنفَّذ هذا التابع عند استقبال رسالةٍ من الخادم. ينبغي أن تكون الرسالة من النوع ForwardedMessage، وتُمثِّل شيئًا أرسله أحد المُستخدِمين المتواجدين بغرفة المحادثة. تُضَاف الرسالة ببساطة إلى الشاشة مع رقم مُعرِّف الهوية الخاص بالمُرسِل. [4] يُستدعَى هذا التابع عندما يُغلَق الاتصال مع عميلٍ ما نتيجةً لحدوث خطأ ما (يحدث ذلك عند غلق الخادم). [5] يُظهِر رسالةً على الشاشة عندما ينضم شخصٌ ما إلى غرفة المحادثة. [6] يُظهر رسالةً على الشاشة عندما يغادر شخصٌ ما غرفة المحادثة. يُمكِنك الإطلاع على ملفات الشيفرة المصدرية الخاصة بتطبيق غرفة المحادثة من حزمة netgame.chat. لعبة إكس-أو عبر الشبكة تطبيقنا الثاني سيكون لعبةً بسيطةً للغاية: لعبة إكس-أو الشهيرة؛ حيث يضع لاعبان بتلك اللعبة علاماتٍ بلوحةٍ مكوَّنة من 3 صفوف و3 أعمدة. يلعب أحدهما الرمز X بينما يلعب الآخر O، ويكون الهدف هو الحصول على 3 رموز X أو 3 رموز O بصفٍ واحد. تتكوَّن حالة لعبة إكس-أو بأي لحظة من مجموعةٍ من المعلومات، مثل مكونات اللوحة الحالية؛ واللاعب الذي حان دوره؛ وفي حال انتهت اللعبة، فمن الفائز ومن الخاسر. إذا لم نكن نُطوّر برنامجًا شبكيًا، كان بإمكاننا اِستخدَام متغيرات نسخة لتمثيل حالة اللعبة بحيث يستعين بها البرنامج لتحديد طريقة رسم اللوحة وطريقة الاستجابة لأفعال المُستخدِم مثل نقرات الفأرة، ولكن نظرًا لأننا نُطوِّر برنامجًا شبكيًا من اللعبة، سنَستخدِم ثلاثة كائنات؛ بحيث ينتمي اثنان منهما إلى صنف العميل الذي يُوفِّر واجهة المُستخدِمين للعبة بالإضافة إلى الكائن المُمثِل للموزع والمسؤول عن إدارة الاتصالات مع العملاء. لا تتواجد تلك الكائنات بنفس الحاسوب، وبالتالي لا يُمكِنهم بالتأكيد اِستخدَام نفس متغيرات الحالة؛ ومع ذلك هناك حالةٌ واحدةٌ مُحدَّدة للعبة بأي لحظة، وينبغي أن يكون اللاعبان على درايةٍ بتلك الحالة. يُمكِننا حل تلك المشكلة بتخزين الحالة "الرسمية" للعبة بالموزع، وإرسال نسخةٍ منها إلى كل لاعب كلما تغيرت. لا يستطيع اللاعبان تعديل الحالة مباشرةً، فعندما يُنفِّذ لاعبٌ فِعلًا معينًا مثل وضع قطعةٍ على اللوحة، يُرسَل ذلك الفعِل إلى الموزع بهيئة رسالة. بعد ذلك، يُعدِّل الموزع حالة اللعبة لكي تَعكس نتيجة ذلك الفعل، ثم يُرسِل الحالة الجديدة لكلا اللاعبين، وعندها تُحدَّث نافذتهما لتعكس الحالة الجديدة. يُمكِننا بتلك الطريقة ضمَان ظهور اللعبة بنفس الحالة دائمًا عند كلا اللاعبين. بدلًا من إرسال نسخةٍ كاملةٍ من الحالة بكل مرةٍ تُعدَّل فيها، قد نُرسِل ذلك التعديل فقط. ولكن، سيتطلَّب ذلك استخدام طريقةٍ ما لتشفير التعديلات وتحويلها إلى رسائلٍ يُمكِن إرسالها عبر الشبكة. نظرًا لأن الحالة بهذا البرنامج بسيطةٌ للغاية، فإنه من الأسهل إرسالها كاملة. ستَجِد البرنامج إكس-أو مُعرَّفًا بعدة أصناف بحزمة netgame.tictactoe؛ حيث يُمثِّل الصنف TicTacToeGameState حالة اللعبة، ويتضمَّن التابع التالي: public void applyMessage(int senderID, Object message) يُعدِّل ذلك التابع حالة اللعبة لتَعكِس تأثير الرسالة المُرسَلة من إحدى اللاعبين؛ حيث تُمثِّل تلك الرسالة فعِلًا معينًا أقدم عليه اللاعب، مثل النقر على اللوحة. لا يَعرِف الصنف Hub أي شيءٍ عن تطبيق إكس-أو، ولأن الموزع بهذا التطبيق ينبغي أن يحتفظ بحالة اللعبة، كان من الضروري تعريف صنفٍ فرعي مشتقٍ من الصنف Hub. عرَّفنا الصنف TicTacToeGameHub، وهو صنفٌ بسيطٌ للغاية يُعيد تعريف التابع messageReceived()‎ ليجعله يَستجيب لرسائل اللاعبين بتطبيقها على حالة اللعبة ثم إرسال نسخةٍ من الحالة الجديدة لكلا اللاعبين. يُعيد ذلك الصنف تعريف التابعين playerConnected()‎ و playerDisconnected()‎ أيضًا لكي يُنفِّذا الفعِل المناسب؛ لأن المباراة تُلعَب فقط عندما يكون هناك لاعبين متصلين. انظر الشيفرة المصدرية كاملة: package netgame.tictactoe; import java.io.IOException; import netgame.common.Hub; // 1 public class TicTacToeGameHub extends Hub { private TicTacToeGameState state; // يسجّل حالة اللعبة // 2 public TicTacToeGameHub(int port) throws IOException { super(port); state = new TicTacToeGameState(); setAutoreset(true); } // 3 protected void messageReceived(int playerID, Object message) { state.applyMessage(playerID, message); sendToAll(state); } // 4 protected void playerConnected(int playerID) { if (getPlayerList().length == 2) { shutdownServerSocket(); state.startFirstGame(); sendToAll(state); } } // 5 protected void playerDisconnected(int playerID) { state.playerDisconnected = true; sendToAll(state); } } حيث أن: [1]: يُمثِّل الموزع hub باللعبة إكس-أو. هناك موزعٌ واحدٌ فقط باللعبة، ويتصِل كلا اللاعبين بنفس الموزع، الذي تتواجد بد المعلومات الرسمية عن حالة اللعبة؛ وعند حدوث تغييرات بتلك الحالة، يُرسِل الموزع الحالة الجديدة لكلا اللاعبين ليتأكّد من ظهور نفس الحالة لكليهما. [2]: يُنشِئ موزعًا يَستمِع إلى رقم المنفذ المُخصَّص. يَستدعِي التابع setAutoreset(true)‎ لكي يتأكّد من إعادة ضبط مجرى الخرج الخاص بكل عميل تلقائيًا قبل إرسال أي رسالة. يُعدّ ذلك ضروريًا لأن نفس الكائن المُمثِل للحالة يُرسَل مرارً وتكرارً مع بعض التعديلات. يُمثِّل port رقم المنفذ الذي سيستمِع إليه الموزع. قد يُبلِّغ التابع عن استثناءِ من النوع IOException في حالة عدم التمكُّن من فتح اتصالٍ برقم المنفذ المُخصَّص. [3]: يُنفَّذ هذا التابع عند وصول رسالةٍ من عميل؛ حيث تُطبَّق الرسالة عندئذٍ على حالة اللعبة باستدعاء التابع state.applyMessage()‎. تُنقَل الحالة بعد ذلك إلى جميع اللاعبين المتصلين إذا كانت قد تغيرت. [4]: يُستدعَى هذا التابع عند اتصال لاعب؛ وإذا كان ذلك اللاعب هو اللاعب الثاني، يُغلَق مقبس الاستماع الخاص بالخادم (يُسمح فقط وجود لاعبين). بعد ذلك، تبدأ اللعبة الأولى وتُنقَل الحالة الجديدة إلى كلا اللاعبين. [5]: يُستدعَى هذا التابع عندما يغلق لاعبٌ اتصاله. يُنهِي ذلك اللعبة ويتسبَّب بإغلاقها عند اللاعب الآخر أيضًا. يحدث ذلك بضبط قيمة state.playerDisconnected إلى true ثم إرسال الحالة الجديدة إلى اللاعب الآخر إذا كان موجودًا لتبليغه بأن اللعبة قد انتهت. يُمثَّل الصنف TicTacToeWindow واجهة اللاعب إلى اللعبة. كما حدث في تطبيق غرفة المحادثة، يُعرِّف ذلك الصنف صنفًا فرعيًا متداخلًا مُشتقًا من الصنف Client لتمثيل اتصال العميل مع الموزع. عندما تتغيّر حالة اللعبة، تُرسَل رسالةٌ إلى كل عميل، ويُستدعَى التابع messageReceived()‎ الخاص بالعميل ليُعالِج تلك الرسالة. يستدعي ذلك التابع بدوره التابع newState()‎ المُعرَّف بالصنف TicTacToeWindow لتحديث النافذة. اِستخدَمنا Platform.runLater()‎ لكي نَستدعيه بخيط تطبيق JavaFX : protected void messageReceived(Object message) { if (message instanceof TicTacToeGameState) { Platform.runLater( () -> newState( (TicTacToeGameState)message ) ); } } والآن، لنُشغِّل تطبيق إكس-أو، ينبغي أن يُشغِّل اللاعبان البرنامج Main.java الموجود بحزمة netgame.tictactoe، حيث يعرِض البرنامج نافذةً للمُستخدِم تُمكِّنه من اختيار بدء لعبة جديدة أو الانضمام إلى لعبةٍ موجودة. إذا بدأ المُستخدِم لعبةً جديدة، يُنشِئ البرنامج موزعًا من النوع TicTacToeHub لإدارة اللعبة، ويَعرِض نافذةً جديدةً من النوع TicTacToeWindow، وتكون متصلةً بالموزع على الفور. تبدأ اللعبة بمجرد اتصال لاعبٍ آخر بذلك الموزع. في المقابل، إذا اختار المُستخدِم الانضمام إلى لعبةٍ موجودة، لا يُنشِئ البرنامج موزعًا جديدًا، وإنما يُنشِئ نافذةً من النوع TicTacToeWindow، وتحاول تلك النافذة الاتصال بالموزع الذي أنشأه اللاعب الأول. ولذلك، لا بُدّ أن يَعرِف اللاعب الثاني اسم الحاسوب الذي يَعمَل عليه برنامج اللاعب الأول. إذا أردت اختبار البرنامج، يُمكِنك تشغيل كل شيء بنفس الحاسوب واستخدام "localhost" اسمًا للحاسوب. يُعدّ هذا البرنامج هو أول برنامج يَستخدِم نافذتين مختلفتين نراه. لاحِظ أن الصنف TicTacToeWindow مُعرَّف على أنه صنفٌ فرعي من الصنف Stage الذي يُستخدَم لتمثيل النوافذ بمكتبة JavaFX. تبدأ برامج JavaFX "بمرحلة رئيسية primary stage" يُنشئِها النظام ويُمرِّرها معاملًا إلى التابع start()‎، ولكن يستطيع التطبيق إلى جانب ذلك إنشاء أي نوافذ إضافية. لعبة بوكر Poker عبر الشبكة ننتقل الآن إلى التطبيق الذي يُعدّ المُلهم لإطار عمل netgame، وهو تطبيق لعبة بوكر. بالتحديد، نفَّذنا نسخةً من النسخة التقليدية "سحب خمسة بطاقات five card draw" من تلك اللعبة وبلاعبين. هذا التطبيق معقدٌ نوعًا ما، ولن نناقشه تفصيليًا هنا، ولكننا سنوضِّح تصميمه العام. يُمكِنك الإطلاع على الشيفرة المصدرية كاملة بحزمة netgame.fivecarddraw. ينبغي أن تكون على درايةٍ بتلك النسخة من اللعبة لتتمكَّن من فهم البرنامج بالكامل. تتشابه لعبة Poker مع لعبة إكس-أو في العموم، حيث يتوفَّر الصنف Main الذي يُشغِّله كلا اللاعبين. يبدأ اللاعب الأول لعبةً جديدة بينما ينضم اللاعب الثاني إلى اللعبة الموجودة. يُمثِّل الصنف PokerGameState حالة اللعبة؛ بينما يدير الصنف الفرعي PokerHub اللعبة. لعبة Poker أكثر تعقيدًا من لعبة إكس-أو، وهو ما ينعكس على حالة اللعبة، فهي معقدةٌ بالموازنة مع حالة لعبة إكس-أو، وبالتالي فإننا لا نرغب بنشر النسخة الجديدة من حالة اللعبة كاملةً إلى اللاعبين في كل مرة نُجرِي فيها تعديلًا صغيرًا على الحالة. علاوة على ذلك، لا معنى لأن يَعرِف اللاعبان حالة اللعبة بالكامل بما في ذلك بطاقات الخصم وبطاقات اللعب التي يَسحَب منها اللاعبان. لن تكون برامج العملاء مضطّرةً لعرض حالة اللعبة بالكامل للاعبين، ولكنهما قد يَستبدلا تلك البرامج ببرامجٍ أخرى ويتمكَّنا من الغش بسهولة. ولذلك، سيكون الموزع من النوع PokerHub فقط على درايةٍ بكامل حالة اللعبة بهذا التطبيق. سيُمثِّل كائنٌ من الصنف PokerGameState حالة اللعبة من وجهة نظر لاعبٍ واحدٍ فقط. عندما تتغير حالة اللعبة، سيُنشِئ الموزع كائنين مختلفين من النوع PokerGameState يُمثِّلان حالة اللعبة من وجهة نظر كل لاعب، ثم سيُرسِل الكائن المناسب لكل لاعب. يُمكِنك الإطلاع على الشيفرة المصدرية لمزيدٍ من التفاصيل. تُعدّ موازنة يد لاعبين لمعرفة أيهما أكبر الجزء الأصعب بلعبة البوكر، وقد عالجناها بهذا التطبيق بالصنف PokerRank. ربما تجده مفيدًا بألعاب بوكر أخرى. ترجمة -بتصرّف- للقسم Section 5: Network Programming Example: A Networked Game Framework من فصل Chapter 12: Threads and Multiprocessing من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: الخيوط Threads والشبكات في جافا تواصل تطبيقات جافا عبر الشبكة تطبيق عملي: بناء لعبة ورق في جافا
  7. ناقشنا بالمقال السابق عدة أمثلة على البرمجة الشبكية، حيث تعلّمنا طريقة إنشاء اتصالٍ شبكيٍ وطريقة تبادل المعلومات عبر تلك الاتصالات، ولكننا لم نتعامل مع واحدةٍ من أهم خاصيات البرمجة عبر الشبكة، وهي حقيقة كون الاتصال الشبكي غير متزامن asynchronous. بالنسبة لبرنامج معين بأحد طرفي اتصال شبكي معين، قد تصِل الرسائل من الطرف الآخر بأي لحظة؛ أي يكون وصول رسالةٍ معينة حدثًا خارج نطاق تحكُّم البرنامج المُستقبِل للرسالة، ولذلك ربما تكون الاستعانة بواجهة برمجة تطبيقات API مبنيةٍ على الأحداث وخاصةٍ بالشبكات فكرةً جيدةً تُمكِّننا من التعامل مع الطبيعة غير المتزامنة للاتصالات عبر الشبكة؛ ولكن لا تعتمد جافا في الواقع على تلك الطريقة، حيث تَستخدِم برمجة الشبكات بلغة جافا الخيوط threads عمومًا. مشكلة الدخل والخرج المعطل Blocking I/O كما ناقشنا في مقال تواصل تطبيقات جافا عبر الشبكة، تَستخدِم برمجة الشبكات المقابس sockets، والتي تشير إلى أحد طرفي اتصال شبكي. يَملُك كل مقبس مجرى دْخَل input stream ومجرى خرج output stream، وتُنقَل البيانات المُرسَلة إلى مجرى الخرج بأحد طرفي الإتصال عبر الشبكة إلى مجرى الدْخَل بالطرف الآخر. إذا أراد برنامجٌ معينٌ قراءة البيانات من مجرى الدْخَل الخاص بالمقبس، فعليه استدعاء أحد توابع قراءة البيانات من مجرى الدْخَل. قد تكون البيانات قد وصلت بالفعل قبل استدعاء التابع، وفي تلك الحالة يَسترجِع التابع البيانات ويعيدها على الفور. ومع ذلك، قد يضطّر التابع لانتظار وصول البيانات من الطرف الآخر من الاتصال؛ أي يُعطَّل block كُلًا من التابع والخيط الذي استدعاه إلى أن تَصِل البيانات. قد يتعطَّل أيضًا تابع إرسال البيانات إلى مجرى الخرج الخاص بالمقبس؛ حيث يحدث ذلك إذا حاول البرنامج إرسال البيانات إلى المقبس بسرعةٍ أكبر من سرعة نقل البيانات عبر الشبكة. يَستخدِم المقبس مخزنًا مؤقتًا buffer لحمل البيانات المُفترَض نقلها عبر الشبكة. تذكَّر أن المخزن المؤقت هو مساحةٌ من الذاكرة تَعمَل بطريقةٍ مشابهةٍ للرتل queue. يضع تابع الخرج البيانات بالمخزن المؤقت، ثم يقرأها برنامج منخفض المستوى، ويُرسِلها عبر الشبكة. إذا كان المخزن ممتلئًا، يتعطَّل تابع الخرج إلى أن تتوفَّر مساحةٌ بالمخزن. يَستخدِم الاتصال الشبكي دخلًا وخرجًا مُعطِّلًا؛ لأن عمليات الدْخَل والخَرْج عبر الشبكة قد تتسبَّب بحدوث تعطّيلٍ لفتراتٍ غير محددة، ولذلك لا بُدّ أن تكون البرامج المُستخدِمة للشبكات مهيأةً للتعامل مع ذلك التعطيل. في بعض الحالات، قد يكون إغلاق البرنامج لجميع العمليات الأخرى وانتظار البيانات المطلوبة مقبولًا، مثلما يحدث عندما يُحاوِل برنامج سطر أوامر قراءة البيانات التي ينبغي أن يُدْخِلها المُستخدِم. في الواقع، تُعدّ مدخلات المُستخدِم نوعًا من الدْخل والخَرْج المُعطِّل. ومع ذلك، تَسمَح الخيوط لبعض أجزاء البرنامج بالاستمرار بالعمل بينما بعض الأجزاء الأخرى مُعطَّلة. على سبيل المثال، ربما يكون من المناسب اِستخدَام خيطٍ واحدٍ مع برنامج عميل client شبكي يُرسِل طلباتٍ إلى خادم server، إذا لم يكن هناك أي شيءٍ ينبغي للبرنامج فعله بينما ينتظر رد الخادم. في المقابل، قد يتصل برنامج خادم شبكي مع عدة عملاء بنفس الوقت، وبالتالي بينما ينتظر الخادم وصول البيانات من العميل، سيكون لديه الكثير ليفعله خلال تلك الفترة، مثل الاتصال مع عملاءٍ آخرين. عندما يَستخدِم الخادم خيوطًا مختلفةً لمعالجة الاتصالات مع العملاء المختلفة، لن يؤدي التعطُّل الناتج عن عميلٍ معين إلى إيقاف تعامل الخادم مع العملاء الآخرين. يختلف استخدام الخيوط للتعامل مع مشكلة الدْخَل والخَرْج المعُطِّل جذريًا عن استخدامها لتسريع عملية المعالجة. عندما نَستخدِم الخيوط لزيادة سرعة المعالجة -كما ناقشنا بالقسم مجمع الخيوط وأرتال المهام من مقال الخيوط threads والمعالجة على التوازي في جافا السابق- كان من المنطقي استخدام خيطٍ واحدٍ لكل معالج. إذا كان الحاسوب يحتوي على معالج واحد، فلن يزيد اِستخدَام أكثر من خيط ن سرعة المعالجة إطلاقًا، بل قد يُبطئها نتيجةً للحمل الزائد overhead الناتج عن إنشاء الخيوط وإداراتها. من الجهة الأخرى، إذا كان الهدف هو حل مشكلة الدْخَل والخَرْج المُعطِّل، فسييكون من المنطقي اِستخدَام خيوطٍ بعددٍ أكبر مما يحتويه الحاسوب من معالجات؛ لأن كثيرًا من تلك الخيوط قد يتعطَّل بأي لحظة، وستتنافس بالتالي الخيوط غير المُعطَّلة فقط على زمن المعالجة. إذا أردنا تشغيل جميع المعالجات على الدوام، فلا بُدّ من وجود خيطٍ واحدٍ نشطٍ لكل معالج (في الواقع، أقل من ذلك بعض الشيء لنَسمَح بالاختلافات بين عدد الخيوط النشطة من فترةٍ لأخرى). تقضِي الخيوط ببرامج الخوادم مثلًا معظم الوقت مُعطَّلة بانتظار اكتمال عمليات الدخل والخرج؛ فإذا كانت الخيوط مُعطَّلةً بنسبة 90% من الوقت مثلًا، فسنحتاج إلى خيوطٍ بعددٍ يُقارِب عشرة أضعاف عدد المعالجات؛ أي تستطيع الخوادم الاستفادة من اِستخدَام عددٍ كبيرٍ من الخيوط حتى لو كان الحاسوب يحتوي على معالجٍ واحد. برنامج محادثة عبر الشبكة غير متزامن سنفحص الآن المثال الأول على استخدام الخيوط ببرامج الاتصال الشبكي، وسيكون برنامج محادثة بواجهة مُستخدِم رسومية GUI. اِستخدَمنا بروتوكولًا للاتصال في برامج المحادثة عبر سطر الأوامر CLChatClient.java و CLChatServer.java في مقال تواصل تطبيقات جافا عبر الشبكة المشار إليه بالأعلى؛ حيث بعدما يُدْخِل المُستخدِم بأحد طرفي الاتصال رسالةً، لا بُدّ له أن ينتظر ردًا من الطرف الآخر. سيكون من الأفضل لو أنشأنا برنامج محادثة غير متزامن asynchronous، حيث سيتمكَّن المُستخدِم من الاستمرار بكتابة الرسائل وإرسالها دون انتظار أي ردودٍ من الطرف الآخر، كما ستُعرَض الرسائل الآتية من الطرف الآخر بمجرد وصولها. في الواقع، من الصعب تنفيذ ذلك ببرنامج سطر أوامر، ولكنه بديهي ببرنامجٍ ذي واجهة مُستخدِم رسومية. تتمثل فكرة البرنامج باختصار في إنشاء خيطٍ وظيفته قراءة الرسائل القادمة من الطرف الآخر من الاتصال، وتُعرَض للمُستخدِم بمجرد وصولها، ثم يُعطَّل خيط قراءة الرسائل إلى أن تَصِل رسائلٌ أخرى. تستطيع الخيوط الأخرى الاستمرار بالعمل بينما ذلك الخيط مُعطَّل؛ حيث سيتمكَّن خيط معالجة الأحداث events بالتحديد من الاستجابة لما يفعله المُستخدِم، وسيتمكَّن بالتالي من إرسال الرسائل بعد أن يكتبها المُستخدِم مباشرةً. يَعمَل برنامج "GUIChat" مثل عميلٍ أو خادم.و يحتوي البرنامج على زر "Listen"، وعندما ينقر عليه المُستخدِم، يُنشِئ البرنامج مقبس خادم يَستمِع إلى طلبات الاتصال القادمة، وبالتالي يَعمَل البرنامج خادمًا. يحتوي البرنامج أيضًا على زر "Connect"، وعندما يَنقُر عليه المُستخدِم، يُرسِل البرنامج طلب اتصال، وبالتالي يَعمَل البرنامج عميلًا. يَستمِع الخادم كما جرت العادة إلى رقم منفذٍ port number معين، ولا بُدّ أن يعرف العميل الحاسوب الذي يَعمَل عليه الخادم ورقم المنفذ الذي يَستمِع إليه. تحتوي نافذة البرنامج "GUIChat" على صناديق إدخال تَسمَح للمُستخدِم بإدخال تلك المعلومات. بمجرد بدء الاتصال بين برنامجي "GUIChat"، يستطيع المُستخدِم الموجود بأي طرفٍ من طرفي الاتصال إرسال الرسائل إلى المُستخدِم بالطرف الآخر. تحتوي النافذة على صندوق إدخالٍ يَسمَح للمُستخدِم بكتابة الرسائل، ثم النقر على زر العودة الى بداية السطر لإرسالها. يستجيب خيط معالجة الأحداث إلى الحدث الناشئ عن كتابة الرسالة بإرسالها. وفي المقابل، تُستقبَل الرسائل القادمة بخيطٍ منفصلٍ جُلّ ما يفعله هو انتظار الرسائل القادمة. يكون ذلك الخيط مُعطَّلًا أثناء انتظاره؛ وعند وصول رسالة، يَعرِضها للمُستخدِم. تحتوي نافذة البرنامج على مساحةٍ كبيرةٍ لِعرض الرسائل المُرسَلة والمُستقبَلة إلى جانب بعض المعلومات الأخرى عن الاتصال الشبكي. يُفضَّل أن تُصرِّف compile شيفرة البرنامج GUIChat.java، وتُجرِّبه. إذا أردت تجريبه على حاسوبٍ واحد، يُمكِنك تشغيل نسختين من البرنامج على نفس الحاسوب، وإنشاء الاتصال بين نافذتي البرنامج باستخدام "localhost" أو "127.0.0.1" على أنه اسمٌ للحاسوب. حاوِل أيضًا قراءة الشيفرة المصدرية للبرنامج. سنناقش بعضها فقط فيما يلي. يَستخدِم البرنامج الصنف المُتداخِل ConnectionHandler المُشتَق من الصنف Thread لمعالجة معظم المهام المُتعلّقة بالشبكة، حيث يتولى الخيط المُمثَّل بواسطة الصنف ConnectionHandler مهمة إنشاء الاتصال الشبكي، وقراءة الرسائل القادمة بعد فتح الاتصال. نضمَن عدم تعطُّل واجهة المُستخدِم الرسومية أثناء إنشاء الاتصال بوضعنا الشيفرة المسؤولة عن إنشاء الاتصال واستقبال الرسائل بخيطٍ منفصل. يُعدّ فتح الاتصال عمليةً مُعطِّلة، مثل عملية قراءة الرسائل، وقد تَستغرِق بعض الوقت. يُنشِئ الصنف ConnectionHandler الاتصال بكلا الحالتين؛ أي عندما يَعمَل البرنامج خادمًا أو عميلًا. يُنشَأ ذلك الخيط عندما ينقر المُستخدم على زر "Listen" أو زر "Connect"؛ حيث يؤدي زر "Listen" إلى عمل البرنامج على أنه خادم؛ بينما يؤدي زر"Connect" إلى عمله عميلًا. يُميّز الصنف ConnectionHandler بين تلك الحالتين بتعريف بانيين constructors معروضين بالأسفل. يَعرِض التابع postMessage()‎ رسالةً بالمساحة النصية الموجودة بالنافذة لكي يراها المُستخدِم. ألقِ نظرةً على تعريف كلا البانيين: // 1 ConnectionHandler(int port) { // For acting as the "server." state = ConnectionState.LISTENING; this.port = port; postMessage("\nLISTENING ON PORT " + port + "\n"); try { setDaemon(true); } catch (Exception e) {} start(); } // 2 ConnectionHandler(String remoteHost, int port) { // For acting as "client." state = ConnectionState.CONNECTING; this.remoteHost = remoteHost; this.port = port; postMessage("\nCONNECTING TO " + remoteHost + " ON PORT " + port + "\n"); try { setDaemon(true); } catch (Exception e) {} start(); } حيث: [1]: تعني اِستمِع إلى طلب اتصالٍ برقم منفَّذٍ مُخصَّص. لا يفعل الباني أي عملياتٍ شبكية، وإنما يضبُط فقط بعض متغيرات النسخ ويُشغِّل الخيط. يَستمِع الخيط إلى طلب اتصالٍ واحدٍ فقط، ثم يَغلِق مقبس الخادم. [2]: تعني اِفتح اتصال مع الحاسوب برقم المنفَّذ المُخصَّص. لا يفعل الباني أي عمليات شبكية، وإنما يضبُط فقط بعض متغيرات النسخ ويُشغِّل الخيط. لاحِظ أن state هو متغير نسخة instance variable من نوع التعداد enumerated type التالي: enum ConnectionState { LISTENING, CONNECTING, CONNECTED, CLOSED }; تُمثِّل قيم ذلك التعداد الحالات المختلفة للاتصال الشبكي. يُفضَّل غالبًا التعامل مع الاتصال الشبكي مثل آلة حالة state machine (انظر قسم آلات الحالة من مقال تعرف على أهم الأحداث والتعامل معها في مكتبة جافا إف إكس JavaFX)؛ نظرًا لاعتماد الاستجابة إلى الأحداث المختلفة عادةً على الحالة التي كان عليها الاتصال عند وقوع الحدث. يُحدِّد الخيط ما إذا كان عليه التصرف مثل خادمٍ أو عميل أثناء إنشاء الاتصال من خلال ضبط قيمة المتغير state إلى القيمة LISTENING أو CONNECTING. بمجرد بدء تشغيل الخيط، يُنفَّذ التابع run()‎ المُعرَّف على النحو التالي: // 1 public void run() { try { if (state == ConnectionState.LISTENING) { // أنشِئ اتصالًا بصفة خادم listener = new ServerSocket(port); socket = listener.accept(); listener.close(); } else if (state == ConnectionState.CONNECTING) { // أنشِئ اتصالًا بصفة عميل socket = new Socket(remoteHost,port); } connectionOpened(); // 2 while (state == ConnectionState.CONNECTED) { // 3 String input = in.readLine(); if (input == null) connectionClosedFromOtherSide(); // أغلق المقبس وبلِّغ المُستخدِم else received(input); // بلِّغ الرسالة إلى المُستخدم } } catch (Exception e) { // 4 if (state != ConnectionState.CLOSED) postMessage("\n\n ERROR: " + e); } finally { // Clean up before terminating the thread. cleanUp(); } } حيث أن: [1]: يُستدعَى التابع run()‎ بواسطة الخيط. يَفتَح التابع اتصالًا مثل خادمٍ أو عميل بالاعتماد على نوع الباني المُستخدَم. [2]: جهِّز الاتصال بما في ذلك إنشاء كائن من الصنف BufferedReader لقراءة الرسائل القادمة. [3]: اقرأ سطرًا نصيًا واحدًا من الطرف الآخر من الاتصال وبلِّغه للمُستخدِم. [4]: حدث خطأ. بلِّغ المُستخدِم إذا لم يكن قد أغلق الاتصال، لأنه قد يكون الخطأ المُتوقَّع حدوثه عند غلق مقبس الاتصال. يَستدعِي هذا التابع مجموعةً من التوابع الأخرى لإنجاز بعض الأمور، ولكن بإمكانك فهم الفكرة العامة لطريقة عمله. بعد فتح الاتصال مثل خادمٍ أو عميل، يُنفِّذ التابع run()‎ حلقة while يَستقبِل خلالها الرسائل من الطرف الآخر من الاتصال ويعالجها إلى أن يُغلَق الاتصال. من المهم فهم طريقة غلق الاتصال؛ حيث تُوفِّر نافذة البرنامج GUIChat الزر "Disconnect"، وعندما ينقر عليه المُستخدِم، يُغلَق الاتصال. يَستجِيب البرنامج إلى ذلك الحدث بغلق المقبس المُمثِّل للاتصال، وكذلك بضبط حالة الاتصال إلى CLOSED. في تلك الأثناء، قد يكون خيط معالجة الاتصال مُعطَّلًا بانتظار الرسالة التالية نتيجة لاستدعائه التابع in.readLine()‎. عندما يُغلِق خيط واجهة المُستخدِم الرسومية المقبس، يفشل ذلك التابع ويُبلِّغ عن استثناءٍ exception يتسبَّب بانتهاء الخيط. إذا كان خيط معالجة الاتصال واقعًا بين لحظتي استدعاء التابع in.readLine()‎ عند غلق المقبس، تنتهي حلقة while لأن حالة الاتصال تتبدَّل من CONNECTED إلى CLOSED. يؤدي غلق نافذة البرنامج إلى غلق الاتصال بنفس الطريقة. علاوةً على ذلك، قد يُغلَق الاتصال بواسطة المُستخدِم على الطرف الآخر، ويُغلَق في تلك الحالة مجرى الرسائل القادمة، ويُعيد التابع in.readLine()‎ بهذا الطرف من الاتصال القيمة null، وهو ما يُشير إلى نهاية المجرى، ويُمثِّل إشارةً إلى أن المُستخدِم الآخر قد أغلق الاتصال. سنُلقِي نظرةً أخيرة على شيفرة البرنامج GUIChat وخاصةً على التوابع المسؤولة عن إرسال الرسائل واستقبالها. يَستدعِي الخيطان تلك التوابع؛ حيث يَستدعِي خيط معالجة الأحداث التابع send()‎ بغرض إرسال رسالةٍ إلى المُستخدِم على الطرف الآخر من الاتصال استجابةً لفِعِل المُستخدِم. قد تؤدي عملية إرسال البيانات إلى حدوث تعطيل -وإن كان احتمال ذلك ضئيلًا- إذا أصبح المخزن المؤقت buffer الخاص بالمقبس ممتلئًا. ربما يأخذ برنامج أكثر تطورًا ذلك الاحتمال بالحسبان. يَستخدِم التابع كائنًا من النوع PrintWriter، اسمه هو out، لإرسال الخرج إلى مجرى الخرج الخاص بالمقبس. لاحِظ أن التابع متزامنٌ ليَمنَع تعديل حالة الاتصال أثناء إجراء عملية الإرسال. انظر شيفرة التابع: // 1 synchronized void send(String message) { if (state == ConnectionState.CONNECTED) { postMessage("SEND: " + message); out.println(message); out.flush(); if (out.checkError()) { postMessage("\nERROR OCCURRED WHILE TRYING TO SEND DATA."); close(); // أغلِق الاتصال } } } وتعني [1]: يُرسِل رسالةً إلى الطرف الآخر من الاتصال ويعرضها على الشاشة. ينبغي أن يُستدعَى هذا التابع عندما تكون حالة الاتصال ConnectionState.CONNECTED فقط. يتجاهل التابع الاستدعاء بالحالات الأخرى. يَستدعِي خيط معالجة الأحداث التابع received()‎ بعد قراءة رسالةٍ من المُستخدِم على الطرف الآخر من الاتصال، حيث يعرِض التابع الرسالة للمُستخدِم. لاحِظ أن التابع متزامن لتجنُّب حالة التسابق race condition التي يُمكِنها أن تحدُث إذا بدَّل خيطٌ آخر حالة الاتصال بينما ما يزال التابع قيد التنفيذ. ألقِ نظرةً على شيفرة التابع: // 1 synchronized private void received(String message) { if (state == ConnectionState.CONNECTED) postMessage("RECEIVE: " + message); } [1] يُستدعَى هذا التابع بواسطة التابع run()‎ عند استقبال رسالةٍ من الطرف الآخر من الاتصال. يُظهِر التابع الرسالة على الشاشة إذا كانت حالة الاتصال CONNECTED فقط؛ لأنها قد تَصِل بعد أن ينقر المُستخدم على زر "Disconnect"، وبالتالي لا ينبغي أن يراها المُستخدِم. خادم شبكي متعدد الخيوط تُستخدَم الخيوط عادةً ببرامج الخوادم؛ لأنها تَسمَح للخادم بالتعامل مع مجموعةٍ من العملاء بنفس الوقت. عندما يبقى عميلٌ معينٌ متصلًا لفترةٍ طويلةٍ نسبيًا، لا ينبغي أن يضطّر العملاء الآخرون إلى انتظار الخدمة، وحتى إن كان من المتوقَّع أن يكون تعامل الخادم مع كل عميلٍ قصيرًا للغاية، حيث لا يُمكِننا أن نفترض أن ذلك سيكون هو الحال دائمًا؛ فقد يُسيء عميلٌ معينٌ التصرف، ويبقى متصلًا دون أن يُرسِل البيانات التي يتوقَّعها الخادم؛ لذلك ينبغي أن نأخذ تلك الاحتمالية بالحسبان، لأننا إن لم نفعل، فإنه قد يؤدي إلى غلق الخيط تمامًا. في المقابل، إذا اعتمد الخادم على الخيوط، سيكون هناك خيوطٌ أخرى بإمكانها الرد على العملاء الآخرين. يُعدّ المثال التوضيحي DateServer.java من مقال تواصل تطبيقات جافا عبر الشبكة برنامج خادم بسيطٍ للغاية؛ فهو لا يَستخدِم الخيوط، ولذلك لا بُدّ أن ينتهي الخادم من اتصاله مع عميلٍ معين قبل أن يَقبَل اتصالًا من عميلٍ آخر. سنرى الآن كيفية تحويل ذلك البرنامج إلى خادمٍ مبني على الخيوط. في الواقع، يُعَد هذا الخادم بسيطًا جدًا لدرجة أن اِستخدَام الخيوط معه لا معنى له، ولكن الفكرة أنه من الممكن تطبيق نفس تلك التقنيات على الخوادم الأكثر تعقيدًا. ألقِ نظرةً على تمرين 12.5 على سبيل المثال. لنناقش الآن المحاولة الأولى من البرنامج DateServerWithThreads.java، حيث يُنشِئ هذا البرنامج خيطًا جديدًا بكل مرة يَستقبِل خلالها طلب اتصال بدلًا من مُعالجة الاتصال بنفسه باستدعاء برنامجٍ فرعي subroutine. يُنشِئ البرنامج main الخيط ويُمرِّر إليه الاتصال. يَستغرِق ذلك وقتًا قصيرًا للغاية، والأهم أنه لن يتسبَّب بحدوث تعطيل. في المقابل، يُعالِج تابع الخيط run()‎ الاتصال بنفس الكيفية التي عالجنا بها الاتصال بالبرنامج الأصلي. تُعد برمجة ذلك سهلة، ويمكنك إلقاء نظرةٍ على النسخة الجديدة من البرنامج بعد إجراء التعديلات؛ حيث يمكننا ملاحظة أن الباني constructor الخاص بخيط الاتصال لا يَفعَل شيئًا تقريبًا، ولا يُسبِّب تعطيلًا، وهذا أمرٌ مهم؛ لأنه يُنفَّذ بالخيط الرئيسي: import java.net.*; import java.io.*; import java.util.Date; // 1 public class DateServerWithThreads { public static final int LISTENING_PORT = 32007; public static void main(String[] args) { ServerSocket listener; // اِستمِع لطلبات الاتصال القادمة Socket connection; // للتواصل مع البرنامج المتصِل // استمر باستقبال طلبات الاتصال ومعالجتها للأبد إلى أن يحدث خطأ ما try { listener = new ServerSocket(LISTENING_PORT); System.out.println("Listening on port " + LISTENING_PORT); while (true) { // اِقبَل طلب الاتصال التالي وأنشِئ خيطًا لمعالجته connection = listener.accept(); ConnectionHandler handler = new ConnectionHandler(connection); handler.start(); } } catch (Exception e) { System.out.println("Sorry, the server has shut down."); System.out.println("Error: " + e); return; } } // end main() // 2 private static class ConnectionHandler extends Thread { Socket client; // يُمثِّل اتصالًا مع عميل ConnectionHandler(Socket socket) { client = socket; } public void run() { // (code copied from the original DateServer program) String clientAddress = client.getInetAddress().toString(); try { System.out.println("Connection from " + clientAddress ); Date now = new Date(); // التوقيت والتاريخ الحالي PrintWriter outgoing; // مجرى لإرسال البيانات outgoing = new PrintWriter( client.getOutputStream() ); outgoing.println( now.toString() ); outgoing.flush(); // تأكّد من إرسال البيانات client.close(); } catch (Exception e){ System.out.println("Error on connection with: " + clientAddress + ": " + e); } } } } //end class DateServerWithThreads إذ أن: [1]: يمثّل هذا البرنامج خادمًا يَستقبِل طلبات الاتصال برقم المنفذ الذي يُخصِّصه الثابث LISTENING_PORT. يرسِل البرنامج عند فتح اتصال التوقيت الحالي إلى المقبس المتصِل، ويستمر باستقبال طلبات الاتصال ومعالجتها إلى أن يُغلَق بالضغط على CONTROL-C على سبيل المثال. تُنشِئ هذه النسخة من البرنامج خيطًا جديدًا لكل طلب اتصال. [2]: يُعرِّف خيطًا لمعالجة الاتصال مع عميلٍ واحد. انظر إلى نهاية التابع run()‎، حيث أضفنا clientAddress إلى رسالة الخطأ؛ لنتمكَّن من معرفة الاتصال الذي تشير إليه رسالة الخطأ. نظرًا لأن الخيوط تُنفَّذ على التوازي، قد يَختلِط خرج الخيوط المختلفة؛ أي ليس من الضروري أن تَظهَر الرسائل الخاصة بخيطٍ معين معًا، وإنما قد يَفصِل بينها رسائلٌ من خيوطٍ أخرى. يُمثِّل ذلك واحدًا فقط من ضمن التعقيدات الكثيرة التي ينبغي الانتباه لها عند تعاملنا مع الخيوط. استخدام مجمع الخيوط لا يُعدّ إنشاء خيطٍ جديدٍ لكل اتصال الحل الأفضل، خاصةً إذا كانت تلك الاتصالات قصيرة. لحسن الحظ، يُمكِننا أن نَستخدِم مُجمّع خيوط الذي ناقشناه بقسم مجمع الخيوط وأرتال المهام من المقال السابق. يُعدّ البرنامج DateServerWithThreadPool.java نسخةً مُحسنَّةً من الخادم؛ حيث يَستخدِم مجمع خيوط. يُنفِّذ كل خيطٍ ضمن ذلك المُجمّع حلقة تكرار لا نهائية infinite loop يُعالِج كل تكرارٍ منها اتصالًا. علينا أن نَجِد طريقةً تُمكِّن البرنامج main من إرسال الاتصالات إلى الخيوط. ومن البديهي أن نَستخدِم رتلًا مُعطِّلًا blocking queue لذلك الغرض، وسيكون اسمه هو connectionQueue. تقرأ خيوط معالجة الاتصال الاتصالات من الرتل؛ ونظرًا لكونه رتلًا مُعطِّلًا، تتعطَّل الخيوط عندما يكون الرتل فارغًا، وتستيقظ عند إتاحة اتصالٍ جديدٍ بالرتل. لن نحتاج إلى أي تقنيات مزامنة أو اتصال أخرى؛ فكل شيءٍ مبنيٌ مُسبقًا بالرتل المُعطِّل. تعرض الشيفرة التالية التابع run()‎ الخاص بخيوط معالجة الاتصال: public void run() { while (true) { Socket client; try { client = connectionQueue.take(); // تعطَّل إلى أن يُتاح عنصر جديد } catch (InterruptedException e) { continue; // إذا قُوطِعَ، عدّ إلى بداية حلقة التكرار } String clientAddress = client.getInetAddress().toString(); try { System.out.println("Connection from " + clientAddress ); System.out.println("Handled by thread " + this); Date now = new Date(); // التاريخ والتوقيت الحالي PrintWriter outgoing; // مجرى لإرسال البيانات outgoing = new PrintWriter( client.getOutputStream() ); outgoing.println( now.toString() ); outgoing.flush(); // تأكّد من إرسال البيانات فعليًا client.close(); } catch (Exception e){ System.out.println("Error on connection with: " + clientAddress + ": " + e); } } } يُنفِّذ البرنامج main()‎ حلقة تكرارٍ لا نهائية تَستقبِل الاتصالات وتُضيفها إلى الرتل: while (true) { // اقبل طلب الاتصال التالي وأضِفه إلى الرتل connection = listener.accept(); try { connectionQueue.put(connection); // تعطَّل إذا كان الرتل ممتلئًا } catch (InterruptedException e) { } } لاحِظ أن الرتل بهذا البرنامج من النوع ArrayBlockingQueue<Socket>‎؛ أي أن لديه سعةً قصوى، وبالتالي إذا كان الرتل ممتلئًا، سيؤدي تنفيذ عملية put()‎ إلى حدوث تعطيل. ولكن ألسنا نريد تجنُّب حدوث أي تعطيلٍ بالبرنامج main()‎؟ لأنه إذا تعطُّل، فلن يستقبل الخادم أي اتصالاتٍ أخرى، ويضطّر العملاء الذي يحاولون الاتصال إلى الانتظار. أليس من الأفضل اِستخدام الصنف LinkedBlockingQueue بسعةٍ غير محدودة؟ في الحقيقة، تُعدّ الاتصالات الموجودة بالرتل المُعطِّل قيد الانتظار على أية حال، أي أنها لم تُخدَّم بعد؛ وإذا ازداد حجم الرتل بدرجةٍ غير معقولة، فستضطّر الاتصالات الموجودة بالرتل إلى الانتظار إلى فتراتٍ غير معقولةٍ أيضًا. وبالتالي، إذا ازداد حجم الرتل إلى ما لانهاية، لا يَعنِي ذلك سوى أن الخادم يَستقبِل طلبات اتصال بسرعةٍ أكبر من سرعة قدرته على معالجة تلك الاتصالات. قد يحدث ذلك لعدة أسباب: ربما يكون خادمك غير قوي كفاية لمعالجة كمية الاتصالات المطلوبة، وينبغي في تلك الحالة أن تشتري خادمًا جديدًا؛ أو ربما بسبب عدم احتواء مجمع الخيوط على عددٍ كافٍ من الخيوط بدرجة تَسمَح بالاستفادة من إمكانيات الخادم بالكامل، وينبغي في تلك الحالة زيادة حجم مجمع الخيوط ليتوافق مع إمكانيات الخادم؛ أو ربما يتعرَّض الخادم لهجوم الحرمان من الخدمات Denial Of Service، أي أن هناك شخصٌ معينٌ يحاول متعمّدًا إرسال طلبات اتصال إلى الخادم بقدرٍ أكبر مما يَستطيع احتماله في محاولة لمنع العملاء من الوصول إلى الخدمة. في جميع الأحوال، يُعدّ الصنف ArrayBlockingQueue ذو السعة المحدودة هو الخيار الأصح. ينبغي أن يكون الرتل قصيرًا حتى لا تضطّر الإتصالات الموجودة به إلى الانتظار لفتراتٍ طويلة قبل وصولها إلى الخدمة. بالنسبة للخوادم الحقيقية، لا بُدّ من ضبط كُلٍ من حجم الرتل وعدد خيوط المجمع بما يتناسب مع الخادم؛ كما ينبغي أن يُؤخَذ العتاد والشبكة التي يَعمَل عليها الخادم بالحسبان؛ وكذلك طبيعة طلبات العملاء التي يُفترَض معالجتها، وهو ما يُمثِّل تحديًا صعبًا عمومًا. بالمناسبة، هناك احتماليةٌ أخرى قد تسوء الأمور نتيجة لها: لنفترض أن الخادم يحتاج إلى قراءة بعض البيانات من العميل، ولكن العميل لا يرسل أية بيانات. في تلك الحالة، قد يتعطَّل الخيط الذي يحاول قراءة البيانات إلى الأبد بانتظار المُدْخَلات. إذا كنا نَستخدِم مجمع خيوط، فقد يَحدُث ذلك لجميع الخيوط الموجودة بالمجمع، وعندها، لن تحدث أي معالجةٍ أخرى. يتمثل حل تلك المشكلة بإيقاف الاتصال إذا بقي غير نشطٍ لفترةٍ طويلة من الوقت. ينتبه كل خيط اتصال عادةً إلى آخر توقيت استقبل به بياناتٍ من العميل. يُشغِّل الخادم خيطًا آخر يُطلَق عليه أحيانًا اسم "خيط reaper" تيمُنًا بفرقة "Grim Reaper"، ويوقظه دوريًا؛ حتى يَفحَص خيوط الاتصال، ويرى فيما إذا كان هناك خيطٌ غير نشط لفترةٍ طويلة. إذا بقي خيطٌ قيد الانتظار لفترة طويلة، فإنه يُنهَى، ويحلّ محله خيطٌ جديد. تُمثِّل الإجابة على سؤال "كم من الوقت ينبغي أن تكون تلك الفترة الطويلة؟" تحديًا آخر. الحوسبة الموزعة لقد رأينا طريقة اِستخدام الخيوط لإجراء المعالجة على التوازي؛ حيث تَعمَل عدة معالجات معًا لإنجاز مهمةٍ معينة. كان افتراضنا حتى الآن هو أن جميع تلك المعالجات موجودةٌ ضمن حاسوبٍ واحد مُتعدِّد المعالجات، ولكن يُمكِن إجراء المعالجة على التوازي باستخدام معالجاتٍ موجودة بحواسيبٍ مختلفة شرط أن تكون تلك الحواسيب متصلةً عبر شبكةٍ معينة؛ ويُطلَق على هذا النوع من المعالجة المتوازية -أي أن تَعمَل عدة حواسيب معًا لإنجاز مهمةٍ عبر الشبكة- اسم الحوسبة المُوزَّعة distributed computing. يُمكِننا أن ننظر لشبكة الانترنت وكأنها نموذجٌ ضخمٌ لحوسبةٍ موزَّعة، ولكننا في الواقع معنيون بالكيفية التي يمكن أن تتعاون من خلالها الحواسيب المتصلة عبر شبكةٍ على حل مشكلة حوسبية. هناك طرائقٌ متعددة للحوسبة الموزّعة، وتدعم جافا بعضًا منها. على سبيل المثال، تُمكِّن كُلًا من تقنية استدعاء التوابع عن بعد Remote Method Invocation - RMI وتقنية كوربا Common Object Request Broker Architecture - CORBA برنامجًا مُشغَّلًا على حاسوبٍ معين من استدعاء توابع كائناتٍ موجودة بحواسيبٍ اخرى. يَسمَح ذلك بتصميم برنامجٍ كائني التوجه object-oriented تُنفَّذ أجزاءه المختلفة بحواسيب مختلفة. تدعم RMI الاتصال بين كائنات جافا فقط؛ بينما تُعدّ CORBA المعيار الأكثر عمومية، حيث تَسمَح للكائنات المكتوبة بأي لغة برمجية، بما في ذلك جافا، بالتواصل مع بعضها بعضًا. كما هو الحال مع الشبكات، لدينا مشكلة تحديد موقع الخدمات (المقصود بالخدمة هنا هو الكائن المُتاح للاستدعاء عبر الشبكة)، أي كيف يستطيع حاسوبٌ معين معرفة الحاسوب الذي تَعمَل عليه خدمةٌ معينة ورقم المنفذ الذي تَستمِع إليه؟ تحلّ تقنيتي "RMI" و "CORBA" تلك المشكلة باستخدام "وسيط طلب request broker"؛ حيث يمثّل ذلك الوسيط برنامج خادم يَعمَل بعنوانٍ معروف، ويحتوي على قائمة الخدمات المتاحة بالحواسيب الأخرى، وتُبلِّغ الحواسيب المُقدِّمَة لخدمات الوسيط بخدماتها. ينبغي أن تعرف الحواسيب التي تحتاج خدمةً معينةً عنوان الوسيط للتواصل معه وسؤاله عن الخدمات المُتاحة وعناوينها. تُعدّ "RMI" و "COBRA" أنظمةً معقدة وليس من السهل تمامًا استخدامها، وذُكرت هنا لأنها جزءٌ من واجهة برمجة تطبيقات API جافا للشبكات القياسية، ولكننا لن نناقشها إلى أبعد من ذلك. بدلًا منها، سنفحص مثالًا أبسط على الحوسبة المُوزَّعة يَستخدِم أساسيات الشبكات فقط. يتشابه هذا المثال مع ذلك الذي اِستخدمناه بالبرنامج MultiprocessingDemo1.java ونُسخه المختلفة بالمقالين البرمجة باستخدام الخيوط threads في جافا والمقال السابق، وهو مثال حساب ألوان صورةٍ معقدة، ولكننا لن نُنشِئ هنا برنامجًا بواجهة مُستخدِم رسومية، ولن نعرض الصورة على الشاشة. تَستخدِم عملية المعالجة أبسط أنواع المعالجة على التوازي بتقسيم المشكلة إلى عدة مهام يُمكِن تنفيذها باستقلالية دون الاتصال مع المهام الأخرى. لتطبيق الحوسبة المُوزَّعة على هذا النوع من المشكلات، سنَستخدِم برنامجًا رئيسيًا master يتولى مسؤولية تقسيم المشكلة إلى عدة مهام وإرسالها عبر الشبكة إلى البرامج العاملة worker لتُنفِّذها. ينبغي أن تُرسِل تلك البرامج نتائجها إلى البرنامج الرئيسي الذي يَدمِج نتائج جميع المهام معًا ليكوِّن حلًا لكامل المشكلة. يُطلَق على البرامج العاملة ضمن هذا السياق اسم "البرامج التابعة slaves"، ويُقال أن البرنامج يَستخدِم أسلوب "البرنامج الرئيسي والبرنامج التابع master/slave" لإجراء الحوسبة المُوزَّعة. يتكوَّن هذا البرنامج من ثلاثة ملفات؛ حيث يُعرِّف الملف CLMandelbrotMaster.java البرنامج الرئيسي، بينما يُعرِّف الملف CLMandelbrotWorker.java البرامج العاملة التابعة؛ وأخيرًا يُعرِّف الملف CLMandelbrotTask.java الصنف CLMandelbrotTask الذي يُمثِّل المهمة التي تُنفِّذها البرامج العاملة. يُقسِّم البرنامج الرئيسي master المشكلة إلى عدة مهام، ثم يُوزِّعها على البرامج العاملة، التي تُنفِّذ تلك المهام، ثم تُعيد النتائج إلى البرنامج الرئيسي. ويُطبِّق البرنامج الرئيسي بالنهاية نتائج جميع المهام المفردة على المشكلة الأساسية. لتشغيل البرنامج، سنبدأ أولًا بتشغيل البرنامج "CLMandelbrotWorker" على عدّة حواسيب (ربما بتنفيذها بسطر الأوامر). يَستخدِم ذلك البرنامج الصنف CLMandelbrotTask، ولذلك يجب أن يكون كل من الملف CLMandelbrotWorker.class والملف CLMandelbrotTask.class متوفرين بالحواسيب المُمثِّلة للبرامج العاملة. بعد ذلك، سنُشغِّل البرنامج المسمى "CLMandelbrotMaster" على الحاسوب المُمثِل للبرنامج الرئيسي، مع ملاحظة أنه يحتاج إلى الصنف CLMandelbrotTask أيضًا. يجب أن نُمرِّر اسم أو عنوان بروتوكول الانترنت IP address الخاص بجميع الحواسيب العاملة إلى البرنامج المسمى "CLMandelbrotMaster" مثل وسطاءٍ عبر سطر الأوامر. تستمع البرامج العاملة إلى طلبات الاتصال القادمة من البرنامج الرئيسي، ولهذا يجب أن يعرف البرنامج الرئيسي العناوين التي سيُرسِل إليها تلك الطلبات. على سبيل المثال، إذا كان البرنامج العامل مُشغَّلًا على ثلاثة حواسيب عناوينها هي: 172.21.7.101 و 172.21.7.102 و 172.21.7.103، يُمكِننا أن نُشغِّل البرنامج "CLMandelbrotMaster" بكتابة الأمر التالي: java CLMandelbrotMaster 172.21.7.101 172.21.7.102 172.21.7.103 سينُشِئ البرنامج الرئيسي اتصالًا شبكيًا مع البرنامج العامل بكل عنوان بروتوكول إنترنت، وستُستخدَم تلك الاتصالات للتواصل بين كُلٍ من البرنامج الرئيسي والبرامج العاملة. يُمكِننا تشغيل عدة نسخٍ من البرنامج "CLMandelbrotWorker" على نفس الحاسوب، ولكن ينبغي أن يستمع كُلًا منها إلى الاتصالات الشبكية القادمة عبر أرقام منافذٍ مختلفة. كما يُمكِننا تشغيل البرنامج "CLMandelbrotWorker" على نفس الحاسوب الذي يَعمَل عليه البرنامج "CLMandelbrotMaster"، ولربما ستلاحظ عندها زيادة سرعة البرنامج إذا كان حاسوبك يحتوي على عدة معالجات. ألقِ نظرةً على التعليقات الموجودة بشيفرة البرنامج لمزيدٍ من المعلومات. نستعرض فيما يلي بعض الأوامر المُمكِن استخدامها لتشغيل البرنامج الرئيسي مع نسختين من البرنامج العامل على نفس الحاسوب. اُكتُبها بنوافذ سطر أوامر مختلفة: java CLMandelbrotWorker (Listens on default port) java CLMandelbrotWorker 2501 (Listens on port 2501) java CLMandelbrotMaster localhost localhost:2501 يَحلّ البرنامج "CLMandelbrotMaster" نفس المشكلة بالضبط في كل مرةٍ نُشغِّل بها. في الواقع، طبيعة المشكلة ذاتها غير مهم، ولكنها تمثّل هنا حساب البيانات التي يتطلَّبها رسم جزءٍ صغير من صورة مجموعة ماندلبرو Mandelbrot Set الشهيرة. إذا كنت تريد رؤية الصورة الناتجة، ألغِ التعليق الموجود فوق الاستدعاء saveImage()‎ بنهاية البرنامج main()‎ المُعرَّف بالملف CLMandelbrotMaster.java). يُمكِنك تشغيل البرنامج "CLMandelbrotMaster" مع عددٍ مختلف من البرامج العاملة لرؤية مدى اعتمادية الزمن الذي يتطلَّبه حل المشكلة على عدد البرامج العاملة. لاحِظ استمرار البرامج العاملة بالعمل حتى بعد انتهاء البرنامج الرئيسي؛ أي يُمكِنك تشغيل البرنامج الرئيسي عدة مرات دون الاضطرار لإعادة تشغيل البرامج العاملة في كل مرة. علاوةً على ذلك، إذا شغَّلت البرنامج "CLMandelbrotMaster" دون أن تُمرِّر إليه أي وسطاء، فإنه سيحلّ المشكلة بالكامل بمفرده، ويُمكِنك بذلك رؤية الزمن الذي يتطلَّبه حل المشكلة بدون الحوسبة المُوزَّعة. في الواقع، جرَّبنا ذلك بإحدى الحواسيب القديمة والبطيئة جدًا، ووجدنا أن البرنامج "CLMandelbrotMaster" قد استغرق 40 ثانية لحل المشكلة بمفرده؛ في حين استغرق 43 ثانية عند تشغيل برنامجٍ عاملٍ واحد. تُمثِّل تلك الزيادة العمل الإضافي المطلوب لاستخدام الشبكات، حيث يتطلَّب إنشاء الاتصال الشبكي، وإرسال الرسائل عبر الشبكة المزيد من الوقت. في المقابل، اِستغرَق البرنامج 22 ثانية فقط لحل المشكلة عند تشغيل برنامجين عاملين بحواسيبٍ مختلفة. في تلك الحالة، نفَّذ كل برنامجٍ منهما نصف العمل على التوازي، ولذلك أنُجزَت العملية بنصف الوقت تقريبًا. يستمر الزمن بالنقصان مع زيادة عدد البرامج العاملة، ولكن إلى حدٍ معين؛ لأن البرنامج الرئيسي لديه أيضًا بعض العمل الواجب إنجازه مهما ازداد عدد البرامج العاملة، وعليه لا يُمكِن للزمن الكلي المطلوب لحل المشكلة أن يكون أقل من الزمن الذي يستغرقه البرنامج الرئيسي لتنفيذ عمله. وفي تلك الحالة، بدا أن أقل زمنٍ ممكنٍ هو 5 ثوانٍ تقريبًا. والآن، لنرى طريقة برمجة هذا التطبيق المُوزَّع. سيُقسِّم البرنامج الرئيسي المشكلة إلى مجموعة مهام؛ بحيث تُمثَّل كل مهمة منها باستخدام كائنٍ ينتمي إلى الصنف CLMandelbrotTask. تُرسَل تلك المهام إلى البرامج العاملة، التي ينبغي لها أن تعيد نتائجها بعد أن تحسبها. يَعنِي ذلك أننا بحاجة إلى نوعٍ من البروتوكول للتواصل؛ ولذلك قررنا استخدام مجرى محارف character stream لهذا الغرض. سيُشفِّر البرنامج الرئيسي كل مهمةٍ بهيئة سطرٍ نصي يُرسَل إلى البرنامج العامل، الذي سيَفك التشفير ويُعيد السطر إلى كائنٍ من الصنف CLMandelbrotTask ليتمكَّن من فهم المهمة المفترض تنفيذها. بعد ذلك، يُنجِز البرنامج العامل المهمة، ثم يُشفِّر النتائج بهيئة سطرٍ نصي آخر يُرسَل عائدًا إلى البرنامج الرئيسي. أخيرًا، يَفُك البرنامج الرئيسي الشيفرة، ويَدمِج النتيجة مع نتائج المهام الأخرى. بعد اكتمال جميع المهام ودمج نتائجها معًا، تكون المشكلة قد حُلَّت. لا يستقبل كل برنامجٍ عاملٍ مهمةً واحدةً فقط، وإنما متتاليةً من المهام؛ فبمجرد أن يُنجز مهمةً معينة، ويُرسِل نتائجها، تُسنَد إليه مهمةٌ جديدة. يَستقبِل البرنامج العامل الأمر "close" بعد اكتمال جميع المهام ليخبره بأن عليه إغلاق الاتصال. يُجرَى كل ما سبق ضمن تابعٍ، اسمه handleConnection()‎ بالملف CLMandelbrotWorker.java، حيث يُستدعَى ذلك التابع لمعالجة اتصالٍ قد اُنشئ بالفعل مع البرنامج الرئيسي. يَستخدِم ذلك التابع بدوره تابعًا آخر، اسمه readTask()‎ لفك تشفير المهمة التي استقبلها من البرنامج الرئيسي؛ كما يستخدِم التابع writeResults()‎ بهدف تشفير نتائج المهمة قبل إرسالها إلى البرنامج الرئيسي. يجب عليه أيضًا معالجة أي أخطاءٍ ممكنة. انظر تعريف التابع: private static void handleConnection(Socket connection) { try { BufferedReader in = new BufferedReader( new InputStreamReader( connection.getInputStream()) ); PrintWriter out = new PrintWriter(connection.getOutputStream()); while (true) { String line = in.readLine(); // رسالة من البرنامج الرئيسي if (line == null) { // واجهنا نهاية المجرى. لا ينبغي أن يحدث ذلك throw new Exception("Connection closed unexpectedly."); } if (line.startsWith(CLOSE_CONNECTION_COMMAND)) { // يُمثِّل الانتهاء الطبيعي للاتصال System.out.println("Received close command."); break; } else if (line.startsWith(TASK_COMMAND)) { // يُمثِل مهمةً من النوع‫ CLMandelbrotTask ينبغي أن ينفذها الخيط CLMandelbrotTask task = readTask(line); // فك تشفير الرسالة task.compute(); // نفِّذ المهمة out.println(writeResults(task)); // أرسِل النتائج out.flush(); // تأكّد من إرسال النتائج } else { // ليس هناك أي رسائل أخرى ضمن البروتوكول throw new Exception("Illegal command received."); } } } catch (Exception e) { System.out.println("Client connection closed with error " + e); } finally { try { connection.close(); // تأكّد من إغلاق المقبس } catch (Exception e) { } } } لا يُنفَّذ التابع المُعرَّف بالأعلى بخيطٍ thread مُنفصل، فالبرنامج العامل لديه شيءٌ واحدٌ فقط ليفعله بأي لحظة، ولا يحتاج إلى عدة خيوط. لنعود الآن إلى البرنامج الرئيسي CLMandelbrotMaster.java، حيث نواجه وضعًا أكثر تعقيدًا. يجب أن يُنشِئ البرنامج اتصالًا مع مجموعةٍ من البرامج العاملة عبر مجموعةٍ من الاتصالات الشبكية، وسيَستخدِم البرنامج لإنجاز ذلك عدة خيوط؛ بحيث يُعالِج كل خيطٍ منها الاتصال مع برنامج عاملٍ واحد. تُوضِّح الشيفرة الوهمية pseudocode التالية الفكرة العامة للبرنامج main()‎: // أنشِئ المهام التي ينبغي تنفيذها وضفها إلى الرتل create the tasks that must be performed and add them to a queue // إذا لم تُمرَّر أي وسائط بالأمر if there are no command line arguments { // يُنفِّذ البرنامج الرئيسي كل شيء بنفسه Remove each task from the queue and perform it. } else { // تُنفِّذ البرامج العاملة المهام for each command line argument: // احصل على معلومات العامل من وسيط سطر الأوامر Get information about a worker from command line argument. // أنشِئ خيطًا جديدًا وشغِّله لكي يُرسِل المهام إلى البرامج العاملة Create and start a thread to send tasks to workers. // انتظر اكتمال جميع الخيوط Wait for all threads to terminate. } // جميع المهام قد انتهت بفرض عدم حدوث أخطاء تُوضَع المهام بمتغيرٍ اسمه tasks من نوع الرتل ConcurrentBlockingQueue<CLMandelbrotTask>‎؛ وتَقَرأ خيوط الاتصال المهام من ذلك الرتل، وتُرسِلها إلى البرامج العاملة. يُستخدَم التابع tasks.poll()‎ لقراءة مهمةٍ من الرتل؛ فإذا كان الرتل فارغًا، فسيُعيد القيمة null، والتي تُعدّ إشارةً إلى أن جميع المهام قد أُسنَدت بالفعل وأن بإمكان خيط الاتصال أن ينتهي. يتولى كل خيط اتصال مهمة إرسال متتاليةٍ من المهام إلى خيطٍ عاملٍ معين؛ واستقبال النتائج التي يعيدها ذلك الخيط العامل؛ كما أنه مسؤولٌ عن إنشاء اتصالٍ مع الخيط العامل بالبداية. تُوضِّح الشيفرة الوهمية pseudocode التالية الفكرة العامة للعملية التي يُنفِّذها خيط الاتصال: // أنشِئ مقبسًا متصلًا مع البرنامج العامل Create a socket connected to the worker program. // أنشِئ مجرى دخل ومجرى خرج للتواصل مع البرنامج العامل Create input and output streams for communicating with the worker. while (true) { Let task = tasks.poll(). If task == null break; // جميع المهام قد أسنَدت // شفِّر‫ المهمة إلى رسالة وأرسلها إلى البرنامج العامل Encode the task into a message and transmit it to the worker. // اقرأ رد العامل Read the response from the worker. // فك تشفير الرد وعالجه Decode and process the response. } // أرسِل الأمر‫ "close" إلى البرنامج العامل Send a "close" command to the worker. Close the socket. سيَعمَل ذلك بطريقة مناسبة، ولكن هناك بعض الملاحظات: أولًا، يجب أن يكون الخيط مُهيأً للتعامل مع أخطاء الشبكة. على سبيل المثال، قد تُغلَق إحدى البرامج العاملة بصورةٍ غير متوقَّعة، وسيتمكَّن البرنامج الرئيسي من إكمال عمله في تلك الحالة نظرًا لوجود برامجٍ عاملةٍ أخرى. (عندما تُشغِّل البرنامج، يُمكِنك أن تُجرِّب ما يلي: أوقف إحدى البرامج العاملة بالضغط على "CONTROL-C"، وسترى أنه بإمكان البرنامج الرئيسي أن يكتمل بنجاح). تَنشَأ المشكلة إذا حدث خطأٌ بينما يَعمَل الخيط على مهمةٍ معينة؛ ونظرًا لأننا نهدف إلى حل المشكلة كاملةً، لا بُدّ إذًا من إعادة إسناد تلك المهمة إلى برنامجٍ عاملٍ آخر. يُمكِننا تحقيق ذلك بإعادة المهام غير المكتملة إلى رتل المهام. لسوء الحظ، لا يُعالج البرنامج الذي نَستعرِضه هنا جميع الأخطاء المحتملة. على سبيل المثال، إذا فَشَل خيط العامل الأخير، فلن يكون هناك عملاء آخرين نُسنِد إليهم المهام غير المكتملة. علاوةً على ذلك، إذا عُلّقَ الاتصال الشبكي دون أن يُولِّد خطأً فعليًا، سيُعلَّق البرنامج أيضًا نتيجة انتظاره لرد العامل. لا بُدّ لأي برنامج متين أن يجد طريقةً يَكشِف من خلالها عن تلك المشكلة ومن ثم يُعيد إسناد المهمة. ثانيًا، تترك الطريقة الموضحة بالأعلى البرنامج العامل دون أي عملٍ أثناء معالجة البرنامج الرئيسي لرد العامل. سيكون من الأفضل لو أُسندت مهمةٌ جديدةٌ إلى البرنامج العامل قبل معالجة ردّه على المهمة السابقة؛ حيث سيُبقِي ذلك البرنامج العامل مُنشغِلًا؛ كما سيَسمَح بتنفيذ العمليتين على التوازي بدلًا من تنفيذهما على التوالي. بهذا المثال، تستغرق معالجة الرد زمنًا قصيرًا للغاية، ولذلك قد لا يُمثِل ترك العامل منتظرًا أو تشغيله على الفور فارقًا كبيرًا، ولكن ينبغي عمومًا تطبيق التوازي بأقصى قدرٍ ممكن. ألقِ نظرةً على التصور العام للخوارزمية بعد التعديل: try { // أنشِئ مقبسًا متصلًا مع البرنامج العامل Create a socket connected to the worker program. // أنشِئ مجرى دخل ومجرى خرج للتواصل مع البرنامج العامل Create input and output streams for communicating with the worker. Let currentTask = tasks.poll(). // شفِّر‫ `currentTask` إلى رسالة وأرسلها إلى البرنامج العامل Encode currentTask into a message and send it to the worker. while (true) { // اِقرأ الرد من البرنامج العامل Read the response from the worker. Let nextTask = tasks.poll(). If nextTask != null { // أرسل‫ `nextTask` إلى البرنامج العامل قبل معالجة الرد على المهمة `currentTask` // شفِّر‫ `nextTask` إلى رسالة وأرسلها إلى البرنامج العامل Encode nextTask into a message and send it to the worker. } // فك تشفير الرد على المهمة‫ `currentTask` وعالجه Decode and process the response to currentTask. currentTask = nextTask. if (currentTask == null) break; // جميع المهام قد أسنَدت } // أرسِل الأمر‫ "close" إلى البرنامج العامل Send a "close" command to the worker. // أغلق المقبس Close the socket. } catch (Exception e) { // أعِد المهمة غير المكتملة إن وجدت إلى رتل المهام مرة أخرى Put uncompleted task, if any, back into the task queue. } finally { // أغلق المقبس Close the connection. } يُمكِنك الإطلاع على الصنف المتداخل WorkerConnection المُعرَّف بالملف CLMandelbrotMaster.java لترى طريقة تحويل الشيفرة الوهمية السابقة إلى شيفرة جافا. ترجمة -بتصرّف- للقسم Section 4: Threads and Networking من فصل Chapter 12: Threads and Multiprocessing من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: الخيوط threads والمعالجة على التوازي في جافا البرمجة باستخدام الخيوط threads في جافا مقدمة إلى الخيوط Threads في جافا كيفية إنشاء عدة خيوط وفهم التزامن في جافا تواصل تطبيقات جافا عبر الشبكة
  8. استخدمنا تقنية البرمجة على التوازي بمثال القسم الفرعي التعاود داخل الخيوط من المقال السابق البرمجة باستخدام الخيوط threads في جافا لتنفيذ أجزاءٍ صغيرة من مهمةٍ كبيرة، حيث تَسمَح تلك التقنية للحواسيب مُتعدّدة المعالجات بإكمال عملية المعالجة بوتيرةٍ أسرع. ولكننا في الواقع لم نَستخدِم لا الطريقة الأفضل لتقسيم المشكلة ولا الطريقة الأفضل لإدارة الخيوط threads. سنتناول بهذا القسم نسختين من نفس البرنامج؛ حيث سنُحسِّن بالنسخة الأولى طريقة تقسيم المشكلة إلى عدّة مهام؛ بينما سنُحسِّن بالنسخة الثانية طريقة اِستخدام الخيوط. بالإضافة إلى ذلك، سنمرّ عبر مجموعةٍ من الأصناف المبنية مسبقًا والتي تُوفِّرها جافا لدعم المعالجة المتوازية، وسنناقش بنهاية القسم التابعين wait()‎ و notify()‎ المُستخدمين للتحكُّم بالعمليات المتوازية تحكمًا مباشرًا. تقسيم المشكلات يُقسِّم البرنامج MultiprocessingDemo1.java مُهمة حساب صورة إلى عدة مهامٍ فرعية، ويُسنِد كُلًا منها إلى خيط. يَعمَل هذا البرنامج بنجاح، ولكن هناك مشكلة متمثّلة في إمكانية استغراق بعض المهام وقتًا أطول من المهام الأخرى. في حين يُقسِّم البرنامج الصورة إلى أجزاءٍ متساوية، تتطلَّب بعض أجزائها معالجةً أكثر من أجزاءٍ أخرى. إذا شغَّلت البرنامج باستخدام ثلاثة خيوط، ستلاحظ أن معالجة الجزء الأوسط تستغرِق وقتًا أطول بعض الشيء من معالجة الجزأين السفلي والعلوي. في العموم، عندما نُقسِّم مشكلةً معينةً إلى عدة مشكلاتٍ فرعيةٍ أصغر، يكون من الصعب توقُّع الزمن الذي ستحتاجه معالجة كل مشكلةٍ منها. والآن، لنفترض أن لدينا مشكلةً فرعيةً معينة تستغرق زمنًا أطول من غيرها. في تلك الحالة، ستكتمل جميع الخيوط عدا الخيط المسؤول عن معالجة تلك المشكلة؛ حيث سيستمر بالعمل لفترةٍ طويلةٍ نسبيًا، وسيَعمَل معالجٌ واحدٌ فقط خلال تلك الفترة دون بقية المعالجات. إذا احتوى الحاسوب مثلًا على معالجين، وقسَّمنا مشكلةً ما إلى مشكلتين فرعيتين، ثم أنشأنا خيطًا لكل مشكلةٍ منهما. نطمح باستخدامنا لمعالجيّن إلى الحصول على الإجابة بنصف الزمن الذي سيستغرقه الحصول عليها عند استخدام معالجٍ واحد، ولكن إذا كان حل مشكلةٍ منهما يحتاج إلى أربعة أضعاف الزمن الذي يحتاجه حل المشكلة الأخرى، فسيكون معالجًا واحدًا فقط مُشغَّلًا غالبية الوقت، ولم نُقلِل في تلك الحالة الزمن المطلوب لحل المشكلة بنسبةٍ أكبر من 20%. حتى لو تمكَّنا من تقسيم المشكلة إلى عدة مشكلاتٍ فرعية تتطلَّب زمن المعالجة نفسه، فإننا لا نستطيع الاعتماد ببساطةٍ على حقيقة كونها ستتطلَّب زمنًا متساويًا؛ حيث من الممكن أن تكون بعض المعالجات منهمكةً بتشغيل برامجٍ أخرى، أو قد يكون بعضها أبطأ عمومًا (هذا ليس محتملًا إذا شغَّلنا البرنامج على حاسوبٍ واحد، ولكن تختلف سرعة المعالجات بالحوسبة المُوزَّعة من خلال مجموعة حواسيب عبر الشبكة -وهو ما سنفعله لاحقًا-، وتُعدّ عندها مسألةً مهمة). تتمثّل التقنية الأكثر شيوعًا للتعامل مع كل تلك المشكلات في تقسيم المشكلة إلى عددٍ كبيرٍ من المشكلات الفرعية الأصغر؛ أي أكبر بكثير من عدد المعالجات الموجودة، وسيضطّر بالتالي كل معالجٍ لحلّ عدة مشكلاتٍ فرعية. عندما يُكمِل معالج مهمةً فرعيةً معينة، تُسنَد إليه مهمةٌ فرعيةٌ أخرى ليَعمَل عليها إلى أن تُسنَد جميع المهام الفرعية. سيظل بالطبع هناك اختلافٌ بالزمن الذي تتطلَّبه كل مهمة، وبالتالي قد يُكمِل معالجٌ معينٌ عدة مشكلاتٍ فرعية، بينما قد يَعمَل معالجٌ آخر على مهمةٍ واحدةٍ معقدة؛ كما قد يُكمِل معالج بطيءٌ أو مشغولٌ مهمةً واحدةً فقط أو اثنتين، بينما قد يُنهِي معالجٌ آخر خمس أو ست مهام. في العموم، سيَعمَل كل معالجٍ وفقًا لسرعته الخاصة، وستستمر غالبية المعالجات بالعمل إلى قرب اكتمال المعالجة بالكامل بشرط أن تكون المشكلات الفرعية صغيرةً كفاية. ويُطلَق على ذلك "توزيع الحمل load balancing"؛ أي توزيع حمل المعالجة بين المعالجات المتاحة لإبقائها جميعًا منشغلةً بأقصى ما يُمكِن. ستنهي بعض المعالجات بالطبع عملها قبل بعضها الآخر، ولكن بفترةٍ لا تتعدى الزمن المطلوب لإتمام المهمة الفرعية الأطول. كما ذكرنا بالأعلى، ينبغي أن يكون حجم المشكلات الفرعية صغيرًا لا غاية في الصغر؛ لأن تقسيم المشكلة وإسنادها إلى المعالجات يتطلَّب حملًا إضافيًا overhead؛ فإذا كانت المشكلات الفرعية غايةً في الصغر، سيتراكم الحمل الإضافي ويُشكِّل فارقًا بمقدار العمل الكلي المطلوب. كانت المهمة بالمثال السابق هي حساب لون كل بكسلٍ بالصورة، فإذا أردنا تقسيم تلك المهمة إلى عدة مهامٍ فرعية، هناك عدة احتمالات؛ فقد تتكوَّن كل مهمة فرعية مثلًا من حساب لون بكسلٍ واحدٍ فقط، ولكنها تُعدّ صغيرة للغاية. بدلًا من ذلك، ستتكوَّن كل مهمةٍ فرعية من حساب لون صفٍ واحد من البكسلات. نظرًا لوجود عدة مئاتٍ من الصفوف بكل صورة، سيكون عدد المهام الفرعية كبيرًا كفاية، كما سيكون حجم كل مهمةٍ فرعيةٍ منها مناسبًا. سينتج عن ذلك توزيعًا جيدًا للحمل مع قدرٍ معقولٍ من الحمل الإضافي. ملاحظة: تُعدّ المشكلة التي ناقشناها بالمثال السابق سهلةً للغاية بالنسبة للبرمجة على التوازي؛ بمعنى أنه عند تقسيم مشكلة حساب ألوان الصورة إلى عدة مشكلاتٍ فرعية أصغر، ستَظِل جميع المشكلات الفرعية مستقلةً تمامًا، وبالتالي يُمكِن معالجة أي عددٍ منها بنفس الوقت ووفقًا لأي ترتيب. في المقابل، قد تتطلّب بعض المهام الفرعية بمشكلاتٍ أخرى النتائج المحسوبة بواسطة بعض المهام الأخرى؛ أي أن المهام الفرعية غير مستقلة، وبالتالي ستتعقد الأمور في تلك الحالات، وسيُصبح ترتيب تنفيذ المهام الفرعية مُهمًا. علاوةً على ذلك، سيكون من الضروري توفير طريقةٍ ما لتشارُك تلك النتائج بين المهام؛ وفي حالة تنفيذها بخيوطٍ مختلفة، سنضطّر إلى مواجهة كل تلك المشكلات المُتعلّقة بالتحكم بوصول الخيوط إلى الموارد التشاركية. لذلك، يُعدّ تقسيم مشكلةٍ معينةٍ لمعالجتها على التوازي أمرًا أصعب بكثير مما قد يوحي به المثال السابق، ولكن بالنهاية، يُعدّ ذلك موضوعًا لدورةٍ تدريبية عن الحوسبة المتوازية لا دورةٍ عن أساسيات البرمجة. مجمع الخيوط وأرتال المهام بعد أن نُقرِّر طريقة تقسيم المشكلة إلى عدة مهامٍ فرعية، ينبغي أن نعرف كيفية إسناد تلك المهام الفرعية إلى الخيوط؛ حيث ينبغي وفقًا للأسلوب كائني التوجه object-oriented تمثيل كل مهمةٍ فرعيةٍ بواسطة كائن، ولأن كل مهمة تُمثِّل عملية معالجة معينة، فمن البديهي أن يُعرِّف ذلك الكائن تابع نسخة instance method يُنفِّذ تلك العملية. من الضروري استدعاء التابع المقابل لمهمةٍ معينة حتى تُنفَّذ، وسيكون تابع المعالجة لهذا البرنامج، هو run()‎، وسيُنفِّذ الكائن المُمثِّل للمهمة الواجهة interface القياسية Runnable التي ناقشناها بقسم إنشاء الخيوط وتشغيلها من مقال مقدمة إلى الخيوط Threads في جافا. تُعدّ تلك الواجهة الطريقة المباشرة لتمثيل مهام المعالجة، ويُمكِننا إنشاء خيطٍ جديدٍ لكل كائن Runnable، ولكن في حالة وجود عددٍ كبيرٍ من المهام، لا يكون لذلك أي معنى؛ نتيجةً لمقدار الحمل الإضافي الناتج عن إنشاء كل خيطٍ جديد. بدلًا من ذلك، يُفضَّل إنشاء عددٍ قليل من الخيوط، بحيث يُسمَح لكُلٍ منها بتنفيذ قدرٍ معيّنٍ من المهام. لاحِظ أن عدد الخيوط التي ينبغي استخدامها غير معروف، وقد يعتمد على المشكلة التي نحاول حلها. يتمحور الهدف عمومًا في إبقاء جميع معالجات الحاسوب مُشغَّلة؛ فبالنسبة لمثال حساب الصورة، كان إنشاء خيطٍ مقابل كل معالجٍ مناسبًا، ولكنه قد لا يتناسب مع جميع المشكلات. وبالتحديد، إذا كان هناك خيطٌ قد يتسبَّب بحدوث تعطيلٍ block لفترةٍ طويلة بينما ينتظر وقوع حدثٍ event معين، أو بينما ينتظر الوصول إلى موردٍ ما، فلربما يكون من الأفضل إنشاء خيوطٍ إضافية؛ لكي يَعمَل عليها كل معالجٍ أثناء تعطُّل الخيوط الأخرى. يُطلَق على مجموعة الخيوط المُتاحة لتنفيذ المهام اسم "مجمع الخيوط thread pool"، وتُستخدَم بغرض تجنُّب إنشاء خيطٍ جديدٍ لكل مهمة، حيث تُسنَد المهمة المطلوب تنفيذها إلى أي خيطٍ مُتاحٍ بالمجمع. عندما تنشغِل جميع الخيوط الموجودة بالمجمع، تضطّر أي مهامٍ أخرى إضافية للانتظار إلى أن تُتاح إحدى الخيوط، ويُعدّ ذلك تطبيقًا مباشرًا على الرتل queue؛ حيث يرتبط بمجمع الخيوط رتلٌ مُكوَّنٌ من المهام قيد الانتظار. بمجرد توفُّر مهمةٍ جديدة، ستُضَاف إلى الرتل؛ وبمجرد انتهاء خيطٍ معينٍ من تنفيذ المهمة المُوكَلة إليه، فسيَحصُل على مهمةٍ أخرى من الرتل ليَعمَل عليها. هناك رتل مهامٍ task queue وحيدٍ لمجمع الخيوط. يَعنِي ذلك استخدام جميع خيوط المجمع نفس الرتل، فهو يُعدّ موردًا تشاركيًا. كما هو الحال مع أي موردٍ تشاركي، قد تقع حالات التسابق race conditions، ولهذا يكون استخدام المزامنة synchronization ضروريًا؛ فقد يحاول بدونها خيطان قراءة عنصرٍ من الرتل بنفس الوقت مثلًا، وعندها سيَحصُل كلاهما على نفس العنصر. حاول التعرف على الأماكن المُحتمَلة لوقوع حالات التسابق بالتابع dequeue()‎ المُعرَّف في قسم الأرتال Queues من الفصل المكدس Stack والرتل Queue وأنواع البيانات المجردة ADT. تُوفِّر جافا الصنف ConcurrentLinkedQueue من أجل حل تلك المشكلة؛ وهو صنفٌ مُعرَّفٌ بحزمة package java.util.concurrent إلى جانب أصنافٍ أخرى مفيدة للبرمجة على التوازي. لاحِظ أنه صنف ذو معاملاتٍ غير مُحدَّدة النوع parameterized، ولذلك إذا أردنا إنشاء رتلٍ لحَمْل كائناتٍ من النوع Runnable، يُمكِننا كتابة ما يلي: ConcurrentLinkedQueue<Runnable> queue = new ConcurrentLinkedQueue<>(); يُمثِّل هذا الصنف رتلًا مُنفَّذًا مثل قائمةٍ مترابطة linked list، كما أن عملياته متزامنةً بطريقةٍ مناسبة. ليست العمليات المُعرَّفة بالصنف ConcurrentLinkedQueue نفس العمليات على الأرتال التي عهدناها؛ فعلى سبيل المثال، يُضيف التابع queue.add(x)‎ العنصر الجديد x إلى نهاية queue؛ بينما يَحذِف التابع queue.poll()‎ عنصرًا من مقدمة الرتل queue. إذا كان الرتل فارغًا، يعيد التابع queue.poll()‎ القيمة null، ولهذا يُمكِننا استخدامه لفحص فيما إذا كان الرتل فارغًا، أو لإسترجاع عنصرٍ إذا لم يكن كذلك. في الحقيقة، يُفضَّل فعل ذلك على هذا النحو؛ فقد يؤدي التأكُّد مما إذا كان الرتل فارغًا قبل قراءة عنصرٍ منه إلى وقوع حالة تسابق؛ حيث يستطيع خيطٌ آخر بدون تحقيق المزامنة حذف آخر عنصرٍ بالرتل باللحظة الواقعة بين لحظتي اختبارٍ للرتل فيما إذا كان فارغًا ومحاولة قراءة العنصر من الرتل، وفي تلك الحالة لن نجد شيئًا عند محاولة قراءة العنصر. في المقابل، يُعدّ التابع queue.poll()‎ بمثابة عمليةٍ ذرية atomic. يُمكِننا استخدام رتلٍ ينتمي إلى الصنف ConcurrentLinkedQueue مع مجمع خيوطٍ لحساب الصورة من المثال السابق، حيث سنُنشِئ جميع المهام المسؤولة عن حساب الصورة ونضيفها إلى الرتل، ثم سنُنشِئ الخيوط التي ستُنفِّذ تلك المهام، ونُشغِّلها. سيتضمَّن كل خيطٍ منها حلقة تكرار loop بحيث يُستدعى تابع الرتل poll()‎ بكل تكرارٍ لقراءة مهمةٍ منه ثم تُنفَّذ. نظراً لأن المهمة هي كائنٌ من النوع Runnable، فكل ما ينبغي أن يفعله الخيط هو استدعاء تابع المهمة run()‎؛ عندما يُعيد التابع poll()‎ القيمة null، فإن الرتل قد أصبح فارغًا أي أن جميع المهام قد أُسندَت لخيوطٍ أخرى، ويُمكِن عندها للخيط المُستدعِي الانتهاء. يُنفِّذ البرنامج MultiprocessingDemo2.java تلك الفكرة؛ حيث يَستخدِم رتلًا، اسمه taskQueue من النوع ConcurrentLinkedQueue<Runnable>‎ لحَمْل المهام. كما يَسمَح البرنامج للمُستخدِم بإلغاء العملية قبل انتهائها، حيث يَستخدِم متغيرًا منطقيًا متطايرًا volatile، اسمه running إشارةً للخيط بأن المُستخدِم قد ألغى العملية. عندما تُصبِح قيمة ذلك المتغير مساويةً للقيمة false، ينبغي أن ينتهي الخيط حتى لو لم يَكن الرتل فارغًا. تُعرِّف الشيفرة التالية الصنف المُتداخِل WorkerThread لتمثيل الخيوط: private class WorkerThread extends Thread { public void run() { try { while (running) { Runnable task = taskQueue.poll(); // اقرأ مهمةً من الرتل if (task == null) break; // لأن الرتل فارغ task.run(); // Execute the task; } } finally { threadFinished(); // ‫تذكّر أن الخيط قد انتهى. // ‫أضفناها بعبارة `finally` لنتأكَّد من استدعائها } } } يَستخدِم البرنامج الصنف المُتداخِل MandelbrotTask لتمثيل مهمة حساب صفٍ واحدٍ من البكسلات، حيث يُنفِّذ ذلك الصنف الواجهة Runnable، ويَحسِب تابعه run()‎ لون كل بكسلٍ بالصف، ثم يَنسَخ تلك الألوان إلى الصورة. تُوضِح الشيفرة التالية ما يفعله البرنامج عند بدء عملية المعالجة مع حذف قليلٍ من التفاصيل: taskQueue = new ConcurrentLinkedQueue<Runnable>(); // Create the queue. for (int row = 0; row < height; row++) { // عدد الصفوف الموجودة بالصورة MandelbrotTask task; task = ... ; // أنشِئ مهمةً لمعالجة صفٍ واحد من الصورة taskQueue.add(task); // أضف المهمة إلى الرتل } int threadCount = ... ; // عدد الخيوط الموجودة بالمجمع. يضبُطه المُستخدِم workers = new WorkerThread[threadCount]; running = true; // اضبط الإشارة قبل بدء تشغيل الخيوط threadsRemaining = workers; // عدد الخيوط قيد التشغيل for (int i = 0; i < threadCount; i++) { workers[i] = new WorkerThread(); try { workers[i].setPriority( Thread.currentThread().getPriority() - 1 ); } catch (Exception e) { } workers[i].start(); } تجدر الإشارة هنا إلى أنه من الضروري إضافة المهام إلى الرتل قبل بدء تشغيل الخيوط؛ لأننا نَستخدِم الرتل الفارغ بمثابة إشارةٍ إلى ضرورة انتهاء الخيوط؛ أي إذا وجدت الخيوط عند تشغيلها الرتل فارغًا، فستنتهي فورًا دون أن تُنجِز أي مهمة. جرِّب البرنامج "MultiprocessingDemo2"؛ فهو يَحسِب نفس الصورة التي يَحسبِها البرنامج "MultiprocessingDemo1"، ولكنه يختلف عنه بالترتيب الذي تُحسَب على أساسه الصفوف في حالة استخدام أكثر من خيط. إذا شاهدت البرنامج بحرص، فستلاحظ عدم إضافة صفوف البكسلات بالترتيب من أعلى إلى أسفل؛ لأن خيط الصف i+1 قد يُنهِي عمله قبل أن يُنهِي خيط الصف i عمله أو حتى ما يَسبقه من صفوف. ستلاحِظ هذا التأثير بقدرٍ أكبر إذا استخدمت عدد خيوطٍ أكبر مما يحتويه حاسوبك من معالجات. جرِّب 20 خيطًأ مثلًا. نمط المنتج والمستهلك والأرتال المعطلة يُنشِئ البرنامج "MultiprocessingDemo2" مجمع خيوطٍ جديد تمامًا بكل مرةٍ يَرسِم بها صورة، وهو ما يبدو سيئًا. أليس من المفترض إنشاء مجموعةٍ واحدةٍ فقط من الخيوط ببداية البرنامج، واستخدامها لحساب أي صورة؟ حيث أن الهدف من اِستخدَام مجمع الخيوط بالنهاية هو انتظار الخيوط للمهام الجديدة وتنفيذها. ولكننا، لم نُوضِّح حتى الآن أي طريقةٍ لجعل خيطٍ ينتظر قدوم مهمةٍ جديدة، حيث يُوفِّر الرتل المُعطِّل blocking queue ذلك. يُعدّ الرتل المُعطِّل تنفيذًا لإحدى أنماط المعالجة على التوازي، وهو نمط المُنتِج والمُستهلِك producer/consumer؛ حيث يُستخدَم هذا النمط في حالة وجود "مُنتِجٍ" واحدٍ أو أكثر لشيءٍ معين إلى جانب وجود "مُستهلِكٍ" واحد أو أكثر لذلك الشيء. لا بُدّ أن يَعمَل جميع المُنتِجين والمُستهِلِكين بنفس الوقت (أي معالجة على التوازي). إذا لم يَتوفَّر أي شيء للمعالجة، فسيضطّر المُستهلِك للانتظار إلى أن تتَوفَّر إحداها. قد يضطَّر المُنتِج ببعض التطبيقات للانتظار أحيانًا: إذا كان مُعدّل استهلاك الأشياء واحدًا لكل دقيقةٍ مثلًا، فمن غير المنطقي أن يكون معدّل إنتاجها اثنين لكل دقيقة مثلًا؛ لأنه سيؤدي إلى تراكمها بصورةٍ غير محدودة. ولذلك، لا بُدّ من تخصيص حدٍ أقصى لعدد الأشياء قيد الانتظار، وإذا وصلنا إلى ذلك الحد، لا بُدّ أن يتوقَّف المنتجون عن إنتاج أي أشياءٍ أخرى لبعض الوقت. والآن، سنحتاج إلى طريقةٍ لنقل الأشياء من المُنتجِين إلى المُستهلِكين، حيث يُعدّ الرتل الحل الأمثل لذلك: سيَضَع المنتجون الأشياء بأحد طرفي الرتل، وسيقرأها المُستهلِكون من الطرف الآخر. نظرًا لأننا نُجرِي معالجةً على التوازي، سنَستخدِم رتلًا متزامنًا، ولكننا نحتاج إلى ما هو أكثر من ذلك؛ فعندما يُصبِح الرتل فارغًا، نريد طريقةً تضطّر المستهلِكين للانتظار إلى أن يُوضَع شيءٌ جديدٌ بالرتل؛ وإذا أصبح ممتلئًا، نريد طريقةً تضطّر المُنتجِين للانتظار إلى أن يُتاح مكانٌ بالرتل. يُمثَّل كُلٌ من المُستهلِكين والمُنتجِين باستخدام الخيوط. إذا كان الخيط مُتوقِّفًا بانتظار حدوث شيءٍ معين، يُقَال أنه مُعطَّل blocked، ولذلك يُعدُّ الرتل المُعطِّل هو نوع الرتل الذي نحتاجه. عندما نَستخدِم رتلًا معطِّلًا، وكان ذلك الرتل فارغًا، ستؤدي عملية سحب dequeue عنصرٍ من الرتل إلى تعطيل المُستدعِي؛ أي إذا حاول خيطٌ معينٌ سحَب عنصرٍ من رتلٍ فارغ، فسيتوقَّف إلى أن يُتاح عنصرٌ جديد، وسيستيقظ عندها الخيط ويقرأ العنصر ويُكمِل عمله. بالمثل، إذا وصل الرتل إلى سعته القصوى، وحاول مُنتِجٌ معينٌ إدخال عنصرٍ به، فسيتعطَّل إلى أن يُتَاح مكانٌ بالرتل. تحتوي حزمة java.util.concurrent على صنفين يُنفِّذان الرتل المُعطِّل: LinkedBlockingQueue و ArrayBlockingQueue، وهما من الأنواع ذات المعاملات غير مُحدَّدة النوع parameterized types؛ أي يَسمحَا بتخصيص نوع العنصر الذي سيَحمله الرتل، ويُنفِّذ كلٌ منهما الواجهة BlockingQueue. إذا كان bqueue رتلًا مُعطِّلًا ينتمي إلى أحد الصنفين السابقين، فإنه يُعرِّف العمليات التالية: bqueue.take()‎: يَحذِف item من الرتل ويعيده؛ فإذا كان الرتل فارغًا عند استدعائه، يتعطَّل الخيط المُستدعِي إلى أن يُتاح عنصرٌ جديدٌ بالرتل. يُبلِّغ التابع عن استثناءٍ exception من النوع InterruptedException إذا قُوطَع الخيط أثناء تعطُّله. bqueue.put(item)‎: يُضيِف item إلى الرتل. إذا كان للرتل سعةً قصوى وقد أصبح ممتلئًا، يتعطَّل الخيط المُستدعِي إلى أن يُتَاح مكانٌ بالرتل. يُبلِّغ التابع عن استثناءٍ من النوع InterruptedException إذا قُوطَع الخيط أثناء تعطُّله. bqueue.add(item)‎: يُضيف item إلى الرتل في حالة وجود مكانٍ متاح. إذا كان للرتل سعةً قصوى وقد أصبح ممتلئًا، يُبلِّغ التابع عن استثناءٍ من النوع IllegalStateException، ولكنه لا يُعطِّل المُستدعِي. bqueue.clear()‎: يحذِف جميع العناصر من الرتل ويُهملها. تُعرِّف الأرتال المُعطِّلة بجافا الكثير من التوابع الأخرى. يتشابه التابع bqueue.poll(500)‎ مثلًا مع التابع bqueue.take()‎ باستثناء أنه يُعطِّل لمدة 500 ميللي ثانية بحدٍ أقصى، ولكن التوابع المذكورة بالأعلى كافيةٌ للأمثلة التالية. لاحِظ وجود تابعين لإضافة العناصر إلى الرتل؛ حيث يُعطِّل التابع bqueue.put(item)‎ المُستدعِي إذا لم يَكُن هناك أي مكانٍ آخر متاحٍ بالرتل، ولذلك يُستخدَم مع أرتال التعطيل محدودة السعة؛ بينما لا يُعطِّل التابع bqueue.add(item)‎ المُستدعِي، ولذلك يُستخدَم مع أرتال التعطيل التي تملُك سعةً غير محدودة. تُخصَّص السعة القصوى لرتلٍ من النوع ArrayBlockingQueue عند إنشائه. تُنشِئ الشيفرة التالية على سبيل المثال رتلًا مُعطِّلًا بإمكانه حَمْل ما يَصِل إلى 25 كائنٍ من النوع ItemType: ArrayBlockingQueue<ItemType> bqueue = new ArrayBlockingQueue<>(25); إذا اِستخدَمنا الرتل المُعرَّف بالأعلى، فسيُعطِّل التابع bqueue.put(item)‎ المُستدعِي إذا كان bqueue يحتوي على 25 عنصرٍ بالفعل؛ بينما يُبلِّغ التابع bqueue.add(item)‎ عن استثناءٍ في تلك الحالة. يضمَن ذلك عدم إنتاج العناصر بمعدّلٍ أسرع من مُعدّل استهلاكها. في المقابل، يُستخدَم الصنف LinkedBlockingQueue لإنشاء أرتالٍ مُعطِّلة بسعةٍ غير محدودة. ألقِ نظرةً على المثال التالي: LinkedBlockingQueue<ItemType> bqueue = new LinkedBlockingQueue<>(); تُنشِئ الشيفرة السابقة رتلًا بدون حدٍ أقصى لعدد العناصر التي يُمكِنه حملها. في تلك الحالة، لن يتسبَّب التابع bqueue.put(item)‎ بحدوث تعطيل نهائيًا، ولن يُبلِّغ التابع bqueue.add(item)‎ عن استثناءٍ من النوع IllegalStateException على الإطلاق. يُمكِننا اِستخدَام الصنف LinkedBlockingQueue إذا أردنا تجنُّب تعطيل المُنتجِين، ولكن من الجهة الأخرى، لا بُدّ أن نتأكَّد من بقاء الرتل بحجمٍ معقول. يؤدي التابع bqueue.take()‎ إلى حدوث تعطيلٍ إذا كان الرتل فارغًا لكلا الصنفين. يَستخدِم البرنامج التوضيحي MultiprocessingDemo3.java رتلًا من الصنف LinkedBlockingQueue بدلًا من الصنف ConcurrentLinkedQueue المُستخدَم في النسخة السابقة من البرنامج MultiprocessingDemo2.java. يحتوي الرتل في هذا المثال على عدة مهام (أي العناصر المنتمية للنوع Runnable)، ويُصرَّح عنه على أنه تابع نسخة instance variable اسمه taskQueue على النحو التالي: LinkedBlockingQueue<Runnable> taskQueue; عندما ينقر المُستخدِم على زر "Start" لحساب الصورة، نُضيف جميع مهام حساب الصورة إلى ذلك الرتل باستدعاء التابع taskQueue.add(task)‎ لكل مهمةٍ على حدى. من المهم أن يحدث ذلك دون تعطيل؛ لأننا نُنشِئ تلك المهام بخيط معالجة الأحداث events الذي لا ينبغي تعطيله. لن ينمو الرتل إلى ما لانهاية؛ لأن البرنامج يَعمَل على صورةٍ واحدةٍ فقط بكل مرة، وهناك مئاتٌ قليلةٌ من المهام للصورة الواحدة. على نحوٍ مشابه للنسخة السابقة من البرنامج، ستحذِف الخيوط العاملة المنتمية إلى مجمع الخيوط thread pool المهام من الرتل وتُنفِّذها، ولكنها -أي الخيوط- تُنشَئ مرةً واحدةً فقط ببداية البرنامج؛ أو بتعبيرٍ أدق عندما ينقر المُستخدِم على زر "Start" لأول مرة. يُعاد استخدام نفس تلك الخيوط لأي عددٍ من الصور، وفي حالة عدم وجود أي مهامٍ أخرى، سيُصبِح الرتل فارغًا، وستتعطَّل الخيوط إلى حين قدوم مهامٍ جديدة. تُنفِّذ تلك الخيوط حلقة تكرارٍ لا نهائية infinite loop، وتُعالِج المهام للأبد، ولكنها تقضِي وقتًا طويلًا معطَّلةً بانتظار إضافة مهمةٍ جديدةٍ إلى الرتل. انظر تعريف الصنف المُمثِل لتلك الخيوط: // 1 private class WorkerThread extends Thread { WorkerThread() { try { setPriority( Thread.currentThread().getPriority() - 1); } catch (Exception e) { } try { setDaemon(true); } catch (Exception e) { } start(); // يبدأ الخيط العمل بمجرد تشغيله } public void run() { while (true) { try { Runnable task = taskQueue.take(); // انتظر مهمة إذا كان ذلك ضروريًا task.run(); } catch (InterruptedException e) { } } } } [1] يُعرِّف هذا الصنف الخيوط العاملة الموجودة بمجمع الخيوط، حيث يَعمَل كائنٌ من هذا الصنف بحلقةٍ يَسترجِع كل تكرارٍ منها مهمةً من الرتل taskQueue ثم يستدعي التابع run()‎ الخاص بتلك المهمة. إذا كان الرتل فارغًا، يتعطَّل الخيط إلى أن تتوفَّر مهمةٌ جديدةٌ بالرتل. يتولى الباني مهمة بدء الخيط، وبالتالي لن يضطر البرنامج main لفعل ذلك. يَعمَل الخيط بأولويةٍ أقل من أولوية الخيط الذي اِستدعَى الباني. صُمِّم الصنف لكي يَعمَل بحلقةٍ لا نهائية تنتهي فقط عند إغلاق آلة جافا الافتراضية Java virtual machine، وهذا على فرض عدم تبليغ المهام المُنفَّذة عن أي استثناءاتٍ وهو افتراضٌ صحيحٌ بهذا البرنامج. يَضبُط الباني الخيط ليعمل مثل خيطٍ خفي، وبالتالي تنتهي آلة جافا الافتراضية تلقائيًا عندما تكون الخيوط الوحيدة الموجودة من النوع الخفي، أي لا يَمنَع وجود تلك الخيوط آلة جافا من الإغلاق. ينبغي فحص طريقة عمل مجمع الخيوط، حيث تُنشَأ الخيوط وتُشغَّل قبل وجود أي مهمة. يَستدعِي كل خيطٍ منها التابع taskQueue.take()‎ فورًا، ونظرًا لأن رتل المهام فارغ، تتعطَّل جميع الخيوط بمجرد تشغيلها. والآن، لكي نُعالِج صورةً معينة، يُنشِئ خيط معالجة الأحداث المهام الخاصة بتلك الصورة، ويُضيفها إلى الرتل. بمجرد حدوث ذلك، تعود الخيوط للعمل وتبدأ بمعالجة المهام، ويستمر الحال كذلك إلى أن يَفرُغ الرتل مرةً أخرى. في حالة تشغيل البرنامج بحاسوبٍ مُتعدّد المعالجات، تبدأ بعض الخيوط بمعالجة المهام المُضافة إلى الرتل بينما ما يزال خيط معالجة الأحداث مستمرٌ بإضافة المهام. عندما يُصبِح الرتل فارغًا، تتعطَّل الخيوط مجددًا إلى أن نرغب بمعالجة صورةٍ جديدة. إضافةً إلى ما سبق، قد نرغب بإلغاء معالجة صورةٍ معينةٍ قبل انتهائها، ولكننا لا نريد إنهاء الخيوط العاملة في تلك الحالة. عندما ينقر المُستخدِم على الزر "Abort"، يَستدعِي البرنامج التابع taskQueue.clear()‎، مما يَمنَع إسناد أي مهامٍ أخرى إلى الخيوط، ومع ذلك فمن المحتمل أن تكون بعض المهام قيد التنفيذ بالفعل بينما نُفرِّغ الرتل، وستكتمل بالتالي تلك المهام بعد إلغاء المعالجة المُفترَض كونهم أجزاءٌ منها، ولكننا لا نريد تطبيق خَرْج تلك المهام على الصورة. يُمكِننا حلّ تلك المشكلة بإسناد رقم وظيفة لكل وظيفة معالجة؛ حيث سيُخزَّن رقم الوظيفة الحالية بمتغير نسخة instance variable اسمه jobNum. ينبغي أن يحتوي كل كائن مُمثِّل لمهمة على تابع نسخة يُحدِّد الوظيفة التي تُعدّ تلك المهمة جزءًا منها. تزداد قيمة jobNum بمقدار الواحد عند انتهاء وظيفة؛ إما لأنها انتهت على نحوٍ طبيعي؛ أو لأن المُستخدِم قد ألغاها. عند اكتمال مهمةٍ معينة، لا بُدّ أن نوازن بين رقم الوظيفة المُخزَّن بكائن المهمة وبين jobNum؛ فإذا كانا متساويين، تكون المهمة جزءًا من الوظيفة الحالية، ويُطبَق خَرْجها على الصورة؛ أما إذا لم يكونا متساويين، تكون تلك المهمة جزءًا من الوظيفة السابقة، ويُهمَل خَرْجها. من المهم أن يكون الوصول إلى jobNum متزامنًا synchronized، وإلا قد يَفحَص خيطٌ معينٌ رقم الوظيفة بينما يزيده خيطٌ آخر، وعندها قد نَعرِض خرجًا معنيًّا لوظيفةٍ سابقة مع أننا ألغينا تلك الوظيفة. جميع التوابع التي تقرأ قيمة jobNum أو تُعدِّله في هذا البرنامج متزامنة. يُمكِنك قراءة شيفرة البرنامج لترى طريقة عملها. هناك ملاحظةٌ إضافية عن البرنامج "MultiprocessingDemo3"، وهي: نحن لم نُوفِّر أي طريقةٍ لإنهاء الخيوط العاملة ضمن ذلك البرنامج أي أنها ستستمر بالعمل إلى أن نُغلِق آلة جافا الافتراضية Java Virtual Machine. يُمكِننا السماح بإنهاء الخيوط قبل ذلك باستخدام متغيرٍ متطايرٍ volatile، اسمه running، وضبط قيمته إلى false عندما نرغب بإنهائها، وسنُعرِّف التابع run()‎ الموجود بالخيوط على النحو التالي: public void run() { while ( running ) { try { Runnable task = taskQueue.take(); task.run(); } catch (InterruptedException e) { } } } ومع ذلك، إذا كان هناك خيطٌ مُعطّلٌ نتيجةً لاستدعاء taskQueue.take()‎، فلن يتمكَّن من رؤية القيمة الجديدة للمتغيّر running قبل أن يعود للعمل. لنتأكَّد من إنهائه، يُمكِننا استدعاء التابع worker.interrupt()‎ لكل خيط worker بعد ضبط قيمة running إلى false. في حالة تنفيذ خيطٍ لمهمةٍ بينما نضبُط قيمة running إلى false، فإنه لن ينتهي حتى يُكمِل تلك المهمة. إذا كانت المهام قصيرةً نسبيًا، لن يُشكِّل ذلك مشكلة، ولكن إذا استغرقت المهام وقتًا أطول مما ترغب بانتظاره، فلا بُدّ أن تَفحَص المهام قيمة running دوريًا، وتنتهي إذا أصبحت قيمته مساويةً القيمة false. نهج ExecutorService لتنفيذ المهام يشيع استخدام مجمعات الخيوط thread pools بالبرمجة على التوازي، ولذلك، تُوفِّر جافا أدوات عالية المستوى لإنشاء مجمعات الخيوط وإدارتها. تُعرِّف الواجهة ExecutorService من حزمة java.util.concurrent خدماتٍ يُمكِنها تنفيذ المهام المُرسَلة إليها. يحتوي الصنف Executors على توابعٍ ساكنة static تُنشِئ أنواعًا مختلفةً من النوع ExecutorService. وبالأخص، يُنشِئ التابع Executors.newFixedThreadPool(n)‎ مجمع خيوطٍ مُكوَّن من عدد n من الخيوط، حيث n هي عدد صحيح. تُنشِئ الشيفرة التالية مجمع خيوط مُكوّنٍ من خيطٍ واحدٍ لكل معالج: int processors = Runtime.getRuntime().availableProcessors(); ExecutorService executor = Executors.newFixedThreadPool(processors); يُستخدَم التابع executor.execute(task)‎ لإرسال كائنٍ من النوع Runnable لتنفيذه، ويعود على الفور بعد وضعه للمهمة داخل رتل المهام المُنتظِرَة. تَحذِف الخيوط الموجودة بمجمع الخيوط المهام من الرتل وتُنفِّذها. يُخبِر التابع executor.shutdown()‎ مجمع الخيوط بأن عليه الانتهاء بعد تنفيذ جميع المهام المُنتظِرَة، ويعود التابع على الفور دون أن ينتظر انتهاء الخيوط. بعد استدعاء ذلك التابع، لا يُسمَح بإضافة مهامٍ جديدة. يُمكِنك استدعاء shutdown()‎ أكثر من مرة، ولن يُعدّ ذلك خطأً. لا تُعدّ الخيوط الموجودة بمجمع الخيوط خيوطًا خفية daemon threads؛ أي في حالة انتهاء الخيوط الأخرى دون إغلاق الخدمة، يكون وجود تلك الخيوط كافٍ لمنع إغلاق آلة جافا الافتراضية. يتشابه التابع executor.shutdownNow()‎ مع التابع executor.shutdown()‎، إلا أنه يُهمِل المهام التي ما تزال قيد الانتظار بالرتل، وتُكمِل الخيوط المهام التي كانت قد حُذفت من الرتل بالفعل قبل الإغلاق. يَختلف البرنامج التوضيحي MultiprocessingDemo4.java عن البرنامج MultiprocessingDemo3؛ حيث يَستخدِم النوع ExecutorService بدلًا من الاستخدام المباشر للخيوط والأرتال المُعطِّلة. نظرًا لعدم وجود طريقةٍ بسيطةٍ تَسمَح للنوع ExecutorService بتجاهُل المهام المُنتظِرَة دون أن يُغلَق، يُنشِئ البرنامج "MultiprocessingDemo4" كائنًا جديدًا من النوع ExecutorService لكل صورة. يُمكِننا تمثيل المهام المُستخدَمة مع النوع ExecutorService بكائناتٍ من النوع Callable<T>‎، حيث يُمثِّل ذلك النوع واجهة نوع دالة functional interface ذات معاملاتٍ غير مُحدَّدة النوع، ويُعرِّف التابع call()‎، الذي لا يستقبل أي معاملاتٍ ويعيد النوع T. يُمثِل النوع Callable مهمةً تُخرِج قيمة. يُمكِننا إرسال كائنٍ c من النوع Callable إلى النوع ExecutorService باستدعاء التابع executor.submit(c)‎، حيث تُنفَّذ المهمة من النوع Callable بلحظةٍ ما في المستقبل. في تلك الحالة، كيف سنَحصُل على نتيجة المعالجة عند اكتمالها؟ يُمكِننا حل تلك المشكلة باستخدام واجهةٍ أخرى هي Future<T>‎، التي تُمثِّل قيمةً من النوع T قد تكون غير متاحةٍ حتى وقتٍ ما بالمستقبل. يعيد التابع executor.submit(c)‎ قيمةً من النوع Future تُمثِّل نتيجة المعالجة المؤجَّلة. يُعرِّف كائنٌ v من النوع Future مجموعةً من التوابع، مثل الدالة المنطقية v.isDone()‎ التي يُمكِننا استدعاؤها لفحص فيما إذا كانت نتيجة المعالجة قد أصبحت متاحة؛ وكذلك التابع v.get()‎ الذي يسترجع نتيجة المعالجة المؤجَّلة، وسيُعطًّل إلى أن تُصبِح القيمة متاحة، كما قد يُبلِّغ عن استثناءات، ولذلك ينبغي استدعاؤه ضمن تعليمة try..catch. يَستخدِم المثال ThreadTest4.java الأنواع Callable و Future و ExecutorService لعدّ عدد الأعداد الأولية الواقعة ضمن نطاقٍ معينٍ من الأعداد الصحيحة؛ كما يُجرِي نفس المعالجة التي أجراها البرنامج ThreadTest2.java في قسم الإقصاء التشاركي Mutual Exclusion وتعليمة التزامن synchronized من مقال مقدمة إلى الخيوط Threads في جافا. ستَعُدّ كل مهمةٍ فرعية في هذا البرنامج عدد الأعداد الأولية ضمن نطاقٍ أصغر من الأعداد الصحيحة، وستُمثَّل تلك المهام الفرعية من خلال كائناتٍ من النوع Callable<Integer>‎ المُعرَّفة بالصنف المتداخل nested التالي: // 1 private static class CountPrimesTask implements Callable<Integer> { int min, max; public CountPrimesTask(int min, int max) { this.min = min; this.max = max; } public Integer call() { int count = countPrimes(min,max); // يبدأ بالعدّ return count; } } [1] تعدّ الكائنات المنتمية إلى هذا الصنف الأعداد الأولية الموجودة ضمن نطاقٍ معين من الأعداد الصحيحة من min إلى max. تُمرَّر قيمة المتغيرين min و max مثل معاملاتٍ للباني. يحسب التابع call()‎ عدد الأعداد الأولية ثم يعيدها. ستُرسَل جميع المهام الفرعية إلى مجمع خيوط مُنفَّذ باستخدام النوع ExecutorService، وتُخزَّن النتائج من النوع Future التي يعيدها داخل مصفوفةٍ من النوع ArrayList. ألقِ نظرةً على الشيفرة التالية: int processors = Runtime.getRuntime().availableProcessors(); ExecutorService executor = Executors.newFixedThreadPool(processors); ArrayList<Future<Integer>> results = new ArrayList<>(); for (int i = 0; i < numberOfTasks; i++) { CountPrimesTask oneTask = . . . ; Future<Integer> oneResult = executor.submit( oneTask ); results.add(oneResult); // خزِّن الكائن الذي يُمثِّل النتيجة المؤجَلة } لا بُدّ أن نُضيف الأعداد الصحيحة الناتجة عن المهام الفرعية إلى المجموع النهائي. سنحصل أولًا على خَرْج تلك المهام باستدعاء التابع get()‎ لعناصر المصفوفة المنتمية إلى النوع Future. لن تكتمل العملية إلا بعد انتهاء جميع المهام الفرعية، لأن التابع يُعطِّل المُستدعِي إلى أن تتوفَّر النتيجة. ألقِ نظرةً على الشيفرة التالية: int total = 0; for ( Future<Integer> res : results) { try { total += res.get(); // انتظر اكتمال المهمة } catch (Exception e) { // لا ينبغي أن تحدث بهذا البرنامج } } تابعا الانتظار Wait والتنبيه Notify إذا كنا نريد كتابة تنفيذٍ للرتل المعطِّل، فينبغي أن نُعطِّل الخيط إلى حين وقوع حدثٍ معين؛ أي أن ينتظر الخيط وقوع ذلك الحدث، وينبغي أن نُبلِّغه عند وقوعه بطريقةٍ ما. سنَستخدِم لذلك خيطين؛ حيث يقع الفعل المُسبِّب للحدث المنتظَر (مثل إضافة عنصرٍ إلى رتل) بخيطٍ غير الخيط المُعطَّل. لا يُمثِّل ما يَلي مشكلةً للأرتال المُعطِّلة فقط؛ ففي حالة وجود خيطٍ يُنتِج خرجًا يحتاج إليه خيطٌ آخر، فإن ذلك يَفرِض نوعًا من التقييد على الترتيب الذي ينبغي للخيوط أن تُنفِّذ العمليات على أساسه. إذا وصلنا إلى النقطة التي يحتاج خلالها الخيط الثاني إلى الخرج الناتج عن الخيط الأول، قد يضطّر الخيط الثاني إلى التوقُّف وانتظار إتاحة ذلك الخرج؛ ونظرًا لأنه لا يستطيع الاستمرار، فإنه قد ينام sleep، ولا بُدّ في تلك الحالة من توفير طريقةٍ لتنبيهه عندما يُصبِح الخرج متاحًا، حتى يستيقظ ويُكمِل عملية المعالجة. تُوفِّر جافا بالطبع طريقةً لتنفيذ هذا النوع من الانتظار والتنبيه؛ حيث يحتوي الصنف Object على تابعي النسخة wait()‎ و notify()‎، ويُمكِن استخدامهما مع أي كائن، كما يمكن للأرتال المعطِّلة اِستخدَام تلك التوابع ضمن تنفيذها الداخلي، ولكنها منخفضة المستوى وعرضةً للأخطاء؛ ولذلك يُفضَّل اِستخدام أساليب التحكُّم عالية المستوى مثل أرتال الأولوية قدر الإمكان. مع ذلك، من الجيد معرفة القليل عن التابعين wait()‎ و notify()‎، فلربما قد تحتاج إلى استخدامهما مباشرةً. من غير المعروف فيما إذا كانت أصناف جافا القياسية للأرتال المُعطِّلة تَستخدِم هذين التابعين فعليًا، خاصةً مع توقُّر طرائقٍ أخرى لحل مشكلة الانتظار والتنبيه. السبب وراء ضرورة ربط التابعين wait()‎ و notify()‎ بالكائنات واضح، وبالتالي ليس هناك داعٍ للقلق بشأن ذلك، فهو يَسمَح على الأقل بتوجيه تنبيهاتٍ من أنواعٍ مختلفة إلى مستقبلين من أنواعٍ مختلفة اعتمادًا على تابع الكائنnotify()‎ المُستدعى. عندما يَستدعِي خيطٌ ما التابع wait()‎ الخاص بكائنٍ معين، يتوقَّف ذلك الخيط وينام إلى حين استدعاء التابع notify()‎ الخاص بنفس الكائن، حيث سيكون استدعاؤه ضروريًا من خلال خيطٍ آخر؛ لأن الخيط الذي اِستدعَى wait()‎ سيكون نائمًا. تَعمَل إحدى الأنماط الشائعة على النحو التالي: يستدعِي خيط A التابع wait()‎ عندما يحتاج إلى الخرج الناتج من خيط B، ولكن ذلك الخرج غير متاحٍ بعد. عندما يُحصِّل الخيط B الخرج المطلوب، فإنه يَستدعِي التابع notify()‎ الذي سيوقِظ الخيط A إذا كان منتظرًا ليتمكَّن من اِستخدَام الناتج. في الواقع، ليس من الخطأ استدعاء التابع notify()‎ حتى لو لم يَكُن هناك أي خيوطٍ مُنتظِرَة، فليس لها أي تأثير. لنُنفِّذ ذلك، ينبغي أن يُنفِّذ الخيط A شيفرةً مشابهةً لما يلي، حيث obj هو كائن: if ( resultIsAvailable() == false ) obj.wait(); // انتظر تنبيهًا بأن النتيجة مُتاحة useTheResult(); بينما ينبغي أن يُنفِّذ الخيط B شيفرةً مشابهةً لما يَلي: generateTheResult(); obj.notify(); // أرسل تنبيهًا بأن النتيجة قد أصبحت متاحة تعاني تلك الشيفرة من حالة تسابق race condition، فقد يُنفِّذ الخيطان شيفرتهما بالترتيب التالي: // يفحص الخيط‫ A التابع `resultIsAvailable()‎` ولا يجد النتيجة بعد، لذلك، يُنفِّذ تعليمة `obj.wait()‎`، ولكن قبل أن يفعل، 1. Thread A checks resultIsAvailable() and finds that the result is not ready, so it decides to execute the obj.wait() statement, but before it does, // ‫ينتهي الخيط B من عمله ويَستدعِي التابع `obj.notify()‎` 2. Thread B finishes generating the result and calls obj.notify() // ‫يَستدعِي الخيط A التابع `obj.wait()‎` لينتظر تنبيهًا بتوفُّر النتيجة 3. Thread A calls obj.wait() to wait for notification that the result is ready. ينتظر الخيط A بالخطوة الثالثة تنبيهًا لن يحدث أبدًا؛ لأن notify()‎ قد اُستدعيَت بالفعل بالخطوة الثانية. يُمثِّل ذلك نوعًا من القفل الميت deadlock الذي يُمكِنه أن يترك الخيط A مُنتظِرًا للأبد. نحتاج إذًا إلى نوعٍ من المزامنة synchronization. يكمن حل تلك المشكلة في وضع شيفرة الخيطين A و B داخل تعليمة synchronized، ومن البديهي أن تكون المزامنة بناءً على نفس الكائن obj المُستخدَم عند استدعاء wait()‎ و notify()‎. نظرًا لأهمية استخدام المزامنة عند كل استدعاءٍ للتابعين wait()‎ و notify()‎ تقريبًا، جعلته جافا أمرًا ضروريًا؛ أي بإمكان خيطٍ معينٍ استدعاء obj.wait()‎ أو obj.notify()‎ فقط إذا كان ذلك الخيط قد حَصَل على قفل المزامنة المُرتبِط بالكائن obj؛ أما إذا لم يَكُن قد حَصَل عليه، يحدث استثناء من النوع IllegalMonitorStateException. لا يتطلَّب هذا الاستثناء معالجةً إجباريةً ولا يُلتقَط على الأرجح. علاوةً على ذلك، قد يُبلِّغ التابع wait()‎ عن اتستثناءٍ من النوع InterruptedException، ولذلك لا بُدّ من استدعائه ضمن تعليمة try لمعالجته. لنفحص الآن طريقة وصول خيطٍ معينٍ إلى نتيجةٍ يحسبها خيطٌ آخر. يُعدّ ذلك مثالًا مبسطًا على مشكلة المُنتِج والمُستهلِك producer/consumer، حيث يُنتَج عنصرٌ واحدٌ فقط ثم يُستهلَك. لنفترض أن لدينا متغيرًا تشاركيًا، اسمه sharedResult مُستخدَمٌ لنقل النتيجة من المُنتِج إلى المُستهلِك. عندما تُصبِح النتيجة جاهزة، يضبُط المُنتِج ذلك المتغير إلى قيمةٍ غير فارغة. يُحدِّد المُستهلِك من الجهة الأخرى فيما إذا كانت النتيجة جاهزةً أم لا بفحص قيمة المتغير sharedResult إذا كانت فارغة. سنَستخدِم مُتغيّرًا اسمه lock للمزامنة. يُمكِننا كتابة شيفرة الخيط المُمثِّل للمُنتِج على النحو التالي: makeResult = generateTheResult(); // غير متزامن synchronized(lock) { sharedResult = makeResult; lock.notify(); } بينما سيُنفِّذ المُستهلِك الشيفرة التالية: synchronized(lock) { while ( sharedResult == null ) { try { lock.wait(); } catch (InterruptedException e) { } } useResult = sharedResult; } useTheResult(useResult); // Not synchronized! لاحِظ أن استدعاء كُلٍ من التابعين generateTheResult()‎ و useTheResult()‎ غير متزامن، لنَسمَح بتنفيذهما على التوازي مع الخيوط الأخرى التي قد تُجرِي تزامنًا بناءً على lock، ولكن نظرًا لأن المتغير sharedResult تشاركي، كان من الضروري أن تكون جميع مراجِعه references متزامنة؛ أي لا بُدّ من كتابتها داخل تعليمة synchronized، مع محاولة تنفيذ أقل ما يُمكِن عمومًا داخل كتل الشيفرة المتزامنة. ربما لاحظت شيئًا مضحكًا بالشيفرة: لا ينتهي lock.wait()‎ قبل تنفيذ lock.notify()‎، ولكن نظرًا لأن كليهما مكتوبٌ داخل تعليمة synchronized بتزامنٍ مبني على الكائن نفسه، قد تتساءل: أليس من المستحيل تنفيذ هذين التابعين بنفس الوقت؟ في الواقع، يُعدّ التابع lock.wait()‎ حالةً خاصة؛ فعندما يَستدعِي خيطٌ ما التابع lock.wait()‎، فإنه يترك قفله بالضرورة على كائن المزامنة، مما يَسمَح لخيطٍ آخرٍ بتنفيذ كتلة شيفرة داخل تعليمة synchronized(lock)‎ أخرى يوجد بداخلها استدعاءٌ للتابع lock.notify()‎. وبالتالي، بعدما يُنهِي الخيط الثاني تنفيذ تلك الكتلة، يعود القفل إلى الخيط الأول المُستهلِك مما يُمكِّنه من إكمال عمله. تُنتَج في نمط المُنتِج والمُستهلِك العادي عدة نتائجٍ بواسطة خيط مُنتِجٍ واحدٍ أو أكثر، وتُستهلَك بواسطة خيط مُستهلِكٍ واحدٍ أو أكثر، وبدلًا من وجود كائن sharedResult وحيد، تجد قائمةً بالكائنات المُنتَجَة التي لم تُستهلَك بعد. لنفحص طريقة فعل ذلك باستخدام صنفٍ بسيطٍ للغاية يُنفِّذ العمليات الثلاثة على رتلٍ من النوع LinkedBlockingQueue<Runnable>‎، الذي اِستخدَمناه بالبرنامج MultiprocessingDemo3. ألقِ نظرةً على الشيفرة التالية: import java.util.LinkedList; public class MyLinkedBlockingQueue { private LinkedList<Runnable> taskList = new LinkedList<Runnable>(); public void clear() { synchronized(taskList) { taskList.clear(); } } public void add(Runnable task) { synchronized(taskList) { taskList.addLast(task); taskList.notify(); } } public Runnable take() throws InterruptedException { synchronized(taskList) { while (taskList.isEmpty()) taskList.wait(); return taskList.removeFirst(); } } } سنَستخدِم كائنًا من ذلك الصنف بديلًا عن الكائن taskQueue بالبرنامج "MultiprocessingDemo3". فضَّلنا إجراء المزامنة بناءً على الكائن taskList، ولكن كان من الممكن إجراؤها بناءً على أي كائنٍ آخر. يُمكِننا في الحقيقة استخدام توابعٍ متزامنة synchronized methods، وهو ما سيُكافِئ المزامنة بناءً على this. من الضروري أن يكون استدعاء التابع taskList.clear()‎ مبنيًا على نفس الكائن حتى لو لم نَستدعِي wait()‎ أو notify()‎؛ وإذا لم نَفعَل ذلك، قد تحدث حالة تسابق race condition، ألا وهي: قد تُفرَّغ القائمة بعدما يتأكَّد التابع take()‎ من أن القائمة taskList غير فارغة وقبل أن يحاول حذف عنصرٍ منها. في تلك الحالة، ستُصبِح القائمة فارغة عند لحظة استدعاء taskList.removeFirst()‎ مما سيتسبَّب بحدوث خطأ. في حالة تواجد عدة خيوطٍ متزامنة بناءً على كائن obj ومُنتظِرةٍ للتنبيه. يُوقِظ التابع obj.notify()‎ عند استدعائه واحدًا فقط من تلك الخيوط المُنتظِرة؛ وإذا أردت أن توقظها جميعًا، ينبغي أن تَستدعِي التابع obj.notifyAll()‎. من المناسب اِستخدَام التابع obj.notify()‎ بالمثال السابق؛ لأن الخيوط المُستهلِكة فقط هي الخيوط المُعطَّلة، ونحن نريد إيقاظ مُستهلِكٍ واحدٍ فقط عند إضافة مهمةٍ إلى الرتل، ولا يُهِم أي مُستهلِكٍ تُسنَد إليه المهمة. في المقابل، إذا كان لدينا رتلٌ مُعطِّل blocking queue بسعةٍ قصوى، أي أنه قد يُعطِّل المُنتجِين أو المُستهلِكِين؛ فعند إضافة مهمةٍ إلى الرتل، ينبغي التأكُّد من تنبيه خيط مُستهلِكٍ لا خيط مُنتِج، ويُمثِّل استدعاء التابع notifyAll()‎ بدلًا من التابع notify()‎ إحدى حلول تلك المشكلة، لأنه سيُنبِّه جميع الخيوط بما في ذلك أي خيط مُستهلِكٍ مُنتظِر. قد يعطيك اسم التابع obj.notify()‎ انطباعًا خاطئًا. لا يُنبِّه ذلك التابع الكائن obj بأي شيء، وإنما يُنبِّه الخيط الذي اِستدعَى التابع obj.wait()‎ إذا كان موجودًا. بالمثل، لا ينتظر الكائن obj بالاستدعاء obj.wait()‎ أي شيء، وإنما الخيط المُستدعِي هو من ينتظر. وفي ملاحظة أخيرة بخصوص wait: هناك نسخةٌ أخرى من التابع wait()‎، وهي تَستقبِل زمنًا بوحدة الميللي ثانية مثل مُعامِل؛ وهنا سينتظر الخيط المُستدعِي للتابع obj.wait(milliseconds)‎ تنبيهًا لفترةٍ تَصِل إلى القيمة الُممرَّرة بحدٍ أقصى؛ وإذا لم يحدث التنبيه خلال تلك الفترة، يستيقظ الخيط ويُكمِل عمله دون تنبيه. تُستخدَم تلك الخاصية عمليًا لتَسمَح لخيطٍ مُنتظِر بالاستيقاظ كل فترة لإنجاز مهمةٍ دوريةٍ معينة، مثل التسبُّب في ظهور رسالة مثل "Waiting for computation to finish". لنفحص الآن مثالًا يَستخدِم التابعين wait()‎ و notify()‎ ليَسمَح لخيطٍ بالتحكُّم بخيطٍ آخر. يَحِلّ البرنامج التوضيحي TowersOfHanoiGUI.java مسألة أبراج هانوي التي تعرَّضنا لها بالقسم مشكلة أبراج هانوي Hanoi من مقال التعاود recursion في جافا، ويُوفِّر أزرارًا تَسمَح للمُستخدِم بالتحكُّم بتنفيذ الخوارزمية. يَستطيع المُستخدِم مثلًا النقر على زر "Next Step" ليُنفِّذ خطوةً واحدةً من الحل، والتي تُحرِّك قرصًا واحدًا من كومةٍ لأخرى. عند النقر على زر "Run"، تُنفَّذ الخوارزمية أتوماتيكيًا دون تدخُّل المُستخدِم، ويتبدَّل النص المكتوب على الزر من "Run" إلى "Pause". عند النقر على "Pause"، يتوقَّف التشغيل التلقائي. يُوفِّر البرنامج الزر "Start Over"، الذي يُلغي الحل الحالي، ويعيد المسألة إلى حالتها الابتدائية. تَعرِض الصورة التالية شكل البرنامج بإحدى خطوات الحل، ويُمكِنك رؤية الأزرار المذكورة: يوجد خيطان بهذا البرنامج؛ حيث يُنفِّذ الأول خوارزميةً تعاودية recursive لحلّ المسألة؛ ويُعالِج الآخر الأحداث الناتجة عن أفعال المُستخدِم. عندما ينقر المُستخدِم على أحد الأزرار، تُستدعَى إحدى التوابع بخيط معالجة الأحداث، ولكن من يَستجِيب فعليًا للحدث هو الخيط المُنفِّذ للتعاود؛ فقد يُنفِّذ مثلًا خطوةً واحدةً من الحل أو يبدأه من جديد. لا بُدّ أن يُرسِل خيط معالجة الأحداث نوعًا من الإشارة إلى خيط الحل من خلال ضبط قيمة متغيرٍ يتشاركه الخيطان. اسم هذا المتغير بالبرنامج هو status، وقيمه المُحتمَلة هي الثوابت GO و PAUSE و STEP و RESTART. عندما يُعدِّل خيط معالجة الأحداث قيمة ذلك المتغيّر، لا بُدّ أن يلاحظ خيط الحل القيمة الجديدة للمتغير، ويستجيب على أساسها؛ فإذا كانت قيمة status هي PAUSE، لا بُدّ أن يتوقَّف الخيط بانتظار نَقْر المُستخدِم على زر "Run" أو "Next Step"، ويُمثِّل ذلك الحالة المبدئية عند بدء البرنامج؛ أما إذا نقر المُستخدِم على زر "Next Step"، يَضبُط خيط معالجة الأحداث قيمة status إلى "STEP"، وبالتتابع، لا بُدّ أن يلاحِظ خيط الحل القيمة الجديدة، ويستجيب بتنفيذ خطوةٍ واحدةٍ من الحل، ثم يعيد ضَبْط قيمة status إلى PAUSE مرةً أخرى. إذا نقر المُستخدِم على زر "Run"، تُضبَط قيمة status إلى "GO"، وينبغي أن يُنفِّذ خيط الحل الخوارزمية أتوماتيكيًا؛ وإذا نقر المُستخدِم على زر "Pause" بينما الحل مُشغَّل، تُضبَط قيمة status إلى "PAUSE"، وينبغي أن يعود خيط الحل إلى حالة الإيقاف؛ أما إذا نقر المُستخدِم على زر "Start Over"، يَضبُط خيط معالجة الأحداث قيمة المُتغيّر status إلى "RESTART"، ولا بُدّ أن يُنهِي خيط الحل حلّه الحالي. ما يُهمّنا بهذا المثال هو الحالة التي يتوقَّف خلالها خيط الحل؛ حيث يكون الخيط نائمًا في تلك الحالة، ولا يكون بإمكانه رؤية القيمة الجديدة للمتغير status إلا إذا أيقظناه. سنَستخدِم التابع wait()‎ بخيط الحل لجعله ينام، وسنَستخدِم التابع notify()‎ بخيط معالجة الأحداث عندما نُعدِّل قيمة المتغير status لكي نُوقِظ خيط الحل. تعرض الشيفرة التالية التوابع التي تَستجيب لحدث النقر على الأزرار. عندما ينقر المُستخدِم على زر معين، يُعدِّل التابع المقابل لذلك الزر قيمة المتغير status، ثم يَستدعِي التابع notify()‎ لكي يُوقِظ خيط الحل: synchronized private void doStopGo() { if (status == GO) { // التحريكة مُشغَّلة. أوقفها status = PAUSE; nextStepButton.setDisable(false); runPauseButton.setText("Run"); } else { // Animation is paused. Start it running. status = GO; nextStepButton.setDisable(true); // يُعطَّل عند تشغيل التحريكة runPauseButton.setText("Pause"); } notify(); // أيقظ الخيط ليتمكَّن من رؤية الحالة الجديدة } synchronized private void doNextStep() { status = STEP; notify(); } synchronized private void doRestart() { status = RESTART; notify(); } لاحِظ أن تلك التوابع متزامنة لتَسمَح باستدعاء notify()‎. تذكَّر أنه لا بُدّ للخيط المُستدعِي للتابع notify()‎ ضمن كائنٍ معين أن يكون قد حَصَل على قفل المزامنة المُرتبِط بذلك الكائن. في هذه الحالة، يكون كائن المزامنة هو this. تُعدّ المزامنة ضروريةً لأنه من الممكن أن تحدث حالات التسابق نظرًا لإمكانية خيط الحل أن يُعدِّل قيمة المتغير status. يَستدعِي خيط الحل تابعًا اسمه checkStatus()‎ ليَفحَص قيمة status؛ فإذا كانت قيمة status تُساوِي "PAUSE"، يَستدعِي ذلك التابع بدوره التابع wait()‎ مما يؤدي إلى توقُّف خيط الحل إلى حين استدعاء خيط معالجة الأحداث للتابع notify()‎. لاحِظ أن التابع checkStatus()‎ يُبلِّغ عن استثناءٍ من النوع IllegalStateException إذا كانت قيمة status تُساوِي "RESTART": synchronized private void checkStatus() { while (status == PAUSE) { try { wait(); } catch (InterruptedException e) { } } // بالوصول إلى تلك النقطة، تكون الحالة‫ RUN أو STEP أو RESTART if (status == RESTART) throw new IllegalStateException("Restart"); // بالوصول إلى تلك النقطة، تكون الحالة‫ RUN أو STEP وينبغي أن يستمر الحل } يَضبُط التابع run()‎ الخاص بخيط الحل الحالة المبدئية للمسألة، ثم يَستدعِي التابع solve()‎ لحلها، كما يُنفِّذ حلقةً لا نهائية ليتمكَّن من حل المسألة عدة مرات. يَستدعِي التابع run()‎ التابع checkStatus()‎ قبل أن يبدأ الحل، ويَستدعِي التابع solve()‎ التابع checkStatus()‎ بعد كل حركة. إذا بلَّغ التابع checkStatus()‎ عن استثناءِ من النوع IllegalStateException، يُنهَى استدعاء solve()‎ مبكرًا. كنا قد استخدمنا نفس طريقة التبليغ عن استثناء لإنهاء خوارزميةٍ تعاوديةٍ من قبل بالقسم التعاود داخل الخيوط من المقال السابق. يُمكِنك الإطلاع على الشيفرة الكاملة للبرنامج TowersOfHanoiGUI.java لترى الطريقة التي دمجنا بها جميع تلك الأجزاء إلى البرنامج النهائي، وسيُعينك فهمه على تعلُم طريقة استخدام wait()‎ و notify()‎ مباشرةً. ترجمة -بتصرّف- للقسم Section 3: Threads and Parallel Processing من فصل Chapter 12: Threads and Multiprocessing من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: البرمجة باستخدام الخيوط threads في جافا مقدمة إلى الخيوط Threads في جافا كيفية إنشاء عدة خيوط وفهم التزامن في جافا
  9. لدى أقسام علوم الحاسوب هوس غير طبيعي بخوارزميات الترتيب، فبناءً على الوقت الذي يمضيه طلبة علوم الحاسوب في دراسة هذا الموضوع، قد تظن أن الاختيار ما بين خوارزميات الترتيب هو حجر الزاوية في هندسة البرمجيات الحديثة. واقع الأمر هو أن مطوري البرمجيات قد يقضون سنوات قد تصل إلى مسارهم المهني بأكمله دون التفكير في طريقة حدوث عملية الترتيب، فهم يَستخدِمون في كل التطبيقات تقريبًا الخوارزمية متعددة الأغراض التي توفِّرها اللغة أو المكتبة التي يستخدمونها، والتي تكون كافيةً في معظم الحالات. لذلك لو تجاهلت هذه المقالة ولم تتعلم أي شيء عن خوارزميات الترتيب، فما يزال بإمكانك أن تصبح مُطوِّر برمجيات جيدًا، ومع ذلك، هناك عدة أسباب قد تدفعك لقراءته: على الرغم من وجود خوارزميات متعددة الأغراض يُمكِنها العمل بشكل جيد في غالبية التطبيقات، هنالك خوارزميّتان مُتخصّصتان قد تحتاج إلى معرفة القليل عنهما: الترتيب بالجذر radix sort والترتيب بالكومة المقيدّة bounded heap sort. تُعدّ خوارزمية الترتيب بالدمج merge sort المثال التعليميّ الأمثل، فهي تُوضِّح استراتيجية "قسِّم واغزُ divide and conquer" المهمة والمفيدة في تصميم الخوارزميات. بالإضافة إلى ذلك، ستتعلم عن ترتيب نمو order of growth لم تَرَهُ من قبل، هو ترتيب النمو "الخطي-اللوغاريتمي linearithmic". ومن الجدير بالذكر أن غالبية الخوارزميات الشهيرة تكون عادةً خوارزميّاتٍ هجينةً وتستخدم بشكلٍ أو بآخر فكرة الترتيب بالدمج. أحد الأسباب الأخرى التي قد تدفعك إلى تعلم خوارزميات الترتيب هي مقابلات العمل التقنية، فعادةً ما تُسأل خلالها عن تلك الخوارزميات. إذا كنت تريد الحصول على وظيفة، فسيُساعدك إظهار اطلاعك على أبجديات علوم الحاسوب. سنُحلِّل في هذا الفصل خوارزمية الترتيب بالإدراج insertion sort، وسننفِّذ خوارزمية الترتيب بالدمج، وأخيرًا، سنشرح خوارزمية الترتيب بالجذر، وسنكتب نسخةً بسيطةً من خوارزمية الترتيب بالكومة المُقيّدة. الترتيب بالإدراج Insertion sort سنبدأ بخوارزمية الترتيب بالإدراج، لأنها بسيطة ومهمة. على الرغم من أنها ليست الخوارزمية الأكفأ إلا أنها تملك بعض الميزات المتعلقة بتحرير الذاكرة كما سنرى لاحقًا. لن نشرح هذه الخوارزمية هنا، ولكن يُفضّلُ لو قرأت مقالة ويكيبيديا عن الترتيب بالإدراج Insertion Sort، فهي تحتوي على شيفرة وهمية وأمثلة متحركة. وبعدما تفهم فكرتها العامة يمكنك متابعة القراءة هنا. تَعرِض الشيفرة التالية تنفيذًا بلغة جافا لخوارزمية الترتيب بالإدراج: public class ListSorter<T> { public void insertionSort(List<T> list, Comparator<T> comparator) { for (int i=1; i < list.size(); i++) { T elt_i = list.get(i); int j = i; while (j > 0) { T elt_j = list.get(j-1); if (comparator.compare(elt_i, elt_j) >= 0) { break; } list.set(j, elt_j); j--; } list.set(j, elt_i); } } } عرَّفنا الصنف ListSorter ليَعمَل كحاوٍ لخوارزميات الترتيب. نظرًا لأننا استخدمنا معامل نوع type parameter، اسمه T، ستتمكَّن التوابع التي سنكتبها من العمل مع قوائم تحتوي على أي نوع من الكائنات. يستقبل التابع insertionSort معاملين: الأول عبارة عن قائمة من أي نوع ممتدّ من الواجهة List والثاني عبارة عن كائن من النوع Comparator بإمكانه موازنة كائنات النوع T. يُرتِّب هذا التابع القائمة في نفس المكان أي أنه يُعدِّل القائمة الموجودة ولا يحتاج إلى حجز مساحة إضافية جديدة. تَستدعِي الشيفرة التالية هذا التابع مع قائمة من النوع List تحتوي على كائنات من النوع Integer: List<Integer> list = new ArrayList<Integer>( Arrays.asList(3, 5, 1, 4, 2)); Comparator<Integer> comparator = new Comparator<Integer>() { @Override public int compare(Integer elt1, Integer elt2) { return elt1.compareTo(elt2); } }; ListSorter<Integer> sorter = new ListSorter<Integer>(); sorter.insertionSort(list, comparator); System.out.println(list); يحتوي التابع insertionSort على حلقتين متداخلتين nested loops، ولذلك، قد تظن أن زمن تنفيذه تربيعي، وهذا صحيحٌ في تلك الحالة، ولكن قبل أن تتوصل إلى تلك النتيجة، عليك أولًا أن تتأكّد من أن عدد مرات تنفيذِ كل حلقةٍ يتناسب مع حجم المصفوفة n. تتكرر الحلقة الخارجية من 1 إلى list.size()‎، ولذلك تُعدّ خطيّةً بالنسبة لحجم القائمة n، بينما تتكرر الحلقة الداخلية من i إلى صفر، لذلك هي أيضًا خطيّة بالنسبة لقيمة n. بناءً على ذلك، يكون عدد مرات تنفيذ الحلقة الداخلية تربيعيًّا. إذا لم تكن متأكّدًا من ذلك، انظر إلى البرهان التالي: في المرة الأولى، قيمة i تساوي 1، وتتكرر الحلقة الداخليّة مرةً واحدةً على الأكثر. في المرة الثانية، قيمة i تساوي 2، وتتكرر الحلقة الداخليّة مرتين على الأكثر. في المرة الأخيرة، قيمة i تساوي n-1، وتتكرر الحلقة الداخليّة عددًا قدره n-1 من المرات على الأكثر. وبالتالي، يكون عدد مرات تنفيذ الحلقة الداخلية هو مجموع المتتالية 1، 2، … حتى n-1، وهو ما يُساوِي n(n-1)/2. لاحِظ أن القيمة الأساسية (ذات الأس الأكبر) بهذا التعبير هي n2‎. يُعدّ الترتيب بالإدراج تربيعيًا في أسوأ حالة، ومع ذلك: إذا كانت العناصر مُرتَّبةً أو شبه مُرتَّبةٍ بالفعل، فإن الترتيب بالإدراج يكون خطّيًّا. بالتحديد، إذا لم يكن كل عنصرٍ أبعدَ من موضعه الصحيح مسافةً أكبرَ من k، فإن الحلقة الداخلية لن تُنفَّذ أكثر من عددٍ قدره k من المرات، ويكون زمن التنفيذ الكلي هو O(kn)‎. نظرًا لأن تنفيذ تلك الخوارزمية بسيط، فإن تكلفته منخفضة، أي على الرغم من أن زمن التنفيذ يساوي a n2‎، إلا أن المعامل a قد يكون صغيرًا. ولذلك، إذا عرفنا أن المصفوفة شبه مُرتَّبة أو إذا لم تكن كبيرةً جدًا، فقد يكون الترتيب بالإدراج خيارًا جيدًا، ولكن بالنسبة للمصفوفات الكبيرة، فهنالك خيارات أفضل بكثير. تمرين 14 تُعدّ خوارزمية الترتيب بالدمج merge sort واحدةً من ضمن مجموعةٍ من الخوارزميات التي يتفوق زمن تنفيذها على الزمن التربيعيّ. ننصحك قبل المتابعة بقراءة مقالة ويكيبيديا عن الترتيب بالدمج merge sort. بعد أن تفهم الفكرة العامة للخوارزمية، يُمكِنك العودة لاختبار فهمك بكتابة تنفيذٍ لها. ستجد ملفات الشيفرة التالية الخاصة بالتمرين في مستودع الكتاب: ListSorter.java ListSorterTest.java نفِّذ الأمر ant build لتصريف ملفات الشيفرة ثم نفِّذ الأمر ant ListSorterTest. سيفشل الاختبار كالعادة لأن ما يزال عليك إكمال بعض الشيفرة. ستجد ضمن الملف ListSorter.java شيفرةً مبدئيّةً للتابعين mergeSortInPlace و mergeSort: public void mergeSortInPlace(List<T> list, Comparator<T> comparator) { List<T> sorted = mergeSortHelper(list, comparator); list.clear(); list.addAll(sorted); } private List<T> mergeSort(List<T> list, Comparator<T> comparator) { // TODO: fill this in! return null; } يقوم التابعان بنفس الشيء، ولكنهما يوفران واجهاتٍ مختلفة. يَستقبِل التابع mergeSort قائمةً ويعيد قائمةً جديدةً تحتوي على نفس العناصر بعد ترتيبها ترتيبًا تصاعديًا. في المقابل، يُعدِّل التابع mergeSortInPlace القائمة ذاتها ولا يعيد أيّة قيمة. عليك إكمال التابع mergeSort. ويمكنك بدلًا من كتابة نسخة تعاودية recursive بالكامل، أن تتبع الطريقة التالية: قسِّم القائمة إلى نصفين. رتِّب النصفين باستخدام التابع Collections.sort أو التابع insertionSort. ادمج النصفين المُرتَّبين إلى قائمة واحدة مُرتَّبة. سيعطيك هذا التمرين الفرصة لتنقيح شيفرة الدمج دون التعامل مع تعقيدات التوابع التعاودية. والآن أضف حالة أساسية base case. إذا كان لديك قائمة تحتوي على عنصر واحد فقط، يُمكِنك أن تعيدها مباشرةً لأنها نوعًا ما مُرتَّبة بالفعل، وإذا كان طول القائمة أقل من قيمة معينة، يُمكِنك أن تُرتِّبها باستخدام التابع Collections.sort أو التابع insertionSort. اختبر الحالة الأساسية قبل إكمال القراءة. أخيرًا، عدِّل الحل واجعله يُنفِّذ استدعاءين تعاوديّين لترتيب نصفي المصفوفة. إذا عدلته بالشكل الصحيح، ينبغي أن ينجح الاختباران testMergeSort و testMergeSortInPlace. تحليل أداء خوارزمية الترتيب بالدمج لكي نصنف زمن تنفيذ خوارزمية الترتيب بالدمج، علينا أن نفكر بمستويات التعاود وبكمية العمل المطلوب في كل مستوى. لنفترض أننا سنبدأ بقائمةٍ تحتوي على عددٍ قدره n من العناصر. وفيما يلي خطوات الخوارزمية: نُنشِئ مصفوفتين وننسخ نصف العناصر إليهما. نُرتِّب النصفين. ندمج النصفين. تنسخ الخطوة الأولى كل عنصر مرةً واحدةً، أي أنها خطّيّة. بالمثل، تنسخ الخطوة الثالثة كل عنصر مرةً واحدةً فقط، أي أنها خطّيّة كذلك. علينا الآن أن نُحدِّد تعقيد الخطوة الثانية. ستساعدنا على ذلك الصورة التالية التي تَعرِض مستويات التعاود. في المستوى الأعلى، سيكون لدينا قائمة واحدة مُكوَّنة من عددٍ قدره n من العناصر. للتبسيط، سنفترض أن n عبارة عن قيمة مرفوعة للأس 2، وبالتالي، سيكون لدينا في المستوى التالي قائمتان تحتويان على عدد n/2 من العناصر. ثمّ في المستوى التالي، سيكون لدينا 4 قوائم تحتوي على عدد قدره n/4 من العناصر، وهكذا حتى نصل إلى عدد n من القوائم تحتوي جميعها على عنصر واحد فقط. لدينا إذًا عدد قدره n من العناصر في كل مستوى. أثناء نزولنا في المستويات، قسّمنا المصفوفات في كل مستوى إلى نصفين، وهو ما يستغرق زمنًا يتناسب مع n في كل مستوى، وأثناء صعودنا للأعلى، علينا أن ندمج عددًا من العناصر مجموعه n وهو ما يستغرق زمنًا خطيًا أيضًا. إذا كان عدد المستويات يساوي h، فإن العمل الإجمالي المطلوب يساوي O(nh)‎، والآن، كم هو عدد المستويات؟ يُمكِننا أن نفكر في ذلك بطريقتين: كم عدد المرات التي سنضطر خلالها لتقسيم n إلى نصفين حتى نصل إلى 1. أو كم عدد المرات التي سنضطرّ خلالها لمضاعفة العدد 1 قبل أن نصل إلى n. يُمكِننا طرح السؤال الثاني بطريقة أخرى: "ما هي قيمة الأس المرفوع للعدد 2 لكي نحصل على n؟". 2h = n بحساب لوغاريتم أساس 2 لكلا الطرفين، نحصل على التالي: h = log2 n أي أن الزمن الكلي يساوي O(n log(n))‎. لاحظ أننا تجاهلنا قيمة أساس اللوغاريتم لأن اختلاف أساس اللوغاريتم يؤثر فقط بعامل ثابت، أي أن جميع اللوغاريتمات لها نفس ترتيب النمو order of growth. يُطلَق أحيانًا على الخوارزميات التي تنتمي إلى O(n log(n))‎ اسم "خطي-لوغاريتمي linearithmic"، ولكن في العادة نقول "n log n". في الواقع، يُعدّ O(n log(n))‎ الحد الأدنى من الناحية النظرية لخوارزميات الترتيب التي تَعتمد على موازنة العناصر مع بعضها البعض. يعني ذلك أنه لا توجد خوارزمية ترتيب بالموازنة ذات ترتيب نموٍّ أفضلَ من n log n. ولكن كما سنرى في القسم التالي، هناك خوارزميات ترتيبٍ لا تعتمد على الموازنة وتستغرق زمنًا خطيًا. خوارزمية الترتيب بالجذر Radix sort أثناء الحملة الرئاسية في الولايات المتحدة الأمريكية لعام 2008، طُلِبَ من المرشح باراك أوباما Barack Obama تحليل أداء خوارزمية impromptu أثناء زيارته لمؤسسة جوجل Google. سأله الرئيس التنفيذي إريك شميدت Eric Schmidt مازحًا عن أكفأ طريقة لترتيب مليون عدد صحيح من نوع 32 بت. بدا أن أوباما كان قد اُخبرَ بذلك قبل اللقاء لأنه أجاب سريعًا "لا أظن أن خوارزمية ترتيب الفقاعات bubble sort ستكون الطريقة الأفضل". يُمكِنك مشاهدة فيديو لقاء أوباما مع إريك شميدت لو أردت. كان أوباما على حق، فخوارزمية ترتيب الفقاعات bubble sort صحيحٌ أنها بسيطة وسهلة الفهم، لكنّها تستغرق زمنًا تربيعيًا، كما أن أداءها ليس جيدًا بالموازنة مع خوارزميات الترتيب التربيعية الأخرى. ربما خوارزمية الترتيب بالجذر radix sort هي الإجابة التي كان ينتظرها شميدت، فهي خوارزمية ترتيب غير مبنيّة على الموازنة، كما أنها تَعمَل بنجاح عندما يكون حجم العناصر مقيّدًا كعدد صحيح من نوع 32 بت أو سلسلة نصية مُكوَّنة من 20 محرفًا. لكي نفهم طريقة عملها، لنتخيل أن لدينا مكدّسًا stack من البطاقات، وكل واحدة منها تحتوي على كلمة مُكوَّنة من ثلاثة أحرف. ها هي الطريقة التي يُمكِن أن نرتب بها تلك البطاقات: مرّ عبر البطاقات وقسمها إلى مجموعات بناءً على الحرف الأول، أي ينبغي أن تكون الكلمات البادئة بالحرف a ضمن مجموعة واحدة، يليها الكلمات التي تبدأ بحرف b، وهكذا. قسِّم كل مجموعة مرة أخرى بناءً على الحرف الثاني، بحيث تصبح الكلمات البادئة بالحرفين aa معًا، يليها الكلمات التي تبدأ بالحرفين ab، وهكذا. لن تكون كل المجموعات مملوءةً بالتأكيد، ولكن لا بأس بذلك. قسِّم كل مجموعة مرة أخرى بحسب الحرف الثالث. والآن، أصبحت كل مجموعة مُكوَّنة من عنصر واحد فقط، كما أصبحت المجموعات مُرتَّبةً ترتيبًا تصاعديًا. تَعرِض الصورة التالية مثالًا عن الكلمات المكوَّنة من ثلاثة أحرف. يَعرِض الصف الأول الكلمات غير المُرتَّبة، بينما يَعرِض الصف الثاني شكل المجموعات بعد اجتيازها للمرة الأولى. تبدأ كلمات كل مجموعة بنفس الحرف. بعد اجتياز الكلمات للمرة الثانية، تبدأ كلمات كل مجموعة بنفس الحرفين الأوليين، وبعد اجتيازها للمرة الثالثة، سيكون هنالك كلمة واحدة فقط في كل مجموعة، وستكون المجموعات مُرتَّبة. أثناء كل اجتياز، نمرّ عبر العناصر ونضيفها إلى المجموعات. يُعدّ كل اجتياز منها خطيًا طالما كانت تلك المجموعات تَسمَح بإضافة العناصر إليها بزمن خطي. تعتمد عدد مرات الاجتياز -التي سنطلق عليها w- على عرض الكلمات، ولكنه لا يعتمد على عدد الكلمات n، وبالتالي، يكون ترتيب النمو O(wn)‎ وهو خطي بالنسبة لقيمة n. تتوفَّر نسخ أخرى من خوارزمية الترتيب بالجذر، ويُمكِن تنفيذ كُلٍّ منها بطرق كثيرة. يُمكِنك قراءة المزيد عن خوارزمية الترتيب بالجذر، كما يُمكِنك أن تحاول كتابة تنفيذ لها. خوارزمية الترتيب بالكومة Heap sort إلى جانب خوارزمية الترتيب بالجذر التي تُطبَّق عندما يكون حجم الأشياء المطلوب ترتيبها مقيّدًا، هنالك خوارزمية مُخصَّصة أخرى هي خوارزمية الترتيب بالكومة المُقيّدة، والتي تُطبَّق عندما نعمل مع بياناتٍ ضخمةٍ جدًا ونحتاج إلى معرفة أكبر 10 أو أكبر عدد k حيث k قيمة أصغر بكثير من n. لنفترض مثلًا أننا نراقب خدمةً عبر الإنترنت تتعامل مع بلايين المعاملات يوميًا، وأننا نريد في نهاية كل يوم معرفة أكبر k من المعاملات (أو أبطأ أو أي معيار آخر). يُمكِننا مثلًا أن نُخزِّن جميع المعاملات، ثم نُرتِّبها في نهاية اليوم، ونختار أول k من المعاملات. سيستغرق ذلك زمنًا يتناسب مع n log n، وسيكون بطيئًا جدًا لأننا من المحتمل ألا نتمكَّن من ملاءمة بلايين المعاملات داخل ذاكرة برنامج واحد، وبالتالي، قد نضطرّ لاستخدام خوارزمية ترتيب بذاكرة خارجية (خارج النواة). يُمكِننا بدلًا من ذلك أن نَستخدِم كومة مُقيدّة heap. إليك ما سنفعله في ما تبقى من هذه المقالة: سنشرح خوارزمية الترتيب بالكومة (غير المقيدة). ستنفذ الخوارزمية. سنشرح خوارزمية الترتيب بالكومة المُقيدة ونُحلِّلها. لكي تفهم ما يعنيه الترتيب بالكومة، عليك أولًا فهم ماهية الكومة. الكومة ببساطة عبارة عن هيكل بياني data structure مشابه لشجرة البحث الثنائية binary search tree. تتلخص الفروق بينهما في النقاط التالية: تتمتع أي عقدة x بشجرة البحث الثنائية بـ"خاصية BST" أي تكون جميع عقد الشجرة الفرعية subtree الموجود على يسار العقدة x أصغر من x كما تكون جميع عقد الشجرة الفرعية الموجودة على يمينها أكبر من x. تتمتع أي عقدة x ضمن الكومة بـ"خاصية الكومة" أي تكون جميع عقد الشجرتين الفرعيتين للعقدة x أكبر من x. تتشابه الكومة مع أشجار البحث الثنائية المتزنة من جهة أنه عندما تضيف العناصر إليها أو تحذفها منها، فإنها قد تقوم ببعض العمل الإضافي لضمان استمرارية اتزان الشجرة، وبالتالي، يُمكِن تنفيذها بكفاءة باستخدام مصفوفة من العناصر. دائمًا ما يكون جذر الكومة هو العنصر الأصغر، وبالتالي، يُمكِننا أن نعثر عليه بزمن ثابت. تستغرق إضافة العناصر وحذفها من الكومة زمنًا يتناسب مع طول الشجرة h، ولأن الكومة دائمًا ما تكون متزنة، فإن h يتناسب مع log n. يُمكِنك قراءة المزيد عن الكومة لو أردت. تُنفِّذ جافا الصنف PriorityQueue باستخدام كومة. يحتوي ذلك الصنف على التوابع المُعرَّفة في الواجهة Queue ومن بينها التابعان offer و poll اللّذان نلخص عملهما فيما يلي: offer: يضيف عنصرًا إلى الرتل queue، ويُحدِّث الكومة بحيث يَضمَن استيفاء "خاصية الكومة" لجميع العقد. لاحِظ أنه يستغرق زمنًا يتناسب مع log n. poll: يَحذِف أصغر عنصر من الرتل من الجذر ويُحدِّث الكومة. يستغرق أيضًا زمنًا يتناسب مع log n. إذا كان لديك كائن من النوع PriorityQueue، تستطيع بسهولة ترتيب تجميعة عناصر طولها n على النحو التالي: أضف جميع عناصر التجميعة إلى كائن الصنف PriorityQueue باستخدام التابع offer. احذف العناصر من الرتل باستخدام التابع poll وأضفها إلى قائمة من النوع List. نظرًا لأن التابع poll يعيد أصغر عنصر مُتبقٍّ في الرتل، فإن العناصر تُضاف إلى القائمة مُرتَّبةً تصاعديًّا. يُطلَق على هذا النوع من الترتيب اسم الترتيب بالكومة. تستغرق إضافة عددٍ قدره n من العناصر إلى رتلٍ زمنًا يتناسب مع n log n، ونفس الأمر ينطبق على حذف عددٍ قدره n من العناصر منه، وبالتالي، تنتمي خوارزمية الترتيب بالكومة إلى المجموعة O(n log(n))‎. ستجد ضمن الملف ListSorter.java تعريفًا مبدئيًا لتابع اسمه heapSort. أكمله ونفِّذ الأمر ant ListSorterTest لكي تتأكّد من أنه يَعمَل بشكل صحيح. الكومة المُقيدّة Bounded heap تَعمَل الكومة المقيدة كأي كومة عادية، ولكنها تكون مقيدة بعدد k من العناصر. إذا كان لديك عدد n من العناصر، يُمكِنك أن تحتفظ فقط بأكبر عدد k من العناصر باتباع التالي: ستكون الكومة فارغة مبدئيًا، وعليك أن تُنفِّذ التالي لكل عنصر x: التفريع الأول: إذا لم تكن الكومة ممتلئةً، أضف x إلى الكومة. التفريع الثاني: إذا كانت الكومة ممتلئةً، وازن قيمة x مع أصغر عنصر في الكومة. إذا كانت قيمة x أصغر، فلا يُمكِن أن تكون ضمن أكبر عدد k من العناصر، ولذلك، يُمكِنك أن تتجاهلها. التفريع الثالث: إذا كانت الكومة ممتلئةً، وكانت قيمة x أكبر من قيمة أصغر عنصر بالكومة، احذف أصغر عنصر، وأضف x مكانه. بوجود أصغر عنصر أعلى الكومة، يُمكِننا الاحتفاظ بأكبر عدد k من العناصر. لنحلل الآن أداء هذه الخوارزمية. إننا ننفِّذ ما يلي لكل عنصر: التفريع الأول: تستغرق إضافة عنصر إلى الكومة زمنًا يتناسب مع O(log k)‎. التفريع الثاني: يستغرق العثور على أصغر عنصر بالكومة زمنًا يتناسب مع O(1)‎. التفريع الثالث: يستغرق حذف أصغر عنصر زمنًا يتناسب مع O(log k)‎، كما أن إضافة x تستغرق نفس مقدار الزمن. في الحالة الأسوأ، تكون العناصر مُرتَّبة تصاعديًا، وبالتالي، نُنفِّذ التفريع الثالث دائمًا، ويكون الزمن الإجمالي لمعالجة عدد n من العناصر هو O(n log K)‎ أي خطّيّ مع n. ستجد في الملف ListSorter.java تعريفًا مبدئيًا لتابع اسمه topK. يَستقبِل هذا التابع قائمةً من النوع List وكائنًا من النوع Comparator وعددًا صحيحًا k، ويعيد أكبر عدد k من عناصر القائمة بترتيب تصاعدي. أكمل متن التابع ثم نفِّذ الأمر ant ListSorterTest لكي تتأكّد من أنه يَعمَل بشكل صحيح. تعقيد المساحة Space complexity تحدثنا حتى الآن عن تحليل زمن التنفيذ فقط، ولكن بالنسبة لكثير من الخوارزميات، ينبغي أن نُولِي للمساحة التي تتطلّبها الخوارزمية بعض الاهتمام. على سبيل المثال، تحتاج خوارزمية الترتيب بالدمج merge sort إلى إنشاء نسخ من البيانات، وقد كانت مساحة الذاكرة الإجمالية التي تطلّبها تنفيذنا لتلك الخوارزمية هو O(n log n)‎. في الواقع، يُمكِننا أن نُخفِّض ذلك إلى O(n)‎ إذا نفذنا نفس الخوارزمية بطريقة أفضل. في المقابل، لا تنسخ خوارزمية الترتيب بالإدراج insertion sort البيانات لأنها تُرتِّب العناصر في أماكنها، وتَستخدِم متغيراتٍ مؤقتةً لموازنة عنصرين في كل مرة، كما تَستخدِم عددًا قليلًا من المتغيرات المحلية local الأخرى، ولكن المساحة التي تتطلبها لا تعتمد على n. تُنشِئ نسختنا من خوارزمية الترتيب بالكومة كائنًا جديدًا من النوع PriorityQueue، وتُخزِّن فيه العناصر، أي أن المساحة المطلوبة تنتمي إلى المجموعة O(n)‎، وإذا سمحنا بترتيب عناصر القائمة في نفس المكان، يُمكِننا أن نُخفِّض المساحة المطلوبة لتنفيذ خوارزمية الترتيب بالكومة إلى المجموعة O(1)‎. من مميزات النسخة التي نفَّذناها من تلك الخوارزمية هي أنها تحتاج فقط إلى مساحة تتناسب مع k (أي عدد العناصر المطلوب الاحتفاظ بها)، وعادةً ما تكون k أصغر بكثير من قيمة n. يميل مطورو البرمجيات إلى التركيز على زمن التشغيل وإهمال حيز الذاكرة المطلوب، وهذا في الحقيقة، مناسب لكثير من التطبيقات، ولكن عند التعامل مع بيانات ضخمة، تكون المساحة المطلوبة بنفس القدر من الأهمية إن لم تكن أهم، كما في الحالات التالية مثلًا: إذا لم تكن مساحة ذاكرة البرنامج ملائمةً للبيانات، عادةً ما يزداد زمن التشغيل إلى حد كبير وقد لا يَعمَل البرنامج من الأساس. إذا اخترت خوارزمية تتطلّب حيزًا أقل من الذاكرة، وتَسمَح بملائمة المعالجة ضمن الذاكرة، فقد يَعمَل البرنامج بسرعةٍ أعلى بكثير. بالإضافة إلى ذلك، تَستغِل البرامج التي تتطلَّب مساحة ذاكرة أقل الذاكرة المؤقتة لوحدة المعالجة المركزية CPU caches بشكل أفضل وتَعمَل بسرعة أكبر. في الخوادم التي تُشغِّل برامج كثيرة في الوقت نفسه، إذا أمكنك تقليل المساحة التي يتطلبها كل برنامج، فقد تتمكّن من تشغيل برامج أكثر على نفس الخادم، مما يُقلل من تكلفة الطاقة والعتاد المطلوبة. كانت هذه بعض الأسباب التي توضح أهمية الاطلاع على متطلبات الخوارزميات المتعلقة بالذاكرة. يمكنك التوسع أكثر في الموضوع بقراءة توثيق خوارزميات الترتيب في توثيق موسوعة حسوب. ترجمة -بتصرّف- للفصل Chapter 17: Sorting من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا المقال السابق: البحث الثنائي Boolean search ودمج نتائج البحث وترتيبها تعقيد الخوارزميات Algorithms Complexity مدخل إلى تحليل الخوارزميات
  10. تضيف الخيوط threads مستوًى جديدًا من التعقيد إلى البرمجة، ولكنها مهمةٌ وستصبح أساسيةً بالمستقبل، ولذلك لا بُدّ أن يَطلِّع كل مبرمجٍ على بعض أنماط التصميم design pattern الأساسية المُستخدَمة مع الخيوط، حيث سنفحص بهذا المقال بعض التقنيات البسيطة وسنبني عليها بالأقسام التالية. الخيوط والمؤقتات ومكتبة جافا إف إكس يُمكِننا استخدام الخيوط لتنفيذ مهمةٍ معينةٍ تنفيذًا دوريًا، وهو ما يُعدّ أمرًا بسيطًا لدرجة وجود أصنافٍ مُتخصِّصة لتنفيذ تلك المهمة، ولقد تعاملنا مع إحداها بالفعل، وهو الصنف AnimationTimer المُعرَّف بحزمة javafx.animation، التي درسناها بالقسم الفرعي الصنف AnimationTimer من المقال تعرف على أهم الأحداث والتعامل معها في مكتبة جافا إف إكس JavaFX حيث يستدعِي ذلك الصنف تابعه handle()‎ دوريًا بمعدل 60 مرةٍ لكل ثانية. في الواقع، كان اِستخدَام الخيوط ضروريًا لتنفيذ العمليات المشابهة قبل أن تتوفَّر المؤقتات. لنفترض أننا نريد فعل شيءٍ مشابه باستخدام خيط، كأن نَستدعِي برنامجًا فرعيًا subroutine على فتراتٍ دورية، مثل 30 مرةٍ لكل ثانية. سيُنفِّذ تابع الخيط run()‎ حلقة تكرار loop، سيتوقَّف خلالها الخيط لمدة "30 ميللي ثانية"، ثم سيستدعِي بعدها البرنامج الفرعي. تُنفِّذ الشيفرة التالية ذلك باستخدِام Thread.sleep()‎ -الذي ناقشناه بقسم العمليات على الخيوط من المقال السابق مقدمة إلى الخيوط Threads في جافا- ضمن صنفٍ متداخل nested: private class Animator extends Thread { public void run() { while (true) { try { Thread.sleep(30); } catch (InterruptedException e) { } callSubroutine(); } } } سنُنشِئ الآن كائنًا ينتمي إلى ذلك الصنف، ونَستدعِي تابعه start()‎، مع الملاحظة بأنه لن يكون هناك أي طريقةٍ لإيقاف الخيط بعد تشغيله؛ وإنما يُمكِننا إيقاف حلقة التكرار عندما تساوي قيمة متغيرٍ متطايرٍ volatile منطقي معين وليكن اسمه هو terminate القيمة true كما ناقشنا بقسم المتغيرات المتطايرة من المقال السابق. إذا أردنا تشغيل التحريكة animation مرةً أخرى بعد إيقافها، فسنضطّر لإنشاء كائنٍ جديدٍ من النوع Thread؛ نظرًا لأنه من الممكن تنفيذ تلك الكائنات مرةً واحدةً فقط. سنناقش بالقسم التالي بعض التقنيات المُستخدَمة للتحكم بالخيوط. تختلف الخيوط عن المؤقتات جزئيًا فيما يتعلّق بالتحريكات؛ حيث لا يفعل الخيط الذي يَستخدِمه الصنف AniamtionTimer والمُعرَّف بمكتبة جافا إف إكس أكثر من مجرد استدعاء البرنامج handle()‎ مرةً بعد أخرى، والذي يُنفَّذ ضمن خيط تطبيق جافا إف إكس المسؤول عن إعادة رسم مكوِّنات الواجهة والإستجابة لما يفعله المُستخدِم. يُعدّ ذلك أمرًا مهمًا لأن مكتبة جافا إف إكس ليست آمنةً خيطيًا thread-safe؛ بمعنى أنها لا تَستخدِم المزامنة synchronization لتجنُّب حالات التسابق race conditions الممكن حدوثها بين الخيوط التي تحاول الوصول إلى كلٍ من مكوِّنات واجهة المُستخدِم الرسومية GUI ومتغيرات الحالة الخاصة بها. لا يُمثِّل ذلك مشكلةً بشرط أن يحدث كل شيء ضمن خيط التطبيق. في المقابل، قد تَنشَأ مشكلةٌ إذا حاول خيطٌ آخر تعديل إحدى مُكوِّنات الواجهة أو إحدى المتغيرات المُستخدَمة بخيط واجهة المُستخدِم الرسومية، وعندها قد يكون اِستخدَام المزامنة حلًا مناسبًا مع أن اِستخدَام الصنف AnimationTimer -إن كان ذلك ممكنًا- عادةً ما يكون الحل الأمثل؛ ولكن يُمكِنك استخدام Platform.runLater()‎، إذا كنت مضطّرًا لاستخدام خيطٍ منفصل. تحتوي حزمة javafx.application على الصنف Platform الذي يتضمَّن التابع الساكن Platform.runLater(r)‎؛ حيث يَستقبِل هذا التابع كائنًا من النوع Runnable، أي نفس الواجهة interface المُستخدَمة لإنشاء الخيوط على أنه معاملٌ بإمكاننا اِستدعائه من أي خيط. تتلّخص مسؤولية ذلك التابع في تسليم r إلى خيط تطبيق جافا إف إكس لتنفيذه، ثم يعود مباشرةً دون أن ينتظر انتهاء تنفيذ r. بعد ذلك، يَستدعِي خيط التطبيق التابع r.run()‎ بعد أجزاءٍ من الثانية أو حتى على الفور إذا لم يَكُن الحاسوب مُنشغِّلًا بتنفيذ شيءٍ آخر. تُنفَّذ الأصناف المُنفِّذة للواجهة Runnable بنفس ترتيب تَسلُمها، ونظرًا لأنها تُنفَّذ داخل خيط التطبيق، يُمكِنها معالجة واجهة المُستخدِم الرسومية معالجةً آمنةً بدون مزامنة. يُمرَّر معامل التابع Platform.runLater()‎ عادةً مثل تعبير لامدا lambda expression من النوع Runnable. سنَستخدِم Platform.runLater()‎ بعدة أمثلة خلال هذا المقال وما يليه. سنفحص الآن المثال التوضيحي RandomArtWithThreads.java الذي يَستخدِم خيطًا للتحكُّم بتحريكةٍ بسيطةٍ جدًا. لا يفعل الخيط بهذا المثال أكثر من مجرد استدعاء التابع redraw()‎ كل ثانيتين، الذي يُعيد رسم محتويات الحاوية canvas؛ واستخدام التابع Platform.runLater()‎ لتنفيذ redraw()‎ ضمن خيط التطبيق. يستطيع المُستخدِم الضغط على زر لبدء التحريكة وإيقافها. يُنشَأ خيطٌ جديدٌ بكل مرة تبدأ خلالها التحريكة، ويُضبَط متغيرٌ منطقيٌ متطايرٌ اسمه running إلى القيمة false عندما يُوقِف المُستخدِم التحريكة إشارةً للخيط بأن عليه أن يتوقف، كما ناقشنا بالمقال المتغيرات المتطايرة من المقال السابق. يُعرِّف الخيط بواسطة الصنف التالي: private class Runner extends Thread { public void run() { while (running) { Platform.runLater( () -> redraw() ); try { Thread.sleep(2000); // انتظر ثانيتين قبل إعادة رسم الشاشة } catch (InterruptedException e) { } } } } التعاود داخل الخيوط إذا كان الخيط يُنفِّذ خوارزميةً تعاوديةً recursive (ناقشنا التعاود في مقال التعاود recursion في جافا)، وكنت تريد إعادة رسم الواجهة عدة مرات أثناء حدوث التعاود؛ فقد تضطّر لاستخدام خيطٍ منفصلٍ للتحكُّم بالتحريكة. من الصعب تقسيم خوارزمية تعاودية إلى سلسلةٍ من استدعاءات التوابع داخل مؤقت، فمن البديهي أكثر استدعاء تابعٍ تعاودي واحدٍ لإجراء التعاود، وهو أمرٌ سهل إنجازه ضمن خيط. سنفحص المثال التوضيحي QuicksortThreadDemo.java الذي يَرسِم تحريكةً تُوضِح طريقة عمل خوارزمية QuickSort التعاودية لترتيب المصفوفات. ستحتوي المصفوفة في هذا المثال على ألوان، وسيكون الهدف هو ترتيبها وفقًا لسلّم الألوان المعروف من الأحمر إلى البنفسجي. يَسمَح البرنامج للمُستخدِم أيضًا بالنقر على زر "Start" لبدء العملية، وعندها تُرتَّب الألوان ترتيبًا عشوائيًا، ثم تُستدعَى خوارزمية QuickSort لترتيبها وتُعرَض العملية بحركة بطيئة. يتبدَّل زر "Start" أثناء عملية الترتيب إلى "Finish" ليَسمَح للمُستخدِم بإيقاف عملية الترتيب قبل انتهائها. في الواقع، من الممتع مشاهدة خرج هذا البرنامج، ولربما يساعدك حتى على فهم طريقة عمل خوارزمية QuickSort على نحوٍ أفضل، لذلك عليك أن تُجرِّب تشغيله. ينبغي أن تتغيّر الصورة المعروضة بالحاوية في هذا البرنامج بكل مرةٍ تُجرِي خلالها الخوارزمية تعديلًا على المصفوفة. لاحِظ أن المصفوفة تتغيّر بخيط التحريكة بينما لا بُدّ من إجراء التغيير المقابل على الحاوية بخيط تطبيق جافا إف إكس باستخدام Platform.runLater()‎ كما ناقشنا بالأعلى. بكل مرة يُستدعى خلالها Platform.runLater()‎، يتوقف الخيط لمدة "100 ميللي ثانية" ليَسمَح لخيط التطبيق بتنفيذ المعامل المُمرَّر من النوع Runnable وليتمكَّن المُستخدِم من مشاهدة التعديلات. هناك أيضًا توقُّفٌ أطول بما يصِل إلى ثانيةٍ كاملة بعد ترتيب عناصر المصفوفة عشوائيًا مباشرةً وقبل بدء عملية الترتيب الفعلي. يُعرِّف الصنف QuicksortThreadDemo التابع delay()‎ الذي يجعل الخيط المُستدِعي له يتوقَّف لفترة معينة، نظرًا لأن الشيفرة تتوقَّف بأكثر من مكان. والآن، كيف نُنفِّذ شيفرة الزر "Finish" المسؤولة عن إيقاف عملية الترتيب وإنهاء الخيط؟ في الواقع، يؤدي النقر على هذا الزر إلى ضبط قيمة المتغير المنطقي المتطاير running إلى القيمة false إشارةً للخيط بأنه عليه الانتهاء. تَكْمُن المشكلة في إمكانية النقر عليه بأي لحظة، حتى لو كان البرنامج منهمكًا بتنفيذ الخوارزمية وبمستوًى منخفضٍ جدًا من التعاود. لا بُدّ أن تعود جميع استدعاءات التوابع التعاودية لنتمكّن من إنهاء الخيط، ويُعد التبليغ عن استثناء exception إحدى أبسط الطرق التي يُمكنِها تحقيق ذلك. يُعرِّف الصنف QuickSortThreadDemo صنف استثناءٍ جديد اسمه ThreadTerminationException لهذا الغرض، ويفحص التابع delay()‎ قيمة المُتغيّر running؛ فإذا كانت مساويةً للقيمة false، سيُبلِّغ عن استثناءٍ تسبَّب بإنهاء الخوارزمية التعاودية، وبالتتابع خيط التحريكة ذاته. ألقِ نظرةً على تعريف التابع delay()‎: private void delay(int millis) { if (! running) throw new ThreadTerminationException(); try { Thread.sleep(millis); } catch (InterruptedException e) { } if (! running) // افحصها مرة أخرى فربما تكون قد تغيرت أثناء توقُّف الخيط throw new ThreadTerminationException(); } يَلتقِط تابع الخيط run()‎ الاستثناء المنتمي للصنف ThreadTerminationException: // 1 private class Runner extends Thread { public void run() { for (int i = 0; i < hue.length; i++) { // املأ المصفوفة باستخدام الفهارس hue[i] = i; } for (int i = hue.length-1; i > 0; i--) { // رتّب المصفوفة عشوائيًا int r = (int)((i+1)*Math.random()); int temp = hue[r]; hue[r] = hue[i]; // 2 setHue(i,temp); } try { delay(1000); // انتظر ثانية قبل بدء عملية الترتيب quickSort(0,hue.length-1); // رتّب المصفوفة بالكامل } catch (ThreadTerminationException e) { // ألغى المُستخدِم عملية الترتيب // 3 Platform.runLater( () -> drawSorted() ); } finally { running = false; // 4 Platform.runLater( () -> startButton.setText("Start") ); } } } حيث أن: [1]: يُعرِّف هذا الصنف خيطًا يُنفِّذ خوارزمية QuickSort التعاودية؛ حيث يبدأ الخيط بخلط عناصر المصفوفة hue عشوائيًا، ثم يَستدعِي التابع quickSort()‎ لترتيبها بالكامل. إذا توقَّف التابع quickSort نتيجة استثناء من النوع ThreadTerminationException -يحدُث إذا نقر المُستخدِم على زر "Finish"-، يُعيد الخيط المصفوفة إلى حالتها المُرتَّبة قبل أن ينتهي؛ وبالتالي سواءٌ ألغى المُستخدِم عملية الترتيب أم لا، تكون المصفوفة مرتبةً بنهاية الخيط. في جميع الحالات، يُضبَط نص الزر إلى "Start" بالنهاية. [2]: التعليمة الأخيرة التي ينبغي إنجازها ضمن الحلقة هي hue = temp. لن تتغير قيمة hue‎ بعد ذلك، ولذلك تُنجز عملية الإسناد باستدعاء التابع setHue(i,temp)‎ الذي سيُبدِّل القيمة الموجودة بالمصفوفة، كما أنه يَستِخدِم Platform.runLater()‎ لتغيير لون الشريط رقم i بالحاوية. [3]: ضع الألوان بصورةٍ مرتبة. يرسم التابع drawSorted()‎ ألوان جميع الشرائط بالترتيب. [4]: تأكَّد من أن running يُساوِي false. يكون ذلك ضروريًا فقط إذا انتهى الخيط طبيعيًا. يَستخدِم البرنامج المتغير runner من النوع Runner لتمثيل الخيط المسؤول عن عملية الترتيب. عندما ينقر المُستخدِم على الزر "Start"، تُنفَّذ الشيفرة التالية لإنشاء الخيط وتشغيله: startButton.setText("Finish"); runner = new Runner(); running = true; // اضبط قيمة الإشارة قبل تشغيل الخيط runner.start(); لا بُدّ من ضبط قيمة متغير الإشارة running إلى القيمة true قبل بدء الخيط؛ لأنه لو كان يحتوي على القيمة false بالفعل عند بدء الخيط، فلربما سيرى الخيط تلك القيمة بمجرد بدءه، ويُفسِّرها على كونها إشارةً للتوقُّف قبل أن يفعل أي شيء. تذكَّر أنه عند استدعاء runner.start()‎، يبدأ الخيط runner بالعمل على التوازي مع الخيط المُستدعِي له. عندما ينقر المُستخدِم على زر "Finish"، تُضبَط قيمة running إلى القيمة false إشارةً للخيط بأن عليه الانتهاء، ولكن ماذا لو كان الخيط نائمًا في تلك اللحظة؟ في تلك الحالة لا بُدّ أن يستيقظ الخيط أولًا حتى يتمكَّن من الإستجابة لتلك الإشارة؛ أما إذا أردنا أن نجعله يستجيب بصورةٍ أسرع، يُمكِننا استدعاء التابع runner.interrupt()‎ لإيقاظ الخيط إذا كان نائمًا. لا يؤثر هذا على البرنامج من الناحية العملية، ولكنه يجعل استجابة البرنامج أسرع على نحوٍ ملحوظ بالأخص إذا نقر المُستخدِم على زر "Finish" بعد النقر على زر "Start" مباشرةً عندما ينام الخيط لمدة ثانيةٍ كاملة. استخدام الخيوط بالعمليات المنفذة بالخلفية إذا أردنا أن تكون استجابة برامج واجهة المُستخدِم الرسومية GUI سريعة، أي تستجيب للأحداث events بمجرد وقوعها تقريبًا، لا بُدّ أن تُنهِي توابع معالجة الأحداث الموجودة بالبرنامج عملها بسرعة. تُخزَّن الأحداث برتل queue أثناء وقوعها، ولا يستطيع الحاسوب الاستجابة لحدثٍ معين قبل أن تُنهِي توابع معالجة الأحداث السابقة له عملها. يَعنِي ذلك أنه ينبغي للأحداث الانتظار أثناء تنفيذ الحاسوب لمُعالِج حدثٍ معين؛ وإذا استغرق مُعالِج حدثٍ معين فترةً طويلة لتنفيذ عمله، ستَجْمُد freeze واجهة المُستخدِم خلال تلك الفترة، وهو ما يُضايق المُستخدِم بالأخص إذا استمر لأكثر من جزءٍ من الثانية. تستطيع الحواسيب العصرية لحسن الحظ إنجاز الكثير من العمليات الضخمة خلال جزءٍ من الثانية. ومع ذلك، هناك بعض العمليات الضخمة للغاية لدرجة لا يُمكِن تنفيذها بمعالجات الأحداث event handlers (أو بتمرير مُنفَّذات للواجهة Runnable إلى Platform.runlater()‎). ويكون من الأفضل في تلك الحالة تنفيذ تلك العمليات بخيطٍ آخر منفصل يَعمَل على التوازي مع خيط معالجة الأحداث، وهذا يَسمَح للحاسوب بالاستجابة إلى الأحداث الأخرى في نفس الوقت الذي يُنفَّذ خلاله تلك العملية، ويُقال أن العملية "تُنفَّذ بالخلفية background". يختلف تطبيق الخيوط في هذا المثال عن المثال السابق؛ فعندما يُستخدَم الخيط للتحكُّم بتحريكة، فإنه فعليًا لا يَفعَل سوى القليل، إذ عليه فقط أن يستيقظ كل عدة ثواني ليُجرِي قليلًا من العمليات المتعلّقة بتحديث متغيرات الحالة state variables لإطار التحريكة التالي ومن ثَمّ رَسْمه. يُوفِّر ذلك وقتًا كافيًا لخيط تطبيق جافا إف إكس، ويَسمَح له بإجراء أي إعادة رسمٍ ضرورية لمُكوِّنات واجهة المُستخدِم الرسومية، وكذلك معالجة أي أحداثٍ اخرى. عندما نُنفِّذ عمليةٌ معينة بالخلفية ضمن خيط، فإننا نريد إبقاء الحاسوب مُنشغِلًا بتنفيذ تلك العملية بأقصى ما يمكن، ولكن قد يتسابق هذا الخيط مع خيط التطبيق على زمن المعالجة، وعندها يبقى تعطُّل معالجة الأحداث بالأخص إعادة الرسم ممكنًا إذا لم ننتبه كفاية. يُمكِننا لحسن الحظ استخدام أولويات priorities للخيوط لنتجنَّب تلك المشكلة، حيث يُمكِننا ضبط الخيط المسؤول عن تنفيذ العملية بالخلفية بحيث يَعمَل بأولويةٍ أقل من أولوية خيط معالجة الأحداث، وسنضمَن بذلك معالجة الأحداث بأسرع ما يمكن، وسيَحظَى بنفس الوقت الخيط الآخر بأي زمن معالجةٍ إضافي. تستغرق معالجة الأحداث وقتًا قصيرًا للغاية عمومًا، وبالتالي سيُستغَل غالبية زمن المعالجة بتنفيذ العملية المُشغَّلة بالخلفية بنفس الوقت الذي ستستجيب فيه الواجهة بسرعة. ناقشنا أولوية الخيوط في قسم العمليات على الخيوط من المقال السابق. يُعدّ البرنامج BackgroundComputationDemo.java مثالًا توضيحيًا على معالجة العمليات بالخلفية. يُنشِئ هذا البرنامج صورةً يستغرِق تحديد ألوان بكسلاتها وقتًا طويلًا نوعًا ما. تمثّل تلك الصورة قطعةً من شكلٍ هندسيٍ معروف باسم "مجموعة ماندلبرو Mandelbrot set"، وسنستخدِم تلك الصورة بعدة أمثلة خلال هذا المقال. يتشابه البرنامج "BackgroundComputationDemo" مع برنامج "QuicksortThreadDemo" الذي ناقشناه بالأعلى، حيث تُجرَى العمليات ضمن خيطٍ مُعرَّفٍ بواسطة صنفٍ مُتداخِل اسمه Runner، وسنَستخدِم متغيرًا متاطيرًا اسمه running للتحكُّم بالخيط؛ فإذا كان المُتغيّر يساوي false، ينبغي أن ينتهي الخيط. يُوفِّر البرنامج زرًا لبدء العملية وإنهائها. بخلاف البرنامج السابق، سيَعمَل الخيط باستمرار دون أن ينام. بعد انتهاء الخيط من حساب كل صف من البسكلات، سيَستدعِي التابع Platform.runLater()‎ لينسخ تلك البكسلات إلى الصورة المعروضة على الشاشة، وسيتمكَّن بذلك المُستخدِم من مشاهدة التحديثات الناتجة عن العمليات المُجرَاة، وسيشاهد الصورة أثناء تكوُّنها صفًا بعد آخر. عندما ينقر المُستخدِم على زر "Start"، يُنشَأ الخيط المسؤول عن عملية المعالجة، والذي لا بُدّ من ضبطه ليَعمَل بأولويةٍ أقل من أولوية خيط تطبيق جافا إف إكس. نظرًا لوقوع الشيفرة المسؤولة عن إنشاء ذلك الخيط ضمن خيط التطبيق، يُمكِننا ضبط أولوية الخيط المُنشَا بحيث تكون أقل بمقدار الواحد من أولوية الخيط المُشغَّل. تذكَّر أنه من الضروري ضبط تلك الأولوية داخل تعليمة try..catch؛ فإذا حدث خطأ أثناء ذلك، نَضمَن استمرار البرنامج، وإن لم يَكُن بنفس السلاسة التي كان سيَعمَل بها لو كانت الأولوية قد ضُبطَت صحيحًا. تُنشِئ الشيفرة التالية الخيط، وتُشغِّله: runner = new Runner(); try { runner.setPriority( Thread.currentThread().getPriority() - 1 ); } catch (Exception e) { System.out.println("Error: Can't set thread priority: " + e); } running = true; // اضبط قيمة الإشارة قبل تشغيل الخيط runner.start(); على الرغم من عمل البرنامج BackgroundComputationDemo جيدًا، إلا أن هناك مشكلةً واحدةً وهي أن هدفنا هو إتمام العملية بأسرع وقتٍ ممكن من خلال استغلال ما هو مُتوفّرٌ من زمن المعالجة. سيتمكَّن البرنامج من إنجاز ذلك الهدف إذا كان مُشغَّلًا على حاسوبٍ بمعالج واحد؛ ولكن إذا كان الحاسوب يحتوي على عدة معالجات، فإننا في الواقع نَستخدِم معالجًا واحدًا فقط لإنجاز العملية، وفي تلك الحالة، لن يكون لأولوية الخيط أي أهمية؛ فمن الممكن تشغيل كُلٍ من خيط التطبيق وخيط التحريكة على التوازي باستخدام معالجين مختلفين. سيكون من الأفضل لو تمكَّنا من استخدام جميع تلك المعالجات لإتمام العملية، وهو ما يتطلَّب معالجةً على التوازي parallel processing من خلال عدة خيوط. سننتقل إلى تلك المشكلة فيما يلي. استخدام الخيوط في المعالجة المتعددة سنفحص الآن البرنامج MultiprocessingDemo1.java، الذي يختلف قليلًا عن البرنامج "BackgroundComputationDemo"؛ فبدلًا من إجراء المعالجة بخيطٍ واحد فقط، سيُقسِّم البرنامج "MultiprocessingDemo1" المعالجة على عدة خيوط. وسيَسمَح البرنامج للمُستخدِم بتخصيص عدد الخيوط المطلوب تشغيلها، وسيتولى كل خيطٍ منها المعالجة المطلوبة لجزءٍ معين من الصورة. ينبغي أن تنجز الخيوط عملها على التوازي؛ فإذا استخدمنا خيطين على سبيل المثال، فسيَحسِب الأول النصف الأعلى من الصورة؛ بينما سيَحسِب الثاني النصف السفلي. تعرض الصورة التوضيحية التالية شاشة البرنامج مع اقتراب نهاية المعالجة عند استخدام ثلاثة خيوط، حيث تُشير المساحات الرمادية إلى أجزاء الصورة غير المُعالجة بعد. عليك أن تُجرِّب البرنامج؛ فعند استخدام عدة خيوطٍ بحاسوبٍ متعدّد المعالجات، ستكتمل المعالجة على نحوٍ أسرع بالموازنة مع استخدام خيطٍ واحد. لا تُعدّ الطريقة المُستخدَمة لتقسيم المشكلة على مجموعة الخيوط بهذا المثال الطريقة الأمثل، وسنتناول بالقسم التالي إمكانية تحسين تلك الطريقة، ومع ذلك ما يزال البرنامج "MultiprocessingDemo1" مثالًا جيدًا على المعالجة المُتعدّدة. عندما ينقر المُستخدِم على زر "Start"، سيُنشِئ البرنامج عدد الخيوط المُخصَّصة ويُشغِّلها، كما سيُسنِد لكُلٍ منها جزءًا من الصورة. ألقِ نظرةً على الشيفرة التالية: workers = new Runner[threadCount]; // يحمل خيوط المعالجة int rowsPerThread; // عدد الخيوط التي ينبغي أن يحسبها كل خيط rowsPerThread = height / threadCount; // (height = vertical size of image) running = true; // اضبط قيمة الإشارة قبل تشغيل الخيوط threadsCompleted = 0; // Records how many of the threads have terminated. for (int i = 0; i < threadCount; i++) { int startRow; // ‫الصف الأول الذي يحسبه الخيط رقم i‫ int endRow; // 1 startRow = rowsPerThread*i; if (i == threadCount-1) endRow = height-1; else endRow = rowsPerThread*(i+1) - 1; workers[i] = new Runner(startRow, endRow); try { workers[i].setPriority( Thread.currentThread().getPriority() - 1 ); } catch (Exception e) { } workers[i].start(); } [1] الصف الأخير الذي يحسبه الخيط رقم i. انشِئ خيطًا وشغَِله لحساب صفوف الصورة من startRow إلى endRow. لا بُدّ أن تكون قيمة endRow للخيط الأخير مساويةً لرقم الصف الأخير بالصورة. أجرينا عددًا قليلًا من التعديلات لتفعيل المعالجة المتعددة إلى جانب إنشاء عدة خيوط بدلًا من خيط واحد. كما هو الحال في المثال السابق: عندما ينتهي أي خيطٍ من حساب ألوان صفٍ من البسكلات، فإنه يَستدعِي التابع Platform.runLater()‎ ليَنسَخ الصف إلى الصورة. هناك شيءٌ واحدٌ جديد، وهو: عندما تنتهي جميع الخيوط من عملها، سيتبدّل اسم الزر من "Abort" إلى "Start Again"، وسيُعاد تفعيل القائمة التي كانت قد عُطّلَت بينما الخيوط مُشغَّلة. والآن، كيف سنعرف أن جميع الخيوط قد انتهت؟ ربما تتساءل لما لا نَستخدِم join()‎ لننتظر انتهاء الخيوط كما فعلنا بمثالٍ سابق في قسم العمليات على الخيوط من المقال السابق؟ حيث لا يُمكِننا بالتأكيد فعل ذلك بخيط تطبيق جافا إف إكس على الأقل. سنَستخدِم في هذا المثال متغير نسخة instance variable، اسمه threadsRunning لتمثيل عدد خيوط المعالجة قيد التنفيذ؛ وعندما ينتهي كل خيطٍ من عمله، عليه استدعاء تابعٍ لإنقاص قيمة ذلك المتغير بمقدار الواحد، حيث سيُستدعَى ذلك التابع ضمن عبارة finally بتعليمة try لنتأكَّد تمامًا من تنفيذها عند انتهاء الخيط. عندما يُصبِح عدد الخيوط المُشغَّلة صفرًا، سيُحدِّث التابع حالة البرنامج على النحو المطلوب. تعرض الشيفرة التالية التابع الذي ستستدعيه الخيوط قبل انتهائها: synchronized private void threadFinished() { threadsRunning--; if (threadsRunning == 0) { // انتهت جميع الخيوط Platform.runLater( () -> { // تأكَّد من صحة حالة واجهة المُستخدِم الرسومية عندما تنتهي الخيوط startButton.setText("Start Again"); startButton.setDisable(false); threadCountSelect.setDisable(false); }); running = false; workers = null; } } لاحِظ أن التابع المُعرَّف بالأعلى متزامن synchronized؛ لضمان تجنُّب حالة التسابق race condition التي يُمكِنها أن تقع عند إنقاص قيمة المُتغيّر threadsRunning. قد يَستدعِي خيطان ذلك التابع بنفس اللحظة إذا لم نَستخدِم المزامنة، وإذا كان التوقيت دقيقًا، فربما يقرأ كلاهما نفس قيمة المتغير threadsRunning، ويَحسِبا نفس الإجابة بعد إنقاصه. ستقل في تلك الحالة قيمة المتغير threadsRunning بمقدار واحدٍ فقط وليس اثنين؛ أي أننا لم نَعُدّ خيطًا على النحو الصحيح، وعليه لن يَصِل المتغير threadsRunning إلى الصفر نهائيًا، وسيَعمَل البرنامج باستمرار بطريقةٍ مشابهة للقفل الميت deadlock. في الواقع، نادرًا ما تَحدث تلك المشكلة لأنها تعتمد على توقيتٍ بعينه، ولكن بالبرامج الأكبر حجمًا، تصبح تلك المشاكل خطيرةً للغاية كما أن تنقيحها debug ليس سهلًا. في المقابل، تمنع المزامنة حدوث ذلك الخطأ تمامًا. ترجمة -بتصرّف- للقسم Section 2: Programming with Threads من فصل Chapter 12: Threads and Multiprocessing من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: مقدمة إلى الخيوط Threads في جافا كيفية إنشاء عدة خيوط وفهم التزامن في جافا كتابة أصناف وتوابع معممة في جافا
  11. سنشرح في هذه المقالة حل التمرين التالي من المقالة السابقة فهرسة الصفحات وتحليل زمن تشغيلها، ثم ننفّذ شيفرة تدمج مجموعةً من نتائج البحث وترتِّبها بحسب مدى ارتباطها بكلمات البحث. الزاحف crawler لنمرّ أولًا على حل تمرين المقالة المشار إليها. كنا قد وفَّرنا الشيفرة المبدئية للصنف WikiCrawler وكان المطلوب منك هو إكمال التابع crawl. انظر الحقول المُعرَّفة في ذلك الصنف: public class WikiCrawler { // يشير إلى المكان الذي بدأنا منه private final String source; // المفهرِس الذي سنخزن فيه النتائج private JedisIndex index; // رتل محددات الموارد الموحدة المطلوب فهرستها private Queue<String> queue = new LinkedList<String>(); // يُستخدَم لقراءة الصفحات من موقع ويكيبيديا final static WikiFetcher wf = new WikiFetcher(); } عندما نُنشِئ كائنًا من النوع WikiCrawler، علينا أن نُمرِّر قيمتي source و index. يحتوي المتغير queue مبدئيًا على عنصر واحد فقط هو source. لاحِظ أن الرتل queue مُنفَّذ باستخدام قائمةٍ من النوع LinkedList، وبالتالي، تستغرق عملية إضافة العناصر إلى نهايته -وحذفها من بدايته- زمنًا ثابتًا، ولأننا أسندنا قائمةً من النوع LinkedList إلى متغير من النوع Queue، أصبح استخدامنا له مقتصرًا على التوابع المُعرَّفة بالواجهة Queue، أي سنَستخدِم التابع offer لإضافة العناصر و poll لحذفها منه. انظر تنفيذنا للتابع WikiCrawler.crawl: public String crawl(boolean testing) throws IOException { if (queue.isEmpty()) { return null; } String url = queue.poll(); System.out.println("Crawling " + url); if (testing==false && index.isIndexed(url)) { System.out.println("Already indexed."); return null; } Elements paragraphs; if (testing) { paragraphs = wf.readWikipedia(url); } else { paragraphs = wf.fetchWikipedia(url); } index.indexPage(url, paragraphs); queueInternalLinks(paragraphs); return url; } السبب وراء التعقيد الموجود في التابع السابق هو تسهيل عملية اختباره. تُوضِّح النقاط التالية المنطق المبني عليه التابع: إذا كان الرتل فارغًا، يعيد التابع القيمة الفارغة null لكي يشير إلى أنه لم يُفهرِس أي صفحة. إذا لم يكن فارغًا، فإنه يقرأ محدد الموارد الموحد URL التالي ويَحذِفه من الرتل. إذا كان محدد الموارد قيد الاختيار مُفهرَسًا بالفعل، لا يُفهرِّسه التابع مرة أخرى إلا إذا كان في وضع الاختبار. يقرأ التابع بعد ذلك محتويات الصفحة: إذا كان التابع في وضع الاختبار، فإنه يقرؤها من ملف، وإن لم يكن كذلك، فإنه يقرؤها من شبكة الإنترنت. يُفهرِس الصفحة. يُحلِّل الصفحة ويضيف الروابط الداخلية الموجودة فيها إلى الرتل. يعيد في النهاية مُحدّد موارد الصفحة التي فهرَسها للتو. كنا قد عرضنا تنفيذًا للتابع Index.indexPage في نفس المقالة المشار إليها في الأعلى، أي أن التابع الوحيد الجديد هو WikiCrawler.queueInternalLinks. كتبنا نسختين من ذلك التابع بمعاملات parameters مختلفة: تَستقبِل الأولى كائنًا من النوع Elements يتضمَّن شجرة DOM واحدةً لكل فقرة، بينما تَستقبِل الثانية كائنًا من النوع Element يُمثِل فقرة واحدة. تمرّ النسخة الأولى عبر الفقرات، في حين تُنفِّذ النسخة الثانية العمل الفعلي. void queueInternalLinks(Elements paragraphs) { for (Element paragraph: paragraphs) { queueInternalLinks(paragraph); } } private void queueInternalLinks(Element paragraph) { Elements elts = paragraph.select("a[href]"); for (Element elt: elts) { String relURL = elt.attr("href"); if (relURL.startsWith("/wiki/")) { String absURL = elt.attr("abs:href"); queue.offer(absURL); } } } لكي نُحدِّد ما إذا كان مُحدّد موارد موحد معين هو مُحدّد داخلي، فإننا نفحص ما إذا كان يبدأ بكلمة "/wiki/". قد يتضمَّن ذلك بعض الصفحات التي لا نرغب في فهرستها مثل بعض الصفحات الوصفية لموقع ويكيبيديا، كما قد يستثني ذلك بعض الصفحات التي نريدها مثل روابط الصفحات المكتوبة بلغات أخرى غير الإنجليزية، ومع ذلك، تُعدّ تلك الطريقة جيدة بالقدر الكافي كبداية. لا يتضمَّن هذا التمرين الكثير، فهو فرصة فقط لتجميع الأجزاء الصغيرة معًا. استرجاع البيانات سننتقل الآن إلى المرحلة التالية من المشروع، وهي تنفيذ أداة بحث تتكوّن مما يلي: واجهة تُمكِّن المُستخدمين من إدخال كلمات البحث ومشاهدة النتائج. طريقة لاستقبال كلمات البحث وإعادة الصفحات التي تتضمَّنها. طريقة لدمج نتائج البحث العائدة من عدة كلمات بحث. خوارزمية تُصنِّف نتائج البحث وتُرتِّبها. يُطلَق على تلك العمليات وما يشابهها اسم استرجاع المعلومات Information retrieval. أنشأنا بالفعل نسخةً بسيطةً من الخطوة رقم 2، وسنُركِّز في هذا التمرين على الخطوتين 3 و 4. قد ترغب بالعمل أيضًا على الخطوة رقم 1 إذا كنت مهتمًا ببناء تطبيقات الويب. البحث المنطقي/الثنائي Boolean search تستطيع معظم محركات البحث أن تُنفِّذ بحثًا منطقيًا، بمعنى أن بإمكانها دمج نتائج البحث الخاصة بعدة كلمات باستخدام المنطق الثنائي، ولنأخذ أمثلةً على ذلك: تعيد عملية البحث عن "java AND programming" الصفحات التي تحتوي على الكلمتين "java" و "programming" فقط. تعيد عملية البحث عن "java OR programming" الصفحات التي تحتوي على إحدى الكلمتين وليس بالضرورة كلتيهما. تعيد عملية البحث عن "java -indonesia" الصفحات التي تحتوي على كلمة "java" ولا تحتوي على كلمة "indonesia". يُطلَق على تلك التعبيرات، أي تلك التي تحتوي على كلمات بحث وعمليات، اسم "استعلامات queries". عندما تُطبَّق تلك العمليات على نتائج البحث، فإن الكلمات "AND" و "OR" و "-" تقابل في الرياضياتِ عمليات "التقاطع" و "الاتحاد" و "الفرق" على الترتيب. لنفترض مثلًا أن: s1 يمثل مجموعة الصفحات التي تحتوي على كلمة "java"، s2 يمثل مجموعة الصفحات التي تحتوي على كلمة "programming"، s3 يمثل مجموعة الصفحات التي تحتوي على كلمة "indonesia"، في تلك الحالة: يُمثِل التقاطع بين s1 و s2 مجموعة الصفحات التي تحتوي على الكلمتين "java" و "programming" معًا. يُمثِل الاتحاد بين s1 و s2 مجموعة الصفحات التي تحتوي على كلمة "java" أو كلمة "programming". يُمثِل الفرق بين s1 و s3 مجموعة الصفحات التي تحتوي على كلمة "java" ولا تحتوي على كلمة "indonesia". ستكتب في القسم التالي تابعًا يُنفِّذ تلك العمليات. تمرين 13 ستجد ملفات شيفرة هذا التمرين في مستودع الكتاب: WikiSearch.java: يُعرِّف كائنًا يحتوي على نتائج البحث ويُطبِّق العمليات عليها. WikiSearchTest.java: يحتوي على شيفرة اختبار للصنفWikiSearch. Card.java: يُوضِّح طريقة استخدام التابع sort المُعرَّف بالنوع java.util.Collections. ستجد أيضًا بعض الأصناف المساعدة التي استخدَمناها من قبل في هذه السلسلة. انظر بداية تعريف الصنف WikiSearch: public class WikiSearch { // يربط مُحدّدات الموارد التي تحتوي على الكلمة بدرجة الارتباط private Map<String, Integer> map; public WikiSearch(Map<String, Integer> map) { this.map = map; } public Integer getRelevance(String url) { Integer relevance = map.get(url); return relevance==null ? 0: relevance; } } يحتوي كائن النوع WikiSearch على خريطة map تربط مُحدّدات الموارد الموحدة URLs بدرجة الارتباط relevance score، والتي تُمثِل -ضمن سياق استرجاع البيانات- عددًا يشير إلى المدى الذي يستوفي به مُحدِّد الموارد الاستعلام الذي يدخله المُستخدِم. تتوفّر الكثير من الطرائق لحساب درجة الارتباط، ولكنها تعتمد في الغالب على "تردد الكلمة" أي عدد مرات ظهورها في الصفحة. يُعدّ TF-IDF واحدًا من أكثر درجات الارتباط شيوعًا، وتُمثِل الأحرف اختصارًا لعبارة تردد المصطلح term frequency - معكوس تردد المستند inverse document frequency. إذا احتوى الاستعلام على كلمة بحث واحدة، فإن درجة الارتباط لصفحة معينة تساوي تردّد الكلمة، أي عدد مرات ظهورها في تلك الصفحة. بالنسبة للاستعلامات التي تحتوي على عدة كلمات، تكون درجة الارتباط لصفحة معينة هي حاصل مجموع تردد الكلمات، أي عدد مرات ظهور أي كلمة منها. والآن وقد أصبحت مستعدًا لبدء التمرين، نفِّذ الأمر ant build لتصريف ملفات الشيفرة، ثم نفِّذ الأمر ant WikiSearchTest. ستفشل الاختبارات كالعادة لأن ما يزال عليك إكمال بعض العمل. أكمل متن كلٍّ من التوابع and و or و minus في الملف WikiSearch.java لكي تتمكّن من اجتياز الاختبارات المرتبطة بتلك التوابع. لا تقلق بشأن التابع testSort، فسنعود إليه لاحقًا. يُمكِنك أن تُنفِّذ WikiSearchTest بدون اِستخدَام Jedis لأنه لا يعتمد على فهرس قاعدة بيانات Redis الخاصة بك، ولكن، إذا أردت أن تَستخِدم الفهرس للاستعلام query، فلا بُدّ أن توفِّر بيانات الخادم في ملف، كما أوضحنا في مقالة "استخدام قاعدة بيانات Redis لتحقيق استمرارية البيانات". نفِّذ الأمر ant JedisMaker لكي تتأكّد من قدرته على الاتصال بخادم Redis، ثم نفِّذ WikiSearch الذي يَطبَع نتائج الاستعلامات الثلاثة التالية: "java" "programming" "java AND programming" لن تكون النتائج مُرتَّبة في البداية لأن التابع WikiSearch.sort ما يزال غير مكتمل. أكمل متن التابع sort لكي تُصبِح النتائج مُرتَّبة تصاعديًا بحسب درجة الارتباط. يُمكِنك الاستعانة بالتابع sort المُعرَّف بالنوع java.util.Collections حيث يُمكِنه ترتيب أي نوع قائمة List. يُمِكنك الاطلاع على توثيق النوع List. تتوفَّر نسختان من التابع sort: نسخة أحادية المعامل تَستقبِل قائمةً وتُرتِّب عناصرها باستخدام التابع compareTo، ولذلك ينبغي أن تكون العناصر من النوع Comparable. نسخة ثنائية المعامل تَستقبِل قائمةً من أي نوع وكائنًا من النوع Comparator، ويُستخدَم التابع compare المُعرَّف ضمن الكائن لموازنة العناصر. سنتحدث عن الواجهتين Comparable و Comparator في القسم التالي إن لم تكن على معرفة بهما. الواجهتان Comparable و Comparator يتضمَّن مستودع الكتاب الصنف Card الذي يحتوي على طريقتين لترتيب قائمة كائنات من النوع Card. انظر إلى بداية تعريف الصنف: public class Card implements Comparable<Card> { private final int rank; private final int suit; public Card(int rank, int suit) { this.rank = rank; this.suit = suit; } تحتوي كائنات الصنف Card على الحقلين rank و suit من النوع العددي الصحيح. يُنفِّذ الصنف Card الواجهة Comparable<Card>‎ مما يَعنِي أنه بالضرورة يُوفِّر تنفيذًا للتابع compareTo: public int compareTo(Card that) { if (this.suit < that.suit) { return -1; } if (this.suit > that.suit) { return 1; } if (this.rank < that.rank) { return -1; } if (this.rank > that.rank) { return 1; } return 0; } تشير بصمة التابع compareTo إلى أن عليه أن يعيد عددًا سالبًا إذا كان this أقل من that، وعددًا موجبًا إذا كان أكبر منه، وصفرًا إذا كانا متساويين. إذا استخدمت نسخة التابع Collections.sort أحادية المعامل، فإنها بدورها تَستدعِي التابع compareTo المُعرَّف ضمن العناصر لكي تتمكّن من ترتيبها. على سبيل المثال، تُنشِئ الشيفرة التالية قائمة تحتوي على 52 بطاقة: public static List<Card> makeDeck() { List<Card> cards = new ArrayList<Card>(); for (int suit = 0; suit <= 3; suit++) { for (int rank = 1; rank <= 13; rank++) { Card card = new Card(rank, suit); cards.add(card); } } return cards; } ثم تُرتِّبها كالتالي: Collections.sort(cards); تُرتِّب تلك النسخة من التابع sort العناصر وفقًا لما يُطلَق عليه "الترتيب الطبيعي" لأن الترتيب مُحدّد بواسطة العناصر نفسها. في المقابل، يُمكِننا أيضًا أن نستعين بكائن من النوع Comparator لكي نَفرِض نوعًا مختلفًا من الترتيب. على سبيل المثال، تحتل بطاقات الأص المَرتَبَة الأقلَّ بحسب الترتيب الطبيعي للصنف Card، ومع ذلك، فإنها أحيانًا تحتل المرتبة الأكبر في بعض ألعاب البطاقات، ولذلك، سنُعرِّف كائنًا من النوع Comparator يُعامِل الأصّ على أنّها البطاقة الأكبر ضمن مجموعة بطاقات اللعب. انظر إلى شيفرة ذلك النوع: Comparator<Card> comparator = new Comparator<Card>() { @Override public int compare(Card card1, Card card2) { if (card1.getSuit() < card2.getSuit()) { return -1; } if (card1.getSuit() > card2.getSuit()) { return 1; } int rank1 = getRankAceHigh(card1); int rank2 = getRankAceHigh(card2); if (rank1 < rank2) { return -1; } if (rank1 > rank2) { return 1; } return 0; } private int getRankAceHigh(Card card) { int rank = card.getRank(); if (rank == 1) { return 14; } else { return rank; } } }; تُعرِّف تلك الشيفرة صنفًا مجهول الاسم anonymous يُنفِّذ التابع compare على النحو المطلوب، ثم تُنشِئ نسخةً منه. يُمكِنك القراءة عن الأصناف مجهولة الاسم Anonymous Classes في لغة جافا إذا لم تكن على معرفة بها. يُمكِننا الآن أن نُمرِّر ذلك الكائن المنتمي للنوع Comparator إلى التابع sort، كما هو مبين في الشيفرة التالية: Collections.sort(cards, comparator); يُعد الأص البستوني وفقًا لهذا الترتيب البطاقة الأكبر ضمن مجموعة بطاقات اللعب، بينما يُعدّ الثنائي السباتي البطاقة الأصغر. ستجد شيفرة هذا القسم في الملف Card.java إن كنت تريد تجريبه. قد ترغب أيضًا في كتابة كائن آخر من النوع Comparator يُرتِّب العناصر بناءً على قيمة rank أولًا ثم قيمة suit، وبالتالي، تصبح جميع بطاقات الأصّ معًا وجميع البطاقات الثنائية معًا، وهكذا. ملحقات إذا تمكَّنت من كتابة الكائن الذي أشرنا إليه في الأعلى، هاك بعض الأفكار الأخرى التي يُمكِنك أن تحاول القيام بها: اقرأ عن درجة الارتباط TF-IDF ونفِّذها. قد تحتاج إلى تعديل الصنف JavaIndex لكي تجعله يَحسِب قيمة ترددات المستند أي عدد مرات ظهور كل كلمة في جميع الصفحات الموجودة بالفهرس. بالنسبة للاستعلامات المكوَّنة من أكثر من كلمةٍ واحدة، يُمكِنك أن تَحسِب درجة الارتباط الإجمالية لكل صفحة بحساب مجموع درجة الارتباط لجميع الكلمات. فكر متى يُمكِن لهذه النسخة المبسطة أن تَفشَل وجرِّب بدائل أخرى. أنشِئ واجهة مُستخدِم تَسمَح للمُستخدِمين بإدخال استعلامات تحتوي على عوامل operators منطقية. حلِّل الاستعلامات المُدْخَلة، وولِّد النتائج، ثم رتِّبها بحسب درجة الارتباط، واعرض مُحدِّدات الموارد التي أحرزت أعلى درجات. حاول أيضًا أن تُولِّد مقطع شيفرة يَعرِض مكان ظهور كلمات البحث في الصفحة. إذا أردت أن تُنشِئ تطبيق ويب لواجهة المُستخدِم التي أنشأتها، فإن منصة Heroku تُعدّ خيارًا بسيطًا لتطوير تطبيقات الويب ونشرها باستخدام جافا. ترجمة -بتصرّف- للفصل Chapter 16: Boolean search من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا المقال التالي: نظرة سريعة على بعض خوارزميات الترتيب المقال السابق: فهرسة الصفحات وتحليل زمن تشغيلها باستخدام قاعدة بيانات Redis استخدام أشجار البحث الثنائية والأشجار المتزنة balanced trees لتنفيذ الخرائط تحليل زمن تشغيل الخرائط المنفذة باستخدام شجرة بحث ثنائية TreeMap في جافا
  12. يمكن للحواسيب إنجاز عدة مهامٍ مختلفة بنفس الوقت؛ فإذا كان الحاسوب مُكوَّنًا من وحدة معالجةٍ واحدة، فإنه لا يستطيع حرفيًا إنجاز شيئين بنفس الوقت؛ ولكن ما يزال بإمكانه تحويل انتباهه بين عدة مهامٍ باستمرار. تتكوَّن معظم الحواسيب العصرية من أكثر من مجرد وحدة معالجةٍ واحدة، وبإمكانها تنفيذ عدة مهامٍ بنفس الوقت حرفيًا، وغالبًا ما تكون زيادة قدرة المعالجة للحواسيب ناتجةً عن إضافة عددٍ أكبر من المعالجات إليها بدلًا من زيادة سرعة كل معالجٍ على حدى. حتى تتمكّن البرامج من تحقيق الاستفادة القصوى من الحواسيب متعددة المعالجات، لا بُدّ أن تكون مُبرمَجَةً على التوازي parallel programming؛ بما يعني كتابة البرنامج بهيئة مجموعةٍ من المهام المُمكِن تنفيذها بنفس الوقت. ما تزال تقنيات البرمجة على التوازي مفيدة حتى بالحواسيب أحادية المعالج، حيث يُساعد تقسيم المشكلات إلى مجموعة من المهام على معالجة المشكلة بصورةٍ مُبسّطة. يُطلَق اسم خيط thread بلغة جافا على كل مهمة، ويشير ذلك الاسم إلى "خيط التحكُّم" أو "خيط التنفيذ" الذي يعني متتالية التعليمات المُنفَّذة واحدةً تلو الأخرى؛ حيث يمتد الخيط عبر الزمن ويربط كل تعليمةٍ بما يليها من تعليمات. توجد خيوط تحكمٍ كثيرة بالبرامج مُتعدّدة الخيوط، وتَعمَل جميعًا على التوازي، لتُشكِّل "نسيج" البرنامج. يتكوَّن كل برنامج عمومًا من خيطٍ واحدٍ على الأقل؛ فعندما نُشغِّل برنامجًا معينًا بآلة جافا الافتراضية Java virtual machine، فإنها تُنشِئ خيطًا مسؤولًا عن تنفيذ البرنامج الرئيسي الذي يستطيع أن يُنشِئ بدوره خيوطًا أخرى قد تستمر حتى بعد انتهاء الخيط الرئيسي. بالنسبة لبرامج واجهة المُستخدِم الرسومية GUI، يكون هنالك خيطٌ إضافي مسؤولٌ عن معالجة الأحداث events ورسم مُكوِّنات الواجهة على الشاشة؛ وفي حالة اِستخدَام مكتبة جافا إف إكس JavaFX، يكون ذلك الخيط هو خيط التطبيق، ويكون مسؤولًا عن إنجاز كل الأمور المتعلّقة بمعالجة الأحداث ورسم المكونات على الشاشة. تُعدّ البرمجة المتوازية أصعب نوعًا ما من البرمجة أحادية الخيط؛ فعند وجود عدة خيوطٍ تَعمَل معًا لحل مشكلةٍ معينة، قد تَنشَأ أنواعٌ جديدة من الأخطاء، ولذلك تُعدّ التقنيات المُستخدَمة لكتابة البرامج كتابة صحيحة ومتينة أكثر أهميةً بالبرمجة المتوازية منها بالبرمجة العادية. تُوفِّر جافا لحسن الحظ واجهة برمجة تطبيقات للخيوط threads API تُسِّهل كثيرًا من استخدام الخيوط على نحوٍ معقول، كما أنها تحتوي على الكثير من الأصناف القياسية التي تُغلِّف الأجزاء الأكثر تعقيدًا وتخفيها بالكامل. يُمكِننا إنجاز أمورٍ كثيرة باستخدام الخيوط دون أن نتعلم أي شيءٍ عن تقنياتها منخفضة المستوى. إنشاء الخيوط وتشغيلها يُمثَّل الخيط بلغة جافا بواسطة كائنٍ ينتمي إلى الصنف java.lang.Thread، أو إلى أيٍّ من أصنافه الفرعية؛ حيث يُنشَأ هذا الكائن بغرض تنفيذ تابعٍ method -يُمثِّل المهمة المُفترَض للخيط تنفيذها- لمرةٍ واحدةٍ فقط، أي يُنفَّذ ذلك التابع داخل خيط التحكم الخاص به، والذي يُمكِنه أن يَعمَل بالتوازي مع خيوطٍ أخرى. يتوقف الخيط عن العمل بعد الانتهاء من تنفيذ التابع طبيعيًا أو نتيجةً لحدوث استثناءٍ exception لم يُلتقَط، وعندها لا تتوفَّر أي طريقةٍ لإعادة تشغيله أو حتى لاستخدام الكائن المُمثِّل لذلك الخيط لإنشاء واحدٍ جديد. تتوفَّر طريقتان لبرمجة خيط؛ حيث تتمثّل الأولى بإنشاء صنفٍ فرعي من الصنف Thread يحتوي على تعريفٍ للتابع public void run()‎، ويكون هذا التابع مسؤولًا عن تعريف المُهمة التي سيُنفِّذها الخيط؛ فهو يَعمَل بمجرد بدء تشغيل الخيط. تُعرِّف الشيفرة التالية على سبيل المثال صنفًا بسيطًا لخيطٍ لا يَفعَل أكثر من مجرد طباعة رسالةٍ إلى الخرج القياسي standard output: public class NamedThread extends Thread { private String name; // اسم الخيط public NamedThread(String name) { // يضبُط الباني اسم الخيط this.name = name; } public void run() { // تُرسِل رسالة إلى الخرج القياسي System.out.println("Greetings from thread '" + name + "'!"); } } يجب أن نُنشِئ كائنًا ينتمي إلى الصنف NamedThread لنتمكَّن من اِستخدامه. ألقِ نظرةً على ما يلي، على سبيل المثال: NamedThread greetings = new NamedThread("Fred"); لا يؤدي إنشاء ذلك الكائن إلى بدء تشغيل الخيط، أو تنفيذ تابعه run()‎ تلقائيًا، وإنما يجب استدعاء التابع start()‎ المُعرَّف بالكائن. يُمكِننا مثلًا كتابة التعليمة التالية: greetings.start(); يُنشِئ التابع start()‎ خيط تحكمٍ جديد مسؤولٍ عن تنفيذ التابع run()‎ المُعرَّف بالكائن، حيث يعمل هذا الخيط الجديد على التوازي إلى جانب الخيط الذي استدعينا به التابع start()‎، وكذلك إلى جانب أي خيوطٍ أخرى موجودةٍ مسبقًا. ينتهي التابع start()‎ من العمل ويُعيد قيمته بمجرد تشغيله للخيط الجديد دون أن ينتظر انتهاء الخيط من العمل؛ وهذا يَعنِي أن شيفرة التابع run()‎ المُعرَّف بكائن الخيط تُنفَّذ بنفس الوقت الذي تُنفَّذ خلاله التعليمات التالية لتعليمة استدعاء التابع start()‎. ألقِ نظرةً على الشيفرة التالية: NamedThread greetings = new NamedThread("Fred"); greetings.start(); System.out.println("Thread has been started"); يوجد بعد تنفيذ التعليمة greetings.start()‎ خيطان؛ حيث يَطبَع الأول جملة "Thread has been started"؛ بينما يريد الآخر طباعة جملة "!`Greetings from thread 'Fred". قد يختلف ترتيب طباعة الجملتين كلما شغَّلت البرنامج، حيث يَعمَل الخيطان بنفس الوقت، ويحاول كلٌ منهما الوصول إلى الخرج القياسي لطباعة الرسالة الخاصة به. وبالتالي، سيطبع الخيط الذي يتمكَّن من الوصول إلى الخرج القياسي أولًا، رسالته أولًا. يختلف ذلك عن البرامج العادية أحادية الخيط، حيث تُنفَّذ التعليمات بترتيبٍ مُحدَّد ومتوقَّع من البداية إلى النهاية؛ بينما هناك دائمًا عدم تحديد indeterminacy في البرامج متعددة الخيوط، فلا يكون الترتيب معروفًا أو محددًا، ولذلك لا يُمكِننا التأكُّد أبدًا من الترتيب الذي ستُنفَّذ على أساسه التعليمات، وهذا يَجعَل البرمجة المتوازية parallel programming صعبةً نوعًا ما. افترضنا حتى الآن احتواء الحاسوب الذي نُشغِّل عليه البرنامج على أكثر من وحدة معالجةٍ واحدة، ويسمح هذا بتنفيذ كُلٍ من الخيط الأصلي والخيط الجديد بنفس الوقت حرفيًا. ومع ذلك، من الممكن حتى إنشاء عدة خيوطٍ بحاسوبٍ مكوَّنٍ من وحدة معالجة واحدة، ومن الممكن إنشاء خيوطٍ يتجاوز عددها عدد معالجات الحاسوب عمومًا. في تلك الحالة، يتنافس الخيطان على زمن المعالج، ويَظِل هناك نوعٌ من عدم التحديد؛ لأن المعالج يُمكِنه الانتقال من تنفيذ خيط لآخر بطريقةٍ غير متوقعة. أما بالنسبة للمبرمج، لا تختلف البرمجة بحاسوبٍ أحادي المعالج عنها بحاسوبٍ متعدد المعالجات ولهذا، سنتجاهل ذلك التمييز. كنا قد ذكرنا أن هناك طريقتين لبرمجة خيط؛ حيث كانت الطريقة الأولى بتعريف صنفٍ فرعي من الصنف Thread. ننتقل الآن إلى الطريقة الثانية، وهي بتعريف صنفٍ يُنفِّذ الواجهة java.lang.Runnable؛ حيث تُعرِّف تلك الواجهة interface تابعًا وحيدًا، هو public void run()‎. يُمكِننا إنشاء خيطٍ من النوع Thread مهمته هي تنفيذ التابع run()‎ المُعرَّف بالواجهة، بمجرد حصولنا على كائنٍ منفِّذ لتلك الواجهة. يحتوي الصنف Thread على بانٍ constructor يَستقبِل كائنًا منفِّذًا للواجهة Runnable على أنه معاملٌ parameter. عندما نُمرِّر ذلك الكائن للباني، يَستدعِي تابع الخيط run()‎ التابع run()‎ المُعرَّف بالواجهة Runnable؛ وعندما نَستدعِي تابع الخيط start()‎، فإنه يُنِشئ خيط تحكمٍ جديد يكون مسؤولًا عن تنفيذ التابع run()‎ المُعرَّف بالواجهة Runnable. يُمكِننا مثلًا تعريف الصنف التالي بدلًا من إنشاء الصنف NamedThread: public class NamedRunnable implements Runnable { private String name; // الاسم public NamedRunnable(String name) { // يَضبُط الباني اسم الكائن this.name = name; } public void run() { // يُرسِل رسالةً إلى الخرج القياسي System.out.println("Greetings from runnable '" + name +"'!"); } } سنُنشِئ الآن كائنًا ينتمي إلى الصنف NamedRunnable المُعرَّف بالأعلى، ونَستخدِمه لإنشاء كائن من النوع Thread على النحو التالي: NamedRunnable greetings = new NamedRunnable("Fred"); Thread greetingsThread = new Thread(greetings); greetingsThread.start(); تتميِّز تلك الطريقة عن الأولى في إمكانية أي كائنٍ من تنفيذ الواجهة Runnable وتعريف التابع run()‎، والذي يُمكِن تنفيذه بعد ذلك بخيطٍ منفصل. بالإضافة إلى ذلك، يستطيع التابع run()‎ الوصول إلى أي شيءٍ مُعرَّفٍ بالصنف بما في ذلك توابعه ومتغيراته الخاصة private. في المقابل، لا تُعدّ تلك الطريقة كائنية التوجه object-oriented تمامًا؛ فهي تخالف المبدأ الذي ينصّ على ضرورة أن يكون لكل كائنٍ مسؤوليةً وحيدةً محددةً بوضوح. لذلك، يكون من الأفضل أن نُعرِّف الخيط باستخدام صنف متداخل nested فرعي من الصنف Thread، بدلًا من إنشاء كائنٍ عشوائي من النوع Runnable فقط لنَستخدِمه مثل خيط. انظر مقال الأصناف المتداخلة Nested Classes في جافا. أخيرًا، لاحِظ أن الواجهة Runnable هي واجهة نوع دالة functional interface، أي يُمكِن تمريرها مثل تعبير لامدا lambda expression. يَعنِي ذلك أن بإمكان باني الصنف Thread استقبال تعبير لامدا على أنه معامل. ألقِ نظرةً على المثال التالي: Thread greetingsFromFred = new Thread( () -> System.out.println("Greetings from Fred!") ); greetingsFromFred.start(); سنفحص الآن المثال التوضيحي ThreadTest1.java، لنفهم طريقة تنفيذ الخيوط المتعددة على التوازي؛ حيث سيُنشِئ هذا البرنامج عدة خيوط، بحيث ينفِّذ كل خيطٍ منها نفس المهمة تمامًا. ستكون المهمة هي عدُّ الأعداد الصحيحة الأوليّة الأقل من 5000000. ليس هناك غرضٌ محددٌ من اختيار تلك المهمة بالتحديد، فكل ما يَهُمّ هنا هو أن تستغرق المهمة وقتًا طويلًا بعض الشيء. لاحِظ أيضًا أن هذا البرنامج غير واقعي، فمن الحماقة إنشاء عدة خيوط لتنفيذ الأمر نفسه. لاحِظ أيضًا عدم عمل التابع المسؤول عن العدّ بكفاءة عالية. لن يستغرق البرنامج أكثر من عدّة ثوانٍ على أي حاسوبٍ عصري. تُعرِّف الشيفرة التالية صنفًا متداخلًا ساكنًا static nested class لتمثيل الخيوط المسؤولة عن تنفيذ المهمة: // 1 private static class CountPrimesThread extends Thread { int id; // مُعرِّف هوية لهذا الخيط public CountPrimesThread(int id) { this.id = id; } public void run() { long startTime = System.currentTimeMillis(); int count = countPrimes(2,5000000); // عدّ الأعداد الأولية long elapsedTime = System.currentTimeMillis() - startTime; System.out.println("Thread " + id + " counted " + count + " primes in " + (elapsedTime/1000.0) + " seconds."); } } [1] عند تشغيل خيطٍ ينتمي إلى هذا الصنف، فإنه يَعُدّ عدد الأعداد الأولية الواقعة بنطاقٍ يتراوح من 2 إلى 5000000. سيَطبَع النتيجة إلى الخرج القياسي، مع رقم مُعرِّف الهوية الخاص به، وكذلك الزمن المُنقضِي منذ لحظة بدء المعالجة وحتى نهايتها. سيطلب البرنامج main()‎ المُعرَّف فيما يلي من المُستخدِم إدخال عدد الخيوط المطلوب تشغيلها، ثم سيُنشِئ تلك الخيوط ويُشغِّلها: public static void main(String[] args) { int numberOfThreads = 0; while (numberOfThreads < 1 || numberOfThreads > 25) { System.out.print("How many threads do you want to use (1 to 25) ? "); numberOfThreads = TextIO.getlnInt(); if (numberOfThreads < 1 || numberOfThreads > 25) System.out.println("Please enter a number between 1 and 25 !"); } System.out.println("\nCreating " + numberOfThreads + " prime-counting threads..."); CountPrimesThread[] worker = new CountPrimesThread[numberOfThreads]; for (int i = 0; i < numberOfThreads; i++) worker[i] = new CountPrimesThread( i ); for (int i = 0; i < numberOfThreads; i++) worker[i].start(); System.out.println("Threads have been created and started."); } ربما من الأفضل أن تُصرِّف compile البرنامج وتُشغِّله. عند تشغيل البرنامج باستخدام خيطٍ واحدٍ على حاسوبٍ قديمٍ نوعًا ما، يستغرق الحاسوب حوالي "6.251 ثانية" لإجراء العملية؛ وعند تشغيله باستخدام ثمانية خيوط، يكون الخرج على النحو التالي: Creating 8 prime-counting threads... Threads have been created and started. Thread 4 counted 348513 primes in 12.264 seconds. Thread 2 counted 348513 primes in 12.569 seconds. Thread 3 counted 348513 primes in 12.567 seconds. Thread 0 counted 348513 primes in 12.569 seconds. Thread 7 counted 348513 primes in 12.562 seconds. Thread 5 counted 348513 primes in 12.565 seconds. Thread 1 counted 348513 primes in 12.569 seconds. Thread 6 counted 348513 primes in 12.563 seconds. يَطبَع الحاسوب السطر الثاني تلقائيًا بعد السطر الأول، ويكون البرنامج main()‎ في تلك اللحظة قد انتهى، بينما تستمر الثمانية خيوط الأخرى بالعمل. بعد فترةٍ تَصِل إلى "12.5 ثانية"، تكتمل جميع الخيوط الثمانية بنفس الوقت تقريبًا. لا يكون ترتيب انتهاء الخيوط من العمل هو نفسه ترتيب بدء تشغيلها، فالترتيب غير حتمي؛ أي إذا شغَّلنا البرنامج مرةً أخرى، فلربما سيختلف ذلك الترتيب. نظرًا لاحتواء الحاسوب على أربعة معالجات، استغرقت الثمانية خيوط عند تشغيلها عليه ضعف الزمن الذي استغرقه خيطٌ واحدٌ تقريبًا؛ فعند تشغيل ثمانية خيوط على أربعة معالجات (أي نصف معالج لكل خيط)، كان كل خيطٍ منها نَشطًا فعليًا لمدةٍ تصل إلى نصف ذلك الزمن فقط، ولهذا استغرقت ضعف الوقت لإنهاء نفس المهمة. بالمثل، إذا احتوى الحاسوب على معالجٍ واحدٍ فقط، فستستغرق الخيوط الثمان زمنًا يَصِل إلى ثمانية أضعاف الزمن الذي يستغرقه الخيط الواحد؛ وإذا احتوى الحاسوب على ثمانية معالجات أو أكثر، فلربما لن تستغرق الخيوط الثمان زمنًا أكبر مما يستغرقه الخيط الواحد. ومع ذلك، قد يكون التزايد الفعلي في السرعة أصغر قليلًا مما أشرنا إليه هنا نتيجةً لبعض التعقيدات، ويكون التزايد الفعلي مُحددًا في الحواسيب متعددة المعالجات. والآن حان دورك، ماذا يحدث عندما تُشغِّل البرنامج على حاسوبك الشخصي؟ كم عدد المعالجات الموجودة بحاسوبك؟ عندما يكون هناك خيوطٌ أكثر من عدد المعالجات المتاحة، يُقسِّم الحاسوب قدرته المعالجية على الخيوط المُشغَّلة بالتبديل بينها بسرعة. يعني ذلك تشغيل كل معالجٍ خيطًا واحدًا لفترة، ثم الانتقال إلى خيطٍ آخر لتشغيله لفترة، ثم ينتقل لغيره، وهكذا. يُطلق على تلك التنقلات اسم تبديلات السياق context switches، والتي تحدث بمعدلٍ يصل إلى 100 مرة أو أكثر بالثانية الواحدة. يستطيع الحاسوب بتلك الطريقة إحراز بعض التقدم بجميع المهمات المطلوبة، ويظن المُستخدِم أنها تُنفَّذ جميعًا بنفس الوقت. ولهذا السبب، انتهت جميع الخيوط التي كان مطلوبًا منها نفس حجم العمل بنفس الوقت تقريبًا في المثال التوضيحي السابق. خلاصة القول أنه ولأي فترةٍ زمنيةٍ أكبر من جزءٍ من الثانية، فسيُقسَّم زمن الحاسوب بالتساوي تقريبًا على جميع الخيوط. العمليات على الخيوط ستجد غالبية واجهة برمجة تطبيقات جافا للخيوط مُعرَّفةً بالصنف Thread، ومع ذلك سنبدأ بتابعٍ متعلقٍ بالخيوط ومعرَّفٍ بالصنف Runtime؛ حيث يَسمَح ذلك الصنف لبرامج جافا بالوصول إلى بعض المعلومات عن البيئة التي يعمل عليها البرنامج. عند إجراء برمجةٍ على التوازي بغرض توزيع العمل على أكثر من معالجٍ واحد، فلربما يكون من المهم أن نعرف عدد المعالجات المتاحة أولًا، فقد تُنشِئ خيطًا واحدًا لكل معالجٍ مثلًا. تستطيع معرفة عدد المعالجات باستدعاء الدالة التالية: Runtime.getRuntime().availableProcessors() تُعيد تلك الدالة قيمةً من النوع int تُمثِّل عدد المعالجات المتاحة بآلة جافا الافتراضية Java Virtual Machine. قد تكون تلك القيمة في بعض الحالات أقل من عدد المعالجات الفعلية المتاحة بالحاسوب. يحتوي أي كائنٍ من النوع Thread على الكثير من التوابع المفيدة المتعلِّقة بالعمل مع الخيوط؛ حيث يُعدّ التابع start()‎ الذي نُوقِش بالأعلى واحدًا من أهم تلك التوابع. بمجرد بدء الخيط، فإنه يَظَل مُشغَّلًا إلى حين انتهاء تابعه run()‎ من العمل. من المفيد في بعض الأحيان أن يعرف خيط معين فيما إذا كان خيطٌ آخر قد انتهى أم لا؛ فإذا كان thrd كائنًا من النوع Thread، ستفحص الدالة thrd.isAlive()‎ فيما إذا thrd قد انتهى أم لا. يُعدّ الخيط "نشطًا alive" منذ لحظة تشغيله إلى لحظة انتهائه، ويُعدّ "ميتًا dead" بعد انتهائه. تُستخدَم نفس تلك الاستعارة عندما نُشير إلى "إيقاف" أو "إلغاء" الخيط. تذكَّر أنه من غير الممكن إعادة تشغيل أي خيطٍ بعد انتهائه. يؤدي استدعاء التابع الساكن Thread.sleep(milliseconds)‎ إلى "سُبات sleep" الخيط المُستدعِي لفترةٍ مساويةٍ للزمن المُمرَّر بوحدة الميللي ثانية. يُعدّ الخيط النائم sleep نشطًا، ولكنه غير مُشغَّل، ويستطيع الحاسوب تنفيذ أي خيوطٍ أو برامجٍ أخرى أثناء توقُّف ذلك الخيط. يُمكِننا استخدام التابع Thread.sleep()‎ لإيقاف تنفيذ خيطٍ معين مؤقتًا. بإمكان التابع sleep()‎ التبليغ عن استثناء من النوع InterruptedException، والذي يُعدّ من الاستثناءات المُتحقَّق منها checked exception، أي لا بُدّ من معالجته؛ ويَعنِي ذلك عمليًا ضرورة استدعاء التابع sleep()‎ داخل تعليمة try..catch لالتقاط أي استثناءات محتملةٍ من النوع InterruptedException. ألقِ نظرةً على الشيفرة التالية: try { Thread.sleep(lengthOfPause); } catch (InterruptedException e) { } يستطيع خيطٌ معينٌ مقاطعة Interrupt خيطٍ آخر نائم أو مُتوقِّف لأسبابٍ أخرى بهدف إيقاظه. إذا كان thrd كائنًا من النوع Thread، فسيؤدي استدعاء التابع thrd.interrupt()‎ إلى مقاطعته. يُمكِننا الاستعانة بذلك التابع إذا كان من الضروري إرسال إشارةٍ معينة من خيطٍ لآخر. عندما يلتقط أي خيطٍ استثناءًا من النوع InterruptedException، فإنه يُدرك أن خيطًا آخر قد قاطعه. بالإضافة إلى ذلك، يستطيع الخيط استدعاء التابع الساكن Thread.interrupted()‎ بأي مكانٍ خارج عبارة catch ليَفحَص فيما إذا كان قد قُوطعَ بواسطة خيطٍ آخر. بمجرد استدعاء ذلك التابع، تنمحي حالة المقاطعة من الخيط؛ أي يستطيع الخيط قراءة حالة المقاطعة لمرةٍ واحدةٍ فقط، وهو ما قد تراه غريبًا بعض الشيء. بالنسبة للبرامج الخاصة بك، فلن يُقاطع أي خيط خيطًا آخرًا إلا إذا برمجته ليفعل ذلك بنفسك؛ لذلك لا تحتاج غالبًا لفعل أي شيء فيما هو متعلِّق بالاستثناء InterruptedException أكثر من مجرد التقاطه. يكون من الضروري في بعض الأحيان لخيطٍ معين الانتظار إلى حين انتهاء خيطٍ آخر من العمل. يُمكِننا فعل ذلك باستخدام التابع join()‎ المُعرَّف بالصنف Thread. بفرض أن thrd كائنٌ من النوع Thread، يُمكِن لأي خيطٍ آخر استدعاء thrd.join()‎؛ مما يَعنِي أنه سيدخل في حالة سُبات sleep إلى حين انتهاء thrd. في حالة كان thrd ميتًا بالفعل عند استدعاء thrd.join()‎، لا يكون لها أي تأثير. بإمكان التابع join()‎ التبليغ عن استثناءٍ من النوع InterruptedException، والذي يجب مُعالجته كما ذكرنا بالأعلى. على سبيل المثال، تُشغِّل الشيفرة التالية عدة خيوط، وتنتظر إلى حين انتهائها جميعًا من العمل، ثم تَطبَع الزمن المُستغرَق: CountPrimesThread[] worker = new CountPrimesThread[numberOfThreads]; long startTime = System.currentTimeMillis(); for (int i = 0; i < numberOfThreads; i++) { worker[i] = new CountPrimesThread(); worker[i].start(); } for (int i = 0; i < numberOfThreads; i++) { try { worker[i].join(); // انتظر إلى أن ينتهي إذا لم يكن قد انتهى بالفعل } catch (InterruptedException e) { } } // بالوصول إلى تلك اللحظة، تكون جميع الخيوط العاملة قد انتهت long elapsedTime = System.currentTimeMillis() - startTime; System.out.println("Total elapsed time: " + (elapsedTime/1000.0) + " seconds"); ربما لاحظت أن تلك الشيفرة تفترض عدم حدوث استثناءاتٍ من النوع InterruptedException. إذا كانت تلك الاستثناءات محتملةً بالبيئة المُشغَّل عليها البرنامج، ينبغي استخدام الشيفرة التالية للتأكُّد تمامًا من أن الخيط الموجود بالكائن worker[‎i]‎ قد انتهى أم لا: while (worker[i].isAlive()) { try { worker[i].join(); } catch (InterruptedException e) { } } تستقبل نسخةٌ أخرى من التابع join()‎ معاملًا من النوع العددي الصحيح، وهو يُمثِّل الحد الأقصى من الزمن المسموح بانتظاره بوحدة الميللي ثانية، حيث ينتظر الاستدعاء thrd.join(m)‎ إلى أن ينتهي الخيط thrd من العمل، أو إلى أن يمر m ميللي ثانية، أو إلى أن يُقاطَع الخيط المُنتظِر. يُمكِننا استخدام ذلك التابع للسماح للخيط المُنتظِر بإنجاز مهمةٍ ما أثناء انتظاره للخيط الآخر. على سبيل المثال، تُشغِّل الشيفرة التالية خيطًا اسمه thrd، ثم تَطبَع الزمن المُنقضِي كل 2 ثانية طالما كان thrd مُشغَّلًا: System.out.print("Running the thread "); thrd.start(); while (thrd.isAlive()) { try { thrd.join(2000); System.out.print("."); } catch (InterruptedException e) { } } System.out.println(" Done!"); تتميز الخيوط بخاصيتين مفيدتين في بعض الأحيان، هما: الحالة الخفية daemon status والأولوية priority؛ حيث يُمكِن للخيط أن يُضبَط ليُصبِح خيطًا خفيًا باستدعاء التابع thrd.setDaemon(true)‎ قبل بدء تشغيل الخيط. قد يُبلِّغ الاستدعاء عن استثناءٍِ من النوع SecurityException إذا لم يكن الخيط المُستدعِي قادرًا على تعديل خاصيات الخيط thrd. وفي تلك الحالة، يكون إنهاء آلة جافا الافتراضية ممكنًا بمجرد انتهاء جميع الخيوط الحيّة غير الخفية، أي لا يُعدّ وجود بعض الخيوط الحيّة الخفية كافيًا لإبقاء آلة جافا الافتراضية مشغَّلة. يُعدّ ذلك منطقيًا، فالخيوط الخفية بالنهاية موجودةٌ فقط لتوفير بعض الخدمات للخيوط غير الخفية، ونظرًا لعدم وجود أي خيوطٍ غير خفية أخرى، لا تُستدَعى تلك الخدمات التي توفِّرها الخيوط الخفية مجددًا، ولذلك يُمكِن إنهاء البرنامج أيضًا. لاحِظ أن استدعاء System.exit()‎ ينهِي آلة جافا الافتراضية JVM إجباريًأ حتى في حالة وجود بعض الخيوط المُشغَّلة غير الخفية. تُعدّ أولوية الخيط خاصيةً أكثر أهمية، حيث يمتلك أي خيط عمومًا أولويةً مُمثَلةً باستخدام عددٍ صحيح، ويكون تشغيل الخيوط ذات الأولوية الأكبر مُفضّلًا على حساب تشغيل الخيوط ذات الأولوية الأقل. على سبيل المثال، يُمكِن للعمليات الموجودة بالخلفية، والتي تُشغَّل عندما لا يكون هناك عملٌ ضروريٌ بخيطٍ هام آخر، أن تُشغَّل بأولوية أقل. إذا كان thrd كائنًا من النوع Thread، يُعيد التابع thrd.getPriority()‎ عددًا صحيحًا يُمثِّل أولوية الخيط thrd، بينما يَضبُط التابع thrd.setPriority(p)‎ أولوية الخيط إلى العدد الصحيح المُخصَّص p. لا يُمكِن تخصيص أي أعدادٍ صحيحة عشوائية على أنها أولويةً لخيطٍ معين، وسيبلِّغ التابع thrd.setPriority()‎ عن استثناءِ من النوع llegalArgumentException، إذا لم تَكن الأولوية المُخصَّصة بالنطاق المسموح به للخيط. يختلف نطاق الأعداد المسموح بها لقيم أولوية خيطٍ من حاسوبٍ لآخر، وتكون مُخصَّصةً عبر الثوابت Thread.MIN_PRIORITY و Thread.MAX_PRIORITY، ومع ذلك، يُمكِن تقييد أولوية خيطٍ معينٍ لتقع ضمن قيمٍ أقل من الثابت Thread.MAX_PRIORITY. يتوفَّر أيضًا الثابت Thread.NORM_PRIORITY الذي يُمثِّل القيمة الافتراضية لأولوية خيط. يُمكِننا استخدام التعليمة التالية لضبط الخيط thrd؛ بحيث يَعمَل بقيمة أولوية أقل من القيمة الافتراضية بقليل: thrd.setPriority( Thread.NORM_PRIORITY - 1 ); ملاحظة: قد يُبلِّغ التابع thrd.setPriority()‎ عن استثناءِ من النوع SecurityException أيضًا إذا لم يَكن مسموحًا للخيط المُستدعِي بضَبْط أولوية الخيط thrd إلى القيمة المُمرَّرة. أخيرًا، يعيد التابع الساكن Thread.currentThread()‎ الخيط الحالي؛ أي أنه يعيد الخيط المُستدِعي لنفس ذلك التابع، وبذلك يستطيع الخيط أن يحصُل على مرجع reference لذاته، وهو ما يُمكِّنه من تعديل خاصياته. يُمكِننا مثلًا تحديد أولوية الخيط الجاري تشغيله باستدعاء Thread.currentThread().getPriority()‎. الإقصاء التشاركي Mutual Exclusion وتعليمة التزامن synchronized من السهل برمجة عدة خيوطٍ لتنفيذ بعض المهمات المستقلة تمامًا. تَكْمُن الصعوبة الحقيقية عندما تضطّر الخيوط للتفاعل مع بعضها بطريقةٍ أو بأخرى. تُعدّ مشاركة الموارد resources واحدةً من طرق تفاعل الخيوط مع بعضها؛ فعندما يحتاج خيطان مثلًا للوصول إلى نفس المورد، مثل متغير أو نافذةٍ على الشاشة، لا بُدّ من التأكُّد من عدم استخدامهما لنفس المورد بنفس اللحظة؛ وإلا سيكون الموقف مشابهًا لما يلي: إذا كان لدينا مجموعةٌ من الطباخين يتشاركون استخدام كوب قياسٍ واحدٍ فقط. لنتخيل أن الطباخ A قد ملئ كوب القياس بالحليب، وقبل أن يتمكَّن من تفريغه بالصحن الخاص به، أمسك الطباخ B بكوب القياس المملوء بالحليب. لذلك، لا بُدّ من توفير طريقة للطباخ A تُمكِّنه من المطالبة بأحقيته وحده للوصول إلى الكوب أثناء تنفيذه للعمليتين: إضافة الحليب إلى الكوب وتفريغ الكوب بالصحن. ينطبق الأمر ذاته على الخيوط حتى أثناء إجرائها لعمليةٍ بسيطة مثل زيادة قيمة عدادٍ بمقدار الواحد. ألقِ نظرةً على التعليمة التالية: count = count + 1; في الواقع، تتكوَّن التعليمة السابقة فعليًا من ثلاث عمليات: // اقرأ قيمة العداد Step 1. Get the value of count // زِد قيمة العداد بمقدار الواحد Step 2. Add 1 to the value. // خزِّن القيمة الجديدة بالعداد Step 3. Store the new value in count لنفترض أنه لدينا مجموعةٌ من الخيوط تنفِّذ جميعها نفس الخطوات الثلاثة السابقة. تذكَّر أنه من الممكن تشغيل خيطين بنفس الوقت حتى في حالة وجود معالجٍ واحدٍ فقط؛ حيث يستطيع ذلك المعالج التبديل بين الخيوط الموجودة بأي لحظة. لنفترض الآن أنه وبينما كان خيطٌ معينٌ بين الخطوتين الثانية والثالثة، بدأ خيطٌ آخر بتنفيذ نفس مجموعة الخطوات. نظرًا لعدم تخزين الخيط الأول القيمة الجديدة داخل المُتغيّر count بعد، سيقرأ الخيط الآخر القيمة "القديمة" للمتغير count، وبالتالي سيزيد تلك القيمة بمقدار الواحد. بناءً على ذلك، يَحسِب الخيطان نفس القيمة الجديدة، وعند تنفيذهما للخطوة الثالثة، يخزِّن كلاهما تلك القيمة بالمتغير count. بعد انتهاء الخيطين من العمل، تكون قيمة المتغيرcount` قد ازدادت بمقدار 1 فقط بدلًا من 2. يُطلَق على هذا النوع من المشكلات اسم "حالة التسابق race condition"، والتي تَحدُث في حالة وجود خيطٍ معينٍ وسط عمليةٍ مكوَّنةٍ من عدة خطوات، ويُغيّر خيطٌ آخر قيمةً أو شرطًا يعتمد عليه الخيط الأول لإتمام العملية التي يُجريها؛ ويُقال أن الخيط الأول يكون في حالة "تسابق" لإكمال جميع الخطوات قبل أن يقاطعه خيط آخر. يُمكِن أن تقع حالة التسابق أيضًا بتعليمة if الشرطية. لنفحص التعليمة التالية التي تحاول تجنُّب وقوع خطأ القسمة على صفر: if ( A != 0 ) { B = C / A; } لنفترض أن تلك التعليمة مُشغَّلةٌ بخيطٍ معين، ولنفترض أن هنالك خيطٌ آخر أو عدة خيوطٍ أخرى تتشارك مع الخيط الأول المورد A. إذا لم نُوفِّر حمايةً ضد حالة التسابق بطريقةٍ ما، فبإمكان أيٍّ من تلك الخيوط تعديل قيمة A إلى الصفر في اللحظة الواقعة بين لحظة فحص الخيط الأول للشرط A != 0، ولحظة إجراءه لعملية القسمة؛ أي قد ينتهي به الحال بإجراء عملية القسمة على صفر على الرغم من أنه قد فحص للتو أن المتغير A لا يساوي الصفر. لحل مشكلة حالات التسابق، لا بُدّ من توفُّر طريقة تُمكِّن الخيط من الوصول وحده دون غيره إلى موردٍ تشاركي. في الواقع، لا يُعدّ ذلك أمرًا سهل التنفيذ، ولكن تُوفِّر جافا أسلوبًا عالي المستوى وسهل الاستخدام نسبيًا لتحقيق ذلك باستخدام التوابع المتزامنة synchronized، وتعليمة synchronized؛ حيث توفِّر تلك الطرائق حمايةً للموارد التي تتشاركها عدة خيوط، وذلك من خلال السماح لخيطٍ واحدٍ فقط بالوصول إلى المورد بكل مرة. يُوفِّر التزامن بلغة جافا ما يُعرف باسم "الإقصاء التشاركي mutual exclusion"، والذي يَعنِي ضمان تحقُّق الوصول الإقصائي لموردٍ معين إذا استخدمت جميع الخيوط التي تحتاج الوصول إلى ذلك المورد المزامنة. إذا طبقنا مبدأ المزامنة على مثال الطباخين، فإنه يَعنِي أن يترك الطباخ الأول ملاحظةً تقول "إنني استخدِم كوب القياس"، وهو ما يمنحه أحقيةً حصريّةً إقصائية في الوصول إلى الكوب، ولكن لن يتحقَّق ذلك إلا إذا اتفق جميع الطباخين على فحص الملاحظة قبل محاولة الإمساك بالكوب. نظرًا لأنه موضوع معقدٌ نوعًا ما، سنبدأ بمثالٍ بسيط. لنفترض أننا نريد تجنُّب حالة التسابق ممكنة الحدوث عند محاولة مجموعةٍ من الخيوط زيادة قيمة عدادٍ بمقدار الواحد. بدايةً، سنُعرِّف صنفًا يُمثِّل العداد، وسنَستخدِم التوابع المتزامنة ضمن ذلك الصنف. يُمكِننا الإعلان عن أن تابعًا معينًا هو من النوع المتزامن بإضافة الكلمة المحجوزة synchronized مثل مُعدِّلٍ بتعريف ذلك التابع على النحو التالي: public class ThreadSafeCounter { private int count = 0; // قيمة العداد synchronized public void increment() { count = count + 1; } synchronized public int getValue() { return count; } } إذا كان tsc من النوع ThreadSafeCounter، يُمكِن لأي خيطٍ استدعاء التابع tsc.increment()‎ لزيادة قيمة العداد بمقدار 1 بطريقةٍ آمنةٍ تمامًا. نظرًا لأن التابع tsc.increment()‎ متزامن، سيكون بإمكان خيطٍ واحدٍ فقط تنفيذه بالمرة الواحدة. يَعنِي ذلك أنه وبمجرد بدء خيطٍ معين بتنفيذ ذلك التابع، فسيُنهي ذلك الخيط تنفيذ التابع بالضرورة قبل أن يُسمَح لخيطٍ آخر بالوصول إلى count. وبالتالي لا يكون هناك أي احتماليةٍ لحدوث حالة تسابق. لاحِظ أن ما سبق مشروطٌ بحقيقة أن count مُعرَّف على أنه متغيرٌ خاص private، وبالتالي لا بُدّ أن تحدث أي محاولةٍ للوصول إليه عبر التوابع المتزامنة المُعرَّفة بالصنف؛ وإذا كان count مُعرَّفًا على أنه متغيرٌ عام، فبإمكان خيطٍ آخر اجتياز المزامنة بكتابة tsc.count++‎ مثلًا، وستتغيّر في تلك الحالة قيمة المتغير count بينما ما يزال خيطٌ آخر يُنفِّذ عملية tsc.increment()‎؛ أي لا تضمَن عملية المزامنة بحد ذاتها تحقُّق الوصول الإقصائي للموارد في العموم، وإنما تضمَن تحقُّق "الإقصاء التشاركي" بين جميع الخيوط المتزامنة فقط. ومع ذلك، لا يمنع الصنف ThreadSafeCounter جميع حالات التسابق محتملة الحدوث عند استخدام مجموعة خيوطٍ لعداد. انظر تعليمة if التالية مثلًا: if ( tsc.getValue() == 10 ) { doSomething(); } يتطلَّب التابع doSomething()‎ أن تكون قيمة العداد مساويةً للعدد 10. قد تحدث حالة تسابق إذا زاد خيطٌ آخر قيمة العداد بين لحظتي اختبار الخيط الأول للشرط tsc.getValue() == 10 وتنفيذه للتابع doSomething()‎. يحتاج الخيط الأول إذًا إلى وصولٍ إقصائي إلى العداد أثناء تنفيذه تعليمة if بالكامل؛ بينما تمنحه المزامنة بالصنف ThreadSafeCounter وصولًا إقصائيًا أثناء تحصيله لقيمة tsc.getValue()‎ فقط. يُمكِننا حل تلك المشكلة بوضع تعليمة if داخل تعليمة synchronized على النحو التالي: synchronized(tsc) { if ( tsc.getValue() == 10 ) doSomething(); } تستقبل تعليمة synchronized كائنًا على أنه معاملٌ -كان tsc في المثال السابق-، وتُكتَب وفقًا لقواعد الصيغة التالية: synchronized( object ) { statements } يرتبط الإقصاء التشاركي بجافا دائمًا بكائنٍ معين، ويقال أن التزامن مبنيٌ على ذلك الكائن، حيث يُعدّ تزامن تعليمة if بالأعلى مثلًا مبنيًا على الكائن tsc؛ بينما يُعدّ تزامن توابع النسخ instance method المتزامنة، مثل تلك المُعرَّفة بالصنف ThreadSafeCounter، مبنيًا على الكائن المُتضمِّن لتابع النسخة. تتكافئ إضافة المُعدِّل synchronized إلى تعريف تابع نسخة مع كتابة متن التابع داخل تعليمة synchronized على الصيغة التالية synchronized(this) {...}‎. من الممكن أيضًا تعريف توابعٍ ساكنةٍ متزامنة، ويُعدّ تزامنها مبنيًا على الكائن الخاص الذي يُمثِّل الصنف المُتضمِّن للتابع الساكن. لا يُمكِن أن يتزامن خيطان بناءً على نفس الكائن بنفس الوقت، حيث تُعدّ العبارة السابقة القاعدة الحقيقية للمزامنة بلغة جافا؛ وتَعنِي أنه لا يُمكِن لخيطين تنفيذ شيفرتين متزامنتين بناءً على نفس الكائن بنفس الوقت؛ بمعنى أنه إذا تزامن خيطٌ معينٌ بناءً على كائنٍ معين، وحاول خيطٌ آخر أن يتزامن بناءً على نفس الكائن، فسيضطّر الخيط الثاني للانتظار إلى حين انتهاء الخيط الأول. في الواقع، لا يعني ذلك أنه ليس بإمكانهما فقط تنفيذ نفس التابع المتزامن بنفس الوقت، وإنما ليس بإمكانهما أن يُنفِّذا تابعين مختلفين بنفس الوقت إذا كان تزامن هذين التابعين مبنيًا على نفس الكائن. تُنفِّذ جافا ذلك باستخدام "قفل المزامنة synchronization lock، حيث يملك كل كائنٍ قفلًا يُمكِن أن يحصُل عليه خيطٌ واحدٌ فقط خلال أي لحظة. عندما نستخدِم تعليمة synchronized أو نستدعي تابعًا متزامنًا، فلا بُدّ للخيط أن يحصل على قفل الكائن المبني عليه التزامن أولًا؛ فإذا كان القفل متاحًا، سيحصل الخيط عليه فورًا ويبدأ بتنفيذ الشيفرة المتزامنة، ثم يُحرِّره بمجرد انتهاءه من تنفيذها؛ أما إذا حاول الخيط A الحصول على قفلٍ قد حصل عليه خيطٌ آخر B، فلا بُدّ إذًا أن ينتظر الخيط A حتى يُحرِّر الخيط B القفل؛ أي يتوقَّف/ينام الخيط A، ولا يعود للعمل حتى يُصبِح القفل متاحًا. ذكرنا بالقسم اللامتغايرات من مقال كيفية كتابة برامج صحيحة باستخدام لغة جافا أن التفكير بطريقة عمل اللا متباين invariants ستصبح أعقد كثيرًا عند استخدام الخيوط، حيث تَكُمن المشكلة في حالات التسابق race conditions. نريد للصنف ThreadSafeCounter أن يُحقِّق لا متباين الصنف الذي ينص على: "تُمثِّل قيمة count عدد مرات استدعاء increment()‎". يتحقَّق ذلك بدون مزامنة synchronization بالبرامج أحادية الخيط، ولكن تصبح المزامنة ضرورية بالبرامج متعددة الخيوط للتأكُّد من تحقُّق لا متباين الصنف. سنعود الآن إلى مسألة عدّ الأعداد الأولية بمثابة مثالٍ بسيطٍ على الموارد التشاركية shared resources، وبدلًا من أن تُنفِّذ جميع الخيوط نفس المهمة تمامًا، سننفِّذ معالجةً على التوازي أكثر واقعية. سيَعُدّ البرنامج الأعداد الأولية ضمن نطاقٍ معين من الأعداد الصحيحة، وسيفعل ذلك بتوزيع العمل على عدة خيوط؛ أي سيتعيّن على كل خيط عدّ الأعداد الأولية الموجودة ضمن جزءٍ معين من النطاق الكلي، ثم سيضطّر لإضافة القيمة التي حسبها إلى المجموع الكلي ضمن كامل النطاق. نظرًا لأن جميع الخيوط مضطرةٌ لإضافة عددٍ إلى المجموع الكلي، فلا بُدّ أن تتشارك جميعها مُتغيرًا يُمثِّل ذلك المجموع الكلي، وليكن اسمه هو total. إذا اِستخدَمت جميع الخيوط التعليمة التالية: total = total + count; فهناك احتماليةٌ ولو صغيرة أن يحاول خيطان تنفيذ التعليمة ذاتها بنفس الوقت، وستكون القيمة النهائية للمتغير total غير صحيحة. لذلك لا بُدّ أن يكون الوصول إلى total متزامنًا لمنع حالة التسابق race condition. سنَستخدِم ضمن هذا البرنامج تابعًا متزامنًا يَسمَح بزيادة قيمة total بمقدارٍ معين، بحيث يَستدعِي كل خيطٍ ذلك التابع لمرةٍ واحدة. سيُمثِّل ذلك التابع الطريقة الوحيدة لتعديل قيمة total. ألقِ نظرةً على شيفرة التابع: synchronized private static void addToTotal(int x) { total = total + x; System.out.println(total + " primes found so far."); } يُمكِنك الإطلاع على شيفرة البرنامج من الملف ThreadTest2.java، حيث يَعُدّ هذا البرنامج الأعداد الأولية الواقعة بنطاقٍ يتراوح من 3000001 إلى 6000000 (مجرد أعداد عشوائية). يُنشِئ البرنامج main()‎ عدة خيوطٍ يتراوح عددها من "1" إلى "5"، ويُسنِد جزءًا من المهمة لكل خيط، ثم ينتظر إلى أن تنتهي جميع الخيوط من عملها باستخدام التابع join()‎ المذكور بالأعلى، وأخيرًا يُبلّغ عن العدد الكلي للأعداد الأولية مصحوبًا بالزمن الذي استغرقه البرنامج لحساب ذلك العدد. لاحِظ أن اِستخدَام التابع join()‎ ضروري؛ فلا معنى لطباعة عدد الأعداد الأولية قبل أن تنتهي جميع الخيوط. إذا شغَّلت البرنامج على حاسوبٍ متعدّد المعالجات، فسيستغرق البرنامج وقتًا أقل عند استخدام أكثر من مجرد خيطٍ واحد. تساعد المزامنة على منع حالات التسابق، ولكنها قد تتسبَّب بنوعٍ آخر من الأخطاء يُدعى "قفل ميت deadlock". يحدث هذا النوع من الأخطاء عندما ينتظر خيطٌ موردًا معينًا إلى الأبد دون أن يحصل عليه. إذا عدنا إلى مثال "المطبخ"، فلربما يحدث القفل الميت إذا أراد طبّاخان ساذجان قياس كوب حليب بنفس الوقت، حيث سيُمسِك الطباخ الأول بكوب القياس، بينما سيُمسِك الطباخ الآخر بالحليب. يحتاج الطباخ الأول إلى الحليب، ولكنه لا يستطيع العثور عليه لأنه بحوزة الطباخ الثاني. ومن الجهة الأخرى، يحتاج الطباخ الثاني إلى كوب القياس، ولكنه لا يستطيع العثور عليه لأنه بحوزة الطباخ الأول. وبذلك، لا يتمكَّن الطباخان من إكمال عملهما. يُعدّ ذلك بمثابة قفل ميت، وقد يحدث نفس الشيء ببرنامجٍ معين إذا كان هناك خيطين (الطباخين) مثلًا يريد كلاهما الحصول على قفلين على كائنين معينين (الحليب وكوب القياس) قبل أن يكملا عملهما. تقع الأقفال الميتة بسهولة إذا لم ننتبه بما يكفي لمنعها. المتغيرات المتطايرة تُعدّ المزامنة واحدةً ضمن عدة تقنيات تتحكَّم بالتواصل بين الخيوط، وسنتناول تقنياتٍ أخرى لاحقًا، أما الآن فسنُنهِي هذا القسم بمناقشة التقنيتين التاليتين: المتغيرات المتطايرة volatile variables والمتغيرات الذرية atomic variables. تتواصل الخيوط مع بعضها عمومًا من خلال تشارك عدة متغيرات والوصول إليها من خلال استخدام توابعٍ متزامنة أو تعليمة synchronized. ومع ذلك، تُعدّ عملية المزامنة مكلفةً حاسوبيًا، ولهذا ينبغي تجنُّب اِستخدَامها بكثرة، وقد يكون من المنطقي في بعض الأحيان للخيوط الإشارة إلى المتغيرات التشاركية دون مزامنة وصولها إلى تلك المتغيرات. من الجهة الأخرى، قد تنشأ مشكلةٌ صغيرة إذا كانت قيمة متغير تشاركي تُضبَط بخيطٍ وتُستخدَم بآخر. نظرًا للطريقة التي تُنفِّذ جافا الخيوط على أساسها، فلربما لا يرى الخيط الثاني القيمة الجديدة للمتغير على الفور؛ ويَعنِي ذلك أنه من الممكن لخيطٍ معين الاستمرار بقراءة القيمة "القديمة" لمتغيرٍ تشاركي لمدةٍ معينة بالرغم من أن قيمة ذلك المتغير قد عُدِّلت بخيطٍ آخر. يَحدث ذلك لأن جافا تسمح للخيوط بتخزين البيانات التشاركية مؤقتًا cache؛ أي يُمكِن لكل خيطٍ الاحتفاظ بنسخته المحلية من البيانات التشاركية، وبالتالي عندما يُعدِّل خيطٌ معينٌ قيمة متغيرٍ تشاركي، لا تُعدَّل النسخ المحلية بمخزِّنات الخيوط الأخرى المؤقتة على الفور، وقد تستمر تلك الخيوط بقراءة القيمة القديمة لفترةٍ قصيرةٍ على الأقل. في المقابل، يُعدّ استخدام مُتغيّر تشاركي داخل تابعٍ متزامن أو داخل تعليمة synchronized آمنًا شرط أن تكون جميع محاولات الوصول إلى ذلك المُتغيّر متزامنةً بناءً على نفس الكائن بجميع الحالات. بعبارة أخرى، إذا حاول خيطٌ معين الوصول إلى قيمة متغيرٍ داخل شيفرةٍ متزامنة، فإنه يَضمَن أن يرى أي تغييرات تجريها الخيوط الأخرى على ذلك المتغير شرط أن تكون تلك التعديلات قد أُجريت داخل شيفرةٍ متزامنةٍ بناءً على نفس الكائن. يُمكِننا أيضًا استخدام متغيرٍ تشاركي اِستخدَامًا آمنًا خارج شيفرةٍ متزامنة، ولكن لا بُدّ في تلك الحالة أن نُصرِّح عن كون المتغير متطايرًا volatile باستخدام كلمة volatile، حيث تُعدّ الكلمة المحجوزة volatile واحدةً من المُعدِّلات modifiers المُمكِن إضافتها إلى تعليمات التصريح عن المتغيرات العامة global variable على النحو التالي: private volatile int count; إذا صرَّحنا عن متغيرٍ باستخدام كلمة volatile، فلن يتمكَّن أي خيطٍ من الاحتفاظ بنسخةٍ محليةٍ من ذلك المتغير ضمن مُخزِّنه المؤقت، وستضطر الخيوط بدلًا من ذلك من استخدام النسخة الرئيسية الأصلية للمتغير دائمًا، وستكون بالتالي التعديلات المُجراة على ذلك المتغير مرئيةً لجميع الخيوط فورًا. بناءً على ذلك، يصبح من الآمن للخيوط الإشارة إلى المتغيرات التشاركية المُصرَّح عنها بكلمة volatile حتى خارج الشيفرات المتزامنة. ومع ذلك، يُعدّ الوصول إلى المتغيرات المتطايرة أقل كفاءةً من الوصول إلى المتغيرات العادية غير المتطايرة، ولكنه على الأقل أكثر كفاءةً من استخدام المزامنة. تذكَّر مع ذلك أن استخدام المتغيرات المتطايرة لا يحمي ضد حالات التسابق التي قد تحدث عند زيادة قيمة المتغير مثلًا، فربما يُقاطِع خيطٌ آخر عملية الزيادة. عند تطبيق المُعدِّل volatile على متغيرٍ من نوع كائني، فإن ما يُصرَّح عنه ليكون متطايرًا هو المتغير ذاته فقط لا محتويات الكائن الذي يشير إليه المتغير، ولهذا يُستخدَم غالبًا المُعدِّل volatile مع المتغيرات من الأنواع البسيطة، مثل الأنواع الأساسية primitive types، أو الأنواع الثابتة غير المتغيرة immutable types مثل String. لنفحص الآن مثالًا على استخدام متغيرٍ متطايرٍ لإرسال إشارةٍ من خيط لآخر ليخبره بأن عليه الانتهاء terminate. يتشارك الخيطان المتغير التالي: volatile boolean terminate = false; يفحص التابع run()‎ الخاص بالخيط الثاني قيمة terminate باستمرار، وينتهي عندما تصبح قيمته مساويةً القيمة true. ألقِ نظرةً على الشيفرة التالية: public void run() { while ( terminate == false ) { . . // Do some work. . } } سيستمر هذا الخيط بالعمل إلى أن يضبط خيطٌ آخر قيمة terminate إلى true. تُعدّ الشيفرة السابقة الطريقة الوحيدة للسماح لخيطٍ بغلْق خيطٍ آخر بطريقةٍ نظيفة. قد تتساءل عن سبب استعانة الخيوط ببياناتٍ محليةٍ مؤقتة من الأساس، خاصةً وأنها تُعقّد الأمور بصورةٍ غير ضرورية. في الواقع، يُسمَح للخيوط بالتخزين المؤقت نتيجةً لبنية الحواسيب متعددة المعالجات، حيث يملك كل معالجٍ ذاكرةً محليةً متصلةً به مباشرةً، وتُخزَّن الذاكرة المحلية المؤقتة للخيط بالذاكرة المحلية للمعالج المُشغَّل عليه الخيط. يُعدّ الوصول إلى تلك الذاكرة المحلية أسرع بكثير من الوصول إلى الذاكرة الرئيسية التي تتشاركها جميع المعالجات، ولذلك يُعدّ اِستخدَام الخيط لنسخةٍ محليةٍ من المتغيرات التشاركية أكثر كفاءةً من استخدام نسخةٍ رئيسيةٍ مخزَّنةٍ بالذاكرة الرئيسية. المتغيرات الذرية تكْمُن مشكلة البرمجة على التوازي بتعليمةٍ مثل count = count + 1 في أنها تستغرق عدة خطوات لتنفيذ التعليمة، وتكون التعليمة قد نُفذَّت على النحو الصحيح فقط إذا اكتملت الخطوات دون حدوث أي تقاطعٍ مع خيوطٍ أخرى. تُعدّ العمليات الذرية atomic operation بمثابة شيءٍ لا يمكن مقاطعته؛ فإما يحدث كله أو لا شيء، بمعنى أنه لا يُمكِن أن يكتمل جزئيًا. تحتوي معظم الحواسيب على عملياتٍ ذرية بمستوى لغة الآلة machine language level. قد تتوفَّر على سبيل المثال تعليمةً بلغة الآلة مسؤولةً عن زيادة قيمة موضعٍ معينٍ بالذاكرة بخطوةٍ واحدة. لا تعاني مثل تلك التعليمات من خطر حالات التسابق. بالنسبة للبرامج، يمكن لعمليةٍ أن تكون ذرية حتى لو لم تكن كذلك حرفيًا بمستوى لغة الآلة؛ حيث تُعدّ العملية ذريةً إذا لم يكن بإمكان أي خيطٍ أن يراها مكتملةً جزئيًا. على سبيل المثال، يحتوي الصنف ThreadSafeCounter المُعرَّف بالأعلى على عملية زيادةٍ ذرية. يُمكِننا أن نفكر بالمزامنة مثل طريقةٍ للتأكيد على كون العمليات ذرية. ومع ذلك، سيكون من الأفضل لو استطعنا الحصول على عملياتٍ ذرية دون استخدام المزامنة، خاصةً وأنه من الممكن تنفيذ تلك العمليات بكفاءة عالية على مستوى العتاد. تُوفِّر جافا حزمة java.util.concurrent.atomic، والتي تحتوي على أصنافٍ تُنفِّذ عملياتٍ ذرية على عدة أنواع متغيراتٍ بسيطة. سنفحص الصنف AtomicInteger الذي يُعرَّف بعض العمليات الذرية على قيمةٍ من النوع العددي الصحيح، بما في ذلك الجمع والزيادة والنقصان. لنفترض على سبيل المثال أننا نريد إضافة قيمٍ عدديةٍ صحيحة تنتجها مجموعةٌ من الخيوط. يُمكِننا فعل ذلك باستخدام الصنف AtomicInteger على النحو التالي: private static AtomicInteger total = new AtomicInteger(); يُنشَأ total بقيمةٍ مبدئية تُساوي الصفر. عندما يحاول خيطٌ معينٌ إضافة قيمةٍ ما إلى total، فيُمكِنه استخدام التابع total.addAndGet(x)‎، الذي يضيف x إلى total، ويعيد قيمة total الجديدة بعد الإضافة. يُعدّ ذلك مثالًا على عمليةٍ ذرية لا يمكن مقاطعتها، أي ستكون قيمة total صحيحةً بالضرورة. يُعَد البرنامج ThreadTest3.java نسخةً أخرى من البرنامج ThreadTest2، ولكنه يَستخدِم الصنف AtomicInteger بدلًا من المزامنة لحساب حاصل مجموع بعض القيم التي تنتجها خيوطٌ متعددة. يحتوي الصنف AtomicInteger على توابعٍ أخرى، مثل total.incrementAndGet()‎ لإضافة 1 إلى total، و total.decrementAndGet()‎ لطرح 1 منه. يضبُط التابع total.getAndSet(x)‎ قيمة total إلى x، ويعيد القيمة السابقة التي حلّت x محلها. تحدث جميع تلك العمليات على الفور؛ إما لأنها تَستخدِم تعليمات لغة آلة ذرية؛ أو لكونها تَستخدِم المزامنة داخليًا. تحذير: لا يُعدّ اِستخدَام المتغيرات الذرية حلًا تلقائيًا لجميع حالات التسابق التي قد تشملها تلك المتغيرات. ألقِ نظرةً على الشيفرة التالية على سبيل المثال: int currentTotal = total.addAndGet(x); System.out.println("Current total is " + currentTotal); ربما تكون قيمة total قد تغيرت بخيطٍ آخر بحلول وقت تنفيذ تعليمة الطباعة، وبذلك لا تكون currentTotal هي القيمة الحالية للمتغير total. ترجمة -بتصرّف- للقسم Section 1: Introduction to Threads من فصل Chapter 12: Threads and Multiprocessing من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: مقدمة مختصرة للغة XML واستعمالها في تطبيقات جافا معالجة الملفات في جافا تواصل تطبيقات جافا عبر الشبكة
  13. سنُقدِّم في هذه المقالة حل تمرين مقالة استخدام قاعدة بيانات Redis لحفظ البيانات. بعد ذلك، سنُحلِّل أداء خوارزمية فهرسة صفحات الإنترنت، ثم سنبني زاحفَ ويب Web crawler بسيطًا. المفهرس المبني على قاعدة بيانات Redis سنُخزِّن هيكلي البيانات التاليين في قاعدة بيانات Redis: سيُقابِل كلَّ كلمة بحثٍ كائنٌ من النوع URLSet هو عبارةٌ عن مجموعة Set في قاعدة بيانات Redis تحتوي على مُحدِّدات الموارد الموحدة URLs التي تحتوي على تلك الكلمة. سيُقابِل كل مُحدّد موارد موحد كائنًا من النوع TermCounter يُمثّل جدول Hash في قاعدة بيانات Redis يربُط كل كلمة بحث بعدد مرات ظهورها. يُمكِنك مراجعة أنواع البيانات التي ناقشناها في المقالة المشار إليها، كما يُمكِنك قراءة المزيد عن المجموعات والجداول بقاعدة بيانات Redis من توثيقها الرسمي. يُعرِّف الصنف JedisIndex تابعًا يَستقبِل كلمة بحثٍ ويعيد مفتاح كائن الصنف URLSet المقابل في قاعدة بيانات Redis: private String urlSetKey(String term) { return "URLSet:" + term; } كما يُعرِّف الصنفُ المذكور التابع termCounterKey، والذي يستقبل مُحدّد موارد موحدًا ويعيد مفتاح كائن الصنف TermCounter المقابل في قاعدة بيانات Redis: private String termCounterKey(String url) { return "TermCounter:" + url; } يَستقبِل تابع الفهرسة indexPage محددَ موارد موحدًا وكائنًا من النوع Elements يحتوي على شجرة DOM للفقرات التي نريد فهرستها: public void indexPage(String url, Elements paragraphs) { System.out.println("Indexing " + url); // أنشئ كائنًا من النوع‫ TermCounter وأحصِ كلمات الفقرات النصية TermCounter tc = new TermCounter(url); tc.processElements(paragraphs); // أضف محتويات الكائن إلى قاعدة بيانات‫ Redis pushTermCounterToRedis(tc); } سنقوم بالتالي لكي نُفهرِّس الصفحة: نُنشِئ كائنًا من النوع TermCounter يُمثِل محتويات الصفحة باستخدام شيفرة تمرين المقال المشار إليه بالأعلى. نُضيف محتويات ذلك الكائن في قاعدة بيانات Redis. تُوضِّح الشيفرة التالية طريقة إضافة كائنات النوع TermCounter إلى قاعدة بيانات Redis: public List<Object> pushTermCounterToRedis(TermCounter tc) { Transaction t = jedis.multi(); String url = tc.getLabel(); String hashname = termCounterKey(url); // إذا كانت الصفحة مفهرسة مسبقًا، احذف الجدول القديم t.del(hashname); // أضف مدخلًا جديدًا في كائن الصنف‫ TermCounter وعنصرًا جديدًا إلى الفهرس // لكل كلمة بحث for (String term: tc.keySet()) { Integer count = tc.get(term); t.hset(hashname, term, count.toString()); t.sadd(urlSetKey(term), url); } List<Object> res = t.exec(); return res; } يَستخدم هذا التابع معاملةً من النوع Transaction لتجميع العمليات، ثم يرسلها جميعًا إلى الخادم على خطوة واحدة. تُعدّ تلك الطريقة أسرع بكثير من إرسال متتاليةٍ من العمليات الصغيرة. يمرّ التابع عبر العناصر الموجودة في كائن الصنف TermCounter، ويُنفِّذ التالي من أجل كل عنصرٍ منها: يبحث عن كائنٍ من النوع TermCounter -أو ينشئه إن لم يجده- في قاعدة بيانات Redis، ثم يضيف حقلًا فيه يُمثِل العنصر الجديد. يبحث عن كائنٍ من النوع URLSet -أو ينشئه إن لم يجده- في قاعدة بيانات Redis، ثم يضيف إليه محدّد الموارد الموحد الحالي. إذا كنا قد فهرَسنا تلك الصفحة من قبل، علينا أن نحذف كائن الصنف TermCounter القديم الذي يمثلها قبل أن نضيف المحتويات الجديدة. هذا هو كل ما نحتاج إليه لفهرسة الصفحات الجديدة. طلبَ الجزءُ الثاني من التمرين كتابةَ التابع getCounts الذي يَستقبِل كلمة بحثٍ ويعيد خريطةً تربط محددات الموارد الموحدة التي ظهرت فيها تلك الكلمة بعدد مرات ظهورها فيها. انظر إلى تنفيذ التابع: public Map<String, Integer> getCounts(String term) { Map<String, Integer> map = new HashMap<String, Integer>(); Set<String> urls = getURLs(term); for (String url: urls) { Integer count = getCount(url, term); map.put(url, count); } return map; } يَستخدِم هذا التابعُ تابعين مساعدين: getURLs: يَستقبِل كلمة بحثٍ ويعيد مجموعةً من النوع Set تحتوي على محددات الموارد الموحدة التي ظهرت فيها الكلمة. getCount: يَستقبِل محدد موارد موحدًا URI وكلمة بحث، ويعيد عدد مرات ظهور الكلمة بمحدد الموارد المُمرَّر. انظر تنفيذات تلك التوابع: public Set<String> getURLs(String term) { Set<String> set = jedis.smembers(urlSetKey(term)); return set; } public Integer getCount(String url, String term) { String redisKey = termCounterKey(url); String count = jedis.hget(redisKey, term); return new Integer(count); } تَعمَل تلك التوابع بكفاءةٍ نتيجةً للطريقة التي صمّمّنا بها المُفهرِس. تحليل أداء عملية البحث لنفترض أننا فهرسنا عددًا مقداره N من الصفحات، وتوصلنا إلى عددٍ مقداره M من كلمات البحث. كم الوقت الذي سيستغرقه البحث عن كلمةٍ معينةٍ؟ فكر قبل أن تكمل القراءة. سنُنفِّذ التابع getCounts للبحث عن كلمةٍ، يُنفِّذ ذلك التابع ما يلي: يُنشِئ خريطةً من النوع HashMap. يُنفِّذ التابع getURLs ليسترجع مجموعة مُحدِّدات الموارد الموحدة. يَستدعِي التابع getCount لكل مُحدِّد موارد، ويضيف مُدْخَلًا إلى الخريطة. يستغرق التابع getURLs زمنًا يتناسب مع عدد محددات الموارد الموحدة التي تحتوي على كلمة البحث. قد يكون عددًا صغيرًا بالنسبة للكلمات النادرة، ولكنه قد يكون كبيرًا -قد يَصِل إلى N- في حالة الكلمات الشائعة. سنُنفِّذ داخل الحلقة التابعَ getCount الذي يبحث عن كائنٍ من النوع TermCounter في قاعدة بيانات Redis، ثم يبحث عن كلمةٍ، ويضيف مُدخْلًا إلى خريطةٍ من النوع HashMap. تستغرق جميع تلك العمليات زمنًا ثابتًا، وبالتالي، ينتمي التابع getCounts في المجمل إلى المجموعة O(N)‎ في أسوأ الحالات، ولكن عمليًا، يتناسب زمن تنفيذه مع عدد الصفحات التي تحتوي على تلك الكلمة، وهو عادةً عددٌ أصغر بكثيرٍ من N. وأما فيما يتعلق بتحليل الخوارزميات، فإن تلك الخوارزمية تتميز بأقصى قدرٍ من الكفاءة، ولكنها مع ذلك بطيئةٌ لأنها ترسل الكثير من العمليات الصغيرة إلى قاعدة بيانات Redis. من الممكن تحسين أدائها باستخدام معاملةٍ من النوع Transaction. يُمكِنك محاولة تنفيذ ذلك أو الاطلاع على الحل في الملف RedisIndex.java (انتبه إلى أن اسمه في المستودع JedisIndex.java والله أعلم). تحليل أداء عملية الفهرسة ما الزمن الذي تستغرقه فهرسة صفحةٍ عند استخدام هياكل البيانات التي صمّمْناها؟ فكر قبل أن تكمل القراءة. لكي نُفهرِّس صفحةً، فإننا نمرّ عبر شجرة DOM، ونعثر على الكائنات التي تنتمي إلى النوع TextNode، ونُقسِّم السلاسل النصية إلى كلمات بحثٍ. تَستغرِق كل تلك العمليات زمنًا يتناسب مع عدد الكلمات الموجودة في الصفحة. نزيد العدّاد ضمن خريطة النوع HashMap لكل كلمة بحثٍ ضمن الصفحة، وهو ما يَستغرِق زمنًا ثابتًا، ما يَجعَل الصنف TermCounter يَستغرِق زمنًا يتناسب مع عدد الكلمات الموجودة في الصفحة. تتطلَّب إضافة كائن الصنف TermCounter إلى قاعدة بيانات Redis حذف كائنٍ آخرَ منها. يستغرِق ذلك زمنًا يتناسب خطّيًّا مع عدد كلمات البحث. بعد ذلك، علينا أن نُنفِّذ التالي من أجل كل كلمة: نضيف عنصرًا إلى كائنٍ من النوع URLSet. نضيف عنصرًا إلى كائنٍ من النوع TermCounter. تَستغرِق العمليتان السابقتان زمنًا ثابتًا، وبالتالي، يكون الزمن الكليُّ المطلوبُ لإضافة كائنٍ من النوع TermCounter خطيًا مع عدد كلمات البحث الفريدة. نستخلص مما سبق أنّ زمن تنفيذ الصنف TermCounter يتناسب مع عدد الكلمات الموجودة في الصفحة، وأن إضافة كائنٍ ينتمي إلى النوع TermCounter إلى قاعدة بيانات Redis تتطلَّب زمنًا يتناسب مع عدد الكلمات الفريدة. ولمّا كان عدد الكلمات الموجودة في الصفحة يتجاوز عادةً عدد كلمات البحث الفريدة، فإن التعقيد يتناسب طردًا مع عدد الكلمات الموجودة في الصفحة، ومع ذلك، قد تحتوي صفحةٌ ما نظرًيًا على جميع كلمات البحث الموجودة في الفهرس، وعليه، ينتمي الأداء في أسوأ الحالات إلى المجموعة O(M)‎، ولكننا لا نتوقَّع حدوث تلك الحالة عمليًّا. يتضِّح من التحليل السابق أنه ما يزال من الممكن تحسين أداء الخوارزمية، فمثلًا يُمكِننا أن نتجنَّب فهرسة الكلمات الشائعة جدًا، لأنها أولًا تحتل مساحةً كبيرةً من الذاكرة كما أنها تستغرِق وقتًا طويلًا؛ فهي تَظهرَ تقريبًا في جميعِ كائناتِ النوعين URLSet و TermCounter، كما أنها لا تحمل أهميةً كبيرةً فهي لا تساعد على تحديد الصفحات التي يُحتمَل أن تكون ذاتَ صلةٍ بكلمات البحث. تتجنَّب غالبية محركات البحث فهرسةَ الكلماتِ الشائعةِ التي يطلق عليها اسم الكلمات المهملة stop words ضمن هذا السياق. اجتياز بيان graph إذا أكملت تمرين مقالة "تنفيذ أسلوب البحث بالعمق أولًا باستخدام الواجهتين Iterables و Iterators"، فلديك بالفعل برنامجٌ يقرأ صفحةً من موقع ويكيبيديا، ويبحث عن أول رابطٍ فيها، ويَستخدِمه لتحميل الصفحة التالية، ثم يكرر العملية. يُعدّ هذا البرنامج بمنزلةِ زاحفٍ من نوع خاص، فعادةً عندما يُذكرُ مصطلح "زاحف إنترنت" Web crawler، فالمقصود برنامج يُنفِّذ ما يلي: يُحمِّل صفحة بداية معينة، ويُفهرِس محتوياتها. يبحث عن جميع الروابط الموجودة في تلك الصفحة ويضيفها إلى تجميعة collection. يمرّ عبر تلك الروابط، ويُحمِّل كلًّا منها، ويُفهرِسه، ويضيف أثناء ذلك روابطَ جديدة. إذا عَثَر على مُحدّدِ مواردَ مُوحدٍ فهرَسَه من قبل، فإنه يتخطاه. يُمكِننا أن نتصوّر شبكة الإنترنت كما لو كانت شعبة graph. تُمثِل كل صفحة إنترنت عقدةً node في تلك الشعبة، ويُمثِل كل رابط ضلعًا مُوجّهًا directed edge من عقدةٍ إلى عقدةٍ أخرى. يُمكِنك قراءة المزيد عن الشعب إذا لم تكن على معرفةٍ بها. بناءً على هذا التصور، يستطيع الزاحف أن يبدأ من عقدةٍ معيّنةٍ، ويجتاز الشعبة، وذلك بزيارة العقد التي يُمكِنه الوصول إليها مرةً واحدةً فقط. تُحدِّد التجميعة التي سنَستخدِمها لتخزين محددات الموارد المحددة نوع الاجتياز الذي يُنفِّذه الزاحف: إذا كانت التجميعة رتلًا queue، أي تتبع أسلوب "الداخل أولًا، يخرج أولًا" FIFO، فإن الزاحف يُنفِّذ اجتياز السّعة أولًا breadth-first. إذا كانت التجميعة مكدسًا stack، أي تتبع أسلوب "الداخل آخرًا، يخرج أولًا" LIFO، فإن الزاحف يُنفِّذ اجتياز العمق أولًا depth-first. من الممكن تحديد أولويّاتٍ لعناصر التجميعة. على سبيل المثال، قد نرغب في إعطاء أولوية أعلى للصفحات التي لم تُفهرَس منذ فترة طويلة. تمرين 12 والآن، عليك كتابة الزاحف، ستجد ملفات الشيفرة التالية الخاصة بالتمرين في مستودع الكتاب: WikiCrawler.java: يحتوي على شيفرة مبدئيّة للزاحف. WikiCrawlerTest.java: يحتوي على اختبارات وحدة للصنف WikiCrawler. JedisIndex.java: يحتوي على حلِّ تمرينِ مقالة استخدام قاعدة بيانات Redis لحفظ البيانات. ستحتاج أيضًا إلى الأصناف المساعدة التالية التي استخدَمناها في تمارين المقالات السابقة: JedisMaker.java WikiFetcher.java TermCounter.java WikiNodeIterable.java سيتعيّن عليك توفير ملفٍّ يحتوي على بيانات خادم Redis قبل تنفيذ الصنف JedisMaker. إذا أكملت تمرين المقالة مقالة استخدام قاعدة بيانات Redis لحفظ البيانات، فقد جهّزت كل شيء بالفعل، أما إذا لم تكمله، فستجد التعليمات الضرورية لإتمام ذلك في نفس المقالة. نفِّذ الأمر ant build لتصريف ملفات الشيفرة، ثم نفِّذ الأمر ant JedisMaker لكي تتأكّد من أنه مهياٌ للاتصال مع خادم Redis الخاص بك. والآن، نفِّذ الأمر ant WikiCrawlerTest. ستجد أن الاختبارات قد فشلت؛ لأن ما يزال عليك إتمام بعض العمل أولًا. انظر إلى بداية تعريف الصنف WikiCrawler: public class WikiCrawler { public final String source; private JedisIndex index; private Queue<String> queue = new LinkedList<String>(); final static WikiFetcher wf = new WikiFetcher(); public WikiCrawler(String source, JedisIndex index) { this.source = source; this.index = index; queue.offer(source); } public int queueSize() { return queue.size(); } يحتوي هذا الصنف على متغيرات النسخ instance variables التالية: source: مُحدّد الموارد الموحد الذي ستبدأ منه. index: مفهرِس -من النوع JedisIndex- ينبغي أن تُخزَّن النتائج فيه. queue: عبارة عن قائمة من النوع LinkedList. يُفترَض أن تُخزَّن فيها كلُّ محددات الموارد التي عثرت عليها، ولكن لم تُفهرِسها بعد. wf: عبارة عن كائن من النوع WikiFetcher. عليك أن تَستخدِمه لقراءة صفحات الإنترنت وتحليلها. عليك الآن أن تكمل التابع crawl. انظر إلى بصمته: public String crawl(boolean testing) throws IOException {} ينبغي أن تكون قيمة المعامل testing مساويةً للقيمة true إذا كان مستدعيه هو الصنف WikiCrawlerTest، وأن تكون مساويةً للقيمة false في الحالات الأخرى. عندما تكون قيمة المعامل testing مساويةً للقيمة true، يُفترَض أن يُنفِّذ التابع crawl ما يلي: يختار مُحدّدَ موارد موحدًا من الرتل وفقًا لأسلوب "الداخل أولًا، يخرج أولًا" ثم يحذفه منه. يقرأ محتويات تلك الصفحة باستخدام التابع WikiFetcher.readWikipedia الذي يعتمد في قراءته للصفحات على نسخٍ مُخزّنةٍ مؤقتًا في المستودع بهدف الاختبار (لكي نتجنَّب أيَّ مشكلات ممكنة في حال تغيّرت النسخُ الموجودةُ في موقع ويكيبيديا). يُفهرِس الصفحة بغض النظر عما إذا كانت قد فُهرِسَت من قبل. ينبغي أن يجد كل الروابط الداخلية الموجودة في الصفحة ويضيفها إلى الرتل بنفس ترتيب ظهورها. يُقصَد بالروابط الداخلية تلك الروابط التي تشير إلى صفحات أخرى ضمن موقع ويكيبيديا. ينبغي أن يعيد مُحدّد الموارد الخاص بالصفحة التي فهرسها. في المقابل، عندما تكون قيمة المعامل testing مساويةً للقيمة false، يُفترَض له أن يُنفِّذ ما يلي: يختار مُحدّد موارد موحدًا من الرتل وفقًا لأسلوب "الداخل أولًا، يخرج أولًا" ثم يحذفه منه. إذا كان محدّد الموارد المختار مفهرَسًا بالفعل، لا ينبغي أن يعيد فهرسته، وعليه أن يعيد القيمة null. إذا لم يُفهرَس من قبل، فينبغي أن يقرأ محتويات الصفحة باستخدام التابع. WikiFetcher.fetchWikipedia الذي يعتمد في قراءته للصفحات على شبكة الإنترنت. ينبغي أن يُفهرِس الصفحة، ويضيف أيَّ روابطَ موجودةٍ فيها إلى الرتل، ويعيد مُحدّد الموارد الخاصَّ بالصفحة التي فهرسها. يُحمِّل الصنف WikiCrawlerTest رتلًا يحتوي على 200 رابط، ثم يَستدعِي التابع crawl ثلاث مرّات، ويفحص في كلِّ استدعاء القيمةَ المعادة والطولَ الجديد للرتل. إذا أكملت زاحف الإنترنت الخاص بك بشكل صحيح، فينبغي أن تنجح الاختبارات. ترجمة -بتصرّف- للفصل Chapter 15: Crawling Wikipedia من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا تحليل زمن تشغيل الخرائط المنفذة باستخدام مصفوفة في جافا شرح الفروقات بين قواعد بيانات SQL ونظيراتها NoSQL كيفية استيراد وتصدير قواعد بيانات MySQL أو MariaDB
  14. سنُكمِل في التمارين القليلة القادمة بناء محرك البحث الذي تحدثنا عنه في مقالة تنفيذ أسلوب البحث بالعمق أولًا تعاوديًّا وتكراريًّا. يتكوَّن أيّ محرك بحثٍ من الوظائف التالية: الزحف crawling: ويُنفّذُ من خلال برنامجٍ بإمكانه تحميلُ صفحة إنترنت وتحليلُها واستخراجُ النص وأيِّ روابط إلى صفحاتٍ أخرى. الفهرسة indexing: وتنفّذُ من خلال هيكل بيانات data structure بإمكانه البحث عن كلمة والعثور على الصفحات التي تحتوي على تلك الكلمة. الاسترجاع retrieval: وهي طريقةٌ لتجميع نتائج المُفهرِس واختيار الصفحات الأكثر صلة بكلمات البحث. إذا كنت قد أتممت تمرين مقالة استخدام خريطة ومجموعة لبناء مُفهرِّس Indexer، فقد نفَّذت مُفهرسًا بالفعل باستخدام خرائط جافا. سنناقش هذا التمرين هنا وسنُنشِئ نسخةً جديدةً تُخزِّن النتائج في قاعدة بيانات. وإذا كنت قد أكملت تمرين مقالة تنفيذ أسلوب البحث بالعمق أولًا باستخدام الواجهتين Iterables و Iterators، فقد نفَّذت بالفعل زاحفًا يَتبِع أول رابطٍ يعثرُ عليه. سنُنشِئ في التمرين التالي نسخةً أعمّ تُخزِّن كل رابطٍ تجده في رتل queue، وتتبع تلك الروابط بالترتيب. في النهاية، ستُكلّف بالعمل على برنامج الاسترجاع. سنُوفِّر شيفرةً مبدئيّةً أقصر في هذه التمارين، وسنعطيك فرصةً أكبر لاتخاذ القرارات المتعلقة بالتصميم. وتجدر الإشارة إلى أن هذه التمارين ذات نهايات مفتوحة، أي سنطرح عليك فقط بعض الأهداف البسيطة التي يتعين عليك الوصول إليها، ولكنك تستطيع بالطبع المُضِي قدمًا إذا أردت المزيد من التحدي. والآن، سنبدأ بالنسخة الجديدة من المُفهرِس. قاعدة بيانات Redis تُخزِّن النسخة السابقة من المُفهرِس البيانات في هيكلَيْ بياناتٍ: الأول هو كائنٌ من النوع TermCounter يَربُط كل كلمة بحثٍ بعدد المرات التي ظهرت فيها الكلمة في صفحة إنترنت معينةٍ، والثاني كائنٌ من النوع Index يربُط كلمة البحث بمجموعة الصفحات التي ظهرت فيها. يُخزَّن هيكلا البيانات في ذاكرة التطبيق، ولذا يتلاشيان بمجرد انتهاء البرنامج. توصف البيانات التي تُخزَّن فقط في ذاكرة التطبيق بأنها "متطايرة volatile"؛ لأنها تزول بمجرد انتهاء البرنامج. في المقابل، تُوصف البيانات التي تظل موجودةً بعد انتهاء البرنامج الذي أنشأها بأنها "مستمرة persistent". مثال ذلك الملفات المُخزَّنة في نظام الملفات فهي مُستمرة في العموم، وكذلك البيانات المُخزَّنة في قاعدة بيانات أيضًا مستمرة. يُعدّ تخزين البيانات في ملف واحدة من أبسط طرق حفظ البيانات، فيُمكِننا ببساطة أن نحوِّل هياكل البيانات المُتضمِّنة للبيانات إلى صيغة JSON ثم نكتبها إلى ملف قبل انتهاء البرنامج. عندما نُشغِّل البرنامج مرة أخرى، سنتمكَّن من قراءة الملف وإعادة بناء هياكل البيانات. ولكن هناك مشكلتان في هذا الحل: عادةً ما تكون عمليتا قراءة هياكل البيانات الضخمة (مثل مفهرس الويب) وكتابتها عمليتين بطيئتين. قد لا تستوعب مساحة ذاكرة برنامج واحد هيكل البيانات بأكمله. إذا انتهى برنامجٌ معينٌ على نحوٍ غير متوقع (نتيجة لانقطاع الكهرباء مثلًا)، فسنفقد جميع التغييرات التي أجريناها على البيانات منذ آخر مرة فتحنا فيها البرنامج. وهناك طريقة أخرى لحفظ البيانات وهي قواعد البيانات. إذ تُعدّ قواعدُ البيانات البديلَ الأفضلَ، فهي تُوفِّر مساحةَ تخزينٍ مستمرةً، كما أنها قادرةٌ على قراءة جزءٍ من قاعدة البيانات أو كتابته دون الحاجة إلى قراءة قاعدة البيانات أو كتابتها بالكامل. تتوفَّر الكثير من أنواع نظم إدارة قواعد البيانات DBMS، ويتمتع كلٌّ منها بإمكانيات مختلفة. ويُمكِنك قراءة مقارنة بين أنظمة إدارة قواعد البيانات العلاقية والاطلاع على سلسلة تصميم قواعد البيانات. تُوفِّر قاعدة بيانات Redis -التي سنَستخدِمها في هذا التمرين- هياكل بيانات مستمرة مشابهةً لهياكل البيانات التي تُوفِّرها جافا، فهي تُوفِّر: قائمة سلاسل نصية مشابهة للنوع List. جداول مشابهة للنوع Map. مجموعات من السلاسل النصية مشابهة للنوع Set. تُعدّ Redis قاعدة بيانات من نوع زوج مفتاح/قيمة، ويَعني ذلك أن هياكل البيانات (القيم) التي تُخزِّنها تكون مُعرَّفةً بواسطة سلاسل نصية ٍفريدةٍ (مفاتيح). تلعب المفاتيح في قاعدة بيانات Redis نفس الدور الذي تلعبه المراجع references في لغة جافا، أي أنّها تُعرِّف هوية الكائنات. سنرى أمثلةً على ذلك لاحقًا. خوادم وعملاء Redis عادةً ما تَعمَل قاعدة بيانات Redis كخدمةٍ عن بعد، فكلمة Redis هي في الحقيقة اختصار لعبارة "خادم قاموس عن بعد REmote DIctionary Server"، ولنتمكن من استخدامها، علينا أن نُشغِّل خادم Redis في مكانٍ ما ثم نَتصِل به عبر عميل Redis. من الممكن إعداد ذلك الخادم بأكثرَ من طريقةٍ، كما يُمكِننا الاختيار من بين العديد من برامج العملاء، وسنَستخدِم في هذا التمرين ما يلي: بدلًا من أن نُثبِّت الخادم ونُشغِّله بأنفسنا، سنَستخدِم خدمةً مثل RedisToGo. تُشغِّل تلك الخدمة قاعدة بيانات Redis على السحابة cloud، وتُقدِّم خطةً مجانيةً بمواردَ تتناسب مع متطلبات هذا التمرين. بالنسبة للعميل، سنَستخدِم Jedis، وهو عبارةٌ عن مكتبة جافا تحتوي على أصنافٍ وتوابعَ يُمكِنها العمل مع قاعدة بيانات Redis. انظر إلى التعليمات المُفصَّلة التالية لمساعدتك على البدء: أنشِئ حسابًا على موقع RedisToGo، واختر الخطة التي تريدها (ربما الخطة المجانية). أنشِئ نسخة آلة افتراضية يعمل عليها خادم Redis. إذا نقرت على تبويب "Instances"، ستجد أن النسخة الجديدة مُعرَّفة باسم استضافة ورقم منفذ. كان اسم النسخة الخاصة بنا مثلًا هو dory-10534. انقر على اسم النسخة لكي تفتح صفحة الإعدادات، وسجِّل اسم مُحدّد الموارد الموحد URL الموجود أعلى الصفحة. سيكون مشابهًا لما يلي: redis://redistogo:1234567feedfacebeefa1e1234567@dory.redistogo.com:10534 يحتوي محدد الموارد الموحد السابق ذكره على اسم الاستضافة الخاص بالخادم dory.redistogo.com، ورقم المنفذ 10534، وكلمة المرور التي سنحتاج إليها للاتصال بالخادم، وهي السلسلة النصية الطويلة المُكوَّنة من أحرف وأعدادٍ في المنتصف. ستحتاج تلك المعلومات في الخطوة التالية. إنشاء مفهرس يعتمد على Redis ستجد الملفات التالية الخاصة بالتمرين في مستودع الكتاب: JedisMaker.java: يحتوي على بعض الأمثلة على الاتصال بخادم Redis وتشغيل بعض توابع Jedis. JedisIndex.java: يحتوي على شيفرةٍ مبدئيةٍ لهذا التمرين. JedisIndexTest.java: يحتوي على اختبارات للصنف JedisIndex. WikiFetcher.java: يحتوي على شيفرةٍ تقرأ صفحات إنترنت وتُحلِّلها باستخدام مكتبة jsoup. كتبنا تلك الشيفرة في تمارين المقالات المشار إليها بالأعلى. ستجد أيضًا الملفات التالية التي كتبناها في نفس تلك التمارين: Index.java: يُنفِّذ مفهرِسًا باستخدام هياكل بيانات تُوفِّرها جافا. TermCounter.java: يُمثِل خريطةً تربُط كلمات البحث بعدد مرات حدوثها. WikiNodeIterable.java: يمرّ عبر عقد شجرة DOM الناتجة من مكتبة jsoup. إذا تمكَّنت من كتابة نسخك الخاصة من تلك الملفات، يُمكِنك استخدامها لهذا التمرين. إذا لم تكن قد أكملت تلك التمارين أو أكملتها ولكنك غير متأكّد مما إذا كانت تَعمَل على النحو الصحيح، يُمكِنك أن تَنسَخ الحلول من مجلد solutions. والآن، ستكون خطوتك الأولى هي استخدام عميل Jedis للاتصال بخادم Redis الخاص بك. يُوضِّح الصنف RedisMaker.java طريقة القيام بذلك: عليه أولًا أن يقرأ معلومات الخادم من ملفٍّ، ثم يتصل به، ويُسجِل دخوله باستخدام كلمة المرور، وأخيرًا، يُعيد كائنًا من النوع Jedis الذي يُمكِن استخدامه لتنفيذ عمليات Redis. ستجد الصنف JedisMaker مُعرَّفًا في الملف JedisMaker.java. يَعمَل ذلك الصنف كصنف مساعد حيث يحتوي على التابع الساكن make الذي يُنشِئ كائنًا من النوع Jedis. يُمكِنك أن تَستخدِم ذلك الكائن بعد التصديق عليه للاتصال بقاعدة بيانات Redis الخاصة بك. يقرأ الصنف JedisMaker بيانات خادم Redis من ملفٍّ اسمه redis_url.txt موجودٍ في المجلد src/resources: استخدم مُحرّرَ نصوصٍ لإنشاء وتعديل الملف ThinkDataStructures/code/src/resources/redis_url.txt. ضع فيه مُحدّد موارد الخادم الخاص بك. إذا كنت تَستخدِم خدمة RedisToGo، سيكون محدد الموارد مشابهًا لما يلي: redis://redistogo:1234567feedfacebeefa1e1234567@dory.redistogo.com:10534 لا تضع هذا الملف في مجلدٍ عامٍّ لأنه يحتوي على كلمة مرور خادم Redis. يُمكِنك تجنُّب وقوع ذلك عن طريق الخطأ باستخدام الملف ‎.gitignore الموجود في مستودع الكتاب لمنع وضع الملف فيه. نفِّذ الأمر ant build لتصريف ملفات الشيفرة والأمر ant JedisMaker لتشغيل المثال التوضيحيّ بالتابع main: public static void main(String[] args) { Jedis jedis = make(); // String jedis.set("mykey", "myvalue"); String value = jedis.get("mykey"); System.out.println("Got value: " + value); // Set jedis.sadd("myset", "element1", "element2", "element3"); System.out.println("element2 is member: " + jedis.sismember("myset", "element2")); // List jedis.rpush("mylist", "element1", "element2", "element3"); System.out.println("element at index 1: " + jedis.lindex("mylist", 1)); // Hash jedis.hset("myhash", "word1", Integer.toString(2)); jedis.hincrBy("myhash", "word2", 1); System.out.println("frequency of word1: " + jedis.hget("myhash", "word1")); System.out.println("frequency of word1: " + jedis.hget("myhash", "word2")); jedis.close(); } يَعرِض المثال أنواع البيانات والتوابع التي ستحتاج إليها غالبًا في هذا التمرين. ينبغي أن تحصل على الخرج التالي عند تشغيله: Got value: myvalue element2 is member: true element at index 1: element2 frequency of word1: 2 frequency of word2: 1 سنشرح طريقة عمل تلك الشيفرة في القسم التالي. أنواع البيانات في قاعدة بيانات Redis تَعمَل Redis كخريطةٍ تربط مفاتيحَ (سلاسل نصيّة) بقيم. قد تنتمي تلك القيم إلى مجموعة أنواع مختلفة من البيانات. يُعدّ النوع string واحدًا من أبسط الأنواع التي تُوفِّرها قاعدة بيانات Redis. لاحظ أننا سنكتب أنواع بيانات Redis بخطوط مائلة لنُميزها عن أنواع جافا. سنَستخدِم التابع jedis.set لإضافة سلسلةٍ نصيّةٍ من النوع string إلى قاعدة البيانات. قد تجد ذلك مألوفًا، فهو يشبه التابع Map.put، حيث تُمثِل المعاملات المُمرَّرة المفتاح الجديد وقيمته المقابلة. في المقابل، يُستخدَم التابع jedis.get للبحث عن مفتاحٍ معينٍ والعثور على قيمته. انظر الشيفرة إلى التالية: jedis.set("mykey", "myvalue"); String value = jedis.get("mykey"); كان المفتاح هو "mykey" والقيمة هي "myvalue" في هذا المثال. تُوفِّر Redis هيكل البيانات set الذي يَعمَل بشكلٍ مشابهٍ للنوع Set<String>‎ في جافا. إذا أردت أن تضيف عنصرًا جديدًا إلى مجموعةٍ من النوع set، اختر مفتاحًا يُحدّد هوية تلك المجموعة، ثم اِستخدِم التابع jedis.sadd كما يلي: jedis.sadd("myset", "element1", "element2", "element3"); boolean flag = jedis.sismember("myset", "element2"); لاحِظ أنه ليس من الضروري إنشاء المجموعة بخطوةٍ منفصلةٍ، حيث تُنشؤها Redis إن لم تكن موجودةً. تُنشِئ Redis في هذا المثال مجموعةً من النوع set اسمها myset تحتوي على ثلاثة عناصر. يفحص التابع jedis.sismember ما إذا كان عنصر معين موجودًا في مجموعة من النوع set. تَستغرِق عمليتا إضافة العناصر وفحص عضويّتها زمنًا ثابتًا. تُوفِّر Redis أيضًا هيكل البيانات list الذي يشبه النوع List<String>‎ في جافا. يضيف التابع jedis.rpush العناصر إلى النهاية اليمنى من القائمة، كما يلي: jedis.rpush("mylist", "element1", "element2", "element3"); String element = jedis.lindex("mylist", 1); مثلما سبق، لا يُعدّ إنشاء هيكل البيانات قبل إضافة العناصر إليه أمرًا ضروريًا. يُنشِئ هذا المثال قائمةً من النوع list اسمها mylist تحتوي على ثلاثة عناصر. يَستقبِل التابع jedis.lindex فهرسًا هو عبارةٌ عن عددٍ صحيحٍ، ويعيد عنصرَ القائمةِ المشارَ إليه. تَستغرِق عمليتا إضافة العناصر واسترجاعها زمنًا ثابتًا. أخيرًا، تُوفِّر Redis الهيكل hash الذي يشبه النوع Map<String, String>‎ في جافا. يضيف التابع jedis.hset مُدخَلًا جديدًا إلى الجدول على النحو التالي: jedis.hset("myhash", "word1", Integer.toString(2)); String value = jedis.hget("myhash", "word1"); يُنشِئ هذا المثال جدولًا جديدًا اسمه myhash يحتوي على مدخلٍ واحدٍ يربُط المفتاح word1 بالقيمة "2". تنتمي المفاتيح والقيم إلى النوع string، ولذلك، إذا أردنا أن نُخزِّن عددًا صحيحًا من النوع Integer، علينا أن نُحوِّله أولًا إلى النوع String قبل أن نَستدعِي التابع hset. وبالمثل، عندما نبحث عن قيمة باستخدام التابع hget، ستكون النتيجة من النوع String، ولذلك، قد نضطرّ إلى تحويلها مرةً أخرى إلى النوع Integer. قد يكون العمل مع النوع hash في قاعدة بيانات Redis مربكًا نوعًا ما؛ لأننا نَستخدِم مفتاحين، الأول لتحديد الجدول الذي نريده، والثاني لتحديد القيمة التي نريدها من الجدول. في سياق قاعدة بيانات Redis، يُطلَق على المفتاح الثاني اسم "الحقل field"، أي يشير مفتاح مثل myhash إلى جدولٍ معينٍ من النوع hash بينما يشير حقلٌ مثل word1 إلى قيمةٍ ضمن ذلك الجدول. عادةً ما تكون القيم المُخزَّنة في جداول من النوع hash أعدادًا صحيحة، ولذلك، يوفِّر نظام إدارة قاعدة بيانات Redis بعض التوابع الخاصة التي تعامل القيم وكأنها أعداد مثل التابع hincrby. انظر إلى المثال التالي: jedis.hincrBy("myhash", "word2", 1); يسترجع هذا التابع المفتاح myhash، ويحصل على القيمة الحالية المرتبطة بالحقل word2 (أو على الصفر إذا لم يكن الحقل موجودًا)، ثم يزيدها بمقدار 1، ويكتبها مرةً أخرى في الجدول. تستغرق عمليات إضافة المُدْخَلات إلى جدول من النوع hash واسترجاعها وزيادتها زمنًا ثابتًا. تمرين 11 بوصولك إلى هنا، أصبح لديك كل المعلومات الضرورية لإنشاء مُفهرسٍ قادرٍ على تخزين النتائج في قاعدة بيانات Redis. والآن، نفِّذ الأمر ant JedisIndexTest. ستفشل بعض الاختبارات لأنه ما يزال أمامنا بعض العمل. يختبر الصنف JedisIndexTest التوابع التالية: JedisIndex: يستقبل هذا الباني كائنًا من النوع Jedis كمعامل. indexPage: يضيف صفحة إنترنت إلى المفهرس. يَستقبِل سلسلةً نصيّةً من النوع String تُمثِل مُحدّد موارد URL بالإضافة إلى كائن من النوع Elements يحتوي على عناصر الصفحة المطلوب فهرستها. getCounts: يستقبل كلمةَ بحثٍ ويعيد خريطةً من النوع Map<String, Integer>‎ تربُط كل محدد مواردَ يحتوي على تلك الكلمة بعدد مرات ظهورها في تلك الصفحة. يُوضِح المثال التالي طريقة استخدامِ تلك التوابع: WikiFetcher wf = new WikiFetcher(); String url1 = "http://en.wikipedia.org/wiki/Java_(programming_language)"; Elements paragraphs = wf.readWikipedia(url1); Jedis jedis = JedisMaker.make(); JedisIndex index = new JedisIndex(jedis); index.indexPage(url1, paragraphs); Map<String, Integer> map = index.getCounts("the"); إذا بحثنا عن url1 في الخريطة الناتجة map، ينبغي أن نحصل على 339، وهو عدد مرات ظهور كلمة "the" في مقالة ويكيبيديا عن لغة جافا (نسخة المقالة التي خزّناها). إذا حاولنا فهرسة الصفحة مرةً أخرى، ستحُلّ النتائج الجديدة محل النتائج القديمة. إذا أردت تحويل هياكل البيانات من جافا إلى Redis، فتذكّر أن كل كائنٍ مُخزّنٍ في قاعدة بيانات Redis مُعرَّفٌ بواسطة مفتاحٍ فريدٍ من النوع string. إذا كان لديك نوعان من الكائنات في نفس قاعدة البيانات، فقد ترغب في إضافة كلمةٍ إلى بداية المفاتيح لتمييزها عن بعضها. على سبيل المثال، لدينا النوعان التاليان من الكائنات: يُمثِل النوع URLSet مجموعةً من النوع set في قاعدة بيانات Redis. تحتوي تلك المجموعة على محددات الموارد الموحدة URL التي تحتوي على كلمةٍ بحثٍ معينة. يبدأ المفتاح الخاص بكل قيمةٍ من النوع URLSet بكلمة":URLSet"، وبالتالي، لكي نحصل على محددات الموارد الموحدة التي تحتوي على كلمة "the"، علينا أن نسترجع المجموعة التي مفتاحها هو"URLSet:the". يُمثِل النوع TermCounter جدولًا من النوع hash في قاعدة بيانات Redis. يربُط هذا الجدول كل كلمة بحث ظهرت في صفحة معيّنة بعدد مرات ظهورها. يبدأ المفتاح الخاصُّ بكل قيمة من النوع TermCounter بكلمة "TermCounter:‎" وينتهي بمحدد الموارد الموحّدِ الخاص بالصفحة التي نبحث فيها. يحتوي التنفيذ الخاص بنا على قيمةٍ من النوع URLSet لكل كلمة بحثٍ، وعلى قيمةٍ من النوع TermCounter لكل صفحة مُفهرسَة. وفَّرنا أيضًا التابعين المساعدين urlSetKey و termCounterKey لإنشاء تلك المفاتيح. المزيد من الاقتراحات أصبح لديك الآن كل المعلومات الضرورية لحل التمرين، لذا يُمكِنك أن تبدأ الآن إذا أردت، ولكن ما يزال هناك بعض الاقتراحات القليلة التي ننصحك بقراءتها: سنوفِّر لك مساعدةً أقلّ في هذا التمرين، ونترك لك حريّة أكبر في اتخاذ بعض القرارات المتعلقة بالتصميم. سيكون عليك تحديدًا أن تُفكِّر بالطريقة التي ستُقسِّم بها المشكلة إلى أجزاءَ صغيرةٍ يُمكِن اختبار كلٍّ منها على حدة. بعد ذلك، ستُجمِّع تلك الأجزاء إلى حلٍّ كاملٍ. إذا حاولت كتابة الحل بالكامل على خطوةٍ واحدةٍ بدون اختبار الأجزاء الأصغر، فقد تستغرِق وقتًا طويلًا جدًا لتنقيح الأخطاء. تُمثِّل الاستمرارية واحدةً من تحديات العمل مع البيانات المستمرة، لأن الهياكل المُخزَّنة في قواعد البيانات قد تتغير في كل مرةٍ تُشغِّل فيها البرنامج. فإذا تسبَّبت بخطأٍ في قاعدة البيانات، سيكون عليك إصلاحه أو البدء من جديد. ولكي نُبقِي الأمور تحت السيطرة، وفّرنا لك التوابع deleteURLSets و deleteTermCounters و deleteAllKeys التي تستطيع أن تَستخدِمها لتنظيف قاعدة البيانات والبدء من جديد. يُمكِنك أيضًا استخدام التابع printIndex لطباعة محتويات المُفهرِس. في كلّ مرةٍ تستدعي فيها أيًّا من توابع Jedis، فإنه يُرسِل رسالةً إلى الخادم الذي يُنفِّذ بدوره الأمر المطلوب، ثم يردّ برسالة. إذا نفَّذت الكثير من العمليات الصغيرة، فستحتاج إلى وقت طويل لمعالجتها، ولهذا، من الأفضل تجميع متتالية من العمليات ضمن معاملة واحدة من النوع Transaction لتحسين الأداء. على سبيل المثال، انظر إلى تلك النسخة البسيطة من التابع deleteAllKeys: public void deleteAllKeys() { Set<String> keys = jedis.keys("*"); for (String key: keys) { jedis.del(key); } } في كل مرةٍ تَستدعِي خلالها التابع del، يضطرّ العميل إلى إجراء اتصالٍ مع الخادم وانتظار الرد. إذا كان المُفهرِس يحتوي على بضع صفحاتٍ، فقد يَستغرِق تنفيذ ذلك التابع وقتًا طويلًا. بدلًا من ذلك، يُمكِنك أن تُسرِّع تلك العملية باستخدام كائنٍ من النوع Transaction على النحو التالي: public void deleteAllKeys() { Set<String> keys = jedis.keys("*"); Transaction t = jedis.multi(); for (String key: keys) { t.del(key); } t.exec(); } يعيد التابع jedis.multi كائنًا من النوع Transaction. يُوفِّر هذا الكائن جميع التوابع المتاحة في كائنات النوع Jedis. عندما تستدعي أيًّا من تلك التوابع بكائنٍ من النوع Transaction، فإن العميل لا يُنفِّذها تلقائيًا، ولا يتصل مع الخادم، وإنما يُخزِّن تلك العمليات إلى أن تَستدعِي التابع exec، وعندها، يُرسِل جميعَ العملياتِ المُخزَّنة إلى الخادم في نفس الوقت، وهو ما يكون أسرع عادةً. تلميحات بسيطة بشأن التصميم الآن وقد أصبح لديك جميع المعلومات المطلوبة، يمكنك البدء في حل التمرين. إذا لم يكن لديك فكرةٌ عن طريقة البدء، يُمكِنك العودة لقراءة المزيد من التلميحات البسيطة. لا تتابع القراءة قبل أن تُشغِّل شيفرة الاختبار، وتُجرِّب بعض أوامر Redis البسيطة، وتكتب بعض التوابع الموجودة في الملف JedisIndex.java. إذا لم تتمكّن من متابعة الحل فعلًا، إليك بعض التوابع التي قد ترغب في العمل عليها: /** * أضف محدد موارد موحدًا إلى المجموعة الخاصة بكلمة */ public void add(String term, TermCounter tc) {} /** * ابحث عن كلمة وأعد مجموعة مُحدِّدات الموارد الموحدة التي تحتوي عليها */ public Set<String> getURLs(String term) {} /** * أعد عدد مرات ظهور كلمة معينة بمحدد موارد موحد */ public Integer getCount(String url, String term) {} /** * ‫أضف محتويات كائن من النوع `TermCounter` إلى قاعدة بيانات Redis */ public List<Object> pushTermCounterToRedis(TermCounter tc) {} تلك هي التوابع التي استخدمناها في الحل، ولكنها ليست بالتأكيد الطريقة الوحيدة لتقسيم المشكلة. لذلك، استعن بتلك الاقتراحات فقط إذا وجدتها مفيدة، وتجاهلها تمامًا إذا لم تجدها كذلك. اكتب بعض الاختبارات لكل تابعٍ منها أولًا، فبمعرفة الطريقة التي ينبغي بها أن تختبر تابعًا معينًا، عادةً ما تتكوَّن لديك فكرةٌ عن طريقة كتابته. ترجمة -بتصرّف- للفصل Chapter 14: Persistence من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا تحليل نظام الملفات لإدارة البيانات وتخزينها واختلافه عن نظام قاعدة البيانات شرح الفروقات بين قواعد بيانات SQL ونظيراتها NoSQL كيفية استيراد وتصدير قواعد بيانات MySQL أو MariaDB
  15. عند تخزين بياناتٍ معينة بملفٍ معين أو نقلها خلال شبكةٍ معينة، سيكون من الضروري تمثيلها بطريقةٍ ما بشرط أن تَسمَح تلك الطريقة بإعادة بناء البيانات لاحقًا عند قراءة الملف أو عند تَسلُّم البيانات. رأينا بالفعل أسبابًا جيدةً تدفعنا لتفضيل التمثيلات النصية المُعتمِدة على المحارف، ومع ذلك هناك طرائقٌ كثيرةٌ لتمثيل مجموعةٍ معينةٍ من البيانات تمثيلًا نصيًا. سنُقدِّم بهذا المقال لمحةً مختصرةً عن أحد أكثر أنواع تمثيل البيانات data representation شيوعًا. تُعدّ لغة الترميز القابلة للامتداد eXtensible Markup Language -أو اختصارًا XML- أسلوبًا لإنشاء لغاتٍ لتمثيل البيانات. هناك جانبان أو مستويان بلغة XML؛ حيث تُخصِّص XML في المستوى الأول قواعد صيغة صارمةً ولكنها بسيطةً نوعًا ما، وتُعدّ أي متتالية محارف تَتبِع تلك القواعد مستند XML سليم؛ بينما تُوفِّر XML على المستوى الآخر طريقةً لإضافة المزيد من القيود على ما يُمكِن كتابته بالمستند من خلال ربط مستند XML معين بما يُعرَف باسم "تعريف نوع المستند Document Type Definition" -أو اختصارًا DTD- الذي يُخصِّص قائمةً بالأشياء المسموح بكتابتها بمستند XML. يُقال على مستندات XML السليمة المُرتبِطَة بتعريف نوع مستند معين والمُتبعِّة للقواعد التي خصَّصها ذلك التعريف "مستندات XML صالحة". يُمكِننا النظر إلى لغة XML كما لو كانت صيغةً عامةً لتمثيل البيانات؛ في حين تُخصِّص DTD الكيفية التي ينبغي بها استخدام XML لتمثيل نوعٍ معينٍ من البيانات. تتوفَّر بدائلٌ أخرى بخلاف DTD، مثل XML schemas لتعريف مستندات XML صالحة، ولكننا لن نناقشها هنا. لا يوجد شيءٌ سحريٌ بخصوص XML، فهي ليست مثالية، وتُعدُّ في الحقيقة لغةً مُسهبةً verbose ويراها البعض قبيحة، ولكنها من الناحية الأخرى مرنةٌ للغاية؛ حيث يُمكِن اِستخدَامها لتمثيل أي نوعٍ من البيانات تقريبًا. لقد صُمِّمت من البداية لتدعم جميع لغات البرمجة، والأهم من ذلك، أنها قد أصبحت بالفعل معيارًا مقبولًا على نطاقٍ واسع، فبإمكان جميع لغات البرمجة معالجة مستندات XML، بل ويتوفَّر تعريف نوع مستند DTD قياسي لوصف مختلف أنواع البيانات. هناك طرائقٌ مختلفةٌ كثيرة لتصميم لغةٍ لتمثيل مجموعةٍ من البيانات، ولكن حظت XML بشعبيةٍ واسعة، بل إنها حتى قد وجدت طريقها إلى جميع تقنيات المعلومات. على سبيل المثال، هناك لغات XML لتمثيل كُلٍ من التعبيرات الحسابية "MathML" والرموز الموسيقية "MusicXML" والجزيئات والتفاعلات الكيميائية "CML" ورسومات الفيكتور "SVG" وغيرها من المعلومات. بالإضافة إلى ذلك، تَستخِدم برامج OpenOffice والإصدارات الأحدث من Microsoft Office لغة XML بصيغ المستندات لتطبيقات معالجة النصوص وجداول البيانات spreadsheets والعروض. مثالٌ آخر هو لغة مشاركة المواقع "RSS, ATOM"، التي سهَّلت على المواقع الإلكترونية والمدونات والجرائد الإخبارية إنشاء قائمةٍ بالعناوين الأحدث المتاحة بصيغةٍ قياسيةٍ قابلةٍ للِاستخدَام بواسطة مواقع ومتصفحات الويب، وهي في الواقع نفس الصيغة المُستخدَمة لنشر المدونات الصوتية podcasts. تُعدّ XML عمومًا الصيغة الأكثر شيوعًا لتبادل المعلومات إلكترونيًا. ليس الهدف هنا هو إطلاعك على كل شيء يُمكِن معرفته عن XML، وإنما سنشرح فقط طرائقًا قليلةً يَصلُح معها استخدام XML بالبرامج؛ أي أننا لن نناقش أي شيءٍ آخر عن تعريفات نوع المستند DTD، أو مستندات XML الصالحة، وإنما سنَكتفِي باستخدام مستندات XML سليمة دون ربطها بأي تعريفاتٍ لنوع المستند DTD؛ فهي عادةً ما تكون كافيةً للعديد من الأغراض. قواعد صيغة بسيطة للغة XML إذا كنت تعرف لغة HTML -اللغة المُستخدَمة لكتابة صفحات الإنترنت-، فستبدو لغة XML مألوفةً بالنسبة لك، حيث تشبه مستندات XML كثيرًا مستندات HTML. لا تُعدّ لغة HTML هي نفسها لغة XML؛ فهي لا تَتبِع جميع قواعد الصيغة الصارمة الخاصة بلغة XML، ولكن الأفكار الأساسية هي نفسها. نعرض فيما يلي مثالًا قصيرًا على مستند XML سليم: <?xml version="1.0"?> <simplepaint version="1.0"> <background red='1' green='0.6' blue='0.2'/> <curve> <color red='0' green='0' blue='1'/> <symmetric>false</symmetric> <point x='83' y='96'/> <point x='116' y='149'/> <point x='159' y='215'/> <point x='216' y='294'/> <point x='264' y='359'/> <point x='309' y='418'/> <point x='371' y='499'/> <point x='400' y='543'/> </curve> <curve> <color red='1' green='1' blue='1'/> <symmetric>true</symmetric> <point x='54' y='305'/> <point x='79' y='289'/> <point x='128' y='262'/> <point x='190' y='236'/> <point x='253' y='209'/> <point x='341' y='158'/> </curve> </simplepaint> يُعدّ السطر الأول بالأعلى اختياريًا، حيث يُشير ببساطةٍ إلى أن ما يليه هو مستند XML. قد يتضمَّن هذا السطر معلوماتٍ أخرى أيضًا، مثل الترميز المُستخدَم لترميز محارف المستند إلى صيغةٍ ثنائية. وقد يتضمَّن المستند سطرًا للموجه "DOCTYPE" لتحديد تعريف نوع المستند. يتكوَّن المستند بخلاف السطر الأول من مجموعةٍ من العناصر elements والسمات attributes والمحتويات النصية. يبدأ أي عنصرٍ بوسم tag، مثل <curve> وينتهي بوسم الغلق المقابل، مثل </curve>، ونجد محتويات العنصر بين هذين الوسمين، والتي يُمكِن أن تتكوَّن من نصٍ وعناصرٍ متداخلة أخرى. يُعدّ المحتوى النصي الوحيد بالمثال السابق هو القيمة true أو false بالعنصر <symmetric>. إذا لم يحتوِ عنصرٌ معينٌ على أي شيء، يُمكِننا دمج وسميه للفتح والغلق بوسمٍ واحدٍ فارغ بكتابة الاختصار ‎<point x='83' y='96'/>‎ بدلًا من <point x='83' y='96'></point>. لاحِظ استخدام "/" قبل "<". قد يتضمَّن الوسم عِدّة سمات، مثل x و y بالوسم ‎<point x='83' y='96'/>‎، و version بالوسم <simplepaint version="1.0">. قد يتضمَّن المستند أشياءً قليلةً أخرى، مثل التعليقات ولكننا لن نناقشها هنا. ينبغي لأي مؤلف مستند XML سليم أن يختار أسماء الوسوم والسمات، وبطبيعة الحال عليه اختيار أسماءٍ ذات معنًى مفهوم للقارئ. في المقابل، إذا كان المستند يعتمد على تعريف نوع مستند DTD معين، فإن مؤلف التعريف هو من يختار أسماء الوسوم. يجب على أي مستند XML سليم أن يَتبِّع مجموعةً صارمةً من قواعد الصياغة syntax، نُلخِّص أهمها فيما يلي: تُميِّز أسماء الوسوم والسمات بمستندات XML بين حالة الأحرف case sensitive؛ فقد تتكوَّن الأسماء من مجموعةٍ من الأحرف والأرقام وبعض المحارف الأخرى، ولكن لا بُدّ أن تبدأ بحرف. ليس للفراغات ومحرف نهاية السطر أي أهمية إلا إذا وقعت بالمحتويات النصية؛ فعند كتابة وسمٍ معينٍ، ولم يَكُن هذا الوسم فارغًا، فيجب إذًا أن يُقابِله وسم غلق؛ ويَعنِي ذلك أنه إذا تداخلت عدة عناصرٍ مع بعضها، فيجب أن يكون التداخل سليمًا. بتعبيرٍ آخر، إذا تضمَّن عنصرٌ معينٌ وسمًا، فيجب أن يَقع وسم غلقه ضمن نفس العنصر. لا بُدّ أن يحتوي أي مستندٍ على عنصر جذر root، والذي يحتوي بدوره على جميع العناصر الأخرى. لاحِظ أن اسم الوسم المُمثِّل لعنصر الجذر بالمثال السابق، هو simplepaint. لا بُدّ أن يكون لأي سمةٍ قيمة، والتي ينبغي أن تكون محاطةً بعلامتي اقتباس إما مفردة أو مزدوجة. في حالة اِستخدَام المحارف الخاصة > و & بقيمةٍ لسمة أو بمحتوى نصي، فيجب أن تُكْتَب على النحو التالي ‎&gt;‎ و ‎&quot;‎. أمثلة أخرى على ذلك، هي ‎&gt;‎ و ‎&quot;‎ و ‎&apos;‎، والتي تُمثِّل > وعلامة اقتباسٍ مزدوجة وعلامة اقتباسٍ مفردة على الترتيب. يُمكِن تعريف المزيد بتعريف DTD. يُعدّ ما سَبَق مقدمةً سريعةً عن مستندات XML، والتي ربما لن تساعدك على فهم كل شيء تقابله بمستند XML، ولكنها ستُمكِّنك على الأقل من تصميم مستندات XML سليمة لتمثيل بعض بنى البيانات data structures المُستخدَمة ببرامج جافا. العمل مع شجرة DOM صمَّمنا في الأعلى مثال XML لتخزين معلوماتٍ عن رسومٍ بسيطة يَرسِمها المُستخدِم، واعتمدنا على البرنامج التوضيحي SimplePaint2.java من مقال مفهوم المصفوفات الديناميكية (ArrayLists) في جافا فيما هو مُتعلِّقٌ برسم تلك الرسوم. سنناقش بهذا المقال نسخةً أخرى من نفس البرنامج، والتي يُمكِنها حفظ رسوم المُستخدِم بملف بياناتٍ معتمِدٍ على صيغة XML. يُمكِنك الإطلاع على شيفرة النسخة الجديدة من SimplePaintWithXML.java. سنَستخدِم مستند XML التوضيحي الذي عرضناه بالأعلى ضمن هذا البرنامج. في الحقيقة، لقد صمَّمنا هذا المستند بحيث يحتوي على جميع البيانات المطلوبة لإعادة إنشاء الكائن المُمثِّل للرسمة، والذي ينتمي للصنف SimplePaint؛ حيث يتضمَّن المستند على سبيل المثال معلوماتٍ عن لون خلفية الصورة وكذلك قائمةً بالمنحنيات التي رَسَمها المُستخدِم. لاحِظ احتواء كل عنصر <curve> على بيانات كائنٍ من النوع CurveData. من السهل كتابة برنامج يُخرِج البيانات بصيغة XML، مع الحرص طبعًا على اتباع جميع قواعد صيغة XML، حيث تبيّن الشيفرة التالية الطريقة التي يَستخدِمها الصنف SimplePaintWithXML لإرسال بيانات رسمةٍ من النوع SimplePaint إلى out من النوع PrintWriter. يَنتُج عن ذلك مستند XML بنفس هيئة المثال المُوضَح بالأعلى: out.println("<?xml version=\"1.0\"?>"); out.println("<simplepaint version=\"1.0\">"); out.println(" <background red='" + backgroundColor.getRed() + "' green='" + backgroundColor.getGreen() + "' blue='" + backgroundColor.getBlue() + "'/>"); for (CurveData c : curves) { out.println(" <curve>"); out.println(" <color red='" + c.color.getRed() + "' green='" + c.color.getGreen() + "' blue='" + c.color.getBlue() + "'/>"); out.println(" <symmetric>" + c.symmetric + "</symmetric>"); for (Point2D pt : c.points) out.println(" <point x='" + pt.getX() + "' y='" + pt.getY() + "'/>"); out.println(" </curve>"); } out.println("</simplepaint>"); قراءة البيانات من مستند XML إلى البرنامج مرةً أخرى هي عمليةٌ أخرى تمامًا، حيث ينبغي تحليل parse المستند واستخراج البيانات منه؛ لنتمكَّن من إعادة إنشاء بنية البيانات المُمثِلة للمستند، وهو ما يتطلّب الكثير من العمل. تُوفِّر جافا لحسن الحظ واجهة برمجة تطبيقات قياسية standard API لتحليل مستندات XML ومعالجتها، وهي تُوفِّر في الواقع واجهتين ولكننا سنتعامل مع واحدةٍ منهما فقط. يتكوَّن أي مستند XML سليم من بنيةٍ معروفةٍ مكوَّنة من مجموعةٍ من العناصر elements مُتضمَّنةً مجموعةً من السمات attributes؛ والعناصر المتداخلة؛ والمحتويات النصية. يُمكِننا إنشاء بنية بياناتٍ مكافئةً لبنية المستند ومحتوياته في ذاكرة الحاسوب، حيث تتوفَّر الكثير من الطرائق لذلك، ولكن يُعدّ تمثيل نموذج كائن المستند Document Object Model -أو اختصارًا DOM- أكثرها شيوعًا؛ حيث يُخصِّص هذا النموذج طريقةً لبناء بنية بياناتٍ مكافئة لمستندات XML؛ كما أنه يُعرِّف مجموعةً من التوابع methods القياسية، التي تَسمَح باسترجاع البيانات الموجودة ضمن تلك البنية. تُعدّ تلك البنية أشبه ما تكون بشجرةٍ تُكافئ بنيتها بنية المستند، حيث تتكوَّن الشجرة من أنواعٍ مختلفة من العُقد nodes المُستخدَمة لتمثيل العناصر والسمات والنصوص، كما قد تحتوي على أنواعٍ آخرى من العُقد لتمثيل جوانبٍ أخرى من XML. سنُركز فقط على العقد المُمثِّلة للعناصر بسبب عدم القدرة على معالجة السمات والنصوص مباشرةً دون معالجة ما يُقابِلها من عُقد. يُمكِّنك البرنامج التوضيحي XMLDemo.java من التعامل مع كُلٍ من XML و DOM. يتضمَّن البرنامج صندوقًا نصيًا حيث يُمكِنك كتابة مستند XML، كما يحتوي على مستند XML توضيحي من هذا المقال افتراضيًا. إذا ضغطت على الزر "Parse XML Input"، سيقرأ البرنامج المستند المكتوب بالصندوق، وسيحاول بناء تمثيل DOM له. إذا لم تَكْن المُدْخلات مُمثِّلةً لمستند XML سليم، فستظهر رسالة خطأ؛ أما إذا كانت سليمة، فسيجتاز البرنامج تمثيل DOM بالكامل، وسيَعرِض قائمةً بكل العناصر والسمات والمحتويات النصية التي سيُقابِلها. يَستخدِم البرنامج بعض التقنيات لمعالجة XML، ولكننا لن نناقشها هنا. يُمكِننا إنشاء تمثيل DOM لمستند XML باستخدام التعليمتين التاليتين. إذا كان selectedFile متغيرًا من النوع File يُمثِّل مستند XML، فسيكون xmldoc من النوع Document: DocumentBuilder docReader = DocumentBuilderFactory.newInstance().newDocumentBuilder(); xmldoc = docReader.parse(selectedFile); تَفتَح الشيفرة السابقة الملف، وتقرأ محتوياته، ثم تَبنِي تمثيل DOM. ستَجِد الصنفين DocumentBuilder و DocumentBuilderFactory بحزمة javax.xml.parsers. يُحاوِل التابع docReader.parse()‎ إجراء العملية، ويُبلِّغ عن اعتراضٍ إذا لم يتمكَّن من قراءة الملف أو إذا لم يحتوِ الملف على مستند XML صالح. في المقابل، إذا نجح التابع، فإنه يعيد كائنًا يُمثِّل كامل المستند. تُعدّ هذه العملية معقدةً بالتأكيد، ولكنها بُرمجَت بالكامل مرةً واحدةً، وضُمِّنَت بتابعٍ يُمكِنك اِستخدَامه بسهولة بأي برنامج جافا، وهو ما يُمثِّل إحدى فوائد الاعتماد على صيغةٍ قياسية. يُمكِنك الإطلاع على تعريف بنية البيانات DOM بحزمة org.w3c.dom، والتي تحتوي على أنواع بياناتٍ مختلفة لتمثيل مستند XML بالكامل؛ وكذلك لتمثيل العقد الموجودة بالمستند على حدى. يشير الاسم "org.w3c" إلى اتحاد شبكة الويب العالمية World Wide Web Consortium -أو اختصارًا W3C، والمسؤولة عن تنظيم المعايير القياسية لتقنيات الويب. يُعدّ تمثيل DOM مثل XML معيارًا عامًا؛ أي لا يختص بلغة جافا دون غيرها. سنحتاج بهذا المثال أنواع البيانات التالية Document و Node و Element و NodeList، والمُعرَّفة جميعًا على أنها واجهات interfaces لا أصناف، ولكن هذا غير مهم. سنَستخدِم التوابع المُعرَّفة بتلك الأنواع للوصول إلى البيانات الموجودة بتمثيل DOM لمستند XML. يعيد التابع docReader.parse()‎ قيمةً من النوع Document، والتي كانت xmldoc في المثال السابق، وهي تُمثِّل مستند XML بالكامل. سنحتاج إلى التابع التالي فقط من هذا الصنف. إذا كان xmldoc من النوع Document، يُمكِننا كتابة ما يلي: xmldoc.getDocumentElement() يُعيد هذا التابع قيمةً من النوع Element تُمثِّل عنصر الجذر root الخاص بالمستند؛ وهوالعنصر الموجود بأعلى مستوى بالمستند كما ذكرنا مُسبقًا، ويتضمَّن كافة العناصر الأخرى. يتكوَّن عنصر الجذر بمستند XML الخاص بالمثال السابق من هذا المقال من الوسمين <simplepaint version="1.0"‎> و </simplepaint>، وكل ما هو موجودٌ بينهما. تُمثَّل العناصر الواقعة داخل عنصر الجذر باستخدام عقد nodes، والتي تُعدّ بمثابة أبناء عقدة الجذر. تتضمَّن كائنات النوع Element مجموعةً من التوابع المفيدة، والتي سنستعرِض بعضًا منها فيما يلي. إذا كان element من النوع Element، يُمكِننا استخدام التوابع التالية: element.getTagName()‎: يُعيد سلسلةً نصيةً من النوع String للاسم المُستخدَم بالوسم الخاص بالعنصر؛ حيث سيُعيد على سبيل المثال السلسلة النصية "curve" للعنصر <curve>. element.getAttribute(attrName)‎: إذا كان attrName اسم سمةٍ في العنصر، سيُعيد التابع قيمة تلك السمة. على سبيل المثال، يعيد الاستدعاء element.getAttribute("x")‎ السلسلة النصية "83" للعنصر <point x="83" y="42"/‎>، وتكون القيمة المعادة من النوع String دائمًا حتى لو كانت السمة تُمثِّل قيمة عددية. إذا لم يحتوِ عنصرٌ معين على سمةٍ للاسم المُخصَّص، فسيعيد التابع سلسلةً نصيةً فارغة. element.getTextContent()‎: يُعيد سلسلةً نصيةً من النوع String تحتوي على المحتوى النصي الموجود بالعنصر، بما في ذلك محتوى العناصر الأخرى الواقعة ضمن العنصر. element.getChildNodes()‎: يعيد قيمةً من النوع NodeList تحتوي على جميع العُقد الأبناء لذلك العنصر. تتضمَّن القائمة العقد المُمثِلة لأي عنصرٍ آخر أو محتوى نصي (إلى جانب أنواع أخرى من العقد) مُتداخِل مباشرةً مع العنصر. يَسمَح التابع getChildNodes()‎ باجتياز بنية البيانات DOM بالكامل بدءًا من عنصر الجذر، مرورًا بأبنائه، وأبناء أبنائه، وهكذا. يتوفَّر تابعٌ آخر يعيد جميع سمات العنصر، ولكننا لن نَستخدِمه هنا. element.getElementsByTagName(tagName)‎: يُعيد قيمةً من النوع NodeList تحتوي على جميع العُقد المُمثِّلة لعناصر واقعةٍ داخل element، والتي لها نفس اسم الوسم المُخصَّص. يتضمَّن ذلك العناصر الواقعة بأي مستوى داخل element، وليس فقط تلك المتصلة مباشرةً معه. يَسمَح التابع getElementsByTagName()‎ بالوصول إلى بيانات مُحدَّدة ضمن المستند. يُمثِّل كائنٌ من النوع NodeList قائمةً من العقدة من النوع Node، ولكنه لا يَستخدِم واجهة برمجة التطبيقات الخاصة بالقوائم والمُعرَّفة بإطار عمل جافا للتجميعات. يُعرِّف الصنف NodeList بدلًا من ذلك التابعين التاليين: nodeList.getLength()‎ و nodeList.item(i)‎؛ حيث يُعيد الأول عدد العقد ضمن القائمة؛ بينما يُعيد الثاني العقدة الموجودة بموضع i، والذي تتراوح قيمه من 0 حتى nodeList.getLength() - 1. يُعيد التابع nodeList.get()‎ قيمةً من النوع Node، والتي يُمكِن تحويلها type-cast إلى نوعٍ مُخصّصٍ من العقد قبل استخدامها. بناءً على هذه المعلومات، يُمكِنك إجراء أغلب أنواع العمليات الممكنة على تمثيلات DOM. لنفحص الآن بعض الشيفرة. لنفترض أنه وبينما تعالج إحدى المستندات، قد توصلت إلى عقدةٍ من النوع Element تُمثِّل العنصر التالي: <background red='1' green='0.6' blue='0.2'/> قد يقابلنا العنصر السابق إما أثناء اجتياز المستند باستخدام getChildNodes()‎ أو نتيجةً لاستدعاء التابع getElementsByTagName("background")‎. علينا الآن إعادة إنشاء بنية البيانات التي يُمثِّلها المستند، والتي يُعدّ هذا العنصر جزءًا من بياناتها؛ حيث يُمثِل لون الخلفية تحديدًا بواسطة ثلاث سمات attributes تُمثِّل مكوّنات اللون الأحمر والأخضر والأزرق. إذا كان element متغيرًا يُشير إلى تلك العقدة، يُمكِننا استرجاع اللون بكتابة ما يَلي: double r = Double.parseDouble( element.getAttribute("red") ); double g = Double.parseDouble( element.getAttribute("green") ); double b = Double.parseDouble( element.getAttribute("blue") ); Color bgColor = Color.color(r,g,b); لنفترض الآن أن element يشير إلى العقدة المُمثِّلة للعنصر التالي: <symmetric>true</symmetric> يُمثِل element في تلك الحالة قيمة مُتغيّر من النوع المنطقي boolean، ولكنها رُمزَّت كأنها محتوًى نصي للعنصر. يُمكِننا استرجاع تلك القيمة من العنصر بكتابة ما يَلي: String bool = element.getTextContent(); boolean symmetric; if (bool.equals("true")) symmetric = true; else symmetric = false; لنفكر الآن بمثالٍ يَستخدِم كائنًا من الصنف NodeList. إذا واجهنا العنصر التالي الذي ينبغي تمثيله بقائمة عناصر تنتمي للصنف Point2D: <pointlist> <point x='17' y='42'/> <point x='23' y='8'/> <point x='109' y='342'/> <point x='18' y='270'/> </pointlist> لنفترض أن element يشير إلى العقدة المُمثِّلة للعنصر <pointlist>، علينا الآن إنشاء قائمةٍ من النوع ArrayList<Point2D>‎ لتمثيله، حيث تجتاز الشيفرة التالية قائمة الصنف NodeList المُتضمِّنة لعقد أبناء العنصر على النحو التالي: ArrayList<Point2D> points = new ArrayList<>(); NodeList children = element.getChildNodes(); for (int i = 0; i < children.getLength(); i++) { Node child = children.item(i); // أحد عقد أبناء العنصر if ( child instanceof Element ) { Element pointElement = (Element)child; // One of the <point> elements. int x = Integer.parseInt( pointElement.getAttribute("x") ); int y = Integer.parseInt( pointElement.getAttribute("y") ); // ‫أنشئ النقطة التي يُمثِلها pointElement Point2D pt = new Point2D(x,y); points.add(pt); // أضف النقطة إلى قائمة النقاط } } تُعدّ جميع عناصر <point> الواقعة داخل العنصر <pointlist> أبناءً له. يجب أن نَستخدِم تعليمة if -كما بالأعلى- نظرًا لإمكانية احتواء العنصر على أبناء تنتمي لأصناف أخرى غير الصنف Element، والتي نحن في الواقع غير مهتمين بمعالجتها ضمن هذا المثال. يُمكِننا توظيف كل تلك التقنيات لكتابة تابعٍ يقرأ ملف الدْخَل بالبرنامج التوضيحي SimplePaintWithXML.java. عندما نُنشِئ بنية بياناتٍ لتمثيل ملف XML، يُفضَّل أن نبدأ ببنية بياناتٍ افتراضية، والتي يُمكِننا تعديلها، والإضافة إليها بينما نجتاز شجرة DOM المُمثِلة للملف. في الواقع، هذه العملية ليست سهلة نوعًا ما، ولذلك حاول أن تقرأها بعناية: Color newBackground = Color.WHITE; ArrayList<CurveData> newCurves = new ArrayList<>(); Element rootElement = xmldoc.getDocumentElement(); if ( ! rootElement.getNodeName().equals("simplepaint") ) throw new Exception("File is not a SimplePaint file."); String version = rootElement.getAttribute("version"); try { double versionNumber = Double.parseDouble(version); if (versionNumber > 1.0) throw new Exception("File requires a newer version of SimplePaint."); } catch (NumberFormatException e) { } NodeList nodes = rootElement.getChildNodes(); for (int i = 0; i < nodes.getLength(); i++) { if (nodes.item(i) instanceof Element) { Element element = (Element)nodes.item(i); if (element.getTagName().equals("background")) { double r = Double.parseDouble(element.getAttribute("red")); double g = Double.parseDouble(element.getAttribute("green")); double b = Double.parseDouble(element.getAttribute("blue")); newBackground = Color.color(r,g,b); } else if (element.getTagName().equals("curve")) { CurveData curve = new CurveData(); curve.color = Color.BLACK; curve.points = new ArrayList<>(); newCurves.add(curve); NodeList curveNodes = element.getChildNodes(); for (int j = 0; j < curveNodes.getLength(); j++) { if (curveNodes.item(j) instanceof Element) { Element curveElement = (Element)curveNodes.item(j); if (curveElement.getTagName().equals("color")) { double r = Double.parseDouble(curveElement.getAttribute("red")); double g = Double.parseDouble(curveElement.getAttribute("green")); double b = Double.parseDouble(curveElement.getAttribute("blue")); curve.color = Color.color(r,g,b); } else if (curveElement.getTagName().equals("point")) { double x = Double.parseDouble(curveElement.getAttribute("x")); double y = Double.parseDouble(curveElement.getAttribute("y")); curve.points.add(new Point2D(x,y)); } else if (curveElement.getTagName().equals("symmetric")) { String content = curveElement.getTextContent(); if (content.equals("true")) curve.symmetric = true; } } } } } } backgroundColor = newBackground; curves = newCurves; يُمكِنك الإطلاع على كامل الشيفرة المصدرية في الملف SimplePaintWithXML.java. تطوَّرت XML لتُصبِح واحدةً من أهم التقنيات المُستخدَمة لتطوير بعض أعقد التطبيقات، وهناك مع ذلك بعض الأفكار الأساسية البسيطة التي يَسهُل تطبيقها بجافا. بمجرد إطلاعك على أساسيات XML، يُمكِنك استخدامها بفعالية ضمن برامج جافا الخاصة بك. ترجمة -بتصرّف- للقسم Section 5: A Brief Introduction to XML من فصل Chapter 11: Input/Output Streams, Files, and Networking من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: تواصل تطبيقات جافا عبر الشبكة الدليل السريع للغة البرمجة Java اكتب برنامجك الأول بلغة جافا
×
×
  • أضف...