prod logs
45 subscribers
1 photo
5 links
Заметки разработчика: Go, базы данных, очереди, алгоритмы. Разбираю код и подходы — то, что сам долго искал нормально объяснённым.
Статьи на Хабре.
Download Telegram
Проектирование структуры

Сложный проект меняет не код — он меняет порядок, в котором ты думаешь о коде.

Повезло оказаться в большом проекте Мастерской, в процессе которого я начал иначе смотреть на то, с чего начинается реализация. Раньше казалось: разберусь с архитектурой, напишу репозитории, потом сервисы, потом хендлеры. Логично же? Нет.

Писать код — это про понимание структуры, инфраструктуры и надёжности системы до первой бизнес-строчки.

Конкретно это выглядит так:
1. Скелет — приложение запускается и корректно гасится. Без логики.
2. Конфиги — структуры по сущностям, дефолты, валидация на старте.
3. Зависимости — логгер, кэш, метрики. До бизнес-логики, не во время.

Почему не во время? Потому что вот так начинают:

func New(db *pgxpool.Pool) *App { ... }

// потом логгер
func New(db *pgxpool.Pool, logger *zap.Logger) *App { ... }

// потом кэш, метрики, конфиг...
func New(db *pgxpool.Pool, logger *zap.Logger, cache *redis.Client, cfg *Config) *App { ... }

// хочется рефакторить?


Правильно — решить это один раз и не возвращаться:

type App struct {
db *pgxpool.Pool
cache *redis.Client
logger *zap.Logger
}

func New(cfg *Config) (*App, error) { ... }


4. Первый сквозной путь — один рабочий сценарий end-to-end. Всё остальное заглушки.
5. Скелет тестов — точка входа для интеграционных. Не сами тесты — структура.
6. Рост — новый функционал по уже выстроенному шаблону.

 Горизонтально:
[все репо] → [все сервисы] → [все хендлеры]
↳ долго ждёшь первого рабочего результата

Вертикально:
[скелет] → [конфиги] → [зависимости] → [1 сценарий] → рост
↳ каждый шаг даёт обратную связь
1
Окно возможностей

Постоянное саморазвитие — это хорошо. Но без переключения энергия иссякнет. Что вас заряжает?

В ожидании следующего проекта прошёлся seq scan по составленной roadmap. Через index scan — погрузился в проблемные темы. Несколько недель часы напролёт — и в какой-то момент мозг просто перестаёт брать новое: нужна понятная задача, не просто поток информации.

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

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

Без проекта контекст уменьшается — и его нужно заполнять. Самое время для нового: теория, практика, алгоритмы. И более глубокие посты в канале — есть время подумать, а не только зафиксировать находку.

prod logs — инструмент в обоих режимах. В проекте разгружает, между проектами — углубляет. И в обоих случаях — карта моего пути в IT.
👍21
Паттерн singleflight и delete(g.m, key)

При изучении исходников бросился в глаза один участок кода в doCall:

func (g *Group) doCall(c *call, key string, fn func() (any, error)) {
...
if g.m[key] == c {
delete(g.m, key)
}
...
}


Сразу появился вопрос: мы удаляем ключ — что происходит с данными?

Вспоминаем как работает GC в Go. Он оперирует ссылками: пока на область памяти есть живые ссылки — объект не собирается. delete убирает ключ из мапы, но не объект из памяти.

А ссылки уже взяты раньше — в Do:

func (g *Group) Do(key string, fn func() (any, error)) (v any, err error, shared bool) {
...
if c, ok := g.m[key]; ok {
c.dups++
g.mu.Unlock()
c.wg.Wait()
return c.val, c.err, true
}
...
}


Каждая ожидающая горутина держит локальную копию указателя c. Первый вызов завершается, пишет результат в структуру, doCall удаляет ключ из мапы. Все ожидающие спокойно считывают данные через свои указатели, завершаются — и только тогда GC может собрать объект.

delete(g.m, key) ≠ удаление данных.

func (g *Group) Forget(key string)

Тут и появляется Forget. Если doCall всё равно почистит мапу сам — зачем он?

Forget нужен когда не хочешь ждать завершения текущего вызова. Например, fn зависла — внешний сервис деградировал. Вызываешь Forget — ключ удаляется из мапы прямо сейчас. Следующий Do не встанет в очередь к зависшему вызову, а выполнит в Do до ветки с ожидающими горутинами:

c := new(call)
c.wg.Add(1)
g.m[key] = c


Новый *call, новый wg — независимо от старого. Старые ожидающие горутины ждут свой *call, новые — свой. Когда старый завершится, doCall проверит g.m[key] == c — увидит там уже другой указатель и не тронет новый вызов.
Проверка на идентичность указателя в doCall — не случайная деталь. Защита от ситуации когда старый вызов завершается позже нового. Осознанный трейдофф: дублирование в обмен на latency.

