JavaRush /Курси /Go SELF /Резервна копія .bak...

Резервна копія .bak: стратегія створення та відновлення

Go SELF
Рівень 42 , Лекція 2
Відкрита

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 у гілках помилок).

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