说明
在公司微信项目开发中,我主要负责消息中心的模板消息接口设计实现。主要是将微信公众号的推送模板消息功能放到公司的消息中心系统中,微信后台项目通过RMI调用接口,实现推送功能。在这里记录总结下当时的设计实现思路。
公司使用Hessian实现RMI,所以接口设计为一个Hessian接口。
正文
接口的设计要先确定接口的名称,参数,返回值。这里由于考虑到了今后的扩展,可能会运营不止一个公众号,所以在接口参数的设计上,比较具体。
public interface IWechatMessageService {
public int sendTempMsg(String spid, Integer templateId, String data);
}
通过阅读微信开发文档,我们知道了在推送模板消息时,必须要有Access_Token,templateid,openId和要发送的数据,这些数据组成一个json串发送给微信服务器,微信服务器返回一个json数据包包含对应的信息。
消息推送的设计思路:
通过微信后台项目远程调用接口,公众号的id,模板的id(id均为在表中的主键),还有推送的数据作为参数传递,消息中心接受到参数后,首先会根据公众号id判断是否存在该公众号信息,再根据模板id判断是否存在模板信息,若都不存在问题,则会将这些参数数据组装成一个DTO,放入消息队列,此时接口方法执行完毕,返回状态码。放入消息队列后,有队列订阅者对消息进行处理(即发送数据到微信服务器),至此推送模板消息的流程结束。
在以上的思路中有几个问题:
- 对于公众号和模板信息的校验,这些信息都存储在数据库中,每次调用接口校验时都是否都需要从数据库查询数据?
- 对于推送的数据,这个参数是多用户数据,也就是一次传递一批用户的数据,每个用户推送的内容不同,所以数据格式为JsonArray,在往消息队列中投递的时候,是一次投递一批,一个DTO,还是将每个用户的数据查分组成DTO,进行投递?
- 消息数据进入队列后,如何消费,该怎样向微信服务器发送数据?
- 在推送时,需要Access_Token,这个token与微信后台项目是共用的,而且token是有有效期的,在过期时导致消息推送失败,该如何处理?
接下来针对每个问题记录下当时的解决方法:
1. 公众号、模板标识的校验
在发送模板消息时,需要Access_Token和对应的模板id,Access_Token的获取需要公众号的appid和secret,定制模板需要向微信官方申请,而且是人工审核,十分严格。当运营不止一个公众号时就需要通过表记录公众号的信息,而且推送的内容不同使用的模板不同,也需要表记录使用的模板信息。所以传递的参数都应该在表中有记录,为了避免每次校验时查询数据库,这里采用了将公众号信息和模板信息加载到内存的方式。具体是使用一个监听器,在项目启动时,从数据库查询数据并存储到内存中,这里使用HashMap作为存储的数据结构,key为主键 value为对应的实体类对象。
//加载模板信息
private static Map<Integer,Template> ALL_TEMPLATE = new HashMap<Integer, Template>();
List<Template> templateList = templateService.getAll();
if (templateList != null && templateList.size() > 0) {
for (Template template : templateList) {
nteger templateid = template.getTemplateid();
ALL_TEMPLATE.put(templateid,template);
}
}
2.关于多用户数据的投递
在开始设计时,我采用的是直接将数据组装成一个DTO,发送到队列中,消费时是接受一批的用户数据,在发送时对数据做处理,拆分出每个用户的数据,逐个发送。不过在审查的时候,主管说要提前拆分,将单个用户的DTO用list存储,调用方法以list形式发送,消费时以多线程的方式进行发送。为什么要这么做?主管告诉我这样可以防止数据的大批量丢失,如果一次以单个DTO放到队列,当队列出现问题时,会丢很多数据。公司使用的是RabbitMQ,并且封装了管理类RabbitmqManager,通过阅读源码发现,当以list形式发送时,在方法内部还是循环一个个发送到队列中。
//将一批消息中的所有用户消息以list放入队列
List<WechatTempMsgDTO> dataList = new ArrayList<WechatTempMsgDTO>();
for(int i = 0; i < dataArray.size(); i++){
JSONObject dataOfUser = dataArray.getJSONObject(i);
WechatTempMsgDTO wechatTempMsgDTO = new WechatTempMsgDTO(accessToken,templateId,dataOfUser.toJSONString(),batchid,0,spid);
dataList.add(wechatTempMsgDTO);
}
int status = queueManager.sendMessageObject(GlobalVars.WECHAT_QUEUE_NAME,dataList);
发送到队列的源码
public <T> int sendMessageObject(String queueName, List<T> messgaes) {
try {
MessageProperties properties = messgaes != null && messgaes.size() > 0 ? this.getTypeProperties(messgaes.get(0).getClass()) : null;
Iterator it = messgaes.iterator();
while(it.hasNext()) {
T expectedBody = it.next();
this.mqTemplate.convertAndSend(queueName, new Message(JSON.toJSONString(expectedBody).getBytes(Charsets.UTF_8), properties));
}
return 1;
} catch (Exception var6) {
return -1;
}
}
3.队列信息消费
这里使用了消息队列的发布订阅模式,通过一个监听器,在项目启动时订阅队列,等待从队列中获取消息,进行处理。这里采用了多线程的方式进行推送,通过阅读源码,发现多线程的实现方式是一个订阅者subscribe有一个线程池,每个线程代表一个消费者consumer
订阅部分的实现源码
queueManager.subscribeQueue(GlobalVars.WECHAT_QUEUE_NAME, new QueueSubscriber<WechatTempMsgDTO>() {
@Override
public void onGetMessage(QueueContext<WechatTempMsgDTO> queueContext) {
WechatTempMsgDTO wechatTempMsgDTO = queueContext.getMessage();
//此时接收到的是单个用户信息的DTO
sendMessgae(wechatTempMsgDTO);
}
},GlobalVars.WECHAT_CONCURRENT_THREAD_NUM,1);
订阅多线程消费的实现源码
Connection conn = this.factory.newConnection();
ExecutorService threadPool = Executors.newFixedThreadPool(threadCount);
((Map)subToThread).put(subscriber, threadPool);
for(int i = 0; i < threadCount; ++i) {
RabbitConsumer<T> consumer = new RabbitConsumer(conn, queueName, subscriber, maxCount);
consumer.init();
threadPool.submit(consumer);
}
4.关于Access_Token的使用
阅读微信开发文档,了解到Access_Token只有两个小时的有效期,且重复获将导致上次获取的token失效。由于公司中的消息系统与微信项目是两个不同的项目,这就导致了token的共享问题及token失效时的获取问题。
这里使用了redis缓存,将token存入到redis中并设置有效期,解决共享问题。
在失效获取时,问题在队列消费推送时可能会出现消息没有推送完,token过期剩余所有消息推送失败的问题,此时就需要消息系统去主动获取token,而此时如果微信项目也同时请求,就会有竞争造成一方失效。为解决此问题使用了分布式锁,用redis实现分布式锁,思路是当token失效时,先从redis缓存中获取,如果没有则设置锁,使用了redis的setnx命令,若成功设置获得锁,则先设置锁的有效期避免死锁,再进行请求token,请求成功后将token存入缓存设置有效期,释放锁返回token;若获取锁失败,则循环获取,仍先从redis中获取。这里要避免死循环,如果是相关参数错误,则会一直获取不到造成死循环。在编写代码时,我觉得这部分锁的设计仍然有缺陷,锁的可重入性并没有得到保证,这里需不需要可重入性?如果是利用redis设计实现一个锁工具,就应该保证可重入性。希望大家不吝赐教
参考资料:分布式锁的作用及实现(Redis)
//使用redis实现分布式锁 获取Access_Token
private String getAccessToken(final String spid, String appid, String secret){
String res = "";
final String url = GlobalVars.GET_ACCESS_TOKEN_URL.replace("APPID",appid).replace("APPSECRET",secret);
while("".equals(res)){
res = tempmanager.getString(GlobalVars.WECHAT_ACCESS_TOKEN_CACHE_PREFIX + spid);
if(Validator.isNotNull(res)){
return res;
}
res = tempmanager.execute(new IRedisCacheManager.RedisExecutor<String>() {
@Override
public String execute(Jedis jedis) throws Exception {
String access_token = "";
String currentTimeMillis = String.valueOf(System.currentTimeMillis());
//加锁
Long result = jedis.setnx(GlobalVars.ACCESS_TOKEN_DISTRIBUTE_LOCK_KEY + spid,currentTimeMillis);
//加锁成功 进行请求 否则直接返回 循环获取
if(result == 1){
// 加锁成功后,必须设置锁的有效期,避免死锁
jedis.expire(GlobalVars.ACCESS_TOKEN_DISTRIBUTE_LOCK_KEY + spid,2);
String strResult = HttpUtil.getRequestURL(url,null);
JSONObject jsonResult = JSONObject.parseObject(strResult);
access_token = jsonResult.getString("access_token");
if(Validator.isNull(access_token)){
logger.error("请求Access_Token失败: " + jsonResult.toString());
return null; //当请求微信服务器出错,返回错误的数据包 返回null跳出循环,避免死循环
}
jedis.set(GlobalVars.WECHAT_ACCESS_TOKEN_CACHE_PREFIX + spid,access_token);
//设置access_token缓存的有效期
jedis.expire(GlobalVars.WECHAT_ACCESS_TOKEN_CACHE_PREFIX + spid,GlobalVars.WECHAT_ACCESS_TOKEN_CACHE_EXPIRE);
//释放锁
jedis.del(GlobalVars.ACCESS_TOKEN_DISTRIBUTE_LOCK_KEY + spid);
}
return access_token;
}
});
}
logger.info("获取Access_Token为: " + res);
return res;
}
对于推送失败后,这里采用了重新放回对列进行重试的机制,重试次数限制为3次。当发送成功时会记录到send_success表中,最后一次重试发送失败则会记录到send_failed表中并记录错误信息
if(Integer.valueOf(code) == 0){
//将发送成功的信息记录到成功表
saveDataToSuccess(templateId,toUser,batchid,param,tryCount);
}else{
//当错误代码代表access_token失效时,需要重新获取
if(Integer.valueOf(code) == GlobalVars.WECHAT_ACCESS_TOKEN_INVALID){
accessToken = accessTokenManager.getAccessToken(spid);
}
String msg = jsonResp.toString();
//判断重复次数 若已超过限定次数 则记录到失败表,否则重新放入消息队列
if(tryCount < GlobalVars.TRY_COUNT){
sendToQueue(accessToken,templateId,batchid,dataOfUser.toJSONString(),tryCount + 1);
}else{
saveDataToFaild(templateId,toUser,batchid,param,tryCount,msg);
}
}
最后感谢主管和同事们对我完成这次任务的帮助,让我学到了很多
其他优秀博文:
分布式锁的几种实现方式
分布式之消息队列复习精讲