diff --git a/framework-docs/modules/ROOT/pages/core/expressions.adoc b/framework-docs/modules/ROOT/pages/core/expressions.adoc index a87addc06f54..cfde855e6d3c 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions.adoc @@ -47,7 +47,9 @@ The expression language supports the following functionality: * Inline maps * Ternary operator * Variables -* User-defined functions +* User-defined functions added to the context + * reflective invocation of `Method` + * various cases of `MethodHandle` * Collection projection * Collection selection * Templated expressions diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref.adoc index f920ac60470e..c69ffaf6bc1b 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref.adoc @@ -15,7 +15,7 @@ topics: * xref:core/expressions/language-ref/types.adoc[Types] * xref:core/expressions/language-ref/constructors.adoc[Constructors] * xref:core/expressions/language-ref/variables.adoc[Variables] -* xref:core/expressions/language-ref/functions.adoc[Functions] +* xref:core/expressions/language-ref/functions.adoc[User-Defined Functions] * xref:core/expressions/language-ref/bean-references.adoc[Bean References] * xref:core/expressions/language-ref/operator-ternary.adoc[Ternary Operator (If-Then-Else)] * xref:core/expressions/language-ref/operator-elvis.adoc[The Elvis Operator] diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/functions.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/functions.adoc index 014d6e8b8c44..4621b2a19aaa 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/functions.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/functions.adoc @@ -3,7 +3,8 @@ You can extend SpEL by registering user-defined functions that can be called within the expression string. The function is registered through the `EvaluationContext`. The -following example shows how to register a user-defined function: +following example shows how to register a user-defined function to be invoked via reflection +(i.e. a `Method`): [tabs] ====== @@ -94,5 +95,97 @@ Kotlin:: ---- ====== +The use of `MethodHandle` is also supported. This enables potentially more efficient use +cases if the `MethodHandle` target and parameters have been fully bound prior to +registration, but partially bound handles are also supported. + +Consider the `String#formatted(String, Object...)` instance method, which produces a +message according to a template and a variable number of arguments. + +You can register and use the `formatted` method as a `MethodHandle`, as the following +example shows: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes",role="primary"] +---- + ExpressionParser parser = new SpelExpressionParser(); + EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build(); + + MethodHandle mh = MethodHandles.lookup().findVirtual(String.class, "formatted", + MethodType.methodType(String.class, Object[].class)); + context.setVariable("message", mh); + + String message = parser.parseExpression("#message('Simple message: <%s>', 'Hello World', 'ignored')") + .getValue(context, String.class); + //returns "Simple message: " +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +---- + val parser = SpelExpressionParser() + val context = SimpleEvaluationContext.forReadOnlyDataBinding().build() + + val mh = MethodHandles.lookup().findVirtual(String::class.java, "formatted", + MethodType.methodType(String::class.java, Array::class.java)) + context.setVariable("message", mh) + + val message = parser.parseExpression("#message('Simple message: <%s>', 'Hello World', 'ignored')") + .getValue(context, String::class.java) +---- +====== + +As hinted above, binding a `MethodHandle` and registering the bound `MethodHandle` is also +supported. This is likely to be more performant if both the target and all the arguments +are bound. In that case no arguments are necessary in the SpEL expression, as the +following example shows: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes",role="primary"] +---- + ExpressionParser parser = new SpelExpressionParser(); + EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build(); + + String template = "This is a %s message with %s words: <%s>"; + Object varargs = new Object[] { "prerecorded", 3, "Oh Hello World!", "ignored" }; + MethodHandle mh = MethodHandles.lookup().findVirtual(String.class, "formatted", + MethodType.methodType(String.class, Object[].class)) + .bindTo(template) + .bindTo(varargs); //here we have to provide arguments in a single array binding + context.setVariable("message", mh); + + String message = parser.parseExpression("#message()") + .getValue(context, String.class); + //returns "This is a prerecorded message with 3 words: " +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +---- + val parser = SpelExpressionParser() + val context = SimpleEvaluationContext.forReadOnlyDataBinding().build() + + val template = "This is a %s message with %s words: <%s>" + val varargs = arrayOf("prerecorded", 3, "Oh Hello World!", "ignored") + + val mh = MethodHandles.lookup().findVirtual(String::class.java, "formatted", + MethodType.methodType(String::class.java, Array::class.java)) + .bindTo(template) + .bindTo(varargs) //here we have to provide arguments in a single array binding + context.setVariable("message", mh) + + val message = parser.parseExpression("#message()") + .getValue(context, String::class.java) +---- +====== + diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/FunctionReference.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/FunctionReference.java index 4ad5b0dda907..766ac2704bbd 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/FunctionReference.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/FunctionReference.java @@ -16,6 +16,8 @@ package org.springframework.expression.spel.ast; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodType; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.StringJoiner; @@ -70,7 +72,17 @@ public TypedValue getValueInternal(ExpressionState state) throws EvaluationExcep if (value == TypedValue.NULL) { throw new SpelEvaluationException(getStartPosition(), SpelMessage.FUNCTION_NOT_DEFINED, this.name); } - if (!(value.getValue() instanceof Method function)) { + Object resolvedValue = value.getValue(); + if (resolvedValue instanceof MethodHandle methodHandle) { + try { + return executeFunctionBoundMethodHandle(state, methodHandle); + } + catch (SpelEvaluationException ex) { + ex.setPosition(getStartPosition()); + throw ex; + } + } + if (!(resolvedValue instanceof Method function)) { // Possibly a static Java method registered as a function throw new SpelEvaluationException( SpelMessage.FUNCTION_REFERENCE_CANNOT_BE_INVOKED, this.name, value.getClass()); @@ -138,6 +150,78 @@ private TypedValue executeFunctionJLRMethod(ExpressionState state, Method method } } + /** + * Execute a function represented as {@code java.lang.invoke.MethodHandle}. + * Method types that take no arguments (fully bound handles or static methods + * with no parameters) can use {@code #invoke()} which is the most efficient. + * Otherwise, {@code #invokeWithArguments)} is used. + * @param state the expression evaluation state + * @param methodHandle the method to invoke + * @return the return value of the invoked Java method + * @throws EvaluationException if there is any problem invoking the method + * @since 6.1.0 + */ + private TypedValue executeFunctionBoundMethodHandle(ExpressionState state, MethodHandle methodHandle) throws EvaluationException { + Object[] functionArgs = getArguments(state); + MethodType declaredParams = methodHandle.type(); + int spelParamCount = functionArgs.length; + int declaredParamCount = declaredParams.parameterCount(); + + boolean isSuspectedVarargs = declaredParams.lastParameterType().isArray(); + + if (spelParamCount < declaredParamCount || (spelParamCount > declaredParamCount + && !isSuspectedVarargs)) { + //incorrect number, including more arguments and not a vararg + throw new SpelEvaluationException(SpelMessage.INCORRECT_NUMBER_OF_ARGUMENTS_TO_FUNCTION, + functionArgs.length, declaredParamCount); + //perhaps a subset of arguments was provided but the MethodHandle wasn't bound? + } + + // simplest case: the MethodHandle is fully bound or represents a static method with no params: + if (declaredParamCount == 0) { + //note we consider MethodHandles not compilable + try { + return new TypedValue(methodHandle.invoke()); + } + catch (Throwable ex) { + throw new SpelEvaluationException(getStartPosition(), ex, SpelMessage.EXCEPTION_DURING_FUNCTION_CALL, + this.name, ex.getMessage()); + } + finally { + this.exitTypeDescriptor = null; + this.method = null; + } + } + + // more complex case, we need to look at conversion and vararg repacking + Integer varArgPosition = null; + if (isSuspectedVarargs) { + varArgPosition = declaredParamCount - 1; + } + TypeConverter converter = state.getEvaluationContext().getTypeConverter(); + boolean conversionOccurred = ReflectionHelper.convertAllMethodHandleArguments(converter, + functionArgs, methodHandle, varArgPosition); + + if (isSuspectedVarargs && declaredParamCount == 1) { + //we only repack the varargs if it is the ONLY argument + functionArgs = ReflectionHelper.setupArgumentsForVarargsInvocation( + methodHandle.type().parameterArray(), functionArgs); + } + + //note we consider MethodHandles not compilable + try { + return new TypedValue(methodHandle.invokeWithArguments(functionArgs)); + } + catch (Throwable ex) { + throw new SpelEvaluationException(getStartPosition(), ex, SpelMessage.EXCEPTION_DURING_FUNCTION_CALL, + this.name, ex.getMessage()); + } + finally { + this.exitTypeDescriptor = null; + this.method = null; + } + } + @Override public String toStringAST() { StringJoiner sj = new StringJoiner(",", "(", ")"); diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/support/ReflectionHelper.java b/spring-expression/src/main/java/org/springframework/expression/spel/support/ReflectionHelper.java index e9b62ec2adbb..2e44a02fce96 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/support/ReflectionHelper.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/support/ReflectionHelper.java @@ -16,6 +16,8 @@ package org.springframework.expression.spel.support; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodType; import java.lang.reflect.Array; import java.lang.reflect.Executable; import java.lang.reflect.Method; @@ -23,6 +25,7 @@ import java.util.Optional; import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; import org.springframework.core.convert.TypeDescriptor; import org.springframework.expression.EvaluationException; import org.springframework.expression.TypeConverter; @@ -330,6 +333,91 @@ else if (!sourceType.equals(targetType.getElementTypeDescriptor())) { return conversionOccurred; } + /** + * Takes an input set of argument values and converts them to the types specified as the + * required parameter types. The arguments are converted 'in-place' in the input array. + * @param converter the type converter to use for attempting conversions + * @param arguments the actual arguments that need conversion + * @param methodHandle the target MethodHandle + * @param varargsPosition the known position of the varargs argument, if any + * ({@code null} if not varargs) + * @return {@code true} if some kind of conversion occurred on an argument + * @throws EvaluationException if a problem occurs during conversion + * @since 6.1.0 + */ + public static boolean convertAllMethodHandleArguments(TypeConverter converter, Object[] arguments, + MethodHandle methodHandle, @Nullable Integer varargsPosition) throws EvaluationException { + boolean conversionOccurred = false; + final MethodType methodHandleArgumentTypes = methodHandle.type(); + if (varargsPosition == null) { + for (int i = 0; i < arguments.length; i++) { + Class argumentClass = methodHandleArgumentTypes.parameterType(i); + ResolvableType resolvableType = ResolvableType.forClass(argumentClass); + TypeDescriptor targetType = new TypeDescriptor(resolvableType, argumentClass, null); + + Object argument = arguments[i]; + arguments[i] = converter.convertValue(argument, TypeDescriptor.forObject(argument), targetType); + conversionOccurred |= (argument != arguments[i]); + } + } + else { + // Convert everything up to the varargs position + for (int i = 0; i < varargsPosition; i++) { + Class argumentClass = methodHandleArgumentTypes.parameterType(i); + ResolvableType resolvableType = ResolvableType.forClass(argumentClass); + TypeDescriptor targetType = new TypeDescriptor(resolvableType, argumentClass, null); + + Object argument = arguments[i]; + arguments[i] = converter.convertValue(argument, TypeDescriptor.forObject(argument), targetType); + conversionOccurred |= (argument != arguments[i]); + } + + final Class varArgClass = methodHandleArgumentTypes.lastParameterType().getComponentType(); + ResolvableType varArgResolvableType = ResolvableType.forClass(varArgClass); + TypeDescriptor varArgContentType = new TypeDescriptor(varArgResolvableType, varArgClass, null); + + // If the target is varargs and there is just one more argument, then convert it here. + if (varargsPosition == arguments.length - 1) { + Object argument = arguments[varargsPosition]; + TypeDescriptor sourceType = TypeDescriptor.forObject(argument); + if (argument == null) { + // Perform the equivalent of GenericConversionService.convertNullSource() for a single argument. + if (varArgContentType.getElementTypeDescriptor().getObjectType() == Optional.class) { + arguments[varargsPosition] = Optional.empty(); + conversionOccurred = true; + } + } + // If the argument type is equal to the varargs element type, there is no need to + // convert it or wrap it in an array. For example, using StringToArrayConverter to + // convert a String containing a comma would result in the String being split and + // repackaged in an array when it should be used as-is. + else if (!sourceType.equals(varArgContentType.getElementTypeDescriptor())) { + arguments[varargsPosition] = converter.convertValue(argument, sourceType, varArgContentType); + } + // Possible outcomes of the above if-else block: + // 1) the input argument was null, and nothing was done. + // 2) the input argument was null; the varargs element type is Optional; and the argument was converted to Optional.empty(). + // 3) the input argument was correct type but not wrapped in an array, and nothing was done. + // 4) the input argument was already compatible (i.e., array of valid type), and nothing was done. + // 5) the input argument was the wrong type and got converted and wrapped in an array. + if (argument != arguments[varargsPosition] && + !isFirstEntryInArray(argument, arguments[varargsPosition])) { + conversionOccurred = true; // case 5 + } + } + // Otherwise, convert remaining arguments to the varargs element type. + else { + Assert.state(varArgContentType != null, "No element type"); + for (int i = varargsPosition; i < arguments.length; i++) { + Object argument = arguments[i]; + arguments[i] = converter.convertValue(argument, TypeDescriptor.forObject(argument), varArgContentType); + conversionOccurred |= (argument != arguments[i]); + } + } + } + return conversionOccurred; + } + /** * Check if the supplied value is the first entry in the array represented by the possibleArray value. * @param value the value to check for in the array diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java b/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java index 7cde54692e0d..4940861a369a 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java @@ -16,6 +16,7 @@ package org.springframework.expression.spel.support; +import java.lang.invoke.MethodHandle; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.List; @@ -251,6 +252,10 @@ public void registerFunction(String name, Method method) { this.variables.put(name, method); } + public void registerFunction(String name, MethodHandle methodHandle) { + this.variables.put(name, methodHandle); + } + @Override @Nullable public Object lookupVariable(String name) { diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/ExpressionLanguageScenarioTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/ExpressionLanguageScenarioTests.java index aa6bf033f52e..3fbc0af024fd 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/ExpressionLanguageScenarioTests.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/ExpressionLanguageScenarioTests.java @@ -188,6 +188,45 @@ public void testScenario_RegisteringJavaMethodsAsFunctionsAndCallingThem() throw } } + /** + * Scenario: looking up your own MethodHandles and calling them from the expression + */ + @Test + public void testScenario_RegisteringJavaMethodsAsMethodHandlesAndCallingThem() throws SecurityException, NoSuchMethodException { + try { + // Create a parser + SpelExpressionParser parser = new SpelExpressionParser(); + //this.context is already populated with all relevant MethodHandle examples + + Expression expr = parser.parseRaw("#message('Message with %s words: <%s>', 2, 'Hello World', 'ignored')"); + Object value = expr.getValue(this.context); + assertThat(value).isEqualTo("Message with 2 words: "); + + expr = parser.parseRaw("#messageTemplate('bound', 2, 'Hello World', 'ignored')"); + value = expr.getValue(this.context); + assertThat(value).isEqualTo("This is a bound message with 2 words: "); + + expr = parser.parseRaw("#messageBound()"); + value = expr.getValue(this.context); + assertThat(value).isEqualTo("This is a prerecorded message with 3 words: "); + + Expression staticExpr = parser.parseRaw("#messageStatic('Message with %s words: <%s>', 2, 'Hello World', 'ignored')"); + Object staticValue = staticExpr.getValue(this.context); + assertThat(staticValue).isEqualTo("Message with 2 words: "); + + staticExpr = parser.parseRaw("#messageStaticTemplate('bound', 2, 'Hello World', 'ignored')"); + staticValue = staticExpr.getValue(this.context); + assertThat(staticValue).isEqualTo("This is a bound message with 2 words: "); + + staticExpr = parser.parseRaw("#messageStaticBound()"); + staticValue = staticExpr.getValue(this.context); + assertThat(staticValue).isEqualTo("This is a prerecorded message with 3 words: "); + } + catch (EvaluationException | ParseException ex) { + throw new AssertionError(ex.getMessage(), ex); + } + } + /** * Scenario: add a property resolver that will get called in the resolver chain, this one only supports reading. */ diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/SpelDocumentationTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/SpelDocumentationTests.java index 3e9d3bc92a95..1ba488ccab0b 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/SpelDocumentationTests.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/SpelDocumentationTests.java @@ -16,6 +16,9 @@ package org.springframework.expression.spel; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; @@ -415,6 +418,38 @@ void functions() throws Exception { assertThat(helloWorldReversed).isEqualTo("dlrow olleh"); } + @Test + void methodHandlesNotBound() throws Throwable { + ExpressionParser parser = new SpelExpressionParser(); + StandardEvaluationContext context = new StandardEvaluationContext(); + + MethodHandle mh = MethodHandles.lookup().findVirtual(String.class, "formatted", + MethodType.methodType(String.class, Object[].class)); + context.setVariable("message", mh); + + String message = parser.parseExpression("#message('Simple message: <%s>', 'Hello World', 'ignored')") + .getValue(context, String.class); + assertThat(message).isEqualTo("Simple message: "); + } + + @Test + void methodHandlesFullyBound() throws Throwable { + ExpressionParser parser = new SpelExpressionParser(); + StandardEvaluationContext context = new StandardEvaluationContext(); + + String template = "This is a %s message with %s words: <%s>"; + Object varargs = new Object[] { "prerecorded", 3, "Oh Hello World!", "ignored" }; + MethodHandle mh = MethodHandles.lookup().findVirtual(String.class, "formatted", + MethodType.methodType(String.class, Object[].class)) + .bindTo(template) + .bindTo(varargs); //here we have to provide arguments in a single array binding + context.setVariable("message", mh); + + String message = parser.parseExpression("#message()") + .getValue(context, String.class); + assertThat(message).isEqualTo("This is a prerecorded message with 3 words: "); + } + // 7.5.10 @Test diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/TestScenarioCreator.java b/spring-expression/src/test/java/org/springframework/expression/spel/TestScenarioCreator.java index ebeebcf7e0ce..2d626013f076 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/TestScenarioCreator.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/TestScenarioCreator.java @@ -16,6 +16,9 @@ package org.springframework.expression.spel; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; import java.util.Arrays; import java.util.GregorianCalendar; @@ -37,6 +40,12 @@ public static StandardEvaluationContext getTestEvaluationContext() { setupRootContextObject(testContext); populateVariables(testContext); populateFunctions(testContext); + try { + populateMethodHandles(testContext); + } + catch (NoSuchMethodException | IllegalAccessException e) { + throw new RuntimeException(e); + } return testContext; } @@ -62,6 +71,36 @@ private static void populateFunctions(StandardEvaluationContext testContext) { } } + /** + * Register some Java {@code MethodHandle} as well known functions that can be called from an expression. + * @param testContext the test evaluation context + */ + private static void populateMethodHandles(StandardEvaluationContext testContext) throws NoSuchMethodException, IllegalAccessException { + // #message(template, args...) + MethodHandle message = MethodHandles.lookup().findVirtual(String.class, "formatted", + MethodType.methodType(String.class, Object[].class)); + testContext.registerFunction("message", message); + // #messageTemplate(args...) + MethodHandle messageWithParameters = message.bindTo("This is a %s message with %s words: <%s>"); + testContext.registerFunction("messageTemplate", messageWithParameters); + // #messageTemplateBound() + MethodHandle messageBound = messageWithParameters + .bindTo(new Object[] { "prerecorded", 3, "Oh Hello World", "ignored"}); + testContext.registerFunction("messageBound", messageBound); + + //#messageStatic(template, args...) + MethodHandle messageStatic = MethodHandles.lookup().findStatic(TestScenarioCreator.class, + "message", MethodType.methodType(String.class, String.class, String[].class)); + testContext.registerFunction("messageStatic", messageStatic); + //#messageStaticTemplate(args...) + MethodHandle messageStaticPartiallyBound = messageStatic.bindTo("This is a %s message with %s words: <%s>"); + testContext.registerFunction("messageStaticTemplate", messageStaticPartiallyBound); + //#messageStaticBound() + MethodHandle messageStaticFullyBound = messageStaticPartiallyBound + .bindTo(new String[] { "prerecorded", "3", "Oh Hello World", "ignored"}); + testContext.registerFunction("messageStaticBound", messageStaticFullyBound); + } + /** * Register some variables that can be referenced from the tests * @param testContext the test evaluation context @@ -117,4 +156,8 @@ public static String varargsFunction2(int i, String... strings) { return String.valueOf(i) + "-" + Arrays.toString(strings); } + public static String message(String template, String... args) { + return template.formatted((Object[]) args); + } + }