DEV Community

Cover image for Como funciona o Express JS? Criando um HTTP Server "express like" do zero, sem frameworks
Thiago Moraes
Thiago Moraes

Posted on

Como funciona o Express JS? Criando um HTTP Server "express like" do zero, sem frameworks

Introdução

Na primeira parte deste artigo, fiz uma abordagem com prós e contras sobre o uso (ou não uso) de frameworks. Apesar de ter grandes vantagens, tem um ponto negativo que me chama bastante atenção no dia-a-dia: muitos desenvolvedores estão completamente dependentes de frameworks, ao ponto de não conhecer alguns dos fundamentos básicos da tecnologia com a qual trabalham. Como já citei anteriormente, eu defendo bastante o “do the basics”, ter o conhecimento da base faz com que você tenha uma visão mais ampla e consequentemente, implementações melhores para suas soluções.

Hoje eu vou trazer a implementação de um HTTP Server inspirado no Express, feito completamente do zero, sem uso de frameworks. Ao longo do caminho vou mostrar alguns exemplos bem comuns no universo Node JS, como high order functions, chaining functions e events. Em paralelo, vou trazer a implementação com o próprio Express também para fazermos um comparativo. No final do artigo, vou deixar o link com todos os exemplos no Github. Então, vamos nessa!

Nota: De forma alguma a intenção deste artigo é refazer o express ou criar um framework que substitua o mesmo. Os exemplos são totalmente didáticos, visando o entendimento de alguns conceitos, não tendo a intenção de rodar em produção ou ser um robusto framework de servidor http.

Primeiramente nós precisamos de um nome legal, então para fazer uma alusão ao express, vamos chamar nosso módulo de Rockspress.

Conhecendo o módulo HTTP

Antes de iniciarmos o desenvolvimento do módulo em si, vou demonstrar como criar um simples http server em NodeJS (acreditem, muitos desenvolvedores nunca tiveram a necessidade de criar um). No fim das contas, todos os frameworks que fazem abstração de servidor HTTP utilizam o módulo nativo http (ou https). Segue exemplo:

//http.js
const http = require('http');

http.createServer((req, res) => {
    res.write('done!');
    res.write('\n');
    res.end();
}).listen(8000, () => console.log('server listen at 8000'));

Primeiramente chamamos a função createServer, que retorna um callback com um objeto para o request (que contém todas as informações do que é recebido a cada requisição ao servidor, como url, body, headers, etc) e outro para o response (que contém toda a estrutura para retornar ao cliente uma resposta no padrão http, como status code, body, headers, etc). Em seguida utilizamos a função listen para deixar o servidor disponível na porta 8000. No nosso exemplo estamos apenas escrevendo uma mensagem no corpo do response e finalizando o mesmo. Agora, vamos de fato ao nosso módulo.

Iniciando o módulo Rockspress

A primeira coisa que faremos é criar uma classe e no método construtor, criaremos uma propriedade que vai receber nosso server nativo, conforme o exemplo anterior:

//rockspress.js
const http = require(http);

class Rockspress {
    constructor() {
        this.server = http.createServer();
    }
}

Criando uma estrutura de dados

Em seguida, criaremos uma estrutura de dados, que vai ser responsável por receber e armazenar nossas funções de acordo com o roteamento. Escolhi utilizar uma estrutura de chave / valor:

class Rockspress {
    constructor() {
        this.server = http.createServer();

        this.router = {
            GET: {},
            POST: {},
            PUT: {},
            DEL: {}
        };
    }
} 

Criamos um objeto chamado router, que vai conter uma propriedade para cada método http que iremos implementar. Por sua vez, cada uma das dessas propriedades também é um objeto.

Registrando novas rotas

Agora que já temos umas estrutura para armazenar os dados, vamos criar uma função para registrar novas rotas de acordo com o método http escolhido. Vamos utilizar uma high order function (para quem não conhece o termo, é uma estratégia bem comum em programação funcional, se tratando uma função que opera outra função, recebendo como argumento ou retornando como saída). Não vou entrar muito a fundo nos detalhes, pois isso renderia um artigo inteiro. No nosso caso a intenção é fazer uma abstração de um método que recebe um parâmetro predefinido e parâmetros inseridos por quem chama a função.

registerRoute(method) {
    return function (route, callback) {
        this.router[method][route] = callback;
    }
}

A abstração recebe o método http, a rota escolhida e qual função será executada e salva essas informações de acordo com nossa estrutura de dados.

