Nous allons réaliser un lecteur RSS en lignes de commande. A travers ce projet, nous verrons les aspects suivants d’un projet TypeScript:
- Installation de NodeJS et de NPM
- Installation du compilateur TypeScript
- Conception d’interfaces TypeScript, séparation des responsabilités
- Implémentation de classes TypeScript
- Installation de Jest pour TypeScript
- Tester une classe TypeScript avec Jest
- Isolation des dépendances avec des mocks
- Intégration continue avec Gitlab
- Ajout de scripts NPM (ex:
npm run test
) - Compilation du projet TypeScript (vers Javascript)
L’objectif de notre projet est de récupérer une liste d’articles en ligne à partir de différents flux RSS, puis de les afficher sur la console.
Installation de Node et du compilateur TypeScript
Installation de Node et NPM
La méthode d’installation que je vous propose consiste à télécharger
une archive NodeJS compilée depuis le site
officiel avec wget
puis de l’extraire dans
le répertoire /usr/local
. Cette archive contient aussi NPM qui est
le gestionnaire de paquets de NodeJS.
wget https://nodejs.org/dist/v16.14.0/node-v16.14.0-linux-x64.tar.xz -O node-v16_14_0.tar.xz
tar --strip-component=1 -xJf node-v16_14_0.tar.xz -C /usr/local
Vous pouvez vérifier l’installation avec node --version
.
Installation de TypeScript
Maintenant qu’on dispose de NPM, on va pouvoir installer le compilateur TypeScript et initialiser sa configuration. Tout d’abord, on se place dans le dossier qui va héberger le projet, puis:
npm i --save-dev typescript
permet d’installer le compilateur dans le projetnpx tsc --init
crée un fichier de configuration par défaut danstsconfig.json
Ces deux commandes ont ajouté trois fichiers au dossier courant:
tsconfig.json
contient la configuration de tsc (TypeScript Compiler)package.json
liste lespackages
dont dépend le projet (seulement typescript pour l’instant)package-lock.json
est un fichier généré par NPM qui contient la résolution de l’arbre des dépendances issues des packages depackage.json
Avant propos: comment fonctionne un flux RSS
RSS permet de lister les articles publiés sur un site internet dans un format structuré dérivé du XML. On va analyser la spécification de RSS pour concevoir notre application.
Voici les éléments obligatoires d’un flux RSS: à la racine du document
XML, on va trouver un élément channel
qui décrit les
caractéristiques du flux représenté dans le document. L’élément
channel
présente les propriétés suivantes obligatoires:
title
: le titre du flux RSSlink
: l’URL du site associé à ce flux RSSdescription
: la description de ce flux RSS
L’élément channel
contient un ensemble d’item
qui correspondent
chacuns à une ressource du site internet spécifié dans l’élément
channel
. Chaque item
contient également une liste d’élements
obligatoires qui sont les mêmes que pour channel
:
title
: le titre de la ressource du fluxlink
: le lien de la ressourcedescription
: la description liée à la ressource
Conception logicielle et séparation des responsabilités
A ce stade de réflexion, on peut découper l’application en trois classes qui auront chacune une responsabilité:
- Article
- Flux
- Console
Un article devra pouvoir être affiché sous forme de texte et présenter un titre optionnel et un lien.
Un flux sera capable de lister les articles qu’il contient.
La console affichera les articles uniques provenant des flux précisés par l’utilisateur à l’écran.
On va maintenant définir les opérations que devront être capable de réaliser ces classes avec des interfaces TypeScript.
Notez que ces notions ne sont pas spécifiques à RSS. On pourrait très bien lire des articles à partir d’une page HTML listant les articles récents du blog par exemple, et implémenter une classe Flux pour un flux provenant d’une page web.
Définition d’un article
Un article devra être capable d’afficher son contenu. De plus, on pourra comparer deux articles en terme d’égalité pour ne pas afficher deux fois un article qui serait présent dans deux flux différents.
Le type Writable désigne un objet qui implémente l’API stream de
NodeJS
. La sortie standard est un stream dans lequel on peut écrire: un
Writable
. On implémentera par la suite un Writable qui nous servira
pour tester l’affichage de l’article.
export interface Article {
affiche(sortie: WritableStream): void;
egal(autreArticle: Article): boolean;
}
Définition d’un flux
Un flux devra être capable de retourner les articles qu’il
contient. getArticles
renvoie une
Promise
,
ce qui permet de récupérer les articles de manière asynchrone.
import { Article } from './article'
export interface Flux {
getArticles(): Promise<Article[]>;
}
Définition de la console
La console devra afficher les articles à l’écran. On pourra lui ajouter des flux.
import { Flux } from './flux';
interface Console {
ajouteFlux(flux: Flux): void;
afficheTousFlux(): void;
}
Vous pouvez explorer le projet à ce stade de l’article au commit suivant ➡️ feat: définition d’interfaces pour le lecteur d’articles .
Je suis la convention des commits conventionnels dans la suite de cet article. J’ai déjà expliqué ce mode de fonctionnement dans un toot Mastodon, n’hésitez pas à me rejoindre sur ce réseau 🌟
Implémentation d’un Flux RSS
On va en premier lieu créer une classe FluxRSS
qui implémente
l’interface Flux
. On crée également la classe ArticleRSS
qui est
pour l’instant une implémentation vide de l’interface Article
.
NPM contient toutes sortes de librairies pour des multitudes d’usages. Avant de coder quoi que ce soit, il est bienvenu de rechercher si une librairie correspondante existe.
Pour lire un flux RSS, on va utiliser la librairie
rss-parser qui va
retourner le contenu du flux sous forme d’objets TypeScript. Pour
l’installer, lancez npm i --save rss-parser
.
Notre implémentation d’ArticleRSS
va lire le flux RSS distant lors
de la construction de l’objet et stocker une promesse des articles lus
dans ce flux dans la variable privée articles
.
import Parser from 'rss-parser';
export class FluxRSS implements Flux {
private articles: Promise<Article[]>;
private rssParser = new Parser();
constructor(url: string) {
this.articles = this.getArticlesFromUrl(url);
}
public getArticles(): Promise<Article[]> {
return this.articles;
}
private async getArticlesFromUrl(url: string): Promise<Article[]> {
const feed = await this.rssParser.parseURL(url);
return feed.items.map((item) => new ArticleRSS(item));
};
}
Cette classe sera couplée à la classe ArticleRSS
, dont on définit
une implémentation vide pour qu’elle réponde à l’interface
Article
:
export class ArticleRSS implements Article {
constructor(item: Parser.Item) {};
affiche(sortie: WritableStream) {};
egal(autreArticle: Article) { return false };
}
On réalise un commit contenant cet avancement: feat: implémentation d’un Flux RSS
Test de l’implémentation
Installation de Jest
Pour tester notre projet TypeScript, nous allons utiliser Jest, pour des raisons déjà évoquées ⏪
Pour utiliser Jest avec TypeScript, on va installer Jest plus le
module ts-jest : npm i --save-dev jest @types/jest ts-jest
. Le module ts-jest
va nous
permettre de générer une configuration Jest compatible avec TypeScript
de manière automatique avec la commande npx ts-jest config:init
.
Jest a été écrit en JavaScript et non en TypeScript. Le package @types/jest contient la définition TypeScript du package Jest et permet de rendre Jest compatible avec TypeScript. Plus d’informations ici.
Les changements du dépôt Git sont les suivants: test: installation de Jest
Implémentation d’un premier test
Nous allons maintenant vérifier qu’un FluxRSS
construit à partir
d’une URL utilise bien la librairie rss-parser
pour construire sa
liste d’articles.
De plus, lorsque le Parser
de rss-parser
renvoie une liste
d’articles vide, la méthode getArticles
du FluxRSS
devra également
renvoyer une promesse dont le résultat sera une liste d’articles vide.
Pour cela, nous allons créer un mock du Parser
pour qu’il renvoie
systématiquement une liste d’articles vide:
import Parser from 'rss-parser';
jest.genMockFromModule('rss-parser');
jest.mock('rss-parser');
const mockParser = {
parseURL: jest.fn().mockReturnValue({ items: [] })
};
(Parser as jest.Mock).mockImplementation(() => mockParser);
Ensuite, ajoutons un premier test qui s’assure que lors de sa
construction, FluxRSS
utilise bien le Parser
:
describe('La classe FluxRSS', () => {
it('doit parser le flux passé en argument', () => {
const urlExemple = 'https://example.com/index.xml';
new FluxRSS(urlExemple);
expect(mockParser.parseURL.mock.calls[0][0]).toBe(urlExemple);
})
})
Puis dans un second temps, vérifions que le FluxRSS
nous renvoie
bien dans ce cas une liste d’articles vides lors de l’appel à sa
méthode getArticles
:
it('doit pouvoir retourner liste d\'articles vide', async () => {
const urlExemple = 'https://example.com/index.xml';
const flux = new FluxRSS(urlExemple);
let articles = await flux.getArticles();
expect(articles).toEqual([]);
})
🎉 test: FluxRSS peut renvoyer une liste d’articles vide
Intégration Continue avec Gitlab
On va maintenant ajouter l’intégration continue avec Gitlab. Pour
cela, on va simplement demander à Gitlab de nous provisionner un
conteneur Docker basé sur l’image Docker de la version LTS de
NodeJs qu’on a installé au début du projet: node:16.14.0
. Notre
pipeline aura une seule étape, les tests unitaires lancés par la
commande npm run test
. On devra au préalable avoir installé les
dépendances NPM.
tests_unitaires:
image: node:16.14.0
stage: test
before_script:
- npm ci
script:
- npm run test
Implémentation d’un Article RSS
On va maitenant réaliser les méthodes affiche
et égal
d’ArticleRSS
pour valider le contrat de l’interface Article
.
Résolution d’une coquille dans l’interface Article
La méthode affiche
de l’interface Article
prend en fait un objet
Writable
et non WritableStream
en argument. Petit commit de
correction: fix(article): utilisation de Writable dans
l’interface
👀
Utilitaire de test pour récupérer l’affichage de l’article
A terme, on utilisera la sortie standard process.stdout
en argument
de la méthode affiche
de l’article, pour afficher l’article sur la
console. Mais pour nos tests, on préfère simplement récupérer le
résultat de la méthode affiche
dans une chaîne de caractères.
Pour cela, on implémente un utilitaire, WritableGetSortie
qui
implémente l’interface Writable
et a une méthode getSortie
qui
permet de récupérer ce qui a été écrit dans l’objet.
Voici notre nouvel outil test(article): classe Writable utilitaire pour récupérer la sortie 🔧
On s’assure que l’utilitaire WritableGetSortie
se comporte bien
comme décrit ci-dessus avec des tests:
import { WritableGetSortie } from './writable';
describe('La classe WritableGetSortie', () => {
const writable = new WritableGetSortie();
beforeEach(() => writable.reset());
it('Doit pouvoir renvoyer son entrée', () => {
const message = 'Récupération des données';
writable.write(message);
expect(writable.getSortie()).toBe(message);
})
it('Doit pouvoir renvoyer la somme de plusieurs entrées', () => {
const messageA = 'Récupération des données';
const messageB = '\nCeci est un second message';
writable.write(messageA);
writable.write(messageB);
expect(writable.getSortie()).toBe(messageA + messageB);
})
})
Implémentation de la méthode affiche de l’article
On va pouvoir passer à l’implémentation de l’affichage de l’article. Pour cela, on définit d’abord la spécification suivante pour le texte affiché par l’article:
- Le texte contient le titre et le lien en entier
- Il doit au moins contenir les premiers mots de la description
- Le dernier caractère imprimé doit être un retour à la ligne
Cette spécification est réalisée dans test(article): test de la méthode affiche de ArticleRSS
Passons maintenant à l’implémentation de la méthode affiche
:
export class ArticleRSS implements Article {
private item: Parser.Item;
constructor(item: Parser.Item) {
this.item = item;
};
affiche(sortie: Writable) {
this.afficheElement(sortie, 'Titre', this.item.title);
this.afficheElement(sortie, 'Lien', this.item.link);
this.afficheElement(sortie, 'Description', this.item.content);
};
private afficheElement(sortie: Writable, header: string, content: string | undefined) {
sortie.write(`${header}: `);
sortie.write(content);
sortie.write(`\n`);
}
egal(autreArticle: Article) { return false };
}
Et voilà, l’article pourra maintenant être affichée sur la sortie
standard de la console par exemple. Il ne reste plus qu’à implémenter
la méthode egal
pour remplir le contrat de l’interface Article
.
✨ feat(article): implémentation de la méthode affiche pour ArticleRSS
Implémentation de la méthode egal d’ArticleRSS
Cette méthode va nous servir pour comparer deux articles (et savoir si ils font référence à la même chose). On pourra ainsi afficher les articles unique à l’écran, et éliminer les doublons (par exemple deux mêmes articles dans deux flux différents).
On ajoute aussi la méthode getId
à l’interface Article
, ce qui va
nous permettre de récupérer l’identifiant unique de l’article à des
fins de comparaison.
export interface Article {
affiche(sortie: Writable): void;
egal(autreArticle: Article): boolean;
getId(): string | undefined;
}
L’id d’un article RSS est le premier attribut non undefined
de
l’item
lu par le Parser
RSS parmi la liste suivante: guid
,
link
, content
.
Pour la comparaison en égalité, on renvoie faux si l’id d’un des deux
articles est undefined
, sinon on compare leurs deux ids.
egal(autreArticle: Article) {
if (autreArticle.getId() == undefined || this.getId() == undefined) {
return false;
}
return autreArticle.getId() == this.getId();
};
public getId(): string | undefined {
return this.item.guid || this.item.link || this.item.content;
}
✨ feat(article): implémentation de la méthode egal/getId d’ArticleRSS
On implémente aussi un test basique pour la méthode egal
:
test(article): un article doit être égal à
lui-même.
Implémentation de la console
La classe ConsoleStdout
devra afficher les articles des flux qu’on
lui a assigné via la méthode ajouteFlux
sur la sortie standard.
Avant d’afficher un article, on vérifiera qu’il n’a pas déjà été
affiché avec sa méthode egal
. On stocke les articles affichés par
ConsoleStdout
dans son attribut privé articlesAffiches
.
Pour récupérer les articles de tous les flux dans une liste, on
utilise la méthode map
de la liste des
flux
pour récupérer une liste de promesses d’articles. On passe cette liste
de promesses à Promise.all
qui va attendre la réalisation de toutes
les promesses passées en argument. Cela donne une liste de liste
d’articles que l’on transforme en une liste simple d’articles avec la
méthode
flat
.
export class ConsoleStdout implements Console {
private output: Writable = process.stdout;
private flux: Flux[] = [];
private articlesAffiches: Article[] = [];
public ajouteFlux(flux: Flux): void {
this.flux.push(flux);
};
public async afficheTousFlux(): Promise<void> {
const articles = (
await Promise.all(this.flux.map((f) => f.getArticles()))
).flat();
for(const article of articles) {
if (this.articleDejaAffiche(article)) {
continue;
} else {
this.afficheArticle(article);
this.articlesAffiches.push(article);
}
}
};
private afficheArticle(article: Article) {
this.output.write(`Article n° ${this.articlesAffiches.length + 1}\n`);
article.affiche(this.output);
this.output.write('------\n\n');
};
private articleDejaAffiche(article: Article) {
return !!this.articlesAffiches.find((a) => a.egal(article));
};
}
On réalise également des tests de la console pour vérifier qu’elle affiche les articles de manière unique.
✨ feat(console): ConsoleStdout affiche les flux sur la sortie standard
Compilation du projet dans dist
Par défaut, npx tsc
qui compile le projet de Javascript vers
TypeScript va stocker le code Javascript obtenu dans un fichier
adjacent au code Typescript. Par exemple, src/article.ts
sera
compilé vers src/article.ts
.
Cela pollue le projet. Pour y remédier, on va modifier la
configuration TypeScript tsconfig.json
pour préciser que le
répertoire de sortie doit être dist
.
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"outDir": "./dist", // Specify an output folder for all emitted files.
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
Il est aussi nécéssaire de préciser à Jest que les fichiers de tests
se situent dans src
et test
uniquement, sans quoi Jest utilisera
aussi les fichiers compilés dans dist
:
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['src', 'test'],
};
Maintenant qu’on a configuré la compilation de TypeScript, on peut
créer un script NPM associé, nommé build
:
"scripts": {
"build": "tsc",
"test": "jest"
},
✨ feat: compilation du projet dans le dossier dist
Finalisation du projet
On va maintenant utiliser ConsoleStdout
et FluxRSS
pour afficher
les articles de mon blog et ceux du Journal du
Hacker.
import { ConsoleStdout } from "./src/console";
import { FluxRSS } from "./src/flux";
const urlsRSS = [
'https://www.journalduhacker.net/rss',
'https://sagot.dev/index.xml'
];
const affichage = new ConsoleStdout();
urlsRSS
.map((u) => new FluxRSS(u))
.forEach((f) => affichage.ajouteFlux(f));
affichage.afficheTousFlux();
On ajoute un script NPM (ligne de commande: npm run start
) qui lance le script TypeScript compilé en Javascript réalisé
ci-dessus:
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"test": "jest"
},
🎆 feat: npm run start affiche deux flux RSS sur la console
Voici le résultat quand on lance npm run start
:
Conclusion
J’espère que cet article vous aura aidé à vous lancer dans le développement d’applications avec TypeScript. Il peut être laborieux de lancer un projet avec ce langage étant donné le nombre de nouvelles notions à assimiler. Vous savez maintenant comment initialiser un projet TypeScript, installer des librairies, compiler et tester le code produit.