Планета Марс уже не первый год населена роботами. То тут, то там появляются беспилотные электрокары и летающие дроны, а в программах, написанных на Java, с завидной регулярностью всплывают проблемы с кодировками.
Хочу поделиться своими мыслями о том, почему это происходит.
Предположим, у нас есть файл, в котором хранится нужный нам текст. Чтобы поработать с этим текстом в Java нам нужно загнать данные в String. Как это сделать?
String readFile(String fileName, String encoding) { StringBuilder out = new StringBuilder(); char buf[] = new char[1024]; InputStream inputStream = null; Reader reader = null; try { inputStream = new FileInputStream(fileName); reader = new InputStreamReader(inputStream, encoding); for (int i = reader.read(buf); i >= 0; i = reader.read(buf)) { out.append(buf, 0, i); } } catch (FileNotFoundException e) { e.printStackTrace(); } catch (UnsupportedEncodingException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } finally { if (reader != null) { try { reader.close(); } catch (IOException e) { e.printStackTrace(); } } if (inputStream != null) { try { inputStream.close(); } catch (IOException e) { e.printStackTrace(); } } } String result = out.toString(); return result; }
Обратите внимание, что для чтения файла недостаточно просто знать его имя. Нужно еще знать, в какой кодировке в нем находятся данные. Двоичное представление символов в памяти Java-машины и в файле на жестком диске практически никогда не совпадает, поэтому нельзя просто взять и скопировать данные из файла в строку. Сначала нужно получить последовательность байт, а уже потом произвести преобразование в последовательность символов. В приведенном примере это делает класс InputStreamReader.
Код получается достаточно громоздким при том, что необходимость в преобразовании из байтов в символы и обратно возникает очень часто. В связи с этим логичным было бы предоставить разработчику вспомомогательные функции и классы, облегчающие работу по перекодировке. Что для этого сделали разработчики Java? Они завели функции, которые не требуют указания кодировки. Например, класс InputStreamReader имеет конструктор с одним параметром типа InputStream.
String readFile(String fileName) { StringBuilder out = new StringBuilder(); char buf[] = new char[1024]; try ( InputStream inputStream = new FileInputStream(fileName); Reader reader = new InputStreamReader(inputStream); ) { for (int i = reader.read(buf); i >= 0; i = reader.read(buf)) { out.append(buf, 0, i); } } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } String result = out.toString(); return result; }
Стало чуть попроще. Но здесь разработчики Java закопали серьезные грабли. В качестве кодировки для преобразования данных они использовали так называемый «default character encoding».
Default charset устанавливается Java-машиной один раз при старте на основании данных взятых из операционной системы и сохраняется для информационных целей в системном свойстве file.encoding. В связи с этим возникают следующие проблемы.
- Кодировка по умолчанию — это глобальный параметр. Нельзя установить для одних классов или функций одну кодировку, а для других — другую.
- Кодировку по умолчанию нельзя изменить во время выполнения программы.
- Кодировка по умолчанию зависит от окружения, поэтому нельзя заранее знать, какая она будет.
- Поведение методов, зависящих от кодировки по умолчанию, нельзя надежно покрыть тестами, потому что кодировок достаточно много, и множество их значений может расширяться. Может выйти какая-нибудь новая ОС с кодировкой типа UTF-48, и все тесты на ней окажутся бесполезными.
- При возникновении ошибок приходится анализировать больше кода, чтобы узнать, какую именно кодировку использовала та или иная функция.
- Поведение JVM в случае изменения окружения после старта становится непредсказуемо.
Но главное — это то, что от разработчика скрывается важный аспект работы программы, и он может просто не заметить, что использовал функцию, которая в разном окружении будет работать по-разному. Класс FileReader вообще не содержит функций, которые позволяют указать кодировку, хотя сам класс логичен и удобен, поэтому он стимулирует пользователя на создание платформозависимого кода.
Из-за этого происходят удивительные вещи. Например, программа может неправильно открыть файл, который ранее сама же создала.
Или, скажем, есть у нас XML-файл, у которого в заголовке написано encoding=«UTF-8», но в Java-программе этот файл открывается при помощи класса FileReader, и привет. Где-то откроется нормально, а где-то нет.
Особенно ярко проблема file.encoding проявляется в Windows. В ней Java в качестве кодировки по умолчанию использует ANSI-кодировку, которая для России равна Cp1251. В самой Windows говорится, что «этот параметр задает язык для отображения текста в программах, не поддерживающих Юникод». При чем здесь Java, которая изначально задумывалась для полной поддержки Юникода, непонятно, ведь для Windows родная кодировка — UTF-16LE, начиная где-то с Windows 95, за 3 года до выхода 1-й Java.
Так что если вы сохранили при помощи Java-программы файл у себя на компьютере и отправили его вашему коллеге в Европу, то получатель при помощи той же программы может и не суметь открыть его, даже если версия операционной системы у него такая же как и у вас. А когда вы переедете с Windows на Mac или Linux, то вы уже и сами свои файлы можете не прочитать.
А ведь еще есть Windows консоль, которая работает в OEM-кодировке. Все мы наблюдали, как вплоть до Java 1.7 любой вывод русского текста в черном окне при помощи System.out выдавал крокозябры. Это тоже результат использования функций, основанных на default character encoding.
Я у себя проблему кодировок в Java решаю следующим образом:
- Всегда запускаю Java с параметром -Dfile.encoding=UTF-8. Это позволяет убрать зависимость от окружения, делает поведение программ детерминированным и совместимым с большинством операционных систем.
- При тестировании своих программ обязательно делаю тесты с нестандартной (несовместимой с ASCII) кодировкой по умолчанию. Это позволяет отловить библиотеки, которые пользуются классами типа FileReader. При обнаружении таких библиотек стараюсь их не использовать, потому что, во-первых, с кодировками обязательно будут проблемы, а во-вторых, качество кода в таких библиотеках вызывает серьезные сомнения. Обычно я запускаю java с параметром -Dfile.encoding=UTF-32BE, чтобы уж наверняка.
Это не дает стопроцентной гарантии от проблем, потому что есть же еще и лаунчеры, которые запускают Java в отдельном процессе с теми параметрами, которые считают нужными. Например, так делали многие плагины к анту. Сам ант работал с file.encoding=UTF-8, но какой-нибудь генератор кода, вызываемый плагином, работал с кодировкой по умолчанию, и получалась обычная каша из разных кодировок.
По идее, со временем код должен становиться более качественным, программы более надежными, форматы более стандартизованными. Однако этого не происходит. Вместо этого наблюдается всплеск ошибок с кодировками в Java-программах. Видимо, это связано с тем, что в мир Java иммигрировали люди, не привыкшие решать проблему кодировок. Скажем, в C# по умолчанию применяется кодировка UTF-8, поэтому разработчик, переехавший с C#, вполне разумно считает, что InputStreamReader по умолчанию использует эту же кодировку, и не вдается в детали его реализации.
Недавно наткнулся на подобную ошибку в maven-scr-plugin.
Но настоящее удивление пришлось испытать при переезде на восьмерку. Тесты показали, что проблема с кодировкой затесалась в JDK.
import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.Arrays; import javax.crypto.Cipher; public class PemEncodingBugDemo { public static void main(String[] args) { try { String str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345467890\r\n /=+-"; byte ascii[] = str.getBytes(StandardCharsets.US_ASCII); byte current[] = str.getBytes(Charset.defaultCharset()); if (Arrays.equals(ascii, current)) { System.err.printf("Run this test with non-ascii native encoding,%n"); System.err.printf("for example java -Dfile.encoding=UTF-16%n"); } Cipher.getInstance("RC4"); } catch (Throwable e) { e.printStackTrace(); } } }
На девятке не воспроизводится, видимо, там уже починили.
Поискав по базе ошибок, я нашел еще одну недавно закрытую ошибку, связанную с теми же самыми функциями. И что характерно, их даже исправляют не совсем правильно. Коллеги забывают, что для стандартных кодировок, начиная с Java 7, следует использовать константы из класса StandardCharsets. Так что впереди, к сожалению, нас ждет еще масса сюрпризов.
Запустив grep по исходникам JDK, я нашел десятки мест, где используются платформозависимые функции. Все они будут работать некорректно в окружении, где родная кодировка, несовместима с ASCII. Например, класс Currency, хотя казалось бы, уж этот-то класс должен учитывать все аспекты локализации.
Когда некоторые функции начинают создавать проблемы, и для них существует адекватная альтернатива, давно известно, что нужно делать. Нужно отметить эти функции как устаревшие и указать, на что их следует заменить. Это хорошо зарекомендовавший себя механизм deprecation, который даже планируют развивать.
Я считаю, что функции, зависящие от кодировки по умолчанию, надо обозначить устаревшими, тем более, что их не так уж и много:
Функция | На что заменить |
---|---|
Charset.defaultCharset() | удалить |
FileReader.FileReader(String) | FileReader.FileReader(String, Charset) |
FileReader.FileReader(File) | FileReader.FileReader(File, Charset) |
FileReader.FileReader(FileDescriptor) | FileReader.FileReader(FileDescriptor, Charset) |
InputStreamReader.InputStreamReader (InputStream) | InputStreamReader.InputStreamReader (InputStream, Charset) |
FileWriter.FileWriter(String) | FileWriter.FileWriter(String, Charset) |
FileWriter.FileWriter(String, boolean) | FileWriter.FileWriter(String, boolean, Charset) |
FileWriter.FileWriter(File) | FileWriter.FileWriter(File, Charset) |
FileWriter.FileWriter(File, boolean) | FileWriter.FileWriter(File, boolean, Charset) |
FileWriter.FileWriter(FileDescriptor) | FileWriter.FileWriter(FileDescriptor, Charset) |
OutputStreamWriter.OutputStreamWriter (OutputStream) | OutputStreamWriter.OutputStreamWriter (OutputStream, Charset) |
String.String(byte[]) | String.String(byte[], Charset) |
String.String(byte[], int, int) | String.String(byte[], int, int, Charset) |
String.getBytes() | String.getBytes(Charset) |
Часть программного обеспечения для марсианского зонда Скиапарелли написали на Java, на актуальной в то время версии 1.7. Запустили изделие весной, и путь к месту назначения составил полгода. Пока он летел, в Европейском космическом агентстве обновили JDK.
Ну а что? Разработка софта для нынешней миссии завершена, надо делать ПО уже для следующей, а мы все еще на семерке сидим. НАСА и Роскосмос уже давно на восьмерку перешли, а там лямбды, стримы, интерфейсные методы по умолчанию, новый сборщик мусора, и вообще.
Обновились и перед посадкой отправили на космический аппарат управляющую команду не в той кодировке, в которой он ожидал.
ссылка на оригинал статьи https://habrahabr.ru/post/315374/
Добавить комментарий