Как мы выживаем с использованием локального часового пояса с Breeze

Я пишу это, чтобы собрать комментарии по нашим подходам и, надеюсь, помочь кому-то еще (и моей памяти).

Сценарий

  • Все наши базы данных используют типы данных DateTime без информации о часовом поясе.
  • Внутри мы знаем, что все даты/время в наших базах данных находятся в локальной (Новой Зеландии) времени, а не в UTC. Для веб-приложения это не идеально, но мы не контролируем дизайн всех этих баз данных, поскольку они поддерживают другие системы (учет, начисление заработной платы и т.д.).
  • Мы используем Entity Framework (сначала модель) для доступа к данным.

Наша проблема

  • Без конкретной информации о часовом поясе стек Breeze/Web Api/Entity Framework кажется предпосылкой того, что время UTC, а не локальное, что, вероятно, является лучшим, но не подходит для наших приложений.
  • Breeze любит передавать даты на сервер в стандартном формате UTC, особенно в строках запроса (например, where). Представьте контроллер Breeze, который непосредственно предоставляет таблицу из базы данных как IQueryable. Клиент Breeze будет передавать любые условия фильтра даты (где) на сервер в формате UTC. Entity Framework будет точно использовать эти даты для создания SQL-запроса, совершенно не подозревая, что даты таблицы базы данных находятся в нашем локальном часовом поясе. Для нас это означает, что результаты будут от 12 до 13 часов смещены от тех, которые мы хотим (в зависимости от летнего времени).

Наша цель - обеспечить, чтобы наш код на стороне сервера (и база данных) последовательно использовал даты в нашем локальном часовом поясе и что все запросы возвращают желаемые результаты.

Ответ 1

Наше решение часть 1: структура сущностей

Когда Entity Framework получает значения DateTime из базы данных, она устанавливает их в DateTimeKind.Unspecified. Другими словами, ни локальный, ни UTC. Мы специально хотели отметить наши даты как DateTimeKind.Local.

Для этого мы решили настроить шаблон Entity Framework, который генерирует классы сущностей. Вместо того, чтобы наши даты были простым свойством, мы ввели дату хранилища и использовали средство настройки свойств, чтобы сделать дату 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

Следующая проблема заключалась в том, что Бриз передавал даты UTC при применении предложений where к нашим действиям контроллера IQueryable. Просмотрев код для Breeze, Web API и Entity Framework, мы решили, что лучшим вариантом было перехват вызовов на наши действия с контроллером и изменение дат 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 и поменяли установление BZZ конвертера Json.

Конвертер выглядит так:

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;
    }
}

Что об этом. Все комментарии и предложения приветствуются.

Ответ 2

Хотя я понимаю, что вы не можете контролировать это в своем сценарии, я считаю, что другим решением этой проблемы является использование типа DateTimeOffset, а не DateTime, чтобы представлять дату и время в вашей модели сущности.

Ответ 3

Я добрался до вашей статьи и хотел передать некоторую информацию. Ваш коллега выполнил ваше решение, и он хорошо работал для всех пользователей в часовом поясе сервера. К сожалению, для пользователей за пределами часовой пояс сервера это не сработало.

Я изменил ваш класс конвертера, чтобы использовать TimeZoneInfo. Здесь код:

public class JsonLocalDateTimeConverter : IsoDateTimeConverter
{
    public JsonLocalDateTimeConverter()
        : base()
    {
        // Hack is for the issue described in this post (copied from BreezeConfig.cs):
        // http://stackoverflow.com/info/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 UTC Offset. All our dates are saved in user 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 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;
}