2 points par GN⁺ 2024-02-10 | 1 commentaires | Partager sur WhatsApp
  • 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 run testable
  • Les handlers doivent être conçus comme des fonctions qui renvoient un http.Handler et 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 dans run() un context.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.Once réduisent le code répétitif tout en conservant le flux standard net/http de 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 /healthz ou /readyz

Création du serveur et point d’entrée du service

  • Le constructeur NewServer est la fonction qui crée le http.Handler central 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.go est 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, addRoutes peut avoir une liste d’arguments similaire
    • Le typage de Go détecte les arguments manquants ou dans le mauvais ordre
  • addRoutes doit 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, addRoutes peut aussi renvoyer une erreur

main ne fait qu’appeler run

  • func main() reste une fonction mince qui appelle run(), écrit l’erreur éventuelle sur stderr et quitte avec un code anormal
    • run reç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 run renvoie une erreur, elle peut être gérée comme dans du Go classique
  • Exemples de valeurs que l’on peut passer à run
    • os.Args : arguments d’exécution du programme et parsing des flags
    • os.Stdin : lecture de l’entrée
    • os.Stdout : écriture de la sortie
    • os.Stderr : écriture des logs d’erreur
    • os.Getenv : lecture des variables d’environnement
    • os.Getwd : obtention du répertoire de travail courant
  • signal.NotifyContext est configuré dans run
    • Lorsqu’un signal d’arrêt comme Ctrl+C arrive, le contexte est annulé
    • Si run renvoie nil, le programme se termine normalement
    • Si une erreur est renvoyée, main l’affiche et quitte avec un code non nul
  • Éviter l’état global permet d’utiliser t.Parallel() dans davantage de tests
    • Plusieurs appels à run ne se perturbent pas entre eux
    • Les flags sont gérés avec flags.NewFlagSet à l’intérieur de run, plutôt qu’avec le package global flag
    • 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

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() ou ctx.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 Shutdown pour 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
  • Pour vérifier en test que le serveur est réellement prêt, on expose un endpoint /healthz ou /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 250ms entre deux requêtes

