Сохранение даты и времени в UTC неточно иногда

В общем, наилучшая практика при работе с датами - хранить их в UTC и конвертировать обратно в то, что ожидает пользователь на уровне приложения.

Это не обязательно работает с будущими датами, особенно когда дата/время зависит от пользователя в их часовом поясе.

В моем случае атрибут theres содержит метку времени, содержащую start_time будущего события, по сравнению со всем остальным, что сейчас или в прошлом (включая отметки времени created_at и updated_at).

Почему

Это конкретное поле является меткой времени будущего события, где пользователь выбирает время.

Для будущих событий кажется, что наилучшей практикой является не хранить UTC.

Вместо экономии времени в UTC вместе с часовым поясом разработчики могут сохранить то, что пользователь ожидает от нас, чтобы сохранить: время стены.

Когда пользователь выбирает 10 утра, он должен оставаться на уровне 10 утра, даже когда пользователи смещаются от изменений в UTC между созданием и датой события из-за перехода на летнее время.

Итак, в июне 2016 года, если пользователь создаст событие для 1 января 2017 года в полночь в Сиднее, эта метка времени будет сохранена в базе данных как 2017-01-01 00:00. Смещение в момент создания будет +10: 00, но во время события itd будет +11: 00.. если правительство не решит изменить это тем временем.

Как и мудрый, Id ожидает отдельное событие, которое я создаю в течение 1 января 2016 года в полночь в Брисбене, чтобы оно также хранилось как 2017-01-01 00:00. Я сохраняю часовой пояс, т.е. Australia/Brisbane в отдельном поле.

Каков наилучший способ сделать это в Rails?

Ive пробовал множество вариантов без успеха:

1. Пропустить преобразование

Проблема, это только пропускает преобразование при чтении, а не в записи.

self.skip_time_zone_conversion_for_attributes = [:start_time]

2. Измените всю конфигурацию приложения, чтобы использовать config.default_timestamp :local

Для этого я устанавливаю:

конфигурации /application.rb

config.active_record.default_timezone = :local
config.time_zone = 'UTC'

приложение/модель/event.rb

...
self.skip_time_zone_conversion_for_attributes = [:start_time]
before_save :set_timezone_to_location
after_save :set_timezone_to_default

def set_timezone_to_location
  Time.zone = location.timezone
end

def set_timezone_to_default
  Time.zone = 'UTC'
end
...

Чтобы быть откровенным, я не уверен, что это делает. Но не то, что я хочу.

Я думал, что это работает, поскольку мое событие в Брисбене хранилось как 2017-01-01 00:00, но когда я создал новое событие для Сиднея, оно было сохранено как 2017-01-01 01:00, хотя оно отображается как полночь правильно в представлении.

В этом случае я обеспокоен тем, что все еще имеет ту же проблему с событием в Сиднее, которое я пытаюсь избежать.

3. Переопределите геттер и сеттер для сохранения модели как целого

Ive попытался также сохранить событие start_time как целое число в базе данных.

Я пробовал сделать это, обезьяна патчирует класс Time и добавляет обратный вызов before_validates для преобразования.

конфигурации/инициализаторы/time.rb

class Time
  def time_to_i
    self.strftime('%Y%m%d%H%M').to_i
  end
end

приложение/модель/event.rb

before_validation :change_start_time_to_integer
def change_start_time_to_integer
  start_time = start_time.to_time if start_time.is_a? String
  start_time = start_time.time_to_i
end

# read value from DB
# TODO: this freaks out with an error currently
def start_time
  #take integer YYYYMMDDHHMM and convert it to timestamp
  st = self[:start_time]
  Time.new(
    st / 100000000,
    st / 1000000 % 100,
    st / 10000 % 100,
    st / 100 % 100,
    st % 100,
    0,
    offset(true)
  )
end

Идеальное решение

Id нравится иметь возможность хранить временную метку в своем естественном типе данных в базе данных, поэтому запросы не становятся беспорядочными в моих контроллерах, но я не могу понять, как хранить "время стены", которое не конвертирует.

Во-вторых, Id рассчитывается для целочисленного варианта, если мне нужно.

Как другие справляются с этим? Что мне не хватает? В частности, с опцией "целочисленное преобразование" выше, я делаю вещи намного сложнее, чем они должны быть.

Ответ 1

Я предлагаю, чтобы вы по-прежнему использовали первый вариант, но с небольшим взломом: по сути, вы можете отключить изменение часового пояса для нужного атрибута и использовать настраиваемый сеттер для преодоления преобразования во время записи атрибутов.

Этот трюк сохраняет время как поддельное время UTC. Хотя технически он имеет зону UTC (так как все время сохраняется в db в UTC), но по определению он должен интерпретироваться как местное время, независимо от текущего часового пояса.

class Model < ActiveRecord::Base
  self.skip_time_zone_conversion_for_attributes = [:start_time]

  def start_time=(time)
    write_attribute(:start_time, time ? time + time.utc_offset : nil)
  end
end

Позвольте проверить это в консоли rails:

$ rails c
>> future_time = Time.local(2020,03,30,11,55,00)
=> 2020-03-30 11:55:00 +0200

