Skip to content

Commit 906c54f

Browse files
committed
Add SpEL support for registered MethodHandles
This commit adds support for MethodHandles in SpEL, using the same syntax as user-defined functions (which also covers reflective Methods). The most benefit is expected with handles that capture a static method with no arguments, or with fully bound handles (where all the arguments have been bound, including a target instance as first bound argument if necessary). Partially bound MethodHandle should also be supported. A best effort approach is taken to detect varargs as there is no API support to determine if an argument is a vararg or an explicit array, unlike with Method. Argument conversions are also applied. Finally, array repacking is not always necessary with varargs so it is only performed when the vararg is the sole argument to the invoked method. See gh-27099 Closes gh-30045
1 parent d3c3088 commit 906c54f

File tree

9 files changed

+393
-4
lines changed

9 files changed

+393
-4
lines changed

framework-docs/modules/ROOT/pages/core/expressions.adoc

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ The expression language supports the following functionality:
4747
* Inline maps
4848
* Ternary operator
4949
* Variables
50-
* User-defined functions
50+
* User-defined functions added to the context
51+
* reflective invocation of `Method`
52+
* various cases of `MethodHandle`
5153
* Collection projection
5254
* Collection selection
5355
* Templated expressions

framework-docs/modules/ROOT/pages/core/expressions/language-ref.adoc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ topics:
1515
* xref:core/expressions/language-ref/types.adoc[Types]
1616
* xref:core/expressions/language-ref/constructors.adoc[Constructors]
1717
* xref:core/expressions/language-ref/variables.adoc[Variables]
18-
* xref:core/expressions/language-ref/functions.adoc[Functions]
18+
* xref:core/expressions/language-ref/functions.adoc[User-Defined Functions]
1919
* xref:core/expressions/language-ref/bean-references.adoc[Bean References]
2020
* xref:core/expressions/language-ref/operator-ternary.adoc[Ternary Operator (If-Then-Else)]
2121
* xref:core/expressions/language-ref/operator-elvis.adoc[The Elvis Operator]

framework-docs/modules/ROOT/pages/core/expressions/language-ref/functions.adoc

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33

44
You can extend SpEL by registering user-defined functions that can be called within the
55
expression string. The function is registered through the `EvaluationContext`. The
6-
following example shows how to register a user-defined function:
6+
following example shows how to register a user-defined function to be invoked via reflection
7+
(i.e. a `Method`):
78

89
[tabs]
910
======
@@ -94,5 +95,97 @@ Kotlin::
9495
----
9596
======
9697

