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

كانت هذه فرصة لي لأتعلم كيفية التعامل الصحيح مع التواريخ والأوقات في البرمجيات الدولية. في هذا المقال، سأقدم لك لمحة عن قضايا المناطق الزمنية وأشاركك بعض القواعد الأساسية التي يجب اتباعها لضمان عمل برمجياتك بسلاسة عبر الحدود الجغرافية.
فهم المناطق الزمنية: أساسيات ومفاهيم
نظرًا لأن الأرض كروية الشكل، فإن الشمس تشرق في اليابان بينما تغرب في أمريكا. إذا استخدم الجميع توقيتًا عالميًا واحدًا، لنقل إن الساعة 09:00 صباحًا ستكون شروق الشمس في اليابان، لكنها ستكون غروب الشمس للأمريكيين، وهو أمر غير عملي على الإطلاق. لضمان توافق الوقت مع الشمس للجميع، من الضروري التحول من التوقيت العالمي وفقًا لموقعك الجغرافي. ونتيجة لذلك، تنقسم الكرة الأرضية إلى مناطق زمنية، ولكل منطقة offset (إزاحة زمنية).
ما هي الإزاحة الزمنية (Offset)؟
الإزاحة الزمنية هي عدد من الدقائق يجب إضافتها إلى التوقيت العالمي للحصول على التوقيت المحلي لمنطقتك. يمكن أن تكون هذه الإزاحة إيجابية أو سلبية.

التوقيت العالمي يُعرف بـ UTC، وهو اختصار لـ Coordinated Universal Time (التوقيت العالمي المنسق). قد تكون قد سمعت أيضًا عن GMT (توقيت غرينتش)، وهو منطقة زمنية بدون أي إزاحة. على سبيل المثال، عندما تكون الساعة 10:50 صباحًا بتوقيت UTC، تكون الساعة 03:50 صباحًا في سان فرانسيسكو بإزاحة -0700، والساعة 18:50 مساءً في بكين بإزاحة +0800. ومع ذلك، فإن التحول ليس دائمًا بساعات كاملة؛ فإزاحة نيبال هي +0545. يمكنك التحقق من ذلك على ويكيبيديا.
التوقيت الصيفي (DST) وقواعد البيانات الزمنية
بالإضافة إلى هذه الإزاحة التي تأتي مع المنطقة الزمنية، تقوم بعض البلدان أيضًا بتحويل الساعات مرتين في السنة. يضيف التوقيت الصيفي (DST أو summer time) ساعة واحدة إلى إزاحة المنطقة الزمنية قبل الصيف، ثم يتم إعادة ضبط الساعة إلى توقيت المنطقة الزمنية في الشتاء. الهدف من ذلك هو إطالة ساعات النهار.
الطريقة الأكثر شيوعًا لتحديد المنطقة الزمنية هي استخدام قاعدة بيانات مناطق IANA الزمنية (IANA Time Zone Database). ستجد سلاسل نصية مثل Europe/Paris تتبع نمط Area/City (المنطقة/المدينة). بالإضافة إلى ذلك، تحتفظ مايكروسوفت بقاعدة بيانات مناطق زمنية خاصة بها (Microsoft Time Zone Database) تُستخدم في أنظمة تشغيلها. لكن هذا يمكن أن يسبب مشكلات عند تشغيل تطبيقات .NET Core متعددة المنصات. لا تزال قاعدة بيانات IANA هي الخيار المفضل.
قاعدة بيانات مايكروسوفت لا تُحدّث كثيرًا، وتحتوي على تاريخ أقل، وأسماء مناطق زمنية غريبة إلى حد ما (مثل Romantic Standard Time)، وهي عرضة للأخطاء. على سبيل المثال، حاول ألا تخلط بين Arab و Arabic و Arabian Standard Time. لمزيد من التفاصيل حول كل قاعدة بيانات واختلافاتهما، تحقق من هذا المقال.
أخيرًا، هناك العديد من الطرق لكتابة التاريخ. لحسن الحظ، تحدد مواصفة ISO 8601 قاعدة مشتركة لتنسيق التاريخ:
November 11, 2018 at 12:51:43 AM (in a time zone at UTC+00:00)
2018-11-05T12:51:43Z <- Z stands for UTC
November 11, 2018 at 12:51:43 AM (in a time zone at UTC+07:30)
2018-11-05T12:51:43+0730
كيف تتعامل أجهزة الكمبيوتر مع التواريخ
تستطيع أجهزة الكمبيوتر إجراء العمليات باستخدام الأرقام فقط. هذا يعني أن التعبير 2020-08-01 + 1 لا يساوي 2020-08-02 ولا يمكن التعامل معه مباشرة. لتبسيط العمل مع التواريخ، يمكننا تمثيلها كأرقام. هذا هو جوهر الطوابع الزمنية (timestamps). الطابع الزمني هو عدد المللي ثانية التي انقضت من تاريخ محدد مسبقًا (أو epoch) حتى التاريخ المحدد. رائع، لنختر عصرًا إذًا! في الواقع، تم تحديد العصر الشائع بالفعل وقيمته هي 1 يناير 1970 (منتصف الليل بتوقيت UTC).

