.NET 6中的日期、时间和时区增强功能介绍和代码展示

425 阅读13分钟

我很高兴与你分享.NET在日期、时间和时区方面的一些改进,这些改进将在.NET 6中实现。你可以从 .NET 6预览版4开始尝试以下所有的内容

在这篇博文中,我将介绍以下内容:

关于更多的细节,你也可以参考GitHub上的dotnet/runtime#45318

介绍DateOnly和TimeOnly类型

如果你在.NET中处理过日期和时间,你可能使用过DateTimeDateTimeOffsetTimeSpanTimeZoneInfo 。在这个版本中,我们引入了两个额外的类型:DateOnlyTimeOnly 。这两种类型都在System 名称空间中,并且是.NET的内置类型,就像其他日期和时间类型一样。

单一日期类型

DateOnly 类型是一种结构,旨在表示一个日期。换句话说,只是一个年、月、日。这里有一个简单的例子:

// Construction and properties
DateOnly d1 = new DateOnly(2021, 5, 31);
Console.WriteLine(d1.Year);      // 2021
Console.WriteLine(d1.Month);     // 5
Console.WriteLine(d1.Day);       // 31
Console.WriteLine(d1.DayOfWeek); // Monday

// Manipulation
DateOnly d2 = d1.AddMonths(1);  // You can add days, months, or years. Use negative values to subtract.
Console.WriteLine(d2);     // "6/30/2021"  notice no time

// You can use the DayNumber property to find out how many days are between two dates
int days = d2.DayNumber - d1.DayNumber;
Console.WriteLine($"There are {days} days between {d1} and {d2}");

// The usual parsing and string formatting tokens all work as expected
DateOnly d3 = DateOnly.ParseExact("31 Dec 1980", "dd MMM yyyy", CultureInfo.InvariantCulture);  // Custom format
Console.WriteLine(d3.ToString("o", CultureInfo.InvariantCulture));   // "1980-12-31"  (ISO 8601 format)

// You can combine with a TimeOnly to get a DateTime
DateTime dt = d3.ToDateTime(new TimeOnly(0, 0));
Console.WriteLine(dt);       // "12/31/1980 12:00:00 AM"

// If you want the current date (in the local time zone)
DateOnly today = DateOnly.FromDateTime(DateTime.Today);

DateOnly 对于诸如出生日期、周年纪念日、雇用日期和其他通常不与任何特定时间相关的业务日期等情况来说是理想的。另一种思考方式是,一个DateOnly 代表整个日期(从一天的开始到一天的结束),例如由印刷的挂历的一个给定的方块来可视化。到目前为止,你可能已经使用DateTime 来实现这一目的,可能是将时间设置为午夜(00:00:00.0000000 )。虽然这仍然有效,但使用DateOnly ,有几个好处。这些优点包括:

  • DateOnlyDateTime 提供了更好的类型安全,因为 只是为了表示一个日期。这在使用API时很重要,因为并不是每个对日期和时间有意义的操作对整个日期也有意义。例如,TimeZoneInfo.ConvertTime 方法可以用来将一个DateTime 从一个时区转换到另一个时区。传递给它一个完整的日期是没有意义的,因为只有该日期的一个时间点才可能被转换。通过DateTime ,这些无意义的操作可能会发生,这也是导致某人的生日晚一天或早一天的错误的部分原因。由于没有这样的时区转换API可以在DateOnly ,因此可以防止意外的误用。
  • 一个DateTime 还包含一个Kind 属性,其类型为DateTimeKind ,可以是LocalUtcUnspecified 。该类型会影响转换API的行为以及字符串的格式化和解析。一个DateOnly 没有这样的类型--它实际上是Unspecified ,始终如此。
  • 当序列化一个DateOnly ,你只需要包括年、月、日。这使得你的数据更加清晰,因为它防止了一堆零被粘在最后。它也让你的API的任何消费者清楚地知道,这个值代表了一个完整的日期,而不是该日期的午夜的时间。
  • 当与数据库(如SQL Server和其他数据库)交互时,整个日期几乎总是存储在一个date 数据类型中。直到现在,存储和检索这些数据的API都与DateTime 类型紧密相连。在存储时,时间会被截断,可能会导致数据丢失。在检索时,时间将被设置为零,与午夜的日期没有区别。有了DateOnly ,就可以对数据库的date 类型进行更精确的匹配。请注意,还有一些工作要做,以使各种数据提供者支持这种新的类型,但至少它现在是可能的。

