При запуске нового проекта самые большие трудности у меня всегда вызывала его настройка. Всегда стараешься сделать её «идеальной»:
- используешь лучшую структуру каталогов, чтобы всё было легко найти и импортирование происходило без проблем;
- настраиваешь все команды так, чтобы нужные действия выполнялись в один клик или с вводом одной команды;
- находишь лучший инструмент контроля качества кода, средство форматирования, среду тестирования для используемого в проекте языка и библиотеки…
Этот список можно продолжать и продолжать, и всё равно до идеальной настройки будет ещё далеко… Но, по моему скромному мнению, эта настройка для Golang просто лучшая!
Она так хорошо себя проявляет отчасти и потому, что основана на существующих проектах, которые вы можете найти здесь и тут.
Краткое изложение доступно в моём репозитории — https://github.com/MartinHeinz/go-project-blueprint
Структура каталогов
Первым делом обратимся к структуре каталогов нашего проекта. Здесь у нас несколько файлов верхнего уровня и четыре каталога:
pkg
— это пакет Go, который содержит только строку версии global. Меняется на версию из хэша текущей фиксации при проведении сборки;config
— конфигурационный каталог, который содержит файлы со всеми необходимыми переменными среды. Вы можете использовать любой тип файла, но я бы рекомендовал файлы YAML: их проще читать;build
— в этой директории у нас все скрипты оболочки, необходимые для сборки и тестирования приложения, а также создания отчётов для инструментов анализа кода;cmd
— фактический исходный код. По правилам именования исходный каталог называетсяcmd
. Внутри есть ещё один каталог с именем проекта (в нашем случаеblueprint
). В свою очередь, внутри этого каталога находитсяmain.go
, запускающий всё приложение. Также здесь можно найти все остальные исходные файлы, разделённые на модули (подробнее об этом далее).
Оказывается, многие предпочитают помещать исходный код в каталоги internal
и pkg
. Я думаю, что это лишнее: достаточно использовать для этого cmd
, где для всего есть своё место.
Помимо каталогов, есть ещё большое количество файлов, о которых мы поговорим в статье.
Модули Go для идеального управления зависимостями
В проектах Go используются самые разные стратегии управления зависимостями. Однако с версии 1.11 Go обзавёлся официальным решением. Все наши зависимости приводятся в файле go.mod
, в корневом каталоге. Вот как он может выглядеть:
module github.com/MartinHeinz/go-project-blueprint
go 1.12
require (
github.com/spf13/viper v1.4.0
github.com/stretchr/testify v1.4.0
)
Вы можете спросить: «А как в этот файл включить зависимости?». Очень просто, всего одной командой:
go mod vendor
Эта команда переустанавливает vendor
каталог основного модуля для включения всех пакетов, необходимых для сборки и тестирования каждого пакета модуля исходя из состояния файлов go.mod
и исходного кода Go.
Фактический исходный код и конфигурация
И вот наконец мы добрались до исходного кода. Как уже говорилось, исходный код разделён на модули. Модуль представляет собой каталог внутри исходного корневого каталога. В каждом модуле находятся исходные файлы вместе с соответствующими файлами тестов. Например:
./cmd/
└── blueprint
├── apis <- Module
│ ├── apis_test.go
│ ├── user.go
│ └── user_test.go
├── daos <- Module
│ ├── user.go
│ └── user_test.go
├── services <- Module
│ ├── user.go
│ └── user_test.go
├── config <- Module
│ └── config.go
└── main.go
Такая структура способствует лучшей читаемости и лёгкости сопровождения кода: он идеально разделён на части, которые проще просматривать. Что касается конфигурации, в этой настройке используем библиотеку конфигураций Go Viper, которая может иметь дело с разными форматами, параметрами командной строки, переменными среды и т.д.
Посмотрим, как мы используем этот Viper здесь. Вот пакет config
:
var Config appConfig
type appConfig struct {
// Пример переменной, загружаемой в функции LoadConfig
ConfigVar string
}
// LoadConfig загружает конфигурацию из файлов
func LoadConfig(configPaths ...string) error {
v := viper.New()
v.SetConfigName("example") // <- имя конфигурационного файла
v.SetConfigType("yaml")
v.SetEnvPrefix("blueprint")
v.AutomaticEnv()
for _, path := range configPaths {
v.AddConfigPath(path) // <- // путь для поиска конфигурационного файла в
}
if err := v.ReadInConfig(); err != nil {
return fmt.Errorf("failed to read the configuration file: %s", err)
}
return v.Unmarshal(&Config)
}
Он состоит из единственного файла. Объявляет один struct
, который содержит все переменные конфигурации и имеет одну функцию LoadConfig
, которая загружает конфигурацию. Требуется путь до конфигурационных файлов, в нашем случае используем путь до каталога config
, который находится в корневом каталоге проекта и содержит наши YAML файлы. И как их будем использовать? Запустим первым делом в main.go
:
if err := config.LoadConfig("./config"); err != nil {
panic(fmt.Errorf("invalid application configuration: %s", err))
}
Простое и быстрое тестирование
Что важнее всего после кода? Тесты. Чтобы писать много хороших тестов, нужна настройка, с которой это будет делать легко. Для этого мы используем цель Makefile
под названием test
, которая собирает и выполняет все тесты в подкаталогах cmd
(все файлы с расширением _test.go
). Эти тесты кэшируются, так что их запуск происходит только при наличии изменений в соответствующей части кода. Это очень важно: если тесты будут слишком медленными, рано или поздно вы перестанете их запускать и сопровождать. Помимо модульного тестирования, make test
помогает следить за общим качеством кода, запуская с каждым тестовым прогоном gofmt
и go vet
. gofmt
способствует правильному форматированию кода, а go vet
помогает с помощью эвристических алгоритмов выявлять в коде любые подозрительные конструкции. Вот пример того, что может получиться в результате выполнения:
foo@bar:~$ make test
Running tests:
ok github.com/MartinHeinz/go-project-blueprint/cmd/blueprint (cached)
? github.com/MartinHeinz/go-project-blueprint/cmd/blueprint/config [no test files]
? github.com/MartinHeinz/go-project-blueprint/pkg [no test files]
Checking gofmt: FAIL - the following files need to be gofmt'ed:
cmd/blueprint/main.go
Checking go vet: FAIL
# github.com/MartinHeinz/go-project-blueprint/cmd/blueprint
cmd/blueprint/main.go:19:7: assignment copies lock value to l: sync.Mutex
Makefile:157: recipe for target 'test' failed
make: *** [test] Error 1
Запуск всегда в Docker
Многие говорят, что у них запуск невозможен в облаке, а только на компьютере. Здесь есть простое решение: всегда запускаться в контейнере docker
. Делаете ли вы сборку, запускаете ли или тестируете — делайте всё это в контейнере. Кстати, что касается тестирования, make test
выполняется тоже только в docker
.
Посмотрим, как это происходит. Начнём с файлов Dockerfile
из корневого каталога проекта: один из них для тестирования (test.Dockerfile
), а другой — для запуска приложения (in.Dockerfile
):
test.Dockerfile
— в идеале нам было бы достаточно одного файлаDockerfile
для запуска приложения и тестирования. Но во время тестовых прогонов нам может потребоваться внести небольшие изменения в среде выполнения, поэтому у нас здесь есть образ для установки дополнительных инструментов и библиотек. Предположим, например, что мы подключаемся к базе данных. Нам не нужно поднимать весь PostgreSQL-сервер при каждом тестовом прогоне или зависеть от какой-нибудь базы данных на хост-машине. Мы просто используем для тестовых прогонов базу данных в памяти SQLite. И если дополнительные установки не понадобятся нашим тестам, то двоичным данным в SQLite они будут очень даже кстати: устанавливаемgcc
иg++
, переключаем флажок наCGO_ENABLED
, и готово.in.Dockerfile
— если посмотреть на этотDockerfile
в репозитории, что мы увидим: просто несколько аргументов и копирование конфигурации в образ. Но что здесь происходит?in.Dockerfile
используется только изMakefile
(заполненного аргументами), когда мы запускаемmake container
. Давайте теперь обратимся в самMakefile
, Всё, что связано сdocker
, делает для нас именно он. ?
Связываем всё вместе с помощью Makefile
Долгое время Make-файлы казались мне страшными (до этого я сталкивался с ними лишь при работе с кодом C
), но на самом деле ничего страшного здесь нет, и их много где можно использовать, в том числе для этого проекта! Посмотрим, какие цели у нас здесь есть в Makefile
:
make test
— первая в рабочем потоке — собранное приложение — создаёт исполняемый двоичный код в каталогеbin
:
@echo "making $(OUTBIN)"
@docker run \ # <- Это `докерный запуск`
-i \ # скрытая команда
--rm \ # <- Удаляем контейнер по завершении
-u $$(id -u):$$(id -g) \ # <- Используем текущего пользователя
-v $$(pwd):/src \ # <- Подключаем исходную папку
-w /src \ # <- Устанавливаем рабочий каталог
-v $$(pwd)/.go/bin/$(OS)_$(ARCH):/go/bin \ # <- Подключаем каталоги
-v $$(pwd)/.go/bin/$(OS)_$(ARCH):/go/bin/$(OS)_$(ARCH) \ # с выводом двоичных данных
-v $$(pwd)/.go/cache:/.cache \
--env HTTP_PROXY=$(HTTP_PROXY) \
--env HTTPS_PROXY=$(HTTPS_PROXY) \
$(BUILD_IMAGE) \
/bin/sh -c " \ # <- Запускаем скрипт сборки
ARCH=$(ARCH) \ # (Проверяет на наличие
OS=$(OS) \ # аргументов, устанавливает
VERSION=$(VERSION) \ # переменные среды и запускает
./build/build.sh \ # `go install`)
"
@if ! cmp -s .go/$(OUTBIN) $(OUTBIN); then \ # <- Если двоичные данные изменились
mv .go/$(OUTBIN) $(OUTBIN); \ # перемещаем их из `.go` в `bin`
date >$@; \
fi
make test
— тестовая — она снова использует почти тот жеdocker run
, только здесь ещё есть скриптtest.sh
(покажем только то, что нас интересует):
TARGETS=$(for d in "$@"; do echo ./$d/...; done)
go test -installsuffix "static" ${TARGETS} 2>&1
ERRS=$(find "$@" -type f -name \*.go | xargs gofmt -l 2>&1 || true)
ERRS=$(go vet ${TARGETS} 2>&1 || true)
Эти строчки — важная часть файла. Первая из них собирает тестовые цели, где в качестве параметра указан путь. Вторая строчка запускает тесты и выводит документацию по тестированию ПО. Оставшиеся две строчки запускают gofmt
и go vet
. Они собирают и выводят ошибки (если таковые имеются).
make container
— и, наконец, важнейшая часть — создание развёртываемого контейнера:
.container-$(DOTFILE_IMAGE): bin/$(OS)_$(ARCH)/$(BIN) in.Dockerfile
@sed \
-e 's|{ARG_BIN}|$(BIN)|g' \
-e 's|{ARG_ARCH}|$(ARCH)|g' \
-e 's|{ARG_OS}|$(OS)|g' \
-e 's|{ARG_FROM}|$(BASEIMAGE)|g' \
in.Dockerfile > .dockerfile-$(OS)_$(ARCH)
@docker build -t $(IMAGE):$(TAG) -t $(IMAGE):latest -f .dockerfile-$(OS)_$(ARCH) .
@docker images -q $(IMAGE):$(TAG) > $@
Код для этой цели довольно прост: сначала он подставляет переменные в in.Dockerfile
, а затем запускает docker build
для получения образа с «изменёнными» и «последними» тегами. И дальше передаёт имя контейнера на стандартный вывод.
- Теперь, когда у нас есть образ, нужно где-то его хранить. С этим нам помогает
make push
, который помещает образ в хранилище образов Docker registry. make ci
— ещё один способ использоватьMakefile
— задействовать его в процессах непрерывной интеграции и развёртывания приложений (об этом речь пойдёт далее). Эта цель очень похожа наmake test
: тоже запускает все тесты и плюс к этому генерирует отчёты о покрытии, которые потом используются как вводная информация при проведении анализа кода.make clean
— и, наконец, если нам нужно провести очистку проекта, запускаемmake clean
, который удалит все файлы, сгенерированные предыдущими целями.
Остальные цели можно объединить в две группы: первые не так важны для нормального рабочего процесса, а вторые являются лишь частью других целей, поэтому о них можно не упоминать.
Интеграция и развёртывание ПО для идеальной разработки
Завершаем статью важной частью — процессом непрерывной интеграции и развёртывания приложений. Не буду подробно расписывать, что в нем такого — вы и сами прекрасно сможете разобраться (практически в каждой строке есть комментарий, так что всё должно быть понятно):
# Matrix build запускает 4 параллельные сборки
matrix:
include:
- language: go # Сборка и тестирование sudo: required
services:
- docker
script:
- export GO111MODULE=on
- go mod vendor # Загружаем зависимости - make build # Собираем приложение
- test -f bin/linux_amd64/blueprint # Тест на наличие двоичных данных, полученных на предыдущем этапе
- make all-container # Создаём все докерные контейнеры - docker images | grep "^docker.pkg.github.com/martinheinz/go-project-blueprint/blueprint.*__linux_amd64" # Проверяем наличие созданных образов
- make test # Запускает тесты внутри тестового образа
- language: go # SonarCloud
addons:
sonarcloud:
organization: martinheinz-github token:
secure:
"tYsUxue9kLZWb+Y8kwU28j2sa0pq20z2ZvZrbKCN7Sw0WGtODQLaK9tZ94u1Sy02qL5QcabukbENmbvfouzXf4EfaKjDmYH9+Ja22X26MfTLVpaCDTQEGmNyREOFCHpjNXPgDMv1C70By5U+aPWSYF/lehB5rFijwCf7rmTFRNUDeotCTCuWb2dIkrX2i6raVu34SvqqGxKQmmH+NPLe7uKO/wXqH+cWQH1P9oJYeVksNGruw4M0MznUeQHeJQYpTLooxhEEzYiBbkerWGDMwBdZdPQwVrO2b8FEDRw/GWTFoL+FkdVMl4n4lrbO/cQLbPMTGcfupNCuVHh1n8cGp8spMkrfQGtKqvDRuz2tBs0n1PWXCRS6pgZQw/ClLPgi/vVryVRwOabIHSQQLRVhcdp8pkYdyX3aH1EdlIHiJLT6sacS0vJPqZMF/HNsPEoHe4YdiYvx/tcYMU63KQVZzgF4HfQMWy69s1d0RZUqd+wrtHU1DHwnkq1TSe+8nMlbvbmMsm6FVqGistrnVjx4C9TjDWQcjprYU40zCvc1uvoSPimVcaD8ITalCDHlEfoV7wZuisV8+gJzOh9pDZ/joohW7/P3zklGgI2sH7qt62GE4o5UyRArzJC7eIj7Oxx6GdbeEqw09M4rCfR1g5tHWIqVHz5CajvkXkPqrRGu2oI="
before_script:
- ./reports.sh # Создаёт каталоги и файлы для отчётов - export GO111MODULE=on
- go mod vendor # Загружаем зависимости - make ci # Запускаем тесты и генерируем отчёты (см. этап `ci` в Makefile)
script:
- sonar-scanner # Запускаем анализ с помощью плагина SonarCloud scanner plugin
- language: go # CodeClimate before_script:
- ./reports.sh # Создаём каталоги и файлы для отчётов - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter # Загружаем генератор тестовых отчётов CodeClimate
- chmod +x ./cc-test-reporter # Делаем его исполняемым - ./cc-test-reporter before-build # Уведомляем CodeClimate о готовом отчёте
script:
- export GO111MODULE=on
- go mod vendor # Загружаем зависимости - make ci # Запускаем тесты и генерируем отчёты (см. этап `ci` в Makefile)
after_script:
- ./cc-test-reporter after-build -t gocov --exit-code $TRAVIS_TEST_RESULT # Отправляем отчёт в CodeClimate или уведомляем о неудавшейся сборке кодом завершения
- language: go # Помещаем контейнер в хранилище services:
- docker
if: branch = master
script:
- export GO111MODULE=on
- go mod vendor # Загружаем зависимости - echo "$DOCKER_PASSWORD" | docker login docker.pkg.github.com -u "$DOCKER_USERNAME" --password-stdin # Подключаемся ко хранилищу GitHub Registry, используя переменные среды Travis
- make container # Создаём изменённые и последние образы - make push # Помещаем образ в хранилище
notifications:
email: false
Но кое-что можно прояснить.
В этой сборке Travis использована сборка Matrix Build с 4 параллельными заданиями для ускорения всего процесса:
- Сборка и тестирование: здесь мы проверяем, что приложение работает как надо;
- SonarCloud: здесь мы генерируем отчёты о покрытии и отправляем их на сервер SonarCloud;
- CodeClimate: здесь — как и в предыдущем задании — мы генерируем отчёты о покрытии и отправляем их, только на этот раз в CodeClimate с помощью их генератора тестовых отчётов;
- Push to Registry: и, наконец, помещаем наш контейнер в хранилище GitHub Registry.
Заключение
Надеюсь, эта статья поможет вам в ваших будущих разработках кода на Go. Все подробности изложены в репозитории.
В следующей части узнаем, как на базе этого макета проекта, который мы сегодня выстроили, с лёгкостью создавать интерфейсы RESTful API, тестировать с базой данных в памяти, а также настраивать крутую документацию (а пока можно подсмотреть в ветке rest-api
репозитория). ?
Перевод статьи Martin Heinz: Ultimate Setup for Your Next Golang Project (впервые опубликована на martinheinz.dev).