支持多种推送方式的消息推送设计方案

1 摘要

通过邮件、短信等方式向用户发送通知是一项很常见的业务场景。如何设计一套好用、简洁消息推送架构?这是一个问题。本文将介绍一种支持多种推送方式的消息推送设计模型,该模型可以满足以下功能:

  • 保存推送消息本身
  • 一套消息文本支持多种类型消息推送
  • 消息推送记录占用空间小
  • 支持消息重试推送机制

2 消息推送架构

multi-type-message-push-v1.0

3 数据库设计脚本

./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 '用户消息推送方式';

4 核心代码

用户推送方式、用户消息的基本 CRUD 这里就不做具体展示,有需要的可以查看 Github 源码。

这里重点看消息推送以及重试模块的代码逻辑

4.1 消息推送业务实现

消息推送功能请求参数:

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、微信推送的结构与此一样,代码不再重复贴出

4.2 消息重试推送

重试机制这里使用的通过时定时任务扫描失败的消息,然后进行消息重推

查询推送失败的消息:

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 &lt; m.push_total
            AND (DATEDIFF(NOW(),m.create_time) &lt; 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));
        }
    }


}

5 可优化思路

以上代码主要为多类型消息推送实现的基本方案,如果要应对大流量,可以有以下升级优化思路:

  • 将要推送的消息放到消息队列中
  • 使用线程池做专门的消息推送处理
  • 将推送失败的消息放到延时队列中,不使用定期扫表的方式

6 Github 源码

Gtihub 源码地址 : https://github.com/Flying9001/springBootDemo/tree/master/demo-knife4j-openapi3

个人公众号:404Code,分享半个互联网人的技术与思考,感兴趣的可以关注.
404Code

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值