Знайдена причина аварії європейського марсіанського зонда і звідки в Java спливають проблеми з кодуваннями

Планета Марс вже не перший рік населена роботами. То тут, То там з'являються безпілотні електрокари і літаючі дрони, а в програмах, написаних на Java з завидною регулярністю з'являються проблеми з кодуваннями.

Хочу поділитися своїми думками про те, чому це відбувається.



Припустимо, у нас є файл, в якому зберігається потрібний нам текст. Щоб попрацювати з цим текстом в Java нам потрібно загнати дані String. Як це зробити?

readFile
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.

readFile
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. У зв'язку з цим виникають наступні проблеми.

  1. Кодування за замовчуванням — це глобальний параметр. Не можна встановити для одних класів або функцій одну кодування, а для інших — інший.
  2. Кодування за замовчуванням не можна змінити під час виконання програми.
  3. Кодування за умовчанням залежить від оточення, тому не можна заздалегідь знати, яка вона буде.
  4. Поведінка методів, залежать від кодування за замовчуванням, не можна надійно покрити тестами, тому що кодувань досить багато, і безліч їх значень може розширюватися. Може вийти якась нова ОС з кодуванням типу UTF-48, і всі тести на ній виявляться марними.
  5. При виникненні помилок доводиться аналізувати більше коду, щоб дізнатися, яку саме кодування використовувала та чи інша функція.
  6. Поведінка 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 вирішую наступним чином:

  1. Завжди запускаю Java з параметром -Dfile.encoding=UTF-8. Це дозволяє прибрати залежність від оточення, робить поведінку програм детермінованим і сумісний з більшістю операційних систем.
  2. При тестуванні своїх програм обов'язково роблю тести з нестандартною (несумісною з ASCII) кодуванням за замовчуванням. Це дозволяє відловити бібліотеки, які користуються класами типу FileReader. При виявленні таких бібліотек намагаюся їх не використовувати, тому що, по-перше, з кодуваннями обов'язково будуть проблеми, а по-друге, якість коду в таких бібліотеках викликає серйозні сумніви. Зазвичай я запускаю java з параметром -Dfile.encoding=UTF-32BE, щоб уже напевно.


Це не дає стовідсоткової гарантії від проблем, тому що є ж ще і лаунчер, які запускають Java в окремому процесі з тими параметрами, які вважають потрібними. Наприклад, так робили багато плагіни до анту. Сам ант працював з file.encoding=UTF-8, але якийсь генератор коду, що викликається плагіном, працював з кодуванням за замовчуванням, і виходила звичайна каша з різних кодувань.

По ідеї, з часом код повинен ставати більш якісним, програми більш надійними, формати більш стандартизованими. Однак цього не відбувається. Замість цього спостерігається сплеск помилок з кодуваннями в Java-програми. Мабуть, це пов'язано з тим, що в світ Java іммігрували люди, які не звикли вирішувати проблему кодувань. Скажімо, в C# за замовчуванням застосовується кодування UTF-8, тому розробник, який переїхав з C#, цілком розумно вважає, що InputStreamReader за замовчуванням використовує цю ж кодування, і не вдається в деталі його реалізації.

Нещодавно натрапив на подібну помилку в maven-scr-plugin.

Але справжнє здивування довелося випробувати при переїзді на вісімку. Тести показали, що проблема з кодуванням затесалася в JDK.

Баг в JDK 8
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.

Ну а що? Розробка софта для нинішньої місії завершена, треба робити вже для наступної, а ми все ще на сімці сидимо. НАСА і Роскосмос вже давно на вісімку перейшли, а там лямбды, стріми, інтерфейсні методи за замовчуванням, новий збирач сміття, і взагалі.

Оновилися і перед посадкою відправили космічний апарат керуючу команду не в тій кодуванні, в якій він очікував.


Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.