Comment écrire des services HTTP en Go après 13 ans
(grafana.com)- Un service HTTP Go destiné à durer devient plus facile à maintenir et à valider lorsqu’il repose sur une transmission explicite des dépendances, des routes regroupées au même endroit et une fonction
runtestable - Les handlers doivent être conçus comme des fonctions qui renvoient un
http.Handleret reçoivent les valeurs nécessaires via des closures, plutôt que comme des méthodes d’une struct serveur ; les middlewares communs se composent lors de la création du serveur et de l’enregistrement des routes - Garder
func main()minimal et injecter dansrun()uncontext.Context, les arguments, l’accès à l’environnement et les entrées/sorties standard simplifie la gestion de l’arrêt et le contrôle en test - L’encodage des requêtes/réponses, la validation, les adaptateurs de middleware et l’initialisation différée avec
sync.Onceréduisent le code répétitif tout en conservant le flux standardnet/httpde Go - Les tests privilégient une approche end-to-end, plus proche de véritables appels API que de tests de handlers isolés ; chaque test lance son propre serveur et vérifie l’état de préparation via
/healthzou/readyz
Création du serveur et point d’entrée du service
- Le constructeur
NewServerest la fonction qui crée lehttp.Handlercentral du service- On en a généralement un par service, avec des routes internes qui répartissent les requêtes vers chaque handler
- Il reçoit en arguments toutes les dépendances comme le logger, la configuration, le stockage et les clients externes
- Il renvoie si possible un
http.Handler, et peut utiliser un type dédié dans les cas plus complexes - Il configure son propre muxer, puis le transmet à la fonction d’enregistrement des routes dans
routes.go
- Les traitements HTTP communs à tous les endpoints sont regroupés dans
NewServer- CORS
- Middleware d’authentification
- Logging
- Middleware d’ID de trace
- Même si la liste des dépendances s’allonge, on préfère les expliciter sous forme d’arguments de fonction
- Oublier un champ de struct peut ne pas être détecté par le compilateur, alors qu’une fonction ne peut pas être appelée si une valeur requise n’est pas fournie
- Une longue liste d’arguments reste lisible si elle est formatée verticalement
- Une dépendance non utilisée dans un test donné peut être passée à
nil, ce qui signale qu’elle n’est pas utilisée
Regrouper la surface de l’API dans routes.go
routes.goest le fichier où toutes les routes du service sont visibles au même endroit- Chaque projet dispose ainsi d’un emplacement unique pour parcourir la surface de l’API
- En raison de la longue liste de dépendances de
NewServer,addRoutespeut avoir une liste d’arguments similaire - Le typage de Go détecte les arguments manquants ou dans le mauvais ordre
addRoutesdoit rester aussi simple et plat que possible- Les opérations susceptibles d’échouer sont traitées en amont dans la fonction
run - Lors de l’enregistrement des handlers, on se concentre sur le routage avec
mux.Handle,mux.HandleFunc,http.NotFoundHandler, etc. - Si la conception impose que les handlers eux-mêmes renvoient des erreurs,
addRoutespeut aussi renvoyer une erreur
- Les opérations susceptibles d’échouer sont traitées en amont dans la fonction
main ne fait qu’appeler run
func main()reste une fonction mince qui appellerun(), écrit l’erreur éventuelle surstderret quitte avec un code anormalrunreçoit en arguments les éléments de base du système d’exploitation :context.Context, arguments, entrées/sorties et fonctions d’accès à l’environnement- Comme
runrenvoie une erreur, elle peut être gérée comme dans du Go classique
- Exemples de valeurs que l’on peut passer à
runos.Args: arguments d’exécution du programme et parsing des flagsos.Stdin: lecture de l’entréeos.Stdout: écriture de la sortieos.Stderr: écriture des logs d’erreuros.Getenv: lecture des variables d’environnementos.Getwd: obtention du répertoire de travail courant
signal.NotifyContextest configuré dansrun- Lorsqu’un signal d’arrêt comme
Ctrl+Carrive, le contexte est annulé - Si
runrenvoienil, le programme se termine normalement - Si une erreur est renvoyée,
mainl’affiche et quitte avec un code non nul
- Lorsqu’un signal d’arrêt comme
- Éviter l’état global permet d’utiliser
t.Parallel()dans davantage de tests- Plusieurs appels à
runne se perturbent pas entre eux - Les flags sont gérés avec
flags.NewFlagSetà l’intérieur derun, plutôt qu’avec le package globalflag - Les variables d’environnement sont contrôlées en injectant
getenv func(string) string, au lieu de modifier l’environnement réel - Contrairement à
t.SetEnv, cette approche permet de conserver des tests parallèles
- Plusieurs appels à
Gestion de l’arrêt et de l’état prêt
- Le contexte doit être propagé à toutes les couches du service
- Lorsqu’un signal d’arrêt arrive, le contexte est annulé
- Les traitements longs ou répétitifs vérifient
ctx.Err()ouctx.Done()et s’interrompent - Même lorsqu’on lance d’autres goroutines, le contexte sert à déterminer quand s’arrêter
- Lors de l’arrêt, le serveur HTTP appelle
Shutdownpour s’arrêter proprement- Dans l’exemple, une goroutine séparée attend
ctx.Done() - Le contexte d’arrêt reçoit un timeout de
10 * time.Second - Toute erreur pendant l’arrêt est consignée dans
stderr
- Dans l’exemple, une goroutine séparée attend
- Pour vérifier en test que le serveur est réellement prêt, on expose un endpoint
/healthzou/readyz- On peut aussi créer un signal de disponibilité avec un canal séparé, mais la vérification par requête HTTP réelle est privilégiée
- La boucle de vérification de disponibilité envoie des requêtes jusqu’à recevoir
200 OK - Elle renvoie une erreur si le contexte est annulé ou si le timeout est atteint
- Dans l’exemple, la boucle attend
250msentre deux requêtes
Façon de composer les handlers
- Les fonctions de handler renvoient un
http.Handlerou unhttp.HandlerFunc, plutôt que de les implémenter directement- Exemple :
func handleSomething(logger *Logger) http.Handler - On peut créer un environnement de closure propre à chaque handler
- Les valeurs initialisées peuvent être utilisées lors du traitement des requêtes
- Exemple :
- Il est plus sûr d’utiliser les données partagées uniquement en lecture seule
- Si un handler modifie des valeurs, il faut un mécanisme de protection comme un mutex
- Stocker l’état du programme dans une closure n’est généralement pas recommandé
- Dans un environnement cloud, il est difficile de supposer qu’une instance restera longtemps en vie
- Le serveur peut être arrêté pour économiser des ressources, ou crasher pour d’autres raisons
- Plusieurs instances peuvent s’exécuter en même temps et les requêtes peuvent être réparties de manière imprévisible
- Dans un vrai projet, il vaut mieux placer l’état persistant dans une base de données ou une API de stockage séparée
Encodage, décodage et validation des requêtes/réponses
- Tous les services ont besoin de décoder le corps des requêtes et d’encoder le corps des réponses ; on ajoute donc des helpers
encode/decode- L’exemple définit le
Content-TypeJSON, écrit le code de statut, puis appellejson.NewEncoder(w).Encode(v) - Le décodage encapsule
json.NewDecoder(r.Body).Decode(&v)et ajoute du contexte à l’erreur - Avec les génériques, l’inférence de type permet d’écrire
encode(w, r, http.StatusOK, obj) - Comme
decoderenvoie une valeur typée, il faut préciser le type attendu, par exempledecode[CreateSomethingRequest](https://grafana.com/blog/2024/02/09/how-i-write-http-services-in-go-after-13-years/r)
- L’exemple définit le
- La validation utilise une interface à méthode unique
- L’interface
Validatora la formeValid(ctx context.Context) map[string]string - S’il n’y a pas de problème, une map de longueur 0 est renvoyée
- Les champs problématiques utilisent le nom du champ comme clé et une description lisible par un humain comme valeur
- L’interface
- Les objets à valider se prêtent à des vérifications rapides de champs
- Vérifier qu’un champ obligatoire n’est pas vide
- Vérifier qu’une chaîne suit un format donné, par exemple un e-mail
- Vérifier qu’un nombre se situe dans la plage autorisée
- Les contrôles plus complexes, comme une requête en base de données, sont traités ailleurs
- Ces contrôles sont trop importants pour être cachés dans une fonction de validation rapide
- La version générique
decodeValid[T Validator]force le typeTà implémenterValidator - Appeler
len(problems)sur une mapnilrenvoie 0, donc ne provoque pas de panic
Pattern d’adaptateur de middleware
- Un middleware reçoit un
http.Handleret renvoie un nouveauhttp.Handler- Il peut exécuter du code avant ou après l’appel du handler d’origine
- Selon les conditions, il peut aussi ne pas appeler le handler d’origine
- Dans l’exemple,
adminOnlyrenvoieHTTP 404 Not Foundsi l’utilisateur n’est pas administrateur, sans appeler le handler d’origine
- Les middlewares sont généralement appliqués dans
routes.go- Rien qu’en consultant la liste des endpoints, on voit quels middlewares sont attachés à quelles routes
- Si la liste des middlewares devient longue, on la répartit sur plusieurs lignes pour améliorer la lisibilité
- Un middleware avec beaucoup de dépendances est encapsulé dans une fonction qui renvoie le middleware
newMiddleware(logger, db, slackClient, rroll)renvoiefunc(http.Handler) http.Handler- Le code d’enregistrement des routes reste concis avec
middleware(handleSomething(...)) - On peut définir un
type middleware func(h http.Handler) http.Handlerséparé, mais écrire directement le type de retour rend le code plus clair à la lecture
Réduire la portée des types de requête/réponse
- Les types de requête/réponse utilisés par un seul endpoint peuvent être définis à l’intérieur de la fonction handler
- L’espace de noms global reste propre
- Cela évite que d’autres handlers dépendent de types dont la stabilité n’est pas garantie
- Si le même type est nécessaire dans le code de test, cela peut créer de la friction
- Dans ce cas, sortir le type de la fonction est également légitime
- Quand les types de requête/réponse sont dans le handler, les tests peuvent déclarer une nouvelle struct anonyme ou un type local
- Les types locaux dans les tests expriment l’intention
- Par exemple, si l’endpoint
/greetn’a besoin que du champNameet non de toutPerson, la struct d’entrée du test ne contient queName - La personne qui lit le test voit immédiatement quels champs intéressent cet endpoint
- Par exemple, si l’endpoint
Différer l’initialisation avec sync.Once
- Les opérations coûteuses pendant la préparation d’un handler sont reportées jusqu’à la première requête avec
sync.Once- Le temps de démarrage de l’application diminue
- Si le handler n’est jamais appelé, l’opération coûteuse n’est jamais exécutée
- L’exemple parse des fichiers de templates une seule fois lors de la première requête
sync.Oncegarantit que le code ne s’exécute qu’une seule fois- Les autres requêtes arrivant en même temps attendent la fin de l’initialisation
- Le contrôle d’erreur est effectué en dehors de
init.Doafin que l’erreur continue à remonter
- Cette approche déplace le temps d’initialisation du démarrage vers le moment du premier accès à l’endpoint en runtime
- Elle peut être adaptée dans des environnements qui utilisent beaucoup Google App Engine
- Selon l’environnement de déploiement, il faut décider où et quand utiliser
sync.Once
Stratégie de test
- Cette structure vise fortement la testabilité
- La fonction
runpermet au code de test d’exécuter directement le programme - Les tests sont évalués selon leur capacité à rendre le comportement du programme compréhensible, à réduire la crainte de casser quelque chose lors d’un changement et à donner confiance pour un déploiement en production après leur passage au vert
- La fonction
- On peut aussi tester seulement les handlers de façon isolée
- On appelle la fonction de création du handler et on lui fournit les dépendances nécessaires
- On construit requête et réponse avec
httptest.NewRecorderethttp.NewRequest - On vérifie le code de statut, le corps de réponse et les headers
- Cette approche contourne les middlewares comme l’authentification et entre directement dans le code du handler
- L’approche préférée se rapproche davantage des tests end-to-end
- On appelle
runpour lancer le programme presque comme en conditions réelles - Cela inclut le parsing des arguments, le câblage des dépendances, les migrations de base de données et le démarrage du serveur
- Quand le test appelle l’API, toutes les couches ainsi que
routes.gosont vérifiées ensemble - Il peut interagir avec une vraie base de données
- On appelle
- Cette approche aide à réduire les tests répétitifs
- Tester chaque couche séparément peut vérifier plusieurs fois la même chose de façon légèrement différente
- Les tests end-to-end fournissent un ensemble central de tests décrivant l’interaction entre l’utilisateur et le système
- Les tests unitaires déjà créés, par exemple via TDD, peuvent être conservés s’ils restent pertinents, mais supprimés s’ils répètent la même chose que les tests end-to-end
- Chaque test peut lancer sa propre instance du programme
- Il passe des arguments, flags, entrées/sorties standard et variables d’environnement différents selon le test
- Il crée une fonction d’annulation avec
context.WithCancelet l’enregistre avect.Cleanup(cancel) - À la fin du test, le contexte est annulé et le programme s’arrête proprement
t.Cleanupde Go 1.14 sert d’alternative à l’utilisation directe dedefer
Portée d’application concrète et contexte organisationnel
- Pour construire une API simple, ce pattern vise un code lisible et facile à faire évoluer
- Le pattern est facile à copier et à étendre
- Il facilite le travail des nouvelles personnes
- Les changements suscitent moins d’inquiétude
- La composition est explicite, sans comportement magique
- Même avec des outils de génération de code, cette approche peut être conservée
- Par exemple, le package Oto peut servir à générer du boilerplate basé sur des templates
- Dans les grands projets ou les grandes organisations, les choix techniques existants peuvent modifier la décision
- Dans une organisation comme Grafana Labs, certains outils et abstractions peuvent déjà être largement utilisés
- gRPC en est un exemple
- Lorsqu’il existe des patterns établis et de l’expérience, le choix pratique consiste à suivre ce mouvement
- Le contexte de la suite Grafana IRM est également inclus
- Grafana IRM est une suite de produits en cours de construction chez Grafana Labs
- Grafana Alerting envoie des alertes lorsque des métriques sortent des plages autorisées
- Grafana OnCall automatise le processus de contact de la bonne personne à l’aide de plannings et de règles d’escalade
- Grafana Incident crée une salle Zoom, un canal Slack dédié et une chronologie des événements, et aide à la réponse aux incidents
- Les éléments d’un canal Slack ayant reçu une réaction avec l’emoji visage de robot sont ajoutés à la chronologie
1 commentaires
Avis sur Hacker News
J’ai aussi essayé une approche consistant à séparer un validateur, comme une méthode
Valid, mais depuis que j’ai lu « Parse, Don’t Validate » de Lexi Lambda [0], j’ai l’impression qu’exploiter le vérificateur de types de Go entraîne beaucoup moins d’erreursPar exemple, si l’on veut empêcher absolument qu’un utilisateur définisse un nom d’utilisateur illégal contenant des chevrons, avec l’approche par validateur il faut appeler le validateur dans tous les chemins de code où le nom d’utilisateur provient d’une entrée non fiable
À la place, si l’on définit un type
Usernameet un constructeurNewUsername(username string) (Username, error), le simple fait qu’un objetUsernameexiste garantit déjà qu’il a passé la validation[0] https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...
string, mais parser et attribuer un typeCe patron permet de valider aussi tôt ou aussi tard qu’on le veut, mais il ne dit pas quand il faut le faire. Souvent, le mieux est de le faire dans le cadre du parsing/de la validation d’un objet plus large
Dans le contexte d’une UI, l’article « I is for Intent » [1] de Steven Witten est une bonne référence pour l’idée de manipuler des données non validées
[1] https://acko.net/blog/i-is-for-intent/
Usernameest inclus dans une structure et qu’on oublie de lui affecter une valeur, on obtient une zero value qui peut violer la contrainteL’un des patrons que je déteste le plus consiste à recevoir un objet Config représentant la configuration de tout le système et à le passer partout de manière mutable
Avec cela, tout se retrouve couplé via l’objet de configuration. Dans un système, quelqu’un réécrivait des valeurs dans l’objet de configuration reçu, si bien que chaque partie devait être configurée dans un ordre précis pour que tout fonctionne correctement
Dans un autre cas, un sous-système écrivait dans l’objet de configuration des données qu’un autre lirait plus tard, ce qui empêchait de désactiver une partie du système
Le patron où « la configuration est une seule grosse valeur mutable » est assez pénible, pas seulement en Go mais aussi dans d’autres langages
Dans les projets Python, j’utilise beaucoup des
dataclassde configuration immuables que je passe à plusieurs modules ; quand plusieurs fonctions dépendent de plusieurs valeurs, au lieu de passer chacune d’elles en argument de fonction et de définir les types séparément, avoir toutes les variables et définitions de types au même endroit dans unedataclassdevient un patron de conception assez pratiqueOptionLes
optionsinternes ne sont modifiées que pendant la création, et seulesConfiget des méthodes d’accès sont exposées à l’extérieur. Par exemple, on peut créer avecconfig.New(config.Name("Emanon"))et lire aveccfg.Name()Configpour chaque package, et à faire en sorte queconfigs.Configregroupe lesConfigde chaque packageCe n’est peut-être pas une bonne pratique Go, mais au démarrage on peut représenter toute la configuration du système comme une seule entité, tout en ne passant à chaque package que les dépendances minimales dont il a besoin
Pour les tests aussi, cela devient un peu plus simple, car il n’est plus nécessaire de fabriquer une fausse configuration complète juste pour tester un seul package
Il ne faut jamais le modifier. Comme on ne sait pas où ni comment il est utilisé, cela gaspille inutilement du temps humain ; si un changement est nécessaire, mieux vaut créer une valeur dérivée à partir de l’original
Ce qui est drôle, c’est que l’objet de configuration était conçu pour être en quelque sorte immuable, et qu’il fallait utiliser une API
WARNING_DO_NOT_USEpour le modifier, mais j’ai quand même changé l’objet avec cette API et causé l’incidentJ’apprécie énormément le travail de Mat Ryer, et j’ai appliqué à tous mes projets Go depuis lors la plupart des idées présentées dans la version 2018 de cet article
Cela dit, j’ai toujours été gêné par le fait que
NewServersoit un gros constructeur qui reçoit toutes les dépendances en arguments, et que, dans les tests, on passenilpour signaler qu’une dépendance inutile n’est pas utiliséeRésultat : de larges pans du code se retrouvent avec beaucoup d’état partagé inutile. En pratique, beaucoup de handlers HTTP n’ont besoin que de vérifier si l’utilisateur de la requête peut accéder à une ressource puis d’appeler une seule fonction du dépôt de données, mais ils deviennent une partie d’un énorme bloc ayant accès à tous les objets du serveur parent et à l’intégralité du dépôt de données
Même quand on veut seulement mocker deux méthodes pour les tester, il devient difficile d’écrire un test simple ; le pattern de Mat Ryer est le meilleur que j’aie vu jusqu’ici, mais il me reste l’impression qu’il doit exister une meilleure solution
Quand c’est possible, les plugins sont une bonne stratégie de frontière de code. Une architecture à plugins est, par défaut, désactivée : elle ne se révèle que si on la choisit explicitement, et n’impose donc pas toutes les possibilités à un morceau de code donné
J’appelle ce type de logiciel « à la carte ». En général, il faut éviter les situations où l’on « fait tout pour pouvoir faire n’importe quoi »
usersou un packagecommentsCes packages n’ont aucune interface HTTP, mais chacun a son propre
mainet une sorte d’interface CLI. Le commentaire de fichier//go:build ignoreest utile pour celaAu lieu de définir un handler comme
func HandleX(w http.ResponseWriter, req *http.Request), je lui fais recevoir les dépendances nécessaires sous la formefunc HandleX(store *DataStore, dep1 Foo, dep2 Bar, commonDep Common) http.HandlerFunc, puis retourner le vraihttp.HandlerFuncà l’intérieurEnsuite, je l’initialise une seule fois au point d’entrée
NewServerfait trop de choses. Il y a de bonnes chances que trop de types de données et de comportements soient couplésExemple simple : si l’on ajoute un logger comme dépendance du constructeur, l’objet fait un peu plus de choses que dans l’implémentation simple initiale. Ce n’est pas un problème en soi, mais il est dommage de ne pas trouver de façon de logger sans modifier l’implémentation de quelque chose de simple
Les fonctions d’ordre supérieur, par exemple un décorateur de logger, permettent la composition, même si elles ont aussi leurs inconvénients. Cela reste une forme de structure maîtrisable, pas une erreur
L’idée centrale est de valider les valeurs de cette structure optionnelle dans
NewServer, puis de les copier dans la structure du serveur. On peut ainsi mocker moins de dépendances, ce qui rend les tests beaucoup plus simplesJ’ai aussi beaucoup essayé le pattern des options fonctionnelles, comme plusieurs personnes le recommandent, mais j’ai fini par l’abandonner. Je l’ai trouvé un peu trop malin, plus difficile à lire, et avec davantage de boilerplate que le pattern structure de configuration + validation puis copie
[0] https://news.ycombinator.com/item?id=39320170
J’aimerais que cette idée soit plus largement acceptée pour les services HTTP, quel que soit le langage : si un handler a besoin de dépendances, il doit les demander directement comme arguments, au lieu d’être une méthode accrochée à une structure serveur qui introduira des dépendances surprises au moment des tests
Les handlers d’un service HTTP contiennent généralement beaucoup de logique métier, et cette logique a de fortes chances d’avoir beaucoup de dépendances. En pratique, je vois souvent un handler unique utiliser une DB, un cache, un stockage de blobs, une vérification d’autorisations propre à l’endpoint, un vérificateur de licence, une queue, un logger spécialisé, un client de métriques, etc.
On peut se retrouver avec 9 paramètres ou plus, et les linters ou règles empiriques essaient souvent d’empêcher cela ; mais les dépendances ne disparaissent pas, on les cache simplement dans une classe/structure
server, puis on se ment en disant qu’il y a peu de dépendances parce que la signature de méthode est courteAvec le temps, j’ai fini par penser qu’un code où toutes les dépendances apparaissent dans les signatures de fonctions/méthodes est préférable, même si elles finissent par être 20. Au moins, on ne se cache pas que la complexité du code augmente
Par exemple, une structure
CreateUsercontient uniquement les dépendances nécessaires à cette opération, commestore,cache,logger,pub, et implémenteServeHTTPDans
main.goou à l’endroit où les dépendances sont configurées, on crée chaque opération en lui passant seulement les dépendances dont elle a besoin. C’est pratique, car les méthodes auxiliaires propres à une opération/un handler peuvent rester des méthodes privées de cette structureEn revanche, si une opération a besoin d’une autre opération, on finit par se les passer mutuellement ou par devoir les extraire dans un package/service séparé, ce qui peut devenir pénible
Par exemple, avec
handleHello({ db, cache, blobStore, authz }, req, res), si deux handlers utilisent exactement le même contexte, on peut le réutiliser, et il est aussi facile de déclarer au point d’appel le contexte propre à chaque handlerJe suis d’accord avec une grande partie de cet article et j’aimerais ajouter quelques points
Si l’on passe un WaitGroup à la structure de service avec le contexte de l’application, une interruption peut déclencher l’arrêt de l’application via le contexte, et la goroutine principale peut attendre le WaitGroup avant l’arrêt effectif
Pour un programme CLI, il est utile de tester stdout, stdin, stderr, args, env, etc., mais je pense que c’est moins le cas pour un serveur HTTP. Je passerais une configuration structurée à la fonction
runafin que les tests soient plus ciblésJe ne suis pas d’accord avec l’idée de parser les templates dans le handler avec
sync.Once. À mon avis, un handler ne devrait pas parser les templates ; cela devrait être fait au démarrage de l’application. Si les templates ne peuvent pas être parsés, l’application ne doit pas être considérée comme prête à recevoir des requêtes et doit se terminer avec un code de sortie non nulJe joue récemment avec ogen : https://github.com/ogen-go/ogen
Quand on écrit une définition OpenAPI, il prend en charge le routage, la définition des structures, la validation des schémas JSON, etc. Il ne me reste qu’à implémenter le service.
Des validations comme la plage d’entiers d’une query string sont vraiment fastidieuses, et si on les écrit soi-même, il est très facile de faire une faute de frappe.
Pour l’instant, je n’en suis qu’au stade où je m’amuse avec, donc je n’ai pas encore trouvé de mauvais côté.
Écrire un IDL similaire, comme Protobuf ou capnproto, me paraît beaucoup plus productif.
[1] https://github.com/danielgtaylor/huma
[2] https://github.com/swaggest/rest
Je pensais que rédiger la spécification serait pénible, mais c’était bien mieux que prévu ; de toute façon, comme il faut une spécification, autant l’écrire en amont.
J’ai le sentiment que fx (https://github.com/uber-go/fx) est un outil à la fois très simple et polyvalent pour concevoir une application.
Les conseils de l’article restent utiles, mais il supprime complètement la question « comment garantir que X est initialisé quand Y en a besoin ». Le problème N*M devient un problème N : il suffit de se soucier de la façon d’initialiser chaque morceau, sans se préoccuper de la synchronisation de l’initialisation entre les morceaux.
J’ai pas mal utilisé des bibliothèques d’injection de dépendances dans plusieurs langages, et j’en ai aussi implémenté moi-même, mais la simplicité et la généralité de fx sont ce que j’ai préféré jusqu’ici.
Dans un système bien conçu, ce problème devrait être trivial. Garantir que quelque chose est initialisé quand on veut l’utiliser revient simplement à s’assurer qu’il est prêt à être passé comme argument de constructeur.
On peut faire quelque chose comme
stockService := NewStockService(),orderService := NewOrderService(),orderProcessor := NewOrderProcessor(stockService, orderService).Il ne devrait pas y avoir besoin de « synchronisation » de l’initialisation, et si c’est incorrect, ça ne compile pas. Même si on ajoute une dépendance circulaire, le fait qu’on ne puisse pas la construire dans le bon ordre apparaît clairement.
C’est un excellent article, avec beaucoup d’idées intéressantes. J’ai du mal à croire que je ne connaissais pas
signal.NotifyContext.Maintenant, je vais pouvoir me souvenir de la façon de gérer les signaux sans avoir à faire du copier-coller dans chaque projet.
J’aime beaucoup l’approche présentée ici, mais mes tests sont un peu différents.
Dans
newTestServer(), je démarre un serveur avec de fausses dépendances injectées ; si je veux tester une erreur de dépendance, je remplace l’attribut concerné par un faux qui renvoie une erreur.Cela me permet de vérifier les chemins d’erreur, les entrées de log, l’émission de métriques, les timeouts, et même le graceful shutdown.
Une fois le serveur démarré, je vérifie sur quel port il s’est lié. Comme la valeur par défaut est
:0, il faut attendre le port réellement attribué.Les tests « unitaires » peuvent se faire au niveau du handler ou au niveau HTTP, en traversant toute la chaîne de middlewares ou pas du tout, tout en testant suffisamment le code tel que l’utilisateur le verra. Il est aussi possible de lancer N instances et de les tester en parallèle.
Je n’utilise pas Go, mais j’aime ces patterns. Ils me semblent assez universellement applicables au code testable.
Je ne veux plus revoir de guides de démarrage rapide, surtout en Python, qui traitent les dépendances de manière implicite/statique/intestable.