لنفرض أنك تكتب Python أو JavaScript منذ سنوات، وانضممت هذا الشهر إلى فريق في شركة ناشئة تبيع القهوة والتمور على الإنترنت، وخدمة الطلبات والفوترة عندهم مكتوبة بـ C# وتعمل على سيرفرات Linux داخل Containers. وفي أول يوم يرسل لك قائد الفريق رابط المستودع ويطلب منك إصلاحاً صغيراً في حساب ضريبة القيمة المضافة VAT، فتفتح المجلد فتجد ملفات بامتداد .csproj و.slnx، ومجلدين اسمهما bin وobj، وكوداً فيه record وswitch بشكل لم تره في لغتك، ويخبرك زميلك أن تثبت «الـ SDK وليس الـ Runtime». وأنت لا تحتاج دورة في البرمجة، فأنت تعرف المتغيرات والدوال والكلاسات والاختبارات، وإنما تحتاج أن تعرف كيف تقابل ما تعرفه بما في C#، وما الأدوات التي يستعملها الفريق، وأين الفخاخ التي لا يذكرها أحد في البداية.
وهذا المقال هو أول خمسة مقالات في مرحلة لغة C# للمطورين، وهو مكتوب لك أنت: مطور يعرف البرمجة بلغة أخرى، ويريد أن يبدأ العمل بـ C# اليوم، أو يرجع إليها كمرجع. ولن نشرح ما هو المتغير أو الحلقة، فهذا موجود في مقال أساسيات البرمجة، وإنما سوف نثبت أحدث إصدار مستقر بأرقامه، ونبني مشروعاً حقيقياً صغيراً فيه مكتبة وبرنامج واختبارات، ونمر على كل أداة في سلسلة الأدوات Toolchain بأوامرها ومخرجها الحقيقي. وكل الأرقام في المقال من تجربة شغلناها فعلاً بـ .NET SDK 10.0.403 داخل Container محدود بنواتين.
وسوف نناقش في هذا المقال ما يلي:
- من أين جاءت C# و.NET، ولماذا لم تعد لغة Windows فقط، وما معنى إصدار الدعم الطويل LTS.
- ماذا تثبت: الـ SDK أم الـ Runtime، وكيف تقرأ رقم الإصدار، وكيف تثبت الإصدار نفسه لكل الفريق.
- جولة سريعة في اللغة مقارنة بـ Python وJavaScript: السجلات Records، وLINQ، ومطابقة الأنماط Pattern Matching، والقيمة الفارغة null.
- ماذا يحدث عندما تبني الكود: المترجم Roslyn، واللغة الوسيطة IL، والترجمة وقت التشغيل JIT، والترجمة المسبقة Native AOT.
- الأمر
dotnetوكل أدواته: البناء والتشغيل والاختبار والتنسيق والحزم والنشر. - هيكل المشروع: الحل Solution والمشروع Project وملف
.csproj. - تجربة عملية كاملة بالمخرج سطراً بسطر، وفيها فخ حقيقي واجهناه مع التحذيرات Warnings، وأربعة أشكال للنشر بأحجامها.
- المحررات، والأخطاء الشائعة في الأسبوع الأول، وقائمة تحقق.
لماذا C# اليوم؟ من .NET Framework إلى .NET المفتوحة Open Source
إذا كانت صورة C# في ذهنك أنها لغة Microsoft التي تعمل على Windows فقط مع Visual Studio وسيرفرات IIS، فهذه الصورة صحيحة لكنها قديمة بعشر سنوات. فقد ظهرت C# 1.0 في يناير 2002 مع Visual Studio .NET، وكانت تشبه Java كثيراً كما تقول Microsoft نفسها في صفحة تاريخ الإصدارات، وكانت تعمل فوق .NET Framework، وهو إطار مرتبط بـ Windows، ويأتي مع نظام التشغيل، وله إصدار واحد على الجهاز كله. وبقي الحال كذلك سنوات طويلة، فمن أراد أن يشغل خدمة C# على Linux كان يستعمل مشروع Mono الذي بنته جهة أخرى، ولم يكن يغطي كل شيء.
ثم تغير الاتجاه في 2014 عندما أعلنت Microsoft أنها تفتح المصدر وتبني نسخة جديدة تعمل على Linux وmacOS، وصدرت .NET Core 1.0 في يونيو 2016 كمشروع مفتوح المصدر على GitHub، ثم توحد الاسم في 2020 مع .NET 5 فصار اسمها .NET فقط، وبقي .NET Framework القديم في وضع الصيانة لتطبيقات Windows القديمة. لذلك عندما تقرأ اليوم «.NET» بدون كلمة Framework فالمقصود هذه المنصة الجديدة المفتوحة، والمترجم نفسه Roslyn مفتوح المصدر على GitHub، واللغة لها معيار رسمي هو ECMA-334.
وأهم ما تحتاج معرفته من هذا التاريخ هو جدول الإصدارات، فـ .NET تصدر نسخة رئيسية كل نوفمبر، والنسخ الزوجية مثل 8 و10 نسخ دعم طويل LTS تدعمها Microsoft ثلاث سنوات، والنسخ الفردية مثل 9 و11 نسخ دعم قياسي STS لمدة سنتين، وكل هذا مكتوب في سياسة الدعم الرسمية. وقد صدرت .NET 10 في 11 نوفمبر 2025 كنسخة LTS مدعومة حتى نوفمبر 2028، ومعها C# 14. ووقت كتابة هذا المقال كانت .NET 11 في مرحلة الإصدار المرشح Release Candidate، ولذلك اخترنا .NET 10 لكل مقالات هذه المرحلة، والسبب أن الخدمة التي تنشرها اليوم سوف تبقى في الإنتاج سنوات، ونسخة LTS تعني تحديثات أمنية شهرية دون أن تضطر للترقية كل سنة.
وقد يتساءل البعض: لماذا يختار فريق شركة ناشئة C# أصلاً؟ والإجابة في الغالب مزيج من أسباب عملية: أنواع قوية Static Types تكشف كثيراً من الأخطاء قبل التشغيل، وأداء قريب من Java وGo في خدمات الويب، ومكتبة قياسية كبيرة فيها HTTP وJSON والتشفير والتزامن دون حزم خارجية، وأدوات بناء واختبار موحدة يأتي بها أمر واحد. وقد شرحنا طريقة الاختيار بين اللغات بالتفصيل في مقال كيف تختار لغة البرمجة لمشروعك، فلن نعيدها هنا، وإنما نبدأ من النقطة التي يكون فيها القرار قد اتخذ والكود أمامك.
التثبيت Installation: الـ SDK أم الـ Runtime؟
أول سؤال يواجهك هو ماذا تحمل من صفحة تحميل .NET 10، فالصفحة فيها ثلاثة أشياء مختلفة، والفرق بينها مهم لأنك سوف تختار بينها مرة أخرى عندما تكتب ملف Dockerfile للإنتاج. والصورة التالية تبين ما في كل واحد منها:

- الـ SDK: هو ما تثبته على جهازك كمطور، وفيه الأمر
dotnetبكل أوامره، والمترجم، ومحرك البناء MSBuild، ومدير الحزم NuGet، وفيه أيضاً نسخة من الـ Runtime لتشغيل ما تبنيه. ولاحظ أن Image الحاوية الرسمية للـ SDK حجمها على جهاز التجربة 1.31 GB، وهذا حجم مقبول لمرحلة البناء ولكنه غير مقبول في الإنتاج. - الـ .NET Runtime: الآلة التي تشغل البرنامج المبني، أي الـ CLR مع جامع القمامة Garbage Collector والمكتبات الأساسية، وهذا ما يحتاجه سيرفر يشغل برنامج سطر أوامر أو خدمة خلفية.
- الـ ASP.NET Core Runtime: الـ Runtime السابق ومعه مكتبات الويب وسيرفر Kestrel، وهو ما تحتاجه أي خدمة HTTP، وحجم الـ Image الرسمية له 342 MB، وهذه التي سوف نبني عليها الخدمة في مقال الخدمة الحقيقية.
وعلى Linux عندك عدة طرق للتثبيت مشروحة في صفحة التثبيت على Linux: حزم التوزيعة نفسها مثل Ubuntu التي تضع .NET في مستودعها الرسمي، أو مستودع Microsoft، أو سكربت dotnet-install.sh الذي يثبت أي إصدار في مجلد المستخدم. وحزمة التوزيعة أسهل طريقة ولكنها قد تتأخر عن آخر تحديث أمني بأيام، والسكربت مناسب لأجهزة CI التي تحتاج إصداراً محدداً بدقة. أما في هذه المرحلة كلها فسوف نستعمل الـ Images الرسمية من سجل Microsoft، أي mcr.microsoft.com/dotnet/sdk:10.0، والسبب أن التجربة تتكرر عندك بنفس النتيجة على Windows أو Mac أو Linux، ولا يبقى على جهازك شيء بعد حذفها.
الأوامر التالية تشغل Container من الـ SDK وتعرض الإصدارات التي فيه:
docker run --rm mcr.microsoft.com/dotnet/sdk:10.0 dotnet --version
docker run --rm mcr.microsoft.com/dotnet/sdk:10.0 dotnet --list-sdks
docker run --rm mcr.microsoft.com/dotnet/sdk:10.0 dotnet --list-runtimesوالمخرج سوف يكون كما يلي:
10.0.403
10.0.403 [/usr/share/dotnet/sdk]
Microsoft.AspNetCore.App 10.0.14 [/usr/share/dotnet/shared/Microsoft.AspNetCore.App]
Microsoft.NETCore.App 10.0.14 [/usr/share/dotnet/shared/Microsoft.NETCore.App]كيف تقرأ رقم الإصدار SDK Version؟
لاحظ أن في المخرج رقمين مختلفين: الـ SDK رقمه 10.0.403، والـ Runtime رقمه 10.0.14، وهذا يربك كل من يأتي من لغة أخرى. والسبب أن للـ SDK رقماً خاصاً به يتبع قواعد ترقيم الإصدارات: الرقمان الأولان هما إصدار .NET، والخانة الأخيرة فيها ما يسمى نطاق الميزات Feature Band، فالرقم 403 يعني النطاق 4xx والتحديث الثالث فيه. وكل نطاق جديد قد يأتي بتحسينات في الأدوات والمترجم، بينما رقم الـ Runtime يزيد مع كل تحديث شهري، وأغلب هذه التحديثات أمنية. لذلك عندما يسألك أحد «على أي .NET تعمل الخدمة؟» فالإجابة الصحيحة رقم الـ Runtime، وعندما يسألك «بأي أدوات بنيتها؟» فالإجابة رقم الـ SDK.
وإذا كان في الفريق خمسة مطورين وعلى كل جهاز SDK مختلف، فقد يبني الكود عند أحدهم ويفشل عند الآخر، لذلك تضع ملف global.json في جذر المستودع، وتكتب فيه الإصدار وسياسة التقدم Roll Forward، والمثال التالي يقبل أي تحديث داخل نطاق 4xx ويرفض غيره:
{
"sdk": {
"version": "10.0.403",
"rollForward": "latestPatch"
}
}جولة سريعة في اللغة Syntax Tour مقارنة بما تعرفه
بالعودة إلى المهمة الأولى في الفريق، وهي حساب ضريبة القيمة المضافة، فلنكتبها بـ C# كما يكتبها فريق حديث، ثم نقرأها سطراً بسطر ونقابل كل جزء بما تعرفه من Python وJavaScript. والقاعدة التي نتبعها في كل هذه المرحلة أن المبالغ تحفظ كأعداد صحيحة بالهللة، فالسعر 9.90 ريال يحفظ 990، والسبب أن الأعداد العشرية Floating Point تخطئ في المبالغ المالية، وسوف نرى ذلك بأرقام حقيقية في المقال التالي عن الأنواع والذاكرة. والملف التالي هو المكتبة Pricing.cs:
namespace Store.Pricing;
// A product as the catalogue knows it. A record: value equality, ToString and immutability for free.
public record Product(int Id, string NameAr, int PriceHalalas, int Stock);
public record OrderLine(Product Product, int Qty);
public static class Vat
{
public const int RatePercent = 15;
// VAT in integer halalas, rounded half up: 445.5 halalas -> 446.
public static int Of(int subtotalHalalas)
{
if (subtotalHalalas < 0) throw new ArgumentOutOfRangeException(nameof(subtotalHalalas));
return (subtotalHalalas * RatePercent + 50) / 100;
}
public static (int Subtotal, int Vat, int Total) Total(IEnumerable<OrderLine> lines)
{
int subtotal = lines.Sum(l => l.Product.PriceHalalas * l.Qty);
int vat = Of(subtotal);
return (subtotal, vat, subtotal + vat);
}
public static string Sar(int halalas) => $"{halalas / 100}.{halalas % 100:D2} SAR";
}في الكود أعلاه لاحظ التالي:
- السطر الأول
namespace Store.Pricing;ينهيه فاصلة منقوطة، وهذا يسمى مساحة أسماء على مستوى الملف File-scoped Namespace، أي أن كل ما في الملف داخلها دون أقواس إضافية ودون مستوى إزاحة زائد. والـ Namespace تشبه الحزمة Package في Java أو مسار الوحدة Module في Python، ولكنها لا ترتبط بمسار الملف إلا بالاتفاق. - السطر
public record Product(...)يعرف نوعاً كاملاً في سطر واحد، وهذا هو السجل Record، ويعطيك تلقائياً خصائص Properties للقراءة فقط، ومقارنة بالقيمة Value Equality، ودالةToStringمقروءة، ونسخاً مع تعديل بالكلمةwith. وأقرب شيء له في Python هو@dataclass(frozen=True)، وفي Java الـrecordمنذ Java 16. - الكلاس
static class Vatلا يمكن إنشاء كائن منه، وكل ما فيه دوال وثوابت، وهو الطريقة المعتادة في C# لتجميع دوال لا تحتاج حالة، لأن C# لا تسمح بدوال حرة خارج الكلاسات إلا في ملف البرنامج الرئيسي. - الحساب
(subtotalHalalas * RatePercent + 50) / 100هو التقريب نصف للأعلى Round Half Up بأعداد صحيحة فقط، والقسمة بين عددين صحيحين في C# تعطي عدداً صحيحاً وتحذف الكسر، كما في Java وGo، وعلى عكس Python التي تعطي/فيها عدداً عشرياً. - نوع الإرجاع
(int Subtotal, int Vat, int Total)هو صف Tuple بأسماء، فترجع عدة قيم دون أن تعرف كلاساً، ثم تفككها عند الاستدعاء. - الرمز
=>فيSarجسم دالة من تعبير واحد Expression-bodied Member، والنص الذي يبدأ بـ$نص مدمج Interpolated String، مثل f-string في Python أو القوالب بعلامة`في JavaScript.
والآن البرنامج الذي يستعمل المكتبة، وهو الملف Program.cs:
using Store.Pricing;
// Top-level statements: no class Program, no static void Main.
var catalogue = new List<Product>
{
new(1, "قهوة خولاني 250 جم", 990, 40),
new(2, "تمر سكري 1 كجم", 4500, 12),
new(3, "دلة نحاس", 12900, 0),
};
var cart = new[] { new OrderLine(catalogue[0], 3), new OrderLine(catalogue[1], 1) };
var (subtotal, vat, total) = Vat.Total(cart);
Console.WriteLine($"subtotal={Vat.Sar(subtotal)} vat={Vat.Sar(vat)} total={Vat.Sar(total)}");
// LINQ: the same idea as list comprehensions or JS array methods
var available = catalogue.Where(p => p.Stock > 0).OrderBy(p => p.PriceHalalas).Select(p => p.NameAr);
Console.WriteLine(string.Join(" | ", available));
// Pattern matching on a record
foreach (var p in catalogue)
{
string label = p switch
{
{ Stock: 0 } => "نفد",
{ Stock: < 15 } => "كمية قليلة",
_ => "متوفر",
};
Console.WriteLine($"{p.Id} {label}");
}
// Records compare by value
var a = catalogue[0];
var b = a with { };
Console.WriteLine($"a == b: {a == b}, same object: {ReferenceEquals(a, b)}");
Console.WriteLine(a);والمخرج سوف يكون كما يلي:
subtotal=74.70 SAR vat=11.21 SAR total=85.91 SAR
قهوة خولاني 250 جم | تمر سكري 1 كجم
1 متوفر
2 كمية قليلة
3 نفد
a == b: True, same object: False
Product { Id = 1, NameAr = قهوة خولاني 250 جم, PriceHalalas = 990, Stock = 40 }في المخرج أعلاه لاحظ أن ثلاثة أكياس قهوة بـ 9.90 وكيس تمر بـ 45 ريالاً تعطي 74.70، والضريبة عليها 11.205 ريالاً بالضبط، أي 1120.5 هللة، فقربها الكود إلى 1121 هللة، أي 11.21، وهذا ما تطلبه قاعدة التقريب نصف للأعلى. ولاحظ أيضاً السطر قبل الأخير: الكائنان a وb متساويان بالقيمة مع أنهما كائنان مختلفان في الذاكرة، وهذا ما يفعله الـ Record، ولو كان Product كلاساً عادياً class لكانت المقارنة == بين المرجعين، ولأعطت False.
البرنامج بدون Main: التعليمات العليا Top-level Statements
إذا كنت قرأت كود C# قديماً فستتذكر أن كل برنامج يبدأ بكلاس اسمه Program وفيه دالة static void Main(string[] args)، أما الآن فالملف Program.cs أعلاه يبدأ بالكود مباشرة، وهذه هي التعليمات العليا Top-level Statements. والمترجم لم يتغير، فهو ما زال يولد كلاساً ودالة Main خلف الكواليس، وإنما وفر عليك كتابتها، ولذلك سوف ترى في رسائل الخطأ لاحقاً أسماء غريبة مثل Program.<Main>$، وهذه هي الدالة التي ولدها. والقاعدة أن ملفاً واحداً فقط في المشروع يمكن أن يحوي تعليمات عليا، وأن المتغير args متاح فيه دون تعريف.
ولاحظ أيضاً أن الملف لم يكتب using System; ولا using System.Linq; ومع ذلك استعمل Console وWhere، والسبب الخيار ImplicitUsings في ملف المشروع، فهو يضيف مجموعة من مساحات الأسماء الشائعة لكل ملفات المشروع تلقائياً، وسوف نراه في ملف .csproj بعد قليل.
الاستعلامات LINQ: نفس فكرة List Comprehensions
السطر الذي يحسب المنتجات المتوفرة هو LINQ، أي الاستعلام المدمج في اللغة Language Integrated Query، وهو مجموعة دوال مثل Where وSelect وOrderBy وGroupBy وSum تعمل على أي مجموعة تطبق الواجهة IEnumerable<T>. وإذا كنت تكتب في JavaScript السطر ps.filter(...).map(...) أو في Python التعبير [p.name for p in ps if p.stock > 0] فأنت تعرف الفكرة، ولكن بينها فرق مهم: LINQ مؤجلة التنفيذ Deferred Execution، فالمتغير available لا يحمل قائمة، وإنما يحمل وصفاً للاستعلام، ولا يحسب شيئاً إلا عندما يمر عليه أحد، وهنا مر عليه string.Join. وهذا يشبه المولدات Generators في Python أكثر مما يشبه القوائم، ولو مررت على available مرتين لحسب الاستعلام مرتين، وسوف نرى في مقال الأخطاء الشائعة كيف يسبب هذا أخطاء حقيقية.
مطابقة الأنماط Pattern Matching والتعبير switch
التعبير p switch { ... } في الكود ليس switch القديم الذي تعرفه من C أو Java، وإنما تعبير يرجع قيمة، وكل فرع فيه نمط Pattern يفحص شكل الكائن: { Stock: 0 } يعني «كائن خاصيته Stock تساوي صفراً»، و{ Stock: < 15 } نمط علائقي Relational Pattern، و_ هو أي شيء آخر. والمترجم يفحص الفروع بالترتيب، ويحذرك إذا كان هناك فرع لا يمكن الوصول إليه أو قيمة لا يغطيها أي فرع. وتوجد أنماط أخرى كثيرة في صفحة الأنماط، مثل أنماط النوع is string s وأنماط القوائم [first, .. rest]، وأقرب شيء لها في Python هو match منذ Python 3.10. والصورة التالية تجمع المقابلة بين اللغات الثلاث لأهم ما رأيناه:

القيمة الفارغة null والأنواع القابلة للفراغ Nullable Reference Types
آخر شيء في هذه الجولة هو علامة الاستفهام بعد النوع، مثل string?، فمنذ C# 8 يمكن أن تطلب من المترجم أن يتابع القيم التي قد تكون null، وهذا الخيار Nullable مفعل في كل مشروع جديد. فالنوع string يعني نصاً لا يكون null، وstring? يعني نصاً قد يكون null، وإذا استعملت الثاني دون فحص يعطيك المترجم تحذيراً. وكلمة «تحذير» مهمة هنا، فهذه الأنواع القابلة للفراغ Nullable Reference Types تحليل يقوم به المترجم وقت البناء فقط، ولا تمنع شيئاً وقت التشغيل، والبرنامج الذي فيه التحذير يبنى ويعمل، ثم يرمي NullReferenceException في الإنتاج، وسوف نرى هذا بعيننا في التجربة، ونرى أيضاً كيف يختفي التحذير نفسه من مخرج البناء بطريقة لا يتوقعها أحد.
ماذا يحدث عندما تبني الكود؟ المترجم Roslyn ثم IL ثم JIT
إذا كنت تأتي من Python فأنت معتاد على أن المفسر يقرأ الملف ويشغله، وإذا كنت تأتي من Go أو C فأنت معتاد على أن المترجم يخرج ملفاً تنفيذياً للمعالج مباشرة. و.NET تقف في المنتصف مثل Java: المترجم Roslyn يحول كود C# إلى لغة وسيطة اسمها IL، أي Intermediate Language، ويضعها في ملف .dll، ثم عندما تشغل البرنامج يقرأ الـ Runtime هذا الملف ويترجم كل دالة إلى كود المعالج عند أول استدعاء لها، وهذا هو المترجم وقت التشغيل JIT، أي Just-In-Time. والتفاصيل العامة لهذا الأسلوب شرحناها في مقال المترجم والمفسر و JIT، وما يخص .NET منه مكتوب في صفحة الكود المدار Managed Code.
والـ JIT في .NET لا يترجم كل دالة مرة واحدة بأفضل جودة، وإنما على مستويات Tiered Compilation كما تشرح صفحة إعدادات الترجمة: أول استدعاء يترجم الدالة بسرعة وبتحسينات قليلة Tier 0 حتى يبدأ البرنامج بسرعة، ثم الدوال التي تستدعى كثيراً يعاد ترجمتها في الخلفية بتحسينات كاملة Tier 1، مستفيدة مما جمعه الـ Runtime عن طريقة استعمالها فعلاً، وهذا يسمى التحسين الموجه بالقياس Dynamic PGO. ولهذا السبب ترى في قياسات الأداء أن أول ثوان من عمر الخدمة أبطأ من بقيتها، وأن أي قياس جاد يبدأ بتسخين Warm-up.
وهناك طريق ثان هو الترجمة المسبقة Native AOT، حيث يترجم كل الكود إلى ملف تنفيذي للمعالج وقت النشر، فلا يوجد JIT ولا IL وقت التشغيل، ويبدأ البرنامج أسرع ويستهلك ذاكرة أقل، ولكن بشروط سوف نراها في التجربة: يحتاج أدوات ربط النظام Linker، ولا يعمل مع كل المكتبات، لأن بعضها يعتمد على الانعكاس Reflection وتوليد الكود وقت التشغيل. والصورة التالية تبين الطريقين بالأرقام التي قسناها:

الأمر dotnet: سلسلة الأدوات Toolchain في أمر واحد
من أكثر ما يريح القادم من JavaScript أو Python أن C# لا تحتاج أن تختار بين خمسة مديري حزم وأربع أدوات اختبار وثلاث أدوات تنسيق، فكل ذلك موجود في الأمر dotnet الذي يأتي مع الـ SDK، وهو مبني فوق MSBuild، أي محرك البناء الذي شرحنا فكرته في مقال أدوات البناء. والجدول التالي يقابل أهم أوامره بما تعرفه:
| العمل | C# (.NET) | JavaScript (Node.js) | Python |
|---|---|---|---|
| مشروع جديد من قالب | dotnet new console | npm init | uv init |
| إضافة حزمة | dotnet add package Npgsql | npm install pg | uv add psycopg |
| استعادة الحزم | dotnet restore | npm ci | uv sync |
| البناء | dotnet build | tsc أو أداة الحزم | لا يوجد |
| التشغيل | dotnet run | node app.js | python app.py |
| الاختبارات | dotnet test | npm test | pytest |
| التنسيق | dotnet format | prettier | ruff format |
| التحليل Linting | المحللات Analyzers داخل البناء | eslint | ruff check |
| النشر | dotnet publish | نسخ المجلد | بناء Wheel أو Image |
ولاحظ صف التحليل Linting، ففي C# لا توجد أداة منفصلة تشغلها بعد البناء، وإنما المحللات Analyzers تعمل داخل المترجم نفسه أثناء البناء، وتطبع تحذيراتها مع تحذيرات المترجم، ويمكن أن تضيف محللات من حزم NuGet. وهذا التصميم له ميزة واضحة وهي أنك لا تنسى تشغيل الـ Linter، وله عيب سوف نراه في التجربة، وهو أن ما لا يمر عليه المترجم لا يمر عليه المحلل أيضاً.
هيكل المشروع Project Layout: الحل Solution والمشروع Project
في JavaScript يكون عندك غالباً ملف package.json واحد للمشروع، أما في C# فالوحدة الأساسية هي المشروع Project، وكل مشروع ملف .csproj يبنى إلى مكتبة Assembly واحدة، أي ملف .dll، وعدة مشاريع تجمعها في حل Solution. والسبب في هذا التقسيم أن المشروع حدود حقيقية: الكود في مشروع الاختبارات لا يصل إلى ما هو internal في المكتبة إلا إذا سمحت له، والمكتبة لا تعرف شيئاً عن برنامج سطر الأوامر الذي يستعملها، وكل مشروع له اعتمادياته الخاصة. والصورة التالية تبين الهيكل الذي سوف نبنيه في التجربة:

وملف الحل نفسه تغير في .NET 10، فالصيغة القديمة .sln كانت ملفاً نصياً غريب الشكل فيه معرفات GUID طويلة يصعب دمجه في Git، والصيغة الجديدة .slnx ملف XML قصير يذكر المشاريع فقط، وهي ما ينشئه dotnet new sln الآن. أما ملف المشروع فهو بصيغة المشاريع المعتمدة على SDK، وأهم ما فيها أنها لا تذكر ملفات الكود: كل ملف .cs في مجلد المشروع ومجلداته الفرعية جزء منه تلقائياً، فلا تحتاج أن تعدل ملف المشروع كلما أضفت ملفاً، وهذا كان مصدر تعارضات Merge Conflicts لا تنتهي في المشاريع القديمة.
وعندما يكبر الحل ويصير فيه عشرون مشروعاً، فلن تكرر نفس الخيارات في كل ملف .csproj، وإنما تضعها في ملف Directory.Build.props في جذر المستودع، فيقرؤه MSBuild قبل كل مشروع تحته. والمثال التالي يجمع الخيارات التي ننصح بها لأي مستودع جديد، وسوف نشرح سبب كل سطر فيه أثناء التجربة:
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
</Project>التجربة العملية Lab: حل كامل من الصفر بالأمر dotnet
الآن نبني الهيكل السابق كله بالأوامر، ونشغل كل أداة ونقرأ مخرجها. والتجربة كلها في السكربت lab/run.sh المرفق بالمقال، ويعمل داخل Container من الـ SDK، مع Volume لتخزين الحزم حتى لا تحمل في كل مرة. ولاحظ أننا نحدد للـ Container نواتين و2 GB ذاكرة، وأن الجهاز نفسه كان يشغل تجارب أخرى في نفس الوقت، لذلك أرقام الزمن هنا تقريبية وتكفي للمقارنة بين الخطوات، وليست قياساً لسرعة .NET نفسها. والأمر الذي يشغل التجربة:
docker volume create slc-cs-nuget
docker run --rm --name slc-cs-1 --cpus=2 --memory=2g \
-v slc-cs-nuget:/root/.nuget/packages -v "$PWD/lab":/lab \
mcr.microsoft.com/dotnet/sdk:10.0 bash /lab/run.shإنشاء الحل والمشاريع dotnet new
الخطوة الأولى تنشئ الحل والمشاريع الثلاثة من القوالب Templates، ثم تضيفها للحل، ثم تضيف المراجع بينها:
dotnet new sln -n Store
dotnet new classlib -n Store.Pricing -o src/Store.Pricing
dotnet new console -n Store.Cli -o src/Store.Cli
dotnet new xunit -n Store.Pricing.Tests -o tests/Store.Pricing.Tests
dotnet sln add src/Store.Pricing src/Store.Cli tests/Store.Pricing.Tests
dotnet add src/Store.Cli reference src/Store.Pricing
dotnet add tests/Store.Pricing.Tests reference src/Store.Pricingوالمخرج سوف يكون كما يلي، مختصراً:
The template "Solution File" was created successfully.
The template "Class Library" was created successfully.
Restored /w/src/Store.Pricing/Store.Pricing.csproj (in 646 ms).
The template "Console App" was created successfully.
Restored /w/src/Store.Cli/Store.Cli.csproj (in 492 ms).
The template "xUnit Test Project" was created successfully.
Restored /w/tests/Store.Pricing.Tests/Store.Pricing.Tests.csproj (in 38.16 sec).
Project `src/Store.Pricing/Store.Pricing.csproj` added to the solution.
Project `src/Store.Cli/Store.Cli.csproj` added to the solution.
Project `tests/Store.Pricing.Tests/Store.Pricing.Tests.csproj` added to the solution.
Reference `..\Store.Pricing\Store.Pricing.csproj` added to the project.
Store.slnxفي المخرج أعلاه لاحظ التالي:
- كل قالب يشغل الاستعادة Restore بعد الإنشاء مباشرة، أي يحمل الحزم التي يحتاجها المشروع من NuGet.
- المكتبة والبرنامج استعادا في أقل من ثانية، لأنهما لا يعتمدان على أي حزمة خارجية، بينما مشروع الاختبارات أخذ 38 ثانية، لأنه حمل xUnit وأداة تشغيل الاختبارات وأداة تغطية الكود Code Coverage لأول مرة، والمرات التالية تأتي من الـ Volume في أجزاء من الثانية.
- الملف الذي أنشأه
dotnet new slnاسمهStore.slnx، أي الصيغة الجديدة.
ثم ننسخ ملفات الكود التي رأيناها في الجولة إلى المشاريع، ونقرأ ملفات المشاريع التي أنشأتها القوالب. هذا ملف المكتبة:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>وهذا ملف مشروع الاختبارات:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Store.Pricing\Store.Pricing.csproj" />
</ItemGroup>
</Project>في الملفين أعلاه لاحظ التالي:
- السطر
Sdk="Microsoft.NET.Sdk"يحدد نوع المشروع، ومشروع الويب يكتب فيهMicrosoft.NET.Sdk.Web، وهذا وحده يضيف مكتبات ASP.NET Core وإعداداتها. - الخيار
TargetFrameworkهو إصدار .NET الذي يبنى له المشروع، والقيمةnet10.0تعني أن الكود يحتاج Runtime 10 أو أحدث ليعمل. - الحزم تذكر بأرقام إصدارات ثابتة في
PackageReference، ولاحظ أن قالب الاختبارات في هذا الـ SDK ما زال يضع xUnit 2.9.3، بينما الإصدار الأحدث هو xUnit v3 الذي سوف نستعمله في مقال الخدمة الحقيقية، وهذا مثال على أن القوالب نقطة بداية، وليست توصية بأحدث شيء. - السطر
<Using Include="Xunit" />يضيفusing Xunit;لكل ملفات المشروع، مثل ImplicitUsings ولكن لمساحة أسماء تختارها أنت. - المرجع
ProjectReferenceهو ما أضافه الأمرdotnet add reference، وبه يبني MSBuild المكتبة قبل الاختبارات.
البناء والتشغيل Build and Run
الآن نبني الحل كله مرتين، الأولى من الصفر والثانية دون أي تغيير:
=== 3. restore + build (cold) ===
Store.Pricing.Tests -> /w/tests/Store.Pricing.Tests/bin/Debug/net10.0/Store.Pricing.Tests.dll
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:00:48.17
=== 3b. build again (warm, no changes) ===
0 Error(s)
Time Elapsed 00:00:19.98وهذه أرقام تبدو كبيرة لثلاثة مشاريع صغيرة، والسبب ليس المترجم، وإنما أن كل استدعاء لـ dotnet build في Container جديد يشغل MSBuild من الصفر، ويقيم ملفات المشاريع، ويفحص الحزم، ويترجم الـ JIT أدوات البناء نفسها، وكل ذلك على نواتين مشتركتين مع تجارب أخرى. ولكي نعرف أين يذهب الوقت فعلاً قسنا مكتبة واحدة بأربع طرق في سكربت lab/run-warnings.sh:
rebuild: 10583 ms
no-op build: 12773 ms
one file changed: 11230 ms
no-op with --no-restore: 9773 msولاحظ أن البناء الذي لا يغير شيئاً No-op Build أخذ نفس زمن البناء الكامل تقريباً، أي أن أغلب الثواني العشر تكلفة ثابتة لتشغيل الأدوات، وليست ترجمة. وعلى جهاز المطور العادي، حيث يبقى MSBuild يعمل في الخلفية بين بناء وآخر ويستعيد خوادم البناء Build Servers، يصير البناء التزايدي ثانية أو ثانيتين، أما في CI فالتكلفة الثابتة حقيقية، ولذلك يفصل الناس dotnet restore في خطوة مستقلة ثم يبنون ويختبرون بالخيار --no-restore. والآن التشغيل بطريقتين:
=== 4. run ===
subtotal=74.70 SAR vat=11.21 SAR total=85.91 SAR
...
elapsed 20.85s (exit 0)
=== 4b. run the built dll directly ===
subtotal=74.70 SAR vat=11.21 SAR total=85.91 SAR
...
elapsed 0.26s (exit 0)وهذا فرق يجب أن تعرفه من اليوم الأول: الأمر dotnet run أداة تطوير، فهو يفحص المشروع ويبنيه إذا احتاج ثم يشغله، ولذلك أخذ 20 ثانية، بينما تشغيل الملف المبني dotnet Store.Cli.dll أخذ ربع ثانية، وهذا هو الزمن الحقيقي لبدء البرنامج مع تحميل الـ Runtime والـ JIT. لذلك لا تضع dotnet run في ملف Dockerfile أو في خدمة systemd أبداً، وإنما تنشر البرنامج ثم تشغل الملف المنشور.
الاختبارات dotnet test مع xUnit
ملف الاختبارات يختبر حالة الضريبة التي سوف نعود إليها في كل مقالات المرحلة، وهي ثلاثة أكياس قهوة بـ 9.90 ريال:
using Store.Pricing;
using Xunit;
public class VatTests
{
[Fact]
public void Three_coffees_at_9_90()
{
var coffee = new Product(1, "قهوة", 990, 10);
var (subtotal, vat, total) = Vat.Total([new OrderLine(coffee, 3)]);
Assert.Equal(2970, subtotal);
Assert.Equal(446, vat); // 445.5 rounds half up
Assert.Equal(3416, total);
}
[Theory]
[InlineData(0, 0)]
[InlineData(100, 15)]
[InlineData(10, 2)] // 1.5 -> 2
[InlineData(3, 0)] // 0.45 -> 0
public void Rounds_half_up(int subtotal, int expected) => Assert.Equal(expected, Vat.Of(subtotal));
}في الكود أعلاه لاحظ أن [Fact] اختبار واحد بلا معاملات، و[Theory] مع [InlineData] اختبار يتكرر لكل سطر بيانات، مثل @pytest.mark.parametrize في Python، فهذا الملف فيه خمسة اختبارات وليس اثنين. ولاحظ أيضاً [new OrderLine(coffee, 3)]، فهذه تعبيرات المجموعات Collection Expressions من C# 12، وتنشئ مصفوفة أو قائمة بالنوع الذي تطلبه الدالة. والمخرج سوف يكون كما يلي:
Test run for /w/tests/Store.Pricing.Tests/bin/Debug/net10.0/Store.Pricing.Tests.dll (.NETCoreApp,Version=v10.0)
A total of 1 test files matched the specified pattern.
Passed! - Failed: 0, Passed: 5, Skipped: 0, Total: 5, Duration: 156 ms - Store.Pricing.Tests.dll (net10.0)والاختبارات نفسها أخذت 156 ملي ثانية، والأمر كله 24 ثانية، وهي نفس التكلفة الثابتة للبناء التي رأيناها. وأنواع الاختبارات وكيف تختار بينها شرحناها في مقال أنواع الاختبارات، والخيارات المتاحة في .NET بين xUnit وNUnit وMSTest مذكورة في صفحة الاختبار في .NET، والأكثر انتشاراً في المشاريع المفتوحة هو xUnit.
التحذيرات Warnings: الفخ الذي واجهناه في التجربة
هذه الخطوة كان هدفها بسيطاً: نضيف ملفاً فيه خطأ null واضح، ونرى تحذير المترجم، ثم نجعل التحذيرات أخطاء حتى يفشل البناء. والملف Bad.cs مكتوب عمداً بتنسيق سيئ وبخطأ في الـ null:
namespace Store.Pricing;
public static class Discounts {
public static string? CouponFor(string customer) => customer.StartsWith("vip") ? "VIP10" : null;
public static int CouponLength(string customer) { var c = CouponFor(customer); return c.Length; }
}الدالة CouponFor ترجع string?، أي قد ترجع null، والدالة الثانية تستعمل c.Length دون أن تفحص. وفي التشغيل الأول من السكربت كانت النتيجة غريبة:
=== 6. nullable warning, then warnings as errors ===
1 Warning(s)
/w/src/Store.Pricing/Bad.cs(4,89): warning CS8602: Dereference of a possibly null reference. [/w/src/Store.Pricing/Store.Pricing.csproj]
Build succeeded.
Build succeeded.البناء الأول أعطى التحذير CS8602 كما توقعنا، ولكن البناء الثاني بالخيار TreatWarningsAsErrors=true قال Build succeeded، مع أن نفس التحذير يجب أن يصير خطأ. ولكي نفهم السبب كتبنا السكربت run-warnings.sh الذي يبني نفس الملف أربع مرات دون أي تعديل بينها:
=== build 1
1 Warning(s)
/w3/Store.Pricing/Bad.cs(4,89): warning CS8602: Dereference of a possibly null reference. [/w3/Store.Pricing/Store.Pricing.csproj]
=== build 2 (nothing changed)
0 Warning(s)
=== build 3 with -warnaserror, nothing changed
Build succeeded.
=== build 4 with -warnaserror --no-incremental
/w3/Store.Pricing/Bad.cs(4,89): error CS8602: Dereference of a possibly null reference. [/w3/Store.Pricing/Store.Pricing.csproj]
Build FAILED.
exit 1والسبب هو البناء التزايدي Incremental Build، فـ MSBuild يقارن تاريخ ملفات الكود بتاريخ الـ dll الناتج، وإذا كان الـ dll أحدث فلا يشغل المترجم أصلاً، وبالتالي لا يوجد من يطبع التحذير. فالبناء الثاني قال «0 Warning(s)» لا لأن التحذير أصلح، وإنما لأن أحداً لم ينظر في الكود، والبناء الثالث بالخيار -warnaserror نجح لنفس السبب، وفقط عندما أجبرنا البناء من الصفر بالخيار --no-incremental ظهر الخطأ وفشل البناء. والصورة التالية تلخص البناءات الأربعة:

وهذا ليس خطأ في .NET، وإنما سلوك مقصود يوفر الوقت، ولكنه يعني أن أمرين يجب أن يكونا في كل مستودع: أن يكون الخيار TreatWarningsAsErrors في ملف المشروع أو في Directory.Build.props وليس في سطر الأوامر، لأنه عندها يفشل البناء الأول فلا يوجد dll قديم يخدع البناء التالي، وأن يبني خط CI من نسخة نظيفة للمستودع بدون مجلدات bin وobj من بناء سابق. أما على جهازك فلا تعتمد على أن «البناء نجح بدون تحذيرات» إذا لم تغير شيئاً، وإذا أردت أن ترى كل التحذيرات فاستعمل dotnet build --no-incremental.
التنسيق dotnet format
الأداة dotnet format تأتي مع الـ SDK، وتقرأ قواعد التنسيق من ملف .editorconfig إن وجد، وإلا تستعمل القواعد الافتراضية. وفي CI تشغلها بالخيار --verify-no-changes فتفشل إذا وجدت ملفاً غير منسق دون أن تعدل شيئاً:
/w/src/Store.Pricing/Bad.cs(2,1): error WHITESPACE: Fix whitespace formatting. Insert '\n'. [/w/src/Store.Pricing/Store.Pricing.csproj]
/w/src/Store.Pricing/Bad.cs(2,30): error WHITESPACE: Fix whitespace formatting. Replace 1 characters with '\n'. [/w/src/Store.Pricing/Store.Pricing.csproj]
/w/src/Store.Pricing/Bad.cs(3,3): error WHITESPACE: Fix whitespace formatting. Insert '\s\s'. [/w/src/Store.Pricing/Store.Pricing.csproj]
/w/src/Store.Pricing/Bad.cs(3,52): error WHITESPACE: Fix whitespace formatting. Delete 2 characters. [/w/src/Store.Pricing/Store.Pricing.csproj]
/w/src/Store.Pricing/Bad.cs(3,57): error WHITESPACE: Fix whitespace formatting. Delete 2 characters. [/w/src/Store.Pricing/Store.Pricing.csproj]
/w/src/Store.Pricing/Bad.cs(4,3): error WHITESPACE: Fix whitespace formatting. Insert '\s\s'. [/w/src/Store.Pricing/Store.Pricing.csproj]وكل سطر فيه رقم السطر والعمود والتعديل المطلوب، مثل نقل القوس { إلى سطر مستقل كما تتبع C# في أسلوبها الافتراضي، وإزاحة بأربع مسافات. وفي التشغيل الأول كتب السكربت بعد هذا المخرج «exit 0»، وهذا خطأ منا وليس من الأداة، لأن السكربت مرر المخرج إلى head فصار رمز الخروج رمز head، ولما شغلناها دون أنبوب في run-warnings.sh رجعت بالرمز 2، أي فشل، وهذا ما يجب أن يوقف خط CI. وبعد تشغيل dotnet format دون الخيار صار الملف هكذا:
namespace Store.Pricing;
public static class Discounts
{
public static string? CouponFor(string customer) => customer.StartsWith("vip") ? "VIP10" : null;
public static int CouponLength(string customer) { var c = CouponFor(customer); return c.Length; }
}ولاحظ أن الأداة أصلحت المسافات والأقواس فقط، وبقي خطأ الـ null كما هو، فالتنسيق والتحليل أمران مختلفان، والأول لا يغني عن الثاني.
برنامج من ملف واحد File-based Apps
من أجمل ما جاء في .NET 10 للقادمين من Python أنك تستطيع أن تكتب ملف .cs واحداً وتشغله مباشرة دون مشروع ودون .csproj، وهذا ما تسميه Microsoft التطبيقات المعتمدة على ملف File-based Apps، ويمكن أن تذكر الحزم التي يحتاجها الملف في أوله بتوجيه #:package:
#:package [email protected]
using Humanizer;
// .NET 10 file-based app: one file, no .csproj, run with: dotnet run hello.cs
Console.WriteLine($"Built {TimeSpan.FromMinutes(90).Humanize()} ago, {"order_items".Pascalize()}");
Console.WriteLine($"Runtime: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}");والمخرج من تشغيلين متتاليين:
Built 1 hour ago, OrderItems
Runtime: .NET 10.0.14
elapsed 120.19s (exit 0)
Built 1 hour ago, OrderItems
Runtime: .NET 10.0.14
elapsed 1.73s (exit 0)التشغيل الأول أخذ دقيقتين لأنه حمل حزمة Humanizer.Core وبنى مشروعاً مؤقتاً خلف الكواليس، والثاني أخذ أقل من ثانيتين لأن المشروع المؤقت محفوظ. ولاحظ أن TimeSpan.FromMinutes(90).Humanize() أعطت «1 hour» وليس ساعة ونصفاً، لأن الدالة تقرب إلى أكبر وحدة افتراضياً. وهذه الطريقة ممتازة لسكربتات الصيانة وتجربة فكرة سريعة، وعندما يكبر الملف تحوله إلى مشروع عادي بالأمر dotnet project convert hello.cs.
النشر Publish: أربعة أشكال وأحجامها
عندما تنتهي من البرنامج وتريد تشغيله على سيرفر، فالأمر dotnet publish يجمع كل ما يحتاجه في مجلد واحد، وله أشكال مختلفة شرحتها Microsoft في صفحة النشر، وقد جربنا ثلاثة منها على نفس البرنامج:
dotnet publish src/Store.Cli -c Release -o /out/fdd
dotnet publish src/Store.Cli -c Release -r linux-x64 --self-contained -o /out/sc
dotnet publish src/Store.Cli -c Release -r linux-x64 --self-contained \
-p:PublishSingleFile=true -p:PublishTrimmed=true -o /out/trimوالمخرج سوف يكون كما يلي:
framework-dependent: 136K, files 7
self-contained: 80M, files 194
single-file trimmed: 15M, files 3- المعتمد على الإطار Framework-dependent: 136 كيلوبايت في سبعة ملفات، أي الـ dll الخاص بالبرنامج والمكتبة وملفات الإعداد فقط، ويحتاج أن يكون الـ Runtime مثبتاً على السيرفر، وهذا هو الشكل الذي تضعه فوق Image الـ Runtime الرسمية في Docker.
- المستقل Self-contained: 80 ميجابايت في 194 ملفاً، لأنه يحمل الـ Runtime كاملاً معه، فيعمل على سيرفر Linux لا يوجد فيه .NET، ولكن عليك أنت أن تعيد النشر مع كل تحديث أمني للـ Runtime.
- ملف واحد مقلم Single-file Trimmed: 15 ميجابايت، حيث التقليم Trimming يحذف من الـ Runtime والمكتبات كل ما لا يصل إليه الكود، ثم يجمع الباقي في ملف واحد Single File. والتقليم يحلل الكود تحليلاً ثابتاً، فإذا كان الكود يستعمل الانعكاس Reflection ليصل إلى نوع بالاسم فقد يحذفه، ولذلك يطبع تحذيرات IL2026 وما يشبهها عندما يشك، ولم يطبع برنامجنا أي تحذير.
والصورة التالية تضع الأحجام الأربعة جنباً إلى جنب، ومنها الشكل الرابع الذي جربناه في الخطوة التالية:

ماذا يحدث إذا طلبت Native AOT؟
بقي الشكل الرابع، وهو الترجمة المسبقة Native AOT، فأضفنا إلى السكربت الأمر نفسه مع الخيار PublishAot، والنتيجة كانت خطأ:
/root/.nuget/packages/microsoft.dotnet.ilcompiler/10.0.14/build/Microsoft.NETCore.Native.Unix.targets(316,5): error : Platform linker ('clang' or 'gcc') not found in PATH. Ensure you have all the required prerequisites documented at https://aka.ms/nativeaot-prerequisites. [/w/src/Store.Cli/Store.Cli.csproj]والرسالة واضحة: الـ SDK حمل المترجم المسبق ILCompiler كحزمة NuGet، وهذا المترجم يحول الـ IL إلى كود المعالج، ولكنه يحتاج رابط النظام Platform Linker ليجمع الملف التنفيذي النهائي، وImage الـ SDK الرسمية لا تحتوي clang. لذلك كتبنا السكربت lab/run-aot.sh الذي يثبت clang ومكتبة zlib1g-dev أولاً كما تطلب صفحة المتطلبات، ثم ينشر، ثم يقارن زمن البدء بين الملف الأصلي والـ dll ثلاث مرات:
installed: Ubuntu clang version 18.1.3 (1ubuntu1)
aot publish took 68.0s
-rwxr-xr-x 1 root root 1622200 Oct 31 07:40 Store.Cli
-rwxr-xr-x 1 root root 3058488 Oct 31 07:40 Store.Cli.dbg
-rw-r--r-- 1 root root 11492 Oct 31 07:40 Store.Pricing.pdb
aot: 4.5M
run 1: native aot 15 ms, dotnet dll 183 ms
run 2: native aot 61 ms, dotnet dll 202 ms
run 3: native aot 5 ms, dotnet dll 357 msفي المخرج أعلاه لاحظ التالي:
- السطر الأول يبين أن Image الـ SDK مبنية على Ubuntu، ولذلك جاء
clang18 من مستودع Ubuntu. - النشر أخذ 68 ثانية، وهو أبطأ بكثير من النشر العادي، لأن المترجم المسبق يحلل البرنامج كله مع كل ما يستعمله من الـ Runtime ويترجمه بتحسينات كاملة.
- الملف التنفيذي
Store.Cliحجمه 1.6 ميجابايت فقط، وهو البرنامج كله مع جامع القمامة وما يحتاجه من المكتبات، ويعمل على Linux دون أي .NET مثبت. والملفStore.Cli.dbgفيه رموز التصحيح Debug Symbols ولا تحتاج نشره، ولذلك مجموع المجلد 4.5 ميجابايت والذي تنشره فعلاً 1.6. - زمن البدء بين 5 و61 ملي ثانية للملف الأصلي، وبين 183 و357 للـ dll، والتذبذب الكبير بين التشغيلات سببه الجهاز المشغول بتجارب أخرى، ولكن الفرق ثابت في الاتجاه: الـ dll يحتاج تحميل الـ Runtime وترجمة الدوال بالـ JIT قبل أول سطر، والملف الأصلي يبدأ مباشرة.
وقد يتساءل البعض: لماذا لا ننشر كل شيء بـ Native AOT إذن؟ والإجابة أن له ثمناً: لا يعمل مع المكتبات التي تولد كوداً وقت التشغيل أو تعتمد على الانعكاس دون تحضير، فـ Entity Framework Core مثلاً ما زال دعمه محدوداً، وسلسلة JSON تحتاج مولد الكود Source Generator بدل الانعكاس، والبناء يحتاج أدوات النظام لكل منصة تنشر لها، والأداء بعد التسخين قد يكون أقل قليلاً من الـ JIT الذي يحسن الكود بحسب الاستعمال الفعلي. لذلك هو اختيار ممتاز لأدوات سطر الأوامر والدوال السحابية Serverless Functions التي يهمها زمن البدء، وأقل أهمية لخدمة ويب تعمل أسابيع دون إعادة تشغيل، وسوف نعود إلى هذا القرار بأرقام الخدمة الحقيقية في المقال الرابع.
مدير الحزم NuGet والاعتماديات Dependencies
الحزم في .NET تأتي من NuGet، وهو مثل npm أو PyPI، وتضيف حزمة بالأمر dotnet add package فيكتب سطر PackageReference في ملف المشروع برقم الإصدار، كما رأينا في ملف مشروع الاختبارات. والحزم تحمل إلى مجلد مشترك على الجهاز في ~/.nuget/packages وليس داخل المشروع، فلا يوجد مجلد مثل node_modules، وهذا سبب أن الـ Volume في تجربتنا وفر علينا التحميل في كل تشغيل. والمفاهيم العامة لمديري الحزم وملفات القفل Lock Files شرحناها في مقال مديرو الحزم، وما يخص NuGet منها ثلاثة أمور:
- الإدارة المركزية Central Package Management: عندما يكون في الحل عشرة مشاريع تستعمل نفس الحزمة، تضع أرقام الإصدارات كلها في ملف واحد
Directory.Packages.propsفي جذر المستودع، وتكتب المشاريع اسم الحزمة فقط، كما تشرح صفحة الإدارة المركزية، وبهذا لا يحدث أن يستعمل مشروع إصداراً ومشروع آخر إصداراً غيره. - ملف القفل: NuGet لا ينشئ ملف قفل افتراضياً، لأن الأرقام في
PackageReferenceثابتة أصلاً، ولكن الاعتماديات غير المباشرة Transitive قد تتغير، فإذا أردت بناءً يتكرر بالضبط فعلRestorePackagesWithLockFileليكتبpackages.lock.json، واستعملdotnet restore --locked-modeفي CI. - فحص الثغرات NuGet Audit: منذ .NET 9 تفحص الاستعادة كل حزمة مقابل قاعدة الثغرات المعروفة وتطبع تحذيراً إذا وجدت واحدة، كما في صفحة فحص الحزم، ومع
TreatWarningsAsErrorsيصير هذا التحذير خطأ يوقف البناء، وهذا ما تريده في الغالب.
المحرر IDE: VS Code أم Rider أم Visual Studio؟
جزء كبير من راحة العمل في C# يأتي من المحرر، لأن اللغة ذات أنواع قوية، فالمحرر يعرف نوع كل متغير ويكمل لك ويعيد التسمية في كل الحل بأمان. والخيارات العملية ثلاثة:
- VS Code مع C# Dev Kit: مجاني ويعمل على كل الأنظمة، والإضافة الرسمية من Microsoft تعطي مستكشف الحل والاختبارات والتصحيح، والبداية مشروحة في صفحة C# في VS Code. ولاحظ أن C# Dev Kit لها رخصة خاصة بها مثل Visual Studio، فاقرأها إذا كانت الشركة كبيرة.
- JetBrains Rider: محرر كامل لـ .NET على Windows وmacOS وLinux، وصار مجانياً للاستعمال غير التجاري منذ أكتوبر 2024، وللشركات برخصة مدفوعة، وهو اختيار كثير من الفرق التي تعمل على Mac وLinux.
- Visual Studio: المحرر التقليدي لـ C#، على Windows فقط، وهو الأقوى في أدوات التحليل والتصحيح، وإذا كان فريقك كله على Windows فهو الخيار الطبيعي.
والمهم أن المشروع نفسه لا يرتبط بأي محرر، فكل ما رأيناه في التجربة يعمل من سطر الأوامر، وهذا ما يجب أن يعتمد عليه خط CI، ولا يوجد ملف خاص بمحرر يحتاجه البناء.
أخطاء شائعة Common Mistakes في الأسبوع الأول
- تثبيت الـ Runtime بدل الـ SDK على جهاز المطور: فيعمل
dotnetولكن يرفضdotnet newوdotnet buildبرسالة أن الـ SDK غير موجود. والعكس في الإنتاج، أي وضع Image الـ SDK في الـ Container النهائي، يضيف قرابة جيجابايت ومترجماً لا حاجة له. - رفع
binوobjإلى Git: فتظهر تعارضات في ملفات ثنائية، ويبني CI من بقايا قديمة، وسوف ترى بعدها فخ التحذيرات نفسه. وملف.gitignoreالمناسب تنشئه بالأمرdotnet new gitignore. - تشغيل الخدمة بـ
dotnet runفي الإنتاج: فتبدأ في عشرين ثانية بدل ربع ثانية، وتبني في كل تشغيل، وتحتاج الـ SDK على السيرفر. - إطفاء
Nullableلأن التحذيرات كثيرة: وهذا يعيدك إلى C# قبل 2019، والحل الصحيح أن تصلح التحذيرات واحداً واحداً، أو تفعله ملفاً بملف بالتوجيه#nullable enableفي المشاريع القديمة. - إسكات التحذير بعلامة التعجب
!: فالتعبيرc!.Lengthيقول للمترجم «أنا أعرف أنه ليس null»، والمترجم يصدقك، ولكن الـ Runtime لا يصدق أحداً، وسوف نرى في المقال التاليNullReferenceExceptionالذي يأتي بعدها. - الاعتماد على أن البناء «نجح بدون تحذيرات» دون أن يتغير شيء: كما رأينا في التجربة، والحل خيار في ملف المشروع وCI نظيف.
- عدم تثبيت إصدار الـ SDK: فيترقى جهاز مطور إلى نطاق ميزات جديد يأتي بمحللات جديدة، فتظهر عنده أخطاء لا يراها غيره. والحل ملف
global.json. - استعمال
doubleللمبالغ: لأن هذا ما تفعله في JavaScript، وسوف نرى في المقال التالي كم مرة يخطئ في الضريبة بالأرقام.
قائمة تحقق Checklist: مستودع C# جديد
| البند | كيف | لماذا |
|---|---|---|
| إصدار ثابت للـ SDK | global.json برقم 10.0.4xx | كل الفريق وCI يبنون بنفس الأدوات |
| إطار LTS | net10.0 | دعم أمني حتى نوفمبر 2028 |
| خيارات مشتركة | Directory.Build.props | لا تتكرر الإعدادات في كل مشروع |
| القيم الفارغة | Nullable مفعل | المترجم يتابع الـ null بدلاً عنك |
| التحذيرات | TreatWarningsAsErrors في الملف وليس في الأمر | تجنب فخ البناء التزايدي |
| التنسيق | .editorconfig وdotnet format --verify-no-changes في CI | لا نقاش في المراجعة على المسافات |
| الحزم | Directory.Packages.props وملف قفل | نفس الإصدارات في كل المشاريع وكل بناء |
| CI | restore ثم build --no-restore ثم test --no-build من نسخة نظيفة | أسرع، ولا بقايا من بناء سابق |
| النشر | dotnet publish -c Release فوق Image الـ Runtime | صغير، والـ Runtime يتحدث مع الـ Image |
الخلاصة
- .NET اليوم منصة مفتوحة المصدر تعمل على Linux، وتصدر كل نوفمبر، و.NET 10 نسخة LTS حتى نوفمبر 2028 ومعها C# 14، وهي ما نستعمله في كل هذه المرحلة.
- الـ SDK للمطور وللبناء، والـ Runtime أو ASP.NET Runtime وحده للإنتاج، ورقم الـ SDK 10.0.403 يختلف عن رقم الـ Runtime 10.0.14 لأن لكل منهما ترقيماً خاصاً.
- أغلب ما تعرفه من Python وJavaScript له مقابل مباشر: السجلات Records بدل dataclass، وLINQ بدل comprehensions مع تنفيذ مؤجل، والتعبير switch مع الأنماط، و
string?للقيم الفارغة كتحليل وقت البناء فقط. - المترجم Roslyn يخرج IL، والـ JIT يترجمها وقت التشغيل على مستويات، وNative AOT طريق بديل يخرج ملفاً أصلياً بشروط.
- الأمر
dotnetيجمع البناء والتشغيل والاختبار والتنسيق والحزم والنشر، والمحللات Analyzers تعمل داخل البناء نفسه. - في التجربة رأينا تحذيراً يختفي من البناء الثاني بسبب البناء التزايدي، وبناء بخيار
-warnaserrorينجح رغم الخطأ، والحل الخيار في ملف المشروع وCI نظيف. - نفس البرنامج نشر بـ 136 كيلوبايت معتمداً على الإطار، و80 ميجابايت مستقلاً، و15 ميجابايت ملفاً واحداً مقلماً، و1.6 ميجابايت ملفاً أصلياً بـ Native AOT يبدأ في أجزاء من مئة من الثانية، بعد أن ثبتنا
clangالذي لا يوجد في Image الـ SDK.
وفي المقال التالي من مرحلة لغة C# للمطورين ننتقل إلى الأنواع: القيم Value Types والمراجع Reference Types، ومن يدير الذاكرة، وكيف تتعامل C# مع الأخطاء بالاستثناءات Exceptions، بتجارب تقيس كل ذلك. وهذه مقالات تكمل هذا المقال:
دليلكيف تختار لغة البرمجة Programming Language لمشروعك؟نختار لغة خدمة حقيقية بأسئلة الفريق والتوظيف والمكتبات والرخصة، ثم نكتبها بأربع لغات ونقيس البناء والحجم والذاكرة والطلبات في الثانية بأنفسنا.
دليلالمترجم Compiler والمفسر Interpreter و JIT و WebAssemblyنشغل دالة واحدة بلغة C وPython وJavaScript وWebAssembly على نفس الجهاز، ونفتح ما كتبه كل مترجم لنعرف لماذا اختلف الوقت من ثلث ثانية إلى دقيقة.
دليلأدوات البناء Build Tools وماذا يحدث عندما تبني مشروعكما الذي يحدث بين ضغطة البناء والملف الناتج، ولماذا ينجح البناء على جهازك ويفشل على سيرفر CI، ومتى يعطيك نفس النتيجة مرتين.
دليلمديرو الحزم Package Managers: npm و pip و Maven و Cargoنجرب npm وpip وMaven وCargo على مشروع واحد بأربع لغات، فنقيس كم حزمة تأتي مع كل سطر، ونرى كيف يجعل ملف القفل التثبيت متطابقاً، وكيف خدع حرف واحد في اسم مكتبة مطوري Rust.
دليلأنماط البرمجة وأنظمة الأنواع: لماذا تختلف اللغات؟نحل مسألة واحدة بعدة أساليب برمجة، ونرى هل تكتشف اللغة أخطاءك قبل التشغيل أم بعده، وماذا يعني ذلك عند اختيار لغة لمشروعك.
دليلأنواع الاختبارات للمبتدئين: من Unit إلى E2E مع Playwrightما الذي يكشفه كل نوع من الاختبارات وما الذي يفوته، وكم اختباراً تكتب من كل نوع، وأين يعمل كل منها في خط CI.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة بـ .NET SDK 10.0.403 وRuntime 10.0.14 في Container رسمي على Linux.