Привет, Хабр! Не так давно у нас вышла новая книга по Golang, и успех ее настолько впечатляет, что мы решили опубликовать здесь очень важную статью о подходах к проектированию приложений на Go. Идеи, изложенные в статье, очевидно не устареют в обозримом будущем. Возможно, автору даже удалось предвосхитить некоторые гайдлайны по работе с Go, которые могут войти в широкую практику в ближайшем будущем.
Язык Go был впервые анонсирован в конце 2009 года, а официальный релиз состоялся в 2012 году, но лишь в последние несколько лет стал приобретать серьезное признание. Go был одним из наиболее быстрорастущих языков в 2018 году и третьим по востребованности языком программирования в 2019 году.
Поскольку сам язык Go достаточно новый, в сообществе разработчиков не слишком строго формулируют рекомендации по написанию кода. Если рассмотреть аналогичные соглашения, действующие в сообществах более древних языков, например, Java, то выяснится, что большинство проектов имеет схожую структуру. Это может очень пригодиться, когда пишешь большие базы кода, однако, многие могли бы настаивать, что в современных практических контекстах это было бы контрпродуктивно. По мере того, как мы переходим к написанию микросистем и поддержке сравнительно компактных баз кода, гибкость Go в области структурирования проектов становится весьма привлекательной.
Всем известен пример с hello world http на Golang, и его можно сравнить с аналогичными примерами на других языках, например, на Java. Между первым и вторым не заметно существенной разницы ни в сложности, ни в количестве кода, который нужно написать для реализации примера. Но видна фундаментальная разница в подходе. Go стимулирует нас действовать по принципу «пиши простой код, когда это только возможно». Если абстрагироваться от объектно-ориентированных аспектов Java, то, думаю, наиболее важный вывод из этих фрагментов кода заключается в следующем: Java требует создавать отдельный экземпляр для каждой операции (экземпляр
Таким образом, вам придется поддерживать меньше кода, передавать в нем меньше ссылок. Если вы знаете, что вам придется создать всего один сервер (а так обычно и бывает), то зачем же утруждаться лишнего? Такая философия кажется все более веской по мере того, как растет ваша база кода. Тем не менее, жизнь иногда подбрасывает сюрпризы :(. Дело в том, что на выбор вам все равно остается несколько уровней абстрагирования, и, если неправильно их комбинировать, то можно самому себе понаставить серьезных капканов.
Именно поэтому я хочу заострить ваше внимание на трех подходах к организации и структурированию кода на Go. В каждом из этих подходов подразумевается свой уровень абстрагирования. В заключение статьи я сравню все три и расскажу, в каких прикладных случаях наиболее уместен каждый из этих подходов.
Мы собираемся реализовать HTTP-сервер, на котором содержится информация о пользователях (на следующем рисунке обозначен как Main DB), где каждому пользователю присвоена роль (допустим, базовый, модератор, администратор), а также реализовать дополнительную базу данных (на следующем рисунке обозначена как Configuration DB), где указаны совокупности прав доступа, отведенные для каждой из ролей (напр., чтение, запись, редактирование). Наш HTTP-сервер должен реализовывать конечную точку, возвращающую набор прав доступа, которыми обладает пользователь с заданным ID.
Далее давайте предположим, что конфигурационная база данных меняется редко, и на ее загрузку требуется много времени, поэтому мы собираемся держать ее в оперативной памяти, загружать вместе с запуском сервера и обновлять ежечасно.
Весь код находится в репозитории к этой статье, расположенном на GitHub.
В подходе с единственным пакетом используется одноуровневая иерархия, где весь сервер реализован в рамках одного пакета. Весь код.
Обратите внимание: мы все равно используем разные файлы, это делается для разделения ответственности. Так код получается более удобочитаемым и более удобным в поддержке.
В этом подходе давайте узнаем, что такое работа с пакетами. Пакет должен единолично отвечать за некоторое определенное поведение. Здесь мы позволяем пакетам взаимодействовать друг с другом – таким образом, нам приходится поддерживать меньше кода. Тем не менее, необходимо убедиться, что мы не нарушаем принцип единственной ответственности, и поэтому гарантировать, что каждая часть логики полностью реализована в отдельном пакете. Еще одна важная рекомендация при данном подходе такова: поскольку в Go не допускаются кольцевые зависимости между пакетами, необходимо создать нейтральный пакет, в котором содержатся лишь голые определения интерфейсов и экземпляров синглтона. Так мы избавимся от кольцевых зависимостей. Весь код.
При данном подходе проект также организуется в виде пакетов. В данном случае каждый пакет должен интегрировать все свои зависимости локально, через интерфейсы и переменные. Таким образом, он совершенно ничего не знает о других пакетах. При таком подходе пакет с определениями, упоминавшийся в предыдущем подходе, фактически будет размазан между всеми остальными пакетами; каждый пакет объявляет собственный интерфейс для каждого сервиса. На первый взгляд это может показаться назойливым дублированием, но на самом деле это не так. Каждый пакет, использующий сервис, должен объявить собственный интерфейс, в котором указано лишь то, что ему нужно от этого сервиса, и ничего больше. Весь код.
Вот и все! Мы рассмотрели три уровня абстрагирования, первый из которых самый тонкий, содержащий глобальное состояние и сильно связанную логику, но обеспечивает самую быструю реализацию, а также обойтись минимумом кода, который потребуется писать и поддерживать. Второй вариант – умеренно-гибридный, а третий совершенно самодостаточен и подходит для многократного использования, но сопряжен с максимальными усилиями при поддержке.
Подход I: Единственный пакет
За
Против
Подход II: Спаренные пакеты
За
Против
Подход III: Независимые пакеты
За
Против
Учитывая недостаток гайдлайнов по написанию кода в Go, он принимает самые разные очертания и формы, и у каждого варианта есть свои интересные достоинства. Однако, при смешивании различных паттернов проектирования могут возникать проблемы. Чтобы дать представление о них, я рассказал о трех различных подходах к написанию и структурированию кода на Go.
Итак, когда же должен использоваться каждый из подходов? Предлагаю такую расстановку:
Подход I: Подход с единственным пакетом, пожалуй, наиболее уместен при работе в небольших многоопытных командах, занятых на малых проектах, где требуется быстро достигать результата. Такой подход проще и надежнее для быстрого старта, хотя, требует серьезного внимания и координации на этапе поддержки проекта.
Подход II: Подход со спаренными пакетами можно назвать гибридным синтезом двух других подходов: среди его преимуществ – относительно быстрый старт и легкость при поддержке и, в то же время, здесь создаются условия для строгого соблюдения правил. Он уместен в сравнительно крупных проектах и больших командах, но в нем ограничены возможности переиспользования кода и существуют определенные сложности при поддержке.
Подход III: Подход с независимыми пакетами наиболее уместен в тех проектах, которые сложны сами по себе, являются долгосрочными, разрабатываются большими командами, а также для проектов, в которых имеются фрагменты логики, создаваемые с прицелом на дальнейшее переиспользование. На внедрение такого подхода требуется много времени, также он непрост в поддержке.
Язык Go был впервые анонсирован в конце 2009 года, а официальный релиз состоялся в 2012 году, но лишь в последние несколько лет стал приобретать серьезное признание. Go был одним из наиболее быстрорастущих языков в 2018 году и третьим по востребованности языком программирования в 2019 году.
Поскольку сам язык Go достаточно новый, в сообществе разработчиков не слишком строго формулируют рекомендации по написанию кода. Если рассмотреть аналогичные соглашения, действующие в сообществах более древних языков, например, Java, то выяснится, что большинство проектов имеет схожую структуру. Это может очень пригодиться, когда пишешь большие базы кода, однако, многие могли бы настаивать, что в современных практических контекстах это было бы контрпродуктивно. По мере того, как мы переходим к написанию микросистем и поддержке сравнительно компактных баз кода, гибкость Go в области структурирования проектов становится весьма привлекательной.
Всем известен пример с hello world http на Golang, и его можно сравнить с аналогичными примерами на других языках, например, на Java. Между первым и вторым не заметно существенной разницы ни в сложности, ни в количестве кода, который нужно написать для реализации примера. Но видна фундаментальная разница в подходе. Go стимулирует нас действовать по принципу «пиши простой код, когда это только возможно». Если абстрагироваться от объектно-ориентированных аспектов Java, то, думаю, наиболее важный вывод из этих фрагментов кода заключается в следующем: Java требует создавать отдельный экземпляр для каждой операции (экземпляр
HttpServer
), тогда как Go стимулирует нас использовать глобальный синглтон.Таким образом, вам придется поддерживать меньше кода, передавать в нем меньше ссылок. Если вы знаете, что вам придется создать всего один сервер (а так обычно и бывает), то зачем же утруждаться лишнего? Такая философия кажется все более веской по мере того, как растет ваша база кода. Тем не менее, жизнь иногда подбрасывает сюрпризы :(. Дело в том, что на выбор вам все равно остается несколько уровней абстрагирования, и, если неправильно их комбинировать, то можно самому себе понаставить серьезных капканов.
Именно поэтому я хочу заострить ваше внимание на трех подходах к организации и структурированию кода на Go. В каждом из этих подходов подразумевается свой уровень абстрагирования. В заключение статьи я сравню все три и расскажу, в каких прикладных случаях наиболее уместен каждый из этих подходов.
Мы собираемся реализовать HTTP-сервер, на котором содержится информация о пользователях (на следующем рисунке обозначен как Main DB), где каждому пользователю присвоена роль (допустим, базовый, модератор, администратор), а также реализовать дополнительную базу данных (на следующем рисунке обозначена как Configuration DB), где указаны совокупности прав доступа, отведенные для каждой из ролей (напр., чтение, запись, редактирование). Наш HTTP-сервер должен реализовывать конечную точку, возвращающую набор прав доступа, которыми обладает пользователь с заданным ID.
Далее давайте предположим, что конфигурационная база данных меняется редко, и на ее загрузку требуется много времени, поэтому мы собираемся держать ее в оперативной памяти, загружать вместе с запуском сервера и обновлять ежечасно.
Весь код находится в репозитории к этой статье, расположенном на GitHub.
Подход I: Единственный пакет
В подходе с единственным пакетом используется одноуровневая иерархия, где весь сервер реализован в рамках одного пакета. Весь код.
Внимание: комментарии в коде информативны, важны для понимания принципов каждого подхода.
/main.go
package main
import (
"net/http"
)
// Как было указано выше, поскольку у нас планируется всего по одному экземпляру
// на эти три сервиса, мы объявим экземпляры-синглтоны,
// и убедимся, что пользуемся ими только для доступа к этим сервисам.
var (
userDBInstance userDB
configDBInstance configDB
rolePermissions map[string][]string
)
func main() {
// Предполагается, что далее наши экземпляры синглтонов будут
// инициализироваться, и отвечает за их инициализацию
// инициатор.
// Главная функция будет проделывать это над конкретной
// реализацией, а тестовые кейсы, если мы планируем их иметь,
// могут пользоваться сымитированной реализацией.
userDBInstance = &someUserDB{}
configDBInstance = &someConfigDB{}
initPermissions()
http.HandleFunc("/", UserPermissionsByID)
http.ListenAndServe(":8080", nil)
}
// Таким образом права доступа, хранящиеся в памяти, будут оставаться актуальными.
func initPermissions() {
rolePermissions = configDBInstance.allPermissions()
go func() {
for {
time.Sleep(time.Hour)
rolePermissions = configDBInstance.allPermissions()
}
}()
}
/database.go
package main
// Мы используем интерфейсы в качестве типов экземпляров нашей базы данных,
// чтобы можно было писать тесты и использовать имитационные реализации.
type userDB interface {
userRoleByID(id string) string
}
// Обратите внимание на именование `someConfigDB`. В конкретных случаях мы
// используем некоторую реализацию БД и соответственно именуем наши структуры
// Например, при использовании MongoDB, мы назовем нашу конкретную структуру
// `mongoConfigDB`. При работе с тестовыми кейсами также может быть объявлена
// имитационная реализация `mockConfigDB`.
type someUserDB struct {}
func (db *someUserDB) userRoleByID(id string) string {
// Для ясности опускаем детали реализации...
}
type configDB interface {
allPermissions() map[string][]string // отображается с роли на ее права доступа
}
type someConfigDB struct {}
func (db *someConfigDB) allPermissions() map[string][]string {
// реализация
}
/handler.go
package main
import (
"fmt"
"net/http"
"strings"
)
func UserPermissionsByID(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query()["id"][0]
role := userDBInstance.userRoleByID(id)
permissions := rolePermissions[role]
fmt.Fprint(w, strings.Join(permissions, ", "))
}
Обратите внимание: мы все равно используем разные файлы, это делается для разделения ответственности. Так код получается более удобочитаемым и более удобным в поддержке.
Подход II: Парные пакеты
В этом подходе давайте узнаем, что такое работа с пакетами. Пакет должен единолично отвечать за некоторое определенное поведение. Здесь мы позволяем пакетам взаимодействовать друг с другом – таким образом, нам приходится поддерживать меньше кода. Тем не менее, необходимо убедиться, что мы не нарушаем принцип единственной ответственности, и поэтому гарантировать, что каждая часть логики полностью реализована в отдельном пакете. Еще одна важная рекомендация при данном подходе такова: поскольку в Go не допускаются кольцевые зависимости между пакетами, необходимо создать нейтральный пакет, в котором содержатся лишь голые определения интерфейсов и экземпляров синглтона. Так мы избавимся от кольцевых зависимостей. Весь код.
/main.go
package main
// Обратите внимание: пакет main – единственный, импортирующий
// другие пакеты сверх пакета с определениями.
import (
"github.com/myproject/config"
"github.com/myproject/database"
"github.com/myproject/definition"
"github.com/myproject/handler"
"net/http"
)
func main() {
// В данном подходе также используются экземпляры синглтона, и,
// опять же, инициатор отвечает за то, чтобы они
// были инициализированы.
definition.UserDBInstance = &database.SomeUserDB{}
definition.ConfigDBInstance = &database.SomeConfigDB{}
config.InitPermissions()
http.HandleFunc("/", handler.UserPermissionsByID)
http.ListenAndServe(":8080", nil)
}
/definition/database.go
package definition
// Обратите внимание, что при данном подходе и экземпляр синглтона,
// и тип его интерфейса объявляются в пакете с определениями.
// Убедитесь, что в этом пакете не содержится никакой логики; в
// противном случае в него, возможно, потребуется импортировать другие пакеты,
// и его нейтральная суть будет нарушена.
var (
UserDBInstance UserDB
ConfigDBInstance ConfigDB
)
type UserDB interface {
UserRoleByID(id string) string
}
type ConfigDB interface {
AllPermissions() map[string][]string // отображение с роли на права доступа
}
/definition/config.go
package definition
var RolePermissions map[string][]string
/database/user.go
package database
type SomeUserDB struct{}
func (db *SomeUserDB) UserRoleByID(id string) string {
// реализация
}
/database/config.go
package database
type SomeConfigDB struct{}
func (db *SomeConfigDB) AllPermissions() map[string][]string {
// реализация
}
/config/permissions.go
package config
import (
"github.com/myproject/definition"
"time"
)
// Поскольку пакет с определениями не должен содержать никакой логики,
// управление конфигурацией реализуется в пакете config.
func InitPermissions() {
definition.RolePermissions = definition.ConfigDBInstance.AllPermissions()
go func() {
for {
time.Sleep(time.Hour)
definition.RolePermissions = definition.ConfigDBInstance.AllPermissions()
}
}()
}
/handler/user_permissions_by_id.go
package handler
import (
"fmt"
"github.com/myproject/definition"
"net/http"
"strings"
)
func UserPermissionsByID(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query()["id"][0]
role := definition.UserDBInstance.UserRoleByID(id)
permissions := definition.RolePermissions[role]
fmt.Fprint(w, strings.Join(permissions, ", "))
}
Подход III: Независимые пакеты
При данном подходе проект также организуется в виде пакетов. В данном случае каждый пакет должен интегрировать все свои зависимости локально, через интерфейсы и переменные. Таким образом, он совершенно ничего не знает о других пакетах. При таком подходе пакет с определениями, упоминавшийся в предыдущем подходе, фактически будет размазан между всеми остальными пакетами; каждый пакет объявляет собственный интерфейс для каждого сервиса. На первый взгляд это может показаться назойливым дублированием, но на самом деле это не так. Каждый пакет, использующий сервис, должен объявить собственный интерфейс, в котором указано лишь то, что ему нужно от этого сервиса, и ничего больше. Весь код.
/main.go
package main
// Обратите внимание: главный пакет – единственный, импортирующий
// другие локальные пакеты.
import (
"github.com/myproject/config"
"github.com/myproject/database"
"github.com/myproject/handler"
"net/http"
)
func main() {
userDB := &database.SomeUserDB{}
configDB := &database.SomeConfigDB{}
permissionStorage := config.NewPermissionStorage(configDB)
h := &handler.UserPermissionsByID{UserDB: userDB, PermissionsStorage: permissionStorage}
http.Handle("/", h)
http.ListenAndServe(":8080", nil)
}
/database/user.go
package database
type SomeUserDB struct{}
func (db *SomeUserDB) UserRoleByID(id string) string {
// реализация
}
/database/config.go
package database
type SomeConfigDB struct{}
func (db *SomeConfigDB) AllPermissions() map[string][]string {
// реализация
}
/config/permissions.go
package config
import (
"time"
)
// Здесь мы определяем интерфейс, представляющий наши локальные потребности,
// предъявляемые к конфигурационной БД, а именно,
// метод `AllPermissions`.
type PermissionDB interface {
AllPermissions() map[string][]string // отображение роли на права доступа
}
// Затем мы импортируем сервис, который будет предоставлять
// права доступа из памяти, и, чтобы использовать этот сервис, другому
// пакету потребуется объявить локальный интерфейс
type PermissionStorage struct {
permissions map[string][]string
}
func NewPermissionStorage(db PermissionDB) *PermissionStorage {
s := &PermissionStorage{}
s.permissions = db.AllPermissions()
go func() {
for {
time.Sleep(time.Hour)
s.permissions = db.AllPermissions()
}
}()
return s
}
func (s *PermissionStorage) RolePermissions(role string) []string {
return s.permissions[role]
}
/handler/user_permissions_by_id.go
package handler
import (
"fmt"
"net/http"
"strings"
)
// объявление наших локальных потребностей из пользовательского экземпляра бд
type UserDB interface {
UserRoleByID(id string) string
}
// ... и наших локальных потребностей из долговременного хранилища данных в памяти.
type PermissionStorage interface {
RolePermissions(role string) []string
}
// Наконец наш обработчик не может быть полностью функциональным,
// поскольку требует ссылок на экземпляры, не являющиеся синглтонами.
type UserPermissionsByID struct {
UserDB UserDB
PermissionsStorage PermissionStorage
}
func (u *UserPermissionsByID) ServeHTTP(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query()["id"][0]
role := u.UserDB.UserRoleByID(id)
permissions := u.PermissionsStorage.RolePermissions(role)
fmt.Fprint(w, strings.Join(permissions, ", "))
}
Вот и все! Мы рассмотрели три уровня абстрагирования, первый из которых самый тонкий, содержащий глобальное состояние и сильно связанную логику, но обеспечивает самую быструю реализацию, а также обойтись минимумом кода, который потребуется писать и поддерживать. Второй вариант – умеренно-гибридный, а третий совершенно самодостаточен и подходит для многократного использования, но сопряжен с максимальными усилиями при поддержке.
За и Против
Подход I: Единственный пакет
За
- Меньше кода, гораздо быстрее в реализации, меньше работы по поддержке
- Нет пакетов, а, значит, не приходится волноваться и о кольцевых зависимостях
- Легко тестировать, поскольку существуют интерфейсы сервисов. Чтобы протестировать элемент логики, можно задать для синглтона любую реализацию на ваш выбор (конкретную или сымитированную), а затем запустить логику теста.
Против
- Единственный пакет также не предусматривает приватного доступа, все открыто отовсюду. В результате ответственность разработчика возрастает. Например, помните, что нельзя напрямую инстанцировать структуру, когда для выполнения некоторой логики инициализации требуется функция конструктора.
- Глобальное состояние (экземпляры синглтона) могут создавать невыполняемые допущения, например, неинициализированный экземпляр синглтона может спровоцировать во время выполнения панику нулевого указателя.
- Поскольку логика тесно связана, в этом проекте ничего нельзя с легкостью переиспользовать, и из него будет сложно извлечь какие-либо составляющие.
- Когда у вас нет пакетов, независимо управляющих каждый своим элементом логики, разработчик должен быть очень внимателен и правильно расставлять все элементы кода – иначе могут возникать неожиданные поведения.
Подход II: Спаренные пакеты
За
- Упаковывая проект, удобнее гарантировать ответственность за конкретную логику в рамках пакета, причем, это может соблюдаться при помощи компилятора. Кроме того, мы сможем использовать приватный доступ и контролировать, какие элементы кода нам открывать.
- Использование пакета с определениями позволяет работать с экземплярами синглтонов, и в то же время избегать кольцевых зависимостей. Таким образом, можно писать меньше кода, обойтись без передачи ссылок при управлении экземплярами и не тратить времени на проблемы, которые потенциально могут возникать при компиляции.
- Этот подход также располагает к тестированию, ведь существуют сервисные интерфейсы. При таком подходе возможно внутреннее тестирование каждого пакета.
Против
- При организации проекта в виде пакетов возникают некоторые издержки – так, например, на первичную реализацию должно потребоваться больше времени, чем при подходе с единственным пакетом.
- Использование глобального состояния (экземпляров синглтона) при данном подходе также может вызывать проблемы.
- Проект разделен на пакеты, что сильно облегчает извлечение и переиспользование отдельных его элементов. Однако, пакеты не являются полностью независимыми, поскольку все они взаимодействуют с пакетом определений. При данном подходе извлечение и переиспользование кода не являются полностью автоматическими.
Подход III: Независимые пакеты
За
- При использовании пакетов мы гарантируем, что конкретная логика реализуется в пределах одного пакета, и мы обладаем полным контролем доступа.
- Потенциально не должно возникать кольцевых зависимостей, так как пакеты полностью автономны.
- Все пакеты отлично извлекаемы и доступны для многократного использования. Во всех тех случаях, когда пакет нужен нам в другом проекте, мы просто переносим его в разделяемое пространство и используем, ничего в нем не меняя.
- Нет глобального состояния – значит, нет и непредусмотренных поведений.
- Этот подход лучше всех подходит для тестирования. Каждый пакет можно полностью протестировать, не беспокоясь о том, что он, возможно, зависит от других пакетов через локальные интерфейсы.
Против
- Этот подход гораздо медленнее в реализации, чем предыдущие два.
- Гораздо больше кода требуется поддерживать. Поскольку происходит передача ссылок, приходится обновлять множество мест после внесения серьезных изменений. Кроме того, когда у нас несколько интерфейсов, предоставляющих один и тот же сервис, нам приходится обновлять эти интерфейсы всякий раз, когда мы вносим изменения в этот сервис.
Выводы и примеры использования
Учитывая недостаток гайдлайнов по написанию кода в Go, он принимает самые разные очертания и формы, и у каждого варианта есть свои интересные достоинства. Однако, при смешивании различных паттернов проектирования могут возникать проблемы. Чтобы дать представление о них, я рассказал о трех различных подходах к написанию и структурированию кода на Go.
Итак, когда же должен использоваться каждый из подходов? Предлагаю такую расстановку:
Подход I: Подход с единственным пакетом, пожалуй, наиболее уместен при работе в небольших многоопытных командах, занятых на малых проектах, где требуется быстро достигать результата. Такой подход проще и надежнее для быстрого старта, хотя, требует серьезного внимания и координации на этапе поддержки проекта.
Подход II: Подход со спаренными пакетами можно назвать гибридным синтезом двух других подходов: среди его преимуществ – относительно быстрый старт и легкость при поддержке и, в то же время, здесь создаются условия для строгого соблюдения правил. Он уместен в сравнительно крупных проектах и больших командах, но в нем ограничены возможности переиспользования кода и существуют определенные сложности при поддержке.
Подход III: Подход с независимыми пакетами наиболее уместен в тех проектах, которые сложны сами по себе, являются долгосрочными, разрабатываются большими командами, а также для проектов, в которых имеются фрагменты логики, создаваемые с прицелом на дальнейшее переиспользование. На внедрение такого подхода требуется много времени, также он непрост в поддержке.