ParamNameResolver源码分析

827 阅读6分钟

看名字就知道,ParamNameResolver是MyBatis用来解析方法参数的。我们调用Mapper接口的方法进行传参,经过ParamNameResolver解析后,xml文件里的SQL语句就可以使用这些参数了,就可以完成动态SQL和占位符的参数填充。

MyBatis是如何解析参数的?如何在xml里更好的使用这些参数呢?今天我们从源码的角度来分析下。 ​

1. 源码分析

ParamNameResolver类结构很简单,没有继承和实现,源码很好看懂。它的职责也非常单一:解析方法形参存放到SortedMap、根据实参完成参数名和参数值的映射得到ParamMap。 ​

1.1 属性

public class ParamNameResolver {

  // 自动生成的参数名前缀
  public static final String GENERIC_NAME_PREFIX = "param";

  // 是否使用实际的参数名(反射获取)
  private final boolean useActualParamName;

  // 参数下标-对应的参数名 映射关系
  private final SortedMap<Integer, String> names;

  // 是否存在@Param注解
  private boolean hasParamAnnotation;
}

如果参数没有加@Param注解,MyBatis会使用反射去获取参数名。但是,当源码被编译成字节码文件后,参数名称对于程序的执行实际上是没有任何意义的,因此编译时默认会去掉参数名称,反射获取到的参数名是arg0,arg1这种无意义的名称。那如何才能通过反射获取到参数名呢? ​

只有当JDK版本为8及以上,且编译时加上了**-parameters**参数,才能通过反射获取到真实的参数名。

正是因为开发者可能漏加了注解,或者反射获取不到参数名称,这两者充满了不确定性,因此MyBatis提供了一种确定性的解决方案,那就是为参数自动生成名称,常量GENERIC_NAME_PREFIX就是自动生成的名称前缀,生成规则为:【param+序号】,因此你也完全可以在xml中通过【param+序号】来使用参数。 ​

useActualParamName代表是否使用实际的参数名称,也就是反射获取的参数名称。上面已经说过了,要想反射获取到真实的参数名门槛是比较高的,得到的很可能是无意义的参数名称。该值默认为true,当配置为false时,MyBatis则自动使用参数所在下标作为参数名,此时你可以在xml中通过#{0}来使用第一个参数。 ​

names是一个有序Map容器,它存储的是方法形参中,参数下标对应的参数名称。 ​

hasParamAnnotation记录的是方法形参中,是否存在@Param注解。 ​

1.2 构造函数

属性看完,我们接下来看看构造函数。

/**
 * 1.优先获取@Param注解的值
 * 2.通过反射获取参数名
 * 3.使用参数下标
 */
public ParamNameResolver(Configuration config, Method method) {
  // 从配置对象中获取:是否使用实际参数名称 <setting name="useActualParamName" value="true" />
  this.useActualParamName = config.isUseActualParamName();
  // 反射获取方法参数类型
  final Class<?>[] paramTypes = method.getParameterTypes();
  // 反射获取参数注解
  final Annotation[][] paramAnnotations = method.getParameterAnnotations();
  // 存放 参数下标对应参数名称的Map容器
  final SortedMap<Integer, String> map = new TreeMap<>();
  int paramCount = paramAnnotations.length;
  // get names from @Param annotations
  for (int paramIndex = 0; paramIndex < paramCount; paramIndex++) {
    if (isSpecialParameter(paramTypes[paramIndex])) {
      /**
       * 跳过RowBounds和ResultHandler参数,这两个参数不做解析。
       * RowBounds:处理分页
       * ResultHandler:处理结果
       */
      continue;
    }
    String name = null;
    for (Annotation annotation : paramAnnotations[paramIndex]) {
      if (annotation instanceof Param) {
        // 如果加了@Param注解,则取注解的值
        hasParamAnnotation = true;
        name = ((Param) annotation).value();
        break;
      }
    }
    // 没有加@Param注解
    if (name == null) {
      // @Param was not specified
      if (useActualParamName) {
        // 如果使用实际的参数名,则通过反射获取参数名。
        // JDK8编译类加–parameters参数可以保留参数名称,否则得到的是arg0,arg1这种无意义的参数名
        name = getActualParamName(method, paramIndex);
      }
      if (name == null) {
        // 如果名称还是为空,则可以使用下标来获取参数:#{param1},#{param2}...
        name = String.valueOf(map.size());
      }
    }
    map.put(paramIndex, name);
  }
  // 使其不可变
  names = Collections.unmodifiableSortedMap(map);
}

构造函数主要做了这么几件事:

  1. 反射获取参数类型。
  2. 如果参数类型为RowBounds或ResultHandler则跳过解析。
  3. 反射获取方法形参注解。
  4. 如果加了@Param注解,则参数名直接使用注解值。
  5. 如果没有注解,则判断是否需要反射获取参数名。
  6. 否则使用参数下标作为参数名。
  7. 映射参数下标和参数名称的关系,构建Map容器。

整个流程还是很清晰易懂的,这里举几个示例。

void select(String name, int age);

解析的names结果为:

-- 编译时不加-parameters参数
-- 使用实际参数名
{
	0:"arg0",
  1:"arg1"
}
-- 不使用实际参数名
{
	0:"0",
  1:"1"
}
-- 编译时加了-parameters参数,且使用实际参数名
{
	0:"name",
  1:"age"
}

加了注解的情况:

void select(@Param("name") String name,@Param("age") int age);

解析的names结果为:

{
	0:"name",
  1:"age"
}

