Дано

Angular, PrimeNG, Spring Boot, JDBC, PostgreSQL

Надо

Передавать дату с формы в базу и обратно

Подготовка

create database test_date;
CREATE TABLE test_table (
	test_date date NULL,
	test_timestamp timestamp NULL,
	test_timestamptz timestamptz NULL,
	id serial2,
	CONSTRAINT test_table_pk PRIMARY KEY (id)
);

java.util.Date

Решение 1

Надо сохранять только дату без времени. Использую колонку с типом date.

Выбираю на форме дату 2020-12-22. На сервер отправится 2020-12-21T21:00:00.000Z. Это текущее время по UTC, так как браузер в зоне +3. Java сделает запрос

statement.setObject(1, entity.getTestDate(), Types.DATE)
insert into test_table (test_date) values ('2020-12-22 +03')

Java отбрасывает время и передает автоматически таймзону (по умолчанию зона сервера или -Duser.timezone=Europe/Moscow). Postgres не учитывает зону для типа данных date. Будет сохранено 2020-12-22. При чтении из базы вернется эта же дата. В Json попадет

{ "testDate": "2020-12-22" }

Браузер прочитает такой формат, как начало дня по UTC.

new Date('2020-12-22')
new Date('2020-12-22T00:00:00.000+00:00')
Tue Dec 22 2020 03:00:00 GMT+0300 (Moscow Standard Time)

Т.е на форме отображается 2020-12-22 03:00 или просто без времени 2020-12-22. Все верно.

Я встречал ситуацию , когда Chrome и Firefox интерпретировали дату без времени по-разному. Кто-то как начало дня по локальному времени. В данный момент такое не воспроизводится на обновленных версиях. Документация говорит, что сейчас такой формат стандартизирован. Но если строка отличается от формата 2020-12-22T00:00:00.000+00:00, то поведение не гарантировано.

Ошибка всплывет только если начнет тестировать пользователь, который восточнее часового пояса сервера. Например Europe/Samara (+4). Выберет на форме 2020-12-22. На сервер отправится 2020-12-21T20:00:00.000Z (2020-12-22 00:00 +4). Сервер (работает в зоне +3) переведет это в 2020-12-21T23:00:00.000+03:00, отбросит время и сохранит как

insert into test_table (test_date) values ('2020-12-21 +03')

При чтении сервер отдаст 2020-12-21, что превратится в 2020-12-21 04:00. На форме видим 2020-12-21. Ошибка.

Решение 2

При сохранении в БД указать временную зону пользователя, а не зону сервера.

statement.setDate(1, new java.sql.Date(entity.getTestDate().getTime()),
    Calendar.getInstance(TimeZone.getTimeZone(userZoneId)));

Получить её можно отдельным параметром в запросе. Для этого в JS можно выполнить.

Intl.DateTimeFormat().resolvedOptions().timeZone;

Можно попробовать с полифилом новый API Temporal.now().timeZone().id. Этот параметр должен содержать зону по умолчанию. На старых браузерах может не работать или возвращать неправильную зону.

Запрос на сервер:

{"testDate":"2020-12-21T20:00:00.000Z","zoneId":"Europe/Samara"}

Сохранение:

insert into test_table (test_date) values ('2020-12-22 +04')

Зная зону, драйвер преобразовал 2020-12-21T23:00:00.000+03:00 в 2020-12-22T00:00:00.000+04:00, и сформировал строку 2020-12-22 +04. При чтении получится 2020-12-22 -> 2020-12-22 04:00:00. На форме видим 2020-12-22. Все верно.

Теперь протестируем ситуацию, когда пользователь к западу от UTC. Например America/Chicago (-6). Сохранится выбранная дата 2020-12-22. При чтении сервер отдаст её обратно, но она превратится в 2020-12-21 18:00 по местному времени пользователя и отобразится как 2020-12-21.

Решение 3

