Как дата саинтист имиджборду писал
- вторник, 3 марта 2026 г. в 00:00:07

Цель статьи — посмотреть на мир веб-разработки глазами человека, знакомого с алгоритмами и структурами данных, синтаксисом языка, работой с БД, но ничего не знающего про веб. Статья носит сугубо развлекательный характер, написана простым языком, некоторые технические моменты намеренно опускаются.
На дворе конец 2023. Я только что уволился из Яндекса и скучаю по ячану, чуть меньше скучаю по этушке, вообще не скучаю по таскам, дедлайнам, ревью. Чтобы заполнить возникший информационный вакуум, пробую переключиться на реддит, hacker news, пикабу, вышивание крестиком, сканворды, пилатес — не то. Тогда мне в голову приходит гениальная идея: а почему бы не сделать свою имиджборду с авторизацией по корпоративной почте крупных российских компаний? Ячан для всех!
Первая мысль — взять готовый движок и допилить под себя, в открытом доступе уже есть: lynx, vichan, wakaba, kareha, fchannel. Потыкался — ничего не понятно. Как ленивый человек решаю, что надо писать своё.
На тот момент я:
Не понимал разницу между HTTP и HTTPS
Не знал, что такое handler, router, middleware
Считал, что DNS — это какой-то раздел электронной музыки
Думал, что куки и кэш — это одно и то же
Не без труда отличал header от body
Не мог пропатчить kde2 под freebsd
Короче говоря, я был именно тем человеком, который должен был писать проект с нуля. Цель понятна, надо выбрать инструменты. Я неплохо знал питон и c++... поэтому языком разработки выбрал Голанг. Мой опыт с Голангом на тот момент ограничивался прослушанным фоном на х2 ШАДовским курсом. Прослушал я его в автопоездке Москва — Челябинск. Я не написал ни одной строчки кода, но суммарно прослушал — именно «прослушал», ибо рассмотреть мелкий шрифт на экране телефона, будучи за рулём, решительно невозможно — около 30 часов материала. Написать свой движок имиджборды - хороший повод попрактиковаться.
В самом начале я заложил для себя несколько принципов:
Простота используемых решений. The simpler, the better.
Быстродействие и низкая вычислительная сложность важнее пользовательского опыта.
Масштабируемость. Архитектура должна поддерживать репликацию на несколько инстансов с минимальными изменениями кода. Спойлер — если и получилось, то с большими оговорками.
Минимум внешних зависимостей. Я ничего не умел и во всём хотел разобраться сам.
Не подглядывать в движки других имиджборд. Это наивно, но мне хотелось иметь «незамыленный» взгляд. Только собственные решения, только хардкор.
Минимум JS. Вместо объяснений лучше покажу свой референс в плане дизайна: https://news.ycombinator.com/login.
В статье будет описание архитектурных проблем, неочевидных (для дурака вроде меня) особенностей веба и различного рода курьёзов, с которыми я столкнулся по мере разработки. Всё, что меня удивило, застало врасплох, заставило подумать. Сниппеты с кодом будут представлены на псевдо-языке с синтаксисом Голанга.
Стоит держать в уме, что практически всю дистанцию проект разрабатывался руками, без LLM-агентов, что я не могу проиллюстрировать иначе как картинкой ниже:

Это комбинация лени, упрямости и ригидности психики. На финальных этапах агенты очень сильно помогли с несколькими крупными рефакторингами, написанием документации и причёсыванием тестов.
Материала много, в одну статью без ущерба для читательского опыта уместить не получится. Сегодня сосредоточимся на бэкенде, базах данных и парсере разметки. Между делом оценим один из архитектурных компромиссов движка двача.
В этой части поговорим о вещах, которые до меня изобретали сотни раз. Некоторые из них избыточны и являются преждевременной оптимизацией, но наша цель — космос!
По заветам Николая Дурова, писать сайт я начал с авторизации. Никакого 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
Если токен протух, юзера попросят заново залогиниться. А логин для пользователей из черного списка мы запретили.
Да, блокировка происходит не мгновенно, максимальный лаг — время между походами в базу. Плюсы — в памяти держим только актуальные записи, легко масштабируется на несколько инстансов, хоть и с задержкой синхронизации между ними, но мы с умным видом можем сказать, что это eventual consistency, и собеседнику останется только уважительно кивнуть.
Вот как это выглядит в реальном коде:
// 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)
Подход 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 секунд ✅ масштабируется, нет дубликации логики
Мы не хотим хранить все треды, мы хотим хранить N последних.
Казалось бы, что может быть проще? При создании нового треда проверяем, что количество тредов превысило N, и удаляем самые старые. Но если мы параллельно создаём несколько тредов — каждый в своей транзакции — они все попробуют почистить одни и те же старые треды. Критично ли это? Нет: иногда мы будем превышать N, иногда будем пытаться удалить уже удалённые треды. Но это некрасиво, а значит думаем дальше.
А что если в операцию «создание треда» добавить мьютекс? Запретить параллельно создавать несколько тредов. Тогда вопрос: как этот мьютекс синхронизировать между несколькими инстансами? Как один инстанс может знать, что другой сейчас создаёт тред?
Подумал-подумал и придумал: сделаю фоновый процесс, который будет периодически удалять старые треды.
Как только я написал решение с фоновым сборщиком мусора, я узнал про advisory locks в 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-хэш от имени доски:
// 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)
Подход 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 ✅ масштабируется на любое число инстансов
Да, это запрещает создание тредов параллельно, но я обрабатываю загрузку медиафайлов отдельно. Само создание треда — это сохранение его метаданных, очень быстрая операция. Для долгих операций лучшим решением был бы фоновый сборщик мусора.
В процессе оптимизации базы данных я решил навести красоту в коде. У меня был запрос, который прямо внутри 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, )
Я подумал: зачем считать 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, )
Я запускаю код, ожидая увидеть прирост эстетики, и получаю от драйвера базы данных ошибку:
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, )
С созданием и удалением тредов разобрались. Но главная головная боль имиджборды — это чтение.
Главная страница доски — это список отсортированных тредов и их последних сообщений. Сортировка осуществляется по времени последнего сообщения в треде:

Чтобы собрать главную страницу доски, нам необходимо иметь список тредов, их последние сообщения, порядок сортировки. Это уже как минимум один подзапрос с джоинами нескольких таблиц и оконная функция. Без учета ответов на сообщения и вложений.
Получение главной страницы доски — это самый частый запрос на имиджборде, её сердце. На странице могут быть сотни тредов, каждый — с несколькими сотнями сообщений. Огромное поле для оптимизации.
Откровенно дурацкие варианты вроде «получать все сообщения доски и фильтровать/сортировать на стороне Go» я даже рассматривать не буду. Начнём с мало-мальски адекватного решения.
Мгновенный кэш на стороне бэкенда: создаём объект доски, который обновляем при каждом новом сообщении/треде/удалении. Возникает несколько проблем:
Синхронизация состояния среди нескольких инстансов. Как сообщить одному серверу, что на другом отправили сообщение или создали тред? Тут нужна отдельная система событий или очередь сообщений — ещё одна зависимость.
Сложная структура: набор упорядоченных тредов с их сообщениями и метаданными. Надо внимательно следить, чтобы ничего не потерялось, чтобы новые сообщения и удаления обрабатывались корректно, чтобы кэш не расходился с базой. Логику придётся дублировать и для кэша, и для БД.
Как вы уже могли понять, я очень люблю кэш и eventual consistency, поэтому выделю две неплохие опции:
периодическая синхронизация с базой: кэшируем ответ функции GetBoard на несколько секунд, как кэш протух — делаем поход в базу для сборки нового
кэшировать на стороне PostgreSQL через материализованное представление
В случае мат. представления у нас сразу два преимущества:
в случае нескольких инстансов у всех у них будет один и тот же кэш
кэшировать на стороне 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 }
Код обновления представления
Код сбора представления
Информация об ответах хранится в отдельной таблице. У каждого сообщения может быть несколько ответов. Для сбора главной страницы доски у нас уже есть тяжёлый запрос с тремя джоинами, подзапросом и оконной функцией (для сортировки тредов по времени обновления).
Если у вас есть мазохистские наклонности — можете собирать ответы в основном запросе через 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
UNNEST необходим из-за составного ключа для сообщений.
Так как для борды есть пагинация, размер unnest ограничен maxThreadsPerPage * (1 + nLastMessages) — число lookup'ов фиксировано, а каждый из них по B-tree индексу практически мгновенный.
Итоговое решение:
Для каждой доски — своё материализованное представление
Фоновый процесс для обновления всех представлений активных досок раз в несколько секунд
Поток данных: от записи до отображения запись ┌────────┐ 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) │ └────────────────────┘
Спойлер: то, что материализованное представление обновляется раз в несколько секунд, дало мне неожиданный бонус. Я синхронизировал с ним 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 за пределами окна ❌ Потеряна!
Решение простое — делать интервал проверки активности в несколько раз дольше, чем интервал обновления.

