DESAI.DEV
← ВЕРНУТЬСЯ

Go — Базы данных, сеть и продвинутые темы

Краткое руковдство

Go — Базы данных, сеть и продвинутые темы

> [!info] Как читать этот материал > Каждый раздел устроен по одной схеме: > - Что это? — суть концепции одним абзацем > - Зачем нужно? — какую проблему решает > - Как правильно — рабочие примеры с пояснениями > - Как неправильно — типичные ошибки и почему они плохи > - Главное запомнить — краткий итог

---

Содержание

  • [[#1. Работа с базами данных (database/sql)]]
  • [[#2. PostgreSQL + pgx]]
  • [[#3. ORM — GORM]]
  • [[#4. Миграции]]
  • [[#5. HTTP-сервер (net/http)]]
  • [[#6. REST API]]
  • [[#7. Middleware]]
  • [[#8. WebSocket]]
  • [[#9. HTTP-клиент]]
  • [[#10. JSON и XML]]
  • [[#11. Горутины и каналы]]
  • [[#12. sync — мьютексы и WaitGroup]]
  • [[#13. Работа с файлами]]
  • [[#14. Переменные окружения и конфиг]]
  • [[#15. Логирование]]
  • [[#16. Обработка ошибок]]

---

1. Работа с базами данных (database/sql)

Что это?

database/sql — стандартный пакет Go для работы с реляционными базами данных. Он не умеет подключаться ни к одной конкретной БД сам по себе — он определяет универсальный интерфейс. Конкретная реализация (PostgreSQL, MySQL, SQLite) подключается отдельным драйвером, который регистрирует себя через init().

Зачем нужно?

Без стандартного интерфейса каждая библиотека для работы с БД имела бы свой уникальный API. Вы бы писали код, жёстко привязанный к PostgreSQL, и при смене БД пришлось бы переписывать всё. database/sql решает это: ваш код работает с интерфейсом, а не с конкретной БД. Смена драйвера — смена одной строчки импорта.

Как правильно

# Установка драйверов
go get github.com/lib/pq              # PostgreSQL
go get github.com/go-sql-driver/mysql # MySQL
go get github.com/mattn/go-sqlite3    # SQLite
package main

import (
    "database/sql"
    "fmt"
    "log"
    "time"

    _ "github.com/lib/pq" // _ означает "импортировать только ради side-эффекта (init)"
)

func main() {
    connStr := "host=localhost port=5432 user=postgres password=secret dbname=mydb sslmode=disable"

    // sql.Open НЕ создаёт соединение — только парсит строку подключения
    db, err := sql.Open("postgres", connStr)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // Ping() создаёт первое реальное соединение и проверяет доступность БД
    if err := db.Ping(); err != nil {
        log.Fatal("Не могу подключиться к БД:", err)
    }

    // Настройка пула — делается один раз при старте приложения
    db.SetMaxOpenConns(25)                  // не создавать больше 25 соединений
    db.SetMaxIdleConns(10)                  // держать в резерве не более 10
    db.SetConnMaxLifetime(5 * time.Minute)  // пересоздавать соединения через 5 минут

    fmt.Println("Подключено!")
}

> [!question] Почему нужен пул соединений? > Создание TCP-соединения с БД — дорогая операция (десятки миллисекунд). Если каждый HTTP-запрос создаёт и закрывает своё соединение, при 100 RPS вы тратите секунды только на открытие соединений. Пул держит соединения живыми и переиспользует их. database/sql управляет пулом автоматически — вы только задаёте лимиты.

// CRUD операции

// Создание таблицы
_, err = db.Exec(`
    CREATE TABLE IF NOT EXISTS users (
        id   SERIAL PRIMARY KEY,
        name TEXT NOT NULL,
        age  INT
    )
`)

// INSERT — $1, $2 это плейсхолдеры (PostgreSQL синтаксис)
// MySQL использует ?
result, err := db.Exec(
    "INSERT INTO users (name, age) VALUES ($1, $2)",
    "Алиса", 30,
)

// INSERT с возвратом ID (специфика PostgreSQL)
var newID int
err = db.QueryRow(
    "INSERT INTO users (name, age) VALUES ($1, $2) RETURNING id",
    "Боб", 25,
).Scan(&newID)

// SELECT одной строки — QueryRow
var name string
var age int
err = db.QueryRow("SELECT name, age FROM users WHERE id = $1", 1).Scan(&name, &age)
if err == sql.ErrNoRows {
    fmt.Println("Пользователь не найден")
}

// SELECT нескольких строк — Query
rows, err := db.Query("SELECT id, name, age FROM users WHERE age > $1", 20)
if err != nil {
    log.Fatal(err)
}
defer rows.Close() // всегда откладываем закрытие сразу после проверки ошибки

type User struct {
    ID   int
    Name string
    Age  int
}

var users []User
for rows.Next() {
    var u User
    if err := rows.Scan(&u.ID, &u.Name, &u.Age); err != nil {
        log.Fatal(err)
    }
    users = append(users, u)
}
// Итерация могла прерваться из-за ошибки сети — проверяем
if err := rows.Err(); err != nil {
    log.Fatal(err)
}

Транзакции

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

tx, err := db.Begin()
if err != nil {
    log.Fatal(err)
}

// defer с проверкой: если err != nil к моменту выхода — откатим
defer func() {
    if err != nil {
        tx.Rollback()
        return
    }
    err = tx.Commit()
}()

_, err = tx.Exec("UPDATE accounts SET balance = balance - $1 WHERE id = $2", 100, 1)
if err != nil {
    return // defer вызовет Rollback
}

_, err = tx.Exec("UPDATE accounts SET balance = balance + $1 WHERE id = $2", 100, 2)
if err != nil {
    return // defer вызовет Rollback
}
// defer вызовет Commit

Подготовленные запросы

// Stmt компилируется один раз на стороне БД — повторные вызовы быстрее
stmt, err := db.Prepare("INSERT INTO users (name, age) VALUES ($1, $2)")
if err != nil {
    log.Fatal(err)
}
defer stmt.Close()

for _, user := range usersToInsert {
    _, err = stmt.Exec(user.Name, user.Age)
}

Как неправильно

// ❌ ПЛОХО: SQL-инъекция через fmt.Sprintf
query := fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", userInput)
// Если userInput = "' OR '1'='1", запрос вернёт всех пользователей
// Если userInput = "'; DROP TABLE users; --", потеряете таблицу

// ✅ ХОРОШО: плейсхолдеры экранируют параметры
db.Query("SELECT * FROM users WHERE name = $1", userInput)

// ❌ ПЛОХО: не закрывать rows
rows, _ := db.Query("SELECT * FROM users")
for rows.Next() { /* ... */ }
// rows.Close() забыли — соединение не вернётся в пул, утечка!

// ✅ ХОРОШО
rows, err := db.Query("SELECT * FROM users")
if err != nil { log.Fatal(err) }
defer rows.Close()

// ❌ ПЛОХО: не проверять rows.Err()
for rows.Next() {
    // ...
}
// rows.Next() вернёт false и при ошибке, и при конце данных
// без rows.Err() вы не узнаете, что данные пришли не полностью

// ❌ ПЛОХО: игнорировать SetMaxOpenConns
// По умолчанию лимит = 0 (бесконечно). Под нагрузкой
// можно исчерпать лимиты подключений на стороне PostgreSQL
// и получить "too many connections"

> [!danger] Главное запомнить > 1. Всегда defer rows.Close() сразу после проверки ошибки от Query > 2. Всегда проверять rows.Err() после цикла > 3. Никогда не подставлять параметры через fmt.Sprintf — только плейсхолдеры > 4. Настраивать пул соединений при старте приложения

---

2. PostgreSQL + pgx

Что это?

pgx — альтернативный драйвер и инструментарий для PostgreSQL, написанный специально для Go. В отличие от lib/pq, он не является просто "переходником" к database/sql, а реализует весь протокол PostgreSQL нативно.

Зачем нужно?

lib/pq уже не развивается и сам рекомендует переходить на pgx. Преимущества pgx:

  • В 2–5 раз быстрее на массовых операциях
  • Поддерживает специфичные типы PostgreSQL (массивы, JSON, UUID) без извращений
  • Встроенный пул соединений (pgxpool) с более умной логикой
  • Поддержка COPY для массовой загрузки данных
go get github.com/jackc/pgx/v5
go get github.com/jackc/pgx/v5/pgxpool

Как правильно

package main

import (
    "context"
    "fmt"
    "log"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()

    // pgxpool — потокобезопасный пул, безопасно шарить между горутинами
    pool, err := pgxpool.New(ctx, "postgres://postgres:secret@localhost:5432/mydb")
    if err != nil {
        log.Fatal(err)
    }
    defer pool.Close()

    // QueryRow — одна строка
    var name string
    err = pool.QueryRow(ctx,
        "SELECT name FROM users WHERE id = $1", 1,
    ).Scan(&name)
    if err != nil {
        log.Fatal(err)
    }

    // Query — несколько строк
    rows, err := pool.Query(ctx, "SELECT id, name FROM users ORDER BY id")
    if err != nil {
        log.Fatal(err)
    }
    defer rows.Close()

    // pgx умеет собирать строки в срез автоматически
    type User struct{ ID int; Name string }
    users, err := pgx.CollectRows(rows, pgx.RowToStructByName[User])

    // Exec — без возврата строк
    _, err = pool.Exec(ctx,
        "UPDATE users SET age = $1 WHERE id = $2", 31, 1,
    )

    // Batch — несколько запросов за один round-trip к БД
    // Полезно когда нужно сделать N независимых запросов
    batch := &pgx.Batch{}
    batch.Queue("INSERT INTO logs (msg) VALUES ($1)", "событие 1")
    batch.Queue("INSERT INTO logs (msg) VALUES ($1)", "событие 2")
    batch.Queue("INSERT INTO logs (msg) VALUES ($1)", "событие 3")

    br := pool.SendBatch(ctx, batch)
    defer br.Close()
    // Читаем результаты каждого запроса по очереди
    for i := 0; i < 3; i++ {
        _, err := br.Exec()
        if err != nil {
            log.Printf("Ошибка в запросе %d: %v", i, err)
        }
    }
}

> [!question] Когда использовать pgx напрямую, а когда через database/sql? > Используйте pgx напрямую, если: > - Вы точно знаете, что БД — PostgreSQL, и менять не планируете > - Нужна максимальная производительность > - Используете специфические типы PostgreSQL (JSONB, массивы, UUID) > > Используйте database/sql если: > - Приложение должно поддерживать несколько СУБД > - Вы используете ORM или другую библиотеку поверх стандартного интерфейса

---

3. ORM — GORM

Что это?

ORM (Object-Relational Mapping) — прослойка, которая отображает строки таблиц БД на Go-структуры и обратно. Вместо написания SQL вы вызываете методы на объектах.

Зачем нужно?

Чистый SQL требует много шаблонного кода: написать запрос, разобрать строки через Scan, собрать в структуры. ORM берёт этот шаблон на себя. Особенно выигрыш заметен для:

  • Простых CRUD операций
  • Работы со связями (один-ко-многим, многие-ко-многим)
  • Автоматического создания/обновления таблиц

Цена ORM — вы теряете контроль над SQL. Сложные запросы с несколькими JOIN'ами, оконными функциями или специфичной оптимизацией лучше писать на чистом SQL.

go get gorm.io/gorm
go get gorm.io/driver/postgres

Как правильно

package main

import (
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
    "gorm.io/gorm/logger"
)

// Структура с тегами описывает таблицу
type User struct {
    gorm.Model        // добавляет поля: ID, CreatedAt, UpdatedAt, DeletedAt
    Name     string   `gorm:"size:100;not null"`
    Email    string   `gorm:"uniqueIndex;not null"`
    Age      int      `gorm:"check:age >= 0"`
    Posts    []Post   `gorm:"foreignKey:UserID"` // один-ко-многим
}

type Post struct {
    gorm.Model
    Title   string `gorm:"not null"`
    Body    string
    UserID  uint   // внешний ключ для User
    Tags    []Tag  `gorm:"many2many:post_tags;"` // многие-ко-многим через промежуточную таблицу
}

type Tag struct {
    gorm.Model
    Name string `gorm:"uniqueIndex"`
}

func main() {
    dsn := "host=localhost user=postgres password=secret dbname=mydb port=5432 sslmode=disable"

    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
        Logger: logger.Default.LogMode(logger.Info), // печатать SQL в консоль — полезно при разработке
    })
    if err != nil {
        panic(err)
    }

    // AutoMigrate создаёт таблицы если их нет, добавляет новые колонки
    // НО не удаляет старые — для этого нужны нормальные миграции
    db.AutoMigrate(&User{}, &Post{}, &Tag{})
}
// ── CREATE ────────────────────────────────────────────────────────────────────

user := User{Name: "Алиса", Email: "alice@example.com", Age: 30}
result := db.Create(&user)
// После Create структура заполняется данными из БД (ID, CreatedAt и т.д.)
fmt.Println("Создан с ID:", user.ID) // 1

// Несколько записей за один INSERT
users := []User{
    {Name: "Боб",  Email: "bob@example.com"},
    {Name: "Карл", Email: "carl@example.com"},
}
db.Create(&users)

// ── READ ──────────────────────────────────────────────────────────────────────

var found User
db.First(&found, 1)                               // WHERE id = 1 ORDER BY id LIMIT 1
db.First(&found, "email = ?", "alice@example.com")

var allUsers []User
db.Find(&allUsers)                                // SELECT * FROM users WHERE deleted_at IS NULL
db.Where("age > ?", 25).Find(&allUsers)
db.Where("name LIKE ?", "%ли%").Find(&allUsers)
db.Order("age DESC").Limit(10).Offset(20).Find(&allUsers)
db.Select("name", "email").Find(&allUsers)        // SELECT name, email FROM users

// Подсчёт
var count int64
db.Model(&User{}).Where("age > ?", 18).Count(&count)

// Pluck — один столбец в срез
var names []string
db.Model(&User{}).Pluck("name", &names)

// ── UPDATE ────────────────────────────────────────────────────────────────────

// Save — полное обновление всех полей (опасно: обнулит пустые поля!)
db.Save(&user)

// Update — одно поле
db.Model(&user).Update("age", 31)

// Updates — несколько полей через структуру (нулевые значения игнорируются!)
db.Model(&user).Updates(User{Name: "Алиса Б", Age: 31})

// Updates — несколько полей через map (нулевые значения НЕ игнорируются)
db.Model(&user).Updates(map[string]any{"name": "Алиса Б", "age": 0})

// ── DELETE ────────────────────────────────────────────────────────────────────

// Мягкое удаление: ставит DeletedAt, запись остаётся в БД
// Все запросы GORM автоматически добавляют WHERE deleted_at IS NULL
db.Delete(&user)
db.Delete(&User{}, 1)
db.Where("age < ?", 18).Delete(&User{})

// Физическое удаление
db.Unscoped().Delete(&user)

// Найти "удалённые" записи
db.Unscoped().Where("deleted_at IS NOT NULL").Find(&users)
// ── СВЯЗИ (Associations) ──────────────────────────────────────────────────────

// Preload загружает связанные данные отдельным запросом (N+1 проблема решена)
var user User
db.Preload("Posts").First(&user, 1)
// Выполнит 2 запроса: SELECT * FROM users WHERE id=1
//                     SELECT * FROM posts WHERE user_id=1

db.Preload("Posts.Tags").First(&user, 1) // вложенный preload

// Создание вместе со связями
db.Create(&User{
    Name:  "Дима",
    Email: "dima@example.com",
    Posts: []Post{
        {
            Title: "Первый пост",
            Tags:  []Tag{{Name: "go"}, {Name: "tutorial"}},
        },
    },
})

// Добавление связи к существующей записи
db.Model(&user).Association("Posts").Append(&Post{Title: "Новый пост"})

// ── ТРАНЗАКЦИИ ────────────────────────────────────────────────────────────────

// Функциональный вариант — проще и безопаснее
err := db.Transaction(func(tx *gorm.DB) error {
    if err := tx.Create(&user).Error; err != nil {
        return err // автоматический Rollback
    }
    if err := tx.Create(&post).Error; err != nil {
        return err
    }
    return nil // автоматический Commit
})

Scopes — переиспользуемые условия

// Scope — это функция, которая модифицирует запрос
// Позволяет один раз написать условие и переиспользовать везде
func Adults(db *gorm.DB) *gorm.DB {
    return db.Where("age >= ?", 18)
}

func Recent(db *gorm.DB) *gorm.DB {
    return db.Where("created_at > ?", time.Now().AddDate(0, -1, 0))
}

// Применение — можно комбинировать
db.Scopes(Adults, Recent).Find(&users)

Как неправильно

// ❌ ПЛОХО: N+1 проблема — для каждого пользователя делается отдельный запрос
var users []User
db.Find(&users)
for _, u := range users {
    var posts []Post
    db.Where("user_id = ?", u.ID).Find(&posts) // 1 запрос на каждого пользователя
    // Если 100 пользователей — 101 запрос к БД
}

// ✅ ХОРОШО: Preload делает 2 запроса независимо от кол-ва пользователей
db.Preload("Posts").Find(&users)

// ❌ ПЛОХО: Save перезаписывает все поля
user.Name = "Новое имя"
db.Save(&user) // если Age = 0, он запишет 0 в БД (обнулит возраст!)

// ✅ ХОРОШО: Updates только то, что нужно
db.Model(&user).Update("name", "Новое имя")

// ❌ ПЛОХО: AutoMigrate в продакшене
// AutoMigrate не удаляет колонки и не переименовывает их
// Для продакшена нужны версионированные миграции (см. раздел 4)

// ❌ ПЛОХО: игнорировать result.Error
db.Create(&user) // а если ошибка — уникальности нарушена?

// ✅ ХОРОШО
if err := db.Create(&user).Error; err != nil {
    // обработать ошибку
}

> [!danger] Главное запомнить > 1. Preload вместо ручных запросов в цикле — иначе N+1 проблема > 2. Updates(struct) игнорирует нулевые значения, Updates(map) — нет. Выбирайте осознанно > 3. Save перезаписывает все поля — опасно, если структура заполнена не полностью > 4. Всегда проверять .Error после операций > 5. AutoMigrate — только для разработки. В продакшене — версионированные миграции

---

4. Миграции

Что это?

Миграция — это версионированный скрипт изменения схемы БД. Каждое изменение таблиц (добавить колонку, создать индекс, переименовать поле) фиксируется как отдельный файл с номером версии.

Зачем нужно?

Представьте команду из 5 человек. Один добавил колонку в своей локальной БД. Как другие узнают об этом? Как это попадёт на staging и production? AutoMigrate не подходит — он не умеет удалять колонки и не оставляет истории. Миграции решают это: история изменений в git, воспроизводимое состояние БД, возможность откатить изменение.

go get -tags 'postgres' github.com/golang-migrate/migrate/v4

Структура файлов

migrations/
  000001_create_users.up.sql    ← применить
  000001_create_users.down.sql  ← откатить
  000002_add_email_to_users.up.sql
  000002_add_email_to_users.down.sql
  000003_create_posts.up.sql
  000003_create_posts.down.sql

Каждое изменение — пара файлов: up (применить) и down (откатить).

-- 000001_create_users.up.sql
CREATE TABLE users (
    id         SERIAL PRIMARY KEY,
    name       TEXT NOT NULL,
    created_at TIMESTAMPTZ DEFAULT now()
);

-- 000001_create_users.down.sql
DROP TABLE users;

-- 000002_add_email_to_users.up.sql
ALTER TABLE users ADD COLUMN email TEXT UNIQUE NOT NULL DEFAULT '';

-- 000002_add_email_to_users.down.sql
ALTER TABLE users DROP COLUMN email;
import (
    "github.com/golang-migrate/migrate/v4"
    _ "github.com/golang-migrate/migrate/v4/database/postgres"
    _ "github.com/golang-migrate/migrate/v4/source/file"
)

func runMigrations(databaseURL string) error {
    m, err := migrate.New(
        "file://migrations",       // путь к папке с файлами
        databaseURL,               // строка подключения к БД
    )
    if err != nil {
        return err
    }

    if err := m.Up(); err != nil && err != migrate.ErrNoChange {
        return err
    }

    return nil
}

// Вызывается при старте приложения, до запуска сервера
func main() {
    if err := runMigrations(os.Getenv("DATABASE_URL")); err != nil {
        log.Fatal("Ошибка миграции:", err)
    }
    // дальше запуск сервера...
}

> [!question] Как работает migrate "под капотом"? > golang-migrate создаёт в вашей БД таблицу schema_migrations с одним полем — номером последней применённой версии. При запуске Up() он смотрит на этот номер и применяет все файлы с более высоким номером. Это значит: миграции идемпотентны — можно запускать при каждом старте приложения, лишних операций не будет.

Как неправильно

-- ❌ ПЛОХО: деструктивные операции в up без down
-- 000005_cleanup.up.sql
DROP TABLE old_users;
-- 000005_cleanup.down.sql
-- пусто — откатить невозможно

-- ❌ ПЛОХО: изменение существующей миграции
-- Если миграция уже применена на production, изменение файла
-- ничего не сделает — она больше не запустится
-- Нужно создать НОВУЮ миграцию

-- ✅ ХОРОШО: всегда писать down.sql
-- ✅ ХОРОШО: новая миграция на каждое изменение, старые не трогать

> [!danger] Главное запомнить > 1. Применённые миграции никогда не изменяются — создаётся новая > 2. Всегда писать down.sql — иначе нельзя откатиться > 3. Миграции запускаются при старте приложения, до открытия портов > 4. AutoMigrate — только для прототипов, не для продакшена

---

5. HTTP-сервер (net/http)

Что это?

net/http — стандартный пакет Go для создания HTTP-серверов и клиентов. Он достаточно мощный для продакшена без сторонних библиотек. Многие популярные фреймворки (chi, gorilla/mux) — это просто тонкая обёртка над ним.

Зачем нужно?

HTTP — основной протокол для веб-приложений и API. net/http реализует всё: парсинг запросов, управление соединениями, TLS, keep-alive. Вы пишете только бизнес-логику — что делать с запросом.

Как правильно

package main

import (
    "fmt"
    "net/http"
    "time"
    "log"
)

// Обработчик — функция с фиксированной сигнатурой
// w — куда писать ответ, r — данные запроса
func helloHandler(w http.ResponseWriter, r *http.Request) {
    // Получить query параметр: /hello?name=Alice
    name := r.URL.Query().Get("name")
    if name == "" {
        name = "мир"
    }

    // Установить заголовок ДО WriteHeader/Write
    w.Header().Set("Content-Type", "text/plain; charset=utf-8")
    w.WriteHeader(http.StatusOK) // 200 — можно не вызывать, это значение по умолчанию

    fmt.Fprintf(w, "Привет, %s!", name)
}

func main() {
    mux := http.NewServeMux() // мультиплексер — направляет запросы к обработчикам

    mux.HandleFunc("/hello", helloHandler)
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        // В стандартном mux "/" матчит всё, что не совпало с другим маршрутом
        if r.URL.Path != "/" {
            http.NotFound(w, r)
            return
        }
        fmt.Fprint(w, "Главная страница")
    })

    // Настраивайте сервер явно — дефолтный http.ListenAndServe не имеет таймаутов!
    server := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  5 * time.Second,   // время на чтение всего запроса
        WriteTimeout: 10 * time.Second,  // время на отправку ответа
        IdleTimeout:  120 * time.Second, // время keep-alive соединения без запросов
    }

    log.Println("Сервер запущен на :8080")
    if err := server.ListenAndServe(); err != nil {
        log.Fatal(err)
    }
}

> [!question] Почему важны таймауты? > Без таймаутов медленный или злонамеренный клиент может держать соединение открытым вечно. 1000 таких клиентов — и ваш сервер исчерпает горутины и память. ReadTimeout защищает от медленной загрузки тела запроса. WriteTimeout — от медленного скачивания ответа клиентом.

Chi — роутер с URL-параметрами

Стандартный ServeMux не поддерживает параметры в URL (/users/{id}). Для REST API нужен роутер.

go get github.com/go-chi/chi/v5
import (
    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
)

r := chi.NewRouter()

// Встроенные middleware
r.Use(middleware.Logger)    // логирование каждого запроса
r.Use(middleware.Recoverer) // перехватывает панику, возвращает 500 вместо краша
r.Use(middleware.Compress(5)) // gzip компрессия ответов

// Маршруты с параметрами
r.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    fmt.Fprintf(w, "User ID: %s", id)
})

// Группировка — общий префикс и middleware
r.Route("/api/v1", func(r chi.Router) {
    r.Use(AuthMiddleware) // применяется только к /api/v1/*

    r.Get("/users", listUsers)
    r.Post("/users", createUser)

    r.Route("/users/{id}", func(r chi.Router) {
        r.Get("/", getUser)
        r.Put("/", updateUser)
        r.Delete("/", deleteUser)
    })
})

http.ListenAndServe(":8080", r)

Как неправильно

// ❌ ПЛОХО: использовать http.DefaultServeMux и http.ListenAndServe напрямую
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil) // nil = DefaultServeMux, нет таймаутов

// ❌ ПЛОХО: устанавливать заголовки после WriteHeader
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json") // слишком поздно! уже отправлено

// ✅ ХОРОШО: сначала заголовки, потом WriteHeader
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)

// ❌ ПЛОХО: вызывать WriteHeader дважды
// Второй вызов молча игнорируется, но логируется как "superfluous response.WriteHeader"
w.WriteHeader(200)
w.WriteHeader(404) // не работает, 200 уже отправлен

> [!danger] Главное запомнить > 1. Всегда создавать http.Server с таймаутами — не использовать http.ListenAndServe напрямую > 2. Заголовки устанавливать до WriteHeader и Write > 3. Стандартный ServeMux не поддерживает URL-параметры — нужен роутер (chi, gorilla/mux)

---

6. REST API

Что это?

REST (Representational State Transfer) — архитектурный стиль для API. Ключевые принципы: ресурсы адресуются URL-ами (/users/42), HTTP-методы определяют действие (GET = читать, POST = создать, PUT = заменить, PATCH = обновить частично, DELETE = удалить), ответы — JSON.

Как правильно

package main

import (
    "encoding/json"
    "net/http"
    "strconv"
    "sync"
    "errors"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
)

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
    Age  int    `json:"age"`
}

// Вспомогательная функция — исключает повторение json.NewEncoder + w.WriteHeader
func respond(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    if err := json.NewEncoder(w).Encode(data); err != nil {
        // Это уже поздно менять статус, но логируем
        // (на практике используют структурированный логгер)
        http.Error(w, err.Error(), 500)
    }
}

func respondError(w http.ResponseWriter, status int, msg string) {
    respond(w, status, map[string]string{"error": msg})
}

// "База данных" в памяти — для примера
var (
    users  = map[int]User{}
    nextID = 1
    mu     sync.RWMutex
)

func listUsers(w http.ResponseWriter, r *http.Request) {
    mu.RLock()
    defer mu.RUnlock()

    list := make([]User, 0, len(users))
    for _, u := range users {
        list = append(list, u)
    }
    respond(w, http.StatusOK, list)
}

func createUser(w http.ResponseWriter, r *http.Request) {
    // Ограничиваем размер тела — защита от больших запросов
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB

    var input struct {
        Name string `json:"name"`
        Age  int    `json:"age"`
    }

    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields() // 400 если пришли лишние поля

    if err := dec.Decode(&input); err != nil {
        respondError(w, http.StatusBadRequest, "невалидный JSON: "+err.Error())
        return
    }

    // Валидация
    if input.Name == "" {
        respondError(w, http.StatusUnprocessableEntity, "поле name обязательно")
        return
    }
    if input.Age < 0 || input.Age > 150 {
        respondError(w, http.StatusUnprocessableEntity, "недопустимый возраст")
        return
    }

    mu.Lock()
    u := User{ID: nextID, Name: input.Name, Age: input.Age}
    users[nextID] = u
    nextID++
    mu.Unlock()

    respond(w, http.StatusCreated, u) // 201 Created
}

func getUser(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(chi.URLParam(r, "id"))
    if err != nil {
        respondError(w, http.StatusBadRequest, "id должен быть числом")
        return
    }

    mu.RLock()
    u, ok := users[id]
    mu.RUnlock()

    if !ok {
        respondError(w, http.StatusNotFound, "пользователь не найден")
        return
    }
    respond(w, http.StatusOK, u)
}

func updateUser(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(chi.URLParam(r, "id"))
    if err != nil {
        respondError(w, http.StatusBadRequest, "id должен быть числом")
        return
    }

    var input struct {
        Name string `json:"name"`
        Age  int    `json:"age"`
    }
    if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
        respondError(w, http.StatusBadRequest, "невалидный JSON")
        return
    }

    mu.Lock()
    defer mu.Unlock()

    u, ok := users[id]
    if !ok {
        respondError(w, http.StatusNotFound, "пользователь не найден")
        return
    }

    if input.Name != "" {
        u.Name = input.Name
    }
    if input.Age != 0 {
        u.Age = input.Age
    }
    users[id] = u

    respond(w, http.StatusOK, u)
}

func deleteUser(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(chi.URLParam(r, "id"))
    if err != nil {
        respondError(w, http.StatusBadRequest, "id должен быть числом")
        return
    }

    mu.Lock()
    _, ok := users[id]
    if ok {
        delete(users, id)
    }
    mu.Unlock()

    if !ok {
        respondError(w, http.StatusNotFound, "пользователь не найден")
        return
    }
    w.WriteHeader(http.StatusNoContent) // 204 No Content — тело ответа пустое
}

func main() {
    r := chi.NewRouter()
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)

    r.Get("/users", listUsers)
    r.Post("/users", createUser)
    r.Get("/users/{id}", getUser)
    r.Put("/users/{id}", updateUser)
    r.Delete("/users/{id}", deleteUser)

    http.ListenAndServe(":8080", r)
}

Как неправильно

// ❌ ПЛОХО: возвращать 200 для ошибок
func badHandler(w http.ResponseWriter, r *http.Request) {
    user, err := db.GetUser(id)
    if err != nil {
        // Клиент получает 200 OK, но тело содержит ошибку — как он должен понять?
        json.NewEncoder(w).Encode(map[string]string{"error": "not found"})
        return
    }
}

// ✅ ХОРОШО: правильные HTTP-коды
// 200 OK — запрос выполнен
// 201 Created — ресурс создан (POST)
// 204 No Content — успех, но тела нет (DELETE)
// 400 Bad Request — клиент отправил невалидные данные
// 401 Unauthorized — не аутентифицирован
// 403 Forbidden — аутентифицирован, но нет прав
// 404 Not Found — ресурс не существует
// 422 Unprocessable Entity — синтаксис верный, но семантика неверная (валидация)
// 500 Internal Server Error — ошибка на сервере

// ❌ ПЛОХО: не валидировать входные данные
func createUserBad(w http.ResponseWriter, r *http.Request) {
    var u User
    json.NewDecoder(r.Body).Decode(&u)
    db.Create(&u) // что если Name = "" или Age = -999?
}

// ❌ ПЛОХО: возвращать внутренние ошибки пользователю
respondError(w, 500, err.Error()) // "pq: syntax error at line 42" — утечка информации
// ✅ ХОРОШО: логировать подробно, возвращать общее сообщение
log.Error("db error", "err", err)
respondError(w, 500, "внутренняя ошибка сервера")

> [!danger] Главное запомнить > 1. Правильные HTTP-коды — не 200 для всего подряд > 2. Валидировать все входные данные до работы с БД > 3. Не возвращать внутренние ошибки (SQL, стек трейс) клиенту — логировать, возвращать общее сообщение > 4. Ограничивать размер тела запроса через MaxBytesReader

---

7. Middleware

Что это?

Middleware — функция, которая оборачивает HTTP-обработчик. Она получает управление до и/или после основного обработчика. По сути — декоратор для HTTP-хендлеров.

Зачем нужно?

Многие задачи одинаковы для всех или большинства маршрутов: логировать запросы, проверить токен аутентификации, измерить время выполнения, добавить CORS заголовки. Без middleware это повторяется в каждом обработчике. Middleware выносит общую логику в одно место.

// Сигнатура middleware в chi (и в целом в Go)
// Получает следующий обработчик, возвращает новый обработчик
type MiddlewareFunc func(http.Handler) http.Handler

// ── АУТЕНТИФИКАЦИЯ ─────────────────────────────────────────────────────────────

func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if token == "" {
            http.Error(w, `{"error":"требуется авторизация"}`, http.StatusUnauthorized)
            return // НЕ вызываем next — останавливаем цепочку
        }

        // Валидируем токен и получаем данные пользователя
        userID, err := validateToken(token)
        if err != nil {
            http.Error(w, `{"error":"неверный токен"}`, http.StatusUnauthorized)
            return
        }

        // Передаём данные в обработчик через контекст
        ctx := context.WithValue(r.Context(), contextKeyUserID, userID)
        next.ServeHTTP(w, r.WithContext(ctx)) // передаём управление дальше
    })
}

// Как достать данные из контекста в обработчике
func profileHandler(w http.ResponseWriter, r *http.Request) {
    userID, ok := r.Context().Value(contextKeyUserID).(int)
    if !ok {
        http.Error(w, "нет userID в контексте", 500)
        return
    }
    fmt.Fprintf(w, "Профиль пользователя %d", userID)
}

// ── ИЗМЕРЕНИЕ ВРЕМЕНИ ──────────────────────────────────────────────────────────

// ResponseWriter обёртка чтобы перехватить статус код
type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

func TimingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()

        wrapped := &responseWriter{w, http.StatusOK}
        next.ServeHTTP(wrapped, r)

        duration := time.Since(start)
        log.Printf("%s %s %d %v", r.Method, r.URL.Path, wrapped.statusCode, duration)
    })
}

// ── CORS ───────────────────────────────────────────────────────────────────────

func CORSMiddleware(allowedOrigins []string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            origin := r.Header.Get("Origin")

            // Проверяем, разрешён ли origin
            allowed := false
            for _, o := range allowedOrigins {
                if o == "*" || o == origin {
                    allowed = true
                    break
                }
            }

            if allowed {
                w.Header().Set("Access-Control-Allow-Origin", origin)
                w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
                w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
            }

            // Preflight запрос — браузер проверяет разрешения перед реальным запросом
            if r.Method == http.MethodOptions {
                w.WriteHeader(http.StatusNoContent)
                return
            }

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

// ── ПРИМЕНЕНИЕ ─────────────────────────────────────────────────────────────────

r := chi.NewRouter()

// Глобальные middleware — для всех маршрутов
r.Use(TimingMiddleware)
r.Use(CORSMiddleware([]string{"https://myapp.com"}))
r.Use(middleware.Recoverer) // перехват паник

// Публичные маршруты
r.Get("/health", healthHandler)
r.Post("/auth/login", loginHandler)

// Защищённые маршруты — только с AuthMiddleware
r.Group(func(r chi.Router) {
    r.Use(AuthMiddleware)
    r.Get("/profile", profileHandler)
    r.Get("/users", listUsers)
})

// Или для одного маршрута
r.With(AuthMiddleware).Delete("/users/{id}", deleteUser)

Как неправильно

// ❌ ПЛОХО: хранить данные в глобальной переменной вместо контекста
var currentUserID int // потокобезопасность? нет!

func badMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        currentUserID = 42 // горутины из разных запросов перезапишут друг друга
        next.ServeHTTP(w, r)
    })
}

// ✅ ХОРОШО: контекст — это per-request хранилище
ctx := context.WithValue(r.Context(), keyUserID, 42)
next.ServeHTTP(w, r.WithContext(ctx))

// ❌ ПЛОХО: использовать string в качестве ключа контекста — риск коллизий
ctx = context.WithValue(r.Context(), "userID", 42) // другой пакет тоже может использовать "userID"

// ✅ ХОРОШО: кастомный тип ключа исключает коллизии
type contextKey string
const keyUserID contextKey = "userID"

// ❌ ПЛОХО: писать middleware вручную для стандартного функционала
// Логирование, recovery, компрессия — уже есть в chi/middleware

> [!danger] Главное запомнить > 1. Для передачи данных между middleware и обработчиком — только контекст, не глобальные переменные > 2. Ключи контекста — кастомный тип, не строки > 3. Если не вызвать next.ServeHTTP() — запрос остановится на этом middleware (намеренно для auth, ошибка для остальных) > 4. Порядок r.Use() важен — middleware выполняются в порядке добавления

---

8. WebSocket

Что это?

WebSocket — протокол двусторонней связи в реальном времени поверх TCP. В отличие от обычного HTTP (клиент спрашивает → сервер отвечает), WebSocket позволяет серверу самому отправлять данные клиенту без запроса.

Зачем нужно?

HTTP-опрос ("polling") — клиент каждую секунду спрашивает "есть новости?" — создаёт огромную нагрузку. WebSocket устанавливает постоянное соединение. Используется для: чатов, уведомлений в реальном времени, онлайн-игр, трансляций данных (биржевые котировки, метрики).

go get github.com/gorilla/websocket
package main

import (
    "fmt"
    "net/http"
    "sync"

    "github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
    // CheckOrigin проверяет, разрешено ли соединение с данного Origin
    // В продакшене ОБЯЗАТЕЛЬНО проверяйте!
    CheckOrigin: func(r *http.Request) bool {
        origin := r.Header.Get("Origin")
        return origin == "https://myapp.com"
    },
}

// Hub управляет всеми активными соединениями и рассылкой сообщений
type Hub struct {
    clients   map[*websocket.Conn]bool
    broadcast chan []byte    // сообщения для рассылки всем
    mu        sync.Mutex
}

func newHub() *Hub {
    return &Hub{
        clients:   make(map[*websocket.Conn]bool),
        broadcast: make(chan []byte, 256), // буферизованный — не блокирует отправителя
    }
}

// run крутится в отдельной горутине и рассылает сообщения
func (h *Hub) run() {
    for msg := range h.broadcast {
        h.mu.Lock()
        for conn := range h.clients {
            // WriteMessage не потокобезопасен — одна горутина на соединение или мьютекс
            if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
                conn.Close()
                delete(h.clients, conn)
            }
        }
        h.mu.Unlock()
    }
}

func (h *Hub) wsHandler(w http.ResponseWriter, r *http.Request) {
    // Upgrade превращает HTTP-соединение в WebSocket
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        fmt.Println("Upgrade error:", err)
        return
    }
    defer conn.Close()

    h.mu.Lock()
    h.clients[conn] = true
    h.mu.Unlock()

    defer func() {
        h.mu.Lock()
        delete(h.clients, conn)
        h.mu.Unlock()
    }()

    // Настраиваем ping/pong для определения "живости" соединения
    conn.SetReadDeadline(time.Now().Add(60 * time.Second))
    conn.SetPongHandler(func(string) error {
        conn.SetReadDeadline(time.Now().Add(60 * time.Second))
        return nil
    })

    // Цикл чтения — блокирует горутину до закрытия соединения
    for {
        _, msg, err := conn.ReadMessage()
        if err != nil {
            if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) {
                fmt.Printf("Неожиданное закрытие: %v\n", err)
            }
            break
        }
        fmt.Printf("Получено: %s\n", msg)
        h.broadcast <- msg // отправляем в канал — hub разошлёт всем
    }
}

func main() {
    hub := newHub()
    go hub.run() // hub работает в отдельной горутине

    http.HandleFunc("/ws", hub.wsHandler)
    http.ListenAndServe(":8080", nil)
}

> [!question] Почему hub в отдельной горутине? > conn.WriteMessage не потокобезопасен — если две горутины одновременно напишут в одно соединение, будет гонка данных и крэш. Паттерн с hub решает это: все записи идут через одну горутину, которая читает из канала broadcast. Это "single writer per connection" паттерн.

Как неправильно

// ❌ ПЛОХО: разрешать все Origin в продакшене
CheckOrigin: func(r *http.Request) bool {
    return true // любой сайт может подключиться к вашему WebSocket!
}

// ❌ ПЛОХО: не устанавливать read deadline
// Клиент исчез (вырубили интернет) — соединение висит "зомби" вечно
// На 1000 клиентов = 1000 зомби-горутин

// ❌ ПЛОХО: писать в conn из нескольких горутин
go func() { conn.WriteMessage(...) }()
go func() { conn.WriteMessage(...) }() // гонка данных!

> [!danger] Главное запомнить > 1. Всегда проверять Origin в CheckOrigin > 2. Устанавливать ReadDeadline и обновлять его в PongHandler > 3. Писать в соединение только из одной горутины (паттерн hub/канал) > 4. Каждое WebSocket-соединение держит горутину — планировать нагрузку соответственно

---

9. HTTP-клиент

Что это?

net/http включает не только сервер, но и клиент для выполнения HTTP-запросов к другим сервисам.

Зачем нужно?

Современные приложения интегрируются с внешними API (платёжные системы, SMS, карты, ML-сервисы) и с другими микросервисами. Правильно настроенный клиент обеспечивает надёжность: таймауты, повторные попытки, пул соединений.

Как правильно

// Создавайте один клиент и переиспользуйте — не создавайте новый на каждый запрос
var httpClient = &http.Client{
    Timeout: 10 * time.Second, // общий таймаут на весь запрос

    Transport: &http.Transport{
        MaxIdleConns:        100,              // максимум idle соединений в пуле
        MaxIdleConnsPerHost: 10,               // из них — на один хост
        IdleConnTimeout:     90 * time.Second,
        TLSHandshakeTimeout: 5 * time.Second,
    },
}

// GET запрос
func fetchUser(ctx context.Context, id int) (*User, error) {
    url := fmt.Sprintf("https://api.example.com/users/%d", id)

    // NewRequestWithContext — запрос с поддержкой отмены через context
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, fmt.Errorf("создание запроса: %w", err)
    }
    req.Header.Set("Authorization", "Bearer "+apiToken)

    resp, err := httpClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("выполнение запроса: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
        return nil, fmt.Errorf("API вернул %d: %s", resp.StatusCode, body)
    }

    var user User
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
        return nil, fmt.Errorf("декодирование ответа: %w", err)
    }
    return &user, nil
}

// POST запрос с JSON телом
func createUser(ctx context.Context, name string, age int) (*User, error) {
    payload := map[string]any{"name": name, "age": age}

    body, err := json.Marshal(payload)
    if err != nil {
        return nil, err
    }

    req, err := http.NewRequestWithContext(
        ctx,
        http.MethodPost,
        "https://api.example.com/users",
        bytes.NewReader(body),
    )
    req.Header.Set("Content-Type", "application/json")

    resp, err := httpClient.Do(req)
    // ...
}

// Ретраи — для временных сетевых ошибок
func doWithRetry(req *http.Request, maxAttempts int) (*http.Response, error) {
    var lastErr error

    for attempt := 0; attempt < maxAttempts; attempt++ {
        if attempt > 0 {
            // Экспоненциальная задержка: 1s, 2s, 4s...
            wait := time.Duration(1<<uint(attempt-1)) * time.Second
            time.Sleep(wait)
        }

        resp, err := httpClient.Do(req)
        if err != nil {
            lastErr = err
            continue
        }

        // Ретрай только для 5xx (ошибки сервера), не для 4xx (ошибки клиента)
        if resp.StatusCode >= 500 {
            resp.Body.Close()
            lastErr = fmt.Errorf("статус %d", resp.StatusCode)
            continue
        }

        return resp, nil
    }

    return nil, fmt.Errorf("все %d попытки исчерпаны: %w", maxAttempts, lastErr)
}

Как неправильно

// ❌ ПЛОХО: использовать http.Get / http.Post — нет контроля над таймаутами
resp, err := http.Get("https://api.example.com/users")
// Если сервер не отвечает — зависнет навсегда!

// ❌ ПЛОХО: создавать новый http.Client на каждый запрос
func handler(w http.ResponseWriter, r *http.Request) {
    client := &http.Client{Timeout: 10 * time.Second}
    resp, _ := client.Get("https://...")
    // Каждый запрос создаёт новый пул соединений → нет переиспользования → медленно
}

// ❌ ПЛОХО: не закрывать resp.Body
resp, _ := client.Get("https://...")
// resp.Body.Close() забыли — утечка горутин и TCP-соединений

// ❌ ПЛОХО: не ограничивать размер тела ответа
body, _ := io.ReadAll(resp.Body)
// Злонамеренный сервер вернёт 10 GB → OOM

// ✅ ХОРОШО: лимитировать чтение
body, _ := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) // 10 MB максимум

> [!danger] Главное запомнить > 1. Один http.Client на приложение — переиспользовать, не создавать в каждом обработчике > 2. Всегда defer resp.Body.Close() сразу после проверки ошибки > 3. NewRequestWithContext вместо http.Get — чтобы context мог отменить запрос > 4. Ограничивать размер ответа через io.LimitReader

---

10. JSON и XML

Что это?

encoding/json и encoding/xml — стандартные пакеты для сериализации (Go → байты) и десериализации (байты → Go) структур данных.

Зачем нужно?

HTTP API обменивается данными в текстовом формате. JSON стал де-факто стандартом для REST API. Пакеты позволяют прозрачно преобразовывать ваши Go-структуры в JSON и обратно.

// ── ТЕГИ СТРУКТУР ─────────────────────────────────────────────────────────────

type User struct {
    ID        int       `json:"id"`                   // имя в JSON
    Name      string    `json:"name"`
    Email     string    `json:"email,omitempty"`      // не включать если пусто
    Password  string    `json:"-"`                    // никогда не включать
    CreatedAt time.Time `json:"created_at"`
    Internal  string    // нет тега → будет "Internal" (с заглавной)
    private   string    // нижний регистр → игнорируется всегда
}

// ── MARSHAL — Go → JSON ───────────────────────────────────────────────────────

u := User{ID: 1, Name: "Алиса", Email: "alice@example.com"}

// Компактный JSON
data, err := json.Marshal(u)
// {"id":1,"name":"Алиса","email":"alice@example.com","created_at":"0001-01-01T00:00:00Z"}

// Красивый JSON (для отладки, конфигов)
data, err = json.MarshalIndent(u, "", "  ")
// {
//   "id": 1,
//   "name": "Алиса",
//   ...
// }

// Потоковая запись — эффективнее для HTTP (не создаёт промежуточный []byte)
json.NewEncoder(w).Encode(u)

// ── UNMARSHAL — JSON → Go ─────────────────────────────────────────────────────

jsonStr := `{"id":1,"name":"Боб","age":25}`

var user User
err = json.Unmarshal([]byte(jsonStr), &user)

// Потоковое чтение — эффективнее для HTTP
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields() // ошибка если в JSON есть поля, которых нет в структуре
err = decoder.Decode(&user)

// ── ПРОИЗВОЛЬНЫЙ JSON ─────────────────────────────────────────────────────────

// Если структура заранее неизвестна — используем map или json.RawMessage
var raw map[string]any
json.Unmarshal(data, &raw)

name := raw["name"].(string)           // type assertion — может паниковать
age, ok := raw["age"].(float64)        // json числа → float64
if !ok { /* поле отсутствует или другой тип */ }

// json.RawMessage — отложенная десериализация вложенного объекта
type APIResponse struct {
    Status string          `json:"status"`
    Data   json.RawMessage `json:"data"` // декодируем позже, когда узнаем тип
}

var resp APIResponse
json.Unmarshal(body, &resp)

var users []User
json.Unmarshal(resp.Data, &users) // декодируем data как массив пользователей

// ── КАСТОМНАЯ СЕРИАЛИЗАЦИЯ ────────────────────────────────────────────────────

// Свой формат даты вместо RFC3339
type Date time.Time

func (d Date) MarshalJSON() ([]byte, error) {
    return json.Marshal(time.Time(d).Format("2006-01-02"))
}

func (d *Date) UnmarshalJSON(data []byte) error {
    var s string
    if err := json.Unmarshal(data, &s); err != nil {
        return err
    }
    t, err := time.Parse("2006-01-02", s)
    if err != nil {
        return err
    }
    *d = Date(t)
    return nil
}

Как неправильно

// ❌ ПЛОХО: не проверять ошибки Decode
json.NewDecoder(r.Body).Decode(&user)
// если тело пустое или невалидное — user останется нулевым, работаем с мусором

// ❌ ПЛОХО: использовать interface{} и type assertion без проверки
raw := map[string]any{}
json.Unmarshal(data, &raw)
name := raw["name"].(string) // паника если "name" = nil или не string

// ✅ ХОРОШО: с проверкой
name, ok := raw["name"].(string)
if !ok {
    return fmt.Errorf("поле name отсутствует или не строка")
}

// ❌ ПЛОХО: передавать Password/токен в JSON по невнимательности
type User struct {
    ID       int    `json:"id"`
    Password string `json:"password"` // опасно! утечёт в ответе API
}
// ✅ ХОРОШО: json:"-" или отдельные DTO-структуры для ответов

> [!danger] Главное запомнить > 1. json:"-" для чувствительных полей (пароли, токены) > 2. omitempty для необязательных полей — JSON не раздувается нулями > 3. Потоковые Encoder/Decoder эффективнее Marshal/Unmarshal в HTTP > 4. DisallowUnknownFields() — хорошая практика для входящих данных

---

11. Горутины и каналы

Что это?

Горутина — легковесный поток выполнения, управляемый Go runtime, а не ОС. Канал — безопасный способ передачи данных между горутинами. Вместе они реализуют модель конкурентного программирования CSP (Communicating Sequential Processes).

Зачем нужно?

Обычные потоки ОС весят ~1–8 MB памяти. Горутины — ~2–8 KB, и их можно создавать миллионы. Каналы заменяют разделяемую память и мьютексы для коммуникации — код проще и безопаснее. Девиз Go: "Не делитесь памятью для общения — общайтесь для обмена памятью".

// ── ГОРУТИНЫ ──────────────────────────────────────────────────────────────────

go func() {
    fmt.Println("Выполняется параллельно")
}()

// ВАЖНО: если main() завершится — все горутины убьются немедленно
// Нужна синхронизация (WaitGroup, channel, etc.)

// ── КАНАЛЫ ────────────────────────────────────────────────────────────────────

// Небуферизованный — отправитель блокируется, пока получатель не возьмёт значение
ch := make(chan int)
go func() { ch <- 42 }() // горутина блокируется здесь...
val := <-ch               // ...до этого момента

// Буферизованный — отправитель блокируется только когда буфер полон
buffered := make(chan string, 3)
buffered <- "a" // не блокирует (есть место)
buffered <- "b"
buffered <- "c"
// buffered <- "d" // заблокировало бы — буфер полон

// Направленные каналы — делают намерения явными
func produce(out chan<- int) { // только запись
    for i := 0; i < 5; i++ {
        out <- i
    }
    close(out) // всегда закрывает отправитель, не получатель
}

func consume(in <-chan int) { // только чтение
    for val := range in { // range читает до закрытия канала
        fmt.Println(val)
    }
}

// ── SELECT ────────────────────────────────────────────────────────────────────

// select — ждать одновременно несколько каналов, обработать тот что готов первым
func main() {
    results := make(chan string)
    timeout := time.After(5 * time.Second)

    go slowOperation(results)

    select {
    case res := <-results:
        fmt.Println("Готово:", res)
    case <-timeout:
        fmt.Println("Таймаут!")
    }
}

// Non-blocking select — проверить без блокировки
select {
case msg := <-ch:
    fmt.Println("Есть сообщение:", msg)
default:
    fmt.Println("Канал пустой — продолжаем работу")
}
// ── ПАТТЕРНЫ КОНКУРЕНТНОСТИ ───────────────────────────────────────────────────

// PIPELINE — цепочка обработки
func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

for result := range square(square(generate(2, 3, 4))) {
    fmt.Println(result) // 16, 81, 256
}

// WORKER POOL — N горутин обрабатывают задачи из общей очереди
func workerPool(jobs <-chan int, results chan<- int, numWorkers int) {
    var wg sync.WaitGroup

    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func(workerID int) {
            defer wg.Done()
            for job := range jobs { // автоматически завершается при close(jobs)
                result := heavyComputation(job)
                results <- result
            }
        }(i)
    }

    // Закрываем results когда все воркеры закончат
    go func() {
        wg.Wait()
        close(results)
    }()
}

// Использование
jobs := make(chan int, 100)
results := make(chan int, 100)

go workerPool(jobs, results, 5) // 5 параллельных воркеров

for _, task := range tasks {
    jobs <- task
}
close(jobs) // сигнал воркерам — задач больше нет

for result := range results {
    fmt.Println(result)
}
// ── CONTEXT ДЛЯ ОТМЕНЫ ────────────────────────────────────────────────────────

// Context позволяет отменять горутины "сверху вниз" по дереву вызовов
func processRequest(ctx context.Context, userID int) error {
    // Создаём подконтекст с таймаутом — операция должна уложиться в 3 секунды
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel() // ВСЕГДА вызывать, иначе утечка ресурсов

    results := make(chan Result, 1)

    go func() {
        result, err := fetchFromDB(ctx, userID)
        // ... передаём result в канал
    }()

    select {
    case res := <-results:
        return processResult(res)
    case <-ctx.Done():
        return ctx.Err() // context.DeadlineExceeded или context.Canceled
    }
}

Как неправильно

// ❌ ПЛОХО: утечка горутины — горутина блокируется навсегда
func leak() {
    ch := make(chan int) // небуферизованный
    go func() {
        ch <- 42 // блокируется, если никто не читает из ch
    }()
    // ch никогда не читается — горутина висит в памяти вечно
}

// ❌ ПЛОХО: запись в закрытый канал — паника
close(ch)
ch <- 1 // panic: send on closed channel

// ❌ ПЛОХО: закрыть канал дважды — паника
close(ch)
close(ch) // panic: close of closed channel

// ✅ ХОРОШО: закрывает только отправитель, и только один раз

// ❌ ПЛОХО: гонка данных — читать и писать в переменную из разных горутин
var counter int
go func() { counter++ }() // горутина 1
go func() { counter++ }() // горутина 2 — гонка!

// ✅ ХОРОШО: через канал или sync.Mutex
// Проверить гонки: go run -race main.go или go test -race ./...

// ❌ ПЛОХО: слишком много горутин без ограничений
for _, task := range millionTasks {
    go process(task) // 1 000 000 горутин × 8KB = 8GB памяти
}

// ✅ ХОРОШО: worker pool с фиксированным числом горутин

> [!danger] Главное запомнить > 1. Запускайте -race флаг при тестировании — он обнаруживает гонки данных > 2. Канал закрывает отправитель, только один раз > 3. Каждая горутина должна иметь путь завершения — иначе утечка > 4. Worker pool вместо "горутина на каждую задачу" при большом объёме > 5. context.WithCancel/Timeout для отмены горутин

---

12. sync — мьютексы и WaitGroup

Что это?

Пакет sync предоставляет примитивы синхронизации для случаев, когда каналы неудобны: Mutex (взаимное исключение), WaitGroup (ожидание группы горутин), Once (однократное выполнение), sync.Map (потокобезопасная карта).

Зачем нужно?

Иногда несколько горутин работают с одними данными. Канал для этого избыточен — проще защитить данные мьютексом. Правило выбора: если горутины обмениваются данными — канал. Если горутины разделяют данные (читают/пишут одну структуру) — мьютекс.

import "sync"

// ── MUTEX ─────────────────────────────────────────────────────────────────────

// RWMutex — эффективнее обычного Mutex для частых чтений
// Несколько горутин могут читать одновременно, но запись монопольна
type Cache struct {
    mu    sync.RWMutex
    items map[string]string
}

func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()         // несколько читателей одновременно — ОК
    defer c.mu.RUnlock()
    val, ok := c.items[key]
    return val, ok
}

func (c *Cache) Set(key, val string) {
    c.mu.Lock()          // монопольный доступ для записи
    defer c.mu.Unlock()
    c.items[key] = val
}

// ── WAITGROUP ─────────────────────────────────────────────────────────────────

// WaitGroup — ждать завершения группы горутин
func processAll(items []string) []Result {
    results := make([]Result, len(items))
    var wg sync.WaitGroup

    for i, item := range items {
        wg.Add(1) // инкремент перед запуском горутины (не внутри неё!)
        go func(i int, item string) {
            defer wg.Done() // декремент при завершении
            results[i] = process(item) // записываем в разные индексы — безопасно
        }(i, item)
    }

    wg.Wait() // блокируется пока счётчик не достигнет 0
    return results
}

// ── ONCE ─────────────────────────────────────────────────────────────────────

// Once — выполнить функцию ровно один раз, даже при конкурентных вызовах
// Типичный use case: ленивая инициализация синглтона
type DB struct {
    conn *sql.DB
}

var (
    instance *DB
    once     sync.Once
)

func GetDB() *DB {
    once.Do(func() {
        instance = &DB{conn: connectToDB()}
    })
    return instance
}

// ── SYNC.MAP ──────────────────────────────────────────────────────────────────

// sync.Map оптимизирован для двух случаев:
// 1. Запись один раз, чтение много раз
// 2. Конкурентные операции на непересекающихся ключах
var sm sync.Map

sm.Store("key", "value")

val, ok := sm.Load("key")       // получить
actual, loaded := sm.LoadOrStore("key", "new") // атомарный get-or-set
sm.Delete("key")

sm.Range(func(k, v any) bool {
    fmt.Printf("%v: %v\n", k, v)
    return true // false = остановить итерацию
})

Как неправильно

// ❌ ПЛОХО: wg.Add внутри горутины — гонка
var wg sync.WaitGroup
for _, item := range items {
    go func(item string) {
        wg.Add(1) // может не выполниться до wg.Wait()!
        defer wg.Done()
        process(item)
    }(item)
}
wg.Wait() // может завершиться до того как все горутины запустятся

// ✅ ХОРОШО: wg.Add перед go
for _, item := range items {
    wg.Add(1)
    go func(item string) {
        defer wg.Done()
        process(item)
    }(item)
}

// ❌ ПЛОХО: копировать Mutex — теряется состояние блокировки
mu := sync.Mutex{}
muCopy := mu // muCopy — это другой мьютекс, не связанный с mu!
// Передавайте Mutex через указатель или встраивайте в структуру

// ❌ ПЛОХО: использовать sync.Map везде
// Обычная map + RWMutex быстрее sync.Map для большинства случаев
// sync.Map — только для специфичных паттернов доступа

> [!danger] Главное запомнить > 1. wg.Add(1) перед go, не внутри горутины > 2. defer mu.Unlock() сразу после mu.Lock() — защита от утечки блокировки > 3. Mutex нельзя копировать — только указатель или встроить в структуру > 4. RWMutex вместо Mutex если чтений значительно больше записей

---

13. Работа с файлами

Что это?

Пакеты os, io, bufio, path/filepath предоставляют инструменты для работы с файловой системой: чтение, запись, навигация по директориям.

Как правильно

import (
    "bufio"
    "os"
    "path/filepath"
)

// ── ЧТЕНИЕ ────────────────────────────────────────────────────────────────────

// Читать весь файл целиком — удобно для небольших файлов
data, err := os.ReadFile("config.json")
fmt.Println(string(data))

// Построчное чтение — для больших файлов (логи, CSV)
f, err := os.Open("big.log")
if err != nil {
    log.Fatal(err)
}
defer f.Close()

scanner := bufio.NewScanner(f)
// Увеличить буфер для очень длинных строк
scanner.Buffer(make([]byte, 1<<20), 1<<20) // 1 MB буфер

for scanner.Scan() {
    line := scanner.Text()
    processLine(line)
}
if err := scanner.Err(); err != nil {
    log.Fatal(err)
}

// ── ЗАПИСЬ ────────────────────────────────────────────────────────────────────

// Записать весь файл сразу — атомарная запись (0644 = rw-r--r--)
err = os.WriteFile("output.txt", []byte("содержимое"), 0644)

// Добавление в конец файла (append)
f, err = os.OpenFile("app.log",
    os.O_APPEND|os.O_CREATE|os.O_WRONLY, // флаги
    0644,
)
if err != nil {
    log.Fatal(err)
}
defer f.Close()

// bufio.Writer буферизует запись — меньше системных вызовов
writer := bufio.NewWriter(f)
writer.WriteString("строка лога\n")
writer.Flush() // ВАЖНО: сбросить буфер в файл перед закрытием

// Атомарная запись (write-then-rename) — защита от частичной записи при краше
func writeAtomic(path string, data []byte) error {
    tmp := path + ".tmp"
    if err := os.WriteFile(tmp, data, 0644); err != nil {
        return err
    }
    return os.Rename(tmp, path) // атомарная операция на большинстве ОС
}

// ── НАВИГАЦИЯ ─────────────────────────────────────────────────────────────────

// Информация о файле
info, err := os.Stat("file.txt")
if os.IsNotExist(err) {
    fmt.Println("Файл не существует")
}
fmt.Println(info.Size(), info.IsDir(), info.ModTime())

// Работа с путями — os-независимо
path := filepath.Join("dir", "subdir", "file.txt")   // dir/subdir/file.txt
dir := filepath.Dir("/usr/local/go/bin/go")           // /usr/local/go/bin
base := filepath.Base("/usr/local/go/bin/go")          // go
ext := filepath.Ext("archive.tar.gz")                 // .gz
abs, _ := filepath.Abs("./relative")                   // абсолютный путь

// Чтение директории
entries, err := os.ReadDir(".")
for _, e := range entries {
    fmt.Printf("%s\t%v\n", e.Name(), e.IsDir())
}

// Рекурсивный обход дерева
filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
    if err != nil {
        return err // пропустить недоступные файлы
    }
    if d.IsDir() && d.Name() == ".git" {
        return filepath.SkipDir // пропустить поддерево
    }
    if !d.IsDir() && filepath.Ext(path) == ".go" {
        fmt.Println(path) // все .go файлы
    }
    return nil
})

Как неправильно

// ❌ ПЛОХО: не закрывать файл
f, _ := os.Open("file.txt")
// f.Close() забыли — файловый дескриптор утёк

// ❌ ПЛОХО: читать большой файл целиком
data, _ := os.ReadFile("100gb.log") // OOM

// ❌ ПЛОХО: не Flush буферизованный writer
writer := bufio.NewWriter(f)
writer.WriteString("данные")
// writer.Flush() забыли — данные остались в буфере, в файл не записались
f.Close()

// ❌ ПЛОХО: конкатенировать пути через "/"
path := "dir" + "/" + "file.txt" // сломается на Windows
// ✅ ХОРОШО
path = filepath.Join("dir", "file.txt")

> [!danger] Главное запомнить > 1. defer f.Close() сразу после os.Open/os.Create > 2. bufio.Writer требует явного Flush() перед закрытием > 3. Большие файлы — потоковое чтение через Scanner, не ReadFile > 4. Пути — через filepath.Join, не конкатенацию строк

---

14. Переменные окружения и конфиг

Что это?

12-factor app принцип: конфигурация (пароли, порты, URL сервисов) должна передаваться через переменные окружения, а не зашиваться в код. В development используется .env файл, в production — системные переменные.

Как правильно

// Базовое использование
dbHost := os.Getenv("DB_HOST") // "" если переменная не установлена

// Со значением по умолчанию
func getEnv(key, defaultVal string) string {
    if val, ok := os.LookupEnv(key); ok {
        return val
    }
    return defaultVal
}

func getEnvInt(key string, defaultVal int) int {
    if val, ok := os.LookupEnv(key); ok {
        if i, err := strconv.Atoi(val); err == nil {
            return i
        }
        log.Printf("Некорректное значение %s=%s, используем %d", key, val, defaultVal)
    }
    return defaultVal
}
# .env файл (НЕ добавлять в git!)
DB_HOST=localhost
DB_PORT=5432
DB_NAME=mydb
DB_PASSWORD=secret
SERVER_PORT=8080
import "github.com/joho/godotenv"

func init() {
    // Загружает .env в окружение процесса
    // В production .env нет — переменные берутся из системы
    if err := godotenv.Load(); err != nil {
        log.Println("Файл .env не найден — используем системные переменные")
    }
}

// Конфиг-структура — центральное место для всех настроек
type Config struct {
    DB struct {
        Host     string
        Port     int
        Name     string
        Password string
    }
    Server struct {
        Port int
        Host string
    }
}

func LoadConfig() (*Config, error) {
    cfg := &Config{}

    cfg.DB.Host = getEnv("DB_HOST", "localhost")
    cfg.DB.Port = getEnvInt("DB_PORT", 5432)

    // Обязательные поля — ошибка если не установлены
    cfg.DB.Name = os.Getenv("DB_NAME")
    if cfg.DB.Name == "" {
        return nil, errors.New("переменная DB_NAME обязательна")
    }

    cfg.DB.Password = os.Getenv("DB_PASSWORD")
    if cfg.DB.Password == "" {
        return nil, errors.New("переменная DB_PASSWORD обязательна")
    }

    cfg.Server.Port = getEnvInt("SERVER_PORT", 8080)
    cfg.Server.Host = getEnv("SERVER_HOST", "0.0.0.0")

    return cfg, nil
}

func main() {
    cfg, err := LoadConfig()
    if err != nil {
        log.Fatal("Ошибка конфигурации:", err)
    }

    dsn := fmt.Sprintf("host=%s port=%d dbname=%s password=%s sslmode=disable",
        cfg.DB.Host, cfg.DB.Port, cfg.DB.Name, cfg.DB.Password)
    // ...
}

Как неправильно

// ❌ ПЛОХО: зашивать конфигурацию в код
db, _ := sql.Open("postgres", "host=prod.db password=myRealPassword123 dbname=prod")
// пароль в коде = пароль в git = утечка

// ❌ ПЛОХО: добавлять .env в git
// .gitignore ОБЯЗАН содержать:
// .env
// *.env
// .env.local

// ❌ ПЛОХО: os.Getenv без LookupEnv когда важно "установлена ли переменная"
val := os.Getenv("FEATURE_FLAG") // "" — это "не установлена" или "установлена в пустую строку"?

// ✅ ХОРОШО: os.LookupEnv различает эти случаи
val, ok := os.LookupEnv("FEATURE_FLAG")
if !ok {
    // переменная не установлена вообще
}

> [!danger] Главное запомнить > 1. .env в .gitignore — никогда не коммитить секреты > 2. В production секреты через переменные окружения или vault (Vault, AWS SSM) > 3. Валидировать обязательные переменные при старте — лучше упасть сразу, чем в runtime > 4. LookupEnv вместо Getenv когда важно различить "пустая строка" и "не установлена"

---

15. Логирование

Что это?

Логирование — запись событий приложения для диагностики и мониторинга. В Go есть стандартный log, современный структурированный log/slog (Go 1.21+), и высокопроизводительный zap от Uber.

Зачем нужно?

В production нет дебаггера. Логи — единственный способ понять, что происходит в реальном времени и что произошло до ошибки. Структурированные логи (ключ-значение) важнее текстовых — их можно фильтровать, агрегировать, строить дашборды.

Как правильно

// ── СТАНДАРТНЫЙ LOG ───────────────────────────────────────────────────────────
// Используйте для простых утилит и скриптов

log.SetFlags(log.LstdFlags | log.Lshortfile)
// 2024/01/15 10:30:00 main.go:42: Сообщение

log.Printf("Сервер запущен на порту %d", 8080)
log.Fatal("Критическая ошибка:", err) // log.Print + os.Exit(1)

// ── SLOG (Go 1.21+) ───────────────────────────────────────────────────────────
// Используйте для большинства приложений

import "log/slog"

// Text логгер — удобен при разработке
textLogger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelDebug,
}))

