التاريخ والوقت والمناطق الزمنية في تطبيقات الأعمال ── من فخاخ DateTime إلى مبدأ التخزين بتوقيت UTC وتصميم الاختبارات

· آخر تحديث: · · C#, .NET, .NET Framework, Windows, المناطق الزمنية, معالجة التاريخ والوقت, الاختبار, التشغيل, الاستشارات التقنية

«بعد نقل الخادم إلى VM سحابيّة، انزاح وقت جميع التقارير 9 ساعات كاملةً» «الأجهزة الموزَّعة على الفروع الخارجيّة وحدها يظهر فيها تاريخ التقرير اليوميّ كأنّه اليوم السابق» «هناك أثر يدلّ على أنّ دفعة التجميع الليليّة عملت مرّتين في يوم معيّن». تصل الاستشارات المتعلّقة بالتاريخ والوقت والمناطق الزمنيّة غالبًا بهذا الشكل: «بمجرّد تغيّر البيئة». دون تغيير سطر واحد من الكود.

يُظنّ غالبًا «تطبيقنا مخصّص للسوق اليابانيّ فقط، فلا علاقة له بالمناطق الزمنيّة»، لكنّ الكثير من الاستشارات الفعليّة يحدث بالضبط في هذا النوع من التطبيقات المخصّصة للسوق المحليّ. فـVM وحاويات السحابة غالبًا ما تُصدَر بإعداد UTC، والمكتبات وواجهات API من نوع SaaS الأجنبيّة تُرجِع طوابع زمنيّة بتوقيت UTC أو بإزاحة مرفقة. فالتطبيق نفسه يظنّ أنّه يعيش بتوقيت اليابان فقط، لكنّ الطرف الآخر من الحدود يعيش بالفعل في عالم UTC. وعندما تُتداوَل القيم دون توضيح «ما الأساس الذي يستند إليه هذا الوقت»، تنكشف المشكلة دفعةً واحدةً كانزياح 9 ساعات في يوم نقل الخادم أو التحوّل إلى السحابة.

في هذا المقال، وبافتراض تطبيق أعمال مبنيّ على .NET، سنرتّب فخّ خاصيّة Kind في DateTime والتحويل الضمنيّ، والتمييز بين استخدامها واستخدام DateTimeOffset، والمبدأ القائل «التخزين والاتّصال بتوقيت UTC أو مع إزاحة، والعرض فقط بالتوقيت المحليّ»، وTimeZoneInfo والتوقيت الصيفيّ، والحدود مع قاعدة البيانات، وصولًا إلى تصميم الاختبارات باستخدام TimeProvider. وسننتهي بتحويل البنود التي نراجعها في كلّ مراجعة تصميم إلى شكل قائمة تحقّق.

1. الخلاصة أوّلاً

  • السبب الجذريّ لأعطال التاريخ والوقت واحد تقريبًا: قيمة لا تحمل معلومة «ما الأساس الذي يستند إليه هذا الوقت» تعبر حدودًا (قاعدة البيانات، واجهة API، الملفّ). تظهر الأعراض في اليوم الذي تتغيّر فيه البيئة، لا الكود.
  • تحمل DateTime خاصيّةً تُدعى Kind (Utc / Local / Unspecified)، وقيمتها الافتراضيّة Unspecified. يوجد تفسير ضمنيّ غير متماثل: تفترض ToLocalTime أنّ Unspecified هي UTC، بينما تفترض ToUniversalTime أنّها محليّة، وهذا هو المصدر النمطيّ لـ«انزياح 9 ساعات».1
  • اجعل الخيار الافتراضيّ للكود الجديد DateTimeOffset. تنصّ الإرشادات الرسميّة صراحةً على «النظر فيه كنوع التاريخ والوقت الافتراضيّ لتطوير التطبيقات».2
  • المبدأ هو «التخزين والاتّصال بتوقيت UTC أو مع إزاحة، والعرض فقط بالتوقيت المحليّ». عند التحويل إلى نصّ، اكتب بصيغة الجولة الكاملة “o” المتوافقة مع ISO 8601، وأعد التحليل عبر DateTimeStyles.RoundtripKind.3
  • تُجرى تحويلات المناطق الزمنيّة عبر TimeZoneInfo. ابتداءً من .NET 6، يمكن استخدام معرّف IANA (Asia/Tokyo) ومعرّف Windows (Tokyo Standard Time) معًا، وتوجد واجهات للتحويل المتبادل بينهما.4 لكنّ حلّ معرّفات IANA على Windows يعتمد على ICU، ويفشل في إصدارات Windows Server القديمة أو في إعدادات العولمة الثابتة (الفصل 4).5
  • حتّى إن لم يكن لليابان توقيت صيفيّ، بمجرّد المرور عبر جهاز فرع خارجيّ، أو تكامل مع SaaS أجنبيّ، أو خادم مضبوط بتوقيت UTC، تصطدم بـ«الوقت غير الموجود والوقت الغامض» الخاصّ بالتوقيت الصيفيّ.6
  • تجنَّب كتابة DateTime.Now مباشرةً، واحقن TimeProvider (قياسيّ في .NET 8، وفي البيئات القديمة استخدم Microsoft.Bcl.TimeProvider) لتصميم يسمح بتحريك الوقت بحرّيّة أثناء الاختبار.7

2. خاصيّة Kind في DateTime ── معنى القيم الثلاث وعطل التحويل الضمنيّ

تحمل DateTime إلى جانب قيمة التاريخ والوقت (Ticks) خاصيّةً واحدةً تُدعى Kind. تُوجد ثلاث قيم، وقيمتها الافتراضيّة Unspecified.1

Kind المعنى المصدر الرئيسيّ
Utc وقت مبنيّ على UTC DateTime.UtcNow، نتيجة ToUniversalTime()
Local مبنيّ على المنطقة الزمنيّة المحليّة للجهاز المُنفِّذ DateTime.Now، نتيجة ToLocalTime()
Unspecified غير معروف الأساس (الافتراضيّ) new DateTime(…)‏، DateTime.Parse (في أغلب الحالات)، القراءة من قاعدة البيانات

المهمّ هو أنّ كتابة الكود بشكل عاديّ تُنتج كثرة من قيم Unspecified. القيمة المُنشأة عبر المُنشئ، والقيمة المُحلَّلة من نصّ، والقيمة المقروءة من قاعدة البيانات، كلّها تكون افتراضيًّا «غير معروفة الأساس». هذا بحدّ ذاته ليس مشكلةً. المشكلة أنّ طرق التحويل تفترض الأساس صامتةً عند التعامل مع هذه القيمة غير المحدَّدة.1

