微信接入探秘(四)——万事俱备,只欠架构(回调篇)

版权声明:本文为博主原创文章,更多博文请访问:http://blog.csdn.net/chaijunkun,未经博主允许不得转载。 https://blog.csdn.net/chaijunkun/article/details/53484878

本文出处:http://blog.csdn.net/chaijunkun/article/details/53484878,转载请注明。由于本人不定期会整理相关博文,会对相应内容作出完善。因此强烈建议在原始出处查看此文。

在之前文章中分别介绍了微信被动回调接口的基本概念、OXM技术选型和安全模式下的消息加解密原理。底层夯实之后就可以开始在功能上的设计了。很多时候我们并不对上述细节关心,更希望集中精力去满足业务需求,这也是我们这套框架提供出来的核心价值。

本专栏代码可在本人的CSDN代码库中下载,项目地址为:https://code.csdn.net/chaijunkun/wechat-common

本篇博文的示例代码可在CSDN代码库中下载,项目地址为:https://code.csdn.net/chaijunkun/wechat-common-web

wechat-common软件架构设计

降低耦合,轻松分离业务

我们希望微信接入细节与业务功能解耦,不要引入过多的框架,还要能轻松组装。于是我们有了如下架构的设计:

Created with Raphaël 2.1.0 回调接口 回调接口 请求参数 请求参数 请求体 请求体 派遣器 派遣器 消息转发 消息转发 文本消息服务 文本消息服务 图片消息服务 图片消息服务 语音消息服务 语音消息服务 ... ... 提取 提取 传入 传入 类型分析 文本消息 图片消息 语音消息 ...

派遣器需要接收HttpServletRequest对象的两部分参数,在最初实现的版本中,入参就是request,后来发现这样做就必然引入Servlet-API包,使得架构变得臃肿。于是决定瘦身,将入参变为更加通用的Map<String, String[]> params和String requestBody,request对象中调用getParameterMap()得到的是一个标准的key-values[]映射,而requestBody字符串可以通过读取request对象的输入流来获取。这样就将接口轻松与J2EE进行了解耦。回调接口的接入也很方便,只需要写固定的代码即可:

@Controller
public class CallbackController extends BaseController {

    @Autowired
    private WeChatCallbackDispatcher weChatCallbackDispatcher;

    @RequestMapping(value = "/msg")
    @ResponseBody
    @SuppressWarnings("unchecked")
    public String msg(HttpServletRequest request, HttpServletResponse response) throws WeChatCallbackException, IOException{
        return weChatCallbackDispatcher.dispatch(request.getParameterMap(), HttpUtil.readStream(request.getInputStream(), "UTF-8"));
    }

}

派遣器模式,专注于你要做的

WeChatCallbackDispatcher中集成了签名验证、安全模式加解密、类型判断和消息派遣的重要功能。受Servlet的dispatch模式启发,当接收到请求体的XML时,首先对其转换为刚好能判别消息类型的最小化对象:TypeAnalyzingBean(只包含MsgType和Event字段),然后通过预定义的枚举类型进行类型分析:

TypeAnalyzingBean baseMsg = XMLUtil.fromXML(callbackXML, TypeAnalyzingBean.class);
MsgType msgType = MsgType.getByType(baseMsg.getMsgType());
private BaseMsg dispatchMsg(MsgType msgType, TypeAnalyzingBean bean, String xml) throws WeChatCallbackException {
    BaseMsg retVal = null;
    try {
        switch (msgType) {
        case text:
            TextMsg textMsg = XMLUtil.fromXML(xml, TextMsg.class);
            retVal = textMsgDispatchService.dispatchMsg(textMsg);
            break;
        case voice:
            VoiceMsg voiceMsg = XMLUtil.fromXML(xml, VoiceMsg.class);
            retVal = voiceMsgDispatchService.dispatchMsg(voiceMsg);
            break;
        case shortvideo:
            ShortVideoMsg shortVideoMsg = XMLUtil.fromXML(xml, ShortVideoMsg.class);
            retVal = shortVideoMsgDispatchService.dispatchMsg(shortVideoMsg);
            break;
        case event:
            EventType eventType = EventType.getByType(bean.getEvent());
            retVal = dispatchEvent(eventType, bean, xml);
            break;
        case unknown:
        default:
            if (logger.isDebugEnabled()){
                logger.debug("other type of msg:{}", bean.getMsgType());
            }
            break;
        }
    } catch (IOException e) {
        throw new WeChatCallbackException(CallbackErrEnum.SysErr, e);
    }
    return retVal;
}

