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

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

سجل التعديلات (2 تحديثات، آخر تحديث 2 Sep، 2026)

سجل بالتغييرات التي أُجريت على هذا المقال. وحيثما حُفظت نسخة سابقة، تبقى متاحة للقراءة عبر رابط دائم يحمل معرّف DOI.

أُعيدَت الترجمة العربية كترجمة كاملة عن النص الياباني الأصلي، وأُضيفَت خريطة المعرفة.
أعيدت الترجمة كترجمة كاملة عن النص الياباني الأصلي. كانت النسخة العربية السابقة مختصراً يسقط أبواباً وجداول ورسوم Mermaid وتعليقات الأشكال وFAQ. أُعيدت هذه العناصر وفق الأصل الياباني، والادّعاءات التقنية مطابقة للنسخة اليابانية.
النشر الأول
الاستشهاد بهذا المقال(DOI: 10.5281/zenodo.21621614)

هذا المقال محفوظ على Zenodo. يرد أدناه معرّف DOI الذي يشير دائمًا إلى أحدث نسخة، ومعرّف DOI المثبَّت على النسخة التي تقرؤها.

غو كومورا (2026). التاريخ والوقت والمناطق الزمنية في تطبيقات الأعمال ── من فخاخ DateTime إلى مبدأ التخزين بـ UTC وتصميم الاختبارات. شركة كومورا سوفت ذ.م.م.. https://doi.org/10.5281/zenodo.21621614 https://comcomponent.com/ar/blog/business-app-datetime-timezone-guide/

DOI (أحدث نسخة)
10.5281/zenodo.21621614
DOI (هذه النسخة)
10.5281/zenodo.22241000

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

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

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

جمهور المقال ومسارات القراءة

