Spring(三)异步调用、定时器、缓存

本系列文章:
  Spring(一)控制反转、两种IOC容器、自动装配、作用域
  Spring(二)延迟加载、生命周期、面向切面、事务
  Spring(三)异步调用、定时器、缓存
  Spring(四)Spring MVC
  Spring(五)Spring Boot
  Spring(六)Spring Cloud----Eureka、Ribbon、Hystrix
  Spring(七)Spring Cloud----Feign、Zuul和Apollo

一、异步调用(@EnableAsync/@Async)

  可以通过@EnableAsync和@Async来实现。使用步骤:

  1. 需要异步执行的方法上面使用@Async注解标注,若bean中所有的方法都需要异步执行,可以直接将@Async加载类上。
  2. 将@EnableAsync添加在Spring配置类上,此时@Async注解才会起效。

1.1 是否需要方法返回值

  方法返回值不是Future类型的,被执行时,会立即返回,并且无法获取方法返回值。示例:

@Async
public void log(String msg) throws InterruptedException {
    System.out.println("开始记录日志," + System.currentTimeMillis());
    //模拟耗时2秒
    TimeUnit.SECONDS.sleep(2);
    System.out.println("日志记录完毕," + System.currentTimeMillis());
}

  若需取异步执行结果,方法返回值必须为Future类型,使用Spring提供的静态方法AsyncResult.forValue创建返回值。示例:

@Async
@Component
public class GoodsService {
    //模拟获取商品基本信息,内部耗时500毫秒
    public Future<String> getGoodsInfo(long goodsId) throws InterruptedException{
        TimeUnit.MILLISECONDS.sleep(500);
        return AsyncResult.forValue(String.format("商品%s基本信息!", goodsId));
    }
    //模拟获取商品描述信息,内部耗时500毫秒
    public Future<String> getGoodsDesc(long goodsId) throws InterruptedException{
        TimeUnit.MILLISECONDS.sleep(500);
        return AsyncResult.forValue(String.format("商品%s描述信息!", goodsId));
    }
    //模拟获取商品评论信息列表,内部耗时500毫秒
    public Future<List<String>> getGoodsComments(long goodsId) throws InterruptedException {
         TimeUnit.MILLISECONDS.sleep(500);
         List<String> comments = Arrays.asList("评论1", "评论2");
         return AsyncResult.forValue(comments);
     }
}

1.2 自定义线程池和默认线程池

  默认情况下, @EnableAsync使用内置的线程池来异步调用方法,不过我们也可以自定义异步执行任务的线程池。

1.2.1 自定义线程池(@Configuration + @Bean + @Async(“线程池名”))

  示例:

@Configuration
@EnableAsync
public class AsyncConfig {
 
    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4); // 核心线程数
        executor.setMaxPoolSize(8); // 最大线程数
        executor.setQueueCapacity(100); // 队列大小
        executor.setKeepAliveSeconds(60); // 线程空闲时的存活时间
        executor.setThreadNamePrefix("MyThreadPoolTaskExecutor-"); // 线程名前缀
        executor.setRejectedExecutionHandler((ThreadPoolExecutor.AbortPolicy) -> {
            // 拒绝策略,例如记录日志或者抛出异常
        });
        executor.initialize(); // 初始化线程池
        return executor;
    }
}

  接下来就可以使用@Async(“taskExecutor”)注解来指定使用这个自定义线程池来异步执行方法。

1.2.2 内置线程池(SimpleAsyncTaskExecutor,不复用线程,不建议使用)

  使用@Async()注解,不指定线程池对应的额Bean时,就代表使用Spring内置的线程池SimpleAsyncTaskExecutor。
  SimpleAsyncTaskExecutor,不是真的线程池,这个对象不重用线程每次调用都会创建一个新的线程没有最大线程数设置。并发大的时候会产生严重的性能问题。

