Blog

HTTP em Java com o JDK: HttpClient e um pequeno HttpServer

O JDK do Java chama e serve HTTP sem bibliotecas. Aprenda o HttpClient, síncrono e assíncrono, e depois construa um pequeno serviço de tarefas em JSON sobre o HttpServer embutido, com virtual threads, status codes certos e um encerramento limpo.

O JDK traz as duas metades do HTTP. java.net.http.HttpClient envia requisições, e com.sun.net.httpserver.HttpServer responde a elas. Nenhum dos dois precisa de biblioteca, e juntos eles bastam para um pequeno serviço interno, um servidor falso num teste ou uma ferramenta que chama uma API.

Este post cobre o cliente, requisições assíncronas, timeouts, redirects e as regras do servidor para respostas, e depois constrói um pequeno serviço de tarefas como um módulo de verdade. Todos os programas abaixo rodaram no Java 25, e a saída foi colada da execução. Para rodar um deles, salve como Main.java e rode java Main.java. A máquina que verifica esses programas não tem acesso à internet, então cada um inicia o próprio servidor na porta 0 e chama esse servidor.

Um servidor na porta 0, e um cliente para chamá-lo

Um HttpServer escuta numa porta e passa cada requisição para um handler registrado para um caminho. A porta 0 pede ao sistema operacional qualquer porta livre, que é o que exemplos e testes querem. Este programa inicia um servidor com um handler, chama esse handler quatro vezes e para o servidor:

import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

void main() throws Exception {
    // Port 0 asks the operating system for any free port.
    HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
    server.createContext("/hello", exchange -> {
        String text = "hello, you asked for " + exchange.getRequestURI().getPath() + "\n";
        byte[] body = text.getBytes(StandardCharsets.UTF_8);
        exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
        exchange.sendResponseHeaders(200, body.length);
        try (var out = exchange.getResponseBody()) {
            out.write(body);
        }
    });
    server.start();
    int port = server.getAddress().getPort();

    try (HttpClient client = HttpClient.newHttpClient()) {
        for (String path : List.of("/hello", "/hello/world", "/helloworld", "/")) {
            var uri = URI.create("http://localhost:" + port + path);
            var response = client.send(HttpRequest.newBuilder(uri).build(),
                    HttpResponse.BodyHandlers.ofString());
            IO.println(response.statusCode() + " " + path + ": " + response.body().strip());
        }
    }
    server.stop(0);
}

Ele imprime:

200 /hello: hello, you asked for /hello
200 /hello/world: hello, you asked for /hello/world
200 /helloworld: hello, you asked for /helloworld
404 /: <h1>404 Not Found</h1>No context found for request

O handler recebe um HttpExchange, que guarda a requisição e a resposta. Ele define um header, chama sendResponseHeaders com o status e o tamanho do corpo em bytes, e depois escreve o corpo. getAddress().getPort() diz qual porta o servidor recebeu, e stop(0) para o servidor sem esperar.

Olhe para /helloworld. Um context casa com qualquer caminho que comece com a sua string, então /hello casou com ele. Um caminho sem context recebe o 404 em HTML do próprio JDK. Um handler que se importa com caminhos exatos precisa verificá-los por conta própria.

Os imports não são opcionais. Um arquivo-fonte compacto importa java.base para você, mas java.net.http e jdk.httpserver são módulos separados.

HttpClient: montar uma requisição, enviar e ler a resposta

Um HttpClient envia objetos HttpRequest e retorna objetos HttpResponse. Crie um e reutilize, porque ele mantém um pool de conexões abertas. HttpClient virou uma API padrão no Java 11, e javac --release 10 não encontra o pacote java.net.http.

import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

void main() throws Exception {
    HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
    server.createContext("/echo", exchange -> {
        String sent = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
        String text = exchange.getRequestMethod() + " with "
                + exchange.getRequestHeaders().getFirst("Content-Type") + ": " + sent;
        byte[] body = text.getBytes(StandardCharsets.UTF_8);
        exchange.getResponseHeaders().set("X-Echo-Length", String.valueOf(sent.length()));
        exchange.sendResponseHeaders(200, body.length);
        try (var out = exchange.getResponseBody()) {
            out.write(body);
        }
    });
    server.start();
    URI echo = URI.create("http://localhost:" + server.getAddress().getPort() + "/echo");

    try (HttpClient client = HttpClient.newHttpClient()) {
        HttpRequest request = HttpRequest.newBuilder(echo)
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString("{\"title\":\"Buy milk\"}"))
                .build();
        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

        IO.println("status:  " + response.statusCode());
        IO.println("body:    " + response.body());
        IO.println("header:  " + response.headers().firstValue("X-Echo-Length").orElse("none"));
        IO.println("asked:   " + client.version());
        IO.println("got:     " + response.version());
    }
    server.stop(0);
}

Ele imprime:

status:  200
body:    POST with application/json: {"title":"Buy milk"}
header:  20
asked:   HTTP_2
got:     HTTP_1_1

send bloqueia até a resposta chegar. O body handler decide no que o corpo se transforma: ofString() aqui, ou ofByteArray(), ofFile(path) e discarding(). headers().firstValue retorna um Optional<String> e ignora maiúsculas e minúsculas no nome.

