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



За те уже несколько лет, что я пишу на Ruby on Rails, мне не приходилось сталкиваться с подобными проблемами — до этого все мои приложения работали в одном часовом поясе. А тут неожиданно пришлось немало попотеть, отлавливая самые разные ошибки и пытаясь выяснить, как же работать с датой и временем так, чтобы их в дальнейшем избежать.

В результате, сегодня мне есть, чем с вами поделиться. Если вы регулярно встречаетесь с тем, что время сохраняется или отображается некорректно с характерным разбросом в несколько часов (3 часа для Москвы), какие-то ночные записи перекочёвывают на соседние дни, а время упорно отображается не так, как хотят пользователи, и вы не знаете, что со всем этим делать — добро пожаловать под кат.

Итак, первое и самое важное — что есть время, которым мы оперируем в повседневности и из чего оно состоит?
В обычной жизни мы оперируем некоторым локальным временем, которое действует там, где мы живём, однако, в компьютерных системах с ним работать сложно и опасно — из-за перевода часов (летнее время, госдума и т.п.) оно неравномерно и неоднозначно (подробнее об этом позже). Поэтому требуется некоторое универсальное время, которое обладает равномерностью и однозначностью (тут в статью врывается високосная секунда и всё портит, но о ней мы говорить не будем), одно значение которого отображает один и тот же момент времени в любой точке Земли (физики, молчать!) — единая точка отсчёта, её роль исполняет UTC — всемирное координированное время. А ещё нам потребуются часовые пояса (часовые зоны в современной терминологии), чтобы конвертировать локальное время в универсальное и наоборот.

А что же такое вообще часовой пояс?

Во-первых, это смещение от UTC. То есть на какое количество часов и минут наше локальное время отличается от UTC. Заметьте, что это не обязательно должно быть целое число часов. Так, Индия, Непал, Иран, Новая Зеландия, части Канады и Австралии и многие другие живут с отличием от UTC в X часов 30 минут или X часов 45 минут. Более того, в некоторые моменты на Земле действуют аж три даты — вчера, сегодня и завтра, так как разница между крайними часовыми поясами — 26 часов.

Во-вторых, это правила перехода на летнее время. Среди стран, имеющих часовые пояса с одинаковым смещением, некоторые не переходят на летнее время совсем, некоторые переходят в одних числах, другие — в других. Некоторые летом, некоторые зимой (да, у нас есть южное полушарие). Некоторые страны (в том числе Россия) переходили на летнее время раньше, но мудро отказались от этой идеи. И для правильного отображения даты и времени в прошлом это всё нужно учитывать. Важно помнить, что при переходе на летнее время меняется именно смещение (было в Москве раньше +3 часа зимой, становилось +4 летом).

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

В Windows вроде бы используется какая-то своя база, а практически во всём опенсурсном мире стандарт де-факто — это база данных часовых поясов IANA Time Zone Database, более известная как tzdata. В ней хранится история всех часовых поясов с начала эпохи Unix, то есть с 1-го января 1970-го года: какие часовые пояса когда появлялись, какие когда исчезали (и в какие они вливались), где и когда переходили на летнее время, как по нему жили и когда его отменяли. Каждый часовой пояс обозначается как Регион/Место, например, московский часовой пояс называется Europe/Moscow. Tzdata используется в GNU/Linux, Java, Ruby (гем tzinfo), PostgreSQL, MySQL и ещё много где.

В Ruby on Rails для работы с часовыми поясами предназначен класс ActiveSupport::TimeZone, поставляемый в составе библиотеки ActiveSupport из стандартной поставки Ruby on Rails. Он представляет собой обёртку вокруг гема tzinfo, который, в свою очередь, предоставляет ruby-интерфейс к tzdata. Он предоставляет методы для работы со временем, а также активно используется в расширенном ActiveSupport'ом классом Time из стандартной библиотеки Ruby для полноценной работы с часовыми поясами. Ну и в классе ActiveSupport::TimeWithZone из Ruby on Rails, который хранят в себе не только время со смещением, но и сам часовой пояс. Многие методы у часового пояса возвращают именно объекты ActiveSupport::TimeWithZone, но в большинстве случаев вы этого даже не почувствуете. В чём же состоит разница между этими двумя классами, написано в документации, и эту разницу полезно знать.