Надо , чтобы сервер отдавал дату с временем 2020-12-22T00:00:00.000 и без зоны, тогда это будет преобразовано браузером в начало дня по местному времени. Для этого сделаю сериалайзер даты

import java.text.SimpleDateFormat;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;

public class DateSerializer extends JsonSerializer<Date> {

  private final static SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");

  @Override
  public void serialize(Date value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
    gen.writeString(format.format(value));
  }
}

Теперь все верно. Выбранная пользователем дата сохраняется в БД правильно. И возвращается на форму правильно. Проверено на разных таймзонах. Можно на прод.

С прода приходит баг. Пользователи видят неправильную дату. И смещение не на один день, а вообще не та дата.

Решение 4

Метод java.text.SimpleDateFormat.format() не потокобезопасный. А я создал его один раз на все приложение. Надо для каждой сериализации делать свой экземпляр.

private static final String format = "yyyy-MM-dd'T'HH:mm:ss";

@Override
public void serialize(Date value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
  gen.writeString(new SimpleDateFormat(format).format(value));
}

Получился сервис со странным интерфейсом для сохранения даты. В другом клиенте придется передавать не только дату, но и рассчитывать время и передавать зону, для которой это время рассчитано. Если я захочу сохранить 2020-12-22, то мне надо сначала определится с зоной. Если это +3, то надо передавать

{"testDate":"2020-12-21T21:00:00.000Z","zoneId":"Europe/Moscow"}

или

{"testDate":"2020-12-22T00:00:00.000+03:00","zoneId":"Europe/Moscow"}

В последнем варианте вообще получилось дублирование нужной информации о смещении. Надо убрать из запроса зону и оставить дату в формате 2020-12-22. Если придет 2020-12-21T21:00:00.000Z, то игнорировать время с зоной – сохранять как 2020-12-21). Возвращаться результат должен тоже без времени.

Решение 5

Убираю свой сериалайзер из java. На фронте надо обработать 2020-12-22 без времени, чтобы это было начало дня по локальному времени.

const ymd: string[] = obj.testDate.split('-');
const date: Date = new Date(ymd);

Это удобно и оно работает в Chrome и Firefox. Но конструктор с параметром Array не описан в стандарте. Поэтому параметр будет преобразован в строку и передан в Date.parse(). А этот метод стандартно работает только для 2020-12-22.

Поэтому напишу по стандарту

const ymd: number[] = obj.testDate.split('-').map((s: string) => Number(s));
const date: Date = new Date(ymd[0], ymd[1] - 1, ymd[2])

Следующим шагом надо отбрасывать время при сохранении. Это уже делает JDBC. Но это вызывает ошибку для пользователей с востока. Так как время отбрасывается от даты по серверному времени. Поэтому время надо отбрасывать до конвертации строки в дату по серверному времени. Тут появляется ещё проблема: браузер отправляет дату, как начало дня по времени UTC. Т.е код надо писать ещё перед отправкой на фронте.

public saveEntity(entity: TestEntity): Observable<number> {
  const date: Date = entity.testDate;
  const testDate: string = [date.getFullYear(), date.getMonth() + 1, date.getDate()]
    .map(n => String(n).padStart(2, '0')).join('-');
  const body: any = Object.assign({}, entity, {testDate});
  return this.http.post<number>(CONTROLLER, body);
}

Такой вариант работает, для пользователей из всех зон работает. И не надо ничего программировать на сервере.

Потом на проекте появляется разработчик из Чикаго. И тестирует приложение у себя. На сервер отправляется 2020-12-22. Сервер превращает это в 2020-12-21 18:00:00 по местному времени. И сохраняет

insert into test_table (test_date) values ('2020-12-21 -06')

Ошибка. Решение работает только для сервера к востоку от UTC.

Решение 6

Самое простое решение – захардкодить зону приложения.

System.setProperty("user.timezone", "UTC")