As duas últimas linhas importam mais adiante. O cliente prefere HTTP/2, mas o servidor do JDK respondeu com HTTP/1.1, e o cliente usou isso sem avisar. O bloco try fecha o cliente, o que o HttpClient permite desde o Java 21.

sendAsync retorna um CompletableFuture

sendAsync inicia a requisição e retorna na hora, com um CompletableFuture que se completa quando a resposta chega. A parte sobre java.util.concurrent trata do próprio CompletableFuture.

import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

void main() throws Exception {
    HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
    server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
    server.createContext("/square", exchange -> {
        int n = Integer.parseInt(exchange.getRequestURI().getQuery().substring("n=".length()));
        byte[] body = String.valueOf(n * n).getBytes(StandardCharsets.UTF_8);
        exchange.sendResponseHeaders(200, body.length);
        try (var out = exchange.getResponseBody()) {
            out.write(body);
        }
    });
    server.start();
    String base = "http://localhost:" + server.getAddress().getPort();

    try (HttpClient client = HttpClient.newHttpClient()) {
        List<CompletableFuture<String>> futures = new ArrayList<>();
        for (int n = 1; n <= 5; n++) {
            var request = HttpRequest.newBuilder(URI.create(base + "/square?n=" + n)).build();
            CompletableFuture<String> future = client
                    .sendAsync(request, HttpResponse.BodyHandlers.ofString())
                    .thenApply(HttpResponse::body);
            futures.add(future);
        }
        IO.println("sent " + futures.size() + " requests, none of them waited for another");

        CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();
        IO.println(futures.stream().map(CompletableFuture::join).toList());
    }
    server.stop(0);
}

Ele imprime:

sent 5 requests, none of them waited for another
[1, 4, 9, 16, 25]

O laço inicia cinco requisições sem esperar por nenhuma delas. thenApply(HttpResponse::body) transforma cada future de uma resposta num future do seu corpo. CompletableFuture.allOf espera as cinco, e depois disso cada join retorna na hora. Os resultados saem na ordem em que os futures entraram na lista, não na ordem em que as respostas chegaram.

O servidor roda cada requisição numa virtual thread, definida com setExecutor. O serviço mais abaixo explica por quê.

Timeouts e redirects

Um cliente sem timeout pode esperar para sempre por um servidor que nunca responde. O HttpClient tem dois timeouts: connectTimeout no cliente, para abrir uma conexão, e timeout em cada requisição, para esperar a resposta. Seguir redirects é uma linha no mesmo builder:

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;

void reply(HttpExchange exchange, int status, String text) throws IOException {
    byte[] body = text.getBytes(StandardCharsets.UTF_8);
    exchange.sendResponseHeaders(status, body.length);
    try (var out = exchange.getResponseBody()) {
        out.write(body);
    }
}

