MiniSpring框架学习笔记-JDBC 访问框架: MiniBatis如何将 SQL 语句配置化?

MiniSpring框架学习-JDBC 访问框架: MiniBatis如何将 SQL 语句配置化?

  • [15. MiniBatis:如何将 SQL 语句配置化?](#15. MiniBatis:如何将 SQL 语句配置化?)
    • [一、先用 MyBatis 建立直觉](#一、先用 MyBatis 建立直觉)
    • 二、先看最终使用方式
    • [三、SQL 放到 Mapper XML](#三、SQL 放到 Mapper XML)
    • [四、MapperNode:XML 中一条 SQL 的内存模型](#四、MapperNode:XML 中一条 SQL 的内存模型)
    • [五、SqlSessionFactory:启动时解析 XML](#五、SqlSessionFactory:启动时解析 XML)
      • [1. 配置入口](#1. 配置入口)
      • [2. 初始化并扫描目录](#2. 初始化并扫描目录)
      • [3. 解析 XML 并放入 Map](#3. 解析 XML 并放入 Map)
    • [六、SqlSession:按 sqlId 执行 SQL](#六、SqlSession:按 sqlId 执行 SQL)
    • 七、完整调用链
    • 八、当前实现边界

教程: https://github.com/YaleGuo/minis

极客时间: 手把手带你写一个 MiniSpring

前言:这节源教程写的不错,清晰易懂,点赞!

15. MiniBatis:如何将 SQL 语句配置化?

前面几章里,JdbcTemplate 已经帮我们收起了 JDBC 的固定流程:获取连接、创建 PreparedStatement、绑定参数、关闭资源、转换异常。

但业务代码里还有一块东西很显眼:

java 复制代码
final String sql = "select id, name, birthday from users where id = ?";

SQL 直接写在 Java 代码里可以工作,但当 SQL 变长、变复杂,或者需要多人协作维护时,Java 类会越来越乱。所以这一节模仿 MyBatis 的一个核心思路:把 SQL 放到 XML 里,Java 代码只通过一个 SQL id 去执行它。

一、先用 MyBatis 建立直觉

在真实 MyBatis 里,最基础的调用可以写成这样:

java 复制代码
try (SqlSession session = sqlSessionFactory.openSession()) {
    Blog blog = session.selectOne(
            "org.mybatis.example.BlogMapper.selectBlog", 101);
}

这段代码表面上只有两步:

text 复制代码
打开一次 SqlSession
        ↓
根据 statement id 执行一条 SQL

但它背后已经提前做过一件重要的事:MyBatis 会把配置文件和 Mapper XML 解析成内存里的元数据。比如下面这个 id:

text 复制代码
org.mybatis.example.BlogMapper.selectBlog

通常可以拆成:

text 复制代码
namespace = org.mybatis.example.BlogMapper
id        = selectBlog

然后 MyBatis 就可以用 namespace + id 找到 XML 中配置好的 SQL、参数信息和结果映射规则。

粗略地说,MyBatis 的设计逻辑是:

text 复制代码
启动阶段
    读取配置和 Mapper XML
    把每条 SQL 解析成内存对象
    用 namespace.id 建立索引

运行阶段
    业务代码传入 statement id 和参数
    框架找到对应 SQL
    绑定参数
    执行 JDBC
    把 ResultSet 映射成对象

这里有个小细节:openSession() 不是每次都重新加载 XML。XML 通常在 SqlSessionFactory 构建或初始化时已经解析好了;openSession() 更像是打开一次执行会话,让后续的 selectOne(...) 有地方执行 SQL、管理执行过程。

这么做的好处很直接:

  1. Java 代码更干净,业务方法不用塞一大段 SQL;
  2. SQL 有独立位置,长 SQL、复杂 SQL 更容易阅读和维护;
  3. SQL 可以用 namespace.id 统一管理,不容易散落在各个 Service 里;
  4. Java 层表达"我要做什么",XML 层表达"具体 SQL 怎么写"。

我们这一章不做完整 MyBatis,只先实现它里面最容易看清的一小段:SQL 配置化。

这一节实现的是教学版 MiniBatis,不是完整 MyBatis。它只完成这条主线:

text 复制代码
Mapper XML
    ↓ 启动时解析
MapperNode
    ↓ 放入 Map,key = namespace.id
SqlSession.selectOne(sqlId, args, callback)
    ↓ 找到 SQL
JdbcTemplate.query(...)

二、先看最终使用方式

现在 UserService.getUserInfo(...) 不再直接写查询 SQL,而是写一个 SQL id:

java 复制代码
package com.chenhai.jdbc.example;

import com.chenhai.beans.factory.annotation.Autowired;
import com.chenhai.batis.SqlSession;
import com.chenhai.batis.SqlSessionFactory;
import com.chenhai.jdbc.core.JdbcTemplate;

import java.sql.ResultSet;
import java.sql.Date;
import java.util.List;

/**
 * 演示业务层怎样使用 JdbcTemplate 和教学版 SqlSession。
 *
 * 本节新增的变化是:查询 SQL 可以放到 Mapper XML 中,
 * 业务代码只通过 SQL id 发起调用。
 */
public class UserService {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    /**
     * SQL 配置化入口。字段名必须和 XML 中的 bean id="sqlSessionFactory" 一致。
     */
    @Autowired
    private SqlSessionFactory sqlSessionFactory;

    public User getUserInfo(int userId) {
        String sqlId = "com.chenhai.jdbc.example.User.getUserInfo";
        SqlSession sqlSession = this.sqlSessionFactory.openSession();

        return sqlSession.selectOne(sqlId, new Object[]{userId}, statement -> {
            try (ResultSet resultSet = statement.executeQuery()) {
                return resultSet.next() ? mapUser(resultSet) : null;
            }
        });
    }

    public List<User> getUsers(int minUserId) {
        final String sql = "select id, name, birthday from users where id > ?";
        return jdbcTemplate.query(sql, new Object[]{minUserId},
                (resultSet, rowNum) -> mapUser(resultSet));
    }

    public int updateUserName(int userId, String name) {
        final String sql = "update users set name = ? where id = ?";
        return jdbcTemplate.update(sql, new Object[]{name, userId});
    }

    private User mapUser(ResultSet resultSet) throws java.sql.SQLException {
        User user = new User();
        user.setId(resultSet.getInt("id"));
        user.setName(resultSet.getString("name"));

        Date birthday = resultSet.getDate("birthday");
        if (birthday != null) {
            user.setBirthday(new java.util.Date(birthday.getTime()));
        }
        return user;
    }
}

对比一下变化:

text 复制代码
上一章:
    Java 代码直接写 SQL

这一章:
    Java 代码写 sqlId
    SQL 放在 mapper/UserMapper.xml

注意,当前教学版还没有做到"根据 Mapper 接口自动生成代理对象"。所以业务代码仍然手动调用:

java 复制代码
sqlSession.selectOne(sqlId, args, callback)

结果映射也仍然由调用者传入的 PreparedStatementCallback 完成。

三、SQL 放到 Mapper XML

项目中的 XML 文件是:

text 复制代码
src/main/resources/mapper/UserMapper.xml

内容如下:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<mapper namespace="com.chenhai.jdbc.example.User">
    <!--
        namespace + id 组成 SQL 唯一标识:
        com.chenhai.jdbc.example.User.getUserInfo

        parameterType 和 resultType 当前主要用于教学说明,真正的参数绑定和结果映射
        仍由 SqlSession 传入的 PreparedStatementCallback 完成。
    -->
    <select id="getUserInfo"
            parameterType="java.lang.Integer"
            resultType="com.chenhai.jdbc.example.User">
        select id, name, birthday
        from users
        where id = ?
    </select>
</mapper>

这里最关键的是两个属性:

属性 作用
namespace 一组 SQL 的命名空间
id 当前 SQL 在这个命名空间下的名字

两者拼起来就是 SQL id:

text 复制代码
com.chenhai.jdbc.example.User.getUserInfo

这个 id 会作为 Map 的 key。运行时业务代码传入这个 key,就能找到对应 SQL。

parameterTyperesultType 在当前版本里只是保存到 MapperNode 中,方便理解 MyBatis 的配置结构;真正的参数绑定还是 JdbcTemplate 做,结果映射还是回调做。

四、MapperNode:XML 中一条 SQL 的内存模型

XML 是文本,不适合每次查询时反复解析。更合理的方式是:容器启动时解析一次,把每条 SQL 变成 Java 对象放到内存里。

这个 Java 对象就是 MapperNode

java 复制代码
package com.chenhai.batis;

/**
 * Mapper XML 中一条 SQL 语句的内存模型。
 *
 * MyBatis 不会在业务调用时才去读 XML,而是在启动阶段把 XML 解析成
 * 类似 MapperNode 的对象并放入 Map。
 */
public class MapperNode {

    private String namespace;
    private String id;
    private String parameterType;
    private String resultType;
    private String sql;
    private String parameter;

    public String getStatementId() {
        return this.namespace + "." + this.id;
    }

    @Override
    public String toString() {
        return getStatementId() + " : " + this.sql;
    }

    // 省略普通 getter/setter
}

它对应 XML 里的这几块:

text 复制代码
<mapper namespace="...">
    <select id="..."
            parameterType="..."
            resultType="...">
        SQL 文本
    </select>
</mapper>

可以理解成:

text 复制代码
Mapper XML          MapperNode
----------          ----------
namespace     --->  namespace
select.id     --->  id
parameterType --->  parameterType
resultType    --->  resultType
SQL 文本       --->  sql

五、SqlSessionFactory:启动时解析 XML

SqlSessionFactory 做两件事:

java 复制代码
package com.chenhai.batis;

/**
 * Factory 负责保存 Mapper 元数据并创建 SqlSession。
 */
public interface SqlSessionFactory {

    SqlSession openSession();

    MapperNode getMapperNode(String name);
}

默认实现是 DefaultSqlSessionFactory。它在容器启动时扫描 mapperLocations 指定的目录,解析里面的 XML。

1. 配置入口

applicationContext.xml 中这样配置:

xml 复制代码
<!--
    MiniBatis 教程配置。

    1. mapperLocations 指向 classpath 下的 mapper 目录。
    2. sqlSessionFactory 启动时解析 Mapper XML,把 namespace.id 映射到 SQL。
    3. 业务代码只传 SQL id;真正执行仍然委托给 jdbcTemplate。
-->
<bean id="sqlSessionFactory"
      class="com.chenhai.batis.DefaultSqlSessionFactory"
      init-method="init">
    <property type="String" name="mapperLocations" value="mapper"/>
</bean>

init-method="init" 表示 MiniSpring 创建完这个 Bean、注入完属性后,会调用 init()

2. 初始化并扫描目录

java 复制代码
public void init() {
    if (this.mapperLocations == null || this.mapperLocations.trim().isEmpty()) {
        throw new IllegalStateException("mapperLocations must be configured");
    }
    scanLocation(this.mapperLocations);
}

private void scanLocation(String location) {
    URL locationUrl = getClass().getClassLoader().getResource(location);
    if (locationUrl == null) {
        throw new IllegalStateException("Mapper location not found: " + location);
    }

    File dir = new File(decodePath(locationUrl));
    File[] files = dir.listFiles();
    if (files == null) {
        return;
    }

    for (File file : files) {
        String childLocation = location + "/" + file.getName();
        if (file.isDirectory()) {
            scanLocation(childLocation);
        } else if (file.getName().endsWith(".xml")) {
            buildMapperNodes(childLocation);
        }
    }
}

这段逻辑和前面 IoC 扫描配置文件有点像:先找到 classpath 下的 mapper 目录,再递归处理里面的 XML 文件。

3. 解析 XML 并放入 Map

java 复制代码
private Map<String, MapperNode> buildMapperNodes(String filePath) {
    SAXReader saxReader = new SAXReader();
    URL xmlPath = getClass().getClassLoader().getResource(filePath);
    if (xmlPath == null) {
        throw new IllegalStateException("Mapper XML not found: " + filePath);
    }

    try {
        Document document = saxReader.read(xmlPath);
        Element rootElement = document.getRootElement();
        String namespace = rootElement.attributeValue("namespace");
        if (namespace == null || namespace.trim().isEmpty()) {
            throw new IllegalStateException(
                    "Mapper namespace must not be empty: " + filePath);
        }

        Iterator<?> nodes = rootElement.elementIterator();
        while (nodes.hasNext()) {
            Element node = (Element) nodes.next();
            MapperNode mapperNode = buildMapperNode(namespace, node);
            this.mapperNodeMap.put(mapperNode.getStatementId(), mapperNode);
        }
        return this.mapperNodeMap;
    } catch (Exception e) {
        throw new IllegalStateException("Parse mapper XML failed: " + filePath, e);
    }
}

private MapperNode buildMapperNode(String namespace, Element node) {
    String id = node.attributeValue("id");
    if (id == null || id.trim().isEmpty()) {
        throw new IllegalStateException(
                "Mapper statement id must not be empty: " + namespace);
    }

    MapperNode mapperNode = new MapperNode();
    mapperNode.setNamespace(namespace);
    mapperNode.setId(id);
    mapperNode.setParameterType(node.attributeValue("parameterType"));
    mapperNode.setResultType(node.attributeValue("resultType"));
    mapperNode.setSql(node.getTextTrim());
    mapperNode.setParameter("");
    return mapperNode;
}

解析完成后,内存里大概是这样:

text 复制代码
mapperNodeMap
    |
    +-- key:
    |       com.chenhai.jdbc.example.User.getUserInfo
    |
    +-- value:
            MapperNode {
                namespace = "com.chenhai.jdbc.example.User"
                id = "getUserInfo"
                parameterType = "java.lang.Integer"
                resultType = "com.chenhai.jdbc.example.User"
                sql = "select id, name, birthday from users where id = ?"
            }

业务调用时不用再读 XML,直接从这个 Map 里拿就行。

六、SqlSession:按 sqlId 执行 SQL

SqlSessionFactory 负责保存 SQL 配置,SqlSession 负责面向业务代码执行 SQL。

接口很小:

java 复制代码
package com.chenhai.batis;

import com.chenhai.jdbc.core.JdbcTemplate;
import com.chenhai.jdbc.core.PreparedStatementCallback;

/**
 * 当前教学版只实现 selectOne,并保留 PreparedStatementCallback 作为结果处理回调。
 */
public interface SqlSession {

    void setJdbcTemplate(JdbcTemplate jdbcTemplate);

    void setSqlSessionFactory(SqlSessionFactory sqlSessionFactory);

    <T> T selectOne(String sqlId,
                    Object[] args,
                    PreparedStatementCallback<T> callback);
}

创建 SqlSession 时,把两个依赖塞进去:

java 复制代码
@Override
public SqlSession openSession() {
    DefaultSqlSession sqlSession = new DefaultSqlSession();
    sqlSession.setJdbcTemplate(this.jdbcTemplate);
    sqlSession.setSqlSessionFactory(this);
    return sqlSession;
}

这两个依赖分别负责:

依赖 作用
SqlSessionFactory 根据 sqlId 找到 MapperNode
JdbcTemplate 执行 SQL、绑定参数、关闭资源

DefaultSqlSession.selectOne(...) 的核心代码是:

java 复制代码
@Override
public <T> T selectOne(String sqlId,
                       Object[] args,
                       PreparedStatementCallback<T> callback) {
    if (this.jdbcTemplate == null) {
        throw new IllegalStateException("JdbcTemplate must be configured");
    }
    if (this.sqlSessionFactory == null) {
        throw new IllegalStateException("SqlSessionFactory must be configured");
    }

    MapperNode mapperNode = this.sqlSessionFactory.getMapperNode(sqlId);
    if (mapperNode == null) {
        throw new IllegalArgumentException(
                "No mapped SQL statement found: " + sqlId);
    }

    return this.jdbcTemplate.query(mapperNode.getSql(), args, callback);
}

所以 selectOne(...) 并没有重新实现一套 JDBC。它只是多做了一步:

text 复制代码
sqlId
    ↓
MapperNode
    ↓
SQL 字符串
    ↓
JdbcTemplate.query(...)

七、完整调用链

再回到业务代码:

java 复制代码
public User getUserInfo(int userId) {
    String sqlId = "com.chenhai.jdbc.example.User.getUserInfo";
    SqlSession sqlSession = this.sqlSessionFactory.openSession();

    return sqlSession.selectOne(sqlId, new Object[]{userId}, statement -> {
        try (ResultSet resultSet = statement.executeQuery()) {
            return resultSet.next() ? mapUser(resultSet) : null;
        }
    });
}

实际执行过程是:

text 复制代码
UserService.getUserInfo(1)
        ↓
sqlSessionFactory.openSession()
        ↓
创建 DefaultSqlSession
        ↓
注入 JdbcTemplate 和 SqlSessionFactory
        ↓
sqlSession.selectOne(sqlId, args, callback)
        ↓
sqlSessionFactory.getMapperNode(sqlId)
        ↓
取出 MapperNode.sql
        ↓
jdbcTemplate.query(sql, args, callback)
        ↓
ArgumentPreparedStatementSetter 绑定参数
        ↓
PreparedStatement.executeQuery()
        ↓
callback 把 ResultSet 转成 User

换成组件关系图就是:

text 复制代码
UserService
    |
    | sqlId + args + callback
    v
SqlSession
    |
    | 根据 sqlId 找 SQL
    v
SqlSessionFactory
    |
    | mapperNodeMap
    v
MapperNode
    |
    | sql
    v
JdbcTemplate
    |
    | JDBC 固定流程
    v
DataSource / Database

八、当前实现边界

这一章只实现了 SQL 配置化的第一步,重点是把"SQL 文本从 Java 代码移到 XML"讲清楚。

当前还没有实现:

  • Mapper 接口代理;
  • #{id} 这种命名参数解析;
  • 自动根据 resultType 反射封装对象;
  • <insert><update><delete> 的独立语义;
  • 动态 SQL,例如 <if><where><foreach>
  • 一级缓存、二级缓存;
  • 事务和真实 MyBatis 的 SqlSession 生命周期。

所以当前版本里:

text 复制代码
parameterType / resultType
    只是被解析并保存,还没有真正驱动参数绑定和结果映射。

PreparedStatementCallback
    仍然由业务代码提供,用来执行 SQL 和处理 ResultSet。

JdbcTemplate
    仍然是最终执行 JDBC 的核心。

对应测试主要验证两件事:

  1. DefaultSqlSessionFactory 能把 mapper/UserMapper.xml 解析成 MapperNode
  2. SqlSession.selectOne(...) 能通过 SQL id 找到 SQL,并委托 JdbcTemplate 执行。

这一节可以总结成一句话:

MiniBatis 先不急着做完整 ORM,而是先把 SQL 配置化:启动时解析 XML,运行时按 namespace.id 找 SQL,最后仍然交给 JdbcTemplate 执行。