DEV Community 👩‍💻👨‍💻

Cover image for Webservers, Proxies, Load Balancers e Api Gateways.
André Guelfi Torres
André Guelfi Torres

Posted on

Webservers, Proxies, Load Balancers e Api Gateways.

Se você esta lendo esse post tem 99.90% de chance de você estar lendo ele através da internet, o outro 0.10% de chance você provavelmente é assinante do Internet Impressa, um serviço aonde você semanalmente recebe todas novidades da internet impressa por correio. Você também pode assinar a internet em video que te manda fitas VHS dos melhores videos da internet, ou se você quiser uma fita com um AMV do Rock Lee contra o Gaara ao som de Linkin Park. Acho que eles também tem o Internet em video XXX que você já sabe o que é.

Mas não estamos aqui para falar sobre esse grandioso serviço, na verdade esse post é o contrario, aqui nós vamos falar sobre como podemos servir nossas aplicações através da internet.

Você pode achar o repositório com o exemplo em: https://github.com/andre2w/webserver-post-examples

Webservers

Começando pelo mais básico nós temos que servir arquivos estáticos. Você vai lá escreve todo o HTML, CSS e JavaScript para um site, igual se fazia antigamente, quando a era tudo mato. Agora você precisa um jeito de servir esses arquivos.

Assim como a Internet Impressa uma grande parte da internet costumava ser arquivos estáticos que não mudavam programaticamente. Alguém tinha que ir lá e escrever o .html, o .css e o .js e esses arquivos eram enviados para o servidor e assim que você entrasse no site esses arquivos eram enviados para o seu browser do jeito que eles foram escritos.
Sem transpilador, sem polyfill, sem nenhuma alteração.

Mas agora precisamos servir esses arquivos para quem acessa o seu site, e não só isso é preciso diferenciar qual pagina você está acessando através do caminho na URL. Nós podemos fazer isso utilizando um Webserver.

Geralmente o Webservers serve os arquivos convertendo o caminho da url em uma estrutura de pastas. Então se você tentar acessar:

<http://localhost:3030/blog/posts/webserver.html>
Enter fullscreen mode Exit fullscreen mode

Um Webserver configurado para ter a pasta pages como a raiz vai procurar no seguinte caminho:

pages
+- blog
+-- posts
+--- webserver.html
Enter fullscreen mode Exit fullscreen mode

Web servers também serve para servir imagens e qualquer outro arquivo estático através de maneira eficiente. Então vamos criar um webserver básico em TypeScript só por motivos que podemos.

Começamos os arquivos que vamos servir, vamos começar com um index.html e um 404.html dentro de uma pasta chamada pages.

<!-- pages/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <h1>Hello, World!</h1>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode
<!-- pages/404.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    Pagina não encontrada!
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Agora vamos criar o nosso Webserver para servir essas duas paginas. Em src/index.ts nós criamos.

import http from "http";
import path from "path";
import fs from "fs";

const requestListener = (req: http.IncomingMessage, res: http.ServerResponse) => {
    if (req.url === "/") {
        const index = fs.readFileSync(path.join(__dirname, "..", "pages", "index.html"));
        res.writeHead(200); // Retorna o status code 200 - OK
        res.end(index);     // Envia o conteudo do arquivo index.html como resposta 
    } else {
        const notFound = fs.readFileSync(path.join(__dirname, "..", "pages", "404.html"));
        res.writeHead(404); // Retorna o status code 404 - Not Found
        res.end(notFound);
    }
};

const server = http.createServer(requestListener);
server.listen(3030);
Enter fullscreen mode Exit fullscreen mode

