Представим, что вы хотите преобразовать свою систему из одного состояния в другое. Начальное состояние — это когда DateTime используется везде, в C# коде и в БД. Конечное состояние — когда везде используется DateTimeOffset. Вы хотите сделать переход плавно и внести как можно меньше изменений. Это описание может быть началом очень интересной задачи с тупиком в конце.

Тип DateTime был типом .NET по умолчанию для работы с датой и временем некоторое время назад, и обычно построенная вокруг него логика была сделана так, как будто она никогда не поменяется. Если попытаться менять тип в один этап, это приведёт к каскадным изменениям практически во всех частях системы. В крайних случаях может потребоваться изменить около 200 хранимых процедур только для одного поля. Это первая проблема. И вторая проблема заключается в том, что последствия таких изменений трудно найти во время тестирования. Регрессионное тестирование не гарантирует, что вы ничего не упустите, или система будет функционировать в любых случаях. Необходимые усилия по обеспечению качества будут увеличиваться по мере работы, и у вас не будет четкого понимания, когда оно закончится.

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

  1. Добавить парное вычисляемое поле для чтения значений DateTimeOffset из БД.
  2. Сделать преобразование операций чтения.
  3. Сделать преобразование операций записи.

Такой подход поможет локализовать изменения и ограничить усилия QA. Он также обеспечит хорошую предсказуемость для оценки будущей работы. Ниже я описал этапы более подробно.

Неудачный Подход


Представьте, что существует около 150 полей, связанных со значениями даты/времени. Вы можете использовать следующий сценарий SQL, чтобы узнать полный список в вашей БД.

select
    tbl.name as 'table',
    col.name as 'column',
    tp.name as 'type',
    def.name as 'default'
from sys.columns col
    inner join sys.tables tbl on tbl.[object_id] = col.[object_id]
    inner join sys.types tp on tp.system_type_id = col.system_type_id
        and tp.name in ('datetime', 'date', 'time', 'datetime2', 'datetimeoffset', 'smalldatetime')
    left join sys.default_constraints def on def.parent_object_id = col.[object_id]
        and def.parent_column_id = col.column_id
order by tbl.name, col.name

В то время как в БД преобразование из DateTime в DateTimeOffset и обратно поддерживается на очень хорошем уровне, в C# коде это сложно из-за типизации. Вы не можете прочитать значение DateTime, если БД возвращает значение DateTimeOffset. При изменении возвращаемого типа для одного поля необходимо изменить все места, где он используется во всей системе. В некоторых случаях это просто невозможно, потому что вы можете не знать о некоторых местах, если система очень большая. Именно по этой причине подход с простым изменением типа поля не будет работать. Вы можете попробовать найти все использования определенного поля таблицы в БД, используя следующий скрипт.

SELECT
    ROUTINE_NAME,
    ROUTINE_DEFINITION
FROM INFORMATION_SCHEMA.ROUTINES
WHERE ROUTINE_DEFINITION LIKE '%table%'
    OR ROUTINE_DEFINITION LIKE '%field%'
    AND ROUTINE_TYPE='PROCEDURE'

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

Подход «Получше»


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

Создание Вычисляемого Поля


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

declare @table sysname, @column sysname, @type sysname, @default sysname

declare cols cursor for
select
    tbl.name as 'table',
    col.name as 'column',
    tp.name as 'type',
    def.name as 'default'
from sys.columns col
    inner join sys.tables tbl on tbl.[object_id] = col.[object_id]
    inner join sys.types tp on tp.system_type_id = col.system_type_id
        and tp.name in ('datetime', 'date', 'time', 'datetime2', 'smalldatetime')
    left join sys.default_constraints def on def.parent_object_id = col.[object_id]
        and def.parent_column_id = col.column_id
order by tbl.name, col.name

open cols
fetch from cols into @table, @column, @type, @default
while @@FETCH_STATUS = 0
begin
    declare @cmd nvarchar(max)
    set @cmd = 'alter table ['+@table+'] add ['+@column+'_dto] as todatetimeoffset(['+@column+'], ''+00:00'')'
    exec (@cmd)
    fetch from cols into @table, @column, @type, @default
end
close cols
deallocate cols

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

Трансформация Чтения


