spring5源码系列--循环依赖 之 手写代码模拟spring循环依赖 (下)

简介: spring5源码系列--循环依赖 之 手写代码模拟spring循环依赖 (下)

6. 增加三级缓存



三级缓存有什么作用呢? 这个问题众说纷纭, 有说代理, 有说AOP. 其实AOP的问题可以用二级缓存来解决. 下面就来看看AOP如何用二级缓存解决.


创建AOP动态代理 (不是耦合的, 采用解耦的, 通过BeanPostProcessor bean的后置处理器来创建). 之前讲过, 如下图


在初始化之后, 调用Bean的后置处理器去创建的AOP的动态代理

1187916-20201106103849714-2078147201.png


如上图. 我们在创建bean 的时候, 会有很多Bean的后置处理器BeanPostProcessor. 如果有AOP, 会在什么时候创建呢? 在初始化以后, 调用BeanPostProcessor创建动态代理.

结合上面的代码, 我们想一想, 其实在初始化以后创建动态代理就晚了. 为什么呢? 因为, 如果有循环依赖, 在初始化之后才调用, 那就不是动态代理. 其实我们这时候应该在实例化之后, 放入到二级缓存之前调用


面试题: 在创建bean的时候, 在哪里创建的动态代理, 这个应该怎么回答呢?

很多人会说在初始化之后, 或者在实例化之后.


其实更严谨的说,有两种情况: 第一种是在初始化之后调用 . 第二种是出现了循环依赖, 会在实例化之后调用


我们上面说的就是第二种情况. 也就是说,正常情况下是在初始化之后调用的, 但是如果有循环依赖, 就要在实例化之后调用了.

 

下面来看看如何在二级缓存加动态代理.

首先, 我们这里有循环依赖, 所以将动态代理放在实例化之后,

/**
     * 获取bean, 根据beanName获取
     */
    public static Object getBean(String beanName) throws Exception {
        // 增加一个出口. 判断实体类是否已经被加载过了
        Object singleton = getSingleton(beanName);
        if (singleton != null) {
            return singleton;
        }
        /**
         * 第一步: 实例化
         * 我们这里是模拟, 采用反射的方式进行实例化. 调用的也是最简单的无参构造函数
         */
        RootBeanDefinition beanDefinition = (RootBeanDefinition) beanDefinitionMap.get(beanName);
        Class<?> beanClass = beanDefinition.getBeanClass();
        // 调用无参的构造函数进行实例化
        Object instanceBean = beanClass.newInstance();
        /**
         * 创建AOP动态代理 (不是耦合的, 采用解耦的, 通过BeanPostProcessor bean的后置处理器得来的.  之前讲过,
         * 在初始化之后, 调用Bean的后置处理器去创建的AOP的动态代理 )
         */
        instanceBean = new JdkProxyBeanPostProcessor().getEarlyBeanReference(instanceBean, "instanceA");
        /**
         * 第二步: 放入到二级缓存
         */
        earlySingletonObjects.put(beanName, instanceBean);
        /**
         *  第三步: 属性赋值
         *  instanceA这类类里面有一个属性, InstanceB. 所以, 先拿到 instanceB, 然后在判断属性头上有没有Autowired注解.
         *  注意: 这里我们只是判断有没有Autowired注解. spring中还会判断有没有@Resource注解. @Resource注解还有两种方式, 一种是name, 一种是type
          */
        Field[] declaredFields = beanClass.getDeclaredFields();
        for (Field declaredField: declaredFields) {
            // 判断每一个属性是否有@Autowired注解
            Autowired annotation = declaredField.getAnnotation(Autowired.class);
            if (annotation != null) {
                // 设置这个属性是可访问的
                declaredField.setAccessible(true);
                // 那么这个时候还要构建这个属性的bean.
                /*
                 * 获取属性的名字
                 * 真实情况, spring这里会判断, 是根据名字, 还是类型, 还是构造函数来获取类.
                 * 我们这里模拟, 所以简单一些, 直接根据名字获取.
                 */
                String name = declaredField.getName();
                /**
                 * 这样, 在这里我们就拿到了 instanceB 的 bean
                 */
                Object fileObject = getBean(name);
                // 为属性设置类型
                declaredField.set(instanceBean, fileObject);
            }
        }
        /**
         * 第四步: 初始化
         * 初始化就是设置类的init-method.这个可以设置也可以不设置. 我们这里就不设置了
         */
      //   正常动态代理创建的时机
        /**
         * 第五步: 放入到一级缓存
         */
        singletonObjects.put(beanName, instanceBean);
        return instanceBean;
    }

