springBoot+quartz

定时任务

单节点定时任务:直接使用 @EnableScheduling + @Scheduled(cron="0/1 * * * * ?") 执行定时任务

分布式定时任务:推荐使用quratzxxl-job

区别

quartxxl-job
集群、弹性扩容多节点部署,通过竞争数据库锁来保证只有一个节点执行任务使用Quartz基于数据库的分布式功能,服务器超出一定数量会给数据库造成一定的压力
任务分片不支持支持
管理界面
高级功能弹性扩容,分片广播,故障转移,Rolling实时日志,GLUE(支持在线编辑代码,免发布),任务进度监控,任务依赖,数据加密,邮件报警,运行报表,国际化
缺点没有管理界面,以及不支持任务分片等。不适用于分布式场景调度中心通过获取DB锁来保证集群中执行任务的唯一性,如果短任务很多,随着调度中心集群数量增加,那么数据库的锁竞争会比较厉害,性能不好。
任务不能重复执行数据库锁使用Quartz基于数据库的分布式功能
并行调度调度系统多线程(默认10个线程)触发调度运行,确保调度精确执行,不被堵塞。
失败处理策略调度失败时的处理策略,策略包括:失败告警(默认)、失败重试(界面可配置)
动态分片策略分片广播任务以执行器为维度进行分片,支持动态扩容执行器集群从而动态增加分片数量,协同进行业务处理;在进行大数据量业务操作时可显著提升任务处理能力和速度。执行器集群部署时,任务路由策略选择”分片广播”情况下,一次任务调度将会广播触发对应集群中所有执行器执行一次任务,同时传递分片参数;可根据分片参数开发分片任务;

xxl-job就是quartz的一个增强版,其弥补了quartz不支持并行调度,不支持失败处理策略和动态分片的策略等诸多不足,同时其有管理界面,上手比较容易,支持分布式,适用于分布式场景下的使用。两者相同的是都是通过数据库锁来控制任务不能重复执行

本文主要介绍quartz

1.quartz

核心概念

SchedulerQuartz 中的任务调度器,通过 TriggerJobDetail 可以用来调度、暂停和删除任务。调度器就相当于一个容器,装载着任务和触发器,该类是一个接口,代表一个 Quartz 的独立运行容器,TriggerJobDetail 可以注册到 Scheduler 中,两者在 Scheduler 中拥有各自的组及名称,组及名称是 Scheduler 查找定位容器中某一对象的依据,Trigger 的组及名称必须唯一JobDetail 的组和名称也必须唯一(但可以和 Trigger 的组和名称相同,因为它们是不同类型的)


Trigger:Quartz 中的触发器,是一个类,描述触发 Job 执行的时间触发规则,主要有 SimpleTriggerCronTrigger 这两个子类。当且仅当需调度一次或者以固定时间间隔周期执行调度,SimpleTrigger 是最适合的选择;而 CronTrigger 则可以通过 Cron 表达式定义出各种复杂时间规则的调度方案:如工作日周一到周五的 15:00 ~ 16:00 执行调度等


JobDetailQuartz 中需要执行的任务详情,包括了任务的唯一标识和具体要执行的任务,可以通过 JobDataMap 往任务中传递数据


JobQuartz 中具体的任务,包含了执行任务的具体方法。是一个接口,只定义一个方法 execute() 方法,在实现接口的 execute() 方法中编写所需要定时执行的 Job
 

2.Springboot 整合 Quartz

2.1 数据库导入

quartz通过数据库存储定时任务信息,sql 脚本下载地址:Downloads,创建成功后数据库中多出 11 张表,本文使用的数据库是pgsql,以下是pgsql脚本

-- 业务表
-- Quartz定时任务相关表
-- Name: qrtz_job_details; Type: TABLE; Schema: public; Owner: -
CREATE TABLE qrtz_job_details (
    sched_name character varying(120) NOT NULL,
    job_name character varying(200) NOT NULL,
    job_group character varying(200) NOT NULL,
    description character varying(250),
    job_class_name character varying(250) NOT NULL,
    is_durable boolean NOT NULL,
    is_nonconcurrent boolean NOT NULL,
    is_update_data boolean NOT NULL,
    requests_recovery boolean NOT NULL,
    job_data bytea
);
ALTER TABLE ONLY qrtz_job_details ADD CONSTRAINT qrtz_job_details_pkey PRIMARY KEY (sched_name, job_name, job_group);
CREATE INDEX idx_qrtz_j_grp ON qrtz_job_details USING btree (sched_name, job_group);
CREATE INDEX idx_qrtz_j_req_recovery ON qrtz_job_details USING btree (sched_name, requests_recovery);

-- Name: qrtz_triggers; Type: TABLE; Schema: public; Owner: -
CREATE TABLE qrtz_triggers (
    sched_name character varying(120) NOT NULL,
    trigger_name character varying(200) NOT NULL,
    trigger_group character varying(200) NOT NULL,
    job_name character varying(200) NOT NULL,
    job_group character varying(200) NOT NULL,
    description character varying(250),
    next_fire_time bigint,
    prev_fire_time bigint,
    priority integer,
    trigger_state character varying(16) NOT NULL,
    trigger_type character varying(8) NOT NULL,
    start_time bigint NOT NULL,
    end_time bigint,
    calendar_name character varying(200),
    misfire_instr smallint,
    job_data bytea
);
ALTER TABLE ONLY qrtz_triggers ADD CONSTRAINT qrtz_triggers_pkey PRIMARY KEY (sched_name, trigger_name, trigger_group);
ALTER TABLE ONLY qrtz_triggers ADD CONSTRAINT qrtz_triggers_sched_name_fkey FOREIGN KEY (sched_name, job_name, job_group) REFERENCES qrtz_job_details(sched_name, job_name, job_group);
CREATE INDEX idx_qrtz_t_c ON qrtz_triggers USING btree (sched_name, calendar_name);
CREATE INDEX idx_qrtz_t_g ON qrtz_triggers USING btree (sched_name, trigger_group);
CREATE INDEX idx_qrtz_t_j ON qrtz_triggers USING btree (sched_name, job_name, job_group);
CREATE INDEX idx_qrtz_t_jg ON qrtz_triggers USING btree (sched_name, job_group);
CREATE INDEX idx_qrtz_t_n_g_state ON qrtz_triggers USING btree (sched_name, trigger_group, trigger_state);
CREATE INDEX idx_qrtz_t_n_state ON qrtz_triggers USING btree (sched_name, trigger_name, trigger_group, trigger_state);
CREATE INDEX idx_qrtz_t_next_fire_time ON qrtz_triggers USING btree (sched_name, next_fire_time);
CREATE INDEX idx_qrtz_t_nft_misfire ON qrtz_triggers USING btree (sched_name, misfire_instr, next_fire_time);
CREATE INDEX idx_qrtz_t_nft_st ON qrtz_triggers USING btree (sched_name, trigger_state, next_fire_time);
CREATE INDEX idx_qrtz_t_nft_st_misfire ON qrtz_triggers USING btree (sched_name, misfire_instr, next_fire_time, trigger_state);
CREATE INDEX idx_qrtz_t_nft_st_misfire_grp ON qrtz_triggers USING btree (sched_name, misfire_instr, next_fire_time, trigger_group, trigger_state);
CREATE INDEX idx_qrtz_t_state ON qrtz_triggers USING btree (sched_name, trigger_state);

