Инженерия

Как устроены Go-бэкенды

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

01 · job-queue

Конвейер: очередь задач

Очередь фоновых задач на SQLite: приоритеты, ретраи с backoff, cron, идемпотентные ключи. Главное — гарантии: at-least-once, lease и fencing.

  • Go
  • SQLite
  • REST API
  • SSE
  • Prometheus
  • Docker
  • 25 тестов
  • 19 маршрутов
  • 2 161 строк Go
Схема компонентов: Конвейер: очередь задачСхема проекта «Конвейер: очередь задач»: 7 компонентов и 7 связей, нумерация связей совпадает со списком потоков ниже.Клиент APIPOST /api/jobsПланировщикcron-расписанияSQLite (WAL)jobs · runs · schedulesЗахват задачиUPDATE … RETURNINGReaperистёкшие leaseПул воркеровheartbeat арендыДашборд и /metricsSSE · Prometheus1234567
Клиент / входСервисХранилище

Потоки данных

  1. 1Задача ставится через REST под Bearer-токеном; повторная постановка с тем же идемпотентным ключом возвращает уже существующую задачу.
  2. 2Каждый запуск cron ставится с ключом cron:<id>:<время>, а next_run сдвигается условным UPDATE, поэтому несколько процессов не создадут дубль.
  3. 3Самая приоритетная готовая задача выбирается по priority DESC, run_at, id.
  4. 4Один оператор выставляет статус, счётчик попыток, срок lease и уникальный токен аренды и отдаёт задачу воркеру.
  5. 5Воркер продлевает аренду heartbeat-ом каждую треть срока и завершает задачу (Complete/Fail), предъявляя токен: «зомби»-воркер получает ErrLeaseLost.
  6. 6Reaper возвращает задачи с истёкшей арендой в очередь с backoff или в dead, если попытки кончились.
  7. 7Снимок состояния раз в секунду уходит в дашборд по SSE; счётчики и гистограмма доступны на /metrics.

Решения

Атомарный захват одним UPDATE … RETURNING

Выбор задачи и выдача аренды происходят в одном операторе, поэтому два воркера, даже из разных процессов на одном файле БД, не получат одну задачу.

Lease, heartbeat и fencing-токен

Упавший воркер не теряет задачу: аренда истекает, а Complete/Fail/Extend проверяют токен, так что очнувшийся «зомби» не может испортить результат.

At-least-once, а не exactly-once

Обработчик может быть вызван повторно, поэтому обработчики должны быть идемпотентными. Очередь не обещает того, чего не может гарантировать.

Backoff с jitter

Задержка BACKOFF_BASE · 2^(n-1) с потолком, умноженная на случайный множитель 0,5–1; Permanent-ошибка и неизвестный тип сразу уходят в dead.

Компромиссы

  • Один файл SQLite: масштабирование только на одной машине (несколько процессов на одном томе допустимы, по сети нет).
  • Счётчики метрик живут в памяти и обнуляются при перезапуске; нет цепочек задач, rate limit по очередям и ролей (один токен).

Тесты

Конкурентный захват (400 задач, два «процесса» на одном файле, 12 воркеров — ни одного двойного исполнения), истечение lease и fencing, идемпотентные ключи в гонке, cron из двух процессов, graceful shutdown.

Исходники: README проекта job-queue: «Архитектура», «Гарантии доставки», «Ограничения»

02 · uptime-monitor

Пульс: монитор доступности

Монитор доступности: HTTP и TCP проверки, аптайм, p50/p95, инциденты, живой дашборд и публичная статус-страница.

  • Go
  • SQLite
  • SSE
  • REST API
  • Docker
  • 18 тестов
  • 15 маршрутов
  • 1 445 строк Go
Схема компонентов: Пульс: монитор доступностиСхема проекта «Пульс: монитор доступности»: 8 компонентов и 9 связей, нумерация связей совпадает со списком потоков ниже.Managerгорутина на проверкуЦелиHTTP / TCPRunзамер проверкиHub (pub/sub)неблокирующийSQLite (WAL)проверки · результаты · инцидентыstatsp50/p95 · аптаймDetectorинцидентыREST + SSEдашборд · /status123456789
Клиент / входСервисХранилище

Потоки данных

  1. 1Manager держит отдельную горутину на каждую проверку; следующий запуск планируется после завершения предыдущего, поэтому проверки одного адреса не накладываются.
  2. 2Run делает HTTP GET (до 5 редиректов, ожидаемый код или любой < 400) либо TCP connect с таймаутом.
  3. 3Результат записывается в SQLite.
  4. 4После записи результат публикуется в Hub.
  5. 5Detector получает результат: fail_threshold падений подряд открывают инцидент, первая успешная проверка его закрывает.
  6. 6Смена состояния пишется как инцидент; открытый инцидент переживает перезапуск.
  7. 7Аптайм за 24 ч, 7 и 30 дней и p50/p95 считаются запросами по сырой истории (чистые функции в internal/stats).
  8. 8REST отдаёт цифры дашборду и публичной статус-странице (только public-проверки, без адресов и текстов ошибок).
  9. 9Hub рассылает события SSE-подписчикам неблокирующе: медленный клиент теряет события, а не тормозит проверки.

Решения

Перцентили методом ближайшего ранга

p-й процентиль — элемент отсортированного среза с номером ceil(p/100·N). Простая чистая функция без интерполяции, легко проверить тестом.

