JavaRush /Курси /Swift SELF /Файл резервної копії .bak і стратегія відкату

Файл резервної копії .bak і стратегія відкату

Swift SELF
Рівень 61 , Лекція 1
Відкрита

1. Навіщо потрібен .bak, навіть якщо є temp → replace

Якщо ви вже відчули впевненість рівня «ну все, я тепер роблю атомарну заміну — я безсмертний», то маю для вас новину. temp → replace справді помітно зменшує ризик отримати «обірваний» JSON, але не перетворює світ на ідеальне місце. Резервна копія потрібна не тому, що ми не довіряємо своєму коду, а тому, що живемо в реальності, де файли можуть псуватися не лише під час нашого запису.

Уявіть, що temp → replace у нас працює бездоганно: ми пишемо новий знімок у .tmp, а потім замінюємо основний library.json. Але що, якщо основний файл виявиться пошкодженим не через цю операцію? Наприклад, його змінили вручну, синхронізація або утиліта резервного копіювання «допомогла», диск видав помилку або хтось запустив стару версію застосунку, яка пише інший формат. У цей момент «атомарний запис» чесно скаже: «я молодець», але відкотитися буде нікуди. Ось тут .bak і стає вашим маленьким парашутом.

Зафіксуймо цю ідею: temp → replace захищає цілісність однієї операції запису, а .bak дає шанс відновитися, коли основний файл уже виявився пошкодженим з будь-якої причини. І так, це ще й варіант «плану Б» для користувача: краще втратити останні зміни, ніж усе одразу.

2. Правила для .bak у LibraryCLI

Щоб резервна копія працювала як інструмент, а не як «ще один незрозумілий файл, який іноді з’являється», потрібно домовитися про правила. Резервна копія — це частина політики збереження, отже правила мають бути простими й перевірюваними.

У LibraryCLI дотримуємося такої моделі: поряд з основним файлом library.json лежить library.json.bak. Цей .bak зберігає попередню версію основного файла, тобто «останню напевно добру». Ключове тут — попередню. Резервна копія не має бути копією temp, не має бути «якоюсь випадковою версією» і не має оновлюватися, якщо ми не впевнені, що основний файл був коректним.

Нижче — таблиця, щоб не плутати ролі файлів:

Файл Приклад імені Коли з’являється Що всередині
основний
library.json
завжди — ціль запису поточний стан сховища
тимчасовий
library.json.<uuid>.tmp
лише під час запису майбутня версія основного файла (ще не прийнята)
резервний
library.json.bak
після хоча б одного збереження попередня версія основного файла

Мінісхема стану, яку зручно тримати в голові:

flowchart TD
    A[Є старий main] --> B[Створюємо резервну копію з main]
    B --> C[Записуємо новий temp]
    C --> D[Замінюємо main тимчасовим файлом]
    D --> E[Готово: main оновлено, bak = попередній main]

Ця схема спеціально не обговорює завантаження під час запуску й відновлення — це окрема тема. Зараз нам важливо навчитися створювати резервну копію правильно і вбудувати її в процес збереження.

3. Створюємо .bak: шлях і копіювання

URL для резервної копії і чому імʼя має бути передбачуваним

Перш ніж копіювати файли, потрібно домовитися, як ми будуємо шлях до .bak. І тут раптом з’ясовується, що «давайте приліпимо -backup-final-v2(1).json» — погана ідея. Резервна копія потрібна для автоматичного відкату, отже її імʼя має бути передбачуваним, як дедлайн у пʼятницю: ви не раді, але він стабільний.

Найпростіший і найчитабельніший варіант — додати розширення .bak до основного URL. У Swift це виглядає гарно: appendingPathExtension("bak").

import Foundation

func backupURL(for mainURL: URL) -> URL {
    mainURL.appendingPathExtension("bak")
}

let mainURL = URL(fileURLWithPath: "library.json")
print(backupURL(for: mainURL).path) // library.json.bak

