本文已录入至我的Github库房DayDayUP:github.com/RobodLee/DayDayUP,欢迎Star

⭐⭐⭐⭐⭐转载请注明出处:blog.csdn.net/weixin_4346…

前语

最近学习了守时使命相关的内容,了解了下Quartz结构,可是原生的一些API用起来不太便利,所以依照我自己的运用场景做了一些封装。这篇文章就带小伙伴们了解一下Quartz的根本运用。包括根本概念以及怎样动态地对守时使命进行CRUD,而且怎样完成守时使命的耐久化以及使命康复。

一、Quartz的根本运用

Quartz 是一个开源的作业调度结构。在运用这个结构之前,咱们需求知道几个根本的概念JobTrigger以及Schedule

Job和JobDetail

既然是作业调度,那么必定要有作业呀,这个作业便是Job。在界说咱们自己的Job的时分,只需求完成Job接口,然后在execute办法里编写详细的事务逻辑即可。也能够承继QuartzJobBean类并重写executeInternal办法,因为QuartzJobBean类也是完成了Job接口。

public class TemplateJob implements Job {
​
  @Override
  public void execute(JobExecutionContext context) throws JobExecutionException {
    // 欢迎重视我的微信大众号:Robod
   }
​
}

JobDetail为Job实例提供了许多设置特点,以及JobDataMap成员变量特点,它用来存储特定Job实例的状况信息,调度器需求凭借Jobdetail对象来增加Job实例。

Trigger

有了使命之后,就该设置使命的触发事件了,在Quartz中运用Trigger来描绘触发Job履行的时刻触发规矩。一个Job能够增加多个Trigger,可是一个Trigger只能绑定一个Job。

Quartz入门,看这篇就够了

Quartz中一共有四种Trigger:

  • SimpleTrigger:这种触发器能够在给守时刻触发作业,而且可选择以指定的时刻距离重复。
  • CronTrigger:用过守时使命的小伙伴应该会猜到这个是干什么的吧。这个触发器能够设置一个Cron表达式,指定使命的履行周期。
  • CalendarIntervalTrigger:用于根据重复的日历时刻距离触发。
  • DailyTimeIntervalTrigger:用于根据每天重复的时刻距离触发使命的触发器。

后面两种我并没有用过,所以下文的介绍首要基于前两种。后两种感兴趣的小伙伴能够自行研讨。

Schedule

Schedule所扮演的是一个履行者的人物,将JobDetail和Trigger注册到Schedule中,它就会依照指定的规矩去履行使命。

界说一个守时使命

假如是Springboot的项目,增加如下依靠即可:

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

界说使命分为三步:界说一个JobDetail界说一个Trigger运用Scheduler去履行使命

JobDetail jobDetail = JobBuilder.newJob(TemplateJob.class).withIdentity("使命名", "使命组名").build();
SimpleTrigger trigger = (SimpleTrigger) TriggerBuilder.newTrigger()
     .withIdentity("触发器名", "触发器组名")
     .startNow()
     .build();
Scheduler scheduler = new StdSchedulerFactory().getScheduler();
scheduler.scheduleJob(jobDetail, trigger);
scheduler.start();

上面这段代码中,将触发器设为startNow,也便是当即履行。也能够运用startAt以及endAt办法设置开端时刻以及完毕时刻等。

关于SimpleTrigger的详细用法能够参阅这个链接:xuzongbao.gitbooks.io/quartz/cont…

在Job中获取自界说参数

在实践的运用过程中,咱们或许需求在创立一个Job时指定一些参数用于详细的事务场景,就能够凭借JobDataMap。比方指定一个时刻给某个人发送奖品,那么在创立使命时就需求用户id,奖品称号,奖品id等信息。咱们就能够在界说JobDetail时,运用usingJobData办法设置一些参数,或许运用setJobData办法将界说好的jobDetail填入进去。

JobDetail jobDetail = JobBuilder.newJob(TemplateJob.class)
     .usingJobData("userId", "VIP2345678")
     .usingJobData("awardName", "100元优惠券")
     .usingJobData("awardId", "YHQ675567687765")
        .usingJobData("awardValue",6.6)
     .withIdentity("使命名", "使命组名")
     .build();
