这节课主要是 Spring Task了解下cron表达式还有WebSocket感觉挺常见的没有讲更细节的东西课程内容Spring Task订单状态定时处理WebSocket来单提醒客户催单功能实现订单状态定时处理、来单提醒和客户催单订单状态定时处理来单提醒客户催单1. Spring Task1.1 介绍Spring Task是Spring框架提供的任务调度工具可以按照约定的时间自动执行某个代码逻辑。定位定时任务框架作用定时自动执行某段Java代码为什么要在Java程序中使用Spring Task应用场景1). 信用卡每月还款提醒2). 银行贷款每月还款提醒3). 火车票售票系统处理未支付订单4). 入职纪念日为用户发送通知强调只要是需要定时处理的场景都可以使用Spring Task1.2 cron表达式cron表达式其实就是一个字符串通过cron表达式可以定义任务触发的时间构成规则分为6或7个域由空格分隔开每个域代表一个含义每个域的含义分别为秒、分钟、小时、日、月、周、年(可选)举例2022年10月12日上午9点整 对应的cron表达式为0 0 9 12 10 ? 2022说明一般日和周的值不同时设置其中一个设置另一个用表示。比如描述2月份的最后一天最后一天具体是几号呢可能是28号也有可能是29号所以就不能写具体数字。为了描述这些信息提供一些特殊的字符。这些具体的细节我们就不用自己去手写因为这个cron表达式它其实有在线生成器。cron表达式在线生成器https://cron.qqe2.com/可以直接在这个网站上面只要根据自己的要求去生成corn表达式即可。所以一般就不用自己去编写这个表达式。通配符* 表示所有值? 表示未说明的值即不关心它为何值- 表示一个指定的范围, 表示附加一个可能值/ 符号前表示开始时间符号后表示每次递增的值cron表达式案例*/5 * * * * ? 每隔5秒执行一次0 */1 * * * ? 每隔1分钟执行一次0 0 5-15 * * ? 每天5-15点整点触发0 0/3 * * * ? 每三分钟触发一次0 0-5 14 * * ? 在每天下午2点到下午2:05期间的每1分钟触发0 0/5 14 * * ? 在每天下午2点到下午2:55期间的每5分钟触发0 0/5 14,18 * * ? 在每天下午2点到2:55期间和下午6点到6:55期间的每5分钟触发0 0/30 9-17 * * ? 朝九晚五工作时间内每半小时0 0 10,14,16 * * ? 每天上午10点下午2点4点1.3 入门案例1.3.1 Spring Task使用步骤1). 导入maven坐标 spring-context已存在2). 启动类添加注解 EnableScheduling 开启任务调度3). 自定义定时任务类1.3.2 代码开发编写定时任务类进入sky-server模块中package com.sky.task; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.util.Date; /** * 自定义定时任务类 */ Component Slf4j public class MyTask { /** * 定时任务 每隔5秒触发一次 */ Scheduled(cron 0/5 * * * * ?) public void executeTask(){ log.info(定时任务开始执行{},new Date()); } }开启任务调度启动类添加注解 EnableSchedulingpackage com.sky; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCaching; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.transaction.annotation.EnableTransactionManagement; SpringBootApplication EnableTransactionManagement //开启注解方式的事务管理 Slf4j EnableCaching EnableScheduling public class SkyApplication { public static void main(String[] args) { SpringApplication.run(SkyApplication.class, args); log.info(server started); } }1.3.3 功能测试启动服务查看日志每隔5秒执行一次。2.订单状态定时处理2.1 需求分析用户下单后可能存在的情况下单后未支付订单一直处于“待支付”状态用户收货后管理端未点击完成按钮订单一直处于“派送中”状态支付超时的订单如何处理 派送中的订单一直不点击完成如何处理对于上面两种情况需要通过定时任务来修改订单状态具体逻辑为通过定时任务每分钟检查一次是否存在支付超时订单下单后超过15分钟仍未支付则判定为支付超时订单如果存在则修改订单状态为“已取消”通过定时任务每天凌晨1点检查一次是否存在“派送中”的订单如果存在则修改订单状态为“已完成”2.2 代码开发1). 自定义定时任务类OrderTask待完善package com.sky.task; /** * 自定义定时任务实现订单状态定时处理 */ Component Slf4j public class OrderTask { Autowired private OrderMapper orderMapper; /** * 处理支付超时订单 */ Scheduled(cron 0 * * * * ?) public void processTimeoutOrder(){ log.info(处理支付超时订单{}, new Date()); } /** * 处理“派送中”状态的订单 */ Scheduled(cron 0 0 1 * * ?) public void processDeliveryOrder(){ log.info(处理派送中订单{}, new Date()); } }讲解下 Scheduled(cron 0 * * * * ?) Scheduled(cron 0 0 1 * * ?)Scheduled(cron 0 * * * * ?)—— 每分钟执行一次拆分来看秒0→ 第 0 秒分*→ 每分钟时*→ 每小时日*→ 每天月*→ 每月周?→ 不指定含义在每一分钟的第 0 秒执行一次即每分钟执行一次。Scheduled(cron 0 0 1 * * ?)—— 每天凌晨1点执行拆分来看秒0→ 第 0 秒分0→ 第 0 分时1→ 凌晨 1 点日*→ 每天月*→ 每月周?→ 不指定含义每天凌晨 1 点整执行一次。2). 在OrderMapper接口中扩展方法:/** * 根据状态和下单时间查询订单 * param status * param orderTime */ Select(select * from orders where status #{status} and order_time #{orderTime}) ListOrders getByStatusAndOrdertimeLT(Integer status, LocalDateTime orderTime);3). 完善定时任务类的processTimeoutOrder方法/** * 处理支付超时订单 */ Scheduled(cron 0 * * * * ?) public void processTimeoutOrder(){ log.info(处理支付超时订单{}, new Date()); LocalDateTime time LocalDateTime.now().plusMinutes(-15); // select * from orders where status 1 and order_time 当前时间-15分钟 ListOrders ordersList orderMapper.getByStatusAndOrdertimeLT(Orders.PENDING_PAYMENT, time); if(ordersList ! null ordersList.size() 0){ ordersList.forEach(order - { order.setStatus(Orders.CANCELLED); order.setCancelReason(支付超时自动取消); order.setCancelTime(LocalDateTime.now()); orderMapper.update(order); }); } }4). 完善定时任务类的processDeliveryOrder方法/** * 处理“派送中”状态的订单 */ Scheduled(cron 0 0 1 * * ?) public void processDeliveryOrder(){ log.info(处理派送中订单{}, new Date()); // select * from orders where status 4 and order_time 当前时间-1小时 LocalDateTime time LocalDateTime.now().plusMinutes(-60); ListOrders ordersList orderMapper.getByStatusAndOrdertimeLT(Orders.DELIVERY_IN_PROGRESS, time); if(ordersList ! null ordersList.size() 0){ ordersList.forEach(order - { order.setStatus(Orders.COMPLETED); orderMapper.update(order); }); } }2.3 功能测试可以通过如下方式进行测试查看控制台sql查看数据库中数据变化支付超时的订单测试1). 查看订单表有一条订单状态为1。订单状态 1待付款 2待接单 3已接单 4派送中 5已完成 6已取消2). 开启定时任务启动服务观察控制台日志。处理支付超时订单任务每隔1分钟执行一次。3). 再次查看订单表状态已更改为6已取消。证明定时任务已生效。处理“派送中”状态的订单任务测试自已完成测试步骤和上述一致。可适当修改cron表达式改变任务执行频率方便测试。3. WebSocket3.1 介绍WebSocket 是基于 TCP 的一种新的网络协议。它实现了浏览器与服务器全双工通信——浏览器和服务器只需要完成一次握手两者之间就可以创建持久性的连接 并进行双向数据传输。HTTP协议和WebSocket协议对比HTTP是短连接WebSocket是长连接HTTP通信是单向的基于请求响应模式WebSocket支持双向通信HTTP和WebSocket底层都是TCP连接思考既然WebSocket支持双向通信功能看似比HTTP强大那么我们是不是可以基于WebSocket开发所有的业务功能WebSocket缺点服务器长期维护长连接需要一定的成本各个浏览器支持程度不一WebSocket 是长连接受网络限制比较大需要处理好重连结论WebSocket并不能完全取代HTTP它只适合在特定的场景下使用WebSocket应用场景1). 视频弹幕2). 网页聊天3). 体育实况更新4). 股票基金报价实时更新3.2 入门案例3.2.1 案例分析需求实现浏览器与服务器全双工通信。浏览器既可以向服务器发送消息服务器也可主动向浏览器推送消息。效果展示实现步骤1). 直接使用websocket.html页面作为WebSocket客户端2). 导入WebSocket的maven坐标3). 导入WebSocket服务端组件WebSocketServer用于和客户端通信4). 导入配置类WebSocketConfiguration注册WebSocket的服务端组件5). 导入定时任务类WebSocketTask定时向客户端推送数据3.2.2 代码开发1). 定义websocket.html页面(资料中已提供)!DOCTYPE HTML html head meta charsetUTF-8 titleWebSocket Demo/title /head body input idtext typetext / button onclicksend()发送消息/button button onclickcloseWebSocket()关闭连接/button div idmessage /div /body script typetext/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 /html2). 导入maven坐标在sky-server模块pom.xml中已定义dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency3). 定义WebSocket服务端组件(资料中已提供)直接导入到sky-server模块即可package com.sky.websocket; import org.springframework.stereotype.Component; import javax.websocket.OnClose; import javax.websocket.OnMessage; import javax.websocket.OnOpen; import javax.websocket.Session; import javax.websocket.server.PathParam; import javax.websocket.server.ServerEndpoint; import java.util.Collection; import java.util.HashMap; import java.util.Map; /** * WebSocket服务 */ Component ServerEndpoint(/ws/{sid}) public class WebSocketServer { //存放会话对象 private static MapString, 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) { CollectionSession sessions sessionMap.values(); for (Session session : sessions) { try { //服务器向客户端发送消息 session.getBasicRemote().sendText(message); } catch (Exception e) { e.printStackTrace(); } } } }代码讲解ServerEndpoint(/ws/{sid})声明这是一个 WebSocket 服务端。前端连接地址类似ws://localhost:8080/ws/123。{sid}路径参数作为客户端的唯一标识苍穹外卖里通常传管理员的 ID 或 UUID。Component让 Spring 管理这个 Bean但注意WebSocket 每建立一个连接都会 new 一个新的WebSocketServer实例Component只是为了 Spring 能扫描到它不能靠它实现单例。private static MapString, Session sessionMap new HashMap();为什么必须是static因为每次客户端连接WebSocket 都会创建一个新的WebSocketServer对象。如果不加static这个 Map 就是每个连接私有的无法实现“给所有客户端群发消息”。为什么用Map存sid, Session的映射。Session是 WebSocket 的会话对象代表一个客户端连接。通过Session才能给对应客户端发消息。坑点HashMap是线程不安全的。多个客户端同时连接、断开时可能会引发并发修改异常ConcurrentModificationException。生产环境建议改用ConcurrentHashMap。实战避坑指南面试高频1.Autowired注入失效问题如果你在WebSocketServer里用Autowired注入OrderService会发现注入为 null。原因WebSocket 每个连接都会 new 一个新实例而 Spring 默认是单例。新实例不会走 Spring 的依赖注入流程。解法方案 A用static字段 Autowired的 setter 方法注入。方案 B通过ApplicationContext手动获取 Bean。2. 集群部署下的 Session 不共享如果苍穹外卖部署了 3 台服务器用户连接到了服务器 A但下单请求被 Nginx 转发到了服务器 B。服务器 B 的sessionMap里没有用户的 Session消息推送失败。解法用Redis 发布订阅Pub/Sub或MQRabbitMQ/Kafka做广播。消息发到 Redis所有服务器订阅后各自推送给本机连接的客户端。3. 心跳保活机制WebSocket 长连接可能因为网络波动、防火墙超时被断开。前端通常需要每隔 30 秒发一个ping消息后端回pong保持连接活跃。4. 线程安全sessionMap建议改成private static MapString, Session sessionMap new ConcurrentHashMap();并且在sendToAllClient里如果发送失败要及时把失效的 Session 移除。5.System.out.println替换为日志生产环境不要用System.out.println应该用Slf4j的log.info。4). 定义配置类注册WebSocket的服务端组件(从资料中直接导入即可)package com.sky.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.server.standard.ServerEndpointExporter; /** * WebSocket配置类用于注册WebSocket的Bean */ Configuration public class WebSocketConfiguration { Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }5). 定义定时任务类定时向客户端推送数据(从资料中直接导入即可)package com.sky.task; import com.sky.websocket.WebSocketServer; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; 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())); } }3.2.3 功能测试启动服务打开websocket.html页面浏览器向服务器发送数据服务器向浏览器间隔5秒推送数据发现浏览器F12 里面网络是空的结果是没选全部...选了全部就有了4. 来单提醒4.1 需求分析和设计用户下单并且支付成功后需要第一时间通知外卖商家。通知的形式有如下两种语音播报弹出提示框设计思路通过WebSocket实现管理端页面和服务端保持长连接状态当客户支付后调用WebSocket的相关API实现服务端向客户端推送消息客户端浏览器解析服务端推送的消息判断是来单提醒还是客户催单进行相应的消息提示和语音播报约定服务端发送给客户端浏览器的数据格式为JSON字段包括typeorderIdcontenttype 为消息类型1为来单提醒 2为客户催单orderId 为订单idcontent 为消息内容4.2 代码开发在OrderServiceImpl中注入WebSocketServer对象修改paySuccess方法加入如下代码Autowired private WebSocketServer webSocketServer; /** * 支付成功修改订单状态 * * param outTradeNo */ public void paySuccess(String outTradeNo) { // 当前登录用户id Long userId BaseContext.getCurrentId(); // 根据订单号查询当前用户的订单 Orders ordersDB orderMapper.getByNumberAndUserId(outTradeNo, userId); // 根据订单id更新订单的状态、支付方式、支付状态、结账时间 Orders orders Orders.builder() .id(ordersDB.getId()) .status(Orders.TO_BE_CONFIRMED) .payStatus(Orders.PAID) .checkoutTime(LocalDateTime.now()) .build(); orderMapper.update(orders); ////////////////////////////////////////////// Map map new HashMap(); map.put(type, 1);//消息类型1表示来单提醒 map.put(orderId, orders.getId()); map.put(content, 订单号 outTradeNo); //通过WebSocket实现来单提醒向客户端浏览器推送消息 webSocketServer.sendToAllClient(JSON.toJSONString(map)); /////////////////////////////////////////////////// }4.3 功能测试可以通过如下方式进行测试查看浏览器调试工具数据交互过程前后端联调1). 登录管理端后台登录成功后浏览器与服务器建立长连接查看控制台日志2). 小程序端下单支付修改回调地址利用内网穿透获取域名下单支付3). 查看来单提醒支付成功后后台收到来单提醒并有语音播报5. 客户催单5.1 需求分析和设计用户在小程序中点击催单按钮后需要第一时间通知外卖商家。通知的形式有如下两种语音播报弹出提示框设计思路通过WebSocket实现管理端页面和服务端保持长连接状态当用户点击催单按钮后调用WebSocket的相关API实现服务端向客户端推送消息客户端浏览器解析服务端推送的消息判断是来单提醒还是客户催单进行相应的消息提示和语音播报约定服务端发送给客户端浏览器的数据格式为JSON字段包括typeorderIdcontenttype 为消息类型1为来单提醒 2为客户催单orderId 为订单idcontent 为消息内容当用户点击催单按钮时向服务端发送请求。接口设计(催单)5.2 代码开发5.2.1 Controller层根据用户催单的接口定义在user/OrderController中创建催单方法/** * 用户催单 * * param id * return */ GetMapping(/reminder/{id}) ApiOperation(用户催单) public Result reminder(PathVariable(id) Long id) { orderService.reminder(id); return Result.success(); }5.2.2 Service层接口在OrderService接口中声明reminder方法/** * 用户催单 * param id */ void reminder(Long id);5.2.3 Service层实现类在OrderServiceImpl中实现reminder方法/** * 用户催单 * * param id */ public void reminder(Long id) { // 查询订单是否存在 Orders orders orderMapper.getById(id); if (orders null) { throw new OrderBusinessException(MessageConstant.ORDER_NOT_FOUND); } //基于WebSocket实现催单 Map map new HashMap(); map.put(type, 2);//2代表用户催单 map.put(orderId, id); map.put(content, 订单号 orders.getNumber()); webSocketServer.sendToAllClient(JSON.toJSONString(map)); }5.2.4 Mapper层在OrderMapper中添加getById/** * 根据id查询订单 * param id */ Select(select * from orders where id#{id}) Orders getById(Long id);5.3 功能测试可以通过如下方式进行测试查看浏览器调试工具数据交互过程前后端联调1). 登录管理端后台登录成功后浏览器与服务器建立长连接查看控制台日志2). 用户进行催单用户可在订单列表或者订单详情进行催单3). 查看催单提醒既有催单弹窗同时语音播报