Перейти к основному содержимому

Я поспорил с интервьюером по Go и получил оффер

· 44 мин. чтения

Сегодня мы разберём реальное собеседование на позицию Senior Go Backend-разработчика в крупный маркетплейс, которое завершилось офером на 450 000 рублей на руки. Кандидат продемонстрировал глубокое владение темой — от базовых типов данных и внутреннего устройства слайсов в Go, до нюансов работы с каналами, контекстом, микросервисной архитектурой, паттерном Saga и протокола gRPC, при этом не поборспорил с интервьюером даже когда тот ошибся в деталях реализации строк в Go, сославшись на исходники языка. Вместе с тем мы увидим, что даже сильный кандидат может допустить пробелы (например, забыв название популярного инструмента для генерации моков), а формат собеседования — с заготовленным списком вопросов и перебиванием — отражает текущую реальность рынка, где кандидаты вынуждены мириться с определёнными несовершенствами процесса.

Вопрос 1. Какие типы данных в Go ты знаешь?

Таймкод: 00:00:32

Ответ собеседника: Правильный. Перечислил основные типы данных Go: числовые типы различной битности (int8, int16, int32, int64, uint и т.д.), булев тип (bool), строки (string), указатели, массивы (статические), слайсы (срезы — динамические массивы), структуры (struct), мапы (map), каналы (chan), функции, интерфейсы. Также разделил типы на примитивы (числа, bool, указатель) и сложные типы/структуры данных (массивы, слайсы, мапы, каналы, интерфейсы, функции). Помимо Go, упомянул аналоги из других языков: векторы в C++, ArrayList в Java.

Правильный ответ:

Ответ собеседника полный и правильный. Для систематизации можно представить типы данных Go в виде иерархии:

1. Базовые (скалярные) типы

  • Целочисленные знаковые: int8, int16, int32, int64, int (платформозависимый, 32 или 64 бита)
  • Целочисленные беззнаковые: uint8 (byte), uint16, uint32, uint64, uint
  • С плавающей точкой: float32, float64
  • Комплексные числа: complex64, complex128
  • Булев тип: bool (значения true, false)
  • Строка: string (неизменяемая последовательность байт в UTF-8)
  • Байт: byte (алиас для uint8)
  • Руна: rune (алиас для int32, представляет Unicode code point)
  • Указатель: *T — хранит адрес в памяти значения типа T

2. Составные (композитные) типы

  • Массив [N]T — фиксированной длины, значение по умолчанию — zero value элемента
  • Слайс []T — динамический массив, ссылается на базовый массив
  • Структура struct — набор полей с именами
  • Указатель *T — адрес в памяти
  • Функция func — функциональный тип
  • Интерфейс interface — набор методов
  • Мапа map[K]V — хеш-таблица пар ключ-значение
  • Канал chan T — тип для коммуникации между горутинами

3. Специальные типы

  • interface{} (или any с Go 1.18) — пустой интерфейс, может содержать значение любого типа
  • error — встроенный интерфейс для обработки ошибок

4. Псевдонимы типов (type aliases)

С помощью type можно создавать именованные типы и определять методы для них:

type UserID int64
type Handler func(http.ResponseWriter, *http.Request)

5. Generics (с Go 1.18)

Появились параметризованные типы:

type Stack[T any] struct {
items []T
}

func Min[T constraints.Ordered](a, b T) T {
if a < b {
return a
}
return b
}

Ключевые различия между типами:

  • Массив vs Слайс: массив — значение (копируется при передаче), слайс — ссылка на базовый массив (заголовок: указатель, len, cap)
  • Map vs Struct: map — динамическая коллекция пар, struct — фиксированная структура с именованными полями
  • Канал vs другие типы: канал — единственный встроенный тип, безопасный для конкурентного доступа без мьютексов

Вопрос 2. Что такое слайс (срез) в Go?

Таймкод: 00:02:48

Ответ собеседника: Правильный. Слайс — это динамический массив в Go. Внутри он представляет собой структуру из трёх полей: указатель на базовый (статический) массив, длина (length) и ёмкость (capacity). При заполнении базового массив происходит релокация — выделение нового массива большего размера. Коэффициент роста в Go равен 2 (для малых размеров), а при больших размерах уменьшается примерно до 1.5. Также пояснил, что строка не является слайсом, так как у строки нет поля capacity, и если бы строка была наследником слайса, это нарушало бы принцип подстановки Лисков (третий принцип SOLID).

Правильный ответ:

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

Внутреннее устройство слайса

Слайс — это структура (slice header) из трёх полей:

type slice struct {
array unsafe.Pointer // указатель на базовый массив
len int // длина (количество доступных элементов)
cap int // ёмкость (размер базового массива)
}

В reflect это представлено как:

type SliceHeader struct {
Data uintptr
Len int
Cap int
}

Создание слайсов

// 1. Литерал
s := []int{1, 2, 3}

// 2. Из массива (slicing)
arr := [5]int{1, 2, 3, 4, 5}
s := arr[1:4] // [2, 3, 4], len=3, cap=4

// 3. make — с предвыделенной ёмкостью
s := make([]int, 0, 10) // len=0, cap=10

// 4. nil слайс
var s []int // nil, len=0, cap=0

Механизм роста (append)

При добавлении элемента через append, если len == cap, происходит релокация:

s := make([]int, 0, 2)
s = append(s, 1) // [1], len=1, cap=2
s = append(s, 2) // [1, 2], len=2, cap=2
s = append(s, 3) // релокация! len=3, cap=4 (или больше)

Алгоритм роста (из runtime/slice.go, функция growslice):

  • Если текущая ёмкость < 256, новая ёмкость = cap * 2
  • Если >= 256, коэффициент роста постепенно снижается от 2.0 до ~1.25 в зависимости от размера
  • Также учитывается выравнивание по размеру элемента и maxAlloc

Важные нюансы

1. Слайс — ссылочный тип. При передаче в функцию копируется только заголовок (24 байта на 64-bit), но базовый массив общий:

func modify(s []int) {
s[0] = 999 // изменит оригинал
}

s := []int{1, 2, 3}
modify(s)
fmt.Println(s) // [999, 2, 3]

2. Срез разделяет базовый массив. Два слайса могут ссылаться на один массив:

original := []int{1, 2, 3, 4, 5}
s1 := original[:2] // [1, 2], cap=5
s2 := original[2:] // [3, 4, 5], cap=3

s1 = append(s1, 999) // перезапишет original[2]!
fmt.Println(s2) // [999, 4, 5] — неожиданно!

3. Создание независимой копии:

// Правильный способ — copy
original := []int{1, 2, 3, 4, 5}
s := make([]int, 2)
copy(s, original[:2]) // s = [1, 2], отдельный базовый массив

4. Строка vs слайс. Строка (string) — неизменяемая последовательность байт. У неё нет поля cap, и она не является слайсом. Конвертация []byte(s) создаёт копию данных:

s := "hello"
b := []byte(s) // копирование данных в новый массив

5. Пустой vs nil слайс:

var s1 []int // nil слайс, len=0, cap=0, s1 == nil → true
s2 := []int{} // пустой слайс, len=0, cap=0, s2 == nil → false
s3 := make([]int, 0) // пустой слайс, len=0, cap=0, s3 == nil → false

Оба (nil и пустой) корректно работают с append, len, cap, range, но могут вести себя при сериализации (JSON: null vs []).

Оптимизация: предвыделение ёмкости

Если известен примерный размер, лучше использовать make с capacity, чтобы избежать множественных релокаций:

// Плохо: множественные релокации
var result []int
for i := 0; i < 10000; i++ {
result = append(result, i)
}

// Хорошо: одна аллокация
result := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
result = append(result, i)
}

Вопрос 3. Содержит ли строка в Go указатель на срез байтов?

Таймкод: 00:07:16

Ответ собеседника: Правильный. Оспорил утверждение интервьюера. Пояснил, что в строке Go первое поле — это указатель не на тип данных «срез байтов» (slice of bytes), а на непрерывную область памяти, то есть на массив байтов. Массив байтов и срез байтов — это разные типы данных. Ссылался на исходники Go на GitHub, где в структуре string поле описано как *byte (указатель на байт), а не как указатель на структуру слайса. Интервьюер согласился с необходимостью перепроверки.

Правильный ответ:

Ответ собеседника абсолютно верный. Строка в Go содержит указатель на массив байтов, а не на слайс.

Внутреннее устройство строки

В исходниках Go (runtime/string.go) строка представлена как:

type stringStruct struct {
str unsafe.Pointer // указатель на первый байт данных (массив байтов)
len int // длина строки в байтах
}

Через reflect это доступно как:

type StringHeader struct {
Data uintptr
Len int
}

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

