MYBATIS
关于Mybatis框架
Mybatis框架解决了数据库编程相关的问题,主要是简化了数据库编程。
当使用Mybatis框架实现数据库编程时,只需要:
- 定义数据操作功能的抽象方法(此抽象方法必须在接口中)
- 配置以上抽象方法映射的SQL语句
Mybatis框架在实现过程中,会自动生成各接口的代理对象,所以,开发人员并不需要关注接口的实现问题。
使用Mybatis框架
在Spring Boot项目中,当需要使用Mybatis框架实现数据库编程时,需要添加:
mybatis-spring-boot-starter
- 数据库的依赖,例如
mysql-connector-java
所以,在pom.xml
中添加:
<!-- Mybatis框架 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
<!-- MySQL依赖项,仅运行时需要 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
由于添加以上依赖后,Spring Boot在启动时就会读取连接数据库的配置信息,如果未配置,则启动报错且失败,需要在src/main/resources
下的application.properties
中添加必要的配置:
spring.datasource.url=jdbc:mysql://localhast:8888
提示:以上配置中,属性名称是固定,以上示例值是错误值,但是,启动Spring Boot只是加载以上配置,并不会执行连接,所以,配置值错误并不影响启动项目。
连接数据库的配置
在Spring Boot项目中,src/main/resources
下的application.properties
是默认的配置文件,项目在启动时,Spring Boot会自动从此文件中读取相关的配置信息。
在许多配置过程中,需要在application.properties
中的配置的属性的名称是固定的!
在配置数据库的连接信息时,至少需要配置spring.datasource.url
、spring.datasource.username
、spring.datasource.password
这3个属性,分别表示连接数据库的URL、登录数据库的用户名、登录数据库的密码
spring.datasource.url=jdbc:mysql://localhost:3306/xxx?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=root
为了检验配置值是否正确,可以在src/test/java
下默认的包下创建DatabaseConnectionTests
测试类,在测试类中编写测试方法,以尝试连接数据库,即可检验:
package com.unkeer.csmall.server;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import javax.sql.DataSource;
// 测试类必须:
// 1. 在组件扫描的包下(默认的包及其子孙包)
// 2. 只有添加了@SpringBootTest注解才会加载整个项目的运行环境(包括Spring、读取配置等)
@SpringBootTest
public class DatabaseConnectionTests {
// Spring Boot自动读取application.properties的配置,并创建了数据源对象
@Autowired
DataSource dataSource;
// 连接数据库,可用于检查application.properties中的连接参数值是否正确
@Test
public void testConnection() throws Exception {
// 获取与MySQL的连接,此方法的调用会实质的连接数据库
dataSource.getConnection();
System.out.println("当你看到这句话时,表示在application.properties中的数据库连接参数的值是正确的");
}
}
关于设计数据表
关于id
阿里巴巴的建议是:每张表都应该有id
字段,且是bigint unsigned
类型,其中,bigint
对应Java中的long
类型,unsigned
表示“无符号位”,将使得此字段值不会出现负数,且取值区间是原正数的2倍……以tinyint
为例,没有添加unsigned
时,取值区间是[-128, 127],添加unsigned
以后,取值区间是[0, 255]。
当把id
的类型设置为bigint
时,理论上id值足够使用,即使不添加unsigned
也不会出现id值不够用的情况,但仍推荐添加,其目的是为了表现语义。
关于编码
应该在创建表时指定编码,创建库时可以不指定编码。
在MySQL / MariaDB强烈推荐使用utf8mb4
。
关于字符串的字段类型
如果某个字符串类型的字段的值的长度变化可能较大,应该使用varchar
类型,例如用户名,如果某个字符串类型的字段的值的长度变化不大,应该使用char
类型。
注意:某些数据可能是纯数字的,但并不具备算术运算含义,也应该使用字符串类型,不要使用数值类型。
在使用varchar
时,指定的长度一定是“大于必要长度”的标准,例如,现行的标准是“用户名最多15个字符”,则建议设计为varchar(25)
或比25
更大的值,但是,也不要过于夸张,避免影响语义。
使用Mybatis时定义的抽象方法
使用Mybatis时,定义的抽象方法都必须在接口中,通常,接口会使用Mapper
作为名称的最后一个单词,例如命令为BrandMapper
等。
关于抽象方法的声明原则:
- 返回值类型:如果需要执行的SQL是增、删、改类型的,统一使用
int
作为返回值类型,表示“受影响的行数”,其实也可以使用void
,但并不推荐,如果需要执行的SQL是查询类型的,如果查询最多只返回1个结果,则只需要保证返回值类型可以装得下所需的查询结果即可,如果查询返回的结果可能超过1条,则必须使用List
集合进行封装,且集合的元素类型依然只需要保证可以装得下所需的查询结果即可 - 方法名称:自定义
- 获取单个对象的方法用 get 做前缀
- 获取多个对象的方法用 list 做前缀
- 获取统计值的方法用 count 做前缀
- 插入的方法用 save/insert 做前缀
- 删除的方法用 remove/delete 做前缀
- 修改的方法用 update 做前缀
- 参数列表:如果需要执行的SQL语句中的参数数量较多,推荐将多个参数封装到自定义类中
关于@Mapper和@MapperScan
Mybatis框架只要求开发人员编写接口和抽象方法,不需要开发人员编写实现类,是因为Mybatis会通过代理模式自动生成接口的实现对象,但是,它需要明确哪些接口是需要生成代理对象的。
可以在各接口上添加@Mapper
注解,在启动项目时,Mybatis会对整个项目进行扫描,对于已经添加此注解的接口,就会生成代理对象。
也可以在配置类上添加@MapperScan
注解,用于指定各接口所在的包,则Mybatis会扫描此包及其子孙包下的所有接口,并生成这些接口的代理对象。
关于@Mapper
和@MapperScan
这2种注解,只需要选择其中1种使用即可,通常推荐@MapperScan
。
注意:使用@MapperScan
时,一定只指向Mapper接口所在的包,并确保此包下无其它接口!
提示:Mybatis框架的@MapperScan
,与Spring框架的@ComponentScan
没有任何关系,且不会互相影响!
使用Mybatis实现插入数据
以实现“插入品牌数据”为例,需要执行的SQL语句大致是:
insert into pms_brand (name, pinyin, logo, description, keywords, sort, sales, product_count, comment_count, positive_comment_count, enable, gmt_create, gmt_modified) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
先在项目的默认包下创建pojo.entity.Brand
类,类中的属性应该与数据表对应:
public class Brand implements Serializable {
private Long id;
private String name;
private String pinyin;
private String logo;
private String description;
private String keywords;
private Integer sort;
private Integer sales;
private Integer productCount;
private Integer commentCount;
private Integer positiveCommentCount;
private Integer enable;
private LocalDateTime gmtCreate;
private LocalDateTime gmtModified;
// 按照POJO规范补充后续代码
}
接下来,准备接口和抽象方法,在项目的默认包下创建mapper.BrandMapper
接口,并在接口中添加抽象方法:
package com.unkeer.csmall.server.mapper;
public interface BrandMapper {
/**
* 插入品牌数据
* @param brand 品牌数据
* @return 受影响的行数,成功插入数据时,将返回1
*/
int insert(Brand brand);
}
关于SQL语句,可以使用@Insert
等注解进行配置,但不推荐!
推荐使用XML文件配置SQL语句!
关于此文件的配置:
- 根节名必须是
<mapper>
- 根节点必须配置
namespace
属性,取值为对应的接口的全限定名 - 在根节点内部,根据需要执行的SQL语句的类型不同,使用
<insert>
、<delete>
、<update>
、<select>
节点 - 在
<insert>
等节点上,必须配置id
属性,取值为抽象方法的名称(不包含括号及参数) - 在
<insert>
等节点内部,配置SQL语句,SQL语句不需要使用分号结束
例如配置为:
<?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">
<mapper namespace="com.unkeer.csmall.server.mapper.BrandMapper">
<!-- int insert(Brand brand); -->
<insert id="insert">
insert into pms_brand (
name, pinyin, logo, description, keywords,
sort, sales, product_count, comment_count, positive_comment_count,
enable, gmt_create, gmt_modified
) values (
#{name}, #{pinyin}, #{logo}, #{description}, #{keywords},
#{sort}, #{sales}, #{productCount}, #{commentCount}, #{positiveCommentCount},
#{enable}, #{gmtCreate}, #{gmtModified}
)
</insert>
</mapper>
最后,还需要补充一个配置,用于告诉Mybatis框架这类XML文件的位置!在application.properties
中添加:
mybatis.mapper-locations=classpath:mapper/*.xml
另外,在插入数据时,还可以配置,得到自动编号的ID值,具体做法是在<insert>
节点上添加配置:
<!-- int insert(Brand brand); -->
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
暂不关心此处的SQL语句
</insert>
练习
目标:实现向pms_album
表中插入数据,并要求可以获取自动编号的id。
开发步骤:
- 在
entity
包创建Album
实体类,类中的属性与pms_album
表保持一致,且符合POJO规范 - 在
mapper
包中创建AlbumMapper
接口 - 在
AlbumMapper
接口中添加抽象方法:int insert(Album album);
(记得添加注释) - 在
src/main/resources
的mapper
文件夹下,通过复制粘贴得到AlbumMapper.xml
文件 - 配置
AlbumMapper.xml
中的根节点的namespace
- 在
AlbumMapper.xml
中,在根节点内部添加<insert>
节点,并配置id
、useGeneratedKeys
、keyProperty
属性,在<insert>
节点内部配置SQL语句 - 在
src/test/java
下的cn.tedu.csmall.server.mapper
包(你的包名可能不同)下创建AlbumMapperTests
测试类,在类中自动装配AlbumMapper
对象,并编写、执行测试方法
使用Mybatis实现删除数据
目标:根据id删除某个品牌
需要执行的SQL语句大致是:
delete from pms_brand where id=?
在BrandMapper
接口中添加抽象方法:
/**
* 根据品牌id,删除品牌数据
* @param id 期望删除的品牌数据的id
* @return 受影响的行数,当删除成功时,将返回1,如果无此id对应的数据,将返回0
*/
int deleteById(Long id);
在BrandMapper.xml
中配置SQL:
<!-- int deleteById(Long id); -->
<delete id="deleteById">
delete from pms_brand where id=#{id}
</delete>
在BrandMapperTests
中编写并执行测试:
@Test
public void testDeleteById() {
Long id = 1L;
int rows = mapper.deleteById(id);
System.out.println("删除完成,受影响的行数=" + rows);
}
练习
目标:实现根据id删除pms_album
表中的数据。
使用Mybatis实现修改数据
目标:实现根据id修改pms_brand
表中某条数据的name
字段值。
需要执行的SQL语句大致是:
update pms_brand set name=? where id=?
在BrandMapper
接口中添加抽象方法:
/**
* 根据id修改品牌的名称
* @param id 品牌id
* @param name 新的品牌名称
* @return 受影响的行数,当修改成功时,将返回1,如果无此id对应的数据,将返回0
*/
int updateNameById(@Param("id") Long id, @Param("name") String name);
在BrandMapper.xml
中配置SQL:
<!-- int updateNameById(@Param("id") Long id, @Param("name") String name); -->
<update id="updateNameById">
update pms_brand set name=#{name} where id=#{id}
</update>
在BrandMapperTests
中编写并执行测试:
@Test
public void testUpdateNameById() {
Long id = 3L;
String name = "白萝卜";
int rows = mapper.updateNameById(id, name);
System.out.println("修改品牌名称完成,受影响的行数=" + rows);
}
练习
目标:实现根据id修改pms_album
表中某条数据的name
字段值。
使用Mybatis实现批量删除数据
在Mybatis中,有“动态SQL”的机制,它允许根据调用方法时传入的参数值不同,来生成不同的SQL语句。
目标:根据若干个id
一次性删除多个品牌。
需要执行的SQL语句大致是:
delete from pms_brand where id=? or id=? or id=?;
或者:
delete from pms_brand where id in (?, ?, ?);
注意:以上SQL语句中的id
的数量是不确定的。
在BrandMapper
接口中,抽象方法可以是:
int deleteByIds(Long... ids); // 注意:可变参数在处理时,本质上就是数组
或者:
int deleteByIds(Long[] ids);
或者:
int deleteByIds(List<Long> ids);
在BrandMapper.xml
中配置SQL语句:
<!-- int deleteByIds(Long... ids); -->
<delete id="deleteByIds">
delete from pms_brand where id in (
<foreach collection="array" item="id" separator=",">
#{id}
</foreach>
)
</delete>
由于需要对参数ids
(若干个id
)进行遍历,需要使用到动态SQL中的<foreach>
节点,此节点可以对数组或集合进行遍历!关于<foreach>
的配置:
collection
属性:表示被遍历的参数对象,当抽象方法的参数只有1个,且没有添加@Param
注解时,当参数值的类型是数组时,此属性值为array
,当参数值的类型是List
时,此属性值为list
;否则,此属性值为@Param
注解中的参数值item
属性:表示被遍历到的元素的名称,是自定义的名称,在<foreach>
内部,使用#{}
格式的占位符时,也使用此属性来表示每个元素separator
属性:表示遍历过程中各元素值之间的分隔符号
最后,在BrandMapperTests
中编写并执行测试:
@Test
public void testDeleteByIds() {
int rows = mapper.deleteByIds(1L, 3L, 5L, 7L, 9L);
System.out.println("批量删除完成,受影响的行数=" + rows);
}
使用Mybatis实现动态SQL的修改数据
在动态SQL机制中,可以使用<if>
标签,可用于对某参数值进行判断,从而生成不同的SQL语句片段,常用于设计更新数据的操作。
目标:使用1个方法,实现多种不同的数据更新(想更新哪些字段就更新哪些字段,不想更新的字段值将保持不变)
需要执行的SQL语句大致是:
update pms_brand set name=?, pinyin=?, logo=? where id=?
注意:以上SQL语句的修改的字段列表应该不是固定的,应该根据传入的参数值来决定。
先在BrandMapper
接口中添加抽象方法:
int updateById(Brand brand);
然后,在BrandMapper.xml
中进行配置:
<!-- int updateById(Brand brand); -->
<update id="updateById">
UPDATE
pms_brand
<set>
<if test="name != null">
name=#{name},
</if>
<if test="pinyin != null">
pinyin=#{pinyin},
</if>
<if test="logo != null">
logo=#{logo},
</if>
</set>
WHERE
id=#{id}
</update>
需要注意的是,在Mybatis的动态SQL中,<if>
并没有对应的<else>
,如果一定要实现类似Java中的if...else
效果,需要使用<choose>
标签,其基本格式是:
<choose>
<when test="条件">
满足条件时的SQL片段
</when>
<otherwise>
不满足条件时的SQL片段
</otherwise>
</choose>
或者,也可以使用2个条件完全相反的<if>
标签来实现类似效果(但是执行效率偏低),例如:
<if test="pinyin != null">
某代码片段
</if>
<if test="pinyin == null">
某代码片段
</if>
使用Mybatis实现查询数据
统计查询
目标:统计品牌表中的数据的数量
需要执行的SQL语句大致是:
select count(*) from pms_brand;
在BrandMapper
接口中添加抽象方法:
int count();
在BrandMapper.xml
中配置SQL:
<!-- int count(); -->
<select id="count" resultType="int">
SELECT count(*) FROM pms_brand
</select>
注意:所有查询节点(<select>
)必须配置resultType
或resultMap
这2个属性中的其中1个。
当使用resultType
声明封装结果的数据类型时,取值与抽象方法的返回值对应,如果是基本类型,直接写类型名称即可,例如resultType="int"
,如果是引用数据类型,在java.lang
包下的可以直接写类名,其它包下的写全限定名。
指定条件的单一结果查询
目标:根据id查询品牌详情
需要执行的SQL语句大致是:
select id, name, pinyin ... from pms_brand where id=?
由于不推荐使用星号表示字段列表,并且,在实际查询时,可能有部分字段是不需要体现在查询结果中的,推荐的做法是针对所需的查询字段,另外创建类型进行结果的封装。
例如,在pojo.vo
下创建BrandDetailVO
类:
public class BrandDetailVO implements Serializable {
/**
* 记录id
*/
private Long id;
/**
* 品牌名称
*/
private String name;
/**
* 品牌名称的拼音
*/
private String pinyin;
/**
* 品牌logo的URL
*/
private String logo;
/**
* 品牌简介
*/
private String description;
/**
* 关键词列表,各关键词使用英文的逗号分隔
*/
private String keywords;
/**
* 自定义排序序号
*/
private Integer sort;
/**
* 销量(冗余)
*/
private Integer sales;
/**
* 商品种类数量总和(冗余)
*/
private Integer productCount;
/**
* 买家评论数量总和(冗余)
*/
private Integer commentCount;
/**
* 买家好评数量总和(冗余)
*/
private Integer positiveCommentCount;
/**
* 是否启用,1=启用,0=未启用
*/
private Integer enable;
// 参考POJO规范添加其它代码
}
在BrandMapper
接口中添加抽象方法:
BrandDetailVO getById(Long id);
然后,在BrandMapper.xml
中配置SQL:
<select id="getById" resultType="com.unkeer.csmall.server.pojo.vo.BrandDetailVO">
SELECT
id, name, pinyin, logo, description,
keywords, sort, sales, product_count, comment_count,
positive_comment_count, enable
FROM
pms_brand
WHERE
id=#{id}
</select>
另外,在查询时,一定要明确几个概念:
- 字段(Field):在创建数据表时指定的名称(后续也能修改表结构时改名)
- 列(Column):查询的结果集中的每一竖排,列名默认情况下是字段名,如果查询时指定了别名,则列名就是指定的别名
- 属性( Property):类中的属性
Mybatis在执行查询时,会尝试自动的将结果集中的数据封装到返回结果类型的对象中,但是,它只能自动处理列名与属性名相同的部分,如果列名与属性名不同,默认并不能自动封装!
可以在查询的SQL语句中,自定义别名,使得列名与属性名相同,则可以实现自动封装,例如:
<select id="getById" resultType="com.unkeer.csmall.server.pojo.vo.BrandDetailVO">
SELECT
id, name, pinyin, logo, description,
keywords, sort, sales,
product_count AS productCount,
comment_count,
positive_comment_count, enable
FROM
pms_brand
WHERE
id=#{id}
</select>
以上的product_count AS productCount
自定义别名可以保证product_count
字段的值可以被自动封装!
更推荐使用<resultMap>
来指导Mybatis如何封装结果,它将与<select>
标签的resultMap
属性一起使用,例如:
<select id="xx" resultMap="DetailResultMap">
</select>
<resultMap id="DetailResultMap" type="com.unkeer.csmall.server.pojo.vo.BrandDetailVO">
</resultMap>
然后,在<resultMap>
内部,使用<result>
来配置列名与属性名的对应关系,例如:
<resultMap id="DetailResultMap" type="com.unkeer.csmall.server.pojo.vo.BrandDetailVO">
<result column="product_count" property="productCount" />
<result column="comment_count" property="commentCount" />
<result column="positive_comment_count" property="positiveCommentCount" />
</resultMap>
提示:在使用<resultMap>
配置时,从规范的角度出发,每个列与属性的关系都需要显式的配置出来(即使从功能实现的角度来看可能并不需要),另外,还应该使用<id>
节点对主键进行配置。
查询列表
目标:查询品牌列表
需要执行的SQL语句大致是(暂时使用星号表示字段列表):
select * from pms_brand order by id
通常,查询列表时,所需要查询的字段与查单个数据可能是不同的,所以,可能需要自定义新的VO类作为List
中的元素类型,为了避免写完代码后发现某个VO不能复用于查询单个数据和查询列表这2个功能,推荐一开始就定义用于封装列表项结果的VO类,例如:
public class BrandListItemVO implements Serializable {
// 可暂时与BrandDetailVO使用完全相同的属性
}
在BrandMapper
接口中添加抽象方法:
List<BrandListItemVO> list();
然后,在BrandMapper.xml
中配置SQL:
<select id="list" resultMap="ListItemResultMap">
SELECT
id, name, pinyin, logo, description,
keywords, sort, sales, product_count, comment_count,
positive_comment_count, enable
FROM
pms_brand
ORDER BY id
</select>
<resultMap id="ListItemResultMap" type="com.unkeer.csmall.server.pojo.vo.BrandListItemVO">
<id column="id" property="id" />
<result column="product_count" property="productCount" />
<result column="comment_count" property="commentCount" />
<result column="positive_comment_count" property="positiveCommentCount" />
</resultMap>
关于<sql>
与<resultMap>
在使用XML配置SQL语句时,可以使用<sql>
封装SQL语句片段,并使用<include>
进行引用,例如:
<select id="list" resultMap="ListItemResultMap">
SELECT
<include refid="ListItemQueryFields"/>
FROM
pms_brand
ORDER BY id
</select>
<sql id="ListItemQueryFields">
id, name, pinyin, logo, description,
keywords, sort, sales, product_count, comment_count,
positive_comment_count, enable
</sql>
通过,使用<sql>
封装字段列表,而<resultMap>
通常与之对应,所以,这2个节点的id
命名通常会使用相同的关键词,例如<sql>
配置为id="ListItemQueryFields"
,并且<resultMap id="ListItemResultMap">
,甚至,在编码时,会把这2个节点放在相邻的位置。
提示:使用<sql>
封装字段列表时,IntelliJ IDEA可能会误判为错误的语法,将字段列表使用<if test="true>"
框住(或将此<if>
添加在之前)即可避免出现这样的错误提示。
练习
完成以下查询功能:
- 统计相册的数量
- 统计类别的数量
- 根据id查询相册详情
- 除了
gmt_create
和gmt_modified
字段的值
- 除了
- 根据id查询类别详情
- 除了
gmt_create
和gmt_modified
字段的值
- 除了
- 查询相册的列表
- 除了
gmt_create
和gmt_modified
字段的值
- 除了
- 根据父级类别,查询子级类别列表(条件:
where parent_id=?
)- 除了
gmt_create
和gmt_modified
字段的值
- 除了
关于异常
BindingException
绑定异常,异常提示信息如下:
org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): com.unkeer.csmall.server.mapper.BrandMapper.insert
出现此异常是因为找不到与抽象方法对应的SQL语句,原因可能有:
- 在XML中配置的接口名有误
<mapper>
节点的namespace
属性值有误
- 在XML中配置的抽象方法名称有误
<insert>
或类似节点的id
属性值有误
- 在配置文件中指定的XML路径,此项配置有误
application.properties
中配置的mybatis.mapper-locations
属性有误
- 如果十分确实以上代码都没有问题,则只可能是依赖项出错
- 先检查
pom.xml
中的依赖代码是否正确,如果无误,则删除本地仓库并重新下载
- 先检查
其它:关于#{}
和${}
格式的占位符
提示:在开发实践中,并不推荐使用${}
格式的占位符。
假设需要实现: 分页查询品牌数据。
需要执行的SQL语句大致是:
SELECT * FROM pms_brand ORDER BY id LIMIT ?, ?
以上SQL语句中,需要使用到2个参数,分别表示“跳过几条记录”、“查询几条记录”。
在BrandMapper
中添加抽象方法:
List<BrandListItemVO> listPage(
@Param("offset") Integer offset, @Param("count") Integer count);
提示:offset表示“偏移量”。
在BrandMapper.xml
中配置SQL:
<select id="listPage" resultMap="ListItemResultMap">
SELECT
<include refid="ListItemQueryFields" />
FROM
pms_brand
ORDER BY id
LIMIT #{offset}, #{count}
</select>
完成后,在BrandMapperTests
中测试:
@Test
public void testListPage() {
Integer offset = 5;
Integer count = 3;
List<BrandListItemVO> list = mapper.listPage(offset, count);
System.out.println("查询列表完成,结果集中的数据的数量=" + list.size());
for (BrandListItemVO item : list) {
System.out.println(item);
}
}
经过测试,可以发现,在配置SQL时,2个参数无论使用#{}
还是${}
都可以正常运行!
假设需要实现:根据名称查询品牌详情
在BrandMapper
中添加抽象方法:
BrandDetailVO getByName(String name);
在BrandMapper.xml
中配置SQL:
<select id="getByName" resultMap="DetailResultMap">
SELECT
<include refid="DetailQueryFields" />
FROM
pms_brand
WHERE
name=#{name}
</select>
完成后,在BrandMapperTests
中测试:
@Test
public void testGetByName() {
String name = "华为";
BrandDetailVO brandDetailVO = mapper.getByName(name);
System.out.println("根据name=" + name + "查询完成,结果=" + brandDetailVO);
}
经过测试,可以发现,在配置SQL时,参数使用#{}
可以正常运行,但是使用${}
会出错!
其实,#{}
和${}
这2种占位符的处理机制是不同的!
当占位符是#{}
时,是预编译的,会先将SQL语句中的参数使用?
表示,当编译通过后,再将参数值代入并执行。
当占位符是${}
时,不是预编译的,会将各参数值先拼接到SQL语句中,然后再执行编译流程,完成后,再执行SQL语句。
以分页查询为例,当使用#{}
时,其大致SQL是:
SELECT * FROM pms_brand ORDER BY id LIMIT ?, ?
以上SQL会先执行编译流程,完成后,再将参数值代入并执行SQL语句。
当使用${}
时,需要先将参数值代入,假设offset
的值是5
,count
的值是3
,则其SQL大致是:
SELECT * FROM pms_brand ORDER BY id LIMIT 5, 3
然后,会将以上SQL语句进行编译流程,最终执行SQL语句。
换成根据名称来查询时,当#{}
占位符时,其SQL语句大致是:
SELECT * FROM pms_brand WHERE name=?
当使用${}
占位符时,依然是先使用参数值替换占位符,得到的SQL语句大致是:
SELECT * FROM pms_brand WHERE name=华为
由于华为
这2个字并没有添加引号表示这是一个字符串,所以,会被视为字段名,最终执行时的错误就是:
Cause: java.sql.SQLSyntaxErrorException: Unknown column '华为' in 'where clause'
所以,当使用${}
格式的占位符,且参数值是字符串类型时(其实,其它非数值型都是如此),需要考虑数据类型的问题,例如,以上出错时,将测试数据的华为
改成'华为'
即可。
结论:使用${}
格式的占位符,需要自行考虑数据类型的问题,但是,使用#{}
时,并不需要。
另外,如果不使用预编译的做法,由于参数值可以改变语义,所以,还存在SQL注入的风险!
所以,为了保证SQL语句不会被注入,不应该使用${}
格式的占位符!
提示:即便${}
格式的占位符是有SQL注入风险的,但是,也并不是不能解决的,可以在执行SQL语句之前,对参数值使用正则表达式检查,如果存在SQL注入的关键字时,不执行SQL语句即可。
Mybatis的缓存机制
缓存:是一种临时存储数据的机制,甚至,使用这种机制存储的数据也只是临时使用而已。
通常,使用缓存存储数据时,都会比其它的某种处理机制更快(访问数据的速度更快,效率更高)!
当使用Mybatis实现增删改查的数据访问时,本质上,程序是运行在APP服务器上,而数据库通常都在另一台服务器上,则访问数据的效率非常低(需要在2台电脑之间进行互相通信,另外,还可以处理SQL语句、查询结果等),特别是查询数据非常多的数据表,在没有索引等机制的情况下,每次查询耗时都非常久!
通常,会使用缓存来解决查询效率低下的问题(与增删改无关),Mybatis内置的缓存机制就是将查询到的结果临时存储到APP服务器上,下次执行相同的查询时,直接将APP服务器上的结果进行返回即可,并不会真实的查询MySQL数据库服务器中的数据。
Mybatis在处理缓存时,区分为一级缓存和二级缓存(是2种不同的缓存机制,但可同时存在)。
Mybatis的一级缓存又被称之为“会话缓存”,是默认开启的,且无法关闭,其能够缓存数据的条件是:
- 必须是同一个会话
- 必须是通过同一个Mapper接口的对象执行的查询
- 必须是执行完全相同的SQL语句
- 必须是完全相同的SQL参数
当满足以上条件时,查询到的每个数据都会被Mybatis缓存下来,并且,下次查询的还是此数据时,将直接返回此前缓存的结果。
一级缓存会在关闭Session、通过SqlSession清空缓存、通过此会话的Mapper使得表中的数据发生修改时自动清空缓存数据!
Mybatis的二级缓存又称之为"namespace缓存",是作用于每一个XML配置的缓存,也就是说,只要执行的是同一个XML中的同一个查询,且参数相同,即使使用不同的会话,也可以共用缓存数据!
在整合Spring的Mybatis工程中(包括Spring Boot),二级缓存默认是全局开启,但各namespace默认未开启的,如果需要开启各namespace缓存,需要在XML文件中添加<cache/>
节点(此节点直属于根节点,与其它同级节点不区分先后顺序)。
注意:二级缓存的数据必须在sqlSession
提交(commit
)或关闭(close
)后才会产生。
注意:Mybatis在执行查询时,会先查找二级缓存,如果命中,直接返回结果,如果未命中,则查询一级缓存,如果命中则返回一级缓存中的结果,如果仍未命中,则执行实际的查询(连接到数据库服务器查询数据)。
提示:使用二级缓存后,输出中的日志中将包含
Cache Hit Ratio
信息,此信息表示的是“缓存数据的命中率”,将在日志的尾部通过0.0
、0.5
类似的数值表示。
另外,在每个查询的<select>
上还可以配置useCache
属性,取值为true
/ false
,表示此查询是否使用缓存,默认值为true
。
另外,当开启二级缓存后,封装查询结果的类型必须实现Serializable
接口,否则就会出现不可序列化的异常!
二级缓存也会因为当前namespace中执行了增删改操作而清空缓存数据!
【总结】
Mybatis内置的缓存机制有一级缓存和二级缓存这2种,并且,在执行查询时,会先查找二级缓存,再查找一级缓存。
其中,一级缓存是会话缓存,必须是同一个会话、同一个Mapper、执行同样的SQL、且使用同样的SQL参数,才会应用缓存,且一级缓存会因为关闭会话、在会话中主动清除缓存、使用此会话的Mapper执行了任何写操作后自动清除缓存数据,一级缓存是默认开启的,一定程度上人为不可控;二级缓存是Namespace缓存,即使不同的会话也可以使用到缓存数据,默认是全局开启、各Namespace未开启的状态,当需要使用Namespace缓存时,需要在XML文件中添加<cache/>
节点以开启当前Namespace的二级缓存,另外,还可以在各<select>
节点上配置useCache
属性,以配置某个查询功能是否使用二级缓存,并且,使用二级缓存时,需要封装结果的类型实现了Serializable
接口,二级缓存也会因为写数据而被清除。
由于无论是一级缓存还是二级缓存,都会因为发生了数据的写操作后清除缓存数据,以保证缓存数据的准确性,但是,这种机制并不一定符合开发实践中的应用需求,在开发实践中,可能并不需要每时每刻关注每个数据的准确性,例如微博的热搜、头条的热榜的机制是每10分钟或15分钟更新一次缓存数据,通过Mybatis的缓存机制是做不到这一点的,所以,通常会使用自定义策略的缓存做法,例如使用Redis来处理缓存。
MyBatis小结
关于Mybatis的使用,你需要:
- 了解使用Mybatis时需要添加的依赖
mybatis-spring-boot-starter
mysql-connector-java
- 了解使用Mybatis时的一次性配置(每个项目中只做1次)
- 在
application.properties
中配置数据库的连接参数 - 使用
@MapperScan
配置接口文件所在的包 - 在
application.properties
中配置XML文件的位置
- 在
- 掌握抽象方法的声明原则
- 返回值类型:如果SQL是增删改类型的,则返回
int
,如果SQL是查询类型的,当查询结果是单个数据时,只需要保证足以封装结果即可,如果查询结果是多条数据,使用List
,且元素类型依然是保证足以封装结果(每一条数据)即可 - 方法名称:参考阿里的建议,不要使用重载
- 参数列表:事先分析需要执行的SQL语句(或事先打草稿),在SQL语句中的参数将是抽象方法的参数,如果参数数量只有1个,则方法参数列表就指定对应的参数,如果参数超过1个,可以声明为多个参数,各参数添加
@Param
注解来配置参数名称,或将多个参数封装到自定义的POJO中
- 返回值类型:如果SQL是增删改类型的,则返回
- 掌握在XML中配置各抽象方法映射的SQL
- 这类XML文件应该有固定的声明(顶部的2句),所以,这类XML通常是复制粘贴得到,或使用工具来创建、生成
- 必须在根节点
<mapper>
的namespace
指定对应的接口 - 应该根据需要执行的SQL语句来选择
<insert>
等4种节点中的某1个 - 在
<insert>
等4种节点中,必须配置id
属性,取值为抽象方法名称 - 在配置
<insert>
实现插入数据时,应该还配置useGeneratedKeys
和keyProperty
属性,以获取自动编号的id(如果此表的id不是自动编号,则不配置) - 在
<select>
节点中,必须配置resultType
或resultMap
中的其中1个属性 - 使用
<sql>
节点可以封装SQL语句片段,当需要使用这段SQL语句时,可以使用<include>
节点来引用 - 使用
<resultMap>
节点可以指导Mybatis封装查询结果,例如可以配置列与属性的对应关系 - 掌握动态SQL中的
<foreach>
,通常用于实现批量删除、批量插入等 - 掌握动态SQL中的
<if>
,通常结合<set>
处理更新数据
- 掌握规范的SQL语句
- 在
insert
语句中,应该显式的指定字段列表- 正例:
insert into user (username, password) values ('root', '1234')
- 反例:
insert into user values (null, 'root', '1234')
- 正例:
- 在统计查询时,使用
count(*)
进行统计 - 在查询表数据时,不要使用星号表示字段列表
- 当查询结果可能超过1条时,必须显式的使用
order by
指定结果集的排序规则 - 当查询结果可能超过1条时,应该考虑是否需要进行分页处理
- 在
补充:自动更新gmt_create和gmt_modified的Mybatis拦截器
MybatisConfiguration.java
// 补充
@Autowired
private List<SqlSessionFactory> sqlSessionFactoryList;
@PostConstruct
public void addInterceptor() {
Interceptor interceptor = new InsertUpdateTimeInterceptor();
for (SqlSessionFactory sqlSessionFactory : sqlSessionFactoryList) {
sqlSessionFactory.getConfiguration().addInterceptor(interceptor);
}
}
InsertUpdateTimeInterceptor.java
package com.unkeer.mall.product.interceptor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.plugin.*;
import java.lang.reflect.Field;
import java.sql.Connection;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* <p>基于MyBatis的自动更新"最后修改时间"的拦截器</p>
*
* <p>需要SQL语法预编译之前进行拦截,则拦截类型为StatementHandler,拦截方法是prepare</p>
*
* <p>具体的拦截处理由内部的intercept()方法实现</p>
*
* <p>注意:由于仅适用于当前项目,并不具备范用性,所以:</p>
*
* <li>拦截所有的update方法(根据SQL语句以update前缀进行判定),无法不拦截某些update方法</li>
* <li>所有数据表中"最后修改时间"的字段名必须一致,由本拦截器的FIELD_MODIFIED属性进行设置</li>
*
* @see cn.tedu.mall.product.config.InsertUpdateTimeInterceptorConfiguration
*/
@Slf4j
@Intercepts({@Signature(
type = StatementHandler.class,
method = "prepare",
args = {Connection.class, Integer.class}
)})
public class InsertUpdateTimeInterceptor implements Interceptor {
/**
* 自动添加的创建时间字段
*/
private static final String FIELD_CREATE = "gmt_create";
/**
* 自动更新时间的字段
*/
private static final String FIELD_MODIFIED = "gmt_modified";
/**
* SQL语句类型:其它(暂无实际用途)
*/
private static final int SQL_TYPE_OTHER = 0;
/**
* SQL语句类型:INSERT
*/
private static final int SQL_TYPE_INSERT = 1;
/**
* SQL语句类型:UPDATE
*/
private static final int SQL_TYPE_UPDATE = 2;
/**
* 查找SQL类型的正则表达式:INSERT
*/
private static final String SQL_TYPE_PATTERN_INSERT = "^insert\\s";
/**
* 查找SQL类型的正则表达式:UPDATE
*/
private static final String SQL_TYPE_PATTERN_UPDATE = "^update\\s";
/**
* 查询SQL语句片段的正则表达式:gmt_modified片段
*/
private static final String SQL_STATEMENT_PATTERN_MODIFIED = ",\\s*" + FIELD_MODIFIED + "\\s*=";
/**
* 查询SQL语句片段的正则表达式:gmt_create片段
*/
private static final String SQL_STATEMENT_PATTERN_CREATE = ",\\s*" + FIELD_CREATE + "\\s*[,)]?";
/**
* 查询SQL语句片段的正则表达式:WHERE子句
*/
private static final String SQL_STATEMENT_PATTERN_WHERE = "\\s+where\\s+";
/**
* 查询SQL语句片段的正则表达式:VALUES子句
*/
private static final String SQL_STATEMENT_PATTERN_VALUES = "\\)\\s*values?\\s*\\(";
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 检查方法的注解,如果方法包含 @IgnoreGmtTimeField 则不进行拦截
// if(invocation.getMethod().getDeclaredAnnotation(IgnoreGmtTimeField.class)!=null){
// return invocation.proceed();
// }
// 日志
log.debug("准备拦截SQL语句……");
// 获取boundSql,即:封装了即将执行的SQL语句及相关数据的对象
BoundSql boundSql = getBoundSql(invocation);
// 从boundSql中获取SQL语句
String sql = getSql(boundSql);
// 日志
log.debug("原SQL语句:{}", sql);
// 准备新SQL语句
String newSql = null;
// 判断原SQL类型
switch (getOriginalSqlType(sql)) {
case SQL_TYPE_INSERT:
// 日志
log.debug("原SQL语句是【INSERT】语句,准备补充更新时间……");
// 准备新SQL语句
newSql = appendCreateTimeField(sql, LocalDateTime.now());
break;
case SQL_TYPE_UPDATE:
// 日志
log.debug("原SQL语句是【UPDATE】语句,准备补充更新时间……");
// 准备新SQL语句
newSql = appendModifiedTimeField(sql, LocalDateTime.now());
break;
}
// 应用新SQL
if (newSql != null) {
// 日志
log.debug("新SQL语句:{}", newSql);
reflectAttributeValue(boundSql, "sql", newSql);
}
// 执行调用,即拦截器放行,执行后续部分
return invocation.proceed();
}
public String appendModifiedTimeField(String sqlStatement, LocalDateTime dateTime) {
Pattern gmtPattern = Pattern.compile(SQL_STATEMENT_PATTERN_MODIFIED, Pattern.CASE_INSENSITIVE);
if (gmtPattern.matcher(sqlStatement).find()) {
log.debug("原SQL语句中已经包含gmt_modified,将不补充添加时间字段");
return null;
}
StringBuilder sql = new StringBuilder(sqlStatement);
Pattern whereClausePattern = Pattern.compile(SQL_STATEMENT_PATTERN_WHERE, Pattern.CASE_INSENSITIVE);
Matcher whereClauseMatcher = whereClausePattern.matcher(sql);
// 查找 where 子句的位置
if (whereClauseMatcher.find()) {
int start = whereClauseMatcher.start();
int end = whereClauseMatcher.end();
String clause = whereClauseMatcher.group();
log.debug("在原SQL语句 {} 到 {} 找到 {}", start, end, clause);
String newSetClause = ", " + FIELD_MODIFIED + "='" + dateTime + "'";
sql.insert(start, newSetClause);
log.debug("在原SQL语句 {} 插入 {}", start, newSetClause);
log.debug("生成SQL: {}", sql);
return sql.toString();
}
return null;
}
public String appendCreateTimeField(String sqlStatement, LocalDateTime dateTime) {
// 如果 SQL 中已经包含 gmt_create 就不在添加这两个字段了
Pattern gmtPattern = Pattern.compile(SQL_STATEMENT_PATTERN_CREATE, Pattern.CASE_INSENSITIVE);
if (gmtPattern.matcher(sqlStatement).find()) {
log.debug("已经包含 gmt_create 不再添加 时间字段");
return null;
}
// INSERT into table (xx, xx, xx ) values (?,?,?)
// 查找 ) values ( 的位置
StringBuilder sql = new StringBuilder(sqlStatement);
Pattern valuesClausePattern = Pattern.compile(SQL_STATEMENT_PATTERN_VALUES, Pattern.CASE_INSENSITIVE);
Matcher valuesClauseMatcher = valuesClausePattern.matcher(sql);
// 查找 ") values " 的位置
if (valuesClauseMatcher.find()) {
int start = valuesClauseMatcher.start();
int end = valuesClauseMatcher.end();
String str = valuesClauseMatcher.group();
log.debug("找到value字符串:{} 的位置 {}, {}", str, start, end);
// 插入字段列表
String fieldNames = ", " + FIELD_CREATE + ", " + FIELD_MODIFIED;
sql.insert(start, fieldNames);
log.debug("插入字段列表{}", fieldNames);
// 定义查找参数值位置的 正则表达 “)”
Pattern paramPositionPattern = Pattern.compile("\\)");
Matcher paramPositionMatcher = paramPositionPattern.matcher(sql);
// 从 ) values ( 的后面位置 end 开始查找 结束括号的位置
String param = ", '" + dateTime + "', '" + dateTime + "'";
int position = end + fieldNames.length();
while (paramPositionMatcher.find(position)) {
start = paramPositionMatcher.start();
end = paramPositionMatcher.end();
str = paramPositionMatcher.group();
log.debug("找到参数值插入位置 {}, {}, {}", str, start, end);
sql.insert(start, param);
log.debug("在 {} 插入参数值 {}", start, param);
position = end + param.length();
}
if (position == end) {
log.warn("没有找到插入数据的位置!");
return null;
}
} else {
log.warn("没有找到 ) values (");
return null;
}
log.debug("生成SQL: {}", sql);
return sql.toString();
}
@Override
public Object plugin(Object target) {
// 本方法的代码是相对固定的
if (target instanceof StatementHandler) {
return Plugin.wrap(target, this);
} else {
return target;
}
}
@Override
public void setProperties(Properties properties) {
// 无须执行操作
}
/**
* <p>获取BoundSql对象,此部分代码相对固定</p>
*
* <p>注意:根据拦截类型不同,获取BoundSql的步骤并不相同,此处并未穷举所有方式!</p>
*
* @param invocation 调用对象
* @return 绑定SQL的对象
*/
private BoundSql getBoundSql(Invocation invocation) {
Object invocationTarget = invocation.getTarget();
if (invocationTarget instanceof StatementHandler) {
StatementHandler statementHandler = (StatementHandler) invocationTarget;
return statementHandler.getBoundSql();
} else {
throw new RuntimeException("获取StatementHandler失败!请检查拦截器配置!");
}
}
/**
* 从BoundSql对象中获取SQL语句
*
* @param boundSql BoundSql对象
* @return 将BoundSql对象中封装的SQL语句进行转换小写、去除多余空白后的SQL语句
*/
private String getSql(BoundSql boundSql) {
return boundSql.getSql().toLowerCase().replaceAll("\\s+", " ").trim();
}
/**
* <p>通过反射,设置某个对象的某个属性的值</p>
*
* @param object 需要设置值的对象
* @param attributeName 需要设置值的属性名称
* @param attributeValue 新的值
* @throws NoSuchFieldException 无此字段异常
* @throws IllegalAccessException 非法访问异常
*/
private void reflectAttributeValue(Object object, String attributeName, String attributeValue) throws NoSuchFieldException, IllegalAccessException {
Field field = object.getClass().getDeclaredField(attributeName);
field.setAccessible(true);
field.set(object, attributeValue);
}
/**
* 获取原SQL语句类型
*
* @param sql 原SQL语句
* @return SQL语句类型
*/
private int getOriginalSqlType(String sql) {
Pattern pattern;
pattern = Pattern.compile(SQL_TYPE_PATTERN_INSERT, Pattern.CASE_INSENSITIVE);
if (pattern.matcher(sql).find()) {
return SQL_TYPE_INSERT;
}
pattern = Pattern.compile(SQL_TYPE_PATTERN_UPDATE, Pattern.CASE_INSENSITIVE);
if (pattern.matcher(sql).find()) {
return SQL_TYPE_UPDATE;
}
return SQL_TYPE_OTHER;
}
}