void main() throws Exception {
    HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
    server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
    server.createContext("/old", exchange -> {
        exchange.getResponseHeaders().set("Location", "/new");
        reply(exchange, 302, "moved");
    });
    server.createContext("/new", exchange -> reply(exchange, 200, "the new page"));
    server.createContext("/slow", exchange -> {
        try {
            Thread.sleep(Duration.ofSeconds(2));
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        reply(exchange, 200, "finally");
    });
    server.start();
    String base = "http://localhost:" + server.getAddress().getPort();

    try (HttpClient plain = HttpClient.newHttpClient();
            HttpClient following = HttpClient.newBuilder()
                    .connectTimeout(Duration.ofSeconds(2))
                    .followRedirects(HttpClient.Redirect.NORMAL)
                    .build()) {
        var old = HttpRequest.newBuilder(URI.create(base + "/old")).build();

        var r1 = plain.send(old, HttpResponse.BodyHandlers.ofString());
        IO.println("plain:     " + r1.statusCode() + " " + r1.body()
                + ", Location " + r1.headers().firstValue("Location").orElse("none"));

        var r2 = following.send(old, HttpResponse.BodyHandlers.ofString());
        IO.println("following: " + r2.statusCode() + " " + r2.body()
                + ", from " + r2.uri().getPath());

        var slow = HttpRequest.newBuilder(URI.create(base + "/slow"))
                .timeout(Duration.ofMillis(200))
                .build();
        try {
            following.send(slow, HttpResponse.BodyHandlers.ofString());
        } catch (HttpTimeoutException e) {
            IO.println("slow:      " + e.getClass().getSimpleName() + ": " + e.getMessage());
        }
    }
    server.stop(0);
}

Ele imprime:

plain:     302 moved, Location /new
following: 200 the new page, from /new
slow:      HttpTimeoutException: request timed out

A política de redirect padrão é Redirect.NEVER, então o cliente simples retornou o 302 e o seu header Location. Redirect.NORMAL segue redirects, exceto de uma URL HTTPS para uma HTTP, e response.uri() mostra de onde veio a resposta final.

A requisição lenta lançou HttpTimeoutException depois de 200 milissegundos, enquanto o handler no servidor ainda dormia. Ela é uma subclasse de IOException.

O projeto: um serviço de tarefas num módulo

O resto deste post constrói um pequeno serviço que guarda uma lista de tarefas na memória e a serve como JSON. Ele é um módulo nomeado, com.example.tasks, sem ferramenta de build e sem biblioteca. Ele responde a quatro requisições:

Requisição O que faz Sucesso Erros do cliente
GET /tasks lista todas as tarefas, ordenadas por id 200
POST /tasks cria uma tarefa 201 com Location 400, 413, 415, 422
GET /tasks/{id} busca uma tarefa 200 404
DELETE /tasks/{id} apaga uma tarefa 204 404

Um caminho conhecido com o método errado recebe 405 e um header Allow. Qualquer outro caminho recebe 404, e um bug no código recebe 500. Todo erro tem o mesmo formato de corpo, {"error":"..."}. Aqui está o projeto inteiro, fora o .gitignore:

19-tasks-service/
├── run-checks.sh
├── checks/
│   └── SmokeCheck.java
└── src/
    └── com.example.tasks/
        ├── module-info.java
        └── com/example/tasks/
            ├── Main.java
            ├── http/
            │   ├── Json.java
            │   ├── TaskHandler.java
            │   └── TaskServer.java
            └── store/
                ├── InMemoryTaskStore.java
                ├── Task.java
                └── TaskStore.java

O módulo precisa do módulo de servidor HTTP do JDK e exporta dois pacotes:

/** A small task list over HTTP, built on the JDK's own HTTP server. */
module com.example.tasks {
    requires jdk.httpserver;

    exports com.example.tasks.http;
    exports com.example.tasks.store;
}

Como http e store são exportados, outro código, como um teste, pode iniciar o servidor com qualquer store. A parte sobre testes e distribuição monta um runtime pequeno a partir deste módulo com jlink.

O store: uma interface na frente de um map

Uma tarefa é record Task(long id, String title, boolean done), e o código HTTP só chega às tarefas por uma interface. Assim, um teste, ou uma versão futura com banco de dados, pode passar um store diferente:

package com.example.tasks.store;

import java.util.List;
import java.util.Optional;

/** Everything the HTTP layer needs from storage. Implementations must be thread-safe. */
public interface TaskStore {
    /** Returns every task, sorted by id. */
    List<Task> list();

    /** Returns the task with this id, or an empty Optional. */
    Optional<Task> get(long id);

    /** Stores a new task under the next id and returns it. */
    Task create(String title, boolean done);

    /** Deletes the task with this id. Returns false if there was none. */
    boolean delete(long id);
}

A versão em memória usa um ConcurrentHashMap para as tarefas e um AtomicLong para os ids:

/** Keeps tasks in memory. Safe for many requests at once; lost on restart. */
public final class InMemoryTaskStore implements TaskStore {
    private final ConcurrentHashMap<Long, Task> tasks = new ConcurrentHashMap<>();
    private final AtomicLong lastId = new AtomicLong();

    /** Creates an empty store. The first task gets id 1. */
    public InMemoryTaskStore() {
    }

    @Override
    public List<Task> list() {
        return tasks.values().stream()
                .sorted(Comparator.comparingLong(Task::id))
                .toList();
    }

    @Override
    public Optional<Task> get(long id) {
        return Optional.ofNullable(tasks.get(id));
    }

    @Override
    public Task create(String title, boolean done) {
        var task = new Task(lastId.incrementAndGet(), title, done);
        tasks.put(task.id(), task);
        return task;
    }

    @Override
    public boolean delete(long id) {
        return tasks.remove(id) != null;
    }
}

As requisições rodam ao mesmo tempo, então duas requisições POST podem chegar juntas a create. incrementAndGet ainda dá um id diferente a cada uma, sem lock. list ordena por id, porque um hash map não tem uma ordem útil. Um list que roda durante um create pode ou não incluir a tarefa nova, já que percorrer um ConcurrentHashMap não o congela.

Uma virtual thread por requisição

TaskServer monta o servidor. Ele recebe as dependências, um store e uma porta, como argumentos, então um teste pode iniciar um na porta 0 com um store novo:

    /**
     * Starts serving the store on the given port, on every local address.
     * Port 0 asks the operating system for a free port; port() says which one it got.
     */
    public static TaskServer start(TaskStore store, int port) throws IOException {
        HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
        server.setExecutor(executor);
        server.createContext("/", new TaskHandler(store));
        server.start();
        return new TaskServer(server, executor);
    }

Um context em / manda toda requisição para um único handler. A linha setExecutor decide qual thread roda cada requisição. Sem ela, o JDK roda todo handler na única thread dispatcher do servidor, uma requisição de cada vez. Este programa envia cinco requisições de uma vez para um handler que espera 300 milissegundos, primeiro sem executor e depois com virtual threads:

import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.atomic.AtomicInteger;

/** Sends 5 requests at once. Each handler waits 300 ms, like a slow database call. */
void run(String label, Executor executor) throws Exception {
    var inFlight = new AtomicInteger();
    var mostAtOnce = new AtomicInteger();
    var virtual = new AtomicInteger();

    HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
    server.setExecutor(executor);
    server.createContext("/work", exchange -> {
        mostAtOnce.accumulateAndGet(inFlight.incrementAndGet(), Math::max);
        if (Thread.currentThread().isVirtual()) {
            virtual.incrementAndGet();
        }
        try {
            Thread.sleep(300);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        inFlight.decrementAndGet();
        exchange.sendResponseHeaders(204, -1);
        exchange.close();
    });
    server.start();

    var uri = URI.create("http://localhost:" + server.getAddress().getPort() + "/work");
    try (HttpClient client = HttpClient.newHttpClient()) {
        var futures = new ArrayList<CompletableFuture<HttpResponse<Void>>>();
        for (int i = 0; i < 5; i++) {
            futures.add(client.sendAsync(HttpRequest.newBuilder(uri).build(),
                    HttpResponse.BodyHandlers.discarding()));
        }
        futures.forEach(CompletableFuture::join);
    }
    server.stop(0);
    IO.println(label + ": at most " + mostAtOnce.get() + " at once, "
            + virtual.get() + " of 5 on virtual threads");
}

void main() throws Exception {
    run("no executor    ", null);
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        run("virtual threads", executor);
    }
}

Ele imprime:

no executor    : at most 1 at once, 0 of 5 on virtual threads
virtual threads: at most 5 at once, 5 of 5 on virtual threads

Sem executor, as requisições fizeram fila uma atrás da outra. Com Executors.newVirtualThreadPerTaskExecutor(), os cinco handlers estavam esperando no mesmo momento, cada um na sua própria virtual thread. As virtual threads ficaram finais no Java 21, e a parte dedicada a elas explica como funcionam.

Explicado como se você tivesse dez anos

Pense no servidor como uma loja com um balcão. Uma pessoa fica na porta e repara em cada cliente que entra.

Sem executor, essa mesma pessoa também atende todos os clientes. Se um cliente pede algo do estoque, todo mundo atrás dele espera até a coisa voltar.

Com virtual threads, cada cliente ganha o próprio ajudante assim que entra. Ajudantes quase não custam nada, então a loja pode ter milhares. Enquanto um ajudante espera o estoque, ele sai do balcão, e outro ajudante usa o balcão.

A versão precisa

O HttpServer tem uma thread dispatcher que observa todas as conexões. Quando uma requisição está pronta, a dispatcher entrega uma tarefa ao executor, e essa tarefa lê a requisição e chama o seu handler. newVirtualThreadPerTaskExecutor() inicia uma virtual thread nova para cada tarefa. Quando o handler bloqueia, por exemplo em Thread.sleep ou na leitura de um socket, a virtual thread dele larga a thread de plataforma em que estava rodando, chamada de carrier. Existem só alguns carriers, mais ou menos um por núcleo de CPU por padrão, e eles ficam livres para rodar outras virtual threads.

TaskServer.stop fecha o executor depois de server.stop, porque o HttpServer nunca fecha um executor que você deu a ele.

Onde a analogia falha: ajudantes não deixam o estoque maior. Se dez mil requisições esperam por um banco de dados que aceita dez conexões, 9.990 virtual threads ficam na fila lá. Virtual threads também não aceleram trabalho que usa a CPU o tempo todo, porque esse trabalho precisa de um carrier enquanto roda. Elas ajudam quando as requisições passam a maior parte do tempo esperando.

Roteando por método e caminho dentro do handler

O handler transforma um método e um caminho numa chamada ao store. O JDK não tem roteador, então é if e switch puros:

    @Override
    public void handle(HttpExchange exchange) throws IOException {
        try (exchange) {
            try {
                route(exchange);
            } catch (RequestException e) {
                sendError(exchange, e.status, e.getMessage());
            } catch (RuntimeException e) {
                LOG.log(Level.ERROR, "request failed: " + exchange.getRequestURI(), e);
                if (exchange.getResponseCode() == -1) {
                    sendError(exchange, 500, "internal server error");
                }
            }
        }
    }

    private void route(HttpExchange exchange) throws IOException, RequestException {
        String method = exchange.getRequestMethod();
        String path = exchange.getRequestURI().getPath();

        if (path.equals("/tasks")) {
            switch (method) {
                case "GET" -> sendJson(exchange, 200, Json.tasks(store.list()));
                case "POST" -> createTask(exchange);
                default -> throw methodNotAllowed(exchange, "GET, POST");
            }
        } else if (path.startsWith("/tasks/")) {
            String rawId = path.substring("/tasks/".length());
            switch (method) {
                case "GET" -> getTask(exchange, parseId(rawId));
                case "DELETE" -> deleteTask(exchange, parseId(rawId));
                default -> throw methodNotAllowed(exchange, "GET, DELETE");
            }
        } else {
            throw new RequestException(404, "not found");
        }
    }

HttpExchange é AutoCloseable, então o try de fora sempre encerra o exchange. O de dentro transforma uma RequestException numa resposta de erro. Qualquer outra RuntimeException é um bug: ela vai para o log, e o cliente recebe um 500 se nenhum status saiu ainda, que é o que getResponseCode() == -1 significa. Sem esse catch, o JDK só fecha a conexão, e o cliente não recebe status nenhum.

route compara o caminho inteiro, então /tasksfoo é um 404. Um método errado recebe um 405 com o header Allow que o HTTP exige nele:

    private static RequestException methodNotAllowed(HttpExchange exchange, String allow) {
        exchange.getResponseHeaders().set("Allow", allow);
        return new RequestException(405, "method not allowed");
    }

    /** Only a positive decimal number names a task: "7" does; "+7", "07x" and "-3" don't. */
    private static long parseId(String raw) throws RequestException {
        boolean digits = !raw.isEmpty() && raw.length() <= 18
                && raw.chars().allMatch(c -> c >= '0' && c <= '9');
        long id = digits ? Long.parseLong(raw) : 0;
        if (id <= 0) {
            throw new RequestException(404, "task not found");
        }
        return id;
    }

A verificação de dígitos vem antes de Long.parseLong, porque parseLong aceita "+7". /tasks/abc, /tasks/0 e /tasks/+7 respondem 404. RequestException é uma exceção checada que carrega um status:

    /** A problem with the request that the client can fix. */
    static final class RequestException extends Exception {
        @Serial
        private static final long serialVersionUID = 1L;

        final int status;

        RequestException(int status, String message) {
            super(message);
            this.status = status;
        }
    }

Lendo o corpo de uma requisição com limite de tamanho

O corpo de uma requisição vem de quem quer que a tenha enviado, então o serviço verifica o tipo e limita o tamanho antes de fazer o parsing:

    private void createTask(HttpExchange exchange) throws IOException, RequestException {
        requireJson(exchange);
        NewTask input = parseNewTask(readBody(exchange));
        Task task = store.create(input.title(), input.done());
        exchange.getResponseHeaders().set("Location", "/tasks/" + task.id());
        sendJson(exchange, 201, Json.task(task));
    }

    private static void requireJson(HttpExchange exchange) throws RequestException {
        String type = exchange.getRequestHeaders().getFirst("Content-Type");
        String mediaType = type == null ? "" : type.split(";", 2)[0].strip();
        if (!mediaType.equalsIgnoreCase("application/json")) {
            throw new RequestException(415, "Content-Type must be application/json");
        }
    }

    /** Reads the whole body as UTF-8, but never more than MAX_BODY_BYTES + 1 bytes of it. */
    private static String readBody(HttpExchange exchange) throws IOException, RequestException {
        byte[] bytes;
        try (InputStream in = exchange.getRequestBody()) {
            bytes = in.readNBytes(MAX_BODY_BYTES + 1);
        }
        if (bytes.length > MAX_BODY_BYTES) {
            throw new RequestException(413,
                    "request body must not be larger than " + MAX_BODY_BYTES + " bytes");
        }
        try {
            return StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString();
        } catch (CharacterCodingException e) {
            throw new RequestException(400, "request body must be UTF-8");
        }
    }

requireJson responde 415 Unsupported Media Type quando o Content-Type não é application/json. O corpo pode estar certo, e o status diz que o rótulo é que está errado.

readNBytes(MAX_BODY_BYTES + 1) nunca lê mais de 16.385 bytes, e esse byte a mais separa “grande demais” de “exatamente no limite”. Passado o limite, a resposta é 413 Content Too Large. O código conta bytes em vez de confiar em Content-Length, que um cliente pode errar ou deixar de fora.

O decoder de newDecoder() reporta UTF-8 inválido como erro. new String(bytes, UTF_8) trocaria os bytes inválidos por sem avisar.

JSON sem biblioteca

O JDK não tem API de JSON, e no Java 25 não existe módulo para isso:

$ java --list-modules | grep -c .
69
$ java --list-modules | grep -ci json
0

Serviços reais usam uma biblioteca como Jackson ou Gson. Este projeto usa só o JDK, então escreve JSON à mão e lê um subconjunto pequeno e rígido. Escrever é a metade fácil:

    static String task(Task task) {
        return "{\"id\":" + task.id()
                + ",\"title\":" + string(task.title())
                + ",\"done\":" + task.done() + "}";
    }

    static String tasks(List<Task> tasks) {
        return tasks.stream().map(Json::task).collect(Collectors.joining(",", "[", "]"));
    }

    static String error(String message) {
        return "{\"error\":" + string(message) + "}";
    }

    /** Quotes a string, escaping what JSON requires: quote, backslash, control characters. */
    static String string(String s) {
        var out = new StringBuilder(s.length() + 2).append('"');
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            switch (c) {
                case '"' -> out.append("\\\"");
                case '\\' -> out.append("\\\\");
                case '\n' -> out.append("\\n");
                case '\r' -> out.append("\\r");
                case '\t' -> out.append("\\t");
                default -> {
                    if (c < 0x20) {
                        out.append(String.format("\\u%04x", (int) c));
                    } else {
                        out.append(c);
                    }
                }
            }
        }
        return out.append('"').toString();
    }

