微信接入探秘(二)——懒人的OXM之路

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

在上一篇博文中,简单对微信接口分成了两大类:被动回调接口和主动调用接口。并阐述了两类接口的实现机理。在序列图中我们可以得知任何数据都是有具体格式要求的,那么如何管理这些结构化的数据呢?这里就跟大家分享一下我在管理实体对象时走过的一些弯路和技术探索。

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

抽象数据层次关系

Java是一门面向对象的语言,要方便地使用结构化的数据,就必须对其进行对象化映射。抽象数据层次是一个很主观的事情,好在微信API已经规定好了格式,我们要做的就是对其进行更好地适配。一定要时刻谨记面向对象三要素:继承、封装和多态。

接下来就是发现规律的时刻。挑重点看了一下关于回调接口文档,下面是一些消息的格式说明:

普通消息

普通消息是用户显式发送给公众号的消息,现有的消息类型分别覆盖了在客户端能直接发送的所有消息种类。

文本消息

<xml>
    <ToUserName><![CDATA[toUser]]></ToUserName>
    <FromUserName><![CDATA[fromUser]]></FromUserName>
    <CreateTime>1348831860</CreateTime>
    <MsgType><![CDATA[text]]></MsgType>
    <Content><![CDATA[this is a test]]></Content>
    <MsgId>1234567890123456</MsgId>
</xml>

图片消息

<xml>
    <ToUserName><![CDATA[toUser]]></ToUserName>
    <FromUserName><![CDATA[fromUser]]></FromUserName>
    <CreateTime>1348831860</CreateTime>
    <MsgType><![CDATA[image]]></MsgType>
    <PicUrl><![CDATA[this is a url]]></PicUrl>
    <MediaId><![CDATA[media_id]]></MediaId>
    <MsgId>1234567890123456</MsgId>
</xml>

语音消息

<xml>
    <ToUserName><![CDATA[toUser]]></ToUserName>
    <FromUserName><![CDATA[fromUser]]></FromUserName>
    <CreateTime>1357290913</CreateTime>
    <MsgType><![CDATA[voice]]></MsgType>
    <MediaId><![CDATA[media_id]]></MediaId>
    <Format><![CDATA[Format]]></Format>
    <Recognition><![CDATA[腾讯微信团队]]></Recognition>
    <MsgId>1234567890123456</MsgId>
</xml>

视频消息

<xml>
    <ToUserName><![CDATA[toUser]]></ToUserName>
    <FromUserName><![CDATA[fromUser]]></FromUserName>
    <CreateTime>1357290913</CreateTime>
    <MsgType><![CDATA[video]]></MsgType>
    <MediaId><![CDATA[media_id]]></MediaId>
    <ThumbMediaId><![CDATA[thumb_media_id]]></ThumbMediaId>
    <MsgId>1234567890123456</MsgId>
</xml>

小视频消息

<xml>
    <ToUserName><![CDATA[toUser]]></ToUserName>
    <FromUserName><![CDATA[fromUser]]></FromUserName>
    <CreateTime>1357290913</CreateTime>
    <MsgType><![CDATA[shortvideo]]></MsgType>
    <MediaId><![CDATA[media_id]]></MediaId>
    <ThumbMediaId><![CDATA[thumb_media_id]]></ThumbMediaId>
    <MsgId>1234567890123456</MsgId>
</xml>

地理位置消息

<xml>
    <ToUserName><![CDATA[toUser]]></ToUserName>
    <FromUserName><![CDATA[fromUser]]></FromUserName>
    <CreateTime>1351776360</CreateTime>
    <MsgType><![CDATA[location]]></MsgType>
    <Location_X>23.134521</Location_X>
    <Location_Y>113.358803</Location_Y>
    <Scale>20</Scale>
    <Label><![CDATA[位置信息]]></Label>
    <MsgId>1234567890123456</MsgId>
</xml>

链接消息

