开发Web应用时,多数应用都具备任务调度功能。常用的任务包括异步任务、定时任务和发邮件任务。我们以数据库报表为例看看任务调度如何帮助改善系统设计。报表可能是错综复杂的,用户可能需要很长时间找到需要的报表数据,此时,我们可以在这个报表应用中添加异步任务减少用户等待时间,从而提高用户体验;除此之外,还可以在报表应用中添加定时任务和邮件任务,以便用户可以安排在任何他们需要的时间定时生成报表,并在Email中发送。
Web应用开发中,大多数情况都是通过同步方式完成数据交互处理,但是,当任务与第三方系统的交互时,容易造成响应迟缓的情况,之前大部分都是使用多线程完成此类任务,除此之外,还可以使用异步调用的方式完美解决这个问题。根据异步处理方式的不同,可以将异步任务的调用分为无返回值异步任务调用和有返回值异步任务调用,接下来我们在Spring Boot项目中分别针对这两种方式讲解。
在实际开发中,项目可能会向新注册用户发送短信验证码,此时,可以考虑使用异步任务调用的方式实现,一方面是因为用户对这个时效性要求不是特别高,另一方面在特定时间范围内没有收到验证码,用户可以点击再次发送验证码。
Spring Boot项目创建
使用Spring Initializr方式创建一个名为chapter09的Spring Boot项目,在Dependencies依赖中选择Web模块中的Web依赖。
需要说明的是,Spring框架提供了对异步任务的支持,Spring Boot框架继承了这一异步任务功能。在Spring Boot中整合异步任务时,只需在项目中引入Web模块中的Web依赖就可以实现这种异步任务功能。
编写异步调用方法
在chapter09项目中创建名为com.example.service的包,并在该包下创建一个业务实现类MyAsyncService,在该类中编写模拟用户短信验证码发送的方法。
MyAsyncService.java
package com.example.service;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class MyAsyncService {
@Async
public void sendSMS() throws InterruptedException {
System.out.println("调用短信验证码业务方式...");
Long startTime=System.currentTimeMillis();
Thread.sleep(5000);
Long endTime=System.currentTimeMillis();
System.out.println("短信业务执行完成耗时:" + (endTime - startTime));
}
}
开启基于注解的异步任务支持
下一步编写的用户短信验证码发送业务方法中,使用@Async注解标记了异步方法,如果在Spring Boot中希望异步方法生效,还需要使用@EnableAsync注解开启基于注解的异步任务支持。@EnableAsync注解通常会添加在项目启动类上。
Chapter09Application.java
package com.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@EnableAsync //开启基于注解的异步任务支持
@SpringBootApplication
public class Chapter09Application {
public static void main(String[] args) {
SpringApplication.run(Chapter09Application.class, args);
}
}
编写控制层业务调用方法
在chapter09项目中创建名为com.example.controller的包,并在该包下创建类MyAsyncController用于调用异步方法。在该类中模拟编写用户短信验证码发送的处理方法。
MyAsyncController.java
package com.example.controller;
import com.example.service.MyAsyncService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MyAsyncController {
@Autowired
private MyAsyncService myAsyncService;
@GetMapping("/sendSMS")
public String sendSMS() throws InterruptedException {
Long startTime =System.currentTimeMillis();
myAsyncService.sendSMS();
Long endTime = System.currentTimeMillis();
System.out.println("主流程耗时:" + (endTime - startTime));
return "success";
}
}
sendSMS()方法模拟发送短信,专门用于处理请求路径为"/sendSMS"的请求。
异步任务效果测试
启动chapter09项目,项目启动成功后,在浏览器上访问"http://localhost:8080/sendSMS"测试异步任务请求,此时会发现浏览器上会快速响应"success"信息。
上述案例中的异步方法是没有返回值的,这样主流程在执行异步方法时不会阻塞,而是继续向下执行主流程程序,直接向页面响应结果,而调用的异步方法会作为一个子线程单独执行,直到异步方法执行完成。
在实际开发中,项目中可能会涉及有返回值的异步任务调用。例如,一个程序中需要调用两个业务方法对相关业务数据统计分析,并将统计结果汇总。
编写异步调用方法
在之前创建的MyAsyncService异步任务业务处理类中,添加两个模拟有返回值的异步任务业务处理方法。
@Async
public Future<Integer> processA() throws InterruptedException {
System.out.println("开始分析并统计业务A数据...");
Long startTime =System.currentTimeMillis();
Thread.sleep(4000);
//模拟定义一个假的统计结果
int count=123456;
Long endTime=System.currentTimeMillis();
System.out.println("业务A数据统计耗时:" + (endTime - startTime));
return new AsyncResult<Integer>(count);
}
@Async
public Future<Integer> processB() throws InterruptedException {
System.out.println("开始分析并统计业务B数据...");
Long startTime =System.currentTimeMillis();
Thread.sleep(5000);
//模拟定义一个假的统计结果
int count=654321;
Long endTime=System.currentTimeMillis();
System.out.println("业务B数据统计耗时:" + (endTime - startTime));
return new AsyncResult<Integer>(count);
}
上述代码中,在MyAsyncService异步业务处理类中添加了两个分别处理业务A数据统计的方法processA()和处理业务B数据统计的方法processB(),在方法上都是用了@Async注解标记为异步方法。另外,上述两个异步方法都会有一定的处理时间,并且需要返回统计结果,示例中使用了new AsyncResult
编写控制层业务调用方法
在之前创建的异步任务业务处理类MyAsyncController中,编写业务数据分析统计的请求处理方法。
@GetMapping("/statistics")
public String statistics() throws InterruptedException, ExecutionException {
Long startTime =System.currentTimeMillis();
Future<Integer> futureA=myAsyncService.processA();
Future<Integer> futureB=myAsyncService.processB();
int total = futureA.get()+futureB.get();
System.out.println("异步任务数据统计汇总结果:" + total);
Long endTime = System.currentTimeMillis();
System.out.println("主流程耗时:" + (endTime - startTime));
return "success";
}
上述代码中,statistics()方法处理映射路径为"/statistics"的业务数据统计的请求,通过调用异步方法processA()和processB()输出主流程的耗时时长。
异步任务效果测试
在实际开发中,可能会有这样一个需求,需要在每天的某个固定时间或者每隔一段时间让程序去执行某一个任务。例如,服务器数据定时在晚上零点备份。通常我们可以使用Spring框架提供的Scheduling Tasks实现这一定时任务的处理。
Spring框架的定时任务调度功能支持配置和注解两种方式,Spring Boot不仅继承了Spring框架定时任务调度功能,而且可以更好地支持注解方式的定时任务。
@EnableScheduling
@EnableScheduling注解是Spring框架提供的,用于开启基于注解方式的定时任务支持,该注解主要用在框架启动类上。
@Scheduled
@Scheduled注解同样是Spring框架提供的,匹配定时任务的执行规则,该注解主要用在定时业务方法上。@Scheduled注解提供有多个属性,精细化配置定时任务执行规则。
属性 | 说明 |
---|---|
cron | 类似于cron的表达式,可以定制定时任务触发的秒、分钟、小时、月中的日、月、周中的日 |
zone | 指定cron表达式将被解析的时区。默认情况下,该属性是空字符串(即使用服务器的本地时区) |
fixedDelay | 表示在上一次任务执行结束后在指定时间后继续执行下一次任务(属性值为long类型) |
fixedDelayString | 表示在上一次任务执行结束后在执行时间后继续执行下一次任务(属性值为long类型的字符串形式) |
fixedRate | 表示每个指定时间执行一次任务(属性值为long类型) |
fixedRateString | 表示每个指定时间执行一次任务(属性值为long类型的字符串形式) |
initialDelay | 表示在fixedRate或fixedDelay任务第一次执行之前要延迟的毫秒数(属性值为long类型) |
initialDelayString | 表示在fixedRate或fixedDelay任务第一次执行之前要延迟的毫秒数(属性值为long类型的字符串形式) |
1)cron属性
cron属性是@Scheduled定时任务注解中最常用也是最复杂的一个属性,其属性值由类似于cron表达式的6位数组成,可以详细地指定定时任务执行的秒、分、小时、日、月、星期。
@Scheduled(cron = "0 * * * * MON_FRI")
上述代码中,cron = "0 * * * * MON_FRI"
表示周一到周五每一分钟执行一次定时任务。第一位表示秒,第二位表示分,第三位表示小时,第四位表示月份中的日,第五位表示月份中的月,第六位星期,*表示的是任意时刻,MON-FRI表示的是周一到周五。
字段 | 可取值 | 允许的特殊字符 |
---|---|---|
秒 | 0~59 | , - * / |
分 | 0~59 | , - * / |
小时 | 0~23 | , - * / |
日 | 1-31 | , - * / ? L |
月 | 1~12、月份对应英文前3个字母(大小写均可) | , - * / |
星期 | 0~7(0和7表示SUN)、星期对应英文前3个字母(大小写均可) | , - * / ? L |
特殊字符 | 说明 | 示例 |
---|---|---|
, | 表示枚举 | @Scheduled(cron=“1,3,5 * * * * *”)表示任意时间的1、3、5秒钟都会执行 |
- | 表示区间 | @Scheduled(cron=“0 0-5 14 * * ?”)表示每天下午2:00~2:05期间,每一分钟触发一次 |
* | 表示任意可取值 | @Scheduled(cron=“0 0 12 * * *”)表示每天中午12:00触发一次 |
/ | 表示步长 | @Scheduled(cron=“0/5 * * * * *”)表示从任意时间的整秒开始,每隔5秒都会执行 |
? | 日/星期冲突匹配符 | @Scheduled(cron=“0 * * 26 * ?”)表示每月的26日每一分钟都执行 |
L | 最后 | @Scheduled(cron=“0 0 * L * ?”)表示每月最后一日每一小时都执行 |
需要注意的是,注解@Scheduled的cron属性与cron的表达式并不完全一致,@Scheduled的cron属性只提供6位字段赋值。cron表达式支持的特殊字符在@Scheduled的cron属性中是不支持的,例如,字符C、W、#可以作为cron表达式的字符,但是无法作为@Scheduled的cron属性值。
2)zone属性
zone属性主要与cron属性配合使用,指定解析cron属性值的时区。通常情况下,不需要指定zone属性,cron属性值会自动以服务器所在区域作为本地时区进行表达式解析。例如,中国地区服务器的时区通常默认为Asia/Shanghai。
3)fixedDelay和fixedDelayString属性
fixedDelay和fixedDelayString属性的作用类似,用于在上一次任务执行完毕后,一旦到达指定时间就继续执行下一次任务。两者的区别在于属性值的类型不同,fixedDelay属性值为long类型,而fixedDelayString属性值是数值字符串。
@Scheduled(fixedDelay=5000)
@Scheduled(fixedDelayString="5000")
4)fixedRate和fixedRateString属性
fixedRate和fixedRateString属性的作用类似,指定每相隔一段时间重复执行一次定时任务。他们的主要区别是属性值的类型不同,其中fixedRate属性值为long类型,fixedRateString属性值为数值字符串。
@Scheduled(fixedRate=5000)
@Scheduled(fixedRateString="5000")
5)initialDelay和initialDelayString属性
initialDelay和initiDelayString属性的作用类似,主要是与fixedRate/fixedRateString或者fixedDelay/fixedDelayString属性配合使用,指定定时任务第一次执行的延迟时间,然后再按照各自相隔时间重复执行任务。
@Scheduled(initialDelay=1000,fixedDelay=5000)
@Scheduled(initialDelay=1000,fixedRate=5000)
上述代码在程序启动后,会延迟1秒后再执行第一次定时任务,然后相隔5秒重复执行定时任务。
编写定时任务业务处理方法
在项目的com.example.service的包下新建一个定时任务管理的业务处理类ScheduledTaskService,并在该类中编写对应的定时任务处理方法。
ScheduledTaskService.java
package com.example.service;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.text.SimpleDateFormat;
import java.util.Date;
@Service
public class ScheduledTaskService {
private static final SimpleDateFormat dateFormat =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
private Integer count1=1;
private Integer count2=1;
private Integer count3=1;
@Scheduled(fixedRate = 60000)
public void scheduledTaskImmediately(){
System.out.println(String.format("fixedRate第%s次执行,当前时间为:%s", count1++, dateFormat.format(new Date())));
}
@Scheduled(fixedDelay = 60000)
public void scheduledTaskAfterSleep() throws InterruptedException {
System.out.println(String.format("fixedDelay第%s次执行,当前时间为:%s", count3, dateFormat.format(new Date())));
Thread.sleep(10000);
}
@Scheduled(cron = "0 * * * * *")
public void scheduledTaskCron(){
System.out.println(String.format("cron第%s次执行,当前时间为:%s", count3++, dateFormat.format(new Date())));
}
}
使用@Scheduled注解声明了3个定时任务方法,这3个方法定制的执行规则基本相同,都是每隔1分钟重复执行一次定时任务。在使用fixedDelay属性的方法scheduledTaskAfterSleep()中,使用Thread.sleep(10000)模拟该定时任务处理耗时为10秒。需要说明的是,Spring Boot使用定时任务相关注解时,必须先引入Spring框架依赖。由于这里之前创建的项目引入了Web依赖,Web依赖包含了Spring框架依赖,因此这里可以直接使用相关注解。
开启基于注解的定时任务支持
为了使Spring Boot中基于注解方式的定时任务生效,还需要再项目启动类上使用@EnableScheduling注解开启基于注解的定时任务支持。
Chapter09Application.java
package com.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@EnableAsync //开启基于注解的异步任务支持
@EnableScheduling //开启基于注解的异步任务支持
@SpringBootApplication
public class Chapter09Application {
public static void main(String[] args) {
SpringApplication.run(Chapter09Application.class, args);
}
}
再实际开发中,邮件发送服务应该是网站的必备功能之一,例如用户注册验证、忘记密码、给用户发送营销信息等。在早期开发过程中,开发人员通常会使用JavaMail相关API实现邮件发送功能,后来Spring推出JavaMailSender简化了邮件发送的过程和实现,Spring Boot框架对Spring提出的邮件发送服务也进行了整合支持。
邮件发送任务中,最简单的莫过于纯文本邮件的发送。在定制纯文本邮件时,只需要指定收件人邮件账号、邮件标题和邮件内容即可。
添加邮件服务依赖启动器
打开项目chapter09的pom.xml文件,在该依赖文件中添加Spring Boot整合支持的邮件服务依赖启动器spring-boot-starter-mail。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
当添加上述依赖后,Spring Boot自动配置的邮件服务会生效,在邮件发送任务时,可以直接使用Spring框架提供的JavaMailSender接口或者它的实现类JavaMailSenderImpl。
添加邮件服务配置
在项目中添加邮件服务依赖启动器后,还需要在配置文件中添加邮件服务相关的配置,确保邮件服务正常发送。打开项目的application.properties全局配置文件,在该文件中添加发件人邮箱服务配置和邮件服务超时的相关配置。
# 发件人邮箱服务器相关配置
spring.mail.host=smtp.qq.com
spring.mail.port=587
# 配置个人QQ账户和密码(密码是加密后的授权码)
spring.mail.username=**********@qq.com
spring.mail.password=dza*******gbagc
spring.mail.default-encoding=UTF-8
#邮件服务超时时间配置
spring.mail.properties.mail.smtp.connectiontime=5000
spring.mail.properties.mail.smtp.timeout=3000
spring.mail.properties.mail.smtp.writetimeout=5000
这里需要开启qq邮箱的smtp服务,并生成授权码。
邮件服务超时配置可以灵活更改超时时间,如果没有配置邮件服务超时的话,Spring Boot内部默认超时是无限制的,这可能会造成线程被无响应的邮件服务器长时间阻塞。
定制邮件发送服务
先以发送纯文本邮件为例,在之前创建的com.example.service的包中,新建一个邮件发送任务管理的业务处理类SendEmailService,并在该类中编写一个发送纯文本邮件的业务方法。
SendEmailService.java
package com.example.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.MailException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.stereotype.Service;
@Service
public class SendEmailService {
@Autowired
private JavaMailSenderImpl mailSender;
@Value("${spring.mail.username}")
private String from;
public void sendSimpleEmail(String to,String subject,String text){
//定制纯文本邮件信息SimpleMailMessage
SimpleMailMessage message=new SimpleMailMessage();
message.setFrom(from);
message.setTo(to);
message.setSubject(subject);
message.setText(text);
try{
//发送邮件
mailSender.send(message);
System.out.println("纯文本邮件发送成功");
}catch (MailException e){
System.out.println("纯文本邮件发送失败" + e.getMessage());
e.printStackTrace();
}
}
}
编写了一个发送纯文本邮件的sendSimpleEmail()方法,在方法中通过SimpleMailMessage类定制了邮件信息的发件人地址(From)、收件人地址(To)、邮件标题(Subject)和邮件内容(Text),最后使用JavaMailSenderImpl的send()方法实现纯文本邮件发送。
纯文本邮件发送效果测试
Chapter09ApplicationTests.java
package com.example;
import com.example.service.SendEmailService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class Chapter09ApplicationTests {
@Autowired
private SendEmailService sendEmailService;
@Test
public void sendSimpleMailTest() {
String to="********@qq.com";
String subject="【纯文本邮件】标题";
String text="Spring Boot 纯文本邮件发送内容测试.....";
//发送纯文本邮件
sendEmailService.sendSimpleEmail(to,subject,text);
}
}
定制邮件发送服务
由于在前一个案例中实现发送纯文本邮件功能时,已经在项目中添加了邮件服务依赖和相关配置,因此后续操作时可以直接使用。打开之前创建的邮件发送任务的业务处理类SendEmailService,在该类中编写一个发送待附件和图片邮件的业务方法。
public void sendComplexEmail(String to,String subject,String text,String filePath,String rscId,String rscPath){
//定制复杂邮件信息MimeMessage帮助类,并设置multipart多部件使用为true
MimeMessage message=mailSender.createMimeMessage();
try{
//使用MimeMessageHelper帮助类,并设置multipart多部件使用为true
MimeMessageHelper helper=new MimeMessageHelper(message,true);
helper.setFrom(from);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(text,true);
//设置邮件静态资源
FileSystemResource res=new FileSystemResource(new File(rscPath));
helper.addInline(rscId,res);
//设置邮件附件
FileSystemResource file=new FileSystemResource(new File(filePath));
String fileName=filePath.substring(filePath.lastIndexOf(File.separator));
helper.addAttachment(fileName,file);
//发送邮件
mailSender.send(message);
System.out.println("复杂邮件发送成功");
} catch (MessagingException e) {
System.out.println("复杂邮件发送失败" + e.getMessage());
e.printStackTrace();
}
}
复杂邮件发送效果测试
在项目测试类Chapter09ApplicationTests中添加一个方法,调用带附件和图片的复杂邮件发送的方法,实现邮件发送效果测试。
package com.example;
import com.example.service.SendEmailService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class Chapter09ApplicationTests {
@Autowired
private SendEmailService sendEmailService;
//@Test
public void sendSimpleMailTest() {
String to="2*********[email protected]";
String subject="【纯文本邮件】标题";
String text="Spring Boot 纯文本邮件发送内容测试.....";
//发送纯文本邮件
sendEmailService.sendSimpleEmail(to,subject,text);
}
@Test
public void sendComplexEmailTest(){
String to="23*****[email protected]";
String subject="【复杂邮件】标题";
//定义邮件内容
StringBuilder text = new StringBuilder();
text.append("");
text.append("1024程序员节快乐!
");
//cid为固定写法,rscId自定义的资源唯一标识
String rscId="img001";
text.append("");
text.append("");
//指定静态资源文件和附件路径
String rscPath="D:\\文件\\IDEA\\chapter09\\src\\main\\resources\\static\\img\\test.png";
String filePath="D:\\文件\\IDEA\\chapter09\\src\\main\\resources\\static\\file\\test.txt";
//发送复杂邮件
sendEmailService.sendComplexEmail(to,subject,text.toString(),filePath,rscId,rscPath);
}
}
添加Thymeleaf模板引擎依赖启动器
既然提到了使用定制邮件模板的方式实现通用邮件的发送,少不了需要前端模板页面的支持,这里选择Thymeleaf模板引擎定制模板邮件内容。打开项目chapter09的pom.xml文件,在该依赖文件中添加Spring Boot整合支持的Thymeleaf模板引擎依赖启动器spring-boot-starter-thymeleaf。
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-thymeleafartifactId>
dependency>
定制模板邮件
DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8"/>
<title>用户验证码title>
head>
<body>
<div><span th:text="${username}">XXXspan> 先生/女士,您好: div>
<P style="text-indent: 2em">您的新用户验证码为<span th:text="${code}" style="color: cornflowerblue">123456span>,请妥善保管。span> P>
body>
html>
定制邮件发送服务
打开邮件发送任务的业务处理类SendEmailService,在该类中编写一个发送Html模板邮件的业务方法。
public void sendTemplateEmail(String to,String subject,String content){
MimeMessage message = mailSender.createMimeMessage();
try{
//使用MimeMessageHelper帮助类,并设置multipart多部件使用为true
MimeMessageHelper helper = new MimeMessageHelper(message,true);
helper.setFrom(from);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(content,true);
//发送邮件
mailSender.send(message);
System.out.println("模板邮件发送成功");
} catch (MessagingException e) {
System.out.println("模板邮件发送失败" + e.getMessage());
e.printStackTrace();
}
}
模板邮件发送效果测试
在项目测试类Chapter09ApplicationTests中添加一个方法sendTemplateEmailTests(),在该方法中调用以编写的emailTemplate_vercode模板邮件,发送该方法并测试邮件发送效果。
@Autowired
private TemplateEngine templateEngine;
@Test
public void sendTemplateEmailTest(){
String to="23*********[email protected]";
String subject="【模板邮件】标题";
//使用模板邮件定制邮件正文内容
Context context =new Context();
context.setVariable("username","石头");
context.setVariable("code","456123");
//使用TemplateEngine设置要处理的模板页面
String emailContent = templateEngine.process("emailTemplate_vercode",context);
//发送模板邮件
sendEmailService.sendTemplateEmail(to,subject,emailContent);
}
如果要演示一次指定多个收件人时,将收件人地址转为字符串的数值即可。