عنوان:

‫مدیریت و قالب‌بندی DateTimeOffset در دات‌نت


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۵/۱۰ ۰۹:۴۰
آدرس: www.dntips.ir
چکیده: مدیریت زمان و تاریخ در سامانه‌های نرم‌افزاریِ توزیع‌شده، به‌محض عبور داده‌ها از مرزهای برنامه‌ی مبدأ، به یکی از چالش‌های اصلی توسعه‌دهندگان تبدیل می‌شود. تغییرات منطقه زمانی (Time Zone)، نادیده‌گرفتن اختلاف از ساعت هماهنگ جهانی (UTC Offset) و قالب‌بندی‌های نادرست، از عوامل رایج بروز اختلالات خاموش در ارتباطات API، پایگاه‌های داده و لایه‌های Frontend هستند. در این مقاله، به بررسی دقیق رفتار ساختار DateTimeOffset در زبان #C# و اکوسیستم .NET می‌پردازیم. چگونگی تبدیل این ساختار به قالب‌های استاندارد نظیر ISO 8601، RFC 3339، Unix Time و JSON، و همچنین سازوکار بازخوانی (Parse) و میزان بقای اطلاعات (Round-trip) در هر سناریو، ارزیابی خواهد شد.

مقدمه
در نسخه‌های قدیمی‌تر .NET، استفاده از ساختار DateTime برای نگهداری زمان رایج بود. با این حال، DateTime به دلیل عدم ذخیره‌سازی صریح UTC Offset، گزینه‌ای غیرقابل اطمینان برای تبادل داده در مرزهای سرویس (Service Boundaries) محسوب می‌شود. در توسعه نرم‌افزار مدرن دات‌نت، ساختار DateTimeOffset به یک استاندارد طلایی تبدیل شده است؛ زیرا علاوه بر ثبت زمان محلی، میزان اختلاف آن با UTC را نیز به دقت نگهداری می‌کند.
هنگامی که یک نقطه زمانی (Timestamp) از حافظه برنامه خارج و وارد قالب‌هایی چون JSON، Headerهای HTTP، کلیدهای Cache یا فایل‌های Log می‌شود، به متن یا عددی تبدیل خواهد شد که سایر سیستم‌ها باید آن را رمزگشایی کنند. در این مقاله، تمامی رفتارهای قالب‌بندی و بازخوانی متقابل بر روی یک نمونه مشخص پیاده‌سازی شده است.
سناریوی مرجع در تمام نمونه‌کدهای این مقاله، یک زمان مشخص در بعدازظهر تابستان با منطقه زمانی اروپای مرکزی (CEST / Offset +02:00) است:
var timestamp = new DateTimeOffset(
    year: 2026, month: 7, day: 14,
    hour: 9, minute: 11, second: 30, millisecond: 123,
    offset: TimeSpan.FromHours(2));

نگاشت و رفتار قالب‌های استاندارد در یک نگاه
جدول زیر خلاصه رفتاری قالب‌های مختلف را هنگام بازخوانی (Parsing) مجدد به DateTimeOffset نشان می‌دهد:

مشخصه / قالبنمونه خروجی (Output)بقای اطلاعات پس از Parse
ISO 8601 / RFC 3339 (با Offset)2026-07-14T09:11:30.123+02:00بله، کاملاً دقیق با حفظ Offset
RFC 3339 (در حالت UTC)2026-07-14T07:11:30.123Zبله، به عنوان زمان UTC
System.Text.Json پیش‌فرض2026-07-14T09:11:30.123+02:00بله، کاملاً دقیق
.NET Round-trip (O / o)2026-07-14T09:11:30.1230000+02:00بله، کاملاً دقیق (با دقت ۷ رقم اعشار)
Unix Time (ثانیه)1784013090بله، به عنوان UTC (بدون میلی‌ثانیه)
Unix Time (میلی‌ثانیه)1784013090123بله، به عنوان UTC
RFC 1123 (R)Tue, 14 Jul 2026 07:11:30 GMTبله، به عنوان UTC (بدون میلی‌ثانیه)
Sortable (s)2026-07-14T09:11:30خیر، Offset حذف می‌شود
Universal Sortable (u)2026-07-14 07:11:30Zبله، به عنوان UTC (بدون میلی‌ثانیه)
قالب فشرده UTC (Basic)20260714T071130.123Zبله، به عنوان UTC
فقط تاریخ (DateOnly)2026-07-14خیر، زمان و Offset حذف می‌شوند
فقط ساعت (TimeOnly)09:11:30.123خیر، تاریخ و Offset حذف می‌شوند