الاستدعاء Kind=Utc Kind=Local Kind=Unspecified
ToUniversalTime() تُعاد كما هي تُحوَّل إلى UTC تُفترَض محليّةً وتُحوَّل إلى UTC
ToLocalTime() تُحوَّل إلى محليّة تُعاد كما هي تُفترَض UTC وتُحوَّل إلى محليّة

النقطة الجوهريّة هي عدم التماثل: نفس قيمة Unspecified تُفسَّر تارةً «محليّةً» وتارةً «UTC» بحسب الطريقة المستدعاة. وهذا ما يظهر في الكود على النحو التالي.

// قيمة مقروءة من قاعدة البيانات. في كثير من المسارات تكون Kind = Unspecified
var fromDb = new DateTime(2026, 7, 3, 9, 0, 0);

// إذا كان الجهاز المُنفِّذ بتوقيت JST (UTC+9):
Console.WriteLine(fromDb.ToLocalTime());      // 18:00 ── تُفترَض UTC فتُضاف 9 ساعات
Console.WriteLine(fromDb.ToUniversalTime());  // 00:00 ── تُفترَض محليّةً فتُطرَح 9 ساعات

يحدث العطل بهذا الشكل. إذا استُدعيت ToLocalTime() على القيمة 09:00 (Unspecified) المحفوظة بتوقيت اليابان في قاعدة البيانات، بنيّة «تحويل احترازيّ» قبل العرض، تُفترَض UTC فتصبح 18:00. وبالعكس، إذا وُجد في مكان ما من المسار تحويل مضاعف بنيّة «توحيد التوقيت إلى UTC قبل الحفظ»، تُطرَح 9 ساعات مرّتين. والأصعب من ذلك أنّ هذا السلوك يعتمد على إعداد المنطقة الزمنيّة للجهاز المُنفِّذ. على جهاز التطوير (JST) ينزاح 9 ساعات، بينما على الخادم المضبوط بتوقيت UTC لا ينزاح شيء، فتصل الاستفسارات على شاكلة «لا يتكرّر عندي». وما ورد في المقدّمة «انزاح 9 ساعات بسبب نقل الخادم» هو غالبًا هذا البناء بعينه.

2.1 الفرق مع DateTimeOffset والتمييز بينهما

بما أنّ DateTimeOffset يحمل دائمًا إزاحةً عن UTC (مثل +09:00) إلى جانب التاريخ والوقت، فإنّ القيمة وحدها كافية لتحديد أيّ لحظة في العالم بشكل فريد. بالنسبة لاستخدامات «تسجيل لحظة» مثل تسجيل السجلّات، ووقت المعاملات، وتسجيل أحداث النظام، تنصّ الإرشادات الرسميّة صراحةً على النظر في DateTimeOffset كنوع التاريخ والوقت الافتراضيّ.2 وبما أنّه لا مجال أصلًا للاعتماد على التفسير الضمنيّ لـKind، فإنّ معظم الأعطال التي يتناولها هذا المقال لا تحدث بنيويًّا.

مع ذلك، ليس هذا النوع كليّ القدرة. لاحظ أنّ ما يحمله DateTimeOffset هو الإزاحة، وليست المنطقة الزمنيّة. فلا يمكن معرفة ما إذا كانت +09:00 هي اليابان أم كوريا، ولا يحمل قواعد ضبط التوقيت الصيفيّ.2 إذا أردتَ إعادة إنتاج «حركة ساعة الحائط في تلك المنطقة»، يلزم الجمع بين هذا النوع وTimeZoneInfo الذي سنتناوله لاحقًا. إليك جدول للتمييز بين الاستخدامات.

النوع المعلومات التي يحملها الاستخدام المناسب ملاحظة
DateTimeOffset التاريخ والوقت + إزاحة UTC تسجيل وقت الحدوث، السجلّات، حدود واجهة API الخيار الافتراضيّ للكود الجديد2
DateTime (تشغيل بـKind=Utc) التاريخ والوقت فقط الحساب الداخليّ، التوافق مع الأصول القائمة إدارة Kind بالكامل على عاتقك
DateOnly / TimeOnly التاريخ فقط / الوقت فقط تاريخ العمل، ساعات العمل، وقت الإغلاق غير متاح في .NET Framework2
TimeSpan فاصل زمنيّ الوقت المنقضي، الفرق بين لحظتين  
TimeZoneInfo تعريف المنطقة الزمنيّة (يشمل قواعد الضبط) التحويل، تحديد التوقيت الصيفيّ الفصل 4

بما أنّ إعادة كتابة جميع أصول DateTime القائمة إلى DateTimeOffset غالبًا ما تكون غير واقعيّة، فإنّ الحلّ الوسط الذي نعتمده غالبًا في مشاريع التطوير لدينا هو «توحيد المعالجة الداخليّة والتخزين على DateTime بقيمة Kind=Utc، واقتصار الحدود (واجهة API، التسلسل) على DateTimeOffset أو نصّ بصيغة “o”». وفي كلتا الحالتين، فإنّ مبدأ الفصل التالي هو الأساس.

3. المبدأ ── التخزين والاتّصال بتوقيت UTC أو مع إزاحة، والعرض فقط بالتوقيت المحليّ

يمكن تلخيص مبدأ تصميم معالجة التاريخ والوقت في 3 أسطر.

  1. يُحصَّل وقت الحدوث عبر DateTime.UtcNow أو DateTimeOffset.UtcNow، ويُتداوَل بتوقيت UTC (أو مع إزاحة) كما هو
  2. عند عبور حدود (قاعدة البيانات، واجهة API، الملفّ، السجلّ)، يُوضَّح الصيغة والأساس كمواصفة
  3. يُجرى التحويل إلى الوقت المحليّ مرّةً واحدةً فقط، مباشرةً قبل العرض على الشاشة أو التقرير

التصميم القائم على التخزين بالتوقيت المحليّ هو تصميم يجعل «معنى القيمة» معتمدًا على حالة خارجيّة هي إعداد نظام تشغيل الخادم. ما دام يعمل على خادم يابانيّ الإعداد داخل الشركة، لا تظهر المشكلة، لكن أيّ عنصر واحد من هذه العناصر - النقل إلى VM سحابيّة، وتصميم استعادة الكوارث في منطقة أجنبيّة، والاختلاف بين إعدادات بيئة التطوير والإنتاج - يغيّر المعنى. أمّا مع التخزين بتوقيت UTC، فمعنى القيمة واحد في أيّ بيئة. ويُجرى تحويل العرض حسب إعداد الجهة المستخدمة (أو المنطقة الزمنيّة لفرع المستخدم في جدول المستخدمين)، فتظهر نفس البيانات في طوكيو وبرلين كوقت ساعة حائط صحيح في كلّ منهما.

3.1 التمثيل النصّيّ عند الحدود هو صيغة ISO 8601 / “o”

عند عبور الحدود بنصّ (JSON، CSV، السجلّ، ملفّ الإعدادات)، استخدم صيغة الجولة الكاملة “o” المتوافقة مع ISO 8601. تُبقي “o” على خاصيّة Kind في DateTime وإزاحة DateTimeOffset داخل النصّ، ويمكن استرجاع القيمة الأصليّة بتحديد DateTimeStyles.RoundtripKind عند التحليل.3

using System.Globalization;

// الكتابة: 2026-07-03T13:30:00.0000000+09:00
DateTimeOffset now = DateTimeOffset.Now;
string s = now.ToString("o", CultureInfo.InvariantCulture);

// إعادة القراءة: تُستعاد القيمة مع الحفاظ على الإزاحة
var restored = DateTimeOffset.Parse(
    s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);

احرص دائمًا على استخدام CultureInfo.InvariantCulture معه. فبدون ذلك، وباستخدام الثقافة الافتراضيّة فقط، يتغيّر تدوين السنة على الأجهزة التي تعمل بثقافات غير غريغوريّة كالتقويم الياباني. وما ينبغي تجنّبه بالمقابل هو الحفظ والاتّصال بصيغة لا تحمل معلومة الأساس مثل “yyyy/MM/dd HH:mm”. فالطرف المستقبل لهذا النصّ لا يملك إلّا تخمين «ما الأساس»، والتخمين يخطئ عند تغيّر البيئة. كما أنّ التمثيل الافتراضيّ للتاريخ والوقت في System.Text.Json من نوع ISO 8601 أيضًا، لذا فالاعتماد على الصيغة الافتراضيّة بسذاجة عند حدود JSON آمن.

فكرة «توضيح صيغة البيانات العابرة للحدود كمواصفة» لا تقتصر على التاريخ والوقت. نتناول النقاش نفسه تمامًا بخصوص ترميز الأحرف وترميز نهاية السطر في «ترميز الأحرف ونهاية السطر في Windows». فالسكوت عند الحدود ينكسر بالشكل نفسه، مهما اختلف النوع.

3.2 التمييز بين الطابع الزمنيّ و«تاريخ العمل»

نقطة تمييز أخرى، تحلّ ما ورد في المقدّمة عن «الفروع الخارجيّة وحدها يظهر فيها تاريخ التقرير اليوميّ كأنّه اليوم السابق». الطابع الزمنيّ (لحظة فريدة عالميًّا) وتاريخ العمل (تسمية مثل «تقرير 3 يوليو اليوميّ») أمران مختلفان. إذا حُمل تاريخ العمل بصيغة «DateTime عند منتصف الليل» ومُرِّر عبر تحويل UTC، فإنّ الساعة 00:00 من يوم 3 يوليو بتوقيت UTC+9 تصبح الساعة 15:00 من يوم 2 يوليو بتوقيت UTC، فينزاح إلى اليوم السابق فور اقتطاع جزء التاريخ. هذا هو آليّة الحدوث النمطيّة.

احتفظ بتاريخ العمل بنوع DateOnly (أو، في .NET Framework، بنصّ بصيغة yyyy-MM-dd أو بقيمة سنة-شهر-يوم)، وحدِّد في المواصفة «بأيّ منطقة زمنيّة يُقتطَع التاريخ». إذا كُتب في المواصفة «تاريخ التقرير اليوميّ مبنيّ على الوقت المحليّ للفرع» أو «الإغلاق مبنيّ على توقيت JST للمقرّ الرئيسيّ»، فإنّ التنفيذ يقتصر على تحويل UtcNow إلى المنطقة الزمنيّة المعنيّة ثمّ اقتطاع التاريخ. وإذا لم يُكتَب في المواصفة، فهذا يعني أنّ إعداد جهاز المنفِّذ نفسه أصبح هو المواصفة.

4. تحويل المنطقة الزمنيّة ── TimeZoneInfo ونظام المعرّفات

تُجرى تحويلات المنطقة الزمنيّة عبر ConvertTimeFromUtc / ConvertTimeToUtc / ConvertTime الخاصّة بـTimeZoneInfo. وما يجب الانتباه إليه هو أنّ هذه الواجهات تتحقّق من توافق Kind الخاصّة بـDateTime مع المنطقة الزمنيّة المصدر للتحويل. فمثلًا، إذا مُرِّرت قيمة Kind=Utc على أنّها «المصدر هو طوكيو»، يظهر ArgumentException.8 بعبارة أخرى، فإنّ الكود الذي تُدار فيه Kind بإهمال لا يستطيع استدعاء واجهات تحويل المنطقة الزمنيّة بشكل سليم أصلًا. ما ورد في الفصل 2 يتّصل مباشرةً بهذا.

4.1 معرّفات المنطقة الزمنيّة في Windows ومعرّفات IANA

توجد نظامان لمعرّفات تحديد المنطقة الزمنيّة.

  معرّف Windows معرّف IANA
مثال (اليابان) Tokyo Standard Time Asia/Tokyo
مثال (ألمانيا) W. Europe Standard Time Europe/Berlin
الجهة المُدِيرة Windows (السجلّ) قاعدة بيانات IANA tz
جهة الاستخدام الرئيسيّة واجهات Windows، .NET (Framework) لينكس، SaaS أجنبيّ، واجهات API، لغات أخرى

في عهد .NET Framework، لم يكن يُستخدَم إلّا معرّف Windows، وكانت هناك مشكلة جدول التحويل من نوع «تصل Asia/Tokyo من واجهة API لكن لا يمكن تمريرها إلى FindSystemTimeZoneById». ابتداءً من .NET 6، أصبحت TimeZoneInfo.FindSystemTimeZoneById تقبل كلا نظامي المعرّفات، وتحلّ تلقائيًّا المعرّف غير الموجود في النظام عبر تحويله. وأُضيفت أيضًا TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId لمن يريد التحويل الصريح.4

// .NET 6+ : معرّف IANA يمرّ كما هو (نفس النتيجة أيضًا مع معرّف Windows "Tokyo Standard Time")
var tokyo  = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");

DateTime utc = DateTime.UtcNow;
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(utc, tokyo));   // وقت ساعة الحائط في طوكيو
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(utc, berlin));  // وقت ساعة الحائط في برلين

// التحويل المتبادل بين نظامي المعرّفات (.NET 6+)
if (TimeZoneInfo.TryConvertWindowsIdToIanaId("Tokyo Standard Time", out var ianaId))
    Console.WriteLine(ianaId);  // Asia/Tokyo

