Comment créer une API gRPC avec NodeJS ?

Comment créer une API gRPC avec NodeJS ?

Introduction

Lorsque l'on parle d'une API gRPC, cela signifie que l'on parle d'une application Web qui communique en utilisant le framework gRPC créé par Google en 2016.

Ce framework multiplateforme est basé sur les Remote Procedure Calls, en français Appels de Procédure à Distance.
Ce sont donc des appels à des fonctions qui ont des effets sur un serveur distant, mais qui sont exécutées de manière locale sans que le développeur ait besoin de développer les détails de l'interaction à distance.

La version gRPC diffère de la communication RPC classique en choisissant d'utiliser la version 2 du protocole réseau HTTP, nommée HTTP/2. Une version majeure créée en 2015 qui améliore la vitesse du transport des données et offre la possibilité d'une communication bidirectionnelle.

La différence majeure entre RPC et gRPC se trouve dans le format utilisé pour transporter les données. Il s'agit de l'utilisation de Protocol Buffers, un format de donnée open source développé par Google.
Ce format permet de sérialiser des données structurées en un paquet compact rétro compatible mais aussi compatible avec les futures versions.

Ces données sont faites pour être envoyées vers un serveur distant ou stockées, mais ne peuvent pas être compréhensibles sans se rapporter à son schéma, contenu dans un fichier avec l'extension ".proto".

Création de l'API

Le code source de cette étape est disponible ici.

Passons maintenant à l'action, en commençant par créer un simple fichier "hello_world.proto" qui va contenir le schéma de notre API.

syntax = "proto3";

service GreeterService {
  rpc SayHello (SayHelloRequest) returns (SayHelloResponse);
}

message SayHelloRequest {
  string name = 1;
}

message SayHelloResponse {
  string message = 1;
}

Pour le moment, le fichier contient le strict minimum pour être exécuté côté serveur et être appelé côté client.

Comprendre le schéma Protocol Buffers

syntax = "proto3" permet de spécifier que le schéma utilise la version 3 de Protocol Buffers. Si cette ligne n'est pas présente, le compilateur se comportera comme s'il s'agissait de la version 2 "proto2". La version "proto1" existait avant que Protocol Buffers soit open source, mais est désormais dépréciée.

service GreeterService { ... } englobe les différentes fonctions de notre service qui devront être implémentées coté serveur et qui pourront être appelées côté client. Plusieurs services peuvent exister au sein d'un même fichier d'extension ".proto" en précédant le nom du service par le mot-clé service.

rpc SayHello (SayHelloRequest) returns (SayHelloResponse) décrit une fonction attachée au service parent. Ces fonctions commencent par rpc suivi d'un nom écrit en PascalCase, comme suggéré dans le guide de style de Google.

Le premier mot entre parenthèses SayHelloRequest représente la structure de données des paramètres qui peuvent être envoyés à cette fonction.
Le second mot entre parenthèses SayHelloResponse est précédé par le mot-clé obligatoire returns pour indiquer la structure de donnée renvoyée par la méthode SayHello.

Il est conseillé de reprendre le nom de la fonction et de rajouter "Request" ou "Response" en tant que suffixe pour le nom de la structure de donnée, en fonction de son rôle.
Dans un but de rétro compatibilité, ces paramètres doivent être définis pour pouvoir changer dans le futur sans changement de code nécessaire.

message SayHelloRequest { ... } est la structure de donnée utilisée comme argument à notre méthode SayHello. Celle-ci est précédée du mot-clé message, obligatoire pour les structures de données, suivie de son nom en Pascal Case.

string name = 1 est la première propriété de cette structure de donnée. Elle est composée en 3 parties : son type, son nom et sa position dans la structure, précédée par un symbole "=", en commençant par la valeur "1".
De nombreux types sont disponibles pour déterminer le contenu de chaque propriété. Vous pouvez trouver ici la liste exhaustive des types disponibles dans un message.

message HelloReply { ... } est aussi une structure de donnée qui est utilisée pour décrire ce que contient la réponse de la méthode comme premier argument

Création de la partie serveur

Le code source de cette étape est disponible ici.

