JavaRush /Курсы /Swift SELF /Backup‑файл .bak и стратегия отката

Backup‑файл .bak и стратегия отката

Swift SELF
61 уровень , 1 лекция
Открыта

1. Зачем нужен .bak, даже если есть temp → replace

Если вы почувствовали уверенность уровня «ну всё, я теперь делаю атомарную замену, я бессмертен», то у меня есть новости. temp → replace действительно сильно снижает шанс получить «обрезанный» JSON, но он не превращает мир в идеальное место. Backup нужен не потому, что мы не доверяем своему коду, а потому что мы живём в реальности, где файлы могут портиться не только в момент нашей записи.

Представьте, что temp → replace у нас работает идеально: мы пишем новый снимок в .tmp, затем заменяем основной library.json. Но что если основной файл окажется испорчен не из‑за этой операции? Например, файл изменили руками, синхронизация/бэкап‑утилита «помогла», диск выдал ошибку, или кто-то запустил старую версию программы, которая пишет другой формат. В этот момент «атомарная запись» вам честно скажет: «я молодец», но откатываться вам некуда. Вот тут .bak и становится вашим маленьким парашютом.

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

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

Чтобы backup работал как инструмент, а не как «ещё один непонятный файл, который иногда появляется», нужно договориться о правилах. Backup — это часть политики хранения, значит правила должны быть простыми и проверяемыми.

В LibraryCLI будем придерживаться такой модели: рядом с основным файлом library.json лежит library.json.bak. Этот .bak хранит предыдущую версию основного файла, то есть «последнюю заведомо хорошую». Ключевая фраза — предыдущую. Backup не должен быть копией temp, не должен быть «какой-нибудь случайной версией», и не должен обновляться, если мы не уверены, что основной файл был корректным.

Ниже — табличка, чтобы не путать роли файлов:

Файл Пример имени Когда появляется Что внутри по смыслу
main
library.json
всегда «цель» текущее состояние хранилища
temp
library.json.<uuid>.tmp
только во время записи будущая версия main (ещё не принята)
backup
library.json.bak
после хотя бы одного сохранения предыдущая версия main

Мини‑схема состояния, которую удобно держать в голове:

flowchart TD
    A[Есть старый main] --> B[Делаем backup из main]
    B --> C[Пишем новый temp]
    C --> D[Заменяем main temp-ом]
    D --> E[Готово: main обновлён, bak = прошлый main]

Эта схема специально не обсуждает загрузку при запуске и восстановление — это отдельная тема. Сейчас нам важно научиться создавать backup правильно и встроить его в процесс сохранения.

3. Создаём .bak: путь и копирование

URL для backup и почему имя должно быть предсказуемым

Прежде чем копировать файлы, нужно договориться, как мы строим путь к .bak. И тут внезапно оказывается, что «давайте прилепим -backup-final-v2(1).json» — плохая идея. Backup нужен для автоматического отката, значит его имя должно быть предсказуемым, как дедлайн в пятницу: вы не рады, но он стабилен.

Самый простой и читаемый вариант — добавить расширение .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.

Ручной backup через copyItem

Самый «честный» способ: если основной файл существует, мы копируем его в .bak. Если .bak уже есть — удаляем и копируем заново. Логика простая, и для новичка это большой плюс: меньше магии, больше контроля.

Начнём с функции, которая создаёт backup только если main существует. Если main ещё не существует (первый запуск, база пустая) — backup не нужен.

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)
}

Здесь есть важная философия: backup делается из main, а не из каких-то «данных в памяти». Это гарантирует, что .bak — это именно предыдущая версия файла, а не «мы так думаем».

Теперь — момент, который часто вызывает вопросы: а что если copyItem упал? Например, нет прав, нет места, файловая система ругается. Ответ — это и есть часть политики. Для учебного проекта будем считать, что ошибка backup — это ошибка сохранения. То есть «не смогли обеспечить надёжность — не сохраняем».

В реальных продуктах иногда допускают сохранение без backup, но тогда это должно быть осознанное решение, а не «ой, само получилось».

4. Backup через replaceItemAt: системный вариант

Есть альтернативный подход: иногда система может сделать backup за нас прямо в момент замены файла. Это делается через replaceItemAt(_:withItemAt:backupItemName:options:resultingItemURL:). Звучит как заклинание, но смысл такой: «замени main на temp, а старый main положи в backup с указанным именем».

Почему это удобно? Потому что операция замены и создание backup происходят в одном системном действии, и шанс получить странные промежуточные состояния ниже. Но есть нюансы: этот метод ожидает, что основной файл уже существует (иначе заменять нечего), а ещё важно понимать, какое имя backup получится и где он появится.

