Идиоматика 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, когда писать интерфейс, когда нет, как обращаться с ошибками.
Канонические тексты:
- Effective Go, https://go.dev/doc/effective_go
- Go Code Review Comments, https://go.dev/wiki/CodeReviewComments
- Go Proverbs Роба Пайка, https://go-proverbs.github.io/
- The Go Programming Language (Donovan/Kernighan), учебник
Дальше пройдёмся по каждому, плюс структура проекта и 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,
newvsmake, 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-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.