JavaRush /Курсы /Kotlin SELF /Backup и ротация: сохраняем предыдущие версии перед замен...

Backup и ротация: сохраняем предыдущие версии перед заменой файла

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

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
expenses.txt
переименовали в
expenses.txt.bak
expenses.txt.bak
(нового ещё нет) записываем новую версию
expenses.txt + expenses.txt.bak

Обратите внимание на коварную деталь: если .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.

Небольшая табличка, как это «двигается»:

До сохранения После ротации (перед записью нового файла)
expenses.txt
expenses.txt.bak1
expenses.txt.bak1
expenses.txt.bak2
expenses.txt.bak2
expenses.txt.bak3
expenses.txt.bak3
удаляем (если 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 вкладок и теперь боюсь закрыть хоть одну».

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