YourWorldTime

المناطق الزمنية في الشفرة: دليل عملي

· 4 دقيقة قراءة

تكاد كل علة في المناطق الزمنية ترجع إلى واحد من أربعة أخطاء. وهذا ما ينبغي فعله بدلاً منها، في جافاسكربت وبايثون وجافا وGo وSQL، مع التعليل لا مجرد القاعدة.

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

القاعدة 1: خزّن اللحظات بالتوقيت العالمي المنسق

اللحظة نقطة على خط الزمن. أما الوقت المحلي فقراءة ساعة حائط، تحتاج منطقةً وسياسةَ رفع لبس قبل أن تعني شيئاً.

-- جيد: لحظة
created_at TIMESTAMPTZ NOT NULL

-- سيئ: قراءة ساعة حائط بلا منطقة
created_at TIMESTAMP NOT NULL

النوع TIMESTAMPTZ في PostgreSQL لا يخزّن منطقة، بل يوحّد إلى التوقيت العالمي المنسق عند الكتابة ويحوّل عند القراءة. وهذا بالضبط ما تريده لسؤال «متى حدث هذا».

القاعدة 2: خزّن معرّف المنطقة لا الفرق

إن احتجت أن تعرف أين — لحدث دوري، أو تفضيل مستخدم، أو ساعات عمل منشأة — فخزّن Europe/London لا +01:00.

الفرق واقعةٌ تخص لحظة واحدة. أما المنطقة فمجموعة قواعد تنجو من تغيّر رأي حكومة. فبريطانيا على +00:00 في يناير وعلى +01:00 في يوليو؛ وتخزين أي منهما وتسميته «منطقة المستخدم» خطأٌ نصف السنة.

event_start_local  TIMESTAMP   NOT NULL,  -- وقت ساعة الحائط الذي اختاروه
event_time_zone    TEXT        NOT NULL,  -- 'Europe/London'
event_start_utc    TIMESTAMPTZ NOT NULL   -- اللحظة المحلولة، تُعاد حوسبتها إن تغيّرت القواعد

في الحدث الدوري يكون الوقت المحلي والمنطقة مصدر الحقيقة؛ واللحظة بالتوقيت العالمي فهرسٌ مشتق. فإن تغيّرت tzdata — وهي تتغير عدة مرات في السنة — يُعاد توليد العمود المشتق وتبقى التاسعة صباحاً عند المستخدم تاسعةً صباحاً.

القاعدة 3: حوّل عند الحافة

منطق العمل يشتغل باللحظات. أما التحويل إلى وقت محلي مقروء فيحدث في آخر لحظة ممكنة، في الطبقة التي تعرف من يقرأ.

واللازمة أهم: لا تدع منطقة الخادوم نفسها تتسرب إلى السلوك أبداً. اضبط TZ=UTC في بيئة النشر، وعدّ أي مسار في الشفرة تعتمد نتيجته على إعدادات الخادوم المحلية علةً. وهذا صنف الأعطال الذي لا يظهر إلا حين ينشر أحدهم في إقليم ثانٍ.

القاعدة 4: لا تبنِ جدول فروق أبداً

كل بيئة تشغيل تحمل قاعدة IANA. فاستعملها.

جافاسكربت

// التنسيق ضمن منطقة
new Intl.DateTimeFormat('en-GB', {
  timeZone: 'Asia/Kathmandu',
  dateStyle: 'medium',
  timeStyle: 'short',
}).format(new Date());

// الحصول على فرق بلا مكتبة: قارن ساعة حائط المنطقة بالتوقيت العالمي المنسق
function offsetMinutes(timeZone, instant) {
  const parts = new Intl.DateTimeFormat('en-US', {
    timeZone,
    hourCycle: 'h23',
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit',
  }).formatToParts(new Date(instant));
  const get = (type) => Number(parts.find((p) => p.type === type).value);
  const asUtc = Date.UTC(
    get('year'),
    get('month') - 1,
    get('day'),
    get('hour'),
    get('minute'),
    get('second'),
  );
  return (asUtc - Math.floor(instant / 1000) * 1000) / 60000;
}

اضبط hourCycle: 'h23' صراحةً. فبعض إصدارات ICU تبلّغ عن منتصف الليل بوصفه الساعة 24 تحت en-US، وهو ما يزيح التاريخ يوماً كاملاً ويشكّل علةً بائسة حقاً في تعقّبها.

بايثون

from datetime import datetime, timezone
from zoneinfo import ZoneInfo  # مكتبة قياسية منذ 3.9

now = datetime.now(timezone.utc)
local = now.astimezone(ZoneInfo("Asia/Kathmandu"))

لا تستعمل datetime.utcnow() أبداً. فهي تعيد تاريخاً ساذجاً يبدو بالتوقيت العالمي المنسق لكنه لا يحمل منطقة، وخلطه بتواريخ واعية للمنطقة يرمي استثناءً في أسوأ لحظة. والاستدعاء الصحيح هو datetime.now(timezone.utc).

جافا

