استفاده‌ی گسترده از DateTimeOffset در NET Core.
اندازه‌ی قلم متن
تخمین مدت زمان مطالعه‌ی مطلب: پنج دقیقه

اگر به سورس‌های ASP.NET Identity نگارش‌های 2 و 3 دقت کنیم، این تفاوت به وضوح قابل مشاهده‌است:
در نگارش 2
public virtual DateTime? LockoutEndDateUtc { get; set; }
در نگارش 3
public virtual DateTimeOffset? LockoutEnd { get; set; }
و در کل، در طراحی تمام قسمت‌ها و اجزای NET Core. بجای استفاده‌ی از DateTime متداول، شاهد استفاده‌ی گسترده‌ای از DateTimeOffset هستیم که از زمان ارائه‌ی NET 3.5. معرفی شده‌است. چرا؟


مشکل ساختار DateTime چیست؟

تمام کسانیکه مدتی با NET Framework. کار کرده‌اند، قطعا از ساختار DateTime برای ذخیره سازی اطلاعاتی زمانی محلی استفاده کرد‌ه‌اند. اما مشکل DateTime چیست؟
فرض کنید در حال استفاده‌ی از یک وب سرویس قرار گرفته‌ی در یک منطقه‌ی زمانی غربی هستید و این وب سرویس تاریخ تولد افراد را با یک چنین فرمتی ارائه می‌دهد:
 2012-03-01 00:00:00-05:00
در این حالت برای استفاده‌ی متداول از این زمان می‌توان به صورت زیر عمل کرد:
 var dateString = "2012-03-01 00:00:00-05:00";
var birthDay = DateTime.Parse(dateString);
هرچند این عملیات ساده به نظر می‌رسد، اما با توجه به قرارگیری سرور برنامه در یک منطقه‌ی زمانی دیگر، زمان پردازش شده به صورت ذیل خواهد بود:
 2012-02-29 11:00:00 PM
اتفاقی که رخ داده‌است، تبدیل DateTime رسیده به زمان محلی سرور است و در این حالت تاریخ تولد شخص از یکم ماه، به 29 ام ماه قبل تغییر کرده‌است. علت آن هم وجود 05:00 یا offset (فاصله‌ی با UTC) در تاریخ ارائه شده‌است.
چگونه می‌توان offset را در تاریخ ذکر کرد، اما از تبدیل آن به زمان محلی جلوگیری کرد؟ این مورد جایی‌است که ساختار DateTimeOffset بکار خواهد آمد.


DateTimeOffset و ذخیره‌ی DateTime به همراه Offset

ساختار کلی DateTimeOffset بسیار واضح بوده و تشکیل شده‌است از Date + Time + Offset. اهمیت آن نیز به ذخیره سازی اطلاعات منطقه‌ی زمانی، در قسمت Offset ساختار ارائه شده بر می‌گردد. ساختار DateTimeOffset در بسیاری از موارد با DateTime متداول یکسان است و تفاوت‌های آن شامل خواص اضافی ذیل هستند:
- DateTime: قسمت DateTime مقدار را بدون توجه به offset باز می‌گرداند (به زمان محلی تبدیل نخواهد شد).
- LocalDateTime: قسمت DateTime را با توجه به منطقه زمانی سروری که برنامه بر روی آن اجرا می‌شود، بر می‌گرداند.
- Offset: فاصله‌ی زمانی با UTC را بیان می‌کند. یک TimeSpan است که فاصله‌ی با UTC را بیان می‌کند.
- UtcDateTime: قسمت DateTime را با توجه به UTC time ارائه می‌کند.

در این ساختار خواص Now و UtcNow نیز یک DateTimeOffset را باز می‌گردانند.


چه زمانی از DateTime و چه زمانی از DateTimeOffset استفاده کنیم؟

