Spring依赖注入(下)

梦想不抛弃苦心追求的人,只要不停止追求,你们会沐浴在梦想的光辉之中。再美好的梦想与目标,再完美的计划和方案,如果不能尽快在行动中落实,最终只能是纸上谈兵,空想一番。只要瞄准了大方向,坚持不懈地做下去,才能够扫除挡在梦想前面的障碍,实现美好的人生蓝图。Spring依赖注入(下),希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com,来源:原文

《依赖注入(上)》介绍了,会根据注入点来进行注入,而属性注入点和方法注入点的具体实现略有不同,但它们的核心方法都是通过ConfigurableListableBeanFactory的resolveDependency()类获取属性值,下面将先简单介绍一下属性注入点和方法注入点的逻辑,然后重点介绍resolveDependency()是如何工作

一、属性注入点

1.1 调用注入方法

属性注入点调用的是AutowiredFieldElement.inject(),源码如下:

对于原型Bean,第一次创建的时候,也找注入点,然后进行注入,此时cached为false,注入完了之后cached为true,第二次创建的时候,先找注入点(此时会拿到缓存好的注入点),也就是AutowiredFieldElement对象,此时cache为true,也就进到此处了,注入点内并没有缓存被注入的具体Bean对象,而是beanName,这样就能保证注入到不同的原型Bean对象

protected void inject(Object bean, @Nullable String beanName, @Nullable PropertyValues pvs) throws Throwable {

   Field field = (Field) this.member;
   Object value;
   if (this.cached) {
      try {
         value = resolvedCachedArgument(beanName, this.cachedFieldValue);
      }
      catch (NoSuchBeanDefinitionException ex) {
         // Unexpected removal of target bean for cached argument -> re-resolve
         value = resolveFieldValue(field, bean, beanName);
      }
   }
   else {
      // 根据filed从BeanFactory中查到的匹配的Bean对象
      value = resolveFieldValue(field, bean, beanName);
   }

   // 反射给filed赋值
   if (value != null) {
      ReflectionUtils.makeAccessible(field);
      field.set(bean, value);
   }
}

1.2 获取属性注入值

如果缓存中没有,则直接调用resolveFieldValue()方法来获取注入值,该方法会将属性封装成一个依赖描述器对象,并且获取当前工厂的类型转换器,类型转换器在处理@Value注解时有很大用处,然后调用resolveDependency()方法来获取属性值,并把获取到属性值进行缓存

private Object resolveFieldValue(Field field, Object bean, @Nullable String beanName) {

    DependencyDescriptor desc = new DependencyDescriptor(field, this.required);
    desc.setContainingClass(bean.getClass());

    Set<String> autowiredBeanNames = new LinkedHashSet<>(1);
    TypeConverter typeConverter = beanFactory.getTypeConverter();
    Object value;
    value = beanFactory.resolveDependency(desc, beanName, autowiredBeanNames, typeConverter);
    synchronized (this) {
        if (!this.cached) {
            Object cachedFieldValue = null;
            ……
            // 缓存属性值
            this.cachedFieldValue = cachedFieldValue;
            this.cached = true;
        }
    }
    return value;
}

二、方法注入点

2.1 调用注入方法

属性注入点调用的是AutowiredMethodElement.inject(),源码如下:

如果pvs中已经有当前注入点的值了,则跳过注入,然后同样的也是先判断缓存是否有注入值,如果有则从缓存取,如果没有则调用resolveMethodArguments()获取方法注入的参数值列表

protected void inject(Object bean, @Nullable String beanName, @Nullable PropertyValues pvs) throws Throwable { 
   if (checkPropertySkipping(pvs)) {
      return;
   }
   Method method = (Method) this.member;
   Object[] arguments;
   if (this.cached) {
      arguments = resolveCachedArguments(beanName);
   }
   else {
      arguments = resolveMethodArguments(method, bean, beanName);
   }
   if (arguments != null) {
       ReflectionUtils.makeAccessible(method);
       method.invoke(bean, arguments);
   }
}

2.2 获取方法参数的注入值

获取注入点方法的参数数量,生成一个依赖描述器的数组,存放所有参数的依赖描述,然后根据方法及参数下标索引,将参数生成一个MethodParameter对象,通过该对象生成参数的依赖描述,然后同样调用resolveDependency()来获取参数的注入值,然后将注入值存放在参数值列表中

