Arquitetura limpa com Go

Meu nome é Edgar (ZergsLaw), Eu trabalho para uma empresa de desenvolvimento fintech para b2b e b2c. Quando consegui um emprego na empresa, entrei na equipe de um grande projeto de fintech e recebi um pequeno microsserviço “on the load”. Fui instruído a estudar e preparar um plano de refatoração para alocar uma equipe de suporte separada para o serviço.







"Meu" serviço é um proxy entre certos módulos de um grande projeto. À primeira vista, você pode estudá-lo em uma noite e começar a fazer coisas mais importantes. Mas começando a trabalhar, percebi que estava enganado. O serviço foi escrito seis meses atrás em algumas semanas com a tarefa de testar o MVP. Todo esse tempo ele se recusou a trabalhar: ele perdeu eventos e dados, ou os reescreveu. O projeto foi jogado de equipe em equipe, porque ninguém queria fazer, nem mesmo seus idealizadores. Agora ficou claro por que eles estavam procurando um programador separado para isso.



"Meu" serviço é um exemplo de arquitetura deficiente e design inerentemente incorreto. Todos nós entendemos que isso não deve ser feito. Mas por que não, a que consequências isso leva e como tentar consertar tudo, eu vou te dizer.



Como a arquitetura ruim atrapalha



História típica:



  • make MVP;

  • testar hipóteses sobre ele;

  • , MVP;

  • ...;

  • PROFIT.



Mas isso não pode ser feito (o que todos nós entendemos).



Quando os sistemas são construídos com pressa, a única maneira de continuar lançando novas versões de um produto é "inchar" a equipe. Inicialmente, os desenvolvedores mostram produtividade perto de 100%, mas quando o produto inicialmente "bruto" está sobrecarregado com recursos e dependências, leva mais e mais tempo para descobrir.



A cada nova versão, a produtividade do desenvolvedor cai. Ninguém pensa na limpeza, design e arquitetura do código. Como resultado, o preço de uma linha de código pode aumentar 40 vezes.







Esses processos podem ser vistos claramente nos gráficos de Robert Martin. Apesar do fato de que a equipe de desenvolvimento está aumentando de versão para versão, a taxa de crescimento do produto está apenas diminuindo. Os custos estão crescendo, as receitas estão caindo, o que já está levando à redução do quadro de funcionários.



Desafio de arquitetura limpa



Não importa para as empresas como o aplicativo é projetado e escrito. É importante para as empresas que o produto se comporte da maneira que os usuários desejam e seja lucrativo. Mas às vezes (não às vezes, mas com freqüência) a empresa muda suas soluções e requisitos. Com uma estrutura deficiente, é difícil se adaptar a novos requisitos, alterar produtos e adicionar novas funcionalidades.



Um sistema bem projetado é mais fácil de se ajustar ao comportamento desejado. Novamente, Robert Martin acredita que o comportamento é secundário e sempre pode ser corrigido se o sistema for bem projetado.



A arquitetura limpa promove a comunicação entre as camadas do projeto, onde o centro é a lógica de negócios com todas as suas entidades que lidam com as tarefas aplicadas.



  • Todas as camadas externas são adaptadores para comunicação com o mundo externo. 

  • Elementos do mundo externo não devem penetrar na parte central do projeto.



A lógica de negócios não se importa com quem é: um aplicativo de desktop, um servidor web ou um microcontrolador. Não deve depender do "rótulo". Ela deve realizar tarefas específicas. Todo o resto são detalhes, por exemplo, bancos de dados ou desktop.



Com uma arquitetura limpa, temos um sistema independente. Por exemplo, é independente da versão do banco de dados ou estrutura. Podemos substituir o aplicativo de desktop para as necessidades do servidor sem alterar o componente interno da lógica de negócios. É para isso que a lógica de negócios é valorizada.



Uma arquitetura limpa reduz a complexidade cognitiva do projeto, os custos de suporte e simplifica o desenvolvimento e manutenção adicional dos programadores. 



Como identificar uma arquitetura "ruim"



Não há conceito de arquitetura "ruim" na programação. Existem critérios para arquitetura pobre: ​​rigidez, imobilidade, resistência e repetibilidade excessiva. Por exemplo, esses são os critérios que usei para entender que a arquitetura do meu microsserviço é ruim.



Rigidez . É a incapacidade do sistema de reagir até mesmo a pequenas mudanças. Quando se torna difícil mudar partes de um projeto sem danificar todo o sistema, o sistema é rígido. Por exemplo, quando uma estrutura é usada em várias camadas do projeto ao mesmo tempo, sua pequena mudança cria problemas em todo o projeto de uma vez.