Не накладывать проверки

Следующий запуск планируется после завершения предыдущего: медленный адрес не копит параллельные проверки сам на себя.

Медленный клиент не тормозит мониторинг

Рассылка в Hub неблокирующая; после переподключения дашборд заново читает состояние.

SSE через fetch, а не EventSource

Так токен передаётся в заголовке Authorization, а не в URL.

Компромиссы

  • Аптайм считается по числу проверок, а не по длительности: у проверок с разным интервалом цифры несопоставимы по точности.
  • Агрегаты считаются по сырой истории: для десятков проверок мгновенно, для тысяч понадобятся предагрегаты. Одна нода, один токен, уведомлений нет.

Тесты

Планировщик, перцентили и аптайм, жизненный цикл инцидента (в том числе после перезапуска), HTTP/TCP проверки с таймаутом, API, SSE.

Исходники: README проекта uptime-monitor: «Возможности», «Архитектура», «Ограничения»

03 · realtime-rooms

Комнаты: чат и доска в реальном времени

Комнаты на WebSocket: чат с историей, присутствие, общая доска. Протокол RFC 6455 написан на stdlib, чтобы показать, что происходит под new WebSocket(…).

  • Go
  • WebSocket
  • SQLite
  • Canvas
  • Docker
  • 48 тестов
  • 7 маршрутов
  • 1 822 строк Go
Схема компонентов: Комнаты: чат и доска в реальном времениСхема проекта «Комнаты: чат и доска в реальном времени»: 7 компонентов и 7 связей, нумерация связей совпадает со списком потоков ниже.БраузерыWebSocket-клиентыinternal/wsрукопожатие · фреймы · лимитыHub комнатмьютекс на комнатуОчередь клиента256 сообщенийwrite pumpдедлайн записи 10 сSQLite (WAL)история чатаДоска в памятиштрихи1234567
Клиент / входСервисХранилище

Потоки данных

  1. 1Браузер открывает WebSocket: GET с Upgrade, версия 13, ключ, проверка Origin; ответ 101 с Sec-WebSocket-Accept.
  2. 2Фреймы разбираются в internal/ws: маскирование, фрагментация, лимит размера, UTF-8, ping/pong; JSON-сообщение уходит в хаб (read pump).
  3. 3Рассылка кладёт сообщение в ограниченную очередь каждого клиента; при переполнении медленный клиент отключается кодом 1008, остальные не ждут.
  4. 4write pump забирает сообщения из очереди и пишет фреймы с дедлайном 10 с.
  5. 5Фреймы доходят до браузеров; эфемерные сообщения (курсоры, «печатает») пропускаются при заполнении очереди на 3/4.
  6. 6Сообщение рассылается только после успешной записи в SQLite; при ошибке отправитель получает error: store.
  7. 7Штрихи доски хранятся в памяти комнаты и отдаются вошедшим позже.

Решения

Свой слой WebSocket

Около 530 строк в internal/ws: рукопожатие, фреймы, маскирование, фрагментация, ping/pong и close-коды. Цель — понять протокол; для продакшена обычно берут проверенную библиотеку.

Backpressure вместо ожидания

Ограниченная очередь на клиента и отключение медленного клиента: одна медленная вкладка не задерживает комнату.

Одинаковый порядок для всех

Сохранение и рассылка выполняются под одним мьютексом комнаты; история в welcome читается под тем же мьютексом, поэтому нет ни пересечения, ни разрыва с живым потоком.

Лимиты на соединение

Токен-бакет для чата (2 сообщения/с, пачка 5) и общий лимит (200/с, пачка 400); флуд закрывает соединение кодом 1008.

Компромиссы

  • Один процесс: хаб в памяти, горизонтального масштабирования нет. Доска не переживает перезапуск сервера, очистить её может любой участник.
  • Нет permessage-deflate, TLS и аутентификации; Autobahn Test Suite не запускался. Ники анонимные и не защищены.

Тесты

Пример рукопожатия из RFC, 12 видов нарушений протокола с проверкой close-кода, фрагментация с ping посередине, отключение медленного клиента, а также нагрузочный тест: 360 соединений в 6 комнатах.

Исходники: README проекта realtime-rooms: «Почему свой слой WebSocket», «Возможности», «Гарантии», «Ограничения»

04 · booking-slots

Запись на приём

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

  • Go
  • SQLite
  • Docker
  • 12 тестов
  • 18 маршрутов
  • 891 строк Go
Схема компонентов: Запись на приёмСхема проекта «Запись на приём»: 6 компонентов и 6 связей, нумерация связей совпадает со списком потоков ниже.Клиентуслуга · день · слотАдминка /adminBearer-токенHTTP APIслоты · брони · админРасчёт слотовчасы минус занятоеБронированиеодна транзакцияSQLiteUNIQUE (date, start_min) WHERE active123456
Клиент / входСервисХранилище

Потоки данных

  1. 1Клиент запрашивает 14 дней вперёд и слоты дня, затем отправляет POST /api/bookings.
  2. 2Владелец управляет записями, услугами, часами работы и закрытыми датами.
  3. 3Слоты считаются по длительности услуги, часам дня недели и занятым интервалам, шаг сетки 30 минут.
  4. 4Book проверяет свободность слота и вставляет запись в одной транзакции.
  5. 5Расчёт читает часы работы, закрытые даты и активные записи.
  6. 6Вставка защищена частичным UNIQUE-индексом; при конфликте клиент получает 409.