Dans le même dossier qui contient votre schéma, initialisez un projet node avec la commande npm init en rajoutant -y pour ne pas avoir de confirmation supplémentaire.

npm init -y

Deux librairies sont nécessaires pour faire une API gRPC avec NodeJS
Installez les librairies en utilisant npm.

npm install @grpc/grpc-js @grpc/proto-loader

Dans un nouveau fichier "server.mjs", commencez par importer la méthode loadSync de "@grpc/proto-loader" pour charger le schéma.

import { loadSync } from '@grpc/proto-loader'

Créez une variable qui va contenir le chemin relatif vers le fichier de notre schéma.

const PROTO_PATH = './hello_world.proto'

Utilisez la méthode loadSync avec comme premier paramètre le chemin du schéma contenu dans la variable PROTO_PATH.
Le deuxième paramètre de cette fonction permet de définir des options pour récupérer les différentes informations du schéma formaté d'une certaine façon. Liste exhaustive disponible ici.

const packageDefinition = loadSync(PROTO_PATH, {
    keepCase: true,
    longs: String,
    enums: String,
    defaults: true,
    oneofs: true,
})

Pour utiliser le contenu du schéma après l'avoir chargé, une autre fonction doit être appelée : loadPackageDefinition. Cette fonction est disponible dans la seconde librairie que nous avons installée au préalable @grpc/grpc-js.

import { loadPackageDefinition } from "@grpc/grpc-js"

Pour l'utiliser, il suffit d'ajouter la variable packageDefinition qui contient le résultat du chargement du schéma, en tant qu'argument de celle-ci.

const hello_proto = loadPackageDefinition(packageDefinition)

Passons à la partie démarrage du serveur en créant une fonction startServer.

function startServer() {}

Celle-ci va permettre de créer un serveur, d’ajouter un service, puis de le démarrer sur un port prédéfini. Ces actions nécessitent deux nouveaux imports Server et ServerCredentials de la librairie @grpc/grpc-js qui viennent se rajouter à la fonction loadPackageDefinition.

import {
  loadPackageDefinition,
  Server,
  ServerCredentials,
} from "@grpc/grpc-js"

Créez une instance du serveur en l'appelant sans argument, bien que de nombreux paramètres existent et puissent être configurés.

function startServer() {
  const server = new Server()
}

Vous pouvez maintenant ajouter un service à l'instance de notre serveur en appelant addService. Il faut passer en premier argument la propriété service d'un service défini dans notre schéma.

function startServer() {
  const server = new Server()
  server.addService(hello_proto.GreeterService.service, { sayHello })
}

Son second argument est un objet qui déclare l'implémentation de chaque fonction de ce service. Créez cette fonction sayHello en dehors du scope de la fonction startServer.

La fonction devra être composée de deux paramètres call et callback qui serviront respectivement à récupérer un ou des éléments de la requête et de retourner un ou des éléments dans la réponse.

const sayHello = (call, callback) => {
  const { name } = call.request
  callback(null, { message: `Bonjour ${name}` })
}

Le paramètre name définit en tant que paramètre de la fonction sayHello dans notre schéma peut être récupéré en décomposant l'objet request.
Puis on appelle la fonction callback pour envoyer une réponse avec deux paramètres: le premier pour l'erreur, le second pour le détail de la réponse.

Ici le premier argument est null car il n'y a pas d'erreur. Le second est un objet qui correspond à SayHelloResponse décrit dans le schéma "hello_world.proto".

Maintenant que le serveur possède un service, il faut le démarrer.
Le démarrage du serveur se fait en deux temps. D'abord en faisant appel à la méthode bindAsync. Puis dans la fonction callback de cette méthode, il faut appeler la méthode start de ce même serveur pour qu'il soit finalement démarré.

function startServer() {
  const server = new Server()
  server.addService(hello_proto.GreeterService.service, { sayHello })
  const PORT = 3000
  const host = `localhost:${PORT}`
  server.bindAsync(host, ServerCredentials.createInsecure(), (error) => {
    if (error) {
      console.log("An error has occurred in bindAsync", error)
      return
    }
    server.start()
    console.log(`Server listening on: ${host}`)
  })
}

Le premier argument `localhost:${PORT}` est un template string pour désigner l'adresse d'écoute qui se compose d'un hostname et d'un port. Le port choisi doi être disponible sur votre réseau local. Dans le cadre de ce tutoriel, le port 3000 est choisi et donc assigné à une variable PORT.

Le deuxième argument ServerCredentials.createInsecure() est nécessaire pour spécifier la sécurité du serveur. Il est possible d'utiliser l'une des deux méthodes statiques de la classe ServerCredentials: createInsecure ou createSsl.

Le troisième argument (error) => { ... } permet d'inscrire une fonction callback pour exécuter d'autres fonctions après vérification que le serveur puisse bien démarrer avec les informations renseignées.
Cette fonction peut potentiellement recevoir une erreur en premier argument. Après avoir passé la condition qu'il n'y ait pas d'erreur, on peut exécuter la méthode start de server avec plus de sécurité.

Le serveur est désormais configuré. Pour s'assurer que tout se déroule bien, il suffit de faire appel à la fonction startServer après avoir déclaré la fonction.

startServer()

Puis d'exécuter dans un terminal, la commande node sur le fichier server.mjs.

node server.mjs

Si tout s'est bien déroulé de votre côté, vous devriez voir ce résultat dans la console de votre terminal.

Server listening on: localhost:3000

Création de la partie client

Le code source de cette étape est disponible ici.

Pour commencer à faire des requêtes clients vers notre tout nouveau serveur, il faut d'abord créer un nouveau fichier: client.mjs

Le début de ce fichier ressemble à la partie serveur, car il s'agit de la récupération et du chargement du contenu de notre schéma Protocol Buffers.
On retrouve donc d'abord les deux imports nécessaires loadSync et loadPackageDefinition

import { loadSync } from "@grpc/proto-loader"
import { loadPackageDefinition } from "@grpc/grpc-js"

Puis on les utilise de la même façon que dans la partie serveur, en définissant le chemin relatif du schéma à utiliser.

const PROTO_PATH = "./hello_world.proto";
const packageDefinition = loadSync(PROTO_PATH, {
  keepCase: true,
  longs: String,
  enums: String,
  defaults: true,
  oneofs: true,
});
const hello_proto = loadPackageDefinition(packageDefinition);

Il faut maintenant développer la fonction principale qui s'appellera aussi simplement que celle dans la partie serveur: startClient.
À l’intérieur, on y retrouve l'utilisation du service récupéré depuis le schéma chargé.

function startClient() {
  const service = new hello_proto.GreeterService(
    "localhost:3000",
    credentials.createInsecure()
  )
}

Le service récupéré est au même nom que celui inscrit dans le schéma: GreeterService. Il est appelé avec deux paramètres : son adresse d'écoute ainsi que les informations de sécurité nécessaires pour s'y connecter.

Rappelez-vous, nous avons choisi de ne pas sécuriser la connexion. Il faut donc envoyer des informations du même type avec la fonction createInsecure après avoir importé credentials de la librairie npm déjà installée @grpc/grpc-js.

import { credentials } from "@grpc/grpc-js";

Maintenant que le service est récupéré, vous pouvez appeler ses fonctions.
La seule fonction déclarée dans notre schéma et implémentée côté serveur est SayHello.

function startClient() {
  ...
  service.SayHello({ name: "Aurélien" })
}

Cette fonction est contenue dans le service et appelée avec comme premier argument un objet pour envoyer un ou plusieurs paramètres. Notre fonction SayHello n'a besoin que d'un paramètre name, il est donc seul dans cet objet.

À ce moment-là, la requête a bien été envoyée mais quid de la réponse ?
Pour savoir ça, il faut rajouter un second paramètre à la méthode SayHello.

function startClient() {
  ...
  service.SayHello({ name: "Lecteur" }, (error, result) => {
    if (error) {
      console.error("An error has occured", error)
      return
    }
    const { message } = result
    console.log(message)
  })
}

Ce second paramètre est une fonction qui agit comme callback après que la requête a bien été envoyée. On y retrouve deux paramètres: error et result.

