1. JSON‑файл как снимок и почему он легко ломается
Если вы делаете маленькое CLI‑приложение, JSON‑файл кажется идеальным хранилищем: читается человеком, быстро сериализуется через Codable, легко переносится и дебажится. Но есть неприятная правда: «снимок состояния» обычно записывают целиком, перезаписывая файл полностью. И вот этот момент перезаписи — самый опасный.
В LibraryCLI мы обычно храним «всю библиотеку» одним JSON‑контейнером: версия схемы, массив книг, возможно индекс или метаданные (пока неважно). Это похоже на фотографию: вы не дорисовываете по пикселю, а делаете новое фото и заменяете старое. Удобно, пока фотоаппарат не сел на середине кадра.
Представим, что у нас есть DTO контейнера (упрощённо):
import Foundation
struct LibraryFileDTO: Codable {
let schemaVersion: Int
let items: [String]
}
let dto = LibraryFileDTO(schemaVersion: 1, items: [])
let data = try JSONEncoder().encode(dto) // JSON bytes готовы
print(data.count) // например: 31
Ключевой момент: после encode у нас есть Data. Это просто байты. Дальше начинается «мир диска», где могут происходить вещи, которые не зависят от вашей логики и не спрашивают у вас разрешения.
Что такое «повреждённый JSON» и почему он «ломается молча»
Когда говорят «файл повредился», новички часто представляют что-то драматичное: искры, дым, надпись “Your file is corrupted”. В реальности чаще скучнее (и от этого опаснее): файл становится частично записанным, обрезанным или смешанным из кусков старой и новой версии, и это выглядит как обычный текстовый файл — просто неправильный.
Проблема в том, что чтение файла и понимание файла — разные этапы. Система может вполне успешно прочитать байты: «да, на диске лежат 143 байта, вот они». Но JSONDecoder затем честно скажет: «это невалидный JSON» или «валидный JSON, но не соответствует структуре LibraryFileDTO». И это отличная новость: декодер не пытается «угадать», что вы имели в виду, он защищает вас от тихой порчи данных.
Давайте прямо руками создадим «битый JSON» и посмотрим, как именно это проявляется:
import Foundation
struct LibraryFileDTO: Codable {
let schemaVersion: Int
let items: [String]
}
let url = URL(fileURLWithPath: "library.json")
try Data("{".utf8).write(to: url) // намеренно битый JSON
do {
let data = try Data(contentsOf: url)
_ = try JSONDecoder().decode(LibraryFileDTO.self, from: data)
print("Decode OK") // сюда не попадём
} catch {
print("Decode failed:", error) // Decode failed: ...
}
Здесь важно почувствовать идею: Data(contentsOf:) почти всегда «удачно читает» (если файл существует и есть права), а вот декодирование — это уже проверка смысла.
Прямая запись поверх файла и «полуфайл»
Сейчас подойдём к главному механизму поломки. Типичная «простая запись» выглядит так: мы открываем существующий library.json и пишем туда новое содержимое. Часто при этом файл сначала обнуляется (truncate), а потом в него начинают поступать новые байты. Если процесс умер между этими двумя фазами — вы остаётесь с пустым файлом или с огрызком.
Самый коварный момент: ошибка может случиться после того, как файл уже изменён. То есть вы ловите throw, но данные уже частично потеряны. Новички ожидают «если упало — значит ничего не поменялось». В файловом мире это не всегда так.
Вот пример «наивной» записи, которую очень легко написать и очень легко потом проклинать:
import Foundation
let url = URL(fileURLWithPath: "library.json")
let jsonText = #"{"schemaVersion":1,"items":["A","B","C"]}"#
// Прямая запись без атомарности: риск "полуфайла" выше.
try jsonText.write(to: url, atomically: false, encoding: .utf8)
print("Saved") // Saved
Чтобы картинка стала совсем ясной, представьте запись как временную линию:
sequenceDiagram
participant App as LibraryCLI
participant FS as File system
App->>FS: открыть library.json на запись
FS-->>App: ok
App->>FS: truncate (обнулить файл)
FS-->>App: ok (старое содержимое уже исчезло)
App->>FS: записать байты нового JSON (часть 1)
FS-->>App: ok
App->>FS: записать байты нового JSON (часть 2)
Note over App: здесь процесс может упасть / закончиться / получить ошибку
Проблема не в том, что Swift «плохой», и не в том, что JSON «ненадёжный». Проблема в том, что запись — это процесс во времени, и он может не завершиться.
2. Почему запись может оборваться
В этот момент обычно звучит фраза: «Ну у меня же маленький файл, что с ним случится?» Случится ровно то же, что и с большим: запись — прерываемая операция. Размер влияет на вероятность, но не отменяет сам риск. А если вы хоть раз потеряли данные из-за «редкого случая», вы быстро становитесь философом.
Прерывание записи может произойти по очень земным причинам. Приложение может быть принудительно завершено. На диске может закончиться место ровно в момент записи. Права доступа могут отличаться на разных машинах или после изменения окружения. Файловая система может вернуть ошибку. И да, иногда «всё было нормально», но внезапно write бросает исключение — не потому что вы плохой программист, а потому что внешний мир не подписывал контракт «всегда успешно записывать файл».
Самое неприятное — последствия: если вы писали напрямую в основной файл, вы могли разрушить последнюю рабочую версию. Даже если новая версия не записалась, старая могла быть уже стёрта.
Именно поэтому мы в этом дне говорим: «надёжная запись» — это политика, а не один флажок. Флажок может уменьшить риск одного класса проблем, но он не создаёт запасной план и не заставляет реальность вести себя прилично.
3. Где всплывает проблема: Data читается, decode падает
Давайте закрепим ключевую диагностическую мысль. Если у вас файл повредился, первый симптом обычно появляется в момент декодирования. И это очень логично: декодер — это «охранник на входе» в ваш домен, который не пускает мусор.
Возьмём пример загрузки DTO. Он очень похож на тот, что будет в Repository.load():
import Foundation
struct LibraryFileDTO: Codable {
let schemaVersion: Int
let items: [String]
}
let url = URL(fileURLWithPath: "library.json")
do {
let data = try Data(contentsOf: url) // байты можно прочитать
let file = try JSONDecoder().decode(LibraryFileDTO.self, from: data)
print("OK, items:", file.items.count) // OK, items: ...
} catch {
print("Load/decode failed:", error) // Load/decode failed: ...
}
Обратите внимание на психологию отладки. Если вы видите ошибку decode, не спешите обвинять Codable. Очень часто Codable честно сообщает: «я не могу из этого сделать LibraryFileDTO», потому что данные на диске уже не являются тем JSON, который вы когда-то туда писали.
Кстати, JSONEncoder/JSONDecoder изначально и вводились как более безопасный мост между типами и JSON‑представлением: они не делают вид, что всё хорошо, если всё плохо.
4. Что делает atomically: true и где его границы
Вот место, где новичок обычно хочет магию: «А можно сделать так, чтобы файл никогда не ломался?» Можно сделать сильно надёжнее, но нельзя одним словом отменить физику. Однако atomically: true — реально полезная ступенька, просто нужно понимать её границы.
Смысл атомарной записи (в бытовом смысле) такой: либо на диске остаётся старая версия, либо новая — но не «половина новой». Технически это обычно реализуется через временный файл и последующую замену. В Swift у String.write(to:atomically:encoding:) есть параметр atomically, а у Data.write(to:options:) есть опция .atomic.
Вот пример «чуть более безопасной» записи:
import Foundation
let url = URL(fileURLWithPath: "library.json")
let jsonText = #"{"schemaVersion":1,"items":[]}"#
// Базовая защита: запись старается быть атомарной.
try jsonText.write(to: url, atomically: true, encoding: .utf8)
print("Saved atomically") // Saved atomically
Почему это не «полная политика надёжности»? Потому что даже если одна операция записи защищена от полуфайла, остаются вопросы: что делать, если файл уже повреждён не этой записью? что делать, если запись прошла, но данные внутри логически некорректны? что если одновременно два процесса пытаются сохранить разные версии? где взять «последнюю гарантированно хорошую копию», если основная стала подозрительной?
Если провести аналогию, atomically: true — это ремень безопасности. Он не делает вас бессмертным, но сильно повышает шанс доехать живым при одном типе аварий. А ещё он не заменяет подушки безопасности, тормоза и правило «не гонять в тумане».
Кстати, слово «atomicity» в программировании вообще часто означает «между началом и концом не вклинивается чужое выполнение». В другой области Swift подчёркивает похожую мысль: точки await прерывают атомарность исполнения, потому что в промежутке может вклиниться другая работа. Это другой контекст (конкурентность), но интуиция та же: «есть кусок, который должен быть цельным, иначе появляется окно проблем».
5. “Файла нет” и “файл сломан”: разные реакции
Одна из самых практичных привычек — перестать смешивать ситуации. «Файла нет» — это часто нормальный сценарий: первый запуск, пользователь ещё ничего не сохранял, директория данных пустая. «Файл есть, но не читается/не декодируется» — это уже тревожный сигнал, и реакция обычно другая.
Если вы смешаете эти случаи в одну ветку «ну значит создадим пустую базу», вы рискуете сделать самую обидную вещь: тихо потерять данные. Потому что пустая база выглядит как корректное состояние, и пользователь не понимает, что «всё пропало».
Пока мы не строим полноценный recovery, но даже на этом этапе можно сделать минимально здравую развилку: проверяем существование файла, и только потом пробуем декодировать.
import Foundation
let fm = FileManager.default
let url = URL(fileURLWithPath: "library.json")
if !fm.fileExists(atPath: url.path) {
print("No data file yet") // No data file yet
} else {
print("File exists, try to decode...") // File exists, try to decode...
}
А дальше — попытка decode, и если она падает, это не «как будто файла нет», а отдельная ситуация, которую вы обязаны заметить хотя бы в логах.
Для наглядности полезна маленькая таблица (это не «список дел», а карта сценариев):
| Ситуация на диске | Как выглядит в коде | Смысл для приложения |
|---|---|---|
| Файла нет | |
Скорее всего первый запуск, можно начать с пустого состояния |
| Файл есть, но не читается | |
Проблема доступа/пути/прав, это не «пустая база» |
| Файл читается, но decode падает | |
Данные повреждены или не соответствуют схеме, это отдельный инцидент |
Эта табличка — ваш компас на отладке. С ним вы перестаёте «лечить всё подорожником» (то есть try? и пустой массив).
6. Данные отдельно, I/O отдельно
Сейчас мы ещё не внедряем полноценный механизм надёжной записи (он будет развёрнут следующим шагом дня), но нам важно уже сегодня заложить правильную архитектурную привычку: отделить «получение байтов JSON» от «записи этих байтов на диск».
Почему это важно? Потому что JSONEncoder и FileManager решают разные задачи. Энкодер отвечает за формат и структуру данных, а файловый слой — за то, как эти данные оказываются на диске. Если вы смешаете их в один «метод на 50 строк», вам будет сложнее тестировать, логировать и улучшать политику записи.
Сделаем маленький каркас, который нам потом будет легко усиливать:
import Foundation
struct LibraryFileDTO: Codable {
let schemaVersion: Int
let items: [String]
}
func encodeLibrary(_ dto: LibraryFileDTO) throws -> Data {
try JSONEncoder().encode(dto)
}
func writeSnapshot(_ data: Data, to url: URL) throws {
try data.write(to: url) // пока наивно, улучшим дальше
}
Эти функции маленькие, читаемые и честно признаются: «пока мы пишем просто». Зато когда мы начнём усиливать надёжность (временный файл, замена, резервная копия, восстановление), нам не придётся переписывать энкодер — мы будем улучшать только слой writeSnapshot.
7. Типичные ошибки
Ошибка №1: “Файл маленький — значит не сломается”.
Маленький файл действительно записывается быстрее, но он не становится волшебно неубиваемым. Если запись — операция во времени, значит у неё есть момент «посередине», и в этот момент может прилететь прерывание. Правильная привычка: проектировать запись как потенциально прерываемую независимо от размера.
Ошибка №2: “Если write бросил ошибку, значит файл точно не изменился”.
Это опасная вера в «транзакционность по умолчанию». При прямой перезаписи файл мог быть уже обнулён или частично перезаписан, а ошибка произошла позже. Поэтому мы не строим логику «раз не сохранилось — откатилось само». Откат — это отдельная политика, которую нужно проектировать.
Ошибка №3: Смешивать “файла нет” и “файл повреждён” в один сценарий.
Если вы в обоих случаях создаёте пустую базу, вы стираете разницу между нормальным первым запуском и аварией с данными. Пользователь увидит «пусто» и не поймёт, что это потеря. Минимальная гигиена — различать fileExists == false и decode‑ошибку, хотя бы для логов и диагностики.
Ошибка №4: Использовать try? на критичных шагах загрузки/декодирования.
try? превращает ошибку в nil, и в момент, когда вам больше всего нужен контекст (что именно сломалось и почему), вы добровольно выбрасываете эту информацию. Иногда try? уместен как инструмент (мы это обсуждали на ошибках), но загрузка основного хранилища — почти никогда не тот случай: вы ослепляете собственный recovery и усложняете отладку.
Ошибка №5: Пытаться лечить проблему “полуфайла” только форматированием JSON.
Иногда хочется включить prettyPrinted, добавить sortedKeys, сделать файл «красивее» и надеяться, что это повысит надёжность. Красота файла помогает человеку глазами, но не защищает от прерванной записи. Надёжность здесь достигается именно политикой записи и восстановления, а не косметикой JSON.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