Из недостатков ActiveSupport::TimeZone можно отметить то, что он использует свои собственные, «типа человекочитаемые» идентификаторы для часовых поясов, что иногда создаёт неудобства, а также то, что эти идентификаторы есть не для всех часовых поясов, имеющихся в tzdata, но и это поправимо.

Каждый «рельсовик» уже сталкивался с этим классом, устанавливая часовой пояс в файле config/application.rb после создания нового приложения:

config.time_zone = 'Moscow'

В приложении можно получить доступ к этому часовому поясу c помощью метода zone у класса Time.

Здесь уже видно, что используется идентификатор Moscow вместо Europe/Moscow, но если посмотреть в вывод метода inspect у объекта часового пояса, то мы увидим, что внутри есть отображение на идентификатор tzdata:

 > Time.zone
=> #<ActiveSupport::TimeZone:0x007f95aaf01aa8 @name="Moscow", @tzinfo=#<TZInfo::TimezoneProxy: Europe/Moscow>>

Итак, самыми интересными методами для нас будут (все возвращают объекты типа ActiveSupport::TimeWithZone):

  • Метод now, который возвращает текущее время в данном часовом поясе.

    Time.zone.now # => Sun, 16 Aug 2015 22:47:28 MSK +03:00

  • Метод parse, который, так же как и метод parse у класса Time, распарсит строку со временем в объект класса Time, но заодно сразу переведёт его в часовой пояс этого объекта. Если же в строке не будет указано смещение от UTC, то заодно этот метод решит, что в строке указано локальное время этого часового пояса.

    ActiveSupport::TimeZone['Novosibirsk'].parse('2015-06-19T12:13:14') # => Fri, 19 Jun 2015 12:13:14 NOVT +06:00

  • Метод at сконвертирует Unix timestamp (количество секунд с 1 января 1970 г.), который, как известно, всегда в UTC, в объект типа Time в данном часовом поясе.

    Time.zone.at(1234567890) #=> Sat, 14 Feb 2009 02:31:30 MSK +03:00

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

    ActiveSupport::TimeZone['Yakutsk'].local(2015, 6, 19, 12, 13, 14) # => Fri, 19 Jun 2015 12:13:14 YAKT +09:00

Класс ActiveSupport::TimeZone также активно используется при операциях с объектами класса Time и добавляет в него несколько полезных методов, например:

  • Метод класса Time.zone вернёт объект класса ActiveSupport::TimeZone, представляющий часовой пояс, который в данный момент действует во всём приложении (и его можно менять).

  • А метод класса Time.zone_default вернёт тот часовой пояс, который вы указали в файле config/application.rb.

  • Метод with_zone позволяет временно поменять текущий часовой пояс для всего кода, выполняющегося в переданном ему блоке.

  • Ну а метод объекта Time#in_time_zone позволяет менять часовой пояс у уже имеющегося объекта (вернёт объект типа ActiveSupport::TimeWithZone):

    Time.parse('2015-06-19T12:50:00').in_time_zone('Asia/Tokyo') # => Fri, 19 Jun 2015 18:50:00 JST +09:00

Важно! Есть два различных набора методов, возвращающих «сейчас» — Time.current вместе с Date.current и Time.now вместе с Date.today. Разница между ними в том, что первые (те, что current) возвращают время или дату в часовом поясе приложения, как объект типа ActiveSupport::TimeWithZone, в том самом поясе, который в данный момент возвращает метод Time.zone и добавляет эти методы Ruby on Rails, а вторые возвращают время в часовом поясе, внимание, операционной системы сервера и идут в стандартной библиотеке Ruby (возвращают, соответственно, просто Time). Будьте осторожны — возможны странные баги, невоспроизводимые локально, поэтому всегда используйте Time.current и Date.current.

