W3docs

Введение в Java I/O

Обзор Java I/O: байтовые и символьные потоки, буферизованный ввод-вывод, java.io против java.nio.file.

Введение в Java I/O

Часть 12 завершилась словарём, который вы можете перенести прямо в эту часть: лямбды, Consumer<T> и Supplier<T> как формы, стоящие за «дай мне строку» и «сделай что-нибудь с этой строкой», try-with-resources для всего, что требует детерминированной очистки, и конвейер Stream для строкоориентированных данных. API ввода-вывода Java были спроектированы именно вокруг этих форм — задолго до того, как появились слова «функциональный интерфейс», у лежащих в основе объектов уже было по одному методу, а появившийся после Java 8 фасад довёл дело до конца.

Эта часть охватывает четыре пересекающихся набора инструментов:

  1. Потоки java.io — изначальный API Java 1.0: InputStream/OutputStream для байтов, Reader/Writer для символов и декораторы Buffered*, Data*, Print*, которые их оборачивают.
  2. java.io.File — устаревший класс в духе «эта строка — это путь». Всё ещё повсюду в старом коде; вытеснен java.nio.file.Path для новых работ.
  3. java.nio.file — современный API (Java 7+): Path, Files и статические вспомогательные методы (Files.readString, Files.writeString, Files.lines, Files.walk), которые превращают большинство файловых операций в одну строку.
  4. Сериализация — превращение графов объектов в байты и обратно с помощью ObjectOutputStream / ObjectInputStream.

Первые шесть глав прорабатывают высокоуровневые файловые операции (открытие, создание, чтение, запись, удаление) с использованием как java.io, так и java.nio.file, чтобы вы могли увидеть одну и ту же задачу двумя способами. Средние главы крупным планом рассматривают сами классы потоков — байт против символа, буферизацию, данные, печать. Последние главы охватывают сериализацию и API path/walk.

Разделение на байты и символы

Каждый API ввода-вывода в java.io имеет одну из двух форм:

InputStream  /  OutputStream     — байтоориентированный   (сырые байты: int read() возвращает 0..255 или -1)
Reader       /  Writer            — символьноориентированный (декодированный текст: int read() возвращает символ или -1)

Это разделение не косметическое. Байты — это то, что хранят диски и сокеты; символы — это то, что читают люди. Файл .png — это байты; файл .txtтоже байты на диске, но обычно вы хотите декодировать его в символы с помощью кодировки. Смешивание этих двух без кодировки — самый частый источник ошибок «странные символы» в старом коде Java.

Классы-мосты — InputStreamReader и OutputStreamWriter — преобразуют между двумя формами и принимают аргумент Charset. Используйте StandardCharsets.UTF_8, если у вас нет задокументированной причины применять что-то иное; формы без аргументов используют кодировку платформы по умолчанию, которая различается между операционными системами и является хрестоматийным источником ошибок «работает на моём Mac, ломается на сервере Linux».

Паттерн «декоратор»

java.io построен на паттерне «декоратор»: небольшой набор сырых потоков (FileInputStream, FileOutputStream, FileReader, FileWriter), обёрнутых послойной функциональностью (буферизация, построчный текст, примитивные типы, форматированный вывод). Вы собираете то, что вам нужно, прямо в месте вызова:

// Читаем текстовый файл в UTF-8 построчно:
try (BufferedReader in = new BufferedReader(
        new InputStreamReader(new FileInputStream("a.txt"), StandardCharsets.UTF_8))) {
  String line;
  while ((line = in.readLine()) != null) {
    System.out.println(line);
  }
}

Три слоя, снизу вверх: FileInputStream читает сырые байты; InputStreamReader декодирует их как символы UTF-8; BufferedReader добавляет буфер в памяти и метод readLine(). Каждый слой — это отдельный класс с одной задачей. Java 11 свела ровно этот паттерн к одной строке — Files.newBufferedReader(path) —, но декорирование по-прежнему происходит под капотом.

try-with-resources — это правило

Каждый поток, reader, writer и канал в java.io и java.nio реализует AutoCloseable. Закрытие имеет значение: незакрытый FileOutputStream может потерять свой последний буфер; незакрытый сокет утекает дескриптором файла; незакрытый reader в Windows удерживает блокировку, которую ОС не освободит. Конструкция try-with-resources (Java 7+) гарантирует, что close() будет вызван на каждом успешном и каждом исключительном пути:

try (BufferedReader in = Files.newBufferedReader(path)) {
  return in.readLine();
}                                  // close() выполняется здесь, даже если readLine() бросит исключение

В одном try можно объявить более одного ресурса; они закрываются в обратном порядке. Старый код try/finally, вызывающий close() вручную, почти всегда неверен — внутреннее исключение поглощает исключение закрытия, или само закрытие забывается на пути ошибки. Используйте try-with-resources для всего, что открывает дескриптор.

java.io против java.nio.file

