MiniSpring框架学习-JDBC 访问框架: MiniBatis如何将 SQL 语句配置化?
- 15. MiniBatis:如何将 SQL 语句配置化?
- 一、先用 MyBatis 建立直觉
- 二、先看最终使用方式
- 三、SQL 放到 Mapper XML
- 四、MapperNode:XML 中一条 SQL 的内存模型
- 五、SqlSessionFactory:启动时解析 XML
- 1. 配置入口
- 2. 初始化并扫描目录
- 3. 解析 XML 并放入 Map
- 六、SqlSession:按 sqlId 执行 SQL
- 七、完整调用链
- 八、当前实现边界
教程:https://github.com/YaleGuo/minis
极客时间:手把手带你写一个 MiniSpring
前言:这节源教程写的不错,清晰易懂,点赞!
15. MiniBatis:如何将 SQL 语句配置化?
前面几章里,JdbcTemplate已经帮我们收起了 JDBC 的固定流程:获取连接、创建PreparedStatement、绑定参数、关闭资源、转换异常。
但业务代码里还有一块东西很显眼:
finalStringsql="select id, name, birthday from users where id = ?";SQL 直接写在 Java 代码里可以工作,但当 SQL 变长、变复杂,或者需要多人协作维护时,Java 类会越来越乱。所以这一节模仿 MyBatis 的一个核心思路:把 SQL 放到 XML 里,Java 代码只通过一个 SQL id 去执行它。
一、先用 MyBatis 建立直觉
在真实 MyBatis 里,最基础的调用可以写成这样:
try(SqlSessionsession=sqlSessionFactory.openSession()){Blogblog=session.selectOne("org.mybatis.example.BlogMapper.selectBlog",101);}这段代码表面上只有两步:
打开一次 SqlSession ↓ 根据 statement id 执行一条 SQL但它背后已经提前做过一件重要的事:MyBatis 会把配置文件和 Mapper XML 解析成内存里的元数据。比如下面这个 id:
org.mybatis.example.BlogMapper.selectBlog通常可以拆成:
namespace = org.mybatis.example.BlogMapper id = selectBlog然后 MyBatis 就可以用namespace + id找到 XML 中配置好的 SQL、参数信息和结果映射规则。
粗略地说,MyBatis 的设计逻辑是:
启动阶段 读取配置和 Mapper XML 把每条 SQL 解析成内存对象 用 namespace.id 建立索引 运行阶段 业务代码传入 statement id 和参数 框架找到对应 SQL 绑定参数 执行 JDBC 把 ResultSet 映射成对象这里有个小细节:openSession()不是每次都重新加载 XML。XML 通常在SqlSessionFactory构建或初始化时已经解析好了;openSession()更像是打开一次执行会话,让后续的selectOne(...)有地方执行 SQL、管理执行过程。
这么做的好处很直接:
- Java 代码更干净,业务方法不用塞一大段 SQL;
- SQL 有独立位置,长 SQL、复杂 SQL 更容易阅读和维护;
- SQL 可以用
namespace.id统一管理,不容易散落在各个 Service 里; - Java 层表达“我要做什么”,XML 层表达“具体 SQL 怎么写”。
我们这一章不做完整 MyBatis,只先实现它里面最容易看清的一小段:SQL 配置化。
这一节实现的是教学版 MiniBatis,不是完整 MyBatis。它只完成这条主线:
Mapper XML ↓ 启动时解析 MapperNode ↓ 放入 Map,key = namespace.id SqlSession.selectOne(sqlId, args, callback) ↓ 找到 SQL JdbcTemplate.query(...)二、先看最终使用方式
现在UserService.getUserInfo(...)不再直接写查询 SQL,而是写一个 SQL id:
packagecom.chenhai.jdbc.example;importcom.chenhai.beans.factory.annotation.Autowired;importcom.chenhai.batis.SqlSession;importcom.chenhai.batis.SqlSessionFactory;importcom.chenhai.jdbc.core.JdbcTemplate;importjava.sql.ResultSet;importjava.sql.Date;importjava.util.List;/** * 演示业务层怎样使用 JdbcTemplate 和教学版 SqlSession。 * * 本节新增的变化是:查询 SQL 可以放到 Mapper XML 中, * 业务代码只通过 SQL id 发起调用。 */publicclassUserService{@AutowiredprivateJdbcTemplatejdbcTemplate;/** * SQL 配置化入口。字段名必须和 XML 中的 bean id="sqlSessionFactory" 一致。 */@AutowiredprivateSqlSessionFactorysqlSessionFactory;publicUsergetUserInfo(intuserId){StringsqlId="com.chenhai.jdbc.example.User.getUserInfo";SqlSessionsqlSession=this.sqlSessionFactory.openSession();returnsqlSession.selectOne(sqlId,newObject[]{userId},statement->{try(ResultSetresultSet=statement.executeQuery()){returnresultSet.next()?mapUser(resultSet):null;}});}publicList<User>getUsers(intminUserId){finalStringsql="select id, name, birthday from users where id > ?";returnjdbcTemplate.query(sql,newObject[]{minUserId},(resultSet,rowNum)->mapUser(resultSet));}publicintupdateUserName(intuserId,Stringname){finalStringsql="update users set name = ? where id = ?";returnjdbcTemplate.update(sql,newObject[]{name,userId});}privateUsermapUser(ResultSetresultSet)throwsjava.sql.SQLException{Useruser=newUser();user.setId(resultSet.getInt("id"));user.setName(resultSet.getString("name"));Datebirthday=resultSet.getDate("birthday");if(birthday!=null){user.setBirthday(newjava.util.Date(birthday.getTime()));}returnuser;}}对比一下变化:
上一章: Java 代码直接写 SQL 这一章: Java 代码写 sqlId SQL 放在 mapper/UserMapper.xml注意,当前教学版还没有做到“根据 Mapper 接口自动生成代理对象”。所以业务代码仍然手动调用:
sqlSession.selectOne(sqlId,args,callback)结果映射也仍然由调用者传入的PreparedStatementCallback完成。
三、SQL 放到 Mapper XML
项目中的 XML 文件是:
src/main/resources/mapper/UserMapper.xml内容如下:
<?xml version="1.0" encoding="UTF-8"?><mappernamespace="com.chenhai.jdbc.example.User"><!-- namespace + id 组成 SQL 唯一标识: com.chenhai.jdbc.example.User.getUserInfo parameterType 和 resultType 当前主要用于教学说明,真正的参数绑定和结果映射 仍由 SqlSession 传入的 PreparedStatementCallback 完成。 --><selectid="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:
com.chenhai.jdbc.example.User.getUserInfo这个 id 会作为 Map 的 key。运行时业务代码传入这个 key,就能找到对应 SQL。
parameterType和resultType在当前版本里只是保存到MapperNode中,方便理解 MyBatis 的配置结构;真正的参数绑定还是JdbcTemplate做,结果映射还是回调做。
四、MapperNode:XML 中一条 SQL 的内存模型
XML 是文本,不适合每次查询时反复解析。更合理的方式是:容器启动时解析一次,把每条 SQL 变成 Java 对象放到内存里。
这个 Java 对象就是MapperNode:
packagecom.chenhai.batis;/** * Mapper XML 中一条 SQL 语句的内存模型。 * * MyBatis 不会在业务调用时才去读 XML,而是在启动阶段把 XML 解析成 * 类似 MapperNode 的对象并放入 Map。 */publicclassMapperNode{privateStringnamespace;privateStringid;privateStringparameterType;privateStringresultType;privateStringsql;privateStringparameter;publicStringgetStatementId(){returnthis.namespace+"."+this.id;}@OverridepublicStringtoString(){returngetStatementId()+" : "+this.sql;}// 省略普通 getter/setter}它对应 XML 里的这几块:
<mapper namespace="..."> <select id="..." parameterType="..." resultType="..."> SQL 文本 </select> </mapper>可以理解成:
Mapper XML MapperNode ---------- ---------- namespace ---> namespace select.id ---> id parameterType ---> parameterType resultType ---> resultType SQL 文本 ---> sql五、SqlSessionFactory:启动时解析 XML
SqlSessionFactory做两件事:
packagecom.chenhai.batis;/** * Factory 负责保存 Mapper 元数据并创建 SqlSession。 */publicinterfaceSqlSessionFactory{SqlSessionopenSession();MapperNodegetMapperNode(Stringname);}默认实现是DefaultSqlSessionFactory。它在容器启动时扫描mapperLocations指定的目录,解析里面的 XML。
1. 配置入口
applicationContext.xml中这样配置:
<!-- MiniBatis 教程配置。 1. mapperLocations 指向 classpath 下的 mapper 目录。 2. sqlSessionFactory 启动时解析 Mapper XML,把 namespace.id 映射到 SQL。 3. 业务代码只传 SQL id;真正执行仍然委托给 jdbcTemplate。 --><beanid="sqlSessionFactory"class="com.chenhai.batis.DefaultSqlSessionFactory"init-method="init"><propertytype="String"name="mapperLocations"value="mapper"/></bean>init-method="init"表示 MiniSpring 创建完这个 Bean、注入完属性后,会调用init()。
2. 初始化并扫描目录
publicvoidinit(){if(this.mapperLocations==null||this.mapperLocations.trim().isEmpty()){thrownewIllegalStateException("mapperLocations must be configured");}scanLocation(this.mapperLocations);}privatevoidscanLocation(Stringlocation){URLlocationUrl=getClass().getClassLoader().getResource(location);if(locationUrl==null){thrownewIllegalStateException("Mapper location not found: "+location);}Filedir=newFile(decodePath(locationUrl));File[]files=dir.listFiles();if(files==null){return;}for(Filefile:files){StringchildLocation=location+"/"+file.getName();if(file.isDirectory()){scanLocation(childLocation);}elseif(file.getName().endsWith(".xml")){buildMapperNodes(childLocation);}}}这段逻辑和前面 IoC 扫描配置文件有点像:先找到 classpath 下的mapper目录,再递归处理里面的 XML 文件。
3. 解析 XML 并放入 Map
privateMap<String,MapperNode>buildMapperNodes(StringfilePath){SAXReadersaxReader=newSAXReader();URLxmlPath=getClass().getClassLoader().getResource(filePath);if(xmlPath==null){thrownewIllegalStateException("Mapper XML not found: "+filePath);}try{Documentdocument=saxReader.read(xmlPath);ElementrootElement=document.getRootElement();Stringnamespace=rootElement.attributeValue("namespace");if(namespace==null||namespace.trim().isEmpty()){thrownewIllegalStateException("Mapper namespace must not be empty: "+filePath);}Iterator<?>nodes=rootElement.elementIterator();while(nodes.hasNext()){Elementnode=(Element)nodes.next();MapperNodemapperNode=buildMapperNode(namespace,node);this.mapperNodeMap.put(mapperNode.getStatementId(),mapperNode);}returnthis.mapperNodeMap;}catch(Exceptione){thrownewIllegalStateException("Parse mapper XML failed: "+filePath,e);}}privateMapperNodebuildMapperNode(Stringnamespace,Elementnode){Stringid=node.attributeValue("id");if(id==null||id.trim().isEmpty()){thrownewIllegalStateException("Mapper statement id must not be empty: "+namespace);}MapperNodemapperNode=newMapperNode();mapperNode.setNamespace(namespace);mapperNode.setId(id);mapperNode.setParameterType(node.attributeValue("parameterType"));mapperNode.setResultType(node.attributeValue("resultType"));mapperNode.setSql(node.getTextTrim());mapperNode.setParameter("");returnmapperNode;}解析完成后,内存里大概是这样:
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。
接口很小:
packagecom.chenhai.batis;importcom.chenhai.jdbc.core.JdbcTemplate;importcom.chenhai.jdbc.core.PreparedStatementCallback;/** * 当前教学版只实现 selectOne,并保留 PreparedStatementCallback 作为结果处理回调。 */publicinterfaceSqlSession{voidsetJdbcTemplate(JdbcTemplatejdbcTemplate);voidsetSqlSessionFactory(SqlSessionFactorysqlSessionFactory);<T>TselectOne(StringsqlId,Object[]args,PreparedStatementCallback<T>callback);}创建SqlSession时,把两个依赖塞进去:
@OverridepublicSqlSessionopenSession(){DefaultSqlSessionsqlSession=newDefaultSqlSession();sqlSession.setJdbcTemplate(this.jdbcTemplate);sqlSession.setSqlSessionFactory(this);returnsqlSession;}这两个依赖分别负责:
| 依赖 | 作用 |
|---|---|
SqlSessionFactory | 根据sqlId找到MapperNode |
JdbcTemplate | 执行 SQL、绑定参数、关闭资源 |
DefaultSqlSession.selectOne(...)的核心代码是:
@Overridepublic<T>TselectOne(StringsqlId,Object[]args,PreparedStatementCallback<T>callback){if(this.jdbcTemplate==null){thrownewIllegalStateException("JdbcTemplate must be configured");}if(this.sqlSessionFactory==null){thrownewIllegalStateException("SqlSessionFactory must be configured");}MapperNodemapperNode=this.sqlSessionFactory.getMapperNode(sqlId);if(mapperNode==null){thrownewIllegalArgumentException("No mapped SQL statement found: "+sqlId);}returnthis.jdbcTemplate.query(mapperNode.getSql(),args,callback);}所以selectOne(...)并没有重新实现一套 JDBC。它只是多做了一步:
sqlId ↓ MapperNode ↓ SQL 字符串 ↓ JdbcTemplate.query(...)七、完整调用链
再回到业务代码:
publicUsergetUserInfo(intuserId){StringsqlId="com.chenhai.jdbc.example.User.getUserInfo";SqlSessionsqlSession=this.sqlSessionFactory.openSession();returnsqlSession.selectOne(sqlId,newObject[]{userId},statement->{try(ResultSetresultSet=statement.executeQuery()){returnresultSet.next()?mapUser(resultSet):null;}});}实际执行过程是:
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换成组件关系图就是:
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生命周期。
所以当前版本里:
parameterType / resultType 只是被解析并保存,还没有真正驱动参数绑定和结果映射。 PreparedStatementCallback 仍然由业务代码提供,用来执行 SQL 和处理 ResultSet。 JdbcTemplate 仍然是最终执行 JDBC 的核心。对应测试主要验证两件事:
DefaultSqlSessionFactory能把mapper/UserMapper.xml解析成MapperNode;SqlSession.selectOne(...)能通过 SQL id 找到 SQL,并委托JdbcTemplate执行。
这一节可以总结成一句话:
MiniBatis 先不急着做完整 ORM,而是先把 SQL 配置化:启动时解析 XML,运行时按
namespace.id找 SQL,最后仍然交给JdbcTemplate执行。