В большинстве моих личных и рабочих Go-проектов есть Makefile. Он за меня занимается всеми вопросами сборки и проверки кода через вызов одной или двух основных команд. Все инструменты включая golangci-lint и mockgen ставятся в каталог bin/ внутри проекта. Версии закреплены в самом Makefile, а каталог bin/ исключён из git. В статье разберу шаблон, который я переношу из проекта в проект.
Зачем ставить инструменты локально
По умолчанию go install кладёт бинарники в $GOPATH/bin. Это общий каталог для всех проектов на машине. Отсюда и проблема: в одном проекте линтер настроен под golangci-lint первой мажорной версии, в другом уже вторая. Глобально может стоять только одна. Кто-то из команды обновился и постепенно у остальных линтер начал падать с непонятными ошибками.
Локальная установка эту проблему исключает:
- Версия инструмента закреплена в Makefile и попадает в git вместе с кодом. У всей команды и в CI единая версия.
- Проекты не конфликтуют между собой. В каждом свой
bin/со своими зависимостями и их версиями. - Новому человеку достаточно клонировать репозиторий и запустить
make lint. Инструменты доедут сами. - Глобальное окружение остаётся чистым. Удалили каталог проекта, вместе с ним удалились и его инструменты.
GOBIN внутри проекта
Ключ ко всему это переменная окружения GOBIN. Если она задана, go install кладёт бинарник в неё, а не в $GOPATH/bin:
BIN_DIR := $(CURDIR)/bin
export GOBIN := $(BIN_DIR)
GOLANGCI_LINT_VERSION := v2.12.2
GOLANGCI_LINT := $(BIN_DIR)/golangci-lint
$(GOLANGCI_LINT):
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
.PHONY: lint
lint: $(GOLANGCI_LINT)
$(GOLANGCI_LINT) run
Обратите внимание, что цель $(GOLANGCI_LINT) это путь к файлу. Make проверяет, существует ли файл bin/golangci-lint. Если существует, установка пропускается. Если нет, make сначала соберёт линтер, потом запустит проверку. Отдельную команду для установки инструментов запоминать не нужно.
В .gitignore при этом одна строка:
/bin/
Бинарники не попадают в репозиторий. Версии инструментов при этом зафиксированы в Makefile и уходят в git вместе с кодом.
У подхода есть один нюанс. Make смотрит только на существование файла и не знает, какой версией тот собран. Если вы подняли GOLANGCI_LINT_VERSION, старый бинарник останется на месте. Я решаю это через удаление всех временных файлов и бинарников (например через make clean-tools) после смены версии для полной переустановки. Есть и более строгий вариант: добавить версию в имя файла, например bin/golangci-lint-v2.12.2. Тогда смена версии автоматически вызовет переустановку. В большинстве проектов мне хватает простого варианта.
Полный Makefile
Вот шаблон целиком: lint, test, build, release плюс установка инструментов.
BIN_DIR := $(CURDIR)/bin
VERSION := $(shell git describe --tags --always --dirty)
# Версии инструментов. Меняются здесь и попадают в git.
GOLANGCI_LINT_VERSION := v2.12.2
MOCKGEN_VERSION := v0.6.0
GOLANGCI_LINT := $(BIN_DIR)/golangci-lint
MOCKGEN := $(BIN_DIR)/mockgen
$(GOLANGCI_LINT):
GOBIN=$(BIN_DIR) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
$(MOCKGEN):
GOBIN=$(BIN_DIR) go install go.uber.org/mock/mockgen@$(MOCKGEN_VERSION)
# Поставить все инструменты разом. Удобно для CI.
.PHONY: tools
tools: $(GOLANGCI_LINT) $(MOCKGEN)
.PHONY: lint
lint: $(GOLANGCI_LINT)
$(GOLANGCI_LINT) run ./...
.PHONY: test
test:
go test -race -count=1 ./...
# Генерация моков перед тестами, если в проекте они есть.
.PHONY: generate
generate: $(MOCKGEN)
PATH=$(BIN_DIR):$$PATH go generate ./...
.PHONY: build
build:
go build -o $(BIN_DIR)/app ./cmd/app
# Релизная сборка: статический бинарник с зашитой версией.
.PHONY: release
release:
CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X main.version=$(VERSION)" \
-o $(BIN_DIR)/app ./cmd/app
.PHONY: clean-tools
clean-tools:
rm -rf $(BIN_DIR)
Пара пояснений по целям:
testгоняет тесты с детектором гонок и-count=1, чтобы отключить кеш результатов. Подробнее о том, как я организую тесты, писал в статье «Тестирование в Go».generateдобавляетbin/в началоPATH. Директивы//go:generate mockgen ...в коде находят локальный mockgen, а не глобальный.releaseсобирает статический бинарник без отладочной информации и зашивает в него версию из git-тега через-ldflags -X.
Альтернатива: go tool из Go 1.24
В Go 1.24 появился встроенный механизм для той же задачи. Команда go get -tool github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest добавляет в go.mod директиву tool, а запускается инструмент через go tool golangci-lint run. Версия фиксируется в go.mod, бинарник живёт в кеше сборки. В проект вообще ничего не попадает.
Механизм хороший, и для маленьких утилит вроде stringer я его использую. Однако, зависимости инструмента попадают в модульный граф вашего проекта и в go.sum. Тяжёлый линтер притащит десятки чужих зависимостей. Обновление инструмента может неожиданно повлиять на версии ваших собственных зависимостей. Подход с bin/ от этого свободен: go install собирает инструмент в изоляции, по его собственному go.mod.
И главное, Makefile всё равно остаётся точкой входа. make lint работает одинаково, что внутри вызывается bin/golangci-lint, что go tool golangci-lint. Команды для человека и CI не меняются при смене механизма под капотом.
Итог
Makefile с локальными инструментами даёт воспроизводимость почти бесплатно. Версии закреплены в git, бинарники живут в bin/ и не засоряют ни репозиторий, ни глобальное окружение. Новый разработчик запускает make test и получает тот же результат, что и другие участники команды.
Этот подход у меня не ограничивается Go. Блог, который вы сейчас читаете, тоже собирается и деплоится через make. Как устроена его автоматическая проверка орфографии и пунктуации, рассказывал в статье «Как я автоматизировал проверку правописания в блоге».
Теги: