لغات البرمجة عملياً الأدلة التقنية

Java لمن يعرف البرمجة: JDK 25 والأدوات Toolchain

تثبيت JDK 25 وجولة في صيغة Java مقارنة بما تعرفه، ثم مشروع Gradle فيه منسق وفاحص واختبارات، مع أخطاء زرعناها لنرى أين يمسكها البناء.

Java لمن يعرف البرمجة: JDK 25 والأدوات Toolchain

لنفرض أنك مطور تكتب Python أو JavaScript منذ سنوات، وانتقلت إلى فريق يدير خدمة الطلبات والمدفوعات لمتجر إلكتروني، والخدمة مكتوبة بـ Java منذ عشر سنوات، وأول مهمة وصلتك تعديل صغير في حساب ضريبة القيمة المضافة VAT على الفاتورة. والمهمة نفسها لا تحتاج أكثر من سطرين، ولكنك عندما تفتح المستودع تجد أمامك مجلدات عميقة مثل src/main/java/store، وملف بناء اسمه build.gradle.kts، وزميلاً يقول لك «شغل gradle build قبل أن ترفع التعديل»، وآخر يسألك «أي JDK عندك؟». وأنت لا تريد دورة في البرمجة من الصفر، فأنت تعرف المتغيرات والدوال والكائنات، وإنما تريد أن تعرف كيف تكتب ما تعرفه بطريقة Java، وما الأدوات التي يستخدمها الفريق، ولماذا هي بهذا الشكل، حتى تنجز المهمة اليوم وتفهم ما يحدث عندما يفشل البناء.

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

وسوف نناقش في هذا المقال ما يلي:

  • لماذا ما زالت Java لغة الأنظمة الكبيرة، وما هي الآلة الافتراضية JVM وقطار الإصدارات.
  • تثبيت JDK 25 وقراءة مخرج java -version سطراً بسطر، وما الأدوات التي تأتي معه.
  • أول برنامج دون class بفضل الملفات المصدرية المختصرة Compact Source Files، والتشغيل المباشر من الملف، وJShell.
  • جولة في الصيغة مقارنة بـ Python وJavaScript: الأنواع، والسجلات Records، وswitch، والمجموعات، والنصوص، وتجاوز المدى.
  • أدوات المشروع: Gradle وهيكل المجلدات، والمنسق Formatter، والفحص الثابت Static Analysis، والاختبارات، والتشغيل.
  • المشكلة التي ظهرت في التجربة: بناء أبطأ ست مرات بسبب مكان الملفات، ثم الأخطاء الشائعة وقائمة تحقق لأول يوم.

لماذا Java اليوم؟ الآلة الافتراضية JVM والمنظومة Ecosystem

عندما تكتب Python فأنت تعطي الملف للمفسر CPython ويقرؤه وقت التشغيل، وعندما تكتب Go أو C فأنت تترجم الكود إلى ملف تنفيذي لمعالج ونظام تشغيل محددين. أما Java فقد اختارت طريقاً ثالثاً منذ 1995، وهو أن المترجم javac لا يخرج كود آلة، وإنما يخرج تعليمات لآلة افتراضية اسمها Java Virtual Machine واختصاراً JVM، وتسمى هذه التعليمات Bytecode وتحفظ في ملفات .class. وبعد ذلك تقوم الـ JVM على كل جهاز بتحميل هذه الملفات وتشغيلها، فتبدأ بتفسيرها، ثم تراقب الدوال التي تتكرر كثيراً وتترجمها أثناء التشغيل إلى كود آلة محسن بتقنية الترجمة أثناء التشغيل Just-In-Time واختصاراً JIT، وقد شرحنا هذه الآلية بالتفصيل وبالقياس في مقال المترجم والمفسر وJIT.

وهذا الاختيار هو سبب ما تراه في الشركات الكبيرة: ملف JAR بني على جهاز مطور يعمل بنفس الشكل على سيرفر Linux بمعالج ARM، والـ JVM نفسها تحسنت عبر ثلاثين سنة حتى صارت من أنضج بيئات التشغيل في جمع القمامة Garbage Collection والترجمة والمراقبة. ولهذا بنيت عليها لغات أخرى مثل Kotlin وScala وClojure، وصارت مكتبات Java في كل مجال تقريباً، من أطر الويب مثل Spring إلى معالجة البيانات مثل Kafka وElasticsearch. والجانب الآخر من هذه الصورة أن Java لها ثمن تدفعه في زمن البدء Startup Time وفي استهلاك الذاكرة مقارنة بلغة تترجم إلى ملف تنفيذي، وسوف ترى هذا الثمن بالأرقام في هذا المقال وفي مقال الخدمة الحقيقية من هذه المرحلة. وإذا كنت ما زلت تقارن بين اللغات لمشروع جديد فقد قارنا بين أربع لغات في كيف تختار لغة البرمجة لمشروعك.

وأهم ما تحتاج معرفته عن الإصدارات أن Java تصدر نسخة جديدة كل ستة أشهر، في مارس وسبتمبر، وأن بعض هذه النسخ تحمل دعماً طويل الأمد Long-Term Support واختصاراً LTS، والشركات في الغالب لا تنتقل إلا بين نسخ LTS. وآخرها حالياً Java 25 التي صدرت في سبتمبر 2025، وقبلها 21 ثم 17 ثم 11، ويمكنك رؤية جدول الدعم في خارطة دعم Java SE. ولذلك فإن السؤال الأول في أي فريق ليس «هل تعرف Java؟» وإنما «أي Java؟»، والسبب أن الكود المكتوب لـ Java 8 يختلف كثيراً في أسلوبه عن الكود المكتوب لـ Java 25، وكثير من النصائح التي ستجدها في الإنترنت كتبت قبل عشر سنوات ولم تعد الطريقة الأنسب اليوم.

تثبيت JDK 25: أي توزيعة Distribution وأي إصدار Version؟

تحتاج لكتابة Java إلى عدة التطوير Java Development Kit واختصاراً JDK، وهي تحتوي على المترجم والأدوات والـ JVM معاً. وكل التوزيعات المعروفة مبنية من نفس الكود المصدري في مشروع OpenJDK، والفرق بينها في من يبنيها ويدعمها وبأي رخصة، وقد فصلنا ذلك في دليل الرخص. وفي هذا المقال استخدمنا Eclipse Temurin من مؤسسة Adoptium، وهو الإصدار الموجود داخل الـ Docker Image الرسمية لـ Gradle، فلم نحتج لتثبيت أي شيء على الجهاز نفسه غير Docker.

وعلى جهازك أمامك ثلاث طرق شائعة. الطريقة الأولى مدير الحزم في النظام، مثل apt install openjdk-25-jdk في Ubuntu أو winget في Windows أو brew في macOS، وهي مناسبة إذا كنت تعمل على مشروع واحد. والطريقة الثانية أداة SDKMAN في Linux وmacOS، وهي تسمح لك بتثبيت عدة إصدارات والتبديل بينها لكل مشروع، وهذا مفيد لأنك سوف تجد في نفس الشركة خدمة على Java 17 وأخرى على Java 21. والطريقة الثالثة أن تبني داخل Container فيه JDK، وهي الطريقة التي سوف نستخدمها في كل تجارب هذه المرحلة حتى تكون النتائج قابلة للتكرار. والأمر التالي يشغل Container من Image فيها JDK 25 وGradle، ويطبع الإصدارات:

docker run --rm gradle:9.8.2-jdk25-noble sh -c 'java -version; javac -version; jshell --version; gradle --version'

والمخرج سوف يكون كما يلي:

openjdk version "25.0.5" 2026-10-20 LTS
OpenJDK Runtime Environment Temurin-25.0.5+8 (build 25.0.5+8-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.5+8 (build 25.0.5+8-LTS, mixed mode, sharing)
javac 25.0.5
jshell 25.0.5

------------------------------------------------------------
Gradle 9.8.2
------------------------------------------------------------

Build time:    2026-10-19 16:29:26 UTC
Revision:      8de7bcf76e785507ad92f0741ab9235a430c95e6

Kotlin:        2.4.10
Groovy:        4.0.33
Ant:           Apache Ant(TM) version 1.10.17 compiled on April 6 2026
Launcher JVM:  25.0.5 (Eclipse Adoptium 25.0.5+8-LTS)

في المخرج أعلاه لاحظ التالي:

  • السطر الأول يعطيك رقم الإصدار 25.0.5 وتاريخه، والكلمة LTS تعني أنه من خط الدعم الطويل. والرقم الأول هو الإصدار الرئيسي Feature Release، والرقم الأخير هو التحديث الذي يحمل إصلاحات أمنية، وتصدر هذه التحديثات كل ثلاثة أشهر، ولذلك يجب أن تحدث الـ JDK في الإنتاج بانتظام مثل أي مكتبة أخرى.
  • السطر الثاني يقول من بنى هذه النسخة: Temurin-25.0.5+8، والرقم بعد + هو رقم البناء Build Number. وهذا السطر هو أسرع طريقة لتعرف المورد Vendor إذا سألك أحد عن الرخصة.
  • السطر الثالث عن الـ JVM نفسها: 64-Bit Server VM هي HotSpot، وmixed mode تعني أنها تفسر وتترجم بـ JIT معاً، وsharing تعني أن مشاركة بيانات الـ Classes Class Data Sharing مفعلة، وهي ملف جاهز من الـ Classes الأساسية تحمله الـ JVM من القرص بدل أن تحللها في كل تشغيل، وهذا يقلل زمن البدء.
  • الأسطر التالية تؤكد أن javac وjshell بنفس الإصدار، وأن Gradle 9.8.2 يعمل فوق نفس الـ JVM، ولاحظ أن Gradle يحمل معه Kotlin وGroovy لأن ملفات البناء تكتب بإحداهما، ولا علاقة لذلك بلغة مشروعك.

وإذا ثبت JDK على جهازك فتذكر متغير البيئة JAVA_HOME، فكثير من الأدوات مثل Gradle وMaven وبيئات التطوير تبحث عنه أولاً، وإذا كان يشير إلى نسخة قديمة فسوف ترى أخطاء غريبة رغم أن java -version في الطرفية يطبع النسخة الصحيحة.

ماذا يوجد داخل JDK؟ أدوات سطر الأوامر Command-Line Tools

الصورة التالية تبين الطريق من الملف الذي تكتبه إلى البرنامج الذي يعمل، والأدوات التي تأتي مع الـ JDK وسوف تستخدمها في هذه المرحلة:

مخطط يبين الكود المصدري ثم المترجم javac ثم Bytecode ثم الآلة الافتراضية JVM، وتحتها أدوات JDK مثل jshell وjar وjlink وjcmd
من الملف .java إلى الـ Bytecode ثم إلى الـ JVM، والأدوات التي تأتي مع JDK 25.

والأدوات التي تحتاجها في الأسبوع الأول قليلة: javac للترجمة، وjava للتشغيل، وjshell للتجربة السريعة، وjar لحزم الـ Classes في ملف واحد. أما jcmd وjstack فسوف تحتاجهما عندما تفحص برنامجاً يعمل، فالأولى تطبع معلومات الذاكرة والخيوط وتطلب تفريغ الذاكرة Heap Dump، والثانية تطبع حالة كل خيط Thread، وسوف نستخدمهما في مقالي الذاكرة والتزامن من هذه المرحلة لنكشف تسرباً في الذاكرة وجموداً Deadlock. وأداة jlink تبني بيئة تشغيل مصغرة فيها فقط الوحدات Modules التي يحتاجها برنامجك، وقد جربناها في دليل الرخص. ولاحظ أن الـ JDK لم تعد تأتي معها بيئة تشغيل منفصلة اسمها JRE كما كانت قديماً، ولكن موردي التوزيعات يبنون Images باسم jre فيها الـ JVM دون المترجم والأدوات، وهي ما سوف نستخدمه في الإنتاج لأن حجمها أصغر.

أول برنامج دون class: الملفات المصدرية المختصرة Compact Source Files

إذا كنت قد رأيت Java قبل سنوات فأنت تتذكر أن أصغر برنامج يحتاج class عامة ودالة public static void main(String[] args) وسطر System.out.println، وهذا كان أول ما يشتكي منه كل من يأتي من Python. وفي Java 25 صار هذا اختيارياً، فالمقترح JEP 512: Compact Source Files and Instance Main Methods أصبح جزءاً نهائياً من اللغة، ويسمح لك بكتابة دالة main مباشرة في الملف، والمترجم ينشئ class ضمنية حولها. والملف التالي هو أول برنامج في التجربة، ويحسب مجموع ثلاثة أسعار بالهللات:

void main() {
    var prices = List.of(990, 1450, 2300);
    int total = 0;
    for (int p : prices) {
        total += p;
    }
    IO.println("products: " + prices.size() + ", total halalas: " + total);
}

ونشغله دون أي خطوة ترجمة منفصلة:

java Hello.java
products: 3, total halalas: 4740

في الكود أعلاه لاحظ التالي:

  • لا توجد class ولا public static ولا String[] args، والدالة main هنا دالة عادية في كائن ينشئه المشغل.
  • استخدمنا List دون أي import، لأن الملفات المختصرة تستورد تلقائياً كل الحزم التي تصدرها الوحدة java.base، وهي التي فيها المجموعات والنصوص والملفات.
  • الكلاس IO جديدة في java.lang، وفيها println وreadln للقراءة من الطرفية، وهي اختصار لـ System.out.println الذي ما زال يعمل كما هو.
  • الأسعار بالهللات وليست بالريال، وسبب ذلك سوف تراه في مقال الأنواع من هذه المرحلة: الأعداد العشرية Floating Point لا تمثل 0.1 بدقة، والمبالغ المالية تحفظ أعداداً صحيحة بأصغر وحدة.

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

التشغيل من الملف مباشرة Source Launch: كم يكلف؟

الأمر java Hello.java لا يكتب أي ملف .class على القرص، وإنما يشغل المترجم داخل الذاكرة ثم ينفذ الناتج، وهذه الميزة موجودة منذ Java 11 بالمقترح JEP 330. وسهولة هذا الأمر قد تخفي عنك ثمنه، فقمنا بقياس الطريقتين خمس مرات لكل واحدة: التشغيل من الملف المصدري، ثم الترجمة مرة واحدة بـ javac والتشغيل من الـ Bytecode الجاهز. والمخرج كما يلي:

java Hello.java        3.132 s
java Hello.java        3.125 s
java Hello.java        2.947 s
java Hello.java        3.299 s
java Hello.java        2.487 s
java -cp /tmp/out Hello 0.135 s
java -cp /tmp/out Hello 0.109 s
java -cp /tmp/out Hello 0.158 s
java -cp /tmp/out Hello 0.136 s
java -cp /tmp/out Hello 0.185 s
ثلاث طرق لتشغيل كود Java مع مقارنة زمن التشغيل من الملف المصدري وزمن التشغيل من Bytecode جاهز
التشغيل من الملف المصدري يدفع ثمن الترجمة في كل مرة: 3 ثوان في المتوسط مقابل 0.14 ثانية.

والفرق كبير: حوالي ثلاث ثوان في المتوسط مقابل 0.14 ثانية، أي أن أغلب الوقت في الحالة الأولى يذهب إلى تشغيل المترجم javac نفسه، وهو برنامج Java كبير يبدأ بارداً في كل مرة قبل أن يترجم ملفاً من ثمانية أسطر. ولاحظ أن الأرقام أخذت داخل Container على جهاز كانت تعمل عليه برامج أخرى في نفس الوقت، فالرقم المطلق يختلف على جهازك، ولكن النسبة بين الطريقتين هي ما يهمك. وبالتالي فإن التشغيل من الملف المصدري مناسب لسكربت تشغله يدوياً، وغير مناسب إطلاقاً لأمر يعمل كل دقيقة في Cron Job، والحل الأنسب هو أن تترجم مرة واحدة وتشغل الـ Bytecode، وهذا ما تفعله أداة البناء لك.

ماذا يحدث إذا ترجمت الملف لإصدار أقدم؟ الخيار --release

في الفريق سوف تجد غالباً أن الخدمة تبنى لإصدار محدد من Java حتى لو كان المطور يملك JDK أحدث، وهذا يتم بالخيار --release الذي يخبر المترجم أن يقبل فقط ما هو موجود في ذلك الإصدار. ولنرى ماذا يحدث إذا ترجمنا الملف المختصر لـ Java 21:

javac --release 21 -d /tmp/out21 Hello.java
Hello.java:1: error: implicitly declared classes are not supported in -source 21
void main() {
^
  (use -source 25 or higher to enable implicitly declared classes)
1 error

والرسالة واضحة: الـ class الضمنية Implicitly Declared Class غير مدعومة قبل Java 25، والمترجم يقترح عليك الإصدار المطلوب. وهذه أول قاعدة تحتاجها في فريق فيه خدمات بإصدارات مختلفة: لا تفترض أن ميزة قرأت عنها موجودة في المشروع الذي أمامك، وابحث أولاً في ملف البناء عن languageVersion أو release أو maven.compiler.release، لأن هذا الرقم وليس الـ JDK المثبت عندك هو ما يحدد أي صيغة مسموحة.

برنامج من عدة ملفات Multi-File Source Launch

منذ Java 22 صار التشغيل المباشر يقبل أكثر من ملف بالمقترح JEP 458، فإذا كان الملف الذي تشغله يستخدم class في ملف آخر بنفس المجلد فإن المشغل يبحث عنه ويترجمه أيضاً. وفي التجربة وضعنا دالة الضريبة في ملف Vat.java:

public class Vat {
    // 15% VAT in integer halalas, rounding half up
    static int withVat(int halalas) {
        return halalas + (halalas * 15 + 50) / 100;
    }
}

واستدعيناها من Main.java، ثم شغلنا java Main.java:

total with VAT: 3416 halalas

والرقم 3416 هو نفس المثال الذي سوف نعود إليه في كل المرحلة: ثلاث قطع سعر الواحدة 9.90 ريال تعطي 29.70 ريال، والضريبة 15% منها 4.455 ريال، ومع التقريب لأقرب هللة نصفاً لأعلى Rounding Half Up تصبح 4.46، والمجموع 34.16 ريال. ولاحظ كيف حسبنا التقريب بالأعداد الصحيحة: نضرب في 15 ونضيف 50 ثم نقسم على 100، والقسمة في Java بين عددين صحيحين تحذف الجزء العشري، فإضافة النصف قبل القسمة هي التي تجعل 445.5 هللة تصبح 446. وهذا الأسلوب يعمل للمبالغ الموجبة فقط، وسوف نكتب له اختباراً بعد قليل.

التجربة السريعة في JShell REPL

إذا كنت معتاداً على تجربة سطر في مفسر Python أو في Node قبل أن تكتبه في الكود، فلديك نفس الشيء في Java باسم JShell، وهو حلقة قراءة وتنفيذ وطباعة Read-Eval-Print Loop واختصاراً REPL موجودة منذ Java 9. وفي التجربة أدخلنا الأسطر التالية:

int unit = 990;
unit * 3
(unit * 3 * 15 + 50) / 100
"9.90".equals("9.9")
var names = List.of("coffee", "dates")
names.add("cups")

والمخرج سوف يكون كما يلي:

jshell> unit ==> 990

jshell> $2 ==> 2970

jshell> $3 ==> 446

jshell> $4 ==> false

jshell> names ==> [coffee, dates]

jshell> |  Exception java.lang.UnsupportedOperationException
|        at ImmutableCollections.uoe (ImmutableCollections.java:159)
|        at ImmutableCollections$AbstractImmutableCollection.add (ImmutableCollections.java:164)
|        at (#6:1)

في المخرج أعلاه لاحظ التالي:

  • كل تعبير دون اسم يحفظ في متغير تلقائي مثل $2 و$3، فيمكنك استخدامه في السطر التالي.
  • السطر الرابع يقارن نصين بالدالة equals لا بالعلامة ==، وسبب ذلك من أهم الفروق عن Python وJavaScript وسوف نعود إليه في مقال الأخطاء الشائعة: العلامة == بين كائنين تقارن هل هما نفس الكائن في الذاكرة، لا هل محتواهما متساو.
  • السطر الأخير رمى استثناء، لأن List.of تنشئ قائمة غير قابلة للتعديل Immutable، ومحاولة الإضافة إليها ترمي UnsupportedOperationException وقت التشغيل، والمترجم لا يمنعك لأن النوع List فيه الدالة add. وهذه أول مفاجأة لمن يأتي من Python، فالقائمة هناك قابلة للتعديل دائماً، وإذا أردت قائمة تعدلها في Java فاستخدم new ArrayList<>(...).

وتشغيل JShell داخل Container يحتاج الخيار -i في docker run حتى يصله الإدخال، وبدونه سوف يبقى ينتظر دون أي رسالة، وهذا ما حدث معنا في المحاولة الأولى. وإذا حفظت الأسطر في ملف ومررته إلى jshell فسوف ينفذها دون أن يطبع قيم التعبيرات، فاستخدم الإدخال من الطرفية عندما تريد رؤية النتائج.

جولة في الصيغة Syntax مقارنة بما تعرفه

بعد أن عرفت كيف تشغل الكود، نأخذ الآن ملفاً واحداً فيه أغلب ما سوف تراه في كود الفريق، ونقرؤه مقارنة بما تعرفه من لغتك. والملف التالي هو Tour.java من التجربة، ويمثل جزءاً صغيراً من متجر: منتجات، ومخزون، وطريقة دفع، وإيصال:

import java.util.*;
import java.util.stream.*;

public class Tour {

    // record: an immutable data carrier, like a Python dataclass(frozen=True) or a TS readonly type
    record Product(int id, String name, int priceHalalas) {}

    sealed interface Payment permits Cash, Card {}
    record Cash() implements Payment {}
    record Card(String last4) implements Payment {}

    static String describe(Payment p) {
        return switch (p) {              // switch expression with patterns, checked for exhaustiveness
            case Cash c -> "cash";
            case Card(String last4) -> "card ****" + last4;
        };
    }

    public static void main(String[] args) {
        var products = List.of(
            new Product(1, "coffee", 990),
            new Product(2, "dates", 4500),
            new Product(3, "cups", 1250));

        // static types, inferred with var
        var expensive = products.stream()
            .filter(p -> p.priceHalalas() > 1000)
            .map(Product::name)
            .toList();
        System.out.println("expensive: " + expensive);

        Map<String, Integer> stock = new HashMap<>();
        stock.put("coffee", 12);
        stock.merge("coffee", -2, Integer::sum);
        System.out.println("stock: " + stock);

        Optional<Product> found = products.stream().filter(p -> p.id() == 7).findFirst();
        System.out.println("id 7: " + found.map(Product::name).orElse("not found"));

        String receipt = """
            Order #%d
            total: %d halalas
            """.formatted(42, 7740);
        System.out.print(receipt);

        System.out.println(describe(new Card("4242")));
        System.out.println(products.get(0));           // records get toString/equals/hashCode
        System.out.println(products.get(0).equals(new Product(1, "coffee", 990)));

        int big = Integer.MAX_VALUE;
        System.out.println("int overflow: " + (big + 1));      // no exception, wraps around
        long sum = IntStream.rangeClosed(1, 1_000_000).asLongStream().sum();
        System.out.println("sum: " + sum);
    }
}

وعند تشغيل الأمر java Tour.java سوف تجد المخرج التالي:

expensive: [dates, cups]
stock: {coffee=10}
id 7: not found
Order #42
total: 7740 halalas
card ****4242
Product[id=1, name=coffee, priceHalalas=990]
true
int overflow: -2147483648
sum: 500000500000

والجدول التالي يضع كل فكرة في هذا الملف بجانب ما يقابلها في Python وJavaScript، ثم نشرح الأجزاء التي تحتاج شرحاً:

جدول يقارن صيغة Python وJavaScript وJava في المتغيرات والسجلات والقوائم والقواميس وswitch والنصوص وتجاوز المدى
خريطة سريعة: الفكرة التي تعرفها، وكيف تكتب في Java 25.

الأنواع الثابتة Static Typing والاستنتاج var

كل متغير في Java له نوع يعرفه المترجم قبل التشغيل، ولا يتغير بعد ذلك، وهذا هو الفرق الأكبر عن Python وJavaScript. والكلمة var الموجودة منذ Java 10 لا تجعل المتغير ديناميكياً، وإنما تطلب من المترجم أن يستنتج النوع من القيمة Type Inference، فالمتغير expensive نوعه List<String> حتى لو لم تكتبه، ولو حاولت أن تضع فيه رقماً بعد ذلك فسوف يرفض المترجم. واستخدم var للمتغيرات المحلية عندما يكون النوع واضحاً من السطر نفسه، واكتب النوع صراحة عندما يكون مفيداً للقارئ، كما فعلنا مع Map<String, Integer> stock، فهنا النوع يخبر من يقرأ الكود ماذا يوجد في القاموس.

ولاحظ أن Java تفرق بين الأنواع البدائية Primitive Types مثل int وlong وdouble وboolean، وهي قيم مباشرة في الذاكرة، وبين الأنواع المرجعية Reference Types مثل String وInteger وكل الـ Classes، وهي مؤشرات إلى كائنات. والمجموعات Collections لا تقبل إلا الأنواع المرجعية، ولهذا كتبنا Map<String, Integer> وليس int، والمترجم يحول بين int وInteger تلقائياً فيما يسمى Autoboxing. وهذا التحويل له ثمن في الذاكرة وله فخ في المقارنة، وسوف نقيس الاثنين في مقال الأنواع والذاكرة.

السجلات Records والأنماط Pattern Matching

السطر record Product(int id, String name, int priceHalalas) {} يغنيك عن عشرات الأسطر التي كان المطور يكتبها قديماً: حقول خاصة نهائية، ومنشئ Constructor، ودوال قراءة، وequals وhashCode وtoString. والمخرج يبين ذلك، فطباعة المنتج أعطت Product[id=1, name=coffee, priceHalalas=990]، ومقارنته بمنتج آخر بنفس القيم أعطت true. ولاحظ أن دوال القراءة اسمها priceHalalas() وليس getPriceHalalas()، وأن السجل لا يقبل التعديل بعد إنشائه، فإذا أردت سعراً جديداً فأنت تنشئ سجلاً جديداً. وهذا يشبه dataclass(frozen=True) في Python، وهو الشكل المناسب لكل ما يمثل بيانات تنتقل بين أجزاء البرنامج، مثل طلب يصل من الـ API أو صف يرجع من قاعدة البيانات.

أما sealed interface Payment permits Cash, Card فتقول للمترجم إن طرق الدفع نوعان فقط ولا يوجد غيرهما، وبالتالي فإن switch في الدالة describe يستطيع أن يتحقق أنك غطيت كل الحالات دون أن تكتب default. وإذا أضاف زميلك نوعاً ثالثاً مثل Wallet بعد سنة، فإن المترجم سوف يرفض كل switch لم يغطه، وهذا أفضل بكثير من خطأ يظهر للعميل في الإنتاج. والسطر case Card(String last4) يفكك السجل ويأخذ الحقل منه مباشرة، وهذا ما يسمى أنماط السجلات Record Patterns، وهي قريبة من match في Python 3.10.

المجموعات Collections والتدفقات Streams

المجموعات الأساسية التي سوف تراها في كل مكان ثلاث: List للقائمة المرتبة، وMap للقاموس، وSet لمجموعة دون تكرار. وكل واحدة منها واجهة Interface لها عدة تطبيقات، مثل ArrayList وHashMap وHashSet، وأسلوب Java أن تكتب نوع المتغير بالواجهة وتنشئ الكائن بالتطبيق، حتى تستطيع تغيير التطبيق لاحقاً دون تغيير بقية الكود. والدالة merge في المثال أنقصت المخزون من 12 إلى 10 في سطر واحد دون أن تقرأ القيمة ثم تكتبها، وهي من الدوال التي تستحق أن تعرفها مبكراً.

والتدفقات Streams هي الطريقة الحديثة لمعالجة المجموعات، وتقابل filter وmap في JavaScript أو List Comprehension في Python. ولاحظ أن التدفق لا يعمل حتى تطلب نتيجة في النهاية مثل toList() أو findFirst()، وهذا ما يسمى التنفيذ الكسول Lazy Evaluation. وله فخ حقيقي وقعنا فيه في مقال التزامن من هذه المرحلة: الدالة count() قد لا تنفذ خطوة map قبلها أصلاً إذا كان العدد معروفاً من المصدر، فأعطى قياس كامل نتيجة 2 ملي ثانية لعمل يحتاج ثانيتين. والقيمة Optional في السطر الذي يبحث عن المنتج 7 هي طريقة Java لتقول «قد لا توجد نتيجة»، فبدلاً من أن ترجع الدالة null ويتذكر المستدعي أن يفحصه، ترجع صندوقاً قد يكون فارغاً، وتعطيك orElse قيمة بديلة. واستخدمها كقيمة راجعة من الدوال، ولا تستخدمها في حقول الكائنات أو المعاملات Parameters.

النصوص Strings وتجاوز المدى Integer Overflow

الكتلة النصية Text Block بين ثلاث علامات تنصيص موجودة منذ Java 15، وتحفظ الأسطر كما هي وتحذف المسافات المشتركة في بداية الأسطر، وformatted تعمل مثل printf. ولا توجد في Java صيغة مثل f-strings في Python أو Template Literals في JavaScript، فإما formatted أو الجمع بالعلامة +.

أما السطر قبل الأخير في المخرج فهو أخطر ما في الملف: أضفنا 1 إلى أكبر قيمة يحملها int فأصبحت -2147483648 دون أي استثناء. فالأعداد الصحيحة في Java لها حجم ثابت، int بـ 32 بت وlong بـ 64 بت، وعندما تتجاوز الحد تلتف بصمت إلى الطرف الآخر، بينما في Python يكبر العدد كلما احتجت. وفي متجر يحسب المبالغ بالهللات فإن int يكفي حتى حوالي 21 مليون ريال في الفاتورة الواحدة، وهذا كاف لأغلب المتاجر، ولكن مجموع مبيعات سنة كاملة يحتاج long، ولهذا جمعنا المليون رقم في السطر الأخير بـ asLongStream. وإذا أردت أن يرمي الكود استثناء عند التجاوز بدل الالتفاف فاستخدم Math.addExact وMath.multiplyExact.

أدوات المشروع Toolchain: Maven أم Gradle؟

إلى هنا كنا نشغل ملفات منفردة، ولكن مشروع الفريق يحتاج أكثر من ذلك: مكتبات خارجية تنزل بإصدارات محددة، وترجمة لعشرات الملفات، واختبارات، وفحص للتنسيق، وحزمة نهائية. وفي عالم Java أداتان تسيطران على هذا العمل: Maven التي تصف المشروع في ملف pom.xml وتتبع اصطلاحاً ثابتاً، وGradle التي تكتب البناء ككود في build.gradle.kts بلغة Kotlin أو build.gradle بلغة Groovy. وقد شرحنا الفرق بينهما بالتفصيل وبتجربة في أدوات البناء، وشرحنا كيف تحل الاعتماديات Dependencies وما قاعدة «الأقرب يفوز» في Maven في مديرو الحزم.

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

هيكل المشروع Project Layout في Gradle

المشروع في التجربة اسمه store-cli، وهو أداة سطر أوامر صغيرة تحسب الفاتورة مع الضريبة. والصورة التالية تبين الملفات التي تكتبها، والملفات التي تخرجها الأداة:

شجرة ملفات مشروع Gradle: ملفات البناء ومجلدات src/main/java وsrc/test/java، ومخرجات مجلد build
ما تضعه في Git على اليمين، وما تخرجه أداة البناء على اليسار.

وملف البناء كاملاً كما يلي:

plugins {
    application
    id("com.diffplug.spotless") version "8.10.4"
    id("net.ltgt.errorprone") version "5.2.0"
}

group = "store"
version = "0.1.0"

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(25)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    errorprone("com.google.errorprone:error_prone_core:2.51.0")
    testImplementation(platform("org.junit:junit-bom:6.1.3"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

application {
    mainClass = "store.Main"
}

spotless {
    java {
        googleJavaFormat("2.0.0")
    }
}

tasks.test {
    useJUnitPlatform()
    testLogging {
        events("passed", "failed")
        exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.SHORT
    }
}

في الإعداد أعلاه لاحظ التالي:

  • الإضافة application تضيف المهمة run وسكربتات التشغيل، والإضافتان الأخريان للمنسق والفحص الثابت، وكل إضافة لها إصدار مثبت، فلا يتغير البناء غداً لأن إصداراً جديداً صدر الليلة.
  • الجزء toolchain يحدد إصدار Java الذي يبنى به المشروع بغض النظر عن الـ JDK الذي شغل Gradle، وهذا هو الرقم الذي ذكرناه في فقرة --release: هو ما يحدد الصيغة المسموحة.
  • mavenCentral() هو السجل العام Registry الذي تنزل منه المكتبات، وهو نفس السجل الذي يستخدمه Maven.
  • الأسطر في dependencies تقسم الاعتماديات حسب الاستخدام: testImplementation للاختبارات فقط فلا تدخل في الحزمة النهائية، وerrorprone لا تدخل في الكود أصلاً وإنما في المترجم. والسطر platform("org.junit:junit-bom:6.1.3") قائمة مواد Bill of Materials تثبت إصدارات كل أجزاء JUnit معاً، فلا تكتب الإصدار في كل سطر.
  • mainClass يجب أن يكون الاسم الكامل مع الحزمة، أي store.Main وليس Main.

وفي المشاريع الحقيقية سوف تجد أيضاً ملف gradlew ومجلد gradle/wrapper، وهو غلاف Gradle Wrapper ينزل إصدار Gradle المحدد في المشروع ويشغله، فيبني كل المطورين وخادم الـ CI بنفس الإصدار. واستخدم ./gradlew بدلاً من gradle المثبت عندك متى وجدته. وفي التجربة استخدمنا gradle مباشرة لأن الـ Docker Image نفسها مثبتة على الإصدار 9.8.2.

البناء الأول First Build: ماذا يحدث؟

الأمر gradle build يشغل سلسلة من المهام Tasks، وكل مهمة تعتمد على ما قبلها، والمخرج التالي من التجربة يبين ترتيبها:

> Task :compileJava
> Task :processResources NO-SOURCE
> Task :classes
> Task :jar
> Task :startScripts
> Task :distTar
> Task :distZip
> Task :assemble
> Task :spotlessJava
> Task :spotlessJavaCheck
> Task :spotlessCheck
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test

VatTest > rejectsNegative() PASSED

VatTest > roundsHalfUp() PASSED

والمهام بالترتيب: compileJava تترجم كود src/main ومعها Error Prone، وprocessResources تنسخ ملفات الإعداد ولم تجد شيئاً فطبعت NO-SOURCE، وjar تحزم الـ Classes، وstartScripts وdistTar وdistZip تبني حزمة توزيع فيها سكربت تشغيل، ثم spotlessCheck تفحص التنسيق، ثم compileTestJava وtest تترجمان الاختبارات وتشغلانها. وبعد ذلك تأتي check وbuild وهما مهمتان تجمعان ما قبلهما ولا تفعلان شيئاً بنفسيهما. وإذا شغلت الأمر مرة ثانية دون تغيير فسوف ترى UP-TO-DATE بجانب كل مهمة، لأن Gradle يحفظ بصمة المدخلات والمخرجات لكل مهمة ولا يعيدها إذا لم يتغير شيء، وهذا ما يسمى البناء التدريجي Incremental Build:

BUILD SUCCESSFUL in 5s
9 actionable tasks: 9 up-to-date

ومن أين جاءت المكتبات؟ الأمر gradle dependencies يطبع الشجرة كاملة، وهذا جزء منها لاعتماديات الاختبار:

testRuntimeClasspath - Runtime classpath of source set 'test'.
+--- org.junit:junit-bom:6.1.3
|    +--- org.junit.jupiter:junit-jupiter:6.1.3 (c)
|    +--- org.junit.platform:junit-platform-launcher:6.1.3 (c)
|    +--- org.junit.jupiter:junit-jupiter-engine:6.1.3 (c)
|    +--- org.junit.jupiter:junit-jupiter-api:6.1.3 (c)
|    +--- org.junit.jupiter:junit-jupiter-params:6.1.3 (c)
|    +--- org.junit.platform:junit-platform-engine:6.1.3 (c)
|    \--- org.junit.platform:junit-platform-commons:6.1.3 (c)
+--- org.junit.jupiter:junit-jupiter -> 6.1.3
|    +--- org.junit.jupiter:junit-jupiter-engine:6.1.3
|    |    +--- org.junit:junit-bom:6.1.3 (*)
|    |    +--- org.junit.platform:junit-platform-engine:6.1.3
|    |    |    +--- org.junit:junit-bom:6.1.3 (*)
|    |    |    +--- org.opentest4j:opentest4j:1.3.0

والحرف (c) يعني قيداً Constraint جاء من قائمة المواد، والسهم -> 6.1.3 يعني أن السطر لم يحدد إصداراً فأخذه من القائمة، والنجمة (*) تعني أن الفرع طبع من قبل فلم يكرره. وكل هذه المكتبات نزلت مرة واحدة إلى ~/.gradle/caches، وكان حجم هذا المجلد بعد التجربة 68 ميجابايت، وهو مشترك بين كل مشاريعك على الجهاز.

المنسق Formatter: google-java-format عبر Spotless

في Python تستخدم Black أو Ruff، وفي JavaScript تستخدم Prettier، وفي Java لا يوجد منسق رسمي يأتي مع الـ JDK، ولذلك تتفق الفرق على أداة، وأكثرها انتشاراً google-java-format التي تطبق دليل أسلوب Google، وتشغل من داخل البناء بإضافة Spotless. والفائدة أن النقاش حول المسافات والأقواس ينتهي، فالأداة تقرر والبناء يفشل إذا خالفها أحد. وفي التجربة أضفنا مسافات زائدة إلى سطر في Main.java ثم شغلنا gradle spotlessCheck:

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':spotlessJavaCheck' (registered in build file 'build.gradle.kts').
> The following files had format violations:
      src/main/java/store/Main.java
          @@ -3,7 +3,7 @@
           public·class·Main·{
           ··public·static·void·main(String[]·args)·{
           ····int·unit·=·args.length·>·0·?·Integer.parseInt(args[0])·:·990;
          -········int·qty·=····args.length·>·1·?·Integer.parseInt(args[1])·:·3;
          +····int·qty·=·args.length·>·1·?·Integer.parseInt(args[1])·:·3;
           ····System.out.printf("%d·x·%d·halalas·=·%d·with·VAT%n",·qty,·unit,·Vat.total(unit,·qty));

والمخرج يعرض الفرق Diff بين ما كتبته وما تريده الأداة، والنقطة · تمثل مسافة حتى تراها، والسطر الذي يبدأ بـ - هو الحالي، والذي يبدأ بـ + هو المطلوب. ولاحظ أن google-java-format تستخدم مسافتين للإزاحة وليس أربعاً، وهذا يفاجئ من يأتي من Python. والإصلاح لا يكون يدوياً، وإنما بالأمر gradle spotlessApply الذي يعيد كتابة الملفات، وفي التجربة رجع السطر بعده إلى int qty = args.length > 1 ? Integer.parseInt(args[1]) : 3;. واجعل spotlessCheck جزءاً من الـ CI، وspotlessApply عادة قبل كل Commit.

الفحص الثابت Static Analysis: Error Prone

المترجم javac يفحص الأنواع، ولكنه لا يعرف الأخطاء المنطقية المعروفة التي تمر رغم أن أنواعها صحيحة. وهنا يأتي دور أدوات الفحص الثابت، وفي Java ثلاث أدوات مشهورة: Checkstyle للأسلوب والتسمية، وPMD وSpotBugs لأنماط الأخطاء، وError Prone من Google التي تعمل داخل المترجم نفسه فتظهر أخطاؤها كأخطاء ترجمة عادية. ولتجربتها حذفنا كلمة throw من سطر التحقق في Vat.java، فصار السطر ينشئ الاستثناء ولا يرميه، وهو خطأ حقيقي يحدث عندما يكتب المطور بسرعة، والكود بعده يترجم بشكل طبيعي مع javac وحده. وعند تشغيل gradle compileJava كان المخرج كما يلي:

/tmp/p/src/main/java/store/Vat.java:10: error: [DeadException] Exception created but not thrown
      new IllegalArgumentException("negative amount: " + halalas);
      ^
    (see https://errorprone.info/bugpattern/DeadException)
  Did you mean 'throw new IllegalArgumentException("negative amount: " + halalas);'?
1 error

والرسالة فيها اسم النمط DeadException ورابط شرحه، ثم الاقتراح بالإصلاح نفسه. ولو مر هذا الخطأ لكانت الدالة تقبل المبالغ السالبة بصمت، وتحسب ضريبة سالبة على فاتورة مرتجع. وError Prone يحتاج مع الـ JDK الحديثة خيارات تفتح له بعض الحزم الداخلية في المترجم، والإضافة net.ltgt.errorprone تضيفها لك، ولهذا لا تحاول تشغيله يدوياً في أسبوعك الأول.

الاختبارات Unit Tests مع JUnit 6

إطار الاختبار الأساسي في Java هو JUnit، وآخر إصدار رئيسي منه هو 6، والاختبار class في src/test/java بنفس حزمة الكود، وكل دالة عليها @Test اختبار مستقل. وهذا هو اختبار الضريبة في المشروع:

package store;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import org.junit.jupiter.api.Test;

class VatTest {
  @Test
  void roundsHalfUp() {
    // 9.90 SAR x 3 = 29.70, VAT 4.455 -> 4.46
    assertEquals(446, Vat.vatOf(2970));
    assertEquals(3416, Vat.total(990, 3));
  }

  @Test
  void rejectsNegative() {
    assertThrows(IllegalArgumentException.class, () -> Vat.vatOf(-1));
  }
}

ولنرى كيف يبدو الفشل، غيرنا سطر الحساب إلى (halalas * 15) / 100، أي القص Truncation بدل التقريب، وهو خطأ يكتبه كثيرون لأن الفرق هللة واحدة. وكان مخرج gradle test كما يلي:

VatTest > rejectsNegative() PASSED

VatTest > roundsHalfUp() FAILED
    org.opentest4j.AssertionFailedError at VatTest.java:12

2 tests completed, 1 failed
BUILD FAILED in 28s

والرسالة الكاملة في تقرير الاختبار build/test-results/test كانت expected: <446> but was: <445>، أي أن الضريبة نقصت هللة. وقد يبدو هذا صغيراً، ولكنه في فاتورة ضريبية خطأ في المبلغ المصرح به، ويتكرر في كل فاتورة. ولاحظ أن الإعداد exceptionFormat = SHORT يطبع اسم الاستثناء والسطر فقط، فإذا أردت الرسالة في الطرفية مباشرة فاستخدم FULL، أو افتح التقرير build/reports/tests/test/index.html في المتصفح. وقد شرحنا أنواع الاختبارات ومتى تكتب كل نوع في أنواع الاختبارات.

والصورة التالية تجمع الأخطاء التي زرعناها، وأين أمسك البناء كل واحد منها:

أربع محطات في البناء: الترجمة مع Error Prone، والتنسيق، والاختبار، والحزمة، والخطأ الذي أمسكته كل محطة
كل محطة في gradle build تمسك نوعاً مختلفاً من الأخطاء، ولا تغني واحدة عن الأخرى.

التشغيل Run والحزمة JAR: no main manifest attribute

بعد أن نجح البناء، نشغل البرنامج. والطريقة الأولى من Gradle نفسه:

gradle -q run --args="990 3"
3 x 990 halalas = 3416 with VAT

ولكن في الإنتاج لن يكون Gradle موجوداً، فالمتوقع أن تشغل الحزمة نفسها. وأول ما يجربه أي مطور هو java -jar، وهذا ما حدث:

no main manifest attribute, in build/libs/store-cli-0.1.0.jar

والسبب أن ملف JAR هو ملف ZIP فيه الـ Classes وملف وصف اسمه META-INF/MANIFEST.MF، والأمر java -jar يقرأ من هذا الملف السطر Main-Class ليعرف من أين يبدأ، والإضافة application لا تكتب هذا السطر لأنها تتوقع أن تشغل البرنامج من سكربتاتها. ولهذا أمامك حلان: الأول أن تحدد الـ Class بنفسك مع مسار الـ Classes Classpath:

java -cp build/libs/store-cli-0.1.0.jar store.Main 990 3

والثاني أن تستخدم gradle installDist الذي يبني مجلداً فيه سكربت تشغيل لـ Linux وآخر لـ Windows، ومجلد lib فيه الحزمة وكل اعتمادياتها، والسكربت يبني الـ Classpath لك:

build/install/store-cli/bin:
store-cli
store-cli.bat

build/install/store-cli/lib:
store-cli-0.1.0.jar
2 x 4500 halalas = 10350 with VAT

ولاحظ أن الحزمة هنا 1,741 بايت فقط لأن البرنامج لا يستخدم مكتبات خارجية، فإذا أضفت مكتبات فإن java -jar وحده لن يجدها حتى لو أضفت Main-Class، لأن الـ JAR العادي لا يحتوي اعتمادياته. ولهذا تبني أطر مثل Spring Boot ما يسمى Fat JAR فيه كل شيء، وهذا ما سوف تراه في مقال الخدمة الحقيقية من هذه المرحلة.

المشكلة التي واجهناها: بناء بطيء على مجلد Windows Bind Mount

في التجربة كان المشروع على قرص Windows، وكنا نشغل Gradle داخل Container ونعطيه المجلد بـ Bind Mount، وهو ما يفعله كثير من المطورين على Windows مع Docker Desktop. والبناء الأول بعد تنزيل الاعتماديات لم ينته خلال عشر دقائق، فأوقفناه عند حد الوقت الذي وضعناه وهو في مهمة الاختبار. وعندما قسنا البناء الكامل clean build بعد أن صارت الاعتماديات في الـ Cache، ثم نسخنا نفس المشروع إلى مجلد داخل الـ Container وبنيناه هناك، كانت النتيجة كما طبعها Gradle:

## 1. clean build on the Windows bind mount (/lab/store-cli)
BUILD SUCCESSFUL in 3m 32s
## 2. clean build inside the container file system (/tmp/p)
BUILD SUCCESSFUL in 33s
## 3. build again, nothing changed
BUILD SUCCESSFUL in 5s
9 actionable tasks: 9 up-to-date
مقارنة زمن البناء على Bind Mount من قرص Windows وداخل نظام ملفات الـ Container، وزمن البناء الثاني دون تغيير
نفس المشروع ونفس الأوامر: 3 دقائق و32 ثانية على مجلد Windows، و33 ثانية داخل الـ Container.

والفرق أكثر من ست مرات لنفس الكود ونفس الأوامر. والسبب أن Docker Desktop على Windows يشغل الـ Containers داخل آلة Linux في WSL 2، وكل قراءة أو كتابة على مجلد من قرص Windows تعبر طبقة تترجم بين نظامي الملفات، وGradle والمترجم يلمسان في كل بناء مئات الملفات الصغيرة: ملفات .class، وبصمات المهام في .gradle، وتقارير الاختبار. وفي الحالة الثالثة لم يتغير شيء، فقرأ Gradle البصمات فقط واكتفى بخمس ثوان.

والحل أن تضع المشروع حيث تعمل الأداة: إذا كنت تبني داخل Linux أو داخل Container فضع المستودع داخل نظام ملفات WSL نفسه أو في Volume، وإذا كنت تعمل على Windows مباشرة فثبت JDK وGradle على Windows ولا تمر عبر Docker في البناء اليومي. وبنفس المنطق لا تضع مجلد ~/.gradle على Bind Mount، بل في Volume كما فعلنا في التجربة. وهذه المشكلة ليست خاصة بـ Java، ولكنها تظهر فيها بوضوح لأن البناء يكتب ملفاً لكل class.

بيئة التطوير IDE: هل تحتاج IntelliJ IDEA؟

قد يتساءل البعض: كتبت Python سنوات في محرر بسيط، فهل أحتاج بيئة تطوير ثقيلة لـ Java؟ والإجابة أن Java من اللغات التي تستفيد من بيئة التطوير أكثر من غيرها، والسبب أن الأنواع الثابتة تعطي الأداة معلومات كاملة عن الكود، فتستطيع أن تكمل الكود وتعيد تسمية دالة في كل المشروع وتنتقل إلى تعريف أي class بدقة لا تتوفر في لغة ديناميكية. والخيارات الشائعة ثلاثة: IntelliJ IDEA وهي الأكثر استخداماً في الفرق ولها نسخة مجانية، وVS Code مع إضافة Java من Microsoft وRed Hat وهي مناسبة إذا كنت تعمل بعدة لغات، وEclipse التي ما زالت موجودة في بعض المؤسسات. وكلها تقرأ ملف البناء من Gradle أو Maven وتفهم المشروع منه، ولذلك اجعل ملف البناء هو المرجع، ولا تضف إعدادات للمشروع داخل بيئة التطوير وحدها، لأن خادم الـ CI لا يراها.

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

أخطاء شائعة Common Mistakes في الأسبوع الأول

  • تثبيت أحدث JDK وتجاهل إصدار المشروع: تكتب record أو switch بالأنماط، ثم يفشل البناء في الـ CI لأن المشروع على Java 17 أو 11. والسبب أن ما يحدد الصيغة هو toolchain أو release في ملف البناء، فاقرأه أولاً.
  • اسم الملف لا يطابق الـ Class العامة: الـ Class العامة Vat يجب أن تكون في ملف Vat.java، والحزمة store يجب أن تكون في مجلد store، وإلا رفض المترجم أو لم يجد أحدهما الآخر.
  • مقارنة النصوص بـ ==: تعمل أحياناً في الاختبار وتفشل في الإنتاج، والسبب أنها تقارن المرجع لا المحتوى، واستخدم equals دائماً.
  • تعديل قائمة من List.of: يمر من المترجم ويرمي UnsupportedOperationException وقت التشغيل كما رأيت في JShell.
  • المبالغ المالية في double: احفظها بالهللات في int أو long، أو في BigDecimal إذا احتجت كسوراً أدق، وسوف نقيس الفرق في مقال الأنواع.
  • تشغيل java -jar على JAR لا يحتوي Main-Class ولا اعتمادياته: استخدم installDist أو إضافة تبني Fat JAR، أو إطاراً مثل Spring Boot يفعل ذلك.
  • إطفاء الفحص بدل إصلاحه: عندما يفشل spotlessCheck أو Error Prone فالحل spotlessApply أو إصلاح الخطأ، وليس حذف الإضافة من ملف البناء، والسبب أن الفريق وضعها حتى لا يناقش نفس الأخطاء في كل مراجعة.
  • البناء على Bind Mount من Windows: البناء أبطأ ست مرات كما رأيت، فضع المشروع داخل نظام ملفات Linux إذا كنت تبني في Container.
  • البحث عن حلول قديمة: كثير من الإجابات في الإنترنت كتبت لـ Java 8، فإذا وجدت Date أو Vector أو getters مكتوبة يدوياً لكائن بيانات، فابحث عن الطريقة الحديثة أولاً: java.time وArrayList وrecord.

قائمة تحقق Checklist لأول يوم مع Java

السؤالكيف تتحققلماذا
أي إصدار Java يبني المشروع؟languageVersion في build.gradle.kts أو maven.compiler.release في pom.xmlيحدد الصيغة المسموحة، لا الـ JDK عندك
هل الـ JDK عندك من مورد مقبول في الشركة؟السطر الثاني من java -versionالرخصة والتحديثات الأمنية
هل تستخدم الـ Wrapper؟./gradlew --version أو ./mvnw -vنفس إصدار أداة البناء عند الجميع وفي الـ CI
هل البناء الكامل ينجح عندك قبل أن تغير شيئاً؟./gradlew buildتعرف أن أي فشل بعد ذلك من تعديلك
ما المنسق والفاحص المستخدمان؟الإضافات في ملف البناء، والأمر spotlessApply أو ما يقابلهتتجنب تعليقات المراجعة على المسافات
أين الاختبارات وكيف تشغل اختباراً واحداً؟./gradlew test --tests 'store.VatTest'دورة سريعة أثناء التعديل
كيف تشغل الخدمة محلياً؟المهمة run أو bootRun، أو ملف READMEتجرب التعديل كما سيعمل
أين المشروع على القرص؟داخل نظام ملفات Linux إذا كنت تبني في Containerتجنب البناء الأبطأ ست مرات

تدريب عملي

  • أضف إلى Vat دالة لمجموع فاتورة فيها عدة أسطر بأسعار وكميات مختلفة، واكتب لها اختباراً، ثم جرب فاتورة يتجاوز مجموعها حد int ولاحظ ما يحدث، وأصلحه بـ long أو Math.multiplyExact.
  • أضف إلى Tour.java نوع دفع ثالثاً إلى sealed interface Payment دون أن تعدل الدالة describe، وشغل الملف، واقرأ رسالة المترجم.
  • غير googleJavaFormat("2.0.0") إلى palantirJavaFormat() وشغل spotlessCheck، ولاحظ أي الأسطر تختلف بين الأسلوبين.
  • حول مشروع store-cli إلى Maven بملف pom.xml يعطي نفس النتيجة، وقارن طول الملفين وزمن البناء.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • Java تترجم إلى Bytecode تشغله الـ JVM بالمفسر ثم بـ JIT، وهذا سبب قابليتها للنقل ونضجها، وسبب ثمنها في زمن البدء والذاكرة.
  • Java 25 هي آخر إصدار LTS، وفيها تكتب برنامجاً صغيراً دون class بفضل JEP 512، وتشغله مباشرة بـ java Hello.java، ولكن هذا التشغيل كلف 3 ثوان مقابل 0.14 ثانية من Bytecode جاهز.
  • ما يحدد الصيغة المسموحة في المشروع هو إصدار البناء في ملف البناء، وقد رأيت المترجم يرفض الملف المختصر مع --release 21.
  • الأنواع ثابتة حتى مع var، والسجلات Records وsealed وswitch بالأنماط تغني عن كثير من الكود القديم، وint يلتف بصمت عند تجاوز الحد.
  • مشروع Gradle النموذجي فيه ملف بناء وهيكل src/main/java وsrc/test/java، والبناء يمر بالترجمة مع Error Prone والتنسيق بـ Spotless والاختبارات بـ JUnit 6، وكل محطة أمسكت في التجربة خطأ لا تمسكه الأخرى.
  • البناء على Bind Mount من Windows كان أبطأ ست مرات من البناء داخل الـ Container، فضع المشروع حيث تعمل أداة البناء.

وفي المقالات التالية من مرحلة لغة Java للمطورين سوف ننتقل إلى الأنواع والذاكرة والاستثناءات، ثم التزامن والخيوط الافتراضية، ثم نبني خدمة الطلبات كاملة بـ Spring Boot وPostgreSQL، وتجد هنا المقالات المرتبطة بما شرحناه:

دليلقصة Java من Sun إلى Oracle، وكيف بقيت مفتوحةكيف فتحت Sun كود Java ثم باعتها، وماذا حدث مع Oracle وGoogle في المحاكم، وكيف أصبحت Java تصدر كل ستة أشهر.دليلهل Java مجانية؟ أي JDK تستخدم في شركتكمتى تحتاج اشتراكاً في Oracle JDK ومتى لا تحتاجه، وكيف تعرف نسخ Java التي تعمل عندك وتنقلها إلى نسخة مجانية بأمان.دليلأدوات البناء Build Tools وماذا يحدث عندما تبني مشروعكما الذي يحدث بين ضغطة البناء والملف الناتج، ولماذا ينجح البناء على جهازك ويفشل على سيرفر CI، ومتى يعطيك نفس النتيجة مرتين.دليلمديرو الحزم Package Managers: npm و pip و Maven و Cargoنجرب npm وpip وMaven وCargo على مشروع واحد بأربع لغات، فنقيس كم حزمة تأتي مع كل سطر، ونرى كيف يجعل ملف القفل التثبيت متطابقاً، وكيف خدع حرف واحد في اسم مكتبة مطوري Rust.دليلالمترجم Compiler والمفسر Interpreter و JIT و WebAssemblyنشغل دالة واحدة بلغة C وPython وJavaScript وWebAssembly على نفس الجهاز، ونفتح ما كتبه كل مترجم لنعرف لماذا اختلف الوقت من ثلث ثانية إلى دقيقة.دليلكيف تختار لغة البرمجة Programming Language لمشروعك؟نختار لغة خدمة حقيقية بأسئلة الفريق والتوظيف والمكتبات والرخصة، ثم نكتبها بأربع لغات ونقيس البناء والحجم والذاكرة والطلبات في الثانية بأنفسنا.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على JDK 25.0.5 (Temurin) وGradle 9.8.2 وJUnit 6.1.3 وSpotless 8.10.4 وError Prone 2.51.0 داخل Docker.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.