Итак, зная это всё, мы уже можем добавить поддержку часовых поясов в любое приложение:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base

  around_action :with_time_zone, if: 'current_user.try(:time_zone)'

  protected

  def with_time_zone(&block)
    time_zone = current_user.time_zone
    logger.debug "Используется часовой пояс пользователя: #{time_zone}"
    Time.use_zone(time_zone, &block)
  end

end

В данном примере у нас есть модель User с неким методом time_zone, возвращающим объект ActiveSupport::TimeZone с часовым поясом пользователя.

Если этот метод возвращает не nil, то используя колбэк around_action мы вызываем метод класса Time.use_zone и в переданном ему блоке продолжаем обработку запроса. Таким образом все времена во всех вьюхах будут автоматически отображены в часовом поясе пользователя. Вуаля!

В базе данных храним идентификатор tzdata, а для его преобразовывания в объект служит вот такой метод в файле app/models/user.rb:

# Инициализирует объект класса +ActiveSupport::TimeZone+ для работы с
# часовым поясом, хранящимся в БД как идентификатор TZ database.
def time_zone
  unless @time_zone
    tz_id = read_attribute(:time_zone)
    as_name = ActiveSupport::TimeZone::MAPPING.select do |_,v|
      v == tz_id
    end.sort_by do |k,v|
      v.ends_with?(k) ? 0 : 1
    end.first.try(:first)
    value = as_name || tz_id
    @time_zone = value && ActiveSupport::TimeZone[value]
  end
  @time_zone
end

Причём это специально усложнённый мной метод, который преобразует хранящийся в базе идентификатор tzdata вида Europe/Moscow в объект ActiveSupport::TimeZone, идентификатор у которого просто Moscow. Причина того, что я храню в базе id часового пояса из tzdata, а не рельсовый, кроется в интероперабельности — id из tzdata понимают все, а id часового пояса рельсы — только Ruby on Rails.

А так выглядит парный ему метод-сеттер часового пояса, сохраняющий идентификатор tzdata в базу. На вход он может принимать как объект класса ActiveSupport::TimeZone, так и любой из идентификаторов.

# Сохраняет в базу данных идентификатор часового пояса из TZ Database,
# у объекта устанавливает часовой пояс — объект +ActiveSupport::TimeZone+
def time_zone=(value)
  tz_id   = value.respond_to?(:tzinfo) && value.tzinfo.name || nil
  tz_id ||= TZInfo.Timezone.get(ActiveSupport::TimeZone::MAPPING[value.to_s] || value.to_s).identifier rescue nil # Неизвестный идентификатор — игнорируем
  @time_zone = tz_id && ActiveSupport::TimeZone[ActiveSupport::TimeZone::MAPPING.key(tz_id) || tz_id]
  write_attribute(:time_zone, tz_id)
end

Основная причина, почему я предпочитаю сохранять идентификатор tzdata в базу — используемый нами PostgreSQL хорошо работает с часовыми поясами. Имея в базе идентификатор tzdata, можно довольно удобно смотреть локальное время в часовом поясе пользователя и дебажить разные проблемы с часовыми поясами с помощью запросов вида:

SELECT '2015-06-19T12:13:14Z' AT TIME ZONE 'Europe/Moscow';

Одна особенность PostgreSQL, про которую важно помнить – это то, что типы данных, оканчивающиеся на with time zone, не хранят в себе информацию о часовом поясе, а только преобразуют вставляемые в них значение в UTC для хранения и обратно в локальное время для отображения. Ruby on Rails в миграциях создаёт колонки с типом timestamp without time zone, которые хранят время так, как в них запишешь.

Ruby on Rails по умолчанию при подключении к базе устанавливает часовой пояс в UTC. То есть при любой работе с базой вся работа с временем производится в UTC. Значения во все колонки также записываются строго в UTC, поэтому, например, при выборке записей за определённый день нужно всегда про это помнить и передавать в SQL-запросы не просто даты, которые СУБД преобразует в полночь по UTC, а отметки времени, хранящие полночь в нужном часовом поясе. И тогда никакие записи у вас на соседнюю дату не уедут.

Следующий запрос не вернёт записи за первые три часа суток для приложения, заточенного под Московское время (UTC+3, все дела):

News.where('published_at >= ? AND published_at <= ?', Date.today, Date.tomorrow)

Необходимо прямо указать момент времени в нужном часовом поясе, чтобы ActiveRecord его правильно сконвертировал:

News.where('published_at >= ? AND published_at < ?', Time.current.beginning_of_day, Time.current.beginning_of_day + 1.day)
# => News Load (0.8ms)  SELECT "news".* FROM "news" WHERE (published_at >= '2015-08-16 21:00:00.000000' AND published_at < '2015-08-17 21:00:00.000000') ORDER BY "news"."published_at" DESC

Сериализация и передача даты и времени


Перед вами «грабли», больно стукнувшие меня по лбу не так давно. В коде приложения у нас было место, где время генерировалось на клиенте конструированием нового джаваскриптового объекта Date и неявным приведением его в строку. В таком виде оно и передавалось на сервер. Так обнаружился баг в методе parse класса Time из стандартной библиотеки Ruby, в результате которого время в Новосибирском часовом поясе парсится неправильно — дата оказывалась в ноябре почти всегда:

Time.parse('Mon May 18 2015 22:16:38 GMT+0600 (NOVT)') # => 2015-11-01 22:16:38 +0600

Самое главное, что мы не могли обнаружить этот баг до тех пор, пока приложением не воспользовался первый клиент, у которого в настройках ОС стоял новосибирский часовой пояс. По доброй традиции этим клиентом оказался заказчик. Разрабатывая в Москве, вы никогда не обнаружите этот баг!

Отсюда следует совет: установите на вашем CI-сервере часовой пояс, отличный от того, который используют разработчики. Мы открыли это свойство случайно, поскольку наш CI-сервер был в UTC по умолчанию, а у всех разработчиков локально установлен московский. Таким образом мы поймали несколько ранее не проявлявших себя багов, поскольку браузер на CI-сервере запускался с часовым поясом, отличным от часового пояса рельсового приложения по умолчанию (и часового пояса тестовых пользователей).

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

Пример такого машиночитаемого формата — ISO 8601. К примеру, это рекомендуемый формат для передачи времени и даты при сериализации в JSON согласно Google JSON Style Guide.

Время из примера будет выглядеть в нём вот так: 2015-05-18T22:16:38+06:00.

На клиенте, если у вас moment.js, то вам нужен метод toISOString(). А, например, Angular.js сериализует время в ISO 8601 по умолчанию (и правильно делает!).

По моему скромному мнению, весьма желательно сразу ожидать время в таком формате и попытаться его парсить соответствующим методом класса Time, а метод parse оставить для обратной совместимости. Вот так:

Time.iso8601(params[:till]) rescue Time.parse(params[:till])

А если обратной совместимости не нужно, то я бы просто ловил эксепшен и возвращал код ошибки 400 Bad Request с сообщением «у вас параметр кривой и вообще вы — злой буратино».

Однако предыдущий метод всё равно подвержен ошибкам — в случае, если в params[:till] будет передано время без смещения от UTC, оба метода (и iso8601 и parse) разберут его так, как будто это локальное время в часовом поясе сервера, а не приложения. Вот вы знаете, в каком часовом поясе у вас сервер? У меня в разных. Более пуленепробиваемый метод парсинга времени будет выглядеть вот так (к сожалению у ActiveSupport::TimeZone нет метода iso8601, а жаль):

