golang

Go подробно! Часть 1. Структура проекта

  • пятница, 10 июля 2026 г. в 00:00:13
https://habr.com/ru/articles/1057358/

Привет!

Буду с вами честен, для меня читать сухие переводы документации или смотреть 40-часовые видеокурсы - то еще удовольствие. Поэтому я решил устроить эксперимент и разбирать Go прямо в публичном поле, шаг за шагом документируя весь процесс.

Я не новичок в разработке - повседневно пишу на Python и TypeScript, знаком с базовым devops. Но вот на территорию Go осознанно захожу впервые.

Я возьму за основу официальный трек обучения от создателей языка и постараюсь подробно разобрать как Go устроен. Мне интересно не только то, как реализованы конкретные механизмы языка, но и почему/зачем так сделано, попутно проводя параллели с теми технологиями, к которым я уже привык.

Этот цикл статей - мой интерактивный учебник, который я пишу в реальном времени параллельно с изучением языка. Я не Go-гуру и не архитектор с многолетним опытом в этом стеке.

Поэтому если вы опытный Go-разработчик и видите в тексте неточность или косяк - буду искренне рад конструктивным подсказкам в комментариях.

Иногда я буду осознанно сворачивать с официальной тропы, чтобы узнать тему чуть глубже и точнее, чем это предлагает базовый туториал


Пакеты и импорты

Официальный интерактивный курс встречает нас знакомством с таким понятием как «package». Услышав данное слово, у меня в голове сразу вспоминается Java. Как и в Java, пакет - это директория. Но различий тут больше, чем сходств.

package main

import (
	"fmt"
	"math/rand"
)

func main() {
	fmt.Println("My favorite number is", rand.Intn(10))
}

Демонстрационный код документации сразу знакомит нас с двумя примерами импорта.

rand и fmt- стандартные пакеты, поэтому сложно представить как бы импортировались пользовательские пакеты. Чтобы это понять, давайте нарисуем структуру гипотетического проекта, где мы попытались воссоздать логику стандартных библиотек локально:

project/                  <-- Корневая папка проекта
├── main.go              <-- Go файл корневого пакета
├── fmt/                 <-- Пакет fmt
│   └── printer.go       <-- Go файл пакета fmt
└── math/                <-- Пакет "math"
    └── rand/            <-- *Вложенный* пакет "rand"
        └── generator.go <-- Go файл пакета rand

В Go нет вложенных пакетов, как может показаться из строки import "math/rand". Вложенность папок существует исключительно как инструкция для компилятора - по какому пути на диске искать код.

Т.е. мы не можем обратиться к пакету через родительский пакет:

package main

import "math"

func main() {
	math.rand.Intn() // ОШИБКА! rand не импортирован
}
undefined: math.rand

Импортируем только по прямому пути к пакету:

package main

import (
	"math/rand"
	"fmt"
)

func main() {
	fmt.Println(rand.Intn(5))
}

В самих Go-файлах при объявлении пакета нужно писать только название текущего каталога. Вы никогда не увидите конструкцию package math/rand, внутри файла generator.go будет строго написано package rand.

Ссылаясь на официальную документацию:

«Пакет — это набор исходных файлов в одном каталоге, которые компилируются вместе»

Значит, можно сказать, что пакет - это атомарная, неделимая единица компиляции. Именно то, что компилируются вместе в единый неделимый блок, объясняет, почему в Go нельзя импортировать конкретную переменную или функцию (как мы привыкли делать в Python через from module import func или в TS через import { точечно })

Несмотря на то, что мы импортируем целый пакет, это не влияет на вес скомпилированного бинарника. За удаление неиспользуемого кода отвечает линкер Go. Данный процесс называется Dead Code Elimination и является одним из финальных этапов компиляции.

Цитируя вторую часть трактовки пакетов в официальной документации:

«Функции, типы, переменные и константы, определенные в одном исходном файле, видны всем остальным исходным файлам в том же пакете»

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

Создадим рядом два файла в одной папке:

//test.go
package main

func test() string {
    return "test function from another go file"
}
//main.go
package main

import "fmt"

func main() {
    fmt.Println(test())
}

Результат выполнения:

test function from another go file

Важно упомянуть роль пакетa main и функции main. В некоторых статьях, упоминается, что каждая Go-программа, должна содержать пакет main и соответственно функцию main. Это, отнюдь, не верное утверждение. Если ваш проект предполагает компиляцию в исполняемый файл, то ,несомненно, у программы должна быть точка входа - для этого и есть пакет main с функцией main. Но если ваш проект - это библиотека, то зачем ей входной пакет с соответствующей функцией, кроме как какого-то тестирования? Отсюда, закрепим знание, что в проекте на Go нет обязательного правила наличия пакета main.

