Shiro入门学习(整合SpringBoot)

本文涉及的产品
密钥管理服务KMS,1000个密钥,100个凭据,1个月
简介: Apache Shiro是Java的一个安全(权限)框架可以完成:认证、授权、加密、会话管理、与Web集成和缓存等用户登录时把身份信息(用户名/手机号/邮箱地址等)和凭证信息(密码/证书等)封装成一个Token令牌,通过安全管理器中的认证器进行校验,成功则授权以访问系统

Apache Shiro是Java的一个安全(权限)框架


可以完成:认证、授权、加密、会话管理、与Web集成和缓存等


用户登录时把身份信息(用户名/手机号/邮箱地址等)和凭证信息(密码/证书等)封装成一个Token令牌,通过安全管理器中的认证器进行校验,成功则授权以访问系统

65a2f1cd832f58173e9a8b8422a87c4d.jpg

shiro三大功能模块

  1. Subject:主体,一般指用户。
  2. SecurityManager:安全管理器,管理所有Subject,可以配合内部安全组件。(类似于SpringMVC中的DispatcherServlet)
  3. Realms:用于进行权限信息的验证,一般需要自己实现。

一、默认Realm

shiro配置文件后缀为.ini(类似于.txt,但是支持复杂的数据格式,一般用于系统配置)

默认方法:通过ini配置文件(new IniRealm(“classpath:shiro.ini”)

public class Shiro1 {
    public static void main(String[] args) {
        //创建安全管理器对象
        DefaultSecurityManager defaultSecurityManager = new DefaultSecurityManager();
        //给安全管理器设置realm
        defaultSecurityManager.setRealm(new IniRealm("classpath:shiro.ini"));
        //给全局安全工具类securityUtils设置安全管理器
        SecurityUtils.setSecurityManager(defaultSecurityManager);
        //关键对象:subject主体
        Subject subject = SecurityUtils.getSubject();
        //创建令牌
        UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken("xiaochen","12s3");
        try {
            System.out.println("认证状态:" + subject.isAuthenticated());
            subject.login(usernamePasswordToken);
            System.out.println("认证状态:" + subject.isAuthenticated());
        } catch (UnknownAccountException e) {
            e.printStackTrace();
            System.out.println("认证失败,用户名不存在");
        } catch (IncorrectCredentialsException e) {
            e.printStackTrace();
            System.out.println("认证失败,密码错误");
        }
    }
}
[users]
xiaochen=123
xiaowang=124

二、自定义Realm

AuthenticatingRealml类的doGetAuthenticationInfo方法用于认证realm

AuthorizingRealm类的doGetAuthorizationInfo方法用于授权realm

认证:

  • 最终执行用户名比较: SimpleAccountRealm类中的doGetAuthenticationInfo方法
  • 最终密码校验:AuthenticatingRealm类中的assertCredentialsMatch方法

自定义Realm实现认证

自定义Realm继承AuthorizingRealm(AuthorizingRealm继承了AuthenticatingRealm),实现doGetAuthenticationInfo和doGetAuthorizationInfo这两个抽象方法即可实现认证和授权处理

public class CustomerRealm extends AuthorizingRealm {
    // 授权
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        return null;
    }
    // 认证
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        // 在token中获取用户名
        String principal = (String) authenticationToken.getPrincipal();
        // 从数据库中获取用户名,此处直接模拟数据
        if ("cyh".equals(principal)) {
            // 参数一:数据库中的正确用户名, 参数二:数据库中的正确密码, 参数三:提供当前realm的名字 this.getName()
            SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(principal, "123", this.getName());
            return simpleAuthenticationInfo;
        }
        return null;
    }
}
public class CustomerRealmTest {
    public static void main(String[] args) {
        DefaultSecurityManager defaultSecurityManager = new DefaultSecurityManager();
        CustomerRealm customerRealm = new CustomerRealm();
        defaultSecurityManager.setRealm(customerRealm);
        SecurityUtils.setSecurityManager(defaultSecurityManager);
        UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken("cyh","123");
        Subject subject = SecurityUtils.getSubject();
        try {
            subject.login(usernamePasswordToken);
            System.out.println(subject.isAuthenticated());
        } catch (AuthenticationException e) {
            e.printStackTrace();
        }
    }
}

三、Salt+MD5

MD5一般用来加密或者签名(校验和)

MD5一般用来加密或者签名(校验和)


MD5算法不可逆,如果内容相同加密多少次结果都相同


MD5算法结果始终是一个16进制32位长度的字符串


为了解决被穷举破解密码的情况,我们一般对用户输入的密码进行“加盐”以增加密码复杂性,提高安全性。


所谓加盐即随机生成一段字符对用户输入的密码进行包装(按照一定的规则将盐拼接到原密码),随后再进行MD5加密存储到数据库中,同时也要把盐值进行存储

,这样首先提高密码复杂性难以被穷举破解,其次即使被破解了拿到盐值后也难以推算出盐是如何拼接在原密码中。

public class Md5Test {
    public static void main(String[] args) {
        // 第一个参数为待加密值,第二个参数为盐值(默认加在待加密值前面),第三个参数为hash散列次数(默认1次)
        Md5Hash md5Hash = new Md5Hash("123");
        System.out.println(md5Hash.toHex()); // 202cb962ac59075b964b07152d234b70
        Md5Hash md5Hash1 = new Md5Hash("123", "o12*k");
        System.out.println(md5Hash1.toHex()); // 1fb300cc9a77b971ab845263d802966b
        Md5Hash md5Hash2 = new Md5Hash("123","o12*k",1024);
        System.out.println(md5Hash2.toHex()); // a72061f5b311794e5068574ece5935be
    }
}
public class CustomerMd5Realm extends AuthorizingRealm {
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        return null;
    }
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        String principal = (String) authenticationToken.getPrincipal();
        if ("cyh".equals(principal)) {
            // 参数一:数据库用户名,参数二:数据库md5+salt加密散列后的密码,参数三:注册时的盐,参数四:realm的名字
            return new SimpleAuthenticationInfo(principal, "a72061f5b311794e5068574ece5935be", ByteSource.Util.bytes("o12*k"), this.getName());
        }
        return null;
    }
}
public class CustomerMd5RealmTest {
    public static void main(String[] args) {
        DefaultSecurityManager defaultSecurityManager = new DefaultSecurityManager();
        CustomerMd5Realm customerMd5Realm = new CustomerMd5Realm();
        // 获取密码匹配器(默认使用equals匹配)
        HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
        // 选择使用的加密算法
        hashedCredentialsMatcher.setHashAlgorithmName("md5");
        // 设置hash散列次数
        hashedCredentialsMatcher.setHashIterations(1024);
        // 设置realm使用hash凭证匹配器
        customerMd5Realm.setCredentialsMatcher(hashedCredentialsMatcher);
        defaultSecurityManager.setRealm(customerMd5Realm);
        SecurityUtils.setSecurityManager(defaultSecurityManager);
        Subject subject = SecurityUtils.getSubject();
        UsernamePasswordToken token = new UsernamePasswordToken("cyh","123");
        try {
            subject.login(token);
            System.out.println("登录成功");
        } catch (UnknownAccountException e) {
            e.printStackTrace();
            System.out.println("认证失败,用户名不存在");
        } catch (IncorrectCredentialsException e) {
            e.printStackTrace();
            System.out.println("认证失败,密码错误");
        }
    }
}

四、授权

授权,即访问控制,控制谁能访问哪些资源。主体进行身份认证后需要分配权限方可访问系统的资源,对于某些资源没有权限是无法访问的。

基于角色的访问控制权限:以角色为中心

if(subject.hasRole("admin")){
  //操作什么资源
}

基于资源的访问控制权限:以资源为中心

if(subject.isPermitted("user:create:*")){
  //对用户模块的所有实例具有创建权限
}
if(subject.isPermitted("user:*:01")){
  //对用户模块的01号资源具有所有权限
}
if (subject.isAuthenticated()) {
    System.out.println(subject.hasRole("admin"));
    System.out.println(subject.hasRole("users"));
    System.out.println(subject.isPermitted("user:create:*"));
    System.out.println(subject.isPermitted("product:create:01"));
}
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
    String primaryPrincipal = (String) principalCollection.getPrimaryPrincipal();//获得用户名
    System.out.println("身份信息:" + primaryPrincipal);
    // 根据身份信息 用户名 获取当前用户的角色信息,权限信息
    SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
    // 将数据库中查询到的角色信息赋值给权限对象
    simpleAuthorizationInfo.addRole("users");
    // 将数据库中查询到的权限信息赋值给权限对象
    simpleAuthorizationInfo.addStringPermission("users:update:01");
    simpleAuthorizationInfo.addStringPermission("product:create");
    return simpleAuthorizationInfo;
}

授权方式

  • 编程式(如下注释处)
  • 注解式(@RequireRoles(“”))
  • 标签式(应用于JSP中)