Fazendo o handle dos requests

Agora nós precisamos redirecionar os requests recebidos de acordo com a rota e método escolhidos. O módulo http nativo do Node, é todo baseado em eventos. Sempre que recebemos uma nova requisição no nosso servidor, é emitido um evento chamado request. Vamos criar uma função que vai fazer o gerenciamento desse evento, utilizando as propriedades req.method e req.url para acessar nossa estrutura de dados, enviando como argumentos os objetos de request e response.

class Rockspress {
    constructor() {
        //...

        this.server.on('request', this.handleRequest.bind(this));
    }

    async handleRequest(req, res) {
        if (!this.router[req.method][req.url]) {
            res.statusCode = 404;
            res.write('not found');
            return res.end();
        }

        this.router[req.method][req.url](req, res);
    }
}

Adicionamos mais uma abstração que vai verificar se o método e rota solicitados existem. Caso negativo, será retornada uma resposta padrão com o statusCode 404 (not found).

Agora que já estamos fazendo o roteamento das requisições recebidas para seus devidos métodos, precisamos alterar os objetos de request e response, colocando algumas abstrações adicionais para tornar o uso mais amigável.

Alterando o objeto Response

Vamos começar com o response. Criaremos uma função que recebe o response original e adicionar dois métodos no mesmo. O primeiro, seta o statusCode e o segundo escreve no responseBody um argumento recebido. Os dois retornam a própria response, permitindo assim o uso de um padrão bastante comum em javascript chamado chaining functions, que consiste em chamar múltiplas funções consecutivas a partir mesmo objeto.

setupResponse(response) {
    response.status = (statusCode) => {
        response.statusCode = statusCode;
        return response;
    }

    response.send = (responseBody) => {
        response.write(responseBody);
        response.end();
    }

    return response;
}

Exemplo de chamada com chaining function:

res.status(200).send('ok');

Nesse momento, já somos capazes de receber requisições, rotear para seus devidos metódos e retornar uma resposta. Mas antes de testar tudo funcionando, iremos implementar uma abstração para o objeto do request, permitindo pegar o que foi enviado como body da requisição.

Alterando o objeto Request

O recebimento do corpo da requisição, também é feito via evento, o mesmo é um stream que vem em partes. O que faremos é juntar as partes dessa stream, colocando tudo em uma propriedade do request em forma de json, para um acesso mais fácil (simulando algo semelhante ao que o middleware body-parser faz).

setupRequest(request) {
    request.body = '';

    request.on('data', chunk => {
        request.body += chunk.toString();
    });

    return new Promise(resolve => request.on('end', () => {
        request.body = request.body ? JSON.parse(request.body) : '';
        resolve(request);
    }));
}

Assim que o request recebe todas as partes, fazemos o parse para JSON e retornamos o request já alterado.
Vamos adicionar nossos métodos de setup de request e response no nosso handler de requests. Esse setup também poderia ser feito por eventos, porém, escolhi utilizar promises para facilitar o entendimento (É necessário colocar a keyword async no mesmo, pois estamos chamando uma promessa utilizando a keyword await).

async handleRequest(req, res) {
    req = await this.setupRequest(req);
    res = this.setupResponse(res);

    if (!this.router[req.method][req.url]) {
        res.statusCode = 404;
        res.write('not found');
        return res.end();
    }

    this.router[req.method][req.url](req, res);
}

Expondo o servidor em uma porta

É necessário também expor uma porta onde o server vai escutar as requisições enviadas. Para isso, faremos uma simples abstração do método listen do módulo http, apenas passando pra frente os mesmos argumentos recebidos:

listen() {
    const args = Array.prototype.slice.call(arguments);
    return this.server.listen.apply(this.server, args);
}

Refatorando

Para finalizar, vamos refatorar o código, dando mais clareza na leitura, criando funções que abstraem a criação da estrutura de dados e configuração dos métodos. Também iremos exportar uma instância do mesmo. Com isso, nosso módulo completo ficará da seguinte forma:

//rockspress.js
const http = require('http');

class Rockspress {
    constructor() {
        this.server = http.createServer();
        this.setupRoutes();
        this.setupMethods();
        this.server.on('request', this.handleRequest.bind(this));
    }

    setupRoutes() {
        this.router = {
            GET: {},
            POST: {},
            PUT: {},
            DEL: {}
        };
    }

    setupMethods() {
        this.get = this.registerRoute('GET');
        this.post = this.registerRoute('POST');
        this.put = this.registerRoute('PUT');
        this.del = this.registerRoute('DEL');
    }

    async handleRequest(req, res) {
        req = await this.setupRequest(req);
        res = this.setupResponse(res);

        if (!this.router[req.method][req.url]) {
            res.statusCode = 404;
            res.write('not found');
            return res.end();
        }

        this.router[req.method][req.url](req, res);
    }

    setupRequest(request) {
        request.body = '';

        request.on('data', chunk => {
            request.body += chunk.toString();
        });

        return new Promise(resolve => request.on('end', () => {
            request.body = request.body ? JSON.parse(request.body) : '';
            resolve(request);
        }));
    }

    setupResponse(response) {
        response.status = (statusCode) => {
            response.statusCode = statusCode;
            return response;
        }

        response.send = (responseBody) => {
            response.write(responseBody);
            response.end();
        }

        return response;
    }

    registerRoute(method) {
        return function (route, callback) {
            this.router[method][route] = callback;
        }
    }

    listen() {
        const args = Array.prototype.slice.call(arguments);
        return this.server.listen.apply(this.server, args);
    }
}

module.exports = new Rockspress();

Implementando o HTTP Server

E agora vamos criar a implementação do mesmo, com rotas GET e POST:

//index.js
const rockspress = require('./rockspress');

rockspress.get('/', (req, res) => {
    return res.status(200).send('main');
});

rockspress.get('/ping', (req, res) => {
    return res.status(200).send('pong');
});

rockspress.post('/send', (req, res) => {
    console.log('request body', req.body);

    return res.status(200).send('sent');
});

rockspress.get('/error', (req, res) => {
    return res.status(500).send('error');
});

rockspress.listen(8000, () => console.log('listen at 8000'));

Testando as rotas implementadas

Para testar, vamos utilizar o comando curl (pode-se utilizar qualquer client http desejado). Primeiro vamos inicializar app:

node index.js
#listen at 8000

Rota GET:

curl http://localhost:8000/ping
#pong

Rota POST:

curl -X POST http://localhost:8000/send -H 'Content-Type: application/json' --data '{"foo":"bar"}'
##request body {"foo":"bar"}
##sent

E para finalizar, a implementação lado a lado com express e com nosso rockspress:

//both.js

/**
 * ------------------------------------------------------------------------------
 *  Server with express framework
 */

const express = require('express');
const app = express();

app.use(express.json());

app.get('/', (req, res) => {
    return res.status(200).send('main');
});

app.get('/ping', (req, res) => {
    return res.status(200).send('pong');
});

app.post('/send', (req, res) => {
    console.log('request body', req.body);

    return res.status(200).send('sent');
});

app.get('/error', (req, res) => {
    return res.status(500).send('error');
});

app.listen(8000, () => console.log('listen at 8000'));


/**
 * ------------------------------------------------------------------------------
 *  Server with custom framework
 */


const rockspress = require('./rockspress');

rockspress.get('/', (req, res) => {
    return res.status(200).send('main');
});

rockspress.get('/ping', (req, res) => {
    return res.status(200).send('pong');
});

rockspress.post('/send', (req, res) => {
    console.log('request body', req.body);

    return res.status(200).send('sent');
});

rockspress.get('/error', (req, res) => {
    return res.status(500).send('error');
});

rockspress.listen(8001, () => console.log('listen at 8001'));

Segue o link do github com todos os exemplos: https://github.com/thiagomr/rockspress

Conclusão

E assim vamos chegando ao fim. Além de aprender diversos conceitos da linguagem, é possível também perceber o quanto é trabalhoso implementar o mínimo de funcionalidades (mesmo que para fins didáticos, sem se preocupar com performance, dentre outros aspectos importantes) de um framework como o express. Eu recomendo que vocês façam esse tipo de engenharia reversa, pra mim funciona como uma ótima fonte de aprendizado e para os novatos pode ser realmente esclarecedor, afinal, quem nunca se perguntou "mas de onde vem esse req, res"?

Então é isso, espero que tenham gostado. Me sigam no twitter para ficarem ligados nos proximos artigos e compartilhem com os amigos para que eu possa continuar gerando conteúdo. Feedbacks, comentários e sugestões sempre são bem-vindos.

Um grande abraço e até a próxima!

Top comments (0)