问题

Service层注入Dao时, Intellij 总会以红色波浪线提示我们

1
2
@Autowired
private UserDao userDao;

Could not autowire. No beans of ‘UserDao’ type found.
Checks autowiring problems in a bean class.

尽管我们都知道 Dao 层的 Bean 实际上都是有的,并且可以设置关闭这恼人的提示,但是我们有没有想过为什么 Intellij 就找不到这个 Bean 呢?甚至有人有这种做法

1
2
3
@Repository
public interface UserDao {
}

来避免提示,但是这种做法正确么?

所以今天我们的疑问就是

  1. 为什么 Dao 层不需要加 @Repository 注解,源码里到底做了什么?
  2. 加了 @Repository 注解有什么影响?

答案

  1. 关键在于 ClassPathMapperScanner 对指定包的扫描,并且扫描过程对 Spring 原本的扫描 Bean 的步骤 “加了料” ,Spring 本身只扫实现类,但 MyBatis 的扫描器扫了接口 。并且扫完接口之后,为接口配了个 BeanDefinition ,并且这个 bd 的 BeanClass 是 MapperFactoryBean

    对于 BeanDefinition 和 MapperFactoryBean 不了解的同学请查询相关资料和源码

  2. 仅仅只能解决 Intellij 静态查找 bean 的问题,没有实际作用。即使加了注解,比如@Controller,@Service 等等,也会被 Spring 的扫描器给忽略掉,因为扫描器会过滤掉接口

源码探索

下面的源码部分如果读者提前有 MyBatis 的 Bean 的执行流程,和 Spring 的 Bean加载的相关知识就更好理解。

1. 分析问题

关于为什么不需要注解就能获取到 Dao 层的 Bean,看似答案很简单,因为配置了扫描指定这个包里的 xxxDao.class 啊,比如使用注解 @MapperScan(“com.example.dao”)。

这个答案太过表面,觉得问题简单只是因为对 Spring 的 Bean 不熟悉。

我们何时见过 @Component 及其衍生的3个注解 @Controller、@Service、@Repository 加在接口上面的?

自己测试新建个接口,上面加注解,然后找个 Controller 里 @Autowired 注入一下,项目立马会报错 NoSuchBeanDefinitionException 。

2. 切入源码

切入点

既然使用注解 @MapperScan 就好使,那么我们就从这个点切入源码看一下,先找出源码中何处用了此注解,非常幸运的是,只有一处用到了此注解 :MapperScannerRegistrar.registerBeanDefinitions() 。

并且从类名和方法名就可以很清楚的看出这个类的功能是扫描 Mapper 并注册,方法的功能就是注册 BeanDefinitions 到 Spring 中。方法的源码我就不贴了,很容易看出来是创建一个扫描器 ClassPathMapperScanner ,设置好一系列属性比如 Spring 的注册表之后,执行 doScan() 方法去扫描 @MapperScan 提供的包。

doScan() 扫描资源,转换为 BeanDefinition

doScan() 方法也很简单,就是两步:

  1. 调用父类 ClassPathBeanDefinitionScanner 的doScan()方法,也就是 Spring 扫描BeanDefinition 的方法。过程不是很重要,我们需要知道这个扫描方法的一个关键就是
1
Set<BeanDefinition> candidates = findCandidateComponents(basePackage);

在其中对所有的候选者使用 isCandidateComponent() 方法判断是否为符合要求的 BeanDefinition。

1
2
3
4
5
6
7
8
9
10
for (TypeFilter tf : this.excludeFilters) {
if (tf.match(metadataReader, this.metadataReaderFactory)) {
return false;
}
}
for (TypeFilter tf : this.includeFilters) {
if (tf.match(metadataReader, this.metadataReaderFactory)) {
return isConditionMatch(metadataReader);
}
}

这有两组过滤器来过滤扫描到的资源。Spring 默认的过滤器是排除掉抽象类/接口的。而MyBatis 的扫描器重新注册了过滤器,默认对接口放行。

其实还有一些其它的过滤要求,但是不影响我们本问题的探究,所以不深入解读了。

源码读到这里,我们先找到了本文的第二个问题的答案。也就是 Spring 会忽略掉接口上面的注解,不会添加它进入 BeanDefiniiton ,也就难怪测试的时候会抛出 NoSuchBeanDefinitionException 的异常了。而 MyBatis 则会把这些接口拉过来注册BD 。

对 BeanDefinition 的加工

读到这里我们可能有了更大的疑问,拿接口注册 BeanDefinition ,那获取 Bean 的时候如何去实例化这个对象啊?接口可是不能实例化出对象的啊,而且我们也没有做实现。

原来是 MyBatis 的扫描器在调用完父类的扫描方法后,对 BeanDefinition 进行了加工 processBeanDefinitions() 。其中最关键的两行代码是

