系列文章共三篇

  • Mybatis (1) 拓展机制
  • Mybatis (2) Mybatis-plus 为什么能和 Mybatis 成为 CP?
  • Mybatis (3) 常见面试点源码视角解析

阅读前提 源码版本为:org.mybatis.mybatis@3.5.6

题解

#{} 和 ${} 的区别

原题描述

请说说 mybatis 中 #{}${} 的区别,以及各自应用场景?

简明简答

动态 SQL 是 mybatis 的强大特性之一,也是它优于其他 ORM 框架的一个重要原因。mybatis 在对 sql 语句进行预编译之前,会对 sql 进行动态解析,解析为一个 BoundSql 对象,也是在此处对动态 SQL 进行处理的。
在动态 SQL 解析阶段, #{}${} 会有不同的表现:
**
#{} 解析为一个 JDBC 预编译语句(prepared statement)的参数标记符。
例如,sqlMap 中如下的 sql 语句:

  1. select * from user where name = #{name};

解析为:

select * from user where name = ?;

一个 #{ } 被解析为一个参数占位符 ?

${} 仅仅为一个纯碎的 string 替换,在动态 SQL 解析阶段将会进行变量替换

例如,sqlMap 中如下的 sql:

select * from user where name = '${name}';

当我们传递的参数为 “ruhua” 时,上述 sql 的解析为:

select * from user where name = "ruhua";

预编译之前的 SQL 语句已经不包含变量 name 了。
综上所得, ${} 的变量的替换阶段是在动态 SQL 解析阶段,而 #{} 的变量的替换是在 DBMS 中。

源码视角

开门见山,直接定位语句解析模块:

// method => org.apache.ibatis.scripting.xmltags.XMLScriptBuilder#parseScriptNode
public SqlSource parseScriptNode() {
    // 判断语句类型
    MixedSqlNode rootSqlNode = parseDynamicTags(context);
    SqlSource sqlSource;
    // 在这里,已经决定了提交到 prepareStatement 的语句样式,分别为
    // $:select * from user where name = "ruhua"
    // #: select * from user where name = ?
    if (isDynamic) {
        // 包含 ${} 进入逻辑
        sqlSource = new DynamicSqlSource(configuration, rootSqlNode);
    } else {
        // 只包含 #{} 进入逻辑
        sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);
    }
    return sqlSource;
}

// method => org.apache.ibatis.scripting.xmltags.XMLScriptBuilder#parseDynamicTags
protected MixedSqlNode parseDynamicTags(XNode node) {
    // ......
    // isDynamic 检测语句中是否包含 ${},如果包含则设置 isDynamic=true
    if (textSqlNode.isDynamic()) {
        contents.add(textSqlNode);
        isDynamic = true;
    } else {
        contents.add(new StaticTextSqlNode(data));
    }
    // ......
}

默认情况下,mybatis 使用的是 SimpleExecutor,该执行器 未对返回的 statement 做缓存,也就是说不管何种情况 mybatis 都会尝试重新生成预编译语句。

// method => org.apache.ibatis.executor.statement.PreparedStatementHandler#instantiateStatement
protected Statement instantiateStatement(Connection connection) throws SQLException {
    String sql = boundSql.getSql();

    //......
    else if (mappedStatement.getResultSetType() == ResultSetType.DEFAULT) {
        // 调用 JDBC 生成预编译语句
        return connection.prepareStatement(sql);
    }
    //......
}

我们可以通过指定执行器为 sqlSessionFactory.openSession(ExecutorType.REUSE) ,来实现预编译语句的复用,注意缓存是以会话连接进行隔离的。

// method => org.apache.ibatis.executor.ReuseExecutor#prepareStatement
private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException {
    Statement stmt;
    BoundSql boundSql = handler.getBoundSql();
    String sql = boundSql.getSql();
    // 检查是否有缓存有则返回,显然通过 ? 占位的语句被缓存命中的概率更高
    if (hasStatementFor(sql)) {
        stmt = getStatement(sql);
        applyTransactionTimeout(stmt);
    } else {
        Connection connection = getConnection(statementLog);
        stmt = handler.prepare(connection, transaction.getTimeout());
        putStatement(sql, stmt);
    }
    handler.parameterize(stmt);
    return stmt;
}

本以为改完 ReuseExecutor 就真的不用每次在 DB Server 端进行语句编译了,但事与愿违,
查阅 Mysql JDBC 源码发现,缓存特性依赖还依赖 JDBC 实现,例如默认情况下 Mysql JDBC 驱动使用的客户端预编译语句,每次还是提交完整的 SQL 语句到 DB Server 端。

// method => com.mysql.jdbc.ConnectionImpl#prepareStatement
public java.sql.PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency) throws SQLException {
    // ......
    if (this.useServerPreparedStmts && canServerPrepare) {
        // 使用服务端预编译语句
        // ......
    } else {
        // 框架默认,使用客户端预编译语句,每次提交到 mysql 服务器的依旧是完整的 Query 语句
        pStmt = (PreparedStatement) clientPrepareStatement(nativeSql, resultSetType, resultSetConcurrency, false);
    }

    return pStmt;
    //......
}

在 mysql 中 服务端预编译语句 指的是:在语句编译完成后,每次调用只需要传递参数即可

