1. Схема файла: корень-объект, schemaVersion и items
Если вы впервые сохраняете список книг в JSON, самая естественная мысль — “ну у меня же список, значит корень файла должен быть массивом”. Логика честная, как утренний будильник: неприятная, но убедительная. Проблема всплывает чуть позже — когда вы захотите добавить к данным что-то ещё, кроме самих книг.
Например, вы захотите хранить номер версии формата, чтобы понимать, “как читать этот JSON”. Или захотите хранить метаданные: дату последнего обновления, источник, флаги, что угодно. Если корень — массив, то положить рядом метаданные просто некуда: JSON может иметь только один корень.
Поэтому мы делаем корень не массивом, а объектом (dictionary-подобной структурой), а сам массив кладём внутрь — в поле items.
Представьте, что JSON-файл — это коробка при переезде. Если вы просто насыпали туда вещи (массив), то через месяц вы уже не помните, из какой это квартиры и что в коробке вообще лежит. Корневой контейнер — это та самая наклейка “Кухня, коробка №3, версия упаковки 1”. Без наклейки вы тоже доедете, но на месте будет весело (не вам).
Корень-массив vs корень-объект: сравнение
Чтобы голова не держала абстракции на честном слове, зафиксируем это в таблице. Она короткая, но сильно экономит нервы.
| Вариант JSON-корня | Как выглядит | Плюсы | Минусы |
|---|---|---|---|
| Корень — массив | |
Простота, сразу видно список | Метаданные класть некуда, формат трудно расширять |
| Корень — объект-контейнер | |
Можно хранить метаданные и данные вместе, проще проверять ожидания | Чуть больше “обвязки”, но это честная цена |
И да, тот самый “объект-контейнер” — это и есть наш LibraryFile.
Минимальная схема: schemaVersion + items
Теперь договоримся о простейшей “схеме” (в смысле договорённости о ключах и типах). Мы хотим:
- чтобы файл всегда был JSON-объектом,
- чтобы в нём был номер версии схемы,
- чтобы в нём был массив элементов (книг).
Вот как это будет выглядеть концептуально:
{
"schemaVersion": 1,
"items": [
{ "id": 1, "title": "Book A" },
{ "id": 2, "title": "Book B" }
]
}
Обратите внимание на важную вещь: названия ключей — часть формата. Если вы сегодня назовёте поле items, а завтра решите “а давайте books”, то ваш декодер завтра не “догадается”, что вы имели в виду. Он честно скажет: “обязательного поля нет” — и будет прав.
3. Модели Book и LibraryFile
Перед тем как кодировать файл, давайте честно опишем, что такое “книга” в нашем учебном CLI. Мы не усложняем: только id и title. Всё остальное (авторы, годы, теги) можно добавить позже — но сегодня нам важнее увидеть сам паттерн контейнера.
Модель книги
Сейчас мы пишем тип, который будет лежать в массиве items. Он должен быть простым, со stored properties, и Codable, чтобы JSONEncoder/JSONDecoder могли автоматически сделать свою работу.
import Foundation
struct Book: Codable {
let id: Int
let title: String
}
Здесь компилятор может синтезировать Codable, потому что все поля тоже “кодируемые” (целое число и строка). Эта идея автосинтеза — одна из причин, почему Codable так любят: меньше рутины, больше смысла.
Корневой контейнер файла
Теперь пишем контейнер. Это не “ещё один тип”, это тип, который описывает формат файла целиком.
import Foundation
struct LibraryFile: Codable {
let schemaVersion: Int
var items: [Book]
}
Заметка по стилю: items — var, потому что мы часто будем “держать файл в памяти” и менять массив книг (добавлять/удалять). А schemaVersion почти всегда let: версия схемы — это не то, что мы “случайно поменяли во время работы программы”.
4. schemaVersion как предохранитель
Новички часто воспринимают schemaVersion как “ну положили число, потому что так сказали”. На самом деле это контракт, который позволяет быстро понять: “я вообще умею читать этот файл или нет”.
schemaVersion помогает в двух ключевых ситуациях.
Первая ситуация — файл не тот. Например, вы случайно подсунули программе чужой JSON. Без версии вы начнёте декодировать, получите загадочную ошибку про типы или отсутствующие поля, и будете разбираться, почему “вдруг сломалось”. С версией вы можете сразу сказать: “я ожидал версию 1, а тут 999 — это не мой формат”.
Вторая ситуация — формат изменился (даже внутри вашего проекта). Вы добавили/переименовали поля, поменяли структуру. Если версия не хранится в файле, отличить “старый формат” от “нового” становится сложно, и программа может начать вести себя непредсказуемо.
И да: сегодня мы не реализуем поддержку нескольких версий — мы только делаем минимальную проверку “я понимаю эту версию или нет”. Но даже это уже огромный шаг от “авось прокатит” к “формат под контролем”.
5. Кодирование и декодирование LibraryFile
Кодируем LibraryFile в JSON
Важно зафиксировать одну мысль: многие новички пытаются кодировать “по частям” (отдельно массив, отдельно метаданные), а потом склеить строками. Так делать не надо. Мы хотим, чтобы весь файл был одним Codable-значением — тогда кодирование это один encode(...).
Соберём маленький пример с тестовыми книгами и выведем JSON как строку (для диагностики). Обратите внимание: JSONEncoder.encode возвращает Data и может бросить ошибку.
import Foundation
let file = LibraryFile(
schemaVersion: 1,
items: [
Book(id: 1, title: "Clean Code"),
Book(id: 2, title: "The Pragmatic Programmer")
]
)
var encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
do {
let data = try encoder.encode(file)
let jsonText = String(data: data, encoding: .utf8) ?? "<not UTF-8>"
print(jsonText) // печатает читаемый JSON
} catch {
print("Encoding failed:", error)
}
Почему мы делаем String(data:encoding:) через ??, а не через !? Потому что Data — это просто байты, и превращение в UTF‑8 строку не обязано быть успешным. В нашем случае JSON почти всегда UTF‑8, но мы учимся писать код так, чтобы он не ломался “из принципа”.
Декодируем LibraryFile и проверяем версию
Декодирование — это место, где “внешний мир” пытается проникнуть в вашу строго типизированную программу. Здесь нельзя вести себя самоуверенно. Если encode — это “я аккуратно упаковал свои данные”, то decode — “мне принесли посылку неизвестного происхождения”.
Мы хотим:
- декодировать LibraryFile из Data,
- проверить, что schemaVersion поддерживается,
- либо вернуть файл, либо бросить понятную ошибку.
Опишем ошибку через enum. Так её удобно различать и печатать.
import Foundation
enum LibraryFileError: Error {
case unsupportedSchemaVersion(Int)
}
Теперь функция декодирования:
import Foundation
func decodeLibraryFile(from data: Data) throws -> LibraryFile {
let decoded = try JSONDecoder().decode(LibraryFile.self, from: data)
guard decoded.schemaVersion == 1 else {
throw LibraryFileError.unsupportedSchemaVersion(decoded.schemaVersion)
}
return decoded
}
Здесь важно, что мы разделяем две разные проблемы.
Если JSON битый или структура не совпадает — JSONDecoder бросит свою ошибку декодирования.
Если JSON валидный, структура совпала, но версия схемы не та — это уже наша доменная ошибка unsupportedSchemaVersion. Это гораздо приятнее, чем “какой-то typeMismatch где-то там”.
Мини-сценарий в памяти: JSON-строка → Data → LibraryFile
Хочется увидеть цельную картинку: как тестовый JSON превращается в тип. Пока мы не трогаем файловую систему (это отдельная тема и отдельная ответственность), мы работаем “в памяти”: строка → данные → модель.
import Foundation
let json = """
{
"schemaVersion": 1,
"items": [
{ "id": 10, "title": "Swift in Depth" }
]
}
"""
do {
let data = Data(json.utf8)
let file = try decodeLibraryFile(from: data)
print(file.items.count) // 1
print(file.items[0].title) // Swift in Depth
} catch {
print("Decode failed:", error)
}
Тут полезно заметить: Data(json.utf8) — это удобный способ получить UTF‑8 байты из строки. Мы прямо проговариваем, что JSON — это текст, а для JSONDecoder он должен стать байтами.
6. Пустой items и интеграция в CLI
Почему items: [] — нормальное состояние
У начинающих часто есть желание считать пустые коллекции ошибкой. Но для формата хранения пустая библиотека — абсолютно нормальна. Пользователь мог ещё ничего не добавить. Программа могла только что стартовать. И это не должно ломать декодирование.
То есть корректный файл — это и такой тоже:
{
"schemaVersion": 1,
"items": []
}
И это очень полезно для устойчивости программы: вам не нужно иметь “специальный случай” “если файла нет, то другое поведение”. На уровне модели всё одинаково: “вот контейнер, в нём массив, он может быть пустым”.
Если хотите быстро увидеть это в Swift:
import Foundation
let empty = LibraryFile(schemaVersion: 1, items: [])
print(empty.items.isEmpty) // true
Как это встраивается в CLI-приложение
Важно правильно обозначить границы: мы сейчас говорим про формат данных и про кодирование/декодирование, а не про файловую систему. Но уже на этом уровне можно встроить LibraryFile в логику CLI как “снимок состояния библиотеки”.
Представим, что у нас есть текущее состояние в памяти:
import Foundation
var currentFile = LibraryFile(schemaVersion: 1, items: [])
currentFile.items.append(Book(id: 1, title: "Domain-Driven Design"))
print(currentFile.items.count) // 1
Дальше программа может, например, в одном месте “сериализовать состояние в JSON-Data”, а в другом месте “восстановить состояние из JSON-Data”. Даже если источник этих Data сейчас тестовый (строка в коде), структура приложения получается аккуратной: формат не размазан по проекту, он живёт в одном типе LibraryFile.
7. Схема потока данных
Иногда полезно увидеть процесс как схему: не потому что “так красивее”, а потому что мозг перестаёт путать “строка”, “данные”, “модель” и “файл”.
flowchart TD
A["LibraryFile (Swift struct)"] -->|JSONEncoder.encode| B["Data (bytes)"]
B -->|"String(data:encoding:) для просмотра"| C["JSON text (debug)"]
D["JSON text"] -->|"Data(text.utf8)"| B
B -->|JSONDecoder.decode| A
A -->|проверка schemaVersion| E["OK / Error"]
schemaVersion тут стоит отдельно, потому что это не “часть декодера”, это часть вашего контракта: даже если декодер справился, вы можете решить, что данные использовать нельзя.
8. Типичные ошибки
Ошибка №1: делать корнем файла массив, а потом “хотеть метаданные”.
Это очень частый путь: сначала всё хорошо, потом появляется желание добавить версию схемы, а потом — “ну давайте сделаем рядом второй файл” или “давайте вставим первым элементом массива объект версии”. Оба решения делают формат неудобным. Гораздо проще изначально сделать корень объектом и положить массив в items.
Ошибка №2: воспринимать schemaVersion как “декорацию” и не проверять его при чтении.
Если вы декодируете файл и сразу используете items, игнорируя версию, то через некоторое время вы получите файл “не того формата” и будете ловить странные эффекты. Проверка версии — это буквально две строчки guard, но по смыслу это ваш ремень безопасности: дешёвый, но очень полезный.
Ошибка №3: менять имена ключей (items, schemaVersion) без причины.
Ключи в JSON — часть формата. Если вы поменяли items на books, то старые файлы перестанут читаться. Иногда это допустимо, но тогда это осознанное изменение формата, а не “я просто люблю слово books”. В учебном проекте держите ключи стабильными, чтобы поведение было предсказуемым.
Ошибка №4: пытаться “склеивать JSON руками” строками.
Как только вы начинаете собирать JSON через "{\"schemaVersion\": \(v), ... }" — вы почти гарантированно рано или поздно сделаете невалидный JSON: пропустите запятую, неправильно экранируете кавычки или получите число как строку. Codable и JSONEncoder как раз придуманы, чтобы вы не играли в “мини-парсер” вместо разработки приложения.
Ошибка №5: использовать try! при декодировании “потому что ну это же наш файл”.
Даже если это “наш файл”, он может быть пустым, частично записанным, испорченным, отредактированным руками, или просто неожиданно содержать другой JSON. try! превращает любой такой сценарий в падение программы. Для внешних данных нормальный стиль — do/catch, а внутри catch уже решать, что показать пользователю.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