الجمهور المستهدف مطوّرون يبنون تطبيقات أعمال بـ .NET (C#) أو يصونونها. من يظن «تطبيقنا محلي فلا علاقة له بالمناطق الزمنية» هو الجمهور المقصود بالذات. أمثلة الشيفرة بـ C# / .NET 8، وننبّه في موضعها إلى ما ينطبق أيضاً على .NET Framework.

المقال طويل، فنضع أولاً مسارات قراءة حسب الغرض.

الغرض أين تقرأ
تطبيق محلي صغير، وتريد الحد الأدنى فقط الفصل 1 (الخلاصة) ← الفصل 2 (فخ Kind) ← الفصل 3 (مبدأ التخزين بـ UTC) ← الفصل 6 (حدود قاعدة البيانات). هذه الأربعة تمنع معظم الحوادث
تريد فصل سبب انزياح يحدث الآن الفصل 2، وخصوصاً خطوات إعادة الإنتاج في القسم 2.1 ← «شيفرة تلتقطها بـ grep عند المراجعة» في الفصل 8
لديك فروع خارجية أو تكامل مع SaaS أجنبي ما سبق إضافة إلى الفصل 4 (معرّفات المناطق الزمنية) والفصل 5 (التوقيت الصيفي)
تريد إعادة إنتاج أعطال تغيّر السنة أو نهاية الشهر في الاختبار القسم 7.2 (TimeProvider)
تريد زوايا مراجعة التصميم الجديد فقط قائمة التحقّق في الفصل 8

مصطلحات تثبتها أولاً

نلخّص المصطلحات التي تظهر في النص مختصرة.

المصطلح المعنى
UTC (Coordinated Universal Time) التوقيت العالمي المنسَّق. مرجع مشترك للعالم، وتوقيت اليابان (JST) هو UTC+9
ISO 8601 معيار دولي لتدوين التاريخ والوقت كنص. شكل مثل 2026-07-03T13:30:00+09:00
IANA tz database قاعدة بيانات تجمع تعريفات المناطق الزمنية في العالم وتاريخ تعديلاتها. تديرها IANA (Internet Assigned Numbers Authority) وتحدّد معرّفات مثل Asia/Tokyo (القسم 4.1)
ICU (International Components for Unicode) مكتبة قياسية للمعالجة الدولية. منذ .NET 5 يُعهَد إليها بحل الثقافة والمناطق الزمنية (القسم 4.1)1
NLS (National Language Support) واجهة دولية يملكها Windows من قبل ICU. يمكن تشغيل .NET بوضع NLS، لكن معرّفات IANA لا تُحل حينها1
DST (Daylight Saving Time) التوقيت الصيفي. لا يوجد حالياً في اليابان، لكنك تصطدم به مع الفروع الخارجية وSaaS الأجنبية (الفصل 5)
Kind سمة في DateTime بمعنى «ما الأساس». ثلاث قيم: Utc / Local / Unspecified (الفصل 2)

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

في سطر واحد: «التحصيل والحفظ والاتصال بـ UTC (أو مع إزاحة)، والتحويل إلى الوقت المحلي مرّة واحدة فقط مباشرة قبل العرض على الشاشة». فيما يلي التفصيل، وما يحدث إن خُرق.

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

في المخطّط، يشير الخطّ المتّصل إلى علاقة قائمة دائماً، ويشير الخطّ المتقطّع إلى علاقة مشروطة (شروط قيامها مذكورة في شرح كلّ علاقة في الصفحة التفصيليّة). القائمة الكاملة للعلاقات (المجموع 21، مع الأدلّة ودرجة اليقين) وتعريفات المفاهيم الرئيسة مجمّعة في صفحة تفاصيل خريطة المعرفة (باليابانية). البيانات: JSON-LD / Turtle

2. Kind في DateTime ── معنى القيم الثلاث وحادث التحويل الضمني

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

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

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

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

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

// DB から読み出した値。多くの経路で 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 إعادة إنتاج «انزياح 9 ساعات» على جهازك ── تجربة بخمس دقائق

أسرع طريقة للخروج من «لا يتكرّر على جهاز التطوير» هي تغيير المنطقة الزمنية لجهاز التطوير وتشغيل الشيفرة نفسها. تنتهي في خمس دقائق.

أولاً، جهّز تطبيق الكونسول التالي.

// .NET 8 / C#. قيمة Unspecified تحاكي «9:00 بتوقيت اليابان» المقروءة من قاعدة البيانات
var fromDb = new DateTime(2026, 7, 3, 9, 0, 0);

Console.WriteLine($"المنطقة الزمنية لنظام التشغيل: {TimeZoneInfo.Local.Id}");
Console.WriteLine($"Kind              : {fromDb.Kind}");
Console.WriteLine($"ToLocalTime()     : {fromDb.ToLocalTime():HH:mm}");
Console.WriteLine($"ToUniversalTime() : {fromDb.ToUniversalTime():HH:mm}");

الخطوات كالتالي. لتبديل المنطقة الزمنية استخدم tzutil القياسي في Windows (إن رُفض التغيير، شغّل موجه الأوامر كمسؤول).

  1. احفظ معرّف المنطقة الزمنية الحالي بـ tzutil /g (لتعيده لاحقاً). على جهاز تطوير بإعداد اليابان يظهر Tokyo Standard Time.
  2. شغّل كما هو واحفظ النتيجة.
  3. غيّر المنطقة الزمنية إلى UTC بـ tzutil /s "UTC"، ثم أعد تشغيل التطبيق وانظر النتيجة نفسها. TimeZoneInfo.Local يُخزَّن مؤقّتاً داخل العملية، فلا يتبدّل إن بقي التطبيق يعمل.
  4. أعد الأصل بـ tzutil /s "Tokyo Standard Time".

بالشيفرة نفسها والقيمة المدخلة نفسها، تتغيّر النتيجة كالتالي.

المنطقة الزمنية للجهاز المنفّذ ToLocalTime() ToUniversalTime()
Tokyo Standard Time (UTC+9) 18:00 (تُفترض UTC فتُضاف 9 ساعات) 00:00 (تُفترض محلية فتُطرح 9 ساعات)
UTC 09:00 (لا تغيّر) 09:00 (لا تغيّر)

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

لا يمكن استبدال هذه التجربة بـ FakeTimeProvider. ما يبدّله FakeTimeProvider.SetLocalTimeZone هو خاصية LocalTimeZone لذلك TimeProvider فقط، ولا تتغيّر TimeZoneInfo.Local للعملية كلّها.7 وما تراه ToLocalTime() / ToUniversalTime() في الشيفرة أعلاه هو الثانية، لذا حتى مع وضع وهمي تظهر نتيجة إعداد جهاز CI كما هي.

وبالعكس، هذا أيضاً سبب طرد ToLocalTime() من شيفرة الأعمال. الدوال التي تقرأ إعداد العملية كلّها لا يمكن استبدالها من الاختبار. إن أردت وضع معالجة المنطقة الزمنية على اختبارات الانحدار، اجعل وجهة التحويل وسيطاً.

// .NET 8 / C#. مرِّر وجهة التحويل من الخارج. إن حقنت TimeProvider
// فمرِّر provider.LocalTimeZone
static DateTime JstWallClockToUtc(DateTime wallClock, TimeZoneInfo zone)
    => TimeZoneInfo.ConvertTimeToUtc(
           DateTime.SpecifyKind(wallClock, DateTimeKind.Unspecified), zone);

// اختبار: النتيجة نفسها بغضّ النظر عن إعداد المنطقة الزمنية لجهاز التنفيذ
var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time");
Assert.Equal(
    new DateTime(2026, 7, 3, 0, 0, 0, DateTimeKind.Utc),          // 9:00 JST = 0:00 UTC
    JstWallClockToUtc(new DateTime(2026, 7, 3, 9, 0, 0), tokyo));

FakeTimeProvider في القسم 7.2 يعمل فقط عندما تمر الشيفرة المستهدفة عبر provider.GetLocalNow() أو provider.LocalTimeZone. لا يصل إلى شيفرة تستدعي DateTime.Now أو ToLocalTime() مباشرة. إن أردت التحقّق من أن إعداد الجهاز نفسه يغيّر الناتج، فخطوات tzutil أعلاه هي في النهاية الأضمن.

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

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

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

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

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

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

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

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

في الشكل، موضع التحويل المسموح به موضع واحد فقط.

لا تفعل هذاالحدوث والتحصيلDateTimeOffset.UtcNowTimeProvider.GetUtcNow()داخل التطبيقالتداول بـ UTC كما هوالمقارنة والترتيب أيضاً بـ UTCقاعدة البياناتحفظ UTC في datetime2اسم العمود CreatedAtUtcواجهة API والملفات والسجلاتصيغة الجولة الكاملة o وفق ISO 86012026-07-03T04:30:00.0000000Zتحويل واحد مباشرة قبل العرضTimeZoneInfo.ConvertTimeFromUtcالوجهة تتحدّد بإعداد المستخدم أو الفرعالشاشة والتقريروقت ساعة الحائط في تلك الأرضالحفظ والإرسال بالوقت المحلي كما هومعنى القيمة يعتمد على إعداد نظام التشغيلينزاح 9 ساعات يوم النقل

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

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

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

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 إلى المنطقة الزمنية المعنية ثم اقتطاع التاريخ. وإذا لم يُكتب في المواصفة، فهذا يعني أن إعداد جهاز المنفّذ نفسه أصبح هو المواصفة.

إن أسقطت ذلك في تصميم الجدول، يختلف النوع من الأساس. بمثال SQL Server يكون كالتالي (تفاصيل الأنواع في القسم 6.1).

الغرض مثال العمود (SQL Server) النوع في جانب .NET القصد
طابع زمني (لحظة الحدوث) created_at_utc datetime2(3) NOT NULL DateTime (Kind=Utc) الحفظ بـ UTC وحرق الأساس في اسم العمود (القسم 6.3)
طابع زمني (مطلوب إعادة إنتاج الوقت المحلي) signed_at datetimeoffset(3) NOT NULL DateTimeOffset الإبقاء حتى على إزاحة لحظة الإدخال
تاريخ عمل (تسمية التقرير اليومي والإغلاق) report_date date NOT NULL DateOnly لا وقت ولا منطقة زمنية
المنطقة الزمنية التي يُقطع بها التاريخ site_time_zone_id varchar(64) NOT NULL string (يُحل إلى TimeZoneInfo) احفظه بمعرّف IANA في جدول الفروع (القسم 4.1)
وقت الإغلاق وساعات العمل closing_time time(0) NOT NULL TimeOnly لا تُحمّل «17:30 كل يوم» تاريخاً

المطلوب باختصار: لا تستخدم نوعاً يحمل وقتاً لعمود تاريخ العمل. في اللحظة التي تجعل فيها report_date من نوع datetime2 وتضع 00:00، يعود مسار انزياح اليوم السابق المذكور في صدر هذا القسم. والعكس، بنوع date لا مجال أصلاً لتمرير تحويل UTC.

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) Linux، SaaS أجنبي، واجهات API، لغات أخرى

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

