mirror of
https://github.com/hs-web/hsweb-framework.git
synced 2026-09-03 06:35:25 +08:00
feat(bean): 支持FastBeanCopier复制record
This commit is contained in:
@@ -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 基线不能直接引用 RecordComponent;record 不可变,只能重建新实例。
|
||||
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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user