// JSON логгер — для production (Elasticsearch, Loki, CloudWatch)
jsonLogger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
}))

// Установить как логгер по умолчанию
slog.SetDefault(jsonLogger)

// Базовое использование
slog.Info("Запрос обработан",
    slog.String("method", "GET"),
    slog.String("path", "/api/users"),
    slog.Int("status", 200),
    slog.Duration("latency", 15*time.Millisecond),
)

slog.Error("Ошибка БД",
    slog.String("query", "SELECT * FROM users"),
    slog.Any("err", err),
)

// Логгер с постоянными атрибутами — для контекста запроса
func handleRequest(r *http.Request) {
    requestLogger := slog.With(
        slog.String("request_id", r.Header.Get("X-Request-ID")),
        slog.String("user_agent", r.UserAgent()),
    )

    requestLogger.Info("Начало обработки")
    // ... делаем работу ...
    requestLogger.Info("Конец обработки", slog.Int("result_count", 42))
    // В обоих логах будут request_id и user_agent
}
# go get go.uber.org/zap  — если нужна максимальная скорость
// ── ZAP ───────────────────────────────────────────────────────────────────────
// Используйте когда логирование в критическом пути производительности

import "go.uber.org/zap"

// Production: JSON, только Info и выше, без reflection
logger, _ := zap.NewProduction()
defer logger.Sync() // сбросить буфер при завершении

