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 Time1. 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 timezone2.
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::TimeWithZone3. 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.currentem vez deTime.now. - Use
Date.currentem 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.currenteDate.currentpara pegar as datas atuais. - Use
Time.zone.parsepara converter strings em datas. - Converta
DateemActiveSupport::TimeWithZonepara 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 deDatecom conhecimento sobre os atributos de hora. Em versões antigas do Ruby, a classeTimetinha limitações no intervalo de datas em sistemas 32-bit; nestas situações, o uso deDateTimeera recomendado. À partir do Ruby 1.9.2 essa limitação foi removida e a classeTimepode agora trabalhar com qualquer intervalo de datas. Além disso, as classesTimeeDatetem performance semelhante. ↩O PostgreSQL só irá usar a variável de ambiente
TZcaso a configuraçãotimezonenã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ávelTZa não ser que você tenha essa intenção. ↩Os métodos que retornam datas, como
Time.zone.today,Date.yesterdayeDate.tomorrowretornam objetos da classeDatee, por este motivo, não possuem conhecimento sobre fuso horário. Se precisar, converta estes objetos para um objeto do tipoActiveSupport::TimeWithZonecomdate.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. ↩