دروس متقدمة في Java Class: دليل لإعادة تحميل الفصل

نشرت: 2022-03-11

في مشاريع تطوير Java ، يتضمن سير العمل النموذجي إعادة تشغيل الخادم مع كل تغيير في الفئة ، ولا يشكو أحد من ذلك. هذه حقيقة حول تطوير Java. لقد عملنا هكذا منذ أول يوم لنا مع Java. ولكن هل من الصعب إعادة تحميل فئة Java؟ وهل يمكن أن تكون هذه المشكلة صعبة ومثيرة لحلها لمطوري Java المهرة؟ في هذا البرنامج التعليمي لفئة Java ، سأحاول معالجة المشكلة ، ومساعدتك في الحصول على جميع مزايا إعادة تحميل الفصل أثناء التنقل ، وتعزيز إنتاجيتك بشكل كبير.

لا تتم غالبًا مناقشة إعادة تحميل فئة Java ، وهناك القليل جدًا من الوثائق التي تستكشف هذه العملية. أنا هنا لتغيير ذلك. سيوفر هذا البرنامج التعليمي لفصول Java شرحًا تفصيليًا لهذه العملية ويساعدك على إتقان هذه التقنية الرائعة. ضع في اعتبارك أن تنفيذ إعادة تحميل فئة Java يتطلب قدرًا كبيرًا من العناية ، ولكن تعلم كيفية القيام بذلك سيضعك في بطولات الدوري الكبرى ، كمطور Java وكمهندس برمجيات. لن يضر أيضًا فهم كيفية تجنب أخطاء Java العشرة الأكثر شيوعًا.

إعداد مساحة العمل

يتم تحميل جميع التعليمات البرمجية المصدر لهذا البرنامج التعليمي على GitHub هنا.

لتشغيل الكود أثناء اتباع هذا البرنامج التعليمي ، ستحتاج إلى Maven و Git وإما Eclipse أو IntelliJ IDEA.

إذا كنت تستخدم Eclipse:

  • قم بتشغيل الأمر mvn eclipse:eclipse لإنشاء ملفات مشروع Eclipse.
  • قم بتحميل المشروع المُنشأ.
  • تعيين مسار الإخراج إلى target/classes .

إذا كنت تستخدم IntelliJ:

  • قم باستيراد ملف pom الخاص بالمشروع.
  • لن يقوم IntelliJ بالتجميع التلقائي عند تشغيل أي مثال ، لذلك عليك إما:
  • قم بتشغيل الأمثلة داخل IntelliJ ، ثم في كل مرة تريد فيها التحويل ، يجب عليك الضغط على Alt+BE
  • قم بتشغيل الأمثلة خارج IntelliJ باستخدام run_example*.bat . قم بتعيين الترجمة التلقائية لمترجم IntelliJ على true. بعد ذلك ، في كل مرة تقوم فيها بتغيير أي ملف java ، سيقوم IntelliJ بتجميعه تلقائيًا.

مثال 1: إعادة تحميل فئة باستخدام Java Class Loader

سيوفر لك المثال الأول فهمًا عامًا لمحمل فئة Java. هنا هو شفرة المصدر.

بالنظر إلى تعريف فئة User التالي:

 public static class User { public static int age = 10; }

يمكننا القيام بما يلي:

 public static void main(String[] args) { Class<?> userClass1 = User.class; Class<?> userClass2 = new DynamicClassLoader("target/classes") .load("qj.blog.classreloading.example1.StaticInt$User"); ...

في هذا المثال التعليمي ، سيكون هناك فئتا User تم تحميلهما في الذاكرة. سيتم تحميل userClass1 بواسطة مُحمل الفئة الافتراضي الخاص بـ JVM ، و userClass2 باستخدام DynamicClassLoader ، وهو مُحمل فئة مخصص يتم توفير كود مصدره أيضًا في مشروع GitHub ، والذي سأصفه بالتفصيل أدناه.

إليك بقية الطريقة main :

 out.println("Seems to be the same class:"); out.println(userClass1.getName()); out.println(userClass2.getName()); out.println(); out.println("But why there are 2 different class loaders:"); out.println(userClass1.getClassLoader()); out.println(userClass2.getClassLoader()); out.println(); User.age = 11; out.println("And different age values:"); out.println((int) ReflectUtil.getStaticFieldValue("age", userClass1)); out.println((int) ReflectUtil.getStaticFieldValue("age", userClass2)); }

