Восстановление типов 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<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.

Этой поделкой можно пользоваться в двух стилях:

  1. вызывать статичные методы класса JSONSon;

  2. сделать экземпляр класса и вызывать его методы.

// 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 с примитивной стандартной реализацией. И его предполагается менять на свой лад в месте применения. Примерно так у меня выглядит блок инициализации на фронте:

<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.

Исходники поделки

https://github.com/bbars/utils/tree/master/js-json-son


ссылка на оригинал статьи https://habr.com/ru/post/650513/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *