电话&微信

18600577194

.NET软件开发实战:用NodaTime建模时间

标签: .NET开发 2026-01-23 

.NET软件开发的基类库(BCL)改进不小——自.NET 6起新增的DateOnlyTimeOnly就是处理日期和时间的靠谱类型。但要搞涉及时区的调度(比如会议、截止日期、得扛住夏令时变化的约会),你还得用NodaTime。它给你提供了BCL至今没补齐的那些类型。

这篇文章教你用NodaTime正确开发建模时间、妥善存储,避开咱们系列里聊过的那些坑。

图片压缩.jpg


为啥DateTime不够用(以及BCL修了啥)

.NET开发里的DateTime是个“万能”类型,想一股脑代表多种概念:

var a = DateTime.Now;           // 本机的本地时间  
var b = DateTime.UtcNow;        // UTC瞬间  
var c = new DateTime(2026, 6, 5, 10, 0, 0);  // 这是本地时间?UTC?还是没指定的?

问题出在它的Kind属性上:

  • DateTimeKind.Local:本机的本地时间(不是某个特定时区)

  • DateTimeKind.Utc:一个UTC瞬间

  • DateTimeKind.Unspecified:啥都可能

当你写new DateTime(2026, 6, 5, 10, 0, 0)时,KindUnspecified。这10点是维也纳时间?伦敦时间?还是UTC时间?类型不知道,你的代码也不知道。

BCL的进步:DateOnly和TimeOnly

.NET 6加了俩类型,解决了部分问题:

DateOnly birthday = new DateOnly(1990, 3, 15);    // 纯日期,没时间混淆  
TimeOnly openingTime = new TimeOnly(9, 0);         // 纯时间,没日期混淆

这俩挺好!要是只需要日期或只需要时间,就用它们。它们是BCL自带的,EF Core支持到位,干啥像啥。

BCL还缺啥

