From 04f3aef9f4091c89750c0ee832d31909a1e44297 Mon Sep 17 00:00:00 2001 From: JohnNiang Date: Sun, 22 Sep 2024 23:10:31 +0800 Subject: [PATCH] Refactor ReactivePropertyAccessor by wrapping existing PropertyAccessor Signed-off-by: JohnNiang --- .../halo/app/infra/utils/ReactiveUtils.java | 62 ++++++ .../app/theme/ReactivePropertyAccessor.java | 118 ---------- ...activeSpelVariableExpressionEvaluator.java | 33 ++- .../dialect/EvaluationContextEnhancer.java | 210 ++++++++++++++++++ .../theme/dialect/HaloProcessorDialect.java | 2 +- ...dePropertyAccessorBoundariesProcessor.java | 49 ---- .../ReactiveFinderExpressionParserTests.java | 1 - 7 files changed, 288 insertions(+), 187 deletions(-) create mode 100644 application/src/main/java/run/halo/app/infra/utils/ReactiveUtils.java delete mode 100644 application/src/main/java/run/halo/app/theme/ReactivePropertyAccessor.java create mode 100644 application/src/main/java/run/halo/app/theme/dialect/EvaluationContextEnhancer.java delete mode 100644 application/src/main/java/run/halo/app/theme/dialect/JsonNodePropertyAccessorBoundariesProcessor.java diff --git a/application/src/main/java/run/halo/app/infra/utils/ReactiveUtils.java b/application/src/main/java/run/halo/app/infra/utils/ReactiveUtils.java new file mode 100644 index 0000000000..c87b0e03b6 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/utils/ReactiveUtils.java @@ -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); + } +} diff --git a/application/src/main/java/run/halo/app/theme/ReactivePropertyAccessor.java b/application/src/main/java/run/halo/app/theme/ReactivePropertyAccessor.java deleted file mode 100644 index db847d24b7..0000000000 --- a/application/src/main/java/run/halo/app/theme/ReactivePropertyAccessor.java +++ /dev/null @@ -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 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 getPropertyAccessorsToTry( - @Nullable Object contextObject, List propertyAccessors) { - - Class targetType = (contextObject != null ? contextObject.getClass() : null); - - List 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"); - } -} diff --git a/application/src/main/java/run/halo/app/theme/ReactiveSpelVariableExpressionEvaluator.java b/application/src/main/java/run/halo/app/theme/ReactiveSpelVariableExpressionEvaluator.java index 1794497789..b961f0eea1 100644 --- a/application/src/main/java/run/halo/app/theme/ReactiveSpelVariableExpressionEvaluator.java +++ b/application/src/main/java/run/halo/app/theme/ReactiveSpelVariableExpressionEvaluator.java @@ -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 @@ 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); } } diff --git a/application/src/main/java/run/halo/app/theme/dialect/EvaluationContextEnhancer.java b/application/src/main/java/run/halo/app/theme/dialect/EvaluationContextEnhancer.java new file mode 100644 index 0000000000..2d01e48b65 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/EvaluationContextEnhancer.java @@ -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 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() + .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); + } + + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java b/application/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java index a88296531c..a355f5ae88 100644 --- a/application/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java +++ b/application/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java @@ -38,7 +38,7 @@ public Set getProcessors(String dialectPrefix) { // 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; diff --git a/application/src/main/java/run/halo/app/theme/dialect/JsonNodePropertyAccessorBoundariesProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/JsonNodePropertyAccessorBoundariesProcessor.java deleted file mode 100644 index 0ab86fffe4..0000000000 --- a/application/src/main/java/run/halo/app/theme/dialect/JsonNodePropertyAccessorBoundariesProcessor.java +++ /dev/null @@ -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 - } -} diff --git a/application/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java b/application/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java index 346aa9945a..20010b0f33 100644 --- a/application/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java +++ b/application/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java @@ -38,7 +38,6 @@ * Tests expression parser for reactive return value. * * @author guqing - * @see ReactivePropertyAccessor * @see ReactiveSpelVariableExpressionEvaluator * @since 2.0.0 */