Go to English Blog

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:

É 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:

Resources


  1. Existe uma terceira classe chamada DateTime, que é apenas uma subclasse de Date com conhecimento sobre os atributos de hora. Em versões antigas do Ruby, a classe Time tinha limitações no intervalo de datas em sistemas 32-bit; nestas situações, o uso de DateTime era recomendado. À partir do Ruby 1.9.2 essa limitação foi removida e a classe Time pode agora trabalhar com qualquer intervalo de datas. Além disso, as classes Time e Date tem performance semelhante

  2. O PostgreSQL só irá usar a variável de ambiente TZ caso a configuração timezone não tenha sido especificada no arquivo postgresql.conf. No entanto, como ela é definida com um valor padrão, é muito improvável que o PostgreSQL use a variável TZ a não ser que você tenha essa intenção. 

  3. Os métodos que retornam datas, como Time.zone.today, Date.yesterday e Date.tomorrow retornam objetos da classe Date e, por este motivo, não possuem conhecimento sobre fuso horário. Se precisar, converta estes objetos para um objeto do tipo ActiveSupport::TimeWithZone com date.in_time_zone

  4. O horário de verão 2016 vai de 18 de outubro de 2015 a 21 de fevereiro de 2016. 

  5. A configuracão ActiveRecord::Base.time_zone_aware_attributes é desativada por padrão se você está usando o ActiveRecord fora do Ruby on Rails.