- PVSM.RU - https://www.pvsm.ru -

Как дата саинтист имиджборду писал

Добро пожаловать. Снова.

Добро пожаловать. Снова.
Дисклеймер

Цель статьи — посмотреть на мир веб-разработки глазами человека, знакомого с алгоритмами и структурами данных, синтаксисом языка, работой с БД, но ничего не знающего про веб. Статья носит сугубо развлекательный характер, написана простым языком, некоторые технические моменты намеренно опускаются.

На дворе конец 2023. Я только что уволился из Яндекса и скучаю по ячану [1], чуть меньше скучаю по этушке, вообще не скучаю по таскам, дедлайнам, ревью. Чтобы заполнить возникший информационный вакуум, пробую переключиться на реддит, hacker news, пикабу, вышивание крестиком, сканворды, пилатес — не то. Тогда мне в голову приходит гениальная идея: а почему бы не сделать свою имиджборду с авторизацией по корпоративной почте крупных российских компаний? Ячан для всех!

Первая мысль — взять готовый движок и допилить под себя, в открытом доступе уже есть: lynx, vichan, wakaba, kareha, fchannel. Потыкался — ничего не понятно. Как ленивый человек решаю, что надо писать своё.
На тот момент я:

  • Не понимал разницу между HTTP и HTTPS

  • Не знал, что такое handler, router, middleware

  • Считал, что DNS — это какой-то раздел электронной музыки

  • Думал, что куки и кэш — это одно и то же

  • Не без труда отличал header от body

  • Не мог пропатчить kde2 под freebsd

Короче говоря, я был именно тем человеком, который должен был писать проект с нуля. Цель понятна, надо выбрать инструменты. Я неплохо знал питон и c++... поэтому языком разработки выбрал Голанг. Мой опыт с Голангом на тот момент ограничивался прослушанным фоном на х2 ШАДовским [2] курсом. Прослушал я его в автопоездке Москва — Челябинск. Я не написал ни одной строчки кода, но суммарно прослушал — именно «прослушал», ибо рассмотреть мелкий шрифт на экране телефона, будучи за рулём, решительно невозможно — около 30 часов материала. Написать свой движок имиджборды - хороший повод попрактиковаться.

В самом начале я заложил для себя несколько принципов:

  1. Простота используемых решений. The simpler, the better.

  2. Быстродействие и низкая вычислительная сложность важнее пользовательского опыта.

  3. Масштабируемость. Архитектура должна поддерживать репликацию на несколько инстансов с минимальными изменениями кода. Спойлер — если и получилось, то с большими оговорками.

  4. Минимум внешних зависимостей. Я ничего не умел и во всём хотел разобраться сам.

  5. Не подглядывать в движки других имиджборд. Это наивно, но мне хотелось иметь «незамыленный» взгляд. Только собственные решения, только хардкор.

  6. Минимум JS. Вместо объяснений лучше покажу свой референс в плане дизайна: https://news.ycombinator.com/login [3].

В статье будет описание архитектурных проблем, неочевидных (для дурака вроде меня) особенностей веба и различного рода курьёзов, с которыми я столкнулся по мере разработки. Всё, что меня удивило, застало врасплох, заставило подумать. Сниппеты с кодом будут представлены на псевдо-языке с синтаксисом Голанга.
Стоит держать в уме, что практически всю дистанцию проект разрабатывался руками, без LLM-агентов, что я не могу проиллюстрировать иначе как картинкой ниже:

разработка без llm

разработка без llm

Это комбинация лени, упрямости и ригидности психики. На финальных этапах агенты очень сильно помогли с несколькими крупными рефакторингами, написанием документации и причёсыванием тестов.

Материала много, в одну статью без ущерба для читательского опыта уместить не получится. Сегодня сосредоточимся на бэкенде, базах данных и парсере разметки. Между делом оценим один из архитектурных компромиссов движка двача.

Переизобретая велосипед

В этой части поговорим о вещах, которые до меня изобретали сотни раз. Некоторые из них избыточны и являются преждевременной оптимизацией, но наша цель — космос!

Черный список

По заветам [4] Николая Дурова, писать сайт я начал с авторизации. Никакого OAuth 2.0, никаких refresh-токенов. Один JWT на юзера — залогинился, получил токен, всё.
При попытке попасть на страницу, которая закрыта аутентификацией, сайт проверяет наличие токена либо в куке, либо в хэдере запроса.

Допустим, кто-то сказал, что админ-дурак, и мы хотим ограничить ему доступ к сайту.
С логином проблем нет, это редкие запросы, и мы можем каждый раз ходить в БД и проверять, заблокирован ли юзер. Но что делать с теми, кто уже залогинился и получил токен?
Самое простое решение аналогично решению для логина — на каждый запрос к сайту ходить в базу и проверять. При таком подходе блокировка срабатывает мгновенно, сам запрос (поиск по индексу) тратит пару миллисекунд, что незначительно относительно JSON-сериализации, сети до клиента и рендеринга HTML. Но вдруг локальная имиджборда для айтишников разрастётся до размеров гугла? Тогда на каждый (практически) запрос к сайту мы добавляем отдельный запрос в БД. Тысячи запросов будут жрать CPU и истощать connection pool. Думаем дальше.

