Golang программирование: уровень Middle

Курс для разработчиков, уже знакомых с основами Go и желающих выйти на уровень Middle. Разберём идиоматичный Go-код, конкурентность, работу с сетью и БД, тестирование, профилирование и проектирование сервисов.

1. Идиомы Go, стиль, ошибки и архитектура пакетов

Идиомы Go, стиль, ошибки и архитектура пакетов

Go ценится за простоту чтения, предсказуемость и единообразие. На уровне Middle от вас ожидают не просто «писать рабочий код», а писать его так, чтобы он:

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

    Идиомы Go: как мыслит экосистема

    Идиомы Go — это не «священные правила», а договорённости, которые делают код одинаково читаемым во всех командах.

    Ясность важнее «умности»

    Ключевой принцип: простое и прямолинейное решение предпочтительнее абстрактного, даже если абстрактное выглядит «красиво».

  • меньше магии
  • меньше скрытых побочных эффектов
  • проще отладка и ревью
  • Практический вывод: прежде чем вводить паттерн, убедитесь, что он действительно снижает сложность, а не повышает.

    Нулевое значение должно быть полезным

    Хороший Go-тип должен быть работоспособен в нулевом состоянии (zero value) или хотя бы не приводить к панике.

    Примеры из стандартной библиотеки:

  • sync.Mutex можно использовать без конструктора
  • bytes.Buffer готов к работе без инициализации
  • Ваши типы тоже стоит проектировать аналогично, когда это разумно.

    Предпочитайте композицию наследованию

    В Go нет классического наследования. Типовая идиома — композиция через встраивание и небольшие интерфейсы.

  • интерфейсы описывают поведение, а не «иерархию типов»
  • встраивание (type X struct { Y }) — способ переиспользования без усложнения дерева типов
  • Полезная ориентация: «принимай интерфейсы, возвращай структуры».

    Интерфейсы должны быть маленькими

    Чем меньше интерфейс, тем проще его реализовать и тестировать.

    Хорошая практика:

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

    Стиль и форматирование: единообразие как инструмент

    gofmt и goimports

    В Go форматирование — это не тема для обсуждений.

  • gofmt приводит код к стандартному виду
  • goimports делает то же самое и дополнительно управляет импортами
  • На практике:

  • форматирование должно быть частью CI
  • разработчик не должен «вручную» выставлять пробелы и выравнивание
  • Ссылки:

  • gofmt (документация)
  • goimports (репозиторий)
  • Именование

    Именование — один из главных источников читаемости.

  • имена короткие, но информативные
  • избегайте data, info, manager, если они ничего не объясняют
  • однотипные сущности называйте одинаково во всём проекте
  • Распространённые соглашения:

  • ID, HTTP, URL — аббревиатуры в CamelCase обычно пишутся как UserID, HTTPServer
  • геттеры обычно не называют GetX, если это обычное поле/метод: user.Name() лучше, чем user.GetName()
  • Официальные рекомендации:

  • Effective Go
  • Go Code Review Comments
  • Комментарии: зачем и где

    Комментарий должен отвечать на вопрос «почему так?», а не «что делает код?».

  • «что» видно из кода, если код написан ясно
  • «почему» часто невозможно восстановить без контекста
  • Особое правило: экспортируемые сущности должны иметь комментарий в стиле GoDoc.

    Пример:

    Линтеры и статический анализ

    На уровне Middle ожидается, что вы используете инструменты, которые предотвращают ошибки ещё до запуска.

  • go vet для типичных багов
  • staticcheck для более широкого спектра проблем
  • агрегаторы вроде golangci-lint — удобно для CI
  • Ссылки:

  • go vet
  • Staticcheck
  • golangci-lint
  • Ошибки в Go: идиоматическая обработка

    Ошибка — это значение

    В Go ошибка — это обычное значение типа error. Это означает:

  • ошибки возвращают, а не бросают
  • поток управления остаётся явным
  • Классический шаблон:

    Не игнорируйте ошибки

    Игнорирование ошибок (_ = err) допустимо только если вы осознанно решили, что ошибка неважна, и это документировано/очевидно.

    Если ошибка действительно неважна, лучше явно оформить это решением в коде (например, логирование с пояснением).

    Контекст в ошибках: добавляйте смысл

    Одна и та же ошибка нижнего уровня может происходить в разных местах. Оборачивайте её с контекстом:

    Так вы получаете цепочку причин, полезную в логах и мониторинге.

    Обёртка ошибок и сравнение: errors.Is и errors.As

    С Go 1.13 рекомендуется:

  • оборачивать ошибки через %w
  • проверять ошибки через errors.Is
  • извлекать типизированные ошибки через errors.As
  • Пример:

    Ссылки:

  • errors (package)
  • fmt.Errorf (package fmt)
  • Сентинельные ошибки: когда нужны и когда вредят

    Сентинельная ошибка — это ошибка, которую сравнивают по идентичности (errors.Is или прямым сравнением), например io.EOF.

    Используйте сентинелы, когда:

  • у вас небольшой набор стабильных «категорий» ошибок
  • вызывающему коду важно ветвление по ним
  • Избегайте, когда:

  • ошибка содержит полезные данные (например, какой именно параметр неверен)
  • набор ошибок будет расширяться и начнутся сложные проверки
  • Типизированные ошибки (custom error types)

    Когда нужно передать детали (например, поле, значение, код), используйте тип ошибки.

    Выигрыш: вызывающий код может принимать решение на основе структурированных данных, а не парсинга строк.

    Где логировать ошибку

    Важно: ошибка должна быть залогирована ровно один раз на правильном уровне.

    Практическое правило:

  • нижний слой возвращает ошибку (возможно, оборачивает)
  • верхний слой (граница приложения: cmd, HTTP handler, worker) логирует и принимает решение (ретраи, статус-код)
  • Если логировать на каждом уровне, вы получите шум и дубликаты.

    Архитектура пакетов: как организовать проект

    Цели хорошей архитектуры пакетов

  • отсутствие циклических зависимостей
  • минимальная связность между слоями
  • ясные границы ответственности
  • простая тестируемость
  • Что такое пакет в Go

    Пакет — это единица компиляции и публичного API.

  • всё, что экспортируется (с заглавной буквы), становится частью контракта
  • менять экспортируемые имена — почти всегда breaking change для пользователей пакета
  • Отсюда следует вывод: экспортируйте только то, что действительно нужно.

    internal: как скрывать реализацию

    Директория internal — механизм языка, который запрещает импорт пакетов извне родительского дерева.

    Типовой сценарий:

  • вы хотите разделить код на пакеты, но не хотите делать их публичным API
  • Пример структуры:

  • cmd/myapp может импортировать myapp/internal/app
  • внешний проект не сможет импортировать myapp/internal/app
  • Ссылка:

  • Go blog: Internal packages
  • cmd: точки входа

    cmd/ обычно содержит бинарники (один каталог — один main пакет).

  • весь «запускной» код: конфиг, wiring зависимостей, создание логгера, запуск сервера
  • бизнес-логика не должна жить в cmd, иначе её сложнее переиспользовать и тестировать
  • pkg: публичные библиотеки (если они действительно нужны)

    Если ваш репозиторий — приложение, pkg/ часто не нужен.

    pkg/ оправдан, когда:

  • вы сознательно предоставляете внешним пользователям библиотечный код
  • вы готовы поддерживать совместимость и API
  • Популярный ориентир (не официальный стандарт, но часто используемый шаблон):

  • Standard Go Project Layout (GitHub)
  • Разделение по слоям: домен, use cases, инфраструктура

    На практике удобно разделять код по ответственности:

  • доменная логика (правила предметной области)
  • прикладные сценарии (use cases, сервисы)
  • инфраструктура (БД, HTTP, брокеры, файловая система)
  • Ключевая мысль: зависимости должны идти внутрь, к более стабильным слоям.

    !Диаграмма показывает, что внешние слои зависят от внутренних, а не наоборот

    Избегайте циклических зависимостей

    Циклы обычно появляются, когда пакеты смешивают ответственность.

    Приёмы борьбы:

  • ввести интерфейс в пакете-потребителе
  • вынести общие типы в отдельный пакет, но не превращать его в «dump-пакет»
  • разделить пакет на два: foo (API) и foo/internal (реализация)
  • Не делайте «utils» без границ

    Пакет utils или common почти всегда превращается в свалку.

    Вместо этого:

  • группируйте по смыслу: clock, retry, httputil (если это реально утилиты вокруг HTTP)
  • держите утилиты близко к месту использования, пока не появится явная потребность переиспользования
  • Практические рекомендации для Middle-разработчика

  • используйте gofmt всегда, в идеале через pre-commit или IDE
  • добавляйте контекст к ошибкам на границе смыслового уровня (service, use case)
  • используйте %w и errors.Is/As, а не сравнение строк
  • логируйте ошибки один раз на границе приложения
  • проектируйте пакеты так, чтобы internal скрывал реализацию, а публичный API был минимальным
  • избегайте больших интерфейсов и «универсальных» пакетов
  • Материалы для углубления

  • Effective Go
  • Go Code Review Comments
  • Package errors
  • Go blog: Internal packages
  • Standard Go Project Layout (GitHub)
  • 2. Конкурентность: goroutine, каналы, контекст и паттерны

    Конкурентность: goroutine, каналы, контекст и паттерны

    Конкурентность в Go — это умение структурировать параллельную работу так, чтобы код был предсказуемым, безопасным и легко сопровождаемым. На уровне Middle от вас ожидают, что вы:

  • понимаете, как и почему возникают блокировки и утечки goroutine
  • умеете выбирать между каналами и sync-примитивами
  • используете context для отмены, таймаутов и ограничения жизненного цикла операций
  • применяете стандартные конкурентные паттерны и умеете их тестировать
  • Связь с предыдущей статьёй курса:

  • идиомы: простота и ясные границы ответственности особенно важны в конкурентном коде
  • ошибки: конкурентные сценарии требуют аккуратной агрегации ошибок и единого места логирования
  • архитектура: конкурентность легче контролировать, когда слои разделены, а зависимости направлены внутрь
  • Что Go называет конкурентностью

    Конкурентность — это организация выполнения нескольких задач так, чтобы они могли продвигаться независимо. Она не равна параллельности.

  • конкурентность: задачи структурированы так, что могут чередоваться
  • параллельность: задачи реально выполняются одновременно на разных ядрах
  • Go даёт вам простые примитивы для конкурентности: goroutine и каналы, плюс низкоуровневые инструменты синхронизации в пакете sync.

    Материалы:

  • Effective Go: Concurrency
  • Goroutine

    Goroutine — это лёгкий поток выполнения, управляемый рантаймом Go.

    Запуск:

    Ключевые свойства:

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

    Типичные причины:

  • goroutine ждёт чтения из канала, но никто больше не пишет
  • goroutine ждёт записи в канал, но никто больше не читает
  • goroutine ждёт WaitGroup, который никогда не дойдёт до нуля
  • goroutine крутит бесконечный цикл без проверки отмены
  • Идиоматическое правило:

  • каждая goroutine должна иметь условие завершения
  • Обычно это одно из:

  • закрытие канала done
  • отмена context
  • завершение входного канала в пайплайне
  • Ожидание завершения: sync.WaitGroup

    sync.WaitGroup помогает дождаться группы goroutine.

    Важно:

  • Add делайте до запуска goroutine, иначе возможна гонка
  • всегда используйте defer wg.Done(), чтобы не забыть при раннем return
  • Документация:

  • sync
  • Гонки данных и выбор инструмента синхронизации

    Гонка данных возникает, когда:

  • две goroutine обращаются к одной памяти одновременно
  • хотя бы одна из них пишет
  • нет синхронизации
  • Это приводит к непредсказуемым багам.

    Практики уровня Middle:

  • запускайте тесты с детектором гонок
  • выбирайте один «владелец состояния» и синхронизацию вокруг него
  • Инструмент:

  • Race Detector
  • Команда:

    Каналы или sync.Mutex

    Go не заставляет выбирать только один подход, но важно понимать компромиссы.

    | Инструмент | Когда уместен | Типичная ошибка | |---|---|---| | Каналы | передача событий/работы между goroutine, пайплайны | блокировки из-за неверного закрытия/буферизации | | sync.Mutex | защита общего состояния | забытый Unlock, слишком широкая критическая секция | | sync.RWMutex | много чтения, мало записи | ожидания писателей из-за частых читателей | | sync/atomic | простые счетчики и флаги | попытка сделать сложную логику на атомиках |

    Главная идиома: делайте код таким, чтобы было понятно, кто владеет состоянием и кто отвечает за завершение.

    Каналы

    Канал — типизированная очередь для обмена данными между goroutine.

  • отправка: ch <- v
  • получение: v := <-ch
  • Небуферизированные и буферизированные

  • небуферизированный канал синхронизирует отправителя и получателя: отправка блокирует до получения
  • буферизированный канал позволяет отправителю положить до cap(ch) элементов без ожидания
  • Закрытие канала

    Закрытие означает: значений больше не будет.

  • закрывать должен тот, кто производит значения
  • читать можно до исчерпания через range
  • Чтение из закрытого канала:

  • возвращает нулевое значение типа и ok=false
  • Ошибки, которые часто встречаются:

  • закрыть канал дважды: паника
  • отправить в закрытый канал: паника
  • пытаться закрывать канал со стороны потребителя: обычно архитектурная ошибка
  • Направленные каналы

    Ограничивайте API, чтобы по сигнатуре было видно намерение.

    Это хорошо сочетается с принципами из первой статьи: минимальный API и ясные границы.

    select

    select ждёт готовности одной из операций с каналами.

    Особенности:

  • если готовы несколько веток, выбирается псевдослучайная
  • default делает select неблокирующим
  • nil-канал в ветке никогда не готов, это иногда используют для включения и выключения веток
  • Таймауты и осторожность с time.After

    Частый шаблон:

    Это корректно по смыслу, но в высоконагруженном коде time.After может создавать лишние таймеры.

    Более управляемый вариант — time.NewTimer:

    Документация:

  • time
  • context: отмена, дедлайны и границы ответственности

    context — стандартный способ передавать:

  • сигнал отмены
  • дедлайн или таймаут
  • ограниченные по смыслу значения уровня запроса
  • Важно: context не предназначен для передачи бизнес-параметров и конфигурации.

    Документация и статья:

  • context
  • Go blog: Context
  • Правила использования context

    Идиоматические правила:

  • ctx обычно первый аргумент: func Do(ctx context.Context, ...)
  • не храните context внутри структуры надолго, чаще передавайте по вызовам
  • отмену должен вызывать тот, кто создал контекст через WithCancel или WithTimeout
  • Пример таймаута:

    Связь с ошибками из первой статьи:

  • при отмене возвращайте ctx.Err() или оборачивайте его контекстом через %w
  • логируйте ошибку отмены один раз на границе приложения, например в HTTP handler
  • ctx.Done() в goroutine

    ctx.Done() — канал, который закрывается при отмене.

    Базовые конкурентные паттерны

    Паттерны в Go обычно строятся из очень небольшого набора деталей:

  • goroutine
  • канал
  • select
  • context
  • WaitGroup или errgroup
  • !Пайплайн с fan-out/fan-in и общей отменой через context

    Пайплайн

    Пайплайн — последовательность стадий, где каждая стадия читает из входного канала и пишет в выходной.

    Идея:

  • каждая стадия делает одну понятную трансформацию
  • завершение распространяется через закрытие каналов и отмену context
  • Упрощённый пример стадии:

    Статья:

  • Go blog: Pipelines and cancellation
  • Fan-out и fan-in

  • fan-out: распределение входных задач на несколько worker’ов
  • fan-in: сбор результатов из нескольких источников в один канал
  • Fan-out обычно реализуется так:

  • один канал jobs
  • несколько goroutine читают из него конкурентно
  • Fan-in удобно делать через WaitGroup и закрытие results после завершения всех worker’ов.

    Worker pool

    Worker pool ограничивает параллелизм и стабилизирует нагрузку на CPU, сеть и БД.

    Ключевые детали:

  • workers завершаются по закрытию jobCh или по ctx.Done()
  • resCh закрывается после wg.Wait()
  • Семафор через буферизированный канал

    Иногда нужен не пул, а просто ограничение количества одновременных операций.

    Отмена по первой ошибке: errgroup

    errgroup помогает:

  • запускать группу goroutine
  • отменять общий контекст при первой ошибке
  • дождаться завершения всех задач
  • Документация:

  • errgroup
  • Связь с обработкой ошибок:

  • errgroup возвращает одну ошибку, поэтому для множественных ошибок нужна отдельная стратегия
  • оборачивайте ошибку контекстом на границе смыслового уровня, но не логируйте на каждом уровне
  • Graceful shutdown

    Для сервисов важен корректный останов:

  • перестать принимать новые запросы
  • завершить текущие с ограничением по времени
  • закрыть ресурсы
  • Базовая схема:

  • ловим SIGINT или SIGTERM
  • отменяем корневой context
  • вызываем остановку серверов и фоновых задач
  • Документация:

  • os/signal
  • net/http: Server.Shutdown
  • Частые ошибки и как их избегать

  • забыть закрыть выходной канал в стадии пайплайна и повесить потребителя
  • закрывать канал не тем владельцем и получить панику
  • делать отправку в канал без учёта отмены контекста и повесить goroutine
  • держать Mutex слишком долго и создавать «пробки»
  • логировать ошибки на каждом уровне и получать дубликаты вместо полезной цепочки причин
  • Практический чеклист перед ревью конкурентного кода:

  • понятно, кто закрывает каждый канал
  • у каждой goroutine есть путь завершения
  • отмена через context доведена до всех потенциально блокирующих операций
  • есть go test -race в CI или хотя бы локально перед PR
  • Итоги

  • goroutine — дешёвый, но опасный ресурс: без продуманного завершения легко получить утечки
  • каналы — про координацию и передачу данных, sync — про защиту общей памяти
  • context задаёт жизненный цикл операций и должен проходить через границы слоёв
  • основные паттерны уровня Middle: пайплайны, worker pool, fan-in/fan-out, семафор, errgroup, graceful shutdown
  • 3. Работа с памятью и производительностью: профилирование и оптимизация

    Работа с памятью и производительностью: профилирование и оптимизация

    На уровне Middle производительность в Go — это не «микрооптимизации ради красоты», а умение:

  • находить реальные узкие места по данным профилирования
  • понимать, как аллокации и GC влияют на задержки и пропускную способность
  • делать изменения, которые улучшают метрики и не ломают читаемость кода
  • Связь с предыдущими статьями курса:

  • из идиом и ошибок важно правило «ясность важнее умности»: оптимизация должна быть обоснована измерениями и не превращать код в головоломку
  • из конкурентности важно понимать: высокие аллокации, блокировки и утечки goroutine часто маскируются под «медленный код», а правильный инструмент профилирования покажет реальную причину
  • Базовые понятия: что именно «тормозит» Go-программу

    Производительность почти всегда упирается в один или несколько факторов:

  • CPU: «чистая» вычислительная нагрузка, неэффективные алгоритмы, лишние преобразования
  • память: частые аллокации, давление на сборщик мусора, рост heap
  • синхронизация: Mutex, ожидание каналов, contention
  • I/O: сеть, диски, внешние сервисы
  • Практический вывод: пока вы не измерили, вы не знаете, что оптимизировать.

    Как работает память в Go на прикладном уровне

    Stack и heap: почему аллокации важны

    В Go у goroutine есть стек (stack), который растёт и сжимается автоматически. Данные могут размещаться:

  • на stack: обычно дешевле, освобождается автоматически при выходе из функции
  • на heap: управляется сборщиком мусора (GC), то есть освобождение происходит позже и требует работы рантайма
  • Переход значений на heap увеличивает:

  • число аллокаций
  • объём работы GC
  • вероятность всплесков задержек (latency) при высокой нагрузке
  • Escape analysis: почему значение «убежало» на heap

    Компилятор пытается оставить значения на stack, но иногда вынужден размещать их на heap, если:

  • возвращается указатель на локальную переменную
  • значение захватывается замыканием (closure)
  • интерфейсные преобразования заставляют хранить данные иначе
  • компилятор не может доказать, что значение живёт меньше вызывающей стороны
  • Посмотреть escape analysis можно так:

    Важно: вывод может быть шумным, но как минимум вы увидите, какие места стабильно дают аллокации.

    GC: что важно знать Middle-разработчику

    Go использует конкурентный сборщик мусора. На практике это означает:

  • GC старается работать параллельно с приложением, но полностью бесплатным он не бывает
  • большое количество короткоживущих объектов на heap почти всегда ухудшает tail latency
  • оптимизация аллокаций часто даёт больше эффекта, чем оптимизация арифметики
  • Полезные ссылки:

  • Diagnostics
  • runtime package
  • Главный принцип: сначала измеряем

    Оптимизационный цикл в Go обычно выглядит так:

  • Сформулировать метрику: CPU, p95/p99 latency, RPS, память, количество аллокаций
  • Сделать воспроизводимый сценарий: бенчмарк, нагрузочный тест, локальный прогон
  • Снять профили: CPU, heap, allocs, goroutine, mutex, block
  • Понять первопричину
  • Внести изменение
  • Снова измерить и сравнить
  • !Цикл оптимизации: измерение → профиль → анализ → изменение → повторное измерение

    Бенчмарки: фундамент для воспроизводимости

    Бенчмарки в Go пишутся в пакете testing.

    Ссылка:

  • testing package
  • Минимальный пример:

    Практические правила бенчмарков:

  • не делайте аллокации и подготовку данных внутри измеряемой части, если это не цель бенчмарка
  • используйте b.ReportAllocs(), чтобы видеть аллокации
  • избегайте случайности и внешних зависимостей (сеть, диск), либо чётко изолируйте их
  • запускайте с -benchtime и -count, чтобы уменьшить шум
  • Примеры команд:

    Профилирование через pprof

    В Go есть два стандартных пути:

  • профили из тестов и бенчмарков (go test -cpuprofile, -memprofile)
  • профили из живого процесса через HTTP (net/http/pprof)
  • Ссылки:

  • Profiling Go Programs
  • net/http/pprof package
  • runtime/pprof package
  • CPU-профиль

    CPU-профиль отвечает на вопрос: где процессорное время?

    Снять из бенчмарка:

    В pprof полезные команды:

  • top: самые «дорогие» функции
  • list <func>: аннотированный исходник с горячими строками
  • web: граф вызовов (нужен Graphviz)
  • Цель анализа: найти место, которое даёт основную долю времени, и понять почему.

    Heap и allocs: память бывает «в моменте» и «в сумме»

    В pprof по памяти важны два разных взгляда:

  • heap (inuse): сколько памяти удерживается сейчас
  • allocs (alloc_space/alloc_objects): сколько памяти было выделено суммарно
  • Снять мемпрофиль из тестов:

    Интерпретация:

  • если -alloc_space огромный, но -inuse_space небольшой, значит много краткоживущих аллокаций: вероятный кандидат на оптимизацию для latency
  • если -inuse_space растёт и не падает, возможно, вы удерживаете ссылки и мешаете GC освобождать память (логические утечки)
  • Профиль goroutine: утечки и зависания

    Профиль goroutine помогает увидеть:

  • сколько goroutine живёт
  • где они блокируются
  • какие стеки повторяются массово
  • В сервисах удобно включать net/http/pprof и смотреть /debug/pprof/goroutine?debug=2.

    Минимальное подключение (только для внутреннего порта и защищённого окружения):

    Важно: не открывайте pprof наружу без аутентификации и сетевых ограничений.

    Mutex и block профили: когда проблема в синхронизации

    Если программа «вроде бы CPU почти не ест», но медленная, часто причина в ожиданиях.

  • mutex profile показывает contention на sync.Mutex
  • block profile показывает блокировки на каналах, select, time.Sleep и прочих ожиданиях
  • Подключение профилирования ожиданий обычно требует включения через runtime.

    Ссылка:

  • runtime package
  • Типовые источники аллокаций и как их уменьшать

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

    Конкатенация строк и преобразования string[]byte

    Частый антипаттерн:

    Лучше использовать strings.Builder для строк или bytes.Buffer для байтов.

    Ссылки:

  • strings.Builder
  • bytes.Buffer
  • Пример:

    Слайсы: рост capacity и лишние копирования

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

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

    Плохо (может удерживать большой backing array):

    Если нужно отделиться по памяти, сделайте копию:

    map: рост, хеширование и предвыделение

    Если вы знаете примерное число элементов, задайте capacity:

    Это уменьшает число внутренних ростов и пересозданий таблиц.

    Интерфейсы и аллокации

    Иногда аллокации появляются из-за:

  • упаковки значения в интерфейс и потери возможности разместить на stack
  • возврата интерфейса вместо конкретного типа в горячем участке
  • Идиома из первой статьи «принимай интерфейсы, возвращай структуры» часто помогает держать производительность и ясный API одновременно.

    sync.Pool: переиспользование, но с оговорками

    sync.Pool полезен для снижения давления на GC в сценариях с большим числом временных объектов, например буферов.

    Ссылка:

  • sync.Pool
  • Важно понимать поведение:

  • GC может очищать pool, поэтому это не «кэш навсегда»
  • pool эффективен, когда объект дорогой в аллокации и часто используется кратковременно
  • неправильное использование может ухудшить ситуацию из-за усложнения и скрытых зависимостей
  • Оптимизация конкурентного кода: производительность и корректность вместе

    Из статьи про конкурентность важно перенести в оптимизацию две мысли:

  • у каждой goroutine должен быть путь завершения, иначе «рост памяти» может быть следствием утечки goroutine
  • каналы и Mutex дают разные профили проблем: каналы часто видны в block профиле, Mutex — в mutex профиле
  • Типичные причины деградации:

  • fan-out без ограничения параллелизма приводит к всплеску аллокаций и переключений контекста
  • буферизация канала выбрана «на глаз» и создаёт либо блокировки, либо рост памяти
  • общий Mutex вокруг «толстой» работы создаёт очередь и рост p99
  • Стратегия изменений: как не сломать код ради скорости

    Идиоматический подход к оптимизации в Go:

  • начинайте с улучшения алгоритма и структуры данных, а не с микротрюков
  • фиксируйте результат бенчмарками и профилями в PR
  • избегайте оптимизаций, которые ухудшают читаемость, если выигрыш несущественный
  • добавляйте контекст к ошибкам и логируйте их на границе приложения, чтобы «оптимизация» не прятала проблемы наблюдаемости
  • Мини-чеклист перед тем как сказать «мы оптимизировали»

  • есть воспроизводимый бенчмарк или нагрузочный сценарий
  • сняты профили до и после
  • улучшение видно по выбранной метрике (время, аллокации, heap, p95/p99)
  • не ухудшилась корректность, не появились гонки (проверено go test -race)
  • код остался поддерживаемым и не нарушает архитектурные границы пакетов
  • Материалы для углубления

  • Profiling Go Programs
  • Diagnostics
  • testing package
  • net/http/pprof package
  • runtime/pprof package
  • sync.Pool
  • 4. Тестирование в Go: unit, integration, мокирование и покрытие

    Тестирование в Go: unit, integration, мокирование и покрытие

    Тестирование в Go на уровне Middle — это не только умение написать _test.go, но и способность построить надёжный контур качества* вокруг кода:

  • быстрые unit-тесты, которые легко читать и поддерживать
  • интеграционные тесты, которые ловят ошибки на границах (БД, HTTP, файловая система)
  • осознанное мокирование без «тестов, повторяющих реализацию»
  • адекватное использование покрытия (coverage) как сигнала, а не самоцели
  • Связь с предыдущими статьями курса:

  • из статьи про идиомы и ошибки берём ясность, маленькие интерфейсы и корректное оборачивание ошибок — всё это напрямую влияет на тестируемость
  • из статьи про конкурентность берём go test -race, аккуратность с t.Parallel() и проверку утечек goroutine
  • из статьи про производительность берём бенчмарки (Benchmark*) как часть тестового контура, когда оптимизации должны подтверждаться измерениями
  • База: как устроены тесты в Go

    В Go тесты — это обычный код, который компилируется вместе с пакетом. Основные элементы:

  • файл something_test.go
  • функции func TestXxx(t *testing.T) для тестов
  • функции func BenchmarkXxx(b *testing.B) для бенчмарков
  • функции func FuzzXxx(f *testing.F) для фаззинга
  • Официальная документация:

  • Пакет testing
  • Команда go test
  • Полезные команды:

    Unit-тесты: быстрые проверки поведения

    Unit-тест проверяет небольшую единицу поведения: функцию или метод, обычно без реальных внешних зависимостей (сеть, БД).

    Признаки хорошего unit-теста:

  • быстро выполняется (миллисекунды)
  • стабилен (не флапает)
  • проверяет наблюдаемое поведение, а не внутреннюю реализацию
  • в сообщениях об ошибке быстро видно, что сломалось
  • Table-driven tests

    Самый распространённый стиль в Go — табличные тесты (table-driven). Они хорошо масштабируются и читаются.

    Ключевые детали:

  • t.Run даёт понятные имена под-тестам
  • tt := tt защищает от захвата переменной цикла в замыкание
  • t.Parallel() ускоряет тесты, но требует аккуратности с общими ресурсами
  • Вспомогательные функции и t.Helper()

    Если вы делаете helper-функции для тестов, помечайте их t.Helper(), чтобы ошибки указывали на строку вызова в тесте, а не внутри helper’а.

    Что (не) стоит проверять в unit-тестах

    Хорошие unit-тесты проверяют:

  • возвращаемые значения
  • возвращаемые ошибки (включая errors.Is/As для типизированных и обёрнутых ошибок)
  • эффекты на переданных структурах (если это часть контракта)
  • Обычно не стоит проверять:

  • точные тексты ошибок, если это не часть API (лучше errors.Is/As)
  • внутренние вызовы и порядок операций, если это не наблюдаемое поведение
  • Ссылка по работе с ошибками:

  • Пакет errors
  • Параллельные тесты, гонки и стабильность

    t.Parallel()

    t.Parallel() полезен, но может сделать тесты нестабильными, если они делят ресурсы:

  • один и тот же файл/директорию
  • общие глобальные переменные
  • общий порт
  • один и тот же singleton в памяти
  • Практики, которые помогают:

  • избегать глобального состояния в тестах
  • использовать t.TempDir() для файлов
  • выделять зависимости через интерфейсы и передавать их в конструкторы
  • Документация:

  • Метод T.Parallel
  • Метод T.TempDir
  • Детектор гонок

    Если код связан с конкурентностью, go test -race — обязательная привычка. Он находит гонки данных, которые могут проявляться редко и «случайно».

    Ссылка:

  • Статья про race detector
  • -shuffle=on

    -shuffle=on рандомизирует порядок тестов и помогает находить скрытые зависимости (например, тест А меняет глобальное состояние, и тест Б начинает зависеть от порядка).

    Интеграционные тесты: проверка границ системы

    Интеграционный тест проверяет взаимодействие нескольких компонентов вместе: код + сериализация + сеть/БД/файловая система.

    Важно: интеграционные тесты обычно медленнее и сложнее в поддержке, поэтому их должно быть меньше, чем unit-тестов.

    !Пирамида тестов помогает выбрать правильный тип теста и не «утонуть» в медленных проверках

    Где проводить границу

    Практическая граница между unit и integration часто проходит по внешним зависимостям:

  • unit: мок/фейк репозитория, фейк часов, фейк HTTP-клиента
  • integration: реальный SQL-драйвер и транзакции, реальный HTTP сервер через httptest, реальная работа с файлами
  • net/http/httptest для HTTP

    Стандартная библиотека позволяет поднимать тестовый HTTP-сервер без реальной сети.

    Документация:

  • Пакет net/http/httptest
  • Временные директории и тестовые данные

    Для файлов:

  • используйте t.TempDir()
  • не пишите в рабочую директорию проекта
  • Для данных:

  • держите фикстуры рядом в testdata/ (эта директория игнорируется go test как пакет)
  • Интеграционные тесты и context

    Если ваш код принимает context, в интеграционных тестах важно проверять:

  • таймауты не «висят» бесконечно
  • отмена корректно прерывает ожидания
  • ошибки отмены передаются наружу как ctx.Err() или как обёртка с %w
  • Документация:

  • Пакет context
  • Мокирование: как не превратить тесты в имитацию

    Термины

    В тестах часто встречаются замены зависимостей:

  • mock: объект с ожиданиями (например, «метод должен быть вызван 1 раз с такими аргументами»)
  • stub: объект, который возвращает заранее заданные ответы
  • fake: упрощённая, но рабочая реализация (например, in-memory репозиторий)
  • На практике в Go часто выгоднее stub/fake, чем тяжёлые mocks с жёсткими ожиданиями.

    Мокирование через маленькие интерфейсы

    Идиома из первой статьи курса напрямую улучшает тестируемость:

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

    Теперь для unit-теста можно сделать простой stub без фреймворков:

    Когда нужны mock-фреймворки

    Фреймворки уместны, если:

  • важны ожидания по вызовам (например, «не должно быть второго запроса»)
  • интерфейс большой, и ручной stub слишком дорог
  • у вас много тестов, и генерация экономит время
  • Популярный вариант в Go:

  • Проект GoMock
  • Риск мокирования:

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

    Покрытие (coverage): как использовать правильно

    Что показывает coverage

    go test -cover показывает покрытие операторов (statement coverage): какая доля операторов исполнилась во время тестов.

    Документация:

  • Команда go tool cover
  • Почему высокий coverage не гарантирует качество

    Coverage не говорит:

  • проверяли ли вы правильные утверждения
  • покрыты ли негативные сценарии (ошибки, таймауты)
  • есть ли гонки
  • Coverage хорошо использовать как:

  • сигнал «в этом пакете тестов почти нет»
  • способ быстро увидеть непокрытые ветки в критичном коде
  • Практика для команды

    Хорошие командные договорённости:

  • не «дожимать проценты» тестами без смысла
  • требовать тесты на новые ветки логики (особенно обработку ошибок)
  • при необходимости ставить порог покрытия на изменённый код в CI, но не превращать это в KPI
  • Бенчмарки и тестирование производительности

    Если вы оптимизируете код (третья статья курса), фиксируйте результат бенчмарками:

  • Benchmark* для сравнения «до/после»
  • -benchmem для аллокаций
  • профилирование через -cpuprofile и -memprofile, когда надо понять причину
  • Ссылки:

  • Пакет testing (бенчмарки)
  • Статья Profiling Go Programs
  • Чеклист Middle-разработчика: тестовый контур

  • unit-тесты быстрые, читаемые, без внешних зависимостей
  • интеграционные тесты проверяют реальные границы (HTTP/БД/файлы), но их немного
  • зависимости вводятся через маленькие интерфейсы, объявленные рядом с потребителем
  • ошибки проверяются через errors.Is/As, а не через сравнение строк
  • конкурентный код проверяется go test -race
  • t.Parallel() используется осознанно, без общих ресурсов
  • coverage используется как сигнал, а не как цель
  • Материалы для углубления

  • Пакет testing
  • Команда go test
  • Пакет net/http/httptest
  • Статья про race detector
  • Команда go tool cover
  • Проект GoMock
  • 5. Сетевое программирование: HTTP, gRPC, middleware и таймауты

    Сетевое программирование: HTTP, gRPC, middleware и таймауты

    Сетевое программирование в Go на уровне Middle — это не только умение поднять сервер, но и способность сделать взаимодействие устойчивым: с понятными границами ответственности, корректной обработкой ошибок, отменой через context, таймаутами, наблюдаемостью и тестируемостью.

    Связь с предыдущими статьями курса:

  • из статьи про идиомы, ошибки и пакеты берём оборачивание ошибок, минимальные интерфейсы и правило «логируем один раз на границе»
  • из статьи про конкурентность и context берём отмену, дедлайны и предотвращение утечек goroutine
  • из статьи про память и производительность берём понимание аллокаций, профилирование и важность повторного использования соединений
  • из статьи про тестирование берём httptest, проверку отмены/таймаутов и интеграционные тесты границ
  • Модель сетевого вызова в Go

    Любой сетевой запрос в продакшене должен отвечать на четыре вопроса:

  • кто владеет жизненным циклом операции (кто создаёт context и кто отменяет)
  • какой у операции бюджет времени (таймаут, дедлайн, каскад таймаутов по зависимостям)
  • как ошибки превращаются в контракт (HTTP статус-коды, gRPC status codes, стабильные категории ошибок)
  • как избежать деградации (повторное использование соединений, лимиты, backpressure)
  • !Диаграмма показывает, как context и таймауты проходят через слои приложения

    HTTP сервер: handler, контекст и корректные ответы

    Базовые строительные блоки

    В стандартной библиотеке HTTP сервер строится вокруг http.Handler.

  • интерфейс: ServeHTTP(http.ResponseWriter, *http.Request)
  • маршрутизация: чаще всего http.ServeMux или внешний роутер, но базовые принципы одинаковые
  • контекст: у *http.Request есть Context(), который отменяется при завершении запроса или разрыве соединения
  • Документация:

  • net/http
  • context
  • Таймауты на сервере

    На стороне сервера таймауты задаются на уровне http.Server. Важно: это защита ресурса сервера, а не замена контекстам в бизнес-логике.

    Типовые настройки:

  • ReadHeaderTimeout защищает от медленных клиентов при чтении заголовков
  • ReadTimeout ограничивает время чтения всего запроса
  • WriteTimeout ограничивает время записи ответа
  • IdleTimeout ограничивает время keep-alive соединения
  • Пример настройки:

    Ссылка:

  • http.Server
  • Контекст запроса и отмена работы

    Правило: любая потенциально блокирующая операция внутри handler должна учитывать r.Context().

    Пример: вызов зависимости с производным таймаутом.

    Практические замечания:

  • если клиент закрыл соединение, r.Context() отменится и корректно завершит работу зависимостей
  • если вы запускаете goroutine внутри handler, у неё должен быть путь завершения по ctx.Done()
  • Ошибки и статус-коды как контракт

    HTTP-ошибки должны быть:

  • предсказуемыми для клиента
  • стабильными по категории
  • логируемыми один раз на границе (например, middleware логирования)
  • Удобный подход:

  • доменный слой возвращает типизированные ошибки
  • HTTP слой превращает их в статус-код и тело ошибки
  • Пример маппинга:

    | Категория | Пример | HTTP | |---|---|---| | неверный ввод | validation | 400 Bad Request | | не найдено | отсутствует ресурс | 404 Not Found | | конфликт | уникальность/версия | 409 Conflict | | зависимость недоступна | upstream timeout | 502 Bad Gateway или 504 Gateway Timeout | | внутренняя ошибка | баг/неучтённый случай | 500 Internal Server Error |

    Ссылки:

  • errors
  • fmt.Errorf
  • Middleware в HTTP: композиция, наблюдаемость и границы

    Что такое middleware

    Middleware — это обёртка вокруг http.Handler, которая добавляет поперечную функциональность:

  • логирование
  • метрики
  • трассировка
  • аутентификация/авторизация
  • ограничение нагрузки
  • паник-рекавери
  • !Схема показывает порядок применения middleware и где выполнять действия до и после handler

    Идиоматическая реализация middleware

    Минимальный тип:

    Пример middleware recovery:

    Важно:

  • recovery — это последний рубеж, но он не заменяет нормальную обработку ошибок
  • если вам нужен статус-код в логах, обычного http.ResponseWriter недостаточно: его часто оборачивают, чтобы перехватывать WriteHeader
  • Таймаут как middleware

    В Go есть готовый middleware:

  • http.TimeoutHandler
  • Он прерывает ответ по таймауту, но не магически останавливает ваш код. Остановку делает только корректное использование r.Context().

    Ссылка:

  • http.TimeoutHandler
  • HTTP клиент: Transport, повторное использование соединений и таймауты

    Главный принцип

    http.Client должен быть долгоживущим.

  • создание нового клиента на каждый запрос обычно ухудшает производительность
  • повторное использование соединений (keep-alive) происходит через Transport
  • Ссылка:

  • http.Client
  • Таймауты у клиента: что где задавать

    Есть несколько уровней таймаутов, и их важно не путать.

  • http.Client.Timeout
  • таймауты в Transport (dial, TLS handshake, ожидание ответа)
  • дедлайн через context.WithTimeout на конкретный запрос
  • Практика для Middle:

  • используйте context.WithTimeout на каждый запрос
  • держите http.Client и Transport как зависимости (в структуре сервиса), а не создавайте их внутри функций
  • Пример клиента с настроенным Transport:

    Ссылки:

  • http.Transport
  • net.Dialer
  • Запрос с контекстом и корректным закрытием body

    Две самые частые ошибки:

  • не закрыть resp.Body
  • читать body без ограничений
  • Пример:

    Ссылки:

  • http.NewRequestWithContext
  • io.LimitReader
  • Ретраи и идемпотентность

    Ретраи нужны не всегда. Перед тем как повторять запрос, проверьте:

  • операция идемпотентна (например, GET, PUT при корректном дизайне)
  • ошибка временная (таймаут, временная недоступность)
  • есть лимит попыток и backoff
  • Технически, повторять POST без идемпотентного ключа рискованно: вы можете создать дубликаты.

    gRPC: контракты, дедлайны, статус-коды и interceptors

    gRPC в Go обычно используют для внутренних сервисов: строгий контракт, бинарная сериализация, HTTP/2, поддержка streaming.

    Документация:

  • grpc-go
  • Базовые сущности gRPC

  • proto-контракт генерирует код клиента и сервера
  • вызовы бывают:
  • - unary: один запрос, один ответ - streaming: поток запросов и или ответов
  • ошибки передаются как status.Status с кодом codes.Code
  • Ссылки:

  • status
  • codes
  • Дедлайны и отмена в gRPC

    Правило такое же, как в HTTP:

  • клиент задаёт context.WithTimeout
  • сервер читает ctx.Done() и прекращает работу
  • Пример клиентского вызова:

    Маппинг ошибок: домен → gRPC status

    На сервере важно не «придумывать строки», а использовать коды:

  • codes.InvalidArgument для ошибок валидации
  • codes.NotFound для отсутствующего ресурса
  • codes.AlreadyExists для конфликтов создания
  • codes.Unauthenticated и codes.PermissionDenied для доступа
  • codes.Unavailable для проблем с зависимостями
  • codes.Internal для неожиданных ошибок
  • Пример:

    Interceptors: middleware-модель в gRPC

    Interceptors — аналог HTTP middleware.

  • unary interceptor оборачивает unary вызов
  • stream interceptor оборачивает streaming
  • Типовые задачи:

  • логирование
  • метрики
  • трассировка
  • проверка auth
  • унификация маппинга ошибок
  • Ссылки:

  • UnaryServerInterceptor
  • ChainUnaryInterceptor
  • Metadata: перенос request-id и контекстных данных

    Metadata — это ключ-значение для запроса.

  • подходит для request-id, auth token, feature flags на уровне инфраструктуры
  • не подходит для бизнес-данных, которые должны быть частью контракта
  • Ссылка:

  • metadata
  • Таймауты как система: бюджет и каскад

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

    Удобное правило:

  • входящий запрос:
  • внешняя зависимость:
  • Но в коде обычно не нужна формула: достаточно фиксированной политики, например:

  • HTTP handler: 2 секунды
  • вызов БД: 300–500 мс
  • вызов внешнего сервиса: 200–400 мс
  • !Иллюстрация показывает, как распределять таймауты между зависимостями

    Типичные ошибки:

  • одинаковые таймауты на всех уровнях, из-за чего верхний уровень не успевает обработать ошибку
  • отсутствие таймаута у клиента, из-за чего зависшая сеть удерживает goroutine
  • ретраи без учёта общего дедлайна
  • Производительность и надёжность сетевого кода

    Практики, которые чаще всего дают эффект без усложнения:

  • переиспользуйте http.Client и настраивайте Transport
  • всегда закрывайте resp.Body
  • ограничивайте размер читаемых ответов
  • ограничивайте параллелизм fan-out запросов (семафор или worker pool из статьи про конкурентность)
  • следите за утечками goroutine при таймаутах и отмене
  • Если кажется, что «просто медленно», возвращайтесь к профилированию:

  • CPU профиль, если жрёт процессор
  • goroutine профиль, если растёт число goroutine
  • block или mutex профили, если ожидания доминируют
  • Ссылки:

  • net/http/pprof
  • runtime/pprof
  • Тестирование HTTP и gRPC сетевых границ

    HTTP: httptest

    Для handler используйте httptest:

  • httptest.NewRequest
  • httptest.NewRecorder
  • Ссылка:

  • net/http/httptest
  • Важно тестировать:

  • корректные статус-коды
  • обработку отмены (контекст отменён)
  • таймауты (handler не зависает)
  • gRPC: in-memory транспорт через bufconn

    Для интеграционных тестов gRPC удобно не поднимать реальный порт, а использовать bufconn.

    Ссылка:

  • bufconn
  • Практика:

  • тестируйте маппинг доменных ошибок в codes.*
  • тестируйте дедлайны и отмену
  • Итоги

  • HTTP и gRPC в Go строятся вокруг одинаковых принципов: контекст, таймауты, контракт ошибок, middleware, тестируемость
  • server timeout защищает сервер, а context даёт корректное завершение работы
  • в HTTP middleware и в gRPC interceptors решают поперечные задачи, и именно там удобно делать логирование «один раз на границе»
  • клиентские таймауты и правильный Transport критичны для надёжности и производительности
  • качество сетевого кода подтверждается тестами (httptest, bufconn) и проверками отмены
  • 6. Хранение данных: SQL, транзакции, миграции и кеширование

    Хранение данных: SQL, транзакции, миграции и кеширование

    Хранение данных в Go на уровне Middle — это умение проектировать границы между доменом и инфраструктурой и одновременно обеспечивать надёжность и производительность работы с SQL и кешами.

    Связь с предыдущими статьями курса:

  • из темы про идиомы, ошибки и архитектуру пакетов берём минимальные API репозиториев, правильное оборачивание ошибок и отсутствие циклических зависимостей
  • из темы про конкурентность и context берём таймауты, отмену и запрет на утечки goroutine при ожидании БД/кеша
  • из темы про производительность берём профилирование, борьбу с аллокациями и необходимость измерений (особенно для кеша)
  • из темы про тестирование берём интеграционные тесты на границе с БД и проверку транзакционных сценариев
  • Роль database/sql: пул, а не соединение

    Пакет database/sql — стандартная точка входа для SQL в Go. Важно понимать, что sql.DB — это пул соединений*, а не одно соединение.

  • sql.Open не устанавливает соединение немедленно, он валидирует драйвер и готовит объект пула
  • реальная проверка доступности — db.PingContext(ctx)
  • размер пула влияет на нагрузку на БД и латентность запросов
  • Документация:

  • Пакет database/sql
  • Настройка пула соединений

    Практический минимум настройки:

  • SetMaxOpenConns ограничивает максимум одновременно открытых соединений
  • SetMaxIdleConns контролирует число простаивающих соединений
  • SetConnMaxLifetime помогает избегать проблем с долгоживущими соединениями (прокси, балансировщики, серверные лимиты)
  • Пример:

    Контекст и таймауты для SQL

    Правило: каждый запрос к БД должен иметь контекст с ограничением по времени.

  • верхний уровень (HTTP/gRPC handler, worker) задаёт общий дедлайн запроса
  • слой хранения может задавать более короткий таймаут для конкретного SQL-вызова
  • См. также:

  • Пакет context
  • Сканирование результатов и NULL

    В SQL NULL — это не нулевое значение Go-типа. Поэтому:

  • для nullable-колонок используйте sql.NullString, sql.NullInt64, sql.NullTime
  • либо используйте указатели string, int64, если это удобно вашему домену
  • Документация:

  • Типы sql.NullString и другие
  • Репозиторий и архитектура пакетов

    Типовая цель — сделать доменный/сервисный слой независимым от конкретного SQL-драйвера.

    Практика уровня Middle:

  • интерфейс репозитория объявляется рядом с кодом, который его использует
  • SQL-реализация лежит в инфраструктурном пакете (часто под internal/storage/...)
  • доменный слой не импортирует database/sql
  • Пример границ:

  • internal/app содержит use case и интерфейс UserRepo
  • internal/storage/postgres содержит реализацию userRepo на SQL
  • Это продолжает идеи из статьи про архитектуру пакетов: минимальный публичный API и понятные зависимости.

    Транзакции: атомарность и границы ответственности

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

    Типовые случаи:

  • запись в несколько таблиц с согласованностью
  • чтение с последующей записью, где важна защита от гонок данных на уровне БД
  • инварианты: например, баланс счёта не может уйти в минус
  • Основная идиома: BeginTx + defer Rollback

    Критическая деталь: Rollback должен быть вызван всегда, если Commit не произошёл.

    Обратите внимание:

  • ошибки оборачиваются смысловым контекстом через %w (из статьи про ошибки)
  • логирование ошибки делается на границе приложения (HTTP/gRPC), а не внутри репозитория
  • Документация:

  • sql.DB.BeginTx
  • sql.TxOptions
  • Где держать транзакцию

    Практическая рекомендация: транзакция должна жить на уровне сценария (use case), а не глубоко в репозитории.

  • сервис (use case) определяет границу атомарности
  • репозиторий предоставляет операции, которые могут работать как с sql.DB, так и с sql.Tx
  • Популярный приём — принимать интерфейс с нужными методами:

    Так вы избегаете дублирования кода для db и tx.

    Уровни изоляции: что важно знать

    Уровень изоляции определяет, какие эффекты параллельных транзакций допускаются. На практике:

    | Уровень | Идея | Компромисс | |---|---|---| | ReadCommitted | видим только зафиксированные данные | меньше блокировок, но возможны неповторяемые чтения | | RepeatableRead | повторное чтение в транзакции стабильно | больше удержаний версии/блокировок | | Serializable | максимально строго, как будто транзакции выполняются последовательно | больше конфликтов, ретраи нужны чаще |

    В Go эти значения задаются через sql.TxOptions.Isolation.

    Важно: поведение зависит от конкретной СУБД, но принцип выбора одинаковый: минимально достаточная строгость.

    Транзакции и конкурентность

    Связь с темой конкурентности:

  • транзакция удерживает ресурсы БД, поэтому нельзя делать внутри неё долгий I/O (внешние HTTP-вызовы)
  • если внутри транзакции ждать сеть, вы увеличиваете риск блокировок и падения пропускной способности
  • Правило: в транзакции только быстрые операции над БД.

    Миграции: управление схемой как часть поставки

    Миграции — это версия схемы БД, которая поставляется вместе с кодом. На уровне Middle от вас ожидают, что вы:

  • умеете делать миграции воспроизводимо
  • понимаете совместимость схемы при выкладке
  • отличаете миграции от данных (seed) и от ad-hoc скриптов
  • Инструменты миграций

    На практике распространены:

  • golang-migrate/migrate
  • pressly/goose
  • Ключевое — не инструмент, а дисциплина:

  • миграции должны быть в репозитории
  • миграции должны выполняться в CI/CD или при старте сервиса в контролируемом режиме
  • состояние применённых миграций должно храниться в самой БД
  • Принцип совместимости: expand/contract

    Чтобы выкладывать без остановки и без гонок между версиями сервисов, используйте подход expand/contract:

  • expand: добавить новые поля/таблицы/индексы, не ломая старый код
  • выкладка кода, который пишет и читает новое
  • contract: удалить старое, когда старые версии кода гарантированно ушли
  • !Схема безопасной миграции без даунтайма

    Практические советы:

  • добавляйте колонку как NULL или с дефолтом, чтобы не переписывать всю таблицу в одном шаге (особенно на больших объёмах)
  • удаление и переименование делайте отдельным шагом после выкладки
  • Идемпотентность и повторные прогоны

    Миграции должны быть безопасны при повторном выполнении в рамках нормального процесса доставки:

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

    Чаще всего проблемы в проде возникают не из-за Go-кода, а из-за схемы и запросов.

    Минимальный набор практик:

  • используйте индексы под реальные фильтры и сортировки
  • избегайте SELECT * в критичном коде
  • не допускайте N+1 запросов (петля по сущностям с отдельным запросом на каждую)
  • анализируйте планы выполнения через EXPLAIN (в вашей СУБД)
  • Связь с темой профилирования:

  • профили Go покажут, что время уходит в ожидание I/O
  • дальше вы диагностируете БД-часть: медленные запросы, планы, блокировки
  • Кеширование: цели, виды и типовые ошибки

    Кеширование — это обмен консистентности на скорость/дешевизну. У кеша всегда есть цена:

  • инвалидировать сложно
  • легко получить устаревшие данные
  • легко перегрузить БД при неправильной стратегии
  • Виды кеша

  • in-memory кеш в процессе: быстро, но не разделяется между инстансами
  • распределённый кеш (например, Redis): разделяется, но добавляет сетевой hop
  • Классический выбор:

  • in-memory для очень горячих небольших данных и локальных вычислений
  • Redis для данных, которые нужны всем инстансам и переживают перезапуск процесса
  • Документация популярного клиента:

  • redis/go-redis
  • Базовая стратегия: cache-aside

    Cache-aside означает:

  • сначала пытаемся прочитать из кеша
  • при промахе читаем из БД
  • результат кладём в кеш с TTL
  • Плюсы:

  • простая реализация
  • кеш не является источником истины
  • Минусы:

  • возможны промахи и всплески нагрузки
  • сложная инвалидция при обновлениях
  • TTL и консистентность

    TTL — время жизни значения в кеше. Практическая логика:

  • маленький TTL уменьшает устаревание, но увеличивает нагрузку на БД
  • большой TTL разгружает БД, но повышает риск отдавать устаревшие данные
  • Уровень Middle — это умение выбирать TTL осознанно по требованиям:

  • для справочников можно минуты/часы
  • для финансовых/критичных данных чаще нужен иной подход (например, без кеша или с точечной инвалидцией)
  • Проблема cache stampede и singleflight

    Если ключ истёк и много запросов одновременно промахнулись, они могут одновременно пойти в БД. Это называется cache stampede.

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

  • golang.org/x/sync/singleflight
  • Идея:

  • один запрос делает реальный поход в БД
  • остальные ждут результат
  • Инвалидация: простые правила

    Инвалидация — самая сложная часть кеша. Практичные и часто достаточные правила:

  • если данные редко меняются, начинайте с TTL, а не с сложной схемы инвалидции
  • если данные меняются часто и важно быстро отражать изменения, продумайте точечное удаление ключей после записи в БД
  • не делайте запись в кеш источником истины, если вы не готовы проектировать полноценную согласованность
  • !Поток данных при cache-aside

    Интеграционные тесты для БД и миграций

    Связь с темой тестирования:

  • unit-тесты обычно мокают репозитории
  • интеграционные тесты поднимают реальную БД, прогоняют миграции и проверяют транзакционные сценарии
  • Подходы:

  • отдельная тестовая БД (например, через docker-compose в CI)
  • контейнеры в тестах
  • Полезная библиотека:

  • testcontainers-go
  • Что имеет смысл проверять интеграционно:

  • миграции применяются с нуля
  • транзакция действительно атомарна (частичные изменения не остаются)
  • ограничения и индексы работают как ожидается
  • Чеклист Middle-разработчика

  • у всех SQL-вызовов есть context с таймаутом
  • *sql.DB настроен как пул и переиспользуется
  • транзакции короткие и не содержат внешнего I/O
  • граница транзакции определяется на уровне use case
  • миграции в репозитории, воспроизводимы, применяются контролируемо
  • сложные изменения схемы делаются через expand/contract
  • кеширование начинается с простого cache-aside и измерений
  • учтены stampede и стратегия инвалидции
  • Материалы для углубления

  • Пакет database/sql
  • Пакет context
  • golang-migrate/migrate
  • pressly/goose
  • singleflight
  • redis/go-redis
  • testcontainers-go
  • 7. Проектирование сервисов: конфиги, логирование, CI/CD и observability

    Проектирование сервисов: конфиги, логирование, CI/CD и observability

    На уровне Middle от Go-разработчика ожидают, что он умеет проектировать сервис не только как набор обработчиков и функций, но и как эксплуатируемую систему: с управляемой конфигурацией, предсказуемым логированием, автоматизированной сборкой и наблюдаемостью.

    Эта статья связывает предыдущие темы курса в единый эксплуатационный контур:

  • из темы про идиомы и ошибки берём минимальные API, оборачивание ошибок и правило логировать один раз на границе
  • из темы про конкурентность и context берём корректную отмену и перенос context через слои
  • из темы про производительность берём профилирование и осторожность с лишними аллокациями в горячем коде
  • из темы про тестирование берём go test, -race, покрытие и интеграционные проверки границ
  • из тем про сеть и БД берём таймауты, стабильные контракты ошибок и наблюдаемость внешних зависимостей
  • !Поток запроса и места, где подключаются логирование, метрики и трассировка

    Конфигурация сервиса

    Конфигурация отвечает на вопрос: как один и тот же бинарник запускается в разных окружениях.

    Что такое конфиг и что в него входит

    Под конфигурацией обычно понимают:

  • сетевые параметры: адреса, порты, TLS
  • параметры зависимостей: DSN БД, адрес Redis, endpoint внешнего API
  • таймауты и лимиты: ReadTimeout, MaxOpenConns, concurrency limit
  • feature flags: включение экспериментальных возможностей
  • параметры наблюдаемости: уровень логирования, адрес экспортера метрик
  • Важно различать:

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

    Источники конфигурации

    Наиболее практичный набор источников для сервисов:

  • переменные окружения
  • флаги командной строки
  • конфигурационный файл как опция, но не обязательная зависимость
  • Многие команды придерживаются идей The Twelve-Factor App для конфигурации через окружение.

    Ссылка:

  • The Twelve-Factor App: Config
  • Идиома: типизированный Config и явная валидация

    На уровне Middle важно, чтобы конфиг:

  • был типизированным
  • имел значения по умолчанию
  • валидировался при старте сервиса
  • давал понятные ошибки
  • Пример на стандартной библиотеке:

    Ссылки:

  • os.LookupEnv
  • time.ParseDuration
  • Связь с темой ошибок: ошибки валидации конфига должны быть понятными и приводить к немедленному завершению, потому что сервис в неверной конфигурации лучше не запускать.

    Конфиг и архитектура пакетов

    Идиоматичный подход к структуре:

  • cmd/<service>/main.go делает wiring: читает конфиг, создаёт логгер, клиенты, репозитории, сервер
  • internal/... содержит бизнес-логику и инфраструктуру
  • internal/config или internal/appconfig содержит тип Config и Load
  • Это продолжает идею из темы про архитектуру пакетов: точка входа живёт отдельно, остальной код остаётся тестируемым.

    Логирование

    Логи отвечают на вопросы что произошло и почему, но только если логирование спроектировано.

    Главные принципы логирования в сервисе

  • логируйте структурировано, а не строками
  • избегайте дублирования: ошибка логируется один раз на границе
  • лог должен быть привязан к контексту запроса: request-id, user-id, метод, latency
  • не логируйте секреты
  • Связь с темой ошибок: низкие уровни возвращают ошибку с контекстом через %w, верхний уровень решает, как её отразить в логах и в ответе.

    slog: стандартный структурный логгер

    Начиная с Go 1.21 в стандартной библиотеке есть log/slog.

    Ссылки:

  • log/slog
  • Пример настройки JSON-логов:

    Корреляция: request-id через context

    Идея: на входе запроса вы назначаете request_id, кладёте его в context, а логер на границе добавляет его в каждую запись.

    Пример middleware для HTTP:

    Ссылки:

  • context.WithValue
  • Практическая оговорка: context.WithValue предназначен для данных уровня запроса, не для бизнес-параметров. request-id подходит, потому что это инфраструктурная корреляция.

    Где именно логировать

    Типовой расклад:

  • middleware или interceptor логирует: вход, выход, статус, latency, request-id
  • handler маппит доменные ошибки в HTTP или gRPC контракт
  • доменные функции не делают logger.Error, они возвращают ошибки
  • Это напрямую уменьшает шум и делает логи пригодными для поиска причин.

    Observability: метрики, трассировка, логи

    Под observability обычно понимают способность понять состояние системы по её сигналам. Практически в бэкенде это триада:

  • логи
  • метрики
  • трассировка
  • Метрики

    Метрики нужны, чтобы видеть тренды и деградации.

    Типовые метрики сервиса:

  • RPS и количество ошибок по кодам
  • latency как histogram
  • текущие очереди и загрузка пулов
  • ошибки и время ответа зависимостей
  • Самый распространённый стек для метрик:

  • Prometheus как сборщик
  • экспозиция /metrics из сервиса
  • Ссылки:

  • Prometheus Go client
  • Практика: метрики должны иметь ограниченную кардинальность лейблов. Например, endpoint=/users/{id} лучше, чем endpoint=/users/123.

    Трассировка

    Трассировка показывает путь запроса через сервисы и зависимости.

    Основные термины:

  • trace: весь путь запроса
  • span: участок работы внутри trace
  • Обычно трассировка строится через OpenTelemetry.

    Ссылки:

  • OpenTelemetry Go
  • Практика: используйте трассировку для медленных запросов и расследований, а метрики для мониторинга.

    Связь с context

    И метрики, и трассировка, и отмена запросов привязаны к context.

  • HTTP: r.Context() отменяется при разрыве соединения
  • gRPC: ctx отменяется при дедлайне/отмене клиента
  • DB и внешние вызовы должны получать ctx, иначе вы получите утечки goroutine и зависшие запросы
  • Это связывает observability с темами про конкурентность и сетевое программирование.

    Health checks и readiness

    Сервису нужно уметь отвечать на вопрос, можно ли на него слать трафик.

    Распространённые проверки:

  • liveness: процесс жив, event loop работает
  • readiness: зависимости доступны, сервис готов обслуживать
  • В Go-HTTP сервисе часто делают:

  • /healthz как простая проверка
  • /readyz как проверка зависимостей: Ping БД, доступность очереди
  • Важно: readiness должна быть быстрой и иметь короткие таймауты.

    CI/CD: от коммита до выкладки

    CI/CD превращает практики из предыдущих статей в автоматические гарантии качества.

    Минимальный CI для Go-сервиса

    Конвейер обычно включает:

  • go test ./...
  • go test -race ./... для конкурентного кода
  • go vet ./...
  • линтеры
  • сборка go build
  • публикация артефакта или Docker-образа
  • Ссылки:

  • go test
  • go vet
  • golangci-lint
  • !Типовой CI/CD поток для Go-сервиса

    Важные практики в CI

  • фиксируйте версию Go в CI, чтобы сборка была воспроизводимой
  • запрещайте мердж без зелёного CI
  • добавляйте -count=1 для устранения влияния кеша при нестабильных тестах
  • для критичного конкурентного кода держите -race как отдельный шаг, потому что он медленнее
  • CD и миграции

    Если сервис использует SQL-миграции, важно выбрать процесс:

  • миграции выполняются отдельным шагом пайплайна
  • либо отдельной джобой при деплое
  • Идея из темы про миграции: применяйте совместимые изменения схемы через expand/contract, чтобы выкладка нескольких версий сервиса не ломала работу.

    Практический каркас: что должно быть в сервисе

    Ниже минимальный набор компонентов, которые обычно ожидаются на уровне Middle.

    Состав стартового кода в main

    Ссылки:

  • net/http Server.Shutdown
  • os/signal NotifyContext
  • Этот каркас связывает несколько тем курса: таймауты HTTP, корректный shutdown, работа с context, и логирование на границе.

    Частые ошибки и как их избегать

    | Ошибка | Чем опасна | Как исправлять | |---|---|---| | конфиг без валидации | сервис запускается в неверном режиме | валидируйте при старте и завершайте процесс | | логирование на каждом уровне | дубликаты и шум | логируйте один раз на границе, внутри возвращайте ошибки | | отсутствие request-id | сложно расследовать инциденты | назначайте request-id в middleware и прокидывайте дальше | | метрики с высокой кардинальностью | перегруз Prometheus и дорогие запросы | ограничивайте лейблы, нормализуйте пути | | нет -race в CI | гонки всплывают только в проде | добавьте отдельный шаг go test -race | | readiness без таймаутов | зависание проверки и деградация | короткие context.WithTimeout на проверки |

    Итоги

  • конфигурация должна быть типизированной, валидируемой и отделённой от бизнес-логики
  • логирование в Go-сервисе должно быть структурным и недублирующим, с корреляцией через request-id
  • observability строится на логах, метриках и трассировке, и почти всегда опирается на context
  • CI/CD превращает практики тестирования, линтинга и конкурентной безопасности в автоматические гарантии
  • хороший Middle-уровень проявляется в том, что сервис удобно запускать, мониторить и безопасно выкатывать