Исходники: https://github.com/golang/sync/blob/master/singleflight/singleflight.go
#go #sync #gogc
«Go: Simple to learn, but hard to master» — так называется первая глава книги 100 Go Mistakes (Teiva Harsanyi).

На примере предыдущего поста про singleflight это хорошо видно. Один паттерн — а за ним: Mutex, WaitGroup, map, указатели, GC. И так в каждом паттерне, в каждом пакете, пока углубляясь не начнёшь ходить по кругу. Ведь всё между собой связано.

Курсы проводят по верхнему слою — дают «знаю». Куда идти дальше и насколько глубоко — зависит только от тебя.

«Знаю» — прочитал про singleflight: дедупликация вызовов по ключу, ждём первого, отдаём всем.

«Понимаю» — могу объяснить что происходит с памятью когда вызывают Forget. Почему GC не тронет объект. Почему в doCall сравнивают указатели, а не ключи.

Второе приходит только через практику + возврат к теории с конкретным вопросом.

На своём опыте с алгоритмами — сначала решал как придётся. Потом пришёл к теории. Акцент сменился — не «решить задачу», а «найти правильный подход».
1
9 июня участвую в открытом mock-интервью от Яндекс Практикума — вебинар про алгоритмические собеседования для джунов.

Тимлид команды инфраструктуры RMS в Яндекс Финтехе, штатный интервьюер по алгоритмам — проверит меня и коллегу из Python-направления в прямом эфире. Хорошая возможность для всех получить советы напрямую от человека, который оценивает такие решения каждый день.

Можно посмотреть на практике как выглядит то самое знакомство с алгоритмами, о котором писал выше.

Бесплатно, 19:00 мск.
Регистрация: https://practicum.yandex.ru/promowebinar/8bdd09d4-596e-4ef9-a16f-1849a9c122e9/registration/
1
Docker для Go сервиса: три уровня

Уровень 1 — просто работает

FROM golang:1.25-alpine
WORKDIR /app
COPY . .
RUN go build -o server ./cmd/server
CMD ["./server"]


Образ ~300MB. Внутри весь Go toolchain, исходники, всё что не нужно в проде.

Уровень 2 — multistage + layer cache

FROM golang:1.25-alpine AS builder
WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN go build -o /app/bin/server ./cmd/server

FROM alpine
RUN apk --no-cache add ca-certificates curl
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup

COPY --from=builder /app/bin/server .
USER appuser:appgroup
ENTRYPOINT ["./server"]


Образ уже меньше — toolchain и исходники в финальный слой не попадают.

go.mod и go.sum копируются отдельно: если зависимости не менялись — go mod download берётся из layer cache. Работает локально и на self-hosted runner. На GitHub Actions с ephemeral runner — нет, там каждый job на чистой машине. Для этого в уровне 3 появляется mount cache.

CA сертификаты нужны если сервис делает HTTPS запросы. Non-root пользователь — минимальная безопасность, которую стоит закладывать сразу.

Уровень 3 — прод

RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build \
-ldflags "-s -w -X main.version=${VERSION} -X main.buildTime=${BUILD_TIME}" \
-trimpath -o /app/bin/server ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/bin/server .
USER nonroot:nonroot
ENTRYPOINT ["./server"]


Два добавления.

Mount cache — живёт между сборками независимо от layer cache. На CI где каждый раз чистая машина, модули берутся из volume, а не скачиваются заново. Трейдофф: 200–500MB на диске, не чистится автоматически.

VERSION и BUILD_TIME — sha коммита и время сборки вшиваются в бинарь через ldflags. В GitHub Actions передаётся так:

build-args: |
VERSION=${{ github.sha }}
BUILD_TIME=${{ github.event.head_commit.timestamp }}


При старте сервера логируешь — всегда знаешь что задеплоено.

distroless вместо alpine — нет shell, нет пакетного менеджера, nonroot встроен. CA сертификаты тоже уже внутри.

И .dockerignore — без него COPY . . тянет .git, тесты, IDE файлы.

А какой 4 уровень? 😉
2
Насмотренность — это первый уровень

Видишь ошибку — обрабатываешь. Это базовый рефлекс. Но есть второй уровень: понимать, с чем именно работаешь.
Два примера из недавнего PR.

NATS MessageBatch

for msg := range msgs.Messages() { ... }
// msgs.Error() — не вызвали

Итерация прошла, всё обработали. Но MessageBatch — не просто итератор. У него есть Error(), который нужно проверить после цикла: батч мог завершиться с ошибкой, и ты об этом не узнаешь. Не думай, что у типа одно поле и один метод — смотри что там есть.

Два момента pgxpool.NewWithConfig
pool, _ := pgxpool.NewWithConfig(context.Background(), config)