java.io.File (1996) моделировал путь как String и предлагал горстку операций (exists, isFile, delete, listFiles). Класс всё ещё повсюду в старом коде, и многие API по-прежнему возвращают или принимают File. Но у него есть ограничения, которые JDK больше не пытается скрывать:

  • Нет способа спросить, почему операция провалилась — file.delete() возвращает false и для «файл не существует», и для «доступ запрещён», и для «файл открыт». Вы не можете определить, что именно.
  • Нет поддержки символических ссылок, атрибутов файлов, прав доступа или атомарных операций.
  • Нет способа обойти дерево каталогов, не написав рекурсию самостоятельно.

java.nio.file (Java 7) заменяет его. Path — это новый тип «это путь», а Files — это static-класс-утилита примерно с 80 методами для всего, что вы захотите сделать с путём:

Path p = Path.of("data", "users.txt");                       // платформонезависимый путь
String text = Files.readString(p, StandardCharsets.UTF_8);   // весь файл, один вызов
List<String> lines = Files.readAllLines(p, StandardCharsets.UTF_8);
Files.writeString(p, "hello\n", StandardCharsets.UTF_8);
try (Stream<String> s = Files.lines(p, StandardCharsets.UTF_8)) {
  s.filter(l -> !l.isBlank()).forEach(System.out::println);
}

Стоит обратить внимание на две вещи. Во-первых, Files.lines(path) возвращает Stream<String> — конвейер потоков, который вы изучили в части 12, читает файлы напрямую. Во-вторых, поток владеет открытым дескриптором файла, поэтому обёртка try-with-resources обязательна — без неё файл остаётся открытым до следующей сборки мусора.

На протяжении всей части 13 мы будем показывать оба API бок о бок. Новый код должен в первую очередь тянуться к java.nio.file; главы про старый код существуют потому, что вы встретите более старые формы в любой кодовой базе старше Java 11.

Куда движется эта часть

  • Следующая глава, Класс Java File, проходит по устаревшему API java.io.File — его методам запроса, перечислению и ограничениям, которые мотивировали java.nio.file.
  • Четыре последующие главы (Создание файлов, Чтение файлов, Запись файлов, Удаление файлов) охватывают высокоуровневые операции «сделать одну вещь с файлом» с использованием обоих API.
  • Главы о байтовых, символьных и буферизованных потоках затем крупным планом рассматривают лежащий в основе стек декораторов java.io.
  • Сериализация, затем Path, Files и API обхода каталогов завершают эту часть.

Проработанный пример: одна задача, четыре способа

Программа ниже записывает короткий текстовый файл четырьмя способами — один раз с помощью современного Files.writeString, один раз с классическим FileWriter + try-with-resources, один раз декорированным BufferedWriter и один раз с PrintWriter для форматированного вывода. Затем она читает файл обратно дважды — один раз с Files.readString (весь файл, один вызов) и один раз с Files.lines как Stream<String>, отфильтрованным с помощью Predicate<String>. В примере используется системный временный файл, поэтому он работает в любой песочнице.

java— editable, runs on the server

Что вынести из запуска:

  • Один и тот же файл был записан четырьмя разными способами. Files.writeString — самый короткий путь для «положи эту строку в этот файл»; FileWriter — классический сырой writer; BufferedWriter добавляет буфер в памяти (дёшево, когда вы пишете много мелких порций); PrintWriter добавляет printf. Каждый перезаписал предыдущее содержимое, потому что режим открытия по умолчанию — «обрезать, затем писать» — нужно передать StandardOpenOption.APPEND (рассмотрено в главе о записи), чтобы дописывать в файл.
  • Каждый writer выполнялся внутри try-with-resources. Пропустить это для буферизованного writer — та самая ошибка, при которой последние несколько символов так и не достигают диска: именно close() сбрасывает последний буфер.
  • Files.readString вернул весь файл как один String — нормально для маленьких файлов, неверный выбор для лога на 4 ГБ. Files.lines вернул Stream<String>, который можно пропустить через конвейер filter, map и count, не удерживая весь файл в памяти. Обязательный try-with-resources для потока нужен потому, что поток владеет открытым дескриптором файла.
  • Строка Predicate<String> nameLine = l -> l.startsWith("name") — это тот же словарь из части 12: значение Predicate, переданное в Stream.filter. Files.lines — это место, где API потоков встречается с API ввода-вывода.
  • Files.deleteIfExists — это удаление без исключений: возвращает true, если файл был удалён, false, если его там не было. Устаревший File.delete() возвращает boolean и для «удалено», и для «не удалось удалить» — Files различает эти два случая, бросая исключение.

Что дальше

Прежде чем современный API java.nio.file возьмёт на себя остаток этой части, следующая глава охватывает класс, с которым вы первым столкнётесь в любой старой кодовой базе: Класс Java File. Это устаревший тип для пути и метаданных — ограниченный, но встречающийся повсюду —, и увидеть, чего он не может, и есть то, что обосновывает переход к Path и Files.

Практика

Практика

Почему `java.io` отделяет `InputStream`/`OutputStream` от `Reader`/`Writer`?