定时任务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属性:

java 复制代码
@Component//实例化该类
@Slf4j
public class toDoTask {
    @Scheduled(cron = "*/10 * * * * *")//每十秒执行一次该方法
    public void task() {
        log.info("task");
    }
}

订单状态定时处理

了解完如何使用Spring Task后,我们回过头来完成相关的业务功能。因为定时任务都是自动执行的,并不需要前端发起请求,因此也不需要做接口设计。

订单超时未付款

检查订单是否超时的频率过高会增加数据库压力,频率过低又会难以实现目标,每分钟检查一次订单正合适。如果超时则将订单状态更新为6(已取消),未超时则不做处理。

我们可以通过定时任务每分钟检查一次是否存在支付超时订单(下单后超过15分钟仍未支付则判定为支付超时订单),如果存在则修改订单状态为"已取消"。

java 复制代码
@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(已完成)。其思路与"订单超时未付款"类似,不再赘述。

java 复制代码
    @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 | TCP连接 |

视频网站的弹幕、网页间的聊天、体育数据实时更新都可以通过WebSocket实现。

入门案例

一、编写html页面作为WebSocket客户端

html 复制代码
<!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坐标

XML 复制代码
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

三、导入WebSocket服务端组件WebSocketServer,用于和客户端通信

在server模块下新建WebSocket包,并创建WebSocketServer类:

java 复制代码
@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。

java 复制代码
@Configuration
public class WebSocketConfiguration {
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

五、测试

在server模块下WebSocket包中新建WebSocketTask类用于测试。

java 复制代码
@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格式的数据并推送。

java 复制代码
@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编写方法:

java 复制代码
// 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));
    }
相关推荐
Nan_Shu_61415 分钟前
学习: Threejs (2)
前端·javascript·学习
G_G#23 分钟前
纯前端js插件实现同一浏览器控制只允许打开一个标签,处理session变更问题
前端·javascript·浏览器标签页通信·只允许一个标签页
@大迁世界38 分钟前
TypeScript 的本质并非类型,而是信任
开发语言·前端·javascript·typescript·ecmascript
GIS之路1 小时前
GDAL 实现矢量裁剪
前端·python·信息可视化
是一个Bug1 小时前
后端开发者视角的前端开发面试题清单(50道)
前端
Amumu121381 小时前
React面向组件编程
开发语言·前端·javascript
持续升级打怪中1 小时前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路1 小时前
GDAL 实现矢量合并
前端
hxjhnct1 小时前
React useContext的缺陷
前端·react.js·前端框架
前端 贾公子2 小时前
从入门到实践:前端 Monorepo 工程化实战(4)
前端