这里只是简单模拟了动态代理.


我们知道动态代理有两个地方. 如果是普通类动态代理在初始化之后执行, 如果是循环依赖, 那么动态代理是在实例化之后.

 

上面在实例化之后创建proxy的代码不完整, 为什么不完整呢, 因为没有判断是否是循环依赖.

 

我们简单模拟一个动态代理的实现.


public class JdkProxyBeanPostProcessor implements SmartInstantiationAwareBeanPostProcessor {
    /**
     * 假设A被切点命中 需要创建代理  @PointCut("execution(* *..InstanceA.*(..))")
     * @param bean the raw bean instance
     * @param beanName the name of the bean
     * @return
     * @throws BeansException
     */
    @Override
    public Object getEarlyBeanReference(Object bean, String beanName) throws BeansException {
        // 假设A被切点命中 需要创建代理  @PointCut("execution(* *..InstanceA.*(..))")
        /**
         * 这里, 我们简单直接判断bean是不是InstanceA实例, 如果是, 就创建动态代理.
         * 这里没有去解析切点, 解析切点是AspectJ做的事.
         */
        if (bean instanceof InstanceA) {
            JdkDynimcProxy jdkDynimcProxy = new JdkDynimcProxy(bean);
            return jdkDynimcProxy.getProxy();
        }
        return bean;
    }
}

这里直接判断, 如果bean是InstanceA的实例, 那么就调用bean的动态代理.  动态代理的简单逻辑就是: 解析切面, 然后创建类, 如果类不存在就新增, 如果存在则不在创建, 直接取出来返回.

 

在来看看动态代理,放在实例化之后. 创建AOP, 但是, 在这里创建AOP动态代理的条件是循环依赖.


问题1: 那么如何判断是循环依赖呢?

二级缓存中bean不是null.

如果一个类在创建的过程中, 会放入到二级缓存, 如果完全创建完了, 会放入到一级缓存, 然后删除二级缓存. 所以, 如果二级缓存中的bean只要存在, 就说明这个类是创建中, 出现了循环依赖.


问题2: 什么时候判断呢?

应该在getSingleton()判断是否是循环依赖的时候判断. 因为这时候我们刚好判断了二级缓存中bean是否为空.


/**
     * 判断是否是循环引用的出口.
     * @param beanName
     * @return
     */
    private static Object getSingleton(String beanName) {
        // 先去一级缓存里拿,如果一级缓存没有拿到,去二级缓存里拿
        if (singletonObjects.containsKey(beanName)) {
            return singletonObjects.get(beanName);
        } else if (earlySingletonObjects.containsKey(beanName)){
            /**
             * 第一次创建bean是正常的instanceBean. 他并不是循环依赖. 第二次进来判断, 这个bean已经存在了, 就说明是循环依赖了
             * 这时候通过动态代理创建bean. 然后将这个bean在放入到二级缓存中覆盖原来的instanceBean.
             */
            Object obj = new JdkProxyBeanPostProcessor()
                    .getEarlyBeanReference(earlySingletonObjects.get(beanName), beanName);
            earlySingletonObjects.put(beanName, obj);
            return earlySingletonObjects.get(beanName);
        } else {
            return null;
        }
    }

这样我们在循环依赖的时候就完成了AOP的创建. 这是在二级缓存里创建的AOP,

问题3: 那这是不是说就不需要三级缓存了呢?

那么,来找问题.  这里有两个问题:

问题1: 我们发现在创建动态代理的时候, 我们使用的bean的后置处理器JdkProxyBeanPostProcessor.这有点不太符合规则,

     因为, spring在getBean()的时候并没有使用Bean的后置处理器, 而是在createBean()的时候才去使用的bean的后置处理器.

问题2: 如果A是AOP, 他一直都是, 最开始创建的时候也应该是. 使用这种方法, 结果是第一次创建出来的bean不是AOP动态代理.

 

