Java 实现分布式定时任务


前言

最近有一个需求:需要实现分布式定时任务。而市面上的定时任务大多数都是基于@Scheduled注解进行实现。不符合需求。所以根据需求整体思路如下:

  1. 要求接口传入cron表达式,根据表达式进行解析出未来近几次次执行时间,然后将其存入数据库表(taskSchedule)中。
  2. 启动一个线程一直去取表中的数据进行任务处理。
  3. 由于是分布式需要实例之间保存心跳。并且要进行抢占式取任务。需要用到redis锁。

一、技术点

1、解析corn表达式
文章链接:
https://blog.csdn.net/qq_43548590/article/details/127424171?spm=1001.2014.3001.5502

https://blog.csdn.net/qq_43548590/article/details/127424630?spm=1001.2014.3001.5502

2、Java集成Redisson分布式锁
文章链接:
https://blog.csdn.net/qq_43548590/article/details/127420314?spm=1001.2014.3001.5502

二、代码实践

1、引入库

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.10</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
        <dependency>
            <groupId>org.quartz-scheduler</groupId>
            <artifactId>quartz</artifactId>
            <version>2.3.2</version>
        </dependency>
        <dependency>
            <groupId>com.cronutils</groupId>
            <artifactId>cron-utils</artifactId>
            <version>9.1.5</version>
        </dependency>
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.16.8</version>
        </dependency>
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.8</version>
        </dependency>
    </dependencies>

2、创建启动线程入口

创建StarterRunner 继承CommandLineRunner 接口。
目的:容器启动之后,加载实现类的逻辑资源,已达到完成资源初始化的任务

这里初始化了两个线程TaskManager为拉取任务,TimeManager为解析cron线程

import au.com.koalaclass.timer.state.InstanceState;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;

/**
 * 启动线程入口
 */
@Component
@Slf4j
public class StarterRunner implements CommandLineRunner {
    @Resource
    InstanceState instanceState;
    @Resource
    TimeManager timeManager;
    @Resource
    TaskManager taskManager;
    
    Thread timeManagerThread;
    Thread taskManagerThread;

    /**
     * 启动线程
     */
    private void startThreads() {
        timeManagerThread = new Thread(timeManager);
        timeManagerThread.start();
        taskManagerThread = new Thread(taskManager);
        taskManagerThread.start();
    }

    @Override
    public void run(String... args) throws Exception {
        log.info("启动了, uuid=" + instanceState.getUuid());
        startThreads();
    }
}

3、表结构

这里使用的是Jpa ,BaseEntity为公司内部模块(id,CreateTime,UpdateTime)此次自行更改

1.task_history 任务记录表,用于记录已发送的任务

import au.com.koalaclass.framework.entity.BaseEntity;
import lombok.Data;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;

@Data
@Entity
@Table(name = "task_history")
public class TaskHistoryEntity extends BaseEntity {
    private long scheduleTime;
    private String CallUrl;
    @Column(length = 4000)
    private String callParams;
    private String taskUuid;
    private String triggerUuid;
    private String taskName;
    private long dispatchTime;
    private boolean executeStatus;
    private String executeResult;
    private long executeTime;
}

2.task_schedule 根据cron解析后的表

import au.com.koalaclass.framework.entity.BaseEntity;
import lombok.Data;
import lombok.experimental.Accessors;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;

@Data
@Entity
@Table(name = "task_schedule")
@Accessors(chain = true)
public class TaskScheduleEntity extends BaseEntity {

    /**
     * 计划时间
     */
    private long scheduleTime;

    /**
     * 回调地址
     */
    private String CallUrl;

    /**
     * 回调参数
     */
    @Column(length = 4000)
    private String callParams;

    /**
     * 任务uuid
     */
    private String taskUuid;

    /**
     * 任务名称
     */
    private String taskName;

    /**
     *  触发器UUID
     */
    private String triggerUuid;

    /**
     * 实例UUid
     */
    private String instanceUuid;

    /**
     * 发送时间
     */
    private long dispatchTime;

}

3.trigger_expression 任务表

import au.com.koalaclass.framework.entity.BaseEntity;
import lombok.Data;
import lombok.experimental.Accessors;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;

@Data
@Entity
@Table(name = "trigger_expression")
@Accessors(chain = true)
public class TriggerExpressionEntity extends BaseEntity {
    /**
     * cron表达式
     */
    private String expression;

    /**
     * 约束开始时间
     */
    private long fromTime;

    /**
     * 约束结束时间
     */
    private long endTime;

    /**
     * 约束最大产生的计划时间数
     */
    private long maxTimes;

    /**
     * 以产生的计划时间数
     */
    private long scheduleTimes;

    /**
     * 上一次生成task的最后一位时间
     */
    private long lastTimeGenerated;

    /**
     * 任务地址
     */
    private String callUrl;

    /**
     * 任务参数
     */
    @Column(length = 4000)
    private String callParams;

    /**
     * 任务名称
     */
    private String name;

    /**
     * uuid
     */
    private String uuid;

    /**
     * 状态
     * 0 开启生成
     * 1 关闭
     */

    private int status;

    /**
     * 开启
     */
    public static int OPEN=0;

    /**
     * 关闭
     */
    public static int CLOSE=1;

}

4、任务解析

TimeManager
使用@Scheduled进行定时处理每一分钟从TriggerExpression表中获取一次任务。根据最后一次获取任务的时间进行重新解析cron。

package au.com.koalaclass.timer.thread;

import au.com.koalaclass.timer.service.RedissonService;
import au.com.koalaclass.timer.service.TriggerExpressionService;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;

@Component
@Data
@Slf4j
public class TimeManager implements Runnable {
    private final String LOCK = "schedule_generator";
    @Resource
    private TriggerExpressionService service;
    @Resource
    private RedissonService redissonService;

    private boolean stopFlag = false;

    /**
     * 启动
     */
    @Override
    @Scheduled(cron = "0 0/1 * * * ?") //每十分钟执行一次
    public void run() {
        if (!redissonService.tryLock(LOCK)) {
            log.warn("拉起任务,获取锁失败");
            try {
                Thread.sleep(10*1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        try {
            log.info("扫描trigger expression 生成task");
            service.generateTaskSchedule();
        }catch (Exception e) {
            log.error("生成任务出错,原因:"+e.getMessage());
        } finally {
            redissonService.unlock(LOCK);
        }
    }
}

Service层
此处每次生成未来要执行的30条日期

package au.com.koalaclass.timer.service.impl;

import au.com.koalaclass.timer.dao.TriggerExpressionDao;
import au.com.koalaclass.timer.entity.TaskScheduleEntity;
import au.com.koalaclass.timer.entity.TriggerExpressionEntity;
import au.com.koalaclass.timer.form.CreateExpressionForm;
import au.com.koalaclass.timer.form.CreateSingleExpressionForm;
import au.com.koalaclass.timer.service.TaskScheduleService;
import au.com.koalaclass.timer.service.TriggerExpressionService;
import au.com.koalaclass.timer.utils.CronUtil;
import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.text.ParseException;
import java.util.Date;
import java.util.List;
import java.util.UUID;

@Slf4j
@Service
public class TriggerExpressionServiceImpl implements TriggerExpressionService {

    @Resource
    private TriggerExpressionDao dao;

    @Resource
    private TaskScheduleService taskScheduleService;

    @Override
    public void createExpression(CreateExpressionForm form) {
        TriggerExpressionEntity expression = formToDto(form);
        dao.save(expression);
        this.generateTaskSchedule();
    }

    @Override
    public void generateTaskSchedule() {
        //查询最后一次转换时间小于当前时间+2分钟
        long minutes=1000*60*2;
        long iTime=System.currentTimeMillis()+minutes;
        log.info("计算后的时间"+iTime);
        List<TriggerExpressionEntity> list = dao.findAllByLastTimeGeneratedLessThanAndStatus(iTime,TriggerExpressionEntity.OPEN);
        log.info("查询Trigger结果:"+list.toString());
        for (TriggerExpressionEntity item : list) {
            //生成次数
            long num=30;
            if(item.getMaxTimes()!=0){
                num=item.getMaxTimes();
            }
            //判断是否可以继续生成
            if(item.getStatus()==TriggerExpressionEntity.OPEN){
                //解析30次
                List<Long> timeList = null;
                try {
                    timeList = CronUtil.nextTimes(item.getExpression(),item.getLastTimeGenerated()==0?System.currentTimeMillis():item.getLastTimeGenerated(),(int) num);
                } catch (ParseException e) {
                    e.printStackTrace();
                }
                //最后一次生成时间
                long lastTimeGenerated=0;
                //生成次数
                int generatedNumber=0;
                for (Long time : timeList) {
                    //判断开始时间为0或者开始时间小于等于生成时间并且结束时间为0或者结束时间大于生成时间
                    if(((item.getFromTime()==0||time>=item.getFromTime())&&(item.getEndTime()==0||time<=item.getEndTime()))){
                        TaskScheduleEntity task = new TaskScheduleEntity();
                        task.setScheduleTime(time)
                                .setTaskUuid(UUID.randomUUID().toString().replaceAll("-",""))
                                .setTaskName(item.getName())
                                .setCallUrl(item.getCallUrl())
                                .setCallParams(item.getCallParams())
                                .setTriggerUuid(item.getUuid());
                        task = taskScheduleService.createTaskSchedule(task);
                        log.info("生成任务:"+task);
                        lastTimeGenerated=time;
                        generatedNumber++;
                    }else{
                        item.setStatus(TriggerExpressionEntity.CLOSE);
                    }
                }
                if(item.getMaxTimes()!=0&&item.getMaxTimes()<=item.getScheduleTimes()+generatedNumber)item.setStatus(TriggerExpressionEntity.CLOSE);
                item.setLastTimeGenerated(lastTimeGenerated);
                item.setScheduleTimes(item.getScheduleTimes()+generatedNumber);
                dao.save(item);
            }
        }
    }

    @Override
    public TaskScheduleEntity createSingleExpression(CreateSingleExpressionForm form) {
        return taskScheduleService.createTaskSchedule(formToTaskScheduleEntity(form));
    }

    public TaskScheduleEntity formToTaskScheduleEntity(CreateSingleExpressionForm form){
        TaskScheduleEntity schedule = new TaskScheduleEntity();
        schedule.setScheduleTime(form.getScheduleTime())
                .setTaskUuid(UUID.randomUUID().toString().replaceAll("-",""))
                .setTaskName(form.getTaskName())
                .setCallUrl(form.getCallUrl())
                .setCallParams(form.getCallParams());
        return schedule;
    }

    public TriggerExpressionEntity formToDto(CreateExpressionForm form){
        TriggerExpressionEntity entity = new TriggerExpressionEntity();
        if(form.getUuid()==null|| StrUtil.isEmpty(form.getUuid())){
            entity.setUuid(UUID.randomUUID().toString().replaceAll("-",""));
        }else{
            entity.setUuid(form.getUuid());
        }
        entity.setExpression(form.getExpression())
                .setCallParams(form.getCallParams())
                .setFromTime(form.getFromTime())
                .setEndTime(form.getEndTime())
                .setCallUrl(form.getCallUrl())
                .setMaxTimes(form.getMaxTimes())
                .setName(form.getName());
        return entity;
    }
}

5、任务拉取

TaskManager

拉取任务将任务进行过滤如果时间不足200毫秒则直接执行否则将任务发布出去。订阅方会自动处理

package au.com.koalaclass.timer.thread;

import au.com.koalaclass.timer.entity.TaskScheduleEntity;
import au.com.koalaclass.timer.event.TimerCallerEvent;
import au.com.koalaclass.timer.service.TaskScheduleService;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.*;

@Component
@Data
@Slf4j
public class TaskManager implements Runnable {
    @Resource
    private ApplicationEventPublisher publisher;
    @Resource
    private TaskScheduleService taskScheduleService;

    private boolean stopFlag = false;

    private Timer timer = new Timer();

    /**
     * 拉取任务
     */
    private void pull() {
        log.info("拉取任务");
        List<TaskScheduleEntity> tasks = taskScheduleService.pullTask();

        /******构造假数据,开始**************/
//        if (tasks == null) {
//            tasks = new ArrayList<>();
//        }
//
//        TaskScheduleEntity task1 = new TaskScheduleEntity();
//        task1.setId(1L);
//        task1.setTaskUuid(UUID.randomUUID().toString());
//        task1.setScheduleTime(System.currentTimeMillis());
//        //TaskQueue.push(ts);
//        tasks.add(task1);
//
//        TaskScheduleEntity task2 = new TaskScheduleEntity();
//        task2.setTaskUuid(UUID.randomUUID().toString());
//        task2.setScheduleTime(System.currentTimeMillis() + 10 * 1000);
//        tasks.add(task2);
        /******构造假数据,完成**************/

        //装载任务
        if (tasks != null && tasks.size() > 0) {
            for (TaskScheduleEntity task : tasks) {
                timeTask(task);
            }
        } else {
            try {
                Thread.sleep(5*1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    private void timeTask(TaskScheduleEntity task) {
        log.info("定时:"+task);
        long delay = task.getScheduleTime() - System.currentTimeMillis();
        //如果距离执行时间不足200毫秒,直接触发执行;否则加入定时器,由定时器去触发。
        if (delay < 200) {
            callTask(task);
        } else {
            TimerTask timerTask = new MyTimerTask(task);

            timer.schedule(timerTask, delay);
        }
    }

    private void callTask(TaskScheduleEntity task) {
        TimerCallerEvent event = new TimerCallerEvent(task);
        event.setTaskSchedule(task);
        log.info("发送消息:" + event);
        publisher.publishEvent(event);
    }

    @Override
    public void run() {
        while (!stopFlag) {
            try {
                pull();
            }catch (Exception e) {
                log.error("拉取任务异常:"+e.getMessage());
            }

            try {
                Thread.sleep(1000 * 5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public class MyTimerTask extends TimerTask{
        private TaskScheduleEntity task;

        public MyTimerTask(TaskScheduleEntity task) {
            this.task = task;
        }
        @Override
        public void run() {
            callTask(task);
        }
    }
}

Service层

package au.com.koalaclass.timer.service.impl;

import au.com.koalaclass.timer.dao.InstanceDao;
import au.com.koalaclass.timer.dao.TaskScheduleDao;
import au.com.koalaclass.timer.entity.InstanceEntity;
import au.com.koalaclass.timer.entity.TaskScheduleEntity;
import au.com.koalaclass.timer.exception.TaskScheduleException;
import au.com.koalaclass.timer.service.RedissonService;
import au.com.koalaclass.timer.service.TaskScheduleService;
import au.com.koalaclass.timer.state.InstanceState;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;

@Slf4j
@Service
public class TaskScheduleServiceImpl implements TaskScheduleService {
    private final static String LOCK = "lock_pull_task";
    @Resource
    TaskScheduleDao taskScheduleDao;
    @Resource
    InstanceDao instanceDao;
    @Resource
    InstanceState instanceState;
    @Resource
    RedissonService redissonService;
    @Override
    public List<TaskScheduleEntity> pullTask() {
        List<TaskScheduleEntity> tasks=new ArrayList<>();
        //判断是否上锁
        if (!redissonService.tryLock(LOCK)) {
            log.warn("拉取任务,获取锁失败");
            return tasks;
        }
        try {
            //只拉取2分钟内要执行的
            long now = System.currentTimeMillis();
            long time = now + 2 * 60 * 1000;
            //查询2分钟内需要触发的任务
            List<TaskScheduleEntity> dbTasks = taskScheduleDao.findAllByScheduleTimeBeforeOrderByScheduleTimeAsc(time);
            //查询所有存活的实例
            List<InstanceEntity> dbInstances = instanceDao.findAll();
            //过滤本次拉取的任务
            tasks = filterTask(dbTasks, dbInstances);
            //更新数据库,标记已分配给自己
            tasks = updateTaskToMe(tasks);
            log.info("抢到任务:{}条", tasks.size());
        } catch (Exception e) {
            log.error("拉取任务异常:{}", e);
        } finally {
            redissonService.unlock(LOCK);
        }
        return tasks;
    }

    @Override
    public TaskScheduleEntity createTaskSchedule(TaskScheduleEntity entity) {
        return taskScheduleDao.save(entity);
    }

    /**
     * 更新任务到自己实例
     *
     * @param tasks
     * @return
     */
    private List<TaskScheduleEntity> updateTaskToMe(List<TaskScheduleEntity> tasks) {
        long now = System.currentTimeMillis();
        if (tasks.size() > 0) {
            for (TaskScheduleEntity task : tasks) {
                task.setInstanceUuid(instanceState.getUuid());
                task.setDispatchTime(now);
            }
            tasks = taskScheduleDao.saveAll(tasks);
        }
        return tasks;
    }

    /**
     * 筛选没分配的,和已分配但是实例死亡的
     *
     * @param dbTasks
     * @param dbInstances
     * @return
     */
    private List<TaskScheduleEntity> filterTask(List<TaskScheduleEntity> dbTasks, List<InstanceEntity> dbInstances) {
        List<TaskScheduleEntity> tasks = new ArrayList<>();
        for (TaskScheduleEntity task : dbTasks) {
            if (tasks.size() >= 10) {
                return tasks;
            }
            if (task.getDispatchTime() < 1) {
                log.info("抢到没有被分配的任务:"+task);
                tasks.add(task);
            } else if (!hasDispatchedToMe(task) && !instanceLive(dbInstances, task)) {
                log.info("抢到别人死亡的任务:"+task);
                tasks.add(task);
            }
        }
        return tasks;
    }

    /**
     * 已分给我自己
     *
     * @param task
     * @return
     */
    private boolean hasDispatchedToMe(TaskScheduleEntity task) {
        return instanceState.getUuid().equalsIgnoreCase(task.getInstanceUuid());
    }

    /**
     * 实例已经死亡
     *
     * @param instances
     * @param task
     * @return
     */
    private boolean instanceLive(List<InstanceEntity> instances, TaskScheduleEntity task) {
        for (InstanceEntity instance : instances) {
            if (instance.getUuid() != null && instance.getUuid().equalsIgnoreCase(task.getInstanceUuid())) {
                return true;
            }
        }
        return false;
    }
}

TimerCallerEvent

package au.com.koalaclass.timer.event;

import au.com.koalaclass.timer.entity.TaskScheduleEntity;
import lombok.Getter;
import lombok.Setter;
import org.springframework.context.ApplicationEvent;

/**
 * 定时器叫醒事件。定时器到了时间就产生一个叫醒事件,叫醒事件的侦听器接收该事件。
 * 参考TimerCallerListener
 */
@Getter
@Setter
public class TimerCallerEvent extends ApplicationEvent {
    private TaskScheduleEntity taskSchedule;

    public TimerCallerEvent(Object source) {
        super(source);
    }
}

TimerCallerListener

package au.com.koalaclass.timer.listener;

import au.com.koalaclass.timer.entity.TaskScheduleEntity;
import au.com.koalaclass.timer.event.TimerCallerEvent;
import au.com.koalaclass.timer.service.CallerExecutorService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;

/**
 * 叫醒事件的侦听器,负责执行具体任务
 */
@Slf4j
@Component
public class TimerCallerListener {
    @Resource
    CallerExecutorService callerExecutorService;

    /**
     * 由线程池负责执行具体的任务
     *
     * @param event
     */
    @Async("callerThreadPoolTaskExecutor")
    @EventListener
    public void processTimerCallerEvent(TimerCallerEvent event) {
        log.info("接收消息:" + event);
        try {
            TaskScheduleEntity task = event.getTaskSchedule();
            if (task == null) return;
            callerExecutorService.execute(task);
        } catch (Exception e) {
        }
    }
}

三、结果展示

在这里插入图片描述在这里插入图片描述

在这里插入图片描述

可以看到根据cron表达式将任务解析进行调用

四、总结

上边用到了一些公司内部包可以自行过滤。保持呼吸部分代码这里没有贴出来。解决的思路就是解析cron然后进行定时逐个发送。
项目地址:https://download.csdn.net/download/qq_43548590/86796289

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Java分布式定时任务可以通过使用Quartz框架来实现。Quartz是一个功能强大且灵活的开源作业调度库,可以用于在Java应用程序中创建和管理定时任务。 要在分布式环境中使用Quartz,可以采用以下步骤: 1. 配置Quartz集群:在分布式环境中,多个应用程序实例可能同时执行相同的定时任务。为了避免重复调度和冲突,需要配置一个Quartz集群。这可以通过将Quartz实例连接到共享的数据库或使用Terracotta等内存网格来实现。 2. 创建定时任务:使用Quartz提供的API,可以创建不同类型的定时任务,如简单触发器(SimpleTrigger)或Cron触发器(CronTrigger)。定时任务可以指定执行时间、重复次数、触发条件等。 3. 配置调度器:在每个应用程序实例中,需要配置一个调度器(Scheduler)来管理定时任务。调度器负责启动、暂停、恢复和停止定时任务的执行。 4. 启动调度器:在应用程序启动时,需要启动调度器以开始执行定时任务。可以通过调用调度器的start方法来实现。 5. 监控和管理:Quartz提供了一些管理和监控工具,可以用于查看和管理正在运行的定时任务。可以使用Quartz的API或使用Quartz提供的web界面来实现。 需要注意的是,在分布式环境中,需要确保定时任务在不同的应用程序实例之间进行正确的负载均衡。可以使用调度器的集群功能或其他负载均衡机制来实现。 希望以上信息对你有所帮助!如有任何问题,请随时提问。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值