Façon de composer les handlers

  • Les fonctions de handler renvoient un http.Handler ou un http.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
  • 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-Type JSON, écrit le code de statut, puis appelle json.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 decode renvoie une valeur typée, il faut préciser le type attendu, par exemple decode[CreateSomethingRequest](https://grafana.com/blog/2024/02/09/how-i-write-http-services-in-go-after-13-years/r)
  • La validation utilise une interface à méthode unique
    • L’interface Validator a la forme Valid(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
  • 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 type T à implémenter Validator
    • Appeler len(problems) sur une map nil renvoie 0, donc ne provoque pas de panic

Pattern d’adaptateur de middleware

  • Un middleware reçoit un http.Handler et renvoie un nouveau http.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, adminOnly renvoie HTTP 404 Not Found si 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) renvoie func(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.Handler sé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 /greet n’a besoin que du champ Name et non de tout Person, la struct d’entrée du test ne contient que Name
    • La personne qui lit le test voit immédiatement quels champs intéressent cet 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.Once garantit 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.Do afin 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 run permet 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
  • 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.NewRecorder et http.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 run pour 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.go sont vérifiées ensemble
    • Il peut interagir avec une vraie base de données
  • 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.WithCancel et l’enregistre avec t.Cleanup(cancel)
    • À la fin du test, le contexte est annulé et le programme s’arrête proprement
    • t.Cleanup de Go 1.14 sert d’alternative à l’utilisation directe de defer

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

 
GN⁺ 2024-02-10
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’erreurs
    Par 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 Username et un constructeur NewUsername(username string) (Username, error), le simple fait qu’un objet Username existe garantit déjà qu’il a passé la validation
    [0] https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...

    • C’est toujours surprenant de constater à quel point le code s’améliore quand on utilise correctement le système de types. Il ne faut pas tout passer en string, mais parser et attribuer un type
    • C’est un bon patron de conception, mais il faut se méfier de la validation trop précoce
      Ce 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/
    • Conceptuellement, c’est la même chose que les vieilles techniques du constructeur privé et de la méthode fabrique
    • Liens connexes : Parse, don't validate (2019) - https://news.ycombinator.com/item?id=35053118 - mars 2023, Parse, Don't Validate (2019) - https://news.ycombinator.com/item?id=27639890 - juin 2021, Parse, Don’t Validate - https://news.ycombinator.com/item?id=21476261 - novembre 2019, Parse, Don't Validate - https://news.ycombinator.com/item?id=21471753 - novembre 2019
    • En Go, ce patron était difficile à appliquer. Si Username est inclus dans une structure et qu’on oublie de lui affecter une valeur, on obtient une zero value qui peut violer la contrainte
  • L’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

    • Le point clé n’est pas un objet générique de données de configuration, mais un objet de configuration mutable
      Dans les projets Python, j’utilise beaucoup des dataclass de 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 une dataclass devient un patron de conception assez pratique
    • Ma façon préférée d’éviter cela consiste à rendre la configuration vraiment immuable, tout en permettant de la composer avec des fonctions Option
      Les options internes ne sont modifiées que pendant la création, et seules Config et des méthodes d’accès sont exposées à l’extérieur. Par exemple, on peut créer avec config.New(config.Name("Emanon")) et lire avec cfg.Name()
    • J’ai tendance à créer une structure Config pour chaque package, et à faire en sorte que configs.Config regroupe les Config de chaque package
      Ce 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
    • D’accord. Un jour, j’ai modifié l’objet de configuration de plus haut niveau et provoqué un incident majeur
      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_USE pour le modifier, mais j’ai quand même changé l’objet avec cette API et causé l’incident
    • La critique me semble valable. Je me demande quel patron serait, selon vous, plus agréable à utiliser
  • J’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 NewServer soit un gros constructeur qui reçoit toutes les dépendances en arguments, et que, dans les tests, on passe nil pour signaler qu’une dépendance inutile n’est pas utilisée
    Ré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

    • Dans les dépôts sur lesquels je travaille, je suis devenu de plus en plus sensible à ces points de fort couplage afférent, surtout à mesure que je me suis enfoncé dans l’univers Bazel, où la gestion des dépendances et la conception physique ont un impact croissant sur le code que l’on écrit
      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 »
    • J’ai tendance à écrire la majeure partie de la logique sous forme de packages. Par exemple, si je créais HN, j’aurais un package users ou un package comments
      Ces packages n’ont aucune interface HTTP, mais chacun a son propre main et une sorte d’interface CLI. Le commentaire de fichier //go:build ignore est utile pour cela
    • J’utilise simplement des closures
      Au 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 forme func HandleX(store *DataStore, dep1 Foo, dep2 Bar, commonDep Common) http.HandlerFunc, puis retourner le vrai http.HandlerFunc à l’intérieur
      Ensuite, je l’initialise une seule fois au point d’entrée
    • Cela signifie que l’objet créé par NewServer fait trop de choses. Il y a de bonnes chances que trop de types de données et de comportements soient couplés
      Exemple 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
    • J’ai eu longtemps le même ressenti, et je suis maintenant passé à une approche avec une structure de configuration optionnelle
      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 simples
      J’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 courte
    Avec 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

    • Je place toujours les handlers dans des structures individuelles ayant une méthode qui traite la route/la requête
      Par exemple, une structure CreateUser contient uniquement les dépendances nécessaires à cette opération, comme store, cache, logger, pub, et implémente ServeHTTP
      Dans main.go ou à 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 structure
      En 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
    • Il n’est pas forcément nécessaire d’avoir 9 arguments séparés ou plus. Dans certains langages, on peut utiliser un unique objet context/env contenant seulement ce dont le handler a besoin
      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 handler
  • Je 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 run afin que les tests soient plus ciblés
    Je 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 nul

    • Le premier point est intéressant. Je me demande si ce n’est pas un problème déjà résolu par la propagation du contexte et l’attente de l’arrêt du serveur
  • Je 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é.

    • Le problème de cette approche, c’est que rédiger OpenAPI à la main dès le départ est terriblement fastidieux.
      Écrire un IDL similaire, comme Protobuf ou capnproto, me paraît beaucoup plus productif.
    • Si vous préférez exporter une spécification OpenAPI depuis du code Go, danielgtaylor/huma[1] et swaggest/rest[2] sont aussi de bonnes options.
      [1] https://github.com/danielgtaylor/huma
      [2] https://github.com/swaggest/rest
    • De même, j’ai commencé avec oapigen : github.com/deepmap/oapi-codegen
      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.

    • Je déteste vraiment ce genre de framework d’injection de dépendances.
      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.