What?Json反序列化为啥报错了

1,619 阅读4分钟

背景描述

有个功能需要将 yml的内容转成一个 POJO。但是在项目启动时出现了报错。

问题现场

  • yml文件内容:
xField: "time"
yField: "total_pct"
  • pojo定义:
@Data
public class ChartBean {
    private String xField;
    private String yField;
}
  • 启动方法:
public class ChartYmlConfUtil {
    private final static ThreadLocal<Yaml> YAML = ThreadLocal.withInitial(Yaml::new);

    // yml转换为javabean的方法
    public static ChartBean load(String path) {
        final Yaml yaml = ChartYmlConfUtil.YAML.get();
        try (final InputStream inputStream = ChartYmlConfUtil.class.getResourceAsStream(path)) {
            final String conf = CharStreams.toString(new InputStreamReader(inputStream));
            return yaml.loadAs(conf, ChartBean.class);
        } catch (IOException e) {
            throw new RuntimeException(String.format("load yaml from [%s] error", path), e);
        }
    }

    // 启动
    public static void main(String[] args) {
        load("/chart.yml");
    }
}
  • 报错信息:
Exception in thread "main" Cannot create property=xField for JavaBean=ChartBean(xField=null, yField=null)
 in 'string', line 1, column 1:
    xField: "time"
    ^
Unable to find property 'xField' on class: com.xyq.test.yml.ChartBean
 in 'string', line 1, column 9:
    xField: "time"
            ^

	at org.yaml.snakeyaml.constructor.Constructor$ConstructMapping.constructJavaBean2ndStep(Constructor.java:313)
	at org.yaml.snakeyaml.constructor.Constructor$ConstructMapping.construct(Constructor.java:190)
	at org.yaml.snakeyaml.constructor.Constructor$ConstructYamlObject.construct(Constructor.java:346)
	at org.yaml.snakeyaml.constructor.BaseConstructor.constructObject(BaseConstructor.java:182)
	at org.yaml.snakeyaml.constructor.BaseConstructor.constructDocument(BaseConstructor.java:141)
	at org.yaml.snakeyaml.constructor.BaseConstructor.getSingleData(BaseConstructor.java:127)
	at org.yaml.snakeyaml.Yaml.loadFromReader(Yaml.java:450)
	at org.yaml.snakeyaml.Yaml.loadAs(Yaml.java:427)
	at com.xyq.test.yml.ChartYmlConfUtil.load(ChartYmlConfUtil.java:24)
	at com.xyq.test.yml.ChartYmlConfUtil.main(ChartYmlConfUtil.java:33)
Caused by: org.yaml.snakeyaml.error.YAMLException: Unable to find property 'xField' on class: com.xyq.test.yml.ChartBean
	at org.yaml.snakeyaml.introspector.PropertyUtils.getProperty(PropertyUtils.java:132)
	at org.yaml.snakeyaml.introspector.PropertyUtils.getProperty(PropertyUtils.java:121)
	at org.yaml.snakeyaml.constructor.Constructor$ConstructMapping.getProperty(Constructor.java:323)
	at org.yaml.snakeyaml.constructor.Constructor$ConstructMapping.constructJavaBean2ndStep(Constructor.java:241)
	... 9 more

排查过程

阅读错误信息

从错误日志的提示信息来看,yml文件中有一个字段xField,但是在ChartBean中找不到该字段,才导致的报错。

可是上面贴出来的代码中可以看到ChartBean里明确定义了该字段,不应该提示找不到。

从源码入手

我解析yml文件使用的是开源工具snakeyaml。那就去源码里看看它是怎么工作的。利用debug模式,可以很方便的观察到解析过程。

a. 解析的入口自然就是Yaml的 loadAs 方法。

public <T> T loadAs(String yaml, Class<T> type) {
    return (T) loadFromReader(new StreamReader(yaml), type);
}


private Object loadFromReader(StreamReader sreader, Class<?> type) {
	Composer composer = new Composer(new ParserImpl(sreader), resolver);
	constructor.setComposer(composer);
    // 从这里开始处理数据与类的映射
	return constructor.getSingleData(type);
}

b. 跳过不重要的部分,来到关键之处 Contructor 内部类 ConstructMappingconstructJavaBean2ndStep 方法。

protected Object constructJavaBean2ndStep(MappingNode node, Object object) {
	// ignore...
    // node 是存放了解析yml得到的数据和java对象信息的数据结构
    Class<? extends Object> beanType = node.getType();
	List<NodeTuple> nodeValue = node.getValue();
	for (NodeTuple tuple : nodeValue) {
        // 遍历nodes
		// ignore...
        // 这里获取到的就是yml中的xField字段(即key=xField)
		String key = (String) constructObject(keyNode);
		try {
            // 这里就是问题的关键:获取bean中与key对应的属性
			Property property = getProperty(beanType, key);
            
            // 后面的都先忽略...
        }
    }
}

c. 继续查看 getProperty 方法内部的逻辑