Зарезервированные функции пакетов

Их всего 2 - main()иinit().
Мы уже обсудили значение функции main(), но что будет если объявить ее в пакетах, отличных от main?
Ответ: данная функция станет обычной, не зарезервированной, т.е. потеряет свое свойство входной точки программы.
Значит, данная функция будет зарезервированной и не потеряет свои свойства только внутри пакета main.

Но что за функция init()?
init() - это зарезервированная функция в Go, которая автоматически вызывается рантаймом (средой выполнения) при импорте пакета. Она не принимает никаких аргументов и ничего не возвращает.
Главная цель init() - это первоначальная настройка окружения и подготовка состояния пакета к работе до того, как начнет выполняться основной код программы (в том числе перед функцией main).

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

Функция init()может быть объявлена в пакете множество раз, это допускается, в таком случае они просто выполнятся друг за другом в том виде, в котором представлены компилятору. Согласно документации передача происходит в алфавитном порядке.

Если мы импортируем несколько разных независимых друг от друга пакетов с функциями init(), они будут инициализированы в алфавитном порядке путей этих пакетов.

В случае, если между пакетами есть зависимость (например, пакет Б импортирует пакет А), порядок будет определяться деревом зависимостей: сначала полностью инициализируется самый глубокий (независимый ни от кого в этой цепочке) пакет, и только потом рантайм пойдет вверх по дереву к пакету main.

Отсюда стоит упомянуть важный вариант импорта - Side-Effect Import и тему Blank identifier.

Blank identifier

package main

import (
	"fmt"
	// Импортируем пакет только ради функции init()
	_ "internal/plugins/crypto" // нижнее подчеркивание = "blank identifier"
)

func main() {
	fmt.Println("Основной цикл программы запущен")
}

Здесь нас встречает новый инструмент - _ - это нижнее подчеркивание называется «blank identifier» и имеет много сценариев применения в Go. Сама документация трактует его как некий «плейсхолдер», сравнивания с "/dev/null".

Конструкция import _ *package_name* позволяет нам вызвать функцию init() пакета без создания локальной переменной-ссылки на него. Но зачем нам такой импорт если мы можем импортировать обычным способом, зная что линкер почистит неиспользуемый код?
Тут важно помнить о строгости компилятора: импортируя пакет мы обязаны его использовать:

package main

import (
	"math/rand"
)

func main() {
	return
}

// Результат: "math/rand" imported and not used

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

Это значит, что side effect import - это «разрешение» нам не использовать импортируемый пакет:

package main

import (
	_ "math/rand"
)

func main() {
	return
}

Но, официальная документация предлагает нам еще один вариант: полноценный импорт, но с игнорированием ошибки.
Это достигается опять же путем использования blank identifier, но уже в блоке глобальных объявлений.

package main 
import ( 
	"fmt" 
	"io" 
	"log" 
	"os" 
) 
var _ = fmt.Printf // Для отладки; 
var _ io.Reader     // Для отладки;
 
func main() { 
}

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

Архитектурные нюансы импортов

Кратко пройдемся по некоторым важным знаниям по импортам, которые не говорятся сразу в документации и интерактивном курсе, но очень важны для раскрытия в главе "Структура проекта".

Циклические зависимости

В отличие от многих других ЯП, в Go строго запрещены циклические зависимости (пакет А не может импортировать пакет B, если пакет B импортирует пакет А).

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

Если допустить такую ситуацию, компилятор даже не начнет сборку. Он выдаст ошибку: import cycle not allowed.

Решается данная проблема добавлением промежуточного пакета C.

Package aliasing

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

package main

import (
	"html/template"
	"text/template"
)

func main() {
	// ...
}

Компилятор моментально остановит сборку и выдаст ошибку text/template redeclared as imported package name

Всё логично: в рамках одного файла имя template уже занято первым импортом. Чтобы разрулить этот конфликт, в Go как раз и существует механизм package aliasing. Мы обязаны явно указать компилятору, под какими именами эти пакеты будут жить в нашем файле:

package main

import (
	htmlTemplate "html/template"
	textTemplate "text/template"
)

func main() {
	htmlTemplate.New("web")
	textTemplate.New("log")
}

Dot import

Данный механизм позволяет все экспортируемые функции, типы и переменные импортируемого пакета поместить в область видимости нашего файла так, будто они объявлены прямо здесь.

Для этого достаточно поставить "." перед путем к пакету.

package main 

import . "fmt" 

func main() { 
	Println("Куда делся префикс пакета?") // Вызываем функцию напрямую 
}

Сообщество разработчиков не рекомендует использовать данный вариант импорта ради сохранения читаемости кода.

