12

我写这篇文章是为了收集对我们方法的评论,并希望能帮助其他人(和我的记忆)。

设想

  • 我们所有的数据库都使用DateTime没有时区信息的数据类型。
  • 在内部,我们知道我们数据库中的所有日期/时间都是本地(新西兰)时间,而不是 UTC。对于 Web 应用程序,这并不理想,但我们无法控制所有这些数据库的设计,因为它们支持其他系统(会计、工资单等)。
  • 我们使用实体框架(模型优先)进行数据访问。

我们的问题

  • 如果没有特定的时区信息,Breeze / Web Api / Entity Framework 堆栈似乎倾向于假设时间是 UTC,而不是本地时间,这可能是最好的,但不适合我们的应用程序。
  • Breeze 喜欢将日期以标准 UTC 格式传回服务器,尤其是在查询字符串(例如where子句)中。想象一个 Breeze 控制器,它直接将数据库中的表公开为 IQueryable。Breeze 客户端将以 UTC 格式将任何日期过滤器 (where) 子句传递给服务器。Entity Framework 将忠实地使用这些日期来创建 SQL 查询,完全不知道数据库表日期在我们当地的时区。对我们来说,这意味着结果与我们想要的结果相差 12 到 13 小时(取决于夏令时)。

我们的目标是确保我们的服务器端代码(和数据库)始终使用我们本地时区的日期,并且所有查询都返回所需的结果。

4

3 回答 3

15

我们的解决方案第 1 部分:实体框架

当实体框架DateTime从数据库中获取值时,它会将它们设置为DateTimeKind.Unspecified. 换句话说,既不是本地的也不是UTC。我们特别想将我们的日期标记为DateTimeKind.Local.

为了实现这一点,我们决定调整生成实体类的实体框架模板。我们没有将日期作为一个简单的属性,而是引入了一个后备存储日期,并使用属性设置器来设置日期(Local如果它是Unspecified.

在模板(.tt 文件)中,我们替换了...

public string Property(EdmProperty edmProperty)
{
    return string.Format(
        CultureInfo.InvariantCulture,
        "{0} {1} {2} {{ {3}get; {4}set; }}",
        Accessibility.ForProperty(edmProperty),
        _typeMapper.GetTypeName(edmProperty.TypeUsage),
        _code.Escape(edmProperty),
        _code.SpaceAfter(Accessibility.ForGetter(edmProperty)),
        _code.SpaceAfter(Accessibility.ForSetter(edmProperty)));
}

... 和 ...

public string Property(EdmProperty edmProperty)
{
    // Customised DateTime property handler to default DateKind to local time
    if (_typeMapper.GetTypeName(edmProperty.TypeUsage).Contains("DateTime")) {
        return string.Format(
            CultureInfo.InvariantCulture,
            "private {1} _{2}; {0} {1} {2} {{ {3}get {{ return _{2}; }} {4}set {{ _{2} = DateKindHelper.DefaultToLocal(value); }}}}",
            Accessibility.ForProperty(edmProperty),
            _typeMapper.GetTypeName(edmProperty.TypeUsage),
            _code.Escape(edmProperty),
            _code.SpaceAfter(Accessibility.ForGetter(edmProperty)),
            _code.SpaceAfter(Accessibility.ForSetter(edmProperty)));
    } else {
        return string.Format(
            CultureInfo.InvariantCulture,
            "{0} {1} {2} {{ {3}get; {4}set; }}",
            Accessibility.ForProperty(edmProperty),
            _typeMapper.GetTypeName(edmProperty.TypeUsage),
            _code.Escape(edmProperty),
            _code.SpaceAfter(Accessibility.ForGetter(edmProperty)),
            _code.SpaceAfter(Accessibility.ForSetter(edmProperty)));
    }
}

这创建了一个相当丑陋的单行设置器,但它完成了工作。它确实使用辅助函数将日期默认为Local如下所示:

public class DateKindHelper
{
    public static DateTime DefaultToLocal(DateTime date)
    {
        return date.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(date, DateTimeKind.Local) : date;
    }

    public static DateTime? DefaultToLocal(DateTime? date)
    {
        return date.HasValue && date.Value.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(date.Value, DateTimeKind.Local) : date;
    }
}

我们的解决方案第 2 部分:IQueryable 过滤器

where下一个问题是 Breeze 在将子句应用于我们的IQueryable控制器操作时传递 UTC 日期。在审查了 Breeze、Web API 和实体框架的代码后,我们决定最好的选择是拦截对我们控制器操作的调用,并将 UTC 日期替换为QueryString本地日期。

我们选择使用可以应用于控制器操作的自定义属性来执行此操作,例如:

[UseLocalTime]
public IQueryable<Product> Products()
{
    return _dc.Context.Products;
}

实现此属性的类是:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Http;
using System.Web.Http.Filters;
using System.Text.RegularExpressions;
using System.Xml;

namespace TestBreeze.Controllers.api
{
    public class UseLocalTimeAttribute : ActionFilterAttribute
    {
        Regex isoRegex = new Regex(@"((?:-?(?:[1-9][0-9]*)?[0-9]{4})-(?:1[0-2]|0[1-9])-(?:3[0-1]|0[1-9]|[1-2][0-9])T(?:2[0-3]|[0-1][0-9]):(?:[0-5][0-9]):(?:[0-5][0-9])(?:\.[0-9]+)?Z)", RegexOptions.IgnoreCase);

        public override void OnActionExecuting(System.Web.Http.Controllers.HttpActionContext actionContext)
        {
            // replace all ISO (UTC) dates in the query string with local dates
            var uriString = HttpUtility.UrlDecode(actionContext.Request.RequestUri.OriginalString);
            var matches = isoRegex.Matches(uriString);
            if (matches.Count > 0)
            {
                foreach (Match match in matches)
                {
                    var localTime = XmlConvert.ToDateTime(match.Value, XmlDateTimeSerializationMode.Local);
                    var localString = XmlConvert.ToString(localTime, XmlDateTimeSerializationMode.Local);
                    var encoded = HttpUtility.UrlEncode(localString);
                    uriString = uriString.Replace(match.Value, encoded);
                }
                actionContext.Request.RequestUri = new Uri(uriString);
            }

            base.OnActionExecuting(actionContext);
        }
    }
}

我们的解决方案第 3 部分:Json

这可能更具争议性,但我们的网络应用程序受众也完全是本地的 :)。