Операции чтения оказались наиболее сложными для преобразования из-за подхода, который используется для интеграции между клиентским кодом и БД. Значения даты/времени передаются посредством сериализации строк. DateTimeOffset имеет другой формат и не может быть прочитан по умолчанию для переменных DateTime на стороне клиента. В тоже время, операции записи просто работают. Если вы передадите значение DateTime аргументу или полю DateTimeOffset, это значение будет принято с предположением, что оно является скорректированным к UTC. Смещение времени после преобразования будет «+00:00».

Теперь можно взять какой-то раздел системы и определить точное количество хранимок, возвращающих DateTime в код клиента. Тут надо будет изменить операции чтения в коде C# для чтения значений DateTimeOffset. Также потребуется изменить сами хранимки в БД, чтобы они возвращали значения из новых вычисляемых полей. Ожидаемый результат этого шага выглядит следующим образом:

  • C# код считывает DateTimeOffset и использует этот тип везде, где возможно, чтобы возвращать значения из системы.
  • Хранимки БД используют DateTimeOffset в аргументах, и C# код передает им значение DateTimeOffset.
  • Новый тип используется внутри хранимок БД.
  • Хранимки БД возвращают значения из новых добавленных полей.

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

Трансформация Записи


Теперь надо фиксировать смещение времени в системе, отправлять его в БД и сохранять в полях. Надо взять старое поле и изменить его на вычисляемое из нового, а новое должно теперь содержать значения. Вы уже читаете их них, теперь еще и записываете значения, а старые — наоборот, только чтение. Этот подход поможет вам изолировать изменения только для определенного раздела. Ожидаемый результат выглядит следующим образом:

  • Код C# создает значения DateTimeOffset и передает их в БД
  • Новые поля теперь являются реальными полями со значениями
  • Cтарыt поля теперь вычисляемые и используются для чтения
  • Хранимки БД сохраняют значения в новые поля

В итоге вы получите систему, которая записывает и читает новый тип DateTimeOffset. Этот тип имеет встроенную поддержку смещения по времени, поэтому вам не нужно будет делать какое-либо ручное преобразование к UTC или между временными зонами, в общем случае.

Дальнейшие Шаги


Единственную рекомендацию, которую я могу дать относительно деления системы на секции для преобразования следующая: необходимо предусмотреть достаточную изолированность модулей в соответствии с используемыми хранимками. Таким образом, вы добьетесь предсказуемости усилий и сможете их оценить заранее. Несомненно, какие-то проблемы все еще могут возникнуть, но они не будут расти, как снежный ком по мере работы. Позже вы сможете избавиться от старых полей. Информацию о тайм зоне можно брать из операционной системы или настроек пользователя. Ниже я разместил информацию о совместимости двух типов в БД.

  • Изменение типа столбца из DateTime в DateTimeOffset работает с неявным преобразованием. Смещение времени будет +00:00. Если нужно указать другой часовой пояс, необходимо использовать временный столбец.
  • Форматирование значений в строку поддержано.
  • Сравнение всеми операторами поддержано.
  • SYSDATETIMEOFFSET() без риска может заменить GETDATE()
  • Любое присвоение между DateTime и DateTimeOffset работает с неявным преобразованием.

Операция T-SQL Комментарий
Конвертация DateTime в DateTimeOffset TODATETIMEOFFSET(datetime_field, '+00:00') Получите значение с добавленным смещением +00:00
Конвертация DateTimeOffset в DateTime CONVERT(DATETIME, datetimeoffset_field)
— or — SET @datetime = @datetimeoffset
Информация о смещении будет потеряна. При преобразовании смещение будет просто проигнорировано. Например, для '2017-04-05 10:02:00 +01:00' получите '2017-04-05 10:02:00'.
Текущее дата/время SWITCHOFFSET(SYSDATETIMEOFFSET(), '+00:00') Это две команды. Результатом будет точка в UTC зоне
Встроенные операции DATEPART, DATEDIFF, BETWEEN, <, >, =, etc. DATEDIFF, BETWEEN и операции сравнения учитывают временное смещение, при этом DateTime значение представляется, как значение со смещением UTC
Форматирование CONVERT(NVARCHAR, datetimeoffset_field, 103) Получите тот же результат что и для DateTime.

