一文详解Spring启动阶段的那些扩展点(上)

目录

一. ApplicationContextInitializer

1. 执行源码分析

2. 加载源码分析

在启动类中定义

application.properties定义

spring.factories定义 

3. Apollo中的实际应用

二. BeanDefinitionRegistryPostProcessor

1. 执行源码位置

2. MyBatis中的实际应用

三. ApplicationContextAware

四. 总结


本文将对启动阶段的Spring三个扩展点(ApplicationContextInitializer、BeanDefinitionRegistryPostProcessor、ApplicationContextAware)做深入分析和案例讲解。Spring启动阶段扩展点系列计划上下两篇,本文是第一篇后续会依次更新。

另外想了解线程池相关的内容请看前文。

现如今在实际开发中Java的微服务项目都是使用SpringBoot框架作为主流框架。深入SpringBoot源码我们会发现SpringBoot其实是基于Spring的封装。我们熟悉的IOC容器、AOP代理等还是来自于Spring的核心功能。在阅读源码时我们发现源码中提供了许多可扩展的接口,用来实现我们想要的自定义逻辑。其实几乎所有框架或中间件都会通过预留扩展接口(拦截器/监听器/发布订阅等)来实现自定义逻辑的策略。

在这篇文章主要是介绍Spring、SpringBoot等在工作中会涉及到的扩展点。个人理解扩展点主要分为两个部分项目启动阶段的扩展点调用接口使用的扩展点。文章主要从项目启动阶段的扩展点入手逐步分析有哪些常见的扩展点。

一. ApplicationContextInitializer

 ApplicationContextInitializer发生在Spring容器刷新之前,在容器刷新之前调用此类的initialize方法。这时由于Spring容器还未刷新,所以不能对Spring容器下的Bean做扩展操作。通常被用来设置环境属性激活配置等操作。比如在中间件配置中心Apollo中就是使用了ApplicationContextInitializer扩展点来完成对配置的加载。

1. 执行源码分析

下面的applyInitializers方法是SpringBoot加载ApplicationContextInitializer扩展点并执行的源码。逻辑不复杂,先通过getInitializers方法获取所有的ApplicationContextInitializer实现类,然后遍历执行initialize方法。

源码位置:org.springframework.boot.SpringApplication#applyInitializers

    /**
	 * Apply any {@link ApplicationContextInitializer}s to the context before it is
	 * refreshed.
	 * @param context the configured ApplicationContext (not refreshed yet)
	 * @see ConfigurableApplicationContext#refresh()
	 */
	@SuppressWarnings({ "rawtypes", "unchecked" })
	protected void applyInitializers(ConfigurableApplicationContext context) {
        //通过getInitializers方法获取所有的ApplicationContextInitializer
		for (ApplicationContextInitializer initializer : getInitializers()) {
			Class<?> requiredType = GenericTypeResolver.resolveTypeArgument(initializer.getClass(),
					ApplicationContextInitializer.class);
			Assert.isInstanceOf(requiredType, context, "Unable to call initializer.");
            //执行initialize方法
			initializer.initialize(context);
		}
	}

ApplicationContextInitializer接口源码:

public interface ApplicationContextInitializer<C extends ConfigurableApplicationContext> {

	/**
	 * Initialize the given application context.
	 * @param applicationContext the application to configure
	 */
	void initialize(C applicationContext);

}

2. 加载源码分析

另外使用ApplicationContextInitializer还有一个特殊之处。Spring实现了几个默认的加载的ApplicationContextInitializer实现类,如果需要自定义的ApplicationContextInitializer类,需要添加到Spring中才能执行initialize方法。

在启动类中定义

在SpringBoot的启动类中通过springApplication.addInitializers方法就可以把ApplicationContextInitializer添加到Spring中。

源码位置:org.springframework.boot.SpringApplication#addInitializers

	/**
	 * Add {@link ApplicationContextInitializer}s to be applied to the Spring
	 * {@link ApplicationContext}.
	 * @param initializers the initializers to add
	 */
	public void addInitializers(ApplicationContextInitializer<?>... initializers) {
		this.initializers.addAll(Arrays.asList(initializers));
	}

使用代码示例: 

@SpringBootApplication
public class TwoApplication {

	public static void main(String[] args) {
		SpringApplication springApplication = new SpringApplication(TwoApplication.class);
		springApplication.addInitializers(new ConfigApplicationContextInitializer());
		springApplication.run(args);
	}
}

application.properties定义

使用这种方式其实是依赖了SpringBoot提供的默认支持的DelegatingApplicationContextInitializer。在它的执行阶段会去application.properties文件读取定义的ApplicationContextInitializer配置(context.initializer.classes然后根据配置的全类名加载自定义类然后执行ApplicationContextInitializer的initialize方法。

/**
 * {@link ApplicationContextInitializer} that delegates to other initializers that are
 * specified under a {@literal context.initializer.classes} environment property.
 *
 * @author Dave Syer
 * @author Phillip Webb
 * @since 1.0.0
 */
public class DelegatingApplicationContextInitializer
        implements ApplicationContextInitializer<ConfigurableApplicationContext>, Ordered {

    // NOTE: Similar to org.springframework.web.context.ContextLoader

    private static final String PROPERTY_NAME = "context.initializer.classes";

    private int order = 0;

    @Override
    public void initialize(ConfigurableApplicationContext context) {
        ConfigurableEnvironment environment = context.getEnvironment();
        //获取配置文件中的ApplicationContextInitializer
        List<Class<?>> initializerClasses = getInitializerClasses(environment);
        if (!initializerClasses.isEmpty()) {
            applyInitializerClasses(context, initializerClasses);
        }
    }

    private List<Class<?>> getInitializerClasses(ConfigurableEnvironment env) {
        //读取context.initializer.classes配置
        String classNames = env.getProperty(PROPERTY_NAME);
        List<Class<?>> classes = new ArrayList<>();
        if (StringUtils.hasLength(classNames)) {
            for (String className : StringUtils.tokenizeToStringArray(classNames, ",")) {
                classes.add(getInitializerClass(className));
            }
        }
        return classes;
    }

    private Class<?> getInitializerClass(String className) throws LinkageError {
        try {
            //根据全类名获取ApplicationContextInitializer类
            Class<?> initializerClass = ClassUtils.forName(className, ClassUtils.getDefaultClassLoader());
            Assert.isAssignable(ApplicationContextInitializer.class, initializerClass);
            return initializerClass;
        }
        catch (ClassNotFoundException ex) {
            throw new ApplicationContextException("Failed to load context initializer class [" + className + "]", ex);
        }
    }

    private void applyInitializerClasses(ConfigurableApplicationContext context, List<Class<?>> initializerClasses) {
        Class<?> contextClass = context.getClass();
        List<ApplicationContextInitializer<?>> initializers = new ArrayList<>();
        for (Class<?> initializerClass : initializerClasses) {
            //添加到list交给下面的方法执行
            initializers.add(instantiateInitializer(contextClass, initializerClass));
        }
        applyInitializers(context, initializers);
    }

    private ApplicationContextInitializer<?> instantiateInitializer(Class<?> contextClass, Class<?> initializerClass) {
        Class<?> requireContextClass = GenericTypeResolver.resolveTypeArgument(initializerClass,
                ApplicationContextInitializer.class);
        Assert.isAssignable(requireContextClass, contextClass,
                () -> String.format(
                        "Could not add context initializer [%s] as its generic parameter [%s] is not assignable "
                                + "from the type of application context used by this context loader [%s]: ",
                        initializerClass.getName(), requireContextClass.getName(), contextClass.getName()));
        return (ApplicationContextInitializer<?>) BeanUtils.instantiateClass(initializerClass);
    }

    @SuppressWarnings({ "unchecked", "rawtypes" })
    private void applyInitializers(ConfigurableApplicationContext context,
            List<ApplicationContextInitializer<?>> initializers) {
        initializers.sort(new AnnotationAwareOrderComparator());
        for (ApplicationContextInitializer initializer : initializers) {
            //遍历执行initialize
            initializer.initialize(context);
        }
    }

使用代码示例 :

context.initializer.classes=com.version.two.config.init.ConfigApplicationContextInitializer

spring.factories定义 

使用这种方式需要在resources目录新建/META-INFI/spring.factories文件中配置自定义的ApplicationContextInitializer。在SpringBoot项目启动时会调用SpringApplication的构造方法,在构造方法内会把这种配置方式的ApplicationContextInitializer加载进来,上面提到的默认加载的几个ApplicationContextInitializer类就是通过这种方式。

源码位置: org.springframework.boot.SpringApplication#SpringApplication(ResourceLoader, Class<?>...)

	/**
	 * Create a new {@link SpringApplication} instance. The application context will load
	 * beans from the specified primary sources (see {@link SpringApplication class-level}
	 * documentation for details). The instance can be customized before calling
	 * {@link #run(String...)}.
	 * @param resourceLoader the resource loader to use
	 * @param primarySources the primary bean sources
	 * @see #run(Class, String[])
	 * @see #setSources(Set)
	 */
	@SuppressWarnings({ "unchecked", "rawtypes" })
	public SpringApplication(ResourceLoader resourceLoader, Class<?>... primarySources) {
		this.resourceLoader = resourceLoader;
		Assert.notNull(primarySources, "PrimarySources must not be null");
		this.primarySources = new LinkedHashSet<>(Arrays.asList(primarySources));
		this.webApplicationType = WebApplicationType.deduceFromClasspath();
		this.bootstrapRegistryInitializers = new ArrayList<>(
				getSpringFactoriesInstances(BootstrapRegistryInitializer.class));
		//通过读取spring.factories文件加载ApplicationContextInitializer
        setInitializers((Collection) getSpringFactoriesInstances(ApplicationContextInitializer.class));
		setListeners((Collection) getSpringFactoriesInstances(ApplicationListener.class));
		this.mainApplicationClass = deduceMainApplicationClass();
	}

读取 /META-INFI/spring.factories文件的源码

public final class SpringFactoriesLoader {

    /**
     * The location to look for factories.
     * <p>Can be present in multiple JAR files.
     */
    public static final String FACTORIES_RESOURCE_LOCATION = "META-INF/spring.factories";

    //配置文件的缓存
    private static final Map<ClassLoader, MultiValueMap<String, String>> cache = new ConcurrentReferenceHashMap<>();


    public static <T> List<T> loadFactories(Class<T> factoryType, @Nullable ClassLoader classLoader) {
        Assert.notNull(factoryType, "'factoryType' must not be null");
        ClassLoader classLoaderToUse = classLoader;
        if (classLoaderToUse == null) {
            classLoaderToUse = SpringFactoriesLoader.class.getClassLoader();
        }

        //加载类名
        List<String> factoryImplementationNames = loadFactoryNames(factoryType, classLoaderToUse);
        if (logger.isTraceEnabled()) {
            logger.trace("Loaded [" + factoryType.getName() + "] names: " + factoryImplementationNames);
        }
        List<T> result = new ArrayList<>(factoryImplementationNames.size());
        for (String factoryImplementationName : factoryImplementationNames) {
            result.add(instantiateFactory(factoryImplementationName, factoryType, classLoaderToUse));
        }
        AnnotationAwareOrderComparator.sort(result);
        return result;
    }


    public static List<String> loadFactoryNames(Class<?> factoryType, @Nullable ClassLoader classLoader) {
        String factoryTypeName = factoryType.getName();
        return loadSpringFactories(classLoader).getOrDefault(factoryTypeName, Collections.emptyList());
    }

    private static Map<String, List<String>> loadSpringFactories(@Nullable ClassLoader classLoader) {
        MultiValueMap<String, String> result = cache.get(classLoader);
        if (result != null) {
            return result;
        }

        try {
            //META-INF/spring.factorie
            Enumeration<URL> urls = (classLoader != null ?
                    classLoader.getResources(FACTORIES_RESOURCE_LOCATION) :
                    ClassLoader.getSystemResources(FACTORIES_RESOURCE_LOCATION));
            result = new LinkedMultiValueMap<>();
            while (urls.hasMoreElements()) {
                URL url = urls.nextElement();
                UrlResource resource = new UrlResource(url);
                Properties properties = PropertiesLoaderUtils.loadProperties(resource);
                for (Map.Entry<?, ?> entry : properties.entrySet()) {
                    String factoryTypeName = ((String) entry.getKey()).trim();
                    for (String factoryImplementationName : StringUtils.commaDelimitedListToStringArray((String) entry.getValue())) {
                        result.add(factoryTypeName, factoryImplementationName.trim());
                    }
                }
            }
            cache.put(classLoader, result);
            return result;
        }
        catch (IOException ex) {
            throw new IllegalArgumentException("Unable to load factories from location [" +
                    FACTORIES_RESOURCE_LOCATION + "]", ex);
        }
    }    }

 使用示例:

# Application Context Initializers
org.springframework.context.ApplicationContextInitializer=\
org.springframework.boot.context.ConfigurationWarningsApplicationContextInitializer,\
org.springframework.boot.context.ContextIdApplicationContextInitializer,\
org.springframework.boot.context.config.DelegatingApplicationContextInitializer,\
org.springframework.boot.rsocket.context.RSocketPortInfoApplicationContextInitializer,\
org.springframework.boot.web.context.ServerPortInfoApplicationContextInitializer

3. Apollo中的实际应用

在使用配置中心时有一个很重要的逻辑就是在项目启动阶段需要把配置的数据加载到我们的项目之中。加载的时间应该选择在Spring容器刷新之前,显然这种场景比较适合使用我们这次介绍的ApplicationContextInitializer。下面将通过实际的案例来展示ApplicationContextInitializer应用。

Apollo加载ApplicationContextInitializer方式

org.springframework.context.ApplicationContextInitializer=\
com.ctrip.framework.apollo.spring.boot.ApolloApplicationContextInitializer

对于主要的实现逻辑做了代码注释,如果想要了解Apollo的整体流程和细节可参考官网: Apollo

public class ApolloApplicationContextInitializer implements
        ApplicationContextInitializer<ConfigurableApplicationContext> , EnvironmentPostProcessor, Ordered {
  
  @Override
  public void initialize(ConfigurableApplicationContext context) {
    ConfigurableEnvironment environment = context.getEnvironment();

    if (!environment.getProperty(PropertySourcesConstants.APOLLO_BOOTSTRAP_ENABLED, Boolean.class, false)) {
      logger.debug("Apollo bootstrap config is not enabled for context {}, see property: ${{}}", context, PropertySourcesConstants.APOLLO_BOOTSTRAP_ENABLED);
      return;
    }
    logger.debug("Apollo bootstrap config is enabled for context {}", context);
    //主要逻辑在这个方法
    initialize(environment);
  }

  /**
   * Initialize Apollo Configurations Just after environment is ready.
   *
   * @param environment
   */
  protected void initialize(ConfigurableEnvironment environment) {
    final ConfigUtil configUtil = ApolloInjector.getInstance(ConfigUtil.class);
    if (environment.getPropertySources().contains(PropertySourcesConstants.APOLLO_BOOTSTRAP_PROPERTY_SOURCE_NAME)) {
      //already initialized, replay the logs that were printed before the logging system was initialized
      DeferredLogger.replayTo();
      if (configUtil.isOverrideSystemProperties()) {
        // ensure ApolloBootstrapPropertySources is still the first
        PropertySourcesUtil.ensureBootstrapPropertyPrecedence(environment);
      }
      return;
    }

    String namespaces = environment.getProperty(PropertySourcesConstants.APOLLO_BOOTSTRAP_NAMESPACES, ConfigConsts.NAMESPACE_APPLICATION);
    logger.debug("Apollo bootstrap namespaces: {}", namespaces);
    List<String> namespaceList = NAMESPACE_SPLITTER.splitToList(namespaces);

    CompositePropertySource composite;
    if (configUtil.isPropertyNamesCacheEnabled()) {
      composite = new CachedCompositePropertySource(PropertySourcesConstants.APOLLO_BOOTSTRAP_PROPERTY_SOURCE_NAME);
    } else {
      composite = new CompositePropertySource(PropertySourcesConstants.APOLLO_BOOTSTRAP_PROPERTY_SOURCE_NAME);
    }
    //上面的代码就是加载Apollo的命名空间和属性源
    for (String namespace : namespaceList) {
      //核心代码 获取配置,
      //逻辑很复杂。主要是通过Http长链接接口来获取的配置和启动定时任务拉取数据
      Config config = ConfigService.getConfig(namespace);

      composite.addPropertySource(configPropertySourceFactory.getConfigPropertySource(namespace, config));
    }
    if (!configUtil.isOverrideSystemProperties()) {
      if (environment.getPropertySources().contains(StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME)) {
        environment.getPropertySources().addAfter(StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, composite);
        return;
      }
    }
    //保存到环境配置下的PropertySources属性源
    //在bean的后置处理环节会根据注解赋值
    environment.getPropertySources().addFirst(composite);
  }

二. BeanDefinitionRegistryPostProcessor

我们常常提及的IOC容器就是把对象(bean)交给Spring来管理,我们开发时直接从容器内获取即可。首先我们需要把对象转换成Bean才能托管给Spring,这些需要转换成Bean的对象可能来自配置类、配置文件、外部中间件等。Spring设计了一个集合BeanDefinition Map来存储这些来自不同来源的需要转换成bean的对象,然后通过对BeanDefinition Map的遍历来创建Bean。
	/** Map of bean definition objects, keyed by bean name. */
	private final Map<String, BeanDefinition> beanDefinitionMap = new ConcurrentHashMap<>(256);

BeanDefinitionRegistryPostProcessor处理器用于把对象定义成BeanDefinition的。最常见的ConfigurationClassPostProcessor后处理器也是该类型的实现,用于解析配置类以及各种配置注解@Configuration 、@Component、@ComponentScan、@Bean。

如果在Spring中使用了其他中间件,比如Mybatis需要把持久层对象交给Spring的IOC容器管理,这时就需要扩展BeanDefinitionRegistryPostProcessor把Mybatis需要托管的对象转换成BeanDefinition

1. 执行源码位置

执行逻辑在Spring容器启动的核心方法refresh内部,执行链路:invokeBeanFactoryPostProcessors -> PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors -> org.springframework.context.support.PostProcessorRegistrationDelegate#invokeBeanDefinitionRegistryPostProcessors。这部分的逻辑根据传进来的BeanDefinitionRegistryPostProcessor集合遍历后执行postProcessBeanDefinitionRegistry

	/**
	 * Invoke the given BeanDefinitionRegistryPostProcessor beans.
	 */
	private static void invokeBeanDefinitionRegistryPostProcessors(
			Collection<? extends BeanDefinitionRegistryPostProcessor> postProcessors, BeanDefinitionRegistry registry) {

		for (BeanDefinitionRegistryPostProcessor postProcessor : postProcessors) {
			postProcessor.postProcessBeanDefinitionRegistry(registry);
		}
	}

2. MyBatis中的实际应用

在使用Mybatis时我们需要创建Mapper接口来定义持久层的方法。通常会使用@MapperScan注解来扫描包里面持久层的接口。查看@MapperScan注解的源码我们发现它是org.mybatis.spring.annotation包下的注解并不属于Spring,Spring的IOC容器也没办法直接对它进行管理。这时就涉及到了我们的提到的BeanDefinitionRegistryPostProcessor扩展点,自定义逻辑把@MapperScan注解扫描到的持久层接口转换成BeanDefinition,最终通过BeanDefinition来生成Bean。

从下面的源码中可以看到MapperScannerConfigurer实现BeanDefinitionRegistryPostProcessor接口,postProcessBeanDefinitionRegistry方法实现了对持久层接口的加载。其中的关键逻辑在ClassPathMapperScanner扫描类scanner.scan方法

public class MapperScannerConfigurer
    implements BeanDefinitionRegistryPostProcessor, InitializingBean, ApplicationContextAware, BeanNameAware {

  /**
   * 忽略非核心代码
   */

  /**
   * {@inheritDoc}
   *
   * @since 1.0.2
   */
  @Override
  public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
    if (this.processPropertyPlaceHolders) {
      processPropertyPlaceHolders();
    }
    // 创建一个扫描器
    ClassPathMapperScanner scanner = new ClassPathMapperScanner(registry);
    scanner.setAddToConfig(this.addToConfig);
    scanner.setAnnotationClass(this.annotationClass);
    scanner.setMarkerInterface(this.markerInterface);
    scanner.setSqlSessionFactory(this.sqlSessionFactory);
    scanner.setSqlSessionTemplate(this.sqlSessionTemplate);
    scanner.setSqlSessionFactoryBeanName(this.sqlSessionFactoryBeanName);
    scanner.setSqlSessionTemplateBeanName(this.sqlSessionTemplateBeanName);
    scanner.setResourceLoader(this.applicationContext);
    scanner.setBeanNameGenerator(this.nameGenerator);
    scanner.setMapperFactoryBeanClass(this.mapperFactoryBeanClass);
    if (StringUtils.hasText(lazyInitialization)) {
      scanner.setLazyInitialization(Boolean.valueOf(lazyInitialization));
    }
    if (StringUtils.hasText(defaultScope)) {
      scanner.setDefaultScope(defaultScope);
    }
    //注册筛选条件
    scanner.registerFilters();
    //调用ClassPathBeanDefinitionScanner扫描包,多个包名可以使用逗号分割
    //核心重点在doScan方法
    scanner.scan(
      StringUtils.tokenizeToStringArray(this.basePackage, ConfigurableApplicationContext.CONFIG_LOCATION_DELIMITERS));
  }
}

核心逻辑在:org.mybatis.spring.mapper.ClassPathMapperScanner#doScan。父类的方法调用获取beanDefinition集合然后注册到DefaultListableBeanFactory

 /**
   * Calls the parent search that will search and register all the candidates. Then the registered objects are post
   * processed to set them as MapperFactoryBeans
   */
  @Override
  public Set<BeanDefinitionHolder> doScan(String... basePackages) {
    // 传入包路径扫描包下的类
    // 获取ClassPathBeanDefinitionScanner 扫描后的 beanDefinition 集合
    Set<BeanDefinitionHolder> beanDefinitions = super.doScan(basePackages);

    if (beanDefinitions.isEmpty()) {
      if (printWarnLogIfNotFoundMappers) {
        LOGGER.warn(() -> "No MyBatis mapper was found in '" + Arrays.toString(basePackages)
            + "' package. Please check your configuration.");
      }
    } else {
      //对beanDefinition做额外处理
      processBeanDefinitions(beanDefinitions);
    }

    return beanDefinitions;
  }

三. ApplicationContextAware

Spring提供了许多感知类的接口,为了让Bean轻松获取Spring的服务。比如在我们日常开发中,除了通过@Autowired注解获取Bean外,有时候我们会需要从Spring容器中手动获取Bean。比如使用ApplicationContextAware来获取ApplicationContext容器即可获取到Bean。以下是常见的几种感知类接口。

BeanNameAware获得到容器中Bean的名称
BeanFactoryAware获得当前bean factory,这样可以调用容器的服务
ApplicationContextAware获得当前application context,这样可以调用容器的服务
MessageSourceAware获得message source这样可以获得文本信息
ApplicationEventPublisherAware应用事件发布器,可以发布事件
ResourceLoaderAware获得资源加载器,可以获得外部资源文件

在实际项目中可能会使用一个SpringUtil来获取到ApplicationContext的bean

@Component
public class SpringContextUtil implements ApplicationContextAware {
    private static ApplicationContext applicationContext;

    @Override
    public void setApplicationContext(ApplicationContext context) throws BeansException {
        applicationContext = context;
    }

    //静态加载applicationContext
    public static ApplicationContext getApplicationContext() {
        return applicationContext;
    }
    //通过反射获取Bean
    public static <T> T getBean(Class<T> requiredType){
        return getApplicationContext().getBean(requiredType);
    }
    //通过id名获取bean
    public static <T> T getBean(String name){
        return (T) getApplicationContext().getBean(name);
    }
}

四. 总结

本文主要介绍了SpringBoot项目启动前期的几个扩展点,简单介绍了原理和源码的分析。毕竟文章是便于实践的,也举了几个在工作中遇见到的实际应用的案例来加深理解。Spring框架内容很多整体分析起来十分耗时,只能选取部分常见的举例。后面将继续对Spring启动过程中的其他扩展点进行分析。

最后感谢大家观看!如有帮助 感谢支持!

后续争取每周更新一篇

  • 33
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

实操手

如有帮助 感谢支持

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值