logger.Info("Сервер запущен",
    zap.String("host", "localhost"),
    zap.Int("port", 8080),
)
logger.Error("Ошибка", zap.Error(err))

// Development: текст, цвета, все уровни
logger, _ = zap.NewDevelopment()

// Sugared — медленнее zap, но удобнее API
sugar := logger.Sugar()
sugar.Infof("Пользователь %s (id=%d) вошёл", name, id)
sugar.Infow("Запрос", "method", "GET", "path", "/api")

Уровни логирования

| Уровень | Когда использовать | |---------|-------------------| | Debug | Детали для диагностики. В production отключать | | Info | Нормальные события: старт, остановка, успешные операции | | Warn | Нестандартные ситуации, которые не сломали работу | | Error | Ошибки, которые нужно исправить. Не использовать для 404 | | Fatal | Критическая ошибка → os.Exit(1). Только при старте |

Как неправильно

// ❌ ПЛОХО: fmt.Println вместо логгера
fmt.Println("Ошибка:", err) // нет времени, нет уровня, нельзя фильтровать

// ❌ ПЛОХО: логировать пароли и токены
log.Printf("Подключение: host=%s password=%s", host, password) // утечка секретов

// ❌ ПЛОХО: использовать log.Fatal везде при ошибках
// Fatal вызывает os.Exit — defer не выполнится, соединения не закроются
func handleRequest(...) {
    result, err := db.Query(...)
    if err != nil {
        log.Fatal(err) // убивает весь сервер из-за одного запроса!
    }
}
// ✅ ХОРОШО: Fatal только при старте, Error + return в runtime