من الناحية العمليّة، يُنصَح بـتوحيد معرّف المنطقة الزمنيّة المحفوظ في جدول الفروع أو ملفّ الإعدادات على معرّف IANA. فمعرّف IANA هو ما يُفهَم بشكل مشترك مع SaaS الأجنبيّ وحاويات لينكس واللغات الأخرى، ويمكن لطرف .NET من الإصدار 6 فما بعده قبوله كما هو من حيث المبدأ. وإذا بقي جزء يعمل بـ.NET Framework، تُحوَّل إلى معرّف Windows فقط عند ذلك الحدّ. كما أنّ FindSystemTimeZoneById تَرمي TimeZoneNotFoundException إذا لم يُعثَر على المعرّف، لذا احرص على رفضه مبكّرًا عبر شاشة تسجيل المعرّف في الجدول أو التحقّق عند بدء التشغيل.

يوجد شرط أساسيّ مهمّ واحد. حلّ معرّف IANA على Windows يعتمد على مكتبة ICU. في التطبيقات التي تعمل بوضع NLS أو وضع العولمة الثابتة (InvariantGlobalization=true)، لا يمكن حلّ معرّف IANA، وتفشل أيضًا TryConvertIanaIdToWindowsId.5 كما أنّه عند تشغيل .NET 6 على بيئة قديمة لا يأتي فيها نظام التشغيل مرفقًا بـICU (مثل Windows Server 2019، أو Windows 10 قبل 1809)، يقع نفس القيد ما لم تُرفَق ICU محليّة مع التطبيق (تغيَّر الوضع ابتداءً من .NET 7 بحيث تُستخدَم ICU حتّى على هذه الأنظمة9). بعبارة أخرى، يحدث فعليًّا «يمرّ Asia/Tokyo على جهاز التطوير (Windows 11)، لكن يظهر TimeZoneNotFoundException على خادم Server 2019 لدى العميل وحده». إذا اعتمدتَ معرّف IANA في الجدول، اجمع بين النقاط الثلاث التالية: (1) التحقّق من أنّ حلّ IANA يعمل على نظام التشغيل وإصدار .NET الفعليّين لبيئة التشغيل، (2) عدم تفعيل InvariantGlobalization بغرض تصغير الحجم بسذاجة في الحاويات ونحوها، (3) إدراج آليّة احتياطيّة في التحقّق عند بدء التشغيل تنزل إلى معرّف Windows عبر TryConvertIanaIdToWindowsId وتعيد المحاولة كتأمين.

5. التوقيت الصيفيّ (DST) ── مواقف تصطدم به حتّى في التطبيقات المخصّصة للسوق اليابانيّ فقط

بما أنّه لا يوجد حاليًّا توقيت صيفيّ في اليابان، يُظنّ غالبًا أنّ «DST لا علاقة له بنا». لكن إذا انطبقت أيّ ممّا يلي، فإنّك تصطدم به حتمًا.

  • يعمل التطبيق على أجهزة فروع خارجيّة أو مسافرين إلى الخارج (والمنطقة الزمنيّة المحليّة للجهاز تعتمد التوقيت الصيفيّ)
  • تكامل مع SaaS أو واجهة API أجنبيّة، مع استقبال طوابع زمنيّة أو جداول مبنيّة على الوقت المحليّ
  • تعمل عمليّات التجميع أو الدفعات على خادم أو VM في منطقة أجنبيّة
  • يوجد تجميع يُغلَق بالوقت المحليّ لفرع أجنبيّ (مثل «الإغلاق عند منتصف الليل لكلّ فرع»)

في المناطق الزمنيّة التي تعتمد التوقيت الصيفيّ، يُنشَأ في يوم التبديل نوعان من الأوقات الشاذّة. الوقت غير الموجود (النطاق الذي يُتخطَّى في الربيع عند تقديم الساعة؛ في ألمانيا مثلًا من 02:00 إلى 03:00 نهاية مارس) والوقت الغامض (النطاق الذي يظهر مرّتين في الخريف عند تأخير الساعة). في .NET يمكن التحديد عبر TimeZoneInfo.IsInvalidTime / IsAmbiguousTime.6 وواجهات التحويل مثل ConvertTimeToUtc تَرمي ArgumentException عند تمرير وقت غير موجود، بينما تفسِّر الوقت الغامض على أنّه ضمن التوقيت المعياريّ.8

var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");

// 2026-03-29 هو يوم بدء التوقيت الصيفيّ في ألمانيا. الوقت المحليّ من 02:00 إلى 03:00 غير موجود
var t = new DateTime(2026, 3, 29, 2, 30, 0);   // Kind = Unspecified

Console.WriteLine(berlin.IsInvalidTime(t));    // True
// TimeZoneInfo.ConvertTimeToUtc(t, berlin) يصبح ArgumentException

إذا كان لديك مسار إدخال من نوع «استقبال نصّ الوقت المحليّ وتحويله إلى UTC وحفظه»، فإنّ هذا الاستثناء يكمن كعطل يحدث يومًا واحدًا فقط في السنة. الحلّ العمليّ الواقعيّ هو التحقّق من IsInvalidTime في مرحلة التحقّق من الإدخال، وتوضيح في المواصفة أنّ «الوقت الغامض يُعامَل على أنّه التوقيت المعياريّ».

5.1 التنفيذ الدوريّ والتوقيت الصيفيّ ── مشكلة التشغيل مرّتين أو عدم التشغيل

التنفيذ الدوريّ نقطة أخرى معتادة للإصابة. المهمّة التي تعمل يوميًّا في الساعة 02:30 بالوقت المحليّ لا يوجد هذا الوقت أصلًا في يوم بدء التوقيت الصيفيّ، ويوجد مرّتين في يوم انتهائه. وبحسب تنفيذ المجدوِل، يختلف السلوك بين «يُتخطَّى» و«يعمل مرّتين» و«يعمل بانزياح ساعة»، لذا فإنّ معالجة التجميع المكتوبة على افتراض «تعمل مرّةً واحدةً في اليوم» تُسبِّب تجميعًا مضاعفًا أو نقصانًا. الحلّ توليفة من ثلاثة عناصر.

  • اجعل أساس الجدولة UTC (أو منطقةً زمنيّةً بلا توقيت صيفيّ). الدفعات التي لا يُشترط فيها التشغيل بالوقت المحليّ، تختفي المشكلة بهذا وحده
  • اجعل المعالجة idempotent. بإدراج علامة تنفيذ منجَز من نوع «تخطَّ إذا كان تجميع اليوم المعنيّ موجودًا بالفعل»، لا ينكسر شيء حتّى لو عملت مرّتين
  • احتفظ بمفتاح التجميع كتاريخ عمل (الفصل 3.2). لا تشتقّ التاريخ عكسيًّا من وقت البدء