Time.strptime(params[:till], "%Y-%m-%dT%H:%M:%S%z").in_time_zone rescue Time.zone.parse(params[:till])

Но и тут есть место, где всё может рухнуть — посмотрите на код внимательно и читайте дальше!

Когда вы передаёте локальное время между системами (или храните где-то), обязательно передавайте его вместе со смещением от UTC! Дело в том, что локальное время само по себе (даже с часовым поясом!) в некоторых ситуациях неоднозначно. Например при переводе времени с летнего на зимнее один и тот же час повторяется дважды, один раз с одним смещением, другой раз с другим. Прошлой осенью в Москве один и тот же час ночи сначала прошёл со смещением +4 часа, а потом прошёл ещё раз, но уже со смещением +3. Как видите, каждому из этих часов соответствуют разные часы в UTC. При обратном переводе один час вообще не случается. Локальное время с указанным смещением от UTC всегда является однозначным. В том случае, если вы «напоретесь» на такой момент времени и смещения у вас не будет, то Time.parse просто вернёт вам более ранний момент времени, а Time.zone.parse выбросит исключение TZInfo::AmbiguousTime.

Вот вам иллюстрирующие примеры:

Time.zone.parse("2014-10-26T01:00:00")
# TZInfo::AmbiguousTime: 2014-10-26 01:00:00 is an ambiguous local time.

Time.zone.parse("2014-10-26T01:00:00+04:00")
# => Sun, 26 Oct 2014 01:00:00 MSK +04:00

Time.zone.parse("2014-10-26T01:00:00+03:00")
# => Sun, 26 Oct 2014 01:00:00 MSK +03:00

Time.zone.parse("2014-10-26T01:00:00+04:00").utc
# => 2014-10-25 21:00:00 UTC

Time.zone.parse("2014-10-26T01:00:00+03:00").utc
# => 2014-10-25 22:00:00 UTC

Различные полезные трюки


Если добавить немного Monkey-патчинга, то можно научить timezone_select отображать русские часовые пояса первыми или даже единственными. В будущем можно будет обойтись без этого — я отправил Pull Request в Ruby on Rails, но пока он, к сожалению, висит без активности: https://github.com/rails/rails/pull/20625

# config/initializers/timezones.rb
class ActiveSupport::TimeZone
  @country_zones  = ThreadSafe::Cache.new

  def self.country_zones(country_code)
    code = country_code.to_s.upcase
    @country_zones[code] ||=
      TZInfo::Country.get(code).zone_identifiers.select do |tz_id|
        MAPPING.key(tz_id)
      end.map do |tz_id|
        self[MAPPING.key(tz_id)]
      end
  end
end

# Где-то в app/views
= f.input :time_zone, priority: ActiveSupport::TimeZone.country_zones(:ru)

Может так оказаться, что часовых поясов «из коробки» вам может не хватить. Например, российские часовые пояса есть далеко не все, но хотя бы есть по одному с каждым отдельным смещением от UTC. Простой вставкой во внутренний хэш ActiveSupport и добавкой переводов к гему i18n-timezones этого можно добиться. Не пытайтесь отправить pull request в Ruby on Rails — они его не примут с формулировкой «мы тут не энциклопедия часовых поясов» (я проверял). https://gist.github.com/Envek/cda8a367764dc2cacbc0

# config/initializers/timezones.rb
ActiveSupport::TimeZone::MAPPING['Simferopol']   = 'Europe/Simferopol'
ActiveSupport::TimeZone::MAPPING['Omsk']         = 'Asia/Omsk'
ActiveSupport::TimeZone::MAPPING['Novokuznetsk'] = 'Asia/Novokuznetsk'
ActiveSupport::TimeZone::MAPPING['Chita']        = 'Asia/Chita'
ActiveSupport::TimeZone::MAPPING['Khandyga']     = 'Asia/Khandyga'
ActiveSupport::TimeZone::MAPPING['Sakhalin']     = 'Asia/Sakhalin'
ActiveSupport::TimeZone::MAPPING['Ust-Nera']     = 'Asia/Ust-Nera'
ActiveSupport::TimeZone::MAPPING['Anadyr']       = 'Asia/Anadyr'
# config/locales/ru.yml
ru:
  timezones:
    Simferopol:   Республика Крым и Севастополь
    Omsk:         Омск
    Novokuznetsk: Новокузнецк
    Chita:        Чита
    Khandyga:     Хандыга
    Sakhalin:     Сахалин
    Ust-Nera:     Усть-Нера
    Anadyr:       Анадырь

