«Мой» сервис — это proxy между определенными модулями большого проекта. На первый взгляд изучить его можно за один вечер и браться за дела поважнее. Но приступив к работе я понял, что ошибся. Сервис был написан полгода назад за пару недель с задачей протестировать MVP. Всё это время он отказывался работать: терял события и данные, или переписывал их. Проект перекидывали из команды в команду, потому что никто не хотел им заниматься, даже его создатели. Теперь стало ясно почему под него искали отдельного программиста.
«Мой» сервис — это пример плохой архитектуры и изначально неправильного проектирования. Все мы понимаем, что так делать нельзя. Но почему нельзя, к каким последствиям это приводит и как попытаться все исправить, я и расскажу.
Чем мешает плохая архитектура
Типичная история:
- сделать MVP;
- протестировать на нём гипотезы;
- при положительном результате не переписывать проект, а наращивать функциональность на базе MVP;
- ...;
- PROFIT.
Но так делать нельзя (что мы все понимаем).
Когда системы создаются второпях, единственный способ продолжать выпускать новые версии продукта — «раздувать» штат. Изначально разработчики показывают продуктивность близкую к 100%, но когда изначально «сырой» продукт обрастает фичами и зависимостями, приходится всё дольше и дольше в этом всём разбираться.
С каждой новой версией продуктивность разработчиков падает. О чистоте кода, дизайне и архитектуре уже никто не думает. Как следствие, цена строки кода может увеличиваться в 40 раз.
Эти процессы хорошо видно на графиках от Роберта Мартина. Несмотря на то, что штат разработчиков увеличивается от версии к версии, динамика роста продукта лишь замедляется. Расходы растут, выручка падает, что приводит уже к сокращению штата.
Задача чистой архитектуры
Для бизнеса неважно как спроектировано и написано приложение. Бизнесу важно, чтобы продукт вел себя как нужно пользователям и приносил прибыль. Но иногда (не иногда, а часто) бизнес меняет решения и требования. С плохой структурой тяжело подстраиваться под новые требования, менять продукты и добавлять новую функциональность.
Хорошо спроектированную систему легче подвести под нужное поведение. Опять же, Роберт Мартин считает, что поведение вторично, его можно всегда исправить, если система грамотно спроектирована.
Чистая архитектура пропагандирует связь между слоями в проекте, где в центре — бизнес-логика со всеми её сущностями, которые занимаются прикладными задачами.
- Все внешние слои — адаптеры для связи с внешним миром.
- Элементы внешнего мира не должны проникать в центральную часть проекта.
Бизнес-логике не важно, кто она: десктопное приложение, веб-сервер или микроконтроллер. Она не должна зависеть от «ярлыка». Она должна выполнять конкретные задачи. Всё остальное — это детали, например, базы данных или десктоп.
С чистой архитектурой мы получаем независимую систему. Например, независимую от версии базы данных или фреймворка. Мы можем заменить десктопное приложение под нужды сервера, не меняя внутреннюю составляющую бизнес-логики. Это то, за что ценится бизнес-логика.
Чистая архитектура снижает когнитивные сложности проекта, затраты на поддержку, упростить развитие и дальнейшее сопровождение у программистов.
Как определить «плохую» архитектуру
В программировании нет понятия «плохая» архитектура. Есть критерии плохой архитектуры: жёсткость, неподвижность, вязкость и излишняя повторяемость. Например, именно эти критерии я использовал, чтобы понять, что архитектура моего микросервиса плохая.
Жёсткость. Это неспособность системы реагировать даже на небольшие изменения Когда становится тяжело менять части проекта, не повредив всю систему — система жесткая. Например, когда одна структура используется сразу в нескольких слоях проекта, то её небольшое изменение создает проблемы сразу во всем проекте.
Проблема лечится конвертацией на каждом слое. Когда каждый из слоев оперирует лишь своими объектами, которые были получены при «конвертации» внешнего объекта, то слои становятся полностью независимыми
Неподвижность. Когда система строилась с плохим разделением (или отсутствием) на переиспользуемые модули. Неподвижные системы тяжело рефакторить.
Например, когда в область бизнес-логики попадает информация о базах данных, замена БД на другую приведет к рефакторингу всей бизнес-логики.
Вязкость. Когда разделение сфер ответственности между пакетами приводит к излишней централизации. Интересно, что бывает и наоборот, когда вязкость приводит к децентрализации — всё разделено на слишком маленькие пакеты. В Go это может приводить к циклическим импортам. Например, так случается, когда пакеты-адаптеры начинают получать лишнюю логику.
Излишняя повторяемость. В Go популярна фраза «Небольшая копия лучше небольшой зависимости». Но это не приводит к тому, что становится меньше зависимостей — просто становится больше копий. Я часто встречаю в разных Go-пакетах копии кода из других пакетов.
Например, Роберт Мартин в книге «Чистая архитектура» пишет, что раньше Google требовал переиспользовать любые строки, какие возможно, и выделять их в отдельные библиотеки. Это приводило к тому, что изменение 2-3 строк маленького сервиса влияло на все остальные связанные с ним сервисы. Компания до сих пор исправляет проблемы подобного подхода.
Желание отрефакторить. Это бонусный критерий плохой архитектуры. Но есть нюансы. Как бы ужасно не был написан проект, вами или не вами, никогда не стоит переписывать его с нуля, это лишь породит дополнительные проблемы. Занимайтесь итеративным рефакторингом.
Как проектировать относительно правильно
«Мой» proxy-сервис жил полгода и все это время не выполнял свои задачи. Как же он прожил столько времени?
Когда бизнес тестирует продукт и он показывает неэффективность — его забрасывают или уничтожают. Это нормально. Когда MVP тестируют и он оказывается работоспособным, то живет дальше. Но обычно MVP не переписывают и он живет дальше «как есть», обрастая кодом и функциональностью. Поэтому «зомби-продукты», которые создавались для MVP — это частая практика.
Когда я выяснил, как не работает мой proxy-сервис, в команде решили его переписать. Это дело поручили мне и коллеге и выделили две недели: бизнес-логики мало, сервис маленький. Это была ещё одна ошибка.
Сервис начали переписывать целиком. Когда вырезали, переписывали части кода и заливали на тестовое окружение, часть платформы падала. Выяснилось, что в сервисе было много недокументированной бизнес-логики, о которой никто не знал. Мы с коллегой не справились, но это ошибка логики сервиса.
Решили подойти к рефакторингу с другой стороны:
- откатываемся на предыдущую версию;
- код не переписываем;
- разделяем код на части — пакеты;
- каждый пакет оборачиваем в отдельный интерфейс.
Мы не понимали что делает сервис, потому что никто этого не понимал. Поэтому «пилить» сервис по частям и разбираться с тем, за что отвечает каждая часть — единственный вариант.
После этого появилась возможность рефакторить каждый пакет по отдельности. Каждую часть сервиса мы могли исправлять по отдельности и/или внедрять в другие части проекта. При этом работа над сервисом продолжается до сих пор.
Получилось так.
Как бы мы написали похожий сервис, если бы проектировали «хорошо» изначально? Покажу на примере небольшого микросервиса, который регистрирует и авторизует пользователя.
Вводные
Нам нужно: ядро системы, сущность, определяющая и выполняющая бизнес логику посредством манипуляции внешними модулями.
type Core struct {
userRepo UserRepo
sessionRepo SessionRepo
hashing Hasher
auth Auth
}
Дальше необходимо два контракта, которые позволят использовать слой repo. Первый контракт предоставляет нам интерфейс. С его помощью будем общаться со слоем БД, которая хранит информацию о пользователях.
// UserRepo interface for user data repository.
type UserRepo interface {
// CreateUser adds to the new user in repository.
// This method is also required to create a notifying hoard.
// Errors: ErrEmailExist, ErrUsernameExist, unknown.
CreateUser(context.Context, User, TaskNotification) (UserID, error)
// UpdatePassword changes password.
// Resets all codes to reset the password.
// Errors: unknown.
UpdatePassword(context.Context, UserID, []byte) error
// UserByID returning user info by id.
// Errors: ErrNotFound, unknown.
UserByID(context.Context, UserID) (*User, error)
// UserByEmail returning user info by email.
// Errors: ErrNotFound, unknown.
UserByEmail(context.Context, string) (*User, error)
// UserByUsername returning user info by id.
// Errors: ErrNotFound, unknown.
UserByUsername(context.Context, string) (*User, error)
}
Второй контракт «общается» со слоем, который хранит информацию о сессиях пользователя.
// SessionRepo interface for session data repository.
type SessionRepo interface {
// SaveSession saves the new user Session in a database.
// Errors: unknown.
SaveSession(context.Context, UserID, TokenID, Origin) error
// Session returns user Session.
// Errors: ErrNotFound, unknown.
SessionByTokenID(context.Context, TokenID) (*Session, error)
// UserByAuthToken returning user info by authToken.
// Errors: ErrNotFound, unknown.
UserByTokenID(context.Context, TokenID) (*User, error)
// DeleteSession removes user Session.
// Errors: unknown.
DeleteSession(context.Context, TokenID) error
}
Теперь нужен интерфейс для работы с паролями, их хеширования и сравнения. А также последний интерфейс для работы с токенами авторизации, которые позволят их генерировать, а также идентифицировать.
// Hasher module responsible for working with passwords.
type Hasher interface {
// Password returns the hashed version of the password.
// Errors: unknown.
Password(password string) ([]byte, error)
// Compare compares two passwords for matches.
Compare(hashedPassword []byte, password []byte) error
}
// Auth module is responsible for working with authorization tokens.
type Auth interface {
// Token generates an authorization auth with a specified lifetime,
// and can also use the UserID if necessary.
// Errors: unknown.
Token(expired time.Duration) (AuthToken, TokenID, error)
// Parse and validates the auth and checks that it's expired.
// Errors: ErrInvalidToken, ErrExpiredToken, unknown.
Parse(token AuthToken) (TokenID, error)
}
Приступим к написанию самой логики. Главный вопрос — что мы хотим от бизнес-логики приложения?
- Регистрацию пользователя.
- Проверку почты и никнейма.
- Авторизацию.
Проверки
Начнем с простых методов — проверка email или никнейма. В нашем UserRepo нет методов для проверки. Но мы не будем их добавлять, мы можем проверить заняты ли те или иные данные, запросив юзера по этим данным.
// VerificationEmail for implemented UserApp.
func (a *Application) VerificationEmail(ctx context.Context, email string) error {
_, err := a.userRepo.UserByEmail(ctx, email)
switch {
case errors.Is(err, ErrNotFound):
return nil
case err == nil:
return ErrEmailExist
default:
return err
}
}
// VerificationUsername for implemented UserApp.
func (a *Application) VerificationUsername(ctx context.Context, username string) error {
_, err := a.userRepo.UserByUsername(ctx, username)
switch {
case errors.Is(err, ErrNotFound):
return nil
case err == nil:
return ErrUsernameExist
default:
return err
}
}
Здесь есть два нюанса.
Почему проверка проходит на ошибку
ErrNotFound
? Имплементация бизнес логики не должна зависеть ни от SQL, ни от иной БД, так что sql.ErrNoRows
должен быть сконвертирован в ту ошибку, которая удобно нашей бизнес-логике.Слою API поднимаем также ошибку слоя бизнес логики, а код ошибки или что-то иное должно решаться на уровне API. Бизнес-логика не должна зависеть от протокола общения с клиентом и принимать решения на этой основе.
Регистрация и авторизация
// CreateUser for implemented UserApp.
func (a *Application) CreateUser(ctx context.Context, email, username, password string, origin Origin) (*User, AuthToken, error) {
passHash, err := a.password.Password(password)
if err != nil {
return nil, "", err
}
email = strings.ToLower(email)
newUser := User{
Email: email,
Name: username,
PassHash: passHash,
}
_, err = a.userRepo.CreateUser(ctx, newUser)
if err != nil {
return nil, "", err
}
return a.Login(ctx, email, password, origin)
}
// Login for implemented UserApp.
func (a *Application) Login(ctx context.Context, email, password string, origin Origin) (*User, AuthToken, error) {
email = strings.ToLower(email)
user, err := a.userRepo.UserByEmail(ctx, email)
if err != nil {
return nil, "", err
}
if err := a.password.Compare(user.PassHash, []byte(password)); err != nil {
return nil, "", err
}
token, tokenID, err := a.auth.Token(TokenExpire)
if err != nil {
return nil, "", err
}
err = a.sessionRepo.SaveSession(ctx, user.ID, tokenID, origin)
if err != nil {
return nil, "", err
}
return user, token, nil
}
Это простой императивный код, который легко читается и легко поддерживается. Этот код можно начать писать сразу при проектировании. Здесь не важно в какую базу добавляем пользователя, какой протокол для общения с клиентами выберем или как хэшируются пароли. Бизнес логика не интересуется всеми этими слоями, ей важно лишь одно — выполнять задачи своей прикладной области.
Простой слой хеширования
Что это значит? Все внешние не слои не должны принимать решения по задачам, которые связаны с прикладной областью. Они выполняют конкретную и простую задачу, которая требуется нашей бизнес-логике. Например, возьмем слой для хеширования паролей.
// Package hasher contains methods for hashing and comparing passwords.
package hasher
import (
"errors"
"github.com/zergslaw/boilerplate/internal/app"
"golang.org/x/crypto/bcrypt"
)
type (
// Hasher is an implements app.Hasher.
// Responsible for working passwords, hashing and compare.
Hasher struct {
cost int
}
)
// New creates and returns new app.Hasher.
func New(cost int) app.Hasher {
return &Hasher{cost: cost}
}
// Hashing need for implements app.Hasher.
func (h *Hasher) Password(password string) ([]byte, error) {
return bcrypt.GenerateFromPassword([]byte(password), h.cost)
}
// Compare need for implements app.Hasher.
func (h *Hasher) Compare(hashedPassword []byte, password []byte) error {
err := bcrypt.CompareHashAndPassword(hashedPassword, password)
switch {
case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword):
return app.ErrNotValidPassword
case err != nil:
return err
}
return nil
}
Это некоторый простой слой, для выполнения задач по хешированию и сравниванию паролей. Это все. Он тонкий и простой и не умеет больше ничего. И не должен.
Repo
Подумаем о слое взаимодействия с хранилищем.
Объявим имплементацию и укажем, какие интерфейсы он должен имплементировать.
var _ app.SessionRepo = &Repo{}
var _ app.UserRepo = &Repo{}
// Repo is an implements app.UserRepo.
// Responsible for working with database.
type Repo struct {
db *sqlx.DB
}
// New creates and returns new app.UserRepo.
func New(repo *sqlx.DB) *Repo {
return &Repo{db: repo}
}
Появится возможность дать читателю кода понять, какие контракты имплементируются слоем, а также учитывать задачи, поставленные перед нашим Repo.
Приступим к имплементации. Чтобы не растягивать статью приведу лишь часть методов.
// CreateUser need for implements app.UserRepo.
func (repo *Repo) CreateUser(ctx context.Context, newUser app.User, task app.TaskNotification) (userID app.UserID, err error) {
const query = `INSERT INTO users (username, email, pass_hash) VALUES ($1, $2, $3) RETURNING id`
hash := pgtype.Bytea{
Bytes: newUser.PassHash,
Status: pgtype.Present,
}
err = repo.db.QueryRowxContext(ctx, query, newUser.Name, newUser.Email, hash).Scan(&userID)
if err != nil {
return 0, fmt.Errorf("create user: %w", err)
}
return userID, nil
}
// UserByUsername need for implements app.UserRepo.
func (repo *Repo) UserByUsername(ctx context.Context, username string) (user *app.User, err error) {
const query = `SELECT * FROM users WHERE username = $1`
u := &userDBFormat{}
err = repo.db.GetContext(ctx, u, query, username)
if err != nil {
return nil, err
}
return u.toAppFormat(), nil
}
В слое Repo простые и элементарные методы. Они не умеют ничего кроме «Сохрани, подай, обнови, удали, найди». Задача слоя только в том, чтобы быть удобным провайдером данных в любую БД, которая необходима нашему проекту.
API
Остался слой API для взаимодействия с клиентом.
От него требуется передавать данные от клиента в бизнес-логику, возвращать результаты обратно, и полностью удовлетворять все потребности HTTP — конвертировать ошибки приложения.
func (api *api) handler(w http.ResponseWriter, r *http.Request) {
params := &arg{}
err := json.NewDecoder(r.Body).Decode(params)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
origin := orifinFromReq(r)
res, err := api.app.CreateUser(
r.Context(),
params.Email,
params.Username,
params.Password,
request,
)
switch {
case errors.Is(err, app.ErrNotFound):
http.Error(w, app.ErrNotFound.Error(), http.StatusNotFound)
case errors.Is(err, app.ErrChtoto):
http.Error(w, app.ErrChtoto.Error(), http.StatusTeapot)
case err == nil:
json.NewEncoder(w).Encode(res)
default:
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
На этом, его задачи кончаются: принес данные, получил результат, сконвертировал в формат удобный для HTTP.
Для чего на самом деле нужная чистая архитектура
Для чего это всё? Зачем реализовывать те или иные архитектурные решения? Не для «чистоты» кода, а для тестируемости. Нам нужна возможность удобно, просто и легко тестировать свой же код.
Например, такой код плохой:
func (api *api) handler(w http.ResponseWriter, r *http.Request) {
params := &arg{}
err := json.NewDecoder(r.Body).Decode(params)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
rows, err := api.db.QueryContext(r.Context(), "sql query", params.Param)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var arrayRes []val
for rows.Next() {
value := val{}
err := rows.Scan(&value)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
arrayRes = append(arrayRes, value)
}
// ещё много кода для выполнения неких бизнес задач
err = json.NewEncoder(w).Encode(arrayRes)
w.WriteHeader(http.StatusOK)
}
Примечание: забыли указать, что этот код плохой. Это могло ввести в заблуждение, если вы читали до обновления. Извините, что так вышло.
Возможность тестировать код без серьезных проблем — главное преимущество чистой архитектуры.
Мы можем тестировать всю бизнес-логику абстрагируясь от БД, сервера, протокола. Нам важно лишь выполнение прикладных задач нашего приложения. Теперь, следуя определенным и простым правилам, можем легко расширяться и безболезненно менять наш код.
У любого продукта есть бизнес-логика. Хорошая архитектура помогает, например, упаковать бизнес логику в один пакет, задача которого оперировать внешними модулями для выполнения прикладных задач.
Но чистая архитектура не всегда хорошо. Иногда она может обернуться злом, принести излишнюю сложность. Если пытаться писать идеально сразу — потеряем драгоценное время и подведем проект. Писать идеально не обязательно — пишите хорошо, исходя из задач бизнеса.
Подробнее о чистой архитектуре и о методах обнаружение проблем в архитектуре на раннем этапе, поговорим уже на Golang Live 2020 с 14 по 17 октября. Бронируйте билеты — 14 сентября повышение цен, и подписывайтесь на рассылку — будем отправлять новости, анонсы и новые статьи.
godzie
Совсем не нравится как вы здесь представляете репозитории.
1. Нейминг
SessionRepo.SaveSession, SessionRepo.DeleteSession, UserRepo.CreateUser и тд
Зачем нужен постфикс у методов? Это бессмысленное дублирование, у вас же не может быть метода SessionRepo.SaveUser…
2. Мне кажется методы в целом несогласованны:
UserRepo.CreateUser — очевидно Create не является зоной ответственности репозитория.
SaveSession(context.Context, UserID, TokenID, Origin) — из имени метода следует что вы сохраняете сессию а не UserID, TokenID, Origin.
И далее, мне видится что для вас репозиторий это абстракция над бд в вакууме, тогда как, кроме этого, интерфейс репозитория должен быть схож с интерфейсом коллекций. Если следовать этому определению методы типа UpdatePassword(context.Context, UserID, []byte) отпадут сами собой.
nogoody
Еще добавлю, что с неймингом пакетов и итерфейсов очень хорошо описано здесь: golang.org/doc/effective_go.html#names