O JSON exige escape para aspas, barra invertida e caracteres de controle. Todo o resto pode sair como está.

Ler foi a decisão mais difícil. Uma opção honesta era aceitar um título em text/plain e não fazer parsing de JSON nenhum. Mas clientes de uma API JSON enviam JSON, então o serviço aceita JSON e diz exatamente quanto:

    /**
     * Parses one flat JSON object whose values are strings, true or false.
     * Numbers, null, arrays, nested objects and repeated names are rejected
     * on purpose: this service never needs them.
     */
    static Map<String, Object> parseObject(String text) {
        return new Parser(text).object();
    }

O parser lê um objeto plano cujos valores são strings, true ou false. Qualquer outra coisa recebe um 400 com uma mensagem, nunca um palpite. Isso cobre o que uma tarefa precisa. O dia em que o serviço precisar de mais é o dia de adicionar uma biblioteca, não de aumentar este parser.

Depois, parseNewTask verifica os campos:

    private static NewTask parseNewTask(String body) throws RequestException {
        Map<String, Object> fields;
        try {
            fields = Json.parseObject(body);
        } catch (Json.BadJson e) {
            throw new RequestException(400, e.getMessage());
        }
        for (String name : fields.keySet()) {
            if (!name.equals("title") && !name.equals("done")) {
                throw new RequestException(400, "unknown field " + Json.string(name));
            }
        }
        if (!(fields.getOrDefault("title", "") instanceof String rawTitle)) {
            throw new RequestException(400, "field \"title\" must be a string");
        }
        if (!(fields.getOrDefault("done", false) instanceof Boolean done)) {
            throw new RequestException(400, "field \"done\" must be true or false");
        }
        String title = rawTitle.strip();
        if (title.isEmpty()) {
            throw new RequestException(422, "title must not be empty");
        }
        if (title.codePointCount(0, title.length()) > MAX_TITLE_LENGTH) {
            throw new RequestException(422,
                    "title must be at most " + MAX_TITLE_LENGTH + " characters");
        }
        return new NewTask(title, done);
    }