// ❌ ПЛОХО: строковое форматирование в slog/zap
slog.Info(fmt.Sprintf("Запрос от %s", ip)) // форматирование происходит всегда
// ✅ ХОРОШО: атрибуты — форматируются только если уровень активен
slog.Info("Запрос", slog.String("ip", ip))

> [!danger] Главное запомнить > 1. Структурированные логи (ключ-значение) вместо строковых — легче фильтровать > 2. log.Fatal — только при инициализации, никогда в обработчиках запросов > 3. Не логировать секреты (пароли, токены, PII) > 4. slog для нового кода, zap если производительность логирования критична

---

16. Обработка ошибок

Что это?

В Go нет исключений. Ошибки — это обычные значения типа error, которые возвращаются из функций явно. Это намеренное решение: ошибки видны в сигнатуре функции и обязательны к обработке.

Зачем нужно именно так?

Исключения (Java, Python) нарушают поток управления — ошибка может "всплыть" через 10 уровней вызовов. В Go каждая функция явно сигнализирует о возможности ошибки, и вызывающий код не может её случайно проигнорировать. Это делает код более предсказуемым.

// ── ОСНОВЫ ────────────────────────────────────────────────────────────────────

// Ошибка — это интерфейс с одним методом
type error interface {
    Error() string
}