А если держать черный список в памяти?
При старте приложения загружаем список из базы, в процессе работы обновляем синхронно таблицу в БД и список в памяти. Плюсы — мгновенная блокировка. Но для масштабирования на несколько инстансов надо будет что-то придумывать. Забанили на одном инстансе → на другом про это ничего не знают.

Добавлять в технологический стек in-memory базу ради такой задачи мне показалось оверкиллом: ещё один контейнер, ещё одна зависимость, ещё одна точка отказа.

У access-токенов есть время жизни, мы можем воспользоваться этим фактом. Будем каждые несколько секунд фоном выгружать всех юзеров, которых заблокировали, но у которых ещё не истёк срок жизни токена:

SELECT user_id
FROM user_blacklist
WHERE blacklisted_at >= now() - token_ttl  -- token_ttl подставляется из конфига приложения
ORDER BY blacklisted_at DESC
Как дата саинтист имиджборду писал - 3 [5]

Если токен протух, юзера попросят заново залогиниться. А логин для пользователей из черного списка мы запретили.
Да, блокировка происходит не мгновенно, максимальный лаг — время между походами в базу. Плюсы — в памяти держим только актуальные записи, легко масштабируется на несколько инстансов, хоть и с задержкой синхронизации между ними, но мы с умным видом можем сказать, что это eventual consistency, и собеседнику останется только уважительно кивнуть.

Вот как это выглядит в реальном коде [6]:

// storage/pg/blacklist.go
func (s *Storage) GetRecentlyBlacklistedUsers(since time.Time) ([]domain.UserId, error) {
    rows, err := q.Query(`
        SELECT user_id
        FROM user_blacklist
        WHERE blacklisted_at >= $1
        ORDER BY blacklisted_at DESC`, since)
    // ...
}
// В сервисном слое: since = time.Now().Add(-tokenTTL)
Как дата саинтист имиджборду писал - 4 [5]
Подход 1: Запрос в БД на каждый запрос
┌────────┐    каждый запрос    ┌────────┐
│ Клиент │ ─────────────────▶  │   БД   │
└────────┘  SELECT blocked?    └────────┘
            ⚡ мгновенная блокировка
            ✅ простой код
            ❌ нагрузка растёт линейно с трафиком

Подход 2: Весь список в памяти
┌────────┐    запрос    ┌──────────┐  при старте   ┌────────┐
│ Клиент │ ──────────▶  │ In-memory│ ◀───────────  ���   БД   │
└────────┘  O(1) lookup │   cache  │  sync write   └────────┘
                        └──────────┘
            ⚡ мгновенная блокировка
            ❌ не масштабируется, необходимо дублировать логику

Подход 3: Периодическая синхронизация (выбран)
┌────────┐    запрос    ┌──────────┐  каждые N сек  ┌────────┐
│ Клиент │ ──────────▶  │   cache  │ ◀───────────   │   БД   │
└────────┘  O(1) lookup │ (только  │  SELECT WHERE  └────────┘
                        │актуальные│  blacklisted_at
                        │ записи)  │  >= now()-TTL
                        └──────────┘
            ⏱ задержка до N секунд
            ✅ масштабируется, нет дубликации логики
Как дата саинтист имиджборду писал - 5 [5]

Удаление старых тредов

Мы не хотим хранить все треды, мы хотим хранить N последних.
Казалось бы, что может быть проще? При создании нового треда проверяем, что количество тредов превысило N, и удаляем самые старые. Но если мы параллельно создаём несколько тредов — каждый в своей транзакции — они все попробуют почистить одни и те же старые треды. Критично ли это? Нет: иногда мы будем превышать N, иногда будем пытаться удалить уже удалённые треды. Но это некрасиво, а значит думаем дальше.

А что если в операцию «создание треда» добавить мьютекс? Запретить параллельно создавать несколько тредов. Тогда вопрос: как этот мьютекс синхронизировать между несколькими инстансами? Как один инстанс может знать, что другой сейчас создаёт тред?

Подумал-подумал и придумал: сделаю фоновый процесс, который будет периодически удалять старые треды.
Как только я написал решение с фоновым сборщиком мусора, я узнал про advisory locks [7] в PostgreSQL. Это мьютекс на уровне базы данных, использовать максимально просто: SELECT pg_advisory_xact_lock($1) — при окончании транзакции замок освобождается автоматически. Важный нюанс: pg_advisory_xact_lock — это именно транзакционный advisory lock (в отличие от pg_advisory_lock, который держится до явного pg_advisory_unlock или конца сессии). Мьютекс на уровне бд решают проблему с синхронизацией.

Финальное решение — при создании треда берём advisory lock по доске, в рамках той же транзакции проверяем, надо ли удалить старые треды. Поскольку advisory lock принимает int64, а борды у меня именуются строками, ключ замка — это FNV-хэш [8] от имени доски:

// storage/pg/thread.go — advisory lock по хэшу имени доски
h := fnv.New32a()
h.Write([]byte(creationData.Board))
lockKey := int64(h.Sum32())

if _, err = q.Exec("SELECT pg_advisory_xact_lock($1)", lockKey); err != nil {
    return -1, time.Time{}, fmt.Errorf("failed to acquire advisory lock: %w", err)
}

// В рамках той же транзакции: проверяем лимит и удаляем старые треды
toDelete, err = s.threadsToDelete(q, creationData.Board, *maxThreadCount-1)
Как дата саинтист имиджборду писал - 6 [5]

Полный код [9]

Подход 1: Наивный (без синхронизации)
┌──────────┐    CREATE     ┌────────┐               ┌──────────┐
│ Инстанс 1│ ────────────▶ │   БД   │ ◀──────────── │ Инстанс 2│
└──────────┘  DELETE old?  └────────┘   DELETE old? └──────────┘
              ❌ race condition: оба пытаются удалить одни и те же треды
              ❌ возможно превышение лимита N

Подход 2: Мьютекс в приложении
┌──────────┐  mutex.Lock() ┌──────────┐    CREATE     ┌────────┐
│ Инстанс 1│ ────────────▶ │   ???    │ ────────────▶ │   БД   │
└──────────┘               └──────────┘               └────────┘
              ❌ как синхронизировать мьютекс между инстансами?
              ❌ нужна внешняя система координации

Подход 3: Advisory lock в PostgreSQL (выбран)
┌──────────┐                ┌────────────────────────────┐
│ Инстанс 1│ ──── BEGIN ──▶ │ pg_advisory_xact_lock(     │
└──────────┘                │   fnv32("board_name"))     │
┌──────────┐                │                            │
│ Инстанс 2│ ──── BEGIN ──▶ │ ... ждёт освобождения ...  │
└──────────┘                └────────────────────────────┘
              ✅ синхронизация на уровне БД
              ✅ автоматическое освобождение при COMMIT/ROLLBACK
              ✅ масштабируется на любое число инстансов
Как дата саинтист имиджборду писал - 7 [5]

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

Бонус: SQL-газлайтинг

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

rows, err := q.Query(`
    SELECT id FROM threads
    WHERE board = $1 AND is_pinned = FALSE
    ORDER BY last_bumped_at ASC, id
    LIMIT GREATEST((SELECT count(*) FROM threads WHERE board = $1) - $2, 0)`,
    board, maxCount,
)
Как дата саинтист имиджборду писал - 8 [5]

Я подумал: зачем считать count внутри основного запроса? Давай вынесем получение количества тредов в отдельный метод, а в запрос передадим уже готовые числа:

count, err := s.threadCount(q, board)
// ... обработка ошибок ...

rows, err := q.Query(`
    SELECT id FROM threads
    WHERE board = $1 AND is_pinned = FALSE
    ORDER BY last_bumped_at ASC, id
    LIMIT GREATEST(0, $2 - $3)`, // Просто вычитаем два аргумента
    board, count, maxCount,
)
Как дата саинтист имиджборду писал - 9 [5]

Я запускаю код, ожидая увидеть прирост эстетики, и получаю от драйвера базы данных ошибку:
operator is not unique: unknown - unknown
PostgreSQL буквально посмотрел на $2 - $3 и сказал: «Я не знаю, что такое минус. Ты пытаешься вычесть строку из строки? Дату из даты? Яблоко из паровоза?».

В первом варианте база видела count(*) (который гарантированно возвращает число), понимала контекст и догадывалась, что $2 — это тоже число. Во втором варианте два голых параметра вогнали строгую типизацию prepared statements в ступор.

Можно было бы, конечно, успокоить базу явным кастом типов прямо в запросе ($2::int - $3::int), но я пошёл по пути наименьшего сопротивления и перенёс эту сложнейшую арифметику в код на Go.

limit := count - maxCount
if limit < 0 {
    limit = 0
}
rows, err := q.Query(`
    SELECT id FROM threads
    ...
    LIMIT $2`,
    board, limit,
)
Как дата саинтист имиджборду писал - 10 [5]

С созданием и удалением тредов разобрались. Но главная головная боль имиджборды — это чтение.

ОПТИМИЗИРУЙ ЭТО!, или как я ускорял самую дорогую операцию на имиджборде

Главная страница доски — это список отсортированных тредов и их последних сообщений. Сортировка осуществляется по времени последнего сообщения в треде:

Страница доски

Страница доски

Чтобы собрать главную страницу доски, нам необходимо иметь список тредов, их последние сообщения, порядок сортировки. Это уже как минимум один подзапрос с джоинами нескольких таблиц и оконная функция. Без учета ответов на сообщения и вложений.
Получение главной страницы доски — это самый частый запрос на имиджборде, её сердце. На странице могут быть сотни тредов, каждый — с несколькими сотнями сообщений. Огромное поле для оптимизации.
Откровенно дурацкие варианты вроде «получать все сообщения доски и фильтровать/сортировать на стороне Go» я даже рассматривать не буду. Начнём с мало-мальски адекватного решения.

Мгновенный кэш на стороне бэкенда: создаём объект доски, который обновляем при каждом новом сообщении/треде/удалении. Возникает несколько проблем:

  • Синхронизация состояния среди нескольких инстансов. Как сообщить одному серверу, что на другом отправили сообщение или создали тред? Тут нужна отдельная система событий или очередь сообщений — ещё одна зависимость.

  • Сложная структура: набор упорядоченных тредов с их сообщениями и метаданными. Надо внимательно следить, чтобы ничего не потерялось, чтобы новые сообщения и удаления обрабатывались корректно, чтобы кэш не расходился с базой. Логику придётся дублировать и для кэша, и для БД.

