Spring实战(5):装配Bean——高级装配

在上一篇博客中我们看到了最为核心的Bean装配技术。但是,Bean装配所涉及的领域并不仅仅局限于那些,Spring还提供了多种技巧,借助它们可以实现更为高级的Bean装配功能。

环境与profile

在开发软件的时候,有一个很大的挑战就是将应用程序从一个环境迁移到另一个环境。在开发阶段中,某些环境相关做法可能并不适合迁移到生产环境中,甚至即便迁移过去也无法工作。数据库配置、加密算法以及与外部系统的集成是跨环境部署时会发生变化的几个典型例子。
有时候在不同的环境中某个Bean也会有所不同,我们必须要有一种方法使其在每种环境下都会选择最合适的配置。
其中一种方式就是在单独的配置类(或XML文件)中配置每个Bean,然后在构建阶段确定要将哪一个配置编译到可部署的应用中。

在JavaConfig中配置Profile Bean

面对不同环境有不同方法生成不同版本的Bean,Spring提供了一种策略,就是在运行时确定创建哪个版本的Bean。这样的结果就是同一个部署单元(可能会是WAR文件)能够适用于所有的环境,没有必要再重建应用。

Spring引入了bean profile的功能。要使用profile,先要将当前所有版本的Bean定义整理到一个或多个profile之中,在将应用部署到具体环境的时候,只要使得对应的profile处于激活状态(active)就可以了。

在Java配置中,可以使用@Profile注解指定某个Bean属于哪一个Profile。例如,在配置类中,嵌入式数据库的DataBSource可能会配置成如下所示:

3.1:dev profile

@Configuration
@Profile("dev")  //这个profile的ID为dev
public class DevelopmentProfileConfig {
    @Bean(destroyMethod = "shutdown")
    public DataSource dataSource(){
        return new EmbeddedDatabaseBuilder()
                .addScript("classpath:schema.sql")
                .addScript("classpath:test-data.sql").build();
    }
}

我们注意到,@Profile注解应用在了类级别上,它会告诉Spring这个配置类中的Bean只有在dev profile激活时才会创建。如果dev profile没有激活的话,那么带有@Bean注解的方法都会被忽略。

同时,我们还需要一个适用于生产环境的配置,如下所示:

3.2:prod profile

@Configuration
@Profile("prod")
public class ProductionProfileConfig {
    @Bean
    public DataSource dataSource(){
        JndiObjectFactoryBean jndiObjectFactoryBean = new JndiObjectFactoryBean();
        jndiObjectFactoryBean.setJndiName("jdbc/myDS");
        jndiObjectFactoryBean.setResourceRef(true);
        jndiObjectFactoryBean.setProxyInterface(javax.sql.DataSource.class);

        return (DataSource)jndiObjectFactoryBean;
    }
}

除了可以使用上面基于类使用的@Profile注解,从Spring3.2开始,也支持在方法级别上使用@Profile了,这样我们就可以将dev和prod写在一个JavaConfig中了。

3.3:@Profile注解基于激活的profile实现Bean的装配

@Configuration
public class DataSourceConfig {
    @Bean(destroyMethod = "shutdown")
    @Profile("dev")
    public DataSource dataSource(){
        return new EmbeddedDatabaseBuilder()
                .addScript("classpath:schema.sql")
                .addScript("classpath:test-data.sql").build();
    }

    @Bean
    @Profile("prod")
    public DataSource jndiSource(){
        JndiObjectFactoryBean jndiObjectFactoryBean = new JndiObjectFactoryBean();
        jndiObjectFactoryBean.setJndiName("jdbc/myDS");
        jndiObjectFactoryBean.setResourceRef(true);
        jndiObjectFactoryBean.setProxyInterface(javax.sql.DataSource.class);

        return (DataSource)jndiObjectFactoryBean;
    }
}

