Зависимости. Дженерики. Они часто звучат в списке проблем в Go сообществе, но есть одна проблема, о которой вспоминают довольно редко — организация кода вашего пакета.
Каждое Go приложение, с которым я работал, похоже, имеет свой ответ на вопрос "Как я должен организовать код?". Некоторые приложения засовывают всё в один пакет, в то время, как другие группируют логику по типам или модулям. Без хорошей стратегии, которой придерживаются все члены команды, вы рано или поздно увидите, что код сильно разбросан по многочисленным пакетам. Нам нужен некий стандарт для дизайна кода в Go приложениях.
Я предлагаю подход получше. Следуя набору простых правил, мы можем добиться того, что код будет несвязанным, легко тестируемым и структура проекта будет цельная. Но прежде, чем мы углубимся в детали, давайте посмотрим на наиболее часто используемые подходы к структуризации Go кода.
Наиболее частые неправильные подходы
Есть несколько наиболее частых подходов к организации Go кода, и у каждого из них есть свои недостатки.
Подход #1: Монолит
Размещать весь код в одном пакете на самом деле очень хорошо работает для небольших приложений. Это гарантирует, что вы не столкнетесь с проблемой круговых зависимостей, потому что, внутри вашего приложения, у вас, вообщем-то, никаких зависимостей и нет.
По поему опыту, этот подход отлично работает для приложений размером до 10 тысяч строк кода. Дальше становится очень сложно разбираться и изолировать части кода монолита.
Подход #2: стиль Rails
Второй подход, это группировать код по его функциональному назначению. Например, все обработчики (handlers) идут в один пакет, контроллеры в другой, а модели в третий. Я видел такой подход много раз у бывших Rails-разработчиков (включая меня самого).
Но с этим подходом есть две проблемы. Во-первых, вы получаете чудовищные имена. У вас будут имена вроде controller.UserController
, в которых вы дублируете имя пакета в имени типа. Я вообще считаю себя ярым сторонником внимательного подхода к именам. Я уверен, что хорошие имена — это ваша наилучшая документация, особенно когда вы пробираетесь сквозь чащи кода. Имена также часто выступают показателем качества кода — это первое, что другой программист увидит, когда впервые столкнется с вашим кодом.
Но наибольшая проблема, на самом деле, это круговые зависимости. Ваши функциональные типы, разнесенные по пакетам, могут понадобиться друг-другу. И это будет работать только в случае, если эти зависимости односторонние, но в большинстве случаев ваше приложение будет посложнее.
Подход #3: Группировка по модулю
Этот подход похож на предыдущий, с тем исключением, что мы группируем код по модулям, а не по функциям. Например, вы можете разделить код так, что будете иметь пакеты user
и accounts
.
Тут мы имеем те же проблемы. Опять же, ужасные имена вроде users.User
и та же проблема с круговыми зависимостями, когда наш accounts.Controller
должен взаимодействовать с users.Controller
и наоборот.
Лучший подход
Стратегия по организации кода, которую использую я в своих проектах, включает 4 принципа:
- Корневой (главный) пакет для доменных типов
- Группировка пакетов по зависимостям
- Использование общего
mock
пакета - Пакет
main
объединяет вместе зависимости
Эти правила помогают изолировать пакеты и установить четкий язык внутри приложения. Давайте посмотрим, как каждый из этих пунктов работает на практике.
1. Корневой пакет для доменных типов
У вашего приложения есть логичный высокоуровневый язык, который описывает взаимодействие данных и процессов. Это и есть ваш домен. Если вы пишете e-commerce приложение, то ваш домен будет включать такие понятия, как клиенты, счета, списание с кредитных карт и инвентаризация. Если вы это Facebook, то ваш домен — пользователи, лайки и взаимосвязи. Другими словами, это то, что не зависит от выбранной технологии.
Я располагаю доменные типы в главном, корневом (root) пакете. Этот пакет содержит только простые типы данных вроде User, в котором есть только данные о пользователе или UserService интерфейс для сохранения и запроса пользовательских данных.
Это может выглядеть примерно так:
package myapp
type User struct {
ID int
Name string
Address Address
}
type UserService interface {
User(id int) (*User, error)
Users() ([]*User, error)
CreateUser(u *User) error
DeleteUser(id int) error
}
Это делает ваш корневой пакет исключительно простым. В него можно также включать типы, которые совершают какие-то действия, но только если они зависят полностью от других доменных типов. Например, вы можете добавить тип, который периодически опрашивает ваш UserService. Но он не должен делать вызовы к внешним сервисам или сохранять в базу данных. Это детали реализации.
Корневой пакет не должен зависеть от других пакаетов внутри вашего приложения!
2. Группировка пакетов по зависимостям
Поскольку в корневом пакете не разрешено иметь внешние зависимости, то мы должны вынести эти зависимости в другие вложенные пакеты(subpackages). В этом подходе вложеные пакеты существуют как адаптер между вашим доменом и реализацией.
Например, ваш UserService может быть реализован в виде PostgreSQL базы данных. Вы можете добавить в приложение пакет postgres, который предоставляет реализацию postgres.UserService:
package postgres
import (
"database/sql"
"http://ift.tt/2bMUfnK"
_ "github.com/lib/pq"
)
// UserService represents a PostgreSQL implementation of myapp.UserService.
type UserService struct {
DB *sql.DB
}
// User returns a user for a given id.
func (s *UserService) User(id int) (*myapp.User, error) {
var u myapp.User
row := db.QueryRow(`SELECT id, name FROM users WHERE id = $1`, id)
if row.Scan(&u.ID, &u.Name); err != nil {
return nil, err
}
return &u, nil
}
// implement remaining myapp.UserService interface...
Это полностью изолирует зависимость от PostgreSQL, что сильно упрощает тестирование и открывает простой способ мигрировать на другую базу данных в будущем. Это также позволяет создать динамически изменяемую архитектуру, если в будущем вы захотите поддерживать ещё и другие реализации, например на BoltDB.
Также это даёт возможность создания слоёв реализаций. Например, вы хотите добавить LRU-кеш в памяти перед вашей PostgreSQL реализацией. Тогда вы просто добавляете UserCache, который реализует UserService, и в который вы заворачиваете вашу PostgreSQL реализацию:
package myapp
// UserCache wraps a UserService to provide an in-memory cache.
type UserCache struct {
cache map[int]*User
service UserService
}
// NewUserCache returns a new read-through cache for service.
func NewUserCache(service UserService) *UserCache {
return &UserCache{
cache: make(map[int]*User),
service: service,
}
}
// User returns a user for a given id.
// Returns the cached instance if available.
func (c *UserCache) User(id int) (*User, error) {
// Check the local cache first.
if u := c.cache[id]]; u != nil {
return u, nil
}
// Otherwise fetch from the underlying service.
u, err := c.service.User(id)
if err != nil {
return nil, err
} else if u != nil {
c.cache[id] = u
}
return u, err
}
Мы можем увидеть этот подход также в стандартной библиотеке. io.Reader — это доменный тип для чтения байт, а его реализации сгруппированы по зависимостям — tar.Reader, gzip.Reader, multipart.Reader. И они также могут использоваться в несколько слоёв. Нередко можно увидеть os.File, обёрнутый bufio.Reader, который обёрнут в gzip.Reader, который, в свою очередь, обёрнут в tar.Reader.
Зависимости между зависимостями
Ваши зависимости обычно не живут сами по себе. Вы можете хотеть хранить данные о пользователях в PostgreSQL, но финансовые данные о транзакциях могут быть во внешнем сервисе вроде Stripe. В этом случае мы заворачиваем нашу зависимость от Stripe в логический доменный тип — назовём его TransactionService
.
Добавляя наш TransactionService
к UserService
мы развязываем(decouple) наши две зависимости:
type UserService struct {
DB *sql.DB
TransactionService myapp.TransactionService
}
Теперь наши зависимости общаются исключительно с помощью нашего доменного языка. Это означает, что мы можем переключиться с PostgreSQL на MySQL или перейти со Stripe на другой обработчик платежей и ничего не придётся изменять в зависимостях.
Не ограничивайтесь только внешними зависимостями
Это может прозвучать странно, но я также изолирую зависимости от стандартной библиотеки таким же методом. Например, пакет net/http
это всего лишь ещё одна зависимость. Мы можем её изолировать, добавив вложенный пакет http
в приложение.
Это может выглядеть странно иметь пакет с тем же самым именем, что и в стандартной библиотеке, но это сделанно намеренно. У вас не будет конфликтов имён, если вы не используете net/http в других местах вашего приложения. Выгода от дублирования имени будет в том, что вы изолируете весь HTTP код внутри вашего http пакета:
package http
import (
"net/http"
"http://ift.tt/2bMUfnK"
)
type Handler struct {
UserService myapp.UserService
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// handle request
}
Теперь ваш http.Handler работает как адаптер между вашим доменом и HTTP протоколом.
3. Использование общего mock
пакета
Посколько наши зависимости изолированы от других посредством доменных типов и интерфейсов, мы можем использовать эти точки соприкосновения для внедрения заглушек(mock).
Есть несколько библиотек для заглушек, вроде GoMock, которые сгенерируют код за вас, но я лично предпочитаю писать их лично. Мне кажется, большая часть инструментов для заглушек неоправданно усложнены.
Заглушки, которые я использую обычно очень просты. Например, заглушка для UserService выглядит вот так:
package mock
import "http://ift.tt/2bMUfnK"
// UserService represents a mock implementation of myapp.UserService.
type UserService struct {
UserFn func(id int) (*myapp.User, error)
UserInvoked bool
UsersFn func() ([]*myapp.User, error)
UserInvoked bool
// additional function implementations...
}
// User invokes the mock implementation and marks the function as invoked.
func (s *UserService) User(id int) (*myapp.User, error) {
s.UserInvoked = true
return s.UserFn(id)
}
// additional functions: Users(), CreateUser(), DeleteUser()
Такая заглушка позволяет мне внедрять функции во все места, в которых используется myapp.UserService
интерфейс, чтобы проверять аргументы. возвращать ожидаемые значения или внедрять ошибочные данные.
Допустим, мы хотим протестировать наш http.Handler, который мы добавили чуть выше:
package http_test
import (
"testing"
"net/http"
"net/http/httptest"
"http://ift.tt/2be4tKX"
)
func TestHandler(t *testing.T) {
// Inject our mock into our handler.
var us mock.UserService
var h Handler
h.UserService = &us
// Mock our User() call.
us.UserFn = func(id int) (*myapp.User, error) {
if id != 100 {
t.Fatalf("unexpected id: %d", id)
}
return &myapp.User{ID: 100, Name: "susy"}, nil
}
// Invoke the handler.
w := httptest.NewRecorder()
r, _ := http.NewRequest("GET", "/users/100", nil)
h.ServeHTTP(w, r)
// Validate mock.
if !us.UserInvoked {
t.Fatal("expected User() to be invoked")
}
}
Наша заглушка позволила полностью изолировать этот unit-тест и протестировать только часть HTTP протокола.
4. Пакет main
объединяет вместе зависимости
Со всеми этими пакетами с зависимостями, вы можете спросить, как они все объединяются. И это, как раз, работа для пакета main
.
Организация пакета main
Приложение может состоять из нескольких бинарных исполняемых файлов, поэтому мы будем использовать стандартное соглашение в Go о расположении main пакета в поддиректории cmd/. Например, наш проект может иметь исполняемый файл сервера myapp
, плюс дополнительный бинарник myappctl
для управление сервером из терминала. Мы располагаем файл следующим образом:
myapp/
cmd/
myapp/
main.go
myappctl/
main.go
Внедрение зависимостей во время компиляции
Термин "внедрение зависимостей" (dependency injection) получил плоху репутацию. Обычно люди сразу начинают думать про XML-файлы Spring-а. Но на самом деле, этот термин означает, что мы передаём зависимости объекту, а не объект ищет их сам.
Пакет main
это как раз место, где происходит выбор того, какие зависимости внедрять в какие объекты. Поскольку этот пакет обычно просто соединяет между собой различные куски приложения, обычно это довольно небольшой и простой код:
package main
import (
"log"
"os"
"http://ift.tt/2bMUfnK"
"http://ift.tt/2bMTAmf"
"github.com/benbjohnson/myapp/http"
)
func main() {
// Connect to database.
db, err := postgres.Open(os.Getenv("DB"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Create services.
us := &postgres.UserService{DB: db}
// Attach to HTTP handler.
var h http.Handler
h.UserService = us
// start http server...
}
Также важно понимать, что пакет main является тоже адаптером. Он соединяет терминал с вашим доменом.
Заключение
Дизайн приложения это сложная проблема. Нужно принимать массу решений и без хорошего набора принципов, проблема становится ещё хуже. Мы посмотрели на несколько подходов к структуризации Go приложений и рассмотрели их недостатки.
Я уверен, что организация кода, основанная на зависимостях, облегчает дизайн и делает код более понятным. Сначала мы определяем язык нашего домена. Затем, изолируем зависимости. Далее создаём заглушки для тестов. И в конце, склеиваем это всё вместе с помощью пакета main.
Посмотрите на такой подход в следующем вашем приложении. Если у вас есть вопросы или вы хотите обсудить дизайн приложений, я доступен в Твиттере — @benbjohnson или как benbjohnson в Slack-канале по Go.
Комментарии (0)