blog.title

blog.return

Chez Kumojin, nous pouvons nous adapter à n'importe quelle technologie que nos clients utilisent ou souhaitent utiliser. Cependant, nous devons les aider à choisir la bonne lorsqu'ils construisent leur produit à partir de zéro.

Golang est souvent la technologie de choix pour la partie backend de l'infrastructure, et notamment lors de l'implémentation d'API REST. Nous pourrions écrire un article entier sur les avantages et les inconvénients de Go, mais pour les premiers, nous pensons que la simplicité, la lisibilité et la productivité des développeurs sont des facteurs importants.

Nous avons écrit plusieurs applications back-end pour nos clients et à chaque fois, nous avons appris quelque chose de nouveau et amélioré notre itération précédente dans nos tentatives d'implémenter un modèle de clean architecture. Les applications étaient principalement des implémentations d'API REST, mais nous avons aussi eu quelques outils supplémentaires et une fois une API GRPC.

Nous avons élaboré une architecture en couches et des composants faiblement couplés qui ont du sens pour nous. Une chose importante cependant : gardez à l'esprit que nous parlons d'applications Golang qui grandissent en taille, pensez aux API REST avec des dizaines, parfois des centaines de points de terminaison. Pour des cas plus simples, cette structure pourrait être excessive, et vous pourriez tout aussi bien faire ce que vous avez à faire dans votre fichier main.go ! Gardez toujours le principe KISS au centre de votre vie de développeur !

De plus, nous ne parlerons pas des bibliothèques que nous utilisons ici car cela n'est pas pertinent en termes de structuration de l'application Golang. Mais cela pourrait faire l'objet d'un article séparé. Étant donné le riche écosystème Golang, il existe de nombreux excellents choix à explorer.

Qu'est-ce qu'une architecture propre ?

Il n'y a pas de meilleur moyen d'expliquer cela que cet excellent article Wikipedia.

L'objectif principal d'une architecture propre est d'avoir des composants d'application hautement découplés et d'obtenir le code le plus maintenable possible, où changer une ligne de code quelque part ne casse pas tout le reste (nous voulons tous cela, n'est-ce pas ?).

Quant au vocabulaire, c'est-à-dire « stockage », « dépôts », « modèles », « cas d'usage », « ports », nous les expliquerons tous dans les paragraphes suivants.

La structure d'une application Go

Nous allons étudier la structure d'une simple application Go communiquant avec une base de données et exposant un serveur web et un serveur GRPC. Ce back-end expose des processus métier pour l'enregistrement d'un nouvel utilisateur et la connexion, et nous ajoutons maintenant un nouveau processus pour la mise à jour de l'image d'avatar d'un utilisateur.

- cmd 
  - main.go 
- config 
- db 
  - cmd 
    - main.go 
  - migrations 
  migrate.go 
- pkg 
  - context 
  - models 
    - user.go 
  - ports 
    - http 
      user.go 
  - storage 
    - db 
      user.go 
    repositories.go 
  - usecases 
    - user 
      login.go 
      register.go 
      update_avatar.go 
- grpc 
   - cmd 
     - main.gp 
   server.go 
- rest 
   - cmd 
     - main.go 
   server.go 
main.go 

Cela peut sembler compliqué au premier abord, mais ne vous inquiétez pas. Dans les paragraphes suivants, nous vous expliquerons pourquoi nous le structurons ainsi, ce que contient chaque dossier, et à quoi servent les multiples packages cmd.

Le point d'entrée d'une application Go

Tout d'abord, notre répertoire racine n'a qu'un seul fichier, main.go, le point d'entrée de notre application Go. Vous voulez garder ceci aussi court que possible. La plupart du temps, la première chose que vous attendriez de votre application est de lire sa configuration, que ce soit le port du serveur web, l'emplacement de la base de données, les emplacements des fichiers statiques, etc.

Dans notre cas, nous déléguerons ces opérations à notre analyseur de commandes principal dans cmd/main.go.

Analyser la commande

L'analyseur principal

Le cmd/main.go est responsable de deux choses :

  • localiser le fichier de configuration et le lire ;
  • déléguer la lecture de la configuration spécifique à l'analyseur de sous-commandes approprié.

Donc, disons que notre application Go accepte un fichier de configuration. Nous allons vérifier si nous avons celui par défaut, ou si nous devons lire celui fourni dans la commande (--config=config.local.yml). Et ensuite nous sauvegarderons les informations dans notre configuration (le package config). Rien de fantaisiste ici, nous nous attendons à ce que ce soit un ensemble de struct disponibles globalement.