Зверніть увагу на деталь: це не «заміна розширення», а «додавання розширення». Тому library.json перетворюється на library.json.bak. Це добре: за іменем видно, від чого воно походить, і ми не втрачаємо початкового розширення.

Маленький анти-приклад — так не робимо: намагатися вручну склеювати рядки й додавати ".bak" до path. Це майже завжди призводить до «ой, а в нас два слеші» або «ой, а на Windows...». Ми працюємо через URL, отже й далі працюємо через URL.

Ручна резервна копія через copyItem

Найпростіший спосіб: якщо основний файл існує, копіюємо його в .bak. Якщо .bak уже є — видаляємо його й копіюємо заново. Логіка проста, і для новачка це великий плюс: менше магії, більше контролю.

Почнімо з функції, яка створює резервну копію лише якщо main існує. Якщо main ще не існує (перший запуск, база порожня) — резервна копія не потрібна.

import Foundation

func makeBackupIfPossible(of mainURL: URL) throws {
    let fm = FileManager.default
    let bakURL = backupURL(for: mainURL)

    guard fm.fileExists(atPath: mainURL.path) else { return }

    if fm.fileExists(atPath: bakURL.path) {
        try fm.removeItem(at: bakURL)
    }
    try fm.copyItem(at: mainURL, to: bakURL)
}

Тут є важлива філософія: резервна копія робиться з main, а не з якихось «даних у памʼяті». Це гарантує, що .bak — це саме попередня версія файла, а не «ми так думаємо».

Тепер — момент, який часто викликає запитання: а що, якщо copyItem упав? Наприклад, немає прав, не вистачає місця, файлова система свариться. Відповідь — це й є частина політики. Для навчального проєкту вважатимемо, що помилка резервного копіювання — це помилка збереження. Тобто не змогли забезпечити надійність — не зберігаємо.

У реальних продуктах іноді допускають збереження без резервної копії, але тоді це має бути свідоме рішення, а не «ой, само вийшло».

4. Резервне копіювання через replaceItemAt: системний варіант

Є альтернативний підхід: іноді система може зробити резервну копію за нас просто в момент заміни файла. Це робиться через replaceItemAt(_:withItemAt:backupItemName:options:resultingItemURL:). Звучить як заклинання, але сенс такий: «заміни main на temp, а старий main поклади в резервну копію з указаним іменем».

Чому це зручно? Тому що операція заміни та створення резервної копії відбуваються в одній системній операції, і шанс отримати дивні проміжні стани нижчий. Але є нюанси: цей метод очікує, що основний файл уже існує (інакше замінювати нічого), а ще важливо розуміти, яке імʼя резервної копії вийде і де вона з’явиться.

Мініприклад функції заміни з іменем резервної копії:

import Foundation

func replaceMainUsingSystemBackup(mainURL: URL, tmpURL: URL) throws {
    let fm = FileManager.default
    let bakName = mainURL.lastPathComponent + ".bak"

    _ = try fm.replaceItemAt(
        mainURL,
        withItemAt: tmpURL,
        backupItemName: bakName,
        options: [],
        resultingItemURL: nil
    )
}

Тут ми задаємо bakName як імʼя файла, а не повний шлях. Резервну копію буде створено поруч із mainURL. При цьому ви помітите легку «подвійність»: у ручному підході ми отримуємо library.json.bak через appendingPathExtension, а в системному — через lastPathComponent + ".bak". У результаті імʼя збігається, але стиль різний.

Для навчального проєкту краще почати з ручного copyItem, бо він простіший для розуміння й налагодження. Системний варіант можна підключати пізніше, коли ви впевнено читаєте сигнатури FileManager і не боїтеся довгих методів.

5. Вбудовуємо резервну копію в збереження

Зберімо все в одну політику збереження. Раніше у нас був temp → replace: спочатку пишемо тимчасовий файл, а потім замінюємо основний. Тепер додаємо ще один крок: перед заміною створюємо резервну копію поточного main.