اگر هدف شما ذخیره سازی اطلاعات زمانی محلی (جایی که سرور برنامه قرار دارد) است، از DateTime استفاده کنید. اما اگر می‌خواهید مقادیر زمانی را در مناطق زمانی دیگری نیز مورد استفاده قرار دهید و علاقمندید که قسمت TimeZone این اطلاعات نیز حفظ شود، از DateTimeOffset استفاده نمائید.

در این حالت روش پردازش صحیح مثال ابتدای بحث به صورت ذیل خواهد بود:
 string birthDay = "2012-03-01 00:00:00-05:00";
var dtOffset = DateTimeOffset.Parse(birthDay);
و در اینجا اگر علاقمند به مقایسه‌ی این مقدار با یک زمان محلی هستیم، می‌توان از خاصیت Date آن استفاده کرد:
 var theDay = dtOffset.Date;
مطابق توصیه‌ی تیم BCL، استفاده از DateTimeOffset روش ترجیح داده شده‌ی برای ذخیره سازی اطلاعات اکثر سناریوهای زمانی است.


SQL Server و پشتیبانی از DateTimeOffset

ساختار داده‌ای datetime در SQL Server نیز اطلاعات منطقه‌ی زمانی را ذخیره نمی‌کند و درصورت بازیابی آن در برنامه، این زمان، به زمان محلی تبدیل خواهد شد. برای رفع این مشکل، از زمان ارائه‌ی SQL Server 2008، ساختار DateTimeOffset نیز به نوع‌های داده‌آی SQL Server اضافه شده‌است:


این ساختار، اطلاعات +00:00 timezone را نیز ذخیره می‌کند.


مشکلات نوع datetime در بانک‌های اطلاعاتی برای ذخیره سازی اطلاعات UTC در آن‌ها

یکی از روش‌های توصیه شده‌ی جهت ذخیره سازی اطلاعات زمانی در بانک‌های اطلاعاتی، استفاد‌ه‌ی از DateTime.UtcNow است. اما زمانیکه از DateTime.UtcNow برای ذخیره سازی اطلاعاتی زمانی استفاده می‌کنیم، به معنای دریافت زمان محلی بر اساس و نسبت به UTC است. در این حالت هنگامیکه آن‌را از یک فیلد datetime بانک اطلاعاتی بازیابی می‌کنیم، از نوع Unspecified خواهد بود (DateTimeKind.Unspecified) و به صورت خودکار به DateTimeKind.Local ترجمه می‌شود. یعنی مقدار آن مجددا به زمان محلی شیفت پیدا خواهد کرد چون نوع datetime بانک اطلاعاتی درکی از DateTimeKind و منطقه‌ی زمانی ندارد.
به همین جهت روش بازیابی صحیح این زمان UTC، نیاز به قید صریح DateTimeKind.Utc را خواهد داشت:
public static class SqlDataReaderExtensions
{
   public static DateTime GetDateTimeUtc(this SqlDataReader reader, string name)
   {
      int fieldOrdinal = reader.GetOrdinal(name);
      DateTime unspecified = reader.GetDateTime(fieldOrdinal);
      return DateTime.SpecifyKind(unspecified, DateTimeKind.Utc);
   }
}
اما اگر نوع فیلد را DateTimeOffset قرار دهیم و از DateTimeOffset.UTCNow برای ذخیره سازی اطلاعات زمانی استفاده کنیم، SqlDataReader بدون نیاز به تبدیلات فوق، قادر است اطلاعات آن‌را به نحو صحیحی دریافت و پردازش کند.


خلاصه‌ی بحث