1.3 异常处理

  异步方法若发生了异常,如何获取异常信息呢?此时可以通过自定义异常处理来解决。异常处理分2种情况:

  1. 当返回值是Future的时候,方法内部有异常的时候,异常会向外抛出,可以对Future.get采用try…catch来捕获异常。
  2. 当返回值不是Future的时候,可以自定义一个Bean,实现AsyncConfigurer接口中的
    getAsyncUncaughtExceptionHandler方法,返回自定义的异常处理器。
  • 1、返回值为Future类型
try {
    Future<String> future = logService.mockException();
    System.out.println(future.get());
} catch (ExecutionException e) {
    System.out.println("捕获 ExecutionException 异常");
    //通过e.getCause获取实际的异常信息
    e.getCause().printStackTrace();
} catch (InterruptedException e) {
    e.printStackTrace();
}
  • 2、无返回值异常处理
      当返回值不是Future的时候,可以自定义一个bean,实现 AsyncConfigurer接口中的getAsyncUncaughtExceptionHandler方法 ,返回自定义的异常处理器,当目标方法执行过程中抛出异常的时候,此时会自动回调AsyncUncaughtExceptionHandler.handleUncaughtException 这个方法,可以在这个方法中处理异常。
@Configuration
public class AsyncConfig implements AsyncConfigurer {
    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
    //重写代码
        return new AsyncUncaughtExceptionHandler() {
            @Override
            public void handleUncaughtException(Throwable ex, Method method, Object... objects) {
                String name = ex.getClass().getName();
                //可替代异常处理
                if (name.contains("xxxException")) {
                //xxx自定义异常处理
                  xxxExceptione = (xxxException) ex;
                }
            }
        };
    }
}

1.4 线程池隔离(不同的异步任务使用不同的自定义线程池)

  一个系统中可能有很多业务,比如充值服务、提现服务或者其他服务,这些服务中都有一些方法需要异步执行,默认情况下他们会使用同一个线程池去执行,如果有一个业务量比较大,占用了线程池中的大量线程,此时会导致其他业务的方法无法执行,那么我们可以采用线程隔离的方式,对不同的业务使用不同的线程池,相互隔离,互不影响。
  @Async注解有个value参数,用来指定线程池的Bean名称,方法运行的时候,就会采用指定的线程池来执行目标方法。
  使用步骤:

  1. 在Spring容器中,自定义线程池相关的bean。
  2. @Async(“线程池bean名称”)

  示例:

@Component
public class RechargeService {
    //模拟异步充值
    @Async(MainConfig5.RECHARGE_EXECUTORS_BEAN_NAME)
    public void recharge() {
        System.out.println(Thread.currentThread() + "模拟异步充值");
    }
}

@EnableAsync //启用方法异步调用
@ComponentScan
public class MainConfig5 {
    //异步充值业务线程池bean名称
    public static final String RECHARGE_EXECUTORS_BEAN_NAME = "rechargeExecutors";
  
    @Bean(RECHARGE_EXECUTORS_BEAN_NAME)
    public Executor rechargeExecutors() {
         ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
         executor.setCorePoolSize(10);
         executor.setMaxPoolSize(100);
         //线程名称前缀
         executor.setThreadNamePrefix("recharge-thread-");
         return executor;
    }
}

二、定时器

  @Scheduled、@EnableScheduling这2个注解,可以用来快速开发定时器。

2.1 定时器的基本使用(@Scheduled + @EnableScheduling)

  用法:

  1. 需要定时执行的方法上加上@Scheduled注解,这个注解中可以指定定时执行的规则。
  2. Spring容器中使用@EnableScheduling开启定时任务的执行,此时spring容器才可以识别@Scheduled标注的方法,然后自动定时执行。

  示例:

@Component
public class PushJob {
    //推送方法,每秒执行一次
    @Scheduled(fixedRate = 1000)
    public void push() throws InterruptedException {
        System.out.println("模拟推送消息," + System.currentTimeMillis());
    }
}

@ComponentScan
@EnableScheduling //在spring容器中启用定时任务的执行
public class MainConfig1 {
    @Bean
    public ScheduledExecutorService scheduledExecutorService() {
        return Executors.newScheduledThreadPool(20);
    }
}

2.2 定时规则(cron表达式/秒 分 小时 日 月 周 年 年可省略)

  @Scheduled可以用来配置定时器的执行规则,@Scheduled中主要有8个参数:

@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Repeatable(Schedules.class)
public @interface Scheduled {
    String cron() default "";
    String zone() default "";
    long fixedDelay() default -1;
    String fixedDelayString() default "";
    long fixedRate() default -1;
    String fixedRateString() default "";
    long initialDelay() default -1;
    String initialDelayString() default "";
}
  • 1、cron
      该参数接收一个 cron表达式 , cron表达式 是一个字符串,字符串以5或6个空格隔开,分开共6或7个域,每一个域代表一个含义。语法:
#[年]不是必须的域,可以省略[年],则一共6个域
[] [] [小时] [] [] [] []
序号说明必填允许填写的值允许的通配符
10-59, - * /
20-59, - * /
30-23, - * /
41-31, - * ? / L W
51-12 / JAN-DEC, - * /
61-7 or SUN-SAT, - * ? / L #
71970-2099, - * /

  常见的通配符说明:

  * 表示所有值。 例如:在分的字段上设置*,表示每一分钟都会触发。
  ?表示不指定值。使用的场景为不需要关心当前设置这个字段的值。例如:要在每月的10号触发一个操作,但不关心是周几,所以需要周位置的那个字段设置为”?” 具体设置为 0 0 0 10 * ?
  - 表示区间。例如 在小时上设置 “10-12”,表示 10,11,12点都会触发。
  , 表示指定多个值,例如在周字段上设置 “MON,WED,FRI” 表示周一,周三和周五触发
/ 用于递增触发。如在秒上面设置”5/15” 表示从5秒开始,每增15秒触发(5,20,35,50)。 在日字段上设置’1/3’所示每月1号开始,每隔三天触发一次。

  cron表达式示例:

每隔5秒执行一次:*/5 * * * * ?
每隔1分钟执行一次:0 */1 * * * ?
每天23点执行一次:0 0 23 * * ?
每天凌晨1点执行一次:0 0 1 * * ?
每月1号凌晨1点执行一次:0 0 1 1 * ?
每月最后一天23点执行一次:0 0 23 L * ?
每周星期六凌晨1点实行一次:0 0 1 ? * L
在26分、29分、33分执行一次:0 26,29,33 * * * ?
每天的0点、13点、18点、21点都执行一次:0 0 0,13,18,21 * * ?

  在Java代码里的示例:

@Scheduled(cron="${time.cron}")
void testPlaceholder1() {
    System.out.println("Execute at " + System.currentTimeMillis());
}
  • 2、zone
      时区,接收一个 java.util.TimeZone#ID 。 cron表达式会基于该时区解析。默认是一个空字符串,即取服务器所在地的时区。比如我们一般使用的时区Asia/Shanghai 。该字段我们一般留空。
  • 3、fixedDelay
      上一次执行完毕时间点之后多长时间再执行。
@Scheduled(fixedDelay = 5000) //上一次执行完毕时间点之后5秒再执行
  • 4、fixedDelayString
      与fixedDelay意思相同,只是使用字符串的形式。唯一不同的是支持占位符。
@Scheduled(fixedDelayString = "${time.fixedDelay}")
void testFixedDelayString() {
    System.out.println("Execute at " + System.currentTimeMillis());
}
  • 5、fixedRate
      上一次开始执行时间点之后多长时间再执行。
@Scheduled(fixedRate = 5000) //上一次开始执行时间点之后5秒再执行
  • 6、fixedRateString
      与fixedRate意思相同,只是使用字符串的形式,唯一不同的是支持占位符。
  • 7、initialDelay
      第一次延迟多长时间后再执行。
//第一次延迟1秒后执行,之后按fixedRate的规则每5秒执行一次
@Scheduled(initialDelay=1000, fixedRate=5000) 
  • 8、initialDelayString
       和initialDelay意思相同,只是使用字符串的形式,唯一不同的是支持占位符。
  • @Schedules注解
      当一个方法上面需要同时指定多个定时规则的时候,可以通过这个@Schedules来配置。
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Schedules {
     Scheduled[] value();
}

  示例:

//2个定时器,500毫秒的,1000毫秒的
@Schedules({@Scheduled(fixedRate = 500), @Scheduled(fixedRate = 1000)})
public void push3() {
}

2.3 为定时器指定线程池(建议使用@Async指定自定义线程池,因为默认定时任务使用的线程池核心线程数是1)

  在SpringBoot的自动化配置中,会给我们自动初始化一个 核心线程为 1,无界阻塞队列的ScheduledThreadPoolExecutor线程池,所以所有的定时任务都是同步阻塞串行运行的。即:

    new ScheduledThreadPoolExecutor(1)
  • 方式1
      和执行异步任务时可以使用自定义线程池类似,执行定时任务时也可以使用自定义线程池。示例:
@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean(name="myExecutor")
    public ThreadPoolTaskExecutor mqInvokeAysncExecutor() {
 
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        // 线程池中的线程名前缀
        executor.setThreadNamePrefix("log-message-");
        // 设置线程池关闭的时候需要等待所有任务都完成 才能销毁其他的Bean
        executor.setWaitForTasksToCompleteOnShutdown(true);
        // 设置线程池中任务的等待时间,如果超过这个时候还没有销毁就强制销毁,以确保应用最后能够被关闭,而不是阻塞住
        executor.setAwaitTerminationSeconds(120);
		//核心线程数
        executor.setCorePoolSize(4);
		//最大线程数
        executor.setMaxPoolSize(16);
        // 任务阻塞队列的大小
        executor.setQueueCapacity(20000);
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

  然后在需要使用到该线程池的类上,注解@Async后面加上线程池的名称,便可使用该线程池:

@Configuration
@EnableAsync
@Async("myExecutor")
public class TestTwo {
    @Scheduled(cron="0/2 * * * * ?")
    public void funOne(){
        System.out.println("定时任务2");
        System.out.println(Thread.currentThread().getName()+" 2222222222");
    }
}
  • 方式2
      实现SchedulingConfigurer接口。示例:
@Configuration
public class ScheduleConfig implements SchedulingConfigurer {
    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        taskRegistrar.setScheduler(Executors.newScheduledThreadPool(10));
    }
}

三、缓存

  Spring中提供了一整套的缓存解决方案,使用起来特别的容易,主要通过注解的方式使用缓存,常用的有5个注解。在缓存中会用到spel表达式。
  Spring中的缓存主要是利用spring中aop实现的,通过Aop对需要使用缓存的bean创建代理对象,通过代理对象拦截目标方法的执行,实现缓存功能。

3.1 @EnableCaching(启用缓存功能)

  启用缓存功能。配置类中需要加上这个注解,有了这个注解之后,Spring才知道你需要使用缓存的功能,其他和缓存相关的注解才会有效,Spring中主要是通过aop实现的,通过aop来拦截需要使用缓存的方法,实现缓存的功能。

3.2 @Cacheable(该类/方法使用缓存)

  @Cacheable可以标记在一个方法上,也可以标记在一个类上。当标记在一个方法上时表示该方法是支持缓存的,当标记在一个类上时则表示该类所有的方法都是支持缓存的。对于一个支持缓存的方法,Spring会在其被调用后将其返回值缓存起来,以保证下次利用同样的参数来执行该方法时可以直接从缓存中获取结果,而不需要再次执行该方法。
  Spring在缓存方法的返回值时是以键值对进行缓存的,值就是方法的返回结果;至于键的话,Spring支持两种策略,默认策略和自定义策略。
  @Cacheable可以指定三个属性,value、key和condition

  • value属性:指定Cache名称
      value和cacheNames属性作用一样,必须指定其中一个,表示当前方法的返回值是会被缓存在哪个Cache上的,对应Cache的名称。其可以是一个Cache也可以是多个Cache,当需要指定多个Cache时其是一个数组。
      可以将Cache想象为一个HashMap,系统中可以有很多个Cache,每个Cache有一个名字,你需要将方法的返回值放在哪个缓存中,需要通过缓存的名称来指定。
      示例:
@Component
public class ArticleService {
    @Cacheable(cacheNames = {"cache1"})
    public List<String> list() {
        System.out.println("获取文章列表!");
        return Arrays.asList("spring", "mysql", "java高并发", "maven");
    }
}
  • key属性:自定义key
      key属性用来指定Spring缓存方法的返回结果时对应的key的,上面说了你可以将Cache理解为一个hashMap,缓存以key->value的形式存储在hashmap中,value就是需要缓存值(即方法的返回值)。
      key属性支持SpEL表达式;当我们没有指定该属性时,Spring将使用默认策略生成key(SimpleKeyGenerator),默认会方法参数创建key。
      自定义策略是指我们可以通过SpEL表达式来指定我们的key,这里的SpEL表达式可以使用方法参数及它们对应的属性,使用方法参数时我们可以直接使用“#参数名”或者“#p参数index”。
      自定义策略示例:
@Cacheable(cacheNames = {"cache1"}, key = "#root.target.class.name+'-'+#page+'-'+#pageSize")
public String getPage(int page, int pageSize) {
    String msg = String.format("page-%s-pageSize-%s", page, pageSize);
    System.out.println("从db中获取数据:" + msg);
    return msg;
}
  • condition属性:控制缓存的使用条件
      有时候,可能我们希望查询不走缓存,同时返回的结果也不要被缓存,那么就可以通过condition属性来实现,condition属性默认为空,表示将缓存所有的调用情形
      其值是通过spel表达式来指定的,当为true时表示先尝试从缓存中获取;若缓存中不存在,则执行方法,并将方法返回值丢到缓存中;当为false的时候,不走缓存、直接执行方法、并且返回结果也不会丢到缓存中。
      示例:
/**
* 通过文章id获取文章
*
* @param id  文章id
* @param cache 是否尝试从缓存中获取
* @return
*/
@Cacheable(cacheNames = "cache1", key = "'getById'+#id", condition = "#cache")
public String getById(Long id, boolean cache) {
    System.out.println("获取数据!");
    return "spring缓存:" + UUID.randomUUID().toString();
}
  • unless属性:控制是否需要将结果丢到缓存中
      用于否决方法缓存的SpEL表达式。 与condition不同,此表达式是在调用方法后计算的,因此可以引用结果。 默认值为“”,这意味着缓存永远不会被否决。
      前提是condition为空或者为true的情况下,unless才有效,condition为false的时候,unless无效,unless为true,方法返回结果不会丢到缓存中;unless为false,方法返回结果会丢到缓存中。写法和key属性类似。
      当返回结果为null的时候,不要将结果进行缓存。示例:
Map<Long, String> articleMap = new HashMap<>();
/**
* 获取文章,先从缓存中获取,如果获取的结果为空,不要将结果放在缓存中
*
* @param id
* @return
*/
@Cacheable(cacheNames = "cache1", key = "'findById'+#id", unless = "#result==null")
public String findById(Long id) {
    this.articleMap.put(1L, "spring系列");
    System.out.println("----获取文章:" + id);
    return articleMap.get(id);
}
  • condition和unless对比
      缓存的使用过程中有2个点:
  1. 查询缓存中是否有数据。
  2. 如果缓存中没有数据,则去执行目标方法,然后将方法结果丢到缓存中。

  spring中通过condition和unless对这2点进行干预。
  condition作用域上面2个过程,当为true的时候,会尝试从缓存中获取数据,如果没有,会执行方法,然后将方法返回值丢到缓存中;如果为false,则直接调用目标方法,并且结果不会放在缓存中。
  而unless在condition为true的情况下才有效,用来判断上面第2点中,是否不要将结果丢到缓存中,如果为true,则结果不会丢到缓存中,如果为false,则结果会丢到缓存中,并且unless中可以使用spel表达式通过#result来获取方法返回值。

