You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何以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查询中显式比较内部属性,比如:
    query.Where(e => e.StartDateTime.DateTime < target.DateTime && e.StartDateTime.TimeZoneId == target.TimeZoneId)
    
    跨时区比较时,可预先将目标转换为UTC,再对比记录的UTC计算值(需确保EF能识别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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.11 07:45:54