Перейти к основному содержимому

5.10. Важные классы и интерфейсы

Разработчику Архитектору

Важные классы и интерфейсы

Язык Go (Golang) отличается лаконичной типовой системой, опирающейся не на иерархические наследования, а на композицию и интерфейсы с неявной реализацией. В Go отсутствует понятие «класса» в традиционном объектно-ориентированном смысле; вместо этого используются именованные типы (в том числе структуры), методы, привязанные к этим типам, и интерфейсы, определяющие контракты поведения. Это делает систему типов Go гибкой, эффективной и легко тестируемой. В данной главе рассматриваются ключевые абстракции, типы и интерфейсы стандартной библиотеки, а также рекомендации по их применению в реальных сценариях разработки.

1. Основы типовой системы

Прежде чем перейти к конкретным интерфейсам, важно закрепить понимание того, как в Go организована связь между структурами, методами и контрактами.

Именованный тип в Go может быть объявлен на основе любого встроенного типа (например, type ID int) или структуры (type User struct { Name string; Age int }). К такому типу можно привязать методы — функции, принимающие получатель (receiver), который указывает, к какому типу метод относится:

type Counter struct {
value int
}

func (c *Counter) Increment() {
c.value++
}

func (c Counter) Value() int {
return c.value
}

Заметим, что Go допускает как указательные, так и неуказательные получатели, и выбор между ними влияет на семантику вызова (передача по ссылке или по значению), а также на соответствие интерфейсам.

Композиция реализуется через встраивание (embedding): один тип может включать другой тип без явного именования поля. Это делегирование — встроенный тип остаётся самостоятельной сущностью, а его методы «поднимаются» на уровень внешнего типа:

type Logger struct{}

func (l Logger) Log(msg string) {
fmt.Println("[LOG]", msg)
}

type Service struct {
Logger // встраивание
name string
}

func (s Service) Run() {
s.Log("Starting service: " + s.name) // вызов метода встроенного типа
}

Такой подход обеспечивает переиспользование кода без иерархической сложности, характерной для классических ООП-языков.

2. Интерфейсы

Интерфейсы в Go — это множества методов, определяющих поведение. Тип удовлетворяет интерфейсу, если реализует все его методы. При этом не требуется явное ключевое слово implements — соответствие проверяется статически во время компиляции и не фиксируется в коде. Это называется структурной типизацией (structural typing).

Простейший интерфейс:

type Stringer interface {
String() string
}

Любой тип, имеющий метод String() string, автоматически реализует Stringer. Это позволяет писать обобщённый код:

func Print(v interface{}) {
if s, ok := v.(fmt.Stringer); ok {
fmt.Println(s.String())
} else {
fmt.Println(v)
}
}

Такой подход лежит в основе всей стандартной библиотеки. Интерфейсы в Go обычно малы — часто содержат один-два метода — чтобы минимизировать зависимость от конкретных реализации и максимизировать гибкость.

Принцип «принимайте интерфейсы, возвращайте структуры»

Это неформальное, но важнейшее правило проектирования API в Go. При проектировании функций и методов рекомендуется:

  • Принимать интерфейсы — чтобы вызывающая сторона могла передавать любую реализацию, удовлетворяющую контракту (например, io.Reader, http.Handler).
  • Возвращать конкретные типы — чтобы не навязывать абстракции, которые могут оказаться избыточными или ограничивающими для потребителя.

Например, os.Open возвращает *os.File, а не io.ReadCloser, хотя *os.File реализует io.ReadCloser. Это позволяет вызывающему коду, при желании, использовать дополнительные методы *os.File, но при этом он может привести результат к io.ReadCloser, если нужен только базовый функционал.

3. Ключевые интерфейсы стандартной библиотеки

Ниже — обзор наиболее важных интерфейсов, без которых невозможно представить серьёзную разработку на Go. Они образуют остов большинства приложений: от веб-серверов до утилит командной строки.

3.1. error

Интерфейс error — самый фундаментальный в Go:

type error interface {
Error() string
}

Любая функция, которая может завершиться неуспешно, возвращает error в качестве последнего значения. Go не использует исключения; вместо этого ошибка передаётся явно, что делает поток управления прозрачным и предсказуемым.

В стандартной библиотеке определён базовый тип errors.errorString, но в реальных приложениях часто используются обёртки, позволяющие добавлять контекст:

  • fmt.Errorf("...: %w", err) — оборачивает ошибку с поддержкой errors.Unwrap.
  • Пакеты github.com/pkg/errors (устаревший) и golang.org/x/xerrors (влился в errors в Go 1.13+) предоставляли средства для аннотирования стека вызовов; с Go 1.13+ встроенная поддержка позволяет строить цепочки ошибок через %w.

Хорошая практика — создавать доменные типы ошибок:

type NotFoundError struct {
ID string
Kind string
}

func (e NotFoundError) Error() string {
return fmt.Sprintf("%s with ID %q not found", e.Kind, e.ID)
}

Такие ошибки можно проверять с помощью errors.Is и errors.As.

3.2. io.Reader и io.Writer

Эти два интерфейса образуют основу модели потоковой обработки данных в Go:

type Reader interface {
Read(p []byte) (n int, err error)
}

type Writer interface {
Write(p []byte) (n int, err error)
}

Их простота и универсальность позволяют строить композиции: файлы, сетевые соединения, буферы, сжатие, шифрование — всё выражается через Reader и Writer. Комбинации, такие как io.ReadWriter, io.ReadCloser, io.WriteCloser, io.ReadWriteCloser, формируются через встраивание:

type ReadWriter interface {
Reader
Writer
}

Примеры реализаций:

  • *os.File — чтение/запись в файл.
  • strings.Reader, bytes.Buffer — работа с памятью.
  • gzip.Reader, crypto/aes.NewCipher().NewStreamReader() — фильтры преобразования потока.
  • http.Request.Body реализует io.ReadCloser — тело HTTP-запроса как поток.

Функции, работающие с потоками, обычно принимают io.Reader/io.Writer, а не конкретные типы:

func Copy(dst io.Writer, src io.Reader) (written int64, err error)

Это позволяет, например, копировать из файла в сетевое соединение, из одного буфера в другой, или из HTTP-ответа в архив — без изменения логики.

3.3. context.Context

Интерфейс context.Context — механизм распространения отмены, таймаутов, дедлайнов и значений в рамках выполнения логической операции (например, HTTP-запроса или фоновой задачи):

type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}

Он используется почти повсеместно в сетевом и конкурентном коде. Например, http.Request содержит Context(), и все вызовы к базе данных, внешним API и т.п. внутри обработчика должны передавать этот контекст дальше.

Ключевые функции создания контекстов:

  • context.Background() — корневой контекст для процессов с неограниченным временем жизни.
  • context.WithCancel(parent) — позволяет отменить поддерево операций.
  • context.WithTimeout(parent, duration) и context.WithDeadline(parent, t) — задают временные ограничения.
  • context.WithValue(parent, key, val) — передаёт метаданные (например, идентификаторы трейсов, токены аутентификации), но не должен использоваться для передачи обязательных параметров бизнес-логики.

Контекст — это сигнал отмены и таймаута. Значения в контексте — второстепенное средство, и их использование должно быть строго ограничено.

3.4. http.Handler и http.HandlerFunc

В пакете net/http центральное место занимает интерфейс:

type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}

Любой тип, реализующий этот метод, может быть зарегистрирован как маршрут. Поскольку функция func(ResponseWriter, *Request) может быть приведена к http.HandlerFunc (который реализует Handler), это позволяет писать обработчики как обычные функции:

http.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})

Но для более сложных случаев (например, внедрение зависимостей, middleware) создаются структуры:

type HealthCheckHandler struct {
DB *sql.DB
}

