C# - 处理 DST 过渡日的主要时间范围 - 提供的 DateTime 表示无效时间

Posted

技术标签:

【中文标题】C# - 处理 DST 过渡日的主要时间范围 - 提供的 DateTime 表示无效时间【英文标题】:C# - Handling ranges of prevailing times on DST transition days - The supplied DateTime represents an invalid time 【发布时间】:2020-01-12 16:18:00 【问题描述】:

几个前提:

    我所说的“流行时间”是指它在本地的处理方式(我的行业使用这个术语)。例如,东部通行时间的 UTC 偏移量为 -05:00,但 DST 期间为 -04:00 我发现通过将最终值视为排他性而不是骇人听闻的包容性方法来处理范围数据要干净得多(您必须从超出末尾的第一个值中减去 epsilon范围)。

例如,根据interval notation,从0(包括)到1(不包括)的值范围是[0, 1),比[0, 0.99999999999...]更具可读性(并且更不容易出现舍入问题和因此出现一个错误,因为 epsilon 值 depends on the data type 正在使用)。

考虑到这两个想法,当结束时间戳无效(即没有凌晨 2 点,它立即变为凌晨 3 点)时,如何表示春季 DST 过渡日的最后一小时时间范围?

[2019-03-10 01:00, 2019-03-10 02:00) 在您选择的支持 DST 的时区。

将结束时间设置为 03:00 非常具有误导性,因为它看起来像是一个 2 小时宽的时间范围。

当我通过这个 C# 示例代码运行它时,它崩溃了:

DateTime hourEnd_tz = new DateTime(2019, 3, 10, 0, 0, 0, DateTimeKind.Unspecified);//midnight on the spring DST transition day
hourEnd_tz = hourEnd_tz.AddHours(2);//other code variably computes this offset from business logic
TimeZoneInfo EPT = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");//includes DST rules
DateTime hourEnd_utc = TimeZoneInfo.ConvertTime(//interpret the value from the user's time zone
    hourEnd_tz,
    EPT,
    TimeZoneInfo.Utc);

System.ArgumentException: '提供的 DateTime 表示无效时间。例如,当时钟向前调整时,被跳过的周期内的任何时间都是无效的。 参数名称:日期时间'

我该如何处理这种情况(在其他地方我已经在处理秋季ambiguous times),而不必对我的时间范围类库进行广泛的重构?

【问题讨论】:

放弃DateTime 本身,尤其是DateTimeKind.Unspecified - 至少DateTimeOffset 会更好(如果不切换到NodaTime)。这也意味着结束时间始终有效,如果偏移量错误。否则,你实际上想用这个来完成什么? 旁注:有非小时偏移时区。有非小时 DST 更改。在某些时区,DST 在午夜以外的时间更改(直到并包括使“午夜”无效)。 DST 可能会在不同的月份发生(在南半球,预计跳跃会以另一种方式进行......)。时区 + DST 很复杂,这就是我们试图弄清楚您实际需要做什么的原因。 想了几天,我相信你说的很对,DateTimeKind.Unspecified需要尽量避免(虽然我还是要继续解释SQL ServerDATETIME实例因为其他人都使用这种类型)。我将不得不硬着头皮重构我的代码。附言我不是在尝试实现自己的 DST 规则,而是让 TimeZoneInfo.ConvertTime 方法为我处理它,并将 DST 规则从标准标识符复制到我的自定义时区定义中。 【参考方案1】:

前提 1 是合理的,尽管“普遍”一词经常被删除,而只是称为“东部时间”——两者都可以。

前提 2 是最佳实践。半开范围提供了许多好处,例如不必处理涉及 epsilon 的日期数学,或者不必确定 epsilon 应具有的精度。

但是,您试图描述的范围不能仅通过日期和时间来完成。它还需要涉及与 UTC 的偏移量。对于美国东部时间(使用 ISO 8601 格式),它看起来像这样:

[2019-03-10T01:00:00-05:00, 2019-03-10T03:00:00-04:00)  (spring-forward)
[2019-11-03T02:00:00-04:00, 2019-11-03T02:00:00-05:00)  (fall-back)

你说:

将结束时间设置为 03:00 非常具有误导性,因为它看起来像是一个 2 小时宽的时间范围。

啊,但是将春季结束时间设置为 02:00 也会产生误导,因为当天没有观察到当地时间。只有将当地的实际日期和时间与当时的偏移量结合起来才能准确。

您可以使用 .NET 中的 DateTimeOffset 结构来建模这些(或 Noda Time 中的 OffsetDateTime 结构)。

我该如何处理这种情况......而不必对我的时间范围类库进行大量重构?

首先,您需要一个扩展方法,可让您将特定时区的DateTime 转换为DateTimeOffset。您需要这个有两个原因:

new DateTimeOffset(DateTime) 构造函数假定 DateTimeKindDateTimeKind.Unspecified 应被视为本地时间。没有机会指定时区。

