如何以Entity Framework兼容方式存储DateTime+TimeZoneInfo.Id及相关疑问
关于DateTimeZone类的问题解答
1. 能否将该类型作为结构体放入EF模型类中?
可以,但需满足EF对值对象的要求:结构体必须是不可变的(所有字段/属性只读,通过构造函数完成初始化),且实现IEquatable<T>。不过你当前用builder.OwnsOne映射类的方式已经足够稳定,结构体仅能提供值语义的拷贝行为,实际收益有限。如果要转结构体,需确保所有成员只读,再在EF配置中通过ValueConverter或直接标记为值对象。
2. 是否需要添加UTC列并通过守护进程更新?
属于过度设计,完全不需要。原因如下:
- .NET的
TimeZoneInfo会自动使用系统最新时区规则(Windows通过系统更新,Linux通过tzdata包),需要UTC时间时直接调用ConvertToUtc计算即可,无需提前存储。 - 对于未来预约,时区规则变更后,用最新规则计算的UTC才是正确的,存储固定UTC反而会导致错误。只有当业务要求固定历史UTC值(比如创建预约时的UTC,不受后续时区规则影响)时,才需要存储UTC列,但这不属于常规场景。
3. 用户查看未来一周事件时,是否需要转换为UTC?
不需要直接转UTC展示。用户关注的是自身时区的本地时间,直接用存储的DateTime+TimeZoneId渲染即可。如果需要筛选“未来一周”的事件:
- 若用户仅查看自身时区的事件,直接筛选
DateTime在用户时区的“未来一周”范围内,且TimeZoneId匹配的记录即可。 - 若需跨时区筛选,可将查询条件转换为UTC范围,再通过EF计算每个记录的UTC时间进行比较(需注意EF是否能翻译转换逻辑,若不能则拉取到内存后过滤)。
4. 如何让EF支持对象直接比较?
你已经实现了IComparable<DateTimeZone>和比较运算符,但EF无法将这些逻辑翻译为SQL,仅能在内存查询中生效。实用的解决方案有两种:
- 拆解字段比较:在EF查询中显式比较内部属性,比如:
跨时区比较时,可预先将目标转换为UTC,再对比记录的UTC计算值(需确保EF能识别query.Where(e => e.StartDateTime.DateTime < target.DateTime && e.StartDateTime.TimeZoneId == target.TimeZoneId)ConvertToUtc的逻辑,或在内存中过滤)。 - 自定义ValueComparer:在EF模型配置中为
DateTimeZone设置值比较器,但仅适用于内存查询,无法生成SQL。
5. 现有实现的遗漏要点
- 时区ID验证:构造函数和
ConvertTimeZone方法未验证TimeZoneId的有效性,应添加TimeZoneInfo.FindSystemTimeZoneById的异常捕获或前置校验,避免无效ID导致崩溃。 - 夏令时异常处理:转换时区时可能遇到夏令时切换导致的重复时间或不存在时间,当前代码未处理
TimeZoneInfo.ConvertTime抛出的异常,需添加异常处理逻辑,或使用TimeZoneInfo.ConvertTimeBySystemTimeZoneId并指定时间调整规则。 - 不可变性优化:当前类的
DateTime和TimeZoneId是私有setter,建议改为完全只读(用init修饰或只读字段),避免意外修改,更符合值对象的设计原则。 - EF列名自定义:用
OwnsOne映射时默认生成带前缀的列名(如StartDateTime_DateTime),可通过配置自定义列名:builder.OwnsOne(e => e.StartDateTime, b => { b.Property(p => p.DateTime).HasColumnName("StartDateTime"); b.Property(p => p.TimeZoneId).HasColumnName("StartTimeZoneId"); }); - 调试友好性:
ToString方法可添加UTC时间输出,方便调试时快速查看转换结果。
原DateTimeZone类代码
/// <summary> /// A combined date and time with a time zone offset. The DateTime whenever passed in, if not set to Unspecified, /// is silently converted to Unspecified, not changing the DateTime value. /// </summary> public class DateTimeZone : IComparable<DateTimeZone> { private DateTime _dateTime; /// <summary> /// The DateTime. This will always return DateTimeKind.Unspecified. The setter will silently convert /// to DateTimeKind.Unspecified if needed, <b>not</b> changing the DateTime if it does so. /// </summary> public DateTime DateTime { get => _dateTime; private set => _dateTime = value.Kind == DateTimeKind.Unspecified ? value : DateTime.SpecifyKind(value, DateTimeKind.Unspecified); } /// <summary> /// The TimeZoneId. These are defined in https://docs.microsoft.com/en-us/dotnet/api/system.timezoneinfo.id /// </summary> public string TimeZoneId { get; private set; } /// <summary> /// The TimeZoneInfo for the TimeZoneId. This is created on the fly and is not stored in the database as its /// underlying data can change when timezone rules are changed. /// </summary> [NotMapped] public TimeZoneInfo TimeZoneInfo => TimeZoneInfo.FindSystemTimeZoneById(TimeZoneId); /// <summary> /// Returns the DateTime field converted to UTC. /// </summary> public DateTime ConvertToUtc { get { Trap.Break(); return TimeZoneInfo.ConvertTimeToUtc(DateTime); } } /// <summary> /// Create a new DateTimeZone with the current time and the passed in TimeZoneId. /// </summary> /// <param name="dateTime">The DateTime. DateTimeKind will be silently set to Unspecified.</param> /// <param name="timeZoneId">The TimeZone Id.</param> public DateTimeZone(DateTime dateTime, string timeZoneId) { if (dateTime.Kind != DateTimeKind.Unspecified) dateTime = DateTime.SpecifyKind(dateTime, DateTimeKind.Unspecified); DateTime = dateTime; TimeZoneId = timeZoneId; } /// <summary> /// Create the object with the current time and the local timezone. /// </summary> public DateTimeZone() { DateTime = DateTime.SpecifyKind(DateTime.Now, DateTimeKind.Unspecified); TimeZoneId = TimeZoneInfo.Local.Id; } /// <summary> /// Create and return a new object that is converted to the passed in TimeZoneId. This <b>will</b> adjust /// the DateTime property by the difference in the time zones. /// </summary> /// <param name="timeZoneId">Adjust the returned datetime to the matching time in this timezone.</param> /// <returns>The DateTime in the requested timezone.</returns> public DateTimeZone ConvertTimeZone(string timeZoneId) { if (timeZoneId == TimeZoneId) return this; Trap.Break(); var destTimeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); var dateTime = TimeZoneInfo.ConvertTime(DateTime, TimeZoneInfo, destTimeZoneInfo); return new DateTimeZone(dateTime, timeZoneId); } /// <summary> /// Returns with the DateTime set to Now in the passed in TimeZoneId. /// </summary> /// <param name="timeZoneId">Return Now in this timezone.</param> /// <returns>DateTime.Now in the requested timezone.</returns> public static DateTimeZone Now(string timeZoneId) { var now = new DateTimeZone(DateTime.Now, TimeZoneInfo.Local.Id); return now.ConvertTimeZone(timeZoneId); } /// <inheritdoc /> public int CompareTo(DateTimeZone? other) { if (ReferenceEquals(this, other)) return 0; if (ReferenceEquals(null, other)) return 1; // if they have the same timezone, compare the dates if (TimeZoneId == other.TimeZoneId) return _dateTime.CompareTo(other._dateTime); // ok, convert both to UTC and then compare the dates. Trap.Break(); var utc1 = ConvertTimeZone(TimeZoneInfo.Utc.Id); var utc2 = other.ConvertTimeZone(TimeZoneInfo.Utc.Id); return utc1.CompareTo(utc2); } public static bool operator <(DateTimeZone x, DateTimeZone y) => x.CompareTo(y) < 0; public static bool operator <=(DateTimeZone x, DateTimeZone y) => x.CompareTo(y) <= 0; public static bool operator >(DateTimeZone x, DateTimeZone y) => x.CompareTo(y) > 0; public static bool operator >=(DateTimeZone x, DateTimeZone y) => x.CompareTo(y) >= 0; public static bool operator !=(DateTimeZone x, DateTimeZone y) => x.CompareTo(y) != 0; public static bool operator ==(DateTimeZone x, DateTimeZone y) => x.CompareTo(y) == 0; protected bool Equals(DateTimeZone other) { return CompareTo(other) == 0; } /// <inheritdoc /> public override bool Equals(object? obj) { if (ReferenceEquals(null, obj)) return false; if (ReferenceEquals(this, obj)) return true; if (obj.GetType() != this.GetType()) return false; return Equals((DateTimeZone)obj); } /// <inheritdoc /> public override int GetHashCode() { return HashCode.Combine(_dateTime, TimeZoneId); } /// <inheritdoc /> public override string ToString() { return $"{nameof(DateTime)}: {_dateTime}, {nameof(TimeZoneId)}: {TimeZoneId}"; } }
内容的提问来源于stack exchange,提问作者David Thielen
相关产品推荐
相关产品推荐

