Привет, Хабр! Меня зовут Данила Проценко. Я — Lead Software Architect в «Лаборатории Касперского», занимаюсь архитектурой микросервисов и монолитов на Go.
Строгий порядок — залог успешного промышленного программирования. А порядок, в свою очередь, начинается со стандартизации. В этой статье я расскажу про то, как во множестве микросервисов и просто сервисов можно стандартизировать структуру кода и сделать ее понятнее для всех. И поскольку эта статья — про код и его структуру, то приготовьтесь к тому, что кода в ней будет очень много :)
Казалось бы, считается, что Go используется для упрощения. Обычно всех коробит от околоэнтерпрайзных паттернов, в том числе DI-контейнеров — слишком это сложно для Go и принятого для него подхода «максимального упрощения».
Но есть удобное решение — библиотека uber.fx. Кажется, что это DI-контейнер, но на самом деле это нечто гораздо большее. Не бойтесь, что это энтерпрайз, на самом деле uber.fx имеет практическую полезность даже для небольших приложений.
Начнем с простого искусственного примера.
Так может выглядеть main.go:
В этом примере есть конфигурация, объявлены компоненты, затем идут разные вызовы, затем опять компоненты и так далее много-много раз — даже для достаточно небольшого сервиса.
Код main() выглядит простым, но с ним возникает много проблем: например, когда в сервисе надо что-то поменять, разработчик, который видит такой код впервые, не знает, куда вставлять новый компонент. В итоге вставляет его куда попало, лишь бы заработало, чтобы радостно сказать менеджеру, что фича готова. Такой код с каждым изменением становится все более запутанным. А если в main не 10 строчек, как сейчас, а 100? Сложность и количество вопросов возрастают в разы. Хочется придумать схему, по которой всегда понятно, как структурировать такой код, как его единообразно дописывать и править (а дописывать и править любой код рано или поздно приходится).
Начнем немного издалека — а что обычно нужно сервису в main.go?
Встречаются все или часть этих пунктов, что-то забывают, что-то добавляют, последовательность тоже может быть разная:
Теперь расскажу, как все это упорядочить.
Речь пойдет именно про стандартизацию. Основная идея — структура приложения должна начинаться с одного места и должна быть достаточно декларативной. Также хочется, чтобы понять приложение можно было из одной точки, хотя бы крупными мазками: из чего приложение состоит, как стартует.
Сделать это можно с помощью библиотеки uber.fx.
https://github.com/uber-go/fx
Начнем с простого шаблончика main() — с uber.fx:
Эти строчки не искусственные, main.go действительно такой однострочный, он превращается в boilerplate, заодно с вызовом fx.Options. Но смысл, конечно же, не в том, что мы просто пытаемся заворачивать содержимое main.go в отдельную функцию и в main() ее вызываем.
Еще: обратим внимание на синхронный Run, который запускает все процессы в main: он следит за сигналами: делает то, что обычно делают вручную (или не делают вообще).
А дальше и начинается структурирование:
fx.Provide — объявление компонентов
fx.Invoke — инициализация, код
Дальше мы будем подробно рассматривать, что значит «объявление компонентов», что значит «инициализация» и так далее.
Это понятие часто упоминается в презентациях про архитектуру. В этой статье термин «компонент» используется в конкретном значении: для обозначения типа, то есть структуры с методами.
Простые типы, такие как DTO (data transfer object), можно не считать компонентами. Компонент — это то, что интересно регистрировать внутри контейнера. Смысл в том, что компонент может зависеть от других компонентов (классов), в Go это тоже структуры, но с зависимостями от интерфейсов.
Например:
В этом коде Auth — компонент. Он зависит от компонентов ConfigAuth — своей конфигурации, от UserAccessor — репозитория и от трейсера.
Сразу можно вспомнить go proverb: «Accept interfaces, return structs» — это означает, что возвращать лучше структуры, а зависеть — от интерфейсов.
Вернемся теперь к fx — к структуре декларации. Начинается все с объявления компонентов.
Перечисляем в fx.Provide конструкторы наших компонентов, без указания параметров конструкторов (прямо функции конструкторов).
Все перечисленные конструкторы теперь перейдут под управление DI-контейнера uber.fx.
На DI-контейнере хочу остановиться поподробнее.
DI-контейнера не надо бояться, и пользоваться им на поверку оказывается очень просто.
Основной практический его смысл — избавить разработчика от необходимости вручную расставлять аргументы во всех конструкторах и создавать экземпляры компонентов в нужной последовательности.
Например, между компонентами могут быть такие зависимости:
Без DI-контейнера разработчикам вручную нужно расставлять эти зависимости — вызывать конструкторы, объявлять переменные, проверять, что никто ошибок не вернул, и так далее. Кода, который этим занимается, образуется достаточно много.
Но, помимо этого, у библиотек для DI еще полно всего — и управление жизненным циклом объектов, которые мы еще увидим, построение графа в виде dot-картинки, и автогенерация фабрик, автомоки, и даже аспекты. Думаю, еще много чего можно придумать на основе такой глобальной конструкции, как DI-контейнер.
Но давайте обсудим более экзистенциальный вопрос — архитектуру и «слои» в структуре приложения.
Откуда, собственно, берется граф? Что означают зависимости?
Вроде бы это все знают — это кто кого вызывает, кто чьи методы дергает. Например, дата-менеджер обращается к репозиториям, чтобы получить данные.
В организации этих вызовов и образуется слоистая структура — с распределением обязанностей.
Мы можем нарисовать на графе разделители и между ними увидим те самые слои.
Зачем это может быть нужно — естественно, чтобы навести порядок!
При таком взгляде на архитектуру при добавлении нового компонента достаточно легко сказать, где он должен находиться, хотя бы визуально.
Также в такой схеме легче добиться упрощения зависимостей: чтобы более общие, как правило управляющие компоненты, не зависели от более частных. Это позволяет избавляться от запутанной логики, которая идет в обход, то есть мы обычно не хотим, чтобы нижние слои зависели от верхних.
Альтернатива — это тесно связанный код, монолит, который при любой доработке требует изменения многих зависимых компонентов.
Обычно слои максимально отделяют друг от друга с помощью интерфейсов, а за счет инверсии зависимостей можно еще и отделить интерфейсами реализацию нижних слоев от верхних.
При организации структуры зависимостей часто возникает проблема циклических зависимостей.
Мы уже знаем, что, когда вызов происходит в обратную сторону, это скорее всего идет вне желаемого потока управления. По графу же сразу видно, что это цикл.
DI-контейнер этот цикл сразу покажет, нарисует ошибку, в которой сообщит о зацикливании.
Failed: cycle detected in dependency graph: *fxshow.C2 provided by «fxshow».NewC1 (.../fxshow/components.go:196)
depends on *fxshow.C3 provided by ".../fxshow".NewC2 (.../fxshow/components.go:204)
depends on *fxshow.C1 provided by ".../fxshow".NewC3 (.../fxshow/components.go:212)
depends on *fxshow.C2 provided by ".../fxshow".NewC1 (.../fxshow/components.go:196)
main_test.go:10: cycle detected in dependency graph: *fxshow.C2 provided by ".../fxshow".NewC1 (.../fxshow/components.go:196)
depends on *fxshow.C3 provided by ".../fxshow".NewC2 (.../fxshow/components.go:204)
depends on *fxshow.C1 provided by ".../fxshow".NewC3 (.../fxshow/components.go:212)
depends on *fxshow.C2 provided by ".../fxshow".NewC1 (.../fxshow/components.go:196)
Вручную замкнуть цикл — легко, а в контейнере это сделать несколько сложнее. Кстати, не волнуйтесь насчет алгоритмов библиотеки fx — DI-контейнер справится и с большим графом:
Но бывают ситуации, когда циклические зависимости по логике вещей нужны.
Например: у вас есть трейсер-логгер, который пишет в stdout. От него зависят практически все компоненты. Неудивительно, что система конфигурирования тоже от него зависит, поскольку конфигурировать логгер-трейсер тоже надо. Причем — не при старте, а с задержкой, пока расширенная конфигурация не получена. Или даже в динамике переконфигуриться.
Так или иначе, получается, этому абстрактному трейсеру-логгеру надо знать про систему конфигурирования — и получается циклическая зависимость.
Как с этим быть? На самом деле все просто, и вы часто так делали, скорее всего с пэкаджами. Надо добавить компонент, от которого зависеть будут оба. Для трейсера это будет источник конфигурации, а система конфигурации будет его менять.
То же самое можно оформить и коллбэком, и каналом, и другими способами.
Вернемся теперь к декларациям:
Вы перечисляете все свои конструкторы, при этом не думая об их зависимости друг от друга.
Что мы получаем: декларативное перечисление, к тому же стойкое к изменениям. Если вы рефакторите сигнатуру конструкторов, то (при наличии зависимостей) вам менять ничего в коде не надо, контейнер расставит зависимости за вас.
На практике можно накидывать конструкторы по максимуму — вызываются они лениво, так что лишние не мешают, разве только захламляют ваш общий вид. Про ленивые вызовы будет подробнее расписано ниже.
Также конструктору допустимо возвращать error — он будет обработан, приложение будет остановлено с ошибкой.
Но не все так радужно, и есть ряд еще не решенных вопросов в работе с этим контейнером.
Первое — проверка целостности делается только в рантайме. Так что нужен юнит-тест. В compile-time или тем более в подсветке в редакторе, на ходу, отсутствие зависимости не будет видно. Но хотя бы тест очень простой:
Если в структуре чего-то не хватает, подробное описание будет в тексте ошибки:
Error: Received unexpected error:
could not build arguments for function “…".CreateApp.func2
…/main.go:36:
failed to build *fxshow.Cache:
missing dependencies for function “…".NewCache
…/components.go:97:
missing type:
— *fxshow.UsersRepo (did you mean to use fxshow.UserAccessor?)
Но юнит-тест надо не забыть прогнать. Гоняйте юнит-тесты!
Также всегда стоит помнить, что связывание компонентов происходит исключительно за счет сигнатур конструкторов и функций в fx.Invoke. Сами функции запускаются, только если связывание прошло. Это значит, что тест не проверяет, что ваши конструкторы по факту вернут в реальном запуске — ошибку или вообще nil вместо объекта.
Следующая сложность — интерфейсы.
Как мы обсуждали, предпочтительно, чтобы конструктор возвращал структуру.
При этом зависимости (параметры в конструкторе) чаще объявляются как интерфейсы (потому что в go proverb есть такой, см. выше).
Uber.fx на данный момент не предоставляет (а может, и не должен) возможность автоматически находить имплементации по интерфейсу.
В Go сопоставляются интерфейсы с реализацией по принципу duck-typing, и заставлять библиотеку через reflection искать все имплементации выглядит жестоким, хотя и не невозможным. В каких-то DI-контейнерах это может быть и оправдано. Можно и самостоятельно такое решение написать.
Так что надо объявлять явно, какой компонент у вас реализует какие интерфейсы.
На моей практике особых страданий от объявления интерфейсов нет.
Делу помогают подробные сообщения от библиотеки: она не только явно говорит о том, кому кого не хватает, но и угадывает правильные реализации, которые нужно подставить. Пример сообщения об ошибке:
Если вам нужно несколько интерфейсов, то это тоже работает.
Можно сделать так:
В таком варианте зависимости будут инициализироваться лениво. Это значит, что фактический вызов конструктора будет только один раз и только при необходимости.
Но, к сожалению, это не работает, если вы будете объявлять конструктор отдельно. Например, если вам надо иметь в контейнере и интерфейс, и реализацию:
С такой записью вы получите неожиданный эффект — uber.fx хоть и работает лениво, но маппинг происходит по выходным типам, а здесь выходные типы будут разные. Библиотека не распознает, что конструкторы тут одинаковые, — отсюда возникнет два инстанса репозитория.
Чтобы этого избежать, нужно заменить конструктор на использование уже объявленного типа:
Ну или, если не лень немного надстроить и порыться в reflection, можно немного сократить эту запись:
Вообще, мы uber.fx по-всякому расширяем, и эти расширения планируется публиковать в open source.
Раз уж мы упомянули reflection, хочется сразу пояснить про его использование. Как вы, наверное, уже догадались, библиотека активно применяет reflection. Это избавляет от необходимости кодогенерации, в отличие от других библиотек, типа гугловой wire.
При этом reflection обычно вызывает справедливые опасения на предмет скорости. В сценариях uber.fx за скорость бояться не стоит: контейнер производит «склейку» ваших компонентов и резолвит («разрешает», если говорить по-русски) зависимости через reflection только один раз. На общую производительность вашей логики или вашей основной работы сервиса он не повлияет.
Теперь перейдем к инициализации приложения, к первому запуску кода. До этого все, что было про декларативное объявление компонент, цветочки. Зависимости, которые мы объявили, используются сначала через функции инициализации — то, что складывается в секцию под fx.Invoke.
Можно считать, это начинается уже специфичная бизнес-логика приложения, в том смысле, что до этого была работа с контейнером и код приложения, собственно, еще не выполнялся.
Смысл в том, что uber.fx сделает последовательный вызов функций, объявленных в fx.Invoke. Каждый вызов — это «стейдж», или шаг запуска.
Поясню, что я называю «стейджами»: это логические части всей процедуры запуска. Не стоит городить простыню кода в одной функции, в которую постоянно что-то докидывают. Должно быть осмысленное деление: вот загрузка конфигов, вот проверка соединений, вот подгрузка кеша.
С точки зрения fx это отдельные вызовы fx.Invoke.
За их запуском fx следит.
Во-первых, есть таймаут. Обращу внимание, что этот таймаут работает без использования контекста, который закэнселится. Если тайм-аута на запуск не хватит, будет считаться, что запуск не состоялся, и библиотека выйдет с ошибочным кодом.
Во-вторых, каждый «стейдж» может возвращать ошибки, которые fx обработает и также затриггерит остановку с ошибкой.
На практике «стейджи» лучше объявлять отдельными функциями, а не лямбдами, как в обучающих примерах. Соответственно, названия им тоже надо дать… — хочется сказать «дать надо понятные названия», но кто ж будет считать, что дает непонятные названия. Так что давайте хотя бы какие-то названия, иначе как в этом всем разбираться. Да и в логах fx этот код тоже отразится с именем функций, которые вы вставили в fx.Invoke.
Такой подход, с отдельными функциями, еще дает возможность юнит-тестирования «стейджей» — функции можно вызывать в тесте, подставив на вход, например, моки.
Соберем это вместе.
Глядя на такой код, можно сходу увидеть, из чего приложение состоит, из каких компонентов и что делает при своем запуске, — всю последовательность шагов.
Детали, конечно, скрыты за функциями, но тут же главное получить общий вид. Представьте, что вы знакомитесь с кодом, который не вы писали. Нельзя сразу ухватить все его детали. Освоение идет постепенно. Приведенная структура дает возможность начать освоение от общего к частному — вам не надо начинать разбирать компоненты или детали их запуска. Вы на первом шаге видите, из каких именно компонентов состоит приложение, затем — из чего состоит его запуск. Детали можно посмотреть, «проваливаясь» в каждую задекларированную функцию.
Но это еще не все.
Мы обсудили инициализацию — про предварительные действия, которые выполняет приложение. Теперь нам надо запустить долгоиграющий код — то, что в сервисе крутится все время жизни сервиса. Эта деятельность требует внимания не только к запуску, но и к остановке.
Начнем с обзора, как вообще в Go решаются такие вопросы.
Я сразу начну с рассмотрения функций — будем подразумевать, что они запускаются в горутинках. Вот у нас есть функция, которой нужно когда-то остановиться.
Мы ей можем сигнализировать остановку через канал:
Более идиоматичный паттерн — использование контекста. Передаем его в функцию и следим через Done() за его завершением. Не забываем вернуть ctx.Err()
Это достаточно простые паттерны, так что давайте теперь рассмотрим более сложный случай.
Остановка (и запуск) у нас — это не всегда моментальные операции. Работающая функция, которая получила сигнал на остановку (сигнал в любом виде), не обязательно моментально завершит свою работу. Управляющей структуре, которая посылала этот сигнал, может потребоваться обратная связь и возможность реакции на проблемы с остановкой, в частности — остановка может зависнуть или вернуть ошибку.
Такое усложнение не очень актуально для вообще всех функций, а вот для компонентов приложения — очень даже.
В этом более сложном случае uber.fx рекомендует реализовывать запуск и остановку компонента в виде паттерна, который условно назовем Start-Stop.
У компонента, у которого есть жизненный цикл с запуском и остановкой, должна быть функция запуска и функция остановки, например Start и Stop. Имена, если что, я сам придумал, uber.fx их никак не регламентирует. Регламентируется только сигнатура.
В функцию Start первым и единственным параметром передается контекст — он для управления процедурой запуска. То есть процедура запуска может иметь свой тайм-аут и может быть остановлена сама по себе. Важно, что этот контекст следует использовать только в функции Start. Когда процедура запуска закончится, контекст будет уже не валидным. Это значит, что если вы в функции Start запускаете горутины, которые будут работать и после ее завершения, то контекст Start нельзя в них использовать. Нужно делать отдельную остановку, которая как раз и будет триггериться функцией Stop.
Также Start может вернуть ошибку, если процедура запуска закончилась с ошибкой.
Аналогично у функции Stop — тоже контекст, тоже про процедуру остановки, тоже с ошибкой.
По результатам вызова Start у вас должна запуститься ваша долгоиграющая работа. То же самое со Stop — когда пришло время компоненту прекратить свою работу, вызывается Stop. То есть не компонент решает, что Stop надо вызвать, а управляющая структура ему дергает Stop, и тоже на это есть тайм-аут и возможность вернуть ошибку.
Пример таких функций есть в стандартной библиотеке в пакете net. У типа net.Server есть функция ListenAndServe, она блокирующая. Ее надо запускать в отдельной горутинке в функции Start. Соответственно, в функции Stop надо дернуть функцию http.Server Shutdown — и это будет правильная работа с http.Server.
Функции Start и Stop нужно регистрировать, делается это через зависимость fx.Lifecycle.
Все Start будут вызваны последовательно, после всех Invoke.
При остановке приложения будут вызваны все функции Stop.
Этот пример из документации немного длинноват, но его легко рефакторить. Если вам не лень писать собственный код, можно сделать совсем простые wrapper’ы:
Или еще раз обобщить, чтобы просто ничего не было заметно: это уже чтоб все компоненты со Start и Stop разом регистрировались. В библиотеке fx этого нет, но делается очень легко.
Про остановку начнем тоже немного издалека, а именно — какие возможны варианты на практике.
Самое простое, конечно, просто убить процесс. Это плохая практика, так как «жесткая» остановка не дает возможности отработать отложенному коду: это defer-ы, фоновые горутинки.
Приличное приложение не ложится втихую, а выполняет всю нужную логику. Дает возможность всем defer-ам выполниться, сигнализирует всем горутинам остановиться, убрать за собой, и еще пишет в лог причину остановки и только потом останавливает процесс, еще и вернув приличный код возврата.
То есть вот так делать не надо:
Вы не даете выполниться ни defer, ни фоновым горутинам. Если у вас не суперпростой сервис, это может привести к плохим последствиям, особенно если этот код писали больше одного человека и вы просто не увидели, что в сервисе что-то еще может происходить во время остановки.
uber.fx предоставляет механизмы для оформления корректной остановки.
Вспомним бойлерплейт main() функции
Под капотом тут в вызове Run-библиотека подпишется на SIGINT и SIGTERM (и на эквивалентные сигналы на других платформах) и будет их принимать и реагировать — останавливать приложение.
При их получении будет вызываться вся инфраструктура остановки — функции Stop у всех компонентов, которые зарегистрировались (в последовательности, обратной последовательности вызовов Start).
Симметрично у остановки тоже есть тайм-аут, если он не соблюден, остановка будет считаться некорректной, и так же, если какая-либо из Stop-функций вернет ошибку.
Напоследок обращу внимание, что библиотека работает в пределах процесса. То есть если вы убьете процесс снаружи, например контейнер у вас помер, то библиотека ничего сделать не сможет. Это легко забыть, увлекшись построениями graceful shutdown внутри сложного приложения.
Но есть еще один кейс, который, так скажем, не до конца предусмотрен в uber.fx.
Ситуация такая: у вас работает компонент и тут с ним что-то пошло не так. Нужно в результате остановить приложение. Это аварийная остановка. По смыслу — надо, чтобы компонент сигнализировал контейнеру, что все, хватит, у нас проблемы.
Хотелось бы, конечно, такой сценарий предусмотреть на уровне библиотеки.
Что предлагает uber.fx: есть зависимость Shutdowner, у которой есть метод, который инициирует остановку. Когда нужно, этот метод можно дернуть.
Это выглядит так, что компонент, которому дано право инициировать остановку приложения, надо подписывать на вызов этого метода.
Метод, само собой, вызывает не просто os.Exit, а полноценную с точки зрения uber.fx процедуру остановки.
Это значит — вызовет все Stop-функции у всех компонентов, учитывая тайм-ауты и обработку ошибок.
Вот мы собрали наше приложение, задекларировали все зависимости, шаги. Выглядеть это может вот так:
Структура появилась, и ясно, что где подключать. Добавлю еще немного практических наблюдений по работе с библиотекой uber.fx.
Не увлекайтесь лямбдами.
Вас может потянуть увлечься лямбдами, которые можно вставлять прямо в Provide, Invoke, регистрацию компонентов. В примерах и документации такого хватает.
В продакшене не стоит так делать. Лямбды не генерируют имена в логах, да и тесты писать на них не получится. Объявляйте функции.
Аналогично, в Provide лучше всего вставлять свои реальные конструкторы, не стоит их чем-то дополнительно оборачивать, даже из желания подстроиться под контейнер. Чисто и гладко спроектированный конструктор будет как есть работать в uber.fx.
Не регистрируйте литеральные типы.
Использовать можно, но неудобно. Рано или поздно обнаружатся два объекта с одним типом. Чтобы это решить, потянет попробовать именуемые инстансы, а это целая история, которая красивое наполнение контейнера усложняет настолько, что теряется его смысл.
Лучше заворачивайте экземпляр вашего литерального значения в объект — в структуру с полем.
Функции инициализации (fx.Invoke) не стоит сильно дробить.
Это верхнеуровневые, управляющие конструкции. Однострочные функции в «стейджах» — это обычно перебор. Задача — структурировать ваши шаги инициализации, то есть разделить простыню на понятные части.
А если увлекаться, то будет та же самая простыня, только из отдельных из функций.
Пишите юнит-тесты.
Тестируем в такой схеме ну в общем-то все. Контейнер, функции запуска и отдельные компоненты.
Тестируйте контейнер ValidateApp() стандартным способом, который я показывал:
Пишите тесты на «стейджи» — это чистые функции без сайд-эффектов (функциональное программирование!).
Тестируйте компоненты — конечно, сильно все упрощает, если у вас разрисованы интерфейсы, ну или используются какие-то другие подходы для моков.
Давайте взглянем на итоговую структуру и код и сравним, что у нас получается с uber.fx и без.
Вот скелет простого исходного приложения:
и вот как выглядит структурированный пример
и более полное объявление
На самом деле они бывают и гораздо больше, но наглядности от этого не теряют.
Теперь сравним вообще всю структуру, опять реальный проект. Сенситивные места тут убраны, если что.
Надеюсь, увидели структуру.
Не хочу разочаровывать тех, кто думал, что кода будет меньше. Смысл тут не в этом. Напомню, что идея именно в структуризации: разделить код от общего к частному, добавить подсказок разработчикам, куда следует вносить изменения и новую функциональность, о чем не забывать.
Давайте кратко напомню, о чем мы думаем, когда делаем чистый и красивый main и вообще сервис:
>>>
Ну а если вам тоже интересны хорошие практики в Go, и вы хотите приложить руку к подобным изысканиям, приходите к нам в команду :)
Строгий порядок — залог успешного промышленного программирования. А порядок, в свою очередь, начинается со стандартизации. В этой статье я расскажу про то, как во множестве микросервисов и просто сервисов можно стандартизировать структуру кода и сделать ее понятнее для всех. И поскольку эта статья — про код и его структуру, то приготовьтесь к тому, что кода в ней будет очень много :)
Это все ваш энтерпрайз
Казалось бы, считается, что Go используется для упрощения. Обычно всех коробит от околоэнтерпрайзных паттернов, в том числе DI-контейнеров — слишком это сложно для Go и принятого для него подхода «максимального упрощения».
Но есть удобное решение — библиотека uber.fx. Кажется, что это DI-контейнер, но на самом деле это нечто гораздо большее. Не бойтесь, что это энтерпрайз, на самом деле uber.fx имеет практическую полезность даже для небольших приложений.
Начнем с простого искусственного примера.
main.go
Так может выглядеть main.go:
func main() {
// greeting
// load config or something
c := &fxshow.Config{}
// ....
// setup basic logging
logger := ktrace.NewFactory(golog.New(nil))
// create components
ur := fxshow.NewUsersRepo(logger, c.UsersRepo)
// .....
auth := fxshow.NewAuth(logger, c.Auth, ur)
// .....
// run something
err := auth.Start(context.TODO())
if err != nil {
panic(err)
}
// wait for stop
// .....
}
В этом примере есть конфигурация, объявлены компоненты, затем идут разные вызовы, затем опять компоненты и так далее много-много раз — даже для достаточно небольшого сервиса.
Код main() выглядит простым, но с ним возникает много проблем: например, когда в сервисе надо что-то поменять, разработчик, который видит такой код впервые, не знает, куда вставлять новый компонент. В итоге вставляет его куда попало, лишь бы заработало, чтобы радостно сказать менеджеру, что фича готова. Такой код с каждым изменением становится все более запутанным. А если в main не 10 строчек, как сейчас, а 100? Сложность и количество вопросов возрастают в разы. Хочется придумать схему, по которой всегда понятно, как структурировать такой код, как его единообразно дописывать и править (а дописывать и править любой код рано или поздно приходится).
Что нужно сервису?
Начнем немного издалека — а что обычно нужно сервису в main.go?
Встречаются все или часть этих пунктов, что-то забывают, что-то добавляют, последовательность тоже может быть разная:
- Сконфигурироваться — считать конфиг-файлик. Но не всегда. Часто бывает, что надо еще подключиться к хранилищу секретов или вообще к системе конфигурирования.
- Склеить части системы — расставить друг друга в конструкторы или вызвать какие-нибудь методы для этого.
- Запуститься — подключиться к внешним системам или проверить подключение. Например, к очередям, базам данных, любым другим сервисам.
- Обработать ошибки, которые возникли при запуске.
- Остановиться, если продолжать работать невозможно, например нельзя никуда подключиться.
- Продолжить запуск, если это возможно.
- Принять сигнал на остановку, например, если сервис хотят перезагрузить.
- Сообщить об остановке. Например, написать об этом в Discovery и записать в лог причину своей остановки.
Теперь расскажу, как все это упорядочить.
Речь пойдет именно про стандартизацию. Основная идея — структура приложения должна начинаться с одного места и должна быть достаточно декларативной. Также хочется, чтобы понять приложение можно было из одной точки, хотя бы крупными мазками: из чего приложение состоит, как стартует.
Сделать это можно с помощью библиотеки uber.fx.
https://github.com/uber-go/fx
main.go + fx
Начнем с простого шаблончика main() — с uber.fx:
func main() {
fx.New(CreateApp()).Run()
}
func CreateApp() fx.Option {
return fx.Options(
// fx.Supply(),
fx.Provide(
// ....
),
fx.Invoke(
// ....
),
)
}
Эти строчки не искусственные, main.go действительно такой однострочный, он превращается в boilerplate, заодно с вызовом fx.Options. Но смысл, конечно же, не в том, что мы просто пытаемся заворачивать содержимое main.go в отдельную функцию и в main() ее вызываем.
Еще: обратим внимание на синхронный Run, который запускает все процессы в main: он следит за сигналами: делает то, что обычно делают вручную (или не делают вообще).
А дальше и начинается структурирование:
fx.Provide — объявление компонентов
fx.Invoke — инициализация, код
Дальше мы будем подробно рассматривать, что значит «объявление компонентов», что значит «инициализация» и так далее.
Компоненты
Это понятие часто упоминается в презентациях про архитектуру. В этой статье термин «компонент» используется в конкретном значении: для обозначения типа, то есть структуры с методами.
Простые типы, такие как DTO (data transfer object), можно не считать компонентами. Компонент — это то, что интересно регистрировать внутри контейнера. Смысл в том, что компонент может зависеть от других компонентов (классов), в Go это тоже структуры, но с зависимостями от интерфейсов.
Например:
type Auth struct {
conf *ConfigAuth
usersRepo UserAccessor
trs ktrace.Tracer
}
func NewAuth(
tf ktrace.TracerProvider,
conf *ConfigAuth,
usersRepo UserAccessor,
) *Auth {
// ...
return &Auth{ /*...*/ }
}
В этом коде Auth — компонент. Он зависит от компонентов ConfigAuth — своей конфигурации, от UserAccessor — репозитория и от трейсера.
Сразу можно вспомнить go proverb: «Accept interfaces, return structs» — это означает, что возвращать лучше структуры, а зависеть — от интерфейсов.
Декларация компонентов в uber.fx
Вернемся теперь к fx — к структуре декларации. Начинается все с объявления компонентов.
Перечисляем в fx.Provide конструкторы наших компонентов, без указания параметров конструкторов (прямо функции конструкторов).
func CreateApp() fx.Option {
return fx.Options(
fx.Provide(
fxshow.NewRepo1,
fxshow.NewRepo2,
fxshow.NewUsersRepo,
fxshow.NewUserManager,
fxshow.NewCache,
fxshow.NewDataCombiner,
fxshow.NewWeb,
),
fx.Invoke(
// ....
),
)
}
Все перечисленные конструкторы теперь перейдут под управление DI-контейнера uber.fx.
DI-контейнер
На DI-контейнере хочу остановиться поподробнее.
DI-контейнера не надо бояться, и пользоваться им на поверку оказывается очень просто.
Основной практический его смысл — избавить разработчика от необходимости вручную расставлять аргументы во всех конструкторах и создавать экземпляры компонентов в нужной последовательности.
Например, между компонентами могут быть такие зависимости:
Без DI-контейнера разработчикам вручную нужно расставлять эти зависимости — вызывать конструкторы, объявлять переменные, проверять, что никто ошибок не вернул, и так далее. Кода, который этим занимается, образуется достаточно много.
Но, помимо этого, у библиотек для DI еще полно всего — и управление жизненным циклом объектов, которые мы еще увидим, построение графа в виде dot-картинки, и автогенерация фабрик, автомоки, и даже аспекты. Думаю, еще много чего можно придумать на основе такой глобальной конструкции, как DI-контейнер.
Но давайте обсудим более экзистенциальный вопрос — архитектуру и «слои» в структуре приложения.
Слои
Откуда, собственно, берется граф? Что означают зависимости?
Вроде бы это все знают — это кто кого вызывает, кто чьи методы дергает. Например, дата-менеджер обращается к репозиториям, чтобы получить данные.
В организации этих вызовов и образуется слоистая структура — с распределением обязанностей.
Мы можем нарисовать на графе разделители и между ними увидим те самые слои.
Зачем это может быть нужно — естественно, чтобы навести порядок!
При таком взгляде на архитектуру при добавлении нового компонента достаточно легко сказать, где он должен находиться, хотя бы визуально.
Также в такой схеме легче добиться упрощения зависимостей: чтобы более общие, как правило управляющие компоненты, не зависели от более частных. Это позволяет избавляться от запутанной логики, которая идет в обход, то есть мы обычно не хотим, чтобы нижние слои зависели от верхних.
Альтернатива — это тесно связанный код, монолит, который при любой доработке требует изменения многих зависимых компонентов.
Обычно слои максимально отделяют друг от друга с помощью интерфейсов, а за счет инверсии зависимостей можно еще и отделить интерфейсами реализацию нижних слоев от верхних.
Циклическая зависимость
При организации структуры зависимостей часто возникает проблема циклических зависимостей.
Мы уже знаем, что, когда вызов происходит в обратную сторону, это скорее всего идет вне желаемого потока управления. По графу же сразу видно, что это цикл.
DI-контейнер этот цикл сразу покажет, нарисует ошибку, в которой сообщит о зацикливании.
Failed: cycle detected in dependency graph: *fxshow.C2 provided by «fxshow».NewC1 (.../fxshow/components.go:196)
depends on *fxshow.C3 provided by ".../fxshow".NewC2 (.../fxshow/components.go:204)
depends on *fxshow.C1 provided by ".../fxshow".NewC3 (.../fxshow/components.go:212)
depends on *fxshow.C2 provided by ".../fxshow".NewC1 (.../fxshow/components.go:196)
main_test.go:10: cycle detected in dependency graph: *fxshow.C2 provided by ".../fxshow".NewC1 (.../fxshow/components.go:196)
depends on *fxshow.C3 provided by ".../fxshow".NewC2 (.../fxshow/components.go:204)
depends on *fxshow.C1 provided by ".../fxshow".NewC3 (.../fxshow/components.go:212)
depends on *fxshow.C2 provided by ".../fxshow".NewC1 (.../fxshow/components.go:196)
Вручную замкнуть цикл — легко, а в контейнере это сделать несколько сложнее. Кстати, не волнуйтесь насчет алгоритмов библиотеки fx — DI-контейнер справится и с большим графом:
Когда цикл необходим
Но бывают ситуации, когда циклические зависимости по логике вещей нужны.
Например: у вас есть трейсер-логгер, который пишет в stdout. От него зависят практически все компоненты. Неудивительно, что система конфигурирования тоже от него зависит, поскольку конфигурировать логгер-трейсер тоже надо. Причем — не при старте, а с задержкой, пока расширенная конфигурация не получена. Или даже в динамике переконфигуриться.
Так или иначе, получается, этому абстрактному трейсеру-логгеру надо знать про систему конфигурирования — и получается циклическая зависимость.
Как с этим быть? На самом деле все просто, и вы часто так делали, скорее всего с пэкаджами. Надо добавить компонент, от которого зависеть будут оба. Для трейсера это будет источник конфигурации, а система конфигурации будет его менять.
То же самое можно оформить и коллбэком, и каналом, и другими способами.
Декларация компонентов с зависимостями
Вернемся теперь к декларациям:
func CreateApp() fx.Option {
return fx.Options(
fx.Provide(
fxshow.NewRepo1,
fxshow.NewRepo2,
fxshow.NewUsersRepo,
fxshow.NewUserManager,
fxshow.NewCache,
fxshow.NewDataCombiner,
fxshow.NewWeb,
),
fx.Invoke(
// ....
),
)
}
Вы перечисляете все свои конструкторы, при этом не думая об их зависимости друг от друга.
Что мы получаем: декларативное перечисление, к тому же стойкое к изменениям. Если вы рефакторите сигнатуру конструкторов, то (при наличии зависимостей) вам менять ничего в коде не надо, контейнер расставит зависимости за вас.
На практике можно накидывать конструкторы по максимуму — вызываются они лениво, так что лишние не мешают, разве только захламляют ваш общий вид. Про ленивые вызовы будет подробнее расписано ниже.
Также конструктору допустимо возвращать error — он будет обработан, приложение будет остановлено с ошибкой.
Как проверить структуру?
Но не все так радужно, и есть ряд еще не решенных вопросов в работе с этим контейнером.
Первое — проверка целостности делается только в рантайме. Так что нужен юнит-тест. В compile-time или тем более в подсветке в редакторе, на ходу, отсутствие зависимости не будет видно. Но хотя бы тест очень простой:
func TestValidateApp(t *testing.T) {
err := fx.ValidateApp(CreateApp())
require.NoError(t, err)
}
Если в структуре чего-то не хватает, подробное описание будет в тексте ошибки:
Error: Received unexpected error:
could not build arguments for function “…".CreateApp.func2
…/main.go:36:
failed to build *fxshow.Cache:
missing dependencies for function “…".NewCache
…/components.go:97:
missing type:
— *fxshow.UsersRepo (did you mean to use fxshow.UserAccessor?)
Но юнит-тест надо не забыть прогнать. Гоняйте юнит-тесты!
Также всегда стоит помнить, что связывание компонентов происходит исключительно за счет сигнатур конструкторов и функций в fx.Invoke. Сами функции запускаются, только если связывание прошло. Это значит, что тест не проверяет, что ваши конструкторы по факту вернут в реальном запуске — ошибку или вообще nil вместо объекта.
Регистрация интерфейсов
Следующая сложность — интерфейсы.
Как мы обсуждали, предпочтительно, чтобы конструктор возвращал структуру.
При этом зависимости (параметры в конструкторе) чаще объявляются как интерфейсы (потому что в go proverb есть такой, см. выше).
Uber.fx на данный момент не предоставляет (а может, и не должен) возможность автоматически находить имплементации по интерфейсу.
В Go сопоставляются интерфейсы с реализацией по принципу duck-typing, и заставлять библиотеку через reflection искать все имплементации выглядит жестоким, хотя и не невозможным. В каких-то DI-контейнерах это может быть и оправдано. Можно и самостоятельно такое решение написать.
Так что надо объявлять явно, какой компонент у вас реализует какие интерфейсы.
func CreateApp() fx.Option {
return fx.Options(
fx.Provide(
fxshow.NewRepo1,
fxshow.NewRepo2,
fx.Annotate(fxshow.NewUsersRepo,
fx.As(new(fxshow.UserAccessor))),
fxshow.NewUserManager,
fxshow.NewCache,
fxshow.NewDataCombiner,
fxshow.NewWeb,
),
// ...
)
}
На моей практике особых страданий от объявления интерфейсов нет.
Делу помогают подробные сообщения от библиотеки: она не только явно говорит о том, кому кого не хватает, но и угадывает правильные реализации, которые нужно подставить. Пример сообщения об ошибке:
- *fxshow.UsersRepo (did you mean to use fxshow.UserAccessor?)
Несколько интерфейсов
Если вам нужно несколько интерфейсов, то это тоже работает.
Можно сделать так:
func CreateApp() fx.Option {
return fx.Options(
fx.Provide(
// ....
fx.Annotate(
fxshow.NewRepo,
fx.As(new(fxshow.UserAccessor)),
fx.As(new(fxshow.GroupAccessor)),
),
// ....
),
)
}
В таком варианте зависимости будут инициализироваться лениво. Это значит, что фактический вызов конструктора будет только один раз и только при необходимости.
Но, к сожалению, это не работает, если вы будете объявлять конструктор отдельно. Например, если вам надо иметь в контейнере и интерфейс, и реализацию:
func CreateApp() fx.Option {
return fx.Options(
fx.Provide(
// ....
// ....
fxshow.NewRepo,
fx.Annotate(
fxshow.NewRepo,
fx.As(new(fxshow.UserAccessor)),
fx.As(new(fxshow.GroupAccessor)),
),
// ....
// ....
),
)
}
С такой записью вы получите неожиданный эффект — uber.fx хоть и работает лениво, но маппинг происходит по выходным типам, а здесь выходные типы будут разные. Библиотека не распознает, что конструкторы тут одинаковые, — отсюда возникнет два инстанса репозитория.
Чтобы этого избежать, нужно заменить конструктор на использование уже объявленного типа:
func CreateApp() fx.Option {
return fx.Options(
fx.Provide(
// ...
// ...
fxshow.NewUsersRepo,
fx.Annotate(
func(r *fxshow.UsersRepo) *fxshow.UsersRepo {
return r
},
fx.As(new(fxshow.UserAccessor)),
fx.As(new(fxshow.GroupAccessor)),
),
// ...
// ...
),
)
}
Ну или, если не лень немного надстроить и порыться в reflection, можно немного сократить эту запись:
func CreateApp() fx.Option {
return fx.Options(
fx.Provide(
// ...
// ...
fxshow.NewUsersRepo,
klfx.Map(
new(fxshow.UsersRepo),
fx.As(new(fxshow.UserAccessor)),
fx.As(new(fxshow.GroupAccessor)),
),
// ...
// ...
),
)
}
Под катом вариант реализации klfx.Map
func MapFor(targets ...interface{}) interface{} {
in := make([]reflect.Type, len(targets))
out := make([]reflect.Type, len(targets))
for i := 0; i < len(targets); i++ {
if targets[i] == nil {
return nil
}
in[i] = reflect.TypeOf(targets[i])
out[i] = in[i]
}
newFnType := reflect.FuncOf(in, out, false)
newFn := reflect.MakeFunc(newFnType, func(args []reflect.Value) []reflect.Value {
return args
})
return newFn.Interface()
}
func Map(t interface{}, anns ...fx.Annotation) interface{} {
return fx.Annotate(MapFor(t), anns...)
}
Вообще, мы uber.fx по-всякому расширяем, и эти расширения планируется публиковать в open source.
Скорость и рекомендации
Раз уж мы упомянули reflection, хочется сразу пояснить про его использование. Как вы, наверное, уже догадались, библиотека активно применяет reflection. Это избавляет от необходимости кодогенерации, в отличие от других библиотек, типа гугловой wire.
При этом reflection обычно вызывает справедливые опасения на предмет скорости. В сценариях uber.fx за скорость бояться не стоит: контейнер производит «склейку» ваших компонентов и резолвит («разрешает», если говорить по-русски) зависимости через reflection только один раз. На общую производительность вашей логики или вашей основной работы сервиса он не повлияет.
Запуск fx.Invoke
Теперь перейдем к инициализации приложения, к первому запуску кода. До этого все, что было про декларативное объявление компонент, цветочки. Зависимости, которые мы объявили, используются сначала через функции инициализации — то, что складывается в секцию под fx.Invoke.
func CreateApp() fx.Option {
return fx.Options(
fx.Invoke(
func(cc fxshow.Cacher) error {
// ....
err := cc.PreloadCache()
if err != nil {
return err
}
// ....
return nil
},
func(diGraph fx.DotGraph) {
// ....
log.Println(diGraph)
// ....
},
func(cc fxshow.Cacher, w *fxshow.Web) {
// ....
// ....
},
),
)
}
Можно считать, это начинается уже специфичная бизнес-логика приложения, в том смысле, что до этого была работа с контейнером и код приложения, собственно, еще не выполнялся.
Смысл в том, что uber.fx сделает последовательный вызов функций, объявленных в fx.Invoke. Каждый вызов — это «стейдж», или шаг запуска.
Поясню, что я называю «стейджами»: это логические части всей процедуры запуска. Не стоит городить простыню кода в одной функции, в которую постоянно что-то докидывают. Должно быть осмысленное деление: вот загрузка конфигов, вот проверка соединений, вот подгрузка кеша.
С точки зрения fx это отдельные вызовы fx.Invoke.
За их запуском fx следит.
Во-первых, есть таймаут. Обращу внимание, что этот таймаут работает без использования контекста, который закэнселится. Если тайм-аута на запуск не хватит, будет считаться, что запуск не состоялся, и библиотека выйдет с ошибочным кодом.
Во-вторых, каждый «стейдж» может возвращать ошибки, которые fx обработает и также затриггерит остановку с ошибкой.
На практике «стейджи» лучше объявлять отдельными функциями, а не лямбдами, как в обучающих примерах. Соответственно, названия им тоже надо дать… — хочется сказать «дать надо понятные названия», но кто ж будет считать, что дает непонятные названия. Так что давайте хотя бы какие-то названия, иначе как в этом всем разбираться. Да и в логах fx этот код тоже отразится с именем функций, которые вы вставили в fx.Invoke.
Такой подход, с отдельными функциями, еще дает возможность юнит-тестирования «стейджей» — функции можно вызывать в тесте, подставив на вход, например, моки.
func CreateApp() fx.Option {
return fx.Options(
fx.Invoke(
preloadCache,
printGraph,
registerRunners,
),
)
}
func preloadCache(cc fxshow.Cacher) error {
// ....
}
func printGraph(diGraph fx.DotGraph) {
// ....
// ....
}
func registerRunners(cc *fxshow.Cache,
w *fxshow.Web, lc fx.Lifecycle) {
// ....
// ....
}
Запуск и компоненты
Соберем это вместе.
func CreateApp() fx.Option {
return fx.Options(
fx.Provide(
fxshow.NewRepo1,
fxshow.NewRepo2,
fxshow.NewUsersRepo,
fxshow.NewUserManager,
fxshow.NewCache,
fx.Annotate(fxshow.NewCache, fx.As(new(fxshow.Cacher))),
fxshow.NewDataCombiner,
fxshow.NewAuth,
fxshow.NewRouter,
fxshow.NewWeb,
),
fx.Invoke(
preloadCache,
printGraph,
registerRunners,
),
)
}
Глядя на такой код, можно сходу увидеть, из чего приложение состоит, из каких компонентов и что делает при своем запуске, — всю последовательность шагов.
Детали, конечно, скрыты за функциями, но тут же главное получить общий вид. Представьте, что вы знакомитесь с кодом, который не вы писали. Нельзя сразу ухватить все его детали. Освоение идет постепенно. Приведенная структура дает возможность начать освоение от общего к частному — вам не надо начинать разбирать компоненты или детали их запуска. Вы на первом шаге видите, из каких именно компонентов состоит приложение, затем — из чего состоит его запуск. Детали можно посмотреть, «проваливаясь» в каждую задекларированную функцию.
Но это еще не все.
Запуск и остановка in the wild
Мы обсудили инициализацию — про предварительные действия, которые выполняет приложение. Теперь нам надо запустить долгоиграющий код — то, что в сервисе крутится все время жизни сервиса. Эта деятельность требует внимания не только к запуску, но и к остановке.
Начнем с обзора, как вообще в Go решаются такие вопросы.
Я сразу начну с рассмотрения функций — будем подразумевать, что они запускаются в горутинках. Вот у нас есть функция, которой нужно когда-то остановиться.
Мы ей можем сигнализировать остановку через канал:
type SomeComponent struct {
// ...
done chan struct{}
}
func (c *SomeComponent) DoSomething() error {
// ....
select {
// ....
case <-c.done:
return nil
}
}
Более идиоматичный паттерн — использование контекста. Передаем его в функцию и следим через Done() за его завершением. Не забываем вернуть ctx.Err()
func DoSomething(ctx context.Context /* ... */) error {
// ....
select {
// ....
case <-ctx.Done():
return ctx.Err()
}
}
Это достаточно простые паттерны, так что давайте теперь рассмотрим более сложный случай.
Остановка (и запуск) у нас — это не всегда моментальные операции. Работающая функция, которая получила сигнал на остановку (сигнал в любом виде), не обязательно моментально завершит свою работу. Управляющей структуре, которая посылала этот сигнал, может потребоваться обратная связь и возможность реакции на проблемы с остановкой, в частности — остановка может зависнуть или вернуть ошибку.
Такое усложнение не очень актуально для вообще всех функций, а вот для компонентов приложения — очень даже.
В этом более сложном случае uber.fx рекомендует реализовывать запуск и остановку компонента в виде паттерна, который условно назовем Start-Stop.
Start-Stop
У компонента, у которого есть жизненный цикл с запуском и остановкой, должна быть функция запуска и функция остановки, например Start и Stop. Имена, если что, я сам придумал, uber.fx их никак не регламентирует. Регламентируется только сигнатура.
В функцию Start первым и единственным параметром передается контекст — он для управления процедурой запуска. То есть процедура запуска может иметь свой тайм-аут и может быть остановлена сама по себе. Важно, что этот контекст следует использовать только в функции Start. Когда процедура запуска закончится, контекст будет уже не валидным. Это значит, что если вы в функции Start запускаете горутины, которые будут работать и после ее завершения, то контекст Start нельзя в них использовать. Нужно делать отдельную остановку, которая как раз и будет триггериться функцией Stop.
Также Start может вернуть ошибку, если процедура запуска закончилась с ошибкой.
Аналогично у функции Stop — тоже контекст, тоже про процедуру остановки, тоже с ошибкой.
func (c *SomeComponent) Start(ctx context.Context) error {
// ...
return err
}
func (c *SomeComponent) Stop(ctx context.Context) error {
// ...
return err
}
По результатам вызова Start у вас должна запуститься ваша долгоиграющая работа. То же самое со Stop — когда пришло время компоненту прекратить свою работу, вызывается Stop. То есть не компонент решает, что Stop надо вызвать, а управляющая структура ему дергает Stop, и тоже на это есть тайм-аут и возможность вернуть ошибку.
Пример таких функций есть в стандартной библиотеке в пакете net. У типа net.Server есть функция ListenAndServe, она блокирующая. Ее надо запускать в отдельной горутинке в функции Start. Соответственно, в функции Stop надо дернуть функцию http.Server Shutdown — и это будет правильная работа с http.Server.
Функции Start и Stop нужно регистрировать, делается это через зависимость fx.Lifecycle.
Все Start будут вызваны последовательно, после всех Invoke.
При остановке приложения будут вызваны все функции Stop.
func CreateApp() fx.Option {
return fx.Options(
fx.Invoke(
func(web *fxshow.Web, lc fx.Lifecycle) error {
lc.Append(
fx.Hook{
OnStart: func(ctx context.Context) (err error) {
// ....
return err
},
OnStop: func(ctx context.Context) (err error) {
// ....
return err
},
},
)
return nil
},
),
)
}
Этот пример из документации немного длинноват, но его легко рефакторить. Если вам не лень писать собственный код, можно сделать совсем простые wrapper’ы:
func CreateApp() fx.Option {
return fx.Options(
fx.Invoke(
func(web *fxshow.Web, lc fx.Lifecycle) error {
lc.Append(
fx.Hook{
OnStart: web.Start,
OnStop: web.Stop,
},
)
return nil
},
),
)
}
func CreateApp() fx.Option {
return fx.Options(
fx.Invoke(
func(web *fxshow.Web, lc fx.Lifecycle) error {
lc.Append(fxAdapter.ToHook(web))
return nil
},
),
)
}
Или еще раз обобщить, чтобы просто ничего не было заметно: это уже чтоб все компоненты со Start и Stop разом регистрировались. В библиотеке fx этого нет, но делается очень легко.
func CreateApp() fx.Option {
return fx.Options(
fx.Provide(
fx.Annotate(fxshow.NewWeb, fxAdapter.TagRunner...),
),
fx.Invoke(
fxAdapter.RegisterRunners,
),
)
}
Остановка приложения
Про остановку начнем тоже немного издалека, а именно — какие возможны варианты на практике.
Самое простое, конечно, просто убить процесс. Это плохая практика, так как «жесткая» остановка не дает возможности отработать отложенному коду: это defer-ы, фоновые горутинки.
Приличное приложение не ложится втихую, а выполняет всю нужную логику. Дает возможность всем defer-ам выполниться, сигнализирует всем горутинам остановиться, убрать за собой, и еще пишет в лог причину остановки и только потом останавливает процесс, еще и вернув приличный код возврата.
То есть вот так делать не надо:
// ....
if err != nil {
log.Fatal( /*...*/ )
}
// ....
if err != nil {
os.Exit(1)
// ....
Вы не даете выполниться ни defer, ни фоновым горутинам. Если у вас не суперпростой сервис, это может привести к плохим последствиям, особенно если этот код писали больше одного человека и вы просто не увидели, что в сервисе что-то еще может происходить во время остановки.
uber.fx предоставляет механизмы для оформления корректной остановки.
Вспомним бойлерплейт main() функции
func main() {
fx.New(CreateApp()).Run()
}
Под капотом тут в вызове Run-библиотека подпишется на SIGINT и SIGTERM (и на эквивалентные сигналы на других платформах) и будет их принимать и реагировать — останавливать приложение.
При их получении будет вызываться вся инфраструктура остановки — функции Stop у всех компонентов, которые зарегистрировались (в последовательности, обратной последовательности вызовов Start).
Симметрично у остановки тоже есть тайм-аут, если он не соблюден, остановка будет считаться некорректной, и так же, если какая-либо из Stop-функций вернет ошибку.
Напоследок обращу внимание, что библиотека работает в пределах процесса. То есть если вы убьете процесс снаружи, например контейнер у вас помер, то библиотека ничего сделать не сможет. Это легко забыть, увлекшись построениями graceful shutdown внутри сложного приложения.
Но есть еще один кейс, который, так скажем, не до конца предусмотрен в uber.fx.
Ошибка посреди
Ситуация такая: у вас работает компонент и тут с ним что-то пошло не так. Нужно в результате остановить приложение. Это аварийная остановка. По смыслу — надо, чтобы компонент сигнализировал контейнеру, что все, хватит, у нас проблемы.
func (c *SomeComponent) Start(ctx context.Context) { /*...*/ }
// ...
// ...ТУТ ОШИБКА
// ...
// ...
func (c *SomeComponent) Stop(ctx context.Context) { /*...*/ }
Хотелось бы, конечно, такой сценарий предусмотреть на уровне библиотеки.
Что предлагает uber.fx: есть зависимость Shutdowner, у которой есть метод, который инициирует остановку. Когда нужно, этот метод можно дернуть.
Это выглядит так, что компонент, которому дано право инициировать остановку приложения, надо подписывать на вызов этого метода.
fx.Invoke(
func(
webSrv fxshow.Weber,
shutdowner fx.Shutdowner,
) {
// ...
webSrv.OnFatalError(
func(err error) {
// ...
_ = shutdowner.Shutdown()
},
)
// ...
},
),
Метод, само собой, вызывает не просто os.Exit, а полноценную с точки зрения uber.fx процедуру остановки.
Это значит — вызовет все Stop-функции у всех компонентов, учитывая тайм-ауты и обработку ошибок.
Общий вид структуры
Вот мы собрали наше приложение, задекларировали все зависимости, шаги. Выглядеть это может вот так:
func main() {
fx.New(CreateApp()).Run()
}
func CreateApp() fx.Option {
c := fxshow.Config{}
// load config or something
return fx.Options(
fxAdapter.Full(),
fx.Supply(c),
fx.Provide(
fxshow.NewRepo1,
fxshow.NewRepo2,
fxshow.NewUsersRepo,
fx.Annotate(
fxshow.NewUsersRepo,
fx.As(new(fxshow.UserAccessor)),
),
fxshow.NewUserManager,
fxshow.NewCache,
fx.Annotate(
fxshow.NewCache,
fx.As(new(fxshow.Cacher)),
),
fxshow.NewDataCombiner,
fxshow.NewAuth,
fxshow.NewRouter,
fxshow.NewWeb,
),
fx.Invoke(
preloadCache,
printGraph,
registerRunners,
),
)
}
func preloadCache(cc fxshow.Cacher) error {
// ....
err := cc.PreloadCache()
if err != nil {
return err
}
// ....
return nil
}
func printGraph(diGraph fx.DotGraph) {
// ....
log.Println(diGraph)
// ....
}
func registerRunners(cc *fxshow.Cache, w *fxshow.Web, lc fx.Lifecycle) {
// ....
lc.Append(fxAdapter.ToHook(cc))
// ....
lc.Append(fxAdapter.ToHook(w))
}
Структура появилась, и ясно, что где подключать. Добавлю еще немного практических наблюдений по работе с библиотекой uber.fx.
Практика и бест-практисы
Не увлекайтесь лямбдами.
Вас может потянуть увлечься лямбдами, которые можно вставлять прямо в Provide, Invoke, регистрацию компонентов. В примерах и документации такого хватает.
В продакшене не стоит так делать. Лямбды не генерируют имена в логах, да и тесты писать на них не получится. Объявляйте функции.
Аналогично, в Provide лучше всего вставлять свои реальные конструкторы, не стоит их чем-то дополнительно оборачивать, даже из желания подстроиться под контейнер. Чисто и гладко спроектированный конструктор будет как есть работать в uber.fx.
Не регистрируйте литеральные типы.
Использовать можно, но неудобно. Рано или поздно обнаружатся два объекта с одним типом. Чтобы это решить, потянет попробовать именуемые инстансы, а это целая история, которая красивое наполнение контейнера усложняет настолько, что теряется его смысл.
Лучше заворачивайте экземпляр вашего литерального значения в объект — в структуру с полем.
Функции инициализации (fx.Invoke) не стоит сильно дробить.
Это верхнеуровневые, управляющие конструкции. Однострочные функции в «стейджах» — это обычно перебор. Задача — структурировать ваши шаги инициализации, то есть разделить простыню на понятные части.
А если увлекаться, то будет та же самая простыня, только из отдельных из функций.
Пишите юнит-тесты.
Тестируем в такой схеме ну в общем-то все. Контейнер, функции запуска и отдельные компоненты.
Тестируйте контейнер ValidateApp() стандартным способом, который я показывал:
func TestValidateApp(t *testing.T) {
err := fx.ValidateApp(CreateApp())
require.NoError(t, err)
}
Пишите тесты на «стейджи» — это чистые функции без сайд-эффектов (функциональное программирование!).
Тестируйте компоненты — конечно, сильно все упрощает, если у вас разрисованы интерфейсы, ну или используются какие-то другие подходы для моков.
Сравнение
Давайте взглянем на итоговую структуру и код и сравним, что у нас получается с uber.fx и без.
Вот скелет простого исходного приложения:
func main() {
// greeting
// load config or something
c := &fxshow.Config{}
// ....
// setup basic logging
logger := ktrace.NewFactory(golog.New(nil))
// create components
ur := fxshow.NewUsersRepo(logger, c.UsersRepo)
// .....
auth := fxshow.NewAuth(logger, c.Auth, ur)
// .....
// run something
err := auth.Start(context.TODO())
if err != nil {
panic(err)
}
// wait for stop
// .....
}
и вот как выглядит структурированный пример
func main() {
fx.New(CreateApp()).Run()
}
func CreateApp() fx.Option {
return fx.Options(
fxAdapter.Full(),
fx.Provide(
configuration,
fxshow.NewUsersRepo,
fx.Annotate(fxshow.NewUsersRepo, fx.As(new(fxshow.UserAccessor))),
fxshow.NewAuth,
fx.Annotate(fxshow.NewAuth, fxAdapter.TagRunner...),
),
fx.Invoke(
fxAdapter.RegisterRunners,
preloadCache,
),
)
}
и более полное объявление
func main() {
fx.New(CreateApp()).Run()
}
func CreateApp() fx.Option {
return fx.Options(
fxAdapter.Full(),
fx.Provide(
configuration,
fxshow.NewRepo1,
fxshow.NewRepo2,
fxshow.NewUsersRepo,
fx.Annotate(fxshow.NewUsersRepo, fx.As(new(fxshow.UserAccessor))),
fxshow.NewUserManager,
fxshow.NewCache,
fx.Annotate(fxshow.NewCache, fx.As(new(fxshow.Cacher))),
fx.Annotate(fxshow.NewCache, fxAdapter.TagRunner...),
fxshow.NewDataCombiner,
fxshow.NewAuth,
fx.Annotate(fxshow.NewAuth, fxAdapter.TagRunner...),
fxshow.NewRouter,
fxshow.NewWeb,
),
fx.Invoke(
fxAdapter.RegisterRunners,
preloadCache,
printGraph,
),
)
}
На самом деле они бывают и гораздо больше, но наглядности от этого не теряют.
Теперь сравним вообще всю структуру, опять реальный проект. Сенситивные места тут убраны, если что.
Надеюсь, увидели структуру.
Не хочу разочаровывать тех, кто думал, что кода будет меньше. Смысл тут не в этом. Напомню, что идея именно в структуризации: разделить код от общего к частному, добавить подсказок разработчикам, куда следует вносить изменения и новую функциональность, о чем не забывать.
Заключение
Давайте кратко напомню, о чем мы думаем, когда делаем чистый и красивый main и вообще сервис:
- иметь общий вид приложения в коде;
- декларативно регистрировать компоненты;
- отделять инициализацию от основной работы;
- оформлять «стейджи» инициализации приложения — разделять и властвовать;
- делать процедуры запуска и остановки в виде паттерна Start/Stop;
- обрабатывать сигнал на остановку, корректно почистив за собой в Stop;
- не забыть централизованно подключить логгер;
- использовать надо uber.fx!
>>>
Ну а если вам тоже интересны хорошие практики в Go, и вы хотите приложить руку к подобным изысканиям, приходите к нам в команду :)