Instant now = Instant.now();
ZonedDateTime local = now.atZone(ZoneId.of("Asia/Kathmandu"));

مكتبة java.time مصمّمة تصميماً جيداً: Instant لنقاط خط الزمن، وLocalDateTime لقراءات ساعة الحائط بلا منطقة، وZonedDateTime للاثنين معاً. ونظام الأنواع يفرض التمييز الذي يلحّ عليه هذا الدليل.

Go

loc, _ := time.LoadLocation("Asia/Kathmandu")
local := time.Now().In(loc)

تقرأ LoadLocation قاعدة tzdata الخاصة بالنظام. وفي حاوية فارغة لا وجود لها — فاستورد _ "time/tzdata" لتضمينها في الملف التنفيذي، وإلا انحدر كل بحث عن منطقة إلى التوقيت العالمي المنسق في صمت.

يوما السنة

التقديم يمحو ساعة؛ والتأخير يكرّر ساعة. اختر سياسة، وطبّقها باطراد، وأخبر المستخدم بما فعلت:

  • وقت محلي غير موجود (02:30 في تاريخ الربيع الأمريكي): قدّم بمقدار حجم الفجوة، فتصير 02:30 هي 03:30.
  • وقت محلي ملتبس (01:30 في تاريخ الخريف الأمريكي): خذ الظهور الأول، أي الأبكر.

وهاتان هما السياستان اللتان يستعملهما هذا الموقع، وهما توافقان القيمة الافتراضية disambiguation: 'compatible' في Temporal بلغة ECMAScript — ويجدر التوافق معها ليتفق سلوكك مع المنصة.

والفجوة ليست ساعة دائماً. فجزيرة لورد هاو تتحول بثلاثين دقيقة، فاقرأ الحجم من الانتقال نفسه بدل افتراضه.

الاختبار

أربع حالات تلتقط معظم الأمر:

  1. منطقة بنصف ساعةAsia/Kolkata (+05:30). تلتقط افتراضات الساعات الصحيحة.
  2. منطقة بخمس وأربعين دقيقةAsia/Kathmandu (+05:45). تلتقط افتراضات نصف الساعة.
  3. منطقة في نصف الكرة الجنوبيAustralia/Sydney. تلتقط «الصيف يعني يونيو».
  4. جزيرة لورد هاوAustralia/Lord_Howe. تلتقط التحول المثبَّت بستين دقيقة، ولن يلتقطه سواها.

أضف لكل منطقة يقيم فيها مستخدموك فعلاً تاريخَ تقديم وتاريخَ تأخير، وثبّت إصدار tzdata في خط التكامل كي لا يُسقط تحديثٌ لقاعدة البيانات بناءً لسبب خاطئ.

الأحداث الدورية مشكلة أخرى

الحدث المفرد لحظة. أما الحدث الدوري فقاعدة، والاثنان يحتاجان تخزيناً مختلفاً.

فـ«كل ثلاثاء الساعة 09:00 في Europe/London» ليست «كل 604800 ثانية اعتباراً من هذه اللحظة». وتتباعد الصيغتان لحظة تغيّر بريطانيا ساعاتها: فالقاعدة تُبقي الاجتماع عند 09:00 محلياً، بينما تنجرف به المدة إلى 08:00 أو 10:00.

خزّن القاعدة — الوقت المحلي ومعرّف المنطقة ونمط التكرار — وولّد اللحظات منها. وحين تتغير tzdata أعد توليد اللحظات المولَّدة؛ لا ترحّلها. فالقاعدة هي ما وافق عليه المستخدم.

وتنبع الحالات المزعجة من الانقطاعين السنويين. فاجتماعٌ عند 02:30 كل يوم سيجد أن ذلك الوقت لا وجود له في يوم ربيعي واحد، وأنه يقع مرتين في يوم خريفي واحد. اختر سياسة، وطبّقها باطراد، واعلم أن أنظمة التقاويم المختلفة اختارت سياسات مختلفة — ولهذا قد يظهر الحدث الدوري نفسه بفارق ساعة في تقويمَي شخصين، في يومين اثنين بالضبط من السنة.

ملاحظة عن Temporal

تستبدل واجهة Temporal في لغة ECMAScript بالنوع Date أنواعاً تميّز بين اللحظات وأوقات ساعة الحائط والتواريخ ذات المنطقة، على منوال java.time. فـTemporal.ZonedDateTime يحمل معرّف منطقة؛ وTemporal.Instant نقطة على خط الزمن؛ وTemporal.PlainDateTime قراءة ساعة حائط بلا منطقة، ونظام الأنواع يمنعك من الخلط بينها.

ويكشف خيارها disambiguation بالضبط سياسة يومي السنة التي يعود إليها هذا الدليل مراراً: 'compatible' (الافتراضي — التقديم عبر الفجوات، وأخذ الأبكر من الزوج الملتبس)، و'earlier'، و'later'، و'reject'. ويستحق الأخير النظر في كل ما يكون فيه الخطأ الصامت بساعة أسوأ من إظهار خطأ صريح.