Ensuite, nous allons regarder quelle commande nous voulons exécuter. Nous voyons que la sous-commande qui doit être exécutée est web, ce qui signifie que nous voulons que web/cmd/main.go prenne le relais.

L'analyseur de sous-commandes

Dans notre structure d'application, nous pouvons voir plusieurs packages liés aux sous-commandes tels que db/cmd, grpc/cmd et rest/cmd.

Les sous-commandes grpc/cmd et rest/cmd liront leur configuration spécifique et démarreront respectivement le serveur GRPC ou web.

Vous pouvez avoir autant de sous-commandes que vous voulez supportant d'autres protocoles et/ou outils. La partie importante est que chaque sous-commande soit responsable de la lecture de sa configuration.

Vous vous demandez peut-être ce que fait db/cmd... Est-ce qu'on démarre une base de données ? Non, ne vous inquiétez pas !

Nous travaillons avec des fichiers de migration up/down pour toujours avoir notre structure de base de données compatible avec le code d'application que nous avons, et pouvoir revenir en arrière si nécessaire. Pensez à Liquibase ou Flyway, mais plus simple.

Donc db/cmd nous permettra de migrer vers le haut ou vers le bas notre base de données en utilisant les fichiers de migration stockés dans le dossier db/migration.

Exemple :

# the following command expects the database to be migrated to version 34 
go run . db migrate up 34

Exécuter une sous-commande

Lorsque la ligne de commande est complètement analysée et la configuration disponible, la dernière tâche de la sous-commande invoquée est d'exécuter le morceau de code attendu :

  • db/migrate.go effectuera les migrations de base de données comme mentionné précédemment ;
  • grpc/server.go démarrera le serveur GRPC ;
  • rest/server.go démarrera notre serveur d'API REST.

Le code de migration est un peu une exception car il n'a pas besoin de connaissances du domaine métier. Les autres commandes interagiront très probablement avec le domaine métier... Alors comment font-elles ?

Code d'application/couche métier

Notre code métier est situé dans le package pkg. Il possède un ensemble de packages que nous allons parcourir.

Les modèles

pkg/models contient nos modèles, évidemment !

Par exemple, nous nous attendons à ce que user.go contienne au moins une structure User et/ou une structure NewUser pour créer un nouvel utilisateur et effectuer l'enregistrement.

Quelque chose comme ça :

type User struct { 
	ID        string `db:"id" json:"id"` 
	Email     string `db:"email" json:"email"` 
	FirstName string `db:"first_name" json:"firstName"` 
	LastName  string `db:"last_name" json:"lastName"` 
	AvatarURL string `db:"avatar_url" json:"avatarURL"` 
} 

Les dépôts

pkg/storage contient nos dépôts, l'implémentation de notre communication avec la couche de stockage. Stockage est un mot générique car il peut s'agir d'une base de données, comme nous l'avons ici, mais aussi d'un serveur FTP, d'un service cloud, etc.

Quel que soit notre type de stockage, pkg/storage/repositories.go contient une liste d'interfaces pour atteindre nos données. L'utilisation d'interfaces définissant des ensembles de méthodes au lieu de structures ici est cruciale car nous ne voulons pas qu'une implémentation quelconque apparaisse en dehors du package storage. Peu importe si nous stockons nos utilisateurs dans une base de données ou sur un serveur FTP (enfin, espérons que ce ne soit pas ce dernier...).

Dans notre exemple, nos données métier sont stockées dans une base de données, donc nous implémentons notre interface User dans pkg/storage/user.go où nous nous attendons à trouver les méthodes CRUD habituelles pour rechercher/obtenir/sauvegarder nos utilisateurs.

Pour mieux illustrer notre point précédent, disons que chaque utilisateur peut avoir un avatar, et nous avons décidé de stocker les avatars des utilisateurs dans Amazon S3. Nous nous attendons à avoir une interface appropriée définie dans repositories.go pour cela. Et l'implémentation de notre dépôt pour sauvegarder un avatar d'utilisateur serait dans le package pkg/storage/s3 par exemple.

Voici les interfaces que nous devrions voir dans repositories.go :

// UserRepository is for interacting with users 
type UserRepository interface { 
	FindByID(userID string) (*models.User, error) 
	Create(user *models.User) error 
	Update(user *models.User) error 
} 
 
// UserAvatarRepository is for interacting with user avatars (blobs) 
type UserAvatarRepository interface { 
	CreateOrUpdate(userID string, blob io.Reader) error 
} 