Решения

Две линии защиты от двойной брони

Проверка свободности и вставка идут в одной транзакции, а частичный UNIQUE-индекс по (date, start_min) для активных записей ловит то, что проверка пропустила. Отменённая запись освобождает время.

Часовой пояс в данных

Пояс берётся из TZ_NAME, база tzdata встроена в бинарник: расписание не зависит от настроек машины.

Компромиссы

  • Один специалист/ресурс: все услуги делят одно расписание.
  • Закрытие даты не отменяет созданные записи; клиент не может отменить запись сам, уведомлений нет; админка за общим токеном.

Тесты

Расчёт слотов и границы периода, пересечения, конкурентная бронь: 30 горутин на одно время — успешна ровно одна.

Исходники: README проекта booking-slots: «Возможности», «Тесты», «Ограничения» и код internal/booking

06 · kanban-tasks

Янтарь: канбан-доска

Многопользовательская канбан-доска: регистрация, доски, колонки, карточки с перетаскиванием.

  • Go
  • SQLite
  • Docker
  • 7 тестов
  • 18 маршрутов
  • 871 строк Go
Схема компонентов: Янтарь: канбан-доскаСхема проекта «Янтарь: канбан-доска»: 5 компонентов и 5 связей, нумерация связей совпадает со списком потоков ниже.Браузерванильный JS · drag and dropHTTP API (JSON)Content-Type · same-originАутентификацияPBKDF2 · сессииДоски и карточкиданные по пользователюSQLiteбез CGO12345
Клиент / входСервисХранилище

Потоки данных

  1. 1Браузер ходит в JSON API; изменяющие запросы проверяются на Content-Type и same-origin.
  2. 2Регистрация и вход по почте и паролю: PBKDF2-HMAC-SHA256, сессия в HttpOnly-cookie с SameSite=Lax.
  3. 3Доски, колонки и карточки доступны только владельцу: данные пользователей изолированы.
  4. 4Пользователи и сессии хранятся в SQLite.
  5. 5Доски, колонки, карточки и их порядок хранятся в SQLite.

Решения

Защита без внешних библиотек

PBKDF2 из стандартной библиотеки, HttpOnly-сессия, проверка Content-Type и same-origin для изменяющих запросов.

Компромиссы

  • Учебный пример: нет сброса пароля, подтверждения почты и ограничения частоты входа.
  • Не выставлять в интернет без HTTPS и обратного прокси.

Тесты

Тесты API (go vet и go test в CI).

Исходники: README проекта kanban-tasks: «Возможности», «Ограничения»

07 · api-gateway

Шлюз: API-gateway

API-шлюз на чистой stdlib: маршруты из JSON-конфига с горячей перезагрузкой, балансировка, health-checks, circuit breaker на узел, ретраи, rate limit. Главное — неизменяемое состояние и защита от open-proxy и SSRF.

  • Go
  • stdlib only
  • Reverse proxy
  • Circuit breaker
  • Token bucket
  • SSE
  • Prometheus
  • slog
  • Vanilla JS
  • embed
  • Docker
  • GitHub Actions
  • 48 тестов
  • 11 маршрутов
  • 2 334 строк Go
Схема компонентов: Шлюз: API-gatewayСхема проекта «Шлюз: API-gateway»: 10 компонентов и 9 связей, нумерация связей совпадает со списком потоков ниже.КлиентHTTP на :8087ОператорSIGHUP · PUT /api/configВход шлюзаrequest-id · host + prefixAuth и rate limitsha256 ключа · token bucketСостояниеatomic.PointerБалансировщикround-robin · least-connHealth-checksGET /health на узелCircuit breakerclosed · open · half-openДашборд и /metricsSSE · PrometheusАпстримыSSRF-guard · ретраи123456789
Клиент / входСервисХранилище

Потоки данных

  1. 1Запрос приходит на :8087, получает request-id, маршрут ищется по хосту и самому длинному префиксу; не найден — 404, шлюз не работает как open-proxy.
  2. 2Оператор меняет конфиг через SIGHUP (только Unix) или PUT/POST в админке на :8088; конфиг проверяется целиком, ошибочный отклоняется, старый продолжает работать.
  3. 3Новое состояние (маршруты, апстримы, ключи) подменяется одним atomic.Pointer: слушающий сокет не трогается, запрос в полёте доезжает на старой версии.
  4. 4Ключ API хэшируется sha256 и сверяется с конфигом; rate limit по token bucket ведётся по IP (IPv6 по /64) или по ключу, лишний запрос не доходит до апстрима.
  5. 5Балансировщик выбирает узел round-robin или least-conn, но только среди узлов в состоянии healthy.
  6. 6Отдельный health-loop на каждый узел опрашивает GET /health и по порогам помечает узел healthy или нет.
  7. 7У каждого узла свой breaker (closed, open, half-open); ошибкой считаются сбой соединения, таймаут и любой 5xx кроме 501, обрыв запроса клиентом не считается.
  8. 8Соединение идёт через guarded Dialer, который проверяет IP в момент соединения (SSRF, DNS-rebinding); при 502/503/504 или сбое сети GET, HEAD, OPTIONS, PUT и DELETE повторяются на другом узле.
  9. 9Окно статистики и счётчики уходят в дашборд по SSE и на /metrics в формате Prometheus; метки ограничены конфигом, путь и ключи в метки не попадают.