O problema é resolvido com a conversão em cada camada. Quando cada camada opera apenas seus objetos, que foram obtidos pela "conversão" do objeto externo, as camadas tornam-se totalmente independentes.



Imobilidade... Quando o sistema foi construído com má separação (ou falta de) em módulos reutilizáveis. Os sistemas fixos são difíceis de refatorar. 



Por exemplo, quando as informações sobre bancos de dados entram na área de lógica de negócios, a substituição do banco de dados por outro levará à refatoração de toda a lógica de negócios.



Viscosidade . Quando a divisão de responsabilidades entre pacotes leva a uma centralização desnecessária. Curiosamente, o que acontece ao contrário, quando a viscosidade leva à descentralização - tudo é dividido em embalagens muito pequenas. Em Go, isso pode levar a importações circulares. Por exemplo, isso acontece quando os pacotes do adaptador começam a receber lógica extra.



Repetibilidade excessiva... A frase popular em Go é "Uma cópia pequena é melhor do que uma pequena dependência." Mas isso não leva ao fato de que existem menos dependências - apenas se torna mais cópias. Costumo ver cópias de código de outros pacotes em diferentes pacotes Go.



Por exemplo, Robert Martin escreve em seu livro "Clean Architecture" que, no passado, o Google exigia reutilizar quaisquer strings que pudesse e alocá-las em bibliotecas separadas. Isso fez com que a alteração de 2 a 3 linhas de um pequeno serviço afetasse todos os outros serviços relacionados. A empresa ainda está corrigindo problemas com essa abordagem.



Desejo de refatorar... Este é um critério de bônus para uma arquitetura ruim. Mas existem nuances. Não importa o quão mal o projeto foi escrito, por você ou não, você nunca deve reescrevê-lo do zero, isso só criará problemas adicionais. Faça refatoração iterativa.



Como projetar de maneira relativamente correta



"Meu" serviço de proxy durou seis meses e todo esse tempo não cumpriu suas tarefas. Como ele viveu por tanto tempo?



Quando uma empresa testa um produto e ele mostra ineficácia, ele é abandonado ou destruído. Isto é normal. Quando o MVP é testado e se mostra eficiente, ele continua vivo. Mas normalmente o MVP não é reescrito e vive "como está", repleto de código e funcionalidade. Portanto, "produtos zumbis" que foram criados para MVPs são uma prática comum.



Quando descobri que meu serviço de proxy não estava funcionando, a equipe decidiu reescrevê-lo. Este negócio foi atribuído a mim e a um colega e alocou duas semanas: há pouca lógica de negócio, o serviço é pequeno. Este foi outro erro.



O serviço começou a ser totalmente reescrito. Quando eles cortaram, reescreveram partes do código e as carregaram no ambiente de teste, parte da plataforma travou. Descobriu-se que o serviço tinha muitas lógicas de negócios não documentadas que ninguém conhecia. Meu colega e eu falhamos, mas isso é um erro na lógica do serviço.



Decidimos abordar a refatoração do outro lado:



  • reverter para a versão anterior;

  • o código não é reescrito;

  • dividimos o código em partes - pacotes;

  • cada pacote é embalado em uma interface separada.



Não entendíamos o que o serviço estava fazendo, porque ninguém entendia. Portanto, "serrar" o serviço em partes e descobrir o que cada parte é responsável é a única opção.



Depois disso, tornou-se possível refatorar cada pacote separadamente. Podemos consertar cada parte do serviço separadamente e / ou implementá-lo em outras partes do projeto. Ao mesmo tempo, o trabalho no serviço continua até hoje. 





Acabou assim.



Como escreveríamos um serviço semelhante se o projetássemos "bem" desde o início? Deixe-me mostrar o exemplo de um pequeno microsserviço que registra e autoriza um usuário.



Introdutório



Precisamos: o núcleo do sistema, uma entidade que define e executa a lógica de negócios manipulando módulos externos.



type Core struct {
userRepo     UserRepo
sessionRepo  SessionRepo
hashing      Hasher
auth         Auth
}


Em seguida, você precisa de dois contratos que permitirão o uso da camada de repo. O primeiro contrato nos fornece uma interface. Com sua ajuda, iremos nos comunicar com a camada de banco de dados que armazena informações sobre os usuários.




// 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)
}


O segundo contrato "se comunica" com a camada que armazena informações sobre as sessões do usuário.



// 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
}


