Зависимости. Дженерики. Они часто звучат в списке проблем в Go сообществе, но есть одна проблема, о которой вспоминают довольно редко — организация кода вашего пакета. Каждое Go приложение, с которым я работал, похоже, имеет свой ответ на вопрос "Как я должен организовать код?". Некоторые приложения засовывают всё в один пакет, в то время, как другие группируют логику по типам или модулям. Без хорошей стратегии, которой придерживаются все члены команды, вы рано или поздно увидите, что код сильно разбросан по многочисленным пакетам. Нам нужен некий стандарт для дизайна кода в Go приложениях. Я предлагаю подход получше. Следуя набору простых правил, мы можем добиться того, что код будет несвязанным, легко тестируемым и структура проекта будет цельная. Но прежде, чем мы углубимся в детали, давайте посмотрим на наиболее часто используемые подходы к структуризации Go кода. Есть несколько наиболее частых подходов к организации Go кода, и у каждого из них есть свои недостатки. Размещать весь код в одном пакете на самом деле очень хорошо работает для небольших приложений. Это гарантирует, что вы не столкнетесь с проблемой круговых зависимостей, потому что, внутри вашего приложения, у вас, вообщем-то, никаких зависимостей и нет. По поему опыту, этот подход отлично работает для приложений размером до 10 тысяч строк кода. Дальше становится очень сложно разбираться и изолировать части кода монолита. Второй подход, это группировать код по его функциональному назначению. Например, все обработчики (handlers) идут в один пакет, контроллеры в другой, а модели в третий. Я видел такой подход много раз у бывших Rails-разработчиков (включая меня самого). Но с этим подходом есть две проблемы. Во-первых, вы получаете чудовищные имена. У вас будут имена вроде controller.UserController, в которых вы дублируете имя пакета в имени типа. Я вообще считаю себя ярым сторонником внимательного подхода к именам. Я уверен, что хорошие имена — это ваша наилучшая документация, особенно когда вы пробираетесь сквозь чащи кода. Имена также часто выступают показателем качества кода — это первое, что другой программист увидит, когда впервые столкнется с вашим кодом. Но наибольшая проблема, на самом деле, это круговые зависимости. Ваши функциональные типы, разнесенные по пакетам, могут понадобиться друг-другу. И это будет работать только в случае, если эти зависимости односторонние, но в большинстве случаев ваше приложение будет посложнее. Этот подход похож на предыдущий, с тем исключением, что мы группируем код по модулям, а не по функциям. Например, вы можете разделить код так, что будете иметь пакеты user и accounts. Тут мы имеем те же проблемы. Опять же, ужасные имена вроде users.User и та же проблема с круговыми зависимостями, когда наш accounts.Controller должен взаимодействовать с users.Controller и наоборот. Стратегия по организации кода, которую использую я в своих проектах, включает 4 принципа: Эти правила помогают изолировать пакеты и установить четкий язык внутри приложения. Давайте посмотрим, как каждый из этих пунктов работает на практике. У вашего приложения есть логичный высокоуровневый язык, который описывает взаимодействие данных и процессов. Это и есть ваш домен. Если вы пишете e-commerce приложение, то ваш домен будет включать такие понятия, как клиенты, счета, списание с кредитных карт и инвентаризация. Если вы это Facebook, то ваш домен — пользователи, лайки и взаимосвязи. Другими словами, это то, что не зависит от выбранной технологии. Я располагаю доменные типы в главном, корневом (root) пакете. Этот пакет содержит только простые типы данных вроде User, в котором есть только данные о пользователе или UserService интерфейс для сохранения и запроса пользовательских данных. Это может выглядеть примерно так: Это делает ваш корневой пакет исключительно простым. В него можно также включать типы, которые совершают какие-то действия, но только если они зависят полностью от других доменных типов. Например, вы можете добавить тип, который периодически опрашивает ваш UserService. Но он не должен делать вызовы к внешним сервисам или сохранять в базу данных. Это детали реализации. Корневой пакет не должен зависеть от других пакаетов внутри вашего приложения! Поскольку в корневом пакете не разрешено иметь внешние зависимости, то мы должны вынести эти зависимости в другие вложенные пакеты(subpackages). В этом подходе вложеные пакеты существуют как адаптер между вашим доменом и реализацией. Например, ваш UserService может быть реализован в виде PostgreSQL базы данных. Вы можете добавить в приложение пакет postgres, который предоставляет реализацию postgres.UserService: Это полностью изолирует зависимость от PostgreSQL, что сильно упрощает тестирование и открывает простой способ мигрировать на другую базу данных в будущем. Это также позволяет создать динамически изменяемую архитектуру, если в будущем вы захотите поддерживать ещё и другие реализации, например на BoltDB. Также это даёт возможность создания слоёв реализаций. Например, вы хотите добавить LRU-кеш в памяти перед вашей PostgreSQL реализацией. Тогда вы просто добавляете UserCache, который реализует UserService, и в который вы заворачиваете вашу PostgreSQL реализацию: Мы можем увидеть этот подход также в стандартной библиотеке. io.Reader — это доменный тип для чтения байт, а его реализации сгруппированы по зависимостям — tar.Reader, gzip.Reader, multipart.Reader. И они также могут использоваться в несколько слоёв. Нередко можно увидеть os.File, обёрнутый bufio.Reader, который обёрнут в gzip.Reader, который, в свою очередь, обёрнут в tar.Reader. Ваши зависимости обычно не живут сами по себе. Вы можете хотеть хранить данные о пользователях в PostgreSQL, но финансовые данные о транзакциях могут быть во внешнем сервисе вроде Stripe. В этом случае мы заворачиваем нашу зависимость от Stripe в логический доменный тип — назовём его TransactionService. Добавляя наш TransactionService к UserService мы развязываем(decouple) наши две зависимости: Теперь наши зависимости общаются исключительно с помощью нашего доменного языка. Это означает, что мы можем переключиться с PostgreSQL на MySQL или перейти со Stripe на другой обработчик платежей и ничего не придётся изменять в зависимостях. Это может прозвучать странно, но я также изолирую зависимости от стандартной библиотеки таким же методом. Например, пакет net/http это всего лишь ещё одна зависимость. Мы можем её изолировать, добавив вложенный пакет http в приложение. Это может выглядеть странно иметь пакет с тем же самым именем, что и в стандартной библиотеке, но это сделанно намеренно. У вас не будет конфликтов имён, если вы не используете net/http в других местах вашего приложения. Выгода от дублирования имени будет в том, что вы изолируете весь HTTP код внутри вашего http пакета: Теперь ваш http.Handler работает как адаптер между вашим доменом и HTTP протоколом. Посколько наши зависимости изолированы от других посредством доменных типов и интерфейсов, мы можем использовать эти точки соприкосновения для внедрения заглушек(mock). Есть несколько библиотек для заглушек, вроде GoMock, которые сгенерируют код за вас, но я лично предпочитаю писать их лично. Мне кажется, большая часть инструментов для заглушек неоправданно усложнены. Заглушки, которые я использую обычно очень просты. Например, заглушка для UserService выглядит вот так: Такая заглушка позволяет мне внедрять функции во все места, в которых используется myapp.UserService интерфейс, чтобы проверять аргументы. возвращать ожидаемые значения или внедрять ошибочные данные. Допустим, мы хотим протестировать наш http.Handler, который мы добавили чуть выше: Наша заглушка позволила полностью изолировать этот unit-тест и протестировать только часть HTTP протокола. Со всеми этими пакетами с зависимостями, вы можете спросить, как они все объединяются. И это, как раз, работа для пакета main. Приложение может состоять из нескольких бинарных исполняемых файлов, поэтому мы будем использовать стандартное соглашение в Go о расположении main пакета в поддиректории cmd/. Например, наш проект может иметь исполняемый файл сервера myapp, плюс дополнительный бинарник myappctl для управление сервером из терминала. Мы располагаем файл следующим образом: Термин "внедрение зависимостей" (dependency injection) получил плоху репутацию. Обычно люди сразу начинают думать про XML-файлы Spring-а. Но на самом деле, этот термин означает, что мы передаём зависимости объекту, а не объект ищет их сам. Пакет main это как раз место, где происходит выбор того, какие зависимости внедрять в какие объекты. Поскольку этот пакет обычно просто соединяет между собой различные куски приложения, обычно это довольно небольшой и простой код: Также важно понимать, что пакет main является тоже адаптером. Он соединяет терминал с вашим доменом. Дизайн приложения это сложная проблема. Нужно принимать массу решений и без хорошего набора принципов, проблема становится ещё хуже. Мы посмотрели на несколько подходов к структуризации Go приложений и рассмотрели их недостатки. Я уверен, что организация кода, основанная на зависимостях, облегчает дизайн и делает код более понятным. Сначала мы определяем язык нашего домена. Затем, изолируем зависимости. Далее создаём заглушки для тестов. И в конце, склеиваем это всё вместе с помощью пакета main. Посмотрите на такой подход в следующем вашем приложении. Если у вас есть вопросы или вы хотите обсудить дизайн приложений, я доступен в Твиттере — @benbjohnson или как benbjohnson в Slack-канале по Go.