refactor: support chaining calls for flux and mono in javascript inline tag (#2715)

#### What type of PR is this?
/kind improvement
/area core
/milestone 2.0

#### What this PR does / why we need it:
支持在 JavaScript 中对 Flux 或 Mono 的泛型类型属性链式调用
测试参考:
7e9bdec492/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java (L131-L147)
期望
7e9bdec492/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java (L64-L79)

#### Special notes for your reviewer:
/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?

```release-note
支持在 JavaScript 中对 Flux 或 Mono 的泛型类型属性链式调用
```
pull/2718/head^2
guqing 2022-11-18 14:32:22 +08:00 committed by GitHub
parent eeb9c4dc7a
commit c8bc96ffc3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 197 additions and 50 deletions

View File

@ -1,18 +1,16 @@
package run.halo.app.theme; package run.halo.app.theme;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import org.springframework.expression.AccessException; import org.springframework.expression.AccessException;
import org.springframework.expression.EvaluationContext; import org.springframework.expression.EvaluationContext;
import org.springframework.expression.PropertyAccessor; import org.springframework.expression.PropertyAccessor;
import org.springframework.expression.TypedValue; import org.springframework.expression.TypedValue;
import org.springframework.expression.spel.ast.AstUtils;
import org.springframework.integration.json.JsonPropertyAccessor; import org.springframework.integration.json.JsonPropertyAccessor;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable; import org.springframework.lang.Nullable;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.infra.utils.JsonUtils;
/** /**
* A SpEL PropertyAccessor that knows how to read properties from {@link Mono} or {@link Flux} * A SpEL PropertyAccessor that knows how to read properties from {@link Mono} or {@link Flux}
@ -24,25 +22,25 @@ import run.halo.app.infra.utils.JsonUtils;
* @since 2.0.0 * @since 2.0.0
*/ */
public class ReactivePropertyAccessor implements PropertyAccessor { public class ReactivePropertyAccessor implements PropertyAccessor {
private static final Class<?>[] SUPPORTED_CLASSES = {
Mono.class,
Flux.class
};
private final JsonPropertyAccessor jsonPropertyAccessor = new JsonPropertyAccessor();
@Override @Override
public Class<?>[] getSpecificTargetClasses() { public Class<?>[] getSpecificTargetClasses() {
return SUPPORTED_CLASSES; return null;
} }
@Override @Override
public boolean canRead(@NonNull EvaluationContext context, Object target, @NonNull String name) public boolean canRead(@NonNull EvaluationContext context, Object target, @NonNull String name)
throws AccessException { throws AccessException {
if (target == null) { if (isReactiveType(target)) {
return false; return true;
} }
return Mono.class.isAssignableFrom(target.getClass()) List<PropertyAccessor> propertyAccessors = context.getPropertyAccessors();
|| Flux.class.isAssignableFrom(target.getClass()); for (PropertyAccessor propertyAccessor : propertyAccessors) {
if (propertyAccessor.canRead(context, target, name)) {
return true;
}
}
return false;
} }
@Override @Override
@ -52,29 +50,44 @@ public class ReactivePropertyAccessor implements PropertyAccessor {
if (target == null) { if (target == null) {
return TypedValue.NULL; return TypedValue.NULL;
} }
Class<?> clazz = target.getClass(); Object value = blockingGetForReactive(target);
Object value = null;
if (Mono.class.isAssignableFrom(clazz)) {
value = ((Mono<?>) target).block();
} else if (Flux.class.isAssignableFrom(clazz)) {
value = ((Flux<?>) target).toIterable();
}
if (value == null) {
return TypedValue.NULL;
}
List<PropertyAccessor> propertyAccessorsToTry = List<PropertyAccessor> propertyAccessorsToTry =
getPropertyAccessorsToTry(value, context.getPropertyAccessors()); getPropertyAccessorsToTry(value, context.getPropertyAccessors());
for (PropertyAccessor propertyAccessor : propertyAccessorsToTry) { for (PropertyAccessor propertyAccessor : propertyAccessorsToTry) {
try { try {
return propertyAccessor.read(context, target, name); TypedValue result = propertyAccessor.read(context, value, name);
return new TypedValue(blockingGetForReactive(result.getValue()));
} catch (AccessException e) { } catch (AccessException e) {
// ignore // ignore this
} }
} }
JsonNode jsonNode = JsonUtils.DEFAULT_JSON_MAPPER.convertValue(value, JsonNode.class);
return jsonPropertyAccessor.read(context, jsonNode, name); 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 false;
}
Class<?> clazz = target.getClass();
return Mono.class.isAssignableFrom(clazz)
|| Flux.class.isAssignableFrom(clazz);
} }
private List<PropertyAccessor> getPropertyAccessorsToTry( private List<PropertyAccessor> getPropertyAccessorsToTry(
@ -82,27 +95,10 @@ public class ReactivePropertyAccessor implements PropertyAccessor {
Class<?> targetType = (contextObject != null ? contextObject.getClass() : null); Class<?> targetType = (contextObject != null ? contextObject.getClass() : null);
List<PropertyAccessor> specificAccessors = new ArrayList<>(); List<PropertyAccessor> resolvers =
List<PropertyAccessor> generalAccessors = new ArrayList<>(); AstUtils.getPropertyAccessorsToTry(targetType, propertyAccessors);
for (PropertyAccessor resolver : propertyAccessors) { // remove this resolver to avoid infinite loop
Class<?>[] targets = resolver.getSpecificTargetClasses(); resolvers.remove(this);
if (targets == null) {
// generic resolver that says it can be used for any type
generalAccessors.add(resolver);
} else if (targetType != null) {
for (Class<?> clazz : targets) {
if (clazz == targetType) {
specificAccessors.add(resolver);
break;
} else if (clazz.isAssignableFrom(targetType)) {
generalAccessors.add(resolver);
}
}
}
}
List<PropertyAccessor> resolvers = new ArrayList<>(specificAccessors);
generalAccessors.removeAll(specificAccessors);
resolvers.addAll(generalAccessors);
return resolvers; return resolvers;
} }

View File

@ -37,7 +37,7 @@ public class ReactiveSpelVariableExpressionEvaluator
return ((Mono<?>) returnValue).block(); return ((Mono<?>) returnValue).block();
} }
if (Flux.class.isAssignableFrom(clazz)) { if (Flux.class.isAssignableFrom(clazz)) {
return ((Flux<?>) returnValue).toIterable(); return ((Flux<?>) returnValue).collectList().block();
} }
return returnValue; return returnValue;
} }