@RequestMapping("save")
@RequiresRoles(value = {"admin","user"}, logical = Logical.OR)
//@RequiresPermissions("user:update:*")
public String save(){
    Subject subject = SecurityUtils.getSubject();
    //        if (subject.hasRole("admin")) {
    //            System.out.println("保存订单");
    //        }
    //        else {
    //            System.out.println("无权访问");
    //        }
    //        if (subject.isPermitted("user:save:01")) {
    //            System.out.println("保存订单");
    //        }
    //        else {
    //            System.out.println("无权访问");
    //        }
    return "redirect:/index.html";
}

五、SpringBoot整合Shiro

Maven

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring-boot-starter</artifactId>
    <version>1.8.0</version>
</dependency>

自定义CustomerRealm

public class CustomerRealm extends AuthorizingRealm {
    @Autowired
    private UserService userService;
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        String primaryPrincipal = (String) principalCollection.getPrimaryPrincipal();
        User user = userService.findRolesByUsername(primaryPrincipal);
        SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
        List<Role> roles = user.getRoles();
        roles.forEach(role -> {
            simpleAuthorizationInfo.addRole(role.getName());
            List<Permission> permissions = userService.findPermissionsByRoleId(role.getId());
            permissions.forEach(permission -> simpleAuthorizationInfo.addStringPermission(permission.getName()));
        });
        return simpleAuthorizationInfo;
    }
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        String principal = (String) authenticationToken.getPrincipal();
        User user = userService.findByUsername(principal);
        if (!ObjectUtils.isEmpty(user)) {
            return new SimpleAuthenticationInfo(user.getUsername(),user.getPassword(), ByteSource.Util.bytes(user.getSalt()),this.getName());
        }
        return null;
    }
}

ShiroConfig

@Configuration
public class ShiroConfig {
    // 创建ShiroFilter,负责拦截所有请求
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager defaultWebSecurityManager){
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        // 给filter设置安全管理器
        shiroFilterFactoryBean.setSecurityManager(defaultWebSecurityManager);
        // 配置系统受限资源
        // 配置系统公共资源
        Map<String,String> map = new HashMap<String,String>();
        map.put("/index.html","authc"); // authc表示请求这个资源需要认证和授权,未授权会自动跳转至认证界面
        map.put("/order/save","authc");
        // /**通配符表示所有资源受限,需要先将登录设置为公共资源再设置通配受限
        // 相应的还有anon过滤器,指定的资源可以匿名访问
        // 设置认证界面路径
        shiroFilterFactoryBean.setLoginUrl("/login.html"); // 默认认证界面路径为login.jsp
        shiroFilterFactoryBean.setFilterChainDefinitionMap(map);
        return shiroFilterFactoryBean;
    }
    // 创建安全管理器
    @Bean
    public DefaultWebSecurityManager getDefaultWebSecurityManager(@Qualifier("getRealm") Realm realm){
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        // 给安全管理器设置realm
        defaultWebSecurityManager.setRealm(realm);
        return defaultWebSecurityManager;
    }
    // 创建自定义Realm
    @Bean
    public CustomerRealm getRealm(){
        CustomerRealm customerRealm = new CustomerRealm();
        HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
        hashedCredentialsMatcher.setHashAlgorithmName("md5");
        hashedCredentialsMatcher.setHashIterations(1024);
        customerRealm.setCredentialsMatcher(hashedCredentialsMatcher);
        return customerRealm;
    }
}

Utils

public class SaltUtils {
    public static String getSalt(int len){
        String list = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890[];,./!@#$%^&*()_+-=";
        String result = "";
        for(int i=0; i<len; i++){
            result += list.charAt(new Random().nextInt(list.length()));
        }
        return result;
    }
}

页面

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
首页
<ul>
    <li >保存订单</li>
</ul>
<a href="/shiro/user/logout">退出</a>
</body>
</html>

register.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form action="http://localhost:8889/shiro/user/register" method="post">
    用户名:<input type="text" name="username"> <br>
    密码:<input type="text" name="password"> <br>
    <input type="submit" value="注册">
</form>
</body>
</html>

login.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form action="http://localhost:8889/shiro/user/login" method="post">
    用户名:<input type="text" name="username"> <br>
    密码:<input type="text" name="password"> <br>
    <input type="submit" value="登录">
</form>
</body>
</html>

controller

UserController

@Controller
@RequestMapping("user")
public class UserController {
    @Autowired
    private UserService userService;
    @PostMapping("login")
    public String login(String username, String password){
        //获取主体(ShiroConfig中创建了安全管理器,则SecurityUtils会自动配置上安全管理器)
        Subject subject = SecurityUtils.getSubject();
        try {
            subject.login(new UsernamePasswordToken(username,password));
            return "redirect:/index.html";
        } catch (AuthenticationException e) {
            e.printStackTrace();
            return "redirect:/login.html";
        }
    }
    @GetMapping("logout")
    public String logout(){
        Subject subject = SecurityUtils.getSubject();
        subject.logout();
        return "redirect:/login.html";
    }
    @PostMapping("register")
    public String register(User user) {
        userService.register(user);
        return "redirect:/login.html";
    }
}

OrderController

@Controller
@RequestMapping("order")
public class OrderController {
    @RequestMapping("save")
    @RequiresRoles(value = {"admin","user"}, logical = Logical.OR)
    @RequiresPermissions("user:update:*")
    public String save(){
        Subject subject = SecurityUtils.getSubject();
//        if (subject.hasRole("admin")) {
//            System.out.println("保存订单");
//        }
//        else {
//            System.out.println("无权访问");
//        }
//        if (subject.isPermitted("user:save:01")) {
//            System.out.println("保存订单");
//        }
//        else {
//            System.out.println("无权访问");
//        }
        return "redirect:/index.html";
    }
}

serviceimpl

@Service
public class UserServiceImpl implements UserService {
    @Autowired
    private UserDao userDao;
    @Override
    public void register(User user) {
        user.setId(2L);
        String salt = SaltUtils.getSalt(8);
        user.setSalt(salt);
        Md5Hash md5Hash = new Md5Hash(user.getPassword(), salt, 1024);
        user.setPassword(md5Hash.toHex());
        userDao.save(user);
    }
    @Override
    public User findByUsername(String username) {
        return userDao.findByUsername(username);
    }
    @Override
    public User findRolesByUsername(String username) {
        return userDao.findRolesByUsername(username);
    }
    @Override
    public List<Permission> findPermissionsByRoleId(Long id) {
        return userDao.findPermissionsByRoleId(id);
    }
}
@Mapper
public interface UserDao {
    void save(User user);
    User findByUsername(String username);
    User findRolesByUsername(String username);
    List<Permission> findPermissionsByRoleId(Long id);
}
<?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="cn.ken.springboot_shiro.dao.UserDao">
    <insert id="save" parameterType="user">
        insert into user values(#{id},#{username},#{password},#{salt})
    </insert>
    <select id="findByUsername" parameterType="string" resultType="user">
        select * from user where username = #{username}
    </select>
    <resultMap id="userMap" type="User">
        <id column="uid" property="id"/>
        <result column="username" property="username"/>
        <collection property="roles" javaType="list" ofType="role">
            <id column="rid" property="id"/>
            <result column="rname" property="name"/>
        </collection>
    </resultMap>
    <select id="findRolesByUsername" parameterType="String" resultMap="userMap">
        SELECT u.id uid, u.username, r.id rid, r.name rname
        FROM `user` u LEFT JOIN `user_role` ur
        ON u.id = ur.user_id
        LEFT JOIN `role` r
        ON ur.role_id = r.id
        WHERE u.username = #{username}
    </select>
    <select id="findPermissionsByRoleId" parameterType="Long" resultType="permission">
        SELECT p.id, p.name, p.url FROM `role` r
        LEFT JOIN `role_permission` rp
        ON r.id = rp.role_id
        LEFT JOIN `permission` p
        ON p.id = rp.per_id
        WHERE r.id = #{id}
    </select>
</mapper>

entity

@Data
@NoArgsConstructor
@AllArgsConstructor
public class User implements Serializable {
    private Long id;
    private String username;
    private String password;
    private String salt;
    private List<Role> roles;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Role implements Serializable {
    private Long id;
    private String name;
    private List<Permission> permissions;
}

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Permission implements Serializable {
    private Long id;
    private String name;
    private String url;
}


六、缓存

以上操作存在一定的问题,就是需要频繁的操作数据库,对数据库压力过大,故引入缓存

EhCache(程序内部缓存,重启数据丢失)


1、引入依赖

<dependency>
  <groupId>org.apache.shiro</groupId>
  <artifactId>shiro-ehcache</artifactId>
  <version>1.8.0</version>
</dependency>

2、开启缓存

在ShiroConfig的getRealm中设置

// 创建自定义Realm
@Bean
public CustomerRealm getRealm(){
    CustomerRealm customerRealm = new CustomerRealm();
    HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
    hashedCredentialsMatcher.setHashAlgorithmName("md5");
    hashedCredentialsMatcher.setHashIterations(1024);
    customerRealm.setCredentialsMatcher(hashedCredentialsMatcher);
    // 开启缓存管理
    customerRealm.setCacheManager(new EhCacheManager());
    customerRealm.setCachingEnabled(true); // 开启全局缓存
    customerRealm.setAuthenticationCachingEnabled(true); // 开启认证缓存
    customerRealm.setAuthenticationCacheName("authenticationCache");
    customerRealm.setAuthorizationCachingEnabled(true); // 开启授权缓存
    customerRealm.setAuthorizationCacheName("authorizationCache");
    return customerRealm;
}

3、启动刷新页面进行测试

如果控制台没有任何sql展示说明缓存已经开启

Redis(分布式缓存)

相关文章
|
28天前
|
监控 Java 应用服务中间件
微服务——SpringBoot使用归纳——为什么学习Spring Boot
本文主要探讨为什么学习Spring Boot。从Spring官方定位来看,Spring Boot旨在快速启动和运行项目,简化配置与编码。其优点包括:1) 良好的基因,继承了Spring框架的优点;2) 简化编码,通过starter依赖减少手动配置;3) 简化配置,采用Java Config方式替代繁琐的XML配置;4) 简化部署,内嵌Tomcat支持一键式启动;5) 简化监控,提供运行期性能参数获取功能。此外,从未来发展趋势看,微服务架构逐渐成为主流,而Spring Boot作为官方推荐技术,与Spring Cloud配合使用,将成为未来发展的重要方向。
37 0
微服务——SpringBoot使用归纳——为什么学习Spring Boot
|
28天前
|
安全 Java Apache
微服务——SpringBoot使用归纳——Spring Boot中集成 Shiro——Shiro 身份和权限认证
本文介绍了 Apache Shiro 的身份认证与权限认证机制。在身份认证部分,分析了 Shiro 的认证流程,包括应用程序调用 `Subject.login(token)` 方法、SecurityManager 接管认证以及通过 Realm 进行具体的安全验证。权限认证部分阐述了权限(permission)、角色(role)和用户(user)三者的关系,其中用户可拥有多个角色,角色则对应不同的权限组合,例如普通用户仅能查看或添加信息,而管理员可执行所有操作。
68 0
|
28天前
|
安全 Java 数据安全/隐私保护
微服务——SpringBoot使用归纳——Spring Boot中集成 Shiro——Shiro 三大核心组件
本课程介绍如何在Spring Boot中集成Shiro框架,主要讲解Shiro的认证与授权功能。Shiro是一个简单易用的Java安全框架,用于认证、授权、加密和会话管理等。其核心组件包括Subject(认证主体)、SecurityManager(安全管理员)和Realm(域)。Subject负责身份认证,包含Principals(身份)和Credentials(凭证);SecurityManager是架构核心,协调内部组件运作;Realm则是连接Shiro与应用数据的桥梁,用于访问用户账户及权限信息。通过学习,您将掌握Shiro的基本原理及其在项目中的应用。
78 0
|
5月前
|
前端开发 Java 数据库
SpringBoot入门 - 对Hello world进行MVC分层
SpringBoot入门 - 对Hello world进行MVC分层
100 3
SpringBoot入门 - 对Hello world进行MVC分层
|
5月前
|
Java 数据库连接 测试技术
SpringBoot入门 - 添加内存数据库H2
SpringBoot入门 - 添加内存数据库H2
246 3
SpringBoot入门 - 添加内存数据库H2
|
3月前
|
XML JavaScript Java
SpringBoot集成Shiro权限+Jwt认证
本文主要描述如何快速基于SpringBoot 2.5.X版本集成Shiro+JWT框架,让大家快速实现无状态登陆和接口权限认证主体框架,具体业务细节未实现,大家按照实际项目补充。
202 11
|
4月前
|
Java 开发者 微服务
Spring Boot 入门:简化 Java Web 开发的强大工具
Spring Boot 是一个开源的 Java 基础框架,用于创建独立、生产级别的基于Spring框架的应用程序。它旨在简化Spring应用的初始搭建以及开发过程。
137 7
Spring Boot 入门:简化 Java Web 开发的强大工具
|
5月前
|
Java 应用服务中间件 数据库连接
SpringBoot入门 - SpringBoot HelloWorld
SpringBoot入门 - SpringBoot HelloWorld
SpringBoot入门 - SpringBoot HelloWorld
|
5月前
|
Java Spring
SpringBoot入门 - 定制自己的Banner
SpringBoot入门 - 定制自己的Banner
73 2
SpringBoot入门 - 定制自己的Banner
|
5月前
|
缓存 IDE Java
SpringBoot入门(7)- 配置热部署devtools工具
SpringBoot入门(7)- 配置热部署devtools工具
375 1
SpringBoot入门(7)- 配置热部署devtools工具