Сохранение даты и времени в 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}