一个DateOnly 有一个范围,从0001-01-019999-12-31 ,就像DateTime 。我们还包括一个构造函数,它可以接受.NET支持的任何日历。然而,就像DateTime ,一个DateOnly 对象总是代表Proleptic Gregorian日历的值,不管用哪种日历来构造它。如果你确实向构造函数传递了一个日历,它将只用于解释传递到同一构造函数中的年、月、日值。比如说:

Calendar hebrewCalendar = new HebrewCalendar();
DateOnly d4 = new DateOnly(5781, 9, 16, hebrewCalendar);                   // 16 Sivan 5781
Console.WriteLine(d4.ToString("d MMMM yyyy", CultureInfo.InvariantCulture)); // 27 May 2021

关于这一点的更多信息,见 与日历一起工作.

仅限时间的类型

我们还得到了一个新的TimeOnly 类型,这是一个旨在表示一天中的时间的结构。如果DateOnlyDateTime 的一半,那么TimeOnly 就是另一半。这里有一个简单的例子:

// Construction and properties
TimeOnly t1 = new TimeOnly(16, 30);
Console.WriteLine(t1.Hour);      // 16
Console.WriteLine(t1.Minute);    // 30
Console.WriteLine(t1.Second);    // 0

// You can add hours, minutes, or a TimeSpan (using negative values to subtract).
TimeOnly t2 = t1.AddHours(10);
Console.WriteLine(t2);     // "2:30 AM"  notice no date, and we crossed midnight

// If desired, we can tell how many days were "wrapped" as the clock passed over midnight.
TimeOnly t3 = t2.AddMinutes(5000, out int wrappedDays);
Console.WriteLine($"{t3}, {wrappedDays} days later");  // "1:50 PM, 3 days later"

// You can subtract to find out how much time has elapsed between two times.
// Use "end time - start time".  The order matters, as this is a circular clock.  For example:
TimeOnly t4 = new TimeOnly(2, 0);  //  2:00  (2:00 AM)
TimeOnly t5 = new TimeOnly(21, 0); // 21:00  (9:00 PM)
TimeSpan x = t5 - t4;
TimeSpan y = t4 - t5;
Console.WriteLine($"There are {x.TotalHours} hours between {t4} and {t5}"); // 19 hours
Console.WriteLine($"There are {y.TotalHours} hours between {t5} and {t4}"); //  5 hours

// The usual parsing and string formatting tokens all work as expected
TimeOnly t6 = TimeOnly.ParseExact("5:00 pm", "h:mm tt", CultureInfo.InvariantCulture);  // Custom format
Console.WriteLine(t6.ToString("T", CultureInfo.InvariantCulture));   // "17:00:00"  (long time format)

// You can get an equivalent TimeSpan for use with previous APIs
TimeSpan ts = t6.ToTimeSpan();
Console.WriteLine(ts);      // "17:00:00"

// Or, you can combine with a DateOnly to get a DateTime
DateTime dt = new DateOnly(1970, 1, 1).ToDateTime(t6);
Console.WriteLine(dt);       // "1/1/1970 5:00:00 PM"

// If you want the current time (in the local time zone)
TimeOnly now = TimeOnly.FromDateTime(DateTime.Now);

// You can easily tell if a time is between two other times
if (now.IsBetween(t1, t2))
    Console.WriteLine($"{now} is between {t1} and {t2}.");
else
    Console.WriteLine($"{now} is NOT between {t1} and {t2}.");

一个TimeOnly 是理想的情景,如经常性的会议时间,每天的闹钟时间,或一个企业在一周内每天的开门和关门时间。由于TimeOnly 与任何特定的日期无关,它最好被想象成一个可能挂在墙上的圆形模拟钟(尽管是一个24小时的钟,而不是12小时的钟)。到目前为止,有两种常见的方法来表示这种值,要么使用TimeSpan 类型,要么使用DateTime 类型。虽然这些方法仍然有效,但使用TimeOnly 来代替有几个优点,包括:

  • TimeSpan 主要用于经过的时间,如你用秒表测量的时间。它的上限范围超过29000,而且它的值也可以是负数,以表示在时间上向后移动。相反,TimeOnly 是用来测量时间值的,所以它的范围是从00:00:00.000000023:59:59.9999999 ,而且总是正的。当TimeSpan 被用作一天中的时间时,有一种风险,即它可能被操纵,从而超出了可接受的范围。使用TimeOnly ,则没有这种风险。
  • 使用DateTime 作为一天中的时间值需要指定一些任意的日期。一个常见的日期是DateTime.MinValue (0001-01-01),但有时在操作过程中会导致超出范围的异常,如果时间被减去。选择其他的任意日期仍然需要记住以后不考虑它--这在序列化过程中可能是一个问题。
  • TimeOnly 是一个真正的时间-日期类型,因此它为这些值提供了比 或 更好的类型安全,就像使用 为日期值提供比 更好的类型安全一样。DateTime TimeSpan DateOnly DateTime
  • 一个常见的对时间-日期值的操作是添加或减去一段时间的时间。与TimeSpan 不同,TimeOnly 值将正确处理跨越午夜时的这种操作。例如,一个雇员的轮班可能从18:00 开始,持续8个小时,在02:00 结束。TimeOnly 将在加法操作中处理这个问题,它还有一个InBetween 方法,可以很容易地用来判断任何给定的时间是否在工人的轮班之内。

