net/http, encoding/json, testing
Три кита стандартной библиотеки, которые проверяют почти на каждом бэкенд-собесе: net/http изнутри, encoding/json с его ловушками и идиомы testing.
net/http: Handler, HandlerFunc, ServeMux, middleware
В Go нет ключевого слова implements — интерфейс это просто список требований к методам, и тип автоматически ему удовлетворяет, если у него есть нужные методы (структурная типизация, без явного объявления "я реализую этот интерфейс"). http.Handler требует ровно один метод: ServeHTTP(w, r). Значит, чтобы стать хендлером, типу достаточно иметь такой метод — необязательно писать функцию именно в форме func(w, r).
Отсюда возникает практический вопрос: если у нас уже есть обычная функция func(w, r), а не тип с методом ServeHTTP, как превратить её в Handler? HandlerFunc — это ответ, и его механизм стоит увидеть буквально:
type HandlerFunc func(http.ResponseWriter, *http.Request)
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f(w, r) // просто вызывает саму себя как функцию
}type HandlerFunc func(w, r) объявляет ИМЕНОВАННЫЙ тип-функцию — а у именованных типов, в отличие от голого func(...), можно объявлять методы. Дальше на этом типе объявляется метод ServeHTTP, тело которого — просто вызов f(w, r). В результате любую обычную функцию с подходящей сигнатурой можно превратить в Handler простым приведением типа: HandlerFunc(myFunc). mux.HandleFunc(...) под капотом делает именно это автоматически, поэтому в коде почти никогда не приходится писать HandlerFunc(...) руками.
Middleware — функция, принимающая Handler и возвращающая новый Handler, который оборачивает вызов next.ServeHTTP своей логикой до и после. При нескольких вложенных middleware порядок выполнения работает как матрёшка: код ДО next выполняется в порядке снаружи внутрь, а код ПОСЛЕ next — в обратном порядке, изнутри наружу.
Вывод показывает порядок ровно так, как описано: "до next" печатается первым (снаружи), потом сам хендлер (внутри), потом "после next" (снова снаружи). Если бы middleware было два (например, withLogging(withAuth(hello))), порядок был бы: код "до" у withLogging, код "до" у withAuth, сам хендлер, код "после" у withAuth, код "после" у withLogging — как вложенные скобки.
Про сам обмен данными: r *http.Request предназначен только для чтения (r.Method, r.URL.Path, r.Header.Get(...), r.Body), а w http.ResponseWriter — только для записи, причём порядок операций строгий: сначала выставляются заголовки через w.Header().Set(...), потом вызывается w.WriteHeader(status), потом пишется тело через w.Write(...). После вызова WriteHeader заголовки уже отправлены клиенту и изменить их нельзя — попытка вызвать Header().Set после WriteHeader не паникует, но результата не даёт.
ServeMux — диспетчер, который сопоставляет входящий путь запроса с зарегистрированными хендлерами. Начиная с Go 1.22 паттерн регистрации может включать HTTP-метод прямо в строке ("GET /items/{id}"), и если два зарегистрированных паттерна могли бы совпасть с одним и тем же путём — побеждает самый КОНКРЕТНЫЙ маршрут (например, /items/123 предпочтёт /items/{id} перед более общим /items/), а не тот, что был зарегистрирован раньше по порядку кода.
encoding/json и стандартные пакеты
json.Marshal сериализует поля структуры по тегам json:"...". Тег omitempty исключает поле из результата, если его значение равно zero value этого типа (для int — 0, для string — пустая строка, для указателя — nil).
Вывод — {"name":"sample"}, и это ловушка, которую стоит проговорить явно: Price: 0 пропал ровно ТАК ЖЕ, как пропала бы пустая строка Notes — omitempty не различает "поле явно установлено в 0" и "поле вообще не задано". Если для бизнес-логики это разные вещи (например, "цена 0 — бесплатный товар" и "цена не указана — ошибка данных"), обычный int не годится: нужен указатель *int, у которого nil однозначно означает "не задано", а &zero — "явно установлено в 0" (указатель сам по себе не является zero-value для omitempty, если он не nil).
Отдельно: json:"-" (дефис вместо имени) исключает поле из JSON всегда, в обе стороны — и при Marshal, и при Unmarshal, независимо от значения.
json.Unmarshal(data, v) требует, чтобы v был указателем — иначе функции физически некуда писать результат разбора, а самая частая опечатка на практике — забыть & перед переменной:
var item Itemerr := json.Unmarshal(data, item)var item Itemerr := json.Unmarshal(data, &item)Забытый & не всегда падает с ошибкой на месте — если item уже интерфейсного типа или указателем является сам тип, компилятор может пропустить это молча, и результат разбора просто не запишется никуда, item останется нулевым значением. Кастомная сериализация под нестандартный формат (например, дата в своём формате вместо RFC3339) делается через реализацию методов MarshalJSON() ([]byte, error) и UnmarshalJSON([]byte) error на своём типе — encoding/json вызывает их автоматически, если они есть.
Смежные стандартные пакеты, которые тоже часто спрашивают: bufio.Scanner — построчное чтение файла/потока без загрузки его целиком в память (важно для больших логов); strconv.Atoi — упрощённый парсинг строки в int по основанию 10, а strconv.ParseInt(s, base, bitSize) — более общий вариант с явным контролем системы счисления и разрядности результата.
В проде вместо log.Println почти всегда используют структурированное логирование — запись в виде JSON с именованными полями ({"level":"error","user_id":42,"msg":"..."}), которую легко фильтровать и агрегировать в системах вроде ELK/Loki по конкретному полю, а не грепать текст. Популярные библиотеки — zap, zerolog, а начиная с Go 1.21 в стандартной библиотеке появился log/slog, закрывающий этот сценарий без сторонних зависимостей.
testing: table-driven, testify, httptest, бенчмарки
Table-driven тест — это срез структур, где каждый элемент описывает один тестовый случай (обычно входные данные + ожидаемый результат), и один цикл, который прогоняет их все через одну и ту же логику проверки. Ключевая деталь, которую часто упускают в примерах, — оборачивать каждый случай в t.Run(name, func(t *testing.T){...}), а не просто перебирать их в плоском цикле без вложенных subtest'ов:
package realtest
import "testing"
func TestAdd(t *testing.T) {
cases := []struct {
name string
a, b int
expected int
}{
{"положительные", 2, 3, 5},
{"с отрицательным", -1, 1, 0},
{"оба нуля", 0, 0, 0},
}
for _, tc := range cases {
tc := tc // важно для версий Go до 1.22, см. главу про горутины
t.Run(tc.name, func(t *testing.T) {
got := add(tc.a, tc.b)
if got != tc.expected {
t.Errorf("add(%d,%d) = %d, ожидали %d", tc.a, tc.b, got, tc.expected)
}
})
}
}Реальный вывод go test -v на этом коде (не запускается в песочнице play.golang.org — там нет go test, но код компилируется и проходит один в один):
=== RUN TestAdd
=== RUN TestAdd/положительные
=== RUN TestAdd/с_отрицательным
=== RUN TestAdd/оба_нуля
--- PASS: TestAdd (0.00s)
--- PASS: TestAdd/положительные (0.00s)
--- PASS: TestAdd/с_отрицательным (0.00s)
--- PASS: TestAdd/оба_нуля (0.00s)
PASS
t.Run даёт две конкретные практические выгоды, которые стоит проговорить, а не просто знать как факт: каждый случай виден в выводе ОТДЕЛЬНОЙ строкой со своим именем (в примере — TestAdd/положительные, пробелы в имени Go сам заменяет на подчёркивания), и любой отдельный случай можно перезапустить точечно флагом -run TestAdd/положительные, не гоняя остальные. Без t.Run при падении одного случая из десяти видно только "TestAdd упал", а не какой конкретно из case'ов.
testify — сторонняя библиотека для более удобных проверок внутри тестов. Разница между assert.Equal(t, want, got) и require.Equal(t, want, got) — не синтаксис, а поведение при неудаче: assert помечает тест проваленным, но выполнение функции-теста ПРОДОЛЖАЕТСЯ дальше по коду; require немедленно прерывает выполнение этой тестовой функции через t.FailNow(). Практический вывод: require используют для предусловий, без которых дальнейшие проверки бессмысленны или опасны (например, require.NoError(t, err) сразу после вызова, который должен был успешно создать объект — если он не создался, следующие строки, читающие поля этого объекта, попросту паникнут на nil).
httptest.NewRequest/NewRecorder (уже использованы в примере с middleware выше) позволяют тестировать HTTP-хендлеры без реального сетевого сервера и без реальных сокетов: NewRecorder() реализует интерфейс http.ResponseWriter, но вместо отправки байт по сети сохраняет всё записанное в обычные Go-поля (rec.Code — код ответа, rec.Body — тело как *bytes.Buffer), которые потом можно проверить обычными assert.Equal.
Бенчмарки (func BenchmarkX(b *testing.B)) устроены иначе, чем обычные тесты: b.N — не то число, которое задаёт программист, framework сам подбирает и постепенно увеличивает b.N, пока не наберёт достаточно статистически надёжных измерений; код внутри бенчмарка использует b.N только как верхнюю границу цикла (for i := 0; i < b.N; i++), не пытаясь угадать разумное значение самостоятельно.
Из инструментов статического анализа: go vet — встроенный в тулчейн анализатор вероятных ошибок (неверные форматы в Printf, копирование структуры с полем sync.Mutex — см. главу про sync — и другие частые баги), входит в стандартную поставку Go. golangci-lint — сторонний агрегатор из десятков отдельных линтеров под одной командой с единой конфигурацией, фактический индустриальный стандарт для CI большинства Go-проектов.
Ловушка, которую стоит проговорить вслух: писать отдельные функции TestAdd1, TestAdd2, TestAddNegative вместо одной table-driven TestAdd с несколькими случаями — такой код формально работает и проходит go test, но не идиоматичен и плохо масштабируется: добавление 20-го случая означает копирование ещё одной почти идентичной функции вместо одной новой строки в срезе cases.