JavaRush /Курсы /Kotlin SELF /ZIP‑архивы: ZipEntry, чтение/запись

ZIP‑архивы: ZipEntry, чтение/запись

Kotlin SELF
43 уровень , 4 лекция
Открыта

1. ZIP как контейнер на JVM

Когда слышишь слово «архив», легко представить магию: «как-то там оно сжимается, упаковывается, распаковывается». На практике полезнее думать проще и немного скучнее (скучное мышление — топливо для надёжных программ): ZIP — это контейнер, то есть один файл, внутри которого лежит набор элементов. Каждый элемент имеет имя и набор байтов (данные файла), а иногда ещё и признак «это папка».

Чтобы не путаться, маленькая ремарка для любителей Kotlin: в стандартной библиотеке Kotlin есть функция zip() для коллекций (склеить элементы попарно). Она никак не связана с ZIP‑архивом, кроме совпадения букв.

В Java/Kotlin на JVM ZIP чаще всего обрабатывают потоково через классы из java.util.zip. Нам понадобятся три героя:

Что это Класс Роль
«Описание элемента в архиве»
ZipEntry
Имя внутри архива, флаги, метаданные
«Пишем ZIP»
ZipOutputStream
Добавляем entry и записываем туда байты
«Читаем ZIP»
ZipInputStream
Перебираем 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.

1
Задача
Kotlin SELF, 43 уровень, 4 лекция
Недоступна
Каталог архива
Каталог архива
1
Задача
Kotlin SELF, 43 уровень, 4 лекция
Недоступна
Запаковать файл
Запаковать файл
1
Задача
Kotlin SELF, 43 уровень, 4 лекция
Недоступна
Архив сборки
Архив сборки
1
Задача
Kotlin SELF, 43 уровень, 4 лекция
Недоступна
Распаковка в папку
Распаковка в папку
1
Опрос
Бинарные файлы, 43 уровень, 4 лекция
Недоступен
Бинарные файлы
Бинарные файлы
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