需要注意的是,尽管每个DataSource Bean都被声明在一个profile中并且只有当规定的profile激活时,相应的Bean才会被创建。但是可能有的Bean并没有指定profile,这样的Bean始终都会被创建,与激活哪个profile没有关系。

在XML中配置Profile Bean

在JavaConfig中我们可以使用@Profile注解,在XML中我们可以使用< beans>元素的profile属性。

例如,为了在XML中定义适用于开发阶段的嵌入式数据库DataSource Bean,我们可以创建如下的XML文件:

3.4:在XML文件中配置profile Bean

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

    <jdbc:embedded-database id="dataSource">
        <jdbc:script location="classpath:schema.sql" />
        <jdbc:script location="classpath:test-data.sql" />
    </jdbc:embedded-database>

</beans>

我们还可以像上面的JavaConfig一样,将多个profile Bean声明在一个XML文件中。

3.5:将多个profile Bean声明在一个XML文件中

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

    <beans profile="dev">
        <jdbc:embedded-database id="dataSource">
            <jdbc:script location="classpath:schema.sql" />
            <jdbc:script location="classpath:test-data.sql" />
        </jdbc:embedded-database>   
    </beans>

    <beans profile="prod">
        <jee:jndi-lookup id="datasource" jndi-name="jdbc/myDatabase"
                         resource-ref="true" proxy-interface="javax.sql.DataSource"/>
    </beans>

</beans>

激活Profile

Spring在确定哪个profile处于激活状态时,需要依赖两个独立的属性:spring.profiles.active和spring.profiles.default。
如果设置了spring.profiles.active属性的话,那么它的值就会用来确定哪个profile是激活的。但如果没有设置spring.profiles.active属性的话,那Spring将会查找spring.profiles.default的值。如果两个属性均没有设置,那就没有激活的profile,因此只会创建那些没有定义在profile中的Bean。
有多种方式来设置这两个属性:

  • 作为DispatcherServlet的初始化参数
  • 作为Web应用的上下文参数
  • 作为JNDI条目
  • 作为环境变量
  • 作为JVM的系统属性
  • 在集成测试类上,使用@ActiveProfiles注解设置

我们可以随意选择其中一种方式,作者比较推荐的是使用DispatcherServlet的参数将spring.profiles.default设置为开发环境的profile,然后将应用部署到相应环境的时候再使用系统属性/环境变量等设置active就可以了。

3.6:在web.xml中设置默认的profile

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
         version="3.1">

    <!--maven下自动生成的web.xml版本时2.3的, 所以有些配置结点idea会识别不出来,-->
    <!--因此我们重新添加一个3.1的, 以后使用的时候将这个文件直接复制进去就可以了.-->

    <display-name>Archetype Created Web Application</display-name>

    <!--welcome pages-->
    <welcome-file-list>
        <welcome-file>index.jsp</welcome-file>
    </welcome-file-list>

    <!--(1)为上下文设置默认的profile-->
    <context-param>
        <param-name>ContextConfigLocation</param-name>
        <param-value>dev</param-value>
    </context-param>

    <!--配置springmvc DispatcherServlet-->
    <servlet>
        <servlet-name>springMVC</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <!--配置dispatcher.xml作为mvc的配置文件-->
            <param-name>contextConfigLocation</param-name>
            <param-value>/WEB-INF/mvc-dispatcher-servlet.xml</param-value>
        </init-param>

        <!--(2)为Servlet设置默认的profile-->
        <init-param>
            <param-name>spring.profiles.default</param-name>
            <param-value>dev</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
        <async-supported>true</async-supported>
    </servlet>
    <servlet-mapping>
        <servlet-name>springMVC</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>
    <!--把applicationContext.xml加入到配置文件中, 这个XML文件中现在并没有什么, 暂时不添加也可以-->
    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/applicationContext.xml</param-value>
    </context-param>
    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>

</web-app>