StringHeaderSliceHeader
ПоляData, LenData, Len, Cap
Тип данныхУказатель на массив байтовУказатель на базовый массив
ИзменяемостьНеизменяема (immutable)Изменяема
ЁмкостьНет поля capЕсть поле cap

Почему это важно:

1. Строка — неизменяема. Нет поля cap, потому что строка не может расти. Это гарантия безопасности: строковые литералы и подстроки могут разделять память.

2. Подстроки без копирования:

s := "Hello, World!"
sub := s[7:] // "World!" — указывает на ту же память, что и s

Обе строки s и sub ссылаются на одну и ту же область памяти. Это возможно именно потому, что строка неизменяема.

3. Конвертация string ↔ []byte:

// string → []byte: всегда копирование
str := "hello"
bytes := []byte(str) // новый массив байтов

// []byte → string: всегда копирование
bytes := []byte{'h', 'e', 'l', 'l', 'o'}
str := string(bytes) // новая строка

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

4. Unsafe-конвертация (без копирования):

import "unsafe"

func stringToBytes(s string) []byte {
return unsafe.Slice(unsafe.StringData(s), len(s))
}

func bytesToString(b []byte) string {
return unsafe.String(unsafe.SliceData(b), len(b))
}

С Go 1.20 появились unsafe.StringData и unsafe.SliceData для безопасной unsafe-конвертации. Но нужно быть осторожным: если исходные данные изменятся, результат станет невалидным.

Вывод: строка в Go — это структура из указателя на массив байтов и длины. У неё нет поля cap, и она не является слайсом. Утверждение интервьюера было некорректным.

Вопрос 4. Что будет при чтении из nil-канала и при записи в nil-канал?

Таймкод: 00:08:25

Ответ собеседника: Правильный. Чтение из nil-канала приводит к блокировке (горутина заблокируется навсегда). Запись в nil-канал также приводит к блокировке. Пояснил, что это аксиомы каналов в Go, и на практике далеко не все разработчики знают наизусть всю таблицу состояний каналов, так как в продакшене используется лишь часть этих сценариев.

Правильный ответ:

Ответ собеседника правильный. Дополню полной таблицей состояний каналов для систематизации знаний.

Таблица состояний каналов

Операцияnil каналОткрытый каналЗакрытый канал
Запись (ch <- v)Блокировка навсегдаЗапись / блокировка (если буфер полон)panic: send on closed channel
Чтение (<-ch)Блокировка навсегдаЧтение / блокировка (если буфер пуст)Чтение остатка, затем zero value
Закрытие (close(ch))panic: close of nil channelЗакрытиеpanic: close of closed channel
len(ch)0Количество элементов в буфереКоличество оставшихся элементов
cap(ch)0Размер буфераРазмер буфера

Практические примеры

1. Nil канал блокирует навсегда:

var ch chan int // nil канал

// Горутина зависнет навсегда
go func() {
ch <- 42 // deadlock — блокировка
}()

// Тоже зависнет
go func() {
val := <-ch // deadlock — блокировка
}()

2. Закрытый канал возвращает zero value:

ch := make(chan int, 2)
ch <- 1
close(ch)

val := <-ch // 1 — прочитали из буфера
val = <-ch // 0 — zero value для int, канал пустой и закрыт

// Проверка на закрытие
val, ok := <-ch // val=0, ok=false

3. Запись в закрытый канал — panic:

ch := make(chan int)
close(ch)
ch <- 42 // panic: send on closed channel

Практическое применение nil-каналов

Nil-каналы используются для управления поведением в select:

func merge(a, b <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)

for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok {
a = nil // отключаем этот case — он будет блокироваться вечно
continue
}
out <- v
case v, ok := <-b:
if !ok {
b = nil
continue
}
out <- v
}
}
}()
return out
}

Когда канал устанавливается в nil, соответствующий case в select никогда не сработает (потому что чтение из nil-канала блокируется), что фактически отключает эту ветку.

Распространённые ошибки

// Ошибка: неинициализированный канал
var ch chan string
go func() {
data := <-ch // зависнет
fmt.Println(data)
}()

// Правильно: инициализация
ch := make(chan string)
go func() {
data := <-ch
fmt.Println(data)
}()
ch <- "hello"

Вывод: nil-канал — это канал без выделенной памяти (var ch chan T). Любая операция чтения/записи блокирует горутину навсегда. Это не баг, а фича, которая используется для управления потоком выполнения в select.

Вопрос 5. Что такое ООП в Go? Как реализован полиморфизм?

Таймкод: 00:10:16

Ответ собеседника: Правильный. ООП в Go существует, но в неявном, не классическом виде. Вместо классов используются структуры (struct), которые содержат поля, а методы определяются отдельно и могут быть «размазаны» по разным частям кода, что даёт гибкость. Полиморфизм достигается через интерфейсы и утиную типизацию (duck typing) — если структура реализует все методы интерфейса, она автоматически удовлетворяет этому интерфейсу. Также провёл аналогию с Rust, где вместо интерфейсов используются трейты.

Правильный ответ:

Ответ собеседника правильный. Дополню деталями о реализации ООП-принципов в Go.

ООП в Go — не классическое

Go намеренно отказался от классической ООП-модели (нет классов, наследования, конструкторов). Вместо этого используется композиция и интерфейсы.

Три столпа ООП в контексте Go:

1. Инкапсуляция

Достигается через видимость на уровне пакетов. Имена с заглавной буквы — экспортируемые, с маленькой — приватные:

package user

type User struct {
ID int // экспортируемое поле
email string // приватное поле (доступно только внутри пакета)
}

func NewUser(id int, email string) *User {
return &User{ID: id, email: email}
}

func (u *User) Email() string {
return u.email
}

2. Наследование → Композиция

Вместо наследования — встраивание (embedding):

type Animal struct {
Name string
}

func (a *Animal) Speak() string {
return "..."
}

// Dog «наследует» Animal через встраивание
type Dog struct {
Animal // встраивание — поля и методы Animal поднимаются на уровень Dog
Breed string
}

dog := Dog{Animal: Animal{Name: "Rex"}, Breed: "Labrador"}
fmt.Println(dog.Name) // "Rex" — поле Animal доступно напрямую
fmt.Println(dog.Speak()) // "..." — метод Animal доступен напрямую

При конфликте имён — явное указание:

type Cat struct {
Animal
}

func (c *Cat) Speak() string {
return "Meow!"
}

cat := Cat{Animal: Animal{Name: "Whiskers"}}
fmt.Println(cat.Speak()) // "Meow!" — метод Cat
fmt.Println(cat.Animal.Speak()) // "..." — метод Animal

3. Полиморфизм через интерфейсы

Go использует утиную типизацию (structural typing): тип удовлетворяет интерфейсу, если реализует все его методы. Явное указание implements не требуется:

type Speaker interface {
Speak() string
}

type Dog struct{ Name string }
func (d Dog) Speak() string { return "Woof!" }

type Cat struct{ Name string }
func (c Cat) Speak() string { return "Meow!" }

type Robot struct{ Model string }
func (r Robot) Speak() string { return "Beep boop!" }

// Полиморфная функция
func MakeSound(s Speaker) {
fmt.Println(s.Speak())
}

MakeSound(Dog{Name: "Rex"}) // "Woof!"
MakeSound(Cat{Name: "Whiskers"}) // "Meow!"
MakeSound(Robot{Name: "R2-D2"}) // "Beep boop!"

Интерфейс внутри: внутреннее представление

Интерфейс — это пара (type, value):

type iface struct {
tab *itab // информация о типе и методах
data unsafe.Pointer // указатель на данные
}

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

Пустой интерфейс и type assertion

func process(v interface{}) {
switch v := v.(type) {
case string:
fmt.Println("string:", v)
case int:
fmt.Println("int:", v)
case Speaker:
fmt.Println("speaker:", v.Speak())
default:
fmt.Println("unknown type")
}
}

Практический пример: паттерн Strategy

type PaymentStrategy interface {
Pay(amount float64) error
}

type CreditCard struct{ Number string }
func (c CreditCard) Pay(amount float64) error {
fmt.Printf("Paid %.2f via credit card %s\n", amount, c.Number)
return nil
}

type PayPal struct{ Email string }
func (p PayPal) Pay(amount float64) error {
fmt.Printf("Paid %.2f via PayPal %s\n", amount, p.Email)
return nil
}

type Order struct {
Amount float64
Strategy PaymentStrategy
}

func (o *Order) Checkout() error {
return o.Strategy.Pay(o.Amount)
}

// Использование
order := Order{Amount: 99.99, Strategy: CreditCard{Number: "4111..."}}
order.Checkout()

order.Strategy = PayPal{Email: "user@example.com"}
order.Checkout()

