- PVSM.RU - https://www.pvsm.ru -
Привет! Не так давно у нас вышла новая книга по Golang [1], и успех ее настолько впечатляет, что мы решили опубликовать здесь очень важную статью о подходах к проектированию приложений на Go. Идеи, изложенные в статье, очевидно не устареют в обозримом будущем. Возможно, автору даже удалось предвосхитить некоторые гайдлайны по работе с Go, которые могут войти в широкую практику в ближайшем будущем.
Язык Go был впервые анонсирован в конце 2009 года, а официальный релиз состоялся в 2012 году, но лишь в последние несколько лет стал приобретать серьезное признание. Go был одним из наиболее быстрорастущих языков в 2018 году [2] и третьим по востребованности языком программирования в 2019 году [3].
Поскольку сам язык Go достаточно новый, в сообществе разработчиков не слишком строго формулируют рекомендации по написанию кода. Если рассмотреть аналогичные соглашения, действующие в сообществах более древних языков, например, Java, то выяснится, что большинство проектов имеет схожую структуру. Это может очень пригодиться, когда пишешь большие базы кода, однако, многие могли бы настаивать, что в современных практических контекстах это было бы контрпродуктивно. По мере того, как мы переходим к написанию микросистем и поддержке сравнительно компактных баз кода, гибкость Go в области структурирования проектов становится весьма привлекательной.
Всем известен пример с hello world http на Golang [4], и его можно сравнить с аналогичными примерами на других языках, например, на Java [5]. Между первым и вторым не заметно существенной разницы ни в сложности, ни в количестве кода, который нужно написать для реализации примера. Но видна фундаментальная разница в подходе. Go стимулирует нас действовать по принципу «пиши простой код, когда это только возможно». Если абстрагироваться от объектно-ориентированных аспектов Java, то, думаю, наиболее важный вывод из этих фрагментов кода заключается в следующем: Java требует создавать отдельный экземпляр для каждой операции (экземпляр HttpServer
), тогда как Go стимулирует нас использовать глобальный синглтон.
Таким образом, вам придется поддерживать меньше кода, передавать в нем меньше ссылок. Если вы знаете, что вам придется создать всего один сервер (а так обычно и бывает), то зачем же утруждаться лишнего? Такая философия кажется все более веской по мере того, как растет ваша база кода. Тем не менее, жизнь иногда подбрасывает сюрпризы :(. Дело в том, что на выбор вам все равно остается несколько уровней абстрагирования, и, если неправильно их комбинировать, то можно самому себе понаставить серьезных капканов.
Именно поэтому я хочу заострить ваше внимание на трех подходах к организации и структурированию кода на Go. В каждом из этих подходов подразумевается свой уровень абстрагирования. В заключение статьи я сравню все три и расскажу, в каких прикладных случаях наиболее уместен каждый из этих подходов.
Мы собираемся реализовать HTTP-сервер, на котором содержится информация о пользователях (на следующем рисунке обозначен как Main DB), где каждому пользователю присвоена роль (допустим, базовый, модератор, администратор), а также реализовать дополнительную базу данных (на следующем рисунке обозначена как Configuration DB), где указаны совокупности прав доступа, отведенные для каждой из ролей (напр., чтение, запись, редактирование). Наш HTTP-сервер должен реализовывать конечную точку, возвращающую набор прав доступа, которыми обладает пользователь с заданным ID.
Далее давайте предположим, что конфигурационная база данных меняется редко, и на ее загрузку требуется много времени, поэтому мы собираемся держать ее в оперативной памяти, загружать вместе с запуском сервера и обновлять ежечасно.
Весь код находится в репозитории к этой статье, расположенном [6] на GitHub.
В подходе с единственным пакетом используется одноуровневая иерархия, где весь сервер реализован в рамках одного пакета. Весь код [7].
Внимание: комментарии в коде информативны, важны для понимания принципов каждого подхода.
/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, ", "))
}
Обратите внимание: мы все равно используем разные файлы, это делается для разделения ответственности. Так код получается более удобочитаемым и более удобным в поддержке.
В этом подходе давайте узнаем, что такое работа с пакетами. Пакет должен единолично отвечать за некоторое определенное поведение. Здесь мы позволяем пакетам взаимодействовать друг с другом – таким образом, нам приходится поддерживать меньше кода. Тем не менее, необходимо убедиться, что мы не нарушаем принцип единственной ответственности, и поэтому гарантировать, что каждая часть логики полностью реализована в отдельном пакете. Еще одна важная рекомендация при данном подходе такова: поскольку в Go не допускаются кольцевые зависимости между пакетами, необходимо создать нейтральный пакет, в котором содержатся лишь голые определения интерфейсов и экземпляров синглтона. Так мы избавимся от кольцевых зависимостей. Весь код [8].
/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, ", "))
}
При данном подходе проект также организуется в виде пакетов. В данном случае каждый пакет должен интегрировать все свои зависимости локально, через интерфейсы и переменные. Таким образом, он совершенно ничего не знает о других пакетах. При таком подходе пакет с определениями, упоминавшийся в предыдущем подходе, фактически будет размазан между всеми остальными пакетами; каждый пакет объявляет собственный интерфейс для каждого сервиса. На первый взгляд это может показаться назойливым дублированием, но на самом деле это не так. Каждый пакет, использующий сервис, должен объявить собственный интерфейс, в котором указано лишь то, что ему нужно от этого сервиса, и ничего больше. Весь код [9].
/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: Подход с независимыми пакетами наиболее уместен в тех проектах, которые сложны сами по себе, являются долгосрочными, разрабатываются большими командами, а также для проектов, в которых имеются фрагменты логики, создаваемые с прицелом на дальнейшее переиспользование. На внедрение такого подхода требуется много времени, также он непрост в поддержке.
Автор: ph_piter
Источник [10]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/356237
Ссылки в тексте:
[1] новая книга по Golang: https://www.piter.com/collection/all/product/golang-dlya-profi-rabota-s-setyu-mnogopotochnost-struktury-dannyh-i-mashinnoe-obuchenie-s-go
[2] наиболее быстрорастущих языков в 2018 году: https://github.blog/2018-11-15-state-of-the-octoverse-top-programming-languages/
[3] третьим по востребованности языком программирования в 2019 году: https://insights.stackoverflow.com/survey/2019#most-loved-dreaded-and-wanted
[4] hello world http на Golang: https://yourbasic.org/golang/http-server-example/
[5] Java: https://www.javacodex.com/Networking/Simple-HTTP-Server
[6] расположенном: https://github.com/PerimeterX/ok-lets-go
[7] Весь код: https://github.com/PerimeterX/ok-lets-go/tree/master/1-single-package
[8] Весь код: https://github.com/PerimeterX/ok-lets-go/tree/master/2-coupled-packages
[9] Весь код: https://github.com/PerimeterX/ok-lets-go/tree/master/3-independent-packages
[10] Источник: https://habr.com/ru/post/516186/?utm_source=habrahabr&utm_medium=rss&utm_campaign=516186
Нажмите здесь для печати.