Fermer

septembre 1, 2020

Création de microservices avec Deno, Reno et PostgreSQL


Dans ce didacticiel, nous vous montrons comment créer des microservices avec Deno et vous présentons Reno – une bibliothèque de routage légère pour Deno. Nous allons explorer comment nous pouvons utiliser cette nouvelle plate-forme JavaScript pour créer un microservice qui expose les points de terminaison pour agir sur une base de données.

Deno est un moteur d'exécution JavaScript et TypeScript du créateur de Node.js Ryan Dahl qui vise pour remédier à certaines des lacunes de cette dernière technologie, telles que la simplification de l'algorithme de recherche de chemin de module et l'alignement plus étroit des API de base avec leurs équivalents basés sur le navigateur. Malgré ces différences fondamentales, les applications potentielles de Deno et Node.js sont pour la plupart identiques. L'une des principales forces de Node réside dans la création de services HTTP, et il en va de même pour Deno.

Ecriture de serveurs HTTP avec std / http

Avant d'introduire une bibliothèque de routage ou d'envisager notre couche d'accès aux données, il serait utile de prendre du recul et de construire un simple serveur HTTP avec le module std / http qui fait partie de la bibliothèque standard de Deno . Si vous ne l’avez pas déjà fait, installez Deno. Dans un système d'exploitation de type Unix, vous pouvez exécuter:

 $ curl -fsSL https://deno.land/x/install/install.sh | sh -s v1.3.0

Notez que ce tutoriel a été développé avec la version 1.3.0 (et std 0.65.0 comme nous le verrons plus tard), mais toutes les versions 1.x ultérieures que vous utiliserez devraient être compatibles. Si vous utilisez une ancienne version de Deno, vous pouvez également passer à la version 1.3.0 avec la commande deno upgrade :

 deno upgrade --version 1.3.0

Vous pouvez vérifier que la version Deno attendue a été installée avec deno --version .

Nous sommes maintenant en mesure de créer un serveur HTTP. Créez un répertoire, dans votre répertoire de développement habituel, nommé deno-hello-http et ouvrez-le dans votre éditeur. Ensuite, créez un fichier appelé server.ts et utilisez la fonction listenAndServe dans std / http pour construire notre serveur:

 import {listenAndServe} from "https://deno.land/std@0.65.0/http/mod.ts";

const BINDING = ": 8000";

console.log (`Écoute sur $ {BINDING} ...`);

attendre listenAndServe (BINDING, (req) => {
  req.respond ({body: "Hello world!"});
});

Developer Experience Protips

Si vous utilisez VS Code, je vous recommande vivement l ' extension officielle Deno qui prend en charge l'algorithme de résolution de chemin de Deno. De plus, vous pouvez exécuter deno cache server.ts pour installer les dépendances et leurs définitions TypeScript, cette dernière servant de guide API inestimable lors de l'écriture de votre code.

Nous pouvons démarrer notre serveur en exécutant ] deno lance --allow-net server.ts dans notre shell. Notez l'indicateur d'autorisations - allow-net accordant à notre programme un accès réseau. Une fois à l'écoute sur le port 8000 nous pouvons le cibler avec une requête HTTP:

 $ curl -v http: // localhost: 8000 /; écho

> GET / HTTP / 1.1
> Hôte: localhost: 8000
> Agent utilisateur: curl / 7.58.0
> Accepter: * / *
>
<HTTP / 1.1 200 OK
<longueur du contenu: 12
<

Bonjour le monde!

Génial! Avec quelques lignes de TypeScript, nous avons pu implémenter un serveur simple. Cela dit, il n'est pas particulièrement bien présenté à ce stade. Étant donné que nous servons systématiquement "Hello world!" à partir de notre fonction de rappel, la même réponse sera renvoyée pour tout point de terminaison ou méthode HTTP. Si nous rencontrons un serveur avec POST / add nous recevrons les mêmes en-têtes et corps:

 $ curl -v -d '{}' http: // localhost: 8000 / add; écho

> POST / ajouter HTTP / 1.1
> Hôte: localhost: 8000
> User-Agent: curl / 7.58.0
> Accepter: * / *
> Contenu-Longueur: 2
> Content-Type: application / x-www-form-urlencoded
>
<HTTP / 1.1 200 OK
<longueur du contenu: 12
<

Bonjour le monde!

Nous pouvons limiter la réponse existante à GET / en vérifiant conditionnellement les propriétés url et method du paramètre req de notre callback:

 import {
  ListenAndServe,
  ServerRequest,
} de "https://deno.land/std@0.65.0/http/mod.ts";

const BINDING = ": 8000";

console.log (`Écoute sur $ {BINDING} ...`);

function notFound ({method, url}: ServerRequest) {
  revenir {
    statut: 404,
    body: `Aucune route trouvée pour $ {method} $ {url}`,
  };
}

attendre listenAndServe (BINDING, (req) => {
  const res = req.method === "GET" && req.url === "/"
    ? {body: "Hello world"}
    : notFound (req);

  req. respond (res);
});

