Как правильно преобразовать util.Date в time.LocalDate для дат до 1893 г.

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

date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();

Однако этот метод, по-видимому, не работает для дат до 1 апреля 1893 г.

Следующий тест не проходит на моей машине с результатом 1893-03-31 вместо 1893-04-01:

@Test
public void testBeforeApril1893() throws ParseException {
    Date date = new SimpleDateFormat("yyyy-MM-dd").parse("1893-04-01");

    System.out.println(date);

    LocalDate localDate2 = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();

    System.out.println(localDate2);

    assertEquals(1893, localDate2.getYear());
    assertEquals(4, localDate2.getMonth().getValue());
    assertEquals(1, localDate2.getDayOfMonth());
}

System.out.prinln нужны мне, чтобы перепроверить даты создания. Я вижу следующий вывод:

Sun Apr 02 00:00:00 CET 1893
1893-04-02
Sat Apr 01 00:00:00 CET 1893
1893-03-31

Для 1400-04-01 я даже получаю вывод 1400-04-09.

Есть ли способ правильно преобразовать даты до 1893-04 в LocalDate?

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


person Hengrui Jiang    schedule 28.06.2017    source источник
comment
Можете ли вы предоставить вывод System.out.println(date.getTime())?   -  person    schedule 28.06.2017
comment
А еще System.out.println(TimeZone.getDefault().toZoneId());   -  person    schedule 28.06.2017
comment
Помните, что желаемое преобразование из Date в LocalDate зависит от часового пояса. Если вы уверены, что ваш объект Date должен интерпретироваться в часовом поясе вашей JVM по умолчанию и в (пролептическом) григорианском календаре, ваш метод преобразования должен быть правильным.   -  person Ole V.V.    schedule 28.06.2017
comment
@Hugo date.getTime(): -2422054800000 zoneId: Европа/Берлин   -  person Hengrui Jiang    schedule 28.06.2017
comment
@Оле В.В. Дата должна интерпретироваться в часовом поясе по умолчанию. Что вы имеете в виду под пролептическим григорианским календарем? (кстати, надеюсь, понятно, что речь идет о java.time.LocalDate, а не о Joda)   -  person Hengrui Jiang    schedule 28.06.2017
comment
Это достаточно ясно, @HengruiJiang, я просто подумал, что проблема была той же или достаточно тесно связана, чтобы вы могли понять из другого вопроса и его ответов. Календари менялись на протяжении истории, и, в частности, разные страны ввели григорианский календарь (который мы с вами используем сегодня) в разные моменты, что привело к расхождениям в датах. Например, Октябрьская революция 1917 года названа так потому, что по русскому календарю она произошла 25 октября, а по григорианскому календарю - 7 ноября.   -  person Ole V.V.    schedule 28.06.2017
comment
@Оле В.В. Я подумал, что связанная проблема основана на той же проблеме, которая не может вывести из нее какое-либо решение...   -  person Hengrui Jiang    schedule 28.06.2017
comment
Если вы хотите отредактировать информацию о том, что вы не можете вывести решение из связанного вопроса в свой вопрос, и, возможно, объяснить, чего вам все еще не хватает, я буду рад нажать ссылку «повторно открыть». Однако мой голос сам по себе не откроет вопрос заново, и часто бывает трудно получить достаточное количество голосов для него, только что упомянутого, чтобы вы не возлагали слишком большие надежды.   -  person Ole V.V.    schedule 28.06.2017
comment
Давайте продолжим обсуждение в чате.   -  person Ole V.V.    schedule 28.06.2017


Ответы (3)


Если вы просто анализируете ввод String, это просто:

LocalDate d1 = LocalDate.parse("1893-04-01");
System.out.println(d1); // 1893-04-01
LocalDate d2 = LocalDate.parse("1400-04-01");
System.out.println(d2); // 1400-04-01

Результат:

1893-04-01
1400-04-01


Но если у вас есть объект java.util.Date и вам нужно его преобразовать, это немного сложнее.

java.util.Date содержит количество миллисекунд от эпохи Unix (1970-01-01T00:00Z). Таким образом, вы можете сказать «это в формате UTC», но когда вы его печатаете, значение «конвертируется» в системный часовой пояс по умолчанию (в вашем случае это CET). И SimpleDateFormat также использует внутренний часовой пояс по умолчанию (непонятными способами, которые, я должен признать, я не совсем понимаю).

