定时任务Spring Task双向数据传输WebSocket
之前的代码已完成基础功能,但仍有一些逻辑未完善:
定时任务:
- 用户下单15分钟后仍未支付,订单判定为超时。status应修改为6(已取消)。
- 管理端忘记点击"完成",使订单长时间处于"派送中"状态。系统需每天定时清理该类订单,status修改为5(已完成)。
可使用Sping框架提供的工具Spring Task来定时处理一些任务。
弹窗任务:
- 来单提醒:用户下单并支付后,管理端需弹出窗口提示有新订单。
- 客户催单:用户下单并支付后,可点击"催单",管理端会弹出窗口提示接单。
可以通过WebSocket协议来实现客户端浏览器和服务器之间进行双向的数据传输。
Spring Task
Spring Task是Spring框架提供的一个任务调度工具,可以按照约定的时间自动执行某个代码逻辑,属于定时任务框架。 其作用类似于手机上的闹钟,即定时自动执行指定的java代码。
指定代码要在哪个时间点触发可以通过cron表达式来确定。
cron表达式
Cron表达式是一个强大的时间表达式,用于定义定时任务的执行时间。它由六个或七个字段(域)组成,中间以空格分隔,每个字段代表一个时间单位,分别为秒、分、时、日、月、周、年(其中年可选)。其本质上就是一个字符串,通过cron表达式可以定义任务触发的时间。
Cron表达式中使用了一些特殊字符来表示复杂的时间规则:
- 星号(*):表示所有可能的值。例如,在“小时”字段中,*表示每小时。
- 问号(?):表示不指定的值,只能用于日和周字段。例如在周字段中使用?,表示不指定具体是周几。
- 连字符(-):表示一个范围。例如,在“小时”字段中,1-5表示从1点到5点。
- 逗号(,):表示列表值。例如,在“周”字段中,Mon,Wed,Fri表示周一、周三和周五。
- 斜杠(/):用于指定增量。例如,在“分”字段中,0/15表示从0分钟开始,每15分钟执行一次。
一些特殊的字符:
- L:表示最后。只能用于日和月字段。在月字段中表示一个月的最后一天;在周字段中表示一个星期的最后一天(即星期六)。如果L前有具体的内容,如“6L”,则表示该月的倒数第6天。
- W:表示有效工作日(周一到周五)。只能用于日字段,系统将在离指定日期的最近的有效工作日触发事件。例如在日字段使用5W,如果5日是休息日,则将在最近的工作日(即星期五,4日)触发;如果是工作日,则就在5日触发。
- LW:表示某个月最后一个工作日,即最后一个星期五。
- #:用于确定每个月第几个星期几。它只能出现在DayofMonth字段中。例如,“4#2”表示某月的第二个星期三。
一般日和周字段必须有一个为"?",例如每月1号早上9点执行一次:0 0 9 1 * ?,因为我们无法确定每月1号是周几,所以只能用"?"代替。
这些只是些常用的字符,还有些特殊的字符我们并不需要去手写,我们可以通过在线生成器来生成cron表达式:
Cron表达式生成器https://cron.ciding.cc/https://cron.ciding.cc/https://cron.ciding.cc/https://cron.ciding.cc/https://cron.ciding.cc/https://cron.ciding.cc/
以下是一些Cron表达式的示例及其含义:
- 0 0 12 * * ?:每天中午12点执行一次。
- 0 15 10 ? * *:每天上午10点15分执行一次。
- 0 0/30 9-17 * * ?:每天上午9点到下午5点之间,每30分钟执行一次。
- 0 0 0 1 * ?:每月1号凌晨0点执行一次。
- 0 0 0 ? * Sun:每周日凌晨0点执行一次。
接下来我们通过一个案例来了解如何使用Spring Task。
入门案例
一、导入maven坐标spring-context(已存在)
因为Spring Task是一个非常小的框架,以至于其没有单独的jar包,其相关的api都封装在spring-context包下,而在原代码中已经导入了相关的maven坐标。
二、在启动类添加注解@EnableScheduling开启任务调度
三、自定义定时任务类
在server模块下新建task包,在该包下新建定时任务类并添加相关注解。然后定义方法,返回值为void,并在方法上添加注解@Scheduled,在该注解内部添加cron属性:
@Component//实例化该类
@Slf4j
public class toDoTask {
@Scheduled(cron = "*/10 * * * * *")//每十秒执行一次该方法
public void task() {
log.info("task");
}
}
订单状态定时处理
了解完如何使用Spring Task后,我们回过头来完成相关的业务功能。因为定时任务都是自动执行的,并不需要前端发起请求,因此也不需要做接口设计。
订单超时未付款
检查订单是否超时的频率过高会增加数据库压力,频率过低又会难以实现目标,每分钟检查一次订单正合适。如果超时则将订单状态更新为6(已取消),未超时则不做处理。
我们可以通过定时任务每分钟检查一次是否存在支付超时订单(下单后超过15分钟仍未支付则判定为支付超时订单),如果存在则修改订单状态为"已取消"。
@Component//实例化该类
@Slf4j
public class OrderTask {
@Autowired
private OrderMapper orderMapper;
@Scheduled(cron = "0 * * * * *")//每分钟触发一次
public void orderTimeOut() {
// 记录当前时间并打印日志
log.info("定时处理超时订单{}", LocalDateTime.now());
// 计算出15分钟前的时间
LocalDateTime time = LocalDateTime.now().minusMinutes(15);
// 从数据库中查询所有状态为"待支付"且下单时间小于15分钟前的订单
List<Orders> ordersList = orderMapper.getByStatusAndOrderTimeLT(Orders.PENDING_PAYMENT, time);
// 如果查询到超时订单,则遍历列表并更新每个订单的状态
if (ordersList != null && ordersList.size() > 0) {
for (Orders orders : ordersList) {
// 设置订单状态为"已取消"
orders.setStatus(Orders.CANCELLED);
// 设置取消原因
orders.setCancelReason("订单超时,自动取消");
// 设置取消时间
orders.setCancelTime(LocalDateTime.now());
// 更新订单信息到数据库
orderMapper.update(orders);
}
}
}
}
订单长时间未完成
每天0点检查一次订单,如果仍在派送中则更新为5(已完成)。其思路与"订单超时未付款"类似,不再赘述。
@Scheduled(cron = "0 0 0 * * *")
public void orderComplete() {
log.info("处理一直处于派送中的订单:{}", LocalDateTime.now());
// 从数据库中查询所有状态为"派送中"的订单
List<Orders> ordersList = orderMapper.getByStatusAndOrderTimeLT(Orders.DELIVERY_IN_PROGRESS, LocalDateTime.now());
// 如果查询到派送中的订单,则遍历列表并更新每个订单的状态
if (ordersList != null && ordersList.size() > 0) {
for (Orders orders : ordersList) {
// 设置订单状态为"已完成"
orders.setStatus(Orders.COMPLETED);
// 更新订单信息到数据库
orderMapper.update(orders);
}
}
}
这样代码便已编写完毕,但测试两功能与之前不同,因为无需发起请求,系统自动执行,我们只需要查看控制台是否输出对应的日志语句即可,"订单长时间未完成"功能则可以将时间暂时改为当前时间来测试。
WebSocket
Web Socket 是基于 TCP 的一种新的网络协议。它实现了浏览器与服务器全双工通信——浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性的连接,并进行双向数据传输。
简而言之就是浏览器和服务器之间都可以主动的向对方传输数据。
与之相似的http协议是由Client(客户端)发起Request,Server响应Response,其必须由浏览器先发送请求,服务器无法主动发送请求,且在服务器响应后连接便不存在,如需再次对话则需建立新的连接,因此http连接我们也称为短连接。
WebSocket的工作原理可以分为三个阶段:握手、数据传输和断开连接。
- 一、握手:
客户端发起WebSocket连接时,通过向服务器发送一个特殊的HTTP请求头(Handshake)来建立连接。服务器检查请求头中的特定字段,确认支持WebSocket协议后,发送特殊的HTTP响应头(Acknowledgement)进行握手确认。 - 二、数据传输:
握手成功后,双方建立了WebSocket连接,可以进行后续的数据传输。客户端和服务器可以通过该连接进行双向的实时数据传输,消息以帧的形式进行传输。 - 三、断开连接:
当连接不再需要时,客户端或服务器可以发起关闭连接的请求。双方会交换特殊的关闭帧,以协商关闭连接,并确保双方都接收到了关闭请求。
连接长度 | 通信模式 | 前缀 | 底层 | |
Http | 短连接 | 单向(请求-响应) | http | TCP连接 |
Web Socket | 长连接 | 双向(全双工通信) | ws |
视频网站的弹幕、网页间的聊天、体育数据实时更新都可以通过WebSocket实现。
入门案例
一、编写html页面作为WebSocket客户端
<!DOCTYPE HTML>
<html>
<head>
<meta charset="UTF-8">
<title>WebSocket Demo</title>
</head>
<body>
<input id="text" type="text" />
<button onclick="send()">发送消息</button>
<button onclick="closeWebSocket()">关闭连接</button>
<div id="message">
</div>
</body>
<script type="text/javascript">
var websocket = null;
var clientId = Math.random().toString(36).substr(2);
//判断当前浏览器是否支持WebSocket
if('WebSocket' in window){
//连接WebSocket节点
websocket = new WebSocket("ws://localhost:8080/ws/"+clientId);
}
else{
alert('Not support websocket')}
//连接发生错误的回调方法
websocket.onerror = function(){
setMessageInnerHTML("error");};
//连接成功建立的回调方法
websocket.onopen = function(){
setMessageInnerHTML("连接成功");}
//接收到消息的回调方法
websocket.onmessage = function(event){
setMessageInnerHTML(event.data);}
//连接关闭的回调方法
websocket.onclose = function(){
setMessageInnerHTML("close");}
//监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
window.onbeforeunload = function(){
websocket.close();}
//将消息显示在网页上
function setMessageInnerHTML(innerHTML){
document.getElementById('message').innerHTML += innerHTML + '<br/>';}
//发送消息
function send(){
var message = document.getElementById('text').value;
websocket.send(message);}
//关闭连接
function closeWebSocket() {
websocket.close();}
</script>
</html>
二、导入WebSocket的maven坐标
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
三、导入WebSocket服务端组件WebSocketServer,用于和客户端通信
在server模块下新建WebSocket包,并创建WebSocketServer类:
@Component
@ServerEndpoint("/ws/{sid}")
public class WebSocketServer {
//存放会话对象
private static Map<String, Session> sessionMap = new HashMap();
/**
* 连接建立成功调用的方法
*/
@OnOpen
public void onOpen(Session session, @PathParam("sid") String sid) {
System.out.println("客户端:" + sid + "建立连接");
sessionMap.put(sid, session);
}
/**
* 收到客户端消息后调用的方法
* @param message 客户端发送过来的消息
*/
@OnMessage
public void onMessage(String message, @PathParam("sid") String sid) {
System.out.println("收到来自客户端:" + sid + "的信息:" + message);
}
/**
* 连接关闭调用的方法
* @param sid
*/
@OnClose
public void onClose(@PathParam("sid") String sid) {
System.out.println("连接断开:" + sid);
sessionMap.remove(sid);
}
/**
* 群发
* @param message
*/
public void sendToAllClient(String message) {
Collection<Session> sessions = sessionMap.values();
for (Session session : sessions) {
try {
//服务器向客户端发送消息
session.getBasicRemote().sendText(message);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
注解@ServerEndpoint("/ws/{sid}")定义了WebSocket的连接地址,"/ws/{sid}"允许客户端在连接时传递一个会话ID(sid)。对应html页面代码中连接WebSocket节点:websocket = new WebSocket("ws://localhost:8080/ws/"+clientId),也就是说其也是根据路径进行匹配的。
首先定义的Map内部存放的是Session(会话),其属于WebSocket包,客户端和服务端建立连接本质上就是一个会话,建立会话之后双方就可以开始通信了,而该Map就是用来存放这些会话对象的。
而其他方法上添加的@OnOpen、@OnMessage、@OnClose注解使这些方法变成回调方法,其分别对应连接建立时、连接中、连接关闭时执行的方法。
最后一个sendToAllClient()方法并未添加注解,也就意为着我们需要手动去调用该方法。同一时间可能有多个客户端与该服务器建立会话,这个方法会将所以的session遍历出来。
四、导入配置类WebSocketConfiguration
在server模块的config包下导入配置类WebSocketConfiguration,用于注册WebSocket的Bean。
@Configuration
public class WebSocketConfiguration {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
五、测试
在server模块下WebSocket包中新建WebSocketTask类用于测试。
@Component
public class WebSocketTask {
@Autowired
private WebSocketServer webSocketServer;
/**
* 通过WebSocket每隔5秒向客户端发送消息
*/
@Scheduled(cron = "0/5 * * * * ?")
public void sendMessageToClient() {
webSocketServer.sendToAllClient("这是来自服务端的消息:" + DateTimeFormatter.ofPattern("HH:mm:ss").format(LocalDateTime.now()));
}
}
启动项目,然后点击之前编辑的html页面,该页面启动/刷新时就会与服务器建立连接,之后便可开始双向通信:
来单提醒
先来分析业务需求:用户下单并且支付成功后,需要第一时间通知外卖商家。通知的形式包括语音播报、弹出提示框两种。
一、通过WebSocket实现管理端页面和服务端保持长连接状态
刚刚导入的代码已包含相关代码,我们在登陆时前端和后端就会建立连接。同样该请求也就由nginx转发。
二、当客户支付后,调用WebSocket的相关API实现服务端向客户端推送消息
在OrderServiceImpl类中负责"支付成功,修改订单状态"的paySuccess()方法中调用WebSocket向客户端浏览器推送消息。要推送消息就要用到WebSocketServer的bean,因此要先注入。
推送的消息应为json数据,同时包含的三个字段包括: type, orderId, content。其中type 为消息类型,1为来单提醒2为客户催单,orderId 为订单id,content 为消息内容。
我们可以先将其封装到Map中,然后再转化为json格式的数据并推送。
@Service
@Slf4j
public class OrderServiceImpl implements OrderService {
@Autowired
private WebSocketServer webSocketServer;
public void paySuccess(String outTradeNo) {
// 根据订单号查询订单
Orders ordersDB = orderMapper.getByNumber(outTradeNo);
// 根据订单id更新订单的状态、支付方式、支付状态、结账时间
Orders orders = Orders.builder()
.id(ordersDB.getId())
.status(Orders.TO_BE_CONFIRMED)
.payStatus(Orders.PAID)
.checkoutTime(LocalDateTime.now())
.build();
orderMapper.update(orders);
//新代码————————————————————————————————————————
// 创建消息内容,通过WebSocket推送给客户端
Map<String, Object> map = new HashMap<>();
map.put("type", 1); // 消息类型,1表示来单提醒
map.put("orderId", ordersDB.getId()); // 订单ID
map.put("content", "订单号:" + outTradeNo); // 消息内容,包含订单号
// 将消息内容转换为JSON字符串
String json = JSON.toJSONString(map);
// 通过WebSocket服务器向所有客户端发送消息
webSocketServer.sendToAllClient(json);
//新代码—————————————————————————————————————————
}
}
重新启动项目,并在小程序端下单测试,客户端浏览器解析服务端推送的消息,判断是来单提醒还是客户催单,进行相应的消息提示和语音播报:
客户催单
同理,用户在小程序点击催单按钮后,需要第一时间通知外卖商家。通知的形式包括语音播报、弹出提示框两种。调用的方法、提交的消息都与上文相同,在此不再赘述。
不同的是来单提醒是集成在paySuccess方法内部的,而客户催单则需要回应小程序端发起的请求。请求路径为/user/order/reminder/{id},请求方法为Get,以路径参数提交id,后端使
回到user包下的OrderController编写方法:
// Controller———————————————————
@GetMapping("/reminder/{id}")
@ApiOperation("用户催单")
public Result reminder(@PathVariable Long id){
orderService.remionder(id);
return Result.success();
}
// Service———————————————————————
void reminder(Long id);
// ServiceImpl———————————————————
@Override
public void reminder(Long id) {
Orders orderDB = orderMapper.getById(id);
// 校验订单是否存在
if (orderDB == null) {
//抛出异常:订单状态错误
throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR);
}
// 创建消息内容,通过WebSocket推送给客户端
Map<String, Object> map = new HashMap<>();
map.put("type", 2); // 消息类型,1表示来单提醒,2表示客户催单
map.put("orderId", id); // 订单ID
map.put("content", "订单号:" + orderDB.getNumber()); // 消息内容,包含订单号
// 通过WebSocket服务器向所有客户端发送消息
webSocketServer.sendToAllClient(JSON.toJSONString(map));
}