Java — «написано однажды работает везде», но увы, не везде одинаково

Готовя тесты к лекции про пакет java.time столкнулся с проблемой, несовместимости работы JDK на разных платформах. Вообще с Java работаю с 2007 г, и с аналогичными проблемами не сталкивался. Возможно просто везло.

Итак, имеется в наличии 3 компьютера

  • Ноутбук HP с операционной системой Windows 10
  • Ноутбук Apple с операционной системой MacOs 10
  • Виртуальный сервер в облаке с операционной системой CentOs 7

На всех установлена свежая версия JDK 13.0.2. И вот этот, простой пример, на всех 3-х системах работает по разному:

System.out.println(Instant.now());

На Windows : 2020-01-31T04:36:29.797244400Z

На CentOs    : 2020-01-31T04:36:29.797244Z

На MacOs    : 2020-01-31T04:36:29.797Z

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

String strInstatnt = "2020-01-31T04:36:29.797Z";
DateTimeFormatter formatter = 
    DateTimeFormatter.
        ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS'Z'").
        withZone(ZoneOffset.UTC);
Instant instant = Instant.from(formatter.parse(strInstatnt));
Exception in thread "main" java.time.format.DateTimeParseException: Text '2020-01-31T04:36:29.797Z' could not be parsed at index 20

Укорачивание формата до нужного количества долей секунды решает проблему:

String strInstatnt = "2020-01-31T04:36:29.797Z";
DateTimeFormatter formatter = 
    DateTimeFormatter.
        ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").
        withZone(ZoneOffset.UTC);
Instant instant = Instant.from(formatter.parse(strInstatnt));

Этот код, несомненно, сработает, но как быть, если к нам приходит разное количество долей секунд? Казалось, эту проблему может решить optional section [] описанная в документации. Но, увы, она работает только на все секцию целиком, а не на часть, и вариант формата «yyyy-MM-dd’T’HH:mm:ss.SSS[SSSSSS]’Z'» не будет работать для 9 символов в долях секунды.

Что же делать? Поиск по уважаемому StackOverflow дает не очень интересные решения — парсить 3 раза, с форматами 3, 6 и 9 символов, обрабатывая DateTimeParseException, если таковое возникает. Не очень эффективная стратегия. Если уж идти этим путем, я бы лучше просто взял strInstatnt.length(), из него вычислил количество знаков в дробной части секунд

String strInstatnt = "2020-01-31T04:36:29.797Z";
String fractional = "S".repeat(strInstatnt.length()-21);
DateTimeFormatter formatter =
    DateTimeFormatter.
         ofPattern("yyyy-MM-dd'T'HH:mm:ss."+fractional+"'Z'").
         withZone(ZoneOffset.UTC);
Instant instant = Instant.from(formatter.parse(strInstatnt));

Такой код будет работать во всех случаях безошибочно.

Но, что самое интересное — самый простой вариант тоже работает во всех 3-х случая без проблем! Это использование метода parse у Instant

String strInstatnt = "2020-01-31T04:36:29.79Z";
System.out.println(Instant.parse(strInstatnt));

И работает это при любом количестве цифр в дробной части секунд.

Мораль — либо пользуйтесь везде одним и тем же форматом, с фиксированным количеством знаков после точки, либо гибким парсингом, позволяющим указывать произвольную точность долей секунды.

Но на этом несовместимости не заканчиваются, поехали дальше. Рассмотрим такой код:

Locale loc = new Locale("en", "US");
DateTimeFormatter formatter =
       DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss.SSS Z zzzz", loc);
ZonedDateTime zoned = Instant.now().atZone(ZoneId.of("Europe/Paris"));
System.out.println(formatter.format(zoned));

Вся разница заключается в именах часовых поясов в различных locale. Но тут разница не в операционных системах, а в версиях JDK. 

Locale loc = new Locale("en", "US"):
JDK 13 : 31.01.2020 07:07:04.209 +0100 Central European Standard Time
JDK   8 : 31.01.2020 07:07:04.209 +0100 Central European Time
Locale loc = new Locale("fr", "FR"):
JDK 13 : 31.01.2020 07:08:43.660 +0100 heure normale d’Europe centrale
JDK   8 : 31.01.2020 07:08:43.660 +0100 Heure d’Europe centrale 
Locale loc = new Locale("ru", "RU"):
JDK 13 : 31.01.2020 07:12:36.128 +0100 Центральная Европа, стандартное время
JDK   8 : 31.01.2020 07:12:36.128 +0100 Central European Time 

И при парсинге такой строки, полученной из системы с другим JDK законно возникнет DateTimeParseException.

Конечно, кто-то может сказать, что JDK 8 сильно устаревший, и я даже готов с этим согласиться, правда за небольшим исключением. JDK 8 это последний JDK, который поддерживает 32-х битные системы. И у некоторых наших студентов такие старые компьютеры периодически встречаются. Т.е. в реальной жизни это до сих пор есть. И в этом случае, при передаче информации из одной системы в другую могут возникнуть проблемы.

Но, предупрежден, значит вооружен! Зная эти особенности можно построить свой код так,чтобы избежать потенциальных проблем. В данном случае лучше не использовать полное имя часового пояса «zzzz» в формате, а использовать краткое («z»), или вообще имя ZoneId «VV». А использование только часового смещения, например +0100 потеряет имя зоны, так как бывает несколько зон с одинаковым смещением. Это, конечно, не сместит дату-время, но сделает вывод этой даты менее удобным для чтения. А в нашем деле нет мелочей!