Construindo um Servidor Web de Thread Única

Beginner

This tutorial is from open-source community. Access the source code

Introdução

Bem-vindo(a) a Construindo um Servidor Web de Thread Única. Este laboratório faz parte do Livro Rust. Você pode praticar suas habilidades em Rust no LabEx.

Neste laboratório, construiremos um servidor web de thread única que utiliza os protocolos HTTP e TCP para lidar com as requisições dos clientes e fornecer respostas.

Construindo um Servidor Web de Thread Única

Começaremos por fazer um servidor web de thread única funcionar. Antes de começarmos, vamos dar uma olhada em uma visão geral rápida dos protocolos envolvidos na construção de servidores web. Os detalhes desses protocolos estão além do escopo deste livro, mas uma breve visão geral fornecerá as informações necessárias.

Os dois principais protocolos envolvidos em servidores web são o Hypertext Transfer Protocol (HTTP) e o Transmission Control Protocol (TCP). Ambos os protocolos são protocolos de requisição-resposta (request-response), o que significa que um cliente inicia requisições e um servidor escuta as requisições e fornece uma resposta ao cliente. O conteúdo dessas requisições e respostas é definido pelos protocolos.

TCP é o protocolo de baixo nível que descreve os detalhes de como a informação chega de um servidor para outro, mas não especifica qual é essa informação. HTTP constrói em cima do TCP, definindo o conteúdo das requisições e respostas. É tecnicamente possível usar HTTP com outros protocolos, mas na grande maioria dos casos, HTTP envia seus dados via TCP. Trabalharemos com os bytes brutos das requisições e respostas TCP e HTTP.

Ouvindo a Conexão TCP

Nosso servidor web precisa ouvir uma conexão TCP, então essa é a primeira parte em que trabalharemos. A biblioteca padrão oferece um módulo std::net que nos permite fazer isso. Vamos criar um novo projeto da maneira usual:

$ cargo new hello
     Created binary (application) `hello` project
$ cd hello

Agora, insira o código na Listagem 20-1 em src/main.rs para começar. Este código ouvirá no endereço local 127.0.0.1:7878 por fluxos TCP de entrada. Quando receber um fluxo de entrada, ele imprimirá Connection established!.

Nome do arquivo: src/main.rs

use std::net::TcpListener;

fn main() {
  1 let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

  2 for stream in listener.incoming() {
      3 let stream = stream.unwrap();

      4 println!("Connection established!");
    }
}

Listagem 20-1: Ouvindo fluxos de entrada e imprimindo uma mensagem quando recebemos um fluxo

Usando TcpListener, podemos ouvir conexões TCP no endereço 127.0.0.1:7878 [1]. No endereço, a seção antes dos dois pontos é um endereço IP que representa seu computador (este é o mesmo em todos os computadores e não representa o computador dos autores especificamente), e 7878 é a porta. Escolhemos esta porta por duas razões: HTTP normalmente não é aceito nesta porta, então é improvável que nosso servidor entre em conflito com qualquer outro servidor web que você possa estar executando em sua máquina, e 7878 é rust digitado em um telefone.

A função bind neste cenário funciona como a função new, pois retornará uma nova instância de TcpListener. A função é chamada bind porque, em rede, conectar-se a uma porta para ouvir é conhecido como "vincular a uma porta".

A função bind retorna um Result<T, E>, o que indica que é possível que a vinculação falhe. Por exemplo, conectar-se à porta 80 requer privilégios de administrador (não administradores podem ouvir apenas em portas superiores a 1023), então, se tentássemos conectar-se à porta 80 sem ser um administrador, a vinculação não funcionaria. A vinculação também não funcionaria, por exemplo, se executássemos duas instâncias do nosso programa e, portanto, tivéssemos dois programas ouvindo na mesma porta. Como estamos escrevendo um servidor básico apenas para fins de aprendizado, não nos preocuparemos em lidar com esse tipo de erro; em vez disso, usamos unwrap para interromper o programa se ocorrerem erros.

O método incoming em TcpListener retorna um iterador que nos dá uma sequência de fluxos [2] (mais especificamente, fluxos do tipo TcpStream). Um único fluxo representa uma conexão aberta entre o cliente e o servidor. Uma conexão é o nome do processo completo de requisição e resposta no qual um cliente se conecta ao servidor, o servidor gera uma resposta e o servidor fecha a conexão. Como tal, leremos do TcpStream para ver o que o cliente enviou e, em seguida, escreveremos nossa resposta no fluxo para enviar dados de volta ao cliente. No geral, este loop for processará cada conexão por sua vez e produzirá uma série de fluxos para que possamos lidar.

Por enquanto, nosso tratamento do fluxo consiste em chamar unwrap para encerrar nosso programa se o fluxo tiver algum erro [3]; se não houver erros, o programa imprime uma mensagem [4]. Adicionaremos mais funcionalidade para o caso de sucesso na próxima listagem. A razão pela qual podemos receber erros do método incoming quando um cliente se conecta ao servidor é que, na verdade, não estamos iterando sobre conexões. Em vez disso, estamos iterando sobre tentativas de conexão. A conexão pode não ser bem-sucedida por vários motivos, muitos deles específicos do sistema operacional (OS). Por exemplo, muitos sistemas operacionais têm um limite para o número de conexões abertas simultâneas que podem suportar; novas tentativas de conexão além desse número produzirão um erro até que algumas das conexões abertas sejam fechadas.

Vamos tentar executar este código! Invoque cargo run no terminal e, em seguida, carregue 127.0.0.1:7878 em um navegador web. O navegador deve mostrar uma mensagem de erro como "Connection reset" porque o servidor não está enviando nenhum dado no momento. Mas quando você olha para o seu terminal, você deve ver várias mensagens que foram impressas quando o navegador se conectou ao servidor!

     Running `target/debug/hello`
Connection established!
Connection established!
Connection established!

Às vezes, você verá várias mensagens impressas para uma solicitação do navegador; a razão pode ser que o navegador está fazendo uma solicitação para a página, bem como uma solicitação para outros recursos, como o ícone favicon.ico que aparece na guia do navegador.

Também pode ser que o navegador esteja tentando se conectar ao servidor várias vezes porque o servidor não está respondendo com nenhum dado. Quando stream sai do escopo e é descartado no final do loop, a conexão é fechada como parte da implementação drop. Os navegadores às vezes lidam com conexões fechadas tentando novamente, porque o problema pode ser temporário. O fator importante é que obtivemos com sucesso um handle para uma conexão TCP!

Lembre-se de parar o programa pressionando ctrl-C quando terminar de executar uma versão específica do código. Em seguida, reinicie o programa invocando o comando cargo run depois de fazer cada conjunto de alterações no código para garantir que você esteja executando o código mais recente.

Lendo a Requisição

Vamos implementar a funcionalidade para ler a requisição do navegador! Para separar as preocupações de primeiro obter uma conexão e, em seguida, tomar alguma ação com a conexão, iniciaremos uma nova função para processar as conexões. Nesta nova função handle_connection, leremos dados do fluxo TCP e os imprimiremos para que possamos ver os dados sendo enviados do navegador. Altere o código para que se pareça com a Listagem 20-2.

Nome do arquivo: src/main.rs

1 use std::{
    io::{prelude::*, BufReader},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

      2 handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
  3 let buf_reader = BufReader::new(&mut stream);
  4 let http_request: Vec<_> = buf_reader
      5 .lines()
      6 .map(|result| result.unwrap())
      7 .take_while(|line| !line.is_empty())
        .collect();

  8 println!("Request: {:#?}", http_request);
}

Listagem 20-2: Lendo do TcpStream e imprimindo os dados

Importamos std::io::prelude e std::io::BufReader para o escopo para ter acesso a traits e tipos que nos permitem ler e escrever no fluxo [1]. No loop for na função main, em vez de imprimir uma mensagem que diz que fizemos uma conexão, agora chamamos a nova função handle_connection e passamos o stream para ela [2].

Na função handle_connection, criamos uma nova instância de BufReader que envolve uma referência mutável ao stream [3]. BufReader adiciona buffering gerenciando chamadas para os métodos da trait std::io::Read para nós.

Criamos uma variável chamada http_request para coletar as linhas da requisição que o navegador envia para nosso servidor. Indicamos que queremos coletar essas linhas em um vetor adicionando a anotação de tipo Vec<_> [4].

BufReader implementa a trait std::io::BufRead, que fornece o método lines [5]. O método lines retorna um iterador de Result<String, std::io::Error> dividindo o fluxo de dados sempre que vê um byte de nova linha. Para obter cada String, mapeamos e unwrap cada Result [6]. O Result pode ser um erro se os dados não forem UTF-8 válidos ou se houver um problema ao ler do fluxo. Novamente, um programa de produção deve lidar com esses erros de forma mais elegante, mas estamos optando por interromper o programa no caso de erro para simplificar.

O navegador sinaliza o fim de uma requisição HTTP enviando dois caracteres de nova linha em sequência, então, para obter uma requisição do fluxo, pegamos linhas até obter uma linha que seja a string vazia [7]. Depois de coletarmos as linhas no vetor, estamos imprimindo-as usando a formatação de depuração bonita [8] para que possamos dar uma olhada nas instruções que o navegador web está enviando para nosso servidor.

Vamos tentar este código! Inicie o programa e faça uma requisição em um navegador web novamente. Observe que ainda obteremos uma página de erro no navegador, mas a saída do nosso programa no terminal agora se parecerá com isto:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished dev [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/hello`
Request: [
    "GET / HTTP/1.1",
    "Host: 127.0.0.1:7878",
    "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0)
Gecko/20100101 Firefox/99.0",
    "Accept:
text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*
;q=0.8",
    "Accept-Language: en-US,en;q=0.5",
    "Accept-Encoding: gzip, deflate, br",
    "DNT: 1",
    "Connection: keep-alive",
    "Upgrade-Insecure-Requests: 1",
    "Sec-Fetch-Dest: document",
    "Sec-Fetch-Mode: navigate",
    "Sec-Fetch-Site: none",
    "Sec-Fetch-User: ?1",
    "Cache-Control: max-age=0",
]

Dependendo do seu navegador, você pode obter uma saída ligeiramente diferente. Agora que estamos imprimindo os dados da requisição, podemos ver por que obtemos várias conexões de uma requisição do navegador, olhando para o caminho após GET na primeira linha da requisição. Se as conexões repetidas estiverem todas solicitando / , sabemos que o navegador está tentando buscar / repetidamente porque não está recebendo uma resposta do nosso programa.

Vamos analisar esses dados de requisição para entender o que o navegador está pedindo ao nosso programa.

Uma Análise Mais Detalhada de uma Requisição HTTP

HTTP é um protocolo baseado em texto, e uma requisição tem este formato:

Método Request-URI HTTP-Version CRLF
headers CRLF
message-body

A primeira linha é a linha de requisição que contém informações sobre o que o cliente está solicitando. A primeira parte da linha de requisição indica o método que está sendo usado, como GET ou POST, que descreve como o cliente está fazendo esta requisição. Nosso cliente usou uma requisição GET, o que significa que está pedindo informações.

A próxima parte da linha de requisição é /, que indica o identificador uniforme de recurso (URI) que o cliente está solicitando: um URI é quase, mas não exatamente, o mesmo que um localizador uniforme de recurso (URL). A diferença entre URIs e URLs não é importante para nossos propósitos neste capítulo, mas a especificação HTTP usa o termo URI, então podemos apenas substituir mentalmente URL por URI aqui.

A última parte é a versão HTTP que o cliente usa, e então a linha de requisição termina em uma sequência CRLF. (CRLF significa retorno de carro e avanço de linha, que são termos dos tempos da máquina de escrever!) A sequência CRLF também pode ser escrita como \r\n, onde \r é um retorno de carro e \n é um avanço de linha. A sequência CRLF separa a linha de requisição do restante dos dados da requisição. Observe que, quando o CRLF é impresso, vemos o início de uma nova linha em vez de \r\n.

Olhando para os dados da linha de requisição que recebemos ao executar nosso programa até agora, vemos que GET é o método, / é o URI da requisição e HTTP/1.1 é a versão.

Após a linha de requisição, as linhas restantes, começando por Host:, são cabeçalhos (headers). Requisições GET não têm corpo.

Tente fazer uma requisição de um navegador diferente ou solicitar um endereço diferente, como 127.0.0.1:7878/test, para ver como os dados da requisição mudam.

Agora que sabemos o que o navegador está pedindo, vamos enviar alguns dados de volta!

Escrevendo uma Resposta

Vamos implementar o envio de dados em resposta a uma requisição do cliente. As respostas têm o seguinte formato:

HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body

A primeira linha é uma linha de status que contém a versão HTTP usada na resposta, um código de status numérico que resume o resultado da requisição e uma frase de motivo que fornece uma descrição textual do código de status. Após a sequência CRLF, vêm quaisquer cabeçalhos (headers), outra sequência CRLF e o corpo da resposta.

Aqui está um exemplo de resposta que usa a versão HTTP 1.1 e tem um código de status 200, uma frase de motivo OK, sem cabeçalhos e sem corpo:

HTTP/1.1 200 OK\r\n\r\n

O código de status 200 é a resposta de sucesso padrão. O texto é uma pequena resposta HTTP bem-sucedida. Vamos escrever isso no fluxo como nossa resposta a uma requisição bem-sucedida! Da função handle_connection, remova o println! que estava imprimindo os dados da requisição e substitua-o pelo código na Listagem 20-3.

Nome do arquivo: src/main.rs

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&mut stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

  1 let response = "HTTP/1.1 200 OK\r\n\r\n";

  2 stream.write_all(response.3 as_bytes()).unwrap();
}

Listagem 20-3: Escrevendo uma pequena resposta HTTP bem-sucedida no fluxo

A primeira linha nova define a variável response que contém os dados da mensagem de sucesso [1]. Em seguida, chamamos as_bytes em nossa response para converter os dados da string em bytes [3]. O método write_all em stream recebe um &[u8] e envia esses bytes diretamente pela conexão [2]. Como a operação write_all pode falhar, usamos unwrap em qualquer resultado de erro como antes. Novamente, em uma aplicação real, você adicionaria tratamento de erros aqui.

Com essas alterações, vamos executar nosso código e fazer uma requisição. Não estamos mais imprimindo nenhum dado no terminal, então não veremos nenhuma saída além da saída do Cargo. Quando você carregar 127.0.0.1:7878 em um navegador web, você deverá obter uma página em branco em vez de um erro. Você acabou de codificar manualmente a recepção de uma requisição HTTP e o envio de uma resposta!

Retornando HTML Real

Vamos implementar a funcionalidade para retornar mais do que uma página em branco. Crie o novo arquivo hello.html na raiz do diretório do seu projeto, não no diretório src. Você pode inserir qualquer HTML que desejar; a Listagem 20-4 mostra uma possibilidade.

Nome do arquivo: hello.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Hello!</title>
  </head>
  <body>
    <h1>Hello!</h1>
    <p>Hi from Rust</p>
  </body>
</html>

Listagem 20-4: Um arquivo HTML de exemplo para retornar em uma resposta

Este é um documento HTML5 mínimo com um cabeçalho e algum texto. Para retornar isso do servidor quando uma requisição for recebida, modificaremos handle_connection conforme mostrado na Listagem 20-5 para ler o arquivo HTML, adicioná-lo à resposta como um corpo e enviá-lo.

Nome do arquivo: src/main.rs

use std::{
  1 fs,
    io::{prelude::*, BufReader},
    net::{TcpListener, TcpStream},
};
--snip--

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&mut stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let status_line = "HTTP/1.1 200 OK";
    let contents = fs::read_to_string("hello.html").unwrap();
    let length = contents.len();

  2 let response = format!(
        "{status_line}\r\n\
         Content-Length: {length}\r\n\r\n\
         {contents}"
    );

    stream.write_all(response.as_bytes()).unwrap();
}

Listagem 20-5: Enviando o conteúdo de hello.html como o corpo da resposta

Adicionamos fs à instrução use para trazer o módulo do sistema de arquivos da biblioteca padrão para o escopo [1]. O código para ler o conteúdo de um arquivo para uma string deve parecer familiar; nós o usamos quando lemos o conteúdo de um arquivo para nosso projeto de E/S na Listagem 12-4.

Em seguida, usamos format! para adicionar o conteúdo do arquivo como o corpo da resposta de sucesso [2]. Para garantir uma resposta HTTP válida, adicionamos o cabeçalho Content-Length, que é definido como o tamanho do corpo da nossa resposta, neste caso, o tamanho de hello.html.

Execute este código com cargo run e carregue 127.0.0.1:7878 em seu navegador; você deve ver seu HTML renderizado!

Atualmente, estamos ignorando os dados da requisição em http_request e apenas enviando de volta o conteúdo do arquivo HTML incondicionalmente. Isso significa que, se você tentar solicitar 127.0.0.1:7878/something-else em seu navegador, ainda receberá a mesma resposta HTML. No momento, nosso servidor é muito limitado e não faz o que a maioria dos servidores web faz. Queremos personalizar nossas respostas dependendo da requisição e enviar de volta o arquivo HTML apenas para uma requisição bem formada para /.

Validando a Requisição e Respondendo Seletivamente

No momento, nosso servidor web retornará o HTML no arquivo, independentemente do que o cliente solicitou. Vamos adicionar funcionalidade para verificar se o navegador está solicitando / antes de retornar o arquivo HTML e retornar um erro se o navegador solicitar qualquer outra coisa. Para isso, precisamos modificar handle_connection, conforme mostrado na Listagem 20-6. Este novo código verifica o conteúdo da requisição recebida em relação ao que sabemos que uma requisição para / se parece e adiciona blocos if e else para tratar as requisições de forma diferente.

Nome do arquivo: src/main.rs

--snip--

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&mut stream);
  1 let request_line = buf_reader
        .lines()
        .next()
        .unwrap()
        .unwrap();

  2 if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("hello.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\n\
             Content-Length: {length}\r\n\r\n\
             {contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
  3 } else {
        // some other request
    }
}

Listagem 20-6: Tratando requisições para / de forma diferente de outras requisições

Vamos analisar apenas a primeira linha da requisição HTTP, então, em vez de ler toda a requisição em um vetor, estamos chamando next para obter o primeiro item do iterador [1]. O primeiro unwrap cuida do Option e interrompe o programa se o iterador não tiver itens. O segundo unwrap lida com o Result e tem o mesmo efeito do unwrap que estava no map adicionado na Listagem 20-2.

Em seguida, verificamos a request_line para ver se ela é igual à linha de requisição de uma requisição GET para o caminho / [2]. Se for, o bloco if retorna o conteúdo do nosso arquivo HTML.

Se a request_line não for igual à requisição GET para o caminho /, significa que recebemos alguma outra requisição. Adicionaremos código ao bloco else [3] em um momento para responder a todas as outras requisições.

Execute este código agora e solicite 127.0.0.1:7878; você deve obter o HTML em hello.html. Se você fizer qualquer outra requisição, como 127.0.0.1:7878/something-else, você receberá um erro de conexão como aqueles que você viu ao executar o código na Listagem 20-1 e na Listagem 20-2.

Agora, vamos adicionar o código na Listagem 20-7 ao bloco else para retornar uma resposta com o código de status 404, que sinaliza que o conteúdo para a requisição não foi encontrado. Também retornaremos algum HTML para uma página renderizar no navegador, indicando a resposta ao usuário final.

Nome do arquivo: src/main.rs

--snip--
} else {
  1 let status_line = "HTTP/1.1 404 NOT FOUND";
  2 let contents = fs::read_to_string("404.html").unwrap();
    let length = contents.len();

    let response = format!(
        "{status_line}\r\n\
         Content-Length: {length}\r\n\r\n
         {contents}"
    );

    stream.write_all(response.as_bytes()).unwrap();
}

Listagem 20-7: Respondendo com o código de status 404 e uma página de erro se qualquer coisa diferente de / for solicitada

Aqui, nossa resposta tem uma linha de status com o código de status 404 e a frase de motivo NOT FOUND [1]. O corpo da resposta será o HTML no arquivo 404.html [1]. Você precisará criar um arquivo 404.html ao lado de hello.html para a página de erro; novamente, sinta-se à vontade para usar qualquer HTML que desejar ou use o HTML de exemplo na Listagem 20-8.

Nome do arquivo: 404.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Hello!</title>
  </head>
  <body>
    <h1>Oops!</h1>
    <p>Sorry, I don't know what you're asking for.</p>
  </body>
</html>

Listagem 20-8: Conteúdo de exemplo para a página a ser enviada com qualquer resposta 404

Com essas alterações, execute seu servidor novamente. Solicitar 127.0.0.1:7878 deve retornar o conteúdo de hello.html, e qualquer outra requisição, como 127.0.0.1:7878/foo, deve retornar o HTML de erro de 404.html.

Um Toque de Refatoração

No momento, os blocos if e else têm muita repetição: ambos estão lendo arquivos e escrevendo o conteúdo dos arquivos no fluxo. As únicas diferenças são a linha de status e o nome do arquivo. Vamos tornar o código mais conciso, extraindo essas diferenças em linhas if e else separadas que atribuirão os valores da linha de status e do nome do arquivo a variáveis; então, podemos usar essas variáveis incondicionalmente no código para ler o arquivo e escrever a resposta. A Listagem 20-9 mostra o código resultante após substituir os grandes blocos if e else.

Nome do arquivo: src/main.rs

--snip--

fn handle_connection(mut stream: TcpStream) {
    --snip--

    let (status_line, filename) =
        if request_line == "GET / HTTP/1.1" {
            ("HTTP/1.1 200 OK", "hello.html")
        } else {
            ("HTTP/1.1 404 NOT FOUND", "404.html")
        };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response = format!(
        "{status_line}\r\n\
         Content-Length: {length}\r\n\r\n\
         {contents}"
    );

    stream.write_all(response.as_bytes()).unwrap();
}

Listagem 20-9: Refatorando os blocos if e else para conter apenas o código que difere entre os dois casos

Agora, os blocos if e else retornam apenas os valores apropriados para a linha de status e o nome do arquivo em uma tupla; então, usamos a desestruturação para atribuir esses dois valores a status_line e filename usando um padrão na instrução let, conforme discutido no Capítulo 18.

O código duplicado anteriormente agora está fora dos blocos if e else e usa as variáveis status_line e filename. Isso torna mais fácil ver a diferença entre os dois casos e significa que temos apenas um lugar para atualizar o código se quisermos alterar como a leitura do arquivo e a escrita da resposta funcionam. O comportamento do código na Listagem 20-9 será o mesmo que o da Listagem 20-8.

Incrível! Agora temos um servidor web simples em aproximadamente 40 linhas de código Rust que responde a uma requisição com uma página de conteúdo e responde a todas as outras requisições com uma resposta 404.

Atualmente, nosso servidor é executado em um único thread, o que significa que ele só pode atender a uma requisição por vez. Vamos examinar como isso pode ser um problema simulando algumas requisições lentas. Então, vamos corrigi-lo para que nosso servidor possa lidar com várias requisições de uma vez.

Resumo

Parabéns! Você concluiu o laboratório Construindo um Servidor Web de Single-Thread. Você pode praticar mais laboratórios no LabEx para aprimorar suas habilidades.