Skip to content

Latest commit

 

History

History
528 lines (398 loc) · 21.6 KB

ch06.1-basic-router-implementation.md

File metadata and controls

528 lines (398 loc) · 21.6 KB

Read Prev

Uma implementação básica do Router

Uma pequena nota: vamos utilizar a convenção camelCase para nomeação em nosso framework web. Planejo usar este framework para meus projetos pessoais e encorajar outros desenvolvedores a utilizarem também. Por essa razão, preciso garantir que minhas convenções para nomeação se alinhem perfeitamente com as convenções de nomeação utilizadas na biblioteca padrão do Node.js.

Vamos começar construindo o bloco construtor fundamental de qualquer framework de servidor web - um Router (Roteador). Um router é um utilitário que determina como uma aplicação responde à diferentes requisições HTTP para URLs específicas ou, em termos simples, determina quais requisições HTTP devem ser manejadas e por qual parte da aplicação.

Vamos dar uma olhada em um servidor node:http normal e entender o porque é realmente complicado gerenciar diferentes métodos HTTP. Crie um novo projeto/diretório e crie um novo arquivo index.js.

// arquivo: index.js
const http = require("node:http");

const PORT = 5255;

const server = http.createServer((req, res) => {
    res.end("Hello World");
});

server.listen(PORT, () => {
    console.log(`Server is listening at :${PORT}`);
});

Falamos sobre o que cada linha do servidor faz no capítulo 5.0 - HTTP Deep Dive, mas para recapitular: o método res.end() diz ao servidor que tudo que está na resposta, incluindo os cabeçalhos e corpo, foram enviados e que o servidor deve tratar a mensagem como finalizada.

Se você tentar enviar uma requisição GET, POST, PUT para esse servidor usando o cURL:

curl --request POST http://localhost:5255
# Hello World

curl -X PUT http://localhost:5255
# Hello World

Como vamos identificar e diferenciar diferentes métodos HTTP? Por sorte, temos o req.method:

// arquivo: index.js

const http = require("node:http");

const PORT = 5255;

const server = http.createServer((req, res) => {
    res.end(`Hello from -> ${req.method} ${req.url}`); // Linha alterada
});

server.listen(PORT, () => {
    console.log(`Server is listening at :${PORT}`);
});

Se você enviar uma requisição novamente utilizando o cURL:

curl -X PUT http://localhost:5255
# Hello from -> PUT /

curl -X PUT http://localhost:5255/hi/there/test
# Hello from -> PUT /hi/there/test

Isso já deve ter feito você pensar, para escrever aplicações reais usando o módulo node:http puro, haveria um monte de declarações if e else na nossa base de código. Você está correto. É por isso que precisamos de uma classe Router, que abstraia todas as funcionalidades de gerenciamento de diferentes métodos HTTP e URLs, e que ofereça uma interface limpa ao desenvolvedor.

Antes de implementarmos nossa classe Router, vamos tentar escrever um servidor web útil com o que temos conosco.

Um Router de Brinquedo

// arquivo: index.js

const http = require("node:http");

const PORT = 5255;

const server = http.createServer((request, response) => {
    const { headers, data, statusCode } = handleRequest(request);
    response.writeHead(statusCode, headers);
    response.end(data);
});

function handleRequest(request) {
    const { method, url } = request;

    let data = "";
    let statusCode = 200;
    let headers = {
        "Content-Type": "text/plain",
    };

    if (method === "GET" && url === "/") {
        data = "Hello World!";
        headers["My-Header"] = "Hello World!";
    } else if (method === "POST" && url === "/echo") {
        statusCode = 201;
        data = "Yellow World!";
        headers["My-Header"] = "Yellow World!";
    } else {
        statusCode = 404;
        data = "Not Found";
        headers["My-Header"] = "Not Found";
    }

    return { headers, data, statusCode };
}

server.listen(PORT, () => {
    console.log(`Server is listening at :${PORT}`);
});

Vamos passar por isso linha a linha.

const { method, url } = request;

Passamos o objeto request inteiro como um argumento para handleRequest, nos importamos apenas com dois campos: method e url. Então, ao invés de acessar as propriedades, como request.method e request.url, estamos usando desestruturação para obter os valores de method e url do objeto request.

let headers = {
    "Content-Type": "text/plain",
};

Já que o método ServerResponse.end do Node.js não define o cabeçalho Content-Type implicitamente, estamos definindo-o manualmente - como sabemos, estamos apenas retornando texto plano de volta ao cliente. É uma boa prática incluir todos os cabeçalhos necessários seguindo as diretrizes do HTTP.

if (method === "GET" && url === "/") {
    data = "Hello World!";
    headers['My-Header'] = "Hello World!";
} else if (method === "POST" && url === "/echo") {
    ...
} else {
    ...
}

Estamos usando a variável data para monitorar o conteúdo que será enviado de volta ao cliente. Se o método HTTP for GET e a URL for / (que significa que a URL de requisição no cURL é http://localhost:5255/ ou http://localhost:5255), vamos enviar um Hello World! de volta e definir nosso cabeçalho customizado My-Header.

O mesmo se aplica à outras rotas também, onde alteramos os dados sendo enviados, o código de status e o cabeçalho My-Header de acordo com o method e url. Se não corresponderem às nossas necessidades, vamos simplesmente enviar um código de status 404 e Not Found de volta ao cliente.

Definir os cabeçalhos corretos é realmente importante ao enviar dados de volta ao cliente. Cabeçalhos controlam como o cliente e o servidor se comunicam e protegem os dados. Se os cabeçalhos estiverem errados ou faltando, coisas ruins podem acontecer, como problemas de segurança ou o não funcionamento da aplicação. Então, ter os cabeçalhos corretos é mais importante que o dado real sendo enviado.

Na verdade, você pode apenas ignorar os dados e enviar uma resposta vazia com o código de status 404. Todo desenvolvedor sabe o que o código 404 significa.

return { headers, data, statusCode };

Estamos retornando a informação de volta à chamada da função - que nesse caso está nessa parte:

const server = http.createServer((request, response) => {
    const { headers, data, statusCode } = handleRequest(request);
    response.writeHead(statusCode, headers);
    response.end(data);
});

Uma vez que a função handleRequest finaliza a execução, temos os cabeçalhos apropriados, os dados e o código de status, que precisa ser enviado de volta ao cliente para que eles saibam: "Ok, acabamos de processar sua requisição. Aqui está o resultado".

response.writeHead(statusCode, headers);

O writeHead é um método na classe ServerResponse, que nos dá a flexibilidade de definir os cabeçalhos de resposta, assim como o código de status. Os cabeçalhos devem ser um objeto, com a chave sendo o nome do cabeçalho e o valor sendo o valor do cabeçalho.

Mas e se não definirmos esses cabeçalhos? O que acontece, é que se você não definir os cabeçalhos antes de res.end(), o Node.js definirá implicitamente a maioria dos cabeçalhos.

Embora o segundo argumento também possa ser um array com múltiplas entradas, não vamos usar isso.

res.end(data);

Diz ao cliente - "Estou oficialmente encerrado agora. Pegue este data (dado)".

Agora, vamos testar nossa simples implementação dessa função "router".

$ curl http://localhost:5255 -v

*   Trying 127.0.0.1:5255...
* Connected to localhost (127.0.0.1) port 5255 (#0)
> GET / HTTP/1.1
> Host: localhost:5255
> User-Agent: curl/7.87.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< My-Header: Hello World!
< Date: Fri, 01 Sep 2023 14:49:06 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
< Transfer-Encoding: chunked
<
* Connection #0 to host localhost left intact
Hello World!

Temos nosso cabeçalho My-Header, o código de status 200 e o corpo da resposta Hello World!. Vamos testar o endpoint POST também:

curl -X POST  http://localhost:5255/echo -v
*   Trying 127.0.0.1:5255...
* Connected to localhost (127.0.0.1) port 5255 (#0)
> POST /echo HTTP/1.1
> Host: localhost:5255
> User-Agent: curl/7.87.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 201 Created
< My-Header: Yello World!
< Date: Fri, 01 Sep 2023 14:52:33 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
< Transfer-Encoding: chunked
<
* Connection #0 to host localhost left intact
Yellow World!%

Perfeito! Tudo parece bom. Agora é hora de testar uma URL ou método que não está sob a cobertura da nossa função handleRequest.

*   Trying 127.0.0.1:5255...
* Connected to localhost (127.0.0.1) port 5255 (#0)
> POST /nope HTTP/1.1
> Host: localhost:5255
> User-Agent: curl/7.87.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 404 Not Found
< My-Header: Not Found
< Date: Fri, 01 Sep 2023 14:53:47 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
< Transfer-Encoding: chunked
<
* Connection #0 to host localhost left intact
Not Found%

Sim, o código de status é 404 como esperado. O My-Header tem o valor correto e o corpo da resposta é Not Found. A implementação parece boa.

Transfer-Encoding: chunked

Você deve ter notado um novo cabeçalho Transfer-Encoding com o valor chunked na saída acima. O que é isso?

Em uma chunked transfer encoding (codificação de transferência em pedaços), os dados são divididos em pedaços separados chamados de "chunks". Esses chunks são enviados e recebidos sem dependerem uns dos outros. Ambos, quem envia e quem recebe, precisam focar apenas no pedaço que estão trabalhando no momento, sem se preocupar com o resto da stream de dados.

Isso acontece porque não especificamos o cabeçalho Content-Length, o que significa que nós (como um servidor) não sabemos o tamanho do conteúdo da resposta em bytes. No entanto, você deve usar o Transfer-Encoding: chunked apenas quando está enviando um conteúdo muito grande e não conhece o tamanho exato dele.

Há uma ligeira (mas notável) sobrecarga de performance com o chunked encoding ao enviar um conteúdo pequeno em pedaços ao cliente. No nosso caso, estamos apenas enviando uma pequena string "Hello, world!" de volta ao cliente.

Chunks, ah não!

A mensagem "Hello, world!" tem apenas 13 bytes de comprimento, então o seu tamanho em hexadecimal é D. Na verdade, estamos apenas enviando uma simples string de volta ao cliente, mas acaba que os dados sendo enviados possuem quase 2x o tamanho da string Hello, world!.

Aqui está como ficaria em chunked encoding:

D\r\n
Hello, world!\r\n

Cada chunk (pedaço) na resposta é precedido por seu tamanho no formato hexadecimal, seguido por \r\n (CRLF), e então outro \r\n à seguida dos dados. Isso significa que para cada pedaço, você tem os bytes extras para representar o comprimento e duas sequências CRLF.

Para indicar o fim de todos os pedaços, um chunk de comprimento zero é enviado:

0\r\n
\r\n

Então, o corpo completo da mensagem HTTP em chunked encoding seria:

D\r\n
Hello, world!\r\n
0\r\n
\r\n

Agora, vamos calcular o tamanho da resposta com Transfer-Encoding: chunked:

  • O tamanho do chunk em hexadecimal (D) = 1 byte
  • O primeiro CRLF (\r\n) depois do tamanho = 2 bytes
  • Os dados ("Hello, world!") = 13 bytes
  • O segundo CRLF (\r\n) depois dos dados = 2 bytes
  • O chunk de comprimento zero para indicar o fim (0) = 1 byte
  • O CRLF (\r\n) depois do chunk de comprimento zero = 2 bytes
  • O CRLF final (\r\n) para indicar o fim de todos os chunks = 2 bytes

No total, formam-se 23 bytes! Na verdade, você está enviando de volta 2x o tamanho do conteúdo, o que é bastante sobrecarga extra. Quanto maior for o seu conteúdo de resposta, menor será a sobrecarga.

Mas a maioria dos textos de resposta não precisam de chunked encoding. É útil para coisas que são grandes e quando você pode não conhecer o tamanho do conteúdo que está enviando, como um arquivo que está na AWS S3 ou um arquivo que você está baixando de um CDN externo. Chunked encoding seria um grande candidato para isso.

Especificando o Content-Length

Para se livrar do Transfer-Encoding: chunked, temos apenas que especificar o cabeçalho Content-Length, com o valor do conteúdo em bytes.

function handleRequest(request) {
    const { method, url } = request;

    let data = "";
    let statusCode = 200;
    let headers = {};

    if (method === "GET" && url === "/") {
        data = "Hello World!";
        headers["My-Header"] = "Hello World!";
    } else if (method === "POST" && url === "/echo") {
        statusCode = 201;
        data = "Yellow World!";
        headers["My-Header"] = "Yello World!";
    } else {
        statusCode = 404;
        data = "Not Found";
        headers["My-Header"] = "Not Found";
    }

    // define o cabeçalho content-length com o valor do comprimento dos dados (data) em bytes.
    headers["Content-Length"] = Buffer.byteLength(data);
    return { headers, data, statusCode };
}

A classe Buffer fornece um método auxiliar muito útil: Buffer.byteLength. Se você tentar fazer uma requisição usando o cURL, você verá o cabeçalho Content-Length e o Transfer-Encoding: chunked não estará lá. Perfeito.

curl http://localhost:5255 -v

# Output da requisição omitido
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< My-Header: Hello World!
< Content-Length: 12
< Date: Fri, 01 Sep 2023 17:25:08 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
<
* Connection #0 to host localhost left intact
Hello World!

Reusabilidade de Código

Ainda estamos a um passo de implementar nossa classe Router. Antes de fazer isso, eu gostaria de falar um pouco sobre manutenibilidade de código. A forma atual está boa, mas sairá de controle quando você estiver lidando com muitas rotas. Devemos sempre desenvolver nossos programas com escalabilidade e reutilidade em mente.

Vamos incluir um objeto global routeHandlers e então atualizar o nosso código de acordo, de uma forma que a gente possa adicionar novas rotas sem ter que mudar a função handleRequest.

// arquivo: index.js

const http = require("node:http");

const PORT = 5255;

const server = http.createServer((request, response) => {
    const { headers, data, statusCode } = handleRequest(request);
    response.writeHead(statusCode, headers);
    response.end(data);
});

// O cabeçalho que precisa ser enviado em cada resposta.
const baseHeader = {
    "Content-Type": "text/plain",
};

const routeHandlers = {
    "GET /": () => ({ statusCode: 200, data: "Hello World!", headers: { "My-Header": "Hello World!" } }),
    "POST /echo": () => ({ statusCode: 201, data: "Yellow World!", headers: { "My-Header": "Yellow World!" } }),
};

const handleRequest = ({ method, url }) => {
    const handler =
        routeHandlers[`${method} ${url}`] ||
        (() => ({ statusCode: 404, data: "Not Found", headers: { "My-Header": "Not Found" } }));

    const { statusCode, data } = handler();
    const headers = { ...baseHeader, "Content-Length": Buffer.byteLength(data) };

    return { headers, statusCode, data };
};

server.listen(PORT, () => {
    console.log(`Server is listening at :${PORT}`);
});

Vamos dar uma olhada em uma das partes mais estranhas:

// O `handler` armazena uma função com base no `método` HTTP de entrada e na `URL`.
 const handler =
        routeHandlers[`${method} ${url}`] ||
        (() => ({ statusCode: 404, data: "Not Found", headers: { "My-Header": "Not Found" } }));

Estamos procurando por uma chave no objeto routeHandlers, que corresponda ao método de entrada e a URL. Se a chave estiver disponível, vamos usar o valor dessa chave em routeHandlers, que na verdade é uma função. Se a chave não for encontrada, significa que não teremos um handler associado à uma combinação método e URL em particular, simplesmente atribuímos à variável handler uma função que retorna:

{ statusCode: 404, data: "Not Found", headers: { "My-Header": "Not Found" } }

Embora a gente não vá utilizar o código escrito acima em nosso Router, é importante entender o significado de um design melhor e de um planejamento prévio. Conforme o projeto cresce, é fácil cair na armadilha da falácia do custo irrecuperável. Essa falácia nos leva a insistir em nossa abordagem inicial simplesmente por causa do tempo e esforço já investidos.

Antes de mergulhar no seu código, tire algum tempo para pensar sobre como seu design poderá crescer e se adaptar facilmente durante a jornada. Confie em mim, isso vai salvar dores de cabeça mais tarde. Comece fazendo um protótipo básico ou alguns recursos. Então, quando as coisas começarem a crescer, é a indicação para se aprofundar na estrutura e design do seu programa.

Separando Preocupações

Nossos handlers de rota estão definidos como propriedades de um objeto routeHandlers. Se precisarmos adicionar suporte para mais métodos HTTP, podemos fazer isso sem alterar quaisquer partes do nosso código:

const routeHandlers = {
    "GET /": () => ({ statusCode: 200, data: "Hello World!", headers: { "My-Header": "Hello World!" } }),
    "POST /echo": () => ({ statusCode: 201, data: "Yellow World!", headers: { "My-Header": "Yellow World!" } }),
    "POST /accounts": () => ({ statusCode: 201, data: "Creating Account!", headers: { ... } }),
    // Adiciona mais métodos HTTP
};

Uma regra geral é - Suas funções devem fazer somente o que se propõem a fazer, ou o que dizem seus nomes. Isso é chamado de Princípio da Responsabilidade Única. Suponha que você tem uma função com a assinatura function add(x, y), ela deveria apenas somar dois números e nada mais. Um exemplo de código ruim que você não deveria fazer:

function add(x, y) {
    // Não soma x e y somente, mas também escreve no console, o que não é esperado.
    console.log(`Adding ${x} and ${y}`);

    // Performando uma operação de arquivo, o que definitivamente não é o esperado na função 'add'.
    const fs = require('fs');
    fs.writeFileSync('log.txt', `Adding ${x} and ${y}\n`, { flag: 'a+' });

    // Enviando uma requisição HTTP, o que está fora de escopo para uma função 'add'.
    const http = require('http');
    const data = JSON.stringify({ result: x + y });
    const options = {
        hostname: 'github.com',
        port: 80,
        path: '/api/add',
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Content-Length': data.length,
        },
    };

    const req = http.request(options);
    req.write(data);
    req.end();

    // Finalmente somando, que é o que a função deveria fazer.
    return x + y;
}

Porém, um bom exemplo ficaria mais ou menos assim:

// Essa função faz apenas o que diz: soma dois números.
function add(x, y) {
    return x + y;
}

// Uma função separada para imprimir a operação de soma.
function logAddition(x, y) {
    console.log(`Adding ${x} and ${y}`);
}

// Uma função separada para gravar a operação de soma em um arquivo.
function writeFileLog(x, y) {
    const fs = require('fs');
    fs.writeFileSync('log.txt', `Adding ${x} and ${y}\n`, { flag: 'a+' });
}

// Uma função separada para enviar o resultado da soma à uma API.
function sendAdditionToAPI(x, y) {
    const http = require('http');
    const data = JSON.stringify({ result: add(x, y) });
    const options = {
        hostname: 'example.com',
        port: 80,
        path: '/api/add',
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Content-Length': data.length,
        },
    };
    const req = http.request(options);
    req.write(data);
    req.end();
}

// Agora você pode compor essas funções de acordo com sua necessidade.

logAddition(3, 5);        // Imprime "Adding 3 and 5"
writeFileLog(3, 5);       // Grava "Adding 3 and 5" em 'log.txt'
sendAdditionToAPI(3, 5);  // Envia uma requisição POST com o resultado
console.log(add(3, 5));   // Imprime "8", o resultado da soma

Apesar de modularidade e Princípio da Responsabilidade Única serem geralmente boas práticas, exagerar nelas pode levar aos seus próprios conjuntos de problemas. Extrair cada pequeno pedaço de funcionalidade em sua própria função, ou módulo, pode tornar a base de código fragmentada e difícil de acompanhar. Isso às vezes é referido como "over-engineering" (engenharia em excesso).

Aqui estão algumas considerações gerais que costumo seguir:

  • Muitas Funções Pequenas: Se você acha que possui muitas funções que são utilizadas apenas uma vez e consistem somente em uma ou duas linhas, isso pode ser um exagero.

  • Alta Sobrecarga de Abstrações: Modularidade excessiva pode introduzir camadas desnecessárias de abstração, tornando o código menos direto e mais difícil de debugar.

  • Performance Reduzida: Apesar de compiladores e interpretadores modernos serem bons em tornar as coisas rápidas, se você tiver um monte de pequenas funções que são chamadas muitas vezes, isso pode adicionar sobrecarga extra se o compilador decidir não colocá-las inline.

Vamos pegar tudo que aprendemos até agora e aplicar em nossa classe Router no próximo capítulo.

Read Next