红茶的个人站点

  • 首页
  • 专栏
  • 开发工具
  • 其它
  • 隐私政策
Awalon
Talk is cheap,show me the code.
  1. 首页
  2. 其它
  3. 正文

Spring Boot 学习笔记3:MyBatisPlus

2023年9月6日 1042点热度 0人点赞 0条评论

1.快速开始

新建一个 SpringBoot 项目,初始依赖只勾选 Lombok 和 MySQL 驱动。

1.1.依赖

MyBatisPlus 的依赖并不存在于 SpringBoot 的初始化器中,需要我们手动添加:

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.3.1</version>
</dependency>

这个依赖包含多个依赖,其中比较重要的有:

  • mybatis-plus

    • mybatis-plus-extension

      • mybatis-plus-core

        • mybatis-plus-annotation,MyBatisPlus 的注解

        • jsqlparser,SQL 解析器

        • mybatis,MyBatis 本体

      • mybatis-spring,MyBatis 对 Spring 接口的实现

  • spring-boot-starter-jdbc

    • spring-jdbc,JDBC 相关依赖

就像我们看到的,MyBatisPlus 的依赖中已经包含了 MyBatis 和 JDBC 的依赖,因此我们不需要在项目中重复添加相关依赖。

如果重复添加,且版本号不兼容,会引发兼容性问题。

1.2.Mapper

添加实体类:

@Data
public class Book {
    private Integer id;
    private String name;
    private String type;
    private String description;
}

添加 Mapper:

@Mapper
public interface BookMapper extends BaseMapper<Book> {
}

和之前学习 MyBatis 时候不同,这里不需要再实现各种用于增删改查的方法,只需要让 Mapper 继承 BaseMapper,MyBatisPlus 就会帮我们实现一些基本的增删改查方法。BaseMapper是一个泛型接口,其泛型参数是 Mapper 对应的实体类。

服务层:

@Service
public class BookServiceImpl implements BookService {
    @Autowired
    private BookMapper bookMapper;
​
    @Override
    public Book getBookById(int id) {
        return bookMapper.selectById(id);
    }
}

服务层的写法与之前并没有什么不同。

1.3.测试

测试用例:

@SpringBootTest
public class BookServiceTests {
    @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
    @Autowired
    private BookService bookService;
​
​
    @Test
    public void testGetBookById(){
        Book book = bookService.getBookById(2);
        Assertions.assertNotNull(book);
        Assertions.assertEquals("计算机理论", book.getType());
    }
}

执行测试会报错:

org.springframework.jdbc.BadSqlGrammarException: 
### Error querying database.  Cause: java.sql.SQLSyntaxErrorException: Table 'mybatis.book' doesn't exist
### The error may exist in cn/icexmoon/mpdemo/mapper/BookMapper.java (best guess)
### The error may involve defaultParameterMap
### The error occurred while setting parameters
### SQL: SELECT id,name,type,description FROM book WHERE id=?
### Cause: java.sql.SQLSyntaxErrorException: Table 'mybatis.book' doesn't exist
; bad SQL grammar []; nested exception is java.sql.SQLSyntaxErrorException: Table 'mybatis.book' doesn't exist

错误信息很明确,MyBatisPlus 帮我们生成的 SQL 使用 book 作为表名进行查询,而我们数据库中的真实表名并不是这个,所以找不到对应的表,SQL 报错。

这说明默认情况下 MP 使用实体类名首字母小写的形式作为对应的表名生成 SQL,如果我们的表名不是,可以进行修改:

@Data
@TableName("tbl_book")
public class Book {
    // ...
}

这样就没问题了。

2.CRUD

2.1.新增

@SpringBootTest
public class BookMapperTests {
    @Autowired
    private BookMapper bookMapper;
​
    @Test
    public void testInsert() {
        Book book = new Book();
        book.setName("巴顿传");
        book.setType("名人传记");
        book.setDescription("巴顿将军传奇的一生");
        bookMapper.insert(book);
    }
}

默认情况下添加进数据库的主键会是一个 MyBatisPlus 生成的很大的数字:

image-20230904214634712

2.2.查询单个

@Test
public void testSelectOne() {
    Book book = bookMapper.selectById(1859473409);
    System.out.println(book);
}

2.3.查询全部

@Test
public void testSelectAll() {
    List<Book> books = bookMapper.selectList(null);
    books.forEach(b -> {
        System.out.println(b);
    });
}

2.4.更新

@Test
public void testUpdate(){
    Book book = new Book();
    book.setId(2);
    book.setName("Spring 开发指南");
    bookMapper.updateById(book);
}

MP 很“智能”,它生成的 SQL 只会更新值不为 null 的属性对应的表字段,相当于我们在 XML 配置中写的一大堆<if>标签。

2.5.删除

@Test
public void testDelete(){
    bookMapper.deleteById(1859473409);
}

3.分页

3.1.IPage

用 MP 实现分页查询:

@Test
public void testSelectPage(){
    IPage<Book> page = new Page<>(1, 5);
    bookMapper.selectPage(page, null);
    System.out.println("当前页码:"+page.getCurrent());
    System.out.println("每页数据条数:"+page.getSize());
    System.out.println("总数据条数:"+page.getTotal());
    System.out.println("总页数:"+page.getPages());
    System.out.println("当前页数据:");
    page.getRecords().forEach(System.out::println);
}

MP 中用于分页的接口是IPage,可以设置当前页码和每页数据条数。执行查询后可以获取当前页数据以及其它分页相关数据。Page是IPage的唯一实现类。

运行上面的示例实际上得到的是错误的查询结果:

当前页码:1
每页数据条数:5
总数据条数:0
总页数:0
当前页数据:
...

不仅总页数等数据有误,查询到的也是全部数据。

3.2.日志

可以通过 MP 的日志查看其生成的 SQL,来排查出了什么问题。

在配置文件中启用 MP 的日志:

mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

这里StdOutImpl用于在标准输出(控制台)中输出日志。

可以从输出的日志看到:

==>  Preparing: SELECT id,name,type,description FROM tbl_book

实际上并没有使用limit关键字进行分页查询。

3.3.分页拦截器

要想使用 MP 的分页功能,需要添加一个分页用的拦截器:

@Configuration
public class MyBatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor(){
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
        return interceptor;
    }
}

这里需要先定义一个 MP 的拦截器MybatisPlusInterceptor,然后将具体的分页拦截器PaginationInnerInterceptor作为内置拦截器添加。

MP 的拦截器可以添加多个内置拦截器。

现在执行可以看到生成的 SQL 使用分页:

==>  Preparing: SELECT id,name,type,description FROM tbl_book LIMIT ?
==> Parameters: 5(Long)

以及正确的查询结果:

当前页码:1
每页数据条数:5
总数据条数:13
总页数:3
当前页数据:
...

4.DQL 编程

在之前学习 Hibernate 的时候,我介绍过如何用 Hibernate 的 API 用编程的方式动态创建 JQPL 查询语句。MyBatis 本体并没有类似的功能,不过 MyBatisPlus 实现了这一点。

4.1.QueryWrapper

BaseMapper的很多方法都有一个Wrapper类型的参数:

/**
* 根据 entity 条件,删除记录
*
* @param queryWrapper 实体对象封装操作类(可以为 null,里面的 entity 用于生成 where 语句)
*/
int delete(@Param(Constants.WRAPPER) Wrapper<T> queryWrapper);

利用这个参数我们可以用编程的方式创建 SQL 查询条件:

@SpringBootTest
public class BrandMapperTests {
    @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
    @Autowired
    private BrandMapper brandMapper;

    @Test
    public void testBrandQuery(){
        QueryWrapper<Brand> qw = new QueryWrapper<>();
        qw.gt("ordered", 10);
        List<Brand> brands = brandMapper.selectList(qw);
        System.out.println(brands);
    }
}

Wrapper是一个抽象类,这里的QueryWrapper是Wrapper的一个实现子类。qw.gt("ordered", 10)表示ordered>10这样的查询条件,这里的写法与 Hibernate 对 JPQL 编程的写法是类似的。

4.2.Lamda

虽然用字符串表示条件语句要使用的数据表字段名很容器,但问题在于没有任何校验,容易输入错误导致 Bug。

可以用 Lamda 表达式的方式构建查询条件,并解决这个问题:

@Test
public void testBrandQuery2(){
    QueryWrapper<Brand> qw = new QueryWrapper<>();
    qw.lambda().gt(Brand::getOrdered, 10);
    List<Brand> brands = brandMapper.selectList(qw);
    System.out.println(brands);
}

这里QueryWrapper.lambda()返回一个LambdaQueryWrapper类型的对象,它的gt方法传入的参数不再是字符串形式的表字段名,而是该字段对应的实体类方法的 Lambda 表达式。

4.3.LambdaQueryWrapper

可以更进一步,直接使用LambdaQueryWrapper对象构建查询条件:

@Test
public void testBrandQuery3(){
    LambdaQueryWrapper<Brand> lqw = new LambdaQueryWrapper<>();
    lqw.gt(Brand::getOrdered, 10);
    List<Brand> brands = brandMapper.selectList(lqw);
    System.out.println(brands);
}

4.4.and

可以构建多个查询条件:

@Test
public void testBrandQuery5() {
    LambdaQueryWrapper<Brand> lqw = new LambdaQueryWrapper<>();
    lqw.gt(Brand::getOrdered, 10)
        .lt(Brand::getOrdered, 90);
    List<Brand> brands = brandMapper.selectList(lqw);
    System.out.println(brands);
}

默认情况下生成的 SQL 中这些条件都以 and 连接:

==>  Preparing: SELECT id,brand_name,company_name,ordered,description,status FROM tb_brand WHERE (ordered > ? AND ordered < ?)
==> Parameters: 10(Integer), 90(Integer)

4.5.or

如果要让多个条件用 OR 进行连接,可以:

@Test
public void testBrandQuery4() {
    LambdaQueryWrapper<Brand> lqw = new LambdaQueryWrapper<>();
    lqw.gt(Brand::getOrdered, 50)
        .or()
        .lt(Brand::getOrdered, 30);
    List<Brand> brands = brandMapper.selectList(lqw);
    System.out.println(brands);
}

4.6.嵌套

构建嵌套的条件语句:

@Test
public void testBrandQuery6() {
    LambdaQueryWrapper<Brand> lqw = new LambdaQueryWrapper<>();
    lqw.eq(Brand::getStatus, 1)
        .and(i -> i
             .gt(Brand::getOrdered, 90)
             .or()
             .lt(Brand::getOrdered, 10));
    List<Brand> brands = brandMapper.selectList(lqw);
    System.out.println(brands);
}

生成的 SQL:

==>  Preparing: SELECT id,brand_name,company_name,ordered,description,status FROM tb_brand WHERE (status = ? AND (ordered > ? OR ordered < ?))
==> Parameters: 1(Integer), 90(Integer), 10(Integer)

4.7.条件参数控制

就像之前我们在学习 MyBatis 的时候,编写多条件查询的 XML 配置文件中做的那样,通常都需要判断条件是否为null,如果条件为null,就不用该条件拼接 SQL。

用 MP 提供的 API 以编程方式构建 SQL 的查询条件,实际上同样要考虑类似的问题。

假设前端页面要用到的查询条件如下:

@Value
public class BrandQueryDTO {
    // 品牌顺序的最小值
    Integer minOrder;
    // 品牌顺序的最大值
    Integer maxOrder;
    // 品牌名称
    String brandName;
    // 品牌状态
    Integer status;
}

服务层实现:

@Service
public class BrandServiceImpl implements BrandService {
    @Autowired
    private BrandMapper brandMapper;

    @Override
    public List<Brand> query(BrandQueryDTO dto) {
        LambdaQueryWrapper<Brand> qw = new LambdaQueryWrapper<>();
        if (dto.getMinOrder() != null) {
            qw.ge(Brand::getOrdered, dto.getMinOrder());
        }
        if (dto.getMaxOrder() != null) {
            qw.le(Brand::getOrdered, dto.getMaxOrder());
        }
        if (dto.getBrandName() != null) {
            qw.like(Brand::getBrandName, dto.getBrandName());
        }
        if (dto.getStatus() != null) {
            qw.eq(Brand::getStatus, dto.getStatus());
        }
        return brandMapper.selectList(qw);
    }
}

测试:

@Test
public void testQuery(){
    BrandQueryDTO dto = new BrandQueryDTO(1, null, null, 1);
    brandService.query(dto);
}

这样是可行的,如果你觉得多层的if语句不够“美观”,MP 也提供了另一种 API:

@Override
public List<Brand> query2(BrandQueryDTO dto) {
    LambdaQueryWrapper<Brand> qw = new LambdaQueryWrapper<>();
    qw.ge(dto.getMinOrder() != null, Brand::getOrdered, dto.getMinOrder());
    qw.le(dto.getMaxOrder() != null, Brand::getOrdered, dto.getMaxOrder());
    qw.like(dto.getBrandName() != null, Brand::getBrandName, dto.getBrandName());
    qw.eq(dto.getStatus() != null, Brand::getStatus, dto.getStatus());
    return brandMapper.selectList(qw);
}

在上面的例子中,使用了Wrapper重载的一组 API 方法,该系列方法的第一个参数是 boolean类型,只有该参数值为true,后边用于构建查询条件的 Lambda 表达式和值才会起作用,否则就不会构建对应的查询条件。

4.8.查询投影

SQL 中 select 部分的字段名列表同样可以动态构建,这部分在 MP 被称作“查询投影”。

@Test
public void testSelect(){
    LambdaQueryWrapper<Brand> qw = new LambdaQueryWrapper<>();
    qw.select(Brand::getId, Brand::getBrandName, Brand::getCompanyName);
    List<Brand> brands = brandMapper.selectList(qw);
    brands.forEach(System.out::println);
}

这里用LambdaQueryWrapper.select设置了查询的表字段。

查询结果:

==>  Preparing: SELECT id,brand_name,company_name FROM tb_brand
==> Parameters: 
<==    Columns: id, brand_name, company_name
<==        Row: 1, 三只松鼠, 三只松鼠股份有限公司
<==        Row: 2, 华为, 华为技术有限公司
<==        Row: 3, 小米, 小米科技有限公司
<==      Total: 3
Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@41522537]
Brand(id=1, brandName=三只松鼠, companyName=三只松鼠股份有限公司, ordered=null, description=null, status=null)
Brand(id=2, brandName=华为, companyName=华为技术有限公司, ordered=null, description=null, status=null)
Brand(id=3, brandName=小米, companyName=小米科技有限公司, ordered=null, description=null, status=null)

可以看到,只查询了我们设置的表字段,并且没有查询的字段在结果集映射后的Brand对象中相应的属性值是null。

如果使用的是普通的QueryWrapper,相应的写法是:

@Test
public void testSelect2(){
    QueryWrapper<Brand> qw = new QueryWrapper<>();
    qw.select("id","brand_name","company_name");
    List<Brand> brands = brandMapper.selectList(qw);
    brands.forEach(System.out::println);
}

效果是相同的。

还可以利用查询投影使用 SQL 聚合函数,比如查询总数据条数:

@Test
public void testCount() {
    QueryWrapper<Brand> qw = new QueryWrapper<>();
    qw.select("count(*) as count");
    List<Map<String, Object>> brands = brandMapper.selectMaps(qw);
    long count = (long)brands.get(0).get("count");
    System.out.println(count);
}

因为这里查询的结果字段名是自定义的count,不是表字段名,所以这里不再适合使用实体类作为结果集映射。

这里的BaseMapper.selectMaps()方法可以将返回的每一条结果包装成Map,整个结果集就是Map组成的List。

还可以在使用聚合函数的同时使用分组:

@Test
public void testCount2() {
    QueryWrapper<Brand> qw = new QueryWrapper<>();
    qw.select("count(*) as count,status");
    qw.groupBy("status");
    List<Map<String, Object>> brands = brandMapper.selectMaps(qw);
    brands.forEach(b->{
        System.out.println(String.format("状态:%d,数目:%d", b.get("status"), b.get("count")));
    });
}

可能部分函数 MP 的 DQL 编程并不支持,对应的 SQL 可以用传统的 MyBatis 方式(注解或 XML 配置)创建。

4.9.查询条件

上面已经介绍了一些 DQL 的查询条件,这里再介绍一些。

4.9.1.between

@Test
public void testBetween(){
    LambdaQueryWrapper<Brand> lqw = new LambdaQueryWrapper<>();
    lqw.between(Brand::getOrdered, 5, 60);
    List<Brand> brands = brandMapper.selectList(lqw);
    System.out.println(brands);
}

生成的 SQL 语句:

==>  Preparing: SELECT id,brand_name,company_name,ordered,description,status FROM tb_brand WHERE (ordered BETWEEN ? AND ?)
==> Parameters: 5(Integer), 60(Integer)

需要注意的是,两个参数中第一个要小于第二个,否则查询不到结果:

lqw.between(Brand::getOrdered, 60, 5);

查询到的结果集行数为 0。

4.9.2.like

用like可以进行模糊匹配:

@Test
public void testLike(){
    LambdaQueryWrapper<Brand> lqw = new LambdaQueryWrapper<>();
    lqw.like(Brand::getCompanyName, "小米");
    List<Brand> brands = brandMapper.selectList(lqw);
    System.out.println(brands);
}

生成的 SQL:

==>  Preparing: SELECT id,brand_name,company_name,ordered,description,status FROM tb_brand WHERE (company_name LIKE ?)
==> Parameters: %小米%(String)

用likeRight可以右匹配:

lqw.likeRight(Brand::getCompanyName, "小米");

生成的 SQL:

==>  Preparing: SELECT id,brand_name,company_name,ordered,description,status FROM tb_brand WHERE (company_name LIKE ?)
==> Parameters: 小米%(String)

对应的,用likeLeft是左匹配:

==>  Preparing: SELECT id,brand_name,company_name,ordered,description,status FROM tb_brand WHERE (company_name LIKE ?)
==> Parameters: %小米(String)

更多的条件构建 API 可以查询官方文档。

5.DML 编程

5.1.结果映射

5.1.1.表映射

MP 执行查询的时候是按照实体类进行映射的,默认情况下用实体类名的首字母小写映射表名,如果实体类名和表明不一致,可以:

@Data
@TableName("tb_brand")
public class Brand {
    // ...
}

5.1.2.字段映射

如果实体类属性名与表字段名不一致,可以:

public class Brand {
	// ...
    @TableField("company_desc")
    private String description;
    private String status;
}

有的实体类属性是不需要存储在数据库中的,这些字段同样需要用注解说明,否则会被 MP 默认用于查询,而数据库中没有,就会报错。

对于数据库中没有的属性:

@Data
@TableName("tb_brand")
public class Brand {
    // ...
    @TableField(exist = false)
    private boolean online = true;
}

某些字段你可能不需要业务层代码知晓,这些字段仅用于 SQL 查询,比如用户的密码、排序字段等。

这些字段同样需要用注解标记为不作为 SELECT 语句的返回值:

public class Brand {
    private Integer id;
    private String brandName;
    private String companyName;
    @TableField(select = false)
    private String ordered;
    // ...
}

5.2.id 生成策略

数据库表主键有多种生成策略,对应的,需要在实体类的主键属性上用注解说明。

5.2.1.AUTO

最常见的策略是数据库自增:

`id` int NOT NULL AUTO_INCREMENT,

实体类中对应的:

public class Brand {
    @TableId(type = IdType.AUTO)
    private Integer id;
    // ...
}

5.2.2.INPUT

由用户输入,也就是说由程序控制主键的值。

此时数据库表字段不需要是自增的:

`id` int NOT NULL,

实体类:

@TableId(type = IdType.INPUT)
private Integer id;

在使用 MP 保存时要指定 id:

@Test
public void testInsert(){
    Brand brand = new Brand();
    brand.setId(6);
	// ...
    brandMapper.insert(brand);
}

5.2.3.ASSIGN_ID

用一种“雪花算法”生成唯一主键,生成的主键是一个64位二进制数,其长度正好是一个整形(long)的长度。

生成的 ID 中包含时间戳、机器编码、自增序列。可以在分布式系统中保证每个服务器生成的 ID 都具有唯一性。

使用 ASSIGN_ID 作为主键的表字段需要使用bigint存储:

`id` bigint NOT NULL,

相应的,实体类属性也要使用Long:

@TableId(type = IdType.ASSIGN_ID)
private Long id;

5.2.4.ASSIGN_UUID

UUID 与上面的 ASSIGN_ID 类似,同样可以在分布式系统中确保生成的 ID 具有唯一性。

与 ASSIGN_ID 不同的是,UUID 是一个64位字符串,因此表字段:

`id` varchar(64) NOT NULL,

其实这里可以使用 char(64),只是我这里有一些旧数据。

实体类属性:

@TableId(type = IdType.ASSIGN_UUID)
private String id;

5.2.5.全局设置

如果数据库中的表都具有某个固定的前缀,可以在全局设置中设置:

mybatis-plus:
  global-config:
    db-config:
      table-prefix: tb_

这样就无需指定每个表名的映射:

@Data
public class Brand {
	// ...
}

一般一个项目的数据库表主键会使用统一的规则生成,这样就可以在全局设置中统一指定:

mybatis-plus:
  global-config:
    db-config:
      id-type: auto

同样就不用在实体类中再次指定:

@Data
public class Brand {
    private Long id;
	// ...
}

5.3.批量操作

批量查询:

@Test
public void testSelectBatch() {
    List<Long> ids = Arrays.asList(1L, 2L, 3L);
    List<Brand> brands = brandMapper.selectBatchIds(ids);
    brands.forEach(System.out::println);
}

批量删除:

@Test
public void testDeleteBatch(){
    List<Long> ids = Arrays.asList(6L,1699029476867108866L);
    brandMapper.deleteBatchIds(ids);
}

5.4.逻辑删除

通常我们不会对数据库表中的数据做物理删除(硬删除),而是会选择将其逻辑删除(软删除)。

使用 MP 可以很容易地实现逻辑删除。

首先要为实现逻辑删除的表添加一个字段作为删除标识:

`del_flag` tinyint NOT NULL,

0 表示未删除,1 表示已删除。

实体类中添加对应的属性:

@Data
public class Brand {
    // ...
    @TableLogic(value = "0", delval = "1")
    private Integer delFlag;
}

执行删除操作:

@Test
public void testLogicDelete(){
    brandMapper.deleteById(1L);
}

实际上此时生成的 SQL 是 UPDATE,用于更新删除标识:

==>  Preparing: UPDATE tb_brand SET del_flag=1 WHERE id=? AND del_flag=0
==> Parameters: 1(Long)

用 MP 查询相关的 API 查询时,MP 会自动使用删除标识,只会检索未删除的数据:

@Test
public void testSelectBatch() {
    List<Long> ids = Arrays.asList(1L, 2L, 3L);
    List<Brand> brands = brandMapper.selectBatchIds(ids);
    brands.forEach(System.out::println);
}

生成的 SQL:

==>  Preparing: SELECT id,brand_name,company_name,company_desc AS description,status,del_flag FROM tb_brand WHERE id IN ( ? , ? , ? ) AND del_flag=0
==> Parameters: 1(Long), 2(Long), 3(Long)

用 DML 编程构建 SQL 执行查询同样会自动使用删除标识。

如果要查询已经被删除的数据,需要使用传统 MyBatis 的方式。

在项目中通常我们不需要对每个表的实体类都手动添加逻辑删除注解,只需要进行全局设置:

mybatis-plus:
  global-config:
    db-config:
      logic-delete-field: delFlag
      logic-delete-value: 1
      logic-not-delete-value: 0

注意,ogic-delete-field的值是实体类的属性名。

5.5.乐观锁

可以利用 PM 对数据库表实现一个简单的乐观锁,以应对数据库因为并发访问导致的问题。

这里创建一个商品表:

CREATE TABLE `tb_goods` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `del_flag` tinyint NOT NULL COMMENT '删除标识',
  `name` varchar(50) NOT NULL COMMENT '商品名称',
  `num` int NOT NULL COMMENT '商品库存',
  `price` decimal(10,2) NOT NULL COMMENT '商品价格',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品表'

实体类:

@Data
public class Goods {
    private Long id;
    private String name;
    private Integer num;
    private Double price;
    private Integer delFlag;
}

Mapper:

@Mapper
public interface GoodsMapper extends BaseMapper<Goods> {
    @Update("update tb_goods set num=num-1 where id=#{id} and del_flag=0")
    void numDecrease(long id);
}

服务层中实现一个简单的购买逻辑:

@Service
public class GoodsServiceImpl implements GoodsService {
    @Autowired
    private GoodsMapper goodsMapper;

    @Override
    public void buyGoods(long id) {
        Goods goods = goodsMapper.selectById(id);
        if (goods.getNum() <= 0) {
            throw new RuntimeException(String.format("商品%d已经卖完", id));
        }
        goodsMapper.numDecrease(id);
    }
}

这里简单判断,如果商品够就扣减库存。

假设当前数据库中有一个编号为 1 的商品,其库存是 1。

简单测试一下:

@Test
public void testBuyGoods(){
    goodsService.buyGoods(1L);
    goodsService.buyGoods(1L);
}

此时第一个购买商品是成功的,但第二个失败,会有商品已经卖光的异常。

看起来这里没有问题,如果存在并发访问,就会出现问题。

为了能够模拟并发下可能出现的问题,需要在业务代码中添加休眠语句:

@Service
@Log4j2
public class GoodsServiceImpl implements GoodsService {
    @Autowired
    private GoodsMapper goodsMapper;

    @Override
    public void buyGoods(long id) {
        Goods goods = goodsMapper.selectById(id);
        log.info("检查商品库存是否足够。");
        if (goods.getNum() <= 0) {
            throw new RuntimeException(String.format("商品%d已经卖完", id));
        }
        log.info("商品库存足够");
        try {
            Thread.sleep(1000);
            Thread.yield();
        } catch (InterruptedException e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
        log.info("执行商品库存扣减");
        goodsMapper.numDecrease(id);
    }
}

在执行完库存检查后会休眠 1 秒钟。

用一个简单的测试用例进行测试:

@Test
public void testBuyGoodsInTime() throws InterruptedException {
    new Thread(()->{goodsService.buyGoods(1L);}).start();
    new Thread(()->{goodsService.buyGoods(1L);}).start();
    Thread.sleep(5000);
}

这里用两个线程来表示并发“抢购”,并且测试用例等待 5 秒,以确保上面两个线程有足够的时间执行完毕。

这里的并发代码写的不完善,实际上应该用线程同步来确保主线程中的子线程执行完毕后主线程才退出。

观察日志就能发现两个线程都通过了库存检查,并执行了库存扣减:

[       Thread-2] c.i.m.service.impl.GoodsServiceImpl      : 检查商品库存是否足够。
[       Thread-3] c.i.m.service.impl.GoodsServiceImpl      : 检查商品库存是否足够。
[       Thread-2] c.i.m.service.impl.GoodsServiceImpl      : 商品库存足够
[       Thread-3] c.i.m.service.impl.GoodsServiceImpl      : 商品库存足够
[       Thread-2] c.i.m.service.impl.GoodsServiceImpl      : 执行商品库存扣减
[       Thread-3] c.i.m.service.impl.GoodsServiceImpl      : 执行商品库存扣减

查看数据库也能发现,库存变成了 -1。

这里用 MP 实现一个乐观锁来解决这个问题。

这种并发问题的解决方案是多种多样的,MP 的乐观锁在低限度的并发下(QPS<1000)会表现的比较好。

为商品表添加一个用于乐观锁的字段:

`version` int NOT NULL DEFAULT '1' COMMENT '乐观锁',

对应的实体类属性:

@Data
public class Goods {
    @Version
    private Integer version;
}

还要在 MyBatisPlus 的配置中添加乐观锁的拦截器:

@Configuration
public class MyBatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor(){
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
        // 添加乐观锁拦截器
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
        return interceptor;
    }
}

修改服务层,使用乐观锁扣减库存:

@Service
@Log4j2
public class GoodsServiceImpl implements GoodsService {
    @Autowired
    private GoodsMapper goodsMapper;

    @Override
    public void buyGoods(long id) {
        Goods goods = goodsMapper.selectById(id);
        // ...
        // 使用乐观锁进行库存扣减
        Goods newGoods = new Goods();
        newGoods.setId(id);
        newGoods.setNum(goods.getNum() - 1);
        newGoods.setVersion(goods.getVersion());
        int rows = goodsMapper.updateById(newGoods);
        if (rows<=0){
            throw new RuntimeException("库存扣减失败");
        }
    }
}

扣减库存时必须要提供乐观锁字段(version),否则乐观锁不会生效。

现在再次执行测试用例,库存只会为 0,不会为负了。且其中一个线程会抛出库存扣减失败的异常。

之所以会这样,观察 MP 的日志:

==>  Preparing: SELECT id,name,num,price,del_flag,version FROM tb_goods WHERE id=? AND del_flag=0
<==    Columns: id, name, num, price, del_flag, version
<==        Row: 1, IPhone6, 1, 5000.00, 0, 1
==>  Preparing: SELECT id,name,num,price,del_flag,version FROM tb_goods WHERE id=? AND del_flag=0
<==    Columns: id, name, num, price, del_flag, version
<==        Row: 1, IPhone6, 1, 5000.00, 0, 1
==>  Preparing: UPDATE tb_goods SET num=?, version=? WHERE id=? AND version=? AND del_flag=0
==> Parameters: 0(Integer), 2(Integer), 1(Long), 1(Integer)
==>  Preparing: UPDATE tb_goods SET num=?, version=? WHERE id=? AND version=? AND del_flag=0
==> Parameters: 0(Integer), 2(Integer), 1(Long), 1(Integer)

