1. ZIP как контейнер на JVM
Когда слышишь слово «архив», легко представить магию: «как-то там оно сжимается, упаковывается, распаковывается». На практике полезнее думать проще и немного скучнее (скучное мышление — топливо для надёжных программ): ZIP — это контейнер, то есть один файл, внутри которого лежит набор элементов. Каждый элемент имеет имя и набор байтов (данные файла), а иногда ещё и признак «это папка».
Чтобы не путаться, маленькая ремарка для любителей Kotlin: в стандартной библиотеке Kotlin есть функция zip() для коллекций (склеить элементы попарно). Она никак не связана с ZIP‑архивом, кроме совпадения букв.
В Java/Kotlin на JVM ZIP чаще всего обрабатывают потоково через классы из java.util.zip. Нам понадобятся три героя:
| Что это | Класс | Роль |
|---|---|---|
| «Описание элемента в архиве» | |
Имя внутри архива, флаги, метаданные |
| «Пишем ZIP» | |
Добавляем entry и записываем туда байты |
| «Читаем ZIP» | |
Перебираем entry и читаем байты каждого |
ZipEntry и имена внутри архива
Если представить ZIP как шкаф, то ZipEntry — это не «вещь», а наклейка на коробке: как коробка называется и что в ней лежит. Внутри ZipEntry нас больше всего интересует name. Это строка, которая задаёт «путь» внутри архива: например, docs/readme.txt или images/logo.png.
И тут есть важный момент: entry.name выглядит как путь, но это не гарантия, что его безопасно напрямую превращать в путь на вашем диске. Архив может содержать странные имена вроде ../../secret.txt. В промышленном коде это проверяют очень строго. Мы сегодня не строим полноценную систему безопасности, но аккуратную базовую проверку всё равно покажем: не позволим выходить из целевой папки при распаковке.
Ещё один нюанс: у entry бывает признак «директория» (папка). В Java это обычно выражено тем, что entry.isDirectory == true (и часто имя оканчивается на /). Мы будем учитывать это при распаковке: папку нужно создать, а не пытаться «записать в неё байты».
2. Запись ZIP
Порядок действий при добавлении записей
Запись ZIP — это место, где новички чаще всего делают одну и ту же ошибку: забывают, что архив состоит из нескольких записей, и у каждой записи есть «границы».
Правильный порядок для одной записи такой: создать ZipEntry, сделать putNextEntry(entry), записать байты, сделать closeEntry(). Если вы пишете несколько файлов, этот цикл повторяется для каждого файла.
Схематично это выглядит так:
flowchart TD
A[ZipOutputStream открыт] --> B["ZipEntry(name)"]
B --> C["putNextEntry(entry)"]
C --> D["copyByChunks(fileInput -> zip)"]
D --> E["closeEntry()"]
E --> F{ещё файлы?}
F -->|да| B
F -->|нет| G[ZipOutputStream закрыт]
Chunk‑копирование: функция, которую удобно переиспользовать
Прежде чем писать ZIP, нужен «рабочий конь»: функция, которая переносит байты из InputStream в OutputStream блоками.
Обратите внимание: ByteArray — это массив фиксированного размера, его удобно переиспользовать как буфер.
import java.io.InputStream
import java.io.OutputStream
fun copyByChunks(input: InputStream, output: OutputStream, bufferSize: Int = 8 * 1024): Long {
val buffer = ByteArray(bufferSize)
var total = 0L
while (true) {
val n = input.read(buffer)
if (n == -1) return total
output.write(buffer, 0, n)
total += n
}
}
Здесь важно, что мы пишем output.write(buffer, 0, n), а не весь буфер целиком. Последний блок почти всегда меньше размера буфера, и если записать лишнее — вы допишете «хвост» из старых байтов.
Простейшая запись ZIP: один файл в архив
Минимальный сценарий: взять файл report.bin и положить его в backup.zip под именем report.bin.
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
fun zipSingleFile(src: File, zipFile: File) {
ZipOutputStream(FileOutputStream(zipFile)).use { zip ->
zip.putNextEntry(ZipEntry(src.name))
FileInputStream(src).use { input -> copyByChunks(input, zip) }
zip.closeEntry()
}
}
Заметьте, что copyByChunks(input, zip) работает, потому что ZipOutputStream — это OutputStream. Для него запись выглядит так же, как запись «в обычный файл»: просто байты.
ZIP из нескольких файлов: цикл с closeEntry()
Если упаковываем несколько файлов, главное правило не меняется: на каждый файл создаём новый ZipEntry, делаем putNextEntry, копируем байты, делаем closeEntry().
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
fun zipFiles(files: List<File>, zipFile: File) {
ZipOutputStream(FileOutputStream(zipFile)).use { zip ->
for (f in files) {
zip.putNextEntry(ZipEntry(f.name))
FileInputStream(f).use { input -> copyByChunks(input, zip) }
zip.closeEntry()
}
}
}
Если хочется мини‑контроль результата (без криптографии и хэшей), можно хотя бы вывести размер архива на диск. Это не доказывает корректность, но помогает заметить «пустой архив», который случайно получился из‑за ошибки.
import java.io.File
fun main() {
val zip = File("backup.zip")
println("zipSize=${zip.length()}") // zipSize=...
}
3. Чтение ZIP и распаковка
ZipInputStream, nextEntry и последовательное чтение
Чтение ZIP устроено иначе, чем чтение обычного каталога: ZipInputStream читает архив последовательно, entry за entry.
Ключевой API: zip.nextEntry. Он возвращает ZipEntry?. Если вернул null, записи закончились. Пока entry активен, сам zip ведёт себя как InputStream именно для данных этого entry: вы вызываете zip.read(buffer) и получаете байты файла.
Мини‑утилита «показать, что внутри архива» (без распаковки):
import java.io.FileInputStream
import java.util.zip.ZipInputStream
fun listZip(zipPath: String) {
ZipInputStream(FileInputStream(zipPath)).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
println("entry=${entry.name}, dir=${entry.isDirectory}")
zip.closeEntry()
entry = zip.nextEntry
}
}
}
closeEntry() при чтении тоже важен. Даже если где-то «и так сработает», лучше держать дисциплину: «закрыли запись» — «перешли к следующей».
Распаковка: директории, родительские папки и безопасный путь
Распаковка — это место, где chunk‑копирование снова в центре сцены. Мы берём ZipInputStream, бежим по entry, и для каждого «файлового» entry создаём FileOutputStream в целевой директории, затем переносим байты блоками: copyByChunks(zip, out).
Есть три бытовые проблемы, которые стоит решить даже в учебной версии:
- Первая проблема — директории. Если entry — папка, мы должны создать её (mkdirs()), а не пытаться «записать в папку байты».
- Вторая проблема — родительские папки файлов. Если entry называется docs/readme.txt, то папка docs должна существовать. Поэтому перед записью файла нужно сделать outFile.parentFile?.mkdirs().
- Третья проблема — небезопасные имена. Минимально корректный подход: собрать итоговый файл, нормализовать/канонизировать путь и проверить, что он остаётся внутри outDir.
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.zip.ZipInputStream
private fun resolveSafely(outDir: File, entryName: String): File? {
val target = File(outDir, entryName)
val outDirCanonical = outDir.canonicalFile
val targetCanonical = target.canonicalFile
return if (targetCanonical.path.startsWith(outDirCanonical.path + File.separator)) {
targetCanonical
} else {
null
}
}
fun unzipAll(zipFile: File, outDir: File) {
outDir.mkdirs()
ZipInputStream(FileInputStream(zipFile)).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
val outFile = resolveSafely(outDir, entry.name)
if (outFile != null) {
if (entry.isDirectory) {
outFile.mkdirs()
} else {
outFile.parentFile?.mkdirs()
FileOutputStream(outFile).use { out ->
copyByChunks(zip, out)
}
}
}
zip.closeEntry()
entry = zip.nextEntry
}
}
}
Если присмотреться, код выглядит почти как «обычное копирование», только входной поток для файла берётся не из FileInputStream, а из ZipInputStream, который «сейчас показывает» данные текущей записи.
4. Мини‑CLI и полезные нюансы
Команды zip, unzip, list в одном main()
Когда вы учитесь, легко превратить проект в коллекцию разрозненных main()-ов. Для практики удобнее сделать один простой CLI‑вход, который вызывает наши функции.
Парсинг сделаем максимально примитивным: читаем строку, делим по пробелам, берём первые слова. Без «умного» разбора кавычек и пробелов в путях — сейчас это не цель.
import java.io.File
fun main() {
while (true) {
print("filelab> ")
val line = readln().trim()
if (line.isEmpty()) continue
val parts = line.split(" ")
when (parts[0]) {
"exit" -> return
"list" -> {
if (parts.size < 2) println("Usage: list <zipPath>")
else listZip(parts[1])
}
"zip" -> {
if (parts.size < 3) println("Usage: zip <srcFile> <zipFile>")
else zipSingleFile(File(parts[1]), File(parts[2]))
}
"unzip" -> {
if (parts.size < 3) println("Usage: unzip <zipFile> <outDir>")
else unzipAll(File(parts[1]), File(parts[2]))
}
else -> println("Unknown command")
}
}
}
Нюансы: память и безопасность путей
ZIP как формат умеет много: сжатие (разные алгоритмы), пароли (в реальном мире часто криво совместимые), комментарии, атрибуты файловых систем. Мы этим не занимаемся. Но два нюанса уровня «не наступить на грабли» стоит помнить.
Первый нюанс — размер и память. ZIP может быть большим, а внутри него может лежать файл на десятки гигабайт. И именно поэтому ZipInputStream/ZipOutputStream — потоковые: они позволяют не держать всё в памяти. Если вы распаковываете entry через «прочитать целиком в ByteArray», вы сами отменяете смысл потоков.
Второй нюанс — имена entry и безопасность путей. Даже если ваш код «внутренний», привычка бездумно делать File(outDir, entry.name) иногда превращается в очень неприятные истории. В этой лекции мы показали простой практический подход: канонизировать путь и убедиться, что итоговый файл всё ещё находится внутри outDir.
5. Типичные ошибки при работе с ZIP‑архивами
Ошибка №1: забыть closeEntry() при записи или чтении.
ZIP — это формат, где записи идут подряд, и границы записи должны быть явно обозначены. Если вы не закрыли entry при записи, следующая запись может «слиться» с предыдущей, а при чтении вы рискуете получить странное поведение при переходе между элементами. Держите в голове правило: одна запись — один putNextEntry(...) и один closeEntry().
Ошибка №2: распаковывать без создания родительских директорий.
Entry внутри архива часто имеют «внутренние папки», например docs/readme.txt. Если вы сразу откроете FileOutputStream(outFile), а папки docs нет, вы получите ошибку на ровном месте. Привычка outFile.parentFile?.mkdirs() перед записью экономит много нервов.
Ошибка №3: писать output.write(buffer) вместо output.write(buffer, 0, n).
Это классика chunk‑копирования: read(buffer) возвращает n, и только первые n байт буфера являются актуальными. Записать весь буфер — значит записать ещё и «мусорный хвост» (обычно это куски данных из прошлой итерации). В архивах такой баг особенно неприятен: вы можете получить архив, который «создался», но файлы внутри будут повреждены.
Ошибка №4: считать, что entry.name — это «безопасный путь на диске».
Имя внутри архива — это просто строка. Если вы напрямую превращаете её в путь, архив потенциально может записать файлы «куда угодно» относительно вашей целевой папки. Даже в учебных примерах стоит хотя бы минимально проверять, что итоговый путь остаётся внутри outDir.
Ошибка №5: пытаться читать ZIP «целиком в память», потому что так проще.
Да, «проще» написать что-то вроде zip.readBytes() (или аналог) — но это часто превращает распаковку в лотерею по памяти. Смысл потоков и буферов в том, чтобы обрабатывать данные постепенно. Если файл маленький — всё будет «казаться нормальным», а потом придёт файл побольше, и программа внезапно станет учебником по ошибкам OutOfMemoryError.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