// .NET 6+ : IANA ID がそのまま通る(Windows ID "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));  // ベルリンの壁時計時刻

// ID 体系の相互変換 (.NET 6+)
if (TimeZoneInfo.TryConvertWindowsIdToIanaId("Tokyo Standard Time", out var ianaId))
    Console.WriteLine(ianaId);  // Asia/Tokyo

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

يوجد شرط أساسي مهم واحد. حل معرّف IANA على Windows يعتمد على مكتبة ICU. في التطبيقات التي تعمل بوضع NLS أو وضع العولمة الثابتة (InvariantGlobalization=true)، لا يمكن حل معرّف IANA، وتفشل أيضاً TryConvertIanaIdToWindowsId. 1 كما أنه عند تشغيل .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 はドイツの DST 開始日。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 以外(DateTime.Now の混入など)も境界で UTC に正規化する。
        // 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 في مكان واحد مباشرة بعد التخطيط. والفعّال إلى جانب ذلك قاعدة التسمية، إذ يكفي حرق الأساس في اسم العمود والخاصية (مثل CreatedAtUtc أو updated_at_utc) لرفع احتمال ملاحظة «ToUniversalTime غريب بالنسبة لهذه القيمة» أثناء المراجعة بشكل كبير. لأن الاسم يُقرأ أكثر من التوثيق.

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

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

