Go Архитектура и прод-код на Go: слои, DI, конфигурация и БД
0%

Архитектура и прод-код на Go: слои, DI, конфигурация и БД

Архитектура и продакшн-код на Go

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

Слои и гексагональная архитектура — по-гошному

Классическая идея: бизнес-логика не должна знать про детали — про то, PostgreSQL там или MySQL, HTTP или gRPC на входе. Это гексагональная архитектура (она же «порты и адаптеры»), она же в упрощённом виде — чистая/слоистая архитектура. В Go её реализуют без церемоний: через пакеты и интерфейсы.

Три ключевых слоя и правило зависимостей — всё указывает внутрь, к домену:

  • Домен (internal/domain) — типы и правила предметной области. Не импортирует ничего инфраструктурного.
  • Сервис/юзкейсы (internal/service) — оркестрация бизнес-логики. Зависит от домена и от интерфейсов (портов), а не от конкретных БД/транспорта.
  • Адаптеры (internal/http, internal/storage) — реализуют порты и переводят внешний мир (HTTP-запрос, строка БД) в доменные термины.

Ключевой приём — интерфейс объявляет тот, кто его использует (сервис), а реализует адаптер. Так стрелка зависимости направлена от инфраструктуры к ядру, а не наоборот.

// internal/service/user.go — ПОРТ объявлен здесь, рядом с потребителем
package service

type UserRepository interface {              // маленький интерфейс — только нужное
	ByID(ctx context.Context, id int64) (domain.User, error)
	Save(ctx context.Context, u domain.User) error
}

type UserService struct {
	repo UserRepository   // зависимость — интерфейс, не конкретный тип
	log  *slog.Logger
}

func NewUserService(repo UserRepository, log *slog.Logger) *UserService {
	return &UserService{repo: repo, log: log}
}

func (s *UserService) Rename(ctx context.Context, id int64, name string) error {
	u, err := s.repo.ByID(ctx, id)
	if err != nil {
		return fmt.Errorf("Rename: %w", err)
	}
	if err := u.SetName(name); err != nil {   // бизнес-правило живёт в домене
		return fmt.Errorf("Rename: %w", err)
	}
	return s.repo.Save(ctx, u)
}

Не переусердствуйте. Для маленького сервиса три пакета — уже архитектура. Плодить слои «на всякий случай» — тоже антипаттерн; в Go ценят соразмерность.

Внедрение зависимостей без фреймворка

В Go DI — это просто передача зависимостей в конструкторы. Никаких контейнеров, аннотаций и магии по умолчанию не нужно. Всё дерево зависимостей собирается вручную в одном месте — в main. Это называют «composition root».

// cmd/server/main.go — здесь и только здесь всё связывается воедино
func main() {
	cfg := config.Load()

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

	pool, err := pgxpool.New(context.Background(), cfg.DatabaseURL)
	if err != nil {
		log.Error("подключение к БД", "err", err)
		os.Exit(1)
	}
	defer pool.Close()

	// Снизу вверх: репозиторий -> сервис -> хендлеры -> роутер -> сервер
	userRepo := storage.NewUserRepo(pool)
	userSvc := service.NewUserService(userRepo, log)
	handler := httpapi.NewHandler(userSvc, log)

	srv := &http.Server{Addr: cfg.Addr, Handler: handler.Router()}
	// ... graceful shutdown из главы про конкурентность
}

Плюсы явного DI: видно всё дерево зависимостей, легко подменить реализацию в тестах, нет рантайм-сюрпризов. Когда граф становится огромным, есть google/wire — он генерирует код связывания на этапе компиляции (не рефлексия!). Но большинству сервисов хватает ручной сборки — начинайте с неё.

Конфигурация через окружение

Стандарт индустрии — 12-Factor App: конфигурация приходит из переменных окружения, а не из файлов в репозитории. Секреты — тем более (их подкладывает секрет-менеджер). Идиома — распарсить env один раз при старте в типизированную структуру и провалиться на старте, если чего-то не хватает («fail fast»).