Um campo desconhecido como "id" é um 400, então um cliente não consegue escolher o próprio id, e um erro de digitação como "titel" não é ignorado em silêncio. Um título em branco recebe 422 Unprocessable Content: o servidor leu a requisição sem problemas, mas um valor quebra uma regra. O tamanho conta code points, então um título em chinês também tem direito a 200 caracteres.

Escrevendo respostas: os três tamanhos de sendResponseHeaders

sendResponseHeaders(status, length) envia a linha de status e os headers, e o segundo argumento diz que tipo de corpo vem depois. Toda resposta JSON do serviço passa por um único método:

    private static void sendJson(HttpExchange exchange, int status, String json)
            throws IOException {
        byte[] body = (json + "\n").getBytes(StandardCharsets.UTF_8);
        exchange.getResponseHeaders().set("Content-Type", "application/json");
        exchange.sendResponseHeaders(status, body.length);
        try (OutputStream out = exchange.getResponseBody()) {
            out.write(body);
        }
    }

    private static void sendError(HttpExchange exchange, int status, String message)
            throws IOException {
        sendJson(exchange, status, Json.error(message));
    }

Os headers são definidos primeiro, porque saem junto com o status. O tamanho é em bytes UTF-8, não em caracteres: "Zoë" tem 3 caracteres, mas 4 bytes. Um delete não envia corpo:

    private void deleteTask(HttpExchange exchange, long id) throws IOException, RequestException {
        if (!store.delete(id)) {
            throw new RequestException(404, "task not found");
        }
        exchange.sendResponseHeaders(204, -1);
    }

