Trabalhando com datas no Ruby on Rails
Leia em 8 minutos
Trabalhar com datas pode ser uma tarefa mais complexa do que você imagina. É preciso levar em consideração o fuso horário, saber como vai armazenar essas datas no banco de dados, entender como converter datas fornecidas pelo usuário ou enviar datas formatadas. Ah, tem também o horário de verão.
Neste artigo veremos quais as principais dificuldades de se trabalhar com datas e como usar os diversos utilitários disponibilizados pelo Ruby e Rails para garantir que seu sistema usa corretamente as datas.
Como funciona no Ruby
O Ruby possui duas classes para lidar com datas: Date
e Time
1. Com elas é possível gerar datas usando strings que são interpretadas por estas classes ou com atributos individuais que identificam o ano, hora, mês, etc.
require "time"
Time.parse("Dec 8 2015 10:19")
#=> 2015-12-08 10:19:00 -0200
Date.parse("Dec 8 2015")
#=> 2015-12-08
Todo o cálculo de dados deve ser feito de forma manual. Por exemplo, para avançar uma hora, você pode somar 3600 segundos à data atual.
time = Time.now
#=> 2015-12-08 10:26:40 -0200
time + 3600
#=> 2015-12-08 11:26:40 -0200
Algumas operações podem ser mais trabalhosas (não difíceis), mas de um modo geral esses cálculos são simples de serem feitos. O mesmo não pode ser dito de cálculos que envolvem o fuso horário.
Trabalhando com fuso horário
A definição de fuso horário é, sem dúvida nenhuma, a parte mais complicada ao se trabalhar com datas. Primeiro, você precisa entender como o Ruby funciona.
No Ruby, a definição de fuso horário é feita exclusivamente pela variável de ambiente TZ
. Na maioria das vezes, essa variável de ambiente não é definida, mas isso vai depender de como seu servidor foi configurado; sistemas POSIX mais antigos usavam esta variável para configurar o fuso horário, mas hoje isso é feito através do arquivo /etc/localtime
.
Veja como o Ruby se comporta com e sem a variável TZ
.
ENV["TZ"]
#=> nil
Time.now
#=> 2015-12-08 10:30:00 -0200
ENV["TZ"] = "America/Los_Angeles"
#=> "America/Los_Angeles"
Time.now
#=> 2015-12-08 04:30:14 -0800
O comando date
, disponível em sistemas *nix, também usa a variável TZ
, caso ela esteja disponível, para exibir a data na sessão de um usuário.
$ date
Tue Dec 8 09:37:12 BRST 2015
$ export TZ=America/Los_Angeles
$ date
Tue Dec 8 03:37:27 PST 2015
O maior problema é que nem todo software irá utilizar esta variável automaticamente. No PostgreSQL, por exemplo, mesmo que a variável TZ
esteja definida, ele irá retornar o valor definido pela configuração timezone
2.
SELECT current_setting('timezone');
#=> Brazil/East
A melhor maneira de evitar de lidar com fuso horário nas diferentes peças de sua infraestrutura é simplesmente ignorando-o; em vez de definir um fuso horário em particular, use Etc/UTC. Isso evitará problemas comuns relacionados a horário de verão em cron jobs, além de simplificar o modo como diferentes aplicações irão trabalhar com datas e conversar entre si. Deixe toda a formatação de fuso horário ser responsabilidade da camada da aplicação.
Como funciona no Ruby on Rails
Para definir o fuso horário de sua aplicação, utilize o arquivo de ambiente ou crie um arquivo de inicialização.
# config/initializers/time_zone.rb
Time.zone = "America/Sao_Paulo"
O Ruby on Rails permite que você defina o fuso horário de sua aplicação, independente do valor definido na variável TZ
e isso traz algumas implicações.
Primeiro, o Ruby desconhece totalmente o fuso horário definido pelo Rails. Isso significa que se meu sistema estiver definido como America/Sao_Paulo
e meu fuso horário estiver como America/Los_Angeles
, terei datas diferentes dependendo de como eu gere essas datas.
Time.now.zone
#=> "BRST"
Time.zone = "America/Los_Angeles"
#=> "America/Los_Angeles"
Time.now.zone
#=> "BRST"
Para que você gere datas que tenha conhecimento sobre o fuso horário, é preciso usar os métodos definidos pelo ActiveSupport, que são objetos gerados à partir da classe ActiveSupport::TimeWithZone
3. São diversos métodos e seu uso vai depender do que você precisa.
Time.zone.now
#=> Tue, 08 Dec 2015 03:37:57 PST -08:00
Time.zone.today
#=> Tue, 08 Dec 2015
Time.current
#=> Tue, 08 Dec 2015 03:38:17 PST -08:00
1.hour.ago
#=> Tue, 08 Dec 2015 02:38:28 PST -08:00
1.day.from_now
#=> Wed, 09 Dec 2015 03:38:36 PST -08:00
Date.yesterday
#=> Mon, 07 Dec 2015
Date.tomorrow
#=> Wed, 09 Dec 2015
De um modo geral:
- Use
Time.current
em vez deTime.now
. - Use
Date.current
em vez deDate.today
.
É importante dizer que Time.current
pode ignorar informações de fuso horário caso esta configuração não esteja definida; o mesmo é válido para Date.current
. Veja a implementação destes métodos na biblioteca ActiveSupport:
# activesupport-4.2.5/lib/active_support/core_ext/time/calculations.rb
# line 30
class Time
def current
::Time.zone ? ::Time.zone.now : ::Time.now
end
end
# activesupport-4.2.5/lib/active_support/core_ext/date/calculations.rb
# line 46
class Date
def current
::Time.zone ? ::Time.zone.today : ::Date.today
end
end
Você pode usar Time.zone.today
e Time.zone.now
para ter um resultado determinístico.
Lidando com o horário de verão
O ActiveSupport utiliza a gem TZInfo para ter conhecimento sobre os diversos fusos horários. Para isso, essa gem carrega diversas informações do sistema operacional; no Ubuntu, essas informações são disponibilizadas através do pacote tzdata
.
Se você mantém o seu servidor atualizado, isso significa que a aplicação terá acesso às informações sobre quando começa ou termina o Horário de Verão por todo o mundo.
Veja, por exemplo, como fica o Horário de Verão deste ano4:
Time.zone.parse('2015-10-17').dst?
#=> false
Time.zone.parse('2015-10-18').dst?
#=> true
Time.zone.parse('2016-02-20').dst?
#=> true
Time.zone.parse('2016-02-21').dst?
#=> false
Às vezes você precisará fazer a conversão e o horário de verão pode retornar um horário diferente. Recentemente precisei integrar a geração de datas com um calendário. A maior dificuldade foi em relação à exibição de uma data futura, levando em consideração o fuso horário local.
Acontece que a solução é bastante simples; basta não definir o fuso horário na string que será interpretada, deixando isso para o método Time.use_zone
.
Time.current
#=> Mon, 07 Dec 2015 18:57:51 BRST -02:00
Time.use_zone("America/Sao_Paulo") do
starts_at = Time.zone.parse("2016-03-05 10:00")
#=> Sat, 05 Mar 2016 10:00:00 BRT -03:00
ends_at = Time.zone.parse("2016-03-05 17:00")
#=> Sat, 05 Mar 2016 17:00:00 BRT -03:00
end
Como as datas são persistidas
O ActiveRecord é configurado por padrão para persistir as datas como UTC. Isso é definido pelas configurações ActiveRecord::Base.default_timezone
e ActiveRecord::Base.time_zone_aware_attributes
.
ActiveRecord::Base.default_timezone
#=> :utc
ActiveRecord::Base.time_zone_aware_attributes
#=> true
Isso significa que toda vez que você define uma data para um atributo do ActiveRecord, esta data será armazenada como UTC. Ao carregar o registro, o Rails irá converter a data de volta para o fuso horário definido na aplicação5.
Perceba que que a data pode ser armazenada incorretamente caso você utilize os métodos Date.today
ou Time.now
.
Time.zone = "America/Los_Angeles"
#=> "America/Los_Angeles"
Time.now.beginning_of_day.utc
#=> 2015-12-08 02:00:00 UTC
Time.current.beginning_of_day.utc
#=> 2015-12-08 08:00:00 UTC
Essa diferença acontece porque Time.now
ignora o fuso horário definido pela aplicação e está usando o fuso horário local do sistema (que neste caso é BRST-0200
). Por isso é muito importante que você use uma data que tenha conhecimento do fuso horário, como mostrado anteriormente.
O Rails assume que todas as datas armazenadas no banco de dados estão em UTC. Se alguém adicionar um registro por fora do sistema sem considerar o fuso horário pode ter uma diferença de algumas horas na hora que esse registro for carregado na aplicação. O PostgreSQL possui um campo que tem conhecimento do fuso e que fará a conversão para você antes de persistir a informação. Infelizmente, o Rails não tem suporte nativo para time with time zone
, mas você pode adicioná-lo facilmente; basta criar um arquivo de inicialização que contenha o seguinte código:
# config/initializers/active_record.rb
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::
NATIVE_DATABASE_TYPES[:datetime] = {name: "timestamp with time zone"}
Fazendo consultas de banco de dados
Como o Rails considera que todas as datas são armazenas como UTC, a única coisa que você precisa garantir é que as datas estejam definidas com a informação de fuso horário (Time.current
ou Date.current
, por exemplo) e o framework irá se encarregar de fazer a conversão correta.
Article.where("published_at >= ?", Time.current)
Lembre-se que definição de datas como 1.day.ago
e Date.current.beginning_of_month
, dentre outros, irão gerar objetos que carregam o fuso horário, então você não terá problemas.
Interpretando datas enviadas pelos usuários
Os mesmos cuidados valem na hora de converter uma string em um objeto de data. O Ruby possui os métodos Time.parse
e Date.parse
, mas eles ignorarão as informações de fuso horário. Por isso, use Time.zone.parse
sempre que precisar fazer esta conversão.
Time.parse("8:47am Dec 7th, 2015").utc
#=> 2015-12-07 10:47:00 UTC
Time.zone.parse("8:47am Dec 7th, 2015").utc
#=> 2015-12-07 16:47:00 UTC
Para o caso de você receber diversos números para cada item que compõe uma data, utilize o método Time.zone.local
em vez de Time.new
.
Time.new(2015, 12, 7, 8, 47, 0).utc
#=> 2015-12-07 10:47:00 UTC
Time.zone.local(2015, 12, 7, 8, 47, 0).utc
#=> 2015-12-07 16:47:00 UTC
Finalmente, se você tiver um timestamp, use Time.zone.at
.
Time.zone.at(1449506820)
#=> 2015-12-07 16:47:00 UTC
Para definir um fuso horário apenas durante uma execução qualquer, podemos usar o método Time.use_zone
. Ele irá definir o fuso horário especificado apenas para a execução do bloco.
Time.zone = "America/Sao_Paulo"
#=> "America/Sao_Paulo"
now_in_sao_paulo = Time.current
#=> Tue, 08 Dec 2015 09:40:31 BRST -02:00
now_in_los_angeles = Time.use_zone("America/Los_Angeles") do
Time.current
end
#=> Tue, 08 Dec 2015 03:40:31 PST -08:00
now_in_sao_paulo.utc
#=> 2015-12-08 11:40:31 UTC
now_in_los_angeles.utc
#=> 2015-12-08 11:40:31 UTC
now_in_sao_paulo.to_i == now_in_los_angeles.to_i
#=> true
Esse método pode ser usado no controller, caso você precise definir o fuso horário especificado pelo usuário.
class ApplicationController < ActionController::Base
around_action :set_timezone, if: :logged_in?
private
def set_timezone(&action)
Time.use_zone(current_user.time_zone, &action)
end
end
Trabalhando com fuso horário no JavaScript
A melhor maneira de se trabalhar com datas e fuso horário no JavaScript é usar a biblioteca moment.js.
Para enviar uma data do Ruby para o JavaScript, utilize o método Time#iso8601
. Ele irá retornar uma formatação como 2015-12-07T19:25:45Z
. Com o moment.js, você pode fazer a conversão desse formato para o objeto Date
do JavaScript.
var date = moment("2015-12-07T19:25:45Z");
//=> Mon Dec 07 2015 17:25:45 GMT-0200 (BRST)
date.format("MMM DD, YYYY - h:mma");
//=> Dec 07, 2015 - 5:25pm
No exemplo acima ele fez a conversão automática para o fuso horário identificado pelo navegador, no meu caso BRST-0200
. Se você precisar fazer conversão para outro fuso horário, use o momentjs-timezone.
Finalizando
Se o seu aplicativo usa datas, principalmente quando há cálculo envolvido, entender como funciona o sistema de datas do Rails é essencial. Para facilitar a sua vida, basta seguir as regras abaixo:
- Defina o fuso horário de seus servidores como
Etc/UTC
. - Defina sempre o fuso horário em sua aplicação Ruby on Rails, mesmo que seja
Etc/UTC
. - Lide com a apresentação do fuso horário na camada da aplicação.
- Use os métodos
Time.current
eDate.current
para pegar as datas atuais. - Use
Time.zone.parse
para converter strings em datas. - Converta
Date
emActiveSupport::TimeWithZone
para fazer comparações, como emdate.in_time_zone == Time.current.beginning_of_day
.
Resources
- Dealing With Time Zones Using Rails and Postgres
- Dealing with timezones effectively in Rails
- Handling Dates & Timezones in Ruby & Rails
- Handling Time Zones in Rails
- How Rails and MySQL are handling time zones
- The Exhaustive Guide to Rails Time Zones
- The Worst Server Setup Mistake You Can Make
- Working with time zones in Ruby on Rails
Existe uma terceira classe chamada
DateTime
, que é apenas uma subclasse deDate
com conhecimento sobre os atributos de hora. Em versões antigas do Ruby, a classeTime
tinha limitações no intervalo de datas em sistemas 32-bit; nestas situações, o uso deDateTime
era recomendado. À partir do Ruby 1.9.2 essa limitação foi removida e a classeTime
pode agora trabalhar com qualquer intervalo de datas. Além disso, as classesTime
eDate
tem performance semelhante. ↩O PostgreSQL só irá usar a variável de ambiente
TZ
caso a configuraçãotimezone
não tenha sido especificada no arquivopostgresql.conf
. No entanto, como ela é definida com um valor padrão, é muito improvável que o PostgreSQL use a variávelTZ
a não ser que você tenha essa intenção. ↩Os métodos que retornam datas, como
Time.zone.today
,Date.yesterday
eDate.tomorrow
retornam objetos da classeDate
e, por este motivo, não possuem conhecimento sobre fuso horário. Se precisar, converta estes objetos para um objeto do tipoActiveSupport::TimeWithZone
comdate.in_time_zone
. ↩O horário de verão 2016 vai de 18 de outubro de 2015 a 21 de fevereiro de 2016. ↩
A configuracão
ActiveRecord::Base.time_zone_aware_attributes
é desativada por padrão se você está usando o ActiveRecord fora do Ruby on Rails. ↩