这就是web.xml的全部配置,其中的(1)(2)处就是我们进行默认profile的设置。
按照这种方式设置的default,所有的开发人员都能从版本控制软件中获得应用程序源码,并使用开发环境的设置(如嵌入式数据库)运行代码,而不需要额外的配置。

在spring.profiles.active和spring.profiles.default中,profile都是复数形式,这意味着我们可以同时激活多个profile(用逗号分隔即可)。

当程序部署在相应环境之后,我们就要激活相应的profile。Spring提供了@ActiveProfiles注解,我们可以使用它来指定运行测试时要激活哪个profile。

3.7:使用@ActiveProfile激活profile

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {PersistenceTestConfig.class})
@ActiveProfiles("dev")
public class PersistenceTest {
    ...
}

条件化的Bean

假设我们希望一个或多个Bean只有在应用的类路径下包含特定的库时才创建。或者希望某个Bean只有当另外某个特定的Bean也声明了之后才会创建。我们还可能要求只有某个特定的环境变量设置了之后才会创建某个Bean。
Spring 4引入了@Conditional注解,它可以用到带有@Bean注解的方法上。如果给定的条件计算结果为true,就会创建这个Bean,否则这个Bean会被忽略。

假设现在有一个MagicBean的类,我们希望只有设置了magic环境属性的时候才进行实例化,如果没有这个属性,MagicBean将会被忽略。

3.8:条件化的配置Bean

@Configuration
public class MagicConfig {
    @Bean
    @Conditional(MagicExistsCondition.class)
    public MagicBean magicBean(){
        return new MagicBean();
    }
}

设置给@Conditional注解的类必须要实现Condition接口,关于Condition接口的代码如下:

3.9:Condition接口

public interface Condition{
    public boolean matches(ConditionContext ctxt, AnnotatedTypeMetadata metadata); 
}

可以看出来,这个接口实现起来很简单,只需提供matches()方法的实现即可,如果方法返回true,则创建带有@Conditional注解的Bean。否则不创建。

现在我们需要创建Condition的实现并根据环境中是否存在magic属性来作出决策。

3.10:MagicExistsCondition

public class MagicExistsCondition implements Condition {
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        Environment env = context.getEnvironment();
        return env.containsProperty("magic");
    }
}

在上面的程序中,matchs()方法通过给定的ConditionContext对象进而得到Environmen对象,并使用这个对象检查环境中是否存在名为magic的环境属性。

我们这个例子只是使用了ConditionContext得到的Environment,但Context实现的考量因素很多。ConditionContext是一个接口:

3.11:ConditionContext接口

public interface ConditionContext {
    BeanDefinitionRegistry getRegistry();
    ConfigurableListableBeanFactory getBeanFactory();
    Environment getEnvironment();
    ResourceLoader getResourceLoader();
    ClassLoader getClassLoader();

通过ConditionContext,我们可以做到以下几点:

  • 借助getRegistry()返回的BeanDefinitionRegistry检查Bean定义
  • 借助getBeanFactory()返回的ConfigurableListableBeanFactory检查Bean是否存在,探查Bean的属性
  • 借助getEnvironment()返回的Environment检查环境变量是够存在以及它的值是什么
  • 读取并探查getResourceLoader()返回的ResourceLoader所加载的资源
  • 借助getClassLoader()返回的ClassLoader加载并检查类是否存在

而AnnotatedTypeMetadata能让我们检查带有@Bean注解的方法上还有什么其他的注解,它也是一个接口:

3.12:AnnotatedTypeMetadata接口

public interface AnnotatedTypeMetadata {
    boolean isAnnotated(String annotationType);
    Map<String, Object> getAnnotationAttributes(String annotationType);
    Map<String, Object> getAnnotationAttributes(String annotationType, boolean classValuesAsString);
    MultiValueMap<String, Object> getAllAnnotationAttributes(String annotationType);
    MultiValueMap<String, Object> getAllAnnotationAttributes(String annotationType boolean classValuesAsString);

从Spring 4开始,@Profile注解进行了重构,使其基于@Conditional和Condition实现。其中,引用ProfileCondition作为Condition的实现。


处理自动装配的歧义性

我们在之前的自动装配中,仅有一个Bean匹配所需结果时,自动装配才有效。而多个Bean都能匹配时,Spring在装配的时候就出现了歧义,它不知道到底应该装配这两个都符合装配条件的Bean中的哪一个。
为了说明这种歧义性,我们来看一个例子。
在Eat类里面有使用了@Autowired注解标注了setDessert()方法:

3.13:自动装配的Eat类

@Component
public class Eat {
    private Dessert dessert;

    @Autowired
    public void setDessert(Dessert dessert){
        this.dessert = dessert;
    }
}

Dessert是一个接口,它有这样几个实现类:

@Component
public class Cake implements Dessert {
}
@Component
public class Cookies implements Dessert {
}
@Component
public class IceCream implements Dessert {
}

上面这三个类都实现了Dessert接口,并且都使用了@Component注解,在组件扫描的时候,都可以被装配到setDessert()方法里,那么怎样才能确定到底是哪一个被装配呢?

1)标示首选的Bean

在声明Bean的时候,通过将其中一个可选的Bean设置为首选Bean。当遇到歧义性的时候,Spring将会使用首选Bean。使用@Primary注解即可。

1.1 在自动装配中声明首选Bean

@Component
@Primary
public class IceCream implements Dessert {
}

1.2 在JavaConfig中声明首选Bean

@Bean
@Primary
public Dessert iceCream() {
    return new IceCream();
}

1.3 在XML中标示首选Bean

<bean id="iceCream"
      class="com.Dessert.IceCream"
        primary="true" />

如果有多个首选Bean,也会带来歧义性。
限定符是一种更强大的机制~

2)限定自动装配的Bean

Spring的限定符能够在所有可选的Bean上进行缩小范围的操作,最终能够达到只有一个Bean满足所规定的限制条件。使用@Qualifier注解即可,它与@Autowired和@Inject协同使用。

3.14:使用限定符标注

@Component
public class Eat {
    private Dessert dessert;

    @Autowired
    @Qualifier("iceCream")
    public void setDessert(Dessert dessert){
        this.dessert = dessert;
    }
}

通过@Qulifier()标注里面的”iceCream”就能匹配到IceCream Bean,可以将”iceCream”理解为是想要注入的Bean的ID。但是实质上, 这个”iceCream”限定符是用来匹配限定符为iceCream的Bean,因为其实每个Bean都有自己默认的限定符,这个限定符默认为该Bean的ID,因此@Qualifier(“iceCream”)就是将默认限定符为iceCream的Bean注入到setDessert()方法中。

但是这里又有一个问题,就是如果现在将IceCream这个类改名, 那么自动装配就会失败。这说明setDessert()方法上所要指定的限定符与要注入的Bean的名称是紧耦合的,这不符合Spring的理念,所以我们可以通过创建自定义的限定符来解决这个问题。

创建自定义的限定符

我们可以为Bean自定义限定符,而不是使用Bean默认的限定符。可以在Bean的声明前加上@Qualifier标注来自定义限定符,比如:

@Component
@Qualifier("cold")
public class IceCream implements Dessert {
}

那么,在JavaConfig中关于setDessert()方法的@Qualifier标注里面就可以使用我们自定义的限定符了。

3.14:创建自定义限定符标注

@Component
public class Eat {
    private Dessert dessert;

    @Autowired
    @Qualifier("cold")
    public void setDessert(Dessert dessert){
        this.dessert = dessert;
    }
}

这样,我们就可以随意更改IceCream Bean而不用担心更改会破坏自动装配。
自定义的限定符通常命名为Bean的特征,比如我们上面指定的cold就是IceCream的特征。

使用自定义的限定符注解

上面我们自定义了限定符,但是仍然存在一些问题,比如有多个Bean都具有相同的特征(cold)怎么办,还是会出现歧义性的问题,要对Bean的范围进行进一步的缩小。
前面我们自定义了限定符,现在可以自定义限定符注解。

在这里不再在IceCream中使用@Qualifier(“cold”),而是自定义一个@Cold注解。

3.15:自定义一个@Cold注解

@Target({ElementType.CONSTRUCTOR, ElementType.FIELD, 
        ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Qualifier
public @interface Cold { }

在IceCream类中添加@Cold注解:

@Component
@Cold
public class IceCream implements Dessert {
}

3.16:在Eat中使用@Cold注解

@Component
public class Eat {
    private Dessert dessert;

    @Autowired
    @Cold
    public void setDessert(Dessert dessert){
        this.dessert = dessert;
    }
}

像@Cold这样的注解,我们可以根据自己的需求自定义多个,并根据自己的需求搭配使用。也就是可以使用多个自定义限定符来限定一个Bean。


Bean的作用域

在默认情况下,Spring应用上下文中所有的Bean都是以单例的形式创建的。也就是说,一个Bean,无论被注入到其它Bean里多少次,每次所注入的都是同一个实例。

在大多数情况下,单例的Bean都是理想的方案。但是有时候你会发现你所使用的类是易变的,它们会保持一些状态,因此重用是不安全的。在这种情况下,将class声明为单例的Bean就不合理了,因为对象会被污染,稍后重用的时候会出现意想不到的问题。

考虑到这种情况的发生,Spring在创建Bean给出了多种可以选择的作用域:

  • 单例(Singleton): 在整个应用中, 只创建一个Bean的实例;
  • 原型(Prototype): 每次注入或者通过Spring应用上下文获取的时候, 都会创建一个新的Bean实例;
  • 会话(Session): 在Web应用中, 为每个会话创建一个Bean实例;
  • 请求(Request): 在Web应用中, 为每个请求创建一个Bean实例.

1)设置Bean的作用域

单例是默认的作用域,如果选择其它的作用域,要使用@Scope注解,它可以与Component或@Bean一起使用。

1.1 在组件扫描中使用@Scope

3.17:在Notepad Bean中设置作用域

@Component
@Scope(ConfigurableListableBeanFactory.SCOPE_PROTOTYPE)
public class Notepad {
}

其实也可以写成@Scope(“prototype”),但是采用上面那种写法不容易出错。所以尽量使用上面的写法。

1.2 在JavaConfig中使用@Scope

3.18:在NotepadConfig中设置作用域

@Configuration
public class NotepadConfig {
    @Bean
    @Scope(ConfigurableListableBeanFactory.SCOPE_PROTOTYPE)
    public Notepad notepad(){
        return new Notepad();
    }
}

1.3 在XML中使用< bean>元素的scope属性

3.19:在XML中设置作用域

<bean id="notapad" 
      class="com.myapp.Notepad"
      scope="prototype" />

不管使用哪种方式来声明原型作用域,每次注入或从Spring应用上下文中检索该Bean的时候,都会创建新的实例。

2)使用会话和请求作用域

在电子商务中有一个典型的例子就是购物车。想象一下,如果购物车是单例的,也就是说所有的用户都将共用一个购物车,这不太合适吧。但是如果将它设置为原型作用域,也就说,用户每浏览的一个网页都有相对应的购物车,用户将一件上衣添加到购物车,又转而去另一个页面添加一个裤子到购物车,等到付款时,难道还要分别去上衣和裤子的页面去买单吗?

显然是不合适的。正确的做法应该是每一个用户拥有一个购物车。
在这种情况下,我们就可以将购物车Bean设置为会话作用域。

3.20:会话作用域的购物车Bean

@Component
@Scope(value = WebApplicationContext.SCOPE_SESSION, 
        proxyMode = ScopedProxyMode.INTERFACES)
public class ShoppingCart {
}