O -1 importa, e a diferença para 0 também. Este programa testa cada tipo de tamanho, mais um que mente:

import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

void main() throws Exception {
    HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
    byte[] hello = "hello".getBytes(StandardCharsets.UTF_8);

    server.createContext("/length", exchange -> {
        exchange.sendResponseHeaders(200, hello.length);   // exactly 5 bytes follow
        try (var out = exchange.getResponseBody()) {
            out.write(hello);
        }
    });
    server.createContext("/chunked", exchange -> {
        exchange.sendResponseHeaders(200, 0);              // 0: any amount, sent in chunks
        try (var out = exchange.getResponseBody()) {
            out.write(hello);
            out.write(hello);
        }
    });
    server.createContext("/nobody", exchange -> {
        exchange.sendResponseHeaders(204, -1);             // -1: no body at all
        exchange.close();
    });
    server.createContext("/toomany", exchange -> {
        exchange.sendResponseHeaders(200, 2);              // promises 2 bytes, writes 5
        try (var out = exchange.getResponseBody()) {
            out.write(hello);
        } catch (IOException e) {
            IO.println("  server: " + e.getMessage());
            throw e;
        }
    });
    server.start();
    String base = "http://localhost:" + server.getAddress().getPort();

    try (HttpClient client = HttpClient.newHttpClient()) {
        for (String path : List.of("/length", "/chunked", "/nobody", "/toomany")) {
            IO.println(path);
            var request = HttpRequest.newBuilder(URI.create(base + path))
                    .POST(HttpRequest.BodyPublishers.noBody())
                    .build();
            try {
                var response = client.send(request, HttpResponse.BodyHandlers.ofString());
                var headers = response.headers();
                IO.println("  " + response.statusCode() + " [" + response.body() + "]"
                        + " content-length=" + headers.firstValue("content-length").orElse("-")
                        + " transfer-encoding="
                        + headers.firstValue("transfer-encoding").orElse("-"));
            } catch (IOException e) {
                IO.println("  client: " + e.getMessage());
            }
        }
    }
    server.stop(0);
}

Ele imprime:

/length
  200 [hello] content-length=5 transfer-encoding=-
/chunked
  200 [hellohello] content-length=- transfer-encoding=chunked
/nobody
  204 [] content-length=- transfer-encoding=-
/toomany
  server: too many bytes to write to stream
  client: HTTP/1.1 header parser received no bytes

Um tamanho positivo envia um header Content-Length, e o corpo precisa ter exatamente esse tamanho. 0 não significa “vazio”. Significa “tamanho desconhecido”, então o servidor envia o corpo em chunks de qualquer tamanho. -1 significa sem corpo. Um 204 nunca pode ter corpo.

Quando passamos 0 com um 204, o JDK registrou WARNING: sendResponseHeaders: rCode = 204: forcing contentLen = -1 na saída de erro padrão. Escrever mais bytes do que o prometido lança IOException. O handler deixa a exceção escapar, então o servidor fecha a conexão. Nossa primeira versão capturava a exceção e seguia em frente, e o cliente dela travou até o matarmos.

Status codes e um só formato de erro

Um cliente lê o status code primeiro, então o serviço escolhe cada um de propósito, e todo corpo de erro tem o mesmo formato, {"error":"..."}:

  • 400: o corpo não pode ser lido: JSON inválido, um campo desconhecido, um tipo errado ou UTF-8 inválido.
  • 404: essa tarefa não existe, ou esse caminho não existe.
  • 405: o caminho existe, mas não com esse método. Allow lista os métodos que funcionam.
  • 413: o corpo passa de 16 KiB.
  • 415: o Content-Type não é application/json.
  • 422: o título está em branco ou é longo demais.
  • 500: um bug. Os detalhes vão para o log, não para o cliente.

Main: iniciar numa porta, parar num shutdown hook

Main lê uma porta opcional dos argumentos, inicia o serviço com um store em memória e registra um shutdown hook:

package com.example.tasks;

import java.io.IOException;

import com.example.tasks.http.TaskServer;
import com.example.tasks.store.InMemoryTaskStore;

/** Starts the tasks service. Usage: Main [port], where the port defaults to 8080. */
public final class Main {
    private Main() {
    }

    public static void main(String[] args) throws IOException {
        int port = args.length == 0 ? 8080 : parsePort(args);
        TaskServer server = TaskServer.start(new InMemoryTaskStore(), port);
        IO.println("listening on port " + server.port());

        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            IO.println("stopping");
            server.stop(2);
            IO.println("stopped");
        }));
    }

    private static int parsePort(String[] args) {
        try {
            int port = Integer.parseInt(args[0]);
            if (args.length == 1 && port >= 0 && port <= 65535) {
                return port;
            }
        } catch (NumberFormatException e) {
            // Fall through to the usage message.
        }
        System.err.println("usage: tasks [port]   (0 to 65535, default 8080)");
        System.exit(2);
        return -1;
    }
}

main retorna na hora, e a JVM continua rodando, porque a thread dispatcher do servidor não é uma thread daemon.

Um shutdown hook é uma thread que a JVM inicia quando recebe um pedido para sair, inclusive com Ctrl+C e SIGTERM. Este chama stop(2), que para de aceitar conexões e dá às requisições em andamento até dois segundos para terminar. Hooks não rodam com kill -9.

Rodando o serviço e chamando com curl

O serviço roda a partir do module path, como qualquer programa modular. Na pasta do projeto, compile e inicie o serviço num terminal:

$ javac -Xlint:all -Werror --release 25 -d out --module-source-path src -m com.example.tasks
$ java -p out -m com.example.tasks/com.example.tasks.Main
listening on port 8080

Depois chame o serviço de um segundo terminal. -d faz o curl enviar um POST, e -w '%{http_code}\n' imprime o status depois do corpo:

$ curl -s localhost:8080/tasks
[]
$ curl -s -i localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": "Buy milk"}' | grep -v '^Date:'
HTTP/1.1 201 Created
Content-type: application/json
Content-length: 41
Location: /tasks/1

{"id":1,"title":"Buy milk","done":false}
$ curl -s localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": "Walk the dog", "done": true}'
{"id":2,"title":"Walk the dog","done":true}
$ curl -s localhost:8080/tasks
[{"id":1,"title":"Buy milk","done":false},{"id":2,"title":"Walk the dog","done":true}]
$ curl -s -X DELETE localhost:8080/tasks/1 -w '%{http_code}\n'
204
$ curl -s localhost:8080/tasks/1 -w '%{http_code}\n'
{"error":"task not found"}
404
$ curl -s -i -X PATCH localhost:8080/tasks/2 | grep -v '^Date:'
HTTP/1.1 405 Method Not Allowed
Allow: GET, DELETE
Content-type: application/json
Content-length: 31

{"error":"method not allowed"}
$ curl -s localhost:8080/tasks -d 'title=Walk the dog' -w '%{http_code}\n'
{"error":"Content-Type must be application/json"}
415
$ curl -s localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": "  "}' -w '%{http_code}\n'
{"error":"title must not be empty"}
422
$ curl -s localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": 42}' -w '%{http_code}\n'
{"error":"only strings, true and false are accepted as values"}
400
$ printf '{"title": "%s"}' "$(printf 'a%.0s' {1..20000})" > big.json
$ curl -s localhost:8080/tasks -H 'Content-Type: application/json' -d @big.json -w '%{http_code}\n'
{"error":"request body must not be larger than 16384 bytes"}
413
$ curl -s --http2 localhost:8080/tasks -o /dev/null -w '%{http_version}\n'
1.1