我们希望发送给客户端的 Json 默认包含本地时区的日期/时间。此外,我们希望从客户端收到的任何 Json 日期都转换为我们的本地时区。为此,我们创建了一个自定义JsonLocalDateTimeConverter并更换了 Json 转换器 Breeze 安装。

转换器如下所示:

public class JsonLocalDateTimeConverter : IsoDateTimeConverter
{
    public JsonLocalDateTimeConverter () : base() 
    {
        // Hack is for the issue described in this post (copied from BreezeConfig.cs):
        // http://stackoverflow.com/questions/11789114/internet-explorer-json-net-javascript-date-and-milliseconds-issue
        DateTimeFormat = "yyyy-MM-dd\\THH:mm:ss.fffK";
    }


    // Ensure that all dates go out over the wire in full LOCAL time format (unless date has been specifically set to DateTimeKind.Utc)
    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        if (value is DateTime)
        {
            // if datetime kind is unspecified then treat is as local time
            DateTime dateTime = (DateTime)value;
            if (dateTime.Kind == DateTimeKind.Unspecified)
            {
                dateTime = DateTime.SpecifyKind(dateTime, DateTimeKind.Local);
            }

            base.WriteJson(writer, dateTime, serializer);
        }
        else
        {
            base.WriteJson(writer, value, serializer);
        }
    }


    // Ensure that all dates arriving over the wire get parsed into LOCAL time
    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        var result = base.ReadJson(reader, objectType, existingValue, serializer);

        if (result is DateTime)
        {
            DateTime dateTime = (DateTime)result;
            if (dateTime.Kind != DateTimeKind.Local)
            {
                result = dateTime.ToLocalTime();
            }
        }

        return result;
    }
}

最后为了安装上面的转换器,我们创建了一个CustomBreezeConfig类:

public class CustomBreezeConfig : Breeze.WebApi.BreezeConfig
{

    protected override JsonSerializerSettings CreateJsonSerializerSettings()
    {
        var baseSettings = base.CreateJsonSerializerSettings();

        // swap out the standard IsoDateTimeConverter that breeze installed with our own
        var timeConverter = baseSettings.Converters.OfType<IsoDateTimeConverter>().SingleOrDefault();
        if (timeConverter != null)
        {
            baseSettings.Converters.Remove(timeConverter);
        }
        baseSettings.Converters.Add(new JsonLocalDateTimeConverter());

        return baseSettings;
    }
}

