> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# خدمة حقيقية بـ C#: ‏ASP.NET Core مع PostgreSQL والاختبارات
- URL: https://arabroot.io/articles/خدمة-حقيقية-ب-csharp-asp-net-core-و-postgresql/
- Published: 2026-10-31T08:46:49.000Z
- Updated: 2026-10-31T09:46:56.000Z
- Description: بنينا واجهة طلبات بـ ASP.NET Core وNpgsql بقفل الصفوف وضريبة بالهللات، واختبرناها بعشرين طلباً متوازياً على PostgreSQL حقيقي، ثم قسنا الـ Image والبدء البارد والذاكرة وwrk بأمانة.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, لغات البرمجة عملياً

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

وهذا هو المقال الرابع في مرحلة [لغة C# للمطورين](https://arabroot.io/start/#stage-lang-csharp)، بعد [الأدوات والأساسيات](https://arabroot.io/articles/csharp-%D9%84%D9%85%D9%86-%D9%8A%D8%B9%D8%B1%D9%81-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D8%A7%D9%84%D8%A7%D8%AF%D9%88%D8%A7%D8%AA-%D9%88%D8%A7%D9%84%D8%A7%D8%B3%D8%A7%D8%B3%D9%8A%D8%A7%D8%AA/) و[الأنواع والذاكرة](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D9%88%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%88%D8%A7%D9%84%D8%A7%D8%B3%D8%AA%D8%AB%D9%86%D8%A7%D8%A1%D8%A7%D8%AA-%D9%81%D9%8A-csharp/) و[التزامن](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B2%D8%A7%D9%85%D9%86-%D9%81%D9%8A-csharp-async-await-%D9%88%D8%A7%D9%84%D9%85%D9%87%D8%A7%D9%85-tasks/)، وفيه نبني هذه الخدمة بـ ASP.NET Core وPostgreSQL 17\. وهي نفس الخدمة بنفس المواصفة بالضبط في كل لغات هذا المسار، بنفس الجداول والمسارات والاختبارات والقياسات، حتى تقارن بين اللغات على شيء حقيقي، وفكرة المقارنة نفسها شرحناها في مقال [كيف تختار لغة البرمجة لمشروعك](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AE%D8%AA%D8%A7%D8%B1-%D9%84%D8%BA%D8%A9-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%84%D9%85%D8%B4%D8%B1%D9%88%D8%B9%D9%83/). والكود كاملاً في مجلد `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 بهذا الشكل بالضبط:

```sql
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% بحسب [هيئة الزكاة والضريبة والجمارك](https://zatca.gov.sa/en/RulesRegulations/Taxes/Pages/default.aspx?ref=arabroot.io) بالأعداد الصحيحة مع التقريب نصف للأعلى، ويرجع 409 إذا لم يكف المخزون، و400 إذا كان المدخل خاطئاً. و`GET /orders/{id}` يرجع الطلب بسطوره. والمثال المرجعي الذي يختبر في كل لغة: ثلاث قهوات بـ 9.90 ريال، المجموع 29.70، والضريبة 4.455 تقرب إلى 4.46، والإجمالي 34.16 ريال.

![أربعة صناديق من اليمين إلى اليسار: العميل يرسل JSON بأسماء snake_case، ثم ASP.NET Core وKestrel بالمسارات GET /products وPOST /orders وGET /orders/{id} مع التحقق وProblemDetails، ثم OrderService بمعاملة واحدة لكل طلب تقفل الصفوف وتفحص وتنقص وتدخل وتحسب الضريبة، ثم PostgreSQL 17 بجداول products وorders وorder_items عبر NpgsqlDataSource، وتحتها ثلاث بطاقات: Image واحدة مبنية بالـ SDK وتعمل فوق aspnet:10.0 بمستخدم غير root، وبدون ORM، ونواتان و512 MB لكل Container](https://arabroot.io/content/images/2026/10/csharp-real-service-01-architecture.webp)

ثلاثة مسارات، وخدمة واحدة تحمل منطق الطلب، وقاعدة بيانات تحمي نفسها بالقيود

## اختيار الأدوات Stack: ‏Minimal APIs مع Npgsql

الإطار في .NET لا خلاف عليه تقريباً، فهو [ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/?ref=arabroot.io)، ولكن داخله طريقتان لكتابة المسارات: المتحكمات Controllers، وهي كلاسات بدوال وخصائص Attributes على طريقة MVC، وكانت الطريقة الوحيدة لسنوات، و[الـ Minimal APIs](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/overview?ref=arabroot.io) التي جاءت مع .NET 6، وتعرف فيها كل مسار بسطر `app.MapGet` ودالة. وللقادم من Express أو FastAPI أو Flask فالثانية مألوفة جداً، ولخدمة بثلاثة مسارات هي الأوضح، ولذلك اخترناها. وفي خدمة بخمسين مسار يقسم الناس المسارات إلى ملفات بمجموعات `MapGroup`، أو يرجعون إلى المتحكمات، والفرق في الأداء بينهما صغير.

أما الوصول إلى قاعدة البيانات فهو القرار الأهم، وفيه ثلاثة خيارات معروفة:

- [**Entity Framework Core**](https://learn.microsoft.com/en-us/ef/core/?ref=arabroot.io)**:** الـ ORM الرسمي من Microsoft والأكثر انتشاراً في مشاريع .NET، يكتب SQL بدلاً عنك من استعلامات LINQ، ويتابع التغييرات على الكائنات ويحفظها، ويدير الترحيل Migrations. وهو اختيار ممتاز لتطبيقات فيها عشرات الجداول وعمليات CRUD كثيرة.
- [**Dapper**](https://github.com/DapperLib/Dapper?ref=arabroot.io)**:** مكتبة صغيرة تكتب فيها SQL بنفسك، وهي تحول الصفوف إلى كائنات، وكانت لسنوات الاختيار المعتاد لمن يريد الأداء والتحكم.
- [**Npgsql**](https://www.npgsql.org/doc/basic-usage.html?ref=arabroot.io) **مباشرة:** وهو مشغل 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

الحل فيه مشروعان فقط، الخدمة والاختبارات، وكل ما حولهما ملفات تشغيل:

```text
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
```

وملف مشروع الخدمة قصير، وكل سطر فيه له سبب:

```xml
<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` هو الخدمة كلها ما عدا منطق الطلب، وهذا أوله:

```csharp
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، وحده الأقصى الافتراضي مئة اتصال كما في [معاملات الاتصال](https://www.npgsql.org/doc/connection-string-parameters.html?ref=arabroot.io)، فكل طلب يستعير اتصالاً ويرجعه عند `Dispose`، ولا يفتح اتصالاً جديداً بقاعدة البيانات.
- `OrderService` مسجل Singleton، أي نسخة واحدة للخدمة كلها، وهذا آمن لأنه لا يحمل أي حالة غير الـ DataSource الذي هو نفسه آمن للاستعمال من عدة خيوط.
- [SnakeCaseLower](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonnamingpolicy.snakecaselower?ref=arabroot.io) يحول `PriceHalalas` إلى `price_halalas`، لأن المواصفة المشتركة بين اللغات تستعمل هذه الأسماء.
- المرمز Encoder: الإعداد الافتراضي في `System.Text.Json` يهرب كل حرف خارج نطاق ASCII كما تشرح [صفحة ترميز الأحرف](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/character-encoding?ref=arabroot.io)، فيكتب «قهوة» بالشكل `قهوة`، وهو JSON صحيح يفهمه أي عميل، ولكنه أطول ولا يقرؤه إنسان في السجلات. والسطر أعلاه يسمح بكل نطاقات Unicode فتكتب العربية كما هي.
- `AddProblemDetails` يجعل كل خطأ يرجع بصيغة ProblemDetails المعيارية، وهي JSON فيه `type` و`title` و`status` و`detail`، وسوف نراها في التجربة. و`StatusCodeSelector` له قصة في قسم الاختبارات.

ثم ينشئ البرنامج الجداول قبل أول طلب، ويعرف المسارات:

```csharp
// 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 يعرف مصدر كل معامل من نوعه ومكانه كما تشرح [صفحة ربط المعاملات](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding?ref=arabroot.io)، والقيد `{id:int}` يجعل `/orders/abc` يرجع 404 دون أن يصل للدالة. وإنشاء الجداول عند البدء مناسب لهذا المختبر لأن الملف لا يضر إذا تكرر، أما في الإنتاج فالفريق يفصل الترحيل Migration في خطوة نشر مستقلة، لأن عشر نسخ من الخدمة تبدأ معاً لا يجب أن تحاول كلها تعديل الجداول في نفس اللحظة.

## قلب الخدمة: الطلب في معاملة واحدة Transaction

الآن `OrderService.PlaceAsync`، وهو الجزء الذي يجب أن يكون صحيحاً تحت الضغط، لأنه هنا بالضبط باعت الخدمة القديمة عشرين دلة من عشر:

```csharp
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](https://www.postgresql.org/docs/17/explicit-locking.html?ref=arabroot.io)، فأي طلب آخر على نفس المنتج ينتظر عند هذا السطر حتى نثبت أو نرجع، ثم يقرأ المخزون الجديد. وهذا بالضبط حل مشكلة «فحص ثم تنفيذ» التي رأيناها في مقال التزامن، ولكن على مستوى قاعدة البيانات، فهو يعمل مع أي عدد من نسخ الخدمة.
- `ORDER BY id` مع القفل ليس للترتيب في النتيجة، وإنما ليأخذ كل طلب الأقفال بنفس الترتيب دائماً، فطلب فيه القهوة والتمر وطلب فيه التمر والقهوة لا يمكن أن يمسك كل منهما صفاً وينتظر الآخر، وهذا نفس إصلاح القفل المميت في مقال التزامن. وشرحنا الأقفال والجمود في قواعد البيانات بالتفصيل في مقال [الأقفال والجمود](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%82%D9%81%D8%A7%D9%84-locks-%D9%88%D8%A7%D9%84%D8%AC%D9%85%D9%88%D8%AF-deadlock-%D9%81%D9%8A-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA/).
- النتيجة نوع مغلق من ثلاثة سجلات، وليس استثناء: المنتج غير الموجود والمخزون الذي لا يكفي حالات متوقعة في منطق الأعمال، فترجع كقيمة، والمسار يحولها إلى 400 أو 409 بالتعبير `switch`. والاستثناء يبقى لما لا يتوقعه أحد، مثل انقطاع قاعدة البيانات.
- لا يوجد `Rollback` صريح في الكود، لأن `await using var tx` ترجع المعاملة تلقائياً عند `Dispose` إذا لم تثبت، أي عند أي `return` مبكر أو استثناء، وهذا نفس `using` الذي رأيناه مع الموارد في مقال الأنواع.
- الإنقاص من المخزون كل السطور في `NpgsqlBatch` واحدة، والإدخال في `order_items` كذلك، فكل مجموعة تذهب إلى قاعدة البيانات في طلب شبكة واحد بدل طلب لكل سطر.

![ست خطوات: تجميع المنتج المكرر والتحقق من الكمية وإلا 400، ثم BEGIN باتصال من المجمع، ثم SELECT id, price_halalas, stock FROM products WHERE id = ANY(@ids) ORDER BY id FOR UPDATE، ثم الفحص فالمنتج غير الموجود 400 والمخزون الذي لا يكفي 409 والمعاملة ترجع، ثم UPDATE products وINSERT INTO orders RETURNING id وINSERT INTO order_items، ثم COMMIT و201 Created، وتحتها ثلاث بطاقات عن سبب FOR UPDATE وسبب ORDER BY id وحساب الضريبة 2970 ضرب 15 زائد 50 على 100 يساوي 446](https://arabroot.io/content/images/2026/10/csharp-real-service-02-transaction.webp)

كل طلب معاملة واحدة: القفل والفحص والكتابة معاً، أو لا شيء

### حساب الضريبة VAT بالهللات Integer Math

الحساب نفسه في ملف مستقل صغير، لأنه أكثر جزء يجب أن يختبر بمعزل عن قاعدة البيانات:

```csharp
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 على نفس الشبكة. والمخرج سوف يكون كما يلي:

```text
[{"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` وحده، ومنها المثال المرجعي:

```csharp
[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](https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests?ref=arabroot.io)، وتوجهها إلى قاعدة بيانات اختبار في PostgreSQL 17 حقيقي، وليس قاعدة في الذاكرة ولا محاكاة Mock، لأن القفل والقيود والمعاملات هي بالضبط ما نريد اختباره، ولا توجد محاكاة تتصرف مثل PostgreSQL فيها. وكل اختبار يبدأ بتفريغ الجداول وإعادة البيانات الأولية حتى لا يتأثر بما قبله. وقاعدة البيانات نفسها تأتي من Container يشغله السكربت قبل الاختبارات ويمرر نص الاتصال في متغير البيئة، ويوجد بديل شائع هو مكتبة Testcontainers التي تشغل الـ Container من داخل الاختبار نفسه، وهي مريحة على جهاز المطور، ولكنها تحتاج الوصول إلى Docker من داخل Container الاختبار في CI، فاخترنا الطريقة الأبسط التي تعمل بنفس الشكل في كل مكان. وأهمها اختبار التزامن:

```csharp
[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` كما تعلمنا في مقال التزامن، فتصل كلها إلى الخدمة في نفس الوقت تقريباً، ويتنافس عشرون اتصالاً على نفس الصف. والمخرج الأخير لكل الاختبارات:

```text
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` فشل قبل أن يشغل أي اختبار:

```text
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](https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-with-dotnet-test?ref=arabroot.io) التي يعمل عليها [xUnit v3](https://xunit.net/docs/getting-started/v3/whats-new?ref=arabroot.io) بإعداداته الحالية، والأمر `dotnet test` لا يخلط بينهما، وإنما يطلب منك أن تختار. والحل سطر في ملف `global.json` في جذر المستودع:

```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

بعد إصلاح الأمر شغلت الاختبارات، ففشل اثنان من تسعة عشر:

```text
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](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.routing.routehandleroptions.throwonbadrequest?ref=arabroot.io)، و`WebApplicationFactory` تشغل الخدمة في بيئة Development افتراضياً. والثانية أن `UseExceptionHandler` يحول أي استثناء يصله إلى 500\. فالاستثناء الذي يحمل في داخله الرمز 400 وصل إلى معالج لا يقرأ هذا الرمز.

والحل هو [StatusCodeSelector](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.builder.exceptionhandleroptions.statuscodeselector?ref=arabroot.io) الذي رأيته في `Program.cs`، فهو يسأل كل استثناء عن الرمز المناسب، فإذا كان `BadHttpRequestException` أخذ رمزه هو، وإلا 500\. ولاحظ أن هذه المشكلة كانت سوف تمر لو اختبرنا في الإنتاج فقط، لأن هناك لا يرمى الاستثناء أصلاً، ولكن أي مطور يشغل الخدمة محلياً سوف يرى 500 لطلب خاطئ ويضيع وقته في البحث عن خطأ في السيرفر، ولو أضاف أحد لاحقاً معالجة أخرى ترمي نفس الاستثناء لظهرت المشكلة في الإنتاج أيضاً. والصورة التالية تجمع الاختبارات والمشكلتين:

![ثلاثة صناديق: ثمانية اختبارات وحدة لـ Pricing بلا قاعدة بيانات منها 990 ضرب 3 والتقريب والمبلغ السالب والفيضان، وعشرة اختبارات تكامل بـ WebApplicationFactory مع PostgreSQL 17 حقيقي، واختبار تزامن بعشرين طلباً على مخزون عشرة أعطى عشرة 201 وعشرة 409 والمخزون صفر، وتحتها صندوقان للمشكلتين: خطأ VSTest مع .NET 10 وحله سطر test runner في global.json، وJSON خاطئ أعطى InternalServerError بدل BadRequest وحله StatusCodeSelector](https://arabroot.io/content/images/2026/10/csharp-real-service-03-tests.webp)

تسعة عشر اختباراً على قاعدة بيانات حقيقية، بعد مشكلتين لم يكن أحد يتوقعهما

### ماذا يحدث إذا حذفنا FOR UPDATE؟

قد يتساءل البعض: هل نحتاج القفل فعلاً، ما دامت جملة الإنقاص نفسها `SET stock = stock - $1` ذرية في PostgreSQL؟ والسكربت `lab/run-without-lock.sh` يجيب: ينسخ الكود داخل Container، ويحذف `FOR UPDATE` من الاستعلام فقط، ثم يشغل اختبار التزامن وحده مرتين:

```text
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](https://learn.microsoft.com/en-us/dotnet/core/docker/build-container?ref=arabroot.io):

```dockerfile
# 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](https://mcr.microsoft.com/en-us/artifact/mar/dotnet/aspnet/about?ref=arabroot.io)، وتنسخ المجلد المنشور فقط، فلا يوجد في الـ Image النهائية مترجم ولا كود مصدري ولا حزم.
- `USER $APP_UID` يشغل الخدمة بمستخدم غير root، والمتغير معرف في Images .NET الرسمية، وظهر في المخرج كـ `user: 1654`.
- `ASPNETCORE_HTTP_PORTS=8080` لأن المستخدم غير root لا يستطيع فتح المنافذ تحت 1024.

والمخرج من خطوة البناء في السكربت:

```text
=== 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](https://github.com/dotnet/dotnet-docker/blob/main/documentation/ubuntu-chiseled.md?ref=arabroot.io)، أو [Native AOT](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/native-aot?ref=arabroot.io) الذي رأينا في المقال الأول أنه يحتاج مكتبات تدعمه.

## القياسات Measurements: نواتان مع 512 MB

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

### الضغط Load بـ wrk مرتين

الأمر `wrk -t2 -c50 -d15s` على `GET /products`، أي خيطان وخمسون اتصالاً مفتوحاً لمدة 15 ثانية، وكل طلب يقرأ الجدول من PostgreSQL، وشغلناه مرتين متتاليتين:

```text
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`:

```text
=== 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، فاتركه على الافتراضي، ولا تغيره إلا إذا قال قياس على جهاز هادئ وبضغط يشبه الإنتاج غير ذلك. والصورة التالية تجمع القياسات:

![جدول بعمودين Server GC الافتراضي وWorkstation GC: حجم الـ Image 344 MB منها 341.7 للـ Image الأساسية و2.3 للخدمة، والذاكرة خاملة 29.8 و26.2 MiB، وwrk التشغيل الأول 875 و747 طلباً في الثانية، والتشغيل الثاني 1,044 و1,091، وp50 في التشغيل الثاني 45.0 و39.3 ms، وp99 171 و238 ms، والذاكرة بعد الضغط 62.4 و54.6 MiB، وتحته بطاقتان: الجهاز كان مشغولاً فالأرقام المطلقة أقل، والفرق بين نوعي الـ GC أصغر من الفرق بين تشغيلين](https://arabroot.io/content/images/2026/10/csharp-real-service-04-measurements.webp)

الفرق بين نوعي الـ GC أصغر من ضجيج الجهاز، والذاكرة أقل من 65 MiB

### البدء البارد Cold Start: والمشكلة في طريقة القياس

البدء البارد هو الزمن من تشغيل الـ Container إلى أول رد 200، وهو مهم عندما تزيد النسخ تحت الضغط أو تعيد التشغيل بعد تحديث. وكانت الطريقة الأولى في السكربت أن نشغل الـ Container وننشر منفذه على الجهاز، ثم نسأل من الجهاز نفسه بـ `curl` كل 20 ملي ثانية:

```text
=== 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`، والوقتان من ساعة نفس الآلة الافتراضية، فلا يدخل الوسيط في القياس. وشغلناه خمس مرات لكل إعداد:

```text
=== 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` الذي يشغل الخدمة مع وقت دقيق في كل سطر من سجلاتها:

```text
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 ملي ثانية. وكلا الجزأين يعتمد على المعالج، وعلى نواتين مشتركتين مع جهاز مشغول يتضاعف. ولهذا السبب نفسه ترى في الخدمات الحقيقية طلب تسخين يرسله خط النشر إلى كل نسخة جديدة قبل أن يضيفها الموزع، فيدفع هو ثمن الطلب الأول بدل أول عميل.

![شريط زمني: من بدء العملية إلى Now listening 3.44 ثانية لتشغيل الـ Runtime وحقن الاعتماديات وإنشاء الجداول، ثم 1.79 ثانية لأول طلب بالـ JIT وأول اتصال، وأول 200 عند 5.23 ثانية، والطلب الثاني 59 ms، وتحته جدول لخمسة تشغيلات لكل إعداد: Server GC بأسرع 4,200 ووسيط 5,470 وأبطأ 6,010 ms، وWorkstation GC بـ 4,554 و4,866 و6,566، وReadyToRun بـ 3,759 و5,806 و7,221](https://arabroot.io/content/images/2026/10/csharp-real-service-05-cold-start.webp)

ثلثا البدء البارد قبل أن تستمع الخدمة، والثلث الأخير أول طلب

### ماذا يحدث مع ReadyToRun؟

الإجابة المعتادة في .NET على بطء البدء بسبب الـ JIT هي [ReadyToRun](https://learn.microsoft.com/en-us/dotnet/core/deploying/ready-to-run?ref=arabroot.io)، أي أن ينشر الكود مترجماً مسبقاً للمعالج بجانب الـ 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](https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks?ref=arabroot.io) تعطيك `AddHealthChecks` و`MapHealthChecks("/healthz")`، مع فحص يتصل بقاعدة البيانات. ولاحظ من قياس البدء البارد أن الخدمة لا تستمع إلا بعد 3.4 ثانية تقريباً، فالفحص يمنع الموزع من إرسال طلبات إليها قبل ذلك.
- **حجم مجمع الاتصالات مقابل حد قاعدة البيانات:** المجمع حده الافتراضي مئة اتصال لكل نسخة، وPostgreSQL حده الافتراضي `max_connections = 100` للسيرفر كله كما في [إعدادات الاتصال](https://www.postgresql.org/docs/17/runtime-config-connection.html?ref=arabroot.io)، فأربع نسخ من الخدمة تحت الضغط قد تطلب أربعمئة اتصال من سيرفر يقبل مئة. لذلك تضع `Maximum Pool Size` في نص الاتصال بقيمة تناسب عدد النسخ، أو تضع PgBouncer أمام قاعدة البيانات.
- **تكرار الطلب Idempotency:** إذا انقطع الاتصال بعد أن ثبتت المعاملة وقبل أن يصل الرد، فسوف يعيد التطبيق إرسال الطلب، فيصير عند العميل طلبان. والحل المعتاد مفتاح فريد يرسله العميل في رأس مثل `Idempotency-Key`، وعمود في جدول الطلبات عليه قيد `UNIQUE`، فيرجع الطلب الثاني نفس نتيجة الأول بدل أن ينشئ طلباً جديداً.
- **الأسرار Secrets:** نص الاتصال في المختبر فيه كلمة المرور في متغير بيئة، وفي الإنتاج يأتي من مخزن أسرار، وASP.NET Core يقرأ الإعداد من أي مصدر بنفس الاسم دون تغيير في الكود، كما تشرح [صفحة الأسرار](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets?ref=arabroot.io).
- **المراقبة Observability:** السجلات وحدها لا تكفي لمعرفة لماذا صار p99 ثانية، فتضيف OpenTelemetry الذي تدعمه .NET مباشرة كما في [صفحة المراقبة](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/observability-with-otel?ref=arabroot.io)، فيصلك لكل طلب زمن كل استعلام، ومعه مقاييس مجمع الخيوط التي ذكرناها في مقال التزامن.
- **الإيقاف الهادئ Graceful Shutdown:** عند التحديث يرسل Docker الإشارة SIGTERM، والمضيف في .NET يتوقف عن قبول طلبات جديدة وينتظر الجارية حتى مهلة محددة، كما في [صفحة المضيف](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/generic-host?ref=arabroot.io)، فلا تنقطع معاملة في منتصفها. ولهذا أيضاً تمرر `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# للمطورين](https://arabroot.io/start/#stage-lang-csharp) نجمع الأخطاء الشائعة التي يحملها القادمون من لغات أخرى، وأسلوب C# الحديث، وخطة قراءة من المصادر الرسمية. وهذه مقالات تكمل هذا المقال:

[![](https://arabroot.io/content/images/2026/10/csharp-concurrency-cover-cover.webp)دليلالتزامن في C#: ‏async/await والمهام Tasks بالأرقامقسنا نموذج التزامن في .NET بمئة طلب HTTP ومهمة SHA-256 على نواة ونواتين وأربع، ورأينا ‏.Result يجمد مجمع الخيوط 23 ثانية، ثم سباق بيانات وقفلاً مميتاً وإصلاح كل منهما.](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B2%D8%A7%D9%85%D9%86-%D9%81%D9%8A-csharp-async-await-%D9%88%D8%A7%D9%84%D9%85%D9%87%D8%A7%D9%85-tasks/)[![](https://arabroot.io/content/images/2026/10/csharp-types-memory-cover-cover.webp)دليلالأنواع والذاكرة والاستثناءات Exceptions في C#قسنا ما تكلفه أنواع القيمة والمرجع وجامع القمامة داخل Container محدود، ثم شغلنا كل خطأ شائع حتى يسقط، وكشفنا أن double يخطئ في ربع مبالغ الضريبة الحدية.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D9%88%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%88%D8%A7%D9%84%D8%A7%D8%B3%D8%AA%D8%AB%D9%86%D8%A7%D8%A1%D8%A7%D8%AA-%D9%81%D9%8A-csharp/)[![](https://arabroot.io/content/images/2026/10/db-locking-cover-cover.webp)دليلالأقفال Locks والجمود Deadlock في قواعد البياناتلماذا يوقف أمر ALTER TABLE صغير كل الاستعلامات خلفه، وكيف تقرأ pg\_locks وتتجنب الجمود وظواهر العزل في PostgreSQL.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%82%D9%81%D8%A7%D9%84-locks-%D9%88%D8%A7%D9%84%D8%AC%D9%85%D9%88%D8%AF-deadlock-%D9%81%D9%8A-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA/)[![](https://arabroot.io/content/images/2026/10/postgres-for-everything-cover-cover.webp)دليلPostgreSQL لكل شيء: متى يكفيك ومتى تحتاج غيره؟كم عملاً يستطيع PostgreSQL واحد أن يقوم به في شركتك الناشئة قبل أن تحتاج Redis أو Elasticsearch، وما الإشارة التي تقول لك إن الوقت قد حان.](https://arabroot.io/articles/postgresql-%D9%84%D9%83%D9%84-%D8%B4%D9%8A%D8%A1-%D9%85%D8%AA%D9%89-%D8%AA%D9%83%D9%81%D9%8A%D9%83-%D9%82%D8%A7%D8%B9%D8%AF%D8%A9-%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D9%88%D8%A7%D8%AD%D8%AF%D8%A9/)[![](https://arabroot.io/content/images/2026/10/testing-types-cover-cover.webp)دليلأنواع الاختبارات للمبتدئين: من Unit إلى E2E مع Playwrightما الذي يكشفه كل نوع من الاختبارات وما الذي يفوته، وكم اختباراً تكتب من كل نوع، وأين يعمل كل منها في خط CI.](https://arabroot.io/articles/%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1%D8%A7%D8%AA-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-unit-%D9%88-integration-%D9%88-e2e/)[![](https://arabroot.io/content/images/2026/10/choose-language-cover-cover.webp)دليلكيف تختار لغة البرمجة Programming Language لمشروعك؟نختار لغة خدمة حقيقية بأسئلة الفريق والتوظيف والمكتبات والرخصة، ثم نكتبها بأربع لغات ونقيس البناء والحجم والذاكرة والطلبات في الثانية بأنفسنا.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AE%D8%AA%D8%A7%D8%B1-%D9%84%D8%BA%D8%A9-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%84%D9%85%D8%B4%D8%B1%D9%88%D8%B9%D9%83/)

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

- أكتوبر 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.