-- Name: qrtz_blob_triggers; Type: TABLE; Schema: public; Owner: -
CREATE TABLE qrtz_blob_triggers (
    sched_name character varying(120) NOT NULL,
    trigger_name character varying(200) NOT NULL,
    trigger_group character varying(200) NOT NULL,
    blob_data bytea
);
ALTER TABLE ONLY qrtz_blob_triggers ADD CONSTRAINT qrtz_blob_triggers_pkey PRIMARY KEY (sched_name, trigger_name, trigger_group);
ALTER TABLE ONLY qrtz_blob_triggers ADD CONSTRAINT qrtz_blob_triggers_sched_name_fkey FOREIGN KEY (sched_name, trigger_name, trigger_group) REFERENCES qrtz_triggers(sched_name, trigger_name, trigger_group);

-- Name: qrtz_calendars; Type: TABLE; Schema: public; Owner: -
CREATE TABLE qrtz_calendars (
    sched_name character varying(120) NOT NULL,
    calendar_name character varying(200) NOT NULL,
    calendar bytea NOT NULL
);
ALTER TABLE ONLY qrtz_calendars ADD CONSTRAINT qrtz_calendars_pkey PRIMARY KEY (sched_name, calendar_name);

-- Name: qrtz_cron_triggers; Type: TABLE; Schema: public; Owner: -
CREATE TABLE qrtz_cron_triggers (
    sched_name character varying(120) NOT NULL,
    trigger_name character varying(200) NOT NULL,
    trigger_group character varying(200) NOT NULL,
    cron_expression character varying(120) NOT NULL,
    time_zone_id character varying(80)
);
ALTER TABLE ONLY qrtz_cron_triggers ADD CONSTRAINT qrtz_cron_triggers_pkey PRIMARY KEY (sched_name, trigger_name, trigger_group);
ALTER TABLE ONLY qrtz_cron_triggers ADD CONSTRAINT qrtz_cron_triggers_sched_name_fkey FOREIGN KEY (sched_name, trigger_name, trigger_group) REFERENCES qrtz_triggers(sched_name, trigger_name, trigger_group);

-- Name: qrtz_fired_triggers; Type: TABLE; Schema: public; Owner: -
CREATE TABLE qrtz_fired_triggers (
    sched_name character varying(120) NOT NULL,
    entry_id character varying(95) NOT NULL,
    trigger_name character varying(200) NOT NULL,
    trigger_group character varying(200) NOT NULL,
    instance_name character varying(200) NOT NULL,
    fired_time bigint NOT NULL,
    sched_time bigint NOT NULL,
    priority integer NOT NULL,
    state character varying(16) NOT NULL,
    job_name character varying(200),
    job_group character varying(200),
    is_nonconcurrent boolean,
    requests_recovery boolean
);
ALTER TABLE ONLY qrtz_fired_triggers ADD CONSTRAINT qrtz_fired_triggers_pkey PRIMARY KEY (sched_name, entry_id);
CREATE INDEX idx_qrtz_ft_inst_job_req_rcvry ON qrtz_fired_triggers USING btree (sched_name, instance_name, requests_recovery);
CREATE INDEX idx_qrtz_ft_j_g ON qrtz_fired_triggers USING btree (sched_name, job_name, job_group);
CREATE INDEX idx_qrtz_ft_jg ON qrtz_fired_triggers USING btree (sched_name, job_group);
CREATE INDEX idx_qrtz_ft_t_g ON qrtz_fired_triggers USING btree (sched_name, trigger_name, trigger_group);
CREATE INDEX idx_qrtz_ft_tg ON qrtz_fired_triggers USING btree (sched_name, trigger_group);
CREATE INDEX idx_qrtz_ft_trig_inst_name ON qrtz_fired_triggers USING btree (sched_name, instance_name);

-- Name: qrtz_locks; Type: TABLE; Schema: public; Owner: -
CREATE TABLE qrtz_locks (
    sched_name character varying(120) NOT NULL,
    lock_name character varying(40) NOT NULL
);
ALTER TABLE ONLY qrtz_locks ADD CONSTRAINT qrtz_locks_pkey PRIMARY KEY (sched_name, lock_name);

-- Name: qrtz_paused_trigger_grps; Type: TABLE; Schema: public; Owner: -
CREATE TABLE qrtz_paused_trigger_grps (
     sched_name character varying(120) NOT NULL,
     trigger_group character varying(200) NOT NULL
);
ALTER TABLE ONLY qrtz_paused_trigger_grps ADD CONSTRAINT qrtz_paused_trigger_grps_pkey PRIMARY KEY (sched_name, trigger_group);