# stmt1 为预编译语句代号,在同一会话连接中可重复多次使用
PREPARE stmt1 FROM 'SELECT * FROM users WHERE id = ?';
SET @a = 1;
# 指定预编译语句代号,填充参数
EXECUTE stmt1 USING @a;

综上:
在涉及表名、字段、指令(SELECT、UPDATE)等需要动态替换的情况下使用 ${} ;对于查询参数变量等使用 #{}
由于最终有没有跳过 DB Server 的语句解析阶段,依赖对应 JDBC 的实现,所以优先使用 #{} 的安全意义大于性能意义。

DAO 对象方法重载问题

原题描述

最佳实践中,通常一个 Xml 映射文件,都会写一个 Dao 接口与之对应,请问,这个 Dao 接口的工作原理是什么?Dao 接口里的方法,参数不同时,方法能重载吗?

简明解答

Dao 接口,就是人们常说的 Mapper 接口,接口的全限名,就是映射文件中的 namespace 的值,接口的方法名,就是映射文件中 MappedStatement 的 id 值,接口方法内的参数,就是传递给 sql 的参数。Mapper 接口是没有实现类的,当调用接口方法时,接口全限名+方法名拼接字符串作为 key 值,可唯一定位一个 MappedStatement

Mybatis 的 Dao 接口可以有多个重载方法,但是多个接口对应的映射必须只有一个,否则启动会报错。
Dao 接口的工作原理是 JDK 动态代理,MyBatis 运行时会使用 JDK 动态代理为 Dao 接口生成代理 proxy 对象,代理对象 proxy 会拦截接口方法,转而执行 MappedStatement 所代表的 sql,然后将 sql 执行结果返回。

源码视角

答案讲了一半,还需要考虑 default method 的情况,下面分情况讨论。

Java 语法层面 JDK Proxy 是支持方法重载的,所以在 MapperInterface 里是可以写重载方法的,这是两种情况下的共性。

情况 1:在 MapperInterface 中的普通 interface method
这也是我们对 mybatis 最普遍的使用方式,在 Mapper 里面定义接口,在 xml 或者注解里面定义 sql

这种情况下,不同的重载方法由于 methodName 相同,最终会生成一致的 statementId ,会被代理到同一个 MapStatement 上,在 MapStatement 里可以通过条件判断来处理不同形式的入参。

从源码角度解读 statementId 生成规则,跟踪代理方法生成路径,找到 statementId 生成函数

// file => /org/apache/ibatis/binding/MapperProxy.java:92
private MapperMethodInvoker cachedInvoker(Method method) throws Throwable {
       // ......
    else {
        return new PlainMethodInvoker(new MapperMethod(mapperInterface, method, sqlSession.getConfiguration()));
    }
    // ......
}

// file => /org/apache/ibatis/binding/MapperMethod.java:224
public SqlCommand(Configuration configuration, Class<?> mapperInterface, Method method) {
    // ......
    MappedStatement ms = resolveMappedStatement(mapperInterface, methodName, declaringClass,
          configuration);
    // ......
}

// file => /org/apache/ibatis/binding/MapperMethod.java:254
private MappedStatement resolveMappedStatement(Class<?> mapperInterface, String methodName, Class<?> declaringClass, Configuration configuration) {
    // 实际调试过程显示 
    // "cn.plusman.mybatis.mapper.BlogMapper"      + "." + "selectBlog"
    String statementId = mapperInterface.getName() + "." + methodName;
    // 注意 statementId 里面是不带方法的参数类型的,所以到 MappedStatement 这里是不存在 Java 语法定义上的重载的。
}

情况 2:在 MapperInterface 中的 default mehtod
示例代码如下,这种情况下是可以实现完全意义上的方法重载的,mybatis 也不会将 default method 关联到特定的 MapStatement,而是直接代理执行 default method。

public interface BlogMapper {
    /**
     * 根据 id 获取博客内容
     * @param id
     * @return
     */
    Blog selectBlog(
        int id
    );

    /**
     * default method
     * @return
     */
    default Blog selectBlog() {
        // 可以自定义一些业务逻辑逻辑,也可以理解成 mybatis 的一个拓展点
        // some code

        // 可以通过调用内部其他方法,返回真实数据
        return this.selectBlog(12);

        // 单元测试的时候,也可以返回 fake 数据
        // return new Blog()
        //     .setBlogId(12)
        //     .setContent("hello world");
    }
}

源码实现相对简单,跟踪如下

// file => /org/apache/ibatis/binding/MapperProxy.java:92
private MapperMethodInvoker cachedInvoker(Method method) throws Throwable {
    // ......
    // 判断是否是 default method
    if (m.isDefault()) {
        try {
            if (privateLookupInMethod == null) {
                return new DefaultMethodInvoker(getMethodHandleJava8(method));
            } else {
                return new DefaultMethodInvoker(getMethodHandleJava9(method));
            }
        } catch (IllegalAccessException | InstantiationException | InvocationTargetException
                 | NoSuchMethodException e) {
            throw new RuntimeException(e);
        }
    }
    // ......
}


// file => /org/apache/ibatis/binding/MapperProxy.java:156
private static class DefaultMethodInvoker implements MapperMethodInvoker {
   // 直接触发代理执行,无 MapStatement 绑定逻辑
    @Override
    public Object invoke(Object proxy, Method method, Object[] args, SqlSession sqlSession) throws Throwable {
      return methodHandle.bindTo(proxy).invokeWithArguments(args);
    }
}

参考资料