Maintenant, vous pourriez commencer à vous demander : « Si sauvegarder un avatar d'utilisateur nécessite de sauvegarder un blob dans S3 et de mettre à jour l'URL de l'avatar dans la table utilisateur, quelqu'un doit appeler séquentiellement les deux dépôts différents, n'est-ce pas ? »

Absolument ! Nous avons les cas d'usage pour cela.

Les cas d'usage

pkg/usecases contient notre logique métier située au-dessus de la couche de stockage. À ce stade, que la requête provienne d'un point de terminaison REST, d'un appel GRPC ou d'ailleurs, cela n'a pas d'importance.

Maintenant, qu'il s'agisse de « créer un nouvel utilisateur » ou de « mettre à jour l'avatar d'un utilisateur », chaque opération/processus métier devrait avoir son propre cas d'usage, et chaque cas d'usage va contrôler un ou plusieurs dépôts pour faire le travail.

Comme nous l'avons vu ci-dessus, notre cas d'usage « créer un avatar d'utilisateur » nécessitera deux dépôts, un pour stocker physiquement le blob d'avatar quelque part, et un pour sauvegarder cet emplacement physique dans nos informations utilisateur. Remarquez que dans la phrase précédente, j'ai évité d'utiliser les mots « S3 » et « base de données ».

C'est parce que cela nous est égal : c'est la responsabilité des dépôts de communiquer avec le stockage. Au niveau du cas d'usage, nous savons juste qu'ils sont capables de supporter notre logique de cas d'usage.

Voici ce à quoi nous pouvons nous attendre :

package user  
 
// What the higher level components should see  
type UpdateUserAvatarUsecase interface { 
    Update(userID UUID, blob io.Reader) error 
} 
 
// The struct that will implement the use case, and the implementation 
// will be based off the two repositories previously mentioned.  
// One for uploading the blob, another for updating the user data. 
type defaultUserAvatarUsecase struct { 
	user       storage.UserRepository 
	userAvatar storage.UserAvatarRepository 
} 
 
func NewDefaultUserAvatarUsecase( 
	user storage.UserRepository, 
	userAvatar storage.UserAvatarRepository, 
) usecases.UserAvatarUsecase { 
	return defaultUserAvatarUsecase{ 
		user:       user, 
		userAvatar: userAvatar, 
	} 
} 
func (u defaultUserAvatarUsecase) CreateOrUpdate(userID string, blob io.Reader) error { 
	// First we upload the blob and get the public URL 
	avatarURL, err := u.userAvatar.CreateOrUpdate(userID, blob) 
	if err != nil { 
		return err 
	} 
 
	// Then we find the user 
	user, err := u.user.FindByID(userID) 
	if err != nil { 
		return err 
	} 
 
	// ...and update the info 
	user.AvatarURL = *avatarURL 
	err = u.user.Update(*user) 
	if err != nil { 
		return err 
	} 
 
	return nil 
} 

Les ports

Les ports contiennent l'implémentation de nos « points de terminaison » accessibles depuis l'extérieur de l'application.

Par exemple, nous pourrions permettre le téléchargement de l'avatar d'utilisateur avec un appel HTTP via l'API REST, ou avec un appel GRPC. Si nous n'avions pas la couche de cas d'usage, nous aurions peut-être dû dupliquer la logique du cas d'usage dans les deux implémentations. Mais nous l'avons, donc chaque point de terminaison n'aurait qu'à appeler le cas d'usage pour opérer.

Y a-t-il plus à ce niveau ? Oui ! Chaque point de terminaison est basé sur un protocole donné, par exemple HTTP pour l'API REST. Donc nous nous attendons à ce que notre point de terminaison HTTP d'avatar d'utilisateur effectue des vérifications spécifiques :

  • valider que le blob a le bon type mime, peut-être que l'image ne dépasse pas une certaine taille ;
  • valider les données JSON si nous en avons une ;
  • retourner le bon code de statut HTTP en fonction du résultat des opérations, etc.

Voici ce à quoi nous nous attendons pour un port HTTP permettant à un client de mettre à jour l'avatar d'un utilisateur :

package user 
 
// Our HTTP port for updating a user avatar. Notice the `echo` 
// package, it's because we use the echo library built on top of 
// net/http. You may use any library you want, including the base 
// net/http one. 
 
type UserAvatarPort interface { 
	CreateOrUpdate(ctx echo.Context) error 
} 
 
type defaultUserAvatarPort struct { 
	userAvatar usecases.UserAvatarUsecase 
} 
 
// NewDefaultUserAvatarPort returns the default implementation of the user avatar port. 
func NewDefaultUserAvatarPort(userAvatar usecases.UserAvatarUsecase) UserAvatarPort { 
	return defaultUserAvatarPort{ 
		userAvatar: userAvatar, 
	} 
} 
 
func (p defaultUserAvatarPort) CreateOrUpdate(ctx echo.Context) error { 
	// We get the id of the user from the URL placeholder 
	// Say: PUT /users/{userID}/avatar 
	userID := ctx.Param("userID") 
 
	// We'll try to get the image part of the multipart request 
	formFile, err := ctx.FormFile("image") 
	if err != nil { 
		return ctx.String(http.StatusBadRequest, fmt.Sprintf("No image found in multipart request: %s", err.Error())) 
	} 
	file, err := formFile.Open() 
	if err != nil { 
		return err 
	} 
	defer file.Close() 
 
	// We have everything, we can call the usecase to do the operation. 
	err = p.userAvatar.CreateOrUpdate(userID, file) 
	if err != nil { 
		return ctx.String(http.StatusInternalServerError, err.Error()) 
	} 
 
	return ctx.NoContent(http.StatusNoContent) 
} 

Maintenant vous vous demandez peut-être : « OK, nous avons des dépôts, des cas d'usage et des ports... Mais qui fait la plomberie et connecte tout ? ».

Le contexte d'application

Empruntant un terme souvent utilisé dans le monde Java/Spring, nous avons un « contexte d'application » pour tout connecter ensemble, bien que ce ne soit qu'un ensemble de méthodes plutôt qu'un objet/structure globale.

Le package pkg/context est responsable de fournir :

  • la connexion au stockage/base de données à chaque dépôt ;
  • les dépôts requis à chaque cas d'usage ;
  • le cas d'usage à chaque port.

Nous avons appliqué le principe d'inversion des dépendances tout au long, et ici nous allons injecter les dépendances requises dans chaque morceau de code qui en a besoin.

Et pour faire cela, nous n'utiliserons aucune bibliothèque sophistiquée, nous exposerons simplement des méthodes régulières. Donc, qu'il s'agisse d'un dépôt, d'un cas d'usage ou d'un port, c'est l'endroit où nous pouvons les obtenir.

Nous séparons habituellement les fonctions constructrices dans des fichiers séparés pour les dépôts, les cas d'usage et les ports, mais pour l'exemple, voici ce que nous devrions trouver dans le package context :

// We use sqlx as our helper library for SQL database stuff, 
// you can use anything you want. The goal here is to connect 
// things, and ultimately one of the repositories used by our 
// use case needs the database.  
 
// NewUserUsecase returns a new instance of the user use case 
func NewUserUsecase(database *sqlx.DB) usecases.UserUsecase { 
	return user.NewDefaultUserUsecase( 
		db.NewUserRepository(database), 
	) 
} 
 
// NewUserAvatarUsecase returns a new instance of the user avatar use case 
func NewUserAvatarUsecase(database *sqlx.DB) usecases.UserAvatarUsecase { 
	return user.NewDefaultUserAvatarUsecase( 
		db.NewUserRepository(database), 
		s3.NewUserAvatarRepository(), 
	) 
} 
 
// NewUserPort returns a new HTTP user port 
func NewUserPort(database *sqlx.DB) http.UserPort { 
	return http.NewDefaultUserPort( 
		NewUserUsecase(database), 
	) 
} 
 
// NewUserAvatarPort returns a new HTTP user avatar port 
func NewUserAvatarPort(database *sqlx.DB) http.UserAvatarPort { 
	return http.NewDefaultUserAvatarPort( 
		NewUserAvatarUsecase(database), 
	) 
} 

Conclusion

Nous venons de voir comment structurer nos applications Go pour avoir une logique métier réutilisable, nous permettant de séparer les préoccupations et de rendre cette logique disponible à travers différents points de terminaison/ports.

Il existe d'innombrables façons d'implémenter une architecture propre dans une application Go, ce n'est qu'une d'entre elles. Et comme mentionné dans l'introduction, nous nous améliorons constamment sur notre itération précédente, peaufinant les choses ici et là pour avoir le code le plus maintenable et lisible possible, tout en restant aussi simple que possible.

Néanmoins, nous espérons que nos tentatives documentées vous aideront à trouver votre propre « bonne » façon d'implémenter ce modèle architectural !

Tous les morceaux de code auxquels nous nous référons dans cet article, avec la « colle » entre eux, sont disponibles sur GitHub.