美文网首页
Quartz并发、Misfire、监听器上手实例

Quartz并发、Misfire、监听器上手实例

作者: 文景大大 | 来源:发表于2019-11-29 16:55 被阅读0次

一、 SpringBoot自带的定时任务

在采用SpringBoot框架的应用中,只需要简单的两个步骤就能实现定时任务的使用。

  1. 创建Job类
@Slf4j
@Component
public class JobTest {
    @Scheduled(cron = "0/5 * * * * ?")
    public void job1(){
        log.info("job1 running...");
    }

    @Scheduled(fixedRate = 2000)
    public void job2(){
        log.info("job2 running...");
    }
}
  1. 在启动类上启用调度策略
@EnableScheduling
@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

如上两个步骤,就能使得我们定义的两个JOB按照设置的频率执行了。

那么问题来了,既然springboot都自带了任务调度的功能,而且使用起来还那么简单,我们为啥还要使用Quartz呢?

原因当然是其自带的任务调用拥有如下的一些缺点:

  • cron表达式不支持年;
    在如上的例子中我们可以看到cron表达式只能写6位,不支持对年的设置,否则就会报错,而Quartz就可以设置年。
  • 所有JOB都是共享一个线程;
    为了证明这一点,我们需要对如上的例子做下更改,我们假设job1需要执行10秒,然后job2还是按照固定频率每2秒执行一次。
    @Scheduled(cron = "0/5 * * * * ?")
    public void job1() throws InterruptedException {
        log.info("job1 running...");
        Thread.sleep(10000);
        log.info("job1 end...");
    }

    @Scheduled(fixedRate = 2000)
    public void job2(){
        log.info("job2 running...");
    }

然后会发现,job1运行的10秒期间内阻塞了job2的运行,等到job1运行完,job2才会一次性把错过的都补执行完。当然,也有别的方案可以弥补这个问题,比如给其配置线程池,但是会比较麻烦,不如Quartz来的简便,Quartz有默认线程数,省去了初学者配置。

  • 系统重启,任务丢失(待验证);
  • 不支持分布式任务调度(待验证);

二、使用Quartz任务调度框架

1.1 Quartz的基本要素介绍

  • Job,用来定义我们自己要执行的任务;
  • JobDetail,用来绑定具体的Job,支持多个JobDetail绑定同一个Job,实现同一个Job的并发。
  • Trigger,触发器用来定义我们的JobDetail以什么样的方式开始工作;
  • Scheduler,调度器,用来绑定JobDetail和Trigger,并控制任务的开始、暂停和终结。

1.2 最简单的一个入门案例

我们需要创建一个任务,每2秒钟控制台告诉我现在几点了。

import lombok.extern.slf4j.Slf4j;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import java.util.Date;

@Slf4j
public class HelloJob implements Job {

    @Override
    public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        log.info("开始执行时间是: {}", new Date());
    }
}
import lombok.extern.slf4j.Slf4j;
import org.quartz.*;
import org.quartz.impl.StdSchedulerFactory;

@Slf4j
public class JobMain {

    public static void main(String[] args) throws SchedulerException {
        // 创建JobDetail,用来绑定具体的Job
        JobDetail jd = JobBuilder.newJob(HelloJob.class).withIdentity("job1", "group1").build();

        // 创建Trigger,定义任务的调度形式
        SimpleScheduleBuilder ss = SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(2).repeatForever();
        CronScheduleBuilder cs = CronScheduleBuilder.cronSchedule("0/2 * * * * ?");
        Trigger trigger = TriggerBuilder.newTrigger().withIdentity("trigger1", "triggerGroup1")
                .startNow()
                .withSchedule(cs)
                .build();

        // 创建调度器
        SchedulerFactory schedulerFactory = new StdSchedulerFactory();
        Scheduler scheduler = schedulerFactory.getScheduler();
        // 执行-使用调度器调度trigger和job
        scheduler.scheduleJob(jd, trigger);
        scheduler.start();

        log.info("主线程结束");
    }

}

好了,如上就能完成我们的任务,每2秒钟在控制台打印出当前的日期时间了。

