Arquitectura limpia con Go

Mi nombre es Edgar (ZergsLaw), Trabajo para una empresa de desarrollo fintech para b2b y b2c. Cuando conseguí un trabajo en la empresa por primera vez, entré en el equipo de un gran proyecto de fintech y recibí un pequeño microservicio "en la carga". Se me indicó que estudiara y preparara un plan de refactorización para poder asignar un equipo de soporte separado para el servicio.







"Mi" servicio es un proxy entre ciertos módulos de un gran proyecto. A primera vista, puede estudiarlo en una noche y dedicarse a cosas más importantes. Pero al comenzar a trabajar, me di cuenta de que estaba equivocado. El servicio fue escrito hace seis meses en un par de semanas con la tarea de probar MVP. Todo este tiempo se negó a trabajar: perdió eventos y datos, o los reescribió. El proyecto se pasó de equipo en equipo, porque nadie quería hacerlo, ni siquiera sus creadores. Ahora quedó claro por qué estaban buscando un programador independiente para ello.



"Mi" servicio es un ejemplo de arquitectura deficiente y diseño intrínsecamente incorrecto. Todos entendemos que esto no debe hacerse. Pero por qué no, a qué consecuencias lleva y cómo intentar arreglarlo todo, te lo diré.



Qué tan mala se interpone la arquitectura



Historia típica:



  • hacer MVP;

  • probar hipótesis sobre él;

  • , MVP;

  • ...;

  • PROFIT.



Pero esto no se puede hacer (que todos entendemos).



Cuando los sistemas se construyen rápidamente, la única forma de seguir lanzando nuevas versiones de un producto es "inflar" al personal. Inicialmente, los desarrolladores muestran una productividad cercana al 100%, pero cuando el producto inicialmente "crudo" está repleto de características y dependencias, lleva más y más tiempo averiguarlo.



Con cada nueva versión, la productividad de los desarrolladores cae. Nadie piensa en la limpieza, el diseño y la arquitectura del código. Como resultado, el precio de una línea de código puede aumentar 40 veces.







Estos procesos se pueden ver claramente en los gráficos de Robert Martin. A pesar de que el personal de desarrollo aumenta de una versión a otra, la tasa de crecimiento del producto solo se está desacelerando. Los costos están aumentando, los ingresos están cayendo, lo que ya está provocando una reducción de personal.



Desafío de arquitectura limpia



No importa para las empresas cómo está diseñada y escrita la aplicación. Es importante para las empresas que el producto se comporte de la forma en que los usuarios lo deseen y sea rentable. Pero a veces (no a veces, pero a menudo) la empresa cambia sus soluciones y requisitos. Con una estructura deficiente, es difícil adaptarse a los nuevos requisitos, cambiar productos y agregar nuevas funciones.



Un sistema bien diseñado es más fácil de adaptar al comportamiento deseado. Una vez más, Robert Martin cree que el comportamiento es secundario y siempre se puede corregir si el sistema está bien diseñado.



La arquitectura limpia promueve la comunicación entre las capas del proyecto, donde el centro es la lógica empresarial con todas sus entidades que se ocupan de los problemas aplicados.



  • Todas las capas externas son adaptadores para comunicarse con el mundo exterior. 

  • Los elementos del mundo exterior no deben penetrar la parte central del proyecto.



A la lógica empresarial no le importa quién es: una aplicación de escritorio, un servidor web o un microcontrolador. No debería depender de la "etiqueta". Ella debe realizar tareas específicas. Todo lo demás son detalles, por ejemplo, bases de datos o escritorio.



Con una arquitectura limpia, obtenemos un sistema independiente. Por ejemplo, es independiente de la base de datos o la versión del marco. Podemos reemplazar la aplicación de escritorio para las necesidades del servidor sin cambiar el componente interno de la lógica empresarial. Para eso se valora la lógica empresarial.



Una arquitectura limpia reduce la complejidad cognitiva del proyecto, los costos de soporte y simplifica el desarrollo y mayor mantenimiento de los programadores. 



Cómo identificar la arquitectura "mala"



No existe el concepto de arquitectura "mala" en la programación. Hay criterios para una arquitectura deficiente: rigidez, inmovilidad, dureza y repetibilidad excesiva. Por ejemplo, estos son los criterios que utilicé para comprender que la arquitectura de mi microservicio es mala.



Rigidez . Es la incapacidad del sistema para reaccionar incluso a los cambios más pequeños.Cuando se hace difícil cambiar partes de un proyecto sin dañar todo el sistema, el sistema es rígido. Por ejemplo, cuando se usa una estructura en varias capas de un proyecto a la vez, su pequeño cambio crea problemas en todo el proyecto a la vez.