1
2
definition.getConstructorArgumentValues().addGenericArgumentValue(definition.getBeanClassName()); 
definition.setBeanClass(this.mapperFactoryBean.getClass());

第一行,我们发现把这个接口的类名塞到了构造器参数中

小彩蛋,这里塞的是 String ,而我们的构造器参数其实要的是 Class 。但是 Spring 的 ConstructorResolver.autowireConstructor 中用到了 Object[] argsToUse 去做了个转换 。

第二行,beanDefinition 的 BeanClass 被设置成了 MapperFactoryBean !

熟悉 Spring 和 MyBatis 的读者肯定一下就明白了,就是这个地方进行了”偷梁换柱”!

1
2
@Autowired
private UserDao userDao;

还是拿 UserDao 为例,我们向 Spring 容器说 “给我来个 UserDao 的实例”,而 Spring 根据注册时候的 BeanDefinition ,去工厂( MapperFactoryBean )里面扔了个 UserDao.class 的参数进去,工厂的 getObject() 方法给我们返回了它制造的 userDao 。

就这样,我们没有去写实现类,轻轻松松拿到了我们需要的 userDao 。

至于 MapperFactoryBean 里做了什么返回了 userDao 出来?其实就是它的 getObject 方法返回的是 DefaultSqlSession.getMapper(Class type)方法,返回的是 MapperProxy 代理的类,而这个代理类的 invoke 方法并不像我们平时见到的代理中的 invoke 方法一样调用原始目标的 method.invoke ,而是去找 MapperMethod 执行了。

收获

这次的源码探究下来,收获的不仅仅是了解了 Dao 层 Bean 的注入,更是串起了我们最常用的 Spring 和 MyBatis ,换句话说,我们打通了从 Service 层到 Dao 层。

在以往 Debug 代码时看到的 MapperProxy,MapperMethod,我们清楚了这是从何而来,也对 MyBatis 中代理的巧妙运用更加熟悉。

参考文献

https://blog.csdn.net/java280580332/article/details/72123890

https://blog.csdn.net/mingtian625/article/details/47684271

评论和共享

问题

上一篇我们讲到,对于未知数据类型的解析,UnknownTypeHandler 把部分任务交给了 TypeHandlerRegistry ,甚至可能仅仅只丢了一个 javaType 过来就要求返回一个合适的解析器回去 。TypeHandlerRegistry 到底里面做了啥可以找到合适的解析器呢?

功能

从类的名字我们就知道这个类的功能大概就是个注册表,而且很可能是全局共用的,记录各种 javaType,jdbcType,TypeHandler 的映射关系。实际这个类的核心也就是维护了几个 map 。

核心字段

JDBC_TYPE_HANDLER_MAP

1
private final Map<JdbcType, TypeHandler<?>> JDBC_TYPE_HANDLER_MAP = new EnumMap<JdbcType, TypeHandler<?>>(JdbcType.class);

这个很直观,就是注册 jdbcType 和解析器的对应关系。

TYPE_HANDLER_MAP

1
private final Map<Type, Map<JdbcType, TypeHandler<?>>> TYPE_HANDLER_MAP = new ConcurrentHashMap<Type, Map<JdbcType, TypeHandler<?>>>();

书上原文是:“记录了 java 类型向指定的 JdbcType 转换时,需要使用的 TypeHandler 对象。例如:Java 类型中的 String 转换成数据库的 char 、varchar 等多种类型,所以存在一对多关系”。

Type 是 java.lang.reflect 包下的接口,Class 类实现了此接口。所以此 map 的 key 值是 javaType ,比如 String.class 。

UNKNOWN_TYPE_HANDLER

就是上一篇文章学习过的 UnknownTypeHandler 的实例,主要用在 Object.class 和 JdbcType.OTHER 上。

ALL_TYPE_HANDLERS_MAP

1
private final Map<Class<?>, TypeHandler<?>> ALL_TYPE_HANDLERS_MAP = new HashMap<Class<?>, TypeHandler<?>>();

key 是解析器的 class ,value 是解析器自身。

记录了全部的解析器的类型及该类型相对应的 TypeHandler 对象。

NULL_TYPE_HANDLER_MAP

仅仅是一个空 TypeHandler 集合的标识。因为 TYPE_HANDLER_MAP 是ConcurrentHashMap, 不能塞 null 值,因此在需要的地方以此空标识作为 value 替代null塞入。

注册

在构造方法中,就调用了大量的 register(…) 的方法,注册了很多映射关系。

register 有很多重载方法,除了简单的向 JDBC_TYPE_HANDLER_MAP 注册之外,全都最终指向了下面的重载方法。

1
2
3
4
5
6
7
8
9
10
11
private void register(Type javaType, JdbcType jdbcType, TypeHandler<?> handler) {
if (javaType != null) {
Map<JdbcType, TypeHandler<?>> map = TYPE_HANDLER_MAP.get(javaType);
if (map == null) {
map = new HashMap<JdbcType, TypeHandler<?>>();
TYPE_HANDLER_MAP.put(javaType, map);
}
map.put(jdbcType, handler);
}
ALL_TYPE_HANDLERS_MAP.put(handler.getClass(), handler);
}

方法并不复杂,我们只需要理解的是这里为两个 map 中都塞入了值,一个是 key 为 javaType 的 map , 也就是 String 与 char/varchar 的一对多的关系。另一个则是记录全部 handler 的map。

这里有一点需要特别留意的就是,比如 String 类,除了映射 char/varchar 之外,还映射 null ,对应的解析器也是 StringTypeHandler 。

1
2
3
4
//这一行就是javaType为String.class jdbcType 为null 的解析器注册
register(String.class, new StringTypeHandler());
//这是普通的注册
register(String.class, JdbcType.CHAR, new StringTypeHandler());

查找 TypeHandler

终于到了查找 TypeHandler 的部分了,看了这么久,是不是差点晕得都忘了我们的这个注册表最核心的功能在这呢?

根据 jdbcType 和 typeHandler 的class 查找解析器的功能都很简单,就是上面的 JDBC_TYPE_HANDLER_MAP 和 ALL_TYPE_HANDLERS_MAP 中取值。

关键的在于根据 javaType 寻找解析器。而且我们的在上一篇也讲到了 PreparedStatement 在赋值的时候,我们没有提供 jdbcType ( null ), 仅仅只有 javaType 。

getTypeHandler 其实就是从 TYPE_HANDLER_MAP 取值.我们入参类型为 String 为例来看看其查找解析器的过程 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private <T> TypeHandler<T> getTypeHandler(Type type, JdbcType jdbcType) {
//首先根据 String.class 查找其一对多的解析器集合.
Map<JdbcType, TypeHandler<?>> jdbcHandlerMap = getJdbcHandlerMap(type);
TypeHandler<?> handler = null;
if (jdbcHandlerMap != null) {
//然后根据其指定的 jdbcType 来找对应的解析器。而此处我们的 jdbcType 是null
//但是在注册的时候我们留意过,对于 null ,同样也进行了注册
//就相当于是给了个默认的解析器
handler = jdbcHandlerMap.get(jdbcType);
if (handler == null) {
handler = jdbcHandlerMap.get(null);
}
if (handler == null) {
handler = pickSoleHandler(jdbcHandlerMap);
}
}
return (TypeHandler<T>) handler;
}

总结

目前我们已经接触过了两种方式对明确指明的 javaType 的转换。主要可以分为两大类:

ObjectTypeHandler

这个主要依靠 JDBC 底层的方法来查找合适的 javaType ,大量的 switch-case 语句。主要用在将返回值的封装到 JSONObject 中。

1
2
3
4
5
6
7
8
9
10
switch (field.getSQLType()) {
case Types.TINYINT:
if (!field.isUnsigned()) {
return Integer.valueOf(getByte(columnIndex));
}
return Integer.valueOf(getInt(columnIndex));
case Types.SMALLINT:
return Integer.valueOf(getInt(columnIndex));
//...
}

TypeHandlerRegistry

注册表功能,将常规的数十种关系映射在初始化时就都注册好,也就是提前存入 map 中,需要的时候去 map 中取。

尤其是将 javaType 和 TypeHandler 关联起来,并对一些类型注册了默认的解析器,即 jdbcType 未指明时所要采用的解析器。

这个更多是用在 PreparedStatement 入参的赋值时。

评论和共享

问题

前面说到,我们可以用 JSONObject 替代习惯使用的 JavaBean ,而之所以能用 JSONObject 主要就是因为它实现了 Map<String,Object>

实际使用我们就会发现,MyBatis 使用JSONObject 封装返回结果的时候很“智能”,数据库里字段是 varchar 类型,JSONObject 中返回值就是 String 类型,数据库字段是 int/float 类型,JSONObject 中返回值就是对应的数值类型。甚至通过 debug 发现数据库中保存了datetime 类型的数据,JSONObject 中保存的是 java.sql.timestamp 类型,而timestamp 类型继承了常见的 java.util.Date

为什么 MyBatis 可以用得这么爽呢?我们实现可完全没声明需要此字段的 javaType 呢。而且用得爽了,类型转换会不会导致程序性能大打折扣呢?

今天我们就从深入源码,探究一番 MyBatis 到底是怎样做到对未明确声明的字段处理返回类型的。

TypeHandler

MyBatis 类型转换的核心就是这个接口,定义的方法可以看做就两种 setParameter 和 getResult ,很好理解,我们传参和接收 sql 结果时就调用这个。

1
2
3
4
5
6
7
8
9
10
11
public interface TypeHandler<T> {

void setParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException;

T getResult(ResultSet rs, String columnName) throws SQLException;

T getResult(ResultSet rs, int columnIndex) throws SQLException;

T getResult(CallableStatement cs, int columnIndex) throws SQLException;

}

抽象类 BaseTypeHandler 部分实现了 TypeHandler ,主要完成了对空值的处理。 非空值的处理全部交给了子类完成。

BaseTypeHandler 子类非常多,对应了数据库的各种数据类型,实现都很简单,比如SqlTimestampTypeHandler 处理 Timestamp 类型。

1
2
3
4
5
6
7
8
9
10
11
@Override
public void setNonNullParameter(PreparedStatement ps, int i, Timestamp parameter, JdbcType jdbcType)
throws SQLException {
ps.setTimestamp(i, parameter);
}

@Override
public Timestamp getNullableResult(ResultSet rs, String columnName)
throws SQLException {
return rs.getTimestamp(columnName);
}

都是直接调用了 PreparedStatement 和 ResultSet 处理相应类型字段的方法。

很明显,一旦指明了我们需要 MyBatis 给我们返回的此字段类型,MyBatis 肯定就去找到对应的 TypeHandler 实现类去处理。而我们没有指定返回类型的是怎么处理的呢?或者说,对于 Object 类型是怎么处理的呢?

ObjectTypeHandler

我们先来看看这个类,看名字就会猜可能估计未知类型全靠它了吧。提前预告下,并不是哦,getNullableResult 还算经常使用,入参赋值就没见用了,毕竟入参的 JavaType 我们通过反射还是可以找到的。

1
2
3
4
5
6
7
8
9
10
11
@Override
public void setNonNullParameter(PreparedStatement ps, int i, Object parameter, JdbcType jdbcType)
throws SQLException {
ps.setObject(i, parameter);
}

@Override
public Object getNullableResult(ResultSet rs, String columnName)
throws SQLException {
return rs.getObject(columnName);
}

一看这实现,居然还是调用的 JDBC 底层的对应方法。事实上 com.mysql.jdbc 在处理Object 类型是也是通过大量的 if-else 或 switch-case 来找到本数据真正的类型的。入参绑定依据 parameterObj instanceof 各种类型,返回结果类型依据 field.getSQLType 的各种类型。

UnknownTypeHandler

事实上,MyBatis 在很多我们没有指明参数类型的情况下,都是使用 UnknownTypeHandler 来解决类型转换的。UnknownTypeHandler 中的核心resolveTypeHandler 方法,就是查找对应数据的类型解析器(TypeHandler) , 再用这个合适的 typeHandler 进行解析。

resolveTypeHandler 方法的重载有3种,主要的两种就是一种处理入参的,一种处理返回结果的。

入参类型解析

1
2
3
4
5
6
7
8
9
10
11
12
13
private TypeHandler<? extends Object> resolveTypeHandler(Object parameter, JdbcType jdbcType) {
TypeHandler<? extends Object> handler;
if (parameter == null) {
handler = OBJECT_TYPE_HANDLER;
} else {
handler = typeHandlerRegistry.getTypeHandler(parameter.getClass(), jdbcType);
// check if handler is null (issue #270)
if (handler == null || handler instanceof UnknownTypeHandler) {
handler = OBJECT_TYPE_HANDLER;
}
}
return handler;
}

在入参为 null 或者实在找不到解析器的情况下,就会返回我们上面讲的 ObjectTypeHandler 。

而这里面关键的方法就是

1
typeHandlerRegistry.getTypeHandler(parameter.getClass(), jdbcType);

typeHandlerRegistry 可以理解为全局共用的各种类型与解析器关系的注册表,后面的文章还会继续深入讲解,我们首先注意这个方法的入参,第一个参数拿到了参数的 class , 第二个参数拿到了 jdbcType 。这不就相当于javaType 和 jdbcType 都有了吗?那即使还没研究 typeHandlerRegistry 到底干了啥,但是条件给的这么充分了,注册表的任务也太轻松了吧!

等等,我们的 DAO 层给入参是 JSONObject 类型,里面 username 字段是 String 类型,money 字段是 float 类型,这些都能通过 getClass() 获取到确实没毛病。但是 jdbcType 是哪来的呢?我们现在可是在处理 PreparedStatement 呢!

通过 debug 我们发现,这里的 jdbcType 我们没有指明的情况下,确实都是 null 。说明 typeHandlerRegistry 里仅仅是通过 javaType 来寻找解析器的。所以 typeHandlerRegistry 还是有很多门道等着我们去探索哦。

返回结果类型解析

