前言
系统有一个需求就是采购员审批注册供应商的信息时,会生成一个供应商的账号,此时需要发送供应商的账号信息(账号、密码)到注册填写的邮箱中,通知供应商账号信息,当时很快就写好了一个工具类,用来发送普通的文本邮件信息。但是随着系统的迭代,后面又新增了一些需求,比如一些单据需要在供应商确认时,发送一条站内信到首页,这样采购员登录时就可以看到最新的单据信息,进行相应的处理;或者采购员创建一些单据时,需要发送站内信到首页,然后供应商登录系统时,可以看到最新单据信息并进行处理,因此,我在原有的工具类基础上,修改发送邮件信息的方法,加入了消息类型参数,并根据消息类型,调用相应的方法处理;过了一段时间,业务又找了过来,说当用户修改密码时,需要发送一个短信验证码,验证码输对了才给他修改,接着我又在工具类里面,加入了处理短信的发送逻辑。
伪代码如下:
java
@Component
public class NoticeSendUtils {
// 省略其他配置
/**
* 发送消息
*
* @param params 参数
* @param type 消息类型(0-邮件消息,1-站内信消息,2-短信消息)
* @param content 消息内容
*/
public void sendMessage(Object params, Integer type, String content) {
if (type.equals(0)) {
this.sendMailMessage(params, content);
} else if (type.equals(1)) {
this.sendStationMessage(params, content);
} else {
this.sendPhoneMessage(params, content);
}
}
/**
* 发送邮件消息
*
* @param params
* @param content
*/
private void sendMailMessage(Object params, String content) {
// 处理邮件消息
}
/**
* 发送站内信消息
*
* @param params
* @param content
*/
private void sendStationMessage(Object params, String content) {
// 处理站内信消息
}
/**
* 发送短信消息
*
* @param params
* @param content
*/
private void sendPhoneMessage(Object params, String content) {
// 处理短信消息
}
存在问题
- 当需要新增一种消息发送类型时,需要修改该工具类加上if-else逻辑,处理新的消息类型发送,这违背了开放封闭原则(软件实体应该对扩展开放,对修改封闭。这意味着当软件需要适应新的需求时,应该通过添加新的代码来扩展系统的行为,而不是修改已有的代码),新增一种消息类型,就要修改该类原有的方法
- 调用者调用时,需要指定消息类型和内容,系统就会存在大量这样的调用代码,如果需要在发送消息的方法新增参数,那么所有调用者都需要改变新增参数,系统后期就会非常难维护
- 没有对消息发送过程产生的异常进行处理,无法知晓消息有没有发送成功
因此,趁着最近没有什么需求,对消息发送功能采用策略模式进行了重构,由消息模板的类型决定调用相应的消息类型处理类处理消息发送,独立维护了一个消息中心模块,也提供页面管理功能,可对消息发送模板进行配置,并且存储了消息发送记录,这样可以知晓消息有没有发送成功,对原有的消息发送功能进行了解耦。
以下仅提供部分核心代码和相关表设计,关键的是其中的设计思想
使用
消息规则配置
主要配置发送方的邮箱配置和短信功能账号配置,系统采用了阿里云的短信服务,所以配置了阿里云的短信服务的账号和密码;在发送消息时,先查一下这里面的配置,比如发送邮箱消息,则查询规则类型为邮箱的信息,查询到了就调用相应的方法发送消息
如图所示
消息模板配置
主要配置消息模板,每个消息模板都有唯一的模板编码,一个消息模板可以有多个适用规则,比如一个模板有短信和站内信的适用规则,那么当调用者使用这个模板时,会同时发送一个站内信(首页待办消息展示)和一封邮件信息
列表页面如图所示
修改页面如图所示
- 短信相关配置只有适用规则为短信才必填
- PC-地址主要是为了站内信实现点击消息时,跳转到对应页面
- 短信模板编码由阿里云短信服务提供
- 调用者的参数字段名称需要和模板内容的${}表达式中的名称一致(使用了freemarker进行模板渲染)
消息发送记录
主要查看消息有没有发送成功
消息接收中心
主要显示站内信发送情况
设计
消息规则配置表
mysql
CREATE TABLE `msg_configuration` (
`id` varchar(36) NOT NULL COMMENT '主键',
`code` varchar(255) DEFAULT NULL COMMENT '编码',
`ip` varchar(255) DEFAULT NULL COMMENT 'ip',
`password` varchar(255) DEFAULT NULL COMMENT '密码',
`port` varchar(50) DEFAULT NULL COMMENT '端口',
`protocol` varchar(100) DEFAULT NULL COMMENT '协议名称',
`type` int(11) DEFAULT NULL COMMENT '0邮箱 1短信',
`username` varchar(255) DEFAULT NULL COMMENT '用户名',
`enable` int(11) DEFAULT NULL COMMENT '0未启用 1启用',
`create_date` datetime DEFAULT NULL COMMENT '创建时间',
`creator` varchar(36) DEFAULT NULL COMMENT '创建人',
`update_date` datetime DEFAULT NULL COMMENT '修改时间',
`modifier` varchar(36) DEFAULT NULL COMMENT '最后修改人',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC COMMENT='消息规则配置表';
消息模板配置表
mysql
CREATE TABLE `msg_public_template` (
`id` varchar(36) NOT NULL COMMENT '主键',
`code` varchar(200) DEFAULT NULL COMMENT '模板编号',
`sys_notice_content` mediumtext COMMENT '模板内容',
`message_code` varchar(100) DEFAULT NULL COMMENT '短信编码',
`message_type_code` varchar(200) DEFAULT NULL COMMENT '消息类型 1站内信 2邮件 3短信',
`name` varchar(200) DEFAULT NULL COMMENT '模板名称',
`notice_type_code` tinyint(2) DEFAULT NULL COMMENT '通知类型快码',
`service_module_code` varchar(100) DEFAULT NULL COMMENT '业务模块快照编码',
`template_type_code` tinyint(7) DEFAULT NULL COMMENT '模板类型快照编码',
`title` varchar(200) DEFAULT NULL COMMENT '消息模板标题',
`pc_url` varchar(255) DEFAULT NULL COMMENT 'PC-跳转地址',
`business_obj_id` varchar(36) DEFAULT NULL COMMENT '业务对象',
`notice_enabled_flag` tinyint(2) DEFAULT NULL COMMENT '通知是否启用(1.启用/0.不启用)',
`create_date` datetime DEFAULT NULL COMMENT '创建时间',
`creator` varchar(36) DEFAULT NULL COMMENT '创建人',
`update_date` datetime DEFAULT NULL COMMENT '修改时间',
`modifier` varchar(36) DEFAULT NULL COMMENT '最后修改人',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC COMMENT='消息模板配置表';
消息发送记录表
mysql
CREATE TABLE `msg_send_record` (
`id` varchar(36) NOT NULL,
`content` mediumtext COMMENT '发送内容',
`msg_public_template_id` varchar(200) DEFAULT NULL COMMENT '消息模板Id',
`read_flag` tinyint(2) DEFAULT NULL COMMENT '已读状态(1.已读/0.未读)',
`receiver_name` varchar(100) DEFAULT NULL COMMENT '接收人姓名',
`receiver_uid` varchar(36) DEFAULT NULL COMMENT '接收人主键',
`send_time` datetime DEFAULT NULL COMMENT '发送时间',
`send_type` tinyint(2) DEFAULT NULL COMMENT '通知渠道 1站内信 2邮件 3短信',
`status` tinyint(2) DEFAULT NULL COMMENT '发送状态(1.发送中/2.发送成功/3.发送失败)',
`title` varchar(200) DEFAULT NULL COMMENT '发送主题',
`business_id` varchar(36) DEFAULT NULL COMMENT '业务id(跳转页面链接可以拼接相关id跳转)',
`create_date` datetime DEFAULT NULL COMMENT '创建时间',
`creator` varchar(36) DEFAULT NULL COMMENT '创建人',
`update_date` datetime DEFAULT NULL COMMENT '修改时间',
`modifier` varchar(36) DEFAULT NULL COMMENT '最后修改人',
`error_msg` varchar(1000) DEFAULT NULL COMMENT '错误信息',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC COMMENT='消息发送记录表';
实现
如图所示,经过策略模式设计如下,后续有新的消息类型增加,只需要新增一个具体策略类实现相关发送逻辑即可,无需修改原有的代码,没有违背开放封闭原则
- 消息发送类型的抽象策略类NoticeExchanger,规定了具体策略类必须重写的抽象方法match(是否支持当前消息类型发送)、exchanger(处理消息发送),以及自己实现的saveMessageRecord方法(保存消息发送记录)、parseMessage方法(解析模板内容和标题)
- 具体策略类EmailNoticeExchanger,负责邮件消息的发送
- 具体策略类StationNoticeExchanger,负责站内信的发送
- 具体策略类SmsNoticeExchanger,负责短信消息的发送
- 环境类NoticeServiceImpl,维护一个策略对象的引用集合,负责将消息发送请求委派给具体的策略对象执行
抽象策略类NoticeExchanger
java
public abstract class NoticeExchanger {
@Resource
private MsgSendRecordService msgSendRecordService;
/**
* 是否支持当前消息类型发送(true-支持,false-不支持)
* @param type 消息类型
* @return
*/
public abstract boolean match(String type);
/**
* 处理消息发送
*
* @param map 相关参数
* @return
*/
public abstract boolean exchanger(Map<String, Object> map) throws Exception;
/**
* 使用Freemarker解析模板内容和标题
*
* @param notice 相关参数
* @return
*/
public Map<String, Object> parseMessage(Map<String, Object> notice){
MsgPublicTemplate msgPublicTemplate = notice.get("msgPublicTemplate");
if(msgPublicTemplate==null){
throw new CommonException(ExceptionDefinition.TEMPLATE_NOT_FOUND);
}
if(msgPublicTemplate.getNoticeEnabledFlag().intValue()==0){
throw new CommonException(ExceptionDefinition.TEMPLATE_NOT_ENABLED);
}
//freemarker解析模板,填充模板内容
//标题
String title=msgPublicTemplate.getTitle();
//内容
String sysNoticeContent = msgPublicTemplate.getContent();
Map<String, Object> params = notice.get("params");
try {
title= FreemarkerUtils.generateContent(params,title);
sysNoticeContent=FreemarkerUtils.generateContent(params,sysNoticeContent);
} catch (Exception e) {
throw new CommonException(ExceptionDefinition.TRANSFORMATION_OF_THE_TEMPLATE);
}
Map<String, Object> result = new HashMap<>();
result.put("title", title);
result.put("sysNoticeContent", sysNoticeContent);
return result;
}
/**
* 保存消息发送记录
*
* @param msgSendRecordDto 相关参数
* @return
*/
public void saveSendMessage(MsgSendRecordDto msgSendRecordDto){
// ...参数校验
String [] ids = msgSendRecordDto.getUserId().split(",");
String [] names = msgSendRecordDto.getUserName().split(",");
// ...参数填充
//是否多个用户
if (ids.length == 0) {
msgSendRecord.setReceiverName(msgSendRecordDto.getUserName())
.setReceiverUid(msgSendRecordDto.getUserId());
msgSendRecordService.save(msgSendRecord);
}
if (ids.length > 0) {
List<MsgSendRecord> msgSendRecordList = Lists.newArrayList();
for (int i = 0; i < ids.length; i++) {
MsgSendRecord data = BeanUtils.copyProperties(msgSendRecordDto, MsgSendRecord.class);
data.setReceiverUid(ids[i]);
data.setReceiverName(names[i]);
msgSendRecordList.add(data);
}
msgSendRecordService.saveBatch(msgSendRecordList);
}
}
}
具体策略类EmailNoticeExchanger
发送邮件
java
@Component
public class EmailNoticeExchanger extends NoticeExchanger {
private Logger logger = LoggerFactory.getLogger(EmailNoticeExchanger.class);
@Autowired
private ISendEmailService sendEmailService;
@Autowired
private MsgConfigurationMapper msgConfigurationMapper;
/**
* 是否支持邮件发送
*
* @param type 消息类型
* @return
*/
@Override
public boolean match(String type) {
if (!String.valueOf(SendTypeEnum.EMAIL.getItem()).equals(type)) {
return false;
}
return true;
}
/**
* 处理消息发送
*
* @param map 相关参数
* @return
*/
@Override
public boolean exchanger(Map<String, Object> map) throws Exception {
EmailNotice notice = new EmailNotice();
BeanUtils.populate(notice, map);
String code = notice.getCode();
Map<String, Object> params = notice.getParams();
// 解析模板内容和标题
Map<String, Object> objectMap = parseMessage(map);
String title = objectMap.get("title") == null ? "" : objectMap.get("title").toString();
String sysNoticeContent = objectMap.get("sysNoticeContent") == null ? "" : objectMap.get("sysNoticeContent").toString();
try {
// 查询邮箱配置
MsgConfiguration msgConfiguration = 省略...
if (msgConfiguration == null) {
throw new CommonException(ExceptionDefinition.NO_LAUNCH_CONFIGURATION);
}
// 组装参数发送邮件
EmailConfig emailConfig = new EmailConfig();
emailConfig.setUsername(msgConfiguration.getUsername());
emailConfig.setPassword(msgConfiguration.getPassword());
emailConfig.setMailServerHost(msgConfiguration.getIp());
emailConfig.setMailServerPort(msgConfiguration.getPort());
emailConfig.setProtocol(msgConfiguration.getProtocol());
emailConfig.setFromAddress(msgConfiguration.getUsername());
MailData mailData = new MailData();
mailData.setSubject(title);
mailData.setContent(sysNoticeContent);
mailData.setToAddresss(notice.getToAddress());
mailData.setCcAddresss(notice.getCcAddress());
//发送邮件
sendEmailService.sendMail(mailData, emailConfig);
// 省略组装参数...
// 保存发送记录
saveSendMessage(msgSendRecordDto);
logger.info("send email success!");
return true;
} catch (Exception e) {
logger.error(e.getMessage());
// 省略组装参数...
// 保存发送记录
saveSendMessage(msgSendRecordDto);
return false;
}
return false;
}
}
具体策略类StationNoticeExchanger
发送站内信
java
@Component
public class StationNoticeExchanger extends NoticeExchanger {
private Logger logger = LoggerFactory.getLogger(StationNoticeExchanger.class);
/**
* 是否支持站内信发送
*
* @param type 消息类型
* @return
*/
@Override
public boolean match(String type) {
if (!String.valueOf(SendTypeEnum.STATION.getItem()).equals(type)) {
return false;
}
return true;
}
/**
* 处理消息发送
*
* @param map 相关参数
* @return
*/
@Override
public boolean exchanger(Map<String, Object> map) throws Exception {
logger.info("=========== send station begin !========================");
StationNotice notice = new StationNotice();
BeanUtils.populate(notice, map);
// 解析模板内容和标题
Map<String, Object> objectMap = parseMessage(map);
String title = objectMap.get("title") == null ? "" : objectMap.get("title").toString();
String sysNoticeContent = objectMap.get("sysNoticeContent") == null ? "" : objectMap.get("sysNoticeContent").toString();
// 发送站内信即保存发送记录即可,记录类型为站内信
MsgSendRecordDto msgSendRecordDto = new MsgSendRecordDto();
// 省略组装参数...
// 保存发送记录
saveSendMessage(msgSendRecordDto);
logger.info("=================send station success!==========================");
return true;
}
}
具体策略类SmsNoticeExchanger
发送短信
java
@Component
public class SmsNoticeExchanger extends NoticeExchanger{
private Logger logger = LoggerFactory.getLogger(SmsNoticeExchanger.class);
@Autowired
private ISendSmsService sendSmsService;
@Autowired
private MsgConfigurationMapper msgConfigurationMapper;
@Override
public boolean match(String type) {
if(!String.valueOf(SendTypeEnum.SMS.getItem()).equals(type)){
return false;
}
return true;
}
@Override
public boolean exchanger(Map<String, Object> map) {
SmsNotice notice = new SmsNotice();
BeanUtils.populate(notice, map);
// 解析模板内容和标题,这里的模板内容和标题只在发送记录使用,短信的模板内容配置在了阿里云短信服务
Map<String, Object> objectMap = parseMessage(map);
String title = objectMap.get("title") == null ? "" : objectMap.get("title").toString();
String sysNoticeContent = objectMap.get("sysNoticeContent") == null ? "" : objectMap.get("sysNoticeContent").toString();
try {
// 查询短信配置
MsgConfiguration msgConfiguration = 省略...
if(msgMailConfiguration == null){
throw new CommonException(ExceptionDefinition.SEND_CHANNELS);
}
logger.info("send sms success begin !");
//发送短信,填充阿里云用户名、密码、短信模板编码、参数等等,调用阿里云api发送短信
sendSmsService.sendSms(notice, msgConfiguration);
// 省略组装参数...
// 保存发送记录
saveSendMessage(msgSendRecordDto);
logger.info("send sms success!");
} catch (Exception e) {
// 省略组装参数...
// 保存发送记录
saveSendMessage(msgSendRecordDto);
logger.error(e.getMessage());
throw new CommonException(ExceptionDefinition.SEND_SMS_EXCEPTIONS);
}
return true;
}
}
消息类型枚举类
java
public enum SendTypeEnum {
/**
* 通知渠道类型
*/
STATION(1,"站内信"),
EMAIL(2,"邮件"),
SMS(3,"短信");
private int item;
private String itemName;
SendTypeEnum(int item, String itemName) {
this.item = item;
this.itemName = itemName;
}
public int getItem() {
return item;
}
public void setItem(int item) {
this.item = item;
}
public String getItemName() {
return itemName;
}
public void setItemName(String itemName) {
this.itemName = itemName;
}
public static String getItemName(int item){
for (SendTypeEnum es : SendTypeEnum.values()){
if(item == es.getItem()){
return es.getItemName();
}
}
return "";
}
}
环境类NoticeServiceImpl
负责将消息发送请求委派给具体的策略对象执行
java
@Service
@Slf4j
public class NoticeServiceImpl implements NoticeService,ApplicationContextAware {
// 保存所有的消息策略类
private Collection<NoticeExchanger> exchangers;
// 线程池,异步发送消息
private ExecutorService executorService;
@Resource
private NoticeConvertUtils noticeConvertUtils;
@Resource
private MsgPublicTemplateMapper msgPublicTemplateMapper;
public NoticeServiceImpl(){
// 创建线程池
Integer availableProcessors = Runtime.getRuntime().availableProcessors();
Integer numOfThreads = availableProcessors * 2;
executorService = new ThreadPoolExecutor(availableProcessors,numOfThreads,100, TimeUnit.SECONDS,new LinkedBlockingDeque<>());
}
/**
* 当前Bean初始化之前会执行当前方法,获取所有的消息策略类
*/
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
// 获取实现了NoticeExchanger接口的所有bean
Map<String, NoticeExchanger> beansOfType = applicationContext.getBeansOfType(NoticeExchanger.class);
this.exchangers=beansOfType.values();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void sendMessage(NoticeParamDto noticeParamDto) {
Map<String, Object> notice = null;
try {
// 将参数转换成map
notice = noticeConvertUtils.sendMessageIsParam(notice);
} catch (Exception e) {
log.error("消息发送失败,消息模板内容转换失败", e);
throw new CommonException("消息发送失败,消息模板内容转换失败", 999);
}
if(notice.get("code") == null){
throw new CustomException(CommonCode.NO_TEMPLATE);
}
QueryWrapper<MsgPublicTemplate> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("code", notice.get("code").toString());
MsgPublicTemplate msgPublicTemplate = msgPublicTemplateMapper.selectOne(queryWrapper);
notice.put("msgPublicTemplate",msgPublicTemplate);
if(msgPublicTemplate.getMessageTypeCode() == null){
throw new CustomException(CommonCode.NO_TEMPLATE);
}
// 获取当前消息模板的类型,以逗号隔开,由所有的消息策略类去匹配类型,匹配成功则提交任务给线程池异步执行
String[] array = msgPublicTemplate.getMessageTypeCode().split(",");
for(int i = 0; i < array.length; i++){
if(StringUtils.isNotBlank(array[i])){
exchangers.forEach(item->{
if(item.match(array[i)){
//开启线程池处理
log.info("发送站内信任务提交");
executorService.submit(new NoticeTask(item,notice));
log.info("发送站内信任务提交完成");
}
});
}
}
}
}
noticeConvertUtils的sendMessageIsParam(notice)逻辑,主要将传递过来的参数转成Map对象
java
public Map<String, Object> sendMessageIsParam(NoticeParamDto notice) throws Exception {
Map<String, Object> map = new HashMap<>();
map = this.convertToMap(notice.getParams(), map);
Map<String, Object> returnMap = new HashMap<>();
returnMap.put("businessId", notice.getBusinessId());
returnMap.put("code", notice.getSendMessageCode());
returnMap.put("phones", notice.getPhones());
returnMap.put("toAddress", notice.getToAddress());
if (StringUtils.isEmpty(notice.getCcAddress())) {
returnMap.put("ccAddress", notice.getCcAddress());
}
returnMap.put("userId", notice.getUserId());
returnMap.put("userName", notice.getUserName());
returnMap.put("params", map);
return returnMap;
}
参数类NoticeParamDto
java
@Data
@Accessors(chain = true)
public class NoticeParamDto {
/**
* 消息id
*/
private String id;
/**
* 业务单据id
*/
private String businessId;
/**
* 消息模板编码
*/
private String sendMessageCode;
/**
* 接收人手机号
*/
private String phones;
/**
* 接收人邮箱,多个以英文逗号分割
*/
private String toAddress;
/**
* 抄送人邮箱
*/
private String ccAddress;
/**
* 接收人账号
*/
private String userId;
/**
* 接收人姓名
*/
private String userName;
/**
* 传递参数,字段名称需和模板内容、标题一样,否则解析模板内容、标题失败
*/
private Object params;
}
任务类NoticeTask
java
@Slf4j
public class NoticeTask implements Callable<Boolean>{
private NoticeExchanger noticeExchanger;
private Map<String, Object> notice;
public NoticeTask(NoticeExchanger noticeExchanger, Map<String, Object> notice){
this.noticeExchanger=noticeExchanger;
this.notice=notice;
}
@Override
public Boolean call() throws Exception {
log.info("============发送消息任务开始=============");
return noticeExchanger.exchanger(notice);
}
}
至此,核心代码已经介绍完成。由于这是一个独立的服务,所以我写了一个接口来调用NoticeServiceImpl的发送消息方法,然后再写一个Feign接口提供给其他服务使用,调用者调用时只需要传递消息模板编码、接收人消息等参数即可,无需在原来的代码上写上大量的内容拼接参数处理,实现了解耦,后续也更好维护。
当后续需要新增消息发送类型,比如要发送微信公众号消息,接着扩展即可,新增一个微信公众号消息发送的策略类和枚举类型,写对应逻辑即可,其他不需要变化,这样就非常灵活,也变得易扩展、易维护了。
当然这里还可以优化,比如我上面代码用了大量的map操作,有时候这些参数看着一头雾水,应该封装成实体类;再比如,我这里采用的是feign远程调用,对于调用者来说还是同步调用,需要等待其发送消息完成,有一定的性能消耗,后续可以采用消息队列进行优化,调用者将参数发送到消息队列就返回客户端提升用户体验,然后环境类监听主题消费即可。当然引入消息队列,还得考虑其中的常见问题(消息丢失、消息重复消费等等)。
好了,今天就讲这么多了!