Но не совсем правильное. Что делать, если в приложении уже куча логики зависит от того, что сервер находится где-то по местному времени на западе? Проблема в том, что Jackson воспринимает полученную дату как начало дня по UTC. A я хотел, чтобы дата была началом дня для сервера.Тогда надо захардкодить эту зону и указать Jackson, что для конвертации надо использовать зону сервера.

public static final String APP_TIMEZONE = "America/Chicago";
public static void main(String[] args) {
    System.setProperty("user.timezone", APP_TIMEZONE);
    SpringApplication.run(TestDateApplication.class, args);
}
import com.fasterxml.jackson.annotation.JsonFormat;

public class TestEntity {

    @JsonFormat(timezone = TestDateApplication.APP_TIMEZONE,
                pattern = "yyyy-MM-dd")
    private Date testDate;

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

Решение 7

Jackson пропускает такие даты, не учитывая время. Поэтому надо писать свой десериалайзер. Он будет выбрасывать исключение, если строка длиннее заданного формата.

import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.exc.InvalidFormatException;

public class DateDeserializer extends JsonDeserializer<Date> {

    private static final String format = "yyyy-MM-dd";

    @Override
    public Date deserialize(JsonParser p, DeserializationContext ctxt)
            throws IOException, JsonProcessingException {
        if (p.hasToken(JsonToken.VALUE_STRING)) {
            String text = p.getText().trim();
            if (text.length() != format.length()) {
                throw new InvalidFormatException(p, "Wrong date", text, Date.class);
            }
            try {
                Date result = new SimpleDateFormat(format).parse(text);
                return result;
            } catch (ParseException e) {
                throw new InvalidFormatException(p, "Wrong date", text, Date.class);
            }
        }
        return (Date) ctxt.handleUnexpectedToken(Date.class, p);
    }
}

Если сохранить дату на востоке, а открыть на западе, то она будет одинакова. Но на западе может быть еще только вчера. Это может быть принято за ошибку. Зависит от задачи. Если речь о дате рождения, то ошибки нет. Если о дате публикации новости, то читатель на западе увидит новость из будущего. Это может выглядеть странно.

Решение 8

Для такого случая придется сохранять время вместе с датой. От времени зависит, одинаковая дата для разных часовых поясов в это время или разная. Для сохранения можно использовать два типа: timestamp или timestamp with time zone. Зону мне хранить, вроде бы, не надо, поэтому сделаю timestamp.

private static final String COLUMN_LABEL = "test_timestamp";

entity.setTestDate(rs.getTimestamp(COLUMN_LABEL));

statement.setTimestamp(1, new Timestamp(entity.getTestDate().getTime()));

С фронта поступит дата 2020-12-21T20:00:00.000Z. Будет передана в базу как

insert into test_table (test_timestamptz) values ('2020-12-21 14:00:00-06')

И сохранена в базе как время 2020-12-21 14:00:00. На фронт придет время с указанием зоны 2020-12-21T20:00:00.000+00:00 и будет показано локальное время. Работает.

Беда придет, если изменится таймзона сервера. Время в базе сохранено по зоне сервера. При чтении на сервере с другой таймзоной будет неправильное время. Из 2020-12-21 14:00:00 на сервере Europe/Moscow получится 2020-12-21T11:00:00.000+00:00. А должно было быть 2020-12-21T20:00:00.000+00:00.

Решение 9

Либо сервер должен быть всегда в одной зоне. Либо надо хранить даты в одной зоне и явно это указывать. Так как сервер был раньше в America/Chicago и время сохранено в такой зоне, то я укажу эту зону

private static final String COLUMN_TIMEZONE = "America/Chicago";

entity.setTestDate(rs.getTimestamp(COLUMN_LABEL,
    Calendar.getInstance(TimeZone.getTimeZone(COLUMN_TIMEZONE))));

statement.setTimestamp(1, new Timestamp(entity.getTestDate().getTime()),
    Calendar.getInstance(TimeZone.getTimeZone(COLUMN_TIMEZONE)));

Чтобы было легче дебажить, лучше сделать зону UTC. И в базе перевести время на UTC.

update test_table
set test_timestamp =
    (test_timestamp at time zone 'America/Chicago') at time zone 'UTC';
private static final String COLUMN_TIMEZONE = "UTC";

С фронта поступит дата 2020-12-21T20:00:00.000Z. Будет передана в базу как

insert into test_table (test_timestamp) values ('2020-12-21 20:00:00+00')

И сохранена в базе как время 2020-12-21 20:00:00. При чтении сервер получит 2020-12-21 14:00:00 по своему времени (-6). На фронт придет время с указанием зоны 2020-12-21T20:00:00.000+00:00 и будет показано локальное время.

Решение 10

Получилось тоже самое, что можно было сделать сразу, используя timestamp with time zone. Данный тип не хранит зону. Он хранит время для зоны UTC и автоматически конвертирует его во время для другой зоны. Поэтому код можно переписать без указания зоны при сохранении и чтении.

private static final String COLUMN_LABEL = "test_timestamptz";

entity.setTestDate(rs.getTimestamp(COLUMN_LABEL));

statement.setTimestamp(1, new Timestamp(entity.getTestDate().getTime()));

С фронта поступит дата 2020-12-21T20:00:00.000Z. Будет передана в базу как

insert into test_table (test_timestamptz) values ('2020-12-21 14:00:00-06')

И сохранена в базе независимо от зоны сервера как время 2020-12-21T20:00:00.000Z. При чтении сервер получит 2020-12-21 14:00:00 по своему времени. На фронт придет время с указанием зоны 2020-12-21T20:00:00.000+00:00 и будет показано локальное время.

Решение 11

Надо чтобы время показывалось всегда то, что ввели, и не зависело от временной зоны браузера или сервера. Для передачи по сети буду использовать формат без указания зоны. Для хранения буду использовать колонку timestamp.

public saveEntity(entity: TestEntity): Observable<number> {
	const date: Date = entity.testDate;
	const testDate: string = [date.getFullYear(), date.getMonth() + 1, date.getDate()]
		.map(n => String(n).padStart(2, '0')).join('-')
		+ 'T' + [date.getHours(), date.getMinutes(), date.getSeconds()]
		.map(n => String(n).padStart(2, '0')).join(':');
	const body: any = Object.assign({}, entity, {testDate});
	return this.http.post<number>(CONTROLLER, body);
}
@JsonDeserialize(using = DateDeserializer.class)
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = TestDateApplication.APP_TIMEZONE)
private Date testDate;