参数中有RowBounds或ResultHandler的情况:

void select(@Param("name") String name,RowBounds rowBounds,ResultHandler resultHandler,@Param("age") int age);

解析的names结果为:

-- 跳过RowBounds和ResultHandler
{
	0:"name",
  3:"age"
}

1.3 getNamedParams()

构造函数解析完names,就知道方法参数下标对应的参数名称了,接下来就可以调用getNamedParams()方法根据实参来解析参数名和参数值的映射ParamMap了。 ​

ParamMap继承自HashMap,只不过它将Key的泛型限制为String了。这也很好理解,参数名称肯定是字符串嘛,而参数值就不确定了,使用Object即可。 ​

下面是源码:

/**
 * 获取参数名对应的参数值
 * 一般是Map结构,当参数只有1个时,直接返回,xml中写任意值都可以匹配到
 * @param args
 * @return
 */
public Object getNamedParams(Object[] args) {
  // 参数数量
  final int paramCount = names.size();
  if (args == null || paramCount == 0) {
    // 无参情况
    return null;
  } else if (!hasParamAnnotation && paramCount == 1) {
    // 没有@Param注解,且参数只有一个的情况
    Object value = args[names.firstKey()];
    // 如果参数数集合类型,参数名会封装成:collection/list/array
    return wrapToMapIfCollection(value, useActualParamName ? names.get(0) : null);
  } else {
    final Map<String, Object> param = new ParamMap<>();
    int i = 0;
    for (Map.Entry<Integer, String> entry : names.entrySet()) {
      // 参数名 对应 实参值
      param.put(entry.getValue(), args[entry.getKey()]);
      // add generic param names (param1, param2, ...)
      // 额外再自动生成一个参数名映射:param1,param2...
      final String genericParamName = GENERIC_NAME_PREFIX + (i + 1);
      // ensure not to overwrite parameter named with @Param
      if (!names.containsValue(genericParamName)) {
        param.put(genericParamName, args[entry.getKey()]);
      }
      i++;
    }
    return param;
  }
}

该方法会得到一个ParamMap,里面存放着参数名和参数值的映射关系,调用该方法需要传递方法实参数组。 ​

它主要做了这么几件事:

  1. 如果参数为空,直接返回null。
  2. 没有@Param注解,且参数只有一个。
    1. 是否是集合类型(包含数组)?
    2. 是,则参数名自动设置为collection/list/array。
    3. 不是,则直接返回参数值。
  3. 创建ParamMap,将参数名和对应的参数值添加到容器中。
  4. 额外再生成以【param+序号】为参数名的映射条目。
  5. 返回Map容器。

对于参数只有一个的情况,且没有@Param注解,并且参数类型为Collection或List或数组的,MyBatis会自动以collection/list/array作为参数名来生成映射关系,你可以在xml中直接使用,如#{list}。源码如下:

/*
如果是集合,可以通过collection访问
如果是List,则可以通过list访问
如果是数组,则可以通过array访问
否则,直接直接返回
 */
public static Object wrapToMapIfCollection(Object object, String actualParamName) {
  if (object instanceof Collection) {
    ParamMap<Object> map = new ParamMap<>();
    map.put("collection", object);
    if (object instanceof List) {
      map.put("list", object);
    }
    Optional.ofNullable(actualParamName).ifPresent(name -> map.put(name, object));
    return map;
  } else if (object != null && object.getClass().isArray()) {
    ParamMap<Object> map = new ParamMap<>();
    map.put("array", object);
    Optional.ofNullable(actualParamName).ifPresent(name -> map.put(name, object));
    return map;
  }
  return object;
}

2. 核心点

2.1 如何反射获取参数名?

默认获取到的是arg0,arg1这样的无意义名称,只有JDK版本为8及以上,且编译时加了-parameters参数才能反射获取到。 ​

2.2 SpringMVC可以根据参数名绑定参数,MyBatis为何不行?

SpringMVC不是JDK8版本,没有加-parameters参数,没有加注解,依然可以根据参数名来动态绑定参数,为什么MyBatis就不行?必须加@Param注解? ​

那是因为Spring的Maven项目在编译打包时自动加了-g参数,这个参数就是告诉编译器,我们需要调试类的信息,这时编译器在编译时,就会保留局部变量表的信息,参数也是局部变量表的一部分。此时Spring再通过ASM去解析字节码文件通过局部变量表变向的获取参数名称,再根据参数名做参数的动态绑定。 ​

总结就是SpringMVC能获取到参数名是因为编译是加了-g参数,编译时保留了【局部变量表】的信息,通过局部变量表变向获取参数名。而MyBatis的Mapper是接口,没有方法体,也就不存在局部变量表了,因此ASM也无能为例了。 ​

3. 总结

本篇文章分析了ParamNameResolver源码,介绍了MyBatis解析参数的内幕。它先是在构造函数中对方法的形参做解析,得到参数下标对应的参数名称,再根据方法实参去做映射,最终得到参数名和参数值的映射关系ParamMap。 ​

关于获取参数名称,还做了额外的扩展,以及和SpringMVC的对比,SpringMVC不用加注解也能获取到参数名称就是因为Controller的方法有方法体,而Mapper是接口,方法不存在方法体,没有局部变量表,也就无法获取。 ​

除了解析能获取到的参数名,MyBatis还会额外生成一条以【param+序号】为参数名的映射关系,即使你漏加了注解,反射获取不到参数名称,也至少保证了一种方法可以正确的使用参数。 ​