Mybatis-Plus

MyBatis-Plus是一个MyBatis的增强工具,在MyBatis的基础上只做增强不做改变,为简化开发、提高效率而生。

MyBatis-Plus和MyBatis是共生的,而非替代品。MyBatis-Plus只做增强不做改变,引入它不会对现有工程产生影响。其会在启动时自动注入基本CRUD(增删改查),性能基本无损耗,直接面向对象操作,使得开发人员能够更加方便地进行数据库操作。

快速入门

我们基于之前《案例》中创建的数据表进行快速入门的学习。

一、引入MyBatis-Plus的依赖:

XML 复制代码
    <!--MyBatis-Plus-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.7</version>
        </dependency>

此时原本的mybatis便已不再需要,可以删掉。

二、使Mapper层的接口继承自接口BaseMapper<T>,泛型为对应的实体类:

java 复制代码
public interface EmpMapper extends BaseMapper<Emp> {}
public interface DeptMapper extends BaseMapper<Dept> {}

该接口会提供大量的增删改查(CRUD)方法,且命名规范都是insert、delete、select、update开头。此时我们就可以使用MyBatis-Plus提供的方法,且之前定义的方法也仍然可以使用(方法名不重复的前提下)。但为了避免相似代码不变理解,建议删除。

此时MyBatis-Plus的基础使用便已完成。

注解

上文说过继承BaseMapper需要指定泛型,MB即可基于反射得到指定的实体类的类型,并利用反射得到对应的字节码以获取实体类信息,接下来就可以把实体类信息作为数据库/表信息了。

而实体类和数据表中的信息的对应关系按照指定规则来实现:

  • 类名驼峰转下划线作为表名
  • 名为id的字段作为主键
  • 属性名驼峰转下划线作为字段名

如果不符合上述规则,则需通过注解自行定义表名和字段名。

一、@TableName:用于指定实体类对应的数据库表名。当实体类的类名与数据库表名不一致时,可以使用这个注解来指定表名。

java 复制代码
//数据表名为tb_user
@TableName("tb_user")
public class User {
    // ...
}

二、@TableId:用于指定实体类中作为主键的字段。这个注解可以配置主键的类型,比如自增主键、UUID等。

java 复制代码
public class User {
    //数据表User中主键为tb_id
    @TableId(value="tb_id",ftype = IdType.AUTO)
    private Long id;
    // ...
}

其中"value="可省略,IdType.AUTO 表示主键自增,其他的例如IdType.INPUT:需要手动输入主键值,插入数据时需要自己指定主键的值、IdType.UUID:自动生成 UUID 作为主键值、IdType.AUTO:数据库中该字段需要支持自增

三、@TableField:用于指定实体类中字段与数据库表字段的映射关系。他有很多使用场景:

  • 成员变量名与数据字段名不一致
    • 在字段上加@TableField("user_name")即可
  • 成员变量名以is开头,且是布尔值
    • MB在底层会基于反射类型的机制获取字段名称,is开头的变量会将is去掉作为变量名,需加上@TableField("is_active")
  • 成员变量名与数据库关键字冲突
    • 例如order之类与数据关键字冲突的字段,需将其作为转义字符@TableField("'order'")
  • 成员变量不是数据库字段
    • 该属性在数据表中无对应的字段,不希望其参与到相关方法则需加上@TableField(exist = false)
java 复制代码
public class User {
    // 成员变量名与数据库字段名不一致的情况
    @TableField("real_name")
    private String name; // 数据库字段名为 real_name
    
    // 成员变量名以 is 开头,且是布尔值的情况
    @TableField("is_active")
    private Boolean active; // 数据库字段名为 is_active
    
    // 成员变量名与数据库关键字冲突的情况
    @TableField("'order'")
    private Long order; // 数据库字段名为 user_id
    
    // 成员变量不是数据库字段的情况
    @TableField(exist = false)
    private String extraInfo; // 这个成员变量不会被持久化到数据库中
    
}

配置

MyBatis-Plus的配置项继承了MyBatis原生配置和一些自己特有的配置:

XML 复制代码
#MyBatis-Plus
mybatis-plus:
  type-aliases-package: org.example.mybatistest.pojo # 指定别名扫描包,即实体类所在的包
  mapper-locations: classpath*:/mapper/**/*.xml # 指定Mapper XML文件的路径,支持通配符匹配多个目录下的文件
  configuration:
    map-underscore-to-camel-case: true # 开启驼峰命名自动映射开关
    cache-enabled: false # 禁用二级缓存
  global-config: #全局配置
    db-config:
      id-type: AUTO # 主键类型
      update-strategy: NOT_NULL # 更新策略

这些配置大多使用其默认值即可,无需单独配置,因此我们熟悉就好,实际运用到了可以到官方配置文档查阅:

使用配置 | MyBatis-Plushttps://baomidou.com/reference/

核心功能

条件构造器

之前演示的方法比如"根据ID查找对应数据"都比较简单,但实际使用中我们可能遇到复杂的查询,MB提供的条件构造器可以解决这一问题。

方法名称大致相同,但参数为Wrapper类的变量,这个Wrapper类为我们提供了一种灵活且强大的方式来构建复杂的查询条件。实现方法分三步:

  • 创建Wrapper实例:首先,我们需要创建一个QueryWrapper<UserList>的实例。这里的UserList是我们定义的实体类。
  • 构建限制条件:调用实例的方法来实现对应的sql语句,如果语句较复杂可使用.setSql()方法
  • 执行:将构建好的Wrapper实例传递给userMapper的方法作为参数并执行。

我们以例子来演示:将sql语句转为同功能的语句:

sql 复制代码
# 查询名字中包含o且id>10的数据------------------------------------------------------------------------------------------
select id, username, password, gender, job
from user_list
where username like '%o%' and id>10;
# 将用户名为yangxiao,密码为123456的数据的job修改为3------------------------------
update user_list
set job=3
where username = 'yangxiao'
  and password = '123456';
#将id为1,2,4的数据的创建时间增加一个月---------------------------------------------------------------------
    <update id="updateCreateTime">
        update user_list
        set creat_time= date_add(create_time, interval 1 month)
        where id in 
        <foreach collection="ids" separator="," item="id" open="(" close=")">
            #{ids}
        </foreach>
    </update>
java 复制代码
    @Test//------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    void testSelect() {
        //构建查询条件
        QueryWrapper<UserList> myWrapper = new QueryWrapper<UserList>()
                .select("id", "username", "password", "gender", "job")  // 添加所有需要的字段
                .like("username", "%o%")  // 修改like方法以匹配SQL查询
                .ge("id", 10);
        //查询
        List<UserList> myuser = userMapper.selectList(myWrapper);
        // 输出查询结果
        for (UserList user : myuser) {
            System.out.println(user);
        }
    }
    @Test//------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    void updateByAandP() {
        UpdateWrapper<UserList> updateWrapper = new UpdateWrapper<UserList>()
                .eq("username", "yangxiao")
                .eq("password", "123456")
                .set("job", 3); // 设置 job 字段的新值
    }
    @Test//------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    void updateT() {
        // 创建 查询条件
        List<Long> ids=List.of(1L,2L,4L);
        UpdateWrapper<UserList> updateWrapper = new UpdateWrapper<UserList>()
                .setSql("create_time = DATE_ADD(create_time, INTERVAL 1 MONTH)")
                .in("id", ids);

        // 执行更新操作
        userMapper.update(null,updateWrapper);
    }

上文的编程我们都是使用硬编码的写法,我们可使用lombdaWrapper来解决这一问题:

java 复制代码
@Test
void updateByAandP() {
    LambdaUpdateWrapper<UserList> updateWrapper = new LambdaUpdateWrapper<UserList>()
            .eq(UserList::getUsername, "yangxiao")
            .eq(UserList::getPassword, "123456")
            .set(UserList::getJob, 3); // 设置 job 字段的新值

    // 执行更新操作
    int updatedRows = userMapper.update(null, updateWrapper);
}

自定义SQL

尽管条件构造器功能强大,但在某些复杂场景下,你可能需要编写自定义 SQL 来实现特定的查询逻辑。我们可以利用MB的Wrapper来构建复杂的Where语句,然后自己定义SQL语句中剩下的部分。

