一、背景
日常开发中,经常需要根据对象和对应的属性名来获取属性的值的场景。
很自然地,使用了 BeanUtils.getProperty(对象, "属性名")
,结果发现该工具类的行为不符合预期。
java.lang.NoSuchMethodException: Unknown property xxx
是什么原因?
本文将为你揭晓!
二、分析和解决
2.1 原因分析
模拟一个自定义对象:
代码语言:javascript复制public class MyObject {
private String aString;
public String getAString() {
return aString;
}
public void setAString(String aString) {
this.aString = aString;
}
}
模拟工具类的使用:
代码语言:javascript复制public class BeanUtilDemo {
public static void main(String[] args) throws InvocationTargetException, IllegalAccessException, NoSuchMethodException {
MyObject myObject = new MyObject();
myObject.setAString("test");
String aString = BeanUtils.getProperty(myObject, "aString");
System.out.println(aString);
}
}
输出的结果:
代码语言:javascript复制Exception in thread "main" java.lang.NoSuchMethodException: Unknown property 'aString' on class 'class org.example.third.commons.beanutils.MyObject'
at org.apache.commons.beanutils.PropertyUtilsBean.getSimpleProperty(PropertyUtilsBean.java:1270)
at org.apache.commons.beanutils.PropertyUtilsBean.getNestedProperty(PropertyUtilsBean.java:809)
at org.apache.commons.beanutils.BeanUtilsBean.getNestedProperty(BeanUtilsBean.java:711)
at org.apache.commons.beanutils.BeanUtilsBean.getProperty(BeanUtilsBean.java:737)
at org.apache.commons.beanutils.BeanUtils.getProperty(BeanUtils.java:380)
at org.example.third.commons.beanutils.BeanUtilDemo.main(BeanUtilDemo.java:12)
What??
明明有 aString
这个属性,为什么报错信息中说该类没有这个属性呢?
org.apache.commons.beanutils.BeanIntrospectionData#getDescriptor
/**
* Returns the {@code PropertyDescriptor} for the property with the specified name. If
* this property is unknown, result is null.
*
* @param name the name of the property in question
* @return the {@code PropertyDescriptor} for this property or null
*/
public PropertyDescriptor getDescriptor(final String name) {
for (final PropertyDescriptor pd : getDescriptors()) {
if (name.equals(pd.getName())) {
return pd;
}
}
return null;
}
断点不难发现属性描述中属性名为 AString
而我们传入的属性名为 aString
。
其中调用 com.sun.beans.introspect.ClassInfo#getProperties
获取属性名和属性对象的对应关系。
代码如下:
public static Map<String,PropertyInfo> get(Class<?> type) {
List<Method> methods = ClassInfo.get(type).getMethods();
if (methods.isEmpty()) {
return Collections.emptyMap();
}
Map<String,PropertyInfo> map = new TreeMap<>();
for (Method method : methods) {
if (!Modifier.isStatic(method.getModifiers())) {
Class<?> returnType = method.getReturnType();
String name = method.getName();
switch (method.getParameterCount()) {
case 0:
if (returnType.equals(boolean.class) && isPrefix(name, "is")) {
PropertyInfo info = getInfo(map, name.substring(2), false);
info.read = new MethodInfo(method, boolean.class);
} else if (!returnType.equals(void.class) && isPrefix(name, "get")) {
PropertyInfo info = getInfo(map, name.substring(3), false);
info.readList = add(info.readList, method, method.getGenericReturnType());
}
break;
case 1:
if (returnType.equals(void.class) && isPrefix(name, "set")) {
PropertyInfo info = getInfo(map, name.substring(3), false);
info.writeList = add(info.writeList, method, method.getGenericParameterTypes()[0]);
} else if (!returnType.equals(void.class) && method.getParameterTypes()[0].equals(int.class) && isPrefix(name, "get")) {
PropertyInfo info = getInfo(map, name.substring(3), true);
info.readList = add(info.readList, method, method.getGenericReturnType());
}
break;
case 2:
if (returnType.equals(void.class) && method.getParameterTypes()[0].equals(int.class) && isPrefix(name, "set")) {
PropertyInfo info = getInfo(map, name.substring(3), true);
info.writeList = add(info.writeList, method, method.getGenericParameterTypes()[1]);
}
break;
}
}
}
map.values().removeIf(propertyInfo -> !propertyInfo.initialize());
return !map.isEmpty()
? Collections.unmodifiableMap(map)
: Collections.emptyMap();
}
从这里可以发现属性名的获取并不是基于 Java 反射的 Field
,而是基于 Method
名称来解析的。
对于 get 方法而言,这里获取到的属性名是 get 之后的字符串。
后面构造 PropertyDescriptor
时,再使用 Introspector#decapitalize
转换一次。
/**
* Creates {@code PropertyDescriptor} from the specified property info.
*
* @param entry the pair of values,
* where the {@code key} is the base name of the property (the rest of the method name)
* and the {@code value} is the automatically generated property info
* @param bound the flag indicating whether it is possible to treat this property as a bound property
*
* @since 9
*/
PropertyDescriptor(Entry<String,PropertyInfo> entry, boolean bound) {
String base = entry.getKey();
PropertyInfo info = entry.getValue();
setName(Introspector.decapitalize(base));
// 省略其他
}
java.beans.Introspector#decapitalize
/**
* Utility method to take a string and convert it to normal Java variable
* name capitalization. This normally means converting the first
* character from upper case to lower case, but in the (unusual) special
* case when there is more than one character and both the first and
* second characters are upper case, we leave it alone.
*
* Thus "FooBah" becomes "fooBah" and "X" becomes "x", but "URL" stays
* as "URL".
*
* @param name The string to be decapitalized.
* @return The decapitalized version of the string.
*/
public static String decapitalize(String name) {
if (name == null || name.length() == 0) {
return name;
}
if (name.length() > 1 && Character.isUpperCase(name.charAt(1)) &&
Character.isUpperCase(name.charAt(0))){
return name;
}
char[] chars = name.toCharArray();
chars[0] = Character.toLowerCase(chars[0]);
return new String(chars);
}
但是这里有个问题:根据注释和源码我们可以知道如果前两个字符都为大写时,直接返回 name,否则将首字母转小写后再作为 name 返回。
因此 getAString
解析后的属性名为 AString
。
我们将属性名改为 AString
,发现一切正常,可以正确输出 test
。
import org.apache.commons.beanutils.BeanUtils;
import java.lang.reflect.InvocationTargetException;
public class BeanUtilDemo {
public static void main(String[] args) throws InvocationTargetException, IllegalAccessException, NoSuchMethodException {
MyObject myObject = new MyObject();
myObject.setAString("test");
String aString = BeanUtils.getProperty(myObject, "AString");
System.out.println(aString);
}
}
2.2 如何解决?
2.2.1 使用工具类
很多工具类都支持获取私有属性,常见的如 commons-lang3 FieldUtils
类 或 pring 的ReflectionUtils
。
- 使用 Apache Commons Lang 库中的
FieldUtils
类,提供了一些静态方法来操作字段,包括私有字段。例如,可以使用FieldUtils.readDeclaredField(Object target, String fieldName, boolean forceAccess)
方法读取私有字段的值。 - 使用Spring Framework中的
ReflectionUtils
类,提供了一些实用方法来操作字段和方法,包括私有的。例如,可以使用ReflectionUtils.findField(Class clazz, String name)
方法查找私有字段对象,并使用ReflectionUtils.makeAccessible(Field field)和ReflectionUtils.getField(Field field, Object target)
方法获取值。
以 commons-lang3 的 FieldUtils
为例:
import org.apache.commons.lang3.reflect.FieldUtils;
import org.example.third.commons.beanutils.MyObject;
public class FieldUtilDemo {
public static void main(String[] args) throws Exception {
MyObject myObject = new MyObject();
myObject.setAString("test");
String value = (String) FieldUtils.readField(myObject, "aString", true);
System.out.println("value: " value);
}
}
该工具方法设计的不太完美,返回值是 Object 需要自己强制转换为目标类型,其实完全可以将强制转换的逻辑封装在工具方法内部。
2.2.2 自定义获取私有属性的工具方法
下面给出一个自定义工具方法来获取对象的私有属性的参考代码。
代码语言:javascript复制import java.lang.reflect.Field;
import java.util.Objects;
public class RefectionUtil {
/**
* 获取对象的私有属性
*
* @param instance 实例
* @param name 属性名
* @param 值类型
* @return 属性值
*/
@SuppressWarnings("unchecked")
public static <T> T getPrivateField(Object instance, String name) throws NoSuchFieldException, IllegalAccessException {
Objects.requireNonNull(instance, "instance 不能为空");
Objects.requireNonNull(name, "name 不能为空");
// 获取目标对象的字节码对象
Class<?> clazz = instance.getClass();
// 定义一个字段对象
Field field = null;
// 循环查找目标字段对象,直到找到或者到达Object类为止
while (clazz != Object.class) {
try {
field = clazz.getDeclaredField(name);
break;
} catch (NoSuchFieldException e) {
// 如果当前类中没有目标字段,继续查找其父类
clazz = clazz.getSuperclass();
}
}
// 如果最终没有找到目标字段,抛出异常
if (field == null) {
throw new NoSuchFieldException("Unknown field '" name "' on class '" clazz "'");
}
// 设置目标字段可访问
field.setAccessible(true);
// 返回目标字段的值,需要强制转换为泛型类型T
return (T) field.get(instance);
}
}
为了提高工具类的健壮性,我们还对该工具方法的入参进行了判空。
该工具方法通过泛型来封装类型转换的逻辑,方便使用者。
该工具方法还考虑到目标属性可能在父类中的情况,因此当前类中获取不到属性时,需要从父类中寻找。当找不到该属性时,我们抛出 NoSuchFieldException
异常并给出明确的提示。
如果代码再严谨一些,我们可以获取属性是否可访问,如果该属性不可访问(field.canAccess(instance)
)临时设置为可访问并获取对应的值以后最好可以恢复为不可访问状态。
使用示例:
代码语言:javascript复制public class ReflectionDemo {
public static void main(String[] args) throws Exception {
MyObject myObject = new MyObject();
myObject.setAString("test");
String aString = RefectionUtil.getPrivateField(myObject, "aString");
System.out.println("value: " aString);
}
}
三、总结
存在未必合理。通过对象的 get 、set 方法来反向推断属性的方案非常奇怪,但却如此“流行”。或许这是一种“规范”,但这很反直觉。属性名就应该是我们定义的属性的名称,而不应该使用属性间接推断。
正是因为很多框架采用类似的方法,导致出现很多不符合预期的行为:根据正确的属性名获取属性时报错、将对象转为 JSON 字符串时因自定义了某 get 方法而被识别出一些不存在的属性等。
我们封装工具方法时,应该讲常见的输入和输出放在注释中,方便用户更好地确认方法是否符合其预期,帮助用户更快上手。
我们封装工具方法时,应该以终为始,应该封装复杂度,降低样板代码,为使用者着想。
我们封装工具方法时,要注意代码的健壮性,充分考虑各种可能的情况,并为其编写完善的单测。
正如我之前文章中提到的:“细节之处见真章”,我们工作中遇到的一些小问题不仅要知道怎么解决,还应该认真分析底层原因,这样能够学到更多。
细节之处见真章,简单的代码也可以有很多讲究,也可以看出一个人的代码功底。