Очень интересно услышать истории о подобной трансформации от тех, кто уже проделывал такой в своих системах. А так же, кто и как поддерживает таймзоны в своих системах.
Поделиться с друзьями
-->

Комментарии (29)


  1. zodchiy
    10.03.2017 12:55

    Вечная проблема DateTime в связке js (angular) <-> asp.net mvc <-> mssql (UTC в datetime), это невозможность точно сказать, какое время будет отображено у пользователя на странице. Чего только не перепробовали и сами форматировали, и использовали сторонние библиотеки, в итоге остановились на DateTimeOffset — и все заработало.


    1. AliasVeter
      10.03.2017 15:15
      +4

      Для нас сработал следующий подход:
      С фронтенда отдавали объект date в UTC формате (использовали momentJs). В Mongo все даты хранили в UTC. Когда нужно было вернуть дату с сервера на клиент, отдавали ее в UTC, а на клиенте приводили к локальной таймзоне.


      1. zodchiy
        10.03.2017 15:56

        У нас так не получалось. Дата писалась в БД (своя зона) как GETUTCDATE(), вроде бы, что может случится? Но когда мы брали дату из БД как DateTime в бэкэнде (другая зона), и передавали во angularJS фронтэнде (другая зона), то получалось, что мы теряли точное время при преобразовании. А уж когда эта дата возвращалась как параметр обратно в БД (дали клиенту выбор из дат загрузки данных, как пример), то мы точно не попадали в ту дату, которая была в БД.
        momentJs не спасал, точнее он спасал, но только до фронтэнда, для отображения, дальше DateTime по мере путешествия БД > бэкэнд > фронтэнд > бэкэнд > БД менялся и не соответствовал тому-же значению в БД.
        Сделали так: из БД брали DateTimeOffset, отдавали его во фронт, там он превращался в объект с двумя полями, одно поле тот же offset (например "/Date(1487688845183)/") которое улетало обратно как параметр, а другое уже отображаемая дата.
        DateTimeOffset после всех сериализаций и десериализаций не менялся, и точно соответствовал тому, что хранилось в БД.


      1. Dywar
        10.03.2017 20:25
        +1

        Рекомендованный подход — принимайте UTC, храните UTC.
        Если форматировать не удобно, кидайте в миллисекундах (если в 1 секунду может произойти несколько действий, и нужно выяснить что было раньше).

        Отличный ресурс https://currentmillis.com/


        1. Dywar
          10.03.2017 20:31
          +1

          Еще, 50 минута а далее.

          Fundamental problems that you all know about now - and how to explain them to junior engineers - Jon skeet


      1. HKA
        17.03.2017 12:59

        В общем случае UTC — не панацея. К примеру, сотрудник, находящийся в командировке, открывая бэкенд своей компании, должен видеть события в локальном времении компании, а не в локальном своем. «Заседание начинается в 15:00, явка обязательна».


        1. AliasVeter
          19.03.2017 20:08

          Если сотрудник находится в другом часовом поясе, то он в любом случае не сможет лично присутствовать на собрании. А вот смотреть трансляцию по Skype вполне может. И логично отображать ему локальное время трансляции, а не серверное время UTC.


          1. HKA
            19.03.2017 22:18

            А через неделю? В моем проекте так и происходит, что привилегированные юзеры колесят по стране и сами создают и корректируют удаленные события — естественно, в локальном времени места события.


            1. AliasVeter
              19.03.2017 23:05

              Вот вы и сохраняете время в UTC, со смещением относительно часового пояса в котором событие было создано. Когда отдадите время на фронтенд, приводите его к локальному часовому поясу. Если локальный часовой пояс совпадает с тем, в котором событие было создано — Вы компенсируете сдвиг дат. Если нет, то клиент получит актуальное время (со смещением) в своем часовом поясе.


              1. HKA
                20.03.2017 08:41

                Вы не поняли. Клиент — админ, он сам создает событие, скажем, в 18:00 локального времени места события, т.к. это публичное время (проще говоря, расклеено на афишах). Он оперирует только этим временем вне зависимости от его текущего местоположения.


  1. indestructable
    10.03.2017 22:15
    +1

    Самый надежный и хороший подход — это хранить DateTimeOffset, и работать с ним как на сервере, так и на клиенте. Использовать свой формат сериализации (возможно, с фоллбэком в часовую зону по умолчанию, для старых клиентов).


    Все остальное, включая хранение UTC, это полумера. UTC решает проблему сравнения дат, но не всегда помогает правильно их отображать.


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


    1. Dywar
      10.03.2017 22:53

      UTC норм, если придерживаться его на входе и на выходе. Задача правильного отображения передается клиенту, он сам должен знать где он находится, в Канаде, Африке или Австралии, и какое там смещение.

      Выбор между типами DateTime, DateTimeOffset, TimeSpan и TimeZoneInfo

      Структура DateTime подходит для приложений, которые:

      работают только с датами и временем в формате UTC;


      Тип DateTimeOffset включает все функциональные возможности типа DateTime, а также сведения о часовом поясе.


      На правах личных предпочтений.


      1. indestructable
        11.03.2017 00:04

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


        Но иногда возникают задачи, когда требуется узнать дату и время в системе координат пользователя.


        Например, иногда важна именно выбранная дата (без времени), и она может отличатся из-за разницы в часовых поясах. Здесь UTC не поможет, из него нельзя восстановить реально выбранное пользователем значение (без дополнительной информации о часовом поясе, скажем, из профиля).


      1. ETman
        11.03.2017 00:05

        Есть несколько случаев, в которых UTC не поможет, даже если все четко с его сохранением и выводом.

        1) Если вы сохраняете информацию о будущем событии, которое должно произойти именно в 10 утра в Апреле через год во Владивостоке. Сервера все в Лондоне. В течение года правительство решает не переходить на летнее время. В этом случае поможет сохранение в локальном времени.

        2) При переходах от зимнего к летнему и обратно у вас может теряться или дублироваться час в UTC. В этом случае помогает наличие информации о смещении времени.

        Вообще, чтобы описать конкретный момент во времени необходимо три величины: дата/время + оффсет + название тайм зоны. Если меньше информации, то время приблизительное, как ни крути. Но для бизнес задач может быть достаточным только UTC, бесспортно. Поэтому необходимо смотреть конкретный случай.


        1. radium
          11.03.2017 23:22
          +1

          у вас может теряться или дублироваться час в UTC
          Такие скачки происходят как раз для локального времени, так как оно определено как смещение от времени UTC. Реальная проблема заключается в том, что не все правила перевода сохраняются в базах времени. Бывают ситуации, когда перевод из UTC в локальное время может быть выполнен неправильно для достаточно старых дат (несколько лет назад), что может искажать, например, отчёты.

          Вообще, чтобы описать конкретный момент во времени необходимо три величины: дата/время + оффсет + название тайм зоны.
          По первым двум параметрам вопросов нет, а зачем название тайм зоны?

          Если углубится, то при высоких требованиях к расчётам разницы между двух дат, в этот список можно добавить и количество добавленных високосных секунд — на данный момент 27.


          1. ETman
            11.03.2017 23:59

            Да, относительно UTC вы правы. Я запутался.

            На счет названия таймзоны. По моим расчетам она нужна именно, чтобы получить эти карты. Офсет в большинстве случаев не нужен, т.к. можно получать локальное из UTC + TimeZoneInfo. Но без офсета будет проблема с переводом в локальное время тех моментов, которые произошли в течение этого DST перехода. Как то так.


        1. Bronx
          12.03.2017 10:39

          информацию о будущем событии, которое должно произойти именно в 10 утра в Апреле через год во Владивостоке.

          Эта информация — не дата, и не интервал, поэтому ни DateTime, ни DateTimeOffset не являются правильными типами для планируемых событий.

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

          Во-вторых, «через год» — это плохо определённое понятие. Например, если сегодня 28 февраля, то что такое «через год» — 27 февраля или 1 марта? То же самое с понятием «через месяц».

          Так что, в вашем сценарии нужно хранить локацию (координаты или код), смещение времени от начала локальных суток, а та часть, что касается будущей даты, должна храниться в виде параметров для алгоритма вычисления этой даты, и их множество не сводится к абсолютной дате или к смещению. И это ещё не вспомнили про рекуррентные события.


          1. ETman
            12.03.2017 12:32

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

            В своем примере я имел ввиду, что можно хранить только название таймзоны и date/time (локальный).


            1. Bronx
              12.03.2017 13:51

              Населённый пункт может переехать из одной таймзоны в другую, если границы зоны изменятся.


  1. dotnetdonik
    11.03.2017 10:45

    Рекомендую всегда брать datetimeoffset. В дотнете много неожиданностей можно отловить с datetime. Например приведение при десериализации. Причина в работе форматеров- если клиент пришлёт datetimeoffset, а параметр типа datetime, время преобразуется с учётом серверной таймзоны. В принципе не понимаю в какой ситуации это может быть ожидаемым поведением.


    1. HKA
      17.03.2017 12:58
      +1

      При сериализации DateTime неожиданностей не будет, если следовать требованию выставлять Kind в DateTimeKind.Utc.


  1. Razbezhkin
    12.03.2017 08:25

    Как определять часовой пояс пользователя? По заголовкам браузера, или просить пользователя указывать его в профиле? Что лучше? Если передавать UTC клиенту и там преобразовывать к локальному времени, то как: javascript или есть другие варианты? А если на сервере формировать, то без знания часового пояса не обойтись…


    1. Bronx
      12.03.2017 11:00

      Хранить в локальном времени можно лишь если выполняются всё нижеперечисленное:
      * единственным и исключительным потребителем информации о времени является тот, кто эту информацию произвёл
      * потребителю не нужна однозначность времени.

      Т.е., фактически, единственный приемлемый сценарий для хранения локального времени — это хранение времени как строки, в точности как она была введена, без обработки, без использования в расчётах, без передачи этой информации другим клиентам. Только чтобы приватно посмотреть на экране или распечатать. Как только начинаются расчёты, появляются несколько пользователей (сервер, другие клиенты) — немедленно переходить к однозначному представлению в хранилище (т.е. UTC) и к конвертации в локальное время на клиентах (они свои часовые пояса знают лучше, чем сервер).


      1. ETman
        12.03.2017 12:39

        При расчете будущих событий UTC не поможет, т.к. события, обычно, должны происходить в какое-то точное локальное время.
        Скажем, как бы вы решали такую проблему наименьшими усилиями: надо расчитать даты запуска джоба в 10 утра по каждой из нескольких локаций, которые находятся в разных часовых поясах?
        По моим расчетам, можно и через UTC: расчитываем последовательность, переводим в локальную, чтобы проверить 10 часов это или нет, сохраняем обратно в UTC.
        А пожно просто забить как 10 утра локально и скедулер уже будет вызывать, без конвертаций и расчетов.

        В чем видите тут проблему?


        1. Bronx
          12.03.2017 14:24

          Задание, я так понимаю, выполняется на лондонском сервере, и должно отработать в 10 часов владивостокского времени, даже если Владик сейчас оффлайн и результатов не получит? Потому что если Владик онлайн, то у него может работать свой планировщик, который ровно в 10 местного времени будет делать запрос в Лондон и инициировать задачу на центральном сервере. Если Владик оффлайн, то зачем вообще нагружать сервер задачей, результатов которой тот в ближайшее время, возможно, не увидит?

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


    1. ETman
      12.03.2017 12:41

      Как определять таймзону не является большой проблемой. Проблема — сохранение этой информации в цикле разных расчетов и чтений-записи данных. Если эта информация всегда будет с date/time, то проблем почти не будет.


  1. hVostt
    12.03.2017 12:44
    +1

    Только DateTimeOffset! Без вариантов. Никаких UTC, — это костыль.


    1. sand14
      20.03.2017 07:57

      дубль.


  1. sand14
    20.03.2017 07:57

    Нужно хранить именно DateTimeOffset, т.к. при приведении к UTC теряется информация, какое локальное время и смещение были в момент регистрации события.


    А эта информация может быть важна — выше привели много примеров.
    И кстати, если на машине, где регистрировалось событие, часовой пояс был выставлен неверно, то полная исходная информация поможет выявить ошибку.


    Кроме того, зачем вообще терять часть информации на входе, если к UTC всегда можно привести позже, если, к примеру, в отчете нужно показывать время в UTC.


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