5.2 响应式响应流

WebFlux 响应式响应输出:Server-Sent Events (SSE) 实时推送、chunked Transfer-Encoding 流式传输、Flux 返回值自动适配、MediaType 协商与客户端消费背压衔接。附可运行示例。

SSE:Server-Sent Events

SSE 是服务器向客户端推送实时事件的标准协议。基于 HTTP/1.1,使用 text/event-stream MIME 类型,每个事件格式为:

event: tick
data: {"seq": 1, "time": 1700000000000}

id: 42
event: price
data: {"symbol": "BTC", "price": 42000}

:heartbeat

WebFlux 的 SSE 支持极其简洁:

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;

import java.time.Duration;

@RestController
public class SseController {

    @GetMapping(value = "/sse/ticks", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> ticks() {
        return Flux.interval(Duration.ofSeconds(1))
            // Spring 会把每个元素当作 SSE 的 data 字段并自动补 "data:",
            // 这里不要再手写 data: 和空行
            .map(i -> "{\"seq\":" + i + ",\"time\":" + System.currentTimeMillis() + "}");
    }
}

浏览器用 EventSource 消费:

const es = new EventSource('/sse/ticks');
es.onmessage = (e) => {
    const data = JSON.parse(e.data);
    console.log('seq:', data.seq, 'time:', data.time);
};

带事件类型的 SSE

上面的例子返回 Flux<String>,Spring 会把每个字符串当作 data 字段,自动补 data: 前缀和空行;如果字符串里再手写 data:,客户端会收到 data:data:...。要控制 id、event、retry 这些字段,才需要换成 ServerSentEvent:

import org.springframework.http.codec.ServerSentEvent;

@GetMapping(value = "/sse/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> events() {
    return Flux.interval(Duration.ofSeconds(1))
        .map(i -> ServerSentEvent.<String>builder()
            .id(String.valueOf(i))
            .event("tick")
            .data("seq=" + i)
            .build());
}

ServerSentEvent 会自动生成格式化的 SSE 帧:

id: 0
event: tick
data: seq=0

id: 1
event: tick
data: seq=1

Flux 响应与背压

Flux 返回值的背压如何工作?Spring WebFlux 内部把 Flux 订阅到 HTTP 输出流,订阅的 request(n) 与 Netty 的 isWritable() 联动:

@GetMapping(value = "/sse/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream() {
    return Flux.interval(Duration.ofMillis(100))
        .map(i -> "event-" + i);
}

执行链:

Flux.interval(100ms)          ← 数据源每 100ms 生成一个 tick
    ↓ onBackpressure...       ← WebFlux 自动背压
HttpMessageWriter             ← Spring 的 SSE writer
    ↓ request(n)              ← 根据 TCP 可写状态动态调整
Reactor Netty HttpServerResponse
    ↓ writeWith()             ← Netty ByteBuf 缓冲区
TCP send buffer               ← 滑动窗口
    ↓
客户端 EventSource            ← onmessage 回调消费

如果客户端处理慢(如 JS 回调中有繁重计算),EventSource 的内部缓冲会积压,TCP 接收窗口缩小,Netty 写缓冲变得不可写,WebFlux 的 writer 随之减少向上游的 request(n)。但要注意:Flux.interval 是定时源,不会因为下游请求变少而降低发射频率;下游没有请求时,它静默丢弃当前 tick(debug 日志里能看到 Dropping tick),不会把元素留到下一次。需要保留每个 tick 时,应在 interval 之后接 onBackpressureBuffer()(并设上限),或者改成由业务事件驱动的 Sinks.Many。真正「按需生产」的源(如数据库游标、文件读取)才会自然跟随 request(n) 降速。

chunked Transfer-Encoding

SSE 是 text/event-stream 的持续响应,不是 chunked 的「特殊形式」:HTTP/1.1 下它通常由 Transfer-Encoding: chunked 承载,HTTP/2 下则直接走流式帧,没有 chunked 这一层。对于普通 HTTP 响应,也可以返回 Flux<DataBuffer> 做流式传输:

@GetMapping(value = "/download/large", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public Flux<DataBuffer> downloadLarge() {
    return DataBufferUtils.read(
        Path.of("/tmp/large-file.zip"),
        new DefaultDataBufferFactory(),
        8192  // 8KB buffer
    );
}

DataBufferUtils.read() 返回 Flux<DataBuffer>。HTTP/1.1 下响应没有 Content-Length,Spring 会用 chunked 分块输出;HTTP/2 下由协议帧承担分块。客户端逐块接收,无需等待整个文件加载。注意这是「分块读取 + 分块发送」,不是零拷贝:文件内容仍要经过 DataBuffer 和 Netty 的写路径。

自定义 Content-Type 协商

WebFlux 根据 Accept 请求头和 produces 属性协商响应格式:

@GetMapping(value = "/report", produces = {
    MediaType.APPLICATION_JSON_VALUE,
    "text/csv"
})
public Mono<Report> report(ServerWebExchange exchange) {
    List<MediaType> accepted = exchange.getRequest().getHeaders().getAccept();
    if (accepted.stream().anyMatch(mt -> mt.isCompatibleWith(MediaType.valueOf("text/csv")))) {
        return generateCsv();
    }
    return generateJson();
}

更正式的做法是使用 ContentNegotiationConfigurer:

@Configuration
public class WebConfig implements WebFluxConfigurer {
    @Override
    public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
        configurer
            .favorParameter(true)          // ?format=json 优先级高于 Accept 头
            .parameterName("format")
            .defaultContentType(MediaType.APPLICATION_JSON);
    }
}

响应式错误与 SSE

SSE 连接的错误处理比单次 HTTP 请求更复杂。如果 Flux 发出 onError,服务端响应以错误终止,连接关闭;浏览器会触发传输层 error 事件,并按规范默认在约 3 秒后自动重连(服务端可用 retry: 字段覆盖间隔)。把业务错误封装为事件只是让客户端能读到原因,并不会阻止自动重连:

@GetMapping(value = "/sse/safe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<Object>> safeStream() {
    return riskyDataSource()
        .map(data -> ServerSentEvent.<Object>builder()
            .event("data")
            .data(data)
            .build())
        .onErrorResume(err -> Flux.just(
            ServerSentEvent.<Object>builder()
                .event("error")
                .data(Map.of("message", err.getMessage()))
                .build()
        ));
}

客户端收到 event: error 后可以决定是否重连:

es.addEventListener('error', (e) => {
    if (typeof e.data === 'string') {
        // 服务端发送的 event: error,带业务错误数据
        console.log('Stream error:', JSON.parse(e.data));
        // 业务错误不需要重连时显式关闭;否则浏览器会自动重连
        es.close();
    } else {
        // 传输层错误(断网、服务端关闭)没有 data,浏览器会自动重连
        console.warn('SSE transport error; browser will retry');
    }
});

注意 addEventListener('error') 同时会收到传输层错误和服务端自定义的 event: error,两者的区别是后者带 e.data。不想重连时,客户端调用 es.close(),或服务端在重连请求上返回 204 No Content。

SSE vs WebSocket 选择

维度SSEWebSocket
方向单向:服务器 → 客户端双向
协议HTTP/1.1,自动重连独立协议,需手动重连
穿透代理好(纯 HTTP)差(某些 proxy 不支持 upgrade)
消息格式文本(JSON)文本或二进制
广播每个客户端独立连接可共享连接做广播

eventd v2 的实时推送同时使用两者:

  • SSE:服务端事件通知(价格变动、订单状态更新)——单向、自动重连
  • WebSocket:交互式场景(聊天、协同编辑)——双向、低延迟

测试 SSE 端点

用 curl 测试:

curl -N http://localhost:8080/sse/ticks
# data:{"seq":0,"time":1700000000000}
# 
# data:{"seq":1,"time":1700000000100}

-N(--no-buffer)关闭 curl 的输出缓冲,保证实时看到事件。

用 WebTestClient 测试(第 6.3 节):

webTestClient.get().uri("/sse/ticks")
    .accept(MediaType.TEXT_EVENT_STREAM)
    .exchange()
    .expectStatus().isOk()
    .expectHeader().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM)
    .returnResult(String.class)
    .getResponseBody()
    .take(3)
    .collectList()
    .block();

小结

能力API注意
基本 SSE@GetMapping(produces = TEXT_EVENT_STREAM) + FluxSpring 自动补 data:,不要手写
结构化 SSEFlux<ServerSentEvent<T>>自动格式化 id/event/data/retry
背压WebFlux 内部自动客户端慢 → request(n) 减小;定时源不会自动降速
chunkedFlux<DataBuffer>大文件流式分块,不是零拷贝
格式协商ContentNegotiationConfigurer?format=json 或 Accept 头
错误处理onErrorResume 转为 error 事件避免 EventSource 自动关闭

下一节 5.3 错误契约 :全局异常处理器、统一 JSON 错误体、错误码体系、与 HTTP 状态码的映射。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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