Решения

Неизменяемое состояние под atomic.Pointer

Маршруты, апстримы и ключи собираются в новое состояние и подменяются одним указателем. Апстримы с неизменённым описанием переиспользуются целиком, поэтому health, breaker и счётчики не сбрасываются при перезагрузке.

Не open-proxy и защита от SSRF

Адрес назначения берётся только из конфига, Host, absolute-URI, CONNECT/TRACE и пути с «..» не дают выйти за маршруты. IP, в который резолвится имя, проверяется ещё раз при соединении, а разрешение приватных сетей задаётся окружением, а не конфигом.

Breaker на каждый узел

Состояния closed, open и half-open ведутся отдельно у каждого узла: один больной узел выпадает из ротации, остальные продолжают принимать трафик.

Ретраи только идемпотентных запросов

POST не повторяется, чтобы не выполнить операцию дважды; тело буферизуется до 1 МиБ, а более крупные запросы идут без ретраев.

Компромиссы

  • Rate limit и состояние breaker живут в памяти одного процесса: при нескольких репликах лимиты не общие, а за балансировщиком все клиенты выглядят одним IP, потому что X-Forwarded-For шлюз не доверяет.
  • Нет TLS на входе, WebSocket и Upgrade не поддерживаются (501); перцентили по гистограмме с погрешностью до 25%; админка с одним токеном; SIGHUP только на Unix.

Тесты

Балансировщики, состояния breaker на фейковых часах, token bucket, SSRF-кейсы, ретраи (GET, PUT с телом; POST не ретраится), горячая перезагрузка с запросом «в полёте», защита от open-proxy, нагрузочный тест (4000 запросов, 40 клиентов); go test -race в CI.

Исходники: README проекта api-gateway: «Архитектура», «Гарантии», «Что считается ошибкой», «Ограничения»

09 · raft-kv

Плот: KV на Raft

Учебная реализация Raft на чистой stdlib: три узла, выборы, репликация, коммит по кворуму, WAL с fsync, снапшоты, read-index. Главное — ядро без времени и сети, которое один и тот же код гоняет и в рантайме, и в детерминированном симуляторе.

  • Go
  • Raft
  • Distributed systems
  • Consensus
  • stdlib only
  • HTTP/JSON
  • SSE
  • WAL + fsync
  • Snapshots
  • Linearizability
  • Deterministic simulation testing
  • Vanilla JS
  • Docker
  • GitHub Actions
  • 27 тестов
  • 13 маршрутов
  • 2 646 строк Go
Схема компонентов: Плот: KV на RaftСхема проекта «Плот: KV на Raft»: 8 компонентов и 8 связей, нумерация связей совпадает со списком потоков ниже.КлиентPUT · GET · DELETEДашборд :8098SSE · аварии (демо)Рантайм узлаHTTP/JSON · тик 25 мс · 307raft.NodeTick() · Step(msg)Два других узлаvote · append · snapshotХранилищеWAL + fsync · state.jsonKV-автоматput · del · снапшотСимуляторвиртуальное время · seed12345678
Клиент / входСервисХранилище

Потоки данных

  1. 1Клиент шлёт PUT, GET или DELETE на любой из узлов 8095–8097; не лидер отвечает 307 с Location на лидера (и заголовком X-Raft-Leader), а если лидер неизвестен, то 503 с Retry-After.
  2. 2Дашборд раз в 200 мс получает по SSE роль, term, commit/applied и хвост лога; кнопки «остановить узел», «оборвать сеть» и «убить лидера» работают только при SEED_DEMO=1 и идут по настоящему HTTP.
  3. 3Рантайм тикает ядро каждые 25 мс (Tick) и передаёт ему входящие сообщения (Step); исходящие сообщения возвращаются вызывающему, сам raft.Node не знает ни о времени, ни о сети.
  4. 4Выборы (vote), репликация (append) и snapshot идут HTTP/JSON-сообщениями, ответы это отдельные сообщения, поэтому сеть может терять и переупорядочивать пакеты; индекс коммитится, когда его хранит большинство и запись относится к текущему term.
  5. 5Закоммиченные записи применяются к автомату состояний (словарь, команды put и del); ответ PUT уходит после коммита на кворуме, а линеаризуемый GET ждёт applied >= readIndex.
  6. 6Ответ уходит только после fsync: term и votedFor пишутся в state.json (временный файл, fsync, rename), записи лога в WAL с CRC32; битый хвост после крэша обрезается при загрузке.
  7. 7После SNAPSHOT_EVERY применённых записей (по умолчанию 200) снапшот словаря пишется новым поколением файлов: новый WAL, затем атомарная публикация snapshot.bin, затем удаление старого WAL.
  8. 8Тот же raft.Node работает в симуляторе: время виртуальное, сеть управляемая (задержки, потери, обрывы), крэш и рестарт узла идут из его хранилища; один seed воспроизводит запуск побайтно, а инварианты и линеаризуемость проверяются после каждого шага.

Решения

Ядро без горутин и часов

raft.Node получает время только через Tick() и сообщения через Step(), а исходящие возвращает вызывающему. Поэтому один и тот же код работает в рантайме (тик 25 мс, HTTP) и в симуляторе, а сбой воспроизводится по seed.

Коммит только записей текущего term