Après avoir rajouté une condition qui récupère une erreur s’il y en a, on peut récupérer le contenu du résultat qui contient notamment une propriété message.

On affiche simplement le contenu du message créé par le serveur et récupéré par le client avec un console.log.

Maintenant on peut vérifier le fonctionnement de bout en bout, en ajoutant l'appel à la fonction startClient.

startClient()

On exécute dans un nouveau terminal, la commande node sur le fichier client.mjs.

node client.mjs

Après exécution, la console devrait afficher ce résultat dans votre terminal.

Bonjour Lecteur

Bonus : TypeScript compatible.

Le code source de cette étape est disponible ici.

Si par chance tout fonctionne bien du premier coup, c'est parce que nous étions encore seulement en JavaScript. Pour continuer l'API gRPC avec plus de confiance dans le développement, passons à TypeScript.

"Alors pourquoi ne pas l'avoir fait dès le début ?", me direz-vous.
Simplement parce que le framework gRPC n'a pas été conçu pour TypeScript mais pour NodeJS qui utilise donc seulement du JavaScript.
Vous pouvez voir la liste complète ici des langages supportés nativement par gRPC.

Génération de l'interface TypeScript

Le schéma que nous avons créé peut être utilisé pour générer du code compilé dans de multiples langages de programmation. JavaScript et TypeScript ne font pas partie de cette liste, car ils n'ont pas besoin de code compilé pour fonctionner.

Afin d'obtenir la puissance des types statiques de TypeScript, un outil existe pour générer des interfaces TypeScript en se basant sur notre fichier "hello_world.proto".
Cet outil est disponible dans la librairie npm @grpc/proto-loader installé précédemment.

Utilisez npx sur l'outil proto-loader-gen-types avec le nom du fichier de notre schéma Protocol Buffers "hello_world.proto".

npx proto-loader-gen-types hello_world.proto --longs=String --enums=String --defaults --oneofs --grpcLib=@grpc/grpc-js --outDir=proto/

Les paramètres --longs, --enums, --defaults,--oneofs doivent avoir les mêmes valeurs que ceux utilisés dans votre code.

Le paramètre suivant --grpcLib permet de choisir la librairie @grpc/grpc-js, déjà installée, pour que les types générés se basent sur la même implémentation.

Le dernier paramètre --outDir permet de choisir le dossier où vous souhaitez extraire les interfaces et types qui vont être générés.

Si la génération des types s'est bien passée, vous devriez voir 4 nouveaux fichiers dans un nouveau dossier nommé "proto".

Utilisation des types

Changez désormais l'extension des fichiers server.mjs et client.mjs en ".ts"

Le principal changement à faire pour appliquer la détection des types se fait sur le chargement du proto.

Il faut rajouter as unknown as ProtoGrpcType après l'utilisation de loadPackageDefinition que ce soit dans le fichier "server.ts" ou le fichier "client.ts".

const hello_proto = loadPackageDefinition(packageDefinition) as unknown as ProtoGrpcType

Spécifier as unknown est nécessaire, car les types associés à la fonction loadPackageDefinition ne peuvent être au courant des propriétés de notre type tout juste généré.

Puis pour la partie serveur, il suffit d'utiliser l'interface GreeterServiceHandlers qui se nomme d'après le service spécifié (GreeterService) avec le suffixe "Handlers".
Cela permet ensuite de sélectionner le nom de la méthode à implémenter dans cet exemple, il s'agit de "SayHello".

import { GreeterServiceHandlers } from "./proto/GreeterService"
...
const sayHello: GreeterServiceHandlers["SayHello"] = ...

Rajouter cette interface permet une sécurité supplémentaire afin de récupérer les bonnes propriétés qui peuvent être dans la requête, ainsi qu'associer des types à chacune de ces valeurs.

Un environnement typé

Maintenant que le code a bien été changé, il faut ajouter des nouvelles librairies à l'environnement pour exécuter ce code typé.

npm install -D typescript ts-node

Il reste à tester le nouveau code, en utilisant cette fois-ci la commande ts-node au lieu de node et npx pour utiliser la nouvelle librairie.

npx ts-node server.ts
npx ts-node client.ts

Conclusion