98+
The use of `MethodHandle` is also supported. This enables potentially more efficient use
99+
cases if the `MethodHandle` target and parameters have been fully bound prior to
100+
registration, but partially bound handles are also supported.
101+
102+
Consider the `String#formatted(String, Object...)` instance method, which produces a
103+
message according to a template and a variable number of arguments.
104+
105+
You can register and use the `formatted` method as a `MethodHandle`, as the following
106+
example shows:
107+
108+
[tabs]
109+
======
110+
Java::
111+
+
112+
[source,java,indent=0,subs="verbatim,quotes",role="primary"]
113+
----
114+
ExpressionParser parser = new SpelExpressionParser();
115+
EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build();
116+
117+
MethodHandle mh = MethodHandles.lookup().findVirtual(String.class, "formatted",
118+
MethodType.methodType(String.class, Object[].class));
119+
context.setVariable("message", mh);
120+
121+
String message = parser.parseExpression("#message('Simple message: <%s>', 'Hello World', 'ignored')")
122+
.getValue(context, String.class);
123+
//returns "Simple message: <Hello World>"
124+
----
125+
126+
Kotlin::
127+
+
128+
[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"]
129+
----
130+
val parser = SpelExpressionParser()
131+
val context = SimpleEvaluationContext.forReadOnlyDataBinding().build()
132+
133+
val mh = MethodHandles.lookup().findVirtual(String::class.java, "formatted",
134+
MethodType.methodType(String::class.java, Array<Any>::class.java))
135+
context.setVariable("message", mh)
136+
137+
val message = parser.parseExpression("#message('Simple message: <%s>', 'Hello World', 'ignored')")
138+
.getValue(context, String::class.java)
139+
----
140+
======
141+
142+
As hinted above, binding a `MethodHandle` and registering the bound `MethodHandle` is also
143+
supported. This is likely to be more performant if both the target and all the arguments
144+
are bound. In that case no arguments are necessary in the SpEL expression, as the
145+
following example shows:
146+
147+
[tabs]
148+
======
149+
Java::
150+
+
151+
[source,java,indent=0,subs="verbatim,quotes",role="primary"]
152+
----
153+
ExpressionParser parser = new SpelExpressionParser();
154+
EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build();
155+
156+
String template = "This is a %s message with %s words: <%s>";
157+
Object varargs = new Object[] { "prerecorded", 3, "Oh Hello World!", "ignored" };
158+
MethodHandle mh = MethodHandles.lookup().findVirtual(String.class, "formatted",
159+
MethodType.methodType(String.class, Object[].class))
160+
.bindTo(template)
161+
.bindTo(varargs); //here we have to provide arguments in a single array binding
162+
context.setVariable("message", mh);
163+
164+
String message = parser.parseExpression("#message()")
165+
.getValue(context, String.class);
166+
//returns "This is a prerecorded message with 3 words: <Oh Hello World!>"
167+
----
168+
169+
Kotlin::
170+
+
171+
[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"]
172+
----
173+
val parser = SpelExpressionParser()
174+
val context = SimpleEvaluationContext.forReadOnlyDataBinding().build()
175+
176+
val template = "This is a %s message with %s words: <%s>"
177+
val varargs = arrayOf("prerecorded", 3, "Oh Hello World!", "ignored")
178+
179+
val mh = MethodHandles.lookup().findVirtual(String::class.java, "formatted",
180+
MethodType.methodType(String::class.java, Array<Any>::class.java))
181+
.bindTo(template)
182+
.bindTo(varargs) //here we have to provide arguments in a single array binding
183+
context.setVariable("message", mh)
184+
185+
val message = parser.parseExpression("#message()")
186+
.getValue(context, String::class.java)
187+
----
188+
======
189+
97190

98191

spring-expression/src/main/java/org/springframework/expression/spel/ast/FunctionReference.java

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
package org.springframework.expression.spel.ast;
1818

19+
import java.lang.invoke.MethodHandle;
20+
import java.lang.invoke.MethodType;
1921
import java.lang.reflect.Method;
2022
import java.lang.reflect.Modifier;
2123
import java.util.StringJoiner;
@@ -70,7 +72,17 @@ public TypedValue getValueInternal(ExpressionState state) throws EvaluationExcep
7072
if (value == TypedValue.NULL) {
7173
throw new SpelEvaluationException(getStartPosition(), SpelMessage.FUNCTION_NOT_DEFINED, this.name);
7274
}
73-
if (!(value.getValue() instanceof Method function)) {
75+
Object resolvedValue = value.getValue();
76+
if (resolvedValue instanceof MethodHandle methodHandle) {
77+
try {
78+
return executeFunctionBoundMethodHandle(state, methodHandle);
79+
}
80+
catch (SpelEvaluationException ex) {
81+
ex.setPosition(getStartPosition());
82+
throw ex;
83+
}
84+
}
85+
if (!(resolvedValue instanceof Method function)) {
7486
// Possibly a static Java method registered as a function
7587
throw new SpelEvaluationException(
7688
SpelMessage.FUNCTION_REFERENCE_CANNOT_BE_INVOKED, this.name, value.getClass());
@@ -138,6 +150,78 @@ private TypedValue executeFunctionJLRMethod(ExpressionState state, Method method
138150
}
139151
}
140152

153+
/**
154+
* Execute a function represented as {@code java.lang.invoke.MethodHandle}.
155+
* Method types that take no arguments (fully bound handles or static methods
156+
* with no parameters) can use {@code #invoke()} which is the most efficient.
157+
* Otherwise, {@code #invokeWithArguments)} is used.
158+
* @param state the expression evaluation state
159+
* @param methodHandle the method to invoke
160+
* @return the return value of the invoked Java method
161+
* @throws EvaluationException if there is any problem invoking the method
162+
* @since 6.1.0
163+
*/
164+
private TypedValue executeFunctionBoundMethodHandle(ExpressionState state, MethodHandle methodHandle) throws EvaluationException {
165+
Object[] functionArgs = getArguments(state);
166+
MethodType declaredParams = methodHandle.type();
167+
int spelParamCount = functionArgs.length;
168+
int declaredParamCount = declaredParams.parameterCount();
169+
170+
boolean isSuspectedVarargs = declaredParams.lastParameterType().isArray();
171+
172+
if (spelParamCount < declaredParamCount || (spelParamCount > declaredParamCount
173+
&& !isSuspectedVarargs)) {
174+
//incorrect number, including more arguments and not a vararg
175+
throw new SpelEvaluationException(SpelMessage.INCORRECT_NUMBER_OF_ARGUMENTS_TO_FUNCTION,
176+
functionArgs.length, declaredParamCount);
177+
//perhaps a subset of arguments was provided but the MethodHandle wasn't bound?
178+
}
179+
180+
// simplest case: the MethodHandle is fully bound or represents a static method with no params:
181+
if (declaredParamCount == 0) {
182+
//note we consider MethodHandles not compilable
183+
try {
184+
return new TypedValue(methodHandle.invoke());
185+
}
186+
catch (Throwable ex) {
187+
throw new SpelEvaluationException(getStartPosition(), ex, SpelMessage.EXCEPTION_DURING_FUNCTION_CALL,
188+
this.name, ex.getMessage());
189+
}
190+
finally {
191+
this.exitTypeDescriptor = null;
192+
this.method = null;
193+
}
194+
}
195+
196+
// more complex case, we need to look at conversion and vararg repacking
197+
Integer varArgPosition = null;
198+
if (isSuspectedVarargs) {
199+
varArgPosition = declaredParamCount - 1;
200+
}
201+
TypeConverter converter = state.getEvaluationContext().getTypeConverter();
202+
boolean conversionOccurred = ReflectionHelper.convertAllMethodHandleArguments(converter,
203+
functionArgs, methodHandle, varArgPosition);
204+
205+
if (isSuspectedVarargs && declaredParamCount == 1) {
206+
//we only repack the varargs if it is the ONLY argument
207+
functionArgs = ReflectionHelper.setupArgumentsForVarargsInvocation(
208+
methodHandle.type().parameterArray(), functionArgs);
209+
}
210+
211+
//note we consider MethodHandles not compilable
212+
try {
213+
return new TypedValue(methodHandle.invokeWithArguments(functionArgs));
214+
}
215+
catch (Throwable ex) {
216+
throw new SpelEvaluationException(getStartPosition(), ex, SpelMessage.EXCEPTION_DURING_FUNCTION_CALL,
217+
this.name, ex.getMessage());
218+
}
219+
finally {
220+
this.exitTypeDescriptor = null;
221+
this.method = null;
222+
}
223+
}
224+
141225
@Override
142226
public String toStringAST() {
143227
StringJoiner sj = new StringJoiner(",", "(", ")");

spring-expression/src/main/java/org/springframework/expression/spel/support/ReflectionHelper.java

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@
1616

1717
package org.springframework.expression.spel.support;
1818

19+
import java.lang.invoke.MethodHandle;
20+
import java.lang.invoke.MethodType;
1921
import java.lang.reflect.Array;
2022
import java.lang.reflect.Executable;
2123
import java.lang.reflect.Method;
2224
import java.util.List;
2325
import java.util.Optional;
2426

2527
import org.springframework.core.MethodParameter;
28+
import org.springframework.core.ResolvableType;
2629
import org.springframework.core.convert.TypeDescriptor;
2730
import org.springframework.expression.EvaluationException;
2831
import org.springframework.expression.TypeConverter;
@@ -330,6 +333,91 @@ else if (!sourceType.equals(targetType.getElementTypeDescriptor())) {
330333
return conversionOccurred;
331334
}
332335

336+
/**
337+
* Takes an input set of argument values and converts them to the types specified as the
338+
* required parameter types. The arguments are converted 'in-place' in the input array.
339+
* @param converter the type converter to use for attempting conversions
340+
* @param arguments the actual arguments that need conversion
341+
* @param methodHandle the target MethodHandle
342+
* @param varargsPosition the known position of the varargs argument, if any
343+
* ({@code null} if not varargs)
344+
* @return {@code true} if some kind of conversion occurred on an argument
345+
* @throws EvaluationException if a problem occurs during conversion
346+
* @since 6.1.0
347+
*/
348+
public static boolean convertAllMethodHandleArguments(TypeConverter converter, Object[] arguments,
349+
MethodHandle methodHandle, @Nullable Integer varargsPosition) throws EvaluationException {
350+
boolean conversionOccurred = false;
351+
final MethodType methodHandleArgumentTypes = methodHandle.type();
352+
if (varargsPosition == null) {
353+
for (int i = 0; i < arguments.length; i++) {
354+
Class<?> argumentClass = methodHandleArgumentTypes.parameterType(i);
355+
ResolvableType resolvableType = ResolvableType.forClass(argumentClass);
356+
TypeDescriptor targetType = new TypeDescriptor(resolvableType, argumentClass, null);
357+
358+
Object argument = arguments[i];
359+
arguments[i] = converter.convertValue(argument, TypeDescriptor.forObject(argument), targetType);
360+
conversionOccurred |= (argument != arguments[i]);
361+
}
362+
}
363+
else {
364+
// Convert everything up to the varargs position
365+
for (int i = 0; i < varargsPosition; i++) {
366+
Class<?> argumentClass = methodHandleArgumentTypes.parameterType(i);
367+
ResolvableType resolvableType = ResolvableType.forClass(argumentClass);
368+
TypeDescriptor targetType = new TypeDescriptor(resolvableType, argumentClass, null);
369+
370+
Object argument = arguments[i];
371+
arguments[i] = converter.convertValue(argument, TypeDescriptor.forObject(argument), targetType);
372+
conversionOccurred |= (argument != arguments[i]);
373+
}
374+
375+
final Class<?> varArgClass = methodHandleArgumentTypes.lastParameterType().getComponentType();
376+
ResolvableType varArgResolvableType = ResolvableType.forClass(varArgClass);
377+
TypeDescriptor varArgContentType = new TypeDescriptor(varArgResolvableType, varArgClass, null);
378+
379+
// If the target is varargs and there is just one more argument, then convert it here.
380+
if (varargsPosition == arguments.length - 1) {
381+
Object argument = arguments[varargsPosition];
382+
TypeDescriptor sourceType = TypeDescriptor.forObject(argument);
383+
if (argument == null) {
384+
// Perform the equivalent of GenericConversionService.convertNullSource() for a single argument.
385+
if (varArgContentType.getElementTypeDescriptor().getObjectType() == Optional.class) {
386+
arguments[varargsPosition] = Optional.empty();
387+
conversionOccurred = true;
388+
}
389+
}
390+
// If the argument type is equal to the varargs element type, there is no need to
391+
// convert it or wrap it in an array. For example, using StringToArrayConverter to
392+
// convert a String containing a comma would result in the String being split and
393+
// repackaged in an array when it should be used as-is.
394+
else if (!sourceType.equals(varArgContentType.getElementTypeDescriptor())) {
395+
arguments[varargsPosition] = converter.convertValue(argument, sourceType, varArgContentType);
396+
}
397+
// Possible outcomes of the above if-else block:
398+
// 1) the input argument was null, and nothing was done.
399+
// 2) the input argument was null; the varargs element type is Optional; and the argument was converted to Optional.empty().
400+
// 3) the input argument was correct type but not wrapped in an array, and nothing was done.
401+
// 4) the input argument was already compatible (i.e., array of valid type), and nothing was done.
402+
// 5) the input argument was the wrong type and got converted and wrapped in an array.
403+
if (argument != arguments[varargsPosition] &&
404+
!isFirstEntryInArray(argument, arguments[varargsPosition])) {
405+
conversionOccurred = true; // case 5
406+
}
407+
}
408+
// Otherwise, convert remaining arguments to the varargs element type.
409+
else {
410+
Assert.state(varArgContentType != null, "No element type");
411+
for (int i = varargsPosition; i < arguments.length; i++) {
412+
Object argument = arguments[i];
413+
arguments[i] = converter.convertValue(argument, TypeDescriptor.forObject(argument), varArgContentType);
414+
conversionOccurred |= (argument != arguments[i]);
415+
}
416+
}
417+
}
418+
return conversionOccurred;
419+
}
420+
333421
/**
334422
* Check if the supplied value is the first entry in the array represented by the possibleArray value.
335423
* @param value the value to check for in the array

spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package org.springframework.expression.spel.support;
1818

19+
import java.lang.invoke.MethodHandle;
1920
import java.lang.reflect.Method;
2021
import java.util.ArrayList;
2122
import java.util.List;
@@ -251,6 +252,10 @@ public void registerFunction(String name, Method method) {
251252
this.variables.put(name, method);
252253
}
253254

255+
public void registerFunction(String name, MethodHandle methodHandle) {
256+
this.variables.put(name, methodHandle);
257+
}
258+
254259
@Override
255260
@Nullable
256261
public Object lookupVariable(String name) {

0 commit comments

Comments
 (0)