// Создание простой ошибки
err := errors.New("что-то пошло не так")

// Ошибка с форматированием
err = fmt.Errorf("не могу загрузить пользователя %d: %w", id, originalErr)
// %w — wrapping: оборачивает ошибку, сохраняя оригинал

// ── ОБЁРТКА И РАЗВОРАЧИВАНИЕ ──────────────────────────────────────────────────

// errors.Is — проверить есть ли в цепочке конкретная ошибка
if errors.Is(err, sql.ErrNoRows) {
    // err может быть обёрнутой: fmt.Errorf("... %w", sql.ErrNoRows)
}

// errors.As — найти в цепочке ошибку конкретного типа
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
    fmt.Println("PostgreSQL код ошибки:", pgErr.Code)
}

// ── КАСТОМНЫЕ ТИПЫ ОШИБОК ────────────────────────────────────────────────────

// Типизированная ошибка несёт дополнительные данные
type NotFoundError struct {
    Resource string
    ID       int
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s с id=%d не найден", e.Resource, e.ID)
}

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("поле '%s': %s", e.Field, e.Message)
}

// В HTTP-обработчике — разный статус в зависимости от типа ошибки
func handleError(w http.ResponseWriter, err error) {
    var notFound *NotFoundError
    var validation *ValidationError

    switch {
    case errors.As(err, &notFound):
        http.Error(w, err.Error(), http.StatusNotFound)
    case errors.As(err, &validation):
        http.Error(w, err.Error(), http.StatusUnprocessableEntity)
    default:
        log.Error("внутренняя ошибка", slog.Any("err", err))
        http.Error(w, "внутренняя ошибка сервера", http.StatusInternalServerError)
    }
}