对 ResultSet 的解析有两种方式,首先查看此结果字段–比如nickname–在 field 中的序号,如果没序号,则直接返回 ObjectTypeHandler 。如果有序号,则进入下面的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private TypeHandler<?> resolveTypeHandler(ResultSetMetaData rsmd, Integer columnIndex) throws SQLException {
TypeHandler<?> handler = null;
//通过下面两个方法获取到jdbcType和javaType
JdbcType jdbcType = safeGetJdbcTypeForColumn(rsmd, columnIndex);
Class<?> javaType = safeGetClassForColumn(rsmd, columnIndex);
//后面的任务就还是交给了typeHandlerRegistry
if (javaType != null && jdbcType != null) {
handler = typeHandlerRegistry.getTypeHandler(javaType, jdbcType);
} else if (javaType != null) {
handler = typeHandlerRegistry.getTypeHandler(javaType);
} else if (jdbcType != null) {
handler = typeHandlerRegistry.getTypeHandler(jdbcType);
}
return handler;
}

其中的关键方法还是从 rsmd 中获取 jdbcType 和 javaType ,然后再通过 typeHandlerRegistry 去查找对应的 handler 。

debug 发现几乎每次从 rsmd 中获取 jdbcType 和 javaType 都获取到了,看来玄机都在safeGetJdbcTypeForColumnsafeGetClassForColumn 中了。

两个方法的关键代码分别如下

1
2
3
return JdbcType.forCode(rsmd.getColumnType(columnIndex));

return Resources.classForName(rsmd.getColumnClassName(columnIndex));

JdbcType

com.mysql.jdbc.ResultSetMetaData 实现了 java.sql.ResultSetMetaData 接口,此处我们调用了其中的

1
2
3
public int getColumnType(int column) throws SQLException {
return getField(column).getSQLType();
}

序号的作用就体现出来了,根据序号找到此 field ,再找其 SQLType ,根据 SQLType 去JdbcType 类 (enum类型) 内查找对应的 jdbcType。

JdbcType 类内维护了一个 map 类型静态变量 codeLookup ,类加载时为 codeLookup 添加了39个元素,key 值其实就是 SQLType , int 类型,value 就是本 jdbcType 。

因此根据 SQLType 在此处就直接能毫不费力地找出对应的 jdbcType。

1
2
3
4
5
static {
for (JdbcType type : JdbcType.values()) {
codeLookup.put(type.TYPE_CODE, type);
}
}

javaType

