Введение
Ты уже знаешь синтаксис Go, умеешь работать со структурами, интерфейсами,
горутинами, ошибками и дженериками. Осталось освоить то, что превращает набор
файлов в настоящий проект: систему модулей (управление зависимостями), пакет
context (отмена и дедлайны, без которых не живёт ни один сетевой
сервис) и тестирование (встроенный инструментарий, за который Go любят). А в
финале мы соберём из всего изученного небольшой HTTP-сервис — чтобы увидеть, как
кусочки складываются в целое. Это заключительная глава курса, поэтому в конце
будет не только итог главы, но и общий обзор пройденного пути.
Модули: go.mod и go.sum
До Go 1.11 весь код обязан был лежать внутри $GOPATH/src —
неудобное наследие ранних лет. Модули (Go 1.11, 2018) сняли это ограничение:
теперь проект может находиться где угодно, а его границы и зависимости описывает
файл go.mod. Модуль — это дерево пакетов с общим корнем и
собственным файлом go.mod. Создаётся он одной командой.
go mod init example.com/myapp # создать модуль (появится go.mod)
go mod tidy # добавить недостающие и убрать лишние зависимости
go get rsc.io/quote@v1.5.2 # добавить конкретную версию зависимости
go build ./... # собрать все пакеты модуля
go run . # собрать и запустить пакет main из текущей папки
Сам файл go.mod компактен: имя модуля, минимальная версия Go и
список прямых зависимостей с версиями.
module example.com/myapp
go 1.23
require (
rsc.io/quote v1.5.2
golang.org/x/text v0.14.0 // indirect
)
Рядом появляется go.sum — он хранит криптографические
контрольные суммы всех зависимостей (включая транзитивные). Благодаря ему у всех
членов команды и на CI собирается ровно тот же код, что и у тебя: если кто-то
подменит содержимое зависимости, хеш не сойдётся и сборка упадёт. Оба файла,
go.mod и go.sum, коммитятся в репозиторий.
Подключение зависимости
Чтобы добавить внешний пакет, достаточно его импортировать и выполнить
go mod tidy — команда сама скачает пакет, добавит
require в go.mod и хеши в go.sum.
Версионирование в Go основано на семантических версиях и правиле «минимально
необходимой версии»: сборка выбирает наименьшую версию, удовлетворяющую всем
требованиям, — это делает сборки воспроизводимыми.
package main
import (
"fmt"
"rsc.io/quote" // внешняя зависимость; go mod tidy её подтянет
)
func main() {
fmt.Println(quote.Hello())
fmt.Println(quote.Go())
}
Пакет context: Background и TODO
Пакет context (Go 1.7, 2016) решает две задачи, критичные для
конкурентных программ: переносит сигнал отмены/дедлайн и
значения, привязанные к запросу, между горутинами и вызовами функций.
Любое дерево контекстов начинается с корневого. Их два: Background()
— пустой корень для main, инициализации и верхних обработчиков; и
TODO() — заглушка «ещё не решил, какой контекст сюда нужен», сигнал
читателю и линтерам. Оба пустые: их нельзя отменить, у них нет дедлайна и
значений.
package main
import (
"context"
"fmt"
)
func main() {
ctx := context.Background()
// У пустого корня нет дедлайна...
deadline, ok := ctx.Deadline()
fmt.Printf("deadline=%v, есть? %v
", deadline, ok)
// ...нет ошибки (он никогда не отменяется)...
fmt.Println("Err():", ctx.Err())
// ...и нет значений.
fmt.Println("Value:", ctx.Value("key"))
// TODO ведёт себя так же — маркер «контекст добавим позже».
todo := context.TODO()
_ = todo
}
WithCancel — ручная отмена
Реальную функциональность добавляют производные контексты.
context.WithCancel возвращает новый контекст и функцию
cancel. Вызов cancel() закрывает канал
ctx.Done(), и все горутины, слушающие этот канал, получают сигнал
остановиться. Это идиоматичный способ корректно завершать горутины и не плодить
утечки. Правило: cancel нужно вызывать всегда (обычно через
defer), иначе освобождение ресурсов откладывается —
go vet это проверяет.
package main
import (
"context"
"fmt"
"time"
)
// worker работает, пока контекст не отменят.
func worker(ctx context.Context) {
for i := 1; ; i++ {
select {
case <-ctx.Done():
// ctx.Err() объяснит причину: context.Canceled.
fmt.Println("worker: останавливаюсь,", ctx.Err())
return
default:
fmt.Println("worker: тик", i)
time.Sleep(200 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // подстраховка
go worker(ctx)
time.Sleep(700 * time.Millisecond)
fmt.Println("main: вызываю cancel()")
cancel() // сигнал worker'у остановиться
time.Sleep(300 * time.Millisecond)
}
Обрати внимание на конструкцию select с
case <-ctx.Done() — это стандартный способ «слушать» отмену. Как
только канал Done() закрыт, ветка срабатывает, и горутина
завершается.
WithTimeout и WithDeadline — автоматическая отмена
context.WithTimeout отменяет контекст автоматически через
заданный интервал (а WithDeadline — к заданному моменту времени).
По истечении таймаута ctx.Done() закрывается, а
ctx.Err() возвращает context.DeadlineExceeded.
Типичный сценарий: «операция должна уложиться в N секунд, иначе прерываем».
package main
import (
"context"
"fmt"
"time"
)
// slowOperation имитирует долгую работу, уважающую отмену.
func slowOperation(ctx context.Context, d time.Duration) error {
select {
case <-time.After(d): // работа завершилась сама
return nil
case <-ctx.Done(): // контекст истёк/отменён раньше
return ctx.Err()
}
}
func main() {
// Даём операции максимум 300 мс.
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
// Успевает (100 мс).
fmt.Println("op1:", slowOperation(ctx, 100*time.Millisecond))
// Не успевает (1 с) → DeadlineExceeded.
fmt.Println("op2:", slowOperation(ctx, 1*time.Second))
}
WithValue — данные, привязанные к запросу
context.WithValue кладёт пару ключ/значение в контекст, а
вложенные функции достают её через ctx.Value(key). Это удобно для
сквозных данных: идентификатор запроса, данные трассировки, аутентифицированный
пользователь. Два важных правила хорошего тона: ключ должен быть своего
неэкспортируемого типа (не string/int) — иначе
ключи из разных пакетов столкнутся; и через контекст передают только сквозные,
опциональные данные, а не обязательные параметры функций.
package main
import (
"context"
"fmt"
)
// Свой приватный тип ключа исключает коллизии.
type ctxKey string
const requestIDKey ctxKey = "requestID"
func handle(ctx context.Context) {
if id, ok := ctx.Value(requestIDKey).(string); ok {
fmt.Println("requestID =", id)
}
}
func main() {
ctx := context.WithValue(context.Background(), requestIDKey, "req-12345")
handle(ctx)
// По «чужому» строковому ключу значения не будет — тип другой.
fmt.Println("по строке:", ctx.Value("requestID"))
}
context в HTTP — самый частый сценарий
На стороне сервера у каждого *http.Request есть
r.Context(), который отменяется, когда клиент закрыл соединение.
Долгий обработчик должен слушать r.Context().Done() и прекращать
работу, не сжигая ресурсы впустую. На стороне клиента контекст с таймаутом
(http.NewRequestWithContext) ограничивает время запроса.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
func slowHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
select {
case <-time.After(1 * time.Second):
fmt.Fprintln(w, "done") // успели ответить
case <-ctx.Done():
// Клиент отвалился / истёк его таймаут.
fmt.Println("сервер: клиент отменил запрос:", ctx.Err())
}
}
func callWithTimeout(url string) {
// Клиент даёт запросу всего 300 мс.
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Println("клиент: запрос прерван:", err) // context deadline exceeded
return
}
defer resp.Body.Close()
fmt.Println("клиент: получен ответ", resp.Status)
}
Тестирование: файлы _test.go и функции TestXxx
Тесты в Go — часть языка, а не отдельный фреймворк. Тесты для пакета
foo лежат в файлах *_test.go рядом с кодом. Тестовая
функция начинается с Test, принимает *testing.T и
сообщает о провале через t.Error/t.Fatal. Запуск —
командой go test.
package math
// math.go
func Adder(xs ...int) int {
res := 0
for _, v := range xs {
res += v
}
return res
}
package math
// math_test.go
import "testing"
func TestAdder(t *testing.T) {
result := Adder(4, 7)
if result != 11 {
// Errorf продолжает тест; Fatalf прервал бы его немедленно.
t.Errorf("Adder(4, 7) = %d, ожидалось 11", result)
}
}
Table-driven тесты и t.Run
Идиоматичный способ тестировать много случаев — табличные тесты:
описываешь входы и ожидания срезом структур, а затем в цикле прогоняешь каждый
случай. t.Run запускает каждый случай как отдельный подтест —
с именем, изоляцией и возможностью запустить только один из них.
package math
import "testing"
func TestAdderTable(t *testing.T) {
cases := []struct {
name string
in []int
want int
}{
{"пусто", []int{}, 0},
{"одно", []int{5}, 5},
{"несколько", []int{1, 2, 3}, 6},
{"отрицательные", []int{-1, -2, 3}, 0},
}
for _, c := range cases {
// Каждый случай — отдельный подтест со своим именем.
t.Run(c.name, func(t *testing.T) {
got := Adder(c.in...)
if got != c.want {
t.Errorf("Adder(%v) = %d, want %d", c.in, got, c.want)
}
})
}
}
go test ./... # прогнать все тесты модуля
go test -v # подробный вывод (имена подтестов)
go test -run TestAdder # только тесты, чьё имя совпадает с шаблоном
go test -cover # показать процент покрытия кода тестами
Fuzzing — тесты со случайными входами (Go 1.18)
Табличные тесты проверяют лишь те случаи, до которых ты сам додумался.
Fuzzing идёт дальше: go test сам генерирует
случайные входы, пытаясь сломать твою функцию. Fuzz-функция начинается с
Fuzz, принимает *testing.F: через f.Add
задают seed-входы (отправные точки для мутаций), а f.Fuzz — целевую
функцию, аргументы которой fuzzer варьирует. Обычно проверяют не точный
результат, а свойства (property-based testing).
package firstfuzz
import (
"testing"
"unicode/utf8"
)
func FuzzReverse(f *testing.F) {
// seed-входы: отправные точки для мутаций.
for _, seed := range []string{"hello", "", "мир", "🙂"} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, s string) {
// Свойства определены только для корректного UTF-8.
if !utf8.ValidString(s) {
t.Skip()
}
rev := Reverse(s)
// Свойство 1: двойной разворот возвращает исходную строку.
if Reverse(rev) != s {
t.Errorf("двойной разворот сломал %q", s)
}
// Свойство 2: разворот валидного UTF-8 остаётся валидным.
if !utf8.ValidString(rev) {
t.Errorf("Reverse(%q) дал невалидный UTF-8", s)
}
})
}
Запускается фаззинг так: go test -fuzz=FuzzReverse. Наивная
побайтная реализация разворота строки прошла бы обычные ASCII-тесты, но fuzzer
быстро подобрал бы многобайтовый символ и показал бы, что она ломает UTF-8, —
именно так фаззинг ловит баги, незаметные глазу.
Кейс из реального проекта: собираем мини-сервис
Соберём всё вместе — маленький HTTP-сервис, который отдаёт приветствие по
имени. В нём встретятся сразу многие темы курса: модули, обработчики,
context для graceful-остановки, структурированное логирование
slog и обработка ошибок. Структура проекта проста.
myapp/ ├── go.mod ├── main.go └── main_test.go
Файл main.go — сам сервис. Сервер поднимается, обрабатывает
запросы и корректно завершается по сигналу (graceful shutdown через
context).
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"time"
)
// greet формирует приветствие; пустое имя — это ошибка.
func greet(name string) (string, error) {
if name == "" {
return "", fmt.Errorf("greet: пустое имя")
}
return "Привет, " + name + "!", nil
}
func greetHandler(logger *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
msg, err := greet(name)
if err != nil {
logger.Warn("плохой запрос", "err", err)
http.Error(w, "укажите ?name=", http.StatusBadRequest)
return
}
logger.Info("приветствие выдано", "name", name)
fmt.Fprintln(w, msg)
}
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
mux := http.NewServeMux()
mux.HandleFunc("/greet", greetHandler(logger))
srv := &http.Server{Addr: ":8080", Handler: mux}
// Запускаем сервер в отдельной горутине.
go func() {
logger.Info("сервер запущен", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("сбой сервера", "err", err)
}
}()
// Ждём сигнала прерывания (Ctrl+C) для корректной остановки.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
<-ctx.Done()
// Даём активным запросам 5 секунд на завершение.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
logger.Info("останавливаюсь...")
_ = srv.Shutdown(shutdownCtx)
}
А вот main_test.go — табличный тест «сердца» сервиса, функции
greet. Заметь: чистую бизнес-логику мы вынесли в отдельную функцию
именно для того, чтобы её было легко тестировать без поднятия HTTP-сервера.
package main
import "testing"
func TestGreet(t *testing.T) {
cases := []struct {
name string
input string
want string
wantErr bool
}{
{"обычное имя", "Аня", "Привет, Аня!", false},
{"пустое имя", "", "", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := greet(c.input)
if (err != nil) != c.wantErr {
t.Fatalf("greet(%q): ошибка = %v, ждали ошибку = %v",
c.input, err, c.wantErr)
}
if got != c.want {
t.Errorf("greet(%q) = %q, want %q", c.input, got, c.want)
}
})
}
}
Запусти проект командами go mod tidy, go test ./...
и go run . — и ты увидишь работающий сервис с логами в JSON,
проверкой ошибок и корректной остановкой. Это концентрат почти всего, что мы
прошли.
Типичные ошибки
Не вызывать cancel
Функция cancel, возвращаемая из WithCancel/
WithTimeout, должна вызываться всегда, даже если контекст
и так истёк по таймауту. Иначе внутренние ресурсы контекста подтекают.
Стандартная страховка — defer cancel() сразу после создания
контекста. go vet предупреждает о забытом cancel.
Хранить context в структуре
Контекст задуман, чтобы передаваться первым аргументом функции
(func Do(ctx context.Context, ...)), а не жить полем структуры.
Спрятанный в поле контекст живёт непредсказуемо долго и ломает всю модель отмены.
Передавай его явно по цепочке вызовов.
Использовать string-ключи в WithValue
Ключ context.WithValue(ctx, "user", u) опасен: другой пакет
может использовать тот же строковый ключ "user", и значения
столкнутся. Всегда объявляй свой неэкспортируемый тип ключа
(type ctxKey string), тогда коллизия между пакетами невозможна.
Тестировать только «счастливый путь»
Легко написать тест, проверяющий, что при корректном входе всё работает. Но
самые ценные тесты — про ошибки и границы: пустой ввод,
переполнение, отрицательные значения, nil. Табличные тесты для того
и придуманы, чтобы удобно перечислять эти случаи. И не забывай про
go test -race для кода с горутинами — он ловит гонки данных.
Практика
- Создай модуль
example.com/calcкомандойgo mod init, добавь функциюDivide(a, b int) (int, error)с ошибкой на деление на ноль и покрой её табличными тестами (включая случай ошибки). - Напиши функцию
fetch(ctx context.Context, url string) (string, error), которая делает HTTP-запрос с учётом контекста, и вызови её сWithTimeoutв 50 мс, показав отмену. - Реализуй пул из трёх воркеров-горутин, которые читают задачи из канала и
завершаются по
ctx.Done()приWithCancel. - Оберни значение
traceIDв контекст через свой ключ-тип и достань его в трёх уровнях вложенных функций. - Возьми функцию
Reverse(string) stringи напиши для неё fuzz-тест, проверяющий свойство «двойной разворот равен исходной строке». - Расширь мини-сервис из кейса: добавь эндпоинт
/health, возвращающий200 OK, и напиши дляgreetещё пару табличных случаев.
Итог
В этой главе ты собрал воедино инфраструктуру Go-проекта: модули
(go.mod/go.sum, команды go mod init/tidy,
go get, воспроизводимые зависимости), пакет context
(корни Background/TODO, производные
WithCancel/WithTimeout/WithValue, отмена
через Done(), ключевой сценарий с HTTP) и встроенное тестирование
(функции TestXxx, табличные тесты с t.Run, команды
go test, фаззинг). В финальном кейсе всё это соединилось в рабочем
мини-сервисе с graceful-остановкой и структурированными логами.
Итог всего курса. Ты прошёл путь от переменных и типов до
современного, промышленного Go. По дороге были управляющие конструкции и функции,
указатели, слайсы и карты, структуры и интерфейсы, горутины и каналы для
конкурентности, дисциплина обработки ошибок как значений, дженерики и свежие
возможности стандартной библиотеки (slog, embed,
итераторы), и, наконец, модули, context и тестирование, которые
превращают код в проект. Философия Go — простота, явность и читаемость — теперь у
тебя в руках. Лучший способ закрепить её: писать. Возьми идею небольшого сервиса
или утилиты и собери её сам, опираясь на то, что прошёл. Удачи — и добро
пожаловать в сообщество гоферов!
Комментарии 0
Пока нет комментариев. Станьте первым!