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

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.

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

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

Комментарии (15)


  1. Kazzman
    09.02.2022 23:04
    +1

    Расчитывал увидеть идею, как забирая из json структуру типа Map<String,Object>, понять достоверно без костылей, что забираемая строка это не String, а Date. Но не увидел :(


    1. bBars Автор
      09.02.2022 23:06
      +1

      Извините, я ничего не понял. Можете пример привести?


      1. Kazzman
        09.02.2022 23:43

        Допустим есть объект

        { key:value, "age":100, "gender":"m", "active":true, "docs":[{"type":1},{"type":2}]}

        тогда он автоматом приводится к map<key, object>, где object это число, бул, строка, массив, вложенный map ( вложенный {}). То есть по всем доступным типам данных в json мы можем создать мапу и там будет объект правильного типа. Но не Date. Ну и не byte[]. Но меня в первую очередь дата интересует. Как дату отличить от строки, которая выглядит как эта самая дата? Только предположением, что если вот такой формат, то Date?

        А в обратную сторону такая map<key, object> хоть в json , хоть в xml идеально перегоняется. Храним так произвольные структуры данных.


        1. bBars Автор
          10.02.2022 00:48
          +1

          Если нужно догадываться: лежит ли там дата-время или просто на неё похожая строка, то у меня решения нет (да и не может быть, если эта информация утеряна). А вот если отправитель вам к данным приложил схему типов (либо у вас контракт), тогда можно:

          JSONSon.make({
            age: 'number',
            gender: 'string', // хороший пример: можно сделать что-то типа enum
            active: 'boolean',
            docs: [
              { type: 'number' } // или тут имеется в виду какой-то конкретный тип?
            ]
          }, yourData);


        1. Lissov
          10.02.2022 01:15
          -1

          Как дату отличить от строки, которая выглядит как эта самая дата?

          Никак, и не надо. Потому что у Вас может быть текстовое поле, в которое пользователь может вписать что угодно. Если он впишет дату, то после конвертации в Date у Вас где-то упадёт toLowerCase().

          Потому только в явном виде. Придётся в парсер передать как-то информацию, что тут именно дата.


        1. bBars Автор
          10.02.2022 03:52

          А, ну и да: дату эта штуковина, разумеется, тоже умеет переваривать, но только если вы знаете, что ожидается именно она:

          JSONSon.make({
            age: 'number',
            active: 'boolean',
            timeJoined: Date,
          }, yourData);


  1. random1st
    10.02.2022 02:41
    +8

    Как люди извращаются, лишь бы protobuf не юзать или аналоги.


    1. bBars Автор
      10.02.2022 03:25
      -1

      Да, и мне нравится :)

      А что именно вы считаете извращением? Какие-то конкретные решения или в принципе разработку инструмента (вместо использования одного из сотни других)?


  1. SlavikF
    10.02.2022 06:37

    Замахнулся я на домашнюю версию Google Photo для запуска на одноплатнике


    Я недавно искал как раз вот это. Не совсем такой набор фич, но довольно близко:
    github.com/bpatrik/pigallery2

    И стэк такой же: NodeJS + JS


    1. bBars Автор
      10.02.2022 17:31

      Выглядит матёро, спасибо. А вы уже щупали её? Не в курсе, она просто по файловой системе ходит или сканирует всё и вся в одну сплошную ленту? И ещё есть ли поддержка всяких экзотических вещей типа панорам?


      1. SlavikF
        10.02.2022 22:02
        +1

        Да, пользуюсь около года. Есть некоторые баги, но в общем — доволен.

        Да, всё основана на файловой системе, хотя к следующему релизу вроде планируют добавить timeline.

        Панорамы показываются простой картинкой.

        Из интересного — есть интеграция с ffmpeg, когда любые видики могут перекодироваться для показа в браузере. Но я этим не пользуюсь.
        Заявляют легковесность, что работать может даже на raspberry.

        В порядке эксперимента, вот пример моей папки 2021 года:
        pix.slavikf.com/share/872da85a

        Заодно и проверим, не ляжет ли под хаброэффектом — это запущено под докером на моей Synology ds1621xs+.


        1. bBars Автор
          11.02.2022 04:09

          Ко внешнему виду придраться можно, но как же шустро работает! Спасибо, изучу её поподробнее


  1. PaulIsh
    10.02.2022 09:29
    +2

    Делал похожую штуку:

    class Serializer {
        #typeMap = new Map();
    
        registerType(typeName, typeRef) {
            this.#typeMap.set(typeName, typeRef);
        }
    
        registerTypes(types = {}) {
            for (const typeName of Object.keys(types)) {
                this.registerType(typeName, types[typeName]);
            }
        }
    
        serialize(value) {
            const typeMap = this.#typeMap;
            // eslint-disable-next-line func-names
            return JSON.stringify(value, function (key, v) {
                const rv = this[key];
                if (typeof rv === 'object' && rv?.constructor?.name) {
                    const className = rv.constructor.name;
                    if (typeMap.has(className)) {
                        return {
                            className,
                            value: rv.toJSON()
                        };
                    }
                }
                return v;
            });
        }
    
        deserialize(str) {
            return JSON.parse(str, (key, v) => {
                if (typeof v === 'object' && v?.className) {
                    const Class = this.#typeMap.get(v.className);
                    if (Class) return new Class(v.value);
                }
                return v;
            });
        }
    }
    

    Наверняка какой-нибудь npm пакет такое же делает.


    1. bBars Автор
      10.02.2022 17:54

      Да, если без нюансов, то мне бы такой вариант для решения задачи тоже подошёл. Но с нюансами интереснее :)


  1. debagger
    10.02.2022 22:12
    -1

    Мне кажется что вы изобрели GraphQL без QL...