使用 BeanUtils.getProperty 获取属性时出现 NoSuchMethodException: Unknown property 问题分析

2023-03-10 10:19:46 浏览数 (1)

一、背景

日常开发中,经常需要根据对象和对应的属性名来获取属性的值的场景。 很自然地,使用了 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

代码语言:javascript复制
    /**
     * 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获取属性名和属性对象的对应关系。 代码如下:

代码语言:javascript复制
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 转换一次。

代码语言:javascript复制
 /**
     * 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

代码语言:javascript复制
    /**
     * 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

代码语言:javascript复制
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 为例:

代码语言:javascript复制
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 方法而被识别出一些不存在的属性等。

我们封装工具方法时,应该讲常见的输入和输出放在注释中,方便用户更好地确认方法是否符合其预期,帮助用户更快上手。

我们封装工具方法时,应该以终为始,应该封装复杂度,降低样板代码,为使用者着想。

我们封装工具方法时,要注意代码的健壮性,充分考虑各种可能的情况,并为其编写完善的单测。

正如我之前文章中提到的:“细节之处见真章”,我们工作中遇到的一些小问题不仅要知道怎么解决,还应该认真分析底层原因,这样能够学到更多。

细节之处见真章,简单的代码也可以有很多讲究,也可以看出一个人的代码功底。

0 人点赞