在这个例子中,有以下几点需要补充说明下:

  • JobDetail和Trigger中的withIdentity都不是必须的,不设置它们我们的任务照样能够按照要求运行,而设置它们是为了给它们一个身份标识,通过name和group来唯一确定它们。
  • SimpleScheduleBuilder和CronScheduleBuilder是两种触发器调度类型,在功能实现上是完全一样的。但是在一般开发中,CronScheduleBuilder更加常用一些,它更加地简便灵活,开发者也不用记住那么多的方法调用。
  • 当scheduler执行start之后,就会新起一个线程去执行我们的JOB,而当前的主线程就运行完毕了。

1.3 Job的并发

我们假设要执行的任务需要耗时5秒,但是任务本身是2秒触发一次的,那么问题来了,当任务第一次触发后,第二次触发是等第一次任务完成,还是不等它完成,按照原调度计划触发呢?

@Override
    public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        log.info("开始执行时间是: {}", new Date());
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.info("结束执行时间是: {}", new Date());
    }

我们将例子改造成如上之后,再来运行试下,输出如下:

开始执行时间是: Thu Nov 07 13:39:36 CST 2019
开始执行时间是: Thu Nov 07 13:39:38 CST 2019
开始执行时间是: Thu Nov 07 13:39:40 CST 2019
结束执行时间是: Thu Nov 07 13:39:41 CST 2019
开始执行时间是: Thu Nov 07 13:39:42 CST 2019
结束执行时间是: Thu Nov 07 13:39:43 CST 2019
............

实验结论就出来了,同一Job的后续执行开始时间是不会受到上一次执行是否完成影响的,也就是说默认情况下,同一个Job是可以并发执行的。

可我们想要后续任务执行必须等上一次执行完毕后才能开始怎么办呢?只需要禁止该JOB并发执行即可。

@Slf4j
@DisallowConcurrentExecution
public class HelloJob implements Job {
..........
}

再次执行,输出结果如下:

开始执行时间是: Thu Nov 07 14:54:12 CST 2019
结束执行时间是: Thu Nov 07 14:54:17 CST 2019
开始执行时间是: Thu Nov 07 14:54:17 CST 2019
结束执行时间是: Thu Nov 07 14:54:22 CST 2019
开始执行时间是: Thu Nov 07 14:54:22 CST 2019
结束执行时间是: Thu Nov 07 14:54:27 CST 2019
开始执行时间是: Thu Nov 07 14:54:27 CST 2019
..........

需要注意的是,我们禁止并发其实禁止的是JobDetail,一个具体的JobDetail才代表我们真正的任务,虽然@DisallowConcurrentExecution注解是加在Job类上的。

我们下面来一个例子,两个不同的JobDetail绑定同一个Job,且在Job类上标明禁止并发,那么这两个JobDetail会同时执行吗?

@Override
    public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        log.info("{} 开始执行时间是: {}", jobExecutionContext.getJobDetail().getKey().getName() , new Date());
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.info("{} 结束执行时间是: {}", jobExecutionContext.getJobDetail().getKey().getName() , new Date());
    }
public static void main(String[] args) throws SchedulerException {
        // 创建调度器Scheduler
        SchedulerFactory sf = new StdSchedulerFactory();
        Scheduler scheduler = sf.getScheduler();

        // 创建JobDetail,用来绑定具体的Job
        JobDetail jd1 = JobBuilder.newJob(HelloJob.class).withIdentity("job1", "group1").build();
        JobDetail jd2 = JobBuilder.newJob(HelloJob.class).withIdentity("job2", "group1").build();

        // 创建Trigger,定义任务的调度形式
        SimpleScheduleBuilder ss = SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(2).repeatForever();
        CronScheduleBuilder cs = CronScheduleBuilder.cronSchedule("0/2 * * * * ?");
        Trigger trigger1 = TriggerBuilder.newTrigger().withIdentity("trigger1", "triggerGroup1")
                .startNow()
                .withSchedule(cs)
                .build();
        Trigger trigger2 = TriggerBuilder.newTrigger().withIdentity("trigger2", "triggerGroup1")
                .startNow()
                .withSchedule(ss)
                .build();

        // 执行-使用调度器调度trigger和job
        scheduler.scheduleJob(jd1, trigger1);
        scheduler.scheduleJob(jd2, trigger2);
        scheduler.start();

        log.info("主线程结束");
    }