// ── SENTINEL ERRORS ────────────────────────────────────────────────────────────

// Sentinel — заранее объявленные ошибки-константы
// Позволяют проверять конкретные условия через errors.Is
var (
    ErrNotFound     = errors.New("не найдено")
    ErrUnauthorized = errors.New("нет прав доступа")
    ErrConflict     = errors.New("конфликт данных")
)

func getUser(id int) (*User, error) {
    u, ok := db[id]
    if !ok {
        // Оборачиваем: добавляем контекст, сохраняем тип
        return nil, fmt.Errorf("getUser(%d): %w", id, ErrNotFound)
    }
    return u, nil
}

// Проверка:
user, err := getUser(42)
if errors.Is(err, ErrNotFound) {
    // работаем с "не найдено"
}

// ── ПАНИКА И RECOVER ─────────────────────────────────────────────────────────

// panic — только для "невозможных" ситуаций (программных ошибок)
// Не использовать для ожидаемых ошибок (нет записи в БД, неверный ввод)
func divide(a, b int) int {
    if b == 0 {
        panic("деление на ноль — ошибка программиста") // не ошибка пользователя
    }
    return a / b
}

// recover — перехватить панику и преобразовать в ошибку
func safeExecute(fn func()) (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("паника: %v\n%s", r, debug.Stack())
        }
    }()
    fn()
    return nil
}

