О 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).

vgo in IDEA

Ещё пара полезных команд.

go mod download выкачивает все зависимости, указанные в go.mod. То есть наполняет тот самый кэш. Это может быть полезно при сборке Docker образов. Зависимости меняются значительно реже, чем код, и качать их сильно дольше, чем компилировать, поэтому имеет смысл выкачивание зависимостей сделать отдельным слоем в Dockerfile. Для работы go mod download достаточно иметь только файл go.mod в каталоге, файлы исходников ему не нужны.

go mod tidy приводит go.modgo.sum) в порядок. Удаляет неиспользуемые более зависимости, например. Иногда такую чистку стоит проводить.

Если вы используете Go modules, вовсе не обязательно, чтобы используемые вами зависимости тоже использовали модули. Можно подрубать и старые библиотеки. Просто в go.mod вместо версии из тега будет дата и хэш последнего коммита в тот репозиторий. Ну а если там есть теги семантического версионирования, то вообще проблем нет.

Через go.mod теперь даже принято подрубать неявные «инструментальные» зависимости. Если вам нужен какой-нибудь бинарник, который нужно собрать из исходников на Go, для кодогенерации до сборки вашего кода. (Да, в Go развита кодогенерация, смотрите go generate.) Получается, что ваш код от кода бинарника прямо не зависит (нет соответствующих импортов), но собрать бинарник надо. Подробности смотрите в примере из репозитория примеров использования модулей.

Go modules

Посмотрим на библиотеку.

Код простой.

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 этой же библиотеки.

Versioning and Dependency Management

Пока наш библиотечный модуль состоит из одного пакета, всё более-менее просто. А добавим-ка ещё один пакет.

$ 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!     

Go Fork

Интересности начинаются, когда мы форкаем библиотеку.

Допустим, мы хотим, чтобы приветствие было по-русски. Сделаем форк "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

Это — работает.

Конечно, не в коем случае нельзя делать локализации как в этом примере, форкая и переправляя все строки :)

Learn Go

Модули в Go — хорошая штука. Они сильно упрощают жизнь простым смертным пользователям языка и библиотек. Можно забыть про $GOPATH и держать свои Go проекты где угодно и как удобно. Можно автоматически взять последнюю версию библиотеки, даже в точности не зная её номер, просто прописав нужный импорт. И эта версия будет зафиксирована в go.mod, пока вы не обновите её явно с помощью go get -u. Получаются стабильные воспроизводимые билды и контроль зависимостей из коробки.

Разработчикам библиотек добавилось немного головной боли. Нужно поддерживать семантическое версионирование (наконец-то!). Нужно думать об именах и структуре репозиториев. Думать, какие части репозитория должны быть разными модулями, или же достаточно одного модуля на весь репозиторий.

Форкам придётся выбирать. Либо ориентироваться на пулреквесты в upstream и использовать replace, в том числе и требовать этого от пользователей. Либо окончательно ответвляться в независимую библиотеку. Впрочем, до модулей первого варианта просто не существовало.