获取方法参数的注入值放入数组之后,如果方法中的每个参数都是需要通过注入来进行赋值的话,则会将将对每个参数生成ShortcutDependencyDescriptor依赖描述的对象,存放在缓存里面

private Object[] resolveMethodArguments(Method method, Object bean, @Nullable String beanName) {
    int argumentCount = method.getParameterCount();
    Object[] arguments = new Object[argumentCount];
    DependencyDescriptor[] descriptors = new DependencyDescriptor[argumentCount];
    Set<String> autowiredBeans = new LinkedHashSet<>(argumentCount);
    Assert.state(beanFactory != null, "No BeanFactory available");
    TypeConverter typeConverter = beanFactory.getTypeConverter();

    // 遍历每个方法参数,找到匹配的bean对象
    for (int i = 0; i < arguments.length; i++) {
        MethodParameter methodParam = new MethodParameter(method, i);

        DependencyDescriptor currDesc = new DependencyDescriptor(methodParam, this.required);
        currDesc.setContainingClass(bean.getClass());
        descriptors[i] = currDesc;
        Object arg = beanFactory.resolveDependency(currDesc, beanName, autowiredBeans, typeConverter);
        if (arg == null && !this.required) {
            arguments = null;
            break;
        }
        arguments[i] = arg;
    }
    
    synchronized (this) {
        if (!this.cached) {
            if (arguments != null) {
                DependencyDescriptor[] cachedMethodArguments = Arrays.copyOf(descriptors, arguments.length);
                registerDependentBeans(beanName, autowiredBeans);
                if (autowiredBeans.size() == argumentCount) {
                    // 缓存参数的依赖描述
                    ……
                }
                this.cachedMethodArguments = cachedMethodArguments;
            }
            this.cached = true;
        }
    }
    return arguments;
}

三、获取属性值

3.1 resolveDependency()介绍

在上面的属性注入点和方法注入点进行注入时,最核心的逻辑就是如何获取依赖值,都是通过调用ConfigurableListableBeanFactory的resolveDependency()来实现,下面将介绍该方法的具体实现

resolveDependency()的源码如下,如果属性类型是Optional、ObjectFactory、ObjectProvider或javaxInjectProviderClass,那么会去调用指定的方法来获取依赖对象,但对于大多数情况,都会走最后面else里面的逻辑

public Object resolveDependency(DependencyDescriptor descriptor, @Nullable String requestingBeanName,
      @Nullable Set<String> autowiredBeanNames, @Nullable TypeConverter typeConverter) throws BeansException {
   // 用来获取方法入参名字的
   descriptor.initParameterNameDiscovery(getParameterNameDiscoverer());

   // 所需要的类型是Optional
   if (Optional.class == descriptor.getDependencyType()) {
      return createOptionalDependency(descriptor, requestingBeanName);
   }
   // 所需要的的类型是ObjectFactory,或ObjectProvider
   else if (ObjectFactory.class == descriptor.getDependencyType() ||
         ObjectProvider.class == descriptor.getDependencyType()) {
      return new DependencyObjectProvider(descriptor, requestingBeanName);
   }
   else if (javaxInjectProviderClass == descriptor.getDependencyType()) {
      return new Jsr330Factory().createDependencyProvider(descriptor, requestingBeanName);
   }
   else {
      // 在属性或set方法上使用了@Lazy注解,那么则构造一个代理对象并返回,真正使用该代理对象时才进行类型筛选Bean
      Object result = getAutowireCandidateResolver().getLazyResolutionProxyIfNecessary(
            descriptor, requestingBeanName);

      if (result == null) {
         // descriptor表示某个属性或某个set方法
         // requestingBeanName表示正在进行依赖注入的Bean
         result = doResolveDependency(descriptor, requestingBeanName, autowiredBeanNames, typeConverter);
      }
      return result;
   }
}

在resolveDependency()的第一行,会去初始化获取方法参数名的方式,getParameterNameDiscoverer(),该方法会返回默认的获取方法参数名的方式,在JDK1.8以下的版本中,没有办法通过反射来获取方法的参数名,Spring提供了通过本地变量表的方式(自行解析字节码文件)来获取方法的参数名,而JDK1.8中虽然提供了获取方法参数名的API,但返回的方法名并不是我们定义的那个参数名,需要在编译时指定”-parameters“参数才能拿到想要的参数名,而StandardReflectionParameterNameDiscoverer()的作用等同于”-parameters“

