Стратегия внедрения Noda Time в существующее приложение MVC5

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

Теперь все изменилось, и мы открываем двери для международной аудитории. Первой мыслью было «Вот дерьмо. Нам нужно провести рефакторинг всего нашего решения!»

TimeZoneInfo

Мы открыли LinQPad и начали набрасывать различные преобразователи для преобразования обычных DateTime объектов в DateTimeOffset объекты на основе объекта TimeZoneInfo, который был создан на основе значения идентификатора часового пояса пользователя из указанного профиля пользователя.

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

Большая часть фрагментов кода была вдохновлена ​​сообщение в блоге Рика Штрала по этой теме.

NodaTime и DateTimeOffset

Но потом я прочитал Превосходный комментарий Мэтта Джонсона. Он подтвердил мое намерение перейти на DateTimeOffset, заявив: «DateTimeOffset необходим в веб-приложении».

Что касается Noda Time, Мэтт говорит:

Говоря о Noda Time, я не соглашусь с вами, что вам нужно заменить все в вашей системе. Конечно, если вы это сделаете, у вас будет намного меньше возможностей ошибиться, но вы, безусловно, можете просто использовать Noda Time там, где это имеет смысл. Я лично работал над системами, которые должны были преобразовывать часовые пояса с использованием часовых поясов IANA (например, «America / Los_Angeles»), но отслеживал все остальное в типах DateTime и DateTimeOffset. На самом деле довольно часто можно увидеть, что Noda Time широко используется в логике приложения, но полностью исключен из DTO и уровней сохраняемости. В некоторых технологиях, таких как Entity Framework, вы не могли использовать Noda Time напрямую, если бы захотели - потому что его некуда подключить.

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

Наш план, хороший или плохой?

Наша основная цель - создать наименее сложный рабочий процесс для работы с датами и временем в различных часовых поясах. По возможности избегайте расчетов часовых поясов в наших Сервисах, репозиториях и контроллерах.

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

Ключевым фактором в определении правильного ZonedDateTime является свойство TimeZoneId в модели пользователя.

public class ApplicationUser : IdentityUser
{
    [Required]
    public string TimezoneId { get; set; }
}

Местное DateTime в NodaTime

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

public class LocalDateTimeModelBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        HttpRequestBase request = controllerContext.HttpContext.Request;

        // Get the posted local datetime
        string dt = request.Form.Get("DateTime");
        DateTime dateTime = DateTime.Parse(dt);

        // Get the logged in User
        IPrincipal p = controllerContext.HttpContext.User;
        var user = p.ApplicationUser();

        // Convert to ZonedDateTime
        LocalDateTime localDateTime = LocalDateTime.FromDateTime(dateTime);
        IDateTimeZoneProvider timeZoneProvider = DateTimeZoneProviders.Tzdb;
        var usersTimezone = timeZoneProvider[user.TimezoneId];
        var zonedDbDateTime = usersTimezone.AtLeniently(localDateTime);

        return zonedDbDateTime;
    }
}

Мы можем засорять наши контроллеры этими переплетами моделей.

[HttpPost]
[Authorize]
public ActionResult SimpleDateTime([ModelBinder(typeof (LocalDateTimeModelBinder))] ZonedDateTime dateTime)
{
   // Do stuff with the ZonedDateTime object
}

Мы слишком много думаем об этом?

Хранение DateTimeOffset в БД

Мы будем использовать концепцию свойств Buddy. Честно говоря, я не большой поклонник этого из-за путаницы, которую это создает. Новые разработчики, вероятно, будут бороться с тем фактом, что у нас есть более одного способа сохранить дату создания.

Мы очень приветствуем предложения о том, как это улучшить. Я читал комментарии о сокрытии свойств из IntelliSense для установки реальных свойств на private.

public class Item
{
    public int Id { get; set; }
    public string Title { get; set; }

    // The "real" property
    public DateTimeOffset DateCreated { get; private set; } 


    // Buddy property
    [NotMapped]
    public ZonedDateTime CreatedAt
    {
        get
        {
            // DateTimeOffset to NodaTime, based on User's TZ
            return ToZonedDateTime(DateCreated);
        }

        // NodaTime to DateTimeOffset
        set { DateCreated = value.ToDateTimeOffset(); }
    }


    public string OwnerId { get; set; }
    [ForeignKey("OwnerId")]
    public virtual ApplicationUser Owner { get; set; }

    // Helper method
    public ZonedDateTime ToZonedDateTime(DateTimeOffset dateTime, string tz = null)
    {
        if (string.IsNullOrEmpty(tz))
        {
            tz = Owner.TimezoneId;
        }
        IDateTimeZoneProvider timeZoneProvider = DateTimeZoneProviders.Tzdb;
        var usersTimezoneId = tz;
        var usersTimezone = timeZoneProvider[usersTimezoneId];

        var zonedDate = ZonedDateTime.FromDateTimeOffset(dateTime);
        return zonedDate.ToInstant().InZone(usersTimezone);
    }
}

