Для транспорта данных я почти всегда упаковываю их в 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<Folder> JSONSon.parse(AContainer, '{...}'); // object<? extends AContainer> JSONSon.parse(Date, '"2022-02-08T21:15:56.180Z"'); // object<Date> 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<Number> {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<Folder>, 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<BigInt> {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); schema.parse("{...}"); // or schema.make(folder);
Передать JSONSon в JSON
Обычно я делаю так, что бэкенд предоставляет апишку вместе с авто-описанием её методов. Клиент обращается к серверу: «Какие у тебя есть методы»? А тот ему передаёт список всех методов с объявленными параметрами, а также и их типами (если язык позволяет). После этого клиент на своей стороне в обёртке организует всё так, чтобы применение выглядело как простой вызов функции: await serverApi.getFolder('~'). Было бы хорошо, если бы клиент ещё и взял на себя преобразование типов. И это можно: нужно лишь как-то передать на клиент саму схему — экземпляр JSONSon. Выходит, что и его нужно правильно преобразовать в JSON. А там же совершенно непригодные для преобразования классы! Как же быть? Было решено слишком не заморачиваться (хе-хе): просто определяем имя класса и отдаём строкой. А клиент сделает обратное преобразование. Но мало ли какая ещё сторона будет делать обратное преобразование. Как найти конструкторы по их именам? Короче говоря, сделал ещё один статичный метод JSONSon.resolveConstructor с примитивной стандартной реализацией. И его предполагается менять на свой лад в месте применения. Примерно так у меня выглядит блок инициализации на фронте:
<script type="module"> import JSONSon from './src/utils/JSONSon.js'; import Folder from './src/Folder.js'; import * as contClasses from './src/containers/index.js'; const supportedConstructorLibs = [ window, { Folder, ...contClasses, }, ]; JSONSon.resolveConstructor = (name) => { for (const lib of supportedConstructorLibs) { if (lib[name]) { return lib[name]; } } return null; }; //... </script>
Итог: сервер отдаёт все объявленные 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.
