重要声明:本文章仅仅代表了作者个人对此观点的理解和表述。读者请查阅时持自己的意见进行讨论。
目录
本系列博文还包含了下面的博客:
- 【微信公众号开发】一、运作及配置流程简介
- 【微信公众号开发】二、解析微信请求及响应消息
- 【微信公众号开发】三、解析微信事件XML数据消息及响应](本文)
- 【微信公众号开发】四、公众号按钮设置及自己的微信按钮编辑器
- 【微信公众号开发】五、微信网页授权获取用户openId
- 【微信公众号开发】六、微信JS的使用
- 【微信公众号开发】七、微信JS需要注意的坑
- 【微信公众号开发】八、微信JS发起支付
完成了微信后台的配置后,就要开始真正的用户行为事件消息处理了。用户事件推送是以 POST
方式提交给配置的接口的。事件具体值内容在请求体里面,并且以XML方式传递的事件数据。那么本文就主要讲如何解析XML数据和具体事件响应。
本文内容是接上一篇博客【微信公众号开发】二、解析微信请求及响应消息,如果还没有阅读上一篇博客,请先阅读。
〇、代码优化
对于已经开发好的 config
方法,似乎内容已经变得十分臃肿,方法体看起来也非常的长,为了让程序可读性更高。在开始本文内容前,我先将 config
方法进行一些优化。
提取加密代码
这部分代码作用是将一个字符串进行sha1加密(其实不能这么讲,它应该称为对内容进行加签),后续可能还有更多的地方会遇到这个需求,所以我将这个代码提取到 Util
中:
将加密方法提取到 Util 类里后,Util 的完整代码如下:
public class Util {
// 将二进制数据转换为16进制字符串。
public static String byte2HexString(byte[] src) {
StringBuilder stringBuilder = new StringBuilder();
if (src == null || src.length <= 0) {
return null;
}
for (byte b : src) {
String hv = Integer.toHexString(b & 0xFF);
if (hv.length() < 2) {
stringBuilder.append(0);
}
stringBuilder.append(hv);
}
return stringBuilder.toString();
}
// 对字符串进行sha1加签
public static String sha1(String context) throws Exception {
// 获取sha1算法封装类
MessageDigest sha1Digest = MessageDigest.getInstance("SHA-1");
// 进行加密
byte[] digestResult = sha1Digest.digest(context.getBytes("UTF-8"));
// 转换为16进制字符串
return Util.byte2HexString(digestResult);
}
public static boolean isEmpty(String value) {
return value == null || value.length() == 0;
}
}
一、获取提交的XML数据内容
由于 xml 数据是在 请求体里以post方式提交上来的,所以不能像获取基本参数那样使用 request.getParameter()
方法去获取了,这部分数据在请求体里面,要获取这部分数据需要使用 request.getInputStream()
方法去获取到里面的内容。 而这个得到的是一个输入流,那么问题就变成了如何从输入流中读取其字符串,而这个需求也是一个可能会多次用到的需求,所以将该方法写到Util 类中,方便调用:
// 将输入流使用指定编码转化为字符串
public static String inputStream2String(InputStream inputStream, String charset) throws Exception {
// 建立输入流读取类
InputStreamReader reader = new InputStreamReader(inputStream, charset);
// 设定每次读取字符个数
char[] data = new char[512];
int dataSize = 0;
// 循环读取
StringBuilder stringBuilder = new StringBuilder();
while ((dataSize = reader.read(data)) != -1) {
stringBuilder.append(data, 0, dataSize);
}
return stringBuilder.toString();
}
现在,有了这个工具方法,在 config
方法中调用拿到xml字符串:
// 配置到微信后台的地址。 为了降低篇幅,部分已知代码以影藏。
@RequestMapping("/config")
@ResponseBody
public String config(HttpServletRequest request) throws Exception {
// 获取微信请求参数
// ...
// 参数排序。 token 就要换成自己实际写的token
// ...
// 拼接加签 并 判断是否正确
// ...
// 判断echostr不为空,表示这是配置时的请求。
// ...
// 拿到微信提交的内容。
String xml = Util.inputStream2String(request.getInputStream(), "UTF-8");
// 待解析 xml
return null;
}
拿到微信提交的xml字符串兴许还是非常简单的,但是接下来有该如何解析这个xml内容呢?下面将做详细介绍。
二、解析XML内容
无论是解析xml,还是解析json。我们都要先知道内容格式是什么样的,不防打印出来我们获取到的xml内容,看看这段内容里到底有些什么:
<xml>
<ToUserName><![CDATA[gh_c2********98]]></ToUserName>
<FromUserName><![CDATA[o-BAy0********diCIE]]></FromUserName>
<CreateTime>1132690210</CreateTime>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[VIEW]]></Event>
<EventKey><![CDATA[https://****.***.***/index.html]]></EventKey>
<MenuId>400000089</MenuId>
</xml>
这是截取自某次微信公众号里按钮点击跳转网页时,微信提交给后台的点击事件(部分内容已隐藏)。可以看到,整个xml内容体也是相对简单的,根节点一个 xml , 然后就是所有的节点+节点值。这样的xml字符串解析起来也是十分方便。
那么对于解析xml字符串,网络上或mvn库里也有大量的解析框架,诸如:XStream
,Castor
,TopLink
等,解析xml的方式也有DOM
、SAX
等方式。为了加深印象,我将采用Dom
方式解析xml内容,这也是java提供的api,不需要引入外部依赖。同时,此类需求也是未来可能多次遇到,故将解析xml方法放入Util 工具类中:
// 将 xml 文件解析为指定类型的实体对象。此方法只能解析简单的只有一层的xml
private static DocumentBuilderFactory documentBuilderFactory = null;
public static <T> T parseXml2Obj(String xml, Class<T> tclass) throws Exception {
if (isEmpty(xml)) throw new NullPointerException("要解析的xml字符串不能为空。");
if (documentBuilderFactory == null) { // 文档解析器工厂初始
documentBuilderFactory = DocumentBuilderFactory.newInstance();
}
// 拿到一个文档解析器。
DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
// 准备数据并解析。
byte[] bytes = xml.getBytes("UTF-8");
Document parsed = documentBuilder.parse(new ByteArrayInputStream(bytes));
// 获取数据
T obj = tclass.newInstance();
Element documentElement = parsed.getDocumentElement();
NodeList childNodes = documentElement.getChildNodes();
for (int i = 0; i < childNodes.getLength(); i++) {
Node item = childNodes.item(i);
// 节点类型是 ELEMENT 才读取值
// 进行此判断是因为如果xml不是一行,而是多行且有很好的格式的,就会产生一些文本的node,这些node内容只有换行符或空格
// 所以排除这些换行符和空格。
if (item.getNodeType() == Node.ELEMENT_NODE) {
String key = item.getNodeName();
String value = item.getTextContent();
// 拿到设置值的set方法。
Method declaredMethod = tclass.getDeclaredMethod("set" + key, String.class);
if (declaredMethod != null) {
declaredMethod.setAccessible(true);
declaredMethod.invoke(obj, value); // 设置值
}
}
}
return obj;
}
解析过程也非常简单,拿到解析器工厂类,新建解析器,筹备数据,开始解析,然后使用反射获取到类里对应字段的设值方法,执行方法设值值。注释也较为详尽,可以很容易理解。现在只需要定义好实体类就好了,我就命名这个类为WxMsg
吧。WxMsg
类的代码定义如下:
public class WxMsg {
private String toUserName; // 文本 图片 语音 视频 小视频 地理位置 链接 事件
private String fromUserName; // 文本 图片 语音 视频 小视频 地理位置 链接 事件
private String createTime; // 文本 图片 语音 视频 小视频 地理位置 链接 事件
private String msgType; // 文本 图片 语音 视频 小视频 地理位置 链接 事件
private String msgId; // 文本 图片 语音 视频 小视频 地理位置 链接
private String event; // 事件
private String eventKey; // 事件:扫描参数二维码、关注公众号、按钮点击
private String menuId; // 事件:按钮点击
private String content; // 文本
private String picUrl; // 图片
private String mediaId; // 图片 语音 视频 小视频
private String format; // 语音
private String recognition; // 语音
private String thumbMediaId; // 视频 小视频
private String location_X; // 地理位置
private String location_Y; // 地理位置
private String scale; // 地理位置
private String label; // 地理位置
private String title; // 链接
private String description; // 链接
private String url; // 链接
private String ticket; // 事件:扫描参数二维码、关注公众号
private String latitude; // 事件:上报地理位置
private String longitude; // 事件:上报地理位置
private String precision; // 事件:上报地理位置
public String getMsgId () { return msgId; }
public String getContent () { return content; }
public String getPicUrl () { return picUrl; }
public String getMediaId () { return mediaId; }
public String getFormat () { return format; }
public String getRecognition () { return recognition; }
public String getThumbMediaId () { return thumbMediaId; }
public String getLocation_X () { return location_X; }
public String getLocation_Y () { return location_Y; }
public String getScale () { return scale; }
public String getLabel () { return label; }
public String getTitle () { return title; }
public String getDescription () { return description; }
public String getUrl () { return url; }
public String getTicket () { return ticket; }
public String getLatitude () { return latitude; }
public String getLongitude () { return longitude; }
public String getPrecision () { return precision; }
public String getToUserName () { return toUserName; }
public String getFromUserName () { return fromUserName; }
public String getCreateTime () { return createTime; }
public String getMsgType () { return msgType; }
public String getEvent () { return event; }
public String getEventKey () { return eventKey; }
public String getMenuId () { return menuId; }
public void setToUserName (String toUserName) { this.toUserName = toUserName; }
public void setFromUserName (String fromUserName) { this.fromUserName = fromUserName; }
public void setCreateTime (String createTime) { this.createTime = createTime; }
public void setMsgType (String msgType) { this.msgType = msgType; }
public void setEvent (String event) { this.event = event; }
public void setEventKey (String eventKey) { this.eventKey = eventKey; }
public void setMenuId (String menuId) { this.menuId = menuId; }
public void setPrecision (String precision) { this.precision = precision; }
public void setLongitude (String longitude) { this.longitude = longitude; }
public void setLatitude (String latitude) { this.latitude = latitude; }
public void setTicket (String ticket) { this.ticket = ticket; }
public void setUrl (String url) { this.url = url; }
public void setDescription (String description) { this.description = description; }
public void setTitle (String title) { this.title = title; }
public void setLabel (String label) { this.label = label; }
public void setScale (String scale) { this.scale = scale; }
public void setLocation_Y (String location_Y) { this.location_Y = location_Y; }
public void setLocation_X (String location_X) { this.location_X = location_X; }
public void setThumbMediaId (String thumbMediaId) { this.thumbMediaId = thumbMediaId; }
public void setRecognition (String recognition) { this.recognition = recognition; }
public void setFormat (String format) { this.format = format; }
public void setMediaId (String mediaId) { this.mediaId = mediaId; }
public void setPicUrl (String picUrl) { this.picUrl = picUrl; }
public void setContent (String content) { this.content = content; }
public void setMsgId (String msgId) { this.msgId = msgId; }
}
上面的实体内容看起来比示列的xml内容好像要多很多,那是因为我将大部分(其实还有一些不常用字段)常见的消息可能出现的字段写在了实体里,并且写了详细的备注,哪些字段会出现在那些消息类型中,都在注释里写的非常详细。现在,终于可以继续 config
方法的编写,在方法结尾处添加解析代码:
// 拼接加签 并 判断是否正确
// ...
// 判断echostr不为空,表示这是配置时的请求。
// ...
// 拿到微信提交的内容。
String xml = Util.inputStream2String(request.getInputStream(), "UTF-8");
// 解析xml
WxMsg wxmsg = Util.parseXml2Obj(xml, WxMsg.class);
wxmsg
对象即为后续将使用的微信消息实体对象了,有了它,后面一切的开发都将变得方便许多。不过到目前为止,都没有详细的介绍过里面各个字段的作用是什么,所以不如先介绍一下这些字段都有啥作用。下面截取了部分微信官方文档的描述文案,同时我对其进行了扩充:
参数 | 描述 | ||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
ToUserName | 消息发送给谁的,对于后台接收来说,这个值永远都是公众号的微信号 | ||||||||||||||||||
FromUserName | 这是来自用户的openid | ||||||||||||||||||
CreateTime | 消息创建时间 (整型),这是一个精确到秒的时间戳,要×1000才是毫秒 | ||||||||||||||||||
MsgType |
| ||||||||||||||||||
MsgId | 消息id | ||||||||||||||||||
Event |
| ||||||||||||||||||
EventKey | 事件关键值 | ||||||||||||||||||
MenuId | 菜单按钮ID,这个基本没得啥用 | ||||||||||||||||||
Content | 文本消息内容 | ||||||||||||||||||
PicUrl | 图片消息的图片链接(由系统生成) | ||||||||||||||||||
MediaId | 消息媒体id,可以调用获取临时素材接口拉取数据。 | ||||||||||||||||||
Format | 语音消息格式,如amr,speex等 | ||||||||||||||||||
Recognition | 语音识别结果,UTF8编码 | ||||||||||||||||||
ThumbMediaId | 视频消息缩略图的媒体id,可以调用多媒体文件下载接口拉取数据。 | ||||||||||||||||||
Location_X | 地理位置纬度 | ||||||||||||||||||
Location_Y | 地理位置经度 | ||||||||||||||||||
Scale | 地图缩放大小 | ||||||||||||||||||
Label | 地理位置信息 | ||||||||||||||||||
Title | 链接消息标题 | ||||||||||||||||||
Description | 链接消息描述 | ||||||||||||||||||
Url | 链接消息链接 | ||||||||||||||||||
Ticket | 事件,二维码的ticket,可用来换取二维码图片 | ||||||||||||||||||
Latitude | 事件,上报地理位置,地理位置纬度 | ||||||||||||||||||
Longitude | 事件,上报地理位置,地理位置经度 | ||||||||||||||||||
Precision | 事件,上报地理位置,地理位置精度 |
基本的了解了微信消息过后,不难发现,我们要处理的其实就是不同的消息或事件。
三、响应微信消息或事件
现在就先简单的处理一下普通文本消息吧,不管用户向我们出了什么样内容的文本消息,我们都回复:然后呢。让用户以为真的有人在回复(实际开发中根据自己业务处理)。首先判断消息的类型:
// ...
// 拿到微信提交的内容。
String xml = Util.inputStream2String(request.getInputStream(), "UTF-8");
// 解析xml
WxMsg wxmsg = Util.parseXml2Obj(xml, WxMsg.class);
// 判断出来是文本消息
if ("text".equalsIgnoreCase(wxmsg.getMsgType())) {
return "然后呢。";
}
// ...
代码中判断了消息类型为文本消息,然后直接返回内容。实际上这样处理是错误的。
我们必须同样的也返回标准的xml格式去回复,一个标准的文本消息回复报文看起来是这样的:
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>12345678</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[你好]]></Content>
</xml>
发现这个xml数据的格式和微信提交的格式几乎一致,并且我们已经完善了对应的java实体类,不如我就直接使用建立好的java类:
// 判断出来是文本消息
if ("text".equalsIgnoreCase(wxmsg.getMsgType())) {
WxMsg wxMsgResponse = new WxMsg();
wxMsgResponse.setToUserName(wxmsg.getFromUserName());
wxMsgResponse.setFromUserName(wxmsg.getToUserName());
wxMsgResponse.setContent("然后呢。");
wxMsgResponse.setMsgType("text");
wxMsgResponse.setCreateTime(String.valueOf(System.currentTimeMillis() / 1000));
return null;
}
当建立好响应消息后,发现我们应该响应字符串的xml数据,现在又要将这个java对象转换为字符串的xml数据,不过此操作要比解析简单,所以我打算直接使用反射+字符串拼接。考虑到此需求以后会多次遇见,故将此方法写在Util 工具类中:
// 将对象转化为xml字符串。 rootName 为根节点名称
public static String obj2Xml(Object obj, String rootName) throws Exception {
// 拿到class 并获取其所有的方法
Class<?> aClass = obj.getClass();
Method[] declaredMethods = aClass.getDeclaredMethods();
// 建立xml。
StringBuilder xmlBuilder = new StringBuilder();
xmlBuilder.append("<").append(rootName).append(">");
// 遍历方法提取值。
for (Method m : declaredMethods) {
m.setAccessible(true);
// 要获取值,只需要获取 get 开头的方法。
String methodName = m.getName();
if (methodName.startsWith("get")) {
// 获取到值。
Object value = m.invoke(obj);
// 拿到key
String key = methodName.replace("get", "").trim();
// 拼接值
xmlBuilder
.append("<").append(key).append("><![CDATA[")
.append(value)
.append("]]></").append(key).append(">");
}
}
// 尾节点
xmlBuilder.append("</").append(rootName).append(">");
return xmlBuilder.toString();
}
当然不得不承认这样的处理方式存在诸多的问题,所以读者在对xml的序列化和反序列化处理时,使用比较热门的框架来处理。
现在进行返回值的最后一步处理:
// ...
// 判断出来是文本消息
if ("text".equalsIgnoreCase(wxmsg.getMsgType())) {
WxMsg wxMsgResponse = new WxMsg();
wxMsgResponse.setToUserName(wxmsg.getFromUserName());
wxMsgResponse.setFromUserName(wxmsg.getToUserName());
wxMsgResponse.setContent("然后呢。");
wxMsgResponse.setMsgType("text");
wxMsgResponse.setCreateTime(String.valueOf(System.currentTimeMillis() / 1000));
// 生成 xml 并返回。
return Util.obj2Xml(wxMsgResponse, "xml");
}
// ...
这样,当用户发送文本消息到公众号里时,都会收到[然后呢。]的回复信息。
但有时候的用户行为我们是不需要关心的,这时如何给微信响应呢? 如果不处理微信的事件或消息,你可以直接返回空字符串,或者返回 success
字符串,但是不能什么都不返回,如果什么都不返回,微信会给用户提示此公众号出现问题,这样就不好了嘛。
最终,现在的 config
方法全部代码如下:
// 配置到微信后台的地址。
@RequestMapping("/config")
@ResponseBody
public String config(HttpServletRequest request) throws Exception {
// 获取微信请求参数
String signature = request.getParameter("signature");
String timestamp = request.getParameter("timestamp");
String nonce = request.getParameter("nonce");
String echostr = request.getParameter("echostr");
// 参数排序。 token 就要换成自己实际写的token
String[] params = new String[] {timestamp, nonce, "token"};
Arrays.sort(params);
// 拼接加签 并 判断是否正确
boolean signSuccess = Util.sha1(params[0] + params[1] + params[2]).equals(signature);
if (!signSuccess) {
return "signature check fail"; // 不正确就直接返回失败提示。
}
// 判断echostr不为空,表示这是配置时的请求。
if(!Util.isEmpty(echostr)) {
return echostr; // 正确,返回传入的随机字符串。
}
// 拿到微信提交的内容。
String xml = Util.inputStream2String(request.getInputStream(), "UTF-8");
// 解析xml
WxMsg wxmsg = Util.parseXml2Obj(xml, WxMsg.class);
// 判断出来是文本消息
if ("text".equalsIgnoreCase(wxmsg.getMsgType())) {
WxMsg wxMsgResponse = new WxMsg();
wxMsgResponse.setToUserName(wxmsg.getFromUserName());
wxMsgResponse.setFromUserName(wxmsg.getToUserName());
wxMsgResponse.setContent("然后呢。");
wxMsgResponse.setMsgType("text");
wxMsgResponse.setCreateTime(String.valueOf(System.currentTimeMillis() / 1000));
return Util.obj2Xml(wxMsgResponse, "xml");
}
// 什么都不处理,返回success或空字符串。
return "success";
}
四、后续
每一个事件、每一个消息都完整的处理好的话, config
方法中必然会产生大量的代码,这样下来变得可读性大大降低,维护也十分困难,所以必须要有一个优雅的方式将这些不同的消息或事件分发给不同的处理对象去处理。这就十分考验一个程序员的代码组织能力和对面向对象的理解深度了。