مدیریت و قالببندی DateTimeOffset در داتنت
نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۵/۱۰ ۰۹:۴۰
آدرس: www.dntips.ir
چکیده: مدیریت زمان و تاریخ در سامانههای نرمافزاریِ توزیعشده، بهمحض عبور دادهها از مرزهای برنامهی مبدأ، به یکی از چالشهای اصلی توسعهدهندگان تبدیل میشود. تغییرات منطقه زمانی (Time Zone)، نادیدهگرفتن اختلاف از ساعت هماهنگ جهانی (UTC Offset) و قالببندیهای نادرست، از عوامل رایج بروز اختلالات خاموش در ارتباطات API، پایگاههای داده و لایههای Frontend هستند. در این مقاله، به بررسی دقیق رفتار ساختار DateTimeOffset در زبان #C# و اکوسیستم .NET میپردازیم. چگونگی تبدیل این ساختار به قالبهای استاندارد نظیر ISO 8601، RFC 3339، Unix Time و JSON، و همچنین سازوکار بازخوانی (Parse) و میزان بقای اطلاعات (Round-trip) در هر سناریو، ارزیابی خواهد شد.DateTime برای نگهداری زمان رایج بود. با این حال، DateTime به دلیل عدم ذخیرهسازی صریح UTC Offset، گزینهای غیرقابل اطمینان برای تبادل داده در مرزهای سرویس (Service Boundaries) محسوب میشود. در توسعه نرمافزار مدرن داتنت، ساختار DateTimeOffset به یک استاندارد طلایی تبدیل شده است؛ زیرا علاوه بر ثبت زمان محلی، میزان اختلاف آن با UTC را نیز به دقت نگهداری میکند.var timestamp = new DateTimeOffset(
year: 2026, month: 7, day: 14,
hour: 9, minute: 11, second: 30, millisecond: 123,
offset: TimeSpan.FromHours(2));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 حذف میشوند |
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 معرفی کرده و اختلال زمانی ایجاد میکند.
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);: در برخی سیستمعاملها (مانند 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);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);
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));
}
}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:
s در داتنت) توسط مرورگر بر اساس ساعت محلی کاربر Parse میشوند."2026-07-14") بر خلاف مورد قبل، توسط مرورگر بر اساس ساعت UTC (00:00:00Z) Parse میشوند که میتواند باعث نمایش تاریخ روز قبل برای کاربران غرب UTC شود.O در داتنت ۷ رقم اعشار تولید میکند، در حالی که استاندارد ECMAScript ۳ رقم اعشار را تضمین میکند. بهتر است از O فقط برای ارتباط بین سرویسهای داتنت استفاده شود.long seconds = timestamp.ToUnixTimeSeconds(); // 1784013090 long milliseconds = timestamp.ToUnixTimeMilliseconds(); // 1784013090123 // بازگرداندن به DateTimeOffset (همواره با Offset +00:00) DateTimeOffset fromSec = DateTimeOffset.FromUnixTimeSeconds(seconds); DateTimeOffset fromMSec = DateTimeOffset.FromUnixTimeMilliseconds(milliseconds);
DateOnly و TimeOnly استفاده کرد:DateOnly dateOnly = DateOnly.FromDateTime(timestamp.DateTime); // 2026-07-14 TimeOnly timeOnly = TimeOnly.FromDateTime(timestamp.DateTime); // 09:11:30.123
CultureInfo استفاده کرد و متن تولیدشده را صرفاً در لایه خروجی نگه داشت:var culture = CultureInfo.GetCultureInfo("fa-IR");
// یا فرهنگهای دیگر جهت قالببندی بومی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))
{
// پردازش ورودی معتبر
}datetimeoffset در SQL Server) بهصورت تایپشده با DateTimeOffset نگهداری کنید.System.Text.Json یا استاندارد صریح RFC 3339 استفاده کنید. فرمت .NET Round-trip (O) را فقط برای سرویسهای .NET-to-.NET حفظ کنید.TryParseExact همراه با DateTimeStyles صریح استفاده کنید.DateTimeOffset در EF Core، رفتار آن در SQL Server و نکات بسیار مهم مربوط به ایندکسگذاری (Indexing) و کارایی پرسوجوها را بهصورت دقیق بررسی میکنیم.DateTimeOffsetدر SQL ServerDateTimeOffset در #C، همان datetimeoffset است.datetimeoffset در SQL Server:-14:00 تا +14:00) را ذخیره میکند.DateTimeOffset در مدل C# تعریف میکنید، EF Core در Migrationها بهصورت پیشفرض آن را به datetimeoffset(7) در SQL Server تبدیل میکند:public class Order
{
public int Id { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}CREATE TABLE [Orders] (
[Id] int NOT NULL IDENTITY,
[CreatedAt] datetimeoffset(7) NOT NULL,
CONSTRAINT [PK_Orders] PRIMARY KEY ([Id])
);protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.Property(o => o.CreatedAt)
.HasPrecision(3); // تبدیل به datetimeoffset(3) که ۸ بایت اشغال میکند
}DateTimeOffset مقدار Offset را نگه میدارد، اما برخی تیمها ترجیح میدهند برای یکپارچگی پایگاهداده، همه دادهها قبل از ذخیرهسازی به UTC تبدیل شوند اما همچنان نوع داده DateTimeOffset باقی بماند:modelBuilder.Entity<Order>()
.Property(o => o.CreatedAt)
.HasConversion(
v => v.ToUniversalTime(),
v => v);datetimeoffset در SQL Server چالشها و رفتارهای خاصی دارد که ناآگاهی از آنها میتواند باعث افت شدید کارایی (Index Scan به جای Index Seek) شود.DateTimeOffset با Offsetهای متفاوت میتوانند نشاندهنده یک لحظه زمانی یکسان باشند:2026-07-14 10:00:00 +03:002026-07-14 07:00:00 +00:00// تبدیل ستون پایگاهداده به UTC در سمت SQL Server باعث میشود ایندکس استفاده نشود!
var targetDate = DateTimeOffset.UtcNow.AddDays(-7);
var orders = await context.Orders
.Where(o => o.CreatedAt.ToUniversalTime() >= targetDate)
.ToListAsync();// مقدار پارامتر ورودی را در C# آماده کنید، نه روی ستون پایگاهداده
var targetDate = DateTimeOffset.UtcNow.AddDays(-7);
var orders = await context.Orders
.Where(o => o.CreatedAt >= targetDate)
.ToListAsync();DateTimeOffset ایندکس تعریف کنید:modelBuilder.Entity<Order>()
.HasIndex(o => o.CreatedAt);نکته کلیدی در مورد ایندکس: اگر پرسوجوهای شما اغلب بازه زمانی خاصی را همراه با یک ستون دیگر (مثلاًStatusیاCustomerId) فیلتر میکنند، حتماً از ایندکس مرکب (Composite Index) استفاده کنید:
modelBuilder.Entity<Order>()
.HasIndex(o => new { o.CustomerId, o.CreatedAt });ORDER BY CreatedAt در SQL Server، موتور پایگاهداده مقادیر را بر اساس زمان معادل UTC مرتب میکند، نه بر اساس رشته یا زمان محلی ظاهری. این رفتار استاندارد و درست است، اما اگر ورودیهای سیستم شما Offsetهای متفاوتی داشته باشند، مرتبسازی فیزیکی داخل ایندکس عملکرد بسیار سریعی خواهد داشت زیرا فیلدها در ایندکس پیشفرض بر اساس معادل UTC ایندکسگذاری شدهاند.| سناریو | بهترین راهکار (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 بهره ببرید. |