О Go modules
2019-08-03
Я тут как-то
возмущался
тем, как в Go принято
жёстко помещать все необходимые исходники,
включая и зависимости,
в один $GOPATH/src
.
Так вот,
в Go 1.11 (август 2018) появилась новая экспериментальная фича.
А в Go 1.12 (февраль 2019) эта фича стала включенной постоянно.
Называется эта фича «модули» ("modules").
Go modules решают сразу несколько проблем.
Теперь не нужен $GOPATH
, и не нужно общее дерево исходников для всех.
Теперь не нужен vendoring,
ибо заботой о зависимостях и их версиях
теперь занимается сам go
(не то, чтобы сам компилятор,
а скорее стандартные инструменты разработки
от Google).
Ну а самое главное,
с помощью модулей можно добиться повторяемой сборки.
Это когда ваша программа зависит именно от тех версий библиотек,
от которых зависит.
И какой-нибудь другой разработчик,
взяв ваш код и попытавшись его собрать,
получит те же самые зависимости,
а не абстрактный master,
как это было раньше.
Чтобы попробовать модули,
нам нужен go 1.12.
В текущем стабильном Ubuntu есть только 1.10.
Так что придётся пойти на официальный сайт,
и скачать архивчик.
Распаковать его, допустим,
в ~/opt/go
.
И прописать ~/opt/go/bin
в $PATH
.
Там всего три бинарника,
кучка статических библиотек,
и ещё больше исходников и документации.
$ go version
go version go1.12 linux/amd64
Потренируемся на кошках. То есть на hello world.
Создадим где-нибудь папочку gogreet_example
.
А в ней файл main.go
.
package main
import (
"fmt"
"github.com/gelin/gogreet"
)
func main() {
fmt.Println(greet.GreetingFor("World"))
}
"github.com/gelin/gogreet" — это такая специальная библиотека, я её специально создал для этого примера. Про библиотеки поговорим чуть позже.
Собираем.
$ go build
main.go:4:2: cannot find package "github.com/gelin/gogreet" in any of:
/home/gelin/opt/go/src/github.com/gelin/gogreet (from $GOROOT)
/home/gelin/go/src/github.com/gelin/gogreet (from $GOPATH)
Всё правильно.
Мы не проинициализировали модули.
А без модулей оно пытается собрать,
используя старый добрый $GOPATH
.
Проинициализируем модули.
$ go mod init example
go: creating new go.mod: module example
Мы создали модуль по имени "example".
Появился файл go.mod
следующего содержания:
module example
go 1.12
Директива module
задаёт имя модуля.
В данном случае это просто программа,
которая не будет чьей-либо зависимостью
и не будет опубликована.
Поэтому достаточно простого короткого имени.
Директива go
говорит,
для какой (минимальной) версии языка Go
написан этот модуль.
Собираем снова.
$ go build
go: finding github.com/gelin/gogreet v0.0.1
go: downloading github.com/gelin/gogreet v0.0.1
go: extracting github.com/gelin/gogreet v0.0.1
Что произошло?
Go увидел, что в коде мы импортируем библиотеку "github.com/gelin/gogreet".
Он пошёл на GitHub и обнаружил,
что в этом репозитории есть тег v0.0.1
,
и это есть самый большой тег,
обозначающий версию.
Он скачал этот репозиторий в ~/go/pkg/mod/github.com/gelin/[email protected]
(можете думать об этом как о кэше зависимостей,
примерно таком же, как у Maven или Gradle в ~/.m2
).
Он обновил go.mod
,
вписал туда найденные зависимости и их точные версии.
module example
go 1.12
require github.com/gelin/gogreet v0.0.1
А ещё появился файлик go.sum
примерно с таким содержимым:
github.com/gelin/gogreet v0.0.1 h1:NMpwLOuvgKyzMlo8Td0LcBXTLScxGFQmQVYf6GTLp0s=
github.com/gelin/gogreet v0.0.1/go.mod h1:OW4TXNEqoUo4n6dBHFXyMz2QxMkwj/gWMNLZ0xjfV6g=
go.sum
— это контрольные суммы.
Использование тега,
конечно,
вроде бы гарантирует,
что мы всегда будем использовать именно ту самую версию зависимости.
Но на всякий пожарный мы таки запишем контрольные суммы коммита и go.mod
файла библиотеки.
Ну и сама программка у нас собралась и даже работает. Бинарник назвался по имени модуля.
$ ./example
Hello, World!
Наличие файла go.mod
и превращает данный каталог
в Go модуль,
меняет поведение go
.
Файлы go.mod
и go.sum
положено коммитить в репозиторий,
чтобы обеспечить повторяемую сборку
всем, кто его склонирует.
Это почти всё, что нужно знать про модули со стороны пользователя.
Intellij IDEA прекрасно работает с модулями.
Сама в фоне обновляет go.mod
,
если вы добавили новый импорт.
Сама выкачивает зависимости,
если go.mod
изменился.
Нужно только включить поддержку модулей для проекта.
В настройках это называется "vgo",
потому что так назывался отдельный бинарник
для работы с модулями,
когда это было ещё диким экспериментом (до 1.11).
Ещё пара полезных команд.
go mod download
выкачивает все зависимости,
указанные в go.mod
.
То есть наполняет тот самый кэш.
Это может быть полезно при сборке Docker образов.
Зависимости меняются значительно реже,
чем код,
и качать их сильно дольше,
чем компилировать,
поэтому имеет смысл выкачивание зависимостей сделать отдельным слоем
в Dockerfile
.
Для работы go mod download
достаточно иметь только файл go.mod
в каталоге,
файлы исходников ему не нужны.
go mod tidy
приводит go.mod
(и go.sum
) в порядок.
Удаляет неиспользуемые более зависимости, например.
Иногда такую чистку стоит проводить.
Если вы используете Go modules,
вовсе не обязательно,
чтобы используемые вами зависимости тоже использовали модули.
Можно подрубать и старые библиотеки.
Просто в go.mod
вместо версии из тега будет дата и хэш последнего коммита
в тот репозиторий.
Ну а если там есть теги семантического версионирования,
то вообще проблем нет.
Через go.mod
теперь даже принято подрубать неявные «инструментальные» зависимости.
Если вам нужен какой-нибудь бинарник,
который нужно собрать из исходников на Go,
для кодогенерации до сборки вашего кода.
(Да, в Go развита кодогенерация, смотрите go generate
.)
Получается,
что ваш код от кода бинарника прямо не зависит
(нет соответствующих импортов),
но собрать бинарник надо.
Подробности смотрите в примере
из репозитория примеров использования модулей.
Посмотрим на библиотеку.
Код простой.
package greet
import "fmt"
func GreetingFor(name string) string {
return fmt.Sprintf("Hello, %s!", name)
}
go.mod
тоже прост.
module github.com/gelin/gogreet
go 1.12
Имя модуля включает в себя полный путь до репозитория.
Это называется "import path".
То есть то,
что нужно писать в import
,
чтобы заполучить себе тот самый пакет,
в каталоге которого лежит go.mod
.
Интересно,
что имя модуля (и репозитория) заканчивается на "gogreet".
А пакет,
на самом деле,
называется "greet".
Так прописано в исходниках.
И хоть вы и импортируете "github.com/gelin/gogreet",
далее в коде вы используете greet.
.
Это видно в нашем main()
,
с которого началась эта статья.
И это обычное поведение Go,
так и раньше было.
Просто, не запутайтесь.
Import path — это одно,
а имя пакета — это немного другое.
Репозиторий вашей библиотеки должен содержать теги
семантического версионирования.
То есть v0.0.1
, v0.1.0
, v1.0.0
и так далее.
Первая цифра — мажорная версия,
инкрементируется,
когда появляются несовместимые изменения.
Вторая цифра — минорная версия,
инкрементируется,
когда появляются совместимые изменения.
Третья цифра (или даже набор цифр и букв) — патч,
инкрементируется,
когда появляются совместимые исправления.
Должен. Если вы используете Go модули, ваши публичные библиотеки обязаны использовать семантическое версионирование. Без этого никак. Либо у пользователей вашей библиотеки не будет повторяемых сборок. А это не то, ради чего они будут заморачиваться с модулями.
Есть особые нюансы для мажорных версий 2 и выше. Они подробно описаны в официальной документации по модулям. Кратко: при смене мажорной версии должен меняться import path. То есть должно стать "github.com/gelin/gogreet/v2". Это нужно, чтобы можно было одновременно использовать разные несовместимые версии одной и той же библиотеки. В том числе и для того, чтобы v2 библиотека могла бы базироваться на v1 этой же библиотеки.
Пока наш библиотечный модуль состоит из одного пакета, всё более-менее просто. А добавим-ка ещё один пакет.
$ tree
.
├── format
│ └── format.go
├── go.mod
├── greet.go
├── greet_test.go
├── LICENSE
└── README.md
Пусть формат приветствия определяется в этом новом пакете.
package format
func GreetingFormat() string {
return "Hello, %[1]s!"
}
А использовать мы его будем так:
package greet
import (
"fmt"
"github.com/gelin/gogreet/format"
)
func GreetingFor(name string) string {
return fmt.Sprintf(format.GreetingFormat(), name)
}
В Go нет относительных импортов. Поэтому новый пакет мы импортируем, добавляя имя соответствующего каталога к изначальному import path модуля. Получается "github.com/gelin/gogreet/format".
Пусть это будет v0.0.2 нашей библиотеки.
С точки зрения клиента ничего не изменилось.
Можно остаться на v0.0.1.
А можно поменять версию в go.mod
.
module example
go 1.12
require github.com/gelin/gogreet v0.0.2
Всё работает как раньше.
$ go build
go: finding github.com/gelin/gogreet v0.0.2
go: downloading github.com/gelin/gogreet v0.0.2
go: extracting github.com/gelin/gogreet v0.0.2
$ ./example
Hello, World!
Интересности начинаются, когда мы форкаем библиотеку.
Допустим, мы хотим, чтобы приветствие было по-русски. Сделаем форк "github.com/gelin/gogreet_fork_ru". (Хитрый GitHub не позволяет форкать в свой же аккаунт, но нас сейчас не GitHub интересует, так что просто запушим под другим именем и склонируем.)
Меняем функцию с нашим форматом сообщения.
package format
func GreetingFormat() string {
return "Здравствуй, %[1]s!"
}
Тесты проходят.
Коммитим и пушим наш форк.
Версия становится v0.0.3.
go.mod
мы не меняли.
Пусть наше приложение теперь разговаривает по-русски.
package main
import (
"fmt"
"github.com/gelin/gogreet_fork_ru"
)
func main() {
fmt.Println(greet.GreetingFor("Мир"))
}
Собираем.
$ go build
go: finding github.com/gelin/gogreet_fork_ru v0.0.3
go: downloading github.com/gelin/gogreet_fork_ru v0.0.3
go: extracting github.com/gelin/gogreet_fork_ru v0.0.3
go: github.com/gelin/[email protected]: parsing go.mod: unexpected module path "github.com/gelin/gogreet"
go: error loading module requirements
Воооот. Go пошёл в репозиторий форка, скачал, а увидел там совсем другое имя модуля.
Нельзя форкнуть Go модуль,
не поменяв go.mod
.
Исправляем.
module github.com/gelin/gogreet_fork_ru
go 1.12
Не работает.
Тесты показывают, что у нас получается сообщение "Hello, Мир!".
Почему?
Потому что пакет gogreet
из форка импортирует "github.com/gelin/gogreet/format",
да, из оригинального репозитория.
Там даже в go.mod
появляется соответствующая зависимость
после прогонки тестов.
Относительных импортов у нас нет. Каждый пакет сам за себя. И пакеты из оригинального репозитория так и норовят просочиться в наш форк.
Для решения этой проблемы есть ещё одна директива для go.mod
: replace
.
Применим.
module github.com/gelin/gogreet_fork_ru
go 1.12
replace github.com/gelin/gogreet => ./
require github.com/gelin/gogreet v0.0.2
Здесь мы говорим, что,
если вы встречаете импорт "github.com/gelin/gogreet",
то надо это понимать как модуль,
находящийся в текущем каталоге (относительно местоположения go.mod
).
При этом появляется ещё и зависимость от того,
что там в этом импорте подразумевалось изначально.
Если вы забудете эту зависимость, go
проставит её самостоятельно.
И, видимо,
лучше там указать именно ту версию,
что вы форкнули.
Библиотека работает. Пушим как v0.0.4.
Что там наша программка?
$ go build
go: finding github.com/gelin/gogreet_fork_ru v0.0.4
go: downloading github.com/gelin/gogreet_fork_ru v0.0.4
go: extracting github.com/gelin/gogreet_fork_ru v0.0.4
$ ./example
Hello, Мир!
Неееееет! Почему? Что пошло не так?
На самом деле,
директива replace
работает только локально.
То есть, только в библиотеке,
когда мы находимся в её каталоге и запускаем тесты.
А итоговая программа зависит от import path "github.com/gelin/gogreet_fork_ru" (явно)
и "github.com/gelin/gogreet/format" (косвенно, через нашу библиотеку).
Это даже видно в go.mod
:
module example
go 1.12
require (
github.com/gelin/gogreet v0.0.2
github.com/gelin/gogreet_fork_ru v0.0.4
)
И даже тесты библиотеки,
если их запустить из нашей программы
как go test github.com/gelin/gogreet_fork_ru
,
будут падать.
Значит, если мы меняем пакеты где-то внутри нашей форкнутой библиотеки, нужно обновлять и все импорты этих пакетов.
package greet
import (
"fmt"
"github.com/gelin/gogreet_fork_ru/format" // <--
)
func GreetingFor(name string) string {
return fmt.Sprintf(format.GreetingFormat(), name)
}
Теперь у нас нет зависимости от оригинальной библиотеки.
Из go.mod
она исчезает.
И replace
больше не нужен,
потому что это единственная внутренняя зависимость в библиотеке.
Пуш. v0.0.5.
В приложении нужно обновить зависимость.
$ go get -u
go: finding github.com/gelin/gogreet_fork_ru v0.0.5
go: downloading github.com/gelin/gogreet_fork_ru v0.0.5
go: extracting github.com/gelin/gogreet_fork_ru v0.0.5
$ go mod tidy
$ go list -m all
example
github.com/gelin/gogreet_fork_ru v0.0.5
Теперь работает.
$ go build
$ ./example
Здравствуй, Мир!
Получается, при форке
вам всё равно придётся переправлять импорты.
А это приведёт к мерзким диффам в пуллреквестах
и вообще невозможности их нормально принимать.
Впрочем,
это общая проблема при отсутствии относительных импортов,
которая была и до модулей.
С модулями у вас лишь появляется костылик в виде replace
.
Но им нужно пользоваться осторожно,
ибо любой replace
не меняет ситуацию глобально.
И либо вам всё равно придётся править импорты,
либо придётся заставлять пользователей тоже делать replace
.
Последний вариант выглядит так:
module example
go 1.12
require github.com/gelin/gogreet v0.0.2
replace github.com/gelin/gogreet => github.com/gelin/gogreet_fork_ru v0.0.4
В самом приложении, которое использует библиотеку, мы не меняем никаких импортов. А просто говорим, что у версии v0.0.2 оригинальной библиотеки есть совместимый форк версии v0.0.4, и именно его мы хотим использовать.
$ go list -m all
example
github.com/gelin/gogreet v0.0.2 => github.com/gelin/gogreet_fork_ru v0.0.4
Это — работает.
Конечно, не в коем случае нельзя делать локализации как в этом примере, форкая и переправляя все строки :)
Модули в Go — хорошая штука.
Они сильно упрощают жизнь простым смертным пользователям языка и библиотек.
Можно забыть про $GOPATH
и держать свои Go проекты где угодно и как удобно.
Можно автоматически взять последнюю версию библиотеки,
даже в точности не зная её номер,
просто прописав нужный импорт.
И эта версия будет зафиксирована в go.mod
,
пока вы не обновите её явно с помощью go get -u
.
Получаются стабильные воспроизводимые билды и контроль зависимостей из коробки.
Разработчикам библиотек добавилось немного головной боли. Нужно поддерживать семантическое версионирование (наконец-то!). Нужно думать об именах и структуре репозиториев. Думать, какие части репозитория должны быть разными модулями, или же достаточно одного модуля на весь репозиторий.
Форкам придётся выбирать.
Либо ориентироваться на пулреквесты в upstream
и использовать replace
,
в том числе и требовать этого от пользователей.
Либо окончательно ответвляться в независимую библиотеку.
Впрочем,
до модулей первого варианта просто не существовало.