定时任务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));
    }
相关推荐
engchina6 小时前
CSS 值和单位详解:从基础到实战
前端·css
落日弥漫的橘_7 小时前
Node.js下载安装及环境配置教程 (详细版)
前端·node.js·环境配置·node安装教程
marshalVS7 小时前
前端学习-事件解绑,mouseover和mouseenter的区别(二十九)
前端·学习
16年上任的CTO8 小时前
一文大白话讲清楚webpack进阶——9——ModuleFederation实战
前端·webpack·node.js·模块联邦·federation
云博客-资源宝9 小时前
2025最新源支付V7全套开源版+Mac云端+五合一云端
前端
光影少年10 小时前
vue2和vue3路由封装及区别
前端·vue.js
Y编程小白11 小时前
解决运行npm时报错
开发语言·前端·npm
孙尚香蕉12 小时前
服务器上安装Nginx详细步骤
java·服务器·前端
GISer_Jing12 小时前
前端面试&笔试题目(一)
前端·javascript·vue.js·react.js