Ключевые отличия от классического ООП:

  • Нет классов — есть структуры
  • Нет наследования — есть композиция и встраивание
  • Нет конструкторов — есть функции-фабрики (NewType())
  • Нет implements — интерфейс удовлетворяется неявно
  • Нет исключений — есть явные ошибки
  • Нет дженериков в классическом смысле (до Go 1.18), теперь есть параметризованные типы

Вопрос 6. Для чего нужен пакет context в Go? Каковы способы его использования?

Таймкод: 00:12:22

Ответ собеседника: Правильный. Context используется для передачи общего состояния между горутинами — например, для отмены операций, которые уже не актуальны. Основные способы использования: context.Background (базовый контекст, используется в ~80% случаев), context.WithTimeout (автоматическая отмена по таймауту), context.WithDeadline (отмена по конкретному времени), context.WithValue (передача данных через контекст, например, авторизационных токенов, хотя этот спорный юзкейс). Также упомянул, что в большинстве продакшен-проектов код линейный (CRUD-операции) и конкурентность используется не так часто.

Правильный ответ:

Ответ собеседника правильный. Дополню деталями и практическими примерами.

Назначение context

context.Context — это стандартный способ передачи:

  • сигналов отмены (cancellation)
  • дедлайнов и таймаутов
  • scoped-данных (request-scoped values)

между границами API и горутинами в рамках одной цепочки вызовов.

Иерархия контекстов

context.Background() — корневой, никогда не отменяется
├── context.WithCancel() — ручная отмена
├── context.WithTimeout() — отмена по таймауту
├── context.WithDeadline() — отмена по конкретному времени
└── context.WithValue() — передача данных

Основные конструкторы

// 1. Базовый контекст — используется на верхнем уровне (main, HTTP handler, init)
ctx := context.Background()

// 2. TODO — когда не знаете, какой контекст использовать (заглушка)
ctx := context.TODO()

// 3. Ручная отмена
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // всегда вызывайте cancel для освобождения ресурсов

// 4. Таймаут
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

// 5. Дедлайн
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
defer cancel()

// 6. Передача значений
ctx = context.WithValue(ctx, "userID", 123)

Практический пример: HTTP-сервер с таймаутом

func handler(w http.ResponseWriter, r *http.Request) {
// Контекст запроса автоматически отменяется при закрытии соединения
ctx := r.Context()

result := make(chan string, 1)
go func() {
result <- heavyComputation(ctx)
}()

select {
case res := <-result:
w.Write([]byte(res))
case <-ctx.Done():
// Клиент отключился или таймаут
http.Error(w, "request cancelled", http.StatusRequestTimeout)
}
}

func heavyComputation(ctx context.Context) string {
select {
case <-time.After(10 * time.Second):
return "done"
case <-ctx.Done():
return "" // отмена — ранний выход
}
}

Практический пример: каскадная отмена горутин

func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("Worker %d: stopped\n", id)
return
default:
// Работа
time.Sleep(500 * time.Millisecond)
fmt.Printf("Worker %d: working\n", id)
}
}
}

func main() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

for i := 0; i < 5; i++ {
go worker(ctx, i)
}

<-ctx.Done() // Ждём отмены
fmt.Println("All workers stopped")
}

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

// Рекомендация: использовать собственные типы для ключей, чтобы избежать коллизий
type contextKey string

const (
userIDKey contextKey = "userID"
requestIDKey contextKey = "requestID"
)

func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, userIDKey, extractUserID(r))
ctx = context.WithValue(ctx, requestIDKey, uuid.New().String())
next.ServeHTTP(w, r.WithContext(ctx))
})
}

func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
userID := ctx.Value(userIDKey).(int)
requestID := ctx.Value(requestIDKey).(string)
// ...
}

Важные правила использования

1. Контекст всегда передаётся первым аргументом:

// Правильно
func DoSomething(ctx context.Context, arg string) error

// Неправильно
func DoSomething(arg string, ctx context.Context) error

2. Не храните контекст в структурах. Передавайте его явно как аргумент функции. Исключение — структуры, специально предназначенные для обработки запросов (например, http.Request).

3. Всегда вызывайте cancel(). Иначе утечка горутин:

ctx, cancel := context.WithCancel(context.Background())
defer cancel() // обязательно!

4. context.WithValue — спорный инструмент. Официальная документация рекомендует использовать его только для request-scoped данных (trace ID, auth tokens). Не передавайте через него опциональные параметры — это ухудшает читаемость и типобезопасность.

5. Проверяйте ctx.Done() в долгих операциях:

func longOperation(ctx context.Context) error {
for _, item := range items {
select {
case <-ctx.Done():
return ctx.Err() // context.Canceled или context.DeadlineExceeded
default:
process(item)
}
}
return nil
}

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

// Ошибка: забыли вызвать cancel
ctx, _ := context.WithTimeout(context.Background(), time.Second)
// cancel не вызван → утечка

// Ошибка: использование string как ключа
ctx = context.WithValue(ctx, "key", "value") // возможны коллизии

// Правильно: собственный тип
type key string
ctx = context.WithValue(ctx, key("myKey"), "value")

Вопрос 7. Зачем нужен Redis? В чём его преимущества и недостатки?

Таймкод: 00:16:36

Ответ собеседника: Правильный. Redis — это in-memory хранилище, данные хранятся в оперативной памяти, что обеспечивает очень быстрое чтение по сравнению с дисковыми базами данных (PostgreSQL и т.д.). Основное преимущество — скорость доступа к данных. Redis часто используется как кэш, чтобы разгрузить основную базу данных. Также может использоваться как полноценное хранилище, поскольку поддерживает персистентность — при выключении системы данные сохраняются на жёсткий диск и при включении обратно загружаются в оперативную память. Привёл пример использования — хранение координат для сервиса такси. В качестве улучшения можно было бы добавить стратегии инвалидации кэша и геопространственные возможности Redis.

Правильный ответ:

Ответ собеседника правильный. Дополню систематизацией и дополнительными сценариями использования.

Что такое Redis

Redis (Remote Dictionary Server) — это in-memory хранилище данных типа «ключ-значение» с поддержкой различных структур данных. Данные хранятся в оперативной памяти, что обеспечивает субмиллисекундную латентность.

Преимущества Redis

1. Скорость. Операции выполняются за микросекунды (O(1) для большинства операций). Нет обращений к диску при чтении/записи.

2. Богатые структуры данных:

ТипОписаниеПрименение
StringСтрока, число, битовая картаКэш, счётчики
HashХеш-таблицаОбъекты, профили
ListСвязный списокОчереди, ленты
SetНеупорядоченное множествоУникальные элементы, теги
Sorted SetМножество с весамиРейтинги, лидеры
StreamЖурнал событийMessage queue
HyperLogLogВероятностная структураПодсчёт уникальных элементов
GeoГеопространственные данныеЛокации, поиск рядом
BitmapБитовый массивФлаги, аналитика

3. Атомарность. Каждая операция атомарна. Поддержка транзакций (MULTI/EXEC) и Lua-скриптов.

4. Персистентность:

  • RDB (snapshotting) — периодические снимки данных на диск
  • AOF (append-only file) — журнал каждой операции записи
  • Гибридный режим (Redis 7+) — комбинация RDB и AOF

5. Кластеризация и масштабируемость:

  • Redis Sentinel — автоматический failover
  • Redis Cluster — шардирование данных на 16384 слотов
  • Master-Replica репликация

6. Pub/Sub. Встроенная система публикации/подписки для real-time коммуникации.

Недостатки Redis

1. Ограничение памяти. Данные хранятся в RAM, что делает Redis дорогим для больших объёмов данных. Максимальный размер ключа — 512 МБ, но практически ключи должны быть небольшими.

2. Сложность консистентности. Репликация asynchronous по умолчанию, возможна потеря данных при failover.

3. Однопоточность (частично). Основной поток однопоточный, что упрощает консистентность, но ограничивает throughput на одном инстансе. Redis 7+ поддерживает I/O threading.

4. Нет сложных запросов. Нет JOIN, нет полнотекстового поиска (без модуля RedisSearch), нет сложной фильтрации.

5. Стоимость. Для больших объёмов данных RAM значительно дороже диска.

Типичные сценарии использования

1. Кэширование:

func getUser(ctx context.Context, userID int) (*User, error) {
// Пробуем кэш
cacheKey := fmt.Sprintf("user:%d", userID)
cached, err := redisClient.Get(ctx, cacheKey).Result()
if err == nil {
var user User
json.Unmarshal([]byte(cached), &user)
return &user, nil
}

// Кэш пуст — идём в БД
user, err := db.GetUser(userID)
if err != nil {
return nil, err
}

// Сохраняем в кэш
data, _ := json.Marshal(user)
redisClient.Set(ctx, cacheKey, data, 5*time.Minute)

return user, nil
}

2. Rate Limiter:

func isAllowed(ctx context.Context, userID int) bool {
key := fmt.Sprintf("rate:%d", userID)
pipe := redisClient.Pipeline()
pipe.Incr(ctx, key)
pipe.Expire(ctx, key, time.Minute)
results, _ := pipe.Exec(ctx)

count := results[0].(*redis.IntCmd).Val()
return count <= 100 // 100 запросов в минуту
}

3. Распределённая блокировка:

func acquireLock(ctx context.Context, key string, ttl time.Duration) bool {
return redisClient.SetNX(ctx, key, "locked", ttl).Val()
}

func releaseLock(ctx context.Context, key string) {
redisClient.Del(ctx, key)
}

4. Сессии:

func saveSession(ctx context.Context, sessionID string, data map[string]string) error {
return redisClient.HSet(ctx, "session:"+sessionID, data).Err()
}

func getSession(ctx context.Context, sessionID string) (map[string]string, error) {
return redisClient.HGetAll(ctx, "session:"+sessionID).Result()
}

5. Рейтинг (Sorted Set):

// Добавить результат
redisClient.ZAdd(ctx, "leaderboard", redis.Z{Score: 100, Member: "player1"})

// Топ-10
top, _ := redisClient.ZRevRangeWithScores(ctx, "leaderboard", 0, 9).Result()

// Ранг игрока
rank := redisClient.ZRevRank(ctx, "leaderboard", "player1")

Стратегии инвалидации кэша

СтратегияОписаниеПлюсыМинусы
TTL (Time-To-Live)Автоматическое истечениеПростотаДанные могут быть устаревшими
Write-ThroughЗапись одновременно в БД и кэшКонсистентностьМедленная запись
Write-BehindЗапись в кэш, асинхронно в БДБыстрая записьРиск потери данных
Cache-AsideПриложение управляет кэшемГибкостьСложнее код
Event-BasedИнвалидация по событиямАктуальностьСложная архитектура

Когда использовать Redis, а когда нет

Использовать: кэширование, сессии, rate limiting, очереди, real-time аналитика, геопоиск, рейтинги, блокировки.

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

Вопрос 8. Что такое Apache Kafka? Для чего она используется и каковы её преимущества?

Таймкод: 00:17:54

Ответ собеседника: Правильный. Kafka — это стандарт индустрии для асинхронного общения между микросервисами, также называется шиной данных. Позволяет проводить стримы данных. Преимущества перед прямыми HTTP-запросами: при недоступности сервиса-получателя данные не теряются — Kafka буферизирует сообщения. Kafka с рождения шардируема и масштабируема, поддерживает консюмер-группы и партиции, что легко настраивается. Из минусов — сложнее разворачивать по сравнению с RabbitMQ. Упомянул личный опыт работы с RabbitMQ, но выразил предпочтение в пользу Kafka.

Правильный ответ:

Ответ собеседника правильный. Дополню архитектурными деталями и сравнением.

Что такое Apache Kafka

Apache Kafka — распределённая платформа потоковой обработки данных (event streaming platform). Изначально разработана в LinkedIn, затем передана Apache Software Foundation. Kafka хранит сообщения на диске и позволяет как публиковать, так и подписываться на потоки событий в реальном времени.

Ключевые понятия

ТерминОписание
TopicКатегория/канал сообщений (аналог таблицы)
PartitionШард топика, обеспечивает параллелизм и масштабируемость
ProducerПриложение, публикующее сообщения в топик
ConsumerПриложение, читающее сообщения из топика
Consumer GroupГруппа консьюмеров, совместно читающих топик
BrokerСервер Kafka в кластере
OffsetУникальный порядковый номер сообщения в партиции
Replication FactorКоличество реплик каждой партиции

Архитектура

Producer → Broker 1 (Topic A, Partition 0, 1)
→ Broker 2 (Topic A, Partition 2, 3)
→ Broker 3 (Topic A, Partition 4, 5)

Consumer Group X:
Consumer 1 → Partition 0, 1
Consumer 2 → Partition 2, 3
Consumer 3 → Partition 4, 5

Преимущества Kafka

1. Высокий throughput. Миллионы сообщений в секунду благодаря последовательной записи на диск и batching.

2. Устойчивость к сбоям. Репликация партиций между брокерами. Данные не теряются при отказе узла.

3. Долговременное хранение. Сообщения хранятся на диске (по умолчанию 7 дней, настраивается). Можно перечитать историю.

4. Масштабируемость. Добавление брокеров и партиций позволяет горизонтально масштабировать кластер.

5. Exactly-once semantics. Поддержка транзакций и идемпотентных продюсеров.

6. Обратное давление (backpressure). Консьюмер читает с нужной скоростью, продюсер не «затопит» его.

Сравнение Kafka vs RabbitMQ

КритерийKafkaRabbitMQ
МодельPull (консьюмер запрашивает)Push (брокер отправляет)
Хранение сообщенийДолговременное (диск)Обычно до доставки
ThroughputОчень высокийВысокий
Порядок сообщенийГарантирован в партицииГарантирован в одной очереди
МаршрутизацияПростая (топики)Гибкая (exchanges, bindings)
Сложность развёртыванияВысокая (нужен ZooKeeper/KRaft)Средняя
Use caseEvent streaming, логирование, метрикиОчереди задач, RPC, маршрутизация

Типичные сценарии использования

  • Event Sourcing — хранение всех изменений состояния как последовательности событий
  • Логирование и мониторинг — агрегация логов из множества сервисов
  • CDC (Change Data Capture) — стриминг изменений из БД
  • Микросервисная интеграция — асинхронное взаимодействие между сервисами
  • Real-time аналитика — обработка событий в реальном времени (Kafka Streams, Flink)

Пример на Go (producer):

import "github.com/segmentio/kafka-go"

func produceMessage() {
writer := kafka.NewWriter(kafka.WriterConfig{
Brokers: []string{"localhost:9092"},
Topic: "orders",
})
defer writer.Close()

writer.WriteMessages(context.Background(),
kafka.Message{
Key: []byte("order-123"),
Value: []byte(`{"id": 123, "amount": 99.99}`),
},
)
}

Пример на Go (consumer):

func consumeMessages() {
reader := kafka.NewReader(kafka.ReaderConfig{
Brokers: []string{"localhost:9092"},
Topic: "orders",
GroupID: "order-processors",
})
defer reader.Close()

for {
msg, err := reader.ReadMessage(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Printf("Partition: %d, Offset: %d, Key: %s, Value: %s\n",
msg.Partition, msg.Offset, msg.Key, msg.Value)
}
}

Гарантии доставки

УровеньОписаниеНастройка
At-most-onceСообщение может быть потеряноacks=0
At-least-onceСообщение доставится, возможно с дубликатамиacks=1, ручной offset commit
Exactly-onceСообщение доставится ровно один разacks=all, идемпотентный продюсер, транзакции

Когда выбирать Kafka, а не RabbitMQ

Kafka: нужен event streaming, высокий throughput, долговременное хранение, перечитывание истории, потоковая обработка.

RabbitMQ: нужны сложные маршрутизации, приоритетные очереди, RPC-паттерн, простота развёртывания, гарантия доставки для каждой задачи.

Вопрос 9. Как в Go перехватить и обработать панику?

Таймкод: 00:00:32

Ответ собеседника: Правильный. В Go нет исключений в классическом понимании. Для перехвата паники используется функция recover(), которая вызывается внутри defer. Это позволяет предотвратить аварийное завершение программы. Пояснил, что recover() должен вызываться только в defer, и рекомендовал использовать его на верхнем уровне приложения.

Правильный ответ:

Ответ собеседника правильный. Дополню деталями и практическими паттернами.

Паника и recover: базовый механизм

В Go нет исключений. Вместо них есть паника — механизм, который прерывает нормальный поток выполнения и разворачивает стек вызовов, вызывая defer-функции на каждом уровне.

func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered from panic:", r)
}
}()

panic("something went wrong")
fmt.Println("This will never be printed")
}
// Output: Recovered from panic: something went wrong

Ключевые правила

1. recover() работает только внутри defer:

// Неправильно — recover вернёт nil
func bad() {
recover() // ничего не сделает
panic("oops")
}

// Правильно
func good() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Caught:", r)
}
}()
panic("oops")
}

2. recover() возвращает interface{} (или any):

defer func() {
r := recover()
switch v := r.(type) {
case string:
fmt.Println("string panic:", v)
case error:
fmt.Println("error panic:", v)
case int:
fmt.Println("int panic:", v)
default:
fmt.Println("unknown panic:", v)
}
}()

3. Паника в горутине убивает только эту горутину:

func main() {
go func() {
panic("goroutine panic") // убьёт всю программу!
}()
time.Sleep(time.Second)
}

Каждая горутина должна иметь свой собственный recover:

func main() {
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Goroutine recovered:", r)
}
}()
panic("goroutine panic") // только эта горутина упадёт
}()
time.Sleep(time.Second)
fmt.Println("Main continues")
}