<xml>
    <ToUserName><![CDATA[toUser]]></ToUserName>
    <FromUserName><![CDATA[fromUser]]></FromUserName>
    <CreateTime>1351776360</CreateTime>
    <MsgType><![CDATA[link]]></MsgType>
    <Title><![CDATA[公众平台官网链接]]></Title>
    <Description><![CDATA[公众平台官网链接]]></Description>
    <Url><![CDATA[url]]></Url>
    <MsgId>1234567890123456</MsgId>
</xml>

普通消息格式的总结

从以上数据中我们可以发现如下规律:
1. 数据均为XML格式,且根节点名称均为“xml”;
2. 目前都是一层深度,没有标签的嵌套,标签内也没有属性,结构比较简单
3. 有一些节点为了防止内容注入攻击,使用了CDATA
4. 有共同的节点。这里我们可以抽出出一个公共的XML结构

<xml>
    <ToUserName><![CDATA[toUser]]></ToUserName>
    <FromUserName><![CDATA[fromUser]]></FromUserName>
    <CreateTime>1351776360</CreateTime>
    <MsgType><![CDATA[link]]></MsgType>
    <MsgId>1234567890123456</MsgId>
</xml>

有人一定觉得,这太容易了,就用这个接口映射的实体当父类,然后依具体消息类型格式对其进行继承。先别着急,当时作者也是基于先进行了映射,导致在实现后续的功能时发现了问题。

事件推送

在本节中,我们接触一种特殊的消息。该消息并不是由用户显式发送,而是由用户一系列操作引发微信通知公众号平台而收到的消息。

关注/取消关注事件

关注事件

<xml>
    <ToUserName><![CDATA[toUser]]></ToUserName>
    <FromUserName><![CDATA[FromUser]]></FromUserName>
    <CreateTime>123456789</CreateTime>
    <MsgType><![CDATA[event]]></MsgType>
    <Event><![CDATA[subscribe]]></Event>
</xml>

用户未关注时,扫描二维码进入,进行关注后的事件推送

<xml>
    <ToUserName><![CDATA[toUser]]></ToUserName>
    <FromUserName><![CDATA[FromUser]]></FromUserName>
    <CreateTime>123456789</CreateTime>
    <MsgType><![CDATA[event]]></MsgType>
    <Event><![CDATA[subscribe]]></Event>
    <EventKey><![CDATA[qrscene_123123]]></EventKey>
    <Ticket><![CDATA[TICKET]]></Ticket>
</xml>

值得一提的是,由于微信产品设计的原因,当用户扫描公众号生成的二维码后,若用户没有关注过该公众号,则用户在点击提示信息中的“确定”后,微信回调公众号后台的消息并非是扫描带参数二维码事件,而是“关注事件”。与普通的关注事件不同的是,由二维码扫描进入的回调事件消息会多出EventKey和Ticket两个节点

取消关注事件

<xml>
    <ToUserName><![CDATA[toUser]]></ToUserName>
    <FromUserName><![CDATA[FromUser]]></FromUserName>
    <CreateTime>123456789</CreateTime>
    <MsgType><![CDATA[event]]></MsgType>
    <Event><![CDATA[unsubscribe]]></Event>
</xml>

扫描带参数二维码事件

用户已关注时的事件推送

<xml>
    <ToUserName><![CDATA[toUser]]></ToUserName>
    <FromUserName><![CDATA[FromUser]]></FromUserName>
    <CreateTime>123456789</CreateTime>
    <MsgType><![CDATA[event]]></MsgType>
    <Event><![CDATA[SCAN]]></Event>
    <EventKey><![CDATA[SCENE_VALUE]]></EventKey>
    <Ticket><![CDATA[TICKET]]></Ticket>
</xml>

上报地理位置事件

<xml>
    <ToUserName><![CDATA[toUser]]></ToUserName>
    <FromUserName><![CDATA[fromUser]]></FromUserName>
    <CreateTime>123456789</CreateTime>
    <MsgType><![CDATA[event]]></MsgType>
    <Event><![CDATA[LOCATION]]></Event>
    <Latitude>23.137466</Latitude>
    <Longitude>113.352425</Longitude>
    <Precision>119.385040</Precision>
</xml>

自定义菜单事件

<xml>
    <ToUserName><![CDATA[toUser]]></ToUserName>
    <FromUserName><![CDATA[FromUser]]></FromUserName>
    <CreateTime>123456789</CreateTime>
    <MsgType><![CDATA[event]]></MsgType>
    <Event><![CDATA[CLICK]]></Event>
    <EventKey><![CDATA[EVENTKEY]]></EventKey>
</xml>

事件推送格式的总结

从以上数据中我们可以发现如下规律:
1. 数据均为XML格式,且根节点名称均为“xml”;
2. 目前都是一层深度,没有标签的嵌套,标签内也没有属性,结构比较简单
3. 有一些节点为了防止内容注入攻击,使用了CDATA
4. 有共同的节点。这里我们可以抽出出一个公共的XML结构

<xml>
    <ToUserName><![CDATA[toUser]]></ToUserName>
    <FromUserName><![CDATA[FromUser]]></FromUserName>
    <CreateTime>123456789</CreateTime>
    <MsgType><![CDATA[event]]></MsgType>
    <Event><![CDATA[subscribe]]></Event>
</xml>

搭建数据结构继承关系

通过上文我们可以总结出,无论是普通消息还是事件推送,都拥有很多类似的结构,这也就保证了对数据结构的封装构想是行得通的。首先我们来分析一下不同类型的消息是怎样区分开的,因为只有深入了解这一区别,才能让我们搭建的数据结构很好地服务于最终的程序。消息的具体类型是由MsgType和Event两个因子共同决定的

Created with Raphaël 2.1.0 接收到消息 获取MsgType字段 获取Event字段 是事件推送吗? 分析事件类型 转换为对应事件 结束 是普通消息吗? 分析消息类型 转换为对应消息 忽略消息 yes no yes no

消息类型的判断

MsgType Event 消息分类 消息类型
event subscribe 事件推送 关注事件
event unsubscribe 事件推送 取消关注事件
event SCAN 事件推送 扫描带参数二维码事件
event LOCATION 事件推送 上报地理位置事件
event CLICK 事件推送 自定义菜单事件
text N/A 普通消息 文本消息
image N/A 普通消息 图片消息
voice N/A 普通消息 语音消息
video N/A 普通消息 视频消息
shortvideo N/A 普通消息 小视频消息
location N/A 普通消息 地理位置消息
link N/A 普通消息 链接消息

按照上述流程和对应关系进行匹配后,即可得到具体消息类型。于是我们有了充分的理由,先构建一个专门用于判断消息类型的实体:TypeAnalyzingBean

public class TypeAnalyzingBean {
    /** 消息类型 */
    private String msgType;
    /** 事件类型 */
    private String event;
    //省略getters 和 setters...
}

得到了具体类型后就可以拆分为普通消息事件推送的父类定义,但是过程中我们发现两者还有很多共同之处,所以新建了一个公共的字段的实体定义:CommonXML

public class CommonXML {
    /** 接收方微信号 */
    private String toUserName;
    /** 发送方微信号,若为普通用户,则是一个OpenID */
    private String fromUserName;
    /** 消息创建时间 */
    private Long createTime;
    /** 消息类型 */
    private String msgType;
    //省略getters 和 setters...
}

普通消息的基础实体定义:BaseMsg,实际上只是在公共字段的基础上增加了msgId属性。

public class BaseMsg extends CommonXML {
    /** 消息id */
    private Long msgId;
    //省略getters 和 setters...
}

事件推送的基础实体定义:BaseEvent,实际上只是在公共字段的基础上增加了event属性。

public class BaseEvent extends CommonXML {
    /** 事件类型 */
    private String event;
    //省略getters 和 setters...
}

