通过邮件、短信等方式向用户发送通知是一项很常见的业务场景。如何设计一套好用、简洁消息推送架构?这是一个问题。本文将介绍一种支持多种推送方式的消息推送设计模型,该模型可以满足以下功能:
./doc/sql/multi-push-type-demo.sql
drop table if exists message_push_result;
drop table if exists user_message;
drop table if exists user_push_type;
/*==============================================================*/
/* Table: message_push_result */
/*==============================================================*/
create table message_push_result
(
id bigint unsigned not null comment 'id',
message_id bigint unsigned comment '消息id',
push_type tinyint comment '推送方式,1-短信;2-邮件;3-app;4-wechat',
push_result tinyint comment '推送结果,0-失败,1-成功,2-未推送',
push_record varchar(32) comment '推送记录值,部分推送方式可根据记录值查询实际推送结果',
retry_time tinyint default 0 comment '消息发送失败重试次数',
create_time datetime default current_timestamp comment '创建时间',
update_time datetime default current_timestamp on update current_timestamp comment '更新时间',
primary key (id)
)
engine = innodb default
charset = utf8mb4;
alter table message_push_result comment '消息推送结果';
/*==============================================================*/
/* Table: user_message */
/*==============================================================*/
create table user_message
(
id bigint not null comment 'id',
user_id bigint comment '用户信息',
push_count tinyint not null default 0 comment '实际消息推送次数',
push_total tinyint not null default 0 comment '总共消息所需推送次数',
message_type tinyint comment '消息类型;1-登录通知;2-费用通知;3-服务器报警',
title varchar(64) comment '消息标题',
content varchar(256) comment '消息内容',
create_time datetime default current_timestamp comment '创建时间',
update_time datetime default current_timestamp on update current_timestamp comment '更新时间',
primary key (id)
)
engine = innodb default
charset = utf8;
alter table user_message comment '用户消息';
/*==============================================================*/
/* Table: user_push_type */
/*==============================================================*/
create table user_push_type
(
id bigint not null comment 'id',
user_id bigint comment '用户id',
push_type tinyint comment '推送方式,1-短信;2-邮件;3-app;4-wechat',
receive_address varchar(128) comment '通知推送接收地址',
enable tinyint comment '是否启用,0-未启用,1-启用',
create_time datetime default current_timestamp comment '创建时间',
update_time datetime default current_timestamp on update current_timestamp comment '更新时间',
primary key (id)
)
engine = innodb default
charset = utf8mb4;
alter table user_push_type comment '用户消息推送方式';
用户推送方式、用户消息的基本 CRUD 这里就不做具体展示,有需要的可以查看 Github 源码。
这里重点看消息推送以及重试模块的代码逻辑
消息推送功能请求参数:
demo-knife4j-openapi3/src/main/java/com/ljq/demo/springboot/knife4j/model/param/messagepush/MessagePushParam.java
package com.ljq.demo.springboot.knife4j.model.param.messagepush;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* @Description: 消息推送请求参数
* @Author: junqiang.lu
* @Date: 2023/8/16
*/
@Data
@Schema(description = "消息推送请求参数")
public class MessagePushParam implements Serializable {
private static final long serialVersionUID = -9163471284052059262L;
@Schema(description = "消息id", requiredMode = Schema.RequiredMode.REQUIRED)
private Long messageId;
}
推送功能业务实现:
demo-knife4j-openapi3/src/main/java/com/ljq/demo/springboot/knife4j/service/impl/UserMessageServiceImpl.java
/**
* 推送消息
*
* @param pushParam
* @return
*/
@Override
public ApiResult push(MessagePushParam pushParam) {
// 参数校验
UserMessageEntity userMessageDb = super.getById(pushParam.getMessageId());
// 校验消息是否存在
if (Objects.isNull(userMessageDb)) {
return ApiResult.fail(ApiMsgEnum.USER_MESSAGE_NOT_EXIST);
}
// 校验消息是否重复推送
if (userMessageDb.getPushTotal() > 0) {
return ApiResult.fail(ApiMsgEnum.USER_MESSAGE_PUSH_REPEAT);
}
// 查询用户支持的推送方式
List<UserPushTypeEntity> userPushTypeDbList = userPushTypeService.list(Wrappers
.lambdaQuery(UserPushTypeEntity.class)
.eq(UserPushTypeEntity::getUserId, userMessageDb.getUserId()));
// 设置推送总次数
userMessageDb.setPushCount(0);
userMessageDb.setPushTotal(userPushTypeDbList.size());
super.updateById(userMessageDb);
// TODO 升级思路: 异步推送
// 根据支持类型推送
for (UserPushTypeEntity pushTypeEntity: userPushTypeDbList) {
switch (pushTypeEntity.getPushType()) {
case MessagePushConst.USER_PUSH_TYPE_SMS:
// 短信推送
messagePushBySms(userMessageDb, pushTypeEntity.getReceiveAddress());
continue;
case MessagePushConst.USER_PUSH_TYPE_EMAIL:
// 邮件推送
messagePushByEmail(userMessageDb, pushTypeEntity.getReceiveAddress());
continue;
case MessagePushConst.USER_PUSH_TYPE_APP:
// APP推送
messagePushByApp(userMessageDb, pushTypeEntity.getReceiveAddress());
continue;
case MessagePushConst.USER_PUSH_TYPE_WECHAT:
// 微信推送
messagePushByWechat(userMessageDb, pushTypeEntity.getReceiveAddress());
continue;
default:
log.warn("user push type is invalid. pushType:{}", pushTypeEntity.getPushType());
}
}
return ApiResult.success();
}
短信推送方法:
/**
* 短信推送消息
*
* @param userMessage
* @param receiveAddress
*/
private void messagePushBySms(UserMessageEntity userMessage, String receiveAddress) {
log.info("message pushed by sms. messageId:{},receiveAddress:{},messageTitle:{}",
userMessage.getId(), receiveAddress, userMessage.getTitle());
// 查询推送结果
LambdaQueryWrapper<MessagePushResultEntity> wrapper = Wrappers.lambdaQuery(MessagePushResultEntity.class)
.eq(MessagePushResultEntity::getMessageId, userMessage.getId())
.eq(MessagePushResultEntity::getPushType, MessagePushConst.USER_PUSH_TYPE_SMS);
MessagePushResultEntity pushResultDb = pushResultService.getOne(wrapper);
// 预先创建推送结果
MessagePushResultEntity pushResult = new MessagePushResultEntity();
pushResult.setMessageId(userMessage.getId());
pushResult.setPushType(MessagePushConst.USER_PUSH_TYPE_SMS);
try {
// 模拟消息发送
Thread.sleep(100L);
// 更新推送结果
pushResult.setPushResult(MessagePushConst.MESSAGE_SEND_SUCCESS);
if (Objects.isNull(pushResultDb)) {
// 初次推送
pushResult.setRetryTime(0);
pushResultService.save(pushResult);
} else {
// 重试推送
pushResultService.update(pushResult, wrapper);
}
// 更新推送次数
userMessage.setPushCount(userMessage.getPushCount() + 1);
super.updateById(userMessage);
} catch (Exception e) {
log.error("sms message push error", e);
// 设置重试次数
pushResult.setPushResult(MessagePushConst.MESSAGE_SEND_FAIL);
if (Objects.isNull(pushResultDb)) {
// 初次推送
pushResult.setRetryTime(1);
pushResultService.save(pushResult);
} else {
// 重试推送
pushResult.setRetryTime(pushResultDb.getRetryTime());
pushResultService.update(pushResult, wrapper);
}
}
}
该方法中适配了初次推送以及重试推送
其他邮件、APP、微信推送的结构与此一样,代码不再重复贴出
重试机制这里使用的通过时定时任务扫描失败的消息,然后进行消息重推
查询推送失败的消息:
demo-knife4j-openapi3/src/main/resources/mybatis/UserMessageMapper.xml
<select id="queryPageFailMessage" resultMap="userMessageMap" >
SELECT
<include refid="user_message_base_field" />
FROM `user_message` m
WHERE m.push_count < m.push_total
AND (DATEDIFF(NOW(),m.create_time) < 1)
select>
消息重试方法:
demo-knife4j-openapi3/src/main/java/com/ljq/demo/springboot/knife4j/service/impl/UserMessageServiceImpl.java
/**
* 重新推送消息
*
* @param userMessage
* @return
*/
@Override
public void repush(UserMessageEntity userMessage) {
// 查询用户支持的推送方式
List<UserPushTypeEntity> userPushTypeDbList = userPushTypeService.list(Wrappers
.lambdaQuery(UserPushTypeEntity.class)
.eq(UserPushTypeEntity::getUserId, userMessage.getUserId()));
Map<Integer, String> pushTypeMap = userPushTypeDbList.stream()
.collect(Collectors.toMap(UserPushTypeEntity::getPushType, UserPushTypeEntity::getReceiveAddress));
// 查询推送失败的结果
List<MessagePushResultEntity> pushResultList = pushResultService.list(Wrappers
.lambdaQuery(MessagePushResultEntity.class)
.eq(MessagePushResultEntity::getMessageId, userMessage.getId())
.eq(MessagePushResultEntity::getPushResult, MessagePushConst.MESSAGE_SEND_FAIL));
// TODO 升级思路: 异步推送
// 根据支持类型推送
for (MessagePushResultEntity pushResult: pushResultList) {
switch (pushResult.getPushType()) {
case MessagePushConst.USER_PUSH_TYPE_SMS:
// 短信推送
messagePushBySms(userMessage, pushTypeMap.get(pushResult.getPushType()));
continue;
case MessagePushConst.USER_PUSH_TYPE_EMAIL:
// 邮件推送
messagePushByEmail(userMessage, pushTypeMap.get(pushResult.getPushType()));
continue;
case MessagePushConst.USER_PUSH_TYPE_APP:
// APP推送
messagePushByApp(userMessage, pushTypeMap.get(pushResult.getPushType()));
continue;
case MessagePushConst.USER_PUSH_TYPE_WECHAT:
// 微信推送
messagePushByWechat(userMessage, pushTypeMap.get(pushResult.getPushType()));
continue;
default:
log.warn("user push type is invalid. pushType:{}", pushResult.getPushType());
}
}
}
定时任务扫描:
demo-knife4j-openapi3/src/main/java/com/ljq/demo/springboot/knife4j/job/MessagePushSchedule.java
package com.ljq.demo.springboot.knife4j.job;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.ljq.demo.springboot.knife4j.model.BasePageParam;
import com.ljq.demo.springboot.knife4j.model.entity.UserMessageEntity;
import com.ljq.demo.springboot.knife4j.service.UserMessageService;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
/**
* @Description: 消息推送定时任务
* @Author: junqiang.lu
* @Date: 2023/8/17
*/
@Component
public class MessagePushSchedule {
@Resource
private UserMessageService userMessageService;
/**
* 消息重试
* 300 秒(5分钟) 1 次
*/
@Scheduled(fixedDelay = 300000L, initialDelay = 10000L)
public void checkAndRepush() {
// 统计所有当天发送失败的消息
int pageSize = 1000;
BasePageParam pageParam = new BasePageParam();
pageParam.setCurrentPage(1);
pageParam.setPageSize(pageSize);
IPage<UserMessageEntity> pageResult = userMessageService.queryPageFailMessage(pageParam);
if (pageResult.getTotal() < 1) {
return;
}
long countAll = pageResult.getTotal();
long times = countAll % pageSize == 0 ? countAll / pageSize : (countAll / pageSize) + 1;
pageResult.getRecords().forEach(userMessage -> userMessageService.repush(userMessage));
for (int i = 2; i < times + 1; i++) {
pageParam.setCurrentPage(i);
pageResult = userMessageService.queryPageFailMessage(pageParam);
if (CollUtil.isEmpty(pageResult.getRecords())) {
continue;
}
pageResult.getRecords().forEach(userMessage -> userMessageService.repush(userMessage));
}
}
}
以上代码主要为多类型消息推送实现的基本方案,如果要应对大流量,可以有以下升级优化思路:
Gtihub 源码地址 : https://github.com/Flying9001/springBootDemo/tree/master/demo-knife4j-openapi3
个人公众号:404Code,分享半个互联网人的技术与思考,感兴趣的可以关注.