func (h *HealthCheckHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := h.DB.PingContext(r.Context()); err != nil {
http.Error(w, "DB down", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}

Middleware реализуется через обёртку: функция, принимающая http.Handler и возвращающая другой http.Handler.

3.5. json.Marshaler и json.Unmarshaler

Для кастомной сериализации в JSON используются:

type Marshaler interface {
MarshalJSON() ([]byte, error)
}

type Unmarshaler interface {
UnmarshalJSON([]byte) error
}

Если структура реализует MarshalJSON, стандартный json.Marshal вызовет её метод вместо рефлексивного обхода полей. Это полезно для:

  • Скрытия/замены имён полей (без тегов json:"...").
  • Форматирования значений (например, time.Time"2025-11-18T12:00:00Z").
  • Преобразования типов (например, []int{"ids": [...]}).

Аналогично существуют encoding/xml.Marshaler, encoding/gob.GobEncoder, encoding.TextMarshaler и др.

3.6. sort.Interface

Для кастомной сортировки:

type Interface interface {
Len() int
Less(i, j int) bool
Swap(i, j int)
}

Если тип реализует этот интерфейс, его можно передать в sort.Sort(x). Пакет sort также предоставляет удобные обёртки: sort.Slice(slice, func(i, j int) bool) позволяет сортировать срезы без создания новых типов.


4. Важные конкретные типы стандартной библиотеки

Хотя Go делает ставку на интерфейсы, многие критически важные компоненты реализованы как конкретные именованные типы, сочетающие данные и поведение. Их следует рассматривать как инкапсулированные единицы состояния и операций, предназначенные для конкретных задач — от синхронизации до шаблонизации.

4.1. sync.Mutex и sync.RWMutex

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

type Counter struct {
mu sync.Mutex
value int
}

func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}

func (c *Counter) Get() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}

Важные нюансы:

  • Мьютексы не являются частью публичного интерфейса структуры — их поле объявляется как неэкспортируемое (mu sync.Mutex, а не Mu sync.Mutex). Это предотвращает внешнее вмешательство и инкапсулирует стратегию синхронизации.
  • Использование defer при разблокировке — стандартная практика, исключающая утечки блокировок при панике или раннем возврате.
  • sync.RWMutex добавляет разделение на чтение и запись: несколько горутин могут удерживать читающую блокировку одновременно, но пишущая блокировка исключает всех остальных. Это эффективно при преобладании операций чтения.

Антипаттерн: мьютекс как получатель метода напрямую. Структура должна владеть мьютексом, а не наоборот.

4.2. sync.Once

Тип sync.Once гарантирует, что некоторое действие будет выполнено ровно один раз, даже если вызвано из нескольких горутин.

var initDB sync.Once
var db *sql.DB

func getDB() *sql.DB {
initDB.Do(func() {
db = connectToDatabase()
})
return db
}

Once предпочтительнее флага bool с мьютексом, потому что он корректно обрабатывает случаи, когда инициализирующая функция завершается с паникой — последующие вызовы Do повторят попытку.

4.3. sync.WaitGroup

Используется для ожидания завершения группы горутин. Каждая запущенная горутина увеличивает счётчик (Add(1)), по завершении — уменьшает (Done()), а основная горутина ждёт (Wait()).

var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
process(id)
}(i)
}
wg.Wait()

Важно: вызов Add должен происходить в той же горутине, что и Wait, до запуска дочерних горутин — иначе возможна гонка, при которой Wait завершится до того, как Add увеличит счётчик.

4.4. time.Timer и time.Ticker

Эти типы управляют временными событиями:

  • time.Timer — однократное срабатывание через заданный интервал.
  • time.Ticker — периодическое срабатывание с фиксированным интервалом.

Оба предоставляют канал C, из которого можно читать время срабатывания.

timer := time.NewTimer(5 * time.Second)
select {
case <-timer.C:
fmt.Println("Timer fired")
case <-ctx.Done():
timer.Stop() // важно: остановить, чтобы избежать утечки
return
}