有了这两个基础实体,其他的消息和事件只需要分别继承两个基类,进行相应的属性拓展即可,这里就不赘述了。

选一个好的OXM框架很重要

微信回调给我们的数据是XML格式的,而我们要面向对象开发,于是必须将这两者映射起来,这就是OXM(Object/XML Mapping)。细心的你也许已经发现,回调给我们的代码风格和Java属性命名风格是不一致的,因此我们要把原有的节点名转义后映射到对应的实体属性上。虽然Java包中提供的w3c API可以实现XML的DOM解析,但是需要一个节点一个节点地爬,整个过程将异常繁琐,后期极难维护,这里就不再展示了。接下来我将介绍第一版使用的JAXB和最终采用的基于Jackson StAX的方案,并进行对比。

在这里我们以最常用的文本消息作为示例,它的继承关系为:

Created with Raphaël 2.1.0 TextMsg BaseMsg CommonXML
public class TextMsg extends BaseMsg {
    /** 文本消息内容 */
    private String content;
    //省略getters 和 setters...
}

JAXB方式的OXM

JAXB支持annotation方式的命名转义,这也正是我在设计之初选择其作为基础OXM框架的原因。由于所有的XML根节名称都是“xml”,因此想将根节点重命名annotation放到CommonXML上,这样,其他类直接或间接继承自他,注解也能够通过继承链来爬取到相应的配置。

这里可以很负责地告诉你,我的想法还是Too young too simple了,最早尝试的是JDK1.6+自带的JAXB实现,这个实现有个恶心的问题:父类注解无法继承到子类。只有在每一个相关类上设置@XmlRootElement(name=”xml”)才可以,否则将报这样的错误:com.sun.istack.internal.SAXException2: unable to marshal type “net.csdn.blog.chaijunkun.wechat.common.callback.xml.msg.TextMsg” as an element because it is missing an @XmlRootElement annotation。

import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlRootElement;
import javax.xml.bind.annotation.XmlType;

import org.eclipse.persistence.oxm.annotations.XmlCDATA;

@XmlRootElement(name="xml")
@XmlType(name="", namespace="")
@XmlAccessorType(XmlAccessType.FIELD)
public class CommonXML {
    @XmlElement(name="ToUserName")
    @XmlCDATA
    private String toUserName;
    //后面的代码就不贴了,只是一些重命名操作和一些getters/setters
}

由于JAXB对CDATA支持得不好,需要自己写@XmlJavaTypeAdapter(CDataAdapter.class)中的CDataAdapter,而且网上找到的例子经过评估,有注入漏洞,于是又额外引入了EclipseLink的moxy(一套JAXB的实现):

<dependency>
    <groupId>org.eclipse.persistence</groupId>
    <artifactId>org.eclipse.persistence.moxy</artifactId>
    <version>2.6.3</version>
</dependency>

为此我写了一个单元测试看下序列化与反序列化的测试:

public class XMLTest extends BaseTest {

    @Test
    public void doTest() throws JAXBException{
        TextMsg msg = new TextMsg();
        msg.setToUserName("jackson");
        msg.setFromUserName("hawaii");
        msg.setContent("jack<xml val='Json'>]]>");

        ByteArrayOutputStream xmlOut = null;
        ByteArrayInputStream xmlIn = null;
        try{
            xmlOut = new ByteArrayOutputStream();
            XMLFactory.toXML(msg, xmlOut);
            String xml = new String(xmlOut.toByteArray());
            logger.info("生成xml:{}", xml);
            xmlIn = new ByteArrayInputStream(xml.getBytes());
            TextMsg msgFromXml = XMLFactory.fromXML(xmlIn, TextMsg.class);
            logger.info("反序列化结果:发送方:{}, 接收方:{}, 内容:{}", msgFromXml.getFromUserName(), msgFromXml.getToUserName(), msgFromXml.getContent());
        }finally{
            IOUtils.closeQuietly(xmlIn);
            IOUtils.closeQuietly(xmlOut);
        }
    }
}

由于CDATA的结束符是“]]>”,在设置消息内容时特别使用了带有歧义性的文字来测试注入危险。先来看下序列化的结果:

<?xml version="1.0" encoding="utf-8"?>
<xml xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="textMsg">
    <ToUserName><![CDATA[jackson]]></ToUserName>
    <FromUserName><![CDATA[hawaii]]></FromUserName>
    <Content><![CDATA[jack<xml val='Json'>]]]]><![CDATA[>]]></Content>
</xml>

moxy实现的JAXB有效地解决了父级注解无法继承到子类上的问题,CDATA内容也顺利通过了我们的测试(它的解决方案是将能够引起歧义的字符组合”]]>”拆分成了”]]”和“>”,然后再分别使用CDATA标签进行包装),但是也带来了新的问题。我们再来回顾一下官方文档提供给我们的回调xml数据格式:

<xml>
    <ToUserName><![CDATA[toUser]]></ToUserName>
    <FromUserName><![CDATA[fromUser]]></FromUserName>
    <CreateTime>1348831860</CreateTime>
    <MsgType><![CDATA[text]]></MsgType>
    <Content><![CDATA[this is a test]]></Content>
    <MsgId>1234567890123456</MsgId>
</xml>

通过对比我们发现使用moxy生成的数据首行多出了xml指令。它标记了文档版本和编码格式,经过相关验证,这个变动和微信对接没有影响,可以正常运行。而根节点“xml”中增加了“xmlns:xsi”“xsi:type”属性,在将这样的数据返回给微信回调时报错了,这两个多余的属性必须去掉才能正常工作。

尽管实体->XML没问题了,但接入微信联调时发现XML->实体又出现了状况:在确定了消息类型后,我们需要把XML字符串立即转换成明确的具体消息类型,例如TextMsg.class。然而由于TextMsg实体上没有@XmlRootElement(name=”xml”)根节点注解,转换失败,类似于使用原生JAXB的状况。设想一下,本来希望“根节点命名”这件事放在一处(CommonXML)来做,现在不得不每个子类上都标注上这头疼的家伙,将来维护起来是多么困难。

经过很多尝试都无果的情况下,JAXB方案我准备放弃了。为了方便大家研究,这部分代码我也上传到了我的代码仓库中,有兴趣的读者可以运行单元测试一试下,仓库地址:https://code.csdn.net/chaijunkun/wechat-common-draft

另辟蹊径的Jackson

说到Jackson很多人都体验过它的JSON处理能力,无论是从灵活性、性能还是文档健全度上都是值得在生产环境中使用的,本人也是其忠实拥趸。就在和JAXB纠结寻找其它途径时,得知Jackson的XML处理能力也很了得,于是立刻将代码进行改造,适配Jackson类似功能的注解:

import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlCData;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;

@JacksonXmlRootElement(localName="xml")
public class CommonXML {
    /** 接收方微信号 */
    @JacksonXmlProperty(localName = "ToUserName")
    @JacksonXmlCData
    private String toUserName;
    //后面的代码就不贴了,只是一些重命名操作和一些getters/setters
}

根据一些StackOverflow上的资料,整理出了一些必要的依赖:

<!-- json和xml的序列化与反序列化工具 -->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.5.2</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.dataformat</groupId>
    <artifactId>jackson-dataformat-xml</artifactId>
    <version>2.5.2</version>
</dependency>
<!-- 使用woodstox不仅比jdk提供的stax实现更快,且可以避免一些已知问题例如在根节点自动添加namespace -->
<dependency>
    <groupId>org.codehaus.woodstox</groupId>
    <artifactId>woodstox-core-asl</artifactId>
    <version>4.4.1</version>
</dependency>

使用Jackson框架处理实体与XML相互转换所担心的注入问题还可以通过转义单个特殊字符的形式来处理,特殊字符及其可用代替字符的对照表:

特殊字符 转义字符
& &amp;
< &lt;
> &gt;
&quot;
&apos;

因此只需要写一个特殊字符互转功能即可。代码逻辑很简单,感兴趣的读者可参阅项目中的XMLStringSerializerXMLStringDeserializer,之后在XMLUtil初始化时将它们注册到JacksonXmlModule实例中即可:

JacksonXmlModule module = new JacksonXmlModule();
module.setDefaultUseWrapper(false);
//序列化与反序列化时针对特殊字符自动转义的工具
module.addSerializer(String.class, new XMLStringSerializer());
module.addDeserializer(String.class, new XMLStringDeserializer());

同样的测试数据,我们看下使用Jackson后会生成什么样的结果:

<?xml version='1.0' encoding='UTF-8'?>
<xml>
    <ToUserName><![CDATA[jackson]]></ToUserName>
    <FromUserName><![CDATA[hawaii]]></FromUserName>
    <Content><![CDATA[jack&lt;xml val=&apos;Json&apos;&gt;]]&gt;]]></Content>
</xml>

可以很明显地看到,根节点不再有讨厌的多余属性了,CDATA注入测试也通过了,没有产生歧义。后续和微信联调时再也没遇到格式不正确的问题了。

对比性能

我们已经通过Jackson完成了实体与XML的相互转换功能,然而性能怎么样呢?不妨做个测试。分别做10,000次转换。
实体->XML转换的测试代码:

/** 计数器 */
private int counter = 10000;

@Test
public void doTest() throws JAXBException{
    TextMsg msg = new TextMsg();
    msg.setToUserName("jackson");
    msg.setFromUserName("hawaii");
    msg.setContent("jack<xml val='Json'>]]>");
    long start = System.currentTimeMillis();
    for(int i=0; i< counter; i++){
        ByteArrayOutputStream xmlOut = null;
        ByteArrayInputStream xmlIn = null;
        try{
            xmlOut = new ByteArrayOutputStream();
            //使用Jackson时,替换成XMLUtil及其配套注解的TextMsg对象
            XMLFactory.toXML(msg, xmlOut);
            String xml = new String(xmlOut.toByteArray());
        }finally{
            IOUtils.closeQuietly(xmlIn);
            IOUtils.closeQuietly(xmlOut);
        }
    }
    long end = System.currentTimeMillis();
    logger.info("耗时:{}", end - start);
}

XML->实体转换的测试代码:

/** 计数器 */
private int counter = 10000;

@Test
public void doTest() throws IOException, JAXBException{
    String xml = "<?xml version='1.0' encoding='UTF-8'?><xml><ToUserName><![CDATA[jackson]]></ToUserName><FromUserName><![CDATA[hawaii]]></FromUserName><Content><![CDATA[jack&lt;xml val=&apos;Json&apos;&gt;]]&gt;]]></Content></xml>";
    long start = System.currentTimeMillis();
    for(int i=0; i< counter; i++){
        ByteArrayOutputStream xmlOut = null;
        ByteArrayInputStream xmlIn = null;
        try{
            xmlOut = new ByteArrayOutputStream();
            //使用Jackson时,替换成XMLUtil及其配套注解的TextMsg对象
            TextMsg textMsg = XMLFactory.fromXML(xml, TextMsg.class);
        }finally{
            IOUtils.closeQuietly(xmlIn);
            IOUtils.closeQuietly(xmlOut);
        }
    }
    long end = System.currentTimeMillis();
    logger.info("耗时:{}", end - start);
}

截取的实验结果(单位:毫秒,3次实验取平均值)如下表所示:

转换方式 \ 转换方案 JAXB Jackson 时间占比
实体->XML 24716 1123 22:1
XML->实体 31622 1049 30:1

结果很明显:执行相同的任务,序列化方向Jackson用时只需JAXB的1/22,而反序列化方向优势更明显,只是JAXB方案的1/30的时间。

你可能感兴趣的:(java,框架,接口,微信,接入)