Какая имиджборда без кривой разметки?
Прямо сейчас я узнал, что редактор статьи хабра при нажатии на кнопочку "добавить спойлер" добавляет <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
Это был настоящий Франкенштейн. Куча хаков, костылей, постоянная борьба с парсером, отсутствие полного контроля над выполнением. Работать оно работало (примерно как я на удалёнке), но на каждое сообщение мы строили AST-дерево, рендерили его, парсили HTML санитайзером, а потом ещё раз парсили регулярками. После очередной попытки победить <br><br> от goldmark с помощью CSS, я вдруг вспомнил пункт 4 из начала статьи: «Минимум внешних зависимостей».
В какой-то момент я задумался: зачем мне тащить в проект две огромные библиотеки, если мне нужно найти в строке пару спецсимволов и обернуть их в теги? Неужели это так сложно (спойлер — да, желающие могут изучить https://spec.commonmark.org/)?
Я полностью выпилил goldmark с bluemonday и разделил парсинг на два логических этапа: парсер блоков и инлайн-парсер. Старый Франкенштейн — это ~350 строк моих надстроек поверх двух внешних библиотек. Новый парсер — ~450 строк, но это всё: trie, стек, блоковый и инлайн-парсер, санитайзинг, проверка payload. Ноль зависимостей.
1. Парсер блоков
Работает максимально просто и железобетонно. Мы читаем сообщение построчно. Если строка начинается с определённого префикса (например, ``` для кода или > для гринтекста), парсер блоков кричит «Моё!» и полностью забирает обработку на себя. Он «проглатывает» все последующие строки до тех пор, пока не встретит закрывающее условие (например, следующие ``` или пустую строку для гринтекста). Внутри такого блока применяются свои правила форматирования (например, он может передавать управление инлайн-парсеру на каждой строке). Никакого сложного AST, простая стейт-машина.
2. Инлайн-парсер и префиксное дерево (Trie)
Если строка не принадлежит никакому блоку (или блок делегирует обработку строки), в дело вступает инлайн-парсер. Как быстро понять, начинается ли текущий кусок текста с **, ~~ или ||? Использовать префиксное дерево! Мы один раз при старте приложения кладём все наши маркеры в Trie. Теперь при проходе по тексту мы за 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>
3. Встроенная защита и Payload
Поскольку теперь я сам иду по каждому символу сообщения, мне больше не нужен тяжёлый bluemonday. Если символ не попадает ни в одно правило (или это обычный текст внутри правила), я на лету эскейплю опасные символы:
// markdown/parser.go func escapeChar(result *strings.Builder, c byte) { switch c { case '<': result.WriteString("<") case '>': result.WriteString(">") case '&': result.WriteString("&") case '"': result.WriteString(""") default: result.WriteByte(c) } }
А как же та самая уродливая проверка на payload? Больше никаких регулярок! Если мы дошли до функции вывода символа, и этот символ — не пробел и не перенос строки, мы просто делаем p.hasPayload = true. Всё! Одно булево присваивание вместо парсинга HTML-дерева.
Вместо префиксного дерева можно было использовать заранее заготовленный словарь префиксов. У нас есть маркеры длины 1, 2 и 3 символа. Мы могли бы на каждом шаге брать подстроки text[i:i+1], text[i:i+2], text[i:i+3] и пытаться матчить их по словарю.
С точки зрения асимптотической сложности это абсолютно то же самое: в худшем случае проверка ограничена константной максимальной длиной маркера O(K). В Go лукапы по мапе работают безумно быстро, и на практике такой подход мог бы оказаться даже производительнее за счёт локальности кэша и меньшего количества разыменований указателей, чем при прыжках по нодам дерева. Но я выбрал префиксное дерево. Почему? Потому что словарь — это МЕНЕЕ ЭЛЕГАНТНО!

Элегантность на практике (но есть нюанс)
Иногда внутренний перфекционист всё-таки брал верх. Была одна проблема, которая мозолила глаза: комбинация жирного шрифта и курсива.
Если юзер писал ***abc***, мой алгоритм сходил с ума. Поиск наибольшего совпадения (longest match) находил правило ** (жирный), клал его в стек, а затем находил оставшуюся * (курсив). При закрытии получалась мешанина, и на выходе рендерилось что-то вроде <strong>*abc</strong>*.
Как бы эту проблему решали в классическом вебе? Пришлось бы писать сложную логику проверки состояний, усложнять лексер или менять сам движок. Но у меня же тупой и прямолинейный поиск по словарю маркеров! Чтобы подружить жирный шрифт и курсив, мне вообще не пришлось трогать алгоритм. Я просто добавил одно новое правило в инициализацию:
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>"}, }
Всё! При старте приложения маркер *** становится частью нашего набора правил. Теперь алгоритм находит его как самое длинное совпадение, кидает в стек целиком, и так же целиком закрывает.
Как это работает: ***abc*** Поз 0: Trie → ["*","**","***"] → longest "***" → PUSH [***] Поз 3: "a","b","c" → буфер Поз 6: Trie → ["*","**","***"] → longest "***" → совпадает с вершиной стека! → POP, оборачиваем: <strong><em>abc</em></strong> Выход: <strong><em>abc</em></strong> ✅
Пока AST-virgin'ы пишут новые паттерн-визиторы, а соевые любители regex'а ломают голову над lookbehind/lookahead группами (да-да, многие до сих пор пытаются парсить разметку регулярками, порождая чудовищ Франкенштейна), stack-энджоеры решают проблему добавлением одной строчки в конфигурацию...
...По крайней мере, так я думал, пока гордо не запустил прогон тестов:
Test case: "**bold***italic*" Expected: "<strong>bold</strong><em>italic</em>" Got: "**bold***italic*"
Я смотрел на консоль, а консоль смотрела на меня.
В чём проблема? Мой «элегантный» алгоритм жадного поиска (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* 😢
Чтобы починить этот 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).
Сайт уже в проде, весь код — опенсорс. В проекте примерно 25 тысяч строк кода, всё делал я один, и багов не избежать. Сайт какое-то время в закрытом режиме тестировала группа моих друзей, знакомых и товарищей, но этого недостаточно.
Большая просьба: если обнаружите что-то — свяжитесь со мной по указанной на сайте почте или через @itchandev. Пулл-реквесты с фиксами уязвимостей тоже приветствуются!
А теперь про доступ. Я понимаю, что стрёмно вводить свою корп. почту на неизвестном ресурсе, поэтому я сгенерировал 200 инвайтов для Хабра. Честно признаюсь: мне было дико лень писать логику многоразовых промокодов. Поэтому сейчас архитектура тупая: 1 инвайт = 1 сгенерированная фейковая почта (логин) в БД. Кто успел руками забрать — молодец. Кто написал скрипт, который спарсит файл и автоматизирует регистрацию, пока остальные спят — ещё больший молодец. Let the Hunger Games begin!
https://itchan.ru/static/habr_invites.txt
Будьте внимательны, сгенерированный логин показывается один раз. Восстановить доступ к инвайт-аккаунту невозможно.
Если вдруг инвайты закончатся, не расстраивайтесь — буду периодически подкидывать новые в комменты.
🌐 Сайт проекта: itchan.ru
📖 FAQ: itchan.ru/faq
🔑 Инвайты для Хабра: habr_invites.txt
🔑 Регистрация по инвайту: https://itchan.ru/register_invite
💻 Исходный код: GitHub / itchan
Многие спросят: зачем вообще хранить почты? Почему не удалять их сразу после генерации токена? Ответ прост: я живу в РФ, работаю в белую и проект функционирует в легальном правовом поле, как тот же Хабр. По закону (ФЗ-152, требования к ОРИ) я обязан хранить эти данные. Если я сделаю иначе — проект улетит в вечный бан РКН, а я получу огромный штраф.
Но хранить данные в открытом виде — это прямое приглашение к сливу базы. Все корпоративные почты в базе зашифрованы алгоритмом AES-256-GCM.
Сразу расставлю точки над i: это шифрование нужно не для того, чтобы играть в кибер-партизана и прятать данные от спецслужб — для этого можно было бы использовать хэширование. Если правоохранительные органы придут ко мне с официальным ордером (и дымящейся канифолью), я передам необходимые данные.
Шифрование — это защита от утечек. Ключ дешифрования лежит отдельно от БД, SSH закрыт по ключам, лишние порты отрезаны. Моя задача — сделать так, чтобы в случае взлома сервера хакеры скачали бесполезный набор байтов, а ваша рабочая почта не оказалась в публичном Telegram-канале со сливами, и до вас не докопался ваш HR или безопасник. У меня нет задачи сделать второй silk road или гидру.