اگر برنامه‌ی وب شما امروز در یک سرور در اروپا هاست می‌شود و سال بعد در یک سرور کانادایی، استفاده‌ی DateTime.UtcNow کمک زیادی به برنامه نکرده و خروجی SQL Server در این حالت DateTimeKind.Unspecified است و این زمان مجددا بر اساس محل سرور جدید و تنظیمات منطقه‌ی زمانی آن، به حالت DateTimeKind.Local شیفت داده می‌شود که الزاما خروجی صحیحی را به همراه نخواهد داشت و یا اگر قرار است از وب سرویس شما در مناطق زمانی مختلفی استفاده کنند نیز DateTime.UtcNow انتخاب مناسبی نیست. جهت درج فاصله‌ی صحیح با UTC و ذخیره سازی آن در بانک اطلاعاتی، روش توصیه شده، استفاده از نوع DateTimeOffset است و در این حالت دیگر SQL Server اطلاعات را با فرمت زمانی Unspecified بازگشت نمی‌دهد و در سمت کلاینت نیازی به تبدیلات خاصی نخواهد بود.
  • #
    ‫۷ سال و ۸ ماه قبل، جمعه ۸ بهمن ۱۳۹۵، ساعت ۱۵:۴۶
    یک نکته‌ی تکمیلی
    در کتابخانه‌ی DNTPersianUtils.Core تمام متدهای تبدیل تاریخ آن Overload هایی با پارامترهایی از نوع DateTimeOffset و ? DateTimeOffset را جهت سهولت کار، به همراه دارند. 
  • #
    ‫۴ سال و ۱۱ ماه قبل، شنبه ۳۰ شهریور ۱۳۹۸، ساعت ۱۴:۱۰
    یک نکته‌ی تکمیلی: بانک اطلاعاتی SQLite از نوع داده‌ی DateTimeOffset پشتیبانی نمی‌کند

    SQLite به صورت توکار از هیچ نوع داده‌ای خاصی برای کار با زمان یا تاریخ پشتیبانی نمی‌کند؛ اما متدهایی را برای کار با آن‌ها به همراه دارد و در این بین، EF Core فقط نوع داده‌ای DateTime را برای آن به خوبی پشتیبانی می‌کند. در سایر حالات استفاده‌ی از DateTimeOffset، پیام عدم امکان ترجمه‌ی این کوئری LINQ را به SQL، مشاهده خواهید کرد. به همین جهت برای کار بدون دردسر با زمان در SQLite و EF Core، بهتر است از همان DateTime استفاده کرد.
    این روش‌ها را نیز مدنظر داشته باشید:
    - در این بانک اطلاعاتی برای مثال می‌توان تاریخ را به صورت زیر ذخیره و بازیابی کرد:
    ((DateTimeOffset)value).Ticks.ToString()

    - و یا می‌توان برای آن تبدیلگر نوشت:
    namespace MySQLite
    {
        public class SQLiteDbContext : DbContext
        {
            public SQLiteDbContext(DbContextOptions options) : base(options)
            {
            }
    
            protected override void OnModelCreating(ModelBuilder builder)
            {
                base.OnModelCreating(builder);
                addDateTimeOffsetConverter(builder);
            }
    
            private static void addDateTimeOffsetConverter(ModelBuilder builder)
            {
                // SQLite does not support DateTimeOffset
                foreach (var property in builder.Model.GetEntityTypes()
                                                      .SelectMany(t => t.GetProperties())
                                                      .Where(p => p.ClrType == typeof(DateTimeOffset)))
                {
                    property.SetValueConverter(
                         new ValueConverter<DateTimeOffset, DateTime>(
                              convertToProviderExpression: dateTimeOffset => dateTimeOffset.UtcDateTime,
                              convertFromProviderExpression: dateTime => new DateTimeOffset(dateTime)
                        ));
                }
    
                foreach (var property in builder.Model.GetEntityTypes()
                                                      .SelectMany(t => t.GetProperties())
                                                      .Where(p => p.ClrType == typeof(DateTimeOffset?)))
                {
                    property.SetValueConverter(
                         new ValueConverter<DateTimeOffset?, DateTime>(
                              convertToProviderExpression: dateTimeOffset => dateTimeOffset.Value.UtcDateTime,
                              convertFromProviderExpression: dateTime => new DateTimeOffset(dateTime)
                        ));
                }
            }
        }
    }

    // یک نمونه‌ی دیگر
    private static readonly ValueConverter<object, string> DateTimeOffsetToStringConverter =
        new ValueConverter<object, string>(
                  v => ((DateTimeOffset)v).ToString(@"yyyy\-MM\-dd HH\:mm\:ss.FFFFFFFzzz", CultureInfo.InvariantCulture),
                  v => DateTimeOffset.Parse(v, CultureInfo.InvariantCulture));
    • #
      ‫۴ سال و ۱۱ ماه قبل، یکشنبه ۳۱ شهریور ۱۳۹۸، ساعت ۱۸:۳۷
      یک نکته‌ی تکمیلی: استفاده از DateTime.UtcNow و EF Core

      زمانیکه نوع فیلد یا خاصیتی را به DateTime تنظیم و مقدار آن‌را به صورت UTC برای مثال با DateTime.UtcNow مقدار دهی کردیم، مقدار بازگشت داده شده‌ی توسط EF Core (مقداری را که از بانک اطلاعاتی می‌خواند) در این حالت به همراه DateTimeKind.Utc آن نیست (همان نکته‌ی «مشکلات نوع datetime در بانک‌های اطلاعاتی برای ذخیره سازی اطلاعات UTC در آن‌ها » انتهای مطلب فوق). برای رفع این مشکل می‌توان تبدیلگرهای زیر را اضافه کرد تا به صورت خودکار DateTimeKind را تنظیم کنند؛ همچنین اگر در جائی از برنامه (در قسمت ثبت یا به روز رسانی) تبدیل به UTC فراموش شد، متد ()ToUniversalTime اینکار را انجام می‌دهد: 
      var dateTimeConverter = new ValueConverter<DateTime, DateTime>(
         v => v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime(),
         v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
      
      var nullableDateTimeConverter = new ValueConverter<DateTime?, DateTime?>(
         v => !v.HasValue ? v : (v.Value.Kind == DateTimeKind.Utc ? v : v.Value.ToUniversalTime()),
         v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : v);
      
      foreach (var property in builder.Model.GetEntityTypes()
                                            .SelectMany(t => t.GetProperties()))
      {
          if (property.ClrType == typeof(DateTime))
          {
              property.SetValueConverter(dateTimeConverter);
          }
      
          if (property.ClrType == typeof(DateTime?))
          {
              property.SetValueConverter(nullableDateTimeConverter);
          }
      }
      • #
        ‫۱ ماه قبل، یکشنبه ۲۱ مرداد ۱۴۰۳، ساعت ۱۹:۵۰

        نگارش به‌روز «یک نکته‌ی تکمیلی: استفاده از DateTime.UtcNow و EF Core»

        به صورت خلاصه اگر در بانک اطلاعاتی خود، اطلاعات زمانی را برای مثال توسط DateTime.UtcNow ذخیره می‌کنید، هرچند DateTimeKind آن به Utc تنظیم می‌شود، اما هنگام دریافت مقدار آن از بانک اطلاعاتی، این DateTimeKind بازیابی نشده (ADO.NET چنین قابلیتی را ندارد) و ... شاهد ناهماهنگی‌هایی خواهیم بود. برای یک‌دست سازی برنامه در این حالت می‌توان از تبدیلگرهای زیر استفاده کرد (برای دو حالت DateTime و ?DateTime):

        public class NullableDateTimeAsUtcValueConverter() : ValueConverter<DateTime?, DateTime?>(
            v => !v.HasValue ? v : ToUtc(v.Value), v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : v)
        {
            private static DateTime? ToUtc(DateTime v) => v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime();
        }
        
        public class DateTimeAsUtcValueConverter() : ValueConverter<DateTime, DateTime>(
            v => v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime(), v => DateTime.SpecifyKind(v, DateTimeKind.Utc));

        که اینبار و در نگارش‌های جدیدتر EF، روش معرفی سراسری آن‌ها به صورت زیر ساده می‌شود:

        protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
        {
           ArgumentNullException.ThrowIfNull(configurationBuilder);
        
           configurationBuilder.Properties<DateTime>().HaveConversion<DateTimeAsUtcValueConverter>();
           configurationBuilder.Properties<DateTime?>().HaveConversion<NullableDateTimeAsUtcValueConverter>();
        }
    • #
      ‫۴ سال و ۱۰ ماه قبل، شنبه ۲۷ مهر ۱۳۹۸، ساعت ۱۲:۲۷
      یک نکته‌ی تکمیلی: تبدیلگرهای DateTimeOffset برای بانک‌های اطلاعاتی که از آن پشتیبانی نمی‌کنند

      خود EF Core به همراه تبدیلگرهای توکار زیر برای کار ساده‌تر با DateTimeOffset در بانک اطلاعاتی‌هایی مانند SQLite و یا MySQL است:

      DateTimeOffsetToBinaryConverter - DateTimeOffset to binary-encoded 64-bit value (stores it as a long, slight reduction in precision)

      DateTimeOffsetToBytesConverter - DateTimeOffset to byte array (stores it as a 12 byte array, 8 bytes for time, 4 bytes for offset. Full precision.)

      DateTimeOffsetToStringConverter - DateTimeOffset to string (ISO 8601 string including timezone) 

      و برای مثال می‌توان آن‌ها را به صورت زیر و سراسری، به سیستم معرفی کرد:
      protected override void OnModelCreating(ModelBuilder builder)
      {
          base.OnModelCreating(builder);
      
          if (Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite")
          {
              // SQLite does not have proper support for DateTimeOffset via Entity Framework Core, see the limitations
              // here: https://docs.microsoft.com/en-us/ef/core/providers/sqlite/limitations#query-limitations
              // To work around this, when the Sqlite database provider is used, all model properties of type DateTimeOffset
              // use the DateTimeOffsetToBinaryConverter
              // Based on: https://github.com/aspnet/EntityFrameworkCore/issues/10784#issuecomment-415769754
              // This only supports millisecond precision, but should be sufficient for most use cases.
              foreach (var entityType in builder.Model.GetEntityTypes())
              {
                  var properties = entityType.ClrType.GetProperties().Where(p => p.PropertyType == typeof(DateTimeOffset));
                  foreach (var property in properties)
                  {
                      builder
                          .Entity(entityType.Name)
                          .Property(property.Name)
                          .HasConversion(new DateTimeOffsetToBinaryConverter());
                  }
              }
          }
      }
  • #
    ‫۱ سال و ۹ ماه قبل، دوشنبه ۳۰ آبان ۱۴۰۱، ساعت ۱۱:۳۳
    با سلام؛ یک api بر اساس AspNetCore  داریم که در یک جایی هاست شده که با ایران حدود 10 ساعت اختلاف ساعت داره. برای این api یک اپلیکیشن موبایل آندروید داریم که ممکنه هر کاربری در هر گوشه ای از دنیا نصبش کنه که یکی از کارهای این اپ زمانبندی و یادآوری کارهاست. ما برای این منظور و پوشش این اختلاف زمانی‌های ناگزیر، فیلدهای تاریخ دیتابیس که SQL Server هست رو از نوع DateTimeOffset در نظر گرفتیم. سوالی که پیش میاد اینجاست.
    اول اینکه: آیا اصلا لزومی برای اینکار بود یا با نوع datetime و تبدیل تمامی datetime‌های ارسالی به مقدار Utc و مدیریت این فیلدها کار راه می‌افتاد؟
    دوم اینکه: آیا از سمت موبایل و اپلیکیشن، داده هایی که از نوع تاریخ ارسال میشن باید اونها هم فورمت DateTimeOffset داشته باشن؟ یا فقط داشتن TimeZone کاربر برای مدیریت تمامی درخواست هاش کافیه؟ البته با فرض ثابت بودن Timezone کاربر و سرور. آیا در این حالت تبدیل زمان وقایع افتاده در سمت سرور به UTC هم لازمه؟ یا فقط تبدیل مقادیر ارسالی کاربر کافیه؟
    فرض کنیم که سوال اول و دوم حل شده. فرض میکنیم که در اپلیکیشن، کاربر درخواست یادآوری کاری رو راس ساعت 10 شب بر اساس زمان محلی خودش داره و از یکی دو روز قبل این درخواست رو ارسال کرده و در دیتابیس داریمش. (به یکی از دو روش سوال 1 یا 2). حالا روی سرور که با کاربرمون حدود 10 ساعت اختلاف ساعت داره و در آینده ممکنه تغییر هم کنه، چطوری باید زمان دقیق یادآوری ایشون بر اساس زمان محلی کاربر رو محاسبه واستخراج کرد؟
    کل سوال‌های بالا رو میشه به طور خلاصه چنین پرسید که در سناریوی مفروض بالا، گذشته از انتخاب نوع Datetime یا Datetimeoffset، آیا تبدیل و سپس نگهداری مقادیر از جنس تاریخ و ساعت به Utc در سناریوی تقریبا بین المللی بالا از بابت مدیریت همه چیز از جمله آلارمها و نوتیفیکیشن‌ها لازم و به جاست یا نه؟
    • #
      ‫۱ سال و ۹ ماه قبل، دوشنبه ۳۰ آبان ۱۴۰۱، ساعت ۱۴:۰۲
      1- بهتر است اینگونه باشد و کار با آن ساده‌‌تر است از پیچیدگی‌های نگهداری اجزای آن به صورت مجزا؛ مانند نگهداری TimeZone به صورت مجزا. هدف از DateTimeOffset چیزی نیست بجز نگهداری زمان بر اساس UTC (وابسته نبودن به زمان محلی) و همچنین نگهداری Offset منطقه‌ی جاری در آن، برای آینده و تبدیلات مرتبط (این زمان UTC درج شده را باید به چه میزانی کم و زیاد کرد تا به زمان دقیق محلی رسید، مثلا برای زمان ایران، UTC +3:30 است و این 3:30+ به همراه DateTimeOffset درج می‌شود) . این موارد در قسمت «DateTimeOffset و ذخیره‌ی DateTime به همراه Offset » مطلب جاری بیشتر بحث شدند.
      2- بله. کتابخانه‌های کلاینت مناسبی برای اینکار موجود هستند. مهم‌ترین قسمت اطلاعات TimeZone، جزئی از اجزای DateTimeOffset است؛ همان قسمت offset در تعریف Date + Time + Offset و اگر TimeZone کاربر و سرور یکی است و همچنین در آینده هم قرار نیست تغییری کنند، نیازی به پیاده سازی این مطلب ندارید. این موارد نیز در قسمت «چه زمانی از DateTime و چه زمانی از DateTimeOffset استفاده کنیم؟ » مطلب جاری بیشتر بحث شدند. اطلاعات ارسالی از سمت کاربر که به همراه زمان UTC و Offset آن منطقه هستند، دقیقا به همان شکل در سمت سرور ذخیره می‌شوند؛ بدون انجام هیچ تبدیلی. وجود این Offset ارسالی، نیاز به تبدیلات مجدد سمت سرور را غیرضروری می‌کند.
      3- همانطور که در نظرات هم عنوان شد، از کتابخانه‌ی DNTPersianUtils.Core استفاده کنید. این تبدیلات را صرفا جهت نمایش تاریخ و زمان شمسی، برای شما انجام می‌دهد. کدهای آن هم برای مطالعه‌ی بیشتر موجود است. البته باید دقت داشت که حتی در کوئری‌های EF هم نیاز به تبدیل سمت سرور خاصی نیست. فقط از Microsoft.CodeAnalysis.BannedApiAnalyzers جهت اجبار به استفاده‌ی از Utc در کدهای سمت سرور استفاده کنید. نکات تکمیلی این مطلب را هم در صورت نیاز به استفاده‌ی از تبدیلگرها مطالعه کنید.