-- Name: qrtz_scheduler_state; Type: TABLE; Schema: public; Owner: -
CREATE TABLE qrtz_scheduler_state (
     sched_name character varying(120) NOT NULL,
     instance_name character varying(200) NOT NULL,
     last_checkin_time bigint NOT NULL,
     checkin_interval bigint NOT NULL
);
ALTER TABLE ONLY qrtz_scheduler_state ADD CONSTRAINT qrtz_scheduler_state_pkey PRIMARY KEY (sched_name, instance_name);

-- Name: qrtz_simple_triggers; Type: TABLE; Schema: public; Owner: -
CREATE TABLE qrtz_simple_triggers (
     sched_name character varying(120) NOT NULL,
     trigger_name character varying(200) NOT NULL,
     trigger_group character varying(200) NOT NULL,
     repeat_count bigint NOT NULL,
     repeat_interval bigint NOT NULL,
     times_triggered bigint NOT NULL
);
ALTER TABLE ONLY qrtz_simple_triggers ADD CONSTRAINT qrtz_simple_triggers_pkey PRIMARY KEY (sched_name, trigger_name, trigger_group);
ALTER TABLE ONLY qrtz_simple_triggers ADD CONSTRAINT qrtz_simple_triggers_sched_name_fkey FOREIGN KEY (sched_name, trigger_name, trigger_group) REFERENCES qrtz_triggers(sched_name, trigger_name, trigger_group);

-- Name: qrtz_simprop_triggers; Type: TABLE; Schema: public; Owner: -
CREATE TABLE qrtz_simprop_triggers (
    sched_name character varying(120) NOT NULL,
    trigger_name character varying(200) NOT NULL,
    trigger_group character varying(200) NOT NULL,
    str_prop_1 character varying(512),
    str_prop_2 character varying(512),
    str_prop_3 character varying(512),
    int_prop_1 integer,
    int_prop_2 integer,
    long_prop_1 bigint,
    long_prop_2 bigint,
    dec_prop_1 numeric(13,4),
    dec_prop_2 numeric(13,4),
    bool_prop_1 boolean,
    bool_prop_2 boolean
);
ALTER TABLE ONLY qrtz_simprop_triggers ADD CONSTRAINT qrtz_simprop_triggers_pkey PRIMARY KEY (sched_name, trigger_name, trigger_group);
ALTER TABLE ONLY qrtz_simprop_triggers ADD CONSTRAINT qrtz_simprop_triggers_sched_name_fkey FOREIGN KEY (sched_name, trigger_name, trigger_group) REFERENCES qrtz_triggers(sched_name, trigger_name, trigger_group);

2.2 maven依赖

spring-boot-starter-quartz
    <!--父类依赖-->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.4</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>


    <!--web启动器依赖-->
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-quartz</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.1.1</version>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.2.9</version>
        </dependency>
        <dependency>
            <groupId>com.github.pagehelper</groupId>
            <artifactId>pagehelper</artifactId>
            <version>5.0.0</version>
        </dependency>

    </dependencies>

2.3 quratz.properties

#quartz集群配置
#调度标识名 集群中每一个实例都必须使用相同的名称
org.quartz.scheduler.instanceName=DefaultQuartzScheduler
#ID设置为自动获取 每一个必须不同
org.quartz.scheduler.instanceId=AUTO


#线程池的实现类(一般使用SimpleThreadPool即可满足需求)
org.quartz.threadPool.class=org.quartz.simpl.SimpleThreadPool
#指定在线程池里面创建的线程是否是守护线程
org.quartz.threadPool.makeThreadsDaemons=true
#指定线程数,至少为1(无默认值)
org.quartz.threadPool.threadCount=20
#设置线程的优先级(最大为java.lang.Thread.MAX_PRIORITY 10,最小为Thread.MIN_PRIORITY 1,默认为5)
org.quartz.threadPool.threadPriority=5
#配置是否启动自动加载数据库内的定时任务,默认true
org.quartz.threadPool.threadsInheritContextClassLoaderOfInitializingThread=true


#数据保存方式为数据库持久化
org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX
#数据库代理类,一般org.quartz.impl.jdbcjobstore.StdJDBCDelegate可以满足大部分数据库(本文使用的是pgsql)
org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
#表的前缀,默认QRTZ_
org.quartz.jobStore.tablePrefix=QRTZ_
#是否加入集群
org.quartz.jobStore.isClustered=true
# 信息保存时间 默认值60秒
org.quartz.jobStore.misfireThreshold=60000
#调度实例失效的检查时间间隔,单位毫秒
org.quartz.jobStore.clusterCheckinInterval=2000

关于配置详细解释:https://blog.csdn.net/zixiao217/article/details/53091812

也可以查看官网:http://www.quartz-scheduler.org/documentation/2.3.1-SNAPSHOT/

2.4 application.properties

数据库配置的是Druid数据源(druid-spring-boot-starter)

server.port=9521

spring.application.name=springboot-quartz-001

#配置数据源
spring.datasource.url=jdbc:postgresql://localhost:5432/test
spring.datasource.username=postgres
spring.datasource.password=qwerm,./12
spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource



# 数据库连接池初始值
spring.datasource.druid.initial-size=5
# 连接池的最小空闲数量
spring.datasource.druid.min-idle=5
# 连接池最大连接数量
spring.datasource.druid.max-active=20
# 获取连接时最大等待时间,单位毫秒
spring.datasource.druid.max-wait=60000
# 申请连接的时候检测,如果空闲时间大于timeBetweenEvictionRunsMillis,执行validationQuery检测连接是否有效。
spring.datasource.druid.test-while-idle=true
# 既作为检测的间隔时间又作为testWhileIdel执行的依据
spring.datasource.druid.time-between-eviction-runs-millis=60000
# 销毁线程时检测当前连接的最后活动时间和当前时间差大于该值时,关闭当前连接(配置连接在池中的最小生存时间)
spring.datasource.druid.min-evictable-idle-time-millis=30000
# 用来检测数据库连接是否有效的sql 必须是一个查询语句(oracle中为 select 1 from dual)
spring.datasource.druid.validation-query=select 1
# 申请连接时会执行validationQuery检测连接是否有效,开启会降低性能,默认为true
spring.datasource.druid.test-on-borrow=false
# 归还连接时会执行validationQuery检测连接是否有效,开启会降低性能,默认为true
spring.datasource.druid.test-on-return=false
# 是否缓存preparedStatement, 也就是PSCache,PSCache对支持游标的数据库性能提升巨大,比如说oracle,在mysql下建议关闭。
spring.datasource.druid.pool-prepared-statements=false
# 置监控统计拦截的filters,去掉后监控界面sql无法统计,stat: 监控统计、Slf4j:日志记录、waLL: 防御sqL注入
spring.datasource.druid.filters=stat,wall,slf4j
# 要启用PSCache,必须配置大于0,当大于0时,poolPreparedStatements自动触发修改为true。在Druid中,不会存在Oracle下PSCache占用内存过多的问题,可以把这个数值配置大一些,比如说100
spring.datasource.druid.max-pool-prepared-statement-per-connection-size=-1
# 合并多个DruidDataSource的监控数据
spring.datasource.druid.use-global-data-source-stat=true
# 通过connectProperties属性来打开mergeSql功能;慢SQL记录
spring.datasource.druid.connect-properties.druid.stat.mergeSql=true
spring.datasource.druid.druid.stat.slowSqlMillis=5000

        
mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
mybatis.type-aliases-package=com.quartz.demo.model

#-------------druid 网页配置-----------------
# 是否启用StatFilter默认值true
spring.datasource.druid.web-stat-filter.enabled=true
# 添加过滤规则
spring.datasource.druid.web-stat-filter.url-pattern=/*
# 忽略过滤的格式
spring.datasource.druid.web-stat-filter.exclusions=/druid/*,*.js,*.gif,*.jpg,*.png,*.css,*.ico
# 是否启用StatViewServlet默认值true
spring.datasource.druid.stat-view-servlet.enabled=true
# 访问路径为/druid时,跳转到StatViewServlet
spring.datasource.druid.stat-view-servlet.url-pattern=/druid/*
# 是否能够重置数据
spring.datasource.druid.stat-view-servlet.reset-enable=false
# 需要账号密码才能访问控制台,默认为root
spring.datasource.druid.stat-view-servlet.login-username=druid
spring.datasource.druid.stat-view-servlet.login-password=druid
# IP白名单
spring.datasource.druid.stat-view-servlet.allow=127.0.0.1
spring.datasource.druid.stat-view-servlet.deny=

2.5 quartz工厂配置

quartzJobDetailFactoryBean.setJobClass(QuartzJob.class)  是通过  AdaptableJobFactory  反射直接初始化  job  对象的,不经过spring  容器。因此我们需要编写一个 QuartzJobFactory   继承  AdaptableJobFactory  类,重写里面的create方法,完成将job对象也注入进spring容器中。

@Component
public class QuartzJobFactory extends AdaptableJobFactory {

    @Autowired
    private AutowireCapableBeanFactory capableBeanFactory;

    @Override
    protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception {
        //调用父类的方法
        Object jobInstance = super.createJobInstance(bundle);
        //进行注入
        capableBeanFactory.autowireBean(jobInstance);
        return jobInstance;
    }

}

2.6 quartz配置类型QuartzConfig

@Configuration
public class QuartzConfig {

    @Autowired
    private QuartzJobFactory jobFactory;

    @Autowired
    private DataSourceTransactionManager quartzTransactionManager;

    @Autowired
    private DataSource druidDataSource;

    @Bean
    public DataSourceTransactionManager quartzTransactionManager(DataSource dataSource) {
        DataSourceTransactionManager dstm = new DataSourceTransactionManager();
        dstm.setDataSource(dataSource);
        return dstm;
    }


    @Bean
    public SchedulerFactoryBean schedulerFactoryBean() throws Exception {
        PropertiesFactoryBean propertiesFactoryBean = new PropertiesFactoryBean();
        propertiesFactoryBean.setLocation(new ClassPathResource("/quartz.properties"));
        //在quartz.properties中的属性被读取并注入后再初始化对象
        propertiesFactoryBean.afterPropertiesSet();


        //创建SchedulerFactoryBean
        SchedulerFactoryBean factory = new SchedulerFactoryBean();
        factory.setQuartzProperties(propertiesFactoryBean.getObject());
        factory.setJobFactory(jobFactory);//支持在JOB实例中注入其他的业务对象
        factory.setApplicationContextSchedulerContextKey("applicationContextKey");
        factory.setOverwriteExistingJobs(true);//是否覆盖己存在的Job
        factory.setWaitForJobsToCompleteOnShutdown(true);//这样当spring关闭时,会等待所有已经启动的quartz job结束后spring才能完全shutdown
        factory.setStartupDelay(10);//QuartzScheduler 延时启动,应用启动完后 QuartzScheduler 再启动
        factory.setSchedulerName("comcsScheduler");
        factory.setTransactionManager(quartzTransactionManager);
        factory.setDataSource(druidDataSource);
        return factory;
    }

    /**
     * 通过SchedulerFactoryBean获取Scheduler的实例
     * @return
     * @throws IOException
     * @throws SchedulerException
     */
    @Bean(name = "scheduler")
    public Scheduler scheduler() throws Exception {
        Scheduler scheduler = schedulerFactoryBean().getScheduler();
        return scheduler;
    }

}

2.7 QuartzJobService

public interface QuartzJobService {
    /**
     * 添加任务可以传参数
     *
     * @param clazzName
     * @param jobName
     * @param groupName
     * @param cronExp
     * @param param
     */
    void addJob(String clazzName, String jobName, String groupName, String cronExp, String jobDescription,Map<String, Object> param);

    /**
     * 暂停任务
     *
     * @param jobName
     * @param groupName
     */
    void pauseJob(String jobName, String groupName);

    /**
     * 恢复任务
     *
     * @param jobName
     * @param groupName
     */
    void resumeJob(String jobName, String groupName);

    /**
     * 立即运行一次定时任务
     *
     * @param jobName
     * @param groupName
     */
    void runOnce(String jobName, String groupName);

