Работа с блоками данных в файлах
Когда файл маленький, его легко прочитать целиком в память, обработать и записать результат. Но реальные файлы могут быть большими: логи, архивы, изображения, дампы, CSV, видео. Если пытаться загрузить все сразу, программа начнет потреблять слишком много памяти.
Блочная обработка решает эту проблему. Мы читаем файл небольшими частями, обрабатываем каждую часть и постепенно записываем результат.
Зачем нужны блоки
Блок - это кусок данных фиксированного или ограниченного размера. Например, 4096 байт или 8192 байта.
Представьте длинную книгу. Чтобы перевести ее на другой язык, не нужно держать всю книгу в голове. Можно переводить страницу за страницей. Блоки работают так же: программа обрабатывает файл частями.
Блоки полезны, потому что:
- не нужно загружать весь файл в память;
- можно начать обработку до получения полного файла;
- проще показывать прогресс;
- можно повторить обработку поврежденного блока;
- некоторые операции можно распараллелить;
- формат файла становится понятнее для чтения.
Буфер
Буфер - это участок памяти для временного хранения данных при чтении или записи.
buffer := make([]byte, 4096)
Обычно цикл чтения выглядит так:
for { n, err := reader.Read(buffer) if n > 0 { block := buffer[:n] process(block) } if errors.Is(err, io.EOF) { break } if err != nil { return err } }
Важно использовать buffer[:n], а не весь buffer. Последний блок почти всегда меньше размера буфера.
Блоки при сжатии
При сжатии можно обрабатывать файл по блокам:
- Прочитать блок исходных данных.
- Сжать этот блок.
- Записать размер сжатого блока.
- Записать сжатые данные.
- Перейти к следующему блоку.
Проблема: после сжатия блоки могут иметь разный размер. Один блок сжался до 100 байт, другой до 3000 байт, третий почти не сжался. Поэтому распаковщик должен знать границы.
Почему нужно записывать размер блока
Без размера блока распаковщик не будет знать, сколько байт читать для следующего сжатого блока.
Проблема:
compressed1 := []byte{0x85, 0x41} // "AAAAA" compressed2 := []byte{0x83, 0x42} // "BBB"
Если записать только байты:
0x85 0x41 0x83 0x42
как понять, где заканчивается первый блок? По содержимому нельзя: сжатые данные могут содержать любые байты.
Поэтому формат часто хранит длину:
[размер block1][данные block1][размер block2][данные block2]
Формат записи
Простой формат архива может выглядеть так:
[1 байт: длина имени файла] [N байт: имя файла] [2 байта: размер блока 1] [N байт: данные блока 1] [2 байта: размер блока 2] [N байт: данные блока 2] ...
Если размер хранится в 2 байтах, максимальный размер сжатого блока - 65535 байт. Это ограничение нужно учитывать при выборе размера исходного блока и алгоритма.
Для больших блоков можно использовать 4 байта:
uint32 block_size
Порядок байтов
Число из нескольких байтов нужно записывать в понятном порядке. Часто используют big-endian:
var size [2]byte binary.BigEndian.PutUint16(size[:], uint16(len(compressed))) if _, err := writer.Write(size[:]); err != nil { return err } if _, err := writer.Write(compressed); err != nil { return err }
При чтении:
var sizeBytes [2]byte if _, err := io.ReadFull(reader, sizeBytes[:]); err != nil { return err } blockSize := binary.BigEndian.Uint16(sizeBytes[:]) compressed := make([]byte, blockSize) if _, err := io.ReadFull(reader, compressed); err != nil { return err }
Используйте io.ReadFull, когда нужно прочитать ровно указанное количество байт. Обычный Read не обязан заполнить весь буфер за один вызов.
Фиксированные и переменные блоки
Фиксированный размер исходного блока удобен при чтении:
читать по 4096 байт
Но размер сжатого блока почти всегда переменный. Поэтому формат хранит длину каждого сжатого блока.
Если блоки не сжимаются, можно использовать фиксированный размер без записи длины для каждого блока. Но для архиватора сжатие меняет размер, поэтому длина нужна.
Ошибки и повреждения
Блочный формат помогает лучше обрабатывать повреждения. Если один блок испорчен, теоретически можно понять, где ошибка, и не перечитывать весь файл. Но это работает только если формат достаточно надежный.
Для более серьезного формата добавляют:
- служебный заголовок, чтобы распознать тип файла;
- version, чтобы менять формат в будущем;
- checksum для блока;
- размер исходного блока;
- размер сжатого блока;
- общий список файлов.
Для учебного проекта достаточно начать с длины блока, но полезно понимать, как формат может расти.
Частые ошибки
- Использовать весь буфер вместо
buffer[:n]. - Не записывать размер сжатого блока.
- Читать размер через обычный
Read, а неio.ReadFull. - Не проверять ошибки
Write. - Использовать 2 байта размера, но допустить блок больше 65535 байт.
- Не описать формат файла в README или комментарии.
- Считать, что разделитель байтов надежно отделит блоки.
Чеклист
- Файл обрабатывается потоково, без загрузки целиком.
- Последний блок обрабатывается через
buffer[:n]. - У каждого переменного блока записан размер.
- Размер читается через
io.ReadFull. - Порядок байтов выбран явно.
- Размер поля подходит под максимальный блок.
- Ошибки чтения и записи проверяются.
- Формат файла описан так, чтобы его можно было восстановить при чтении.