Привычка из других экосистем звучит так: новый сервис, значит нужен веб фреймворк, логгер, валидатор, конфигуратор и прослойка для базы. В 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 выглядит так:
- задача не решается ни одним штатным пакетом, а не только самым коротким путём;
- пакет закрывает проблему целиком, а не одну функцию из пяти строк;
- у проекта регулярные релизы и внятная история исправлений безопасности;
- зависимость можно заменить без переписывания половины кода сервиса;
- инструмент govulncheck не находит известных уязвимостей в выбранной версии.
Микрозависимость ради функции выравнивания строки или проверки email почти всегда убыточна: пять строк своего кода дешевле, чем транспарентность цепочки поставок и чужие тормоза при обновлениях. Драйвер Postgres с полным протоколом, клиент Redis или библиотека работы с конкретным облачным хранилищем находятся по другую сторону границы: протокол большой, тесты дорого писать, реиспользуемость доказана годами. Команда go mod tidy вычищает неиспользуемое, зеркало модулей с базой контрольных сумм защищает исходники от подмены, govulncheck прогоняет зависимости по базе уязвимостей. Самодостаточность по разуму: минимум импортов не как догма, а как следствие честного сравнения затрат.
Продакшен сервис на одной стандартной библиотеке в Go не подвиг, а рабочий будничный вариант. Сервер с маршрутизацией и плавной остановкой, структурированные логи, JSON, пул соединений, тесты с детектором гонок, профилирование и метрики умещаются без единого внешнего импорта. За пределами ядра остаются драйверы баз, экзотические протоколы и громоздкие форматы, и каждая такая зависимость добавляется с открытыми глазами: с фильтром на входе, проверкой уязвимостей и планом на замену. Такой сервис проще читать новому разработчику, быстрее собирается и застревает на старых версиях реже, и это в продакшене стоит дороже любой экономии на строчках.