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

Состав стандартной библиотеки из почти двух сотен публичных пакетов

Команда go list std выводит около двух сотен публичных пакетов, и это не считая внутренних. В списке: net/http для серверов и клиентов, encoding/json для сериализации, database/sql для запросов, crypto/tls для защищённых соединений, testing для тестов, net/http/pprof для профилирования, embed для упаковки файлов прямо в бинарник, log/slog для структурированных логов. Детектор гонок данных и фаззер встроены в набор инструментов, отдельные утилиты ставить не нужно.

За списком стоит обещание совместимости Go 1: код, собранный на одной версии языка, собирается и на следующих без правки импортов. Обновления безопасности для стандартной библиотеки выпускает сама команда языка, ждать патча от стороннего автора не приходится. Ошибка в net/http или crypto/tls чинится в одном месте для всей экосистемы сразу, и уязвимость в популярном внешнем пакете не догоняет ваш сервис через цепочку зависимостей.

Отдельная продакшен история про поставку. Пакет embed упаковывает файлы прямо в исполняемый файл, поэтому миграции, шаблоны страниц и локализация едут внутри бинарника без отдельного шага копирования:

import "embed"

//go:embed migrations/*.sql
var migrationsFS embed.FS

files, _ := migrationsFS.ReadDir("migrations")
for _, f := range files {
	data, _ := migrationsFS.ReadFile("migrations/" + f.Name())
	_, _ = db.ExecContext(ctx, string(data))
}

Один статический файл без внешних файлов и интерпретатора переносится на сервер командой cp и запускается, а флаги сборки вроде CGO_ENABLED=0 дают бинарник, который одинаково работает в альпийском контейнере и на голом железе.

Веб сервис на чистом net/http с маршрутизацией и корректными таймаутами

Версия Go 1.22 сделала встроенный маршрутизатор ServeMux выразительным: шаблоны понимают HTTP методы и подстановки, GET автоматически покрывает и HEAD, конфликты шаблонов решаются по специфичности. Сервер со всей обвязкой помещается в один файл:

mux := http.NewServeMux()

mux.HandleFunc("GET /api/users/{id}", func(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	user, err := loadUser(r.Context(), id)
	if err != nil {
		http.Error(w, "user not found", http.StatusNotFound)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(user)
})

mux.HandleFunc("POST /api/users", createUser)

srv := &http.Server{
	Addr:              ":8080",
	Handler:           mux,
	ReadHeaderTimeout: 5 * time.Second,
	ReadTimeout:       15 * time.Second,
	WriteTimeout:      30 * time.Second,
	IdleTimeout:       60 * time.Second,
	MaxHeaderBytes:    1 << 20,
}
srv.ListenAndServe()

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

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

go func() {
	if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		log.Fatalf("listen: %v", err)
	}
}()

<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)

Метод Shutdown дожидается завершения текущих запросов или истечения лимита, после чего новые соединения не принимаются. Для защищённых соединений net/http сам договаривается по ALPN о HTTP/2, отдельная настройка не нужна. Чистый HTTP/2 без шифрования остаётся за пределами ядра и требует пакета golang.org/x/net/http2/h2c, это одна из честных границ.

JSON конфигурация и логирование встроенными пакетами

Сериализация строится на тегах полей и работает без отражений в руках разработчика:

type User struct {
	Email string `json:"email"`
	Age   int    `json:"age,omitempty"`
}

raw, err := json.Marshal(user)
err = json.Unmarshal(body, &user)

Пакет log/slog, вошедший в состав ядра с версии 1.21, даёт структурированные логи уровнями и JSON форматом вывода:

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
	Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
slog.Info("запрос обработан", "user", id, "ms", elapsed)

Реальная строка лога выглядит так: {"time":"2024-05-12T10:15:03.128946022+03:00","level":"INFO","msg":"запрос обработан","user":"42","ms":17}. Пары ключ значение разбираются любым сборщиком логов без регулярных выражений, уровни фильтруются на месте, обработчики пишутся под свои нужды и проверяются штатным testing/slogtest.

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

dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
	http.Error(w, "bad request", http.StatusBadRequest)
	return
}

Частичный разбор больших документов строится на json.RawMessage: поле сохраняется сырым куском и разбирается позже, только когда его структура становится известна. Обратный полёт тоже настраивается: json.MarshalIndent печатает человекочитаемый JSON для логов и файлов конфигурации.

Конфигурация в ядре тоже есть, пусть и скромная: пакет flag разбирает аргументы командной строки, os.Getenv читает переменные окружения. Парсеров YAML и TOML в стандартной библиотеке нет, и это не случайность: JSON с файлом конфигурации или переменные окружения покрывают большинство сервисов, а расширение набора форматов было бы вечным обязательством.

База данных через database/sql и место внешнего драйвера

Пакет database/sql задаёт единый интерфейс к SQL базам: пул соединений, контекстные вызовы, prepared statements, обработка ошибок. Сами драйверы в ядро не вошли, их ставят отдельно, для Postgres это lib/pq или pgx, для MySQL go-sql-driver/mysql. Код сервиса при этом не зависит от выбранного драйвера:

db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
	log.Fatal(err)
}
defer db.Close()

db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)

var name sql.NullString
err = db.QueryRowContext(ctx,
	"SELECT nickname FROM users WHERE id = $1", id).Scan(&name)
if errors.Is(err, sql.ErrNoRows) {
	http.Error(w, "not found", http.StatusNotFound)
	return
}

Типы вроде sql.NullString честно отражают семантику NULL из SQL в статическую типизацию Go, без ссылок на отсутствие значения через пустую строку. Прослойки уровня ОРМ для типовых сервисов нередко оказываются лишним слоем: прямые запросы читаются, переносятся в код ревью и не требуют изучения собственной грамматики маппинга. Когда выборка разрослась до динамических условий и агрегаций, у запроса обычно появляется собственный слой репозитория, а ОРМ всё равно не спасает.

Тесты бенчмарки и профилирование без установки лишних утилит

Тесты интеграционного уровня пишутся на штатном httptest, который поднимает реальный сервер на случайном порту:

func TestUserHandler(t *testing.T) {
	mux := http.NewServeMux()
	registerRoutes(mux)
	srv := httptest.NewServer(mux)
	defer srv.Close()

	resp, err := http.Get(srv.URL + "/api/users/1")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("ожидали 200, получили %d", resp.StatusCode)
	}
}

Одни и те же проверки гоняются под флагами без внешних раннеров:

go test -race ./...
go test -bench=. -benchmem ./internal/store
go test -fuzz=. -fuzztime=30s ./internal/parse

Детектор гонок ловит конкурентный доступ к памяти, бенчмарки с подсчётом аллокаций показывают регресс производительности, фаззер генерирует входные данные и находит падения парсеров. Типовой бенчмарк умещается в пять строк и меряет то, что действительно меряется:

func BenchmarkEncode(b *testing.B) {
	users := makeUsers(1000)
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		_, _ = json.Marshal(users)
	}
}

Флаг -count=10 отсеивает случайные выбросы, а сравнение двух версий делается штатной командой benchstat из отдельного модуля или простым просмотром таблицы результатов. Продакшен наблюдаемость тоже начинается без импортов: один пустой импорт net/http/pprof открывает эндпоинты профилирования, пакет expvar публикует метрики в JSON по пути /debug/vars:

import (
	_ "expvar"
	_ "net/http/pprof"
)

go http.ListenAndServe("localhost:6060", nil)

Срез процессора снимается одной командой и открывается в штатном анализаторе:

go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30

Пробелы стандартной библиотеки от YAML до планировщика задач

Список того, чего в ядре нет, полезно знать так же точно, как список того, что есть. Нет парсеров YAML и TOML, нет клиентов Redis и брокеров сообщений, нет вебсокетов, нет планировщика задач по расписанию, нет штатной валидации структур, нет ОРМ. Каждый такой пробел закрывается внешним пакетом, и выбор тут превращается в архитектурное решение.

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

Из практических мелочей: чистый HTTP/2 без TLS требует golang.org/x/net/http2/h2c, а чтение метрик из экспортера в Prometheus-совместимом формате не входит в стандартный набор, экспортер ставится отдельно. Названные пробелы не критичны, если сервис HTTP плюс SQL плюс JSON, а это описание большой доли сервисов в продакшене.

Когда сторонний пакет действительно дешевле самодостаточности

Честный фильтр перед добавлением импорта в go.mod выглядит так:

  1. задача не решается ни одним штатным пакетом, а не только самым коротким путём;
  2. пакет закрывает проблему целиком, а не одну функцию из пяти строк;
  3. у проекта регулярные релизы и внятная история исправлений безопасности;
  4. зависимость можно заменить без переписывания половины кода сервиса;
  5. инструмент govulncheck не находит известных уязвимостей в выбранной версии.

Микрозависимость ради функции выравнивания строки или проверки email почти всегда убыточна: пять строк своего кода дешевле, чем транспарентность цепочки поставок и чужие тормоза при обновлениях. Драйвер Postgres с полным протоколом, клиент Redis или библиотека работы с конкретным облачным хранилищем находятся по другую сторону границы: протокол большой, тесты дорого писать, реиспользуемость доказана годами. Команда go mod tidy вычищает неиспользуемое, зеркало модулей с базой контрольных сумм защищает исходники от подмены, govulncheck прогоняет зависимости по базе уязвимостей. Самодостаточность по разуму: минимум импортов не как догма, а как следствие честного сравнения затрат.

Продакшен сервис на одной стандартной библиотеке в Go не подвиг, а рабочий будничный вариант. Сервер с маршрутизацией и плавной остановкой, структурированные логи, JSON, пул соединений, тесты с детектором гонок, профилирование и метрики умещаются без единого внешнего импорта. За пределами ядра остаются драйверы баз, экзотические протоколы и громоздкие форматы, и каждая такая зависимость добавляется с открытыми глазами: с фильтром на входе, проверкой уязвимостей и планом на замену. Такой сервис проще читать новому разработчику, быстрее собирается и застревает на старых версиях реже, и это в продакшене стоит дороже любой экономии на строчках.