在这里将value的值设置为WebApplicationContext.SCOPE_SESSION,这会告诉Spring为Web应用中的每个会话创建一个ShoppingCart Bean。这也就是说ShoppingCart Bean在整个应用中是多例的,而在每一个会话中是单例的。

在Scope中还有另一个属性proxyMode,它被设置为ScopedProxyMode.INTERFACES,这个属性解决了将会话或请求作用域的 Bean注入到单例Bean中所遇到的问题。
在描述proxyMode属性之前,先看一下proxyMode所解决问题的场景。

现在要将ShoppingCart Bean注入到单例StoreService Bean的Setter方法中,如下:

@Component
public class StoreService {
    private ShoppingCart shoppingCart;

    @Autowired
    public void setShoppingCart(ShoppingCart shoppingCart){
        this.shoppingCart = shoppingCart;
    }
}

因为StoreService Bean是单例的, 会在Spring应用上下文加载的时候创建,而当它创建的时候,Spring会试图将ShoppingCart Bean注入到StoreService Bean中,但是ShoppingCart Bean是会话作用域的,此时并不存在,直到某个用户进入系统,创建了会话之后才会出现ShoppingCart实例。

另外,系统中会有多个ShoppingCart实例,每一个用户一个。我们并不想让Spring注入某个固定的ShoppingCart Bean到StoreService Bean中. 我们希望的是当StoreService处理购物车功能时,它所使用的ShoppingCart Bean恰好是当前会话对应的那一个。

那么现在需要解决的有两个问题:

  • 在StoreService Bean创建时,ShoppingCart Bean不存在,无法满足依赖
  • ShoppingCart Bean是多例的,而StoreService是单例的

Spring使用了代理模式:
这里写图片描述

现在详细看一下使用代理模式是如何解决上面两个问题的:

解决问题一:
在StoreService Bean创建而ShoppingCart Bean还不存在的时候,Spring会将一个ShoppingCart Bean的代理注入到StoreService Bean中,这个代理会暴露与ShoppingCart Bean相同的方法,所以StoreService Bean就会认为这个代理就是一个ShoppingCart Bean,从而解决了StoreService Bean创建之初而ShoppingCart Bean还不存在无法满足依赖的问题。

解决问题二:
当StoreService Bean真正需要调用ShoppingCart Bean的方法的时候,代理会对其进行解析并将调用委托给相应会话作用域中真正的ShoppingCart Bean。

现在,我们带着对着两个问题解决的办法来讨论一下proxyMode属性。如配置所示,proxyMode属性被设置成了ScopedProxyMode.INTERFACES,这表明这个代理要实现ShoppingCart接口,并将调用委托给真正的实现Bean。

在上面的代码中,ShoppingCart是一个接口,代理需要实现的就是这个接口,这是最理想的代理模式。但是如果ShoppingCart是一个类的话,Spring就没有办法创建基于接口的代理了。此时,它必须使用CGLib来生成基于类的代理。所以,如果Bean的类型是具体类的话,我们必须要将proxyMode属性设置为ScopedProxyMode.TARGET.CLASS,以此来表示要以生成目标类扩展的方式来创建代理。

3)在XML中声明作用域代理

如果需要使用XML来声明会话或请求作用域的Bean,就需要使用< bean>元素的scope属性来设置Bean的作用域了。但是要怎样指定代理模式呢?

我们使用Spring aop命名空间的一个新元素来指定代理模式:

<bean id="cart" 
      class="com.myapp.ShoppingCart"
      scope="session">
   <aop:scoped-proxy proxy-target-class="false" />
</bean>

< aop:scoped-proxy>是与@Scope注解的proxyMode属性功能相同的Spring XML配置元素。它会告诉Spring为Bean创建一个作用域代理。默认情况下,它会使用CGLib创建目标类的代理。但是我们也可以将proxy-target-class属性设置为false,进而要求它生成基于接口的代理。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值