Correct work with date and time in Ruby on Rails

  • Tutorial
Hello! My name is Andrey Novikov and recently I am working on a project to develop an application that is used in different parts of our country and automates the work of people. In each specific time zone, our application needs to correctly receive, save and display time, both in the past and in the future - for example, calculate the start of a work shift and also display it correctly: count the time until the end of the shift, show how many people were traveling to the destination and determine whether they have met the norm, as well as much, much more.



For the past few years that I write in Ruby on Rails, I have not had to deal with similar problems - before that, all my applications worked in the same time zone. And then suddenly I had to sweat a lot, catching a variety of errors and trying to figure out how to work with the date and time so as to avoid them in the future.

As a result, today I have something to share with you. If you regularly encounter the fact that the time is saved or displayed incorrectly with a characteristic spread of several hours (3 hours for Moscow), some nightly recordings move to neighboring days, and the time is stubbornly displayed not as users want, and you don’t know what to do with all this - welcome under cat.

So, the first and most important thing - what is the time that we operate in everyday life and what it consists of?
In ordinary life, we operate with some local time , which operates where we live, however, it is difficult and dangerous to work with it in computer systems - due to the clock change (summer time, the State Duma, etc.) it is uneven and ambiguous ( more on this later). Therefore, it takes some universal time , which is uniform and unambiguous (a leap second bursts into the article and spoils everything, but we won’t talk about it), one value of which reflects the same moment in time anywhere in the world (physics, keep quiet! ) - a single reference point, its role is played by UTC- Coordinated universal time. And we also need time zones ( time zones in modern terminology) to convert local time to universal and vice versa.

And what is the time zone in general?

The first is the offset from UTC. That is, by how many hours and minutes our local time differs from UTC. Note that this does not have to be an integer number of hours. So, India, Nepal, Iran, New Zealand, parts of Canada and Australia and many others live with honors from UTC at X hours 30 minutes or X hours 45 minutes. Moreover, at some moments on the Earth there are already three dates - yesterday, today and tomorrow, since the difference between the extreme time zones is 26 hours.

Secondly, these are the rules for switching to daylight saving time. Among countries that have time zones with the same offset, some do not switch to daylight saving time at all, some switch to some numbers, others to others. Some in the summer, some in the winter (yes, we have the southern hemisphere). Some countries (including Russia) switched to daylight saving time earlier, but wisely abandoned this idea. And to correctly display the date and time in the past, all this must be taken into account. It is important to remember that when switching to summer time, it is the shift that changes (it was in Moscow before +3 hours in winter, it became +4 in summer).

In computers, information for working with this madness is stored in appropriate databases, all good libraries for working with time are able to take into account all these terrible features.

Windows seems to use some kind of database of its own, and in almost the entire world of the open-source world, the de facto standard is the IANA Time Zone Database , better known as tzdata . It stores the history of all time zones from the beginning of the Unix era, that is, from January 1, 1970: which time zones when they appeared, which when they disappeared (and which they poured), where and when they switched to daylight saving time, how lived on it and when it was canceled. Each time zone is designated as Region / Place, for example, the Moscow time zone is called Europe / Moscow. Tzdata is used in GNU / Linux, Java, Ruby (the tzinfo gem), PostgreSQL, MySQL, and many more.

Ruby on Rails uses the class ActiveSupport::TimeZonesupplied with the library to work with time zonesActiveSupportfrom the standard Ruby on Rails bundle. It is a wrapper around the tzinfo gem , which in turn provides a ruby ​​interface to tzdata . It provides methods for working with time, and is also actively used in ActiveSupport's extended Time class from the Ruby standard library to fully work with time zones. Well, in the class ActiveSupport::TimeWithZonefrom Ruby on Rails, which stores in itself not only time with an offset, but also the time zone itself. Many methods in the time zone return objects ActiveSupport::TimeWithZone, but in most cases you will not even feel it. What is the difference between these two classes is written in the documentation , and this difference is useful to know.

Of the disadvantagesActiveSupport::TimeZoneit can be noted that he uses his own “human-readable” identifiers for time zones, which sometimes creates inconvenience, and also that these identifiers are not for all time zones available in tzdata, but this is fixable.

Each “rail” has already encountered this class, setting the time zone in the file config/application.rbafter creating a new application:

config.time_zone = 'Moscow'

In the application, you can access this time zone using the zoneclass method Time.

Here we can already see that the identifier is used Moscowinstead Europe/Moscow, but if you look at the inspecttime zone object in the method output , we will see that inside there is a mapping to the tzdata identifier :

 > Time.zone
=> #>

So, the most interesting methods for us will be (all return objects of type ActiveSupport::TimeWithZone):

  • A method nowthat returns the current time in a given time zone.

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

  • A method parsethat, like the method parseof a class Time, parses a string with time into a class object Time, but at the same time immediately translates it into the time zone of this object. If the offset from UTC is not indicated in the line, then this method will also decide that the local time of this time zone is indicated in the line.

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

  • The method atconverts a Unix timestamp (the number of seconds since January 1, 1970), which, as you know, is always in UTC, to a type object Timein this time zone.

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

  • And a method localthat allows you to programmatically construct the time in the right time zone from individual components (year, month, day, hour, and so on).

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