تصميم التنفيذ الدوريّ عبر مجدوِل المهامّ (منع التشغيل المتعدّد، فصل حالات الفشل) مشروح بالتفصيل في «مهامّ مجدوِل المهامّ لا تعمل أو تنتهي بـ0x1»، أمّا التصميم القائم على مؤقّت داخل خدمة مقيمة فمشروح في «كيفيّة بناء خدمات Windows وتشغيلها». وأيًّا كانت الطريقة، تحتاج العلاقة بين DST والجدولة إلى أن تُكتَب في المواصفة بالشكل نفسه.

6. الحدود مع قاعدة البيانات ── SQL Server / SQLite / ORM

الحدّ الذي تكثر فيه الأعطال أكثر من غيره هو قاعدة البيانات. فأنواع التاريخ والوقت في قواعد البيانات لا تحتفظ في معظمها بمعلومة «ما الأساس»، إذ تُفقَد معلومة Kind أو الإزاحة بمجرّد الحفظ.

6.1 أنواع التاريخ والوقت في SQL Server

النوع النطاق والدقّة معلومة الأساس معيار الاعتماد الجديد
datetime من 1753 فصاعدًا، دقّة نحو 1/300 ثانية لا يوجد تجنَّبه (توصي الجهة الرسميّة صراحةً بعدم استخدامه في الأعمال الجديدة)10
datetime2 من 0001 فصاعدًا، دقّة تصل إلى 100 نانوثانية لا يوجد ◎ الخيار الأوّل للأعمدة المحفوظة بتوقيت UTC10
datetimeoffset يعادل datetime2 + إزاحة يحتفظ بالإزاحة ○ للأعمدة التي يُشترَط فيها إعادة إنتاج الوقت المحليّ10

datetime نوع من جيل قديم بدقّة تقريب خشنة ونطاق ضيّق، وتنصّ الوثائق الرسميّة صراحةً على «تجنّبه في الأعمال الجديدة، واستخدام time / date / datetime2 / datetimeoffset».10 لا داعي لترحيل datetime في المخطّطات القائمة قسرًا، لكن لا يوجد سبب لاختياره في جدول جديد.

يُحدَّد الاختيار بين الحفظ بتوقيت UTC في datetime2 أو استخدام datetimeoffset بحسب «هل تحتاج إلى إعادة إنتاج إزاحة لحظة الإدخال لاحقًا؟». إذا كان هناك متطلّب تدقيق أو امتثال لتسجيل «كم كانت الساعة بالتوقيت المحليّ للمستخدم»، فاستخدم datetimeoffset؛ وإذا كان يكفي تحديد اللحظة فقط، فيكفي datetime2 بتوقيت UTC. لكن ما يحمله datetimeoffset أيضًا هو الإزاحة فقط، وليس المنطقة الزمنيّة (قواعد الضبط) بحدّ ذاتها، وهذا نفس الأمر بالنسبة لـDateTimeOffset. إذا لزمت المنطقة الزمنيّة أيضًا، احتفظ بمعرّف IANA في عمود منفصل.

6.2 لا يوجد لدى SQLite نوع للتاريخ والوقت

لا يملك SQLite أصلًا نوع تخزين للتاريخ والوقت، وMicrosoft.Data.Sqlite تحفظ DateTime / DateTimeOffset كنصّ TEXT.11 صيغة ISO 8601 لنصّ TEXT تجعل «فرز النصّ = فرز الوقت» «طالما الصيغة والمنطقة الزمنيّة موحّدتان»، لكن بالمقابل، بمجرّد اختلاط UTC والوقت المحليّ في عمود واحد، ينكسر الفرز والبحث ضمن نطاق بصمت. عند استخدام SQLite، لا خيار سوى تثبيت «هذا العمود UTC، وصيغته كذا» كقاعدة على مستوى التطبيق. الممارسة العمليّة لـSQLite بما فيها الاتّصال والمعاملات مُجمَّعة في «استخدام SQLite في تطبيق أعمال بلغة C#».

6.3 التنبيه في EF Core / Dapper ── تختفي Kind عند القراءة

عند قراءة DateTime من نوع لا يحمل معلومة الأساس (مثل datetime2 أو نصّ TEXT في SQLite)، تصبح Kind بطبيعة الحال Unspecified. ومن هنا ينشأ عطل من نوع «وحَّدنا الحفظ بتوقيت UTC، لكن وُجد موضع يستدعي ToUniversalTime() على القيمة المقروءة، فحدث تحويل مضاعف». الحلّ هو استعادة Kind عند الحدّ. في EF Core يمكن التصريح بذلك دفعةً واحدةً عبر مُحوِّل قيمة.

// EF Core: التصريح دفعةً واحدةً في النموذج بأنّ «هذا العمود UTC»
modelBuilder.Entity<Order>()
    .Property(o => o.CreatedAtUtc)
    .HasConversion(
        // الكتابة: تُطبَّع إلى UTC عند الحدّ حتّى القيم غير UTC (مثل تسرّب DateTime.Now).
        // لاحظ أنّ Unspecified تُفترَض محليّةً عند التحويل
        v => v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime(),
        // القراءة: استعادة Kind
        v => DateTime.SpecifyKind(v, DateTimeKind.Utc));

التطبيع في جانب الكتابة هو تأمين أخير فحسب. بما أنّ تحويل Unspecified يعتمد على إعداد المنطقة الزمنيّة للجهاز المُنفِّذ، فإنّ الكود الذي يُدخِل DateTime.Now أو قيمًا Unspecified في مسار الحفظ نفسه يجب إصلاحه فور اكتشافه. افهم الأمر كخطّي دفاع: التأمين والقاعدة معًا. في Dapper أو ADO.NET الخام، اجمع طبقة إعادة تعبئة تمرّر عبر DateTime.SpecifyKind في مكان واحد مباشرةً بعد التخطيط (mapping). والفعّال إلى جانب ذلك قاعدة التسمية، إذ يكفي حرق الأساس في اسم العمود والخاصيّة (مثل CreatedAtUtc أو updated_at_utc) لرفع احتمال ملاحظة «ToUniversalTime غريب بالنسبة لهذه القيمة» أثناء المراجعة بشكل كبير. لأنّ الاسم يُقرَأ أكثر من التوثيق.

7. مزامنة الوقت والاختبار ── w32time وTimeProvider

7.1 لا تفترض أنّ ساعة الجهاز صحيحة

كان النقاش حتّى الآن يفترض أنّ «ساعة الجهاز نفسها صحيحة»، لكنّ ما يضبط تلك الساعة هو خدمة Windows Time (w32time). تُزامِن w32time مع مصدر الوقت على الشبكة عبر NTP، وفي بيئات Active Directory تُزامَن وفق هرميّة النطاق (domain). وهي الأساس لآليّات حسّاسة لانزياح الوقت مثل مصادقة Kerberos.12

