1. Навіщо потрібен .bak і яка в нього політика
Коли ви вперше чуєте «ми зберігатимемо резервну копію поруч із файлом», мозок чесно запитує: «Ми ж і так не пишемо “обрізаний файл” — навіщо ще все ускладнювати?». І це правильне запитання.
temp + rename справді захищає від однієї дуже конкретної неприємності: файл виявився частково перезаписаним через збій посеред запису. Але в цього підходу є сліпа зона: temp + rename не рятує від ситуації, коли нова версія вийшла неправильною, але записалася без жодної помилки.
Уявіть, що ви зберігаєте дані застосунку у файлі: список задач, конфігурацію, мінібазу даних — будь-що. Ви порахували дані неправильно (логічна помилка), серіалізували не ті дані, раптово отримали порожній результат через неочікувані вхідні дані — і цілком коректно записали це у тимчасовий файл, а потім перейменували його. За протоколом усе «успішно». Файл цілий. Але зміст даних уже зіпсований.
І в цей момент дуже приємно мати під рукою попередню версію, щоб хоча б не втратити все.
Саме тому .bak — це не «ще один спосіб записати файл», а політика: «поруч лежить попередня версія».
- temp + rename — про цілісність запису (не отримати “половину файла”).
- .bak — про можливість відкату (повернутися до попередньої версії, якщо нова виявилася невдалою).
Політика .bak
Перш ніж писати код, корисно на хвилину зупинитися й зробити те, про що розробники часто забувають: прийняти рішення. .bak — це не магія, а домовленість. Якщо ви не зафіксуєте політику, ваша програма поводитиметься «як вийде».
У політики .bak є кілька неминучих питань: коли створювати резервну копію, що робити, якщо вона вже існує, і як діяти під час відновлення. Нижче — компактна таблиця рішень, які найчастіше потрібні в навчальному (і в багатьох реальних) проєктах.
| Ситуація | Рішення за замовчуванням | Чому це зручно |
|---|---|---|
| Цільового файла ще немає | .bak не створюємо | Поки нічого резервувати — це перша версія |
| Цільовий файл є | Перед записом робимо .bak | Резервна копія зберігає попередню версію |
| .bak уже існує | Перезаписуємо (видаляємо старий .bak і створюємо новий) | Політика «зберігаємо лише один крок відкату» проста й передбачувана |
| Відновлення | Якщо основного файла немає, але є .bak, відновлюємо | Після збою можна відновитися без ручного втручання |
Так, можна зберігати кілька поколінь (наприклад, .bak1, .bak2), можна тримати їх в окремій папці, можна робити архіви. Але тут ми тримаємо фокус: нам потрібен надійний і зрозумілий протокол, а не домашня система контролю версій.
2. Протокол запису: temp + rename + .bak
Зараз ми акуратно «вставимо» .bak у вже знайомий протокол temp + rename. Важливо не переплутати порядок кроків: резервну копію створюємо до публікації нової версії, інакше ви резервуєте вже нову — а це буде дуже сумна резервна копія.
Якщо намалювати протокол як схему, вийде приблизно так:
flowchart TD
A[Підготувати нову версію у temp] --> B{Цільовий файл існує?}
B -- ні --> E[Опублікувати: rename temp -> target]
B -- так --> C[Створити .bak: rename target -> target.bak]
C --> E[Опублікувати: rename temp -> target]
E --> F{Публікація вдалася?}
F -- так --> G[Готово: target = нова версія, .bak = попередня]
F -- ні --> H[Спробувати відкотити: rename .bak -> target]
Зверніть увагу на важливу думку: .bak сам по собі не гарантує, що «завжди можна відкотитися». Він гарантує, що у вас є спроба відкату і що попередня версія зазвичай залишається під рукою.
Чому «зазвичай»? Бо файлові операції можуть давати збій: немає прав, дивна файлова система, файл зайнятий, раптово закінчилося місце тощо. У реальному світі «надійність» — це не «ніколи не ламається», а «ламається передбачувано й лишає сліди».
Ще один нюанс: коли ви виконуєте Rename(target, target + ".bak"), основний файл зникає — його ім’я змінюється. Між цим кроком і публікацією тимчасового файла у вас є проміжок, коли target може не існувати. Якщо процес упав саме в цей момент, під час наступного запуску застосунок побачить, що target немає, зате є .bak. Тому ми й хочемо вміти відновлюватися.
3. Реалізація в Go
Перед кодом зробимо маленьку архітектурну паузу. Навіть у навчальному проєкті корисно розділяти:
- функції для збереження виконують файлові операції й повертають error;
- користувацький інтерфейс (CLI) друкує повідомлення.
Це зменшує плутанину й робить код тестованим: тести не люблять, коли їх засипають виводом у stdout.
Нижче буде три «цеглинки»: перевірка існування файла, запис із резервною копією та відновлення з резервної копії.
Перевірка «файл існує?»
Ця частина здається нудною, але на практиці саме тут починаються «дивні баги», коли люди плутають «файла немає» і «Stat повернув помилку з іншої причини». Тому корисно тримати різницю в голові: os.Stat може повернути помилку з різних причин.
import "os"
func fileExists(path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
Тут важлива звичка: відсутність файла — це нормальна гілка логіки, а «інша помилка» — уже привід зупинитися й передати її далі.
Запис: WriteWithBackup
Ідея проста: для коду застосунку запис даних має виглядати як одна операція — «ось шлях, ось байти, ось права: зроби надійно». Усередині використовуємо знайому техніку: підготувати temp, потім зробити .bak (якщо треба), а далі опублікувати.
Щоб приклад був коротшим, вважатимемо, що в нас уже є допоміжна функція з минулої лекції:
writeTempFile(dir, data, perm) (tmpName string, err error)
Вона створює тимчасовий файл, записує в нього дані, закриває його й виставляє права доступу. Тут ми зосереджуємося саме на .bak.
import (
"fmt"
"os"
"path/filepath"
)
func WriteWithBackup(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmpName, err := writeTempFile(dir, data, perm)
if err != nil {
return err
}
bakPath := path + ".bak"
if err := rotateBackup(path, bakPath); err != nil {
_ = os.Remove(tmpName)
return err
}
if err := os.Rename(tmpName, path); err != nil {
_ = os.Rename(bakPath, path) // спроба відкату
_ = os.Remove(tmpName)
return fmt.Errorf("не вдалося опублікувати %s: %w", path, err)
}
return nil
}
Тут є кілька моментів «по-дорослому», хоча код усе ще короткий:
- ми прибираємо tmpName, якщо щось пішло не так: тимчасові файли не мають засмічувати директорію;
- у разі помилки публікації намагаємося повернути .bak на місце;
- додаємо контекст через fmt.Errorf("...: %w", err), щоб помилка була читабельною й придатною для подальшої обробки.
Тепер лишилося пояснити, що таке rotateBackup.
Оновлення .bak: rotateBackup
Назва «rotate» схожа на логування, але за змістом усе просто: «онови резервну копію так, щоб у .bak лежала попередня версія». Ми обрали політику «один крок назад», отже старий .bak нам не потрібен — ми його перезаписуємо.
import (
"fmt"
"os"
)
func rotateBackup(path, bakPath string) error {
exists, err := fileExists(path)
if err != nil {
return fmt.Errorf("не вдалося перевірити цільовий файл: %w", err)
}
if !exists {
return nil
}
_ = os.Remove(bakPath) // політика: перезаписуємо .bak
if err := os.Rename(path, bakPath); err != nil {
return fmt.Errorf("не вдалося створити резервну копію: %w", err)
}
return nil
}
Тут спеціально стоїть _ = os.Remove(bakPath): ми заздалегідь прибираємо старий .bak, бо поведінка Rename за наявності файла призначення відрізняється залежно від ОС і файлової системи. Для навчального проєкту важливіша передбачуваність.
І ще раз: резервну копію створюємо через Rename, а не через «прочитав і записав копію». Для великих файлів це швидше, і ми не додаємо ще одну довгу операцію копіювання, яка сама може зупинитися посередині.
Відновлення: RestoreFromBackup
Тут часто хочеться написати: «якщо основного файла немає — віднови .bak». Але безпечніше й точніше так: якщо основного файла немає і водночас є .bak, тоді можна повернути .bak на місце. Якщо основний файл є — не чіпаємо його, бо «самодіяльне відновлення» може бути гіршим за поломку.
import (
"fmt"
"os"
)
func RestoreFromBackup(path string) error {
if _, err := os.Stat(path); err == nil {
return nil // основний файл на місці
}
bakPath := path + ".bak"
if err := os.Rename(bakPath, path); err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("не вдалося відновити резервну копію: %w", err)
}
return nil
}
Тут той самий загальний принцип: помилки бувають «очікувані» (немає файла) і «неочікувані» (немає прав, зламана FS). Очікувані не мають валити програму, неочікувані — мають бути видимі.
4. Застосування в навчальному застосунку
Уявімо, що в нас є найпростіший CLI‑менеджер задач, який зберігає дані у файлі "tasks.txt". Формат примітивний: один рядок — одна задача. Нам важлива механіка збереження, а не формат.
Перед читанням файла спробуємо відновитися з .bak, а під час збереження — писати через WriteWithBackup.
import (
"os"
"strings"
)
func LoadTasks(path string) ([]string, error) {
if err := RestoreFromBackup(path); err != nil {
return nil, err
}
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
return []string{}, nil
}
if err != nil {
return nil, err
}
return strings.Split(strings.TrimSpace(string(b)), "\n"), nil
}
Зверніть увагу на логіку: якщо файла ще немає — це нормальна ситуація, повертаємо порожній список. Якщо він є — читаємо його.
Збереження — це збирання рядків і запис із резервною копією:
import "strings"
func SaveTasks(path string, tasks []string) error {
data := []byte(strings.Join(tasks, "\n") + "\n")
return WriteWithBackup(path, data, 0644)
}
Для решти коду застосунку це виглядає як звичайний Save, але всередині вже є «страховка»: попередня версія лежить у .bak.
5. Типові помилки під час роботи з .bak
Помилка № 1: робити .bak після публікації нової версії.
Це виглядає логічно лише перші пʼять секунд: «спочатку збережемо, потім зробимо копію». Але тоді в .bak опиниться не попередня версія, а нова. У момент, коли вам терміново потрібен відкат, ви виявите, що відкочуватися нікуди. Резервну копію створюємо до публікації — це ключовий порядок.
Помилка № 2: не домовитися, що робити зі старим .bak.
Якщо один розробник очікує, що .bak буде перезаписано, а інший — що він буде «вічним», у робочому середовищі ви отримаєте або помилку rename, або загадкові старі дані, які раптово спливають під час відновлення. Політика має бути одна й очевидна: у навчальному варіанті зазвичай простіше перезаписувати .bak.
Помилка № 3: намагатися відновити поверх наявного основного файла без явного правила.
Іноді реалізують відновлення так: «якщо .bak є — відновимо завжди». Це небезпечно: ви можете знищити актуальний файл просто тому, що .bak лишився від старого запуску. Базова стратегія безпечніша: відновлювати лише тоді, коли основного файла немає, і не чіпати його, якщо він існує.
Помилка № 4: забувати, що .bak — не “магічна пігулка”, а такий самий файл.
Його теж може не вдатися створити (немає прав), його теж може не вдатися перейменувати (файл зайнятий), його теж можна випадково видалити. Тому хороший код робить дві речі: повертає помилки з контекстом і не приховує факт, що «резервна копія не оновилася».
Помилка № 5: залишати сміттєві temp‑файли у разі помилок.
Коли ви додаєте .bak, кількість кроків збільшується, і спокуса забути os.Remove(tmpName) зростає. А потім директорія перетворюється на «кладовище тимчасових файлів». У кожному ранньому виході після створення temp‑файла має бути зрозуміле очищення (через defer або акуратні Remove у гілках помилок).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