The class is ActiveSupport::TimeZonealso actively used in operations with class objects Timeand adds several useful methods to it, for example:

  • The class method Time.zonewill return a class object ActiveSupport::TimeZonerepresenting the time zone that is currently valid throughout the application (and it can be changed).

  • And the class method Time.zone_defaultwill return the time zone that you specified in the file config/application.rb.

  • The method with_zoneallows you to temporarily change the current time zone for all code that runs in the block passed to it.

  • Well, the method of the object Time#in_time_zoneallows you to change the time zone of an existing object (it will return an object of type ActiveSupport::TimeWithZone):

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

Important! There are two different sets of methods that return "now" - Time.currentalong with Date.currentand Time.nowtogether with Date.today. The difference between them is that the first (those that current) return the time or date in the time zone of the application, as an object of the type ActiveSupport::TimeWithZone, in the same belt that currently returns the method Time.zoneand adds these Ruby on Rails methods, and the second returns the time in time zone, attention, server operating system and go to the standard Ruby library (return, respectively, simply Time). Be careful - there may be strange bugs that cannot be played locally, so always use Time.currentand Date.current.

So, knowing all this, we can already add time zone support to any application:

# 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

In this example, we have a model Userwith a certain method time_zonethat returns an object ActiveSupport::TimeZonewith the user's time zone.

If this method returns no nil, then using the callback around_actionwe call the class method Time.use_zoneand continue processing the request in the block passed to it. Thus, all times in all views will be automatically displayed in the user's time zone. Voila!

We store the identifier in the database tzdata, and to convert it to an object, use this method in the file 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

Moreover, this is a specially complicated method by me, which converts the identifier of the form tzdata stored in the database Europe/Moscowinto an object ActiveSupport::TimeZonewhose identifier is simple Moscow. The reason that I stored in the idtime zone of tzdatarather than rail, lies in interoperability - idfrom tzdataunderstanding everything, and idtime zone rails - only Ruby on Rails.

And it looks like a paired time zone setter method that saves the tzdata identifier to the database. It can accept either an object of the ActiveSupport :: TimeZone class or any of the identifiers as an input.

# Сохраняет в базу данных идентификатор часового пояса из 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

The main reason why I prefer to save the identifier tzdatato the database is that PostgreSQL we use works well with time zones. Having an identifier in the database tzdata, it is quite convenient to look at local time in the user's time zone and debate various problems with time zones using queries of the form:

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

One feature of PostgreSQL that is important to keep in mind is that data types ending in with time zone do not store time zone information, but only convert the values ​​inserted into them into UTC for storage and back to local time for display. Ruby on Rails in migrations creates columns with the type timestamp without time zone, which store the time as you write in them.

Ruby on Rails by default when connecting to the database sets the time zone in UTC. That is, during any work with the database, all work with time is done in UTC. Values ​​in all columns are also written strictly in UTC, therefore, for example, when selecting records for a certain day, you should always remember this and send to SQL queries not just dates that the DBMS converts at midnight UTC, but timestamps storing midnight at desired time zone. And then no entries at your next date will leave.

The following query will not return records for the first three hours of the day for an application sharpened for Moscow time (UTC + 3, all things):

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

You must directly specify the point in time in the right time zone so that ActiveRecord converts it correctly:

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

Serialization and transfer of date and time


Here is a "rake" that hit me on the forehead painfully not so long ago. In the application code, we had a place where time was generated on the client by constructing a new javascript Date object and implicitly casting it to a string. In this form, it was transmitted to the server. So a bug was discovered in the parse method of the Time class from the Ruby standard library, as a result of which time in the Novosibirsk time zone is not correctly parsed - the date turned out to be almost always in November:

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

Most importantly, we could not detect this bug until the first client used the application, which had the Novosibirsk time zone in the OS settings. By a good tradition, this customer turned out to be the customer. When developing in Moscow, you will never find this bug!

The advice follows: set a different time zone on your CI server than the one used by the developers. We discovered this property by accident, since our CI server was in UTC by default, and all developers have Moscow locally installed. Thus, we caught several previously unrevealed bugs, since the browser on the CI server started up with a time zone different from the default time zone of the rail application (and the time zone of test users).

This example illustrates the importance of using standardized machine-readable formats for exchanging information between subsystems. There wouldn’t be a previous bug if the developer immediately became interested in transmitting data in a machine-readable format.

An example of such a machine-readable format is ISO 8601. For example, this is the recommended format for transmitting time and date when serialized to JSON according to the Google JSON Style Guide .

Example time will look at it like this: 2015-05-18T22:16:38+06:00.

On the client, if you have moment.js, then you need a method toISOString(). And, for example, Angular.js serializes time in ISO 8601 by default (and does it right!).

