mirror of https://github.com/halo-dev/halo
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
parent
df195b12f2
commit
8b3bde050f
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
*/
|
||||
|
|
Loading…
Reference in New Issue