На мой взгляд, написание библиотек на Go — довольно хорошо освещенная тема… а вот о написании приложений (команд) статей гораздо меньше. Когда дело до этого доходит, весь код на Go представляет собой команду. Так давайте об этом и поговорим! Этот пост будет первым в серии, т.к. у меня много информации, которой я еще не делился.
В сегодняшней статье речь пойдет о базовой компоновке проекта, будем улучшать повторное использование и тестирования кода.
Когда я разрабатываю не библиотеку, а программу, у меня существуют три уникальных правила организации кода:
Пакет main
Это единственный пакет в программе на Go, который обязательно должен быть. Помимо указания инструменту go
создавать исполняемый файл, у этого пакета есть еще одна уникальная штука — из него невозможно импортировать код. Это значит, что любой код, который вы помещаете в пакет main
, не может быть использован напрямую другим проектом, и это расстраивает богов открытого ПО. Поскольку одна из главных причин, по которым я пишу проекты с открытым исходным кодом, заключается в том, что другие разработчики могут его использовать, эта невозможноть переиспользовать код из main
противоречит моим желаниям.
Много раз возникали ситуации, когда я думал: "Я бы использовал в своем коде логику программы X". Но если логика находилась в пакете main
, это было невозможно.
os.Exit
Если вы заботитесь о создании программы, которая делает именно то, чего ожидает пользователь, то вам следует позаботится и о том, с каким кодом выхода она завершается. Единственный способ сделать это — вызвать os.Exit
(или вызвать что-то, что вызовет os.Exit
, например log.Fatal
).
Тем не менее, вы не сможете протестировать функцию, которая вызывает os.Exit
. Почему? Потому что вызов os.Exit
во время выполнения теста приведет к завершению тестируемого приложения. Это довольно трудно обнаружить, если у вас это получилось случайно (это я знаю из личного опыта). Когда вы запускаете тестирование, никакого тестирования в действительности не происходит, эти тесты просто завершаются раньше, чем должны были, а вам остается только чесать в затылке.
Самое простое, что можно сделать — это не вызывать os.Exit
. Большая часть вашего кода в любом случае не должна вызывать os.Exit
… у кого-нибудь может реально "поехать крыша", если он импортирует вашу библиотеку, а она случайным образом, при некоторых условиях, будет останавливать его приложение.
Таким образом, вызывайте os.Exit
ровно в одном месте, как можно ближе к "наружности" вашего приложения, с минимальным количеством точек входа. Кстати, поговорим и о них…
func main()
Это единственная функция, которая должна быть у любой программы, написанной на Go. Вы, наверное, думаете, что каждая функция main
должна отличаться от программы к программе, поскольку все программы разные, так ведь? Что ж, оказывается, если вы действительно хотите сделать ваш код тестируемым и переиспользуемым, существует, по большому счету, только один правильный ответ на вопрос "что находится в вашей функции main?"
Забегая немного вперед, я думаю, что также есть только один правильный ответ на вопрос "что находится в вашем пакете main?" и этот ответ выглядит так:
// command main documentation here.
package main
import (
"os"
"github.com/you/proj/cli"
)
func main() {
os.Exit(cli.Run())
}
Вот и все. Это самый минимальный код, который должен быть в вашем полезном пакете main
. Мы почти не потратили никаких усилий на код, который другие не могут повторно использовать. При этом, мы изолировали os.Exit
в однострочной функции, которая является самой внешней частью нашего проекта и, фактически, не нуждается в тестировании.
Схема проекта
Давайте взглянем на общую схему проекта:
/home/you/src/github.com/you/proj $ tree
.
+-- cli
¦ +-- parse.go
¦ +-- parse*test.go
¦ L-- run.go
+-- LICENSE
+-- main.go
+-- README.md
L-- run
+-- command.go
L-- command*test.go
Мы уже знаем, что находится у нас в main.go
… и, фактически, main.go
является единственным файлом go в пакете main
. Файлы LICENSE
и README.md
не требуют пояснений. (Всегда указывайте лицензию! В противном случае многие люди не смогут использовать ваш код.)
Теперь мы переходим к двум подкаталогам — run
и cli
.
CLI
Пакет cli
содержит логику синтаксического анализа командной строки. Здесь вы определяете интерфейс (UI) вашей программы. Пакет содержит анализ флагов, анализ аргументов, тексты справки и так далее.
Он также содержит исходный код, который возвращает код выхода для функции main
(которая передает его в os.Exit
). Таким образом, вы можете тестировать коды выхода, возвращаемые этими функциями, вместо того, чтобы пытаться тестировать коды выхода вашей программы в целом.
Run
Если пакеты main
и cli
— это "кости" логики вашей программы, то пакет run
содержит ее "мясо". Вам следует писать этот пакет так, как если бы это была отдельная библиотека. Во время его разработки вы не должны думать о CLI, флагах и тому подобном. Пакет должен получать структурированные данные и возвращать ошибки. Представьте, что он может быть вызван другой библиотекой, или вебсервисом, или еще чьей-то программой. Делайте как можно меньше предположений о том, как его будут применять… в общем, это должна быть обычная библиотека.
Очевидно, что для больших проектов потребуется больше одного каталога. Фактически, вы можете разделить вашу логику на отдельные репозитории. Это зависит от того, насколько вероятным вы считаете, что другие люди захотят переиспользовать вашу логику. Если вы считаете это весьма вероятным, я рекомендую реализовать логику в отдельном каталоге (репозитории). На мой взгляд, отдельный каталог для логики обязывает к большему качеству и стабильности, чем некоторый случайный каталог, запрятанный где-то глубоко в репозитории.
Собираем все вместе
Пакет cli
формирует интерфейс командной строки для логики, реализованной в пакете run
. Если кто-нибудь увидит вашу программу и захочет использовать ее логику для веб API, ему нужно будет просто импортировать пакет run
и использовать эту логику напрямую. Аналогично, если кому-то не нравятся ваши параметры командной строки, он может просто написать свой собственный парсер аргументов и использовать его как интерфейс к пакету run
.
Это я и имею в виду, говоря о повторном использовании кода. Я не хочу, чтобы кому-нибудь пришлось "хакать" куски моего кода, чтобы побольше его использовать. И лучший способ облегчить переиспользование кода — отделить интерфейс от логики. Это ключевая часть. Не позволяйте идеям из ваших интерфейсов (UI/CLI) просачиваться в логику. Это лучший способ сохранить логику в общем виде, а интерфейс — управляемым.
Более крупные проекты
Этот схема хороша для малых и средних проектов. Здесь есть единственная программа, которая находится в корне репозитория, поэтому ее легче получить (go-get'нуть), чем если бы она была в нескольких подкаталогах. В больших проектах все может быть совсем по другому. Там может быть несколько исполняемых файлов, а они не могут все вместе лежать в корне репозитория. Однако такие проекты обычно имеют настраиваемые этапы сборки и требуют больше, чем просто go-get (об этом я расскажу позже).
Подробности скоро будут.
18 Октября 2016 г.
Серия: Разработка приложений на Go.
Прим.пер.: серии как таковой не случилось, это единственная статья в "серии" с момента публикации. Тем не менее, статья довольно интересная.
Комментарии (16)
helgihabr
03.04.2017 08:25>> Тем не менее, вы не сможете протестировать функцию, которая вызывает os.Exit
Есть несколько рецептов по тестированию os.Exit, например, с использованием exec.Commandkilgur
03.04.2017 08:44Если честно, не понял… с exec.Command вы будете вызывать бинарник целиком; при этом вам надо как-то заставить его выполнить функцию, которая может вызвать os.Exit() и проверить код возврата. Здесь речь идет о
go test
и тестировании отдельных функций — запуском бинарника вы в этом случае не управляете. Да и это уже поведенческое тестирование, а не модульное…
Один из рецептов и приведен в статье. Можно ссылку, где почитать о других рецептах?helgihabr
12.04.2017 09:34-1Странно, попала в спам нотификация от хабра, не увидел вашего ответа.
Вот пара нагугленых:
Testing an os.exit scenario in Golang
How to test os.exit scenarios in Gokilgur
12.04.2017 12:38Ну, и костылище… :-)
Да, насчет
запуском бинарника вы в этом случае не управляете
я погорячился. Можно, конечно, и так тестировать.
ИМХО, у Финча вариант изящнее, а вариант Герранда более трудоемкий.
Спасибо за ссылки, первую положил в закладки — вдруг пригодится…
SamKrew
03.04.2017 20:41+1Если комму-то интересна эта тема, могу попробовать родить статью о том как это сделано у нас (около 15 проектов с общим кодом).
Nakosika
03.04.2017 21:22+2Сколько ни читаю статей про то как легко реюзать код го, никак не понимаю. Как можно реюзать код на языке который не поддерживает дженерики? Из механизмов реюза есть только функции. Типы реюзать не светит. Вот написал я лру кэш для картинок, захотел хранить в нем что-то другое, либо вся типобезопасность лесом, либо одно из двух...
daregod
03.04.2017 22:56Интерфейсы же. Делайте сразу кэш не только картинок, а чего-то, удовлетворяющего интерфейсу.
Nakosika
04.04.2017 00:57+1Интерфейсы не типобезопасны.
daregod
04.04.2017 01:05Обоснуйте. Если что — я не пустой интерфейс interface{} на вход предлагаю, а удовлетворяющую некоему интерфейсу структуру.
Nakosika
04.04.2017 11:04+1Что обосновать? Сортировку можно извернуться написать не имея ссылок на данные, но когда у тебя смысл в том что ты пишешь какой-то контейнер с данными или устройство ввода вывода то так не извернуться, нужны реальные ссылки на данные, а дженериков нет.
daregod
04.04.2017 13:14Обосновать своё утверждение про типонебезопасность интерфейсов.
Если что-то не получается абстрагировать, спрятав за интерфейс, то, возможно, не надо впрягать в одну телегу коня и трепетную лань?
spinmozg
Скорее, статья довольно очевидная))
Описанный подход используется и без привязки конкретно к Go
kilgur
Про подход — наверное, да… я, кроме go, хорошо знаком только с python, и в нем есть такая общепринятая практика `if __name__ == '__main__"`… особенность go в том, что в нем недостаточно превратить функцию main в «однострочник» — все содержимое пакета main недоступно для импорта