- 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 часов материала. Написать свой движок имиджборды - хороший повод попрактиковаться.
В самом начале я заложил для себя несколько принципов:
Простота используемых решений. The simpler, the better.
Быстродействие и низкая вычислительная сложность важнее пользовательского опыта.
Масштабируемость. Архитектура должна поддерживать репликацию на несколько инстансов с минимальными изменениями кода. Спойлер — если и получилось, то с большими оговорками.
Минимум внешних зависимостей. Я ничего не умел и во всём хотел разобраться сам.
Не подглядывать в движки других имиджборд. Это наивно, но мне хотелось иметь «незамыленный» взгляд. Только собственные решения, только хардкор.
Минимум JS. Вместо объяснений лучше покажу свой референс в плане дизайна: https://news.ycombinator.com/login [3].
В статье будет описание архитектурных проблем, неочевидных (для дурака вроде меня) особенностей веба и различного рода курьёзов, с которыми я столкнулся по мере разработки. Всё, что меня удивило, застало врасплох, заставило подумать. Сниппеты с кодом будут представлены на псевдо-языке с синтаксисом Голанга.
Стоит держать в уме, что практически всю дистанцию проект разрабатывался руками, без 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
Если токен протух, юзера попросят заново залогиниться. А логин для пользователей из черного списка мы запретили.
Да, блокировка происходит не мгновенно, максимальный лаг — время между походами в базу. Плюсы — в памяти держим только актуальные записи, легко масштабируется на несколько инстансов, хоть и с задержкой синхронизации между ними, но мы с умным видом можем сказать, что это 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)
Подход 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 [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)
Полный код [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
✅ масштабируется на любое число инстансов
Да, это запрещает создание тредов параллельно, но я обрабатываю загрузку медиафайлов отдельно. Само создание треда — это сохранение его метаданных, очень быстрая операция. Для долгих операций лучшим решением был бы фоновый сборщик мусора.
В процессе оптимизации базы данных я решил навести красоту в коде. У меня был запрос, который прямо внутри 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 через материализованное представление [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
}
Код обновления представления [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
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) │
└────────────────────┘
Спойлер: то, что материализованное представление обновляется раз в несколько секунд, дало мне неожиданный бонус. Я синхронизировал с ним 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/ [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>
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)
}
}
Полный код парсера [15]
А как же та самая уродливая проверка на 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>*.
Как бы эту проблему решали в классическом вебе? Пришлось бы писать сложную логику проверки состояний, усложнять лексер или менять сам движок. Но у меня же тупой и прямолинейный поиск по словарю маркеров! Чтобы подружить жирный шрифт и курсив, мне вообще не пришлось трогать алгоритм. Я просто добавил одно новое правило в инициализацию [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>"},
}
Всё! При старте приложения маркер *** становится частью нашего набора правил. Теперь алгоритм находит его как самое длинное совпадение, кидает в стек целиком, и так же целиком закрывает.
Как это работает: ***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).
Сайт уже в проде [16], весь код — опенсорс [17]. В проекте примерно 25 тысяч строк кода, всё делал я один, и багов не избежать. Сайт какое-то время в закрытом режиме тестировала группа моих друзей, знакомых и товарищей, но этого недостаточно.
Большая просьба: если обнаружите что-то — свяжитесь со мной по указанной на сайте почте или через @itchandev [18]. Пулл-реквесты с фиксами уязвимостей тоже приветствуются!
А теперь про доступ. Я понимаю, что стрёмно вводить свою корп. почту на неизвестном ресурсе, поэтому я сгенерировал 200 инвайтов для Хабра. Честно признаюсь: мне было дико лень писать логику многоразовых промокодов. Поэтому сейчас архитектура тупая: 1 инвайт = 1 сгенерированная фейковая почта (логин) в БД. Кто успел руками забрать — молодец. Кто написал скрипт, который спарсит файл и автоматизирует регистрацию, пока остальные спят — ещё больший молодец. Let the Hunger Games begin!
https://itchan.ru/static/habr_invites.txt [19]
Будьте внимательны, сгенерированный логин показывается один раз. Восстановить доступ к инвайт-аккаунту невозможно.
Если вдруг инвайты закончатся, не расстраивайтесь — буду периодически подкидывать новые в комменты.
🌐 Сайт проекта: itchan.ru [16]
📖 FAQ: itchan.ru/faq [20]
🔑 Инвайты для Хабра: habr_invites.txt [19]
🔑 Регистрация по инвайту: https://itchan.ru/register_invite [21]
💻 Исходный код: GitHub / itchan [17]
Многие спросят: зачем вообще хранить почты? Почему не удалять их сразу после генерации токена? Ответ прост: я живу в РФ, работаю в белую и проект функционирует в легальном правовом поле, как тот же Хабр. По закону (ФЗ-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
Нажмите здесь для печати.