javaspring-bootspring-webflux

Why is a Flux<String> being collapsed into a single String when returned via ServerResponse, unless each String element is terminated with "\n"?


While playing around with Spring Webflux and WebClient, I noted a behavior (demonstrated by the code below) when returning a ServerResponse containing a Flux<String>. Unless the String elements are terminated with a newline character, returning the Flux<String> via a ServerResponse appears to concatenate all Flux<String> elements into a single String. Can someone explain to me why I'm seeing this behavior, and what I am doing to cause it?

When each String element is terminated with a newline character, the Flux<String> is "returned as expected" via ServerResponse, and subscribing to the returned Flux<String> produces expected results. However, if viewed as simple JSON (via Postman), this also results in an extra, empty, String element being returned in the JSON body.

Console output showing described behavior...

  1. The first listing of String elements occurs in StringProducerHandler.getAll(), and indicates the results of a Flux<String> containing 10 String elements, where the second occurrence of each String value is terminated with a newline character, resulting in a blank line being output.
  2. The second listing of String elements occurs in StringClient.getAll(), and demonstrates how the original String elements that were not terminated with a newline character have been concatenated with the following element.

    2019-10-10 10:13:37.225 INFO 8748 --- [ main] o.s.b.web.embedded.netty.NettyWebServer : Netty started on port(s): 8080 2019-10-10 10:13:37.228 INFO 8748 --- [ main] c.example.fluxtest.FluxTestApplication : Started FluxTestApplication in 1.271 seconds (JVM running for 1.796)

    ***** "Get" issued on http:/localhost:8080/StringClient/String StringClientHandler.getAll( ServerRequest ) StringClient.getAll() StringProducerHandler.getAll( ServerRequest ) ListElement-0 ListElement-0

    ListElement-1 ListElement-1

    ListElement-2 ListElement-2

    ListElement-3 ListElement-3

    ListElement-4 ListElement-4

    ListElement-0ListElement-0 @ 1570727628948 ListElement-1ListElement-1 @ 1570727628948 ListElement-2ListElement-2 @ 1570727628948 ListElement-3ListElement-3 @ 1570727628948 ListElement-4ListElement-4 @ 1570727628949

Code to replicate this behavior is provided below...

@SpringBootApplication
public class FluxTestApplication {

    public static void main(String[] args) {
        SpringApplication.run(FluxTestApplication.class, args);
    }
}


@Configuration
public class StringClientRouter {
    @Bean
    public RouterFunction<ServerResponse> clientRoutes(StringClientHandler requestHandler) {
        return nest(path("/StringClient"),
                nest(accept(APPLICATION_JSON),
                        RouterFunctions.route(RequestPredicates.GET("/String"), requestHandler::getAll)));
    }
}


@Component
public class StringClientHandler {

    @Autowired
    StringClient stringClient;

    public Mono<ServerResponse> getAll(ServerRequest request) {
        System.out.println("StringClientHandler.getAll( ServerRequest )");

        Mono<Void> signal = stringClient.getAll();
            return ServerResponse.ok().build();
        }
    }


@Component
public class StringClient {

    private final WebClient client;

    public StringClient() {
        client = WebClient.create();
    }

    public Mono<Void> getAll() {
        System.out.println("StringClient.getAll()");

        // break chain to explicitly obtain the ClientResponse
        Mono<ClientResponse> monoCR = client.get().uri("http://localhost:8080/StringProducer/String")
                                                  .accept(MediaType.APPLICATION_JSON)
                                                  .exchange();

        // extract the Flux<String> and print to console
        Flux<String> fluxString = monoCR.flatMapMany(response -> response.bodyToFlux(String.class));
        // this statement iterates over the Flux<String> and outputs each element
        fluxString.subscribe(strVal -> System.out.println(strVal + " @ " + System.currentTimeMillis()));

        return Mono.empty();
    }
}


@Configuration
public class StringProducerRouter {
    @Bean
    public RouterFunction<ServerResponse> demoPOJORoute(StringProducerHandler requestHandler) {
        return nest(path("/StringProducer"),
                nest(accept(APPLICATION_JSON),
                        RouterFunctions.route(RequestPredicates.GET("/String"), requestHandler::getAll)));
    }
}


@Component
public class StringProducerHandler {

    public Mono<ServerResponse> getAll(ServerRequest request) {
        System.out.println("StringProducerHandler.getAll( ServerRequest )");

        int          listSize = 5;
        List<String> strList  = new ArrayList<String>();

        for (int i=0; i<listSize; i++) {
            strList.add("ListElement-" + i);         // add String value without newline termination
            strList.add("ListElement-" + i + "\n");  // add String value with    newline termination
        }

        // this statement produces the expected console output of String values
        Flux.fromIterable(strList).subscribe(System.out::println);

        return ServerResponse.ok()
                             .contentType(MediaType.APPLICATION_JSON)
                             .body(Flux.fromIterable(strList), String.class);
    }
}

Solution

  • This is due to how org.springframework.core.codec.StringDecoder works.

    When you call response.bodyToFlux(String.class) the response body is transformed into a Flux of Strings. The org.springframework.core.codec.StringDecoder does the heavy lifting and has the opinion that it should split on default delimiters.

    List<byte[]> delimiterBytes = getDelimiterBytes(mimeType);
    
    Flux<DataBuffer> inputFlux = Flux.from(input)
        .flatMapIterable(buffer -> splitOnDelimiter(buffer, delimiterBytes))
        .bufferUntil(buffer -> buffer == END_FRAME)
        .map(StringDecoder::joinUntilEndFrame)
        .doOnDiscard(PooledDataBuffer.class, DataBufferUtils::release);
    
    return super.decode(inputFlux, elementType, mimeType, hints);
    

    The default delimiters are public static final List<String> DEFAULT_DELIMITERS = Arrays.asList("\r\n", "\n");

    Therefore you get:

    ListElement-0ListElement-0 @ 1570732000374
    ListElement-1ListElement-1 @ 1570732000375
    ...
    

    instead of

    ListElement-0 @ 1570732055461
    ListElement-0 @ 1570732055461
    ListElement-1 @ 1570732055462
    ListElement-1 @ 1570732055462
    ...