Раздел 22 · Go

Идиоматика Go и stdlib

middle~60 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Идиоматика Go и stdlib

Конспект-лекция для тех, кто прошёл Go Tour и code-basics и хочет начать писать код, который не стыдно показать. Параллельно с этой лекцией закрывай воркшоп Weather CLI на cobra, там лежат ДЗ и практика, на которых эта теория ляжет в руки.

0. О чём вообще речь и зачем

Go это язык с очень узкой философией. В отличие от Rust (где выразительность системы типов даёт десятки способов сделать одно и то же) или TypeScript (где экосистема предлагает 5 фреймворков на каждую задачу), в Go обычно есть один идиоматичный способ, и команда языка довольно жёстко его навязывает через gofmt, go vet, code review-культуру и stdlib.

Цель этой лекции, не выучить синтаксис (это быстро), а впитать идиоматику. Человек, пришедший из TS или Java, технически напишет рабочий Go-код за день, но он будет читаться как “TypeScript буквами Go”. Идиоматичный Go отличается интуицией: что считается красивым, что over-engineering, когда писать интерфейс, когда нет, как обращаться с ошибками.

Канонические тексты:

Дальше пройдёмся по каждому, плюс структура проекта и stdlib.


1. Структура Go-проекта

1.1. Минимальный модуль

Прежде чем городить директории, надо понять: в Go директория = пакет. Ты не создаёшь папку, чтобы “разложить файлы покрасивее”, ты создаёшь её, потому что хочешь новый package с отдельным API. Это первое, что ломает интуицию у людей с опытом в Java/C# (где принято class-per-file и глубокая иерархия).

Самый маленький проект:

myproject/
├── go.mod
├── go.sum
├── main.go
└── README.md

go.mod создаётся через go mod init github.com/user/myproject. Этого хватит для CLI-утилиты или прототипа. Не надо сразу создавать internal/, pkg/, cmd/, это типичный over-engineering у новичков.

Цитата из официальной доки Go: для маленького проекта одного main.go и go.mod более чем достаточно.

1.2. Когда выносить в подпакеты

Когда main.go распухает и в нём появляются логически разные куски (например, парсинг конфига, бизнес-логика, работа с HTTP), их можно вынести в подпакет. Самая частая первая итерация:

myproject/
├── go.mod
├── main.go
├── config.go      // package main, рядом с main.go
└── internal/
    └── weather/
        └── weather.go   // package weather

1.3. internal/, это не convention, это feature языка

Ключевой момент, который часто не понимают: internal/ это не просто договорённость, это правило компилятора. Go-компилятор с версии 1.4 запрещает импортировать пакет из internal/ снаружи модуля, то есть код в github.com/me/myproject/internal/foo физически невозможно заимпортить из другого репозитория. Это hard error на этапе сборки.

Что это даёт: ты можешь свободно рефакторить API внутри internal/, не боясь сломать чужой код. Это особенно важно для open-source библиотек, туда кладут всё, что не должно быть публичным API.

myproject/
├── go.mod
├── main.go
└── internal/
    ├── weather/      // приватная бизнес-логика
    └── httpclient/   // приватный HTTP-клиент

Можно иметь несколько internal/ на разных уровнях, каждый ограничивает видимость своим parent-ом.

1.4. cmd/, когда несколько бинарей

cmd/ нужен только если в репозитории несколько main пакетов (например, серверная часть и CLI). Если у тебя один бинарь, cmd/ не нужен, main.go лежит в корне.

myproject/
├── go.mod
├── cmd/
│   ├── server/
│   │   └── main.go     // package main, билдится как myproject-server
│   └── cli/
│       └── main.go     // package main, билдится как myproject-cli
└── internal/
    └── core/
        └── core.go

Билдятся через go build ./cmd/server и go build ./cmd/cli. Внутри main.go обычно минимум кода, только сборка зависимостей и вызов чего-то из internal/.

1.5. pkg/, спорный паттерн, скорее не нужен

pkg/ это директория для публичного, переиспользуемого кода. Идея: “вот это можно импортировать другим проектам”. Но в современной практике это антипаттерн для большинства проектов:

  • если код публичный, клади его в корень модуля или в named package в корне;
  • если приватный, клади в internal/;
  • pkg/ добавляет лишний уровень в import path (github.com/me/proj/pkg/foo вместо github.com/me/proj/foo).

pkg/ остался от старых проектов до появления internal/ (Go 1.4) и от привычек C-разработчиков. Канонический совет на 2024 и 2026 год: не используй pkg/, если у тебя нет конкретной причины.

1.6. golang-standards/project-layout, это НЕ стандарт

В гугле первой ссылкой выпадает golang-standards/project-layout с десятками директорий (api/, web/, configs/, scripts/, build/, deployments/, tools/, examples/, third_party/…). Это не официальный стандарт, репозиторий называется так провокационно. Сами авторы в README пишут: “если ты учишь Go или делаешь PoC, этот layout overkill”.

Russ Cox (один из ключевых разработчиков Go) и многие senior-инженеры комьюнити прямо критиковали этот репозиторий. Безопасный совет: не используй его как cargo cult, бери только то, что реально нужно проекту.

1.7. Layout по доменам, а не по техническим слоям

Когда проект вырастает до 5+ пакетов, есть две стратегии раскладки внутри internal/:

❌ По техническим слоям (привычно для Java/Spring):

internal/
├── handlers/
├── services/
├── repositories/
└── models/

✅ По доменам (идиоматично для Go):

internal/
├── user/
│   ├── handler.go
│   ├── service.go
│   ├── repository.go
│   └── model.go
├── order/
└── payment/

Доменный layout даёт высокую cohesion внутри пакета и ниже coupling между ними, изменение в user не тянет за собой order. Это коррелирует с DDD / Hexagonal, но не требует их явно.

1.8. Итоговый рекомендуемый layout для среднего pet-проекта

weather-service/
├── go.mod
├── go.sum
├── README.md
├── Makefile
├── .golangci.yml
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── config/
│   ├── weather/        // domain
│   ├── forecast/       // domain
│   └── httpapi/        // transport
├── migrations/         // SQL миграции
└── api/                // OpenAPI / proto, если нужно

Начать можно с двух директорий, остальное добавить, когда жмёт.


2. Effective Go, что важно

Документ старый (последний крупный апдейт, годы назад), но 80% актуально. Не читать целиком сразу, распухнет голова. Лучше так:

Прочитать на первой неделе:

  • Names, naming conventions: MixedCaps, короткие имена для receiver-ов (u *User, не user *User), пакеты в нижнем регистре без подчёркиваний.
  • Formatting, gofmt, goimports, не спорить.
  • Control structures, if с инициализацией (if err := f(); err != nil { ... }), отсутствие while (только for).

Прочитать на второй неделе:

  • Functions, multiple return values, named result parameters (использовать редко, в основном для документации).
  • Data, new vs make, slices vs arrays.
  • Methods, pointer vs value receivers (правило: если хоть один метод указатель, то все должны быть указатели; если структура содержит mutex, точно указатель).
  • Interfaces, главная глава.

Прочитать, когда дойдёт до concurrency:

  • Concurrency, горутины, каналы, select. Тема большая, разбираем отдельно дальше по курсу.

Ключевые идиомы из Effective Go, которые ломают привычки

Errors не throw’аются, а возвращаются. Никаких try/catch. Каждая функция, которая может зафейлиться, возвращает (T, error). Звучит примитивно, но даёт явный control flow:

data, err := fetch(url)
if err != nil {
    return fmt.Errorf("fetch %s: %w", url, err)
}

Интерфейсы маленькие. В Java/C# принято писать большие интерфейсы вперёд (“давайте опишем контракт”). В Go идиома обратная: the bigger the interface, the weaker the abstraction (Go Proverb). Канонический пример из stdlib, io.Reader с одним методом. Большинство интерфейсов в идиоматичном Go-коде, от 1 до 3 методов.

Интерфейсы определяются у потребителя, не у поставщика. Это огромное отличие от Java. Если у тебя есть *UserRepository со множеством методов, и handler-у нужны только два из них, handler у себя в пакете определяет свой UserReader interface { GetUser(id) }, а реальный *UserRepository его автоматически удовлетворяет (структурная типизация). Это называется accept interfaces, return structs (ещё один Proverb).

Никаких getter-ов и setter-ов. Поле Owner читается как obj.Owner, не obj.GetOwner(). Если нужен getter, он называется Owner(), а не GetOwner(). Setter, SetOwner().

init(), последнее средство. Его легко переиспользовать как анти-паттерн: “вот тут зарегистрируем глобально хендлер, вот тут подключимся к БД из init”. Идиоматично, явная инициализация в main().


3. Go Code Review Comments

Это короткий документ, буквально список из ~30 пунктов, который Go-team использует как чеклист при ревью кода в самом Go. Почти каждый пункт, конкретная idiom + ссылка на пример.

Самые ходовые пункты, которые встретишь сразу:

  • Error strings, не начинать с большой буквы, не заканчивать точкой. fmt.Errorf("something failed: %w", err), не "Something failed.". Потому что error часто оборачивается и попадает в середину предложения.
  • Receiver names, короткие, 1 или 2 буквы, консистентные внутри типа. func (s *Server) ..., не func (this *Server) и не func (server *Server).
  • Mixed caps, urlPony, не url_pony и не URLPony (последнее, только если URL это весь идентификатор).
  • Doc comments, каждый exported (PascalCase) идентификатор должен иметь комментарий, начинающийся с его имени: // User represents an authenticated user.. Это не для красоты, это формат, который читает go doc и pkg.go.dev.
  • Don’t panic, паника, для truly unrecoverable. Для бизнес-ошибок, error.
  • Useful zero value, проектируй типы так, чтобы их zero-value был полезен. Канонический пример: var b bytes.Buffer сразу готов к использованию, без NewBuffer(). Это главное отличие от Java-стиля “constructors everywhere”.

4. Go Proverbs, короткие но мощные

Роб Пайк собрал их в одном докладе. Каждый, медитативное “почему”. Разберу самые важные:

“Don’t communicate by sharing memory; share memory by communicating.” В большинстве языков concurrency = mutex над shared state. В Go идиоматичный путь, отдавать данные через channel. Это не значит, что mutex запрещён (он есть в stdlib и активно используется), но channel-first, дефолт.

“Concurrency is not parallelism.” Concurrency, это структура (как программа разложена на независимые активности), parallelism, исполнение (одновременное выполнение). В Go ты пишешь concurrent программу через горутины, а пойдёт она параллельно или нет, зависит от GOMAXPROCS и доступных ядер.

“The bigger the interface, the weaker the abstraction.” Уже разбирал выше. io.Reader сильнее, чем какой-нибудь 20-методный IUserRepository.

“Make the zero value useful.” Тоже разбирал. Это глубже, чем кажется, это диктует API всей stdlib.

“A little copying is better than a little dependency.” Важнейшее. В JS/Java-экосистеме нормально притащить пакет ради одной функции (привет, is-odd). В Go, нет. Скопируй 20 строк, разберись, как они работают, и положи в свой проект. Зависимости, это long-term liability.

“Errors are values.” Ошибки, это обычные значения (error, это просто интерфейс с одним методом Error() string), их можно программно проверять, оборачивать, сравнивать, агрегировать. Не “магия со стэк-трейсом”, как exception в JVM.

“Don’t just check errors, handle them gracefully.” Часто пишут:

if err != nil { return err }

Это иногда правильно, но часто стоит подумать, может, нужно залогировать с контекстом? Обернуть с пояснением? Вернуть domain-specific ошибку, не утечку из transport-слоя?


премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.