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 є, а користі з нього немає.
Ротація розвʼязує саме цей біль: ми зберігаємо не одну, а кілька попередніх версій, наприклад три. Тоді в нас буде 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 вкладок і тепер боюся закрити бодай одну».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