Паттерн обработки ошибок в слоях

// Репозиторий — работает с БД, оборачивает ошибки БД
type UserRepository struct{ db *sql.DB }

func (r *UserRepository) GetByID(ctx context.Context, id int) (*User, error) {
    var u User
    err := r.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id).
        Scan(&u.ID, &u.Name)

    if errors.Is(err, sql.ErrNoRows) {
        return nil, fmt.Errorf("repository.GetByID: %w", ErrNotFound)
    }
    if err != nil {
        return nil, fmt.Errorf("repository.GetByID: %w", err)
    }
    return &u, nil
}

// Сервис — бизнес-логика, добавляет свой контекст
type UserService struct{ repo *UserRepository }

func (s *UserService) GetProfile(ctx context.Context, id int) (*UserProfile, error) {
    user, err := s.repo.GetByID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("service.GetProfile: %w", err) // не теряем тип!
    }
    return buildProfile(user), nil
}

// HTTP-обработчик — транслирует ошибки в HTTP-статусы
func (h *Handler) getProfile(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.Atoi(chi.URLParam(r, "id"))

    profile, err := h.service.GetProfile(r.Context(), id)
    if err != nil {
        if errors.Is(err, ErrNotFound) {
            respondError(w, 404, "пользователь не найден")
            return
        }
        // Логируем с полной цепочкой: "service.GetProfile: repository.GetByID: sql: ..."
        slog.Error("getProfile", slog.Any("err", err))
        respondError(w, 500, "внутренняя ошибка")
        return
    }

    respond(w, 200, profile)
}