package config

type Config struct {
	Addr        string
	DatabaseURL string
	LogLevel    string
	Timeout     time.Duration
}

func Load() Config {
	return Config{
		Addr:        getEnv("APP_ADDR", ":8080"),
		DatabaseURL: mustEnv("DATABASE_URL"),           // упадём, если не задано
		LogLevel:    getEnv("LOG_LEVEL", "info"),
		Timeout:     getDuration("HTTP_TIMEOUT", 30*time.Second),
	}
}

func mustEnv(key string) string {
	v, ok := os.LookupEnv(key)
	if !ok {
		log.Fatalf("обязательная переменная %s не задана", key)
	}
	return v
}

Популярные библиотеки, если ручного парсинга мало: caarlos0/env (маппинг env в структуру по тегам), spf13/viper (мощно, но тяжело — для CLI с флагами и файлами). Для сервиса чаще достаточно env или пары строк своего кода. Никогда не коммитьте секреты; для локальной разработки — .env в .gitignore и godotenv.

HTTP: net/http и роутеры

net/http из стандартной библиотеки — полноценный продакшн-сервер, на нём одном можно писать сервисы. С Go 1.22 встроенный http.ServeMux научился методам и path-параметрам в паттернах — теперь для многих задач роутер вообще не нужен:

mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")               // path-параметр из паттерна (Go 1.22+)
	// ...
})
mux.HandleFunc("POST /users", createUser)

Когда нужны богатые middleware, группы маршрутов и удобства — берут сторонний роутер. Trade-offs трёх популярных:

  • chi — тонкий, идиоматичный, на 100% совместим с net/http (хендлеры — обычные http.Handler). Отличные middleware, роутинг-группы. Лучший выбор, когда хотите «стандартный Go, только удобнее». Рекомендуется по умолчанию.
  • echo — фреймворк с собственным Context, встроенными байндингом/валидацией/рендерингом. Быстрее пишется CRUD, но вы уходите от стандартных интерфейсов.
  • gin — самый популярный, очень быстрый, большая экосистема, свой gin.Context. Минус тот же — не net/http-нативный, и API местами «магический».

Совет: начинайте со стандартного net/http + chi. К echo/gin переходите, только если их удобства реально экономят время команды. Своя абстракция поверх http.Handler почти всегда лучше вендор-лока.

Хендлер, отделённый от бизнес-логики, — это тонкий адаптер: распарсить запрос, вызвать сервис, отдать ответ.