Si nous redémarrons notre serveur, nous devrions observer que GET / fonctionne comme prévu, mais toute autre URL ou méthode entraînera un HTTP 404:

 $ curl -v -d '{} 'http: // localhost: 8000 / add; écho

> POST / ajouter HTTP / 1.1
> Hôte: localhost: 8000
> Agent utilisateur: curl / 7.58.0
> Accepter: * / *
> Contenu-Longueur: 2
> Content-Type: application / x-www-form-urlencoded
>
<HTTP / 1.1 404 introuvable
<longueur du contenu: 28
<

Aucune route trouvée pour POST / ajouter

std / http Beyond Simple Services

L'amorçage de serveurs HTTP triviaux avec Deno et std / http s'est avéré relativement simple. Comment cette approche s’adapte à des services plus complexes?

Prenons un point de terminaison / messages qui accepte et renvoie les messages soumis par l’utilisateur. En suivant une approche RESTful, nous pouvons définir le comportement de cet endpoint et de notre service dans son ensemble:

  • / messages
  • GET : retourne un tableau sérialisé JSON de tous les messages stockés dans la mémoire du serveur
  • POST : ajoute un nouveau message au tableau en mémoire
  • Toutes les autres méthodes renverront HTTP 405 (méthode non autorisée)
  • Toutes les autres URL renverront HTTP 404 (non trouvé) [19659031] Mettons à jour notre module server.ts existant afin qu'il soit conforme à notre nouvelle spécification de service:

     import {
      ListenAndServe,
      ServerRequest,
    } de "https://deno.land/std@0.65.0/http/mod.ts";
    
    interface MessagePayload {
      message: chaîne;
    }
    
    const BINDING = ": 8000";
    
    décodeur const = nouveau TextDecoder ();
    messages const: string [] = [];
    
    function jsonResponse  (corps: TBody, statut = 200) {
      revenir {
        statut,
        headers: nouveaux en-têtes ({
          "Content-Type": "application / json",
        }),
        corps: JSON.stringify (corps),
      };
    }
    
    function textResponse (corps: chaîne, état = 200) {
      revenir {
        statut,
        headers: nouveaux en-têtes ({
          "Content-Type": "text / plain",
        }),
        corps,
      };
    }
    
    fonction asynchrone addMessage ({body}: ServerRequest) {
      const {message}: MessagePayload = JSON.parse (
        decoder.decode (attendre Deno.readAll (corps)),
      );
    
      messages.push (message);
    
      return jsonResponse ({success: true}, 201);
    }
    
    function getMessages () {
      return jsonResponse (messages);
    }
    
    function methodNotAllowed ({method, url}: ServerRequest) {
      return textResponse (
        `Méthode $ {method} non autorisée pour la ressource $ {url}`,
        405,
      );
    }
    
    function notFound ({url}: ServerRequest) {
      return textResponse (`Aucune ressource trouvée pour $ {url}`, 404);
    }
    
    function internalServerError ({message}: Erreur) {
      return textResponse (message, 500);
    }
    
    console.log (`Écoute sur $ {BINDING} ...`);
    
    attendre listenAndServe (BINDING, async (req) => {
      let res = notFound (req);
    
      essayez {
        if (req.url === "/ messages") {
          commutateur (req.method) {
            cas "POST":
              res = attendre addMessage (req);
              Pause;
            case "GET":
              res = getMessages ();
              Pause;
            défaut:
              res = methodNotAllowed (req);
          }
        }
      } catch (e) {
        res = internalServerError (e);
      }
    
      req. respond (res);
    });
    

    Redémarrez le serveur et vérifiez que GET / messages renvoie une réponse application / json avec un tableau JSON vide comme corps. Nous pouvons alors tester que l'ajout d'un message fonctionne en faisant une requête POST à / messages avec une charge utile valide et en récupérant ensuite les messages:

     $ curl -v -H "Contenu -Type: application / json "-d '{" message ":" Bonjour! " } 'http: // localhost: 8000 / messages; écho
    <HTTP / 1.1 201 Créé
    <longueur du contenu: 16
    <content-type: application / json
    <
    
    {"succès": vrai}
    
    $ curl -v http: // localhost: 8000 / messages; écho
    <HTTP / 1.1 200 OK
    <longueur du contenu: 10
    <content-type: application / json
    <
    
    ["Hello!"]
    

    Déclarer des routes avec Reno

    Étant donné que notre service ne fournit qu'un seul point de terminaison, le code reste assez discret. Cependant, s'il devait s'étendre sur de nombreux points de terminaison, alors notre code de gestion d'itinéraire deviendrait bientôt ingérable:

     if (req.url === "/ messages") {
      commutateur (req.method) {
        cas "POST":
          res = attendre addMessage (req);
          Pause;
        case "GET":
          // Paramètres d'itinéraire, par exemple / messages / ade25ef
          const [, id] = req.url.match (/ ^  / messages  / ([a-z0-9] *) $ /) || [];
          res = id? getMessage (id): getMessages ();
          Pause;
        défaut:
          res = methodNotAllowed (req);
      }
    } else if (req.url === "/ topics") {
      commutateur (req.method) {
        case "GET":
          res = getTopics ();
          Pause;
        défaut:
          res = methodNotAllowed (req);
      }
    } else if (req.url === "/ utilisateurs") {
      // ...etc
    }
    

    Nous pourrions certainement structurer ce code pour le rendre plus déclaratif, comme définir une Map de fonctions de gestionnaire de route qui correspondent à un chemin particulier, mais nous devrions néanmoins gérer nous-mêmes l'implémentation du routage, en étendant pour la recherche d'itinéraire, l'analyse des paramètres de chemin et de requête, et les itinéraires imbriqués. Même avec le code le plus bien structuré, c'est tout à fait la tâche, et dans un contexte commercial prendrait un temps de développement précieux.

    Au cours de la dernière année, j'ai travaillé sur Reno un routage bibliothèque pour le serveur de std / http qui gère et résume une grande partie de cette complexité, nous permettant de nous concentrer sur la logique de base de nos applications. À l'aide des fonctions d'accompagnement fournies par le routeur, reconstruisons notre service de messagerie:

     import {
      ListenAndServe,
      ServerRequest,
    } de "https://deno.land/std@0.65.0/http/mod.ts";
    
    importer {
      createRouter,
      createRouteMap,
      forMethod,
      avecJsonBody,
      jsonResponse,
      textResponse,
      ProcessedDemande,
      Erreur non trouvée,
    } depuis "https://deno.land/x/reno@v1.3.0/reno/mod.ts";
    
    interface MessagePayload {
      message: chaîne;
    }
    
    const BINDING = ": 8000";
    
    messages const: string [] = [];
    
    fonction asynchrone addMessage (
      {body: {message}}: ProcessedRequest ,
    ) {
      messages.push (message);
      return jsonResponse ({success: true}, {}, 201);
    }
    
    function getMessages () {
      return jsonResponse (messages);
    }
    
    function notFound ({url}: ServerRequest) {
      return textResponse (`Aucune ressource trouvée pour $ {url}`, {}, 404);
    }
    
    function internalServerError ({message}: Erreur) {
      return textResponse (message, {}, 500);
    }
    
    const routes = createRouteMap ([
      [
        "/messages",
        forMethod([
          ["GET", getMessages],
          ["POST"withJsonBody (addMessage)],
        ]),
      ],
    ]);
    
    const router = createRouter (routes);
    
    console.log (`Écoute sur $ {BINDING} ...`);
    
    attendre listenAndServe (BINDING, async (req) => {
      essayez {
        req.respond (attendre le routeur (req));
      } catch (e) {
        req. respond (
          L'instance de NotFoundError? notFound (req): internalServerError (e),
        );
      }
    });
    

    Si vous redémarrez le serveur et effectuez les mêmes requêtes GET et POST à / messages nous remarquerons que la fonctionnalité de base reste intacte. Pour rappeler la complexité gérée par Reno, voici à quoi ressemblerait l'exemple de plusieurs points de terminaison:

     const routes = createRouteMap ([
      [
        / ^  / messages  / ([a-z0-9] *) $ /,
        forMethod ([
          ["GET", ({ routeParams: [id]}) => id? getMessage (id): getMessages],
          ["POST"withJsonBody (addMessage)],
        ]),
      ],
      ["/topics", getTopics],
      ["/users", getUsers],
    ]);
    

    Puisque Reno fournit une analyse de chemin intégrée et une gestion des méthodes HTTP hors de la boîte, parmi ses autres fonctionnalités, nous devons seulement nous préoccuper de la déclaration de nos points de terminaison et de la logique pour répondre à la

    Un principe fondamental de Reno qui mérite d'être souligné est qu'il se présente comme un router-as-a-function . Autrement dit, const response = await router (request) . Contrairement aux cadres de serveurs à part entière qui prennent souvent la charge d'amorcer le serveur HTTP et de gérer son cycle de vie, Reno ne se préoccupe que du routage des requêtes, ce qu'il réalise avec un appel de fonction autonome; cela facilite son adoption ainsi que son intégration avec les services Deno existants.

    Construction de microservices avec Reno

    Compte tenu de la petite API de Reno, il est bien adapté au développement de microservices . Dans ce cas, nous allons créer un microservice de publication de blog avec Deno et Reno, soutenu par une base de données PostgreSQL (nous utiliserons le brillant deno-postgres pour interroger notre base de données depuis Deno). Notre service exposera un seul point de terminaison / posts qui prend en charge un certain nombre d'opérations:

    • GET / posts : récupère les métadonnées pour tous les messages de la base de données
    • GET / posts / [19659050]: récupère les métadonnées et le contenu du message avec l'UUID donné
    • POST / posts : ajoute un nouveau message à la base de données
    • PATCH / posts / : remplace le contenu du message par l'UUID donné

    Construire un microservice à part entière peut sembler une tâche ardue pour un seul didacticiel, mais j'ai pris la courtoisie de fournir un passe-partout substantiel contenant une configuration Docker Compose et une pré- scripts et requêtes de base de données écrits. Pour commencer, assurez-vous d'avoir installé Docker et Docker Compose puis [clonerle microservice du blog Reno vérifiant spécifiquement le sitepoint-passe-partout branche :

     $ git clone --branch sitepoint-passe-partout https://github.com/reno-router/blog-microservice.git
    

    Ouvrez le dossier blog-microservice avec l'éditeur de votre choix. Avant de mettre en œuvre notre première route, je vais discuter de certains des répertoires et fichiers clés à un niveau élevé:

    • data : contient des scripts SQL qui s'exécuteront lors de la création du conteneur de base de données, définissant les tables de notre application et en les remplissant avec quelques données de départ.
    • service / blog_service.ts : fournit des méthodes pour récupérer, créer et mettre à jour les messages stockés dans la base de données.
    • service / db_service.ts : une base de données générique abstraction qui se trouve au-dessus de deno-postgres, gérant gratuitement le pool de connexions et les transactions.
    • service / queries.ts : requêtes Postgres prédéfinies pour nos diverses opérations de base de données; le service de blog les transmet au service DB et transmet les résultats dans un format consommable à l'appelant. Ces requêtes sont paramétrées les valeurs desquelles deno-postgres santise automatiquement.
    • service / server.ts : le point d'entrée de notre serveur.
    • deps.ts : un module centralisé contenant toutes les dépendances externes, leur permettant d'être maintenues en un seul point. Cette pratique est courante dans tous les projets Deno et est approuvée par le manuel officiel .
    • Dockerfile : déclare notre conteneur Docker de production qui installera les dépendances de notre projet au moment de la construction, réduisant considérablement le temps de démarrage à froid .
    • Dockerfile.local : déclare notre conteneur Docker de développement, en utilisant Denon pour redémarrer automatiquement Deno chaque fois que notre code source change.
    • docker-compose.yml : a Docker Compose configuration qui inclut à la fois notre conteneur de développement et un conteneur Postgres sur lequel nos scripts SQL sont exécutés, réduisant considérablement toutes les étapes préalables à l'exécution de notre projet.

    Créons les routes de notre application. Dans le dossier service créez un nouveau fichier nommé routes.ts . Remplissez-le avec ces importations, dont nous aurons besoin sous peu:

     import {
      createRouteMap,
      jsonResponse,
      forMethod,
      DBPool,
      uuidv4,
    } à partir de "../deps.ts";
    
    importer createBlogService depuis "./blog_service.ts";
    importer createDbService depuis "./db_service.ts";
    

    Ensuite, instancions notre pool de connexions à la base de données. Notez qu'en utilisant Object.fromEntries nous pouvons construire l'objet d'options requis par deno-postgres de manière relativement succincte:

     function createClientOpts () {
      renvoie Object.fromEntries ([
        ["hostname", "POSTGRES_HOST"],
        ["user", "POSTGRES_USER"],
        ["password", "POSTGRES_PASSWORD"],
        ["database", "POSTGRES_DB"],
      ] .map (([key, envVar]) => [key, Deno.env.get(envVar)]));
    }
    
    function getPoolConnectionCount () {
      return Number.parseInt (Deno.env.get ("POSTGRES_POOL_CONNECTIONS") || "1", 10);
    }
    
    const dbPool = new DBPool (createClientOpts (), getPoolConnectionCount ());
    

    Avec notre pool de connexions instanciées, nous pouvons créer notre base de données et nos services de blog:

     const blogService = createBlogService (
      createDbService (dbPool),
      uuidv4.generate,
    );
    

    Écrivons maintenant un gestionnaire d'itinéraire pour récupérer tous les messages de la base de données:

     fonction async getPosts () {
      const res = attendre blogService.getPosts ();
      return jsonResponse (res);
    }
    

    Pour lier notre gestionnaire à GET / posts nous devons déclarer une carte d'itinéraire et l'exporter:

     const routes = createRouteMap ([
      ["/posts", forMethod([
        ["GET", getPosts],
      ])],
    ]);
    
    exporter les itinéraires par défaut;
    

    De bout en bout, routes.ts devrait ressembler à ceci:

     import {
      createRouteMap,
      jsonResponse,
      forMethod,
      DBPool,
      uuidv4,
    } à partir de "../deps.ts";
    
    importer createBlogService depuis "./blog_service.ts";
    importer createDbService depuis "./db_service.ts";
    
    function createClientOpts () {
      renvoie Object.fromEntries ([
        ["hostname", "POSTGRES_HOST"],
        ["user", "POSTGRES_USER"],
        ["password", "POSTGRES_PASSWORD"],
        ["database", "POSTGRES_DB"],
      ] .map (([key, envVar]) => [key, Deno.env.get(envVar)]));
    }
    
    function getPoolConnectionCount () {
      return Number.parseInt (Deno.env.get ("POSTGRES_POOL_CONNECTIONS") || "1", 10);
    }
    
    const dbPool = new DBPool (createClientOpts (), getPoolConnectionCount ());
    
    const blogService = createBlogService (
      createDbService (dbPool),
      uuidv4.generate,
    );
    
    fonction asynchrone getPosts () {
      const res = attendre blogService.getPosts ();
      return jsonResponse (res);
    }
    
    const routes = createRouteMap ([
      ["/posts", forMethod([
        ["GET", getPosts],
      ])],
    ]);
    
    exporter les itinéraires par défaut;
    

    Pour transmettre les requêtes à notre gestionnaire, nous devons mettre à jour le module server.ts existant. Ajoutez createRouter aux liaisons importées de deps.ts :

     import {
      ListenAndServe,
      ServerRequest,
      textResponse,
      createRouter,
    } à partir de "../deps.ts";
    

    Au-dessous de cette instruction, nous devrons importer nos routes:

     import routes from "./routes.ts";
    

    Pour créer le routeur de notre service, appelez la fonction createRouter ci-dessus le message d'écoute du serveur, en passant nos routes comme seul argument:

     const router = createRouter (routes);
    

    Enfin, pour transférer les requêtes entrantes vers notre routeur et renvoyer la réponse prévue, appelons le routeur dans le bloc try du rappel de notre serveur:

     try {
      const res = attendre le routeur (req);
      return req. respond (res);
    }
    

    Nous sommes désormais en mesure d'exécuter notre application, mais il reste une dernière étape. Nous devons renommer le fichier .env.sample en .env . Il a le suffixe .sample pour indiquer qu’il ne contient aucune valeur sensible réelle, mais pour commencer, nous pouvons néanmoins les utiliser textuellement:

     $ mv .env.sample .env
    

    Avec un rapide docker-compose up nous devrions voir la base de données et les conteneurs de service prendre vie, ces derniers écoutant finalement sur le port 8000:

     $ docker-compose up
    
    # [...]
    
    db_1 | 2020-08-16 22: 04: 50.314 UTC [1] LOG: le système de base de données est prêt à accepter les connexions
    # [...]
    api_1 | Écoute des demandes sur: 8000 ...
    

    Une fois lié à ce port, nous devrions vérifier que notre point de terminaison fonctionne. Il doit renvoyer l'ID, le titre et les balises de chaque article de la base de données, actuellement renseignés par les données de départ:

     # jq est comme sed pour les données JSON:
    # https://stedolan.github.io/jq/
    
    $ curl http: // localhost: 8000 / messages | jq
    
    [
      {
        "id": "006a8213-8aac-47e2-b728-b0e2c07ddaf6",
        "title": "Go's generics experimentation tool",
        "author": {
          "id": "c9e69690-9246-41bf-b912-0c6190f64f1f",
          "name": "Joe Bloggs"
        },
        "tags": [
          {
            "id": "f9076c31-69eb-45cf-b51c-d7a1b6e3fe0c",
            "name": "Go"
          }
        ]
      },
      {
        "id": "16f9d2b0-baf9-4618-a230-d9b95ab75fa8",
        "title": "Deno 1.3.0 est sorti",
        "auteur": {
          "id": "91ef4450-97ff-44da-8b1d-f1560e9d10cc",
          "nom": "James Wright"
        },
        "tags": [
          {
            "id": "21c1ac3a-9c1b-4be1-be50-001b44cf84d1",
            "name": "JavaScript"
          },
          {
            "id": "ac9c2f73-6f11-470f-b8a7-9930dbbf137a",
            "name": "TypeScript"
          },
          {
            "id": "c35defc4-42f1-43b9-a181-a8f12b8457f1",
            "name": "Deno"
          },
          {
            "id": "d7c2f180-18d6-423e-aeda-31c4a3a7ced1",
            "name": "Rust"
          }
        ]
      }
    ]
    

    Récupération du contenu d'un message

    La prochaine opération à mettre en œuvre est GET / posts / . Étant donné que nous traitons déjà GET / posts nous pouvons apporter un ensemble minimal de modifications pour récupérer les messages individuels par leur identifiant. Tout d'abord, modifions la liaison de chemin "/ posts" dans notre carte de routes pour introduire un segment de chemin générique:

     const routes = createRouteMap ([
      ["/posts/*", forMethod([
        ["GET", getPosts],
      ])],
    ]);
    

    En plus des expressions régulières, Reno autorise l’utilisation de chemins de chaîne avec des caractères génériques (‘*’) qui seront capturés et exposés via la propriété routeParams de la requête. Bien qu'elles ne soient pas aussi spécifiques que les expressions régulières, elles sont sans doute plus faciles à lire et sont principalement un moyen d'atteindre le même objectif. Mettons à jour le gestionnaire de route getPosts pour déterminer l'existence du paramètre de chemin et récupérer un article individuel du service de blog s'il est présent (le type AugmentedRequest peut être importé depuis deps .ts ):

     fonction asynchrone getPosts ({routeParams: [id]}: AugmentedRequest) {
      const res = wait (id? blogService.getPost (id): blogService.getPosts ());
      return jsonResponse (res);
    }
    

    Notez que routeParams est un tableau ordonné linéairement, chaque élément faisant référence au paramètre de chemin dans l'ordre dans lequel il est déclaré. Dans notre cas, nous pouvons ainsi vérifier que le premier élément fait toujours référence à un identifiant de publication. Après avoir enregistré nos modifications, Denon détectera les modifications et redémarrera Deno, et appeler GET / posts suivi de l'ID de l'un de nos messages devrait renvoyer ses métadonnées et contenus:

     $ curl http: // localhost: 8000 / posts / 16f9d2b0-baf9-4618-a230-d9b95ab75fa8 | jq
    
     {
      "id": "16f9d2b0-baf9-4618-a230-d9b95ab75fa8",
      "title": "Deno 1.3.0 est sorti",
      "contents": "Cette version inclut de nouveaux indicateurs pour diverses commandes Deno et implémente l'API FileReader du W3C, entre autres améliorations et correctifs.",
      "auteur": {
        "id": "91ef4450-97ff-44da-8b1d-f1560e9d10cc",
        "nom": "James Wright"
      },
      "tags": [
        {
          "id": "21c1ac3a-9c1b-4be1-be50-001b44cf84d1",
          "name": "JavaScript"
        },
        {
          "id": "ac9c2f73-6f11-470f-b8a7-9930dbbf137a",
          "name": "TypeScript"
        },
        {
          "id": "c35defc4-42f1-43b9-a181-a8f12b8457f1",
          "name": "Deno"
        },
        {
          "id": "d7c2f180-18d6-423e-aeda-31c4a3a7ced1",
          "name": "Rust"
        }
      ]
    }
    

    Traitement des messages inexistants

    L'extension de notre opération GET / posts pour récupérer un message individuel par son identifiant a entraîné un bug. Demandons le contenu d'un message pour un ID inexistant:

     $ curl -v http: // localhost: 8000 / posts / b801087e-f1c9-4b1e-9e0c-70405b685e86
    
    > GET / posts / b801087e-f1c9-4b1e-9e0c-70405b685e86 HTTP / 1.1
    > Hôte: localhost: 8000
    > User-Agent: curl / 7.54.0
    > Accepter: * / *
    >
    <HTTP / 1.1 200 OK
    <longueur du contenu: 0
    <content-type: application / json
    <
    

    Puisque blogService.getPost (id) renvoie undefined lorsqu'un message avec l'ID donné ne peut pas être trouvé, notre gestionnaire actuel donne une réponse HTTP 200 avec un corps vide . Il serait préférable de signaler cette erreur au demandeur. Pour que la fonction getPosts reste lisible, levons l'appel blogService.getPost (id) dans sa propre fonction, dans laquelle nous lancerons une erreur si le message récupéré est undefined . Le type BlogService peut être importé depuis blog_service.ts :

     fonction asynchrone getPost (blogService: BlogService, id: string) {
      const res = attendre blogService.getPost (id);
    
      if (! res) {
        throw new Error (`Message introuvable avec l'ID $ {id}`);
      }
    
      return res;
    }
    
    fonction asynchrone getPosts ({routeParams: [id]}: AugmentedRequest) {
      const res = wait (id? getPost (blogService, id): blogService.getPosts ());
      return jsonResponse (res);
    }
    

    Si nous demandons maintenant un message qui n'existe pas, nous recevrons une réponse d'erreur:

     $ curl -v http: // localhost: 8000 / posts / b801087e-f1c9-4b1e-9e0c- 70405b685e86; écho
    
    > GET / posts / b801087e-f1c9-4b1e-9e0c-70405b685e86 HTTP / 1.1
    > Hôte: localhost: 8000
    > Agent utilisateur: curl / 7.54.0
    > Accepter: * / *
    >
    <Erreur de serveur interne HTTP / 1.1 500
    <longueur du contenu: 59
    <content-type: text / plain
    <
    
    Post non trouvé avec l'ID b801087e-f1c9-4b1e-9e0c-70405b685e86
    

    Il s'agit certainement d'une amélioration, mais le code d'état n'est peut-être pas exact. Cette réponse n’est pas le résultat d’une erreur d’application, mais de l’utilisateur qui a indiqué une publication manquante. Dans ce cas, un HTTP 404 conviendrait mieux. Au-dessus de la fonction getPost nous pouvons définir une classe d'erreur personnalisée à lancer lorsqu'une publication n'est pas trouvée:

     classe d'exportation PostNotFoundError extend Error {
      constructeur (id: chaîne) {
        super (`Message introuvable avec l'ID $ {id}`);
      }
    }
    

    Ensuite, dans le corps de getPost nous pouvons lancer ceci au lieu d'une instance Erreur :

     fonction asynchrone getPost (blogService: BlogService, id: string) {
      const res = attendre blogService.getPost (id);
    
      if (! res) {
        throw new PostNotFoundError (`Message non trouvé avec l'ID $ {id}`);
      }
    
      return res;
    }
    

    L’avantage de générer une erreur personnalisée est que nous sommes en mesure de fournir une réponse particulière lorsqu'elle est détectée. Dans server.ts mettons à jour l'instruction switch dans la fonction mapToErrorResponse pour renvoyer un appel à notFound () lorsque notre PostNotFoundError se produit:

     function mapToErrorResponse (e: Error) {
      commutateur (e.constructor) {
        case PostNotFoundError:
          return notFound (e);
        défaut:
          return serverError (e);
      }
    }
    

    En réessayant la requête précédente, nous devrions maintenant voir que nous recevons un HTTP 404:

     $ curl -v http: // localhost: 8000 / posts / b801087e-f1c9-4b1e-9e0c-70405b685e86; écho
    
    > GET / posts / b801087e-f1c9-4b1e-9e0c-70405b685e86 HTTP / 1.1
    > Hôte: localhost: 8000
    > User-Agent: curl / 7.54.0
    > Accepter: * / *
    >
    <HTTP / 1.1 404 introuvable
    <longueur du contenu: 82
    <content-type: text / plain
    <
    
    Message non trouvé avec ID Post non trouvé avec ID b801087e-f1c9-4b1e-9e0c-70405b685e86
    

    Nous devrions également ajouter Reno's NotFoundError à ce cas, ce qui entraînera également un HTTP 404 si une route de requête n'existe pas:

     switch (e.constructor) {
      case PostNotFoundError:
      case NotFoundError:
        return notFound (e);
      défaut:
        return serverError (e);
    }
    

    Nous pouvons suivre ce modèle pour gérer d'autres types d'erreurs dans notre application. Par exemple, le service complet sert un HTTP 400 (demande incorrecte) lorsque l'utilisateur crée une ressource avec un UUID non valide .

    Ajout de nouvelles publications à la base de données

    Donc loin, les opérations que nous avons implémentées lisent les articles de la base de données. Qu'en est-il de la création de nouveaux articles? Nous pouvons ajouter un gestionnaire d'itinéraire pour cela, mais nous devons d'abord importer avec JsonBody de deps.ts dans routes.ts :

     import {
      createRouteMap,
      jsonResponse,
      forMethod,
      DBPool,
      uuidv4,
      AugmentedRequest,
      avecJsonBody,
    } à partir de "../deps.ts";
    

    Nous devrions également importer l’interface CreatePostPayload de blog_service.ts dont nous aurons besoin sous peu:

     import createBlogService, {
      BlogService,
      CreatePostPayload,
    } de "./blog_service.ts";
    

    withJsonBody est un gestionnaire de route d'ordre supérieur qui supposera que le corps de requête sous-jacent est une chaîne sérialisée JSON et l'analysera pour nous. Il prend également en charge un paramètre générique qui nous permet d'affirmer le type du corps. Utilisons-le pour définir notre gestionnaire addPost :

     const addPost = withJsonBody  (
      fonction asynchrone addPost ({body}) {
        const id = attendre blogService.createPost (corps);
        return jsonResponse ({id});
      },
    );
    

    Nous devons alors enregistrer le gestionnaire dans notre carte de route:

     const routes = createRouteMap ([
      [
        "/posts/*",
        forMethod([
          ["GET", getPosts],
          ["POST", addPost],
        ]),
      ],
    ]);
    

    Pour tester que notre opération POST / posts fonctionne, nous pouvons faire cette requête avec une charge utile de post-création valide:

     $ curl -H "Content-Type: application / json" -d '{
      "authorId": "91ef4450-97ff-44da-8b1d-f1560e9d10cc",
      "title": "Nouveau message",
      "contents": "Cela a été soumis via notre nouveau point de terminaison API!",
      "tagIds": ["6a7e1f4d-7fca-4573-b138-f2cba0163077", "f9076c31-69eb-45cf-b51c-d7a1b6e3fe0c"]
    } 'http: // localhost: 8000 / posts | jq
    
     {
      "id": "586bb055-cea6-4d56-8d8d-1856e8f8e5eb"
    }
    

    Nous pouvons alors nous assurer que cela a été correctement stocké dans notre base de données en demandant le message par l'UUID généré:

     $ curl http: // localhost: 8000 / posts / 586bb055-cea6-4d56-8d8d-1856e8f8e5eb | jq
    
     {
      "id": "586bb055-cea6-4d56-8d8d-1856e8f8e5eb",
      "title": "Nouveau message",
      "contents": "Ceci a été soumis via notre nouveau point de terminaison API!",
      "auteur": {
        "id": "91ef4450-97ff-44da-8b1d-f1560e9d10cc",
        "nom": "James Wright"
      },
      "tags": [
        {
          "id": "6a7e1f4d-7fca-4573-b138-f2cba0163077",
          "name": "C#"
        },
        {
          "id": "f9076c31-69eb-45cf-b51c-d7a1b6e3fe0c",
          "name": "Go"
        }
      ]
    }
    

    Modification de messages existants

    Pour terminer notre service, nous allons mettre en œuvre la route PATCH / posts / qui permet de remplacer le contenu d'un message. Commençons par importer l'interface EditPostPayload depuis blog_service.ts :

     import createBlogService, {
      BlogService,
      CreatePostPayload,
      EditPostPayload,
    } de "./blog_service.ts";
    

    Ensuite, nous devrions ajouter une fonction de gestion d'itinéraire appelée editPost :

     const editPost = withJsonBody  (
      fonction asynchrone editPost ({body: {contents}, routeParams: [id]}) {
        const rowCount = attendre blogService.editPost (id, contenu);
    
        if (rowCount === 0) {
          jeter un nouveau PostNotFoundError (id);
        }
    
        return jsonResponse ({id});
      },
    );
    

    Pour conclure, ajoutons le gestionnaire à nos routes:

     const routes = createRouteMap ([
      [
        "/posts/*",
        forMethod([
          ["GET", getPosts],
          ["POST", addPost],
          ["PATCH", editPost],
        ]),
      ],
    ]);
    

    Nous pouvons établir que notre gestionnaire fonctionne en mettant à jour le contenu du message que nous avons créé dans la section précédente:

     $ curl -X PATCH -H "Content-Type: application / json" -d '{
      "contents": "Ceci a été modifié via notre nouveau point de terminaison d'API!"
    } 'http: // localhost: 8000 / posts / 586bb055-cea6-4d56-8d8d-1856e8f8e5eb | jq
    
     {
      "id": "586bb055-cea6-4d56-8d8d-1856e8f8e5eb"
    }
    
     $ curl http: // localhost: 8000 / posts / 586bb055-cea6-4d56-8d8d-1856e8f8e5eb | jq .contents
    
     "Ceci a été modifié via notre nouveau point de terminaison API!"
    

    L'appel de l'opération GET / posts devrait également démontrer que aucun poste supplémentaire n'a été stocké dans la base de données.

    Étapes suivantes

    Nous avons rassemblé un puits -service conçu et maintenable, mais il y a encore des étapes supplémentaires qui amélioreraient la robustesse et la sécurité de notre service, comme la validation des charges utiles entrantes et l'autorisation des requêtes POST et PUT . De plus, nous pourrions écrire des tests unitaires pour nos gestionnaires de routes. Étant donné qu'il s'agit effectivement de fonctions pures (c'est-à-dire qu'elles produisent une réponse déterministe pour une entrée donnée, et que les effets secondaires sont facultatifs), nous pouvons y parvenir avec relativement peu de frais généraux:

     Deno.test (
      "Le gestionnaire d'itinéraire getPosts doit récupérer le message pour l'ID donné à partir du service de blog",
      async () => {
        const id = "ID de publication";
    
        post const = {
          id,
          title: "Test Post",
          auteur: {
            id: "identifiant de l'auteur",
            name: "James Wright",
          },
          tags: [
            { id: "tag ID", name: "JavaScript" },
            { id: "tag ID", name: "TypeScript" },
          ],
        };
    
        const blogService = {
          getPost: sinon.stub().resolves(post),
          getPosts: sinon.stub().resolves(),
        };
    
        const getPosts = createGetPostsHandler(blogService);
        const response = await getPosts({ routeParams: [id] });
    
        assertResponsesAreEqual(response, jsonResponse(post));
        assertStrictEquals(blogService.getPost.callCount, 1);
        assertStrictEquals(blogService.getPosts.callCount, 0);
      },
    );
    

    Note that we’re using partial application to inject the stub blog service into the route handler, which we can update accordingly:

    export function createGetPostsHandler(
      blogService: Pick,
    ) {
      return async function getPosts(
        { routeParams: [id] }: Pick,
      ) {
        const res = await (id ? getPost(blogService, id) : blogService.getPosts());
        return jsonResponse(res);
      };
    }
    

    The actual service would then provide the real blog service to the handler in a similar way to the tests. Another interesting observation is that Pick allows us to provide an implementation of BlogService with just a subset of its properties, meaning that we don’t have to define every single method to test handlers that don’t even need them.

    Summary

    Building small HTTP services with std/http is attainable, but managing additional endpoints, dedicated logic for particular HTTP methods, and error handling, can become burdensome as our applications grow. Reno conceals these complexities away from us, permitting us to focus on the core business logic of our microservices. Given the structure of route handler functions, applications that are routed with Reno intrinsically lend themselves to unit testing, and can easily integrate with existing Deno projects.

    That said, larger or more complex services may benefit from a full framework such as Oak. For microservices, however, Reno provides a very small, unobtrusive API surface that allows them to scale as our business requirements grow.




Source link