Let's Go! Три підходи до структурування коду на Go

Alex Alex 31 серпня
Let's Go! Три підходи до структурування коду на Go

Мова 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.
Let's Go! Три підходи до структурування коду на Go

Далі давайте припустимо, що конфігураційна база даних змінюється рідко, і на її завантаження потрібно багато часу, тому ми збираємося тримати її в оперативній пам'яті, завантажувати разом з запуском сервера і оновлювати щогодини.

Весь код до цієї статті знаходиться в репозиторії, розташованому на 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

Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Війти / Зареєструватися