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 | |
всегда «цель» | текущее состояние хранилища |
| temp | |
только во время записи | будущая версия main (ещё не принята) |
| backup | |
после хотя бы одного сохранения | предыдущая версия 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‑слоя почти всегда лучше фиксированное имя рядом с основным файлом.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