الشركة الناشئة التي تبيع القهوة والتمور والهيل في مقالات هذه المرحلة تحتاج الآن خدمتها الحقيقية: واجهة API صغيرة يطلب منها الموقع قائمة المنتجات، ويرسل إليها الطلب، ويسأل عنه بعد ذلك. والمواصفات قصيرة، ولكن كل سطر فيها يخفي مشكلة رأيناها في المقالات السابقة: المبالغ يجب أن تكون بالهللات لأن 9.90 * 3 أعطى 29.700000000000003، والمخزون يجب ألا يباع مرتين لأن عشرين طلباً نجحت من مخزون عشرة عندما كان الفحص في الذاكرة، والمدخلات يجب أن تفحص عند الحدود لأن Number('') يساوي صفراً، والأخطاء يجب أن تتحول إلى رموز HTTP صحيحة بدلاً من أن تسقط العملية. وفي هذا المقال سوف نبني هذه الخدمة بالطريقة المعتادة في Node.js اليوم: إطار Fastify مع مكتبة pg وPostgreSQL 17، بـ TypeScript، مع اختبار وحدة واختبار تكامل على قاعدة بيانات حقيقية، وDockerfile متعدد المراحل، ثم نقيسها.
وهذا المقال من مرحلة JavaScript وTypeScript في مسار لغات البرمجة عملياً، والخدمة نفسها بنفس الجداول ونفس الاختبارات ونفس طريقة القياس تبنى في كل لغات المسار، حتى تقارن بين اللغات على شيء حقيقي وليس على مثال «Hello World»، وهي نفس فكرة خدمة الأسعار التي ناقشناها في مقال كيف تختار لغة البرمجة لمشروعك. والكود كاملاً في مجلد lab مع المقال، ومعه سكربتا القياس measure.sh وcoldstart.sh وكل المخرجات الأصلية، وسوف نعرض هنا الأجزاء المهمة ونشرح لماذا كتبت بهذا الشكل، والأخطاء التي واجهناها أثناء البناء.
وسوف نناقش في هذا المقال ما يلي:
- المواصفات المشتركة: الجداول، والنقاط الثلاث، وقواعد الضريبة والأخطاء.
- لماذا Fastify، وكيف يبدو المشروع: الملفات، و
package.json، وملفاtsconfigللفحص والبناء. - دالة المبالغ بالهللات واختبار الوحدة لها، بما في ذلك 9.90 في 3.
- معاملة واحدة لكل طلب: قفل الصفوف بـ
FOR UPDATEبترتيب ثابت، وفحص المخزون، والطرح، والإدخال. - التحقق من المدخلات بـ JSON Schema، والإعدادات الافتراضية في Fastify التي يجب أن تعرفها، ومشكلة واجهناها مع معاملات المسار.
- اختبار التكامل على PostgreSQL حقيقي، واختبار عشرين طلباً متوازياً، وماذا يحدث بدون القفل.
- Dockerfile متعدد المراحل، ثم القياس: حجم الـ Image، والبدء، والذاكرة، وwrk مرتين.
المواصفات المشتركة Specification: نفس الخدمة في كل لغة
الخدمة لها ثلاث نقاط فقط، وثلاثة جداول في 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 NOT NULL REFERENCES orders(id),
product_id int NOT NULL REFERENCES products(id),
qty int NOT NULL CHECK (qty > 0),
price_halalas int NOT NULL
);
INSERT INTO products (name_ar, price_halalas, stock) VALUES
('قهوة عربية مختصة 250 غرام', 4500, 100),
('تمر سكري 1 كيلو', 3800, 100),
('هيل حب 100 غرام', 990, 10);ولاحظ أن قاعدة البيانات نفسها تحمي بعض القواعد بقيود CHECK: لا يوجد سعر سالب، ولا مخزون سالب، ولا كمية صفر. فحتى لو أخطأ كود التطبيق فلن يستطيع أن يكتب مخزوناً بقيمة 1-، وهذا خط الدفاع الأخير وليس الأول. وفي البند order_items نخزن سعر المنتج وقت الطلب، لأن سعر المنتج قد يتغير غداً، والفاتورة يجب أن تبقى كما كانت. والنقاط الثلاث كما يلي:
GET /products: قائمة المنتجات بصيغة JSON.POST /ordersبجسم{"items": [{"product_id": 3, "qty": 2}]}: في معاملة واحدة تقفل صفوف المنتجات، وتفحص المخزون، وتطرحه، وتدخل الطلب وبنوده، وتحسب المجموع مع ضريبة القيمة المضافة 15% بالهللات كأعداد صحيحة مع تقريب النصف إلى الأعلى. والرد 409 عند نقص المخزون، و400 عند المدخلات الخاطئة.GET /orders/{id}: الطلب مع بنوده، و404 إذا لم يوجد.