Ключевое правило: всегда вызывайте Stop() после завершения использования, особенно если таймер может не сработать (например, при отмене через context). В противном случае ресурс не будет освобождён, пока таймер не сработает.

4.5. regexp.Regexp

Компиляция регулярных выражений — дорогая операция. Тип *regexp.Regexp — это скомпилированное выражение, готовое к многократному использованию. Рекомендуется кэшировать его в глобальной или инициализированной переменной:

var emailRegexp = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)

func IsValidEmail(s string) bool {
return emailRegexp.MatchString(s)
}

MustCompile паникует при ошибке синтаксиса — приемлемо, если регулярное выражение известно на этапе разработки и протестировано. При динамическом вводе (например, из конфигурации) следует использовать regexp.Compile и обрабатывать ошибку.

4.6. text/template и html/template

Оба пакета предоставляют тип *template.Template — скомпилированный шаблон. Разница в том, что html/template автоматически экранирует выходные данные для предотвращения XSS-уязвимостей, тогда как text/template нет.

const tmpl = `Hello, {{.Name}}!`
t := template.Must(template.New("greet").Parse(tmpl))
t.Execute(os.Stdout, struct{ Name string }{"Тимур"})

Шаблоны можно объединять: ParseFiles, ParseGlob, Clone, AddParseTree. Компиляция выполняется один раз — последующие вызовы Execute только подставляют данные.

4.7. sync.Pool

Пул объектов для снижения давления на сборщик мусора в высоконагруженных сценариях (например, веб-серверах). Позволяет переиспользовать временные буферы, структуры, *bytes.Buffer и т.п.

var bufferPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}

func process(data []byte) {
buf := bufferPool.Get().(*bytes.Buffer)
defer bufferPool.Put(buf)
buf.Reset()
// ... использовать buf
}

Важно:

  • Пул не гарантирует наличие объекта — Get может вернуть nil, если пул пуст, и тогда вызывается New.
  • Объекты должны быть сброшены перед возвратом в пул (buf.Reset()), иначе возможна утечка данных между вызовами.
  • Пул не подходит для долгоживущих или дорогостоящих объектов (например, соединений с БД); для них используются специализированные пулы.

4.8. sync.Map

Специализированная конкурентно-безопасная карта. В отличие от обычной map, не требует внешней синхронизации. Однако она не является универсальной заменой map + sync.Mutex.

Оптимальна только при:

  • Преобладании операций чтения.
  • Нестабильном или неизвестном множестве ключей (например, кэш с динамическими ID).
  • Отсутствии сложных операций вроде «проверить-и-обновить».

В остальных случаях предпочтительнее map с мьютексом: он проще, быстрее при редкой записи и позволяет атомарно выполнять составные операции.


5. Паттерны проектирования через интерфейсы и композицию

Go не имеет классического наследования, но позволяет выразить многие ООП-паттерны через интерфейсы и встраивание. При этом рекомендуется избегать преждевременной абстракции — сначала реализовать конкретную логику, затем выделять интерфейсы по потребности.

5.1. Интерфейс как адаптер

Часто возникает необходимость подогнать сторонний тип под ожидаемый интерфейс. Например, библиотека ожидает io.Reader, но у вас есть функция, генерирующая данные по частям.

Решение — адаптер:

type Generator func() ([]byte, error)

func (g Generator) Read(p []byte) (n int, err error) {
data, err := g()
if err != nil {
return 0, err
}
n = copy(p, data)
return n, nil
}

// Использование:
gen := Generator(func() ([]byte, error) { return []byte("Hello"), nil })
io.Copy(os.Stdout, gen)

5.2. Middleware через цепочку обработчиков

В HTTP-фреймворках (и не только) middleware реализуется как функция, принимающая http.Handler и возвращающая http.Handler:

func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}

