📜 ⬆️ ⬇️

Correct work with date and time in Ruby on Rails

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 beginning of a work shift and also display it correctly: count the time until the end of the shift, show how many people drove to the point of destination and determine whether they met the standard, as well as much, much more.



Over the past few years I've been writing on Ruby on Rails, I have not had to face such problems - before that, all my applications worked in the same time zone. And then suddenly I had to sweat a lot, catching the most different mistakes and trying to figure out how to work with the date and time so that they could be avoided in the future.
')
As a result, today I have something to share with you. If you regularly meet with the fact that the time is saved or displayed incorrectly with a characteristic spread of several hours (3 hours for Moscow), some night recordings migrate to neighboring days, and the time stubbornly displays not as users want, and you don’t you know what to do with all this - welcome under cat.

So, the first and most important thing - what is the time with which we operate in everyday life and what does it consist of?
In ordinary life, we operate with some local time , which operates where we live, however, it is difficult and dangerous to work with computer systems with it - due to the transfer of hours (summer time, the State Duma, etc.) it is uneven and ambiguous ( more on this later). Therefore, it takes some universal time , which has uniformity and unambiguity (here a leap second bursts into the article and spoils everything, but we will not talk about it), one value of which reflects the same moment of time at any point on Earth (physics, be silent! ) - a single point of reference, its role is played by UTC - universal time coordinated. And we also need time zones ( time zones in modern terminology) to convert local time to universal and vice versa.

And what exactly is the time zone?

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

Secondly, these are daylight saving time rules. Among countries that have time zones with the same offset, some do not switch to summer 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 summer 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 was changing (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 the corresponding databases, all good libraries for working with time are able to take into account all these terrible features.

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

In Ruby on Rails, the ActiveSupport::TimeZone class is ActiveSupport::TimeZone for working with time zones. It is supplied as part of the ActiveSupport library from the standard Ruby on Rails ActiveSupport . 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 the ActiveSupport extended Time class from the standard Ruby library to fully work with time zones. Well, in the class ActiveSupport::TimeWithZone from Ruby on Rails, which is stored in itself not only the time with the offset, but also the time zone itself. Many methods at the time zone return 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.

Among the shortcomings of ActiveSupport::TimeZone it can be noted that it uses its own “human-readable” identifiers for time zones, which sometimes causes inconvenience, and also that these identifiers are not for all time zones available in tzdata, but also it's fixable.

Every “railman” has already come across this class, setting the time zone in the config/application.rb file after creating a new application:

 config.time_zone = 'Moscow' 

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

Here you can already see that the identifier Moscow instead of Europe/Moscow , but if you look at the output of the inspect method for a time zone object, we will see that inside there is a mapping to the tzdata identifier:

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

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

The ActiveSupport::TimeZone class is also actively used in operations with objects of the Time class and adds several useful methods to it, for example:

Important! There are two different sets of methods that return “now” - Time.current along with Date.current and Time.now along with Date.today . The difference between them is that the first (those that are current ) return the time or date in the application's time zone as an object of type ActiveSupport::TimeWithZone , in the same zone that currently returns the Time.zone method and adds these Ruby methods on Rails, and the latter return time in the time zone, attention, server operating systems, and go to the standard Ruby library (they return, respectively, just Time ). Be careful - there are strange bugs that are not reproducible locally, so always use Time.current and Date.current .

So, knowing this all, we can already add support for time zones in 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 User model with a certain time_zone method that returns an ActiveSupport::TimeZone object with the user's time zone.

If this method returns non- nil , then using the around_action around_action we call the class method Time.use_zone and 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!

In the database, we store the tzdata identifier, and to convert it to an object, this method is used 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 

And this is a specially complicated method that converts the Europe/Moscow type tzdata identifier stored in the database into an ActiveSupport::TimeZone object, the identifier of which is simply Moscow . The reason that I store the tzdata time zone id in the database, and not the rail one, lies in the interoperability - everyone understands the id from tzdata , and only Ruby on Rails understands the id time zone rails.

And this is what the setter of the time zone paired to him looks like, which saves the tzdata identifier to the database. It can accept as input an object of class ActiveSupport :: TimeZone, or any of identifiers.

 #         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 tzdata identifier to the database is the PostgreSQL we use works well with time zones. Having the tzdata identifier in the database, you can quite conveniently look at the local time in the user's time zone and debug various problems with time zones using queries like:

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

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

Ruby on Rails defaults to UTC when connecting to a database. That is, for any work with the base, all work with time is performed in UTC. Values ​​in all columns are also written strictly in UTC, so, for example, when retrieving records for a certain day, you should always remember this and transfer to SQL queries not just dates that the DBMS converts at midnight on UTC, but time stamps that store midnight in the right time zone. And then no records on your next date will not leave.

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

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

You must directly specify the 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


In front of you are the “rakes” that hurt my forehead not 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 into a string. In this form, it is transmitted to the server. Thus, a bug was detected in the parse method of the Time class from the standard Ruby library, as a result of which the time in the Novosibirsk time zone was incorrectly parsed - the date was 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 application was used by the first client, who had the Novosibirsk time zone in the OS settings. By tradition, this client turned out to be a customer. Developing in Moscow, you will never find this bug!

Here comes the tip: set a different time zone on your CI server than the one used by the developers. We discovered this property by accident, because our CI server was in UTC by default, and all developers are locally installed in Moscow. Thus, we caught some previously not manifested bugs, because the browser on the CI server was launched with a time zone different from the default time zone of the rail application (and the time zone of test users).

This example well shows the importance of using standardized machine-readable formats for the exchange of information between subsystems. The previous bug would not exist if the developer immediately attended to the transfer of 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 .

The time from the example will look like this: 2015-05-18T22:16:38+06:00 .

On the client, if you have a 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 such a format and try to parse it with the appropriate method of the Time class, and leave the parse 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 exception and return the error code 400 Bad Request with the message “you have a curve parameter and in general you are an evil Buratino”.

However, the previous method is still error prone - if params[:till] is passed to time without offset from UTC, both methods (and iso8601 and parse ) will parse it as if it were local time in the server time zone, and not applications. Do 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::TimeZone does not have the iso8601 method, which is a pity):

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

