Skip to content

Commit 3a33278

Browse files
javier-godoypaodb
authored andcommitted
feat: add utility for class instrumentation
1 parent 9a65fe4 commit 3a33278

File tree

7 files changed

+470
-0
lines changed

7 files changed

+470
-0
lines changed

pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@
6868
<artifactId>vaadin-core</artifactId>
6969
<optional>true</optional>
7070
</dependency>
71+
<dependency>
72+
<groupId>org.ow2.asm</groupId>
73+
<artifactId>asm</artifactId>
74+
<version>9.8</version>
75+
<optional>true</optional>
76+
</dependency>
7177
<dependency>
7278
<groupId>tools.jackson.core</groupId>
7379
<artifactId>jackson-databind</artifactId>
Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
/*-
2+
* #%L
3+
* Json Migration Helper
4+
* %%
5+
* Copyright (C) 2025 Flowing Code
6+
* %%
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
* #L%
19+
*/
20+
package com.flowingcode.vaadin.jsonmigration;
21+
22+
import com.vaadin.flow.component.ClientCallable;
23+
import com.vaadin.flow.component.Component;
24+
import elemental.json.JsonArray;
25+
import elemental.json.JsonBoolean;
26+
import elemental.json.JsonNumber;
27+
import elemental.json.JsonObject;
28+
import elemental.json.JsonString;
29+
import elemental.json.JsonValue;
30+
import java.lang.reflect.Constructor;
31+
import java.lang.reflect.Method;
32+
import java.lang.reflect.Modifier;
33+
import java.util.ArrayList;
34+
import java.util.List;
35+
import java.util.Map;
36+
import java.util.WeakHashMap;
37+
import java.util.concurrent.ConcurrentHashMap;
38+
import lombok.experimental.UtilityClass;
39+
import org.objectweb.asm.ClassWriter;
40+
import org.objectweb.asm.MethodVisitor;
41+
import org.objectweb.asm.Opcodes;
42+
import org.objectweb.asm.Type;
43+
import tools.jackson.databind.JsonNode;
44+
import tools.jackson.databind.node.ArrayNode;
45+
import tools.jackson.databind.node.BooleanNode;
46+
import tools.jackson.databind.node.DoubleNode;
47+
import tools.jackson.databind.node.ObjectNode;
48+
import tools.jackson.databind.node.StringNode;
49+
50+
/**
51+
* Utility class for instrumenting classes at runtime.
52+
*
53+
* <p>
54+
* This class provides methods to dynamically create subclasses of a given parent class using
55+
* bytecode instrumentation. Methods annotated with {@link ClientCallable} that return a type
56+
* assignable to {@link JsonValue} are automatically overridden to convert the result through
57+
* {@link JsonMigration#convertToClientCallableResult(JsonValue)}.
58+
* </p>
59+
*
60+
* @author Javier Godoy / Flowing Code
61+
*/
62+
@UtilityClass
63+
final class ClassInstrumentationUtil {
64+
65+
private static final Map<ClassLoader, InstrumentedClassLoader> classLoaderCache =
66+
new WeakHashMap<>();
67+
68+
/**
69+
* Creates and returns an instance of a dynamically instrumented class that extends the specified
70+
* parent class.
71+
*
72+
* <p>
73+
* This method generates a new class at runtime that extends {@code parent}, and returns a new
74+
* instance of that generated class. The instrumented class will have a default constructor that
75+
* delegates to the parent's default constructor. All methods annotated with
76+
* {@link ClientCallable} that return a type assignable to {@link JsonValue} will be overridden to
77+
* convert the result via JsonMigration.convertClientCallableResult().
78+
* </p>
79+
*
80+
* <p>
81+
* <b>Requirements:</b>
82+
* </p>
83+
* <ul>
84+
* <li>The parent class must not be final</li>
85+
* <li>The parent class must have an accessible no-argument constructor</li>
86+
* </ul>
87+
*
88+
* @param <T> the type of the parent class
89+
* @param parent the parent class to extend
90+
* @return a new instance of the instrumented class extending {@code parent}
91+
* @throws IllegalArgumentException if the parent class is final, an interface, a primitive type,
92+
* an array type, or does not have an accessible no-argument constructor
93+
* @throws RuntimeException if the instrumentation or instantiation fails
94+
*/
95+
public <T extends Component> Class<? extends T> instrumentClass(Class<T> parent) {
96+
// Validate input
97+
if (parent == null) {
98+
throw new IllegalArgumentException("Parent class cannot be null");
99+
}
100+
101+
if (parent.isInterface()) {
102+
throw new IllegalArgumentException("Cannot instrument an interface: " + parent.getName());
103+
}
104+
105+
if (parent.isPrimitive()) {
106+
throw new IllegalArgumentException("Cannot instrument a primitive type: " + parent.getName());
107+
}
108+
109+
if (parent.isArray()) {
110+
throw new IllegalArgumentException("Cannot instrument an array type: " + parent.getName());
111+
}
112+
113+
if (Modifier.isFinal(parent.getModifiers())) {
114+
throw new IllegalArgumentException("Cannot instrument a final class: " + parent.getName());
115+
}
116+
117+
if (!needsInstrumentation(parent)) {
118+
return parent;
119+
}
120+
121+
// Check for accessible no-arg constructor
122+
try {
123+
Constructor<?> defaultConstructor = parent.getDeclaredConstructor();
124+
if (!Modifier.isPublic(defaultConstructor.getModifiers())
125+
&& !Modifier.isProtected(defaultConstructor.getModifiers())) {
126+
try {
127+
defaultConstructor.setAccessible(true);
128+
} catch (Exception e) {
129+
throw new IllegalArgumentException(
130+
"Parent class must have an accessible no-argument constructor: " + parent.getName(),
131+
e);
132+
}
133+
}
134+
} catch (NoSuchMethodException e) {
135+
throw new IllegalArgumentException(
136+
"Parent class must have a no-argument constructor: " + parent.getName(), e);
137+
}
138+
139+
try {
140+
String instrumentedClassName = parent.getName() + "$Instrumented";
141+
return createInstrumentedClass(parent, instrumentedClassName);
142+
} catch (Exception e) {
143+
throw new RuntimeException("Failed to instrument " + parent.getName(), e);
144+
}
145+
}
146+
147+
private static boolean needsInstrumentation(Class<?> parent) {
148+
return !getInstrumentableMethods(parent).isEmpty();
149+
}
150+
151+
private static List<Method> getInstrumentableMethods(Class<?> parent) {
152+
List<Method> methods = new ArrayList<>();
153+
for (Method method : parent.getDeclaredMethods()) {
154+
if (!Modifier.isStatic(method.getModifiers()) && !Modifier.isPrivate(method.getModifiers())) {
155+
boolean isCallable = method.isAnnotationPresent(ClientCallable.class);
156+
boolean isLegacyCallable = method.isAnnotationPresent(LegacyClientCallable.class);
157+
if (isCallable || isLegacyCallable) {
158+
boolean hasJsonValueReturn = JsonValue.class.isAssignableFrom(method.getReturnType());
159+
boolean hasJsonValueParams = hasJsonValueParameters(method);
160+
161+
if (isCallable && hasJsonValueParams) {
162+
throw new IllegalArgumentException(String.format(
163+
"Instrumented method '%s' in class '%s' has JsonValue arguments and must be annotated with @%s instead of @ClientCallable",
164+
method.getName(), method.getDeclaringClass().getName(),
165+
LegacyClientCallable.class.getName()));
166+
} else if (isCallable && hasJsonValueReturn) {
167+
methods.add(method);
168+
} else if (isLegacyCallable) {
169+
methods.add(method);
170+
}
171+
}
172+
}
173+
}
174+
return methods;
175+
}
176+
177+
private static boolean hasJsonValueParameters(Method method) {
178+
for (Class<?> paramType : method.getParameterTypes()) {
179+
if (JsonValue.class.isAssignableFrom(paramType)) {
180+
return true;
181+
}
182+
}
183+
return false;
184+
}
185+
186+
private <T extends Component> Class<? extends T> createInstrumentedClass(Class<T> parent,
187+
String className) throws Exception {
188+
InstrumentedClassLoader classLoader =
189+
getOrCreateInstrumentedClassLoader(parent.getClassLoader());
190+
return classLoader.defineInstrumentedClass(className, parent).asSubclass(parent);
191+
}
192+
193+
private InstrumentedClassLoader getOrCreateInstrumentedClassLoader(ClassLoader parent) {
194+
synchronized (classLoaderCache) {
195+
return classLoaderCache.computeIfAbsent(parent, InstrumentedClassLoader::new);
196+
}
197+
}
198+
199+
private static final class InstrumentedClassLoader extends ClassLoader {
200+
201+
private final Map<Class<?>, Class<?>> instrumentedClassCache = new ConcurrentHashMap<>();
202+
203+
public InstrumentedClassLoader(ClassLoader parent) {
204+
super(parent);
205+
}
206+
207+
public Class<?> defineInstrumentedClass(String className, Class<?> parent) {
208+
return instrumentedClassCache.computeIfAbsent(parent, p -> {
209+
byte[] bytecode = generateBytecode(className, p);
210+
return defineClass(className, bytecode, 0, bytecode.length);
211+
});
212+
}
213+
214+
private byte[] generateBytecode(String className, Class<?> parent) {
215+
String internalClassName = className.replace('.', '/');
216+
String internalParentName = parent.getName().replace('.', '/');
217+
218+
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
219+
220+
cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internalClassName, null, internalParentName, null);
221+
222+
generateConstructor(cw, internalParentName);
223+
generateClientCallableOverrides(cw, parent, internalParentName);
224+
225+
cw.visitEnd();
226+
return cw.toByteArray();
227+
}
228+
229+
private void generateConstructor(ClassWriter cw, String internalParentName) {
230+
MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
231+
mv.visitCode();
232+
mv.visitVarInsn(Opcodes.ALOAD, 0);
233+
mv.visitMethodInsn(Opcodes.INVOKESPECIAL, internalParentName, "<init>", "()V", false);
234+
mv.visitInsn(Opcodes.RETURN);
235+
mv.visitMaxs(0, 0);
236+
mv.visitEnd();
237+
}
238+
239+
private void generateClientCallableOverrides(ClassWriter cw, Class<?> parent,
240+
String internalParentName) {
241+
for (Method method : getInstrumentableMethods(parent)) {
242+
generateMethodOverride(cw, method, internalParentName);
243+
}
244+
}
245+
246+
private void generateMethodOverride(ClassWriter cw, Method method, String internalParentName) {
247+
boolean hasJsonValueReturn = JsonValue.class.isAssignableFrom(method.getReturnType());
248+
boolean hasJsonValueParams = hasJsonValueParameters(method);
249+
250+
String overrideDescriptor = getMethodDescriptor(method, hasJsonValueParams);
251+
String superDescriptor = getMethodDescriptor(method, false);
252+
int access = method.getModifiers() & (Opcodes.ACC_PUBLIC | Opcodes.ACC_PROTECTED);
253+
254+
MethodVisitor mv = cw.visitMethod(access, method.getName(), overrideDescriptor, null,
255+
getExceptionInternalNames(method.getExceptionTypes()));
256+
257+
mv.visitAnnotation(Type.getDescriptor(ClientCallable.class), true);
258+
mv.visitCode();
259+
260+
// Load 'this'
261+
mv.visitVarInsn(Opcodes.ALOAD, 0);
262+
263+
// Load and convert parameters
264+
Class<?>[] paramTypes = method.getParameterTypes();
265+
int localVarIndex = 1;
266+
for (Class<?> paramType : paramTypes) {
267+
if (hasJsonValueParams && JsonValue.class.isAssignableFrom(paramType)) {
268+
// Load the JsonNode parameter
269+
mv.visitVarInsn(Opcodes.ALOAD, localVarIndex);
270+
271+
// Call JsonMigration.convertToJsonValue(JsonNode) -> JsonValue
272+
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/flowingcode/vaadin/jsonmigration/JsonMigration",
273+
"convertToJsonValue", "(Ljava/lang/Object;)Lelemental/json/JsonValue;", false);
274+
275+
// Cast to the original type if not JsonValue
276+
if (paramType != JsonValue.class) {
277+
mv.visitTypeInsn(Opcodes.CHECKCAST, Type.getInternalName(paramType));
278+
}
279+
280+
localVarIndex++;
281+
} else {
282+
localVarIndex += loadParameter(mv, paramType, localVarIndex);
283+
}
284+
}
285+
286+
// Call super.methodName(params) with original descriptor
287+
mv.visitMethodInsn(Opcodes.INVOKESPECIAL, internalParentName, method.getName(), superDescriptor,
288+
false);
289+
290+
if (hasJsonValueReturn) {
291+
// Store result in local variable
292+
mv.visitVarInsn(Opcodes.ASTORE, localVarIndex);
293+
294+
// Load result back
295+
mv.visitVarInsn(Opcodes.ALOAD, localVarIndex);
296+
297+
// Call JsonMigration.convertToClientCallableResult(aux)
298+
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/flowingcode/vaadin/jsonmigration/JsonMigration",
299+
"convertToClientCallableResult", "(Lelemental/json/JsonValue;)Lelemental/json/JsonValue;",
300+
false);
301+
}
302+
303+
// Return converted result or void
304+
if (method.getReturnType() == Void.TYPE) {
305+
mv.visitInsn(Opcodes.RETURN);
306+
} else {
307+
mv.visitInsn(Opcodes.ARETURN);
308+
}
309+
310+
mv.visitMaxs(0, 0);
311+
mv.visitEnd();
312+
}
313+
314+
private int loadParameter(MethodVisitor mv, Class<?> paramType, int localVarIndex) {
315+
if (!paramType.isPrimitive()) {
316+
mv.visitVarInsn(Opcodes.ALOAD, localVarIndex);
317+
return 1;
318+
} else if (paramType == Long.TYPE) {
319+
mv.visitVarInsn(Opcodes.LLOAD, localVarIndex);
320+
return 2;
321+
} else if (paramType == Float.TYPE) {
322+
mv.visitVarInsn(Opcodes.FLOAD, localVarIndex);
323+
return 1;
324+
} else if (paramType == Double.TYPE) {
325+
mv.visitVarInsn(Opcodes.DLOAD, localVarIndex);
326+
return 2;
327+
} else {
328+
mv.visitVarInsn(Opcodes.ILOAD, localVarIndex);
329+
return 1;
330+
}
331+
}
332+
333+
private String getMethodDescriptor(Method method, boolean convertJsonValueParams) {
334+
StringBuilder sb = new StringBuilder("(");
335+
for (Class<?> paramType : method.getParameterTypes()) {
336+
if (convertJsonValueParams && JsonValue.class.isAssignableFrom(paramType)) {
337+
sb.append(getConvertedTypeDescriptor(paramType));
338+
} else {
339+
sb.append(Type.getDescriptor(paramType));
340+
}
341+
}
342+
sb.append(")");
343+
sb.append(Type.getDescriptor(method.getReturnType()));
344+
return sb.toString();
345+
}
346+
347+
private String getConvertedTypeDescriptor(Class<?> type) {
348+
if (type == JsonObject.class) {
349+
return Type.getDescriptor(ObjectNode.class);
350+
} else if (type == JsonArray.class) {
351+
return Type.getDescriptor(ArrayNode.class);
352+
} else if (type == JsonBoolean.class) {
353+
return Type.getDescriptor(BooleanNode.class);
354+
} else if (type == JsonNumber.class) {
355+
return Type.getDescriptor(DoubleNode.class);
356+
} else if (type == JsonString.class) {
357+
return Type.getDescriptor(StringNode.class);
358+
} else if (JsonValue.class.isAssignableFrom(type)) {
359+
return Type.getDescriptor(JsonNode.class);
360+
}
361+
return Type.getDescriptor(type);
362+
}
363+
364+
private String[] getExceptionInternalNames(Class<?>[] exceptionTypes) {
365+
if (exceptionTypes == null || exceptionTypes.length == 0) {
366+
return null;
367+
}
368+
String[] names = new String[exceptionTypes.length];
369+
for (int i = 0; i < exceptionTypes.length; i++) {
370+
names[i] = exceptionTypes[i].getName().replace('.', '/');
371+
}
372+
return names;
373+
}
374+
}
375+
}

0 commit comments

Comments
 (0)