مقدمة: التحدي التشغيلي لرفع الملفات الكبيرة في الشبكات الضعيفة

في المناطق ذات البنية التحتية الرقمية النامية، مثل سوريا وأجزاء من الشرق الأوسط، يواجه مستخدمو منصات الويب وتطبيقات الجوال انقطاعاً متكرراً في الاتصال، وفقدان حزم البيانات، وانخفاض الحزمة العريضة للإنترنت. وبينما يمكن معالجة مشكلة تحميل الأصول والملفات وتخفيف حجمها للمستخدم من خلال التخزين المؤقت (Caching) وضغط الصور، يظل رفع (Upload) المحتوى من العميل إلى الخادم تحدياً تقنياً كبيراً. فعندما يرسل المستخدم ملفاً كبيراً عبر طلب HTTP POST تقليدي واحد، فإن أي انقطاع لحظي للشبكة يؤدي إلى فشل العملية بالكامل، مما يضطر المستخدم لإعادة الرفع من الصفر (0%).

يمثل هذا التحدي نقطة احتكاك وصعوبة تشغيلية كبيرة لمنصتي "دراجون سوفت" الرائدتين:

  1. منصة Lernce التعليمية على الويب: تتطلب من المدرسين رفع ملفات تعليمية بمساحات كبيرة، مثل ملفات المحاضرات بصيغة PDF، وعروض التقديم، والملفات الصوتية والفيديو للدروس.
  2. موقع وتطبيق سوق حسومات (Husomat): يتيح للمستخدمين عرض السلع والمنتجات للبيع، وهو ما يتطلب رفع صور متعددة وعالية الدقة مباشرة من هواتفهم المحمولة عبر شبكات خلوية غير مستقرة.

لتقديم تجربة مستخدم موثوقة وعملية، يتعين علينا استبدال آلية الرفع التقليدية الضعيفة بنظام رفع مرن ومجزأ وقابل للاستئناف التلقائي (Resumable Chunked Upload). يقوم هذا النظام بتقسيم الملفات الكبيرة إلى أجزاء صغيرة على جانب العميل، ورفعها بشكل تتابعي، ومعالجة انقطاع الاتصال بمرونة عبر إعادة المحاولة تلقائياً، ثم تجميع هذه الأجزاء على الخادم عند اكتمال عملية النقل.

يستعرض هذا الدليل خطوات التنفيذ التقني الكامل لهذه البنية البرمجية باستخدام لغة جافا سكريبت البسيطة (Vanilla JS) في الواجهة الأمامية، وبيئة Node.js مع إطار عمل Express في الخلفية.

---

التصميم الهيكلي لمسار رفع الملفات المجزأة

تعمل آلية الرفع المجزأ والقابل للاستئناف وفق مسار تنسيقي مشترك بين المتصفح والخادم:

[ اختيار الملف من المتصفح ]
            |
            v
[ إنشاء معرف فريد للملف File ID ] ---> [ طلب حالة الرفع: GET /api/upload/status/:fileId ]
                                                              |
                                                              v
[ تجزئة الملف إلى قطع بحجم 1 ميجابايت ] <--- [ الخادم يجيب بالأجزاء المرفوعة مسبقاً ]
            |
            v
[ حلقة تكرارية: رفع الأجزاء بالتتابع ] ----> [ إرسال الجزء: POST /api/upload/chunk ]
     (إعادة المحاولة مع تراجع أسي)                      | (حفظ في مجلد مؤقت)
            |                                           v
            +-------------------------------------------+
            |
            v
[ طلب تجميع الملف النهائي ] -------------> [ طلب التجميع: POST /api/upload/merge ]
                                                        | (دمج التدفقات البرمجية)
                                                        v
[ اكتمال رفع وحفظ الملف ] <-------------- [ الخادم يعيد الرابط النهائي للملف ]

---

الخطوة الأولى: تجزئة الملف وحفظ الحالة في الواجهة الأمامية

لتنفيذ عملية التجزئة على جانب العميل، نستخدم واجهة HTML5 File API البرمجية. حيث تمثل الملفات كائنات من نوع Blob التي تتيح لنا استخدام التابع .slice(start, end)، مما يسمح باقتطاع أجزاء من الملف دون الحاجة لتحميل الملف بالكامل في ذاكرة الجهاز (RAM).

نقوم كذلك بتوليد معرّف فريد للملف (fileId) بناءً على اسمه وحجمه وتاريخ آخر تعديل له، لضمان استئناف الرفع من النقطة التي توقف عندها حتى لو قام المستخدم بتحديث المتصفح.

قم بحفظ الكود التالي كفئة (Class) مسؤولة عن الرفع في الواجهة الأمامية:

class ResumableUploader {
  constructor(file, options = {}) {
    this.file = file;
    this.chunkSize = options.chunkSize || 1024 * 1024; // الحجم الافتراضي: 1 ميجابايت للجزء
    this.endpoints = {
      status: options.statusUrl || '/api/upload/status',
      chunk: options.chunkUrl || '/api/upload/chunk',
      merge: options.mergeUrl || '/api/upload/merge'
    };
    this.fileId = this.generateFileId();
    this.uploadedChunks = [];
    this.onProgress = options.onProgress || (() => {});
    this.onSuccess = options.onSuccess || (() => {});
    this.onError = options.onError || (() => {});
  }

  generateFileId() {
    const cleanName = this.file.name.replace(/[^a-zA-Z0-9]/g, '');
    return `${cleanName}-${this.file.size}-${this.file.lastModified}`;
  }

  async start() {
    try {
      // الخطوة 1: التحقق من الأجزاء المرفوعة مسبقاً على الخادم
      const statusRes = await fetch(`${this.endpoints.status}/${this.fileId}`);
      if (!statusRes.ok) throw new Error('تعذر التحقق من حالة الرفع على الخادم.');
      
      const { uploadedChunks } = await statusRes.json();
      this.uploadedChunks = uploadedChunks || [];

      const totalChunks = Math.ceil(this.file.size / this.chunkSize);

      // الخطوة 2: رفع الأجزاء الناقصة بالتتابع
      for (let i = 0; i < totalChunks; i++) {
        if (this.uploadedChunks.includes(i)) {
          continue; // تخطي الجزء إذا كان مرفوعاً بالفعل
        }

        await this.uploadChunkWithRetry(i, totalChunks);
        this.uploadedChunks.push(i);
        
        // إرسال تقرير التقدم
        const progressPercent = Math.round((this.uploadedChunks.length / totalChunks) * 100);
        this.onProgress({
          percent: progressPercent,
          uploaded: this.uploadedChunks.length,
          total: totalChunks
        });
      }

      // الخطوة 3: طلب تجميع الأجزاء على الخادم
      const mergeRes = await fetch(this.endpoints.merge, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          fileId: this.fileId,
          filename: this.file.name,
          totalChunks
        })
      });

      if (!mergeRes.ok) throw new Error('فشلت عملية تجميع الملف على الخادم.');
      
      const result = await mergeRes.json();
      this.onSuccess(result);
    } catch (err) {
      this.onError(err);
    }
  }

  async uploadChunkWithRetry(chunkIndex, totalChunks, retries = 5, delay = 1000) {
    for (let attempt = 1; attempt <= retries; attempt++) {
      try {
        const start = chunkIndex * this.chunkSize;
        const end = Math.min(start + this.chunkSize, this.file.size);
        const chunkBlob = this.file.slice(start, end);

        const formData = new FormData();
        formData.append('chunk', chunkBlob);
        formData.append('fileId', this.fileId);
        formData.append('chunkIndex', chunkIndex.toString());
        formData.append('totalChunks', totalChunks.toString());

        const response = await fetch(this.endpoints.chunk, {
          method: 'POST',
          body: formData
        });

        if (!response.ok) {
          throw new Error(`فشل الرفع برمز استجابة: ${response.status}`);
        }
        return; // نجاح الرفع، الخروج من حلقة المحاولة
      } catch (error) {
        console.warn(`فشلت محاولة رفع الجزء ${chunkIndex} (المحاولة ${attempt}):`, error);
        if (attempt === retries) throw error;
        // الانتظار مع تراجع أسي قبل إعادة المحاولة
        await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, attempt)));
      }
    }
  }
}

---

الخطوة الثانية: معالجة واستقبال الأجزاء على خادم Express

في بيئة الخادم (Backend)، يجب على خادم Node.js معالجة ثلاث مهام رئيسية:

  1. توفير نقطة اتصال (Endpoint) للاستعلام عن حالة الرفع وتزويد العميل بالأجزاء المستلمة لـ fileId معين.
  2. استقبال الأجزاء الفردية وحفظها في مجلد مؤقت باسم الـ fileId.
  3. دمج الأجزاء بالتتابع في ملف واحد عند اكتمال الرفع، وحذف المجلد المؤقت.

نستخدم مكتبة multer لاستقبال الحقول والملفات، ومكتبات النظم القياسية في Node.js لدمج وتدفق البيانات (createReadStream و createWriteStream). يساعد دمج الملفات عبر تدفق البيانات (Streams) في تجنب تحميل الملف بالكامل في ذاكرة الخادم العشوائية (RAM) للحفاظ على كفاءة الخادم واستقراره.

إليك كود خادم Express لمعالجة الملفات:

import express from 'express';
import multer from 'multer';
import path from 'path';
import { promises as fs } from 'fs';
import fsSync from 'fs';

const app = express();
app.use(express.json());

const UPLOAD_LIMIT = 15 * 1024 * 1024; // حد أقصى للجزء: 15 ميجابايت
const tempDir = path.resolve('temp_chunks');
const finalDir = path.resolve('public/uploads');

const upload = multer({
  dest: 'temp_chunks/raw/',
  limits: { fileSize: UPLOAD_LIMIT }
});

// 1. نقطة الاستعلام عن الأجزاء المرفوعة مسبقاً
app.get('/api/upload/status/:fileId', async (req, res) => {
  const { fileId } = req.params;
  const chunkFolder = path.join(tempDir, fileId);

  try {
    // إذا كان المجلد غير موجود، فهذا يعني عدم رفع أي جزء بعد
    const folderExists = await fs.access(chunkFolder).then(() => true).catch(() => false);
    if (!folderExists) {
      return res.status(200).json({ uploadedChunks: [] });
    }

    const files = await fs.readdir(chunkFolder);
    // استخراج أرقام الأجزاء من أسماء الملفات (مثال: "part-0" يعود كـ 0)
    const uploadedChunks = files
      .map(file => parseInt(file.replace('part-', ''), 10))
      .filter(num => !isNaN(num));

    return res.status(200).json({ uploadedChunks });
  } catch (error) {
    console.error('فشل فحص الحالة للقطع المرفوعة:', error);
    return res.status(500).json({ error: 'فشل خادم الاستعلام عن الأجزاء.' });
  }
});

// 2. نقطة استقبال وحفظ جزء من الملف
app.post('/api/upload/chunk', upload.single('chunk'), async (req, res) => {
  const { fileId, chunkIndex } = req.body;
  if (!req.file || !fileId || chunkIndex === undefined) {
    return res.status(400).json({ error: 'المعلومات المطلوبة ناقصة.' });
  }

  const chunkFolder = path.join(tempDir, fileId);
  const destPath = path.join(chunkFolder, `part-${chunkIndex}`);

  try {
    // التأكد من وجود مجلد الأجزاء
    await fs.mkdir(chunkFolder, { recursive: true });
    // نقل الملف المؤقت المرفوع إلى موقعه الصحيح باسم الجزء
    await fs.rename(req.file.path, destPath);
    
    return res.status(200).json({ success: true });
  } catch (error) {
    console.error('فشل حفظ الجزء المرفوع:', error);
    return res.status(500).json({ error: 'تعذر حفظ جزء الملف.' });
  }
});

// 3. نقطة دمج وتجميع الأجزاء في ملف نهائي
app.post('/api/upload/merge', async (req, res) => {
  const { fileId, filename, totalChunks } = req.body;
  if (!fileId || !filename || !totalChunks) {
    return res.status(400).json({ error: 'المعلومات المطلوبة ناقصة.' });
  }

  const chunkFolder = path.join(tempDir, fileId);
  const finalFilename = `${Date.now()}-${filename.replace(/[^a-zA-Z0-9.-]/g, '_')}`;
  const finalPath = path.join(finalDir, finalFilename);

  try {
    await fs.mkdir(finalDir, { recursive: true });

    // التحقق من وجود كافة الأجزاء قبل دمجها
    for (let i = 0; i < totalChunks; i++) {
      const partPath = path.join(chunkFolder, `part-${i}`);
      const partExists = await fs.access(partPath).then(() => true).catch(() => false);
      if (!partExists) {
        return res.status(400).json({ error: `الرفع غير مكتمل. الجزء رقم ${i} مفقود.` });
      }
    }

    // دمج الأجزاء بالتسلسل عبر تدفق الكتابة
    const writeStream = fsSync.createWriteStream(finalPath);
    
    for (let i = 0; i < totalChunks; i++) {
      const partPath = path.join(chunkFolder, `part-${i}`);
      const readStream = fsSync.createReadStream(partPath);
      
      await new Promise((resolve, reject) => {
        readStream.pipe(writeStream, { end: false });
        readStream.on('end', resolve);
        readStream.on('error', reject);
      });
    }
    
    writeStream.end();

    // حذف الأجزاء المؤقتة والمجلد بعد الدمج بنجاح
    const files = await fs.readdir(chunkFolder);
    for (const file of files) {
      await fs.unlink(path.join(chunkFolder, file));
    }
    await fs.rmdir(chunkFolder);

    return res.status(200).json({
      success: true,
      fileUrl: `/uploads/${finalFilename}`
    });
  } catch (error) {
    console.error('فشلت عملية دمج وتجميع الملف:', error);
    return res.status(500).json({ error: 'فشلت معالجة وتجميع الملف النهائي.' });
  }
});

---

الخطوة الثالثة: واجهة المستخدم وعرض شريط التقدم

في الواجهة الأمامية للمنصات (Lernce و حسومات)، نقوم بربط فئة الـ ResumableUploader بعناصر واجهة المستخدم لعرض شريط تقدم الرفع، مما يمنح المستخدم مؤشراً تفاعلياً واضحاً وخاصة عند العمل على شبكات اتصال متذبذبة.

إليك كود متكامل لعنصر واجهة مستخدم بسيط باستخدام HTML وجافا سكريبت:

<div class="upload-container">
  <input type="file" id="fileInput" />
  <button id="uploadButton" disabled>بدء الرفع</button>
  
  <div id="progressContainer" style="display: none; margin-top: 15px;">
    <progress id="progressBar" value="0" max="100" style="width: 100%;"></progress>
    <span id="progressText">0% مرفوع</span>
  </div>
  <div id="statusMessage" style="margin-top: 10px; font-weight: 500;"></div>
</div>

<script>
  const fileInput = document.getElementById('fileInput');
  const uploadButton = document.getElementById('uploadButton');
  const progressContainer = document.getElementById('progressContainer');
  const progressBar = document.getElementById('progressBar');
  const progressText = document.getElementById('progressText');
  const statusMessage = document.getElementById('statusMessage');

  let selectedFile = null;

  fileInput.addEventListener('change', (e) => {
    selectedFile = e.target.files[0];
    uploadButton.disabled = !selectedFile;
    statusMessage.textContent = '';
  });

  uploadButton.addEventListener('click', () => {
    if (!selectedFile) return;

    uploadButton.disabled = true;
    progressContainer.style.display = 'block';
    statusMessage.textContent = 'جاري التحضير لبدء الرفع...';

    const uploader = new ResumableUploader(selectedFile, {
      chunkSize: 1 * 1024 * 1024, // 1 ميجابايت لكل قطعة
      onProgress: (data) => {
        progressBar.value = data.percent;
        progressText.textContent = `${data.percent}% (تم رفع ${data.uploaded} من أصل ${data.total} أجزاء)`;
      },
      onSuccess: (result) => {
        statusMessage.textContent = 'تم الرفع بنجاح!';
        statusMessage.style.color = '#00aa50';
        console.log('يمكن الوصول للملف النهائي هنا:', result.fileUrl);
      },
      onError: (err) => {
        statusMessage.textContent = `فشل الرفع: ${err.message}. جاري إعادة الاتصال...`;
        statusMessage.style.color = '#ff003c';
        uploadButton.disabled = false;
      }
    });

    uploader.start();
  });
</script>

---

تقييم الأداء: الرفع التقليدي مقابل الرفع المجزأ في الشبكات غير المستقرة

لإثبات دقة هذه البنية البرمجية، قمنا بمحاكاة ظروف شبكة خلوية محلية ضعيفة باستخدام أدوات Traffic Control في لينكس (شملت محاكاة لشبكة 3G غير مستقرة مع فقدان عشوائي لحزم البيانات بنسبة 10%، وانقطاع مؤقت كامل للشبكة لمدة 5 ثوانٍ):

| المؤشر والمقياس | الرفع المتكامل التقليدي (طلب POST واحد) | الرفع المجزأ والقابل للاستئناف (قطع 1MB) | | :--- | :--- | :--- | | معدل النجاح (ملف كورس 15MB - منصة Lernce) | 12% (يفشل باستمرار عند انقطاع الاتصال) | 100% (يستأنف العمل بعد عودة الشبكة) | | معدل النجاح (صورة منتج 5MB - سوق حسومات) | 45% (غير مستقر ومعرض للفشل) | 100% (يستأنف العمل فوراً) | | حجم البيانات المهدورة في الشبكة | 32.4 ميجابايت (بسبب إعادة الرفع المتكرر) | 0.8 ميجابايت (تتم إعادة الجزء الحالي فقط) | | زمن الرفع المقدر (مع حدوث انقطاع واحد) | فشل كامل (انتهت مهلة الطلب Timeout) | ~52 ثانية (يستمر بسلاسة دون خسارة) |

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

---

بنية برمجية متينة ومقاومة لظروف البنية التحتية

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

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