// Регистрация:
http.Handle("/api/", LoggingMiddleware(AuthMiddleware(apiHandler)))

Это позволяет строить декларативные, тестируемые цепочки обработки без наследования.

5.3. Опциональное поведение через проверку интерфейса

Если часть API является опциональной, можно проверить реализацию дополнительного интерфейса:

type Flusher interface {
Flush() error
}

func sendResponse(w io.Writer, data []byte) error {
_, err := w.Write(data)
if err != nil {
return err
}

if f, ok := w.(Flusher); ok {
return f.Flush()
}
return nil
}

Так поступает, например, http.ResponseWriter: он не обязан реализовывать http.Flusher, но *http.response (внутренняя реализация) делает это, и код может использовать Flush, если он доступен.


6. Распространённые сторонние библиотеки и их ключевые абстракции

Хотя стандартная библиотека Go обширна, в реальных проектах почти всегда используются сторонние пакеты. Ниже — обзор наиболее популярных категорий и их характерных интерфейсов/типов.

6.1. ORM и работа с базами данных: gorm.DB

Библиотека GORM предоставляет тип *gorm.DB, который является конфигурируемым строителем запросов. Он иммутабелен: каждый вызов метода (Where, Select, Preload) возвращает новый экземпляр.

db.Where("age > ?", 18).Find(&users)
db.Session(&gorm.Session{NewDB: true}).Where(...).Find(...) // изолированная сессия

*gorm.DB — это контекст запроса. Физическое соединение управляется пулом *sql.DB, который инкапсулирован внутри.

6.2. Логгеры: zap.Logger и slog.Handler

Современные логгеры (например, go.uber.org/zap) предоставляют структурированное логирование. zap.Logger — конкретный тип с методами Info, Error и т.д., принимающими пары ключ-значение.

logger.Info("user logged in",
zap.String("user_id", user.ID),
zap.Duration("latency", latency))

С Go 1.21+ в стандартную библиотеку добавлен пакет log/slog, в котором логгер (*slog.Logger) принимает slog.Handler — интерфейс, определяющий, как записывать записи. Это позволяет подключать zap, logrus, JSON-файлы и т.д. без изменения клиентского кода.

6.3. Веб-фреймворки: echo.Context, gin.Context

В отличие от net/http, фреймворки вроде Echo и Gin вводят собственный контекст запроса — структуру, объединяющую http.Request, http.ResponseWriter, параметры маршрута, данные мидлваре и методы удобства (JSON, Bind, Param).

Например, echo.Context:

e.GET("/users/:id", func(c echo.Context) error {
id := c.Param("id")
user, err := db.GetUser(id)
if err != nil {
return c.JSON(http.StatusNotFound, nil)
}
return c.JSON(http.StatusOK, user)
})

Такие контексты не заменяют context.Context, а содержат его (c.Request().Context()). Отмена и таймауты по-прежнему должны передаваться внутрь через ctx := c.Request().Context().

6.4. Конфигурация: viper.Viper

Библиотека Viper предоставляет тип *viper.Viper — централизованное хранилище конфигурации с поддержкой источников (файлы, переменные окружения, флаги), автоматического наблюдения и привязки к структурам.

v := viper.New()
v.SetConfigName("config")
v.AddConfigPath("/etc/app")
v.ReadInConfig()
v.Unmarshal(&cfg)

Рекомендуется инкапсулировать Viper за собственным интерфейсом приложения, чтобы избежать прямой зависимости от сторонней библиотеки в ядре логики.


7. Рекомендации по выбору

Ниже — практические эвристики, выработанные сообществом и закреплённые в официальной документации и code review практиках.

