Это перевод статьи Нейта Финча (Nate Finch) — оригинал (опубликовано 24 марта 2017)
31 января 2017 года было моим последним днем в Canonical после 3,5 лет работы над одним из крупнейших проектов с открытым исходным кодом, написанных на Go — Juju.
На момент написания статьи основной репозиторий Juju — это 3 542 файла, 540 000 строк кода Go (в это число не входит 65 000 строк комментариев). С учетом всех зависимостей, кроме стандартной библиотеки, Juju содержит 9 523 файла, в которых содержится 1 963 000 строк кода Go (без учета 331 000 строк комментариев).
Вот несколько уроков, извлеченных из примерно 7 000 часов работы над этим проектом.
Стоит отметить, что не все в команде Juju согласны со мной, а кодовая база настолько велика, что можно работать с ней в течение года и не увидеть и 2/3 кода. Так что отнеситесь к нижеизложенному с некоторой долей скептицизма.
Juju
Juju — это инструмент оркестрации сервисов, сродни Nomad, Kubernetes и им подобных. Juju состоит (в основном) из двух бинарных файлов: клиента и сервера. Сервер может работать в нескольких различных режимах (раньше сервер был в виде нескольких разных файлов, но поскольку они были на 99% одинаковыми, проще было сделать один, который легче распространять). Сервер может работать в облаке по вашему выбору. Можно запустить несколько дополнительных экземпляров на отдельных машинах и они будут управляться центральным сервером. Клиент и вспомогательные машины общаются с главным сервером посредством RPC поверх вебсокетов.
Juju представляет собой монолит. Никаких микросервисов, для работы нужен один бинарный файл. И это действительно хорошо работает, потому что Go хорош в конкурентности — не надо беспокоиться о том, что какая-нибудь горутина что-нибудь заблокирует. Поэтому удобно использовать все в одном процессе. Вы избегаете оверхэда от сериализации и других межпроцессных взаимодействий. Это делает код более взаимозависимым, но разделение ответственности не всегда имеет наивысший приоритет при разработке. В итоге, мне кажется, что монолит было гораздо проще разрабатывать и тестировать, чем если бы это была куча мелких сервисов, а правильное разбиение кода и инкапсуляция помогли избежать запутанного кода.
Управление пакетами
Juju не использует вендоринг. На мой взгляд, стоило бы, но проект был запущен до того, как появились нормальные инструменты, а переход на использование вендоринга никогда не казался стоящим потраченного на него времени. Сейчас мы используем godeps Роджера Пеппа (Roger Peppe) (между прочим, это не godep
), чтобы фиксировать ревизии. Правда с ним есть проблема — он вносит бардак в другие пакеты в вашем GOPATH, устанавливая их в соответствие с указанным коммитом, поэтому если вы когда-нибудь будете собирать другой проект, который не использует вендоринг, есть вероятность, что зависимости будут собраны не из master-ветки. Тем не менее, фиксация ревизий дала нам повторяемые сборки (до тех пор, пока никто не сделал ничего по-настоящему ужасного во внешних репозиториях), и особых проблем не было, за исключением того, что файл, содержащий хэши коммитов, постоянно был точкой конфликтов слияния. Поскольку он часто менялся, да еще и большим количеством разработчиков, рано или поздно должна была произойти ситуация, когда два человека меняли одну и ту же или смежные строки в этом файле (прим.пер.: утилита godeps
использует для обновления исходников файл, в котором указаны пакеты и хэш коммита, который необходимо установить). Это стало настоящей проблемой, так что я начал писать утилиту для автоматического разрешения этих конфликтов (поскольку godeps
хранит дату зафиксированного коммита, почти всегда можно просто выбрать более свежий коммит). Проблема остается и при использовании glide
, а также любого аналогичного инструмента, который хранит хэши зависимостей в одном единственном файле. Не уверен, что знаю, как это исправить.
В общем и целом, у меня никогда не было ощущения, что управление пакетами — это огромная проблема. Незначительная вещь в нашей повседневной работе… поэтому мне всегда было странно читать истории о том, что люди не воспринимают Go всерьез из-за отсутствия менеджера пакетов. Большинство сторонних репозиториев поддерживают стабильные API, а мы могли привязать наш код к конкретному коммиту… вобщем, это не было проблемой.
Организация проекта
Juju — это на 80% монолитный репозиторий (monorepo), расположенный на <github.com/juju/juju>, а остальные 20% кода существуют в отдельных репозиториях (на <github.com/juju>). В выделении монорепозитория есть свои плюсы и минусы… Легко делать масштабные изменения по всему коду, но это также означает, что скорее всего можно не поддерживать стабильный API в foo/bar/baz/bat/alt/special
… так что мы и не поддерживали. А это выливается в сущее безумие для любого, кто импортирует пакет из этого монорепозитория и будет думать, что пакет будет продолжать существовать примерно в таком же виде в будущем. Вендоринг, конечно, спасет в этом случае, но если вам когда-либо понадобится обновиться, удачи.
Монорепозиторий также означало, что мы менее осторожно относились к API, к разделению ответственности, а код был более взаимозависимым, чем это могло быть. Не сказать, что мы были неосторожны, но мне кажется, что вещи вне основного репозитория Juju были куда более стандартизованы из-за тех же разделения ответственности, качества и стабильности API. Разумеется, документация для внешних репозиториев тоже была лучше, а это уже само по себе много значит.
Проблема с внешними репозиториями заключалась в управлении пакетами и синхронизации изменений между репозиториями. Если вы обновили внешний репозиторий, вам нужно было после этого внести изменения и в главный репозиторий, чтобы начать пользоваться изменениями внешнего. Разумеется, невозможно сделать это атомарно для двух github репозиториев. А иногда возможность внесения изменений в главный заблокирована из-за инспекции кода или провалившимися тестами или еще чем-то, и тогда у вас есть потенциально несовместимые изменения, находящиеся во внешнем репозитории, об которые споткнется любой, кто решит внести свои изменения в этот внешний репозиторий.
Скажу еще одну вещь: утилитные репозитории — это зло. Много раз мы собирались бэкпортировать исправление в некий подпакет нашего репозитория utils
в более раннюю версию Juju, и в очередной раз понимали, что с этим исправлением подтянутся многие-многие другие несвязанные изменения. И все потому что у нас слишком много всего в одном репозитории. Получается, мы должны были делать всякие ужасные ветвления, "костылить", "копипастить", и вообще это все плохо и не делайте так. Просто скажите "нет" пакетам и репозиториям utils
.
Всеобщая простота
Простота Go была определенно главным фактором успеха проекта Juju. Только около трети разработчиков, которых мы наняли, раньше работали с Go. Остальные были новичками. Через неделю большинство новичков уже стали весьма опытными. Размер и сложность продукта были куда большей проблемой для разработчиков, чем сам язык. Были случаи, когда более опытные разработчики Go получали вопросы от команды о том, как лучше сделать X в Go, но это было довольно редко. Сравните это с C# на моей предыдущей работе, где я постоянно объяснял разные части языка или то, почему что-нибудь работает именно так, а не иначе.
Простота была благом для проекта, поскольку мы могли нанять хороших в целом разработчиков, а не только тех, кто имел опыт программирования на Go. То есть этот язык никогда не был препятствием для вникания в новую часть кода. Juju был настолько огромным, что никто не мог знать подробностей всего проекта. Но при этом, почти каждый мог влезть в кусок кода и выяснить, что делают 100 или около того строк, содержащих ошибку, и как они это делают (более или менее). Большинство проблем с изучением нового куска кода были такими же, какими они были бы на любом языке — какова архитектура, как передается информация, каковы ожидания (ориг.: expectations).
Поскольку в Go очень мало магии, мне кажется, что реализовать этот проект на Go было проще, чем если бы это был какой-то другой язык. У вас нет магии, которая есть у других языков. Магии, которая может придать неожиданную функциональность, казалось бы, простым и понятным строкам кода. Изучая старый кусок кода Go вам никогда не придется задаваться вопросом "а как это работает?", потому что это просто кусок кода Go. Разумеется, это еще не значит, что там нет сложного кода, над которым надо "пораскинуть мозгами", скрытых ожиданий и предварительных условий… но по крайней мере это не скрыто за языковыми особенностями, которые затуманивают базовые алгоритмы.
Тестирование
Комплекты тестов
В Juju мы использовали gocheck
Густаво Ниеймера (Gustavo Nieyemer) для запуска наших тестов. Благодаря особенностям gocheck
можно проводить полное тестирование (full stack testing), разворачивая окружение Juju сервера и базы данных mongo в автоматическом режиме перед каждым тестом, тем самым снижая накладные расходы для разработчика. После того, как тесты были написаны, оказалось, что они просто огромны, но вы могли просто встроить этот "базовый набор" в структуру своего тестового набора, и он автоматически сделает всю грязную работу за вас. В результате наши модульные тесты выполнялись почти 20 минут на весьма производительном ноутбуке, потому что для каждого теста выполнялось очень много действий. Такой большой объем кода тестов делал их хрупкими и трудными для понимания и отладки. Чтобы понять, почему тест проходил или проваливался, вам нужно было понять весь код, который выполнялся до открытой фигурной скобки вашей тестовой функции, а поскольку легко встраивать набор в набор, часто МНОГО чего выполнялось до этой открытой фигурной скобки.
В будущем, вместо этого, я буду придерживаться стандартной библиотеки для тестирования. Мне нравится, что тесты со стандартной библиотекой пишутся так же, как и обычный код Go, а также то, что зависимости должны быть явными. Если вы хотите запустить код в начале вашего теста, вы можете просто поместить туда метод… вы должны поместить туда метод.
time
в бутылке
Пакет time
— это проклятие тестов и тестируемого кода. Если у вас есть код, который должен сработать по таймауту через 30 секунд, как вы его тестируете? Проводите тест, на выполнение которого уходит 30 секунд? А остальные тесты выполняются 30 секунд, если что-то пойдет не так? Это связано не только с time.Sleep
, но и с time.After
или time.Ticker
… в общем, это катастрофа во время тестов. И не говоря уже о том, что при тестировании (особенно при запуске с ключом -race) ваш код может выполняться намного медленнее, чем на продакшене.
Решение заключается в том, чтобы поиздеваться над временем… что, конечно, нетривиально, потому что пакет time
— это всего лишь куча функций верхнего уровня. Поэтому везде, где используется пакет time
, нужно взять вместо него ваш специальный интерфейс, который является оберткой для time
, а затем для тестов передать этот поддельный time
, который вы уже можете контролировать. Это сильно увеличивало нам время создания готовых сборок и распространять изменения в коде. Долгое время это был постоянный источник flakey-тестов. Это такие тесты, которые будут проходить большую часть времени, но если в какой-то день машина CI была задумчивой, некоторые случайные тесты проваливались. А когда у вас сотни тысяч строк тестов, высока вероятность, что какой-нибудь тест не пройдет, и, скорее всего, это будет вовсе не тот же самый тест, что и в прошлый раз. Починка flakey-тестов была похожа на игру "прибей крота" (ориг.: whack-a-mole; игровой автомат, в котором из 9 дыр высовываются кроты, которых надо бить молотком).
Счастье кросс-компиляции
Я не знаю точного количества всех комбинаций ОС и архитектуры, но сервер Juju точно собирается под Windows и Linux (Centos и Ubuntu), а также для множества архитектур, не просто для amd64, но даже для таких эксцентричных, как ppc64le, arm64 и s390x.
Сначала Juju использовал gccgo для архитектур, которые компилятор gc не поддерживал. Из-за этого было несколько ошибок в Juju, где gccgo делал какую-то трудноуловимую дурь. Когда gc обновился и стал поддерживать все архитектуры, мы были весьма рады выбросить дополнительный компилятор из проекта и работать только с gc.
Когда мы переключились на gc, практически исчезли архитектурно-специфические ошибки. И это здорово, учитывая широту поддерживаемых архитектур Juju, а также тот факт, что обычно те эксцентричные архитектуры использовали крупные компании, у которых много рычагов давления на Canonical.
ОС-специфичные ошибки
Сначала, когда мы только начали встраивать поддержку Windows, у нас было несколько ошибок, связанных с ОС (все мы разрабатывали на Ubuntu, поэтому Windows-специфичные ошибки не попадались до тех пор, пока не отработает CI). Они в основном сводились к двум распространенным ошибкам, связанных с файловыми системами.
Первая — это использование по умолчанию прямых слэшей для путей в тестах. Например, если вы знаете, что файл конфигурации должен находиться в подпапке "juju" и называться "config.yml", тогда ваш тест может проверить, что путь к файлу представляет собой folder + "/juju/config.yml"
, а под Windows он должен быть folder + "jujuconfig.yml"
.
При создании новых каталогов, даже в тестах, используйте filepath.Join
вместо path.Join
, и, уж тем более не путем объединения строк и слэшей. filepath.Join
будет использовать правильные слэши для ОС. Для сравнения путей всегда используйте path.ToSlash
, чтобы привести пути к каноническому виду, который уже можно сравнивать.
Другая распространенная ошибка заключалась в том, что разработчики Linux позволяют вам удалить / переместить открытый файл. А это не работает в Windows, поскольку Windows блокирует файл при открытии. Это часто случалось в виде вызова defer file.Delete()
, который, согласно FIFO, вызывался перед отложенным вызовом file.Close()
и, таким образом, происходила попытка удалить еще пока открытый файл. Конфуз. Одно из решений — просто всегда вызывать file.Close()
перед выполнением перемещения или удаления. Обратите внимание, вы можете вызывать Close несколько раз, так что вполне безопасно его вызвать перед удалением, даже если у вас уже есть defer file.Close()
, который сработает в конце работы функции.
Ни одна из этих ошибок не представляет трудности и я считаю, что такая сильная поддержка кроссплатформенности в стандартной библиотеке упрощает разработку кроссплатформенного кода.
Обработка ошибок
Обработка ошибок в Go определенно оказала благотворное влияние на стабильность Juju. Тот факт, что вы можете сказать, где конкретная функция может завершиться с ошибкой, намного упрощает запись кода, который ожидает сбоя, и делает это изящно.
В течение долгого времени Juju просто использовал стандартный пакет errors
. Однако мы почувствовали, что нам нужно больше контекста, чтобы лучше отслеживать путь кода, вызвавшего ошибку, и мы подумали, что было бы неплохо сохранять более подробные сведения об ошибке, а также добавить к ней контекст (например, при использовании fmt.Errorf
теряется информация об исходной ошибке, если, допустим, это была бы ошибка os.NotFound
).
Пару лет назад мы приступили к разработке своего пакета ошибок, который захватывает больше контекста, не теряя при этом исходную информацию об ошибке. После бесплодных метаний в разные стороны мы объединили все наши идеи в https://github.com/juju/errors. Это, конечно, не идеальная библиотека, и с годами она раздувалась за счет новых функций, но это было хорошее начало.
Основная проблема заключается в том, что от вас требуется всегда вызывать errors.Trace(err)
при возврате ошибки, чтобы узнать текущие имя файла и номер строки, когда требуется вывести такую вещь, как трассировка стека. Сегодня я бы выбрал пакет <github.com/pkg/errors> от Дейва Чини (Dave Cheney), который захватывает трассировку стека в момент создания ошибки и избегает полной трассировки. Честно говоря, я не считаю трассировку стека при ошибке прямо таки супер полезной. На практике, непредвиденные ошибки имеют достаточный контекст из всего лишь fmt.Errorf("while doing foo: %v", err)
, так что чаще всего трассировка стека не нужна. Возможность исследовать свойства исходной ошибки иногда может пригодиться, но, скорее всего, не так часто, как вы думаете. Если foobar.init()
возвращает что-то вроде os.IsNotFound
, действительно ли это чем-то поможет вашему коду? В большинстве случаев — нет.
Стабильность
Для такого огромного проекта, Juju очень стабилен (это не означает, что в нем нет множества ошибок… я просто имею в виду, что он почти никогда не падал или сильно глючил). Я думаю, что многое зависит от языка. Компания, в которой я работал до Canonical, имела миллион строк кода C#, и оно довольно часто падало с "null reference" исключениями и прочими необработанными исключениями. Честно говоря, я не припомню, чтобы когда-либо видел панику из-за пустого указателя в продакшен-коде Juju, только изредка в процессе разработки, когда я делал какую-нибудь глупость в новом коде.
Я уверен, что шаблон множественных возвратов в Go служит для указания ошибок. Использовать шаблон foo, err :=
и всегда-всегда проверять ошибки — это в самом деле приводит к очень малой вероятности встретится с пустым указателем. Проверка ошибки перед обращением к возвращенной переменной (переменным) является базовым принципом Go, важным настолько, что мы документируем исключения из этого правила. Дополнительное возвращаемое значение ошибки не может быть проигнорировано или забыто благодаря проверкам компилятора на неиспользуемые переменные. Это достаточно хорошо уменьшает проблему пустых указателей в Go, по сравнению с другими подобными языками.
Дженерики
Этот раздел будет коротким, потому что… ну, вы знаете. Всего один или два раза во время работы над Juju я чувствовал, что лично мне не хватает дженериков. И я не помню, чтобы во время ревью кода мне бы хотелось дженериков для чужих исходников. Я был счастлив, что мне не пришлось "грокать" когнитивную (интеллектуальную) сложность, с которой я познакомился с дженериками в C#. Интерфейсы Go достаточно хороши для 99% случаев и я не имею в виду interface{}
. Мы редко использовали interface{}
в Juju, и почти всегда это было потому, что выполнялась какая-то сериализация.
Продолжение следует
Это и так уже довольно длинный пост, поэтому я думаю, что пора остановиться. У меня есть много более конкретных вещей, о которых я могу рассказать… об API, версионировании, базе данных, рефакторинге, ведении журнала, идиомах, инспекциях кода и т.д.
Автор: kilgur