Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add reactive progressive rendering features to MustacheView #16829

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
.DS_Store
.classpath
.factorypath
.attach_pid*
.gradle
.idea
.metadata
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public MustacheViewResolver mustacheViewResolver(Compiler mustacheCompiler,
resolver.setPrefix(mustache.getPrefix());
resolver.setSuffix(mustache.getSuffix());
resolver.setViewNames(mustache.getViewNames());
resolver.setCache(mustache.isCache());
resolver.setRequestContextAttribute(mustache.getRequestContextAttribute());
resolver.setCharset(mustache.getCharsetName());
resolver.setOrder(Ordered.LOWEST_PRECEDENCE - 10);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@

package org.springframework.boot.autoconfigure.mustache;

import java.time.Duration;
import java.util.Date;

import com.samskivert.mustache.Mustache;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
Expand All @@ -35,6 +38,7 @@
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.ui.Model;
Expand All @@ -46,7 +50,7 @@
* Integration Tests for {@link MustacheAutoConfiguration}, {@link MustacheViewResolver}
* and {@link MustacheView}.
*
* @author Brian Clozel
* @author Brian Clozel, Dave Syer
*/
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT,
properties = "spring.main.web-application-type=reactive")
Expand All @@ -69,6 +73,14 @@ public void testPartialPage() {
assertThat(result).contains("Hello App").contains("Hello World");
}

@Test
public void testSse() {
this.client.get().uri("/sse").exchange() //
.expectBody(String.class).value(Matchers.containsString("event: message"))
.value(Matchers.containsString("\ndata: <span>Hello</span>"))
.value(Matchers.containsString("World")).value(Matchers.endsWith("\n\n"));
}

@Configuration(proxyBeanMethods = false)
@Import({ ReactiveWebServerFactoryAutoConfiguration.class,
WebFluxAutoConfiguration.class, HttpHandlerAutoConfiguration.class,
Expand All @@ -92,6 +104,16 @@ public String layout(Model model) {
return "partial";
}

@RequestMapping(path = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public String sse(Model model) {
model.addAttribute("time", new Date());
model.addAttribute("flux.message",
Flux.just("<span>Hello</span>", "<span>World</span>")
.delayElements(Duration.ofMillis(10)));
model.addAttribute("title", "Hello App");
return "sse";
}

@Bean
public MustacheViewResolver viewResolver() {
Mustache.Compiler compiler = Mustache.compiler().withLoader(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{{#flux.message}}
event: message
{{#ssedata}}
<h2>Title</h2>
{{{.}}}
{{/ssedata}}
{{/flux.message}}
Original file line number Diff line number Diff line change
Expand Up @@ -3032,6 +3032,54 @@ Spring Boot includes auto-configuration support for the following templating eng
When you use one of these templating engines with the default configuration, your
templates are picked up automatically from `src/main/resources/templates`.

==== Mustache Views

There are some special features of the `MustacheView` that make it suitable for handling the rendering of reactive elements. Most browsers will start to show content before the HTML tags are closed, so you can drip feed a list or a table into the view as the content becomes available.

===== Progressive Rendering

A model element of type `Publisher` will be left in the model (instead of expanding it before the view is rendered), if its name starts with "flux" or "mono" or "publisher". The `View` is then rendered and flushed to the HTTP response as soon as each element is published. Browsers are really good at rendering partially complete HTML, so the flux elements will most likely be visible to the user as soon as they are available. This is useful for rendering the "main" content of a page if it is a list or a table, for instance.

===== Sserver Sent Event (SSE) Support

To render a `View` with content type `text/event-stream` you need a model element of type `Publisher`, and also a template that includes that element (probably starts and ends with it). There is a convenience Lambda (`ssedata`) added to the model for you that prepends every line with `data:` - you can use it if you wish to simplify the rendering of the data elements. Two new lines are added after each item in `{{#ssedata}}`. E.g. with an element called `flux.events` of type `Flux<Event>`:

```
{{#flux.events}}
event: message
id: {{id}}
{{#ssedata}}
<div>
<span>Name: {{name}}<span>
<span>Value: {{value}}<span>
</div>
{{/ssedata}}
{{/flux.events}}
```

the output will be

```
event: message
id: 0
data: <div>
data: <span>Name: foo<span>
data: <span>Value: bar<span>
data: </div>


event: message
id: 1
data: <div>
data: <span>Name: spam<span>
data: <span>Value: bucket<span>
data: </div>


... etc.
```

assuming the `Event` object has fields `id`, `name`, `value`.


[[boot-features-webflux-error-handling]]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* Copyright 2019-2019 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.springframework.boot.web.reactive.result.view;

import java.io.IOException;
import java.io.Writer;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;

import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import org.springframework.core.io.buffer.DataBuffer;

/**
* A {@link Writer} that can write a {@link Flux} (or {@link Publisher}) to a data buffer.
* Used to render progressive output in a {@link MustacheView}.
*
* @author Dave Syer
*/
class FluxWriter extends Writer {

private final Supplier<DataBuffer> factory;

private final Charset charset;

private List<String> current = new ArrayList<>();

private List<Object> accumulated = new ArrayList<>();

FluxWriter(Supplier<DataBuffer> factory) {
this(factory, Charset.defaultCharset());
}

FluxWriter(Supplier<DataBuffer> factory, Charset charset) {
this.factory = factory;
this.charset = charset;
}

public Publisher<? extends Publisher<? extends DataBuffer>> getBuffers() {
Flux<String> buffers = Flux.empty();
if (!this.current.isEmpty()) {
this.accumulated.add(new ArrayList<>(this.current));
this.current.clear();
}
for (Object thing : this.accumulated) {
if (thing instanceof Publisher) {
@SuppressWarnings("unchecked")
Publisher<String> publisher = (Publisher<String>) thing;
buffers = buffers.concatWith(publisher);
}
else {
@SuppressWarnings("unchecked")
List<String> list = (List<String>) thing;
buffers = buffers.concatWithValues(list.toArray(new String[0]));
}
}
return buffers.map((string) -> Mono.just(buffer().write(string, this.charset)));
}

@Override
public void write(char[] cbuf, int off, int len) throws IOException {
this.current.add(new String(cbuf, off, len));
}

@Override
public void flush() throws IOException {
}

@Override
public void close() throws IOException {
}

public void release() {
// TODO: maybe implement this and call it on error
}

private DataBuffer buffer() {
return this.factory.get();
}

public void write(Object thing) {
if (thing instanceof Publisher) {
if (!this.current.isEmpty()) {
this.accumulated.add(new ArrayList<>(this.current));
this.current.clear();
}
this.accumulated.add(thing);
}
else {
if (thing instanceof String) {
this.current.add((String) thing);
}
}
}

}
Loading