2021年12月25日 @Param 注解的问题

1,825 阅读4分钟

背景

今天线上爆出一个很奇怪的问题,一个定时Job上线一个多月了,每天都在执行,都很正常,没有异常情况。但是昨天上有上线,上线之后,这个定时Job就报错了。

具体错误信息

org.mybatis.spring.MyBatisSystemException: nested exception is org.apache.ibatis.binding.BindingException: Parameter 'id' not found. Available parameters are [arg1, arg0, param1, param2]

错误原因根本是 mapper 层传入多个参数,在较低版本的 mybatis 中,需要使用 @Param 注解。而在代码中并没有使用 @Param 注解,导致错误。但是比较奇怪的是,如果这种用法是错误的,那么应该在一开始的测试环境中就会发现。但是并没有出现错误。而后我今天在测试环境中触发定时 Job 任务时,发现也报了一摸一样的错,很奇怪。

先了解一下 @Param 注解吧。

解决方案

加上 @Param 注解,功能正常运行。

原因

首先追究的是 @Param 的注解加与不加,mybatis 是怎么处理参数的,可以顺着程序 debug 一下。这里不介绍源码,只是看一下 @Param 如何执行。

看一下 @Param 源码

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.PARAMETER})
public @interface Param {
    String value();
}

可以看到只有一个 value 属性,而这个 value 属性正对应着 mapper.xml 中获取参数的 key。纳闷 mybatis 是怎么处理 mapper 接口中方法的参数名称的呢?顺着 mapper 层接口中的方法,最终找到了这个类-》org.apache.ibatis.reflection.ParamNameResolver 参数名称处理,根据类名可以大致猜到最终 @Param 注解的参数名称会在这里处理。

从下面的代码中可以知道,处理逻辑主要集中在该类的构造方法中。

public ParamNameResolver(Configuration config, Method method) {
  this.useActualParamName = config.isUseActualParamName();
  final Class<?>[] paramTypes = method.getParameterTypes();
  final Annotation[][] paramAnnotations = method.getParameterAnnotations();
  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])) {
      // skip special parameters
      continue;
    }
    String name = null;
    for (Annotation annotation : paramAnnotations[paramIndex]) {
      if (annotation instanceof Param) {
        hasParamAnnotation = true;
        name = ((Param) annotation).value();
        break;
      }
    }
    if (name == null) {
      // @Param was not specified.
      if (useActualParamName) {
        name = getActualParamName(method, paramIndex);
      }
      if (name == null) {
        // use the parameter index as the name ("0", "1", ...)
        // gcode issue #71
        name = String.valueOf(map.size());
      }
    }
    map.put(paramIndex, name);
  }
  names = Collections.unmodifiableSortedMap(map);
}

构造动态 sql 的主要逻辑在后面一层的 for 循环中,从注解 @Param 获取 value 值

String name = null;
for (Annotation annotation : paramAnnotations[paramIndex]) {
  if (annotation instanceof Param) {
    hasParamAnnotation = true;
    name = ((Param) annotation).value();
    break;
  }
}

如果没有添加 @Param注解,那么在后面的逻辑中对获取的 name 再次判断是否空,如果 useActualParamName 配置为 true,那么就从 getActualParamName 获取 name 的值,而执行这个方法的前提是 useActualParamName 为 true。

当 useActualParamName 设置为false,则执行另一层逻辑:

if (name == null) {
    // use the parameter index as the name ("0", "1", ...)
    // gcode issue #71
    name = String.valueOf(map.size());
}

获取当前方法参数的位置,作为 name 的值 -》use the parameter index as the name。

useActualParamName 的赋值是通过 config.isUseActualParamName() 获取的。而 config 是mybatis 的配置对象。通过 yml 的配置选项可以影响 mybatis 的行为。类似于这样

但是看了源码,这个属性在代码直接赋值为 true, 且用 final 修饰,所以根本改变不了。在 3.4.1 版本之前能不能改不太清楚。