public DefaultParameterNameDiscoverer() {
   // TODO Remove this conditional inclusion when upgrading to Kotlin 1.5, see https://youtrack.jetbrains.com/issue/KT-44594
   if (KotlinDetector.isKotlinReflectPresent() && !NativeDetector.inNativeImage()) {
      addDiscoverer(new KotlinReflectionParameterNameDiscoverer());
   }
   // 依次调用Discoverer来获取某个方法的参数名,反射(1.8)和本地变量表
   addDiscoverer(new StandardReflectionParameterNameDiscoverer());
   addDiscoverer(new LocalVariableTableParameterNameDiscoverer());
}

如果在属性或方法上使用了@Lazy注解指定懒加载,那么将会生成生成代理对象作为依赖值,当调用该对象方法的时候,会根据代理对象找到真正的Bean对象,然后调用Bean对象的方法getLazyResolutionProxyIfNecessary()会去调用ContextAnnotationAutowireCandidateResolver.buildLazyResolutionProxy()

Object result = getAutowireCandidateResolver().getLazyResolutionProxyIfNecessary(
    descriptor, requestingBeanName);

if (result == null) {
    // descriptor表示某个属性或某个set方法
    // requestingBeanName表示正在进行依赖注入的Bean,解析@Lazy注解生成Bean时也会调用该方法
    result = doResolveDependency(descriptor, requestingBeanName, autowiredBeanNames, typeConverter);
}
return result;

@Lazy注解标识的属性获取注入值的逻辑如下,与没有@Lazy注解一样,都会去调用DefaultListableBeanFactory.doResolveDependency()来生成一个原始的Bean实例,然后根据生成的Bean实例生成一个代理Bean,最后返回的也是代理Bean

protected Object buildLazyResolutionProxy(final DependencyDescriptor descriptor, final @Nullable String beanName) {
	……
    final DefaultListableBeanFactory dlbf = (DefaultListableBeanFactory) beanFactory;
    TargetSource ts = new TargetSource() {
        ……
        @Override
        public Object getTarget() {
            Set<String> autowiredBeanNames = (beanName != null ? new LinkedHashSet<>(1) : null);
            Object target = dlbf.doResolveDependency(descriptor, beanName, autowiredBeanNames, null);
            
            if (autowiredBeanNames != null) {
                for (String autowiredBeanName : autowiredBeanNames) {
                    if (dlbf.containsBean(autowiredBeanName)) {
                        dlbf.registerDependentBean(autowiredBeanName, beanName);
                    }
                }
            }
            return target;
        }
        ……
    };

    ProxyFactory pf = new ProxyFactory();
    pf.setTargetSource(ts);
    Class<?> dependencyType = descriptor.getDependencyType();
    if (dependencyType.isInterface()) {
        pf.addInterface(dependencyType);
    }
    return pf.getProxy(dlbf.getBeanClassLoader());
}

3.2 详解doResolveDependency()

前面resolveDependency()方法中,根据属性类型以及是否有@Lazy注解,提供了不同获取Bean实例的方法,但是,不论是哪种获取Bean的方法,其内部的核心实现都是通过调用DefaultListableBeanFactory.doResolveDependency()来完成的,下面将重点介绍该方法的源码实现

首先判断当前需要注入的依赖,已经在别处做过依赖注入了,则无需再生成依赖值,可以直接从缓存中取

比如UserService实例中依赖了User实例,而OrderService实例中也依赖User实例,但OrderService实例先创建,在OrderService实例的创建过程中已经做过了User的依赖注入,那么当UserService注入User的时候,就无需再匹配各种条件去找了,直接从缓存中取

// 如果当前descriptor之前做过依赖注入了,则可以直接取shortcut了,相当于缓存
Object shortcut = descriptor.resolveShortcut(this);
if (shortcut != null) {
    return shortcut;
}
3.2.1 处理@Value

