Spring源码阅读-- IOC容器资源定位

1、环境搭建

工程目录结构:
这里写图片描述
首先我们从最基础的spring开发代码开始,首先上场的是spring配置文件:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
            http://www.springframework.org/schema/beans/spring-beans-4.3.xsd
            http://www.springframework.org/schema/context
            http://www.springframework.org/schema/context/spring-context-4.3.xsd ">

    <!-- 引入配置文件 -->
    <context:property-placeholder location="classpath:jdbc.properties" />

    <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close">
        <!--属性注入-->
        <property name="driverClassName" value="${jdbc.driverClassName}"/>
        <property name="url" value="${jdbc.url}"/>
        <property name="username" value="${jdbc.username}"/>
        <property name="password" value="${jdbc.password}"/>
        <!-- 连接池最大使用连接数 -->
        <property name="maxActive" value="${jdbc.maxActive}"/>
        <!-- 初始化连接大小 -->
        <property name="initialSize" value="${jdbc.initialSize}"/>
        <!-- 获取连接最大等待时间 -->
        <property name="maxWait" value="${jdbc.maxWait}"/>
        <!-- 连接池最大空闲 -->
        <!--  property name="maxIdle" value="${jdbc.maxIdle}"/>-->
        <!-- 连接池最小空闲 -->
        <property name="minIdle" value="${jdbc.minIdle}"/>
        <!-- 自动清除无用连接 -->
        <property name="removeAbandoned" value="${jdbc.removeAbandoned}"/>
        <!-- 清除无用连接的等待时间 -->
        <property name="removeAbandonedTimeout" value="${jdbc.removeAbandonedTimeout}"/>
        <!-- 连接属性 -->
        <property name="connectionProperties" value="${jdbc.connectionProperties}"/>
    </bean>

    <bean id="zhangsan"  class="com.igood.entity.User">
        <constructor-arg type="java.lang.String">
            <value>zhangsan</value>
        </constructor-arg>
        <constructor-arg type="java.lang.String">
            <value>123456</value>
        </constructor-arg>
        <constructor-arg type="java.lang.Integer">
            <value>20</value>
        </constructor-arg>
    </bean>
    <!--给zhangsan这个bean起几个别名,其中有一个别名和原来bean名称相同-->
    <alias name="zhangsan" alias="zhangsan,zhang3,alias1"/>

    <!--设置名称为wangwu的bean不是懒加载-->
    <bean id="wangwu"  class="com.igood.entity.User" lazy-init="false" scope="prototype">
        <constructor-arg type="java.lang.String">
            <value>wangwu</value>
        </constructor-arg>
        <constructor-arg type="java.lang.String">
            <value>111111</value>
        </constructor-arg>
        <constructor-arg type="java.lang.Integer">
            <value>25</value>
        </constructor-arg>
    </bean>
</beans>

接着是main函数启动代码

//代码片段1
public static void main(String[] args) {

    ClassPathXmlApplicationContext context =
            new ClassPathXmlApplicationContext("classpath:spring-beans.xml");
    User bean = (User)context.getBean("zhang3");
    bean.getUsername();
    context.close();
}

在跟踪源码前,先来看一下spring IOC 体系结构相关类的继承关系图:
这里写图片描述
为了思路清晰,类图中只列出比较重要的方法和变量。
好了,开始跟踪spring源码。首先进入ClassPathXmlApplicationContext的构造函数

//代码片段2
public ClassPathXmlApplicationContext(String... configLocations) throws BeansException {
    this(configLocations, true, null);
}

public ClassPathXmlApplicationContext(String[] configLocations, boolean refresh, ApplicationContext parent)
        throws BeansException {

    super(parent);
    setConfigLocations(configLocations);
    if (refresh) {
        refresh();
    }
}

一直沿着继承关系查看父类的构造函数,到AbstractApplicationContext

//代码片段3
public AbstractApplicationContext() {
    this.resourcePatternResolver = getResourcePatternResolver();
}
protected ResourcePatternResolver getResourcePatternResolver() {
    return new PathMatchingResourcePatternResolver(this);
}

从ClassPathXmlApplicationContext的类图可以看出AbstractApplicationContext和PathMatchingResourcePatternResolver实现了ResourceLoader接口,也就是说AbstractApplicationContext也有ResourcePatternResolver的getResource()的能力,但是AbstractApplicationContext不亲自具体实现getResource,而是委派给PathMatchingResourcePatternResolver去做。

2、径路解析

回到“代码片段2”中,ClassPathXmlApplicationContext的构造函数中setConfigLocations,这个函数在父类AbstractRefreshableConfigApplicationContext中实现

 //代码片段4
 public void setConfigLocations(String... locations) {
    if (locations != null) {
        Assert.noNullElements(locations, "Config locations must not be null");
        this.configLocations = new String[locations.length];
        for (int i = 0; i < locations.length; i++) {
            this.configLocations[i] = resolvePath(locations[i]).trim();
        }
    }
    else {
        this.configLocations = null;
    }
}

protected String resolvePath(String path) {
    return getEnvironment().resolveRequiredPlaceholders(path);
}

resolvePath的作用就是将创建ApplicationContext传入进来的locations(classpath: spring-beans.xml)配置文件路径转换为合法的location,就是将路径中包含环境变量转成变量的实际值,比如路径有 getEnviroment()StandardEnvironment {}的替换。

 //代码片段5
public class StandardEnvironment extends AbstractEnvironment {
    /** System environment property source name: {@value} */
    public static final String SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME = "systemEnvironment";

    /** JVM system properties property source name: {@value} */
    public static final String SYSTEM_PROPERTIES_PROPERTY_SOURCE_NAME = "systemProperties";

    @Override
    protected void customizePropertySources(MutablePropertySources propertySources) {
        propertySources.addLast(new MapPropertySource(SYSTEM_PROPERTIES_PROPERTY_SOURCE_NAME, getSystemProperties()));
        propertySources.addLast(new SystemEnvironmentPropertySource(SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, getSystemEnvironment()));
    }

}

StandardEnvironment就是读取操作系统的环境变量和JVM的变量。当传进来的路径中包含这些环境变量就替换成系统中变量的实际值。比如我们传进来的路径是”classpath: JAVAHOME/springbeans.xml",resolveRequiredPlaceholders {JAVA_HOME}替换成你电脑配置的JAVA_HOME环境变量的值,如”classpath:C:/Program Files/Java/jdk1.8.0_91/spring-beans.xml”。

3、容器刷新

好了,资源路径准备好了,我们回到”代码片段2”中的refresh()函数,重头戏都在这个函数里了。这个函数在父类AbstractApplicationContext中实现。

 //代码片段6
public void refresh() throws BeansException, IllegalStateException {
    synchronized (this.startupShutdownMonitor) {
        // 刷新工厂之前需要做一些准备工作的啦,就想你在运动之前要做一些准备运动一样哦 
        prepareRefresh();

        // 我会告诉我的子类创造一个工厂,来把我需要创建bean的原料BeanDefinition准备好
        ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();

        // 原料准备好之后呢,我要声明一些特殊的依赖关系, 所谓依赖,就是我在创造一个bean A的时候,发现它里面有另外一个属性B
        // 那么B就是A的依赖,我在创造A的时候,必须先把B创造好,特殊关系的依赖就是指我遇到B的类型,我该放弃呢,还是告诉他直接用
        // 现成的(也就是不用再去创造B了)
        prepareBeanFactory(beanFactory);

        try {
            // 这里没啥,就是留给子类做扩展的啦
            postProcessBeanFactory(beanFactory);

            // 到了这里,工厂已经准备好了,如果你之前告诉过我工厂准备好之后应该干什么事情,这边我就可以满足你的需求哦
            // 不信,你去看看BeanFactoryPostProcessors接口是干嘛用的吧==
            invokeBeanFactoryPostProcessors(beanFactory);

            // 在创建一个bean的前后,我也留给你很多扩展,原理上和上面的工厂扩展差不多的哦
            registerBeanPostProcessors(beanFactory);

            // 就是处理一些国际化的操作啦,啊?什么是国际化,就是i18n啦,还不懂?你没救了
            initMessageSource();

            // 我的功能很丰富,除了可以给你创建bean,还可以有事件管理的功能哦,这里我就创建一个管理器(ApplicationEventMulticaster(),
            // 用来注册事件(ApplicationEvent)
            // 我会将这些事件广播给合适的监听者(ApplicationListener)那边哦
            initApplicationEventMulticaster();

            // 啥也不干,留给子类扩展啦
            onRefresh();

            // 前面不是事件管理器搞好了嘛,这边呢,就是把那些事件监听器给注册进来啦,这样来一个新的事件我就知道该发给谁啦
            registerListeners();

            // 如果某些bean告我我,他想在我工厂创建之初就想初始化(一般要是单件singleton并且lazy-init为false),那么我在这个函数会满足他
            finishBeanFactoryInitialization(beanFactory);

            // 终于刷新完了,我要开始发布事件了!
            finishRefresh();
        }

        // 什么?刷新的时候报错了?oh my god,我需要做一些清理
        catch (BeansException ex) {
            logger.warn("Exception encountered during context initialization - cancelling refresh attempt", ex);

            // 我需要将我创建的bean销毁掉
            destroyBeans();

            // 我不再活跃
            cancelRefresh(ex);

            // 我要告诉你,我出异常了,救我!!
            throw ex;
        }

        finally {
            // 一些通用的缓存清掉!!
            resetCommonCaches();
        }
    }
}

protected ConfigurableListableBeanFactory obtainFreshBeanFactory() {
    refreshBeanFactory();
    ConfigurableListableBeanFactory beanFactory = getBeanFactory();
    if (logger.isDebugEnabled()) {
        logger.debug("Bean factory for " + getDisplayName() + ": " + beanFactory);
    }
    return beanFactory;
}

refresh()方法主要为 IOC 容器 Bean 的生命周期管理提供条件, Spring IOC 容器载入 Bean 定义资源文件从其子类容器的 refreshBeanFactory()方法启动, 所以整个 refresh()中“ConfigurableListableBeanFactory beanFactory =obtainFreshBeanFactory();” 这句以后代码的都是注册容器的信息源和生命周期事件, 载入过程就是从这句代码启动。

3.1、创建容器

顺着obtainFreshBeanFactory方法,进入到AbstractRefreshableApplicationContext的refreshBeanFactory方法,这个方法的主要功能就是创建默认的IOC容器和Xml配置文件的相关操作。

//代码片段7
protected final void refreshBeanFactory() throws BeansException {
    if (hasBeanFactory()) {//如果已经有容器, 销毁容器中的 bean, 关闭容器,以保证在 refresh 之后使用的是新建立起来的 IOC 容器
        destroyBeans();
        closeBeanFactory();
    }
    try {
        DefaultListableBeanFactory beanFactory = createBeanFactory();//创建 IOC 容器了
        beanFactory.setSerializationId(getId());
        //对 IOC 容器进行定制化, 如设置启动参数, 开启注解的自动装配等
        customizeBeanFactory(beanFactory);
        // 加载bean的定义,我们用xml描述了各种bean的具体定义,所以这个函数
        loadBeanDefinitions(beanFactory);
        synchronized (this.beanFactoryMonitor) {
            this.beanFactory = beanFactory;
        }
    }
    catch (IOException ex) {
        throw new ApplicationContextException("I/O error parsing bean definition source for " + getDisplayName(), ex);
    }
}

第7行代码DefaultListableBeanFactory beanFactory = createBeanFactory()为我们新创建一个IOC容器,所谓容器就是一个Map数据结构,进入DefaultListableBeanFactory代码就发现有private final Map<String, BeanDefinition> beanDefinitionMap = new ConcurrentHashMap<String, BeanDefinition>(256);这个成员变量,spring就是将以bean的名字做key,bean的定义作为值存到这个Map中,这就是我们需要创建bean所需要的原材料。

3.2、加载bean定义

既然容器创建好了,那么第12行代码loadBeanDefinitions(beanFactory)就要为beanFactory容器加载bean定义(BeanDefinition)了。
AbstractRefreshableApplicationContext 中只定义了抽象的 loadBeanDefinitions 方法, 容器真正调用的是其子类 AbstractXmlApplicationContext 对该方法的实现。

//代码片段8
//AbstractXmlApplicationContext实现父类的抽象方法
@Override
protected void loadBeanDefinitions(DefaultListableBeanFactory beanFactory) throws BeansException, IOException {
    // Create a new XmlBeanDefinitionReader for the given BeanFactory.
    XmlBeanDefinitionReader beanDefinitionReader = new XmlBeanDefinitionReader(beanFactory);

    // Configure the bean definition reader with this context's
    // resource loading environment.
    beanDefinitionReader.setEnvironment(this.getEnvironment());
    //设置资源加载器就是容器本身,1、环境搭建小节中“代码片段3”说明AbstractApplicationContext也有资源加载器的能力
    beanDefinitionReader.setResourceLoader(this);
    beanDefinitionReader.setEntityResolver(new ResourceEntityResolver(this));

    // Allow a subclass to provide custom initialization of the reader,
    // then proceed with actually loading the bean definitions.
    initBeanDefinitionReader(beanDefinitionReader);
    loadBeanDefinitions(beanDefinitionReader);
}

方法里定义了一个XmlBeanDefinitionReader类型的对象,这个对象的作用就是读取Xml配置文件。最后一行代码loadBeanDefinitions(beanDefinitionReader);就是用这个读取器来加载bean定义的。

//代码片段9
protected void loadBeanDefinitions(XmlBeanDefinitionReader reader) throws BeansException, IOException {
    Resource[] configResources = getConfigResources();
    if (configResources != null) {
        reader.loadBeanDefinitions(configResources);
    }
    String[] configLocations = getConfigLocations();
    if (configLocations != null) {
        reader.loadBeanDefinitions(configLocations);
    }
}

我们会看到有两种方式加载配置文件,一种是从Resource类型的路径中加载,还有一种是从String类型的路径中加载。 由于我们main函数入口是new ClassPathXmlApplicationContext("classpath:spring-beans.xml");所以new出来ClassPathXmlApplicationContext对象里getConfigResources()是null的,而getConfigLocations()就前面“ 2、径路解析”小节准备的配置文件路径,因此进入到第8行代码,从从String类型的路径中加载xml文件。

进入XmlBeanDefinitionReader的loadBeanDefinitions方法:

//代码片段10
public int loadBeanDefinitions(String... locations) throws BeanDefinitionStoreException {
    Assert.notNull(locations, "Location array must not be null");
    int counter = 0;
    for (String location : locations) {
        counter += loadBeanDefinitions(location);
    }
    return counter;
}

loadBeanDefinitions循环从每个路径中加载xml文件。真正加载资源的是它重载函数:

//代码片段11
//真正干活的函数
public int loadBeanDefinitions(String location, Set<Resource> actualResources) throws BeanDefinitionStoreException {
    ResourceLoader resourceLoader = getResourceLoader();
    if (resourceLoader == null) {
        throw new BeanDefinitionStoreException(
                "Cannot import bean definitions from location [" + location + "]: no ResourceLoader available");
    }

    if (resourceLoader instanceof ResourcePatternResolver) {
        // Resource pattern matching available.
        try {
            Resource[] resources = ((ResourcePatternResolver) resourceLoader).getResources(location);
            int loadCount = loadBeanDefinitions(resources);
            if (actualResources != null) {
                for (Resource resource : resources) {
                    actualResources.add(resource);
                }
            }
            if (logger.isDebugEnabled()) {
                logger.debug("Loaded " + loadCount + " bean definitions from location pattern [" + location + "]");
            }
            return loadCount;
        }
        catch (IOException ex) {
            throw new BeanDefinitionStoreException(
                    "Could not resolve bean definition resource pattern [" + location + "]", ex);
        }
    }
    else {
        // Can only load single resources by absolute URL.
        Resource resource = resourceLoader.getResource(location);
        int loadCount = loadBeanDefinitions(resource);
        if (actualResources != null) {
            actualResources.add(resource);
        }
        if (logger.isDebugEnabled()) {
            logger.debug("Loaded " + loadCount + " bean definitions from location [" + location + "]");
        }
        return loadCount;
    }
}

第4行代码ResourceLoader resourceLoader = getResourceLoader();获取的ResourceLoader对象就是“代码片段8”中beanDefinitionReader.setResourceLoader(this);,也就是ClassPathXmlApplicationContext类的对象,1、环境搭建小节中“代码片段3”说明AbstractApplicationContext实现ResourcePatternResolver接口,因此作为AbstractApplicationContext的子类ClassPathXmlApplicationContext对象拥有getResource()的能力,但是ClassPathXmlApplicationContext不亲自具体实现getResource,而是委派给PathMatchingResourcePatternResolver去做。因此”代码片段11”中Resource[] resources = ((ResourcePatternResolver) resourceLoader).getResources(location);最终调用如下图:
image

4、资源定位

好了,跟踪这么久现在才进入本文的标题真正要说的事。进入PathMatchingResourcePatternResolver类的getResource方法:

//代码片段12
@Override
public Resource[] getResources(String locationPattern) throws IOException {
    Assert.notNull(locationPattern, "Location pattern must not be null");
    if (locationPattern.startsWith(CLASSPATH_ALL_URL_PREFIX)) {//处理以classpath*:开头的路径
        // a class path resource (multiple resources for same name possible)
        if (getPathMatcher().isPattern(locationPattern.substring(CLASSPATH_ALL_URL_PREFIX.length()))) {//路径中包含*或?这样的通配符
            // a class path resource pattern
            return findPathMatchingResources(locationPattern);
        }
        else {
        //路径中不包含通配符
            // all class path resources with the given name
            return findAllClassPathResources(locationPattern.substring(CLASSPATH_ALL_URL_PREFIX.length()));
        }
    }
    else {
    //处理不以classpath*:开头的路径
        // Only look for a pattern after a prefix here
        // (to not get fooled by a pattern symbol in a strange prefix).
        int prefixEnd = locationPattern.indexOf(":") + 1;
        if (getPathMatcher().isPattern(locationPattern.substring(prefixEnd))) {//路径中包含*或?这样的通配符
        // a file pattern
            return findPathMatchingResources(locationPattern);
        }
        else {
        //路径中不包含通配符
        // a single resource with the given name
            return new Resource[] {getResourceLoader().getResource(locationPattern)};
        }
    }
}

由上面代码我们可以看出在加载配置文件时,以是否是以classpath*开头分为2种情况处理场景,每种情况类在又根据路径中是否包括通配符进行处理。
image

4.1、处理以classpath*开头且不包含通配符

进入findAllClassPathResources函数看看

protected Resource[] findAllClassPathResources(String location) throws IOException {
    String path = location;
    if (path.startsWith("/")) {
        path = path.substring(1);
    }
    Set<Resource> result = doFindAllClassPathResources(path);
    if (logger.isDebugEnabled()) {
        logger.debug("Resolved classpath location [" + location + "] to resources " + result);
    }
    return result.toArray(new Resource[result.size()]);
}
protected Set<Resource> doFindAllClassPathResources(String path) throws IOException {
    Set<Resource> result = new LinkedHashSet<Resource>(16);
    ClassLoader cl = getClassLoader();
    Enumeration<URL> resourceUrls = (cl != null ? cl.getResources(path) : ClassLoader.getSystemResources(path));
    while (resourceUrls.hasMoreElements()) {
        URL url = resourceUrls.nextElement();
        result.add(convertClassLoaderURL(url));
    }
    if ("".equals(path)) {
        // The above result is likely to be incomplete, i.e. only containing file system references.
        // We need to have pointers to each of the jar files on the classpath as well...
        addAllClassLoaderJarRoots(cl, result);
    }
    return result;
}

跟踪doFindAllClassPathResources方法中的ClassLoader cl = getClassLoader();进入到ClassLoader的getResources()方法:

//ClassLoader.java
public Enumeration<URL> getResources(String name) throws IOException {
    @SuppressWarnings("unchecked")
    Enumeration<URL>[] tmp = (Enumeration<URL>[]) new Enumeration<?>[2];
    if (parent != null) {
        //如果存在父加载器,则向上迭代获取资源
        tmp[0] = parent.getResources(name);
    } else {
        //Bootstrap classLoader主要加载JVM自身工作需要的类,位于$JAVA_HOME/jre/lib/下的jar包
        tmp[0] = getBootstrapResources(name);
    }
    tmp[1] = findResources(name);

    return new CompoundEnumeration<>(tmp);
}

