Введение в 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 фасад довёл дело до конца.
Эта часть охватывает четыре пересекающихся набора инструментов:
- Потоки
java.io— изначальный API Java 1.0:InputStream/OutputStreamдля байтов,Reader/Writerдля символов и декораторыBuffered*,Data*,Print*, которые их оборачивают. java.io.File— устаревший класс в духе «эта строка — это путь». Всё ещё повсюду в старом коде; вытесненjava.nio.file.Pathдля новых работ.java.nio.file— современный API (Java 7+):Path,Filesи статические вспомогательные методы (Files.readString,Files.writeString,Files.lines,Files.walk), которые превращают большинство файловых операций в одну строку.- Сериализация — превращение графов объектов в байты и обратно с помощью
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>. В примере используется системный временный файл, поэтому он работает в любой песочнице.
Что вынести из запуска:
- Один и тот же файл был записан четырьмя разными способами.
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`?