بررسی تخصصی قالب‌های رایج
۱. استاندارد ISO 8601 و RFC 3339
استاندارد ISO 8601 دامنه وسیعی از تعاریف زمان، بازه‌ها و دوره‌ها را شامل می‌شود. به همین دلیل، صریحاً مشخص‌نکردن‌زیرمجموعه مورد استفاده می‌تواند باعث ناهماهنگی سرویس‌ها شود. RFC 3339 پروپوزالی دقیق‌تر و محدودتر از ISO 8601 است که استاندارد اصلی تبادل داده در APIها به شمار می‌رود.
برای تولید خروجی مطابق با RFC 3339 در دو حالت (همراه با Offset صریح و حالت UTC)، از کدهای زیر استفاده می‌شود:
using System.Globalization;

// قالب همراه با Offset صریح
const string offsetFormat = "yyyy-MM-dd'T'HH:mm:ss.fffK";
// قالب استاندارد UTC با علامت Z
const string utcFormat = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'";

string withOffset = timestamp.ToString(offsetFormat, CultureInfo.InvariantCulture);
// خروجی: 2026-07-14T09:11:30.123+02:00

string inUtc = timestamp.ToUniversalTime().ToString(utcFormat, CultureInfo.InvariantCulture);
// خروجی: 2026-07-14T07:11:30.123Z
نکته مهم (Trap): توکن fff دقیقاً ۳ رقم اعشار (میلی‌ثانیه) تولید می‌کند. اگر می‌خواهید اعشارهای صفرِ انتهایی حذف شوند، از FFFFFF استفاده کنید. همچنین هرگز حرف 'Z' را بدون تبدیل زمان به UTC (روش .ToUniversalTime()) به رشته اضافه نکنید؛ چرا که این کار زمان محلی را بدون تغییر ساعت، به عنوان UTC معرفی کرده و اختلال زمانی ایجاد می‌کند.

سازوکار Parse:
var utcStyles = DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal;

// پردازش قالب همراه با Offset
DateTimeOffset parsedOffset = DateTimeOffset.ParseExact(
    withOffset, offsetFormat, CultureInfo.InvariantCulture, DateTimeStyles.None);

// پردازش قالب UTC
DateTimeOffset parsedUtc = DateTimeOffset.ParseExact(
    inUtc, utcFormat, CultureInfo.InvariantCulture, utcStyles);

۲. قالب فشرده (Basic Format) برای نام‌گذاری فایل‌ها و Logها
علامت : در برخی سیستم‌عامل‌ها (مانند Windows) برای نام‌گذاری فایل مجاز نیست. در ISO 8601 یک فرم فشرده بدون جداکننده تعریف شده که برای نام فایل، کلیدهای Cache و شناسه Logها ایده‌آل است:
const string fileFormat = "yyyyMMdd'T'HHmmss.fff'Z'";

string fileTimestamp = timestamp
    .ToUniversalTime()
    .ToString(fileFormat, CultureInfo.InvariantCulture);
// خروجی: 20260714T071130.123Z

// برای Parse کردن این قالب، حتماً باید از ParseExact استفاده شود:
DateTimeOffset parsedFileTime = DateTimeOffset.ParseExact(
    fileTimestamp, fileFormat, CultureInfo.InvariantCulture, utcStyles);

۳. تبادل داده در JSON و ارتباط با TypeScript / JavaScript
کتابخانه System.Text.Json به‌صورت پیش‌فرض متغیرهای DateTimeOffset را بر اساس ISO 8601 / RFC 3339 سریال‌سازی می‌کند:
string json = JsonSerializer.Serialize(timestamp);
// خروجی: "2026-07-14T09:11:30.123+02:00"

DateTimeOffset deserialized = JsonSerializer.Deserialize<DateTimeOffset>(json);
اگر سناریوی برنامه ایجاب کند که تمامی خروجی‌های JSON حتماً به صورت UTC و با پسوند Z و دقت ثابت ارسال شوند، یک Custom Converter راه‌حل ساختاریافته آن است:
using System.Text.Json;
using System.Text.Json.Serialization;

public sealed class UtcDateTimeOffsetJsonConverter : JsonConverter<DateTimeOffset>
{
    private const string Format = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'";

