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

Команда работала с фреймворком Angular, ввиду этого будет он и упомянут. Утверждения правдивы и для React/Vue/... в схожих кейсах.

Небольшой голоссарий:

  • пайп (pipe) в Angular - функция приобразования в шаблоне; стоит понимать как чистую функцию

  • TZ - time zone - часовой пояс

  • DRY - don't repeat yourself

Ввиду сильного раздрая и неясности работы frontend и backend с датами и временем, были вынесены на обсуждение следующие договоренности.

Очень рекомендую к прочтению: 

  1. Заблуждения программистов относительно времени

  2. Всемирное координированное время - что такое UTC, GMT

Утверждение

Обоснование

1. Общение фронта с бэком всегда в формате iso-string с указанием часового пояса в формате UTC (Z - метка нулевого часового пояса)

- время в базе данных (должно) хранится в UTC по 0 поясу (не важно это iso-string или timestamp)

- при получении даты с часовым поясом, отличным от 0, время, сохраняемое в базу, будет преобразовано к 0 часовому поясу

- единый формат и шкала измерения данных предупреждают ошибки

- UTC = международный, признанный формат данных

- международная космическая станция использует UTC в качестве эталона времени

2. Временные метки для данных везде где возможно брать на сервере, а не со стороны клиента

- время клиента далеко не всегда совпадает со временем сервера (имеется ввиду не часовой пояс UTC)

- время клиента не всегда правдиво (неправильное время)

3. Смещение по временному поясу для местного времени должно быть доступно в виде параметра смещения

Пример: "+180" = "+03:00" = TZ Москва

- стандартный date pipe angular'а используют смещение для вывода данных

- позволяет соблюсти формат UTC при общении с сервером

Нужно уточнить формат смещения. Стандартно предполагается два варианта: 

- number ( смещение в часах "(+/-)  * 60")

- string (пример, Москва - "+03:00")

Формат типа string используется в смещении времени date pipe в виде как есть (пример: "timeVar | date: "DD:MM:YYYY HH:mm : "+03:00" => 01.01.2020 11:32; UTC - 01.01.2020 08:32).

4. На фронте оптимизировать правильный формат вывода данных в константу

Пример: "DD:MM:YYYY HH:mm"

- унификация и выполнения принципа DRY

5. Для преобразование времени на вывод использовать единый пайп

- унификация и выполнения принципа DRY

6. Использование единого формата iso-string (в UTC) по всему приложению при общении между сущностями

- единый формат и шкала измерения данных предупреждают ошибки

- перевод из iso-string в формат объекта данных просто написать самостоятельно или использовать библиотеку

- iso-string "кушают" все (обязательно нужно помнить про указание часового пояса)

Далее пример с использованием Angular (в целом будет понятен без знания)

  • Component - некая сущность с логикой (в примере - .ts файл)

  • Шаблон - html-файл, соединённый с Component

  • Интерполяция в Angular - механизм привязки переменной шаблона и логики (в примере: Component и шаблона; ссылка на определение)

  • FormControl - элемент Angular Forms; класс с методами set, get, подписки на измененные статусы и события и др.

Дано: 

  1. Компонент (Component) с шаблоном (html)

  2. Сервер (backend)

  3. formatVar = "DD.MM.YYYY HH:mm"

  4. offsetVar = время клиента "+03:00" (предположим что лежит на клиенте, может приходить с сервера в том же запросе)

