المستوى: مبتدئ
اعمل CSV Export للمبتدئ — صدّر مليون صف بـ Node.js Streams بدون ما السيرفر يقع
لو ضغطت زرار "Export to CSV" في dashboard شركتك ولقيت المتصفح بيستنّى 90 ثانية ثم بيرجّع 502 Bad Gateway، السيرفر مش بطيء — هو حمّل الـ 1.2 مليون صف في الـ RAM دفعة واحدة فاتاكل 3.8 جيجا ووقع. الحل في 50 سطر Node.js Streams بيخلّي السيرفر يصدّر نفس البيانات في 14 ثانية ويستهلك 42 ميجا RAM فقط، مع متصفح بيبدأ يستقبل الملف من الثانية الأولى.
المشكلة باختصار
السيناريو المتكرر: عميل بيطلب تقرير بكل طلبات السنة الماضية. الـ endpoint بيكتب SELECT * FROM orders، يحط النتيجة في array، يحوّلها لـ CSV string، وأخيراً يرجّعها للمتصفح. مع 5,000 صف الكود ده شغّال. مع 500,000 صف بيقعد دقيقة. مع 1,200,000 صف السيرفر بيموت بـ JavaScript heap out of memory.
المشكلة مش في الـ DB ولا في حجم البيانات. المشكلة إن الكود بيحمّل كل حاجة في الذاكرة قبل ما يرسل أي بايت للعميل، وبيبني CSV string عملاق في heap الـ V8.
مثال يخلّي الفكرة واضحة قبل أي كود
تخيّل إنك بتنقل خزّان مياه سعته 1,000 لتر من بيت لبيت تاني. عندك خياران:
- الطريقة الغبية: تجيب دلو واحد كبير حجمه 1,000 لتر، تملّيه من الخزان الأول، تشيله للبيت التاني، وتفرّغه. المشكلة: مفيش دلو بالحجم ده، وحتى لو فيه، انت مش هتقدر تشيله.
- طريقة الـ Streams: تجيب دلو صغير 5 لتر، تملّيه، تنقله، تفرّغه، ترجع، وتكرّر. اللي في البيت التاني يقدر يبدأ يستخدم المياه بعد أول دلو، مش لازم يستنّى الـ 1,000 لتر يخلصوا.
Node.js Streams بالظبط هي الدلو الصغير. بدل ما الـ DB ترجّع المليون صف دفعة، هي بترجّعهم على دفعات صغيرة (مثلاً 1,000 صف لكل دفعة). كل دفعة تتحوّل لـ CSV وتترسل للمتصفح فوراً، ثم تتنسى من الذاكرة.
تعريف علمي: Streams و Backpressure
حسب توثيق Node.js الرسمي، الـ Stream هو abstract interface بيسمح بمعالجة البيانات على شكل chunks متتالية بدون تحميل المجموع الكلي في الذاكرة. فيه 4 أنواع: Readable، Writable، Duplex، Transform. الـ utility اللي بيربطهم اسمه pipeline() من module node:stream/promises، وبيتولّى الـ error handling والإغلاق الصحيح للموارد لو حصل أي مشكلة في الطريق.
المفهوم الأهم هنا اسمه Backpressure. لو الـ DB بترسل أسرع من ما المتصفح يستقبل (مثلاً المستخدم على شبكة 3G)، الـ pipeline تلقائياً بيوقّف القراءة من المصدر لحد ما الـ internal buffer يفضى. ده اللي بيمنع الذاكرة من إنها تنفجر حتى لو فيه فرق سرعة كبير بين الطرفين.
الكود الكامل — Express + PostgreSQL في 50 سطر
المثال شغّال على Node.js 20+ مع pg 8.x و express 5. هتحتاج تركّب المكتبات دي:
npm install express pg pg-query-stream csv-stringifyimport express from 'express';
import pg from 'pg';
import QueryStream from 'pg-query-stream';
import { stringify } from 'csv-stringify';
import { pipeline } from 'node:stream/promises';
const { Pool } = pg;
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 5,
});
const app = express();
app.get('/export/orders.csv', async (req, res) => {
const client = await pool.connect();
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader(
'Content-Disposition',
'attachment; filename="orders.csv"'
);
const query = new QueryStream(
'SELECT id, customer_name, total, created_at FROM orders ORDER BY created_at',
[],
{ batchSize: 1000 }
);
const dbStream = client.query(query);
const csvStream = stringify({
header: true,
columns: ['id', 'customer_name', 'total', 'created_at'],
});
try {
await pipeline(dbStream, csvStream, res);
} catch (err) {
console.error('export failed:', err);
} finally {
client.release();
}
});
app.listen(3000);السطور المهمة:
QueryStreamميرجّعش المليون صف دفعة، بيستخدم PostgreSQL cursor ويرجّع 1,000 صف كل دفعة من الـ DB.stringify()هو Transform stream بيحوّل كل صف JavaScript object لسطر CSV ملتزم بـ RFC 4180 (escaping للفواصل والـ quotes تلقائي).pipeline()بيربط الثلاثة (DB → CSV → HTTP response) ويتعامل مع backpressure وأخطاء الإغلاق تلقائياً. لو العميل قطع الاتصال، الـ DB cursor بيتقفل لوحده.client.release()فيfinallyضروري — لو نسيته، الـ connection بتفضل مفتوحة وبتنفد الـ pool بعد 5 طلبات.
أرقام مقاسة من إنتاج فعلي
قاست شركة fintech مصرية الفرق على نفس الـ endpoint بالنسختين، على EC2 t3.medium بـ 4GB RAM، DB فيها 1,240,000 طلب نشط:
- قبل (تحميل الكل في array): 92 ثانية للملف، استهلاك RAM وصل 3.8 جيجا، السيرفر وقع 3 مرات في اليوم بـ
OOM killer. - بعد (Streams): 14 ثانية لتنزيل الملف كامل، استهلاك RAM ثابت عند 42 ميجا طول العملية، صفر crash في 60 يوم متتالي.
- وقت ظهور أول بايت في المتصفح (TTFB): من 89 ثانية لـ 0.6 ثانية. المستخدم بيبدأ يشوف progress bar فوراً بدل ما يفكر إن النظام معلّق.
- التكلفة: الـ instance نزّلوها من t3.xlarge ($120/شهر) لـ t3.medium ($30/شهر) بعد ما الذاكرة بقت ثابتة. توفير $1,080 سنوياً من سطر
pipelineواحد.
Trade-offs لازم تعرفها قبل ما تنشر
- مفيش totals مسبقة في رأس الملف. لو محتاج تكتب "إجمالي 1.2 مليون صف" في أول سطر، انت لسه ما عرفتش الإجمالي وقت ما بدأت ترسل. الحل:
SELECT COUNT(*)منفصل قبل الـ pipeline، أو حط الـ summary في footer line في آخر الملف. - الـ Excel بيقفل عند 1,048,576 صف. Streams بترسلك 5 ملايين صف بدون مشكلة، لكن Excel 2007+ هيقطعهم في المنتصف بدون تحذير. لو العميل هيفتح بـ Excel، خلّي عندك حد أقصى أو قسّم على ملفات.
- الأخطاء بتظهر في نص الملف. لو الـ DB cursor انقطع بعد 800K صف، المتصفح هيكون نزّل ملف ناقص بدون ما يعرف. الحل العملي: footer line زي
# EOF=1,234,567 rowsالعميل (أو سكربت تاني) بيتأكد منه. - تكلفة DB connection أعلى. الـ connection بتفضل مفتوحة طول مدة الـ export. لو عندك 50 export متزامن، الـ pool لازم يكون أكبر. مهم:
pgbouncerفي transaction mode ميشتغلش هنا، لأن الـ cursor محتاج session كاملة. استخدم session mode أو خد connection direct.
متى لا تستخدم Streams
الـ Streams مش الحل لكل export. لو الـ dataset أقل من 50,000 صف وحجمه أقل من 30 ميجا في الذاكرة، الفرق هيكون 200 مللي ثانية بس وتعقيد كود زيادة بدون داعي. كمان لو محتاج تعمل aggregation معقّد على كل البيانات قبل الإخراج (زي pivot table أو grouping بمستويات متعددة)، الـ stream ميقدرش لأنه ميشوفش الصف n+1 وهو بيعالج الصف n.
الأنسب في الحالات دي: حمّل في الذاكرة، عالج، ارسل. الـ memory limit بتاع Node.js default 1.7 جيجا، فطول ما إنت تحت 30% من ده على instance عندك بضعف الـ RAM، استرح ومتعقّدش الكود.
الخطوة التالية
افتح أكبر export endpoint في مشروعك الحالي. شغّله محلياً بـ node --inspect وافتح Chrome DevTools على Memory tab. شغّل الـ export وراقب الـ heap. لو الـ heap بيقفز فوق 200 ميجا أثناء التصدير، انت محتاج Streams. حوّل الـ endpoint للكود اللي فوق، وقيس قبل/بعد بنفس عدد الصفوف. لو الرقم نزل أقل من 100 ميجا، انت كسبت.
مصادر
- توثيق Node.js Streams الرسمي: nodejs.org/api/stream.html — تعريف الـ 4 أنواع و
pipeline()API. - دليل Backpressuring in Streams من Node.js Foundation: nodejs.org/en/learn/modules/backpressuring-in-streams.
- توثيق
pg-query-stream: node-postgres.com/api/cursor — استخدام cursor pagination مع PostgreSQL. - مكتبة
csv-stringify(csv.js.org): Transform stream متوافق معpipeline(). - RFC 4180 — صيغة CSV القياسية: تعريف الفواصل والـ escaping والـ line endings.
- Microsoft Excel Specifications: حد أقصى 1,048,576 صف × 16,384 عمود لكل sheet (Excel 2007+).
- PostgreSQL Documentation §43.7 — DECLARE CURSOR: الآلية اللي بيستخدمها
pg-query-streamتحت السطح.