1. Введение
Если atomic write — это «не испорть файл при сбое», то backup — это «не потеряй данные, даже если ты сам сделал глупость». И это звучит обидно, но честно: большинство потерь данных в учебных проектах происходит не от злых хакеров, а от разработчика, который случайно записал не то, не туда, не тем форматом или перепутал пути. И вот тут atomic write бессилен: он корректно заменит файл на новую, но неправильную версию.
Представьте, что наше консольное приложение (пусть будет простой «трекер расходов») хранит данные в файле data/expenses.txt. Мы добавили/удалили расходы, нажали «сохранить»… и внезапно выяснили, что логика форматирования строки сломалась, и мы записали в файл кашу. Файл записался надёжно (спасибо atomic write), но данные стали бесполезными. Backup в такой ситуации — как «контрольная точка сохранения» в игре: можно вернуться на шаг назад и продолжить без трагедии.
Важная мысль: backup делается до замены файла, потому что backup — это «прошлая версия», а не «тоже какая‑то версия».
Самый простой backup: .bak
Когда начинаешь делать backup впервые, хочется построить систему версионирования уровня Git… но мы сегодня не про Git, и нам не нужна «история на века». Нам нужен простой и понятный протокол: перед тем как заменить expenses.txt, мы сохраняем старый файл как expenses.txt.bak. Если что-то пошло не так — у нас есть «вчерашняя версия».
С точки зрения файлов это выглядит так:
| Было до сохранения | Делаем backup | После backup |
|---|---|---|
|
переименовали в |
|
| (нового ещё нет) | записываем новую версию | |
Обратите внимание на коварную деталь: если .bak уже существует, мы должны решить, что с ним делать. Для базового варианта — либо удаляем старый .bak, либо считаем это ошибкой и останавливаемся. В учебных целях обычно проще удалить (но проверять результат удаления).
Мини‑функция: backup одной копией
После этого подзаголовка я специально не бросаюсь в код с места в карьер. Сначала — логика: если основного файла нет, backup делать нечего, это «успех». Если файл есть, мы хотим переместить его в .bak. Если .bak уже лежит, мы аккуратно его удаляем. И только потом делаем renameTo.
import java.io.File
fun backupOnce(target: File): Boolean {
if (!target.exists()) return true // нечего бэкапить
val dir = target.parentFile ?: File(".")
val bak = File(dir, target.name + ".bak")
if (bak.exists() && !bak.delete()) return false // не смогли очистить старый backup
return target.renameTo(bak) // переносим текущий файл в .bak
}
Тут нет try/catch, потому что renameTo и delete возвращают Boolean, и мы честно его проверяем. Да, это не делает код «неуязвимым», но делает поведение предсказуемым: если backup не удался — мы не продолжаем, потому что можем потерять старую версию.
2. Ротация backup: несколько копий без потери истории
Почему одной копии мало и что такое ротация
Одна копия — это уже неплохо, но в реальных сценариях она ломается об человеческий фактор. Допустим, вы дважды подряд сохранили файл с ошибкой. Первый раз вы испортили данные — .bak стал «нормальной версией» (до ошибки). Второй раз вы снова испортили — и теперь .bak перезаписался версией «после первой ошибки», а «хорошая» версия исчезла. Получается, backup есть, а толку нет.
Ротация решает именно эту боль: мы храним не одну, а несколько прошлых версий, например 3. Тогда у нас будет expenses.txt.bak1, expenses.txt.bak2, expenses.txt.bak3. И каждый раз перед сохранением мы «сдвигаем» копии: .bak2 становится .bak3, .bak1 становится .bak2, а текущий expenses.txt становится .bak1. И только потом записываем новый expenses.txt.
Небольшая табличка, как это «двигается»:
| До сохранения | После ротации (перед записью нового файла) |
|---|---|
|
|
|
|
|
|
|
удаляем (если maxCopies = 3) |
Идея простая: храним конечное число версий, чтобы не разрасталось бесконечно.
Почему ротацию делают «с конца»
Тут есть классическая ловушка новичка: начать с .bak1 и двигать вперёд. Кажется логичным: «сделаю .bak1 → .bak2, потом file → .bak1». Но если вы делаете .bak1 → .bak2, когда .bak2 ещё не перемещён в .bak3, вы рискуете перезаписать .bak2 и потерять историю. Это как переставлять коробки в кладовке, начиная с первой: в какой-то момент вы упираетесь в то, что некуда поставить следующую.
Поэтому ротацию всегда делают «с конца»: сначала освобождаем место под самую старую копию, потом двигаем на шаг «вверх», и только в конце делаем .bak1 из текущего файла. Тогда ничего не затирается случайно.
Эту стратегию удобно держать в голове так: «двигаем историю назад по времени». Самое старое двигаем первым.
3. Реализация ротации: rotateBackups
Сейчас мы напишем функцию ротации так, чтобы она была максимально «учебной»: предсказуемой, с проверками, и без попыток умничать. Она будет возвращать Boolean: получилось — true, нет — false. Если false, мы не продолжаем запись, потому что наша цель — безопасность данных, а не «любой ценой записать».
Сначала небольшая подготовка. Нам нужны имена файлов bak1, bak2, ... Их проще всего собирать через конкатенацию имени и номера.
import java.io.File
fun backupFile(target: File, index: Int): File {
val dir = target.parentFile ?: File(".")
return File(dir, target.name + ".bak" + index)
}
Теперь сама ротация. Сразу договоримся: если maxCopies <= 0, ротацию считать «выключенной» и возвращать true.
import java.io.File
fun rotateBackups(target: File, maxCopies: Int): Boolean {
if (maxCopies <= 0) return true
// 1) Сдвигаем старые копии: bak2 -> bak3, bak1 -> bak2, ...
for (i in maxCopies downTo 2) {
val from = backupFile(target, i - 1)
val to = backupFile(target, i)
if (from.exists()) {
if (to.exists() && !to.delete()) return false
if (!from.renameTo(to)) return false
}
}
// 2) Текущий файл -> bak1
if (target.exists()) {
val bak1 = backupFile(target, 1)
if (bak1.exists() && !bak1.delete()) return false
if (!target.renameTo(bak1)) return false
}
return true
}
Здесь важно заметить несколько «жизненных» деталей.
Во-первых, мы не пытаемся переименовывать то, чего нет: если from не существует — пропускаем. Это нормально: в начале проекта у вас может быть только bak1, а bak2 и bak3 ещё не появились.
Во-вторых, перед renameTo мы удаляем to, если он существует. Иначе renameTo может не сработать (и вернёт false). Мы не пытаемся угадать поведение ОС — мы просто готовим место.
В-третьих, мы двигаем файлы только внутри одной директории, потому что в прошлой лекции про atomic write мы уже обсуждали идею «делай temp рядом с target», и для backup это тоже логично: меньше сюрпризов с доступами и переименованием.
4. Сохранение: ротация + atomic write
Склеиваем backup и atomic write в одну операцию
Теперь самое приятное: мы собираем это в понятный протокол сохранения файла. И тут важно не перепутать порядок шагов. Сначала backup/ротация защищает старое, потом atomic write публикует новое.
В прошлой лекции у нас уже была функция вроде atomicWriteText(target, text) (temp → rename). Мы её сейчас «не переписываем», а используем как готовый кирпич. И это вообще хороший стиль: маленькие функции проще тестировать и меньше шансов сломать всё разом.
Соберём функцию сохранения:
import java.io.File
fun writeWithBackup(target: File, text: String, maxCopies: Int): Boolean {
if (!rotateBackups(target, maxCopies)) return false
return atomicWriteText(target, text) // функция из прошлой лекции
}
Если вы сейчас подумали «а где обработка ошибок?» — она распределена по слоям. rotateBackups даёт false, если не смог защитить старое. atomicWriteText возвращает false, если не смог корректно записать и опубликовать новое. Мы не скрываем проблемы, а честно сообщаем наверх «не получилось».
Про finally снова полезно помнить: блок finally выполняется всегда и удобен, чтобы убирать временные файлы после попытки atomic write.
Пример в консольном приложении: трекер расходов
Чтобы всё это было не в вакууме, давайте представим, что у нас есть простое приложение, которое хранит «состояние» в строке. Например, мы уже умеем превращать список расходов в текст (пусть даже просто много строк). Сегодня нас интересует именно сохранение.
Сделаем минимальный пример: записываем три версии файла подряд и смотрим, что получилось с .bak-копиями.
import java.io.File
fun main() {
val file = File("data/expenses.txt")
writeWithBackup(file, "v1: coffee=3\n", maxCopies = 3)
writeWithBackup(file, "v2: coffee=3\npizza=12\n", maxCopies = 3)
writeWithBackup(file, "v3: coffee=4\npizza=12\n", maxCopies = 3)
println("main exists=${file.exists()}") // main exists=true
println("bak1 exists=${backupFile(file, 1).exists()}") // bak1 exists=true
println("bak2 exists=${backupFile(file, 2).exists()}") // bak2 exists=true
}
Обратите внимание: мы не печатаем содержимое целиком (это можно, но тогда пример распухнет). Мы проверяем сам факт существования. Если всё работает, то после трёх сохранений у нас будет текущий expenses.txt и как минимум bak1, bak2. А когда сохранений станет больше, начнёт появляться bak3, и более старые версии будут вытесняться.
5. Протокол сохранения: схема
Полезно один раз увидеть это как последовательность действий, потому что в голове новичка всё легко путается: «то ли сначала удалять, то ли сначала переименовать, то ли сначала писать». Схема ниже — ваша страховка от логических прыжков.
flowchart TD
A[Нужно сохранить файл target] --> B{Ротация backup прошла?}
B -- нет --> X[Остановиться: не трогаем данные]
B -- да --> C[Atomic write: пишем в temp]
C --> D{Запись temp успешна?}
D -- нет --> Y[Остановиться: temp убрать, target остаётся как был/в bak]
D -- да --> E[Публикация: rename temp -> target]
E --> F{rename успешен?}
F -- нет --> Z[Остановиться: сообщить об ошибке]
F -- да --> G[Готово: target обновлён, старое в bakN]
Заметьте, что при некоторых сбоях (например, если ротация уже переместила target в bak1, а atomic write не удался) текущая «актуальная» версия может оказаться в backup-файле. Это нормально, но это значит, что ваше приложение должно корректно обработать «не удалось сохранить» и не делать вид, что всё хорошо.
6. Сколько копий хранить и как называть
Когда добавляешь ротацию, хочется сразу сделать «100 копий, чтобы точно хватило». Но практика говорит, что это редко нужно: вы тратите место на диске, тормозите переименования, и усложняете жизнь пользователю (в папке появляется зоопарк файлов). В учебных приложениях обычно достаточно 3–5 копий.
Про имена тоже стоит договориться заранее. Мы используем формат имяФайла.bakN, потому что он читается глазами и легко сортируется человеком. Можно было бы делать имяФайла.1.bak, но тогда сортировка по имени будет не такой очевидной (а мы всё ещё живём в мире, где люди открывают папку глазами, а не только кодом).
Если вы делаете отдельные Kotlin‑файлы под такие утилиты, старайтесь давать имена, которые описывают содержимое, а не UtilityMagic42.kt. Это не про строгость ради строгости, а про то, чтобы через неделю вы сами себе сказали «спасибо».
7. Типичные ошибки при backup и ротации
Ошибка №1: делать backup после записи нового файла.
Такой backup выглядит «логично» на уровне интуиции («сначала сохраним, потом сделаем копию»), но по смыслу это уже не backup, а копия новой версии. Если новая версия оказалась испорченной, вы закрепили проблему: откатываться некуда. Backup всегда фиксирует старое состояние, значит выполняется до замены.
Ошибка №2: делать ротацию «с начала» и затирать историю.
Если начать с .bak1 → .bak2, легко перезаписать .bak2 ещё до того, как вы успели переместить .bak2 → .bak3. Результат: часть цепочки исчезает, а вы не сразу это замечаете. Правильный порядок — с конца: .bak2 → .bak3, потом .bak1 → .bak2, и только потом target → .bak1.
Ошибка №3: игнорировать renameTo(...) == false.
renameTo — не «магия», а операция ОС, которая может не получиться. Если вы не проверяете Boolean, программа будет вести себя так, будто backup создан, хотя на самом деле ничего не произошло. И дальше вы можете потерять данные: например, вы удалите target, думая, что он уже в .bak1.
Ошибка №4: продолжать запись, даже если backup не удался.
Когда backup/ротация возвращают false, это означает: «я не смог защитить старую версию». Продолжать запись после этого — рискованно, потому что вы потенциально замените файл и останетесь вообще без предыдущего состояния. В надёжных сценариях лучше остановиться и честно сообщить «сохранение не удалось».
Ошибка №5: держать backup в другой директории.
Иногда хочется складывать .bak в отдельную папку. В теории это нормально, но на практике на уровне новичка это часто приводит к проблемам с правами, разными дисками и непредсказуемым renameTo. Пока мы учимся, лучше хранить backup рядом с файлом: так меньше сюрпризов и проще диагностика.
Ошибка №6: хранить бесконечное число копий.
Если вы каждый раз создаёте новый backup с уникальным именем (например, с таймштампом) и никогда не удаляете старые, через месяц папка превратится в музей прошлых ошибок. Ротация с maxCopies — это как лимит на вкладки в браузере: спасает от ситуации «я открыл 200 вкладок и теперь боюсь закрыть хоть одну».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