والإخراج:

 Seems to be the same class: qj.blog.classreloading.example1.StaticInt$User qj.blog.classreloading.example1.StaticInt$User But why there are 2 different class loaders: qj.util.lang.DynamicClassLoader@3941a79c sun.misc.Launcher$AppClassLoader@1f32e575 And different age values: 11 10

كما ترى هنا ، على الرغم من أن فئات User لها نفس الاسم ، إلا أنهما في الواقع فئتان مختلفتان ، ويمكن إدارتهما ومعالجتهما بشكل مستقل. على الرغم من إعلان القيمة العمرية ثابتة ، إلا أنها موجودة في نسختين ، وترتبط بشكل منفصل بكل فئة ، ويمكن تغييرها بشكل مستقل أيضًا.

في برنامج Java العادي ، ClassLoader هي البوابة التي تجلب الفصول إلى JVM. عندما تتطلب فئة ما تحميل فئة أخرى ، فإن مهمة ClassLoader هي القيام بالتحميل.

ومع ذلك ، في مثال فئة Java هذا ، يتم استخدام ClassLoader المخصص المسمى DynamicClassLoader لتحميل الإصدار الثاني من فئة User . إذا كان علينا بدلاً من DynamicClassLoader استخدام أداة تحميل الفئة الافتراضية مرة أخرى (باستخدام الأمر StaticInt.class.getClassLoader() ) ، فسيتم استخدام نفس فئة User ، حيث يتم تخزين جميع الفئات المحملة مؤقتًا.

يعد فحص الطريقة التي يعمل بها Java ClassLoader الافتراضي مقابل DynamicClassLoader مفتاحًا للاستفادة من دروس Java التعليمية هذه.

DynamicClassLoader

يمكن أن يكون هناك محمل فئات متعددة في برنامج Java عادي. الفئة التي تقوم بتحميل فصلك الرئيسي ، ClassLoader ، هي الفئة الافتراضية ، ومن التعليمات البرمجية الخاصة بك ، يمكنك إنشاء واستخدام أكبر عدد تريده من أدوات تحميل الفصل. هذا ، إذن ، هو مفتاح إعادة تحميل الفصل في Java. من المحتمل أن يكون DynamicClassLoader هو الجزء الأكثر أهمية في هذا البرنامج التعليمي بأكمله ، لذلك يجب أن نفهم كيفية عمل تحميل الفصل الديناميكي قبل أن نتمكن من تحقيق هدفنا.

على عكس السلوك الافتراضي لـ ClassLoader ، يرث DynamicClassLoader استراتيجية أكثر عدوانية. سيعطي محمل الفصل العادي الأولوية لـ ClassLoader الأصل ويقوم فقط بتحميل الفئات التي لا يستطيع الأصل تحميلها. هذا مناسب للظروف العادية ، ولكن ليس في حالتنا. بدلاً من ذلك ، سيحاول DynamicClassLoader البحث في جميع مسارات فئته وحل الفئة المستهدفة قبل أن يتخلى عن حق الأصل.

في المثال أعلاه ، تم إنشاء DynamicClassLoader بمسار فئة واحد فقط: "target/classes" (في دليلنا الحالي) ، لذلك فهو قادر على تحميل جميع الفئات الموجودة في هذا الموقع. بالنسبة لجميع الفئات غير الموجودة هناك ، سيتعين عليها الرجوع إلى أداة تحميل الفصل الأصلية. على سبيل المثال ، نحتاج إلى تحميل فئة String في فئة StaticInt الخاصة بنا ، ولا يستطيع مُحمل الفصل الخاص بنا الوصول إلى rt.jar في مجلد JRE ، لذلك سيتم استخدام فئة String الخاصة بمحمل الفئة الأصل.

الكود التالي مأخوذ من AggressiveClassLoader ، الفئة الأصلية لـ DynamicClassLoader ، ويظهر مكان تعريف هذا السلوك.

 byte[] newClassData = loadNewClass(name); if (newClassData != null) { loadedClasses.add(name); return loadClass(newClassData, name); } else { unavaiClasses.add(name); return parent.loadClass(name); }