В вашем примере значение миллисекунды -2422054800000 эквивалентно моменту UTC 1893-03-31T23:00:00Z. Проверка этого значения в часовом поясе Europe/Berlin:

System.out.println(Instant.ofEpochMilli(-2422054800000L).atZone(ZoneId.of("Europe/Berlin")));

Результат:

1893-03-31T23:53:28+00:53:28[Европа/Берлин]

Да, это очень странно, но до 1900 года все места использовали странные смещения - в каждом городе было свое местное время, до того, как появился стандарт UTC. Это объясняет, почему вы получаете 1893-03-31. Объект Date печатает April 1st, вероятно, потому, что старый API (java.util.TimeZone) не имеет всей истории смещений, поэтому предполагается, что это +01:00.

Один из способов заставить это работать — всегда использовать UTC в качестве часового пояса:

SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setTimeZone(TimeZone.getTimeZone("UTC")); // set UTC to the format
Date date = sdf.parse("1893-04-01");
LocalDate d = date.toInstant().atZone(ZoneOffset.UTC).toLocalDate();
System.out.println(d); // 1893-04-01

Это позволит получить правильную местную дату: 1893-04-01.


Но для дат до 1582-10-15 приведенный выше код не работает. Это дата введения григорианского календаря. До этого использовался юлианский календарь, и даты до него требуют корректировки.

Я мог бы сделать это с помощью проекта ThreeTen Extra (расширение java.time классов, созданное тем же парень кстати). В пакете org.threeten.extra.chrono есть классы JulianChronology и JulianDate:

// using the same SimpleDateFormat as above (with UTC set)
date = sdf.parse("1400-04-01");
// get julian date from date
JulianDate julianDate = JulianChronology.INSTANCE.date(date.toInstant().atZone(ZoneOffset.UTC));
System.out.println(julianDate); // Julian AD 1400-04-01

Вывод будет:

Юлиан н.э. 1400-04-01

Теперь нам нужно преобразовать JulianDate в LocalDate. Если я делаю LocalDate.from(julianDate), он преобразуется в григорианский календарь (и результат 1400-04-10).

Но если вы хотите создать LocalDate ровно с 1400-04-01, вам придется сделать это:

LocalDate converted = LocalDate.of(julianDate.get(ChronoField.YEAR_OF_ERA),
                                   julianDate.get(ChronoField.MONTH_OF_YEAR),
                                   julianDate.get(ChronoField.DAY_OF_MONTH));
System.out.println(converted); // 1400-04-01

Вывод будет:

1400-04-01

Просто имейте в виду, что даты до 1582-10-15 имеют эту корректировку, и SimpleDateFormat не может правильно обрабатывать такие случаи. Если вам нужно работать только с 1400-04-01 (значения года/месяца/дня), используйте файл LocalDate. Но если вам нужно преобразовать его в java.util.Date, имейте в виду, что это может быть не та же дата (из-за корректировок по григорианскому/юлианскому календарю).


Если вы не хотите добавлять еще одну зависимость, вы также можете выполнить всю математику вручную. Я адаптировал код из ThreeTen, но IMO идеально использовать сам API (поскольку он может охватывать крайние случаи и другие вещи, которые я, вероятно, упустил, просто скопировав фрагмент кода):

// auxiliary method
public LocalDate ofYearDay(int prolepticYear, int dayOfYear) {
    boolean leap = (prolepticYear % 4) == 0;
    if (dayOfYear == 366 && leap == false) {
        throw new DateTimeException("Invalid date 'DayOfYear 366' as '" + prolepticYear + "' is not a leap year");
    }
    Month moy = Month.of((dayOfYear - 1) / 31 + 1);
    int monthEnd = moy.firstDayOfYear(leap) + moy.length(leap) - 1;
    if (dayOfYear > monthEnd) {
        moy = moy.plus(1);
    }
    int dom = dayOfYear - moy.firstDayOfYear(leap) + 1;
    return LocalDate.of(prolepticYear, moy.getValue(), dom);
}

// sdf with UTC set, as above
Date date = sdf.parse("1400-04-01");
ZonedDateTime z = date.toInstant().atZone(ZoneOffset.UTC);