But even here there is a place where everything can collapse - 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 itself (even with a time zone!) Is ambiguous in some situations. For example, when changing the time from summer to winter, the same hour is repeated twice, once with one offset, 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 clocks correspond to different clocks in UTC. With a reverse transfer one hour does not happen at all. The local time with the specified offset from UTC is always unique. In the event that you “run up” at such a point in time and you don’t have a displacement, Time.parse will simply return an earlier point in time to you, and Time.zone.parse throw a TZInfo::AmbiguousTime exception.

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 patch, you can teach timezone_select display Russian time zones first or even the only ones. In the future, it will be possible to do without this - I sent the Pull Request to Ruby on Rails, but so far, 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 well be that out of the box you may not have enough time zones. For example, Russian time zones are far from being all, but at least there is one with each separate offset from UTC. By simply inserting into the internal ActiveSupport 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 phrase “we are not an encyclopedia of time zones” ( 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? Take it easy - not everything is so smooth! In pure javascript, you can only get an offset from UTC, which is now operating in the user's OS - and that’s it. Therefore, everyone is practically doomed to use the library of moment.js along with its complementary library at the moment timezone , which drags tzdata directly to the browser to the user (yes, users will again have to download extra kilobytes). But, nevertheless, with it you can do everything. Or almost everything.

Examples of use that you absolutely definitely need:

In case you already have a good and good time stamp in ISO8601 format, then just feed it to the parseZone method of the Moment itself:

 moment.parseZone(ISO8601Timestamp) 

, Moment Timezone , , :

 moment.tz(timestamp, formatString, timezoneIdentifier) 

( new Date() !), « » .

. , angular-moment , . — .


, 90% , :


- , Mail.ru — , : http://habrahabr.ru/company/mailru/blog/242645/

, , , , , :



, ! — , :

PS> DevConf 2015. , RailsClub. , RailsClub — !

Source: https://habr.com/ru/post/266681/


All Articles