Skip to content

Reactive View Rendering #985

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

Open
wants to merge 17 commits into
base: 5.7.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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 gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ thymeleaf-extras-java8time = { module = "org.thymeleaf.extras:thymeleaf-extras-j
kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
groovy-json = { module = "org.apache.groovy:groovy-json" }
reactor-test = { module = "io.projectreactor:reactor-test" }
graal-polyglot = { module = "org.graalvm.polyglot:polyglot", version.ref = "graal" }
graal-js = { module = "org.graalvm.polyglot:js", version.ref = "graal" }
sonatype-scan = { module = "org.sonatype.gradle.plugins:scan-gradle-plugin", version.ref = "sonatype-scan" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,6 @@ The callback object has a few different APIs you can use:
1. `write(string)`: Writes the given string to the network response.
2. `write(bytes)`: Writes the given array of bytes to the network response.
3. `url()`: Returns either null or a string containing the URL of the page being served. Useful for sending to page routers.
4. `complete()`: Completes the callback

IMPORTANT: Please, note you must call `complete()` to end the rendering.
45 changes: 39 additions & 6 deletions views-core/src/main/java/io/micronaut/views/ViewsFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
import io.micronaut.http.filter.ServerFilterPhase;
import io.micronaut.views.exceptions.ViewNotFoundException;
import io.micronaut.views.exceptions.ViewRenderingException;
import io.micronaut.views.reactive.ReactiveViewsRenderer;
import io.micronaut.views.reactive.ReactiveViewsRendererLocator;
import io.micronaut.views.turbo.TurboFrame;
import io.micronaut.views.turbo.TurboFrameRenderer;
import io.micronaut.views.turbo.TurboStreamRenderer;
Expand All @@ -42,6 +44,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.util.Collections;
import java.util.List;
Expand All @@ -68,7 +71,9 @@ public class ViewsFilter implements HttpServerFilter {

/**
* Views Render Locator.
* @deprecated unused
*/
@Deprecated(forRemoval = true, since = "5.8.0")
protected final ViewsRendererLocator viewsRendererLocator;

/**
Expand All @@ -86,14 +91,37 @@ public class ViewsFilter implements HttpServerFilter {
*/
protected final TurboFrameRenderer turboFrameRenderer;

protected final ReactiveViewsRendererLocator reactiveViewsRendererLocator;

/**
* Constructor.
* @param viewsResolver Views Resolver
* @param viewsRendererLocator ViewRendererLocator
* @param reactiveViewsRendererLocator ViewRendererLocator
* @param viewsModelDecorator Views Model Decorator
* @param turboFrameRenderer Turbo Frame renderer
*/
@Inject
public ViewsFilter(ViewsResolver viewsResolver,
ReactiveViewsRendererLocator reactiveViewsRendererLocator,
ViewsModelDecorator viewsModelDecorator,
TurboFrameRenderer turboFrameRenderer) {
this.viewsResolver = viewsResolver;
this.viewsRendererLocator = null;
this.reactiveViewsRendererLocator = reactiveViewsRendererLocator;
this.viewsModelDecorator = viewsModelDecorator;
this.turboStreamRenderer = null;
this.turboFrameRenderer = turboFrameRenderer;
}

/**
* Constructor.
* @param viewsResolver Views Resolver
* @param viewsRendererLocator ViewRendererLocator
* @param viewsModelDecorator Views Model Decorator
* @param turboFrameRenderer Turbo Frame renderer
* @deprecated Use {@link ViewsFilter(ViewsResolver, ReactiveViewsRendererLocator, ViewsModelDecorator, TurboFrameRenderer)} instead.
*/
@Deprecated(forRemoval = true, since = "5.8.0")
public ViewsFilter(ViewsResolver viewsResolver,
ViewsRendererLocator viewsRendererLocator,
ViewsModelDecorator viewsModelDecorator,
Expand All @@ -102,6 +130,7 @@ public ViewsFilter(ViewsResolver viewsResolver,
this.viewsRendererLocator = viewsRendererLocator;
this.viewsModelDecorator = viewsModelDecorator;
this.turboStreamRenderer = null;
this.reactiveViewsRendererLocator = null;
this.turboFrameRenderer = turboFrameRenderer;
}

Expand All @@ -125,6 +154,7 @@ public ViewsFilter(ViewsResolver viewsResolver,
this.viewsModelDecorator = viewsModelDecorator;
this.turboStreamRenderer = turboStreamRenderer;
this.turboFrameRenderer = turboFrameRenderer;
this.reactiveViewsRendererLocator = null;
}

@Override
Expand Down Expand Up @@ -160,17 +190,20 @@ public final Publisher<MutableHttpResponse<?>> doFilter(HttpRequest<?> request,
}

try {
Optional<ViewsRenderer> optionalViewsRenderer = viewsRendererLocator.resolveViewsRenderer(view, type.getName(), body);
Optional<ReactiveViewsRenderer> optionalViewsRenderer = reactiveViewsRendererLocator.resolveViewsRenderer(view, type.getName(), body);
if (!optionalViewsRenderer.isPresent()) {
LOG.debug("no view renderer found for media type: {}, ignoring", type);
return Flux.just(response);
}
ReactiveViewsRenderer viewsRenderer = optionalViewsRenderer.get();
ModelAndView<?> modelAndView = new ModelAndView<>(view, body instanceof ModelAndView ? ((ModelAndView<?>) body).getModel().orElse(null) : body);
viewsModelDecorator.decorate(request, modelAndView);
Writable writable = optionalViewsRenderer.get().render(view, modelAndView.getModel().orElse(null), request);
response.contentType(type);
response.body(writable);
return Flux.just(response);
return Mono.from(viewsRenderer.render(view, modelAndView.getModel().orElse(null), request))
.map(b -> {
response.contentType(type);
response.body(b);
return response;
});
} catch (ViewNotFoundException | ViewRenderingException e) {
return Flux.error(e);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
* Copyright 2017-2023 original 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 io.micronaut.views.reactive;

import io.micronaut.context.ApplicationContext;
import io.micronaut.context.BeanContext;
import io.micronaut.core.annotation.AnnotationValue;
import io.micronaut.core.annotation.Internal;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.core.order.OrderUtil;
import io.micronaut.http.annotation.Produces;
import io.micronaut.inject.BeanDefinition;
import io.micronaut.inject.qualifiers.Qualifiers;
import io.micronaut.views.ViewsRenderer;
import io.micronaut.views.exceptions.ViewNotFoundException;
import jakarta.inject.Singleton;

import java.util.Collection;
import java.util.List;
import java.util.Arrays;
import java.util.Optional;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* Default implementation of {@link ReactiveViewsRendererLocator}.
*
* @author Sergio del Amo
* @since 3.0.0
*/
@Singleton
@Internal
final class DefaultReactiveViewsRendererLocator implements ReactiveViewsRendererLocator {

private final Map<ViewsRendererKey, ReactiveViewsRenderer> viewsRendererMap = new ConcurrentHashMap<>();

private final BeanContext beanContext;

DefaultReactiveViewsRendererLocator(ApplicationContext beanContext) {
this.beanContext = beanContext;
}

@Override
@NonNull
public Optional<ReactiveViewsRenderer> resolveViewsRenderer(@NonNull String view,
@NonNull String contentType,
@Nullable Object body) throws ViewNotFoundException {
Class<?> bodyClass = body != null ? body.getClass() : null;
ViewsRendererKey key = new ViewsRendererKey(view, contentType, bodyClass);
return Optional.ofNullable(viewsRendererMap.computeIfAbsent(key, (viewsRendererKey -> {
List<ReactiveViewsRenderer> viewsRenderers = resolveViewsRenderer(bodyClass, contentType);
if (viewsRenderers.isEmpty()) {
return null;
}
Optional<ReactiveViewsRenderer> result = viewsRenderers.stream()
.filter(viewsRenderer -> viewsRenderer.exists(view))
.findFirst();
if (result.isPresent()) {
return result.get();
}
throw new ViewNotFoundException("View [" + view + "] does not exist");
})));
}

/**
*
* @param bodyClass Response Body Class
* @param contentType Response Content Type
* @return List of {@link ViewsRenderer} which includes those which do not specify an {@link @Produces} annotation or
* whose {link @Produces} annotation value matches the response content type. The list is sorted. The order is those {@link ViewsRenderer} which
* type argument matches the response body class first and then ordered by {@link OrderUtil#COMPARATOR}.
*/
@NonNull
private List<ReactiveViewsRenderer> resolveViewsRenderer(Class<?> bodyClass, @NonNull String contentType) {
return reactiveViewsRenderersByBodyClass(bodyClass)
.stream()
.filter(vr -> producesContentType(contentType, vr))
.sorted((o1, o2) -> {
BeanDefinition o1BeanDefinition = beanDefinitionForViewRenderer(o1);
BeanDefinition o2BeanDefinition = beanDefinitionForViewRenderer(o2);
if (o1BeanDefinition.getTypeArguments().size() != o2BeanDefinition.getTypeArguments().size()) {
return Integer.compare(o1BeanDefinition.getTypeArguments().size(), o2BeanDefinition.getTypeArguments().size());
}
return OrderUtil.COMPARATOR.compare(o1, o2);
}).toList();
}

private Collection<ReactiveViewsRenderer> reactiveViewsRenderersByBodyClass(Class<?> bodyClass) {
if (bodyClass == null) {
return beanContext.getBeansOfType(ReactiveViewsRenderer.class);
}
Collection<ViewsRenderer> viewsRenderers = beanContext.getBeansOfType(ViewsRenderer.class, Qualifiers.byTypeArguments(bodyClass, Object.class));
return beanContext.getBeansOfType(ReactiveViewsRenderer.class, Qualifiers.byTypeArguments(bodyClass, Object.class, Object.class))
Comment on lines +106 to +107
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

think it would be better to remove the generics and add a supports(Class<?> type) method to the ReactiveViewsRenderer rather than 2 getBeansOfType calls which looks like a hack

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it is hackish. But we need to call twice because many ReactiveViewsRenderer implementations are backed by a ViewsRenderer, the ones using ReactiveViewsRendererAdapter.
For those implementations, we need to know if the delegated class supports the body class.

Note, that the lookup cost for the appropriate view renderer for a particular body class is paid only once as it is cached in a map.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok but I am unconvinced wrapping the non-reactive in a reactive API by default is the way to go. The Writable part not being shifted to the blocking thread pool is a problem

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be better to leave the ViewRenderer stuff as is and add an optional interface ReactiveViewRenderer that can be implemented by the view renderers that support it and then adapt the code with different if/else blocks the adding reactive overhead to all view renderers and changing the behaviour of Writable

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sdelamo can you look at splitting this so we are not adding reactive overhead unnecessary for non-reactive view renderers?

.stream()
.filter(reactiveViewsRenderer -> {
if (reactiveViewsRenderer instanceof ReactiveViewsRendererAdapter<?, ?> adapter) {
return viewsRenderers.stream()
.map(vr -> vr.getClass())
.anyMatch(clazz -> clazz == adapter.getDelegateClass());
}
return true;

})
.toList();
}

private BeanDefinition beanDefinitionForViewRenderer(ReactiveViewsRenderer reactiveViewsRenderer) {
if (reactiveViewsRenderer instanceof ReactiveViewsRendererAdapter<?, ?> adapter) {
return beanContext.getBeanDefinition(adapter.getDelegateClass());
}
return beanContext.getBeanDefinition(reactiveViewsRenderer.getClass());
}

private boolean producesContentType(@NonNull String contentType, ReactiveViewsRenderer reactiveViewsRenderer) {
BeanDefinition beanDefinition = beanDefinitionForViewRenderer(reactiveViewsRenderer);
return producesContentType(contentType, beanDefinition);
}

private boolean producesContentType(@NonNull String contentType, BeanDefinition beanDefinition) {
AnnotationValue<Produces> annotation = beanDefinition.getAnnotation(Produces.class);
if (annotation == null) {
return true;
}
return Arrays.asList(annotation.stringValues()).contains(contentType);
}

record ViewsRendererKey(String viewName, String contentType, Class<?> bodyClass) {

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Copyright 2017-2023 original 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 io.micronaut.views.reactive;

import io.micronaut.core.annotation.Experimental;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.core.async.annotation.SingleResult;
import io.micronaut.core.order.Ordered;
import org.reactivestreams.Publisher;

/**
* Interface to be implemented by View Engines implementations.
* @param <T> The model type
* @param <R> The request type
* @param <O> The response type
* @author Sergio del Amo
* @since 5.8.0
*/
@Experimental
public interface ReactiveViewsRenderer<T, R, O> extends Ordered {

/**
* @param viewName view name to be rendered
* @param data response body to render it with a view
* @param request HTTP request
* @return rendered view
*/
@NonNull
@SingleResult
Publisher<O> render(@NonNull String viewName,
@Nullable T data,
@Nullable R request);

/**
* @param viewName view name to be rendered
* @return true if a template can be found for the supplied view name.
*/
boolean exists(@NonNull String viewName);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright 2017-2023 original 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 io.micronaut.views.reactive;

import io.micronaut.core.annotation.Internal;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.core.async.annotation.SingleResult;
import io.micronaut.core.io.Writable;
import io.micronaut.views.ViewsRenderer;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Mono;

/**
* Adapts from {@link ViewsRenderer} to {@link ReactiveViewsRenderer}.
* @param <T> The model type
* @param <R> The request type
* @author Sergio del Amo
* @since 1.0
*/
@Internal
final class ReactiveViewsRendererAdapter<T, R> implements ReactiveViewsRenderer<T, R, Writable> {

private final ViewsRenderer<T, R> delegate;

ReactiveViewsRendererAdapter(ViewsRenderer<T, R> viewsRenderer) {
this.delegate = viewsRenderer;
}

/**
* @param viewName view name to be rendered
* @param data response body to render it with a view
* @param request HTTP request
* @return A writable where the view will be written to.
*/
@Override
@NonNull
@SingleResult
public Publisher<Writable> render(@NonNull String viewName,
@Nullable T data,
@Nullable R request) {
return Mono.just(delegate.render(viewName, data, request));
}

/**
* @param viewName view name to be rendered
* @return true if a template can be found for the supplied view name.
*/
@Override
public boolean exists(@NonNull String viewName) {
return delegate.exists(viewName);
}

/**
*
* @return The class of the delegate
*/
public Class<? extends ViewsRenderer> getDelegateClass() {
return delegate.getClass();
}
}
Loading
Loading