(1)背景
近期在研究Java工程模板的中间,发现了一个不可靠、又绕不开的问题:结果集(ResultSet)转换为PO/VO。经过一段时间的研究,颇有心得,特意记下来。
对于Java对象,定义实在太多,POJO/PO/EO/DO/VO,所以事先说明一下。
- PO指的是用于数据存储的类,与数据库对象的对应关系是:类对表,字段(或叫属性)对列;JPA规范使用注解@Entity和@Table来定义类,注解@Column来修饰字段。PO对应单个数据表。
- VO指的是用于数据展示的类,其与PO的关系,类似于数据库中视图(VIEW)与单表的关系。VO既可以是对PO字段的重组,也可以多个PO对象的组合(连表)。
另外对于字段、属性和列的定义说明如下:
- 字段(Field)是Java反射机制的定义,用于描述类的成员变量
- 属性(Property)是Java Bean的定义,用于描述bean的成员变量,大多数场合可以和字段通用
- 列(Column)是数据库对象中的定义,是数据表的基本组成项
(2)思路
对于工程模板,初步的思路是:
项目 | 内容 |
---|---|
环境 | JDK:1.8,MySQL |
应用框架 | Spring Boot (2.x) |
安全框架 | Apache Shiro |
数据库连接池 | Druid |
数据存取组件1 | 增删改查:Spring Data JPA + Hibernate |
数据存取组件2 | 复杂查询:Spring JDBC |
JAVA对象转JSON | fastjson |
其中,使用Spring Data JPA + Hibernate的组合的目的就是偷懒,对于普通的增删改查,这个组合非常的简便。其主要的缺点就是没法轻松自如地应对复合查询(虽然可以使用NativeQuery的方式)。
所以,补充Spring JDBC的目的就是为了应对复杂的查询并转换为自定义VO。如何获取查询结果并转换为Java对象就成了其中重要的一环。
实际上呢,在Spring JDBC框架中,提供了JdbcTemplate来实现sql的查询并转换成Java对象的方法。例如:
public abstract class VoBaseDAO extends NamedParameterJdbcDaoSupport {
protected <T> List<T> findAll(String sql, Class<T> cls, String callingTag) {
return getJdbcTemplate().query(sql, new BeanPropertyRowMapper<T>(cls));
}
}
其中BeanPropertyRowMapper就是将结果集中的一行转换为Bean的转换器。
其方法mapRow方法就是将结果集中的每行中的列转换成目标bean(VO)的属性。
public T mapRow(ResultSet rs, int rowNumber) throws SQLException
如此一来,转换思路也是比较清晰了:
(0)依据目标类(bean或vo)通过反射机制获取其字段(Field)列表信息,例如:字段名和类型。
(1)执行SQL,获取结果集(ResultSet)。
(2)依据结果集的元数据信息获取数据列的信息,例如:列名和类型。
(3)结果集逐行处理,每一行转换成一个bean或vo,从而实现将结果集转换成bean或vo列表。
(4)(3)中要做的就是(0)中的字段与(2)中的列进行对应,将列值转换成字段类型,
例如: varchar --> String, datetime --> Date,
进而调用字段的setter方法来设置bean/vo的属性/字段值。
(3)问题
然而在实践中,缺发现转换过程并不平坦,主要是(4)会出现一些状况:
通过列名对应字段是一个推测的过程,其匹配代码如下:
for (int index = 1; index <= columnCount; index++) {
String column = JdbcUtils.lookupColumnName(rsmd, index);
String field = lowerCaseName(StringUtils.delete(column, " "));
PropertyDescriptor pd = (this.mappedFields != null ? this.mappedFields.get(field) : null);
if (pd != null) {
try {
Object value = getColumnValue(rs, index, pd);
if (rowNumber == 0 && logger.isDebugEnabled()) {
……
}
try {
bw.setPropertyValue(pd.getName(), value);
}
其规则就是将列名小写(例如:biz_type),在一个已存在的Map进行匹配。
该mappedFields的初始化在转换器的初始化方法中:
protected void initialize(Class<T> mappedClass) {
this.mappedClass = mappedClass;
this.mappedFields = new HashMap<>();
this.mappedProperties = new HashSet<>();
PropertyDescriptor[] pds = BeanUtils.getPropertyDescriptors(mappedClass);
for (PropertyDescriptor pd : pds) {
if (pd.getWriteMethod() != null) {
this.mappedFields.put(lowerCaseName(pd.getName()), pd);
String underscoredName = underscoreName(pd.getName());
if (!lowerCaseName(pd.getName()).equals(underscoredName)) {
this.mappedFields.put(underscoredName, pd);
}
this.mappedProperties.add(pd.getName());
}
}
}
其会按照bean/VO的字段名来“预先”生成列名,例如:bizTyp会生成两条记录:biztype和biz_type。如此一来,列名就会和第2条记录匹配上。以下代码是underscoreName的方法:
protected String underscoreName(String name) {
if (!StringUtils.hasLength(name)) {
return "";
}
StringBuilder result = new StringBuilder();
result.append(lowerCaseName(name.substring(0, 1)));
for (int i = 1; i < name.length(); i++) {
String s = name.substring(i, i + 1);
String slc = lowerCaseName(s);
if (!s.equals(slc)) {
result.append("_").append(slc);
}
else {
result.append(s);
}
}
return result.toString();
}
但是,如果列名为:reverse_1,字段/属性名为 reverse1,这样就会出现列名与字段名匹配不上的问题。因为:reverse_1不在(reverse1)里头。
按照通常的约定,数据库列名采用小写、下划线分隔的命名规范;字段/属性名采用驼峰的规范。
从列名推测字段名/属性名,要比从字段名/属性名推测列名要靠谱一些。例如:
biz_type,推测字段名为bizType;reverse_1,推测字段名为reverse1。
此外,对于逻辑型字段,数据库中可能通过整型或字符来定义,例如:is_force(是否强制)
其在Java对象的定义中,要求定义为名称为force,而不能是isForce。如此一来,BeanPropertyRowMapper就更加难以匹配数据列与Java类的字段了。
此外对于BeanPropertyRowMapper的读取列值,笔者觉得也有问题:
@Nullable
protected Object getColumnValue(ResultSet rs, int index, PropertyDescriptor pd) throws SQLException {
return JdbcUtils.getResultSetValue(rs, index, pd.getPropertyType());
}
以下是被调用的、JdbcUtils类的getResultSetValue方法:
@Nullable
public static Object getResultSetValue(ResultSet rs, int index, @Nullable Class<?> requiredType) throws SQLException {
if (requiredType == null) {
return getResultSetValue(rs, index);
}
Object value;
// Explicitly extract typed value, as far as possible.
if (String.class == requiredType) {
return rs.getString(index);
}
else if (boolean.class == requiredType || Boolean.class == requiredType) {
value = rs.getBoolean(index);
}
else if (byte.class == requiredType || Byte.class == requiredType) {
value = rs.getByte(index);
}
else if (short.class == requiredType || Short.class == requiredType) {
value = rs.getShort(index);
}
else if (int.class == requiredType || Integer.class == requiredType) {
value = rs.getInt(index);
}
else if (long.class == requiredType || Long.class == requiredType) {
value = rs.getLong(index);
}
else if (float.class == requiredType || Float.class == requiredType) {
value = rs.getFloat(index);
}
else if (double.class == requiredType || Double.class == requiredType ||
Number.class == requiredType) {
value = rs.getDouble(index);
}
else if (BigDecimal.class == requiredType) {
return rs.getBigDecimal(index);
}
else if (java.sql.Date.class == requiredType) {
return rs.getDate(index);
}
else if (java.sql.Time.class == requiredType) {
return rs.getTime(index);
}
else if (java.sql.Timestamp.class == requiredType || java.util.Date.class == requiredType) {
return rs.getTimestamp(index);
}
else if (byte[].class == requiredType) {
return rs.getBytes(index);
}
else if (Blob.class == requiredType) {
return rs.getBlob(index);
}
else if (Clob.class == requiredType) {
return rs.getClob(index);
}
else if (requiredType.isEnum()) {
其思路是依据VO类的字段类型例如:String.class、boolean.class等来选择不同的读取结果集的方法。该方法也存在一个局限:需要对VO类的字段类型定义得比较“精确”,否则会导致取值的“精度”丢失。
对于VO类,日期类型既可以定义yyyy-mm-dd格式的日期,也可以定义hh:mm:ss格式的时间,以及yyyy-mm-dd hh:mm:ss格式时间戳。
所以笔者的建议是,在目标类字段类型为主的前提下,还需要考虑数据列的数据类型。例如:
字段类型 | 列类型(MySQL) | 结果集读取方式 |
---|---|---|
字符串(String) | 字符串(getString) | |
整型(int、Integer) | 整型(getInt) | |
日期(Date) | DATETIME | 时间戳(getTimestamp) |
DATE | 日期(getDate) | |
TIMESTAMP | 时间戳(getTimestamp) | |
TIME | 时间(getTime) | |
长整形(long、Long) | 长整形(getLong) | |
双精度浮点型(double、Double) | 双精度浮点型(getDouble) | |
逻辑(boolean、Boolean) | INT | 整型(getInt)是否=1 |
CHAR、VARCHAR | 字符串(getString)是否=“1” | |
浮点型(float、Float) | 浮点型(getFloat) | |
大数值(BigDecimal) | 大数值(getBigDecimal) | |
日期(Time) | 时间(getTime) |
(4)破局
通过之前的问题,我们可以得出以下结论:
- 无论是从列名推测字段名,还是从字段名推测列名,都只是推测而已,都无法保证精准匹配,除非明确指出。例如:PO对象的定义,就通过注解@Column明确指出了字段所对应的数据表列名。
- 对于复杂情形需要明确指出字段与列的对应关系外,需要考虑简化普通场景,例如:直接通过列名来匹配字段名,列名和字段名相同
- 对于结果集列值的读取,不仅需要参考目标类字段类型,还需要考虑数据列的数据类型