@Value可以用于成员变量、方法以及方法参数上,对其进行依赖注入时,首先去获取value注解指定的值,@Value所指定值,可以是一个普通的字符串,也可以是一个占位符,或者是一个Spring表达式,所以当获取到value指定的值后,需要对其进行各种处理

Object value = getAutowireCandidateResolver().getSuggestedValue(descriptor);
if (value != null) {
    if (value instanceof String) {
        // 占位符填充(${})
        String strVal = resolveEmbeddedValue((String) value);
        BeanDefinition bd = (beanName != null && containsBean(beanName) ?
                             getMergedBeanDefinition(beanName) : null);
        // 解析Spring表达式(#{})
        value = evaluateBeanDefinitionString(strVal, bd);
    }
    // 将value转化为descriptor所对应的类型
    TypeConverter converter = (typeConverter != null ? typeConverter : getTypeConverter());
    try {
        return converter.convertIfNecessary(value, type, descriptor.getTypeDescriptor());
    }
    catch (UnsupportedOperationException ex) {
        // A custom TypeConverter which does not support TypeDescriptor resolution...
        return (descriptor.getField() != null ?
                converter.convertIfNecessary(value, type, descriptor.getField()) :
                converter.convertIfNecessary(value, type, descriptor.getMethodParameter()));
    }
}
  • 获取@Value所指定的值

    调用QualifierAnnotationAutowireCandidateResolver.getSuggestedValue()来解析@Value,由于@Value可以用于变量(成员变量和方法参数)和方法上,首先去查看变量上的@Value值,descriptor.getAnnotations()首先去判断是不是成员变量,如果不是则取方法参数的注解

    如果变量都没有value注解,再去找方法上的value注解值

    public Object getSuggestedValue(DependencyDescriptor descriptor) {
       Object value = findValue(descriptor.getAnnotations());
       if (value == null) {
          MethodParameter methodParam = descriptor.getMethodParameter();
          if (methodParam != null) {
             value = findValue(methodParam.getMethodAnnotations());
          }
       }
       return value;
    }
    
  • @Value所指定的值解析

    @Value的值可能是占位符或Spring表达式,需要将其解析的到具体的值,首先解析占位符,resolveStringValue()是一个函数式接口的方法,循环遍历所有的占位符解析器,调用resolveStringValue()

    public String resolveEmbeddedValue(@Nullable String value) {
       if (value == null) {
          return null;
       }
       String result = value;
       for (StringValueResolver resolver : this.embeddedValueResolvers) {
          result = resolver.resolveStringValue(result);
          if (result == null) {
             return null;
          }
       }
       return result;
    }
    

    在BeanFactory初始化的过程中,有如下一段代码,主要就是从配置的系统变量获取对应的值,Spring的Environment包含了计算机所有的系统环境变量、运行时指定的变量以及Spring的配置文件中的配置信息,解析占位符时会把”${}”里面的字符串当作Key来查找值,如果找不到,就会把整个@Value所指定的值当作它的值

    // 设置默认的占位符解析器  ${xxx}  ---key
    if (!beanFactory.hasEmbeddedValueResolver()) {
       beanFactory.addEmbeddedValueResolver(strVal -> getEnvironment().resolvePlaceholders(strVal));
    }
    

    然后进行Spring表达式的解析,Spring表达式支持两个写法,一种是直接@Value指定”#{}“格式,里面的字符串就是Spring表达式;另一种写法是可以将通过占位符来配置Spring表达式,”${}”里面字符串是表达式的Key,对应的配置的值就是Spring表达式

  • 类型转换

    解析获取到@Value的真实值后,如果真实值的类型,与依赖值的类型不匹配,则需要进行类型转换,首先需要找到能对当前类型和目标类型做转换的转换器,如果找不到则会抛异常,如果找到了则进行转换,然后将转换后的值作为依赖注入的依赖值

    如下代码所示,使用@Value注解来注入User对象,因为Spring没有提供将String类型转换为User类型的转换器,所以直接运行以下代码将会报错,我们可以通过自定义类型转换器,来实现属性注入

    @Component
    public class UserService {
    
       @Value("lizhi")
       User user;
    
       public void test() {
          System.out.println(user);
       }
    }
    

    自定义类型转换器,需要实现Converter接口,source就是@Value中的指定的值

    public class CustomizedTypeConvert implements Converter {
       @Override
       public Object convert(Object source) {
          User user = new User(source.toString());
          return user;
       }
    }
    