El problema se soluciona convirtiendo en cada capa. Cuando cada capa opera solo sus objetos, que se obtuvieron "convirtiendo" el objeto externo, las capas se vuelven completamente independientes.



Inmovilidad... Cuando el sistema se construyó con una separación deficiente (o falta de) en módulos reutilizables. Los sistemas fijos son difíciles de refactorizar. 



Por ejemplo, cuando la información sobre bases de datos entra en el área de la lógica empresarial, la sustitución de la base de datos por otra conducirá a la refactorización de toda la lógica empresarial.



Viscosidad . Cuando la división de responsabilidades entre paquetes conduce a una centralización innecesaria. Curiosamente, lo que sucede al revés, cuando la viscosidad conduce a la descentralización, todo se divide en paquetes demasiado pequeños. En Go, esto puede dar lugar a importaciones circulares. Por ejemplo, esto sucede cuando los paquetes de adaptadores comienzan a recibir lógica adicional.



Repetibilidad excesiva... La frase popular en Go es "Una copia pequeña es mejor que una pequeña dependencia". Pero esto no conduce al hecho de que haya menos dependencias, simplemente se convierte en más copias. A menudo veo copias de código de otros paquetes en diferentes paquetes de Go.



Por ejemplo, Robert Martin escribe en su libro "Arquitectura limpia" que, en el pasado, Google requería reutilizar cualquier cadena que pudiera y asignarla en bibliotecas separadas. Esto provocó que el cambio de 2-3 líneas de un servicio pequeño afectara a todos los demás servicios relacionados. La empresa todavía está solucionando problemas con este enfoque.



Deseo de refactorizar... Este es un criterio adicional para una mala arquitectura. Pero hay matices. No importa lo mal que haya escrito el proyecto, por usted o no, nunca debe volver a escribirlo desde cero, esto solo creará problemas adicionales. Realice una refactorización iterativa.



Cómo diseñar relativamente correctamente



“Mi” servicio proxy vivió durante seis meses y todo este tiempo no cumplió con sus tareas. ¿Cómo vivió tanto tiempo?



Cuando una empresa prueba un producto y muestra ineficacia, es abandonado o destruido. Esto es normal. Cuando se prueba el MVP y resulta ser eficiente, sigue vivo. Pero, por lo general, MVP no se reescribe y sigue vivo "tal cual", repleto de código y funcionalidad. Por lo tanto, los "productos zombies" que se crearon para los MVP son una práctica común.



Cuando descubrí que mi servicio de proxy no estaba funcionando, el equipo decidió reescribirlo. Este negocio me fue asignado a mí y a un colega y nos asignaron dos semanas: hay poca lógica comercial, el servicio es pequeño. Este fue otro error.



El servicio comenzó a reescribirse por completo. Cuando cortaron, reescribieron partes del código y las cargaron en el entorno de prueba, parte de la plataforma se bloqueó. Resultó que el servicio tenía mucha lógica empresarial indocumentada que nadie conocía. Mi colega y yo fallamos, pero esto es un error en la lógica del servicio.



Decidimos abordar la refactorización desde el otro lado:



  • retroceder a la versión anterior;

  • el código no se reescribe;

  • dividimos el código en partes: paquetes;

  • cada paquete está empaquetado en una interfaz separada.



No entendíamos lo que estaba haciendo el servicio, porque nadie lo entendía. Por lo tanto, "cortar" el servicio en partes y ocuparse de lo que cada parte es responsable es la única opción.



Después de eso, fue posible refactorizar cada paquete por separado. Podríamos arreglar cada parte del servicio por separado y / o implementarlo en otras partes del proyecto. Al mismo tiempo, el trabajo en el servicio continúa hasta el día de hoy. 





Resultó así.



¿Cómo escribiríamos un servicio similar si lo hubiéramos diseñado “bien” desde el principio? Déjame mostrarte con el ejemplo de un pequeño microservicio que registra y autoriza a un usuario.



Introductorio



Necesitamos: el núcleo del sistema, una entidad que define y ejecuta la lógica empresarial mediante la manipulación de módulos externos.



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


A continuación, necesita dos contratos que le permitirán usar la capa de repositorio. El primer contrato nos proporciona una interfaz. Con su ayuda, nos comunicaremos con la capa de base de datos que almacena información sobre los usuarios.




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