Индекс коммитится, когда его хранит большинство и запись относится к текущему term (раздел 5.4.2 статьи), поэтому новый лидер сразу кладёт в лог no-op. Целенаправленного теста на фигуру 8 нет, и это записано в ограничениях.

Read-index вместо лизов

Лидер запоминает commitIndex, рассылает heartbeat, ждёт подтверждения большинства и только потом отвечает на GET. Корректность не зависит от часов, а отрезанный лидер ответить не может.

Снапшот «поколениями»

Новый WAL, затем атомарная публикация snapshot.bin (точка коммита), затем удаление старого WAL. Крэш на любом шаге оставляет целое старое или целое новое состояние.

Компромиссы

  • Состав фиксирован (ровно три узла), нет динамического членства, pre-vote и check-quorum, клиентских сессий и exactly-once: после 504 исход неизвестен. Снапшот идёт одним сообщением и целиком в памяти, каждый PUT это отдельный fsync, TLS и аутентификации нет.
  • Проверка собственная: симулятор с идеальным диском и проверка линеаризуемости на малых историях, это не Jepsen. Одна ручная мутация, коммит записей прошлых term, тестами не ловится.

Тесты

Выбор одного лидера на term (40 seed-ов с потерями, крэшами и обрывами), переизбрание после падения лидера, репликация, отсутствие коммита без кворума, откат хвоста отрезанного лидера, рестарт с диска, голос не отдаётся дважды после рестарта, снапшоты и догон, линеаризуемость под хаосом (30 seed-ов), детерминизм трассы; HTTP-тесты узла и сам чекер линеаризуемости; go test -race в CI.

Исходники: README проекта raft-kv: «Архитектура», «Как устроены части», «Инварианты», «Тесты», «Чего здесь нет»

10 · auth-service

Ключница: сервис аутентификации

Сервис аутентификации на stdlib crypto и SQLite: EdDSA-JWT с ротацией ключей и JWKS, refresh-токены с ротацией и reuse-detection, TOTP, RBAC, lockout, аудит. Главное — потоки токенов и честная модель угроз с тем, что остаётся незакрытым.

  • Go
  • stdlib crypto
  • SQLite
  • modernc.org/sqlite (без CGO)
  • JWT EdDSA/Ed25519
  • JWKS
  • PBKDF2
  • TOTP
  • RBAC
  • REST
  • OpenAPI 3
  • Vanilla JS
  • embed
  • Docker
  • GitHub Actions
  • 50 тестов
  • 35 маршрутов
  • 2 826 строк Go
Схема компонентов: Ключница: сервис аутентификацииСхема проекта «Ключница: сервис аутентификации»: 8 компонентов и 7 связей, нумерация связей совпадает со списком потоков ниже.Браузер и клиентыBearer · cookie rtСторонний сервиспроверяет по JWKSHTTP-слойCSP · Origin · CSRF · лимит IPЯдро authPBKDF2 · TOTP · RBACJWT EdDSAkid · кольцо ключейSQLiteсессии · токены · ключиJWKS/.well-known/jwks.jsonЖурнал аудитатолько дозапись1234567
Клиент / входСервисХранилище

Потоки данных

  1. 1Вход: логин и пароль, при включённой 2FA второй шаг с кодом TOTP или резервным кодом; в ответе access JWT на 10 минут, а refresh приходит в HttpOnly-cookie SameSite=Strict вместе с CSRF-токеном.
  2. 2Middleware проверяет Origin, CSRF и лимит запросов с IP, затем подпись и срок access-токена, потом по БД что сессия жива, а права берутся из БД на каждом запросе, поэтому отзыв и смена роли действуют сразу.
  3. 3Токены подписывает кольцо ключей Ed25519: принимается только EdDSA, kid берётся из белого списка; POST /api/admin/keys/rotate выпускает новый ключ, а старый остаётся для проверки ещё access_ttl + 1 минуту.
  4. 4В БД лежат хеши пароля (PBKDF2, 600 000 итераций), refresh-токенов (SHA-256) и резервных кодов; refresh гасит старый токен и выдаёт новый, а повторное предъявление использованного отзывает всю семью и пишет token.reuse_detected.
  5. 5Публичные ключи отдаются на /.well-known/jwks.json (RFC 7517/8037) вместе с kid.
  6. 6Сторонний сервис берёт kid из заголовка JWT, достаёт ключ из JWKS (кеш на минуту) и проверяет подпись, iss, aud, typ и exp; об отзыве сессии он узнает не раньше конца жизни access-токена.
  7. 7Вход, неудачи, блокировки, 2FA, сброс пароля, отзывы сессий, действия админа, ротация ключей и отказы по правам пишутся в таблицу audit; триггеры БД запрещают UPDATE и DELETE, пароли и токены в журнал не попадают.

Решения

Проверка сессии по БД на каждом запросе

Access-токен сверяется с БД (сессия не отозвана, пользователь не отключён, права актуальны). Это стоит одного запроса к SQLite и взамен даёт немедленные «выйти на всех устройствах», блокировку и смену роли.

Семья refresh-токенов и reuse-detection

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

Только EdDSA и свой разбор JWT на stdlib

Алгоритмы none и HS256 отклоняются до проверки подписи (защита от alg-confusion), kid из белого списка, длина токена ограничена. Тесты включают HS256 с публичным ключом в роли секрета.

Одинаковый ответ и время для неизвестного логина

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

