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, ¬Found):
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 ./... — детектор гонок данных