//-----------或许---------------
JobDataMap jobDataMap = new JobDataMap();
jobDataMap.put("userId", "VIP5465756453");
jobDataMap.put("awardName", "100元优惠券");
jobDataMap.put("awardId", "YHQ67354747443");
jobDataMap.put("awardValue",6.6);
JobDetail jobDetail2 = JobBuilder.newJob(TemplateJob.class)
     .setJobData(jobDataMap)
     .withIdentity("使命名", "使命组名")
     .build();

JobDataMap就能够将它理解为HashMap,用法都是类似的。然后在Job中就能够获取到JobDataMap,然后拿到相应的参数:

public class AwardJob implements Job {
​
  @Override
  public void execute(JobExecutionContext context) throws JobExecutionException {
    JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
    String userId = jobDataMap.getString("userId");
    String awardName = jobDataMap.getString("awardName");
    String awardId = jobDataMap.getString("awardId");
    double awardValue = jobDataMap.getDouble("awardValue");
    //…………发奖品…………
   }
​
}

二、Quartz东西类完成守时使命的动态CRUD

参阅:blog.csdn.net/u010377605/…

上一节中说到界说一个守时使命要分为三步,可是在一个项目中必然会在多处都用到守时使命,假如每次界说都要写这样一段代码明显不行优雅。为了便利运用,能够写一个通用的东西类去完成守时使命的CRUD。

每个Job的jobName都必须是仅有的,建议运用和事务相关的主键id作为jobName。这样即能够保证仅有性,也能够经过jobName判别这个某个守时使命详细是做什么操作的,还能够在Job的execute办法中获取到这个主键id而履行相关操作,就不必额定传递参数了。比方订票体系中在发车前给用户发短信,就能够将订单的id作为jobName。

JobDetail和Trigger还需求指定一个分组,一个项目中运用到守时使命的分组应该是固定数量的,界说一个枚举类将需求用到的分组称号放在里边,在不同的事务场景下创立守时使命时选用不同的分组即可,需求额定的再往里边增加就行。

@Getter
@AllArgsConstructor
public enum QuartzGroupEnum {
​
  T1( "测验分组1"),
  T2("测验分组2");
​
  private final String value;
​
}

增加一个守时使命

@Component
public class QuartzUtil {
​
  private static final SchedulerFactory SCHEDULER_FACTORY = new StdSchedulerFactory();
​
  @Autowired
  private QuartzService quartzService;
​
  /**
   * 增加一个守时使命
   *
   * @param name    使命名。每个使命仅有,不能重复。便利起见,触发器名也设为这个
   * @param group   使命分组。便利起见,触发器分组也设为这个
   * @param jobClass  使命的类类型  eg:TemplateJob.class
   * @param startTime 使命开端时刻。传null便是当即开端
   * @param endTime  使命完毕时刻。假如是一次性使命或永久履行的使命就传null
   * @param cron    时刻设置表达式。传null便是一次性使命
   */
  public boolean addJob(String name, String group, Class<? extends Job> jobClass,
            LocalDateTime startTime, LocalDateTime endTime, String cron, JobDataMap jobDataMap) {
    try {
      // 第一步: 界说一个JobDetail
      JobDetail jobDetail = JobBuilder.newJob(jobClass).
          withIdentity(name, group).setJobData(jobDataMap).build();
      // 第二步: 设置触发器
      TriggerBuilder<Trigger> triggerBuilder = newTrigger();
      triggerBuilder.withIdentity(name, group);
      triggerBuilder.startAt(toStartDate(startTime));
      triggerBuilder.endAt(toEndDate(endTime)); //设为null则表明不会中止
      if (StrUtil.isNotEmpty(cron)) {
        triggerBuilder.withSchedule(CronScheduleBuilder.cronSchedule(cron));
       }
      Trigger trigger = triggerBuilder.build();
      //第三步:调度器设置
      Scheduler scheduler = SCHEDULER_FACTORY.getScheduler();
      scheduler.scheduleJob(jobDetail, trigger);
      if (!scheduler.isShutdown()) {
        scheduler.start();
       }
     } catch (Exception e) {
      e.printStackTrace();
      return false;
     }
    return true;
   }
​
  private static Date toEndDate(LocalDateTime endDateTime) {
    // 完毕时刻能够为null,所以endDateTime为null,直接回来null即可
    return endDateTime != null ?
        DateUtil.date(endDateTime) : null;
   }
​
  private static Date toStartDate(LocalDateTime startDateTime) {
    // startDateTime为空时回来当时时刻,表明当即开端
    return startDateTime != null ?
        DateUtil.date(startDateTime) : new Date();
   }
​
}

