На мой взгляд, написание библиотек на 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)


  1. spinmozg
    03.04.2017 08:25
    +2

    Скорее, статья довольно очевидная))
    Описанный подход используется и без привязки конкретно к Go


    1. kilgur
      03.04.2017 08:31

      Про подход — наверное, да… я, кроме go, хорошо знаком только с python, и в нем есть такая общепринятая практика `if __name__ == '__main__"`… особенность go в том, что в нем недостаточно превратить функцию main в «однострочник» — все содержимое пакета main недоступно для импорта


  1. helgihabr
    03.04.2017 08:25

    >> Тем не менее, вы не сможете протестировать функцию, которая вызывает os.Exit
    Есть несколько рецептов по тестированию os.Exit, например, с использованием exec.Command


    1. kilgur
      03.04.2017 08:44

      Если честно, не понял… с exec.Command вы будете вызывать бинарник целиком; при этом вам надо как-то заставить его выполнить функцию, которая может вызвать os.Exit() и проверить код возврата. Здесь речь идет о

      go test
      и тестировании отдельных функций — запуском бинарника вы в этом случае не управляете. Да и это уже поведенческое тестирование, а не модульное…
      Один из рецептов и приведен в статье. Можно ссылку, где почитать о других рецептах?


      1. helgihabr
        12.04.2017 09:34
        -1

        Странно, попала в спам нотификация от хабра, не увидел вашего ответа.
        Вот пара нагугленых:
        Testing an os.exit scenario in Golang
        How to test os.exit scenarios in Go


        1. kilgur
          12.04.2017 12:38

          Ну, и костылище… :-)
          Да, насчет

          запуском бинарника вы в этом случае не управляете

          я погорячился. Можно, конечно, и так тестировать.
          ИМХО, у Финча вариант изящнее, а вариант Герранда более трудоемкий.
          Спасибо за ссылки, первую положил в закладки — вдруг пригодится…


  1. eddifisher
    03.04.2017 08:44
    -2

    такое даже переводить не стоит


    1. kilgur
      03.04.2017 08:46
      -1

      <sarcasm>
      и вам спасибо за содержательную критику
      </sarcasm>


    1. berezuev
      03.04.2017 08:52

      Пришлите то, что стоит перевести. Все будут вам благодарны


  1. SamKrew
    03.04.2017 20:41
    +1

    Если комму-то интересна эта тема, могу попробовать родить статью о том как это сделано у нас (около 15 проектов с общим кодом).


  1. Nakosika
    03.04.2017 21:22
    +2

    Сколько ни читаю статей про то как легко реюзать код го, никак не понимаю. Как можно реюзать код на языке который не поддерживает дженерики? Из механизмов реюза есть только функции. Типы реюзать не светит. Вот написал я лру кэш для картинок, захотел хранить в нем что-то другое, либо вся типобезопасность лесом, либо одно из двух...


    1. daregod
      03.04.2017 22:56

      Интерфейсы же. Делайте сразу кэш не только картинок, а чего-то, удовлетворяющего интерфейсу.


      1. Nakosika
        04.04.2017 00:57
        +1

        Интерфейсы не типобезопасны.


        1. daregod
          04.04.2017 01:05

          Обоснуйте. Если что — я не пустой интерфейс interface{} на вход предлагаю, а удовлетворяющую некоему интерфейсу структуру.


          1. Nakosika
            04.04.2017 11:04
            +1

            Что обосновать? Сортировку можно извернуться написать не имея ссылок на данные, но когда у тебя смысл в том что ты пишешь какой-то контейнер с данными или устройство ввода вывода то так не извернуться, нужны реальные ссылки на данные, а дженериков нет.


            1. daregod
              04.04.2017 13:14

              Обосновать своё утверждение про типонебезопасность интерфейсов.
              Если что-то не получается абстрагировать, спрятав за интерфейс, то, возможно, не надо впрягать в одну телегу коня и трепетную лань?