Agora se rodarmos essa aplicação e acessarmos [localhost:3030](http://localhost:3030) você vai ver o conteúdo de index.html e se for qualquer outra pagina você vera o conteúdo de 404.html. Agora precisamos mudar isso para servirmos outras paginas baseadas em nosso diretório pages.

import http from "http";
import path from "path";
import fs from "fs";

/**
 * Esse metodo tenta ler um arquivo baseado na url passada
 * se a url for /blog/posts/webserver.html ele vai tentar ler em um arquivo
 * em ./pages/blog/posts/webserver.html caso não encontre ele vai retornar
 * o conteudo de 404.html  
 * @param filePath 
 * @returns { status: number; content: Buffer }
 */
const serveFile = (filePath?: string) => {
    if (filePath === undefined || filePath === "/") {
        const index = fs.readFileSync(path.join(__dirname, "..", "pages", "index.html"));
        return { status: 200, content: index };
    }

    try {
        const parsedPath = filePath.split("/");
        const content = fs.readFileSync(path.join(__dirname, "..", "pages", ...parsedPath));
        return { status: 200, content };
    } catch {
        const notFound = fs.readFileSync(path.join(__dirname, "..", "pages", "404.html"));
        return { status: 404, content: notFound };
    }
}

const requestListener = (req: http.IncomingMessage, res: http.ServerResponse) => {
    const result = serveFile(req.url);
    res.writeHead(result.status);
    res.end(result.content);
};

const server = http.createServer(requestListener);
server.listen(3030);
Enter fullscreen mode Exit fullscreen mode

Agora qualquer estrutura de pastas que for criada sob o diretório pages pode ser servido como uma url.

cgi-bin e PHP

Provavelmente vai ter uma pessoa que começou a ler a parte sobre arquivos estáticos e começou a pensar:

E PHP? Ele usa o Apache que é um web server mas ele não tem paginas estáticas.

No caso do PHP ele modifica o webserver (não sei se já vem incluso hoje em dia) para chamar uma aplicação que vai gerar o html que ele vai servir. O webserver ainda está servido paginas estáticas mas a cada request uma pagina nova é gerada.

Com cgi-bin é a mesma coisa, você chama um script que gera a pagina baseada nos parâmetros que você passou para o script.

Se fossemos adicionar algo que emulasse um cgi-bin em nosso webserver precisaríamos fazer o seguinte:

  • Criar uma pasta para os scripts. Vamos chamar a pasta de scripts.
  • Depois adicionar o primeiro script em JavaScript que imprima um html.
// scripts/cgi-bin-test.js
console.log(`<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <h1>cgi-bin example</h1>
</body>
</html>`);
Enter fullscreen mode Exit fullscreen mode
  • Agora adicionamos o código para chamar o script no nosso webserver
import http from "http";
import path from "path";
import fs from "fs";
import { execSync } from "child_process";

interface Response {
    status: number;
    content: Buffer;
}

const notFound = () => {
    const notFound = fs.readFileSync(path.join(__dirname, "..", "pages", "404.html"));
    return { status: 404, content: notFound }; 
}

/**
 * Executa um arquivo .js dentro do folder scripts e retorna o stdout
 * como o conteudo da pagina.
 * 
 * Executa usando node.exe porque estou usando o windows e o execFileSync funciona 
 * através do cmd e não powershell 
 * @param scriptPath string
 * @returns Response
 */
const cgiBin = (scriptPath: string) => {
    const result = execSync(`node.exe ${path.join(__dirname, "..", "scripts", scriptPath)}`);
    return { status: 200, content: result }
}

/**
 * Mapeia uma url para a execução de um script com o metodo cgiBin
 */
const routeMap: Record<string, () => Response> = {
    "/cgi-bin": () => { return cgiBin("cgi-bin-test.js") }
}

/**
 * Tenta ler um arquivo dentro do diretorio pages 
 * @param filePath string
 * @returns Response
 */
const loadPage = (filePath: string) => {
    const parsedPath = filePath.split("/");
    const content = fs.readFileSync(path.join(__dirname, "..", "pages", ...parsedPath));
    return { status: 200, content };
}

/**
 * Esse metodo tenta ler um arquivo baseado na url passada
 * se a url for /blog/posts/webserver.html ele vai tentar ler em um arquivo
 * em ./pages/blog/posts/webserver.html caso não encontre ele vai retornar
 * o conteudo de 404.html  
 * @param filePath 
 * @returns { status: number; content: Buffer }
 */
const serveFile: (filepath?: string) => Response = (filePath) => {
    if (filePath === undefined || filePath === "/") {
                return loadPage("index.html");
    }

    try {

        if (routeMap[filePath] !== undefined) {
            return routeMap[filePath]();
        } else {
            return loadPage(filePath);
        }

    } catch {
        return notFound();
    }
}

const requestListener = (req: http.IncomingMessage, res: http.ServerResponse) => {
    const result = serveFile(req.url);
    res.writeHead(result.status);
    res.end(result.content);
};

const server = http.createServer(requestListener);
server.listen(3030);
Enter fullscreen mode Exit fullscreen mode

Reverse Proxy

Agora como que conseguimos usar um webserver com outas linguagens que não se integram com o webserver, tipo Java ou C#? Você não quer ficar chamando um .jar ou .dll toda hora, e essas linguagens possuem

Nesse caso podemos utilizar um Reverse Proxy. O trabalho do proxy é receber os requests e repassar eles para a aplicação correta. Esse tipo de proxy é muito utilizado quando você tem varias aplicações no mesmo servidor. Você pode redirecionar baseado em informações como:

  • headers
  • url
  • domínio
  • cookies

Outra vantagem do proxy reverso é que você reduz o numero de portas expostas em seu servidor já que nesse caso você pode ter múltiplas aplicações acessíveis através da porta 80 e 443

+------------------------------------------+
| Server A                 +-:3031-+       |
|                    +-----| APP 1 |       |
|     +-:80---+      |     +-------+       |
|     | Proxy |------+                     |
|     +-:443--+      |     +-:3032-+       |
|                    +-----| APP 2 |       |
|                          +-------+       |
+------------------------------------------+

Enter fullscreen mode Exit fullscreen mode

Como pode se ver no "diagrama" acima, temos um servidor rodando duas aplicações expondo as portas 80 e 443 que são para HTTP e HTTPS e conectando em duas aplicações. Nesse caso as portas 3031 e 3032 não precisam ser exposta.

E se quisermos fazer um reverse proxy para ir com o nosso webserver?

  • A primeira coisa é que temos que fazer requests para o nosso webserver, então vamos implementar um cliente http
// src/http-client.ts
import http from "http";

interface GetResultProps {
    status: number;
    body: string;
}

interface GetProps {
    path: string;
    port: number;
    callback: (result: GetResultProps) => void;
}

export function get({ path, port, callback }: GetProps): void {
    let body = "";
    http.get({ path, port }, (res) => {
        res.on('data', chunk => {
            body += chunk;
        });

        res.on('close', () => callback({ status: res.statusCode!, body }) );
    });
}
Enter fullscreen mode Exit fullscreen mode
  • Agora implementamos o proxy como um webserver, mas ao invés de ler arquivos ele vai fazer um request para o webserver.
import http from "http";
import { get } from "./http-client";

/**
 * Cria um mapeamento de uma rota para uma porta.
 * Nesse caso esperamos que todas as rotas serão mapeadas localmente
 */
const portMap: Record<string, number> = {
    "/blog": 3030
}

const requestListener = (req: http.IncomingMessage, res: http.ServerResponse) => {

    /**
     * Busca no mapeamento se a rota atual esta mapeada para alguma porta. 
     * Ele verifica o inicio da rota, então uma rota como:
     *                    /blog/posts/webserver.ts 
     * será mapeada para a porta 3030 como foi configurado em portMap. 
     */
    const destination = Object.entries(portMap).find(entry => req.url?.startsWith(entry[0]));

    if (destination) {
        get({
            path: req.url!,
            port: destination[1],
            callback: result => {
                res.writeHead(result.status);
                res.end(result.body);
            }
        })
    } else {
        res.writeHead(404);
        res.end("Rota não configurada");
    }
};

const server = http.createServer(requestListener);
server.listen(3031);
Enter fullscreen mode Exit fullscreen mode

Refactor Time

Eu fiz algumas alterações no código para ficar mais fácil de usa-lo. Algumas das coisas que mudei:

  • Adicionei classes para o Webserver e LoadBalancer para ficar mais simples de configurar outras instancias.
  • Adicionei a opção de passar a porta do webserver como argumento quando rodando pelo yarn webserver <porta>.

Load Balancer

Talvez você não tenha varias aplicações rodando em uma maquina, muito pelo contrario, um misero servidor não é o suficiente para a sua aplicação, você criou a próxima rede social de fake news e o sucesso está cobrando o seu preço.

Como ter varias maquinas rodando uma aplicação tudo através do mesmo endpoint? Você já sabe que um proxy reverso poderia fazer isso, mas tem um problema. O proxy reverso direciona baseado em algum valor do request logo ele não funcionaria corretamente.
É ai que entra os Load Balancers, eles funcionam como um proxy reverso mas ao invés de utilizar as informações do request para definir para qual servidor ele vai redirecionar ele usa outro tipo de informação para redirecionar para um servidor contendo a aplicação.

Então vamos começar escrevendo o Load Balancer sem nenhuma estratégia de balanceamento.

import http, { IncomingMessage } from "http";
import { get, GetResultProps } from "../http-client";

interface Backend {
    host?: string;
    port: number;
}

interface Strategy {
    getServer: () => Backend;
    onConnectionClose(backend: Backend): void;
}

class LoadBalancer {

    constructor(private readonly port: number, private readonly strategy: Strategy) {}

    start() {

        const requestListener = (req: http.IncomingMessage, res: http.ServerResponse) => {
            const backend = this.strategy.getServer();
            const callback = (result: GetResultProps) => {
                res.writeHead(result.status);
                res.end(result.body);
                  this.strategy.onConnectionClose(backend);
            };

            console.log(`Enviando request para ${JSON.stringify(backend)}`);
            this.makeRequest(backend, req, callback);
        }

        console.log(`Iniciando Load Balancer na porta ${this.port}`);
        const server = http.createServer(requestListener);
        server.listen(this.port);
    }

    private makeRequest(backend: Backend, originalRequest: IncomingMessage, callback: (result: GetResultProps) => void) {
        get({
            headers: originalRequest.headers,
            path: originalRequest.url!,
            port: backend.port,
            host: backend.host,
            callback 
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

A partir desse código podemos começar a implementar nossas estratégias.

Estratégias de Balanceamento

Para balancear a carga entre os servidores, os Load Balancers vem com varias estratégias de balanceamento, Aqui temos algumas das mais usadas.

Round Robin

Qual seria a coisa mais fácil a se fazer para distribuir os requests entre as maquinas? Que tal você mandar um request para cada maquina sequencialmente? Round Robin é basicamente isso, o Load Balancer manda um request para cada servidor listado no Load Balancer em forma sequencial.

Para implementar o balanceamento com Round Robin é bem simples:

class RoundRobin implements Strategy {
    private index = -1; 

    constructor(private readonly backends: Backend[]) {}

    getServer(): Backend {
        this.index++;
        if (this.index >= this.backends.length) {
            this.index = 0;
        }

        return this.backends[this.index];
    }    
}

// Agora iniciamos o LoadBalancer com ele
// Na porta 3031 e 3032 está rodando o webserver.
const roundRobin = new RoundRobin([
    { port: 3031 }, { port: 3032 }
]);

new LoadBalancer(3030, roundRobin).start();
Enter fullscreen mode Exit fullscreen mode

Least Connections

Você deve estar pensando que Round Robin é um algoritmo bem simplista. E sim, se as conexões em um servidor ficarem ativas por muito tempo em um servidor e o Load Balancer continuar enviando conexões o seu servidor vai acabar sobrecarregado e vai cair. Então uma maneira de resolver esse problema é configurar o Load Balancer para enviar os requests para o servidor que tem menos conexões ativas. O Load Balancer sabe qual é o servidor porque ele mantem a conexão aberta até receber uma resposta.

A implementação da estratégia do Least Connections seria assim:

class LeastConnections implements Strategy {

    /**
     * Cria um mapa com o numero de conexoes em cada backend
     */
    private readonly connections: Map<Backend, number>;

    constructor(backends: Backend[]) {
        if (backends.length === 0) {
            throw new Error("Não é possivel criar uma estratégia sem backends")
        }

       this.connections = new Map();
       backends.forEach(backend => this.connections.set(backend, 0));
    }

    getServer(): Backend {
        console.log(`Escolhendo uma conexao`);
        const backend = this.pickLeastConnectedBackend();
        console.log(`Backend utilizado ${JSON.stringify(backend)}`);
        this.connections.set(backend, this.connections.get(backend)! + 1);
        return backend;
    }

    /**
     * Seleciona o Backend com o menor numero de conexões comparando todos os backends
     * Não é demorado porque o numero de Backends é pequeno 
     * @returns Backend
     */
    private pickLeastConnectedBackend(): Backend{
        let selectedBackend: [Backend, number] | undefined;

        for (const entry of this.connections.entries()) {
            if (selectedBackend === undefined) {
                selectedBackend = entry;
            }

            if (entry[1] < selectedBackend[1]) {
                selectedBackend = entry;
            }
        }

        const backend = selectedBackend![0];
        return backend;
    }

    /**
     * Diminui o numero de conexões no backend  
     * @param backend Backend
     */
    onConnectionClose(backend: Backend) {
        this.connections.set(backend, this.connections.get(backend)! - 1);
    }
}

const leastConnections = new LeastConnections([
    { port: 3031 }, { port: 3032 }
]);

new LoadBalancer(3030, leastConnections).start();
Enter fullscreen mode Exit fullscreen mode

Agora para testar isso precisamos de uma maneira em que um dos webservers fique esperando, podemos fazer isso criando um novo script pro cgi-bin que vai esperar 10 segundos antes de responder.

// scripts/sleep.js

function sleep(milliseconds) {
 return new Promise(resolve => setTimeout(resolve, milliseconds));
}

const template = `<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <h1>Sleep Example</h1>
</body>
</html>`;

sleep(10000).then(() => console.log(template));
Enter fullscreen mode Exit fullscreen mode

E adicionamos o script no nosso webserver

const webserver = new Webserver({
    port,
    rootFolder: path.join(__dirname, "..", "..", "pages"),
    cgiBinRootFolder: path.join(__dirname, "..", "..", "scripts"),
    cgiBinMapping: {
        "/cgi-bin": "cgi-bin-test.js",
        "/sleep": "sleep.js"
    }
});

webserver.start();
Enter fullscreen mode Exit fullscreen mode

Agora se você for em seu navegador e em uma aba acessar [localhost:3030/sleep](http://localhost:3030/sleep) e em outra aba acessar [localhost:3030/blog/webserver.html](http://localhost:3030/blog/webserver.html) e ficar atualizando a pagina você verá que todos os requests para /blog/webserver.html estão indo para o servidor na porta 3032 pois a porta 3031 já tem um request.

Weighted Round Robin

Nem sempre os servidores que você vai distribuir as aplicações são iguais, você pode ter um servidor bom top da NASA e um Athlon 64 com 2 Gb de ram. Você sabe qual dos servidores deve receber mais requests. Nesse caso você pode usar uma estratégia chamada Weighted, que você pode atribuir um peso para o servidor e o Load Balancer vai dar preferencia ao servidores mais pesados.

Cada Load Balancer pode ter um algoritmo ou maneira diferente de atribuir o peso, mas para fins de entendermos como esse algoritmo funcionaria vamos pensar que temos 3 servidores. Um servidor maior e outros dois menores, os dois menores possuem a mesma configuração e você quer que o servidor maior receba metade dos requests.
Metade dos requests seria 50% (espero não estar errado), os outros 50% queremos dividir entre as duas maquinas, o que seria 25%.

Logo configuraríamos nosso Load Balancer assim:

import { Strategy, Backend } from "./strategy"

export type WeightedBackend = Backend & {
    weight: number
}

new WeightedRoundRobin([
  { port: 3031, weight: 20 },
  { port: 3032, weight: 80 }
]);
Enter fullscreen mode Exit fullscreen mode

Agora como o nosso Load Balancer pode enviar os requests considerando o peso de cada maquina? Bem nossa configuração soma um total de 100%. Então vamos remover a porcentagem para facilitar e só utilizar o 100. Você pode criar um array com 100 valores e distribuir os servidores nesse array baseado no peso. O resultado seria algo assim:

    0         1      2..48     49       50       51...73    74     75      76...98    99
[server1, server1, ..., ..., server1, server2, ..., ..., server2, server3, ..., ..., server3 ]

Enter fullscreen mode Exit fullscreen mode

Agora cada request recebido pelo Load Balancer um servidor é selecionado sequencialmente no array e quando chega no fim é só voltar para o inicio do array e continuar assim enquanto receber algum request. É claro que esse algoritmo é bem ingênuo e tem muito espaço para otimização, mas não é isso que queremos aqui. O objetivo é ver como a porta 3032 toma a maior parte do array e ira receber metade dos requests como esperado.

Para nossa implementação nós vamos fazer algo que precisa de menos espaço na memória e que seja um pouco mais tranquilo de configurar. Essa implementação permite você passar qualquer valor acima de 0 para o weight:

import { Strategy, Backend } from "./strategy"

export type WeightedBackend = Backend & {
    weight: number
}

export class WeightedRoundRobin implements Strategy {
    private index = -1; 
    private readonly backends: WeightedBackend[];
    private readonly totalWeight: number;

    /**
     * Transforma a propriedade weight do backend em um valor sequencial somando com valor anterior 
     * E calcula o peso total para utilizarmos como valor maximo do indice
     * @param backends WeightedBackend[]
     */ 
    constructor(backends: WeightedBackend[]) {
       this.backends = backends.map((backend, index) => {
           if (backend.weight <= 0) {
               throw new Error(`Weight tem que ser maior que 0, valor atual: ${backend.weight}`)
           }

           if (index === 0) {
               return backend
           } else {
               return {
                   ...backend,
                   weight: backend.weight + backends[index-1].weight
               };
           }
       });

       this.totalWeight = backends.reduce((acc, backend) => acc + backend.weight, 0)
    }

    getServer(): Backend {
        this.index++;
        if (this.index > this.totalWeight) {
            this.index = 0;
        }

        /**
         * Seleciona o backend em que o indice esteja dentro do weight 
         */
        return this.backends.reduce((acc, backend) => {
            if (this.index > acc.weight && this.index <= backend.weight) {
                return backend;
            } else {
                return acc;
            }
        }, { port: 0, weight: -1 });
    }    

    onConnectionClose(backend: Backend) {}
}
Enter fullscreen mode Exit fullscreen mode

Agora basta você fazer 30 requests para o Load Balancer e ver para qual servidor ele vai.

Resource Based:

Para encerrarmos temos o Resource Based, que vai checar a utilização de recursos de cada servidor, isso pode ser feito através de uma aplicação instalada no servidor aonde o Load Balancer fica observando os valores ou até mesmo por endpoints em http que são disponibilizados por quem opera o servidor.

Eu não vou implementar esse porque seria muito trabalho. Mas acredito que agora você deve ter uma ideia de como deve funcionar.

Não é só isso

É claro, há muitos outros tipos de algoritmos só que esses são os principais. Se você quiser saber mais sobre isso a Wikipedia em inglês tem uma lista de algoritmos. https://en.wikipedia.org/wiki/Load_balancing_(computing)

Norvana

A verdade é que quando estamos trabalhando com uma aplicação web nós precisamos combinar todas as funcionalidades que nós vimos para termos sistemas que são escaláveis e seguros. A maior parte dos softwares que fazem isso eles todas as funções juntas. Então nós temos que fazer o mesmo com o nosso código, precisamos unir todas as tribos assim como o Norvana fez.

import http, { IncomingMessage } from "http";
import path from "path";
import fs from "fs";
import { exec } from "child_process";
import { WebserverProps, Response } from "./webserver/webserver";
import { MappedRoute, RouteMapper } from "./reverse-proxy/reverse-proxy";
import { Backend, Strategy } from "./load-balancer/strategy";
import { get, GetResultProps } from "./http-client";
import { RoundRobin } from "./load-balancer/round-robin";
import { argv, exit } from "process";
import { LeastConnections } from "./load-balancer/least-connections";
import { WeightedRoundRobin } from "./load-balancer/weighted-round-robin";

type NorvanaProps = WebserverProps & {
    reverseProxy: Array<MappedRoute & {
        strategy: Strategy
    }>
};

class Norvana {
    private readonly routeMapper: RouteMapper<NorvanaProps['reverseProxy'][0]>;
    private readonly webserverProps: WebserverProps;
    private readonly port: number;

    constructor(norvanaProps: NorvanaProps) {
        this.routeMapper = new RouteMapper<NorvanaProps['reverseProxy'][0]>(norvanaProps.reverseProxy);
        this.webserverProps = norvanaProps;
        this.port = norvanaProps.port;
    }

    start() {
        const requestListener = (req: http.IncomingMessage, res: http.ServerResponse) => {
            /**
             * Faz o trabalho do proxy reverso e pega a rota necessaria
             */
            const destination = this.routeMapper.routeFor(req.url!);

            if (destination === undefined) {
                /**
                 * Serve arquivos estaticos assim como um webserver
                 */
                this.serveFile(response => {
                    res.writeHead(response.status, { 'Content-Type': 'text/html' });
                    res.end(response.content);
                }, req.url!);
                return;
            }

            /**
             * Faz o trabalho do load balancer e define o servidor
             */
            const backend = destination.strategy.getServer();

            const callback = (result: GetResultProps) => {
                res.writeHead(result.status, { 'Content-Type': 'text/html' });
                res.end(result.body);
                destination.strategy.onConnectionClose(backend);
            };

            /**
             * Faz o request para o servidor na rota correta e balanceado
             */
            this.makeRequest(backend, req, callback);
        }

        const server = http.createServer(requestListener);
        server.listen(this.port);
    }

    private makeRequest(backend: Backend, originalRequest: IncomingMessage, callback: (result: GetResultProps) => void) {
        get({
            headers: originalRequest.headers,
            path: originalRequest.url!,
            port: backend.port,
            host: backend.host,
            callback 
        });
    }

    /**
     * Esse metodo tenta ler um arquivo baseado na url passada
     * se a url for /blog/posts/webserver.html ele vai tentar ler em um arquivo
     * em ./pages/blog/posts/webserver.html caso não encontre ele vai retornar
     * o conteudo de 404.html  
     * @param filePath 
     * @returns { status: number; content: Buffer }
     */
    private serveFile(callback: (response: Response) => void, filePath: string): void {
        if (filePath === undefined || filePath === "/") {
            this.loadPage("index.html", callback);
        } else if (this.webserverProps.cgiBinMapping[filePath!] !== undefined) {
            this.cgiBin(this.webserverProps.cgiBinMapping[filePath], callback);
        } else {
            this.loadPage(filePath, callback);
        }
    }

    /**
     * Tenta ler um arquivo dentro do diretorio pages 
     * @param filePath string
     * @returns Response
     */
    private loadPage(filePath: string, callback: (response: Response) => void) {
        const parsedPath = filePath.split("/");
        fs.readFile(path.join(this.webserverProps.rootFolder, ...parsedPath), (err, data) => {
            if (err) {
                callback({ status: 500, content: Buffer.from("Alguma coisa errada não está correta") });
            } else {
                callback({ status: 200, content: data});
            }
        });
    }

    private cgiBin(scriptPath: string, callback: (response: Response) => void) {
        exec(`node.exe ${path.join(this.webserverProps.cgiBinRootFolder, scriptPath)}`, (err, stdout) => {
            if (err) {
                callback({ status: 500, content: Buffer.from(err.message) });
                return;
            }
            callback({ status: 200, content: Buffer.from(stdout) })
        });
    }
}

const port = Number.isInteger(parseInt(argv[2])) ? parseInt(argv[2]) : 3030;

const strategies: Record<string, Strategy> = {
    "round-robin": new RoundRobin([{ port: 3031 }, { port: 3032 }]),
    "least-connections": new LeastConnections([{ port: 3031 }, { port: 3032 }]),
    "weighted-round-robin": new WeightedRoundRobin([{ port: 3031, weight: 3 }, { port: 3032, weight: 22 }])
}

const strategyName = argv[3];
const strategy = strategies[strategyName];

if (strategy === undefined) {
    console.log(`Estrategia ${strategyName} é invalida, Por favor escolha uma das estrategias: ${Object.keys(strategies).join(", ")}`);
    exit(1);
}

new Norvana({
    port,
    rootFolder: path.join(__dirname, "..", "pages"),
    cgiBinRootFolder: path.join(__dirname, "..", "scripts"),
    cgiBinMapping: {
        "/cgi-bin": "cgi-bin-test.js",
        "/sleep": "sleep.js"
    },
    reverseProxy: [
        { matcher: /^\/blog/, port: 3031, strategy }
    ]
}).start();
Enter fullscreen mode Exit fullscreen mode

Você também pode ver a versão refatorada no repositório. Agora para testar essa monstruosidade você pode seguir os passos:

  • Abra três terminais
  • Inicie um webserver na porta 3031 em um dos terminais
  • Inicia um webserver na porta 3032 em outro terminal
  • E no terminal livre você pode iniciar o Norvana

Agora todos os requests para /blog/webserver.html ira pelo reverse proxy e load balancer, você pode notar isso através dos logs.

Agora os requests para /cgi-bin e /sleep serão servidos como um webserver pelo o norvana mesmo.

API Gateway

Por fim nós temos API Gateways, que pode ser considerado o pièce de entrée (eu juro que isso é um termo real, tipo pièce de résistance, mas nesse caso é a peça de entrada) no mundo da plataformização e microserviços. Quando você começa a criar muitos serviços coisas que antes eram mais triviais de se lidar começam a ficar mais complexo. Então algo que era implementado uma vez em um monólito tem que ser implementado para cada serviço, coisas como autenticação, autorização e limites de uso tem que ser implementado múltiplas vezes.

Uma ideia que pode vir a cabeça é implementar uma biblioteca que faça isso, só que bibliotécas não são tão simples assim, você ainda tem que escrever o código de integração e também você tem que manter a biblioteca em todas as aplicações, imagine se você tem mais de 1500 serviços assim como o Monzo na Inglaterra. Uma mudança na API da biblioteca pode causar grandes problemas de migração.

Então para resolver isso esse tipo de código é movido para outro lugar, nos exemplos que dei todos são coisas que queremos fazer antes de processar qualquer request, logo podemos mover isso para a entrada da plataforma. E o que temos na entrada da plataforma nesse caso? Bem provavelmente um proxy reverso, um web server ou balanceador de carga. Mas no caso deles nós nos preocupamos apenas em servir os requests, a unica lógica involvida é para saber qual servidor deve receber o request.

É ai que entra o API Gateway, ele serve para dar uma camada extra de lógica aonde podemos agrupar operações comuns que não estão relacionadas ao negócio em um único ponto. Agora todos os requests que chegam aos serviços já estão devidamente validados.

Síndrome de Não Inventado Aqui

A internet é um lugar perigoso, todas as ruas da internet tem dois caras numa moto esperando para você dar um vacilo. Nós implementamos algumas coisas aqui, você pode olhar e pensar "Isso não é tão difícil" esse tipo de pensamento chamado de Síndrome de Não Inventado Aqui aonde as pessoas acham que elas tem que desenvolver todo software que eles vão rodar.

Isso é um problema sério, qualquer software de infra-estrutura pode parecer simple mas na hora de implementar os detalhes e fazer o software seguro as coisas ficam muito difíceis. Então se um dia você se ver implementando qualquer uma das coisas que vemos aqui para colocar em produção e você não trabalhar para uma empresa de infra-estrutura, pare e procure ajuda.

Top comments (0)

We are hiring! Do you want to be our Senior Platform Engineer? Are you capable of chipping in across sysadmin, ops, and site reliability work, while supporting the open source stack that runs DEV and other communities?

This role might just be for you!

Apply now