يا جماعة الخير، السلام عليكم ورحمة الله. معكم أخوكم أبو عمر.
خلوني أحكيلكم هالسولافة اللي صارت معي قبل كم سنة، يومها شاب شعري قبل أوانه. كنا شغالين على نظام دفع لمتجر إلكتروني جديد، والعميل كان مستعجل “بده يطلع لايف” بأسرع وقت. الفريق كله كان شغال ليل نهار، والقهوة صارت مثل المي. أطلقنا الموقع، والأمور كانت ماشية زي الحلاوة أول يومين.
في اليوم الثالث، حوالي الساعة 11 الصبح، بلّش تلفون الدعم الفني يولّع. “انخصم مني المبلغ مرتين!”، “دفعت الفاتورة ووصلني إيميلين خصم!”، “شو هالنظام الفاشل هاد؟”. قلبي نغزني، يا خوفي! فتحنا لوحة تحكم بوابة الدفع، والصدمة كانت هناك: عشرات العمليات مكررة. نفس العميل، نفس الطلب، بس دفعتين أو ثلاث! ولّعت.
بعد تحقيق سريع، اكتشفنا المصيبة. أغلب العملاء اللي صارت معهم المشكلة كانوا بيستخدموا إنترنت بطيء. الواحد منهم بيكبس على زر “ادفع الآن”، والصفحة بتطوّل لَتحمّل، فبيرجع يكبس مرة ومرتين وثلاث… ومع كل كبسة، المتصفح كان يبعت طلب جديد للخادم تبعنا، والخادم “المسكين” كان ينفذ كل طلب بيوصله، ويخصم المبلغ مرة ومرتين وثلاث. يومها قضينا النهار كله نرجع فلوس للناس يدويًا ونعتذر منهم. كانت بهدلة ما بعدها بهدلة. ومن يومها، حلفت يمين إنه ما في مشروع فيه مليم واحد بيندفع إلا ويكون محصّن ضد هاي الكارثة. الحل كان بسيط وأنيق، واسمه: Idempotency-Key.
ما هي “الخمولية” أو “التكرارية الآمنة” (Idempotency)؟
قبل ما نغوص في الحل التقني، لازم نفهم أصل المشكلة. المفهوم اللي بنحكي عنه اسمه “Idempotency” (الخمولية أو التكرارية الآمنة). ببساطة شديدة، العملية “الخاملة” هي العملية اللي لو نفذتها مرة أو ألف مرة، النتيجة النهائية بتكون وحدة.
خلونا ناخد مثال من حياتنا اليومية: كبسة المصعد. لو كبست على زر الطابق الخامس مرة وحدة، المصعد بيطلع للطابق الخامس. طيب لو رجعت كبست عليه عشر مرات وهو طالع؟ ولا إشي رح يتغير، رح يضل رايح على الطابق الخامس. هاي عملية “خاملة” (Idempotent).
بالمقابل، تخيل إنك بتسحب مصاري من الصراف الآلي. لو طلبت سحب 100 دينار، رح يطلعلك 100 دينار. لو كررت العملية مرة ثانية، رح يطلعلك 100 دينار ثانية، والمجموع 200. هاي عملية “غير خاملة” (Non-idempotent) لأنه تكرارها بيغير النتيجة النهائية.
في عالم الـ APIs، عمليات مثل GET (جلب بيانات) أو PUT (تحديث كامل لبيانات) أو DELETE (حذف بيانات) مصممة لتكون خاملة بطبيعتها. لكن المشكلة الكبرى تكمن في عمليات POST، اللي غالبًا تُستخدم لإنشاء شيء جديد (زي طلب جديد، أو عملية دفع جديدة). بطبيعتها، هي عملية غير خاملة.
لماذا تعتبر التكرارية الآمنة شريان الحياة في واجهات برمجة التطبيقات (APIs)؟
المشكلة اللي صارت معنا مش حالة نادرة. الطلبات المكررة ممكن تحصل لأسباب كثيرة جدًا، منها:
- مشاكل الشبكة: العميل يرسل الطلب، لكن الرد يتأخر أو يضيع بسبب ضعف الشبكة. العميل (أو الكود اللي في المتصفح) بيفترض إن الطلب فشل وبيعيد إرساله.
- سلوك المستخدم: مثل ما صار معنا، المستخدم “العصبي” اللي بيكبس على الزر أكثر من مرة (Double-click).
- أنظمة إعادة المحاولة (Retry Logic): كثير من المكتبات البرمجية الحديثة فيها منطق لإعادة المحاولة تلقائيًا عند فشل الشبكة.
- بنيات الأنظمة الموزعة: في الأنظمة المعقدة اللي بتستخدم طوابير الرسائل (Message Queues) مثل RabbitMQ أو Kafka، ممكن الرسالة توصل للمعالج أكثر من مرة (at-least-once delivery).
بدون آلية للتعامل مع هاي التكرارات، ممكن تلاقي حالك في ورطة كبيرة: فواتير مكررة، طلبات شحن مزدوجة، بيانات فاسدة، وفقدان كامل لثقة المستخدم.
مفتاح الخلاص: تعرف على رأس ‘Idempotency-Key’
هنا يأتي دور البطل الصامت: Idempotency-Key. هو ليس معيارًا رسميًا في بروتوكول HTTP، ولكنه نمط تصميم شائع جدًا تبنته كبرى الشركات مثل Stripe وأصبح شبه قياسي في عالم أنظمة الدفع والـ APIs الحرجة.
الفكرة عبقرية في بساطتها: العميل، قبل ما يرسل أي عملية حساسة (مثل إنشاء دفعة)، يقوم بإنشاء “مفتاح فريد” خاص بهاي العملية. هذا المفتاح، اللي غالبًا بيكون UUID (معرّف فريد عالميًا)، يتم إرساله مع الطلب داخل رأس (Header) خاص اسمه Idempotency-Key.
كيف يتعامل الخادم مع هذا المفتاح؟
لما الخادم يستقبل طلب POST ومعه هذا الرأس، يقوم بالآتي:
- التحقق من المفتاح: يبحث الخادم في قاعدة بيانات مؤقتة (مثل Redis) أو جدول خاص عنده: “هل رأيت هذا المفتاح من قبل؟”.
- إذا كان المفتاح جديدًا:
- يقوم الخادم بتخزين المفتاح وحالته “قيد المعالجة” (in-progress) لمنع أي طلب آخر بنفس المفتاح من العمل في نفس اللحظة (لمنع حالة السباق – Race Condition).
- ينفذ العملية المطلوبة بشكل طبيعي (مثلاً، يخصم المبلغ من البطاقة).
- بعد انتهاء العملية بنجاح، يخزّن الخادم نتيجة الاستجابة (الـ response body والـ status code) بجانب هذا المفتاح.
- يرسل الاستجابة للعميل.
- إذا كان المفتاح موجودًا مسبقًا:
- هنا مربط الفرس. الخادم لا يعيد تنفيذ العملية مرة أخرى.
- بدلاً من ذلك، يذهب مباشرة إلى النتيجة المخزنة مسبقًا والمرتبطة بهذا المفتاح، ويرجعها للعميل كما هي.
بهذه الطريقة، حتى لو أرسل العميل الطلب 10 مرات بسبب مشكلة في الشبكة، عملية الخصم الحقيقية ستحدث مرة واحدة فقط. وفي كل مرة من المرات التسعة التالية، سيحصل العميل على نفس الرد الناجح الذي حصل عليه في المرة الأولى، بدون أي أثر جانبي.
ورشة عمل أبو عمر: لنبني نظام تكرارية آمن معًا
حكي النظري حلو، بس خلينا نشوف الكود كيف بيكون. “فرجيني كودك، بقلك مين إنت”. رح نستخدم مثال بسيط باستخدام Node.js و Express، لكن المبدأ نفسه ينطبق على أي لغة أو إطار عمل.
الجانب العميل (Client-Side): كيف نرسل الطلب؟
في الواجهة الأمامية، قبل إرسال طلب الدفع، كل ما علينا فعله هو إنشاء معرّف فريد. مكتبة مثل uuid ممتازة لهذا الغرض.
// npm install uuid
import { v4 as uuidv4 } from 'uuid';
async function processPayment(paymentData) {
// 1. Generate a unique key for this specific payment attempt
const idempotencyKey = uuidv4();
try {
const response = await fetch('/api/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// 2. Attach the key to the request header
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(paymentData),
});
if (!response.ok) {
throw new Error('Payment failed!');
}
const result = await response.json();
console.log('Payment successful:', result);
// Redirect to success page
} catch (error) {
console.error('An error occurred:', error);
// Maybe show an error message to the user
}
}
// Example usage when user clicks "Pay"
const paymentDetails = { amount: 2500, currency: 'USD', orderId: 'ORD-123' };
processPayment(paymentDetails);
الجمال في هذا الكود أن منطق إعادة المحاولة يمكن بناؤه حوله بسهولة. لو فشل الطلب بسبب الشبكة، يمكن إعادة استدعاء processPayment بنفس البيانات، وسيتم إنشاء مفتاح جديد. لكن لو أُعيد إرسال نفس الطلب بالضبط (بنفس المفتاح)، الخادم سيتكفل بالباقي.
الجانب الخادم (Server-Side): كيف نستقبل الطلب؟
هنا يكمن السحر الحقيقي. سنبني Middleware في Express لاعتراض الطلبات والتعامل مع المفتاح. في هذا المثال، سأستخدم كائن بسيط في الذاكرة للتخزين، ولكن في التطبيقات الحقيقية، يجب استخدام قاعدة بيانات سريعة ومستمرة مثل Redis.
const express = require('express');
const app = express();
app.use(express.json());
// In-memory store for demonstration.
// !! WARNING: Use a persistent store like Redis in production !!
const idempotencyStore = new Map();
// The Idempotency Middleware
async function idempotencyMiddleware(req, res, next) {
// We only care about POST and PATCH methods
if (req.method !== 'POST' && req.method !== 'PATCH') {
return next();
}
const idempotencyKey = req.headers['idempotency-key'];
// If there's no key, proceed as a normal request
if (!idempotencyKey) {
return next();
}
// --- Check the store for the key ---
const cachedResponse = idempotencyStore.get(idempotencyKey);
if (cachedResponse) {
console.log(`[Idempotency] Key ${idempotencyKey} found. Returning cached response.`);
// If found, return the cached response immediately
return res.status(cachedResponse.statusCode).json(cachedResponse.body);
}
// --- If key is new, wrap the response to cache it ---
const originalJson = res.json;
const originalStatus = res.status;
let responseBody;
let responseStatusCode;
// Override res.status to capture the status code
res.status = (code) => {
responseStatusCode = code;
return originalStatus.call(res, code);
};
// Override res.json to capture the body and save to store
res.json = (body) => {
responseBody = body;
const cacheEntry = {
statusCode: responseStatusCode || 200,
body: responseBody,
};
console.log(`[Idempotency] Key ${idempotencyKey} is new. Caching response.`);
idempotencyStore.set(idempotencyKey, cacheEntry);
// IMPORTANT: Set a TTL to prevent memory leaks!
// In Redis, you would use 'SETEX' command.
setTimeout(() => {
idempotencyStore.delete(idempotencyKey);
console.log(`[Idempotency] Key ${idempotencyKey} expired and removed.`);
}, 24 * 60 * 60 * 1000); // 24-hour expiry
return originalJson.call(res, body);
};
// Proceed to the actual route handler
next();
}
// Apply the middleware to all routes or specific ones
app.use(idempotencyMiddleware);
// --- The actual payment route ---
app.post('/api/payments', (req, res) => {
// This is your core business logic
// It will only run ONCE for a given idempotency key
console.log('Processing new payment for order:', req.body.orderId);
// 1. Call payment gateway
// 2. Update database
// 3. Send confirmation email
const paymentResult = {
id: `payment_${Date.now()}`,
status: 'succeeded',
...req.body
};
// The overridden res.json will handle caching
res.status(201).json(paymentResult);
});
app.listen(3000, () => console.log('Server with Idempotency layer running on port 3000'));
ملاحظة هامة: المثال أعلاه مبسط. في نظام حقيقي، يجب التعامل مع حالات السباق (Race Conditions) عن طريق قفل المفتاح (locking) أثناء معالجة الطلب الأول.
نصائح من خبرة أبو عمر: الذهب في التفاصيل
تطبيق هذا النمط ليس معقدًا، لكن الشيطان يكمن في التفاصيل. إليكم بعض النصائح من أرض المعركة:
- استخدمه بحكمة: لا تحتاج لتطبيق هذا النمط على كل طلب
POST. ركز على العمليات الحرجة التي لا يجب أن تتكرر أبدًا، مثل إنشاء المدفوعات، أو إرسال طلبات الشحن، أو إجراء تحويلات مالية. - عمر المفتاح (Key TTL): لا تخزن المفاتيح إلى الأبد! هذا سيؤدي إلى تضخم قاعدة بياناتك أو استهلاك الذاكرة. قاعدة جيدة هي تخزين المفتاح لمدة 24 ساعة. هذا يعطي العميل وقتًا كافيًا لإعادة المحاولة في حالة فشل الشبكة، ولكنه يمنع التخزين غير الضروري. استخدم ميزة TTL (Time-To-Live) في Redis لهذا الغرض.
- نطاق المفتاح (Key Scope): ماذا لو قام مستخدمان مختلفان بإنشاء نفس الـ UUID بالصدفة (احتمال ضئيل جدًا ولكنه ممكن)؟ الأفضل أن يكون نطاق المفتاح مرتبطًا بالمستخدم أو الحساب. بدلاً من تخزين
idempotencyKeyفقط، قم بتخزينه كـ${userId}:${idempotencyKey}. هذا يضمن عدم حدوث أي تداخل. - توليد المفتاح مسؤولية العميل: يجب دائمًا أن يقوم العميل بتوليد المفتاح. إذا قام الخادم بتوليده، فإننا نفقد الغرض منه، لأن كل طلب جديد سيُعتبر فريدًا من قبل الخادم.
الخلاصة: نقرة واحدة، فاتورة واحدة، وراحة بال 😌
في عالم اليوم، حيث الشبكات غير مستقرة وسلوك المستخدم غير متوقع، لم تعد التكرارية الآمنة رفاهية، بل ضرورة قصوى لبناء أنظمة موثوقة وقوية. إن نمط Idempotency-Key هو أداة بسيطة بشكل خادع، لكنها قوية للغاية لحماية عملياتك من الفوضى.
نصيحتي الأخيرة لك يا صديقي المبرمج: لا تنتظر حتى تقع كارثة الفواتير المكررة لتبدأ بالتفكير في هذا الموضوع. كن استباقيًا. بضعة أسطر من الكود اليوم يمكن أن توفر عليك جبلاً من الصداع (ورسائل البريد الإلكتروني الغاضبة من العملاء) غدًا.
والله ولي التوفيق. في أمان الله.