O que realmente trafega entre um navegador e um servidor, um primeiro servidor de trinta linhas que já responde ao curl, três formas de derrubá-lo e a arquitetura do Farol, o servidor HTTP que as próximas aulas vão construir só com o JDK.
Quarenta e cinco aulas atrás, o primeiro programa deste curso imprimiu uma linha no terminal. Desde então, você aprendeu a linguagem, a orientação a objetos, as coleções, os streams, as exceções, os arquivos, os testes, a JVM por dentro e a concorrência. A Fase 9 junta tudo isso num único projeto: um servidor HTTP, o programa que está do outro lado de cada página que um navegador abre, escrito do zero, sobre sockets, sem nenhuma biblioteca além do JDK. O servidor vai se chamar Farol, como o que guia os barcos até uma ilha, e vai servir as páginas e os dados de verdade para um navegador. A escolha tem um propósito: todo framework web, do Spring ao Quarkus que a Trilha A apresenta, faz exatamente o que o Farol vai fazer, só que escondido. Depois de construí-lo, nenhum deles será uma caixa-preta.
Esta primeira aula não escreve o servidor definitivo. Ela olha de perto o protocolo, monta um rascunho ingênuo que funciona, mostra como derrubá-lo e desenha a arquitetura que as próximas aulas vão construir.
Um protocolo feito de texto
O HTTP (Hypertext Transfer Protocol) nasceu entre 1989 e 1991, no CERN, na Suíça, criado por Tim Berners-Lee junto com a própria World Wide Web: o HTML para escrever páginas, as URLs para localizá-las e o HTTP para transferi-las. A primeira versão, depois chamada de HTTP/0.9, tinha um único comando, GET, e a resposta era o documento, sem nenhuma informação adicional. O HTTP/1.0, documentado em 1996 na RFC 1945, acrescentou cabeçalhos, códigos de estado e outros métodos. O HTTP/1.1 veio em 1997, com a RFC 2068, revisada em 1999 na RFC 2616, e trouxe as conexões persistentes e o cabeçalho Host, que permite a um mesmo servidor hospedar vários sites. É a versão que o Farol vai falar. Sua especificação atual é de 2022, dividida entre a RFC 9110, sobre a semântica comum a todas as versões, e a RFC 9112, sobre o formato das mensagens do HTTP/1.1.
Existem versões mais novas: o HTTP/2, de 2015, que troca o texto por quadros binários e multiplexa várias requisições numa conexão, e o HTTP/3, de 2022, que troca o TCP pelo QUIC, sobre UDP. Mas o HTTP/1.1 continua sendo usado em toda parte, especialmente entre servidores e por trás de proxies, e tem uma característica preciosa para quem está aprendendo: ele é texto. Uma requisição HTTP/1.1 pode ser lida por uma pessoa.
Por baixo do HTTP está o TCP, o protocolo que garante que os bytes enviados cheguem ao outro lado, na ordem, sem perdas. Um programa acessa uma conexão TCP por meio de um socket, uma abstração criada no Unix de Berkeley no início dos anos 1980 e presente em todos os sistemas operacionais. Em Java, o socket existe desde o Java 1.0, no pacote java.net: a classe ServerSocket espera conexões numa porta, um número de 0 a 65535 que identifica o programa dentro da máquina, e cada conexão aceita vira um Socket, com um InputStream para ler o que o cliente manda e um OutputStream para responder. São os mesmos fluxos de bytes de "Recursos que se Fecham Sozinhos", com a diferença de que, do outro lado, não há um arquivo, e sim outro programa.
Espiando uma requisição de verdade
A melhor forma de entender o protocolo é ver os bytes. O programa abaixo espera uma conexão na porta 8080 e mostra exatamente o que o cliente enviou, com as quebras de linha visíveis:
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
// Aceita UMA conexão e mostra, byte a byte, o que o cliente mandou
void main() throws IOException {
try (ServerSocket servidor = new ServerSocket(8080)) {
IO.println("esperando em http://localhost:8080 ...");
try (Socket conexao = servidor.accept()) {
conexao.setSoTimeout(1000);
InputStream entrada = conexao.getInputStream();
ByteArrayOutputStream recebido = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
try {
int lidos;
while ((lidos = entrada.read(buffer)) != -1) recebido.write(buffer, 0, lidos);
} catch (SocketTimeoutException e) {
// o cliente parou de mandar e ficou esperando a resposta
}
String texto = recebido.toString(StandardCharsets.ISO_8859_1);
IO.println(texto.replace("\r", "\\r").replace("\n", "\\n\n"));
}
}
}
O accept() bloqueia até um cliente se conectar. O setSoTimeout faz cada read desistir depois de um segundo sem dados, e isso é necessário por um motivo que vai atravessar todo o projeto: o cliente não fecha a conexão depois de enviar o pedido, porque fica esperando a resposta. Um read sem prazo esperaria para sempre. Com o espião rodando, noutro terminal, uma requisição com o curl, a ferramenta de linha de comando para HTTP:
$ curl -X POST -H "Content-Type: application/json" \
-d '{"titulo":"Estudar sockets"}' "http://localhost:8080/tarefas?lista=casa"
O espião mostra:
esperando em http://localhost:8080 ...
POST /tarefas?lista=casa HTTP/1.1\r\n
Host: localhost:8080\r\n
User-Agent: curl/8.5.0\r\n
Accept: */*\r\n
Content-Type: application/json\r\n
Content-Length: 28\r\n
\r\n
{"titulo":"Estudar sockets"}
Isso é uma requisição HTTP inteira. Sem mistério: linhas de texto.
A anatomia de uma requisição
A requisição tem quatro partes, e o formato é rígido.
A linha de pedido (request line) tem três campos separados por um espaço: o método, POST, que diz o que o cliente quer fazer; o alvo, /tarefas?lista=casa, com o caminho do recurso e, depois do ?, a query string com parâmetros; e a versão do protocolo, HTTP/1.1. Os métodos mais usados são GET para obter um recurso, POST para enviar dados que criam algo, PUT para substituir um recurso, DELETE para removê-lo e HEAD, que é um GET sem o corpo da resposta.
Depois vêm os cabeçalhos (headers), um por linha, no formato Nome: valor. O Host é obrigatório no HTTP/1.1. O Content-Type diz o formato do corpo, e o Content-Length diz o seu tamanho em bytes. Os nomes de cabeçalho não diferenciam maiúsculas de minúsculas: content-length e Content-Length são o mesmo cabeçalho.
Uma linha vazia marca o fim dos cabeçalhos. E, depois dela, vem o corpo (body), opcional, com exatamente Content-Length bytes. É o único jeito de o servidor saber onde o corpo termina, porque a conexão continua aberta: sem o tamanho, ele não teria como distinguir "o cliente terminou de mandar" de "o cliente ainda vai mandar mais".
Dois detalhes do formato fazem diferença na implementação. As linhas terminam com dois caracteres, \r\n, o retorno de carro e a quebra de linha, herança dos terminais de teletipo, chamados em conjunto de CRLF. E a parte de texto da mensagem, a linha de pedido e os cabeçalhos, é definida em termos de bytes, tradicionalmente interpretados como ISO-8859-1, uma codificação em que cada byte corresponde a exatamente um caractere. O corpo, por outro lado, é uma sequência de bytes qualquer: pode ser JSON em UTF-8, uma imagem PNG ou um arquivo ZIP. Por isso o espião usou ISO-8859-1: nenhum byte se perde na conversão, seja ele o que for.
A anatomia de uma resposta
A resposta tem a mesma estrutura, trocando a linha de pedido pela linha de estado (status line): a versão, um código de estado de três dígitos e uma frase explicativa, como em HTTP/1.1 200 OK. O primeiro dígito do código diz a categoria: 2xx é sucesso (200 OK, 201 Created, 204 No Content), 3xx é redirecionamento (301 Moved Permanently, 304 Not Modified), 4xx é erro do cliente (400 Bad Request, 404 Not Found, 405 Method Not Allowed, 413 Content Too Large) e 5xx é erro do servidor (500 Internal Server Error, 503 Service Unavailable). A frase é só para humanos; os programas olham o número.
Depois vêm os cabeçalhos da resposta, a linha vazia e o corpo. O cabeçalho Content-Type diz ao navegador o que fazer com o corpo, se desenhar como HTML, mostrar como imagem ou oferecer como download, e o Content-Length, de novo, diz onde ele termina.
Um rascunho que funciona
Com isso, já dá para escrever um servidor. Este é deliberadamente ingênuo:
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.time.LocalTime;
// O servidor HTTP mais simples possível: ingênuo de propósito
void main() throws IOException {
try (ServerSocket servidor = new ServerSocket(8080)) {
IO.println("Farol (rascunho) em http://localhost:8080");
while (true) {
try (Socket conexao = servidor.accept()) {
var leitor = new BufferedReader(new InputStreamReader(conexao.getInputStream(), StandardCharsets.ISO_8859_1));
String linhaDePedido = leitor.readLine(); // "GET /algo HTTP/1.1"
String linha;
while ((linha = leitor.readLine()) != null && !linha.isEmpty()) {
// ignora os cabeçalhos, por enquanto
}
IO.println(LocalTime.now().withNano(0) + " " + linhaDePedido);
byte[] corpo = ("Olá do Farol! Você pediu: " + linhaDePedido + "\n").getBytes(StandardCharsets.UTF_8);
OutputStream saida = conexao.getOutputStream();
String cabecalho = "HTTP/1.1 200 OK\r\n"
+ "Content-Type: text/plain; charset=utf-8\r\n"
+ "Content-Length: " + corpo.length + "\r\n"
+ "Connection: close\r\n"
+ "\r\n";
saida.write(cabecalho.getBytes(StandardCharsets.ISO_8859_1));
saida.write(corpo);
saida.flush();
}
}
}
}
O laço aceita uma conexão, lê a linha de pedido e os cabeçalhos até a linha vazia, monta uma resposta e fecha a conexão ao sair do try. O cabeçalho Connection: close avisa o cliente de que a conexão será encerrada depois da resposta. Testando com o curl, com -i para ver também os cabeçalhos da resposta:
$ curl -i http://localhost:8080/tarefas
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 50
Connection: close
Olá do Farol! Você pediu: GET /tarefas HTTP/1.1
E o servidor registra:
Farol (rascunho) em http://localhost:8080
18:34:41 GET /tarefas HTTP/1.1
Abrindo http://localhost:8080/qualquer/coisa num navegador, a mesma mensagem aparece na tela. Um servidor web em trinta linhas.
Repare no Content-Length: 50. A mensagem tem 48 caracteres, mas "á" e "ê" ocupam dois bytes cada em UTF-8, e o cabeçalho precisa do tamanho em bytes. Por isso o código converte o corpo para bytes primeiro e usa corpo.length, e não o tamanho da String. Um Content-Length errado é um dos defeitos mais comuns em servidores feitos à mão: com um número menor, o cliente corta o fim da mensagem; com um maior, fica esperando bytes que nunca vão chegar.
Para confirmar que não há mágica nenhuma do outro lado, um cliente HTTP também pode ser escrito à mão:
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
// Um cliente HTTP "na unha": abre um socket, escreve o pedido, lê tudo
void main() throws IOException {
try (Socket s = new Socket("localhost", 8080)) {
String pedido = "GET /manual HTTP/1.1\r\nHost: localhost\r\n\r\n";
s.getOutputStream().write(pedido.getBytes(StandardCharsets.ISO_8859_1));
byte[] resposta = s.getInputStream().readAllBytes(); // até o servidor fechar
IO.print(new String(resposta, StandardCharsets.UTF_8));
}
}
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 49
Connection: close
Olá do Farol! Você pediu: GET /manual HTTP/1.1
O readAllBytes só funciona aqui porque o servidor fecha a conexão depois de responder. Com conexões persistentes, o cliente teria de ler os cabeçalhos e usar o Content-Length para saber quantos bytes esperar, exatamente o que o servidor terá de fazer com o corpo das requisições.
Três formas de derrubar o rascunho
O rascunho funciona com clientes bem-comportados. Um servidor de verdade não pode contar com isso. O programa abaixo é um cliente lento: conecta e não envia nada durante dez segundos, como faria alguém numa rede ruim, ou um atacante:
import java.net.Socket;
// Conecta e não manda nada por 10 segundos
void main() throws Exception {
try (Socket s = new Socket("localhost", 8080)) {
IO.println("cliente lento conectado, calado...");
Thread.sleep(10_000);
}
}
Com o cliente lento conectado, um curl com prazo de três segundos, e depois outro, quando o lento termina:
$ curl -s -m 3 -w "curl: código %{http_code} em %{time_total}s\n" http://localhost:8080/
curl: código 000 em 3.002691s
$ curl -s -w "curl: código %{http_code} em %{time_total}s\n" http://localhost:8080/depois
curl: código 000 em 0.000116s
E no terminal do servidor:
Farol (rascunho) em http://localhost:8080
18:35:02 null
Exception in thread "main" java.net.SocketException: Broken pipe
at java.base/sun.nio.ch.SocketDispatcher.write0(Native Method)
...
at java.base/java.io.OutputStream.write(OutputStream.java:124)
at Rascunho.main(Rascunho.java:28)
Um único cliente, sem enviar um byte, derrubou o servidor de três formas.
A primeira: enquanto o cliente lento estava conectado, o curl não recebeu resposta nenhuma e desistiu depois de três segundos. O rascunho atende uma conexão por vez: a thread main está parada no readLine() do cliente lento, e todos os outros esperam na fila do accept. Um servidor com mil usuários seria derrubado por um só. É o problema que "Um Milhão de Threads" resolve, e que a terceira pedra do projeto vai tratar.
A segunda: quando o cliente lento fechou a conexão sem ter enviado nada, o readLine() devolveu null, e o servidor registrou uma requisição "null" e seguiu em frente como se ela existisse. O rascunho não valida a entrada. Uma linha de pedido ausente, malformada, com um método inventado ou com um milhão de caracteres seria tratada da mesma forma. As lições de "Contratos à Vista" se aplicam aqui com força redobrada, porque a entrada vem de qualquer lugar da internet.
A terceira: o servidor tentou escrever a resposta numa conexão que o cliente já tinha fechado, recebeu uma SocketException, e, como ninguém a capturou, a exceção subiu até o main e matou o servidor. O segundo curl não encontrou ninguém escutando. Um erro numa conexão derrubou o atendimento de todas as outras. O servidor precisa isolar cada conexão, de forma que o erro de uma, qualquer que seja, seja registrado e esquecido.
Há outros defeitos, menos visíveis. O BufferedReader lê os bytes da conexão em blocos de 8 KB e os converte em caracteres, e com isso ele pode ter "engolido" o começo do corpo da requisição junto com os cabeçalhos, deixando o corpo inacessível para quem lê o InputStream depois. Não há limite para o tamanho de uma linha, e um cliente que envie uma linha de cabeçalho de 2 GB esgotaria a memória. E cada resposta fecha a conexão, obrigando o navegador a abrir uma nova para cada imagem ou folha de estilo de uma página.
O que o Farol vai fazer
Os defeitos do rascunho viram os requisitos do servidor definitivo. O Farol vai:
- entender requisições HTTP/1.1 dos métodos
GET,HEAD,POST,PUTeDELETE, lendo a linha de pedido, os cabeçalhos e o corpo diretamente dos bytes, comContent-Length; - recusar entradas inválidas com a resposta certa: 400 para requisições malformadas, 405 para métodos não suportados numa rota, 411 quando um corpo vier sem
Content-Length, 413 para corpos grandes demais, 431 para cabeçalhos grandes demais e 505 para versões que não sejam HTTP/1.1; - impor limites e prazos: tamanho máximo de linha, de cabeçalhos e de corpo, e tempo máximo de espera por um cliente calado;
- atender milhares de conexões simultâneas, uma thread virtual para cada, com conexões persistentes (keep-alive), atendendo várias requisições pela mesma conexão;
- direcionar cada requisição a um manipulador por uma tabela de rotas, com parâmetros no caminho, como
/tarefas/{id}; - servir arquivos estáticos, como HTML, CSS e imagens, de uma pasta, com o
Content-Typecorreto e proteção contra caminhos que tentem sair dela; - isolar falhas: uma exceção num manipulador vira uma resposta 500 para aquele cliente, e o servidor segue;
- desligar com ordem, terminando as requisições em andamento antes de sair;
- ser coberto por testes, e empacotado com as ferramentas de "Do JAR ao Executável".
E, tão importante quanto, o que ele não vai fazer, para que o projeto caiba em cinco aulas: HTTPS (a criptografia fica a cargo de um proxy como o nginx, como na Trilha A), HTTP/2, o envio de corpos em partes (chunked transfer encoding) nas requisições, compressão e autenticação. Cada item dessa lista é um exercício possível depois do curso.
A arquitetura
O Farol será organizado em camadas, cada uma num pacote, cada uma conhecendo apenas a de baixo:
┌───────────────────────────────────────────────┐
aplicação │ app: Principal (configura rotas e sobe tudo) │
└───────────────────────┬───────────────────────┘
┌───────────────────────▼───────────────────────┐
rotas │ rotas: Roteador, Rota, Manipulador │
│ estatico: ArquivosEstaticos │
└───────────────────────┬───────────────────────┘
┌───────────────────────▼───────────────────────┐
modelo │ http: Requisicao, Resposta, Metodo, Status, │
│ Cabecalhos (records, enums, sealed) │
└───────────────────────┬───────────────────────┘
┌───────────────────────▼───────────────────────┐
protocolo │ protocolo: LeitorDeRequisicao, │
│ EscritorDeResposta, ErroDeProtocolo│
└───────────────────────┬───────────────────────┘
┌───────────────────────▼───────────────────────┐
rede │ nucleo: Servidor (ServerSocket, threads), │
│ Conexao (keep-alive, prazos, falhas) │
└───────────────────────────────────────────────┘
A camada de rede cuida dos sockets: aceitar conexões, criar uma thread virtual para cada uma, aplicar prazos, capturar qualquer falha e desligar com ordem. A camada de protocolo transforma bytes em objetos e objetos em bytes, e é a única que conhece CRLF, Content-Length e ISO-8859-1. A camada de modelo define os tipos que as outras trocam: a Requisicao e a Resposta como records imutáveis, o Metodo como enum, e os resultados como tipos selados. A camada de rotas decide qual código atende cada requisição. E a aplicação só configura: quais rotas existem, em que porta, com que limites.
O caminho de uma requisição atravessa todas elas, e é o que o código das próximas aulas vai implementar:
Servidor.accept() → nova thread virtual → Conexao
laço enquanto a conexão estiver viva:
LeitorDeRequisicao.ler(bytes) → Requisicao (ou ErroDeProtocolo → 400/413/431...)
Roteador.encontrar(requisicao) → Manipulador (ou 404/405)
Manipulador.atender(requisicao) → Resposta (ou exceção → 500)
EscritorDeResposta.escrever(resposta) → bytes
decide: manter a conexão (keep-alive) ou fechar
A peça central do desenho é uma interface funcional de uma linha, que é tudo o que um manipulador precisa implementar:
@FunctionalInterface
public interface Manipulador {
Resposta atender(Requisicao requisicao) throws Exception;
}
Com ela, uma rota é uma lambda, no espírito de "Lições de Quem Veio Antes", e o Farol final vai permitir configurar a aplicação assim:
Roteador rotas = new Roteador()
.get("/", req -> Resposta.texto("Bem-vindo à Ilha do Conhecimento"))
.get("/tarefas/{id}", req -> buscarTarefa(req.parametro("id")))
.post("/tarefas", req -> criarTarefa(req.corpoComoTexto()));
Essa é a mesma forma de API do Javalin, do Spark e de outros frameworks leves, e muito parecida com a do Spring, que usa anotações em vez de lambdas. Não é coincidência: é o desenho natural para o problema.
As decisões
Algumas escolhas valem para o projeto inteiro. O Farol usa só o JDK 25, sem nenhuma dependência de produção e sem recursos em prévia, para que tudo o que ele faz esteja à vista. O JDK até tem um servidor HTTP embutido, o com.sun.net.httpserver, útil para testes e protótipos, e o Farol não vai usá-lo, pelo motivo óbvio. O projeto é construído com Maven, como em "Construção Automatizada", e organizado como um módulo, br.com.exemplo.farol, o que permite empacotá-lo com o jlink no fim. Os testes usam JUnit e AssertJ, de "Provando que Funciona", as únicas dependências, e só de teste.
O plano das próximas aulas segue as camadas de baixo para cima. "A Primeira Pedra" escreve o leitor de requisições sobre bytes, com os limites e as respostas de erro, e um servidor que atende uma conexão de forma robusta. "A Segunda Pedra" transforma requisições e respostas em tipos, com records, enums e hierarquias seladas, e constrói o roteador com parâmetros no caminho. "A Terceira Pedra" coloca cada conexão numa thread virtual, implementa o keep-alive, os prazos e o desligamento ordenado, e mede quantas conexões simultâneas o Farol aguenta. "A Pedra de Acabamento" serve arquivos estáticos com NIO.2, completa os testes e empacota o servidor. E "A Jornada Completa" olha para trás.
Fontes e leituras recomendadas
- RFC 9112, "HTTP/1.1" (IETF, 2022): a especificação atual do formato das mensagens HTTP/1.1, com a gramática exata da linha de pedido, dos cabeçalhos e do corpo. É a referência que o Farol vai seguir. https://www.rfc-editor.org/rfc/rfc9112
- RFC 9110, "HTTP Semantics" (IETF, 2022): os métodos, os códigos de estado e os cabeçalhos comuns a todas as versões do HTTP. https://www.rfc-editor.org/rfc/rfc9110
- MDN Web Docs, "An overview of HTTP": uma introdução clara e ilustrada ao protocolo, com links para cada cabeçalho e código de estado. https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Overview
- Tim Berners-Lee, "The Original HTTP as defined in 1991": a especificação de uma página do HTTP/0.9, preservada pelo W3C. Vale a leitura pela simplicidade. https://www.w3.org/Protocols/HTTP/AsImplemented.html
- Documentação das classes
ServerSocketeSocket(Java SE 25): os métodosaccept,setSoTimeout,getInputStreame o comportamento no fechamento das conexões. https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/net/ServerSocket.html
Exercícios
Exercício 1
Escreva, byte por byte, com os \r\n explícitos, a requisição que o comando curl -X PUT -H "Content-Type: text/plain" -d "Comprar pão" http://localhost:8080/tarefas/7 envia (ignore os cabeçalhos User-Agent e Accept). Qual é o valor do Content-Length?
Ver resposta
✓ Resposta:
PUT /tarefas/7 HTTP/1.1\r\n
Host: localhost:8080\r\n
Content-Type: text/plain\r\n
Content-Length: 12\r\n
\r\n
Comprar pão
O Content-Length é 12, e não 11: "Comprar pão" tem 11 caracteres, mas o "ã" ocupa dois bytes em UTF-8, a codificação que o terminal e o curl usam para enviar o texto. O corpo não termina com \r\n: ele tem exatamente os 12 bytes indicados, e é o Content-Length, e não uma quebra de linha, que marca o seu fim. O Host inclui a porta, porque ela não é a padrão do HTTP, a 80. E o alvo é só o caminho, /tarefas/7, sem o http://localhost:8080, porque o endereço do servidor já foi usado para abrir a conexão e está no Host.
Exercício 2
Explique por que o rascunho converte o corpo em bytes antes de calcular o Content-Length, e o que aconteceria com o curl se o código usasse o tamanho da String, com a mensagem "Olá do Farol! Você pediu: GET /tarefas HTTP/1.1\n".
Ver resposta
✓ Resposta: O Content-Length é definido em bytes, porque o HTTP transporta bytes e o receptor precisa saber quantos ler. A String tem 48 caracteres, mas em UTF-8 o "á" e o "ê" ocupam dois bytes cada, e o corpo tem 50 bytes. Com o tamanho da String, o cabeçalho diria 48, e o curl leria só os primeiros 48 bytes do corpo. Como o rascunho fecha a conexão depois de responder, os dois bytes restantes seriam descartados, e a resposta chegaria cortada: o fim, 1\n, se perderia, e a linha terminaria em HTTP/1., sem o último dígito e sem a quebra de linha. Numa conexão persistente, o efeito seria pior: os dois bytes que sobraram seriam interpretados como o começo da próxima resposta, corrompendo a comunicação a partir daí. O erro contrário, um Content-Length maior que o corpo, faria o cliente esperar bytes que nunca chegariam, até um prazo esgotar. Por isso a regra do Farol será sempre produzir o corpo como byte[] primeiro e derivar o cabeçalho dele.
Exercício 3
Qual código de estado o Farol deve devolver em cada caso? (a) GET /tarefas/99 e a tarefa 99 não existe; (b) DELETE / numa rota que só aceita GET; (c) a linha de pedido é GET /tarefas, sem a versão; (d) um POST com Content-Length: 5000000000; (e) a requisição usa HTTP/2.0 na linha de pedido; (f) o manipulador lança um NullPointerException.
Ver resposta
✓ Resposta: (a) 404 Not Found: o recurso pedido não existe. (b) 405 Method Not Allowed: o recurso existe, mas não aceita esse método; a RFC 9110 exige que a resposta traga o cabeçalho Allow listando os métodos aceitos, aqui Allow: GET, HEAD. (c) 400 Bad Request: a linha de pedido não segue a gramática, que exige três campos; o servidor não tem como interpretar a requisição com segurança. (d) 413 Content Too Large: o servidor recusa o corpo antes de lê-lo, só pelo cabeçalho, sem gastar memória nem banda; o nome antigo desse código era "Payload Too Large". (e) 505 HTTP Version Not Supported: o servidor só fala HTTP/1.1 (e aceitaria 1.0); uma mensagem de texto com "HTTP/2.0" nem é uma requisição HTTP/2 real, que é binária. (f) 500 Internal Server Error: o erro é do servidor, não do cliente. A exceção deve ser registrada com a pilha completa no log do servidor, mas a resposta ao cliente não deve incluir a pilha, porque ela revela detalhes internos que um atacante poderia explorar.
Exercício 4
O rascunho lê os cabeçalhos com um BufferedReader. Explique por que ele não serve para ler o corpo de um POST logo em seguida, mesmo que o código leia o Content-Length corretamente.
Ver resposta
✓ Resposta: O BufferedReader não lê do socket um caractere por vez: ele pede ao InputStreamReader um bloco grande, tipicamente 8 KB, converte os bytes em caracteres e guarda o excesso no seu buffer interno, para entregar nas chamadas seguintes de readLine(). Quando a requisição chega inteira num único pacote, como a do curl no espião, o primeiro readLine() já puxa para dentro do buffer a linha de pedido, os cabeçalhos e o corpo. Se o código depois tentar ler Content-Length bytes diretamente do InputStream do socket, eles já não estão lá: foram consumidos pelo buffer, e a leitura fica esperando bytes que o cliente já enviou. Ler o corpo pelo próprio BufferedReader também não resolve direito, por dois motivos: ele entrega caracteres, já convertidos pela codificação escolhida, e o Content-Length conta bytes, então não há como saber quantos caracteres ler; e um corpo binário, como uma imagem, seria corrompido pela conversão. A solução, que a próxima aula implementa, é ler tudo como bytes, de um único InputStream com buffer, reconhecendo o CRLF à mão, e converter para texto só a linha de pedido e os cabeçalhos.
Exercício 5
Por que um único cliente lento conseguiu impedir que todos os outros fossem atendidos? Proponha uma correção mínima para o rascunho, e diga por que ela ainda não é suficiente.
Ver resposta
✓ Resposta: O rascunho executa tudo na thread main, uma conexão depois da outra. Enquanto ela está bloqueada no readLine() do cliente lento, esperando bytes que não vêm, ela não volta ao accept(), e as outras conexões ficam na fila de espera do sistema operacional até o cliente lento desistir. A correção mínima é atender cada conexão em outra thread, e voltar imediatamente ao accept():
try (ServerSocket servidor = new ServerSocket(8080);
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
while (true) {
Socket conexao = servidor.accept();
executor.submit(() -> atender(conexao)); // atender faz o que o laço fazia, num try-with-resources
}
}
Com isso, o cliente lento prende só a sua thread virtual, que se desmonta enquanto espera, e os outros são atendidos normalmente. De quebra, a exceção de uma conexão fica presa na tarefa dela, e o servidor não morre. Mas não é suficiente. Sem um prazo de leitura, com setSoTimeout, cada cliente calado mantém uma conexão e um socket abertos para sempre, e um atacante que abra dezenas de milhares de conexões silenciosas esgota os descritores de arquivo do sistema operacional, um ataque conhecido como Slowloris. Sem limites de tamanho, um cliente pode enviar uma linha infinita e esgotar a memória. E a exceção capturada pelo submit desaparece em silêncio, o problema de "Não Crie Threads, Entregue Tarefas", quando deveria ser registrada. Concorrência sem limites troca um problema por outro. É por isso que o Farol vai combinar threads virtuais com prazos, limites de tamanho e tratamento explícito de falhas em cada conexão.