Dans le processus de faire connaissance avec Golang, j'ai décidé de créer le cadre de l'application, qui me sera utile pour travailler à l'avenir. Le résultat a été, à mon avis, une bonne pièce, que j'ai décidé de partager, et en même temps de discuter des moments qui se sont produits lors de la création du cadre.

En principe, la conception du langage Go laisse entendre qu'il n'a pas besoin de faire des applications à grande échelle (je veux dire le manque de génériques et un mécanisme de gestion des erreurs peu puissant). Mais nous savons toujours que la taille des applications ne diminue généralement pas, mais le plus souvent au contraire. Par conséquent, il est préférable de créer immédiatement un framework sur lequel il sera possible de chaîner de nouvelles fonctions sans sacrifier le support du code.
J'ai essayé d'insérer moins de code dans l'article, mais j'ai ajouté des liens vers des lignes de code spécifiques sur Github dans l'espoir qu'il serait plus pratique de voir l'image entière.
Tout d'abord, j'ai esquissé un plan pour ce qui devrait être dans la demande. Puisque je parlerai de chaque élément séparément dans l'article, je donnerai d'abord le principal de cette liste comme contenu.
- Choisissez le gestionnaire de packages
- Choisissez un cadre pour créer une API
- Sélectionnez l'outil pour l'injection de dépendance (DI)
- Itinéraires de demande Web
- Réponses JSON / XML selon les en-têtes de demande
- ORM
- Migrations
- Créer des classes de base pour les couches de modèle Service-> Repository-> Entity
- Dépôt CRUD de base
- Service CRUD de base
- Contrôleur CRUD de base
- Demande de validation
- Configurations et variables d'environnement
- Commandes de la console
- Journalisation
- Intégration de l'enregistreur avec Sentry ou un autre système d'alerte
- Définition d'une alerte pour les erreurs
- Tests unitaires avec redéfinition des services via DI
- Pourcentage et carte de code de couverture de test
- Swagger
- Docker compose
Gestionnaire de paquets
Après avoir lu les descriptions des différentes implémentations, j'ai choisi govendor et pour le moment j'étais satisfait du choix. La raison est simple - elle vous permet d'installer des dépendances à l'intérieur du répertoire avec l'application, de stocker des informations sur les packages et leurs versions.
Les informations sur les packages et leurs versions sont stockées dans un fichier vendor.json. Il y a aussi un inconvénient à cette approche. Si vous ajoutez un package avec ses dépendances, ainsi que des informations sur le package, des informations sur ses dépendances seront également incluses dans le fichier. Le fichier se développe rapidement et il n'est plus possible de déterminer clairement quelles dépendances sont les principales et quelles sont les dérivées.
Dans PHP composer ou dans npm, les principales dépendances sont décrites dans un seul fichier, et toutes les dépendances principales et dérivées et leurs versions sont automatiquement enregistrées dans le fichier de verrouillage. Cette approche est plus pratique à mon avis. Mais pour l'instant, la mise en œuvre du gouverneur m'a suffi.
Cadre
Du framework je n'ai pas besoin de grand chose, d'un routeur pratique, de la validation des requêtes. Tout cela a été trouvé dans le populaire Gin . Il s'est arrêté dessus.
Injection de dépendance
Avec DI, j'ai dû souffrir un peu. D'abord choisi Dig. Et au début, tout était super. Services décrits, Dig construit davantage les dépendances, de manière pratique. Mais il s'est avéré que les services ne pouvaient pas être redéfinis, par exemple, pendant les tests. Par conséquent, à la fin, je suis arrivé à la conclusion que j'ai pris un simple sarulabs / di de conteneur de service.
Je devais juste le fourche, car hors de la boîte, il vous permet d'ajouter des services et interdit de les redéfinir. Et lors de l'écriture d'autotests, à mon avis, il est plus pratique d'initialiser le conteneur comme dans l'application, puis de redéfinir certains services, en spécifiant des stubs à la place. Dans fork, il a ajouté une méthode pour remplacer la description du service.
Mais à la fin, à la fois dans le cas de Dig et dans le cas du conteneur de service, j'ai dû mettre les tests dans un package séparé. Sinon, il s'avère que les tests sont exécutés séparément dans des packages ( go test model/service
), mais ils ne démarrent pas immédiatement pour l'ensemble de l'application ( go test ./...
), en raison des dépendances cycliques qui surviennent dans ce cas.
Dans Gin, je ne l'ai pas trouvé, j'ai donc ajouté une méthode au contrôleur de base qui génère une réponse en fonction de l'en-tête de la demande.
func (c BaseController) response(context *gin.Context, obj interface{}, code int) { switch context.GetHeader("Accept") { case "application/xml": context.XML(code, obj) default: context.JSON(code, obj) } }
ORM
Avec ORM n'a pas ressenti le long tourment du choix. Il y avait beaucoup de choix. Mais selon la description des fonctionnalités, j'ai aimé GORM, qui est l'un des plus populaires au moment de la sélection. Il existe un support pour les SGBD les plus couramment utilisés. Au moins PostgreSQL et MySQL sont définitivement là. Il propose également des méthodes de gestion du schéma de base que vous pouvez utiliser lors de la création de migrations.
Migrations
Pour les migrations, j'ai opté pour le package gorm-goose . Je mets un package séparé globalement et commence la migration vers celui-ci. Au début, une telle implémentation était gênante, car la connexion à la base de données devait être décrite dans un fichier db / dbconf.yml distinct . Mais il s'est avéré que la chaîne de connexion qu'elle contient pouvait être décrite de telle manière que la valeur soit prise dans la variable d'environnement.
development: driver: postgres open: $DB_URL
Et c'est assez pratique. Au moins avec docker-compose, je n'ai pas eu à dupliquer la chaîne de connexion .
Gorm-goose prend également en charge les annulations de migration, ce que je trouve très utile.
Dépôt CRUD de base
Je préfère que tout ce qui fait référence aux ressources soit placé dans une couche de référentiel distincte. À mon avis, avec cette approche, le code de logique métier est plus propre. Dans ce cas, le code logique métier sait seulement qu'il doit travailler avec les données qu'il prend dans le référentiel. Et ce qui se passe dans le référentiel, la logique métier n'est pas importante. Le référentiel peut fonctionner avec une base de données relationnelle, avec un stockage KV, avec un disque, ou peut-être avec l'API d'un autre service. Le code logique métier sera le même dans tous ces cas.
Le référentiel CRUD implémente l' interface suivante
type CrudRepositoryInterface interface { BaseRepositoryInterface GetModel() (entity.InterfaceEntity) Find(id uint) (entity.InterfaceEntity, error) List(parameters ListParametersInterface) (entity.InterfaceEntity, error) Create(item entity.InterfaceEntity) entity.InterfaceEntity Update(item entity.InterfaceEntity) entity.InterfaceEntity Delete(id uint) error }
Autrement dit, CRUD implémente les opérations Create()
, Find()
, List()
, Update()
, Delete()
et la méthode GetModel()
.
À propos de GetModel () . Il existe un référentiel de base CrudRepository
qui implémente les opérations CRUD de base. Dans les référentiels qui l'intègrent en eux-mêmes, il suffit d'indiquer avec quel modèle ils doivent travailler. Pour ce faire, la méthode GetModel()
doit renvoyer un modèle GORM. Ensuite, nous avons dû utiliser le résultat de GetModel()
utilisant la réflexion dans les méthodes CRUD.
Par exemple
func (c CrudRepository) Find(id uint) (entity.InterfaceEntity, error) { item := reflect.New(reflect.TypeOf(c.GetModel()).Elem()).Interface() err := c.db.First(item, id).Error return item, err }
En effet, dans ce cas, il a fallu abandonner le typage statique au profit du typage dynamique. À de tels moments, le manque de génériques dans la langue est particulièrement ressenti.
Pour que les référentiels qui fonctionnent avec des modèles spécifiques implémentent leurs propres règles de filtrage des listes dans la méthode List()
, j'ai d'abord implémenté la liaison tardive afin que la méthode responsable de la construction de la requête de sélection soit appelée à partir de la méthode List()
. Et cette méthode pourrait être implémentée dans un référentiel spécifique. Il est difficile d'abandonner en quelque sorte les schémas de pensée qui se sont formés en travaillant avec d'autres langues. Mais en le regardant avec un regard neuf et en appréciant «l'élégance» du chemin choisi, il l'a refait à une approche plus proche de Go. Pour ce faire, simplement dans CrudRepository
via l'interface, un générateur de requêtes est déclaré, qui est déjà List()
.
listQueryBuilder ListQueryBuilderInterface
Cela s'avère assez drôle. Limiter le langage à une liaison tardive, ce qui semble à première vue comme une faille, encourage une séparation plus claire du code.
Service CRUD de base
Il n'y a rien d'intéressant ici, car il n'y a pas de logique métier dans le cadre. Les appels des méthodes CRUD au référentiel sont simplement mandatés .
Dans la couche services, la logique métier doit être implémentée.
Contrôleur CRUD de base
Le contrôleur implémente les méthodes CRUD . Ils traitent les paramètres de la demande, le contrôle est transféré à la méthode de service correspondante, et en fonction de la réponse du service, une réponse est formée pour le client.
Avec le contrôleur, j'ai eu la même histoire qu'avec le référentiel concernant les listes de filtrage. En conséquence, j'ai refait l'implémentation avec une liaison tardive maison et ajouté un hydrateur , qui, en fonction des paramètres de la demande, forme une structure avec des paramètres pour filtrer la liste.
Dans l'hydrateur fourni avec le contrôleur CRUD, seuls les paramètres de pagination sont traités. Dans les contrôleurs spécifiques dans lesquels le contrôleur CRUD est intégré, l' hydrateur peut être redéfini .
Demande de validation
La validation est effectuée par Gin. Par exemple, lors de l'ajout d'un enregistrement (méthode Create()
), il suffit de décorer les éléments de la structure d'entité
Name string `binding:"required"`
La méthode ShouldBindJSON()
du ShouldBindJSON()
prend en charge la vérification des paramètres de requête pour la conformité aux exigences décrites dans le décorateur.
Configurations et variables d'environnement
J'ai vraiment aimé l'implémentation de Viper , surtout en conjonction avec Cobra.
Lire la configuration que j'ai décrite dans main.go. Les paramètres de base qui ne contiennent pas de secrets sont décrits dans le fichier base.env . Vous pouvez les remplacer dans le fichier .env qui est ajouté à .gitignore. En .env, vous pouvez décrire des valeurs secrètes pour l'environnement.
Les variables d'environnement ont une priorité plus élevée.
Commandes de la console
Pour la description des commandes de la console, j'ai choisi Cobra . Qu'il est bon d'utiliser Cobra avec Viper. Nous pouvons décrire la commande
serverCmd.PersistentFlags().StringVar(&serverPort, "port", defaultServerPort, "Server port")
Et liez la variable d'environnement à la valeur du paramètre de commande
viper.BindPFlag("SERVER_PORT", serverCmd.PersistentFlags().Lookup("port"))
En fait, toute l'application de ce framework est console. Le serveur Web est lancé par l'une des commandes de la console du serveur.
gin -i run server
Journalisation
J'ai choisi le package logrus pour la journalisation , car il contient tout ce dont j'ai généralement besoin: définir les niveaux de journalisation, où se connecter, ajouter des hooks, par exemple, pour envoyer des journaux au système d'alerte.
Intégration de l'enregistreur avec le système d'alerte
J'ai choisi Sentry, car tout s'est avéré assez simple grâce à l'intégration facile avec logrus: logrus_sentry . J'ai fait les paramètres avec l'URL de Sentry SENTRY_DSN
et le délai pour envoyer à Sentry SENTRY_TIMEOUT
. Il s'est avéré que par défaut, le délai d'attente est petit, sinon erroné, 300 ms, et de nombreux messages n'ont pas été remis.
Définition d'une alerte pour les erreurs
J'ai fait un traitement de panique séparément pour le serveur Web et pour les commandes de la console .
Tests unitaires avec redéfinition des services via DI
Comme indiqué ci-dessus, un package séparé devait être alloué pour les tests unitaires. Étant donné que la bibliothèque sélectionnée pour créer un conteneur de services ne permettait pas de redéfinir les services, dans fork a ajouté une méthode pour redéfinir la description des services. De ce fait, dans le test unitaire, vous pouvez utiliser la même description de services que dans l'application
dic.InitBuilder()
Et redéfinissez seulement certaines descriptions de service dans les talons de cette façon
dic.Builder.Set(di.Def{ Name: dic.UserRepository, Build: func(ctn di.Container) (interface{}, error) { return NewUserRepositoryMock(), nil }, })
Ensuite, vous pouvez créer un conteneur et utiliser les services nécessaires dans le test:
dic.Container = dic.Builder.Build() userService := dic.Container.Get(dic.UserService).(service.UserServiceInterface)
Ainsi, nous testerons userService, qui au lieu du vrai référentiel utilisera le stub fourni.
Pourcentage et carte de code de couverture de test
J'étais complètement satisfait de l'utilitaire standard de test go.
Vous pouvez exécuter des tests individuellement
go test test/unit/user_service_test.go -v
Vous pouvez exécuter tous les tests en même temps
go test ./... -v
Vous pouvez créer une carte de couverture et calculer le pourcentage de couverture
go test ./... -v -coverpkg=./... -coverprofile=coverage.out
Et voir une carte de la couverture du code avec des tests dans un navigateur
go tool cover -html=coverage.out
Swagger
Il existe un projet gin-swagger pour Gin, qui peut être utilisé à la fois pour générer des spécifications pour Swagger et pour générer une documentation basée sur celui-ci. Mais, comme il s'est avéré, afin de générer des spécifications pour des opérations spécifiques, il est nécessaire d'indiquer des commentaires sur des fonctions spécifiques du contrôleur. Cela ne s'est pas avéré très pratique pour moi, car je ne voulais pas dupliquer le code d'opérations CRUD dans chaque contrôleur. Au lieu de cela, dans des contrôleurs spécifiques, j'incorpore simplement un contrôleur CRUD comme décrit ci-dessus. Je ne voulais pas vraiment non plus créer de fonctions de stub pour cela.
Par conséquent, je suis arrivé à la conclusion que la spécification est générée à l'aide de goswagger , car dans ce cas, les opérations peuvent être décrites sans être liées à des fonctions spécifiques .
swagger generate spec -o doc/swagger.yml
Soit dit en passant, avec goswagger, vous pouvez même aller de l'inverse et générer le code du serveur Web basé sur la spécification Swagger. Mais avec cette approche, il y avait des difficultés à utiliser ORM, et je l'ai finalement abandonné.
La documentation est générée à l'aide de gin-swagger, pour cela un fichier de spécifications pré-généré est indiqué .
Docker compose
Dans le cadre, j'ai ajouté une description de deux conteneurs - pour le code et pour la base . Au début du conteneur avec le code, nous attendons que le conteneur avec la base soit complètement lancé. Et à chaque démarrage, nous effectuons des migrations si nécessaire. Les paramètres de connexion à la base de données pour les migrations sont décrits, comme mentionné ci-dessus, dans dbconf.yml , où il était possible d'utiliser la variable d'environnement pour transférer les paramètres de connexion à la base de données.
Merci de votre attention. Dans le processus, j'ai dû m'adapter aux caractéristiques de la langue. Je serais intéressé de connaître l'opinion de collègues qui ont passé plus de temps avec Go. Certes, certains moments pourraient être rendus plus élégants, donc je serai heureux de recevoir des critiques utiles. Lien vers le cadre: https://github.com/zubroide/go-api-boilerplate