Стандартная библиотека вглубь
«Батарейки в комплекте» — обещание из обзора, которое в Go выполняется буквально: типичный продакшн-сервис тянет три-четыре внешние зависимости, а всё остальное берёт из стандартной библиотеки. Но пользоваться ей на уровне «нашёл функцию, вызвал» — значит потерять половину выгоды. Стандартная библиотека Go — ещё и учебник идиоматичного дизайна: маленькие интерфейсы, композиция вместо конфигурации, честные контракты.
Эта глава — про то, что нужно знать наизусть: io, bufio, encoding/json, time, os/exec, embed и шаблоны. Именно здесь живут ошибки, которые компилятор не поймает.
io.Reader и io.Writer — два метода, на которых держится всё
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
Это буквально всё. Файл, сетевое соединение, тело HTTP-запроса, gzip-поток, буфер в памяти, хеш-функция, шифр, os.Stdout — всё это Reader или Writer. Функция, принимающая io.Reader, автоматически работает с любым из них, включая те, что напишут после вас. Это и есть «accept interfaces» из главы про идиомы в чистейшем виде.
Контракт Read, который читают невнимательно
Read наполняет переданный срез и возвращает, сколько байт реально прочитал. Три правила, нарушение которых даёт плавающие баги:
Readвправе вернуть меньше байт, чем помещается вp, и это не ошибка. Читать «всё сразу» нельзя, только в цикле — или черезio.ReadFull.Readможет вернутьn > 0вместе сerr == io.EOF. Сначала обрабатывайтеnбайт, потом смотрите ошибку. Обратный порядок молча теряет последний кусок данных.io.EOF— не ошибка, а нормальное завершение. Отличайте его отio.ErrUnexpectedEOF, который означает «поток кончился посреди структуры».
buf := make([]byte, 32*1024)
for {
n, err := r.Read(buf)
if n > 0 {
process(buf[:n]) // СНАЧАЛА данные
}
if err == io.EOF {
break // потом завершение
}
if err != nil {
return fmt.Errorf("чтение: %w", err)
}
}
На практике этот цикл писать почти никогда не нужно — он уже написан в io.Copy.
Композиция: сила маленьких интерфейсов
Интерфейсы io не наследуют друг друга — они складываются через встраивание. Отсюда набор функций-комбинаторов, которые заменяют сотни строк ручного кода:
io.Copy(dst, src)— перекачать всё из читателя в писателя. Умнее, чем кажется: если у источника естьWriteTo, а у приёмникаReadFrom, вызовется он, и на Linux копирование файла в сокет может уйти в системный вызовsendfile, вообще не поднимая данные в userspace.io.TeeReader(r, w)— читатель, который попутно пишет всё прочитанное вw. Так считают хеш «по дороге», не читая данные дважды.io.MultiReader(r1, r2, ...)— склейка нескольких источников в один поток.io.MultiWriter(w1, w2)— запись сразу в несколько мест (файл и stdout).io.LimitReader(r, n)— обрезать поток на N байтах. Защита от «сервер прислал 40 ГБ».io.Pipe()— синхронная труба в памяти: то, что пишут в один конец, читается из другого. Нужна, когда API требуетReader, а у вас есть только код, умеющий писать.io.Discard— писатель в никуда, для отбрасывания остатка тела ответа.
Классическая задача целиком: скачать архив, посчитать его SHA-256, распаковать и записать на диск — одним проходом, без временных файлов и без загрузки в память.
io.ReadCloser"] --> LIM["io.LimitReader
потолок 500 МБ"] LIM --> TEE["io.TeeReader"] TEE -->|"копия байтов"| HASH["sha256.New
io.Writer"] TEE --> GZ["gzip.Reader
io.Reader"] GZ --> BUF["bufio.Writer"] BUF --> FILE["os.File"] HASH --> SUM["Итоговая контрольная сумма"]
func download(ctx context.Context, url, dst string) (sum string, err error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("запрос: %w", err)
}
defer resp.Body.Close()
f, err := os.Create(dst)
if err != nil {
return "", fmt.Errorf("создание файла: %w", err)
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr // ошибка закрытия важна при записи
}
}()
h := sha256.New()
limited := io.LimitReader(resp.Body, 500<<20) // не больше 500 МБ
tee := io.TeeReader(limited, h) // считаем хеш попутно
gz, err := gzip.NewReader(tee)
if err != nil {
return "", fmt.Errorf("gzip: %w", err)
}
defer gz.Close()
bw := bufio.NewWriter(f)
if _, err := io.Copy(bw, gz); err != nil { // весь перенос — одна строка
return "", fmt.Errorf("копирование: %w", err)
}
if err := bw.Flush(); err != nil { // без Flush хвост останется в буфере
return "", fmt.Errorf("сброс буфера: %w", err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
Память под эту функцию — десятки килобайт независимо от размера архива. Это и есть практический смысл потоковой модели: io.ReadAll на теле ответа неизвестного размера — прямой путь к OOM.
bufio: почему без буфера медленно
Каждый Read у файла или сокета без буфера — это системный вызов, а системный вызов стоит порядка микросекунды (подробно — в главе про ввод-вывод и системные вызовы). Читать по байту напрямую — значит платить микросекунду за байт. bufio вставляет между вами и источником буфер (по умолчанию 4096 байт) и превращает тысячу мелких чтений в одно крупное.
r := bufio.NewReader(f) // буфер 4096 байт
r = bufio.NewReaderSize(f, 64*1024) // явный размер под крупные записи
w := bufio.NewWriter(f)
defer w.Flush() // САМАЯ ЧАСТАЯ ОШИБКА — забыть Flush
Забытый Flush — классика: программа отработала без ошибок, а в файле нет последних килобайт. defer w.Flush() игнорирует ошибку сброса, поэтому в коде, где потеря данных критична, вызывайте Flush явно и проверяйте результат, как в примере выше.
bufio.Scanner и его ловушка
Scanner — удобный построчный обход, но с жёстким ограничением: токен длиннее 64 КБ роняет сканирование с ошибкой bufio.Scanner: token too long. На логах с длинной JSON-строкой это происходит в проде, а не в тестах.
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) // потолок токена — 4 МБ
for sc.Scan() {
line := sc.Text() // осторожно: Bytes() переиспользует внутренний буфер
process(line)
}
if err := sc.Err(); err != nil { // ОБЯЗАТЕЛЬНО: цикл молча кончается при ошибке
return fmt.Errorf("сканирование: %w", err)
}
Два правила: всегда проверяйте sc.Err() после цикла и помните, что sc.Bytes() возвращает срез внутреннего буфера — он будет перезаписан на следующей итерации, так что для сохранения делайте slices.Clone. Режим разбиения меняется через sc.Split(bufio.ScanWords), а свой SplitFunc позволяет разбирать бинарные протоколы с префиксом длины.
encoding/json: от простого к потоковому
Теги и структуры
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"` // пропустить, если пусто
Password string `json:"-"` // никогда не сериализовать
CreatedAt time.Time `json:"created_at"`
Meta any `json:"meta,omitempty"`
}
Тонкости, на которых спотыкаются:
- Только экспортированные поля попадают в JSON. Поле со строчной буквы молча исчезает — самая частая ошибка новичка.
omitemptyработает по «пустому», а не по «незаданному». Дляintпустое — это0, дляbool—false. ПолеCount intсо значением 0 исчезнет из вывода, хотя вы этого не хотели. Лечение: указатель (*int) либо тегomitzero(Go 1.24), который смотрит именно на нулевое значение типа и работает предсказуемее.- Числа в
map[string]anyстановятсяfloat64. Разбор{"id": 12345678901234567890}вanyтеряет точность. Если нужна точность —json.Numberчерезdec.UseNumber(). - Неизвестные поля игнорируются молча. Для API, где важна строгость, включайте
dec.DisallowUnknownFields().
Потоковый разбор вместо чтения всего в память
json.Unmarshal требует, чтобы все данные уже лежали в []byte. Для тела HTTP-запроса это плохой выбор: злоумышленник пришлёт 2 ГБ и получит OOM. Правильный набор — json.Decoder плюс ограничение размера.
func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // потолок 1 МБ
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields() // опечатка в поле — ошибка, а не тишина
var in createUserRequest
if err := dec.Decode(&in); err != nil {
http.Error(w, "невалидный JSON", http.StatusBadRequest)
return
}
// Проверяем, что после объекта нет мусора: {"a":1}{"b":2} — это ошибка клиента
if dec.More() {
http.Error(w, "лишние данные после объекта", http.StatusBadRequest)
return
}
// ... дальше бизнес-логика
}
Тот же Decoder в цикле разбирает поток объектов (формат JSON Lines, типичный для логов и выгрузок) без загрузки файла целиком:
dec := json.NewDecoder(f)
for {
var rec Record
if err := dec.Decode(&rec); err == io.EOF {
break
} else if err != nil {
return fmt.Errorf("запись %d: %w", n, err)
}
handle(rec)
}
Отложенный и полиморфный разбор
json.RawMessage откладывает разбор куска до момента, когда станет известен его тип, — стандартный приём для «конвертов» с полем type:
type Envelope struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"` // не разбираем сразу
}
var e Envelope
_ = json.Unmarshal(data, &e)
switch e.Type {
case "order":
var o Order
_ = json.Unmarshal(e.Payload, &o)
case "refund":
var r Refund
_ = json.Unmarshal(e.Payload, &r)
}
Свои правила сериализации задают методами MarshalJSON/UnmarshalJSON — так, например, оформляют собственный формат даты или скрывают секреты в логах.
Про скорость
encoding/json работает через рефлексию и потому не рекордсмен. Если профиль (а не интуиция) показал, что упираетесь именно в него, есть варианты: github.com/goccy/go-json и github.com/bytedance/sonic как совместимые по API замены, easyjson с кодогенерацией. В самом Go готовится encoding/json/v2 — переработанная версия с потоковым ядром и исправленными шероховатостями API, доступная под флагом эксперимента. Для 95% сервисов стандартный пакет достаточно быстр, а рефлексия теряется на фоне похода в базу.
time: там, где режут чаще всего
Монотонные и настенные часы
time.Now() возвращает две отметки: настенное время (может прыгать при синхронизации NTP или переводе часов) и монотонные часы (только вперёд). Вычитание t2.Sub(t1) автоматически использует монотонную составляющую — поэтому измерять длительности надо именно так, а не через Unix().
start := time.Now()
doWork()
elapsed := time.Since(start) // монотонно, корректно даже при скачке системных часов
Монотонная часть теряется при сериализации, Round, Truncate и парсинге. Отсюда правило: сравнивайте время через t1.Equal(t2), а не через ==. Оператор == сравнивает структуру целиком, включая монотонную часть и указатель на локацию, и даёт false там, где моменты одинаковы.
Формат времени, который все запоминают
В Go нет %Y-%m-%d. Макет задаётся эталонным моментом: Mon Jan 2 15:04:05 MST 2006 — то есть 1, 2, 3, 4, 5, 6, 7 по порядку.
t.Format("2006-01-02 15:04:05") // 2026-07-16 10:00:00
t.Format(time.RFC3339) // предпочтительно для API и логов
time.Parse(time.RFC3339, s) // разбор
time.ParseInLocation(layout, s, loc) // разбор в конкретной зоне
Часовые пояса берутся из системной базы, которой в scratch- и distroless-образах может не быть. Лечится одной строкой — import _ "time/tzdata" встраивает базу в бинарник (плюс около 450 КБ).
Таймеры и утечки
// Ticker живёт, пока его не остановить
tk := time.NewTicker(time.Second)
defer tk.Stop()
for {
select {
case <-tk.C:
collectMetrics()
case <-ctx.Done():
return
}
}
time.After в горячем цикле select создаёт по таймеру на итерацию. С Go 1.23 недостижимые таймеры собираются сборщиком мусора, но привычка использовать переиспользуемый time.Timer с Reset в циклах остаётся правильной: она экономит и аллокации, и нервы на старых версиях.
Тестируемость
Прямой вызов time.Now() в бизнес-логике делает тесты недетерминированными. Идиома — тонкая зависимость:
type Clock interface{ Now() time.Time }
type realClock struct{}
func (realClock) Now() time.Time { return time.Now() }
type Service struct{ clock Clock } // в тестах подставляем фиксированное время
Это тот же приём «зависимость через маленький интерфейс», что и в главе про тестирование. И никогда не синхронизируйте тесты через time.Sleep — используйте каналы.
os/exec: запуск внешних процессов
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "git", "rev-parse", "HEAD")
cmd.Dir = repoPath
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
out, err := cmd.Output() // stdout; stderr попадёт в err как ExitError.Stderr
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return fmt.Errorf("git завершился с кодом %d: %s", ee.ExitCode(), ee.Stderr)
}
return fmt.Errorf("запуск git: %w", err)
}
Что важно знать:
- Это не shell.
exec.Command("ls", "*.go")не раскроет звёздочку: аргументы передаются процессу как есть. Это не недостаток, а защита от инъекции команд — не восстанавливайте её вызовомsh -cсо склеенной строкой (см. главу про инъекции). CommandContextубивает процесс по отмене контекста. С Go 1.20 поведение настраивается полямиCmd.Cancel(например, послатьSIGTERMвместоSIGKILL) иCmd.WaitDelay(сколько ждать после сигнала).- Порядок при работе с пайпами. Если использовали
StdoutPipe, дочитайте пайп до конца до вызоваWait, иначе получите дедлок или обрезанный вывод. Output()— только stdout,CombinedOutput()— stdout и stderr вперемешку (удобно для отладки, плохо для разбора).
embed: файлы внутри бинарника
Обещание «один статический бинарник» из главы про деплой ломается, если рядом нужно положить шаблоны, миграции и статику. Директива //go:embed (Go 1.16) решает это на уровне компиляции.
import "embed"
//go:embed migrations/*.sql
var migrationsFS embed.FS // виртуальная файловая система
//go:embed templates/*.html
var templatesFS embed.FS
//go:embed VERSION
var version string // можно прямо в строку
func Migrations() fs.FS { return migrationsFS }
embed.FS реализует fs.FS, поэтому подходит куда угодно: template.ParseFS, http.FileServerFS, golang-migrate с драйвером iofs. Тонкости: по умолчанию не встраиваются файлы и каталоги, начинающиеся с . или _ — для них нужен префикс all: (//go:embed all:web/dist). И помните, что встроенное увеличивает бинарник: класть туда стомегабайтные ассеты — плохая идея.
text/template и html/template
Два пакета с почти одинаковым API и принципиально разной ответственностью.
tpl := template.Must(template.New("mail").Parse(`
Здравствуйте, {{ .Name }}!
{{ if .Trial }}Пробный период истекает {{ .ExpiresAt.Format "02.01.2006" }}.{{ end }}
{{ range .Items }}- {{ .Title }} ({{ .Price }} RUB)
{{ end }}`))
var buf bytes.Buffer
if err := tpl.Execute(&buf, data); err != nil { // Execute принимает io.Writer
return fmt.Errorf("шаблон: %w", err)
}
html/template — это не «text/template для HTML», а средство безопасности. Он разбирает результат как HTML и применяет контекстно-зависимое экранирование: одно и то же значение экранируется по-разному внутри текста, внутри атрибута, внутри <script> и внутри URL. Именно это защищает от XSS автоматически, без ручных вызовов «escape».
// БАГ, ведущий к XSS: text/template ничего не экранирует
t := texttemplate.Must(texttemplate.New("p").Parse(`<div>{{ . }}</div>`))
// Правильно: тот же шаблон через html/template экранирует <script> в тексте
Правило простое: генерируете HTML — только html/template; генерируете конфиг, письмо, SQL-скрипт или код — text/template. Обход экранирования через template.HTML(s) допустим лишь для данных, которым вы доверяете полностью. Механику атаки разбирает глава про XSS и CSRF.
Шаблоны — ещё и рабочая лошадка кодогенерации: stringer, sqlc и подобные инструменты построены на text/template. К этому вернёмся в следующей главе.
Что ещё стоит держать в голове
http.DefaultClientне имеет таймаута. Один медленный сервер повесит вашу горутину навсегда. Всегда собирайте свой клиент сTimeoutи настроеннымTransport, и переиспользуйте его — новыйTransportна каждый запрос убивает пул соединений.strconvвместоfmtдля чисел.strconv.Itoa(n)в разы быстрееfmt.Sprintf("%d", n)и не тащит значение вany.strings.Builderдля сборки строк,bytes.Buffer— для байтов.regexpреализует RE2: линейное время, без обратных ссылок. Это значит, что катастрофического бэктрекинга (ReDoS) в Go не бывает — приятное следствие отказа от возможностей PCRE. Компилируйте регулярку один раз в переменную пакета, а не в каждом вызове.crypto/randдля всего, что связано с безопасностью (токены, соли),math/rand/v2— только для неответственных задач.path/filepathдля путей на диске (учитывает разделитель ОС),path— только для URL и слеш-путей.os.ReadFile/os.WriteFileдля мелких файлов вместо ручного открытия — короче и без утечек дескрипторов.
Типичные ошибки
io.ReadAllна теле запроса или ответа без ограничения размера.- Проверка
errдо обработкиnвRead. - Забытый
Flushуbufio.Writer. - Отсутствие
sc.Err()послеbufio.Scanner. - Сохранение среза из
sc.Bytes()без копирования. omitemptyтам, где нужно отличать «ноль» от «не задано».- Сравнение
time.Timeчерез==. time.Sleepкак способ синхронизации в тестах.sh -cсо склеенной из пользовательского ввода строкой.text/templateдля генерации HTML.- HTTP-клиент без таймаута.
Мини-итог
io.Reader/io.Writer— универсальный клей; комбинаторыCopy,TeeReader,MultiWriter,LimitReaderзаменяют ручные циклы.- Потоковая обработка вместо «прочитать всё в память» — вопрос устойчивости, а не эстетики.
bufioэкономит системные вызовы; уScannerесть предел токена и обязательная проверкаErr().- JSON разбирайте
Decoder‘ом поверхMaxBytesReader, а неUnmarshalпо всему телу. - Время: длительности — через
Since, сравнение — черезEqual, зоны — черезtime/tzdataв контейнере. embedвозвращает обещание одного бинарника;html/templateзащищает от XSS автоматически.
Источники
- pkg.go.dev/std — вся стандартная библиотека; читайте не только сигнатуры, но и примеры.
- Go blog: JSON and Go — введение в модель сериализации.
- Документация
io,bufio,time,embed,html/template. - How To Use Contexts and time in Go — про отмену, дополняет раздел о таймерах.
- Исходники стандартной библиотеки — лучший учебник идиоматичного Go:
go doc -src io.Copyпоказывает реализацию прямо в терминале.
Что дальше
Стандартная библиотека решает почти всё — но не задачи, где типы становятся данными: сериализаторы, валидаторы, ORM и генераторы кода. Разберём механизмы, которыми Go отвечает на такие задачи, и цену каждого.