需求背景
- 项目进行过程中,接到一个新需求:工作流任务到达某一节点,超过一定时间未办理则触发超期提醒,提醒任务办理人:您的某某任务已超期,请尽快办理
- 首先想到定时任务,定时触发查询当前的待办任务是否超过预定的时间,但是定时任务的执行频次不好确定,而且做不到实时响应
- 当然也可以引入MQ,但是对我们项目来说太重了,为了一个消息提醒功能不值得
实现思路
- 之前项目引入了Redis做缓存,想到了通过Redis的key过期监听机制,可以用来触发超期逻辑,而且满足实时的条件
- 不同的工作流不同的节点有不同的超期逻辑,但基本思路都是到达某一节点超过多少时间未办理,触发一个提醒,所以这块可以通过策略模式处理下
- 手绘业务流程图
具体代码
Redis开启key过期监听
修改Redis的配置文件,找到 notify-keyspace-events 配置项,默认为 "",修改为 Ex ,即打开过期key监听
项目中过期监听的实现
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);
}
}
}
构建超期逻辑的策略模式
- 设计一个策略接口,提供一个处理key的方法,这里继承InitializingBean是让 IMessageHandler 提供一个约束:所有的实现类都需要提供注册方法,并在对象实例构造后注册
public interface IMessageHandler extends InitializingBean {
/**
* 消息具体执行方法
* @param expiredKey 消息key
*/
void handle(String expiredKey);
}
- 一个简单地策略实现类
- 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);
}
}
- 策略管理类
-
该策略管理类通过register方法,让策略实现类在初始化后就自动注册到MESSAGE_HANDLERS中
-
redis中的key过期时会被监听到,然后调用processingHandler方法,进行匹配key前缀
- 如果匹配到,则自动调用对应的实现类进行处理
- 如果匹配不到,则不是我们需要的,直接结束就可以
-
在调用策略实现类的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);
...
}
小结
当然针对这个需求还会有更好的解决方案,这只是针对我们项目的一种解决方案,目前运行良好,先分享出来,仅供参考