3.3 @CachePut(将结果放入缓存)

  @CachePut也可以标注在类或者方法上,被标注的方法每次都会被调用,然后方法执行完毕之后,会将方法结果丢到缓存中;当标注在类上,相当于在类的所有方法上标注了@CachePut
  参数:

  value和cacheNames:用来指定缓存名称,可以指定多个。
  key:缓存的key,spel表达式,写法参考@Cacheable中的key。
  condition:spel表达式,写法和@Cacheable中的condition一样,当为空或者计算结果为true的时候,方法的返回值才会丢到缓存中;否则结果不会丢到缓存中。
  unless:当condition为空或者计算结果为true的时候,unless才会起效;true:结果不会被丢到缓存,false:结果会被丢到缓存。

  有3种情况,结果不会丢到缓存:

  1. 当方法向外抛出的时候。
  2. condition的计算结果为false的时候。
  3. unless的计算结果为true的时候。

3.4 @CacheEvict(清理缓存)

  缓存清理。@CacheEvict也可以标注在类或者方法上,被标注在方法上,则目标方法被调用的时候,会清除指定的缓存;当标注在类上,相当于在类的所有方法上标注了@CacheEvict。

public @interface CacheEvict {
    /**
    * cache的名称,和cacheNames效果一样
    */
    String[] value() default {};
    /**
    * cache的名称,和cacheNames效果一样
    */
    String[] cacheNames() default {};
    /**
    * 缓存的key,写法参考上面@Cacheable注解的key
    */
    String key() default "";
    /**
    * @CacheEvict 注解生效的条件,值为spel表达式,写法参考上面 @Cacheable注解中的condition
    */
    String condition() default "";
    /**
    * 是否清理 cacheNames 指定的缓存中的所有缓存信息,默认是false
    * 可以将一个cache想象为一个HashMap,当 allEntries 为true的时候,相当于HashMap.clear()
    * 当 allEntries 为false的时候,只会干掉key对应的数据,相当于HashMap.remove(key)
    */
    boolean allEntries() default false;
    /**
    * 何事执行清除操作(方法执行前 or 方法执行成功之后)
    * true:@CacheEvict 标注的方法执行之前,执行清除操作
    * false:@CacheEvict 标注的方法执行成功之后,执行清除操作,当方法弹出异常的时候,不会执行清除操作
    */
    boolean beforeInvocation() default false;
}

  condition属性:@CacheEvict 注解生效的条件,值为spel表达式。

  • 会清除哪些缓存
      默认情况下会清除cacheNames指定的缓存中key参数指定的缓存信息。但是当 allEntries 为true的时候,会清除 cacheNames 指定的缓存中的所有缓存信息。
  • 具体什么时候清除缓存
      这个是通过beforeInvocation参数控制的,这个参数默认是false,默认会在目标方法成功执行之后执行清除操作,若方法向外抛出了异常,不会执行清理操作。如果 beforeInvocation为true,则方法被执行之前就会执行缓存清理操作,方法执行之后不会再执行了。
      清理cache1中key=findById+参数id 的缓存信息,示例:
@CacheEvict(cacheNames = "cache1", key = "'findById'+#id") //@1
public void delete(Long id) {
    System.out.println("删除文章:" + id);
    this.articleMap.remove(id);
}

3.5 @Caching

  缓存注解组。在类上或者同一个方法上同时使用@Cacheable、@CachePut和@CacheEvic这几个注解中的多个的时候,此时可以使用@Caching这个注解来实现。

3.6 @CacheConfig(公共缓存配置)

  这个注解标注在类上,可以将其他几个缓存注解(@Cacheable、@CachePut和@CacheEvic)的公共参数给提取出来放在@CacheConfig中。
  @Cacheable、@CachePut和@CacheEvic)这些缓存注解有很多公共的属性,比如:cacheNames、keyGenerator、cacheManager、cacheResolver,若这些属性值都是一样的,可以将其提取出来,放在@CacheConfig中。不过这些注解(@Cacheable、@CachePut和@CacheEvic)中也可以指定属性的值对@CacheConfig中的属性值进行覆盖。

@CacheConfig(cacheNames = "cache1")
public class ArticleService {
    @Cacheable(key = "'findById'+#id")
    public String findById(Long id) {
        this.articleMap.put(1L, "spring系列");
        System.out.println("----获取文章:" + id);
        return articleMap.get(id);
   }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值