就是这样。欢迎所有意见和建议。

于 2013-04-28T09:52:05.603 回答
2

尽管我意识到您可能无法在您的场景中控制这一点,但我相信解决此问题的另一种方法是使用类型 DateTimeOffset 而不是 DateTime 来表示实体模型中的日期/时间。

于 2015-07-06T13:41:48.093 回答
1

我看到了你的文章,想传递一些信息。一位同事实施了您的解决方案,它适用于服务器时区的任何用户。不幸的是,对于服务器时区以外的用户,它不起作用。

我已修改您的转换器类以使用 TimeZoneInfo。这是代码:

public class JsonLocalDateTimeConverter : IsoDateTimeConverter
{
    public JsonLocalDateTimeConverter()
        : base()
    {
        // Hack is for the issue described in this post (copied from BreezeConfig.cs):
        // http://stackoverflow.com/questions/11789114/internet-explorer-json-net-javascript-date-and-milliseconds-issue
        DateTimeFormat = "yyyy-MM-dd\\THH:mm:ss.fffK";
    }


    // Ensure that all dates go out over the wire in full LOCAL time format (unless date has been specifically set to DateTimeKind.Utc)
    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        if (value is DateTime)
        {
            // if datetime kind is unspecified - coming from DB, then treat is as UTC - user's UTC Offset. All our dates are saved in user's proper timezone. Breeze will Re-add the offset back
            var userdateTime = (DateTime)value;
            if (userdateTime.Kind == DateTimeKind.Unspecified)
            {
                userdateTime = DateTime.SpecifyKind(userdateTime, DateTimeKind.Local);
                var timeZoneInfo = ApplicationContext.Current.TimeZoneInfo;
                var utcOffset = timeZoneInfo.GetUtcOffset(userdateTime);
                userdateTime = DateTime.SpecifyKind(userdateTime.Subtract(utcOffset), DateTimeKind.Utc);
            }

            base.WriteJson(writer, userdateTime, serializer);
        }
        else
        {
            base.WriteJson(writer, value, serializer);
        }
    }


    // Ensure that all dates arriving over the wire get parsed into LOCAL time
    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        var result = base.ReadJson(reader, objectType, existingValue, serializer);

        if (result is DateTime)
        {
            var utcDateTime = (DateTime)result;
            if (utcDateTime.Kind != DateTimeKind.Local)
            {
                // date is UTC, convert it to USER's local time
                var timeZoneInfo = ApplicationContext.Current.TimeZoneInfo;
                var utcOffset = timeZoneInfo.GetUtcOffset(utcDateTime);
                result = DateTime.SpecifyKind(utcDateTime.Add(utcOffset), DateTimeKind.Local);
            }
        }

        return result;
    }
}

这里的关键是:

var timeZoneInfo = ApplicationContext.Current.TimeZoneInfo;

此变量在登录时在我们的用户上下文中设置。当用户登录时,我们在登录请求中传递 jsTimezoneDetect 的结果,并将该信息放在服务器上用户的上下文中。因为我们有一个 Windows 服务器,而 jsTimezoneDetect 会输出一个 IANA 时区,我们需要一个 Windows 时区,所以我在我们的解决方案中导入了 noda-time nuget,并使用以下代码,我们可以将 IANA 时区转换为 Windows 时区:

// This will return the Windows zone that matches the IANA zone, if one exists.
public static string IanaToWindows(string ianaZoneId)
{
    var utcZones = new[] { "Etc/UTC", "Etc/UCT" };
    if (utcZones.Contains(ianaZoneId, StringComparer.OrdinalIgnoreCase))
        return "UTC";

    var tzdbSource = NodaTime.TimeZones.TzdbDateTimeZoneSource.Default;

    // resolve any link, since the CLDR doesn't necessarily use canonical IDs
    var links = tzdbSource.CanonicalIdMap
      .Where(x => x.Value.Equals(ianaZoneId, StringComparison.OrdinalIgnoreCase))
      .Select(x => x.Key);

    var mappings = tzdbSource.WindowsMapping.MapZones;
    var item = mappings.FirstOrDefault(x => x.TzdbIds.Any(links.Contains));
    if (item == null) return null;
    return item.WindowsId;
}
于 2014-09-05T18:03:30.470 回答