يحمل هذا مدلولين لتصميم التطبيق. الأوّل، لا تجعل ساعة الحاسوب العميل مرجعًا لمنطق الأعمال. فالأجهزة التي توقّفت مزامنتها قد تنزاح بضع دقائق دون مشكلة، لذا اجعل ترتيب الأحداث والحكم على وقت الإغلاق مبنيّين على وقت جهة الخادم، واقتصر وقت العميل على معلومة مرجعيّة فقط. الثاني، عند استفسار من نوع «الوقت غير صحيح»، تحقّق من حالة مزامنة الجهاز عبر الأمر w32tm /query /status قبل البدء في فحص التطبيق. يمكن فصل هذا الاحتمال في دقيقة واحدة قبل بدء تحقيق أعطال التطبيق.

7.2 تجنَّب كتابة DateTime.Now مباشرةً ── TimeProvider

أكبر عائق أمام اختبار معالجة التاريخ والوقت هو DateTime.Now المكتوبة مباشرةً في أنحاء الكود. حتّى لو أردتَ اختبار «الحكم على إغلاق نهاية الشهر» أو «إعادة تعيين الترقيم التسلسليّ عند تغيّر السنة» أو «جدولة يوم تبديل DST»، فإذا تعذّر تثبيت الوقت الحاليّ، لا يمكن التحقّق منها حتّى يأتي ذلك اليوم فعليًّا.

ابتداءً من .NET 8، أُدرِج المُجرَّد القياسيّ للوقت TimeProvider. يمكن استبدال GetUtcNow() / GetLocalNow() / LocalTimeZone وحتّى إنشاء المؤقّتات عبر مُجرَّد واحد. حتّى .NET Framework 4.6.2 فما بعده و.NET Standard 2.0 يمكنهما استخدام النوع نفسه عبر حزمة NuGet باسم Microsoft.Bcl.TimeProvider، فيمكن إدخاله حتّى في الأصول القديمة. ويُوفَّر التنفيذ الاختباريّ FakeTimeProvider عبر حزمة Microsoft.Extensions.TimeProvider.Testing.7

public sealed class DailyReportService
{
    private readonly TimeProvider _clock;
    private readonly TimeZoneInfo _siteTimeZone;

    public DailyReportService(TimeProvider clock, TimeZoneInfo siteTimeZone)
    {
        _clock = clock;
        _siteTimeZone = siteTimeZone;
    }

    // تنفيذ يوضّح أنّ تاريخ العمل «يُقتطَع بمنطقة الفرع الزمنيّة» (الفصل 3.2)
    public string GetReportDateKey()
    {
        var localNow = TimeZoneInfo.ConvertTime(_clock.GetUtcNow(), _siteTimeZone);
        return localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
    }
}

في الإنتاج، يُمرَّر TimeProvider.System، وفي الاختبار يُثبَّت الوقت أو يُقدَّم بحرّيّة عبر FakeTimeProvider.

[Fact]
public void ينتقل_تاريخ_العمل_بشكل_صحيح_عند_تغيّر_السنة()
{
    // البدء مع تثبيت الوقت عند الساعة 23:30 من ليلة رأس السنة بتوقيت JST
    var clock = new FakeTimeProvider(
        new DateTimeOffset(2026, 12, 31, 23, 30, 0, TimeSpan.FromHours(9)));
    var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
    var svc = new DailyReportService(clock, tokyo);

    Assert.Equal("2026-12-31", svc.GetReportDateKey());

    clock.Advance(TimeSpan.FromHours(1));   // إعادة إنتاج تغيّر السنة في لحظة واحدة
    Assert.Equal("2027-01-01", svc.GetReportDateKey());
}

يمكن لـFakeTimeProvider، إلى جانب تثبيت الوقت وتقديمه يدويًّا، استبدال المنطقة الزمنيّة المحليّة أيضًا (SetLocalTimeZone)، ما يجعل من الممكن إعادة إنتاج عطل من نوع «التاريخ يظهر كأنّه اليوم السابق على جهاز بإعداد ألمانيّ» على جهاز يابانيّ داخل بيئة CI. وإن كان إضافة حزمة NuGet نفسها صعبةً في مشروع قديم بـ.NET Framework، فيكفي أيضًا واجهة IClock ذاتيّة الصنع لا تحمل سوى DateTimeOffset UtcNow { get; } لتحقيق الأثر نفسه. المهمّ ليس فخامة المُجرَّد، بل معاملة «الوقت الحاليّ» كاعتماديّة قابلة للحقن.

حالات الاختبار الدنيا التي يُنصَح بإدراجها هي، بحسب الخبرة، هذه الخمس: نهاية العام وبدايته (تغيّر السنة)، ونهاية الشهر (31 و30 وفبراير)، و29 فبراير في السنة الكبيسة، ويوم تبديل التوقيت الصيفيّ للمنطقة الزمنيّة المستهدَفة (ربيعًا وخريفًا)، وما حول منتصف الليل (حدّ تاريخ العمل). كلّها موائل نمطيّة لأعطال لا تظهر إلّا «في ذلك اليوم بالذات»، ومع FakeTimeProvider يمكن اختبارها جميعًا في بضع ميلّي ثانية.

8. قائمة التحقّق وجدول القرار

معالجة التاريخ والوقت عنصر أساسيّ يُترَك غالبًا دون معالجة بذريعة «يعمل بشكل ما» تمامًا مثل UUID (وهذا شبيه جدًّا بالبناء الذي كتبناه في «هل يتصادم UUID؟»). إذا ثبَّتنا البنود التي نراجعها في التصميم الجديد والمراجعة، يختفي الاعتماد على شخص بعينه.

قائمة التحقّق عند التصميم الجديد:

  • هل تحصيل وقت الحدوث موحَّد على DateTimeOffset.UtcNow / TimeProvider.GetUtcNow()؟
  • هل أساس الحفظ والاتّصال (UTC أو مع إزاحة) والصيغة (“o” / ISO 8601) موضَّحان في وثيقة المواصفات؟
  • هل نوع عمود قاعدة البيانات وأساسه محدَّدان (في SQL Server: datetime2 بتوقيت UTC أو datetimeoffset، مع حرق Utc في اسم العمود)؟
  • هل مُيِّز بين الطابع الزمنيّ وتاريخ العمل، وحُدِّدت المنطقة الزمنيّة التي يُقتطَع بها التاريخ؟
  • هل نظام إدارة معرّف المنطقة الزمنيّة (يُنصَح بمعرّف IANA) ومعالجة الخطأ عند معرّف غير صالح محدَّدان؟
  • هل حُدِّدت سياسة DST للتنفيذ الدوريّ (جدولة مبنيّة على UTC + جعله idempotent)؟
  • هل TimeProvider / IClock قابل للحقن، وتوجد حالات اختبار لحدود الوقت؟

كود يُلتقَط بـgrep عند المراجعة، وينبغي الشكّ فيه:

الكود الذي إن وُجد فاشكَّ فيه ما الذي قد يحدث طريقة الإصلاح
DateTime.Now ينزاح عند نقل الخادم. لا يمكن اختباره UtcNow + التحويل عند العرض. حقن TimeProvider
ToLocalTime() / ToUniversalTime() تفسير ضمنيّ لـUnspecified (الفصل 2) تثبيت Kind عند الحدّ، والتحويل فقط مباشرةً قبل العرض
DateTime.Parse(s) (بلا تحديد styles) يعتمد على ثقافة بيئة التنفيذ ومنطقتها الزمنيّة ParseExact + InvariantCulture + RoundtripKind
الحفظ أو الاتّصال عبر ToString(“yyyy/MM/dd HH:mm”) تختفي معلومة الأساس صيغة “o” + InvariantCulture
استخدام new DateTime(…) مباشرةً في المقارنة أو الحفظ تسرّب Unspecified SpecifyKind أو التحويل إلى DateTimeOffset
عمود datetime جديد في SQL Server الدقّة والنطاق والمستقبليّة datetime2 / datetimeoffset10

معظم بنود قائمة التحقّق هذه يمكن حسمها في اليوم الأوّل إذا كان التطوير جديدًا. أمّا الإصلاح بعد التشغيل، فيتحوّل بالمقابل إلى عمل تخمين أساس البيانات المحفوظة بالفعل وترحيلها (نوع من علم الآثار: أيّ فترة من البيانات أُدخِلت وفق أيّ أساس)، وتتغيّر التكلفة بمقدار رتبة كاملة.

9. الخلاصة

أعطال التاريخ والوقت والمناطق الزمنيّة تتفاوت أعراضها - «ينزاح 9 ساعات» «التاريخ يصبح اليوم السابق» «الدفعة تعمل مرّتين» - لكنّ السبب واحد باستمرار: قيمة لا تحمل معلومة الأساس تعبر حدودًا. والعلاج أيضًا واحد باستمرار. التحصيل بتوقيت UTC، والحفظ والاتّصال بتوقيت UTC أو مع إزاحة + ISO 8601 (“o”)، والعرض فقط بالتوقيت المحليّ. عند الحدود، وضِّح الصيغة والأساس في المواصفة، واحرق الأساس في اسم عمود قاعدة البيانات. تعامَل مع المناطق الزمنيّة عبر TimeZoneInfo ومعرّف IANA، واستقبِل الوقت غير الموجود والوقت الغامض الخاصّين بـDST عبر التحقّق من الإدخال والتنفيذ الدوريّ المصمَّم بشكل idempotent. واحقن TimeProvider لجعل تغيّر السنة ويوم تبديل DST قابلَين لإعادة الإنتاج في الاختبار ── إذا وصلتَ إلى هنا، تنتقل من طرف يُستدعى في يوم تتغيّر فيه البيئة، إلى طرف يشير إلى المشكلة قبل حدوث التغيير.

نتعامل لدينا مع تحقيق أسباب انزياح الوقت المصاحب لنقل الخادم أو التحوّل إلى السحابة، ومراجعة تصميم معالجة التاريخ والوقت، ودعم إصلاح دعم الفروع الخارجيّة (المنطقة الزمنيّة وDST). ونساعد أيضًا فيما يخصّ جرد الحالات التي اختلط فيها أساس البيانات المحفوظة بالفعل وخطّة الترحيل، فلا تتردَّدوا في التواصل معنا عند التردّد في القرار.

مقالات ذات صلة

مجالات الاستشارة ذات الصلة

تتعامل شركة كومورا سوفت المحدودة مع مراجعة تصميم معالجة التاريخ والمنطقة الزمنيّة في تطبيقات الأعمال، وتحقيق أسباب انزياح الوقت أو التاريخ المصاحب لنقل الخادم أو التحوّل إلى السحابة، وإصلاح معالجة التاريخ والوقت المتعلّق بدعم الفروع الخارجيّة أو الأصول القديمة.

روابط مرجعيّة

  1. Microsoft Learn، DateTime.Kind Property. حول أنّ القيمة الافتراضيّة لـKind هي Unspecified، وتأثير قيمة Kind على نتيجة تحويل ToLocalTime / ToUniversalTime (أنّ Unspecified تُفترَض UTC في ToLocalTime ومحليّةً في ToUniversalTime).  2 3

  2. Microsoft Learn، Choose between DateTime, DateOnly, DateTimeOffset, TimeSpan, TimeOnly, and TimeZoneInfo. حول ضرورة النظر في DateTimeOffset كنوع التاريخ والوقت الافتراضيّ لتطوير التطبيقات، وأنّ DateTimeOffset يحمل الإزاحة فقط ولا يرتبط بمنطقة زمنيّة، وأنّ DateOnly / TimeOnly غير متاحين في .NET Framework.  2 3 4 5

  3. Microsoft Learn، Standard date and time format strings. حول أنّ صيغة الجولة الكاملة “o” متوافقة مع ISO 8601 وتحتفظ بـKind الخاصّة بـDateTime وبإزاحة DateTimeOffset داخل النصّ، وأنّها تعود بالتحليل عبر DateTimeStyles.RoundtripKind.  2

  4. Microsoft Learn، What’s new in .NET 6. حول أنّ TimeZoneInfo.FindSystemTimeZoneById في .NET 6 تقبل معرّفات IANA وWindows معًا مع التحويل التلقائيّ، وإضافة TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId.  2

  5. Microsoft Learn، .NET globalization and ICU. حول اعتماد حلّ معرّف المنطقة الزمنيّة IANA وواجهات التحويل المتبادل على Windows على ICU، وعدم إمكانيّة استخدامها في وضع NLS أو وضع العولمة الثابتة، وإمكانيّة التعامل عبر ICU محليّة مع التطبيق على أنظمة تشغيل لا تملك ICU.  2

  6. Microsoft Learn، TimeZoneInfo.IsInvalidTime(DateTime) Method. حول تعريف «الوقت غير الموجود» الناتج عن الانتقال إلى التوقيت الصيفيّ وطريقة تحديده، وIsAmbiguousTime المقابلة له (تحديد الوقت الغامض).  2

  7. Microsoft Learn، What is TimeProvider?. حول تضمين TimeProvider قياسيًّا ابتداءً من .NET 8، وإمكانيّة استخدامه في .NET Framework 4.6.2+ و.NET Standard 2.0 عبر حزمة Microsoft.Bcl.TimeProvider، والمُجرَّد الخاصّ بـGetUtcNow / GetLocalNow / LocalTimeZone وإنشاء المؤقّتات، وتوفير FakeTimeProvider الاختباريّ عبر حزمة Microsoft.Extensions.TimeProvider.Testing.  2

  8. Microsoft Learn، TimeZoneInfo.ConvertTime Method. حول اشتراط توافق DateTime.Kind مع المنطقة الزمنيّة المصدر للتحويل وظهور ArgumentException عند عدم التوافق، وتفسير الوقت الغامض كتوقيت معياريّ، وظهور ArgumentException عند تمرير وقت غير موجود.  2

  9. Microsoft Learn، Globalization APIs use ICU libraries on Windows Server 2019. حول أنّه ابتداءً من .NET 7، أصبحت مكتبة ICU تُستخدَم حتّى على أنظمة مثل Windows Server 2019 التي لا ترفق ICU، وأنّه قبل ذلك كان يلزم نشر ICU محليّة يدويًّا. 

  10. Microsoft Learn، datetime (Transact-SQL). حول ضرورة تجنّب datetime في الأعمال الجديدة واستخدام time / date / datetime2 / datetimeoffset، ودقّة datetime2 / datetimeoffset العالية ودعم datetimeoffset لإزاحة المنطقة الزمنيّة.  2 3 4 5

  11. Microsoft Learn، Data types (Microsoft.Data.Sqlite). حول أنّ SQLite لا يملك سوى 4 أنواع أوّليّة، وأنّ Microsoft.Data.Sqlite تحفظ DateTime / DateTimeOffset كنصّ TEXT. 

  12. Microsoft Learn، Windows Time Service (W32Time). حول مزامنة خدمة Windows Time لوقت الحواسيب على الشبكة عبر NTP، وهرميّة المزامنة في نطاق Active Directory، واعتماد مصادقة Kerberos على مزامنة الوقت. 

أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.

ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.

الأسئلة الشائعة

أسئلة شائعة حول موضوع هذه المقالة.

لماذا ينزاح الوقت 9 ساعات بعد نقل الخادم؟
السبب الجذريّ هو أنّ قيمةً لا تحمل معلومة «ما الأساس الذي يستند إليه هذا الوقت» تعبر حدودًا مثل قاعدة البيانات وواجهة API والملفّات. تكون خاصيّة Kind في DateTime بلغة .NET افتراضيًّا Unspecified، وتقوم ToLocalTime() بافتراض أنّ القيمة UTC وتحويلها بإضافة 9 ساعات صامتةً، بينما تقوم ToUniversalTime() بافتراض أنّها محليّة وتحويلها بطرح 9 ساعات صامتةً. يعتمد هذا السلوك على إعداد المنطقة الزمنيّة للجهاز الذي يُنفَّذ عليه الكود، لذا لا تظهر المشكلة على جهاز التطوير بتوقيت اليابان، وتنكشف دفعةً واحدةً في اليوم الذي يُنقَل فيه التطبيق إلى VM سحابيّة مضبوطة بتوقيت UTC.
أيّهما ينبغي استخدامه، DateTime أم DateTimeOffset؟
الخيار الافتراضيّ للكود الجديد هو DateTimeOffset. فهو يحمل دائمًا إزاحةً عن UTC، ما يجعل القيمة وحدها كافيةً لتحديد أيّ لحظة في العالم بشكل فريد، ولا تحدث أعطال بنيويّة ناتجة عن التفسير الضمنيّ لـKind. وتنصّ الإرشادات الرسميّة صراحةً على النظر فيه كنوع التاريخ والوقت الافتراضيّ لتطوير التطبيقات. لكن بما أنّ الإزاحة ليست منطقةً زمنيّةً بحدّ ذاتها، فإذا لزمت قواعد ضبط التوقيت الصيفيّ، يُدمَج معه TimeZoneInfo. وإذا كانت أصول DateTime القائمة كثيرةً، فالحلّ الوسط العمليّ هو توحيد المعالجة الداخليّة والتخزين على DateTime بقيمة Kind=Utc، وجعل DateTimeOffset مقتصرًا على الحدود فقط.
هل يلزم التعامل مع التوقيت الصيفيّ (DST) حتّى في تطبيق مخصّص للسوق اليابانيّ فقط؟
نعم، إذا انطبقت أيّ من الحالات التالية: يعمل التطبيق على أجهزة فروع خارج اليابان أو مسافرين إلى الخارج، أو يتكامل مع SaaS أو واجهة API أجنبيّة، أو تعمل عمليّة دفعيّة على خادم في منطقة أجنبيّة. ففي المناطق الزمنيّة التي تعتمد التوقيت الصيفيّ، يُنشَأ في يوم التبديل «وقت غير موجود» و«وقت غامض»، وتَرمي واجهات برمجة تحويل TimeZoneInfo استثناء ArgumentException عند تمرير وقت غير موجود. أمّا المهمّة الدوريّة التي تعمل في الساعة 02:30 بالتوقيت المحليّ، فإمّا تُتخطَّى في يوم بدء التوقيت الصيفيّ أو تعمل مرّتين في يوم انتهائه، لذا فالعلاج هو جدولة قائمة على UTC ومعالجة idempotent (لا يتغيّر أثرها بتكرار التنفيذ).
كيف تُكتَب اختبارات معالجة التاريخ والوقت؟
تجنَّب كتابة DateTime.Now مباشرةً في الكود، واحقن TimeProvider القياسيّ في .NET 8 لجعل الوقت الحاليّ قابلًا للاستبدال. حتّى في .NET Framework 4.6.2 فما بعده، يمكن استخدام النوع نفسه عبر حزمة Microsoft.Bcl.TimeProvider. في الاختبارات، يمكن لـFakeTimeProvider تثبيت الوقت أو تقديمه، كما يمكن استبدال المنطقة الزمنيّة المحليّة، ما يجعل من الممكن إعادة إنتاج أعطال تغيّر السنة أو يوم تبديل التوقيت الصيفيّ على بيئة CI. الحدّ الأدنى من الأوقات التي ينبغي اختبارها هو خمسة: نهاية العام وبدايته، ونهاية الشهر، و29 فبراير في السنة الكبيسة، ويوم تبديل التوقيت الصيفيّ، وما حول منتصف الليل.

الملف الشخصي للمؤلف

صفحة الملف الشخصي لمؤلف المقالة.

غو كومورا

مؤسّس شركة كومورا سوفت ذ.م.م.

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

روابط عامة

العودة إلى المدونة