El segundo contrato "se comunica" con la capa que almacena información sobre las sesiones de los usuarios.



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


Ahora necesita una interfaz para trabajar con contraseñas, hacer hash y compararlas. Y también la última interfaz para trabajar con tokens de autorización, que permitirá generarlos y también identificarlos.



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


Comencemos a escribir la lógica en sí. La pregunta principal es ¿qué queremos de la lógica empresarial de la aplicación?



  • Registro de usuario.

  • Comprobación de correo y apodo.

  • Autorización.



Cheques



Comencemos con métodos simples: verificar el correo electrónico o el apodo. Nuestro UserRepo no tiene métodos para verificar. Pero no los agregaremos, podemos verificar si este o aquel dato está ocupado solicitando al usuario estos datos.



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


Aquí hay dos matices.



¿ ErrNotFoundPor qué pasa la verificación por un error ? La implementación de la lógica empresarial no debe depender de SQL ni de ninguna otra base de datos, por lo que sql.ErrNoRowsdebe convertirse en el error que sea conveniente para nuestra lógica empresarial.



También planteamos el error de la capa de lógica empresarial con la capa de API, y el código de error o algo más debe resolverse a nivel de API. La lógica empresarial no debe depender del protocolo de comunicación con el cliente y tomar decisiones en base a este.



Registro y autorización



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


Es un código simple e imperativo que es fácil de leer y mantener. Puede comenzar a escribir este código de inmediato al diseñar. No importa a qué base de datos agreguemos el usuario, qué protocolo elegimos para comunicarnos con los clientes o cómo se procesan las contraseñas. La lógica empresarial no está interesada en todas estas capas, solo es importante que realice las tareas de su área de aplicación.



Capa de hash simple



Qué significa eso? Todas las no capas externas no deben tomar decisiones sobre tareas relacionadas con el área de aplicación. Realizan una tarea específica y sencilla que requiere nuestra lógica empresarial. Por ejemplo, tomemos una capa para hash de contraseñas.



// 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 es una capa simple para realizar tareas de comparación y hash de contraseñas. Es todo. Es delgado y sencillo y no sabe nada más. Y no debería.



Repo



Pensemos en la capa de interacción de almacenamiento.



Declaremos la implementación e indiquemos qué interfaces debe 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á posible que el lector del código comprenda qué contratos implementa la capa, así como también tener en cuenta las tareas establecidas para nuestro Repo.

Vayamos a la implementación. Para no estirar el artículo, daré solo una parte de los 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
}


La capa Repo tiene métodos simples y básicos. No saben cómo hacer nada excepto "Guardar, enviar, actualizar, eliminar, buscar". La tarea de la capa es solo ser un conveniente proveedor de datos para cualquier base de datos que nuestro proyecto necesite.



API



Todavía hay una capa de API para interactuar con el cliente.



Es necesario transferir datos del cliente a la lógica empresarial, devolver los resultados y satisfacer completamente todas las necesidades de HTTP: convertir los errores de la aplicación.



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


En esto, sus tareas terminan: trajo los datos, obtuvo el resultado, lo convirtió a un formato conveniente para HTTP.



¿Para qué se necesita realmente la arquitectura limpia?



¿Para qué es todo esto? ¿Por qué implementar determinadas soluciones arquitectónicas? No por la "limpieza" del código, sino por la capacidad de prueba. Necesitamos la capacidad de probar nuestro propio código de manera conveniente, simple y sencilla.



Por ejemplo, un código como este es malo :



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: olvidé señalar que este código es incorrecto. Esto podría ser engañoso si lee antes de la actualización. Lo siento por eso.



La capacidad de probar su código sin mayores problemas es el principal beneficio de una arquitectura limpia.


Podemos probar toda la lógica empresarial abstrayéndola de la base de datos, el servidor y el protocolo. Solo es importante para nosotros realizar las tareas aplicadas de nuestra aplicación. Ahora, siguiendo ciertas y simples reglas, podemos expandir y cambiar fácilmente nuestro código sin dolor.



Cualquier producto tiene lógica empresarial. Una buena arquitectura ayuda, por ejemplo, a empaquetar la lógica empresarial en un solo paquete, cuya tarea es operar con módulos externos para realizar tareas aplicadas.



Pero la arquitectura limpia no siempre es buena. A veces puede volverse malvado, traer una complejidad innecesaria. Si intenta escribir perfectamente de inmediato, perderemos un tiempo precioso y defraudaremos el proyecto. No tiene que escribir a la perfección, escriba bien en función de sus objetivos comerciales.



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



All Articles