لاحظ الخصائص التالية لـ DynamicClassLoader :

  • الفئات المحملة لها نفس الأداء والسمات الأخرى مثل الفئات الأخرى المحملة بواسطة محمل الفئة الافتراضي.
  • يمكن جمع DynamicClassLoader مع كل الفئات والكائنات المحملة.

مع القدرة على تحميل واستخدام نسختين من نفس الفئة ، فإننا نفكر الآن في التخلص من الإصدار القديم وتحميل الإصدار الجديد لاستبداله. في المثال التالي ، سنفعل ذلك… بشكل مستمر.

مثال 2: إعادة تحميل فصل بشكل مستمر

سيوضح لك مثال Java التالي هذا أنه يمكن لـ JRE تحميل الفئات وإعادة تحميلها إلى الأبد ، مع التخلص من الفئات القديمة وجمع القمامة ، وتحميل فئة جديدة تمامًا من القرص الصلب وتشغيلها. هنا هو شفرة المصدر.

هذه هي الحلقة الرئيسية:

 public static void main(String[] args) { for (;;) { Class<?> userClass = new DynamicClassLoader("target/classes") .load("qj.blog.classreloading.example2.ReloadingContinuously$User"); ReflectUtil.invokeStatic("hobby", userClass); ThreadUtil.sleep(2000); } }

كل ثانيتين ، سيتم التخلص من فئة User القديمة ، وسيتم تحميل فئة جديدة hobby الطريقة الخاصة بها.

