Mybatis - 预编译

  • 一. 什么是预编译
  • 1.1 Mybatis中预编译的运用
  • 1.2 预编译的原理
  • 1.2.1 动态SQL的分类
  • 1.2.2 预编译的处理(占位符的替换)
  • 1.2.3 执行的时候如何替换参数(参数赋值)
  • 1.3 总结


一. 什么是预编译

首先我们来说下预编译的一个背景:我们知道一条SQL语句到达Mysql之后,Mysql并不是会马上执行它,而是需要经过几个阶段性的动作(细节的可以查看Mysql复习计划(一)- 字符集、文件系统和SQL执行流程):

  1. 缓存的检查。
  2. 解析器解析。
  3. 优化器解析。
  4. 执行器执行。

那么这几个阶段肯定是需要一定的时间的。而有时候我们一条SQL语句可能需要反复的执行,只不过里面的参数可能不一样,比如where子句中的条件。如果每次都需要经过上面的几个步骤,那么效率就会下降。因此为了解决这种问题,就出现了预编译。

预编译语句就是将这类语句中的值用占位符替代,可以视为将SQL语句模板化或者说参数化。一次编译、多次运行,省去了解析优化等过程。

1.1 Mybatis中预编译的运用

Mybatis中,默认是开启预编译的功能的,我们来测试一下(用的SpringBoot):

1.pom依赖:

<dependency>
   <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.1.3</version>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid</artifactId>
    <version>1.2.1</version>
</dependency>
<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis</artifactId>
    <version>3.4.6</version>
</dependency>

2.配置文件:application.yml文件。

server:
  port: 8080

spring:
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://IP地址:3306/数据库名称
    username: xxx
    password: xxx
# 别名,这样写Mapper配置文件的时候可以省略前缀
mybatis:
  type-aliases-package: com.application.bean

application.properties文件:

#druid数据库连接池
type=com.alibaba.druid.pool.DruidDataSource
#配置mapper
mybatis.mapper-locations=classpath:mapper/*.xml
#配置日志输出
mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl

3.Mapper.xml文件:

对应目录:

MYSQL 预编译处理 和 mybatis 处理效率 mysql的预编译_java


文件内容:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!--关联UserMapper接口 ,下面的id要和接口中对应的方法名称一致,实现一对一的对应-->
<mapper namespace="com.application.mapper.UserMapper">
    <select id="getUserById" parameterType="String" resultType="user">
        select * from user where userName=#{userName}
    </select>
</mapper>

4.实体类:

MYSQL 预编译处理 和 mybatis 处理效率 mysql的预编译_mybatis_02

public class User {
    private String userName;
    private int id;
    // get/set
}

5.mapper接口:

@Mapper
@Repository
public interface UserMapper {
    User getUserById(String userName);
}

6.Service类:

@Service
public class UserProcess {
    @Autowired
    private UserMapper userMapper;

    public User getUser(String name){
        return userMapper.getUserById(name);
    }
}

7.Controller类:

@RestController
public class MyController {
    @Autowired
    private UserProcess userProcess;

    @PostMapping("/hello")
    public User hello(){
        return userProcess.getUser("tom");
    }
}

8.运行后的日志输出:

MYSQL 预编译处理 和 mybatis 处理效率 mysql的预编译_sql_03


当你看到了占位符 的时候,就说明预编译成功了,Mybatis就是将#{}这样的参数用占位符问号来代替。形成一个SQL模板。以达到预编译的目的。

1.2 预编译的原理

Mybatis中,对于SQL的处理解析动作在于XMLStatementBuilder.parseStatementNode这个类的函数中,我们来从这里为入口来看。我们先来看本文案例中的Mapper文件:

<mapper namespace="com.application.mapper.UserMapper">
    <select id="getUserById" parameterType="String" resultType="user">
        select * from user where userName=#{userName}
    </select>
</mapper>

1.2.1 动态SQL的分类

再看下源码:

public void parseStatementNode() {
  // 命名空间中的唯一标识,一般可以用方法名
  String id = context.getStringAttribute("id");
  // 对应的数据库标识
  String databaseId = context.getStringAttribute("databaseId");

  if (!databaseIdMatchesCurrent(id, databaseId, this.requiredDatabaseId)) {
    return;
  }
  // 驱动程序每次批量返回的结果行数
  Integer fetchSize = context.getIntAttribute("fetchSize");
  // 等待数据库返回结果的超时时间
  Integer timeout = context.getIntAttribute("timeout");
  String parameterMap = context.getStringAttribute("parameterMap");
  // 该SQL中,传入的参数的完全限定名或者别名。本文是user
  String parameterType = context.getStringAttribute("parameterType");
  Class<?> parameterTypeClass = resolveClass(parameterType);
  // Map结果集
  String resultMap = context.getStringAttribute("resultMap");
  // 该SQL返回类型
  String resultType = context.getStringAttribute("resultType");
  String lang = context.getStringAttribute("lang");
  LanguageDriver langDriver = getLanguageDriver(lang);

  Class<?> resultTypeClass = resolveClass(resultType);
  // 结果集的相关配置,下文给出详细的点
  String resultSetType = context.getStringAttribute("resultSetType");
  // 语句类型的处理,下文给出详细的点
  StatementType statementType = StatementType.valueOf(context.getStringAttribute("statementType", StatementType.PREPARED.toString()));
  ResultSetType resultSetTypeEnum = resolveResultSetType(resultSetType);

  String nodeName = context.getNode().getNodeName();
  // 得到SQL命令的类型:对应着增删改查,有这么几种枚举值:UNKNOWN, INSERT, UPDATE, DELETE, SELECT, FLUSH;
  SqlCommandType sqlCommandType = SqlCommandType.valueOf(nodeName.toUpperCase(Locale.ENGLISH));
  boolean isSelect = sqlCommandType == SqlCommandType.SELECT;
  // 设置为true的时候,只要语句被调用,就会让本地缓存和二级缓存被清空。默认值为false(针对select)
  boolean flushCache = context.getBooleanAttribute("flushCache", !isSelect);
  // true代表将本次查询的语句通过二级缓存缓存起来,针对select,为true。
  boolean useCache = context.getBooleanAttribute("useCache", isSelect);
  boolean resultOrdered = context.getBooleanAttribute("resultOrdered", false);

  // Include Fragments before parsing
  XMLIncludeTransformer includeParser = new XMLIncludeTransformer(configuration, builderAssistant);
  includeParser.applyIncludes(context.getNode());

  // Parse selectKey after includes and remove them.
  processSelectKeyNodes(id, parameterTypeClass, langDriver);
  
  // 生成SQL的地方。
  SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);
  // ...省略
}

resultSetType有哪些类型?

  • FORWARD_ONLY:结果集的游标只能向下滚动。
  • SCROLL_INSENSITIVE:结果集的游标可以上下移动,当数据库变化时,当前结果集不变。
  • SCROLL_SENSITIVE:返回可滚动的结果集,当数据库变化时,当前结果集同步改变。

statementType有哪些类型?

  • STATEMENT:普通语句。
  • PREPARED:预处理。
  • CALLABLE:存储过程。

那么本文只关注于预编译的处理过程。我们继续看上述函数中的关键代码:

SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);

这段代码就是通过LanguageDriverSQL语句进行解析,返回的结果中,就可能包含动态SQL或者占位符。我们来看下这个函数的源码:LanguageDriver的默认实现是XMLLanguageDriver

public class XMLLanguageDriver implements LanguageDriver {
  // 接着到这里
  @Override
  public SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {
    // 创建一个XMLScriptBuilder对象。并通过它来解析SQL脚本
    XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType);
    return builder.parseScriptNode();
  }
  ↓↓↓↓↓↓↓
  public SqlSource parseScriptNode() {
    MixedSqlNode rootSqlNode = parseDynamicTags(context);
    SqlSource sqlSource = null;
    // 这里就是重点了,判断这个SQL是否是动态的
    if (isDynamic) {
      sqlSource = new DynamicSqlSource(configuration, rootSqlNode);
    } else {
      sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);
    }
    return sqlSource;
  }
}

从上面源码可以得出,最终SQL构建起来,有两个分支:

  • 动态的:通过DynamicSqlSource来构建。
  • 非动态的:通过RawSqlSource来构建。

那么是否为动态的判断标准是啥呢?关键看下面这行代码:

MixedSqlNode rootSqlNode = parseDynamicTags(context);
↓↓↓↓↓↓↓
protected MixedSqlNode parseDynamicTags(XNode node) {
  List<SqlNode> contents = new ArrayList<SqlNode>();
  NodeList children = node.getNode().getChildNodes();
  for (int i = 0; i < children.getLength(); i++) {
    XNode child = node.newXNode(children.item(i));
    if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE || child.getNode().getNodeType() == Node.TEXT_NODE) {
      String data = child.getStringBody("");
      // 将文本节点封装成TextSqlNode ,然后判断是否是动态的
      TextSqlNode textSqlNode = new TextSqlNode(data);
      if (textSqlNode.isDynamic()) {
        contents.add(textSqlNode);
        isDynamic = true;
      } else {
        contents.add(new StaticTextSqlNode(data));
      }
    } else if (child.getNode().getNodeType() == Node.ELEMENT_NODE) { // issue #628
      String nodeName = child.getNode().getNodeName();
      NodeHandler handler = nodeHandlerMap.get(nodeName);
      if (handler == null) {
        throw new BuilderException("Unknown element <" + nodeName + "> in SQL statement.");
      }
      handler.handleNode(child, contents);
      isDynamic = true;
    }
  }
  return new MixedSqlNode(contents);
}
↓↓↓↓↓↓↓
textSqlNode.isDynamic()
↓↓↓↓↓↓↓
public class TextSqlNode implements SqlNode {
  public boolean isDynamic() {
    DynamicCheckerTokenParser checker = new DynamicCheckerTokenParser();
    GenericTokenParser parser = createParser(checker);
    parser.parse(text);
    return checker.isDynamic();
  }
  ↓↓↓↓↓↓↓
  private GenericTokenParser createParser(TokenHandler handler) {
    return new GenericTokenParser("${", "}", handler);
  }
}

到这里为止,我们可以观察到,在判断这个SQL是否为动态的时候,底层会创建一个GenericTokenParser类型的解析器。而这个解析器呢则是一个以 ${ 为开始和以 } 为结尾的解析器。如果解析成功,说明该SQL中包含了${},那么该SQL就会被标记为动态SQL标签。

那么反之,我们回到这段代码中:

MYSQL 预编译处理 和 mybatis 处理效率 mysql的预编译_java_04


对于#{}这样的语句,就是一个非动态标签。总结下就是:

  • 如果SQL中的参数是用${}作为占位符的,那么该SQL属于动态SQL,封装为DynamicSqlSource
  • 否则其他的都是非动态SQL,封装为RawSqlSource

1.2.2 预编译的处理(占位符的替换)

那么我们来看下RawSqlSource的构造函数:

public RawSqlSource(Configuration configuration, String sql, Class<?> parameterType) {
  SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
  Class<?> clazz = parameterType == null ? Object.class : parameterType;
  // SQL的解析
  sqlSource = sqlSourceParser.parse(sql, clazz, new HashMap<String, Object>());
}

public class SqlSourceBuilder extends BaseBuilder {
  public SqlSource parse(String originalSql, Class<?> parameterType, Map<String, Object> additionalParameters) {
    // 主要的一个解析器
    ParameterMappingTokenHandler handler = new ParameterMappingTokenHandler(configuration, parameterType, additionalParameters);
    GenericTokenParser parser = new GenericTokenParser("#{", "}", handler);
    // 获取真实的可执行性的sql语句
    String sql = parser.parse(originalSql);
    // 包装成StaticSqlSource然后返回
    return new StaticSqlSource(configuration, sql, handler.getParameterMappings());
  }
}

这里我们可以看出来,对于非动态SQL,会生成一个以 #{ 为开头,} 为结尾的解析器。紧接着就会创建一个StaticSqlSource 类,在这里做个区分:

  • RawSqlSource : 存储的是只有 #{} 或者没有标签的纯文本SQL信息
  • DynamicSqlSource : 存储的是写有 ${} 或者具有动态SQL标签的SQL信息
  • StaticSqlSource : 是DynamicSqlSourceRawSqlSource解析为BoundSql的一个中间态对象类型。
  • BoundSql:用于生成我们最终执行的SQL语句,属性包括参数值、映射关系、以及SQL(带问号的)

接着我们看下解析器ParameterMappingTokenHandler 的处理,它是SqlSourceBuilder的一个静态内部类:

private static class ParameterMappingTokenHandler extends BaseBuilder implements TokenHandler {
  @Override
  public String handleToken(String content) {
    parameterMappings.add(buildParameterMapping(content));
    return "?";
  }
}

这段代码在哪里用到呢?就是在GenericTokenParser.parse()这段代码中执行到的:

public class GenericTokenParser {
  public String parse(String text) {
    // ...
    while (start > -1) {
      if (start > 0 && src[start - 1] == '\\') {
        // this open token is escaped. remove the backslash and continue.
        builder.append(src, offset, start - offset - 1).append(openToken);
        offset = start + openToken.length();
      } else {
        // ...
        if (end == -1) {
          // close token was not found.
          builder.append(src, start, src.length - start);
          offset = src.length;
        } else {
          // 就是找到了#{}的结束标识},然后将中间的内容替换成?
          builder.append(handler.handleToken(expression.toString()));
          offset = end + closeToken.length();
        }
      }
      start = text.indexOf(openToken, offset);
    }
    if (offset < src.length) {
      builder.append(src, offset, src.length - offset);
    }
    return builder.toString();
  }
}

这段代码执行完毕之后,我们发现最后返回了一个StaticSqlSource 对象。

1.2.3 执行的时候如何替换参数(参数赋值)

那么在执行SQL的时候,则会去根据BoundSql来完成参数的赋值等操作。我们来看下RawSqlSource.getBoundSql这个函数:

public class RawSqlSource implements SqlSource {
  private final SqlSource sqlSource;

  @Override
  public BoundSql getBoundSql(Object parameterObject) {
    return sqlSource.getBoundSql(parameterObject);
  }
}

因为只有#{}SQL语句,在上文中可以看到最后会生成一个StaticSqlSource对象,而这个类中就重写了getBoundSql函数,里面主要构造了一个BoundSql对象。

public class StaticSqlSource implements SqlSource {
  @Override
  public BoundSql getBoundSql(Object parameterObject) {
    return new BoundSql(configuration, sql, parameterMappings, parameterObject);
  }
}

Mybatis在执行SQL的时候,主要看的是SimpleExecutor.prepareStatement这个入口:

public class SimpleExecutor extends BaseExecutor {
  private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException {
    Statement stmt;
    // 获取JDBK的数据库连接
    Connection connection = getConnection(statementLog);
    // 准备工作,初始化Statement连接
    stmt = handler.prepare(connection, transaction.getTimeout());
    // 使用ParameterHandler处理入参
    handler.parameterize(stmt);
    return stmt;
  }
}

我们主要关注入参的处理函数上:

public class PreparedStatementHandler extends BaseStatementHandler {
  @Override
  public void parameterize(Statement statement) throws SQLException {
    parameterHandler.setParameters((PreparedStatement) statement);
  }
}
↓↓↓↓↓
public class DefaultParameterHandler implements ParameterHandler {
  @Override
  public void setParameters(PreparedStatement ps) {
    ErrorContext.instance().activity("setting parameters").object(mappedStatement.getParameterMap().getId());
    // 从boundSql中拿到我们传入的参数
    List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
    if (parameterMappings != null) {
      // 循环处理每一个参数,需要应用到类型转换器
      for (int i = 0; i < parameterMappings.size(); i++) {
        ParameterMapping parameterMapping = parameterMappings.get(i);
        // mode属性有三种:IN, OUT, INOUT。如果参数为 OUT 或 INOUT,参数对象属性的真实值将会被改变
        if (parameterMapping.getMode() != ParameterMode.OUT) {
          Object value;
          String propertyName = parameterMapping.getProperty();
          if (boundSql.hasAdditionalParameter(propertyName)) { // issue #448 ask first for additional params
            value = boundSql.getAdditionalParameter(propertyName);
          } else if (parameterObject == null) {
            value = null;
          } 
          // 如果类型处理器里面有这个类型,直接赋值即可。
          else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
            value = parameterObject;
          } 
		  // 否则转化为元数据处理,通过反射来完成get/set赋值
		  else {
            MetaObject metaObject = configuration.newMetaObject(parameterObject);
            value = metaObject.getValue(propertyName);
          }
          TypeHandler typeHandler = parameterMapping.getTypeHandler();
          JdbcType jdbcType = parameterMapping.getJdbcType();
          if (value == null && jdbcType == null) {
            jdbcType = configuration.getJdbcTypeForNull();
          }
          try {
            // 使用不同的类型处理器向jdbc中的PreparedStatement设置参数
            typeHandler.setParameter(ps, i + 1, value, jdbcType);
          } catch (TypeException e) {
            throw new TypeException("Could not set parameters for mapping: " + parameterMapping + ". Cause: " + e, e);
          } catch (SQLException e) {
            throw new TypeException("Could not set parameters for mapping: " + parameterMapping + ". Cause: " + e, e);
          }
        }
      }
    }
  }
}

前面一部分逻辑主要是对参数进行类型转换。最后则对SQL进行参数的赋值和替换。那么我们主要关注最后的赋值代码:

typeHandler.setParameter(ps, i + 1, value, jdbcType);
↓↓↓↓↓
public abstract class BaseTypeHandler<T> extends TypeReference<T> implements TypeHandler<T> {
  @Override
  public void setParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException {
    if (parameter == null) {
      if (jdbcType == null) {
        throw new TypeException("JDBC requires that the JdbcType must be specified for all nullable parameters.");
      }
      try {
        ps.setNull(i, jdbcType.TYPE_CODE);
      } catch (SQLException e) {
        throw new TypeException("Error setting null for parameter #" + i + " with JdbcType " + jdbcType + " . " +
                "Try setting a different JdbcType for this parameter or a different jdbcTypeForNull configuration property. " +
                "Cause: " + e, e);
      }
    } else {
      try {
        setNonNullParameter(ps, i, parameter, jdbcType);
      } catch (Exception e) {
        throw new TypeException("Error setting non null for parameter #" + i + " with JdbcType " + jdbcType + " . " +
                "Try setting a different JdbcType for this parameter or a different configuration property. " +
                "Cause: " + e, e);
      }
    }
  }
}

我们重点关注那些非null值的赋值,看下Mybatis是怎么替换SQL中的 的:

setNonNullParameter(ps, i, parameter, jdbcType);

这个函数是一个抽象函数,有很多具体的实现,对应的参数是什么类型的,就用对应类型的处理方式去完成,如图:

MYSQL 预编译处理 和 mybatis 处理效率 mysql的预编译_java_05


无论是哪种类型,本质上都是对 占位符进行值的替换操作。

1.3 总结

因为本篇文章主要是将Mybatis对于预处理的原理。因此整个Mybatis的机制或者是二级缓存、一级缓存的知识点不在本文内容范围中(准备再写几篇内容补上)。本文主要讲了:什么是预编译。Mybatis中对预编译的处理、如何生成模板SQL、如何进行值的替换。

因此这里做个小总结:

首先预编译对于Mybatis而言,相当于构建出了一条SQL的模板,将#{}对应的参数改为?而已。届时只需要更改参数的值即可,无需再对SQL进行语法解析等操作。


对于动态SQL的判断,就是在于是否包含${}占位符。如果包含了就通过DynamicSqlSource来解析。而这里则影响到SQL的解析:

  • DynamicSqlSource:解析包含${}的语句,其实也会解析#{}的语句。
  • RawSqlSource:解析只包含#{}的语句。

这两种类型到最后都会转化为StaticSqlSource,然后由他创建一个BoundSql对象。包括参数值、映射关系、以及转化好的SQL


最后是关于SQL的执行,即如何将真实的参数赋值到我们上面生成的模板SQL中。这部分逻辑发生在SQL的执行过程中,其入口SimpleExecutor.prepareStatement主要做了这么几件事。

  1. 根据我们上面生成的BoundSql对象。拿到我们传入的参数。
  2. 对每个参数进行解析,转化成对应的类型。
  3. 如果转化出的参数值为null,则直接赋值,否则,还要通过类型处理器来完成赋值操作。typeHandler.setParameter(ps, i + 1, value, jdbcType);
  4. 每种类型处理器,则会对对应的参数进行赋值。

备注,Mybatis支持的类型处理器可以看TypeHandlerRegistry这个类,相关处理器的注册在其构造函数中。这里贴出部分截图:

MYSQL 预编译处理 和 mybatis 处理效率 mysql的预编译_java_06