Восстановление типов JSON

db1a27ac5a1ab3fec1405dd18544f7e0

Для транспорта данных я почти всегда упаковываю их в 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

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] }

Пришлось писать. Сделал простой симметричный обход структур с конвертацией типов, парой проверок — и готово. Надо сказать, что привязки к 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.

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

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

Итог: сервер отдаёт все объявленные 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

© Habrahabr.ru