new DateTimeOffset(dt, TimeZoneInfo.GetUtcOffset(dt)) 方法不够好,因为GetUtcOffset 假定您希望标准时间 偏移以防歧义或无效。通常情况并非如此,因此您必须自己编写以下代码:

public static DateTimeOffset ToDateTimeOffset(this DateTime dt, TimeZoneInfo tz)

    if (dt.Kind != DateTimeKind.Unspecified)
    
        // Handle UTC or Local kinds (regular and hidden 4th kind)
        DateTimeOffset dto = new DateTimeOffset(dt.ToUniversalTime(), TimeSpan.Zero);
        return TimeZoneInfo.ConvertTime(dto, tz);
    

    if (tz.IsAmbiguousTime(dt))
    
        // Prefer the daylight offset, because it comes first sequentially (1:30 ET becomes 1:30 EDT)
        TimeSpan[] offsets = tz.GetAmbiguousTimeOffsets(dt);
        TimeSpan offset = offsets[0] > offsets[1] ? offsets[0] : offsets[1];
        return new DateTimeOffset(dt, offset);
    

    if (tz.IsInvalidTime(dt))
    
        // Advance by the gap, and return with the daylight offset  (2:30 ET becomes 3:30 EDT)
        TimeSpan[] offsets =  tz.GetUtcOffset(dt.AddDays(-1)), tz.GetUtcOffset(dt.AddDays(1)) ;
        TimeSpan gap = offsets[1] - offsets[0];
        return new DateTimeOffset(dt.Add(gap), offsets[1]);
    

    // Simple case
    return new DateTimeOffset(dt, tz.GetUtcOffset(dt));

现在您已经定义了它(并将其放在项目的某个静态类中),您可以在应用程序中需要的地方调用它。

例如:

TimeZoneInfo tz = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
DateTime dt = new DateTime(2019, 3, 10, 2, 0, 0, DateTimeKind.Unspecified);
DateTimeOffset dto = dt.ToDateTimeOffset(tz);  // 2019-03-10T03:00:00-04:00

TimeZoneInfo tz = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
DateTime dt = new DateTime(2019, 11, 3, 1, 0, 0, DateTimeKind.Unspecified);
DateTimeOffset dto = dt.ToDateTimeOffset(tz);  // 2019-11-03T01:00:00-04:00

TimeZoneInfo tz = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
DateTime dt = new DateTime(2019, 3, 10, 0, 0, 0, DateTimeKind.Unspecified);

DateTimeOffset midnight = dt.ToDateTimeOffset(tz);                     // 2019-03-10T00:00:00-05:00
DateTimeOffset oneOClock = midnight.AddHours(1);                       // 2019-03-10T01:00:00-05:00
DateTimeOffset twoOClock = oneOClock.AddHours(1);                      // 2019-03-10T02:00:00-05:00
DateTimeOffset threeOClock = TimeZoneInfo.ConvertTime(twoOClock, tz);  // 2019-03-10T03:00:00-04:00

TimeSpan diff = threeOClock - oneOClock;  // 1 hour

请注意,减去两个DateTimeOffset 值会正确考虑它们的偏移量(而减去两个DateTime 值会完全忽略它们的Kind)。

【讨论】:

P.S.:有关“隐藏的第 4 类”的更多信息,请参阅 More Fun with DateTime,在标题为“DateTime 的深层黑暗秘密”的部分中。 我一直在寻找有关处理范围的最佳实践的更多信息,并且根据我的经验,我绝对同意使用开放范围要好得多。你还有什么要说的吗?我试图说服我的团队采用相同的方法,因为我一直看到他们在 SQL 中使用讨厌的 '23:59:59.997' 值。谢谢。此外,我发现的另一件事是解析 ISO 时间戳字符串 even with the Z identifier 不会以 UTC 类型返回。 对于解析,使用接受DateTimeStyles参数的重载,并传递DateTimeStyles.RoundTripKind。如果您使用ParseExact,请将其与the K specifier 匹配。 我在my Date and Time Fundamentals course on Pluralsight 中有一个关于范围的部分(在最后一个模块“常见错误和最佳实践”-“使用范围”中)。我还在会议演讲“如何拥有最好的约会”中谈到了他们,这是here on YouTube。范围约为 35:04。

以上是关于C# - 处理 DST 过渡日的主要时间范围 - 提供的 DateTime 表示无效时间的主要内容,如果未能解决你的问题,请参考以下文章

在 DateTime DST 重叠期间选择首选偏移量

获取两个日期范围内的上周日(GMT、DST)

DST 和 PHP 日期和时间戳

几个时区(欧洲/尼科西亚)的 DST 进入时间使用 System.Globalization C# 报告错误的日期

使用 Joda Time 进行 DST 转换

数据库读出出来的日期格式的不同,主要是月和日的1位和2位的显示