feat(bean): 支持FastBeanCopier复制record

This commit is contained in:
zhouhao
2026-07-14 17:45:36 +08:00
parent 11ed64b489
commit c8c064629b
2 changed files with 302 additions and 0 deletions

View File

@@ -21,7 +21,10 @@ import org.springframework.util.ReflectionUtils;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Array;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Type;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BiFunction;
@@ -110,6 +113,9 @@ public final class FastBeanCopier {
@SneakyThrows
public static <T, S> T copy(S source, Class<T> target, String... ignore) {
if (isRecordType(target)) {
return copyToRecord(source, target, DEFAULT_CONVERT, ignore);
}
return copy(source, target.newInstance(), DEFAULT_CONVERT, ignore);
}
@@ -123,6 +129,10 @@ public final class FastBeanCopier {
@SuppressWarnings("all")
public static <T, S> T copy(S source, T target, Converter converter, Set<String> ignore) {
if (target != null && isRecordType(getUserClass(target))) {
// Java 8 基线不能直接引用 RecordComponentrecord 不可变,只能重建新实例。
return (T) copyToRecord(source, (Class) getUserClass(target), converter, ignore);
}
if (source instanceof Map && target instanceof Map) {
if (CollectionUtils.isEmpty(ignore)) {
((Map) target).putAll(((Map) source));
@@ -142,6 +152,52 @@ public final class FastBeanCopier {
return target;
}
@SneakyThrows
static <T, S> T copyToRecord(S source, Class<T> target, Converter converter, String... ignore) {
Set<String> ignored = (ignore == null || ignore.length == 0)
? Collections.emptySet()
: new HashSet<>(Arrays.asList(ignore));
return copyToRecord(source, target, converter, ignored);
}
@SneakyThrows
@SuppressWarnings({"unchecked", "rawtypes"})
static <T, S> T copyToRecord(S source, Class<T> target, Converter converter, Set<String> ignore) {
// record 没有无参构造和 setter只能按 canonical constructor 的组件顺序组装参数。
Object[] components = getRecordComponents(target);
Class<?>[] constructorTypes = new Class<?>[components.length];
Object[] values = new Object[components.length];
Map<String, ClassProperty> sourceProperties = source instanceof Map
? Collections.emptyMap()
: createProperty(getUserClass(source));
for (int i = 0; i < components.length; i++) {
Object component = components[i];
String name = getRecordComponentName(component);
Class<?> componentType = getRecordComponentType(component);
constructorTypes[i] = componentType;
if (ignore != null && ignore.contains(name)) {
values[i] = defaultValue(componentType);
continue;
}
Object value = readRecordSourceValue(source, sourceProperties, name);
if (value != null) {
Class[] genericTypes = resolveRecordGenericTypes(component);
boolean requiresGenericConversion = genericTypes.length > 0
&& (Collection.class.isAssignableFrom(componentType) || Map.class.isAssignableFrom(componentType));
if (requiresGenericConversion || !isDirectAssignable(componentType, value)) {
value = converter.convert(value, (Class) componentType, genericTypes);
}
}
values[i] = value == null && componentType.isPrimitive()
? defaultValue(componentType)
: value;
}
Constructor<T> constructor = target.getDeclaredConstructor(constructorTypes);
ReflectionUtils.makeAccessible(constructor);
return constructor.newInstance(values);
}
static Class<?> getUserClass(Object object) {
if (object instanceof Map) {
return Map.class;
@@ -218,6 +274,10 @@ public final class FastBeanCopier {
private static Map<String, ClassProperty> createProperty(Class<?> type) {
if (isRecordType(type)) {
return createRecordProperty(type);
}
List<String> fieldNames = Arrays
.stream(type.getDeclaredFields())
.map(Field::getName)
@@ -234,6 +294,15 @@ public final class FastBeanCopier {
}
private static Map<String, ClassProperty> createRecordProperty(Class<?> type) {
return Arrays.stream(getRecordComponents(type))
.map(RecordClassProperty::new)
.collect(Collectors.toMap(ClassProperty::getName,
Function.identity(),
(k, k2) -> k,
LinkedHashMap::new));
}
private static Map<String, ClassProperty> createMapProperty(Map<String, ClassProperty> template) {
return template
.values()
@@ -517,6 +586,22 @@ public final class FastBeanCopier {
}
}
static class RecordClassProperty extends ClassProperty {
public RecordClassProperty(Object component) {
type = getRecordComponentType(component);
Method accessor = getRecordComponentAccessor(component);
readMethodName = accessor.getName();
writeMethodName = null;
getter = createGetterFunction();
setter = createSetterFunction(paramGetter -> {
throw new UnsupportedOperationException("Record property is read-only: " + getRecordComponentName(component));
});
name = getRecordComponentName(component);
beanType = accessor.getDeclaringClass();
}
}
static class MapClassProperty extends ClassProperty {
public MapClassProperty(String name) {
type = Object.class;
@@ -541,6 +626,116 @@ public final class FastBeanCopier {
}
static boolean isRecordType(Class<?> type) {
try {
Method method = Class.class.getMethod("isRecord");
return Boolean.TRUE.equals(method.invoke(type));
} catch (Throwable ignore) {
return false;
}
}
static Object[] getRecordComponents(Class<?> type) {
try {
Method method = Class.class.getMethod("getRecordComponents");
Object components = method.invoke(type);
return components == null ? new Object[0] : (Object[]) components;
} catch (Throwable e) {
throw new UnsupportedOperationException("Unsupported record type: " + type, e);
}
}
static String getRecordComponentName(Object component) {
return invokeRecordComponentMethod(component, "getName", String.class);
}
static Class<?> getRecordComponentType(Object component) {
return invokeRecordComponentMethod(component, "getType", Class.class);
}
static Method getRecordComponentAccessor(Object component) {
return invokeRecordComponentMethod(component, "getAccessor", Method.class);
}
static Type getRecordComponentGenericType(Object component) {
return invokeRecordComponentMethod(component, "getGenericType", Type.class);
}
@SneakyThrows
private static <T> T invokeRecordComponentMethod(Object component, String methodName, Class<T> returnType) {
Method method = component.getClass().getMethod(methodName);
return returnType.cast(method.invoke(component));
}
private static Object readRecordSourceValue(Object source, Map<String, ClassProperty> sourceProperties, String name) throws Exception {
if (source instanceof Map) {
return ((Map<?, ?>) source).get(name);
}
ClassProperty property = sourceProperties.get(name);
if (property == null) {
return null;
}
Method method = ReflectionUtils.findMethod(property.getBeanType(), property.getReadMethodName());
if (method == null) {
return null;
}
ReflectionUtils.makeAccessible(method);
return method.invoke(source);
}
private static Class<?>[] resolveRecordGenericTypes(Object component) {
Class<?>[] genericTypes = Arrays.stream(ResolvableType.forType(getRecordComponentGenericType(component)).getGenerics())
.map(ResolvableType::getRawClass)
.filter(Objects::nonNull)
.toArray(Class[]::new);
return genericTypes.length == 0 ? EMPTY_CLASS_ARRAY : genericTypes;
}
private static boolean isDirectAssignable(Class<?> targetType, Object value) {
if (value == null) {
return false;
}
if (targetType.isInstance(value)) {
return true;
}
if (!targetType.isPrimitive()) {
return false;
}
Class<?> wrapper = wrapperClassMapping.get(targetType);
return wrapper != null && wrapper.isInstance(value);
}
private static Object defaultValue(Class<?> type) {
if (!type.isPrimitive()) {
return null;
}
if (type == boolean.class) {
return false;
}
if (type == char.class) {
return (char) 0;
}
if (type == byte.class) {
return (byte) 0;
}
if (type == short.class) {
return (short) 0;
}
if (type == int.class) {
return 0;
}
if (type == long.class) {
return 0L;
}
if (type == float.class) {
return 0F;
}
if (type == double.class) {
return 0D;
}
return null;
}
public static final class DefaultConverter implements Converter {
private BeanFactory beanFactory = BEAN_FACTORY;
@@ -721,6 +916,9 @@ public final class FastBeanCopier {
return (T) copy(source, Maps.newHashMapWithExpectedSize(sourType.getFieldSize()));
}
if (isRecordType(targetClass)) {
return copyToRecord(source, targetClass, this, Collections.emptySet());
}
return copy(source, beanFactory.newInstance(targetClass), this);
} catch (Exception e) {
log.warn("复制类型{}->{}失败", targetClass, e);

View File

@@ -6,6 +6,7 @@ import lombok.Setter;
import lombok.SneakyThrows;
import org.hswebframework.ezorm.core.DefaultExtendable;
import org.junit.Assert;
import org.junit.Assume;
import org.junit.Test;
import org.springframework.util.ClassUtils;
@@ -15,7 +16,11 @@ import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.*;
import javax.tools.JavaCompiler;
import javax.tools.ToolProvider;
import java.util.concurrent.atomic.AtomicReference;
/**
@@ -176,6 +181,105 @@ public class FastBeanCopierTest {
}
}
@Test
@SneakyThrows
@SuppressWarnings({"unchecked", "rawtypes"})
public void testRecordCopy() {
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
Assume.assumeNotNull(compiler);
File sourceDir = new File("target/generated-test-records/src/org/hswebframework/web/bean/recordcopy");
File classesDir = new File("target/generated-test-records/classes");
sourceDir.mkdirs();
classesDir.mkdirs();
File nestedFile = writeRecordSource(sourceDir,
"NestedRecord.java",
"package org.hswebframework.web.bean.recordcopy;\n" +
"public record NestedRecord(String name) { }\n");
File sourceFile = writeRecordSource(sourceDir,
"SourceRecord.java",
"package org.hswebframework.web.bean.recordcopy;\n" +
"import org.hswebframework.web.bean.Color;\n" +
"public record SourceRecord(String name, int age, Color color2, NestedRecord nested) { }\n");
File targetFile = writeRecordSource(sourceDir,
"TargetRecord.java",
"package org.hswebframework.web.bean.recordcopy;\n" +
"import java.util.List;\n" +
"import org.hswebframework.web.bean.Color;\n" +
"public record TargetRecord(String name, int age, Color color2, NestedRecord nested, List<NestedRecord> nestedList) { }\n");
int exit = compiler.run(null,
null,
null,
"--release",
"17",
"-classpath",
System.getProperty("java.class.path"),
"-d",
classesDir.getAbsolutePath(),
nestedFile.getAbsolutePath(),
sourceFile.getAbsolutePath(),
targetFile.getAbsolutePath());
Assume.assumeTrue("Current JDK does not support compiling record test classes", exit == 0);
try (URLClassLoader loader = new URLClassLoader(new URL[]{classesDir.toURI().toURL()},
ClassUtils.getDefaultClassLoader())) {
Class<?> nestedClass = loader.loadClass("org.hswebframework.web.bean.recordcopy.NestedRecord");
Class<?> sourceClass = loader.loadClass("org.hswebframework.web.bean.recordcopy.SourceRecord");
Class<?> targetClass = loader.loadClass("org.hswebframework.web.bean.recordcopy.TargetRecord");
Map<String, Object> values = new LinkedHashMap<>();
values.put("name", "record-target");
values.put("age", "18");
values.put("color2", "RED");
values.put("nested", Collections.singletonMap("name", "nested-map"));
values.put("nestedList", Collections.singletonList(Collections.singletonMap("name", "nested-list")));
Object target = FastBeanCopier.copy(values, (Class) targetClass);
Assert.assertEquals("record-target", invokeAccessor(target, "name"));
Assert.assertEquals(18, invokeAccessor(target, "age"));
Assert.assertEquals(Color.RED, invokeAccessor(target, "color2"));
Assert.assertEquals("nested-map", invokeAccessor(invokeAccessor(target, "nested"), "name"));
List<?> nestedList = (List<?>) invokeAccessor(target, "nestedList");
Assert.assertEquals("nested-list", invokeAccessor(nestedList.get(0), "name"));
Object ignored = FastBeanCopier.copy(values, (Class) targetClass, "age");
Assert.assertEquals(0, invokeAccessor(ignored, "age"));
Object nested = nestedClass.getDeclaredConstructor(String.class).newInstance("nested-source");
Object source = sourceClass
.getDeclaredConstructor(String.class, int.class, Color.class, nestedClass)
.newInstance("record-source", 20, Color.BLUE, nested);
Target beanTarget = FastBeanCopier.copy(source, new Target());
Assert.assertEquals("record-source", beanTarget.getName());
Assert.assertEquals(20, beanTarget.getAge());
Assert.assertEquals(Color.BLUE, beanTarget.getColor2());
Map<String, Object> copiedMap = FastBeanCopier.copy(source, new HashMap<>());
Assert.assertEquals("record-source", copiedMap.get("name"));
Assert.assertEquals(20, copiedMap.get("age"));
Object emptyTarget = targetClass
.getDeclaredConstructor(String.class, int.class, Color.class, nestedClass, List.class)
.newInstance("old", 1, Color.BLUE, nested, Collections.emptyList());
Object rebuilt = FastBeanCopier.copy(values, emptyTarget);
Assert.assertNotSame(emptyTarget, rebuilt);
Assert.assertEquals("record-target", invokeAccessor(rebuilt, "name"));
}
}
private File writeRecordSource(File sourceDir, String fileName, String source) throws IOException {
File file = new File(sourceDir, fileName);
Files.write(file.toPath(), source.getBytes(StandardCharsets.UTF_8));
return file;
}
private Object invokeAccessor(Object target, String name) throws Exception {
return target.getClass().getMethod(name).invoke(target);
}
@Test
public void testCopyMap() {