当前类加载器,如果存在父加载器,则向上迭代获取资源, 因此能加到jar包里面的资源文件。

4.2、处理不以classpath*开头且不包含通配符

就是一行代码

new Resource[] {getResourceLoader().getResource(locationPattern)};

getResourceLoader()方法获取到就是默认的资源加载器

public ResourceLoader getResourceLoader() {
    return this.resourceLoader;
}
public PathMatchingResourcePatternResolver(ClassLoader classLoader) {
    this.resourceLoader = new DefaultResourceLoader(classLoader);
}

因此,我们进入DefaultResourceLoader类的getResource方法:

public Resource getResource(String location) {
    Assert.notNull(location, "Location must not be null");

    for (ProtocolResolver protocolResolver : this.protocolResolvers) {
        Resource resource = protocolResolver.resolve(location, this);
        if (resource != null) {
            return resource;
        }
    }

    if (location.startsWith("/")) {
        return getResourceByPath(location);
    }
    else if (location.startsWith(CLASSPATH_URL_PREFIX)) {
        //如果以classpath开头,则创建为一个ClassPathResource
        return new ClassPathResource(location.substring(CLASSPATH_URL_PREFIX.length()), getClassLoader());
    }
    else {
        try {
            // Try to parse the location as a URL...
            //以URL的方式加载资源,创建一个UrlResource.
            URL url = new URL(location);
            return new UrlResource(url);
        }
        catch (MalformedURLException ex) {
            // No URL -> resolve as resource path.
            return getResourceByPath(location);
        }
    }
}

由此可看出,不以“classpath*”的开头路径getResource()仅返回路径(包括jar包)中的一个且仅一个资源;对于多个匹配的也只返回一个。

4.3、路径包含通配符的

protected Resource[] findPathMatchingResources(String locationPattern) throws IOException {
    //获取确定的根路径(没有通配符的最长路径),如cn/javass/config-*.xml,则根路径是cn/javass/;
    //如cn/**/config.xml,跟路径是cn/
    String rootDirPath = determineRootDir(locationPattern);
    //含通配符的,如cn/javass/config-*.xml,则subPattern是config-*.xml
    String subPattern = locationPattern.substring(rootDirPath.length());
    //获取跟路径下所有资源
    Resource[] rootDirResources = getResources(rootDirPath);
    Set<Resource> result = new LinkedHashSet<Resource>(16);
    for (Resource rootDirResource : rootDirResources) {//查找满足通配符的资源
        rootDirResource = resolveRootDirResource(rootDirResource);
        URL rootDirURL = rootDirResource.getURL();
        if (equinoxResolveMethod != null) {
            if (rootDirURL.getProtocol().startsWith("bundle")) {
                rootDirURL = (URL) ReflectionUtils.invokeMethod(equinoxResolveMethod, null, rootDirURL);
                rootDirResource = new UrlResource(rootDirURL);
            }
        }
        if (rootDirURL.getProtocol().startsWith(ResourceUtils.URL_PROTOCOL_VFS)) {
            result.addAll(VfsResourceMatchingDelegate.findMatchingResources(rootDirURL, subPattern, getPathMatcher()));
        }
        else if (ResourceUtils.isJarURL(rootDirURL) || isJarResource(rootDirResource)) {
            result.addAll(doFindPathMatchingJarResources(rootDirResource, rootDirURL, subPattern));
        }
        else {
            result.addAll(doFindPathMatchingFileResources(rootDirResource, subPattern));
        }
    }
    if (logger.isDebugEnabled()) {
        logger.debug("Resolved location pattern [" + locationPattern + "] to resources " + result);
    }
    return result.toArray(new Resource[result.size()]);
}

以“classpath*”的开头路径,getResources()加载类路径(包括jar包)中的所有匹配的资源。

至此, Spring IOC 容器在初始化时将配置的 Bean 定义资源文件定位为 Spring 封装的 Resource。

总结

来一张图过一遍代码执行简要过程
image

参考
http://blog.csdn.net/ray_seu/article/details/50096889
http://blog.csdn.net/zl3450341/article/details/9306983

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值