1. Recovery: какие бывают исходы
Когда мы говорим «recovery» (восстановление), это звучит как что-то из мира баз данных и больших серверов, но на самом деле это очень бытовая штука. Ваше CLI‑приложение тоже живёт в реальном мире: оно падает, его убивают, его запускают из странных директорий, а иногда рядом с ним живёт антивирус с характером. Recovery — это не «починка всего на свете», а заранее описанная реакция на то, что диск может быть в неидеальном состоянии.
Проблема наивного подхода «прочитал Data, декодировал JSON, если ошибка — сделаю пустую базу» в том, что он маскирует потерю данных. Пользователь потерял библиотеку книг, а вы такие: «О, ну ладно, вот вам чистый лист, как в понедельник». Мы хотим другую культуру: сначала попытка загрузить main, затем разумная попытка загрузить backup, и только потом — пустая база (если политика допускает) или понятная ошибка.
Три исхода восстановления и их смысл
Очень важно с самого начала разделить три исхода восстановления, потому что они про разное.
Пустая база — это нормальный исход, когда данных действительно нет: например, первый запуск, пользователь удалил файл осознанно, или вы храните данные в отдельной директории, которая ещё не создана. Пустая база не должна выглядеть как «мы потеряли данные, но делаем вид, что всё ок». Это именно «данных нет — начинаем с нуля».
Backup — это сценарий «данные есть, но основной файл не годится». И тут ключевое: backup не должен подменять собой нормальную работу. Это аварийный парашют, который открывается, когда основной купол порван. Если main валиден, мы не трогаем .bak, иначе мы превращаем backup в рулетку.
Ошибка пользователю — это сценарий «мы не можем безопасно продолжать». Например, нет прав доступа к директории, диск повреждён так, что чтение падает, файл занят или находится в состоянии, где чтение невозможно. В таком случае делать пустую базу — опасно: пользователь может думать, что «всё сохранилось», а вы на самом деле просто не смогли прочитать данные и начали с нуля.
Чтобы закрепить различие, удобно держать в голове такую таблицу:
| Ситуация на диске | Что это означает | Правильное действие |
|---|---|---|
| main отсутствует, .bak отсутствует | Скорее всего первый запуск | Создать пустую базу |
| main есть и декодируется | Всё хорошо | Использовать main |
| main есть, но не декодируется, .bak декодируется | main повреждён, есть «последняя хорошая версия» | Использовать .bak и восстановить main |
| main не читается (I/O), даже если .bak есть | Проблема доступа/диска, не факт что данные «пропали» | Показать ошибку пользователю |
| main не декодируется и .bak не декодируется | Повреждение данных, безопасного пути нет | Ошибка пользователю (и лог разработчику) |
2. Ошибки I/O и декодирования: разделяем, иначе recovery сломается
Сейчас будет маленькая, но очень практичная мысль. Одна и та же конструкция try Data(contentsOf: ...) может упасть по причинам, которые принципиально отличаются: «файла нет», «нет прав», «диск не читается», «путь не существует». А JSONDecoder().decode(...) падает по другой причине: «байты прочитали, но они не JSON» или «JSON не соответствует модели».
Для recovery нам критично отличать:
I/O‑ошибку: «не могу прочитать файл как байты». Это часто проблема окружения, и в этом случае лучше остановиться и объяснить пользователю, что не так.
Decode‑ошибку: «байты есть, но структура не та». Это классическая «битая база» или несовместимый формат. Вот тут backup имеет смысл.
Чтобы это различие стало явным в коде, заведём свою ошибку загрузки. Да, Swift и так даёт Error, но нам нужен смысл, а не «что-то пошло не так».
import Foundation
enum StorageLoadError: Error {
case io(Error) // не смогли прочитать байты
case decode(Error) // байты прочитали, но JSON не подходит
}
Заметьте: мы не делаем case fileNotFound, потому что отсутствие файла — это чаще не «ошибка», а отдельный сценарий recovery (пустая база). Это как «в холодильнике нет молока»: неприятно, но не exception уровня «всё падаем».
Мини‑блоки: чтение и декодирование как отдельные шаги
Если попытаться написать recovery одним большим do/catch, получится «комбайн», который сложно читать и ещё сложнее поддерживать. Гораздо легче, когда у нас есть маленькие функции, каждая делает одно дело.
Начнём с функции «прочитать и декодировать DTO». Здесь важно аккуратно завернуть ошибки, чтобы верхний уровень понимал: это I/O или decode.
import Foundation
func loadDTO<T: Decodable>(_ type: T.Type, from url: URL) throws -> T {
do {
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(T.self, from: data)
} catch let error as DecodingError {
throw StorageLoadError.decode(error)
} catch {
throw StorageLoadError.io(error)
}
}
Обратите внимание на стиль: мы отдельно ловим DecodingError, а всё остальное считаем I/O. Это не «идеальная классификация всех ошибок во вселенной», но для recovery этого достаточно и это уже сильно лучше, чем один безликий catch.
3. Алгоритм recovery: main → backup → empty
Сам алгоритм лучше воспринимать как дорожную развязку. Да, можно попытаться держать всё в голове, но гораздо приятнее нарисовать схему и следовать ей.
flowchart TD
A[Старт загрузки] --> B{main существует?}
B -- нет --> C{backup существует?}
C -- нет --> D[Пустая база]
C -- да --> E[Пробуем загрузить backup]
E -- успех --> F["Используем backup (возможно восстановим main)"]
E -- ошибка --> G[Ошибка пользователю]
B -- да --> H[Пробуем загрузить main]
H -- успех --> I[Используем main]
H -- decode ошибка --> J{backup существует?}
J -- да --> E
J -- нет --> D2[Пустая база или ошибка по политике]
H -- io ошибка --> G
Главная идея: I/O‑ошибка при чтении main — это почти всегда «не трогай данные, скажи пользователю». Decode‑ошибка — это «похоже, файл битый, попробуем backup».
Реализация: возвращаем данные и решение recovery
В CLI‑приложении нам важно не просто получить DTO, но и понимать, что произошло. Потому что UX (сообщение пользователю) зависит от исхода: «создали пустую базу» — это одно сообщение, «восстановились из backup» — другое, а «ошибка доступа» — третье.
Для этого сделаем маленький enum решения recovery. Он будет полезен и для логов, и для сообщений.
import Foundation
enum RecoveryDecision {
case loadedMain
case loadedBackup
case createdEmpty
}
А теперь функция, которая возвращает tuple: (dto, decision). Tuple тут подходит: это «два результата», которые логически связаны.
Пусть у нас есть DTO контейнер хранилища (упрощённо):
import Foundation
struct LibraryFileDTO: Codable {
let schemaVersion: Int
var items: [String]
}
И вот сам recovery:
import Foundation
func recoverLibrary(mainURL: URL, backupURL: URL) throws -> (LibraryFileDTO, RecoveryDecision) {
let fm = FileManager.default // FileManager — базовый API для файлов в Foundation
if fm.fileExists(atPath: mainURL.path) {
do {
let dto = try loadDTO(LibraryFileDTO.self, from: mainURL)
return (dto, .loadedMain)
} catch StorageLoadError.decode {
// main битый как JSON/DTO — можно пробовать backup
}
}
if fm.fileExists(atPath: backupURL.path) {
let dto = try loadDTO(LibraryFileDTO.self, from: backupURL)
return (dto, .loadedBackup)
}
return (LibraryFileDTO(schemaVersion: 1, items: []), .createdEmpty)
}
Здесь есть одна намеренная недоговорённость: мы «проглотили» decode‑ошибку main, но не I/O‑ошибку. Однако сейчас мы её не обрабатываем явно — потому что catch StorageLoadError.decode ловит только decode. Если main упал по I/O, функция выбросит ошибку наружу (что для нас и правильно).
Если вы сейчас подумали: «А почему мы не делаем пустую базу, если main не читается?» — вы только что избежали очень типичного бага «тихая потеря данных».
Восстановление main из .bak после успешной загрузки backup
Самая опасная часть recovery — это «перезаписать main». Потому что если вы ошибётесь, вы можете потерять даже шанс на диагностику (а иногда и на ручное восстановление). Поэтому правило простое: восстанавливать main можно только тогда, когда .bak успешно декодировался.
Сделаем маленькую функцию восстановления: удалить битый main и скопировать .bak на его место. Да, это «в лоб», но зато предсказуемо.
import Foundation
func restoreMainFromBackup(mainURL: URL, backupURL: URL) throws {
let fm = FileManager.default
if fm.fileExists(atPath: mainURL.path) {
try fm.removeItem(at: mainURL) // удаляем повреждённый main
}
try fm.copyItem(at: backupURL, to: mainURL) // ставим backup как новый main
}
Теперь соединим с recovery: если мы загрузились из backup — можно попробовать восстановить main. Но в этом месте важно не «сломать запуск», если восстановление main не удалось. Например, .bak читается, а вот права на перезапись main отсутствуют. Данные мы хотя бы загрузили — значит, можно продолжить работу, но предупредить (через лог) и/или показать пользователю сообщение.
Мы добавим try? не на загрузку, а на «вторичный» шаг восстановления main. Это хороший пример того, где try? уместен.
import Foundation
func recoverAndMaybeRestore(mainURL: URL, backupURL: URL) throws -> (LibraryFileDTO, RecoveryDecision) {
let (dto, decision) = try recoverLibrary(mainURL: mainURL, backupURL: backupURL)
if decision == .loadedBackup {
try? restoreMainFromBackup(mainURL: mainURL, backupURL: backupURL)
// Если не вышло — живём дальше: данные уже загружены из backup.
}
return (dto, decision)
}
Это и есть тот самый «прагматичный recovery»: мы спасаем данные в память, а уже потом пытаемся привести диск в порядок.
4. Сообщения пользователю и политика
Когда recovery сработал, у нас появляется вопрос: что сказать пользователю? И тут легко попасть в крайности. Если молчать всегда — пользователь не поймёт, что его библиотека только что была восстановлена из backup. Если писать страшные сообщения в стиле «DECODE FAILED, STORAGE CORRUPTION DETECTED» — пользователь подумает, что вы взломали Пентагон (а он всего лишь хотел добавить книгу).
Хороший тон выглядит так: короткое человеческое сообщение пользователю и подробный диагноз в логах.
В нашем курсе у нас уже есть Logger (единый контракт логирования модуля хранения). Не будем заново изобретать интерфейс, просто покажем идею использования: по decision печатаем дружелюбный текст. В CLI это обычно print, потому что это «вывод пользователю», а логгер — «диагностика».
import Foundation
func printRecoveryMessage(_ decision: RecoveryDecision) {
switch decision {
case .loadedMain:
break // всё штатно, можно не шуметь
case .loadedBackup:
print("Хранилище восстановлено из резервной копии (.bak).")
case .createdEmpty:
print("Хранилище не найдено: создана пустая база.")
}
}
А вот если мы поймали ошибку (особенно I/O), тут уже нужен другой текст. И важно: это не «пустая база», это «мы не смогли получить доступ». То есть пользователь должен понять, что исправлять надо окружение: путь, права, диск.
Пример «верхнего уровня» обработки:
import Foundation
do {
let (dto, decision) = try recoverAndMaybeRestore(mainURL: mainURL, backupURL: backupURL)
printRecoveryMessage(decision)
print("Books:", dto.items.count) // Books: 0 (или больше)
} catch {
print("Ошибка доступа к хранилищу. Проверьте путь и права доступа.")
}
Да, это сообщение короткое. И это нормально. Подробности — в лог. Пользователю не нужно читать стек‑трейс (если только ваш пользователь не вы сами в 3 ночи, но это отдельный жизненный жанр).
Нюанс политики: когда пустая база допустима
Сценарий «создать пустую базу» кажется очень удобным: всегда можно «продолжить работу». Но это удобство иногда превращается в потайную дверь для потери данных. Представьте: файл есть, но сломан (decode error), и вы вместо восстановления из backup делаете пустую базу. Для пользователя это будет выглядеть как «все книги исчезли». Технически вы «продолжили работу», но по факту вы сделали хуже.
Поэтому пустая база должна быть допустима только в ситуациях, где отсутствие данных логично: нет main, нет .bak. Для «файл есть, но битый» лучше считать это ошибкой, если backup отсутствует. В некоторых приложениях допускается «начать с нуля даже если файл битый», но это должно быть явно оформлено как действие пользователя (например, команда «сбросить базу»). Мы такие команды пока не обсуждаем, поэтому в рамках этой лекции правило простое: битый main без backup — это ошибка пользователю, а не молчаливый сброс.
Чтобы выразить это кодом, добавим параметр политики. Он не про «красоту», а про честность поведения.
import Foundation
enum CorruptionPolicy {
case fail
case recreateEmpty
}
И применим его в recovery (показано коротко, без лишней архитектуры):
import Foundation
func recoverLibrary(
mainURL: URL,
backupURL: URL,
policy: CorruptionPolicy
) throws -> (LibraryFileDTO, RecoveryDecision) {
let fm = FileManager.default
if fm.fileExists(atPath: mainURL.path) {
do {
let dto = try loadDTO(LibraryFileDTO.self, from: mainURL)
return (dto, .loadedMain)
} catch StorageLoadError.decode(let e) {
if !fm.fileExists(atPath: backupURL.path), policy == .fail {
throw StorageLoadError.decode(e)
}
}
}
if fm.fileExists(atPath: backupURL.path) {
let dto = try loadDTO(LibraryFileDTO.self, from: backupURL)
return (dto, .loadedBackup)
}
return (LibraryFileDTO(schemaVersion: 1, items: []), .createdEmpty)
}
Теперь поведение «битый main без backup» становится управляемым и предсказуемым, а не случайным.
5. Типичные ошибки
Ошибка №1: «при любой ошибке делаем пустую базу».
Это самая частая и самая дорогая ошибка в recovery. Она превращает реальную проблему (битый файл, нет прав, диск не читается) в тихую потерю данных. Правильный подход — отличать I/O от decode и не делать пустую базу, если есть шанс, что данные просто недоступны.
Ошибка №2: восстановление main из .bak без проверки, что .bak декодируется.
Иногда код «на автомате» видит .bak и копирует его на место main, а потом пытается загрузить. Если .bak тоже повреждён, вы просто перезаписали main мусором и усложнили себе жизнь. Сначала загрузка .bak в память, только потом восстановление main.
Ошибка №3: использование try? на критичных шагах загрузки.
try? loadDTO(...) на main и backup выглядит компактно, но убивает смысл ошибок: вы не знаете, это I/O или decode. А значит, не можете принять правильное решение. try? хорош в «вторичных» шагах (например, попытка восстановить main после успешной загрузки), но плох в основном алгоритме выбора источника.
Ошибка №4: смешивание сценариев «файла нет» и «файл есть, но битый».
Отсутствие main — нормальный старт. Битый main — сигнал, что что-то пошло не так, и нужно либо восстановление из backup, либо явная ошибка. Если вы объединяете эти случаи, вы начинаете относиться к потере данных как к «обычному делу», а это плохая привычка для любых программ, которые хоть что-то хранят.
Ошибка №5: слишком подробная «техническая» ошибка пользователю.
Печатать пользователю DecodingError.keyNotFound(...) — это как объяснять человеку на кассе супермаркета, что «у терминала не прошёл TLS-handshake». Пользователю нужно коротко: «не удалось загрузить хранилище, проверьте права/файл повреждён». А детальная диагностика должна уходить в лог, где её можно спокойно читать без дрожи в глазах.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