كل مطور يضغط زر البناء Build في محرره عشرات المرات في اليوم، وفي خط CI يكتب سطراً مثل mvn package أو dotnet build وينتظر النتيجة الخضراء، وغالباً لا يسأل نفسه ماذا حدث بين الضغطة والملف الناتج حتى يأتي يوم يفشل فيه البناء على سيرفر CI وينجح على جهازه، أو يأخذ البناء عشر دقائق بعد أن كان دقيقة، أو يطلب منك المدقق أن تثبت أن الملف المنشور في الإنتاج بني فعلاً من الكود الموجود في المستودع، وعندها تكتشف أن «البناء» عملية لها خطوات ومدخلات ومخرجات، وأن فهمها يوفر عليك ساعات من التخمين.
وفي مقال أنماط البرمجة وأنظمة الأنواع تكلمنا عن الفرق بين اللغات المترجمة Compiled واللغات المفسرة Interpreted، وفي هذا المقال سوف ننتقل خطوة إلى الأمام ونرى ما الذي تفعله أدوات البناء Build Tools فوق المترجم Compiler. وسوف نناقش في هذا المقال ما يلي:
- ماذا تعني كلمة البناء: الترجمة Compile والربط Link والاختبار Test والتحزيم Package والناتج Artifact.
- Make كأصل الفكرة، ولماذا ما زالت قاعدته تعمل في كل أداة حديثة.
- Maven ودورة حياته وملف pom.xml، ثم Gradle، ثم dotnet وMSBuild، ثم npm scripts والمجمعات Bundlers باختصار، ثم البناء المدمج في Go وCargo.
- حل الاعتماديات Dependency Resolution وملفات القفل Lock Files باختصار.
- البناء التدريجي Incremental Build وذاكرة البناء Build Cache.
- البناء القابل للتكرار Reproducible Build: ما تعريفه، وما الذي يكسره.
- كيف يعمل البناء داخل خط CI.
- تجربة عملية نبني فيها نفس البرنامج الصغير بـ Maven وGradle وdotnet داخل Containers، مع المخرجات والأوقات الحقيقية، ثم Make وGo باختصار.
ماذا يحدث عندما تضغط زر البناء؟
البناء Build هو تحويل الكود المصدري Source Code الذي يكتبه الإنسان إلى ملف أو مجموعة ملفات يمكن تشغيلها أو نشرها، ويسمى هذا الملف الناتج Artifact، مثل ملف jar في Java، أو ملف dll في .NET، أو ملف تنفيذي Executable في Go وC، أو مجلد dist فيه ملفات JavaScript مضغوطة لموقع ويب. والصورة التالية تبين الخطوات التي تمر بها أغلب عمليات البناء مهما كانت اللغة:

وفيما يلي معنى كل خطوة باختصار:
- حل الاعتماديات Dependency Resolution: قراءة قائمة المكتبات التي يحتاجها المشروع من ملف الوصف، ثم تنزيلها مع المكتبات التي تحتاجها هي بدورها Transitive Dependencies إلى ذاكرة محلية على الجهاز.
- الترجمة Compile: تحويل الكود إلى bytecode كما في Java و.NET، أو إلى كود آلة Machine Code كما في C وGo وRust، وفي هذه الخطوة تظهر أخطاء الأنواع والأسماء.
- الربط Link: في اللغات التي تنتج كود آلة، يجمع الرابط Linker ملفات الكائنات Object Files والمكتبات في ملف تنفيذي واحد، أما في Java و.NET فالربط يحدث غالباً وقت التشغيل.
- الاختبار Test: تشغيل اختبارات الوحدة Unit Tests، وإذا فشل اختبار واحد يتوقف البناء ولا يخرج أي ملف، وهذا مقصود.
- التحزيم Package: وضع الناتج في شكل قابل للنقل، مثل
jarأو حزمة NuGet أو Docker Image. - النشر Publish: رفع الناتج إلى مستودع Repository أو Registry حتى تستخدمه مشاريع أخرى أو تنشره إلى السيرفر، وهذه الخطوة تكون عادةً في خط CI وليس على جهاز المطور.
وقد يتساءل البعض: لماذا أحتاج أداة بناء أصلاً، ما دام المترجم javac أو gcc موجوداً؟ والإجابة أن مشروعاً حقيقياً فيه مئات الملفات وعشرات المكتبات، وكل واحدة منها بنسخة محددة، وترتيب يجب أن تبنى به الأجزاء، واختبارات يجب أن تعمل قبل التحزيم، فإذا كتبت كل هذا كأوامر يدوية في ملف نصي سوف تحصل على سكربت لا يعرف ما الذي تغير، فيعيد ترجمة كل شيء في كل مرة، ولا يعرف من أين ينزل المكتبات، ويعمل على جهاز كاتبه فقط. وأداة البناء تحل هذه المشكلات الثلاث: تعرف الاعتماديات بين الملفات، وتعرف مصدر المكتبات ونسخها، وتعرف ماذا تعيد بناءه وماذا تترك.
Make: من أين بدأت الفكرة؟
في ورقة Make: A Program for Maintaining Computer Programs التي نشرها Stuart Feldman من Bell Laboratories ضمن توثيق الإصدار السابع من Unix بتاريخ 15 أغسطس 1978، يصف المشكلة التي جاءت منها الأداة بوضوح: في أي مشروع برمجي من السهل أن تفقد متابعة الملفات التي تحتاج إلى إعادة ترجمة بعد تعديل جزء من الكود، فإذا نسيت ترجمة ملف تغير سوف تحصل على برنامج لا يعمل وخطأ يصعب تتبعه، وإذا أعدت ترجمة كل شيء احتياطاً فهذا هدر كبير. وقد حصل Feldman على جائزة ACM Software System Award عام 2003 عن Make، ونص الجائزة يقول إنه لا يكاد يوجد نظام برمجي كبير في العالم لم يمر عبر نسخة من Make أو أداة خرجت منها.
والفكرة بسيطة: تكتب في ملف Makefile قواعد Rules، وكل قاعدة فيها هدف Target والملفات التي يعتمد عليها Prerequisites والأوامر Recipe التي تنتجه، ثم يقرر Make بنفسه ما يعيد بناءه، والقاعدة كما يكتبها دليل GNU Make أن الهدف قديم Out of Date إذا لم يكن موجوداً أو كان أقدم من أي ملف يعتمد عليه، بمقارنة وقت آخر تعديل Modification Time. والصورة التالية تبين ذلك على المثال الذي سوف نشغله في التجربة العملية:

لاحظ أن Make لا يعرف شيئاً عن لغة C، فهو يعرف الملفات وأوقاتها والأوامر فقط، ولذلك ما زال يستخدم حتى اليوم لأغراض لا علاقة لها بالترجمة، مثل ملف Makefile في المجلد الرئيسي للمشروع فيه أوامر مختصرة مثل make test وmake deploy. ولكن هناك مشكلتان رئيسيتان في Make عندما يكبر المشروع: الأولى أنه لا يعرف شيئاً عن المكتبات الخارجية ومن أين تنزل ونسخها، والثانية أن المقارنة بالوقت تخدعه أحياناً، فإذا نسخت الملفات أو استرجعتها من Git بأوقات جديدة يظن أن كل شيء تغير، وإذا تغير خيار في الأمر نفسه مثل CFLAGS لا يعرف أن الناتج يجب أن يتغير. ومن هنا جاءت أدوات تعرف اللغة والمكتبات، وأشهرها في عالم Java أداة Apache Ant ثم Maven ثم Gradle.
Maven: اصطلاح بدل الإعداد
Ant كان يشبه Make بصيغة XML: أنت تكتب كل خطوة بنفسك، وكل مشروع له شكل مختلف، أما Maven فقد عكس الفكرة: بدل أن تكتب الخطوات تصف المشروع، وتلتزم بشكل ثابت للمجلدات (src/main/java للكود وsrc/test/java للاختبارات)، وMaven يعرف الخطوات وحده. ووصف المشروع يكون في ملف pom.xml واختصاره Project Object Model، وفيه إحداثيات المشروع Coordinates (groupId وartifactId وversion) والاعتماديات Dependencies والإضافات Plugins.
دورة حياة البناء Build Lifecycle
بحسب توثيق Maven لدورة حياة البناء هناك ثلاث دورات مدمجة: default للبناء والنشر، وclean للتنظيف، وsite لتوليد موقع توثيق المشروع، والدورة الأساسية default مكونة من مراحل Phases بالترتيب: validate ثم compile ثم test ثم package ثم verify ثم install ثم deploy، والقاعدة المهمة أنك عندما تطلب مرحلة ينفذ Maven كل المراحل التي قبلها بالترتيب، والصورة التالية تبين ذلك:

وفي الإعداد أعلاه لاحظ التالي:
- المرحلة Phase اسم فقط، والعمل الفعلي يقوم به هدف Goal من إضافة Plugin مربوط بها، فمرحلة
compileيقوم بها الهدفcompiler:compileمن الإضافةmaven-compiler-plugin، وسوف ترى هذه الأسماء في مخرج التجربة العملية. installلا يعني تثبيت البرنامج على السيرفر، وإنما نسخ الناتج إلى المستودع المحلي Local Repository في~/.m2/repositoryحتى تستخدمه مشاريع أخرى على نفس الجهاز.deployيرفع الناتج إلى مستودع بعيد Remote Repository مثل Nexus أو Artifactory أو Maven Central، وليس إلى سيرفر الإنتاج.
ما الجديد في Maven 3.10؟
صدر Maven 3.10.0 في 27 سبتمبر 2026، وبحسب ملاحظات الإصدار فإن أهم ما يعنينا هنا ثلاثة أمور: الأول أن البناء القابل للتكرار Reproducible Builds أصبح مفعلاً افتراضياً لأن ملف Super POM يعطي قيمة افتراضية للخاصية project.build.outputTimestamp، والثاني أن Super POM لم يعد يحتوي على إدارة نسخ الإضافات Plugin Management، فالمطلوب أن يحدد كل مشروع نسخ إضافاته بنفسه، والثالث أن التحقق من ملف POM أصبح أشد، فأشياء مثل تكرار نفس الاعتمادية مرتين كانت تعطي تحذيراً في 3.9 وأصبحت توقف البناء. وسوف نرى أثر الأمر الأول في التجربة العملية.
Gradle: البناء ككود
Gradle يأخذ من Maven شكل المجلدات ومستودعات المكتبات، لكنه يكتب ملف البناء كبرنامج بلغة Kotlin أو Groovy بدل XML، ويبني على مفهوم المهام Tasks، وكل مهمة لها مدخلات Inputs ومخرجات Outputs معرفة. وبحسب توثيق البناء التدريجي في Gradle 9.8.0 فإن Gradle يأخذ قبل تنفيذ المهمة بصمة Fingerprint لمدخلاتها فيها مسارات الملفات وهاش Hash لمحتوى كل ملف، وبعد التنفيذ يأخذ بصمة للمخرجات، وفي المرة التالية إذا كانت البصمات نفسها يعتبر المهمة محدثة UP-TO-DATE ويتجاوزها. والفرق المهم عن Make أن المقارنة بالمحتوى وليس بالوقت، وسوف نرى في التجربة أن إضافة تعليق Comment في ملف اختبار تعيد ترجمة الملف ولا تعيد تشغيل الاختبارات، لأن الناتج المترجم لم يتغير.
وGradle يحتاج إلى نسخة محددة منه، لذلك الطريقة التي يوصي بها توثيق Gradle Wrapper هي تشغيل البناء عبر سكربت gradlew الموجود في المستودع، والذي ينزل نسخة Gradle المحددة في المشروع قبل التشغيل، وبهذا يبني كل المطورين وخط CI بنفس النسخة. وفي التجربة العملية استخدمنا Docker Image رسمية فيها Gradle 9.8.0 مباشرة لأن نسخة الأداة ثابتة في وسم ال Image نفسه.
dotnet وMSBuild
في عالم .NET الأمر dotnet build هو الواجهة، والذي يعمل تحتها هو MSBuild، وبحسب توثيق dotnet build فإن الأمر يبني المشروع واعتمادياته إلى ملفات فيها كود IL بامتداد dll، ومعها ملف pdb للتصحيح Debugging، وملف deps.json فيه قائمة الاعتماديات، وملف runtimeconfig.json فيه نسخة ال Runtime المطلوبة، ويشغل قبلها dotnet restore تلقائياً لتنزيل حزم NuGet، وتشغيل dotnet build يعادل dotnet msbuild -restore. ووصف المشروع في ملف csproj بصيغة XML، والاعتماديات فيه عناصر PackageReference كما يشرح توثيق NuGet.
أما البناء التدريجي في MSBuild فيعمل بطريقة قريبة من Make، فبحسب توثيق Incremental Builds لكل هدف Target مدخلات Inputs ومخرجات Outputs، ويقارن MSBuild وقت كل ملف مدخل بوقت الملف الناتج المقابل له، فإذا كانت كل المخرجات أحدث من مدخلاتها يتجاوز الهدف.
dotnet test برسالة تقول إن الاختبار عبر هدف VSTest لم يعد مدعوماً، والحل كما في توثيق dotnet test إضافة قسم test إلى ملف global.json فيه "runner": "Microsoft.Testing.Platform"، وسوف ترى هذا الملف في التجربة العملية.npm scripts والمجمعات Bundlers باختصار
في مشاريع JavaScript لا توجد ترجمة بالمعنى الذي رأيناه في Java، لكن توجد خطوة بناء في أغلب المشاريع الحديثة، لأن الكود يكتب بـ TypeScript أو JSX أو بمئات الملفات الصغيرة، ويحتاج إلى تحويل وتجميع في ملفات قليلة يفهمها المتصفح. والأداة الأولى هنا هي خاصية scripts في ملف package.json، والتي يشرحها توثيق npm scripts، فتكتب فيها أوامر مثل "build": "vite build" وتشغلها بـ npm run build، وهي في الحقيقة أقرب إلى Makefile فيه أوامر مختصرة منها إلى أداة بناء تعرف الاعتماديات بين الملفات. والعمل الفعلي تقوم به المجمعات Bundlers مثل Vite وesbuild، والتي تتبع ملفات import من نقطة البداية وتخرج مجلداً مثل dist فيه الملفات النهائية.
وفي خط CI استخدم npm ci بدل npm install، والسبب كما في توثيق npm ci أنه يشترط وجود ملف package-lock.json، ويتوقف بخطأ إذا كان لا يطابق package.json بدل أن يعدله، ويحذف مجلد node_modules قبل التثبيت، ولا يكتب أبداً في package.json أو ملف القفل.
Go وCargo: البناء جزء من اللغة
اللغات الأحدث لم تترك البناء لأداة خارجية، ففي Go الأمر go build يعرف الحزم والاعتماديات من ملف go.mod، ويحفظ نتائج الترجمة في ذاكرة البناء Build Cache، وبحسب توثيق الأمر go فإن مكانها الافتراضي مجلد اسمه go-build داخل مجلد ال Cache للمستخدم ويمكن تغييره بالمتغير GOCACHE. ومن جهة التكرار فإن Russ Cox كتب في مقال Perfectly Reproducible, Verified Go Toolchains في أغسطس 2023 أن أدوات Go نفسها أصبحت منذ الإصدار 1.21 قابلة للتكرار تماماً، أي أن المدخل الوحيد المؤثر في بنائها هو الكود المصدري.
وفي Rust تقوم Cargo بالدورين معاً: مدير الحزم وأداة البناء، فالأمر cargo build ينزل الاعتماديات ويبنيها ويبني المشروع، والنسخ الدقيقة تحفظ في ملف Cargo.lock الذي يقول عنه دليل Cargo إنه يحتوي معلومات دقيقة عن الاعتماديات وتحدثه Cargo ولا يعدل يدوياً، وإن كان لديك شك فضعه في نظام إدارة النسخ مثل Git.
حل الاعتماديات وملفات القفل باختصار
عندما تكتب في pom.xml أنك تحتاج junit-jupiter بالإصدار 6.1.3، فأنت لا تطلب مكتبة واحدة، وإنما شجرة كاملة، وسوف ترى في التجربة العملية أن هذه المكتبة الواحدة جلبت معها ثماني مكتبات أخرى، وأداة البناء هي التي تقرر أي نسخة تأخذ إذا طلبت مكتبتان نسختين مختلفتين من نفس الاعتمادية، ولكل أداة قاعدتها، ففي Maven مثلاً يشرح توثيق آلية الاعتماديات قاعدة «الأقرب يفوز» Nearest Definition.
وملف القفل Lock File يسجل النسخ الدقيقة التي اختيرت، حتى يحصل كل مطور وكل بناء في CI على نفس الشجرة، ومثاله package-lock.json في npm وCargo.lock في Rust وgo.sum في Go، وفي Gradle يمكن تفعيل قفل الاعتماديات Dependency Locking. أما أمان هذه الشجرة، أي من أين تأتي الحزم وكيف تكتشف الحزم الخبيثة والثغرات المعروفة فيها، فهو موضوع مستقل شرحناه في مقال أمن سلسلة التوريد البرمجية ومقال فحص الاعتماديات SCA، ولن نكرره هنا.
البناء التدريجي Incremental Build وذاكرة البناء Build Cache
البناء الكامل في كل مرة هو الحل البسيط، وهو صحيح دائماً لكنه بطيء، لذلك تحاول كل أداة أن تعرف ما الذي تغير وتبني ذلك فقط، وهذا هو البناء التدريجي Incremental Build. وهناك طريقتان للمعرفة: المقارنة بوقت التعديل كما في Make وMSBuild، وهي سريعة لكنها تخطئ إذا تغيرت الأوقات دون تغير المحتوى، والمقارنة بهاش المحتوى كما في Gradle وGo، وهي أدق. والصورة التالية تبين كيف يقرر Gradle مصير كل مهمة:

وذاكرة البناء Build Cache تذهب خطوة أبعد من البناء التدريجي، فالبناء التدريجي يعرف آخر تشغيل في نفس المجلد فقط، أما ال Build Cache فيحفظ مخرجات كل مهمة مع بصمة مدخلاتها، فإذا ظهرت نفس البصمة مرة أخرى في أي وقت أو في أي مجلد أو حتى على جهاز آخر عبر Cache مشترك Remote Cache تنسخ المخرجات بدل التنفيذ، وهذا ما يعرفه توثيق Gradle Build Cache بأنه آلية تحفظ مخرجات البناء محلياً أو عن بعد وتعيد استخدامها عندما تكون المدخلات لم تتغير. وفي التجربة العملية سوف ترى FROM-CACHE بعد gradle clean مباشرة.
وقد يتساءل البعض: هل يمكن أن يعطيني ال Cache ناتجاً خاطئاً؟ نعم، إذا كانت المهمة تعتمد على شيء لم يعلن كمدخل، مثل متغير بيئة Environment Variable تقرؤه أداة خارجية، أو ملف خارج المشروع، وعندها تتطابق البصمة ويختلف الناتج الحقيقي، لذلك إذا كتبت مهام بناء خاصة بك فعرف كل مدخلاتها، وإذا شككت في ناتج غريب فابن مرة بدون Cache لتتأكد، مثل dotnet build --no-incremental أو gradle build --rerun-tasks.
البناء القابل للتكرار Reproducible Build
التعريف الذي يعتمده مشروع Reproducible Builds في صفحة التعريف هو أن البناء قابل للتكرار إذا استطاع أي طرف، بنفس الكود المصدري وبيئة البناء وتعليمات البناء، أن ينتج نسخاً مطابقة بايت ببايت Bit-by-bit من كل الملفات الناتجة المحددة. والفائدة الأمنية مباشرة: إذا بنى طرفان مستقلان نفس الكود وحصلا على نفس الهاش، فأنت تعرف أن الملف المنشور لم يحقن فيه شيء أثناء البناء، وهذه نقطة يعود إليها مقال أمن سلسلة التوريد عند الكلام عن اختراق نظام البناء نفسه.

والصعوبة أن البيئة تتسرب إلى الناتج بطرق لا تنتبه لها، وأشهرها أربعة:
- الوقت Timestamps: ملفات
jarوzipتحفظ وقت كل ملف داخلها، فإذا بنيت مرتين في دقيقتين مختلفتين يختلف الهاش، والحل المتعارف عليه وقت ثابت مثل المتغير SOURCE_DATE_EPOCH، وفي Maven الخاصيةproject.build.outputTimestampالتي يشرحها دليل Maven للبناء القابل للتكرار. - المسار Build Path: بعض المترجمات تكتب المسار الكامل للمجلد داخل الناتج، وتوثيق خيار Deterministic في مترجم C# يذكر مسار المجلد الحالي ضمن المدخلات التي تؤثر على الناتج، وسوف نرى ذلك في التجربة العملية، وفي Go الخيار
-trimpathيحذف مسارات الملفات من الناتج. - نسخ الأدوات Tool Versions: نسخة مختلفة من المترجم أو من إضافة Plugin قد تنتج bytecode مختلفاً، لذلك ثبت نسخة ال Docker Image ونسخ الإضافات.
- ترتيب الملفات File Order: ترتيب الملفات في الأرشيف قد يتبع ترتيب نظام الملفات الذي يختلف من جهاز لآخر، وبحسب توثيق Gradle للتعامل مع الملفات فإن مهام الأرشيف مثل Jar وZip تنتج منذ Gradle 9.0.0 أرشيفات قابلة للتكرار افتراضياً بترتيب ثابت للملفات وأوقات ثابتة وصلاحيات ثابتة.
والخبر الجيد أن الأدوات الكبيرة جعلت ذلك افتراضياً تقريباً في 2026: Gradle 9 للأرشيفات، وMaven 3.10 بقيمة افتراضية للوقت، و.NET بخاصية Deterministic المفعلة افتراضياً كما يذكر توثيق المترجم أعلاه، ويبقى عليك أن تثبت البيئة نفسها، وأسهل طريقة لذلك أن تبني داخل Container بنسخة Image محددة وفي مسار ثابت.
البناء داخل خط CI
على جهاز المطور يكون البناء سريعاً لأن الذاكرة المحلية ممتلئة بالمكتبات والمخرجات السابقة، أما في CI فكل مهمة تبدأ عادةً من Container جديد فارغ، وإذا لم تنتبه سوف ينزل كل بناء نفس المكتبات من الإنترنت في كل مرة. والصورة التالية تبين أين يحدث البناء في خط CI:

وفيما يلي القواعد التي تجعل البناء في CI سريعاً وموثوقاً:
- ابن داخل Container بنسخة محددة مثل
maven:3.10.0-eclipse-temurin-25، ولا تستخدم وسماً متحركاً مثلlatest، والسبب أن الوسم المتحرك يغير نسخة الأداة دون أن يتغير الكود، فيتغير الناتج أو يفشل البناء فجأة. - احفظ ذاكرة الاعتماديات بين التشغيلات: مجلد
~/.m2في Maven، و~/.gradleفي Gradle، و~/.nuget/packagesفي .NET، إما عبر آلية ال Cache في منصة CI، أو عبر Volume في ال Runner الخاص بك، أو عبر Cache Mounts في Docker BuildKit إذا كان البناء داخل Dockerfile. - شغل الاختبارات كجزء من البناء، ولا تنشر ناتجاً تجاوز الاختبارات بخيار مثل
-DskipTests. - ابن مرة واحدة وانقل الناتج، فلا تعيد البناء لكل بيئة، والسبب أن البناء الثاني قد يختلف عن الأول، وقد شرحنا هذه الفكرة في مقال البيئات وGitOps.
- إذا كان الناتج Docker Image فاستخدم البناء متعدد المراحل Multi-stage Build: مرحلة فيها SDK كامل تبني، ومرحلة أخيرة صغيرة فيها ال Runtime والناتج فقط.
وإذا كنت تشغل CI على سيرفرك فقد شرحنا تجهيز Runner وأول Workflow يبني Image ويرفعها في دليل Gitea Actions، وأين تضع فحوص الأمان بجانب البناء في مقال DevSecOps للمبتدئين.
تجربة عملية: نفس البرنامج بثلاث أدوات بناء
الآن سوف نبني برنامجاً صغيراً واحداً بـ Maven ثم Gradle ثم dotnet، والبرنامج يحسب السعر بعد ضريبة القيمة المضافة 15% بالهللات (حتى نتجنب أخطاء الأرقام العشرية)، ومعه اختباران، وكل بناء يعمل داخل Container بنسخة محددة من الأداة، والذاكرة المحلية للمكتبات في Volume منفصل حتى نرى الفرق بين البناء الأول والبناء التالي. والأوقات التي سوف تراها حقيقية من جهاز واحد، لكنها توضيحية فقط ولا تصلح للمقارنة بين الأدوات، لأن كل أداة تنزل مكتبات مختلفة وتشغل JVM أو Runtime مختلفاً.
sleep infinity، ثم ننسخ الملفات إليه بـ docker cp وننفذ البناء بـ docker exec، وبهذا تعمل نفس الأوامر على Linux وWindows وmacOS دون مشكلات المسارات المشتركة، وفي مشروعك الحقيقي يكفي docker run --rm -v "$PWD":/src ….Maven: البناء الأول والثاني
المثال التالي يبين ملف pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>io.arabroot</groupId>
<artifactId>vat</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.3</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>والكود في الملف src/main/java/io/arabroot/Vat.java:
package io.arabroot;
public final class Vat {
public static final int RATE_PERCENT = 15;
// amounts in halalas (1 riyal = 100 halalas) to avoid floating point errors
public static long total(long net) {
if (net < 0) throw new IllegalArgumentException("net must be >= 0");
return net + Math.round(net * RATE_PERCENT / 100.0);
}
public static void main(String[] args) {
long net = Long.parseLong(args.length > 0 ? args[0] : "10000");
System.out.println("net=" + net + " total=" + total(net));
}
}والاختبار في الملف src/test/java/io/arabroot/VatTest.java:
package io.arabroot;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class VatTest {
@Test
void addsFifteenPercent() {
assertEquals(11500, Vat.total(10000));
}
@Test
void rejectsNegative() {
assertThrows(IllegalArgumentException.class, () -> Vat.total(-1));
}
}الآن سوف نقوم بتشغيل ال Container ونسخ المشروع إليه، ثم البناء الأول والذاكرة المحلية فارغة:
docker run -d --name lab-build-maven -v lab-build-m2:/root/.m2 -w /src maven:3.10.0-eclipse-temurin-25 sleep infinity
docker cp . lab-build-maven:/src
docker exec lab-build-maven mvn -v
docker exec lab-build-maven bash -c 'time mvn -B package'والمخرج سوف يكون كما يلي، مختصراً: أبقينا سطرين من أسطر التنزيل الكثيرة (182 ملفاً) وحذفنا الأسطر الفارغة:
Apache Maven 3.10.0 (c43a36b8d67be7e0805a411bc0898af1a51f5472)
Maven home: /usr/share/maven
Java version: 25.0.4.1, vendor: Eclipse Adoptium, runtime: /opt/java/openjdk
Default locale: en_US, platform encoding: UTF-8, time zone: Etc/UTC
OS name: "linux", version: "5.15.167.4-microsoft-standard-wsl2", arch: "amd64", family: "unix"
[INFO] Downloaded from central: https://repo.maven.apache.org/maven2/org/apache/maven/plugins/maven-resources-plugin/3.5.0/maven-resources-plugin-3.5.0.pom (7.7 kB at 24 kB/s)
[INFO] Downloaded from central: https://repo.maven.apache.org/maven2/org/junit/jupiter/junit-jupiter-api/6.1.3/junit-jupiter-api-6.1.3.jar (313 kB at 498 kB/s)
[INFO] --- resources:3.5.0:resources (default-resources) @ vat ---
[INFO] skip non existing resourceDirectory /src/src/main/resources
[INFO] --- compiler:3.16.0:compile (default-compile) @ vat ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 1 source file with javac [debug release 25] to target/classes
[INFO] --- resources:3.5.0:testResources (default-testResources) @ vat ---
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ vat ---
[INFO] Compiling 1 source file with javac [debug release 25] to target/test-classes
[INFO] --- surefire:3.6.0:test (default-test) @ vat ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO] Running io.arabroot.VatTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.282 s -- in io.arabroot.VatTest
[INFO] --- jar:3.5.1:jar (default-jar) @ vat ---
[INFO] Building jar: /src/target/vat-1.0.0.jar
[INFO] BUILD SUCCESS
[INFO] Total time: 01:22 min
real 1m31.769sفي المخرج أعلاه لاحظ التالي:
- كل سطر يبدأ بـ
---هو هدف Goal من إضافة مربوط بمرحلة:resourcesثمcompiler:compileثمsurefire:testثمjar:jar، وهذا هو ترتيب دورة الحياة الذي رأيناه في الصورة. - نسخ الإضافات مثل
compiler:3.16.0وsurefire:3.6.0جاءت من الإعداد الافتراضي لأننا لم نحددها، وفي مشروع حقيقي حددها في قسمpluginManagement، والسبب أن Maven 3.10 حذف إدارة نسخ الإضافات من Super POM كما ذكرنا، وتحديدها يجعل البناء لا يتغير مع نسخة Maven. - أغلب الدقيقة والنصف ذهبت في تنزيل 182 ملفاً هي الإضافات والمكتبات، والترجمة نفسها أجزاء من الثانية.
وشجرة الاعتماديات التي جاءت من سطر واحد في pom.xml تراها بالأمر التالي:
docker exec lab-build-maven mvn -B -o dependency:tree[INFO] io.arabroot:vat:jar:1.0.0
[INFO] \- org.junit.jupiter:junit-jupiter:jar:6.1.3:test
[INFO] +- org.junit.jupiter:junit-jupiter-api:jar:6.1.3:test
[INFO] | +- org.opentest4j:opentest4j:jar:1.3.0:test
[INFO] | +- org.junit.platform:junit-platform-commons:jar:6.1.3:test
[INFO] | +- org.apiguardian:apiguardian-api:jar:1.1.2:test
[INFO] | \- org.jspecify:jspecify:jar:1.0.0:test
[INFO] +- org.junit.jupiter:junit-jupiter-params:jar:6.1.3:test
[INFO] \- org.junit.jupiter:junit-jupiter-engine:jar:6.1.3:test
[INFO] \- org.junit.platform:junit-platform-engine:jar:6.1.3:testالآن البناء الثاني بعد clean والذاكرة المحلية ممتلئة، ثم بناء ثالث دون أي تغيير، ثم تشغيل الناتج:
docker exec lab-build-maven bash -c 'du -sh /root/.m2/repository; time mvn -B -q clean package'
docker exec lab-build-maven bash -c 'time mvn -B package | grep -E "compiler|Nothing to compile|Total time"'
docker exec lab-build-maven java -cp target/vat-1.0.0.jar io.arabroot.Vat 1000025M /root/.m2/repository
real 0m29.310s
[INFO] --- compiler:3.16.0:compile (default-compile) @ vat ---
[INFO] Nothing to compile - all classes are up to date.
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ vat ---
[INFO] Nothing to compile - all classes are up to date.
[INFO] Total time: 13.348 s
real 0m20.534s
net=10000 total=11500لاحظ أن البناء نزل من دقيقة ونصف إلى نصف دقيقة لأن 25 ميجابايت من المكتبات أصبحت في ال Volume، وأن البناء دون تغيير لم يترجم شيئاً (Nothing to compile) لكنه أخذ عشرين ثانية، والسبب أن Maven يعيد تشغيل الاختبارات وتحزيم ال jar في كل مرة، فالبناء التدريجي في Maven محدود بالترجمة، وهذا من أهم الفروق عن Gradle كما سيأتي.
نكسر البناء: اختبار يفشل
الآن سوف نغير نسبة الضريبة في الكود إلى 14% عن قصد، ونرى ماذا يحدث للبناء:
docker exec lab-build-maven bash -c 'sed -i "s/RATE_PERCENT = 15/RATE_PERCENT = 14/" src/main/java/io/arabroot/Vat.java; mvn -B -o clean package 2>&1 | grep -E "ERROR|FAIL|Tests run|BUILD|expected" | head -12; ls target/*.jar'[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.867 s <<< FAILURE! -- in io.arabroot.VatTest
[ERROR] io.arabroot.VatTest.addsFifteenPercent -- Time elapsed: 0.293 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <11500> but was: <11400>
[ERROR] Failures:
[ERROR] VatTest.addsFifteenPercent:9 expected: <11500> but was: <11400>
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
[ERROR] BUILD FAILURE
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.6.0:test (default-test) on project vat: There are test failures.
[ERROR]
[ERROR] See /src/target/surefire-reports for the individual test results.
[ERROR] See dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream.
[ERROR] -> [Help 1]
ls: cannot access 'target/*.jar': No such file or directoryوهذا بالضبط ما تريده من أداة البناء: فشل الاختبار أوقف دورة الحياة عند مرحلة test، فلم تصل إلى package ولم يخرج أي ملف jar يمكن أن ينشر بالخطأ، وسطر expected: <11500> but was: <11400> يخبرك بالقيمة المتوقعة والقيمة الفعلية ورقم السطر في الاختبار. بعدها أعدنا القيمة إلى 15.
هل بناء Maven قابل للتكرار؟
الآن سوف نبني مرتين بينهما ثلاث ثوان ونقارن الهاش، دون أن نضيف أي إعداد خاص في pom.xml:
docker exec lab-build-maven bash -c 'mvn -B -q -o clean package -DskipTests && sha256sum target/vat-1.0.0.jar && sleep 3 && mvn -B -q -o clean package -DskipTests && sha256sum target/vat-1.0.0.jar'
docker exec lab-build-maven jar tvf target/vat-1.0.0.jar
docker exec lab-build-maven bash -c 'mvn -B -q help:effective-pom -Doutput=/tmp/ep.xml >/dev/null 2>&1; grep -n outputTimestamp /tmp/ep.xml'67b79ece197d767cebe19cbaff91934f654e21ac5c53742e1be123bda80f66e7 target/vat-1.0.0.jar
67b79ece197d767cebe19cbaff91934f654e21ac5c53742e1be123bda80f66e7 target/vat-1.0.0.jar
0 Fri Feb 01 00:00:00 UTC 1980 META-INF/
99 Fri Feb 01 00:00:00 UTC 1980 META-INF/MANIFEST.MF
0 Fri Feb 01 00:00:00 UTC 1980 io/
0 Fri Feb 01 00:00:00 UTC 1980 io/arabroot/
0 Fri Feb 01 00:00:00 UTC 1980 META-INF/maven/
0 Fri Feb 01 00:00:00 UTC 1980 META-INF/maven/io.arabroot/
0 Fri Feb 01 00:00:00 UTC 1980 META-INF/maven/io.arabroot/vat/
1456 Fri Feb 01 00:00:00 UTC 1980 io/arabroot/Vat.class
806 Fri Feb 01 00:00:00 UTC 1980 META-INF/maven/io.arabroot/vat/pom.xml
49 Fri Feb 01 00:00:00 UTC 1980 META-INF/maven/io.arabroot/vat/pom.properties
20: <project.build.outputTimestamp>1980-02-01T00:00:00Z</project.build.outputTimestamp>الهاش متطابق، وكل الملفات داخل ال jar بتاريخ 1 فبراير 1980 بدل وقت البناء، والسبب هو القيمة الافتراضية التي أضافها Maven 3.10 للخاصية project.build.outputTimestamp كما ترى في السطر الأخير. وفي مشروعك الحقيقي ضع قيمة لهذه الخاصية في pom.xml بنفسك (عادةً تاريخ آخر commit أو تاريخ الإصدار) بدل الاعتماد على القيمة الافتراضية، وتحقق بالأمر mvn clean verify artifact:compare كما يشرح دليل Maven للبناء القابل للتكرار.
Gradle: UP-TO-DATE وFROM-CACHE
نفس مجلد src بدون تغيير، ومعه ملفان بدل pom.xml، الأول settings.gradle.kts:
rootProject.name = "vat"والثاني build.gradle.kts:
plugins {
java
}
group = "io.arabroot"
version = "1.0.0"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
repositories {
mavenCentral()
}
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter:6.1.3")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform()
}الآن البناء الأول:
docker run -d --name lab-build-gradle -v lab-build-gradle-home:/home/gradle/.gradle -w /src gradle:9.8.0-jdk25 sleep infinity
docker cp . lab-build-gradle:/src
docker exec lab-build-gradle bash -c 'time gradle build --console=plain'Starting a Gradle Daemon (subsequent builds will be faster)
> Task :compileJava
> Task :processResources NO-SOURCE
> Task :classes
> Task :jar
> Task :assemble
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test
> Task :check
> Task :build
BUILD SUCCESSFUL in 1m 41s
4 actionable tasks: 4 executed
real 1m42.067sثم ثلاث تجارب متتالية: بناء دون تغيير، ثم إضافة تعليق Comment فقط في ملف الاختبار، ثم clean مع ذاكرة البناء --build-cache مرتين:
docker exec lab-build-gradle bash -c 'gradle build --console=plain
echo "// a comment only" >> src/test/java/io/arabroot/VatTest.java; gradle build --console=plain
gradle clean build --build-cache --console=plain -q; sha256sum build/libs/vat-1.0.0.jar
gradle clean build --build-cache --console=plain; sha256sum build/libs/vat-1.0.0.jar'> Task :compileJava UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar UP-TO-DATE
> Task :assemble UP-TO-DATE
> Task :compileTestJava UP-TO-DATE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test UP-TO-DATE
> Task :check UP-TO-DATE
> Task :build UP-TO-DATE
BUILD SUCCESSFUL in 3s
4 actionable tasks: 4 up-to-date
> Task :compileJava UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar UP-TO-DATE
> Task :assemble UP-TO-DATE
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test UP-TO-DATE
> Task :check UP-TO-DATE
> Task :build UP-TO-DATE
BUILD SUCCESSFUL in 3s
4 actionable tasks: 1 executed, 3 up-to-date
b8ccfef595abf5d48bd6c95543b74cedf6ddb80816dc4e95dcfd815f5bba5531 build/libs/vat-1.0.0.jar
> Task :clean
> Task :compileJava FROM-CACHE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar
> Task :assemble
> Task :compileTestJava FROM-CACHE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test FROM-CACHE
> Task :check UP-TO-DATE
> Task :build
BUILD SUCCESSFUL in 3s
5 actionable tasks: 2 executed, 3 from cache
b8ccfef595abf5d48bd6c95543b74cedf6ddb80816dc4e95dcfd815f5bba5531 build/libs/vat-1.0.0.jarفي المخرج أعلاه لاحظ التالي:
- البناء دون تغيير أخذ 3 ثوان وكل المهام
UP-TO-DATEبما فيهاtest، على عكس Maven الذي أعاد تشغيل الاختبارات في كل مرة. - بعد إضافة التعليق أعاد Gradle ترجمة ملف الاختبار فقط (
compileTestJava)، لكن مهمةtestبقيتUP-TO-DATE، والسبب أن التعليق لا يظهر في ال bytecode، فالمخرجات المترجمة التي هي مدخلات مهمة الاختبار لم يتغير هاشها، وهذا هو الفرق بين المقارنة بالمحتوى والمقارنة بالوقت. - بعد
cleanجاءت نتائج الترجمة والاختبار من ال Build Cache (FROM-CACHE) فلم تنفذ الاختبارات من جديد، وأعيد تحزيم ال jar فقط. - الهاش
b8ccfef5…هو نفسه في المرتين، وهو أيضاً نفس الهاش الذي حصلنا عليه في Container سابق حذفناه وبدأنا من جديد، أي أن ال jar قابل للتكرار بين بيئتين نظيفتين دون أي إعداد إضافي، وهذا أثر الأرشيفات القابلة للتكرار افتراضياً في Gradle 9.
dotnet: نفس البرنامج بلغة C#
المشروع هنا مكون من مشروعين: البرنامج ومشروع الاختبارات، والمثال التالي يبين الملف Vat/Vat.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>والكود في الملف Vat/Vat.cs:
namespace ArabRoot;
public static class Vat
{
public const int RatePercent = 15;
// amounts in halalas (1 riyal = 100 halalas)
public static long Total(long net)
{
ArgumentOutOfRangeException.ThrowIfNegative(net);
return net + (long)Math.Round(net * RatePercent / 100.0, MidpointRounding.AwayFromZero);
}
public static void Main(string[] args)
{
long net = args.Length > 0 ? long.Parse(args[0]) : 10000;
Console.WriteLine($"net={net} total={Total(net)}");
}
}ومشروع الاختبار Vat.Tests/Vat.Tests.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="4.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Vat/Vat.csproj" />
</ItemGroup>
</Project>والاختبار في الملف Vat.Tests/VatTests.cs:
using Xunit;
namespace ArabRoot.Tests;
public class VatTests
{
[Fact]
public void AddsFifteenPercent() => Assert.Equal(11500, Vat.Total(10000));
[Fact]
public void RejectsNegative() =>
Assert.Throws<ArgumentOutOfRangeException>(() => Vat.Total(-1));
}وملف global.json في المجلد الرئيسي حتى يعمل dotnet test بوضع Microsoft.Testing.Platform:
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}الآن سوف نقوم بتشغيل ال Container وإنشاء ملف ال Solution بصيغة slnx ثم البناء الأول:
docker run -d --name lab-build-dotnet -v lab-build-nuget:/root/.nuget/packages -w /src -e DOTNET_CLI_TELEMETRY_OPTOUT=1 -e DOTNET_NOLOGO=1 mcr.microsoft.com/dotnet/sdk:10.0.401 sleep infinity
docker cp . lab-build-dotnet:/src
docker exec lab-build-dotnet bash -c 'dotnet new sln -n Vat --format slnx && dotnet sln add Vat/Vat.csproj Vat.Tests/Vat.Tests.csproj; time dotnet build -c Release --tl:off'وآخر أسطر المخرج:
Determining projects to restore...
Restored /src/Vat/Vat.csproj (in 292 ms).
Restored /src/Vat.Tests/Vat.Tests.csproj (in 30.21 sec).
Vat -> /src/Vat/bin/Release/net10.0/Vat.dll
Vat.Tests -> /src/Vat.Tests/bin/Release/net10.0/Vat.Tests.dll
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:00:43.55
real 0m44.549sثم الاختبارات، ثم بناء دون تغيير، ثم الملفات الناتجة وحجم ذاكرة الحزم:
docker exec lab-build-dotnet bash -c 'dotnet test -c Release --no-build'
docker exec lab-build-dotnet bash -c 'time dotnet build -c Release --tl:off 2>&1 | tail -5'
docker exec lab-build-dotnet bash -c 'dotnet Vat/bin/Release/net10.0/Vat.dll 10000; ls Vat/bin/Release/net10.0/; du -sh /root/.nuget/packages; ls /root/.nuget/packages | wc -l'Running tests from /src/Vat.Tests/bin/Release/net10.0/Vat.Tests.dll (net10.0|x64)
/src/Vat.Tests/bin/Release/net10.0/Vat.Tests.dll (net10.0|x64) passed (1s 388ms)
Test run summary: Passed!
total: 2
failed: 0
succeeded: 2
skipped: 0
duration: 2s 344ms
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:00:05.86
real 0m6.602s
net=10000 total=11500
Vat
Vat.deps.json
Vat.dll
Vat.pdb
Vat.runtimeconfig.json
39M /root/.nuget/packages
17لاحظ أن الملفات الناتجة هي نفسها التي ذكرها توثيق dotnet build: ملف dll وملف pdb وملف deps.json وملف runtimeconfig.json، ومعها ملف تنفيذي صغير Vat يشغل ال dll، وأن الحزمة الواحدة xunit.v3 جلبت معها 17 حزمة بحجم 39 ميجابايت، وأن الاستعادة Restore أخذت ثلاثين ثانية من أصل 44.
نكسر التكرار: نفس الكود في مجلد آخر
خاصية Deterministic مفعلة افتراضياً في .NET، فهل يكفي ذلك؟ الآن سوف نبني نفس المشروع مرتين في /src، ثم مرة في نسخة منه في /build2، ونطبع الهاش ومسار ملف ال pdb المكتوب داخل ال dll:
docker exec lab-build-dotnet bash -c 'cp -r /src /build2; for d in /src /src /build2; do cd $d; dotnet build Vat/Vat.csproj -c Release --no-incremental -v q --tl:off >/dev/null; echo "$d: $(sha256sum Vat/bin/Release/net10.0/Vat.dll | cut -c1-16) $(grep -a -o "[/a-zA-Z0-9_.]*Vat\.pdb" Vat/bin/Release/net10.0/Vat.dll)"; done'/src: 234b072f795185e1 /src/Vat/obj/Release/net10.0/Vat.pdb
/src: 234b072f795185e1 /src/Vat/obj/Release/net10.0/Vat.pdb
/build2: dc7fa4f236a99272 /build2/Vat/obj/Release/net10.0/Vat.pdbفي نفس المجلد الهاش متطابق، وفي مجلد آخر تغير الهاش مع أن الكود والأداة نفسها، والسبب ظاهر في العمود الثاني: المسار الكامل لملف ال pdb مكتوب داخل ال dll، وهذا ما يقصده توثيق المترجم عندما يضع مسار المجلد الحالي ضمن مدخلات الناتج. والحل العملي أن تبني دائماً في مسار ثابت داخل Container (مثل /src في كل بناء)، أو أن تستخدم خيارات تعيين المسارات في المترجم إذا كان لا بد من مسارات مختلفة.
Make وGo باختصار
أخيراً المثال الصغير بلغة C الذي رأيته في صورة Make، وملف Makefile كما يلي (الأسطر التي تحت كل قاعدة تبدأ بحرف Tab وليس بمسافات):
CC = gcc
CFLAGS = -O2 -Wall
vat: main.o vat.o
$(CC) -o vat main.o vat.o
main.o: main.c vat.h
$(CC) $(CFLAGS) -c main.c
vat.o: vat.c vat.h
$(CC) $(CFLAGS) -c vat.c
clean:
rm -f vat *.o
.PHONY: cleanوالملفات الثلاثة vat.h وvat.c وmain.c:
/* vat.h */
long vat_total(long net);
/* vat.c */
#include "vat.h"
long vat_total(long net) { return net + (net * 15 + 50) / 100; }
/* main.c */
#include <stdio.h>
#include "vat.h"
int main(void) { printf("net=10000 total=%ld\n", vat_total(10000)); return 0; }نشغلها في Image الرسمية لـ Go لأنها تحتوي على gcc وmake:
docker run -d --name lab-build-make -w /src golang:1.27.1-bookworm sleep infinity
docker cp . lab-build-make:/src
docker exec lab-build-make bash -c 'make; ./vat; make; sleep 1; touch vat.c; make; touch vat.h; make'gcc -O2 -Wall -c main.c
gcc -O2 -Wall -c vat.c
gcc -o vat main.o vat.o
net=10000 total=11500
make: 'vat' is up to date.
gcc -O2 -Wall -c vat.c
gcc -o vat main.o vat.o
gcc -O2 -Wall -c main.c
gcc -O2 -Wall -c vat.c
gcc -o vat main.o vat.oالبناء الأول ترجم الملفين وربطهما، والثاني لم يفعل شيئاً، وبعد touch vat.c أعاد ترجمة vat.c فقط ثم الربط، وبعد touch vat.h أعاد ترجمة الملفين لأن كليهما يعتمد عليه، وهذا كله دون أن يتغير حرف واحد في المحتوى، لأن touch يغير الوقت فقط وMake يقرأ الوقت.
وفي Go نفس البرنامج في ملفين، go.mod:
module example.com/vat
go 1.27وmain.go:
package main
import "fmt"
func total(net int64) int64 { return net + (net*15+50)/100 }
func main() { fmt.Printf("net=10000 total=%d\n", total(10000)) }والتجربة: بناء بذاكرة بناء فارغة، ثم بناء ثان، ثم البناء في مجلدين مختلفين مع -trimpath وبدونه:
docker exec lab-build-make bash -c 'cp -r /go-src /tmp/other; go clean -cache; cd /go-src; time go build -o vat .; rm vat; time go build -o vat .; ./vat
for d in /go-src /tmp/other; do cd $d; go build -o vat . && echo "$d default: $(sha256sum vat | cut -c1-16)"; go build -trimpath -o vat . && echo "$d -trimpath: $(sha256sum vat | cut -c1-16)"; done'real 0m25.343s
real 0m0.528s
net=10000 total=11500
/go-src default: 9accaa7a1cd41e6b
/go-src -trimpath: e7d2f2e2972dae34
/tmp/other default: 229d1e91e93fcb6f
/tmp/other -trimpath: e7d2f2e2972dae34البناء الأول أخذ 25 ثانية لأن go clean -cache حذف حتى نتائج ترجمة المكتبة القياسية Standard Library، والثاني نصف ثانية من ال Build Cache، وفي المجلدين اختلف الهاش بدون -trimpath وتطابق معه، وهي نفس مشكلة المسار التي رأيناها في .NET.
النتائج في جدول واحد
| الأداة | البناء الأول | بعد امتلاء ال Cache | دون أي تغيير | تكرار الهاش |
|---|---|---|---|---|
| Maven 3.10.0 | 1m31s مع 182 تنزيلاً | 29s (مع clean) | 20s، الاختبارات تعمل مرة أخرى | متطابق افتراضياً بوقت 1980 |
| Gradle 9.8.0 | 1m42s | 3s مع FROM-CACHE | 3s، كل المهام UP-TO-DATE | متطابق حتى بين Containers جديدة |
| dotnet SDK 10.0.401 | 44s منها 30s للاستعادة | لم نقس | 6.6s | متطابق في نفس المسار فقط |
| Go 1.27.1 | 25s مع المكتبة القياسية | 0.5s | 0.5s | متطابق مع -trimpath |
والأرقام من جهاز واحد في يوم واحد، وهي توضيحية، والدرس منها ليس أي أداة أسرع، وإنما أن البناء الأول يذهب أغلبه في التنزيل، لذلك ذاكرة الاعتماديات في CI تختصر أكبر جزء من الوقت.
التنظيف
docker rm -f lab-build-maven lab-build-gradle lab-build-dotnet lab-build-make
docker volume rm lab-build-m2 lab-build-gradle-home lab-build-nuget
docker image rm maven:3.10.0-eclipse-temurin-25 gradle:9.8.0-jdk25 mcr.microsoft.com/dotnet/sdk:10.0.401واحذف ال Images فقط إذا لم تكن تستخدمها في مشاريع أخرى.
أي أداة تستخدم؟
في أغلب الحالات لا تختار أداة البناء، وإنما تأتي مع اللغة والفريق: Go وRust معهما أداتهما، و.NET معه dotnet وMSBuild، وJavaScript معه npm ومجمع مثل Vite. والاختيار الحقيقي في Java بين Maven وGradle، وفيما يلي نقاط القوة ونقاط الضعف لكل منهما:
| الأداة | نقاط القوة | نقاط الضعف |
|---|---|---|
| Maven | شكل ثابت لكل المشاريع، أي مطور Java يفهم pom.xml فوراً، دعم واسع في كل أدوات CI والفحص | XML طويل، البناء التدريجي محدود بالترجمة، والمنطق الخاص يحتاج إضافة Plugin |
| Gradle | بناء تدريجي بالمحتوى، Build Cache محلي ومشترك، مرونة كاملة لأن الملف كود | المرونة نفسها تجعل كل مشروع مختلفاً، ومنحنى تعلم أعلى |
والقاعدة: للمشاريع الصغيرة والمتوسطة وللفرق التي تريد شيئاً لا يحتاج تفكيراً Maven يكفي، وللمشاريع الكبيرة متعددة الوحدات Multi-module حيث يصبح وقت البناء مشكلة يومية فإن Gradle مع ال Build Cache يوفر وقتاً حقيقياً، وإذا كبر المشروع أكثر من ذلك ستسمع عن أدوات مثل Bazel المصممة للمستودعات الكبيرة جداً مع Cache مشترك.
أخطاء شائعة
- «يعمل على جهازي»: نسخة JDK أو SDK على جهاز المطور تختلف عن CI، والحل نسخة محددة في Image البناء، وGradle Wrapper أو ملف
global.jsonلتثبيت نسخة SDK في .NET. - وسم
latestفي Image البناء: يتغير دون أن تعرف، فيتغير الناتج أو يفشل البناء في يوم لم يتغير فيه الكود. - تجاوز الاختبارات للسرعة:
-DskipTestsفي خط النشر يعني أن الاختبار الذي رأيناه يفشل كان سيمر إلى الإنتاج. - عدم حفظ ذاكرة الاعتماديات في CI: كل بناء ينزل مئات الملفات، ويصبح البناء معطلاً كلما تعطل المستودع الخارجي.
- إعادة البناء لكل بيئة: ناتج staging ليس هو ناتج الإنتاج، فما اختبرته ليس ما نشرته.
- ترك نسخ الإضافات Plugins دون تحديد في Maven: تتغير مع نسخة Maven نفسها.
- الثقة بال Cache دائماً: مهمة خاصة لم تعرف كل مدخلاتها تعطي ناتجاً قديماً، وعند الشك ابن دون Cache.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- البناء تحويل الكود إلى ناتج Artifact عبر خطوات ثابتة تقريباً: حل الاعتماديات ثم الترجمة ثم الربط ثم الاختبار ثم التحزيم، والنشر خطوة منفصلة في CI.
- Make الذي وصفه Feldman عام 1978 وضع القاعدة التي ما زالت تعمل: يعاد بناء الهدف إذا كان أقدم من أي ملف يعتمد عليه.
- Maven يصف المشروع في
pom.xmlويشغل دورة حياة ثابتة كل مرحلة فيها تشغل ما قبلها، وGradle يقارن هاش المدخلات فيتجاوز ما لم يتغير وينسخ من ال Build Cache، وdotnet يعمل فوق MSBuild الذي يقارن أوقات الملفات. - البناء القابل للتكرار يعني نفس الهاش لكل من يبني نفس الكود بنفس البيئة والتعليمات، وأعداؤه الوقت والمسار ونسخ الأدوات وترتيب الملفات، وMaven 3.10 وGradle 9 و.NET تعالج أغلبها افتراضياً.
- في CI ابن داخل Container بنسخة محددة، واحفظ ذاكرة الاعتماديات، وشغل الاختبارات، وابن مرة واحدة وانقل نفس الناتج إلى كل البيئات.
وأقرب المقالات إلى هذا الموضوع على الموقع:
دليلGitea Actions: تشغيل CI/CD وبناء Docker Images على سيرفركاجعل سيرفرك يختبر الكود ويبني Docker Images تلقائياً مع كل push، دون أن يخرج كودك أو أسرارك إلى خدمة CI خارجية.
دليلDevSecOps للمبتدئين: الأمن داخل خط CI/CD ومتى يعمل كل فحصما الذي يجده كل فحص أمني في خط CI/CD وما الذي يفوته، وكيف ترتب نتائجه حتى تصل إلى قرار بدل قائمة طويلة.
دليلأمن سلسلة التوريد البرمجية Software Supply Chain للمبتدئينكل ما بين كود غيرك وسيرفرك قد يحمل كود المهاجم، وهنا تعرف التهديدات الحقيقية بحوادث موثقة والدفاع المناسب لكل حلقة.
دليلSCA: كيف تجد الثغرات المعروفة في مكتبات مشروعككيف تجد أداة فحص الاعتماديات SCA المكتبات المصابة في مشروعك، ولماذا تختلف نتائج الأدوات، وكيف تبقي مكتباتك محدثة.
دليلمن الفرع إلى الإنتاج: البيئات وGitOps للفرقكيف يختبر فريقك كل ميزة وحدها ويعرضها على العميل دون أن تصل ميزة غير جاهزة إلى الإنتاج.
دليلأنماط البرمجة وأنظمة الأنواع: لماذا تختلف اللغات؟نحل مسألة واحدة بعدة أساليب برمجة، ونرى هل تكتشف اللغة أخطاءك قبل التشغيل أم بعده، وماذا يعني ذلك عند اختيار لغة لمشروعك.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة العملية على Docker Engine 29.8.0 مع Maven 3.10.0 وGradle 9.8.0 على Temurin JDK 25.0.4.1، و.NET SDK 10.0.401، وGo 1.27.1 وGNU Make 4.3 وgcc 12.2.0.