在dispatchMsg代码中可以看出,每一种DispatchService只处理对应的消息,相互没有关联。换句话说,作为开发者,只需要关心接收到特定类型的消息之后去做什么,而不用关系消息是怎样传递到被动回调接口的。

框架中针对每一种类型的消息都提供了一个默认的DispatchService,如果你将日志级别设置为Debug,就会在日志中看到一条对应类型消息的调试信息,没有其他业务逻辑。这也就保证了当收到开发者不关心的消息类型时的空指向异常(NullPointerException)问题。

public class WeChatCallbackDispatcher {
    /** 文本消息处理服务 */
    @Autowired(required = false)
    private TextMsgDispatchService textMsgDispatchService;

    /** 语音消息处理服务 */
    @Autowired(required = false)
    private VoiceMsgDispatchService voiceMsgDispatchService;

    /** 短视频消息处理服务 */
    @Autowired(required = false)
    private ShortVideoMsgDispatchService shortVideoMsgDispatchService;

    /** 关注事件处理服务 */
    @Autowired(required = false)
    private SubscribeEventDispatchService subscribeEventDispatchService;

    /** 关注事件处理服务 */
    @Autowired(required = false)
    private UnsubscribeEventDispatchService unsubscribeEventDispatchService;

    /** 定位事件处理服务 */
    @Autowired(required = false)
    private LocationEventDispatchService locationEventDispatchService;

    /** 自定义菜单事件处理服务 */
    @Autowired(required = false)
    private CustomMenuEventDispatchService customMenuEventDispatchService;

    public WeChatCallbackDispatcher(){
        logger.debug("initializing WeChat callback dispatcher");
        //回调消息处理
        textMsgDispatchService = new DefaultTextMsgDispatchServiceImpl();
        voiceMsgDispatchService = new DefaultVoiceMsgDispatchServiceImpl();
        shortVideoMsgDispatchService = new DefaultShortVideoMsgDispatchServiceImpl();
        //回调事件处理
        subscribeEventDispatchService = new DefaultSubscribeEventDispatchServiceImpl();
        unsubscribeEventDispatchService = new DefaultUnsubscribeEventDispatchServiceImpl();
        locationEventDispatchService = new DefaultLocationEventDispatchServiceImpl();
        customMenuEventDispatchService = new DefaultCustomMenuEventDispatchServiceImpl();
    }
}
public class DefaultTextMsgDispatchServiceImpl extends WeChatCallbackDispatchService implements TextMsgDispatchService {

    @Override
    public BaseMsg dispatchMsg(TextMsg msg) {
        if (logger.isDebugEnabled()){
            logger.debug("receive a text msg, msg id:{}, from:{}, to:{}, content:{}", msg.getMsgId(), msg.getFromUserName(), msg.getToUserName(), msg.getContent());
        }
        return null;
    }

}

下面代码实现了一个自动报时功能,当用户发送给公众号任意消息,公众号返回用户一条文本消息,内容为当前时间:

@Service
public class TextMsgDispatchServiceImpl extends WeChatCallbackDispatchService implements TextMsgDispatchService {

    private SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    @Override
    public BaseMsg dispatchMsg(TextMsg msg) {
        logger.info("本机微信账号:{}", msg.getToUserName());
        logger.info("收到文本消息,From:{}, To:{}, 消息内容:{}", msg.getFromUserName(), msg.getToUserName(), msg.getContent());
        TextMsg retMsg = new TextMsg();
        retMsg.setFromUserName(msg.getToUserName());
        retMsg.setToUserName(msg.getFromUserName());
        retMsg.setCreateTime(TimeUnit.SECONDS.convert(System.currentTimeMillis(), TimeUnit.MILLISECONDS));
        retMsg.setMsgType(MsgType.text.getType());
        retMsg.setContent(String.format("当前时间:%s", format.format(new Date())));
        return retMsg;
    }

}