هنا تعريف فئة User :

 @SuppressWarnings("UnusedDeclaration") public static class User { public static void hobby() { playFootball(); // will comment during runtime // playBasketball(); // will uncomment during runtime } // will comment during runtime public static void playFootball() { System.out.println("Play Football"); } // will uncomment during runtime // public static void playBasketball() { // System.out.println("Play Basketball"); // } }

عند تشغيل هذا التطبيق ، يجب أن تحاول التعليق وإلغاء التعليق على الكود المشار إليه في فئة User . سترى أنه سيتم دائمًا استخدام أحدث تعريف.

فيما يلي بعض الأمثلة على الإخراج:

 ... Play Football Play Football Play Football Play Basketball Play Basketball Play Basketball

في كل مرة يتم فيها إنشاء مثيل جديد من DynamicClassLoader ، سيتم تحميل فئة User من المجلد target/classes ، حيث قمنا بتعيين Eclipse أو IntelliJ لإخراج أحدث ملف فئة. سيتم إلغاء ربط جميع فئات DynamicClassLoader القديمة وفئات User القديمة وستخضع لمجمع البيانات المهملة.

من الأهمية بمكان أن يفهم مطورو Java المتقدمون إعادة تحميل الفئة الديناميكية ، سواء كانت نشطة أو غير مرتبطة.

إذا كنت معتادًا على JVM HotSpot ، فمن الجدير بالذكر هنا أنه يمكن أيضًا تغيير هيكل الفصل وإعادة playFootball : يجب إزالة طريقة playBasketball طريقة playBasketball. هذا يختلف عن HotSpot ، والذي يسمح فقط بتغيير محتوى الطريقة ، أو لا يمكن إعادة تحميل الفصل.

الآن وقد أصبحنا قادرين على إعادة تحميل الفصل ، فقد حان الوقت لمحاولة إعادة تحميل العديد من الفصول مرة واحدة. لنجربها في المثال التالي.

مثال 3: إعادة تحميل فئات متعددة

سيكون ناتج هذا المثال هو نفسه مع المثال 2 ، ولكنه سيوضح كيفية تنفيذ هذا السلوك في بنية أكثر شبهاً بالتطبيق مع كائنات السياق والخدمة والنموذج. الكود المصدري لهذا المثال كبير نوعًا ما ، لذا فقد عرضت أجزاء منه فقط هنا. كود المصدر الكامل هنا.

هذه هي الطريقة main :

 public static void main(String[] args) { for (;;) { Object context = createContext(); invokeHobbyService(context); ThreadUtil.sleep(2000); } }

وطريقة createContext :

 private static Object createContext() { Class<?> contextClass = new DynamicClassLoader("target/classes") .load("qj.blog.classreloading.example3.ContextReloading$Context"); Object context = newInstance(contextClass); invoke("init", context); return context; }

تستدعي الطريقة invokeHobbyService :

 private static void invokeHobbyService(Object context) { Object hobbyService = getFieldValue("hobbyService", context); invoke("hobby", hobbyService); }

وهنا فئة Context :

 public static class Context { public HobbyService hobbyService = new HobbyService(); public void init() { // Init your services here hobbyService.user = new User(); } }

وفئة HobbyService :

 public static class HobbyService { public User user; public void hobby() { user.hobby(); } }

تعتبر فئة Context في هذا المثال أكثر تعقيدًا من فئة User في الأمثلة السابقة: فهي تحتوي على روابط لفئات أخرى ، ولديها طريقة init ليتم استدعاؤها في كل مرة يتم إنشاء مثيل لها. في الأساس ، إنه مشابه جدًا لفئات سياق تطبيق العالم الحقيقي (التي تتعقب وحدات التطبيق وتقوم بحقن التبعية). لذا فإن القدرة على إعادة تحميل فئة Context هذه مع كل الفئات المرتبطة يعد خطوة رائعة نحو تطبيق هذه التقنية في الحياة الواقعية.

يعد إعادة تحميل فئة Java أمرًا صعبًا حتى بالنسبة لمهندسي Java المتقدمين.

مع ازدياد عدد الفئات والكائنات ، ستصبح خطوتنا المتمثلة في "إسقاط الإصدارات القديمة" أكثر تعقيدًا. هذا أيضًا هو السبب الأكبر وراء صعوبة إعادة تحميل الفصل. لإسقاط الإصدارات القديمة ، سيتعين علينا التأكد من أنه بمجرد إنشاء السياق الجديد ، يتم إسقاط جميع الإشارات إلى الفئات والكائنات القديمة. كيف نتعامل مع هذا بأناقة؟

الطريقة main هنا ستحتوي على كائن السياق ، وهذا هو الرابط الوحيد لجميع الأشياء التي يجب حذفها. إذا قمنا بفك هذا الارتباط ، فسيخضع كل من كائن السياق وفئة السياق وكائن الخدمة… لمجمع البيانات المهملة.

شرح بسيط حول سبب استمرار الفصول بشكل طبيعي ، ولا يتم جمع القمامة:

  • عادة ، نقوم بتحميل جميع الفئات الخاصة بنا في أداة تحميل فئة Java الافتراضية.
  • علاقة أداة تحميل الفئة هي علاقة ثنائية الاتجاه ، حيث يقوم مُحمل الفئة أيضًا بالتخزين المؤقت لجميع الفئات التي قام بتحميلها.
  • وطالما أن أداة تحميل الفصل لا تزال متصلة بأي مؤشر ترابط مباشر ، فسيكون كل شيء (جميع الفئات المحملة) محصنًا من أداة تجميع البيانات المهملة.
  • ومع ذلك ، ما لم نتمكن من فصل الكود الذي نريد إعادة تحميله عن الكود الذي تم تحميله بالفعل بواسطة أداة تحميل الفئة الافتراضية ، فلن يتم تطبيق تغييرات الكود الجديدة الخاصة بنا أبدًا أثناء وقت التشغيل.

من خلال هذا المثال ، نرى أن إعادة تحميل جميع فئات التطبيق أمر سهل إلى حد ما. الهدف هو مجرد الحفاظ على اتصال رفيع وقابل للتقطير من الخيط المباشر إلى محمل الفئة الديناميكي قيد الاستخدام. ولكن ماذا لو أردنا عدم إعادة تحميل بعض الكائنات (وفئاتها) ، وإعادة استخدامها بين دورات إعادة التحميل؟ لنلق نظرة على المثال التالي.

مثال 4: فصل مسافات الفصل الدائمة عن المساحات المعاد تحميلها

هذا هو الكود المصدري ..

الطريقة main :

 public static void main(String[] args) { ConnectionPool pool = new ConnectionPool(); for (;;) { Object context = createContext(pool); invokeService(context); ThreadUtil.sleep(2000); } }

لذلك يمكنك أن ترى أن الحيلة هنا هي تحميل فئة ConnectionPool وتشغيلها خارج دورة إعادة التحميل ، وإبقائها في المساحة المستمرة ، وتمرير المرجع إلى كائنات Context

تختلف طريقة createContext قليلاً أيضًا:

 private static Object createContext(ConnectionPool pool) { ExceptingClassLoader classLoader = new ExceptingClassLoader( (className) -> className.contains(".crossing."), "target/classes"); Class<?> contextClass = classLoader.load("qj.blog.classreloading.example4.reloadable.Context"); Object context = newInstance(contextClass); setFieldValue(pool, "pool", context); invoke("init", context); return context; }

من الآن فصاعدًا ، سوف نطلق على الكائنات والفئات التي يتم إعادة تحميلها مع كل دورة "مساحة قابلة لإعادة التحميل" وغيرها - الكائنات والفئات التي لم يتم إعادة تدويرها ولم يتم تجديدها أثناء دورات إعادة التحميل - "الفضاء المستمر". سيتعين علينا أن نكون واضحين جدًا بشأن الأشياء أو الفئات التي تبقى في أي مساحة ، وبالتالي رسم خط فاصل بين هذين الفراغين.

يمكن أن يؤدي هذا الفصل لتحميل فئة Java ، ما لم يتم التعامل معه بشكل صحيح ، إلى الفشل.

كما يتضح من الصورة ، لا يشير فقط كائن Context وكائن UserService إلى كائن ConnectionPool ، ولكن الفئتين Context و UserService تشير أيضًا إلى فئة ConnectionPool . هذا وضع خطير للغاية يؤدي غالبًا إلى الارتباك والفشل. يجب ألا يتم تحميل فئة ConnectionPool بواسطة DynamicClassLoader بنا ، ويجب أن يكون هناك فئة ConnectionPool واحدة فقط في الذاكرة ، وهي الفئة التي تم تحميلها بواسطة ClassLoader الافتراضي. هذا أحد الأمثلة على أهمية توخي الحذر عند تصميم بنية إعادة تحميل للفئة في Java.

ماذا لو قام DynamicClassLoader الخاص بنا بتحميل فئة ConnectionPool بطريق الخطأ؟ ثم لا يمكن تمرير كائن ConnectionPool من الفضاء المستمر إلى كائن Context ، لأن كائن Context يتوقع كائنًا من فئة مختلفة ، والتي تسمى أيضًا ConnectionPool ، ولكنها في الواقع فئة مختلفة!

إذن كيف يمكننا منع DynamicClassLoader من تحميل فئة ConnectionPool ؟ بدلاً من استخدام DynamicClassLoader ، يستخدم هذا المثال فئة فرعية منه تسمى: ExceptingClassLoader ، والتي ستنقل التحميل إلى محمل فئة فائق بناءً على دالة شرطية:

 (className) -> className.contains("$Connection")

إذا لم نستخدم ExceptingClassLoader هنا ، DynamicClassLoader بتحميل فئة ConnectionPool لأن هذه الفئة موجودة في مجلد " target/classes ". هناك طريقة أخرى لمنع التقاط فئة ConnectionPool بواسطة DynamicClassLoader وهي تجميع فئة ConnectionPool إلى مجلد مختلف ، ربما في وحدة نمطية مختلفة ، وسيتم تجميعها بشكل منفصل.

قواعد اختيار الفضاء

الآن ، أصبحت مهمة تحميل فئة Java مربكة حقًا. كيف نحدد الفئات التي يجب أن تكون في الفضاء المستمر ، وأي الفئات في المساحة القابلة لإعادة التحميل؟ فيما يلي القواعد:

  1. قد يشير الفصل الموجود في المساحة القابلة لإعادة التحميل إلى فئة في المساحة الثابتة ، ولكن قد لا يشير الفصل الموجود في الفضاء المستمر إلى فئة في المساحة القابلة لإعادة التحميل. في المثال السابق ، تشير فئة Context القابلة لإعادة التحميل إلى فئة ConnectionPool المستمرة ، ولكن ليس لـ ConnectionPool أي مرجع إلى Context
  2. يمكن أن توجد فئة في أي من الفراغين إذا لم تشير إلى أي فئة في المساحة الأخرى. على سبيل المثال ، يمكن تحميل فئة الأداة المساعدة مع جميع الطرق الثابتة مثل StringUtils مرة واحدة في المساحة المستمرة ، وتحميلها بشكل منفصل في المساحة القابلة لإعادة التحميل.

لذلك يمكنك أن ترى أن القواعد ليست مقيدة للغاية. باستثناء الفئات المتقاطعة التي تحتوي على كائنات مُشار إليها عبر الفراغين ، يمكن استخدام جميع الفئات الأخرى بحرية إما في المساحة الثابتة أو المساحة القابلة لإعادة التحميل أو كليهما. بالطبع ، ستستمتع فقط الفصول الموجودة في المساحة القابلة لإعادة التحميل بإعادة تحميلها بدورات إعادة التحميل.

لذلك يتم التعامل مع المشكلة الأكثر صعوبة في إعادة تحميل الفصل. في المثال التالي ، سنحاول تطبيق هذه التقنية على تطبيق ويب بسيط ، والاستمتاع بإعادة تحميل فئات Java تمامًا مثل أي لغة برمجة نصية.

مثال 5: دليل الهاتف الصغير

هذا هو الكود المصدري ..

سيكون هذا المثال مشابهًا جدًا للشكل الذي يجب أن يبدو عليه تطبيق الويب العادي. إنه تطبيق صفحة واحدة مع AngularJS و SQLite و Maven و Jetty Embedded Web Server.

هذه هي المساحة القابلة لإعادة التحميل في هيكل خادم الويب:

سيساعدك الفهم الشامل للمساحة القابلة لإعادة التحميل في هيكل خادم الويب على إتقان تحميل فئة Java.

لن يحتفظ خادم الويب بمراجع إلى servlets الحقيقية ، والتي يجب أن تبقى في المساحة القابلة لإعادة التحميل ، حتى يتم إعادة تحميلها. ما يحمله هو خادم أبتر ، والذي ، مع كل استدعاء لطريقة الخدمة ، سوف يحل servlet الفعلي في السياق الفعلي ليتم تشغيله.

يقدم هذا المثال أيضًا كائنًا جديدًا ReloadingWebContext ، والذي يوفر لخادم الويب جميع القيم مثل السياق العادي ، ولكنه يحمل داخليًا مراجع لكائن سياق حقيقي يمكن إعادة تحميله بواسطة DynamicClassLoader . هذا هو ReloadingWebContext الذي يوفر servlets كعب إلى خادم الويب.

يعالج ReloadingWebContext servlets كعب روتين إلى خادم الويب في عملية إعادة تحميل فئة Java.

سيكون ReloadingWebContext هو غلاف السياق الفعلي ، و:

  • سيتم إعادة تحميل السياق الفعلي عند استدعاء HTTP GET إلى "/".
  • ستوفر servlets كعب إلى خادم الويب.
  • سيتم تعيين القيم واستدعاء العمليات في كل مرة يتم فيها تهيئة السياق الفعلي أو إتلافه.
  • يمكن تهيئتها لإعادة تحميل السياق أم لا ، وأي محمل مصنف يتم استخدامه لإعادة التحميل. سيساعد هذا عند تشغيل التطبيق في الإنتاج.

نظرًا لأنه من المهم جدًا فهم كيفية عزل المساحة الثابتة والمساحة القابلة لإعادة التحميل ، فإليك الفئتان اللتان تتقاطعان بين الفراغين:

صنف qj.util.funct.F0 للكائن public F0<Connection> connF في Context

  • كائن الوظيفة ، سيعيد الاتصال في كل مرة يتم فيها استدعاء الوظيفة. هذه الفئة موجودة في حزمة qj.util ، المستثناة من DynamicClassLoader .

صنف java.sql.Connection للكائن public F0<Connection> connF in Context

  • كائن اتصال SQL عادي. لا توجد هذه الفئة في مسار فئة DynamicClassLoader بنا ، لذا لن يتم انتقاؤها.

ملخص

في هذا البرنامج التعليمي لفصول Java ، رأينا كيفية إعادة تحميل فئة واحدة ، وإعادة تحميل فئة واحدة باستمرار ، وإعادة تحميل مساحة كاملة من فئات متعددة ، وإعادة تحميل فئات متعددة بشكل منفصل عن الفئات التي يجب أن تستمر. باستخدام هذه الأدوات ، فإن العامل الرئيسي لتحقيق إعادة تحميل موثوق بها للفئة هو أن يكون لديك تصميم فائق النقاء. ثم يمكنك التلاعب بحرية في فصولك الدراسية وكلية JVM.

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

حظًا سعيدًا يا أصدقائي واستمتعوا بقوتكم الخارقة المكتشفة حديثًا!