1.2 请求响应管线

在 Reactor Netty 的 handle 回调中构建完整的请求响应管线:URI 路由分发与路径参数提取、请求体异步聚合与 JSON 解码、Content-Type 字符集声明、中间件链式组合与统一错误处理、阻塞操作 subscribeOn 隔离。附可编译运行示例。

URI 路由分发

最小服务器只有一个 handle() 回调,真实服务需要按路径分发。最朴素的实现是用 req.path() 做分支:

import reactor.core.publisher.Mono;
import reactor.netty.http.server.HttpServer;
import reactor.netty.http.server.HttpServerRequest;
import reactor.netty.http.server.HttpServerResponse;

public class RoutedServer {
    public static void main(String[] args) {
        HttpServer.create().port(8080)
            .handle(RoutedServer::route)
            .bindNow();
        System.out.println("http://localhost:8080");
        try { System.in.read(); } catch (Exception ignored) {}
    }

    static Mono<Void> route(HttpServerRequest req, HttpServerResponse res) {
        return switch (req.path()) {
            case "/health" -> health(req, res);
            case "/users"  -> users(req, res);
            default        -> res.status(404).send();
        };
    }

    static Mono<Void> health(HttpServerRequest req, HttpServerResponse res) {
        return res.status(200)
            .header("Content-Type", "application/json")
            .sendString(Mono.just("{\"status\":\"up\"}"))
            .then();  // sendString 返回 NettyOutbound,不是 Mono
    }

    static Mono<Void> users(HttpServerRequest req, HttpServerResponse res) {
        return res.status(200)
            .header("Content-Type", "application/json")
            .sendString(Mono.just("[\"alice\",\"bob\"]"))
            .then();
    }
}

这种 switch 写法适合演示和小服务。第 3 章的 eventd v2 网关会用更正式的中间件链替代它。

路径参数提取

REST API 需要路径参数,例如 /users/123。手动解析:

static Mono<Void> userById(HttpServerRequest req, HttpServerResponse res) {
    String path = req.path();           // /users/123
    String id = path.substring(path.lastIndexOf('/') + 1);
    return res.status(200)
        .sendString(Mono.just("User: " + id));
}

更健壮的方案是使用正则或预编译模式匹配。网关层(第 3 章)会封装通用的路由表,避免每个 handler 自己解析。

请求体读取与 JSON 解码

请求体是异步字节流,需要用 receive() 读取并聚合:

import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;

static Mono<Void> createUser(HttpServerRequest req, HttpServerResponse res) {
    // 1. 先把字节流聚合成一个 ByteBuf,再按 UTF-8 解码
    //    不要直接对 ByteBufFlux 调 asString():它按每个 ByteBuf 分片各自解码,
    //    一个中文字符如果跨 TCP 分片就会被拆坏
    Mono<String> bodyMono = req.receive()
        .aggregate()                        // ByteBufFlux → ByteBufMono
        .asString(StandardCharsets.UTF_8);  // 整体解码

    // 2. 解析 JSON(这里用手动解析演示;生产用 Jackson)
    return bodyMono.flatMap(json -> {
        String name = extractName(json);   // 简易解析:找 "name":"..."
        System.out.println("Creating user: " + name);
        return res.status(201)
            .header("Content-Type", "application/json")
            .sendString(Mono.just("{\"id\":1,\"name\":\"" + name + "\"}"))
            .then();  // flatMap 要求返回 Mono,NettyOutbound 不是 Mono
    });
}

static String extractName(String json) {
    int start = json.indexOf("\"name\":\"");
    if (start < 0) {
        throw new IllegalArgumentException("missing name field");
    }
    start += 8;
    int end = json.indexOf('"', start);
    if (end < 0) {
        throw new IllegalArgumentException("unterminated name field");
    }
    return json.substring(start, end);
}

req.receive() 返回 ByteBufFlux(即分片的 Flux<ByteBuf>)。关键操作链:

  1. .aggregate() —— 把多个 ByteBuf 合并成一个 ByteBufMono。它没有 maxInMemorySize 之类的总量保护(Reactor Netty 的 HTTP 解码器只有单行、单头、单 chunk 上限),整个 body 会被读进内存,必须自己在应用层限制请求体大小
  2. .asString(Charset) —— 对合并后的完整字节做一次解码;直接对 ByteBufFlux 调 asString() 是逐分片解码,跨分片的多字节字符会被破坏
  3. .flatMap(process) —— 业务处理,返回新的 Mono<Void>

请求体不适合整体读入内存时(大文件上传),应改为流式处理 Flux<DataBuffer> / Flux<ByteBuf>,而不是 aggregate()。

Content-Type 与状态码的统一处理

每个 handler 都写 .header("Content-Type", ...) 很重复。用一个包装函数:

static Mono<Void> jsonResponse(HttpServerResponse res, int status, String body) {
    return res.status(status)
        .header("Content-Type", "application/json; charset=utf-8")
        .sendString(Mono.just(body))
        .then();
}

static Mono<Void> health(HttpServerRequest req, HttpServerResponse res) {
    return jsonResponse(res, 200, "{\"status\":\"up\"}");
}

这里的 charset=utf-8 其实可以省略:application/json 按 RFC 8259 固定使用 UTF-8,客户端不应依赖 charset 参数。真正需要显式声明的是 text/plain、text/html 这类 text/* 响应——没有 charset 时浏览器会用页面或系统的默认编码解析,中文才可能变成乱码。统一写成 application/json 即可,把 charset 留给纯文本响应。

中间件链式组合

真实服务需要在 handler 之前执行日志、认证、跨域等横切逻辑。用函数组合实现中间件:

import java.util.function.BiFunction;

@FunctionalInterface
interface Handler extends BiFunction<HttpServerRequest, HttpServerResponse, Mono<Void>> {}

static Handler withLogging(Handler next) {
    return (req, res) -> {
        long start = System.nanoTime();
        System.out.println("→ " + req.method().name() + " " + req.path());
        return next.apply(req, res)
            .doFinally(sig -> {
                long ms = (System.nanoTime() - start) / 1_000_000;
                System.out.println("← " + req.path() + " in " + ms + "ms [" + sig + "]");
            });
    };
}

static Handler withCors(Handler next) {
    return (req, res) -> {
        res.header("Access-Control-Allow-Origin", "*");
        if ("OPTIONS".equals(req.method().name())) {
            res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
            return res.status(204).send();
        }
        return next.apply(req, res);
    };
}

// 使用
Handler app = withCors(withLogging(RoutedServer::route));
HttpServer.create().port(8080)
    .handle(app::apply)
    .bindNow();

中间件顺序很重要:CORS 必须排在业务路由之前,OPTIONS 预检请求才不会落到业务 handler 里变成 404。最终按 withErrorHandler(withCors(withLogging(...))) 组合时,错误处理在最外层,CORS 次之,日志在内层。

错误处理

handler 中的异常会传播到 Publisher,Reactor Netty 默认返回 500 空响应。添加统一错误处理:

static Handler withErrorHandler(Handler next) {
    return (req, res) ->
        next.apply(req, res)
            .onErrorResume(err -> {
                System.err.println("Error handling " + req.path() + ": " + err.getMessage());
                return jsonResponse(res, 500,
                    "{\"error\":\"" + err.getClass().getSimpleName() + "\"}");
            });
}

组合:withErrorHandler(withCors(withLogging(...))),错误处理在最外层,能捕获所有下游异常。

这个包装只在响应尚未提交时有效。如果 handler 已经开始 sendString 并写出一部分数据,再遇到错误,jsonResponse(res, 500, ...) 无法改回状态码,Reactor Netty 会直接中断连接。需要「写出过程中出错也能给出结构化响应」的接口,要么先聚合成完整字节再一次性发送,要么改用 WebFlux 的 ResponseEntity/ServerResponse,由框架判断响应是否已提交(见第 4 章)。

阻塞操作的隔离

如果你的业务逻辑必须调用阻塞 API(如 JDBC),绝对不能在 EventLoop 线程执行。使用 subscribeOn 切换调度器:

static Mono<Void> slowHandler(HttpServerRequest req, HttpServerResponse res) {
    return Mono.fromCallable(() -> {
            // 模拟阻塞操作(如 JDBC 查询)
            Thread.sleep(100);
            return "Result";
        })
        .subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic())
        .flatMap(result -> res.sendString(Mono.just(result)).then());
}

Schedulers.boundedElastic() 创建有上限的弹性线程池,适合包裹阻塞 I/O。不要用 parallel():它是按 CPU 核数固定的线程池,为 CPU 密集计算设计,线程数少且不弹性;多个阻塞调用会把它占满,反而拖住所有使用 parallel() 的算子。

小结

能力API注意点
路由分发req.path() + switch 或模式匹配网关层会封装更正式的路由表
请求体读取req.receive().aggregate().asString()先聚合再解码,避免多字节字符跨分片被拆坏
响应编码.header("Content-Type", ...) + .sendString()JSON 不用带 charset,text/* 才需要
中间件函数包装 Handler顺序:错误处理 → CORS → 日志 → 业务
阻塞隔离subscribeOn(boundedElastic())绝不在 EventLoop 调用阻塞 API

下一节 1.3 HTTP 层的背压 :TCP 发送窗口、接收缓冲、Reactor Netty 的 writeWith() 背压衔接、慢客户端保护。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 15.3 全书总结与学习路径回顾
  2. 15.2 响应式流处理的演进趋势
  3. 15.1 引擎选型指南