【Java进阶篇】Mybatis框架

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.urlspring.datasource.usernamespring.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/resourcesmapper文件夹下,通过复制粘贴得到AlbumMapper.xml文件
  • 配置AlbumMapper.xml中的根节点的namespace
  • AlbumMapper.xml中,在根节点内部添加<insert>节点,并配置iduseGeneratedKeyskeyProperty属性,在<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>)必须配置resultTyperesultMap这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_creategmt_modified字段的值
  • 根据id查询类别详情
    • 除了gmt_creategmt_modified字段的值
  • 查询相册的列表
    • 除了gmt_creategmt_modified字段的值
  • 根据父级类别,查询子级类别列表(条件:where parent_id=?
    • 除了gmt_creategmt_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的值是5count的值是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.00.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中
  • 掌握在XML中配置各抽象方法映射的SQL
    • 这类XML文件应该有固定的声明(顶部的2句),所以,这类XML通常是复制粘贴得到,或使用工具来创建、生成
    • 必须在根节点<mapper>namespace指定对应的接口
    • 应该根据需要执行的SQL语句来选择<insert>等4种节点中的某1个
    • <insert>等4种节点中,必须配置id属性,取值为抽象方法名称
    • 在配置<insert>实现插入数据时,应该还配置useGeneratedKeyskeyProperty属性,以获取自动编号的id(如果此表的id不是自动编号,则不配置)
    • <select>节点中,必须配置resultTyperesultMap中的其中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;
    }

}

猜你喜欢

转载自blog.csdn.net/u013488276/article/details/126095561