Видимость элементов

Мы уже не один раз вызывали методы импортируемых пакетов, но так и не разобрали тему видимости данных элементов.

В Go, в отличие от стандартных ЯП с ООП, нет ключевых слов для определения модификатора доступа.

Всё решает регистр первой буквы идентификатора (будь то функция, структура, переменная или кастомный тип):

  • Имя начинается с заглавной (Большой) буквы — сущность является экспортируемой (exported). Это аналог public. Её увидят и смогут вызвать любые другие пакеты, которые импортируют пакет. Например: fmt.Println(), rand.Intn().

  • Имя начинается со строчной (Маленькой) буквы — сущность является неэкспортируемой (unexported). Это аналог private. Она видна абсолютно всем файлам внутри её родного пакета, но наглухо скрыта от любого внешнего мира.

Инкапсуляция на уровне папок: Пакет internal

Еще один механизм определения видимости элементов - создание пакета internal.

Представим ситуацию: ты пишешь большую библиотеку, у тебя там 10 пакетов, и ты хочешь, чтобы они видели код друг друга, но внешний мир (те, кто скачает твою библиотеку с GitHub) этот код вызвать не мог?

Для этого авторы Go встроили в компилятор поддержку специального имени папки — internal.

Если ты создаешь пакет по пути myproject/internal/auth, то компилятор разрешит импортировать этот пакет только тем пакетам, которые находятся внутри дерева myproject. Если чужой проект попытается написать import "github.com/user/myproject/internal/auth", компилятор выдаст ошибку.

Стоп! А как у нас в пути к пакету оказался адрес GitHub?
Тут мы наконец перейдем к главному герою главы про структуру проекта на Go - модули.

Модули

Об относительных импортах

В основном блоке статьи, где мы говорили про импорты, я упустил еще один вариант - относительный импорт.

import "./relative/path"

Данный способ невозможно использовать в режиме модуля, о чем нам сообщает компилятор:

"./relative/path" is relative, but relative import paths are not supported in module mode

Оставим данный вариант импорта просто для некого "знания", что такой способ когда-то использовался, сейчас, вероятно, из-за того, что большинство проектов пишутся в модульном варианте такой способ не используется.

Но что это за такой режим, режим модуля?

Что такое модуль?

Модуль — это набор пакетов Go, хранящихся в виде дерева файлов, в корне которого лежит файл go.mod. Этот файл определяет путь модуля (module path) (который также является префиксом для импорта корневого каталога) и требования к зависимостям — то есть другие модули, необходимые для успешной сборки. Каждое требование записывается в виде пути к модулю и его конкретной семантической версии (подробнее о версионировании модулей, подробнее о семантическом версионировании).

Для нас это означает появление новой вложенности, стоящей перед пакетом. Теперь мы понимаем почему в примерах кода на Go локальные пакеты чаще всего импортируются не относительным импортом и не как стандартные пакеты, а с припиской какого-то Github-репозитория (или любого иного варианта именования проекта).

Для создания модуля достаточно написать go mod init github.com/example/repo
(Принято указывать именно путь к вашему будущему репозиторию на GitHub. Это гарантирует, что имя вашего модуля будет уникальным, и никто другой не столкнется с конфликтом имён).

После выполнения данной команды и появляется долгожданный файл go.mod:

module github.com/example/repo

go 1.25.3

Таким образом, импорты имеют такой вид:

project/                  
├── main.go              
├── packageA/                 
│   └── main.go       
└── packageB/                
    └── subpackage/            
        └── main.go 
package main 

import (
	"github.com/example/repo/packageA"
	"github.com/example/repo/packageB/subpackage"
)

func main() { 
	...
}

В современном Go режим модуля стоит по умолчанию и уже почти каждый проект использует эту концепцию.

Стоит сказать, что модули пришли в язык не сразу, это своего рода аналог Python virtualenv, node_modules в NodeJS (скорее pnpm). До модулей было просто большое глобальное пространство (GOPATH), где все проекты и их зависимости хранились в одной куче в локальной памяти.

С приходом Go Modules система стала похожа на то, к чему мы привыкли в других языках: проект изолирован, а в его корне лежит манифест.

Вот мы и закончили первую часть. Для меня написание подобных статей - это совершенно новый, неизведанный опыт. Понятно, что публикацией таких текстов я не открою Америку - на просторах интернета полно разборов и документации. Но у каждого автора своя система, свой бэкграунд и свой порядок повествования. У меня нет грандиозной цели кого-то поучать, скорее это подробный разбор для таких же людей, которые хотят осознанно разобраться в экосистеме Go. Спасибо, что дочитали, и до встречи во второй части!