لماذا Fastify؟ الإطار Framework والبدائل
أشهر إطار للسيرفرات في Node.js تاريخياً هو Express، وما زال موجوداً في أغلب المشاريع القديمة والشروحات، وتوجد أطر أكبر مثل NestJS التي تبني فوق Express أو Fastify طبقة كاملة من الوحدات وحقن الاعتماديات تشبه Spring في Java، وأطر أحدث مثل Hono التي تعمل على Node.js وعلى بيئات أخرى. واخترنا Fastify لهذه الخدمة لثلاثة أسباب عملية: التحقق من المدخلات بـ JSON Schema مدمج في الإطار نفسه بدلاً من مكتبة إضافية، وهذا بالضبط ما تحتاجه خدمة تستقبل طلبات مالية، ومعالجة الأخطاء في الدوال غير المتزامنة تعمل بدون أي غلاف، فالخطأ المرمي داخل async يصل إلى معالج الأخطاء تلقائياً، وأداؤه من الأعلى بين أطر Node.js. وإذا كنت قادماً من Spring أو ASP.NET فسوف تجد Fastify أخف بكثير: لا توجد Annotations ولا حاوية حقن، وإنما دالة تنشئ التطبيق، ودوال تسجل المسارات.
وإذا كنت ستعمل على مشروع قائم بـ Express فانتبه إلى فرق واحد مهم: في Express 4 لا يصل الخطأ المرمي داخل دالة async إلى معالج الأخطاء، وإنما يصبح وعداً مرفوضاً بلا معالج، فتسقط العملية كلها كما رأينا في مقال الأخطاء، ولذلك كانت المشاريع القديمة تلف كل دالة بغلاف يمسك الخطأ ويمرره بـ next(err). وExpress 5 أصلح ذلك، ولكن كثيراً من المشاريع ما زالت على الإصدار 4. أما في Fastify فالدالة async هي الطريقة الأساسية من البداية، وما ترجعه يرسل كرد JSON، وما ترميه يصل إلى setErrorHandler. والفرق الثاني أن Fastify يبني كل شيء على الإضافات Plugins المغلفة Encapsulated، فكل إضافة لها نطاقها الخاص من المسارات والإعدادات، وهذا مفيد عندما تكبر الخدمة إلى عشرات المسارات، ولم نحتجه في ثلاث نقاط.
وهيكل المشروع كما يلي، وهو نفس الهيكل الذي شرحناه في مقال JavaScript لمن يعرف لغة أخرى مع ملف بناء إضافي:
orders-api/
├── package.json package-lock.json
├── tsconfig.json # type check only (noEmit), used by tests and CI
├── tsconfig.build.json # emits dist/ for the production image
├── Dockerfile .dockerignore
├── schema.sql
├── src/
│ ├── money.ts # totals and VAT in halalas
│ ├── orders.ts # createOrder / getOrder, one transaction
│ ├── app.ts # Fastify routes, schemas, error handler
│ └── server.ts # pg.Pool, listen, graceful shutdown
└── test/
├── money.test.ts # unit
└── api.test.ts # integration against PostgreSQLوثبتنا الحزم داخل Container من node:26.13.0-slim، فكانت النتيجة:
[email protected] /work
+-- @types/[email protected]
+-- @types/[email protected]
+-- [email protected]
+-- [email protected]
`-- [email protected]
packages: 61 production, 66 totalأي أن حزمتين مباشرتين للإنتاج، Fastify وpg، جلبتا معهما 59 حزمة أخرى، وهذا رقم معتدل في عالم npm، ولكنه يذكرك بأن كل اعتمادية مباشرة تأتي بشجرة كاملة، وأن ملف القفل هو ما يثبت هذه الشجرة. والملف package.json بعد التثبيت فيه السكربتات التي يستخدمها الفريق وCI:
"scripts": {
"start": "node dist/server.js",
"dev": "node --watch src/server.ts",
"typecheck": "tsc -p tsconfig.json",
"build": "tsc -p tsconfig.build.json",
"test:unit": "node --test test/money.test.ts",
"test": "node --test --test-concurrency=1 test/"
}ولاحظ أن التطوير يشغل ملفات TypeScript مباشرة مع --watch الذي يعيد التشغيل عند كل حفظ، أما الإنتاج فيشغل dist/server.js المبني، والسبب هو تكلفة حذف الأنواع عند البدء التي قسناها في المقال الأول من المرحلة. والخيار --test-concurrency=1 يشغل ملفات الاختبار واحداً بعد الآخر، لأن اختبار التكامل يعيد إنشاء الجداول قبل كل اختبار، ولو عمل ملفان على نفس قاعدة البيانات معاً لحذف أحدهما جداول الآخر.
المبالغ بالهللات Money: دالة صغيرة واختبار وحدة Unit Test
أول ملف نكتبه هو أهم ملف، لأنه المكان الوحيد الذي تحسب فيه المبالغ، وكل ما عداه ينقلها فقط:
// Money is integer halalas everywhere (1 SAR = 100 halalas). No floats, no rounding until display.
export const VAT_PERCENT = 15;
export type PricedLine = { priceHalalas: number; qty: number };
export type Totals = { subtotal: number; vat: number; total: number };
// VAT on the order subtotal, rounded half up, integer arithmetic only:
// 2970 * 15 = 44550 -> (44550 + 50) / 100 = 446.0 -> 446
export function vatHalalas(subtotal: number): number {
return Math.floor((subtotal * VAT_PERCENT + 50) / 100);
}
export function totals(lines: PricedLine[]): Totals {
const subtotal = lines.reduce((sum, l) => sum + l.priceHalalas * l.qty, 0);
if (!Number.isSafeInteger(subtotal)) throw new RangeError(`subtotal out of range: ${subtotal}`);
const vat = vatHalalas(subtotal);
return { subtotal, vat, total: subtotal + vat };
}في الكود أعلاه لاحظ التالي:
- الضريبة تحسب على مجموع الطلب وليس على كل بند، وهذا قرار في المواصفات وليس تفصيلاً، لأن التقريب على كل بند ثم الجمع يعطي رقماً مختلفاً، كما يبين الاختبار الثالث بعد قليل.
- التقريب بعملية صحيحة: نضيف 50 قبل القسمة على 100، ثم نأخذ الجزء الصحيح، فلا يظهر أي كسر عشري في أي خطوة، وهذا يعمل لأن المجموع موجب دائماً.
- الفحص
Number.isSafeIntegerيحمي من الحالة التي شرحناها في مقال الأنواع والذاكرة والأخطاء: رقم أكبر من 2 أس 53 يفقد دقته بصمت، فنرميRangeErrorبدلاً من أن نحفظ مبلغاً خاطئاً.
واختبار الوحدة يغطي المثال الذي نستخدمه في كل المسار، وحالات التقريب الحدية:
test('9.90 SAR x 3 = 29.70, VAT 4.455 -> 4.46, total 34.16', () => {
assert.deepEqual(totals([{ priceHalalas: 990, qty: 3 }]), { subtotal: 2970, vat: 446, total: 3416 });
});
test('VAT rounds half up only at exactly half a halala', () => {
assert.equal(vatHalalas(10), 2); // 1.5 -> 2
assert.equal(vatHalalas(3), 0); // 0.45 -> 0
assert.equal(vatHalalas(4), 1); // 0.6 -> 1
assert.equal(vatHalalas(0), 0);
});
test('VAT is on the order subtotal, not per line', () => {
// per line: vat(990)=149 twice = 298; on the subtotal: vat(1980) = 297
assert.equal(totals([{ priceHalalas: 990, qty: 1 }, { priceHalalas: 990, qty: 1 }]).vat, 297);
});والمخرج سوف يكون كما يلي:
✔ 9.90 SAR x 3 = 29.70, VAT 4.455 -> 4.46, total 34.16 (11.257473ms)
✔ VAT rounds half up only at exactly half a halala (0.560038ms)
✔ VAT is on the order subtotal, not per line (0.347947ms)
ℹ tests 3
ℹ pass 3
ℹ fail 0والاختبار الثالث هو الذي يحمي قرار المواصفات: علبتا هيل في بندين منفصلين، فإذا حسب أحدهم الضريبة لكل بند فسوف تكون 149 مرتين، أي 298 هللة، أما على المجموع 1980 فهي 297.5 تقرب إلى 297. وفرق هللة واحدة يبدو تافهاً، ولكنه يظهر في مطابقة الحسابات آخر الشهر، والأسوأ أن يحسبه النظام بطريقة ونظام الفوترة بطريقة أخرى. لذلك نكتب القرار كاختبار له اسم واضح، فمن يغيره يعرف أنه يغير قراراً وليس سطراً. وقواعد الفوترة الرسمية في السعودية تصدرها هيئة الزكاة والضريبة والجمارك في صفحة الفوترة الإلكترونية، وخدمة حقيقية تصدر فواتير يجب أن تطابق متطلباتها، وهذا خارج نطاق مقال عن اللغة.
معاملة واحدة Transaction لكل طلب: القفل ثم الفحص ثم الكتابة
الآن قلب الخدمة: الدالة createOrder. وقبل الكود تذكر السباق الذي صنعناه في مقال التزامن في JavaScript: قراءة المخزون، ثم await، ثم كتابة القيمة الجديدة، فنجح عشرون طلباً من مخزون عشرة. وقلنا هناك إن القفل في الذاكرة لا يكفي لأن الخدمة تعمل بعدة نسخ، والإصلاح الحقيقي في قاعدة البيانات. والطرق الممكنة ثلاث:
- التحديث المشروط:
UPDATE products SET stock = stock - 2 WHERE id = 3 AND stock >= 2، ثم تفحص عدد الصفوف المتأثرة. وهذا ممتاز لمنتج واحد، ولكنه يصبح معقداً مع عدة منتجات في الطلب، لأنك تحتاج أن تتراجع عن الأول إذا فشل الثاني. - مستوى العزل Serializable: تترك PostgreSQL تكتشف التعارض وتلغي إحدى المعاملتين بخطأ، وعليك أن تعيد المحاولة. وهذا صحيح، ولكنه ينقل التعقيد إلى منطق إعادة المحاولة، كما يشرح توثيق مستويات العزل في PostgreSQL.
- قفل الصفوف
SELECT ... FOR UPDATE: تقفل صفوف المنتجات في بداية المعاملة، فكل طلب آخر يريد نفس المنتج ينتظر حتى تنتهي المعاملة، ثم يقرأ المخزون الجديد. وهذا أبسط طريقة لعدة منتجات، وهي التي اخترناها.
والكود كما يلي، مختصراً من الملف src/orders.ts:
export async function createOrder(pool: pg.Pool, items: OrderItemInput[]) {
// The same product twice in one request becomes one line with the summed quantity.
const wanted = new Map<number, number>();
for (const { product_id, qty } of items) wanted.set(product_id, (wanted.get(product_id) ?? 0) + qty);
const ids = [...wanted.keys()].sort((a, b) => a - b); // fixed lock order: no deadlock between two orders
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query<{ id: number; price_halalas: number; stock: number }>(
`SELECT id, price_halalas, stock FROM products WHERE id = ANY($1::int[]) ORDER BY id${NO_LOCK ? '' : ' FOR UPDATE'}`,
[ids],
);
if (rows.length !== ids.length) {
const found = new Set(rows.map((r) => r.id));
throw new UnknownProductError(ids.filter((id) => !found.has(id)));
}
for (const p of rows) {
const qty = wanted.get(p.id)!;
if (p.stock < qty) throw new OutOfStockError(p.id, qty, p.stock);
}
for (const p of rows) {
const qty = wanted.get(p.id)!;
await client.query('UPDATE products SET stock = stock - $2 WHERE id = $1', [p.id, qty]);
}
const lines = rows.map((p) => ({ productId: p.id, priceHalalas: p.price_halalas, qty: wanted.get(p.id)! }));
const t = totals(lines);
const { rows: [order] } = await client.query<{ id: number; created_at: Date }>(
'INSERT INTO orders (total_halalas) VALUES ($1) RETURNING id, created_at', [t.total]);
await client.query(
`INSERT INTO order_items (order_id, product_id, qty, price_halalas)
SELECT $1, * FROM unnest($2::int[], $3::int[], $4::int[])`,
[order.id, lines.map((l) => l.productId), lines.map((l) => l.qty), lines.map((l) => l.priceHalalas)],
);
await client.query('COMMIT');
return { id: order.id, created_at: order.created_at, /* items and totals */ };
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}والصورة التالية تبين خطوات المعاملة بالترتيب، ومكان كل رد خطأ:

وكل سطر هنا له سبب، فاقرأه كما يلي:
- اتصال واحد للمعاملة كلها:
pool.connect()يأخذ اتصالاً ويحتفظ به حتىrelease()، والسبب كما يشرح توثيق node-postgres للمعاملات أنpool.queryالعادية قد ترسل كل استعلام على اتصال مختلف، فيصبحBEGINعلى اتصال وUPDATEعلى اتصال آخر خارج المعاملة. وهذا أشهر خطأ في المعاملات معpg. finallyيعيد الاتصال دائماً: إذا نسيتrelease()في مسار خطأ واحد، فكل خطأ يأخذ اتصالاً من المجموعة إلى الأبد، وبعد عشرة أخطاء تتوقف الخدمة كلها تنتظر اتصالاً، دون أي رسالة.- ترتيب المعرفات قبل القفل: طلب فيه المنتجان 1 و3، وطلب آخر فيه 3 و1، لو قفل كل منهما بترتيب ورود المنتجات لأمكن أن يملك الأول المنتج 1 وينتظر 3، ويملك الثاني 3 وينتظر 1، وهذا الجمود الذي صنعناه بالأقفال في مقال التزامن. و
ORDER BY idمعFOR UPDATEيجعل الجميع يقفلون بنفس الترتيب، كما يوصي توثيق الأقفال في PostgreSQL. - دمج المنتج المكرر: إذا أرسل العميل نفس المنتج في بندين، نجمع الكميتين قبل الفحص، وإلا سوف يمر كل بند على الفحص وحده بنفس المخزون.
- الطرح في قاعدة البيانات:
stock = stock - $2وليسstock = $newValueمحسوبة في التطبيق، فحتى لو حدث خطأ في القفل يوماً ما يبقى الطرح صحيحاً، والقيدCHECK (stock >= 0)يمنع السالب. - البنود باستعلام واحد:
unnestيحول ثلاث مصفوفات إلى صفوف، فطلب بعشرين بنداً يبقى استعلاماً واحداً إلى قاعدة البيانات بدلاً من عشرين. - الأخطاء كلاسات لها أسماء:
OutOfStockErrorوUnknownProductErrorيرثان منErrorكما شرحنا في مقال الأنواع والأخطاء، فيعرف معالج الأخطاء كيف يحول كل واحد منهما إلى رمز HTTP.
وقد يتساءل البعض: لماذا لا نستخدم ORM مثل Prisma أو Drizzle أو TypeORM؟ والإجابة أن هذه الأدوات مفيدة في مشروع كبير بعشرات الجداول، ولكن هذه المعاملة بالذات، بقفل الصفوف وترتيبها وunnest، تحتاج أن ترى SQL الذي يرسل فعلاً، وأغلب أدوات ORM تجعل FOR UPDATE خياراً مخفياً أو تحتاج إلى SQL خام على أي حال. والقاعدة: اكتب SQL مباشرة للأجزاء التي يعتمد عليها صحة المال والمخزون، واستخدم ما يريحك في الباقي.
الاستعلامات المعاملية Parameterized Queries وأنواع الصفوف
لاحظ أن كل قيمة تصل من العميل تذهب إلى PostgreSQL عبر $1 و$2 وليس بدمجها في نص الاستعلام، فحتى لو تجاوز أحدهم التحقق وأرسل نصاً فيه علامة تنصيص أو أمر SQL فلن يكون إلا قيمة. والسبب أن pg يرسل النص والقيم منفصلين في بروتوكول PostgreSQL، فالخادم لا يحلل القيمة كجزء من الأمر أصلاً، وهذا هو العلاج الحقيقي لحقن SQL Injection، وليس تنظيف النصوص يدوياً. والاستثناء الوحيد في الكود هو NO_LOCK الذي يغير نص الاستعلام، وقيمته تأتي من متغير بيئة يتحكم فيه من يشغل الخدمة، وليس من الطلب. والقاعدة: لا يوجد قالب نصي `...${value}...` يحمل قيمة من المستخدم داخل SQL أبداً، وأي أداة تدقيق في الفريق يجب أن تبحث عن هذا الشكل.
ولاحظ أيضاً النوع العام في client.query<{ id: number; price_halalas: number; stock: number }>، فهو يجعل المحرر يعرف شكل الصفوف، ولكنه وعد منك للمترجم وليس فحصاً، فإذا غيرت اسم عمود في قاعدة البيانات أو رجع العمود نصاً لأنه numeric فلن يعترض أحد قبل التشغيل. وهذا هو نفس الحد الذي رأيناه في مقال TypeScript بين الأنواع داخل الكود والبيانات القادمة من الخارج، وقاعدة البيانات «خارج» بالنسبة للمترجم. لذلك اخترنا أعمدة int التي يرجعها pg كأرقام فعلاً، واختبار التكامل يقارن الصفوف بقيم محددة، فيكشف أي تغيير في الشكل.
التحقق Validation عند الحدود: JSON Schema في Fastify
في مقال الأنواع رأينا أن TypeScript لا ترى البيانات القادمة وقت التشغيل، وأن JSON يغير الأنواع في الطريق، وأن JavaScript تحول النصوص إلى أرقام دون أن تسأل. لذلك فإن أول ما يمر عليه جسم الطلب هو مخطط JSON Schema يعرفه المسار، وFastify يفحصه بمكتبة Ajv قبل أن يصل الطلب إلى الدالة. والملف src/app.ts كاملاً تقريباً:
const orderBody = {
type: 'object',
required: ['items'],
additionalProperties: false,
properties: {
items: {
type: 'array', minItems: 1, maxItems: 50,
items: {
type: 'object', required: ['product_id', 'qty'], additionalProperties: false,
properties: { product_id: { type: 'integer', minimum: 1 }, qty: { type: 'integer', minimum: 1, maximum: 1000 } },
},
},
},
} as const;
export function buildApp(pool: pg.Pool, opts: { logger?: boolean } = {}): FastifyInstance {
// Strict validation: no type coercion ("2" is not 2), extra fields rejected instead of silently removed.
const app = Fastify({ logger: opts.logger ?? false, ajv: { customOptions: { coerceTypes: false, removeAdditional: false } } });
app.get('/products', async () => {
const { rows } = await pool.query('SELECT id, name_ar, price_halalas, stock FROM products ORDER BY id');
return rows;
});
app.post<{ Body: { items: OrderItemInput[] } }>('/orders', { schema: { body: orderBody } }, async (req, reply) => {
const order = await createOrder(pool, req.body.items);
return reply.code(201).send(order);
});
// Path params always arrive as strings; with coercion off we validate the digits and convert ourselves.
app.get<{ Params: { id: string } }>('/orders/:id', {
schema: { params: { type: 'object', properties: { id: { type: 'string', pattern: '^[1-9][0-9]{0,9}$' } } } },
}, async (req, reply) => {
const id = Number(req.params.id);
const order = id <= 2147483647 ? await getOrder(pool, id) : null; // beyond int4 cannot exist
return order ?? reply.code(404).send({ error: 'order not found' });
});
app.setErrorHandler((err, req, reply) => {
if (err instanceof OutOfStockError) return reply.code(409).send({ error: err.message, product_id: err.productId, left: err.left });
if (err instanceof UnknownProductError) return reply.code(400).send({ error: err.message });
if ((err as { validation?: unknown }).validation) return reply.code(400).send({ error: (err as Error).message });
req.log.error(err);
return reply.code(500).send({ error: 'internal error' });
});
return app;
}وأهم سطر في الملف هو سطر ajv، لأن Fastify له إعدادات افتراضية لـ Ajv يجب أن تعرفها قبل أن تثق بالتحقق. فالإعداد الأساسي في الإطار فيه coerceTypes: 'array'، أي أن Ajv يحول القيم لتطابق المخطط، فالكمية "2" كنص تصبح الرقم 2 وتمر، وفيه removeAdditional: true، أي أن الحقول الزائدة تحذف بصمت بدلاً من أن ترفض حتى مع additionalProperties: false. وهذا قد يكون مناسباً لنموذج HTML، ولكنه في خدمة مالية يعني أن عميلاً أرسل "qty": "2" أو أخطأ في اسم حقل لن يعرف أبداً أن طلبه غير صحيح. لذلك أطفأنا الخيارين، فصار المخطط يقول ما يقبله بالضبط. ومعالج الأخطاء الواحد في النهاية هو المكان الوحيد الذي تتحول فيه الأخطاء إلى رموز HTTP: 409 لنقص المخزون مع الكمية المتبقية، و400 للتحقق وللمنتج غير الموجود، و500 مع تسجيل الخطأ لكل ما عدا ذلك، دون أن نرسل تفاصيله الداخلية للعميل.

مشكلة واجهناها: التحويل Coercion ومعاملات المسار
في النسخة الأولى كان مخطط GET /orders/:id يقول إن id من نوع integer، وهو ما يبدو طبيعياً. وعندما شغلنا اختبارات التكامل فشل اختباران:
✖ POST /orders: 3 x 9.90 SAR -> 3416 halalas, stock 10 -> 7, GET /orders/:id (336.666728ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ undefined
- 3416
✖ 400 on bad input (317.150188ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
400 !== 404والطلب نفسه أنشئ بنجاح، ولكن قراءته أرجعت جسماً بلا total_halalas، والطلب غير الموجود أرجع 400 بدلاً من 404. والسبب أن معاملات المسار في HTTP نصوص دائماً، فالرقم 7 في /orders/7 يصل كالنص "7"، وكان Fastify يحوله إلى رقم بالإعداد الافتراضي، فلما أطفأنا التحويل لنحمي جسم الطلب، صار كل معرف في المسار يفشل في التحقق من النوع integer ويرجع 400. والحل الذي اخترناه أن نصف المعامل كما هو فعلاً: نص من أرقام فقط بنمط ^[1-9][0-9]{0,9}$، ثم نحوله بـ Number() في الدالة، ونرجع 404 لأي رقم أكبر من حد العمود int بدلاً من أن نرسله إلى قاعدة البيانات فترمي خطأ. والحل الآخر الذي يذكره نفس توثيق Fastify هو أن تحدد مدققاً مختلفاً لكل جزء من الطلب، فتطفئ التحويل للجسم وحده وتبقيه للمسار والاستعلام. والدرس العام: كل إعداد «يحمي» يجب أن يرافقه اختبار يثبت أنه لم يكسر الحالة الصحيحة، ولولا اختبار التكامل لوصلت هذه المشكلة إلى الإنتاج.
التشغيل Server والإغلاق الآمن Graceful Shutdown
الملف الأخير src/server.ts هو الوحيد الذي يقرأ متغيرات البيئة وينشئ الموارد، وهذا يجعل buildApp قابلة للاختبار بأي Pool نعطيها إياه:
import pg from 'pg';
import { buildApp } from './app.ts';
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: Number(process.env.PG_POOL_MAX ?? 10),
});
const app = buildApp(pool, { logger: process.env.LOG !== '0' });
const close = async (signal: string) => {
app.log.info({ signal }, 'shutting down');
await app.close(); // stop accepting, finish in-flight requests
await pool.end(); // then close database connections
process.exit(0);
};
process.once('SIGTERM', close);
process.once('SIGINT', close);
await app.listen({ host: '0.0.0.0', port: Number(process.env.PORT ?? 3000) });والإشارة SIGTERM هي ما يرسله Docker وKubernetes عند إيقاف الـ Container أو استبداله بنسخة جديدة، وبعدها بمهلة يرسل SIGKILL الذي لا يمكن التقاطه. فإذا لم تلتقط SIGTERM فكل طلب كان في منتصف معاملة سوف يقطع، وهذا يعني طلبات فشلت عند العميل مع كل نشر جديد. والترتيب مهم: أوقف استقبال الطلبات الجديدة وانتظر الطلبات الحالية بـ app.close()، ثم أغلق اتصالات قاعدة البيانات. والعنوان 0.0.0.0 ضروري داخل الـ Container، لأن الاستماع على 127.0.0.1 وحده يجعل الخدمة غير مرئية من خارج الـ Container. وحجم المجموعة max عشرة افتراضياً كما في توثيق التجميع في node-postgres، وهو حد لكل نسخة من الخدمة، فأربع نسخ تعني أربعين اتصالاً، ويجب أن يبقى المجموع تحت max_connections في PostgreSQL.
اختبار التكامل Integration Test على PostgreSQL حقيقي
اختبار الوحدة يثبت أن الحساب صحيح، ولكنه لا يثبت شيئاً عن المعاملة والأقفال والمخطط، ولذلك نحتاج اختبار تكامل يعمل على PostgreSQL حقيقي، وليس على قاعدة بيانات في الذاكرة أو على محاكاة Mock، لأن ما نريد اختباره هو سلوك PostgreSQL نفسه مع FOR UPDATE. والاختبار يشغل الخدمة على منفذ عشوائي، ويرسل إليها طلبات HTTP حقيقية بـ fetch، ويعيد إنشاء الجداول من schema.sql قبل كل اختبار:
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
const app = buildApp(pool);
let base = '';
const schema = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
before(async () => { base = await app.listen({ host: '127.0.0.1', port: 0 }); });
after(async () => { await app.close(); await pool.end(); });
beforeEach(async () => {
await pool.query('DROP TABLE IF EXISTS order_items, orders, products');
await pool.query(schema);
});
test('20 parallel orders for a product with stock 10 -> exactly 10 succeed', async () => {
const results = await Promise.all(Array.from({ length: 20 }, () => post({ items: [{ product_id: 3, qty: 1 }] })));
const codes = results.map((r) => r.status);
const ok = codes.filter((c) => c === 201).length;
const conflict = codes.filter((c) => c === 409).length;
const { rows: [p] } = await pool.query('SELECT stock FROM products WHERE id = 3');
const { rows: [o] } = await pool.query('SELECT count(*)::int AS n FROM orders');
console.log(` parallel: 201=${ok} 409=${conflict} other=${20 - ok - conflict} stock=${p.stock} orders=${o.n}`);
assert.equal(ok, 10);
assert.equal(conflict, 10);
assert.equal(p.stock, 0);
assert.equal(o.n, 10);
});وبقية الاختبارات في الملف تفحص قائمة المنتجات، وطلباً عادياً يحسب 3416 هللة وينزل المخزون من 10 إلى 7 ثم يقرأ الطلب، والرد 409 مع التأكد أن شيئاً لم يكتب، وست حالات 400 ومعرف غير رقمي ومعرف غير موجود. وشغلنا PostgreSQL في Container على شبكة Docker خاصة، والاختبارات في Container آخر على نفس الشبكة:
docker network create slj-js-net
docker run -d --name slj-js-db --network slj-js-net -e POSTGRES_PASSWORD=shop -e POSTGRES_USER=shop -e POSTGRES_DB=shop postgres:17-alpine
docker run --rm --network slj-js-net -e DATABASE_URL=postgres://shop:shop@slj-js-db:5432/shop -v "$PWD:/src:ro" node:26.13.0-slim sh /src/test.shوالمخرج بعد إصلاح مشكلة المسار سوف يكون كما يلي:
tsc: no errors
✔ GET /products lists the three seeded products (1413.838024ms)
✔ POST /orders: 3 x 9.90 SAR -> 3416 halalas, stock 10 -> 7, GET /orders/:id (213.196592ms)
✔ 409 on insufficient stock, and nothing is written (144.914896ms)
✔ 400 on bad input (185.29155ms)
parallel: 201=10 409=10 other=0 stock=0 orders=10
✔ 20 parallel orders for a product with stock 10 -> exactly 10 succeed (1726.294461ms)
✔ 9.90 SAR x 3 = 29.70, VAT 4.455 -> 4.46, total 34.16 (1.88346ms)
✔ VAT rounds half up only at exactly half a halala (0.43476ms)
✔ VAT is on the order subtotal, not per line (0.28714ms)
ℹ tests 8
ℹ pass 8
ℹ fail 0
ℹ duration_ms 4967.899308في المخرج أعلاه لاحظ التالي:
- السطر
parallelهو ما يهمنا: عشرون طلباً أرسلت معاً عبر HTTP، فنجح عشرة بالرمز 201، ورفض عشرة بالرمز 409، والمخزون صفر، وعدد الطلبات في الجدول عشرة بالضبط. فالقفل في PostgreSQL فعل ما فشل فيه الفحص في الذاكرة. - أول اختبار أخذ 1.4 ثانية، وأغلب الوقت فتح أول اتصال بقاعدة البيانات وتحميل الكود، والاختبارات بعده بين 145 و220 ميلي ثانية مع إعادة إنشاء الجداول كل مرة.
- اختبار التوازي أخذ 1.7 ثانية، لأن الطلبات العشرين تنتظر بعضها عند قفل الصف، وهذا ثمن الصحة: الطلبات على نفس المنتج تتسلسل، أما الطلبات على منتجات مختلفة فتعمل معاً.
tsc: no errorsفي البداية، فالاختبارات تشغل الكود بحذف الأنواع، والفحص خطوة مستقلة قبلها.

كيف يرى PostgreSQL الطلبات العشرين؟ داخل القفل Row Lock
من المفيد أن تتخيل ما حدث داخل PostgreSQL في اختبار التوازي، لأنه يفسر الرقمين: عشرة نجحت، وعشرة رفضت. فالطلبات العشرون وصلت إلى Fastify معاً، ولكن المجموعة فيها عشرة اتصالات فقط، فبدأت عشر معاملات وانتظرت عشر أخرى في طابور المكتبة. والمعاملات العشر وصلت كلها إلى SELECT ... FOR UPDATE على صف المنتج 3، فأخذت الأولى قفل الصف، ووقفت التسع الأخرى تنتظر هذا القفل داخل PostgreSQL. وعندما نفذت الأولى UPDATE وCOMMIT صار المخزون 9 وتحرر القفل، فأخذته الثانية. وهنا التفصيل المهم: الثانية بدأت انتظارها عندما كان المخزون 10، ولكنها لا ترجع القيمة القديمة، لأن PostgreSQL في مستوى Read Committed، كما يشرح توثيق مستويات العزل، يعيد قراءة الصف بنسخته الأحدث بعد أن يحصل على القفل، فترى 9. وهكذا حتى العاشرة التي رأت 1 فطرحته، ثم الحادية عشرة رأت 0 فرمت OutOfStockError وعملت ROLLBACK، وكذلك كل ما بعدها.
وهذا يعني أن الطلبات على نفس المنتج تتسلسل فعلاً، واحداً بعد الآخر، وزمن كل طلب يشمل انتظاره للطلبات التي قبله، ولهذا أخذ اختبار التوازي 1.7 ثانية. أما طلبان على منتجين مختلفين فلا يقفل أحدهما صف الآخر، فيعملان معاً. فإذا كان لديك منتج واحد عليه عرض يطلبه الجميع في نفس الدقيقة، فهذا الصف هو عنق الزجاجة Bottleneck في الخدمة كلها، والحل عندها ليس في اللغة ولا في الإطار، وإنما في التصميم، مثل حجز كميات مسبقاً أو تقسيم المخزون، وهذا موضوع تصميم أنظمة وليس موضوع هذا المقال.
ماذا يحدث إذا حذفنا FOR UPDATE؟ تجربة NO_LOCK
اختبار يمر لا يثبت أن القفل هو سبب النجاح، فربما كانت الطلبات تصل متتابعة بالصدفة. لذلك أضفنا إلى createOrder مفتاحاً للتجربة فقط: مع متغير البيئة NO_LOCK=1 يقرأ المخزون بدون FOR UPDATE، وينتظر 5 ميلي ثوان كما لو كانت الشبكة أبطأ، ثم يكتب القيمة المحسوبة في التطبيق stock = $newValue، أي نفس الكود الذي يكتبه كثيرون في أول نسخة. والمخرج:
parallel: 201=20 409=0 other=0 stock=8 orders=20
✖ 20 parallel orders for a product with stock 10 -> exactly 10 succeed (2144.424119ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
20 !== 10عشرون طلباً نجحت، وعشرون طلباً في الجدول، والمخزون يقول إنه بقي 8. أي أن المتجر باع عشرين علبة هيل من عشر، وقاعدة البيانات تظن أن لديه ثماني علب أخرى للبيع، وهذا أسوأ من الخطأين معاً، لأن القيد CHECK (stock >= 0) لم يتدخل أصلاً، فلم تكتب أي قيمة سالبة. والسبب أن كل معاملة قرأت المخزون قبل أن تكتب المعاملات الأخرى، ثم كتبت قيمتها فوق قيمهم، وهذا التحديث المفقود Lost Update بنفس الشكل الذي رأيناه في الذاكرة. ولاحظ أن مستوى العزل الافتراضي في PostgreSQL، Read Committed، لا يمنع هذا، فكل معاملة رأت بيانات «مؤكدة» صحيحة، ولكنها قديمة لحظة الكتابة. فالمعاملة وحدها لا تكفي، وما يحمي المخزون هو القفل أو الطرح داخل قاعدة البيانات أو مستوى عزل أعلى.
Dockerfile متعدد المراحل Multi-stage
الـ Image الذي يعمل في الإنتاج يجب أن يحتوي ما يلزم للتشغيل فقط: Node.js، وحزم الإنتاج، والكود المبني. أما TypeScript وتعريفات الأنواع وذاكرة npm المؤقتة فمكانها مرحلة البناء، وهذا ما تفعله المراحل المتعددة:
# syntax=docker/dockerfile:1
# Stage 1: all dependencies, type check and build to plain JavaScript
FROM node:26.13.0-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json tsconfig.build.json ./
COPY src ./src
RUN npx tsc -p tsconfig.json && npx tsc -p tsconfig.build.json
# Stage 2: production dependencies only
FROM node:26.13.0-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Stage 3: runtime, no npm cache, no TypeScript, non-root user
FROM node:26.13.0-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]في الإعداد أعلاه لاحظ التالي:
- ننسخ
package.jsonوملف القفل قبل الكود، فإذا تغير الكود فقط استخدم Docker طبقةnpm ciمن الذاكرة المؤقتة ولم يعد التحميل، وهذا يحول بناء بعد تعديل سطر واحد من دقيقة إلى ثوان. - مرحلة
buildتفحص الأنواع قبل البناء، فـ Image لا يبنى إذا كان في الكود خطأ أنواع. - مرحلة
depsمنفصلة عنbuild، حتى لا تحملnode_modulesفي الإنتاج أي حزمة تطوير، و--omit=devيترك 61 حزمة من 66. USER node: الـ Image الرسمي فيه مستخدم اسمهnode، وأفضل الممارسات في مستودع Docker Image الرسمي توصي بتشغيل التطبيق به بدلاً من root.CMD ["node", "dist/server.js"]بصيغة المصفوفة وليسnpm start، لأنnpmلا يمرر إشارةSIGTERMإلى العملية بشكل موثوق، فيضيع الإغلاق الآمن الذي كتبناه.
ومع الـ Dockerfile ملف صغير لا يقل أهمية هو .dockerignore، وفيه node_modules وdist وtest. والسبب أن الأمر docker build يرسل مجلد المشروع كله إلى محرك البناء قبل أن يبدأ، فإذا كان في المجلد node_modules من جهاز المطور بحجم مئات الميجابايتات فسوف يرسل في كل بناء، والأسوأ أن COPY . . في Dockerfile آخر قد ينسخ حزماً مبنية لنظام Windows أو macOS فوق حزم Linux، فتعمل الخدمة على جهازك وتفشل في الـ Container برسالة غامضة عن ملف أصلي لا يمكن تحميله. ونحن ننسخ الملفات بأسمائها، ولكن الملف يحمي من أي تعديل لاحق على الـ Dockerfile.
وبنينا الـ Image ونظرنا في طبقاته:
docker build -t slj-js-orders:1 .
docker images slj-js-orders:1
docker history slj-js-orders:1 --format '{{.Size}}\t{{.CreatedBy}}'build took 72 s
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
slj-js-orders:1 184ca8ca4131 371MB 87.5MB
0B CMD ["node" "dist/server.js"]
0B EXPOSE [3000/tcp]
0B USER node
12.3kB COPY package.json ./ # buildkit
28.7kB COPY /app/dist ./dist # buildkit
16.4MB COPY /app/node_modules ./node_modules # buil…
8.19kB WORKDIR /app
0B ENV NODE_ENV=production
والأرقام تقول إن الـ Image 371 ميجابايت على القرص، منها 355 للـ Image الأساسي node:26.13.0-slim، وما أضفناه 16.4 ميجابايت لحزم الإنتاج و28.7 كيلوبايت فقط للكود المبني. فإذا أردت Image أصغر فالمكان الذي يستحق الجهد هو الأساس وليس الكود، مثل Images بلا Shell من نوع Distroless، وهذا موضوع تقليل حجم الـ Images في مسار Docker، وليس موضوع اللغة.
القياس Benchmark: نفس الطريقة في كل لغة
الآن القياس الذي نكرره في كل لغات المسار: الـ Container محدود بنواتين و512 ميجابايت، ونقيس حجم الـ Image، والبدء حتى أول رد 200، والذاكرة وهو خامل بعد خمس ثوان، ثم wrk بخيطين وخمسين اتصالاً لمدة 15 ثانية على GET /products مرتين، ثم الذاكرة بعد الضغط. وأطفأنا سجل الطلبات بـ LOG=0 أثناء القياس، لأن كتابة سطر JSON لكل طلب تقيس سرعة السجلات وليس الخدمة. والمخرج:
== image size
371.2 MB
== idle memory after 5 s
34.23MiB / 512MiB
== wrk run 1
Running 15s test @ http://slj-js-svc:3000/products
2 threads and 50 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 42.24ms 64.88ms 897.62ms 96.54%
Req/Sec 759.67 295.59 1.73k 72.01%
22559 requests in 15.10s, 9.23MB read
Requests/sec: 1493.66
Transfer/sec: 625.76KB
== wrk run 2
Running 15s test @ http://slj-js-svc:3000/products
2 threads and 50 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 34.56ms 20.89ms 254.24ms 78.38%
Req/Sec 756.68 281.34 1.68k 73.20%
22644 requests in 15.09s, 9.26MB read
Requests/sec: 1501.08
Transfer/sec: 628.87KB
== memory after load
46.52MiB / 512MiBوسكربت القياس المشترك يقيس زمن البدء أيضاً من الجهاز نفسه، بحلقة curl إلى المنفذ المنشور، وأعطى في التشغيل الأول رقماً غير معقول هو 61,895 ميلي ثانية. وعندما فحصنا السبب وجدنا أن الأمر docker run وحده يأخذ حوالي 5 ثوان على Docker Desktop في Windows قبل أن يرجع، وأن كل محاولة curl فاشلة من Git Bash إلى منفذ لم يفتح بعد تعلق مدة طويلة لأن السكربت لا يحدد مهلة لكل محاولة، فالرقم يقيس Docker Desktop والشبكة على Windows وليس الخدمة. لذلك قسنا البدء بطريقة أدق من داخل شبكة Docker، والمشكلة نفسها درس في القياس: قبل أن تثق برقم، تأكد أنه يقيس ما تظنه يقيسه.
ولذلك قسنا زمن البدء من داخل شبكة Docker: لحظة بدء الـ Container كما يسجلها Docker، حتى أول رد 200 يراه Container آخر يسأل كل بضع ميلي ثوان، ثلاث مرات:
run 1: start -> first 200: 2961 ms
run 2: start -> first 200: 2989 ms
run 3: start -> first 200: 4529 msولأن الأمر docker run نفسه كان بطيئاً جداً على هذا الجهاز المشغول، أخذنا أيضاً الرقم الذي نقارن به بين لغات المسار: زمن العملية نفسها حتى تصبح جاهزة، من لحظة بدء الـ Container كما يسجلها Docker إلى الحقل time في سطر السجل Server listening الذي يطبعه Fastify، ثلاث مرات:
run 1: container start -> "Server listening" log: 6010 ms
run 2: container start -> "Server listening" log: 3189 ms
run 3: container start -> "Server listening" log: 2935 msوالقراءتان الأخيرتان قريبتان من قياس الشبكة، أما الأولى فتبين كم يتأثر البدء بانشغال الجهاز في تلك اللحظة.

وهذه الأرقام تحتاج قراءة حذرة، وفيما يلي ما تقوله وما لا تقوله:
- الإنتاجية حوالي 1500 طلب في الثانية في التشغيلين، وكل طلب يذهب إلى PostgreSQL ويرجع ثلاثة صفوف ويحولها إلى JSON. والرقمان متقاربان جداً، 1493.66 و1501.08، وهذا يعني أن الإنتاجية ثابتة.
- الزمن الأعلى مختلف كثيراً: 898 ميلي ثانية في التشغيل الأول و254 في الثاني، والمتوسط 42 مقابل 35. والتشغيل الأول هو أول ضغط على الخدمة بعد البدء، فالمحرك V8 ما زال يترجم الدوال الساخنة، ومجموعة الاتصالات تفتح اتصالاتها العشرة، وهذا ما يسمى الإحماء Warm-up، ولهذا نشغل wrk مرتين.
- الذاكرة صغيرة: 34 ميجابايت خاملة، و46.5 بعد 45 ألف طلب، وهي بعيدة جداً عن حد 512، وعن حد الـ Heap الذي يحسبه Node.js من ذاكرة الـ Container كما قسنا في مقال الأنواع والذاكرة، وهو 268 ميجابايت.
- البدء ثلاث ثوان تقريباً، وهذا رقم مرتفع، والسبب الأكبر هو الجهاز وليس الخدمة: في نفس الساعة احتاج Node.js الفارغ
node -e 0داخل نفس الـ Image بين 304 و583 ميلي ثانية، واستيراد Fastify وpgوحده بين 836 و1415 ميلي ثانية، مع حصة نواتين وجهاز مشغول. فخذ الرقم للمقارنة بين اللغات على نفس الجهاز، وليس كرقم تتوقعه على سيرفرك. - من أين تأتي 34 ميجابايت؟ Node.js الفارغ نفسه يحجز ذاكرة للمحرك V8 وللكود المترجم وللمكتبات القياسية، ثم Fastify و
pgوكودنا، ومجموعة الاتصالات بقاعدة البيانات، وكلها تقريباً ثابتة لا تتغير مع عدد الطلبات، أما الزيادة إلى 46.5 بعد الضغط فهي كائنات مؤقتة للطلبات لم يجمعها الجامع بعد، وكود ترجمه V8 أثناء الضغط. وهذا الرقم هو ما سوف تقارنه مع اللغات الأخرى، حيث تختلف الصورة كثيراً بين لغة تترجم إلى ملف تنفيذي واحد ولغة تحمل آلة افتراضية كاملة. - ما لا تقوله الأرقام: wrk يعمل على نفس الجهاز بلا حد، فهو ينافس الخدمة وقاعدة البيانات على نفس الأنوية، والطلب نفسه بسيط جداً. فهذا ليس اختبار ضغط لخدمة حقيقية، والطريقة الصحيحة لذلك شرحناها في مقال اختبار الضغط Load Testing.
ماذا يحدث إذا غيرنا حجم مجموعة الاتصالات Pool Size؟
السؤال الذي يتكرر عند كل خدمة جديدة: كم اتصالاً بقاعدة البيانات نحتاج؟ والحدس يقول «أكثر أفضل»، فجربنا نفس القياس بثلاثة أحجام للمجموعة عبر متغير البيئة PG_POOL_MAX، مع إحماء wrk لعشر ثوان قبل كل قياس، ثم عددنا الاتصالات المفتوحة من جهة PostgreSQL بالاستعلام pg_stat_activity:
== PG_POOL_MAX=2
Latency 67.70ms 28.13ms 212.14ms 70.80%
Requests/sec: 739.86
3
== PG_POOL_MAX=10
Latency 57.79ms 45.48ms 646.86ms 87.71%
Requests/sec: 935.23
11
== PG_POOL_MAX=25
Latency 67.80ms 96.76ms 1.33s 96.30%
Requests/sec: 905.81
26والرقم الأخير في كل قسم هو عدد الاتصالات، وهو حجم المجموعة زائد اتصال psql نفسه الذي سأل. ولاحظ أولاً أن الإنتاجية هنا حوالي 900 طلب في الثانية وليست 1500 كما في القياس الرئيسي، لأن الجهاز كان أكثر انشغالاً في هذه الساعة، وهذا تذكير آخر بأن نقارن الأرقام داخل نفس التشغيل فقط. وداخل هذا التشغيل: اتصالان فقط أعطيا 740 طلباً في الثانية، فالطلبات تنتظر دورها في طابور المكتبة، وعشرة أعطت 935، وخمسة وعشرون لم تعط شيئاً إضافياً، 906، بل صار أعلى زمن 1.33 ثانية بدلاً من 647 ميلي ثانية. والسبب أن الخدمة محدودة بنواتين، وعندما تصبح الخدمة نفسها هي الحد فإن مزيداً من الاتصالات يعني مزيداً من الطلبات المفتوحة في نفس الوقت تتنافس على نفس المعالج، ومزيداً من العمل على PostgreSQL، دون أي فائدة. وهذا يطابق ما تقوله قاعدة شائعة في ضبط PostgreSQL: المجموعة الصغيرة المشغولة أفضل من المجموعة الكبيرة المنتظرة، والرقم الصحيح يأتي من القياس على جهازك، وليس من ضرب عدد المستخدمين في شيء.
ما الذي ينقص هذه الخدمة قبل الإنتاج؟ Production Readiness
الخدمة في هذا المقال كاملة من حيث المواصفات المشتركة، ولكنها ليست جاهزة للإنتاج كما هي، ومن المفيد أن تعرف ما ينقصها حتى لا تنسخها وتنشرها. فالسجلات Logs في Fastify تأتي من مكتبة pino بصيغة JSON سطراً لكل حدث، مثل السطر الذي طبعته الخدمة عند البدء:
{"level":30,"time":1793436614858,"pid":1,"hostname":"6b1864d758bb","msg":"Server listening at http://127.0.0.1:3000"}وهذا الشكل مناسب لأدوات جمع السجلات لأن كل حقل قابل للبحث، والحقل time بالميلي ثانية منذ 1970 وليس نصاً مقروءاً، والرقم 30 يعني مستوى info. وFastify يضيف لكل طلب معرفاً reqId يظهر في كل سطر يخصه، فتتبع طلباً واحداً بين مئات الأسطر. وما ينقص بعد ذلك قائمة معروفة:
- فحص صحة Health Check منفصل، مثل
GET /healthيسأل قاعدة البياناتSELECT 1، يستخدمه الموزع وKubernetes لمعرفة متى تكون النسخة جاهزة. - المصادقة Authentication والصلاحيات، فالآن أي أحد يستطيع أن يطلب وأن يقرأ أي طلب برقمه.
- حد الطلبات Rate Limit لكل عميل، حتى لا يستطيع عميل واحد أن يستهلك كل الاتصالات العشرة.
- مفتاح عدم التكرار Idempotency Key على
POST /orders، لأن العميل الذي انقطع اتصاله سوف يعيد الطلب، ولا تريد أن تبيعه مرتين. - أداة ترحيل Migrations لمخطط قاعدة البيانات بدلاً من
schema.sqlواحد، فالمخطط سوف يتغير، ويجب أن يتغير بنفس الطريقة في كل البيئات. - مهلة على الاستعلامات
statement_timeoutوعلى انتظار اتصال من المجموعةconnectionTimeoutMillis، فلا يبقى طلب معلقاً إلى الأبد كما رأينا مع الجمود في مقال التزامن.
وكل بند من هذه له مقال أو أكثر في مسارات أخرى على الموقع، والمهم هنا أن تعرف أن هيكل الخدمة لا يتغير عند إضافتها: إضافات Plugins في Fastify تسجل قبل المسارات، والمنطق في orders.ts يبقى كما هو.
أخطاء شائعة Common Mistakes في خدمات Node.js
pool.query('BEGIN')ثمpool.queryللباقي: كل استعلام قد يذهب إلى اتصال مختلف، فالمعاملة ليست معاملة. خذ اتصالاً واحداً بـpool.connect().- نسيان
release()في مسار خطأ: المجموعة تفرغ بعد عشرة أخطاء وتتوقف الخدمة بصمت. ضعه فيfinally. - فحص المخزون ثم الكتابة بدون قفل: عشرون طلباً نجحت من عشرة في تجربة
NO_LOCK. - قفل الصفوف بترتيب ورودها في الطلب: جمود بين طلبين بنفس المنتجات بترتيب مختلف، فرتب المعرفات.
- الثقة بالإعدادات الافتراضية للتحقق: Fastify يحول الأنواع ويحذف الحقول الزائدة بصمت، فقرر ما تريده صراحة واختبره.
- المبالغ كأرقام عشرية أو أعمدة
numericبلا انتباه:pgيرجعnumericوbigintكنصوص كما في توثيق الأنواع في node-postgres، فإذا جمعتها كما هي حصلت على دمج نصوص. CMD ["npm", "start"]: الإشارة لا تصل إلى Node.js بشكل موثوق، والطلبات تقطع عند كل نشر.- الاستماع على
127.0.0.1داخل الـ Container: الخدمة تعمل ولا يصل إليها أحد. - اختبار المعاملات على محاكاة: الـ Mock لا يعرف شيئاً عن
FOR UPDATE، واختبار التوازي يحتاج PostgreSQL حقيقياً.
قائمة تحقق Checklist قبل نشر الخدمة
| البند | في هذه الخدمة |
|---|---|
| المبالغ أعداد صحيحة بالهللات، والضريبة في دالة واحدة لها اختبار | money.ts مع 3 اختبارات |
| كل كتابة متعلقة بالمال أو المخزون داخل معاملة باتصال واحد | pool.connect() مع BEGIN وCOMMIT وROLLBACK |
| قفل الصفوف بترتيب ثابت، والطرح داخل قاعدة البيانات | ORDER BY id FOR UPDATE وstock = stock - $2 |
| قيود في قاعدة البيانات كخط دفاع أخير | CHECK على السعر والمخزون والكمية |
| مخطط تحقق صارم بلا تحويل وبلا حذف صامت | coerceTypes: false وremoveAdditional: false |
| معالج أخطاء واحد يحول الأخطاء إلى رموز HTTP | 409 و400 و404 و500 |
| اختبار تكامل على PostgreSQL حقيقي مع اختبار توازي | عشرون طلباً، عشرة تنجح |
إغلاق آمن عند SIGTERM | app.close() ثم pool.end() |
| Image متعدد المراحل بمستخدم غير root | 371 MB، منها 16.5 أضفناها |
تدريب عملي
- غير المعاملة لتستخدم التحديث المشروط
UPDATE ... WHERE stock >= $2 RETURNING stockبدلاً منFOR UPDATE، وشغل اختبار التوازي، وفكر كيف تتعامل مع طلب فيه منتجان نجح الأول وفشل الثاني. - أضف إلى الاختبارات طلبين متوازيين، الأول بالمنتجين 1 ثم 3، والثاني 3 ثم 1، مئة مرة، واحذف
sortمن الكود، وانظر هل يظهر خطأ الجمود40P01من PostgreSQL. - أضف رأس
Idempotency-KeyإلىPOST /ordersوجدولاً يحفظه، حتى لا يتكرر الطلب إذا أعاد العميل الإرسال بعد انقطاع، كما شرحنا في مقال Idempotency. - شغل wrk على
POST /ordersبمنتج مخزونه مليون، وقارن الإنتاجية بـGET /products، ولاحظ أثر قفل الصف الواحد على طلبات نفس المنتج.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- خدمة الطلبات بثلاث نقاط مبنية بـ Fastify 5.12.7 و
pg8.23.1 وTypeScript 7 على Node.js 26، بحزمتين مباشرتين للإنتاج و61 حزمة في الشجرة. - المبالغ بالهللات، والضريبة على مجموع الطلب بتقريب النصف للأعلى بعملية صحيحة: 2970 ثم 446 ثم 3416، والفرق بين الضريبة على البند وعلى المجموع هللة كاملة في مثال بسيط.
- معاملة واحدة باتصال واحد، وقفل الصفوف بترتيب ثابت، والطرح داخل قاعدة البيانات: عشرون طلباً متوازياً على مخزون عشرة أعطت عشرة 201 وعشرة 409 ومخزوناً صفراً، وبدون القفل أعطت عشرين ناجحاً ومخزوناً يقول 8.
- الإعدادات الافتراضية في Fastify تحول الأنواع وتحذف الحقول الزائدة، وإطفاء التحويل كسر معاملات المسار حتى أصلحناه بمخطط نصي، واختبار التكامل هو الذي كشف ذلك.
- مجموعة الاتصالات بحجم 2 أعطت 740 طلباً في الثانية، و10 أعطت 935، و25 لم تزد شيئاً وضاعفت أعلى زمن إلى 1.33 ثانية، لأن الخدمة المحدودة بنواتين هي الحد وليس عدد الاتصالات، فالرقم الصحيح للمجموعة يأتي من القياس على جهازك وليس من التخمين.
- طريقة القياس نفسها تحتاج فحصاً: زمن البدء من خارج Docker Desktop على Windows أعطى 62 ثانية لا علاقة لها بالخدمة، ومن داخل شبكة Docker أعطى ثلاث ثوان تقريباً.
- الـ Image 371 ميجابايت منها 16.5 للخدمة، والذاكرة 34 ميجابايت خاملة و46.5 بعد الضغط، وwrk حوالي 1500 طلب في الثانية في التشغيلين، والبدء ثلاث ثوان تقريباً على جهاز مشغول.
ونفس الخدمة بنفس الاختبارات والقياس تجدها في بقية لغات المسار، والمقال التالي في مرحلة JavaScript وTypeScript يجمع الأخطاء التي يقع فيها القادمون إلى اللغة وخطة القراءة من المصادر الرسمية:
دليلالتزامن في JavaScript: الوعود Promises والعمال Workersمئة طلب HTTP نزلت من 5.6 ثانية إلى 284 ميلي ثانية بالوعود وحدها، وحساب ثقيل لم يتحرك إلا بالعمال: نموذج التزامن في Node.js 26 بقياسات حقيقية، مع سباق وجمود حقيقيين وإصلاحهما.
دليلالأنواع Types والذاكرة والأخطاء في JavaScriptلماذا ظهر في الفاتورة 29.700000000000003، ولماذا انهارت الخدمة برسالة heap out of memory، وأين يهرب الخطأ من try/catch: قيم JavaScript وذاكرتها وأخطاؤها بقياسات وتشغيلات حقيقية على Node.js 26.
دليلJavaScript لمن يعرف لغة أخرى: Node.js مع TypeScript والأدواتإذا كنت تبرمج بلغة أخرى وانتقلت إلى فريق يعمل بـ Node.js وTypeScript، فهذا يومك الأول: الإصدار الصحيح، والصيغة مقارنة بما تعرفه، وأدوات العمل وهيكل المشروع بأوامر ومخرجات حقيقية.
دليلPostgreSQL لكل شيء: متى يكفيك ومتى تحتاج غيره؟كم عملاً يستطيع PostgreSQL واحد أن يقوم به في شركتك الناشئة قبل أن تحتاج Redis أو Elasticsearch، وما الإشارة التي تقول لك إن الوقت قد حان.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وبناء الخدمة واختبارها وقياسها على Node.js v26.13.0 وFastify 5.12.7 وpg 8.23.1 وTypeScript 7.0.2 وPostgreSQL 17.11.