ЗадачаПредпочтительный выборОбоснование
Чтение данных из любого источникаio.ReaderУниверсальность, композиция, поддержка потоков
Передача коротких данных (< 4 КБ)[]byteНет накладных расходов на интерфейс и аллокации в bytes.Buffer
Многократное использование регулярного выражения*regexp.Regexp, глобальная переменнаяКомпиляция — дорогая операция
Защита состояния от конкурентного доступаsync.Mutex (или RWMutex) + инкапсуляцияПростота, отладочная ясность, предсказуемость
Однократная инициализацияsync.OnceГарантия выполнения ровно один раз, включая случаи паники
Сбор результатов от горутинsync.WaitGroup + каналыWaitGroup для синхронизации, каналы — для передачи данных
Кэширование временных буферовsync.PoolСнижение GC pressure в hot paths
Логированиеlog/slog.Logger с кастомным HandlerСтандартизация, структурированность, совместимость
HTTP-обработчикиПриём http.ResponseWriter, *http.Request; для сложных случаев — структуры с ServeHTTPСоответствие стандартной библиотеке, лёгкость тестирования
Отмена операцийcontext.Context, передаваемый явноЕдиный механизм отмены, таймаутов и трейсов

Не создавайте интерфейс ради интерфейса. Лучше начинать с конкретного типа, а интерфейс объявлять только тогда, когда появляется вторая реализация или потребность в мокировании для тестов.


8. Типичные ошибки проектирования и их последствия

Несмотря на простоту синтаксиса, Go допускает ряд системных ошибок, которые трудно диагностировать на ранних этапах, но которые приводят к проблемам масштабируемости, сопровождаемости и производительности. Ниже — наиболее распространённые из них, с пояснением, почему они возникают, и как их избежать.

8.1. Чрезмерно большие интерфейсы

Интерфейс, содержащий более трёх–четырёх методов, обычно нарушает принцип разделения интерфейсов (ISP). Такой интерфейс трудно реализовать, тестировать и расширять.

Пример антипаттерна:

type UserService interface {
CreateUser(ctx context.Context, u User) error
GetUser(ctx context.Context, id string) (User, error)
UpdateUser(ctx context.Context, u User) error
DeleteUser(ctx context.Context, id string) error
ListUsers(ctx context.Context, filter UserFilter) ([]User, error)
SendWelcomeEmail(ctx context.Context, id string) error
ExportToCSV(ctx context.Context) (io.ReadCloser, error)
}

Проблемы:

  • SendWelcomeEmail и ExportToCSV — побочные эффекты (рассылка) и представление (экспорт).
  • Тестирование требует мока всех методов, даже тех, что не используются в конкретном тесте.
  • Нарушается принцип единственной ответственности: один интерфейс управляет и хранением, и внешними системами.

Рекомендация:
Разделить на:

  • UserRepository — CRUD-операции.
  • EmailClient — с методом Send(...) error.
  • ReportGenerator — с методом GenerateUserReport(ctx context.Context) (io.ReadCloser, error).

Композиция на уровне вызова (а не интерфейса) обеспечивает гибкость:

type UserService struct {
repo UserRepository
email EmailClient
}

8.2. Возврат интерфейсов из функций

Нарушение правила «возвращайте структуры» приводит к излишней абстрагированности и потере возможностей.

Пример:

func NewUserService() UserService { // интерфейс!
return &userService{db: db}
}

Последствия:

  • Потребитель не может использовать методы, специфичные для конкретной реализации (например, (*userService).DebugDump()).
  • Усложняется тестирование: даже если нужен только мок, приходится создавать фиктивную реализацию интерфейса.
  • В стандартной библиотеке такого подхода почти нет — os.Open возвращает *os.File, net.Dialnet.Conn, и т.д.

Исключения (редкие):

  • Фабрики, где реализация выбирается динамически (например, sql.Open возвращает *sql.DB, но внутри может быть любой драйвер — однако интерфейс скрыт, клиент работает с *sql.DB как с конкретным типом).
  • Когда абстракция фактически требуется на границе модулей (например, плагины через plugin), но даже там предпочтительнее явный интерфейс в параметрах.

8.3. Неправильное использование context.WithValue