3.2.2 注入多个同类实例

如果没有@Value注解,则会注入Spring中的实例

Spring不仅支持注入一个实例,还支持注入多个同类实例,比如:

@Autowired
Map<String,User> userMap;

@Autowired
List<User> userList;

如果依赖所对应的类型是数组、Map这些,就将依赖对应的类型所匹配的所有bean方法,不用进一步做筛选了

Object multipleBeans = resolveMultipleBeans(descriptor, beanName, autowiredBeanNames, typeConverter);
if (multipleBeans != null) {
   return multipleBeans;
}

resolveMultipleBeans()方法中,Spring5.3.10版本支持Stream、数组、集合和Map四种类型,而对于Map类型,它的Key必须是String类型,Value类型不能是null,否则将直接返回Null,不论是哪一种实现,都会去调用findAutowireCandidates()来获取匹配Bean实例

3.2.3 匹配满足条件的Bean实例或beanClass

找到所有Bean,key是beanName, value有可能是bean对象,有可能是beanClass,因为有些Bean是懒加载的,此时Spring容器中还没有该Bean的实例,对于懒加载的Bean,我们只能获取到beanClass,然后在进行对其进行实例化

Map<String, Object> matchingBeans = findAutowireCandidates(beanName, type, descriptor);
if (matchingBeans.isEmpty()) {
   // required为true,抛异常
   if (isRequired(descriptor)) {
      raiseNoMatchingBeanFound(type, descriptor.getResolvableType(), descriptor);
   }
   return null;
}

findAutowireCandidates()就是去匹配所有满足条件的Bean,具体实现如下:

  • 查找BeanName

    从BeanFactory中找出和requiredType所匹配的beanName,仅仅是beanName,这些bean不一定经过了实例化,只有到最终确定某个Bean了,如果这个Bean还没有实例化才会真正进行实例化

    String[] candidateNames = BeanFactoryUtils.beanNamesForTypeIncludingAncestors(
          this, requiredType, true, descriptor.isEager());
    Map<String, Object> result = CollectionUtils.newLinkedHashMap(candidateNames.length);
    
  • 从缓存中匹配Bean

    根据类型从resolvableDependencies中匹配Bean,resolvableDependencies中存放的是类型:Bean对象,比如BeanFactory.class:BeanFactory对象,在Spring启动时设置

    匹配到相同的类型之后,取出Bean实例,然后判断是不是requiredType的实例,如果是则加入到结果集中

    for (Map.Entry<Class<?>, Object> classObjectEntry : this.resolvableDependencies.entrySet()) {
       Class<?> autowiringType = classObjectEntry.getKey();
       if (autowiringType.isAssignableFrom(requiredType)) {
          Object autowiringValue = classObjectEntry.getValue();
          autowiringValue = AutowireUtils.resolveAutowiringValue(autowiringValue, requiredType);
    
          if (requiredType.isInstance(autowiringValue)) {
             result.put(ObjectUtils.identityToString(autowiringValue), autowiringValue);
             break;
          }
       }
    }
    
  • 判断能不能进行注入

    首先判断需要注入的实例是不是自己(Spring支持注入自己),然后判断当前依赖的beanName是否可以用来进行自动注入,如果当前beanName既不是自己也可以进行依赖注入,则

    for (String candidate : candidateNames) {
       // 如果不是自己,则判断该candidate到底能不能用来进行自动注入
       if (!isSelfReference(beanName, candidate) && isAutowireCandidate(candidate, descriptor)) {
          addCandidateEntry(result, candidate, descriptor, requiredType);
       }
    }
    

    判断是不是自己,就根据beanName来判断,先判断普通的beanName,再判断factoryBeanName,Spring注入的时候,先找不是自己的Bean实例,如果找不到,再把自己注入进来

    private boolean isSelfReference(@Nullable String beanName, @Nullable String candidateName) {
       return (beanName != null && candidateName != null &&
             (beanName.equals(candidateName) || (containsBeanDefinition(candidateName) &&
                   beanName.equals(getMergedLocalBeanDefinition(candidateName).getFactoryBeanName()))));
    }
    

    判断当前beanName能都进行注入也很简单,根据BeanDefinition判断beanName对应的Bean可不可以用来进行依赖注入

    protected boolean isAutowireCandidate(
        String beanName, DependencyDescriptor descriptor, AutowireCandidateResolver resolver)
        throws NoSuchBeanDefinitionException {
    
        // 根据BeanDefinition判断beanName对应的Bean可不可以用来进行依赖注入
        String bdName = BeanFactoryUtils.transformedBeanName(beanName);
        if (containsBeanDefinition(bdName)) {
            return isAutowireCandidate(beanName, getMergedLocalBeanDefinition(bdName), descriptor, resolver);
        }
        else if (containsSingleton(beanName)) {
            return isAutowireCandidate(beanName, new RootBeanDefinition(getType(beanName)), descriptor, resolver);
        }
        BeanFactory parent = getParentBeanFactory();
        //递归调用
        ……
    }
    

    上述条件都满足之后,获取注入值,加入的结果集中,如果注入的是数组、集合和Map三种类型,直接调用getBean去创建,否则从单例池中去取,如果没有,将beanClass存一下,后面再实例化

    private void addCandidateEntry(Map<String, Object> candidates, String candidateName,
          DependencyDescriptor descriptor, Class<?> requiredType) {
    
       if (descriptor instanceof MultiElementDescriptor) {
          Object beanInstance = descriptor.resolveCandidate(candidateName, requiredType, this);
          if (!(beanInstance instanceof NullBean)) {
             candidates.put(candidateName, beanInstance);
          }
       }
       else if (containsSingleton(candidateName) || (descriptor instanceof StreamDependencyDescriptor &&
             ((StreamDependencyDescriptor) descriptor).isOrdered())) {
          // 如果在单例池中存在,则直接放入bean对象
          Object beanInstance = descriptor.resolveCandidate(candidateName, requiredType, this);
          candidates.put(candidateName, (beanInstance instanceof NullBean ? null : beanInstance));
       }
       else {
          // 将匹配的beanName,以及beanClass存入
          candidates.put(candidateName, getType(candidateName));
       }
    }
    
  • 注入自己

    前面进行诸如判断的时候,优先其他的Bean实例,如果没有满足条件的Bean,判断是不是要注入自己,如果要注入自己,则保存自己的beanClass

    // 为空要么是真的没有匹配的,要么是匹配的自己
    if (result.isEmpty()) {
       // 需要匹配的类型是不是Map、数组之类的
       boolean multiple = indicatesMultipleBeans(requiredType);
       // 匹配的是自己,被自己添加到result中
       if (result.isEmpty() && !multiple) {
          // Consider self references as a final pass...
          // but in the case of a dependency collection, not the very same bean itself.
          for (String candidate : candidateNames) {
             if (isSelfReference(beanName, candidate) &&
                   (!(descriptor instanceof MultiElementDescriptor) || !beanName.equals(candidate)) &&
                   isAutowireCandidate(candidate, fallbackDescriptor)) {
                addCandidateEntry(result, candidate, descriptor, requiredType);
             }
          }
       }
    }
    

如果没有找到匹配的Bean实例和beanClass,判断注解上的required值是否为true,如果为true,表示必须要进行注入,则将抛出异常

3.2.3 筛选出最终的Bean实例或beanClass

如果根据类型匹配到了多个Bean实例或beanClass,进一步筛选出某一个, @Primary–>优先级最高—>name,如果得到的是一个beanClass,则进行getBean()进行实例化

if (matchingBeans.size() > 1) {
    // 根据类型找到了多个Bean,进一步筛选出某一个, @Primary-->优先级最高--->name
    autowiredBeanName = determineAutowireCandidate(matchingBeans, descriptor);
    instanceCandidate = matchingBeans.get(autowiredBeanName);
}

// 有可能筛选出来的是某个bean的类型,此处就进行实例化,调用getBean
if (instanceCandidate instanceof Class) {
    instanceCandidate = descriptor.resolveCandidate(autowiredBeanName, type, this);
}

四、核心流程图

4.1 根据Type获取BeanName流程图在这里插入图片描述

4.2 判断Bean是否可以用来依赖注入流程图

在这里插入图片描述

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/153666.html

(0)
飞熊的头像飞熊bm

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!