Архітектура веб-застосунків на прикладі Golang

6 хв. читання

З недавнього часу я пишу на 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, про які цікаво почитати — то теж пишіть в коменти, якщо шарю, то напишу ще і про це :)

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.8K
Приєднався: 8 місяців тому
Коментарі (0)

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

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

Вхід / Реєстрація