Контекст — не контейнер для параметров. Распространённая ошибка — передавать через context.Value идентификаторы пользователей, настройки, DTO.

ctx = context.WithValue(ctx, "user_id", "abc123") // ❌

Проблемы:

  • Типизация слабая: ключи — interface{}, значения — interface{}. Ошибки выявляются только в рантайме.
  • Затрудняется статический анализ, рефакторинг и документирование.
  • Нарушается инкапсуляция: код в глубине стека зависит от неявного состояния, установленного где-то выше.

Правильно:

  • Обязательные параметры — в аргументах функции.
  • Значения, нужные только для трассировки, логирования, аутентификации — через context.WithValue, но с типизированными ключами:
type userIDKey struct{}

func WithUserID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, userIDKey{}, id)
}

func UserID(ctx context.Context) (string, bool) {
id, ok := ctx.Value(userIDKey{}).(string)
return id, ok
}

Даже в этом случае такие значения не должны использоваться для принятия бизнес-решений (например, «если user_id = X, выдать повышенные права») — только для аудита и диагностики.

8.4. Отсутствие отмены в длительных операциях

Горутина, запущенная без привязки к context, может «утечь» — продолжать работу после завершения родительской операции.

Пример:

go func() {
data := fetchFromSlowAPI() // без ctx!
ch <- data
}()

Если клиент отменил запрос (например, закрыл соединение), горутина всё равно завершит вызов, потратит ресурсы и, возможно, запишет в закрытый канал — вызвав панику.

Исправление:

go func() {
select {
case data := <-fetchWithTimeout(ctx, 10*time.Second):
ch <- data
case <-ctx.Done():
return
}
}()

Любая операция, превышающая микросекунды, должна учитывать ctx.Done() — либо явно, либо через вызовы, которые его поддерживают (http.Client.Do, sql.DB.QueryContext, time.Sleeptime.After + select).

8.5. Паника в библиотечном коде

panic допустим только в случаях, когда:

  • Ошибка программиста (например, передача nil в regexp.MustCompile).
  • Восстановление невозможно, и процесс должен завершиться.

В библиотечном коде, особенно в обработчиках (http.Handler, мидлваре), panic должен быть перехвачен и преобразован в ошибку:

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
// ...
}

В противном случае паника завершит одну горутину, но оставит другие в неопределённом состоянии.


9. Архитектурные шаблоны

Go часто используется в трёх основных сценариях: консольные утилиты, HTTP-сервисы и фоновые процессы. Рассмотрим, какие типы и интерфейсы задействованы в каждом.

9.1. CLI-приложение (например, утилита обработки логов)

Ключевые компоненты:

  • flag или urfave/cli — парсинг аргументов.
  • os.Stdin, os.Stdout, os.Stderr — реализуют io.Reader/io.Writer.
  • bufio.Scanner, csv.Reader — потоковая обработка.
  • regexp.Regexp — фильтрация строк.
  • sync.Pool — для переиспользования буферов при обработке тысяч строк в секунду.

Типичный поток данных:

os.Args → флаги → конфиг
os.Stdin (io.Reader) → bufio.Scanner → обработка строки → regexp.Match → вывод в os.Stdout (io.Writer)

Архитектура: линейная конвейерная обработка. Горутины редко нужны, если только нет параллельной обработки независимых файлов.

9.2. HTTP-микросервис (REST API)

Слои и ответственные типы:

  1. Транспортный слой (net/http или echo/gin):

    • http.Request, http.ResponseWriter
    • context.Context (из r.Context())
    • Middleware: логирование, аутентификация, метрики.
  2. Слой приложения (handlers → services):

    • Service-структуры с методами, принимающими context.Context.
    • Возвращаемые типы — доменные структуры (User, Order), а не DTO.
    • Ошибки — кастомные типы с поддержкой errors.Is/errors.As.
  3. Слой данных:

    • *sql.DB — пул соединений.
    • *gorm.DB или sqlx.DB — для удобства.
    • Репозитории: интерфейсы (UserRepository), реализации (userRepo{db: db}).
    • io.Reader/io.Writer — для загрузки/выгрузки файлов.
  4. Инфраструктурные зависимости:

    • *zap.Logger, prometheus.Client, redis.Client — передаются через конструктор (dependency injection).
    • sync.Once — для ленивой инициализации подключений.

Пример вызова:

ctx := r.Context()
user, err := h.userService.GetUser(ctx, id)
if err != nil {
if errors.Is(err, user.ErrNotFound) {
http.Error(w, "Not found", http.StatusNotFound)
return
}
// логирование + 500
}
h.encoder.Encode(w, user) // encoder: интерфейс с методом Encode(io.Writer, interface{})

Обратите внимание: http.ResponseWriter используется как io.Writer — это позволяет заменить HTTP-вывод на запись в файл при тестировании.

9.3. Фоновый воркер (например, обработка очереди задач)

Ключевые компоненты:

  • context.Context с отменой (например, signal.NotifyContext).
  • sync.WaitGroup или errgroup.Group (из golang.org/x/sync/errgroup) — ожидание завершения.
  • time.Ticker или cron — периодические задачи.
  • chan Task — канал как очередь (ограниченная ёмкость — make(chan Task, 100)).
  • sync.Pool — для объектов Task, если они тяжёлые.

Цикл обработки:

ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()

eg, ctx := errgroup.WithContext(ctx)
eg.Go(func() error { return consumer.Run(ctx) })
eg.Go(func() error { return metricsServer.Run(ctx) })

if err := eg.Wait(); err != nil && !errors.Is(err, context.Canceled) {
log.Fatal(err)
}

errgroup.Group — предпочтительнее sync.WaitGroup, потому что она агрегирует ошибки и отменяет контекст при первой панике/ошибке.


10. Интеграция с инструментами разработки

Go обладает зрелой экосистемой инструментов, многие из которых полагаются на определённые соглашения и интерфейсы.

10.1. Генерация документации: go doc и godoc

Утилита go doc извлекает комментарии над пакетами, типами и функциями. Требования:

  • Комментарий должен идти непосредственно перед объявлением.
  • Начинаться с имени сущности (// Counter implements ..., а не // This is a counter).
  • Для интерфейсов — пояснение контракта, а не реализации.

Пример корректного комментария:

// Stringer is implemented by any type that has a String method,
// used to provide a human-readable representation.
type Stringer interface {
String() string
}

10.2. Тестирование: testing.T, таблицы тестов, моки

Go не требует фреймворков для тестирования. Основные практики:

  • Таблицы тестов ([]struct{ name, input, want }) — для проверки множества случаев.
  • Моки — через интерфейсы и ручную реализацию (без gomock, если интерфейс мал).
  • testify/assert — опционально, для удобства сравнений.

Пример мока без сторонних библиотек:

type mockEmailClient struct {
called bool
}

func (m *mockEmailClient) Send(ctx context.Context, to, msg string) error {
m.called = true
return nil
}

// В тесте:
client := &mockEmailClient{}
svc := NewUserService(repo, client)
_ = svc.WelcomeUser(ctx, "id123")
if !client.called {
t.Error("email not sent")
}

10.3. OpenAPI и генерация кода: swaggo/swag, oapi-codegen

Для HTTP-API часто требуется спецификация OpenAPI. Библиотека swaggo/swag сканирует комментарии над обработчиками:

// @Summary Get user
// @Param id path string true "User ID"
// @Success 200 {object} User
// @Router /users/{id} [get]
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) { ... }

Затем swag init генерирует docs/swagger.json. Важно: эти комментарии — часть кода, их нужно поддерживать в актуальном состоянии.

Альтернатива — oapi-codegen: сначала пишется openapi.yaml, затем генерируются интерфейсы обработчиков и клиентский код. Это строже, но исключает рассогласование.