在需求增加守时使命的地方,咱们只需求调用这个办法,将指定的几个参数传入进去,就能够界说并开端一个守时使命了。现在咱们只需求重视怎样在Job中编写自己的事务代码而不需求去关怀怎样创立一个守时使命了。

在界说触发器时,假如startTime参数传过来为null的话,就表明是当即履行,那么就在startAt中将现在的时刻传入。不必判别是应该用startAt仍是startNow,因为从源码中能够看到,startNow办法也是将时刻设为现在。

Quartz入门,看这篇就够了

这儿有一个地方需求留意,在界说触发器时,写的是Trigger而不是SimpleTrigger或许CronTrigger。这是因为我想用这个办法去增加一个一次性使命或许周期性使命,这样写的话,因为Java多态的特点,假如不指定cron,在运行时便是自动转型为SimpleTrigger,指定了cron后,运行时就会自动转型为CronTrigger。这样咱们就不必关怀是该用SimpleTrigger仍是CronTrigger了。这一点需求留意,因为在下面的小节中会用到这个常识点。

触发器设置时刻用的是Date,可是我平时用LocalDateTime比较多,所以在传参时我选择运用LocalDateTime然后调用一个办法进行转化。

修正一个守时使命

/**
 * 修正一个使命的开端时刻、完毕时刻、cron。不改的就传null
 *
 * @param name     使命名。每个使命仅有,不能重复。便利起见,触发器名也设为这个
 * @param group     使命分组。便利起见,触发器分组也设为这个
 * @param newStartTime 新的开端时刻
 * @param newEndTime  新的完毕时刻
 * @param cron     新的时刻表达式
 */
public boolean modifyJobTime(String name, String group, LocalDateTime newStartTime,
             LocalDateTime newEndTime, String cron) {
  try {
    Scheduler scheduler = SCHEDULER_FACTORY.getScheduler();
    TriggerKey triggerKey = TriggerKey.triggerKey(name, group);
    Trigger oldTrigger = scheduler.getTrigger(triggerKey);
    if (oldTrigger == null) {
      return false;
     }
    TriggerBuilder<Trigger> triggerBuilder = newTrigger();
    triggerBuilder.withIdentity(name, group);
    if (newStartTime != null) {
      triggerBuilder.startAt(toStartDate(newStartTime));  // 使命开端时刻设定
     } else if (oldTrigger.getStartTime() != null) {
      triggerBuilder.startAt(oldTrigger.getStartTime()); //没有传入新的开端时刻就不变
     }
    if (newEndTime != null) {
      triggerBuilder.endAt(toEndDate(newEndTime));  // 使命完毕时刻设定
     } else if (oldTrigger.getEndTime() != null) {
      triggerBuilder.endAt(oldTrigger.getEndTime()); //没有传入新的完毕时刻就不变
     }
    if (StrUtil.isNotEmpty(cron)) {
      triggerBuilder.withSchedule(CronScheduleBuilder.cronSchedule(cron));
     } else if (oldTrigger instanceof CronTrigger) {
      String oldCron = ((CronTrigger) oldTrigger).getCronExpression();
      triggerBuilder.withSchedule(CronScheduleBuilder.cronSchedule(oldCron));
     }
    Trigger newTrigger = triggerBuilder.build();
    scheduler.rescheduleJob(triggerKey, newTrigger);  // 修正触发时刻
   } catch (Exception e) {
    e.printStackTrace();
    return false;
   }
    return true;
}

修正使命其实便是重新设置一个Trigger。先经过触发器名和触发器组名(也是使命名和使命组名)将旧的触发器 oldTrigger 查询出来,因为咱们会用到其间的一些信息,然后界说一个新的触发器,对于不需求修正的参数就继续运用 oldTrigger 中的。

这儿有段代码留意一下:

if (StrUtil.isNotEmpty(cron)) {
		triggerBuilder.withSchedule(CronScheduleBuilder.cronSchedule(cron));
} else if (oldTrigger instanceof CronTrigger) {
		String oldCron = ((CronTrigger) oldTrigger).getCronExpression();
    triggerBuilder.withSchedule(CronScheduleBuilder.cronSchedule(oldCron));
}

