Часто информация, которая отображается пользователю и имеет свойства разметки, хранится в базе данных и сразу в формате HTML. Я уверен, что те, кто хоть раз создавал свой блог, Хабр или что-то подобное, скажут: «Да, было». Но что, если я предложу другой подход? Использовать JSON.


Давайте вместе разберёмся в преимуществах и недостатках такого подхода к хранению данных.


Ирония создания новых стандартов: попытка объединить стандарты приводит к их увеличению


В чем проблема хранить html в БД?


Проблемы проявляются в долгосроке. Рассмотрим наш гипотетический веб-сервис, в базе данных которого мы храним html для отображения некоторого контента.


Во-первых, фронтенд вашего сервиса должны поддерживать и периодически обновлять дизайн, это привычная практика, раз в три-пять лет происходят редизайны.


Соответственно, рано или поздно возникнет потребность в сильной переработке визуала. Фронтендеру будет необходимо обновить пользовательский интерфейс, для этого он должен учитывать, что на сервере весь HTML в БД является статичным и, возможно, даже есть устаревшие UI-элементы или, например, легаси CSS.


Волшебным образом HTML с CSS-классами не поменяются, для этого уже потребуется вмешательство второго разработчика — бэкендера, ему нужно будет провести ряд манипуляций по миграции одного формата вёрстки в другой, и хорошо, если это делается редко. Если это не так и изменения происходят часто, то это будет большая боль и постоянная трата ресурсов команды.


Можно, например, посмотреть, что выдается для мобильной версии фронтенда Хабра под недавним мегапостом ссылка на апи. Автоматизированно, без вмешательства человека, такие данные модифицировать ��ложно.


Big article


Некоторое количество заинлайненого css и javascript (около половины поста генерируется им) осталось за кадром.


Во-вторых, абсолютно точно не стоит забывать про нативные приложения. И если ваш сервис сейчас ориентируется только на веб, то с течением времени это может измениться.


Мобильные приложения — де-факто стандарт, каждая уважающая компания, ориентированная на расширение пользовательской аудитории, создаст мобильное приложение. Как разработчик вы должны быть к этому готовы. Также не стоит списывать десктопные приложения со счетов, они пользуются спросом как в b2b, так и в b2c, и привлекательны для аудитории за счет удобства.


В этот момент, когда у бизнеса появилась потребность, у команды нативной разработки появится проблема, бекенд отдает html верстку. В этом случае есть два основных пути решения:


  1. Использовать WebView, что сразу заметно глазу пользователей. И да, такой подход жизнеспособен, он решает задачу, но 1) часто приводит к просадке перформанса и 2) следующее из первого, команда на борьбу с WebView в последствии начнёт тратить сил больше, чем эта технология на первом этапе сэкономила сил.


  2. Использовать транслятор html в нативные UI-элементы. Хорошо если для вашего платформы существует такой транслятор, но если нет или он не удовлетворяет вашим требованиям, то разработчику сложно позавидовать, написание собственноручно такого транслятора достаточно не тривиально и трудоемко. Кстати об в том числе этом, когда-то я написал статью о реализации мобильного приложения Хабра на Flutter, советую, там в том числе об этой проблеме есть комментарии и код.



В общем, используя HTML, команда разработки приобретает только головную боль при поддержке разных платформ.


В-третьих, безопасность. HTML является мощным инструментом для создания и отображения контента, но он также может быть использован для выполнения вредоносного кода. Если злоумышленник получит доступ к базе данных, он может использовать HTML-верстку для внедрения вредоносных скриптов на сайт или в приложение, что может привести к серьезным последствиям: взлом коснется не только компании и её внутренней инфраструктуры, но и напрямую затронет безопасность пользователей.


Универсальный способ хранения


Вместо того чтобы хранить HTML, нужно разделить отвественность: отдельно платформозависимая верстка и отдельно логика содержимого. Например, JSON с заранее определенной или легко расширяемой структурой. Это позволит каждому элементу иметь четкую структуру и не зависеть от представления, созданного фронтендером.


Преимущества такого подхода:


Мы полностью абстрагируемся от представления данных и сосредоточимся исключительно на их содержимом. Благодаря этому в любой момент сможем легко обновить код для рендеринга JSON в HTML, который будет отображаться в браузере. Более того, мы сможем написать свой легковесный рендеринг из JSON не только в HTML, но и для нативных приложений, не прибегая к использованию WebView.


Рассмотрим идею на примерах


Для этого предлагаю ознакомится с тем, что выдает по api Хабр (только уже без мегапостов) \
Важно: я предполагаю, что статьи хранятся в том же виде, в каком и отправляются.


Показывать я будут сначала html, а потом альтернативу в виде json.


Изображение с подписью: источник: https://habr.com/ru/company/tuturu/blog/526710/


<img src="https://habrastorage.org/webt/nz/2t/p3/nz2tp3ywl9fdr7quadxm1dzmhdo.jpeg"><br>
<sup> Хотя пеликан, конечно, улетит. Пеликаны они вообще такие. </sup><br>

{
    "type": "image",
    "src": "https://habrastorage.org/webt/nz/2t/p3/nz2tp3ywl9fdr7quadxm1dzmhdo.jpeg",
    "caption": "Хотя пеликан, конечно, улетит. Пеликаны они вообще такие."
}


Изображение с подписью: источник: https://habr.com/ru/post/526676/


<img src="https://habrastorage.org/webt/lr/ql/v9/lrqlv9u6cxmuoyn-0hgrik3z0aw.jpeg"><br>
<i>Макет пункта захоронения ЖРО на одном из трех подобных российских объектов.</i><br>

{
    "type": "image",
    "src": "https://habrastorage.org/webt/lr/ql/v9/lrqlv9u6cxmuoyn-0hgrik3z0aw.jpeg",
    "caption": "Макет пункта захоронения ЖРО на одном из трех подобных российских объектов."
}

Думаю, что человек, который знает про тег figcaption, начал грустить, а фронтендер, который видит подобное многообразие на которое не может или может, но с трудом, повлиять еще больше грустит.


Рассмотрим более комплексный пример.


Оригинал откуда взята часть текста


А вот так может например выглядит спойлер (просто напомню, что в html5 давно есть details):


Большой кусок кода
<div class="spoiler" role="button" tabindex="0">
    <b class="spoiler_title">Я приведу список моих документов. Он очень скучный</b>
    <div class="spoiler_text">Итак, нужно было перевести на немецкий:<br />
        <br />
        <ul>
            <li>Мои дипломы (бакалавра и магистра) с приложениями</li>
            <li>Диплом супруги с приложением</li>
            <li>Свидетельство о браке</li>
            <li>Свидетельства о рождении детей</li>
            <li>Трудовую книжку</li>
            <li>Резюме</li>
        </ul><br />
        Перед выполнением перевода нужно не забыть поставить апостиль на «Свидетельство о браке» и «Свидетельства о
        рождении детей». Вот тут-то я понял, почему эти документы нельзя ламинировать.<br />
        <br />
        Также необходимо проверить факт соответствия диплома по базе данных <a
            href="https://anabin.kmk.org/anabin.html" rel="nofollow">Anabin</a>.<br />
        <br />
        Далее нужно было:<br />
        <br />
        <ul>
            <li>Получить приглашение от немецкой компании</li>
            <li>Получить копию трудового договора со стороны компании. В моем случае оригинал с «мокрой» печатью и
                подписями не понадобился</li>
            <li>Оформить медицинскую страховку</li>
            <li>Подписать бумагу о том, что являешься цивилизованным человеком и в курсе, как вести себя в Германии
            </li>
            <li> заполнить <a
                    href="https://germania.diplo.de/ru-ru/service/05-VisaEinreise/langfristigerAufenthalt/-/1611410?openAccordionId=item-1611410-3-panel"
                    rel="nofollow">анкету </a>на получение национальной визы </li>
            <li>Взять гражданский и заграничный паспорт с копиями</li>
            <li>Сфотографироваться на визу</li>
        </ul><br />
        Разумеется, все документы должны оформляться на немецком языке.<br />
        <br />
        Интересный нюанс: есть несоответствие информации на сайте <a href="https://germania.diplo.de/ru-ru"
            rel="nofollow">представительства Германии в России</a> и реальных требований. А именно: сертификат,
        подтверждающий знания немецкого языка предоставлять не нужно. Кроме того, не нужно иметь договор аренды
        жилья в Германии, хотя этот пункт присутствует в анкете. Напомню, это не нужно если вам открывают рабочую
        визу с последующим оформлением «blue card», но если вы переезжаете по-другому алгоритму — и документы также
        могут потребоваться другие.<br />
        <br />
        В немецком посольстве все были предельно вежливыми, белыми и пушистыми.<br />
        <br />
        Однако, но из-за какой-то внутренней ошибки они чуть было не выдали мне рабочую визу на 3 месяца вместо 6-и.
        Так что доверяй, но проверяй!<br />
        <br />
        Да, немцы тоже косячат. <br />
    </div>
</div>

Иииии… преобразуем это в json:


{
    "type": "details",
    "title": "Я приведу список моих документов. Он очень скучный",
    "child": {
        "type": "div",
        "children": [
            {
                "type": "tp",
                "text": "Итак, нужно было перевести на немецкий:"
            },
            {
                "type": "unordered_list",
                "children": [
                    {
                        "type": "tp",
                        "text": "Мои дипломы (бакалавра и магистра) с приложениями"
                    },
                    {
                        "type": "tp",
                        "text": "Диплом супруги с приложением"
                    },
                    {
                        "type": "tp",
                        "text": "Свидетельство о браке"
                    },
                    {
                        "type": "tp",
                        "text": "Свидетельства о рождении детей"
                    },
                    {
                        "type": "tp",
                        "text": "Трудовую книжку"
                    },
                    {
                        "type": "tp",
                        "text": "Резюме"
                    }
                ]
            },
            {
                "type": "tp",
                "text": "Перед выполнением перевода нужно не забыть поставить апостиль на «Свидетельство о браке» и «Свидетельства о рождении детей». Вот тут-то я понял, почему эти документы нельзя ламинировать."
            },
            {
                "type": "paragraph",
                "children": [
                    {
                        "type": "span",
                        "text": "Также необходимо проверить факт соответствия диплома по базе данных",
                        "mode": []
                    },
                    {
                        "type": "link_span",
                        "text": "Anabin",
                        "src": "https://anabin.kmk.org/anabin.html"
                    },
                    {
                        "type": "span",
                        "text": ".",
                        "mode": []
                    }
                ]
            },
            {
                "type": "tp",
                "text": "Далее нужно было:"
            },
            {
                "type": "unordered_list",
                "children": [
                    {
                        "type": "tp",
                        "text": "Получить приглашение от немецкой компании"
                    },
                    {
                        "type": "tp",
                        "text": "Получить копию трудового договора со стороны компании. В моем случае оригинал с «мокрой» печатью и подписями не понадобился"
                    },
                    {
                        "type": "tp",
                        "text": "Оформить медицинскую страховку"
                    },
                    {
                        "type": "tp",
                        "text": "Подписать бумагу о том, что являешься цивилизованным человеком и в курсе, как вести себя в Германии"
                    },
                    {
                        "type": "paragraph",
                        "children": [
                            {
                                "type": "span",
                                "text": "заполнить",
                                "mode": []
                            },
                            {
                                "type": "link_span",
                                "text": "анкету ",
                                "src": "https://germania.diplo.de/ru-ru/service/05-VisaEinreise/langfristigerAufenthalt/-/1611410?openAccordionId=item-1611410-3-panel"
                            },
                            {
                                "type": "span",
                                "text": "на получение национальной визы",
                                "mode": []
                            }
                        ]
                    },
                    {
                        "type": "tp",
                        "text": "Взять гражданский и заграничный паспорт с копиями"
                    },
                    {
                        "type": "tp",
                        "text": "Сфотографироваться на визу"
                    }
                ]
            },
            {
                "type": "tp",
                "text": "Разумеется, все документы должны оформляться на немецком языке."
            },
            {
                "type": "paragraph",
                "children": [
                    {
                        "type": "span",
                        "text": "Интересный нюанс: есть несоответствие информации на сайте",
                        "mode": []
                    },
                    {
                        "type": "link_span",
                        "text": "представительства Германии в России",
                        "src": "https://germania.diplo.de/ru-ru"
                    },
                    {
                        "type": "span",
                        "text": "и реальных требований. А именно: сертификат, подтверждающий знания немецкого языка предоставлять не нужно. Кроме того, не нужно иметь договор аренды жилья в Германии, хотя этот пункт присутствует в анкете. Напомню, это не нужно если вам открывают рабочую визу с последующим оформлением «blue card», но если вы переезжаете по-другому алгоритму — и документы также могут потребоваться другие.",
                        "mode": []
                    }
                ]
            },
            {
                "type": "tp",
                "text": "В немецком посольстве все были предельно вежливыми, белыми и пушистыми."
            },
            {
                "type": "tp",
                "text": "Однако, но из-за какой-то внутренней ошибки они чуть было не выдали мне рабочую визу на 3 месяца вместо 6-и. Так что доверяй, но проверяй!"
            },
            {
                "type": "tp",
                "text": "Да, немцы тоже косячат."
            }
        ]
    }
}

Вообще, сложно спорить с тем, что XML удобнее, чтобы представлять rich-text.
Только взгляните на этого монстра, если представить rich-text в формате JSON:


{
    "type": "paragraph",
    "children": [
        {
            "type": "span",
            "text": "Также необходимо проверить факт соответствия диплома по базе данных",
            "mode": []
        },
        {
            "type": "link_span",
            "text": "Anabin",
            "src": "https://anabin.kmk.org/anabin.html"
        },
        {
            "type": "span",
            "text": ".",
            "mode": []
        }
    ]
}

Примечание: mode используется для того, чтобы представлять такие значения как bold, italic, strike и группировать их, просто пример не очень удачный.


Ст��лько символов, чтобы представить один параграф с одной ссылкой.


Удобно ли это? Не уверен, легко можно выяснить какие стили у конкретного span без необходимости ходить для поиска предыдущих нод, но, например, в андроид есть стандартный TextView, который без проблем сработается с простеньким html


Все это я, конечно, переводил не в ручную (кроме некоторых кейсов). Я брал некоторые статьи и прогонял html через свою небольшую консольную утилиту на Dart, её результаты работы вы и увидели. Да, она написана не идеально, но для проверки концепта — сойдет.


Потыркать утилиту и посмотреть код можно по ссылке на мой репозиторий.


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


Бенчмарк


Что я вообще сравниваю? Мне интересно 2 вещи:


  1. Есть ли разница в скорости парсинга json с заинлайненым html и json с представлением статьи в виде json'а.
  2. Насколько быстро распарсить два формата вложенных друг в друга.

Зачем тестировать второй вариант? Если вы используете React, например, вместе с библиотекой "html_to_react" (внутри она использует "html-dom-parser"), то, думаю, вам будет интересно узнать об альтернативе.


Кратко опишу методику тестирования:


  • Я заранее перевел с помощью утилиты пару хабровских статей в json.
  • Есть три операции, которые, тестируются:
    1. Скорость парсинга статьи без преобразований (статья полностью в том виде в котором его выдает по апи хабр, в html)
    2. Скорость парсинга если html заменить на преобразованный моей утилитой json
    3. Скорость парсинга когда парсим сначала json а после парсим html внутри.
  • тестируем библиотеку только для построения dom дерева, замеры для "html_to_react" не проводились

Код бенчмарка:


import Benchmark from "benchmark";
import fs from 'fs';
import htmlToDOM from 'html-dom-parser';

function parse(str) {
    return JSON.parse(str);
}

function prepareAndParse(str) {
    return htmlToDOM(JSON.parse(str)["textHtml"]);
}

const suiteName = '526574'
const defaultHtml = fs.readFileSync(`./test_data/${suiteName}/html.json`);
const jHtml = fs.readFileSync(`./test_data/${suiteName}/jhtml.json`);

const suite = new Benchmark.Suite;

suite.add('Json c html2json', () => parse(jHtml))
suite.add('Html в json', () => parse(defaultHtml))
suite.add('Html в json и парсинг html-dom-parser', () => prepareAndParse(defaultHtml))

suite.on('complete', function() {
    console.log('Fastest is ' + this.filter('fastest').map('name'));
}).on('cycle', function(event) {
    console.log(String(event.target));
})

suite.run()

Результаты тестирования


После запуска бенчмарка мы получим такие результаты:


Json c html2json x 1,892 ops/sec ±0.79% (88 runs sampled)
Html в json x 1,917 ops/sec ±0.81% (89 runs sampled)
Html в json и парсинг html-dom-parser x 798 ops/sec ±3.04% (86 runs sampled)

  1. Скорость парсинга статьи полностью из json'а и с html внутри json'а по производительности не имеет значительной разницы.
  2. Производительность при двух проходах парсинга (сначала парсим json, а потом html) примерно в 2 раза меньше и не в пользу html.

Плюсы и минусы


плюсы:


  1. Снижение нагрузки на клиентские устройства: cделав подобное преобразование на сервере, в некоторых ситуациях можно немного ослабить нагрузку на гаджет пользователя и экономить заряд батареи
  2. Независимость от HTML и CSS: Мы не зависим от способа представления кнопки, спойлера или цитаты на клиенте, от css классов и атрибутов. Это позволяет создавать на основе данных с сервера нативные виджеты для Android, iOS, Flutter и десктопа без использования WebView или подобных решений.
  3. Минимизация зависимостей: Избавление от лишних зависимостей упрощает разработку и поддержку.

минусы:


  1. Размер: json весит незначительно, но больше чем аналогичный html


    возможно и обратное, если:


    • у вас особый формат, размер которого лучше поддается оптимизации
    • вы захотите использовать бинарный формат для представления ваших данных (забавно — bson оказался в моем случае даже больше чем стандартный json), тогда получится сократить размер, но опять же это достаточно трудоемко, а фичи не ждут.

  2. Необходимость конвертации: json придется в любом случае конвертировать в нативные для данной платформы виджеты будь то веб с его html или нативный мир уже с собственными особенностями


    И при этом не понятно, когда это лучше делать в случае веб-приложений:


    • Можно конвертировать прямо на клиенте, что удобно в случае, если вы используете кастомные виджеты.
    • Можно конвертировать на сервере, а на фронтенд отправлять уже все готовое.

  3. Сложность HTML: html очень многогранный с широким функционалом. Если необходимо поддерживать множество его возможностей, это может потребовать значительных усилий и привести к созданию сложностей, сопоставимых с разработкой браузера.
  4. Отсутствие стандартизации: Разработка собственного решения может обернуться созданием уникального велосипеда, который придется поддерживать самостоятельно.

Это все, конечно, хорошо, но как можно это применить?


Мой небольшой список:


  1. Создание платформо-независимого генерируемого в рантайме ui
  2. Отказ от редакторов, использующих html. Написать собственный редактор получится проще и, скорее всего, он будет безопаснее, т.к. встроить зловредный javascript будет сложнее.

Заключение


В качестве итога я бы хотел сказать, что не предлагаю всем разом переходить на описанный мной в данной статье подход. В первую очередь мне интересно мнение других разработчиков, как бекенд, так и фронтенд. Скорее всего, я не учел нюансы и готов выслушать и обсудить критику и замечания.