    public override DateTimeOffset Read(
        ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        return DateTimeOffset.ParseExact(
            reader.GetString()!,
            Format,
            CultureInfo.InvariantCulture,
            DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
    }

    public override void Write(
        Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
    {
        writer.WriteStringValue(
            value.ToUniversalTime().ToString(Format, CultureInfo.InvariantCulture));
    }
}

رفتار در فرانت‌اند (TypeScript / JavaScript):
شیء Date در JavaScript زمان را به صورت میلی‌ثانیه از مبدأ Epoch نگهداری می‌کند. هنگام بازخوانی رشته، Offset پردازش شده اما متغیر داخلی داده را در قالب UTC ذخیره می‌کند:
const fromText = new Date("2026-07-14T09:11:30.123+02:00");
const fromUnix = new Date(1784013090123);

console.log(fromText.toISOString()); // 2026-07-14T07:11:30.123Z
console.log(fromText.getTime() === fromUnix.getTime()); // true
تله‌های رایج سمت Client:
  • رشته‌های بدون Offset (مانند قالب s در دات‌نت) توسط مرورگر بر اساس ساعت محلی کاربر Parse می‌شوند.
  • رشته‌های فقط تاریخ (مانند "2026-07-14") بر خلاف مورد قبل، توسط مرورگر بر اساس ساعت UTC (00:00:00Z) Parse می‌شوند که می‌تواند باعث نمایش تاریخ روز قبل برای کاربران غرب UTC شود.
  • مشخصه O در دات‌نت ۷ رقم اعشار تولید می‌کند، در حالی که استاندارد ECMAScript ۳ رقم اعشار را تضمین می‌کند. بهتر است از O فقط برای ارتباط بین سرویس‌های دات‌نت استفاده شود.

۴. تبدیل به Unix Time (Epoch Time)
در مبادلات عددی (مانند Standard Claimهای توکن JWT)، استفاده از Unix Time رایج است:
long seconds = timestamp.ToUnixTimeSeconds();       // 1784013090
long milliseconds = timestamp.ToUnixTimeMilliseconds(); // 1784013090123

// بازگرداندن به DateTimeOffset (همواره با Offset +00:00)
DateTimeOffset fromSec = DateTimeOffset.FromUnixTimeSeconds(seconds);
DateTimeOffset fromMSec = DateTimeOffset.FromUnixTimeMilliseconds(milliseconds);

۵. مقادیر تقویمی مجزا و نمایش (Display)
اگر نیازی به نگهداری زمان یا Offset نیست (مانند تاریخ تولد یا ساعت کاری)، باید از انواع داده‌ای DateOnly و TimeOnly استفاده کرد:
DateOnly dateOnly = DateOnly.FromDateTime(timestamp.DateTime); // 2026-07-14
TimeOnly timeOnly = TimeOnly.FromDateTime(timestamp.DateTime); // 09:11:30.123
برای نمایش متنی به کاربر نهایی (UI) نیز باید از CultureInfo استفاده کرد و متن تولیدشده را صرفاً در لایه خروجی نگه داشت:
var culture = CultureInfo.GetCultureInfo("fa-IR");
// یا فرهنگ‌های دیگر جهت قالب‌بندی بومی

۶. پردازش ایمن ورودی‌ها (Strict Parsing)
استفاده از DateTimeOffset.TryParse معمولی به دلیل پذیرش طیف وسیعی از فرمت‌های غیرمنتظره و همچنین اعمال Offset سرورِ اجراکننده در صورت غایب بودن Offset در ورودی، در APIهای حساس توصیه نمی‌شود. روش ایمن، استفاده از TryParseExact است:
string[] acceptedFormats =
{
    "O",
    "yyyy-MM-dd'T'HH:mm:ss.fffK",
    "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK"
};

if (DateTimeOffset.TryParseExact(
    input, acceptedFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
{
    // پردازش ورودی معتبر
}

نتیجه‌گیری
مدیریت یکپارچه زمان در نرم‌افزار، مستلزم رعایت اصول زیر در مرزهای سیستم است:
  • لایه‌های داخلی و پایگاه داده: تمام Timestampها را داخل برنامه و در پایگاه داده (مثلاً نوع داده datetimeoffset در SQL Server) به‌صورت تایپ‌شده با DateTimeOffset نگهداری کنید.
  • ارتباطات متنی (JSON / REST API): از پیکربندی پیش‌فرض System.Text.Json یا استاندارد صریح RFC 3339 استفاده کنید. فرمت .NET Round-trip (O) را فقط برای سرویس‌های .NET-to-.NET حفظ کنید.
  • پروتکل‌های عددی: در صورت استفاده از Unix Time، unit (ثانیه یا میلی‌ثانیه) را مشخص کنید.
  • دقت در Parsing: برای جلوگیری از رفتارهای غیرمنتظره به دلیل اختلاف منطقه زمانی سرورها، از TryParseExact همراه با DateTimeStyles صریح استفاده کنید.

نظرات

  • وحید نصیری در ۱۴۰۵/۰۵/۱۰ ۰۹:۴۳
    در توسعه برنامه‌های کاربردی با استفاده از Entity Framework Core (EF Core) و پایگاه‌داده SQL Server، نگاشت صحیح زمان و تاریخ یکی از موضوعات کلیدی در حفظ صحت داده‌ها و کارایی (Performance) پرس‌وجوها است. در ادامه، نحوه نگاشت نوع داده DateTimeOffset در EF Core، رفتار آن در SQL Server و نکات بسیار مهم مربوط به ایندکس‌گذاری (Indexing) و کارایی پرس‌وجوها را به‌صورت دقیق بررسی می‌کنیم.

    ۱. نحوه نگاشتDateTimeOffsetدر SQL Server
    در SQL Server، نوع داده معادل و بومی برای DateTimeOffset در #C، همان datetimeoffset است.
    ویژگی‌های نوع داده datetimeoffset در SQL Server:
    • حجم ذخیره‌سازی: بسته به دقت اعشار بخش ثانیه (Scale)، بین ۸ تا ۱۰ بایت فضا اشغال می‌کند (در حالت پیش‌فرض با دقت ۷ رقم اعشار، ۱۰ بایت است).
    • دقت: تا ۱۰۰ نانومتر (۷ رقم اعشار برای ثانیه).
    • نگهداری Offset: زمان محلی به همراه اختلاف آن از UTC (از -14:00 تا +14:00) را ذخیره می‌کند.

    رفتار پیش‌فرض EF Core:
    هنگامی که یک ویژگی (Property) از نوع DateTimeOffset در مدل C# تعریف می‌کنید، EF Core در Migrationها به‌صورت پیش‌فرض آن را به datetimeoffset(7) در SQL Server تبدیل می‌کند:
    public class Order
    {
        public int Id { get; set; }
        public DateTimeOffset CreatedAt { get; set; }
    }
    کد SQL معادل تولیدشده در Migration:
    CREATE TABLE [Orders] (
        [Id] int NOT NULL IDENTITY,
        [CreatedAt] datetimeoffset(7) NOT NULL,
        CONSTRAINT [PK_Orders] PRIMARY KEY ([Id])
    );

    ۲. سفارشی‌سازی نگاشت در EF Core
    الف) کاهش دقت (Scale) برای کاهش حجم ذخیره‌سازی
    اگر سیستم شما نیازی به دقت ۷ رقم اعشار (۱۰۰ نانومتر) ندارد، می‌توانید دقت آن را (مثلاً به ۳ رقم اعشار برای میلی‌ثانیه) کاهش دهید. این کار حجم ذخیره‌سازی و اندازه ایندکس‌ها را کاهش می‌دهد:
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>()
            .Property(o => o.CreatedAt)
            .HasPrecision(3); // تبدیل به datetimeoffset(3) که ۸ بایت اشغال می‌کند
    }

    ب) تبدیل ارزش (Value Converter) جهت ذخیره به‌صورت UTC
    اگرچه DateTimeOffset مقدار Offset را نگه می‌دارد، اما برخی تیم‌ها ترجیح می‌دهند برای یکپارچگی پایگاه‌داده، همه داده‌ها قبل از ذخیره‌سازی به UTC تبدیل شوند اما همچنان نوع داده DateTimeOffset باقی بماند:
    modelBuilder.Entity<Order>()
        .Property(o => o.CreatedAt)
        .HasConversion(
            v => v.ToUniversalTime(),
            v => v);