执行结果如下:

job2 开始执行时间是: Thu Nov 07 15:30:03 CST 2019
job1 开始执行时间是: Thu Nov 07 15:30:04 CST 2019
job2 结束执行时间是: Thu Nov 07 15:30:08 CST 2019
job2 开始执行时间是: Thu Nov 07 15:30:08 CST 2019
job1 结束执行时间是: Thu Nov 07 15:30:09 CST 2019
job1 开始执行时间是: Thu Nov 07 15:30:09 CST 2019
job2 结束执行时间是: Thu Nov 07 15:30:13 CST 2019
...........

如此,我们可以确定如上的结论,不同JobDetail的Job是可以并发执行的。

1.4 Job的Misfire

Misfire策略主要是为了解决我们的Job错过了某些执行时机该怎么处理的问题,是等待下一次时机呢还是补救所有错过的执行时机?

首先,我们需要了解misfireThreshold这个参数值,其默认值是60秒,表示,任务晚于原计划执行时间60秒之后,才判定该次执行是错过了,否则,就不算错过,任务立马开始执行。

为了验证这一点,我们需要更改实验示例,首先将任务的启动改为每10秒运行一次,然后通过JobDetail传入参数来决定Job每次执行需要耗费的时间:

JobDetail jd = JobBuilder.newJob(HelloJob.class).withIdentity("job1", "group1")
                .usingJobData("sleepTime",12).build();

然后在具体的Job中,每次执行都动态地更改执行耗费的时间,从而能让后续执行时能恢复计划,不再misfire。

@Slf4j
// 每次执行修改的参数sleepTime都能保留到下一次执行
@PersistJobDataAfterExecution
// 禁止单个JobDetail并发执行
@DisallowConcurrentExecution
public class HelloJob implements Job {
    @Override
    public void execute(JobExecutionContext jobExecutionContext) {
        log.info("开始执行时间是: {}", new Date());
        int sleepTime = (Integer) jobExecutionContext.getJobDetail().getJobDataMap().get("sleepTime");
        log.info("任务需要休眠{}秒",sleepTime);
        try {
            Thread.sleep(sleepTime * 1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        if(sleepTime > 3){
            sleepTime -= 3;
            jobExecutionContext.getJobDetail().getJobDataMap().put("sleepTime", sleepTime);
        }
    }
}

然后得到运行结果如下:

开始执行时间是: Thu Nov 14 21:56:30 CST 2019
任务需要休眠12秒
开始执行时间是: Thu Nov 14 21:56:42 CST 2019
任务需要休眠9秒
开始执行时间是: Thu Nov 14 21:56:51 CST 2019
任务需要休眠6秒
开始执行时间是: Thu Nov 14 21:57:00 CST 2019
任务需要休眠3秒
开始执行时间是: Thu Nov 14 21:57:10 CST 2019
任务需要休眠3秒
开始执行时间是: Thu Nov 14 21:57:20 CST 2019
任务需要休眠3秒
..........

我们可以看出来,第二次和第三次的执行都misfire了,但是因为没有超过60秒,所以都可以立即得到执行,后续第四次开始,就仍然按照原计划执行。

然后,我们再来了解Misfire超过默认60秒之后的策略,对于不同的Trigger是拥有不通的Misfire策略的,此处我们先只看CronTrigger的三种策略:

  • withMisfireHandlingInstructionFireAndProceed,这是默认的策略,我们可以不在代码中添加,也可以手动加上:
CronScheduleBuilder cs = CronScheduleBuilder.cronSchedule("10 * * * * ?")
.withMisfireHandlingInstructionFireAndProceed();

意思是,错过多少次就不去补救了,现在立马执行一次,后续则是按照原计划执行。
我们修改如上的例子,将任务改为每分钟的第10秒开始,并且传递到JobDetail中的初始sleepTime为223秒,然后每次执行耗费时间衰减3倍:

        if(sleepTime > 3){
            sleepTime /= 3;
            jobExecutionContext.getJobDetail().getJobDataMap()
.put("sleepTime", sleepTime);
        }

其执行结果为:

开始执行时间是: Thu Nov 14 22:23:10 CST 2019
任务需要休眠223秒
开始执行时间是: Thu Nov 14 22:26:53 CST 2019
任务需要休眠74秒
开始执行时间是: Thu Nov 14 22:28:07 CST 2019
任务需要休眠24秒
开始执行时间是: Thu Nov 14 22:28:31 CST 2019
任务需要休眠8秒
开始执行时间是: Thu Nov 14 22:29:10 CST 2019
任务需要休眠2秒
开始执行时间是: Thu Nov 14 22:30:10 CST 2019
任务需要休眠2秒
..........

可以看到,原本计划在22:24:10、22:25:10执行的全部misfire超过60秒,而计划在22:26:10执行的虽然没有misfire超过60秒,但会和之前所有misfire的任务合并成一个,在休眠结束后立即执行。后续就没有misfire超过60秒的任务了,因此就按照misfire小于60秒的机制进行处理。

  • withMisfireHandlingInstructionDoNothing,意思是,所有错过的都不去补救了,直接按照原计划执行。
    我们还是使用上面的示例,只不过更换策略为:
CronScheduleBuilder cs = CronScheduleBuilder.cronSchedule("10 * * * * ?")
.withMisfireHandlingInstructionDoNothing();

执行结果为:

开始执行时间是: Thu Nov 14 23:00:10 CST 2019
任务需要休眠223秒
开始执行时间是: Thu Nov 14 23:04:10 CST 2019
任务需要休眠74秒
开始执行时间是: Thu Nov 14 23:05:24 CST 2019
任务需要休眠24秒
开始执行时间是: Thu Nov 14 23:06:10 CST 2019
任务需要休眠8秒
开始执行时间是: Thu Nov 14 23:07:10 CST 2019
任务需要休眠2秒
..........

可以看到,计划于23:01:10、23:02:10、23:03:10执行的任务全部misfire了,任务休眠到23:03:53结束,此时虽然23:03:10misfire不超过60秒,但和前面misfire超过60秒的一样,全部被忽略,等到下一个计划时间23:04:10才重新执行。后续就没有misfire超过60秒的任务了,因此就按照misfire小于60秒的机制进行处理。

  • withMisfireHandlingInstructionIgnoreMisfires,意思是,所有错过的都需要按照顺序一个个补执行。
    我们还是使用上面的示例,只不过更换策略为:
CronScheduleBuilder cs = CronScheduleBuilder.cronSchedule("10 * * * * ?").withMisfireHandlingInstructionIgnoreMisfires();

执行结果为:

开始执行时间是: Thu Nov 14 23:19:10 CST 2019
任务需要休眠223秒
开始执行时间是: Thu Nov 14 23:22:53 CST 2019
任务需要休眠74秒
开始执行时间是: Thu Nov 14 23:24:07 CST 2019
任务需要休眠24秒
开始执行时间是: Thu Nov 14 23:24:31 CST 2019
任务需要休眠8秒
开始执行时间是: Thu Nov 14 23:24:39 CST 2019
任务需要休眠2秒
开始执行时间是: Thu Nov 14 23:24:41 CST 2019
任务需要休眠2秒
开始执行时间是: Thu Nov 14 23:25:10 CST 2019
任务需要休眠2秒
..........

可以看出,原计划23:20:10、23:21:10、23:22:10全部misfire了,此时总计misfire3个任务;然后等任务休眠到23:22:53结束时,立即补充执行一次,然后23:23:10的任务misfire,此时总计misfire3个任务;等任务休眠到23:24:07结束时,再立即补充执行一次,然后23:24:10的任务misfire,此时总计misfire3个任务;然后在23:24:31、23:24:39、23:24:41补充执行了3次,此时已经没有misfire的任务了,然后再按照原计划执行。

1.5 监听器的使用

监听器主要分为Job监听器、Trigger监听器和Scheduler监听器,分别用来监听各自事件触发或者生命周期的,从而能在特定的事件前后做一些事情,比如做消息通知、执行记录等。

  • Job监听器
    我们先创建一个Job监听器:
@Slf4j
public class HelloListener implements JobListener {
    @Override
    public String getName() {
        String listenerName = this.getClass().getSimpleName();
        log.info("当前监听器的名字是:{}", listenerName);
        return this.getClass().getSimpleName();
    }

    @Override
    public void jobToBeExecuted(JobExecutionContext jobExecutionContext) {
        String jobName = jobExecutionContext.getJobDetail().getKey().getName();
        log.info("{}将要被执行了...", jobName);
    }

    @Override
    public void jobExecutionVetoed(JobExecutionContext jobExecutionContext) {
        String jobName = jobExecutionContext.getJobDetail().getKey().getName();
        log.info("{}被取消执行了...", jobName);
    }

    @Override
    public void jobWasExecuted(JobExecutionContext jobExecutionContext, JobExecutionException e) {
        String jobName = jobExecutionContext.getJobDetail().getKey().getName();
        log.info("{}已经执行完毕!!!", jobName);
    }
}

然后在scheduler中注册该监听器:

// 创建一个全局的Job监听器
scheduler.getListenerManager()
.addJobListener(new HelloListener(), EverythingMatcher.allJobs());

全局监听器的意思,就是无论多少Job,它们有几个分组,全部都会使用该监听器进行事件触发。

如果我们只是想监听某个特定的Job,可以这么注册监听器:

// 通过Job的name和groupName来唯一绑定一个Job
scheduler.getListenerManager().addJobListener(new HelloListener()
, KeyMatcher.keyEquals(JobKey.jobKey("byeJob", "group2")));

如果我们只是想监听某个分组的所有Job,可以这么注册监听器:

// 通过分组的名称来绑定某一组或者多组的Job
scheduler.getListenerManager().addJobListener(new HelloListener()
, GroupMatcher.jobGroupEquals("group1"));

如果我们既想绑定某组Job,又想绑定其他组中的某个Job,可以这样注册:

scheduler.getListenerManager().addJobListener(new HelloListener()
, OrMatcher.or(GroupMatcher.jobGroupEquals("group1")
, KeyMatcher.keyEquals(JobKey.jobKey("byeJob", "group2"))));
  • Trigger监听器
    大体用法同JobListener,我们简单过一下,先创建一个TriggerListener:
public class HelloTrigger implements TriggerListener {
    @Override
    public String getName() {
        String triggerName = this.getClass().getSimpleName();
        log.info("当前触发器监听器的名字是:{}", triggerName);
        return this.getClass().getSimpleName();
    }

    @Override
    public void triggerFired(Trigger trigger, JobExecutionContext context) {
        String jobName = context.getJobDetail().getKey().getName();
        String triggerName = trigger.getKey().getName();
        log.info("{}将要被{}执行了...", jobName, triggerName);
    }

    @Override
    public boolean vetoJobExecution(Trigger trigger, JobExecutionContext context) {
        String triggerName = trigger.getKey().getName();
        log.info("{}触发器vote...", triggerName);
        // 如果返回true,则Job会被取消执行,并且触发JobListener中的jobExecutionVetoed方法
        return false;
    }

    @Override
    public void triggerMisfired(Trigger trigger) {
        String triggerName = trigger.getKey().getName();
        log.info("{}触发器misfire了...", triggerName);
    }

    @Override
    public void triggerComplete(Trigger trigger, JobExecutionContext context, Trigger.CompletedExecutionInstruction triggerInstructionCode) {
        String jobName = context.getJobDetail().getKey().getName();
        String triggerName = trigger.getKey().getName();
        log.info("{}将{}执行完毕!!!", jobName, triggerName);
    }
}

然后在scheduler中注册该监听器:

scheduler.getListenerManager().addTriggerListener(new HelloTrigger(), EverythingMatcher.allTriggers());
  • Scheduler监听器
    大体用法同JobListener,当增删Job、Trigger或者Scheduler自己触发生命周期时可以使用:
@Slf4j
public class SchedulerListener implements org.quartz.SchedulerListener {
      // 内容较多,此处不再展示
}

然后注册该监听器:

scheduler.getListenerManager().addSchedulerListener(new SchedulerListener());

1.6 使用配置文件

如果我们没有创建自己的quartz.properties的话,就会默认使用jar包中的。并不是所有的配置项都需要我们进行配置。

这里是一份常用的配置列表:
Quartz配置文件常用配置项
更加详细的配置可以参考:
Quartz配置参考

1.7 SpringBoot整合Quartz的最佳实践

如果按照上面的示例使用Quartz的话,将会存在如下问题:

  • WEB应用打包部署后如何启动所有的JOB?
  • 如何管理所有的JOBDetail和Trigger?
  • 如何管理Cron表达式?

如果使用SpringBoot的话,这些问题都可以得到解决,如下是一个实际开发中常用的案例。
首先,第一步我们需要引入springboot中关于quartz的启动器,之前引入的单独Quartz依赖可以不需要了。

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-quartz</artifactId>
        </dependency>

然后,我们创建需要的JOB

@Slf4j
public class HelloJob extends QuartzJobBean {
    @Override
    protected void executeInternal(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        log.info("{}开始执行时间是:{}", jobExecutionContext.getJobDetail().getKey().getName(), new Date());
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.info("{}结束执行时间是:{}", jobExecutionContext.getJobDetail().getKey().getName(), new Date());
    }
}
@Slf4j
public class ByeJob extends QuartzJobBean {
    @Override
    protected void executeInternal(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        log.info("{}开始执行时间是:{}", jobExecutionContext.getJobDetail().getKey().getName(), new Date());
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.info("{}结束执行时间是:{}", jobExecutionContext.getJobDetail().getKey().getName(), new Date());
    }
}

最后,我们需要创建一个配置类,来管理所有的JobDetail和Trigger。

@Configuration
@PropertySource("cron.properties")
public class QuartzConfig {
    @Value("${helloJob.cron}")
    private String helloJobCron;
    @Value("${byeJob.cron}")
    private String byeJobCron;

    // HelloJob的启动配置信息
    @Bean
    public JobDetail helloJobDetail(){
        return JobBuilder.newJob(HelloJob.class)
                .withIdentity("helloJob","myJobGroup")
                .storeDurably()
                .build();
    }

    @Bean
    public Trigger helloJobTrigger(){
        return TriggerBuilder.newTrigger()
                .forJob(helloJobDetail())
                .withIdentity("helloJobTrigger","myTriggerGroup")
                .startNow()
                .withSchedule(CronScheduleBuilder.cronSchedule(helloJobCron))
                .build();
    }
    //############################
    // ByeJob的启动配置信息
    @Bean
    public JobDetail byeJobDetail(){
        return JobBuilder.newJob(ByeJob.class)
                .withIdentity("byeJob","myJobGroup")
                .storeDurably()
                .build();
    }
    @Bean
    public Trigger byeJobTrigger(){
        return TriggerBuilder.newTrigger()
                .forJob(byeJobDetail())
                .withIdentity("byeJobTrigger","myTriggerGroup")
                .startNow()
                .withSchedule(CronScheduleBuilder.cronSchedule(byeJobCron))
                .build();
    }
}

其中,所有的cron表达式我们使用properties文件进行管理:

helloJob.cron=0/5 * * * * ?
byeJob.cron=0/10 * * * * ?

如果需要管理的JOB太多了,一个配置文件会显得内容很长,那么可以为每一个JOB创建一个单独的配置类,比如HelloJobConfig.javaByeJobConfig.java,然后将如上配置文件中各自的内容挪到各个单独的配置文件中即可。

如此,在我们需要更改某个JOB执行时间或者管理JOB的时候,就更加的方便了。

相关文章

网友评论

      本文标题:Quartz并发、Misfire、监听器上手实例

      本文链接:https://www.haomeiwen.com/subject/uhwmbctx.html