Практические паттерны

1. Middleware для HTTP-сервера:

func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if r := recover(); r != nil {
log.Printf("Panic: %v\n%s", r, debug.Stack())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}

2. Обёртка для горутин:

func safeGo(f func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("Panic in goroutine: %v\n%s", r, debug.Stack())
}
}()
f()
}()
}

// Использование
safeGo(func() {
// потенциально опасный код
panic("oops")
})

3. Паттерн worker с восстановлением:

func worker(id int, jobs <-chan Job, results chan<- Result) {
for job := range jobs {
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("Worker %d panic on job %v: %v", id, job, r)
results <- Result{Error: fmt.Errorf("panic: %v", r)}
}
}()
results <- process(job)
}()
}
}

4. Паника vs ошибка — когда что использовать:

// Паника — для программерских ошибок (багов)
func getElement(arr []int, index int) int {
if index < 0 || index >= len(arr) {
panic(fmt.Sprintf("index %d out of bounds [0, %d)", index, len(arr)))
}
return arr[index]
}

// Ошибка — для ожидаемых проблем
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}

Правило: паника — для ситуаций, которые не должны происходить (инварианты, баги). Ошибки — для ожидаемых проблем (сеть, файл не найден, невалидный ввод).

5. Создание собственных паник:

// С ошибкой
func must(err error) {
if err != nil {
panic(err)
}
}

func main() {
file := must(os.Open("config.json")).(*os.File)
defer file.Close()
}

// С проверкой
func check(cond bool, msg string) {
if !cond {
panic(msg)
}
}

Отладка паники

import "runtime/debug"

defer func() {
if r := recover(); r != nil {
fmt.Println("Panic:", r)
fmt.Println("Stack trace:")
fmt.Println(string(debug.Stack()))
}
}()

Чего нельзя делать с recover

  • Нельзя передать управление в другую функцию после recover() — функция, в которой была паника, всё равно завершится
  • Нельзя вызвать recover() из другой функции (не из defer текущего фрейма)
  • Нельзя использовать recover() как замену обработки ошибок — это антипаттерн
// Антипаттерн
func doWork() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
riskyOperation()
return nil
}

// Лучше: не использовать панику вообще
func doWork() error {
return riskyOperation() // возвращать error
}

Вопрос 10. Использовал ли моки для тестирования? Какие инструменты применял?

Таймкод: 00:20:53

Ответ собеседника: Неполный. Использовал моки, генерировал их автоматически с помощью инструмента, но не смог вспомнить название конкретного инструмента (предположительно имелся в виду mockery). Моки использовались для подмены сервисов в юнит-тестах, что соответствует пятому принципу SOLID — Dependency Inversion. Сам мокер (утилиту для генерации) не писал — всё делалось автоматически.

Правильный ответ:

Ответ собеседника в целом правильный, но неполный. Дополню полным обзором инструментов и практик мокирования в Go.

Основные инструменты для мокирования в Go

1. testify/mock — самый популярный

import "github.com/stretchr/testify/mock"

// Интерфейс
type UserRepository interface {
GetByID(ctx context.Context, id int) (*User, error)
Save(ctx context.Context, user *User) error
}

// Мок (пишется вручную или генерируется)
type MockUserRepository struct {
mock.Mock
}

func (m *MockUserRepository) GetByID(ctx context.Context, id int) (*User, error) {
args := m.Called(ctx, id)
return args.Get(0).(*User), args.Error(1)
}

func (m *MockUserRepository) Save(ctx context.Context, user *User) error {
args := m.Called(ctx, user)
return args.Error(0)
}

// Использование в тесте
func TestUserService_GetUser(t *testing.T) {
mockRepo := new(MockUserRepository)
service := NewUserService(mockRepo)

expectedUser := &User{ID: 1, Name: "John"}
mockRepo.On("GetByID", context.Background(), 1).Return(expectedUser, nil)

user, err := service.GetUser(context.Background(), 1)

assert.NoError(t, err)
assert.Equal(t, expectedUser, user)
mockRepo.AssertExpectations(t)
}

2. mockery — генератор моков

Утилина CLI, которая автоматически генерирует моки из интерфейсов:

# Установка
go install github.com/vektra/mockery/v2@latest

# Генерация моков для всех интерфейсов в проекте
mockery --all --output=./mocks

# Генерация для конкретного интерфейса
mockery --name=UserRepository --output=./mocks

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

3. gomock (official Google)

import "github.com/golang/mock/gomock"

func TestUserService_GetUser(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockRepo := NewMockUserRepository(ctrl)
mockRepo.EXPECT().GetByID(gomock.Any(), 1).Return(&User{ID: 1, Name: "John"}, nil)

service := NewUserService(mockRepo)
user, err := service.GetUser(context.Background(), 1)

assert.NoError(t, err)
assert.Equal(t, "John", user.Name)
}

Генерация через mockgen:

go install github.com/golang/mock/mockgen@latest
mockgen -source=repository.go -destination=mocks/mock_repository.go

4. uber-go/mock (форк gomock)

Активно поддерживаемый форк gomock от Uber:

go install go.uber.org/mock/mockgen@latest

5. miniredis — мок для Redis

import "github.com/alicebob/miniredis/v2"

func TestCache(t *testing.T) {
s := miniredis.RunT(t) // запускает in-memory Redis
client := redis.NewClient(&redis.Options{Addr: s.Addr()})

client.Set(ctx, "key", "value", time.Minute)
val, _ := client.Get(ctx, "key").Result()
assert.Equal(t, "value", val)
}

6. httptest — мок для HTTP

func TestHTTPClient(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}))
defer server.Close()

resp, err := http.Get(server.URL)
// ...
}

7. sqlmock — мок для базы данных

import "github.com/DATA-DOG/go-sqlmock"

func TestRepository(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()

mock.ExpectQuery("SELECT id, name FROM users WHERE id = ?").
WithArgs(1).
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(1, "John"))

repo := NewUserRepository(db)
user, err := repo.GetByID(1)

assert.NoError(t, err)
assert.Equal(t, "John", user.Name)
assert.NoError(t, mock.ExpectationsWereMet())
}

Сравнение подходов к мокированию

ПодходПлюсыМинусы
testify/mockПростой, популярныйРутинная ручная работа или доп. инструмент
mockery + testifyАвтогенерация, удобствоДополнительная зависимость
gomock/uber-go/mockСтрогие ожидания, Google-styleБолее многословный API
miniredis/httptestРеалистичное поведениеМедленнее чистых моков
sqlmockБез реальной БДПривязка к SQL-синтаксису

Лучшие практики

1. Мокайте интерфейсы, а не реализации:

// Правильно: сервис зависит от интерфейса
type UserService struct {
repo UserRepository // интерфейс
}

// Неправильно: мокать конкретную структуру
type UserService struct {
repo *PostgresUserRepository // конкретный тип
}

2. Не мокайте то, что не нужно мокать. Простые value objects и DTO не требуют мокирования.

3. Используйте AssertExpectations, чтобы убедиться, что все ожидаемые вызовы произошли:

mockRepo.AssertExpectations(t)

4. Моки vs стабы: моки проверяют поведение (какие методы вызывались), стабы — только возвращают заданные значения. Для юнит-тестов чаще достаточно стабов.

// Стаб — проще, если не нужно проверять вызовы
type StubUserRepository struct{}

func (s *StubUserRepository) GetByID(ctx context.Context, id int) (*User, error) {
return &User{ID: id, Name: "Test User"}, nil
}

Вопрос 11. Использовал ли инструменты мониторинга и логирования (Prometheus, Grafana, Kibana, Jaeger)?

Таймкод: 00:21:53

Ответ собеседника: Неполный. Использовал Grafana для просмотра логов и метрик нагрузки (TCP-сокеты, нагрузка на диск и т.д. — около 50 графиков). Jaeger использовал в меньшей степени. При этом графики в Grafana не настраивал сам — за это отвечал другой человек, а он лишь вносил небольшие правки. Также добавлял кастомные метрики в сервисы, но поднимать инфраструктуру мониторинга с нуля не занимался.

Правильный ответ:

Ответ собеседника демонстрирует практический опыт использования инструментов мониторинга, хотя и не глубокий. Дополню полным обзором экосистемы наблюдаемости (observability).

Три столпа наблюдаемости

1. Метрики (Prometheus + Grafana)

Prometheus — система сбора и хранения метрик. Grafana — визуализация.

Типы метрик в Prometheus:

ТипОписаниеПример
CounterМонотонно возрастающий счётчикКоличество запросов
GaugeЗначение, которое может расти и падатьТекущее количество горутин
HistogramРаспределение значений по бакетамВремя ответа (p50, p95, p99)
SummaryКвантили на стороне клиентаАналог histogram, но по-другому

Интеграция с Go:

import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
httpRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of HTTP requests",
},
[]string{"method", "endpoint", "status"},
)

httpRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request duration in seconds",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "endpoint"},
)

activeConnections = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "active_connections",
Help: "Number of active connections",
},
)
)

func metricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
activeConnections.Inc()
defer activeConnections.Dec()

next.ServeHTTP(w, r)

duration := time.Since(start).Seconds()
httpRequestsTotal.WithLabelValues(r.Method, r.URL.Path, "200").Inc()
httpRequestDuration.WithLabelValues(r.Method, r.URL.Path).Observe(duration)
})
}

func main() {
http.Handle("/metrics", promhttp.Handler())
http.ListenAndServe(":8080", nil)
}

Настройка Prometheus (prometheus.yml):

global:
scrape_interval: 15s

scrape_configs:
- job_name: 'my-service'
static_configs:
- targets: ['localhost:8080']

2. Логирование (ELK/EFK стек)

  • Elasticsearch — хранение и индексация логов
  • Logstash/Fluentd — сбор и обработка логов
  • Kibana — визуализация и поиск по логам

Структурированное логирование в Go:

import "go.uber.org/zap"

func setupLogger() *zap.Logger {
config := zap.NewProductionConfig()
config.EncoderConfig.TimeKey = "timestamp"
config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder

logger, _ := config.Build()
return logger
}

func handler(logger *zap.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
logger.Info("incoming request",
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.String("remote_addr", r.RemoteAddr),
zap.String("request_id", r.Header.Get("X-Request-ID")),
)
}
}

3. Трейсинг (Jaeger / OpenTelemetry)

Jaeger — распределённая трассировка запросов через микросервисы.

Интеграция с Go:

import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/jaeger"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.17.0"
)

func initTracer() (*sdktrace.TracerProvider, error) {
exporter, err := jaeger.New(jaeger.WithCollectorEndpoint(
jaeger.WithEndpoint("http://localhost:14268/api/traces"),
))
if err != nil {
return nil, err
}

tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName("my-service"),
)),
)
otel.SetTracerProvider(tp)
return tp, nil
}

func handler(w http.ResponseWriter, r *http.Request) {
ctx, span := otel.Tracer("my-service").Start(r.Context(), "handler")
defer span.End()

// Добавляем атрибуты
span.SetAttributes(
semconv.HTTPMethod(r.Method),
semconv.HTTPURL(r.URL.Path),
)

processRequest(ctx)
}

OpenTelemetry — современный стандарт

OpenTelemetry (OTel) объединяет метрики, логи и трейсы в единую систему:

import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
)

func initOTel() {
client := otlptracegrpc.NewClient(
otlptracegrpc.WithEndpoint("otel-collector:4317"),
otlptracegrpc.WithInsecure(),
)
exporter, _ := otlptrace.New(context.Background(), client)

tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter))
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.TraceContext{})
}

Связка инструментов

Приложение
├── Prometheus metrics (/metrics) → Prometheus → Grafana (дашборды)
├── Structured logs → Fluentd → Elasticsearch → Kibana (поиск логов)
└── OpenTelemetry traces → Jaeger/Tempo → Grafana (трассировка)

Практические рекомендации

1. RED-метрики для сервисов:

  • Rate — количество запросов в секунду
  • Errors — процент ошибок
  • Duration — время ответа (p50, p95, p99)

2. USE-метрики для ресурсов:

  • Utilization — утилизация (CPU, RAM, диск)
  • Saturation — нагрузка (очереди, ожидание)
  • Errors — ошибки (сетевые, I/O)

3. Алертинг:

# prometheus alerting rules
groups:
- name: service-alerts
rules:
- alert: HighErrorRate
expr: rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.05
for: 5m
labels:
severity: critical
annotations:
summary: "High error rate detected"

4. Correlation ID для связи логов и трейсов:

func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = uuid.New().String()
}

ctx := context.WithValue(r.Context(), "request_id", requestID)
w.Header().Set("X-Request-ID", requestID)

next.ServeHTTP(w, r.WithContext(ctx))
})
}

Итог: собеседник имеет опыт работы с Grafana и Jaeger на уровне потребителя и добавления кастомных метрик. Для позиции senior-разработчика желательно также уметь настраивать инфраструктуру мониторинга с нуля, писать алерты и настраивать распределённую трассировку.

Вопрос 12. Каковы преимущества и недостатки gRPC?

Таймкод: 00:23:00

Ответ собеседника: Правильный. Преимущества gRPC: двоичная передача данных (меньший объём по сравнению с JSON), встроенная генерация кода из коробки (через Protocol Buffers), что упрощает интеграцию. Недостатки: не все разработчики умеют работать с gRPC, поэтому при написании внешних интеграций иногда приходилось соглашаться на REST, так как любого разработчика проще посадить за REST-сервис, чем за gRPC.

Правильный ответ:

Ответ собеседника правильный. Дополню систематизацией и техническими деталями.

Что такое gRPC

gRPC (gRPC Remote Procedure Call) — фреймворк для удалённого вызова процедур, разработанный Google. Использует HTTP/2 как транспорт и Protocol Buffers (protobuf) как формат сериализации.

Преимущества gRPC

1. Производительность. Бинарный формат protobuf в 3-10 раз компактнее JSON и в 5-10 раз быстрее при сериализации/десериализации:

JSON: {"id": 12345, "name": "John Doe", "email": "john@example.com"} // ~60 байт
protobuf: [08 91 4E 12 08 4A 6F 68 6E 20 44 6F 65 1A 11 6A 6F 68 6E 40 ...] // ~25 байт

2. Генерация кода. Из .proto файла автоматически генерируется клиентский и серверный код на множестве языков:

protoc --go_out=. --go-grpc_out=. service.proto

3. Строгая типизация. Контракт API определён в .proto файле, что исключает ошибки с типами данных:

syntax = "proto3";

service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc CreateUser(CreateUserRequest) returns (User);
rpc ListUsers(ListUsersRequest) returns (stream User); // серверный стриминг
}

message User {
int64 id = 1;
string name = 2;
string email = 3;
repeated string roles = 4;
}

message GetUserRequest {
int64 id = 1;
}

4. Четыре типа взаимодействия:

ТипОписаниеПример
UnaryЗапрос → ОтветПолучить пользователя по ID
Server StreamingЗапрос → Поток ответовПодписка на обновления
Client StreamingПоток запросов → ОтветЗагрузка файла
Bidirectional StreamingПоток ↔ ПотокЧат

5. HTTP/2 из коробки:

  • Multiplexing — множественные запросы через одно соединение
  • Header compression (HPACK)
  • Server push
  • Flow control

6. Перехватчики (interceptors):

func loggingInterceptor(ctx context.Context, req interface{},
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
start := time.Now()
resp, err := handler(ctx, req)
log.Printf("Method: %s, Duration: %v, Error: %v",
info.FullMethod, time.Since(start), err)
return resp, err
}

server := grpc.NewServer(
grpc.UnaryInterceptor(loggingInterceptor),
)

Недостатки gRPC

1. Сложность для фронтенда. Браузеры не поддерживают gRPC напрямую. Нужен grpc-web или прокси (envoy).

2. Отладка. Бинарный формат неудобен для ручного тестирования. Нужны специальные инструменты (grpcurl, BloomRPC, Postman):

# Вместо curl
grpcurl -plaintext localhost:50051 list
grpcurl -plaintext -d '{"id": 1}' localhost:50051 UserService/GetUser

3. Кривая обучения. Разработчики должны знать protobuf, понимать HTTP/2, уметь работать с генерацией кода.

4. Ограниченная экосистема. Не все прокси, CDN и API-шлюзы поддерживают gRPC.

5. Сложность версионирования. Изменения в .proto требуют перегенерации кода и координации между командами.

gRPC vs REST: когда что выбирать

КритерийgRPCREST
ПроизводительностьВысокая (binary + HTTP/2)Средняя (JSON + HTTP/1.1)
Генерация кодаДаНет (OpenAPI частично)
Браузерная поддержкаОграниченнаяПолная
ОтладкаСложнаяПростая (curl, Postman)
StreamingВстроенныйНет (WebSocket отдельно)
КонтрактСтрогий (.proto)Опциональный (OpenAPI)
ПрименимостьМикросервисы (внутренние)Публичные API, фронтенд

Практический пример на Go:

// Сервер
type server struct {
pb.UnimplementedUserServiceServer
}

func (s *server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
user, err := repository.GetUser(req.Id)
if err != nil {
return nil, status.Errorf(codes.NotFound, "user not found: %v", err)
}
return &pb.User{
Id: user.ID,
Name: user.Name,
Email: user.Email,
}, nil
}

func main() {
lis, _ := net.Listen("tcp", ":50051")
s := grpc.NewServer()
pb.RegisterUserServiceServer(s, &server{})
s.Serve(lis)
}

