11 September 2015

Правильная работа с датой и временем в Ruby on Rails

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



За те уже несколько лет, что я пишу на 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 — до скорой встречи там!
Tags:время
Hubs: AT Consulting corporate blog Programming Ruby on Rails
+20
23.9k 108
Comments 16