Мини‑пример функции замены с backup‑именем:

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 как имя файла (не полный путь). Backup будет создан рядом с mainURL. При этом вы заметите лёгкую «двойственность»: в ручном подходе мы получаем library.json.bak через appendingPathExtension, а в системном — через lastPathComponent + ".bak". В итоге имя совпадёт, но стиль разный.

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

5. Встраиваем backup в сохранение

Соберём всё в одну «политику сохранения». Раньше у нас был temp → replace: сначала пишем временный файл, затем заменяем основной. Теперь добавляем шаг: перед заменой мы создаём backup текущего main.

Важно соблюдать порядок. Мы не хотим сделать backup из temp (это будет не «предыдущая версия», а «новая»). Мы также не хотим обновлять backup, если main отсутствует. Поэтому порядок такой: backup (если есть что бэкапить) → 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. Rollback: восстановление из .bak при сбое

Слово «rollback» звучит так, будто мы запускаем базу данных уровня банка, но по смыслу это очень простая идея: если мы начали операцию сохранения и что-то сломалось, мы хотим вернуться к последнему стабильному состоянию. В нашем случае «последнее стабильное состояние» — это либо старый main, либо .bak, если main внезапно пропал.

Чаще всего, если replaceMain упал, основной файл просто останется прежним. Но иногда полезно иметь «страховочный» код: если main исчез, а .bak есть — восстановим main из .bak. Это и будет rollback.

Напишем маленькую функцию восстановления main из backup. Она ничего не «понимает» про 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)
}

Теперь встроим rollback в 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? выглядит немного «лениво». Но это осознанно: если rollback не удался, мы всё равно уже в состоянии ошибки сохранения, и «ошибка отката» не должна скрыть оригинальную проблему. В реальном проекте такие вещи логируют, но пользователю обычно показывают одну основную ошибку («не удалось сохранить данные»), а детали пишут в лог.

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("Saved!") // Saved!

После первого сохранения у вас появится library.json. После второго сохранения (когда main уже существует) появится и library.json.bak, который будет содержать предыдущую версию library.json.

И это тот момент, когда вы можете почувствовать себя взрослым разработчиком: рядом с вашей программой начинает жить маленькая файловая экосистема. Главное — не забывать, что это не «мусор», а часть контракта хранения.

8. Типичные ошибки при работе с .bak и откатом

Ошибка №1: делать backup из temp‑файла, потому что «он же новый и хороший».
Такой backup перестаёт быть backup‑ом и превращается в копию того, что вы и так собирались сохранить. Если потом основной файл окажется плохим, откатываться будет некуда: .bak содержит то же самое. Backup по смыслу должен хранить предыдущую версию main, а не будущую.

Ошибка №2: обновлять .bak, даже когда основной файл подозрительный или пустой.
Иногда разработчик пишет «если main существует — копируем в .bak», не задумываясь, что main мог быть уже битым. Тогда вы перезаписываете хорошую резервную копию плохими данными. В идеальной политике backup обновляют только когда уверены, что main был валидным. На учебном уровне мы оставляем правило простым, но мысль запомните: .bak — это «последняя хорошая версия», а не «последняя попавшаяся».

Ошибка №3: забывать, что copyItem не перезаписывает файл, если он уже существует.
FileManager.copyItem упадёт с ошибкой, если library.json.bak уже есть. Если не удалять старый .bak, сохранение начнёт «случайно падать» на втором запуске. Поэтому перед копированием мы либо удаляем старый .bak, либо выбираем системный replaceItemAt(... backupItemName: ...).

Ошибка №4: пытаться «откатить всегда» при любой ошибке, стирая рабочий main.
Rollback нужен как страховка, а не как паника. Если основной файл на месте, лучше его не трогать. Самая безопасная стратегия в нашей реализации: восстанавливать из .bak только когда main пропал. Иначе можно устроить себе «я сам себе враг»: сохранение почти получилось, main остался старым (но рабочим), а вы поверх него восстановили ещё более старый .bak.

Ошибка №5: делать .bak с непредсказуемым именем.
Если вы добавляете к имени .bak случайный UUID или дату, то потом автоматический откат превращается в «найди нужный файл среди 17 кандидатов». Это уже не backup, а коллекционирование артефактов. Для одного backup‑слоя почти всегда лучше фиксированное имя рядом с основным файлом.

1
Задача
Swift SELF, 61 уровень, 1 лекция
Недоступна
Имя бэкапа
Имя бэкапа
1
Задача
Swift SELF, 61 уровень, 1 лекция
Недоступна
Резервная копия
Резервная копия
1
Задача
Swift SELF, 61 уровень, 1 лекция
Недоступна
Бэкап без боли
Бэкап без боли
1
Задача
Swift SELF, 61 уровень, 1 лекция
Недоступна
Сейф-сейв с откатом
Сейф-сейв с откатом
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