为什么它们被命名为 "唯一"?

为事物命名总是很困难,这一点也不例外。我们考虑了几个不同的名字并进行了长时间的辩论,但最终我们决定采用DateOnlyTimeOnly ,因为它们满足了几个不同的限制:

  • 它们没有使用任何.NET语言的保留关键字。Date 本来是一个理想的名字,但它是VB.NET语言的关键字和数据类型,是System.DateTime 的别名,因此不能被选中。
  • 在文档和IntelliSense中,如果以 "Date "或 "Time "开头,它们将很容易被发现。我们认为这很重要,因为许多.NET开发者习惯于使用DateTimeTimeSpan 类型。其他平台也对相同的功能使用前缀名称,比如Java的java.time包中的LocalDateLocalTime 类,或者即将发布的JavaScript的Temporal提案中的PlainDatePlainTime 类型。然而,这两个反例都将所有的日期和时间类型归入了一个特定的命名空间,而.NET则将其日期和时间类型归入更大的System 命名空间。
  • 他们会尽可能地避免与现有的API混淆。特别是,DateTimeDateTimeOffset 类型的属性都被命名为Date (返回一个DateTime )和TimeOfDay (返回一个TimeSpan )。我们觉得,如果我们用TimeOfDay ,而不是TimeOnly ,但DateTime.TimeOfDay 属性返回一个TimeSpan 类型,而不是TimeOfDay 类型,这将是非常混乱的。如果我们能回到过去,从头开始做,那么我们会选择这些作为属性的名称它们返回的类型的名称,但现在不可能有这样的突破性改变。
  • 它们很容易记住,并直观地说明它们的用途。事实上,"仅有日期和仅有时间的值 "是对如何使用DateOnlyTimeOnly 类型的良好描述。此外,它们组合成一个DateTime ,所以给它们起类似的名字可以使它们在逻辑上配对在一起。

诺达时间呢?

在介绍这两种类型时,许多人问到用诺达时间代替。的确,Noda Time是高质量、社区开发的.NET开源库的一个很好的例子,如果需要,你当然可以使用它。然而,我们认为在.NET中实现类似Noda的API是不合适的。经过仔细的评估,我们决定最好是增强现有的类型以填补空白,而不是彻底改造和取代它们。毕竟,有许多.NET应用程序是使用现有的DateTimeDateTimeOffsetTimeSpanTimeZoneInfo 等类型建立的。DateOnlyTimeOnly 类型在使用时应该感觉很自然。

此外,已经提议支持将DateOnlyTimeOnly 与它们的等效诺达时间类型(LocalDateLocalTime )进行互换。

时区转换API

首先介绍一下背景和历史。一般来说,有两套用于计算的时区数据:

  • 由微软创建的、与Windows一起发货的一套时区。
    • 实例ID。"AUS Eastern Standard Time"
  • 其他所有人使用的时区集,目前由IANA维护。
    • 例证ID。"Australia/Sydney"

我所说的其他人,不仅仅是指Linux和macOS,还包括Java、Python、Perl、Ruby、Go、JavaScript和其他许多

.NET中对时区的支持是由TimeZoneInfo 类提供的。然而,这个类是与.NET框架3.5一起设计的,它只在Windows操作系统上运行。因此,TimeZoneInfo 从Windows中获取其时区数据。对于那些想在系统间传递的数据中引用时区的人来说,这很快成为一个问题。当.NET Core问世时,这个问题就更严重了,因为Windows时区数据在Linux和macOS等非Windows系统上是不可用的。

以前,TimeZoneInfo.FindSystemTimeZoneById 方法是查找操作系统上可用的时区。这意味着Windows系统使用Windows时区,其他系统使用IANA时区。这是有问题的,特别是如果一个人的目标是他们的代码和数据的跨平台可移植性。到目前为止,处理这个问题的方法是在一组时区与另一组时区之间进行手动翻译,最好是使用Unicode CLDR项目建立和维护的映射关系。这些映射也是由ICU等库浮现的。更常见的是,.NET开发者使用TimeZoneConverter库,它也使用这些映射。虽然这些方法都可以继续使用,但现在有一个更简单的方法。

从这个版本开始,如果在系统中找不到所要求的时区,TimeZoneInfo.FindSystemTimeZoneById 方法会自动将其输入转换为相反的格式。这意味着你现在可以在任何安装了时区数据的操作系统上使用IANA或Windows时区ID*。它使用相同的CLDR映射,但通过.NET的ICU全球化支持获得这些映射,所以你不必使用单独的库。

一个简单的例子:

// Both of these will now work on any supported OS where ICU and time zone data are available.
TimeZoneInfo tzi1 = TimeZoneInfo.FindSystemTimeZoneById("AUS Eastern Standard Time");
TimeZoneInfo tzi2 = TimeZoneInfo.FindSystemTimeZoneById("Australia/Sydney");

在Unix上,Windows时区实际上并没有安装在操作系统上,但它们的标识符通过ICU提供的转换和数据被识别。你可以在你的系统上安装libicu ,或者你可以使用.NET的App-Local ICU功能,将数据与你的应用程序捆绑在一起。

注意,一些.NET的Docker镜像,如Alpine Linux,并没有预装tzdata ,但你可以轻松地添加它

在这个版本中,我们还为TimeZoneInfo 类添加了一些新的方法,称为TryConvertIanaIdToWindowsIdTryConvertWindowsIdToIanaId ,用于当你仍然需要从一种时区形式手动转换到另一种时区的情况。

一些使用实例:

// Conversion from IANA to Windows
string ianaId1 = "America/Los_Angeles";
if (!TimeZoneInfo.TryConvertIanaIdToWindowsId(ianaId1, out string winId1))
    throw new TimeZoneNotFoundException($"No Windows time zone found for "{ianaId1}".");
Console.WriteLine($"{ianaId1} => {winId1}");  // "America/Los_Angeles => Pacific Standard Time"

// Conversion from Windows to IANA when a region is unknown
string winId2 = "Eastern Standard Time";
if (!TimeZoneInfo.TryConvertWindowsIdToIanaId(winId2, out string ianaId2))
    throw new TimeZoneNotFoundException($"No IANA time zone found for "{winId2}".");
Console.WriteLine($"{winId2} => {ianaId2}");  // "Eastern Standard Time => America/New_York"

// Conversion from Windows to IANA when a region is known
string winId3 = "Eastern Standard Time";
string region = "CA"; // Canada
if (!TimeZoneInfo.TryConvertWindowsIdToIanaId(winId3, region, out string ianaId3))
    throw new TimeZoneNotFoundException($"No IANA time zone found for "{winId3}" in "{region}".");
Console.WriteLine($"{winId3} + {region} => {ianaId3}");  // "Eastern Standard Time + CA => America/Toronto"

我们还为TimeZoneInfo 添加了一个实例属性,叫做HasIanaId ,当Id 属性是一个 IANA 时区标识符时,它将返回true 。这应该可以帮助你确定是否需要转换,这取决于你的需求。例如,也许你正在使用从Windows或IANA标识符的混合中加载的TimeZoneInfo 对象,然后特别需要一个IANA时区标识符来进行一些外部API调用。你可以定义一个辅助方法,如下:

static string GetIanaTimeZoneId(TimeZoneInfo tzi)
{
    if (tzi.HasIanaId)
        return tzi.Id;  // no conversion necessary

    if (TimeZoneInfo.TryConvertWindowsIdToIanaId(tzi.Id, out string ianaId))
        return ianaId;  // use the converted ID

    throw new TimeZoneNotFoundException($"No IANA time zone found for "{tzi.Id}".");
}

或者反过来说,也许你需要的是一个Windows时区标识符。例如,SQL Server的 AT TIME ZONE函数目前需要一个Windows时区标识符--即使在Linux上使用SQL Server。你可以定义一个辅助方法,如下:

static string GetWindowsTimeZoneId(TimeZoneInfo tzi)
{
    if (!tzi.HasIanaId)
        return tzi.Id;  // no conversion necessary

    if (TimeZoneInfo.TryConvertIanaIdToWindowsId(tzi.Id, out string winId))
        return winId;   // use the converted ID

    throw new TimeZoneNotFoundException($"No Windows time zone found for "{tzi.Id}".");
}

Linux和macOS上的时区显示名称

另一个常见的时区操作是获得一个时区列表,通常是为了要求终端用户选择一个时区。TimeZoneInfo.GetSystemTimeZones 方法在Windows上一直很好地实现了这个目的。它返回一个只读的TimeZoneInfo 对象的集合,这样一个列表可以使用每个对象的IdDisplayName 属性来建立。

在Windows上,.NET使用与当前操作系统显示语言相关的资源文件来填充显示名称。在Linux和macOS上,则使用ICU的全球化数据来代替。这一般来说是可以的,只是必须确保DisplayName 值对整个列表中的值是不含糊的,否则这样的列表就变得无法使用。例如,有13个不同的时区返回,它们都有相同的显示名称:"(UTC-07:00) Mountain Standard Time" ,使用户几乎不可能挑选出属于他们的时区--是的,有差异!例如,America/Denver 代表美国大部分山区时间,但America/Phoenix 是在亚利桑那州使用的,那里不遵守夏令时。

在这个版本中,内部增加了额外的算法,以便从ICU中选择更好的值用于显示名称。现在的列表更可用了。例如,America/Denver 现在被显示为"(UTC-07:00) Mountain Time (Denver)" ,而America/Phoenix 被显示为"(UTC-07:00) Mountain Time (Phoenix)" 。如果你想看看列表的其他部分是如何变化的,请参考GitHub pull request中的 "之前 "和 "之后 "部分。

请注意,就目前而言,时区列表和它们在Windows上的显示名称基本保持不变。然而,一个次要但相关的修正是,以前UTC时区的显示名称是硬编码为英语"Coordinated Universal Time" ,这对其他语言是个问题。现在,在所有操作系统上,它正确地遵循了与其他时区显示名称相同的语言。

TimeZoneInfo.AdjustmentRule的改进

最后要介绍的改进是一个稍微不常用的改进,但也同样重要。TimeZoneInfo.AdjustmentRule 类被用作.NET的时区内存表示法的一部分。一个TimeZoneInfo 类可以有零到许多调整规则。这些规则记录了一个时区与UTC的偏移量在历史过程中是如何调整的,以便在特定的时间点上进行正确的转换。这种变化是非常复杂的,而且大多超出了本文的范围。然而,我将描述一些已经进行的改进。

TimeZoneInfo 的最初设计中,假定BaseUtcOffset 将是一个固定的值,所有的调整规则将简单地控制夏令时的开始或停止时间。不幸的是,这种设计没有考虑到时区在历史上的不同时期改变了它们的标准偏移量,例如,加拿大育空地区最近决定不再在UTC-8和UTC-7之间进行,而是全年保持在UTC-7。为了适应这种变化,.NET在TimeZoneInfo.AdjustmentRule 类中增加了一个内部属性(很久以前),称为BaseUtcOffsetDelta 。这个值用于跟踪TimeZoneInfo.BaseUtcOffset 从一个调整规则到下一个规则的变化。

然而,有一些高级场景偶尔需要获得所有的原始数据,在内部保持一块数据的隐藏并没有什么意义。因此,在这个版本中,TimeZoneInfo.AdjustmentRule 类上的BaseUtcOffsetDelta 属性被公开了。为了完整起见,我们还采取了额外的措施,为CreateAdjustmentRule 方法创建了一个重载,该方法接受一个baseUtcOffsetDelta 参数--我们并不期望大多数开发者需要或想要创建自定义的时区或调整规则。

另外两项小的改进是关于在非Windows操作系统上如何从IANA数据中填充调整规则的问题。除了确保某些边缘情况下的正确性之外,它们并不影响外部行为。

如果这一切听起来令人困惑,不要担心你不是一个人。值得庆幸的是,所有正确使用数据的逻辑都已经包含在TimeZoneInfo ,如GetUtcOffsetConvertTime 的各种方法中。人们一般不需要使用调整规则。

结论

总的来说,.NET 6中的日期、时间和时区的事情正在形成。我很高兴看到新的DateOnlyTimeOnly 类型是如何在.NET生态系统的其他部分实现的,特别是在序列化和数据库方面。我也很高兴看到.NET继续对本地化和可用性进行改进--即使是在时区这个晦涩难懂的领域也是如此。