背景描述
有个功能需要将
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 内部类 ConstructMapping 的 constructJavaBean2ndStep 方法。
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了。)