Go Стандартная библиотека вглубь: io, bufio, encoding/json, time, exec, embed и шаблоны
0%

Стандартная библиотека вглубь: io, bufio, encoding/json, time, exec, embed и шаблоны

Стандартная библиотека вглубь

«Батарейки в комплекте» — обещание из обзора, которое в 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 наполняет переданный срез и возвращает, сколько байт реально прочитал. Три правила, нарушение которых даёт плавающие баги:

  1. Read вправе вернуть меньше байт, чем помещается в p, и это не ошибка. Читать «всё сразу» нельзя, только в цикле — или через io.ReadFull.
  2. Read может вернуть n > 0 вместе с err == io.EOF. Сначала обрабатывайте n байт, потом смотрите ошибку. Обратный порядок молча теряет последний кусок данных.
  3. 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, распаковать и записать на диск — одним проходом, без временных файлов и без загрузки в память.

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 байт) и превращает тысячу мелких чтений в одно крупное.

Анатомия буфера bufio.Reader: заполнение из источника и выдача по частям

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, для boolfalse. Поле 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 отвечает на такие задачи, и цену каждого.

12. Рефлексия, кодогенерация, unsafe и cgo

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

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

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

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