Мова 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" ) // Тут ми визначаємо інтерфейс, який представляє наші локальні потреби, // з файлу конфігурацію DB, а саме, // метод `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 : Підхід з незалежними пакетами найдоречніший в тих проєктах, які складні самі по собі, є довгостроковими, розробляються великими командами, а також для проєктів, в яких є фрагменти логіки, створювані з прицілом на подальше перевикористання. На впровадження такого підходу потрібно багато часу, також він непростий в підтримці.
Джерело ENG: perimeterx.com
Ще немає коментарів