MiniSpring框架学习-完成的 IoC 容器

MiniSpring框架学习-完成的 IoC 容器

教程: https://github.com/YaleGuo/minis
极客时间: 手把手带你写一个 MiniSpring

05. 完成的 IoC 容器

前面已经可以通过 XML 创建 Bean、处理 setter 循环依赖了,现在继续往前走一步,让容器支持 @Autowired 注解,并且把 BeanFactory 的体系拆得更像 Spring。

学完这一节,重点要明白三件事:

  1. 注解本身不会自动注入对象,注解只是元信息,真正干活的是容器。
  2. @Autowired 注入的核心,是运行时通过反射找到字段,再从容器里拿依赖对象塞进去。
  3. 容器体系拆成一堆接口和抽象类,不是为了复杂,而是为了把"能力"和"实现流程"分开。

本节主线可以先记成这样:

text 复制代码
读取 BeanDefinition
    -> 创建 Bean
    -> 处理 XML 属性注入
    -> 触发初始化前扩展点,Demo 在这里处理 @Autowired 字段注入
    -> 执行初始化和初始化后扩展点
    -> 放入单例缓存

先说清楚:注解是什么

Java 注解本质上是一种元数据声明方式。

说得口语一点:注解就是贴在类、字段、方法上的一个"标记"。它自己不会执行逻辑,只是告诉后面的程序:"这里有个信息,你要不要处理一下?"

比如:

java 复制代码
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Autowired {
}

这里有两个关键点:

  1. @Target(ElementType.FIELD):说明这个注解只能贴在字段上。
  2. @Retention(RetentionPolicy.RUNTIME):说明程序运行时还能通过反射读到它。

如果没有 RetentionPolicy.RUNTIME,运行时就读不到这个注解,容器也就没法根据它做注入。

再看使用方式:

java 复制代码
public class UserService {

    @Autowired
    private UserDao userDao;
}

这行代码不会让 userDao 自动有值。

真正的过程是:

text 复制代码
程序编译时,把 @Autowired 信息写进 .class 文件
JVM 加载 UserService.class
容器创建 UserService 对象
容器通过反射扫描字段
发现 userDao 字段上有 @Autowired
容器从 BeanFactory 中找到 UserDao 对象
容器通过反射把 UserDao 对象设置到 userDao 字段上

所以一定要记住:

text 复制代码
注解只是标记,容器读到标记以后执行逻辑,注入才会发生。

反射怎么读注解

下面这个小例子只做一件事:判断字段上有没有 @Autowired

java 复制代码
public class Test {
    public static void main(String[] args) throws Exception {
        UserService userService = new UserService();

        Class<?> clazz = userService.getClass();
        Field[] fields = clazz.getDeclaredFields();

        for (Field field : fields) {
            if (field.isAnnotationPresent(Autowired.class)) {
                System.out.println(field.getName() + " 字段上有 @Autowired 注解");
            }
        }
    }
}

这里有几个小细节:

  1. getDeclaredFields() 只拿当前类自己声明的字段,不会自动拿父类字段。
  2. isAnnotationPresent(Autowired.class) 只是判断有没有注解。
  3. 如果字段是 private,真正赋值前需要调用 field.setAccessible(true)

注解在 .class 文件里的感觉,可以粗略理解成这样:

text 复制代码
field: userDao
  type: UserDao
  annotations:
    - Autowired

JVM 加载类以后,反射 API 就能读到这些信息。

从 XML 注入到注解注入

前面 XML 注入时,依赖关系写在 XML 里:

xml 复制代码
<property type="com.chenhai.test.BaseService" name="ref1" ref="baseservice"/>

容器读到这段配置,就知道:

text 复制代码
aservice 的 ref1 属性,需要注入名为 baseservice 的 Bean。

现在改成注解以后,XML 里可能只保留 Bean 的类名:

xml 复制代码
<bean id="baseservice" class="com.chenhai.test.BaseService"/>
<bean id="bbs" class="com.chenhai.test.BaseBaseService"/>

依赖关系写到 Java 类里:

java 复制代码
@Data
public class BaseService {

    @Autowired
    private BaseBaseService bbs;

    public void sayHello() {
        System.out.println("Base Service says Hello");
        System.out.println(this.bbs != null);
    }
}

这时容器就不能只看 BeanDefinition 里的 property 配置了,还要扫描 Bean 对象字段上的注解。

也就是说,注入信息来源变多了:

text 复制代码
XML property/ref   -> 从 BeanDefinition 里读
@Autowired 字段    -> 从 Class/Field 反射信息里读

入口代码

测试入口可以这样写:

java 复制代码
public class Test1 {

    public static void main(String[] args) throws BeansException {
        ClassPathXmlApplicationContext context =
                new ClassPathXmlApplicationContext("beans.xml");

        AService aService = (AService) context.getBean("aservice");
        aService.sayHello();

        BaseService baseService = (BaseService) context.getBean("baseservice");
        baseService.sayHello();
    }
}

这里重点看两个结果:

  1. aservice 仍然可以使用 XML 里的构造器注入、普通属性注入、ref 注入。
  2. baseservice.bbs 可以通过 @Autowired 注入,打印 true 表示注入成功。

ApplicationContext 做了什么变化

原来的上下文可能直接使用一个简单的 SimpleBeanFactory

现在为了支持自动注入和生命周期扩展,需要换成更强的 BeanFactory,比如:

java 复制代码
public ClassPathXmlApplicationContext(String fileName, boolean isRefresh) throws BeansException {
    Resource resource = new ClassPathXmlResource(fileName);

    AutowireCapableBeanFactory beanFactory = new AutowireCapableBeanFactory();
    XmlBeanDefinitionReader reader = new XmlBeanDefinitionReader(beanFactory);

    reader.loadBeanDefinitions(resource);
    this.beanFactory = beanFactory;

    if (isRefresh) {
        refresh();
    }
}

这段代码的意思是:

  1. 还是先读取 XML。
  2. 还是把 XML 转成 BeanDefinition
  3. 但是负责创建 Bean 的工厂换了。
  4. 新工厂除了会创建 Bean,还能处理自动注入、BeanPostProcessor 等扩展点。

如果后面已经拆成 DefaultListableBeanFactory,那这里也可以换成:

java 复制代码
DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();

名字变了,但职责没变:它是这个阶段真正负责管理 Bean 的核心工厂。

@Autowired 是在哪里生效的

这个 Demo 里,可以把 @Autowired 注入放在 postProcessBeforeInitialization 里处理。

示例代码如下:

java 复制代码
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
    Field[] fields = bean.getClass().getDeclaredFields();

    for (Field field : fields) {
        if (!field.isAnnotationPresent(Autowired.class)) {
            continue;
        }

        Object dependency = this.beanFactory.getBean(field.getName());

        try {
            field.setAccessible(true);
            field.set(bean, dependency);
        } catch (IllegalAccessException e) {
            throw new BeansException("Autowired field failed: " + field.getName());
        }
    }

    return bean;
}

这段代码做了四件事:

  1. 拿到当前 Bean 的所有字段。
  2. 找出标了 @Autowired 的字段。
  3. 根据字段名从容器里拿依赖 Bean。
  4. 通过反射把依赖对象设置到字段上。

BaseService 举例:

java 复制代码
@Autowired
private BaseBaseService bbs;

字段名是 bbs,所以 Demo 会执行:

java 复制代码
beanFactory.getBean("bbs");

然后再执行类似:

java 复制代码
field.set(baseService, bbsBean);

这样 baseService.bbs 就有值了。

这里有几个容易误解的点

第一,这个 Demo 是按字段名注入。

field.getName() 拿到的是字段名,比如 bbs。所以容器里必须有一个 id 叫 bbs 的 Bean。

真实 Spring 的 @Autowired 默认更偏向按类型查找,然后再结合名称、@Qualifier 等规则处理冲突。Demo 为了简单,先按名称注入就够了。

第二,field.set(...) 是字段注入,不是 setter 注入。

它不会调用 setBbs(...) 方法,而是直接给 private BaseBaseService bbs 这个字段赋值。

第三,放在 postProcessBeforeInitialization 是 Demo 的简化做法。

这个方法名里的 initialization,通常指的是 Bean 初始化阶段,比如执行 init 方法之前。真实 Spring 里,@Autowired 主要不是靠普通的 BeanPostProcessor#postProcessBeforeInitialization 完成,而是有更细的扩展点,比如处理属性填充阶段的后置处理器。

但是对 MiniSpring 这个 Demo 来说,把注解注入放到这个扩展点里,可以先把"容器读取注解并注入依赖"的核心逻辑讲清楚。

第四,异常最好带上原始原因。

示例里只写:

java 复制代码
throw new BeansException("Autowired field failed: " + field.getName());

教学 Demo 可以这样写。真实项目里建议把原始异常 e 也传进去,不然排查问题会比较痛苦。

为什么要构建容器体系

刚开始写 Demo 时,一个类把所有事情都做了,理解起来很爽:

text 复制代码
读取 BeanDefinition
创建 Bean
保存单例
处理 getBean
处理 @Autowired
处理 BeanPostProcessor

但继续往后写,就会发现这个类越来越胖。

比如 AutowireCapableBeanFactory 如果既负责 getBean(),又负责单例缓存,又负责 BeanDefinition 注册,又负责自动注入,还负责生命周期扩展,它就变成了一个"大杂烩"。

所以框架通常会这么拆:

text 复制代码
接口:描述能力
抽象类:定义模板流程,并复用通用实现
默认实现类:继承抽象类、实现接口,把能力整合起来给应用使用

这部分其实是学 MiniSpring 很有价值的地方。

学到这里,会发现最有用的不只是 @Autowired 怎么注入,而是 Spring 这套架构背后的设计理念:

text 复制代码
接口描述能力。
抽象类定义模板方法,沉淀通用流程。
默认实现类继承抽象类、实现接口,对外提供完整能力。

这背后用到的一个重要原则,就是接口隔离原则。

接口不要一上来就设计成"大而全"。每个接口只描述一小块清晰能力,谁需要这块能力,谁就继承或实现它。能力更强的接口,可以在小接口的基础上继续组合;能力更强的抽象类,也可以在更基础的抽象类上一步步扩展出来。

所以这不是为了把代码写复杂,而是为了让每一层都比较清楚:

  1. 外部用户只需要 getBean(),不应该暴露太多内部方法。
  2. 框架内部需要注册单例、添加后置处理器、读取 BeanDefinition。
  3. 有些 BeanFactory 只支持按名称查找,不一定支持列举全部 Bean。
  4. 创建 Bean、缓存 Bean、管理 BeanDefinition 是不同职责,拆开之后更容易扩展。

放到这个 MiniSpring 里看,就是两条线:

text 复制代码
接口线:BeanFactory -> ListableBeanFactory / ConfigurableBeanFactory / AutowireCapableBeanFactory -> ConfigurableListableBeanFactory

抽象类线:DefaultSingletonBeanRegistry -> AbstractBeanFactory -> AbstractAutowireCapableBeanFactory -> DefaultListableBeanFactory

接口线是在描述"这个容器有什么能力"。

抽象类线是在复用"这个容器创建 Bean 的流程"。

最终的 DefaultListableBeanFactory 把两条线合起来:它继承抽象类拿到通用流程,又实现接口对外声明自己具备哪些能力。

容器接口和类的职责

下面按能力来理解这些接口和类。

BeanFactory

BeanFactory 是最基础的容器接口。

它只表达一个核心能力:

text 复制代码
给我一个 beanName,我返回一个 Bean。

常见方法:

java 复制代码
Object getBean(String name);
boolean containsBean(String name);
Class<?> getType(String name);

它是面向使用者的。业务代码一般只需要知道这些就够了。

ListableBeanFactory

ListableBeanFactory 表示这个容器可以"列举 Bean"。

比如:

text 复制代码
拿到所有 BeanDefinition 的名字
根据类型找一批 Bean
判断某种类型的 Bean 有几个

为什么不直接放进 BeanFactory

因为不是所有 BeanFactory 都需要支持批量列举。比如有些轻量容器、远程容器、按需加载容器,可能只想暴露 getBean(),不想暴露全部 Bean 列表。

所以它单独拆成一个能力接口。

SingletonBeanRegistry

SingletonBeanRegistry 负责单例 Bean 的注册和查询。

它关心的是:

text 复制代码
这个单例对象有没有注册过?
注册进去的单例对象是什么?

它不关心 BeanDefinition,也不关心如何创建 Bean。

DefaultSingletonBeanRegistry

DefaultSingletonBeanRegistrySingletonBeanRegistry 的默认实现。

它通常会维护一个 Map:

java 复制代码
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

职责很简单:

text 复制代码
保存单例 Bean
获取单例 Bean
判断单例 Bean 是否存在
移除单例 Bean

把这层单独抽出来,是为了让后面的 BeanFactory 都能复用单例缓存能力。

ConfigurableBeanFactory

ConfigurableBeanFactory 是给框架内部使用的配置接口。

它通常会包含这些能力:

text 复制代码
注册单例
添加 BeanPostProcessor
获取 BeanPostProcessor 列表
管理 BeanFactory 内部配置

它和 BeanFactory 的区别是:

text 复制代码
BeanFactory:给普通使用者用,主要是 getBean。
ConfigurableBeanFactory:给框架内部用,主要是配置和扩展。

这个拆分很重要。否则所有内部方法都暴露给业务调用方,容器边界会很乱。

AutowireCapableBeanFactory

AutowireCapableBeanFactory 表示这个工厂有"自动装配 Bean"的能力。

它关心的是创建 Bean 过程中的这些事情:

text 复制代码
实例化 Bean
填充 Bean 属性
处理 @Autowired
执行 BeanPostProcessor

注意它不是普通业务方主要使用的接口。

业务代码大多数时候只需要 BeanFactory#getBean()。自动装配是容器内部创建 Bean 时使用的能力。

AbstractBeanFactory

AbstractBeanFactory 是一个抽象类,用来复用 getBean() 的通用流程。

它一般会做这些事:

text 复制代码
先查单例缓存
缓存没有命中,再准备创建 Bean
维护 BeanPostProcessor 列表
把真正的 createBean 留给子类实现

为什么做成抽象类?

因为 getBean() 的大流程基本固定,但不同容器创建 Bean 的细节可能不同。

也就是:

text 复制代码
父类管流程,子类管细节。

BeanDefinitionRegistry

BeanDefinitionRegistry 是 BeanDefinition 的注册中心。

它管理的是"Bean 的定义",不是"Bean 的实例"。

常见能力:

text 复制代码
注册 BeanDefinition
删除 BeanDefinition
根据 beanName 获取 BeanDefinition
判断 BeanDefinition 是否存在

这层单独拆出来,是因为读取 XML、扫描注解、Java Config 最后都可能要注册 BeanDefinition。

但是注册 BeanDefinition 和创建 Bean 实例,不是同一件事。

ConfigurableListableBeanFactory

ConfigurableListableBeanFactory 可以理解成框架内部使用的完整 BeanFactory 接口。

它通常整合了几类能力:

text 复制代码
BeanFactory:可以 getBean
ListableBeanFactory:可以列举 Bean
ConfigurableBeanFactory:可以做内部配置
AutowireCapableBeanFactory:可以自动装配

这个接口一般不是给业务代码直接用的,而是给 ApplicationContextBeanFactoryPostProcessor 这类框架内部组件用的。

AbstractAutowireCapableBeanFactory

AbstractAutowireCapableBeanFactory 是真正负责创建 Bean 的抽象基类。

它一般会实现这条流程:

text 复制代码
createBean
    -> 实例化 Bean
    -> 提前暴露单例引用,处理 setter 循环依赖
    -> 填充 XML property/ref
    -> 处理 @Autowired 字段
    -> 执行初始化前 BeanPostProcessor
    -> 执行初始化方法
    -> 执行初始化后 BeanPostProcessor

它比 AbstractBeanFactory 更具体。

可以这样理解:

text 复制代码
AbstractBeanFactory:管 getBean 的大流程。
AbstractAutowireCapableBeanFactory:管 createBean 的细流程。

DefaultListableBeanFactory

DefaultListableBeanFactory 是最终整合类。

它通常继承:

java 复制代码
public class DefaultListableBeanFactory
        extends AbstractAutowireCapableBeanFactory
        implements ConfigurableListableBeanFactory, BeanDefinitionRegistry {
}

它整合了这些能力:

text 复制代码
BeanFactory:能 getBean
ListableBeanFactory:能列举 Bean
ConfigurableBeanFactory:能配置容器
AutowireCapableBeanFactory:能自动装配
BeanDefinitionRegistry:能注册 BeanDefinition
DefaultSingletonBeanRegistry:能缓存单例 Bean

所以应用真正使用的,一般就是这个默认实现类。

接口关系

可以先看:

text 复制代码
BeanFactory
├── ListableBeanFactory
├── ConfigurableBeanFactory
└── AutowireCapableBeanFactory

ListableBeanFactory
└── ConfigurableListableBeanFactory

ConfigurableBeanFactory
└── ConfigurableListableBeanFactory

AutowireCapableBeanFactory
└── ConfigurableListableBeanFactory

BeanDefinitionRegistry

再看实现类关系:

text 复制代码
SingletonBeanRegistry
└── DefaultSingletonBeanRegistry
    └── AbstractBeanFactory
        └── AbstractAutowireCapableBeanFactory
            └── DefaultListableBeanFactory

最后把两边合起来看:

text 复制代码
interface BeanFactory
    ↑
interface ListableBeanFactory
    ↑
interface ConfigurableListableBeanFactory

interface ConfigurableBeanFactory ─────────────┐
interface AutowireCapableBeanFactory ──────────┤
interface BeanDefinitionRegistry ──────────────┤
                                                │
interface SingletonBeanRegistry                 │
    ↑                                           │
class DefaultSingletonBeanRegistry              │
    ↑                                           │
abstract class AbstractBeanFactory              │
    ↑                                           │
abstract class AbstractAutowireCapableBeanFactory
    ↑
class DefaultListableBeanFactory
    implements ConfigurableListableBeanFactory,
               BeanDefinitionRegistry

这个图不用死背,重点看方向:

text 复制代码
越上面越抽象,描述能力。
越下面越具体,复用流程并完成实现。
DefaultListableBeanFactory 是最后的总装类。

为什么这样依赖

这套设计最核心的思路是职责拆分,再往深一点说,就是"能力分层"和"流程复用"。

BeanFactory 不应该关心 BeanDefinition 怎么注册,因为业务方只想拿对象。

BeanDefinitionRegistry 不应该关心 Bean 怎么创建,因为它只是保存"定义信息"。

SingletonBeanRegistry 不应该关心 XML 和注解,因为它只是保存单例对象。

AutowireCapableBeanFactory 不应该成为所有能力的大杂烩,所以通用的缓存逻辑放到父类,BeanDefinition 注册能力放到 DefaultListableBeanFactory

用接口这条线举例:

text 复制代码
BeanFactory

只描述最基础的拿 Bean 能力,比如 getBean()

text 复制代码
ListableBeanFactory extends BeanFactory

在拿 Bean 的基础上,增加"列举 Bean"的能力。

text 复制代码
ConfigurableBeanFactory extends BeanFactory

在拿 Bean 的基础上,增加"配置容器"的内部能力,比如添加 BeanPostProcessor

text 复制代码
ConfigurableListableBeanFactory
    extends ListableBeanFactory, ConfigurableBeanFactory, AutowireCapableBeanFactory

它就是更强的内部接口,把列举、配置、自动装配这些能力组合起来。

这就是接口隔离原则在这里的体现:小接口只描述小能力,大接口通过继承小接口来组合能力,而不是一开始就把所有方法塞进一个巨大接口。

再看抽象类这条线:

text 复制代码
DefaultSingletonBeanRegistry

先解决最基础的单例缓存问题。

text 复制代码
AbstractBeanFactory extends DefaultSingletonBeanRegistry

在单例缓存基础上,加入 getBean() 的模板流程:先查缓存,查不到再创建。

text 复制代码
AbstractAutowireCapableBeanFactory extends AbstractBeanFactory

继续往下扩展,补上 createBean() 的模板流程:实例化、属性填充、处理注解、初始化。

text 复制代码
DefaultListableBeanFactory extends AbstractAutowireCapableBeanFactory

最后把 BeanDefinition 注册、批量查找、自动装配这些能力整合起来,成为真正可用的默认容器。

所以抽象类不是随便抽的。它们是在一层层复用模板方法:

text 复制代码
getBean 模板流程
    -> createBean 模板流程
        -> populateBean / initializeBean 等细节流程

拆完以后,每一层都比较清楚:

text 复制代码
BeanFactory:使用入口
BeanDefinitionRegistry:定义注册入口
SingletonBeanRegistry:单例缓存能力
AbstractBeanFactory:getBean 模板流程
AbstractAutowireCapableBeanFactory:createBean 模板流程
DefaultListableBeanFactory:最终整合实现

这就是 Spring 里很常见的写法:接口描述能力,抽象类沉淀模板流程,默认实现类继承抽象类并实现接口,最终组合出一个完整能力。

创建 Bean 的完整流程

整理一下最终版容器创建 Bean 的过程:

text 复制代码
1. ClassPathXmlApplicationContext 启动
2. XmlBeanDefinitionReader 读取 beans.xml
3. DefaultListableBeanFactory 注册 BeanDefinition
4. refresh() 触发非懒加载单例预实例化
5. getBean(beanName) 先查单例缓存
6. 缓存没有命中,根据 BeanDefinition 创建 Bean
7. 调用构造方法实例化 Bean
8. 提前暴露早期 Bean 引用,解决 setter 循环依赖
9. 根据 XML property/ref 做属性注入
10. 执行 BeanPostProcessor 初始化前逻辑,Demo 在这里扫描 @Autowired 并注入字段
11. 执行初始化方法
12. 执行 BeanPostProcessor 初始化后逻辑
13. 放入完整单例缓存
14. 返回 Bean

这里要注意顺序。

@Autowired 注入一定发生在 Bean 创建过程中。它不是在你写下 @Autowired 那一刻发生的,也不是 JVM 自动帮你完成的。

容器必须先创建对象,再扫描字段,再从 BeanFactory 里拿依赖对象,最后通过反射注入。

本节几个关键修正

第一,@Autowired 不等于"自动有值"。

它只是一个运行时能被读取的标记。没有容器扫描和反射赋值,它什么都不会发生。

第二,Demo 里的 @Autowired 是按字段名查 Bean。

比如字段叫 bbs,容器就找 id 为 bbs 的 Bean。这和真实 Spring 默认按类型注入不完全一样。

第三,BeanPostProcessor 是扩展点,不是只能处理 @Autowired

后面 AOP 代理、初始化增强、属性检查,都可以借类似扩展点接入容器生命周期。

第四,接口拆分不是为了绕。

BeanFactoryListableBeanFactoryConfigurableBeanFactory 这些名字看起来多,但本质是在拆能力边界。谁对外,谁对内,谁负责缓存,谁负责定义,拆开以后才能继续扩展。

第五,DefaultListableBeanFactory 是最终总装类。

它不只是"能列举 Bean 的工厂",在这个阶段它承担了完整 IoC 容器的核心职责。

最后总结

这一节可以用两句话收住:

text 复制代码
@Autowired 的核心是:注解提供元信息,容器通过反射读取元信息,再从 BeanFactory 取对象注入。
text 复制代码
完整 IoC 容器的核心是:用接口拆能力,用抽象类复用流程,用 DefaultListableBeanFactory 把能力整合起来。

前面我们解决的是"容器怎么创建对象、怎么处理循环依赖"。

这一节继续解决的是"容器怎么通过注解发现依赖、怎么把框架结构拆得更清楚"。

理解了这些,再往后看 AOP、事务、MVC,很多"为什么 Spring 能在中间插一脚"的问题,就不会那么神秘了。

相关推荐
小小编程路1 小时前
C++ 多线程与并发
java·jvm·c++
AI视觉网奇1 小时前
linux 检索库 判断库是否支持
java·linux·服务器
云边云科技_云网融合2 小时前
企业大模型时代的网络架构五层演进:从连接到智能的范式重构
网络·重构·架构
Yunzenn2 小时前
字节最新研究cola-DLM第 01 章:语言生成的三次范式之争 —— 从 RNN 到 AR 到扩散
架构·github
她的男孩2 小时前
从零搭一个企业后台,为什么我把能力拆成 Starter 和 Plugin
java·后端·架构
RainCity2 小时前
Java Swing 自定义组件库分享(七)
java·笔记·后端
啷里格啷2 小时前
第二章 Fast-DDS 整体架构与分层框架
后端·架构
DolphinDB2 小时前
漫长人工,耗费存储?用 BackupRestore 模块一站式解决跨环境数据同步难题
运维·后端·架构
闫记康2 小时前
Linux学习day5
linux·chrome·学习