可以看到,两个线程都先查询了商品信息,此时 version 都是 1,然后在用 UPDATE 语句执行扣减时,两个线程都执行的是:

UPDATE tb_goods SET num=0, version=2 WHERE id=1 AND version=1 AND del_flag=0

只要有其中一个线程更新成功,另一个线程的 UPDATE 语句势必不能成功,因为此时 version 已经变成了 2。

也就是说。乐观锁的逻辑在于,只要有一个 UPDATE 语句执行成功,version 就会变成 version+1,原来那些通过 SELECT 查询到的旧 verion(verion=1)的线程再执行 UPDATE 就不会成功。除非他们重新读取新的 version 并在 version 自增前执行 UPDATE 操作。

6.代码生成器

MP 提供一个代码生成器,可以读取数据库信息生成相应的框架代码。

这里用一个简单示例说明如何使用。

先创建一个 SpringBoot 应用,初始依赖选择 web\MySQL 驱动\Lombok。

添加 MyBatisPlus 依赖:

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.2</version>
</dependency>

添加 MyBatisPlus 代码生成器的依赖:

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-generator</artifactId>
    <version>3.5.2</version>
</dependency>

3.5.3.x 版本的 MP 代码生成器依赖无法正常从阿里 Maven 镜像仓库下载,下载的 jar 包无法打开,会导致 Idea 不能正确读取相应的类定义。

代码生成器需要使用模版,相应的依赖没有默认包含,需要自己添加。这里使用 freemarker:

<dependency>
    <groupId>org.freemarker</groupId>
    <artifactId>freemarker</artifactId>
    <version>2.3.31</version>
</dependency>

使用的示例数据库为 shopping.sql。

MP 官方的代码生成器可配置项很多,使用了大量的 Builder 模式,配置起来很麻烦。

我这里做了一个简单封装,可以实现简易化配置和使用。

首先定义了一个代码生成器基本配置信息的配置类:

@Getter
@Setter
public class CodeGeneratorConfig {
    @Getter
    @NoArgsConstructor
    public static class Tables {
        // 一组表名
        private List<String> tableNames = new LinkedList<>();
        // 表名拥有的前缀
        private List<String> tableNamePrefixes = new LinkedList<>();

        public Tables addTableName(String tn) {
            this.tableNames.add(tn);
            return this;
        }

        public Tables addTableNamePrefix(String prefix) {
            this.tableNamePrefixes.add(prefix);
            return this;
        }

        public Tables addTableNames(List<String> tableNames){
            this.tableNames.addAll(tableNames);
            return this;
        }

        public Tables addTableNames(String ... tableNames){
            this.tableNames.addAll(Arrays.asList(tableNames));
            return this;
        }
    }

    /**
     * 数据库连接信息
     */
    @Value
    public static class DbConnInfo {
        String userName;
        String password;
        String url;
    }

    // 作者
    private String author;
    // 生成代码目录(使用项目根目录)
    private String homeDir = System.getProperty("user.dir");
    // 是否为生成的代码添加 swagger 注解
    private boolean addSwaggerAnnotation;
    // 是否覆盖已生成的 Controller 文件
    private boolean controllerOverride = false;
    // 是否覆盖已生成的 service 文件
    private boolean serviceOverride = false;
    // 是否覆盖已生成的 entity 文件
    private boolean entityOverride = false;
    // 是否覆盖已生成的 mapper 文件
    private boolean mapperOverride = false;
    // 项目包名
    private String projectPackageName;
    // 模块,key 表示模块名,value 表示模块包含的表信息
    private Map<String, Tables> models;
    // 数据库连接信息
    private DbConnInfo dbConnInfo;
    // 是否对实体类使用 Lombok 注解
    private boolean useLombok = true;
    // 生成代码后是否打开目录
    private boolean openDirAfterGen = false;

    /**
     * 从 application.yml 加载数据库连接信息
     */
    public void loadDbConnInfoFromProperties() {
        // 获取资源对象
        Resource resource = new ClassPathResource("application.yml");
        // 解析 yaml
        YamlPropertiesFactoryBean yamlProFb = new YamlPropertiesFactoryBean();
        yamlProFb.setResources(resource);
        Properties properties = yamlProFb.getObject();
        // 获取属性
        final String PROPERTY_PREFIX = "spring.datasource.";
        String url = properties.getProperty(PROPERTY_PREFIX + "url");
        String username = properties.getProperty(PROPERTY_PREFIX + "username");
        String password = properties.getProperty(PROPERTY_PREFIX + "password");
        this.dbConnInfo = new DbConnInfo(username, password, url);
    }
}

然后定义了一个使用配置类执行 MP 代码生成器的类:

public class CodeGeneratorRunner {
    private CodeGeneratorConfig config;

    /**
     * 构造器
     *
     * @param config 代码生成器配置
     */
    public CodeGeneratorRunner(CodeGeneratorConfig config) {
        this.config = config;
    }

