From 1d8ebdf9107566e2fe20307b5a8fea08954d6f5e Mon Sep 17 00:00:00 2001 From: Dave Syer Date: Mon, 13 May 2019 07:49:35 +0100 Subject: [PATCH 1/6] Add reactive progressive rendering features to MustacheView --- .gitignore | 1 + .../MustacheReactiveWebConfiguration.java | 1 + ...ConfigurationReactiveIntegrationTests.java | 24 ++- .../resources/mustache-templates/sse.html | 7 + .../main/asciidoc/spring-boot-features.adoc | 48 ++++++ .../web/reactive/result/view/FluxWriter.java | 93 +++++++++++ .../reactive/result/view/MustacheView.java | 158 +++++++++++++++--- .../result/view/MustacheViewResolver.java | 21 ++- .../result/view/MustacheViewTests.java | 55 +++++- .../boot/web/reactive/result/view/flux.html | 4 + .../boot/web/reactive/result/view/sse.html | 6 + .../web/reactive/result/view/ssedata.html | 6 + 12 files changed, 400 insertions(+), 24 deletions(-) create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/resources/mustache-templates/sse.html create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/result/view/FluxWriter.java create mode 100644 spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/reactive/result/view/flux.html create mode 100644 spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/reactive/result/view/sse.html create mode 100644 spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/reactive/result/view/ssedata.html diff --git a/.gitignore b/.gitignore index 5bc4cc6abfdd..febbaa89d3c2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ .DS_Store .classpath .factorypath +.attach_pid* .gradle .idea .metadata diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mustache/MustacheReactiveWebConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mustache/MustacheReactiveWebConfiguration.java index f2c3f98a54ea..26956e4a0048 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mustache/MustacheReactiveWebConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mustache/MustacheReactiveWebConfiguration.java @@ -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); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mustache/MustacheAutoConfigurationReactiveIntegrationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mustache/MustacheAutoConfigurationReactiveIntegrationTests.java index 431387ad5880..e6fb5fbdd30b 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mustache/MustacheAutoConfigurationReactiveIntegrationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mustache/MustacheAutoConfigurationReactiveIntegrationTests.java @@ -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; @@ -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; @@ -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") @@ -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: Hello")) + .value(Matchers.containsString("World")).value(Matchers.endsWith("\n\n")); + } + @Configuration(proxyBeanMethods = false) @Import({ ReactiveWebServerFactoryAutoConfiguration.class, WebFluxAutoConfiguration.class, HttpHandlerAutoConfiguration.class, @@ -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("Hello", "World") + .delayElements(Duration.ofMillis(10))); + model.addAttribute("title", "Hello App"); + return "sse"; + } + @Bean public MustacheViewResolver viewResolver() { Mustache.Compiler compiler = Mustache.compiler().withLoader( diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/mustache-templates/sse.html b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/mustache-templates/sse.html new file mode 100644 index 000000000000..2f3a79267505 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/mustache-templates/sse.html @@ -0,0 +1,7 @@ +{{#flux.message}} +event: message +{{#ssedata}} +

Title

+{{{.}}} +{{/ssedata}} +{{/flux.message}} diff --git a/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc b/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc index 15550b8e3c59..fbb7a7e94219 100644 --- a/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc +++ b/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc @@ -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`: + +``` +{{#flux.events}} +event: message +id: {{id}} +{{#ssedata}} +
+ Name: {{name}} + Value: {{value}} +
+{{/ssedata}} +{{/flux.events}} +``` + +the output will be + +``` +event: message +id: 0 +data:
+data: Name: foo +data: Value: bar +data:
+ + +event: message +id: 1 +data:
+data: Name: spam +data: Value: bucket +data:
+ + +... etc. +``` + +assuming the `Event` object has fields `id`, `name`, `value`. [[boot-features-webflux-error-handling]] diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/result/view/FluxWriter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/result/view/FluxWriter.java new file mode 100644 index 000000000000..e5c635b85f46 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/result/view/FluxWriter.java @@ -0,0 +1,93 @@ +/* + * Copyright 2016-2017 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 + * + * http://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.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 factory; + + private final Charset charset; + + private Flux buffers; + + public FluxWriter(Supplier factory) { + this(factory, Charset.defaultCharset()); + } + + public FluxWriter(Supplier factory, Charset charset) { + this.factory = factory; + this.charset = charset; + this.buffers = Flux.empty(); + } + + public Publisher> getBuffers() { + return this.buffers + .map(string -> Mono.just(buffer().write(string, this.charset))); + } + + @Override + public void write(char[] cbuf, int off, int len) throws IOException { + this.buffers = this.buffers.concatWith(Mono.just(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) { + @SuppressWarnings("unchecked") + Publisher publisher = (Publisher) thing; + this.buffers = this.buffers.concatWith(Flux.from(publisher)); + } + else { + if (thing instanceof String) { + this.buffers = this.buffers.concatWith(Mono.just((String) thing)); + } + } + } + +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/result/view/MustacheView.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/result/view/MustacheView.java index 5a6e4bedcf5d..fad3a1813159 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/result/view/MustacheView.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/result/view/MustacheView.java @@ -16,24 +16,29 @@ package org.springframework.boot.web.reactive.result.view; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStreamReader; -import java.io.OutputStreamWriter; import java.io.Reader; +import java.io.StringWriter; import java.io.Writer; import java.nio.charset.Charset; +import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.Optional; +import com.samskivert.mustache.Mustache; import com.samskivert.mustache.Mustache.Compiler; import com.samskivert.mustache.Template; +import com.samskivert.mustache.Template.Fragment; +import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import org.springframework.core.io.Resource; -import org.springframework.core.io.buffer.DataBuffer; -import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.http.MediaType; import org.springframework.web.reactive.result.view.AbstractUrlBasedView; import org.springframework.web.reactive.result.view.View; @@ -42,7 +47,7 @@ /** * Spring WebFlux {@link View} using the Mustache template engine. * - * @author Brian Clozel + * @author Brian Clozel, Dave Syer * @since 2.0.0 */ public class MustacheView extends AbstractUrlBasedView { @@ -51,6 +56,18 @@ public class MustacheView extends AbstractUrlBasedView { private String charset; + private static Map templates = new HashMap<>(); + + private boolean cache = true; + + /** + * Flag to indiciate that templates ought to be cached. + * @param cache the flag value + */ + public void setCache(boolean cache) { + this.cache = cache; + } + /** * Set the JMustache compiler to be used by this view. Typically this property is not * set directly. Instead a single {@link Compiler} is expected in the Spring @@ -82,21 +99,68 @@ protected Mono renderInternal(Map model, MediaType content return Mono.error(new IllegalStateException( "Could not find Mustache template with URL [" + getUrl() + "]")); } - DataBuffer dataBuffer = exchange.getResponse().bufferFactory().allocateBuffer(); - try (Reader reader = getReader(resource)) { - Template template = this.compiler.compile(reader); - Charset charset = getCharset(contentType).orElse(getDefaultCharset()); - try (Writer writer = new OutputStreamWriter(dataBuffer.asOutputStream(), - charset)) { - template.execute(model, writer); - writer.flush(); + boolean sse = MediaType.TEXT_EVENT_STREAM.isCompatibleWith(contentType); + Charset charset = getCharset(contentType).orElse(getDefaultCharset()); + FluxWriter writer = new FluxWriter( + () -> exchange.getResponse().bufferFactory().allocateBuffer(), charset); + Mono