前面提过,设置了Cron便是CronTrigger,未设置便是SimpleTrigger。所以这儿就能够经过Trigger的类型来判别是哪一种,传过来的cron为null表明不需求修正,假如之前是一次性使命就不必管,假如之前便是周期性使命,那么必定是CronTrigger,在不需求修正的状况下,就将cron设为之前的。

撤销一个守时使命

public boolean cancelJob(String jobName, String groupName) {
    try {
        Scheduler scheduler = SCHEDULER_FACTORY.getScheduler();
        TriggerKey triggerKey = TriggerKey.triggerKey(jobName, groupName);
        scheduler.pauseTrigger(triggerKey); // 中止触发器
        scheduler.unscheduleJob(triggerKey);    // 移除触发器
        scheduler.deleteJob(JobKey.jobKey(jobName, groupName)); // 删去使命
    } catch (Exception e) {
        e.printStackTrace();
        return false;
    }
    //将数据库中的使命状况设为 撤销
    return true;
}

撤销就比较简略了,直接将触发器中止并移除,最终删去使命即可。

查询一切的守时使命

public List<QuartzEntity> getAllJobs() throws SchedulerException {
    Scheduler scheduler = SCHEDULER_FACTORY.getScheduler();
    List<QuartzEntity> quartzJobs = new ArrayList<>();
    try {
        List<String> triggerGroupNames = scheduler.getTriggerGroupNames();
        for (String groupName : triggerGroupNames) {
            GroupMatcher<TriggerKey> groupMatcher = GroupMatcher.groupEquals(groupName);
            Set<TriggerKey> triggerKeySet = scheduler.getTriggerKeys(groupMatcher);
            for (TriggerKey triggerKey : triggerKeySet) {
                Trigger trigger = scheduler.getTrigger(triggerKey);
                JobKey jobKey = trigger.getJobKey();
                JobDetail jobDetail = scheduler.getJobDetail(jobKey);
                //组装数据
                QuartzEntity entity = new QuartzEntity();
                entity.setJobName(jobDetail.getKey().getName());
                entity.setGroupName(jobDetail.getKey().getGroup());
                entity.setStartTime(LocalDateTimeUtil.of(trigger.getStartTime()));
                entity.setEndTime(LocalDateTimeUtil.of(trigger.getStartTime()));
                entity.setJobClass(jobDetail.getJobClass().getName());
                if (trigger instanceof CronTrigger) {
                    entity.setCron(((CronTrigger) trigger).getCronExpression());
                }
                entity.setJobDataMapJson(JSONUtil.toJsonStr(jobDetail.getJobDataMap()));
                quartzJobs.add(entity);
            }
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    return quartzJobs;
}

先获取一切的触发器组名,再遍历获取每个触发器组中的触发器Set调集,最终遍历触发器Set调集获取JobDetail信息,然后用一个QuartzEntity对象对数据进行封装回来,再将entity放入List中。

三、在Job中注入Bean

在履行详细的守时使命时,必定会用到相应的Service,可是经过@Autowired或许构造器注入的办法都会注入失败。能够经过一个东西类去完成在Job中注入Bean。在Service的完成类上一定要增加@Service(“xxxxService”)注解,否则会注入失败。

@Component
public class SpringContextJobUtil implements ApplicationContextAware {
    private static ApplicationContext context;
    @Override
    @SuppressWarnings("static-access")
    public void setApplicationContext(ApplicationContext context)
            throws BeansException {
        this.context = context;
    }
    public static Object getBean(String beanName) {
        return context.getBean(beanName);
    }
}

调用getBean办法就能够正常注入了。

public class TemplateJob implements Job {
    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        QuartzService quartzService = (QuartzService) SpringContextJobUtil.getBean("quartzService");
      	//…………欢迎重视我的微信大众号:Robod
    }
}

参阅这篇文章:www.jianshu.com/p/aff9199b4…

四、耐久化Job并完成程序发动时使命康复

当遇到更新版本等状况时,必定要将程序给停了,可是程序中止后那些还未开端或许没履行完的守时使命就没了。所以咱们需求将使命耐久化到数据库中,然后在程序发动时将这些使命进行康复。

Quartz入门,看这篇就够了

在数据库中增加一张表,用于存储Job的信息。

然后在QuartzUtil中界说一个 recoveryAllJob 办法用于康复守时使命:

public void recoveryAllJob() {
    List<QuartzEntity> tasks = quartzService.notStartOrNotEndJobs();
    if (tasks != null && tasks.size() > 0) {
        for (QuartzEntity task : tasks) {
            try {
                JobDataMap jobDataMap = JSONUtil.toBean(task.getJobDataMapJson(), JobDataMap.class);
                JobDetail jobDetail = JobBuilder.newJob((Class<? extends Job>) Class.forName(task.getJobClass()))
                        .withIdentity(task.getJobName(), task.getGroupName())
                        .setJobData(jobDataMap).build();
                TriggerBuilder<Trigger> triggerBuilder = TriggerBuilder.newTrigger();
                triggerBuilder.withIdentity(task.getJobName(), task.getGroupName());
                triggerBuilder.startAt(toStartDate(task.getStartTime()));
                triggerBuilder.endAt(toEndDate(task.getEndTime()));
                if (StrUtil.isNotEmpty(task.getCron())) {
                    triggerBuilder.withSchedule(CronScheduleBuilder.cronSchedule(task.getCron()));
                }
                Trigger trigger = triggerBuilder.build();
                Scheduler scheduler = SCHEDULER_FACTORY.getScheduler();
                scheduler.scheduleJob(jobDetail, trigger);
                if (!scheduler.isShutdown()) {
                    scheduler.start();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

首先从数据库中将需求康复的使命查询出来,然后遍历使命将其挨个创立出来。

然后在前面的CRUD办法中增加对数据库的一些操作,也别忘了在一次性使命的Job中履行完成后调用quartzService.modifyTaskStatus(jobName, “1”) 办法将使命的状况修正为已完成,否则程序发动后使命又康复过来了:

@Component
public class QuartzUtil {
    public boolean addJob(String name, String group, Class<? extends Job> jobClass,
                       LocalDateTime startTime, LocalDateTime endTime, String cron, JobDataMap jobDataMap) {
       	//…………
        //存储到数据库中
        QuartzEntity entity = new QuartzEntity();
        entity.setJobName(name);
        entity.setGroupName(group);
        entity.setStartTime(startTime != null ? startTime : LocalDateTime.now());
        entity.setEndTime(endTime);
        entity.setJobClass(jobClass.getName());
        entity.setCron(cron);
        entity.setJobDataMapJson(JSONUtil.toJsonStr(jobDataMap));
        entity.setStatus("0");
        quartzService.save(entity);
        return true;
    }
    public boolean modifyJobTime(String name, String group, LocalDateTime newStartTime,
                             LocalDateTime newEndTime, String cron) {
        //…………
        // 修正数据库中的记录
        QuartzEntity entity = new QuartzEntity();
        entity.setJobName(name);
        entity.setGroupName(group);
        if (newStartTime != null) {
            entity.setStartTime(newStartTime);
        }
        if (newEndTime != null) {
            entity.setEndTime(newEndTime);
        }
        if (StrUtil.isNotEmpty(cron)) {
            entity.setCron(cron);
        }
        return quartzService.modifyJob(entity);
    }
    public boolean cancelJob(String jobName, String groupName) {
        //…………
        //将数据库中的使命状况设为 撤销
        return quartzService.modifyTaskStatus(jobName, "2");
    }
}

在保存和康复使命时,将jobDataMap以Json的办法进行存储

QuartzService中的代码便是一些根本的CRUD,没有什么好说的,就不在这儿进行阐明了,小伙伴们能够下载完好代码进行查看。(链接在文末)

那么有了康复办法后,怎样在程序发动时调用这个办法呢?

很简略,只需求修正发动类,让其完成ApplicationRunner接口并完成run办法,在run办法中调用康复办法即可。

@SpringBootApplication
@MapperScan("com.robod.quartzdemo.mapper")
public class QuartzDemoApplication implements ApplicationRunner {
    @Autowired
    private QuartzUtil quartzUtil;
    public static void main(String[] args) {
        SpringApplication.run(QuartzDemoApplication.class, args);
    }
    @Override
    public void run(ApplicationArguments args) throws Exception {
        quartzUtil.recoveryAllJob();
    }
}

这样在程序发动时,就会自动地调用recoveryAllJob办法去康复守时使命了。

五、小事例

现在经过一个详细的事例来简略模拟一下该怎样用。假设有这样一个场景:在火车票的订票体系中,在创立订单时设立一个守时使命,在发车前两个小时给乘客发送提醒搭车的短信,用户或许改签或许撤销订单,那么也应该同样的对守时使命进行修正。

先来看一下Service中是怎样运用QuartzUtil来操作守时使命的吧:

@Service("orderService")
public class OrderServiceImpl implements OrderService {
    @Autowired
    private QuartzUtil quartzUtil;
    @Override
    public String bookTicket(String userId, String ticketId) {
        //TODO 查询余票下订单等一些列操作
        Order order = new Order();
        //…………
        //创立一个守时使命
        LocalDateTime noticeTime = order.getDepartureTime().minusHours(2); //告诉时刻为发车前两小时
        quartzUtil.addJob(String.valueOf(order.getId()), QuartzGroupEnum.DEPARTURE_NOTICE.getValue(),
                DepartureNoticeJob.class, noticeTime, null, null, null);
        return "";
    }
    @Override
    public String rebook(Order order) {
        //TODO 修正订单等一系列操作
        //修正守时使命
        LocalDateTime noticeTime = order.getDepartureTime().minusHours(2); //告诉时刻为发车前两小时
        quartzUtil.modifyJob(String.valueOf(order.getId()), QuartzGroupEnum.DEPARTURE_NOTICE.getValue(),
                noticeTime, null, null);
        return "";
    }
    @Override
    public String cancelOrder(Order order) {
        //TODO 撤销订单等一系列操作
        //撤销守时使命
        quartzUtil.cancelJob(String.valueOf(order.getId()), QuartzGroupEnum.DEPARTURE_NOTICE.getValue());
        return "";
    }
}

首先将QuartzUtil给注入进来,然后调用其间相应的办法并将参数传入进去就能够操作守时使命了。这儿的JobName设置为了订单的id,一方面是为了防止重复,另一方面是免去了额定传参的麻烦,因为在Job中只需求用到订单id。假如只有订单id这一个参数不行用,那么再运用JobDataMap 设置自界说的参数也是OK的,详细用法前面也有阐明。GroupName则是在枚举类中界说的。

再来看一下DepartureNoticeJob中都做了些什么:

public class DepartureNoticeJob implements Job {
    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        QuartzService quartzService = (QuartzService) SpringContextJobUtil.getBean("quartzService");
        OrderService orderService = (OrderService) SpringContextJobUtil.getBean("orderService");
        String jobName = context.getJobDetail().getKey().getName();
        long orderId = Long.parseLong(jobName);
        // TODO 获取订单及用户信息,封装短信内容,调用短信发送模块发送短信
        quartzService.modifyTaskStatus(jobName, "1");
    }
}

在这个Job中,因为Job的jobName被设为的订单的id,所以咱们能够经过订单的id查询到订单以及用户的相关信息,然后封装短信的内容,进行发送短信操作。因为发短信是一次性使命,那么在完毕后应该修正这条使命的状况为已完毕,否则程序重启后这个使命又被康复了,又会给用户发送重复的信息。

QuartzUtil的运用大约便是这样,用起来仍是非常简略的。

总结

介绍到这儿就完毕了,我感觉这应该足够满意一些根本的事务场景了。小伙伴们能够将代码下载下来进行学习,然后根据自己的需求去做一些更改就能够用在自己的项目中了。代码我没有进行过多的测验,所以或许会有一些瑕疵,不过整体的思路应该是没问题的。

我也在Controller中增加了几个测验接口,并在ApiFox中增加了一些测验用例,将测验用例导入到ApiFox中就能够测验了。

Quartz入门,看这篇就够了

完好的代码以及相关文件:github.com/RobodLee/Qu…

⭐⭐⭐⭐⭐转载请注明出处:blog.csdn.net/weixin_4346…

本文已录入至我的Github库房DayDayUP:github.com/RobodLee/Da…,欢迎Star

假如您觉得文章还不错,请给我来个点赞收藏重视

学习更多编程常识,欢迎重视微信大众号『 R o b o d 』: