واقع طلبات الويب عبر الاتصالات غير المستقرة
في عالم تطوير الويب الحديث، يفترض المبرمجون غالباً أن طلبات الشبكة ثنائية الحالة: إما أن تنجح فوراً أو تفشل مع رمز حالة HTTP واضح. ومع ذلك، فإن بناء تطبيقات ويب موجهة للسوق السورية—أو أي سوق ذي بنية تحتية نامية—يتطلب أنماطاً برمجية تتكيف مع أوقات الاستجابة الطويلة (Latency)، والانقطاعات المتكررة، وإعادة تشغيل بوابات الـ ADSL، والانتقال غير السلس بين شبكات الخليوي الضعيفة (3G/4G) وشبكات الواي فاي المحلية.
إذا اعتمد تطبيق الويب بالكامل على طلبات fetch() المباشرة فقط، فسيواجه المستخدمون حتماً المشاكل التالية:
- فقدان البيانات: تختفي بيانات النماذج غير المكتملة، أو المسودات، أو الفواتير عند انقطاع الاتصال أثناء الإرسال.
- تجربة مستخدم سيئة: ظهور مؤشرات تحميل لا نهائية تنتهي بأخطاء مهلة الاتصال (Timeout)، مما يجبر المستخدم على إعادة إدخال بياناته مجدداً.
- عدم اتساق بيانات السيرفر: حدوث طلبات مكررة بسبب محاولات إعادة الإرسال العشوائية من جهة العميل عندما ينجح السيرفر في معالجة الطلب ولكن يفشل العميل في استقبال رد التأكيد.
لحل هذه المشكلة، يمكن للمطورين تطبيق طابور مزامنة محلي (Offline Sync Queue). بدلاً من إرسال إجراءات المستخدم مباشرة إلى السيرفر، يقوم التطبيق بكتابة الطلب في قاعدة بيانات محلية ومستمرة أولاً داخل جهاز المستخدم، ثم يتولى مدير مزامنة يعمل في الخلفية معالجة هذا الطابور وإرساله عند كشف اتصال شبكة مستقر.
فيما يلي دليل تقني خطوة بخطوة لبناء طابور مزامنة محلي مرن باستخدام لغة JavaScript القياسية.
---
معمارية طابور المزامنة
يعتمد محرك المزامنة على نموذج التخزين المؤقت أولاً محلياً:
+-------------------------+
| حفظ المستخدم للبيانات |
+-------------------------+
|
v
+-------------------------+
| هل يوجد اتصال بالشبكة؟ |
+-------------------------+
/ \
نعم / \ لا / فشل الطلب
v v
+-----------------------+ +-------------------------------+
| إرسال فوري عبر | | حفظ في طابور IndexedDB |
| طلب HTTP fetch() عادي | | (منهج الطلب، الرابط، البيانات)|
+-----------------------+ +-------------------------------+
| |
v (نجاح) v
[انتهى] +-------------------------------+
| مراقبة الشبكة / إطلاق |
| حلقة المزامنة في الخلفية |
+-------------------------------+
|
v
+-------------------------------+
| معالجة طلبات الطابور مع منطق |
| التراجع الأسّي وإعادة المحاولة |
+-------------------------------+
|
v (نجاح)
+-------------------------------+
| حذف الطلب من IndexedDB |
+-------------------------------+
سنستخدم IndexedDB للتخزين المحلي لأنها مدعومة في جميع المتصفحات الحديثة، وتوفر استعلامات غير متزامنة لا تتسبب في تجميد واجهة المستخدم، كما تتيح تخزين ميجابايتات من البيانات المهيكلة دون قيود المساحة الضيقة لـ localStorage.
---
الخطوة 1: إعداد مخزن البيانات باستخدام IndexedDB
أولاً، نحتاج إلى فئة (Class) برمجية بسيطة تعتمد على الوعود (Promises) لإدارة قاعدة بيانات IndexedDB. ستقوم قاعدة البيانات هذه بحفظ طلبات المزامنة المعلقة.
class SyncStore {
constructor(dbName = 'OfflineSyncDB', storeName = 'requestQueue') {
this.dbName = dbName;
this.storeName = storeName;
this.db = null;
}
async init() {
if (this.db) return this.db;
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(this.storeName)) {
// استخدام مفتاح تلقائي الزيادة للحفاظ على ترتيب الإدخال
db.createObjectStore(this.storeName, { keyPath: 'id', autoIncrement: true });
}
};
request.onsuccess = (event) => {
this.db = event.target.result;
resolve(this.db);
};
request.onerror = (event) => {
reject(event.target.error);
};
});
}
async addRequest(method, url, body, headers = {}) {
const db = await this.init();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const requestItem = {
method,
url,
body: body instanceof Object ? JSON.stringify(body) : body,
headers,
timestamp: Date.now(),
attempts: 0
};
const addOp = store.add(requestItem);
addOp.onsuccess = () => resolve(addOp.result);
addOp.onerror = (event) => reject(event.target.error);
});
}
async getAllRequests() {
const db = await this.init();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const getOp = store.getAll();
getOp.onsuccess = () => resolve(getOp.result);
getOp.onerror = (event) => reject(event.target.error);
});
}
async deleteRequest(id) {
const db = await this.init();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const deleteOp = store.delete(id);
deleteOp.onsuccess = () => resolve();
deleteOp.onerror = (event) => reject(event.target.error);
});
}
}
const syncStore = new SyncStore();
---
الخطوة 2: إضافة الطلبات إلى طابور الانتظار
بدلاً من استدعاء fetch() مباشرة في كامل أجزاء الكود الخاص بك، مرر كافة طلبات تعديل البيانات (مثل POST و PUT و DELETE) عبر دالة تغليف مخصصة. تقوم هذه الدالة بالتحقق من حالة الاتصال والتعامل مع فشل الإرسال المباشر.
async function executeRequest(url, options = {}) {
const method = options.method || 'GET';
const headers = options.headers || {};
const body = options.body || null;
// 1. إذا كان الجهاز غير متصل بالإنترنت، أضف الطلب للطابور فوراً
if (!navigator.onLine) {
if (method !== 'GET') {
await syncStore.addRequest(method, url, body, headers);
triggerBackgroundSync();
return { offline: true, queued: true, message: 'حُفظ أوفلاين. ستتم المزامنة لاحقاً.' };
}
throw new Error('أنت غير متصل بالإنترنت حالياً.');
}
// 2. إذا كان متصلاً، حاول إرسال الطلب
try {
const response = await fetch(url, options);
// التحقق من وجود مشاكل على السيرفر
if (!response.ok && response.status >= 500) {
throw new Error(`خطأ في السيرفر: ${response.status}`);
}
return response;
} catch (error) {
// 3. التقاط أخطاء الشبكة والمهلة وإضافة طلبات التعديل للطابور
if (method !== 'GET') {
console.warn('فشل طلب الشبكة. يتم الإضافة لطابور المزامنة...', error);
await syncStore.addRequest(method, url, body, headers);
triggerBackgroundSync();
return { offline: true, queued: true, message: 'خطأ في الشبكة. تم الحفظ محلياً.' };
}
throw error;
}
}
---
الخطوة 3: معالجة المزامنة والتراجع الأسّي (Exponential Backoff)
عندما تكون الاتصالات ضعيفة أو غير مستقرة، فإن محاولة إعادة الإرسال المتكررة فوراً بدون توقف ستؤدي إلى استهلاك بطارية جهاز العميل وتزيد من ازدحام الشبكة المتدهورة بالفعل. يجب علينا تطبيق خوارزمية التراجع الأسّي مع إضافة عامل عشوائي (Jitter) لتباعد فترات إعادة المحاولة.
let isSyncing = false;
let syncTimeoutId = null;
async function triggerBackgroundSync() {
if (isSyncing) return;
if (syncTimeoutId) {
clearTimeout(syncTimeoutId);
}
// جدولة بدء المزامنة
syncTimeoutId = setTimeout(() => {
runSyncLoop();
}, 1000);
}
async function runSyncLoop() {
if (isSyncing || !navigator.onLine) return;
isSyncing = true;
try {
const requests = await syncStore.getAllRequests();
if (requests.length === 0) {
isSyncing = false;
return;
}
console.log(`بدء معالجة طابور المزامنة: ${requests.length} طلبات معلقة.`);
for (const req of requests) {
// التحقق مجدداً؛ قد ينقطع الاتصال أثناء تنفيذ الحلقة
if (!navigator.onLine) break;
let success = false;
try {
const response = await fetch(req.url, {
method: req.method,
headers: {
...req.headers,
'Content-Type': 'application/json',
'X-Offline-Sync-Id': req.id // تتبع المعاملة على السيرفر
},
body: req.body
});
if (response.ok) {
await syncStore.deleteRequest(req.id);
success = true;
console.log(`تمت مزامنة الطلب رقم #${req.id} بنجاح`);
} else if (response.status < 500) {
// إذا كان الخطأ من جانب العميل (مثل 400 طلب خاطئ، 403 غير مسموح)،
// فلن يفيد تكرار المحاولة. نحذف الطلب أو نضع عليه علامة للمراجعة.
await syncStore.deleteRequest(req.id);
console.error(`تم رفض الطلب رقم #${req.id} بسبب خطأ من العميل: ${response.status}`);
}
} catch (err) {
console.warn(`فشلت محاولة مزامنة الطلب رقم #${req.id}`, err);
}
if (!success) {
// نوقف معالجة باقي الطابور للحفاظ على الترتيب الصحيح لإرسال البيانات.
// ننتظر ونعيد جدولة المزامنة بعد فترة تراجع أسّي.
const backoffDelay = calculateBackoff(req.attempts || 0);
req.attempts = (req.attempts || 0) + 1;
console.log(`إعادة المحاولة بعد ${backoffDelay / 1000} ثانية...`);
isSyncing = false;
syncTimeoutId = setTimeout(runSyncLoop, backoffDelay);
return;
}
}
} catch (error) {
console.error('حدث خطأ أثناء تنفيذ المزامنة:', error);
} finally {
isSyncing = false;
}
}
function calculateBackoff(attempts) {
const base = 1000; // 1 ثانية
const maxDelay = 60000; // دقيقة واحدة كحد أقصى
const factor = 2;
// حساب التراجع الأسّي: 1ث، 2ث، 4ث، 8ث، 16ث...
const delay = Math.min(base * Math.pow(factor, attempts), maxDelay);
// إضافة عامل عشوائي بنسبة 0-30% لمنع تزامن الطلبات من عدة عملاء في نفس اللحظة
const jitter = Math.random() * 0.3 * delay;
return delay + jitter;
}
// الاستماع لحدث استعادة الاتصال بالشبكة تلقائياً
window.addEventListener('online', () => {
console.log('تم استعادة الاتصال بالشبكة. بدء المزامنة...');
triggerBackgroundSync();
});
---
الخطوة 4: حل تعارض البيانات
عند العمل دون اتصال، يصبح تعارض البيانات أمراً لا مفر منه. على سبيل المثال، إذا قام مندوب مبيعات ميداني بتحديث الملف الشخصي لأحد العملاء دون اتصال بالإنترنت، وقام موظف الإدارة بتعديل نفس العميل عبر المتصفح المتصل، فسيحدث تعارض عند مزامنة طابور العميل الميداني.
النهج الأسهل والأكثر كفاءة من جهة العميل هو تطبيق إستراتيجية الأحدث يفوز (Last-Write-Wins - LWW) باستخدام الطوابع الزمنية الدقيقة:
- يحتوي كل تعديل على خاصية
lastUpdatedتحدد وقت التعديل على جهاز العميل عبرDate.now(). - يقارن السيرفر الطابع الزمني للطلب القادم مع الطابع الزمني المخزن لديه بالفعل في قاعدة البيانات.
- إذا كان طلب العميل أحدث، يقوم السيرفر بتجاوز البيانات القديمة وحفظ الجديدة. أما إذا كان طلب العميل أقدم، فيرفض السيرفر الطلب ولكنه يعيد رمز
200 OK(لإنجاح المزامنة وحذفها من الطابور المحلي للعميل) ويرسل الحالة الأحدث لديه ليقوم العميل بتحديث واجهته بها.
مثال على هيكل البيانات المرسلة:
async function saveCustomerData(customerId, data) {
const payload = {
...data,
id: customerId,
lastUpdated: Date.now() // تسجيل وقت التعديل المحلي
};
const response = await executeRequest(`/api/customers/${customerId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: payload
});
return response;
}
---
إرشادات خاصة بالبيئة البرمجية في سوريا
عند تهيئة هذا النظام للعمل في السوق السورية، يجب على المهندسين مراعاة الحقائق الخدمية الثلاث التالية:
1. انقطاع التيار الكهربائي المفاجئ للأجهزة
تنقطع طاقة الحواسيب وأجهزة التوجيه فجأة نتيجة لبرامج التقنين أو الأعطال الكهربائية.
- توجيه: لا تقم بتخزين طابور المزامنة في الذاكرة العشوائية المؤقتة (مثل React State أو متغيرات JS). يجب حفظ كل إدخال في
IndexedDBبشكل متزامن قبل تحديث واجهة المستخدم. عند تشغيل التطبيق، ابدأ دائماً باستدعاءtriggerBackgroundSync()في الملف الرئيسي لمعالجة العمليات التي لم تكتمل من الجلسة السابقة بسبب الانقطاع المفاجئ.
2. حجم حزم البيانات واستهلاك الإنترنت
تخضع باقات البيانات الخلوية لاحتساب دقيق للحجم، كما أن سرعات الجيل الثالث (3G) قد تواجه بطئاً شديداً.
- توجيه: قلل حجم البيانات المخزنة في طابور المزامنة إلى الحد الأدنى. قم بإزالة المصفوفات والكائنات المتداخلة غير الضرورية قبل حفظ الطلب. للملفات النصية الكبيرة، يمكنك استخدام مكتبات خفيفة لضغط النصوص مثل
lz-stringقبل إضافتها للطابور، وفك ضغطها على بوابة الـ API الخاصة بك.
3. مؤشرات واجهة مستخدم واضحة
يجب أن يعرف المستخدم دائماً متى يعرض التطبيق بيانات محلية مؤقتة ومتى تكون تعديلاته قيد المزامنة.
- توجيه: ابنِ شريط حالة للمزامنة في واجهة التطبيق لعرض رسائل واضحة مثل:
- وضع العمل دون اتصال (حفظ محلي للبيانات) - جاري المزامنة: يتبقى 3 عمليات معلقة... - تم حفظ جميع التعديلات سحابياً بنجاح
---
المضي قدماً مع البرمجيات المرنة
يضمن بناء البرمجيات التي تدعم العمل دون اتصال بالإنترنت استمرار عمليات شركتك بكفاءة ودون تأثر بالظروف الخارجية للشبكة. ومع التقدم في المشاريع الرقمية الوطنية الكبرى مثل مشروع SilkLink المدعوم من مجموعة stc وتوسعات الألياف الضوئية المحلية، يمكن للشركات السورية تحقيق المرونة الرقمية الكاملة والمنافسة بقوة.
هل تبحث عن بناء تطبيقات ويب وجوال تدعم العمل دون اتصال لشركتك؟ في Dragonfly Soft، نصمم ونبني أنظمة ويب وقواعد بيانات عالية الأداء مصممة للعمل بكفاءة وموثوقية عالية تحت مختلف ظروف السوق المحلية. تواصل مع Dragonfly Soft اليوم لمناقشة مشروعك القادم مع فريقنا الهندسي.