بعد حادثة الدلال النحاسية التي باعت فيها الخدمة عشرين قطعة من عشر، قرر فريق المتجر أن يعيد كتابة خدمة الطلبات من الصفر بشكل صحيح، وأن تكون صغيرة بما يكفي ليفهمها كل من في الفريق في جلسة واحدة. والمطلوب ثلاثة مسارات فقط: قائمة المنتجات، وإنشاء طلب يحسب ضريبة القيمة المضافة بالهللات ويرفض الطلب إذا لم يكف المخزون، وقراءة طلب سابق. ولكن خلف هذه المسارات الثلاثة كل ما تحتاجه أي خدمة حقيقية: معاملة Transaction في قاعدة البيانات تقفل الصفوف، وتحقق من المدخلات، ورسائل خطأ بالشكل الصحيح، واختبارات وحدة واختبارات تكامل على قاعدة بيانات حقيقية، واختبار تزامن يرسل عشرين طلباً في نفس اللحظة، ثم Image صغيرة تقاس في Container محدود.
وهذا هو المقال الرابع في مرحلة لغة C# للمطورين، بعد الأدوات والأساسيات والأنواع والذاكرة والتزامن، وفيه نبني هذه الخدمة بـ ASP.NET Core وPostgreSQL 17. وهي نفس الخدمة بنفس المواصفة بالضبط في كل لغات هذا المسار، بنفس الجداول والمسارات والاختبارات والقياسات، حتى تقارن بين اللغات على شيء حقيقي، وفكرة المقارنة نفسها شرحناها في مقال كيف تختار لغة البرمجة لمشروعك. والكود كاملاً في مجلد lab المرفق، وكل الأرقام من تشغيل حقيقي بـ .NET 10.0.14، وفيه مشكلتان حقيقيتان ظهرتا قبل أن تنجح الاختبارات، وتجربة «ماذا يحدث إذا» حذفنا قفل الصفوف.
وسوف نناقش في هذا المقال ما يلي:
- المواصفة المشتركة: الجداول والمسارات وقواعد الضريبة والأخطاء.
- اختيار الأدوات: لماذا Minimal APIs، ولماذا Npgsql مباشرة وليس Entity Framework Core أو Dapper.
- الملف
Program.cs: حقن الاعتماديات، ومجمع الاتصالات، وإعدادات JSON، وأخطاء ProblemDetails. - قلب الخدمة: الطلب في معاملة واحدة بقفل الصفوف
FOR UPDATE، وحساب الضريبة بالهللات. - الاختبارات: وحدة وتكامل وتزامن، والمشكلتان اللتان ظهرتا، وماذا يحدث إذا حذفنا القفل.
- الـ Dockerfile بمرحلتين، ثم القياسات: حجم الـ Image، والبدء البارد، والذاكرة، و
wrkمرتين، وماذا يحدث مع Workstation GC وReadyToRun. - الأخطاء الشائعة في خدمات ASP.NET Core، وقائمة تحقق قبل الإنتاج.
المواصفة Specification: نفس الخدمة في كل لغات المسار
الخدمة واجهة طلبات Orders API لمتجر صغير، وقاعدة بياناتها ثلاثة جداول في PostgreSQL 17 بهذا الشكل بالضبط:
CREATE TABLE IF NOT EXISTS products (
id serial PRIMARY KEY,
name_ar text NOT NULL,
price_halalas int NOT NULL CHECK (price_halalas >= 0),
stock int NOT NULL CHECK (stock >= 0)
);
CREATE TABLE IF NOT EXISTS orders (
id serial PRIMARY KEY,
created_at timestamptz DEFAULT now(),
total_halalas int NOT NULL
);
CREATE TABLE IF NOT EXISTS order_items (
order_id int REFERENCES orders(id),
product_id int REFERENCES products(id),
qty int CHECK (qty > 0),
price_halalas int
);
INSERT INTO products (name_ar, price_halalas, stock)
SELECT * FROM (VALUES ('قهوة خولاني 250 جم', 990, 40), ('تمر سكري 1 كجم', 4500, 12), ('دلة نحاس', 12900, 10)) AS v(n, p, s)
WHERE NOT EXISTS (SELECT 1 FROM products);في الجداول أعلاه لاحظ التالي:
- كل المبالغ أعداد صحيحة بالهللة، فالقهوة بـ 9.90 ريال تحفظ 990، والسبب ما رأيناه في مقال الأنواع:
doubleأخطأ في ربع المبالغ الحدية. - القيد
CHECK (stock >= 0)يمنع المخزون السالب في قاعدة البيانات نفسها، مهما كان الكود الذي يكتب، وسوف نرى في التجربة أنه أنقذ البيانات عندما حذفنا القفل. - سعر كل سطر يحفظ في
order_itemsوقت الطلب، لأن سعر المنتج قد يتغير بعد ذلك، والفاتورة يجب أن تبقى كما كانت. - البيانات الأولية Seed تضاف فقط إذا كان الجدول فارغاً، فيمكن تشغيل الملف كله عند كل بدء دون تكرار.
والمسارات ثلاثة: GET /products يرجع قائمة المنتجات بصيغة JSON، وPOST /orders يستقبل {"items":[{"product_id":1,"qty":3}]} وينفذ كل شيء في معاملة واحدة: يقفل صفوف المنتجات، ويفحص المخزون، وينقصه، ويدخل الطلب وسطوره، ويحسب الإجمالي مع ضريبة القيمة المضافة 15% بحسب هيئة الزكاة والضريبة والجمارك بالأعداد الصحيحة مع التقريب نصف للأعلى، ويرجع 409 إذا لم يكف المخزون، و400 إذا كان المدخل خاطئاً. وGET /orders/{id} يرجع الطلب بسطوره. والمثال المرجعي الذي يختبر في كل لغة: ثلاث قهوات بـ 9.90 ريال، المجموع 29.70، والضريبة 4.455 تقرب إلى 4.46، والإجمالي 34.16 ريال.

اختيار الأدوات Stack: Minimal APIs مع Npgsql
الإطار في .NET لا خلاف عليه تقريباً، فهو ASP.NET Core، ولكن داخله طريقتان لكتابة المسارات: المتحكمات Controllers، وهي كلاسات بدوال وخصائص Attributes على طريقة MVC، وكانت الطريقة الوحيدة لسنوات، والـ Minimal APIs التي جاءت مع .NET 6، وتعرف فيها كل مسار بسطر app.MapGet ودالة. وللقادم من Express أو FastAPI أو Flask فالثانية مألوفة جداً، ولخدمة بثلاثة مسارات هي الأوضح، ولذلك اخترناها. وفي خدمة بخمسين مسار يقسم الناس المسارات إلى ملفات بمجموعات MapGroup، أو يرجعون إلى المتحكمات، والفرق في الأداء بينهما صغير.
أما الوصول إلى قاعدة البيانات فهو القرار الأهم، وفيه ثلاثة خيارات معروفة:
- Entity Framework Core: الـ ORM الرسمي من Microsoft والأكثر انتشاراً في مشاريع .NET، يكتب SQL بدلاً عنك من استعلامات LINQ، ويتابع التغييرات على الكائنات ويحفظها، ويدير الترحيل Migrations. وهو اختيار ممتاز لتطبيقات فيها عشرات الجداول وعمليات CRUD كثيرة.
- Dapper: مكتبة صغيرة تكتب فيها SQL بنفسك، وهي تحول الصفوف إلى كائنات، وكانت لسنوات الاختيار المعتاد لمن يريد الأداء والتحكم.
- Npgsql مباشرة: وهو مشغل PostgreSQL الرسمي لـ .NET الذي تقوم عليه المكتبتان السابقتان أصلاً.
واخترنا الثالث، والسبب أن قلب هذه الخدمة جملة SQL واحدة بالتحديد: SELECT ... FOR UPDATE مرتبة بالمعرف داخل معاملة، وهذه الجملة لا يوجد لها مقابل في LINQ في EF Core، فكنت سوف تكتبها نصاً خاماً على أي حال، ومعها كل الفحص والإنقاص الذي يجب أن يكون في نفس المعاملة. ومع Npgsql المباشر يبقى كل SQL في الخدمة مرئياً في ملف واحد يقرؤه أي أحد في الفريق، ولا توجد اعتمادية إضافية غير المشغل نفسه، والكود متوافق مع Native AOT إذا احتجناه يوماً. وثمن هذا الاختيار واضح: تحويل الصفوف إلى كائنات يدوي بـ GetInt32 وGetString، وهذا مقبول في ثلاثة استعلامات، وغير مقبول في خمسين، فلو كبرت الخدمة لأضفنا Dapper أولاً.
ولكي ترى الفرق بنفسك، فهذا شكل نفس القفل لو كتبت الخدمة بـ EF Core: تعرف الكلاس Product وسياق البيانات DbContext، ثم تكتب داخل معاملة db.Products.FromSql($"SELECT * FROM products WHERE id = ANY({ids}) ORDER BY id FOR UPDATE").ToListAsync()، أي نفس الجملة نصاً خاماً، ثم تعدل Stock على الكائنات وتستدعي SaveChangesAsync فيولد EF Core جمل التعديل والإدخال. وهذا شكل جيد ومقروء، ولكنه يضيف حزمتين، وطبقة تتابع التغييرات Change Tracking لا نحتاجها هنا، وترحيلاً يولد الجداول من الكلاسات بدل ملف schema.sql الذي تشترك فيه كل لغات المسار. ولذلك في فريق يكتب خدماته كلها بـ EF Core يكون الاختيار الطبيعي أن تبقى معه، وفي خدمة صغيرة قلبها SQL واحد فالمشغل المباشر أبسط.
هيكل المشروع Project Layout
الحل فيه مشروعان فقط، الخدمة والاختبارات، وكل ما حولهما ملفات تشغيل:
lab/
├─ global.json SDK 10.0.403 + the new test runner
├─ Dockerfile multi-stage: sdk:10.0 -> aspnet:10.0
├─ Dockerfile.r2r what-if: ReadyToRun
├─ run-host.sh PostgreSQL, tests, image, measurements
├─ src/Store.Api/
│ ├─ Store.Api.csproj
│ ├─ Program.cs routes, DI, JSON, errors
│ ├─ OrderService.cs the order transaction
│ ├─ Pricing.cs VAT in halalas
│ └─ schema.sql
└─ tests/Store.Tests/
├─ Store.Tests.csproj xUnit v3 + Mvc.Testing
├─ PricingTests.cs unit
└─ ApiTests.cs integration + concurrencyوملف مشروع الخدمة قصير، وكل سطر فيه له سبب:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Npgsql.DependencyInjection" Version="10.0.3" />
</ItemGroup>
<ItemGroup>
<None Update="schema.sql" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>في الملف أعلاه لاحظ التالي: Microsoft.NET.Sdk.Web يضيف ASP.NET Core كله دون أي حزمة، وTreatWarningsAsErrors في الملف نفسه كما تعلمنا من فخ البناء التزايدي في المقال الأول، وInvariantGlobalization يجعل الخدمة كلها تعمل بالثقافة الثابتة، فلا يتغير تحويل الأرقام والتواريخ بإعدادات السيرفر، وهذا من أخطر الأخطاء التي نراها في مقال الأخطاء الشائعة. والحزمة الوحيدة هي Npgsql.DependencyInjection، وهي المشغل مع دالة تسجله في حقن الاعتماديات.
نقطة البداية Program.cs: حقن الاعتماديات Dependency Injection والإعدادات
الملف Program.cs هو الخدمة كلها ما عدا منطق الطلب، وهذا أوله:
var builder = WebApplication.CreateBuilder(args);
// One NpgsqlDataSource for the whole process: it owns the connection pool.
var connString = builder.Configuration.GetConnectionString("Store")
?? throw new InvalidOperationException("ConnectionStrings__Store is not set");
builder.Services.AddNpgsqlDataSource(connString);
builder.Services.AddSingleton<OrderService>();
builder.Services.ConfigureHttpJsonOptions(o =>
{
o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; // product_id, price_halalas
o.SerializerOptions.Encoder = JavaScriptEncoder.Create(UnicodeRanges.All); // Arabic as text, not قه...
});
builder.Services.AddProblemDetails();
var app = builder.Build();
// Bad JSON or a wrong type in the body throws BadHttpRequestException (in Development, and in tests);
// without the selector the exception handler turns it into 500 instead of 400.
app.UseExceptionHandler(new ExceptionHandlerOptions
{
StatusCodeSelector = ex => ex is BadHttpRequestException bad ? bad.StatusCode : StatusCodes.Status500InternalServerError,
});في الكود أعلاه لاحظ التالي:
GetConnectionString("Store")يقرأ الإعداد من أي مصدر، وفي الـ Container يأتي من متغير البيئةConnectionStrings__Store، والشرطتان السفليتان تقابلان النقطتين في مسار الإعداد. وإذا لم يوجد ترفض الخدمة أن تبدأ، وهذا أفضل من أن تبدأ وتفشل عند أول طلب.AddNpgsqlDataSourceيسجلNpgsqlDataSourceواحداً للعملية كلها، وهو يملك مجمع الاتصالات Connection Pool، وحده الأقصى الافتراضي مئة اتصال كما في معاملات الاتصال، فكل طلب يستعير اتصالاً ويرجعه عندDispose، ولا يفتح اتصالاً جديداً بقاعدة البيانات.OrderServiceمسجل Singleton، أي نسخة واحدة للخدمة كلها، وهذا آمن لأنه لا يحمل أي حالة غير الـ DataSource الذي هو نفسه آمن للاستعمال من عدة خيوط.SnakeCaseLowerيحولPriceHalalasإلىprice_halalas، لأن المواصفة المشتركة بين اللغات تستعمل هذه الأسماء.- المرمز Encoder: الإعداد الافتراضي في
System.Text.Jsonيهرب كل حرف خارج نطاق ASCII كما تشرح صفحة ترميز الأحرف، فيكتب «قهوة» بالشكلقهوة، وهو JSON صحيح يفهمه أي عميل، ولكنه أطول ولا يقرؤه إنسان في السجلات. والسطر أعلاه يسمح بكل نطاقات Unicode فتكتب العربية كما هي. AddProblemDetailsيجعل كل خطأ يرجع بصيغة ProblemDetails المعيارية، وهي JSON فيهtypeوtitleوstatusوdetail، وسوف نراها في التجربة. وStatusCodeSelectorله قصة في قسم الاختبارات.
ثم ينشئ البرنامج الجداول قبل أول طلب، ويعرف المسارات:
// Create the tables and seed the products once (idempotent), before the first request
await using (var cmd = app.Services.GetRequiredService<NpgsqlDataSource>()
.CreateCommand(await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "schema.sql"))))
await cmd.ExecuteNonQueryAsync();
app.MapGet("/products", async (NpgsqlDataSource db) =>
{
await using var cmd = db.CreateCommand("SELECT id, name_ar, price_halalas, stock FROM products ORDER BY id");
await using var r = await cmd.ExecuteReaderAsync();
var list = new List<ProductDto>();
while (await r.ReadAsync()) list.Add(new(r.GetInt32(0), r.GetString(1), r.GetInt32(2), r.GetInt32(3)));
return list;
});
app.MapPost("/orders", async (OrderRequest? req, OrderService orders) =>
{
if (Validate(req) is { } problem) return Results.Problem(problem, statusCode: 400);
var result = await orders.PlaceAsync(req!.Items!);
return result switch
{
OrderResult.Created c => Results.Created($"/orders/{c.Order.Id}", c.Order),
OrderResult.UnknownProduct u => Results.Problem($"product {u.ProductId} does not exist", statusCode: 400),
OrderResult.OutOfStock s => Results.Problem($"product {s.ProductId}: requested {s.Requested}, in stock {s.Available}", statusCode: 409),
_ => throw new UnreachableException(),
};
});
app.MapGet("/orders/{id:int}", async (int id, OrderService orders) =>
await orders.GetAsync(id) is { } order ? Results.Ok(order) : Results.NotFound());ولاحظ أن كل دالة مسار تأخذ ما تحتاجه كمعاملات: NpgsqlDataSource وOrderService من حقن الاعتماديات، وOrderRequest من جسم الطلب، وid من المسار، وASP.NET Core يعرف مصدر كل معامل من نوعه ومكانه كما تشرح صفحة ربط المعاملات، والقيد {id:int} يجعل /orders/abc يرجع 404 دون أن يصل للدالة. وإنشاء الجداول عند البدء مناسب لهذا المختبر لأن الملف لا يضر إذا تكرر، أما في الإنتاج فالفريق يفصل الترحيل Migration في خطوة نشر مستقلة، لأن عشر نسخ من الخدمة تبدأ معاً لا يجب أن تحاول كلها تعديل الجداول في نفس اللحظة.
قلب الخدمة: الطلب في معاملة واحدة Transaction
الآن OrderService.PlaceAsync، وهو الجزء الذي يجب أن يكون صحيحاً تحت الضغط، لأنه هنا بالضبط باعت الخدمة القديمة عشرين دلة من عشر:
public async Task<OrderResult> PlaceAsync(IReadOnlyList<OrderItemRequest> items)
{
// The same product twice in one order counts as one line with the sum of quantities
var wanted = items.GroupBy(i => i.ProductId)
.ToDictionary(g => g.Key, g => checked(g.Sum(i => i.Qty)));
int[] ids = [.. wanted.Keys.Order()];
await using var conn = await db.OpenConnectionAsync();
await using var tx = await conn.BeginTransactionAsync();
// Lock the rows in id order: two orders touching the same products always lock them in the same order (no deadlock)
var found = new Dictionary<int, (int Price, int Stock)>();
await using (var cmd = new NpgsqlCommand(
"SELECT id, price_halalas, stock FROM products WHERE id = ANY(@ids) ORDER BY id FOR UPDATE", conn, tx))
{
cmd.Parameters.Add(new("ids", NpgsqlDbType.Array | NpgsqlDbType.Integer) { Value = ids });
await using var r = await cmd.ExecuteReaderAsync();
while (await r.ReadAsync()) found[r.GetInt32(0)] = (r.GetInt32(1), r.GetInt32(2));
}
foreach (var id in ids)
{
if (!found.TryGetValue(id, out var p)) return new OrderResult.UnknownProduct(id); // rollback on dispose
if (p.Stock < wanted[id]) return new OrderResult.OutOfStock(id, wanted[id], p.Stock);
}
var lines = ids.Select(id => new OrderLineDto(id, wanted[id], found[id].Price)).ToList();
var (subtotal, vat, total) = Pricing.Totals(lines.Select(l => (l.PriceHalalas, l.Qty)));
// ... UPDATE products (one NpgsqlBatch), INSERT orders RETURNING id, INSERT order_items (one NpgsqlBatch)
await tx.CommitAsync();
return new OrderResult.Created(new OrderDto(orderId, createdAt, subtotal, vat, total, lines));
}في الكود أعلاه لاحظ التالي:
- الطلب الذي فيه نفس المنتج مرتين يجمع في سطر واحد قبل أي شيء، وإلا فحصنا الكمية الأولى والثانية كل واحدة وحدها ضد نفس المخزون، فمرر طلب ست قطع من مخزون خمس على دفعتين.
FOR UPDATEيقفل صفوف المنتجات المطلوبة حتى نهاية المعاملة، كما تشرح صفحة الأقفال في PostgreSQL، فأي طلب آخر على نفس المنتج ينتظر عند هذا السطر حتى نثبت أو نرجع، ثم يقرأ المخزون الجديد. وهذا بالضبط حل مشكلة «فحص ثم تنفيذ» التي رأيناها في مقال التزامن، ولكن على مستوى قاعدة البيانات، فهو يعمل مع أي عدد من نسخ الخدمة.ORDER BY idمع القفل ليس للترتيب في النتيجة، وإنما ليأخذ كل طلب الأقفال بنفس الترتيب دائماً، فطلب فيه القهوة والتمر وطلب فيه التمر والقهوة لا يمكن أن يمسك كل منهما صفاً وينتظر الآخر، وهذا نفس إصلاح القفل المميت في مقال التزامن. وشرحنا الأقفال والجمود في قواعد البيانات بالتفصيل في مقال الأقفال والجمود.- النتيجة نوع مغلق من ثلاثة سجلات، وليس استثناء: المنتج غير الموجود والمخزون الذي لا يكفي حالات متوقعة في منطق الأعمال، فترجع كقيمة، والمسار يحولها إلى 400 أو 409 بالتعبير
switch. والاستثناء يبقى لما لا يتوقعه أحد، مثل انقطاع قاعدة البيانات. - لا يوجد
Rollbackصريح في الكود، لأنawait using var txترجع المعاملة تلقائياً عندDisposeإذا لم تثبت، أي عند أيreturnمبكر أو استثناء، وهذا نفسusingالذي رأيناه مع الموارد في مقال الأنواع. - الإنقاص من المخزون كل السطور في
NpgsqlBatchواحدة، والإدخال فيorder_itemsكذلك، فكل مجموعة تذهب إلى قاعدة البيانات في طلب شبكة واحد بدل طلب لكل سطر.

حساب الضريبة VAT بالهللات Integer Math
الحساب نفسه في ملف مستقل صغير، لأنه أكثر جزء يجب أن يختبر بمعزل عن قاعدة البيانات:
public static int Vat(int subtotalHalalas)
{
ArgumentOutOfRangeException.ThrowIfNegative(subtotalHalalas);
return checked((subtotalHalalas * VatPercent + 50) / 100);
}
public static (int Subtotal, int Vat, int Total) Totals(IEnumerable<(int PriceHalalas, int Qty)> lines)
{
int subtotal = checked(lines.Sum(l => l.PriceHalalas * l.Qty));
int vat = Vat(subtotal);
return (subtotal, vat, checked(subtotal + vat));
}والفكرة في + 50 ثم القسمة على مئة أن الضريبة بالهللة هي المجموع ضرب 15 على مئة، ولكي نقرب نصف الهللة للأعلى نضيف نصف المقسوم عليه قبل القسمة الصحيحة: فـ 2970 ضرب 15 يساوي 44,550، وزائد 50 يساوي 44,600، وعلى مئة يساوي 446 بالضبط، أي 4.46 ريال. ولاحظ checked حول كل عملية، فطلب جملة يتجاوز حد int يرمي OverflowException بدل فاتورة سالبة، كما رأينا في مقال الأنواع، وفي الخدمة يصير هذا الاستثناء 500 مع سجل واضح، وهو المطلوب لحالة لا يجب أن تحدث.
تجربة الخدمة باليد API by Hand
السكربت lab/run-host.sh يشغل كل شيء بالترتيب: PostgreSQL 17 في Container، ثم الاختبارات، ثم بناء الـ Image، ثم الخدمة، ثم يرسل طلبات بـ curl من Container على نفس الشبكة. والمخرج سوف يكون كما يلي:
[{"id":1,"name_ar":"قهوة خولاني 250 جم","price_halalas":990,"stock":40},{"id":2,"name_ar":"تمر سكري 1 كجم","price_halalas":4500,"stock":12},{"id":3,"name_ar":"دلة نحاس","price_halalas":12900,"stock":10}]
HTTP/1.1 201 Created
Location: /orders/1
{"id":1,"created_at":"2026-10-31T08:51:09.720532+00:00","subtotal_halalas":2970,"vat_halalas":446,"total_halalas":3416,"items":[{"product_id":1,"qty":3,"price_halalas":990}]}
{"id":1,"created_at":"2026-10-31T08:51:09.720532+00:00","subtotal_halalas":2970,"vat_halalas":446,"total_halalas":3416,"items":[{"product_id":1,"qty":3,"price_halalas":990}]}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Conflict","status":409,"detail":"product 2: requested 500, in stock 12","trace_id":"00-db97a6f1f70488174d9bd7c18bc0b0da-db41d9634434dcf1-00"}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"detail":"qty must be between 1 and 1000","trace_id":"00-fa607c4264252751c67885f298c7f019-679890d7d528ecfc-00"}
400في المخرج أعلاه لاحظ التالي:
- السطر الأول قائمة المنتجات، والأسماء العربية مكتوبة كما هي بفضل المرمز، والأسماء بصيغة snake_case.
- طلب ثلاث قهوات رجع
201 Createdمع رأسLocationيشير إلى الطلب الجديد، والإجمالي 3416 هللة والضريبة 446 كما في المثال المرجعي، ثم قراءة نفس الطلب من المسار الثالث أعطت نفس القيم. created_atبتوقيت UTC مع الفرق+00:00، لأن العمودtimestamptzقرأناه كـDateTimeOffset، والعرض بتوقيت السعودية مهمة الواجهة وليس الخدمة.- طلب 500 كيس تمر من مخزون 12 رجع 409 بصيغة ProblemDetails، والتفاصيل تقول الكمية المطلوبة والمتاحة، و
trace_idيربط هذا الرد بسجلات الخدمة إذا اشتكى العميل. - الكمية صفر رجعت 400 من التحقق في الكود، والكمية «three» بدل رقم رجعت 400 من ASP.NET Core نفسه لأن الجسم لا يمكن تحويله إلى
OrderRequest.
الاختبارات Tests: وحدة وتكامل وتزامن
الاختبارات في مشروع واحد بثلاثة مستويات. اختبارات الوحدة Unit Tests لملف Pricing.cs وحده، ومنها المثال المرجعي:
[Fact]
public void Three_coffees_at_9_90_SAR()
{
var (subtotal, vat, total) = Pricing.Totals([(990, 3)]);
Assert.Equal(2970, subtotal); // 29.70 SAR
Assert.Equal(446, vat); // 4.455 SAR rounds half up to 4.46
Assert.Equal(3416, total); // 34.16 SAR
}واختبارات التكامل Integration Tests تشغل الخدمة كلها داخل عملية الاختبار بـ WebApplicationFactory، وتوجهها إلى قاعدة بيانات اختبار في PostgreSQL 17 حقيقي، وليس قاعدة في الذاكرة ولا محاكاة Mock، لأن القفل والقيود والمعاملات هي بالضبط ما نريد اختباره، ولا توجد محاكاة تتصرف مثل PostgreSQL فيها. وكل اختبار يبدأ بتفريغ الجداول وإعادة البيانات الأولية حتى لا يتأثر بما قبله. وقاعدة البيانات نفسها تأتي من Container يشغله السكربت قبل الاختبارات ويمرر نص الاتصال في متغير البيئة، ويوجد بديل شائع هو مكتبة Testcontainers التي تشغل الـ Container من داخل الاختبار نفسه، وهي مريحة على جهاز المطور، ولكنها تحتاج الوصول إلى Docker من داخل Container الاختبار في CI، فاخترنا الطريقة الأبسط التي تعمل بنفس الشكل في كل مكان. وأهمها اختبار التزامن:
[Fact]
public async Task Twenty_parallel_orders_for_ten_in_stock_exactly_ten_succeed()
{
// product 3 (دلة نحاس) has stock 10
var tasks = Enumerable.Range(0, 20).Select(_ => http.PostAsJsonAsync("/orders", Order((3, 1)))).ToArray();
var results = await Task.WhenAll(tasks);
Assert.Equal(10, results.Count(r => r.StatusCode == HttpStatusCode.Created));
Assert.Equal(10, results.Count(r => r.StatusCode == HttpStatusCode.Conflict));
var products = await http.GetFromJsonAsync<List<ProductDto>>("/products", Snake, TestContext.Current.CancellationToken);
Assert.Equal(0, products!.Single(p => p.Id == 3).Stock);
}ولاحظ أن الاختبار يرسل الطلبات العشرين بـ Task.WhenAll كما تعلمنا في مقال التزامن، فتصل كلها إلى الخدمة في نفس الوقت تقريباً، ويتنافس عشرون اتصالاً على نفس الصف. والمخرج الأخير لكل الاختبارات:
passed PricingTests.Three_coffees_at_9_90_SAR (145ms)
passed PricingTests.Vat_rounds_half_up(subtotal: 30, expected: 5) (57ms)
passed PricingTests.Overflow_is_an_error_not_a_wrong_total (278ms)
passed ApiTests.Order_is_created_with_vat_and_stock_goes_down (5s 755ms)
passed ApiTests.Not_enough_stock_is_409_and_nothing_changes (247ms)
passed ApiTests.Bad_input_is_400(json: "not json") (169ms)
passed ApiTests.Bad_input_is_400(json: "{\"items\":[{\"product_id\":1,\"qty\":\"three\"}]}") (109ms)
passed ApiTests.Twenty_parallel_orders_for_ten_in_stock_exactly_ten_succeed (1s 478ms)
passed ApiTests.Missing_order_is_404 (88ms)
passed ApiTests.Products_are_listed_with_arabic_names (217ms)
...
Test run summary: Passed!
total: 19
failed: 0
succeeded: 19
duration: 13s 941msتسعة عشر اختباراً: ثمانية للوحدة، وعشرة للتكامل منها ستة مدخلات خاطئة، واختبار التزامن الذي نجح في كل تشغيل. ولاحظ أن أول اختبار تكامل أخذ قرابة ست ثوان، لأنه يدفع ثمن تشغيل الخدمة داخل الاختبار وفتح أول اتصالات وترجمة الكود بالـ JIT، والباقي أجزاء من الثانية. ولكن هذا المخرج لم يكن الأول، فقبله ظهرت مشكلتان.
المشكلة الأولى: الأمر dotnet test مع Microsoft.Testing.Platform
استعملنا xUnit v3، وهو الإصدار الحالي من xUnit، وأول تشغيل بالأمر المعتاد dotnet test tests/Store.Tests فشل قبل أن يشغل أي اختبار:
error : Testing with VSTest target is no longer supported by Microsoft.Testing.Platform on .NET 10 SDK and later. If you use dotnet test, you should opt-in to the new dotnet test experience. For more information, see https://aka.ms/dotnet-test-mtp-error [/w/tests/Store.Tests/Store.Tests.csproj]والسبب أن .NET 10 جاء بطريقتين لتشغيل الاختبارات: الطريقة القديمة VSTest التي يستعملها قالب dotnet new xunit الذي رأيناه في المقال الأول مع xUnit 2، والمنصة الجديدة Microsoft.Testing.Platform التي يعمل عليها xUnit v3 بإعداداته الحالية، والأمر dotnet test لا يخلط بينهما، وإنما يطلب منك أن تختار. والحل سطر في ملف global.json في جذر المستودع:
{
"sdk": { "version": "10.0.403", "rollForward": "latestPatch" },
"test": { "runner": "Microsoft.Testing.Platform" }
}ومعه تغيرت صيغة الأمر نفسه إلى dotnet test --project tests/Store.Tests، وصارت الخيارات مختلفة، فالخيار --logger الذي تجده في كل مثال قديم لم يعد موجوداً، ومكانه --output Detailed. وهذا من الأشياء التي يجب أن تعرفها عندما تقرأ أمثلة من الإنترنت: أغلب ما كتب عن dotnet test قبل 2026 يخص الطريقة القديمة.
المشكلة الثانية: JSON خاطئ يرجع 500 بدل 400
بعد إصلاح الأمر شغلت الاختبارات، ففشل اثنان من تسعة عشر:
failed ApiTests.Bad_input_is_400(json: "not json") (550ms)
Assert.Equal() Failure: Values differ
Expected: BadRequest
Actual: InternalServerError
failed ApiTests.Bad_input_is_400(json: "{\"items\":[{\"product_id\":1,\"qty\":\"three\"}]}") (103ms)
Assert.Equal() Failure: Values differ
Expected: BadRequest
Actual: InternalServerError
Test run summary: Failed!
total: 19
failed: 2
succeeded: 17جسم ليس JSON أصلاً، وكمية نصية بدل رقم، رجعا 500، أي «خطأ في السيرفر»، مع أن الخطأ خطأ العميل. والسبب في تفاعل بين ميزتين. الأولى أن ASP.NET Core عندما يفشل في تحويل جسم الطلب إلى OrderRequest يرد 400 مباشرة في الإنتاج، ولكن في بيئة التطوير Development يرمي استثناء BadHttpRequestException بدلاً من ذلك ليرى المطور التفاصيل، وهذا يتحكم فيه الإعداد ThrowOnBadRequest، وWebApplicationFactory تشغل الخدمة في بيئة Development افتراضياً. والثانية أن UseExceptionHandler يحول أي استثناء يصله إلى 500. فالاستثناء الذي يحمل في داخله الرمز 400 وصل إلى معالج لا يقرأ هذا الرمز.
والحل هو StatusCodeSelector الذي رأيته في Program.cs، فهو يسأل كل استثناء عن الرمز المناسب، فإذا كان BadHttpRequestException أخذ رمزه هو، وإلا 500. ولاحظ أن هذه المشكلة كانت سوف تمر لو اختبرنا في الإنتاج فقط، لأن هناك لا يرمى الاستثناء أصلاً، ولكن أي مطور يشغل الخدمة محلياً سوف يرى 500 لطلب خاطئ ويضيع وقته في البحث عن خطأ في السيرفر، ولو أضاف أحد لاحقاً معالجة أخرى ترمي نفس الاستثناء لظهرت المشكلة في الإنتاج أيضاً. والصورة التالية تجمع الاختبارات والمشكلتين:

ماذا يحدث إذا حذفنا FOR UPDATE؟
قد يتساءل البعض: هل نحتاج القفل فعلاً، ما دامت جملة الإنقاص نفسها SET stock = stock - $1 ذرية في PostgreSQL؟ والسكربت lab/run-without-lock.sh يجيب: ينسخ الكود داخل Container، ويحذف FOR UPDATE من الاستعلام فقط، ثم يشغل اختبار التزامن وحده مرتين:
28: "SELECT id, price_halalas, stock FROM products WHERE id = ANY(@ids) ORDER BY id", conn, tx))
--- run 1
Actual: 0
Assert.Equal() Failure: Values differ
Expected: 10
Npgsql.PostgresException (0x80004005): 23514: new row for relation "products" violates check constraint "products_stock_check"
at ApiTests.Twenty_parallel_orders_for_ten_in_stock_exactly_ten_succeed() in /w/tests/Store.Tests/ApiTests.cs:102
failed ApiTests.Twenty_parallel_orders_for_ten_in_stock_exactly_ten_succeed (4s 508ms)والسطر 102 في الاختبار هو فحص عدد ردود 409، أي أن السطر الذي قبله، وهو عشرة طلبات ناجحة، مر بنجاح، ولكن الطلبات العشرة الباقية لم ترجع 409، وإنما رجعت كلها 500. والذي حدث أن العشرين طلباً قرأت المخزون عشرة في نفس اللحظة، فمرت كلها من فحص الكود، ثم وصلت جمل UPDATE إلى قاعدة البيانات، وPostgreSQL نفذها واحدة بعد الأخرى على الصف لأن التعديل نفسه يقفل الصف، فنزل المخزون من عشرة إلى صفر، ثم حاول الطلب الحادي عشر أن يجعله سالب واحد، فرفضه القيد CHECK (stock >= 0) بالخطأ 23514، فرمى Npgsql استثناء، فصار 500.
والنتيجة مهمة من جهتين. الأولى أن القيد في قاعدة البيانات أنقذ البيانات، فلم تبع الخدمة دلة واحدة زائدة هذه المرة، وهذا سبب أن القيود في الجداول ليست زينة، وإنما خط الدفاع الأخير عندما يخطئ الكود. والثانية أن العميل رأى «خطأ في السيرفر» بدل «المخزون لا يكفي»، وفي لوحة المراقبة ظهرت عشرة أخطاء 500 تجعل المناوب يظن أن الخدمة تتعطل، والتطبيق لا يستطيع أن يقول للعميل ماذا حدث. فالقفل لا يحمي المخزون فقط، وإنما يجعل الخطأ المتوقع يظهر كخطأ متوقع.
الـ Dockerfile بمرحلتين Multi-stage Build
الـ Image تبنى بمرحلتين، كما تشرح صفحة بناء Container لتطبيق .NET:
# Stage 1: build with the full SDK (about 1.3 GB, never shipped)
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY src/Store.Api/Store.Api.csproj src/Store.Api/
RUN dotnet restore src/Store.Api/Store.Api.csproj
COPY src/ src/
RUN dotnet publish src/Store.Api/Store.Api.csproj -c Release -o /app --no-restore
# Stage 2: only the ASP.NET Core runtime and the published app
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
USER $APP_UID
ENTRYPOINT ["dotnet", "Store.Api.dll"]في الملف أعلاه لاحظ التالي:
- ملف المشروع ينسخ وحده قبل الكود، ثم
dotnet restore، ثم الكود، والسبب طبقات الـ Image: ما دام ملف المشروع لم يتغير يعيد Docker استعمال طبقة الحزم المحملة، فلا يحمل NuGet من جديد مع كل تعديل في سطر كود. - المرحلة الثانية تبدأ من
aspnet:10.0، وتنسخ المجلد المنشور فقط، فلا يوجد في الـ Image النهائية مترجم ولا كود مصدري ولا حزم. USER $APP_UIDيشغل الخدمة بمستخدم غير root، والمتغير معرف في Images .NET الرسمية، وظهر في المخرج كـuser: 1654.ASPNETCORE_HTTP_PORTS=8080لأن المستخدم غير root لا يستطيع فتح المنافذ تحت 1024.
والمخرج من خطوة البناء في السكربت:
=== 3. image
build: 30304 ms
slc-cs-store 344MB
layers: 8, user: 1654الـ Image النهائية 344 ميجابايت، منها 341.7 للـ Image الأساسية aspnet:10.0، والخدمة نفسها قرابة 2.3 ميجابايت فقط، أي أن الحجم كله تقريباً هو نظام Ubuntu والـ Runtime. وإذا كان الحجم مهماً عندك فالخيار التالي Images «chiseled» التي فيها الـ Runtime فقط دون أي أداة نظام، كما في توثيق dotnet-docker، أو Native AOT الذي رأينا في المقال الأول أنه يحتاج مكتبات تدعمه.
القياسات Measurements: نواتان مع 512 MB
كل Container في القياس، الخدمة وقاعدة البيانات وwrk، محدود بنواتين و512 ميجابايت. وقبل الأرقام يجب أن نذكر بوضوح أن الجهاز كان أثناء القياس يشغل تجارب لغات أخرى من نفس المسار، وفي لحظة القياس أخذ بناء واحد منها أكثر من أربع أنوية، فالأرقام المطلقة هنا أقل بكثير مما يعطيه نفس الكود على سيرفر هادئ، وتصلح للمقارنة بين الإعدادات في نفس الجلسة، لا للمقارنة مع أرقام منشورة في مكان آخر.
الضغط Load بـ wrk مرتين
الأمر wrk -t2 -c50 -d15s على GET /products، أي خيطان وخمسون اتصالاً مفتوحاً لمدة 15 ثانية، وكل طلب يقرأ الجدول من PostgreSQL، وشغلناه مرتين متتاليتين:
idle memory: 29.8MiB / 512MiB
--- wrk run 1
Latency 70.04ms 81.45ms 818.44ms 93.74%
Req/Sec 451.98 266.95 1.65k 76.76%
50% 51.76ms
99% 522.25ms
13200 requests in 15.09s, 4.92MB read
Requests/sec: 874.77
--- wrk run 2
Latency 50.55ms 34.19ms 477.21ms 78.34%
Req/Sec 529.94 216.30 1.39k 72.22%
50% 44.96ms
99% 171.29ms
15758 requests in 15.10s, 5.88MB read
Requests/sec: 1043.70
memory after load: 62.43MiB / 512MiBفي المخرج أعلاه لاحظ التالي:
- الذاكرة والخدمة خاملة 29.8 ميجابايت، وبعد جولتين من الضغط 62.4، أي أقل من ثمن الحد. وهذا رقم منخفض لخدمة .NET، وسببه DATAS التي رأيناها في مقال الأنواع، فهي تجعل Server GC يحجز بحسب الحاجة.
- الجولة الأولى 875 طلباً في الثانية والثانية 1,044، والفرق بينهما تسخين: في الأولى ما زال الـ JIT يعيد ترجمة الدوال الساخنة بالمستوى الثاني، ومجمع الاتصالات يفتح اتصالاته.
- الوسيط p50 في الجولة الثانية 45 ملي ثانية، والـ p99 171، والفرق الكبير بين الجولتين في p99، من 522 إلى 171، هو أوضح أثر للتسخين ولضجيج الجهاز معاً.
ماذا يحدث مع Workstation GC؟
ASP.NET Core يستعمل Server GC افتراضياً، والسؤال الذي يطرح دائماً في Containers صغيرة: هل Workstation GC أفضل لحد 512 ميجابايت؟ فأعدنا نفس القياس بالمتغير DOTNET_gcServer=0:
=== 7. what if: Workstation GC (DOTNET_gcServer=0)
idle memory: 26.15MiB / 512MiB
--- wrk run 1
50% 59.06ms
99% 522.53ms
Requests/sec: 747.05
--- wrk run 2
50% 39.25ms
99% 238.35ms
Requests/sec: 1091.42
memory after load: 54.64MiB / 512MiBالذاكرة أقل قليلاً، 26 خاملة و55 بعد الضغط، والإنتاجية في الجولة الثانية 1,091 مقابل 1,044، والـ p99 أعلى، 238 مقابل 171. وهذه فروق أصغر من الفرق بين جولتين لنفس الإعداد، فلا يمكن أن نقول منها إن أحدهما أفضل. والنتيجة العملية: مع DATAS في .NET 9 وما بعده لم يعد Server GC يحجز الذاكرة الكبيرة التي جعلت الناس يغيرونه في الـ Containers، فاتركه على الافتراضي، ولا تغيره إلا إذا قال قياس على جهاز هادئ وبضغط يشبه الإنتاج غير ذلك. والصورة التالية تجمع القياسات:

البدء البارد Cold Start: والمشكلة في طريقة القياس
البدء البارد هو الزمن من تشغيل الـ Container إلى أول رد 200، وهو مهم عندما تزيد النسخ تحت الضغط أو تعيد التشغيل بعد تحديث. وكانت الطريقة الأولى في السكربت أن نشغل الـ Container وننشر منفذه على الجهاز، ثم نسأل من الجهاز نفسه بـ curl كل 20 ملي ثانية:
=== 4. cold start, three times
cold start to first 200: 6770 ms
cold start to first 200: 7305 ms
cold start to first 200: 6333 ms
...
=== 6. measurements (Server GC, the ASP.NET Core default)
cold start to first 200: 28414 msثلاث قراءات بين 6.3 و7.3 ثانية، ثم في الخطوة السادسة من نفس السكربت 28 ثانية، وفي الخطوة السابعة 32. وهذا القفز لا يمكن أن يكون الخدمة نفسها، فهي نفس الـ Image ونفس قاعدة البيانات، وإنما الطريق بين الجهاز والـ Container: على Docker Desktop يمر المنفذ المنشور عبر وسيط Proxy بين Windows والآلة الافتراضية التي تشغل Containers، وكان الجهاز تحت ضغط متزايد من تجارب أخرى. فغيرنا طريقة القياس في السكربت lab/cold-start.sh: Container صغير على نفس شبكة Docker يسأل الخدمة كل 10 ملي ثانية ويسجل وقت أول 200، ونطرح منه وقت بدء العملية كما يسجله Docker في State.StartedAt، والوقتان من ساعة نفس الآلة الافتراضية، فلا يدخل الوسيط في القياس. وشغلناه خمس مرات لكل إعداد:
=== image slc-cs-store, DOTNET_gcServer=1
run 1: process start to first 200: 5168 ms
run 2: process start to first 200: 6010 ms
run 3: process start to first 200: 5813 ms
run 4: process start to first 200: 5470 ms
run 5: process start to first 200: 4200 ms
=== image slc-cs-store, DOTNET_gcServer=0
run 1: process start to first 200: 4866 ms
...
run 5: process start to first 200: 4554 ms
=== image slc-cs-store:r2r, DOTNET_gcServer=1
run 1: process start to first 200: 7221 ms
run 2: process start to first 200: 3985 ms
...والوسيط مع الإعداد الافتراضي 5.5 ثانية، وهو رقم كبير لخدمة .NET، فأردنا أن نعرف أين تذهب هذه الثواني، وكتبنا lab/cold-start-diag.sh الذي يشغل الخدمة مع وقت دقيق في كل سطر من سجلاتها:
container started: 09:11:05.986
09:11:09.422 info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://[::]:8080
09:11:09.429 info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
first 200 (poller): 09:11:11.217
second request: 59 msمن بدء العملية إلى «Now listening» 3.44 ثانية، وفيها تشغيل الـ Runtime، وبناء حاوية حقن الاعتماديات، وأول اتصال بقاعدة البيانات لتنفيذ ملف الجداول. ثم من «Now listening» إلى أول 200 قرابة 1.8 ثانية، وهي ترجمة دوال المسار والـ JSON وNpgsql بالـ JIT عند أول استدعاء، وفتح أول اتصال في المجمع للطلب. والطلب الثاني مباشرة بعدها أخذ 59 ملي ثانية. وكلا الجزأين يعتمد على المعالج، وعلى نواتين مشتركتين مع جهاز مشغول يتضاعف. ولهذا السبب نفسه ترى في الخدمات الحقيقية طلب تسخين يرسله خط النشر إلى كل نسخة جديدة قبل أن يضيفها الموزع، فيدفع هو ثمن الطلب الأول بدل أول عميل.

ماذا يحدث مع ReadyToRun؟
الإجابة المعتادة في .NET على بطء البدء بسبب الـ JIT هي ReadyToRun، أي أن ينشر الكود مترجماً مسبقاً للمعالج بجانب الـ IL، فلا يحتاج الـ JIT عند أول استدعاء، ثم يعيد الـ JIT ترجمة الدوال الساخنة لاحقاً بتحسين أفضل. فبنينا Image ثانية بالملف Dockerfile.r2r، وهو نفس الملف مع -r linux-x64 -p:PublishReadyToRun=true. وأول بناء فشل، لأننا أضفنا الخيار إلى publish فقط، وpublish --no-restore يحتاج أن تكون الاستعادة قد حملت حزمة المترجم المسبق Crossgen، وهي لا تحمل إلا إذا مر الخيار نفسه على restore أيضاً، فأضفناه هناك. والنتيجة: Image بحجم 347.6 ميجابايت، أي أن الخدمة صارت قرابة 6 ميجابايت بدل 2.3، والبدء البارد في خمسة تشغيلات بين 3.8 و7.2 ثانية، والوسيط 5.8.
والقراءة الأمينة لهذه الأرقام أن الفرق بين أسرع تشغيل وأبطأ تشغيل لنفس الإعداد، أكثر من ثلاث ثوان، أكبر من أي أثر لـ ReadyToRun أو لنوع الـ GC، فلا يمكن أن نقول من هذه الجلسة إن ReadyToRun حسن البدء أو لم يحسنه. وما يمكن قوله أن أكثر من نصف الزمن يذهب قبل أن تستمع الخدمة، في تشغيل الـ Runtime والاتصال الأول بقاعدة البيانات، وهذا لا يغيره ReadyToRun كثيراً، وأن على جهاز مشغول كهذا تحتاج إعادة القياس على سيرفر هادئ قبل أي قرار. وإذا كان البدء البارد حاسماً عندك، كدوال Serverless تبدأ مع كل طلب، فالطريق الحقيقي هو Native AOT، ورأينا في المقال الأول أنه بدأ برنامجاً صغيراً في أجزاء من مئة من الثانية، مع ثمنه في توافق المكتبات.
ماذا ينقص هذه الخدمة قبل الإنتاج؟ Production Readiness
الخدمة التي بنيناها صحيحة ومختبرة، ولكنها مكتوبة لتقارن بين اللغات، فينقصها ما تضيفه أي منظمة قبل أن تضعها أمام العملاء، وكل بند منها سطر أو سطران في ASP.NET Core، والمهم أن تعرف لماذا يوجد:
- فحص الصحة Health Checks: موزع الطلبات وKubernetes يحتاجان مساراً يسألانه «هل أنت جاهز؟»، وفحوص الصحة في ASP.NET Core تعطيك
AddHealthChecksوMapHealthChecks("/healthz")، مع فحص يتصل بقاعدة البيانات. ولاحظ من قياس البدء البارد أن الخدمة لا تستمع إلا بعد 3.4 ثانية تقريباً، فالفحص يمنع الموزع من إرسال طلبات إليها قبل ذلك. - حجم مجمع الاتصالات مقابل حد قاعدة البيانات: المجمع حده الافتراضي مئة اتصال لكل نسخة، وPostgreSQL حده الافتراضي
max_connections = 100للسيرفر كله كما في إعدادات الاتصال، فأربع نسخ من الخدمة تحت الضغط قد تطلب أربعمئة اتصال من سيرفر يقبل مئة. لذلك تضعMaximum Pool Sizeفي نص الاتصال بقيمة تناسب عدد النسخ، أو تضع PgBouncer أمام قاعدة البيانات. - تكرار الطلب Idempotency: إذا انقطع الاتصال بعد أن ثبتت المعاملة وقبل أن يصل الرد، فسوف يعيد التطبيق إرسال الطلب، فيصير عند العميل طلبان. والحل المعتاد مفتاح فريد يرسله العميل في رأس مثل
Idempotency-Key، وعمود في جدول الطلبات عليه قيدUNIQUE، فيرجع الطلب الثاني نفس نتيجة الأول بدل أن ينشئ طلباً جديداً. - الأسرار Secrets: نص الاتصال في المختبر فيه كلمة المرور في متغير بيئة، وفي الإنتاج يأتي من مخزن أسرار، وASP.NET Core يقرأ الإعداد من أي مصدر بنفس الاسم دون تغيير في الكود، كما تشرح صفحة الأسرار.
- المراقبة Observability: السجلات وحدها لا تكفي لمعرفة لماذا صار p99 ثانية، فتضيف OpenTelemetry الذي تدعمه .NET مباشرة كما في صفحة المراقبة، فيصلك لكل طلب زمن كل استعلام، ومعه مقاييس مجمع الخيوط التي ذكرناها في مقال التزامن.
- الإيقاف الهادئ Graceful Shutdown: عند التحديث يرسل Docker الإشارة SIGTERM، والمضيف في .NET يتوقف عن قبول طلبات جديدة وينتظر الجارية حتى مهلة محددة، كما في صفحة المضيف، فلا تنقطع معاملة في منتصفها. ولهذا أيضاً تمرر
CancellationTokenالطلب إلى استعلامات قاعدة البيانات في الخدمات الأكبر.
ولم نضف هذه البنود إلى الكود لأنها نفسها في كل لغة تقريباً، والهدف من الخدمة أن تبقى قصيرة بما يكفي لتقارن بين اللغات سطراً بسطر، ولكن لا تنشر خدمة طلبات حقيقية بدونها.
أخطاء شائعة Common Mistakes في خدمات ASP.NET Core
- فتح اتصال جديد بقاعدة البيانات لكل طلب بـ
new NpgsqlConnectionمع إعدادات مختلفة: فيتفرق المجمع، والحلNpgsqlDataSourceواحد من حقن الاعتماديات. - الفحص في الكود دون قفل أو قيد: عشرون دلة من عشر، أو كما رأينا بدون القفل: البيانات سليمة بفضل القيد، ولكن عشرة أخطاء 500.
- الأقفال بترتيب مختلف بين طلبين: قفل مميت في قاعدة البيانات، يرمي فيه PostgreSQL أحد الطلبين بخطأ 40P01 بعد ثانية.
- استثناء لكل حالة متوقعة: «المخزون لا يكفي» ليس استثناء، وتحويله إلى 409 من نتيجة مغلقة أوضح وأرخص.
- اختبار التكامل على قاعدة بيانات في الذاكرة: لا يختبر القفل ولا القيود ولا المعاملات، وهي أهم ما في الخدمة.
- معالج استثناءات يحول كل شيء إلى 500: كما رأينا مع
BadHttpRequestException. - إنشاء الجداول من الخدمة في الإنتاج: عشر نسخ تبدأ معاً وتحاول الترحيل في نفس اللحظة، والصحيح خطوة ترحيل في خط النشر.
- Image الـ SDK في الإنتاج: قرابة جيجابايت إضافي ومترجم لا حاجة له، والحل البناء بمرحلتين.
- الخدمة بمستخدم root: وImages .NET تعطيك
APP_UIDجاهزاً. - قياس الأداء عبر المنفذ المنشور على Docker Desktop: رأينا البدء البارد نفسه يقاس بين 6 و32 ثانية، والقياس داخل شبكة Docker أدق.
- تغيير نوع الـ GC أو إعدادات الـ Runtime دون قياس: الفروق في تجربتنا كانت أصغر من الضجيج.
قائمة تحقق Checklist قبل الإنتاج
| البند | في هذه الخدمة |
|---|---|
المبالغ أعداد صحيحة بأصغر وحدة، مع checked | Pricing.cs |
كل تعديل على المخزون داخل معاملة مع FOR UPDATE مرتبة | OrderService.PlaceAsync |
| قيود في الجداول ضد القيم المستحيلة | CHECK (stock >= 0) وغيرها |
| تحقق من المدخلات، و400 لكل مدخل خاطئ حتى JSON المكسور | Validate وStatusCodeSelector |
أخطاء بصيغة ProblemDetails مع trace_id | AddProblemDetails |
| اختبارات تكامل على PostgreSQL حقيقي، واختبار تزامن | 19 اختباراً |
| الثقافة ثابتة في الخدمة | InvariantGlobalization |
| Image بمرحلتين، ومستخدم غير root | 344 MB، user: 1654 |
| الترحيل خطوة مستقلة في النشر | هنا عند البدء لأنه مختبر فقط |
| حد ذاكرة ونواتين في الـ Container، ومراقبة p99 وطابور مجمع الخيوط | أقل من 65 MiB تحت الضغط |
الخلاصة
- الخدمة ثلاثة مسارات بـ ASP.NET Core Minimal APIs وNpgsql مباشرة، لأن قلبها جملة
FOR UPDATEلا تعبر عنها LINQ، وكل SQL مرئي في ملف واحد. - الطلب معاملة واحدة: تجميع المنتج المكرر، وقفل الصفوف بترتيب المعرف، والفحص، والكتابة في دفعات، والنتيجة نوع مغلق يتحول إلى 201 أو 400 أو 409.
- تسعة عشر اختباراً نجحت على PostgreSQL 17 حقيقي، ومنها عشرون طلباً متوازياً على مخزون عشرة أعطت عشرة 201 وعشرة 409 في كل تشغيل.
- ظهرت مشكلتان قبل ذلك:
dotnet testمع xUnit v3 يحتاج سطراً فيglobal.jsonعلى .NET 10، وJSON الخاطئ رجع 500 حتى أضفناStatusCodeSelector. - بدون
FOR UPDATEحمى القيدCHECKالمخزون، ولكن عشرة عملاء رأوا 500 بدل 409. - قبل الإنتاج ينقصها فحص الصحة، وحجم مجمع يناسب حد اتصالات PostgreSQL وعدد النسخ، ومفتاح لتكرار الطلب، والأسرار من مخزن خاص، وOpenTelemetry، والإيقاف الهادئ، وكلها سطور قليلة في ASP.NET Core ولكنها لا تترك.
- الـ Image 344 ميجابايت منها 2.3 للخدمة، والذاكرة 30 ميجابايت خاملة و62 بعد الضغط، و
wrkأعطى 875 ثم 1,044 طلباً في الثانية على جهاز مشغول جداً. - البدء البارد قرابة 5.5 ثانية داخل شبكة Docker، أغلبها قبل أن تستمع الخدمة، وWorkstation GC وReadyToRun لم يغيرا شيئاً يتجاوز ضجيج الجهاز، والقياس عبر منفذ Docker Desktop كان مضللاً.
وفي المقال الأخير من مرحلة لغة C# للمطورين نجمع الأخطاء الشائعة التي يحملها القادمون من لغات أخرى، وأسلوب C# الحديث، وخطة قراءة من المصادر الرسمية. وهذه مقالات تكمل هذا المقال:
دليلالتزامن في C#: async/await والمهام Tasks بالأرقامقسنا نموذج التزامن في .NET بمئة طلب HTTP ومهمة SHA-256 على نواة ونواتين وأربع، ورأينا .Result يجمد مجمع الخيوط 23 ثانية، ثم سباق بيانات وقفلاً مميتاً وإصلاح كل منهما.
دليلالأنواع والذاكرة والاستثناءات Exceptions في C#قسنا ما تكلفه أنواع القيمة والمرجع وجامع القمامة داخل Container محدود، ثم شغلنا كل خطأ شائع حتى يسقط، وكشفنا أن double يخطئ في ربع مبالغ الضريبة الحدية.
دليلالأقفال Locks والجمود Deadlock في قواعد البياناتلماذا يوقف أمر ALTER TABLE صغير كل الاستعلامات خلفه، وكيف تقرأ pg_locks وتتجنب الجمود وظواهر العزل في PostgreSQL.
دليلPostgreSQL لكل شيء: متى يكفيك ومتى تحتاج غيره؟كم عملاً يستطيع PostgreSQL واحد أن يقوم به في شركتك الناشئة قبل أن تحتاج Redis أو Elasticsearch، وما الإشارة التي تقول لك إن الوقت قد حان.
دليلأنواع الاختبارات للمبتدئين: من Unit إلى E2E مع Playwrightما الذي يكشفه كل نوع من الاختبارات وما الذي يفوته، وكم اختباراً تكتب من كل نوع، وأين يعمل كل منها في خط CI.
دليلكيف تختار لغة البرمجة Programming Language لمشروعك؟نختار لغة خدمة حقيقية بأسئلة الفريق والتوظيف والمكتبات والرخصة، ثم نكتبها بأربع لغات ونقيس البناء والحجم والذاكرة والطلبات في الثانية بأنفسنا.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة بـ .NET SDK 10.0.403 وASP.NET Core 10.0.14 وNpgsql 10.0.3 وxUnit v3 4.0.2 وPostgreSQL 17.11، كل Container بنواتين و512 MB.