    /**
     * 更新任务
     *
     * @param jobName
     * @param groupName
     * @param cronExp
     * @param param
     */
    void updateJob(String jobName, String groupName, String cronExp, String jobDescription, Map<String, Object> param);

    /**
     * 删除任务
     *
     * @param jobName
     * @param groupName
     */
    void deleteJob(String jobName, String groupName);

    /**
     * 启动所有任务
     */
    void startAllJobs();

    /**
     * 暂停所有任务
     */
    void pauseAllJobs();

    /**
     * 恢复所有任务
     */
    void resumeAllJobs();

    /**
     * 关闭所有任务
     */
    void shutdownAllJobs();
}

2.8 QuartzJobServiceImpl

@Service
@Slf4j
public class QuartzJobServiceImpl implements QuartzJobService {

    @Autowired
    private Scheduler scheduler;

    @Override
    public void addJob(String clazzName, String jobName, String groupName, String cronExp, String jobDescription, Map<String, Object> param) {
        try {
            // 启动调度器,默认初始化的时候已经启动
//            scheduler.start();
            //构建job信息
            Class<? extends Job> jobClass = (Class<? extends Job>) Class.forName(clazzName);
            JobDetail jobDetail = JobBuilder.newJob(jobClass).withIdentity(jobName, groupName).withDescription(jobDescription).build();
            //表达式调度构建器(即任务执行的时间)
            CronScheduleBuilder scheduleBuilder = CronScheduleBuilder.cronSchedule(cronExp);
            //按新的cronExpression表达式构建一个新的trigger
            CronTrigger trigger = TriggerBuilder.newTrigger().withIdentity(jobName, groupName).withSchedule(scheduleBuilder).build();
            //获得JobDataMap,写入数据
            if (param != null) {
                trigger.getJobDataMap().putAll(param);
            }
            scheduler.scheduleJob(jobDetail, trigger);
        } catch (Exception e) {
            log.error("创建任务失败", e);
        }
    }

    @Override
    public void pauseJob(String jobName, String groupName) {
        try {
            scheduler.pauseJob(JobKey.jobKey(jobName, groupName));
        } catch (SchedulerException e) {
            log.error("暂停任务失败", e);
        }
    }

    @Override
    public void resumeJob(String jobName, String groupName) {
        try {
            scheduler.resumeJob(JobKey.jobKey(jobName, groupName));
        } catch (SchedulerException e) {
            log.error("恢复任务失败", e);
        }
    }

    @Override
    public void runOnce(String jobName, String groupName) {
        try {
            scheduler.triggerJob(JobKey.jobKey(jobName, groupName));
        } catch (SchedulerException e) {
            log.error("立即运行一次定时任务失败", e);
        }
    }

    @Override
    public void updateJob(String jobName, String groupName, String cronExp, String jobDescription, Map<String, Object> param) {
        try {
            TriggerKey triggerKey = TriggerKey.triggerKey(jobName, groupName);
            CronTrigger trigger = (CronTrigger) scheduler.getTrigger(triggerKey);
            if (cronExp != null) {
                // 表达式调度构建器
                CronScheduleBuilder scheduleBuilder = CronScheduleBuilder.cronSchedule(cronExp);
                // 按新的cronExpression表达式重新构建trigger
                trigger = trigger.getTriggerBuilder().withIdentity(triggerKey).withSchedule(scheduleBuilder).withDescription(jobDescription).build();
            }
            //修改map
            if (param != null) {
                trigger.getJobDataMap().putAll(param);
            }
            // 按新的trigger重新设置job执行
            scheduler.rescheduleJob(triggerKey, trigger);
        } catch (Exception e) {
            log.error("更新任务失败", e);
        }
    }

    @Override
    public void deleteJob(String jobName, String groupName) {
        try {
            //暂停、移除、删除
            scheduler.pauseTrigger(TriggerKey.triggerKey(jobName, groupName));
            scheduler.unscheduleJob(TriggerKey.triggerKey(jobName, groupName));
            scheduler.deleteJob(JobKey.jobKey(jobName, groupName));
        } catch (Exception e) {
            log.error("删除任务失败", e);
        }
    }

    @Override
    public void startAllJobs() {
        try {
            scheduler.start();
        } catch (Exception e) {
            log.error("开启所有的任务失败", e);
        }
    }

    @Override
    public void pauseAllJobs() {
        try {
            scheduler.pauseAll();
        } catch (Exception e) {
            log.error("暂停所有任务失败", e);
        }
    }

    @Override
    public void resumeAllJobs() {
        try {
            scheduler.resumeAll();
        } catch (Exception e) {
            log.error("恢复所有任务失败", e);
        }
    }

    @Override
    public void shutdownAllJobs() {
        try {

            if (!scheduler.isShutdown()) {
                // 需谨慎操作关闭scheduler容器
                // scheduler生命周期结束,无法再 start() 启动scheduler
                scheduler.shutdown(true);
            }
        } catch (Exception e) {
            log.error("关闭所有的任务失败", e);
        }
    }
}

2.9 TestJob

具体执行的任务

@Slf4j
public class TestJob implements Job {

    @Override
    public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        log.info("data = {}", dateFormat.format(new Date()));
    }
}

2.10 Controller

@RestController
public class TestController {

    @Autowired
    private QuartzJobService quartzJobService;

    @PostMapping("test")
    public void test() {
        quartzJobService.addJob(TestJob.class.getName(),
                "testJob", "testJobGroup", "0 36 11 * * ?", "测试", null);
    }
}

postman调用接口

定时任务数据入库成功

console打印job执行日志

3.Quartz可视化

        此处只提供参考思路,定时任务的可视化主要实现还是通过查询数据库中的定时任务数据实现,后端提供相应的接口

3.1 开放接口Controller

/**
 * @Author: dingyong6
 * @Date: 2023/09/19/15:48
 * @Description:
 * @Version V1.0
 */

@RestController
@RequestMapping(value = "/job")
public class JobController {

    @Autowired
    private QuartzJobService quartzJobService;

    @Autowired
    private JobAndTriggerService jobAndTriggerSevice;