    ۳. نکات حیاتی در ایندکس‌گذاری (Indexing) و عملکرد پرس‌وجوها
    ایجاد ایندکس روی ستون‌های datetimeoffset در SQL Server چالش‌ها و رفتارهای خاصی دارد که ناآگاهی از آن‌ها می‌تواند باعث افت شدید کارایی (Index Scan به جای Index Seek) شود.
    ۱. چالش مقایسه با Offsetهای مختلف (تفاوت ارزش منطقی با ارزش فیزیکی)
    در SQL Server، دو مقدار DateTimeOffset با Offsetهای متفاوت می‌توانند نشان‌دهنده یک لحظه زمانی یکسان باشند:
    • 2026-07-14 10:00:00 +03:00
    • 2026-07-14 07:00:00 +00:00
    SQL Server این دو مقدار را برابر می‌داند. اما در ساختار B-Tree ایندکس، مقادیر بر اساس زمان معادل UTC مرتب‌سازی می‌شوند.

    ۲. خطرات عدم رعایت Sargability در پرس‌وجوهای LINQ
    یکی از بزرگ‌ترین اشتباهات در EF Core، تغییر ستون داخل پرس‌وجو است که باعث غیرفعال شدن ایندکس (Non-Sargable Query) می‌شود.
    ✕ روش نامناسب (باعث Index Scan و افت شدید سرعت می‌شود):
    // تبدیل ستون پایگاه‌داده به UTC در سمت SQL Server باعث می‌شود ایندکس استفاده نشود!
    var targetDate = DateTimeOffset.UtcNow.AddDays(-7);
    var orders = await context.Orders
        .Where(o => o.CreatedAt.ToUniversalTime() >= targetDate)
        .ToListAsync();
    ✓ روش صحیح (استفاده بهینه از Index Seek):
    // مقدار پارامتر ورودی را در C# آماده کنید، نه روی ستون پایگاه‌داده
    var targetDate = DateTimeOffset.UtcNow.AddDays(-7);
    var orders = await context.Orders
        .Where(o => o.CreatedAt >= targetDate)
        .ToListAsync();