    /**
     * 运行代码生成器生成代码
     */
    public void run() {
        config.getModels().forEach((modelName, tables) -> {
            FastAutoGenerator generator = this.buildModelGenerator(modelName, tables);
            generator.execute();
        });
    }

    /**
     * 为一个模块创建代码生成器
     *
     * @param modelName 模块名称
     * @param tables    模块包含的表
     * @return MP 代码生成器
     */
    private FastAutoGenerator buildModelGenerator(String modelName, CodeGeneratorConfig.Tables tables) {
        // 从配置文件读取数据库连接信息
        return FastAutoGenerator.create(config.getDbConnInfo().getUrl(),
                        config.getDbConnInfo().getUserName(),
                        config.getDbConnInfo().getPassword())
                .globalConfig(builder -> {
                    builder.author(config.getAuthor()) // 设置作者
                            .outputDir(config.getHomeDir() + "/src/main/java"); // 指定输出目录
                    if (config.isAddSwaggerAnnotation()) {
                        // 开启 swagger 模式
                        builder.enableSwagger();
                    }
                    if (!config.isOpenDirAfterGen()) {
                        builder.disableOpenDir();
                    }
                })
                .templateEngine(new FreemarkerTemplateEngine()) // 使用Freemarker引擎模板,默认的是Velocity引擎模板
                .packageConfig(builder -> {
                    String xmlPath = String.format("%s/src/main/resources/%s/%s/mapper",
                            config.getHomeDir(),
                            config.getProjectPackageName().replace('.', '/'),
                            modelName);
                    builder.parent(config.getProjectPackageName()) // 设置父包名
                            .moduleName(modelName) // 设置父包模块名
                            .pathInfo(Collections.singletonMap(OutputFile.xml, xmlPath)); // 设置mapperXml生成路径
                })
                .strategyConfig(builder -> {
                    builder.addInclude(tables.getTableNames()) // 设置需要生成的表名
                            .addTablePrefix(tables.getTableNamePrefixes()); // 设置过滤表前缀
                    if (config.isUseLombok()) {
                        builder.entityBuilder()
                                .enableLombok();
                    }
                    Controller.Builder controllerBuilder = builder.controllerBuilder();
                    if (config.isControllerOverride()) {
                        controllerBuilder.fileOverride();
                    }
                    if (config.isUseRestController()){
                        controllerBuilder.enableRestStyle();
                    }
                    if (config.isServiceOverride()){
                        builder.serviceBuilder().fileOverride();
                    }
                    if (config.isEntityOverride()){
                        builder.entityBuilder().fileOverride();
                    }
                    if (config.isMapperOverride()){
                        builder.mapperBuilder().fileOverride();
                    }
                });
    }
}

最后就是一个入口类,内容很简单,定义配置类,并用该配置类执行 MP 的代码生成器生成代码:

public class Main {
    public static void main(String[] args) {
        CodeGeneratorConfig config = new CodeGeneratorConfig();
        config.loadDbConnInfoFromProperties();
        config.setAuthor("icexmoon@qq.com");
        config.setAddSwaggerAnnotation(false);
        config.setControllerOverride(true);
        config.setServiceOverride(true);
        config.setEntityOverride(true);
        config.setMapperOverride(true);
        config.setProjectPackageName("cn.icexmoon.codegendemo");
        Map<String, CodeGeneratorConfig.Tables> models = new HashMap<>();
        CodeGeneratorConfig.Tables userTables = new CodeGeneratorConfig.Tables();
        userTables.addTableNames("users");
        models.put("user", userTables);
        CodeGeneratorConfig.Tables orderTables = new CodeGeneratorConfig.Tables();
        orderTables.addTableNames("cart","order","order_item");
        models.put("order", orderTables);
        CodeGeneratorConfig.Tables itemTables = new CodeGeneratorConfig.Tables();
        itemTables.addTableNames("item");
        models.put("item", itemTables);
        config.setModels(models);
        CodeGeneratorRunner runner = new CodeGeneratorRunner(config);
        runner.run();
    }
}

我这里仅进行了最通用的代码封装,如果需要调整,可以查看官方文档中代码生成器配置的部分,在我封装的基础上修改。

本文的完整示例可以从这里获取。

7.参考资料

  • 黑马程序员SSM框架教程

  • 代码生成器(新) | MyBatis-Plus (baomidou.com)

  • 从零开始 Spring Boot 7:生成框架代码 - 红茶的个人站点 (icexmoon.cn)

本作品采用 知识共享署名 4.0 国际许可协议 进行许可
标签: MyBatisPlus spring spring boot
最后更新:2023年9月6日

魔芋红茶

加一点PHP,加一点Go,加一点Python......

点赞
< 上一篇
下一篇 >

文章评论

取消回复

*

code

COPYRIGHT © 2021 icexmoon.cn. ALL RIGHTS RESERVED.
本网站由提供CDN加速/云存储服务

Theme Kratos Made By Seaton Jiang

宁ICP备2021001508号

宁公网安备64040202000141号