MyBatis中灵活的主键自增功能使用及其原理详解

分类: Java

保留所有版权,请引用而不是转载本文(原文地址 https://yeecode.top/blog/79/ )。

在进行数据插入操作时,经常需要一个自增生成的主键编号,这既能保证主键的唯一性,又能保证主键的连续性。

许多数据库都支持主键自增功能,如MySQL数据库、SQL Server数据库等。当然也有一些数据库不支持主键自增功能,如Oracle数据库。MyBatis的executor包中的keygen子包兼容以上这两种情况。

接下来我们通过MyBatis源码详细了解MyBatis的主键自增功能的使用方法和实现原理。相关内容均参考《通用源码阅读指导书——MyBatis源码详解》一书。

主键自增功能在MyBatis的executor包的keygen子包中,一共包含下图所示的四个类或接口。

keygen子包类图

KeyGenerator作为接口提供了两个方法,即processBefore方法和processAfter方法。关于这两个方法的实现细节我们会在接下来的小节中分别介绍。

NoKeyGenerator不提供任何主键自增功能,其processBefore方法和processAfter方法均为空方法。因此,我们不再介绍该类。

1 主键自增的配置与生效

在阅读MyBatis的主键自增相关代码之前,我们先了解在怎么在MyBatis中启用主键自增功能。

通过KeyGenerator的类图我们知道,MyBatis中的KeyGenerator实现类一共有三种:Jdbc3KeyGenerator、SelectKeyGenerator、NoKeyGenerator。在实际使用时,这三种实现中只能有一种实现生效。而如果生效的是NoKeyGenerator,则代表不具有任何的主键自增功能。

要启用Jdbc3KeyGenerator可以在配置文件中增加如下所示的配置:

<setting name="useGeneratedKeys" value="true"/>

或者直接在相关语句上启用useGeneratedKeys,如下所示。

<insert id="addUser_A" parameterType="User" useGeneratedKeys="true" keyProperty="id">
    INSERT INTO `user`
    (`name`,`email`,`age`,`sex`,`schoolName`)
    VALUES
    (#{name},#{email},#{age},#{sex},#{schoolName})
</insert>

如果要启用SelectKeyGenerator则需要在SQL语句前加一段selectKey标签,如下所示。

<insert id="addUser_B" parameterType="User">
    <selectKey resultType="java.lang.Integer" keyProperty="id" order="AFTER">
        SELECT LAST_INSERT_ID()
    </selectKey>
    INSERT INTO `user`
    (`name`,`email`,`age`,`sex`,`schoolName`)
    VALUES
    (#{name},#{email},#{age},#{sex},#{schoolName})
</insert>

如果某一条语句同时设置了useGeneratedKeys和selectKey的话,则是后者生效。

以上各个配置项的作用范围、优先级等结论,均可以通过阅读下面的源码得出。下面的源码在XMLStatementBuilder类中,在集成开发软件的帮助下,我们可以通过查找KeyGenerator的引用找到。这段代码就是主键自增功能被解析的地方。

// 处理SelectKey节点,在这里会将KeyGenerator加入到Configuration.keyGenerators中
processSelectKeyNodes(id, parameterTypeClass, langDriver);

// 原注释:此时,<selectKey> 和 <include> 节点均已被解析完毕并被删除
// 进行SQL解析
KeyGenerator keyGenerator;
String keyStatementId = id + SelectKeyGenerator.SELECT_KEY_SUFFIX;
keyStatementId = builderAssistant.applyCurrentNamespace(keyStatementId, true);
// 判断是否已经有解析好的KeyGenerator
if (configuration.hasKeyGenerator(keyStatementId)) {
  keyGenerator = configuration.getKeyGenerator(keyStatementId);
} else {
  // 全局或者本语句只要启用自动key生成,则使用key生成
  keyGenerator = context.getBooleanAttribute("useGeneratedKeys",
      configuration.isUseGeneratedKeys() && SqlCommandType.INSERT.equals(sqlCommandType))
      ? Jdbc3KeyGenerator.INSTANCE : NoKeyGenerator.INSTANCE;
}

processSelectKeyNodes方法经过最终解析了selectKey节点的信息并在解析完成后将selectKey节点从XML中删除了。而解析出来的信息而放入了configurationkeyGenerators中。之后,如果没有解析好的KeyGenerator,则会根据useGeneratedKeys判断是否使用Jdbc3KeyGenerator。

最终,KeyGenerator信息会被保存在整个Statement中。在Statement的执行时,直接调用KeyGenerator中的processBefore方法和processAfter方法即可,必然会有Jdbc3KeyGenerator、SelectKeyGenerator、NoKeyGenerator三者中的一个来实际执行这两个方法。

接下来我们介绍Jdbc3KeyGenerator和SelectKeyGenerator的源码。

2 Jdbc3KeyGenerator

2.1 Jdbc3KeyGenerator的功能

Jdbc3KeyGenerator是为具有主键自增功能的数据库准备的。说到这里大家可能会疑惑,既然数据库已经支持主键自增了,那Jdbc3KeyGenerator存在的意义是什么?

它存在的意义是提供自增主键的回写功能。

接下来我们通过示例来说明此功能。首先我们对user表中的id字段启用主键自增功能,然后下所示配置XML映射文件。这里并没有启用主键自增功能。

<insert id="addUser_D" parameterType="User">
    INSERT INTO `user`
    (`name`,`email`,`age`,`sex`,`schoolName`)
    VALUES
    (#{name},#{email},#{age},#{sex},#{schoolName})
</insert>

然后运行下面的Java语句。

User user04 = new User("李二壮", 0, "KEYUAN SCHOOL");

// 使用NoKeyGenerator,即不使用主键自增功能
System.out.println("before insert :" + user04.toString());
result = session.insert("com.github.yeecode.mybatisdemo.dao.UserDao.addUser_D", user04);
System.out.println("insert result : " + result);
System.out.println("after insert :" + user04.toString());

因为在数据库中对id字段启用了自增功能,所以在数据插入操作结束后,数据库中的user04的id字段会被设置为一个数值。然而,我们Java程序中的user04对象却不会被更新,因此输出的user04的id值仍然为null。结果如下。

程序运行结果

Jdbc3KeyGenerator提供的回写功能,能够将数据库中产生的id值回写给Java对象本身。我们可以通过下面的设置启用Jdbc3KeyGenerator

<insert id="addUser_A" parameterType="User" useGeneratedKeys="true" keyProperty="id">
    INSERT INTO `user`
    (`name`,`email`,`age`,`sex`,`schoolName`)
    VALUES
    (#{name},#{email},#{age},#{sex},#{schoolName})
</insert>

然后同样执行代码22-18所示的操作便可以得到下图所示的结果:

程序运行结果

因此Jdbc3KeyGenerator所做的工作就是在Java对象插入完成后,将数据库自增产生的id读取出来,然后回写给Java对象本身。其功能示意图如下所示,其中粗实线表示的工作就是Jdbc3KeyGenerator完成的。

Jdbc3KeyGenerator功能示意图

2.2 Jdbc3KeyGenerator的原理

Jdbc3KeyGenerator的工作是在数据库主键自增结束后,将自增出来的主键读取出来并赋给Java对象。这些工作都是在数据插入完成后进行的,即在processAfter方法中进行。而processBefore方法中不需要进行任何操作。

processAfter方法直接调用了processBatch方法。在阅读processBatch方法前我们先复习一个小的知识:Statement对象的getGeneratedKeys方法能返回此语句操作自增生成的主键,如果此语句没有产生自增主键则结果为空ResultSet对象。该内容在《通用源码阅读指导书——MyBatis源码详解》的17.1.5 Statement章节做过介绍。

接下来我们直接阅读下面代码给出的带注释的processBatch方法源码。该方法的主要工作就是调用Statement对象的getGeneratedKeys方法获取数据库自增生成的主键,然后将主键赋给实参以达到回写的目的。

public void processBatch(MappedStatement ms, Statement stmt, Object parameter) {
  // 拿到主键的属性名
  final String[] keyProperties = ms.getKeyProperties();
  if (keyProperties == null || keyProperties.length == 0) {
    // 没有主键则无需操作
    return;
  }
  // 调用Statement对象的getGeneratedKeys方法获取自动生成的主键值
  try (ResultSet rs = stmt.getGeneratedKeys()) {
    // 获取输出结果的描述信息
    final ResultSetMetaData rsmd = rs.getMetaData();
    final Configuration configuration = ms.getConfiguration();
    if (rsmd.getColumnCount() < keyProperties.length) {
      // 主键数目比结果的总字段数目还多,则发生了错误。
      // 但因为此处是获取主键这样的附属操作,因此忽略错误,不影响主要工作
    } else {
      // 调用子方法,将主键值赋给实参
      assignKeys(configuration, rs, rsmd, keyProperties, parameter);
    }
  } catch (Exception e) {
    throw new ExecutorException("Error getting generated key or setting result to parameter object. Cause: " + e, e);
  }
}

2.3 SelectKeyGenerator

Jdbc3KeyGenerator其实并没有真正的生成自增主键,而只是将数据库自增出的主键值回写到了Java对象中。因此,面对不支持主键自增功能的数据库时,Jdbc3KeyGenerator将无能为力。这时就需要SelectKeyGenerator类,因为它可以真正地生成自增的主键。

SelectKeyGenerator实现了processBefore和processAfter这两个方法。然而,如下面代码所示,这两个方法均直接调用了子方法processGeneratedKeys,这可能会让看到源码的我们感到疑惑。

/**
 * 数据插入前进行的操作
 * @param executor 执行器
 * @param ms 映射语句对象
 * @param stmt Statement对象
 * @param parameter SQL语句实参对象
 */
@Override
public void processBefore(Executor executor, MappedStatement ms, Statement stmt, Object parameter) {
  if (executeBefore) {
    processGeneratedKeys(executor, ms, parameter);
  }
}

/**
 * 数据插入后进行的操作
 * @param executor 执行器
 * @param ms 映射语句对象
 * @param stmt Statement对象
 * @param parameter SQL语句实参对象
 */
@Override
public void processAfter(Executor executor, MappedStatement ms, Statement stmt, Object parameter) {
  if (!executeBefore) {
    processGeneratedKeys(executor, ms, parameter);
  }
}

为了解答这个疑惑,我们先介绍SelectKeyGenerator的功能。

SelectKeyGenerator的功能描述起来很简单:先执行一段特定的SQL语句获取一个值,然后将该值赋给Java对象的自增属性。

然而,SelectKeyGenerator这一功能的执行时机分为两种,这两种执行时机通过配置二选一:

  • 在数据插入之前执行。执行完特定的SQL语句并将值赋给对象的自增属性后,再将这个完整的对象插入到数据库中。而这种操作又分为两种情况:
    • 如果数据库没有设置或者不支持主键自增,则完整的对象会被完整地插入到数据库中。这是SelectKeyGenerator最常用的使用场景。
    • 如果数据库设置了主键自增,则刚才特定SQL语句生成的自增属性值会被数据库自身的自增值覆盖掉。这种情况下,Java对象的自增属性值可能会和数据库中的自增属性值不一致,因此是错误的。这种情况下,建议使用Jdbc3KeyGenerator的回写功能。
  • 在数据插入之后执行。对象插入数据库结束后,Java对象的自增属性被设置成特定SQL语句的执行结果。这种操作也分为两种情况:
    • 如果数据库不支持主键自增,则之前被插入到数据库中的对象的自增属性是没有被赋值的,而Java对象的自增属性却被赋值了,这会导致不一致。这种操作是错误的。
    • 果数据库设置了主键自增,则数据库自增生成的值和SQL语句执行产生的值可能不一样。不过我们一般通过设置特定SQL语句来保证两者一致,这其实和Jdbc3KeyGenerator的回写功能类似。

可见SelectKeyGenerator的功能描述起来简单又灵活,但是因为执行时机、数据库状况等不同可能产生多种情况,需要在使用者自己把握。SelectKeyGenerator的功能示意图如下所示。

SelectKeyGenerator功能示意图

processBefore和processAfter这两个方法都直接调用了processGeneratedKeys方法,所以processGeneratedKeys方法的功能就是执行一段SQL语句后获取一个值,然后将该值赋给Java对象的自增属性。

在阅读processGeneratedKeys方法前,我们先对SelectKeyGenerator中的属性进行介绍,如下所示。

// 用户生成主键的SQL语句的特有标志,该标志会追加在用于生成主键的SQL语句的id的后方
public static final String SELECT_KEY_SUFFIX = "!selectKey";
// 插入前执行还是插入后执行
private final boolean executeBefore;
// 用户生成主键的SQL语句
private final MappedStatement keyStatement;

下面是processGeneratedKeys方法的带注释的源码。

/**
 + 执行一段SQL语句后获取一个值,然后将该值赋给Java对象的自增属性
 *
 + @param executor 执行器
 + @param ms 插入操作的SQL语句(不是生成主键的SQL语句)
 + @param parameter 插入操作的对象
 */
private void processGeneratedKeys(Executor executor, MappedStatement ms, Object parameter) {
  try {
    // keyStatement为生成主键的SQL语句;keyStatement.getKeyProperties()拿到的是要自增的属性
    if (parameter != null && keyStatement != null && keyStatement.getKeyProperties() != null) {
      // 要自增的属性
      String[] keyProperties = keyStatement.getKeyProperties();
      final Configuration configuration = ms.getConfiguration();
      final MetaObject metaParam = configuration.newMetaObject(parameter);
      if (keyProperties != null) {
        // 为生成主键的SQL语句创建执行器keyExecutor。
        // 原注释:不要关闭keyExecutor,因为它会被父级的执行器关闭
        Executor keyExecutor = configuration.newExecutor(executor.getTransaction(), ExecutorType.SIMPLE);
        // 执行SQL语句,得到主键值
        List<Object> values = keyExecutor.query(keyStatement, parameter, RowBounds.DEFAULT, Executor.NO_RESULT_HANDLER);
        // 主键值必须唯一
        if (values.size() == 0) {
          throw new ExecutorException("SelectKey returned no data.");
        } else if (values.size() > 1) {
          throw new ExecutorException("SelectKey returned more than one value.");
        } else {
          MetaObject metaResult = configuration.newMetaObject(values.get(0));
          if (keyProperties.length == 1) {
            // 要自增的主键只有一个,为其赋值
            if (metaResult.hasGetter(keyProperties[0])) {
              // 从metaResult中用getter得到主键值
              setValue(metaParam, keyProperties[0], metaResult.getValue(keyProperties[0]));
            } else {
              // 可能返回的直接就是主键值本身
              setValue(metaParam, keyProperties[0], values.get(0));
            }
          } else {
            // 要把执行SQL得到的值赋给多个属性
            handleMultipleProperties(keyProperties, metaParam, metaResult);
          }
        }
      }
    }
  } catch (ExecutorException e) {
    throw e;
  } catch (Exception e) {
    throw new ExecutorException("Error selecting key or setting result to parameter object. Cause: " + e, e);
  }
}

这样,我们对SelectKeyGenerator类的功能以及如何实现这些功能够进行了详细的介绍。因此,我们可以将SelectKeyGenerator作为Jdbc3KeyGenerator的升级版或者自由定制版:

  • SelectKeyGenerator可以设置为插入前执行并实现主键的主动生成,并且可以通过SQL语句设置主键生成方式。这是Jdbc3KeyGenerator没有的功能。
  • SelectKeyGenerator可以设置为插入后执行。通过将主键生成SQL设置为类似“SELECT LAST_INSERT_ID()”的语句便可以实现主键回写功能。

以上内容均参考《通用源码阅读指导书——MyBatis源码详解》一书。

通用源码阅读指导书-京东自营

《通用源码阅读指导书》

这是一本以MyBatis的源码为实例讲述源码阅读方法的书籍,并且附带有示例项目源码,MyBatis的全中文注释。书籍还总结了大量的编程知识和架构经验,对提升编程和架构能力十分有用。推荐给大家。

可以访问个人知乎阅读更多文章:易哥(https://www.zhihu.com/people/yeecode),欢迎关注。

作者书籍推荐

作者书籍推荐 作者书籍推荐