For the complete documentation index, see llms.txt. This page is also available as Markdown.

Lecture de fichiers

Dans ce chapitre, nous allons apprendre comment lire des fichiers, en extraire des données et faire quelque chose d'utile.

Imaginez que vous travaillez avec un ami pour créer un logiciel de blog. L'idée est qu'un auteur écrira ses articles en markdown, avec des métadonnées en haut du fichier. Au démarrage, le serveur web lira un dossier pour créer des Post, puis une fonction NewHandler séparée utilisera ces Post comme source de données pour le serveur web du blog.

On nous a demandé de créer le package qui convertit un dossier donné de fichiers d'articles de blog en une collection de Post.

Exemple de données

hello world.md

Title: Hello, TDD world!
Description: First post on our wonderful blog
Tags: tdd, go
---
Hello world!

Le corps des articles commence après le `---`

Données attendues

type Post struct {
	Title, Description, Body string
	Tags                     []string
}

Développement itératif et piloté par les tests

Nous adopterons une approche itérative où nous prenons toujours des étapes simples et sûres vers notre objectif.

Cela nous oblige à découper notre travail, mais nous devons veiller à ne pas tomber dans le piège d'une approche "bottom-up".

Nous ne devrions pas faire confiance à notre imagination trop active lorsque nous commençons à travailler. Nous pourrions être tentés de créer une sorte d'abstraction qui n'est validée qu'une fois que nous avons tout assemblé, comme une sorte de BlogPostFileParser.

Ce n'est pas itératif et cela nous prive des boucles de rétroaction serrées que le TDD est censé nous apporter.

Kent Beck dit :

L'optimisme est un risque professionnel de la programmation. La rétroaction est le traitement.

Au lieu de cela, notre approche devrait s'efforcer d'être aussi proche que possible de la livraison d'une réelle valeur pour le consommateur aussi rapidement que possible (souvent appelée "happy path"). Une fois que nous avons livré une petite quantité de valeur pour le consommateur de bout en bout, l'itération ultérieure du reste des exigences est généralement simple.

Réfléchir au type de test que nous voulons voir

Rappelons-nous notre état d'esprit et nos objectifs au départ :

  • Écrire le test que nous voulons voir. Réfléchissons à la façon dont nous aimerions utiliser le code que nous allons écrire du point de vue d'un consommateur.

  • Se concentrer sur le quoi et le pourquoi, mais ne pas se laisser distraire par le comment.

Notre package doit offrir une fonction qui peut être dirigée vers un dossier et nous retourner des articles.

Pour écrire un test à ce sujet, nous aurions besoin d'une sorte de dossier de test avec quelques exemples d'articles. Il n'y a rien de terriblement mal à cela, mais vous faites quelques compromis :

  • pour chaque test, vous devrez peut-être créer de nouveaux fichiers pour tester un comportement particulier

  • certains comportements seront difficiles à tester, comme l'échec du chargement des fichiers

  • les tests s'exécuteront un peu plus lentement car ils devront accéder au système de fichiers

Nous nous couplons également inutilement à une implémentation spécifique du système de fichiers.

Abstractions du système de fichiers introduites dans Go 1.16

Go 1.16 a introduit une abstraction pour les systèmes de fichiers ; le package io/fs.

Le package fs définit des interfaces de base pour un système de fichiers. Un système de fichiers peut être fourni par le système d'exploitation hôte mais aussi par d'autres packages.

Cela nous permet de desserrer notre couplage à un système de fichiers spécifique, ce qui nous permettra ensuite d'injecter différentes implémentations selon nos besoins.

Du côté du producteur de l'interface, le nouveau type embed.FS implémente fs.FS, tout comme zip.Reader. La nouvelle fonction os.DirFS fournit une implémentation de fs.FS soutenue par un arbre de fichiers du système d'exploitation.

Si nous utilisons cette interface, les utilisateurs de notre package disposent d'un certain nombre d'options intégrées à la bibliothèque standard à utiliser. Apprendre à tirer parti des interfaces définies dans la bibliothèque standard de Go (par exemple, io.fs, io.Reader, io.Writer) est vital pour écrire des packages faiblement couplés. Ces packages peuvent ensuite être réutilisés dans des contextes différents de ceux que vous avez imaginés, avec un minimum de tracas pour vos consommateurs.

Dans notre cas, peut-être que notre consommateur souhaite que les articles soient intégrés dans le binaire Go plutôt que dans des fichiers d'un système de fichiers "réel" ? Quoi qu'il en soit, notre code n'a pas besoin de s'en soucier.

Pour nos tests, le package testing/fstest nous offre une implémentation de io/FS à utiliser, similaire aux outils que nous connaissons dans net/http/httptest.

Compte tenu de ces informations, l'approche suivante semble meilleure,

Écrire le test d'abord

Nous devrions garder la portée aussi petite et utile que possible. Si nous prouvons que nous pouvons lire tous les fichiers d'un répertoire, ce sera un bon début. Cela nous donnera confiance dans le logiciel que nous écrivons. Nous pouvons vérifier que le nombre de []Post retournés est le même que le nombre de fichiers dans notre système de fichiers fictif.

Créez un nouveau projet pour travailler sur ce chapitre.

  • mkdir blogposts

  • cd blogposts

  • go mod init github.com/{votre-nom}/blogposts

  • touch blogposts_test.go

Notez que le package de notre test est blogposts_test. Rappelez-vous, lorsque le TDD est bien pratiqué, nous adoptons une approche pilotée par le consommateur : nous ne voulons pas tester les détails internes car les consommateurs ne s'en soucient pas. En ajoutant _test à notre nom de package prévu, nous n'accédons qu'aux membres exportés de notre package - tout comme un vrai utilisateur de notre package.

Nous avons importé testing/fstest qui nous donne accès au type fstest.MapFS. Notre système de fichiers fictif passera fstest.MapFS à notre package.

Un MapFS est un système de fichiers simple en mémoire à utiliser dans les tests, représenté comme une carte des noms de chemins (arguments à Open) aux informations sur les fichiers ou répertoires qu'ils représentent.

Cela semble plus simple que de maintenir un dossier de fichiers de test, et cela s'exécutera plus rapidement.

Enfin, nous avons codifié l'utilisation de notre API du point de vue d'un consommateur, puis vérifié si elle crée le bon nombre d'articles.

Essayer d'exécuter le test

Écrire le minimum de code pour que le test s'exécute et vérifier la sortie du test qui échoue

Le package n'existe pas. Créez un nouveau fichier blogposts.go et mettez package blogposts à l'intérieur. Vous devrez ensuite importer ce package dans vos tests. Pour moi, les imports ressemblent maintenant à :

Maintenant, les tests ne compileront pas car notre nouveau package n'a pas de fonction NewPostsFromFS, qui renvoie un type de collection.

Cela nous oblige à créer le squelette de notre fonction pour faire fonctionner le test. N'oubliez pas de ne pas trop réfléchir au code à ce stade ; nous essayons simplement d'obtenir un test qui s'exécute, et de nous assurer qu'il échoue comme prévu. Si nous sautons cette étape, nous risquons de passer sur des hypothèses et d'écrire un test qui n'est pas utile.

Le test devrait maintenant échouer correctement

Écrire suffisamment de code pour le faire passer

Nous pourrions "slimer" pour le faire passer :

Mais, comme l'a écrit Denise Yu :

Le sliming est utile pour donner un "squelette" à votre objet. Concevoir une interface et exécuter une logique sont deux préoccupations, et slimer les tests de manière stratégique vous permet de vous concentrer sur l'une à la fois.

Nous avons déjà notre structure. Alors, que faisons-nous à la place ?

Comme nous avons réduit la portée, tout ce que nous devons faire est de lire le répertoire et de créer un article pour chaque fichier que nous rencontrons. Nous n'avons pas à nous soucier d'ouvrir des fichiers et de les analyser pour le moment.

fs.ReadDir lit un répertoire dans un fs.FS donné et renvoie []DirEntry.

Notre vision idéalisée du monde a déjà été déjouée car des erreurs peuvent se produire, mais rappelez-vous que notre objectif est maintenant de faire passer le test, pas de changer la conception, nous ignorerons donc l'erreur pour l'instant.

Le reste du code est simple : parcourir les entrées, créer un Post pour chacune et retourner la slice.

Refactoriser

Même si nos tests réussissent, nous ne pouvons pas utiliser notre nouveau package en dehors de ce contexte, car il est couplé à une implémentation concrète fstest.MapFS. Mais ce n'est pas obligatoire. Modifiez l'argument de notre fonction NewPostsFromFS pour accepter l'interface de la bibliothèque standard.

Relancez les tests : tout devrait fonctionner.

Gestion des erreurs

Nous avons mis de côté la gestion des erreurs plus tôt lorsque nous nous sommes concentrés sur le fonctionnement du happy path. Avant de continuer à itérer sur la fonctionnalité, nous devrions reconnaître que des erreurs peuvent se produire lors du travail avec des fichiers. Au-delà de la lecture du répertoire, nous pouvons rencontrer des problèmes lors de l'ouverture de fichiers individuels. Modifions notre API (via nos tests d'abord, naturellement) pour qu'elle puisse renvoyer une error.

Exécutez le test : il devrait se plaindre du mauvais nombre de valeurs de retour. La correction du code est simple.

Cela fera passer le test. Le praticien TDD en vous pourrait être agacé que nous n'ayons pas vu un test échouer avant d'écrire le code pour propager l'erreur de fs.ReadDir. Pour faire cela "correctement", nous aurions besoin d'un nouveau test où nous injectons un double de test fs.FS défaillant pour faire en sorte que fs.ReadDir renvoie une error.

Cela devrait vous donner confiance dans notre approche. L'interface que nous utilisons a une seule méthode, ce qui rend la création de doubles de test pour tester différents scénarios triviale.

Dans certains cas, tester la gestion des erreurs est la chose pragmatique à faire mais, dans notre cas, nous ne faisons rien d'intéressant avec l'erreur, nous la propageons simplement, donc cela ne vaut pas la peine d'écrire un nouveau test.

Logiquement, nos prochaines itérations porteront sur l'expansion de notre type Post pour qu'il contienne des données utiles.

Écrire le test d'abord

Nous allons commencer par la première ligne du schéma d'article de blog proposé, le champ titre.

Nous devons modifier le contenu des fichiers de test pour qu'ils correspondent à ce qui a été spécifié, puis nous pouvons faire une assertion qu'il est analysé correctement.

Essayer d'exécuter le test

Écrire le minimum de code pour que le test s'exécute et vérifier la sortie du test qui échoue

Ajoutez le nouveau champ à notre type Post pour que le test s'exécute

Relancez le test, et vous devriez obtenir un test qui échoue clairement

Écrire suffisamment de code pour le faire passer

Nous devrons ouvrir chaque fichier puis extraire le titre

Rappelez-vous que notre objectif à ce stade n'est pas d'écrire un code élégant, c'est juste d'arriver à un point où nous avons un logiciel qui fonctionne.

Même si cela semble une petite avancée, cela nous a quand même obligé à écrire une bonne quantité de code et à faire des suppositions concernant la gestion des erreurs. Ce serait un point où vous devriez parler à vos collègues et décider de la meilleure approche.

L'approche itérative nous a donné un retour rapide que notre compréhension des exigences est incomplète.

fs.FS nous donne un moyen d'ouvrir un fichier à l'intérieur par nom avec sa méthode Open. À partir de là, nous lisons les données du fichier et, pour l'instant, nous n'avons pas besoin d'une analyse sophistiquée, juste de couper le texte Title: en découpant la chaîne.

Refactoriser

Séparer le 'code d'ouverture de fichier' du 'code d'analyse du contenu du fichier' rendra le code plus simple à comprendre et à utiliser.

Lorsque vous refactorisez de nouvelles fonctions ou méthodes, faites attention et réfléchissez aux arguments. Vous êtes en train de concevoir ici, et vous êtes libre de réfléchir profondément à ce qui est approprié car vous avez des tests qui passent. Pensez au couplage et à la cohésion. Dans ce cas, vous devriez vous demander :

Est-ce que newPost doit être couplé à un fs.File ? Utilisons-nous toutes les méthodes et données de ce type ? De quoi avons-nous vraiment besoin ?

Dans notre cas, nous l'utilisons uniquement comme argument pour io.ReadAll qui a besoin d'un io.Reader. Nous devrions donc assouplir le couplage dans notre fonction et demander un io.Reader.

Vous pouvez faire un argument similaire pour notre fonction getPost, qui prend un argument fs.DirEntry mais appelle simplement Name() pour obtenir le nom du fichier. Nous n'avons pas besoin de tout cela ; découplons de ce type et passons le nom du fichier comme une chaîne. Voici le code entièrement refactorisé :

À partir de maintenant, la plupart de nos efforts peuvent être soigneusement contenus dans newPost. Les préoccupations d'ouverture et d'itération sur les fichiers sont terminées, et maintenant nous pouvons nous concentrer sur l'extraction des données pour notre type Post. Bien que ce ne soit pas techniquement nécessaire, les fichiers sont un bon moyen de regrouper logiquement des choses connexes, donc j'ai déplacé le type Post et newPost dans un nouveau fichier post.go.

Fonction d'aide pour les tests

Nous devrions également prendre soin de nos tests. Nous allons faire beaucoup d'assertions sur Posts, nous devrions donc écrire du code pour nous aider avec cela

Écrire le test d'abord

Étendons notre test pour extraire la ligne suivante du fichier, la description. Jusqu'à ce que cela passe, cela devrait maintenant sembler confortable et familier.

Essayer d'exécuter le test

Écrire le minimum de code pour que le test s'exécute et vérifier la sortie du test qui échoue

Ajoutez le nouveau champ à Post.

Les tests devraient maintenant compiler et échouer.

Écrire suffisamment de code pour le faire passer

La bibliothèque standard a une bibliothèque pratique pour vous aider à parcourir les données, ligne par ligne ; bufio.Scanner

Scanner fournit une interface pratique pour lire des données telles qu'un fichier de lignes de texte délimitées par des sauts de ligne.

Heureusement, il prend également un io.Reader à lire (merci encore, couplage lâche), nous n'avons pas besoin de changer nos arguments de fonction.

Appelez Scan pour lire une ligne, puis extrayez les données en utilisant Text.

Cette fonction ne pourrait jamais renvoyer d'error. Il serait tentant à ce stade de la supprimer du type de retour, mais nous savons que nous devrons gérer des structures de fichiers non valides plus tard, alors nous pouvons aussi bien la laisser.

Refactoriser

Nous avons une répétition autour de la numérisation d'une ligne puis de la lecture du texte. Nous savons que nous allons effectuer cette opération au moins une fois de plus, c'est une refactorisation simple pour sécher, alors commençons par là.

Cela a à peine économisé des lignes de code, mais ce n'est rarement le but de la refactorisation. Ce que j'essaie de faire ici, c'est juste de séparer le quoi du comment de la lecture des lignes pour rendre le code un peu plus déclaratif pour le lecteur.

Bien que les nombres magiques 7 et 13 fassent le travail, ils ne sont pas terriblement descriptifs.

Maintenant que je fixe le code avec mon esprit de refactorisation créatif, j'aimerais essayer de faire en sorte que notre fonction readLine s'occupe de supprimer la balise. Il existe également une façon plus lisible de supprimer un préfixe d'une chaîne avec la fonction strings.TrimPrefix.

Vous pourriez aimer cette idée ou non, mais moi, je l'aime. Le point est que dans l'état de refactorisation, nous sommes libres de jouer avec les détails internes, et vous pouvez continuer à exécuter vos tests pour vérifier que les choses fonctionnent toujours correctement. Nous pouvons toujours revenir à des états précédents si nous ne sommes pas satisfaits. L'approche TDD nous donne cette licence pour expérimenter fréquemment des idées, nous avons donc plus de chances d'écrire du code de qualité.

L'exigence suivante est d'extraire les tags de l'article. Si vous suivez, je vous recommande d'essayer de l'implémenter vous-même avant de continuer à lire. Vous devriez maintenant avoir un bon rythme itératif et vous sentir confiant pour extraire la ligne suivante et analyser les données.

Par souci de brièveté, je ne passerai pas par toutes les étapes du TDD, mais voici le test avec les tags ajoutés.

Vous ne vous rendez service à personne si vous vous contentez de copier et coller ce que j'écris. Pour nous assurer que nous sommes tous sur la même page, voici mon code qui inclut l'extraction des tags.

Espérons qu'il n'y ait pas de surprises ici. Nous avons pu réutiliser readMetaLine pour obtenir la ligne suivante pour les tags, puis les diviser en utilisant strings.Split.

La dernière itération sur notre happy path est d'extraire le corps de l'article.

Voici un rappel du format de fichier proposé.

Nous avons déjà lu les 3 premières lignes. Nous devons ensuite lire une ligne de plus, la rejeter, puis le reste du fichier contient le corps de l'article.

Écrire le test d'abord

Modifiez les données de test pour avoir le séparateur, et un corps avec quelques sauts de ligne pour vérifier que nous capturons tout le contenu.

Ajoutez à notre assertion comme les autres

Essayer d'exécuter le test

Comme nous nous y attendions.

Écrire le minimum de code pour que le test s'exécute et vérifier la sortie du test qui échoue

Ajoutez Body à Post et le test devrait échouer.

Écrire suffisamment de code pour le faire passer

  1. Scannez la ligne suivante pour ignorer le séparateur ---.

  2. Continuez à scanner jusqu'à ce qu'il n'y ait plus rien à scanner.

  • scanner.Scan() renvoie un bool qui indique s'il y a plus de données à scanner, nous pouvons donc l'utiliser avec une boucle for pour continuer à lire les données jusqu'à la fin.

  • Après chaque Scan(), nous écrivons les données dans le tampon en utilisant fmt.Fprintln. Nous utilisons la version qui ajoute un saut de ligne car le scanner supprime les sauts de ligne de chaque ligne, mais nous devons les conserver.

  • En raison de ce qui précède, nous devons supprimer le saut de ligne final, afin de ne pas en avoir un en trop.

Refactoriser

Encapsuler l'idée d'obtenir le reste des données dans une fonction aidera les futurs lecteurs à comprendre rapidement ce qui se passe dans newPost, sans avoir à se préoccuper des détails d'implémentation.

Itérer davantage

Nous avons créé notre "fil d'acier" de fonctionnalité, en prenant le chemin le plus court pour arriver à notre happy path, mais il est clair qu'il y a encore du chemin à parcourir avant qu'il ne soit prêt pour la production.

Nous n'avons pas géré :

  • quand le format du fichier n'est pas correct

  • le fichier n'est pas un .md

  • que se passe-t-il si l'ordre des champs de métadonnées est différent ? Cela devrait-il être autorisé ? Devrions-nous être en mesure de le gérer ?

Cependant, de façon cruciale, nous avons un logiciel qui fonctionne et nous avons défini notre interface. Les points ci-dessus ne sont que des itérations supplémentaires, plus de tests à écrire et à diriger notre comportement. Pour prendre en charge l'un des points ci-dessus, nous ne devrions pas avoir à changer notre conception, juste les détails d'implémentation.

Se concentrer sur l'objectif signifie que nous avons pris les décisions importantes et les avons validées par rapport au comportement souhaité, plutôt que de nous enliser dans des questions qui n'affecteront pas la conception globale.

Conclusion

fs.FS et les autres changements dans Go 1.16 nous donnent des moyens élégants de lire des données à partir des systèmes de fichiers et de les tester simplement.

Si vous souhaitez essayer le code "pour de vrai" :

  • Créez un dossier cmd dans le projet, ajoutez un fichier main.go

  • Ajoutez le code suivant

  • Ajoutez quelques fichiers markdown dans un dossier posts et exécutez le programme !

Remarquez la symétrie entre le code de production

Et les tests

C'est à ce moment que le TDD descendant, piloté par le consommateur, semble correct.

Un utilisateur de notre package peut consulter nos tests et comprendre rapidement ce qu'il est censé faire et comment l'utiliser. En tant que mainteneurs, nous pouvons être confiants que nos tests sont utiles car ils sont du point de vue d'un consommateur. Nous ne testons pas les détails d'implémentation ou d'autres détails accessoires, nous pouvons donc être raisonnablement confiants que nos tests nous aideront, plutôt que de nous gêner lors de la refactorisation.

En s'appuyant sur de bonnes pratiques d'ingénierie logicielle comme l'injection de dépendances, notre code est simple à tester et à réutiliser.

Lorsque vous créez des packages, même s'ils sont uniquement internes à votre projet, préférez une approche descendante pilotée par le consommateur. Cela vous empêchera de sur-imaginer des conceptions et de faire des abstractions dont vous n'avez même pas besoin, et cela vous aidera à garantir que les tests que vous écrivez sont utiles.

L'approche itérative a maintenu chaque étape petite, et la rétroaction continue nous a aidés à découvrir des exigences peu claires, peut-être plus tôt qu'avec d'autres approches plus ad hoc.

Écriture ?

Il est important de noter que ces nouvelles fonctionnalités n'ont d'opérations que pour lire des fichiers. Si votre travail a besoin d'écrire, vous devrez chercher ailleurs. N'oubliez pas de continuer à réfléchir à ce que la bibliothèque standard offre actuellement, si vous écrivez des données, vous devriez probablement envisager d'utiliser des interfaces existantes telles que io.Writer pour garder votre code faiblement couplé et réutilisable.

Lectures complémentaires

Mis à jour