Все между

Теперь у нас есть приложение на основе Noda Time. Объект ZonedDateTime упрощает выполнение специальных вычислений и запросов на основе часовых поясов.

Это правильное предположение?


person Fred Fickleberry III    schedule 16.10.2015    source источник
comment
Перевариваю ваш пост .... В ближайшее время отвечу ... :)   -  person Matt Johnson-Pint    schedule 16.10.2015
comment
Круто :) Спасибо, Мэтт. Это будет иметь большое влияние на рефакторинг, поэтому просто хочу проверить наш план, прежде чем мы начнем.   -  person Fred Fickleberry III    schedule 16.10.2015
comment
Важный вопрос - какой тип строкового ввода вы ожидаете получить в качестве параметра для вашего контроллера? ISO? Со смещением или равниной? УНИВЕРСАЛЬНОЕ ГЛОБАЛЬНОЕ ВРЕМЯ? Варьируется? Приведите примерные значения. Спасибо.   -  person Matt Johnson-Pint    schedule 16.10.2015
comment
Мы получаем местное datetime без информации о UTC.   -  person Fred Fickleberry III    schedule 16.10.2015
comment
Пример: 2015-10-16 20:40:00   -  person Fred Fickleberry III    schedule 16.10.2015
comment
И вы всегда интерпретируете это как часовой пояс пользователя, обозначенный ApplicationUser.TimezoneId, верно? Или могут быть случаи, когда один пользователь просматривает данные другого пользователя?   -  person Matt Johnson-Pint    schedule 16.10.2015
comment
(Мне нужно помнить, что не нажимать ввод для новой строки) :) Итак, мы получаем локальное datetime, а затем используем идентификатор часового пояса IANA вошедшего в систему пользователя, чтобы преобразовать его в правильный ZonedDateTime   -  person Fred Fickleberry III    schedule 16.10.2015
comment
Давайте продолжим это обсуждение в чате.   -  person Fred Fickleberry III    schedule 16.10.2015
comment
@FredFickleberryIII. Вы случайно не писали о своем опыте внедрения NodaTime в сообщение в блоге, на которое можно дать ссылку? Я сейчас в таком же положении, и мне интересно, чем ты закончил.   -  person Sven    schedule 23.04.2018


Ответы (1)


Во-первых, я должен сказать, что впечатлен! Это очень хорошо написанный пост, и вы, кажется, исследовали многие вопросы, связанные с этой темой.

Ваш подход хорош. Однако я предлагаю вам рассмотреть в качестве улучшений следующее.

  • Подшивку модели можно было улучшить.

    • Я бы назвал его ZonedDateTimeModelBinder, поскольку вы применяете его для создания ZonedDateTime значений.

    • Вы захотите использовать bindingContext для получения значения, вместо того, чтобы ожидать, что ввод всегда будет в request.Form.Get("DateTime"). Вы можете увидеть пример этого в связывателе модели WebAPI, который я написал для LocalDate. Связующие модели MVC аналогичны.

    • В этом примере вы также увидите, как я использую возможности синтаксического анализа Noda Time вместо DateTime.Parse. Вы можете подумать о том, чтобы сделать что-то подобное, используя LocalDateTimePattern.

    • Убедитесь, что вы понимаете, как работает AtLeniently, а также что мы изменили его поведение в предстоящем выпуске 2.0 (по уважительной причине). См. «Мягкие изменения преобразователя» в нижней части руководства по миграции. Если это имеет значение для вашего домена, вы можете рассмотреть возможность использования нового поведения сегодня, реализовав свой собственный преобразователь.

    • Вы можете подумать, что могут быть контексты, в которых часовой пояс текущего пользователя не соответствует данным, с которыми вы в настоящее время работаете. Возможно, администратор работает с данными какого-то другого пользователя. Следовательно, вам может потребоваться перегрузка, которая принимает идентификатор часового пояса в качестве параметра.

  • В общем случае вы можете попробовать зарегистрировать связыватель модели глобально, что избавит вас от некоторых нажатий клавиш на ваших контроллерах:

      ModelBinders.Binders.Add(typeof(ZonedDateTime), new ZonedDateTimeModelBinder());
    

    Вы всегда можете использовать атрибутированный способ, если есть параметр, который нужно передать.

  • В конце кода ZonedDateTime.FromDateTimeOffset(dto).ToInstant().InZone(tz) подходит, но может быть выполнено с меньшим количеством кода. Любой из них эквивалентен:

    • ZonedDateTime.FromDateTimeOffset(dto).WithZone(tz)
    • Instant.FromDateTimeOffset(dto).InZone(tz)
  • Похоже, это производственное приложение, и поэтому я бы сейчас нашел время, чтобы настроить возможность обновления данных о вашем собственном часовом поясе.

  • См. руководство пользователя о том, как использовать файлы NZD вместо встроенной копии в DateTimeZoneProviders.Tzdb.

  • Хороший подход - внедрить конструктор IDateTimeZoneProvider и зарегистрировать его в контейнере DI по вашему выбору.

  • Обязательно подпишитесь на список объявлений от IANA, чтобы знать, когда будут опубликованы новые обновления TZDB. Файлы Noda Time NZD обычно появляются на короткое время позже.

  • Или вы можете пофантазировать и написать что-нибудь, чтобы проверить наличие последнего файла .NZD и автоматически обновить свою систему, до тех пор, пока вы понимаете, что (если что) должно произойти с вашей стороны после обновления. (Это вступает в игру, когда приложение включает в себя планирование будущих событий.)

  • Свойства друзей WRT - Да, я согласен, это PITA. Но, к сожалению, в настоящее время у EF нет лучшего подхода, поскольку он не поддерживает сопоставления настраиваемых типов. В EF6 этого, скорее всего, никогда не будет, но это отслеживается в aspnet / EntityFramework # 242 для EF7.

    • Update - In EF Core, you can't now use Value Converters to support Noda Time datatypes in your entity models.

Теперь, с учетом всего вышесказанного, вы можете поступить немного иначе. Я сделал это, и да - это сложно. Упрощенный подход:

  • Ни в коем случае не используйте типы Noda Time в своих сущностях. Просто используйте DateTimeOffset вместо ZonedDateTime.

  • Используйте ZonedDateTime и часовой пояс пользователя только в той точке, где вы выполняете логику приложения.

Обратной стороной этого подхода является то, что ситуация с вашим доменом становится непонятной. Иногда бизнес-логика проникает в сервисы вместо того, чтобы оставаться в сущностях, которым она принадлежит. Или, если он остается в сущности, теперь вам нужно передать параметр timeZoneId различным методам, где вы иначе могли бы об этом не думать. Иногда это приемлемо, а иногда нет. Это просто зависит от того, сколько работы он вам создаст.

Наконец, я обращусь к этой части:

Теперь у нас есть приложение на основе Noda Time. Объект ZonedDateTime упрощает выполнение специальных вычислений и запросов на основе часовых поясов.

Это правильное предположение?

И да и нет. Прежде чем вы слишком углубитесь в применение всего вышеперечисленного к вашему приложению, вы можете попробовать несколько операций изолированно с ZonedDateTime.

В первую очередь ZonedDateTime обеспечивает учет часового пояса при преобразовании в другие типы и обратно, а также при выполнении математических операций, связанных с мгновенным временем (с использованием Duration объектов).

Там, где это действительно не помогает, так это при работе с календарным временем. Например, если я хочу добавить один день - мне нужно подумать, означает ли это добавить продолжительность в 24 часа или добавить период в один календарный день. В большинстве дней это будет одно и то же, но не в дни с переходом на летнее время. Там они могут составлять 23, 23,5, 24, 24,5 или 25 часов в зависимости от часового пояса. ZonedDateTime не позволит вам напрямую добавить Period. Вместо этого вам нужно получить LocalDateTime, затем добавить период, затем повторно применить часовой пояс, чтобы вернуться к ZonedDateTime.

Так что - хорошенько подумайте, нужно ли вам везде одинаково или нет. Если логика вашего приложения основана исключительно на календарных днях, то вам может показаться, что лучше всего писать ее исключительно в терминах LocalDate. Возможно, вам придется проработать различные свойства и методы, чтобы на самом деле использовать эту логику, но, по крайней мере, логика моделируется в ее чистом виде.

Надеюсь, это поможет, и, надеюсь, этот пост будет полезным для других читателей. Удачи и не стесняйтесь обращаться ко мне за помощью.

person Matt Johnson-Pint    schedule 16.10.2015
comment
Вот и ответ. Я ценю этого, Мэтт, и я проведу выходные, экспериментируя с вашими предложениями! - person Fred Fickleberry III; 17.10.2015
comment
Не по теме: но чувствовал, что было бы уместно только сообщить вам, что ваши предложения имеют значение. Спасибо! - person Fred Fickleberry III; 06.11.2015
comment
@Matt Вы сказали: вы могли бы пофантазировать и написать что-нибудь, чтобы проверять наличие последнего файла .NZD и автоматически обновлять вашу систему. Лучше всего проверять наличие файла обновления ежедневно или, возможно, даже чаще , т.к. смещения и т. д. могут меняться ежедневно в зависимости от местных правил? - person Emilio; 13.07.2020
comment
Лучшая практика - это перегруженный термин ... лол. Конечно, вы могли бы сделать это программно. Но как минимум я бы подписался на список рассылки объявлений по адресу iana.org/time-zones чтобы вы знали, когда будут выпущены изменения. Последующие выпуски, такие как .nzd файлы Noda Time, следуют вскоре после этого. - person Matt Johnson-Pint; 13.07.2020