O grep tira o header Date, que muda a cada segundo. O JDK envia Content-type, não Content-Type, porque só põe em maiúscula a primeira letra do nome de um header. Nomes de header não diferenciam maiúsculas e minúsculas, então os clientes não se importam. O último comando pediu upgrade para HTTP/2, e o servidor ficou no 1.1.

Apertar Ctrl+C no primeiro terminal imprimiu stopping e stopped, e a JVM saiu com status 130.

Verificando o projeto só com ferramentas do JDK

O run-checks.sh do projeto usa só javac e java. Ele compila o módulo com warnings tratados como erros, e depois compila e roda checks/SmokeCheck.java, que inicia o serviço na porta 0 na própria JVM:

    public static void main(String[] args) throws Exception {
        try (TaskServer server = TaskServer.start(new InMemoryTaskStore(), 0);
                HttpClient c = HttpClient.newHttpClient()) {
            client = c;
            base = "http://localhost:" + server.port();
            checkEndpoints();
            checkBadRequests();
            checkConcurrentCreates();
        }
        if (failures > 0) {
            IO.println(failures + " check(s) failed");
            System.exit(1);
        }
    }

Ele chama cada endpoint com HttpClient e compara status codes, headers e corpos, incluindo 100 requisições POST enviadas de uma vez, que precisam receber 100 ids diferentes. Depois o script inicia o Main de verdade na porta 0, envia SIGTERM para ele e verifica que o hook rodou:

$ ./run-checks.sh
ok  compiled com.example.tasks
ok  smoke check: 45 checks passed
ok  Main starts on port 0 and stops cleanly on SIGTERM
ok  a bad port prints usage and exits 2
ok  tests: 18 passed, 0 failed
ok  java -p tasks.jar -m com.example.tasks starts and stops
ok  jlink image with com.example.tasks,java.base,jdk.httpserver starts and stops
all checks passed

As quatro primeiras linhas são as verificações desta parte. É um smoke check, não uma suíte de testes. As três últimas vêm da próxima parte, que monta um harness de testes para este projeto e o empacota com jlink e jpackage.

O que com.sun.net.httpserver não faz

com.sun.net.httpserver é uma API do JDK com suporte, apesar do nome, exportada pelo módulo jdk.httpserver. Ela serve para pequenos serviços internos, ferramentas e servidores falsos em testes. Para um serviço público em produção, ela deixa muita coisa para você escrever:

  • Sem HTTP/2. O HttpClient e o curl --http2 receberam HTTP/1.1, o curl --http2-prior-knowledge não recebeu resposta, e o módulo não tem classes de HTTP/2.
  • Sem roteador. Os contexts casam por prefixo de string, sem parâmetros de caminho e sem roteamento por método.
  • Sem JSON nem validação. A classe Json é sua para manter.
  • Limites são propriedades de sistema, não uma API. Limites de tempo e de headers são flags -D, como sun.net.httpserver.maxReqTime.
  • Sem servidor WebSocket, embora java.net.http tenha um cliente WebSocket.
  • HTTPS é manual. HttpsServer existe, mas você configura o SSLContext por conta própria.

Para produção, as escolhas comuns são Jetty, um framework construído sobre o Netty, como Vert.x ou Micronaut, e Spring Boot. Eles trazem roteamento, HTTP/2, binding de JSON, configuração de TLS e métricas. A interface do store e os status codes deste post valem para qualquer um deles.

O que lembrar

  • HttpClient.newHttpClient() cria um cliente reutilizável. send bloqueia, sendAsync retorna um CompletableFuture, e BodyHandlers.ofString() lê o corpo como texto.
  • Defina connectTimeout no cliente e timeout em cada requisição. Redirects não são seguidos a não ser que você defina Redirect.NORMAL.
  • Os contexts do HttpServer casam por prefixo de string, então verifique o caminho e o método exatos no handler. A porta 0 escolhe uma porta livre, e getAddress().getPort() diz qual.
  • sendResponseHeaders recebe uma contagem de bytes: um número positivo é o tamanho exato, 0 significa chunked, e -1 significa sem corpo. Defina os headers antes de chamá-lo.
  • Sem executor, uma thread atende todas as requisições. setExecutor(Executors.newVirtualThreadPerTaskExecutor()) dá a cada requisição a própria virtual thread.
  • O JDK não tem API de JSON. Escreva à mão um subconjunto pequeno e rígido, ou use Jackson ou Gson.
  • Limite o corpo das requisições, responda todo erro num só formato JSON e pare o servidor num shutdown hook.

O servidor HTTP do JDK basta para um serviço pequeno, desde que você saiba quais partes agora está escrevendo por conta própria.

Quanto este post te ajudou?

Clique em um coração para avaliar!

Média das avaliações 0 / 5. Total de votos: 0

Nenhum voto até agora. Seja o primeiro a avaliar este post.