LocalDate d;
// difference between the ISO and Julian epoch day count
long julianToIso = 719164;
int daysPerCicle = (365 * 4) + 1;
long julianEpochDay = z.toLocalDate().toEpochDay() + julianToIso;
long cycle = Math.floorDiv(julianEpochDay, daysPerCicle);
long daysInCycle = Math.floorMod(julianEpochDay, daysPerCicle);
if (daysInCycle == daysPerCicle - 1) {
    int year = (int) ((cycle * 4 + 3) + 1);
    d = ofYearDay(year, 366);
} else {
    int year = (int) ((cycle * 4 + daysInCycle / 365) + 1);
    int doy = (int) ((daysInCycle % 365) + 1);
    d = ofYearDay(year, doy);
}
System.out.println(d); // 1400-04-01

Вывод будет:

1400-04-01

Просто напомню, что вся эта математика не нужна для дат после 1582-10-15.


В любом случае, если у вас есть ввод String и вы хотите его разобрать, не используйте SimpleDateFormat — вместо этого вы можете использовать LocalDate.parse(). Или LocalDate.of(year, month, day), если вы уже знаете значения.

Но преобразовать эти локальные даты из/в java.util.Date сложнее, потому что Date представляет собой полную метку времени в миллисекундах, а даты могут варьироваться в зависимости от используемой календарной системы.

person Community    schedule 28.06.2017
comment
На самом деле я не пытаюсь анализировать ввод String, а преобразовываю существующую дату в LocalDate. Я использовал только String SimpleDateFormat для создания даты для теста. - person Hengrui Jiang; 28.06.2017
comment
@HengruiJiang Я также объясняю, как преобразовать его, используя JulianChronology. - person ; 28.06.2017
comment
Есть ли решение, которое не включает дополнительные зависимости? Я бы предпочел не добавлять никаких зависимостей... - person Hengrui Jiang; 28.06.2017
comment
@HengruiJiang Я обновил ответ решением без зависимостей. Но я все еще думаю, что добавление этой зависимости лучше, потому что я мог упустить некоторые детали (я только что адаптировал часть кода API, но не уверен, что он будет работать для всех необходимых вам случаев) - person ; 28.06.2017
comment
Большое спасибо! Насколько я правильно понял, я мог бы также преобразовать в григорианский календарь, а затем создать новую LocalDate с годом, месяцем и днем, если мне не нужно иметь дело с датами до 1583 года? В моем случае этого было бы достаточно - person Hengrui Jiang; 28.06.2017
comment
Для дат до 15 10 1583 вы делаете это преобразование из григорианского в юлианский. Для дат после этого вам не нужно делать всю эту математику (просто работайте с UTC, как объяснено). - person ; 28.06.2017

Кажется, это известная ошибка, которая не будет исправлена: https://bugs.openjdk.java.net/browse/JDK-8061577

После долгих исследований я отказался от всех простых методов API и просто конвертировал их вручную. Вы можете обернуть дату в sql.Date и вызвать toLocalDate() или просто использовать те же устаревшие методы, что и sql.Date. Без устаревших методов вам нужно преобразовать util.Date в Calendar и получить поля одно за другим:

    Calendar calendar = Calendar.getInstance();
    calendar.setTime(value);
    return LocalDate.of(calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH) + 1,
                        calendar.get(Calendar.DAY_OF_MONTH));

Если вы в дальнейшем хотите иметь двузначное преобразование года, например, в SimpleDateFormat (преобразование даты в диапазоне от настоящего момента - 80 лет до настоящего времени + 19 лет), вы можете использовать эту реализацию:

    Calendar calendar = Calendar.getInstance();
    calendar.setTime(value);
    int year = calendar.get(Calendar.YEAR);
    if (year <= 99) {
        LocalDate pivotLocalDate = LocalDate.now().minusYears(80);
        int pivotYearOfCentury = pivotLocalDate.getYear() % 100;
        int pivotCentury = pivotLocalDate.minusYears(pivotYearOfCentury).getYear();
        if (year < pivotYearOfCentury) {
            year += 100;
        }
        year += pivotCentury;
    }
    return LocalDate.of(year, calendar.get(Calendar.MONTH) + 1, calendar.get(Calendar.DAY_OF_MONTH));

Вывод: это действительно уродливо, и я не могу поверить, что нет никакого простого API!

person cornz    schedule 28.06.2017
comment
В моем случае этого решения было бы достаточно, но я принял ответ Хьюго, поскольку он предоставил более подробную информацию. - person Hengrui Jiang; 28.06.2017

Этот код работает для меня:

@Test
public void oldDate() throws ParseException {
    Date date = new SimpleDateFormat("yyyy-MM-dd").parse("1893-04-01");
    assertEquals("1893-04-01", String.format("%tF", date));
}
person Artem Lukanin    schedule 07.12.2018