    ۳. استفاده از ایندکس‌های غیرخوشه‌ای (Non-Clustered Index)
    اگر زیاد بر اساس تاریخ گزارش‌گیری یا فیلتر می‌کنید، روی ستون DateTimeOffset ایندکس تعریف کنید:
    modelBuilder.Entity<Order>()
        .HasIndex(o => o.CreatedAt);
    نکته کلیدی در مورد ایندکس: اگر پرس‌وجوهای شما اغلب بازه زمانی خاصی را همراه با یک ستون دیگر (مثلاً Status یا CustomerId) فیلتر می‌کنند، حتماً از ایندکس مرکب (Composite Index) استفاده کنید:
    modelBuilder.Entity<Order>()
        .HasIndex(o => new { o.CustomerId, o.CreatedAt });

    ۴. چالش تفاوت Offsetها در مرتب‌سازی (ORDER BY)
    هنگام اجرا دستور ORDER BY CreatedAt در SQL Server، موتور پایگاه‌داده مقادیر را بر اساس زمان معادل UTC مرتب می‌کند، نه بر اساس رشته یا زمان محلی ظاهری. این رفتار استاندارد و درست است، اما اگر ورودی‌های سیستم شما Offsetهای متفاوتی داشته باشند، مرتب‌سازی فیزیکی داخل ایندکس عملکرد بسیار سریعی خواهد داشت زیرا فیلدها در ایندکس پیش‌فرض بر اساس معادل UTC ایندکس‌گذاری شده‌اند.

    ۴. جمع‌بندی و دستورالعمل‌های پیشنهادی (Best Practices)

    سناریوبهترین راهکار (Best Practice)
    تعریف ستوناز DateTimeOffset در #C و datetimeoffset در SQL Server استفاده کنید.
    میزان دقت (Precision)در صورت عدم نیاز به دقت نانومتر، با .HasPrecision(3) حجم ذخیره‌سازی و ایندکس را کاهش دهید.
    یکسان‌سازی Offsetترجیحاً در لایه برنامه (Application Layer)، مقادیر را قبل از ارسال به دیتابیس به UTC تبدیل کنید (.ToUniversalTime()) تا داده‌های یکدست داشته باشید.
    فیلتر در LINQهرگز متدهای تبدیلی مانند .ToUniversalTime() یا .DateTime را روی ستون در سمت چپ شرط LINQ اعمال نکنید تا پرس‌وجو Sargable بماند.
    ایندکس‌گذاریبرای جستجوهای بازه‌ای (Range Queries)، ستون DateTimeOffset را در ایندکس قرار دهید و در صورت امکان از Composite Index بهره ببرید.