关于被动回调接口的调试技术

在官方文档中给出了一种调试的手段——人工造数据(文档地址:https://mp.weixin.qq.com/debug/cgi-bin/apiinfo?t=index&type=消息接口调试&form=文本消息),但是该手段比较繁琐,很多字段都要手工填写,费时费力。下面给出一种通过真实微信客户端进行调试的方法。

我想要个公网IP

如果你是一个土豪,在家里直接通过ADSL或者光纤猫接入互联网,请忽略本节。很多时候我们接入互联网的都是通过路由器的转发,这样就分成了外网和路由器的内网。内网主机无法直接被外网连接,而微信被动回调接口很显然需要一个在公网就能直接连到的主机来提供服务。如何将本地的Web应用在互联网访问呢?

  1. 在家中通过路由器接入互联网。这种模式下一般可以自己设置路由器的DMZ主机,当外网访问你当前获得的公网IP时,特定的端口会被直接转发到你的内网设备上,类似于模拟一个在公网上的设备。由于这种手段没有什么技术难度,并且家中的接入方式IP地址经常会发生变化,不利于测试环境的稳定,故而本文不对其进行描述;

  2. 在公司或者复杂内网环境接入互联网。这种模式下一般人不可能有权限去更改公司的路由器配置,而且内网数据也是经过若干网关才转发到公网,单设置一台路由器很难解决问题。已经过实践验证的方式是使用云主机+VPN

其实选择云主机的主要目的是拿到一个比较稳定的公网IP,它和托管在IDC机房的设备一样,都可以选择拥有一个静态的IP地址。在你承租时间内,这个IP地址就不会发生变化。调试微信回调接口使用什么样的云主机呢?

  1. CPU,由于调试时计算集中在我们本地的项目,云主机计算量并不大,选择单核处理器即可,速度没有太高的要求;

  2. 内存,在这台云主机上只运行nginx和vpn服务器,内存开销不大,1GB内存足矣;

  3. 硬盘空间,除正常的系统占用和软件安装外,不需要太多的存储空间,若不是提供商最少提供40GB空间,笔者都想选择10GB或者20GB的硬盘;

  4. 带宽,带宽比较重要,在调试的时候需要有稳定的网络连接,从经济实用度来分析,笔者选择了峰值100Mbps,按使用流量付费的带宽方案。其实调试的过程中流量真的很少,几元的流量费能让你用到很开心;

  5. 操作系统,这个看个人喜好,笔者喜欢CentOS,于是安装了CentOS 6.5 64位系统。

按照一个月的调试时间来估算,上述配置的云主机购买成本大约是50~90元左右,还是比较便宜的。

把自己的调试代码接入公网

云主机部署完成后即可安装nginx,它主要用来做反向代理。一般的反向代理都是指向本地后端服务器或者局域网内其他服务器实例。nginx配置好之后外网即可通过IP地址直接访问nginx服务器,如果你能够看到nginx的默认欢迎页,恭喜你,已经完成了第一步。

如果你对Linux编译部署nginx不熟练的话,可以参考我之前写的文章,虽然是很久以前写的,最近与时俱进,又补充了一些新内容在里面,还是有参考价值的:

http://blog.csdn.net/chaijunkun/article/details/7009183

接下来就是想办法让正在运行着测试代码的tomcat/jetty成为云主机nginx的后端服务器。此时需要在云主机上安装一个VPN服务器。根据经验来看,PPTP协议的VPN搭建最容易。部署完成后,首先将本地计算机拨入云主机的VPN。接入VPN之后,你当前正在使用的计算机就与远端的云主机在逻辑上建立了一个局域网。将你分配到的局域网IP设置到云主机nginx的upstream。此时公网访问的请求就会被转发到你的tomcat/jetty上。尝试用手机微信客户端直接向你的测试公众号/开发者账号发送消息,你的调试代码应该很快就能收到来自微信的回调请求。那么,开始你的调试之旅吧!

猜你喜欢

转载自blog.csdn.net/chaijunkun/article/details/53484878