同样需要先找到 SQLType ,以及field 内的另外几个属性值,例如 isUnsigned 等一起进入 getClassNameForJavaType 方法找到对应类名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static String getClassNameForJavaType(int javaType, boolean isUnsigned, int mysqlTypeIfKnown, boolean isBinaryOrBlob, boolean isOpaqueBinary,
boolean treatYearAsDate) {
switch (javaType) {
case Types.BIT:
case Types.BOOLEAN:
return "java.lang.Boolean";
case Types.TINYINT:
if (isUnsigned) {
return "java.lang.Integer";
}
return "java.lang.Integer";
//......大量 case
default:
return "java.lang.Object";

而这里的大量的 swtich-case 终于算是解除了我们一部分的疑惑了!底层终归还是通过 swtich-case 这种最原始的操作来把 jdbcType 映射到 java 类里去的!

奇怪的现象

上面说到,如果没序号,则直接返回 ObjectTypeHandler。

序号是getColumnType(int column) 用于找到到对应的 field 的关键属性。

那么为什么有可能会没有序号呢?

通过 debug 我们发现,明明 fields 内有 8 个元素,每个字段的原始名和别名都清清楚楚,到 columnIndexLookup 里居然只剩下 6 个? 很显然,问题出在了

1
String name = rsmd.getColumnName(i);

中间有几次取出了重复的 name 。为什么会有重复的 name 呢,我们进入getColumnName 一探究竟。

1
2
3
4
5
6
7
8
9
10
11
12
13
public String getColumnName(int column) throws SQLException {
if (this.useOldAliasBehavior) {
//如果设置了使用别名的属性,就获取此field的别名。
return getField(column).getName();
}
//获取此field的name属性,而不是别名。
String name = getField(column).getNameNoAliases();
if (name != null && name.length() == 0) {
//如果连原始字段名都找不到,就还是获取别名
return getField(column).getName();
}
return name;
}

实际debug发现,我们每次都是通过 getNameNoAliases 找到 name 的。再底层的代码就不需要贴了,看到这里我们就明白了。这里的 name 其实是每个字段的数据库内的字段名,而不是我们定义的别名,所以才会出现重复的情况,比如 user 表有 id 字段,address 表同样会有 id 字段。

总结

今天我们分析完了类型转换器,发现对于未知的数据类型,有一部分是通过ObjectTypeHandler 解析,其底层的用了com.mysql.jdbc.ResultSetImpl.getObject 内的依据 Field.SQLType 的 swtich-case 。

另一部分则是通过UnknownTypeHandler 去查找合适的解析器来解析。

关于查找解析器的步骤,我们将进入下一层级TypeHandlerRegistry 来继续学习。

评论和共享

问题

上一文介绍了 JSONObject 接受 MyBatis 的结果集的简单用法,但是在处理一对多的情况时,单纯的JSONObject就不好使了。

比如要查询一个角色下的多个用户,resultMap如下定义

1
2
3
4
5
6
7
8
<resultMap id="roleMap" type="com.alibaba.fastjson.JSONObject">
<id column="roleId" property="roleId"/>
<result column="roleName" property="roleName"/>
<collection property="users" ofType="com.alibaba.fastjson.JSONObject">
<id column="userId" property="userId"/>
<result column="nickname" property="nickname"/>
</collection>
</resultMap>

期望查出来的users 属性对应着一个数组,

然而实际查出来只是一个对象,只有一条数据。

解决方案

只需要建一个实体类继承 JSONObject ,里面有你要的集合类型的成员变量,就足够了。

比如我建的 One2Many 类:

1
2
3
public class One2Many extends JSONObject {
private List<JSONObject> users;
}

然后xml改为

1
2
3
4
5
6
7
8
<resultMap id="roleMap" type="com.heeexy.example.util.model.One2Many">
<id column="roleId" property="roleId"/>
<result column="roleName" property="roleName"/>
<collection property="users" ofType="com.alibaba.fastjson.JSONObject">
<id column="userId" property="userId"/>
<result column="nickname" property="nickname"/>
</collection>
</resultMap>

是不是非常简单?

更棒的是,这个 One2Many 类是可以复用的,里面再添加其它的成员变量就 OK 了。而且 Dao 层不需要改动,外面正常的还是用 JSONObject 就可以了。

原理

MyBatis 在处理嵌套结果的时候,会判断这个属性的类型,如果是集合,就会初始化一个集合来接收这个属性,否则就只是一个普通的 Object 了。

什么,不满意这个答案?那就拿出源码来吧!

首先我们直接看到最底层判断这个属性是不是集合的这段源码:

DefaultResultSetHandler.instantiateCollectionPropertyIfAppropriate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private Object instantiateCollectionPropertyIfAppropriate(ResultMapping resultMapping, MetaObject metaObject) {
final String propertyName = resultMapping.getProperty();
//先去拿在metaObject已经存好的这个属性值,我们这里以users属性为例
Object propertyValue = metaObject.getValue(propertyName);
if (propertyValue == null) {
//在这里拿你的users的java类型,由于我们使用的是JSONObject,这里的类型都会返回Object,如果是One2Many,这里就会拿到List类型
Class<?> type = resultMapping.getJavaType();
if (type == null) {
type = metaObject.getSetterType(propertyName);
}
try {
//判断属性类型是不是集合,如果是结合才会初始化一个集合的值返回到下一步,否则都会返回null
if (objectFactory.isCollection(type)) {
propertyValue = objectFactory.create(type);
metaObject.setValue(propertyName, propertyValue);
return propertyValue;
}
} catch (Exception e) {
throw new ExecutorException("Error instantiating collection property for result '" + resultMapping.getProperty() + "'. Cause: " + e, e);
}
} else if (objectFactory.isCollection(propertyValue.getClass())) {
//propertyValue不为空的情况,即我们的JSONObject里已经塞入了users属性,即使是这样,MyBatis还是要求你塞入的users属性必须是集合,才返回到下一步,否则还是会返回null到下一步。
return propertyValue;
}
return null;
}

从上面这段代码我们就知道 MyBatis 确实有做这个判断,你定义的 users 属性到底是不是集合类型,

  1. 如果是并且没有初始化好的话,就帮你初始化一个集合到下一步,
  2. 如果已经初始化好了(通常这时候就是已经塞入了几个 user 对象了),就直接返回这个 users 的值到下一步
  3. 如果不是集合类型,就返回 null 到下一步。

那么下一步到底是干啥呢?正常情况下应该就是继续往 users 集合里添加元素吧。

DefaultResultSetHandler.linkObjects

1
2
3
4
5
6
7
8
9
10
11
private void linkObjects(MetaObject metaObject, ResultMapping resultMapping, Object rowValue) {
final Object collectionProperty = instantiateCollectionPropertyIfAppropriate(resultMapping, metaObject);
if (collectionProperty != null) {
//如果上一步返回来的不是null,那就向这个集合里添加元素
final MetaObject targetMetaObject = configuration.newMetaObject(collectionProperty);
targetMetaObject.add(rowValue);
} else {
//上一步返回了null,那就直接把这个属性赋上本值,就是这种情况导致了上面的第二张图片的情况,我们的users变成了一个对象,而不是想要的数组。
metaObject.setValue(resultMapping.getProperty(), rowValue);
}
}

评论和共享

问题背景

项目后端与前端全部使用 JSON 进行数据交互,比如查询用户列表,通常后端从数据库查出的数据放入实体类再转为 JSON 返回给前端,但是前端的小伙伴表示明明只需要 nickname,avatar,userId 三个字段,为什么传过来的数据多出 phone,address ,profile 之类十几个字段,里面都 null,0 等值?

其实是因为 User 实体类定义了太多字段,转为 JSON 的时候都被包括进去了。稍微多几个还能忍,但通常 User/Order 这些实体类字段特别多,对调试开发实在非常不友好。

因此本次任务就是:确保每个接口返回字段都与接口文档上一模一样,去除冗余字段。

解决过程就省略了,直接抛出我最后的解决方案吧:就是 MyBatis 的返回值几乎完全抛弃实体类,全部使用com.alibaba.fastjson.JSONObject

使用 JSONObject

先最简化直观的看一下 JSONObject 的使用吧。

Controller:

1
2
3
4
@GetMapping("/list")
public JSONObject listUser(@RequestBody JSONObject requestJson){
return userService.listUser(requestJson);
}

Service:

1
JSONObject listUser(JSONObject jsonObject);

ServiceImpl:

1
2
3
4
5
6
7
8
9
@Override
public JSONObject listUser(JSONObject jsonObject) {
//fillPageParam是自定义的封装分页参数
CommonUtil.fillPageParam(jsonObject);
int count = userDao.countUser(jsonObject);
List<JSONObject> list = userDao.listUser(jsonObject);
//自定义successPage封装分页结果
return CommonUtil.successPage(jsonObject, list, count);
}

Dao:

1
2
3
4
public interface UserDao {
int countUser(JSONObject jsonObject);
List<JSONObject> listUser(JSONObject jsonObject);
}

UserMapper.xml , 这里的 resultType 直接就是 JSONObject ,如果是一对多的情况,就要多加一步,请移步看这篇文章

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<select id="countUser" resultType="Integer">
SELECT count(0)
FROM sys_user u
WHERE age=#{age}
</select>
<select id="users" resultType="com.alibaba.fastjson.JSONObject">
SELECT
id userId,
nickname nickname,
avatar avatar
FROM sys_user
WHERE age=#{age}
LIMIT #{offSet}, #{pageRow}
</select>

为什么可以使用JSONObject

因为 JSONObject 实现了 Map<String,Object> , MyBatis 那边完全是把它当成 Map<String,Object> 处理的,相信不少人都直接用过 map 来接收MyBatis 返回结果。

fastjson 是这样的,Gson 并不是,所以 Gson 的 JsonObject 是不可以的。

有兴趣了解更深入的原理的话,请参考我此系列的其它文章。

优劣对比

便捷性

JSONObject 便捷到可以说是无脑,接收参数、 sql 传参、封装 sql 结果、返回到前端,全程使用。

JavaBean 则接口层接收参数转为实体类,返回给前端再转为 json,新增一个业务通常还需要多建一个实体类。

工具方法拓展

JSONObject 可以方便地封装出通用的工具方法,比如封装分页信息,比如封装处理结果的成功和失败信息,特别是校验参数字段非空,不同的接口通常需要校验的字段不同,如下面这个简单的方法就可以校验各字段是否都非空。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void hasAllRequired(final JSONObject jsonObject, String requiredColumns) {
if (!StringTools.isNullOrEmpty(requiredColumns)) {
String[] columns = requiredColumns.split(",");
String missCol = "";
for (String column : columns) {
Object val = jsonObject.get(column.trim());
if (StringTools.isNullOrEmpty(val)) {
missCol += column + " ";
}
}
if (!StringTools.isNullOrEmpty(missCol)) {
jsonObject.clear();
jsonObject.put("returnCode", ErrorEnum.E_90003.getErrorCode());
jsonObject.put("returnMsg", "缺少必填参数:" + missCol.trim());
throw new CommonJsonException(jsonObject);
}
}
}

JavaBean 在处理分页上通常要引各自写的 PageBean 工具类 , 使用过程通常不会比上面ServiceImpl里的分页简单。

JavaBean 的参数校验都不用说,更头痛更复杂了,不知道有没有人有用起来很爽的方法。

返回字段

JSONObject 想返回几个字段就几个字段,而 JavaBean 如果只想返回某几个字段的话,就需要单独建个实体类。

字段有改动的话,JSONObject 灵活性就更强了,直接在 mapper.xml 里改下就完事。

业务层

不可否认的是,JSONObject 在业务层进行处理时,失去了 JavaBean 的编译提示,也失去了 IDE 的快捷补全,更有可能出现取错值的情况。

比如 jsonObject.getString(“pasword”) ,输入错了单词都可能没注意,最终取出来 null 。

可读性

通常大家可能认为 JavaBean 有更好的可读性,我觉得只要变量名取得好,JSONObject 也是没有什么问题的。毕竟类名是固定死的,变量名和参数名才能更好地反映此处业务的处理。

尤其是如今前后端分离的项目,前后端开发人员一般对照着文档确认字段含义。

总结

JSONObject 开发非常方便,具有更强的灵活性,适用于中小型项目,简单业务的开发,也适用于文档至上的前后端分离开发与微服务项目。

更具体的用法,可以参考 Github 项目

评论和共享

​ MyBatis 想要打印日志,时不时想要来句 log.debug() 、log.error() ,需要个打印机 ,可是自己又不想去实现(而且跟着整个项目用同样的打印系统才是王道啊),需要去用别人家的打印机,要用别人家的产品啊,那问题可就来了。

问题 1

​ 市面上各家的打印机 slf4j、java.util.logging、log4j 甚至 System.out 都是各种不同的用法,这使用起来就太麻烦了。

思路

​ 不管市面上打印机有多少型号,我家 MyBatis 包里的类只用自家的统一接口,我家的类只管 log.debug()、log.error()…

​ 定义好了接口,就需要实现类 impl 来实现这些 debug()、error() 方法了 , 咱假装是自己来实现,其实去调用真正打印机 slf4、log4j 的方法,这样就把别人家的打印机和咱自家的接口关联起来啦。

​ 这就是适配器模式

​ 我家的每个实现类其实就是一个适配器,每个适配器去适配一种打印机。比如 slf4jLoggerImpl 就完成了对 slf4j 打印机的适配,slf4jLoggerImpl .debug() 调用了slf4j.Logger.debug()。

​ 这样市面上每多一种打印机,比如想用 log4j2 了,我就只需要加一种适配器 log4j2LoggerImpl 去适配它就可以了。

代码

​ 定义接口

1
2
3
4
5
public interface Log {
boolean isDebugEnabled();
void debug(String s);
...
}

​ 在实现类里完成适配,比如适配 slf4j 的 Slf4jLoggerImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import org.apache.ibatis.logging.Log;
import org.slf4j.Logger;

class Slf4jLoggerImpl implements Log {
private final Logger log;

//注意这个地方,入参为 org.slf4j.Logger
//说明咱这个适配器要用起来,是需要传入一个真正的 slf4j 家的打印机进来的
public Slf4jLoggerImpl(Logger logger) {
log = logger;
}

//看似外面调用咱 MyBatis 的 Log 的 debug()
//其实是调用 org.slf4j.Logger 的 debug()
@Override
public void debug(String s) {
log.debug(s);
}
}

问题 2

​ 适配器咱是做好了,slf4j、log4j、stdout…做了那么多适配器,可是怎么用啊?我真正要打印机的时候,怎么才能知道我该调用哪款适配器啊?难道每次取打印机的时候,都去查一遍咱的系统配了那款打印机(jar) 包么?而且以后多加了1种打印机,我还去每个地方都改一遍,都多加一道判断么?

思路

​ 这其实就是获取实例的时候的问题,咱获取实例太累了,不如来个统一的工厂吧,我每次想打印的时候,都去找工厂要一台打印机,你工厂按照我 Log 的接口给我一个实例就是了。我也不管你给的具体实现到底是 slf4j 家的,还是 log4j 的,甚至可能是 NoLoggingImpl 每次调接口都不处理的这种空壳打印机。

​ 这样一来,有了统一的工厂,判断系统用哪种适配器的任务就可以在工厂完成了。

​ 而且以后就算新加了打印机,也只要改改工厂的代码,在工厂里多加一重判断就可以了。

​ 这就是工厂模式

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public final class LogFactory {
private static Constructor<? extends Log> logConstructor;
static {
tryImplementation(new Runnable() {
@Override
public void run() {
useSlf4jLogging();
}
});
tryImplementation(new Runnable() {
@Override
public void run() {
useCommonsLogging();
}
});
}

private LogFactory() {
// disable construction
}
public static Log getLog(String logger) {
try {
return logConstructor.newInstance(logger);
} catch (Throwable t) {
}
}
}

​ LogFactory 提供了对外的 getLog(String logger) 方法,给需要 logger 的地方提供一个 Log 的实例。

​ 内部的实现依靠 logConstructor 这个构造器通过反射来实例化一个 Log 的 Impl ,也就是之前的适配器,比如 Slf4jLoggerImpl 。

​ 判断采用哪种logConstructor 的任务则在类初始化的时候就执行了,依次尝试了我们的每一款适配器,碰上报错就说明没 jar 包,下一款,全都没有的话,就用 NoLoggingImpl 了。

评论和共享

  • 第 1 页 共 1 页
作者的图片

heeexy

世上是不是就没有你不认识的字了?


JAVA


南京