Agora você precisa de uma interface para trabalhar com senhas, fazer hash e compará-las. E também a mais recente interface para trabalhar com tokens de autorização, o que permitirá que sejam gerados e também identificados.



// 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)
}


Vamos começar a escrever a própria lógica. A questão principal é o que queremos da lógica de negócios do aplicativo?



  • Registro do usuário.

  • Verificando e-mail e apelido.

  • Autorização.



Verificações



Vamos começar com métodos simples - verificar e-mail ou apelido. Nosso UserRepo não tem métodos para verificar. Mas não vamos adicioná-los, podemos verificar se este ou aquele dado está ocupado, solicitando ao usuário esses dados.



// 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
   }
}


Existem duas nuances aqui.



Por ErrNotFoundque a verificação passa por um erro ? A implementação da lógica de negócios não deve depender de SQL ou qualquer outro banco de dados, por isso sql.ErrNoRowsdeve ser convertida no erro que seja conveniente para nossa lógica de negócios.



Também levantamos o erro da camada de lógica de negócios com a camada de API, e o código de erro ou algo mais deve ser resolvido no nível de API. A lógica de negócios não deve depender do protocolo de comunicação com o cliente e tomar decisões com base nisso.



Registro e autorização



// 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
}


É um código simples e obrigatório, fácil de ler e manter. Você pode começar a escrever este código imediatamente ao projetar. Não importa a qual banco de dados adicionamos o usuário, que protocolo escolhemos para nos comunicarmos com os clientes ou como as senhas são hash. A lógica de negócios não está interessada em todas essas camadas, é importante apenas para executar as tarefas de sua área de aplicação.



Camada de hash simples



O que isso significa? Todas as não camadas externas não devem tomar decisões sobre tarefas relacionadas à área de aplicação. Eles executam uma tarefa específica e simples que nossa lógica de negócios exige. Por exemplo, vamos pegar uma camada para hash de senhas.



// 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
}


Esta é uma camada simples para executar tarefas de comparação e hash de senha. É tudo. Ele é magro e simples e não sabe mais nada. E não deveria.



Repo



Vamos pensar sobre a camada de interação de armazenamento.



Vamos declarar a implementação e indicar quais interfaces ela deve implementar.



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}
}


Será possível deixar o leitor do código entender quais contratos são implementados pela camada, bem como levar em consideração as tarefas definidas para nosso Repo.

Vamos começar a implementação. Para não esticar o artigo, darei apenas uma parte dos métodos.



// 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
}


A camada Repo possui métodos simples e básicos. Eles não sabem fazer nada além de "Salvar, enviar, atualizar, excluir, localizar." A tarefa da camada é apenas ser um provedor de dados conveniente para qualquer banco de dados de que nosso projeto precisa.



API



Ainda existe uma camada de API para interação com o cliente.



É necessário transferir dados do cliente para a lógica de negócios, retornar os resultados e satisfazer totalmente todas as necessidades de HTTP - converter erros de aplicativo.



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)
	}
}


Com isso, suas tarefas terminam: ele trouxe os dados, obteve o resultado, converteu em um formato conveniente para HTTP.



Para que é realmente necessária uma arquitetura limpa?



Para que serve tudo isso? Por que implementar certas soluções arquitetônicas? Não para "limpeza" do código, mas para testabilidade. Precisamos da capacidade de testar de maneira conveniente, simples e fácil nosso próprio código.



Por exemplo, um código como este é ruim :



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)
}




Nota: esqueci de apontar que este código é ruim. Isso pode ser enganoso se você ler antes da atualização. Me desculpe por isso.



A capacidade de testar o código sem grandes problemas é o principal benefício de uma arquitetura limpa.


Podemos testar toda a lógica de negócios abstraindo do banco de dados, servidor, protocolo. É importante apenas para nós realizarmos as tarefas aplicadas de nosso aplicativo. Agora, seguindo regras simples e certas, podemos facilmente expandir e alterar nosso código sem dor.



Qualquer produto possui lógica de negócios. Uma boa arquitetura ajuda, por exemplo, a compactar a lógica de negócios em um pacote, cuja tarefa é operar com módulos externos para executar tarefas de aplicativo.



Mas a arquitetura limpa nem sempre é boa. Às vezes, pode se transformar em mal, trazer complexidade desnecessária. Se você tentar escrever perfeitamente de imediato, perderemos um tempo precioso e deixaremos o projeto de lado. Você não tem que escrever perfeito - escreva bem com base em seus objetivos de negócios.



, Golang Live 2020 14 17 . — 14 , — , .



All Articles