In my humble opinion, it is highly desirable to immediately expect time in this format and try to parse it with the appropriate class method Time, and parseleave the method for backward compatibility. Like this:

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

And if backward compatibility is not needed, then I would just catch the execution and return the 400 Bad Request error code with the message "you have a curve parameter and in general you are an evil pinocchio."

However, the previous method is still error prone - in case params[:till]time is transferred without an offset from UTC, both methods ( iso8601and parse) will parse it as if it were local time in the server’s time zone , and not the application. So you know what time zone your server is in? I have in different. A more bulletproof time parsing method will look like this (unfortunately ActiveSupport::TimeZonethere is no method iso8601, but sorry):

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

But there is a place where everything can crash - look at the code carefully and read on!

When you transfer local time between systems (or store somewhere), be sure to transfer it along with the offset from UTC! The fact is that local time in itself (even with a time zone!) Is ambiguous in some situations. For example, when changing time from summer to winter, the same hour is repeated twice, once with one shift, another time with another. Last fall in Moscow, the same hour of the night first passed with a shift of +4 hours, and then passed again, but with a shift of +3. As you can see, each of these watches corresponds to a different clock in UTC. With a reverse transfer, one hour does not happen at all. Local time with a specified offset from UTC is always unambiguous. In the event that you “run into” at such a moment in time and you will not have a displacement, thenTime.parsejust returns you an earlier point in time, and Time.zone.parsethrows an exception TZInfo::AmbiguousTime.

Here are some illustrative examples:

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

Various useful tricks


If you add a little Monkey-patching, you can teach how to timezone_selectdisplay Russian time zones first or even unique. In the future, it will be possible to do without this - I sent a Pull Request to Ruby on Rails, but for now, unfortunately, it hangs without activity: 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)

It may turn out that you may not have enough time zones “out of the box”. For example, Russian time zones are far from all, but at least there is one with each individual offset from UTC. By simply inserting ActiveSupport into the internal hash and adding translations to the i18n-timezones gem, this can be achieved. Do not try to send a pull request to Ruby on Rails - they will not accept it with the wording “we are not an encyclopedia of time zones here” ( I checked ). 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


What is a modern web application without a rich frontend? Temper your ardor - not everything is so smooth! In pure javascript, you can only get the offset from UTC, which is now valid in the user's OS - and that’s all. Therefore, everyone is practically doomed to use the moment.js library along with its complementary moment timezone library , which drags the tzdatauser directly into the browser (yes, users will again have to download extra kilobytes). But, nevertheless, with the help of it you can do anything. Well, or almost everything.

Examples of use that you will definitely need:

In case you already have the correct and good timestamp in ISO8601 format, then just feed it to the method of parseZonethe Moment itself:

moment.parseZone(ISO8601Timestamp)

If you have a timestamp in the local time zone, then Moment Timezone needs to be informed in what time zone it is, then the analysis is carried out as follows:

moment.tz(timestamp, formatString, timezoneIdentifier)

If everywhere in the application you analyze time using these methods (forget about it new Date()!), Then everything will be fine with you and you will soon forget about “jumping time” and it will become much calmer.

For a very rich frontend based on fashionable frameworks, see separate libraries for them. For example, we use angular-moment , which allows you to dynamically set the time zone for the entire application and automatically display all the time on the page in this time zone using special directives. If you use angular - wildest recommend.

Summary


General recommendations that work in 90% of cases are as follows:

  • Store and transmit time about past and ongoing right now (i.e., recorded) events in UTC.
  • Over time, in the future everything is somewhat more complicated. Decide what’s more important for you, in the event of unforeseen changes in time zones, local time or UTC time.
  • Ideally, you need to store three values: local time, time in UTC and the identifier of the time zone. In this case, you will be able to find that for some time "went" in advance and take any measures.
  • If you still want to be able to catch the appearance of new time zones, then you can save the user's geographical coordinates.
  • For the same reason that time zones change over time, and also because of the availability of summer time, it is extremely important to store the time zone identifier, and not just the offset.
  • But if you do not know the time zone, then store the offset - this is better than nothing.
  • It is better not to believe the time from the client, because it can be wrong - whether it is by chance, or it can be intentionally changed, the time zone or the offset from UTC can also be completely arbitrary.
  • Well, the last, but important - on your servers always keep the configured NTP and the latest version of the package tzdata(remember that some software carries with it its own copy tzdata).

If this information is not enough for someone, read the useful article on Habrahabr by Vladimir Rudnyh from Mail.ru - it tells much more about the different nuances of working with time zones and time in general, especially if it is in the future: http://habrahabr.ru / company / mailru / blog / 242645 /

There is also an interesting educational video from Tom Scott in which he talks about where all these problems with time zones came from and how much more understandable and interesting than me, but in English:



Well, of course the documentation! She is your main friend and you can learn a lot from it that is beyond the scope of this article:

PS> This article is based on my presentation at DevConf 2015. You can familiarize yourself with the slides here , and the video is posted here by the great guys from RailsClub. By the way, this year we are again sponsors of the RailsClub conference - see you soon there!

Also popular now: