Premiers pas avec une pile JavaScript Express et ES6 +
Publié: 2022-03-10Cet article est la deuxième partie d'une série, avec la première partie située ici, qui a fourni des informations de base et (espérons-le) intuitives sur Node.js, ES6 + JavaScript, les fonctions de rappel, les fonctions fléchées, les API, le protocole HTTP, JSON, MongoDB et Suite.
Dans cet article, nous nous appuierons sur les compétences que nous avons acquises dans le précédent, en apprenant à implémenter et à déployer une base de données MongoDB pour stocker les informations de la liste des utilisateurs, à créer une API avec Node.js et le framework Express Web Application pour exposer cette base de données. et effectuer des opérations CRUD dessus, et plus encore. En cours de route, nous discuterons de la déstructuration d'objet ES6, de la sténographie d'objet ES6, de la syntaxe Async/Await, de l'opérateur de propagation, et nous examinerons brièvement CORS, la politique d'origine identique, et plus encore.
Dans un article ultérieur, nous refactoriserons notre base de code pour séparer les problèmes en utilisant une architecture à trois couches et en réalisant l'inversion de contrôle via l'injection de dépendance, nous effectuerons la sécurité et le contrôle d'accès basés sur le jeton Web JSON et l'authentification Firebase, apprendre à sécuriser stockez les mots de passe et utilisez AWS Simple Storage Service pour stocker les avatars des utilisateurs avec Node.js Buffers et Streams, tout en utilisant PostgreSQL pour la persistance des données. En cours de route, nous réécrirons notre base de code à partir de zéro dans TypeScript afin d'examiner les concepts OOP classiques (tels que le polymorphisme, l'héritage, la composition, etc.) et même les modèles de conception tels que les usines et les adaptateurs.
Un mot d'avertissement
Il y a un problème avec la majorité des articles traitant de Node.js aujourd'hui. La plupart d'entre eux, pas tous, ne vont pas plus loin que de décrire comment configurer le routage express, intégrer Mongoose et peut-être utiliser l'authentification par jeton Web JSON. Le problème est qu'ils ne parlent ni d'architecture, ni de meilleures pratiques de sécurité, ni de principes de codage propre, ni de conformité ACID, de bases de données relationnelles, de cinquième forme normale, du théorème CAP ou de transactions. On suppose soit que vous êtes au courant de tout ce qui arrive, soit que vous ne construirez pas de projets suffisamment importants ou populaires pour justifier les connaissances susmentionnées.
Il semble y avoir différents types de développeurs de nœuds - entre autres, certains sont nouveaux dans la programmation en général, et d'autres viennent d'une longue histoire de développement d'entreprise avec C # et le .NET Framework ou le Java Spring Framework. La majorité des articles s'adressent au premier groupe.
Dans cet article, je vais faire exactement ce que je viens de dire que trop d'articles font, mais dans un article de suivi, nous allons refactoriser entièrement notre base de code, me permettant d'expliquer des principes tels que Dependency Injection, Three- Architecture de couche (contrôleur/service/référentiel), mappage de données et enregistrement actif, modèles de conception, tests unitaires, d'intégration et de mutation, principes SOLID, unité de travail, codage par rapport aux interfaces, meilleures pratiques de sécurité telles que HSTS, CSRF, NoSQL et injection SQL Prévention, etc. Nous allons également migrer de MongoDB vers PostgreSQL, en utilisant le simple générateur de requêtes Knex au lieu d'un ORM - nous permettant de construire notre propre infrastructure d'accès aux données et de nous familiariser avec le langage de requête structuré, les différents types de relations (One- à un, plusieurs à plusieurs, etc.), et plus encore. Cet article devrait donc plaire aux débutants, mais les prochains devraient s'adresser aux développeurs plus intermédiaires cherchant à améliorer leur architecture.
Dans celui-ci, nous allons seulement nous soucier des données persistantes du livre. Nous ne gérerons pas l'authentification des utilisateurs, le hachage des mots de passe, l'architecture ou quoi que ce soit de complexe comme ça. Tout cela viendra dans les prochains et futurs articles. Pour l'instant, et de manière très basique, nous allons simplement créer une méthode permettant à un client de communiquer avec notre serveur Web via le protocole HTTP afin d'enregistrer les informations du livre dans une base de données.
Note : Je l'ai volontairement gardé extrêmement simple et peut-être pas très pratique ici parce que cet article, en soi, est extrêmement long, car j'ai pris la liberté de dévier pour discuter de sujets supplémentaires. Ainsi, nous améliorerons progressivement la qualité et la complexité de l'API au cours de cette série, mais encore une fois, parce que je considère cela comme l'une de vos premières introductions à Express, je garde intentionnellement les choses extrêmement simples.
- Destructuration d'objet ES6
- Raccourci d'objet ES6
- Opérateur de propagation ES6 (...)
- A venir...
Destructuration d'objet ES6
ES6 Object Destructuring, ou Destructuring Assignment Syntax, est une méthode permettant d'extraire ou de décompresser des valeurs de tableaux ou d'objets dans leurs propres variables. Nous commencerons par les propriétés d'objet, puis discuterons des éléments de tableau.
const person = { name: 'Richard P. Feynman', occupation: 'Theoretical Physicist' }; // Log properties: console.log('Name:', person.name); console.log('Occupation:', person.occupation);
Une telle opération est assez primitive, mais cela peut être un peu compliqué étant donné que nous devons continuer à référencer person.something
partout. Supposons qu'il y ait 10 autres endroits dans notre code où nous devions le faire - cela deviendrait assez ardu assez rapidement. Une méthode de brièveté serait d'attribuer ces valeurs à leurs propres variables.
const person = { name: 'Richard P. Feynman', occupation: 'Theoretical Physicist' }; const personName = person.name; const personOccupation = person.occupation; // Log properties: console.log('Name:', personName); console.log('Occupation:', personOccupation);
Cela semble peut-être raisonnable, mais que se passerait-il si nous avions également 10 autres propriétés imbriquées sur l'objet person
? Ce serait beaucoup de lignes inutiles juste pour attribuer des valeurs aux variables - à quel point nous sommes en danger car si les propriétés de l'objet sont mutées, nos variables ne refléteront pas ce changement (rappelez-vous, seules les références à l'objet sont immuables avec l'affectation const
, pas les propriétés de l'objet), donc fondamentalement, nous ne pouvons plus garder "l'état" (et j'utilise ce mot vaguement) en synchronisation. Passer par référence vs passer par valeur peut entrer en jeu ici, mais je ne veux pas trop m'éloigner de la portée de cette section.
ES6 Object Destructing nous permet essentiellement de faire ceci :
const person = { name: 'Richard P. Feynman', occupation: 'Theoretical Physicist' }; // This is new. It's called Object Destructuring. const { name, occupation } = person; // Log properties: console.log('Name:', name); console.log('Occupation:', occupation);
Nous ne créons pas un nouvel objet/littéral d'objet, nous déballons les propriétés de name
et d' occupation
de l'objet d'origine et les plaçons dans leurs propres variables du même nom. Les noms que nous utilisons doivent correspondre aux noms de propriété que nous souhaitons extraire.
Encore une fois, la syntaxe const { a, b } = someObject;
dit spécifiquement que nous nous attendons à ce qu'une propriété a
et une propriété b
existent dans someObject
(c'est-à-dire, someObject
pourrait être { a: 'dataA', b: 'dataB' }
, par exemple) et que nous voulons placer quelles que soient les valeurs de ces clés/propriétés dans les variables const
du même nom. C'est pourquoi la syntaxe ci-dessus nous fournirait deux variables const a = someObject.a
et const b = someObject.b
.
Cela signifie qu'il y a deux côtés à la Destructuration d'Objet. Le côté "Modèle" et le côté "Source", où le côté const { a, b }
(le côté gauche) est le modèle et le côté someObject
(le côté droit) est le côté source - ce qui est logique - nous définissons une structure ou "modèle" sur la gauche qui reflète les données du côté "source".
Encore une fois, juste pour que cela soit clair, voici quelques exemples :
// ----- Destructure from Object Variable with const ----- // const objOne = { a: 'dataA', b: 'dataB' }; // Destructure const { a, b } = objOne; console.log(a); // dataA console.log(b); // dataB // ----- Destructure from Object Variable with let ----- // let objTwo = { c: 'dataC', d: 'dataD' }; // Destructure let { c, d } = objTwo; console.log(c); // dataC console.log(d); // dataD // Destructure from Object Literal with const ----- // const { e, f } = { e: 'dataE', f: 'dataF' }; // <-- Destructure console.log(e); // dataE console.log(f); // dataF // Destructure from Object Literal with let ----- // let { g, h } = { g: 'dataG', h: 'dataH' }; // <-- Destructure console.log(g); // dataG console.log(h); // dataH
Dans le cas de propriétés imbriquées, reproduisez la même structure dans votre affectation de destruction :
const person = { name: 'Richard P. Feynman', occupation: { type: 'Theoretical Physicist', location: { lat: 1, lng: 2 } } }; // Attempt one: const { name, occupation } = person; console.log(name); // Richard P. Feynman console.log(occupation); // The entire `occupation` object. // Attempt two: const { occupation: { type, location } } = person; console.log(type); // Theoretical Physicist console.log(location) // The entire `location` object. // Attempt three: const { occupation: { location: { lat, lng } } } = person; console.log(lat); // 1 console.log(lng); // 2
Comme vous pouvez le voir, les propriétés que vous décidez de retirer sont facultatives, et pour décompresser les propriétés imbriquées, il suffit de refléter la structure de l'objet d'origine (la source) dans le côté modèle de votre syntaxe de déstructuration. Si vous tentez de déstructurer une propriété qui n'existe pas sur l'objet d'origine, cette valeur sera indéfinie.
On peut en outre déstructurer une variable sans la déclarer au préalable — affectation sans déclaration — en utilisant la syntaxe suivante :
let name, occupation; const person = { name: 'Richard P. Feynman', occupation: 'Theoretical Physicist' }; ;({ name, occupation } = person); console.log(name); // Richard P. Feynman console.log(occupation); // Theoretical Physicist
Nous précédons l'expression d'un point-virgule pour nous assurer que nous ne créons pas accidentellement une IIFE (Expression de fonction immédiatement invoquée) avec une fonction sur une ligne précédente (si une telle fonction existe), et les parenthèses autour de l'instruction d'affectation sont nécessaires pour empêcher JavaScript de traiter votre côté gauche (modèle) comme un bloc.
Un cas d'utilisation très courant de la déstructuration existe dans les arguments de fonction :
const config = { baseUrl: '<baseURL>', awsBucket: '<bucket>', secret: '<secret-key>' // <- Make this an env var. }; // Destructures `baseUrl` and `awsBucket` off `config`. const performOperation = ({ baseUrl, awsBucket }) => { fetch(baseUrl).then(() => console.log('Done')); console.log(awsBucket); // <bucket> }; performOperation(config);
Comme vous pouvez le voir, nous aurions pu simplement utiliser la syntaxe de déstructuration normale à laquelle nous sommes maintenant habitués à l'intérieur de la fonction, comme ceci :
const config = { baseUrl: '<baseURL>', awsBucket: '<bucket>', secret: '<secret-key>' // <- Make this an env var. }; const performOperation = someConfig => { const { baseUrl, awsBucket } = someConfig; fetch(baseUrl).then(() => console.log('Done')); console.log(awsBucket); // <bucket> }; performOperation(config);
Mais placer ladite syntaxe à l'intérieur de la signature de la fonction effectue automatiquement la déstructuration et nous fait gagner une ligne.
Un cas d'utilisation réel de ceci est dans React Functional Components for props
:
import React from 'react'; // Destructure `titleText` and `secondaryText` from `props`. export default ({ titleText, secondaryText }) => ( <div> <h1>{titleText}</h1> <h3>{secondaryText}</h3> </div> );
Contrairement à :
import React from 'react'; export default props => ( <div> <h1>{props.titleText}</h1> <h3>{props.secondaryText}</h3> </div> );
Dans les deux cas, nous pouvons également définir des valeurs par défaut pour les propriétés :
const personOne = { name: 'User One', password: 'BCrypt Hash' }; const personTwo = { password: 'BCrypt Hash' }; const createUser = ({ name = 'Anonymous', password }) => { if (!password) throw new Error('InvalidArgumentException'); console.log(name); console.log(password); return { id: Math.random().toString(36) // <--- Should follow RFC 4122 Spec in real app. .substring(2, 15) + Math.random() .toString(36).substring(2, 15), name: name, // <-- We'll discuss this next. password: password // <-- We'll discuss this next. }; } createUser(personOne); // User One, BCrypt Hash createUser(personTwo); // Anonymous, BCrypt Hash
Comme vous pouvez le voir, dans le cas où ce name
n'est pas présent lors de la déstructuration, nous lui fournissons une valeur par défaut. Nous pouvons également le faire avec la syntaxe précédente :
const { a, b, c = 'Default' } = { a: 'dataA', b: 'dataB' }; console.log(a); // dataA console.log(b); // dataB console.log(c); // Default
Les tableaux peuvent également être déstructurés :
const myArr = [4, 3]; // Destructuring happens here. const [valOne, valTwo] = myArr; console.log(valOne); // 4 console.log(valTwo); // 3 // ----- Destructuring without assignment: ----- // let a, b; // Destructuring happens here. ;([a, b] = [10, 2]); console.log(a + b); // 12
Une raison pratique de la déstructuration du tableau se produit avec React Hooks. (Et il y a beaucoup d'autres raisons, j'utilise juste React comme exemple).
import React, { useState } from "react"; export default () => { const [buttonText, setButtonText] = useState("Default"); return ( <button onClick={() => setButtonText("Toggled")}> {buttonText} </button> ); }
Notez useState
est déstructuré hors de l'exportation et que les fonctions/valeurs du tableau sont déstructurées hors du crochet useState
. Encore une fois, ne vous inquiétez pas si ce qui précède n'a pas de sens - vous devez comprendre React - et je l'utilise simplement comme exemple.
Bien qu'il y ait plus à ES6 Object Destructuring, je couvrirai un autre sujet ici : Destructuring Renaming, qui est utile pour éviter les collisions de portée ou les ombres variables, etc. Supposons que nous voulions déstructurer une propriété appelée name
à partir d'un objet appelé person
, mais il y a déjà une variable du nom de name
dans la portée. On peut renommer à la volée avec deux-points :
// JS Destructuring Naming Collision Example: const name = 'Jamie Corkhill'; const person = { name: 'Alan Turing' }; // Rename `name` from `person` to `personName` after destructuring. const { name: personName } = person; console.log(name); // Jamie Corkhill <-- As expected. console.log(personName); // Alan Turing <-- Variable was renamed.
Enfin, nous pouvons également définir des valeurs par défaut en renommant :
const name = 'Jamie Corkhill'; const person = { location: 'New York City, United States' }; const { name: personName = 'Anonymous', location } = person; console.log(name); // Jamie Corkhill console.log(personName); // Anonymous console.log(location); // New York City, United States
Comme vous pouvez le voir, dans ce cas, le name
de person
( person.name
) sera renommé en personName
et défini sur la valeur par défaut Anonymous
s'il n'existe pas.
Et bien sûr, la même chose peut être effectuée dans les signatures de fonction :
const personOne = { name: 'User One', password: 'BCrypt Hash' }; const personTwo = { password: 'BCrypt Hash' }; const createUser = ({ name: personName = 'Anonymous', password }) => { if (!password) throw new Error('InvalidArgumentException'); console.log(personName); console.log(password); return { id: Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15), name: personName, password: password // <-- We'll discuss this next. }; } createUser(personOne); // User One, BCrypt Hash createUser(personTwo); // Anonymous, BCrypt Hash
Raccourci d'objet ES6
Supposons que vous ayez l'usine suivante : (nous aborderons les usines plus tard)
const createPersonFactory = (name, location, position) => ({ name: name, location: location, position: position });
On peut utiliser cette fabrique pour créer un objet person
, comme suit. Notez également que la fabrique renvoie implicitement un objet, mis en évidence par les parenthèses autour des crochets de la fonction Flèche.
const person = createPersonFactory('Jamie', 'Texas', 'Developer'); console.log(person); // { ... }
C'est ce que nous savons déjà de la syntaxe littérale d'objet ES5. Notez, cependant, dans la fonction factory, que la valeur de chaque propriété est le même nom que l'identificateur de propriété (clé) lui-même. C'est-à-dire — location: location
ou name: name
. Il s'est avéré que c'était un phénomène assez courant chez les développeurs JS.
Avec la syntaxe abrégée d'ES6, nous pouvons obtenir le même résultat en réécrivant la fabrique comme suit :
const createPersonFactory = (name, location, position) => ({ name, location, position }); const person = createPersonFactory('Jamie', 'Texas', 'Developer'); console.log(person);
Production de la sortie :
{ name: 'Jamie', location: 'Texas', position: 'Developer' }
Il est important de réaliser que nous ne pouvons utiliser ce raccourci que lorsque l'objet que nous souhaitons créer est créé dynamiquement sur la base de variables, où les noms de variables sont les mêmes que les noms des propriétés auxquelles nous voulons que les variables soient affectées.
Cette même syntaxe fonctionne avec les valeurs d'objet :
const createPersonFactory = (name, location, position, extra) => ({ name, location, position, extra // <- right here. }); const extra = { interests: [ 'Mathematics', 'Quantum Mechanics', 'Spacecraft Launch Systems' ], favoriteLanguages: [ 'JavaScript', 'C#' ] }; const person = createPersonFactory('Jamie', 'Texas', 'Developer', extra); console.log(person);
Production de la sortie :
{ name: 'Jamie', location: 'Texas', position: 'Developer', extra: { interests: [ 'Mathematics', 'Quantum Mechanics', 'Spacecraft Launch Systems' ], favoriteLanguages: [ 'JavaScript', 'C#' ] } }
Comme dernier exemple, cela fonctionne également avec les littéraux d'objet :
const id = '314159265358979'; const name = 'Archimedes of Syracuse'; const location = 'Syracuse'; const greatMathematician = { id, name, location };
Opérateur de propagation ES6 (…)
L'opérateur de propagation nous permet de faire une variété de choses, dont certaines seront abordées ici.
Tout d'abord, nous pouvons répartir les propriétés d'un objet sur un autre objet :
const myObjOne = { a: 'a', b: 'b' }; const myObjTwo = { ...myObjOne }:
Cela a pour effet de placer toutes les propriétés de myObjOne
sur myObjTwo
, de sorte que myObjTwo
est maintenant { a: 'a', b: 'b' }
. Nous pouvons utiliser cette méthode pour remplacer les propriétés précédentes. Supposons qu'un utilisateur souhaite mettre à jour son compte :
const user = { name: 'John Doe', email: '[email protected]', password: ' ', bio: 'Lorem ipsum' }; const updates = { password: ' ', bio: 'Ipsum lorem', email: '[email protected]' }; const updatedUser = { ...user, // <- original ...updates // <- updates }; console.log(updatedUser); /* { name: 'John Doe', email: '[email protected]', // Updated password: ' ', // Updated bio: 'Ipsum lorem' } */
const user = { name: 'John Doe', email: '[email protected]', password: ' ', bio: 'Lorem ipsum' }; const updates = { password: ' ', bio: 'Ipsum lorem', email: '[email protected]' }; const updatedUser = { ...user, // <- original ...updates // <- updates }; console.log(updatedUser); /* { name: 'John Doe', email: '[email protected]', // Updated password: ' ', // Updated bio: 'Ipsum lorem' } */
const user = { name: 'John Doe', email: '[email protected]', password: ' ', bio: 'Lorem ipsum' }; const updates = { password: ' ', bio: 'Ipsum lorem', email: '[email protected]' }; const updatedUser = { ...user, // <- original ...updates // <- updates }; console.log(updatedUser); /* { name: 'John Doe', email: '[email protected]', // Updated password: ' ', // Updated bio: 'Ipsum lorem' } */
const user = { name: 'John Doe', email: '[email protected]', password: ' ', bio: 'Lorem ipsum' }; const updates = { password: ' ', bio: 'Ipsum lorem', email: '[email protected]' }; const updatedUser = { ...user, // <- original ...updates // <- updates }; console.log(updatedUser); /* { name: 'John Doe', email: '[email protected]', // Updated password: ' ', // Updated bio: 'Ipsum lorem' } */
La même chose peut être effectuée avec des tableaux :
const apollo13Astronauts = ['Jim', 'Jack', 'Fred']; const apollo11Astronauts = ['Neil', 'Buz', 'Michael']; const unionOfAstronauts = [...apollo13Astronauts, ...apollo11Astronauts]; console.log(unionOfAstronauts); // ['Jim', 'Jack', 'Fred', 'Neil', 'Buz, 'Michael'];
Notez ici que nous avons créé une union des deux ensembles (tableaux) en répartissant les tableaux dans un nouveau tableau.
Il y a beaucoup plus dans l'opérateur Rest/Spread, mais cela sort du cadre de cet article. Il peut être utilisé pour atteindre plusieurs arguments à une fonction, par exemple. Si vous souhaitez en savoir plus, consultez la documentation MDN ici.
ES6 asynchrone/en attente
Async/Await est une syntaxe pour soulager la douleur du chaînage des promesses.
Le mot-clé réservé await
vous permet « d'attendre » le règlement d'une promesse, mais il ne peut être utilisé que dans les fonctions marquées avec le mot-clé async
. Supposons que j'ai une fonction qui renvoie une promesse. Dans une nouvelle fonction async
, je peux await
le résultat de cette promesse au lieu d'utiliser .then
et .catch
.
// Returns a promise. const myFunctionThatReturnsAPromise = () => { return new Promise((resolve, reject) => { setTimeout(() => resolve('Hello'), 3000); }); } const myAsyncFunction = async () => { const promiseResolutionResult = await myFunctionThatReturnsAPromise(); console.log(promiseResolutionResult); }; // Writes the log statement after three seconds. myAsyncFunction();
Il y a quelques choses à noter ici. Lorsque nous utilisons await
dans une fonction async
, seule la valeur résolue entre dans la variable de gauche. Si la fonction rejette, c'est une erreur que nous devons attraper, comme nous le verrons dans un instant. De plus, toute fonction marquée async
renverra, par défaut, une promesse.
Supposons que j'ai besoin de faire deux appels d'API, un avec la réponse du premier. En utilisant les promesses et le chaînage des promesses, vous pouvez procéder ainsi :
const makeAPICall = route => new Promise((resolve, reject) => { console.log(route) resolve(route); }); const main = () => { makeAPICall('/whatever') .then(response => makeAPICall(response + ' second call')) .then(response => console.log(response + ' logged')) .catch(err => console.error(err)) }; main(); // Result: /* /whatever /whatever second call /whatever second call logged */
Ce qui se passe ici, c'est que nous appelons d'abord makeAPICall
en lui passant /whatever
, qui est enregistré la première fois. La promesse se résout avec cette valeur. Ensuite, nous appelons à nouveau makeAPICall
, en lui passant /whatever second call
, qui est enregistré, et encore une fois, la promesse se résout avec cette nouvelle valeur. Enfin, nous prenons cette nouvelle valeur /whatever second call
lequel la promesse vient de se résoudre, et nous l'enregistrons nous-mêmes dans le journal final, en ajoutant sur logged
à la fin. Si cela n'a pas de sens, vous devriez vous pencher sur le chaînage des promesses.
En utilisant async
/ await
, nous pouvons refactoriser comme suit :
const main = async () => { const resultOne = await makeAPICall('/whatever'); const resultTwo = await makeAPICall(resultOne + ' second call'); console.log(resultTwo + ' logged'); };
Voici ce qui va se passer. La fonction entière cessera de s'exécuter à la toute première instruction await
jusqu'à ce que la promesse du premier appel à makeAPICall
résolue, lors de la résolution, la valeur résolue sera placée dans resultOne
. Lorsque cela se produit, la fonction passera à la deuxième instruction await
, s'arrêtant à nouveau là pendant la durée du règlement de la promesse. Lorsque la promesse se résout, le résultat de la résolution sera placé dans resultTwo
. Si l'idée de l'exécution d'une fonction semble bloquante, n'ayez crainte, elle est toujours asynchrone, et j'expliquerai pourquoi dans une minute.
Cela ne représente que le chemin "heureux". Dans le cas où l'une des promesses est rejetée, nous pouvons l'attraper avec try/catch, car si la promesse est rejetée, une erreur sera renvoyée - qui sera l'erreur avec laquelle la promesse a été rejetée.
const main = async () => { try { const resultOne = await makeAPICall('/whatever'); const resultTwo = await makeAPICall(resultOne + ' second call'); console.log(resultTwo + ' logged'); } catch (e) { console.log(e) } };
Comme je l'ai dit plus tôt, toute fonction déclarée async
renverra une promesse. Ainsi, si vous souhaitez appeler une fonction asynchrone à partir d'une autre fonction, vous pouvez utiliser des promesses normales ou await
si vous déclarez la fonction appelante async
. Cependant, si vous souhaitez appeler une fonction async
à partir du code de niveau supérieur et attendre son résultat, vous devrez utiliser .then
et .catch
.
Par exemple:
const returnNumberOne = async () => 1; returnNumberOne().then(value => console.log(value)); // 1
Ou, vous pouvez utiliser une expression de fonction appelée immédiatement (IIFE):
(async () => { const value = await returnNumberOne(); console.log(value); // 1 })();
Lorsque vous utilisez await
dans une fonction async
, l'exécution de la fonction s'arrêtera à cette instruction await jusqu'à ce que la promesse soit réglée. Cependant, toutes les autres fonctions sont libres de procéder à l'exécution, donc aucune ressource CPU supplémentaire n'est allouée et le thread n'est jamais bloqué. Je le répète - les opérations dans cette fonction spécifique à ce moment précis s'arrêteront jusqu'à ce que la promesse soit réglée, mais toutes les autres fonctions sont libres de se déclencher. Considérez un serveur Web HTTP - sur une base par demande, toutes les fonctions sont libres de se déclencher pour tous les utilisateurs simultanément au fur et à mesure que les demandes sont faites, c'est juste que la syntaxe async/wait donnera l' illusion qu'une opération est synchrone et bloquante à faire promet plus facile à travailler, mais encore une fois, tout restera agréable et asynchrone.
Ce n'est pas tout ce qu'il y a à async
/ await
, mais cela devrait vous aider à saisir les principes de base.
Usines POO classiques
Nous allons maintenant quitter le monde JavaScript et entrer dans le monde Java . Il peut arriver un moment où le processus de création d'un objet (dans ce cas, une instance d'une classe - encore une fois, Java) est assez complexe ou lorsque nous voulons que différents objets soient produits en fonction d'une série de paramètres. Un exemple pourrait être une fonction qui crée différents objets d'erreur. Une usine est un modèle de conception courant dans la programmation orientée objet et est essentiellement une fonction qui crée des objets. Pour explorer cela, éloignons-nous de JavaScript dans le monde de Java. Cela aura du sens pour les développeurs qui viennent d'une POO classique (c'est-à-dire non prototypique), avec un arrière-plan de langage typé statiquement. Si vous n'êtes pas l'un de ces développeurs, n'hésitez pas à ignorer cette section. Il s'agit d'un petit écart, et donc si suivre ici interrompt votre flux de JavaScript, alors encore une fois, veuillez ignorer cette section.
Modèle de création courant, le modèle d'usine nous permet de créer des objets sans exposer la logique métier requise pour effectuer ladite création.
Supposons que nous écrivions un programme qui nous permette de visualiser des formes primitives en n-dimensions. Si nous fournissons un cube, par exemple, nous verrions un cube 2D (un carré), un cube 3D (un cube) et un cube 4D (un Tesseract ou Hypercube). Voici comment cela pourrait être fait, de manière triviale, et à l'exception de la partie de dessin proprement dite, en Java.
// Main.java // Defining an interface for the shape (can be used as a base type) interface IShape { void draw(); } // Implementing the interface for 2-dimensions: class TwoDimensions implements IShape { @Override public void draw() { System.out.println("Drawing a shape in 2D."); } } // Implementing the interface for 3-dimensions: class ThreeDimensions implements IShape { @Override public void draw() { System.out.println("Drawing a shape in 3D."); } } // Implementing the interface for 4-dimensions: class FourDimensions implements IShape { @Override public void draw() { System.out.println("Drawing a shape in 4D."); } } // Handles object creation class ShapeFactory { // Factory method (notice return type is the base interface) public IShape createShape(int dimensions) { switch(dimensions) { case 2: return new TwoDimensions(); case 3: return new ThreeDimensions(); case 4: return new FourDimensions(); default: throw new IllegalArgumentException("Invalid dimension."); } } } // Main class and entry point. public class Main { public static void main(String[] args) throws Exception { ShapeFactory shapeFactory = new ShapeFactory(); IShape fourDimensions = shapeFactory.createShape(4); fourDimensions.draw(); // Drawing a shape in 4D. } }
Comme vous pouvez le voir, nous définissons une interface qui spécifie une méthode pour dessiner une forme. En faisant en sorte que les différentes classes implémentent l'interface, nous pouvons garantir que toutes les formes peuvent être dessinées (car elles doivent toutes avoir une méthode de draw
remplaçable conformément à la définition de l'interface). Considérant que cette forme est dessinée différemment selon les dimensions dans lesquelles elle est visualisée, nous définissons des classes d'assistance qui implémentent l'interface pour effectuer le travail intensif du GPU de simulation du rendu n-dimensionnel. ShapeFactory
fait le travail d'instanciation de la classe correcte - la méthode createShape
est une fabrique, et comme la définition ci-dessus, c'est une méthode qui renvoie un objet d'une classe. Le type de retour de createShape
est l'interface IShape
car l'interface IShape
est le type de base de toutes les formes (car elles ont une méthode draw
).
Cet exemple Java est assez trivial, mais vous pouvez facilement voir à quel point il devient utile dans des applications plus importantes où la configuration pour créer un objet peut ne pas être si simple. Un exemple de ceci serait un jeu vidéo. Supposons que l'utilisateur doive survivre à différents ennemis. Les classes abstraites et les interfaces peuvent être utilisées pour définir les fonctions de base disponibles pour tous les ennemis (et les méthodes qui peuvent être remplacées), peut-être en utilisant le modèle de délégation (favoriser la composition à l'héritage comme le suggère le Gang des Quatre afin de ne pas être bloqué dans l'extension d'un classe de base unique et pour faciliter les tests/mocking/DI). Pour les objets ennemis instanciés de différentes manières, l'interface permettrait la création d'objets d'usine tout en s'appuyant sur le type d'interface générique. Ce serait très pertinent si l'ennemi était créé dynamiquement.
Un autre exemple est une fonction de constructeur. Supposons que nous utilisions le modèle de délégation pour qu'une classe délègue le travail à d'autres classes qui honorent une interface. Nous pourrions placer une méthode de build
statique sur la classe pour qu'elle construise sa propre instance (en supposant que vous n'utilisiez pas de conteneur/cadre d'injection de dépendance). Au lieu d'avoir à appeler chaque setter, vous pouvez faire ceci :
public class User { private IMessagingService msgService; private String name; private int age; public User(String name, int age, IMessagingService msgService) { this.name = name; this.age = age; this.msgService = msgService; } public static User build(String name, int age) { return new User(name, age, new SomeMessageService()); } }
J'expliquerai le modèle de délégation dans un article ultérieur si vous n'êtes pas familier avec lui - essentiellement, grâce à la composition et en termes de modélisation d'objet, il crée une relation "a-un" au lieu d'un "est-un" relation que vous obtiendriez avec l'héritage. Si vous avez une classe Mammal
et une classe Dog
, et que Dog
étend Mammal
, alors un Dog
est un Mammal
. Alors que, si vous aviez une classe Bark
et que vous venez de passer des instances de Bark
dans le constructeur de Dog
, alors Dog
has-a Bark
. Comme vous pouvez l'imaginer, cela facilite particulièrement les tests unitaires, car vous pouvez injecter des simulations et affirmer des faits sur la simulation tant que la simulation respecte le contrat d'interface dans l'environnement de test.
La méthode de fabrique static
"build" ci-dessus crée simplement un nouvel objet de User
et transmet un MessageService
concret. Remarquez comment cela découle de la définition ci-dessus - n'exposant pas la logique métier pour créer un objet d'une classe, ou, dans ce cas, ne pas exposer la création du service de messagerie à l'appelant de l'usine.
Encore une fois, ce n'est pas nécessairement la façon dont vous feriez les choses dans le monde réel, mais cela présente assez bien l'idée d'une fonction/méthode d'usine. Nous pourrions utiliser un conteneur d'injection de dépendance à la place, par exemple. Revenons maintenant à JavaScript.
Commencer avec Express
Express est un framework d'application Web pour Node (disponible via un module NPM) qui permet de créer un serveur Web HTTP. Il est important de noter qu'Express n'est pas le seul framework à faire cela (il existe Koa, Fastify, etc.), et que, comme vu dans l'article précédent, Node peut fonctionner sans Express comme une entité autonome. (Express n'est qu'un module conçu pour Node - Node peut faire beaucoup de choses sans lui, bien qu'Express soit populaire pour les serveurs Web).
Encore une fois, permettez-moi de faire une distinction très importante. Il existe une dichotomie entre Node/JavaScript et Express. Node, l'environnement d'exécution/environnement dans lequel vous exécutez JavaScript, peut faire beaucoup de choses - comme vous permettre de créer des applications React Native, des applications de bureau, des outils de ligne de commande, etc. - Express n'est rien d'autre qu'un cadre léger qui vous permet d'utiliser Node/JS pour créer des serveurs Web au lieu de traiter avec le réseau de bas niveau et les API HTTP de Node. Vous n'avez pas besoin d'Express pour créer un serveur Web.
Avant de commencer cette section, si vous n'êtes pas familier avec HTTP et les requêtes HTTP (GET, POST, etc.), alors je vous encourage à lire la section correspondante de mon ancien article, qui est lié ci-dessus.
À l'aide d'Express, nous allons configurer différentes routes vers lesquelles les requêtes HTTP peuvent être effectuées, ainsi que les points de terminaison associés (qui sont des fonctions de rappel) qui se déclenchent lorsqu'une demande est adressée à cette route. Ne vous inquiétez pas si les routes et les points de terminaison n'ont actuellement aucun sens - je les expliquerai plus tard.
Contrairement à d'autres articles, j'adopterai l'approche consistant à écrire le code source au fur et à mesure, ligne par ligne, plutôt que de vider l'intégralité de la base de code dans un seul extrait, puis de l'expliquer plus tard. Commençons par ouvrir un terminal (j'utilise Terminus au-dessus de Git Bash sous Windows - ce qui est une bonne option pour les utilisateurs de Windows qui veulent un Bash Shell sans configurer le sous-système Linux), configurons le passe-partout de notre projet et l'ouvrons dans Visual Studio Code.
mkdir server && cd server touch server.js npm init -y npm install express code .
Dans le fichier server.js
, je commencerai par exiger express
en utilisant la fonction require()
.
const express = require('express');
require('express')
indique à Node de sortir et d'obtenir le module Express que nous avons installé précédemment, qui se trouve actuellement dans le dossier node_modules
(car c'est ce que fait npm install
- créez un dossier node_modules
et placez-y les modules et leurs dépendances). Par convention, et lorsqu'il s'agit d'Express, nous appelons la variable qui contient le résultat de retour de require('express')
express
, bien qu'elle puisse s'appeler n'importe quoi.
This returned result, which we have called express
, is actually a function — a function we'll have to invoke to create our Express app and set up our routes. Again, by convention, we call this app
— app
being the return result of express()
— that is, the return result of calling the function that has the name express
as express()
.
const express = require('express'); const app = express(); // Note that the above variable names are the convention, but not required. // An example such as that below could also be used. const foo = require('express'); const bar = foo(); // Note also that the node module we installed is called express.
The line const app = express();
simply puts a new Express Application inside of the app
variable. It calls a function named express
(the return result of require('express')
) and stores its return result in a constant named app
. If you come from an object-oriented programming background, consider this equivalent to instantiating a new object of a class, where app
would be the object and where express()
would call the constructor function of the express
class. Remember, JavaScript allows us to store functions in variables — functions are first-class citizens. The express
variable, then, is nothing more than a mere function. It's provided to us by the developers of Express.
I apologize in advance if I'm taking a very long time to discuss what is actually very basic, but the above, although primitive, confused me quite a lot when I was first learning back-end development with Node.
Inside the Express source code, which is open-source on GitHub, the variable we called express
is a function entitled createApplication
, which, when invoked, performs the work necessary to create an Express Application:
A snippet of Express source code:
exports = module.exports = createApplication; /* * Create an express application */ // This is the function we are storing in the express variable. (- Jamie) function createApplication() { // This is what I mean by "Express App" (- Jamie) var app = function(req, res, next) { app.handle(req, res, next); }; mixin(app, EventEmitter.prototype, false); mixin(app, proto, false); // expose the prototype that will get set on requests app.request = Object.create(req, { app: { configurable: true, enumerable: true, writable: true, value: app } }) // expose the prototype that will get set on responses app.response = Object.create(res, { app: { configurable: true, enumerable: true, writable: true, value: app } }) app.init(); // See - `app` gets returned. (- Jamie) return app; }
GitHub: https://github.com/expressjs/express/blob/master/lib/express.js
With that short deviation complete, let's continue setting up Express. Thus far, we have required the module and set up our app
variable.
const express = require('express'); const app = express();
From here, we have to tell Express to listen on a port. Any HTTP Requests made to the URL and Port upon which our application is listening will be handled by Express. We do that by calling app.listen(...)
, passing to it the port and a callback function which gets called when the server starts running:
const PORT = 3000; app.listen(PORT, () => console.log(`Server is up on port {PORT}.`));
We notate the PORT
variable in capital by convention, for it is a constant variable that will never change. You could do that with all variables that you declare const
, but that would look messy. It's up to the developer or development team to decide on notation, so we'll use the above sparsely. I use const
everywhere as a method of “defensive coding” — that is, if I know that a variable is never going to change then I might as well just declare it const
. Since I define everything const
, I make the distinction between what variables should remain the same on a per-request basis and what variables are true actual global constants.
Here is what we have thus far:
const express = require('express'); const app = express(); const PORT = 3000; // We will build our API here. // ... // Binding our application to port 3000. app.listen(PORT, () => { console.log(`Server is up on port ${PORT}.`); });
Let's test this to see if the server starts running on port 3000.
I'll open a terminal and navigate to our project's root directory. I'll then run node server/server.js
. Note that this assumes you have Node already installed on your system (You can check with node -v
).
If everything works, you should see the following in the terminal:
Server is up on port 3000.
Go ahead and hit Ctrl + C
to bring the server back down.
If this doesn't work for you, or if you see an error such as EADDRINUSE
, then it means you may have a service already running on port 3000. Pick another port number, like 3001, 3002, 5000, 8000, etc. Be aware, lower number ports are reserved and there is an upper bound of 65535.
At this point, it's worth taking another small deviation as to understand servers and ports in the context of computer networking. We'll return to Express in a moment. I take this approach, rather than introducing servers and ports first, for the purpose of relevance. That is, it is difficult to learn a concept if you fail to see its applicability. In this way, you are already aware of the use case for ports and servers with Express, so the learning experience will be more pleasurable.
A Brief Look At Servers And Ports
A server is simply a computer or computer program that provides some sort of “functionality” to the clients that talk to it. More generally, it's a device, usually connected to the Internet, that handles connections in a pre-defined manner. In our case, that “pre-defined manner” will be HTTP or the HyperText Transfer Protocol. Servers that use the HTTP Protocol are called Web Servers.
When building an application, the server is a critical component of the “client-server model”, for it permits the sharing and syncing of data (generally via databases or file systems) across devices. It's a cross-platform approach, in a way, for the SDKs of platforms against which you may want to code — be they web, mobile, or desktop — all provide methods (APIs) to interact with a server over HTTP or TCP/UDP Sockets. It's important to make a distinction here — by APIs, I mean programming language constructs to talk to a server, like XMLHttpRequest
or the Fetch
API in JavaScript, or HttpUrlConnection
in Java, or even HttpClient
in C#/.NET. This is different from the kind of REST API we'll be building in this article to perform CRUD Operations on a database.
To talk about ports, it's important to understand how clients connect to a server. A client requires the IP Address of the server and the Port Number of our specific service on that server. An IP Address, or Internet Protocol Address, is just an address that uniquely identifies a device on a network. Public and private IPs exist, with private addresses commonly used behind a router or Network Address Translator on a local network. You might see private IP Addresses of the form 192.168.XXX.XXX
or 10.0.XXX.XXX
. When articulating an IP Address, decimals are called “dots”. So 192.168.0.1
(a common router IP Addr.) might be pronounced, “one nine two dot one six eight dot zero dot one”. (By the way, if you're ever in a hotel and your phone/laptop won't direct you to the AP captive portal, try typing 192.168.0.1 or 192.168.1.1 or similar directly into Chrome).
For simplicity, and since this is not an article about the complexities of computer networking, assume that an IP Address is equivalent to a house address, allowing you to uniquely identify a house (where a house is analogous to a server, client, or network device) in a neighborhood. One neighborhood is one network. Put together all of the neighborhoods in the United States, and you have the public Internet. (This is a basic view, and there are many more complexities — firewalls, NATs, ISP Tiers (Tier One, Tier Two, and Tier Three), fiber optics and fiber optic backbones, packet switches, hops, hubs, etc., subnet masks, etc., to name just a few — in the real networking world.) The traceroute
Unix command can provide more insight into the above, displaying the path (and associated latency) that packets take through a network as a series of “hops”.
Un numéro de port identifie un service spécifique exécuté sur un serveur. SSH, ou Secure Shell, qui permet l'accès distant à un périphérique, s'exécute généralement sur le port 22. FTP ou File Transfer Protocol (qui peut, par exemple, être utilisé avec un client FTP pour transférer des actifs statiques vers un serveur) s'exécute généralement sur Port 21. Nous pourrions donc dire que les ports sont des pièces spécifiques à l'intérieur de chaque maison dans notre analogie ci-dessus, car les pièces dans les maisons sont faites pour différentes choses - une chambre pour dormir, une cuisine pour la préparation des aliments, une salle à manger pour la consommation dudit nourriture, etc., tout comme les ports correspondent à des programmes qui effectuent des services spécifiques. Pour nous, les serveurs Web fonctionnent généralement sur le port 80, bien que vous soyez libre de spécifier le numéro de port que vous souhaitez tant qu'ils ne sont pas utilisés par un autre service (ils ne peuvent pas entrer en collision).
Pour accéder à un site Web, vous avez besoin de l'adresse IP du site. Malgré cela, nous accédons normalement aux sites Web via une URL. Dans les coulisses, un DNS, ou serveur de noms de domaine, convertit cette URL en adresse IP, permettant au navigateur de faire une requête GET au serveur, d'obtenir le code HTML et de le restituer à l'écran. 8.8.8.8
est l'adresse de l'un des serveurs DNS publics de Google. Vous pouvez imaginer qu'exiger la résolution d'un nom d'hôte en une adresse IP via un serveur DNS distant prendra du temps, et vous avez raison. Pour réduire la latence, les systèmes d'exploitation disposent d'un cache DNS - une base de données temporaire qui stocke les informations de recherche DNS, réduisant ainsi la fréquence à laquelle lesdites recherches doivent avoir lieu. Le cache du résolveur DNS peut être visualisé sous Windows avec la commande ipconfig /displaydns
CMD et purgé via la commande ipconfig /flushdns
.
Sur un serveur Unix, les ports de numéro inférieur les plus courants, comme 80, nécessitent des privilèges de niveau racine ( augmentés si vous venez d'un arrière-plan Windows). Pour cette raison, nous utiliserons le port 3000 pour notre travail de développement, mais nous permettrons au serveur de choisir le numéro de port (celui qui est disponible) lors du déploiement dans notre environnement de production.
Enfin, notez que nous pouvons taper les adresses IP directement dans la barre de recherche de Google Chrome, contournant ainsi le mécanisme de résolution DNS. Taper 216.58.194.36
, par exemple, vous amènera à Google.com. Dans notre environnement de développement, lorsque nous utilisons notre propre ordinateur comme serveur de développement, nous utiliserons localhost
et le port 3000. Une adresse est formatée en tant que hostname:port
, donc notre serveur sera sur localhost:3000
. Localhost, ou 127.0.0.1
, est l'adresse de bouclage et signifie l'adresse de "cet ordinateur". Il s'agit d'un nom d'hôte et son adresse IPv4 se résout en 127.0.0.1
. Essayez de faire un ping localhost sur votre machine dès maintenant. Vous pourriez obtenir ::1
back — qui est l'adresse de bouclage IPv6, ou 127.0.0.1
back — qui est l'adresse de bouclage IPv4. IPv4 et IPv6 sont deux formats d'adresse IP différents associés à des normes différentes - certaines adresses IPv6 peuvent être converties en IPv4 mais pas toutes.
Retour à Express
J'ai mentionné les requêtes HTTP, les verbes et les codes d'état dans mon article précédent, Get Started With Node : An Introduction To APIs, HTTP And ES6+ JavaScript. Si vous n'avez pas une compréhension générale du protocole, n'hésitez pas à passer à la section "Requêtes HTTP et HTTP" de cet article.
Afin d'avoir une idée d'Express, nous allons simplement configurer nos points de terminaison pour les quatre opérations fondamentales que nous effectuerons sur la base de données - Créer, Lire, Mettre à jour et Supprimer, connues collectivement sous le nom de CRUD.
N'oubliez pas que nous accédons aux points de terminaison par des routes dans l'URL. Autrement dit, bien que les mots "route" et "endpoint" soient couramment utilisés de manière interchangeable, un point de terminaison est techniquement une fonction de langage de programmation (comme ES6 Arrow Functions) qui effectue certaines opérations côté serveur, tandis qu'une route est ce que le point de terminaison est situé derrière de . Nous spécifions ces points de terminaison en tant que fonctions de rappel, qu'Express déclenchera lorsque la demande appropriée est envoyée par le client à la route derrière laquelle se trouve le point de terminaison. Vous pouvez vous souvenir de ce qui précède en réalisant que ce sont les points de terminaison qui exécutent une fonction et que la route est le nom utilisé pour accéder aux points de terminaison. Comme nous le verrons, la même route peut être associée à plusieurs points de terminaison en utilisant différents verbes HTTP (similaire à la surcharge de méthode si vous venez d'un contexte OOP classique avec polymorphisme).
Gardez à l'esprit que nous suivons l'architecture REST (REpresentational State Transfer) en permettant aux clients de faire des demandes à notre serveur. Il s'agit, après tout, d'une API REST ou RESTful. Des requêtes spécifiques adressées à des routes spécifiques déclencheront des points de terminaison spécifiques qui feront des choses spécifiques. Un exemple d'une telle « chose » qu'un point de terminaison pourrait faire est d'ajouter de nouvelles données à une base de données, de supprimer des données, de mettre à jour des données, etc.
Express sait quel point de terminaison déclencher parce que nous lui indiquons, explicitement, la méthode de requête (GET, POST, etc.) et la route - nous définissons les fonctions à déclencher pour des combinaisons spécifiques de ce qui précède, et le client fait la demande, en spécifiant un parcours et méthode. Pour le dire plus simplement, avec Node, nous dirons à Express - "Hé, si quelqu'un fait une requête GET à cette route, alors allez-y et lancez cette fonction (utilisez ce point de terminaison)". Les choses peuvent devenir plus compliquées : "Express, si quelqu'un fait une requête GET sur cette route, mais qu'il n'envoie pas de jeton de support d'autorisation valide dans l'en-tête de sa requête, veuillez répondre avec un HTTP 401 Unauthorized
. S'ils possèdent un jeton porteur valide, veuillez envoyer la ressource protégée qu'ils recherchaient en déclenchant le point de terminaison. Merci beaucoup et bonne journée. » En effet, ce serait bien si les langages de programmation pouvaient être de ce niveau élevé sans fuite d'ambiguïté, mais cela démontre néanmoins les concepts de base.
N'oubliez pas que le point de terminaison, en quelque sorte, vit derrière la route. Il est donc impératif que le client fournisse, dans l'en-tête de la requête, la méthode qu'il souhaite utiliser afin qu'Express puisse déterminer quoi faire. La demande sera adressée à un itinéraire spécifique, que le client spécifiera (avec le type de demande) lors de la prise de contact avec le serveur, permettant à Express de faire ce qu'il doit faire et à nous de faire ce que nous devons faire quand Express déclenche nos rappels . C'est à cela que tout se résume.
Dans les exemples de code précédents, nous avons appelé la fonction listen
qui était disponible sur app
, en lui transmettant un port et un rappel. app
lui-même, si vous vous en souvenez, est le résultat de retour de l'appel de la variable express
en tant que fonction (c'est-à-dire express()
), et la variable express
est ce que nous avons nommé le résultat de retour de l'exigence de 'express'
de notre dossier node_modules
. Tout comme listen
est appelé sur app
, nous spécifions les points de terminaison de requête HTTP en les appelant sur app
. Regardons GET :
app.get('/my-test-route', () => { // ... });
Le premier paramètre est une string
, et c'est la route derrière laquelle le point de terminaison vivra. La fonction de rappel est le point de terminaison. Je le répète : la fonction de rappel - le deuxième paramètre - est le point de terminaison qui se déclenche lorsqu'une requête HTTP GET est envoyée à la route que nous spécifions comme premier argument ( /my-test-route
dans ce cas).
Maintenant, avant de continuer à travailler avec Express, nous devons savoir comment fonctionnent les itinéraires. La route que nous spécifions sous forme de chaîne sera appelée en faisant la demande à www.domain.com/the-route-we-chose-earlier-as-a-string
. Dans notre cas, le domaine est localhost:3000
, ce qui signifie que pour déclencher la fonction de rappel ci-dessus, nous devons faire une requête GET à localhost:3000/my-test-route
. Si nous utilisions une chaîne différente comme premier argument ci-dessus, l'URL devrait être différente pour correspondre à ce que nous avons spécifié en JavaScript.
Lorsque vous parlez de telles choses, vous entendrez probablement parler de Glob Patterns. Nous pourrions dire que toutes les routes de notre API sont situées au localhost:3000/**
Glob Pattern, où **
est un caractère générique signifiant tout répertoire ou sous-répertoire (notez que les routes ne sont pas des répertoires) dont la racine est un parent — c'est-à-dire tout.
Continuons et ajoutons une instruction de journal dans cette fonction de rappel afin que nous ayons ensemble :
// Getting the module from node_modules. const express = require('express'); // Creating our Express Application. const app = express(); // Defining the port we'll bind to. const PORT = 3000; // Defining a new endpoint behind the "/my-test-route" route. app.get('/my-test-route', () => { console.log('A GET Request was made to /my-test-route.'); }); // Binding the server to port 3000. app.listen(PORT, () => { console.log(`Server is up on port ${PORT}.`) });
Nous allons rendre notre serveur opérationnel en exécutant node server/server.js
(avec Node installé sur notre système et accessible globalement à partir des variables d'environnement système) dans le répertoire racine du projet. Comme précédemment, vous devriez voir le message indiquant que le serveur est en place dans la console. Maintenant que le serveur est en cours d'exécution, ouvrez un navigateur et visitez localhost:3000
dans la barre d'URL.
Vous devriez être accueilli par un message d'erreur indiquant Cannot GET /
. Appuyez sur Ctrl + Maj + I sous Windows dans Chrome pour afficher la console du développeur. Là-dedans, vous devriez voir que nous avons un 404
(ressource introuvable). Cela a du sens - nous avons seulement dit au serveur quoi faire lorsque quelqu'un visite localhost:3000/my-test-route
. Le navigateur n'a rien à rendre à localhost:3000
(ce qui équivaut à localhost:3000/
avec une barre oblique).
Si vous regardez la fenêtre du terminal où le serveur est en cours d'exécution, il ne devrait pas y avoir de nouvelles données. Maintenant, visitez localhost:3000/my-test-route
dans la barre d'URL de votre navigateur. Vous pouvez voir la même erreur dans la console de Chrome (parce que le navigateur met le contenu en cache et n'a toujours pas de code HTML à afficher), mais si vous affichez votre terminal sur lequel le processus serveur est en cours d'exécution, vous verrez que la fonction de rappel s'est effectivement déclenchée et le message de journal a bien été enregistré.
Arrêtez le serveur avec Ctrl + C.
Maintenant, donnons au navigateur quelque chose à restituer lorsqu'une requête GET est envoyée à cette route afin que nous puissions perdre le message Cannot GET /
. Je vais prendre notre app.get()
de plus tôt, et dans la fonction de rappel, je vais ajouter deux arguments. N'oubliez pas que la fonction de rappel que nous transmettons est appelée par Express dans les coulisses, et Express peut ajouter les arguments qu'il souhaite. Cela en ajoute en fait deux (enfin, techniquement trois, mais nous verrons cela plus tard), et bien qu'ils soient tous les deux extrêmement importants, nous ne nous soucions pas du premier pour l'instant. Le deuxième argument s'appelle res
, abréviation de response
, et j'y accéderai en définissant undefined
comme premier paramètre :
app.get('/my-test-route', (undefined, res) => { console.log('A GET Request was made to /my-test-route.'); });
Encore une fois, nous pouvons appeler l'argument res
comme nous voulons, mais res
est une convention lorsqu'il s'agit d'Express. res
est en fait un objet, et il existe différentes méthodes pour renvoyer des données au client. Dans ce cas, je vais accéder à la fonction send(...)
disponible sur res
pour renvoyer le HTML que le navigateur rendra. Cependant, nous ne sommes pas limités à renvoyer du HTML et pouvons choisir de renvoyer du texte, un objet JavaScript, un flux (les flux sont particulièrement beaux) ou autre.
app.get('/my-test-route', (undefined, res) => { console.log('A GET Request was made to /my-test-route.'); res.send('<h1>Hello, World!</h1>'); });
Si vous arrêtez le serveur, puis le rallumez, puis actualisez votre navigateur sur la /my-test-route
, vous verrez le code HTML s'afficher.
L'onglet Réseau des outils de développement Chrome vous permettra de voir cette requête GET avec plus de détails en ce qui concerne les en-têtes.
À ce stade, il nous sera utile de commencer à en savoir plus sur Express Middleware - des fonctions qui peuvent être déclenchées globalement après qu'un client a fait une demande.
Intergiciel express
Express fournit des méthodes permettant de définir un middleware personnalisé pour votre application. En effet, la signification d'Express Middleware est mieux définie dans les Express Docs, ici)
Les fonctions middleware sont des fonctions qui ont accès à l'objet de requête (
req
), à l'objet de réponse (res
) et à la fonction middleware suivante dans le cycle requête-réponse de l'application. La fonction middleware suivante est généralement désignée par une variable nomméenext
.
Les fonctions du middleware peuvent effectuer les tâches suivantes :
- Exécutez n'importe quel code.
- Apportez des modifications à la requête et aux objets de réponse.
- Terminer le cycle requête-réponse.
- Appelez la fonction middleware suivante dans la pile.
En d'autres termes, une fonction middleware est une fonction personnalisée que nous (le développeur) pouvons définir et qui agira comme intermédiaire entre le moment où Express reçoit la demande et le moment où notre fonction de rappel appropriée se déclenche. Nous pourrions créer une fonction de log
, par exemple, qui enregistrera chaque fois qu'une demande est faite. Notez que nous pouvons également choisir de faire en sorte que ces fonctions middleware se déclenchent après le déclenchement de notre point de terminaison, selon l'endroit où vous le placez dans la pile, ce que nous verrons plus tard.
Afin de spécifier un middleware personnalisé, nous devons le définir en tant que fonction et le transmettre à app.use(...)
.
const myMiddleware = (req, res, next) => { console.log(`Middleware has fired at time ${Date().now}`); next(); } app.use(myMiddleware); // This is the app variable returned from express().
Tous ensemble, nous avons maintenant :
// Getting the module from node_modules. const express = require('express'); // Creating our Express Application. const app = express(); // Our middleware function. const myMiddleware = (req, res, next) => { console.log(`Middleware has fired at time ${Date().now}`); next(); } // Tell Express to use the middleware. app.use(myMiddleware); // Defining the port we'll bind to. const PORT = 3000; // Defining a new endpoint behind the "/my-test-route" route. app.get('/my-test-route', () => { console.log('A GET Request was made to /my-test-route.'); }); // Binding the server to port 3000. app.listen(PORT, () => { console.log(`Server is up on port ${PORT}.`) });
Si vous effectuez à nouveau les demandes via le navigateur, vous devriez maintenant voir que votre fonction middleware se déclenche et enregistre les horodatages. Pour favoriser l'expérimentation, essayez de supprimer l'appel à la fonction next
et voyez ce qui se passe.
La fonction de rappel du middleware est appelée avec trois arguments, req
, res
et next
. req
est le paramètre que nous avons ignoré lors de la création du gestionnaire GET plus tôt, et c'est un objet contenant des informations concernant la demande, telles que les en-têtes, les en-têtes personnalisés, les paramètres et tout corps qui aurait pu être envoyé par le client (comme vous faites avec une requête POST). Je sais que nous parlons ici de middleware, mais les points de terminaison et la fonction middleware sont appelés avec req
et res
. req
et res
seront les mêmes (à moins que l'un ou l'autre ne le modifie) à la fois dans le middleware et le point de terminaison dans le cadre d'une seule requête du client. Cela signifie, par exemple, que vous pouvez utiliser une fonction middleware pour nettoyer les données en supprimant tous les caractères susceptibles d'être destinés à effectuer des injections SQL ou NoSQL, puis en transmettant la req
sécurisée au point de terminaison.
res
, comme vu précédemment, vous permet de renvoyer des données au client de différentes manières.
next
est une fonction de rappel que vous devez exécuter lorsque le middleware a fini de faire son travail afin d'appeler la prochaine fonction middleware dans la pile ou le point de terminaison. Assurez-vous de noter que vous devrez l'appeler dans le bloc then
de toutes les fonctions asynchrones que vous déclenchez dans le middleware. En fonction de votre opération asynchrone, vous pouvez ou non l'appeler dans le bloc catch
. C'est-à-dire que la fonction myMiddleware
se déclenche après que la demande a été faite par le client mais avant que la fonction de point de terminaison de la demande ne soit déclenchée. Lorsque nous exécutons ce code et faisons une demande, vous devriez voir le message Middleware has fired...
avant le message A GET Request was made to...
dans la console. Si vous n'appelez pas next()
, cette dernière partie ne s'exécutera jamais - votre fonction de point de terminaison à la demande ne se déclenchera pas.
Notez également que j'aurais pu définir cette fonction de manière anonyme, en tant que telle (une convention à laquelle je m'en tiendrai):
app.use((req, res, next) => { console.log(`Middleware has fired at time ${Date().now}`); next(); });
Pour toute personne novice en JavaScript et ES6, si la manière dont ce qui précède fonctionne n'a pas de sens immédiat, l'exemple ci-dessous devrait vous aider. Nous définissons simplement une fonction de rappel (la fonction anonyme) qui prend une autre fonction de rappel ( next
) comme argument. Nous appelons une fonction qui prend un argument de fonction une fonction d'ordre supérieur. Regardez-le de la manière ci-dessous - il décrit un exemple de base de la façon dont le code source express pourrait fonctionner dans les coulisses :
console.log('Suppose a request has just been made from the client.\n'); // This is what (it's not exactly) the code behind app.use() might look like. const use = callback => { // Simple log statement to see where we are. console.log('Inside use() - the "use" function has been called.'); // This depicts the termination of the middleware. const next = () => console.log('Terminating Middleware!\n'); // Suppose req and res are defined above (Express provides them). const req = res = null; // "callback" is the "middleware" function that is passed into "use". // "next" is the above function that pretends to stop the middleware. callback(req, res, next); }; // This is analogous to the middleware function we defined earlier. // It gets passed in as "callback" in the "use" function above. const myMiddleware = (req, res, next) => { console.log('Inside the myMiddleware function!'); next(); } // Here, we are actually calling "use()" to see everything work. use(myMiddleware); console.log('Moving on to actually handle the HTTP Request or the next middleware function.');
Nous appelons d'abord use
qui prend myMiddleware
en argument. myMiddleware
, en soi, est une fonction qui prend trois arguments - req
, res
et next
. À l'intérieur use
, myMiddlware
est appelé et ces trois arguments sont transmis. next
est une fonction définie dans use
. myMiddleware
est défini comme callback
dans la méthode use
. Si j'avais placé use
, dans cet exemple, sur un objet appelé app
, nous aurions pu imiter entièrement la configuration d'Express, mais sans sockets ni connectivité réseau.
Dans ce cas, myMiddleware
et callback
sont des fonctions d'ordre supérieur, car ils prennent tous deux des fonctions comme arguments.
Si vous exécutez ce code, vous verrez la réponse suivante :
Suppose a request has just been made from the client. Inside use() - the "use" function has been called. Inside the middleware function! Terminating Middleware! Moving on to actually handle the HTTP Request or the next middleware function.
Notez que j'aurais également pu utiliser des fonctions anonymes pour obtenir le même résultat :
console.log('Suppose a request has just been made from the client.'); // This is what (it's not exactly) the code behind app.use() might look like. const use = callback => { // Simple log statement to see where we are. console.log('Inside use() - the "use" function has been called.'); // This depicts the termination of the middlewear. const next = () => console.log('Terminating Middlewear!'); // Suppose req and res are defined above (Express provides them). const req = res = null; // "callback" is the function which is passed into "use". // "next" is the above function that pretends to stop the middlewear. callback(req, res, () => { console.log('Terminating Middlewear!'); }); }; // Here, we are actually calling "use()" to see everything work. use((req, res, next) => { console.log('Inside the middlewear function!'); next(); }); console.log('Moving on to actually handle the HTTP Request.');
Avec cela, espérons-le, réglé, nous pouvons maintenant revenir à la tâche à accomplir - la configuration de notre middleware.
Le fait est que vous devrez généralement envoyer des données via une requête HTTP. Vous avez plusieurs options pour le faire - envoyer des paramètres de requête d'URL, envoyer des données qui seront accessibles sur l'objet req
dont nous avons entendu parler plus tôt, etc. Cet objet n'est pas seulement disponible dans le rappel pour appeler app.use()
, mais aussi à n'importe quel point de terminaison. Nous avons utilisé undefined
comme remplissage plus tôt afin de pouvoir nous concentrer sur res
pour renvoyer le code HTML au client, mais maintenant, nous avons besoin d'y accéder.
app.use('/my-test-route', (req, res) => { // The req object contains client-defined data that is sent up. // The res object allows the server to send data back down. });
Les requêtes HTTP POST peuvent nécessiter l'envoi d'un objet corps au serveur. Si vous avez un formulaire sur le client et que vous prenez le nom et l'e-mail de l'utilisateur, vous enverrez probablement ces données au serveur dans le corps de la requête.
Voyons à quoi cela pourrait ressembler côté client :
<!DOCTYPE html> <html> <body> <form action="https://localhost:3000/email-list" method="POST" > <input type="text" name="nameInput"> <input type="email" name="emailInput"> <input type="submit"> </form> </body> </html>
Côté serveur :
app.post('/email-list', (req, res) => { // What do we now? // How do we access the values for the user's name and email? });
Pour accéder au nom et à l'e-mail de l'utilisateur, nous devrons utiliser un type particulier de middleware. Cela mettra les données sur un objet appelé body
disponible sur req
. Body Parser était une méthode populaire pour ce faire, disponible par les développeurs Express en tant que module NPM autonome. Maintenant, Express est livré pré-emballé avec son propre middleware pour ce faire, et nous l'appellerons ainsi :
app.use(express.urlencoded({ extended: true }));
Maintenant on peut faire :
app.post('/email-list', (req, res) => { console.log('User Name: ', req.body.nameInput); console.log('User Email: ', req.body.emailInput); });
Tout cela ne fait que prendre n'importe quelle entrée définie par l'utilisateur qui est envoyée par le client et les rend disponibles sur l'objet body
de req
. Notez que sur req.body
, nous avons maintenant nameInput
et emailInput
, qui sont les noms des balises d' input
dans le HTML. Maintenant, ces données définies par le client doivent être considérées comme dangereuses (jamais, ne faites jamais confiance au client) et doivent être nettoyées, mais nous aborderons cela plus tard.
Un autre type de middleware fourni par express est express.json()
. express.json
est utilisé pour empaqueter toutes les charges utiles JSON envoyées dans une requête du client sur req.body
, tandis que express.urlencoded
empaquetera toutes les requêtes entrantes avec des chaînes, des tableaux ou d'autres données encodées URL sur req.body
. En bref, les deux manipulent req.body
, mais .json()
est pour les charges utiles JSON et .urlencoded()
est pour, entre autres, les paramètres de requête POST.
Une autre façon de dire cela est que les requêtes entrantes avec un en-tête Content-Type: application/json
(comme la spécification d'un corps POST avec l'API de fetch
) seront gérées par express.json()
, tandis que les requêtes avec l'en-tête Content-Type: application/x-www-form-urlencoded
(comme les formulaires HTML) sera géré avec express.urlencoded()
. J'espère que cela a maintenant un sens.
Démarrage de nos routes CRUD pour MongoDB
Remarque : lors de l'exécution des requêtes PATCH dans cet article, nous ne suivrons pas la spécification RFC JSONPatch - un problème que nous corrigerons dans le prochain article de cette série.
Considérant que nous comprenons que nous spécifions chaque point de terminaison en appelant la fonction appropriée sur app
, en lui transmettant la route et une fonction de rappel contenant les objets de requête et de réponse, nous pouvons commencer à définir nos routes CRUD pour l'API Bookshelf. En effet, et considérant qu'il s'agit d'un article d'introduction, je ne prendrai pas soin de suivre complètement les spécifications HTTP et REST, ni d'essayer d'utiliser l'architecture la plus propre possible. Cela viendra dans un prochain article.
Je vais ouvrir le fichier server.js
que nous avons utilisé jusqu'à présent et tout vider pour commencer à partir de la table rase ci-dessous :
// Getting the module from node_modules. const express = require('express'); // This creates our Express App. const app = express(); // Define middleware. app.use(express.json()); app.use(express.urlencoded({ extended: true )); // Listening on port 3000 (arbitrary). // Not a TCP or UDP well-known port. // Does not require superuser privileges. const PORT = 3000; // We will build our API here. // ... // Binding our application to port 3000. app.listen(PORT, () => console.log(`Server is up on port ${PORT}.`));
Considérez tout le code suivant pour prendre la partie // ...
du fichier ci-dessus.
Pour définir nos points de terminaison, et parce que nous construisons une API REST, nous devons discuter de la bonne façon de nommer les routes. Encore une fois, vous devriez jeter un œil à la section HTTP de mon ancien article pour plus d'informations. Nous avons affaire à des livres, donc toutes les routes seront situées derrière /books
(la convention de nommage au pluriel est standard).
Demande | Route |
---|---|
PUBLIER | /books |
AVOIR | /books/id |
PIÈCE | /books/id |
EFFACER | /books/id |
Comme vous pouvez le voir, un ID n'a pas besoin d'être spécifié lors de la publication d'un livre car nous (ou plutôt, MongoDB), le générerons pour nous, automatiquement, côté serveur. OBTENIR, PATCHer et SUPPRIMER des livres nécessiteront tous que nous transmettions cet ID à notre point de terminaison, dont nous parlerons plus tard. Pour l'instant, créons simplement les points de terminaison :
// HTTP POST /books app.post('/books', (req, res) => { // ... console.log('A POST Request was made!'); }); // HTTP GET /books/:id app.get('/books/:id', (req, res) => { // ... console.log(`A GET Request was made! Getting book ${req.params.id}`); }); // HTTP PATCH /books/:id app.patch('/books/:id', (req, res) => { // ... console.log(`A PATCH Request was made! Updating book ${req.params.id}`); }); // HTTP DELETE /books/:id app.delete('/books/:id', (req, res) => { // ... console.log(`A DELETE Request was made! Deleting book ${req.params.id}`); });
La syntaxe :id
indique à Express que id
est un paramètre dynamique qui sera transmis dans l'URL. Nous y avons accès sur l'objet params
qui est disponible sur req
. Je sais que "nous y avons accès sur req
" ressemble à de la magie et que la magie (qui n'existe pas) est dangereuse en programmation, mais vous devez vous rappeler qu'Express n'est pas une boîte noire. C'est un projet open-source disponible sur GitHub sous une licence MIT. Vous pouvez facilement afficher son code source si vous voulez voir comment les paramètres de requête dynamiques sont placés sur l'objet req
.
Tous ensemble, nous avons maintenant les éléments suivants dans notre fichier server.js
:
// Getting the module from node_modules. const express = require('express'); // This creates our Express App. const app = express(); // Define middleware. app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Listening on port 3000 (arbitrary). // Not a TCP or UDP well-known port. // Does not require superuser privileges. const PORT = 3000; // We will build our API here. // HTTP POST /books app.post('/books', (req, res) => { // ... console.log('A POST Request was made!'); }); // HTTP GET /books/:id app.get('/books/:id', (req, res) => { // ... console.log(`A GET Request was made! Getting book ${req.params.id}`); }); // HTTP PATCH /books/:id app.patch('/books/:id', (req, res) => { // ... console.log(`A PATCH Request was made! Updating book ${req.params.id}`); }); // HTTP DELETE /books/:id app.delete('/books/:id', (req, res) => { // ... console.log(`A DELETE Request was made! Deleting book ${req.params.id}`); }); // Binding our application to port 3000. app.listen(PORT, () => console.log(`Server is up on port ${PORT}.`));
Allez-y et démarrez le serveur, en exécutant node server.js
à partir du terminal ou de la ligne de commande, et visitez votre navigateur. Ouvrez la console de développement Chrome et, dans la barre d'URL (Uniform Resource Locator), visitez localhost:3000/books
. Vous devriez déjà voir l'indicateur dans le terminal de votre système d'exploitation indiquant que le serveur est opérationnel ainsi que l'instruction de journal pour GET.
Jusqu'à présent, nous avons utilisé un navigateur Web pour effectuer des requêtes GET. C'est bien pour débuter, mais nous découvrirons rapidement qu'il existe de meilleurs outils pour tester les routes d'API. En effet, nous pourrions coller les appels de fetch
directement dans la console ou utiliser un service en ligne. Dans notre cas, et pour gagner du temps, nous utiliserons cURL
et Postman. J'utilise les deux dans cet article (bien que vous puissiez utiliser l'un ou l'autre) afin que je puisse les présenter si vous ne les avez pas utilisés. cURL
est une bibliothèque (une bibliothèque très, très importante) et un outil de ligne de commande conçu pour transférer des données à l'aide de divers protocoles. Postman est un outil basé sur une interface graphique pour tester les API. Après avoir suivi les instructions d'installation pertinentes pour les deux outils sur votre système d'exploitation, assurez-vous que votre serveur est toujours en cours d'exécution, puis exécutez les commandes suivantes (une par une) dans un nouveau terminal. Il est important que vous les saisissiez et que vous les exécutiez individuellement, puis que vous regardiez le message de journal dans le terminal séparé de votre serveur. Notez également que le symbole de commentaire de langage de programmation standard //
n'est pas un symbole valide dans Bash ou MS-DOS. Vous devrez omettre ces lignes, et je ne les utilise ici que pour décrire chaque bloc de commandes cURL
.
// HTTP POST Request (Localhost, IPv4, IPv6) curl -X POST https://localhost:3000/books curl -X POST https://127.0.0.1:3000/books curl -X POST https://[::1]:3000/books // HTTP GET Request (Localhost, IPv4, IPv6) curl -X GET https://localhost:3000/books/123abc curl -X GET https://127.0.0.1:3000/books/book-id-123 curl -X GET https://[::1]:3000/books/book-abc123 // HTTP PATCH Request (Localhost, IPv4, IPv6) curl -X PATCH https://localhost:3000/books/456 curl -X PATCH https://127.0.0.1:3000/books/218 curl -X PATCH https://[::1]:3000/books/some-id // HTTP DELETE Request (Localhost, IPv4, IPv6) curl -X DELETE https://localhost:3000/books/abc curl -X DELETE https://127.0.0.1:3000/books/314 curl -X DELETE https://[::1]:3000/books/217
Comme vous pouvez le voir, l'ID transmis en tant que paramètre d'URL peut être n'importe quelle valeur. Le drapeau -X
spécifie le type de requête HTTP (il peut être omis pour GET), et nous fournit l'URL à laquelle la requête sera faite par la suite. J'ai dupliqué chaque demande trois fois, ce qui vous permet de voir que tout fonctionne toujours, que vous utilisiez le nom d'hôte localhost
, l'adresse IPv4 ( 127.0.0.1
) à laquelle localhost
se résout ou l'adresse IPv6 ( ::1
) à laquelle localhost
se résout . Notez que cURL
nécessite d'envelopper les adresses IPv6 entre crochets.
Nous sommes maintenant dans un endroit décent - nous avons mis en place la structure simple de nos itinéraires et points de terminaison. Le serveur fonctionne correctement et accepte les requêtes HTTP comme prévu. Contrairement à ce à quoi vous pourriez vous attendre, il ne reste pas longtemps à parcourir à ce stade - nous devons simplement configurer notre base de données, l'héberger (à l'aide d'une base de données en tant que service - MongoDB Atlas), et conserver les données (et effectuer la validation et créer des réponses d'erreur).
Configuration d'une base de données MongoDB de production
Pour configurer une base de données de production, nous nous dirigerons vers la page d'accueil de MongoDB Atlas et créerons un compte gratuit. Ensuite, créez un nouveau cluster. Vous pouvez conserver les paramètres par défaut, en choisissant une région applicable au niveau de frais. Appuyez ensuite sur le bouton "Créer un cluster". La création du cluster prendra un certain temps, puis vous pourrez obtenir l'URL et le mot de passe de votre base de données. Prenez-en note lorsque vous les voyez. Nous allons les coder en dur pour le moment, puis les stocker ultérieurement dans des variables d'environnement pour des raisons de sécurité. Pour obtenir de l'aide sur la création et la connexion à un cluster, je vous renverrai à la documentation MongoDB, en particulier cette page et cette page, ou vous pouvez laisser un commentaire ci-dessous et j'essaierai de vous aider.
Création d'un modèle de mangouste
Il est recommandé de comprendre la signification des documents et des collections dans le contexte de NoSQL (Not Only SQL — Structured Query Language). Pour référence, vous voudrez peut-être lire à la fois le guide de démarrage rapide Mongoose et la section MongoDB de mon ancien article.
Nous avons maintenant une base de données prête à accepter les opérations CRUD. Mongoose est un module Node (ou ODM - Object Document Mapper) qui nous permettra d'effectuer ces opérations (en supprimant certaines des complexités) ainsi que de configurer le schéma ou la structure de la collection de bases de données.
En tant que clause de non-responsabilité importante, il existe de nombreuses controverses autour des ORM et de modèles tels que Active Record ou Data Mapper. Certains développeurs ne jurent que par les ORM et d'autres ne jurent que contre eux (pensant qu'ils gênent). Il est également important de noter que les ORM s'abstiennent beaucoup comme le regroupement de connexions, les connexions de socket et la gestion, etc. Vous pouvez facilement utiliser le pilote natif MongoDB (un autre module NPM), mais cela demanderait beaucoup plus de travail. Bien qu'il soit recommandé de jouer avec le pilote natif avant d'utiliser les ORM, j'omets ici le pilote natif par souci de brièveté. Pour les opérations SQL complexes sur une base de données relationnelle, tous les ORM ne seront pas optimisés pour la vitesse des requêtes et vous risquez de finir par écrire votre propre SQL brut. ORMs can come into play a lot with Domain-Driven Design and CQRS, among others. They are an established concept in the .NET world, and the Node.js community has not completely caught up yet — TypeORM is better, but it's not NHibernate or Entity Framework.
To create our Model, I'll create a new folder in the server
directory entitled models
, within which I'll create a single file with the name book.js
. Thus far, our project's directory structure is as follows:
- server - node_modules - models - book.js - package.json - server.js
Indeed, this directory structure is not required, but I use it here because it's simple. Allow me to note that this is not at all the kind of architecture you want to use for larger applications (and you might not even want to use JavaScript — TypeScript could be a better option), which I discuss in this article's closing. The next step will be to install mongoose
, which is performed via, as you might expect, npm i mongoose
.
The meaning of a Model is best ascertained from the Mongoose documentation:
Models are fancy constructors compiled from
Schema
definitions. An instance of a model is called a document. Models are responsible for creating and reading documents from the underlying MongoDB database.
Before creating the Model, we'll define its Schema. A Schema will, among others, make certain expectations about the value of the properties provided. MongoDB is schemaless, and thus this functionality is provided by the Mongoose ODM. Let's start with a simple example. Suppose I want my database to store a user's name, email address, and password. Traditionally, as a plain old JavaScript Object (POJO), such a structure might look like this:
const userDocument = { name: 'Jamie Corkhill', email: '[email protected]', password: 'Bcrypt Hash' };
If that above object was how we expected our user's object to look, then we would need to define a schema for it, like this:
const schema = { name: { type: String, trim: true, required: true }, email: { type: String, trim: true, required: true }, password: { type: String, required: true } };
Notice that when creating our schema, we define what properties will be available on each document in the collection as an object in the schema. In our case, that's name
, email
, and password
. The fields type
, trim
, required
tell Mongoose what data to expect. If we try to set the name
field to a number, for example, or if we don't provide a field, Mongoose will throw an error (because we are expecting a type of String
), and we can send back a 400 Bad Request
to the client. This might not make sense right now because we have defined an arbitrary schema
object. However, the fields of type
, trim
, and required
(among others) are special validators that Mongoose understands. trim
, for example, will remove any whitespace from the beginning and end of the string. We'll pass the above schema to mongoose.Schema()
in the future and that function will know what to do with the validators.
Understanding how Schemas work, we'll create the model for our Books Collection of the Bookshelf API. Let's define what data we require:
Titre
ISBN Number
Auteur
Prénom
Nom de famille
Publishing Date
Finished Reading (Boolean)
I'm going to create this in the book.js
file we created earlier in /models
. Like the example above, we'll be performing validation:
const mongoose = require('mongoose'); // Define the schema: const mySchema = { title: { type: String, required: true, trim: true, }, isbn: { type: String, required: true, trim: true, }, author: { firstName:{ type: String, required: true, trim: true }, lastName: { type: String, required: true, trim: true } }, publishingDate: { type: String }, finishedReading: { type: Boolean, required: true, default: false } }
default
will set a default value for the property if none is provided — finishedReading
for example, although a required field, will be set automatically to false
if the client does not send one up.
Mongoose also provides the ability to perform custom validation on our fields, which is done by supplying the validate()
method, which attains the value that was attempted to be set as its one and only parameter. In this function, we can throw an error if the validation fails. Voici un exemple:
// ... isbn: { type: String, required: true, trim: true, validate(value) { if (!validator.isISBN(value)) { throw new Error('ISBN is invalid.'); } } } // ...
Now, if anyone supplies an invalid ISBN to our model, Mongoose will throw an error when trying to save that document to the collection. I've already installed the NPM module validator
via npm i validator
and required it. validator
contains a bunch of helper functions for common validation requirements, and I use it here instead of RegEx because ISBNs can't be validated with RegEx alone due to a tailing checksum. Remember, users will be sending a JSON body to one of our POST routes. That endpoint will catch any errors (such as an invalid ISBN) when attempting to save, and if one is thrown, it'll return a blank response with an HTTP 400 Bad Request
status — we haven't yet added that functionality.
Finally, we have to define our schema of earlier as the schema for our model, so I'll make a call to mongoose.Schema()
passing in that schema:
const bookSchema = mongoose.Schema(mySchema);
To make things more precise and clean, I'll replace the mySchema
variable with the actual object all on one line:
const bookSchema = mongoose.Schema({ title:{ type: String, required: true, trim: true, }, isbn:{ type: String, required: true, trim: true, validate(value) { if (!validator.isISBN(value)) { throw new Error('ISBN is invalid.'); } } }, author:{ firstName: { type: String required: true, trim: true }, lastName:{ type: String, required: true, trim: true } }, publishingDate:{ type: String }, finishedReading:{ type: Boolean, required: true, default: false } });
Let's take a final moment to discuss this schema. We are saying that each of our documents will consist of a title, an ISBN, an author with a first and last name, a publishing date, and a finishedReading boolean.
-
title
will be of typeString
, it's a required field, and we'll trim any whitespace. -
isbn
will be of typeString
, it's a required field, it must match the validator, and we'll trim any whitespace. -
author
is of typeobject
containing a required, trimmed,string
firstName and a required, trimmed,string
lastName. -
publishingDate
is of type String (although we could make it of typeDate
orNumber
for a Unix timestamp. -
finishedReading
is a requiredboolean
that will default tofalse
if not provided.
With our bookSchema
defined, Mongoose knows what data and what fields to expect within each document to the collection that stores books. However, how do we tell it what collection that specific schema defines? We could have hundreds of collections, so how do we correlate, or tie, bookSchema
to the Book
collection?
The answer, as seen earlier, is with the use of models. We'll use bookSchema
to create a model, and that model will model the data to be stored in the Book collection, which will be created by Mongoose automatically.
Append the following lines to the end of the file:
const Book = mongoose.model('Book', bookSchema); module.exports = Book;
As you can see, we have created a model, the name of which is Book
(— the first parameter to mongoose.model()
), and also provided the ruleset, or schema, to which all data is saved in the Book collection will have to abide. We export this model as a default export, allowing us to require
the file for our endpoints to access. Book
is the object upon which we'll call all of the required functions to Create, Read, Update, and Delete data which are provided by Mongoose.
Altogether, our book.js
file should look as follows:
const mongoose = require('mongoose'); const validator = require('validator'); // Define the schema. const bookSchema = mongoose.Schema({ title:{ type: String, required: true, trim: true, }, isbn:{ type: String, required: true, trim: true, validate(value) { if (!validator.isISBN(value)) { throw new Error('ISBN is invalid.'); } } }, author:{ firstName: { type: String, required: true, trim: true }, lastName:{ type: String, required: true, trim: true } }, publishingDate:{ type: String }, finishedReading:{ type: Boolean, required: true, default: false } }); // Create the "Book" model of name Book with schema bookSchema. const Book = mongoose.model('Book', bookSchema); // Provide the model as a default export. module.exports = Book;
Connecting To MongoDB (Basics)
Don't worry about copying down this code. I'll provide a better version in the next section. To connect to our database, we'll have to provide the database URL and password. We'll call the connect
method available on mongoose
to do so, passing to it the required data. For now, we are going hardcode the URL and password — an extremely frowned upon technique for many reasons: namely the accidental committing of sensitive data to a public (or private made public) GitHub Repository. Realize also that commit history is saved, and that if you accidentally commit a piece of sensitive data, removing it in a future commit will not prevent people from seeing it (or bots from harvesting it), because it's still available in the commit history. CLI tools exist to mitigate this issue and remove history.
As stated, for now, we'll hard code the URL and password, and then save them to environment variables later. At this point, let's look at simply how to do this, and then I'll mention a way to optimize it.
const mongoose = require('mongoose'); const MONGODB_URL = 'Your MongoDB URL'; mongoose.connect(MONGODB_URL, { useNewUrlParser: true, useCreateIndex: true, useFindAndModify: false, useUnifiedTopology: true });
Cela se connectera à la base de données. Nous fournissons l'URL que nous avons obtenue à partir du tableau de bord MongoDB Atlas, et l'objet transmis comme deuxième paramètre spécifie les fonctionnalités à utiliser pour, entre autres, empêcher les avertissements de dépréciation.
Mongoose, qui utilise le noyau du pilote natif MongoDB dans les coulisses, doit tenter de suivre les changements de rupture apportés au pilote. Dans une nouvelle version du pilote, le mécanisme utilisé pour analyser les URL de connexion a été modifié, nous passons donc le drapeau useNewUrlParser: true
pour spécifier que nous voulons utiliser la dernière version disponible du pilote officiel.
Par défaut, si vous définissez des index (et ils sont appelés "index" et non "indices") (que nous n'aborderons pas dans cet article) sur les données de votre base de données, Mongoose utilise la ensureIndex()
disponible depuis le pilote natif. MongoDB a déprécié cette fonction en faveur de createIndex()
, et donc définir le drapeau useCreateIndex
sur true indiquera à Mongoose d'utiliser la méthode createIndex()
du pilote, qui est la fonction non dépréciée.
La version originale de mongoose de findOneAndUpdate
(qui est une méthode pour trouver un document dans une base de données et le mettre à jour) est antérieure à la version du pilote natif. Autrement dit, findOneAndUpdate()
n'était pas à l'origine une fonction de pilote natif mais plutôt une fonction fournie par Mongoose. Mongoose a donc dû utiliser findAndModify
fourni en arrière-plan par le pilote pour créer la fonctionnalité findOneAndUpdate
. Avec le pilote maintenant mis à jour, il contient sa propre fonction, nous n'avons donc pas besoin d'utiliser findAndModify
. Cela n'a peut-être pas de sens, et ce n'est pas grave – ce n'est pas une information importante sur l'échelle des choses.
Enfin, MongoDB a rendu obsolète son ancien système de surveillance des serveurs et des moteurs. Nous utilisons la nouvelle méthode avec useUnifiedTopology: true
.
Ce que nous avons jusqu'à présent est un moyen de se connecter à la base de données. Mais voici le problème - ce n'est ni évolutif ni efficace. Lorsque nous écrivons des tests unitaires pour cette API, les tests unitaires vont utiliser leurs propres données de test (ou fixtures) sur leurs propres bases de données de test. Donc, nous voulons un moyen de pouvoir créer des connexions à des fins différentes - certaines pour les environnements de test (que nous pouvons faire tourner et supprimer à volonté), d'autres pour les environnements de développement et d'autres pour les environnements de production. Pour ce faire, nous allons construire une usine. (Rappelez-vous que plus tôt?)
Connexion à Mongo - Construire une implémentation d'une usine JS
En effet, les objets Java ne sont pas du tout analogues aux objets JavaScript, et donc, par la suite, ce que nous savons ci-dessus du Factory Design Pattern ne s'appliquera pas. J'ai simplement fourni cela à titre d'exemple pour montrer le modèle traditionnel. Pour atteindre un objet en Java, ou C#, ou C++, etc., nous devons instancier une classe. Cela se fait avec le new
mot-clé, qui demande au compilateur d'allouer de la mémoire pour l'objet sur le tas. En C++, cela nous donne un pointeur vers l'objet que nous devons nettoyer nous-mêmes afin de ne pas avoir de pointeurs suspendus ou de fuites de mémoire (C++ n'a pas de ramasse-miettes, contrairement à Node/V8 qui est construit sur C++) En JavaScript, le ci-dessus n'a pas besoin d'être fait - nous n'avons pas besoin d'instancier une classe pour atteindre un objet - un objet est juste {}
. Certaines personnes diront que tout en JavaScript est un objet, bien que ce ne soit techniquement pas vrai car les types primitifs ne sont pas des objets.
Pour les raisons ci-dessus, notre usine JS sera plus simple, s'en tenant à la définition lâche d'une usine étant une fonction qui renvoie un objet (un objet JS). Puisqu'une fonction est un objet (car function
hérite de l' object
via l'héritage prototypique), notre exemple ci-dessous répondra à ce critère. Pour implémenter l'usine, je vais créer un nouveau dossier à l'intérieur du server
appelé db
. Dans db
, je vais créer un nouveau fichier appelé mongoose.js
. Ce fichier établira des connexions à la base de données. À l'intérieur de mongoose.js
, je vais créer une fonction appelée connectionFactory
et l'exporter par défaut :
// Directory - server/db/mongoose.js const mongoose = require('mongoose'); const MONGODB_URL = 'Your MongoDB URL'; const connectionFactory = () => { return mongoose.connect(MONGODB_URL, { useNewUrlParser: true, useCreateIndex: true, useFindAndModify: false }); }; module.exports = connectionFactory;
En utilisant le raccourci fourni par ES6 pour les fonctions fléchées qui renvoient une instruction sur la même ligne que la signature de la méthode, je vais simplifier ce fichier en supprimant la définition connectionFactory
et en exportant simplement la fabrique par défaut :
// server/db/mongoose.js const mongoose = require('mongoose'); const MONGODB_URL = 'Your MongoDB URL'; module.exports = () => mongoose.connect(MONGODB_URL, { useNewUrlParser: true, useCreateIndex: true, useFindAndModify: true });
Maintenant, tout ce que l'on a à faire est d'exiger le fichier et d'appeler la méthode qui est exportée, comme ceci :
const connectionFactory = require('./db/mongoose'); connectionFactory(); // OR require('./db/mongoose')();
Vous pouvez inverser le contrôle en fournissant votre URL MongoDB en tant que paramètre à la fonction d'usine, mais nous allons modifier dynamiquement l'URL en tant que variable d'environnement basée sur l'environnement.
Les avantages de faire notre connexion en tant que fonction sont que nous pouvons appeler cette fonction plus tard dans le code pour se connecter à la base de données à partir de fichiers destinés à la production et ceux destinés aux tests d'intégration locaux et distants à la fois sur l'appareil et avec un pipeline CI/CD distant. /construire le serveur.
Construire nos terminaux
Nous commençons maintenant à ajouter une logique CRUD très simple à nos points de terminaison. Comme indiqué précédemment, une courte clause de non-responsabilité s'impose. Les méthodes par lesquelles nous procédons à la mise en œuvre de notre logique métier ici ne sont pas celles que vous devriez refléter pour autre chose que de simples projets. La connexion aux bases de données et l'exécution de la logique directement dans les points de terminaison est (et devrait être) mal vue, car vous perdez la possibilité d'échanger des services ou des SGBD sans avoir à effectuer une refactorisation à l'échelle de l'application. Néanmoins, étant donné qu'il s'agit d'un article pour débutant, j'emploie ces mauvaises pratiques ici. Un prochain article de cette série discutera de la manière dont nous pouvons augmenter à la fois la complexité et la qualité de notre architecture.
Pour l'instant, revenons à notre fichier server.js
et assurons-nous que nous avons tous les deux le même point de départ. Remarquez que j'ai ajouté l'instruction require
pour notre usine de connexion à la base de données et que j'ai importé le modèle que nous avons exporté depuis ./models/book.js
.
const express = require('express'); // Database connection and model. require('./db/mongoose.js'); const Book = require('./models/book.js'); // This creates our Express App. const app = express(); // Define middleware. app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Listening on port 3000 (arbitrary). // Not a TCP or UDP well-known port. // Does not require superuser privileges. const PORT = 3000; // We will build our API here. // HTTP POST /books app.post('/books', (req, res) => { // ... console.log('A POST Request was made!'); }); // HTTP GET /books/:id app.get('/books/:id', (req, res) => { // ... console.log(`A GET Request was made! Getting book ${req.params.id}`); }); // HTTP PATCH /books/:id app.patch('/books/:id', (req, res) => { // ... console.log(`A PATCH Request was made! Updating book ${req.params.id}`); }); // HTTP DELETE /books/:id app.delete('/books/:id', (req, res) => { // ... console.log(`A DELETE Request was made! Deleting book ${req.params.id}`); }); // Binding our application to port 3000. app.listen(PORT, () => console.log(`Server is up on port ${PORT}.`));
Je vais commencer par app.post()
. Nous avons accès au modèle Book
car nous l'avons exporté à partir du fichier dans lequel nous l'avons créé. Comme indiqué dans la documentation de Mongoose, Book
est constructible. Pour créer un nouveau livre, nous appelons le constructeur et transmettons les données du livre, comme suit :
const book = new Book(bookData);
Dans notre cas, nous aurons bookData
comme objet envoyé dans la requête, qui sera disponible sur req.body.book
. N'oubliez pas que le middleware express.json()
placera toutes les données JSON que nous envoyons sur req.body
. Nous devons envoyer JSON au format suivant :
{ "book": { "title": "The Art of Computer Programming", "isbn": "ISBN-13: 978-0-201-89683-1", "author": { "firstName": "Donald", "lastName": "Knuth" }, "publishingDate": "July 17, 1997", "finishedReading": true } }
Cela signifie donc que le JSON que nous laissons passer sera analysé et que l'intégralité de l'objet JSON (la première paire d'accolades) sera placée sur req.body
par le middleware express.json()
. La seule et unique propriété de notre objet JSON est book
, et donc l'objet book
sera disponible sur req.body.book
.
À ce stade, nous pouvons appeler la fonction constructeur de modèle et transmettre nos données :
app.post('/books', async (req, res) => { // <- Notice 'async' const book = new Book(req.body.book); await book.save(); // <- Notice 'await' });
Remarquez quelques choses ici. L'appel de la méthode save
sur l'instance que nous récupérons après avoir appelé la fonction constructeur persistera l'objet req.body.book
dans la base de données si et seulement s'il est conforme au schéma que nous avons défini dans le modèle Mongoose. L'acte de sauvegarder des données dans une base de données est une opération asynchrone, et cette méthode save()
renvoie une promesse - dont nous attendons beaucoup le règlement. Plutôt que de chaîner un appel .then()
, j'utilise la syntaxe ES6 Async/Await, ce qui signifie que je dois rendre la fonction de rappel app.post
async
.
book.save()
rejettera avec une ValidationError
si l'objet envoyé par le client n'est pas conforme au schéma que nous avons défini. Notre configuration actuelle crée un code très floconneux et mal écrit, car nous ne voulons pas que notre application plante en cas d'échec concernant la validation. Pour résoudre ce problème, j'entourerai l'opération dangereuse d'une clause try/catch
. En cas d'erreur, je renverrai une mauvaise requête HTTP 400 ou une entité non traitable HTTP 422. Il y a un certain débat sur lequel utiliser, donc je m'en tiendrai à un 400 pour cet article car il est plus générique.
app.post('/books', async (req, res) => { try { const book = new Book(req.body.book); await book.save(); return res.status(201).send({ book }); } catch (e) { return res.status(400).send({ error: 'ValidationError' }); } });
Notez que j'utilise le raccourci d'objet ES6 pour simplement renvoyer l'objet book
directement au client en cas de réussite avec res.send({ book })
- ce qui équivaudrait à res.send({ book: book })
. Je renvoie également l'expression juste pour m'assurer que ma fonction se termine. Dans le bloc catch
, je définis explicitement le statut sur 400 et renvoie la chaîne 'ValidationError' sur la propriété error
de l'objet qui est renvoyé. Un 201 est le code d'état du chemin de réussite signifiant "CRÉÉ".
En effet, ce n'est pas non plus la meilleure solution car nous ne pouvons pas vraiment être sûrs que la raison de l'échec était une mauvaise demande du côté du client. Peut-être avons-nous perdu la connexion (supposée une connexion de socket abandonnée, donc une exception transitoire) à la base de données, auquel cas nous devrions probablement renvoyer une erreur 500 Internal Server. Une façon de vérifier cela serait de lire l'objet d'erreur e
et de renvoyer sélectivement une réponse. Faisons-le maintenant, mais comme je l'ai dit à plusieurs reprises, un article de suivi discutera de l'architecture appropriée en termes de routeurs, contrôleurs, services, référentiels, classes d'erreurs personnalisées, middleware d'erreur personnalisé, réponses d'erreur personnalisées, modèle de base de données/données d'entité de domaine le mappage et la séparation des requêtes de commande (CQS).
app.post('/books', async (req, res) => { try { const book = new Book(req.body.book); await book.save(); return res.send({ book }); } catch (e) { if (e instanceof mongoose.Error.ValidationError) { return res.status(400).send({ error: 'ValidationError' }); } else { return res.status(500).send({ error: 'Internal Error' }); } } });
Allez-y et ouvrez Postman (en supposant que vous l'ayez, sinon, téléchargez-le et installez-le) et créez une nouvelle demande. Nous ferons une requête POST à localhost:3000/books
. Sous l'onglet "Corps" dans la section Postman Request, je sélectionne le bouton radio "raw" et sélectionne "JSON" dans le bouton déroulant à l'extrême droite. Cela ira de l'avant et ajoutera automatiquement l'en-tête Content-Type: application/json
à la requête. Je vais ensuite copier et coller l'objet Book JSON de plus tôt dans la zone de texte Body. Voici ce que nous avons :
Par la suite, j'appuierai sur le bouton d'envoi et vous devriez voir une réponse 201 Créé dans la section "Réponse" de Postman (la rangée du bas). Nous le voyons parce que nous avons spécifiquement demandé à Express de répondre avec un 201 et l'objet Book — si nous venions de faire res.send()
sans code d'état, express
aurait automatiquement répondu avec un 200 OK. Comme vous pouvez le voir, l'objet Book est maintenant enregistré dans la base de données et a été renvoyé au client en tant que réponse à la requête POST.
Si vous affichez la collection de livres de la base de données via MongoDB Atlas, vous verrez que le livre a bien été enregistré.
Vous pouvez également dire que MongoDB a inséré les champs __v
et _id
. Le premier représente la version du document, dans ce cas, 0, et le second est l'ObjectID du document - qui est généré automatiquement par MongoDB et dont la probabilité de collision est garantie.
Un résumé de ce que nous avons couvert jusqu'à présent
Nous avons couvert beaucoup jusqu'à présent dans l'article. Prenons un court répit en passant en revue un bref résumé avant de revenir pour terminer l'API Express.
Nous avons découvert ES6 Object Destructuring, la syntaxe abrégée d'objet ES6, ainsi que l'opérateur ES6 Rest/Spread. Tous les trois nous permettent de faire ce qui suit (et plus, comme indiqué ci-dessus):
// Destructuring Object Properties: const { a: newNameA = 'Default', b } = { a: 'someData', b: 'info' }; console.log(`newNameA: ${newNameA}, b: ${b}`); // newNameA: someData, b: info // Destructuring Array Elements const [elemOne, elemTwo] = [() => console.log('hi'), 'data']; console.log(`elemOne(): ${elemOne()}, elemTwo: ${elemTwo}`); // elemOne(): hi, elemTwo: data // Object Shorthand const makeObj = (name) => ({ name }); console.log(`makeObj('Tim'): ${JSON.stringify(makeObj('Tim'))}`); // makeObj('Tim'): { "name": "Tim" } // Rest, Spread const [c, d, ...rest] = [0, 1, 2, 3, 4]; console.log(`c: ${c}, d: ${d}, rest: ${rest}`) // c: 0, d: 1, rest: 2, 3, 4
Nous avons également couvert Express, Expess Middleware, les serveurs, les ports, l'adressage IP, etc. Les choses sont devenues intéressantes lorsque nous avons appris qu'il existe des méthodes disponibles sur le résultat de retour de require('express')();
avec les noms des verbes HTTP, tels que app.get
et app.post
.
Si cette partie require('express')()
n'a pas de sens pour vous, c'est ce que je voulais dire :
const express = require('express'); const app = express(); app.someHTTPVerb
Cela devrait avoir un sens de la même manière que nous avons déjà mis le feu à l'usine de connexion pour Mongoose.
Chaque gestionnaire de route, qui est la fonction de point de terminaison (ou fonction de rappel), est transmis dans un objet req
et un objet res
d'Express en coulisses. (Techniquement, ils obtiennent également next
, comme nous le verrons dans une minute). req
contient des données spécifiques à la demande entrante du client, telles que des en-têtes ou tout JSON envoyé. res
est ce qui nous permet de renvoyer des réponses au client. La fonction next
est également transmise aux gestionnaires.
Avec Mongoose, nous avons vu comment nous pouvons nous connecter à la base de données avec deux méthodes - une manière primitive et une manière plus avancée/pratique qui emprunte au modèle d'usine. Nous finirons par l'utiliser lorsque nous discuterons des tests unitaires et d'intégration avec Jest (et des tests de mutation) car cela nous permettra de créer une instance de test de la base de données remplie de données de départ sur lesquelles nous pouvons exécuter des assertions.
Après cela, nous avons créé un objet de schéma Mongoose et l'avons utilisé pour créer un modèle, puis nous avons appris comment appeler le constructeur de ce modèle pour en créer une nouvelle instance. Disponible sur l'instance est une méthode de save
(entre autres), qui est de nature asynchrone, et qui vérifiera que la structure d'objet que nous avons passée est conforme au schéma, en résolvant la promesse si c'est le cas, et en rejetant la promesse avec une ValidationError
si ce ne est pas. En cas de résolution, le nouveau document est enregistré dans la base de données et nous répondons avec un HTTP 200 OK/201 CREATED, sinon, nous attrapons l'erreur générée dans notre point de terminaison et renvoyons une HTTP 400 Bad Request au client.
Au fur et à mesure que nous continuons à développer nos points de terminaison, vous en apprendrez plus sur certaines des méthodes disponibles sur le modèle et l'instance de modèle.
Finition de nos terminaux
Après avoir terminé le POST Endpoint, gérons GET. Comme je l'ai mentionné précédemment, la syntaxe :id
à l'intérieur de la route permet à Express de savoir que id
est un paramètre de route, accessible depuis req.params
. Vous avez déjà vu que lorsque vous correspondiez à un identifiant pour le paramètre "wildcard" dans la route, il était imprimé à l'écran dans les premiers exemples. Par exemple, si vous avez fait une requête GET à "/books/test-id-123", alors req.params.id
serait la chaîne test-id-123
car le nom du paramètre était id
en ayant la route comme HTTP GET /books/:id
.
Donc, tout ce que nous avons à faire est de récupérer cet ID à partir de l'objet req
et de vérifier si un document de notre base de données a le même ID - ce qui est rendu très facile par Mongoose (et le pilote natif).
app.get('/books/:id', async (req, res) => { const book = await Book.findById(req.params.id); console.log(book); res.send({ book }); });
Vous pouvez voir qu'accessible sur notre modèle est une fonction que nous pouvons appeler qui trouvera un document par son ID. Dans les coulisses, Mongoose convertira tout ID que nous transmettrons à findById
au type du champ _id
sur le document, ou dans ce cas, un ObjectId
. Si un ID correspondant est trouvé (et qu'un seul sera jamais trouvé car ObjectId
a une probabilité de collision extrêmement faible), ce document sera placé dans notre variable constante de book
. Sinon, book
sera nul — un fait que nous utiliserons dans un futur proche.
Pour l'instant, redémarrons le serveur (vous devez redémarrer le serveur sauf si vous utilisez nodemon
) et assurez-vous que nous avons toujours le document d'un livre d'avant dans la collection de Books
. Allez-y et copiez l'ID de ce document, la partie en surbrillance de l'image ci-dessous :
Et utilisez-le pour faire une requête GET à /books/:id
avec Postman comme suit (notez que les données du corps sont juste laissées par ma requête POST précédente. Elles ne sont pas réellement utilisées malgré le fait qu'elles soient représentées dans l'image ci-dessous) :
Ce faisant, vous devriez récupérer le document du livre avec l'ID spécifié dans la section de réponse du facteur. Notez qu'auparavant, avec la POST Route, qui est conçue pour "POST" ou "push" de nouvelles ressources sur le serveur, nous avons répondu avec un 201 Created - parce qu'une nouvelle ressource (ou document) a été créée. Dans le cas de GET, rien de nouveau n'a été créé - nous avons juste demandé une ressource avec un ID spécifique, donc un code de statut 200 OK est ce que nous avons reçu, au lieu de 201 Créé.
Comme c'est courant dans le domaine du développement de logiciels, les cas extrêmes doivent être pris en compte - les entrées de l'utilisateur sont intrinsèquement dangereuses et erronées, et c'est notre travail, en tant que développeurs, d'être flexibles quant aux types d'entrées que nous pouvons recevoir et d'y répondre. par conséquent. Que faisons-nous si l'utilisateur (ou l'appelant de l'API) nous transmet un ID qui ne peut pas être converti en un ObjectID MongoDB, ou un ID qui peut être converti mais qui n'existe pas ?
Pour le premier cas, Mongoose va lancer une CastError
- ce qui est compréhensible car si nous fournissons un ID comme math-is-fun
, alors ce n'est évidemment pas quelque chose qui peut être converti en ObjectID, et la conversion en ObjectID est spécifiquement ce que Mongoose s'en sort sous le capot.
Dans ce dernier cas, nous pourrions facilement corriger le problème via une vérification nulle ou une clause de garde. Quoi qu'il en soit, je vais renvoyer une réponse HTTP 404 Not Found. Je vais vous montrer quelques façons de le faire, une mauvaise et une meilleure.
Premièrement, nous pourrions faire ce qui suit :
app.get('/books/:id', async (req, res) => { try { const book = await Book.findById(req.params.id); if (!book) throw new Error(); return res.send({ book }); } catch (e) { return res.status(404).send({ error: 'Not Found' }); } });
Cela fonctionne et nous pouvons très bien l'utiliser. Je m'attends à ce que l'instruction await Book.findById()
lève une Mongoose CastError
si la chaîne d'ID ne peut pas être convertie en ObjectID, provoquant l'exécution du bloc catch
. S'il peut être transtypé mais que l'ObjectID correspondant n'existe pas, alors book
sera null
et la vérification Null générera une erreur, déclenchant à nouveau le bloc catch
. À l'intérieur de la catch
, nous venons de retourner un 404. Il y a deux problèmes ici. Premièrement, même si le livre est trouvé mais qu'une autre erreur inconnue se produit, nous renvoyons un 404 alors que nous devrions probablement donner au client un fourre-tout générique 500. Deuxièmement, nous ne différencions pas vraiment si l'ID envoyé est valide mais inexistant, ou s'il s'agit simplement d'une mauvaise pièce d'identité.
Alors, voici une autre façon:
const mongoose = require('mongoose'); app.get('/books/:id', async (req, res) => { try { const book = await Book.findById(req.params.id); if (!book) return res.status(404).send({ error: 'Not Found' }); return res.send({ book }); } catch (e) { if (e instanceof mongoose.Error.CastError) { return res.status(400).send({ error: 'Not a valid ID' }); } else { return res.status(500).send({ error: 'Internal Error' }); } } });
La bonne chose à ce sujet est que nous pouvons gérer les trois cas d'un 400, d'un 404 et d'un 500 générique. Notez qu'après la vérification nulle sur book
, j'utilise le mot-clé return
dans ma réponse. Ceci est très important car nous voulons nous assurer que nous quittons le gestionnaire de route à cet endroit.
Certaines autres options pourraient être pour nous de vérifier si l' id
sur req.params
peut être transtypé explicitement en ObjectID au lieu de permettre à Mongoose de transtyper implicitement avec mongoose.Types.ObjectId.isValid('id);
, mais il existe un cas limite avec des chaînes de 12 octets qui fait que cela fonctionne parfois de manière inattendue.
Nous pourrions rendre cette répétition moins douloureuse avec Boom
, une bibliothèque de réponse HTTP, par exemple, ou nous pourrions utiliser le middleware de gestion des erreurs. Nous pourrions également transformer les erreurs de Mongoose en quelque chose de plus lisible avec Mongoose Hooks/Middleware comme décrit ici. Une option supplémentaire serait de définir des objets d'erreur personnalisés et d'utiliser l'intergiciel global de gestion des erreurs express, cependant, je vais enregistrer cela pour un article à venir dans lequel nous discuterons de meilleures méthodes architecturales.
Dans le point de terminaison pour PATCH /books/:id
, nous nous attendons à ce qu'un objet de mise à jour soit transmis contenant des mises à jour pour le livre en question. Pour cet article, nous autoriserons la mise à jour de tous les champs, mais à l'avenir, je montrerai comment interdire les mises à jour de champs particuliers. De plus, vous verrez que la logique de gestion des erreurs dans notre point de terminaison PATCH sera la même que notre point de terminaison GET. C'est une indication que nous violons les principes DRY, mais encore une fois, nous y reviendrons plus tard.
Je vais m'attendre à ce que toutes les mises à jour soient disponibles sur l'objet de updates
à jour de req.body
(ce qui signifie que le client enverra JSON contenant un objet de updates
) et utilisera la fonction Book.findByAndUpdate
avec un indicateur spécial pour effectuer la mise à jour.
app.patch('/books/:id', async (req, res) => { const { id } = req.params; const { updates } = req.body; try { const updatedBook = await Book.findByIdAndUpdate(id, updates, { runValidators: true, new: true }); if (!updatedBook) return res.status(404).send({ error: 'Not Found' }); return res.send({ book: updatedBook }); } catch (e) { if (e instanceof mongoose.Error.CastError) { return res.status(400).send({ error: 'Not a valid ID' }); } else { return res.status(500).send({ error: 'Internal Error' }); } } });
Remarquez quelques choses ici. Nous déstructurons d'abord l' id
de req.params
et updates
à jour de req.body
.
Disponible sur le modèle Book
est une fonction du nom de findByIdAndUpdate
qui prend l'ID du document en question, les mises à jour à effectuer et un objet optionnel options. Normalement, Mongoose ne ré-effectuera pas la validation pour les opérations de mise à jour, donc l' runValidators: true
que nous transmettons car l'objet options
l'oblige à le faire. De plus, à partir de Mongoose 4, Model.findByIdAndUpdate
ne renvoie plus le document modifié mais renvoie le document d'origine à la place. L'indicateur new: true
(qui est false par défaut) remplace ce comportement.
Enfin, nous pouvons créer notre point de terminaison DELETE, qui est assez similaire à tous les autres :
app.delete('/books/:id', async (req, res) => { try { const deletedBook = await Book.findByIdAndDelete(req.params.id); if (!deletedBook) return res.status(404).send({ error: 'Not Found' }); return res.send({ book: deletedBook }); } catch (e) { if (e instanceof mongoose.Error.CastError) { return res.status(400).send({ error: 'Not a valid ID' }); } else { return res.status(500).send({ error: 'Internal Error' }); } } });
Avec cela, notre API primitive est complète et vous pouvez la tester en faisant des requêtes HTTP à tous les points de terminaison.
Un court avertissement sur l'architecture et comment nous allons y remédier
D'un point de vue architectural, le code que nous avons ici est assez mauvais, c'est désordonné, ce n'est pas DRY, ce n'est pas SOLIDE, en fait, vous pourriez même l'appeler odieux. Ces soi-disant "gestionnaires d'itinéraires" font bien plus que simplement "gérer les itinéraires" - ils interagissent directement avec notre base de données. Cela signifie qu'il n'y a absolument aucune abstraction.
Avouons-le, la plupart des applications ne seront jamais aussi petites ou vous pourriez probablement vous en tirer avec des architectures sans serveur avec la base de données Firebase. Peut-être que, comme nous le verrons plus tard, les utilisateurs veulent pouvoir télécharger des avatars, des citations et des extraits de leurs livres, etc. Peut-être voulons-nous ajouter une fonctionnalité de chat en direct entre les utilisateurs avec WebSockets, et allons même jusqu'à dire que nous ouvrirons notre application pour permettre aux utilisateurs d'emprunter des livres les uns avec les autres pour une somme modique - à ce stade, nous devons envisager l'intégration des paiements avec l'API Stripe et la logistique d'expédition avec l'API Shippo.
Supposons que nous continuons avec notre architecture actuelle et ajoutons toutes ces fonctionnalités. Ces gestionnaires de route, également connus sous le nom d'actions de contrôleur, vont finir par être très, très volumineux avec une complexité cyclomatique élevée. Un tel style de codage pourrait bien nous convenir au début, mais que se passe-t-il si nous décidons que nos données sont référentielles et que PostgreSQL est donc un meilleur choix de base de données que MongoDB ? Nous devons maintenant refactoriser l'ensemble de notre application, supprimer Mongoose, modifier nos contrôleurs, etc., ce qui pourrait entraîner des bogues potentiels dans le reste de la logique métier. Un autre exemple serait celui de décider qu'AWS S3 est trop cher et que nous souhaitons migrer vers GCP. Encore une fois, cela nécessite un refactor à l'échelle de l'application.
Bien qu'il existe de nombreuses opinions sur l'architecture, de la conception pilotée par le domaine, la séparation des responsabilités des requêtes de commande et l'approvisionnement en événements, au développement piloté par les tests, SOILD, l'architecture en couches, l'architecture Onion, etc., nous nous concentrerons sur la mise en œuvre d'une architecture en couches simple dans les futurs articles, composés de contrôleurs, de services et de référentiels, et utilisant des modèles de conception tels que la composition, les adaptateurs / wrappers et l'inversion de contrôle via l'injection de dépendance. Bien que, dans une certaine mesure, cela puisse être réalisé avec JavaScript, nous examinerons également les options TypeScript pour réaliser cette architecture, nous permettant d'utiliser des paradigmes de programmation fonctionnels tels que les deux monades en plus des concepts OOP tels que les génériques.
Pour l'instant, il y a deux petits changements que nous pouvons faire. Étant donné que notre logique de gestion des erreurs est assez similaire dans le bloc catch
de tous les points de terminaison, nous pouvons l'extraire vers une fonction personnalisée Express Error Handling Middleware à la toute fin de la pile.
Nettoyer notre architecture
À l'heure actuelle, nous répétons une très grande quantité de logique de gestion des erreurs sur tous nos terminaux. Au lieu de cela, nous pouvons créer une fonction Express Error Handling Middleware, qui est une fonction Express Middleware qui est appelée avec une erreur, les objets req et res et la fonction suivante.
Pour l'instant, construisons cette fonction middleware. Tout ce que je vais faire, c'est répéter la même logique de gestion des erreurs à laquelle nous sommes habitués :
app.use((err, req, res, next) => { if (err instanceof mongoose.Error.ValidationError) { return res.status(400).send({ error: 'Validation Error' }); } else if (err instanceof mongoose.Error.CastError) { return res.status(400).send({ error: 'Not a valid ID' }); } else { console.log(err); // Unexpected, so worth logging. return res.status(500).send({ error: 'Internal error' }); } });
Cela ne semble pas fonctionner avec les erreurs de mangouste, mais en général, plutôt que d'utiliser if/else if/else
pour déterminer les instances d'erreur, vous pouvez basculer le constructeur de l'erreur. Je vais laisser ce que nous avons, cependant.
Dans un point de terminaison/gestionnaire de routage synchrone , si vous générez une erreur, Express la détectera et la traitera sans aucun travail supplémentaire de votre part. Malheureusement, ce n'est pas le cas pour nous. Nous avons affaire à du code asynchrone . Afin de déléguer la gestion des erreurs à Express avec des gestionnaires de route asynchrones, nous interceptons l'erreur nous-mêmes et la transmettons à next()
.
Donc, je vais juste permettre à next
d'être le troisième argument dans le point de terminaison, et je vais supprimer la logique de gestion des erreurs dans les blocs catch
en faveur du simple passage de l'instance d'erreur à next
, en tant que tel :
app.post('/books', async (req, res, next) => { try { const book = new Book(req.body.book); await book.save(); return res.send({ book }); } catch (e) { next(e) } });
Si vous faites cela pour tous les gestionnaires de route, vous devriez vous retrouver avec le code suivant :
const express = require('express'); const mongoose = require('mongoose'); // Database connection and model. require('./db/mongoose.js')(); const Book = require('./models/book.js'); // This creates our Express App. const app = express(); // Define middleware. app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Listening on port 3000 (arbitrary). // Not a TCP or UDP well-known port. // Does not require superuser privileges. const PORT = 3000; // We will build our API here. // HTTP POST /books app.post('/books', async (req, res, next) => { try { const book = new Book(req.body.book); await book.save(); return res.status(201).send({ book }); } catch (e) { next(e) } }); // HTTP GET /books/:id app.get('/books/:id', async (req, res) => { try { const book = await Book.findById(req.params.id); if (!book) return res.status(404).send({ error: 'Not Found' }); return res.send({ book }); } catch (e) { next(e); } }); // HTTP PATCH /books/:id app.patch('/books/:id', async (req, res, next) => { const { id } = req.params; const { updates } = req.body; try { const updatedBook = await Book.findByIdAndUpdate(id, updates, { runValidators: true, new: true }); if (!updatedBook) return res.status(404).send({ error: 'Not Found' }); return res.send({ book: updatedBook }); } catch (e) { next(e); } }); // HTTP DELETE /books/:id app.delete('/books/:id', async (req, res, next) => { try { const deletedBook = await Book.findByIdAndDelete(req.params.id); if (!deletedBook) return res.status(404).send({ error: 'Not Found' }); return res.send({ book: deletedBook }); } catch (e) { next(e); } }); // Notice - bottom of stack. app.use((err, req, res, next) => { if (err instanceof mongoose.Error.ValidationError) { return res.status(400).send({ error: 'Validation Error' }); } else if (err instanceof mongoose.Error.CastError) { return res.status(400).send({ error: 'Not a valid ID' }); } else { console.log(err); // Unexpected, so worth logging. return res.status(500).send({ error: 'Internal error' }); } }); // Binding our application to port 3000. app.listen(PORT, () => console.log(`Server is up on port ${PORT}.`));
Pour aller plus loin, il vaudrait la peine de séparer notre middleware de gestion des erreurs dans un autre fichier, mais c'est trivial, et nous le verrons dans les prochains articles de cette série. De plus, nous pourrions utiliser un module NPM nommé express-async-errors
pour nous permettre de ne pas avoir à appeler ensuite dans le bloc catch, mais encore une fois, j'essaie de vous montrer comment les choses se font officiellement.
Un mot sur CORS et la politique de même origine
Supposons que votre site Web soit servi à partir du domaine myWebsite.com
mais que votre serveur se trouve sur myOtherDomain.com/api
. CORS signifie Cross-Origin Resource Sharing et est un mécanisme par lequel les requêtes inter-domaines peuvent être effectuées. Dans le cas ci-dessus, étant donné que le serveur et le code JS frontal se trouvent dans des domaines différents, vous feriez une demande sur deux origines différentes, ce qui est généralement limité par le navigateur pour des raisons de sécurité et atténué en fournissant des en-têtes HTTP spécifiques.
La politique de même origine est ce qui applique les restrictions susmentionnées - un navigateur Web n'autorisera que les requêtes à effectuer sur la même origine.
Nous aborderons CORS et SOP plus tard lorsque nous créerons une interface Webpack groupée pour notre API Book avec React.
Conclusion et suite
Nous avons beaucoup discuté dans cet article. Peut-être que tout n'était pas entièrement pratique, mais j'espère que cela vous a rendu plus à l'aise avec les fonctionnalités JavaScript Express et ES6. Si vous débutez dans la programmation et que Node est la première voie dans laquelle vous vous embarquez, j'espère que les références aux langages de type statique comme Java, C++ et C# ont aidé à mettre en évidence certaines des différences entre JavaScript et ses homologues statiques.
Next time, we'll finish building out our Book API by making some fixes to our current setup with regards to the Book Routes, as well as adding in User Authentication so that users can own books. We'll do all of this with a similar architecture to what I described here and with MongoDB for data persistence. Finally, we'll permit users to upload avatar images to AWS S3 via Buffers.
In the article thereafter, we'll be rebuilding our application from the ground up in TypeScript, still with Express. We'll also move to PostgreSQL with Knex instead of MongoDB with Mongoose as to depict better architectural practices. Finally, we'll update our avatar image uploading process to use Node Streams (we'll discuss Writable, Readable, Duplex, and Transform Streams). Along the way, we'll cover a great amount of design and architectural patterns and functional paradigms, including:
- Controllers/Controller Actions
- Prestations de service
- Repositories
- Data Mapping
- The Adapter Pattern
- The Factory Pattern
- The Delegation Pattern
- OOP Principles and Composition vs Inheritance
- Inversion of Control via Dependency Injection
- Principes SOLIDES
- Coding against interfaces
- Data Transfer Objects
- Domain Models and Domain Entities
- Either Monads
- Validation
- Decorators
- Logging and Logging Levels
- Unit Tests, Integration Tests (E2E), and Mutation Tests
- The Structured Query Language
- Relations
- HTTP/Express Security Best Practices
- Node Best Practices
- OWASP Security Best Practices
- Et plus.
Using that new architecture, in the article after that, we'll write Unit, Integration, and Mutation tests, aiming for close to 100 percent testing coverage, and we'll finally discuss setting up a remote CI/CD pipeline with CircleCI, as well as Message Busses, Job/Task Scheduling, and load balancing/reverse proxying.
Hopefully, this article has been helpful, and if you have any queries or concerns, let me know in the comments below.