Первый — проглоченная ошибка, классика. Ошибку можно не обрабатывать, если ты понимаешь в каких случаях она возникает. И в go, лучше её обработать несмотря ни на что.

Второй интереснее: зачем функции контекст, если Background() и так работает? Потому что при инициализации пул реально устанавливает соединения (MinConns). Контекст позволяет это отменить. Раз параметр есть — значит на него что-то завязано. Не думай, что всё хорошо по умолчанию.
1👍1
Тимлид: роль или название должности

Крайние посты всё чаще о подходах к разработке. Причина — в команде всё чаще спорим: как решать, почему именно так. По обычаю, вопросы такие поднимаю я.

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

Но взгляд на проект у меня уже другой.

Вывод №1. В первом проекте подход был — делать максимально сложно. Redis? Дайте два. Сложный запрос? Почему бы и нет. Чтобы потом ходить рассказывать: я работал с тем и этим.

Сейчас — чем проще, тем лучше. Понимаешь бизнес-задачу, ориентируешься на ожидания заказчика — и не закладываешь функционал на будущее.
«Совершенство достигнуто не тогда, когда нечего добавить, а тогда, когда нечего убрать»
— Антуан де Сент-Экзюпери, «Планета людей»


Вывод №2. Типизируй. В Go есть такая возможность — пользуйся. Когда данные ходят между функциями, структура вместо россыпи параметров ловит ошибки ещё до компиляции. В команде джунов это особенно заметно.

К чему это всё. Чтобы быть лидом, а не называться им — нужна насмотренность, которая за год не набирается физически. А с управленческой стороны — понимать уровень каждого, оценивать темп, держать в голове, что гарантий результата нет.

Тимлид меньше пишет код и больше смотрит на проект целиком. Когнитивную нагрузку от этого не передать.
#тимлид #go #бэкенд #разработка #команда
1
Написал статью на Хабр — про публичный мок-собес по АА-секции от Яндекс Практикума.

Формально это рекламная активность курсов. По факту — опыт, который никакой подготовкой не заменить.

3 недели, ~40 решённых задач с нуля и много пройденных заново, наконец ушёл от решений «в лоб» к пониманию паттернов. А на созвоне дали array длины 6 и k=40 — и код, который прошёл первый кейс, на втором ушёл в панику. Раннего выхода на k > len(nums) просто не было.

Оказалось, что АА-секция проверяет не только знание алгоритмов — она проверяет, видишь ли ты собственные слепые пятна, когда решение уже «работает».

Статья: https://habr.com/ru/articles/1059542/
👍31
Прямой переворот интуиции

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

За последнюю неделю поймал так два бага у себя. Не в момент ревью своего кода — разбирая чужой PR.

Механизм простой: ревью — единственный момент, когда ты смотришь на код не как автор, а как критик. И по инерции применяешь этот режим к своему.

Первый баг: неделей раньше забыл прописать префикс /api/v1 в финальных роутах. Планировал вешать его через группу роутера, но из-за разных обёрток (rate limiter в одних местах, auth в других) решил подождать, пока все ручки устаканятся — и забыл вписать префикс в итоге. Код уже в main, готовимся к деплою.

Второй, там же — увидел в чужом PR, как собирается URL внутри модуля, и вспомнил, что у меня не так:

verifyURL := au.cfg.FrontendURL +
"/verify-email?token=" +
base64.RawURLEncoding.EncodeToString(tokenRaw)


Функции нужен был только токен — сборка url была не её зоной ответственности:

verifyURL := base64.RawURLEncoding.EncodeToString(tokenRaw)


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

Это цена мелкой ошибки — на уровне пары строк. Что бывает, когда без ревью в main проходят решения покрупнее — отдельная история.
#go #codereview #backend #разработка #процессы #инженерка
👍2
Рефакторинг на ровном месте

У нас в main оказалось два хелпера для работы с контекстом — и часть ручек из-за этого не работает.

Логгеру нужен был request_id из middleware — прокидывать его в slog.With без контекста не получится, встал вопрос хелпера.

Параллельно я делал auth middleware — тоже клал в контекст, только role и user_id. Задача с логгером подвисла, моя — нет. Я залил свой пакет первым, не дожидаясь хелпера коллеги, которого ещё не было в main.

Позже коллега смёржил логгер — с собственной реализацией контекста, той же проблемой в зеркале: моего пакета к тому моменту уже не заметил.

Не разгильдяйство — просто у параллельной работы своя цена, и в этот раз её заплатили две одинаковые реализации одного хелпера.

Юнит-тесты — зелёные, каждый написан под свой пакет. Но использование контекста в них разное: там, где код ожидает один формат, получает другой — часть данных не прочитать. E2E-тестов, которые прогнали бы запрос через оба варианта, нет.

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

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

#go #codereview #backend #архитектура
💯1