Путь отображения без промежуточных звеньев:

  1. Component делает запрос на backend, приходит:

    • date: "2020-11-17T15:06:47.523Z" => переменная dateVar

  2. Component сохраняет данные в переменные

  3. В шаблоне (html) в блок (div) через интерполяцию вставляется переменная + angular date pipe

    • dateVar | date : formatVar : offsetVar (см. Дано)

    • вывод => 2020.11.17 18:06

  4. Пользователь меняет переменную dateVar на 2020.11.17 20:00

  5. Пользователь жмет "Сохранить"

  6. Component преобразует дату к формату iso-string (стоит уточнить условия на практике, т.к. на момент написания статьи были нюансы)

    • если из FormControl поступает iso-string с указанием часового пояса, то формат готов 

    • если из FormControl поступает string без часового пояса, то тут возможны варианты, но по факту все сведется к `new Date(var съедобного формата с часовым поясом).toISOstring()`

  7. Component отправляет данные на backend в формате iso-string с TZ

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


  1. maxtorchel
    19.05.2023 09:41

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


    1. DLeo13 Автор
      19.05.2023 09:41

      Предварительно напишу:

      Timestamp = time in ms (UTC) from 1970/1/1 (link). Timestamp имеет формат целых чисел, iso-string имеет формат строки. В iso-string можно передать TZ, в timestamp - нет.

      Предполагаю, что разговор о логировании действий пользователя системой. Если говорить о таблице в БД, можно предположить:

      | id | user_id | action_type | time_utc                         | time_zone       |
      -----------------------------------------------------------------------------------
      | 1  | 1       | some_action | iso_string or timestamp (one of) | timezone_offset |

      В условной функции запроса по энпоинту можно совмещать в обе стороны iso-string+TZ. В любом языке данный функционал имеет место быть.

      Если речь об запросах в БД, то возможно было бы удобно создать дополнительное поле user_time в формате timestamp. Это позволит свести расчеты запросов в единую плоскость для конкретных кейсов, но выдавать другой системе для отображения и использования придется всю строку (иначе бытовые расчеты будут невозможны). Приходилось иметь дело с api, в которой очень много неясных систем отсчета, оттуда и родилась статья по унификации.


      1. maxtorchel
        19.05.2023 09:41

        Не только логирование, я пишу программу для курьерской службы (angular+symfony), у каждой сущности может быть куча полей с датами и временем, десятки (прибытие, убытие, ожидаемое и фактическое и так далее), и всегда надо видеть это относительно того места, где посылка находится. И кстати, если ангуляру отдавать время с таймзоной то date pipe будет менять время при изменении пользователем часового пояса на своем компьютере, что тоже неприемлемо, функции игнора часового пояса у этого пайпа нет, поэтому передавать с бэка в ангуляр таймзону тоже не вариант.


        1. DLeo13 Автор
          19.05.2023 09:41

          Пробовал вторым аргументом передавать в пайп таймзону?


          1. maxtorchel
            19.05.2023 09:41

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


            1. DLeo13 Автор
              19.05.2023 09:41

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

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

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

              Могу предложложить сделать так:

              • у юзера в настройках задается тайм зона, изначально берущаяся из часов

                • в приложении будет очевидная единая тайм зона, которую можно менять при желании

                • можно переопределять TZ автоматически при заходе юзера в приложения (добавить пункт "автоматически" в настройки юзверя)

              • эта тайм зона будет использоваться для вставки в angular pipe вторым аргументом

                • скорее как бонус

              • на сервер будет отправляться время с TZ, но в базе сохраняться время UTC

                • соблюдаем единую шкалу измерений

              • при необходимости, можно сохранить изначальную TZ в качестве сдвига в соседнем столбце

                • сдвиг будет легко вычислить

              • при обмене данными FE-BE использовать iso-string, при получении на BE можно (при необходимости, например, для расчетов) использовать перевод в timestamp UTC

                • это позволит применять расчеты в фильтре запроса к БД; все данные в единой шкале изменений и не будет проблем в вычислениях

              Итого:

              1. Вычисления доступны, все данные в одной системе исчеслений

              2. Структура простая и очевидная

              3. Структура удобна для фронта и бэка, формат унифицирован


    1. me21
      19.05.2023 09:41

      Зачем сохранять штампы времени по часовому поясу клиента? Храните в UTC, он однозначно преобразовывается в другую таймзону, просто делайте преобразование перед показом клиенту.