"Mon" service est un proxy entre certains modules d'un grand projet. À première vue, vous pouvez l'étudier en une seule soirée et passer à des choses plus importantes. Mais en commençant à travailler, j'ai réalisé que je m'étais trompé. Le service a été écrit il y a six mois en quelques semaines avec la tâche de tester MVP. Pendant tout ce temps, il a refusé de travailler: il a perdu des événements et des données, ou les a réécrits. Le projet a été projeté d'équipe en équipe, car personne ne voulait le faire, pas même ses créateurs. Maintenant, il est devenu clair pourquoi ils recherchaient un programmeur séparé pour cela.
«Mon» service est un exemple d'architecture médiocre et de conception intrinsèquement incorrecte. Nous comprenons tous que vous ne pouvez pas faire cela. Mais pourquoi pas, quelles conséquences cela entraîne et comment essayer de tout réparer, je vais vous le dire.
Ă€ quel point l'architecture est mauvaise
Histoire typique:
- faire MVP;
- tester des hypothèses à ce sujet;
- , MVP;
- ...;
- PROFIT.
Mais cela ne peut pas ĂŞtre fait (ce que nous comprenons tous).
Lorsque les systèmes sont construits à la hâte, la seule façon de continuer à publier de nouvelles versions d'un produit est de «gonfler» le personnel. Au départ, les développeurs affichent une productivité proche de 100%, mais lorsque le produit initialement «brut» est envahi par les fonctionnalités et les dépendances, il prend de plus en plus de temps à le comprendre.
À chaque nouvelle version, la productivité des développeurs diminue. Personne ne pense à la propreté, à la conception et à l'architecture du code. En conséquence, le prix d'une ligne de code peut être multiplié par 40.
Ces processus sont clairement visibles dans les graphiques de Robert Martin. Malgré le fait que le personnel de développement augmente d'une version à l'autre, le taux de croissance du produit ne fait que ralentir. Les coûts augmentent, les revenus diminuent, ce qui entraîne déjà une réduction des effectifs.
DĂ©fi de l'architecture propre
La manière dont l'application est conçue et écrite n'a pas d'importance pour les entreprises. Il est important pour les entreprises que le produit se comporte comme les utilisateurs le souhaitent et qu'il soit rentable. Mais parfois (pas parfois, mais souvent) l'entreprise change ses solutions et ses exigences. Avec une structure médiocre, il est difficile de s'adapter aux nouvelles exigences, de changer de produit et d'ajouter de nouvelles fonctionnalités.
Un système bien conçu est plus facile à associer au comportement souhaité. Encore une fois, Robert Martin estime que le comportement est secondaire et peut toujours être corrigé si le système est bien conçu.
L'architecture propre favorise la communication entre les couches du projet, où le centre est la logique métier avec toutes ses entités qui traitent des problèmes appliqués.
- Toutes les couches externes sont des adaptateurs pour la communication avec le monde extérieur.
- Les éléments du monde extérieur ne doivent pas pénétrer la partie centrale du projet.
La logique métier ne se soucie pas de qui il s'agit: une application de bureau, un serveur Web ou un microcontrôleur. Cela ne devrait pas dépendre du "label". Elle doit effectuer des tâches spécifiques. Tout le reste est des détails, par exemple, les bases de données ou le bureau.
Avec une architecture propre, nous obtenons un système indépendant. Par exemple, il est indépendant de la base de données ou de la version du framework. Nous pouvons remplacer l'application de bureau pour les besoins du serveur sans changer le composant interne de la logique métier. C'est pour cela que la logique métier est valorisée.
Une architecture propre réduit la complexité cognitive du projet, les coûts de support, et simplifie le développement et la maintenance des programmeurs.
Comment identifier une "mauvaise" architecture
Il n'y a pas de concept de «mauvaise» architecture en programmation. Il existe des critères de mauvaise architecture: rigidité, immobilité, ténacité et répétabilité excessive. Par exemple, ce sont les critères que j'ai utilisés pour comprendre que l'architecture de mon microservice est mauvaise.
Rigidité . C'est l'incapacité du système à réagir même à de petits changements. Lorsqu'il devient difficile de changer des parties d'un projet sans endommager l'ensemble du système, le système est rigide. Par exemple, lorsqu'une structure est utilisée dans plusieurs couches d'un projet à la fois, sa petite modification crée des problèmes dans l'ensemble du projet à la fois.
Le problème est résolu en convertissant sur chaque couche. Lorsque chaque couche opère uniquement ses objets, qui ont été obtenus en "convertissant" l'objet externe, les couches deviennent totalement indépendantes
Immobilité... Lorsque le système a été construit avec une mauvaise séparation (ou un manque de) en modules réutilisables. Les systèmes fixes sont difficiles à refactoriser.
Par exemple, lorsque des informations sur les bases de données entrent dans le domaine de la logique métier, le remplacement de la base de données par une autre conduira à la refactorisation de toute la logique métier.
Viscosité . Lorsque la répartition des responsabilités entre les packages conduit à une centralisation inutile. Fait intéressant, ce qui se passe dans l'autre sens, lorsque la viscosité conduit à la décentralisation - tout est divisé en paquets trop petits. Dans Go, cela peut conduire à des importations circulaires. Par exemple, cela se produit lorsque les paquets d'adaptateur commencent à recevoir une logique supplémentaire.
Répétabilité excessive... Dans Go, la phrase «Une petite copie est préférable à une petite dépendance» est populaire. Mais cela ne conduit pas au fait qu'il y a moins de dépendances - cela devient simplement plus de copies. Je vois souvent des copies de code d'autres packages dans différents packages Go.
Par exemple, Robert Martin écrit dans son livre "Clean Architecture" que dans le passé, Google avait besoin de réutiliser toutes les chaînes qu'il pouvait et de les allouer à des bibliothèques séparées. Cela a entraîné la modification de 2-3 lignes d'un petit service pour affecter tous les autres services connexes. L'entreprise est toujours en train de résoudre des problèmes avec cette approche.
Désir de refactoriser... C'est un critère bonus pour une mauvaise architecture. Mais il y a des nuances. Peu importe à quel point le projet a été écrit, par vous ou non, vous ne devez jamais le réécrire à partir de zéro, cela ne fera que créer des problèmes supplémentaires. Faites une refactorisation itérative.
Comment concevoir relativement correctement
"Mon" service proxy a vécu pendant six mois et pendant tout ce temps n'a pas rempli ses tâches. Comment a-t-il vécu si longtemps?
Lorsqu'une entreprise teste un produit et qu'il montre son inefficacité, il est abandonné ou détruit. C'est normal. Lorsque le MVP est testé et qu'il s'avère efficace, il perdure. Mais généralement MVP n'est pas réécrit et il vit «tel quel», envahi par le code et les fonctionnalités. Par conséquent, les «produits zombies» créés pour les MVP sont une pratique courante.
Lorsque j'ai découvert que mon service proxy ne fonctionnait pas , l'équipe a décidé de le réécrire. Cette affaire m'a été confiée à moi et à un collègue et a alloué deux semaines: il y a peu de logique métier, le service est petit. C'était une autre erreur.
Le service a commencé à être entièrement réécrit. Lorsqu'ils ont coupé, réécrit des parties du code et les ont téléchargées dans l'environnement de test, une partie de la plate-forme s'est écrasée. Il s'est avéré que le service avait beaucoup de logique métier non documentée que personne ne connaissait. Mon collègue et moi avons échoué, mais c'est une erreur dans la logique du service.
Nous avons décidé d'aborder le refactoring de l'autre côté:
- revenir à la version précédente;
- le code n'est pas réécrit;
- nous divisons le code en parties - packages;
- chaque paquet est enveloppé dans une interface distincte.
Nous n'avons pas compris ce que faisait le service, car personne ne l'avait compris. Par conséquent, «scier» le service en pièces et déterminer ce dont chaque pièce est responsable est la seule option.
Après cela, il est devenu possible de refactoriser chaque paquet séparément. Nous pourrions réparer chaque partie du service séparément et / ou l'implémenter dans d'autres parties du projet. Dans le même temps, le travail sur le service se poursuit à ce jour.
Cela s'est avéré comme ça.
Comment écririons-nous un service similaire si nous le concevions «bien» dès le départ? Permettez-moi de vous montrer l'exemple d'un petit microservice qui enregistre et autorise un utilisateur.
Introduction
Nous avons besoin: du cœur du système, d'une entité qui définit et exécute la logique métier en manipulant des modules externes.
type Core struct {
userRepo UserRepo
sessionRepo SessionRepo
hashing Hasher
auth Auth
}
Ensuite, vous avez besoin de deux contrats qui vous permettront d'utiliser la couche repo. Le premier contrat nous fournit une interface. Avec son aide, nous communiquerons avec la couche de base de données qui stocke les informations sur les utilisateurs.
// 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)
}
Le deuxième contrat «communique» avec la couche qui stocke les informations sur les sessions utilisateur.
// 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
}
Vous avez maintenant besoin d'une interface pour travailler avec les mots de passe, les hacher et les comparer. Et aussi la dernière interface pour travailler avec des jetons d'autorisation, qui leur permettra d'être générés et également identifiés.
// 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)
}
Commençons à écrire la logique elle-même. La question principale est que voulons-nous de la logique métier de l'application?
- Enregistrement de l'utilisateur.
- VĂ©rification du courrier et du pseudo.
- Autorisation.
Chèques
Commençons par des méthodes simples - vérifier votre email ou votre pseudo. Notre UserRepo n'a aucune méthode à vérifier. Mais nous ne les ajouterons pas, nous pouvons vérifier si telle ou telle donnée est occupée en demandant à l'utilisateur ces données.
// 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
}
}
Il y a deux nuances ici.
Pourquoi
ErrNotFound
la vérification des erreurs ? L'implémentation de la logique métier ne doit pas dépendre de SQL ou de toute autre base de données, elle sql.ErrNoRows
doit donc être convertie en erreur qui convient à notre logique métier.
Nous relevons également l'erreur de la couche de logique métier avec la couche API, et le code d'erreur ou autre chose doit être résolu au niveau de l'API. La logique métier ne doit pas dépendre du protocole de communication avec le client et prendre des décisions en fonction de cela.
Enregistrement et autorisation
// 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
}
Il s'agit d'un code simple et impératif, facile à lire et à maintenir. Vous pouvez commencer à écrire ce code immédiatement lors de la conception. Peu importe la base de données à laquelle nous ajoutons l'utilisateur, le protocole que nous choisissons pour communiquer avec les clients ou la manière dont les mots de passe sont hachés. La logique métier ne s'intéresse pas à toutes ces couches, il est seulement important qu'elle exécute les tâches de son domaine d'application.
Couche de hachage simple
Qu'est-ce que ça veut dire? Toutes les non-couches externes ne doivent pas prendre de décisions sur les tâches liées au domaine d'application. Ils effectuent une tâche spécifique et simple que requiert notre logique métier. Par exemple, prenons une couche pour le hachage des mots de passe.
// 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
}
Il s'agit d'une couche simple pour effectuer des tâches de hachage et de comparaison de mots de passe. C'est tout. Il est mince et simple et ne sait rien d'autre. Et ça ne devrait pas.
Repo
Pensons Ă la couche d'interaction de stockage.
Déclarons l'implémentation et indiquons quelles interfaces elle doit implémenter.
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}
}
Il sera possible de laisser le lecteur du code comprendre quels contrats sont implémentés par la couche, ainsi que de prendre en compte les tâches définies pour notre Repo.
Passons à la mise en œuvre. Afin de ne pas étirer l'article, je ne donnerai qu'une partie des méthodes.
// 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 couche Repo a des méthodes simples et basiques. Ils ne savent rien faire d'autre que "Enregistrer, soumettre, mettre à jour, supprimer, rechercher". La tâche de la couche est uniquement d'être un fournisseur de données pratique pour toute base de données dont notre projet a besoin.
API
Il existe toujours une couche API pour interagir avec le client.
Il est nécessaire de transférer les données du client vers la logique métier, de renvoyer les résultats et de satisfaire pleinement tous les besoins HTTP - convertir les erreurs d'application.
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)
}
}
Sur ce, ses tâches se terminent: il a apporté les données, a obtenu le résultat, l'a converti dans un format pratique pour HTTP.
Pourquoi une architecture propre est-elle vraiment nécessaire?
A quoi ça sert? Pourquoi mettre en œuvre certaines solutions architecturales? Pas pour la "propreté" du code, mais pour la testabilité. Nous avons besoin de la capacité de tester de manière pratique, simple et facile notre propre code.
Par exemple, un code comme celui-ci est mauvais :
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)
}
Remarque: j'ai oublié de signaler que ce code est incorrect. Cela pourrait être trompeur si vous lisez avant la mise à jour. Désolé pour ça.
La possibilité de tester votre code sans problèmes majeurs est le principal avantage d'une architecture propre.
Nous pouvons tester toute la logique métier en faisant abstraction de la base de données, du serveur, du protocole. Il est seulement important pour nous d'effectuer les tâches appliquées de notre application. Maintenant, en suivant certaines règles simples, nous pouvons facilement étendre et modifier notre code sans douleur.
Tout produit a une logique métier. Une bonne architecture permet, par exemple, de regrouper la logique métier dans un seul package, dont la tâche est de fonctionner avec des modules externes pour effectuer des tâches d'application.
Mais une architecture propre n'est pas toujours bonne. Parfois, cela peut se transformer en mal, apporter une complexité inutile. Si vous essayez d'écrire parfaitement tout de suite, nous perdrons un temps précieux et laisserons tomber le projet. Vous n'êtes pas obligé d'écrire parfaitement - écrivez bien en fonction de vos objectifs commerciaux.
, Golang Live 2020 14 17 . — 14 , — , .