public Property getProperty(Class<? extends Object> type, String name, BeanAccess bAccess)
		throws IntrospectionException {
    // type = ChartBean.Class, name = xField
    // 这里就会对bean进行解析,找出其中的字段属性 放入map中
	Map<String, Property> properties = getPropertiesMap(type, bAccess);
	Property property = properties.get(name);
	if (property == null && skipMissingProperties) {
		property = new MissingProperty(name);
	}
    // 这个报错就是最开始看到的错误信息了
	if (property == null || !property.isWritable()) {
		throw new YAMLException("Unable to find property '" + name + "' on class: "
				+ type.getName());
	}
	return property;
}

getPropertiesMap 方法里面究竟是怎么从bean中获取字段信息的呢?

protected Map<String, Property> getPropertiesMap(Class<?> type, BeanAccess bAccess)
		throws IntrospectionException {

	Map<String, Property> properties = new LinkedHashMap<String, Property>();
	switch (bAccess) {
	case FIELD:
		// ignore
	default:
        // 从注释也可以看到,这里就是添加属性的地方了。
        // Introspector.getBeanInfo 则是利用java内省来获取bean信息
		// add JavaBean properties
		for (PropertyDescriptor property : Introspector.getBeanInf(type)
				.getPropertyDescriptors()) {
			Method readMethod = property.getReadMethod();
			if (readMethod == null || !readMethod.getName().equals("getClass")) {
				properties.put(property.getName(), new MethodProperty(property));
			}
		}
	// ignore...
	return properties;
}

Java内省

概念:

在计算机科学中,内省是指计算机程序在运行时(Run time)检查对象(Object)类型的一种能力,通常也可以称作运行时类型检查。

java内省又是如何获取属性信息的呢?

a. 内省不光光可以拿到属性,方法、类等信息也可以一并获取。

private BeanInfo getBeanInfo() throws IntrospectionException {
	// the evaluation order here is import, as we evaluate the
	// event sets and locate PropertyChangeListeners before we
	// look for properties.
	BeanDescriptor bd = getTargetBeanDescriptor();
	MethodDescriptor mds[] = getTargetMethodInfo();
	EventSetDescriptor esds[] = getTargetEventInfo();
	PropertyDescriptor pds[] = getTargetPropertyInfo();

	int defaultEvent = getTargetDefaultEventIndex();
	int defaultProperty = getTargetDefaultPropertyIndex();

	return new GenericBeanInfo(bd, esds, defaultEvent, pds,
					defaultProperty, mds, explicitBeanInfo);

}

b.属性则是通过调用 getTargetPropertyInfo 来获取。

private PropertyDescriptor[] getTargetPropertyInfo() {
    // 跳过一些不关键的逻辑
    
	// 1.获取所有的public方法
	Method methodList[] = getPublicDeclaredMethods(beanClass);

	// 2.遍历方法
	for (int i = 0; i < methodList.length; i++) {
		Method method = methodList[i];
		
		// 忽略静态方法
		int mods = method.getModifiers();
		if (Modifier.isStatic(mods)) {
			continue;
		}
		
		// 获取方法名.这里获取到方法名为 getXField
		String name = method.getName();
		Class<?>[] argTypes = method.getParameterTypes();
		// 获取方法参数个数
		int argCount = argTypes.length;

		try {
			if (argCount == 0) {
				// 如果方法名是get开头
				if (name.startsWith(GET_PREFIX)) {
					// 获取截取get后面的字符串作为参数一部分
                    // 由于get方法名一般都是getXField,所以截取的就是XField
                    // 接着往下看...
					pd = new PropertyDescriptor(this.beanClass, name.substring(3), method, null);
				}
                // 后面还有一些列不同的操作,针对不同的方法。
			}
		} catch (IntrospectionException ex) {
		}
	}
}

PropertyDescriptor 拿到XField之后又有什么操作呢?

PropertyDescriptor(Class<?> bean, String base, Method read, Method write) throws IntrospectionException {
    // 省略其他不相关的逻辑
    // Introspector.decapitalize的返回值就是最终得到的字段名称了!
	setName(Introspector.decapitalize(base));
}

离最终结果一步之遥!

public static String decapitalize(String name) {
	if (name == null || name.length() == 0) {
		return name;
	}
    
    // 罪魁祸首!!
    // 如果name的第一个字母和第二个字母都是大写的,那么属性的名称就是name本身
    // 也就是XField,返回的也是XField
	if (name.length() > 1 && Character.isUpperCase(name.charAt(1)) &&
					Character.isUpperCase(name.charAt(0))){
		return name;
	}
    
    // 否则 会将name的第一个字母小写(小驼峰)再返回
	char chars[] = name.toCharArray();
	chars[0] = Character.toLowerCase(chars[0]);
	return new String(chars);
}

结论

java内省获取参数名的方式,是对参数的get方法名称进行截取字符串得到的。

由于我在定义参数时,“不合规范”的使第2个字母为大写字母的命名方式,导致解析得到的参数名与预期的不符。才导致找不到对应的参数进行写入值(名字都不一样了,怎么可能找的到!)

Tip: java内省之所以会有这样神奇的判断方式,是为了解决参数名为URL这一类全是大写字母的参数。(不然都变成小驼峰,URL就变成uRL了。)