Важливо дотримуватися порядку. Ми не хочемо зробити резервну копію з temp — це буде не «попередня версія», а «нова». Ми також не хочемо оновлювати резервну копію, якщо main відсутній. Тому порядок такий: резервна копія (якщо є що копіювати) → temp → replace.

Зберімо функцію safeSave, яка приймає Data і зберігає в main із підтримкою .bak:

import Foundation

func makeTempURL(for mainURL: URL) -> URL {
    let name = mainURL.lastPathComponent + "." + UUID().uuidString + ".tmp"
    return mainURL.deletingLastPathComponent().appendingPathComponent(name)
}

func replaceMain(mainURL: URL, tmpURL: URL) throws {
    let fm = FileManager.default
    if fm.fileExists(atPath: mainURL.path) {
        _ = try fm.replaceItemAt(mainURL, withItemAt: tmpURL)
    } else {
        try fm.moveItem(at: tmpURL, to: mainURL)
    }
}

func safeSave(data: Data, to mainURL: URL) throws {
    let fm = FileManager.default
    let tmpURL = makeTempURL(for: mainURL)

    do {
        try makeBackupIfPossible(of: mainURL)
        try data.write(to: tmpURL)
        try replaceMain(mainURL: mainURL, tmpURL: tmpURL)
    } catch {
        try? fm.removeItem(at: tmpURL)
        throw error
    }
}

Зверніть увагу: defer ми могли б використати, але тут достатньо catch з прибиранням .tmp. Коли з’явиться складніший сценарій відкату, defer стане ще кориснішим, але поки тримаємо код максимально читабельним.

Ще одна деталь: Data.write(to:) за замовчуванням пише «як уміє». Ми не вмикаємо тут atomically: true, тому що в нас уже є своя політика temp → replace. Два шари «атомарності» одночасно не шкодять, але часто створюють ілюзію, що ви все зрозуміли, хоча насправді просто додали прапорець «про всяк випадок».

6. Відкат: відновлення з .bak у разі збою

Слово «rollback» звучить так, ніби ви запускаєте базу даних рівня банку, але за змістом це дуже проста ідея: якщо ми почали операцію збереження і щось зламалося, ми хочемо повернутися до останнього стабільного стану. У нашому випадку «останній стабільний стан» — це або старий main, або .bak, якщо main раптово зник.

Найчастіше, якщо replaceMain упав, основний файл просто залишиться попереднім. Але іноді корисно мати «страхувальний» код: якщо main зник, а .bak є — відновимо main із .bak. Це і буде відкат.

Напишемо маленьку функцію відновлення main із резервної копії. Вона нічого не «розуміє» про JSON, вона лише про файли.

import Foundation

func restoreFromBackupIfPossible(mainURL: URL) throws {
    let fm = FileManager.default
    let bakURL = backupURL(for: mainURL)

    guard fm.fileExists(atPath: bakURL.path) else { return }
    guard !fm.fileExists(atPath: mainURL.path) else { return }

    try fm.copyItem(at: bakURL, to: mainURL)
}

Тепер вбудуємо відкат у safeSave: якщо сталася помилка, спробуємо відновити main із .bak лише якщо main зник. Це важлива умова: якщо main на місці, ми не чіпаємо його, щоб не зробити «відкат поверх нормального файла».

import Foundation

func safeSaveWithRollback(data: Data, to mainURL: URL) throws {
    let fm = FileManager.default
    let tmpURL = makeTempURL(for: mainURL)

    do {
        try makeBackupIfPossible(of: mainURL)
        try data.write(to: tmpURL)
        try replaceMain(mainURL: mainURL, tmpURL: tmpURL)
    } catch {
        try? fm.removeItem(at: tmpURL)
        try? restoreFromBackupIfPossible(mainURL: mainURL)
        throw error
    }
}

Так, тут try? виглядає трохи «ледачим». Але це свідомо: якщо відкат не вдався, ми все одно вже в стані помилки збереження, і «помилка відкату» не має приховувати початкову проблему. У реальному проєкті такі речі логують, але користувачеві зазвичай показують одну основну помилку («не вдалося зберегти дані»), а деталі пишуть у лог.