للتأكد من فهمك، قم بتشغيل المقتطف السابق في متصفحك. ماذا؟ لم تحصل على نفس النتيجة؟ حسنًا، لقد غششت قليلًا للحصول على هذه النتيجة… كان يجب أن أحصل على Thu Jan 01 1970 01:00 GMT+0100 لأن المنطقة الزمنية لجهازي مضبوطة على Europe/Paris. في الواقع، هذه اللحظة ذات الطابع الزمني الصفري هي منتصف الليل في غرينتش، ولكنها أيضًا 05:45 صباحًا في مومباي وحتى 1969-12-31T16:30 في سان فرانسيسكو عندما تأخذ في الاعتبار إزاحة منطقتهم الزمنية.
القاعدة رقم 1: الطوابع الزمنية للحفظ فقط، وليس للعرض
تُعتبر الطوابع الزمنية بتوقيت UTC لأنها لا تتضمن أي إزاحة أو منطقة زمنية. لم تحصل على التاريخ “الصحيح” من قبل لأن JavaScript يستخدم منطقتك الزمنية المحلية لعرض التاريخ/الوقت الأكثر دقة لك. الآن، جرب المقتطف التالي. أنا متأكد من أنك ستحصل على نفس النتيجة التي حصلت عليها:

نعم، الطابع الزمني الصفري هو 1970-01-01T00:00:00 بتوقيت UTC للجميع حول العالم. ومع ذلك، هذا ليس صحيحًا إذا اخترت منطقة زمنية أخرى. باختصار، تعرض الدالة toString() التاريخ باستخدام منطقتك الزمنية المحلية بينما تعتمد الدالة toUTCString() على توقيت UTC. لا تنخدع أيضًا بالدالة toISOString() التي هي نفسها toUTCString() ولكنها تُخرج تنسيق ISO 8601 (يجب أن يكون اسمها toUTCISOString()).
أوصي باستخدام أمر date لتحويل طابع زمني بالثواني (وليس المللي ثانية) إلى سلسلة نصية قابلة للقراءة. يضمن استخدام هذا الأمر مع خيار -u (أو -r في Osx) أنه لا يأخذ في الاعتبار المنطقة الزمنية لجهاز الكمبيوتر/المتصفح الخاص بك:
# Linux
$ date -d @1586159897 -u
Mon Apr 6 07:58:17 UTC 2020
# For Osx users
$ date -r 1586159897 -u
إصلاح اختبار الوحدة لدينا
كانت المشكلة التي واجهتها مع المناطق الزمنية في اختبارات الوحدة الخاصة بي. خذ وقتك لقراءتها وفهم ما يفترض أن تتحقق منه:

في هذا الاختبار، الهدف هو التحقق من أن الدالة setHours() تضبط ساعات ودقائق التاريخ إلى الصفر (منتصف الليل). اخترت أولاً طابعًا زمنيًا عشوائيًا لا يمثل منتصف الليل. ثم قارنت النتيجة بالطابع الزمني لنفس اليوم في منتصف الليل. في الواقع، هذا يعمل – ولكن فقط إذا كانت إزاحة منطقتك الزمنية هي +0200 (بما في ذلك التوقيت الصيفي DST) في هذه اللحظة. على سبيل المثال، لا يعمل هذا لـ Africa/Casablanca (إزاحة +0100 بما في ذلك DST).
دعنا نرى كيف تُطبع هذه الطوابع الزمنية:

هذا هو بيت القصيد، تاريخ UTC لكلا النتيجتين ليس هو نفسه. وهذا يعني أيضًا أن الطوابع الزمنية الناتجة ليست هي نفسها. كما ترى، إزاحة باريس هي +0200 وإزاحة الدار البيضاء هي +0100. لكن كلاهما يعرض منتصف الليل باستخدام toString(). هذا يعني أن الدالة setHours() تستخدم المنطقة الزمنية لجهاز الكمبيوتر الخاص بك لإجراء العملية، وأن toString() تعرض التاريخ باستخدام منطقتك الزمنية.
هذه ليست المشكلة الوحيدة في هذا الاختبار: ماذا لو قمت بتشغيل هذا الاختبار في سان فرانسيسكو؟ صحيح، سيكون اليوم 2020-07-31 لكلا التاريخين بسبب إزاحة -0700. الطريقة الأكثر أمانًا لجعل هذا الاختبار موثوقًا ويعمل في جميع أنحاء العالم هي استخدام تاريخ في منطقتك الزمنية المحلية. لن تستخدم الطوابع الزمنية لتعيين التواريخ الأولية بعد الآن.

القاعدة رقم 2: التواريخ النصية للعرض والحسابات
يمكننا تعزيز القاعدة السابقة حول الطوابع الزمنية:
- القاعدة رقم 2: التواريخ النصية مناسبة للعرض باستخدام المنطقة الزمنية للمستخدم ولإجراء الحسابات. لا تعتمد على
UTCولكنها تتضمن عادةً إزاحة زمنية.
الحفاظ على التواريخ في جانب الخادم
لا تزال القاعدة المتعلقة بالطوابع الزمنية تنطبق على جانب الخادم. ومع ذلك، لا يمكن استخدام القاعدة الثانية المتعلقة باستخدام التواريخ النصية. في الواقع، في بعض الحالات مع تقنيات مثل PHP و Java و Rails، يتم عرض الصفحات على جانب الخادم (SSR – Server Side Rendering). هذا يعني أن كل HTML يتم إنشاؤه بواسطة الخادم، وليس لديه أي فكرة عن المنطقة الزمنية للعميل.
فكر في الخادم – إنه ليس أكثر من جهاز كمبيوتر على الكرة الأرضية. ولديه أيضًا منطقته الزمنية الخاصة، لكنها ليست بالضرورة نفس المنطقة الزمنية للعميل.
القاعدة رقم 3: الخوادم والتعامل مع المناطق الزمنية
- القاعدة رقم 3: يجب على الخوادم إما معرفة المنطقة الزمنية للعميل أو إرسال تاريخ بتوقيت
UTC. المنطقة الزمنية للخادم لا تهم.
تُعتبر واجهة برمجة تطبيقات التاريخ/الوقت الجديدة في Java 8 واحدة من أوضح وأسهل الواجهات التي تساعدك في التعامل مع التاريخ. لن أشرح كيفية عملها هنا، ولكن دعنا نراجع بعض النقاط المثيرة للاهتمام.
تُعد الفئات LocalDateTime و OffsetDateTime و ZonedDateTime هي الفئات الثلاثة المتوفرة لحساب وعرض التاريخ والوقت. لا مزيد من Date أو DateTime التي تخلط بين عرض التاريخ المحلي وتاريخ UTC. الأمثلة التالية مقتبسة من هذا المقال الرائع (الذي كتبه جوناس كونراد) والذي يصف واجهة برمجة تطبيقات التاريخ/الوقت في Java 8 بمجموعة من الأمثلة. بالمناسبة، شكرًا جزيلاً له، فقد سمح لي مشكورًا باقتباس أجزاء من شفرته!
دعنا نلقي نظرة على الاختلافات بين الفئات الثلاث:

هناك فرق صغير ولكنه مهم بين OffsetDateTime و ZonedDateTime، هل لاحظته؟ كما يوحي اسمها، فإن OffsetDateTime تدرك فقط إزاحة بين التاريخ المحلي و UTC. هذا يعني أنها تتعامل مع التوقيت الصيفي (DST) بشكل مختلف عن التاريخ المرتبط بمنطقة زمنية.

يبدو المثال مع المنطقة الزمنية هو السلوك الصحيح. في الواقع، كلاهما صحيح لأن إضافة يوم واحد يمكن أن تعني إما:
- إضافة يوم واحد والحفاظ على نفس الساعة (تتعامل مع
DSTباستخدامZonedDateTime). - إضافة 24 ساعة إلى التاريخ الحالي (باستخدام
OffsetDateTime).
هل تتذكر القاعدة رقم 1 حول الطوابع الزمنية؟ يجب عليك استخدام طابع زمني UTC فقط للحفظ. توفر واجهة برمجة تطبيقات Java فئة Instant وهي طابع زمني يمكنك الحصول عليه من أي من الفئات الثلاث المستخدمة لعرض التاريخ.

