Refactor ReactivePropertyAccessor by wrapping existing PropertyAccessor (#6686)

#### What type of PR is this?

/kind improvement
/area core
/area theme

#### What this PR does / why we need it:

This PR removes ReactivePropertyAccessor because it use `AstUtils#getPropertyAccessorsToTry` which is already hidden  in [the commit](33fbd7141d (diff-deaf3517fbd66f40a8717877a8328dee0fb2581dfb6be487f327dc73ea33b5b5)). If we upgraded to Spring Boot 3.4.0-M3, the code in ReactivePropertyAccessor would be broken.

More importantly, I believe there is one issue with the current implementation although it can resolve the reactive issue.
- The PropertyAccessor modified the process flow of SPEL

This PR provides some wrappers to wrap existing PropertyAccessor and MethodResolver to evaluate reactive return value.

#### Does this PR introduce a user-facing change?

```release-note
None
```
pull/6691/head
John Niang 2024-09-23 16:47:15 +08:00 committed by GitHub
parent df195b12f2
commit 8b3bde050f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 288 additions and 187 deletions

View File

@ -0,0 +1,62 @@
package run.halo.app.infra.utils;
import java.time.Duration;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* Utility class for reactive.
*
* @author johnniang
* @since 2.20.0
*/
public enum ReactiveUtils {
;
private static final Duration DEFAULT_TIMEOUT = Duration.ofMinutes(1);
/**
* Resolve reactive value by blocking operation.
*
* @param value the normal value or reactive value
* @return the resolved value
*/
@Nullable
public static Object blockReactiveValue(@Nullable Object value) {
return blockReactiveValue(value, DEFAULT_TIMEOUT);
}
/**
* Resolve reactive value by blocking operation.
*
* @param value the normal value or reactive value
* @param timeout the timeout of blocking operation
* @return the resolved value
*/
@Nullable
public static Object blockReactiveValue(@Nullable Object value, @NonNull Duration timeout) {
if (value == null) {
return null;
}
Class<?> clazz = value.getClass();
if (Mono.class.isAssignableFrom(clazz)) {
return ((Mono<?>) value).blockOptional(timeout).orElse(null);
}
if (Flux.class.isAssignableFrom(clazz)) {
return ((Flux<?>) value).collectList().block(timeout);
}
return value;
}
/**
* Check if the class is a reactive type.
*
* @param clazz the class to check
* @return true if the class is a reactive type, false otherwise
*/
public static boolean isReactiveType(@NonNull Class<?> clazz) {
return Mono.class.isAssignableFrom(clazz) || Flux.class.isAssignableFrom(clazz);
}
}

View File

@ -1,118 +0,0 @@
package run.halo.app.theme;
import java.util.List;
import org.springframework.expression.AccessException;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.PropertyAccessor;
import org.springframework.expression.TypedValue;
import org.springframework.expression.spel.ast.AstUtils;
import org.springframework.integration.json.JsonPropertyAccessor;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* A SpEL PropertyAccessor that knows how to read properties from {@link Mono} or {@link Flux}
* object. It first converts the target to the actual value and then calls other
* {@link PropertyAccessor}s to parse the result, If it still cannot be resolved,
* {@link JsonPropertyAccessor} will be used to resolve finally.
*
* @author guqing
* @since 2.0.0
*/
public class ReactivePropertyAccessor implements PropertyAccessor {
@Override
public Class<?>[] getSpecificTargetClasses() {
return null;
}
@Override
public boolean canRead(@NonNull EvaluationContext context, Object target, @NonNull String name)
throws AccessException {
if (isReactiveType(target)) {
return true;
}
var propertyAccessors =
getPropertyAccessorsToTry(target.getClass(), context.getPropertyAccessors());
for (PropertyAccessor propertyAccessor : propertyAccessors) {
if (propertyAccessor.canRead(context, target, name)) {
return true;
}
}
return false;
}
@Override
@NonNull
public TypedValue read(@NonNull EvaluationContext context, Object target, @NonNull String name)
throws AccessException {
if (target == null) {
return TypedValue.NULL;
}
Object value = blockingGetForReactive(target);
List<PropertyAccessor> propertyAccessorsToTry =
getPropertyAccessorsToTry(value, context.getPropertyAccessors());
for (PropertyAccessor propertyAccessor : propertyAccessorsToTry) {
try {
TypedValue result = propertyAccessor.read(context, value, name);
return new TypedValue(blockingGetForReactive(result.getValue()));
} catch (AccessException e) {
// ignore this
}
}
throw new AccessException("Cannot read property '" + name + "' from [" + value + "]");
}
@Nullable
private static Object blockingGetForReactive(@Nullable Object target) {
if (target == null) {
return null;
}
Class<?> clazz = target.getClass();
Object value = target;
if (Mono.class.isAssignableFrom(clazz)) {
value = ((Mono<?>) target).block();
} else if (Flux.class.isAssignableFrom(clazz)) {
value = ((Flux<?>) target).collectList().block();
}
return value;
}
private boolean isReactiveType(Object target) {
if (target == null) {
return true;
}
Class<?> clazz = target.getClass();
return Mono.class.isAssignableFrom(clazz)
|| Flux.class.isAssignableFrom(clazz);
}
private List<PropertyAccessor> getPropertyAccessorsToTry(
@Nullable Object contextObject, List<PropertyAccessor> propertyAccessors) {
Class<?> targetType = (contextObject != null ? contextObject.getClass() : null);
List<PropertyAccessor> resolvers =
AstUtils.getPropertyAccessorsToTry(targetType, propertyAccessors);
// remove this resolver to avoid infinite loop
resolvers.remove(this);
return resolvers;
}
@Override
public boolean canWrite(@NonNull EvaluationContext context, Object target, @NonNull String name)
throws AccessException {
return false;
}
@Override
public void write(@NonNull EvaluationContext context, Object target, @NonNull String name,
Object newValue)
throws AccessException {
throw new UnsupportedOperationException("Write is not supported");
}
}

View File

@ -1,12 +1,12 @@
package run.halo.app.theme;
import java.util.Optional;
import org.thymeleaf.context.IExpressionContext;
import org.thymeleaf.spring6.expression.SPELVariableExpressionEvaluator;
import org.thymeleaf.standard.expression.IStandardVariableExpression;
import org.thymeleaf.standard.expression.IStandardVariableExpressionEvaluator;
import org.thymeleaf.standard.expression.StandardExpressionExecutionContext;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.infra.utils.ReactiveUtils;
/**
* Reactive SPEL variable expression evaluator.
@ -17,28 +17,25 @@ import reactor.core.publisher.Mono;
public class ReactiveSpelVariableExpressionEvaluator
implements IStandardVariableExpressionEvaluator {
private final SPELVariableExpressionEvaluator delegate =
SPELVariableExpressionEvaluator.INSTANCE;
private final IStandardVariableExpressionEvaluator delegate;
public static final ReactiveSpelVariableExpressionEvaluator INSTANCE =
new ReactiveSpelVariableExpressionEvaluator();
public ReactiveSpelVariableExpressionEvaluator(IStandardVariableExpressionEvaluator delegate) {
this.delegate = delegate;
}
public ReactiveSpelVariableExpressionEvaluator() {
this(SPELVariableExpressionEvaluator.INSTANCE);
}
@Override
public Object evaluate(IExpressionContext context, IStandardVariableExpression expression,
StandardExpressionExecutionContext expContext) {
Object returnValue = delegate.evaluate(context, expression, expContext);
if (returnValue == null) {
return null;
}
Class<?> clazz = returnValue.getClass();
// Note that: 3 instanceof Foo -> syntax error
if (Mono.class.isAssignableFrom(clazz)) {
return ((Mono<?>) returnValue).block();
}
if (Flux.class.isAssignableFrom(clazz)) {
return ((Flux<?>) returnValue).collectList().block();
}
return returnValue;
var returnValue = delegate.evaluate(context, expression, expContext);
return Optional.ofNullable(returnValue)
.map(ReactiveUtils::blockReactiveValue)
.orElse(null);
}
}

View File

@ -0,0 +1,210 @@
package run.halo.app.theme.dialect;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.expression.AccessException;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.MethodExecutor;
import org.springframework.expression.MethodResolver;
import org.springframework.expression.PropertyAccessor;
import org.springframework.expression.TypedValue;
import org.springframework.expression.spel.support.ReflectivePropertyAccessor;
import org.springframework.integration.json.JsonPropertyAccessor;
import org.springframework.lang.Nullable;
import org.thymeleaf.context.ITemplateContext;
import org.thymeleaf.model.ITemplateEnd;
import org.thymeleaf.model.ITemplateStart;
import org.thymeleaf.processor.templateboundaries.AbstractTemplateBoundariesProcessor;
import org.thymeleaf.processor.templateboundaries.ITemplateBoundariesStructureHandler;
import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext;
import org.thymeleaf.standard.StandardDialect;
import org.thymeleaf.templatemode.TemplateMode;
import run.halo.app.infra.utils.ReactiveUtils;
/**
* Enhance the evaluation context to support reactive types.
*
* @author guqing
* @author johnniang
* @since 2.20.0
*/
public class EvaluationContextEnhancer extends AbstractTemplateBoundariesProcessor {
private static final int PRECEDENCE = StandardDialect.PROCESSOR_PRECEDENCE;
private static final JsonPropertyAccessor JSON_PROPERTY_ACCESSOR = new JsonPropertyAccessor();
public EvaluationContextEnhancer() {
super(TemplateMode.HTML, PRECEDENCE);
}
@Override
public void doProcessTemplateStart(ITemplateContext context, ITemplateStart templateStart,
ITemplateBoundariesStructureHandler structureHandler) {
var evluationContextObject = context.getVariable(
ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME
);
if (evluationContextObject instanceof ThymeleafEvaluationContext evaluationContext) {
evaluationContext.addPropertyAccessor(JSON_PROPERTY_ACCESSOR);
ReactiveReflectivePropertyAccessor.wrap(evaluationContext);
ReactiveMethodResolver.wrap(evaluationContext);
}
}
@Override
public void doProcessTemplateEnd(ITemplateContext context, ITemplateEnd templateEnd,
ITemplateBoundariesStructureHandler structureHandler) {
// nothing to do
}
/**
* A {@link PropertyAccessor} that wraps the original {@link ReflectivePropertyAccessor} and
* blocks the reactive value.
*/
private static class ReactiveReflectivePropertyAccessor
extends ReflectivePropertyAccessor {
private final ReflectivePropertyAccessor delegate;
private ReactiveReflectivePropertyAccessor(ReflectivePropertyAccessor delegate) {
this.delegate = delegate;
}
@Override
public boolean canRead(EvaluationContext context, Object target, String name)
throws AccessException {
if (target == null) {
// For backward compatibility
return true;
}
return this.delegate.canRead(context, target, name);
}
@Override
public TypedValue read(EvaluationContext context, Object target, String name)
throws AccessException {
if (target == null) {
// For backward compatibility
return TypedValue.NULL;
}
var typedValue = delegate.read(context, target, name);
return Optional.of(typedValue)
.filter(tv ->
Objects.nonNull(tv.getValue())
&& Objects.nonNull(tv.getTypeDescriptor())
&& ReactiveUtils.isReactiveType(tv.getTypeDescriptor().getType())
)
.map(tv -> new TypedValue(ReactiveUtils.blockReactiveValue(tv.getValue())))
.orElse(typedValue);
}
@Override
public boolean canWrite(EvaluationContext context, Object target, String name)
throws AccessException {
return delegate.canWrite(context, target, name);
}
@Override
public void write(EvaluationContext context, Object target, String name, Object newValue)
throws AccessException {
delegate.write(context, target, name, newValue);
}
@Override
public Class<?>[] getSpecificTargetClasses() {
return delegate.getSpecificTargetClasses();
}
@Override
public PropertyAccessor createOptimalAccessor(EvaluationContext context, Object target,
String name) {
var optimalAccessor = delegate.createOptimalAccessor(context, target, name);
if (optimalAccessor instanceof OptimalPropertyAccessor optimalPropertyAccessor) {
if (ReactiveUtils.isReactiveType(optimalPropertyAccessor.getPropertyType())) {
return this;
}
return optimalPropertyAccessor;
}
return this;
}
static void wrap(ThymeleafEvaluationContext evaluationContext) {
var wrappedPropertyAccessors = evaluationContext.getPropertyAccessors()
.stream()
.map(propertyAccessor -> {
if (propertyAccessor instanceof ReflectivePropertyAccessor reflectiveAccessor) {
return new ReactiveReflectivePropertyAccessor(reflectiveAccessor);
}
return propertyAccessor;
})
// make the list mutable
.collect(Collectors.toCollection(ArrayList::new));
evaluationContext.setPropertyAccessors(wrappedPropertyAccessors);
}
@Override
public boolean equals(Object obj) {
return delegate.equals(obj);
}
@Override
public int hashCode() {
return delegate.hashCode();
}
}
/**
* A {@link MethodResolver} that wraps the original {@link MethodResolver} and blocks the
* reactive value.
*
* @param delegate the original {@link MethodResolver}
*/
private record ReactiveMethodResolver(MethodResolver delegate) implements MethodResolver {
@Override
@Nullable
public MethodExecutor resolve(EvaluationContext context, Object targetObject, String name,
List<TypeDescriptor> argumentTypes) throws AccessException {
var executor = delegate.resolve(context, targetObject, name, argumentTypes);
return Optional.ofNullable(executor).map(ReactiveMethodExecutor::new).orElse(null);
}
static void wrap(ThymeleafEvaluationContext evaluationContext) {
var wrappedMethodResolvers = evaluationContext.getMethodResolvers()
.stream()
.<MethodResolver>map(ReactiveMethodResolver::new)
// make the list mutable
.collect(Collectors.toCollection(ArrayList::new));
evaluationContext.setMethodResolvers(wrappedMethodResolvers);
}
}
/**
* A {@link MethodExecutor} that wraps the original {@link MethodExecutor} and blocks the
* reactive value.
*
* @param delegate the original {@link MethodExecutor}
*/
private record ReactiveMethodExecutor(MethodExecutor delegate) implements MethodExecutor {
@Override
public TypedValue execute(EvaluationContext context, Object target, Object... arguments)
throws AccessException {
var typedValue = delegate.execute(context, target, arguments);
return Optional.of(typedValue)
.filter(tv ->
Objects.nonNull(tv.getValue())
&& Objects.nonNull(tv.getTypeDescriptor())
&& ReactiveUtils.isReactiveType(tv.getTypeDescriptor().getType())
)
.map(tv -> new TypedValue(ReactiveUtils.blockReactiveValue(tv.getValue())))
.orElse(typedValue);
}
}
}

View File

@ -38,7 +38,7 @@ public class HaloProcessorDialect extends AbstractProcessorDialect
// add more processors
processors.add(new GlobalHeadInjectionProcessor(dialectPrefix));
processors.add(new TemplateFooterElementTagProcessor(dialectPrefix));
processors.add(new JsonNodePropertyAccessorBoundariesProcessor());
processors.add(new EvaluationContextEnhancer());
processors.add(new CommentElementTagProcessor(dialectPrefix));
processors.add(new CommentEnabledVariableProcessor());
return processors;

View File

@ -1,49 +0,0 @@
package run.halo.app.theme.dialect;
import org.springframework.integration.json.JsonPropertyAccessor;
import org.thymeleaf.context.ITemplateContext;
import org.thymeleaf.model.ITemplateEnd;
import org.thymeleaf.model.ITemplateStart;
import org.thymeleaf.processor.templateboundaries.AbstractTemplateBoundariesProcessor;
import org.thymeleaf.processor.templateboundaries.ITemplateBoundariesStructureHandler;
import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext;
import org.thymeleaf.standard.StandardDialect;
import org.thymeleaf.templatemode.TemplateMode;
import run.halo.app.theme.ReactivePropertyAccessor;
/**
* A template boundaries processor for add {@link JsonPropertyAccessor} to
* {@link ThymeleafEvaluationContext}.
*
* @author guqing
* @since 2.0.0
*/
public class JsonNodePropertyAccessorBoundariesProcessor
extends AbstractTemplateBoundariesProcessor {
private static final int PRECEDENCE = StandardDialect.PROCESSOR_PRECEDENCE;
private static final JsonPropertyAccessor JSON_PROPERTY_ACCESSOR = new JsonPropertyAccessor();
private static final ReactivePropertyAccessor REACTIVE_PROPERTY_ACCESSOR =
new ReactivePropertyAccessor();
public JsonNodePropertyAccessorBoundariesProcessor() {
super(TemplateMode.HTML, PRECEDENCE);
}
@Override
public void doProcessTemplateStart(ITemplateContext context, ITemplateStart templateStart,
ITemplateBoundariesStructureHandler structureHandler) {
ThymeleafEvaluationContext evaluationContext =
(ThymeleafEvaluationContext) context.getVariable(
ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME);
if (evaluationContext != null) {
evaluationContext.addPropertyAccessor(JSON_PROPERTY_ACCESSOR);
evaluationContext.addPropertyAccessor(REACTIVE_PROPERTY_ACCESSOR);
}
}
@Override
public void doProcessTemplateEnd(ITemplateContext context, ITemplateEnd templateEnd,
ITemplateBoundariesStructureHandler structureHandler) {
// nothing to do
}
}

View File

@ -38,7 +38,6 @@ import run.halo.app.theme.dialect.HaloProcessorDialect;
* Tests expression parser for reactive return value.
*
* @author guqing
* @see ReactivePropertyAccessor
* @see ReactiveSpelVariableExpressionEvaluator
* @since 2.0.0
*/