Coder un serveur HTTP basique en Node.js
Une bonne méthode pour apprendre et progresser en programmation est d’essayer de re-coder des projets existants. C’est ce que je vous propose dans cet article pour en apprendre plus sur Node.js.
Cet article s’adresse aux débutants sans pour autant s’attarder plus que nécessaire sur les notions de base de cet environnement. N’hésitez pas à visiter la liste des articles sur Node.js et JavaScript pour en apprendre plus.
Lorsque j’ai commencé à apprendre Node.js, le serveur HTTP était l’exemple typique que l’on citait dans tous les articles, blogs et autres cours. Le but était de montrer à quel point il est facile de créer un serveur web avec Node.js.
Bon on se rend vite compte qu’il faut un peu plus de code pour avoir un vrai site web grâce à Node.js. Voilà pourquoi les frameworks backend pour Node.js se sont rapidement multipliés. Le plus connu d’entre eux s’appelle Express.
Express est simple d’utilisation, rapide à mettre en place et possède une très large communauté.
Sans rendre le code plus compliqué, le code ci-dessus permet de retourner une chaine de caractère pour une méthode HTTP et une route précise. Il est très simple de rajouter des routes de la même façon que pour /
.
Express est très léger. Il utilise un système de middlewares qui permettent d’augmenter les capacités du framework.
Par exemple, pour permettre à Express de comprendre le JSON dans le corps d’une requête HTTP, il faut utiliser le middleware bodyParser
.
Comment fonctionne Express ?
Pour ceux qui connaissaient déjà Express avant cet article – et même ceux qui le découvre maintenant d’ailleurs –, est-ce que vous vous êtes déjà demandé comment Express fonctionnait ?
Et si nous codions un petit framework web pour comprendre le fonctionnement de nos outils ? Ne vous méprenez pas sur mes intentions. Le but de cet article n’est pas de coder un énième framework dans un écosystème qui en est déjà saturé et encore moins de produire un code prêt pour la production.
D’ailleurs, je vous conseille en général de ne pas "réinventer la roue" dans vos projets. Node.js possède un univers riche de modules open source pour tous vos besoins, n’hésitez pas à piocher dedans.
Par contre, même si dans la vie de tous les jours il vaut mieux utiliser un framework backend existant, cela ne vous empêche pas d’en coder un dans un but purement éducatif !
Codons un framework backend en Node.js
Comme je vous ai mis les "Hello, World!" de Node.js et Express, je vais aussi vous mettre le mien. Cela nous servira d’objectif à atteindre.
Ce code fait exactement la même chose que celui d’Express. La syntaxe diffère beaucoup – elle est grandement inspirée du .pipe()
de RxJS – mais il s’agit toujours d’afficher « Hello, World! » lorsque l’utilisateur va sur /
et de retourner une 404
s’il va sur une route inconnue.
Dans l’idée, nous retrouvons les middlewares d’Express par lesquels la requête va passer pour créer une réponse à retourner au client (le navigateur de l’utilisateur).
C’est un schéma très simplifié, mais vous comprenez l’idée.
Vous l’avez peut-être compris grâce à la syntaxe (et à la référence à RxJS), j’aimerais bien que l’on réussisse à avoir une approche plutôt fonctionnelle avec ce projet. Lorsque c’est bien fait, je trouve que cela produit un code beaucoup plus expressif.
Le serveur HTTP
La première fonction à implémenter est createServer
. Il ne s’agit que d’un wrapper de la fonction du même nom dans le module http
de Node.js.
import http from 'http'
export function createServer() {
return http.createServer(() => {
serverResponse.end()
})
}
createServer
crée le serveur et le retourne. Nous pourrons ensuite utiliser .listen()
pour lancer le serveur.
La fonction de callback de http.createServer()
peut prendre en paramètre un IncommingMessage
(la requête faite par le client) et un ServerResponse
(la réponse que l’on veut lui retourner).
Un de nos objectifs est d’avoir un système de middlewares qui vont chacun à leur tour modifier la requête pour construire petit à petit la réponse à retourner au client. Pour cela, il nous faut une liste de middlewares auxquels nous allons passer à chaque fois la requête et la réponse.
import http from 'http'
export function createServer(middlewares = []) {
return http.createServer((incommingMessage, serverResponse) => {
for (const middleware of middlewares) {
middleware(incommingMessage, serverResponse)
}
serverResponse.end()
})
}
Pour récupérer les middlewares et avoir la même syntaxe que le .pipe()
de RxJS, j’utilise le paramètre du reste que nous avons vu dans un précédent article.
import http from 'http'
export function createServer(...middlewares) {
return http.createServer((incommingMessage, serverResponse) => {
for (const middleware of middlewares) {
middleware(incommingMessage, serverResponse)
}
serverResponse.end()
})
}
Ainsi les middlewares tels que url
et router
pourront modifier incommingMessage
et/ou serverResponse
par référence. À la fin de notre "pipe" de modification, nous déclenchons l’évènement .end()
qui enverra la réponse au client.
Quelques petites choses me chiffonnent encore concernant ce code :
incommingMessage
etserverResponse
sont directement modifiés. J’aimerais mieux qu’ils ne le soient pas pour rester au plus proche de la philosophie de la programmation fonctionnelle.- Tous les middlewares doivent être appelés dans notre implémentation, mais Express permet de stopper le "pipe" des modifications si besoin. C’est utile pour un middleware d’authentification par exemple.
- Certains middlewares pourraient avoir besoin de bloquer l’exécution du "pipe" de modifications pour réaliser des tâches un peu plus longues. Actuellement notre code n’attendrait pas et l’exécution de certains middlewares pourraient se superposer dans le temps.
Corrigeons donc tout ça. Tout d’abord, le dernier point. C’est le plus simple à mettre en place.
import http from 'http'
export function createServer(...middlewares) {
return http.createServer(async (incommingMessage, serverResponse) => {
for (const middleware of middlewares) {
await middleware(incommingMessage, serverResponse)
}
serverResponse.end()
})
}
Ainsi, si le middleware s’exécute de façon asynchrone nous attendrons la fin de son exécution pour passer à la suite.
Pour éviter la modification de incommingMessage
et serverResponse
, je pense que le plus simple, c’est d’utiliser la valeur de retour des middlewares. Ce sont de simples fonctions, utilisons les comme telles : des valeurs d’entrées qui ne doivent pas être modifiées, mais qui servent à construire la valeur de retour. Cette valeur de retour est ensuite utilisée comme valeur d’entrée de la fonction (du middleware) suivante. Ainsi de suite.
import http from 'http'
export function createServer(...middlewares) {
return http.createServer(async (incommingMessage, serverResponse) => {
let requestContext = {
statusCode: 200,
}
for (const middleware of middlewares) {
requestContext = await middleware(incommingMessage, requestContext)
}
serverResponse.writeHead(requestContext.statusCode)
if (requestContext.responseBody != null) {
serverResponse.end(requestContext.responseBody)
return
}
serverResponse.end()
})
}
J’ai créé un requestContext
qui est un objet de travail pour les middlewares. Il est passé en valeur d’entrée de tous les middlewares aux côtés de la requête.
Le middleware traite ces valeurs et crée un nouveau contexte qu’il retourne ensuite. Nous écrasons l’ancien contexte avec le nouveau qui est plus à jour.
Lorsque nous avons terminé la modification du contexte par les middlewares, nous l’utilisons pour émettre la réponse du serveur.
Enfin, pour pouvoir arrêter le pipe au milieu, je n’ai rien trouvé de mieux qu’un petit boolean
. Nous pouvons le rajouter au contexte. Si un middleware le change a true
, le pipe est cassé et l’on envoie directement la réponse avec le contexte actuel :
import http from 'http'
export function createServer(...middlewares) {
return http.createServer(async (incommingMessage, serverResponse) => {
let requestContext = {
statusCode: 200,
closeConnection: false
}
for (const middleware of middlewares) {
if (requestContext.closeConnection === true) {
break
}
requestContext = await middleware(incommingMessage, requestContext)
}
serverResponse.writeHead(requestContext.statusCode)
if (requestContext.responseBody != null) {
serverResponse.end(requestContext.responseBody)
return
}
serverResponse.end()
})
}
Parser l’URL de la requête
Passons à l’écriture de notre premier middleware : url
. Son but est de parser l’URL de la requête pour nous fournir les informations dont les autres middlewares pourraient avoir besoin ensuite.
export function url() {
return (incomingMessage, requestContext) => ({ ...requestContext })
}
L’API d’un middleware est simple dans notre framework.
Il s’agit d’une fonction qui peut prendre en paramètre tout ce dont elle peut avoir besoin pour son fonctionnement ou pour permettre à l’utilisateur de modifier son fonctionnement. Ici url
ne prend aucun paramètre.
Cette fonction va ensuite retourner une autre fonction qui elle sera exécutée plus tard dans la boucle for (const middleware of middlewares)
du callback de createServer
. C’est cette fonction qui prend en paramètre incommingMessage
et requestContext
puis retourne une nouvelle version de requestContext
.
export function url() {
return (incomingMessage, requestContext) => ({
...requestContext,
url: new URL(incomingMessage.url, `http://${incomingMessage.headers.host}`),
})
}
Le middleware url
ajoute au contexte de la requête un attribut url
qui contient un objet URL
parsé.
Nous pourrons donc faire requestContext.url.pathname
et avoir accès au pathname
de la requête.
Le routeur de notre framework
Le routeur de notre framework sera aussi un middleware utilisable avec createServer
.
Pour faire simple, nous allons définir une route comme suit :
const route = {
method: 'GET',
pathname: '/',
controller(incommingMessage, requestContext) {
return `Hello, World!`
}
}
Cet objet est composé de trois informations indispensables à notre routeur :
- le pathname concerné
- le verbe HTTP utilisé
- la fonction capable de générer quelque chose à retourner au client
En entrée de notre middleware router
nous allons prendre un tableau de routes.
export function router(routes = []) {
return (incommingMessage, requestContext) => {
for (const route of routes) {
if (route.pathname !== requestContext.url.pathname) continue
if (route.method !== requestContext.method) continue
return {
...requestContext,
responseBody: route.controller(incommingMessage, requestContext),
}
}
}
}
À chaque fois qu’un client fait une requête au serveur, le router
va dérouler la liste des routes et vérifier laquelle correspond à la demande du client. Dès qu’il en trouve une il arrête la boucle et exécute la fonction controller
de la route.
Ce que le controller
retourne peut ensuite être ajouté au contexte comme responseBody
. C’est ce que le serveur retourne au client.
export function router(routes = []) {
return (incommingMessage, requestContext) => {
for (const route of routes) {
if (route.pathname !== requestContext.url.pathname) continue
if (route.method !== requestContext.method) continue
return {
requestContext,
responseBody: route.controller(incommingMessage, requestContext),
closeConnection: true,
}
}
return {
...requestContext,
statusCode: 404,
}
}
}
Pour faire en sorte que le client comprenne ce qu’il se passe lorsque le routeur ne trouve pas la route demandée, j’ai ajouté la gestion du code d’erreur 404 après la boucle. Le return
dans la boucle arrête complètement la fonction, donc si la fonction ne s’est pas arrêtée au milieu de la boucle, c’est forcément que l’on n’a pas trouvé de correspondance dans les routes déclarées. Nous retournons donc un contexte avec le statusCode
à 404
.
Avec cette fonction, nous pouvons déclarer nos routes comme suit :
router([
{
method: 'GET',
pathname: '/',
controller() {
return `Hello, World!`
}
},
])
Tout fonctionne correctement et nous pourrions nous arrêter là, mais nous n’avons pas exactement le même code que celui que je vous ai montré au début de l’article.
Tout ce qu’il manque, c’est du sucre syntaxique : du code qui ne servira qu’à rendre nos fonctionnalités plus faciles et agréables à utiliser.
export function get(pathname, controller) {
return {
method: 'GET',
pathname,
controller,
}
}
Cette fonction ne sert qu’à retourner l’objet "route" dont a besoin la fonction router
. Rien de compliqué ici.
export function get(pathname, controller) {
if (typeof controller !== 'function') {
throw new Error('The get() HTTP method needs a controller to work as expected')
}
if (typeof pathname === 'string') {
throw new Error('The get() HTTP method needs a pathname to work as expected')
}
return { method: 'GET', controller, pathname }
}
Un autre intérêt de cette fonction est qu’elle nous permet assez simplement de tester nos données sans compliquer le code de notre routeur.
Le routeur s’utilise maintenant comme suit :
router([
get('/', () => `Hello, World!`)
])
Nous sommes très proches du résultat que l’on souhaite atteindre. Les derniers changements sont à faire dans l’implémentation de router
lui-même.
export function router(...routes) {
return (incommingMessage, requestContext) => {
for (const route of routes) {
if (route.pathname !== requestContext.url.pathname) continue
if (route.method !== requestContext.method) continue
return {
requestContext,
responseBody: route.controller(incommingMessage, requestContext),
closeConnection: true,
}
}
return {
...requestContext,
statusCode: 404,
}
}
}
J’ai uniquement modifié le paramètre routes
pour qu’il ne soit plus un tableau, mais un paramètre du reste comme pour createServer
.
Ainsi, nous avons atteint notre objectif et terminé cet article. Appréciez votre dur travail :
import { createServer } from './lib/http/create-server.js'
import { url, router } from './lib/http/middlewares/index.js'
import { get } from './lib/http/methods/index.js'
const server = createServer(
url(),
router(
get('/', () => `Hello, World!`),
)
)
server.listen(3000)
Si vous souhaitez approfondir un peu les notions vu rapidement dans cet article ou allez plus loin, je vous conseille :