View File

@ -0,0 +1,151 @@
package run.halo.app.theme;
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationContext;
import org.thymeleaf.IEngineConfiguration;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.dialect.SpringStandardDialect;
import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext;
import org.thymeleaf.standard.expression.IStandardVariableExpressionEvaluator;
import org.thymeleaf.templateresolver.StringTemplateResolver;
import org.thymeleaf.templateresource.ITemplateResource;
import org.thymeleaf.templateresource.StringTemplateResource;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.theme.dialect.HaloProcessorDialect;
/**
* Tests expression parser for reactive return value.
*
* @author guqing
* @see ReactivePropertyAccessor
* @see ReactiveSpelVariableExpressionEvaluator
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
public class ReactiveFinderExpressionParserTests {
@Mock
private ApplicationContext applicationContext;
private TemplateEngine templateEngine;
@BeforeEach
void setUp() {
HaloProcessorDialect haloProcessorDialect = new HaloProcessorDialect();
templateEngine = new TemplateEngine();
templateEngine.setDialects(Set.of(haloProcessorDialect, new SpringStandardDialect() {
@Override
public IStandardVariableExpressionEvaluator getVariableExpressionEvaluator() {
return new ReactiveSpelVariableExpressionEvaluator();
}
}));
templateEngine.addTemplateResolver(new TestTemplateResolver());
}
@Test
void javascriptInlineParser() {
Context context = getContext();
context.setVariable("target", new TestReactiveFinder());
context.setVariable("genericMap", Map.of("key", "value"));
String result = templateEngine.process("javascriptInline", context);
assertThat(result).isEqualTo("""
<p>value</p>
<p>ruibaby</p>
<p>guqing</p>
<p>bar</p>
<script>
var genericValue = "value";
var name = "guqing";
var names = ["guqing","johnniang","ruibaby"];
var users = [{"name":"guqing"},{"name":"ruibaby"},{"name":"johnniang"}];
var userListItem = "guqing";
var objectJsonNodeFlux = [{"name":"guqing"}];
var objectJsonNodeFluxChain = "guqing";
var mapMono = "bar";
var arrayNodeMono = "bar";
</script>
""");
}
static class TestReactiveFinder {
public Mono<String> getName() {
return Mono.just("guqing");
}
public Flux<String> names() {
return Flux.just("guqing", "johnniang", "ruibaby");
}
public Flux<TestUser> users() {
return Flux.just(
new TestUser("guqing"), new TestUser("ruibaby"), new TestUser("johnniang")
);
}
public Flux<JsonNode> objectJsonNodeFlux() {
ObjectNode objectNode = JsonUtils.DEFAULT_JSON_MAPPER.createObjectNode();
objectNode.put("name", "guqing");
return Flux.just(objectNode);
}
public Mono<Map<String, Object>> mapMono() {
return Mono.just(Map.of("foo", "bar"));
}
public Mono<JsonNode> arrayNodeMono() {
ArrayNode arrayNode = JsonUtils.DEFAULT_JSON_MAPPER.createArrayNode();
arrayNode.add(arrayNode.objectNode().put("foo", "bar"));
return Mono.just(arrayNode);
}
}
record TestUser(String name) {
}
private Context getContext() {
Context context = new Context();
context.setVariable(
ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME,
new ThymeleafEvaluationContext(applicationContext, null));
return context;
}
static class TestTemplateResolver extends StringTemplateResolver {
@Override
protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration,
String ownerTemplate, String template,
Map<String, Object> templateResolutionAttributes) {
return new StringTemplateResource("""
<p th:text="${genericMap.key}"></p>
<p th:text="${target.users[1].name}"></p>
<p th:text="${target.objectJsonNodeFlux[0].name}"></p>
<p th:text="${target.arrayNodeMono.get(0).foo}"></p>
<script th:inline="javascript">
var genericValue = /*[[${genericMap.key}]]*/;
var name = /*[[${target.getName()}]]*/;
var names = /*[[${target.names()}]]*/;
var users = /*[[${target.users()}]]*/;
var userListItem = /*[[${target.users[0].name}]]*/;
var objectJsonNodeFlux = /*[[${target.objectJsonNodeFlux()}]]*/;
var objectJsonNodeFluxChain = /*[[${target.objectJsonNodeFlux[0].name}]]*/;
var mapMono = /*[[${target.mapMono.foo}]]*/;
var arrayNodeMono = /*[[${target.arrayNodeMono.get(0).foo}]]*/;
</script>
""");
}
}
}