对于第一个问题: 我们希望在实例化的时候创建AOP, 但是具体判断是在getSingleton()方法里判断. 这里通过三级缓存来实现. 三级缓存里面放的是一个接口定义的钩子方法. 方法的执行在后面调用的时候执行.

 

对于第二个问题: 我们的二级缓存就不能直接保存instanceBean实例了, 增加一个参数, 用来标记当前这个类是一个正在创建中的类. 这样来判断循环依赖.

 

下面先来看看创建的三个缓存和一个标识


// 一级缓存
    private static Map<String, Object> singletonObjects = new ConcurrentHashMap<>();
    // 二级缓存: 为了将成熟的bean和纯净的bean分离. 避免读取到不完整的bean.
    private static Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>();
    // 三级缓存:
    private static Map<String, ObjectFactory> singletonFactories = new ConcurrentHashMap<>();
    // 循环依赖的标识---当前正在创建的实例bean
    private static Set<String> singletonsCurrectlyInCreation = new HashSet<>();

然后在来看看循环依赖的出口

/**
     * 判断是否是循环引用的出口.
     * @param beanName
     * @return
     */
    private static Object getSingleton(String beanName) {
        //先去一级缓存里拿
        Object bean = singletonObjects.get(beanName);
        // 一级缓存中没有, 但是正在创建的bean标识中有, 说明是循环依赖
        if (bean == null && singletonsCurrectlyInCreation.contains(beanName)) {
            bean = earlySingletonObjects.get(beanName);
            // 如果二级缓存中没有, 就从三级缓存中拿
            if (bean == null) {
                // 从三级缓存中取
                ObjectFactory objectFactory = singletonFactories.get(beanName);
                if (objectFactory != null) {
                    // 这里是真正创建动态代理的地方.
                    Object obj = objectFactory.getObject();
                    // 然后将其放入到二级缓存中. 因为如果有多次依赖, 就去二级缓存中判断. 已经有了就不在再次创建了
                    earlySingletonObjects.put(beanName, obj);
                }
            }
        }
        return bean;
    }

这里的逻辑是, 先去一级缓存中拿, 一级缓存放的是成熟的bean, 也就是他已经完成了属性赋值和初始化. 如果一级缓存没有, 而正在创建中的类标识是true, 就说明这个类正在创建中, 这是一个循环依赖. 这个时候就去二级缓存中取数据, 二级缓存中的数据是何时放进去的呢, 是后面从三级缓存中创建动态代理后放进去的. 如果二级缓存为空, 说明没有创建过动态代理, 这时候在去三级缓存中拿, 然后创建动态代理. 创建完以后放入二级缓存中, 后面就不用再创建.

 

完成的代码如下:

package com.lxl.www.circulardependencies;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Autowire;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.support.RootBeanDefinition;
import java.lang.reflect.Field;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public class MainStart {
    private static Map<String, BeanDefinition> beanDefinitionMap = new ConcurrentHashMap<>();
    // 一级缓存
    private static Map<String, Object> singletonObjects = new ConcurrentHashMap<>();
    // 二级缓存: 为了将成熟的bean和纯净的bean分离. 避免读取到不完整的bean.
    private static Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>();
    // 三级缓存:
    private static Map<String, ObjectFactory> singletonFactories = new ConcurrentHashMap<>();
    // 循环依赖的标识---当前正在创建的实例bean
    private static Set<String> singletonsCurrectlyInCreation = new HashSet<>();
    /**
     * 读取bean定义, 当然在spring中肯定是根据配置 动态扫描注册的
     *
     * InstanceA和InstanceB都有注解@Component, 所以, 在spring扫描读取配置类的时候, 会把他们两个扫描到BeanDefinitionMap中.
     * 这里, 我们省略这一步, 直接将instanceA和instanceB放到BeanDefinitionMap中.
     */
    public static void loadBeanDefinitions(){
        RootBeanDefinition aBeanDefinition = new RootBeanDefinition(InstanceA.class);
        RootBeanDefinition bBeanDefinition = new RootBeanDefinition(InstanceB.class);
        beanDefinitionMap.put("instanceA", aBeanDefinition);
        beanDefinitionMap.put("instanceB", bBeanDefinition);
    }
    public static void main(String[] args) throws Exception {
        // 第一步: 扫描配置类, 读取bean定义
        loadBeanDefinitions();
        // 第二步: 循环创建bean
        for (String key: beanDefinitionMap.keySet()) {
            // 第一次: key是instanceA, 所以先创建A类
            getBean(key);
        }
        // 测试: 看是否能执行成功
        InstanceA instanceA = (InstanceA) getBean("instanceA");
        instanceA.say();
    }
    /**
     * 获取bean, 根据beanName获取
     */
    public static Object getBean(String beanName) throws Exception {
        // 增加一个出口. 判断实体类是否已经被加载过了
        Object singleton = getSingleton(beanName);
        if (singleton != null) {
            return singleton;
        }
        // 标记bean正在创建
        if (!singletonsCurrectlyInCreation.contains(beanName)) {
            singletonsCurrectlyInCreation.add(beanName);
        }
        /**
         * 第一步: 实例化
         * 我们这里是模拟, 采用反射的方式进行实例化. 调用的也是最简单的无参构造函数
         */
        RootBeanDefinition beanDefinition = (RootBeanDefinition) beanDefinitionMap.get(beanName);
        Class<?> beanClass = beanDefinition.getBeanClass();
        // 调用无参的构造函数进行实例化
        Object instanceBean = beanClass.newInstance();
        /**
         * 第二步: 放入到三级缓存
         * 每一次createBean都会将其放入到三级缓存中. getObject是一个钩子方法. 在这里不会被调用.
         * 什么时候被调用呢?
         * 在getSingleton()从三级缓存中取数据, 调用创建动态代理的时候
         */
        singletonFactories.put(beanName, new ObjectFactory() {
            @Override
            public Object getObject() throws BeansException {
                return new JdkProxyBeanPostProcessor().getEarlyBeanReference(earlySingletonObjects.get(beanName), beanName);
            }
        });
        //earlySingletonObjects.put(beanName, instanceBean);
        /**
         *  第三步: 属性赋值
         *  instanceA这类类里面有一个属性, InstanceB. 所以, 先拿到 instanceB, 然后在判断属性头上有没有Autowired注解.
         *  注意: 这里我们只是判断有没有Autowired注解. spring中还会判断有没有@Resource注解. @Resource注解还有两种方式, 一种是name, 一种是type
         */
        Field[] declaredFields = beanClass.getDeclaredFields();
        for (Field declaredField: declaredFields) {
            // 判断每一个属性是否有@Autowired注解
            Autowired annotation = declaredField.getAnnotation(Autowired.class);
            if (annotation != null) {
                // 设置这个属性是可访问的
                declaredField.setAccessible(true);
                // 那么这个时候还要构建这个属性的bean.
                /*
                 * 获取属性的名字
                 * 真实情况, spring这里会判断, 是根据名字, 还是类型, 还是构造函数来获取类.
                 * 我们这里模拟, 所以简单一些, 直接根据名字获取.
                 */
                String name = declaredField.getName();
                /**
                 * 这样, 在这里我们就拿到了 instanceB 的 bean
                 */
                Object fileObject = getBean(name);
                // 为属性设置类型
                declaredField.set(instanceBean, fileObject);
            }
        }
        /**
         * 第四步: 初始化
         * 初始化就是设置类的init-method.这个可以设置也可以不设置. 我们这里就不设置了
         */
        /**
         * 第五步: 放入到一级缓存
         *
         * 在这里二级缓存存的是动态代理, 那么一级缓存肯定也要存动态代理的实例.
         * 从二级缓存中取出实例, 放入到一级缓存中
         */
        if (earlySingletonObjects.containsKey(beanName)) {
            instanceBean = earlySingletonObjects.get(beanName);
        }
        singletonObjects.put(beanName, instanceBean);
        return instanceBean;
    }
    /**
     * 判断是否是循环引用的出口.
     * @param beanName
     * @return
     */
    private static Object getSingleton(String beanName) {
        //先去一级缓存里拿,
        Object bean = singletonObjects.get(beanName);
        // 一级缓存中没有, 但是正在创建的bean标识中有, 说明是循环依赖
        if (bean == null && singletonsCurrectlyInCreation.contains(beanName)) {
            bean = earlySingletonObjects.get(beanName);
            // 如果二级缓存中没有, 就从三级缓存中拿
            if (bean == null) {
                // 从三级缓存中取
                ObjectFactory objectFactory = singletonFactories.get(beanName);
                if (objectFactory != null) {
                    // 这里是真正创建动态代理的地方.
                    Object obj = objectFactory.getObject();
                    // 然后将其放入到二级缓存中. 因为如果有多次依赖, 就去二级缓存中判断. 已经有了就不在再次创建了
                    earlySingletonObjects.put(beanName, obj);
                }
            }
        }
        return bean;
    }
}

1187916-20201108073933562-1940526219.png


下面就我们的代码分析一下:


第一种情况: 没有循环依赖

第二种情况: 有循环依赖

第三种情况: 有多次循环依赖

我们模拟一个循环依赖的场景, 覆盖这三种情况.

1187916-20201108074451802-402797875.png

用代码表示

类A

package com.lxl.www.circulardependencies;
import org.springframework.beans.factory.annotation.Autowired;
public class A {
    @Autowired
    private B b;
    @Autowired
    private C c;
}

类B

package com.lxl.www.circulardependencies;
import org.springframework.beans.factory.annotation.Autowired;
public class B {
    @Autowired
    private A a;
    @Autowired
    private B b;
}

类C

package com.lxl.www.circulardependencies;
import org.springframework.beans.factory.annotation.Autowired;
public class C {
    @Autowired
    private A a;
}


其中类A刚好匹配AOP的切面@PointCut("execution(* *..A.*(..))")

 

下面分析他们的循环依赖关系.

此时beanDefinitionMap中有三个bean定义. 分别是A, B, C

1. 先解析类A, 根据上面的流程.

  1) 首先调用getSingleton, 此时一级缓存, 二级缓存都没有, 正在创建标志也是null. 所以, 返回的是null

  2) 标记当前类正在创建中

  3) 实例化

  4) 将A放入到三级缓存, 并定义动态代理的钩子方法

  5) 属性赋值. A有两个属性, 分别是B和C. 都带有@Autowired注解, 先解析B.

  6) A暂停, 解析B

2. 解析A类的属性类B

  1) 首先调用getSingleton, 此时一级缓存, 二级缓存都没有, 正在创建标志也是null. 所以, 返回的是null  

  2) 标记当前类正在创建中

  3) 实例化

  4) 将B放入到三级缓存, 并定义动态代理的钩子方法

  5) 属性赋值. B有两个属性, 分别是A和C. 都带有@Autowired注解, 先解析A. 在解析C

  6) B暂停, 解析A

3. 解析B类的属性A 

  1) 首先调用getSingleton, 此时一级缓存中这个属性为null, 正在创建中标志位true, 二级缓存为空, 从三级缓存中创建动态代理, 然后判断是否符合动态代理切面要求, A符合. 所以通过动态代理创建A的代理bean放入到二级缓存.返回实例bean.


  2) A此时已经存在了, 所以, 直接返回

4. 解析B类的属性C

  1) 首先调用getSingleton, 此时一级缓存, 二级缓存都没有, 正在创建标志也是null. 所以, 返回的是null  

  2) 标记当前类C正在创建中

  3) 实例化

  4) 将C放入到三级缓存, 并定义动态代理的钩子方法

  5) 属性赋值. C有一个属性, 是A. 带有@Autowired注解, 先解析A

  6) C暂停, 解析A

5. 解析C中的属性A

  1) 首先调用getSingleton()方法, 此时一级缓存中没有, 标志位为true, 二级缓存中已经有A的动态代理实例了, 所以,直接返回.

  2) A此时已经在存在, 直接返回

6. 继续解析B类的属性C

  1) 接着第4步往下走

  2) 初始化类C

  3) 将类C放入到一级缓存中. 放之前去二级缓存中取, 二级缓存中没有. 所以, 这里存的是C通过反射构建的instanceBean

7. 继续解析A类的属性类B

  1) 接着第2步往下走

  2) 初始化类B

  3) 将类B放入到一级缓存中. 放之前去二级缓存中取.二级缓存中没有, 所以, 这里存的是B通过反射构建的instanceBean

  4) 构建结束,返回

8. 解析A类的属性类C

  1) 首先调用getSingleton()方法, 此时一级缓存中已经有了类C, 所以直接返回

9. 继续解析A类

  1) 接着第1步往下走

  2) 初始化类A

  3) 将A放入到一级缓存中. 放之前判断二级缓存中有没有实例bean, 我们发现有, 所以, 取出来放入到A的一级缓存中.

  4) 构建bean结束, 返回

10. 接下来构建beanDefinitionMap中的类B

  1) 首先调用getSingleton()方法, 此时一级缓存中已经有了类B, 所以直接返回

11. 接下来构建beanDefinitionMap中的类C

  1) 首先调用getSingleton()方法, 此时一级缓存中已经有了类C, 所以直接返回

至此整个构建过程结束.

 

总结:



再来感受一下三级缓存的作用:

一级缓存: 用来存放成熟的bean. 这个bean如果是切入点, 则是一个动态代理的bean,如果不是切入点, 则是一个普通的类

二级缓存: 用来存放循环依赖过程中创建的动态代理bean.

三级缓存: 用来存放动态代理的钩子方法. 用来在需要构建动态代理类的时候使用.


相关文章
|
5天前
|
监控 Java 应用服务中间件
Spring Boot 源码面试知识点
【5月更文挑战第12天】Spring Boot 是一个强大且广泛使用的框架,旨在简化 Spring 应用程序的开发过程。深入了解 Spring Boot 的源码,有助于开发者更好地使用和定制这个框架。以下是一些关键的知识点:
25 6
|
5天前
|
Java 应用服务中间件 测试技术
深入探索Spring Boot Web应用源码及实战应用
【5月更文挑战第11天】本文将详细解析Spring Boot Web应用的源码架构,并通过一个实际案例,展示如何构建一个基于Spring Boot的Web应用。本文旨在帮助读者更好地理解Spring Boot的内部工作机制,以及如何利用这些机制优化自己的Web应用开发。
32 3
|
5天前
|
存储 前端开发 Java
Spring Boot自动装配的源码学习
【4月更文挑战第8天】Spring Boot自动装配是其核心机制之一,其设计目标是在应用程序启动时,自动配置所需的各种组件,使得应用程序的开发和部署变得更加简单和高效。下面是关于Spring Boot自动装配的源码学习知识点及实战。
17 1
|
5天前
|
缓存 Java 开发工具
【spring】如何解决循环依赖
【spring】如何解决循环依赖
14 0
|
5天前
|
传感器 人工智能 前端开发
JAVA语言VUE2+Spring boot+MySQL开发的智慧校园系统源码(电子班牌可人脸识别)Saas 模式
智慧校园电子班牌,坐落于班级的门口,适合于各类型学校的场景应用,班级学校日常内容更新可由班级自行管理,也可由学校统一管理。让我们一起看看,电子班牌有哪些功能呢?
108 4
JAVA语言VUE2+Spring boot+MySQL开发的智慧校园系统源码(电子班牌可人脸识别)Saas 模式
|
5天前
|
设计模式 安全 Java
【初学者慎入】Spring源码中的16种设计模式实现
以上是威哥给大家整理了16种常见的设计模式在 Spring 源码中的运用,学习 Spring 源码成为了 Java 程序员的标配,你还知道Spring 中哪些源码中运用了设计模式,欢迎留言与威哥交流。
|
5天前
|
存储 缓存 Java
【Spring系列笔记】依赖注入,循环依赖以及三级缓存
依赖注入: 是指通过外部配置,将依赖关系注入到对象中。依赖注入有四种主要方式:构造器注入、setter方法注入、接口注入以及注解注入。其中注解注入在开发中最为常见,因为其使用便捷以及可维护性强;构造器注入为官方推荐,可注入不可变对象以及解决循环依赖问题。本文基于依赖注入方式引出循环依赖以及三层缓存的底层原理,以及代码的实现方式。
24 0
|
5天前
|
存储 缓存 Java
【spring】06 循环依赖的分析与解决
【spring】06 循环依赖的分析与解决
9 1
|
Java 关系型数据库 MySQL
06_spring_ 依赖注入| 学习笔记
快速学习 06_spring_ 依赖注入
|
5天前
|
Java 应用服务中间件 Maven
SpringBoot 项目瘦身指南
SpringBoot 项目瘦身指南
63 0