В десериалайзере:

Date result = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss").parse(text);

При выборе на форме 2020-12-22 14:14 отправится 2020-12-22T14:14:00. Это воспринимается как локальное время. И будет отправлено в базу.

insert into test_table (test_timestamp) values ('2020-12-22 14:14:00+04')

Так как тип колонки timestamp without time zone, то переданная зона просто будет отброшена, и дополнительной конвертации не будет. При чтении все также. Отобразится тоже, что сохраняли, в любом часовом поясе.

new Date('2020-12-22T14:14:00')
Tue Dec 22 2020 14:14:00 GMT-0600 (Central Standard Time)

Time API

Решение 1

Сохраняю только дату. Колонка date. Тип поля DTO LocalDate.

private LocalDate testDate;

statement.setObject(1, entity.getTestDate(), Types.DATE);

entity.setTestDate(rs.getObject("test_date", LocalDate.class));

Выберу на форме 2020-12-22. От браузера придет 2020-12-21T21:00:00.000Z. Jackson превратит это в LocalDateTime для зоны UTC и отбросит время.

insert into test_table (test_date) values ('2020-12-21'::date)

В браузер вернется 2020-12-21. Неверно.

new Date('2020-12-21')
Mon Dec 21 2020 03:00:00 GMT+0300 (Moscow Standard Time)