Javascript?


Какое же современное веб-приложение без богатого фронтенда? Поумерьте пыл — тут не всё так гладко! В чистом джаваскрипте вы можете разве что получить смещение от UTC, которое сейчас действует в ОС пользователя — и это всё. Поэтому все практически обречены использовать библиотеку moment.js вместе с её дополняющей библиотекой moment timezone, которая тащит tzdata прямо в браузер пользователю (да, пользователям опять придётся качать лишние килобайты). Но, тем не менее, с помощью неё вы можете всё. Ну или почти всё.

Примеры использования, которые вам совершенно точно понадобятся:

В случае, если у вас уже есть правильная и хорошая метка времени в формате ISO8601, то просто скормите её методу parseZone самого Момента:

moment.parseZone(ISO8601Timestamp)

Если же у вас есть метка времени в локальном часовом поясе, то Moment Timezone нужно сообщить, в каком она часовом поясе, тогда разбор осуществляется так:

moment.tz(timestamp, formatString, timezoneIdentifier)

Если везде в приложении вы разбираете время этими методами (забудьте про new Date()!), то всё у вас будет хорошо и про «скачущее время» вы вскоре забудете и жить станет гораздо спокойнее.

Для уж совсем богатого фронтенда на основе модных фреймворков смотрите отдельные библиотеки для них. Например, мы используем angular-moment, который позволяет динамически задавать часовой пояс для всего приложения и автоматически отображать всё время на странице в этом часовом поясе с помощью специальных директив. Если вы используете ангуляр — дичайше рекомендую.

Резюме


Общие рекомендации, работающие в 90% случаев, таковы:

  • Храните и передавайте время о прошедших и происходящих прямо сейчас (т. е. регистрируемых) событиях в UTC.
  • Со временем в будущем всё несколько сложнее. Решайте, что вам важнее, чтобы в случае непредвиденных изменений часовых поясов поехало локальное время или же время в UTC.
  • В идеале нужно хранить тройку значений: локальное время, время в UTC и идентификатор часового пояса. В таком случае вы сможете обнаружить, что какое-то время «поехало» заранее и предпринять какие-либо меры.
  • Если вы хотите ещё иметь возможность поймать появление новых часовых зон, то тогда можно сохранять географические координаты пользователя.
  • По этой же причине, что часовые пояса меняются со временем, а так же из-за наличия летнего времени, крайне важно хранить именно идентификатор часового пояса, а не просто смещение.
  • Но если часовой пояс вы не знаете, то храните смещение — это лучше, чем ничего.
  • Времени с клиента лучше не верить, ведь оно может быть неправильным — случайно ли, а может и намеренно изменённым, часовой пояс или смещение от UTC тоже могут быть совершенно произвольными.
  • Ну и последнее, но важное — на ваших серверах всегда держите настроенный NTP и самую последнюю версию пакета tzdata (помните, что некоторый софт таскает с собой собственную копию tzdata).

Если кому-то этой информации мало, прочитайте полезную статью на Хабрахабре за авторством Владимира Рудных из Mail.ru — там рассказано гораздо больше про различные нюансы работы с часовыми поясами и временем вообще, особенно если оно в будущем: http://habrahabr.ru/company/mailru/blog/242645/