>> Model.create(start_time: future_time)
D, [2016-03-15T00:01:09.112887 #28379] DEBUG -- :    (0.1ms)  BEGIN
D, [2016-03-15T00:01:09.114785 #28379] DEBUG -- :   SQL (1.4ms)  INSERT INTO `models` (`start_time`) VALUES ('2020-03-30 11:55:00')
D, [2016-03-15T00:01:09.117749 #28379] DEBUG -- :    (2.7ms)  COMMIT
=> #<Model id: 6, start_time: "2020-03-30 13:55:00">

Обратите внимание, что Rails сохранила время в 11:55 в "поддельной" зоне UTC.

Также обратите внимание, что время в объекте, возвращенном из create, неверно, потому что в этом случае зона будет преобразована из "UTC". Вы должны были бы посчитать с этим и перезагрузить объект каждый раз после установки атрибута start_time, чтобы можно было пропустить переход в зону:

>> m = Model.create(start_time: future_time).reload
D, [2016-03-15T00:08:54.129926 #28589] DEBUG -- :    (0.2ms)  BEGIN
D, [2016-03-15T00:08:54.131189 #28589] DEBUG -- :   SQL (0.7ms)  INSERT INTO `models` (`start_time`) VALUES ('2020-03-30 11:55:00')
D, [2016-03-15T00:08:54.134002 #28589] DEBUG -- :    (2.5ms)  COMMIT
D, [2016-03-15T00:08:54.141720 #28589] DEBUG -- :   Model Load (0.3ms)  SELECT  `models`.* FROM `models` WHERE `models`.`id` = 10 LIMIT 1
=> #<Model id: 10, start_time: "2020-03-30 11:55:00">

>> m.start_time
=> 2020-03-30 11:55:00 UTC

После загрузки объекта атрибут start_time является правильным и может быть вручную интерпретирован как локальное время независимо от фактического часового пояса.

Я действительно не понимаю, почему Rails ведет себя так, как это делает в отношении опции конфигурации skip_time_zone_conversion_for_attributes...

Обновление: добавление читателя

Мы также можем добавить читателя, чтобы мы автоматически интерпретировали сохраненное "поддельное" время UTC по местному времени, не меняя время из-за изменения часового пояса:

class Model < ActiveRecord::Base
  # interprets time stored in UTC as local time without shifting time
  # due to time zone change
  def start_time
    t = read_attribute(:start_time)
    t ? Time.local(t.year, t.month, t.day, t.hour, t.min, t.sec) : nil
  end
end

Тест в консоли rails:

>> m = Model.create(start_time: future_time).reload
D, [2016-03-15T08:10:54.889871 #28589] DEBUG -- :    (0.1ms)  BEGIN
D, [2016-03-15T08:10:54.890848 #28589] DEBUG -- :   SQL (0.4ms)  INSERT INTO `models` (`start_time`) VALUES ('2020-03-30 11:55:00')
D, [2016-03-15T08:10:54.894413 #28589] DEBUG -- :    (3.1ms)  COMMIT
D, [2016-03-15T08:10:54.895531 #28589] DEBUG -- :   Model Load (0.3ms)  SELECT  `models`.* FROM `models` WHERE `models`.`id` = 12 LIMIT 1
=> #<Model id: 12, start_time: "2020-03-30 11:55:00">

>> m.start_time
=> 2020-03-30 11:55:00 +0200

т.е. start_time корректно интерпретируется в локальное время, хотя он хранился как один и тот же час и минута, но в формате UTC.

Ответ 2

Это может звучать немного, но я столкнулся с подобными проблемами с недавним приложением, с которым мне было поручено, - но с другой стороны - когда я запускаю ETL для загрузки данных для приложения, даты из источника хранится в EST. Rails считает, что это UTC, когда вы обслуживаете данные, поэтому для этого я преобразовал даты в UTC с помощью P/SQL. Я не хотел, чтобы эти даты отличались от других полей даты в приложении.

Вариант A В этом случае вы могли бы зафиксировать часовой пояс пользователя при создании и отправить его в виде скрытого поля в форме? Я все еще изучаю RoR, поэтому не знаю, как правильно это сделать, но сейчас я бы сделал что-то вроде этого:

Пример (я проверил это, и он отправит смещение (минуты) в скрытое поле):

<div class="field">
  <%= f.hidden_field :offset %>
</div>

<script>
  utcDiff = new Date().getTimezoneOffset();
  dst_field = document.getElementById('timepage_offset');
  dst_field.value = utcDiff;
</script>

Если вы затем отправляете utcDiff вместе с выбранной пользователем датой, вы можете рассчитать дату UTC перед сохранением. Я полагаю, вы могли бы добавить это и в модель, если эти данные необходимо знать позже.

Я думаю, что независимо от того, как это будет сделано, всегда будет небольшая область для путаницы, если только пользователь не сможет предоставить правильную информацию, что приведет меня к...

Вариант B: Вы могли бы вместо скрытого поля предоставить список выбора (и быть дружественным, по умолчанию это локальное смещение пользователей), чтобы они могли предоставить зону, для которой указана дата.

Обновление - Выбор TimeZone Я провел некоторое исследование, и похоже, что в поле выбора часового пояса уже есть помощник формы.

Ответ 3

Я проголосовал за самый простой маршрут и за сохранение в UTC. Создайте столбец для страны происхождения, настройте оповещение google для "летнего времени", и если Чили решит прекратить использовать летнее сбережение или изменить его каким-то безумным способом, вы можете адаптироваться, запросив вашу базу данных для чилийских пользователей и отрегулировав их соответственно соответствует значению script. Затем вы можете использовать Time.parse с датой, временем и часовым поясом. Чтобы проиллюстрировать, вот результаты за день до перехода на летнее время и после перехода на летнее время на 3/13/2016:

Time.parse("2016-03-12 12:00:00 Pacific Time (US & Canada)").utc
=> 2016-03-12 20:00:00 UTC
Time.parse("2016-03-14 12:00:00 Pacific Time (US & Canada)").utc
=> 2016-03-14 19:00:00 UTC

Это даст вам список принятых имен часовых поясов:

timezones = ActiveSupport::TimeZone.zones_map.values.collect{|tz| tz.name}