Компромиссы

  • Секрет TOTP и приватные ключи подписи лежат в БД открытым текстом: с дампом БД можно подделать токены и 2FA. Пароли хешируются PBKDF2, а не argon2id, резервные коды быстрым SHA-256.
  • Не прошёл независимый аудит, нет OAuth/OIDC, WebAuthn и подтверждения почты; письмо сброса это заглушка в логе. Лимитер и блокировки живут в памяти и в SQLite одного процесса, а access-токен не отозвать у сторонних потребителей до exp.

Тесты

JWT (подделка, exp/nbf/iss/aud/typ, alg=none, HS256 с публичным ключом, ротация), TOTP по векторам RFC 6238 и RFC 4226, ротация refresh и reuse-detection, блокировка с экспоненциальной задержкой, 2FA и резервные коды, RBAC, сброс пароля, аудит (запрет UPDATE/DELETE), cookie-флаги, CSRF, CORS, Origin, rate limit; JWKS проверяется независимым кодом; go test -race в CI.

Исходники: README проекта auth-service: «Возможности», «Потоки токенов», «Модель угроз», «Схема БД», «Ограничения»

11 · finance-tracker

Книга учёта: личные финансы

Учёт личных финансов: Go-бинарник с SQLite и встроенным фронтендом на Nuxt 4 (SPA). Главное — деньги целыми минорными единицами, мультивалютность с ручными курсами, расписание и импорт CSV, изоляция пользователей.

  • Go
  • SQLite
  • modernc.org/sqlite (без CGO)
  • REST API
  • Nuxt 4
  • Vue 3
  • TypeScript
  • Pinia
  • SVG-графики
  • vitest
  • ESLint
  • Docker
  • GitHub Actions
  • PBKDF2
  • CSV import/export
  • 30 тестов
  • 45 маршрутов
  • 3 741 строк Go
Схема компонентов: Книга учёта: личные финансыСхема проекта «Книга учёта: личные финансы»: 8 компонентов и 10 связей, нумерация связей совпадает со списком потоков ниже.Nuxt 4 SPAPinia · SVG-графикиCSV из банкаимпорт · экспортHTTP API на Goсессия в cookie · Origin · user_idИмпорт CSVпревью · пробный прогонРасписаниеcron-правило · нагонСчета и операцииминорные единицы · big.IntБюджеты и отчётымесяц · трендыSQLitemodernc · без CGO12345678910
Клиент / входСервисХранилище

Потоки данных

  1. 1Браузер работает с API через JSON; сессия живёт в httpOnly-cookie (SameSite=Lax), в базе хранится только SHA-256 токена, а у запросов на запись проверяется Origin.
  2. 2Файл CSV и экспорт (CSV с защитой от formula-injection и полный JSON) проходят через тот же API, отдельных каналов нет.
  3. 3Каждый запрос привязан к user_id из сессии; обычный CRUD счетов, категорий, курсов и операций идёт прямо в слой счетов и операций, с фильтрами q, kind, account_id, category_id, tag, from, to.
  4. 4POST /api/import/preview определяет разделитель и кодировку и показывает сопоставление колонок; /commit пишет строки, пропускает дубли и возвращает отчёт по ошибочным строкам (не более 20 000 строк за раз).
  5. 5Cron-подобное правило из пяти полей (с точностью до дня) исполняется при открытии приложения и по кнопке /api/recurring/run; пропущенные даты нагоняются, фонового планировщика нет.
  6. 6Импортированные строки становятся обычными транзакциями: дубли пропускаются, недостающие категории создаются, переводы не импортируются.
  7. 7Регулярные платежи тоже становятся обычными транзакциями и хранят recurring_id; last_date правила двигается вперёд.
  8. 8Отчёты читают операции целиком в память и считают в базовой валюте по текущим курсам; наклон тренда считается МНК в целых числах.
  9. 9Суммы пишутся целыми минорными единицами (amount_minor > 0), курс хранится целым ×10⁶, пересчёт идёт через big.Int; баланс счёта не хранится, а считается запросом.
  10. 10Лимиты бюджета (limit_minor) и пороги предупреждения (warn_pct) лежат в таблице budgets и сравниваются с расходами месяца; отчёты читают те же таблицы и ничего не пишут.

Решения

Деньги только целыми минорными единицами

В базе, API и браузере суммы это целые числа копеек или центов, без float. Пересчёт валют идёт через big.Int с округлением «от нуля», а округление и разбор сумм покрыты тестами.

Свои SVG-графики вместо библиотеки

Нужны три простых типа графиков, а библиотека добавила бы десятки КБ JS. Свои (около 250 строк) дают контроль над доступностью (role="img", текстовая таблица), темами через CSS-переменные и строгим CSP.

SPA без SSR

Nuxt собирается как nuxt generate с ssr:false: данные сессии нельзя пререндерить. Go отдаёт встроенную web/dist и 200.html для неизвестных путей, а CSP получает хэши встроенных скриптов вместо unsafe-inline для скриптов.

Баланс считается, а не хранится

Баланс счёта = начальный остаток + доходы − расходы − исходящие переводы + входящие переводы. Хранимого поля нет, поэтому оно не может разойтись с операциями.

Компромиссы

  • Курсы ручные и текущие, истории курсов нет: прошлое пересчитывается по сегодняшним курсам. Базовую валюту нельзя сменить, бюджет одинаков каждый месяц, платежи создаются только при открытии приложения.
  • Нет смены и восстановления пароля, SQLite и один процесс, отчёты читают операции целиком в память. В style-src остался unsafe-inline (стили Vue), SSR отключён.

Тесты

Деньги (разбор, формат, округление, пересчёт, round-trip), бюджеты, cron-правила и нагон пропущенных дат, импорт CSV, изоляция пользователей, API, отчёты, демо-данные, TestRealDist (встроенная сборка отдаётся, CSP без unsafe-inline для скриптов); во фронтенде vitest, ESLint и typecheck; go test -race в CI.

Исходники: README проекта finance-tracker: «Возможности», «Архитектура», «Схема БД», «API», «Ограничения»

12 · pixel-wall

Пиксельная стена: общий холст

Совместный холст 256×256 на WebSocket: свой RFC 6455 на stdlib, бинарный протокол, снимок и дельты, снапшот плюс журнал. Главное — согласованность дельт и восстановление после падения, при том что каждый пиксель стоит 4 бита.

  • Go
  • stdlib only
  • WebSocket
  • Canvas 2D
  • Vanilla JS
  • embed
  • Binary protocol
  • Snapshot + journal
  • Docker
  • GitHub Actions
  • 77 тестов
  • 16 маршрутов
  • 2 715 строк Go
Схема компонентов: Пиксельная стена: общий холстСхема проекта «Пиксельная стена: общий холст»: 8 компонентов и 8 связей, нумерация связей совпадает со списком потоков ниже.БраузерCanvas 2D · зум · таймлапсАдминBearer ADMIN_TOKENHTTP + WebSocketOrigin · лимиты · хаб 50 мсЯдро wallодин мьютекс · seqКулдаун и банысессия · IP · rate limitХолст4 бита на пиксельИстория в памятистарый и новый цветДискcanvas.snap + journal.log12345678
Клиент / входСервисХранилище

Потоки данных

  1. 1Браузер открывает WS /ws?sid=…, получает сжатый снимок (0x01: seq, размер, deflate-raw холста) и дальше только дельты (0x02: firstSeq и до 2000 записей); пиксель уходит в 6 байтах (0x10: x, y, цвет).
  2. 2Админ-запросы идут с заголовком Authorization: Bearer; токен сравнивается за постоянное время, неверные попытки лимитируются по IP, а без ADMIN_TOKEN админ-API выключено (403).
  3. 3Сервер проверяет Origin, размеры сообщений (WS до 64 байт, тело до 4 КиБ) и подписанную сессию, затем передаёт установку в wall; хаб рассылает накопленные дельты раз в 50 мс, а медленный клиент отключается и перечитывает снимок.
  4. 4Перед записью wall спрашивает кулдаун: строго один пиксель за COOLDOWN_SEC на сессию и не больше IP_BURST за то же скользящее окно на IP; бан сессии или IP отвечает 403, лишнее 429.
  5. 5Единый мьютекс упорядочивает запись в холст, журнал и очередь дельт; пиксель занимает 4 бита (чётный индекс в старшей тетраде байта), 256×256 = 32 КиБ.
  6. 6Каждая установка добавляется в историю (до MAX_HISTORY записей) со старым и новым цветом; клиент откатывает таймлапс назад от текущего состояния, базовый снимок не нужен.
  7. 7Снапшот canvas.snap пишется раз в SNAPSHOT_SEC во временный файл с crc32, fsync и rename; восстановление берёт снапшот, затем записи журнала с большим seq и обрезает недописанный хвост.
  8. 8journal.log (10 байт на запись: время, x, y, старый и новый цвет, флаги очистки и бота) дописывается при каждом пикселе, fsync раз в 30 секунд; при превышении MAX_HISTORY на 25% журнал переписывается с хвостом.

Решения

WebSocket вместо SSE и POST

Нужен двунаправленный бинарный канал: пиксель в 6 байтах и сразу ok или err, дельты бинарными. SSE это текст (base64 раздувает трафик на треть) и отдельные POST на каждое действие. Запасной путь без сокета есть: POST /api/pixel.

Снимок со seq и дельты с firstSeq

Клиент регистрируется в хабе и под той же блокировкой получает снимок со seq. Дельта несёт firstSeq: клиент пропускает записи с seq не больше своего, а при пропуске переподключается.

В журнале хранится старый цвет

Запись «старый<<4 | новый» позволяет клиенту откатывать историю от текущего состояния, поэтому базовый снимок для таймлапса не нужен, а усечение журнала лишь сокращает глубину истории.

Снапшот и журнал вместо одного файла

Снапшот через временный файл и rename гарантирует целостность, журнал даёт последние изменения; битый снапшот (crc) восстанавливается из журнала, а не стыкующийся журнал начинается заново.

Компромиссы

  • Всё в одном процессе и на одном диске; кулдауны, сессии и rate limit в памяти и сбрасываются при рестарте. Сессия это идентификатор в localStorage, поэтому защита от простых ботов, а не от атаки с множества IP.
  • Журнал без fsync на каждый пиксель (fsync раз в 30 с), журнал целиком в памяти, расширений WebSocket нет, клиенту нужен DecompressionStream. Браузерных тестов в репозитории нет.

Тесты

Кулдаун (сессия, IP, скользящее окно, переполнение таблицы), упаковка и граничные координаты, кодеки и битые сообщения, хранилище (crc, оборванный хвост), восстановление без снапшота и из снапшота с журналом, компактация, откат истории, непрерывность дельт, API (лимиты, Origin, 401/403/429), WebSocket с двумя клиентами и флуд, права админа, бан, нагрузка на 300 клиентов; go test -race в CI.