##mybatis
mybatis:
  configuration:
    map-underscore-to-camel-case: true
    #logImpl: org.apache.ibatis.logging.stdout.StdOutImpl
  mapper-locations: classpath:/mappers/*/*Mapper.xml,classpath:/mappers/*Mapper.xml

对于 useActualParamName 参数官网是这样描述的

允许通过方法签名中声明的实际名称来引用语句参数。要使用此功能,您的项目必须使用带有-parametersJava 8进行编译。(自:3.4.1)

小结一下使用这个特性必须具备以下这几个条件:

  1. 采用 Java 8 编译
  2. 编译时加上 -parameters 选项
  3. mybatis 在 3.4.1 以上(因为这个以后的版本useActualParamName根本无法配置,不然还要加上 useActualParamName = true的条件)。

image.png

当不添加 @Param 注解时,传入参数的名称前后对比:

添加 @Param 注解

image.png

添加了注解两个参数的名称是 id 和 limtSize

不添加注解 @Param,并且没有添加 -parameters 编译选项

image.png

不添加注解,但是添加了 -parameters 编译选项,则获取的参数变量名称和添加了 @Param 一样。

后面两个执行的方法是一样的,都是通过 getActualParamName 方法为 name 赋值,但是不添加 -parameters 编译选项两者获取的名称根本不同。进入 getActualParamName 方法中,源码如下:

public class ParamNameUtil {
  public static List<String> getParamNames(Method method) {
    return getParameterNames(method);
  }

  public static List<String> getParamNames(Constructor<?> constructor) {
    return getParameterNames(constructor);
  }

  private static List<String> getParameterNames(Executable executable) {
    return Arrays.stream(executable.getParameters()).map(Parameter::getName).collect(Collectors.toList());
  }

  private ParamNameUtil() {
    super();
  }
}

通过 Executable 获取参数然后根据参数名称转换为 List。唯一不同的是指定编译选项和不指定编译选项两个获取的参数变量名称不一样。然后点进去看源码没看懂😭😭😭。

ToDo:后面研究研究

public Parameter[] getParameters() {
    // TODO: This may eventually need to be guarded by security
    // mechanisms similar to those in Field, Method, etc.
    //
    // Need to copy the cached array to prevent users from messing
    // with it.  Since parameters are immutable, we can
    // shallow-copy.
    return privateGetParameters().clone();
}

可以看到两个参数的名称是 arg0 和 arg1,和 mapper.xml 中的参数不对应, 导致 sql 查询错误。

至此对 name 有一个较为充分的理解了。这就解释了加上 @Param 为什么可以解决参数不对应的问题。

但是这个情况并没有解答我当前的疑惑,因为代码经过 本地调试,和 测试环境测试,不可能测试不出来。

本地的原因已经找到,是因为 idea 版本的问题,高版本 idea 在编译项目时会默认给项目添加 -parameters 选项,而我们使用的也是 jdk 8,这样就导致了调试没有出错。这个配置如图所示:

image.png

顺便说一下我的 idea 版本 2020.3.4

找一个网上配置的图:

image.png

那么测试环境呢,按理说测试环境应该出错才对。但是直到上线,并且在线上运行了 一个月并没有出现异常。

首先项目有两点不符合要求:

mybatis 版本看了一下 3.5.5,符合要求,然后测试环境启动参数之前有没有变更不太清楚,反正后来是没有 -parameters 编译选项。然后 yml 里也没有配置

use-actual-param-name: false

所以是为什么呢??? 迷了。

然后看到网上的一篇文章后面解释了一下为什么加上 -parameters 可以获取变量名称了。 在 Java 8 之前,Java 代码编译为 class 文件后,方法参数的类型固定,但是参数名称会丢失,所以当通过反射获取方法参数名称时是不能够获取方法中原本的参数名称的。 Java 编译器会丢掉这部分信息。从 JDK 8 起,可以通过在编译选项添加 -parameters 编译选项来明确告诉编译器我们明确要保留方法参数的原本名称。

public class ReflectionTest {

    // 验证 -parameters 参数的作用
    public static void main(String[] args) {
        Method[] methods = ReflectionTest.class.getMethods();
        for (Method method : methods){
            if("parameterMethodTest".equals(method.getName())){
                Parameter[] parameters = method.getParameters();
                for(Parameter parameter : parameters){
                    System.out.println(parameter.getName());
                }
            }
        }
    }

    public static void parameterMethodTest(int paramOne, String paramTwo, int paramThird){
        System.out.println("test parameters");
    }
}

参考文章:blog.csdn.net/u011821334/…