En utilisant une API gRPC avec NodeJS, il est possible d'appeler des fonctions d'une manière simple et efficace. Tout se passe directement dans les fonctions renseignées par des services.

La découverte des fonctionnalités est autodescriptive, ce qui permet d'éviter de passer par une documentation écrite externe à l'implémentation pour connaître les fonctionnalités disponibles. Une documentation écrite est toujours importante pour renseigner plus d'informations, avec des exemples différents.

Avantages

Rendement élevé : gRPC utilise le format Protocol Buffers pour communiquer plus efficacement entre les clients et les serveurs, comparé aux API REST qui utilisent traditionnellement des objets JSON. Dans un système IoT, ce besoin d'efficacité est souvent présent pour communiquer le minimum de données nécessaires.

Indépendant : Protobufs n'a pas besoin d'utiliser un langage de programmation ou un système spécifique pour sérialiser les données en binaire. Ce besoin peut être présent si l'implémentation du côté client/serveur est prévue pour être sur des systèmes différents (Web/Mobile/PC) ou utilisé avec des langages de programmation différents comme cela peut se faire dans une architecture microservice.

Streaming inclus : Bien que ce tutoriel ne couvre pas une utilisation streaming, celle-ci est utilisable nativement avec ce framework. Une requête peut donc être faite de plusieurs façons différentes :

  • Requête unique et réponse unique appelée "Unary", comparable à REST.

  • Requête unique pour recevoir un flux de données (Server streaming).

  • Flux de données vers le serveur qui répond une fois (Client streaming).

  • Flux de données vers le serveur qui répond aussi en un flux de données (Streaming bidirectionnel).

Inconvénients

Langages de programmation limités : Bien que son indépendance au langage de programmation soit un avantage comme décrit précédemment, il faut que ce langage soit présent dans cette liste pour être supporté par gRPC.

Utilisable seulement avec HTTP/2 : Alors que plus de la majorité des navigateurs internets modernes supporte la version 2 du protocole HTTP, certaines anciennes versions de navigateur peuvent ne pas fonctionner avec gRPC.

Réutilisation fonctionnelle difficile : En fonction de l'implémentation et donc du langage de programmation utilisé, réutiliser le même code peut s'avérer compliqué. Il faudra changer le code implémenté pour qu'il soit compatible avec le nouvel environnement.

Mutation non prévisible : Comme une API gRPC n'utilise pas les méthodes standards de HTTP (GET/POST/DELETE...), il n'existe pas de manière pour savoir à l'avance si une fonction a des effets de mutation.

Promesses non supportées : Depuis septembre 2019, une issue est ouverte pour réclamer l'intégration des Promesses et de la syntaxe async/await qui ne sont toujours pas intégrés nativement dans le framework pour NodeJS.

Dans quels cas utiliser gRPC ?

Après avoir vu les avantages/inconvénients d'une API de type gRPC. Certaines situations peuvent profiter de l'utilisation de gRPC :

  • Lorsque le besoin de communiquer nativement en streaming avec HTTP, en utilisant un framework moderne.

  • Dans une architecture microservices, pour communiquer efficacement avec plusieurs services en un seul client HTTP.

  • L'utilisation de FieldMask, un type défini par Protocol Buffers pour éviter de requêter certaines informations non demandées, comme Netflix l'a ici implémenté. Une fonctionnalité que l'on trouve aussi nativement dans GraphQL.

Alors faut-il changer de REST à gRPC ?

Dans le cadre d'une application web classique FullStack NodeJS avec TypeScript, ma réponse est : non.

D'autres solutions sont plus adaptées à TypeScript telles que tRPC qui reprend la simplicité de RPC, présentée ici avec gRPC, mais conçu spécifiquement pour TypeScript. Cela permet notamment de ne pas avoir de schéma intermédiaire et d'utiliser directement les types déclarés côté serveur depuis le côté client.

Il existe aussi GraphQL, une solution moderne, qui permet d'avoir une sélection précise des données que l'on souhaite recevoir du serveur, à l'instar de FieldMask présent dans gRPC.
C'est aussi une bonne solution pour être utilisée dans une architecture microservices, grâce à une API Gateway.