Исходники: README проекта pixel-wall: «Возможности», «Архитектура», «Бинарный протокол», «Формат хранения», «Ограничения»

13 · gitpulse

GitPulse: аналитика git-репозиториев

CLI для анализа git-репозиториев: разбор потока git log, агрегаторы метрик и три вывода из одной структуры — терминал, самодостаточный HTML и локальный сервер. Главное — не писать свой разбор .git, а доверить историю и переименования самому git.

  • Go
  • stdlib only
  • CLI
  • git
  • html/template
  • SVG
  • ANSI
  • os/exec
  • GitHub Actions
  • Docker
  • 32 тестов
  • 3 маршрутов
  • 2 133 строк Go
Схема компонентов: GitPulse: аналитика git-репозиториевСхема проекта «GitPulse: аналитика git-репозиториев»: 9 компонентов и 8 связей, нумерация связей совпадает со списком потоков ниже.Командная строкачетыре команды и флагиРепозиторий.git · .mailmapgitlog (os/exec)git log -z --numstat -M · потоковый разборanalyze.Aggregatorкоммит за коммитом · переименования · фильтр lock/vendoranalyze.Resultединая структура · JSONtermANSI-таблицы · спарклайныreporthtml/template · inline SVGreport.htmlодин файл или stdoutserve127.0.0.1 · Host-guard12345678
Клиент / входСервисХранилище

Потоки данных

  1. 1Команда разбирает флаги (-rev, -since, -until, -max-commits, -top, -depth, -json, -color) и проверяет репозиторий и ревизию; нет git, не репозиторий или неизвестная ревизия дают код возврата 3, неверные аргументы 2.
  2. 2git читает историю и .mailmap сам; поэтому имена авторов приходят уже с учётом %aN/%aE, а переименования находит git -M, без собственного разбора packfile.
  3. 3gitlog запускает git log -z --numstat -M через os/exec и разбирает поток по одной записи: -z исключает проблемы с кавычками и юникодом в путях, в памяти живёт один коммит; на терминале в stderr рисуется прогресс.
  4. 4Aggregator принимает коммиты от новых к старым и копит метрики; при встрече old => new запоминает переименование (с разрешением цепочек), поэтому более ранние правки old попадают на актуальное имя; lock-файлы, vendor и min-файлы пропускаются, слияния считаются отдельно.
  5. 5Результат — одна структура analyze.Result (JSON при -json); term рисует из неё таблицы с цветами, столбцы и спарклайны, NO_COLOR и запись в канал или файл отключают цвет, на Windows включаются VT и UTF-8.
  6. 6report через html/template собирает HTML с inline SVG-графиками, CSS и небольшим JS переключателя темы; внешних запросов нет, имена и пути экранируются, тема следует за prefers-color-scheme.
  7. 7report пишет один файл (-o файл, по умолчанию gitpulse-report.html) или в stdout с -o -.
  8. 8serve принимает только loopback-адрес в -addr, отвергает запросы с чужим заголовком Host (защита от DNS-rebinding), ставит CSP и no-store, держит результат в кеше (по умолчанию 30 с, /?refresh=1 сбрасывает) и отдаёт /, /api/stats и /healthz.

Решения

git через os/exec, а не разбор .git

Собственный разбор объектов и packfile с дельта-цепочками потребовал бы ещё commit-graph, .mailmap, замены ссылок, shallow- и partial-клонов и обнаружения переименований: тысячи строк с риском ошибиться в метриках. git log --numstat -M решает это надёжно, цена — зависимость от установленного git, которая проверяется явно.

Поток с -z вместо загрузки всего лога

Лог читается по одной записи, память растёт от числа различных файлов и авторов, а не коммитов; -max-commits помечает отчёт как усечённый.

Одна структура Result для всех выводов

Терминал, HTML, JSON и сервер показывают числа из одной analyze.Result, поэтому расходиться им не из чего, а тесты сверяют метрики в одном месте.

Самодостаточный HTML и loopback-сервер

В отчёте есть имена авторов, поэтому он одним файлом без внешних запросов и шрифтов, а serve слушает только 127.0.0.1 и проверяет Host.

Компромиссы

  • Нужен установленный git; время по UTC автора; слияния не анализируются; копирование файлов не отслеживается, переименования видны лишь там, где их находит git -M.
  • Рост языков — оценка «добавлено минус удалено», а bus factor и риск — эвристики по числу строк; память растёт с числом файлов и авторов, в -json есть почты авторов, в HTML нет.

Тесты

Синтетические репозитории через git и метрики на заранее посчитанных данных: слияния, переименования (в том числе правка по новому пути), бинарные файлы, кириллица в путях и именах, lock-файлы, пустой репозиторий, один коммит, лимит коммитов, диапазоны, плохие ревизии; разбор потока (таб в пути, битые записи), golden-тест структуры HTML, самодостаточность и экранирование отчёта, сервер (кеш, Host-guard, методы), коды возврата, JSON CLI, NO_COLOR; go test -race в CI.

Исходники: README проекта gitpulse: «Команды», «Что считается», «Архитектура», «HTML-отчёт», «Тесты», «Ограничения»; код internal/gitlog, internal/analyze, internal/report, internal/term, internal/serve