Как неправильно

// ❌ ПЛОХО: игнорировать ошибку
result, _ := db.Query("SELECT ...")

// ❌ ПЛОХО: логировать и возвращать
func getUser(id int) (*User, error) {
    u, err := repo.Get(id)
    if err != nil {
        log.Println("Ошибка:", err) // ошибка залогирована
        return nil, err             // и возвращена — будет залогирована снова выше!
    }
    return u, nil
}
// Итог: одна ошибка — 5 одинаковых строк в логах

// ✅ ХОРОШО: либо логировать, либо возвращать (не оба варианта)
// Обычно логирует только верхний уровень (HTTP-обработчик)

// ❌ ПЛОХО: использовать panic для ожидаемых ошибок
func getUser(id int) *User {
    u, err := repo.Get(id)
    if err != nil {
        panic(err) // убьёт весь сервер если запись не найдена!
    }
    return u
}

// ❌ ПЛОХО: терять контекст при оборачивании
return fmt.Errorf("ошибка: %v", err) // %v — строка, errors.Is/As не работают
// ✅ ХОРОШО: %w сохраняет тип ошибки
return fmt.Errorf("getUser(%d): %w", id, err)

> [!danger] Главное запомнить > 1. Либо логировать ошибку, либо возвращать — не оба варианта > 2. Оборачивать через %w, не %v — сохраняет тип для errors.Is/As > 3. panic — только для программных ошибок (nil pointer, нарушение инварианта), не для ошибок данных > 4. Верхний уровень (HTTP-обработчик) транслирует типы ошибок в HTTP-статусы

---

Финальный пример: REST API с PostgreSQL

Всё вместе — небольшой, но production-ready сервер.

package main

import (
    "context"
    "encoding/json"
    "errors"
    "log/slog"
    "net/http"
    "os"
    "strconv"
    "time"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/joho/godotenv"
)

// ── МОДЕЛИ ────────────────────────────────────────────────────────────────────

type Todo struct {
    ID        int       `json:"id"`
    Title     string    `json:"title"`
    Done      bool      `json:"done"`
    CreatedAt time.Time `json:"created_at"`
}

var ErrNotFound = errors.New("не найдено")

// ── РЕПОЗИТОРИЙ ───────────────────────────────────────────────────────────────

type TodoRepo struct {
    db *pgxpool.Pool
}

func (r *TodoRepo) List(ctx context.Context) ([]Todo, error) {
    rows, err := r.db.Query(ctx, "SELECT id, title, done, created_at FROM todos ORDER BY id")
    if err != nil {
        return nil, fmt.Errorf("TodoRepo.List: %w", err)
    }
    defer rows.Close()

    var todos []Todo
    for rows.Next() {
        var t Todo
        if err := rows.Scan(&t.ID, &t.Title, &t.Done, &t.CreatedAt); err != nil {
            return nil, fmt.Errorf("TodoRepo.List scan: %w", err)
        }
        todos = append(todos, t)
    }
    return todos, rows.Err()
}

func (r *TodoRepo) Create(ctx context.Context, title string) (*Todo, error) {
    var t Todo
    err := r.db.QueryRow(ctx,
        "INSERT INTO todos (title) VALUES ($1) RETURNING id, title, done, created_at",
        title,
    ).Scan(&t.ID, &t.Title, &t.Done, &t.CreatedAt)
    if err != nil {
        return nil, fmt.Errorf("TodoRepo.Create: %w", err)
    }
    return &t, nil
}

// ── ХЕНДЛЕРЫ ─────────────────────────────────────────────────────────────────

type Handler struct {
    repo   *TodoRepo
    logger *slog.Logger
}

func (h *Handler) respond(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

func (h *Handler) respondErr(w http.ResponseWriter, status int, msg string) {
    h.respond(w, status, map[string]string{"error": msg})
}

func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
    todos, err := h.repo.List(r.Context())
    if err != nil {
        h.logger.Error("list todos", slog.Any("err", err))
        h.respondErr(w, 500, "внутренняя ошибка")
        return
    }
    if todos == nil {
        todos = []Todo{} // возвращаем [] а не null
    }
    h.respond(w, 200, todos)
}

func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Title string `json:"title"`
    }
    if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
        h.respondErr(w, 400, "невалидный JSON")
        return
    }
    if input.Title == "" {
        h.respondErr(w, 422, "поле title обязательно")
        return
    }

    todo, err := h.repo.Create(r.Context(), input.Title)
    if err != nil {
        h.logger.Error("create todo", slog.Any("err", err))
        h.respondErr(w, 500, "внутренняя ошибка")
        return
    }
    h.respond(w, 201, todo)
}

// ── MAIN ──────────────────────────────────────────────────────────────────────

func main() {
    godotenv.Load()

    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    dbURL := os.Getenv("DATABASE_URL")
    if dbURL == "" {
        logger.Error("DATABASE_URL не установлен")
        os.Exit(1)
    }

    db, err := pgxpool.New(context.Background(), dbURL)
    if err != nil {
        logger.Error("подключение к БД", slog.Any("err", err))
        os.Exit(1)
    }
    defer db.Close()

    // Схема — в production использовать миграции
    db.Exec(context.Background(), `
        CREATE TABLE IF NOT EXISTS todos (
            id         SERIAL PRIMARY KEY,
            title      TEXT NOT NULL,
            done       BOOLEAN DEFAULT false,
            created_at TIMESTAMPTZ DEFAULT now()
        )
    `)

    h := &Handler{
        repo:   &TodoRepo{db: db},
        logger: logger,
    }

    r := chi.NewRouter()
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Use(middleware.Timeout(30 * time.Second))

    r.Get("/todos", h.list)
    r.Post("/todos", h.create)

    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }

    srv := &http.Server{
        Addr:         ":" + port,
        Handler:      r,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
    }

    logger.Info("Сервер запущен", slog.String("port", port))
    if err := srv.ListenAndServe(); err != nil {
        logger.Error("сервер упал", slog.Any("err", err))
        os.Exit(1)
    }
}

---

> [!tip] Что читать дальше > - [pkg.go.dev](https://pkg.go.dev) — документация всех стандартных пакетов > - [Go by Example](https://gobyexample.com) — примеры с объяснениями > - [Effective Go](https://go.dev/doc/effective_go) — официальный стайл-гайд > - [100 Go Mistakes](https://100go.co) — книга о типичных ошибках > - go vet ./... — статический анализ кода > - go test -race ./... — детектор гонок данных

← ВСЕ ПОСТЫ