Как вы уже могли понять, я очень люблю кэш и eventual consistency, поэтому выделю две неплохие опции:

  • периодическая синхронизация с базой: кэшируем ответ функции GetBoard на несколько секунд, как кэш протух — делаем поход в базу для сборки нового

  • кэшировать на стороне PostgreSQL через материализованное представление [10]

В случае мат. представления у нас сразу два преимущества:

  • в случае нескольких инстансов у всех у них будет один и тот же кэш

  • кэшировать на стороне PostgreSQL проще с точки зрения кода (хотя тут можно поспорить, смотрим refreshMaterializedViewConcurrent дальше)

Для мгновенного отображения обновлений материализованное представление надо было бы обновлять при каждом: создании треда, создании сообщения, удалении треда, удалении сообщения. Это могут быть десятки обновлений в секунду. Следуя принципу «Быстродействие и низкая вычислительная сложность важнее пользовательского опыта», мы делаем неблокирующее обновление представления раз в несколько секунд. Обновления отображаются с задержкой, но нагрузка на БД существенно меньше.

Ключевое слово здесь — CONCURRENTLY. PostgreSQL позволяет обновлять материализованное представление, не блокируя чтение:

// storage/pg/board_view.go — неблокирующее обновление
func (s *Storage) refreshMaterializedViewConcurrent(board domain.BoardShortName, interval time.Duration) error {
    ctx, cancel := context.WithTimeout(context.Background(), interval*2)
    defer cancel()
    viewName := ViewTableName(board)
    _, err := s.db.ExecContext(ctx,
        fmt.Sprintf("REFRESH MATERIALIZED VIEW CONCURRENTLY %s", viewName))
    return err
}
Как дата саинтист имиджборду писал - 12 [5]

Код обновления представления [11]
Код сбора представления [12]

Ответы

Информация об ответах хранится в отдельной таблице. У каждого сообщения может быть несколько ответов. Для сбора главной страницы доски у нас уже есть тяжёлый запрос с тремя джоинами, подзапросом и оконной функцией (для сортировки тредов по времени обновления).
Если у вас есть мазохистские наклонности — можете собирать ответы в основном запросе через array_agg или json_object_agg, указав в GROUP BY все столбцы основного запроса, а потом пытаться превратить всё это в golang struct и обогатить метаданными (ведь нам нужен не только id ответа). Если вы абсолютно сумасшедший, то можете сделать JOIN нашей «тяжёлой» таблицы и таблицы с ответами, тогда у вас будет множество строк, в которых 90% столбцов дублируется.

После выполнения нашего тяжёлого запроса у нас на руках есть список сообщений. Мы можем отсортировать их в обратном хронологическом порядке и за один проход агрегировать информацию по ответам, ведь на с��общение n могут ответить только сообщения n+1...end. Этот подход имеет право на жизнь, он самый быстрый (собрать эту информацию можно за один проход, в процессе парсинга строк из БД), но у него есть две небольшие проблемы:

  • ОП-пост видит только n (в моём случае 3) последних ответов, другие сообщения мы из базы даже не достаём

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

Любопытное наблюдение. Судя по проблемам с рендерингом ответов на сообщения текущим двачом (ОП-пост видит только то, что есть на странице, ответы между тредами могут теряться), они, вероятно, выбрали именно этот подход. Скорее всего, это осознанный трейд-офф ради выживания под их безумными нагрузками, где лишний поход за связями — непозволительная роскошь. Если в комментариях есть те, кто знаком с внутренностями движка текущего Двача — поправьте меня, если я не прав, было бы интересно узнать, как это реализовано у них на самом деле.

Но остановился на другом решении: при парсинге строк из БД сохраняю id сообщений и делаю отдельный запрос за ответами:

SELECT ... FROM message_replies mr
JOIN unnest($2::bigint[], $3::bigint[]) AS keys(thread_id, msg_id)
ON mr.receiver_thread_id = keys.thread_id AND mr.receiver_message_id = keys.msg_id
Как дата саинтист имиджборду писал - 13 [5]

UNNEST необходим из-за составного ключа для сообщений.
Так как для борды есть пагинация, размер unnest ограничен maxThreadsPerPage * (1 + nLastMessages) — число lookup'ов фиксировано, а каждый из них по B-tree индексу практически мгновенный.

Итоговое решение [11]:

  • Для каждой доски — своё материализованное представление

  • Фоновый процесс для обновления всех представлений активных досок раз в несколько секунд

Поток данных: от записи до отображения

                    запись
┌────────┐  INSERT  ┌──────────────┐
│ Клиент │ ───────▶ │ threads,     │
└────────┘  msg/    │ messages,    │
            thread  │ users        │
                    └──────┬───────┘
                           │
          каждые N сек     │  JOIN + dense_rank()
          ┌────────────────┘  + оконная функция
          ▼
┌─────────────────────────────┐
│   MATERIALIZED VIEW (на     │  REFRESH ... CONCURRENTLY
│   доску): OP + последние    │◀──── горутина по тикеру
│   сообщения + thread_order  │      (только активные доски)
└──────────┬──────────────────┘
           │
           │  SELECT + пагинация
           ▼
┌────────────────────┐  отдельный   ┌──────────────────┐
│ Список тредов      │  запрос по   │  message_replies │
│ с сообщениями      │──── UNNEST ─▶│  (ответы)        │
└────────┬───────────┘  msg IDs     └──────────────────┘
         │
         ▼
┌────────────────────┐
│HTML + Last-Modified│──────▶ Клиент
│ (из времени view)  │
└────────────────────┘
Как дата саинтист имиджборду писал - 14 [5]

Спойлер: то, что материализованное представление обновляется раз в несколько секунд, дало мне неожиданный бонус. Я синхронизировал с ним HTTP-заголовок Last-Modified для фронтенда, сделав отдачу HTML практически бесплатной и сняв 90% читающей нагрузки с сервера. Бенчмарки, нагрузочное тестирование и разбор того, как этот кэш повёл себя под реальной нагрузкой — в следующей части.

Баг, который я ловил неделю

Но даже тут я сначала обосрался: у меня интервал обновления совпадал с интервалом проверки активности: «каждые 5 секунд обновляй те доски, которые были активны в последние 5 секунд». Звучит логично, но now() внутри транзакции возвращает время начала транзакции, а не момента коммита. Если транзакция начиналась 5.5 секунд назад, а заканчивалась 3 секунды назад, то активность записывалась задним числом — за пределами окна проверки. Доска теряла новые данные, пока какая-то другая активность не приводила к обновлению.

refresh_interval = 5 сек, activity_window = 5 сек

t=0.0   BEGIN (INSERT message)
        now() внутри транзакции = 0.0  ← записывается в БД
t=3.0   COMMIT
t=5.0   Проверка: "была ли активность за последние 5 сек?"
        Ищем записи с timestamp >= 0.0... Находим!  ✅ Всё ок

НО при долгой транзакции:
t=0.0   BEGIN (INSERT message + тяжёлая обработка)
        now() = 0.0  ← это уйдёт в БД
t=5.5   COMMIT
t=5.0   Проверка: "активность за последние 5 сек?" (timestamp >= 0.0)
        Транзакция ещё не закоммичена → записи не видно  ❌
t=10.0  Следующая проверка: (timestamp >= 5.0)
        Запись с t=0.0 за пределами окна  ❌ Потеряна!
Как дата саинтист имиджборду писал - 15 [5]

Решение простое — делать интервал проверки активности в несколько раз дольше, чем интервал обновления.

Парсер разметки

— А как вы поняли, что парсер разметки я сам написал?!

— А как вы поняли, что парсер разметки я сам написал?!

Какая имиджборда без кривой разметки?

Про редактор хабра

Прямо сейчас я узнал, что редактор статьи хабра при нажатии на кнопочку "добавить спойлер" добавляет <code><spoiler title="title">text</spoiler></code>, который...не работает.

Но, благодаря моим ИСКЛЮЧИТЕЛЬНЫМ знаниям маркдауна, я обнаружил, что работает ||text||. Если хабр не осилил — куда бедному соло-разработчику?

P.S. Весь текст после <details> (сворачиваемый блок) и до пустой строки парсится как html. На вёрстку этого мини-сообщения у меня ушло 20 минут. Вот и думайте.

Я решил использовать уже ставший всем привычным маркдаун. Минимальный джентльменский набор: **жирный шрифт**, *курсив*, ~~зачеркивание~~, `код`, спойлеры ||вот такие|| и, конечно же, святая святых — >гринтекст.

Как человек психически здоровый, я пошёл по пути наименьшего сопротивления и взял готовую популярную библиотеку goldmark для генерации HTML из маркдауна. Но вот незадача: стандартный маркдаун не знает про гринтекст и ссылки на треды.

Пришлось лезть под капот goldmark и писать свои кастомные парсеры абстрактного синтаксического дерева (AST). Код обрастал структурами вроде greentextParser, переопределениями рендереров и проверками состояний.
Но сгенерировать HTML — это полбеды. В него же могут засунуть <script>alert('XSS')</script>. Поэтому поверх всего этого великолепия я ещё прикрутил библиотеку bluemonday для санитайзинга.

А ещё была вишенка на торте. Нужно проверять, содержит ли сообщение реальную смысловую нагрузку (payload), или юзер отправил кучу пробелов, которые парсер заботливо обернул в пустые теги <p></p>. Знаете, как я это проверял?

// Берем HTML, регуляркой вырезаем ВСЕ теги, анэскейпим сущности и смотрим,
// остался ли хоть один символ. Гениально. (Нет)
var htmlTagRegex = regexp.MustCompile(`<[^>]*>`)
textOnly := htmlTagRegex.ReplaceAllString(htmlString, "")
textOnly = html.UnescapeString(textOnly)
return strings.TrimSpace(textOnly) != "", nil
Как дата саинтист имиджборду писал - 17 [5]

Это был настоящий Франкенштейн. Куча хаков, костылей, постоянная борьба с парсером, отсутствие полного контроля над выполнением. Работать оно работало (примерно как я на удалёнке), но на каждое сообщение мы строили AST-дерево, рендерили его, парсили HTML санитайзером, а потом ещё раз парсили регулярками. После очередной попытки победить <br><br> от goldmark с помощью CSS, я вдруг вспомнил пункт 4 из начала статьи: «Минимум внешних зависимостей».

Пишем свой парсер

В какой-то момент я задумался: зачем мне тащить в проект две огромные библиотеки, если мне нужно найти в строке пару спецсимволов и обернуть их в теги? Неужели это так сложно (спойлер — да, желающие могут изучить https://spec.commonmark.org/ [13])?

Я полностью выпилил goldmark с bluemonday и разделил парсинг на два логических этапа: парсер блоков и инлайн-парсер. Старый Франкенштейн — это ~350 строк моих надстроек поверх двух внешних библиотек. Новый парсер — ~450 строк, но это всё: trie, стек, блоковый и инлайн-парсер, санитайзинг, проверка payload. Ноль зависимостей.

1. Парсер блоков
Работает максимально просто и железобетонно. Мы читаем сообщение построчно. Если строка начинается с определённого префикса (например, ``` для кода или > для гринтекста), парсер блоков кричит «Моё!» и полностью забирает обработку на себя. Он «проглатывает» все последующие строки до тех пор, пока не встретит закрывающее условие (например, следующие ``` или пустую строку для гринтекста). Внутри такого блока применяются свои правила форматирования (например, он может передавать управление инлайн-парсеру на каждой строке). Никакого сложного AST, простая стейт-машина.

2. Инлайн-парсер и префиксное дерево (Trie)
Если строка не принадлежит никакому блоку (или блок делегирует обработку строки), в дело вступает инлайн-парсер. Как быстро понять, начинается ли текущий кусок текста с **, ~~ или ||? Использовать префиксное дерево! Мы один раз при старте приложения кладём все наши маркеры в Trie [14]. Теперь при проходе по тексту мы за O(K) (где K — длина маркера, то есть максимум 3 символа) понимаем, наткнулись мы на форматирование или нет.

А чтобы правильно обрабатывать вложенную и незакрытую разметку, я прикрутил классический стек. Нашли открывающий маркер? Кидаем в стек. Нашли закрывающий? Идём по стеку назад, собираем текст, оборачиваем в <strong>...</strong> и схлопываем. Не закрыли? Стек в конце склеится «как есть», без форматирования.

Пример работы стека на входе: **жирный** и *курсив*

Поз 0:  Trie → match "**"          → PUSH стек: [**]
Поз 2:  "ж","и","р","н","ы","й"    → текст в буфер
Поз 10: Trie → match "**"          → POP, оборачиваем: <strong>жирный</strong>
Поз 12: " и "                      → текст в буфер
Поз 15: Trie → match "*"           → PUSH стек: [*]
Поз 16: "к","у","р","с","и","в"    → текст в буфер
Поз 22: Trie → match "*"           → POP, оборачиваем: <em>курсив</em>

Выход: <strong>жирный</strong> и <em>курсив</em>
Как дата саинтист имиджборду писал - 18 [5]

3. Встроенная защита и Payload
Поскольку теперь я сам иду по каждому символу сообщения, мне больше не нужен тяжёлый bluemonday. Если символ не попадает ни в одно правило (или это обычный текст внутри правила), я на лету эскейплю опасные символы:

// markdown/parser.go
func escapeChar(result *strings.Builder, c byte) {
    switch c {
    case '<': result.WriteString("&lt;")
    case '>': result.WriteString("&gt;")
    case '&': result.WriteString("&amp;")
    case '"': result.WriteString("&quot;")
    default:  result.WriteByte(c)
    }
}
Как дата саинтист имиджборду писал - 19 [5]

Полный код парсера [15]

А как же та самая уродливая проверка на payload? Больше никаких регулярок! Если мы дошли до функции вывода символа, и этот символ — не пробел и не перенос строки, мы просто делаем p.hasPayload = true. Всё! Одно булево присваивание вместо парсинга HTML-дерева.

Honorable mention:

Вместо префиксного дерева можно было использовать заранее заготовленный словарь префиксов. У нас есть маркеры длины 1, 2 и 3 символа. Мы могли бы на каждом шаге брать подстроки text[i:i+1], text[i:i+2], text[i:i+3] и пытаться матчить их по словарю.

С точки зрения асимптотической сложности это абсолютно то же самое: в худшем случае проверка ограничена константной максимальной длиной маркера O(K). В Go лукапы по мапе работают безумно быстро, и на практике такой подход мог бы оказаться даже производительнее за счёт локальности кэша и меньшего количества разыменований указателей, чем при прыжках по нодам дерева. Но я выбрал префиксное дерево. Почему? Потому что словарь — это МЕНЕЕ ЭЛЕГАНТНО!

Как дата саинтист имиджборду писал - 20

Элегантность на практике (но есть нюанс)
Иногда внутренний перфекционист всё-таки брал верх. Была одна проблема, которая мозолила глаза: комбинация жирного шрифта и курсива.
Если юзер писал ***abc***, мой алгоритм сходил с ума. Поиск наибольшего совпадения (longest match) находил правило ** (жирный), клал его в стек, а затем находил оставшуюся * (курсив). При закрытии получалась мешанина, и на выходе рендерилось что-то вроде <strong>*abc</strong>*.

Как бы эту проблему решали в классическом вебе? Пришлось бы писать сложную логику проверки состояний, усложнять лексер или менять сам движок. Но у меня же тупой и прямолинейный поиск по словарю маркеров! Чтобы подружить жирный шрифт и курсив, мне вообще не пришлось трогать алгоритм. Я просто добавил одно новое правило в инициализацию [15]:

p.inlineRules = []InlineRule{
    {marker: "***", OpenTag: "<strong><em>", CloseTag: "</em></strong>"}, // <--- Магия здесь
    {marker: "**", OpenTag: "<strong>", CloseTag: "</strong>"},
    {marker: "~~", OpenTag: "<del>", CloseTag: "</del>"},
    {marker: "||", OpenTag: `<span class="spoiler">`, CloseTag: "</span>"},
    {marker: "`", OpenTag: "<code>", CloseTag: "</code>", EscapeContent: true},
    {marker: "*", OpenTag: "<em>", CloseTag: "</em>"},
}
Как дата саинтист имиджборду писал - 21 [5]

Всё! При старте приложения маркер *** становится частью нашего набора правил. Теперь алгоритм находит его как самое длинное совпадение, кидает в стек целиком, и так же целиком закрывает.

Как это работает: ***abc***

Поз 0: Trie → ["*","**","***"] → longest "***" → PUSH [***]
Поз 3: "a","b","c"             → буфер
Поз 6: Trie → ["*","**","***"] → longest "***" → совпадает с вершиной стека!
       → POP, оборачиваем: <strong><em>abc</em></strong>

Выход: <strong><em>abc</em></strong> ✅
Как дата саинтист имиджборду писал - 22 [5]

Пока AST-virgin'ы пишут новые паттерн-визиторы, а соевые любители regex'а ломают голову над lookbehind/lookahead группами (да-да, многие до сих пор пытаются парсить разметку регулярками, порождая чудовищ Франкенштейна), stack-энджоеры решают проблему добавлением одной строчки в конфигурацию...

...По крайней мере, так я думал, пока гордо не запустил прогон тестов:

Test case: "**bold***italic*"

Expected:
"<strong>bold</strong><em>italic</em>"

Got:
"**bold***italic*"
Как дата саинтист имиджборду писал - 23 [5]

Я смотрел на консоль, а консоль смотрела на меня.

В чём проблема? Мой «элегантный» алгоритм жадного поиска (longest match) увидел **, положил в стек. Потом он пошёл дальше, увидел *** (ведь теперь у нас есть такое правило!), радостно сожрал его и тоже положил в стек. А потом дошёл до одиночной *. Итого в стеке лежат **, *** и *. Ни один из них не нашёл себе пару для закрытия, поэтому парсер выплюнул их обратно как обычный текст.

Наглядно: как жадный поиск ломает **bold***italic*

Поз 0:  Trie → ["*","**"]       → longest "**"  → PUSH [**]
Поз 2:  "b","o","l","d"         → буфер
Поз 7:  Trie → ["*","**","***"] → longest "***" → PUSH [**, ***]
Поз 10: "i","t","a","l","i","c" → буфер
Поз 16: Trie → ["*"]            → "*" ≠ "**", "*" ≠ "***" → текст

Конец: стек не пуст → всё сбрасывается как plain text
Выход: **bold***italic*  😢
Как дата саинтист имиджборду писал - 24 [5]

Чтобы починить этот edge-кейс, мне пришлось бы реализовывать backtracking (откат состояния), заглядывания вперёд (lookahead) и окончательно убивать красивую O(N) сложность линейного прохода.

Я взвесил все «за» и «против», посмотрел на код, тяжело вздохнул... и удалил этот тест. В конце концов, если аноним пишет **bold***italic* вплотную без пробелов — он сам виноват. Сам! Понятно вам?! А парсер хороший!

В процессе мучения с маркдауном я задумался, а зачем вообще придумывать синтаксис, где маркеры пересекаются (* и **)? Если бы для курсива был *, а для жирного, скажем, ^, любой стековый парсер работал бы идеально. Наличие пересекающихся префиксов делает грамматику неоднозначной, парсинг — недетерминированным, алгоритм кратно усложняется. Так что все претензии за кривой рендеринг edge-кейсов прошу направлять лично создателю Markdown Джону Груберу.

Лимитации
Конечно, за всё приходится платить, и мой кастомный велосипед имеет ряд особенностей.
Во-первых, никаких вложенных блоков — архитектура тупая и прямолинейная. Во-вторых, инлайн-парсер не умеет в сложную комбинаторику маркеров. Например, если вы напишете **a****b**, он распарсит это не как <strong>a</strong><strong>b</strong>, а как жирное <strong>a****b</strong>. А если намешать в кучу спойлеры с зачёркиваниями и криво их закрыть, получится то самое современное искусство, которое красуется на скриншоте в начале этой главы.

Я прекрасно осознаю, что мой тупой и прямолинейный подход математически не может полностью и безошибочно реализовать парсинг стандарта Markdown (не зря спецификация CommonMark занимает 30 000 слов). Но мы пишем не корпоративную википедию, а имиджборду. Для того, чтобы жаловаться на еот, этого достаточно.

По итогу: ни одной внешней зависимости, микросекунды на парсинг и, что самое главное, полный контроль над разметкой.

Итог

В статью попала лишь малая часть материала. В следующих частях я планирую описать мою борьбу с обработкой медиа (JPEG-бомбы, очистка EXIF, ffmpeg и lazy load), про боль с JS (система превью на чистом JS: проблема выпадающего меню, таймауты, графы навигации), про деплой (DDoS-защита, Cloudflare в РФ, блокировку email'ов корпоративными политиками, нежелание ставить nginx и осознание неизбежности этого), легальный ад (terms of use, contacts, legal).

Сайт уже в проде [16], весь код — опенсорс [17]. В проекте примерно 25 тысяч строк кода, всё делал я один, и багов не избежать. Сайт какое-то время в закрытом режиме тестировала группа моих друзей, знакомых и товарищей, но этого недостаточно.
Большая просьба: если обнаружите что-то — свяжитесь со мной по указанной на сайте почте или через @itchandev [18]. Пулл-реквесты с фиксами уязвимостей тоже приветствуются!

А теперь про доступ. Я понимаю, что стрёмно вводить свою корп. почту на неизвестном ресурсе, поэтому я сгенерировал 200 инвайтов для Хабра. Честно признаюсь: мне было дико лень писать логику многоразовых промокодов. Поэтому сейчас архитектура тупая: 1 инвайт = 1 сгенерированная фейковая почта (логин) в БД. Кто успел руками забрать — молодец. Кто написал скрипт, который спарсит файл и автоматизирует регистрацию, пока остальные спят — ещё больший молодец. Let the Hunger Games begin!

https://itchan.ru/static/habr_invites.txt [19]

Будьте внимательны, сгенерированный логин показывается один раз. Восстановить доступ к инвайт-аккаунту невозможно.

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

Попробовать

P.S. Про приватность, шифрование и товарища майора

Многие спросят: зачем вообще хранить почты? Почему не удалять их сразу после генерации токена? Ответ прост: я живу в РФ, работаю в белую и проект функционирует в легальном правовом поле, как тот же Хабр. По закону (ФЗ-152, требования к ОРИ) я обязан хранить эти данные. Если я сделаю иначе — проект улетит в вечный бан РКН, а я получу огромный штраф.

Но хранить данные в открытом виде — это прямое приглашение к сливу базы. Все корпоративные почты в базе зашифрованы алгоритмом AES-256-GCM.

Сразу расставлю точки над i: это шифрование нужно не для того, чтобы играть в кибер-партизана и прятать данные от спецслужб — для этого можно было бы использовать хэширование. Если правоохранительные органы придут ко мне с официальным ордером (и дымящейся канифолью), я передам необходимые данные.

Шифрование — это защита от утечек. Ключ дешифрования лежит отдельно от БД, SSH закрыт по ключам, лишние порты отрезаны. Моя задача — сделать так, чтобы в случае взлома сервера хакеры скачали бесполезный набор байтов, а ваша рабочая почта не оказалась в публичном Telegram-канале со сливами, и до вас не докопался ваш HR или безопасник. У меня нет задачи сделать второй silk road или гидру.

Автор: itchan-dev

Источник [22]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/postgresql/446120

Ссылки в тексте:

[1] ячану: https://wikireality.ru/wiki/%D0%AF%D1%87%D0%B0%D0%BD

[2] ШАДовским: https://shad.yandex.ru/

[3] https://news.ycombinator.com/login: https://news.ycombinator.com/login

[4] заветам: https://tass.ru/obschestvo/22095295

[5] Image: https://sourcecraft.dev/

[6] реальном коде: https://github.com/itchan-dev/itchan/blob/v0.1.0/backend/internal/storage/pg/blacklist.go

[7] advisory locks: https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS

[8] FNV-хэш: https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function

[9] Полный код: https://github.com/itchan-dev/itchan/blob/v0.1.0/backend/internal/storage/pg/thread.go

[10] материализованное представление: https://en.wikipedia.org/wiki/Materialized_view

[11] Код обновления представления: https://github.com/itchan-dev/itchan/blob/v0.1.0/backend/internal/storage/pg/board_view.go

[12] Код сбора представления: https://github.com/itchan-dev/itchan/blob/v0.1.0/backend/internal/storage/pg/templates/board_view_template.sql

[13] https://spec.commonmark.org/: https://spec.commonmark.org/

[14] Trie: https://github.com/itchan-dev/itchan/blob/v0.1.0/frontend/internal/markdown/trie.go

[15] Полный код парсера: https://github.com/itchan-dev/itchan/blob/v0.1.0/frontend/internal/markdown/parser.go

[16] проде: https://itchan.ru

[17] опенсорс: https://github.com/itchan-dev/itchan

[18] @itchandev: http://t.me/itchandev

[19] https://itchan.ru/static/habr_invites.txt: https://itchan.ru/static/habr_invites.txt

[20] itchan.ru/faq: https://itchan.ru/faq

[21] https://itchan.ru/register_invite: https://itchan.ru/register_invite

[22] Источник: https://habr.com/ru/articles/1005248/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1005248