7. Мініприклад: зберігаємо LibraryFileDTO із .bak

Щоб це не залишилося набором утиліт «у вакуумі», прив’яжемо до нашої моделі. Припустімо, що наш файл-контейнер (DTO рівня зберігання) виглядає так:

import Foundation

struct LibraryFileDTO: Codable {
    let schemaVersion: Int
    let items: [String]
}

Тепер напишемо функцію, яка кодує DTO в JSON і зберігає через safeSaveWithRollback. Зверніть увагу: ми чітко розділяємо «підготовку даних» і «файлову політику».

import Foundation

func saveLibraryFile(_ file: LibraryFileDTO, to mainURL: URL) throws {
    let encoder = JSONEncoder()
    encoder.outputFormatting = [.prettyPrinted, .sortedKeys]

    let data = try encoder.encode(file)
    try safeSaveWithRollback(data: data, to: mainURL)
}

Маленький приклад «як це виглядає під час запуску» — спрощено, без повноцінного CLI-парсингу:

import Foundation

let mainURL = URL(fileURLWithPath: "library.json")

let file = LibraryFileDTO(schemaVersion: 1, items: ["Clean Code", "Swift Book"])
try saveLibraryFile(file, to: mainURL)

print("Збережено!") // Підтвердження успішного збереження

Після першого збереження у вас з’явиться library.json. Після другого збереження, коли main уже існує, з’явиться і library.json.bak, який міститиме попередню версію library.json.

І це той момент, коли ви можете відчути себе дорослим розробником: поряд із вашою програмою починає жити маленька файлова екосистема. Головне — не забувати, що це не «сміття», а частина контракту зберігання.

8. Типові помилки під час роботи з .bak і відкатом

Помилка № 1: робити резервну копію з temp-файлу, тому що «він же новий і хороший».
Такий підхід перестає бути резервним копіюванням і перетворюється на копію того, що ви й так збиралися зберегти. Якщо потім основний файл виявиться поганим, відкотитися буде нікуди: .bak містить те саме. За змістом резервна копія має зберігати попередню версію main, а не майбутню.

Помилка № 2: оновлювати .bak, навіть коли основний файл підозрілий або порожній.
Іноді розробник пише «якщо main існує — копіюємо в .bak», не замислюючись, що main міг бути вже пошкодженим. Тоді ви перезаписуєте добру резервну копію поганими даними. В ідеальній політиці резервну копію оновлюють лише тоді, коли впевнені, що main був валідним. На навчальному рівні ми залишаємо правило простим, але запам’ятайте думку: .bak — це «остання добра версія», а не «остання випадкова».

Помилка № 3: забувати, що copyItem не перезаписує файл, якщо він уже існує.
FileManager.copyItem упаде з помилкою, якщо library.json.bak уже є. Якщо не видаляти стару .bak, збереження почне «випадково падати» на другому запуску. Тому перед копіюванням ми або видаляємо стару .bak, або обираємо системний replaceItemAt(... backupItemName: ...).

Помилка № 4: намагатися «відкотити завжди» за будь-якої помилки, стираючи робочий main.
Відкат потрібен як страховка, а не як паніка. Якщо основний файл на місці, краще його не чіпати. Найбезпечніша стратегія в нашій реалізації: відновлювати з .bak лише тоді, коли main зник. Інакше можна влаштувати собі «я сам собі ворог»: збереження майже вдалося, main залишився старим, але робочим, а ви поверх нього відновили ще старіший .bak.

Помилка № 5: робити .bak з непередбачуваним іменем.
Якщо ви додаєте до імені .bak випадковий UUID або дату, то потім автоматичний відкат перетворюється на «знайдіть потрібний файл серед 17 кандидатів». Це вже не резервна копія, а колекціонування артефактів. Для одного шару резервного копіювання майже завжди краще фіксоване імʼя поруч з основним файлом.

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