func (h *Handler) getUser(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		http.Error(w, "invalid id", http.StatusBadRequest)
		return
	}
	u, err := h.svc.ByID(r.Context(), id)      // прокидываем context запроса!
	switch {
	case errors.Is(err, service.ErrNotFound):
		http.Error(w, "not found", http.StatusNotFound)
		return
	case err != nil:
		h.log.Error("getUser", "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(u)
}

Всегда пробрасывайте r.Context() вниз — так отмена запроса дойдёт до БД (см. конкурентность). И всегда ставьте таймауты на http.Server (ReadTimeout, WriteTimeout, IdleTimeout) — сервер без таймаутов уязвим к медленным клиентам.

База данных: database/sql, pgx и sqlc

Стандартный интерфейс — пакет database/sql: драйверо-независимый, с пулом соединений. Для PostgreSQL стандарт де-факто — драйвер pgx, причём его нативный API (pgxpool) быстрее и богаче, чем через database/sql.

pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
// ...
var u domain.User
err = pool.QueryRow(ctx,
	`SELECT id, name, email FROM users WHERE id = $1`, id,
).Scan(&u.ID, &u.Name, &u.Email)
if errors.Is(err, pgx.ErrNoRows) {
	return domain.User{}, service.ErrNotFound   // переводим в доменную ошибку
}

Про ORM: в Go к тяжёлым ORM (gorm) относятся с прохладцей — они прячут SQL и генерируют неожиданные запросы. Идиоматичнее — писать SQL явно. Здесь блистает sqlc: вы пишете обычный SQL, а sqlc генерирует типобезопасный Go-код (структуры и методы) на этапе сборки. Никакой рефлексии в рантайме, полный контроль над запросами, компилятор проверяет типы.

-- query.sql
-- name: GetUser :one
SELECT id, name, email FROM users WHERE id = $1;
sqlc generate   # получаем db.GetUser(ctx, id) (User, error) — типизированно

Практические правила работы с БД:

  • Всегда QueryContext/QueryRowContext с контекстом — для таймаутов и отмены.
  • Настраивайте пул: SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime (или параметры pgxpool). Дефолты не годятся для прода.
  • Транзакции — через BeginTx, с defer tx.Rollback() (rollback после commit — no-op, безопасно).
  • Миграции — отдельным инструментом: golang-migrate, goose или atlas. Версионируйте схему в migrations/, накатывайте отдельным шагом деплоя, а не «на старте приложения втихую».
  • Всегда параметризованные запросы ($1, ?) — никогда конкатенация строк (SQL-инъекции).

Устойчивость: таймауты, ретраи, circuit breaker

Прод — это сеть, которая падает. Паттерны устойчивости:

  • Таймауты везде. Каждый внешний вызов — под context.WithTimeout. Отсутствие таймаута — самая частая причина каскадных отказов.
  • Ретраи с экспоненциальной задержкой и джиттером — только для идемпотентных операций и временных ошибок. Ретраить неидемпотентный POST вслепую — путь к дублям.
    var lastErr error
    for attempt := 0; attempt < 3; attempt++ {
    	if err := call(ctx); err == nil {
    		return nil
    	} else {
    		lastErr = err
    	}
    	select {
    	case <-time.After(backoff(attempt)):   // задержка растёт: 100ms, 200ms, 400ms + джиттер
    	case <-ctx.Done():
    		return ctx.Err()
    	}
    }
    return lastErr
    
  • Circuit breaker — перестать долбить упавший сервис, дав ему восстановиться (sony/gobreaker).
  • Rate limitinggolang.org/x/time/rate для ограничения нагрузки на себя и на зависимости.
  • Graceful shutdown — из главы про конкурентность, обязательно.

Структурное логирование: log/slog

С Go 1.21 в стандартной библиотеке есть log/slog — структурное логирование. В проде логи должны быть машиночитаемыми (JSON) с полями, а не строками — чтобы их индексировать и искать.

log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
	Level: slog.LevelInfo,
}))
slog.SetDefault(log)

log.Info("пользователь создан",
	slog.Int64("user_id", u.ID),
	slog.String("email", u.Email),
)
// {"time":"...","level":"INFO","msg":"пользователь создан","user_id":42,"email":"..."}

// Логгер с постоянными полями — прокидывайте по цепочке запроса
reqLog := log.With(slog.String("request_id", reqID))

slog заменяет старые logrus/zap для новых проектов (хотя zap всё ещё быстрее в горячих путях). Правила: не логируйте секреты и PII, используйте уровни осмысленно (Error — то, что будит дежурного), прокидывайте request_id/trace_id во все логи запроса. Подробнее про наблюдаемость — в следующей главе.

Итог: как выглядит здоровый сервис

  • Тонкие адаптеры (HTTP/БД) вокруг ядра с бизнес-логикой.
  • Зависимости — интерфейсы, объявленные потребителем; сборка дерева в main.
  • Конфиг из env, fail-fast на старте.
  • net/http+chi, контекст пробрасывается до БД, таймауты на сервере и запросах.
  • Явный SQL через pgx/sqlc, миграции отдельным шагом.
  • Устойчивость: таймауты, разумные ретраи, circuit breaker.
  • Структурные JSON-логи через slog.

Источники

Что дальше

Сервис написан и структурирован. Осталось собрать его в артефакт, упаковать в контейнер и сделать наблюдаемым в проде.

07. Деплой и наблюдаемость

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов