分享一个超期提醒的方案-Redis

1,531 阅读6分钟

需求背景

  • 项目进行过程中,接到一个新需求:工作流任务到达某一节点,超过一定时间未办理则触发超期提醒,提醒任务办理人:您的某某任务已超期,请尽快办理
  • 首先想到定时任务,定时触发查询当前的待办任务是否超过预定的时间,但是定时任务的执行频次不好确定,而且做不到实时响应
  • 当然也可以引入MQ,但是对我们项目来说太重了,为了一个消息提醒功能不值得

实现思路

  • 之前项目引入了Redis做缓存,想到了通过Redis的key过期监听机制,可以用来触发超期逻辑,而且满足实时的条件
  • 不同的工作流不同的节点有不同的超期逻辑,但基本思路都是到达某一节点超过多少时间未办理,触发一个提醒,所以这块可以通过策略模式处理下
  • 手绘业务流程图image.png

具体代码

Redis开启key过期监听

修改Redis的配置文件,找到 notify-keyspace-events 配置项,默认为 "",修改为 Ex ,即打开过期key监听 image.png

项目中过期监听的实现

public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener {

    @Autowired
    private MessageHandlerManager handlerManager;


    public RedisKeyExpirationListener(RedisMessageListenerContainer listenerContainer) {
        super(listenerContainer);
    }

    /**
     * 针对redis数据失效事件,进行数据处理
     *
     * @param message
     * @param pattern
     */
    @Override
    public void onMessage(Message message, byte[] pattern) {
        // 用户做自己的业务处理即可,注意message.toString()可以获取失效的key
        String expiredKey = message.toString();
        log.info("onMessage --> redis 过期的key是:{}", expiredKey);
        try {
            // 对过期key进行处理
            handlerManager.processingHandler(expiredKey);
            log.info("过期key处理完成:{}", expiredKey);
        } catch (Exception e) {
            log.error("处理redis 过期的key异常:{}", expiredKey, e);
        }
    }

}

构建超期逻辑的策略模式

  1. 设计一个策略接口,提供一个处理key的方法,这里继承InitializingBean是让 IMessageHandler 提供一个约束:所有的实现类都需要提供注册方法,并在对象实例构造后注册
public interface IMessageHandler extends InitializingBean {
    /**
     * 消息具体执行方法
     * @param expiredKey 消息key
     */
    void handle(String expiredKey);
}
  1. 一个简单地策略实现类
  • key的构成是需要提前规定好的。我们是taskID@PIID@节点办理人,如果还有自定义内容可以在后面继续拼接
  • 这里通过afterPropertiesSet方法自动注册该实现类,并声明该类可以处理什么样的key
  • 后续如果有新的工作流要做超期提醒,只需要在此扩展就行
public class ExpressReturnOrderReturnChief implements IMessageHandler {
    //一个公用的service,用于查询工作流有无待办并判断是否发送消息
    private final BaseMessListenService messListenService;

    /**
     * 消息具体执行方法
     *
     * @param expiredKey 消息key
     */
    @Override
    public void handle(String expiredKey) {
        log.info(" 正在处理快返单-返件单位-24小时科长通知");
        //redis的key是包含项目前缀和业务功能自定义前缀的,需要手动去除
        String[] split = expiredKey.replace(RedisConstant.KAIYI_QMS + RedisConstant.FLOW_EXPRESS_RETURN_ORDER_FANJIANFENXI_KEZHANG, "").split("@");
        //调用封装好的超期提醒方法
        messListenService.checkFlowWithSendMessage(split, "快返单申请(%s),%s已经延期,请您催促、监督,请登录系统查看: https://xxxxxx/", false, expiredKey,"WeChat",null);
    }

    @Override
    public void afterPropertiesSet() {
        //自动注册
        //这里通过定义好的redis前缀来区分不同业务模块的消息
        MessageHandlerManager.register(RedisConstant.KAIYI_QMS+ RedisConstant.FLOW_EXPRESS_RETURN_ORDER_FANJIANFENXI_KEZHANG, this);
    }
}
  1. 策略管理类
  • 该策略管理类通过register方法,让策略实现类在初始化后就自动注册到MESSAGE_HANDLERS中

  • redis中的key过期时会被监听到,然后调用processingHandler方法,进行匹配key前缀

    1. 如果匹配到,则自动调用对应的实现类进行处理
    2. 如果匹配不到,则不是我们需要的,直接结束就可以
  • 在调用策略实现类的handle处理业务前,对当前key进行加锁处理,防止分布式场景下多个服务共用处理,造成重复提醒

public class MessageHandlerManager {
    
    private static final Map<String, IMessageHandler> MESSAGE_HANDLERS = new ConcurrentHashMap<>();
    private final RedisTemplate<String, Object> redisTemplate;
    
    /**
     * 统一注册策略类
     * @param type      该类可以处理的key的前缀
     * @param handler   对应的策略实现类
     */
    public static void register(String type, IMessageHandler handler) {
        MESSAGE_HANDLERS.put(type, handler);
    }

    /**
     * 消息处理方法
     * 默认结构是  kaiyi.qms: 自定义目录结构 taskID@piid@消息接收人
     * 可自己向后扩展
     * @param expiredKey key
     */
    public void processingHandler(String expiredKey) {
        log.info("消息监听执行打印:{}",expiredKey);
        //根据key的前缀找对应的策略实现类
        Optional<String> first = MESSAGE_HANDLERS.keySet().stream().filter(expiredKey::startsWith).findFirst();
        //如果存在则执行后续逻辑,如果不存在则不是需要监听的key,不用处理
        first.ifPresent(s -> doTempAbsent(expiredKey,MESSAGE_HANDLERS.get(s)));
    }

    /**
     * 加锁,然后处理消息
     * @param key key
     * @param handler 消息处理类
     */
    private void doTempAbsent(String key, IMessageHandler handler){
        // 临时key,此key可以在业务处理完,然后延迟一定时间删除,或者不处理
        String tempKey = RedisUtil.md5(key, "UTF-8");
        // 临时key不存在才设置值,key超时时间为10秒(此处相当于分布式锁的应用)
        Boolean exist = redisTemplate.opsForValue().setIfAbsent(tempKey, "1", 10, TimeUnit.SECONDS);
        if (Boolean.FALSE.equals(exist)) {
            log.error("其他服务正在处理");
            return;
        }
        //对应策略实现类进行处理
        handler.handle(key);
    }
}

公用处理超期的方法

目前的超期逻辑是redis的key过期时,查询该任务节点还在不在;存在的区别:大多数节点通过企业微信提醒, 部分节点通过邮件提醒;有的节点提醒一次就行,有的节点需要等待24小时继续提醒;此部分通过不同的入参进行控制

public class BaseMessListenService {

    // 消息类型-企业微信 
    private static final String WE_CHAT = "WeChat";
    // 消息类型-邮件 
    private static final String MAIL = "mail";
  
    private final WorkFlowService workFlowService;
    private final SysUserProvider sysUserProvider;
    private final WeChatSendUtil weChatSendUtil;
    private final MailSendUtil mailSendUtil;

    /**
     * 校验流程是否在当前节点,发送消息提醒
     *
     * @param split      0 taskId 1 piid 2 接收人
     * @param format     消息体
     * @param isSend     是否重复推送
     * @param expiredKey redis key
     * @param sendType   发送方式 WeChat/mail
     * @param title      标题 邮件
     */
    public void checkFlowWithSendMessage(String[] split, String format, boolean isSend, String expiredKey, String sendType, String title) {
        PiinstanceDetailQuery piinstanceDetailQuery = new PiinstanceDetailQuery();
        piinstanceDetailQuery.setPiid(split[1]);
        piinstanceDetailQuery.setWhetherQueryToCompleteTask(false);
        piinstanceDetailQuery.setMustHaveLoginUser(false);
        // 根据PIID查询流程实例详情,查不到则结束
        PFPIDetailInstance pfpiDetailInstance = workFlowService.queryPiDetail(piinstanceDetailQuery);
        if (Objects.isNull(pfpiDetailInstance)) {
            return;
        }
        // 获取当前运行的节点,如果没有则结束
        List<PFNodeUserTask> currentRunningNodes = pfpiDetailInstance.getPiListInstance().getCurrentRuningNodes();
        if (JinhuiCollectionUtil.isEmpty(currentRunningNodes)) {
            return;
        }
        // 遍历节点list,跟失效key中的taskID进行比较,不一致则跳过,一致则发送消息
        for (PFNodeUserTask currentRunningNode : currentRunningNodes) {
            String taskId = currentRunningNode.getTaskIdList().get(0);
            if (!split[0].equals(taskId)) {
                continue;
            }
            // 组装需要发送的消息内容
            String userid = currentRunningNode.getIdentitysList().get(0).getUserid();
            SysUserVO sysUserVO = sysUserProvider.queryByUsername(userid);
            userid = Objects.nonNull(sysUserVO) ? sysUserVO.getRealName() : userid;
            String message = String.format(format, pfpiDetailInstance.getPiListInstance().getTitle(), userid);
            // 调用发送消息方法
            sendMessage(title, message, split[2], sendType);
            // 判断是否需要重复进行超期监听
            if (isSend) {
                // 将key再次放入redis中,目前重复提醒间隔时间默认24小时
                JinhuiRedisUtil.setString(expiredKey, "");
                JinhuiRedisUtil.expire(expiredKey, 1, TimeUnit.DAYS);
            }
        }
    }

    /**
     * 消息发送
     *
     * @param title      标题
     * @param message    消息内容
     * @param recepUsers 接收人
     * @param sendType   发送类型
     */
    public void sendMessage(String title, String message, String recepUsers, String sendType) {
        if (JinhuiStringUtil.isBlank(message) || JinhuiStringUtil.isBlank(recepUsers)) {
            return;
        }
        // 异步消息发送
        CompletableFuture.runAsync(() -> {
            // 发送企业微信消息
            if (WE_CHAT.equals(sendType)) {
                weChatSendUtil.sendMsg(recepUsers, message);
            }
            // 发送邮件消息
            if (MAIL.equals(sendType)) {
                mailSendUtil.sendMsg(recepUsers, title, message);
            }
        });
    }
}

超期逻辑的使用

public Boolean submit(Long id) {
      ...
      //流程提交后到达快返单-返件分析节点,设置超期提醒
      // redis key 业务模块前缀+taskId+@+piid+@+节点办理人
      String key = new StringBuilder().append("flow:expressReturnOrder:fanjianfenxi:kezhang:").append(taskId).append("@").append(pfpiStartResult.getPiid()).append("@").append(returnOrder.getReturnManagerCheif()).toString();
      // 设置redis key
      JinhuiRedisUtil.setString(key, "1");
      // 设置有效期为一天,即一天后触发超期提醒逻辑
      JinhuiRedisUtil.expire(key, 1, TimeUnit.DAYS);
     
      ...
}

小结

当然针对这个需求还会有更好的解决方案,这只是针对我们项目的一种解决方案,目前运行良好,先分享出来,仅供参考