![v2-4511d48f14a349305d098fc99d3c9094_1440w.jpg?source=172ae18b](http://img-03.proxy.5ce.com/view/image?&type=2&guid=c643b479-3c2f-eb11-8da9-e4434bdf6706&url=https://pic1.zhimg.com/v2-4511d48f14a349305d098fc99d3c9094_1440w.jpg?source=172ae18b)
本场 Chat 是一个小实例,这是属于真实项目中的一个定时任务管理的一个小模块。通过集成 Spring Boot、MyBatis、Quartz 简单实现的一个定时任务动态管理的功能。整个整合过程包含如下内容:
- 整合 MyBatis
- 整合 Quartz
- 整合 Druid
- Spring 单元测试集成 H2 数据库
整合 MyBatis
添加依赖
<!-- MyBatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis-spring-boot-starter.version}</version>
</dependency>
<!-- MySQL -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
添加 application.properties 配置
主要添加数据库连接配置和 MyBatis 的 Mapper 的 XML 配置文件路径以及实体类的包。还有一些 MyBatis 的相关配置:MyBatis 相关配置参数参考。
# 配置数据库连接
spring.datasource.driver-class-name = com.mysql.jdbc.Driver
spring.datasource.url = jdbc:mysql://localhost:3306/taskmgr?useUnicode=true&characterEncoding=utf-8&useSSL=false
spring.datasource.username = root
spring.datasource.password = 123456
# MyBatis
mybatis.configuration.map-underscore-to-camel-case=true
mybatis.configuration.auto-mapping-unknown-column-behavior=warning
mybatis.configuration.use-generated-keys=true
mybatis.type-aliases-package=com.pingan.wechat.app.entity
mybatis.mapper-locations=classpath:mapper/*.xml
整合通用 Mapper 和分页插件
使用原始的 MyBatis 有个问题就是,每个实体类的通用 CURD 操作等都需要自己写 XML 配置文件,或者在对应的Mapper 文件中写对应的 @Select/@Insert 等注解来实现对应的功能。这是个特别耗时的重复工作。解决这个问题有两种方式:
- 使用 MyBatis Generator 自动生成对应的 XML 文件
- 集成通用 Mapper
在业务较简单的时候,通常使用不到太多的复杂查询,这个时候集成通用 Mapper 相对简单合适。通用 Mapper 的作者本身也有写两种方式的对比。
MyBatis 通用 Mapper3 文档
另外,为了方便使用支持物理分页,也需要集成分页插件 MyBatis_PageHelper。
添加依赖
<!-- mapper -->
<dependency>
<groupId>tk.mybatis</groupId>
<artifactId>mapper-spring-boot-starter</artifactId>
<version>${mapper-spring-boot-starter.version}</version>
</dependency>
<!-- pagehelper -->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>${pagehelper-spring-boot-starter.version}</version>
</dependency>
编写 CommonMapper 接口
编写基本的 CommonMapper
接口,继承通用 Mapper 的接口 Mapper<T>
、MySqlMapper<T>
、SelectByIdsMapper<T>
、DeleteByIdsMapper<T>
,其中有些接口只适用于特定的数据库,需要根据实际情况做调整。 其他的业务相关的 Mapper 则需要继承这个 CommonMapper<T>
接口。
对应 Service 层基本 Service 接口和实现,则根据自身需要考虑是否需要。
import tk.mybatis.mapper.common.Mapper;
import tk.mybatis.mapper.common.MySqlMapper;
import tk.mybatis.mapper.common.ids.DeleteByIdsMapper;
import tk.mybatis.mapper.common.ids.SelectByIdsMapper;
/**
* 支持单表CURD和批量(MYSQL)操作的通用Mapper.
*
* Created by vioao.
*/
public interface CommonMapper<T> extends Mapper<T>, MySqlMapper<T>, SelectByIdsMapper<T>, DeleteByIdsMapper<T> {
}
添加 application.properties 配置
# 这里配置自己写的基本的 CommonMapper
mapper.mappers=com.xx.xxx.app.mapper.CommonMapper
mapper.not-empty=false
mapper.identity=MYSQL
# PageHelper 插件配置
pagehelper.helperDialect=mysql
pagehelper.reasonable=true
pagehelper.supportMethodsArguments=true
pagehelper.params=count=countSql
整合 Quartz
整合 Quartz 时最主要会遇到两个问题:
- 实现了 Job 接口的 Quartz 任务类中无法注入使用 Spring 管理的 Bean
- 使用 Spring AOP 编程在实现了 Job 接口的 Quartz 任务类中无效,即 Quartz AOP 失效
其实上面两个问题的根本都是因为 Job 实例的创建和管理没有交给 Spring 来管理。下面给出可以解决上述两个问题的整合方式:
添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- quartz -->
<dependency>
<groupId>org.quartz-scheduler</groupId>
<artifactId>quartz</artifactId>
<version>${quartz.version}</version>
</dependency>
<dependency>
<groupId>org.quartz-scheduler</groupId>
<artifactId>quartz-jobs</artifactId>
<version>${quartz.version}</version>
</dependency>
编写自定义配置类和自定义 JobFactory
JobFactoy
是 Quartz 提供的一个接口,其作用是用于创建 Job 实例,对应的在 Spring Quartz 中的实现类有 AdaptableJobFactory
和 SpringBeanJobFactory
,其中 AdaptableJobFactory
继承于 SpringBeanJobFactory
,我们看下其源码:
public interface JobFactory {
Job newJob(TriggerFiredBundle bundle, Scheduler scheduler) throws SchedulerException;
}
public class AdaptableJobFactory implements JobFactory {
@Override
public Job newJob(TriggerFiredBundle bundle, Scheduler scheduler) throws SchedulerException {
try {
// 创建Job实例
Object jobObject = createJobInstance(bundle);
return adaptJob(jobObject);
}
catch (Exception ex) {
throw new SchedulerException("Job instantiation failed", ex);
}
}
/**
* 这里可以看到,Job 实例的创建都是通过 getJobClass().newInstance() 来创建的。
* 并没有对应的代理类的创建。所以使用 Spring AOP 编程的时候所有需要被代理的 Job 任务
* 实际上都不会有代理类生成,是无法使用 Spring AOP 编程的
*/
protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception {
return bundle.getJobDetail().getJobClass().newInstance();
}
protected Job adaptJob(Object jobObject) throws Exception {
if (jobObject instanceof Job) {
return (Job) jobObject;
}
else if (jobObject instanceof Runnable) {
return new DelegatingJob((Runnable) jobObject);
}
else {
throw new IllegalArgumentException("Unable to execute job class [" + jobObject.getClass().getName() +
"]: only [org.quartz.Job] and [java.lang.Runnable] supported.");
}
}
}
public class SpringBeanJobFactory extends AdaptableJobFactory implements SchedulerContextAware {
//此处省略部分代码 ...
@Override
protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception {
// 可以看到 SpringBeanJobFactory 中是使用父类 AdaptableJobFactory 的方法来创建 Job 实例的,所以也不会有代理类的创建
Object job = super.createJobInstance(bundle);
if (isEligibleForPropertyPopulation(job)) {
BeanWrapper bw = PropertyAccessorFactory.forBeanPropertyAccess(job);
MutablePropertyValues pvs = new MutablePropertyValues();
if (this.schedulerContext != null) {
pvs.addPropertyValues(this.schedulerContext);
}
pvs.addPropertyValues(bundle.getJobDetail().getJobDataMap());
pvs.addPropertyValues(bundle.getTrigger().getJobDataMap());
if (this.ignoredUnknownProperties != null) {
for (String propName : this.ignoredUnknownProperties) {
if (pvs.contains(propName) && !bw.isWritableProperty(propName)) {
pvs.removePropertyValue(propName);
}
}
bw.setPropertyValues(pvs);
}
else {
bw.setPropertyValues(pvs, true);
}
}
return job;
}
//此处省略部分代码 ...
}
可以发现现有的 Spring 中对 JobFactory
的实现类都无法实现我们的 AOP 编程需求,所以就需要自定义一个 JobFactory
实现类了。
自定义 JobFactory
实现类 CustomSpringBeanJobFactory
:
/**
* 自定义JobFactory,将创建Job实例的操作交给Spring管理.
*
* Created by vioao.
*/
public class CustomSpringBeanJobFactory extends AdaptableJobFactory {
@Autowired
private AutowireCapableBeanFactory beanFactory;
@Override
protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception {
Object jobInstance;
Class<? extends Job> jobClass = bundle.getJobDetail().getJobClass();
// 这里将Job的实例创建喝管理交给 Spring,
// 使用 Spring 的 beanFactory 去获取 Job 实例,
// 获取不到的话就交由 Spring 的 beanFactory 自动创建一个
// 并根据名称自动注入和检查依赖关系
// 这样的话 Job 中就可以实现自动注入和实现 AOP 编程
try {
jobInstance = beanFactory.getBean(jobClass);
} catch (Exception e) {
jobInstance = beanFactory.createBean(jobClass, AutowireCapableBeanFactory.AUTOWIRE_BY_NAME, true);
}
return jobInstance;
}
}
自定义 Quartz 配置:
/**
* Quartz 配置类.
*
* Created by vioao.
*/
@Configuration
public class QuartzConfiguration {
private static final String QUARTZ_CONFIG = "quartz.properties";
@Bean
public CustomSpringBeanJobFactory customSpringBeanJobFactory(){
return new CustomSpringBeanJobFactory();
}
@Bean
public SchedulerFactoryBean schedulerFactoryBean() {
SchedulerFactoryBean schedulerFactoryBean = new SchedulerFactoryBean();
// 配置使用自定义的 JobFactory
schedulerFactoryBean.setJobFactory(customSpringBeanJobFactory());
schedulerFactoryBean.setAutoStartup(true);
// 设置 Quartz 配置文件路径
schedulerFactoryBean.setConfigLocation(new ClassPathResource(QUARTZ_CONFIG));
return schedulerFactoryBean;
}
@Bean
public Scheduler scheduler() {
return schedulerFactoryBean().getScheduler();
}
}
实现动态定时任务管理(根据数据库的配置动态调整)
实现定时任务动态管理主要有两个点:
- 定时任务数据源从数据库中读取
- 服务启动时初始化并启动所有符合条件的定时任务
其关键就是设计定时任务的数据库表,将数据库的数据作为定时任务的数据来源,然后使用 Scheduler 的相关 API 重新设置对应的任务的调度。
1. 设计定时任务调度表并实现对应的 CURD
因为只是一个系统中的简单定时任务管理模块,所以设计比较简单,对应的 CURD 代码就不贴出来了。Entity 定义如下:
@Entity
@Daata
public class QuartzTaskEntity {
private Long id;
private String taskName;
private String taskClass;
private String taskGroup;
private Integer state;
private String cron;
}
2. 实现动态调度代码
/**
* 定时任务调度器.
*
* Created by vioao.
*/
@Component
public class TaskSchedule {
private static final Logger LOGGER = LoggerFactory.getLogger(TaskSchedule.class);
@Autowired
private Scheduler scheduler;
@Autowired
private QuartzTaskService quartzTaskService;
public ApiResult scheduleTask(QuartzTaskEntity task) {
boolean scheduled = false;
String msg = "Schedule Success!";
try {
Class<?> jobClass = Class.forName(task.getTaskClass());
if (Job.class.isAssignableFrom(jobClass)) {
JobDetail jobDetail = buildJobDetail(task, (Class<? extends Job>) jobClass);
Trigger trigger = buildTrigger(task);
scheduler.scheduleJob(jobDetail, trigger);
scheduled = true;
LOGGER.info(msg + "");
}
} catch (ClassNotFoundException e) {
msg = "Schedule Fail! Class not found!";
LOGGER.error(msg + "Task: " + task);
} catch (SchedulerException e) {
msg = "Schedule Fail! " + e.getMessage();
LOGGER.error(msg + "Task: " + task, e);
}
return ApiResult.build(scheduled, msg, task);
}
// 此处省略部分代码...
public ApiResult rescheduleTask(QuartzTaskEntity task) {
boolean flag = false;
String msg = "Reschedule task Success!";
try {
CronTrigger oldTrigger = (CronTrigger) scheduler.getTrigger(getTriggerKey(task));
if (!oldTrigger.getCronExpression().equalsIgnoreCase(task.getCron())) {
Trigger newTrigger = buildTrigger(task);
scheduler.rescheduleJob(getTriggerKey(task), newTrigger);
}
flag = true;
} catch (SchedulerException e) {
msg = "Reschedule task Fail! " + e.getMessage();
LOGGER.error(msg + "Task: " + task, e);
}
return ApiResult.build(flag, msg, task);
}
public synchronized void startSchedule() {
try {
if (scheduler.isShutdown()) {
scheduler.start();
}
} catch (SchedulerException e) {
LOGGER.error("Start scheduler fail.", e);
}
}
// 此处省略部分代码...
private JobDetail buildJobDetail(QuartzTaskEntity task, Class<? extends Job> clazz) {
return JobBuilder.newJob(clazz).withIdentity(getTaskName(task), getTaskGroup(task)).build();
}
private Trigger buildTrigger(QuartzTaskEntity task) {
return TriggerBuilder.newTrigger().withIdentity(getTaskName(task), getTaskGroup(task)).startNow().withSchedule(CronScheduleBuilder.cronSchedule(task.getCron())).build();
}
private JobKey getJobKey(QuartzTaskEntity task) {
return JobKey.jobKey(getTaskName(task), getTaskGroup(task));
}
private TriggerKey getTriggerKey(QuartzTaskEntity task) {
return TriggerKey.triggerKey(getTaskName(task), getTaskGroup(task));
}
private String getTaskName(QuartzTaskEntity task) {
return StringUtils.isEmpty(task.getTaskName()) ? task.getTaskClass() : task.getTaskName();
}
private String getTaskGroup(QuartzTaskEntity task) {
return StringUtils.isEmpty(task.getTaskGroup()) ? Scheduler.DEFAULT_GROUP : task.getTaskGroup();
}
}
3. 启动时初始化所有定时任务
/**
* 定时任务启动器:应用启动时启动所有有效的定时任务.
*
* Created by vioao.
*/
@Component
public class QuartzTaskStarter implements ApplicationListener<ContextRefreshedEvent> {
private static final Logger LOGGER = LoggerFactory.getLogger(QuartzTaskStarter.class);
@Autowired
private TaskSchedule taskSchedule;
@Autowired
private QuartzTaskService quartzTaskService;
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
try {
List<QuartzTaskEntity> tasks = quartzTaskService.selectAllValidTask();
for (QuartzTaskEntity task : tasks) {
taskSchedule.scheduleTask(task);
}
taskSchedule.startSchedule();
} catch (Exception e) {
LOGGER.error("Start all valid task fail.", e);
}
}
}
添加 quartz.properties 配置文件
# 简单实用内存类存储任务状态
# 更多配置使用请访问 Quartz 官网
org.quartz.scheduler.instanceName = MyScheduler
org.quartz.threadPool.threadCount = 3
org.quartz.jobStore.class = org.quartz.simpl.RAMJobStore
处理多机部署的问题
Quartz 本身有个分布式集群的方案,但是这个方案要求创建多张 Quartz 相关的表,且这边需求也并不是非常复杂的定时任务管理,不需要分布式集群的方式,只需要满足一个任务在对应的配置时间周期内只执行一次就可以了。所以这边不打算使用其自带的分布式解决方案,如果需要分布式解决方案的可以自己去看看 Quartz 官方文档,或者是使用开源的定时任务管理系统,如 Elastic-Job。
对于自身简单的需求,这边起初有两个方案:
1. 根据时间周期配置以及部署的机器数量,将实际的执行时间按照机器数量均分,错开每一台机子的执行时间。
- 优点:无需引入第三方组件,实现简单
- 缺点:需要预先知道部署的机器数量,部署实例数动态拓展的话则会有问题
2. 多机执行时,利用分布式锁,使得每次执行就只有一个实例真正获得执行权;删除任务时,通过消息订阅删除所有机子的 Job。
- 优点:和部署实例数量无关,方案通用
- 缺点:需要引入第三方组件
由于这边项目本身就有使用 Redis,而 Redis 也满足实现分布式锁和简单消息发布订阅功能的需求,所以这边选择了第二种方案。该方案的实现主要有两个重点:
1. 实现基于 Redis 的分布式锁
这个的实现同样也是有两个点需要关注:
- 解锁时确保不同线程不会释放掉其他线程的锁
- 锁超时后的处理(通常采用续租的模式来处理)
具体的实现这里就不贴出来了,已有开源的包可以支持到如上的功能,详见 Redisson,建议去看看他的源码实现学习一下。这东西面试的时候也经常会问。
2. 删除任务时,通过 Redis 的发布订阅功能通知集群中的每一台实例
这个 Easy,就不细说了,利用 Redis PubSub 功能做就好。
整合 Druid
现在 Druid 官方已经给出了一个 starter 的依赖。整合 Druid 已经非常简单了,只需要添加对应的依赖和配置即可。
添加 Druid 依赖
<!-- mysql -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>${druid-spring-boot-starter.version}</version>
</dependency>
添加 application.properties 配置
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
# druid
# see more config about druid: https://github.com/alibaba/druid/tree/master/druid-spring-boot-starter
spring.datasource.druid.initial-size=5
spring.datasource.druid.max-active=20
spring.datasource.druid.min-idle=5
spring.datasource.druid.pool-prepared-statements=true
spring.datasource.druid.max-pool-prepared-statement-per-connection-size=20
spring.datasource.druid.max-wait=60000
spring.datasource.druid.time-between-eviction-runs-millis=60000
spring.datasource.druid.min-evictable-idle-time-millis=300000
spring.datasource.druid.validation-query=SELECT 1 FROM DUAL
spring.datasource.druid.test-on-borrow=false
spring.datasource.druid.test-on-return=false
spring.datasource.druid.test-while-idle=true
spring.datasource.druid.filters= stat,wall,slf4j
spring.datasource.druid.filter.stat.slow-sql-millis= 5000
Spring 单元测试集成 H2 数据库
在编写 Dao 的测试用例的时候,会对数据库中的数据进行操作。但是一般我们不想测试用例的运行对我们的开发库/测试服务库中的数据造成污染。那么可以考虑集成 H2 数据库来运行测试用例。集成如下。
添加依赖
<!-- test -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
添加 application.properties 配置
此处的 application.properties
文件是 test 目录下的配置文件。
# 数据源连接
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem:test
spring.datasource.username=root
spring.datasource.password=
# 设置测试启动时执行的创建schema的脚本文件
spring.datasource.schema=classpath:db/schema.sql
# 设置测试启动时执行的插入数据的脚本文件
spring.datasource.data=classpath:db/data.sql
此处两个配置文件比较重要:
classpath:db/schema.sql
schema.sql 保存了对应的测试用例中用到的表 Schema 定义,需要符合 H2 数据库语法。classpath:db/data.sql
data.sql 保存的是测试用例执行前 H2 中需要初始化的数据。
其余的测试用例的编写和 Spring 官方说明一致。
编写测试用例
/**
* 定时任务实体服务测试类.
*
* Created by vioao.
*/
@RunWith(SpringRunner.class)
@SpringBootTest
public class QuartzServiceTest {
@Autowired
QuartzTaskService quartzTaskService;
@Test
public void testTaskExist() {
QuartzTaskEntity quartzTaskEntity = new QuartzTaskEntity();
quartzTaskEntity.setTaskClass("TestTask2");
quartzTaskEntity.setState(0);
int inserted = quartzTaskService.insert(quartzTaskEntity);
Assert.assertEquals(1, inserted);
Assert.assertEquals(true, quartzTaskService.taskExist(quartzTaskEntity));
}
}
总结
至此,一个完整的用例算是完成了,整个从表定义到开发集成插件,再到后面的测试用例编写过程都有相关的说明,算是一个较为简单的模块。当然,将这个定时任务模块单独打包作为一个独立的服务运行也是可以的。Done,Bye。