Восстановление типов JSON
Для транспорта данных я почти всегда упаковываю их в JSON. Но вот беда: как правило, библиотеки для парсинга возвращают примитивные типы и объекты с массивами — всё то, что заложено самим форматом. Но что если хочется получить модели сущностей?
Нравится мне порой понаделать своего, даже если кто-то уже сделал что-то похожее. Ничего не могу с собою поделать. В повседневной работе с JavaScript я связан не слишком тесно, но для домашних проектов применяю часто: пишу на нём бэк и фронт, потому что уж слишком привлекает единая кодовая база. Чёрт с ней — с той производительностью, — раз это для развлечения. Зато какая ж красота!
Коротенько о проекте, который побудил
Место применения: домашний проект
Замахнулся я на домашнюю версию Google Photo для запуска на одноплатнике. Штуковина должна показывать простые фотки, видео, гифки, панорамы, и даже уметь группировать серии снимков (burst) — всё то, что генерирует смартфон жены. Кроме того, хотелось бы вручную организовывать медиа в альбомы, используя концепты ФС: директория = альбом, субдиректория = альбом в альбоме.
Модели: базовые классы
Начнём с классов для директорий и медиа-контейнеров (контейнер — потому что он может «содержать» в себе несколько физических файлов, как например это происходит в случае с burst).
// код упрощён для демонстрации
class Folder {
constructor(data) {
this.dir = data.dir;
this.caption = data.caption;
this.collectTime = data.collectTime;
this.metaThumbnail = data.metaThumbnail;
this.extras = data.extras;
}
get parentDir() {
return this.dir.replace(/\/?[^\/]+$/, '');
}
static fromDirent(dirent) {
return new this(/* ... */);
}
// не относящиеся к содержанию статьи методы
}
// признаю, название ужасное
class AContainer {
constructor(data) {
this.file = data.file;
this.files = data.files; // остальные картинки из серии burst
this.parentDir = data.parentDir;
this.collectTime = data.collectTime;
this.metaTime = data.metaTime;
this.metaLat = data.metaLat;
this.metaLon = data.metaLon;
this.metaThumbnail = data.metaThumbnail;
this.extras = data.extras;
}
static async checkFile(dirent) {
throw new Error("Method 'checkFile' is abstract");
}
}
Специфические классы для медиа
Теперь нужно сделать отдельные классы со своей спецификой для каждого формата. Они будут наследоваться от AContainer
:
ImageBasic
ImageBurst
ImageGif
ImagePano
VideoBasic
VideoSlowmo
VideoTimelapse
Поиск и обработка медиа-файлов и альбомов
В каждом из перечисленных классов будет своя реализация статичного метода checkFile
. Функция будет проверять имя файла, и даже заглядывать вовнутрь — чтобы понять, с каким форматом мы имеем дело. И если вернётся не null
, то считаем, что формат распознался. Готово, пора царапать винт! Открываем директорию, читаем файлы:
async function collectDir(dir = '~') {
const contClasses = [
// order of appearence is important!
ImageGif,
ImageBurst,
ImagePano,
ImageBasic,
VideoSlowmo,
VideoTimelapse,
VideoBasic,
];
const dirents = await fsPromises.readdir(dir, { withFileTypes: true });
const folders = [];
const containers = [];
for (const dirent of dirents) {
if (dirent.isDirectory()) {
folders.push(Folder.fromDirent(dirent));
}
else {
let container;
// последовательные проверки файла
// всеми типами контейнеров:
for (const contClass of contClasses) {
container = contClass.checkFile(dirent);
if (container) {
containers.push(container);
break;
}
}
if (!container) {
console.warn(`Unable to detect media format`, dirent);
}
}
}
}
Теперь у нас в folders
собраны суб-директории (альбомы), а в containers
— куча мала разных объектов-наследников AContainer
. Сохраняем теперь всё это в две разные таблицы БД, попутно создавая превьюшки и вытаскивая остальную метаинформацию из файлов. В таблице для контейнеров я завёл колонку, в которой хранится конкретный тип: "ImageBasic" | "ImageBurst" | ... | "VideoTimelapse"
. При получении из базы я буду заворачивать каждую строку таблицы в свой класс (уже тут виднеется цимес статьи, но это не совсем то).
Клиент в браузере
Вплотную подобрались ко фронту. Пусть будет какая-то ручка, которую можно дёрнуть из браузера по HTTP, а ответом будет JSON-представление директории с имеющимися в ней медиа-объектами и субдиректориями. Примерно так:
async function getFolder(dir) {
dir = dir.replace(/[\/\\]+$/, '');
const resFolder = await db.queryOne(`
SELECT * from Folder where dir = ?
`, [dir], models.Folder);
if (!resFolder) {
throw new Error(`Unknown Folder: ${dir}`);
}
resFolder.childContainers = await db.query(`
SELECT * from Container where parentDir = ?
`, [dir], models.AContainer);
resFolder.childFolders = await db.query(`
SELECT * from Folder where parentDir = ?
`, [dir], models.Folder);
return resFolder;
}
Из браузера дергаем API-метод и получаем с бэка сырые JS-объекты. А хочется классов моделей. Ещё и типы контейнеров потерялись. Как же быть?
Объект класса в JSON
На всякий случай поведаю, что стандартный сериализатор JSON в JavaScript умеет не только вызывать функцию-replacer для каждого значения, но и искать у объектов метод toJSON
. Это позволяет до неузнаваемости обработать объект перед упаковкой. При этом реализация такой обработки будет на месте — в описании класса (а вот и нет, а вот и не всегда). Для некоторых стандартных типов есть такие реализации на уровне движка JS (например, для Date).
С классом Folder всё просто — там ничего особого не требуется. А вот для контейнеров нужно сохранить информацию о типе. Предлагаю просто:
class AContainer {
// ...
toJSON() {
const res = {};
Object.assign(res, this);
// и классы-наследники тут всё сделают правильно:
res.containerType = this.constructor.name;
return this;
}
}
Будь желание, можно было бы даже вложить один объект в другой, чтобы не было кисло из-за возможного конфликта имени свойства containerType
. Но это уже для гурманов.
JSON в объект нужного класса
Предварительные ласки кончились. Нужно развернуть JSON как-то так, чтобы вместо сырых объектов получить сухие хрустящие экземпляры нужных классов. И топорная ручная обработка — не наш метод. Мне пригянулся подход к вопросу в Golang. Ну и потом, раз уж мы уже увидели, что «волшебные» методы вроде toJSON
не порицаются, то почему бы не пойти дальше.
Договор будет следующим: если для класса описан статичный метод fromJSON
, то будем его вызывать сразу после преобразования JSON во внутреннее представление (сырые объекты, массивы и примитивы), но перед выдачей результата обработки. Если же метода нет, то будем просто пробрасывать значение в качестве единственного аргумента конструктора.
Ещё один договор с самим собой будет таким: каждый класс, который расширяет AContainer
, будет этому самому AContainer
сообщать о своём существовании (это в рамках исходного проекта, который про фоточки).
class AContainer {
// ...
static implementations = {};
static registerImplementation(implementationClass) {
this.implementations[implementationClass.name] = implementationClass;
}
static fromJSON(data) {
// нужно распаковать даты:
data.collectTime = new Date(data.collectTime);
data.metaTime = new Date(data.metaTime);
// код курильщика:
/*
switch (data.containerType) {
case 'ImageBasic': return new ImageBasic(data);
case 'ImageBurst': return new ImageBurst(data);
// ...
case 'VideoTimelapse': return new VideoTimelapse(data);
}
*/
// код вейпера:
const contClass = this.implementations[data.containerType];
if (contClass) {
return new contClass(data);
}
throw new Error(`Unknown container type: ${data.containerType}`);
}
}
class ImageBasic extends AContainer { /*...*/ }
AContainer.registerImplementation(ImageBasic);
class ImageBurst extends AContainer { /*...*/ }
AContainer.registerImplementation(ImageBurst);
class VideoTimelapse extends AContainer { /*...*/ }
AContainer.registerImplementation(VideoTimelapse);
Выглядит неплохо. А как же вызывать этот fromJSON? Плясал я вокруг reviver-функции, но каши не сварил. Хотел как-то хитро её генерировать, передавать в JSON.parse(data, <сюда>)
, но с нею нереально работать, когда речь идёт о произвольных объектах переменной вложенности. Что же, значит будем парсить как есть, а потом делать пост-обработку результата.
Пора бы сделать что-то типа модуля. Пусть называется JSONSchema JSONSon.
JSONSon
Прикинул, как бы мне хотелось этим пользоваться:
JSONSon.parse(Folder, '{...}');
// object
JSONSon.parse(AContainer, '{...}');
// object extends AContainer>
JSONSon.parse(Date, '"2022-02-08T21:15:56.180Z"');
// object
JSONSon.parse('string', '"2022-02-08T21:15:56.180Z"');
// "2022-02-08T21:15:56.180Z"
// и остановиться я уже не мог:
JSONSon.parse('number', '"2022-02-08T21:15:56.180Z"');
// NaN
JSONSon.parse('number', '"2022"');
// 2022
JSONSon.parse('boolean', '"2022"');
// true
JSONSon.parse('boolean', '""');
// false
JSONSon.parse('bigint', '"2022"');
// 2022n
JSONSon.parse(Number, '"2022"');
// object {2022}
JSONSon.parse(['number'], '[1, "2", 3, 4]');
// [1, 2, 3, 4]
JSONSon.parse(['boolean', 'number', 'string'], '[1, "2", 3, 4]');
// [true, 2, "3", "4"] - discover tuple in JS!
JSONSon.parse({ foo: Folder, bar: [AContainer] }, '{...}');
// { foo: object, bar: [object extends AContainer>] }
Пришлось писать. Сделал простой симметричный обход структур с конвертацией типов, парой проверок — и готово. Надо сказать, что привязки к JSON уже как бы и не осталось: JSON-ом данные были на предыдущем этапе — до его обработки, а нужная функция будет делать пост-обработку уже разобранных данных. Но так как схема двунаправленная (данные ↔ JSON), и я уже повязан с функцией toJSON
, то пусть она таки будет с ним связана.
Объекты с необъявленными свойствами
Напомню, что апишный метод getFolder
возвращает экземпляр класса Folder
, но с дополнительными посторонними свойствами:
await serverApi.getFolder('~/Pictures'); -> {
// объявленные свойства (те, о которых класс знает):
dir: '~/Pictures',
caption: 'Pictures',
collectTime: '2022-02-08T21:15:56.180Z',
metaThumbnail: 'data:image/jpeg;base64,...',
extras: null,
// посторонние свойства:
childContainers: [{...}, {...}],
childFolders: [{...}, {...}]
}
Конечно, было бы правильно их просто «объявить». Но я же тут занимаюсь целой парсилкой-конвертором-типов, а не каким-то там… с чего я вообще начинал? Короче, интереснее поискать ещё какое-то решение, потому что как ни крути, а в JS такие ситуации не редки. Нужно как-то обернуть класс, и вдобавок сообщить, какие ещё ожидаются посторонние свойства. А при обработке следует сначала преобразовать в экземпляр нужного класса, после чего отдельно пройтись по дополнительным свойствам. Вот так представилось применение:
JSONSon.parse(JSONSon.mix(Folder, {
childContainers: [AContainer],
childFolders: [Folder],
}), jsonData);
Клюка для BigInt
Не проверял, как обстоят дела в других клиентах, но в Chrome 97 вы удивитесь, если захотите упаковать BigInt в JSON:
JSON.stringify(9007199254740993n);
-> Error "TypeError: Do not know how to serialize a BigInt"
Не знаешь — научим:
BigInt.prototype.toJSON = function () { return this.toString(10); };
let json = JSON.stringify(9007199254740993n); // "9007199254740993"
JSONSon.parse('bigint', json); // 9007199254740993n
JSONSon.parse(BigInt, json); // object {9007199254740993n}
Кроме того, BigInt не является конструктором (в отличии от Boolean, Number и String). Поэтому для получения объектной обёртки для этого типа в JSONSon сделано специальное поведение: Object(BigInt(value))
. Не знаю, зачем это может понадобиться, но для порядку — сделано.
Чудно: большие числа конвертируются в строку, и так же хорошо преобразуются обратно. Если хранить в виде обычного числа, то потеряется точность в процессе между разбором JSON-строки (а он, напомню, нативный) и передачей преобразователю типов.
Применение
А самого парсера-то внутри и нет. В самом деле, функция JSONSon.parse
не делает почти ничего: она запускает стандартный парсер, а результат пробрасывает в поистине главную функцию — JSONSon.make
. Поэтому получается, что вовсе не обязательно скармливать ей JSON-строку. Если я уже обладаю какими-то сырыми данными, и нужно просто преобразовать типы, то можно вызывать JSONSon.make
.
Этой поделкой можно пользоваться в двух стилях:
вызывать статичные методы класса JSONSon;
сделать экземпляр класса и вызывать его методы.
// static:
JSONSon.parse(Folder, "{...}");
//or
let folder = await serverApi.getFolder('~/Videos'); // JSON parsed inside
JSONSon.make(Folder, folder);
// non-static:
let schema = new JSONSon(Folder);
JSONSon.parse("{...}");
// or
JSONSon.make(folder);
Передать JSONSon в JSON
Обычно я делаю так, что бэкенд предоставляет апишку вместе с авто-описанием её методов. Клиент обращается к серверу: «Какие у тебя есть методы»? А тот ему передаёт список всех методов с объявленными параметрами, а также и их типами (если язык позволяет). После этого клиент на своей стороне в обёртке организует всё так, чтобы применение выглядело как простой вызов функции: await serverApi.getFolder('~')
. Было бы хорошо, если бы клиент ещё и взял на себя преобразование типов. И это можно: нужно лишь как-то передать на клиент саму схему — экземпляр JSONSon
. Выходит, что и его нужно правильно преобразовать в JSON. А там же совершенно непригодные для преобразования классы! Как же быть? Было решено слишком не заморачиваться (хе-хе): просто определяем имя класса и отдаём строкой. А клиент сделает обратное преобразование. Но мало ли какая ещё сторона будет делать обратное преобразование. Как найти конструкторы по их именам? Короче говоря, сделал ещё один статичный метод JSONSon.resolveConstructor
с примитивной стандартной реализацией. И его предполагается менять на свой лад в месте применения. Примерно так у меня выглядит блок инициализации на фронте:
Итог: сервер отдаёт все объявленные API-методы, и ещё схему JSONSon результата для каждого; клиент это всё получает; в момент вызова каждого метода клиент знает, в какой тип следует преобразовать полученный результат.
Забавно, что реализации JSONSon.toJSON
и JSONSon.fromJSON
получились жирнее, чем код JSONSon.make
, и вообще составляют почти 40% от кода модуля.
Ещё чуть-чуть путаницы
Помните, что апишный метод возвращает Folder
с дополнительными посторонними свойствами? Вот я и подумал: неужели нельзя всё ещё немного усложнить :) Ну в самом деле — зачем мне постоянно писать так:
JSONSon.mix(Folder, {
childContainers: [AContainer],
childFolders: [Folder],
})
Можно же это тоже всё организовать в классе Folder
. Но не вручную в методе fromJSON
, а как-то похитрее. И таки да: можно декларировать, что JSONSon будет искать в классах ещё один волшебный метод, который будет выдавать уточнённую схему для типов. Ну и вот:
class Folder {
// ...
static getJSONSonSchema() {
return JSONSon.mix(
this,
{
childFolders: [this],
childContainers: [AContainer],
}
);
}
}
// демонстрация
let data = {
dir: '/home/bars/',
// ...
childFolders: [
{ dir: '/home/bars/Videos' },
{ dir: '/home/bars/Images' },
// ...
],
childContainers: [{
containerType: 'ImageBasic',
file: '/home/bars/hello.jpg',
// ...
}],
};
// теперь можно прямо так:
let folder = JSONSon.make(Folder, data);
В результате будет папка с правильно заполненным массивом дочерних папок и контейнером типа ImageBasic
.
Исходники поделки
https://github.com/bbars/utils/tree/master/js-json-son