但要完整处理涉及时区的调度,BCL还是差点意思:

  • 没有Instant类型(你得用带Kind.UtcDateTimeDateTimeOffset

  • 没有语义规范的LocalDateTime(你得用Kind.UnspecifiedDateTime

  • 没有能把本地时间和时区绑一块儿的ZonedDateTime

  • 没有一流的IANA时区支持(TimeZoneInfo默认用Windows时区)

DateTimeOffsetDateTime强点——它带了偏移量——但就像第4篇文章说的,偏移量是“快照”,不是“含义”。+02:00可能是维也纳夏天、柏林夏天、开罗或约翰内斯堡的时间,你分不清。

简单场景:DateOnlyTimeOnlyDateTimeDateTimeOffset够用。

涉及时区调度:NodaTime给你对应概念的“对版”类型。

你需要知道的NodaTime类型

下面是NodaTime和我们聊过的概念的对应关系(以及BCL里有的等效类型):

概念

NodaTime类型

BCL等效类型

例子

物理瞬间

Instant

DateTime (UTC)/DateTimeOffset

日志时间戳、令牌过期

日历日期

LocalDate

DateOnly ✓

生日、节假日

时钟显示时间

LocalTime

TimeOnly ✓

“09:00开门”

日期+时间(无时区)

LocalDateTime

DateTime (Unspecified)

用户选的会议时间

IANA时区

DateTimeZone

TimeZoneInfo (部分)

Europe/Vienna

完整上下文

ZonedDateTime

❌ 无

维也纳时间10点的会议

带偏移量的快照

OffsetDateTime

DateTimeOffset

某时刻的时钟显示

✓标记表示BCL类型是靠谱选择。用DateOnly/TimeOnly时,很多时候能完全不用NodaTime。

核心缺口是ZonedDateTime——把本地时间和IANA时区绑一块儿,让你正确处理夏令时。这是NodaTime的拿手好戏。

下面看具体咋用。

Instant:记物理瞬间

记录某事发生的时间(不跟任何人的日历挂钩)时用Instant

// 当前瞬间  
Instant now = SystemClock.Instance.GetCurrentInstant();  

// 从Unix时间戳来  
Instant fromUnix = Instant.FromUnixTimeSeconds(1735689600);  

// 日志、审计、事件溯源用  
public class AuditEntry  
{  
   public Instant OccurredAt { get; init; }  
   public string Action { get; init; }  
}

Instant不含糊。没时区干扰,不用查Kind属性,就是时间线上一个点。

LocalDate、LocalTime、LocalDateTime:记人类概念

这些类型代表日历和时钟的值,不带时区。

// 纯日期(NodaTime)  
LocalDate birthday = new LocalDate(1990, 3, 15);  

// 纯日期(BCL——一样好用!)  
DateOnly birthdayBcl = new DateOnly(1990, 3, 15);  

// 纯时间(NodaTime)  
LocalTime openingTime = new LocalTime(9, 0);  

// 纯时间(BCL——一样好用!)  
TimeOnly openingTimeBcl = new TimeOnly(9, 0);  

// 日期时间放一块(NodaTime)  
LocalDateTime meetingTime = new LocalDateTime(2026, 6, 5, 10, 0);

光要日期或时间,用哪个都行——DateOnly/TimeOnly是BCL自带的,跟EF Core配合也好。

要是你打算之后给日期时间加时区,NodaTime的LocalDateTime更清楚,因为它属于包含ZonedDateTime的完整类型体系。

2026-06-05T10:00这个LocalDateTime意思是“6月5日10点”——但还没说在哪。这是故意的。你后面会把它和时区结合,才完整。

DateTimeZone:时区规则集

DateTimeZone代表一个IANA时区——不止是偏移量,还有完整的规则(包括夏令时切换和历史变更)。

// 用IANA ID获取时区  
DateTimeZone vienna = DateTimeZoneProviders.Tzdb["Europe/Vienna"];  
DateTimeZone london = DateTimeZoneProviders.Tzdb["Europe/London"];  

// 用这个provider能访问所有IANA时区  
IDateTimeZoneProvider tzdb = DateTimeZoneProviders.Tzdb;

DateTimeZoneProviders.Tzdb用的是IANA的tz数据库,会定期更新新规则。你更新NodaTime的tzdb数据后,代码会自动适应新夏令时规则。

ZonedDateTime:完整上下文

ZonedDateTimeLocalDateTimeDateTimeZone绑一块儿——你要的全有了。

LocalDateTime local = new LocalDateTime(2026, 6, 5, 10, 0);  
DateTimeZone zone = DateTimeZoneProviders.Tzdb["Europe/Vienna"];  

// 组合它们  
ZonedDateTime zoned = local.InZoneLeniently(zone);  

// 现在能拿到瞬间  
Instant instant = zoned.ToInstant();  

// 或在其他时区显示  
ZonedDateTime inLondon = instant.InZone(DateTimeZoneProviders.Tzdb["Europe/London"]);  
Console.WriteLine(inLondon.ToString("uuuu-MM-dd HH:mm x", CultureInfo.InvariantCulture));  
// 输出:2026-06-05 09:00 +01(维也纳10点=伦敦9点,注意夏令时)  
// (需要:using System.Globalization;)

为啥用“Leniently”?

InZoneLeniently方法自动处理夏令时边缘情况:

  • 如果本地时间落在“空隙”(不存在),就往前挪

  • 如果落在“重叠”(存在两次),选较早那次

要显式控制,NodaTime有几种选项:

// 在LocalDateTime上调用的  
ZonedDateTime zoned = local.InZoneLeniently(zone);   // 自动解决空隙/重叠  
ZonedDateTime zoned = local.InZoneStrictly(zone);    // 模糊时抛异常  

// 在DateTimeZone上调用的(行为一样,语法不同)  
ZonedDateTime zoned = zone.AtLeniently(local);  
ZonedDateTime zoned = zone.AtStrictly(local);  

// 用自定义解析器  
ZonedDateTime zoned = local.InZone(zone, Resolvers.LenientResolver);

核心模式:存意图,推瞬间

这是第4篇文章的核心模式,用NodaTime实现:

public class Appointment  
{  
   // 源头:用户选的  
   public LocalDateTime LocalStart { get; init; }  
   public string TimeZoneId { get; init; }  

   // 推导的:用于查询和排序  
   public Instant InstantUtc { get; private set; }  

   public void RecalculateInstant()  
   {  
       var zone = DateTimeZoneProviders.Tzdb[TimeZoneId];  
       var zoned = LocalStart.InZoneLeniently(zone);  
       InstantUtc = zoned.ToInstant();  
   }  
}

时区规则变了,你对未来预约调RecalculateInstant()就行。过去的预约不受影响,因为IANA包含历史规则。

真实案例

例1:日志(用Instant)

public class LogEntry  
{  
   public Instant Timestamp { get; init; }  
   public string Level { get; init; }  
   public string Message { get; init; }  

   public static LogEntry Create(string level, string message)  
   {  
       return new LogEntry  
       {  
           Timestamp = SystemClock.Instance.GetCurrentInstant(),  
           Level = level,  
           Message = message  
       };  
   }  
}

例2:生日(用LocalDate)

public class Person  
{  
   public string Name { get; init; }  
   public LocalDate DateOfBirth { get; init; }  

   public int GetAge(LocalDate today)  
   {  
       return Period.Between(DateOfBirth, today, PeriodUnits.Years).Years;  
   }  
}

不用时区——生日是日历概念。

例3:会议(用LocalDateTime+时区)

public class Meeting  
{  
   public string Title { get; init; }  
   public LocalDateTime LocalStart { get; init; }  
   public string TimeZoneId { get; init; }  
   public Instant InstantUtc { get; init; }  

   public static Meeting Create(string title, LocalDateTime localStart, string timeZoneId)  
   {  
       var zone = DateTimeZoneProviders.Tzdb[timeZoneId];  
       var instant = localStart.InZoneLeniently(zone).ToInstant();  

       return new Meeting  
       {  
           Title = title,  
           LocalStart = localStart,  
           TimeZoneId = timeZoneId,  
           InstantUtc = instant  
       };  
   }  

   // 在任何时区显示  
   public string GetDisplayTime(DateTimeZone viewerZone)  
   {  
       var inViewerZone = InstantUtc.InZone(viewerZone);  
       // 注:uuuu是NodaTime推荐的年格式(绝对年)  
       return inViewerZone.ToString("uuuu-MM-dd HH:mm", CultureInfo.InvariantCulture);  
   }  
}

例4:截止日期(用LocalDateTime+时区)

public class Deadline  
{  
   public LocalDateTime LocalDeadline { get; init; }  
   public string TimeZoneId { get; init; }  
   public Instant InstantUtc { get; init; }  

   public bool IsPastDeadline(Instant now)  
   {  
       return now > InstantUtc;  
   }  

   public Duration TimeRemaining(Instant now)  
   {  
       return InstantUtc - now;  
   }  
}

EF Core集成

NodaTime没直接映射到SQL类型,但有现成的包。

PostgreSQL:用Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime

// 在DbContext配置里  
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)  
{  
   optionsBuilder.UseNpgsql(connectionString, o => o.UseNodaTime());  
}

映射关系:

  • Instanttimestamp with time zone

  • LocalDateTimetimestamp without time zone

  • LocalDatedate

  • LocalTimetime

ZonedDateTime呢?PostgreSQL没单个类型存它——这正是咱们模式的用意。你把它拆成几列存:

  • LocalDateTimetimestamp without time zone

  • TimeZoneIdtext

  • 可选:Instanttimestamp with time zone(用于查询)

ZonedDateTime拆分的例子:

ZonedDateTime zoned = local.InZoneLeniently(zone);  

// 拆分存储  
LocalDateTime localPart = zoned.LocalDateTime;  
string timeZoneId = zoned.Zone.Id;           // 比如"Europe/Vienna"  
Instant instantPart = zoned.ToInstant();

SQL Server:用值转换器

public class AppointmentConfiguration : IEntityTypeConfiguration<Appointment>  
{  
   public void Configure(EntityTypeBuilder<Appointment> builder)  
   {  
       builder.Property(a => a.LocalStart)  
           .HasConversion(  
               v => v.ToDateTimeUnspecified(),  // 存为DateTime(未指定)  
               v => LocalDateTime.FromDateTime(v));  // 读回LocalDateTime  

       builder.Property(a => a.InstantUtc)  
           .HasConversion(  
               v => v.ToDateTimeUtc(),  // 存为DateTime(UTC)  
               v => Instant.FromDateTimeUtc(v));  // 读回Instant  

       builder.Property(a => a.TimeZoneId)  
           .HasMaxLength(64);  // 存时区ID,比如"Europe/Vienna"  
   }  
}

示例实体

public class Appointment  
{  
   public Guid Id { get; init; }  
   public string Title { get; init; }  

   // 存为timestamp without time zone  
   public LocalDateTime LocalStart { get; init; }  

   // 存为text/varchar  
   public string TimeZoneId { get; init; }  

   // 存为timestamp with time zone(用于查询)  
   public Instant InstantUtc { get; init; }  
}

处理夏令时切换

创建可能落在夏令时空隙或重叠的预约时,要明确处理:

public class AppointmentService  
{  
   public ZonedDateTime ResolveLocalTime(LocalDateTime local, string timeZoneId)  
   {  
       var zone = DateTimeZoneProviders.Tzdb[timeZoneId];  
       var mapping = zone.MapLocal(local);  // 看这个时间在该时区的映射情况  

       return mapping.Count switch  
       {  
           0 => zone.AtLeniently(local),        // 空隙:往前挪到有效时间  
           1 => mapping.Single(),                // 正常:唯一映射  
           2 => mapping.First(),                 // 重叠:选较早那次  
           _ => throw new InvalidOperationException()  
       };  
   }  
}

要更精细控制(比如重叠时让用户选):

public ZonedDateTime ResolveWithUserChoice(  
   LocalDateTime local,  
   string timeZoneId,  
   Func<ZonedDateTime, ZonedDateTime, ZonedDateTime> overlapResolver)  
{  
   var zone = DateTimeZoneProviders.Tzdb[timeZoneId];  
   var mapping = zone.MapLocal(local);  

   return mapping.Count switch  
   {  
       0 => zone.AtLeniently(local),  
       1 => mapping.Single(),  
       2 => overlapResolver(mapping.First(), mapping.Last()),  // 让用户选  
       _ => throw new InvalidOperationException()  
   };  
}

从DateTime转换

现有代码用DateTime的话,这么转:

// DateTime (UTC) → Instant  
DateTime dtUtc = DateTime.UtcNow;  
Instant instant = Instant.FromDateTimeUtc(dtUtc);  

// DateTime (未指定) → LocalDateTime  
DateTime dt = new DateTime(2026, 6, 5, 10, 0, 0);  
LocalDateTime local = LocalDateTime.FromDateTime(dt);  

// Instant → DateTime (UTC)  
DateTime backToUtc = instant.ToDateTimeUtc();  

// LocalDateTime → DateTime (未指定)  
DateTime backToUnspecified = local.ToDateTimeUnspecified();

测试依赖时间的代码

直接调用SystemClock.Instance.GetCurrentInstant()的代码不好测——你控制不了“现在”。

NodaTime用IClock解决这问题:

// 生产环境:注入真时钟  
public class AppointmentService(IClock clock)  
{  
   public bool IsUpcoming(Appointment appointment)  
   {  
       var now = clock.GetCurrentInstant();  // 用注入的clock  
       return appointment.InstantUtc > now;  
   }  
}  

// 生产环境用真时钟  
var service = new AppointmentService(SystemClock.Instance);  

// 测试用假时钟  
var fakeNow = Instant.FromUtc(2026, 6, 5, 8, 0);  
var fakeClock = new FakeClock(fakeNow);  // 自己实现一个FakeClock也行  
var service = new AppointmentService(fakeClock);  

// 现在能确定性测试时间相关逻辑了

规矩:业务逻辑里别直接调SystemClock.Instance,注入IClock。测试会感谢你。

核心要点

  • 除了简单日志,涉及时区的事儿都用NodaTime——它给的概念对版

  • Instant记物理瞬间(日志、事件、令牌)

  • LocalDate记日历日期(生日、节假日)

  • 人类定的时间(会议、截止日期)用LocalDateTime + DateTimeZone

  • 存意图:LocalDateTime + TimeZoneId当源头数据

  • 推瞬间:算InstantUtc用于查询排序

  • 显式处理夏令时:用InZoneLeniently或查MapLocal处理边缘情况

  • EF Core集成:PostgreSQL用Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime,其他库用时转换器