Привет!
Буду с вами честен, для меня читать сухие переводы документации или смотреть 40-часовые видеокурсы — то еще удовольствие. Поэтому я решил устроить эксперимент и разбирать Go прямо в публичном поле, шаг за шагом документируя весь процесс.
Я не новичок в разработке — повседневно пишу на Python и TypeScript, знаком с базовым devops. Но вот на территорию Go осознанно захожу впервые.
Я возьму за основу официальный трек обучения от создателей языка и постараюсь подробно разобрать как Go устроен. Мне интересно не только то, как реализованы конкретные механизмы языка, но и почему/зачем так сделано, попутно проводя параллели с теми технологиями, к которым я уже привык.
Этот цикл статей — мой интерактивный учебник, который я пишу в реальном времени параллельно с изучением языка. Я не Go-гуру и не архитектор с многолетним опытом в этом стеке.
Поэтому если вы опытный Go-разработчик и видите в тексте неточность или косяк — буду искренне рад конструктивным подсказкам в комментариях.
Иногда я буду осознанно сворачивать с официальной тропы, чтобы узнать тему чуть глубже и точнее, чем это предлагает базовый туториал
Пакеты и импорты
Официальный интерактивный курс встречает нас знакомством с таким понятием как «package». Услышав данное слово, у меня в голове сразу вспоминается Java. Как и в Java, пакет — это директория. Но различий тут больше, чем сходств.
package mainimport ("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 mainimport "math"func main() {math.rand.Intn() // ОШИБКА! rand не импортирован}
undefined: math.rand
Импортируем только по прямому пути к пакету:
package mainimport ("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.gopackage mainfunc test() string { return "test function from another go file"}
//main.gopackage mainimport "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 mainimport ("fmt"// Импортируем пакет только ради функции init()_ "internal/plugins/crypto" // нижнее подчеркивание = "blank identifier")func main() {fmt.Println("Основной цикл программы запущен")}
Здесь нас встречает новый инструмент — _ — это нижнее подчеркивание называется «blank identifier» и имеет много сценариев применения в Go. Сама документация трактует его как некий «плейсхолдер», сравнивания с «/dev/null».
Конструкция import _ *package_name* позволяет нам вызвать функцию init() пакета без создания локальной переменной-ссылки на него. Но зачем нам такой импорт если мы можем импортировать обычным способом, зная что линкер почистит неиспользуемый код?
Тут важно помнить о строгости компилятора: импортируя пакет мы обязаны его использовать:
package mainimport ("math/rand")func main() {return}// Результат: "math/rand" imported and not used
Компилятор видит неиспользуемый импорт и аварийно останавливает сборку, даже не доходя до линкера.
Это значит, что side effect import — это «разрешение» нам не использовать импортируемый пакет:
package mainimport (_ "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 mainimport ("html/template""text/template")func main() {// ...}
Компилятор моментально остановит сборку и выдаст ошибку text/template redeclared as imported package name
Всё логично: в рамках одного файла имя template уже занято первым импортом. Чтобы разрулить этот конфликт, в Go как раз и существует механизм package aliasing. Мы обязаны явно указать компилятору, под какими именами эти пакеты будут жить в нашем файле:
package mainimport (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/repogo 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. Спасибо, что дочитали, и до встречи во второй части!
ссылка на оригинал статьи https://habr.com/ru/articles/1057358/