Ещё есть интересное просветительское видео от Тома Скотта, в котором он рассказывает о том, откуда и как появились все эти проблемы с часовыми поясами, гораздо понятнее и интереснее, чем я, но на английском:



Ну, и разумеется документация! Она — ваш главный друг и из неё можно почерпнуть многое, что осталось за рамками этой статьи:

P.S> Данная статья сделана по мотивам моего выступления на DevConf 2015. Со слайдами вы можете ознакомиться здесь, а видео выложено здесь отличными ребятами из RailsClub. Кстати, мы в этом году снова спонсоры конференции RailsClub — до скорой встречи там!

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


  1. estum
    11.09.2015 15:49
    +3

    Зачем все эти сложности, когда проще на бэкенде хранить все в UTC, а на клиенте уже вычислять с учетом локального часового пояса?


    1. Envek
      11.09.2015 17:02
      +1

      Так и стараемся работать по возможности в UTC, но полностью работать в UTC не получается даже тогда, когда бэкенд и фронтенд раздельные (т.е. API и клиент) — всё равно нужно знать, когда же именно у данного конкретного пользователя полночь, просто чтобы отделить завтрашние котлеты от сегодняшних мух.
      Когда же речь идёт о классическом сайте, который рендерит готовый html на сервере — это всё нужно. И пользователь в datetimepicker'е угадайте какое время вводит? Своё, родное…


      1. avdept
        11.09.2015 18:08
        -5

        На самом деле это выглядит как костыль, который лень сделать нормально.


      1. estum
        11.09.2015 19:14

        Я в основном про смену зоны в around_action. У нас если и вычисляется для конкректного юзера то только в фоновых задачах и при постановке в расписание, и то, но результат всегда в UTC.

        В чем проблема на клиенте переводить данные из дэйтпикера в UTC при отправке и обратно в локальную зону при получении?


        1. Extrapolator
          12.09.2015 11:00

          Присоединяюсь к комментарию.
          В чем проблема работать с таймзонами только на фронтэнде?
          На бэкенде проще только с UTC работать, единственное, что нужно — сохранить таймзону в аттрибуте юзера, и потом при рендеринге добавить что-нибудь типа

          <body data-user-timezone="<%= {name: current_user.timezone.name, offset: current_user.timezone.utc_offset}.as_json %>">...<body>
          

          Потом в JS эту таймзону прочитать — и уже с ней работать.


          1. Envek
            12.09.2015 16:12

            Мы примерно так и работаем — идентификатор таймзоны прилетает при логине пользователя и мы его используем в javascript'е. И всё время отображается в этом часовом поясе. Метки времени передаются между фронтендом и бекендом в ISO8601 и на самом деле не важно, передаются ли они в UTC (с суффиксом Z) или в локальном времени (с суффиксом +0300, например), главное, чтобы смещение от UTC было и всё будет работать.

            Но и бэкенд у нас не глупый — у нас хватает различных валидаций и генераций, завязанных на часовой пояс пользователя. Что-нибудь в стиле «проверить, что изменённый пользователем номер заявки за день не дублируется». А день начинается не в полночь. Ну то есть это и не день совсем, а вообще рабочая смена. И начинается: получи таймстампы начала и конца той смены, спроси у базы, нет ли заявки с таким номером в этот промежуток. Аналогично нужно и выдавать новые номера заявкам. И не только за день, но ещё и за год. А год тоже начинается не в полночь, и уж тем более не по UTC. Не получается только с UTC работать, ну никак.


          1. Dreadatour
            12.09.2015 19:28

            Проблема возникает в тот самый момент, когда нужно полноценно работать во временем, в самом простом случае и в 99% приложений все эти сложности не нужны. Если нужны прям конкретные примеры — то один из сценариев описан тут: habrahabr.ru/company/mailru/blog/242645 (Cmf+F «Работаем со временем»), остальные — в статье по ссылке.


        1. Envek
          12.09.2015 16:19

          Вы говорите про API-приложение + клиент. Для них around_action не так актуален (хотя дебажить проще, когда таймстампы в локальном времени, а не в UTC).

          А вот для «классического» приложения, в котором весь HTML генерённый на сервере и логики в JS нет, around_action — то, что доктор прописал. Всё сразу начинает работать как надо. Да и дэйтпикеры ещё допиливать надо, чтоб работали, как надо.

          Я нашёл у around_action только один минус — мешается в stacktrace'ах — часто показывается, что ошибка в `block in the around_action`, а до настоящего виновника эксепшена, надо глазами ещё добежать.


          1. estum
            12.09.2015 18:01

            Нет, не только API. На веб морде нпример у нас moment.js подключенный давным-давно минут за 10.

            Сейчас вообще без логики на JS довольно уныло, нафиг так жить?)


  1. vintage
    12.09.2015 11:39

    Как насчёт портировать на руби $jin.time с добавлением таймзон?


    1. Envek
      12.09.2015 16:27

      В Ruby on Rails (т.е. в ActiveSupport) с временами и таймзонами всё и так достаточно хорошо. Почему это не портируют в стандартную библиотеку Ruby, в которой всё плохо — вопрос открытый.


      1. vintage
        13.09.2015 01:16

        Да вот судя по тому, что я вижу — не очень хорошо:
        1. Внутри время хранится как таймштамп, а не покомпонентно.
        2. Избыточный, неконсистентный апи.
        3. Нет поддержки диапазонов и продолжительностей.
        4. Мутабельные объекты.
        5. Не мнемоничные паттерны вида "%Y-%m-%d %H:%i"

        Вы статью-то читали? :-)


        1. Envek
          13.09.2015 23:49

          Конечно не читал, ведь чукча — писатель :-)

          Пункты 1, 2, 4, 5 — это всё «обратная совместимость» и прочие корни, уходящие в Perl и C. Вылечат ли это когда-нибудь в стандартной библиотеке — неизвестно.
          1. Разве для времени это критично, покомпонентно оно хранится или одним числом? Какие тут возможны «подводные камни?
          3:
          Для диапазонов используется класс Range (ему вообще пофиг, что у него на концах — числа, даты или таймстампы, пока у них есть методы сравнения и метод succ) — для наших задач пока хватает, но ограничения есть, про них мы в курсе.
          Для продолжительностей есть ActiveSupport::Duration (это вот эта магия типа Date.current + 2.years), к 4-й версии рельс его уже сделали правильно, но у меня есть для него парочка Pull Request'ов, правда они висят уже год мёртвым грузом, что меня огорчает: #16917 и #16919
          В крайнем случае есть и сторонние гемы, типа ISO8601, но для работы с ними нужно ваять свой адаптер для ActiveRecord'а. Пока такой необходимости не возникало — стандартная библиотека, несмотря на всё её несовершенство, вполне устраивает.


          1. vintage
            14.09.2015 00:28

            Оно критично, когда хранить нужно не «число миллисекунд от 1970», а, например, «январь 2015» (целиком январь, в любом часовом поясе) или «промежуток с 9:00 до 18:00» (без учёта секунд, в любой день). Там в статье есть ссылки на примеры, когда из-за сдвига часовых поясов у людей календарики ехали. А казалось бы, какое дело календарю до часовых поясов?


  1. cinic
    13.09.2015 10:50

    Хранить всё в UTC и конвертировать в него-же при обращении к БД не вариант? Тем более что current_user.time_zone это айди зоны из tzinfo.


    1. Envek
      13.09.2015 23:27

      Ruby on Rails именно так делает по умолчанию (в статье написано). И мы именно так делаем хотя бы просто потому, что нафиг не сдалось лезть в недра ActiveRecord и эти умолчания менять (где-то в исходниках я видел крутилку, да).

      Вопрос именно в том, как с этими всеми временами обращаться уже в коде приложения. Всегда работать в UTC — не вариант (писал в комментариях выше).