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

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

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

Однако, этот метод, кажется, терпит неудачу для дат до 1893-04-01

Следующий тест завершился неудачно на моей машине с результатом 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?

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

Ответ 1

Если вы просто разбираете ввод 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 и его нужно преобразовать, это немного сложнее.

A 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 [Europe/Berlin]

Да, это очень странно, но во всех местах использовались странные смещения до 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, созданное тем же парнем BTW). В пакете 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

Выход будет:

Julian AD 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, но ИМО идеалом является использование самого 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), если вы уже знаете значения.

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

Ответ 2

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

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

    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!

Ответ 3

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

@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));
}