// Клиент
func main() {
conn, _ := grpc.Dial("localhost:50051", grpc.WithInsecure())
defer conn.Close()
client := pb.NewUserServiceClient(conn)

user, _ := client.GetUser(context.Background(), &pb.GetUserRequest{Id: 1})
fmt.Println(user)
}

Рекомендация: используйте gRPC для внутренней коммуникации между микросервисами, где важны производительность и строгий контракт. Для публичных API и интеграции с фронтендом — REST.

Вопрос 13. В чём преимущества монолита и микросервисов?

Таймкод: 00:24:11

Ответ собеседника: Правильный. Преимущества монолита: удобен для старта при малом объёме кода и одной команда; более высокая производительность, так как взаимодействие внутри одного рантайма быстрее, чем межсервисное общение по сети (даже через gRPC); проще тестирование — легко поставить моки и написать интеграционные тесты. Преимущества микросервисов: разделение команд (независимые деплои и релизы), независимое масштабирование нагрузки (можно выделить больше ресурсов под конкретный сервис), пример Авито — около 1000 релизов в день благодаря микросервисной архитектуре. Рекомендовал начинать с монолита, если нет особых причин для микросервисов.

Правильный ответ:

Ответ собеседника полный и правильный. Дополню систематизацией и промежуточными паттернами.

Сравнение архитектур

КритерийМонолитМикросервисы
Сложность стартаНизкаяВысокая
ПроизводительностьВысокая (in-process)Ниже (сетевые вызовы)
МасштабированиеВертикальное / всё целикомГоризонтальное / по частям
ДеплойОдин артефактМножество независимых
ТестированиеПрощеСложнее (контрактные тесты)
НадёжностьОдна точка отказаИзоляция сбоев
ТехдолгНакапливаетсяМожно переписывать по частям
КомандыОдна командаМножество автономных команд

Преимущества монолита

1. Простота разработки и отладки. Вешь код в одном процессе — легко проследить цепочку вызовов:

// Монолит: простой вызов функции
result := ordersService.CreateOrder(ctx, req)
inventoryService.Reserve(ctx, req.Items)
paymentService.Charge(ctx, req.Payment)

2. Атомарные транзакции. Одна база данных — одна транзакция:

tx, _ := db.Begin()
orderRepo.Save(tx, order)
inventoryRepo.Reserve(tx, items)
tx.Commit() // всё или ничего

3. Простота деплоя. Один бинарный файл или контейнер.

4. Производительность. Нет сетевых вызовов, сериализации, маршрутизации.

Недостатки монолита

  • Сложно масштабировать отдельные части
  • Разрастается кодовая база
  • Один сбой может уронить всё
  • Сложнее вносить изменения в больших командах
  • Привязка к одному стеку технологий

Преимущества микросервисов

1. Независимость команд. Каждая команда владеет своим сервисом, самостоятельно выбирает стек, график релизов.

2. Гранулярное масштабирование:

Нагрузка: OrderService × 10, AuthService × 2, NotificationService × 1

3. Изоляция сбоев. Если упал payment-service, каталог товаров продолжает работать.

4. Технологическое разнообразие. Можно использовать разные языки и базы данных для разных сервисов.

5. Независимый деплой. Можно выпускать изменения в одном сервисе без передеплоя остальных.

Недостатки микросервисов

1. Распределённые транзакции. Вместо одной транзакции — саги и eventual consistency:

// Сага: компенсирующие действия
func CreateOrderSaga(ctx context.Context, req OrderRequest) error {
// Шаг 1: создать заказ
order, err := orderService.Create(ctx, req)
if err != nil {
return err
}

// Шаг 2: зарезервировать товары
err = inventoryService.Reserve(ctx, req.Items)
if err != nil {
// Компенсация: отменить заказ
orderService.Cancel(ctx, order.ID)
return err
}

// Шаг 3: списать деньги
err = paymentService.Charge(ctx, req.Payment)
if err != nil {
// Компенсация
inventoryService.Release(ctx, req.Items)
orderService.Cancel(ctx, order.ID)
return err
}

return nil
}

2. Сложность инфраструктуры. Нужны service discovery, API gateway, circuit breakers, distributed tracing.

3. Латентность сети. Каждый межсервисный вызов — это сетевой запрос с задержкой.

4. Сложность тестирования. Нужны контрактные тесты, тестовая среда со всеми сервисами.

Промежуточные паттерны

1. Modular Monolith — монолит с чёткими модульными границами:

monolith/
├── modules/
│ ├── orders/ # свой пакет, свой интерфейс
│ ├── inventory/
│ └── payments/
├── internal/
└── cmd/

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

2. Service-Oriented Architecture (SOA) — сервисы крупнее, чем в микросервисах, общение через ESB (Enterprise Service Bus).

3. Strangler Fig Pattern — постепенная миграция от монолита к микросервисам:

API Gateway
/ | \
Service A | Monolith (постепенно уменьшается)
(новое) | |
Service B |
(новое) |
...

Рекомендация (подтверждённая собеседником):

Начинайте с монолита (или модульного монолита). Переходите к микросервисам, когда:

  • Команда выросла и работает над независимыми частями системы
  • Нужно масштабировать отдельные компоненты по-разному
  • Разные части системы имеют разные требования к доступности
  • Есть ресурсы на поддержку инфраструктуры

Как говорит Мартин Фаулер: «Don't start with microservices — start with a monolith, then split when it hurts.»

Вопрос 14. Что такое паттерн Saga? Какие виды саг существуют?

Таймкод: 00:26:20

Ответ собеседника: Правильный. Сага — это паттерн для управления распределёнными транзакциями между микросервисами. В монолите можно откатить транзакцию одной командой (ROLLBACK), а в микросервисах это невозможно, поэтому для каждого шага цепочки нужно создавать компенсирующее событие. Два вида саг: оркестрация — есть центральный контроллер (дирижёр), который управляет всеми микросервисами и обрабатывает их ответы; хореография — децентрализованная система, где каждый микросервис сам реагирует на события, публикуемые другими микросервисами.

Правильный ответ:

Ответ собеседника полный и правильный. Дополню примерами кода и деталями реализации.

Суть паттерна Saga

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

Пример бизнес-процесса: создание заказа

1. OrderService: создать заказ (статус: PENDING)
2. InventoryService: зарезервировать товары
3. PaymentService: списать деньги
4. OrderService: подтвердить заказ (статус: CONFIRMED)

Если шаг 3 не прошёл:
3. InventoryService: освободить резерв (компенсация)
4. OrderService: отменить заказ (статус: CANCELLED)

1. Сага на основе оркестрации (Orchestration)

Есть центральный оркестратор, который управляет потоком:

type OrderSagaOrchestrator struct {
orderService OrderService
inventoryService InventoryService
paymentService PaymentService
eventBus EventBus
}

func (o *OrderSagaOrchestrator) Execute(ctx context.Context, req CreateOrderRequest) error {
// Шаг 1: Создать заказ
order, err := o.orderService.Create(ctx, req)
if err != nil {
return fmt.Errorf("create order failed: %w", err)
}

// Шаг 2: Зарезервировать товары
err = o.inventoryService.Reserve(ctx, order.ID, req.Items)
if err != nil {
// Компенсация: отменить заказ
o.orderService.Cancel(ctx, order.ID)
return fmt.Errorf("reserve inventory failed: %w", err)
}

// Шаг 3: Списать деньги
err = o.paymentService.Charge(ctx, order.ID, req.Payment)
if err != nil {
// Компенсация: освободить резерв и отменить заказ
o.inventoryService.Release(ctx, order.ID, req.Items)
o.orderService.Cancel(ctx, order.ID)
return fmt.Errorf("charge payment failed: %w", err)
}

// Шаг 4: Подтвердить заказ
return o.orderService.Confirm(ctx, order.ID)
}

Состояние саги (для надёжности):

type SagaState struct {
ID string
Status SagaStatus // PENDING, COMPLETED, COMPENSATING, COMPENSATED
Steps []StepState
CreatedAt time.Time
}

type StepState struct {
Name string
Status StepStatus // PENDING, COMPLETED, FAILED, COMPENSATED
Compensate func() error
}

func (s *SagaState) Execute() error {
for i := range s.Steps {
err := s.Steps[i].Execute()
if err != nil {
s.Status = COMPENSATING
// Компенсируем предыдущие шаги в обратном порядке
for j := i - 1; j >= 0; j-- {
s.Steps[j].Compensate()
}
s.Status = COMPENSATED
return err
}
}
s.Status = COMPLETED
return nil
}

2. Сага на основе хореографии (Choreography)

Нет центрального контроллера. Сервисы реагируют на события:

// OrderService
func (s *OrderService) HandleOrderCreated(ctx context.Context, event OrderCreatedEvent) error {
err := s.inventoryClient.Reserve(ctx, event.Items)
if err != nil {
s.eventBus.Publish(ctx, OrderCancelledEvent{OrderID: event.OrderID})
return err
}
return nil
}

// InventoryService
func (s *InventoryService) HandleInventoryReserved(ctx context.Context, event InventoryReservedEvent) error {
err := s.paymentClient.Charge(ctx, event.OrderID, event.Amount)
if err != nil {
s.eventBus.Publish(ctx, InventoryReleaseEvent{OrderID: event.OrderID})
return err
}
return nil
}

// PaymentService
func (s *PaymentService) HandlePaymentCharged(ctx context.Context, event PaymentChargedEvent) error {
return s.orderClient.Confirm(ctx, event.OrderID)
}

// Обработка ошибок через события
func (s *OrderService) HandlePaymentFailed(ctx context.Context, event PaymentFailedEvent) error {
s.eventBus.Publish(ctx, InventoryReleaseEvent{OrderID: event.OrderID})
s.eventBus.Publish(ctx, OrderCancelledEvent{OrderID: event.OrderID})
return nil
}

Сравнение подходов

КритерийОркестрацияХореография
ЦентрализацияДа (оркестратор)Нет
Сложность отладкиПроще (одна точка)Сложнее (распределена)
СвязанностьВыше (оркестратор знает о всех)Ниже (сервисы автономны)
Точка отказаОркестраторНет
Сложность добавления шагаИзменить оркестраторДобавить новый обработчик
Визуализация потокаПростоСложно

Паттерн Outbox для надёжности

Проблема: как гарантировать, что событие будет опубликовано после сохранения в БД?

Решение: записывать событие в ту же транзакцию, что и данные:

-- Таблица заказов
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);

-- Outbox-таблица
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_type VARCHAR(100) NOT NULL,
aggregate_id BIGINT NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
published_at TIMESTAMP
);
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error) {
tx, _ := s.db.BeginTx(ctx, nil)
defer tx.Rollback()

// 1. Сохранить заказ
order := &Order{Status: "PENDING"}
tx.QueryRowContext(ctx,
"INSERT INTO orders (status) VALUES ($1) RETURNING id",
order.Status).Scan(&order.ID)

// 2. Записать событие в outbox (той же транзакцией)
payload, _ := json.Marshal(OrderCreatedEvent{OrderID: order.ID, Items: req.Items})
tx.ExecContext(ctx,
"INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload) VALUES ($1, $2, $3, $4)",
"order", order.ID, "OrderCreated", payload)

tx.Commit() // Оба действия атомарны

return order, nil
}

// Отдельный процесс забирает события из outbox и публикует в Kafka
func (p *OutboxPoller) Poll() {
rows, _ := p.db.Query(
"SELECT id, payload FROM outbox WHERE published_at IS NULL ORDER BY id LIMIT 100")

for rows.Next() {
var id int64
var payload []byte
rows.Scan(&id, &payload)

p.kafkaProducer.Send("orders", payload)
p.db.Exec("UPDATE outbox SET published_at = NOW() WHERE id = $1", id)
}
}

Рекомендации:

  • Оркестрация — для сложных процессов с множеством шагов и условий
  • Хореография — для простых процессов и когда важна автономность сервисов
  • Outbox — всегда используйте для гарантии доставки событий
  • Идемпотентность — обработчики событий должны быть идемпотентны (повторная обработка не должна ломать данные)

Вопрос 15. Что такое OAuth 2.0?

Таймкод: 00:27:40

Ответ собеседника: Правильный. OAuth 2.0 — это стандарт (готовое решение) для авторизации. Разделил понятия аутентификация и авторизация. Процесс: сначала регистрация клиента (client ID, client secret), затем запрос авторизации — получение кода, который обменивается на access-токен. С этим токеном клиент обращается к серверу. Для обновления токена используется refresh-токен. Всё это реализуется через JWT-токены.

Правильный ответ:

Ответ собеседника правильный. Дополню деталями о flow-ах и терминологии.

OAuth 2.0 — протокол авторизации

OAuth 2.0 — это стандарт (RFC 6749), который позволяет приложению получать ограниченный доступ к ресурсам пользователя на другом сервисе, без передачи логина и пароля.

Важное уточнение: OAuth 2.0 — это протокол авторизации (authorization), не аутентификации (authentication). Для аутентификации поверх OAuth используется OpenID Connect (OIDC).

Ключевые роли

РольОписание
Resource OwnerПользователь, который разрешает доступ
ClientПриложение, запрашивающее доступ
Authorization ServerСервер, выдающий токены (Google, Auth0, Keycloak)
Resource ServerAPI, защищённый токенами

Основные Flow (потоки авторизации)

1. Authorization Code Flow (самый безопасный, для веб-приложений)

1. Клиент → Пользователь: перенаправляет на Authorization Server
2. Пользователь вводит логин/пароль на Authorization Server
3. Authorization Server → Клиент: перенаправляет с authorization code
4. Клиент → Authorization Server: обменивает code на access_token
5. Authorization Server → Клиент: возвращает access_token + refresh_token
6. Клиент → Resource Server: запрос с access_token
// Пример на Go
func authHandler(w http.ResponseWriter, r *http.Request) {
url := oauthConfig.AuthCodeURL("state", oauth2.AccessTypeOffline)
http.Redirect(w, r, url, http.StatusFound)
}

func callbackHandler(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
token, err := oauthConfig.Exchange(context.Background(), code)
if err != nil {
http.Error(w, "Failed to exchange token", http.StatusInternalServerError)
return
}
// token.AccessToken, token.RefreshToken
}

2. Client Credentials Flow (для сервер-серверного взаимодействия)

Клиент → Authorization Server: client_id + client_secret
Authorization Server → Клиент: access_token
func getClientCredentialsToken() (*oauth2.Token, error) {
config := &clientcredentials.Config{
ClientID: "my-client-id",
ClientSecret: "my-client-secret",
TokenURL: "https://auth.example.com/token",
}
return config.Token(context.Background())
}

3. Implicit Flow (устаревший, для SPA) — не рекомендуется, заменён на Authorization Code + PKCE

4. Resource Owner Password Credentials (устаревший) — только для доверенных приложений

5. Device Authorization Flow — для устройств без браузера (Smart TV, CLI)

Типы токенов

Access Token — кратковременный токен для доступа к API (обычно 15-60 минут):

// JWT access token (header.payload.signature)
// Payload:
{
"sub": "user-123",
"iss": "https://auth.example.com",
"aud": "https://api.example.com",
"exp": 1699999999,
"scope": "read:orders write:orders"
}

Refresh Token — долговременный токен для получения нового access token:

func refreshAccessToken(refreshToken string) (*oauth2.Token, error) {
token := &oauth2.Token{
RefreshToken: refreshToken,
}
return oauthConfig.TokenSource(context.Background(), token).Token()
}

PKCE (Proof Key for Code Exchange) — расширение для защиты от перехвата authorization code:

import "golang.org/x/oauth2"

// Генерируем code_verifier и code_challenge
verifier := oauth2.GenerateVerifier()
challenge := oauth2.S256ChallengeFromVerifier(verifier)

// Отправляем challenge при авторизации
url := oauthConfig.AuthCodeURL("state",
oauth2.SetAuthURLParam("code_challenge", challenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"))

// Отправляем verifier при обмене на токен
token, err := oauthConfig.Exchange(ctx, code,
oauth2.SetAuthURLParam("code_verifier", verifier))

OpenID Connect (OIDC) — надстройка над OAuth 2.0 для аутентификации:

OAuth 2.0: даёт access_token для доступа к API
OIDC: добавляет id_token (JWT с информацией о пользователе)
// id_token payload
{
"sub": "user-123",
"name": "John Doe",
"email": "john@example.com",
"email_verified": true,
"picture": "https://example.com/avatar.jpg"
}

Проверка токена на Resource Server:

import "github.com/golang-jwt/jwt/v5"

func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenString := extractBearerToken(r)
if tokenString == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}

token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return publicKey, nil
})
if err != nil || !token.Valid {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}

claims := token.Claims.(jwt.MapClaims)
ctx := context.WithValue(r.Context(), "userID", claims["sub"])
next.ServeHTTP(w, r.WithContext(ctx))
})
}

Scopes и Permissions

Scopes определяют, к каким ресурсам приложение может получить доступ:

scope=read:orders — только чтение заказов
scope=read:orders write:orders — чтение и запись
scope=admin — полный доступ

Безопасность: рекомендации

  • Всегда используйте HTTPS
  • Храните client_secret на сервере, не в клиенте
  • Используйте PKCE для мобильных и SPA-приложений
  • Минимальный срок жизни access token
  • Refresh token rotation — при каждом обновлении выдаётся новый refresh token
  • Валидируйте issuer, audience и expiration в JWT