    /**
     * 添加定时任务
     * @param jobClassName
     * @param jobGroupName
     * @param cronExpression
     * @param description
     * @throws Exception
     */
    @PostMapping(value = "/addJob")
    public void addJob(@RequestParam(value = "jobClassName") String jobClassName,
                       @RequestParam(value = "jobGroupName") String jobGroupName,
                       @RequestParam(value = "cronExpression") String cronExpression,
                       @RequestParam(value = "description") String description) throws Exception {
        quartzJobService.addJob(jobClassName, jobClassName, jobGroupName, cronExpression, description, null);
    }

    /**
     * 暂停定时任务
     * @param jobClassName
     * @param jobGroupName
     * @throws Exception
     */
    @PostMapping(value = "/pauseJob")
    public void pauseJob(@RequestParam(value = "jobClassName") String jobClassName, @RequestParam(value = "jobGroupName") String jobGroupName) throws Exception {
        quartzJobService.pauseJob(jobClassName, jobGroupName);
    }

    /**
     * 恢复定时任务
     * @param jobClassName
     * @param jobGroupName
     * @throws Exception
     */
    @PostMapping(value = "/resumeJob")
    public void resumeJob(@RequestParam(value = "jobClassName") String jobClassName, @RequestParam(value = "jobGroupName") String jobGroupName) throws Exception {
        quartzJobService.resumeJob(jobClassName, jobGroupName);
    }

    @PostMapping(value = "/deleteJob")
    public void deleteJob(@RequestParam(value = "jobClassName") String jobClassName, @RequestParam(value = "jobGroupName") String jobGroupName) throws Exception {
        quartzJobService.deleteJob(jobClassName, jobGroupName);
    }

    /**
     * 查询任务列表
     *
     * @param pageNum
     * @param pageSize
     * @return
     */
    @GetMapping(value = "/queryJob")
    public Map<String, Object> queryJob(@RequestParam(value = "pageNum") Integer pageNum, @RequestParam(value = "pageSize") Integer pageSize) {
        Map<String, Object> map = new HashMap<String, Object>();
        PageInfo<JobAndTrigger> jobAndTrigger = jobAndTriggerSevice.getJobAndTriggerDetails(pageNum, pageSize);
        map.put("JobAndTrigger", jobAndTrigger);
        map.put("number", jobAndTrigger.getTotal());
        return map;
    }

    /**
     * 修改定时任务
     *
     * @param jobClassName
     * @param jobGroupName
     * @param cronExpression
     * @param description
     * @throws Exception
     */
    @PostMapping(value = "/rescheduleJob")
    public void rescheduleJob(@RequestParam(value = "jobClassName") String jobClassName,
                              @RequestParam(value = "jobGroupName") String jobGroupName,
                              @RequestParam(value = "cronExpression") String cronExpression,
                              @RequestParam(value = "description") String description) {

        quartzJobService.updateJob(jobClassName, jobGroupName, cronExpression, description, null);

    }

}

3.2 JobAndTrigger

public class JobAndTrigger {
    private String JOB_NAME;
    private String JOB_GROUP;
    private String JOB_CLASS_NAME;
    private String TRIGGER_NAME;
    private String TRIGGER_GROUP;
    private BigInteger REPEAT_INTERVAL;
    private BigInteger TIMES_TRIGGERED;
    private String CRON_EXPRESSION;
    private String TIME_ZONE_ID;

    public String getJOB_NAME() {
        return JOB_NAME;
    }
    public void setJOB_NAME(String jOB_NAME) {
        JOB_NAME = jOB_NAME;
    }
    public String getJOB_GROUP() {
        return JOB_GROUP;
    }
    public void setJOB_GROUP(String jOB_GROUP) {
        JOB_GROUP = jOB_GROUP;
    }
    public String getJOB_CLASS_NAME() {
        return JOB_CLASS_NAME;
    }
    public void setJOB_CLASS_NAME(String jOB_CLASS_NAME) {
        JOB_CLASS_NAME = jOB_CLASS_NAME;
    }
    public String getTRIGGER_NAME() {
        return TRIGGER_NAME;
    }
    public void setTRIGGER_NAME(String tRIGGER_NAME) {
        TRIGGER_NAME = tRIGGER_NAME;
    }
    public String getTRIGGER_GROUP() {
        return TRIGGER_GROUP;
    }
    public void setTRIGGER_GROUP(String tRIGGER_GROUP) {
        TRIGGER_GROUP = tRIGGER_GROUP;
    }
    public BigInteger getREPEAT_INTERVAL() {
        return REPEAT_INTERVAL;
    }
    public void setREPEAT_INTERVAL(BigInteger rEPEAT_INTERVAL) {
        REPEAT_INTERVAL = rEPEAT_INTERVAL;
    }
    public BigInteger getTIMES_TRIGGERED() {
        return TIMES_TRIGGERED;
    }
    public void setTIMES_TRIGGERED(BigInteger tIMES_TRIGGERED) {
        TIMES_TRIGGERED = tIMES_TRIGGERED;
    }
    public String getCRON_EXPRESSION() {
        return CRON_EXPRESSION;
    }
    public void setCRON_EXPRESSION(String cRON_EXPRESSION) {
        CRON_EXPRESSION = cRON_EXPRESSION;
    }
    public String getTIME_ZONE_ID() {
        return TIME_ZONE_ID;
    }
    public void setTIME_ZONE_ID(String tIME_ZONE_ID) {
        TIME_ZONE_ID = tIME_ZONE_ID;
    }
}

3.3 JobMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.quartz.demo.dao.JobAndTriggerMapper">


  <select id="getJobAndTriggerDetails" resultType="com.quartz.demo.model.JobAndTrigger">
    SELECT DISTINCT
    QRTZ_JOB_DETAILS.JOB_NAME ,
    QRTZ_JOB_DETAILS.JOB_GROUP ,
    QRTZ_JOB_DETAILS.JOB_CLASS_NAME ,
    QRTZ_TRIGGERS.TRIGGER_NAME ,
    QRTZ_TRIGGERS.TRIGGER_GROUP ,
    QRTZ_CRON_TRIGGERS.CRON_EXPRESSION ,
    QRTZ_CRON_TRIGGERS.TIME_ZONE_ID
    FROM
    QRTZ_JOB_DETAILS
    LEFT JOIN QRTZ_TRIGGERS ON QRTZ_TRIGGERS.TRIGGER_GROUP = QRTZ_JOB_DETAILS.JOB_GROUP
    LEFT JOIN QRTZ_CRON_TRIGGERS ON QRTZ_JOB_DETAILS.JOB_NAME = QRTZ_TRIGGERS.JOB_NAME
    AND QRTZ_TRIGGERS.TRIGGER_NAME = QRTZ_CRON_TRIGGERS.TRIGGER_NAME
    AND QRTZ_TRIGGERS.TRIGGER_GROUP = QRTZ_CRON_TRIGGERS.TRIGGER_GROUP
  </select>
</mapper>

3.4 html页面

放到resouces/static路径中

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>QuartzDemo</title>

    <!-- 引入样式 -->
    <link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    <script src="https://unpkg.com/element-ui/lib/index.js"></script>
    <script src="https://cdn.bootcss.com/vue-resource/1.5.0/vue-resource.js"></script>

    <style>
        #top {
            background:#20A0FF;
            padding:5px;
            overflow:hidden
        }
    </style>

</head>
<body>
<div id="test">

    <div id="top">
        <el-button type="text" @click="search" style="color:white">查询</el-button>
        <el-button type="text" @click="handleadd" style="color:white">添加</el-button>
        </span>
    </div>

    <br/>

    <div style="margin-top:15px">

        <el-table
                ref="testTable"
                :data="tableData"
                style="width:100%"
                border
        >
            <el-table-column
                    prop="job_NAME"
                    label="任务名称"
                    sortable
                    show-overflow-tooltip>
            </el-table-column>

            <el-table-column
                    prop="job_GROUP"
                    label="任务所在组"
                    sortable>
            </el-table-column>

            <el-table-column
                    prop="job_CLASS_NAME"
                    label="任务类名"
                    sortable>
            </el-table-column>

            <el-table-column
                    prop="trigger_NAME"
                    label="触发器名称"
                    sortable>
            </el-table-column>

            <el-table-column
                    prop="trigger_GROUP"
                    label="触发器所在组"
                    sortable>
            </el-table-column>

            <el-table-column
                    prop="cron_EXPRESSION"
                    label="表达式"
                    sortable>
            </el-table-column>

            <el-table-column
                    prop="time_ZONE_ID"
                    label="时区"
                    sortable>
            </el-table-column>

            <el-table-column label="操作" width="300">
                <template scope="scope">
                    <el-button
                            size="small"
                            type="warning"
                            @click="handlePause(scope.$index, scope.row)">暂停</el-button>

                    <el-button
                            size="small"
                            type="info"
                            @click="handleResume(scope.$index, scope.row)">恢复</el-button>

                    <el-button
                            size="small"
                            type="danger"
                            @click="handleDelete(scope.$index, scope.row)">删除</el-button>

                    <el-button
                            size="small"
                            type="success"
                            @click="handleUpdate(scope.$index, scope.row)">修改</el-button>
                </template>
            </el-table-column>
        </el-table>

        <div align="center">
            <el-pagination
                    @size-change="handleSizeChange"
                    @current-change="handleCurrentChange"
                    :current-page="currentPage"
                    :page-sizes="[10, 20, 30, 40]"
                    :page-size="pagesize"
                    layout="total, sizes, prev, pager, next, jumper"
                    :total="totalCount">
            </el-pagination>
        </div>
    </div>
    <el-dialog title="选择任务" :visible.sync="checkboxChange">
        <el-radio-group v-model="ruleForm.resource">
            <el-radio :label="3">Simple Trigger</el-radio>
            <el-radio :label="6">Cron Trigger</el-radio>
        </el-radio-group>
        <div slot="footer" class="dialog-footer">
            <el-button @click="checkboxChange = false">取 消</el-button>
            <el-button type="primary" @click="change">确 定</el-button>
        </div>
    </el-dialog>
    <el-dialog title="添加任务" :visible.sync="dialogFormVisibleChange" v-if="ruleForm.resource==3">
        <el-form :model="form" >
            <el-form-item label="任务名称" label-width="120px" style="width:35%">
                <el-input v-model="form.jobName" auto-complete="off"></el-input>
            </el-form-item>
            <el-form-item label="任务分组" label-width="120px" style="width:35%">
                <el-input v-model="form.jobGroup" auto-complete="off"></el-input>
            </el-form-item>
            <el-form-item label="多久之后执行" label-width="120px" style="width:35%">
                <el-input v-model="form.cronExpression" auto-complete="off"></el-input>

                <el-select v-model="value4"  placeholder="请选择">
                    <el-option
                            v-for="item in options"
                            :key="item.value"
                            :label="item.label"
                            :value="item.value">
                    </el-option>
                </el-select>
            </el-form-item>
        </el-form>
        <div slot="footer" class="dialog-footer">
            <el-button @click="dialogFormVisibleChange = false">取 消</el-button>
            <el-button type="primary" @click="addSimTir">确 定</el-button>
        </div>
    </el-dialog>
    <el-dialog title="添加任务" :visible.sync="dialogFormVisibleChange" v-if="ruleForm.resource==6">
        <el-form :model="form">
            <el-form-item label="任务名称" label-width="120px" style="width:35%">
                <el-input v-model="form.jobName" auto-complete="off"></el-input>
            </el-form-item>
            <el-form-item label="任务分组" label-width="120px" style="width:35%">
                <el-input v-model="form.jobGroup" auto-complete="off"></el-input>
            </el-form-item>
            <el-form-item label="表达式" label-width="120px" style="width:35%">
                <el-input v-model="form.cronExpression" auto-complete="off"></el-input>
            </el-form-item>
        </el-form>
        <div slot="footer" class="dialog-footer">
            <el-button @click="dialogFormVisibleChange = false">取 消</el-button>
            <el-button type="primary" @click="add">确 定</el-button>
        </div>
    </el-dialog>

    <el-dialog title="修改任务" :visible.sync="updateFormVisible">
        <el-form :model="updateform">
            <el-form-item label="表达式" label-width="120px" style="width:35%">
                <el-input v-model="updateform.cronExpression" auto-complete="off"></el-input>
            </el-form-item>
        </el-form>
        <div slot="footer" class="dialog-footer">
            <el-button @click="updateFormVisible = false">取 消</el-button>
            <el-button type="primary" @click="update">确 定</el-button>
        </div>
    </el-dialog>

</div>

<footer align="center">
    <p>&copy; Quartz 任务管理</p>
</footer>

<script>
    var vue = new Vue({
        el:"#test",
        data: {
            //表格当前页数据
            tableData: [],

            //请求的URL
            url:'job/queryJob',

            //默认每页数据量
            pagesize: 10,

            //当前页码
            currentPage: 1,

            //查询的页码
            start: 1,

            //默认数据总数
            totalCount: 1000,

            //添加对话框默认可见性
            dialogFormVisible: false,

            //修改对话框默认可见性
            updateFormVisible: false,
            //选择对话框
            dialogFormVisibleChange: false,

            checkboxChange:false,

            //提交的表单
            form: {
                jobName: '',
                jobGroup: '',
                cronExpression: '',
                timeType: ''
            },
            ruleForm: {
                resource: 3
            },

            updateform: {
                jobName: '',
                jobGroup: '',
                cronExpression: '',
            },
            options: [{
                value: 1,
                label: '年'
            }, {
                value: 2,
                label: '月'
            }, {
                value: 3,
                label: '天'
            }, {
                value: 4,
                label: '小时'
            }, {
                value: 5,
                label: '分钟'
            }, {
                value: 6,
                label: '周'
            },{
              value: 7,
              label: '秒'
             }],
            value4: ''
        },
        methods: {

            //从服务器读取数据
            loadData: function(pageNum, pageSize){
                this.$http.get('job/queryJob?' + 'pageNum=' +  pageNum + '&pageSize=' + pageSize).then(function(res){
                    console.log(res)
                    this.tableData = res.body.JobAndTrigger.list;
                    this.totalCount = res.body.number;
                },function(){
                    console.log('failed');
                });
            },

            //单行删除
            handleDelete: function(index, row) {
                this.$http.post('job/deleteJob',{"jobClassName":row.job_NAME,"jobGroupName":row.job_GROUP},{emulateJSON: true}).then(function(res){
                    this.loadData( this.currentPage, this.pagesize);
                },function(){
                    console.log('failed');
                });
            },

            //暂停任务
            handlePause: function(index, row){
                this.$http.post('job/pauseJob',{"jobClassName":row.job_NAME,"jobGroupName":row.job_GROUP},{emulateJSON: true}).then(function(res){
                    this.loadData( this.currentPage, this.pagesize);
                },function(){
                    console.log('failed');
                });
            },

            //恢复任务
            handleResume: function(index, row){
                this.$http.post('job/resumeJob',{"jobClassName":row.job_NAME,"jobGroupName":row.job_GROUP},{emulateJSON: true}).then(function(res){
                    this.loadData( this.currentPage, this.pagesize);
                },function(){
                    console.log('failed');
                });
            },

            //搜索
            search: function(){
                this.loadData(this.currentPage, this.pagesize);
            },

            //弹出对话框
            handleadd: function(){
                this.checkboxChange = true;
            },
            change: function(){
                this.dialogFormVisibleChange = true;
            },

            //添加
            add: function(){
                this.$http.post('job/addJob',{"jobClassName":this.form.jobName,"jobGroupName":this.form.jobGroup,"cronExpression":this.form.cronExpression}).then(function(res){
                    this.loadData(this.currentPage, this.pagesize);
                    this.dialogFormVisibleChange = false;
                    this.checkboxChange = false;
                },function(){
                    console.log('failed');
                });
            },
            addSimTir: function () {
                console.log(this.value4)
                this.$http.post('job/addJob',{"jobClassName":this.form.jobName,"jobGroupName":this.form.jobGroup,"cronExpression":this.form.cronExpression,
                    "timeType":this.value4}).then(function(res){
                    this.loadData(this.currentPage, this.pagesize);
                    this.dialogFormVisibleChange = false;
                    this.checkboxChange = false;
                },function(){
                    console.log('failed');
                });
            },

            //更新
            handleUpdate: function(index, row){
                console.log(row)
                this.updateFormVisible = true;
                this.updateform.jobName = row.job_CLASS_NAME;
                this.updateform.jobGroup = row.job_GROUP;
            },

            //更新任务
            update: function(){
                this.$http.post
                ('job/rescheduleJob',
                    {"jobClassName":this.updateform.jobName,
                        "jobGroupName":this.updateform.jobGroup,
                        "cronExpression":this.updateform.cronExpression
                    },{emulateJSON: true}
                ).then(function(res){
                    this.loadData(this.currentPage, this.pagesize);
                    this.updateFormVisible = false;
                },function(){
                    console.log('failed');
                });

            },

            //每页显示数据量变更
            handleSizeChange: function(val) {
                this.pagesize = val;
                this.loadData(this.currentPage, this.pagesize);
            },

            //页码变更
            handleCurrentChange: function(val) {
                this.currentPage = val;
                this.loadData(this.currentPage, this.pagesize);
            },

        },


    });

    //载入数据
    vue.loadData(vue.currentPage, vue.pagesize);
</script>

</body>
</html>

3.5 页面展示

此处添加定时任务有点问题,因为添加的时候传入的是Job的className,手动输入可能会报错,此处有待完善(主要是不太会前端),仅供参考,建议页面只提供查询功能(具体看业务需求)

参考链接

万字长文简单明了的介绍xxl-job以及quartz_quartz和xxl-job_码农飞哥的博客-CSDN博客
SpringBoot整合Quartz_桐花思雨的博客-CSDN博客
Spring Boot项目配置Druid数据源(druid-spring-boot-starter)_springboot配置druid数据源_Hello姜先森的博客-CSDN博客SpringBoot 整合QUARTZ 并嵌入可视化界面_java quartz 可视化_weixin_39477597的博客-CSDN博客

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值