以例子"将id为1,2,4的工资增加200"为例,我们在其基础上实现自定义SQL。利用MyBatisPlus的Wrapper来构建复杂的Where条件,然后自己定义SQL语句中剩下的部分。

一、基于Wrapper构建where条件

java 复制代码
List ids = List.of(1L,2L,4L);
int amount = 200;
//1.构建条件
LambdaQueryWrapper wrapper = new LambdaQueryWrapper().in(User::getId, ids);
//2.自定义SQL方法调用
userMapper.updateBalanceByIds(wrapper,amount);

二、在mapper方法参数中用Param注解声明wrapper变量名称,必须是ew

java 复制代码
void updateBalanceByIds(@Param("ew") LambdaQueryWrapper wrapper,@Param("amount") int amount);

三、自定义SQL,并使用Wrapper条件

sql 复制代码
UPDATE tb_user SET balance = balance - #{amount} ${ew.customSqlSegment}

Service接口

上文都是在Mapper层继承BaseMapper,同理我们还可以使Service层继承IService接口,其大体也离不开增删改查。同时因为继承该接口需要实现其方法,MB还提供了对应的实现类ServiceImpl,我们可以让自定义的接口继承自IService,自定义的实现类继承自ServiceImpl。

java 复制代码
//接口
public interface chnService extends IService<UserList> {
}
//实现类
@Service//需指定泛型,第一个为对应的Mapper接口,第二个为对应的实体类
public class chnServiceImpl extends ServiceImpl<UserMapper, UserList> implements chnService {
}

以其为基础开发controller层:

java 复制代码
@RestController
@RequestMapping("/user")
public class chnController {
    @Autowired
    private chnService chnservice;
    //新增用户
    @PostMapping
    public void saveUser(@RequestBody UserList targetUser) {//@RequestBody接收json类参数
        chnservice.save(targetUser);
    }
    //根据ID删除用户
    @DeleteMapping("{id}")
    public void deleteById(@PathVariable("id") Long id) {//@RequestBody接收json类参数
        chnservice.removeById(id);
    }
    //根据ID查询用户
    @GetMapping("{id}")
    public UserList queryById(@PathVariable("id") Long id) {//@RequestBody接收json类参数
        return (UserList)chnservice.getById(id);
    }
    //根据ID查询多个用户
    @GetMapping
    public List<UserList> queryByIds(@PathVariable("ids")List<Long> ids) {//@RequestBody接收json类参数
        return (List<UserList>)chnservice.listByIds(ids);
    }
}

以上都是一些较为简单的方法,我们接下来演示复杂的业务方法:

java 复制代码
//Controller------------------------------------------------------------------------------------------
    //扣除用户余额
    @PutMapping("/{id}/deduction/{money}")
    public void delMoneyById(@PathVariable("id") Long id,@PathVariable("money") Integer money) {//@RequestBody接收json类参数
        chnservice.delMoneyById(id,money);
    }
//Service------------------------------------------------------------------------------------------------
    void delMoneyById(Long id, Integer money);
//ServiceImpl------------------------------------------------------------------------------------------------
    @Override
    public void delMoneyById(Long id, Integer money) {
        //查询用户
        UserList targetUser= this.getById(id);
        //调用自身的getById()方法,this.可省略
        //校验用户状态和余额
        if(targetUser==null||targetUser.getStatus()==2)
            throw new RuntimeException("用户状态异常!");
        if(targetUser.getBal()<money)
            throw new RuntimeException("用户余额不足!");
        //扣除金额
        baseMapper.delMoneyById(id,money);
    }
//UserMapper------------------------------------------------------------------------------------------
    @Update("update user_list set  bal=bal-#{money} where id=#{id}")
    void delMoneyById(@Param("id")Long id,@Param("money") Integer money);

在IService也提供了一些有关Lambda的方法,我们仍通过几个案例来查看:

需求:实现一个根据复杂条件查询用户的接口,查询条件如下:

  • name: 用户名关键字,可以为空
  • status: 用户状态,可以为空
  • minBalance: 最小余额,可以为空
  • maxBalance: 最大余额,可以为空

因为查询条件较多,且都有可能为空,因此我们新创建一个类UserQuery来接收参数:

java 复制代码
public class UserQuery {//略
//Controller------------------------------------------------------------------------------------------
//复杂条件查询
@GetMapping("/list")
public List<UserList> queryUsers(UserQuery query) {
    List<UserList>users=chnService.queryUsers(query.getName(),query.getStatus(),query.getMin(),query.getMax());
}
//Service------------------------------------------------------------------------------------------------
    List<UserList> queryUsers(String name, Integer status, Integer min, Integer max);
//ServiceImpl------------------------------------------------------------------------------------------------
@Override
public List<UserList> queryUsers(String name, Integer status, Integer min, Integer max) {
    // 使用lambdaQuery()开始构建查询条件
    return lambdaQuery()
            // 如果name不为null,则添加一个模糊查询条件,匹配用户名中包含name的记录
            .like(name != null, UserList::getUsername, name)
            // 如果status不为null,则添加一个等值查询条件,匹配状态等于status的记录
            .eq(status != null, UserList::getStatus, status)
            // 如果min不为null,则添加一个大于等于的查询条件,匹配最小值大于等于min的记录
            .ge(min != null, UserList::getMin, min)
            // 如果max不为null,则添加一个小于等于的查询条件,匹配最大值小于等于max的记录
            .le(max != null, UserList::getMax, max)
            // 执行查询并返回查询结果列表
            .list();
}

再来看下一个案例:改造根据id修改用户余额的接口,要求

  • 完成对用户状态校验
  • 完成对用户余额校验
  • 如果扣减后余额为0,则将用户status修改为2,意为冻结状态

前两步之前已完成,我们主要来看第三步

java 复制代码
    @Override
    public void delMoneyById(Long id, Integer money) {
        //查询用户
        UserList targetUser= this.getById(id);
        //调用自身的getById()方法,this.可省略
        //校验用户状态和余额
        if(targetUser==null||targetUser.getStatus()==2)
            throw new RuntimeException("用户状态异常!");
        if(targetUser.getBal()<money)
            throw new RuntimeException("用户余额不足!");
        //扣除金额
        int newBal=targetUser.getBal()-money;
        lambdaUpdate()
                .set(UserList::getBal,newBal)
                .set(newBal==0,UserList::getStatus,2)
                .eq(targetUser::getId,id)
                .eq(targetUser::getBal,targetUser.getBal())
                .update();
    }

再使用IService实现批量插入有两种方法:

java 复制代码
@Test//方法一------------------------------------------------------------------------------
void testSaveOneByOne() {
    // 记录开始时间
    long b = System.currentTimeMillis();
    // 循环插入10万条数据,每条数据单独保存
    for (int i = 1; i <= 100000; i++) {
        userService.save(buildUser(i));
    }
    // 记录结束时间
    long e = System.currentTimeMillis();
    // 打印出总耗时
    System.out.println("耗时:" + (e - b));
}
@Test方法二------------------------------------------------------------------------------------
void testSaveBatch() {
    // 批量插入数据,每次插入1000条,总共插入10万次
    // 1. 创建一个初始容量为1000的列表,用于存储批量插入的数据
    List<User> list = new ArrayList<>(1000);
    // 记录开始时间
    long b = System.currentTimeMillis();
    for (int i = 1; i <= 100000; i++) {
        // 2. 构建用户对象并添加到列表中
        list.add(buildUser(i));
        // 3. 当列表中的数据达到1000条时,执行批量保存操作
        if (i % 1000 == 0) {
            userService.saveBatch(list);
            // 4. 批量保存后清空列表,为下一批数据做准备
            list.clear();
        }
    }
    // 记录结束时间
    long e = System.currentTimeMillis();
    // 打印出总耗时
    System.out.println("耗时:" + (e - b));
}

第一种方法逐个保存用户信息。它首先记录当前时间戳作为开始时间 (b),然后在一个循环中依次创建用户对象并调用 userService.save() 方法进行保存。最后,再次获取当前时间戳作为结束时间 (e),并打印出整个过程所花费的时间。

这种方法的特点是简单直接,但效率较低,因为对于每个用户都进行了独立的数据库操作。

第二种方法采用批量保存的方式来提高效率。它同样先记录开始时间 (b),然后在循环中收集用户对象到一个列表 (list) 中。当列表中的用户数量达到一定阈值(例如1000个)时,就调用 userService.saveBatch() 方法进行批量保存,并将列表清空以继续收集下一个批次的数据。最后,记录结束时间 (e) 并打印出耗时。

第二种方法通过减少数据库交互次数显著提高了性能,尤其是在处理大量数据时更为明显。

但第二种方法在实际上仍是一个一个插入,这是因为MySQL有个属性rewriteBatchedStatements用于优化批处理操作的性能,默认为false,如果将其改为true,系统则会将1000条数据合并成一条SQL语句一起插入一次。

java 复制代码
spring:
  datasource:
    jdbc-url: jdbc:mysql://host:port/database?rewriteBatchedStatements=true
    #连接第一个参数用"?"后续再连接其他参数用"&"

扩展功能

代码生成

之前写的代码是对员工表进行操作,后续我们如果对其他表进行操作,大致内容也一致,只是java类和数据表不一致。这就能用到代码生成。

老版的代码生成器需先引入依赖,再编写大量代码用于代码生成,因为较复杂,现在已不再使用。我们推荐插件MyBatisX,下载并配置对应的java类和数据表信息即可。

DB静态工具

如果我们在一个UserService类中需要注入DeptService的依赖,同时在DeptService中又需注入UserService的依赖,这就会导致多个Service类之间相互调用,像衔尾蛇一样形成循环。为解决这一问题我们可以调用静态方法,这样免去了依赖注入这一步。

MB还提供了一系列与IService中名字相同的静态方法,与之不同是传入的参数多了一个java类的类型,使用这种方法就可以避免循环依赖注入,接下来演示部分代码(只示范ServiceImpl)。

java 复制代码
//ServiceImpl------------------------------------------------------------------------------------------------------------
    @Override
    public UserList getUADById(Long id){
        //查询用户
        UserList targetUser= this.getById(id);
        if(targetUser==null)
            throw new RuntimeException("用户不存在");
        //查询地址
        List<Address> targetList = Db.lambdaQuery(Address.class)
        // 使用Db工具类执行查询并获取地址列表
                .eq(Address::getUserId, id).list();
        //返回列表
        targetUser.setAddress(BeanUtil.copyToList(address,Address.class));
    }

因此一旦涉及到多个Service相互调用,我们可以使用DB静态工具来解决这一问题。

逻辑删除

有时需要删除某个数据但数据库仍需保存该数据,例如淘宝删除订单,用户端虽已无法查询到该订单数据,但在数据库中其仍然存在。

我们可以通过在数据表中添加一个特定的字段来标记数据是否被"删除"。通常,这个字段的类型为布尔值或者整数,用不同的值(如0和1)来表示数据的不同状态(如未删除和已删除)。

这样我们所有的delete方法都要变更为update方法,这会非常麻烦。

此时就用到了MB提供的逻辑删除功能,无需改变方法调用的方式,其会在底层帮我们自动修改CRUD语句,我们要做的就是在application.yml文件中配置逻辑删除的字段名和值即可。

# application.yml配置逻辑删除字段
mybatis-plus:
  global-config:
    db-config:
      logic-delete-field: flag #管理逻辑删除的实例字段名
      logic-delete-value: 1 # 逻辑已删除值(默认为 1)
      logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)

同时逻辑删除本身也有自己的问题:

  • 会导致数据库表垃圾数据越来越多,影响查询效率。
  • SQL中全都需要对逻辑删除字段做判断,影响查询效率。

因此不太推荐采用逻辑删除功能,建议如果数据需要删除,可以将数据迁移到其它表进行备份。

枚举处理器

上文我们提到了可以以某个属性值的不同来传递不同的信息,但使用的仍为1、2、3、4这样1的int型变量,第一眼并不能意识到这些数字代表什么,我们可以通过枚举来解决这一问题。

为了让Mybatis-Plus能够自动地将枚举类型与数据库中的数据类型进行转换,需要在配置文件application.yml中配置全局枚举处理器:

java 复制代码
mybatis-plus:
  configuration:
    default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler

同时因为枚举类中有多个字段,需要添加到数据库中的属性就那么一两个,我们可以通过注解可以使用注解@EnumValue来表示这些字段需要加入到数据库中。 此时响应的数九就不再是1和2,而是NORMAL和CANCEL,如果感觉还不够直观,想要返回desc,即返回字段"正常"和"取消",我们只需在desc属性上加上注解@JsonValue即可。

java 复制代码
//定义枚举类
@Getter
public enum OrderState {
    NORMAL(0, "正常"),
    CANCEL(1, "取消"),
    DELETE(2, "删除");
 
    @EnumValue // 用于数据库存储
    private Integer code;
 
    @JsonValue // 用于返回的json数据
    private String desc;
 
    OrderState(Integer code, String desc) {
        this.code = code;
        this.desc = desc;
    }
}
//使用
User user = new User();
user.setState(OrderState.NORMAL); 

JSON处理器

同理之前的json数据我们一般使用String接收,例如{"id":1,"name":"名字"},现在可直接使用json接收。需要两步:

首先给对应的java字段定义实体类,在该字段上加上注解来指定JSON类型处理器(处理器一个有三种,其使用的第三方框架不同,执行效果相同),然后给该该java类添加注解@TableName(value = "user", autoResultMap = true)来指定属性autoResultMap = true来开启自动映射。

java 复制代码
@TableName(value = "user", autoResultMap = true)
public class User {
    private Long id;
    private String username;
    
    @TableField(typeHandler = JacksonTypeHandler.class)
    private UserInfo info;
}

插件功能

MB提供了很多内置拦截器(插件),有一个"分页插件"我们最常使用,之前我们都是通过pagehelper来完成分页功能,虽然其与MB不冲突,但因为其需要额外引入依赖,会导致代码不够简洁。我们可以直接使用MB提供的分页插件以简化代码。

首先,要在配置类中注册MyBatisPlus的核心插件,同时添加分页插件:

java 复制代码
//配置类用于注册MyBatisPlus的核心插件和分页插件
@Configuration
public class MybatisConfig {

    //注册MyBatisPlus拦截器和分页插件
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        // 初始化核心插件
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

        // 添加分页插件
        PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
        paginationInterceptor.setMaxLimit(1000L); // 设置分页最大限制
        interceptor.addInnerInterceptor(paginationInterceptor);

        return interceptor;
    }
}

配置好分页插件后,你可以在 Service 层使用 MyBatis-Plus 提供的Page对象来实现分页查询:

java 复制代码
@Test
void testPageQuery() {
    int pageNo = 1, pageSize = 2; //模拟前端传参

    // 分页条件
    Page<User> page = Page.of(pageNo, pageSize); // 创建分页对象
    // 排序条件
    page.addOrder(new OrderItem("balance", true)); // 根据余额升序排序
    page.addOrder(new OrderItem("id", true)); // 根据ID升序排序
    // 分页查询
    Page<User> p = userService.page(page); // 调用UserService的page方法进行分页查询
}
相关推荐
QQ同步助手5 分钟前
C++ 指针进阶:动态内存与复杂应用
开发语言·c++
信徒_7 分钟前
常用设计模式
java·单例模式·设计模式
凯子坚持 c11 分钟前
仓颉编程语言深入教程:基础概念和数据类型
开发语言·华为
神仙别闹13 分钟前
基于C#实现的(WinForm)模拟操作系统文件管理系统
java·git·ffmpeg
小爬虫程序猿13 分钟前
利用Java爬虫速卖通按关键字搜索AliExpress商品
java·开发语言·爬虫
程序猿-瑞瑞15 分钟前
24 go语言(golang) - gorm框架安装及使用案例详解
开发语言·后端·golang·gorm
qq_4335545416 分钟前
C++ 面向对象编程:递增重载
开发语言·c++·算法
组合缺一19 分钟前
Solon v3.0.5 发布!(Spring 可以退休了吗?)
java·后端·spring·solon
程序猿零零漆21 分钟前
SpringCloud 系列教程:微服务的未来(二)Mybatis-Plus的条件构造器、自定义SQL、Service接口基本用法
java·spring cloud·mybatis-plus
猿来入此小猿23 分钟前
基于SpringBoot在线音乐系统平台功能实现十二
java·spring boot·后端·毕业设计·音乐系统·音乐平台·毕业源码