标签: .NET开发 2026-01-23 次
.NET软件开发的基类库(BCL)改进不小——自.NET 6起新增的DateOnly和TimeOnly就是处理日期和时间的靠谱类型。但要搞涉及时区的调度(比如会议、截止日期、得扛住夏令时变化的约会),你还得用NodaTime。它给你提供了BCL至今没补齐的那些类型。
这篇文章教你用NodaTime正确开发建模时间、妥善存储,避开咱们系列里聊过的那些坑。

.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)时,Kind是Unspecified。这10点是维也纳时间?伦敦时间?还是UTC时间?类型不知道,你的代码也不知道。
.NET 6加了俩类型,解决了部分问题:
DateOnly birthday = new DateOnly(1990, 3, 15); // 纯日期,没时间混淆
TimeOnly openingTime = new TimeOnly(9, 0); // 纯时间,没日期混淆
这俩挺好!要是只需要日期或只需要时间,就用它们。它们是BCL自带的,EF Core支持到位,干啥像啥。
但要完整处理涉及时区的调度,BCL还是差点意思:
没有Instant类型(你得用带Kind.Utc的DateTime或DateTimeOffset)
没有语义规范的LocalDateTime(你得用Kind.Unspecified的DateTime)
没有能把本地时间和时区绑一块儿的ZonedDateTime
没有一流的IANA时区支持(TimeZoneInfo默认用Windows时区)
DateTimeOffset比DateTime强点——它带了偏移量——但就像第4篇文章说的,偏移量是“快照”,不是“含义”。+02:00可能是维也纳夏天、柏林夏天、开罗或约翰内斯堡的时间,你分不清。
简单场景:DateOnly、TimeOnly、DateTime、DateTimeOffset够用。
涉及时区调度: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 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属性,就是时间线上一个点。
这些类型代表日历和时钟的值,不带时区。
// 纯日期(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代表一个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把LocalDateTime和DateTimeZone绑一块儿——你要的全有了。
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;)
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包含历史规则。
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
};
}
}
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;
}
}
不用时区——生日是日历概念。
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);
}
}
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;
}
}
NodaTime没直接映射到SQL类型,但有现成的包。
PostgreSQL:用Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime
// 在DbContext配置里
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseNpgsql(connectionString, o => o.UseNodaTime());
}
映射关系:
Instant→ timestamp with time zone
LocalDateTime→ timestamp without time zone
LocalDate→ date
LocalTime→ time
ZonedDateTime呢?PostgreSQL没单个类型存它——这正是咱们模式的用意。你把它拆成几列存:
LocalDateTime→ timestamp without time zone
TimeZoneId→ text
可选:Instant→ timestamp 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 (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,其他库用时转换器