كان النقاش حتى الآن يفترض أن «ساعة الجهاز نفسها صحيحة»، لكن ما يضبط تلك الساعة هو خدمة Windows Time (w32time). تزامن w32time مع مصدر الوقت على الشبكة عبر NTP، وفي بيئات Active Directory تُزامَن وفق هرمية النطاق. وهي الأساس لآليات حسّاسة لانزياح الوقت مثل مصادقة 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 年跨ぎでも業務日付が正しく切り替わる()
{
    // JST の大晦日 23:30 に固定して開始
    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. لكن ما يعمل هو فقط عندما تمر الشيفرة المستهدفة عبر provider.GetLocalNow() أو provider.LocalTimeZone. ما يُستبدل هو المنطقة الزمنية التي يعيدها ذلك المزوّد، لا TimeZoneInfo.Local للعملية كلّها، 7 فلا يصل إلى شيفرة تستدعي DateTime.Now أو ToLocalTime() مباشرة (القسم 2.1). هذه نتيجة بديهية: الشيفرة التي لا تمر عبر الساعة المحقونة لا يمكن استبدالها من الاختبار. وإن كان إضافة حزمة 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, .NET globalization and ICU. حول اعتماد حل معرّف المنطقة الزمنية IANA وواجهات التحويل المتبادل على Windows على ICU، وعدم إمكانية استخدامها في وضع NLS أو وضع العولمة الثابتة، وإمكانية التعامل عبر ICU محلية مع التطبيق على أنظمة تشغيل لا تملك ICU.  2 3 4

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

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

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

  5. Microsoft Learn, What’s new in .NET 6. حول أن TimeZoneInfo.FindSystemTimeZoneById في .NET 6 تقبل معرّفات IANA وWindows معاً مع التحويل التلقائي، وإضافة TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId.  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. TimeProvider.LocalTimeZone خاصية تعيد «المنطقة الزمنية المحلية وفق تصوّر ذلك TimeProvider للوقت»، والتنفيذ الافتراضي يعيد TimeZoneInfo.Local. أي أن ما يستبدله FakeTimeProvider هو التحويل عبر ذلك المزوّد فقط، ولا تتغيّر TimeZoneInfo.Local للعملية كلّها (القيمة التي ترجع إليها DateTime.ToLocalTime() وغيرها).  2 3 4

  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 على مزامنة الوقت. 

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

معالجة التقويم الياباني والعطلات الرسمية وتاريخ الإغلاق في تطبيقات الأعمال ── تصميم يقاوم تغيّر العصر، وJapaneseCalendar، وحساب أيام العمل

نرتّب معالجة التواريخ الخاصة بتطبيقات الأعمال اليابانية: عرض التقويم الياباني، وحساب أيام العمل باستثناء العطلات، وتاريخ الإغلاق. نشرح ال...

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

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

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

لماذا ينزاح الوقت 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 فبراير في السنة الكبيسة، ويوم تبديل التوقيت الصيفي، وما حول منتصف الليل.

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

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

غو كومورا

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

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

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