З недавнього часу я пишу на Go. До цього здебільшого використовував Python/Django. Як виявилось в світі Golang ще немає купи класних фреймворків, які вирішують більшість завдань, тому треба було самому реалізовувати архітектуру застосунку. І це класно! Трохи погугливши, я не знайшов нічого, де б це все було пояснено.
Тому в цій статті я хочу розповісти, як реалізувати архітектуру веб застосунків. З прикладами коду і загальними практиками (знайомими мені). Сеніори, кидайте помідорами виправляйте, якщо щось не так 😛.
Монолітний застосунок vs мікросервіси
За класикою, всі запити обробляються одним застосунком (монолітним), але, як виявилось, у 2018 році на піку популярності є архітектура розробки з допомогою мікросервісів.
Наприклад, архітектура бекенду codeguida виглядала б так: є зовсім різні, незалежні одна від одної, розгорнуті зовсім окремо 4 програми. Перша відповідає за реєстрацію, логін і збереження юзерів, друга — за створення, редагування статей, третя за коментарі, і четверта — за гру про власний аутсорс.
Фронтенд тоді сам вирішує куди йому слати запити, а якщо якомусь сервісу знадобляться дані іншого сервісу, то він запитає ці дані в нього. Перша перевага полягає в тому, що для передачі можна використовувати й http, і REST, і AMQP, і що завгодно . Далі: кожен сервіс може бути написаний будь-якою мовою (тому двоє девелоперів будуть менше плутатись під ногами один в одного, як при розробці монолітних застосунків). І навіть кожен сервіс може мати свою базу даних. Плюс легше і швидше масштабування при збільшенні трафіку. Мінусів теж вистачає, але про це варто написати окрему статтю.
Монолітні застосунки обробляють всі-всі запити в одній програмі.
Тут я опишу архітектуру монолітних застосунків. Про мікросервіси можна почитати окремо або напр. тут
Занурюємось
Отже, класика виглядає так:
Як бачимо, є три шари застосунку: handlers
, services
, repository
.
В обробнику (handler) нам потрібно лише провалідувати вхідні дані: зазвичай це перевірка на правильність типів, ну і ще якісь спецефічні кейси (наприклад чи date_from
не більше date_to
), передати ці дані у відповідний сервіс, отримати відповідь від сервісу і цю відповідь повернути в response.
Сервіс відповідає за бізнес-логіку. Він отримує дані від хендлера, виконує чисто бізнес-логіку, якщо треба, і передає їх в репозиторій, щоб там сформувати запит до DB. А результат виконання з репозиторію повертає в хендлер. Наприклад, сервіс GetUsersArticle
має повернути всі статті користувача, тому він отримує userID
як параметр, і просто передає це у відповідний метод репозиторію, отримуючи звідти масив юзерів. Другий приклад: логін. Перше треба отримати юзера (викликаємо метод з репозиторію), для цього юзера перевіряємо правильність email/пароль, якщо збігаються тоді генеруємо токен або записуємо в куки для подальшої ідентифікації.
В репозиторії нам треба зібрати всі запити до DB. Ці запити будуть викликатися з сервісу і повертати результат + помилку.
Тут варто зазначити, що це все робиться для того, щоб чітко розділити програму на частини, які виконують свої і тільки свої завдання. В хендлері не можна писати бізнес-логіку, а з сервісу не можна прямо робити запит в DB. І в сервісі не треба ще раз валідувати дані (хенделер про це вже потурбувався).
Окей, я це вже знаю, реалізація де?
Переходимо до реалізації. Я буду писати на Go, але впевнений, все майже так само можна заюзати для решти мов.
Старт застосунку
В програмі має бути одна точка входу: в Go це вже визначили розробники мови: файл main
і функція main()
. Тут нам треба реалізувати старт сервера, а якщо детальніше, то завдання номер 1 — створення об'єкту сервера.
Об'єкт сервера — просто екземпляр структури (в Go) або класу, назвемо її Server
, куди в майбутньому ми будемо додавати різні залежності (напр. об'єкт роутингу), але про це пізніше. Зараз нам потрібна структура і метод Start
або Run
який будемо викликати з main
файлу. В цьому методі ми маємо створити роутинг і запустити http сервер (http.ListenAndServe
).
type Server struct {}
func NewServer() (server *Server) {
return &erver{}
}
func (s *Server) Start() {
route := goji.NewMux()
route.HandleFunc(
pat.Post("/register/"),
funcToHandleRegister,
)
// ...
route.ListenAndServe()
}
Думаю, тут все зрозуміло. Я використовую мікрофреймворк goji для роутингу (але можна заюзати й будь-що інше): створюємо необхідні роути й запускаємо сервер. Де має бути цей код? Не в main.go
. Створіть пакунок server
і в ньому файл server.go
. Таким чином зараз структура файлів виглядає отак:
project/
│ README.md
└───app/
│ │ main.go
│ └───server/
│ │ │ server.go
main.go
:
package main
func main() {
server.NewServer().Start()
}
Обробники (Handlers)
Розберемось з хендлерами. Зрозуміло, якщо ми будемо імпортувати, як вище, функції-хендлери, то нічого хорошого не вийде. Нам потрібно створити структуру, методи якої й будуть хенделерами (функція, що приймає w http.ResponseWriter, r *http.Request
). І, пам'ятаєте, я казав, що в хендлері ми будемо тільки читати параметри, і їх передавати в сервіс? От і треба в хендлері зберігати сервіс (його поки нема, але ми його зробимо потім).
type UserHandler struct {
service services.UserService
}
func NewUserHandler(service services.UserService) UserHandler {
return UserHandler{service: service}
}
func (h UserHandler) Register(w http.ResponseWriter, r *http.Request) {
// Parse e.g. json body to get email and password params
email := "..."
password := "..."
// Then pass values into service, by accessing struct field `service`
user, err := h.service.CreateNewUser(email, password)
// Check whether everythig is ok:
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
w.Write(user)
}
Зрозуміли? Ми передали хендлеру сервіс, як поле, таким чином з будь-якого методу хендлера можна дістатись методів сервісу. Цей підхід називається Мостом, вірніше, шаблон проектування має таку назву.
Скільки може бути хендлерів? Зазвичай стільки, скільки є моделей у програмі (до моделей ще дійдемо). Тобто, якщо у нас застосунок — бекенд codeguida, то швидше за все, буде: UserHandler
, PostHandler
, SweetieHandler
, CommentHandler
, LikeHandler
.
Але думаю, зрозуміло, що всі хендлери будуть в окремому пакеті й кожен хендлер — окремий файл:
project/
│ README.md
└───app/
│ │ main.go
│ └───server/
│ │ │ server.go
│ └───handlers/
│ │ │ post_handler.go
│ │ │ sweetie_handler.go
│ │ │ user_handler.go
Як зміниться наш server.go
файл? Лише метод Start
. Нам потрібно створити сервіс (поки зробимо заглушку), і передати в об'єкт хендлера:
func (s *Server) Start() {
userService := services.NewUserService() // <<<
userHandler := handlers.NewUserHandler(userService) // <<<
route := goji.NewMux()
route.HandleFunc(
pat.Post("/register/"),
userHandler.Register, // <<<
)
// ...
route.ListenAndServe()
}
Зверніть увагу на рядки з <<<
!
Сервіси
Б'юсь об заклад, ви вже знаєте, як мають виглядати сервіси. Це теж окремий пакет зі структурами в файлах. А в полі кожної структури ми будемо передавати репозиторій (об'єкт структури), тут теж використовується патерн Міст.
type UserService struct {
repository repositories.UserRepository
}
func NewUserService(repository repositories.UserRepository) *UserService {
return &UserService{repository}
}
func (s *UserService) CreateUser(email, password string) (*models.User, error) {
user := &models.User{
Email: email,
Password: password,
}
if err := s.repository.Create(user); err != nil { // <<<
return user, err
}
return user, nil
}
Тож це просто структура з публічними методами, які може викликати хенделер. Параметри тут можна передавати будь-які, крім всього запиту, оскільки робота хендлера — «витягнути» з запиту всі потрібні дані і передати їх сервісу. Але в жодному випадку сервіс не може сам витягати дані із запиту.
І звісно ж додається пакет services
:
project/
│ README.md
└───app/
│ │ main.go
│ └───server/
│ │ │ server.go
│ └───handlers/
│ │ │ post_handler.go
│ │ │ sweetie_handler.go
│ │ │ user_handler.go
│ └───services/
│ │ │ post_service.go
│ │ │ sweetie_service.go
│ │ │ user_service.go
Тут ще з'явились моделі: не переживайте, зараз дійдемо і до них. Все, що ми робимо в сервісі — створюємо об'єкт моделі типу User, і передаємо його у відповідний метод з репозиторію, який буде виконувати один запит до DB (в даному випадку нам потрібно, щоб він виконав INSERT
, в gorm
це метод Create
).
Репозиторії
Це місце, де ми зберігаємо всі-всі запити до БД у вигляді методів:
type userRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) userRepository {
return &userRepository{db: db}
}
func (r *userRepository) Create(user *models.User) error {
return r.db.Create(user).Error
}
func (r *userRepository) Update(user *models.User) error {
return r.db.Updates(user).Error
}
Запити можна викликати, лише з об'єкта репозиторію, а він відповідно міститься тільки в сервісі. Таким чином ми отримали те, про що говорили з самого початку.
project/
│ README.md
└───app/
│ │ main.go
│ └───server/
│ │ │ server.go
│ └───handlers/
│ │ │ post_handler.go
│ │ │ sweetie_handler.go
│ │ │ user_handler.go
│ └───repositories/
│ │ │ post_repository.go
│ │ │ sweetie_repository.go
│ │ │ user_repository.go
│ └───services/
│ │ │ post_service.go
│ │ │ sweetie_service.go
│ │ │ user_service.go
І тепер, ми плавно підійшли до з'єднання з DB.
БД
Перш за все, потрібно пам'ятати, що точка доступу до БД має бути тільки одна. Функція, GetDbConnection
, яка кожного разу створює новий об'єкт для доступу до БД (як у прикладі) або глобальна змінна, до якої всі мають доступ — погані рішення.
Саме тому у нашій архітектурі, з'єднання з базою даних відбувається тільки один раз і потім цей об'єкт передається в репозиторії. Я буду використовувати ORM gorm
, ви можете іншу, принцип не змінюється:
В методі server.Start()
на самому початку нам потрібно під'єднатись до БД, як в прикладі документації:
dbConnection, err := gorm.Open("sqlite3", "test.db")
if err != nil {
panic("failed to connect database")
}
defer dbConnection.Close()
І передати цей об'єкт dbConnection
в конструктори репозиторіїв.
Тому, остаточний server.go
буде виглядати так:
func (s *Server) Start() {
dbConnection, err := gorm.Open("sqlite3", "test.db")
if err != nil {
panic("failed to connect database")
}
defer dbConnection.Close()
userRepository := repositories.NewUserRepository(dbConnection)
postRepository := repositories.NewPostRepository(dbConnection)
likeRepository := repositories.NewLikeRepository(dbConnection)
userService := services.NewUserService(userRepository)
postService := services.NewPostService(postRepository)
likeService := services.NewLikeService(likeRepository)
userHandler := handlers.NewUserHandler(userService)
postHandler := handlers.NewPostHandler(postService)
likeHandler := handlers.NewLikeHandler(likeService)
route := goji.NewMux()
route.HandleFunc(
pat.Post("/register/"),
userHandler.Register,
)
route.HandleFunc(
pat.Post("/login/"),
userHandler.Register,
)
route.HandleFunc(
pat.Get("/user/:id/"),
userHandler.Get,
)
route.ListenAndServe()
}
Моделі
Якщо ви мали досвід з іншими фреймворками, то повинні все розуміти. Якщо ні… то теж зрозумієте)
Моделі — спосіб представлення даних, що зберігаються в DB, з допомогою ООП. Хоча ООП, як такого в go
немає, нам вистачить і тільки об'єктів структур. Ми оголошуємо структури, поля якої відповідають полям SQL таблиці. А кожна структура — окрема таблиця. Тут краще почитати детальніше
Наприклад:
type User struct {
gorm.Model
Email string
Password string
FirstName string
}
Коли ви почитаєте трохи документацію gorm
, то зрозумієте як завантажувати дані з БД в структуру і як створювати записи в БД з наявної структури.
Загалом, в нас появляється ще один пакет з моделями, які ми будемо використовувати у всьому коді.
project/
│ README.md
└───app/
│ │ main.go
│ └───server/
│ │ │ server.go
│ └───handlers/
│ │ │ post_handler.go
│ │ │ sweetie_handler.go
│ │ │ user_handler.go
│ └───models/
│ │ │ post_model.go
│ │ │ sweetie_model.go
│ │ │ user_model.go
│ └───repositories/
│ │ │ post_repository.go
│ │ │ sweetie_repository.go
│ │ │ user_repository.go
│ └───services/
│ │ │ post_service.go
│ │ │ sweetie_service.go
│ │ │ user_service.go
Тепер в нас є хороший кістяк застосунку.
Увага
Варто звернути увагу: поля service
в хендлерах, repository
в сервісах і db
в репозиторіях мають бути приватними, щоб ніхто крім відповідного шару (handler
, service
, repository
) більше не мав доступу до цих полів. Інакше ж, можна буде зробити запит до бази даних прямо з handler
, а це порушення патерну: КОЖЕН ШАР РОБИТЬ ТІЛЬКИ СВОЮ РОБОТУ.
Давайте, ще розглянемо важливі частини: конфіги й роутинг, щоб ще більше покращити архітектуру.
Configs
Конфіги — змінні, які будуть змінюватись в залежності від того, де розгорнутий проект. Наприклад, конекшни до DB, секретний ключ тощо. Є кілька способів організувати це: env
змінні, .toml
, .json
файли, перевизначення змінних... і ще якісь є, точно. Про це можна почитати окремо, а ми для прикладу використаємо змінні оточення.
Я використовую тут бібліотеку envconfig
Додамо пакет configs
і оголосимо структуру зі змінними:
configs/app.go
:
package config
type App struct {
Host string
Port int
User string
Password string
Debug bool
SecretKey string
}
Ми застосуємо Singleton патерн для того, щоб змінні завантажувались з оточення в об'єкт структури тільки один раз при старті застосунку (про патерни варто почитати додатково):
package config
import (
"sync"
)
type App struct {
Host string
Port int
User string
Password string
Debug bool
SecretKey string
}
var configs *App
var once sync.Once
func GetConfig() *App {
once.Do(func() {
err := envconfig.Process("AppPrefix", &configs)
if err != nil {
log.Fatal(err.Error())
}
})
return configs
}
Функція GetConfig
може бути викликана безліч раз, але лише перший раз, вона створить об'єкт зі структури App
, і завантажить туди змінні з оточення (почитайте про once.Do
), всі інші рази вона просто поверне вже готовий конфіг. Таким чином ми одержали робочий приклад singleton і хорошу реалізацію налаштувань застосунка.
Роутинг
Краще винести в один пакет всі оголошення роутів. Я б оголосив в пакеті структуру Router
, з полем типу *goji.Mux
(роутер з бібліотеки goji.io) і методом CreateRoutes
, в якому ми будемо оголошувати всі роути. Як параметри ми будем передавати покажчики хендлерів (UserHandler
, PostHandler
...) і вже відповідно до них будемо прив'язувати роути:
package router
type Router struct {
Mux *goji.Mux
}
func NewRouter() *Router {
return Router{Mux: goji.NewMux()}
}
func (r *Router) CreateRoutes(userHandler *UserhHandler, postHandler *PostHandler, likeHandler *LikeHandler) {
r.Mux.HandleFunc(
pat.Post("/register/"),
userHandler.Register,
)
r.Mux.HandleFunc(
pat.Post("/login/"),
userHandler.Register,
)
r.Mux.HandleFunc(
pat.Get("/user/:id/"),
userHandler.Get,
)
r.Mux.HandleFunc(
pat.Get("/post/:id/"),
postHandler.Get,
)
r.Mux.HandleFunc(
pat.Post("/post/"),
postHandler.CreatePost,
)
}
Згодом вам доведеться також додати Middleware
, тому тут можна буде просто додати метод
func (r *Router) UseMiddleware(middleware func(http.Handler) http.Handler){
r.Mux.Use(middleware)
}
який буде записувати в поле Router.Mux
всі потрібні middlewares
(краще почитати про мідлевари в документації goji.io).
І викликати його з server.Start()
.
Таким чином, ми розділили логіку створення роутів і старту застосунку (налаштування основних частин): ми керуємо створенням роутів з методу server.Start()
.
Весь код доступний на github: https://github.com/dima-kov/go-architecture — наполегливо рекомендую пройтись по всьому проекті, щоб краще зрозуміти суть.
Напишіть в коментарі, які підходи ви використовуєте при проектуванні веб застосунків.
Якщо є якісь теми в go
, про які цікаво почитати — то теж пишіть в коменти, якщо шарю, то напишу ще і про це :)
Ще немає коментарів