Решение 2

Надо делать десериализацию с таймзоной сервера.

public LocalDate deserialize(JsonParser p, DeserializationContext ctxt)
        throws IOException, JsonProcessingException {
    if (p.hasToken(JsonToken.VALUE_STRING)) {
        String text = p.getText().trim();
        try {
            DateTimeFormatter formatter = new DateTimeFormatterBuilder()
                    .append(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
                    .appendZoneId()
                    .toFormatter();
            LocalDate result = ZonedDateTime.parse(text, formatter)
                    .withZoneSameInstant(ZoneId.systemDefault())
                    .toLocalDate();
            return result;
        } catch (Exception e) {
            throw new InvalidFormatException(p, "Wrong date", text, Date.class);
        }
    }
    return (LocalDate) ctxt.handleUnexpectedToken(LocalDate.class, p);
}

Теперь сохраняется введенная дата.

insert into test_table (test_date) values ('2020-12-22'::date)

Но это не будет работать для пользователя восточнее сервера.

Решение 3

Можно пойти путем передачи зоны пользователя. Тогда надо сменить тип поля на тип с временем: LocalDateTime.

String zoneId = entity.getZoneId();
statement.setObject(1,
    ZonedDateTime.of(entity.getTestDate(), ZoneId.systemDefault())
    .withZoneSameInstant(ZoneId.of(zoneId))
    .toLocalDate(),
    Types.DATE);

entity.setTestDate(
    LocalDateTime.of(rs.getObject(COLUMN_LABEL, LocalDate.class), LocalTime.MIN));

Обратно с сервера вернется дата уже с временем 2021-12-22T00:00:00. Поэтому это решение будет работать и для пользователей с запада от UTC.

Решение 4

Если формировать дату без времени на фронте, то на сервере можно оставить только поле с типом LocalDate. И больше не делать дополнительных конвертаций.

statement.setObject(1, entity.getTestDate(), Types.DATE);

entity.setTestDate(rs.getObject(COLUMN_LABEL, LocalDate.class));

Это решение работает, если даже переместить сервер в другую зону.

Чтобы предотвратить ошибку из-за передачи даты в формате ISO по времени UTC, достаточно указать формат. На дополнительные символы будет ругаться.

@JsonFormat(pattern = "yyyy-MM-dd")
private LocalDate testDate;

Решение 5

Чтобы сохранить время можно использовать LocalDateTime. Но для конвертации строки 2020-12-21T20:00:00.000Z в локальное время нужен десериалайзер с использованием ZoneDateTime. Поэтому буду использовать сразу его.

statement.setObject(1,
    entity.getTestDate()
        .withZoneSameInstant(ZoneId.systemDefault())
        .toLocalDateTime(),
    Types.TIMESTAMP);

entity.setTestDate(
    ZonedDateTime.of(
        rs.getObject(COLUMN_LABEL, LocalDateTime.class),
        ZoneId.systemDefault()
        )
    );

При изменении зоны сервера даты поедут.

Решение 6

Укажу явно зону UTC для хранения времени.

private static final String COLUMN_TIMEZONE = "UTC";

statement.setObject(1,
    entity.getTestDate()
      .withZoneSameInstant(ZoneId.of(COLUMN_TIMEZONE))
      .toLocalDateTime(),
    Types.TIMESTAMP);

entity.setTestDate(
    ZonedDateTime.of(
        rs.getObject(COLUMN_LABEL, LocalDateTime.class),
        ZoneId.of(COLUMN_TIMEZONE)
        )
    );

Решение 7

Теперь можно перейти на тип колонки timestamptz.

Чтобы сохранить ZonedDateTime в такую колонку, можно использовать LocalDateTime, но обязательно сконвертировав в зону сервера. Потому что JDBC сам добавит в запрос смещение на основе зоны сервера. Postgres его учтет для конвертации в UTC.

statement.setObject(1,
    entity.getTestDate()
      .withZoneSameInstant(ZoneId.systemDefault())
      .toLocalDateTime());
insert into test_table (test_timestamptz) values ('2020-12-21 23:30:00+03'::timestamp)

Можно сохранять OffsetDateTime. Тогда будет передано то, что пришло из браузера.

statement.setObject(1, entity.getTestDate().toOffsetDateTime());
insert into test_table (test_timestamptz)
values ('2020-12-21 20:30:00+00'::timestamp with time zone)

Читать из базы драйвер позволяет только в OffsetDateTime.

entity.setTestDate(
    rs.getObject(COLUMN_LABEL, OffsetDateTime.class).toZonedDateTime()
    );

Поэтому поле DTO можно сразу переделать в OffsetDateTime.

Решение 8

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

public saveEntity(entity: TestEntity): Observable<number> {
  const date: Date = entity.testDate;
  const testDate: string =
    [date.getFullYear(), date.getMonth() + 1, date.getDate()]
      .map(n => String(n).padStart(2, '0')).join('-')
    + 'T'
    + [date.getHours(), date.getMinutes(), date.getSeconds()]
		  .map(n => String(n).padStart(2, '0')).join(':');
  const body: any = Object.assign({}, entity, {testDate});
  return this.http.post<number>(CONTROLLER, body);
}

На сервере использовать LocalDateTime и timestamp. Дополнительная настройка Jackson не нужна. При передаче времени с зоной он будет ругаться.

statement.setObject(1, entity.getTestDate());

entity.setTestDate(rs.getObject(COLUMN_LABEL, LocalDateTime.class));

Заключение

Чтобы избежать некоторых ошибок, надо изначально договорится о некоторых вещах. Формат даты в запросе и ответе. Зона, в которой будет запущен сервер. Зона, в которой хранится время.

Дополнительные ошибки могут появится, из-за устаревшей tzdata. PostgreSQL имеет свою tzdata. Если есть колонки timestamptz, то эта база используется. Надо обновлять минорные релизы. PostgreSQL может быть собран с флагом with-system-tzdata. Тогда надо обновлять системные зоны. Java имеет свою tzdata. Надо тоже обновлять. Можно отдельно от всей jre. Joda-time имеет свою tzdata.

Все решения доступны в репозитории https://github.com/Qwertovsky/test_date/commits/. Там две ветки.

Let’s block ads! (Why?)

Read More

Recent Posts

Apple возобновила переговоры с OpenAI и Google для интеграции ИИ в iPhone

Apple возобновила переговоры с OpenAI о возможности внедрения ИИ-технологий в iOS 18, на основе данной операционной системы будут работать новые…

23 часа ago

Российская «дочка» Google подготовила 23 иска к крупнейшим игрокам рекламного рынка

Конкурсный управляющий российской «дочки» Google подготовил 23 иска к участникам рекламного рынка. Общая сумма исков составляет 16 млрд рублей –…

1 день ago

Google завершил обновление основного алгоритма March 2024 Core Update

Google завершил обновление основного алгоритма March 2024 Core Update. Раскатка обновлений была завершена 19 апреля, но сообщил об этом поисковик…

1 день ago

Нейросети будут писать тексты объявления за продавцов на Авито

У частных продавцов на Авито появилась возможность составлять текст объявлений с помощью нейросети. Новый функционал доступен в категории «Обувь, одежда,…

1 день ago

Объявлены победители международной премии Workspace Digital Awards-2024

24 апреля 2024 года в Москве состоялась церемония вручения наград международного конкурса Workspace Digital Awards. В этом году участниками стали…

2 дня ago

Яндекс проведет гик-фестиваль Young Con

27 июня Яндекс проведет гик-фестиваль Young Con для студентов и молодых специалистов, которые интересуются технологиями и хотят работать в IT.…

2 дня ago