الخلاصة النهائية والتحديات المستقبلية
في هذا المقال، تعلمت أن الطوابع الزمنية مخصصة للحفظ (القاعدة رقم 1) وأن التواريخ النصية مخصصة للعرض (القاعدة رقم 2). هل لاحظت أن عدد الثواني من العصر هو رقم كبير جدًا؟ لهذا السبب، بعد مشكلة الألفية في يونكس (Y2K)، تأتي مشكلة Y2K38 التي تشير إلى عام 2038. في 2038-01-19T03:14:07Z، سيصل الطابع الزمني (بالثواني) إلى حده الأقصى للأعداد الصحيحة الموقعة 32 بت، وهو 2,147,483,647. ثم سيتحول إلى رقم سالب بعد إضافة ثانية واحدة أخرى.

في المنتديات، يقول الناس إنهم لا يهتمون لأن برامجهم لن تُستخدم لمدة 20 عامًا دون إعادة كتابة. حسنًا، قد يكون هذا صحيحًا، ولكن دعنا نفكر في بعض الحلول (مع MySQL):
- تحديث نوع
TIMESTAMPإلى أعداد صحيحة موقعة 64 بت. - حفظ تواريخ
UTCفي أعمدةDATETIMEبدلاً منTIMESTAMP.
لكلا الحلين مزاياه وعيوبه. يبدو الحل الأول وكأنه حيلة تؤجل المشكلة لاحقًا. ومع ذلك، فإنه يحل المشكلة لفترة زمنية شبه لا نهائية (مليارات السنين). سيتم إهمال برنامجك ولن يُستخدم بعد الآن عندما تحدث المشكلة مرة أخرى. الحل الثاني يعمل أيضًا لفترة طويلة جدًا (حتى 9999-12-31T23:59:59Z).
يوصى باستخدام TIMESTAMP للسجلات (logs)، بينما DATETIME أفضل للاحتياجات الأخرى. تذكر أن الطابع الزمني لا يمكنه تخزين تاريخ قبل 1970-01-01T00:00:00Z ولا بعد 2038-01-19T03:14:07Z. هذا يعني أنه يجب عليك استخدام DATETIME لحفظ التواريخ البعيدة في الماضي والمستقبل. بالإضافة إلى ذلك، في MySQL، تُخزن TIMESTAMP بتوقيت UTC ولكن تُعرض وفقًا لمنطقة زمنية محددة (وتُحوّل إلى UTC قبل الحفظ). هذه الآلية مفيدة عندما تحتاج إلى الحصول على تاريخ محلي ولا توجد مع DATETIME.
كلمة أخيرة حول moment.js، وهي مكتبة شائعة للتعامل مع التواريخ. واجهت في البداية مشكلة وأردت تحذيرك منها:

كلا عبارتي console.log ستُخرجان 2020-08-02 00:00. إذا كنت معتادًا على البرمجة الوظيفية (functional programming)، فإنك تتوقع أن تُرجع الدالتان hours() و minutes() كائن moment جديدًا لأنهما دالتان نقيتان (pure functions). لكن هذا ليس هو الحال – فهما تعدّلان تاريخ الإدخال وتُرجعانه لتسهيل التسلسل (chaining).
شكرًا لقراءتك حتى النهاية. آمل أن تكون هذه التجربة مفيدة لك. بالمناسبة، لست واثقًا جدًا من الاختيار بين TIMESTAMP و DATETIME، لذا لا تتردد في مشاركة تجربتك!
الخلاصة التقنية
تُعد إدارة المناطق الزمنية تحديًا جوهريًا في تطوير البرمجيات العالمية. المقال يسلط الضوء ببراعة على الفروقات الدقيقة بين الطوابع الزمنية (timestamps) المخصصة للحفظ بتوقيت UTC، والتواريخ النصية (string dates) المناسبة للعرض والحسابات المحلية. كما يوضح أهمية فهم كيفية تعامل الخوادم مع التواريخ، وضرورة فصل منطق العرض عن منطق التخزين. مشكلة Y2K38 هي تذكير حي بأن القرارات الهندسية الحالية لها تداعيات بعيدة المدى. استخدام واجهات برمجة تطبيقات حديثة مثل Java 8 Date/Time API والوعي بسلوكيات المكتبات الشائعة مثل moment.js أمر بالغ الأهمية لإنشاء أنظمة قوية وموثوقة عالميًا.